mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-08 18:10:27 +01:00
Compare commits
35 Commits
mute-rules
...
feat/dashb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ef5c8a890 | ||
|
|
ab27d6661e | ||
|
|
a8858c61bc | ||
|
|
f00153d416 | ||
|
|
ee62669a89 | ||
|
|
7a380537c5 | ||
|
|
28e118cd84 | ||
|
|
39f9928b58 | ||
|
|
08025871ed | ||
|
|
b187c8003d | ||
|
|
f12164fb79 | ||
|
|
549e49202d | ||
|
|
d6c156b5a5 | ||
|
|
8b6391dd6f | ||
|
|
eb2b348f42 | ||
|
|
8b512dee5d | ||
|
|
c72e01b25b | ||
|
|
85144564ac | ||
|
|
b647f045a8 | ||
|
|
d464018668 | ||
|
|
77e8de5b2d | ||
|
|
6af04f14da | ||
|
|
1285880d74 | ||
|
|
c4d6701b4a | ||
|
|
dcb0a47837 | ||
|
|
673328f8a6 | ||
|
|
878ac72ca4 | ||
|
|
b370251665 | ||
|
|
9003a6850d | ||
|
|
312010eeee | ||
|
|
1eae73b455 | ||
|
|
29278fb45b | ||
|
|
3a034bc471 | ||
|
|
1f406823d8 | ||
|
|
f626380b1a |
6
deploy/docker/docker-compose.override.yaml
Normal file
6
deploy/docker/docker-compose.override.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
services:
|
||||
signoz:
|
||||
environment:
|
||||
# Enable dashboard v2 (maps to flagger.config.boolean.use_dashboard_v2: true)
|
||||
# Double underscore is the key separator; single underscore stays part of the key.
|
||||
- SIGNOZ_FLAGGER__CONFIG__BOOLEAN__USE_DASHBOARD_V2=true
|
||||
@@ -5945,22 +5945,6 @@ components:
|
||||
- start
|
||||
- end
|
||||
type: object
|
||||
RuletypesActiveMute:
|
||||
properties:
|
||||
description:
|
||||
type: string
|
||||
end:
|
||||
format: date-time
|
||||
nullable: true
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
start:
|
||||
format: date-time
|
||||
type: string
|
||||
type: object
|
||||
RuletypesAlertCompositeQuery:
|
||||
properties:
|
||||
panelType:
|
||||
@@ -6242,11 +6226,6 @@ components:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
mutes:
|
||||
items:
|
||||
$ref: '#/components/schemas/RuletypesActiveMute'
|
||||
nullable: true
|
||||
type: array
|
||||
notificationSettings:
|
||||
$ref: '#/components/schemas/RuletypesNotificationSettings'
|
||||
preferredChannels:
|
||||
|
||||
@@ -21,6 +21,7 @@ import type {
|
||||
CreateDashboardV2201,
|
||||
CreatePublicDashboard201,
|
||||
CreatePublicDashboardPathParameters,
|
||||
DashboardtypesJSONPatchDocumentDTO,
|
||||
DashboardtypesPostableDashboardV2DTO,
|
||||
DashboardtypesPostablePublicDashboardDTO,
|
||||
DashboardtypesUpdatablePublicDashboardDTO,
|
||||
@@ -33,7 +34,17 @@ import type {
|
||||
GetPublicDashboardPathParameters,
|
||||
GetPublicDashboardWidgetQueryRange200,
|
||||
GetPublicDashboardWidgetQueryRangePathParameters,
|
||||
ListDashboardsV2200,
|
||||
ListDashboardsV2Params,
|
||||
LockDashboardV2PathParameters,
|
||||
PatchDashboardV2200,
|
||||
PatchDashboardV2PathParameters,
|
||||
PinDashboardV2PathParameters,
|
||||
RenderErrorResponseDTO,
|
||||
UnlockDashboardV2PathParameters,
|
||||
UnpinDashboardV2PathParameters,
|
||||
UpdateDashboardV2200,
|
||||
UpdateDashboardV2PathParameters,
|
||||
UpdatePublicDashboardPathParameters,
|
||||
} from '../sigNoz.schemas';
|
||||
|
||||
@@ -633,6 +644,103 @@ export const invalidateGetPublicDashboardWidgetQueryRange = async (
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a page of v2-shape dashboards for the calling user's org. Supports a filter DSL (`query`), sort (`updated_at`/`created_at`/`title`), order (`asc`/`desc`), and offset-based pagination (`limit`/`offset`). Pinned dashboards float to the top of each page.
|
||||
* @summary List dashboards (v2)
|
||||
*/
|
||||
export const listDashboardsV2 = (
|
||||
params?: ListDashboardsV2Params,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<ListDashboardsV2200>({
|
||||
url: `/api/v2/dashboards`,
|
||||
method: 'GET',
|
||||
params,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getListDashboardsV2QueryKey = (
|
||||
params?: ListDashboardsV2Params,
|
||||
) => {
|
||||
return [`/api/v2/dashboards`, ...(params ? [params] : [])] as const;
|
||||
};
|
||||
|
||||
export const getListDashboardsV2QueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof listDashboardsV2>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
params?: ListDashboardsV2Params,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listDashboardsV2>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getListDashboardsV2QueryKey(params);
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof listDashboardsV2>>> = ({
|
||||
signal,
|
||||
}) => listDashboardsV2(params, signal);
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listDashboardsV2>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type ListDashboardsV2QueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof listDashboardsV2>>
|
||||
>;
|
||||
export type ListDashboardsV2QueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary List dashboards (v2)
|
||||
*/
|
||||
|
||||
export function useListDashboardsV2<
|
||||
TData = Awaited<ReturnType<typeof listDashboardsV2>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
params?: ListDashboardsV2Params,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listDashboardsV2>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getListDashboardsV2QueryOptions(params, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary List dashboards (v2)
|
||||
*/
|
||||
export const invalidateListDashboardsV2 = async (
|
||||
queryClient: QueryClient,
|
||||
params?: ListDashboardsV2Params,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getListDashboardsV2QueryKey(params) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint creates a dashboard in the v2 format that follows Perses spec.
|
||||
* @summary Create dashboard (v2)
|
||||
@@ -816,3 +924,518 @@ export const invalidateGetDashboardV2 = async (
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint applies an RFC 6902 JSON Patch to a v2-shape dashboard. The patch is applied against the postable view of the dashboard (metadata, data, tags), so individual panels, queries, variables, layouts, or tags can be updated without re-sending the rest of the dashboard. Locked dashboards are rejected.
|
||||
* @summary Patch dashboard (v2)
|
||||
*/
|
||||
export const patchDashboardV2 = (
|
||||
{ id }: PatchDashboardV2PathParameters,
|
||||
dashboardtypesJSONPatchDocumentDTONull?: BodyType<DashboardtypesJSONPatchDocumentDTO | null> | null,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<PatchDashboardV2200>({
|
||||
url: `/api/v2/dashboards/${id}`,
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: dashboardtypesJSONPatchDocumentDTONull,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getPatchDashboardV2MutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof patchDashboardV2>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: PatchDashboardV2PathParameters;
|
||||
data?: BodyType<DashboardtypesJSONPatchDocumentDTO | null>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof patchDashboardV2>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: PatchDashboardV2PathParameters;
|
||||
data?: BodyType<DashboardtypesJSONPatchDocumentDTO | null>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['patchDashboardV2'];
|
||||
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 patchDashboardV2>>,
|
||||
{
|
||||
pathParams: PatchDashboardV2PathParameters;
|
||||
data?: BodyType<DashboardtypesJSONPatchDocumentDTO | null>;
|
||||
}
|
||||
> = (props) => {
|
||||
const { pathParams, data } = props ?? {};
|
||||
|
||||
return patchDashboardV2(pathParams, data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type PatchDashboardV2MutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof patchDashboardV2>>
|
||||
>;
|
||||
export type PatchDashboardV2MutationBody =
|
||||
| BodyType<DashboardtypesJSONPatchDocumentDTO | null>
|
||||
| undefined;
|
||||
export type PatchDashboardV2MutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Patch dashboard (v2)
|
||||
*/
|
||||
export const usePatchDashboardV2 = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof patchDashboardV2>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: PatchDashboardV2PathParameters;
|
||||
data?: BodyType<DashboardtypesJSONPatchDocumentDTO | null>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof patchDashboardV2>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: PatchDashboardV2PathParameters;
|
||||
data?: BodyType<DashboardtypesJSONPatchDocumentDTO | null>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getPatchDashboardV2MutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint updates a v2-shape dashboard's metadata, data, and tag set. Locked dashboards are rejected.
|
||||
* @summary Update dashboard (v2)
|
||||
*/
|
||||
export const updateDashboardV2 = (
|
||||
{ id }: UpdateDashboardV2PathParameters,
|
||||
dashboardtypesPostableDashboardV2DTO?: BodyType<DashboardtypesPostableDashboardV2DTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<UpdateDashboardV2200>({
|
||||
url: `/api/v2/dashboards/${id}`,
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: dashboardtypesPostableDashboardV2DTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getUpdateDashboardV2MutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateDashboardV2>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateDashboardV2PathParameters;
|
||||
data?: BodyType<DashboardtypesPostableDashboardV2DTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateDashboardV2>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateDashboardV2PathParameters;
|
||||
data?: BodyType<DashboardtypesPostableDashboardV2DTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['updateDashboardV2'];
|
||||
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 updateDashboardV2>>,
|
||||
{
|
||||
pathParams: UpdateDashboardV2PathParameters;
|
||||
data?: BodyType<DashboardtypesPostableDashboardV2DTO>;
|
||||
}
|
||||
> = (props) => {
|
||||
const { pathParams, data } = props ?? {};
|
||||
|
||||
return updateDashboardV2(pathParams, data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type UpdateDashboardV2MutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof updateDashboardV2>>
|
||||
>;
|
||||
export type UpdateDashboardV2MutationBody =
|
||||
| BodyType<DashboardtypesPostableDashboardV2DTO>
|
||||
| undefined;
|
||||
export type UpdateDashboardV2MutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Update dashboard (v2)
|
||||
*/
|
||||
export const useUpdateDashboardV2 = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateDashboardV2>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateDashboardV2PathParameters;
|
||||
data?: BodyType<DashboardtypesPostableDashboardV2DTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof updateDashboardV2>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateDashboardV2PathParameters;
|
||||
data?: BodyType<DashboardtypesPostableDashboardV2DTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getUpdateDashboardV2MutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint unlocks a v2-shape dashboard. Only the dashboard's creator or an org admin may lock or unlock.
|
||||
* @summary Unlock dashboard (v2)
|
||||
*/
|
||||
export const unlockDashboardV2 = (
|
||||
{ id }: UnlockDashboardV2PathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<string>({
|
||||
url: `/api/v2/dashboards/${id}/lock`,
|
||||
method: 'DELETE',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getUnlockDashboardV2MutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof unlockDashboardV2>>,
|
||||
TError,
|
||||
{ pathParams: UnlockDashboardV2PathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof unlockDashboardV2>>,
|
||||
TError,
|
||||
{ pathParams: UnlockDashboardV2PathParameters },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['unlockDashboardV2'];
|
||||
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 unlockDashboardV2>>,
|
||||
{ pathParams: UnlockDashboardV2PathParameters }
|
||||
> = (props) => {
|
||||
const { pathParams } = props ?? {};
|
||||
|
||||
return unlockDashboardV2(pathParams);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type UnlockDashboardV2MutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof unlockDashboardV2>>
|
||||
>;
|
||||
|
||||
export type UnlockDashboardV2MutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Unlock dashboard (v2)
|
||||
*/
|
||||
export const useUnlockDashboardV2 = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof unlockDashboardV2>>,
|
||||
TError,
|
||||
{ pathParams: UnlockDashboardV2PathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof unlockDashboardV2>>,
|
||||
TError,
|
||||
{ pathParams: UnlockDashboardV2PathParameters },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getUnlockDashboardV2MutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint locks a v2-shape dashboard. Only the dashboard's creator or an org admin may lock or unlock.
|
||||
* @summary Lock dashboard (v2)
|
||||
*/
|
||||
export const lockDashboardV2 = (
|
||||
{ id }: LockDashboardV2PathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<string>({
|
||||
url: `/api/v2/dashboards/${id}/lock`,
|
||||
method: 'PUT',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getLockDashboardV2MutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof lockDashboardV2>>,
|
||||
TError,
|
||||
{ pathParams: LockDashboardV2PathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof lockDashboardV2>>,
|
||||
TError,
|
||||
{ pathParams: LockDashboardV2PathParameters },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['lockDashboardV2'];
|
||||
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 lockDashboardV2>>,
|
||||
{ pathParams: LockDashboardV2PathParameters }
|
||||
> = (props) => {
|
||||
const { pathParams } = props ?? {};
|
||||
|
||||
return lockDashboardV2(pathParams);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type LockDashboardV2MutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof lockDashboardV2>>
|
||||
>;
|
||||
|
||||
export type LockDashboardV2MutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Lock dashboard (v2)
|
||||
*/
|
||||
export const useLockDashboardV2 = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof lockDashboardV2>>,
|
||||
TError,
|
||||
{ pathParams: LockDashboardV2PathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof lockDashboardV2>>,
|
||||
TError,
|
||||
{ pathParams: LockDashboardV2PathParameters },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getLockDashboardV2MutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Removes the pin for the calling user. Idempotent — unpinning a dashboard that wasn't pinned still returns 204.
|
||||
* @summary Unpin a dashboard for the current user (v2)
|
||||
*/
|
||||
export const unpinDashboardV2 = (
|
||||
{ id }: UnpinDashboardV2PathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<string>({
|
||||
url: `/api/v2/dashboards/${id}/pins/me`,
|
||||
method: 'DELETE',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getUnpinDashboardV2MutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof unpinDashboardV2>>,
|
||||
TError,
|
||||
{ pathParams: UnpinDashboardV2PathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof unpinDashboardV2>>,
|
||||
TError,
|
||||
{ pathParams: UnpinDashboardV2PathParameters },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['unpinDashboardV2'];
|
||||
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 unpinDashboardV2>>,
|
||||
{ pathParams: UnpinDashboardV2PathParameters }
|
||||
> = (props) => {
|
||||
const { pathParams } = props ?? {};
|
||||
|
||||
return unpinDashboardV2(pathParams);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type UnpinDashboardV2MutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof unpinDashboardV2>>
|
||||
>;
|
||||
|
||||
export type UnpinDashboardV2MutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Unpin a dashboard for the current user (v2)
|
||||
*/
|
||||
export const useUnpinDashboardV2 = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof unpinDashboardV2>>,
|
||||
TError,
|
||||
{ pathParams: UnpinDashboardV2PathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof unpinDashboardV2>>,
|
||||
TError,
|
||||
{ pathParams: UnpinDashboardV2PathParameters },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getUnpinDashboardV2MutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Pins the dashboard for the calling user. A user can pin at most 10 dashboards; pinning when at the limit returns 409. Re-pinning an already-pinned dashboard is a no-op success.
|
||||
* @summary Pin a dashboard for the current user (v2)
|
||||
*/
|
||||
export const pinDashboardV2 = (
|
||||
{ id }: PinDashboardV2PathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<string>({
|
||||
url: `/api/v2/dashboards/${id}/pins/me`,
|
||||
method: 'PUT',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getPinDashboardV2MutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof pinDashboardV2>>,
|
||||
TError,
|
||||
{ pathParams: PinDashboardV2PathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof pinDashboardV2>>,
|
||||
TError,
|
||||
{ pathParams: PinDashboardV2PathParameters },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['pinDashboardV2'];
|
||||
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 pinDashboardV2>>,
|
||||
{ pathParams: PinDashboardV2PathParameters }
|
||||
> = (props) => {
|
||||
const { pathParams } = props ?? {};
|
||||
|
||||
return pinDashboardV2(pathParams);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type PinDashboardV2MutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof pinDashboardV2>>
|
||||
>;
|
||||
|
||||
export type PinDashboardV2MutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Pin a dashboard for the current user (v2)
|
||||
*/
|
||||
export const usePinDashboardV2 = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof pinDashboardV2>>,
|
||||
TError,
|
||||
{ pathParams: PinDashboardV2PathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof pinDashboardV2>>,
|
||||
TError,
|
||||
{ pathParams: PinDashboardV2PathParameters },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getPinDashboardV2MutationOptions(options));
|
||||
};
|
||||
|
||||
@@ -4616,6 +4616,61 @@ export interface DashboardtypesGettableDashboardV2DTO {
|
||||
updatedBy?: string;
|
||||
}
|
||||
|
||||
export interface DashboardtypesGettableDashboardWithPinDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
createdBy?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
image?: string;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
locked: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
orgId: string;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
pinned?: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
schemaVersion: string;
|
||||
source: DashboardtypesSourceDTO;
|
||||
spec: DashboardtypesDashboardSpecDTO;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
tags: TagtypesPostableTagDTO[] | null;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
updatedBy?: string;
|
||||
}
|
||||
|
||||
export interface DashboardtypesGettablePublicDasbhboardDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -4636,6 +4691,55 @@ export interface DashboardtypesGettablePublicDashboardDataDTO {
|
||||
publicDashboard?: DashboardtypesGettablePublicDasbhboardDTO;
|
||||
}
|
||||
|
||||
export enum DashboardtypesJSONPatchOperationDTOOp {
|
||||
add = 'add',
|
||||
remove = 'remove',
|
||||
replace = 'replace',
|
||||
move = 'move',
|
||||
copy = 'copy',
|
||||
test = 'test',
|
||||
}
|
||||
export interface DashboardtypesJSONPatchOperationDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @description Source JSON Pointer for move/copy ops; ignored for other ops.
|
||||
*/
|
||||
from?: string;
|
||||
/**
|
||||
* @enum add,remove,replace,move,copy,test
|
||||
* @type string
|
||||
*/
|
||||
op: DashboardtypesJSONPatchOperationDTOOp;
|
||||
/**
|
||||
* @type string
|
||||
* @description JSON Pointer (RFC 6901) into the dashboard's postable shape — e.g. /data/display/name, /data/panels/<id>, /data/panels/<id>/spec/queries/0, /tags/-.
|
||||
*/
|
||||
path: string;
|
||||
/**
|
||||
* @description Value to add/replace/test against. The expected type depends on the path. Common shapes (see referenced schemas for the exact field set): /data/panels/<id> takes a DashboardtypesPanel; /data/panels/<id>/spec/queries/N (or /-) takes a DashboardtypesQuery; /data/variables/N takes a DashboardtypesVariable; /data/layouts/N takes a DashboardtypesLayout; /tags/N (or /-) takes a TagtypesPostableTag; /data/display/name and other leaf string fields take a string. Required for add/replace/test; ignored for remove/move/copy.
|
||||
*/
|
||||
value?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type DashboardtypesJSONPatchDocumentDTO =
|
||||
| DashboardtypesJSONPatchOperationDTO[]
|
||||
| null;
|
||||
|
||||
export interface DashboardtypesListableDashboardV2DTO {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
dashboards: DashboardtypesGettableDashboardWithPinDTO[];
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
total: number;
|
||||
}
|
||||
|
||||
export enum DashboardtypesPanelPluginKindDTO {
|
||||
'signoz/TimeSeriesPanel' = 'signoz/TimeSeriesPanel',
|
||||
'signoz/BarChartPanel' = 'signoz/BarChartPanel',
|
||||
@@ -7051,31 +7155,6 @@ export interface RulestatehistorytypesGettableRuleStateWindowDTO {
|
||||
state: RuletypesAlertStateDTO;
|
||||
}
|
||||
|
||||
export interface RuletypesActiveMuteDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* @type string,null
|
||||
* @format date-time
|
||||
*/
|
||||
end?: string | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
start?: string;
|
||||
}
|
||||
|
||||
export enum RuletypesPanelTypeDTO {
|
||||
value = 'value',
|
||||
table = 'table',
|
||||
@@ -7444,10 +7523,6 @@ export interface RuletypesRuleDTO {
|
||||
* @type object
|
||||
*/
|
||||
labels?: RuletypesRuleDTOLabels;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
mutes?: RuletypesActiveMuteDTO[] | null;
|
||||
notificationSettings?: RuletypesNotificationSettingsDTO;
|
||||
/**
|
||||
* @type array
|
||||
@@ -9418,6 +9493,42 @@ export type GetUserPreference200 = {
|
||||
export type UpdateUserPreferencePathParameters = {
|
||||
name: string;
|
||||
};
|
||||
export type ListDashboardsV2Params = {
|
||||
/**
|
||||
* @type string
|
||||
* @description undefined
|
||||
*/
|
||||
query?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description undefined
|
||||
*/
|
||||
sort?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description undefined
|
||||
*/
|
||||
order?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @description undefined
|
||||
*/
|
||||
limit?: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @description undefined
|
||||
*/
|
||||
offset?: number;
|
||||
};
|
||||
|
||||
export type ListDashboardsV2200 = {
|
||||
data: DashboardtypesListableDashboardV2DTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type CreateDashboardV2201 = {
|
||||
data: DashboardtypesGettableDashboardV2DTO;
|
||||
/**
|
||||
@@ -9437,6 +9548,40 @@ export type GetDashboardV2200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type PatchDashboardV2PathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type PatchDashboardV2200 = {
|
||||
data: DashboardtypesGettableDashboardV2DTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type UpdateDashboardV2PathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type UpdateDashboardV2200 = {
|
||||
data: DashboardtypesGettableDashboardV2DTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type UnlockDashboardV2PathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type LockDashboardV2PathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type UnpinDashboardV2PathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type PinDashboardV2PathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type GetFeatures200 = {
|
||||
/**
|
||||
* @type array
|
||||
|
||||
@@ -41,14 +41,22 @@ $item-spacing: 8px;
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
outline: none;
|
||||
height: auto;
|
||||
color: var(--l1-foreground);
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
padding: 0;
|
||||
&.ant-input:focus {
|
||||
|
||||
&:focus,
|
||||
&:focus-visible,
|
||||
&:hover {
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
useState,
|
||||
} from 'react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Input } from 'antd';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import cx from 'classnames';
|
||||
import { TimezonePickerShortcuts } from 'constants/shortcuts/TimezonePickerShortcuts';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card, Form, Input } from 'antd';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Card, Form } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { Button, Input } from 'antd';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Button } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
import { X } from '@signozhq/icons';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Button, Input, InputNumber, Popover, Tooltip } from 'antd';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Button, InputNumber, Popover, Tooltip } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import type { DefaultOptionType } from 'antd/es/select';
|
||||
import cx from 'classnames';
|
||||
|
||||
@@ -266,6 +266,14 @@
|
||||
border-left: transparent;
|
||||
border-top-left-radius: 0px;
|
||||
border-bottom-left-radius: 0px;
|
||||
|
||||
&:focus:not(:focus-visible),
|
||||
&.ant-btn:focus:not(:focus-visible) {
|
||||
border-color: var(--l2-border);
|
||||
border-left-color: transparent;
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -291,5 +299,21 @@
|
||||
.cm-placeholder {
|
||||
font-size: 12px !important;
|
||||
}
|
||||
|
||||
$add-on-row-height: 38px;
|
||||
|
||||
.periscope-input-with-label {
|
||||
.input {
|
||||
.ant-select {
|
||||
height: $add-on-row-height;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.input-with-label {
|
||||
.input {
|
||||
height: $add-on-row-height;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,23 @@
|
||||
padding: 12px;
|
||||
gap: 12px;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
|
||||
.search {
|
||||
input {
|
||||
--input-background: var(--l2-background);
|
||||
--input-hover-background: var(--l2-background);
|
||||
--input-focus-background: var(--l2-background);
|
||||
&::placeholder {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
--input-font-size: 14px;
|
||||
--input-border-color: var(--l1-border);
|
||||
--input-focus-border-color: var(--primary-background);
|
||||
--input-focus-outline-width: 0;
|
||||
--input-focus-outline-offset: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-header-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/* eslint-disable sonarjs/no-identical-functions */
|
||||
import { Fragment, useMemo, useState } from 'react';
|
||||
import { Button, Input, Skeleton } from 'antd';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Button, Skeleton } from 'antd';
|
||||
import { Checkbox } from '@signozhq/ui/checkbox';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Button, Input } from 'antd';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Button } from 'antd';
|
||||
import { Check, TableColumnsSplit, X } from '@signozhq/icons';
|
||||
import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';
|
||||
|
||||
|
||||
@@ -42,4 +42,5 @@ export enum LOCALSTORAGE {
|
||||
LICENSE_KEY_CALLOUT_DISMISSED = 'LICENSE_KEY_CALLOUT_DISMISSED',
|
||||
DASHBOARD_PREFERENCES = 'DASHBOARD_PREFERENCES',
|
||||
ACTIVE_SIGNOZ_INSTANCE_URL = 'ACTIVE_SIGNOZ_INSTANCE_URL',
|
||||
DASHBOARDS_LIST_VISIBLE_COLUMNS = 'DASHBOARDS_LIST_VISIBLE_COLUMNS',
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export enum QueryParams {
|
||||
interval = 'interval',
|
||||
editPanelId = 'editPanelId',
|
||||
startTime = 'startTime',
|
||||
endTime = 'endTime',
|
||||
service = 'service',
|
||||
|
||||
@@ -14,7 +14,7 @@ import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQue
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { AlertListTabs } from 'pages/AlertList/types';
|
||||
import { CalendarClock, GalleryVerticalEnd, Pyramid } from '@signozhq/icons';
|
||||
import { GalleryVerticalEnd, Pyramid } from '@signozhq/icons';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
import { AlertDef } from 'types/api/alerts/def';
|
||||
|
||||
@@ -172,24 +172,14 @@ function CreateRules(): JSX.Element {
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<div className="periscope-tab top-level-tab">
|
||||
<CalendarClock size={14} />
|
||||
Planned Downtime
|
||||
</div>
|
||||
),
|
||||
key: AlertListTabs.PLANNED_DOWNTIME,
|
||||
children: null,
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<div className="periscope-tab top-level-tab">
|
||||
<ConfigureIcon width={14} height={14} />
|
||||
Routing Policies
|
||||
Configuration
|
||||
</div>
|
||||
),
|
||||
key: AlertListTabs.ROUTING_POLICIES,
|
||||
key: AlertListTabs.CONFIGURATION,
|
||||
children: null,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Button, Input, Select, Tooltip } from 'antd';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Button, Select, Tooltip } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { CircleX, Trash } from '@signozhq/icons';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
|
||||
@@ -4,4 +4,5 @@ export const THRESHOLD_TAB_TOOLTIP =
|
||||
export const ANOMALY_TAB_TOOLTIP =
|
||||
'An alert is triggered whenever the metric deviates from an expected pattern.';
|
||||
|
||||
export const ROUTING_POLICIES_ROUTE = '/alerts?tab=RoutingPolicies';
|
||||
export const ROUTING_POLICIES_ROUTE =
|
||||
'/alerts?tab=Configuration&subTab=routing-policies';
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Collapse, Input } from 'antd';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Collapse } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import { useCreateAlertState } from '../context';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Input, Select } from 'antd';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Select } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import { ADVANCED_OPTIONS_TIME_UNIT_OPTIONS } from '../../context/constants';
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Input } from 'antd';
|
||||
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import './TimeInput.scss';
|
||||
|
||||
export interface TimeInputProps {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Input, Select } from 'antd';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Select } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import { useCreateAlertState } from '../context';
|
||||
|
||||
@@ -16,9 +16,10 @@ import {
|
||||
Plus,
|
||||
X,
|
||||
} from '@signozhq/icons';
|
||||
import { Button, Card, Input, Modal, Popover, Tooltip } from 'antd';
|
||||
import { Button, Card, Modal, Popover, Tooltip } from 'antd';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import ConfigureIcon from 'assets/Integrations/ConfigureIcon';
|
||||
import { PANEL_GROUP_TYPES, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import ChartLayout from 'container/DashboardContainer/visualization/layout/ChartLayout/ChartLayout';
|
||||
import Legend from 'lib/uPlotV2/components/Legend/Legend';
|
||||
import UPlotLegend from 'lib/uPlotV2/components/Legend/UPlotLegend';
|
||||
import {
|
||||
LegendPosition,
|
||||
TooltipRenderArgs,
|
||||
@@ -47,7 +47,7 @@ export default function ChartWrapper({
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Legend
|
||||
<UPlotLegend
|
||||
config={config}
|
||||
position={legendConfig.position}
|
||||
averageLegendWidth={averageLegendWidth}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
.pieChartWrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pieChartNoData {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
// Size is set inline from the computed chart dimensions (mirrors the uPlot
|
||||
// chart/legend split); this just centres the donut within that box.
|
||||
.pieChartContainer {
|
||||
flex: 0 0 auto;
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pieChartTooltip {
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
|
||||
background-color: var(--l2-background) !important;
|
||||
border: 1px solid var(--l2-border) !important;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.pieTooltipContent {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pieChartIndicator {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 2px;
|
||||
margin-right: 8px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.tooltipValue {
|
||||
font-weight: bold;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
// Wraps the shared chart Legend. Its width/height are set inline from the
|
||||
// computed chart dimensions, so the VirtuosoGrid inside gets the same bounded
|
||||
// box (right column / bottom rows) the uPlot charts use.
|
||||
.pieLegend {
|
||||
flex: 0 0 auto;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
padding: 8px;
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Group } from '@visx/group';
|
||||
import { Pie as VisxPie } from '@visx/shape';
|
||||
import { defaultStyles, useTooltip, useTooltipInPortal } from '@visx/tooltip';
|
||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
import Legend from 'lib/uPlotV2/components/Legend/Legend';
|
||||
import { LegendPosition } from 'lib/uPlotV2/components/types';
|
||||
|
||||
import { PieChartProps, PieSlice } from '../types';
|
||||
import { calculateChartDimensions } from '../utils';
|
||||
|
||||
import PieArc from './PieArc';
|
||||
import PieCenterLabel from './PieCenterLabel';
|
||||
import styles from './Pie.module.scss';
|
||||
import { usePieInteractions } from './usePieInteractions';
|
||||
import { getFillColor } from './utils';
|
||||
|
||||
interface PieTooltipData {
|
||||
label: string;
|
||||
value: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Donut chart rendered with @visx. Splits its area into chart + legend with the
|
||||
* same `calculateChartDimensions` logic as the uPlot charts (right column /
|
||||
* up-to-two bottom rows), renders the shared chart Legend, and delegates the
|
||||
* arcs, centre total and interaction state to PieArc / PieCenterLabel /
|
||||
* usePieInteractions. Pure presentation — slices are pre-resolved by the caller.
|
||||
*/
|
||||
export default function Pie({
|
||||
data,
|
||||
yAxisUnit,
|
||||
decimalPrecision,
|
||||
isDarkMode,
|
||||
position = LegendPosition.BOTTOM,
|
||||
id,
|
||||
onSliceClick,
|
||||
'data-testid': testId,
|
||||
}: PieChartProps): JSX.Element {
|
||||
const {
|
||||
active,
|
||||
setActive,
|
||||
visibleData,
|
||||
legendItems,
|
||||
focusedSeriesIndex,
|
||||
onLegendClick,
|
||||
onLegendMouseMove,
|
||||
onLegendMouseLeave,
|
||||
} = usePieInteractions(data, id);
|
||||
|
||||
const {
|
||||
tooltipOpen,
|
||||
tooltipLeft,
|
||||
tooltipTop,
|
||||
tooltipData,
|
||||
hideTooltip,
|
||||
showTooltip,
|
||||
} = useTooltip<PieTooltipData>();
|
||||
|
||||
const { containerRef, TooltipInPortal } = useTooltipInPortal({
|
||||
scroll: true,
|
||||
detectBounds: true,
|
||||
});
|
||||
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const { width: containerWidth, height: containerHeight } =
|
||||
useResizeObserver(wrapperRef);
|
||||
|
||||
// Reuse the uPlot chart/legend split so the donut + legend get the same area
|
||||
// allocation (right column, or up-to-two bottom rows) as every other panel.
|
||||
const dimensions = useMemo(
|
||||
() =>
|
||||
calculateChartDimensions({
|
||||
containerWidth,
|
||||
containerHeight,
|
||||
legendConfig: { position },
|
||||
seriesLabels: data.map((slice) => slice.label),
|
||||
}),
|
||||
[containerWidth, containerHeight, position, data],
|
||||
);
|
||||
|
||||
const size = Math.min(dimensions.width, dimensions.height);
|
||||
const radius = size * 0.35;
|
||||
const innerRadius = radius * 0.6;
|
||||
const totalValue = visibleData.reduce((sum, slice) => sum + slice.value, 0);
|
||||
const labelColor = isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_400;
|
||||
const activeColor = active?.color ?? null;
|
||||
|
||||
const handleSliceEnter = useCallback(
|
||||
(slice: PieSlice, centroidX: number, centroidY: number): void => {
|
||||
showTooltip({
|
||||
tooltipData: {
|
||||
label: slice.label,
|
||||
value: getYAxisFormattedValue(
|
||||
slice.value.toString(),
|
||||
yAxisUnit || 'none',
|
||||
decimalPrecision,
|
||||
),
|
||||
color: slice.color,
|
||||
},
|
||||
tooltipTop: centroidY + dimensions.height / 2,
|
||||
tooltipLeft: centroidX + dimensions.width / 2,
|
||||
});
|
||||
setActive(slice);
|
||||
},
|
||||
[
|
||||
showTooltip,
|
||||
setActive,
|
||||
yAxisUnit,
|
||||
decimalPrecision,
|
||||
dimensions.height,
|
||||
dimensions.width,
|
||||
],
|
||||
);
|
||||
|
||||
const handleSliceLeave = useCallback((): void => {
|
||||
hideTooltip();
|
||||
setActive(null);
|
||||
}, [hideTooltip, setActive]);
|
||||
|
||||
if (!data.length) {
|
||||
return (
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className={styles.pieChartWrapper}
|
||||
data-testid={testId}
|
||||
>
|
||||
<div className={styles.pieChartNoData}>No data</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isRightLegend = position === LegendPosition.RIGHT;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className={styles.pieChartWrapper}
|
||||
style={{ flexDirection: isRightLegend ? 'row' : 'column' }}
|
||||
data-testid={testId}
|
||||
>
|
||||
<div
|
||||
className={styles.pieChartContainer}
|
||||
style={{ width: dimensions.width, height: dimensions.height }}
|
||||
>
|
||||
{size > 0 && (
|
||||
<svg
|
||||
width={dimensions.width}
|
||||
height={dimensions.height}
|
||||
ref={containerRef}
|
||||
>
|
||||
<Group top={dimensions.height / 2} left={dimensions.width / 2}>
|
||||
<VisxPie
|
||||
data={visibleData}
|
||||
pieValue={(slice: PieSlice): number => slice.value}
|
||||
outerRadius={radius}
|
||||
innerRadius={innerRadius}
|
||||
padAngle={0.01}
|
||||
cornerRadius={3}
|
||||
width={size}
|
||||
height={size}
|
||||
>
|
||||
{(pie): JSX.Element[] =>
|
||||
pie.arcs.map((arc) => (
|
||||
<PieArc
|
||||
key={`arc-${arc.data.label}-${arc.data.value}-${arc.startAngle.toFixed(
|
||||
6,
|
||||
)}`}
|
||||
slice={arc.data}
|
||||
arcPath={pie.path(arc) || ''}
|
||||
centroid={pie.path.centroid(arc)}
|
||||
startAngle={arc.startAngle}
|
||||
endAngle={arc.endAngle}
|
||||
radius={radius}
|
||||
totalValue={totalValue}
|
||||
yAxisUnit={yAxisUnit}
|
||||
decimalPrecision={decimalPrecision}
|
||||
labelColor={labelColor}
|
||||
fill={getFillColor(arc.data.color, activeColor)}
|
||||
onEnter={handleSliceEnter}
|
||||
onLeave={handleSliceLeave}
|
||||
onClick={onSliceClick}
|
||||
/>
|
||||
))
|
||||
}
|
||||
</VisxPie>
|
||||
<PieCenterLabel
|
||||
total={totalValue}
|
||||
yAxisUnit={yAxisUnit}
|
||||
decimalPrecision={decimalPrecision}
|
||||
radius={radius}
|
||||
innerRadius={innerRadius}
|
||||
color={labelColor}
|
||||
/>
|
||||
</Group>
|
||||
</svg>
|
||||
)}
|
||||
{tooltipOpen && tooltipData && (
|
||||
<TooltipInPortal
|
||||
top={tooltipTop}
|
||||
left={tooltipLeft}
|
||||
className={styles.pieChartTooltip}
|
||||
style={{
|
||||
...defaultStyles,
|
||||
color: labelColor,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={styles.pieChartIndicator}
|
||||
style={{ background: tooltipData.color }}
|
||||
/>
|
||||
<div className={styles.pieTooltipContent}>
|
||||
<span>{tooltipData.label}</span>
|
||||
<span className={styles.tooltipValue}>{tooltipData.value}</span>
|
||||
</div>
|
||||
</TooltipInPortal>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={styles.pieLegend}
|
||||
style={{
|
||||
width: dimensions.legendWidth,
|
||||
height: dimensions.legendHeight,
|
||||
}}
|
||||
>
|
||||
<Legend
|
||||
items={legendItems}
|
||||
position={position}
|
||||
averageLegendWidth={dimensions.averageLegendWidth}
|
||||
focusedSeriesIndex={focusedSeriesIndex}
|
||||
onClick={onLegendClick}
|
||||
onMouseMove={onLegendMouseMove}
|
||||
onMouseLeave={onLegendMouseLeave}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import type { PrecisionOption } from 'components/Graph/types';
|
||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
|
||||
import { PieSlice } from '../types';
|
||||
|
||||
import { getArcGeometry } from './utils';
|
||||
|
||||
// Slices below this share of the total don't get a leader label (too cramped).
|
||||
const MIN_LABEL_SHARE = 0.03;
|
||||
const MAX_LABEL_LENGTH = 15;
|
||||
|
||||
interface PieArcProps {
|
||||
slice: PieSlice;
|
||||
/** SVG path `d` for the arc, from the visx pie generator. */
|
||||
arcPath: string;
|
||||
/** Arc centroid `[x, y]`, used to anchor the leader line and tooltip. */
|
||||
centroid: [number, number];
|
||||
startAngle: number;
|
||||
endAngle: number;
|
||||
radius: number;
|
||||
/** Sum of visible slice values — drives the show-label threshold. */
|
||||
totalValue: number;
|
||||
yAxisUnit?: string;
|
||||
decimalPrecision?: PrecisionOption;
|
||||
labelColor: string;
|
||||
/** Resolved fill (already dimmed if another slice is active). */
|
||||
fill: string;
|
||||
onEnter: (slice: PieSlice, centroidX: number, centroidY: number) => void;
|
||||
onLeave: () => void;
|
||||
onClick?: (slice: PieSlice) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A single donut slice: the arc path plus, for non-tiny slices, a leader line
|
||||
* out to an external label + value. Pure presentation — interaction is
|
||||
* delegated to the `onEnter`/`onLeave`/`onClick` callbacks.
|
||||
*/
|
||||
export default function PieArc({
|
||||
slice,
|
||||
arcPath,
|
||||
centroid,
|
||||
startAngle,
|
||||
endAngle,
|
||||
radius,
|
||||
totalValue,
|
||||
yAxisUnit,
|
||||
decimalPrecision,
|
||||
labelColor,
|
||||
fill,
|
||||
onEnter,
|
||||
onLeave,
|
||||
onClick,
|
||||
}: PieArcProps): JSX.Element {
|
||||
const { label, value } = slice;
|
||||
const [centroidX, centroidY] = centroid;
|
||||
const { labelX, labelY, lineEndX, lineEndY, textAnchor } = getArcGeometry(
|
||||
startAngle,
|
||||
endAngle,
|
||||
radius,
|
||||
);
|
||||
|
||||
const displayValue = getYAxisFormattedValue(
|
||||
value.toString(),
|
||||
yAxisUnit || 'none',
|
||||
decimalPrecision,
|
||||
);
|
||||
const shortenedLabel =
|
||||
label.length > MAX_LABEL_LENGTH ? `${label.substring(0, 12)}...` : label;
|
||||
const shouldShowLabel = value / totalValue > MIN_LABEL_SHARE;
|
||||
|
||||
return (
|
||||
<g
|
||||
onMouseEnter={(): void => onEnter(slice, centroidX, centroidY)}
|
||||
onMouseLeave={onLeave}
|
||||
onClick={(): void => onClick?.(slice)}
|
||||
>
|
||||
<path d={arcPath} fill={fill} />
|
||||
{shouldShowLabel && (
|
||||
<>
|
||||
<line
|
||||
x1={centroidX}
|
||||
y1={centroidY}
|
||||
x2={lineEndX}
|
||||
y2={lineEndY}
|
||||
stroke={labelColor}
|
||||
strokeWidth={1}
|
||||
/>
|
||||
<line
|
||||
x1={lineEndX}
|
||||
y1={lineEndY}
|
||||
x2={labelX}
|
||||
y2={labelY}
|
||||
stroke={labelColor}
|
||||
strokeWidth={1}
|
||||
/>
|
||||
<text
|
||||
x={labelX}
|
||||
y={labelY - 8}
|
||||
dy=".33em"
|
||||
fill={labelColor}
|
||||
fontSize={10}
|
||||
textAnchor={textAnchor}
|
||||
pointerEvents="none"
|
||||
>
|
||||
{shortenedLabel}
|
||||
</text>
|
||||
<text
|
||||
x={labelX}
|
||||
y={labelY + 8}
|
||||
dy=".33em"
|
||||
fill={labelColor}
|
||||
fontSize={10}
|
||||
fontWeight="bold"
|
||||
textAnchor={textAnchor}
|
||||
pointerEvents="none"
|
||||
>
|
||||
{displayValue}
|
||||
</text>
|
||||
</>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { PrecisionOption } from 'components/Graph/types';
|
||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
|
||||
import { getScaledFontSize } from './utils';
|
||||
|
||||
interface PieCenterLabelProps {
|
||||
/** Sum of the visible slice values, shown in the donut hole. */
|
||||
total: number;
|
||||
yAxisUnit?: string;
|
||||
decimalPrecision?: PrecisionOption;
|
||||
radius: number;
|
||||
innerRadius: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The total shown in the centre of the donut. Splits the formatted value into
|
||||
* its numeric part and unit so each can be sized independently, and scales the
|
||||
* numeric font down for long values so it never overflows the hole.
|
||||
*/
|
||||
export default function PieCenterLabel({
|
||||
total,
|
||||
yAxisUnit,
|
||||
decimalPrecision,
|
||||
radius,
|
||||
innerRadius,
|
||||
color,
|
||||
}: PieCenterLabelProps): JSX.Element {
|
||||
const formattedTotal = getYAxisFormattedValue(
|
||||
total.toString(),
|
||||
yAxisUnit || 'none',
|
||||
decimalPrecision,
|
||||
);
|
||||
const matches = formattedTotal.match(/([\d.]+[KMB]?)(.*)$/);
|
||||
const numericTotal = matches?.[1] || formattedTotal;
|
||||
const unitTotal = matches?.[2]?.trim() || '';
|
||||
|
||||
const numericFontSize = getScaledFontSize({
|
||||
text: numericTotal,
|
||||
baseSize: radius * 0.3,
|
||||
innerRadius,
|
||||
});
|
||||
const unitFontSize = numericFontSize * 0.5;
|
||||
|
||||
return (
|
||||
<text textAnchor="middle" dominantBaseline="central" fill={color}>
|
||||
<tspan fontSize={numericFontSize} fontWeight="bold">
|
||||
{numericTotal}
|
||||
</tspan>
|
||||
{unitTotal && (
|
||||
<tspan fontSize={unitFontSize} opacity={0.9} dx={2}>
|
||||
{unitTotal}
|
||||
</tspan>
|
||||
)}
|
||||
</text>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { LegendItem } from 'lib/uPlotV2/config/types';
|
||||
import type { Dispatch, MouseEvent, SetStateAction } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
getStoredSeriesVisibility,
|
||||
updateSeriesVisibilityToLocalStorage,
|
||||
} from '../../panels/utils/legendVisibilityUtils';
|
||||
import { PieSlice } from '../types';
|
||||
|
||||
export interface UsePieInteractionsResult {
|
||||
/** The hovered/focused slice (drives donut dimming + tooltip). */
|
||||
active: PieSlice | null;
|
||||
setActive: Dispatch<SetStateAction<PieSlice | null>>;
|
||||
/** Slices currently shown (hidden ones removed). */
|
||||
visibleData: PieSlice[];
|
||||
/** Legend item per slice (`show` reflects hide state). */
|
||||
legendItems: LegendItem[];
|
||||
/** Index of the active slice for the legend's focus highlight, or null. */
|
||||
focusedSeriesIndex: number | null;
|
||||
onLegendClick: (e: MouseEvent<HTMLDivElement>) => void;
|
||||
onLegendMouseMove: (e: MouseEvent<HTMLDivElement>) => void;
|
||||
onLegendMouseLeave: () => void;
|
||||
}
|
||||
|
||||
// Reads the slice index off the nearest `[data-legend-item-id]` ancestor of the
|
||||
// event target (the shared Legend tags each item with its seriesIndex).
|
||||
function getLegendIndex(e: MouseEvent<HTMLDivElement>): number | null {
|
||||
const el = (e.target as HTMLElement | null)?.closest<HTMLElement>(
|
||||
'[data-legend-item-id]',
|
||||
);
|
||||
const id = el?.dataset.legendItemId;
|
||||
return id != null ? Number(id) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pie interaction + derived state: hover/focus, slice hide/unhide (mirroring the
|
||||
* uPlot legend — marker toggles one, label isolates), and persistence of the
|
||||
* hidden set to localStorage (keyed by `id`, matched by label) so it survives
|
||||
* reloads. Returns the visible slices, legend items, focus index, and the
|
||||
* legend container handlers.
|
||||
*/
|
||||
export function usePieInteractions(
|
||||
data: PieSlice[],
|
||||
id?: string,
|
||||
): UsePieInteractionsResult {
|
||||
const [active, setActive] = useState<PieSlice | null>(null);
|
||||
const [hiddenIndices, setHiddenIndices] = useState<Set<number>>(
|
||||
() => new Set(),
|
||||
);
|
||||
const isolatedIndexRef = useRef<number | null>(null);
|
||||
|
||||
const legendItems = useMemo<LegendItem[]>(
|
||||
() =>
|
||||
data.map((slice, index) => ({
|
||||
seriesIndex: index,
|
||||
label: slice.label,
|
||||
color: slice.color,
|
||||
show: !hiddenIndices.has(index),
|
||||
})),
|
||||
[data, hiddenIndices],
|
||||
);
|
||||
|
||||
// Hidden slices drop out so the remaining arcs + centre total recompute.
|
||||
const visibleData = useMemo(
|
||||
() => data.filter((_, index) => !hiddenIndices.has(index)),
|
||||
[data, hiddenIndices],
|
||||
);
|
||||
|
||||
// Rehydrate hide/unhide from localStorage (matched by label) whenever the
|
||||
// data set changes — including first load and every refetch, since the store
|
||||
// is the source of truth and toggles write back to it.
|
||||
useEffect(() => {
|
||||
if (!id || !data.length) {
|
||||
return;
|
||||
}
|
||||
const stored = getStoredSeriesVisibility(id);
|
||||
if (!stored) {
|
||||
return;
|
||||
}
|
||||
const hidden = new Set<number>();
|
||||
data.forEach((slice, index) => {
|
||||
if (stored.find((s) => s.label === slice.label)?.show === false) {
|
||||
hidden.add(index);
|
||||
}
|
||||
});
|
||||
setHiddenIndices(hidden);
|
||||
}, [id, data]);
|
||||
|
||||
// Apply a new hidden set and persist it (label + show) to localStorage.
|
||||
const applyHidden = useCallback(
|
||||
(hidden: Set<number>): void => {
|
||||
setHiddenIndices(hidden);
|
||||
if (id) {
|
||||
updateSeriesVisibilityToLocalStorage(
|
||||
id,
|
||||
data.map((slice, index) => ({
|
||||
label: slice.label,
|
||||
show: !hidden.has(index),
|
||||
})),
|
||||
);
|
||||
}
|
||||
},
|
||||
[id, data],
|
||||
);
|
||||
|
||||
const onLegendMouseMove = useCallback(
|
||||
(e: MouseEvent<HTMLDivElement>): void => {
|
||||
const index = getLegendIndex(e);
|
||||
// Don't focus/dim for hidden slices — they aren't on the donut.
|
||||
setActive(index != null && !hiddenIndices.has(index) ? data[index] : null);
|
||||
},
|
||||
[data, hiddenIndices],
|
||||
);
|
||||
|
||||
// Marker click toggles just that slice on/off; label click isolates it
|
||||
// (clicking the isolated one again resets to all) — mirrors the uPlot legend.
|
||||
const onLegendClick = useCallback(
|
||||
(e: MouseEvent<HTMLDivElement>): void => {
|
||||
const index = getLegendIndex(e);
|
||||
if (index == null) {
|
||||
return;
|
||||
}
|
||||
const isMarker = (e.target as HTMLElement).dataset.isLegendMarker;
|
||||
|
||||
if (isMarker) {
|
||||
const next = new Set(hiddenIndices);
|
||||
if (next.has(index)) {
|
||||
next.delete(index);
|
||||
} else {
|
||||
next.add(index);
|
||||
}
|
||||
applyHidden(next);
|
||||
return;
|
||||
}
|
||||
|
||||
const isReset = isolatedIndexRef.current === index;
|
||||
isolatedIndexRef.current = isReset ? null : index;
|
||||
if (isReset) {
|
||||
applyHidden(new Set());
|
||||
return;
|
||||
}
|
||||
const next = new Set<number>();
|
||||
data.forEach((_, i) => {
|
||||
if (i !== index) {
|
||||
next.add(i);
|
||||
}
|
||||
});
|
||||
applyHidden(next);
|
||||
},
|
||||
[data, hiddenIndices, applyHidden],
|
||||
);
|
||||
|
||||
const onLegendMouseLeave = useCallback((): void => setActive(null), []);
|
||||
|
||||
const focusedIndex = active ? data.indexOf(active) : -1;
|
||||
|
||||
return {
|
||||
active,
|
||||
setActive,
|
||||
visibleData,
|
||||
legendItems,
|
||||
focusedSeriesIndex: focusedIndex >= 0 ? focusedIndex : null,
|
||||
onLegendClick,
|
||||
onLegendMouseMove,
|
||||
onLegendMouseLeave,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Pure presentation helpers for the Pie chart. Kept out of the component file
|
||||
* so the renderer stays declarative (per the one-component-per-file rule).
|
||||
*/
|
||||
|
||||
interface ScaledFontSizeArgs {
|
||||
text: string;
|
||||
baseSize: number;
|
||||
innerRadius: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shrinks the centre-total font as the text gets longer so it never overflows
|
||||
* the donut hole. Ported from the V1 PiePanelWrapper.
|
||||
*/
|
||||
export function getScaledFontSize({
|
||||
text,
|
||||
baseSize,
|
||||
innerRadius,
|
||||
}: ScaledFontSizeArgs): number {
|
||||
if (!text) {
|
||||
return baseSize;
|
||||
}
|
||||
|
||||
const { length } = text;
|
||||
// More aggressive scaling for very long numbers.
|
||||
const scaleFactor = Math.max(0.3, 1 - (length - 3) * 0.09);
|
||||
// Don't use more than 90% of the inner radius.
|
||||
const maxSize = innerRadius * 0.9;
|
||||
|
||||
return Math.min(baseSize * scaleFactor, maxSize);
|
||||
}
|
||||
|
||||
export interface ArcGeometry {
|
||||
/** Outer point where the leader label sits. */
|
||||
labelX: number;
|
||||
labelY: number;
|
||||
/** Elbow point where the leader line bends toward the label. */
|
||||
lineEndX: number;
|
||||
lineEndY: number;
|
||||
/** Anchor the label left/right depending on which half of the circle it's in. */
|
||||
textAnchor: 'start' | 'end';
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the leader-line / label geometry for one arc from its angular span.
|
||||
* Pulled out of the render prop so the SVG markup stays declarative.
|
||||
*/
|
||||
export function getArcGeometry(
|
||||
startAngle: number,
|
||||
endAngle: number,
|
||||
radius: number,
|
||||
): ArcGeometry {
|
||||
const angle = (startAngle + endAngle) / 2;
|
||||
const labelRadius = radius * 1.3;
|
||||
const lineEndRadius = radius * 1.1;
|
||||
return {
|
||||
labelX: Math.sin(angle) * labelRadius,
|
||||
labelY: -Math.cos(angle) * labelRadius,
|
||||
lineEndX: Math.sin(angle) * lineEndRadius,
|
||||
lineEndY: -Math.cos(angle) * lineEndRadius,
|
||||
textAnchor: Math.sin(angle) > 0 ? 'start' : 'end',
|
||||
};
|
||||
}
|
||||
|
||||
interface ParsedRgb {
|
||||
r: number;
|
||||
g: number;
|
||||
b: number;
|
||||
}
|
||||
|
||||
// Parses `#rrggbb` into its components. Returns null for anything else (e.g. an
|
||||
// already-rgba string), letting callers fall back to the original colour.
|
||||
function hexToRgb(color: string): ParsedRgb | null {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(color);
|
||||
return result
|
||||
? {
|
||||
r: parseInt(result[1], 16),
|
||||
g: parseInt(result[2], 16),
|
||||
b: parseInt(result[3], 16),
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an rgba() string for `color` at the given opacity. Used to dim the
|
||||
* non-hovered slices. Falls back to the original colour if it can't be parsed.
|
||||
*/
|
||||
export function lightenColor(color: string, opacity: number): string {
|
||||
const rgb = hexToRgb(color);
|
||||
if (!rgb) {
|
||||
return color;
|
||||
}
|
||||
return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${opacity})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the fill for a slice given the currently-hovered slice colour:
|
||||
* everything but the active slice dims to 40% opacity. With nothing hovered
|
||||
* (`activeColor === null`) every slice keeps its full colour.
|
||||
*/
|
||||
export function getFillColor(
|
||||
color: string,
|
||||
activeColor: string | null,
|
||||
): string {
|
||||
if (activeColor === null) {
|
||||
return color;
|
||||
}
|
||||
return activeColor === color ? color : lightenColor(color, 0.4);
|
||||
}
|
||||
@@ -3,13 +3,14 @@ import { PrecisionOption } from 'components/Graph/types';
|
||||
import {
|
||||
IRenderTooltipFooterArgs,
|
||||
LegendConfig,
|
||||
LegendPosition,
|
||||
TooltipRenderArgs,
|
||||
} from 'lib/uPlotV2/components/types';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
import {
|
||||
DashboardCursorSync,
|
||||
SyncTooltipFilterMode,
|
||||
TooltipClickData,
|
||||
ChartClickData,
|
||||
} from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
|
||||
@@ -22,10 +23,10 @@ interface BaseChartProps {
|
||||
/** Key that pins the tooltip while hovering. Defaults to DEFAULT_PIN_TOOLTIP_KEY ('l'). */
|
||||
pinKey?: string;
|
||||
/** Called when the user clicks the uPlot overlay. Receives resolved click data. */
|
||||
onClick?: (clickData: TooltipClickData) => void;
|
||||
onClick?: (clickData: ChartClickData) => void;
|
||||
yAxisUnit?: string;
|
||||
decimalPrecision?: PrecisionOption;
|
||||
pinnedTooltipElement?: (clickData: TooltipClickData) => React.ReactNode;
|
||||
pinnedTooltipElement?: (clickData: ChartClickData) => React.ReactNode;
|
||||
renderTooltipFooter?: (args: IRenderTooltipFooterArgs) => React.ReactNode;
|
||||
customTooltip?: (props: TooltipRenderArgs) => React.ReactNode;
|
||||
'data-testid'?: string;
|
||||
@@ -69,3 +70,36 @@ export type ChartProps =
|
||||
| TimeSeriesChartProps
|
||||
| BarChartProps
|
||||
| HistogramChartProps;
|
||||
|
||||
/**
|
||||
* One resolved pie/donut slice: a display label, its (already parsed) positive
|
||||
* numeric value, and the colour used for the arc + legend swatch.
|
||||
*/
|
||||
export interface PieSlice {
|
||||
label: string;
|
||||
value: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for the Pie chart. Unlike the others above, Pie is NOT uPlot-based
|
||||
* (it renders with @visx), so it deliberately does not extend BaseChartProps /
|
||||
* UPlotBasedChartProps — it takes pre-resolved slices and self-measures its
|
||||
* draw area rather than receiving a uPlot config + aligned data.
|
||||
*/
|
||||
export interface PieChartProps {
|
||||
data: PieSlice[];
|
||||
yAxisUnit?: string;
|
||||
decimalPrecision?: PrecisionOption;
|
||||
isDarkMode: boolean;
|
||||
/** Legend placement. Drives the chart-vs-legend layout. Default BOTTOM. */
|
||||
position?: LegendPosition;
|
||||
/**
|
||||
* Widget id used to persist per-slice hide/unhide state to localStorage
|
||||
* (shared GRAPH_VISIBILITY_STATES, keyed by label). Omit to disable persistence.
|
||||
*/
|
||||
id?: string;
|
||||
/** Fired when a slice (or its legend entry) is clicked. */
|
||||
onSliceClick?: (slice: PieSlice) => void;
|
||||
'data-testid'?: string;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Button, Input } from 'antd';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Button } from 'antd';
|
||||
import { PrecisionOption, PrecisionOptionsEnum } from 'components/Graph/types';
|
||||
import { ResizeTable } from 'components/ResizeTable';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
.settings-container-root {
|
||||
.ant-drawer-wrapper-body {
|
||||
border-left: 1px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
box-shadow: -4px 10px 16px 2px rgba(0, 0, 0, 0.2);
|
||||
|
||||
.ant-drawer-header {
|
||||
height: 48px;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
padding: 14px 14px 14px 11px;
|
||||
|
||||
.ant-drawer-header-title {
|
||||
gap: 16px;
|
||||
|
||||
.ant-drawer-title {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
padding-left: 16px;
|
||||
border-left: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
.ant-drawer-close {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
margin-inline-end: 0px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-drawer-body {
|
||||
padding: 16px;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0.1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { memo, PropsWithChildren, ReactElement } from 'react';
|
||||
import { Drawer } from 'antd';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
|
||||
import './SettingsDrawer.styles.scss';
|
||||
|
||||
type SettingsDrawerProps = PropsWithChildren<{
|
||||
drawerTitle: string;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}>;
|
||||
|
||||
function SettingsDrawer({
|
||||
children,
|
||||
drawerTitle,
|
||||
isOpen,
|
||||
onClose,
|
||||
}: SettingsDrawerProps): JSX.Element {
|
||||
return (
|
||||
<Drawer
|
||||
title={drawerTitle}
|
||||
placement="right"
|
||||
width="50%"
|
||||
onClose={onClose}
|
||||
open={isOpen}
|
||||
rootClassName="settings-container-root"
|
||||
>
|
||||
{/* Need to type cast because of OverlayScrollbar type definition. We should be good once we remove it. */}
|
||||
<OverlayScrollbar>{children as ReactElement}</OverlayScrollbar>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(SettingsDrawer);
|
||||
@@ -0,0 +1,407 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { FullScreenHandle } from 'react-full-screen';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import {
|
||||
Check,
|
||||
ClipboardCopy,
|
||||
Ellipsis,
|
||||
FileJson,
|
||||
Fullscreen,
|
||||
Globe,
|
||||
LockKeyhole,
|
||||
PenLine,
|
||||
Plus,
|
||||
X,
|
||||
} from '@signozhq/icons';
|
||||
import { Button, Card, Input, Modal, Popover, Tooltip } from 'antd';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import {
|
||||
lockDashboardV2,
|
||||
patchDashboardV2,
|
||||
unlockDashboardV2,
|
||||
} from 'api/generated/services/dashboard';
|
||||
import type { DashboardtypesJSONPatchOperationDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import ConfigureIcon from 'assets/Integrations/ConfigureIcon';
|
||||
import { DeleteButton } from 'container/ListOfDashboard/TableComponents/DeleteButton';
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
import useComponentPermission from 'hooks/useComponentPermission';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
|
||||
import { Base64Icons } from '../../DashboardContainer/DashboardSettings/General/utils';
|
||||
import DashboardSettingsV2 from '../DashboardSettings';
|
||||
import DashboardHeader from '../components/DashboardHeader/DashboardHeader';
|
||||
import DashboardVariablesV2 from '../DashboardVariablesV2';
|
||||
import SettingsDrawer from './SettingsDrawer';
|
||||
|
||||
import '../../DashboardContainer/DashboardDescription/Description.styles.scss';
|
||||
|
||||
import type { V2Dashboard } from '../utils';
|
||||
|
||||
interface DashboardDescriptionV2Props {
|
||||
dashboard: V2Dashboard | undefined;
|
||||
handle: FullScreenHandle;
|
||||
onRefetch: () => void;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
function DashboardDescriptionV2(props: DashboardDescriptionV2Props): JSX.Element {
|
||||
const { dashboard, handle, onRefetch } = props;
|
||||
|
||||
const id = dashboard?.id ?? '';
|
||||
const isDashboardLocked = !!dashboard?.locked;
|
||||
|
||||
const [isSettingsDrawerOpen, setIsSettingsDrawerOpen] =
|
||||
useState<boolean>(false);
|
||||
|
||||
const title = dashboard?.spec?.display?.name ?? '';
|
||||
const description = dashboard?.spec?.display?.description ?? '';
|
||||
const image = dashboard?.image || Base64Icons[0];
|
||||
const tags = useMemo(
|
||||
() =>
|
||||
(dashboard?.tags ?? []).map((t) =>
|
||||
t.key === t.value ? t.key : `${t.key}:${t.value}`,
|
||||
),
|
||||
[dashboard?.tags],
|
||||
);
|
||||
const dashboardVariables = dashboard?.spec?.variables ?? [];
|
||||
|
||||
const [updatedTitle, setUpdatedTitle] = useState<string>(title);
|
||||
|
||||
const { user } = useAppContext();
|
||||
const [editDashboard] = useComponentPermission(['edit_dashboard'], user.role);
|
||||
const [isDashboardSettingsOpen, setIsDashbordSettingsOpen] =
|
||||
useState<boolean>(false);
|
||||
const [isRenameDashboardOpen, setIsRenameDashboardOpen] =
|
||||
useState<boolean>(false);
|
||||
|
||||
const isAuthor =
|
||||
!!user?.email && !!dashboard?.createdBy && dashboard.createdBy === user.email;
|
||||
const addPanelPermission = !isDashboardLocked;
|
||||
// V2 public dashboard wiring lives separately; treat as not-public for chrome.
|
||||
const isPublicDashboard = false;
|
||||
|
||||
const { showErrorModal } = useErrorModal();
|
||||
const { t } = useTranslation(['dashboard', 'common']);
|
||||
|
||||
const [isRenameLoading, setIsRenameLoading] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (dashboard) {setUpdatedTitle(title);}
|
||||
}, [dashboard, title]);
|
||||
|
||||
const handleLockDashboardToggle = async (): Promise<void> => {
|
||||
if (!id) {return;}
|
||||
setIsDashbordSettingsOpen(false);
|
||||
try {
|
||||
if (isDashboardLocked) {
|
||||
await unlockDashboardV2({ id });
|
||||
toast.success('Dashboard unlocked');
|
||||
} else {
|
||||
await lockDashboardV2({ id });
|
||||
toast.success('Dashboard locked');
|
||||
}
|
||||
onRefetch();
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
}
|
||||
};
|
||||
|
||||
const onNameChangeHandler = async (): Promise<void> => {
|
||||
const trimmed = updatedTitle.trim();
|
||||
if (!id || !trimmed || trimmed === title) {
|
||||
setIsRenameDashboardOpen(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setIsRenameLoading(true);
|
||||
const patch: DashboardtypesJSONPatchOperationDTO[] = [
|
||||
{
|
||||
op: 'replace' as DashboardtypesJSONPatchOperationDTO['op'],
|
||||
path: '/spec/display/name',
|
||||
value: trimmed,
|
||||
},
|
||||
];
|
||||
await patchDashboardV2({ id }, patch);
|
||||
toast.success('Dashboard renamed successfully');
|
||||
setIsRenameDashboardOpen(false);
|
||||
onRefetch();
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
setIsRenameDashboardOpen(true);
|
||||
} finally {
|
||||
setIsRenameLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onEmptyWidgetHandler = (): void => {
|
||||
logEvent('Dashboard Detail V2: Add new panel clicked', {
|
||||
dashboardId: id,
|
||||
});
|
||||
toast.info('V2 panel editor coming next');
|
||||
};
|
||||
|
||||
const [state, setCopy] = useCopyToClipboard();
|
||||
|
||||
useEffect(() => {
|
||||
if (state.error) {
|
||||
toast.error(t('something_went_wrong', { ns: 'common' }));
|
||||
}
|
||||
if (state.value) {
|
||||
toast.success(t('success', { ns: 'common' }));
|
||||
}
|
||||
}, [state.error, state.value, t]);
|
||||
|
||||
const dashboardDataJSON = (): string =>
|
||||
JSON.stringify(dashboard ?? {}, null, 2);
|
||||
|
||||
const exportJSON = (): void => {
|
||||
const blob = new Blob([dashboardDataJSON()], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `${title || 'dashboard'}.json`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const onConfigureClick = (): void => {
|
||||
setIsSettingsDrawerOpen(true);
|
||||
};
|
||||
|
||||
const onSettingsDrawerClose = (): void => {
|
||||
setIsSettingsDrawerOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="dashboard-description-container">
|
||||
<DashboardHeader title={title} image={image} />
|
||||
<section className="dashboard-details">
|
||||
<div className="left-section">
|
||||
<img src={image} alt="dashboard-img" className="dashboard-img" />
|
||||
<Tooltip title={title.length > 30 ? title : ''}>
|
||||
<Typography.Text
|
||||
className="dashboard-title"
|
||||
data-testid="dashboard-title"
|
||||
>
|
||||
{title}
|
||||
</Typography.Text>
|
||||
</Tooltip>
|
||||
|
||||
{isPublicDashboard && (
|
||||
<Tooltip title="This dashboard is publicly accessible">
|
||||
<Globe size={14} className="public-dashboard-icon" />
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{isDashboardLocked && (
|
||||
<Tooltip title="This dashboard is locked">
|
||||
<LockKeyhole size={14} className="lock-dashboard-icon" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<div className="right-section">
|
||||
<DateTimeSelectionV2 showAutoRefresh hideShareModal />
|
||||
<Popover
|
||||
open={isDashboardSettingsOpen}
|
||||
arrow={false}
|
||||
onOpenChange={(visible): void => setIsDashbordSettingsOpen(visible)}
|
||||
rootClassName="dashboard-settings"
|
||||
content={
|
||||
<div className="menu-content">
|
||||
<section className="section-1">
|
||||
{(isAuthor || user.role === USER_ROLES.ADMIN) && (
|
||||
<Tooltip
|
||||
title={
|
||||
dashboard?.createdBy === 'integration' &&
|
||||
'Dashboards created by integrations cannot be unlocked'
|
||||
}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<LockKeyhole size={14} />}
|
||||
disabled={dashboard?.createdBy === 'integration'}
|
||||
onClick={handleLockDashboardToggle}
|
||||
data-testid="lock-unlock-dashboard"
|
||||
>
|
||||
{isDashboardLocked ? 'Unlock Dashboard' : 'Lock Dashboard'}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{!isDashboardLocked && editDashboard && (
|
||||
<Button
|
||||
type="text"
|
||||
icon={<PenLine size={14} />}
|
||||
onClick={(): void => {
|
||||
setIsRenameDashboardOpen(true);
|
||||
setIsDashbordSettingsOpen(false);
|
||||
}}
|
||||
>
|
||||
Rename
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="text"
|
||||
icon={<Fullscreen size={14} />}
|
||||
onClick={handle.enter}
|
||||
>
|
||||
Full screen
|
||||
</Button>
|
||||
</section>
|
||||
<section className="section-2">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<FileJson size={14} />}
|
||||
onClick={(): void => {
|
||||
exportJSON();
|
||||
setIsDashbordSettingsOpen(false);
|
||||
}}
|
||||
>
|
||||
Export JSON
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ClipboardCopy size={14} />}
|
||||
onClick={(): void => {
|
||||
setCopy(dashboardDataJSON());
|
||||
setIsDashbordSettingsOpen(false);
|
||||
}}
|
||||
>
|
||||
Copy as JSON
|
||||
</Button>
|
||||
</section>
|
||||
<section className="delete-dashboard">
|
||||
<DeleteButton
|
||||
createdBy={dashboard?.createdBy || ''}
|
||||
name={title}
|
||||
id={id}
|
||||
isLocked={isDashboardLocked}
|
||||
routeToListPage
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
trigger="click"
|
||||
placement="bottomRight"
|
||||
>
|
||||
<Button
|
||||
icon={<Ellipsis size={14} />}
|
||||
type="text"
|
||||
className="icons"
|
||||
data-testid="options"
|
||||
/>
|
||||
</Popover>
|
||||
{!isDashboardLocked && editDashboard && (
|
||||
<>
|
||||
<Button
|
||||
type="text"
|
||||
className="configure-button"
|
||||
icon={<ConfigureIcon />}
|
||||
data-testid="show-drawer"
|
||||
onClick={onConfigureClick}
|
||||
>
|
||||
Configure
|
||||
</Button>
|
||||
<SettingsDrawer
|
||||
drawerTitle="Dashboard Configuration"
|
||||
isOpen={isSettingsDrawerOpen}
|
||||
onClose={onSettingsDrawerClose}
|
||||
>
|
||||
<DashboardSettingsV2
|
||||
dashboard={dashboard}
|
||||
onRefetch={onRefetch}
|
||||
/>
|
||||
</SettingsDrawer>
|
||||
</>
|
||||
)}
|
||||
{!isDashboardLocked && addPanelPermission && (
|
||||
<Button
|
||||
className="add-panel-btn"
|
||||
onClick={onEmptyWidgetHandler}
|
||||
icon={<Plus size="md" />}
|
||||
type="primary"
|
||||
data-testid="add-panel-header"
|
||||
>
|
||||
New Panel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
{tags.length > 0 && (
|
||||
<div className="dashboard-tags">
|
||||
{tags.map((tag) => (
|
||||
<Badge key={tag} className="tag">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!isEmpty(description) && (
|
||||
<section className="dashboard-description-section">{description}</section>
|
||||
)}
|
||||
|
||||
{dashboardVariables.length > 0 && (
|
||||
<section className="dashboard-variables">
|
||||
<DashboardVariablesV2
|
||||
dashboardId={id}
|
||||
variables={dashboardVariables}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
open={isRenameDashboardOpen}
|
||||
title="Rename Dashboard"
|
||||
onOk={onNameChangeHandler}
|
||||
onCancel={(): void => {
|
||||
setIsRenameDashboardOpen(false);
|
||||
}}
|
||||
rootClassName="rename-dashboard"
|
||||
footer={
|
||||
<div className="dashboard-rename">
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<Check size={14} />}
|
||||
className="rename-btn"
|
||||
onClick={onNameChangeHandler}
|
||||
disabled={isRenameLoading}
|
||||
>
|
||||
Rename Dashboard
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<X size={14} />}
|
||||
className="cancel-btn"
|
||||
onClick={(): void => setIsRenameDashboardOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="dashboard-content">
|
||||
<Typography.Text className="name-text">Enter a new name</Typography.Text>
|
||||
<Input
|
||||
data-testid="dashboard-name"
|
||||
className="dashboard-name-input"
|
||||
value={updatedTitle}
|
||||
onChange={(e): void => setUpdatedTitle(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardDescriptionV2;
|
||||
@@ -0,0 +1,227 @@
|
||||
.overviewContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.overviewSettings {
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--l1-border);
|
||||
padding: 16px !important;
|
||||
}
|
||||
|
||||
.crossPanelSyncGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.crossPanelSyncSectionTitle {
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.crossPanelSyncSectionHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.crossPanelSyncInfoIcon {
|
||||
cursor: help;
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.crossPanelSyncTooltipContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.crossPanelSyncTooltipTitle {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.crossPanelSyncTooltipDescription {
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.crossPanelSyncTooltipDocLink {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--primary-background);
|
||||
font-size: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.crossPanelSyncRow {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
|
||||
& + & {
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--l1-border);
|
||||
}
|
||||
}
|
||||
|
||||
.crossPanelSyncInfo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.crossPanelSyncTitle {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.crossPanelSyncDescription {
|
||||
color: var(--l3-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.nameIconInput {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.dashboardImageInput {
|
||||
:global(.ant-select-selector) {
|
||||
display: flex;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 6px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 2px 0px 0px 2px;
|
||||
border: 1px solid var(--l1-border) !important;
|
||||
background: var(--l3-background) !important;
|
||||
|
||||
:global(.ant-select-selection-item) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
&:global(.ant-select-dropdown) {
|
||||
padding: 0px !important;
|
||||
}
|
||||
|
||||
:global(.ant-select-item) {
|
||||
padding: 0px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
:global(.ant-select-item-option-content) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.listItemImage {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
.dashboardNameInput {
|
||||
border-radius: 0px 2px 2px 0px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l3-background);
|
||||
}
|
||||
|
||||
.dashboardName {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.descriptionTextArea {
|
||||
padding: 6px 6px 6px 8px;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l3-background);
|
||||
}
|
||||
|
||||
.overviewSettingsFooter {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: -webkit-fill-available;
|
||||
padding: 12px 16px 12px 0px;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
height: 32px;
|
||||
border-top: 1px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
}
|
||||
|
||||
.unsaved {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.unsavedDot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50px;
|
||||
background: var(--primary-background);
|
||||
box-shadow: 0px 0px 6px 0px
|
||||
color-mix(in srgb, var(--primary-background) 40%, transparent);
|
||||
}
|
||||
|
||||
.unsavedChanges {
|
||||
color: var(--bg-robin-400);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 24px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.footerActionBtns {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.discardBtn {
|
||||
margin: '16px 0';
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.saveBtn {
|
||||
margin: 0px !important;
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 24px;
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
// eslint-disable-next-line signoz/no-antd-components -- TODO: migrate Radio to @signozhq/ui/radio-group
|
||||
import { Col, Input, Radio, Select, Space, Tooltip } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import AddTags from 'container/DashboardContainer/DashboardSettings/General/AddBadges';
|
||||
import { useDashboardCursorSyncMode } from 'hooks/dashboard/useDashboardCursorSyncMode';
|
||||
import { useSyncTooltipFilterMode } from 'hooks/dashboard/useSyncTooltipFilterMode';
|
||||
import {
|
||||
DashboardCursorSync,
|
||||
SyncTooltipFilterMode,
|
||||
} from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
import { isEqual } from 'lodash-es';
|
||||
import { Check, ExternalLink, SolidInfoCircle, X } from '@signozhq/icons';
|
||||
import { patchDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import type {
|
||||
DashboardtypesJSONPatchOperationDTO,
|
||||
TagtypesPostableTagDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import styles from './GeneralSettings.module.scss';
|
||||
import { Button } from './styles';
|
||||
import { Base64Icons } from './utils';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { Events } from 'constants/events';
|
||||
import { getAbsoluteUrl } from 'utils/basePath';
|
||||
|
||||
import type { V2Dashboard } from '../../utils';
|
||||
|
||||
const { Option } = Select;
|
||||
|
||||
interface Props {
|
||||
dashboard: V2Dashboard | undefined;
|
||||
onRefetch: () => void;
|
||||
}
|
||||
|
||||
// Convert V2 tags ({key, value}[]) into "key:value" strings for the V1
|
||||
// AddTags component (which expects string[]), and back on save.
|
||||
//
|
||||
// V2 tags require both `key` and `value` to be non-empty server-side
|
||||
// (returns `tag_invalid_value` otherwise). To preserve the V1 single-word
|
||||
// tag UX, a string with no ':' is round-tripped as `{key: x, value: x}` and
|
||||
// collapsed back to just `x` for display.
|
||||
function tagsToStrings(tags: TagtypesPostableTagDTO[]): string[] {
|
||||
return tags.map((t) => (t.key === t.value ? t.key : `${t.key}:${t.value}`));
|
||||
}
|
||||
|
||||
function stringsToTags(tagStrings: string[]): TagtypesPostableTagDTO[] {
|
||||
return tagStrings
|
||||
.map((s) => {
|
||||
const trimmed = s.trim();
|
||||
const idx = trimmed.indexOf(':');
|
||||
if (idx === -1) {return { key: trimmed, value: trimmed };}
|
||||
const key = trimmed.slice(0, idx).trim();
|
||||
const value = trimmed.slice(idx + 1).trim();
|
||||
return { key, value: value || key };
|
||||
})
|
||||
.filter((t) => t.key.length > 0);
|
||||
}
|
||||
|
||||
function GeneralDashboardSettingsV2({
|
||||
dashboard,
|
||||
onRefetch,
|
||||
}: Props): JSX.Element {
|
||||
const id = dashboard?.id ?? '';
|
||||
|
||||
const [cursorSyncMode, setCursorSyncMode] = useDashboardCursorSyncMode(id);
|
||||
const [syncTooltipFilterMode, setSyncTooltipFilterMode] =
|
||||
useSyncTooltipFilterMode(id);
|
||||
|
||||
const title = dashboard?.spec?.display?.name ?? '';
|
||||
const description = dashboard?.spec?.display?.description ?? '';
|
||||
const image = dashboard?.image || Base64Icons[0];
|
||||
const tagsAsStrings = useMemo(
|
||||
() => tagsToStrings(dashboard?.tags ?? []),
|
||||
[dashboard?.tags],
|
||||
);
|
||||
|
||||
const [updatedTitle, setUpdatedTitle] = useState<string>(title);
|
||||
const [updatedTags, setUpdatedTags] = useState<string[]>(tagsAsStrings);
|
||||
const [updatedDescription, setUpdatedDescription] = useState<string>(
|
||||
description,
|
||||
);
|
||||
const [updatedImage, setUpdatedImage] = useState<string>(image);
|
||||
const [isSaving, setIsSaving] = useState<boolean>(false);
|
||||
const [numberOfUnsavedChanges, setNumberOfUnsavedChanges] = useState<number>(
|
||||
0,
|
||||
);
|
||||
|
||||
const { t } = useTranslation('common');
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
// Sync state when dashboard refetches after a save
|
||||
useEffect(() => {
|
||||
setUpdatedTitle(title);
|
||||
setUpdatedDescription(description);
|
||||
setUpdatedImage(image);
|
||||
setUpdatedTags(tagsAsStrings);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dashboard?.updatedAt]);
|
||||
|
||||
const buildPatch = (): DashboardtypesJSONPatchOperationDTO[] => {
|
||||
const ops: DashboardtypesJSONPatchOperationDTO[] = [];
|
||||
const replace = (
|
||||
path: string,
|
||||
value: unknown,
|
||||
): DashboardtypesJSONPatchOperationDTO => ({
|
||||
op: 'replace' as DashboardtypesJSONPatchOperationDTO['op'],
|
||||
path,
|
||||
value,
|
||||
});
|
||||
|
||||
if (updatedTitle !== title) {
|
||||
ops.push(replace('/spec/display/name', updatedTitle));
|
||||
}
|
||||
if (updatedDescription !== description) {
|
||||
ops.push(replace('/spec/display/description', updatedDescription));
|
||||
}
|
||||
if (updatedImage !== image) {
|
||||
ops.push(replace('/image', updatedImage));
|
||||
}
|
||||
if (!isEqual(updatedTags, tagsAsStrings)) {
|
||||
ops.push(replace('/tags', stringsToTags(updatedTags)));
|
||||
}
|
||||
return ops;
|
||||
};
|
||||
|
||||
const onSaveHandler = async (): Promise<void> => {
|
||||
if (!id) {return;}
|
||||
const ops = buildPatch();
|
||||
if (ops.length === 0) {return;}
|
||||
|
||||
try {
|
||||
setIsSaving(true);
|
||||
await patchDashboardV2({ id }, ops);
|
||||
toast.success('Dashboard updated');
|
||||
onRefetch();
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let n = 0;
|
||||
const initialValues = [title, description, tagsAsStrings, image];
|
||||
const updatedValues = [
|
||||
updatedTitle,
|
||||
updatedDescription,
|
||||
updatedTags,
|
||||
updatedImage,
|
||||
];
|
||||
initialValues.forEach((val, index) => {
|
||||
if (!isEqual(val, updatedValues[index])) {n += 1;}
|
||||
});
|
||||
setNumberOfUnsavedChanges(n);
|
||||
}, [
|
||||
description,
|
||||
image,
|
||||
tagsAsStrings,
|
||||
title,
|
||||
updatedDescription,
|
||||
updatedImage,
|
||||
updatedTags,
|
||||
updatedTitle,
|
||||
]);
|
||||
|
||||
const discardHandler = (): void => {
|
||||
setUpdatedTitle(title);
|
||||
setUpdatedImage(image);
|
||||
setUpdatedTags(tagsAsStrings);
|
||||
setUpdatedDescription(description);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.overviewContent}>
|
||||
<Col className={styles.overviewSettings}>
|
||||
<Space
|
||||
direction="vertical"
|
||||
style={{
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '21px',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<Typography className={styles.dashboardName}>Dashboard Name</Typography>
|
||||
<section className={styles.nameIconInput}>
|
||||
<Select
|
||||
defaultActiveFirstOption
|
||||
data-testid="dashboard-image"
|
||||
suffixIcon={null}
|
||||
rootClassName={styles.dashboardImageInput}
|
||||
value={updatedImage}
|
||||
onChange={(value: string): void => setUpdatedImage(value)}
|
||||
>
|
||||
{Base64Icons.map((icon) => (
|
||||
<Option value={icon} key={icon}>
|
||||
<img
|
||||
src={icon}
|
||||
alt="dashboard-icon"
|
||||
className={styles.listItemImage}
|
||||
/>
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
<Input
|
||||
data-testid="dashboard-name"
|
||||
className={styles.dashboardNameInput}
|
||||
value={updatedTitle}
|
||||
onChange={(e): void => setUpdatedTitle(e.target.value)}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Typography className={styles.dashboardName}>Description</Typography>
|
||||
<Input.TextArea
|
||||
data-testid="dashboard-desc"
|
||||
rows={6}
|
||||
value={updatedDescription}
|
||||
className={styles.descriptionTextArea}
|
||||
onChange={(e): void => setUpdatedDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Typography className={styles.dashboardName}>Tags</Typography>
|
||||
<AddTags tags={updatedTags} setTags={setUpdatedTags} />
|
||||
</div>
|
||||
</Space>
|
||||
</Col>
|
||||
<Col className={`${styles.overviewSettings} ${styles.crossPanelSyncGroup}`}>
|
||||
<div className={styles.crossPanelSyncSectionHeader}>
|
||||
<Typography.Text className={styles.crossPanelSyncSectionTitle}>
|
||||
Cross-Panel Sync
|
||||
</Typography.Text>
|
||||
<Tooltip
|
||||
title={
|
||||
<div className={styles.crossPanelSyncTooltipContent}>
|
||||
<strong className={styles.crossPanelSyncTooltipTitle}>
|
||||
Cross-Panel Sync
|
||||
</strong>
|
||||
<span className={styles.crossPanelSyncTooltipDescription}>
|
||||
Sync crosshair and tooltip across all the dashboard panels
|
||||
</span>
|
||||
<a
|
||||
href="https://signoz.io/docs/dashboards/interactivity/#cross-panel-sync"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={styles.crossPanelSyncTooltipDocLink}
|
||||
>
|
||||
Learn more
|
||||
<ExternalLink size={12} />
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
placement="top"
|
||||
mouseEnterDelay={0.5}
|
||||
>
|
||||
<SolidInfoCircle size="md" className={styles.crossPanelSyncInfoIcon} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className={styles.crossPanelSyncRow}>
|
||||
<div className={styles.crossPanelSyncInfo}>
|
||||
<Typography.Text className={styles.crossPanelSyncTitle}>
|
||||
Sync Mode
|
||||
</Typography.Text>
|
||||
<Typography.Text className={styles.crossPanelSyncDescription}>
|
||||
Sync crosshair and tooltip across all the dashboard panels
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Radio.Group
|
||||
value={cursorSyncMode}
|
||||
onChange={(e): void => {
|
||||
setCursorSyncMode(e.target.value as DashboardCursorSync);
|
||||
}}
|
||||
>
|
||||
<Radio.Button value={DashboardCursorSync.None}>No Sync</Radio.Button>
|
||||
<Radio.Button value={DashboardCursorSync.Crosshair}>
|
||||
Crosshair
|
||||
</Radio.Button>
|
||||
<Radio.Button value={DashboardCursorSync.Tooltip}>Tooltip</Radio.Button>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
{cursorSyncMode === DashboardCursorSync.Tooltip && (
|
||||
<div className={styles.crossPanelSyncRow}>
|
||||
<div className={styles.crossPanelSyncInfo}>
|
||||
<Typography.Text className={styles.crossPanelSyncTitle}>
|
||||
Synced Tooltip Series
|
||||
</Typography.Text>
|
||||
<Typography.Text className={styles.crossPanelSyncDescription}>
|
||||
Show only series that intersect on group-by, or every series with the
|
||||
matching ones highlighted
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Radio.Group
|
||||
value={syncTooltipFilterMode}
|
||||
onChange={(e): void => {
|
||||
logEvent(Events.TOOLTIP_SYNC_MODE_CHANGED, {
|
||||
path: getAbsoluteUrl(window.location.pathname),
|
||||
mode: e.target.value,
|
||||
});
|
||||
setSyncTooltipFilterMode(e.target.value as SyncTooltipFilterMode);
|
||||
}}
|
||||
>
|
||||
<Radio.Button value={SyncTooltipFilterMode.All}>All</Radio.Button>
|
||||
<Radio.Button value={SyncTooltipFilterMode.Filtered}>
|
||||
Filtered
|
||||
</Radio.Button>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
)}
|
||||
</Col>
|
||||
{numberOfUnsavedChanges > 0 && (
|
||||
<div className={styles.overviewSettingsFooter}>
|
||||
<div className={styles.unsaved}>
|
||||
<div className={styles.unsavedDot} />
|
||||
<Typography.Text className={styles.unsavedChanges}>
|
||||
{numberOfUnsavedChanges} unsaved change
|
||||
{numberOfUnsavedChanges > 1 && 's'}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className={styles.footerActionBtns}>
|
||||
<Button
|
||||
disabled={isSaving}
|
||||
icon={<X size={14} />}
|
||||
onClick={discardHandler}
|
||||
type="text"
|
||||
className={styles.discardBtn}
|
||||
>
|
||||
Discard
|
||||
</Button>
|
||||
<Button
|
||||
style={{ margin: '16px 0' }}
|
||||
disabled={isSaving}
|
||||
loading={isSaving}
|
||||
icon={<Check size={14} />}
|
||||
data-testid="save-dashboard-config"
|
||||
onClick={onSaveHandler}
|
||||
type="primary"
|
||||
className={styles.saveBtn}
|
||||
>
|
||||
{t('save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default GeneralDashboardSettingsV2;
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Button as ButtonComponent, Drawer } from 'antd';
|
||||
import styled from 'styled-components';
|
||||
|
||||
export const Container = styled.div`
|
||||
margin-top: 0.5rem;
|
||||
`;
|
||||
|
||||
export const Button = styled(ButtonComponent)`
|
||||
&&& {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
`;
|
||||
|
||||
export const DrawerContainer = styled(Drawer)`
|
||||
.ant-drawer-header {
|
||||
padding: 0;
|
||||
border: none;
|
||||
}
|
||||
`;
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,46 @@
|
||||
import { Collapse, Input } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import { VariableItemRow } from '../../../../DashboardContainer/DashboardSettings/DashboardVariableSettings/VariableItem/styles';
|
||||
|
||||
interface Props {
|
||||
customValue: string;
|
||||
onChange: (v: string) => void;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function CustomFields({ customValue, onChange, error }: Props): JSX.Element {
|
||||
return (
|
||||
<VariableItemRow className="variable-custom-section">
|
||||
<Collapse
|
||||
collapsible="header"
|
||||
rootClassName="custom-collapse"
|
||||
defaultActiveKey={['1']}
|
||||
items={[
|
||||
{
|
||||
key: '1',
|
||||
label: 'Options',
|
||||
children: (
|
||||
<>
|
||||
<Input.TextArea
|
||||
value={customValue}
|
||||
placeholder="Enter options separated by commas."
|
||||
rootClassName="comma-input"
|
||||
onChange={(e): void => onChange(e.target.value)}
|
||||
data-testid="variable-custom-value-v2"
|
||||
/>
|
||||
{error ? (
|
||||
<div>
|
||||
<Typography.Text color="warning">{error}</Typography.Text>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</VariableItemRow>
|
||||
);
|
||||
}
|
||||
|
||||
export default CustomFields;
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import DynamicVariable from 'container/DashboardContainer/DashboardSettings/DashboardVariableSettings/VariableItem/DynamicVariable/DynamicVariable';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
interface Props {
|
||||
dynamicName: string;
|
||||
dynamicSignal: TelemetrytypesSignalDTO | undefined;
|
||||
onNameChange: (v: string) => void;
|
||||
onSignalChange: (v: TelemetrytypesSignalDTO | undefined) => void;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// V1 DynamicVariable stores the source as a UI-friendly label:
|
||||
// 'All telemetry' | 'Logs' | 'Metrics' | 'Traces'. V2 stores the API enum
|
||||
// signal value: undefined (= all) | 'metrics' | 'traces' | 'logs'. We convert
|
||||
// at this boundary so the V1 component can stay untouched.
|
||||
const ALL_TELEMETRY = 'All telemetry';
|
||||
|
||||
function signalToV1Source(
|
||||
signal: TelemetrytypesSignalDTO | undefined,
|
||||
): string {
|
||||
if (signal === TelemetrytypesSignalDTO.logs) {return 'Logs';}
|
||||
if (signal === TelemetrytypesSignalDTO.metrics) {return 'Metrics';}
|
||||
if (signal === TelemetrytypesSignalDTO.traces) {return 'Traces';}
|
||||
return ALL_TELEMETRY;
|
||||
}
|
||||
|
||||
function v1SourceToSignal(
|
||||
source: string,
|
||||
): TelemetrytypesSignalDTO | undefined {
|
||||
if (source === 'Logs') {return TelemetrytypesSignalDTO.logs;}
|
||||
if (source === 'Metrics') {return TelemetrytypesSignalDTO.metrics;}
|
||||
if (source === 'Traces') {return TelemetrytypesSignalDTO.traces;}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function DynamicFields({
|
||||
dynamicName,
|
||||
dynamicSignal,
|
||||
onNameChange,
|
||||
onSignalChange,
|
||||
error,
|
||||
}: Props): JSX.Element {
|
||||
const v1Value = useMemo(
|
||||
() => ({ name: dynamicName, value: signalToV1Source(dynamicSignal) }),
|
||||
[dynamicName, dynamicSignal],
|
||||
);
|
||||
|
||||
const setV1Value: React.Dispatch<
|
||||
React.SetStateAction<{ name: string; value: string } | undefined>
|
||||
> = useCallback(
|
||||
(action) => {
|
||||
const next =
|
||||
typeof action === 'function' ? action(v1Value) : action;
|
||||
if (!next) {return;}
|
||||
if (next.name !== dynamicName) {onNameChange(next.name);}
|
||||
const nextSignal = v1SourceToSignal(next.value);
|
||||
if (nextSignal !== dynamicSignal) {onSignalChange(nextSignal);}
|
||||
},
|
||||
[v1Value, dynamicName, dynamicSignal, onNameChange, onSignalChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="variable-dynamic-section">
|
||||
<DynamicVariable
|
||||
setDynamicVariablesSelectedValue={setV1Value}
|
||||
dynamicVariablesSelectedValue={v1Value}
|
||||
errorAttributeKeyMessage={error}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DynamicFields;
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Button } from 'antd';
|
||||
import { Check, X } from '@signozhq/icons';
|
||||
|
||||
import { VariableItemRow } from '../../../../DashboardContainer/DashboardSettings/DashboardVariableSettings/VariableItem/styles';
|
||||
|
||||
interface Props {
|
||||
saving: boolean;
|
||||
canSave: boolean;
|
||||
onSave: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
function Footer({ saving, canSave, onSave, onCancel }: Props): JSX.Element {
|
||||
return (
|
||||
<div className="variable-item-footer">
|
||||
<VariableItemRow>
|
||||
<Button
|
||||
type="default"
|
||||
onClick={onCancel}
|
||||
icon={<X size={14} />}
|
||||
className="footer-btn-discard"
|
||||
disabled={saving}
|
||||
data-testid="variable-cancel-v2"
|
||||
>
|
||||
Discard
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={onSave}
|
||||
icon={<Check size={14} />}
|
||||
className="footer-btn-save"
|
||||
loading={saving}
|
||||
disabled={!canSave || saving}
|
||||
data-testid="variable-save-v2"
|
||||
>
|
||||
Save Variable
|
||||
</Button>
|
||||
</VariableItemRow>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Footer;
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { V2VariableKind } from '../types';
|
||||
import AllOptionRow from './ListOptions/AllOptionRow';
|
||||
import CapturingRegexpRow from './ListOptions/CapturingRegexpRow';
|
||||
import CustomAllValueRow from './ListOptions/CustomAllValueRow';
|
||||
import DefaultValueRow from './ListOptions/DefaultValueRow';
|
||||
import MultiSelectRow from './ListOptions/MultiSelectRow';
|
||||
import SortRow from './ListOptions/SortRow';
|
||||
|
||||
interface Props {
|
||||
kind: V2VariableKind;
|
||||
allowAllValue: boolean;
|
||||
allowMultiple: boolean;
|
||||
sort: string;
|
||||
defaultValue: string;
|
||||
customAllValue: string;
|
||||
capturingRegexp: string;
|
||||
previewValues: string[];
|
||||
onAllowAllChange: (v: boolean) => void;
|
||||
onAllowMultipleChange: (v: boolean) => void;
|
||||
onSortChange: (v: string) => void;
|
||||
onDefaultValueChange: (v: string) => void;
|
||||
onCustomAllValueChange: (v: string) => void;
|
||||
onCapturingRegexpChange: (v: string) => void;
|
||||
}
|
||||
|
||||
function ListBasicOptions({
|
||||
kind,
|
||||
allowAllValue,
|
||||
allowMultiple,
|
||||
sort,
|
||||
defaultValue,
|
||||
customAllValue,
|
||||
capturingRegexp,
|
||||
previewValues,
|
||||
onAllowAllChange,
|
||||
onAllowMultipleChange,
|
||||
onSortChange,
|
||||
onDefaultValueChange,
|
||||
onCustomAllValueChange,
|
||||
onCapturingRegexpChange,
|
||||
}: Props): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<SortRow sort={sort} onChange={onSortChange} />
|
||||
<MultiSelectRow
|
||||
allowMultiple={allowMultiple}
|
||||
onChange={(v): void => {
|
||||
onAllowMultipleChange(v);
|
||||
if (!v) {onAllowAllChange(false);}
|
||||
}}
|
||||
/>
|
||||
{allowMultiple && kind !== 'DYNAMIC' ? (
|
||||
<AllOptionRow
|
||||
allowAllValue={allowAllValue}
|
||||
onChange={onAllowAllChange}
|
||||
/>
|
||||
) : null}
|
||||
{allowAllValue ? (
|
||||
<CustomAllValueRow
|
||||
customAllValue={customAllValue}
|
||||
onChange={onCustomAllValueChange}
|
||||
/>
|
||||
) : null}
|
||||
{kind === 'QUERY' || kind === 'DYNAMIC' ? (
|
||||
<CapturingRegexpRow
|
||||
capturingRegexp={capturingRegexp}
|
||||
onChange={onCapturingRegexpChange}
|
||||
/>
|
||||
) : null}
|
||||
<DefaultValueRow
|
||||
kind={kind}
|
||||
defaultValue={defaultValue}
|
||||
previewValues={previewValues}
|
||||
onChange={onDefaultValueChange}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default ListBasicOptions;
|
||||
@@ -0,0 +1,29 @@
|
||||
// eslint-disable-next-line signoz/no-antd-components -- TODO: migrate to @signozhq/ui/switch
|
||||
import { Switch } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import { LabelContainer, VariableItemRow } from '../../../../../DashboardContainer/DashboardSettings/DashboardVariableSettings/VariableItem/styles';
|
||||
|
||||
interface Props {
|
||||
allowAllValue: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
}
|
||||
|
||||
function AllOptionRow({ allowAllValue, onChange }: Props): JSX.Element {
|
||||
return (
|
||||
<VariableItemRow className="all-option-section">
|
||||
<LabelContainer>
|
||||
<Typography className="typography-variables">
|
||||
Include an option for ALL values
|
||||
</Typography>
|
||||
</LabelContainer>
|
||||
<Switch
|
||||
checked={allowAllValue}
|
||||
onChange={onChange}
|
||||
data-testid="variable-allow-all-v2"
|
||||
/>
|
||||
</VariableItemRow>
|
||||
);
|
||||
}
|
||||
|
||||
export default AllOptionRow;
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Input } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import { LabelContainer, VariableItemRow } from '../../../../../DashboardContainer/DashboardSettings/DashboardVariableSettings/VariableItem/styles';
|
||||
|
||||
interface Props {
|
||||
capturingRegexp: string;
|
||||
onChange: (v: string) => void;
|
||||
}
|
||||
|
||||
function CapturingRegexpRow({
|
||||
capturingRegexp,
|
||||
onChange,
|
||||
}: Props): JSX.Element {
|
||||
return (
|
||||
<VariableItemRow className="capturing-regexp-section">
|
||||
<LabelContainer>
|
||||
<Typography
|
||||
className="typography-variables"
|
||||
style={{ display: 'block' }}
|
||||
>
|
||||
Capturing regex
|
||||
</Typography>
|
||||
<Typography
|
||||
className="default-value-description"
|
||||
style={{ display: 'block' }}
|
||||
>
|
||||
Regex applied to each value; the first capture group becomes the
|
||||
selectable option.
|
||||
</Typography>
|
||||
</LabelContainer>
|
||||
<Input
|
||||
value={capturingRegexp}
|
||||
placeholder="e.g. env-(.*)-\\d+"
|
||||
onChange={(e): void => onChange(e.target.value)}
|
||||
style={{ width: 400 }}
|
||||
data-testid="variable-capturing-regexp-v2"
|
||||
/>
|
||||
</VariableItemRow>
|
||||
);
|
||||
}
|
||||
|
||||
export default CapturingRegexpRow;
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Input } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import { LabelContainer, VariableItemRow } from '../../../../../DashboardContainer/DashboardSettings/DashboardVariableSettings/VariableItem/styles';
|
||||
|
||||
interface Props {
|
||||
customAllValue: string;
|
||||
onChange: (v: string) => void;
|
||||
}
|
||||
|
||||
function CustomAllValueRow({
|
||||
customAllValue,
|
||||
onChange,
|
||||
}: Props): JSX.Element {
|
||||
return (
|
||||
<VariableItemRow className="custom-all-value-section">
|
||||
<LabelContainer>
|
||||
<Typography
|
||||
className="typography-variables"
|
||||
style={{ display: 'block' }}
|
||||
>
|
||||
Custom "ALL" value
|
||||
</Typography>
|
||||
<Typography
|
||||
className="default-value-description"
|
||||
style={{ display: 'block' }}
|
||||
>
|
||||
Literal value emitted when the user picks ALL (e.g. * or .*).
|
||||
</Typography>
|
||||
</LabelContainer>
|
||||
<Input
|
||||
value={customAllValue}
|
||||
placeholder="Leave blank to send the full union of values"
|
||||
onChange={(e): void => onChange(e.target.value)}
|
||||
style={{ width: 400 }}
|
||||
data-testid="variable-custom-all-value-v2"
|
||||
/>
|
||||
</VariableItemRow>
|
||||
);
|
||||
}
|
||||
|
||||
export default CustomAllValueRow;
|
||||
@@ -0,0 +1,43 @@
|
||||
import CustomSelect from 'components/NewSelect/CustomSelect';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import { LabelContainer, VariableItemRow } from '../../../../../DashboardContainer/DashboardSettings/DashboardVariableSettings/VariableItem/styles';
|
||||
import type { V2VariableKind } from '../../types';
|
||||
|
||||
interface Props {
|
||||
kind: V2VariableKind;
|
||||
defaultValue: string;
|
||||
previewValues: string[];
|
||||
onChange: (v: string) => void;
|
||||
}
|
||||
|
||||
function DefaultValueRow({
|
||||
kind,
|
||||
defaultValue,
|
||||
previewValues,
|
||||
onChange,
|
||||
}: Props): JSX.Element {
|
||||
const description =
|
||||
kind === 'QUERY'
|
||||
? 'Click Test Run Query to see the values or add custom value'
|
||||
: 'Select a value from the preview values or add custom value';
|
||||
|
||||
return (
|
||||
<VariableItemRow className="default-value-section">
|
||||
<LabelContainer>
|
||||
<Typography className="typography-variables">Default Value</Typography>
|
||||
<Typography className="default-value-description">
|
||||
{description}
|
||||
</Typography>
|
||||
</LabelContainer>
|
||||
<CustomSelect
|
||||
placeholder="Select a default value"
|
||||
value={defaultValue}
|
||||
onChange={(v): void => onChange((v as string) ?? '')}
|
||||
options={previewValues.map((v) => ({ label: v, value: v }))}
|
||||
/>
|
||||
</VariableItemRow>
|
||||
);
|
||||
}
|
||||
|
||||
export default DefaultValueRow;
|
||||
@@ -0,0 +1,29 @@
|
||||
// eslint-disable-next-line signoz/no-antd-components -- TODO: migrate to @signozhq/ui/switch
|
||||
import { Switch } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import { LabelContainer, VariableItemRow } from '../../../../../DashboardContainer/DashboardSettings/DashboardVariableSettings/VariableItem/styles';
|
||||
|
||||
interface Props {
|
||||
allowMultiple: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
}
|
||||
|
||||
function MultiSelectRow({ allowMultiple, onChange }: Props): JSX.Element {
|
||||
return (
|
||||
<VariableItemRow className="multiple-values-section">
|
||||
<LabelContainer>
|
||||
<Typography className="typography-variables">
|
||||
Enable multiple values to be checked
|
||||
</Typography>
|
||||
</LabelContainer>
|
||||
<Switch
|
||||
checked={allowMultiple}
|
||||
onChange={onChange}
|
||||
data-testid="variable-allow-multiple-v2"
|
||||
/>
|
||||
</VariableItemRow>
|
||||
);
|
||||
}
|
||||
|
||||
export default MultiSelectRow;
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Select } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import { LabelContainer, VariableItemRow } from '../../../../../DashboardContainer/DashboardSettings/DashboardVariableSettings/VariableItem/styles';
|
||||
import { SORT_OPTIONS } from '../../types';
|
||||
|
||||
interface Props {
|
||||
sort: string;
|
||||
onChange: (v: string) => void;
|
||||
}
|
||||
|
||||
function SortRow({ sort, onChange }: Props): JSX.Element {
|
||||
return (
|
||||
<VariableItemRow className="sort-values-section">
|
||||
<LabelContainer>
|
||||
<Typography className="typography-variables">Sort Values</Typography>
|
||||
</LabelContainer>
|
||||
<Select
|
||||
value={sort}
|
||||
onChange={onChange}
|
||||
options={SORT_OPTIONS}
|
||||
className="sort-input"
|
||||
data-testid="variable-sort-v2"
|
||||
/>
|
||||
</VariableItemRow>
|
||||
);
|
||||
}
|
||||
|
||||
export default SortRow;
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Input } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import { LabelContainer, VariableItemRow } from '../../../../DashboardContainer/DashboardSettings/DashboardVariableSettings/VariableItem/styles';
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
description: string;
|
||||
onNameChange: (v: string) => void;
|
||||
onDescriptionChange: (v: string) => void;
|
||||
nameError?: string;
|
||||
}
|
||||
|
||||
function NameDisplay({
|
||||
name,
|
||||
description,
|
||||
onNameChange,
|
||||
onDescriptionChange,
|
||||
nameError,
|
||||
}: Props): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<VariableItemRow className="variable-name-section">
|
||||
<LabelContainer>
|
||||
<Typography className="typography-variables">Name</Typography>
|
||||
</LabelContainer>
|
||||
<div>
|
||||
<Input
|
||||
placeholder="Unique name of the variable"
|
||||
value={name}
|
||||
className="name-input"
|
||||
onChange={(e): void => onNameChange(e.target.value)}
|
||||
data-testid="variable-name-v2"
|
||||
/>
|
||||
{nameError ? (
|
||||
<div>
|
||||
<Typography.Text color="warning">{nameError}</Typography.Text>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</VariableItemRow>
|
||||
<VariableItemRow className="variable-description-section">
|
||||
<LabelContainer>
|
||||
<Typography className="typography-variables">Description</Typography>
|
||||
</LabelContainer>
|
||||
<Input.TextArea
|
||||
value={description}
|
||||
placeholder="Enter a description for the variable"
|
||||
className="description-input"
|
||||
rows={3}
|
||||
onChange={(e): void => onDescriptionChange(e.target.value)}
|
||||
data-testid="variable-description-v2"
|
||||
/>
|
||||
</VariableItemRow>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default NameDisplay;
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { orange } from '@ant-design/colors';
|
||||
|
||||
import { LabelContainer, VariableItemRow } from '../../../../DashboardContainer/DashboardSettings/DashboardVariableSettings/VariableItem/styles';
|
||||
|
||||
interface Props {
|
||||
previewValues: string[];
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
function PreviewValues({ previewValues, error }: Props): JSX.Element {
|
||||
return (
|
||||
<VariableItemRow className="variables-preview-section">
|
||||
<LabelContainer style={{ width: '100%' }}>
|
||||
<Typography className="typography-variables">
|
||||
Preview of Values
|
||||
</Typography>
|
||||
</LabelContainer>
|
||||
<div className="preview-values">
|
||||
{error ? (
|
||||
<Typography style={{ color: orange[5] }}>{error}</Typography>
|
||||
) : (
|
||||
previewValues.map((v, idx) => (
|
||||
<Badge key={`${v}${idx}`}>{v.toString()}</Badge>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</VariableItemRow>
|
||||
);
|
||||
}
|
||||
|
||||
export default PreviewValues;
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Button } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import Editor from 'components/Editor';
|
||||
|
||||
import { LabelContainer } from '../../../../DashboardContainer/DashboardSettings/DashboardVariableSettings/VariableItem/styles';
|
||||
|
||||
interface Props {
|
||||
queryValue: string;
|
||||
onChange: (v: string) => void;
|
||||
onTestRun?: () => void;
|
||||
testRunLoading?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function QueryFields({
|
||||
queryValue,
|
||||
onChange,
|
||||
onTestRun,
|
||||
testRunLoading,
|
||||
error,
|
||||
}: Props): JSX.Element {
|
||||
return (
|
||||
<div className="query-container">
|
||||
<LabelContainer>
|
||||
<Typography>Query</Typography>
|
||||
</LabelContainer>
|
||||
|
||||
<div style={{ flex: 1, position: 'relative' }}>
|
||||
<Editor
|
||||
language="sql"
|
||||
value={queryValue}
|
||||
onChange={onChange}
|
||||
height="240px"
|
||||
options={{
|
||||
fontSize: 13,
|
||||
wordWrap: 'on',
|
||||
lineNumbers: 'off',
|
||||
glyphMargin: false,
|
||||
folding: false,
|
||||
lineDecorationsWidth: 0,
|
||||
lineNumbersMinChars: 0,
|
||||
minimap: { enabled: false },
|
||||
}}
|
||||
/>
|
||||
{onTestRun ? (
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
onClick={onTestRun}
|
||||
style={{ position: 'absolute', bottom: 0 }}
|
||||
loading={testRunLoading}
|
||||
>
|
||||
Test Run Query
|
||||
</Button>
|
||||
) : null}
|
||||
{error ? (
|
||||
<div>
|
||||
<Typography.Text color="warning">{error}</Typography.Text>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default QueryFields;
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Input } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import { LabelContainer, VariableItemRow } from '../../../../DashboardContainer/DashboardSettings/DashboardVariableSettings/VariableItem/styles';
|
||||
|
||||
interface Props {
|
||||
textValue: string;
|
||||
onChange: (v: string) => void;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function TextFields({ textValue, onChange, error }: Props): JSX.Element {
|
||||
return (
|
||||
<VariableItemRow className="variable-textbox-section">
|
||||
<LabelContainer>
|
||||
<Typography className="typography-variables">Default Value</Typography>
|
||||
</LabelContainer>
|
||||
<div>
|
||||
<Input
|
||||
value={textValue}
|
||||
className="default-input"
|
||||
onChange={(e): void => onChange(e.target.value)}
|
||||
placeholder="Enter a default value (if any)..."
|
||||
style={{ width: 400 }}
|
||||
data-testid="variable-text-value-v2"
|
||||
/>
|
||||
{error ? (
|
||||
<div>
|
||||
<Typography.Text color="warning">{error}</Typography.Text>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</VariableItemRow>
|
||||
);
|
||||
}
|
||||
|
||||
export default TextFields;
|
||||
@@ -0,0 +1,127 @@
|
||||
import { Button } from 'antd';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import {
|
||||
ClipboardType,
|
||||
DatabaseZap,
|
||||
Info,
|
||||
LayoutList,
|
||||
Pyramid,
|
||||
} from '@signozhq/icons';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import cx from 'classnames';
|
||||
import TextToolTip from 'components/TextToolTip';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
|
||||
import { LabelContainer, VariableItemRow } from '../../../../DashboardContainer/DashboardSettings/DashboardVariableSettings/VariableItem/styles';
|
||||
import type { V2VariableKind } from '../types';
|
||||
|
||||
import '../../../../DashboardContainer/DashboardSettings/DashboardVariableSettings/VariableItem/VariableItem.styles.scss';
|
||||
|
||||
interface Props {
|
||||
kind: V2VariableKind;
|
||||
onChange: (kind: V2VariableKind) => void;
|
||||
}
|
||||
|
||||
function TypeSelector({ kind, onChange }: Props): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
return (
|
||||
<VariableItemRow className="variable-type-section">
|
||||
<LabelContainer className="variable-type-label-container">
|
||||
<Typography className="typography-variables">Variable Type</Typography>
|
||||
<TextToolTip
|
||||
text="Learn more about supported variable types"
|
||||
url="https://signoz.io/docs/userguide/manage-variables/#supported-variable-types"
|
||||
urlText="here"
|
||||
useFilledIcon={false}
|
||||
outlinedIcon={
|
||||
<Info
|
||||
size={14}
|
||||
style={{
|
||||
color: isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_500,
|
||||
marginTop: 1,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</LabelContainer>
|
||||
|
||||
<div className="variable-type-btn-group">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<Pyramid size={14} />}
|
||||
className={cx(
|
||||
'variable-type-btn',
|
||||
kind === 'DYNAMIC' ? 'selected' : '',
|
||||
)}
|
||||
onClick={(): void => onChange('DYNAMIC')}
|
||||
data-testid="variable-type-dynamic-v2"
|
||||
>
|
||||
Dynamic
|
||||
<Badge className="sidenav-beta-tag" color="robin">
|
||||
Beta
|
||||
</Badge>
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ClipboardType size={14} />}
|
||||
className={cx(
|
||||
'variable-type-btn',
|
||||
kind === 'TEXT' ? 'selected' : '',
|
||||
)}
|
||||
onClick={(): void => onChange('TEXT')}
|
||||
data-testid="variable-type-text-v2"
|
||||
>
|
||||
Textbox
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<LayoutList size={14} />}
|
||||
className={cx(
|
||||
'variable-type-btn',
|
||||
kind === 'CUSTOM' ? 'selected' : '',
|
||||
)}
|
||||
onClick={(): void => onChange('CUSTOM')}
|
||||
data-testid="variable-type-custom-v2"
|
||||
>
|
||||
Custom
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<DatabaseZap size={14} />}
|
||||
className={cx(
|
||||
'variable-type-btn',
|
||||
kind === 'QUERY' ? 'selected' : '',
|
||||
)}
|
||||
onClick={(): void => onChange('QUERY')}
|
||||
data-testid="variable-type-query-v2"
|
||||
>
|
||||
Query
|
||||
<Badge className="sidenav-beta-tag" color="warning">
|
||||
Not Recommended
|
||||
</Badge>
|
||||
<div onClick={(e): void => e.stopPropagation()}>
|
||||
<TextToolTip
|
||||
text="Learn why we don't recommend"
|
||||
url="https://signoz.io/docs/userguide/manage-variables/#why-avoid-clickhouse-query-variables"
|
||||
urlText="here"
|
||||
useFilledIcon={false}
|
||||
outlinedIcon={
|
||||
<Info
|
||||
size={14}
|
||||
style={{
|
||||
color: isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_500,
|
||||
marginTop: 1,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</VariableItemRow>
|
||||
);
|
||||
}
|
||||
|
||||
export default TypeSelector;
|
||||
@@ -0,0 +1,188 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { Button } from 'antd';
|
||||
import { ArrowLeft } from '@signozhq/icons';
|
||||
import { commaValuesParser } from 'lib/dashboardVariables/customCommaValuesParser';
|
||||
|
||||
import { draftToVariableDTO, validateDraft } from '../draft';
|
||||
import type { SaveCallback, VariableDraft, V2VariableKind } from '../types';
|
||||
import CustomFields from './CustomFields';
|
||||
import DynamicFields from './DynamicFields';
|
||||
import Footer from './Footer';
|
||||
import ListBasicOptions from './ListBasicOptions';
|
||||
import NameDisplay from './NameDisplay';
|
||||
import PreviewValues from './PreviewValues';
|
||||
import QueryFields from './QueryFields';
|
||||
import TextFields from './TextFields';
|
||||
import TypeSelector from './TypeSelector';
|
||||
|
||||
import '../../../../DashboardContainer/DashboardSettings/DashboardVariableSettings/VariableItem/VariableItem.styles.scss';
|
||||
|
||||
interface Props {
|
||||
initialDraft: VariableDraft;
|
||||
existingNames: string[];
|
||||
saving: boolean;
|
||||
onSave: SaveCallback;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Editor for a single V2 variable.
|
||||
*
|
||||
* Type-switch contract: changing `kind` does NOT clear the per-kind fields
|
||||
* the user already typed. They remain in local state and are restored if the
|
||||
* user navigates back to the same kind. Only the fields relevant to the
|
||||
* active `kind` are written into the V2 envelope on save (see
|
||||
* `draftToVariableDTO`).
|
||||
*/
|
||||
function VariableItem({
|
||||
initialDraft,
|
||||
existingNames,
|
||||
saving,
|
||||
onSave,
|
||||
onCancel,
|
||||
}: Props): JSX.Element {
|
||||
const [draft, setDraft] = useState<VariableDraft>(initialDraft);
|
||||
|
||||
const update = useCallback(
|
||||
<K extends keyof VariableDraft>(key: K, value: VariableDraft[K]): void => {
|
||||
setDraft((prev) => ({ ...prev, [key]: value }));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const onKindChange = useCallback(
|
||||
(kind: V2VariableKind): void => {
|
||||
// Retain every other field — only the discriminator changes.
|
||||
update('kind', kind);
|
||||
},
|
||||
[update],
|
||||
);
|
||||
|
||||
const namesExcludingSelf = useMemo(
|
||||
() => existingNames.filter((n) => n !== initialDraft.name),
|
||||
[existingNames, initialDraft.name],
|
||||
);
|
||||
const validationError = useMemo(
|
||||
() => validateDraft(draft, namesExcludingSelf),
|
||||
[draft, namesExcludingSelf],
|
||||
);
|
||||
|
||||
// Local preview values — currently populated only for CUSTOM (CSV parse).
|
||||
// Query / Dynamic previews are wired in the variable execution subsystem.
|
||||
const previewValues = useMemo<string[]>(() => {
|
||||
if (draft.kind === 'CUSTOM') {
|
||||
return commaValuesParser(draft.customValue).map((v) => String(v));
|
||||
}
|
||||
return [];
|
||||
}, [draft.kind, draft.customValue]);
|
||||
|
||||
const handleSave = useCallback((): void => {
|
||||
if (validationError) {return;}
|
||||
onSave(draftToVariableDTO(draft));
|
||||
}, [draft, validationError, onSave]);
|
||||
|
||||
const errorFor = (
|
||||
field: NonNullable<typeof validationError>['field'],
|
||||
): string | undefined => {
|
||||
if (validationError && validationError.field === field) {
|
||||
return validationError.message;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const showListOptions =
|
||||
draft.kind === 'QUERY' || draft.kind === 'CUSTOM' || draft.kind === 'DYNAMIC';
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="variable-item-container">
|
||||
<div className="all-variables">
|
||||
<Button
|
||||
type="text"
|
||||
className="all-variables-btn"
|
||||
icon={<ArrowLeft size={14} />}
|
||||
onClick={onCancel}
|
||||
>
|
||||
All variables
|
||||
</Button>
|
||||
</div>
|
||||
<div className="variable-item-content">
|
||||
<NameDisplay
|
||||
name={draft.name}
|
||||
description={draft.displayName}
|
||||
onNameChange={(v): void => update('name', v)}
|
||||
onDescriptionChange={(v): void => update('displayName', v)}
|
||||
nameError={errorFor('name')}
|
||||
/>
|
||||
|
||||
<TypeSelector kind={draft.kind} onChange={onKindChange} />
|
||||
|
||||
{draft.kind === 'DYNAMIC' ? (
|
||||
<DynamicFields
|
||||
dynamicName={draft.dynamicName}
|
||||
dynamicSignal={draft.dynamicSignal}
|
||||
onNameChange={(v): void => update('dynamicName', v)}
|
||||
onSignalChange={(v): void => update('dynamicSignal', v)}
|
||||
error={errorFor('dynamicName')}
|
||||
/>
|
||||
) : null}
|
||||
{draft.kind === 'QUERY' ? (
|
||||
<QueryFields
|
||||
queryValue={draft.queryValue}
|
||||
onChange={(v): void => update('queryValue', v)}
|
||||
error={errorFor('queryValue')}
|
||||
/>
|
||||
) : null}
|
||||
{draft.kind === 'CUSTOM' ? (
|
||||
<CustomFields
|
||||
customValue={draft.customValue}
|
||||
onChange={(v): void => update('customValue', v)}
|
||||
error={errorFor('customValue')}
|
||||
/>
|
||||
) : null}
|
||||
{draft.kind === 'TEXT' ? (
|
||||
<TextFields
|
||||
textValue={draft.textValue}
|
||||
onChange={(v): void => update('textValue', v)}
|
||||
error={errorFor('textValue')}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{showListOptions ? (
|
||||
<>
|
||||
<PreviewValues previewValues={previewValues} />
|
||||
<ListBasicOptions
|
||||
kind={draft.kind}
|
||||
allowAllValue={draft.allowAllValue}
|
||||
allowMultiple={draft.allowMultiple}
|
||||
sort={draft.sort}
|
||||
defaultValue={draft.defaultValue}
|
||||
customAllValue={draft.customAllValue}
|
||||
capturingRegexp={draft.capturingRegexp}
|
||||
previewValues={previewValues}
|
||||
onAllowAllChange={(v): void => update('allowAllValue', v)}
|
||||
onAllowMultipleChange={(v): void => update('allowMultiple', v)}
|
||||
onSortChange={(v): void => update('sort', v)}
|
||||
onDefaultValueChange={(v): void => update('defaultValue', v)}
|
||||
onCustomAllValueChange={(v): void =>
|
||||
update('customAllValue', v)
|
||||
}
|
||||
onCapturingRegexpChange={(v): void =>
|
||||
update('capturingRegexp', v)
|
||||
}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<Footer
|
||||
saving={saving}
|
||||
canSave={!validationError}
|
||||
onSave={handleSave}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default VariableItem;
|
||||
@@ -0,0 +1,58 @@
|
||||
import React from 'react';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import type { RowProps } from 'antd';
|
||||
import { GripVertical } from '@signozhq/icons';
|
||||
|
||||
/**
|
||||
* Sortable table row that injects a drag handle into the `name` cell —
|
||||
* matches V1's [DashboardVariableSettings/index.tsx:31](TableRow component).
|
||||
*/
|
||||
function TableRow({ children, ...props }: RowProps): JSX.Element {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
setActivatorNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({
|
||||
// @ts-expect-error — antd Table's RowProps doesn't type the data-row-key it injects
|
||||
id: props['data-row-key'],
|
||||
});
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
...props.style,
|
||||
transform: CSS.Transform.toString(transform && { ...transform, scaleY: 1 }),
|
||||
transition,
|
||||
...(isDragging ? { position: 'relative', zIndex: 9999 } : {}),
|
||||
};
|
||||
|
||||
return (
|
||||
<tr {...props} ref={setNodeRef} style={style} {...attributes}>
|
||||
{React.Children.map(children, (child) => {
|
||||
const childElement = child as React.ReactElement;
|
||||
if (childElement.key === 'name') {
|
||||
return React.cloneElement(childElement, {
|
||||
key: 'name-with-drag',
|
||||
children: (
|
||||
<div className="variable-name-drag">
|
||||
<GripVertical
|
||||
ref={setActivatorNodeRef as unknown as React.Ref<SVGSVGElement>}
|
||||
style={{ touchAction: 'none', cursor: 'move' }}
|
||||
size="md"
|
||||
{...listeners}
|
||||
/>
|
||||
{child}
|
||||
</div>
|
||||
),
|
||||
});
|
||||
}
|
||||
return childElement;
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
export default TableRow;
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Button, Space } from 'antd';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { PenLine, Trash2 } from '@signozhq/icons';
|
||||
|
||||
interface Props {
|
||||
description: string;
|
||||
kindLabel: string;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Right cell of the variable table — description text + edit/delete actions.
|
||||
* Variable name + kind tag render in the left cell via column config.
|
||||
*/
|
||||
function VariableRow({
|
||||
description,
|
||||
kindLabel,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: Props): JSX.Element {
|
||||
return (
|
||||
<div className="variable-description-actions">
|
||||
<Typography.Text className="variable-description">
|
||||
{description}
|
||||
</Typography.Text>
|
||||
<Space className="actions-btns">
|
||||
<Badge color="sienna" variant="outline">
|
||||
{kindLabel}
|
||||
</Badge>
|
||||
<Button
|
||||
type="text"
|
||||
onClick={onEdit}
|
||||
className="edit-variable-button"
|
||||
data-testid="variable-edit-v2"
|
||||
>
|
||||
<PenLine size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
onClick={onDelete}
|
||||
className="delete-variable-button"
|
||||
data-testid="variable-delete-v2"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default VariableRow;
|
||||
@@ -0,0 +1,119 @@
|
||||
import { Empty, Table } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import {
|
||||
DndContext,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
} from '@dnd-kit/core';
|
||||
import { restrictToVerticalAxis } from '@dnd-kit/modifiers';
|
||||
import { arrayMove, SortableContext } from '@dnd-kit/sortable';
|
||||
import type { DashboardtypesVariableDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { getVariableKindLabel, getVariableName } from '../draft';
|
||||
import TableRow from './TableRow';
|
||||
import VariableRow from './VariableRow';
|
||||
|
||||
import '../../../../DashboardContainer/DashboardSettings/DashboardSettings.styles.scss';
|
||||
|
||||
interface TableEntry {
|
||||
key: string;
|
||||
name: string;
|
||||
description: string;
|
||||
kindLabel: string;
|
||||
index: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
variables: DashboardtypesVariableDTO[];
|
||||
onEdit: (index: number) => void;
|
||||
onDelete: (index: number) => void;
|
||||
onReorder: (next: DashboardtypesVariableDTO[]) => void;
|
||||
}
|
||||
|
||||
function VariableList({
|
||||
variables,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onReorder,
|
||||
}: Props): JSX.Element {
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: { distance: 1 },
|
||||
}),
|
||||
);
|
||||
|
||||
if (variables.length === 0) {
|
||||
return (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description={
|
||||
<Typography.Text>
|
||||
No variables yet. Click "Add variable" to create one.
|
||||
</Typography.Text>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const dataSource: TableEntry[] = variables.map((v, idx) => ({
|
||||
key: getVariableName(v) || String(idx),
|
||||
name: getVariableName(v),
|
||||
description:
|
||||
(v.spec as { display?: { name?: string } })?.display?.name ?? '',
|
||||
kindLabel: getVariableKindLabel(v),
|
||||
index: idx,
|
||||
}));
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: 'Variable',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
width: '50%',
|
||||
},
|
||||
{
|
||||
title: 'Description',
|
||||
key: 'description',
|
||||
width: '50%',
|
||||
render: (entry: TableEntry): JSX.Element => (
|
||||
<VariableRow
|
||||
description={entry.description}
|
||||
kindLabel={entry.kindLabel}
|
||||
onEdit={(): void => onEdit(entry.index)}
|
||||
onDelete={(): void => onDelete(entry.index)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const onDragEnd = ({ active, over }: DragEndEvent): void => {
|
||||
if (!over || active.id === over.id) {return;}
|
||||
const fromIdx = dataSource.findIndex((d) => d.key === active.id);
|
||||
const toIdx = dataSource.findIndex((d) => d.key === over.id);
|
||||
if (fromIdx < 0 || toIdx < 0) {return;}
|
||||
onReorder(arrayMove(variables, fromIdx, toIdx));
|
||||
};
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
modifiers={[restrictToVerticalAxis]}
|
||||
onDragEnd={onDragEnd}
|
||||
>
|
||||
<SortableContext items={dataSource.map((d) => d.key)}>
|
||||
<Table
|
||||
components={{ body: { row: TableRow } }}
|
||||
rowKey="key"
|
||||
columns={columns}
|
||||
pagination={false}
|
||||
dataSource={dataSource}
|
||||
className="dashboard-variable-settings-table"
|
||||
/>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
|
||||
export default VariableList;
|
||||
@@ -0,0 +1,202 @@
|
||||
import { v4 as generateUUID } from 'uuid';
|
||||
import type {
|
||||
DashboardtypesVariableDTO,
|
||||
DashboardtypesVariablePluginDTO,
|
||||
DashboardtypesListVariableSpecDTO,
|
||||
DashboardTextVariableSpecDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { V2VariableKind, VariableDraft } from './types';
|
||||
|
||||
export function emptyDraft(): VariableDraft {
|
||||
return {
|
||||
id: generateUUID(),
|
||||
kind: 'QUERY',
|
||||
name: '',
|
||||
displayName: '',
|
||||
allowAllValue: false,
|
||||
allowMultiple: false,
|
||||
sort: 'none',
|
||||
defaultValue: '',
|
||||
customAllValue: '',
|
||||
capturingRegexp: '',
|
||||
queryValue: '',
|
||||
customValue: '',
|
||||
dynamicName: '',
|
||||
dynamicSignal: undefined,
|
||||
textValue: '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hydrate the relevant slot from a V2 envelope; other slots stay empty.
|
||||
*/
|
||||
export function variableDTOToDraft(
|
||||
dto: DashboardtypesVariableDTO,
|
||||
): VariableDraft {
|
||||
const base = emptyDraft();
|
||||
if (dto.kind === 'TextVariable') {
|
||||
const spec = dto.spec as DashboardTextVariableSpecDTO;
|
||||
return {
|
||||
...base,
|
||||
kind: 'TEXT',
|
||||
name: spec?.name ?? '',
|
||||
displayName: spec?.display?.name ?? '',
|
||||
textValue: spec?.value ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
// ListVariable
|
||||
const spec = dto.spec as DashboardtypesListVariableSpecDTO;
|
||||
const pluginKind = spec?.plugin?.kind;
|
||||
let kind: V2VariableKind = 'QUERY';
|
||||
if (pluginKind === 'signoz/DynamicVariable') {kind = 'DYNAMIC';}
|
||||
else if (pluginKind === 'signoz/CustomVariable') {kind = 'CUSTOM';}
|
||||
else if (pluginKind === 'signoz/QueryVariable') {kind = 'QUERY';}
|
||||
|
||||
const draft: VariableDraft = {
|
||||
...base,
|
||||
kind,
|
||||
name: spec?.name ?? '',
|
||||
displayName: spec?.display?.name ?? '',
|
||||
allowAllValue: !!spec?.allowAllValue,
|
||||
allowMultiple: !!spec?.allowMultiple,
|
||||
sort: spec?.sort ?? 'none',
|
||||
defaultValue: typeof spec?.defaultValue === 'string' ? spec.defaultValue : '',
|
||||
customAllValue: spec?.customAllValue ?? '',
|
||||
capturingRegexp: spec?.capturingRegexp ?? '',
|
||||
};
|
||||
|
||||
const pluginSpec = spec?.plugin?.spec as Record<string, unknown> | undefined;
|
||||
if (kind === 'QUERY') {
|
||||
draft.queryValue = (pluginSpec?.queryValue as string) ?? '';
|
||||
} else if (kind === 'CUSTOM') {
|
||||
draft.customValue = (pluginSpec?.customValue as string) ?? '';
|
||||
} else if (kind === 'DYNAMIC') {
|
||||
draft.dynamicName = (pluginSpec?.name as string) ?? '';
|
||||
draft.dynamicSignal = pluginSpec?.signal as TelemetrytypesSignalDTO | undefined;
|
||||
}
|
||||
return draft;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize draft to a V2 envelope, reading ONLY the fields relevant to the
|
||||
* active kind. Other fields the user touched stay in React state and are
|
||||
* silently dropped.
|
||||
*/
|
||||
export function draftToVariableDTO(
|
||||
draft: VariableDraft,
|
||||
): DashboardtypesVariableDTO {
|
||||
const display = draft.displayName ? { name: draft.displayName } : undefined;
|
||||
|
||||
if (draft.kind === 'TEXT') {
|
||||
return ({
|
||||
kind: 'TextVariable',
|
||||
spec: {
|
||||
name: draft.name,
|
||||
display,
|
||||
value: draft.textValue,
|
||||
},
|
||||
} as unknown) as DashboardtypesVariableDTO;
|
||||
}
|
||||
|
||||
let plugin: DashboardtypesVariablePluginDTO | undefined;
|
||||
if (draft.kind === 'QUERY') {
|
||||
plugin = ({
|
||||
kind: 'signoz/QueryVariable',
|
||||
spec: { queryValue: draft.queryValue },
|
||||
} as unknown) as DashboardtypesVariablePluginDTO;
|
||||
} else if (draft.kind === 'CUSTOM') {
|
||||
plugin = ({
|
||||
kind: 'signoz/CustomVariable',
|
||||
spec: { customValue: draft.customValue },
|
||||
} as unknown) as DashboardtypesVariablePluginDTO;
|
||||
} else if (draft.kind === 'DYNAMIC') {
|
||||
plugin = ({
|
||||
kind: 'signoz/DynamicVariable',
|
||||
spec: {
|
||||
name: draft.dynamicName,
|
||||
signal: draft.dynamicSignal,
|
||||
},
|
||||
} as unknown) as DashboardtypesVariablePluginDTO;
|
||||
}
|
||||
|
||||
const spec: DashboardtypesListVariableSpecDTO = {
|
||||
name: draft.name,
|
||||
display,
|
||||
allowAllValue: draft.allowAllValue,
|
||||
allowMultiple: draft.allowMultiple,
|
||||
sort: draft.sort,
|
||||
plugin,
|
||||
// VariableDefaultValueDTO is an open `{[key]: unknown}` shape, so a bare
|
||||
// string isn't structurally assignable. We cast at the boundary.
|
||||
defaultValue: draft.defaultValue
|
||||
? ((draft.defaultValue as unknown) as DashboardtypesListVariableSpecDTO['defaultValue'])
|
||||
: undefined,
|
||||
customAllValue: draft.customAllValue || undefined,
|
||||
capturingRegexp: draft.capturingRegexp || undefined,
|
||||
};
|
||||
|
||||
return ({
|
||||
kind: 'ListVariable',
|
||||
spec,
|
||||
} as unknown) as DashboardtypesVariableDTO;
|
||||
}
|
||||
|
||||
export interface DraftValidationError {
|
||||
field:
|
||||
| 'name'
|
||||
| 'queryValue'
|
||||
| 'customValue'
|
||||
| 'dynamicName'
|
||||
| 'textValue'
|
||||
| 'cycle';
|
||||
message: string;
|
||||
}
|
||||
|
||||
export function validateDraft(
|
||||
draft: VariableDraft,
|
||||
existingNames: string[],
|
||||
): DraftValidationError | null {
|
||||
const trimmedName = draft.name.trim();
|
||||
if (!trimmedName) {
|
||||
return { field: 'name', message: 'Variable name is required' };
|
||||
}
|
||||
if (/\s/.test(trimmedName)) {
|
||||
return { field: 'name', message: 'Variable name cannot contain whitespace' };
|
||||
}
|
||||
if (existingNames.includes(trimmedName)) {
|
||||
return { field: 'name', message: 'Variable name already exists' };
|
||||
}
|
||||
|
||||
if (draft.kind === 'QUERY' && !draft.queryValue.trim()) {
|
||||
return { field: 'queryValue', message: 'Query is required' };
|
||||
}
|
||||
if (draft.kind === 'CUSTOM' && !draft.customValue.trim()) {
|
||||
return { field: 'customValue', message: 'Custom values are required' };
|
||||
}
|
||||
if (draft.kind === 'DYNAMIC' && !draft.dynamicName.trim()) {
|
||||
return { field: 'dynamicName', message: 'Attribute name is required' };
|
||||
}
|
||||
if (draft.kind === 'TEXT' && !draft.textValue.trim()) {
|
||||
return { field: 'textValue', message: 'Default text value is required' };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getVariableName(dto: DashboardtypesVariableDTO): string {
|
||||
if (dto.kind === 'TextVariable') {
|
||||
return (dto.spec as DashboardTextVariableSpecDTO)?.name ?? '';
|
||||
}
|
||||
return (dto.spec as DashboardtypesListVariableSpecDTO)?.name ?? '';
|
||||
}
|
||||
|
||||
export function getVariableKindLabel(dto: DashboardtypesVariableDTO): string {
|
||||
if (dto.kind === 'TextVariable') {return 'Text';}
|
||||
const spec = dto.spec as DashboardtypesListVariableSpecDTO;
|
||||
const pluginKind = spec?.plugin?.kind;
|
||||
if (pluginKind === 'signoz/DynamicVariable') {return 'Dynamic';}
|
||||
if (pluginKind === 'signoz/CustomVariable') {return 'Custom';}
|
||||
return 'Query';
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { Button } from 'antd';
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import { patchDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import type {
|
||||
DashboardtypesJSONPatchOperationDTO,
|
||||
DashboardtypesVariableDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import {
|
||||
buildDependencyMap,
|
||||
detectCycle,
|
||||
} from '../../DashboardVariablesV2/dependencyGraph';
|
||||
import type { V2Dashboard } from '../../utils';
|
||||
import {
|
||||
emptyDraft,
|
||||
getVariableName,
|
||||
variableDTOToDraft,
|
||||
} from './draft';
|
||||
import type { VariableDraft } from './types';
|
||||
import VariableItem from './VariableItem';
|
||||
import VariableList from './VariableList';
|
||||
|
||||
interface Props {
|
||||
dashboard: V2Dashboard | undefined;
|
||||
onRefetch: () => void;
|
||||
}
|
||||
|
||||
type EditorState =
|
||||
| { kind: 'closed' }
|
||||
| { kind: 'add'; draft: VariableDraft }
|
||||
| { kind: 'edit'; index: number; draft: VariableDraft };
|
||||
|
||||
function VariablesSettingsV2({ dashboard, onRefetch }: Props): JSX.Element {
|
||||
const dashboardId = dashboard?.id ?? '';
|
||||
const variables = useMemo<DashboardtypesVariableDTO[]>(
|
||||
() => dashboard?.spec?.variables ?? [],
|
||||
[dashboard?.spec?.variables],
|
||||
);
|
||||
|
||||
const [editor, setEditor] = useState<EditorState>({ kind: 'closed' });
|
||||
const [saving, setSaving] = useState<boolean>(false);
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
const existingNames = useMemo(() => variables.map(getVariableName), [
|
||||
variables,
|
||||
]);
|
||||
|
||||
const persistVariables = useCallback(
|
||||
async (next: DashboardtypesVariableDTO[]): Promise<void> => {
|
||||
if (!dashboardId) {return;}
|
||||
const cycle = detectCycle(buildDependencyMap(next));
|
||||
if (cycle.hasCycle) {
|
||||
toast.error(
|
||||
`Cyclic variable dependency: ${cycle.cycle?.join(' → ')}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const patch: DashboardtypesJSONPatchOperationDTO[] = [
|
||||
{
|
||||
op: 'replace' as DashboardtypesJSONPatchOperationDTO['op'],
|
||||
path: '/spec/variables',
|
||||
value: next,
|
||||
},
|
||||
];
|
||||
await patchDashboardV2({ id: dashboardId }, patch);
|
||||
toast.success('Variables updated');
|
||||
onRefetch();
|
||||
setEditor({ kind: 'closed' });
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
},
|
||||
[dashboardId, onRefetch, showErrorModal],
|
||||
);
|
||||
|
||||
const handleSave = useCallback(
|
||||
async (dto: DashboardtypesVariableDTO): Promise<void> => {
|
||||
if (editor.kind === 'add') {
|
||||
await persistVariables([...variables, dto]);
|
||||
} else if (editor.kind === 'edit') {
|
||||
const next = variables.slice();
|
||||
next[editor.index] = dto;
|
||||
await persistVariables(next);
|
||||
}
|
||||
},
|
||||
[editor, variables, persistVariables],
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (index: number): Promise<void> => {
|
||||
const next = variables.slice();
|
||||
next.splice(index, 1);
|
||||
await persistVariables(next);
|
||||
},
|
||||
[variables, persistVariables],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 12,
|
||||
padding: 16,
|
||||
}}
|
||||
>
|
||||
{editor.kind === 'closed' ? (
|
||||
<>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<Plus size={14} />}
|
||||
onClick={(): void =>
|
||||
setEditor({ kind: 'add', draft: emptyDraft() })
|
||||
}
|
||||
data-testid="add-variable-v2"
|
||||
>
|
||||
Add variable
|
||||
</Button>
|
||||
</div>
|
||||
<VariableList
|
||||
variables={variables}
|
||||
onEdit={(index): void =>
|
||||
setEditor({
|
||||
kind: 'edit',
|
||||
index,
|
||||
draft: variableDTOToDraft(variables[index]),
|
||||
})
|
||||
}
|
||||
onDelete={handleDelete}
|
||||
onReorder={persistVariables}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<VariableItem
|
||||
initialDraft={editor.draft}
|
||||
existingNames={existingNames}
|
||||
saving={saving}
|
||||
onSave={handleSave}
|
||||
onCancel={(): void => setEditor({ kind: 'closed' })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default VariablesSettingsV2;
|
||||
@@ -0,0 +1,61 @@
|
||||
import type {
|
||||
DashboardtypesVariableDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
export type V2VariableKind = 'QUERY' | 'CUSTOM' | 'DYNAMIC' | 'TEXT';
|
||||
|
||||
/**
|
||||
* Internal editor state. Holds every per-kind field so that switching `kind`
|
||||
* does not discard user input. Only the fields relevant to the active kind
|
||||
* are written into the resulting V2 envelope on save.
|
||||
*/
|
||||
export interface VariableDraft {
|
||||
id: string; // local identifier for list keys; not persisted to V2
|
||||
kind: V2VariableKind;
|
||||
name: string;
|
||||
displayName: string;
|
||||
|
||||
// Shared by all List variants (QUERY / CUSTOM / DYNAMIC)
|
||||
allowAllValue: boolean;
|
||||
allowMultiple: boolean;
|
||||
sort: string;
|
||||
defaultValue: string;
|
||||
// V2-only: literal value emitted when the user picks "ALL"
|
||||
customAllValue: string;
|
||||
// V2-only: regex applied to query/dynamic results to extract the actual value
|
||||
capturingRegexp: string;
|
||||
|
||||
// QUERY
|
||||
queryValue: string;
|
||||
|
||||
// CUSTOM
|
||||
customValue: string;
|
||||
|
||||
// DYNAMIC
|
||||
dynamicName: string;
|
||||
dynamicSignal: TelemetrytypesSignalDTO | undefined;
|
||||
|
||||
// TEXT
|
||||
textValue: string;
|
||||
}
|
||||
|
||||
export type SaveCallback = (dto: DashboardtypesVariableDTO) => void;
|
||||
|
||||
export const VARIABLE_KIND_LABEL: Record<V2VariableKind, string> = {
|
||||
QUERY: 'Query',
|
||||
CUSTOM: 'Custom',
|
||||
DYNAMIC: 'Dynamic',
|
||||
TEXT: 'Text',
|
||||
};
|
||||
|
||||
// V2 supports a finer sort taxonomy than V1: separate alphabetical and
|
||||
// numerical orderings (V1 only exposed Disabled / Ascending / Descending).
|
||||
// Values match the strings used in the perses fixture and backend.
|
||||
export const SORT_OPTIONS: { label: string; value: string }[] = [
|
||||
{ label: 'Disabled', value: 'none' },
|
||||
{ label: 'Alphabetical ascending', value: 'alphabetical-asc' },
|
||||
{ label: 'Alphabetical descending', value: 'alphabetical-desc' },
|
||||
{ label: 'Numerical ascending', value: 'numerical-asc' },
|
||||
{ label: 'Numerical descending', value: 'numerical-desc' },
|
||||
];
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Button, Empty, Tabs } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Braces, Globe, Table } from '@signozhq/icons';
|
||||
|
||||
import '../../DashboardContainer/DashboardSettings/DashboardSettingsContent.styles.scss';
|
||||
|
||||
import GeneralDashboardSettingsV2 from './General';
|
||||
import VariablesSettingsV2 from './Variables';
|
||||
import type { V2Dashboard } from '../utils';
|
||||
|
||||
interface Props {
|
||||
dashboard: V2Dashboard | undefined;
|
||||
onRefetch: () => void;
|
||||
}
|
||||
|
||||
function Placeholder({ message }: { message: string }): JSX.Element {
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description={<Typography.Text>{message}</Typography.Text>}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DashboardSettingsV2({ dashboard, onRefetch }: Props): JSX.Element {
|
||||
const items = [
|
||||
{
|
||||
label: (
|
||||
<Button type="text" icon={<Table size={14} />}>
|
||||
General
|
||||
</Button>
|
||||
),
|
||||
key: 'general',
|
||||
children: (
|
||||
<GeneralDashboardSettingsV2
|
||||
dashboard={dashboard}
|
||||
onRefetch={onRefetch}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<Button type="text" icon={<Braces size={14} />}>
|
||||
Variables
|
||||
</Button>
|
||||
),
|
||||
key: 'variables',
|
||||
children: (
|
||||
<VariablesSettingsV2 dashboard={dashboard} onRefetch={onRefetch} />
|
||||
),
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<Button type="text" icon={<Globe size={14} />}>
|
||||
Publish
|
||||
</Button>
|
||||
),
|
||||
key: 'public-dashboard',
|
||||
children: (
|
||||
<Placeholder message="V2 public dashboard publishing coming next." />
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return <Tabs items={items} />;
|
||||
}
|
||||
|
||||
export default DashboardSettingsV2;
|
||||
@@ -0,0 +1,135 @@
|
||||
import type {
|
||||
DashboardtypesListVariableSpecDTO,
|
||||
DashboardtypesVariableDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { referencedVariables } from './substitution';
|
||||
|
||||
/**
|
||||
* Extracts the strings on a variable that may contain `$var` references —
|
||||
* i.e. the dependency edges out of this variable.
|
||||
*
|
||||
* Currently only QUERY variables produce dependencies (their `queryValue`
|
||||
* may reference other variables). CUSTOM and DYNAMIC plugin specs don't
|
||||
* embed substitutable strings, and TEXT variables are leaf nodes.
|
||||
*/
|
||||
function dependencyStrings(dto: DashboardtypesVariableDTO): string[] {
|
||||
if (dto.kind !== 'ListVariable') {return [];}
|
||||
const spec = dto.spec as DashboardtypesListVariableSpecDTO;
|
||||
const pluginKind = spec?.plugin?.kind;
|
||||
const pluginSpec = spec?.plugin?.spec as Record<string, unknown> | undefined;
|
||||
if (pluginKind === 'signoz/QueryVariable') {
|
||||
return [String(pluginSpec?.queryValue ?? '')];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function nameOf(dto: DashboardtypesVariableDTO): string {
|
||||
return (dto.spec as { name?: string })?.name ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Direct dependencies for each variable (name → set of names it references).
|
||||
*/
|
||||
export function buildDependencyMap(
|
||||
variables: DashboardtypesVariableDTO[],
|
||||
): Record<string, Set<string>> {
|
||||
const knownNames = new Set(variables.map(nameOf).filter(Boolean));
|
||||
const deps: Record<string, Set<string>> = {};
|
||||
variables.forEach((v) => {
|
||||
const name = nameOf(v);
|
||||
if (!name) {return;}
|
||||
const refs = new Set<string>();
|
||||
dependencyStrings(v).forEach((s) => {
|
||||
referencedVariables(s).forEach((ref) => {
|
||||
if (ref !== name && knownNames.has(ref)) {refs.add(ref);}
|
||||
});
|
||||
});
|
||||
deps[name] = refs;
|
||||
});
|
||||
return deps;
|
||||
}
|
||||
|
||||
export interface CycleResult {
|
||||
hasCycle: boolean;
|
||||
cycle?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect a cycle via DFS; returns the participating names in traversal order.
|
||||
* Used at save time and to guard re-resolution.
|
||||
*/
|
||||
export function detectCycle(
|
||||
deps: Record<string, Set<string>>,
|
||||
): CycleResult {
|
||||
const WHITE = 0;
|
||||
const GRAY = 1;
|
||||
const BLACK = 2;
|
||||
const color: Record<string, number> = {};
|
||||
const stack: string[] = [];
|
||||
const names = Object.keys(deps);
|
||||
names.forEach((n) => {
|
||||
color[n] = WHITE;
|
||||
});
|
||||
|
||||
function visit(node: string): string[] | null {
|
||||
color[node] = GRAY;
|
||||
stack.push(node);
|
||||
for (const next of deps[node] ?? []) {
|
||||
if (color[next] === GRAY) {
|
||||
const idx = stack.indexOf(next);
|
||||
return stack.slice(idx).concat(next);
|
||||
}
|
||||
if (color[next] === WHITE) {
|
||||
const found = visit(next);
|
||||
if (found) {return found;}
|
||||
}
|
||||
}
|
||||
stack.pop();
|
||||
color[node] = BLACK;
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const n of names) {
|
||||
if (color[n] === WHITE) {
|
||||
const cycle = visit(n);
|
||||
if (cycle) {return { hasCycle: true, cycle };}
|
||||
}
|
||||
}
|
||||
return { hasCycle: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Kahn's algorithm — returns variable names in dependency order
|
||||
* (dependencies first). If there's a cycle the result excludes the
|
||||
* participating nodes; combine with `detectCycle` for validation.
|
||||
*/
|
||||
export function topoSort(
|
||||
deps: Record<string, Set<string>>,
|
||||
): string[] {
|
||||
const incoming: Record<string, number> = {};
|
||||
const downstream: Record<string, string[]> = {};
|
||||
Object.keys(deps).forEach((n) => {
|
||||
incoming[n] = 0;
|
||||
downstream[n] = [];
|
||||
});
|
||||
Object.entries(deps).forEach(([n, refs]) => {
|
||||
refs.forEach((ref) => {
|
||||
incoming[n] += 1;
|
||||
downstream[ref] = downstream[ref] ?? [];
|
||||
downstream[ref].push(n);
|
||||
});
|
||||
});
|
||||
|
||||
const queue: string[] = Object.keys(incoming).filter((n) => incoming[n] === 0);
|
||||
const out: string[] = [];
|
||||
while (queue.length > 0) {
|
||||
const n = queue.shift() as string;
|
||||
out.push(n);
|
||||
(downstream[n] ?? []).forEach((next) => {
|
||||
incoming[next] -= 1;
|
||||
if (incoming[next] === 0) {queue.push(next);}
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import type {
|
||||
DashboardtypesListVariableSpecDTO,
|
||||
DashboardTextVariableSpecDTO,
|
||||
DashboardtypesVariableDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { buildDependencyMap, detectCycle, topoSort } from './dependencyGraph';
|
||||
import VariableSelector from './selectors/VariableSelector';
|
||||
import { useVariableSelectionStore } from './state/selectionStore';
|
||||
|
||||
import '../../DashboardContainer/DashboardVariablesSelection/DashboardVariableSelection.styles.scss';
|
||||
|
||||
interface Props {
|
||||
dashboardId: string;
|
||||
variables: DashboardtypesVariableDTO[] | undefined;
|
||||
}
|
||||
|
||||
function nameOf(v: DashboardtypesVariableDTO): string {
|
||||
return (
|
||||
(v.spec as DashboardtypesListVariableSpecDTO | DashboardTextVariableSpecDTO)
|
||||
?.name ?? ''
|
||||
);
|
||||
}
|
||||
|
||||
function kindHint(v: DashboardtypesVariableDTO): 'list' | 'text' {
|
||||
return v.kind === 'TextVariable' ? 'text' : 'list';
|
||||
}
|
||||
|
||||
function DashboardVariablesV2({ dashboardId, variables }: Props): JSX.Element | null {
|
||||
const hydrate = useVariableSelectionStore((s) => s.hydrate);
|
||||
|
||||
// Build hints map (variable-name → list/text) so the store can decode the URL.
|
||||
const hints = useMemo<Record<string, 'list' | 'text'>>(() => {
|
||||
const out: Record<string, 'list' | 'text'> = {};
|
||||
(variables ?? []).forEach((v) => {
|
||||
const n = nameOf(v);
|
||||
if (n) {out[n] = kindHint(v);}
|
||||
});
|
||||
return out;
|
||||
}, [variables]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dashboardId) {return;}
|
||||
hydrate(dashboardId, hints);
|
||||
}, [dashboardId, hints, hydrate]);
|
||||
|
||||
// Sort variables in dependency order so dependent resolvers see fresh
|
||||
// selections from their parents. (Render order doesn't affect the React
|
||||
// Query cache but it does affect *visual* order.)
|
||||
const ordered = useMemo(() => {
|
||||
if (!variables?.length) {return [];}
|
||||
const deps = buildDependencyMap(variables);
|
||||
const cycle = detectCycle(deps);
|
||||
if (cycle.hasCycle) {
|
||||
// Render in the original order; the cycle is surfaced separately at save
|
||||
// time via validateDraft. Resolution will still execute; it just won't
|
||||
// converge.
|
||||
return variables;
|
||||
}
|
||||
const order = topoSort(deps);
|
||||
const byName: Record<string, DashboardtypesVariableDTO> = {};
|
||||
variables.forEach((v) => {
|
||||
const n = nameOf(v);
|
||||
if (n) {byName[n] = v;}
|
||||
});
|
||||
return order.map((n) => byName[n]).filter(Boolean);
|
||||
}, [variables]);
|
||||
|
||||
if (!variables || variables.length === 0) {return null;}
|
||||
|
||||
return (
|
||||
<div className="variables-container">
|
||||
{ordered.map((v) => (
|
||||
<VariableSelector key={nameOf(v)} variable={v} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardVariablesV2;
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Applies V2 `capturingRegexp` to each value: if the regex matches and has a
|
||||
* capture group, replace the value with the first capture; otherwise keep
|
||||
* the raw value. Invalid regex silently passes values through.
|
||||
*
|
||||
* Empty results (no match at all) are filtered out — they would be useless
|
||||
* as selectable options.
|
||||
*/
|
||||
export function applyCapturingRegexp(
|
||||
values: string[],
|
||||
pattern: string | undefined | null,
|
||||
): string[] {
|
||||
if (!pattern) {return values;}
|
||||
|
||||
let re: RegExp;
|
||||
try {
|
||||
re = new RegExp(pattern);
|
||||
} catch {
|
||||
return values;
|
||||
}
|
||||
|
||||
const out: string[] = [];
|
||||
values.forEach((v) => {
|
||||
const m = re.exec(v);
|
||||
if (!m) {return;}
|
||||
out.push(m[1] !== undefined ? m[1] : m[0]);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Apply V2 sort modes to a resolved value list.
|
||||
*
|
||||
* Sort values come from the perses spec — `none`, `alphabetical-asc`,
|
||||
* `alphabetical-desc`, `numerical-asc`, `numerical-desc`. Numerical sort
|
||||
* falls back to string compare for values that aren't numbers so we never
|
||||
* throw away non-numeric entries.
|
||||
*/
|
||||
export function applySort(
|
||||
values: string[],
|
||||
sort: string | null | undefined,
|
||||
): string[] {
|
||||
if (!sort || sort === 'none' || values.length <= 1) {return values;}
|
||||
const copy = values.slice();
|
||||
if (sort === 'alphabetical-asc') {
|
||||
copy.sort((a, b) => a.localeCompare(b));
|
||||
} else if (sort === 'alphabetical-desc') {
|
||||
copy.sort((a, b) => b.localeCompare(a));
|
||||
} else if (sort === 'numerical-asc' || sort === 'numerical-desc') {
|
||||
copy.sort((a, b) => {
|
||||
const na = Number(a);
|
||||
const nb = Number(b);
|
||||
const aFinite = Number.isFinite(na);
|
||||
const bFinite = Number.isFinite(nb);
|
||||
if (aFinite && bFinite) {
|
||||
return sort === 'numerical-asc' ? na - nb : nb - na;
|
||||
}
|
||||
// Mixed numeric/non-numeric: keep non-numerics at the end, sorted alpha.
|
||||
if (aFinite) {return -1;}
|
||||
if (bFinite) {return 1;}
|
||||
return sort === 'numerical-asc'
|
||||
? a.localeCompare(b)
|
||||
: b.localeCompare(a);
|
||||
});
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Output of resolving a single list variable. Text variables don't go
|
||||
* through resolution — their value is the literal string.
|
||||
*/
|
||||
export interface ResolvedValues {
|
||||
values: string[];
|
||||
status: 'idle' | 'loading' | 'success' | 'error';
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const idle: ResolvedValues = { values: [], status: 'idle' };
|
||||
export const loading: ResolvedValues = { values: [], status: 'loading' };
|
||||
export function success(values: string[]): ResolvedValues {
|
||||
return { values, status: 'success' };
|
||||
}
|
||||
export function failure(error: string): ResolvedValues {
|
||||
return { values: [], status: 'error', error };
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useMemo } from 'react';
|
||||
import { commaValuesParser } from 'lib/dashboardVariables/customCommaValuesParser';
|
||||
|
||||
import { success, type ResolvedValues } from './types';
|
||||
|
||||
/**
|
||||
* CUSTOM variables: the comma-separated user input is the value list.
|
||||
* No network call, purely client-side.
|
||||
*/
|
||||
export function useCustomResolver(customValue: string): ResolvedValues {
|
||||
return useMemo(
|
||||
() => success(commaValuesParser(customValue).map((v) => String(v))),
|
||||
[customValue],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// eslint-disable-next-line no-restricted-imports -- TODO: migrate global time selector off redux
|
||||
import { useSelector } from 'react-redux';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useGetFieldValues } from 'hooks/dynamicVariables/useGetFieldValues';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import { failure, idle, loading, success, type ResolvedValues } from './types';
|
||||
|
||||
function signalToV1(
|
||||
signal: TelemetrytypesSignalDTO | undefined,
|
||||
): 'traces' | 'logs' | 'metrics' | undefined {
|
||||
if (signal === TelemetrytypesSignalDTO.traces) {return 'traces';}
|
||||
if (signal === TelemetrytypesSignalDTO.logs) {return 'logs';}
|
||||
if (signal === TelemetrytypesSignalDTO.metrics) {return 'metrics';}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* DYNAMIC variables: telemetry attribute lookup.
|
||||
* - `signal === undefined` → search across all telemetry types.
|
||||
* - Otherwise scoped to the specific signal.
|
||||
*
|
||||
* Uses the existing V1 hook directly; the API is V2-shape-agnostic.
|
||||
*/
|
||||
export function useDynamicResolver(
|
||||
attributeName: string,
|
||||
signal: TelemetrytypesSignalDTO | undefined,
|
||||
): ResolvedValues {
|
||||
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
|
||||
const enabled = !!attributeName;
|
||||
const { data, isLoading, isError, error } = useGetFieldValues({
|
||||
signal: signalToV1(signal),
|
||||
name: attributeName,
|
||||
enabled,
|
||||
startUnixMilli: minTime,
|
||||
endUnixMilli: maxTime,
|
||||
});
|
||||
|
||||
if (!enabled) {return idle;}
|
||||
if (isLoading) {return loading;}
|
||||
if (isError) {
|
||||
return failure(
|
||||
(error as Error)?.message ?? 'Failed to resolve dynamic variable',
|
||||
);
|
||||
}
|
||||
return success(data?.data?.normalizedValues ?? []);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useQuery } from 'react-query';
|
||||
import dashboardVariablesQuery from 'api/dashboard/variables/dashboardVariablesQuery';
|
||||
import type { PayloadVariables } from 'types/api/dashboard/variables/query';
|
||||
|
||||
import { substituteVariables } from '../substitution';
|
||||
import type { SelectionsByName } from '../state/types';
|
||||
import { failure, idle, loading, success, type ResolvedValues } from './types';
|
||||
|
||||
/**
|
||||
* Reduce the user's V2 selections to the V1 `PayloadVariables` shape the
|
||||
* variables/query endpoint expects (a plain name → selected-value map).
|
||||
*/
|
||||
function selectionsToPayload(
|
||||
selections: SelectionsByName,
|
||||
): PayloadVariables {
|
||||
const out: PayloadVariables = {};
|
||||
Object.entries(selections).forEach(([name, sel]) => {
|
||||
if (!sel) {return;}
|
||||
if (sel.kind === 'text') {
|
||||
out[name] = sel.value;
|
||||
} else if (sel.allSelected) {
|
||||
// Endpoint understands `__ALL__`-style markers via the substitution
|
||||
// done client-side; leave the value out so server doesn't double up.
|
||||
// (Callers using IN ($var) expand via substituteVariables instead.)
|
||||
} else if (sel.values.length === 1) {
|
||||
out[name] = sel.values[0];
|
||||
} else {
|
||||
out[name] = sel.values;
|
||||
}
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
interface UseQueryResolverArgs {
|
||||
variableName: string;
|
||||
queryValue: string;
|
||||
selections: SelectionsByName;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* QUERY variables: substitute `$var` references using current selections,
|
||||
* then POST to `/api/v2/variables/query`. React Query caches per
|
||||
* (name, substitutedQuery) so re-render with the same inputs reuses results.
|
||||
*/
|
||||
export function useQueryResolver({
|
||||
variableName,
|
||||
queryValue,
|
||||
selections,
|
||||
enabled,
|
||||
}: UseQueryResolverArgs): ResolvedValues {
|
||||
const substituted = substituteVariables(queryValue, selections);
|
||||
|
||||
const { data, isLoading, isError, error } = useQuery({
|
||||
queryKey: ['v2-variable-query', variableName, substituted],
|
||||
queryFn: () =>
|
||||
dashboardVariablesQuery({
|
||||
query: substituted,
|
||||
variables: selectionsToPayload(selections),
|
||||
}),
|
||||
enabled: enabled && !!substituted,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
if (!enabled || !substituted) {return idle;}
|
||||
if (isLoading) {return loading;}
|
||||
if (isError) {
|
||||
return failure(
|
||||
(error as { details?: { error?: string } })?.details?.error ??
|
||||
(error as Error)?.message ??
|
||||
'Variable query failed',
|
||||
);
|
||||
}
|
||||
const payload = (data as { payload?: { variableValues?: unknown[] } } | undefined)
|
||||
?.payload;
|
||||
const values = (payload?.variableValues ?? []).map((v) => String(v));
|
||||
return success(values);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useMemo } from 'react';
|
||||
import type {
|
||||
DashboardtypesListVariableSpecDTO,
|
||||
DashboardtypesVariableDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { useVariableSelectionStore } from '../state/selectionStore';
|
||||
import { applyCapturingRegexp } from './capturingRegexp';
|
||||
import { applySort } from './sorting';
|
||||
import { useCustomResolver } from './useCustomResolver';
|
||||
import { useDynamicResolver } from './useDynamicResolver';
|
||||
import { useQueryResolver } from './useQueryResolver';
|
||||
import { idle, success, type ResolvedValues } from './types';
|
||||
|
||||
interface UseResolveVariableArgs {
|
||||
variable: DashboardtypesVariableDTO;
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes a variable to the correct resolver hook and applies the V2
|
||||
* post-processing pipeline:
|
||||
*
|
||||
* raw values → capturingRegexp → sort → final list
|
||||
*
|
||||
* Text variables short-circuit since they don't have a value list.
|
||||
*/
|
||||
export function useResolveVariable({
|
||||
variable,
|
||||
}: UseResolveVariableArgs): ResolvedValues {
|
||||
const selections = useVariableSelectionStore((s) => s.selections);
|
||||
|
||||
// Read all fields up front so the React Query / hook order is stable
|
||||
// across renders (hooks must not be called conditionally).
|
||||
const isText = variable.kind === 'TextVariable';
|
||||
const listSpec = (variable.spec as DashboardtypesListVariableSpecDTO) ?? {};
|
||||
const pluginKind = listSpec.plugin?.kind;
|
||||
const pluginSpec = (listSpec.plugin?.spec as Record<string, unknown> | undefined) ?? {};
|
||||
|
||||
const name = listSpec?.name ?? '';
|
||||
const customValue = (pluginSpec.customValue as string) ?? '';
|
||||
const queryValue = (pluginSpec.queryValue as string) ?? '';
|
||||
const dynName = (pluginSpec.name as string) ?? '';
|
||||
const dynSignal = pluginSpec.signal as TelemetrytypesSignalDTO | undefined;
|
||||
|
||||
const customRes = useCustomResolver(
|
||||
pluginKind === 'signoz/CustomVariable' ? customValue : '',
|
||||
);
|
||||
const dynRes = useDynamicResolver(
|
||||
pluginKind === 'signoz/DynamicVariable' ? dynName : '',
|
||||
dynSignal,
|
||||
);
|
||||
const queryRes = useQueryResolver({
|
||||
variableName: name,
|
||||
queryValue: pluginKind === 'signoz/QueryVariable' ? queryValue : '',
|
||||
selections,
|
||||
enabled: pluginKind === 'signoz/QueryVariable',
|
||||
});
|
||||
|
||||
const raw: ResolvedValues = useMemo(() => {
|
||||
if (isText) {return success([]);}
|
||||
if (pluginKind === 'signoz/CustomVariable') {return customRes;}
|
||||
if (pluginKind === 'signoz/DynamicVariable') {return dynRes;}
|
||||
if (pluginKind === 'signoz/QueryVariable') {return queryRes;}
|
||||
return idle;
|
||||
}, [isText, pluginKind, customRes, dynRes, queryRes]);
|
||||
|
||||
return useMemo(() => {
|
||||
if (raw.status !== 'success') {return raw;}
|
||||
const afterRegex = applyCapturingRegexp(raw.values, listSpec.capturingRegexp);
|
||||
const afterSort = applySort(afterRegex, listSpec.sort);
|
||||
return success(afterSort);
|
||||
}, [raw, listSpec.capturingRegexp, listSpec.sort]);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useMemo } from 'react';
|
||||
import SelectVariableInput from 'container/DashboardContainer/DashboardVariablesSelection/SelectVariableInput';
|
||||
import { ALL_SELECT_VALUE } from 'container/DashboardContainer/utils';
|
||||
|
||||
import type { ResolvedValues } from '../resolution/types';
|
||||
import type { VariableSelection } from '../state/types';
|
||||
|
||||
interface Props {
|
||||
variableId: string;
|
||||
resolved: ResolvedValues;
|
||||
selection: VariableSelection | undefined;
|
||||
allowMultiple: boolean;
|
||||
allowAllValue: boolean;
|
||||
defaultValue: string;
|
||||
onChange: (selection: VariableSelection) => void;
|
||||
onClear: () => void;
|
||||
}
|
||||
|
||||
function selectionToValue(
|
||||
selection: VariableSelection | undefined,
|
||||
defaultValue: string,
|
||||
allowMultiple: boolean,
|
||||
): string | string[] | undefined {
|
||||
if (selection && selection.kind === 'list') {
|
||||
if (selection.allSelected) {return ALL_SELECT_VALUE;}
|
||||
if (allowMultiple) {return selection.values;}
|
||||
return selection.values[0];
|
||||
}
|
||||
if (defaultValue) {return allowMultiple ? [defaultValue] : defaultValue;}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* QUERY / CUSTOM / DYNAMIC variables share the same dropdown UX: a list of
|
||||
* options + an optional ALL entry + single / multi-select. Reuses V1's
|
||||
* `SelectVariableInput` so visuals match exactly.
|
||||
*/
|
||||
function ListVariableSelector({
|
||||
variableId,
|
||||
resolved,
|
||||
selection,
|
||||
allowMultiple,
|
||||
allowAllValue,
|
||||
defaultValue,
|
||||
onChange,
|
||||
onClear,
|
||||
}: Props): JSX.Element {
|
||||
const options = useMemo(
|
||||
() => resolved.values.map((v) => ({ label: v, value: v })),
|
||||
[resolved.values],
|
||||
);
|
||||
|
||||
const value = selectionToValue(selection, defaultValue, allowMultiple);
|
||||
|
||||
return (
|
||||
<SelectVariableInput
|
||||
variableId={variableId}
|
||||
options={options}
|
||||
value={value}
|
||||
enableSelectAll={allowAllValue}
|
||||
isMultiSelect={allowMultiple}
|
||||
loading={resolved.status === 'loading'}
|
||||
errorMessage={resolved.error ?? null}
|
||||
onChange={(next): void => {
|
||||
if (Array.isArray(next)) {
|
||||
// Multi-select. Antd's CustomMultiSelect emits the ALL sentinel
|
||||
// when the user toggles the "Select all" row.
|
||||
const hasAll = next.includes(ALL_SELECT_VALUE);
|
||||
onChange({
|
||||
kind: 'list',
|
||||
values: hasAll ? [] : next,
|
||||
allSelected: hasAll,
|
||||
});
|
||||
} else if (next === ALL_SELECT_VALUE) {
|
||||
onChange({ kind: 'list', values: [], allSelected: true });
|
||||
} else {
|
||||
onChange({
|
||||
kind: 'list',
|
||||
values: next ? [next] : [],
|
||||
allSelected: false,
|
||||
});
|
||||
}
|
||||
}}
|
||||
onClear={onClear}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default ListVariableSelector;
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Tooltip } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { SolidInfoCircle } from '@signozhq/icons';
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* V1-style label: `$name` + an info tooltip if a description is set.
|
||||
* Mirrors [DashboardVariablesSelection/VariableItem.tsx:34-42](V1).
|
||||
*/
|
||||
function SelectorLabel({ name, description }: Props): JSX.Element {
|
||||
return (
|
||||
<Typography.Text className="variable-name" truncate={1}>
|
||||
${name}
|
||||
{description ? (
|
||||
<Tooltip title={description}>
|
||||
<SolidInfoCircle className="info-icon" size="md" />
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</Typography.Text>
|
||||
);
|
||||
}
|
||||
|
||||
export default SelectorLabel;
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Input } from 'antd';
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
onCommit: (v: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Text variable input — commits on blur (and on Enter), matching V1's
|
||||
* `TextboxVariableInput` UX which avoids re-fetching panels on every
|
||||
* keystroke.
|
||||
*/
|
||||
function TextVariableSelector({ value, onCommit }: Props): JSX.Element {
|
||||
const [draft, setDraft] = useState<string>(value);
|
||||
|
||||
useEffect(() => {
|
||||
setDraft(value);
|
||||
}, [value]);
|
||||
|
||||
const commit = (): void => {
|
||||
if (draft !== value) {onCommit(draft);}
|
||||
};
|
||||
|
||||
return (
|
||||
<Input
|
||||
className="variable-select"
|
||||
value={draft}
|
||||
onChange={(e): void => setDraft(e.target.value)}
|
||||
onBlur={commit}
|
||||
onPressEnter={commit}
|
||||
data-testid="text-variable-input-v2"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default TextVariableSelector;
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useCallback } from 'react';
|
||||
import type {
|
||||
DashboardtypesListVariableSpecDTO,
|
||||
DashboardTextVariableSpecDTO,
|
||||
DashboardtypesVariableDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { useResolveVariable } from '../resolution/useResolveVariable';
|
||||
import { useVariableSelectionStore } from '../state/selectionStore';
|
||||
import type { VariableSelection } from '../state/types';
|
||||
import ListVariableSelector from './ListVariableSelector';
|
||||
import SelectorLabel from './SelectorLabel';
|
||||
import TextVariableSelector from './TextVariableSelector';
|
||||
|
||||
interface Props {
|
||||
variable: DashboardtypesVariableDTO;
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes one variable to its kind-specific selector. Owns the selection
|
||||
* store binding so the kind-specific components stay dumb.
|
||||
*/
|
||||
function VariableSelector({ variable }: Props): JSX.Element | null {
|
||||
const isText = variable.kind === 'TextVariable';
|
||||
const spec = variable.spec as
|
||||
| DashboardtypesListVariableSpecDTO
|
||||
| DashboardTextVariableSpecDTO
|
||||
| undefined;
|
||||
const name = spec?.name ?? '';
|
||||
|
||||
const selection = useVariableSelectionStore((s) =>
|
||||
name ? s.selections[name] : undefined,
|
||||
);
|
||||
const setSelection = useVariableSelectionStore((s) => s.setSelection);
|
||||
const resolved = useResolveVariable({ variable });
|
||||
|
||||
const setListSelection = useCallback(
|
||||
(next: VariableSelection): void => setSelection(name, next),
|
||||
[name, setSelection],
|
||||
);
|
||||
const clearSelection = useCallback((): void => setSelection(name, undefined), [
|
||||
name,
|
||||
setSelection,
|
||||
]);
|
||||
|
||||
if (!name) {return null;}
|
||||
|
||||
const description = spec?.display?.name ?? '';
|
||||
|
||||
if (isText) {
|
||||
const textSpec = spec as DashboardTextVariableSpecDTO;
|
||||
const current =
|
||||
selection?.kind === 'text' ? selection.value : textSpec?.value ?? '';
|
||||
return (
|
||||
<div className="variable-item">
|
||||
<SelectorLabel name={name} description={description} />
|
||||
<div className="variable-value">
|
||||
<TextVariableSelector
|
||||
value={current}
|
||||
onCommit={(v): void => setSelection(name, { kind: 'text', value: v })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const listSpec = spec as DashboardtypesListVariableSpecDTO;
|
||||
const defaultValue =
|
||||
typeof listSpec?.defaultValue === 'string'
|
||||
? (listSpec.defaultValue as string)
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div className="variable-item">
|
||||
<SelectorLabel name={name} description={description} />
|
||||
<div className="variable-value">
|
||||
<ListVariableSelector
|
||||
variableId={name}
|
||||
resolved={resolved}
|
||||
selection={selection}
|
||||
allowMultiple={!!listSpec?.allowMultiple}
|
||||
allowAllValue={!!listSpec?.allowAllValue}
|
||||
defaultValue={defaultValue}
|
||||
onChange={setListSelection}
|
||||
onClear={clearSelection}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default VariableSelector;
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { SelectionsByName } from './types';
|
||||
|
||||
const STORAGE_PREFIX = 'dashboard-v2-variables';
|
||||
|
||||
function storageKey(dashboardId: string): string {
|
||||
return `${STORAGE_PREFIX}:${dashboardId}`;
|
||||
}
|
||||
|
||||
export function loadSelectionsFromStorage(
|
||||
dashboardId: string,
|
||||
): SelectionsByName {
|
||||
if (!dashboardId) {return {};}
|
||||
try {
|
||||
const raw = window.localStorage.getItem(storageKey(dashboardId));
|
||||
if (!raw) {return {};}
|
||||
const parsed = JSON.parse(raw) as SelectionsByName;
|
||||
return parsed && typeof parsed === 'object' ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function saveSelectionsToStorage(
|
||||
dashboardId: string,
|
||||
selections: SelectionsByName,
|
||||
): void {
|
||||
if (!dashboardId) {return;}
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
storageKey(dashboardId),
|
||||
JSON.stringify(selections),
|
||||
);
|
||||
} catch {
|
||||
// quota / availability issues — selection still lives in memory + URL
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
import {
|
||||
loadSelectionsFromStorage,
|
||||
saveSelectionsToStorage,
|
||||
} from './localStorage';
|
||||
import type { SelectionsByName, VariableSelection } from './types';
|
||||
import { readSelectionsFromUrl, writeSelectionsToUrl } from './urlSync';
|
||||
|
||||
interface SelectionStoreState {
|
||||
dashboardId: string;
|
||||
selections: SelectionsByName;
|
||||
|
||||
/**
|
||||
* Hydrate from URL → fallback to LocalStorage. Called once per dashboard
|
||||
* load. `hints` lets URL decoding pick list vs text encoding.
|
||||
*/
|
||||
hydrate: (
|
||||
dashboardId: string,
|
||||
hints: Record<string, 'list' | 'text'>,
|
||||
) => void;
|
||||
|
||||
/**
|
||||
* Set / clear the selection for a single variable. Persists to both
|
||||
* LocalStorage and URL.
|
||||
*/
|
||||
setSelection: (name: string, selection: VariableSelection | undefined) => void;
|
||||
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export const useVariableSelectionStore = create<SelectionStoreState>(
|
||||
(set, get) => ({
|
||||
dashboardId: '',
|
||||
selections: {},
|
||||
|
||||
hydrate: (dashboardId, hints): void => {
|
||||
const fromUrl = readSelectionsFromUrl(hints);
|
||||
const fromStorage = loadSelectionsFromStorage(dashboardId);
|
||||
// URL wins over LocalStorage (shareable links override personal
|
||||
// preferences).
|
||||
const merged: SelectionsByName = { ...fromStorage, ...fromUrl };
|
||||
set({ dashboardId, selections: merged });
|
||||
},
|
||||
|
||||
setSelection: (name, selection): void => {
|
||||
const { dashboardId, selections } = get();
|
||||
const next: SelectionsByName = { ...selections };
|
||||
if (selection === undefined) {
|
||||
delete next[name];
|
||||
} else {
|
||||
next[name] = selection;
|
||||
}
|
||||
set({ selections: next });
|
||||
saveSelectionsToStorage(dashboardId, next);
|
||||
writeSelectionsToUrl(next);
|
||||
},
|
||||
|
||||
reset: (): void => {
|
||||
set({ dashboardId: '', selections: {} });
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* A single variable's selected value.
|
||||
*
|
||||
* - `kind: 'list'` is used for QUERY / CUSTOM / DYNAMIC list variables.
|
||||
* - `allSelected: true` represents the user picking "ALL"; `values` is
|
||||
* ignored in that case.
|
||||
* - `values` is an array even for single-select to keep the shape uniform;
|
||||
* single-select uses index 0.
|
||||
* - `kind: 'text'` is the TextVariable case: one freeform string.
|
||||
*/
|
||||
export type VariableSelection =
|
||||
| { kind: 'list'; values: string[]; allSelected: boolean }
|
||||
| { kind: 'text'; value: string };
|
||||
|
||||
/**
|
||||
* Map of `variable name` → selection. Per dashboard, in memory + persisted.
|
||||
*/
|
||||
export type SelectionsByName = Record<string, VariableSelection | undefined>;
|
||||
|
||||
export const ALL_SENTINEL = '__ALL__';
|
||||
@@ -0,0 +1,73 @@
|
||||
import { ALL_SENTINEL, type SelectionsByName, type VariableSelection } from './types';
|
||||
|
||||
const URL_PREFIX = 'var-';
|
||||
|
||||
/**
|
||||
* Encodes a single selection into a URL-safe string. Compact format:
|
||||
* - text variable → the freeform string
|
||||
* - list (ALL) → "__ALL__"
|
||||
* - list (single) → "value"
|
||||
* - list (multi) → "v1,v2,v3"
|
||||
*/
|
||||
function encodeSelection(sel: VariableSelection): string {
|
||||
if (sel.kind === 'text') {return sel.value;}
|
||||
if (sel.allSelected) {return ALL_SENTINEL;}
|
||||
return sel.values.join(',');
|
||||
}
|
||||
|
||||
function decodeSelection(
|
||||
raw: string,
|
||||
hint: 'list' | 'text',
|
||||
): VariableSelection {
|
||||
if (hint === 'text') {return { kind: 'text', value: raw };}
|
||||
if (raw === ALL_SENTINEL) {
|
||||
return { kind: 'list', values: [], allSelected: true };
|
||||
}
|
||||
const values = raw ? raw.split(',') : [];
|
||||
return { kind: 'list', values, allSelected: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads `var-<name>=<encoded>` params off the current location.
|
||||
* `hints` tells us each variable's kind (list vs text) for decoding.
|
||||
*/
|
||||
export function readSelectionsFromUrl(
|
||||
hints: Record<string, 'list' | 'text'>,
|
||||
): SelectionsByName {
|
||||
const out: SelectionsByName = {};
|
||||
if (typeof window === 'undefined') {return out;}
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.forEach((value, key) => {
|
||||
if (!key.startsWith(URL_PREFIX)) {return;}
|
||||
const name = key.slice(URL_PREFIX.length);
|
||||
const hint = hints[name];
|
||||
if (!hint) {return;}
|
||||
out[name] = decodeSelection(value, hint);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the current selections into the URL, replacing any previous
|
||||
* `var-*` params. Uses `replaceState` so it doesn't pollute history.
|
||||
*/
|
||||
export function writeSelectionsToUrl(selections: SelectionsByName): void {
|
||||
if (typeof window === 'undefined') {return;}
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
// Strip existing var-* params
|
||||
const keysToDelete: string[] = [];
|
||||
params.forEach((_, key) => {
|
||||
if (key.startsWith(URL_PREFIX)) {keysToDelete.push(key);}
|
||||
});
|
||||
keysToDelete.forEach((k) => params.delete(k));
|
||||
|
||||
Object.entries(selections).forEach(([name, sel]) => {
|
||||
if (!sel) {return;}
|
||||
params.set(`${URL_PREFIX}${name}`, encodeSelection(sel));
|
||||
});
|
||||
|
||||
const search = params.toString();
|
||||
const querySuffix = search ? `?${search}` : '';
|
||||
const nextUrl = `${window.location.pathname}${querySuffix}${window.location.hash}`;
|
||||
window.history.replaceState(window.history.state, '', nextUrl);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { ALL_SENTINEL, type SelectionsByName } from './state/types';
|
||||
|
||||
/**
|
||||
* Replaces `$varname` references in a string with the current selection.
|
||||
*
|
||||
* - text selection → the freeform string
|
||||
* - list, allSelected → ALL_SENTINEL (callers decide whether to expand to
|
||||
* all known values or to send the literal marker)
|
||||
* - list, single value → that value
|
||||
* - list, multi values → comma-joined; brackets if caller wraps with IN ()
|
||||
*
|
||||
* Variable names match `[a-zA-Z_][a-zA-Z0-9_.]*` so dotted attribute keys
|
||||
* like `$service.name` work. Substitution is non-recursive (we don't expand
|
||||
* `$other` if a value happens to contain another reference).
|
||||
*/
|
||||
const VARIABLE_REF = /\$([a-zA-Z_][a-zA-Z0-9_.]*)/g;
|
||||
|
||||
function selectionToString(
|
||||
selection: SelectionsByName[string],
|
||||
): string | null {
|
||||
if (!selection) {return null;}
|
||||
if (selection.kind === 'text') {return selection.value;}
|
||||
if (selection.allSelected) {return ALL_SENTINEL;}
|
||||
if (selection.values.length === 0) {return '';}
|
||||
return selection.values.join(',');
|
||||
}
|
||||
|
||||
export function substituteVariables(
|
||||
template: string,
|
||||
selections: SelectionsByName,
|
||||
): string {
|
||||
if (!template) {return template;}
|
||||
return template.replace(VARIABLE_REF, (match, name: string) => {
|
||||
const sel = selections[name];
|
||||
const value = selectionToString(sel);
|
||||
// Leave unresolved references intact so the consumer can decide how to
|
||||
// handle them (better than producing silent partial substitutions).
|
||||
return value === null ? match : value;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists the variable names referenced in a string. Used by the dependency
|
||||
* graph (Phase 5).
|
||||
*/
|
||||
export function referencedVariables(template: string): string[] {
|
||||
if (!template) {return [];}
|
||||
const out = new Set<string>();
|
||||
let match: RegExpExecArray | null;
|
||||
const re = new RegExp(VARIABLE_REF.source, 'g');
|
||||
// eslint-disable-next-line no-cond-assign
|
||||
while ((match = re.exec(template)) !== null) {
|
||||
out.add(match[1]);
|
||||
}
|
||||
return Array.from(out);
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports -- TODO: migrate global time dispatch off redux
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useLocation, useParams } from 'react-router-dom';
|
||||
import { Tooltip, Spin } from 'antd';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { PenLine, Loader } from '@signozhq/icons';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
import { getPanelDefinition } from 'container/DashboardContainerV2/Panels';
|
||||
import type { DashboardPreference } from 'container/DashboardContainerV2/Panels/types';
|
||||
import { usePanelQuery } from 'container/DashboardContainerV2/hooks/usePanelQuery';
|
||||
import { useDashboardCursorSyncMode } from 'hooks/dashboard/useDashboardCursorSyncMode';
|
||||
import { useSyncTooltipFilterMode } from 'hooks/dashboard/useSyncTooltipFilterMode';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { UpdateTimeInterval } from 'store/actions';
|
||||
|
||||
interface Props {
|
||||
panel: DashboardtypesPanelDTO | undefined;
|
||||
panelId: string;
|
||||
}
|
||||
|
||||
function PanelV2({ panel, panelId }: Props): JSX.Element {
|
||||
const name = panel?.spec?.display?.name || `Panel ${panelId.slice(0, 6)}`;
|
||||
const description = panel?.spec?.display?.description;
|
||||
const fullKind = panel?.spec?.plugin?.kind;
|
||||
const kind = fullKind?.replace(/^signoz\//, '') ?? 'unknown';
|
||||
const queryCount = panel?.spec?.queries?.length ?? 0;
|
||||
|
||||
const panelDef = getPanelDefinition(fullKind);
|
||||
|
||||
const { data, isLoading, error } = usePanelQuery({
|
||||
panel,
|
||||
panelId,
|
||||
enabled: !!panelDef,
|
||||
});
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const { pathname } = useLocation();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const urlQuery = useUrlQuery();
|
||||
|
||||
// Dashboard-wide preferences propagated to every panel renderer on this
|
||||
// dashboard. The hooks key off the dashboard id from the route param so
|
||||
// preferences (cursor sync, tooltip filter) persist per-dashboard.
|
||||
const { dashboardId } = useParams<{ dashboardId: string }>();
|
||||
const [syncMode] = useDashboardCursorSyncMode(
|
||||
dashboardId,
|
||||
PanelMode.DASHBOARD_VIEW,
|
||||
);
|
||||
const [syncFilterMode] = useSyncTooltipFilterMode(dashboardId);
|
||||
const dashboardPreference = useMemo<DashboardPreference>(
|
||||
() => ({ syncMode, syncFilterMode, dashboardId }),
|
||||
[syncMode, syncFilterMode, dashboardId],
|
||||
);
|
||||
|
||||
/**
|
||||
* Handler for drag-selecting a time range on the chart. Updates the URL + global
|
||||
* time interval so every panel re-fetches against the same window.
|
||||
*/
|
||||
const onDragSelect = useCallback(
|
||||
(start: number, end: number): void => {
|
||||
const startTimestamp = Math.trunc(start);
|
||||
const endTimestamp = Math.trunc(end);
|
||||
|
||||
urlQuery.set(QueryParams.startTime, startTimestamp.toString());
|
||||
urlQuery.set(QueryParams.endTime, endTimestamp.toString());
|
||||
safeNavigate(`${pathname}?${urlQuery.toString()}`);
|
||||
|
||||
if (startTimestamp !== endTimestamp) {
|
||||
dispatch(UpdateTimeInterval('custom', [startTimestamp, endTimestamp]));
|
||||
}
|
||||
},
|
||||
[dispatch, pathname, safeNavigate, urlQuery],
|
||||
);
|
||||
|
||||
/**
|
||||
* Opens the V2 panel editor overlay by setting the `editPanelId` query param
|
||||
* on the current dashboard URL (the dashboard stays mounted underneath).
|
||||
* Stops propagation so the click on the drag-handle row doesn't start a grid
|
||||
* drag.
|
||||
*/
|
||||
const onEdit = useCallback(
|
||||
(e: React.MouseEvent): void => {
|
||||
e.stopPropagation();
|
||||
urlQuery.set(QueryParams.editPanelId, panelId);
|
||||
safeNavigate(`${pathname}?${urlQuery.toString()}`);
|
||||
},
|
||||
[urlQuery, safeNavigate, pathname, panelId],
|
||||
);
|
||||
|
||||
const headerTitle = useMemo(() => {
|
||||
if (!description) {
|
||||
return name;
|
||||
}
|
||||
return (
|
||||
<Tooltip title={description}>
|
||||
<span>{name}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}, [name, description]);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
background: 'var(--bg-ink-400, #0b0c0e)',
|
||||
border: '1px solid var(--bg-slate-400, #1d212d)',
|
||||
borderRadius: 4,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="drag-handle"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '8px 12px',
|
||||
borderBottom: '1px solid var(--bg-slate-400, #1d212d)',
|
||||
cursor: 'grab',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<Typography.Text
|
||||
style={{
|
||||
margin: 0,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{headerTitle}
|
||||
</Typography.Text>
|
||||
<Badge style={{ marginInlineEnd: 0 }}>{kind}</Badge>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="panel-v2-edit"
|
||||
aria-label="Edit panel"
|
||||
onClick={onEdit}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 4,
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
color: 'inherit',
|
||||
}}
|
||||
>
|
||||
<PenLine size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
minHeight: 0,
|
||||
}}
|
||||
>
|
||||
{/* eslint-disable-next-line no-nested-ternary -- 3-way branch on shell state */}
|
||||
{!panelDef ? (
|
||||
<div
|
||||
data-testid="panel-v2-unknown-kind-fallback"
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 12,
|
||||
color: 'var(--bg-vanilla-400, #8993ae)',
|
||||
fontSize: 12,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ marginBottom: 6 }}>{kind} panel</div>
|
||||
<div>
|
||||
{queryCount} {queryCount === 1 ? 'query' : 'queries'} · not yet
|
||||
supported in V2
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : isLoading && !data ? (
|
||||
// First-load only. During background refetches `data` is still
|
||||
// populated, so the chart stays mounted and the user sees fresh
|
||||
// data swap in without the panel blinking.
|
||||
<div
|
||||
data-testid="panel-v2-loading"
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 12,
|
||||
}}
|
||||
>
|
||||
<Spin indicator={<Loader size={14} className="animate-spin" />} />
|
||||
</div>
|
||||
) : (
|
||||
<panelDef.Renderer
|
||||
panelId={panelId}
|
||||
panel={panel}
|
||||
data={data}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
onDragSelect={onDragSelect}
|
||||
panelMode={PanelMode.DASHBOARD_VIEW}
|
||||
enableDrillDown={false}
|
||||
dashboardPreference={dashboardPreference}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PanelV2;
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import GridLayout, { WidthProvider, type Layout } from 'react-grid-layout';
|
||||
import { Button } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { ChevronDown, ChevronRight } from '@signozhq/icons';
|
||||
|
||||
import type { DashboardSectionV2 } from '../utils';
|
||||
import PanelV2 from './PanelV2';
|
||||
|
||||
const ResponsiveGridLayout = WidthProvider(GridLayout);
|
||||
|
||||
interface Props {
|
||||
section: DashboardSectionV2;
|
||||
}
|
||||
|
||||
function SectionGrid({ items }: { items: DashboardSectionV2['items'] }): JSX.Element {
|
||||
const rglLayout = useMemo<Layout[]>(
|
||||
() =>
|
||||
items.map((item) => ({
|
||||
i: item.id,
|
||||
x: item.x,
|
||||
y: item.y,
|
||||
w: item.width,
|
||||
h: item.height,
|
||||
})),
|
||||
[items],
|
||||
);
|
||||
|
||||
return (
|
||||
<ResponsiveGridLayout
|
||||
cols={12}
|
||||
rowHeight={45}
|
||||
autoSize
|
||||
useCSSTransforms
|
||||
layout={rglLayout}
|
||||
draggableHandle=".drag-handle"
|
||||
isDraggable={false}
|
||||
isResizable={false}
|
||||
margin={[8, 8]}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<div key={item.id}>
|
||||
<PanelV2 panel={item.panel} panelId={item.id} />
|
||||
</div>
|
||||
))}
|
||||
</ResponsiveGridLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ section }: Props): JSX.Element {
|
||||
// Local toggle override — initial state from layout spec; user can
|
||||
// expand/collapse without persisting.
|
||||
const [open, setOpen] = useState<boolean>(section.open);
|
||||
|
||||
if (!section.title) {
|
||||
// Untitled section — render just the grid (no header chrome).
|
||||
return <SectionGrid items={section.items} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 12,
|
||||
border: '1px solid var(--bg-slate-500)',
|
||||
borderRadius: 4,
|
||||
}}
|
||||
data-testid={`dashboard-section-${section.id}`}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
onClick={(): void => setOpen((v) => !v)}
|
||||
icon={open ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
style={{
|
||||
width: '100%',
|
||||
justifyContent: 'flex-start',
|
||||
padding: '8px 12px',
|
||||
borderBottom: open ? '1px solid var(--bg-slate-500)' : 'none',
|
||||
}}
|
||||
data-testid={`dashboard-section-toggle-${section.id}`}
|
||||
>
|
||||
<Typography.Text style={{ marginLeft: 4 }}>
|
||||
{section.title}
|
||||
</Typography.Text>
|
||||
{section.repeatVariable ? (
|
||||
<Typography.Text style={{ marginLeft: 8, opacity: 0.6 }}>
|
||||
(repeats per ${section.repeatVariable})
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
</Button>
|
||||
{open ? <SectionGrid items={section.items} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Section;
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { Empty } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import type {
|
||||
DashboardtypesLayoutDTO,
|
||||
DashboardtypesPanelDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { layoutsToSections } from '../utils';
|
||||
import Section from './Section';
|
||||
|
||||
import 'react-grid-layout/css/styles.css';
|
||||
import 'react-resizable/css/styles.css';
|
||||
|
||||
interface Props {
|
||||
layouts: DashboardtypesLayoutDTO[] | undefined | null;
|
||||
panels: Record<string, DashboardtypesPanelDTO | undefined> | undefined;
|
||||
}
|
||||
|
||||
function GridCardLayoutV2({ layouts, panels }: Props): JSX.Element {
|
||||
const sections = useMemo(() => layoutsToSections(layouts, panels), [
|
||||
layouts,
|
||||
panels,
|
||||
]);
|
||||
|
||||
const isEmpty = sections.length === 0 || sections.every((s) => s.items.length === 0);
|
||||
|
||||
if (isEmpty) {
|
||||
return (
|
||||
<div style={{ padding: 48, textAlign: 'center' }}>
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description={
|
||||
<Typography.Text>No panels in this dashboard yet</Typography.Text>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{sections.map((section) => (
|
||||
<Section key={section.id} section={section} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default GridCardLayoutV2;
|
||||
@@ -0,0 +1,21 @@
|
||||
.config {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 16px;
|
||||
gap: 16px;
|
||||
border-left: 1px solid var(--l2-border);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Input } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import type { PanelDisplayDraft } from '../types';
|
||||
|
||||
import styles from './ConfigPane.module.scss';
|
||||
|
||||
interface ConfigPaneProps {
|
||||
display: PanelDisplayDraft;
|
||||
onChangeDisplay: (next: Partial<PanelDisplayDraft>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Right-hand configuration pane. Milestone 1 exposes only the panel title and
|
||||
* description; later milestones render the data-driven section framework
|
||||
* (Formatting, Axes, Legend, …) below these general fields, keyed off the
|
||||
* panel kind's `SectionConfig[]`.
|
||||
*/
|
||||
function ConfigPane({
|
||||
display,
|
||||
onChangeDisplay,
|
||||
}: ConfigPaneProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.config}>
|
||||
<div className={styles.section}>
|
||||
<Typography.Text>Panel settings</Typography.Text>
|
||||
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Title</Typography.Text>
|
||||
<Input
|
||||
data-testid="panel-editor-v2-title"
|
||||
value={display.name}
|
||||
placeholder="Panel title"
|
||||
onChange={(e): void => onChangeDisplay({ name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Description</Typography.Text>
|
||||
<Input.TextArea
|
||||
data-testid="panel-editor-v2-description"
|
||||
value={display.description}
|
||||
placeholder="Add a description"
|
||||
rows={3}
|
||||
onChange={(e): void => onChangeDisplay({ description: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConfigPane;
|
||||
@@ -0,0 +1,21 @@
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { X } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Divider } from '@signozhq/ui/divider';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import styles from './Header.module.scss';
|
||||
|
||||
interface HeaderProps {
|
||||
isDirty: boolean;
|
||||
isSaving: boolean;
|
||||
onSave: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function Header({
|
||||
isDirty,
|
||||
isSaving,
|
||||
onSave,
|
||||
onClose,
|
||||
}: HeaderProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.header}>
|
||||
<div className={styles.title}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
suffix={<X size={14} />}
|
||||
data-testid="panel-editor-v2-close"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<Divider type="vertical" />
|
||||
<Typography.Text>Configure panel</Typography.Text>
|
||||
</div>
|
||||
<div className={styles.actions}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
data-testid="panel-editor-v2-save"
|
||||
disabled={!isDirty || isSaving}
|
||||
loading={isSaving}
|
||||
onClick={onSave}
|
||||
>
|
||||
Save changes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Header;
|
||||
@@ -0,0 +1,17 @@
|
||||
.root {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background: var(--l2-background);
|
||||
}
|
||||
|
||||
.left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
.preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
gap: 8px;
|
||||
background-image: radial-gradient(var(--l1-border) 2px, transparent 0);
|
||||
background-size: 20px 20px;
|
||||
border-bottom: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
.header {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.queryType {
|
||||
display: inline-flex;
|
||||
padding: 4px 8px 4px 6px;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border-radius: 4px;
|
||||
background: var(--l3-background);
|
||||
backdrop-filter: blur(6px);
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
padding: 2% 5% 5% 5%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.surface {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
background: var(--l2-background);
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
color: var(--bg-vanilla-400, #8993ae);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports -- seed initial time from global store; never written back
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Spin } from 'antd';
|
||||
import { Loader, Spline } from '@signozhq/icons';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
import { getPanelDefinition } from 'container/DashboardContainerV2/Panels';
|
||||
import {
|
||||
type PanelQueryTimeOverride,
|
||||
usePanelQuery,
|
||||
} from 'container/DashboardContainerV2/hooks/usePanelQuery';
|
||||
import QueryTypeTag from 'container/NewWidget/LeftContainer/QueryTypeTag';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import PreviewTimePicker, {
|
||||
type PreviewTime,
|
||||
} from '../PreviewTimePicker/PreviewTimePicker';
|
||||
|
||||
import styles from './PreviewPane.module.scss';
|
||||
|
||||
const NS_TO_SEC = 1e9;
|
||||
|
||||
interface PreviewPaneProps {
|
||||
panelId: string;
|
||||
panel: DashboardtypesPanelDTO;
|
||||
}
|
||||
|
||||
/**
|
||||
* Live preview for the panel editor. Renders the draft panel through the same
|
||||
* registry + query path the dashboard grid uses (`getPanelDefinition` +
|
||||
* `usePanelQuery`), so the preview is byte-for-byte the production renderer —
|
||||
* only the `panelMode` differs (DASHBOARD_EDIT).
|
||||
*
|
||||
* Time is editor-local (`PreviewTimePicker` never touches global Redux time or
|
||||
* the URL), so changing it here neither modifies nor re-runs the dashboard
|
||||
* behind the overlay. Seeded once from the current global selection so the
|
||||
* preview opens matching the dashboard.
|
||||
*/
|
||||
function PreviewPane({ panelId, panel }: PreviewPaneProps): JSX.Element {
|
||||
const fullKind = panel.spec?.plugin?.kind;
|
||||
const panelDef = getPanelDefinition(fullKind);
|
||||
|
||||
const globalTime = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
|
||||
const [previewTime, setPreviewTime] = useState<PreviewTime>(() =>
|
||||
globalTime.selectedTime === 'custom'
|
||||
? {
|
||||
interval: 'custom',
|
||||
range: [
|
||||
Math.floor(globalTime.minTime / NS_TO_SEC),
|
||||
Math.floor(globalTime.maxTime / NS_TO_SEC),
|
||||
],
|
||||
}
|
||||
: { interval: globalTime.selectedTime, range: null },
|
||||
);
|
||||
|
||||
const time = useMemo<PanelQueryTimeOverride>(
|
||||
() => ({
|
||||
selectedTime: 'GLOBAL_TIME',
|
||||
interval: previewTime.interval,
|
||||
startTime: previewTime.range?.[0],
|
||||
endTime: previewTime.range?.[1],
|
||||
}),
|
||||
[previewTime],
|
||||
);
|
||||
|
||||
const { data, isLoading, error } = usePanelQuery({
|
||||
panel,
|
||||
panelId,
|
||||
enabled: !!panelDef,
|
||||
time,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={styles.preview}>
|
||||
<div className={styles.header}>
|
||||
<div className={styles.queryType}>
|
||||
<Spline size={14} />
|
||||
Plotted with <QueryTypeTag queryType={EQueryType.QUERY_BUILDER} />
|
||||
</div>
|
||||
<PreviewTimePicker value={previewTime} onChange={setPreviewTime} />
|
||||
</div>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.surface}>
|
||||
{/* eslint-disable-next-line no-nested-ternary -- 3-way branch on render state */}
|
||||
{!panelDef ? (
|
||||
<div className={styles.state} data-testid="panel-editor-v2-unknown-kind">
|
||||
This panel type is not yet supported in V2.
|
||||
</div>
|
||||
) : isLoading && !data ? (
|
||||
<div className={styles.state} data-testid="panel-editor-v2-loading">
|
||||
<Spin indicator={<Loader size={14} className="animate-spin" />} />
|
||||
</div>
|
||||
) : (
|
||||
<panelDef.Renderer
|
||||
panelId={panelId}
|
||||
panel={panel}
|
||||
data={data}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
panelMode={PanelMode.DASHBOARD_EDIT}
|
||||
enableDrillDown={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PreviewPane;
|
||||
@@ -0,0 +1,127 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import CustomTimePicker from 'components/CustomTimePicker/CustomTimePicker';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import type { DateTimeRangeType } from 'container/TopNav/CustomDateTimeModal';
|
||||
import { getOptions } from 'container/TopNav/DateTimeSelectionV2/constants';
|
||||
import type {
|
||||
CustomTimeType,
|
||||
Time,
|
||||
} from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import dayjs from 'dayjs';
|
||||
import getStartEndRangeTime from 'lib/getStartEndRangeTime';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
|
||||
const MS_TO_NS = 1e6;
|
||||
|
||||
export interface PreviewTime {
|
||||
/** Relative shorthand (e.g. `30m`) or `custom`. */
|
||||
interval: Time | CustomTimeType;
|
||||
/** Custom range `[startSec, endSec]`; null for relative. */
|
||||
range: [number, number] | null;
|
||||
}
|
||||
|
||||
interface PreviewTimePickerProps {
|
||||
value: PreviewTime;
|
||||
onChange: (next: PreviewTime) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Time picker for the panel editor preview. Wraps the shared `CustomTimePicker`
|
||||
* with fully-local state — it never reads or writes global Redux time or the
|
||||
* URL, so changing the preview window doesn't touch (or re-run) the dashboard
|
||||
* behind the editor overlay. Selections are emitted via `onChange`; the parent
|
||||
* feeds them to the preview fetch.
|
||||
*/
|
||||
function PreviewTimePicker({
|
||||
value,
|
||||
onChange,
|
||||
}: PreviewTimePickerProps): JSX.Element {
|
||||
const { pathname } = useLocation();
|
||||
const { timezone } = useTimezone();
|
||||
const [open, setOpen] = useState<boolean>(false);
|
||||
const [customVisible, setCustomVisible] = useState<boolean>(false);
|
||||
|
||||
const { interval, range } = value;
|
||||
const options = useMemo(() => getOptions(pathname), [pathname]);
|
||||
|
||||
// Active window in ms — custom uses the picked range; relative is computed
|
||||
// now-based (Redux-independent). Drives the relative-duration pill.
|
||||
const [startMs, endMs] = useMemo<[number, number]>(() => {
|
||||
if (range) {
|
||||
return [range[0] * 1000, range[1] * 1000];
|
||||
}
|
||||
const { start, end } = getStartEndRangeTime({
|
||||
type: 'GLOBAL_TIME',
|
||||
interval,
|
||||
});
|
||||
return [Number(start) * 1000, Number(end) * 1000];
|
||||
}, [interval, range]);
|
||||
|
||||
// Label shown for a custom range; relative selections render their own
|
||||
// "Last …" label from `selectedTime` inside CustomTimePicker.
|
||||
const selectedValue = useMemo(() => {
|
||||
if (!range) {
|
||||
return '';
|
||||
}
|
||||
const fmt = DATE_TIME_FORMATS.UK_DATETIME_SECONDS;
|
||||
const start = dayjs(startMs).tz(timezone.value).format(fmt);
|
||||
const end = dayjs(endMs).tz(timezone.value).format(fmt);
|
||||
return `${start} - ${end}`;
|
||||
}, [range, startMs, endMs, timezone.value]);
|
||||
|
||||
const onSelect = (next: string): void => {
|
||||
if (next === 'custom') {
|
||||
setCustomVisible(true);
|
||||
return;
|
||||
}
|
||||
setOpen(false);
|
||||
onChange({ interval: next as Time, range: null });
|
||||
};
|
||||
|
||||
const onCustomDateHandler = (dateTimeRange: DateTimeRangeType): void => {
|
||||
if (!dateTimeRange) {
|
||||
return;
|
||||
}
|
||||
const [startMoment, endMoment] = dateTimeRange;
|
||||
if (!startMoment || !endMoment) {
|
||||
return;
|
||||
}
|
||||
setCustomVisible(false);
|
||||
setOpen(false);
|
||||
onChange({
|
||||
interval: 'custom',
|
||||
range: [
|
||||
Math.floor(startMoment.toDate().getTime() / 1000),
|
||||
Math.floor(endMoment.toDate().getTime() / 1000),
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<CustomTimePicker
|
||||
newPopover
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
items={options}
|
||||
selectedTime={interval}
|
||||
selectedValue={selectedValue}
|
||||
minTime={startMs * MS_TO_NS}
|
||||
maxTime={endMs * MS_TO_NS}
|
||||
// Hides the zoom-out button — it mutates global time, which the editor
|
||||
// must not do.
|
||||
isModalTimeSelection
|
||||
onSelect={onSelect}
|
||||
onError={(): void => {}}
|
||||
onCustomDateHandler={onCustomDateHandler}
|
||||
customDateTimeVisible={customVisible}
|
||||
setCustomDTPickerVisible={setCustomVisible}
|
||||
onValidCustomDateChange={({ timeStr }): void => {
|
||||
setOpen(false);
|
||||
onChange({ interval: timeStr as Time, range: null });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default PreviewTimePicker;
|
||||
@@ -0,0 +1,12 @@
|
||||
.placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
min-height: 30%;
|
||||
margin: 0 16px 16px;
|
||||
border: 1px dashed var(--l2-border);
|
||||
border-radius: 4px;
|
||||
color: var(--l2-foreground);
|
||||
font-size: 13px;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Terminal } from '@signozhq/icons';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import styles from './QueryBuilderPlaceholder.module.scss';
|
||||
|
||||
/**
|
||||
* Placeholder for the query builder in the panel editor's left pane. Milestone 2
|
||||
* replaces this with the shared `QueryBuilderV2`, wired through `fromPerses` /
|
||||
* `toPerses` so query edits flow into the draft and re-fetch the preview.
|
||||
*/
|
||||
function QueryBuilderPlaceholder(): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
className={styles.placeholder}
|
||||
data-testid="panel-editor-v2-query-placeholder"
|
||||
>
|
||||
<Terminal size={16} />
|
||||
<Typography.Text>Query builder coming soon</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default QueryBuilderPlaceholder;
|
||||
@@ -0,0 +1,35 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
|
||||
import ConfigPane from '../ConfigPane/ConfigPane';
|
||||
|
||||
describe('ConfigPane', () => {
|
||||
it('renders the seeded title and description', () => {
|
||||
render(
|
||||
<ConfigPane
|
||||
display={{ name: 'CPU', description: 'usage' }}
|
||||
onChangeDisplay={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('panel-editor-v2-title')).toHaveValue('CPU');
|
||||
expect(screen.getByTestId('panel-editor-v2-description')).toHaveValue(
|
||||
'usage',
|
||||
);
|
||||
});
|
||||
|
||||
it('reports title edits via onChangeDisplay', () => {
|
||||
const onChangeDisplay = jest.fn();
|
||||
render(
|
||||
<ConfigPane
|
||||
display={{ name: 'CPU', description: 'usage' }}
|
||||
onChangeDisplay={onChangeDisplay}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByTestId('panel-editor-v2-title'), {
|
||||
target: { value: 'Memory' },
|
||||
});
|
||||
|
||||
expect(onChangeDisplay).toHaveBeenCalledWith({ name: 'Memory' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { usePanelEditorDraft } from '../usePanelEditorDraft';
|
||||
|
||||
function panel(name = 'CPU', description = 'usage'): DashboardtypesPanelDTO {
|
||||
return {
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
display: { name, description },
|
||||
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
|
||||
queries: [],
|
||||
},
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
}
|
||||
|
||||
describe('usePanelEditorDraft', () => {
|
||||
it('seeds display from the initial panel and starts clean', () => {
|
||||
const { result } = renderHook(() => usePanelEditorDraft(panel()));
|
||||
|
||||
expect(result.current.display).toStrictEqual({
|
||||
name: 'CPU',
|
||||
description: 'usage',
|
||||
});
|
||||
expect(result.current.isDirty).toBe(false);
|
||||
});
|
||||
|
||||
it('updates display and flags the draft dirty', () => {
|
||||
const { result } = renderHook(() => usePanelEditorDraft(panel()));
|
||||
|
||||
act(() => result.current.setDisplay({ name: 'Memory' }));
|
||||
|
||||
expect(result.current.display.name).toBe('Memory');
|
||||
expect(result.current.display.description).toBe('usage');
|
||||
expect(result.current.isDirty).toBe(true);
|
||||
// draft stays in perses shape so preview + save consume it directly
|
||||
expect(result.current.draft.spec?.display?.name).toBe('Memory');
|
||||
});
|
||||
|
||||
it('reset restores the originally-loaded display', () => {
|
||||
const { result } = renderHook(() => usePanelEditorDraft(panel()));
|
||||
|
||||
act(() => result.current.setDisplay({ name: 'Memory', description: 'new' }));
|
||||
act(() => result.current.reset());
|
||||
|
||||
expect(result.current.display).toStrictEqual({
|
||||
name: 'CPU',
|
||||
description: 'usage',
|
||||
});
|
||||
expect(result.current.isDirty).toBe(false);
|
||||
});
|
||||
|
||||
it('treats a panel without display as empty strings', () => {
|
||||
const bare = {
|
||||
kind: 'Panel',
|
||||
spec: { plugin: { kind: 'signoz/PieChartPanel' } },
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
const { result } = renderHook(() => usePanelEditorDraft(bare));
|
||||
|
||||
expect(result.current.display).toStrictEqual({ name: '', description: '' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import {
|
||||
getGetDashboardV2QueryKey,
|
||||
usePatchDashboardV2,
|
||||
} from 'api/generated/services/dashboard';
|
||||
|
||||
import { usePanelEditorSave } from '../usePanelEditorSave';
|
||||
|
||||
const mockInvalidateQueries = jest.fn();
|
||||
jest.mock('react-query', () => ({
|
||||
useQueryClient: (): { invalidateQueries: jest.Mock } => ({
|
||||
invalidateQueries: mockInvalidateQueries,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('api/generated/services/dashboard', () => ({
|
||||
usePatchDashboardV2: jest.fn(),
|
||||
getGetDashboardV2QueryKey: jest.fn(() => ['/api/v2/dashboards/dash-1']),
|
||||
}));
|
||||
|
||||
const mockUsePatch = usePatchDashboardV2 as unknown as jest.Mock;
|
||||
const mockGetQueryKey = getGetDashboardV2QueryKey as unknown as jest.Mock;
|
||||
|
||||
describe('usePanelEditorSave', () => {
|
||||
const mutateAsync = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockUsePatch.mockReturnValue({
|
||||
mutateAsync,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('emits an add patch for the panel display and invalidates the dashboard query', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePanelEditorSave({ dashboardId: 'dash-1', panelId: 'panel-9' }),
|
||||
);
|
||||
|
||||
await result.current.save({ name: 'New title', description: 'desc' });
|
||||
|
||||
expect(mutateAsync).toHaveBeenCalledWith({
|
||||
pathParams: { id: 'dash-1' },
|
||||
data: [
|
||||
{
|
||||
op: 'add',
|
||||
path: '/spec/panels/panel-9/spec/display',
|
||||
value: { name: 'New title', description: 'desc' },
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(mockGetQueryKey).toHaveBeenCalledWith({ id: 'dash-1' });
|
||||
expect(mockInvalidateQueries).toHaveBeenCalledWith([
|
||||
'/api/v2/dashboards/dash-1',
|
||||
]);
|
||||
});
|
||||
|
||||
it('surfaces the mutation loading state as isSaving', () => {
|
||||
mockUsePatch.mockReturnValue({
|
||||
mutateAsync,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePanelEditorSave({ dashboardId: 'dash-1', panelId: 'panel-9' }),
|
||||
);
|
||||
|
||||
expect(result.current.isSaving).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
useDefaultLayout,
|
||||
} from '@signozhq/ui/resizable';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import ConfigPane from './ConfigPane/ConfigPane';
|
||||
import Header from './Header/Header';
|
||||
import layoutStorage from './layoutStorage';
|
||||
import PreviewPane from './PreviewPane/PreviewPane';
|
||||
import QueryBuilderPlaceholder from './QueryBuilderPlaceholder/QueryBuilderPlaceholder';
|
||||
import { usePanelEditorDraft } from './usePanelEditorDraft';
|
||||
import { usePanelEditorSave } from './usePanelEditorSave';
|
||||
|
||||
import styles from './PanelEditor.module.scss';
|
||||
|
||||
interface PanelEditorContainerProps {
|
||||
dashboardId: string;
|
||||
panelId: string;
|
||||
panel: DashboardtypesPanelDTO;
|
||||
/** Dismiss the editor overlay (clears the `editPanelId` query param). */
|
||||
onClose: () => void;
|
||||
/** Called after a successful save so the dashboard can refetch. */
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* V2 panel editor rendered as a full-screen overlay on top of the dashboard
|
||||
* view (the dashboard stays mounted underneath). A resizable split holds the
|
||||
* live preview + query builder on the left and the configuration pane on the
|
||||
* right. Owns the draft state and the save round-trip.
|
||||
*/
|
||||
function PanelEditorContainer({
|
||||
dashboardId,
|
||||
panelId,
|
||||
panel,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: PanelEditorContainerProps): JSX.Element {
|
||||
const { draft, display, setDisplay, isDirty } = usePanelEditorDraft(panel);
|
||||
const { save, isSaving } = usePanelEditorSave({ dashboardId, panelId });
|
||||
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
|
||||
id: 'panel-editor-v2',
|
||||
storage: layoutStorage,
|
||||
});
|
||||
|
||||
const onSave = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
await save(display);
|
||||
toast.success('Panel saved');
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch {
|
||||
toast.error('Failed to save panel');
|
||||
}
|
||||
}, [save, display, onSaved, onClose]);
|
||||
|
||||
// Portal to <body> so the fixed overlay escapes the dashboard content's
|
||||
// stacking context (AppLayout pins `.app-content` at `z-index: 0`, which
|
||||
// would otherwise trap the overlay below the side nav).
|
||||
return createPortal(
|
||||
<div className={styles.root} data-testid="panel-editor-v2">
|
||||
<Header
|
||||
isDirty={isDirty}
|
||||
isSaving={isSaving}
|
||||
onSave={onSave}
|
||||
onClose={onClose}
|
||||
/>
|
||||
<ResizablePanelGroup
|
||||
id="panel-editor-v2"
|
||||
orientation="horizontal"
|
||||
defaultLayout={defaultLayout}
|
||||
onLayoutChanged={onLayoutChanged}
|
||||
>
|
||||
<ResizablePanel minSize="70%" maxSize="80%" defaultSize="75%">
|
||||
<div className={styles.left}>
|
||||
<PreviewPane panelId={panelId} panel={draft} />
|
||||
<QueryBuilderPlaceholder />
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle withHandle />
|
||||
<ResizablePanel minSize="20%" maxSize="30%" defaultSize="25%">
|
||||
<ConfigPane display={display} onChangeDisplay={setDisplay} />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
export default PanelEditorContainer;
|
||||
@@ -0,0 +1,17 @@
|
||||
import getLocalStorageApi from 'api/browser/localstorage/get';
|
||||
import setLocalStorageApi from 'api/browser/localstorage/set';
|
||||
|
||||
/**
|
||||
* `Storage`-shaped adapter (just `getItem`/`setItem`, which is all
|
||||
* `useDefaultLayout` consumes) backed by the scoped localStorage wrappers. The
|
||||
* wrappers prefix keys with the URL base path, so the persisted resizable
|
||||
* layout stays isolated per deployment instead of touching the raw global.
|
||||
*/
|
||||
const layoutStorage: Pick<Storage, 'getItem' | 'setItem'> = {
|
||||
getItem: (key: string): string | null => getLocalStorageApi(key),
|
||||
setItem: (key: string, value: string): void => {
|
||||
setLocalStorageApi(key, value);
|
||||
},
|
||||
};
|
||||
|
||||
export default layoutStorage;
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
/** The panel display fields editable in milestone 1 of the V2 panel editor. */
|
||||
export interface PanelDisplayDraft {
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Local draft state for the panel being edited. The draft is kept as a perses
|
||||
* `DashboardtypesPanelDTO` so the live preview (which feeds the panel renderer)
|
||||
* and the save patch share a single shape — no intermediate translation.
|
||||
*/
|
||||
export interface PanelEditorDraftApi {
|
||||
/** The current (possibly edited) panel. Always a defined object once seeded. */
|
||||
draft: DashboardtypesPanelDTO;
|
||||
/** Read the current display values (name/description) for the config pane. */
|
||||
display: PanelDisplayDraft;
|
||||
/** Patch the panel's display (title/description). */
|
||||
setDisplay: (next: Partial<PanelDisplayDraft>) => void;
|
||||
/** True when the draft diverges from the originally-loaded panel. */
|
||||
isDirty: boolean;
|
||||
/** Restore the draft to the originally-loaded panel. */
|
||||
reset: () => void;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { PanelDisplayDraft, PanelEditorDraftApi } from './types';
|
||||
|
||||
function readDisplay(panel: DashboardtypesPanelDTO): PanelDisplayDraft {
|
||||
return {
|
||||
name: panel.spec?.display?.name ?? '',
|
||||
description: panel.spec?.display?.description ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns the editable draft of a single panel. Seeded once from the loaded panel
|
||||
* (`useState` initializer), then mutated locally until the user saves. Keeping
|
||||
* the draft in the perses `DashboardtypesPanelDTO` shape lets the preview pane
|
||||
* render it through the same renderer registry the dashboard uses, and lets the
|
||||
* save hook diff/patch it without any conversion.
|
||||
*/
|
||||
export function usePanelEditorDraft(
|
||||
initialPanel: DashboardtypesPanelDTO,
|
||||
): PanelEditorDraftApi {
|
||||
const [draft, setDraft] = useState<DashboardtypesPanelDTO>(initialPanel);
|
||||
|
||||
const setDisplay = useCallback((next: Partial<PanelDisplayDraft>): void => {
|
||||
setDraft((prev) => ({
|
||||
...prev,
|
||||
spec: {
|
||||
...prev.spec,
|
||||
display: {
|
||||
...prev.spec?.display,
|
||||
...next,
|
||||
},
|
||||
},
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const reset = useCallback((): void => {
|
||||
setDraft(initialPanel);
|
||||
}, [initialPanel]);
|
||||
|
||||
const display = useMemo(() => readDisplay(draft), [draft]);
|
||||
|
||||
const isDirty = useMemo(() => {
|
||||
const initial = readDisplay(initialPanel);
|
||||
return (
|
||||
initial.name !== display.name || initial.description !== display.description
|
||||
);
|
||||
}, [initialPanel, display]);
|
||||
|
||||
return { draft, display, setDisplay, isDirty, reset };
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user