mirror of
https://github.com/SigNoz/signoz.git
synced 2026-02-08 10:49:56 +00:00
Compare commits
23 Commits
qb-json-fi
...
multiple-t
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e1947800b | ||
|
|
3562de8fbb | ||
|
|
ef80fb39fd | ||
|
|
594d4dc737 | ||
|
|
01415b58be | ||
|
|
f7728c9019 | ||
|
|
3fffe6e198 | ||
|
|
4594f4ffe3 | ||
|
|
b660dc8573 | ||
|
|
c7b9c0d36c | ||
|
|
4433d22c09 | ||
|
|
9443c42334 | ||
|
|
9387c96023 | ||
|
|
ff2555d8dd | ||
|
|
85953c9a4c | ||
|
|
0155cc38cb | ||
|
|
c217cc96c3 | ||
|
|
580cf32eb5 | ||
|
|
6d3580cbfa | ||
|
|
6c5d36caa9 | ||
|
|
c4a6c7e277 | ||
|
|
c9cd974dca | ||
|
|
5b3f121431 |
7
.github/workflows/integrationci.yaml
vendored
7
.github/workflows/integrationci.yaml
vendored
@@ -42,10 +42,11 @@ jobs:
|
||||
- callbackauthn
|
||||
- cloudintegrations
|
||||
- dashboard
|
||||
- querier
|
||||
- ttl
|
||||
- preference
|
||||
- logspipelines
|
||||
- preference
|
||||
- querier
|
||||
- role
|
||||
- ttl
|
||||
- alerts
|
||||
sqlstore-provider:
|
||||
- postgres
|
||||
|
||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -11,5 +11,8 @@
|
||||
"[go]": {
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "golang.go"
|
||||
},
|
||||
"[sql]": {
|
||||
"editor.defaultFormatter": "adpyke.vscode-sql-formatter"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2285,6 +2285,15 @@ paths:
|
||||
deprecated: false
|
||||
description: This endpoint returns the ingestion keys for a workspace
|
||||
operationId: GetIngestionKeys
|
||||
parameters:
|
||||
- in: query
|
||||
name: page
|
||||
schema:
|
||||
type: integer
|
||||
- in: query
|
||||
name: per_page
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
@@ -2597,6 +2606,19 @@ paths:
|
||||
deprecated: false
|
||||
description: This endpoint returns the ingestion keys for a workspace
|
||||
operationId: SearchIngestionKeys
|
||||
parameters:
|
||||
- in: query
|
||||
name: name
|
||||
schema:
|
||||
type: string
|
||||
- in: query
|
||||
name: page
|
||||
schema:
|
||||
type: integer
|
||||
- in: query
|
||||
name: per_page
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
|
||||
@@ -79,7 +79,7 @@ func (module *module) CreatePublic(ctx context.Context, orgID valuer.UUID, publi
|
||||
authtypes.MustNewSelector(authtypes.TypeMetaResource, publicDashboard.ID.String()),
|
||||
)
|
||||
|
||||
err = module.roleSetter.PatchObjects(ctx, orgID, role.ID, authtypes.RelationRead, []*authtypes.Object{additionObject}, nil)
|
||||
err = module.roleSetter.PatchObjects(ctx, orgID, role.Name, authtypes.RelationRead, []*authtypes.Object{additionObject}, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -208,7 +208,7 @@ func (module *module) DeletePublic(ctx context.Context, orgID valuer.UUID, dashb
|
||||
authtypes.MustNewSelector(authtypes.TypeMetaResource, publicDashboard.ID.String()),
|
||||
)
|
||||
|
||||
err = module.roleSetter.PatchObjects(ctx, orgID, role.ID, authtypes.RelationRead, nil, []*authtypes.Object{deletionObject})
|
||||
err = module.roleSetter.PatchObjects(ctx, orgID, role.Name, authtypes.RelationRead, nil, []*authtypes.Object{deletionObject})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -285,7 +285,7 @@ func (module *module) deletePublic(ctx context.Context, orgID valuer.UUID, dashb
|
||||
authtypes.MustNewSelector(authtypes.TypeMetaResource, publicDashboard.ID.String()),
|
||||
)
|
||||
|
||||
err = module.roleSetter.PatchObjects(ctx, orgID, role.ID, authtypes.RelationRead, nil, []*authtypes.Object{deletionObject})
|
||||
err = module.roleSetter.PatchObjects(ctx, orgID, role.Name, authtypes.RelationRead, nil, []*authtypes.Object{deletionObject})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -116,18 +116,18 @@ func (setter *setter) Patch(ctx context.Context, orgID valuer.UUID, role *rolety
|
||||
return setter.store.Update(ctx, orgID, roletypes.NewStorableRoleFromRole(role))
|
||||
}
|
||||
|
||||
func (setter *setter) PatchObjects(ctx context.Context, orgID valuer.UUID, id valuer.UUID, relation authtypes.Relation, additions, deletions []*authtypes.Object) error {
|
||||
func (setter *setter) PatchObjects(ctx context.Context, orgID valuer.UUID, name string, relation authtypes.Relation, additions, deletions []*authtypes.Object) error {
|
||||
_, err := setter.licensing.GetActive(ctx, orgID)
|
||||
if err != nil {
|
||||
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
additionTuples, err := roletypes.GetAdditionTuples(id, orgID, relation, additions)
|
||||
additionTuples, err := roletypes.GetAdditionTuples(name, orgID, relation, additions)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
deletionTuples, err := roletypes.GetDeletionTuples(id, orgID, relation, deletions)
|
||||
deletionTuples, err := roletypes.GetDeletionTuples(name, orgID, relation, deletions)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
|
||||
if err != nil {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "evaluation is invalid: %v", err)
|
||||
}
|
||||
|
||||
if opts.Rule.RuleType == ruletypes.RuleTypeThreshold {
|
||||
// create a threshold rule
|
||||
tr, err := baserules.NewThresholdRule(
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
"@signozhq/checkbox": "0.0.2",
|
||||
"@signozhq/combobox": "0.0.2",
|
||||
"@signozhq/command": "0.0.0",
|
||||
"@signozhq/design-tokens": "1.1.4",
|
||||
"@signozhq/design-tokens": "2.1.1",
|
||||
"@signozhq/input": "0.0.2",
|
||||
"@signozhq/popover": "0.0.0",
|
||||
"@signozhq/resizable": "0.0.0",
|
||||
|
||||
@@ -39,7 +39,7 @@ type AwaitedInput<T> = PromiseLike<T> | T;
|
||||
type Awaited<O> = O extends AwaitedInput<infer T> ? T : never;
|
||||
|
||||
/**
|
||||
* This endpoints deletes the public sharing config and disables the public sharing of a dashboard
|
||||
* This endpoint deletes the public sharing config and disables the public sharing of a dashboard
|
||||
* @summary Delete public dashboard
|
||||
*/
|
||||
export const deletePublicDashboard = ({
|
||||
@@ -118,7 +118,7 @@ export const useDeletePublicDashboard = <
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
/**
|
||||
* This endpoints returns public sharing config for a dashboard
|
||||
* This endpoint returns public sharing config for a dashboard
|
||||
* @summary Get public dashboard
|
||||
*/
|
||||
export const getPublicDashboard = (
|
||||
@@ -222,7 +222,7 @@ export const invalidateGetPublicDashboard = async (
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoints creates public sharing config and enables public sharing of the dashboard
|
||||
* This endpoint creates public sharing config and enables public sharing of the dashboard
|
||||
* @summary Create public dashboard
|
||||
*/
|
||||
export const createPublicDashboard = (
|
||||
@@ -321,7 +321,7 @@ export const useCreatePublicDashboard = <
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
/**
|
||||
* This endpoints updates the public sharing config for a dashboard
|
||||
* This endpoint updates the public sharing config for a dashboard
|
||||
* @summary Update public dashboard
|
||||
*/
|
||||
export const updatePublicDashboard = (
|
||||
@@ -418,7 +418,7 @@ export const useUpdatePublicDashboard = <
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
/**
|
||||
* This endpoints returns the sanitized dashboard data for public access
|
||||
* This endpoint returns the sanitized dashboard data for public access
|
||||
* @summary Get public dashboard data
|
||||
*/
|
||||
export const getPublicDashboardData = (
|
||||
|
||||
@@ -25,7 +25,7 @@ type AwaitedInput<T> = PromiseLike<T> | T;
|
||||
type Awaited<O> = O extends AwaitedInput<infer T> ? T : never;
|
||||
|
||||
/**
|
||||
* This endpoints returns global config
|
||||
* This endpoint returns global config
|
||||
* @summary Get global config
|
||||
*/
|
||||
export const getGlobalConfig = (signal?: AbortSignal) => {
|
||||
|
||||
433
frontend/src/api/generated/services/role/index.ts
Normal file
433
frontend/src/api/generated/services/role/index.ts
Normal file
@@ -0,0 +1,433 @@
|
||||
/**
|
||||
* ! Do not edit manually
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'yarn generate:api'
|
||||
* SigNoz
|
||||
*/
|
||||
import type {
|
||||
InvalidateOptions,
|
||||
MutationFunction,
|
||||
QueryClient,
|
||||
QueryFunction,
|
||||
QueryKey,
|
||||
UseMutationOptions,
|
||||
UseMutationResult,
|
||||
UseQueryOptions,
|
||||
UseQueryResult,
|
||||
} from 'react-query';
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
|
||||
import { GeneratedAPIInstance } from '../../../index';
|
||||
import type {
|
||||
CreateRole201,
|
||||
DeleteRolePathParameters,
|
||||
GetRole200,
|
||||
GetRolePathParameters,
|
||||
ListRoles200,
|
||||
PatchRolePathParameters,
|
||||
RenderErrorResponseDTO,
|
||||
} from '../sigNoz.schemas';
|
||||
|
||||
type AwaitedInput<T> = PromiseLike<T> | T;
|
||||
|
||||
type Awaited<O> = O extends AwaitedInput<infer T> ? T : never;
|
||||
|
||||
/**
|
||||
* This endpoint lists all roles
|
||||
* @summary List roles
|
||||
*/
|
||||
export const listRoles = (signal?: AbortSignal) => {
|
||||
return GeneratedAPIInstance<ListRoles200>({
|
||||
url: `/api/v1/roles`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getListRolesQueryKey = () => {
|
||||
return ['listRoles'] as const;
|
||||
};
|
||||
|
||||
export const getListRolesQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof listRoles>>,
|
||||
TError = RenderErrorResponseDTO
|
||||
>(options?: {
|
||||
query?: UseQueryOptions<Awaited<ReturnType<typeof listRoles>>, TError, TData>;
|
||||
}) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getListRolesQueryKey();
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof listRoles>>> = ({
|
||||
signal,
|
||||
}) => listRoles(signal);
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listRoles>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type ListRolesQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof listRoles>>
|
||||
>;
|
||||
export type ListRolesQueryError = RenderErrorResponseDTO;
|
||||
|
||||
/**
|
||||
* @summary List roles
|
||||
*/
|
||||
|
||||
export function useListRoles<
|
||||
TData = Awaited<ReturnType<typeof listRoles>>,
|
||||
TError = RenderErrorResponseDTO
|
||||
>(options?: {
|
||||
query?: UseQueryOptions<Awaited<ReturnType<typeof listRoles>>, TError, TData>;
|
||||
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getListRolesQueryOptions(options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
query.queryKey = queryOptions.queryKey;
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary List roles
|
||||
*/
|
||||
export const invalidateListRoles = async (
|
||||
queryClient: QueryClient,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getListRolesQueryKey() },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint creates a role
|
||||
* @summary Create role
|
||||
*/
|
||||
export const createRole = (signal?: AbortSignal) => {
|
||||
return GeneratedAPIInstance<CreateRole201>({
|
||||
url: `/api/v1/roles`,
|
||||
method: 'POST',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getCreateRoleMutationOptions = <
|
||||
TError = RenderErrorResponseDTO,
|
||||
TContext = unknown
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createRole>>,
|
||||
TError,
|
||||
void,
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createRole>>,
|
||||
TError,
|
||||
void,
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['createRole'];
|
||||
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 createRole>>,
|
||||
void
|
||||
> = () => {
|
||||
return createRole();
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type CreateRoleMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof createRole>>
|
||||
>;
|
||||
|
||||
export type CreateRoleMutationError = RenderErrorResponseDTO;
|
||||
|
||||
/**
|
||||
* @summary Create role
|
||||
*/
|
||||
export const useCreateRole = <
|
||||
TError = RenderErrorResponseDTO,
|
||||
TContext = unknown
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createRole>>,
|
||||
TError,
|
||||
void,
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof createRole>>,
|
||||
TError,
|
||||
void,
|
||||
TContext
|
||||
> => {
|
||||
const mutationOptions = getCreateRoleMutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
/**
|
||||
* This endpoint deletes a role
|
||||
* @summary Delete role
|
||||
*/
|
||||
export const deleteRole = ({ id }: DeleteRolePathParameters) => {
|
||||
return GeneratedAPIInstance<string>({
|
||||
url: `/api/v1/roles/${id}`,
|
||||
method: 'DELETE',
|
||||
});
|
||||
};
|
||||
|
||||
export const getDeleteRoleMutationOptions = <
|
||||
TError = RenderErrorResponseDTO,
|
||||
TContext = unknown
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteRole>>,
|
||||
TError,
|
||||
{ pathParams: DeleteRolePathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteRole>>,
|
||||
TError,
|
||||
{ pathParams: DeleteRolePathParameters },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['deleteRole'];
|
||||
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 deleteRole>>,
|
||||
{ pathParams: DeleteRolePathParameters }
|
||||
> = (props) => {
|
||||
const { pathParams } = props ?? {};
|
||||
|
||||
return deleteRole(pathParams);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type DeleteRoleMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof deleteRole>>
|
||||
>;
|
||||
|
||||
export type DeleteRoleMutationError = RenderErrorResponseDTO;
|
||||
|
||||
/**
|
||||
* @summary Delete role
|
||||
*/
|
||||
export const useDeleteRole = <
|
||||
TError = RenderErrorResponseDTO,
|
||||
TContext = unknown
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteRole>>,
|
||||
TError,
|
||||
{ pathParams: DeleteRolePathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof deleteRole>>,
|
||||
TError,
|
||||
{ pathParams: DeleteRolePathParameters },
|
||||
TContext
|
||||
> => {
|
||||
const mutationOptions = getDeleteRoleMutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
/**
|
||||
* This endpoint gets a role
|
||||
* @summary Get role
|
||||
*/
|
||||
export const getRole = (
|
||||
{ id }: GetRolePathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetRole200>({
|
||||
url: `/api/v1/roles/${id}`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetRoleQueryKey = ({ id }: GetRolePathParameters) => {
|
||||
return ['getRole'] as const;
|
||||
};
|
||||
|
||||
export const getGetRoleQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getRole>>,
|
||||
TError = RenderErrorResponseDTO
|
||||
>(
|
||||
{ id }: GetRolePathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<Awaited<ReturnType<typeof getRole>>, TError, TData>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getGetRoleQueryKey({ id });
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof getRole>>> = ({
|
||||
signal,
|
||||
}) => getRole({ id }, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: !!id,
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<Awaited<ReturnType<typeof getRole>>, TError, TData> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
};
|
||||
|
||||
export type GetRoleQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getRole>>
|
||||
>;
|
||||
export type GetRoleQueryError = RenderErrorResponseDTO;
|
||||
|
||||
/**
|
||||
* @summary Get role
|
||||
*/
|
||||
|
||||
export function useGetRole<
|
||||
TData = Awaited<ReturnType<typeof getRole>>,
|
||||
TError = RenderErrorResponseDTO
|
||||
>(
|
||||
{ id }: GetRolePathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<Awaited<ReturnType<typeof getRole>>, TError, TData>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetRoleQueryOptions({ id }, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
query.queryKey = queryOptions.queryKey;
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get role
|
||||
*/
|
||||
export const invalidateGetRole = async (
|
||||
queryClient: QueryClient,
|
||||
{ id }: GetRolePathParameters,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetRoleQueryKey({ id }) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint patches a role
|
||||
* @summary Patch role
|
||||
*/
|
||||
export const patchRole = ({ id }: PatchRolePathParameters) => {
|
||||
return GeneratedAPIInstance<string>({
|
||||
url: `/api/v1/roles/${id}`,
|
||||
method: 'PATCH',
|
||||
});
|
||||
};
|
||||
|
||||
export const getPatchRoleMutationOptions = <
|
||||
TError = RenderErrorResponseDTO,
|
||||
TContext = unknown
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof patchRole>>,
|
||||
TError,
|
||||
{ pathParams: PatchRolePathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof patchRole>>,
|
||||
TError,
|
||||
{ pathParams: PatchRolePathParameters },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['patchRole'];
|
||||
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 patchRole>>,
|
||||
{ pathParams: PatchRolePathParameters }
|
||||
> = (props) => {
|
||||
const { pathParams } = props ?? {};
|
||||
|
||||
return patchRole(pathParams);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type PatchRoleMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof patchRole>>
|
||||
>;
|
||||
|
||||
export type PatchRoleMutationError = RenderErrorResponseDTO;
|
||||
|
||||
/**
|
||||
* @summary Patch role
|
||||
*/
|
||||
export const usePatchRole = <
|
||||
TError = RenderErrorResponseDTO,
|
||||
TContext = unknown
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof patchRole>>,
|
||||
TError,
|
||||
{ pathParams: PatchRolePathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof patchRole>>,
|
||||
TError,
|
||||
{ pathParams: PatchRolePathParameters },
|
||||
TContext
|
||||
> => {
|
||||
const mutationOptions = getPatchRoleMutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
@@ -1110,6 +1110,39 @@ export interface RenderErrorResponseDTO {
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface RoletypesRoleDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: Date;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
orgId?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
type?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt?: Date;
|
||||
}
|
||||
|
||||
export interface TypesChangePasswordRequestDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -1310,6 +1343,21 @@ export interface TypesPostableAcceptInviteDTO {
|
||||
token?: string;
|
||||
}
|
||||
|
||||
export interface TypesPostableForgotPasswordDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
email?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
frontendBaseURL?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
orgId?: string;
|
||||
}
|
||||
|
||||
export interface TypesPostableInviteDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -1341,6 +1389,11 @@ export interface TypesPostableResetPasswordDTO {
|
||||
}
|
||||
|
||||
export interface TypesResetPasswordTokenDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
expiresAt?: Date;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -1682,6 +1735,42 @@ export type GetPublicDashboardWidgetQueryRange200 = {
|
||||
status?: string;
|
||||
};
|
||||
|
||||
export type ListRoles200 = {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
data?: RoletypesRoleDTO[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status?: string;
|
||||
};
|
||||
|
||||
export type CreateRole201 = {
|
||||
data?: TypesIdentifiableDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status?: string;
|
||||
};
|
||||
|
||||
export type DeleteRolePathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type GetRolePathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type GetRole200 = {
|
||||
data?: RoletypesRoleDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status?: string;
|
||||
};
|
||||
|
||||
export type PatchRolePathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type ListUsers200 = {
|
||||
/**
|
||||
* @type array
|
||||
|
||||
@@ -40,6 +40,7 @@ import type {
|
||||
TypesChangePasswordRequestDTO,
|
||||
TypesPostableAcceptInviteDTO,
|
||||
TypesPostableAPIKeyDTO,
|
||||
TypesPostableForgotPasswordDTO,
|
||||
TypesPostableInviteDTO,
|
||||
TypesPostableResetPasswordDTO,
|
||||
TypesStorableAPIKeyDTO,
|
||||
@@ -1567,3 +1568,87 @@ export const invalidateGetMyUser = async (
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint initiates the forgot password flow by sending a reset password email
|
||||
* @summary Forgot password
|
||||
*/
|
||||
export const forgotPassword = (
|
||||
typesPostableForgotPasswordDTO: TypesPostableForgotPasswordDTO,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v2/factor_password/forgot`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: typesPostableForgotPasswordDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getForgotPasswordMutationOptions = <
|
||||
TError = RenderErrorResponseDTO,
|
||||
TContext = unknown
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof forgotPassword>>,
|
||||
TError,
|
||||
{ data: TypesPostableForgotPasswordDTO },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof forgotPassword>>,
|
||||
TError,
|
||||
{ data: TypesPostableForgotPasswordDTO },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['forgotPassword'];
|
||||
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 forgotPassword>>,
|
||||
{ data: TypesPostableForgotPasswordDTO }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return forgotPassword(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type ForgotPasswordMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof forgotPassword>>
|
||||
>;
|
||||
export type ForgotPasswordMutationBody = TypesPostableForgotPasswordDTO;
|
||||
export type ForgotPasswordMutationError = RenderErrorResponseDTO;
|
||||
|
||||
/**
|
||||
* @summary Forgot password
|
||||
*/
|
||||
export const useForgotPassword = <
|
||||
TError = RenderErrorResponseDTO,
|
||||
TContext = unknown
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof forgotPassword>>,
|
||||
TError,
|
||||
{ data: TypesPostableForgotPasswordDTO },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof forgotPassword>>,
|
||||
TError,
|
||||
{ data: TypesPostableForgotPasswordDTO },
|
||||
TContext
|
||||
> => {
|
||||
const mutationOptions = getForgotPasswordMutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
|
||||
@@ -16,8 +16,8 @@
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
padding: 12px;
|
||||
background: var(--bg-ink-400, #121317);
|
||||
border: 1px solid var(--bg-ink-200, #23262e);
|
||||
background: var(--bg-ink-400);
|
||||
border: 1px solid var(--bg-ink-200);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
line-height: 1;
|
||||
color: var(--text-neutral-dark-100, #adb4c2);
|
||||
color: var(--text-neutral-dark-100);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
|
||||
.auth-footer-link-icon {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-neutral-dark-50, #eceef2);
|
||||
color: var(--text-neutral-dark-50);
|
||||
}
|
||||
|
||||
.auth-footer-link-status {
|
||||
@@ -84,14 +84,14 @@
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-ink-200, #23262e);
|
||||
background: var(--bg-ink-200);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.auth-footer-content {
|
||||
background: var(--bg-base-white, #ffffff);
|
||||
border-color: var(--bg-vanilla-300, #e9e9e9);
|
||||
background: var(--bg-base-white);
|
||||
border-color: var(--bg-vanilla-300);
|
||||
box-shadow: 0px 1px 4px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
@@ -102,14 +102,14 @@
|
||||
}
|
||||
|
||||
.auth-footer-text {
|
||||
color: var(--text-neutral-light-200, #80828d);
|
||||
color: var(--text-neutral-light-200);
|
||||
}
|
||||
|
||||
.auth-footer-link-icon {
|
||||
color: var(--text-neutral-light-100, #62636c);
|
||||
color: var(--text-neutral-light-100);
|
||||
}
|
||||
|
||||
.auth-footer-separator {
|
||||
background: var(--bg-vanilla-300, #e9e9e9);
|
||||
background: var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
font-size: 15.4px;
|
||||
font-weight: 500;
|
||||
line-height: 17.5px;
|
||||
color: var(--text-neutral-dark-50, #eceef2);
|
||||
color: var(--text-neutral-dark-50);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
gap: 8px;
|
||||
height: 32px;
|
||||
padding: 10px 16px;
|
||||
background: var(--bg-ink-400, #121317);
|
||||
background: var(--bg-ink-400);
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
@@ -52,13 +52,13 @@
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
color: var(--text-neutral-dark-100, #adb4c2);
|
||||
color: var(--text-neutral-dark-100);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-neutral-dark-100, #adb4c2);
|
||||
color: var(--text-neutral-dark-100);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
@@ -68,15 +68,15 @@
|
||||
|
||||
.lightMode {
|
||||
.auth-header-logo-text {
|
||||
color: var(--text-neutral-light-100, #62636c);
|
||||
color: var(--text-neutral-light-100);
|
||||
}
|
||||
|
||||
.auth-header-help-button {
|
||||
background: var(--bg-vanilla-200, #f5f5f5);
|
||||
background: var(--bg-vanilla-200);
|
||||
|
||||
span,
|
||||
svg {
|
||||
color: var(--text-neutral-light-200, #80828d);
|
||||
color: var(--text-neutral-light-200);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
width: 100%;
|
||||
background: var(--bg-neutral-dark-1000, #0a0c10);
|
||||
background: var(--bg-neutral-dark-1000);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -33,7 +33,7 @@
|
||||
.bg-dot-pattern {
|
||||
background: radial-gradient(
|
||||
circle,
|
||||
var(--bg-neutral-dark-50, #eceef2) 1px,
|
||||
var(--bg-neutral-dark-50) 1px,
|
||||
transparent 1px
|
||||
);
|
||||
background-size: 12px 12px;
|
||||
@@ -85,8 +85,8 @@
|
||||
height: 100%;
|
||||
background-image: repeating-linear-gradient(
|
||||
to bottom,
|
||||
var(--bg-ink-200, #23262e) 0px,
|
||||
var(--bg-ink-200, #23262e) 4px,
|
||||
var(--bg-ink-200) 0px,
|
||||
var(--bg-ink-200) 4px,
|
||||
transparent 4px,
|
||||
transparent 8px
|
||||
);
|
||||
@@ -146,7 +146,7 @@
|
||||
|
||||
.lightMode {
|
||||
.auth-page-wrapper {
|
||||
background: var(--bg-base-white, #ffffff);
|
||||
background: var(--bg-base-white);
|
||||
}
|
||||
|
||||
.bg-dot-pattern {
|
||||
@@ -172,8 +172,8 @@
|
||||
.auth-page-line-right {
|
||||
background-image: repeating-linear-gradient(
|
||||
to bottom,
|
||||
var(--bg-vanilla-300, #e9e9e9) 0px,
|
||||
var(--bg-vanilla-300, #e9e9e9) 4px,
|
||||
var(--bg-vanilla-300) 0px,
|
||||
var(--bg-vanilla-300) 4px,
|
||||
transparent 4px,
|
||||
transparent 8px
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ import { IBuilderTraceOperator } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { QueryBuilderV2Provider } from './QueryBuilderV2Context';
|
||||
import { clearPreviousQuery } from './QueryV2/previousQuery.utils';
|
||||
import QueryFooter from './QueryV2/QueryFooter/QueryFooter';
|
||||
import { QueryV2 } from './QueryV2/QueryV2';
|
||||
import TraceOperator from './QueryV2/TraceOperator/TraceOperator';
|
||||
@@ -24,6 +25,7 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
|
||||
version,
|
||||
onSignalSourceChange,
|
||||
signalSourceChangeEnabled = false,
|
||||
savePreviousQuery = false,
|
||||
}: QueryBuilderProps): JSX.Element {
|
||||
const {
|
||||
currentQuery,
|
||||
@@ -61,6 +63,14 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
|
||||
newPanelType,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
// always clear on mount and unmount to avoid stale data
|
||||
clearPreviousQuery();
|
||||
return (): void => {
|
||||
clearPreviousQuery();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const isMultiQueryAllowed = useMemo(
|
||||
() => !isListViewPanel || showTraceOperator,
|
||||
[showTraceOperator, isListViewPanel],
|
||||
@@ -200,6 +210,7 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
|
||||
onSignalSourceChange={onSignalSourceChange || ((): void => {})}
|
||||
signalSourceChangeEnabled={signalSourceChangeEnabled}
|
||||
queriesCount={1}
|
||||
savePreviousQuery={savePreviousQuery}
|
||||
/>
|
||||
) : (
|
||||
currentQuery.builder.queryData.map((query, index) => (
|
||||
@@ -222,6 +233,7 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
|
||||
onSignalSourceChange={onSignalSourceChange || ((): void => {})}
|
||||
signalSourceChangeEnabled={signalSourceChangeEnabled}
|
||||
queriesCount={currentQuery.builder.queryData.length}
|
||||
savePreviousQuery={savePreviousQuery}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -13,6 +13,13 @@ import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { SelectOption } from 'types/common/select';
|
||||
|
||||
import {
|
||||
getPreviousQueryFromKey,
|
||||
getQueryKey,
|
||||
removeKeyFromPreviousQuery,
|
||||
saveAsPreviousQuery,
|
||||
} from '../previousQuery.utils';
|
||||
|
||||
import './MetricsSelect.styles.scss';
|
||||
|
||||
export const SOURCE_OPTIONS: SelectOption<string, string>[] = [
|
||||
@@ -27,6 +34,7 @@ export const MetricsSelect = memo(function MetricsSelect({
|
||||
signalSource,
|
||||
onSignalSourceChange,
|
||||
signalSourceChangeEnabled = false,
|
||||
savePreviousQuery = false,
|
||||
}: {
|
||||
query: IBuilderQuery;
|
||||
index: number;
|
||||
@@ -34,6 +42,7 @@ export const MetricsSelect = memo(function MetricsSelect({
|
||||
signalSource: 'meter' | '';
|
||||
onSignalSourceChange: (value: string) => void;
|
||||
signalSourceChangeEnabled: boolean;
|
||||
savePreviousQuery: boolean;
|
||||
}): JSX.Element {
|
||||
const [attributeKeys, setAttributeKeys] = useState<BaseAutocompleteData[]>([]);
|
||||
|
||||
@@ -50,7 +59,11 @@ export const MetricsSelect = memo(function MetricsSelect({
|
||||
[handleChangeAggregatorAttribute, attributeKeys],
|
||||
);
|
||||
|
||||
const { updateAllQueriesOperators, handleSetQueryData } = useQueryBuilder();
|
||||
const {
|
||||
updateAllQueriesOperators,
|
||||
handleSetQueryData,
|
||||
panelType,
|
||||
} = useQueryBuilder();
|
||||
|
||||
const source = useMemo(
|
||||
() => (signalSource === 'meter' ? 'meter' : 'metrics'),
|
||||
@@ -79,22 +92,63 @@ export const MetricsSelect = memo(function MetricsSelect({
|
||||
[updateAllQueriesOperators],
|
||||
);
|
||||
|
||||
const getDefaultQueryFromSource = useCallback(
|
||||
(selectedSource: string): IBuilderQuery => {
|
||||
const isMeter = selectedSource === 'meter';
|
||||
const baseQuery = isMeter
|
||||
? defaultMeterQuery.builder.queryData[0]
|
||||
: defaultMetricsQuery.builder.queryData[0];
|
||||
|
||||
return {
|
||||
...baseQuery,
|
||||
source: isMeter ? 'meter' : '',
|
||||
queryName: query.queryName,
|
||||
};
|
||||
},
|
||||
[defaultMeterQuery, defaultMetricsQuery, query.queryName],
|
||||
);
|
||||
|
||||
const handleSignalSourceChange = (value: string): void => {
|
||||
let newQueryData: IBuilderQuery;
|
||||
|
||||
if (savePreviousQuery) {
|
||||
const queryName = query.queryName || '';
|
||||
const dataSource = query.dataSource || '';
|
||||
const currSignalSource = query.source ?? '';
|
||||
const newSignalSource = value === 'meter' ? 'meter' : '';
|
||||
|
||||
const currQueryKey = getQueryKey({
|
||||
queryName: queryName,
|
||||
dataSource: dataSource,
|
||||
signalSource: currSignalSource,
|
||||
panelType: panelType || '',
|
||||
});
|
||||
|
||||
// save the current query key in session storage
|
||||
saveAsPreviousQuery(currQueryKey, query);
|
||||
|
||||
const newQueryKey = getQueryKey({
|
||||
queryName: queryName,
|
||||
dataSource: dataSource,
|
||||
signalSource: newSignalSource,
|
||||
panelType: panelType || '',
|
||||
});
|
||||
const savedQuery: IBuilderQuery | null = getPreviousQueryFromKey(
|
||||
newQueryKey,
|
||||
);
|
||||
|
||||
// remove the new query key from session storage
|
||||
removeKeyFromPreviousQuery(newQueryKey);
|
||||
|
||||
newQueryData = savedQuery
|
||||
? savedQuery
|
||||
: getDefaultQueryFromSource(newSignalSource);
|
||||
} else {
|
||||
newQueryData = getDefaultQueryFromSource(value);
|
||||
}
|
||||
|
||||
onSignalSourceChange(value);
|
||||
handleSetQueryData(
|
||||
index,
|
||||
value === 'meter'
|
||||
? {
|
||||
...defaultMeterQuery.builder.queryData[0],
|
||||
source: 'meter',
|
||||
queryName: query.queryName,
|
||||
}
|
||||
: {
|
||||
...defaultMetricsQuery.builder.queryData[0],
|
||||
source: '',
|
||||
queryName: query.queryName,
|
||||
},
|
||||
);
|
||||
handleSetQueryData(index, newQueryData);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -42,10 +42,12 @@ export const QueryV2 = forwardRef(function QueryV2(
|
||||
onSignalSourceChange,
|
||||
signalSourceChangeEnabled = false,
|
||||
queriesCount = 1,
|
||||
savePreviousQuery = false,
|
||||
}: QueryProps & {
|
||||
onSignalSourceChange: (value: string) => void;
|
||||
signalSourceChangeEnabled: boolean;
|
||||
queriesCount: number;
|
||||
savePreviousQuery: boolean;
|
||||
},
|
||||
ref: ForwardedRef<HTMLDivElement>,
|
||||
): JSX.Element {
|
||||
@@ -67,6 +69,7 @@ export const QueryV2 = forwardRef(function QueryV2(
|
||||
filterConfigs,
|
||||
isListViewPanel,
|
||||
entityVersion: version,
|
||||
savePreviousQuery,
|
||||
});
|
||||
|
||||
const handleToggleDisableQuery = useCallback(() => {
|
||||
@@ -234,6 +237,7 @@ export const QueryV2 = forwardRef(function QueryV2(
|
||||
signalSource={signalSource as 'meter' | ''}
|
||||
onSignalSourceChange={onSignalSourceChange}
|
||||
signalSourceChangeEnabled={signalSourceChangeEnabled}
|
||||
savePreviousQuery={savePreviousQuery}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -11,9 +11,14 @@ import {
|
||||
IBuilderQuery,
|
||||
Query,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { QueryFunction } from 'types/api/v5/queryRange';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { UseQueryOperations } from 'types/common/operations.types';
|
||||
import { DataSource, QueryBuilderContextType } from 'types/common/queryBuilder';
|
||||
import {
|
||||
DataSource,
|
||||
QueryBuilderContextType,
|
||||
QueryFunctionsTypes,
|
||||
} from 'types/common/queryBuilder';
|
||||
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
@@ -45,12 +50,17 @@ const mockedUseQueryOperations = jest.mocked(
|
||||
|
||||
describe('QueryBuilderV2 + QueryV2 - base render', () => {
|
||||
let handleRunQueryMock: jest.MockedFunction<() => void>;
|
||||
let handleQueryFunctionsUpdatesMock: jest.MockedFunction<() => void>;
|
||||
let baseQBContext: QueryBuilderContextType;
|
||||
|
||||
beforeEach(() => {
|
||||
const mockCloneQuery = jest.fn() as jest.MockedFunction<
|
||||
(type: string, q: IBuilderQuery) => void
|
||||
>;
|
||||
handleRunQueryMock = jest.fn() as jest.MockedFunction<() => void>;
|
||||
handleQueryFunctionsUpdatesMock = jest.fn() as jest.MockedFunction<
|
||||
() => void
|
||||
>;
|
||||
const baseQuery: IBuilderQuery = {
|
||||
queryName: 'A',
|
||||
dataSource: DataSource.LOGS,
|
||||
@@ -91,7 +101,7 @@ describe('QueryBuilderV2 + QueryV2 - base render', () => {
|
||||
const updateQueriesData: QueryBuilderContextType['updateQueriesData'] = (q) =>
|
||||
q;
|
||||
|
||||
mockedUseQueryBuilder.mockReturnValue(({
|
||||
const baseContext = ({
|
||||
currentQuery: currentQueryObj,
|
||||
stagedQuery: null,
|
||||
lastUsedQuery: null,
|
||||
@@ -124,7 +134,10 @@ describe('QueryBuilderV2 + QueryV2 - base render', () => {
|
||||
initQueryBuilderData: jest.fn(),
|
||||
isStagedQueryUpdated: jest.fn(() => false),
|
||||
isDefaultQuery: jest.fn(() => false),
|
||||
} as unknown) as QueryBuilderContextType);
|
||||
} as unknown) as QueryBuilderContextType;
|
||||
|
||||
baseQBContext = baseContext;
|
||||
mockedUseQueryBuilder.mockReturnValue(baseQBContext);
|
||||
|
||||
mockedUseQueryOperations.mockReturnValue({
|
||||
isTracePanelType: false,
|
||||
@@ -139,7 +152,7 @@ describe('QueryBuilderV2 + QueryV2 - base render', () => {
|
||||
handleDeleteQuery: jest.fn(),
|
||||
handleChangeQueryData: (jest.fn() as unknown) as ReturnType<UseQueryOperations>['handleChangeQueryData'],
|
||||
handleChangeFormulaData: jest.fn(),
|
||||
handleQueryFunctionsUpdates: jest.fn(),
|
||||
handleQueryFunctionsUpdates: handleQueryFunctionsUpdatesMock,
|
||||
listOfAdditionalFormulaFilters: [],
|
||||
});
|
||||
});
|
||||
@@ -199,4 +212,56 @@ describe('QueryBuilderV2 + QueryV2 - base render', () => {
|
||||
|
||||
expect(handleRunQueryMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fx button is disabled when functions already exist', () => {
|
||||
const currentQueryBase = baseQBContext.currentQuery as Query;
|
||||
const supersetQueryBase = baseQBContext.supersetQuery as Query;
|
||||
|
||||
mockedUseQueryBuilder.mockReturnValueOnce({
|
||||
...baseQBContext,
|
||||
currentQuery: {
|
||||
...currentQueryBase,
|
||||
builder: {
|
||||
...currentQueryBase.builder,
|
||||
queryData: [
|
||||
{
|
||||
...currentQueryBase.builder.queryData[0],
|
||||
functions: [
|
||||
{ name: QueryFunctionsTypes.TIME_SHIFT, args: [] } as QueryFunction,
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
supersetQuery: {
|
||||
...supersetQueryBase,
|
||||
builder: {
|
||||
...supersetQueryBase.builder,
|
||||
queryData: [
|
||||
{
|
||||
...supersetQueryBase.builder.queryData[0],
|
||||
functions: [
|
||||
{ name: QueryFunctionsTypes.TIME_SHIFT, args: [] } as QueryFunction,
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
render(<QueryBuilderV2 panelType={PANEL_TYPES.TABLE} version="v4" />);
|
||||
|
||||
const fxButton = document.querySelector('.function-btn') as HTMLButtonElement;
|
||||
expect(fxButton).toBeInTheDocument();
|
||||
expect(fxButton).toBeDisabled();
|
||||
|
||||
const deleteButton = document.querySelector(
|
||||
'.query-function-delete-btn',
|
||||
) as HTMLButtonElement;
|
||||
expect(deleteButton).toBeInTheDocument();
|
||||
userEvent.click(deleteButton);
|
||||
waitFor(() => {
|
||||
expect(fxButton).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
export const PREVIOUS_QUERY_KEY = 'previousQuery';
|
||||
|
||||
function getPreviousQueryFromStore(): Record<string, IBuilderQuery> {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(PREVIOUS_QUERY_KEY);
|
||||
if (!raw) {
|
||||
return {};
|
||||
}
|
||||
const parsed = JSON.parse(raw);
|
||||
return parsed && typeof parsed === 'object' ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function writePreviousQueryToStore(store: Record<string, IBuilderQuery>): void {
|
||||
try {
|
||||
sessionStorage.setItem(PREVIOUS_QUERY_KEY, JSON.stringify(store));
|
||||
} catch {
|
||||
// ignore quota or serialization errors
|
||||
}
|
||||
}
|
||||
|
||||
export const getQueryKey = ({
|
||||
queryName,
|
||||
dataSource,
|
||||
signalSource,
|
||||
panelType,
|
||||
}: {
|
||||
queryName: string;
|
||||
dataSource: string;
|
||||
signalSource: string;
|
||||
panelType: string;
|
||||
}): string => {
|
||||
const qn = queryName || '';
|
||||
const ds = dataSource || '';
|
||||
const ss = signalSource === 'meter' ? 'meter' : '';
|
||||
const pt = panelType || '';
|
||||
return `${qn}:${ds}:${ss}:${pt}`;
|
||||
};
|
||||
|
||||
export const getPreviousQueryFromKey = (key: string): IBuilderQuery | null => {
|
||||
const previousQuery = getPreviousQueryFromStore();
|
||||
return previousQuery?.[key] ?? null;
|
||||
};
|
||||
|
||||
export const saveAsPreviousQuery = (
|
||||
key: string,
|
||||
query: IBuilderQuery,
|
||||
): void => {
|
||||
const previousQuery = getPreviousQueryFromStore();
|
||||
previousQuery[key] = query;
|
||||
writePreviousQueryToStore(previousQuery);
|
||||
};
|
||||
|
||||
export const removeKeyFromPreviousQuery = (key: string): void => {
|
||||
const previousQuery = getPreviousQueryFromStore();
|
||||
delete previousQuery[key];
|
||||
writePreviousQueryToStore(previousQuery);
|
||||
};
|
||||
|
||||
export const clearPreviousQuery = (): void => {
|
||||
try {
|
||||
sessionStorage.removeItem(PREVIOUS_QUERY_KEY);
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,369 @@
|
||||
import { jest } from '@jest/globals';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
import {
|
||||
Having,
|
||||
IBuilderQuery,
|
||||
Query,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
import { QueryBuilderV2 } from '../QueryBuilderV2';
|
||||
import {
|
||||
clearPreviousQuery,
|
||||
PREVIOUS_QUERY_KEY,
|
||||
} from '../QueryV2/previousQuery.utils';
|
||||
|
||||
// Local mocks for domain-specific heavy child components
|
||||
jest.mock(
|
||||
'../QueryV2/QueryAggregation/QueryAggregation',
|
||||
() =>
|
||||
function QueryAggregation(): JSX.Element {
|
||||
return <div>QueryAggregation</div>;
|
||||
},
|
||||
);
|
||||
jest.mock(
|
||||
'../QueryV2/MerticsAggregateSection/MetricsAggregateSection',
|
||||
() =>
|
||||
function MetricsAggregateSection(): JSX.Element {
|
||||
return <div>MetricsAggregateSection</div>;
|
||||
},
|
||||
);
|
||||
// Mock networked children to avoid axios during unit tests
|
||||
jest.mock(
|
||||
'../QueryV2/QuerySearch/QuerySearch',
|
||||
() =>
|
||||
function QuerySearch(): JSX.Element {
|
||||
return <div>QuerySearch</div>;
|
||||
},
|
||||
);
|
||||
jest.mock('container/QueryBuilder/filters', () => ({
|
||||
AggregatorFilter: (): JSX.Element => <div />,
|
||||
}));
|
||||
// Mock hooks
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder');
|
||||
|
||||
const mockedUseQueryBuilder = jest.mocked(useQueryBuilder);
|
||||
|
||||
describe('MetricsSelect - signal source switching (standalone)', () => {
|
||||
let handleSetQueryDataMock: jest.MockedFunction<
|
||||
(index: number, q: IBuilderQuery) => void
|
||||
>;
|
||||
|
||||
beforeEach(() => {
|
||||
clearPreviousQuery();
|
||||
handleSetQueryDataMock = (jest.fn() as unknown) as jest.MockedFunction<
|
||||
(index: number, q: IBuilderQuery) => void
|
||||
>;
|
||||
|
||||
const metricsQuery: IBuilderQuery = {
|
||||
queryName: 'A',
|
||||
dataSource: DataSource.METRICS,
|
||||
aggregateOperator: '',
|
||||
aggregations: [
|
||||
{
|
||||
timeAggregation: '',
|
||||
metricName: 'test_metric',
|
||||
temporality: '',
|
||||
spaceAggregation: '',
|
||||
},
|
||||
],
|
||||
timeAggregation: '',
|
||||
spaceAggregation: '',
|
||||
temporality: '',
|
||||
functions: [],
|
||||
filter: {
|
||||
expression: 'service = "test"',
|
||||
},
|
||||
filters: { items: [], op: 'AND' },
|
||||
groupBy: [],
|
||||
expression: '',
|
||||
disabled: false,
|
||||
having: [] as Having[],
|
||||
limit: 10,
|
||||
stepInterval: null,
|
||||
orderBy: [],
|
||||
legend: 'A',
|
||||
source: '',
|
||||
};
|
||||
|
||||
const currentQueryObj: Query = {
|
||||
id: 'test',
|
||||
unit: undefined,
|
||||
queryType: EQueryType.CLICKHOUSE,
|
||||
promql: [],
|
||||
clickhouse_sql: [],
|
||||
builder: {
|
||||
queryData: [metricsQuery],
|
||||
queryFormulas: [],
|
||||
queryTraceOperator: [],
|
||||
},
|
||||
};
|
||||
|
||||
(mockedUseQueryBuilder as any).mockReturnValue({
|
||||
currentQuery: currentQueryObj,
|
||||
stagedQuery: null,
|
||||
lastUsedQuery: null,
|
||||
setLastUsedQuery: jest.fn(),
|
||||
supersetQuery: currentQueryObj,
|
||||
setSupersetQuery: jest.fn(),
|
||||
initialDataSource: null,
|
||||
panelType: PANEL_TYPES.TABLE,
|
||||
isEnabledQuery: true,
|
||||
handleSetQueryData: handleSetQueryDataMock,
|
||||
handleSetTraceOperatorData: jest.fn(),
|
||||
handleSetFormulaData: jest.fn(),
|
||||
handleSetQueryItemData: jest.fn(),
|
||||
handleSetConfig: jest.fn(),
|
||||
removeQueryBuilderEntityByIndex: jest.fn(),
|
||||
removeAllQueryBuilderEntities: jest.fn(),
|
||||
removeQueryTypeItemByIndex: jest.fn(),
|
||||
addNewBuilderQuery: jest.fn(),
|
||||
addNewFormula: jest.fn(),
|
||||
removeTraceOperator: jest.fn(),
|
||||
addTraceOperator: jest.fn(),
|
||||
cloneQuery: jest.fn(),
|
||||
addNewQueryItem: jest.fn(),
|
||||
redirectWithQueryBuilderData: jest.fn(),
|
||||
handleRunQuery: jest.fn(),
|
||||
resetQuery: jest.fn(),
|
||||
handleOnUnitsChange: jest.fn(),
|
||||
updateAllQueriesOperators: ((q: any) => q) as any,
|
||||
updateQueriesData: ((q: any) => q) as any,
|
||||
initQueryBuilderData: jest.fn(),
|
||||
isStagedQueryUpdated: jest.fn(() => false),
|
||||
isDefaultQuery: jest.fn(() => false),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
clearPreviousQuery();
|
||||
});
|
||||
|
||||
it('savePreviousQuery=true: metrics → meter saves previous query in session storage with appropriate key and query', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(
|
||||
<QueryBuilderV2
|
||||
panelType={PANEL_TYPES.TABLE}
|
||||
version="v5"
|
||||
signalSourceChangeEnabled
|
||||
savePreviousQuery
|
||||
/>,
|
||||
);
|
||||
const trigger = document.querySelector(
|
||||
'.metrics-container .source-selector .ant-select-selector',
|
||||
) as HTMLElement;
|
||||
|
||||
await user.click(trigger);
|
||||
|
||||
// wait for dropdown and choose Meter
|
||||
const meterOption = await screen.findByText('Meter');
|
||||
await user.click(meterOption);
|
||||
|
||||
expect(handleSetQueryDataMock).toHaveBeenCalled();
|
||||
const [, arg] = handleSetQueryDataMock.mock.calls[0];
|
||||
expect(arg.queryName).toBe('A');
|
||||
expect(arg.dataSource).toBe(DataSource.METRICS);
|
||||
expect(arg.source).toBe('meter');
|
||||
|
||||
// verify previousQuery store has the expected key and filter expression
|
||||
const storeRaw = sessionStorage.getItem(PREVIOUS_QUERY_KEY);
|
||||
expect(storeRaw).not.toBeNull();
|
||||
const store = JSON.parse(storeRaw || '{}') as Record<string, IBuilderQuery>;
|
||||
expect(Object.keys(store)).toContain('A:metrics::table');
|
||||
expect(store['A:metrics::table']?.filter?.expression).toBe(
|
||||
'service = "test"',
|
||||
);
|
||||
});
|
||||
|
||||
it('savePreviousQuery=false: metrics → meter does not write to sessionStorage and applies defaults', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
// render WITHOUT savePreviousQuery enabled
|
||||
render(
|
||||
<QueryBuilderV2
|
||||
panelType={PANEL_TYPES.TABLE}
|
||||
version="v5"
|
||||
signalSourceChangeEnabled
|
||||
/>,
|
||||
);
|
||||
|
||||
// open Source and choose Meter
|
||||
const trigger = document.querySelector(
|
||||
'.metrics-container .source-selector .ant-select-selector',
|
||||
) as HTMLElement;
|
||||
await user.click(trigger);
|
||||
const meterOption = await screen.findByText('Meter');
|
||||
await user.click(meterOption);
|
||||
|
||||
// assert no session storage written
|
||||
const storeRaw = sessionStorage.getItem(PREVIOUS_QUERY_KEY);
|
||||
// either null or empty object string
|
||||
expect(storeRaw === null || storeRaw === '{}').toBe(true);
|
||||
|
||||
// and query updated to defaults (at least source should be 'meter')
|
||||
expect(handleSetQueryDataMock).toHaveBeenCalled();
|
||||
const [, arg] = handleSetQueryDataMock.mock.calls[0];
|
||||
expect(arg.queryName).toBe('A');
|
||||
expect(arg.dataSource).toBe(DataSource.METRICS);
|
||||
expect(arg.source).toBe('meter');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DataSource change - Logs to Traces', () => {
|
||||
let handleSetQueryDataMock: jest.MockedFunction<
|
||||
(index: number, q: IBuilderQuery) => void
|
||||
>;
|
||||
|
||||
beforeEach(() => {
|
||||
clearPreviousQuery();
|
||||
handleSetQueryDataMock = (jest.fn() as unknown) as jest.MockedFunction<
|
||||
(i: number, q: IBuilderQuery) => void
|
||||
>;
|
||||
|
||||
const logsQuery: IBuilderQuery = {
|
||||
queryName: 'A',
|
||||
dataSource: DataSource.LOGS,
|
||||
aggregateOperator: '',
|
||||
aggregations: [],
|
||||
timeAggregation: '',
|
||||
spaceAggregation: '',
|
||||
temporality: '',
|
||||
functions: [],
|
||||
filter: { expression: 'body CONTAINS "error"' },
|
||||
filters: { items: [], op: 'AND' },
|
||||
groupBy: [],
|
||||
expression: '',
|
||||
disabled: false,
|
||||
having: [] as Having[],
|
||||
limit: 100,
|
||||
stepInterval: null,
|
||||
orderBy: [],
|
||||
legend: 'L',
|
||||
source: '',
|
||||
};
|
||||
|
||||
const logsCurrentQuery: Query = {
|
||||
id: 'test-logs',
|
||||
unit: undefined,
|
||||
queryType: EQueryType.CLICKHOUSE,
|
||||
promql: [],
|
||||
clickhouse_sql: [],
|
||||
builder: {
|
||||
queryData: [logsQuery],
|
||||
queryFormulas: [],
|
||||
queryTraceOperator: [],
|
||||
},
|
||||
};
|
||||
|
||||
mockedUseQueryBuilder.mockReset();
|
||||
mockedUseQueryBuilder.mockReturnValue({
|
||||
currentQuery: logsCurrentQuery,
|
||||
stagedQuery: null,
|
||||
lastUsedQuery: null,
|
||||
setLastUsedQuery: jest.fn(),
|
||||
supersetQuery: logsCurrentQuery,
|
||||
setSupersetQuery: jest.fn(),
|
||||
initialDataSource: null,
|
||||
panelType: PANEL_TYPES.TABLE,
|
||||
isEnabledQuery: true,
|
||||
handleSetQueryData: handleSetQueryDataMock,
|
||||
handleSetTraceOperatorData: jest.fn(),
|
||||
handleSetFormulaData: jest.fn(),
|
||||
handleSetQueryItemData: jest.fn(),
|
||||
handleSetConfig: jest.fn(),
|
||||
removeQueryBuilderEntityByIndex: jest.fn(),
|
||||
removeAllQueryBuilderEntities: jest.fn(),
|
||||
removeQueryTypeItemByIndex: jest.fn(),
|
||||
addNewBuilderQuery: jest.fn(),
|
||||
addNewFormula: jest.fn(),
|
||||
removeTraceOperator: jest.fn(),
|
||||
addTraceOperator: jest.fn(),
|
||||
cloneQuery: jest.fn(),
|
||||
addNewQueryItem: jest.fn(),
|
||||
redirectWithQueryBuilderData: jest.fn(),
|
||||
handleRunQuery: jest.fn(),
|
||||
resetQuery: jest.fn(),
|
||||
handleOnUnitsChange: jest.fn(),
|
||||
updateAllQueriesOperators: ((q: any) => q) as any,
|
||||
updateQueriesData: ((q: any) => q) as any,
|
||||
initQueryBuilderData: jest.fn(),
|
||||
isStagedQueryUpdated: jest.fn(() => false),
|
||||
isDefaultQuery: jest.fn(() => false),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
clearPreviousQuery();
|
||||
});
|
||||
|
||||
it('updates query dataSource to TRACES saves previous query in session storage', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<QueryBuilderV2
|
||||
panelType={PANEL_TYPES.TABLE}
|
||||
version="v5"
|
||||
signalSourceChangeEnabled
|
||||
savePreviousQuery
|
||||
/>,
|
||||
);
|
||||
|
||||
const logsTrigger = document.querySelector(
|
||||
'.query-data-source .ant-select-selector',
|
||||
) as HTMLElement;
|
||||
await user.click(logsTrigger);
|
||||
|
||||
const tracesContent = screen.getByText('Traces', {
|
||||
selector: '.ant-select-item-option-content',
|
||||
});
|
||||
|
||||
// click the option (prefer the option element; fallback to content)
|
||||
await user.click(
|
||||
(tracesContent.closest('[role="option"]') as HTMLElement) || tracesContent,
|
||||
);
|
||||
|
||||
// verify previousQuery store saved the current LOGS snapshot before switch
|
||||
const storeRaw = sessionStorage.getItem(PREVIOUS_QUERY_KEY);
|
||||
expect(storeRaw).not.toBeNull();
|
||||
const store = JSON.parse(storeRaw || '{}') as Record<string, IBuilderQuery>;
|
||||
expect(Object.keys(store)).toContain('A:logs::table');
|
||||
expect(store['A:logs::table']?.filter?.expression).toBe(
|
||||
'body CONTAINS "error"',
|
||||
);
|
||||
});
|
||||
it('updates query dataSource to TRACES does not saves previous query in session storage', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<QueryBuilderV2
|
||||
panelType={PANEL_TYPES.TABLE}
|
||||
version="v5"
|
||||
signalSourceChangeEnabled
|
||||
/>,
|
||||
);
|
||||
|
||||
const logsTrigger = document.querySelector(
|
||||
'.query-data-source .ant-select-selector',
|
||||
) as HTMLElement;
|
||||
await user.click(logsTrigger);
|
||||
|
||||
const tracesContent = screen.getByText('Traces', {
|
||||
selector: '.ant-select-item-option-content',
|
||||
});
|
||||
|
||||
// click the option (prefer the option element; fallback to content)
|
||||
await user.click(
|
||||
(tracesContent.closest('[role="option"]') as HTMLElement) || tracesContent,
|
||||
);
|
||||
|
||||
// Assert that no snapshot was written
|
||||
const storeRaw = sessionStorage.getItem(PREVIOUS_QUERY_KEY);
|
||||
expect(storeRaw === null || storeRaw === '{}').toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
import {
|
||||
clearPreviousQuery,
|
||||
getPreviousQueryFromKey,
|
||||
getQueryKey,
|
||||
PREVIOUS_QUERY_KEY,
|
||||
removeKeyFromPreviousQuery,
|
||||
saveAsPreviousQuery,
|
||||
} from '../QueryV2/previousQuery.utils';
|
||||
|
||||
describe('previousQuery.utils', () => {
|
||||
const sampleQuery: IBuilderQuery = {
|
||||
queryName: 'A',
|
||||
dataSource: 'metrics' as any,
|
||||
aggregateOperator: '',
|
||||
aggregations: [],
|
||||
timeAggregation: '',
|
||||
spaceAggregation: '',
|
||||
temporality: '',
|
||||
functions: [],
|
||||
filter: { expression: 'service = "test"' },
|
||||
filters: { items: [], op: 'AND' },
|
||||
groupBy: [],
|
||||
expression: '',
|
||||
disabled: false,
|
||||
having: [],
|
||||
limit: 10,
|
||||
stepInterval: null,
|
||||
orderBy: [],
|
||||
legend: 'A',
|
||||
source: '',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
try {
|
||||
sessionStorage.clear();
|
||||
} catch {
|
||||
// jsdom environment
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('getQueryKey normalizes non-meter signal to empty string', () => {
|
||||
const k1 = getQueryKey({
|
||||
queryName: 'A',
|
||||
dataSource: 'metrics',
|
||||
signalSource: 'metrics', // should normalize to ''
|
||||
panelType: 'TABLE',
|
||||
});
|
||||
const k2 = getQueryKey({
|
||||
queryName: 'A',
|
||||
dataSource: 'metrics',
|
||||
signalSource: '',
|
||||
panelType: 'TABLE',
|
||||
});
|
||||
expect(k1).toBe('A:metrics::TABLE');
|
||||
expect(k2).toBe('A:metrics::TABLE');
|
||||
|
||||
const km = getQueryKey({
|
||||
queryName: 'A',
|
||||
dataSource: 'metrics',
|
||||
signalSource: 'meter',
|
||||
panelType: 'TABLE',
|
||||
});
|
||||
expect(km).toBe('A:metrics:meter:TABLE');
|
||||
});
|
||||
|
||||
it('returns null for missing key when store is empty', () => {
|
||||
expect(getPreviousQueryFromKey('missing:key')).toBeNull();
|
||||
});
|
||||
|
||||
it('saveAsPreviousQuery writes and getPreviousQueryFromKey reads the same object', () => {
|
||||
const key = getQueryKey({
|
||||
queryName: 'A',
|
||||
dataSource: 'metrics',
|
||||
signalSource: '',
|
||||
panelType: 'TABLE',
|
||||
});
|
||||
saveAsPreviousQuery(key, sampleQuery);
|
||||
|
||||
const fromStore = getPreviousQueryFromKey(key);
|
||||
expect(fromStore).toEqual(sampleQuery);
|
||||
});
|
||||
|
||||
it('saveAsPreviousQuery merges multiple entries and removeKeyFromPreviousQuery deletes one', () => {
|
||||
const k1 = getQueryKey({
|
||||
queryName: 'A',
|
||||
dataSource: 'metrics',
|
||||
signalSource: '',
|
||||
panelType: 'TABLE',
|
||||
});
|
||||
const k2 = getQueryKey({
|
||||
queryName: 'B',
|
||||
dataSource: 'metrics',
|
||||
signalSource: 'meter',
|
||||
panelType: 'TABLE',
|
||||
});
|
||||
|
||||
saveAsPreviousQuery(k1, sampleQuery);
|
||||
saveAsPreviousQuery(k2, {
|
||||
...sampleQuery,
|
||||
queryName: 'B',
|
||||
source: 'meter' as any,
|
||||
});
|
||||
|
||||
expect(getPreviousQueryFromKey(k1)?.queryName).toBe('A');
|
||||
expect(getPreviousQueryFromKey(k2)?.queryName).toBe('B');
|
||||
|
||||
removeKeyFromPreviousQuery(k1);
|
||||
expect(getPreviousQueryFromKey(k1)).toBeNull();
|
||||
expect(getPreviousQueryFromKey(k2)?.queryName).toBe('B');
|
||||
});
|
||||
|
||||
it('clearPreviousQuery removes the store key', () => {
|
||||
const key = getQueryKey({
|
||||
queryName: 'A',
|
||||
dataSource: 'metrics',
|
||||
signalSource: '',
|
||||
panelType: 'TABLE',
|
||||
});
|
||||
saveAsPreviousQuery(key, sampleQuery);
|
||||
|
||||
expect(sessionStorage.getItem(PREVIOUS_QUERY_KEY)).not.toBeNull();
|
||||
clearPreviousQuery();
|
||||
expect(sessionStorage.getItem(PREVIOUS_QUERY_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('handles malformed JSON in store gracefully', () => {
|
||||
sessionStorage.setItem(PREVIOUS_QUERY_KEY, 'not valid json');
|
||||
|
||||
// Should not throw and behave as empty store
|
||||
expect(getPreviousQueryFromKey('any:key')).toBeNull();
|
||||
|
||||
// After a save, it should overwrite the bad value with valid JSON
|
||||
const validKey = getQueryKey({
|
||||
queryName: 'A',
|
||||
dataSource: 'metrics',
|
||||
signalSource: '',
|
||||
panelType: 'TABLE',
|
||||
});
|
||||
expect(() => saveAsPreviousQuery(validKey, sampleQuery)).not.toThrow();
|
||||
const parsed = JSON.parse(sessionStorage.getItem(PREVIOUS_QUERY_KEY) || '{}');
|
||||
expect(parsed[validKey]).toBeTruthy();
|
||||
});
|
||||
|
||||
it('write errors (e.g., quota) are caught and do not throw', () => {
|
||||
const spy = jest
|
||||
.spyOn(window.sessionStorage.__proto__, 'setItem')
|
||||
.mockImplementation(() => {
|
||||
throw new Error('quota exceeded');
|
||||
});
|
||||
|
||||
const key = getQueryKey({
|
||||
queryName: 'A',
|
||||
dataSource: 'metrics',
|
||||
signalSource: '',
|
||||
panelType: 'TABLE',
|
||||
});
|
||||
|
||||
expect(() => saveAsPreviousQuery(key, sampleQuery)).not.toThrow();
|
||||
|
||||
// Since write failed, reading should still behave as empty
|
||||
spy.mockRestore();
|
||||
expect(getPreviousQueryFromKey(key)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -54,4 +54,5 @@ export enum QueryParams {
|
||||
version = 'version',
|
||||
source = 'source',
|
||||
showClassicCreateAlertsPage = 'showClassicCreateAlertsPage',
|
||||
isTestAlert = 'isTestAlert',
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
}
|
||||
|
||||
.app-content {
|
||||
width: calc(100% - 64px); // width of the sidebar
|
||||
width: calc(100% - 54px); // width of the sidebar
|
||||
z-index: 0;
|
||||
|
||||
margin: 0 auto;
|
||||
|
||||
@@ -338,7 +338,7 @@ describe('CreateAlertV2 utils', () => {
|
||||
const props = getCreateAlertLocalStateFromAlertDef(args);
|
||||
expect(props).toBeDefined();
|
||||
expect(props).toMatchObject({
|
||||
basicAlertState: {
|
||||
basic: {
|
||||
...INITIAL_ALERT_STATE,
|
||||
name: 'test-alert',
|
||||
labels: {
|
||||
@@ -348,10 +348,10 @@ describe('CreateAlertV2 utils', () => {
|
||||
yAxisUnit: UniversalYAxisUnit.MINUTES,
|
||||
},
|
||||
// as we have already verified these utils in their respective tests
|
||||
thresholdState: expect.any(Object),
|
||||
advancedOptionsState: expect.any(Object),
|
||||
evaluationWindowState: expect.any(Object),
|
||||
notificationSettingsState: expect.any(Object),
|
||||
threshold: expect.any(Object),
|
||||
advancedOptions: expect.any(Object),
|
||||
evaluationWindow: expect.any(Object),
|
||||
notificationSettings: expect.any(Object),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -196,3 +196,11 @@ export const INITIAL_NOTIFICATION_SETTINGS_STATE: NotificationSettingsState = {
|
||||
description: NOTIFICATION_MESSAGE_PLACEHOLDER,
|
||||
routingPolicies: false,
|
||||
};
|
||||
|
||||
export const INITIAL_CREATE_ALERT_STATE = {
|
||||
basic: INITIAL_ALERT_STATE,
|
||||
threshold: INITIAL_ALERT_THRESHOLD_STATE,
|
||||
advancedOptions: INITIAL_ADVANCED_OPTIONS_STATE,
|
||||
evaluationWindow: INITIAL_EVALUATION_WINDOW_STATE,
|
||||
notificationSettings: INITIAL_NOTIFICATION_SETTINGS_STATE,
|
||||
};
|
||||
|
||||
@@ -17,26 +17,22 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
|
||||
import { INITIAL_CREATE_ALERT_STATE } from './constants';
|
||||
import {
|
||||
INITIAL_ADVANCED_OPTIONS_STATE,
|
||||
INITIAL_ALERT_STATE,
|
||||
INITIAL_ALERT_THRESHOLD_STATE,
|
||||
INITIAL_EVALUATION_WINDOW_STATE,
|
||||
INITIAL_NOTIFICATION_SETTINGS_STATE,
|
||||
} from './constants';
|
||||
import {
|
||||
AdvancedOptionsAction,
|
||||
AlertThresholdAction,
|
||||
AlertThresholdMatchType,
|
||||
CreateAlertAction,
|
||||
CreateAlertSlice,
|
||||
EvaluationWindowAction,
|
||||
ICreateAlertContextProps,
|
||||
ICreateAlertProviderProps,
|
||||
NotificationSettingsAction,
|
||||
} from './types';
|
||||
import {
|
||||
advancedOptionsReducer,
|
||||
alertCreationReducer,
|
||||
alertThresholdReducer,
|
||||
buildInitialAlertDef,
|
||||
evaluationWindowReducer,
|
||||
createAlertReducer,
|
||||
getInitialAlertTypeFromURL,
|
||||
notificationSettingsReducer,
|
||||
} from './utils';
|
||||
|
||||
const CreateAlertContext = createContext<ICreateAlertContextProps | null>(null);
|
||||
@@ -65,10 +61,65 @@ export function CreateAlertProvider(
|
||||
|
||||
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
|
||||
|
||||
const [alertState, setAlertState] = useReducer(alertCreationReducer, {
|
||||
...INITIAL_ALERT_STATE,
|
||||
yAxisUnit: currentQuery.unit,
|
||||
});
|
||||
const [createAlertState, setCreateAlertState] = useReducer(
|
||||
createAlertReducer,
|
||||
{
|
||||
...INITIAL_CREATE_ALERT_STATE,
|
||||
basic: {
|
||||
...INITIAL_CREATE_ALERT_STATE.basic,
|
||||
yAxisUnit: currentQuery.unit,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const setAlertState = useCallback(
|
||||
(action: CreateAlertAction) => {
|
||||
setCreateAlertState({
|
||||
slice: CreateAlertSlice.BASIC,
|
||||
action,
|
||||
});
|
||||
},
|
||||
[setCreateAlertState],
|
||||
);
|
||||
|
||||
const setThresholdState = useCallback(
|
||||
(action: AlertThresholdAction) => {
|
||||
setCreateAlertState({
|
||||
slice: CreateAlertSlice.THRESHOLD,
|
||||
action,
|
||||
});
|
||||
},
|
||||
[setCreateAlertState],
|
||||
);
|
||||
|
||||
const setEvaluationWindow = useCallback(
|
||||
(action: EvaluationWindowAction) => {
|
||||
setCreateAlertState({
|
||||
slice: CreateAlertSlice.EVALUATION_WINDOW,
|
||||
action,
|
||||
});
|
||||
},
|
||||
[setCreateAlertState],
|
||||
);
|
||||
|
||||
const setAdvancedOptions = useCallback(
|
||||
(action: AdvancedOptionsAction) => {
|
||||
setCreateAlertState({
|
||||
slice: CreateAlertSlice.ADVANCED_OPTIONS,
|
||||
action,
|
||||
});
|
||||
},
|
||||
[setCreateAlertState],
|
||||
);
|
||||
const setNotificationSettings = useCallback(
|
||||
(action: NotificationSettingsAction) => {
|
||||
setCreateAlertState({
|
||||
slice: CreateAlertSlice.NOTIFICATION_SETTINGS,
|
||||
action,
|
||||
});
|
||||
},
|
||||
[setCreateAlertState],
|
||||
);
|
||||
|
||||
const location = useLocation();
|
||||
const queryParams = new URLSearchParams(location.search);
|
||||
@@ -104,92 +155,56 @@ export function CreateAlertProvider(
|
||||
[redirectWithQueryBuilderData],
|
||||
);
|
||||
|
||||
const [thresholdState, setThresholdState] = useReducer(
|
||||
alertThresholdReducer,
|
||||
INITIAL_ALERT_THRESHOLD_STATE,
|
||||
);
|
||||
|
||||
const [evaluationWindow, setEvaluationWindow] = useReducer(
|
||||
evaluationWindowReducer,
|
||||
INITIAL_EVALUATION_WINDOW_STATE,
|
||||
);
|
||||
|
||||
const [advancedOptions, setAdvancedOptions] = useReducer(
|
||||
advancedOptionsReducer,
|
||||
INITIAL_ADVANCED_OPTIONS_STATE,
|
||||
);
|
||||
|
||||
const [notificationSettings, setNotificationSettings] = useReducer(
|
||||
notificationSettingsReducer,
|
||||
INITIAL_NOTIFICATION_SETTINGS_STATE,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setThresholdState({
|
||||
type: 'RESET',
|
||||
setCreateAlertState({
|
||||
slice: CreateAlertSlice.THRESHOLD,
|
||||
action: {
|
||||
type: 'RESET',
|
||||
},
|
||||
});
|
||||
|
||||
if (thresholdsFromURL) {
|
||||
try {
|
||||
const thresholds = JSON.parse(thresholdsFromURL);
|
||||
setThresholdState({
|
||||
type: 'SET_THRESHOLDS',
|
||||
payload: thresholds,
|
||||
setCreateAlertState({
|
||||
slice: CreateAlertSlice.THRESHOLD,
|
||||
action: {
|
||||
type: 'SET_THRESHOLDS',
|
||||
payload: thresholds,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error parsing thresholds from URL:', error);
|
||||
}
|
||||
|
||||
setEvaluationWindow({
|
||||
type: 'SET_INITIAL_STATE_FOR_METER',
|
||||
setCreateAlertState({
|
||||
slice: CreateAlertSlice.EVALUATION_WINDOW,
|
||||
action: {
|
||||
type: 'SET_INITIAL_STATE_FOR_METER',
|
||||
},
|
||||
});
|
||||
|
||||
setThresholdState({
|
||||
type: 'SET_MATCH_TYPE',
|
||||
payload: AlertThresholdMatchType.IN_TOTAL,
|
||||
setCreateAlertState({
|
||||
slice: CreateAlertSlice.THRESHOLD,
|
||||
action: {
|
||||
type: 'SET_MATCH_TYPE',
|
||||
payload: AlertThresholdMatchType.IN_TOTAL,
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [alertType, thresholdsFromURL]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditMode && initialAlertState) {
|
||||
setAlertState({
|
||||
setCreateAlertState({
|
||||
type: 'SET_INITIAL_STATE',
|
||||
payload: initialAlertState.basicAlertState,
|
||||
});
|
||||
setThresholdState({
|
||||
type: 'SET_INITIAL_STATE',
|
||||
payload: initialAlertState.thresholdState,
|
||||
});
|
||||
setEvaluationWindow({
|
||||
type: 'SET_INITIAL_STATE',
|
||||
payload: initialAlertState.evaluationWindowState,
|
||||
});
|
||||
setAdvancedOptions({
|
||||
type: 'SET_INITIAL_STATE',
|
||||
payload: initialAlertState.advancedOptionsState,
|
||||
});
|
||||
setNotificationSettings({
|
||||
type: 'SET_INITIAL_STATE',
|
||||
payload: initialAlertState.notificationSettingsState,
|
||||
payload: initialAlertState,
|
||||
});
|
||||
}
|
||||
}, [initialAlertState, isEditMode]);
|
||||
|
||||
const discardAlertRule = useCallback(() => {
|
||||
setAlertState({
|
||||
type: 'RESET',
|
||||
});
|
||||
setThresholdState({
|
||||
type: 'RESET',
|
||||
});
|
||||
setEvaluationWindow({
|
||||
type: 'RESET',
|
||||
});
|
||||
setAdvancedOptions({
|
||||
type: 'RESET',
|
||||
});
|
||||
setNotificationSettings({
|
||||
setCreateAlertState({
|
||||
type: 'RESET',
|
||||
});
|
||||
handleAlertTypeChange(AlertTypes.METRICS_BASED_ALERT);
|
||||
@@ -212,17 +227,17 @@ export function CreateAlertProvider(
|
||||
|
||||
const contextValue: ICreateAlertContextProps = useMemo(
|
||||
() => ({
|
||||
alertState,
|
||||
alertState: createAlertState.basic,
|
||||
setAlertState,
|
||||
alertType,
|
||||
setAlertType: handleAlertTypeChange,
|
||||
thresholdState,
|
||||
thresholdState: createAlertState.threshold,
|
||||
setThresholdState,
|
||||
evaluationWindow,
|
||||
evaluationWindow: createAlertState.evaluationWindow,
|
||||
setEvaluationWindow,
|
||||
advancedOptions,
|
||||
advancedOptions: createAlertState.advancedOptions,
|
||||
setAdvancedOptions,
|
||||
notificationSettings,
|
||||
notificationSettings: createAlertState.notificationSettings,
|
||||
setNotificationSettings,
|
||||
discardAlertRule,
|
||||
createAlertRule,
|
||||
@@ -234,13 +249,14 @@ export function CreateAlertProvider(
|
||||
isEditMode: isEditMode || false,
|
||||
}),
|
||||
[
|
||||
alertState,
|
||||
createAlertState,
|
||||
setAlertState,
|
||||
setThresholdState,
|
||||
setEvaluationWindow,
|
||||
setAdvancedOptions,
|
||||
setNotificationSettings,
|
||||
alertType,
|
||||
handleAlertTypeChange,
|
||||
thresholdState,
|
||||
evaluationWindow,
|
||||
advancedOptions,
|
||||
notificationSettings,
|
||||
discardAlertRule,
|
||||
createAlertRule,
|
||||
isCreatingAlertRule,
|
||||
|
||||
@@ -9,8 +9,6 @@ import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
import { PostableAlertRuleV2 } from 'types/api/alerts/alertTypesV2';
|
||||
import { Labels } from 'types/api/alerts/def';
|
||||
|
||||
import { GetCreateAlertLocalStateFromAlertDefReturn } from '../types';
|
||||
|
||||
export interface ICreateAlertContextProps {
|
||||
alertState: AlertState;
|
||||
setAlertState: Dispatch<CreateAlertAction>;
|
||||
@@ -52,7 +50,7 @@ export interface ICreateAlertContextProps {
|
||||
export interface ICreateAlertProviderProps {
|
||||
children: React.ReactNode;
|
||||
initialAlertType: AlertTypes;
|
||||
initialAlertState?: GetCreateAlertLocalStateFromAlertDefReturn;
|
||||
initialAlertState?: CreateAlertState;
|
||||
isEditMode?: boolean;
|
||||
ruleId?: string;
|
||||
}
|
||||
@@ -272,3 +270,31 @@ export type NotificationSettingsAction =
|
||||
| { type: 'SET_ROUTING_POLICIES'; payload: boolean }
|
||||
| { type: 'SET_INITIAL_STATE'; payload: NotificationSettingsState }
|
||||
| { type: 'RESET' };
|
||||
|
||||
export type CreateAlertState = {
|
||||
basic: AlertState;
|
||||
threshold: AlertThresholdState;
|
||||
advancedOptions: AdvancedOptionsState;
|
||||
evaluationWindow: EvaluationWindowState;
|
||||
notificationSettings: NotificationSettingsState;
|
||||
};
|
||||
|
||||
export enum CreateAlertSlice {
|
||||
BASIC = 'basic',
|
||||
THRESHOLD = 'threshold',
|
||||
ADVANCED_OPTIONS = 'advancedOptions',
|
||||
EVALUATION_WINDOW = 'evaluationWindow',
|
||||
NOTIFICATION_SETTINGS = 'notificationSettings',
|
||||
}
|
||||
|
||||
export type CreateAlertReducerAction =
|
||||
| { slice: CreateAlertSlice.BASIC; action: CreateAlertAction }
|
||||
| { slice: CreateAlertSlice.THRESHOLD; action: AlertThresholdAction }
|
||||
| { slice: CreateAlertSlice.ADVANCED_OPTIONS; action: AdvancedOptionsAction }
|
||||
| { slice: CreateAlertSlice.EVALUATION_WINDOW; action: EvaluationWindowAction }
|
||||
| {
|
||||
slice: CreateAlertSlice.NOTIFICATION_SETTINGS;
|
||||
action: NotificationSettingsAction;
|
||||
}
|
||||
| { type: 'RESET' }
|
||||
| { type: 'SET_INITIAL_STATE'; payload: CreateAlertState };
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
INITIAL_ADVANCED_OPTIONS_STATE,
|
||||
INITIAL_ALERT_STATE,
|
||||
INITIAL_ALERT_THRESHOLD_STATE,
|
||||
INITIAL_CREATE_ALERT_STATE,
|
||||
INITIAL_EVALUATION_WINDOW_STATE,
|
||||
INITIAL_NOTIFICATION_SETTINGS_STATE,
|
||||
} from './constants';
|
||||
@@ -28,6 +29,9 @@ import {
|
||||
AlertThresholdAction,
|
||||
AlertThresholdState,
|
||||
CreateAlertAction,
|
||||
CreateAlertReducerAction,
|
||||
CreateAlertSlice,
|
||||
CreateAlertState,
|
||||
EvaluationWindowAction,
|
||||
EvaluationWindowState,
|
||||
NotificationSettingsAction,
|
||||
@@ -251,3 +255,57 @@ export const notificationSettingsReducer = (
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
export const createAlertReducer = (
|
||||
state: CreateAlertState,
|
||||
action: CreateAlertReducerAction,
|
||||
): CreateAlertState => {
|
||||
// Global actions
|
||||
if ('type' in action) {
|
||||
switch (action.type) {
|
||||
case 'RESET':
|
||||
return INITIAL_CREATE_ALERT_STATE;
|
||||
case 'SET_INITIAL_STATE':
|
||||
return action.payload;
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
// Slice actions
|
||||
switch (action.slice) {
|
||||
case CreateAlertSlice.BASIC:
|
||||
return { ...state, basic: alertCreationReducer(state.basic, action.action) };
|
||||
case CreateAlertSlice.THRESHOLD:
|
||||
return {
|
||||
...state,
|
||||
threshold: alertThresholdReducer(state.threshold, action.action),
|
||||
};
|
||||
case CreateAlertSlice.ADVANCED_OPTIONS:
|
||||
return {
|
||||
...state,
|
||||
advancedOptions: advancedOptionsReducer(
|
||||
state.advancedOptions,
|
||||
action.action,
|
||||
),
|
||||
};
|
||||
case CreateAlertSlice.EVALUATION_WINDOW:
|
||||
return {
|
||||
...state,
|
||||
evaluationWindow: evaluationWindowReducer(
|
||||
state.evaluationWindow,
|
||||
action.action,
|
||||
),
|
||||
};
|
||||
case CreateAlertSlice.NOTIFICATION_SETTINGS:
|
||||
return {
|
||||
...state,
|
||||
notificationSettings: notificationSettingsReducer(
|
||||
state.notificationSettings,
|
||||
action.action,
|
||||
),
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -21,11 +21,11 @@ import {
|
||||
AlertThresholdMatchType,
|
||||
AlertThresholdOperator,
|
||||
AlertThresholdState,
|
||||
CreateAlertState,
|
||||
EvaluationWindowState,
|
||||
NotificationSettingsState,
|
||||
} from './context/types';
|
||||
import { EVALUATION_WINDOW_TIMEFRAME } from './EvaluationSettings/constants';
|
||||
import { GetCreateAlertLocalStateFromAlertDefReturn } from './types';
|
||||
|
||||
export function Spinner(): JSX.Element | null {
|
||||
const { isCreatingAlertRule, isUpdatingAlertRule } = useCreateAlertState();
|
||||
@@ -265,14 +265,14 @@ export function getThresholdStateFromAlertDef(
|
||||
|
||||
export function getCreateAlertLocalStateFromAlertDef(
|
||||
alertDef: PostableAlertRuleV2 | undefined,
|
||||
): GetCreateAlertLocalStateFromAlertDefReturn {
|
||||
): CreateAlertState {
|
||||
if (!alertDef) {
|
||||
return {
|
||||
basicAlertState: INITIAL_ALERT_STATE,
|
||||
thresholdState: INITIAL_ALERT_THRESHOLD_STATE,
|
||||
advancedOptionsState: INITIAL_ADVANCED_OPTIONS_STATE,
|
||||
evaluationWindowState: INITIAL_EVALUATION_WINDOW_STATE,
|
||||
notificationSettingsState: INITIAL_NOTIFICATION_SETTINGS_STATE,
|
||||
basic: INITIAL_ALERT_STATE,
|
||||
threshold: INITIAL_ALERT_THRESHOLD_STATE,
|
||||
advancedOptions: INITIAL_ADVANCED_OPTIONS_STATE,
|
||||
evaluationWindow: INITIAL_EVALUATION_WINDOW_STATE,
|
||||
notificationSettings: INITIAL_NOTIFICATION_SETTINGS_STATE,
|
||||
};
|
||||
}
|
||||
// Basic alert state
|
||||
@@ -294,10 +294,10 @@ export function getCreateAlertLocalStateFromAlertDef(
|
||||
);
|
||||
|
||||
return {
|
||||
basicAlertState,
|
||||
thresholdState,
|
||||
advancedOptionsState,
|
||||
evaluationWindowState,
|
||||
notificationSettingsState,
|
||||
basic: basicAlertState,
|
||||
threshold: thresholdState,
|
||||
advancedOptions: advancedOptionsState,
|
||||
evaluationWindow: evaluationWindowState,
|
||||
notificationSettings: notificationSettingsState,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ export const listViewInitialLogQuery: Query = {
|
||||
},
|
||||
};
|
||||
|
||||
export const listViewInitialTraceQuery = {
|
||||
export const listViewInitialTraceQuery: Query = {
|
||||
// it should be the above commented query
|
||||
...initialQueriesMap.traces,
|
||||
builder: {
|
||||
|
||||
@@ -22,7 +22,7 @@ import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { PenLine, Trash2 } from 'lucide-react';
|
||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
import { IDashboardVariables } from 'providers/Dashboard/store/dashboardVariablesStore';
|
||||
import { IDashboardVariables } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreTypes';
|
||||
import { IDashboardVariable } from 'types/api/dashboard/getAll';
|
||||
|
||||
import { TVariableMode } from './types';
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { memo, useEffect, useState } from 'react';
|
||||
import { memo, useEffect } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Row } from 'antd';
|
||||
import { ALL_SELECTED_VALUE } from 'components/NewSelect/utils';
|
||||
import { useDashboardVariables } from 'hooks/dashboard/useDashboardVariables';
|
||||
import {
|
||||
useDashboardVariables,
|
||||
useDashboardVariablesSelector,
|
||||
} from 'hooks/dashboard/useDashboardVariables';
|
||||
import useVariablesFromUrl from 'hooks/dashboard/useVariablesFromUrl';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
@@ -12,13 +15,7 @@ import { IDashboardVariable } from 'types/api/dashboard/getAll';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import DynamicVariableSelection from './DynamicVariableSelection';
|
||||
import {
|
||||
buildDependencies,
|
||||
buildDependencyGraph,
|
||||
buildParentDependencyGraph,
|
||||
IDependencyData,
|
||||
onUpdateVariableNode,
|
||||
} from './util';
|
||||
import { onUpdateVariableNode } from './util';
|
||||
import VariableItem from './VariableItem';
|
||||
|
||||
import './DashboardVariableSelection.styles.scss';
|
||||
@@ -35,11 +32,11 @@ function DashboardVariableSelection(): JSX.Element | null {
|
||||
const { updateUrlVariable, getUrlVariables } = useVariablesFromUrl();
|
||||
|
||||
const { dashboardVariables } = useDashboardVariables();
|
||||
|
||||
const [variablesTableData, setVariablesTableData] = useState<any>([]);
|
||||
|
||||
const [dependencyData, setDependencyData] = useState<IDependencyData | null>(
|
||||
null,
|
||||
const sortedVariablesArray = useDashboardVariablesSelector(
|
||||
(state) => state.sortedVariablesArray,
|
||||
);
|
||||
const dependencyData = useDashboardVariablesSelector(
|
||||
(state) => state.dependencyData,
|
||||
);
|
||||
|
||||
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
|
||||
@@ -47,24 +44,6 @@ function DashboardVariableSelection(): JSX.Element | null {
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const tableRowData = [];
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
for (const [key, value] of Object.entries(dashboardVariables)) {
|
||||
const { id } = value;
|
||||
|
||||
tableRowData.push({
|
||||
key,
|
||||
name: key,
|
||||
...dashboardVariables[key],
|
||||
id,
|
||||
});
|
||||
}
|
||||
|
||||
tableRowData.sort((a, b) => a.order - b.order);
|
||||
|
||||
setVariablesTableData(tableRowData);
|
||||
|
||||
// Initialize variables with default values if not in URL
|
||||
initializeDefaultVariables(
|
||||
dashboardVariables,
|
||||
@@ -73,30 +52,6 @@ function DashboardVariableSelection(): JSX.Element | null {
|
||||
);
|
||||
}, [getUrlVariables, updateUrlVariable, dashboardVariables]);
|
||||
|
||||
useEffect(() => {
|
||||
if (variablesTableData.length > 0) {
|
||||
const depGrp = buildDependencies(variablesTableData);
|
||||
const { order, graph, hasCycle, cycleNodes } = buildDependencyGraph(depGrp);
|
||||
const parentDependencyGraph = buildParentDependencyGraph(graph);
|
||||
|
||||
// cleanup order to only include variables that are of type 'QUERY'
|
||||
const cleanedOrder = order.filter((variable) => {
|
||||
const variableData = variablesTableData.find(
|
||||
(v: IDashboardVariable) => v.name === variable,
|
||||
);
|
||||
return variableData?.type === 'QUERY';
|
||||
});
|
||||
|
||||
setDependencyData({
|
||||
order: cleanedOrder,
|
||||
graph,
|
||||
parentDependencyGraph,
|
||||
hasCycle,
|
||||
cycleNodes,
|
||||
});
|
||||
}
|
||||
}, [dashboardVariables, variablesTableData]);
|
||||
|
||||
// this handles the case where the dependency order changes i.e. variable list updated via creation or deletion etc. and we need to refetch the variables
|
||||
// also trigger when the global time changes
|
||||
useEffect(
|
||||
@@ -186,45 +141,30 @@ function DashboardVariableSelection(): JSX.Element | null {
|
||||
}
|
||||
};
|
||||
|
||||
if (!dashboardVariables) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const orderBasedSortedVariables = variablesTableData.sort(
|
||||
(a: { order: number }, b: { order: number }) => a.order - b.order,
|
||||
);
|
||||
|
||||
return (
|
||||
<Row style={{ display: 'flex', gap: '12px' }}>
|
||||
{orderBasedSortedVariables &&
|
||||
Array.isArray(orderBasedSortedVariables) &&
|
||||
orderBasedSortedVariables.length > 0 &&
|
||||
orderBasedSortedVariables.map((variable) =>
|
||||
variable.type === 'DYNAMIC' ? (
|
||||
<DynamicVariableSelection
|
||||
key={`${variable.name}${variable.id}${variable.order}`}
|
||||
existingVariables={dashboardVariables}
|
||||
variableData={{
|
||||
name: variable.name,
|
||||
...variable,
|
||||
}}
|
||||
onValueUpdate={onValueUpdate}
|
||||
/>
|
||||
) : (
|
||||
<VariableItem
|
||||
key={`${variable.name}${variable.id}}${variable.order}`}
|
||||
existingVariables={dashboardVariables}
|
||||
variableData={{
|
||||
name: variable.name,
|
||||
...variable,
|
||||
}}
|
||||
onValueUpdate={onValueUpdate}
|
||||
variablesToGetUpdated={variablesToGetUpdated}
|
||||
setVariablesToGetUpdated={setVariablesToGetUpdated}
|
||||
dependencyData={dependencyData}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
{sortedVariablesArray.map((variable) => {
|
||||
const key = `${variable.name}${variable.id}${variable.order}`;
|
||||
|
||||
return variable.type === 'DYNAMIC' ? (
|
||||
<DynamicVariableSelection
|
||||
key={key}
|
||||
existingVariables={dashboardVariables}
|
||||
variableData={variable}
|
||||
onValueUpdate={onValueUpdate}
|
||||
/>
|
||||
) : (
|
||||
<VariableItem
|
||||
key={key}
|
||||
existingVariables={dashboardVariables}
|
||||
variableData={variable}
|
||||
onValueUpdate={onValueUpdate}
|
||||
variablesToGetUpdated={variablesToGetUpdated}
|
||||
setVariablesToGetUpdated={setVariablesToGetUpdated}
|
||||
dependencyData={dependencyData}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { commaValuesParser } from 'lib/dashbaordVariables/customCommaValuesParser';
|
||||
import sortValues from 'lib/dashbaordVariables/sortVariableValues';
|
||||
import { debounce, isArray, isEmpty, isString } from 'lodash-es';
|
||||
import { IDependencyData } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreTypes';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { IDashboardVariable } from 'types/api/dashboard/getAll';
|
||||
import { VariableResponseProps } from 'types/api/dashboard/variables/query';
|
||||
@@ -24,7 +25,7 @@ import { popupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import { ALL_SELECT_VALUE, variablePropsToPayloadVariables } from '../utils';
|
||||
import { SelectItemStyle } from './styles';
|
||||
import { areArraysEqual, checkAPIInvocation, IDependencyData } from './util';
|
||||
import { areArraysEqual, checkAPIInvocation } from './util';
|
||||
|
||||
import './DashboardVariableSelection.styles.scss';
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useCallback } from 'react';
|
||||
import { useAddDynamicVariableToPanels } from 'hooks/dashboard/useAddDynamicVariableToPanels';
|
||||
import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
|
||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
import { IDashboardVariables } from 'providers/Dashboard/store/dashboardVariablesStore';
|
||||
import { IDashboardVariables } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreTypes';
|
||||
import { IDashboardVariable } from 'types/api/dashboard/getAll';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { OptionData } from 'components/NewSelect/types';
|
||||
import { isEmpty, isNull } from 'lodash-es';
|
||||
import { IDashboardVariables } from 'providers/Dashboard/store/dashboardVariablesStore';
|
||||
import {
|
||||
IDashboardVariables,
|
||||
IDependencyData,
|
||||
} from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreTypes';
|
||||
import { IDashboardVariable } from 'types/api/dashboard/getAll';
|
||||
|
||||
export function areArraysEqual(
|
||||
@@ -97,14 +100,6 @@ export const buildDependencies = (
|
||||
return graph;
|
||||
};
|
||||
|
||||
export interface IDependencyData {
|
||||
order: string[];
|
||||
graph: VariableGraph;
|
||||
parentDependencyGraph: VariableGraph;
|
||||
hasCycle: boolean;
|
||||
cycleNodes?: string[];
|
||||
}
|
||||
|
||||
export const buildParentDependencyGraph = (
|
||||
graph: VariableGraph,
|
||||
): VariableGraph => {
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import ChartLayout from 'container/DashboardContainer/visualization/layout/ChartLayout/ChartLayout';
|
||||
import Legend from 'lib/uPlotV2/components/Legend/Legend';
|
||||
import Tooltip from 'lib/uPlotV2/components/Tooltip/Tooltip';
|
||||
import {
|
||||
LegendPosition,
|
||||
TooltipRenderArgs,
|
||||
} from 'lib/uPlotV2/components/types';
|
||||
import UPlotChart from 'lib/uPlotV2/components/UPlotChart';
|
||||
import { PlotContextProvider } from 'lib/uPlotV2/context/PlotContext';
|
||||
import TooltipPlugin from 'lib/uPlotV2/plugins/TooltipPlugin/TooltipPlugin';
|
||||
import _noop from 'lodash-es/noop';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { ChartProps } from '../types';
|
||||
|
||||
const TOOLTIP_WIDTH_PADDING = 60;
|
||||
const TOOLTIP_MIN_WIDTH = 200;
|
||||
|
||||
export default function TimeSeries({
|
||||
legendConfig = { position: LegendPosition.BOTTOM },
|
||||
config,
|
||||
data,
|
||||
width: containerWidth,
|
||||
height: containerHeight,
|
||||
disableTooltip = false,
|
||||
canPinTooltip = false,
|
||||
timezone,
|
||||
yAxisUnit,
|
||||
decimalPrecision,
|
||||
syncMode,
|
||||
syncKey,
|
||||
onDestroy = _noop,
|
||||
children,
|
||||
layoutChildren,
|
||||
'data-testid': testId,
|
||||
}: ChartProps): JSX.Element {
|
||||
const plotInstanceRef = useRef<uPlot | null>(null);
|
||||
|
||||
const legendComponent = useCallback(
|
||||
(averageLegendWidth: number): React.ReactNode => {
|
||||
return (
|
||||
<Legend
|
||||
config={config}
|
||||
position={legendConfig.position}
|
||||
averageLegendWidth={averageLegendWidth}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[config, legendConfig.position],
|
||||
);
|
||||
|
||||
return (
|
||||
<PlotContextProvider>
|
||||
<ChartLayout
|
||||
config={config}
|
||||
containerWidth={containerWidth}
|
||||
containerHeight={containerHeight}
|
||||
legendConfig={legendConfig}
|
||||
legendComponent={legendComponent}
|
||||
layoutChildren={layoutChildren}
|
||||
>
|
||||
{({ chartWidth, chartHeight, averageLegendWidth }): JSX.Element => (
|
||||
<UPlotChart
|
||||
config={config}
|
||||
data={data}
|
||||
width={chartWidth}
|
||||
height={chartHeight}
|
||||
plotRef={(plot): void => {
|
||||
plotInstanceRef.current = plot;
|
||||
}}
|
||||
onDestroy={(plot: uPlot): void => {
|
||||
plotInstanceRef.current = null;
|
||||
onDestroy(plot);
|
||||
}}
|
||||
data-testid={testId}
|
||||
>
|
||||
{children}
|
||||
{!disableTooltip && (
|
||||
<TooltipPlugin
|
||||
config={config}
|
||||
canPinTooltip={canPinTooltip}
|
||||
syncMode={syncMode}
|
||||
maxWidth={Math.max(
|
||||
TOOLTIP_MIN_WIDTH,
|
||||
averageLegendWidth + TOOLTIP_WIDTH_PADDING,
|
||||
)}
|
||||
syncKey={syncKey}
|
||||
render={(props: TooltipRenderArgs): React.ReactNode => (
|
||||
<Tooltip
|
||||
{...props}
|
||||
timezone={timezone}
|
||||
yAxisUnit={yAxisUnit}
|
||||
decimalPrecision={decimalPrecision}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</UPlotChart>
|
||||
)}
|
||||
</ChartLayout>
|
||||
</PlotContextProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { PrecisionOption } from 'components/Graph/types';
|
||||
import { LegendConfig } from 'lib/uPlotV2/components/types';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
|
||||
interface BaseChartProps {
|
||||
width: number;
|
||||
height: number;
|
||||
disableTooltip?: boolean;
|
||||
timezone: string;
|
||||
syncMode?: DashboardCursorSync;
|
||||
syncKey?: string;
|
||||
canPinTooltip?: boolean;
|
||||
yAxisUnit?: string;
|
||||
decimalPrecision?: PrecisionOption;
|
||||
}
|
||||
|
||||
interface TimeSeriesChartProps extends BaseChartProps {
|
||||
config: UPlotConfigBuilder;
|
||||
legendConfig: LegendConfig;
|
||||
data: uPlot.AlignedData;
|
||||
plotRef?: (plot: uPlot | null) => void;
|
||||
onDestroy?: (plot: uPlot) => void;
|
||||
children?: React.ReactNode;
|
||||
layoutChildren?: React.ReactNode;
|
||||
'data-testid'?: string;
|
||||
}
|
||||
|
||||
export type ChartProps = TimeSeriesChartProps;
|
||||
@@ -1,30 +1,36 @@
|
||||
import { MAX_LEGEND_WIDTH } from 'lib/uPlotV2/components/Legend/Legend';
|
||||
import { LegendConfig, LegendPosition } from 'lib/uPlotV2/components/types';
|
||||
|
||||
export interface ChartDimensions {
|
||||
width: number;
|
||||
height: number;
|
||||
legendWidth: number;
|
||||
legendHeight: number;
|
||||
legendsPerSet: number;
|
||||
averageLegendWidth: number;
|
||||
}
|
||||
|
||||
const AVG_CHAR_WIDTH = 8;
|
||||
const LEGEND_WIDTH_PERCENTILE = 0.85;
|
||||
const DEFAULT_AVG_LABEL_LENGTH = 15;
|
||||
const LEGEND_GAP = 16;
|
||||
const BASE_LEGEND_WIDTH = 16;
|
||||
const LEGEND_PADDING = 12;
|
||||
const LEGEND_LINE_HEIGHT = 36;
|
||||
const MAX_LEGEND_WIDTH = 400;
|
||||
const LEGEND_LINE_HEIGHT = 28;
|
||||
|
||||
/**
|
||||
* Average text width from series labels (for legendsPerSet).
|
||||
* Calculates the average width of the legend items based on the labels of the series.
|
||||
* @param legends - The labels of the series.
|
||||
* @returns The average width of the legend items.
|
||||
*/
|
||||
export function calculateAverageLegendWidth(legends: string[]): number {
|
||||
if (legends.length === 0) {
|
||||
return DEFAULT_AVG_LABEL_LENGTH;
|
||||
return DEFAULT_AVG_LABEL_LENGTH * AVG_CHAR_WIDTH;
|
||||
}
|
||||
const averageLabelLength =
|
||||
legends.reduce((sum, l) => sum + l.length, 0) / legends.length;
|
||||
return averageLabelLength * AVG_CHAR_WIDTH;
|
||||
|
||||
const lengths = legends.map((l) => l.length).sort((a, b) => a - b);
|
||||
|
||||
const index = Math.ceil(LEGEND_WIDTH_PERCENTILE * lengths.length) - 1;
|
||||
const percentileLength = lengths[Math.max(0, index)];
|
||||
|
||||
return BASE_LEGEND_WIDTH + percentileLength * AVG_CHAR_WIDTH;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -65,7 +71,7 @@ export function calculateChartDimensions({
|
||||
height: 0,
|
||||
legendWidth: 0,
|
||||
legendHeight: 0,
|
||||
legendsPerSet: 0,
|
||||
averageLegendWidth: 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -86,13 +92,15 @@ export function calculateChartDimensions({
|
||||
legendWidth: rightLegendWidth,
|
||||
legendHeight: containerHeight,
|
||||
// Single vertical list on the right.
|
||||
legendsPerSet: 1,
|
||||
averageLegendWidth: rightLegendWidth,
|
||||
};
|
||||
}
|
||||
|
||||
const legendRowHeight = LEGEND_LINE_HEIGHT + LEGEND_PADDING;
|
||||
|
||||
const legendItemWidth = Math.min(approxLegendItemWidth, 400);
|
||||
const legendItemWidth = Math.ceil(
|
||||
Math.min(approxLegendItemWidth, MAX_LEGEND_WIDTH),
|
||||
);
|
||||
const legendItemsPerRow = Math.max(
|
||||
1,
|
||||
Math.floor((containerWidth - LEGEND_PADDING * 2) / legendItemWidth),
|
||||
@@ -115,17 +123,11 @@ export function calculateChartDimensions({
|
||||
maxAllowedLegendHeight,
|
||||
);
|
||||
|
||||
// How many legend items per row in the Legend component.
|
||||
const legendsPerSet = Math.ceil(
|
||||
(containerWidth + LEGEND_GAP) /
|
||||
(Math.min(MAX_LEGEND_WIDTH, approxLegendItemWidth) + LEGEND_GAP),
|
||||
);
|
||||
|
||||
return {
|
||||
width: containerWidth,
|
||||
height: Math.max(0, containerHeight - bottomLegendHeight),
|
||||
legendWidth: containerWidth,
|
||||
legendHeight: bottomLegendHeight,
|
||||
legendsPerSet,
|
||||
averageLegendWidth: legendItemWidth,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
|
||||
.chart-layout__legend-wrapper {
|
||||
padding-left: 0 !important;
|
||||
padding-right: 12px !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface ChartLayoutProps {
|
||||
children: (props: {
|
||||
chartWidth: number;
|
||||
chartHeight: number;
|
||||
averageLegendWidth: number;
|
||||
}) => React.ReactNode;
|
||||
layoutChildren?: React.ReactNode;
|
||||
containerWidth: number;
|
||||
@@ -56,6 +57,7 @@ export default function ChartLayout({
|
||||
{children({
|
||||
chartWidth: chartDimensions.width,
|
||||
chartHeight: chartDimensions.height,
|
||||
averageLegendWidth: chartDimensions.averageLegendWidth,
|
||||
})}
|
||||
</div>
|
||||
<div
|
||||
@@ -65,7 +67,7 @@ export default function ChartLayout({
|
||||
width: chartDimensions.legendWidth,
|
||||
}}
|
||||
>
|
||||
{legendComponent(chartDimensions.legendsPerSet)}
|
||||
{legendComponent(chartDimensions.averageLegendWidth)}
|
||||
</div>
|
||||
</div>
|
||||
{layoutChildren}
|
||||
|
||||
@@ -13,3 +13,16 @@ export interface GraphVisibilityState {
|
||||
name: string;
|
||||
dataIndex: SeriesVisibilityItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Context in which a panel is rendered. Used to vary behavior (e.g. persistence,
|
||||
* interactions) per context.
|
||||
*/
|
||||
export enum PanelMode {
|
||||
/** Panel opened in full-screen / standalone view (e.g. from a dashboard widget). */
|
||||
STANDALONE_VIEW = 'STANDALONE_VIEW',
|
||||
/** Panel in the widget builder while editing a dashboard. */
|
||||
DASHBOARD_EDIT = 'DASHBOARD_EDIT',
|
||||
/** Panel rendered as a widget on a dashboard (read-only view). */
|
||||
DASHBOARD_VIEW = 'DASHBOARD_VIEW',
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { MutableRefObject } from 'react';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
import PanelWrapper from 'container/PanelWrapper/PanelWrapper';
|
||||
import { RowData } from 'lib/query/createTableColumnsFromQuery';
|
||||
import { render, screen, waitFor } from 'tests/test-utils';
|
||||
@@ -156,6 +157,7 @@ describe('PanelWrapper with DragSelect', () => {
|
||||
|
||||
render(
|
||||
<PanelWrapper
|
||||
panelMode={PanelMode.STANDALONE_VIEW}
|
||||
widget={mockWidget}
|
||||
queryResponse={mockQueryResponse}
|
||||
onDragSelect={mockOnDragSelect}
|
||||
|
||||
@@ -17,6 +17,7 @@ import WarningPopover from 'components/WarningPopover/WarningPopover';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
import useDrilldown from 'container/GridCardLayout/GridCard/FullView/useDrilldown';
|
||||
import { populateMultipleResults } from 'container/NewWidget/LeftContainer/WidgetGraph/util';
|
||||
import {
|
||||
@@ -366,6 +367,7 @@ function FullView({
|
||||
/>
|
||||
)}
|
||||
<PanelWrapper
|
||||
panelMode={PanelMode.STANDALONE_VIEW}
|
||||
queryResponse={response}
|
||||
widget={widget}
|
||||
setRequestData={setRequestData}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { useNavigateToExplorer } from 'components/CeleryTask/useNavigateToExplor
|
||||
import { ToggleGraphProps } from 'components/Graph/types';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
import { placeWidgetAtBottom } from 'container/NewWidget/utils';
|
||||
import PanelWrapper from 'container/PanelWrapper/PanelWrapper';
|
||||
import useGetResolvedText from 'hooks/dashboard/useGetResolvedText';
|
||||
@@ -407,6 +408,7 @@ function WidgetGraphComponent({
|
||||
ref={graphRef}
|
||||
>
|
||||
<PanelWrapper
|
||||
panelMode={PanelMode.DASHBOARD_VIEW}
|
||||
widget={widget}
|
||||
queryResponse={queryResponse}
|
||||
setRequestData={setRequestData}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { ToggleGraphProps } from 'components/Graph/types';
|
||||
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
|
||||
import { RowData } from 'lib/query/createTableColumnsFromQuery';
|
||||
import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin';
|
||||
import { IDashboardVariables } from 'providers/Dashboard/store/dashboardVariablesStore';
|
||||
import { IDashboardVariables } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreTypes';
|
||||
import { SuccessResponse } from 'types/api';
|
||||
import { Widgets } from 'types/api/dashboard/getAll';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
|
||||
@@ -168,7 +168,7 @@
|
||||
.ant-pagination {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
width: calc(100% - 64px);
|
||||
width: calc(100% - 54px);
|
||||
background: rgb(18, 19, 23);
|
||||
padding: 16px;
|
||||
margin: 0;
|
||||
|
||||
@@ -442,7 +442,7 @@
|
||||
.ant-pagination {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
width: calc(100% - 64px);
|
||||
width: calc(100% - 54px);
|
||||
background: var(--bg-ink-500);
|
||||
padding: 16px;
|
||||
margin: 0;
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
overflow-y: auto;
|
||||
box-sizing: border-box;
|
||||
|
||||
height: calc(100% - 64px);
|
||||
height: calc(100% - 54px);
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
height: 1rem;
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
letter-spacing: 0;
|
||||
color: var(--levels-l1-foreground, #eceef2);
|
||||
color: var(--l1-foreground);
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.065px;
|
||||
color: var(--semantic-secondary-foreground, #adb4c2);
|
||||
color: var(--l2-foreground);
|
||||
max-width: 317px;
|
||||
margin: 0 !important;
|
||||
text-align: center;
|
||||
@@ -51,8 +51,8 @@
|
||||
|
||||
.login-form-card {
|
||||
width: 100%;
|
||||
background: var(--semantic-secondary-background, #121317);
|
||||
border: 1px solid var(--semantic-secondary-border, #23262e);
|
||||
background: var(--bg-neutral-dark-950);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 4px;
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
@@ -174,12 +174,12 @@
|
||||
|
||||
&__docs-button {
|
||||
color: var(--bg-ink-400);
|
||||
border-color: var(--bg-vanilla-300, #e9e9e9);
|
||||
border-color: var(--bg-vanilla-300);
|
||||
background: transparent;
|
||||
|
||||
&:hover {
|
||||
color: var(--bg-ink-100);
|
||||
border-color: var(--bg-vanilla-400, #d1d5db);
|
||||
border-color: var(--bg-vanilla-400);
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
@@ -230,8 +230,8 @@
|
||||
|
||||
.login-form-input {
|
||||
height: 32px;
|
||||
background: var(--levels-l3-background, #23262e);
|
||||
border: 1px solid var(--levels-l3-border, #2c303a);
|
||||
background: var(--l3-background);
|
||||
border: 1px solid var(--l3-border);
|
||||
border-radius: 2px;
|
||||
padding: 6px 8px;
|
||||
font-family: Inter, sans-serif;
|
||||
@@ -239,18 +239,18 @@
|
||||
font-weight: 400;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.065px;
|
||||
color: var(--levels-l1-foreground, #eceef2);
|
||||
color: var(--l1-foreground);
|
||||
|
||||
&::placeholder {
|
||||
color: var(--levels-l3-foreground, #747b8b);
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: var(--levels-l3-border, #2c303a);
|
||||
border-color: var(--l3-border);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--semantic-primary-background, #4e74f8);
|
||||
border-color: var(--primary);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
@@ -271,8 +271,8 @@
|
||||
font-weight: 400 !important;
|
||||
line-height: 1 !important;
|
||||
letter-spacing: -0.065px !important;
|
||||
background: var(--levels-l3-background, #23262e) !important;
|
||||
border: 1px solid var(--levels-l3-border, #2c303a) !important;
|
||||
background: var(--l3-background) !important;
|
||||
border: 1px solid var(--l3-border) !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
}
|
||||
@@ -296,19 +296,19 @@
|
||||
}
|
||||
|
||||
.ant-select-selection-placeholder {
|
||||
color: var(--levels-l3-foreground, #747b8b) !important;
|
||||
color: var(--l3-foreground) !important;
|
||||
}
|
||||
|
||||
.ant-select-selection-item {
|
||||
color: var(--levels-l1-foreground, #eceef2) !important;
|
||||
color: var(--l1-foreground) !important;
|
||||
}
|
||||
|
||||
&:hover .ant-select-selector {
|
||||
border-color: var(--levels-l3-border, #2c303a) !important;
|
||||
border-color: var(--l3-border) !important;
|
||||
}
|
||||
|
||||
&.ant-select-focused .ant-select-selector {
|
||||
border-color: var(--semantic-primary-background, #4e74f8) !important;
|
||||
border-color: var(--primary) !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
}
|
||||
@@ -339,26 +339,26 @@
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
padding: 10px 16px;
|
||||
background: var(--semantic-primary-background, #4e74f8);
|
||||
background: var(--primary);
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
font-family: Inter, sans-serif;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
color: var(--semantic-primary-foreground, #eceef2);
|
||||
color: var(--bg-neutral-dark-50);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--semantic-primary-background, #4e74f8);
|
||||
background: var(--primary);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background: var(--semantic-primary-background, #4e74f8);
|
||||
background: var(--primary);
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
@@ -370,12 +370,12 @@
|
||||
}
|
||||
|
||||
.login-form-description {
|
||||
color: var(--text-neutral-light-200, #80828d);
|
||||
color: var(--text-neutral-light-200);
|
||||
}
|
||||
|
||||
.login-form-card {
|
||||
background: var(--bg-base-white, #ffffff);
|
||||
border-color: var(--bg-vanilla-300, #e9e9e9);
|
||||
background: var(--bg-base-white);
|
||||
border-color: var(--bg-vanilla-300);
|
||||
}
|
||||
|
||||
.forgot-password-link {
|
||||
@@ -383,36 +383,36 @@
|
||||
}
|
||||
|
||||
.login-form-input {
|
||||
background: var(--bg-vanilla-200, #f5f5f5);
|
||||
border-color: var(--bg-vanilla-300, #e9e9e9);
|
||||
background: var(--bg-vanilla-200);
|
||||
border-color: var(--bg-vanilla-300);
|
||||
color: var(--text-ink-500);
|
||||
|
||||
&::placeholder {
|
||||
color: var(--text-neutral-light-200, #80828d);
|
||||
color: var(--text-neutral-light-200);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--semantic-primary-background, #4e74f8);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
// Select component light mode styling
|
||||
&.ant-select {
|
||||
.ant-select-selector {
|
||||
background: var(--bg-vanilla-200, #f5f5f5) !important;
|
||||
border-color: var(--bg-vanilla-300, #e9e9e9) !important;
|
||||
background: var(--bg-vanilla-200) !important;
|
||||
border-color: var(--bg-vanilla-300) !important;
|
||||
color: var(--text-ink-500) !important;
|
||||
}
|
||||
|
||||
.ant-select-selection-placeholder {
|
||||
color: var(--text-neutral-light-200, #80828d) !important;
|
||||
color: var(--text-neutral-light-200) !important;
|
||||
}
|
||||
|
||||
&:hover .ant-select-selector {
|
||||
border-color: var(--bg-vanilla-300, #e9e9e9) !important;
|
||||
border-color: var(--bg-vanilla-300) !important;
|
||||
}
|
||||
|
||||
&.ant-select-focused .ant-select-selector {
|
||||
border-color: var(--semantic-primary-background, #4e74f8) !important;
|
||||
border-color: var(--primary) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ export const Label = styled.label`
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.065px;
|
||||
color: var(--levels-l1-foreground, #eceef2);
|
||||
color: var(--l1-foreground);
|
||||
margin-bottom: 12px;
|
||||
display: block;
|
||||
|
||||
@@ -47,29 +47,29 @@ export const FormContainer = styled(Form)`
|
||||
& .ant-input,
|
||||
& .ant-input-password,
|
||||
& .ant-select-selector {
|
||||
background: var(--levels-l3-background, #23262e) !important;
|
||||
border-color: var(--levels-l3-border, #2c303a) !important;
|
||||
color: var(--levels-l1-foreground, #eceef2) !important;
|
||||
background: var(--l3-background) !important;
|
||||
border-color: var(--l3-border) !important;
|
||||
color: var(--l1-foreground) !important;
|
||||
|
||||
.lightMode & {
|
||||
background: var(--bg-vanilla-200, #f5f5f5) !important;
|
||||
border-color: var(--bg-vanilla-300, #e9e9e9) !important;
|
||||
background: var(--bg-vanilla-200) !important;
|
||||
border-color: var(--bg-vanilla-300) !important;
|
||||
color: var(--text-ink-500) !important;
|
||||
}
|
||||
}
|
||||
|
||||
& .ant-input::placeholder {
|
||||
color: var(--levels-l3-foreground, #747b8b) !important;
|
||||
color: var(--l3-foreground) !important;
|
||||
|
||||
.lightMode & {
|
||||
color: var(--text-neutral-light-200, #80828d) !important;
|
||||
color: var(--text-neutral-light-200) !important;
|
||||
}
|
||||
}
|
||||
|
||||
& .ant-input:focus,
|
||||
& .ant-input-password:focus,
|
||||
& .ant-select-focused .ant-select-selector {
|
||||
border-color: var(--semantic-primary-background, #4e74f8) !important;
|
||||
border-color: var(--primary) !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -194,7 +194,7 @@
|
||||
.ant-pagination {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
width: calc(100% - 64px);
|
||||
width: calc(100% - 54px);
|
||||
background: var(--bg-ink-500);
|
||||
padding: 16px;
|
||||
margin: 0;
|
||||
|
||||
@@ -168,6 +168,7 @@ function QuerySection({
|
||||
isListViewPanel={selectedGraph === PANEL_TYPES.LIST}
|
||||
queryComponents={queryComponents}
|
||||
signalSourceChangeEnabled
|
||||
savePreviousQuery
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useNavigateToExplorer } from 'components/CeleryTask/useNavigateToExplor
|
||||
import { ToggleGraphProps } from 'components/Graph/types';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
import { handleGraphClick } from 'container/GridCardLayout/GridCard/utils';
|
||||
import { useGraphClickToShowButton } from 'container/GridCardLayout/useGraphClickToShowButton';
|
||||
import useNavigateToExplorerPages from 'container/GridCardLayout/useNavigateToExplorerPages';
|
||||
@@ -183,6 +184,7 @@ function WidgetGraph({
|
||||
}}
|
||||
>
|
||||
<PanelWrapper
|
||||
panelMode={PanelMode.DASHBOARD_EDIT}
|
||||
widget={selectedWidget}
|
||||
queryResponse={queryResponse}
|
||||
setRequestData={setRequestData}
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
}
|
||||
|
||||
.table-header-cell {
|
||||
color: var(--levels-l1-foreground, #eceef2);
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
@@ -111,9 +111,9 @@
|
||||
|
||||
height: 32px !important;
|
||||
border-radius: 2px;
|
||||
background: var(--levels-l3-background, #23262e);
|
||||
border: 1px solid var(--levels-l3-border, #2c303a);
|
||||
color: var(--levels-l1-foreground, #eceef2);
|
||||
background: var(--l3-background);
|
||||
border: 1px solid var(--l3-border);
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter, sans-serif;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
@@ -123,15 +123,15 @@
|
||||
box-sizing: border-box;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--levels-l3-foreground, #747b8b);
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: var(--levels-l3-border, #2c303a);
|
||||
border-color: var(--l3-border);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--semantic-primary-background, #4e74f8);
|
||||
border-color: var(--bg-robin-500);
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
@@ -142,9 +142,9 @@
|
||||
.ant-select-selector {
|
||||
height: 32px !important;
|
||||
border-radius: 2px !important;
|
||||
background: var(--levels-l3-background, #23262e) !important;
|
||||
border: 1px solid var(--levels-l3-border, #2c303a) !important;
|
||||
color: var(--levels-l1-foreground, #eceef2) !important;
|
||||
background: var(--l3-background) !important;
|
||||
border: 1px solid var(--l3-border) !important;
|
||||
color: var(--l1-foreground) !important;
|
||||
font-family: Inter, sans-serif !important;
|
||||
font-size: 13px !important;
|
||||
font-weight: 400 !important;
|
||||
@@ -154,25 +154,25 @@
|
||||
box-sizing: border-box !important;
|
||||
|
||||
.ant-select-selection-placeholder {
|
||||
color: var(--levels-l3-foreground, #747b8b) !important;
|
||||
color: var(--l3-foreground) !important;
|
||||
}
|
||||
|
||||
.ant-select-selection-item {
|
||||
color: var(--levels-l1-foreground, #eceef2) !important;
|
||||
color: var(--l1-foreground) !important;
|
||||
line-height: 30px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-select-arrow {
|
||||
color: var(--levels-l3-foreground, #747b8b) !important;
|
||||
color: var(--l3-foreground) !important;
|
||||
}
|
||||
|
||||
&.ant-select-focused .ant-select-selector {
|
||||
border-color: var(--semantic-primary-background, #4e74f8) !important;
|
||||
border-color: var(--bg-robin-500) !important;
|
||||
}
|
||||
|
||||
&:hover .ant-select-selector {
|
||||
border-color: var(--semantic-primary-background, #4e74f8) !important;
|
||||
border-color: var(--bg-robin-500) !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,7 +215,7 @@
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--semantic-primary-background, #4e74f8);
|
||||
outline: 2px solid var(--bg-robin-500);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
@@ -247,9 +247,9 @@
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
border-radius: 2px;
|
||||
border: 1px dashed var(--semantic-secondary-border, #23262e) !important;
|
||||
border: 1px dashed var(--l2-border) !important;
|
||||
background: transparent !important;
|
||||
color: var(--semantic-secondary-foreground, #adb4c2);
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter, sans-serif;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
@@ -263,38 +263,38 @@
|
||||
// Ensure icon is visible
|
||||
svg,
|
||||
[class*='icon'] {
|
||||
color: var(--semantic-secondary-foreground, #adb4c2) !important;
|
||||
color: var(--l2-foreground) !important;
|
||||
display: inline-block !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
button,
|
||||
& {
|
||||
border: 1px dashed var(--semantic-secondary-border, #23262e) !important;
|
||||
border: 1px dashed var(--l2-border) !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: var(--semantic-primary-background, #4e74f8) !important;
|
||||
border-color: var(--bg-robin-500) !important;
|
||||
border-style: dashed !important;
|
||||
color: var(--levels-l1-foreground, #eceef2);
|
||||
color: var(--l1-foreground);
|
||||
background: rgba(78, 116, 248, 0.1) !important;
|
||||
|
||||
svg,
|
||||
[class*='icon'] {
|
||||
color: var(--levels-l1-foreground, #eceef2) !important;
|
||||
color: var(--l1-foreground) !important;
|
||||
}
|
||||
|
||||
button,
|
||||
& {
|
||||
border-color: var(--semantic-primary-background, #4e74f8) !important;
|
||||
border-color: var(--bg-robin-500) !important;
|
||||
border-style: dashed !important;
|
||||
background: rgba(78, 116, 248, 0.1) !important;
|
||||
}
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--semantic-primary-background, #4e74f8);
|
||||
outline: 2px solid var(--bg-robin-500);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
@@ -327,7 +327,7 @@
|
||||
gap: 8px;
|
||||
|
||||
.success-message {
|
||||
color: var(--bg-success-500, #00b37e);
|
||||
color: var(--bg-success-500);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -343,7 +343,7 @@
|
||||
gap: 8px;
|
||||
|
||||
.partially-sent-invites-message {
|
||||
color: var(--bg-warning-500, #fbbd23);
|
||||
color: var(--bg-warning-500);
|
||||
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
@@ -358,110 +358,110 @@
|
||||
.lightMode {
|
||||
.invite-team-members-table-header {
|
||||
.table-header-cell {
|
||||
color: var(--semantic-secondary-foreground, #747b8b);
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.team-member-email-input {
|
||||
background: var(--bg-vanilla-200, #f5f5f5) !important;
|
||||
border-color: var(--bg-vanilla-300, #e9e9e9) !important;
|
||||
color: var(--text-ink-500, #1a1d26) !important;
|
||||
background: var(--bg-vanilla-200) !important;
|
||||
border-color: var(--bg-vanilla-300) !important;
|
||||
color: var(--text-ink-500) !important;
|
||||
|
||||
input {
|
||||
background: var(--bg-vanilla-200, #f5f5f5) !important;
|
||||
border-color: var(--bg-vanilla-300, #e9e9e9) !important;
|
||||
color: var(--text-ink-500, #1a1d26) !important;
|
||||
background: var(--bg-vanilla-200) !important;
|
||||
border-color: var(--bg-vanilla-300) !important;
|
||||
color: var(--text-ink-500) !important;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--text-neutral-light-200, #80828d) !important;
|
||||
color: var(--text-neutral-light-200) !important;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--semantic-primary-background, #4e74f8) !important;
|
||||
border-color: var(--bg-robin-500) !important;
|
||||
}
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--text-neutral-light-200, #80828d) !important;
|
||||
color: var(--text-neutral-light-200) !important;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bg-vanilla-300, #e9e9e9) !important;
|
||||
border-color: var(--bg-vanilla-300) !important;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--semantic-primary-background, #4e74f8) !important;
|
||||
border-color: var(--bg-robin-500) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.team-member-role-select {
|
||||
.ant-select-selector {
|
||||
background: var(--levels-l3-background, #ffffff) !important;
|
||||
border: 1px solid var(--levels-l3-border, #e9e9e9) !important;
|
||||
color: var(--levels-l1-foreground, #1a1d26) !important;
|
||||
background: var(--l3-background) !important;
|
||||
border: 1px solid var(--l3-border) !important;
|
||||
color: var(--l1-foreground) !important;
|
||||
|
||||
.ant-select-selection-placeholder {
|
||||
color: var(--levels-l3-foreground, #747b8b) !important;
|
||||
color: var(--l3-foreground) !important;
|
||||
}
|
||||
|
||||
.ant-select-selection-item {
|
||||
color: var(--levels-l1-foreground, #1a1d26) !important;
|
||||
color: var(--l1-foreground) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-select-arrow {
|
||||
color: var(--levels-l3-foreground, #747b8b) !important;
|
||||
color: var(--l3-foreground) !important;
|
||||
}
|
||||
|
||||
&.ant-select-focused .ant-select-selector {
|
||||
border-color: var(--semantic-primary-background, #4e74f8) !important;
|
||||
border-color: var(--bg-robin-500) !important;
|
||||
}
|
||||
|
||||
&:hover .ant-select-selector {
|
||||
border-color: var(--semantic-primary-background, #4e74f8) !important;
|
||||
border-color: var(--bg-robin-500) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.remove-team-member-button {
|
||||
border: none !important;
|
||||
background: transparent !important;
|
||||
color: var(--bg-cherry-500, #f56565) !important;
|
||||
color: var(--bg-cherry-500) !important;
|
||||
|
||||
svg {
|
||||
color: var(--bg-cherry-500, #f56565) !important;
|
||||
color: var(--bg-cherry-500) !important;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgba(245, 101, 101, 0.1) !important;
|
||||
color: var(--bg-cherry-500, #f56565) !important;
|
||||
color: var(--bg-cherry-500) !important;
|
||||
|
||||
svg {
|
||||
color: var(--bg-cherry-500, #f56565) !important;
|
||||
color: var(--bg-cherry-500) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.add-another-member-button {
|
||||
border: 1px dashed var(--semantic-secondary-border, #e9e9e9) !important;
|
||||
border: 1px dashed var(--text-vanilla-300) !important;
|
||||
background: transparent !important;
|
||||
color: var(--semantic-secondary-foreground, #747b8b);
|
||||
color: var(--l3-foreground);
|
||||
|
||||
svg,
|
||||
[class*='icon'] {
|
||||
color: var(--semantic-secondary-foreground, #747b8b) !important;
|
||||
color: var(--l3-foreground) !important;
|
||||
display: inline-block !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: var(--semantic-primary-background, #4e74f8) !important;
|
||||
border-color: var(--bg-robin-500) !important;
|
||||
border-style: dashed !important;
|
||||
color: var(--levels-l1-foreground, #1a1d26);
|
||||
color: var(--l1-foreground);
|
||||
background: rgba(78, 116, 248, 0.1) !important;
|
||||
|
||||
svg,
|
||||
[class*='icon'] {
|
||||
color: var(--levels-l1-foreground, #1a1d26) !important;
|
||||
color: var(--l1-foreground) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -470,7 +470,7 @@
|
||||
.invite-users-error-message-container,
|
||||
.invite-users-success-message-container {
|
||||
.success-message {
|
||||
color: var(--bg-success-500, #00b37e);
|
||||
color: var(--bg-success-500);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -479,7 +479,7 @@
|
||||
background-color: var(--bg-vanilla-100);
|
||||
|
||||
.partially-sent-invites-message {
|
||||
color: var(--bg-warning-500, #fbbd23);
|
||||
color: var(--bg-warning-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
letter-spacing: 0;
|
||||
color: var(--levels-l1-foreground, #eceef2);
|
||||
color: var(--l1-foreground);
|
||||
margin: 0 !important;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -60,7 +60,7 @@
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.065px;
|
||||
color: var(--semantic-secondary-foreground, #adb4c2);
|
||||
color: var(--l2-foreground);
|
||||
max-width: 528px;
|
||||
margin: 0 !important;
|
||||
text-align: center;
|
||||
@@ -99,8 +99,8 @@
|
||||
align-items: stretch;
|
||||
gap: 24px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--semantic-secondary-border, #23262e);
|
||||
background: var(--semantic-secondary-background, #121317);
|
||||
border: 1px solid var(--l2-border);
|
||||
background: var(--bg-neutral-dark-950);
|
||||
|
||||
.ant-form-item {
|
||||
margin-bottom: 0px !important;
|
||||
@@ -119,9 +119,9 @@
|
||||
width: 100%;
|
||||
height: 80px;
|
||||
resize: none;
|
||||
border: 1px solid var(--levels-l3-border, #2c303a);
|
||||
background: var(--levels-l3-background, #23262e);
|
||||
color: var(--levels-l1-foreground, #eceef2);
|
||||
border: 1px solid var(--l3-border);
|
||||
background: var(--l3-background);
|
||||
color: var(--l1-foreground);
|
||||
border-radius: 2px;
|
||||
font-family: Inter, sans-serif;
|
||||
font-size: 13px;
|
||||
@@ -132,13 +132,13 @@
|
||||
box-sizing: border-box;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--levels-l3-foreground, #747b8b);
|
||||
color: var(--l3-foreground);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--semantic-primary-background, #4e74f8);
|
||||
border-color: var(--bg-robin-500);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,8 +164,8 @@
|
||||
font-size: 12px;
|
||||
|
||||
height: 32px;
|
||||
background: var(--Ink-300, #16181d);
|
||||
border: 1px solid var(--Greyscale-Slate-400, #1d212d);
|
||||
background: var(--bg-ink-300);
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
color: var(--bg-vanilla-400);
|
||||
}
|
||||
|
||||
@@ -173,8 +173,8 @@
|
||||
font-size: 11px;
|
||||
height: 32px;
|
||||
min-width: 80px;
|
||||
background: var(--Ink-300, #16181d);
|
||||
border: 1px solid var(--Greyscale-Slate-400, #1d212d);
|
||||
background: var(--bg-ink-300);
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
border-left: 0px;
|
||||
color: var(--bg-vanilla-400);
|
||||
}
|
||||
@@ -182,7 +182,7 @@
|
||||
}
|
||||
|
||||
.question-label {
|
||||
color: var(--levels-l1-foreground, #eceef2);
|
||||
color: var(--l1-foreground);
|
||||
font-variant-numeric: slashed-zero;
|
||||
font-family: Inter;
|
||||
font-size: 13px;
|
||||
@@ -208,8 +208,8 @@
|
||||
gap: 8px;
|
||||
border-radius: 2px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--semantic-secondary-border, #23262e);
|
||||
color: var(--semantic-secondary-foreground, #adb4c2);
|
||||
border: 1px solid var(--l2-border);
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter, sans-serif;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
@@ -219,7 +219,7 @@
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
border-color: var(--semantic-primary-background, #4e74f8);
|
||||
border-color: var(--bg-robin-500);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
@@ -236,9 +236,9 @@
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
border-radius: 2px;
|
||||
background: var(--semantic-primary-background, #4e74f8);
|
||||
background: var(--bg-robin-500);
|
||||
border: none;
|
||||
color: var(--semantic-primary-foreground, #eceef2);
|
||||
color: var(--bg-neutral-dark-50);
|
||||
font-family: Inter, sans-serif;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
@@ -267,7 +267,7 @@
|
||||
border-radius: 2px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--semantic-secondary-foreground, #adb4c2);
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter, sans-serif;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
@@ -300,7 +300,7 @@
|
||||
margin-top: 12px;
|
||||
|
||||
.ant-slider-mark-text {
|
||||
color: var(--levels-l3-foreground, #747b8b);
|
||||
color: var(--l3-foreground);
|
||||
font-variant-numeric: lining-nums tabular-nums stacked-fractions
|
||||
slashed-zero;
|
||||
font-feature-settings: 'dlig' on, 'salt' on, 'cpsp' on, 'case' on;
|
||||
@@ -343,7 +343,7 @@
|
||||
|
||||
.question {
|
||||
font-family: Inter, sans-serif;
|
||||
color: var(--levels-l1-foreground, #eceef2);
|
||||
color: var(--l1-foreground);
|
||||
font-variant-numeric: slashed-zero;
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
@@ -358,7 +358,7 @@
|
||||
}
|
||||
|
||||
.question-slider {
|
||||
color: var(--levels-l1-foreground, #eceef2);
|
||||
color: var(--l1-foreground);
|
||||
font-variant-numeric: slashed-zero;
|
||||
font-family: Inter;
|
||||
font-size: 13px;
|
||||
@@ -381,18 +381,18 @@
|
||||
line-height: 1;
|
||||
letter-spacing: -0.065px;
|
||||
height: 32px;
|
||||
border: 1px solid var(--levels-l3-border, #2c303a);
|
||||
background: var(--levels-l3-background, #23262e);
|
||||
color: var(--levels-l1-foreground, #eceef2);
|
||||
border: 1px solid var(--l3-border);
|
||||
background: var(--l3-background);
|
||||
color: var(--l1-foreground);
|
||||
box-sizing: border-box;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--levels-l3-foreground, #747b8b);
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--semantic-primary-background, #4e74f8);
|
||||
border-color: var(--bg-robin-500);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -499,7 +499,7 @@
|
||||
width: calc((528px - 12px) / 2);
|
||||
min-width: 258px;
|
||||
flex: 0 0 calc((528px - 12px) / 2);
|
||||
color: var(--levels-l1-foreground, #eceef2);
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter, sans-serif;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
@@ -511,12 +511,12 @@
|
||||
.ant-radio-inner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-color: var(--levels-l3-border, #2c303a);
|
||||
border-color: var(--l3-border);
|
||||
}
|
||||
|
||||
&.ant-radio-checked .ant-radio-inner {
|
||||
border-color: var(--semantic-primary-background, #4e74f8);
|
||||
background-color: var(--semantic-primary-background, #4e74f8);
|
||||
border-color: var(--bg-robin-500);
|
||||
background-color: var(--bg-robin-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -541,7 +541,7 @@
|
||||
width: 100%;
|
||||
|
||||
label {
|
||||
color: var(--levels-l1-foreground, #eceef2) !important;
|
||||
color: var(--l1-foreground) !important;
|
||||
}
|
||||
|
||||
&.checkbox-item-others {
|
||||
@@ -553,9 +553,9 @@
|
||||
padding: 6px 8px;
|
||||
gap: 4px;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--levels-l3-border, #2c303a);
|
||||
background: var(--levels-l3-background, #23262e);
|
||||
color: var(--levels-l1-foreground, #eceef2);
|
||||
border: 1px solid var(--l3-border);
|
||||
background: var(--l3-background);
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter, sans-serif;
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
@@ -566,18 +566,18 @@
|
||||
box-sizing: border-box;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--levels-l3-foreground, #747b8b);
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--semantic-primary-background, #4e74f8);
|
||||
color: var(--levels-l1-foreground, #eceef2);
|
||||
border-color: var(--bg-robin-500);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-checkbox-wrapper {
|
||||
color: var(--levels-l1-foreground, #eceef2);
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter, sans-serif;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
@@ -592,19 +592,19 @@
|
||||
.ant-checkbox-inner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 1.5px solid var(--levels-l3-background, #23262e);
|
||||
border: 1.5px solid var(--l3-background);
|
||||
border-radius: 2px;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
&.ant-checkbox-checked .ant-checkbox-inner {
|
||||
background-color: var(--semantic-primary-background, #4e74f8);
|
||||
border-color: var(--semantic-primary-background, #4e74f8);
|
||||
background-color: var(--bg-robin-500);
|
||||
border-color: var(--bg-robin-500);
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
color: var(--levels-l1-foreground, #eceef2) !important;
|
||||
color: var(--l1-foreground) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -646,7 +646,7 @@
|
||||
|
||||
.add-another-member-button,
|
||||
.remove-team-member-button {
|
||||
color: var(--semantic-secondary-foreground, #adb4c2);
|
||||
color: var(--l2-foreground);
|
||||
text-align: center;
|
||||
font-variant-numeric: slashed-zero;
|
||||
font-family: Inter;
|
||||
@@ -735,11 +735,11 @@
|
||||
}
|
||||
|
||||
.onboarding-header-title {
|
||||
color: var(--levels-l1-foreground, #1a1d26) !important;
|
||||
color: var(--l1-foreground) !important;
|
||||
}
|
||||
|
||||
.onboarding-header-subtitle {
|
||||
color: var(--semantic-secondary-foreground, #747b8b) !important;
|
||||
color: var(--l3-foreground) !important;
|
||||
}
|
||||
|
||||
.questions-form {
|
||||
@@ -750,8 +750,8 @@
|
||||
align-items: stretch;
|
||||
gap: 24px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--semantic-secondary-border, #e9e9e9);
|
||||
background: var(--semantic-secondary-background, #ffffff);
|
||||
border: 1px solid var(--text-vanilla-300);
|
||||
background: var(--text-base-white);
|
||||
|
||||
.ant-form-item {
|
||||
margin-bottom: 0px !important;
|
||||
@@ -767,18 +767,18 @@
|
||||
}
|
||||
|
||||
.discover-signoz-input {
|
||||
border: 1px solid var(--levels-l3-border, #e9e9e9);
|
||||
background: var(--levels-l3-background, #ffffff);
|
||||
color: var(--levels-l1-foreground, #1a1d26);
|
||||
border: 1px solid var(--l3-border);
|
||||
background: var(--l3-background);
|
||||
color: var(--l1-foreground);
|
||||
font-weight: 400;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--levels-l3-foreground, #747b8b);
|
||||
color: var(--l3-foreground);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
border-color: var(--semantic-primary-background, #4e74f8);
|
||||
border-color: var(--bg-robin-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -808,51 +808,51 @@
|
||||
}
|
||||
|
||||
.question {
|
||||
color: var(--levels-l1-foreground, #1a1d26);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.question-slider {
|
||||
color: var(--levels-l1-foreground, #1a1d26);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.checkbox-item {
|
||||
label {
|
||||
color: var(--levels-l1-foreground, #1a1d26) !important;
|
||||
color: var(--l1-foreground) !important;
|
||||
}
|
||||
|
||||
.ant-checkbox-wrapper {
|
||||
color: var(--levels-l1-foreground, #1a1d26);
|
||||
color: var(--l1-foreground);
|
||||
|
||||
.ant-checkbox {
|
||||
.ant-checkbox-inner {
|
||||
border-color: var(--levels-l3-background, #ffffff);
|
||||
border-color: var(--l3-background);
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
&.ant-checkbox-checked .ant-checkbox-inner {
|
||||
background-color: var(--semantic-primary-background, #4e74f8);
|
||||
border-color: var(--semantic-primary-background, #4e74f8);
|
||||
background-color: var(--bg-robin-500);
|
||||
border-color: var(--bg-robin-500);
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
color: var(--levels-l1-foreground, #1a1d26) !important;
|
||||
color: var(--l1-foreground) !important;
|
||||
}
|
||||
}
|
||||
|
||||
&.checkbox-item-others {
|
||||
.onboarding-questionaire-other-input {
|
||||
border: 1px solid var(--levels-l3-border, #e9e9e9);
|
||||
background: var(--levels-l3-background, #ffffff);
|
||||
color: var(--levels-l1-foreground, #1a1d26);
|
||||
border: 1px solid var(--l3-border);
|
||||
background: var(--l3-background);
|
||||
color: var(--l1-foreground);
|
||||
|
||||
&::placeholder {
|
||||
color: var(--levels-l3-foreground, #747b8b);
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--semantic-primary-background, #4e74f8);
|
||||
color: var(--levels-l1-foreground, #1a1d26);
|
||||
border-color: var(--bg-robin-500);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -860,32 +860,32 @@
|
||||
|
||||
.observability-tool-others-item {
|
||||
.onboarding-questionaire-other-input {
|
||||
border: 1px solid var(--levels-l3-border, #e9e9e9);
|
||||
background: var(--levels-l3-background, #ffffff);
|
||||
color: var(--levels-l1-foreground, #1a1d26);
|
||||
border: 1px solid var(--l3-border);
|
||||
background: var(--l3-background);
|
||||
color: var(--l1-foreground);
|
||||
|
||||
&::placeholder {
|
||||
color: var(--levels-l3-foreground, #747b8b);
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--semantic-primary-background, #4e74f8);
|
||||
color: var(--levels-l1-foreground, #1a1d26);
|
||||
border-color: var(--bg-robin-500);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
input[type='text'] {
|
||||
border: 1px solid var(--levels-l3-border, #e9e9e9);
|
||||
background: var(--levels-l3-background, #ffffff);
|
||||
color: var(--levels-l3-foreground, #1a1d26);
|
||||
border: 1px solid var(--l3-border);
|
||||
background: var(--l3-background);
|
||||
color: var(--l1-foreground);
|
||||
|
||||
&::placeholder {
|
||||
color: var(--levels-l3-foreground, #747b8b);
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
border-color: var(--semantic-primary-background, #4e74f8);
|
||||
border-color: var(--bg-robin-500);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -981,16 +981,16 @@
|
||||
.opentelemetry-radio-group {
|
||||
.opentelemetry-radio-items-wrapper {
|
||||
.opentelemetry-radio-item {
|
||||
color: var(--levels-l1-foreground, #1a1d26);
|
||||
color: var(--l1-foreground);
|
||||
|
||||
.ant-radio {
|
||||
.ant-radio-inner {
|
||||
border-color: var(--levels-l3-border, #e9e9e9);
|
||||
border-color: var(--l3-border);
|
||||
}
|
||||
|
||||
&.ant-radio-checked .ant-radio-inner {
|
||||
border-color: var(--semantic-primary-background, #4e74f8);
|
||||
background-color: var(--semantic-primary-background, #4e74f8);
|
||||
border-color: var(--bg-robin-500);
|
||||
background-color: var(--bg-robin-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -999,11 +999,11 @@
|
||||
}
|
||||
|
||||
.onboarding-back-button {
|
||||
border-color: var(--semantic-secondary-border, #e9e9e9);
|
||||
color: var(--semantic-secondary-foreground, #747b8b);
|
||||
border-color: var(--text-vanilla-300);
|
||||
color: var(--l3-foreground);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--semantic-primary-background, #4e74f8);
|
||||
border-color: var(--bg-robin-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ function PanelWrapper({
|
||||
onOpenTraceBtnClick,
|
||||
customSeries,
|
||||
customOnRowClick,
|
||||
panelMode,
|
||||
enableDrillDown = false,
|
||||
}: PanelWrapperProps): JSX.Element {
|
||||
const Component = PanelTypeVsPanelWrapper[
|
||||
@@ -33,6 +34,7 @@ function PanelWrapper({
|
||||
}
|
||||
return (
|
||||
<Component
|
||||
panelMode={panelMode}
|
||||
widget={widget}
|
||||
queryResponse={queryResponse}
|
||||
setRequestData={setRequestData}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
import { render } from 'tests/test-utils';
|
||||
import { Widgets } from 'types/api/dashboard/getAll';
|
||||
|
||||
@@ -11,6 +12,7 @@ describe('Table panel wrappper tests', () => {
|
||||
it('table should render fine with the query response and column units', () => {
|
||||
const { container, getByText } = render(
|
||||
<TablePanelWrapper
|
||||
panelMode={PanelMode.DASHBOARD_VIEW}
|
||||
widget={(tablePanelWidgetQuery as unknown) as Widgets}
|
||||
queryResponse={(tablePanelQueryResponse as unknown) as any}
|
||||
onDragSelect={(): void => {}}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
import { render } from 'tests/test-utils';
|
||||
import { Widgets } from 'types/api/dashboard/getAll';
|
||||
|
||||
@@ -20,6 +21,7 @@ describe('Value panel wrappper tests', () => {
|
||||
it('should render value panel correctly with yaxis unit', () => {
|
||||
const { getByText } = render(
|
||||
<ValuePanelWrapper
|
||||
panelMode={PanelMode.DASHBOARD_VIEW}
|
||||
widget={(valuePanelWidget as unknown) as Widgets}
|
||||
queryResponse={(valuePanelQueryResponse as unknown) as any}
|
||||
onDragSelect={(): void => {}}
|
||||
@@ -34,6 +36,7 @@ describe('Value panel wrappper tests', () => {
|
||||
it('should render tooltip when there are conflicting thresholds', () => {
|
||||
const { getByTestId, container } = render(
|
||||
<ValuePanelWrapper
|
||||
panelMode={PanelMode.DASHBOARD_VIEW}
|
||||
widget={({ ...valuePanelWidget, thresholds } as unknown) as Widgets}
|
||||
queryResponse={(valuePanelQueryResponse as unknown) as any}
|
||||
onDragSelect={(): void => {}}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Dispatch, SetStateAction } from 'react';
|
||||
import { UseQueryResult } from 'react-query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
import { WidgetGraphComponentProps } from 'container/GridCardLayout/GridCard/types';
|
||||
import { RowData } from 'lib/query/createTableColumnsFromQuery';
|
||||
import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin';
|
||||
@@ -31,6 +32,7 @@ export type PanelWrapperProps = {
|
||||
customOnRowClick?: (record: RowData) => void;
|
||||
customSeries?: (data: QueryData[]) => uPlot.Series[];
|
||||
enableDrillDown?: boolean;
|
||||
panelMode: PanelMode;
|
||||
};
|
||||
|
||||
export type TooltipData = {
|
||||
|
||||
@@ -37,6 +37,7 @@ export type QueryBuilderProps = {
|
||||
onChangeTraceView?: (view: TraceView) => void;
|
||||
onSignalSourceChange?: (value: string) => void;
|
||||
signalSourceChangeEnabled?: boolean;
|
||||
savePreviousQuery?: boolean;
|
||||
};
|
||||
|
||||
export enum TraceView {
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
pointer-events: none;
|
||||
// line-height: 18px;
|
||||
|
||||
color: var(--bg-sakura-400) !important;
|
||||
|
||||
@@ -96,6 +96,7 @@ export default function QueryFunctions({
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
const hasAnomalyFunction = functions.some((func) => func.name === 'anomaly');
|
||||
const hasFunctions = functions.length > 0;
|
||||
|
||||
const handleAddNewFunction = (): void => {
|
||||
const defaultFunctionStruct =
|
||||
@@ -180,10 +181,14 @@ export default function QueryFunctions({
|
||||
<div
|
||||
className={cx(
|
||||
'query-functions-container',
|
||||
functions && functions.length > 0 ? 'hasFunctions' : '',
|
||||
hasFunctions ? 'hasFunctions' : '',
|
||||
)}
|
||||
>
|
||||
<Button className="periscope-btn function-btn">
|
||||
<Button
|
||||
className="periscope-btn function-btn"
|
||||
disabled={hasFunctions}
|
||||
onClick={handleAddNewFunction}
|
||||
>
|
||||
<FunctionIcon
|
||||
className="function-icon"
|
||||
fillColor={!isDarkMode ? '#0B0C0E' : 'white'}
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
initialQueriesMap,
|
||||
initialQueryBuilderFormValues,
|
||||
} from 'constants/queryBuilder';
|
||||
import { IUseDashboardVariablesReturn } from 'hooks/dashboard/useDashboardVariables';
|
||||
import { IUseDashboardVariablesReturn } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreTypes';
|
||||
import { QueryBuilderContext } from 'providers/QueryBuilder';
|
||||
import { IDashboardVariable } from 'types/api/dashboard/getAll';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
letter-spacing: 0;
|
||||
color: var(--levels-l1-foreground, #eceef2);
|
||||
color: var(--l1-foreground);
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.065px;
|
||||
color: var(--semantic-secondary-foreground, #adb4c2);
|
||||
color: var(--l2-foreground);
|
||||
margin: 0 !important;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -48,12 +48,12 @@
|
||||
margin-top: 8px;
|
||||
padding: 4px 12px;
|
||||
border-radius: 4px;
|
||||
background: var(--semantic-secondary-background, #121317);
|
||||
border: 1px solid var(--semantic-secondary-border, #23262e);
|
||||
background: var(--bg-neutral-dark-950);
|
||||
border: 1px solid var(--l3-background);
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
line-height: 1.45;
|
||||
color: var(--semantic-secondary-foreground);
|
||||
color: var(--l2-foreground);
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
@@ -63,8 +63,8 @@
|
||||
|
||||
.reset-password-form-container {
|
||||
width: 100%;
|
||||
background: var(--semantic-secondary-background, #121317);
|
||||
border: 1px solid var(--semantic-secondary-border, #23262e);
|
||||
background: var(--bg-neutral-dark-950);
|
||||
border: 1px solid var(--l3-background);
|
||||
border-radius: 4px;
|
||||
padding: 24px;
|
||||
|
||||
@@ -91,8 +91,8 @@
|
||||
&.ant-input-affix-wrapper {
|
||||
height: 32px;
|
||||
border-radius: 2px;
|
||||
background: var(--levels-l3-background, #23262e);
|
||||
border-color: var(--levels-l3-border, #2c303a);
|
||||
background: var(--l3-background);
|
||||
border-color: var(--l3-border);
|
||||
}
|
||||
|
||||
&.ant-input-affix-wrapper {
|
||||
@@ -149,27 +149,27 @@
|
||||
}
|
||||
|
||||
.reset-password-header-subtitle {
|
||||
color: var(--text-neutral-light-200, #80828d);
|
||||
color: var(--text-neutral-light-200);
|
||||
}
|
||||
|
||||
.reset-password-version-badge {
|
||||
background: var(--bg-vanilla-200, #f5f5f5);
|
||||
border: 1px solid var(--bg-vanilla-300, #e9e9e9);
|
||||
color: var(--text-neutral-light-200, #80828d);
|
||||
background: var(--bg-vanilla-200);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
color: var(--text-neutral-light-200);
|
||||
}
|
||||
}
|
||||
|
||||
.reset-password-form {
|
||||
.reset-password-form-container {
|
||||
background: var(--bg-base-white, #ffffff);
|
||||
border: 1px solid var(--bg-vanilla-300, #e9e9e9);
|
||||
background: var(--bg-base-white);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
|
||||
.reset-password-form-input {
|
||||
&.ant-input,
|
||||
&.ant-input-password,
|
||||
&.ant-input-affix-wrapper {
|
||||
background: var(--bg-vanilla-200, #f5f5f5);
|
||||
border-color: var(--bg-vanilla-300, #e9e9e9);
|
||||
background: var(--bg-vanilla-200);
|
||||
border-color: var(--bg-vanilla-300);
|
||||
color: var(--text-ink-500);
|
||||
}
|
||||
|
||||
@@ -181,11 +181,11 @@
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--text-neutral-light-200, #80828d);
|
||||
color: var(--text-neutral-light-200);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--semantic-primary-background, #4e74f8);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,12 @@
|
||||
.nav-item-active-marker {
|
||||
background: #4e74f8;
|
||||
}
|
||||
|
||||
.nav-item-data {
|
||||
.nav-item-label {
|
||||
color: var(--bg-vanilla-100, #fff);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
@@ -27,14 +33,14 @@
|
||||
|
||||
.nav-item-data {
|
||||
color: white;
|
||||
background: var(--Slate-500, #161922);
|
||||
background: var(--bg-slate-500, #161922);
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
.nav-item-data {
|
||||
color: white;
|
||||
background: var(--Slate-500, #161922);
|
||||
background: var(--bg-slate-500, #161922);
|
||||
// color: #3f5ecc;
|
||||
}
|
||||
}
|
||||
@@ -50,9 +56,9 @@
|
||||
|
||||
.nav-item-data {
|
||||
flex-grow: 1;
|
||||
max-width: calc(100% - 24px);
|
||||
max-width: calc(100% - 20px);
|
||||
display: flex;
|
||||
margin: 0px 8px;
|
||||
margin: 0px 0px 0px 6px;
|
||||
padding: 2px 8px;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
@@ -68,7 +74,7 @@
|
||||
|
||||
background: transparent;
|
||||
|
||||
transition: 0.2s all linear;
|
||||
transition: 0.08s all ease;
|
||||
|
||||
border-radius: 3px;
|
||||
|
||||
@@ -100,7 +106,7 @@
|
||||
|
||||
&:hover {
|
||||
.nav-item-label {
|
||||
color: var(--Vanilla-100, #fff);
|
||||
color: var(--bg-vanilla-100, #fff);
|
||||
}
|
||||
|
||||
.nav-item-pin-icon {
|
||||
@@ -120,6 +126,12 @@
|
||||
.nav-item-active-marker {
|
||||
background: #4e74f8;
|
||||
}
|
||||
|
||||
.nav-item-data {
|
||||
.nav-item-label {
|
||||
color: var(--bg-slate-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
/* eslint-disable jsx-a11y/no-static-element-interactions */
|
||||
/* eslint-disable jsx-a11y/click-events-have-key-events */
|
||||
import { Tag } from 'antd';
|
||||
import { Tag, Tooltip } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import { Pin, PinOff } from 'lucide-react';
|
||||
|
||||
import { SidebarItem } from '../sideNav.types';
|
||||
|
||||
import './NavItem.styles.scss';
|
||||
import './NavItem.styles.scss';
|
||||
|
||||
export default function NavItem({
|
||||
@@ -74,21 +75,25 @@ export default function NavItem({
|
||||
)}
|
||||
|
||||
{onTogglePin && !isPinned && (
|
||||
<Pin
|
||||
size={12}
|
||||
className="nav-item-pin-icon"
|
||||
onClick={handleTogglePinClick}
|
||||
color="var(--Vanilla-400, #c0c1c3)"
|
||||
/>
|
||||
<Tooltip title="Add to shortcuts" placement="right">
|
||||
<Pin
|
||||
size={12}
|
||||
className="nav-item-pin-icon"
|
||||
onClick={handleTogglePinClick}
|
||||
color="var(--Vanilla-400, #c0c1c3)"
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{onTogglePin && isPinned && (
|
||||
<PinOff
|
||||
size={12}
|
||||
className="nav-item-pin-icon"
|
||||
onClick={handleTogglePinClick}
|
||||
color="var(--Vanilla-400, #c0c1c3)"
|
||||
/>
|
||||
<Tooltip title="Remove from shortcuts" placement="right">
|
||||
<PinOff
|
||||
size={12}
|
||||
className="nav-item-pin-icon"
|
||||
onClick={handleTogglePinClick}
|
||||
color="var(--Vanilla-400, #c0c1c3)"
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
.sidenav-container {
|
||||
width: 64px;
|
||||
width: 54px;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
@@ -10,47 +10,60 @@
|
||||
}
|
||||
|
||||
.sideNav {
|
||||
flex: 0 0 64px;
|
||||
flex: 0 0 54px;
|
||||
height: 100%;
|
||||
max-width: 64px;
|
||||
min-width: 64px;
|
||||
width: 64px;
|
||||
border-right: 1px solid var(--Slate-500, #161922);
|
||||
background: var(--Ink-500, #0b0c0e);
|
||||
max-width: 54px;
|
||||
min-width: 54px;
|
||||
width: 54px;
|
||||
border-right: 1px solid var(--bg-slate-500, #161922);
|
||||
background: var(--bg-ink-500, #0b0c0e);
|
||||
|
||||
padding-bottom: 48px;
|
||||
transition: all 0.2s, background 0s, border 0s;
|
||||
transition: all 0.08s ease, background 0s, border 0s;
|
||||
|
||||
.brand-container {
|
||||
padding: 8px 18px;
|
||||
padding: 8px 15px;
|
||||
max-width: 100%;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.brand-company-meta {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
gap: 32px;
|
||||
height: 32px;
|
||||
width: 100%;
|
||||
|
||||
.brand-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
width: 20px;
|
||||
height: 16px;
|
||||
position: relative;
|
||||
|
||||
cursor: pointer;
|
||||
|
||||
img {
|
||||
height: 16px;
|
||||
width: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.brand-logo-name {
|
||||
@@ -66,6 +79,10 @@
|
||||
|
||||
.brand-title-section {
|
||||
display: none;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
position: relative;
|
||||
|
||||
.license-type {
|
||||
display: flex;
|
||||
@@ -76,7 +93,7 @@
|
||||
|
||||
color: var(--bg-vanilla-100);
|
||||
border-radius: 4px 0px 0px 4px;
|
||||
background: var(--Slate-400, #1d212d);
|
||||
background: var(--bg-slate-400, #1d212d);
|
||||
|
||||
text-align: center;
|
||||
font-family: Inter;
|
||||
@@ -98,11 +115,11 @@
|
||||
gap: 6px;
|
||||
|
||||
border-radius: 0px 4px 4px 0px;
|
||||
background: var(--Slate-300, #242834);
|
||||
background: var(--bg-slate-300, #242834);
|
||||
}
|
||||
|
||||
.version {
|
||||
color: var(--Vanilla-400, #c0c1c3);
|
||||
color: var(--bg-vanilla-400, #c0c1c3);
|
||||
text-align: center;
|
||||
font-variant-numeric: lining-nums tabular-nums slashed-zero;
|
||||
font-feature-settings: 'salt' on;
|
||||
@@ -156,24 +173,48 @@
|
||||
|
||||
.get-started-nav-items {
|
||||
display: flex;
|
||||
margin: 4px 13px 12px 10px;
|
||||
margin: 4px 10px 12px 8px;
|
||||
|
||||
.get-started-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 8px;
|
||||
margin-left: 2px;
|
||||
gap: 8px;
|
||||
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--Slate-400, #1d212d);
|
||||
background: var(--Slate-500, #161922);
|
||||
border: 1px solid var(--bg-slate-400, #1d212d);
|
||||
background: var(--bg-slate-500, #161922);
|
||||
|
||||
box-shadow: none !important;
|
||||
|
||||
color: var(--bg-vanilla-400, #c0c1c3);
|
||||
|
||||
svg {
|
||||
color: var(--bg-vanilla-400, #c0c1c3);
|
||||
}
|
||||
|
||||
.nav-item-label {
|
||||
color: var(--bg-vanilla-400, #c0c1c3);
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--bg-slate-400, #1d212d);
|
||||
border-color: var(--bg-slate-400, #1d212d);
|
||||
|
||||
color: var(--bg-vanilla-100, #fff);
|
||||
|
||||
svg {
|
||||
color: var(--bg-vanilla-100, #fff);
|
||||
}
|
||||
|
||||
.nav-item-label {
|
||||
color: var(--bg-vanilla-100, #fff);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,6 +233,10 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.nav-top-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -227,7 +272,7 @@
|
||||
}
|
||||
|
||||
.nav-section-title {
|
||||
color: var(--Slate-50, #62687c);
|
||||
color: var(--bg-slate-50, #62687c);
|
||||
font-family: Inter;
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
@@ -241,7 +286,7 @@
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
padding: 0 20px;
|
||||
padding: 0 17px;
|
||||
|
||||
.nav-section-title-text {
|
||||
display: none;
|
||||
@@ -250,11 +295,17 @@
|
||||
.nav-section-title-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
transition: opacity 0.08s ease, transform 0.08s ease;
|
||||
|
||||
&.reorder {
|
||||
display: none;
|
||||
cursor: pointer;
|
||||
margin-left: auto;
|
||||
transition: color 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: var(--bg-vanilla-100, #fff);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,7 +319,7 @@
|
||||
}
|
||||
|
||||
.nav-section-subtitle {
|
||||
color: var(--Vanilla-400, #c0c1c3);
|
||||
color: var(--bg-vanilla-400, #c0c1c3);
|
||||
font-family: Inter;
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
@@ -276,20 +327,20 @@
|
||||
line-height: 14px; /* 150% */
|
||||
letter-spacing: 0.4px;
|
||||
|
||||
padding: 0 20px;
|
||||
padding: 6px 20px;
|
||||
opacity: 0.6;
|
||||
|
||||
display: none;
|
||||
|
||||
transition: all 0.3s, background 0s, border 0s;
|
||||
transition-delay: 0.1s;
|
||||
transition: all 0.08s ease, background 0s, border 0s;
|
||||
transition-delay: 0.03s;
|
||||
}
|
||||
|
||||
.nav-items-section {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
transition: all 0.08s ease;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,7 +353,7 @@
|
||||
.nav-items-section {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
transition: all 0.4s ease;
|
||||
transition: all 0.1s ease;
|
||||
overflow: hidden;
|
||||
height: 0;
|
||||
}
|
||||
@@ -312,11 +363,34 @@
|
||||
.nav-items-section {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
transition: all 0.4s ease;
|
||||
transition: all 0.1s ease;
|
||||
overflow: hidden;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
&.sidebar-collapsed {
|
||||
.nav-title-section {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-items-section {
|
||||
margin-top: 0;
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
transition: all 0.08s ease;
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.shortcut-nav-items {
|
||||
&.sidebar-collapsed {
|
||||
.nav-items-section {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.scroll-for-more-container {
|
||||
@@ -326,7 +400,7 @@
|
||||
width: 100%;
|
||||
bottom: 12px;
|
||||
bottom: 8px;
|
||||
margin-left: 50px;
|
||||
margin-left: 43px;
|
||||
|
||||
.scroll-for-more {
|
||||
display: flex;
|
||||
@@ -370,8 +444,6 @@
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
|
||||
padding-top: 12px;
|
||||
|
||||
.secondary-nav-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -381,10 +453,10 @@
|
||||
overflow-x: hidden;
|
||||
padding: 8px 0;
|
||||
max-width: 100%;
|
||||
width: 64px;
|
||||
width: 54px;
|
||||
// width: 100%; // temp
|
||||
|
||||
transition: all 0.2s, background 0s, border 0s;
|
||||
transition: all 0.08s ease, background 0s, border 0s;
|
||||
|
||||
background: linear-gradient(180deg, rgba(11, 12, 14, 0) 0%, #0b0c0e 27.11%);
|
||||
|
||||
@@ -413,7 +485,7 @@
|
||||
|
||||
&.scroll-available {
|
||||
.nav-bottom-section {
|
||||
border-top: 1px solid var(--Slate-500, #161922);
|
||||
border-top: 1px solid var(--bg-slate-500, #161922);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -424,24 +496,53 @@
|
||||
}
|
||||
|
||||
&.collapsed {
|
||||
flex: 0 0 64px;
|
||||
max-width: 64px;
|
||||
min-width: 64px;
|
||||
width: 64px;
|
||||
flex: 0 0 54px;
|
||||
max-width: 54px;
|
||||
min-width: 54px;
|
||||
width: 54px;
|
||||
|
||||
.nav-wrapper {
|
||||
.nav-top-section {
|
||||
.shortcut-nav-items {
|
||||
.nav-section-title,
|
||||
.nav-section-title {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-section-subtitle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-items-section {
|
||||
display: flex;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.nav-title-section {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
gap: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.more-nav-items {
|
||||
.nav-section-title {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-items-section {
|
||||
display: flex;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.nav-title-section {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.nav-bottom-section {
|
||||
.secondary-nav-items {
|
||||
width: 64px;
|
||||
width: 54px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -466,7 +567,7 @@
|
||||
border-radius: 12px;
|
||||
background: var(--Robin-500, #4e74f8);
|
||||
|
||||
color: var(--Vanilla-100, #fff);
|
||||
color: var(--bg-vanilla-100, #fff);
|
||||
font-variant-numeric: lining-nums tabular-nums slashed-zero;
|
||||
font-feature-settings: 'case' on, 'cpsp' on, 'dlig' on, 'salt' on;
|
||||
font-family: Inter;
|
||||
@@ -479,7 +580,7 @@
|
||||
}
|
||||
|
||||
.sidenav-beta-tag {
|
||||
color: var(--Vanilla-100, #fff);
|
||||
color: var(--bg-vanilla-100, #fff);
|
||||
font-variant-numeric: lining-nums tabular-nums slashed-zero;
|
||||
font-feature-settings: 'case' on, 'cpsp' on, 'dlig' on, 'salt' on;
|
||||
font-family: Inter;
|
||||
@@ -494,7 +595,47 @@
|
||||
background: var(--bg-slate-300);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
&:not(.pinned) {
|
||||
.nav-item {
|
||||
.nav-item-data {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.shortcut-nav-items,
|
||||
.more-nav-items {
|
||||
.nav-section-title {
|
||||
padding: 0 17px;
|
||||
|
||||
.nav-section-title-icon {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.dropdown-open {
|
||||
.nav-item {
|
||||
.nav-item-data {
|
||||
flex-grow: 1;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.shortcut-nav-items,
|
||||
.more-nav-items {
|
||||
.nav-section-title {
|
||||
padding: 0 17px;
|
||||
|
||||
.nav-section-title-icon {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:not(.pinned):hover,
|
||||
&.dropdown-open {
|
||||
flex: 0 0 240px;
|
||||
max-width: 240px;
|
||||
min-width: 240px;
|
||||
@@ -505,8 +646,17 @@
|
||||
z-index: 10;
|
||||
background: #0b0c0e;
|
||||
|
||||
.brand-container {
|
||||
padding: 8px 15px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
justify-content: space-between;
|
||||
justify-content: flex-start;
|
||||
|
||||
.brand-company-meta {
|
||||
justify-content: flex-start;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.brand-title-section {
|
||||
display: flex;
|
||||
@@ -533,6 +683,11 @@
|
||||
.nav-section-title-icon {
|
||||
&.reorder {
|
||||
display: flex;
|
||||
transition: color 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: var(--bg-vanilla-100, #fff);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -574,7 +729,7 @@
|
||||
flex-direction: row;
|
||||
gap: 3px;
|
||||
border-radius: 20px;
|
||||
background: var(--Slate-400, #1d212d);
|
||||
background: var(--bg-slate-400, #1d212d);
|
||||
|
||||
/* Drop Shadow */
|
||||
box-shadow: 0px 103px 12px 0px rgba(0, 0, 0, 0.01),
|
||||
@@ -590,7 +745,7 @@
|
||||
width: 140px;
|
||||
|
||||
.scroll-for-more-label {
|
||||
color: var(--Vanilla-400, #c0c1c3);
|
||||
color: var(--bg-vanilla-400, #c0c1c3);
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
@@ -631,6 +786,13 @@
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
.nav-item-data {
|
||||
flex-grow: 1;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.get-started-nav-items {
|
||||
@@ -664,8 +826,17 @@
|
||||
z-index: 10;
|
||||
background: #0b0c0e;
|
||||
|
||||
.brand-container {
|
||||
padding: 8px 15px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
justify-content: space-between;
|
||||
justify-content: flex-start;
|
||||
|
||||
.brand-company-meta {
|
||||
justify-content: flex-start;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.brand-title-section {
|
||||
display: flex;
|
||||
@@ -692,6 +863,11 @@
|
||||
.nav-section-title-icon {
|
||||
&.reorder {
|
||||
display: flex;
|
||||
transition: color 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: var(--bg-vanilla-100, #fff);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -733,7 +909,7 @@
|
||||
flex-direction: row;
|
||||
gap: 3px;
|
||||
border-radius: 20px;
|
||||
background: var(--Slate-400, #1d212d);
|
||||
background: var(--bg-slate-400, #1d212d);
|
||||
|
||||
/* Drop Shadow */
|
||||
box-shadow: 0px 103px 12px 0px rgba(0, 0, 0, 0.01),
|
||||
@@ -751,7 +927,7 @@
|
||||
.scroll-for-more-label {
|
||||
display: block;
|
||||
|
||||
color: var(--Vanilla-400, #c0c1c3);
|
||||
color: var(--bg-vanilla-400, #c0c1c3);
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
@@ -856,7 +1032,7 @@
|
||||
|
||||
.ant-dropdown-menu-item {
|
||||
.ant-dropdown-menu-title-content {
|
||||
color: var(--Vanilla-400, #c0c1c3);
|
||||
color: var(--bg-vanilla-400, #c0c1c3);
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
@@ -864,6 +1040,12 @@
|
||||
line-height: normal;
|
||||
letter-spacing: 0.14px;
|
||||
}
|
||||
|
||||
&:hover:not(.ant-dropdown-menu-item-disabled) {
|
||||
.ant-dropdown-menu-title-content {
|
||||
color: var(--bg-vanilla-100, #fff);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -875,7 +1057,7 @@
|
||||
gap: 8px;
|
||||
|
||||
.user-settings-dropdown-label-text {
|
||||
color: var(--Slate-50, #62687c);
|
||||
color: var(--bg-slate-50, #62687c);
|
||||
font-family: Inter;
|
||||
font-size: 10px;
|
||||
font-family: Inter;
|
||||
@@ -887,7 +1069,7 @@
|
||||
}
|
||||
|
||||
.user-settings-dropdown-label-email {
|
||||
color: var(--Vanilla-400, #c0c1c3);
|
||||
color: var(--bg-vanilla-400, #c0c1c3);
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
@@ -897,12 +1079,16 @@
|
||||
}
|
||||
|
||||
.ant-dropdown-menu-item-divider {
|
||||
background-color: var(--Slate-500, #161922) !important;
|
||||
background-color: var(--bg-slate-500, #161922) !important;
|
||||
}
|
||||
|
||||
.ant-dropdown-menu-item-disabled {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.ant-dropdown-menu {
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-dropdown,
|
||||
@@ -912,6 +1098,27 @@
|
||||
}
|
||||
}
|
||||
|
||||
.secondary-nav-items {
|
||||
.nav-item {
|
||||
position: relative;
|
||||
|
||||
.nav-item-active-marker {
|
||||
position: absolute;
|
||||
left: -5px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
margin: 0;
|
||||
width: 8px;
|
||||
height: 24px;
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-item-data {
|
||||
margin-left: 8px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.reorder-shortcut-nav-items-modal {
|
||||
width: 384px !important;
|
||||
|
||||
@@ -1028,7 +1235,6 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: 2px;
|
||||
border-radius: 2px;
|
||||
background: var(--Robin-500, #4e74f8) !important;
|
||||
color: var(--bg-vanilla-100) !important;
|
||||
font-family: Inter;
|
||||
@@ -1038,10 +1244,10 @@
|
||||
line-height: 24px;
|
||||
|
||||
&.secondary-btn {
|
||||
background-color: var(--Slate-500, #161922) !important;
|
||||
background-color: var(--bg-slate-500, #161922) !important;
|
||||
border: 1px solid var(--bg-slate-500) !important;
|
||||
|
||||
color: var(--Vanilla-400, #c0c1c3) !important;
|
||||
color: var(--bg-vanilla-400, #c0c1c3) !important;
|
||||
|
||||
/* button/ small */
|
||||
font-family: Inter;
|
||||
@@ -1064,6 +1270,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
.help-support-dropdown li.ant-dropdown-menu-item-divider {
|
||||
background-color: var(--bg-slate-500, #161922) !important;
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.sideNav {
|
||||
background: var(--bg-vanilla-100);
|
||||
@@ -1095,8 +1305,32 @@
|
||||
.get-started-nav-items {
|
||||
.get-started-btn {
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
background: var(--bg-vanilla-100);
|
||||
color: var(--bg-ink-400);
|
||||
background: var(--bg-vanilla-200);
|
||||
color: var(--bg-slate-50, #62687c);
|
||||
|
||||
svg {
|
||||
color: var(--bg-slate-50, #62687c);
|
||||
}
|
||||
|
||||
.nav-item-label {
|
||||
color: var(--bg-ink-400, #62687c);
|
||||
}
|
||||
|
||||
// Hover state (light mode)
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--bg-vanilla-300);
|
||||
border-color: var(--bg-vanilla-300);
|
||||
|
||||
color: var(--bg-slate-500, #161922);
|
||||
|
||||
svg {
|
||||
color: var(--bg-slate-500, #161922);
|
||||
}
|
||||
|
||||
.nav-item-label {
|
||||
color: var(--bg-slate-500, #161922);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1108,7 +1342,25 @@
|
||||
}
|
||||
}
|
||||
|
||||
.brand-container {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.nav-wrapper {
|
||||
.nav-top-section {
|
||||
.shortcut-nav-items {
|
||||
.nav-section-title {
|
||||
.nav-section-title-icon {
|
||||
&.reorder {
|
||||
&:hover {
|
||||
color: var(--bg-slate-400, #1d212d);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.secondary-nav-items {
|
||||
border-top: 1px solid var(--bg-vanilla-300);
|
||||
|
||||
@@ -1123,8 +1375,43 @@
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
&.pinned {
|
||||
.nav-wrapper {
|
||||
.nav-top-section {
|
||||
.shortcut-nav-items {
|
||||
.nav-section-title {
|
||||
.nav-section-title-icon {
|
||||
&.reorder {
|
||||
&:hover {
|
||||
color: var(--bg-slate-400, #1d212d);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:not(.pinned):hover,
|
||||
&.dropdown-open {
|
||||
background: var(--bg-vanilla-100);
|
||||
|
||||
.nav-wrapper {
|
||||
.nav-top-section {
|
||||
.shortcut-nav-items {
|
||||
.nav-section-title {
|
||||
.nav-section-title-icon {
|
||||
&.reorder {
|
||||
&:hover {
|
||||
color: var(--bg-slate-400, #1d212d);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1134,6 +1421,12 @@
|
||||
.ant-dropdown-menu-title-content {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
|
||||
&:hover:not(.ant-dropdown-menu-item-disabled) {
|
||||
.ant-dropdown-menu-title-content {
|
||||
color: var(--bg-ink-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1210,6 +1503,10 @@
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
|
||||
.help-support-dropdown li.ant-dropdown-menu-item-divider {
|
||||
background-color: var(--bg-vanilla-300) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.version-tooltip-overlay {
|
||||
@@ -1222,7 +1519,7 @@
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--bg-slate-500);
|
||||
|
||||
color: var(--Vanilla-100, #fff);
|
||||
color: var(--bg-vanilla-100, #fff);
|
||||
font-family: Inter;
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
@@ -1237,7 +1534,7 @@
|
||||
gap: 4px;
|
||||
|
||||
.version-update-notification-tooltip-title {
|
||||
color: var(--Vanilla-100, #fff);
|
||||
color: var(--bg-vanilla-100, #fff);
|
||||
font-family: Inter;
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
@@ -1247,7 +1544,7 @@
|
||||
}
|
||||
|
||||
.version-update-notification-tooltip-content {
|
||||
color: var(--Vanilla-100, #fff);
|
||||
color: var(--bg-vanilla-100, #fff);
|
||||
font-family: Inter;
|
||||
font-size: 10px;
|
||||
font-style: normal;
|
||||
|
||||
@@ -157,18 +157,27 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
DefaultHelpSupportDropdownMenuItems,
|
||||
);
|
||||
|
||||
const [pinnedMenuItems, setPinnedMenuItems] = useState<SidebarItem[]>([]);
|
||||
|
||||
const [tempPinnedMenuItems, setTempPinnedMenuItems] = useState<SidebarItem[]>(
|
||||
[],
|
||||
);
|
||||
|
||||
const [secondaryMenuItems, setSecondaryMenuItems] = useState<SidebarItem[]>(
|
||||
[],
|
||||
);
|
||||
|
||||
const [hasScroll, setHasScroll] = useState(false);
|
||||
const navTopSectionRef = useRef<HTMLDivElement>(null);
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [pinnedMenuItems, setPinnedMenuItems] = useState<SidebarItem[]>([]);
|
||||
const [secondaryMenuItems, setSecondaryMenuItems] = useState<SidebarItem[]>(
|
||||
[],
|
||||
);
|
||||
|
||||
const handleMouseEnter = useCallback(() => {
|
||||
setIsHovered(true);
|
||||
}, []);
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
setIsHovered(false);
|
||||
}, []);
|
||||
|
||||
const checkScroll = useCallback((): void => {
|
||||
if (navTopSectionRef.current) {
|
||||
@@ -217,63 +226,68 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
const isAdmin = user.role === USER_ROLES.ADMIN;
|
||||
const isEditor = user.role === USER_ROLES.EDITOR;
|
||||
|
||||
useEffect(() => {
|
||||
const navShortcuts = (userPreferences?.find(
|
||||
// Compute initial pinned items and secondary menu items synchronously to avoid flash
|
||||
const computedPinnedMenuItems = useMemo(() => {
|
||||
const navShortcutsPreference = userPreferences?.find(
|
||||
(preference) => preference.name === USER_PREFERENCES.NAV_SHORTCUTS,
|
||||
)?.value as unknown) as string[];
|
||||
);
|
||||
const navShortcuts = (navShortcutsPreference?.value as unknown) as
|
||||
| string[]
|
||||
| undefined;
|
||||
|
||||
const shouldShowIntegrations =
|
||||
(isCloudUser || isEnterpriseSelfHostedUser) && (isAdmin || isEditor);
|
||||
// If userPreferences not loaded yet, return empty to avoid showing defaults before preferences load
|
||||
if (userPreferences === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (navShortcuts && isArray(navShortcuts) && navShortcuts.length > 0) {
|
||||
// nav shortcuts is array of strings
|
||||
const pinnedItems = navShortcuts
|
||||
// If preference exists with non-empty array, use stored shortcuts
|
||||
if (isArray(navShortcuts) && navShortcuts.length > 0) {
|
||||
return navShortcuts
|
||||
.map((shortcut) =>
|
||||
defaultMoreMenuItems.find((item) => item.itemKey === shortcut),
|
||||
)
|
||||
.filter((item): item is SidebarItem => item !== undefined);
|
||||
|
||||
// Set pinned items in the order they were stored
|
||||
setPinnedMenuItems(pinnedItems);
|
||||
|
||||
setSecondaryMenuItems(
|
||||
defaultMoreMenuItems.map((item) => ({
|
||||
...item,
|
||||
isPinned: pinnedItems.some((pinned) => pinned.itemKey === item.itemKey),
|
||||
isEnabled:
|
||||
item.key === ROUTES.INTEGRATIONS
|
||||
? shouldShowIntegrations
|
||||
: item.isEnabled,
|
||||
})),
|
||||
);
|
||||
} else {
|
||||
// Set default pinned items
|
||||
const defaultPinnedItems = defaultMoreMenuItems.filter(
|
||||
(item) => item.isPinned,
|
||||
);
|
||||
setPinnedMenuItems(defaultPinnedItems);
|
||||
|
||||
setSecondaryMenuItems(
|
||||
defaultMoreMenuItems.map((item) => ({
|
||||
...item,
|
||||
isPinned: defaultPinnedItems.some(
|
||||
(pinned) => pinned.itemKey === item.itemKey,
|
||||
),
|
||||
isEnabled:
|
||||
item.key === ROUTES.INTEGRATIONS
|
||||
? shouldShowIntegrations
|
||||
: item.isEnabled,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
// No preference, or empty array → use defaults
|
||||
return defaultMoreMenuItems.filter((item) => item.isPinned);
|
||||
}, [userPreferences]);
|
||||
|
||||
const computedSecondaryMenuItems = useMemo(() => {
|
||||
const shouldShowIntegrationsValue =
|
||||
(isCloudUser || isEnterpriseSelfHostedUser) && (isAdmin || isEditor);
|
||||
|
||||
return defaultMoreMenuItems.map((item) => ({
|
||||
...item,
|
||||
isPinned: computedPinnedMenuItems.some(
|
||||
(pinned) => pinned.itemKey === item.itemKey,
|
||||
),
|
||||
isEnabled:
|
||||
item.key === ROUTES.INTEGRATIONS
|
||||
? shouldShowIntegrationsValue
|
||||
: item.isEnabled,
|
||||
}));
|
||||
}, [
|
||||
userPreferences,
|
||||
computedPinnedMenuItems,
|
||||
isCloudUser,
|
||||
isEnterpriseSelfHostedUser,
|
||||
isAdmin,
|
||||
isEditor,
|
||||
]);
|
||||
|
||||
// Track if we've done the initial sync (to avoid overwriting user actions during session)
|
||||
const hasInitializedRef = useRef(false);
|
||||
|
||||
// Sync state only on initial load when userPreferences first becomes available
|
||||
useEffect(() => {
|
||||
// Only sync once: when userPreferences loads for the first time
|
||||
if (!hasInitializedRef.current && userPreferences !== null) {
|
||||
setPinnedMenuItems(computedPinnedMenuItems);
|
||||
setSecondaryMenuItems(computedSecondaryMenuItems);
|
||||
hasInitializedRef.current = true;
|
||||
}
|
||||
}, [computedPinnedMenuItems, computedSecondaryMenuItems, userPreferences]);
|
||||
|
||||
const isOnboardingV3Enabled = featureFlags?.find(
|
||||
(flag) => flag.name === FeatureKeys.ONBOARDING_V3,
|
||||
)?.active;
|
||||
@@ -327,6 +341,17 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
.map((item) => item.itemKey)
|
||||
.filter(Boolean) as string[];
|
||||
|
||||
// Update context immediately (optimistically) so computed values reflect the change
|
||||
updateUserPreferenceInContext({
|
||||
name: USER_PREFERENCES.NAV_SHORTCUTS,
|
||||
description: USER_PREFERENCES.NAV_SHORTCUTS,
|
||||
valueType: 'array',
|
||||
defaultValue: false,
|
||||
allowedValues: [],
|
||||
allowedScopes: ['user'],
|
||||
value: navShortcuts,
|
||||
});
|
||||
|
||||
updateUserPreferenceMutation(
|
||||
{
|
||||
name: USER_PREFERENCES.NAV_SHORTCUTS,
|
||||
@@ -335,6 +360,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
{
|
||||
onSuccess: (response) => {
|
||||
if (response.data) {
|
||||
// Update context again on success to ensure consistency
|
||||
updateUserPreferenceInContext({
|
||||
name: USER_PREFERENCES.NAV_SHORTCUTS,
|
||||
description: USER_PREFERENCES.NAV_SHORTCUTS,
|
||||
@@ -368,13 +394,13 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
if (isCurrentlyPinned) {
|
||||
return prevItems.filter((i) => i.key !== item.key);
|
||||
}
|
||||
return [item, ...prevItems];
|
||||
return [...prevItems, item];
|
||||
});
|
||||
|
||||
// Get the updated pinned menu items for preference update
|
||||
const updatedPinnedItems = pinnedMenuItems.some((i) => i.key === item.key)
|
||||
? pinnedMenuItems.filter((i) => i.key !== item.key)
|
||||
: [item, ...pinnedMenuItems];
|
||||
: [...pinnedMenuItems, item];
|
||||
|
||||
// Update user preference with the ordered list of item keys
|
||||
updateNavShortcutsPreference(updatedPinnedItems);
|
||||
@@ -455,6 +481,10 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
pathname,
|
||||
]);
|
||||
|
||||
const isSettingsPage = useMemo(() => pathname.startsWith(ROUTES.SETTINGS), [
|
||||
pathname,
|
||||
]);
|
||||
|
||||
const userSettingsDropdownMenuItems: MenuProps['items'] = useMemo(
|
||||
() =>
|
||||
[
|
||||
@@ -594,7 +624,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
},
|
||||
{
|
||||
type: 'group',
|
||||
label: "WHAT's NEW",
|
||||
label: "WHAT'S NEW",
|
||||
},
|
||||
...dropdownItems,
|
||||
{
|
||||
@@ -750,6 +780,15 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
[secondaryMenuItems],
|
||||
);
|
||||
|
||||
// Get active "More" items that should be visible in collapsed state
|
||||
const activeMoreMenuItems = useMemo(
|
||||
() => moreMenuItems.filter((item) => activeMenuKey === item.key),
|
||||
[moreMenuItems, activeMenuKey],
|
||||
);
|
||||
|
||||
// Check if sidebar is collapsed (not pinned, not hovered, and no dropdown open)
|
||||
const isCollapsed = !isPinned && !isHovered && !isDropdownOpen;
|
||||
|
||||
const renderNavItems = (
|
||||
items: SidebarItem[],
|
||||
allowPin?: boolean,
|
||||
@@ -901,7 +940,15 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
|
||||
return (
|
||||
<div className={cx('sidenav-container', isPinned && 'pinned')}>
|
||||
<div className={cx('sideNav', isPinned && 'pinned')}>
|
||||
<div
|
||||
className={cx(
|
||||
'sideNav',
|
||||
isPinned && 'pinned',
|
||||
isDropdownOpen && 'dropdown-open',
|
||||
)}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<div className="brand-container">
|
||||
<div className="brand">
|
||||
<div className="brand-company-meta">
|
||||
@@ -999,35 +1046,43 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
{renderNavItems(primaryMenuItems)}
|
||||
</div>
|
||||
|
||||
<div className="shortcut-nav-items">
|
||||
<div className="nav-title-section">
|
||||
<div className="nav-section-title">
|
||||
<div className="nav-section-title-icon">
|
||||
<MousePointerClick size={16} />
|
||||
</div>
|
||||
{(pinnedMenuItems.length > 0 || !isCollapsed) && (
|
||||
<div
|
||||
className={cx('shortcut-nav-items', isCollapsed && 'sidebar-collapsed')}
|
||||
>
|
||||
{!isCollapsed && (
|
||||
<div className="nav-title-section">
|
||||
<div className="nav-section-title">
|
||||
<div className="nav-section-title-icon">
|
||||
<MousePointerClick size={16} />
|
||||
</div>
|
||||
|
||||
<div className="nav-section-title-text">SHORTCUTS</div>
|
||||
<div className="nav-section-title-text">SHORTCUTS</div>
|
||||
|
||||
{pinnedMenuItems.length > 1 && (
|
||||
<div
|
||||
className="nav-section-title-icon reorder"
|
||||
onClick={(): void => {
|
||||
logEvent('Sidebar V2: Manage shortcuts clicked', {});
|
||||
setIsReorderShortcutNavItemsModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<Logs size={16} />
|
||||
{pinnedMenuItems.length > 1 && (
|
||||
<Tooltip title="Manage shortcuts" placement="right">
|
||||
<div
|
||||
className="nav-section-title-icon reorder"
|
||||
onClick={(): void => {
|
||||
logEvent('Sidebar V2: Manage shortcuts clicked', {});
|
||||
setIsReorderShortcutNavItemsModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<Logs size={16} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{pinnedMenuItems.length === 0 && (
|
||||
<div className="nav-section-subtitle">
|
||||
You have not added any shortcuts yet.
|
||||
{pinnedMenuItems.length === 0 && (
|
||||
<div className="nav-section-subtitle">
|
||||
You have not added any shortcuts yet.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pinnedMenuItems.length > 0 && (
|
||||
{(pinnedMenuItems.length > 0 || isCollapsed) && (
|
||||
<div className="nav-items-section">
|
||||
{renderNavItems(
|
||||
pinnedMenuItems.filter((item) => item.isEnabled),
|
||||
@@ -1036,46 +1091,60 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{moreMenuItems.length > 0 && (
|
||||
<div
|
||||
className={cx(
|
||||
'more-nav-items',
|
||||
isMoreMenuCollapsed ? 'collapsed' : 'expanded',
|
||||
isCollapsed && 'sidebar-collapsed',
|
||||
)}
|
||||
>
|
||||
<div className="nav-title-section">
|
||||
<div
|
||||
className="nav-section-title"
|
||||
onClick={(): void => {
|
||||
logEvent('Sidebar V2: More menu clicked', {
|
||||
action: isMoreMenuCollapsed ? 'expand' : 'collapse',
|
||||
});
|
||||
setIsMoreMenuCollapsed(!isMoreMenuCollapsed);
|
||||
}}
|
||||
>
|
||||
<div className="nav-section-title-icon">
|
||||
<Ellipsis size={16} />
|
||||
</div>
|
||||
{!isCollapsed && (
|
||||
<div className="nav-title-section">
|
||||
<div
|
||||
className="nav-section-title"
|
||||
onClick={(): void => {
|
||||
// Only allow toggling when sidebar is open (pinned, hovered, or dropdown open)
|
||||
if (isCollapsed) {
|
||||
return;
|
||||
}
|
||||
const newCollapsedState = !isMoreMenuCollapsed;
|
||||
logEvent('Sidebar V2: More menu clicked', {
|
||||
action: isMoreMenuCollapsed ? 'expand' : 'collapse',
|
||||
});
|
||||
setIsMoreMenuCollapsed(newCollapsedState);
|
||||
}}
|
||||
>
|
||||
<div className="nav-section-title-icon">
|
||||
<Ellipsis size={16} />
|
||||
</div>
|
||||
|
||||
<div className="nav-section-title-text">MORE</div>
|
||||
<div className="nav-section-title-text">MORE</div>
|
||||
|
||||
<div className="collapse-expand-section-icon">
|
||||
{isMoreMenuCollapsed ? (
|
||||
<ChevronDown size={16} />
|
||||
) : (
|
||||
<ChevronUp size={16} />
|
||||
)}
|
||||
<div className="collapse-expand-section-icon">
|
||||
{isMoreMenuCollapsed ? (
|
||||
<ChevronDown size={16} />
|
||||
) : (
|
||||
<ChevronUp size={16} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="nav-items-section">
|
||||
{renderNavItems(
|
||||
moreMenuItems.filter((item) => item.isEnabled),
|
||||
true,
|
||||
)}
|
||||
{/* Show all items when expanded, only active items when collapsed */}
|
||||
{isCollapsed
|
||||
? renderNavItems(
|
||||
activeMoreMenuItems.filter((item) => item.isEnabled),
|
||||
true,
|
||||
)
|
||||
: renderNavItems(
|
||||
moreMenuItems.filter((item) => item.isEnabled),
|
||||
true,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -1102,6 +1171,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
placement="topLeft"
|
||||
overlayClassName="nav-dropdown-overlay help-support-dropdown"
|
||||
trigger={['click']}
|
||||
onOpenChange={(open): void => setIsDropdownOpen(open)}
|
||||
>
|
||||
<div className="nav-item">
|
||||
<div className="nav-item-data" data-testid="help-support-nav-item">
|
||||
@@ -1122,8 +1192,10 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
placement="topLeft"
|
||||
overlayClassName="nav-dropdown-overlay settings-dropdown"
|
||||
trigger={['click']}
|
||||
onOpenChange={(open): void => setIsDropdownOpen(open)}
|
||||
>
|
||||
<div className="nav-item">
|
||||
<div className={cx('nav-item', isSettingsPage && 'active')}>
|
||||
<div className="nav-item-active-marker" />
|
||||
<div className="nav-item-data" data-testid="settings-nav-item">
|
||||
<div className="nav-item-icon">{userSettingsMenuItem.icon}</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { IDashboardVariables } from 'providers/Dashboard/store/dashboardVariablesStore';
|
||||
import { IDashboardVariables } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreTypes';
|
||||
|
||||
import useGetResolvedText from '../useGetResolvedText';
|
||||
|
||||
|
||||
@@ -1,21 +1,40 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
import { useCallback, useRef, useSyncExternalStore } from 'react';
|
||||
import { dashboardVariablesStore } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStore';
|
||||
import {
|
||||
dashboardVariablesStore,
|
||||
IDashboardVariables,
|
||||
} from '../../providers/Dashboard/store/dashboardVariablesStore';
|
||||
IDashboardVariablesStoreState,
|
||||
IUseDashboardVariablesReturn,
|
||||
} from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreTypes';
|
||||
|
||||
export interface IUseDashboardVariablesReturn {
|
||||
dashboardVariables: IDashboardVariables;
|
||||
}
|
||||
/**
|
||||
* Generic selector hook for dashboard variables store
|
||||
* Allows granular subscriptions to any part of the store state
|
||||
*
|
||||
* @example
|
||||
* ! Select top-level field
|
||||
* const variables = useDashboardVariablesSelector(s => s.variables);
|
||||
*
|
||||
* ! Select specific variable
|
||||
* const fooVar = useDashboardVariablesSelector(s => s.variables['foo']);
|
||||
*
|
||||
* ! Select derived value
|
||||
* const hasVariables = useDashboardVariablesSelector(s => Object.keys(s.variables).length > 0);
|
||||
*/
|
||||
export const useDashboardVariablesSelector = <T>(
|
||||
selector: (state: IDashboardVariablesStoreState) => T,
|
||||
): T => {
|
||||
const selectorRef = useRef(selector);
|
||||
selectorRef.current = selector;
|
||||
|
||||
export const useDashboardVariables = (): IUseDashboardVariablesReturn => {
|
||||
const dashboardVariables = useSyncExternalStore(
|
||||
dashboardVariablesStore.subscribe,
|
||||
dashboardVariablesStore.getSnapshot,
|
||||
const getSnapshot = useCallback(
|
||||
() => selectorRef.current(dashboardVariablesStore.getSnapshot()),
|
||||
[],
|
||||
);
|
||||
|
||||
return {
|
||||
dashboardVariables,
|
||||
};
|
||||
return useSyncExternalStore(dashboardVariablesStore.subscribe, getSnapshot);
|
||||
};
|
||||
|
||||
export const useDashboardVariables = (): IUseDashboardVariablesReturn => {
|
||||
const dashboardVariables = useDashboardVariablesSelector((s) => s.variables);
|
||||
|
||||
return { dashboardVariables };
|
||||
};
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
getPreviousQueryFromKey,
|
||||
getQueryKey,
|
||||
removeKeyFromPreviousQuery,
|
||||
saveAsPreviousQuery,
|
||||
} from 'components/QueryBuilderV2/QueryV2/previousQuery.utils';
|
||||
import { ENTITY_VERSION_V4, ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { LEGEND } from 'constants/global';
|
||||
import {
|
||||
@@ -30,6 +36,7 @@ import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteRe
|
||||
import {
|
||||
IBuilderFormula,
|
||||
IBuilderQuery,
|
||||
Query,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
import {
|
||||
MetricAggregation,
|
||||
@@ -59,6 +66,7 @@ export const useQueryOperations: UseQueryOperations = ({
|
||||
isListViewPanel = false,
|
||||
entityVersion,
|
||||
isForTraceOperator = false,
|
||||
savePreviousQuery = false,
|
||||
}) => {
|
||||
const {
|
||||
handleSetQueryData,
|
||||
@@ -417,11 +425,50 @@ export const useQueryOperations: UseQueryOperations = ({
|
||||
|
||||
const handleChangeDataSource = useCallback(
|
||||
(nextSource: DataSource): void => {
|
||||
let newQueryData: IBuilderQuery | null = null;
|
||||
if (savePreviousQuery) {
|
||||
// save the current query key in session storage
|
||||
const currKey = getQueryKey({
|
||||
queryName: query.queryName || '',
|
||||
dataSource: query.dataSource || '',
|
||||
signalSource: query.source || '',
|
||||
panelType: panelType || '',
|
||||
});
|
||||
|
||||
saveAsPreviousQuery(currKey, query);
|
||||
|
||||
const newKey = getQueryKey({
|
||||
queryName: query.queryName || '',
|
||||
dataSource: nextSource || '',
|
||||
signalSource: query.source || '',
|
||||
panelType: panelType || '',
|
||||
});
|
||||
|
||||
newQueryData = getPreviousQueryFromKey(newKey);
|
||||
|
||||
// remove the new query key from session storage
|
||||
removeKeyFromPreviousQuery(newKey);
|
||||
}
|
||||
|
||||
if (isListViewPanel) {
|
||||
let listPanelQuery: Query | null = null;
|
||||
|
||||
if (nextSource === DataSource.LOGS) {
|
||||
redirectWithQueryBuilderData(listViewInitialLogQuery);
|
||||
listPanelQuery = listViewInitialLogQuery;
|
||||
} else if (nextSource === DataSource.TRACES) {
|
||||
redirectWithQueryBuilderData(listViewInitialTraceQuery);
|
||||
listPanelQuery = listViewInitialTraceQuery;
|
||||
}
|
||||
if (newQueryData && listPanelQuery) {
|
||||
listPanelQuery = {
|
||||
...listPanelQuery,
|
||||
builder: {
|
||||
...listPanelQuery.builder,
|
||||
queryData: [newQueryData],
|
||||
},
|
||||
};
|
||||
}
|
||||
if (listPanelQuery) {
|
||||
redirectWithQueryBuilderData(listPanelQuery);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -443,8 +490,10 @@ export const useQueryOperations: UseQueryOperations = ({
|
||||
aggregateOperator: newOperators[0].value,
|
||||
};
|
||||
|
||||
newQueryData = newQueryData ? newQueryData : newQuery;
|
||||
|
||||
setOperators(newOperators);
|
||||
handleSetQueryData(index, newQuery);
|
||||
handleSetQueryData(index, newQueryData);
|
||||
},
|
||||
[
|
||||
isListViewPanel,
|
||||
@@ -453,6 +502,7 @@ export const useQueryOperations: UseQueryOperations = ({
|
||||
handleSetQueryData,
|
||||
index,
|
||||
redirectWithQueryBuilderData,
|
||||
savePreviousQuery,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
/>
|
||||
<% }); %> <% } %>
|
||||
</head>
|
||||
<body>
|
||||
<body data-theme="default">
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import getStartEndRangeTime from 'lib/getStartEndRangeTime';
|
||||
import { IDashboardVariables } from 'providers/Dashboard/store/dashboardVariablesStore';
|
||||
import { IDashboardVariables } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreTypes';
|
||||
import store from 'store';
|
||||
|
||||
export const getDashboardVariables = (
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
.legend-search-container {
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
padding-right: 8px;
|
||||
|
||||
.legend-search-input {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.legend-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
@@ -17,6 +28,38 @@
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
.virtuoso-grid-list {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-auto-flow: row;
|
||||
grid-template-columns: repeat(
|
||||
auto-fill,
|
||||
minmax(var(--legend-average-width, 240px), 1fr)
|
||||
);
|
||||
row-gap: 4px;
|
||||
column-gap: 12px;
|
||||
}
|
||||
|
||||
.virtuoso-grid-item {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&.legend-virtuoso-container-right {
|
||||
.virtuoso-grid-list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
&.legend-virtuoso-container-single-row {
|
||||
.virtuoso-grid-list {
|
||||
grid-template-columns: repeat(
|
||||
auto-fit,
|
||||
minmax(var(--legend-average-width, 240px), max-content)
|
||||
);
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0.3rem;
|
||||
}
|
||||
@@ -58,10 +101,15 @@
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
max-width: min(400px, 100%);
|
||||
cursor: pointer;
|
||||
|
||||
&.legend-item-right {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&.legend-item-off {
|
||||
opacity: 0.3;
|
||||
text-decoration: line-through;
|
||||
|
||||
@@ -1,22 +1,21 @@
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import { Virtuoso } from 'react-virtuoso';
|
||||
import { Tooltip as AntdTooltip } from 'antd';
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { VirtuosoGrid } from 'react-virtuoso';
|
||||
import { Input, Tooltip as AntdTooltip } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import { LegendItem } from 'lib/uPlotV2/config/types';
|
||||
import useLegendsSync from 'lib/uPlotV2/hooks/useLegendsSync';
|
||||
import { LegendPosition } from 'types/api/dashboard/getAll';
|
||||
|
||||
import { LegendProps } from '../types';
|
||||
import { LegendPosition, LegendProps } from '../types';
|
||||
import { useLegendActions } from './useLegendActions';
|
||||
|
||||
import './Legend.styles.scss';
|
||||
|
||||
const LEGENDS_PER_SET_DEFAULT = 5;
|
||||
export const MAX_LEGEND_WIDTH = 240;
|
||||
|
||||
export default function Legend({
|
||||
position = LegendPosition.BOTTOM,
|
||||
config,
|
||||
legendsPerSet = LEGENDS_PER_SET_DEFAULT,
|
||||
averageLegendWidth = MAX_LEGEND_WIDTH,
|
||||
}: LegendProps): JSX.Element {
|
||||
const {
|
||||
legendItemsMap,
|
||||
@@ -32,53 +31,54 @@ export default function Legend({
|
||||
focusedSeriesIndex,
|
||||
});
|
||||
const legendContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [legendSearchQuery, setLegendSearchQuery] = useState('');
|
||||
|
||||
// Chunk legend items into rows of LEGENDS_PER_ROW items each
|
||||
const legendRows = useMemo(() => {
|
||||
const legendItems = Object.values(legendItemsMap);
|
||||
const legendItems = useMemo(() => Object.values(legendItemsMap), [
|
||||
legendItemsMap,
|
||||
]);
|
||||
|
||||
return legendItems.reduce((acc: LegendItem[][], curr, i) => {
|
||||
if (i % legendsPerSet === 0) {
|
||||
acc.push([]);
|
||||
}
|
||||
acc[acc.length - 1].push(curr);
|
||||
return acc;
|
||||
}, [] as LegendItem[][]);
|
||||
}, [legendItemsMap, legendsPerSet]);
|
||||
const isSingleRow = useMemo(() => {
|
||||
if (!legendContainerRef.current || position !== LegendPosition.BOTTOM) {
|
||||
return false;
|
||||
}
|
||||
const containerWidth = legendContainerRef.current.clientWidth;
|
||||
|
||||
const renderLegendRow = useCallback(
|
||||
(rowIndex: number, row: LegendItem[]): JSX.Element => (
|
||||
<div
|
||||
key={rowIndex}
|
||||
className={cx(
|
||||
'legend-row',
|
||||
`legend-row-${position.toLowerCase()}`,
|
||||
legendRows.length === 1 && position === LegendPosition.BOTTOM
|
||||
? 'legend-single-row'
|
||||
: '',
|
||||
)}
|
||||
>
|
||||
{row.map((item) => (
|
||||
<AntdTooltip key={item.seriesIndex} title={item.label}>
|
||||
<div
|
||||
data-legend-item-id={item.seriesIndex}
|
||||
className={cx('legend-item', {
|
||||
'legend-item-off': !item.show,
|
||||
'legend-item-focused': focusedSeriesIndex === item.seriesIndex,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className="legend-marker"
|
||||
style={{ borderColor: String(item.color) }}
|
||||
data-is-legend-marker={true}
|
||||
/>
|
||||
<span className="legend-label">{item.label}</span>
|
||||
</div>
|
||||
</AntdTooltip>
|
||||
))}
|
||||
</div>
|
||||
const totalLegendWidth = legendItems.length * (averageLegendWidth + 16);
|
||||
const totalRows = Math.ceil(totalLegendWidth / containerWidth);
|
||||
return totalRows <= 1;
|
||||
}, [averageLegendWidth, legendContainerRef, legendItems.length, position]);
|
||||
|
||||
const visibleLegendItems = useMemo(() => {
|
||||
if (position !== LegendPosition.RIGHT || !legendSearchQuery.trim()) {
|
||||
return legendItems;
|
||||
}
|
||||
|
||||
const query = legendSearchQuery.trim().toLowerCase();
|
||||
return legendItems.filter((item) =>
|
||||
item.label?.toLowerCase().includes(query),
|
||||
);
|
||||
}, [position, legendSearchQuery, legendItems]);
|
||||
|
||||
const renderLegendItem = useCallback(
|
||||
(item: LegendItem): JSX.Element => (
|
||||
<AntdTooltip key={item.seriesIndex} title={item.label}>
|
||||
<div
|
||||
data-legend-item-id={item.seriesIndex}
|
||||
className={cx('legend-item', `legend-item-${position.toLowerCase()}`, {
|
||||
'legend-item-off': !item.show,
|
||||
'legend-item-focused': focusedSeriesIndex === item.seriesIndex,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className="legend-marker"
|
||||
style={{ borderColor: String(item.color) }}
|
||||
data-is-legend-marker={true}
|
||||
/>
|
||||
<span className="legend-label">{item.label}</span>
|
||||
</div>
|
||||
</AntdTooltip>
|
||||
),
|
||||
[focusedSeriesIndex, position, legendRows],
|
||||
[focusedSeriesIndex, position],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -88,11 +88,29 @@ export default function Legend({
|
||||
onClick={onLegendClick}
|
||||
onMouseMove={onLegendMouseMove}
|
||||
onMouseLeave={onLegendMouseLeave}
|
||||
style={{
|
||||
['--legend-average-width' as string]: `${averageLegendWidth + 16}px`, // 16px is the marker width
|
||||
}}
|
||||
>
|
||||
<Virtuoso
|
||||
className="legend-virtuoso-container"
|
||||
data={legendRows}
|
||||
itemContent={(index, row): JSX.Element => renderLegendRow(index, row)}
|
||||
{position === LegendPosition.RIGHT && (
|
||||
<div className="legend-search-container">
|
||||
<Input
|
||||
allowClear
|
||||
placeholder="Search..."
|
||||
value={legendSearchQuery}
|
||||
onChange={(e): void => setLegendSearchQuery(e.target.value)}
|
||||
className="legend-search-input"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<VirtuosoGrid
|
||||
className={cx(
|
||||
'legend-virtuoso-container',
|
||||
`legend-virtuoso-container-${position.toLowerCase()}`,
|
||||
{ 'legend-virtuoso-container-single-row': isSingleRow },
|
||||
)}
|
||||
data={visibleLegendItems}
|
||||
itemContent={(_, item): JSX.Element => renderLegendItem(item)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
-webkit-font-smoothing: antialiased;
|
||||
color: var(--bg-vanilla-100);
|
||||
border-radius: 6px;
|
||||
padding: 1rem 1rem 0.5rem 1rem;
|
||||
padding: 1rem 0.5rem 0.5rem 1rem;
|
||||
border: 1px solid var(--bg-ink-100);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -15,6 +15,12 @@
|
||||
background: var(--bg-vanilla-100);
|
||||
color: var(--bg-ink-500);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
|
||||
.uplot-tooltip-list {
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--bg-vanilla-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.uplot-tooltip-header {
|
||||
@@ -22,18 +28,18 @@
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.uplot-tooltip-list-container {
|
||||
height: 100%;
|
||||
.uplot-tooltip-list {
|
||||
&::-webkit-scrollbar {
|
||||
width: 0.3rem;
|
||||
}
|
||||
&::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: rgb(136, 136, 136);
|
||||
}
|
||||
.uplot-tooltip-list {
|
||||
&::-webkit-scrollbar {
|
||||
width: 0.3rem;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--bg-slate-100);
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ export default function Tooltip({
|
||||
data: uPlotInstance.data,
|
||||
series: uPlotInstance.series,
|
||||
dataIndexes,
|
||||
activeSeriesIdx: seriesIndex,
|
||||
activeSeriesIndex: seriesIndex,
|
||||
uPlotInstance,
|
||||
yAxisUnit,
|
||||
decimalPrecision,
|
||||
|
||||
@@ -9,10 +9,10 @@ const FALLBACK_SERIES_COLOR = '#000000';
|
||||
export function resolveSeriesColor(
|
||||
stroke: Series.Stroke | undefined,
|
||||
u: uPlot,
|
||||
seriesIdx: number,
|
||||
seriesIndex: number,
|
||||
): string {
|
||||
if (typeof stroke === 'function') {
|
||||
return String(stroke(u, seriesIdx));
|
||||
return String(stroke(u, seriesIndex));
|
||||
}
|
||||
if (typeof stroke === 'string') {
|
||||
return stroke;
|
||||
@@ -24,7 +24,7 @@ export function buildTooltipContent({
|
||||
data,
|
||||
series,
|
||||
dataIndexes,
|
||||
activeSeriesIdx,
|
||||
activeSeriesIndex,
|
||||
uPlotInstance,
|
||||
yAxisUnit,
|
||||
decimalPrecision,
|
||||
@@ -32,7 +32,7 @@ export function buildTooltipContent({
|
||||
data: AlignedData;
|
||||
series: Series[];
|
||||
dataIndexes: Array<number | null>;
|
||||
activeSeriesIdx: number | null;
|
||||
activeSeriesIndex: number | null;
|
||||
uPlotInstance: uPlot;
|
||||
yAxisUnit: string;
|
||||
decimalPrecision?: PrecisionOption;
|
||||
@@ -40,28 +40,28 @@ export function buildTooltipContent({
|
||||
const active: TooltipContentItem[] = [];
|
||||
const rest: TooltipContentItem[] = [];
|
||||
|
||||
for (let idx = 1; idx < series.length; idx += 1) {
|
||||
const s = series[idx];
|
||||
for (let index = 1; index < series.length; index += 1) {
|
||||
const s = series[index];
|
||||
if (!s?.show) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const dataIdx = dataIndexes[idx];
|
||||
const dataIndex = dataIndexes[index];
|
||||
// Skip series with no data at the current cursor position
|
||||
if (dataIdx === null) {
|
||||
if (dataIndex === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const raw = data[idx]?.[dataIdx];
|
||||
const raw = data[index]?.[dataIndex];
|
||||
const value = Number(raw);
|
||||
const displayValue = Number.isNaN(value) ? 0 : value;
|
||||
const isActive = idx === activeSeriesIdx;
|
||||
const isActive = index === activeSeriesIndex;
|
||||
|
||||
const item: TooltipContentItem = {
|
||||
label: String(s.label ?? ''),
|
||||
value: displayValue,
|
||||
tooltipValue: getToolTipValue(displayValue, yAxisUnit, decimalPrecision),
|
||||
color: resolveSeriesColor(s.stroke, uPlotInstance, idx),
|
||||
color: resolveSeriesColor(s.stroke, uPlotInstance, index),
|
||||
isActive,
|
||||
};
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import { LineChart } from 'lucide-react';
|
||||
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
|
||||
import uPlot, { AlignedData, Options } from 'uplot';
|
||||
|
||||
import { UPlotConfigBuilder } from '../config/UPlotConfigBuilder';
|
||||
import { usePlotContext } from '../context/PlotContext';
|
||||
import { UPlotChartProps } from './types';
|
||||
|
||||
@@ -49,7 +48,6 @@ export default function UPlotChart({
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const plotInstanceRef = useRef<uPlot | null>(null);
|
||||
const prevPropsRef = useRef<UPlotChartProps | null>(null);
|
||||
const configUsedForPlotRef = useRef<UPlotConfigBuilder | null>(null);
|
||||
|
||||
/**
|
||||
* Destroy the existing plot instance if present.
|
||||
@@ -57,12 +55,6 @@ export default function UPlotChart({
|
||||
const destroyPlot = useCallback((): void => {
|
||||
if (plotInstanceRef.current) {
|
||||
onDestroy?.(plotInstanceRef.current);
|
||||
// Clean up the config builder that was used to create this plot (not the current prop)
|
||||
if (configUsedForPlotRef.current) {
|
||||
configUsedForPlotRef.current.destroy();
|
||||
}
|
||||
configUsedForPlotRef.current = null;
|
||||
|
||||
plotInstanceRef.current.destroy();
|
||||
plotInstanceRef.current = null;
|
||||
setPlotContextInitialState({ uPlotInstance: null });
|
||||
@@ -97,13 +89,14 @@ export default function UPlotChart({
|
||||
if (plotRef) {
|
||||
plotRef(plot);
|
||||
}
|
||||
|
||||
setPlotContextInitialState({
|
||||
uPlotInstance: plot,
|
||||
widgetId: config.getWidgetId(),
|
||||
shouldSaveSelectionPreference: config.getShouldSaveSelectionPreference(),
|
||||
});
|
||||
|
||||
plotInstanceRef.current = plot;
|
||||
configUsedForPlotRef.current = config;
|
||||
}, [
|
||||
config,
|
||||
data,
|
||||
@@ -143,11 +136,13 @@ export default function UPlotChart({
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if the plot instance's container has been unmounted (e.g., after "No Data" state)
|
||||
// If so, we need to recreate the plot with the new container
|
||||
// Check if the plot instance's root is no longer attached to our container
|
||||
// (e.g., after React has re‑mounted the container div). In uPlot, `root`
|
||||
// is a child of the container, so we must compare against its parent node.
|
||||
const isPlotOrphaned =
|
||||
plotInstanceRef.current &&
|
||||
plotInstanceRef.current.root !== containerRef.current;
|
||||
!!plotInstanceRef.current &&
|
||||
(!containerRef.current ||
|
||||
plotInstanceRef.current.root.parentElement !== containerRef.current);
|
||||
|
||||
// Update dimensions without reinitializing if only size changed
|
||||
if (
|
||||
|
||||
@@ -75,7 +75,7 @@ export interface LegendConfig {
|
||||
export interface LegendProps {
|
||||
position?: LegendPosition;
|
||||
config: UPlotConfigBuilder;
|
||||
legendsPerSet?: number;
|
||||
averageLegendWidth?: number;
|
||||
}
|
||||
|
||||
export interface TooltipContentItem {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getStoredSeriesVisibility } from 'container/DashboardContainer/visualization/panels/utils/legendVisibilityUtils';
|
||||
import { ThresholdsDrawHookOptions } from 'lib/uPlotV2/hooks/types';
|
||||
import { thresholdsDrawHook } from 'lib/uPlotV2/hooks/useThresholdsDrawHook';
|
||||
import { resolveSeriesVisibility } from 'lib/uPlotV2/utils/seriesVisibility';
|
||||
import { merge } from 'lodash-es';
|
||||
import noop from 'lodash-es/noop';
|
||||
import uPlot, { Cursor, Hooks, Options } from 'uplot';
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
DEFAULT_CURSOR_CONFIG,
|
||||
DEFAULT_PLOT_CONFIG,
|
||||
LegendItem,
|
||||
SelectionPreferencesSource,
|
||||
} from './types';
|
||||
import { AxisProps, UPlotAxisBuilder } from './UPlotAxisBuilder';
|
||||
import { ScaleProps, UPlotScaleBuilder } from './UPlotScaleBuilder';
|
||||
@@ -36,6 +38,10 @@ export class UPlotConfigBuilder extends ConfigBuilder<
|
||||
> {
|
||||
series: UPlotSeriesBuilder[] = [];
|
||||
|
||||
private selectionPreferencesSource: SelectionPreferencesSource =
|
||||
SelectionPreferencesSource.IN_MEMORY;
|
||||
private shouldSaveSelectionPreference = false;
|
||||
|
||||
private axes: Record<string, UPlotAxisBuilder> = {};
|
||||
|
||||
readonly scales: UPlotScaleBuilder[] = [];
|
||||
@@ -64,11 +70,15 @@ export class UPlotConfigBuilder extends ConfigBuilder<
|
||||
|
||||
private onDragSelect: (startTime: number, endTime: number) => void;
|
||||
|
||||
private cleanups: Array<() => void> = [];
|
||||
|
||||
constructor(args?: ConfigBuilderProps) {
|
||||
super(args ?? {});
|
||||
const { widgetId, onDragSelect, tzDate } = args ?? {};
|
||||
const {
|
||||
widgetId,
|
||||
onDragSelect,
|
||||
tzDate,
|
||||
selectionPreferencesSource,
|
||||
shouldSaveSelectionPreference,
|
||||
} = args ?? {};
|
||||
if (widgetId) {
|
||||
this.widgetId = widgetId;
|
||||
}
|
||||
@@ -77,25 +87,38 @@ export class UPlotConfigBuilder extends ConfigBuilder<
|
||||
this.tzDate = tzDate;
|
||||
}
|
||||
|
||||
this.onDragSelect = noop;
|
||||
if (selectionPreferencesSource) {
|
||||
this.selectionPreferencesSource = selectionPreferencesSource;
|
||||
}
|
||||
|
||||
if (shouldSaveSelectionPreference) {
|
||||
this.shouldSaveSelectionPreference = shouldSaveSelectionPreference;
|
||||
}
|
||||
|
||||
this.onDragSelect = noop;
|
||||
if (onDragSelect) {
|
||||
this.onDragSelect = onDragSelect;
|
||||
// Add a hook to handle the select event
|
||||
const cleanup = this.addHook('setSelect', (self: uPlot): void => {
|
||||
this.addHook('setSelect', (self: uPlot): void => {
|
||||
const selection = self.select;
|
||||
// Only trigger onDragSelect when there's an actual drag range (width > 0)
|
||||
// A click without dragging produces width === 0, which should be ignored
|
||||
if (selection && selection.width > 0) {
|
||||
const startTime = self.posToVal(selection.left, 'x');
|
||||
const endTime = self.posToVal(selection.left + selection.width, 'x');
|
||||
|
||||
this.onDragSelect(startTime * 1000, endTime * 1000);
|
||||
}
|
||||
});
|
||||
this.cleanups.push(cleanup);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the save selection preferences
|
||||
*/
|
||||
getShouldSaveSelectionPreference(): boolean {
|
||||
return this.shouldSaveSelectionPreference;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add or merge an axis configuration
|
||||
*/
|
||||
@@ -158,8 +181,7 @@ export class UPlotConfigBuilder extends ConfigBuilder<
|
||||
addThresholds(options: ThresholdsDrawHookOptions): void {
|
||||
if (!this.thresholds[options.scaleKey]) {
|
||||
this.thresholds[options.scaleKey] = options;
|
||||
const cleanup = this.addHook('draw', thresholdsDrawHook(options));
|
||||
this.cleanups.push(cleanup);
|
||||
this.addHook('draw', thresholdsDrawHook(options));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,20 +234,39 @@ export class UPlotConfigBuilder extends ConfigBuilder<
|
||||
this.tzDate = tzDate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns stored series visibility map from localStorage when preferences source is LOCAL_STORAGE, otherwise null.
|
||||
*/
|
||||
private getStoredVisibilityMap(): Map<string, boolean> | null {
|
||||
if (
|
||||
this.widgetId &&
|
||||
this.selectionPreferencesSource === SelectionPreferencesSource.LOCAL_STORAGE
|
||||
) {
|
||||
return getStoredSeriesVisibility(this.widgetId);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get legend items with visibility state restored from localStorage if available
|
||||
*/
|
||||
getLegendItems(): Record<number, LegendItem> {
|
||||
const visibilityMap = this.widgetId
|
||||
? getStoredSeriesVisibility(this.widgetId)
|
||||
: null;
|
||||
const visibilityMap = this.getStoredVisibilityMap();
|
||||
const isAnySeriesHidden = !!(
|
||||
visibilityMap && Array.from(visibilityMap.values()).some((show) => !show)
|
||||
);
|
||||
|
||||
return this.series.reduce((acc, s: UPlotSeriesBuilder, index: number) => {
|
||||
const seriesConfig = s.getConfig();
|
||||
const label = seriesConfig.label ?? '';
|
||||
const seriesIndex = index + 1; // +1 because the first series is the timestamp
|
||||
|
||||
// Priority: stored visibility > series config > default (true)
|
||||
const show = visibilityMap?.get(label) ?? seriesConfig.show ?? true;
|
||||
const show = resolveSeriesVisibility(
|
||||
label,
|
||||
seriesConfig.show,
|
||||
visibilityMap,
|
||||
isAnySeriesHidden,
|
||||
);
|
||||
|
||||
acc[seriesIndex] = {
|
||||
seriesIndex,
|
||||
@@ -238,13 +279,6 @@ export class UPlotConfigBuilder extends ConfigBuilder<
|
||||
}, {} as Record<number, LegendItem>);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all hooks and cleanup functions
|
||||
*/
|
||||
destroy(): void {
|
||||
this.cleanups.forEach((cleanup) => cleanup());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the widget id
|
||||
*/
|
||||
@@ -260,9 +294,27 @@ export class UPlotConfigBuilder extends ConfigBuilder<
|
||||
...DEFAULT_PLOT_CONFIG,
|
||||
};
|
||||
|
||||
const visibilityMap = this.getStoredVisibilityMap();
|
||||
const isAnySeriesHidden = !!(
|
||||
visibilityMap && Array.from(visibilityMap.values()).some((show) => !show)
|
||||
);
|
||||
|
||||
config.series = [
|
||||
{ value: (): string => '' }, // Base series for timestamp
|
||||
...this.series.map((s) => s.getConfig()),
|
||||
...this.series.map((s) => {
|
||||
const series = s.getConfig();
|
||||
const label = series.label ?? '';
|
||||
const visible = resolveSeriesVisibility(
|
||||
label,
|
||||
series.show,
|
||||
visibilityMap,
|
||||
isAnySeriesHidden,
|
||||
);
|
||||
return {
|
||||
...series,
|
||||
show: visible,
|
||||
};
|
||||
}),
|
||||
];
|
||||
config.axes = Object.values(this.axes).map((a) => a.getConfig());
|
||||
config.scales = this.scales.reduce(
|
||||
|
||||
@@ -40,8 +40,8 @@ export class UPlotScaleBuilder extends ConfigBuilder<
|
||||
range,
|
||||
thresholds,
|
||||
logBase = 10,
|
||||
padMinBy = 0.1,
|
||||
padMaxBy = 0.1,
|
||||
padMinBy = 0,
|
||||
padMaxBy = 0.05,
|
||||
} = this.props;
|
||||
|
||||
// Special handling for time scales (X axis)
|
||||
|
||||
@@ -5,8 +5,8 @@ import uPlot, { Series } from 'uplot';
|
||||
import {
|
||||
ConfigBuilder,
|
||||
DrawStyle,
|
||||
FillStyle,
|
||||
LineInterpolation,
|
||||
LineStyle,
|
||||
SeriesProps,
|
||||
VisibilityMode,
|
||||
} from './types';
|
||||
@@ -16,23 +16,29 @@ import {
|
||||
* Handles creation of series settings
|
||||
*/
|
||||
export class UPlotSeriesBuilder extends ConfigBuilder<SeriesProps, Series> {
|
||||
private buildLineConfig(
|
||||
lineColor: string,
|
||||
lineWidth?: number,
|
||||
lineStyle?: { fill?: FillStyle; dash?: number[] },
|
||||
): Partial<Series> {
|
||||
private buildLineConfig({
|
||||
lineColor,
|
||||
lineWidth,
|
||||
lineStyle,
|
||||
lineCap,
|
||||
}: {
|
||||
lineColor: string;
|
||||
lineWidth?: number;
|
||||
lineStyle?: LineStyle;
|
||||
lineCap?: Series.Cap;
|
||||
}): Partial<Series> {
|
||||
const lineConfig: Partial<Series> = {
|
||||
stroke: lineColor,
|
||||
width: lineWidth ?? 2,
|
||||
};
|
||||
|
||||
if (lineStyle && lineStyle.fill !== FillStyle.Solid) {
|
||||
if (lineStyle.fill === FillStyle.Dot) {
|
||||
lineConfig.cap = 'round';
|
||||
}
|
||||
lineConfig.dash = lineStyle.dash ?? [10, 10];
|
||||
if (lineStyle === LineStyle.Dashed) {
|
||||
lineConfig.dash = [10, 10];
|
||||
}
|
||||
|
||||
if (lineCap) {
|
||||
lineConfig.cap = lineCap;
|
||||
}
|
||||
return lineConfig;
|
||||
}
|
||||
|
||||
@@ -138,6 +144,7 @@ export class UPlotSeriesBuilder extends ConfigBuilder<SeriesProps, Series> {
|
||||
lineInterpolation,
|
||||
lineWidth,
|
||||
lineStyle,
|
||||
lineCap,
|
||||
showPoints,
|
||||
pointSize,
|
||||
scaleKey,
|
||||
@@ -148,7 +155,12 @@ export class UPlotSeriesBuilder extends ConfigBuilder<SeriesProps, Series> {
|
||||
|
||||
const lineColor = this.getLineColor();
|
||||
|
||||
const lineConfig = this.buildLineConfig(lineColor, lineWidth, lineStyle);
|
||||
const lineConfig = this.buildLineConfig({
|
||||
lineColor,
|
||||
lineWidth,
|
||||
lineStyle,
|
||||
lineCap,
|
||||
});
|
||||
const pathConfig = this.buildPathConfig({
|
||||
pathBuilder,
|
||||
drawStyle,
|
||||
|
||||
@@ -22,6 +22,14 @@ export abstract class ConfigBuilder<P, T> {
|
||||
merge?(props: Partial<P>): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Preferences source for the uPlot config builder
|
||||
*/
|
||||
export enum SelectionPreferencesSource {
|
||||
LOCAL_STORAGE = 'LOCAL_STORAGE',
|
||||
IN_MEMORY = 'IN_MEMORY',
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for configuring the uPlot config builder
|
||||
*/
|
||||
@@ -29,6 +37,8 @@ export interface ConfigBuilderProps {
|
||||
widgetId?: string;
|
||||
onDragSelect?: (startTime: number, endTime: number) => void;
|
||||
tzDate?: uPlot.LocalDateFromUnix;
|
||||
selectionPreferencesSource?: SelectionPreferencesSource;
|
||||
shouldSaveSelectionPreference?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -92,16 +102,9 @@ export interface ScaleProps {
|
||||
* Props for configuring a series
|
||||
*/
|
||||
|
||||
export enum FillStyle {
|
||||
export enum LineStyle {
|
||||
Solid = 'solid',
|
||||
Dash = 'dash',
|
||||
Dot = 'dot',
|
||||
Square = 'square',
|
||||
}
|
||||
|
||||
export interface LineStyle {
|
||||
dash?: Array<number>;
|
||||
fill?: FillStyle;
|
||||
Dashed = 'dashed',
|
||||
}
|
||||
|
||||
export enum DrawStyle {
|
||||
@@ -141,6 +144,7 @@ export interface SeriesProps {
|
||||
lineInterpolation?: LineInterpolation;
|
||||
lineStyle?: LineStyle;
|
||||
lineWidth?: number;
|
||||
lineCap?: Series.Cap;
|
||||
|
||||
// Points config
|
||||
pointColor?: string;
|
||||
|
||||
@@ -9,16 +9,17 @@ import {
|
||||
import type { SeriesVisibilityItem } from 'container/DashboardContainer/visualization/panels/types';
|
||||
import { updateSeriesVisibilityToLocalStorage } from 'container/DashboardContainer/visualization/panels/utils/legendVisibilityUtils';
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
export interface PlotContextInitialState {
|
||||
uPlotInstance: uPlot | null;
|
||||
widgetId?: string;
|
||||
shouldSaveSelectionPreference?: boolean;
|
||||
}
|
||||
export interface IPlotContext {
|
||||
setPlotContextInitialState: (state: PlotContextInitialState) => void;
|
||||
onToggleSeriesVisibility: (seriesIndex: number) => void;
|
||||
onToggleSeriesOnOff: (seriesIndex: number) => void;
|
||||
onFocusSeries: (seriesIndex: number | null) => void;
|
||||
syncSeriesVisibilityToLocalStorage: () => void;
|
||||
}
|
||||
|
||||
export const PlotContext = createContext<IPlotContext | null>(null);
|
||||
@@ -29,68 +30,84 @@ export const PlotContextProvider = ({
|
||||
const uPlotInstanceRef = useRef<uPlot | null>(null);
|
||||
const activeSeriesIndex = useRef<number | undefined>(undefined);
|
||||
const widgetIdRef = useRef<string | undefined>(undefined);
|
||||
const shouldSavePreferencesRef = useRef<boolean>(false);
|
||||
|
||||
const setPlotContextInitialState = useCallback(
|
||||
({ uPlotInstance, widgetId }: PlotContextInitialState): void => {
|
||||
({
|
||||
uPlotInstance,
|
||||
widgetId,
|
||||
shouldSaveSelectionPreference,
|
||||
}: PlotContextInitialState): void => {
|
||||
uPlotInstanceRef.current = uPlotInstance;
|
||||
widgetIdRef.current = widgetId;
|
||||
activeSeriesIndex.current = undefined;
|
||||
shouldSavePreferencesRef.current = !!shouldSaveSelectionPreference;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const onToggleSeriesVisibility = useCallback((seriesIndex: number): void => {
|
||||
const syncSeriesVisibilityToLocalStorage = useCallback((): void => {
|
||||
const plot = uPlotInstanceRef.current;
|
||||
if (!plot) {
|
||||
if (!plot || !widgetIdRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isReset = activeSeriesIndex.current === seriesIndex;
|
||||
activeSeriesIndex.current = isReset ? undefined : seriesIndex;
|
||||
const seriesVisibility: SeriesVisibilityItem[] = plot.series.map(
|
||||
(series) => ({
|
||||
label: series.label ?? '',
|
||||
show: series.show ?? true,
|
||||
}),
|
||||
);
|
||||
|
||||
plot.batch(() => {
|
||||
plot.series.forEach((_, index) => {
|
||||
if (index === 0) {
|
||||
return;
|
||||
}
|
||||
const currentSeriesIndex = index;
|
||||
plot.setSeries(currentSeriesIndex, {
|
||||
show: isReset || currentSeriesIndex === seriesIndex,
|
||||
});
|
||||
});
|
||||
if (widgetIdRef.current) {
|
||||
const seriesVisibility: SeriesVisibilityItem[] = plot.series.map(
|
||||
(series) => ({
|
||||
label: series.label ?? '',
|
||||
show: series.show ?? true,
|
||||
}),
|
||||
);
|
||||
updateSeriesVisibilityToLocalStorage(widgetIdRef.current, seriesVisibility);
|
||||
updateSeriesVisibilityToLocalStorage(widgetIdRef.current, seriesVisibility);
|
||||
}, []);
|
||||
|
||||
const onToggleSeriesVisibility = useCallback(
|
||||
(seriesIndex: number): void => {
|
||||
const plot = uPlotInstanceRef.current;
|
||||
if (!plot) {
|
||||
return;
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const onToggleSeriesOnOff = useCallback((seriesIndex: number): void => {
|
||||
const plot = uPlotInstanceRef.current;
|
||||
if (!plot) {
|
||||
return;
|
||||
}
|
||||
const isReset = activeSeriesIndex.current === seriesIndex;
|
||||
activeSeriesIndex.current = isReset ? undefined : seriesIndex;
|
||||
|
||||
const series = plot.series[seriesIndex];
|
||||
if (!series) {
|
||||
return;
|
||||
}
|
||||
plot.setSeries(seriesIndex, { show: !series.show });
|
||||
if (widgetIdRef.current) {
|
||||
const seriesVisibility: SeriesVisibilityItem[] = plot.series.map(
|
||||
(series) => ({
|
||||
label: series.label ?? '',
|
||||
show: series.show ?? true,
|
||||
}),
|
||||
);
|
||||
updateSeriesVisibilityToLocalStorage(widgetIdRef.current, seriesVisibility);
|
||||
}
|
||||
}, []);
|
||||
plot.batch(() => {
|
||||
plot.series.forEach((_, index) => {
|
||||
if (index === 0) {
|
||||
return;
|
||||
}
|
||||
const currentSeriesIndex = index;
|
||||
plot.setSeries(currentSeriesIndex, {
|
||||
show: isReset || currentSeriesIndex === seriesIndex,
|
||||
});
|
||||
});
|
||||
if (widgetIdRef.current && shouldSavePreferencesRef.current) {
|
||||
syncSeriesVisibilityToLocalStorage();
|
||||
}
|
||||
});
|
||||
},
|
||||
[syncSeriesVisibilityToLocalStorage],
|
||||
);
|
||||
|
||||
const onToggleSeriesOnOff = useCallback(
|
||||
(seriesIndex: number): void => {
|
||||
const plot = uPlotInstanceRef.current;
|
||||
if (!plot) {
|
||||
return;
|
||||
}
|
||||
|
||||
const series = plot.series[seriesIndex];
|
||||
if (!series) {
|
||||
return;
|
||||
}
|
||||
plot.setSeries(seriesIndex, { show: !series.show });
|
||||
if (widgetIdRef.current && shouldSavePreferencesRef.current) {
|
||||
syncSeriesVisibilityToLocalStorage();
|
||||
}
|
||||
},
|
||||
[syncSeriesVisibilityToLocalStorage],
|
||||
);
|
||||
|
||||
const onFocusSeries = useCallback((seriesIndex: number | null): void => {
|
||||
const plot = uPlotInstanceRef.current;
|
||||
@@ -113,12 +130,14 @@ export const PlotContextProvider = ({
|
||||
setPlotContextInitialState,
|
||||
onToggleSeriesOnOff,
|
||||
onFocusSeries,
|
||||
syncSeriesVisibilityToLocalStorage,
|
||||
}),
|
||||
[
|
||||
onToggleSeriesVisibility,
|
||||
setPlotContextInitialState,
|
||||
onToggleSeriesOnOff,
|
||||
onFocusSeries,
|
||||
syncSeriesVisibilityToLocalStorage,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
.tooltip-plugin-container {
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 1070;
|
||||
white-space: pre;
|
||||
border-radius: 4px;
|
||||
position: fixed;
|
||||
overflow: auto;
|
||||
transform: translate(-1000px, -1000px); // hide the tooltip initially
|
||||
|
||||
&.pinned {
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
}
|
||||
375
frontend/src/lib/uPlotV2/plugins/TooltipPlugin/TooltipPlugin.tsx
Normal file
375
frontend/src/lib/uPlotV2/plugins/TooltipPlugin/TooltipPlugin.tsx
Normal file
@@ -0,0 +1,375 @@
|
||||
import { useLayoutEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import cx from 'classnames';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import {
|
||||
createInitialControllerState,
|
||||
createSetCursorHandler,
|
||||
createSetLegendHandler,
|
||||
createSetSeriesHandler,
|
||||
isScrollEventInPlot,
|
||||
updatePlotVisibility,
|
||||
updateWindowSize,
|
||||
} from './tooltipController';
|
||||
import {
|
||||
DashboardCursorSync,
|
||||
TooltipControllerContext,
|
||||
TooltipControllerState,
|
||||
TooltipLayoutInfo,
|
||||
TooltipPluginProps,
|
||||
TooltipViewState,
|
||||
} from './types';
|
||||
import { createInitialViewState, createLayoutObserver } from './utils';
|
||||
|
||||
import './TooltipPlugin.styles.scss';
|
||||
|
||||
const INTERACTIVE_CONTAINER_CLASSNAME = '.tooltip-plugin-container';
|
||||
// Delay before hiding an unpinned tooltip when the cursor briefly leaves
|
||||
// the plot – this avoids flicker when moving between nearby points.
|
||||
const HOVER_DISMISS_DELAY_MS = 100;
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
export default function TooltipPlugin({
|
||||
config,
|
||||
render,
|
||||
maxWidth = 300,
|
||||
maxHeight = 400,
|
||||
syncMode = DashboardCursorSync.None,
|
||||
syncKey = '_tooltip_sync_global_',
|
||||
canPinTooltip = false,
|
||||
}: TooltipPluginProps): JSX.Element | null {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const portalRoot = useRef<HTMLElement>(document.body);
|
||||
const rafId = useRef<number | null>(null);
|
||||
const dismissTimeoutId = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const layoutRef = useRef<TooltipLayoutInfo>();
|
||||
const renderRef = useRef(render);
|
||||
renderRef.current = render;
|
||||
|
||||
// React-managed snapshot of what should be rendered. The controller
|
||||
// owns the interaction state and calls `updateState` when a visible
|
||||
// change should trigger a React re-render.
|
||||
const [viewState, setState] = useState<TooltipViewState>(
|
||||
createInitialViewState,
|
||||
);
|
||||
const { plot, isHovering, isPinned, contents, style } = viewState;
|
||||
|
||||
/**
|
||||
* Merge a partial view update into the current React state.
|
||||
* Style is merged shallowly so callers can update transform /
|
||||
* pointerEvents without having to rebuild the whole object.
|
||||
*/
|
||||
function updateState(updates: Partial<TooltipViewState>): void {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
...updates,
|
||||
style: { ...prev.style, ...updates.style },
|
||||
}));
|
||||
}
|
||||
|
||||
useLayoutEffect((): (() => void) => {
|
||||
layoutRef.current?.observer.disconnect();
|
||||
layoutRef.current = createLayoutObserver(layoutRef);
|
||||
|
||||
// Controller holds the mutable interaction state for this tooltip
|
||||
// instance. It is intentionally *not* React state so uPlot hooks
|
||||
// and DOM listeners can update it freely without triggering a
|
||||
// render on every mouse move.
|
||||
const controller: TooltipControllerState = createInitialControllerState();
|
||||
|
||||
const syncTooltipWithDashboard = syncMode === DashboardCursorSync.Tooltip;
|
||||
|
||||
// Enable uPlot's built-in cursor sync when requested so that
|
||||
// crosshair / tooltip can follow the dashboard-wide cursor.
|
||||
if (syncMode !== DashboardCursorSync.None && config.scales[0]?.props.time) {
|
||||
config.setCursor({
|
||||
sync: { key: syncKey, scales: ['x', null] },
|
||||
});
|
||||
}
|
||||
|
||||
// Dismiss the tooltip when the user clicks / presses a key
|
||||
// outside the tooltip container while it is pinned.
|
||||
const onOutsideInteraction = (event: Event): void => {
|
||||
const target = event.target as HTMLElement;
|
||||
if (!target.closest(INTERACTIVE_CONTAINER_CLASSNAME)) {
|
||||
dismissTooltip();
|
||||
}
|
||||
};
|
||||
|
||||
// When pinned we want the tooltip to be mouse-interactive
|
||||
// (for copying values etc.), otherwise it should ignore
|
||||
// pointer events so the chart remains fully clickable.
|
||||
function updatePointerEvents(): void {
|
||||
controller.style = {
|
||||
...controller.style,
|
||||
pointerEvents: controller.pinned ? 'all' : 'none',
|
||||
};
|
||||
}
|
||||
|
||||
// Lock uPlot's internal cursor when the tooltip is pinned so
|
||||
// subsequent mouse moves do not move the crosshair.
|
||||
function updateCursorLock(): void {
|
||||
if (controller.plot) {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore uPlot cursor lock is not working as expected
|
||||
controller.plot.cursor._lock = controller.pinned;
|
||||
}
|
||||
}
|
||||
|
||||
// Attach / detach global listeners when pin state changes so
|
||||
// we can detect when the user interacts outside the tooltip.
|
||||
function toggleOutsideListeners(enable: boolean): void {
|
||||
if (enable) {
|
||||
document.addEventListener('mousedown', onOutsideInteraction, true);
|
||||
document.addEventListener('keydown', onOutsideInteraction, true);
|
||||
} else {
|
||||
document.removeEventListener('mousedown', onOutsideInteraction, true);
|
||||
document.removeEventListener('keydown', onOutsideInteraction, true);
|
||||
}
|
||||
}
|
||||
|
||||
// Centralised helper that applies all side effects that depend
|
||||
// on whether the tooltip is currently pinned.
|
||||
function applyPinnedSideEffects(): void {
|
||||
updatePointerEvents();
|
||||
updateCursorLock();
|
||||
toggleOutsideListeners(controller.pinned);
|
||||
}
|
||||
|
||||
// Hide the tooltip and reset the uPlot cursor. This is used
|
||||
// both when the user unpins and when interaction ends.
|
||||
function dismissTooltip(): void {
|
||||
const isPinnedBeforeDismiss = controller.pinned;
|
||||
controller.pinned = false;
|
||||
controller.hoverActive = false;
|
||||
if (controller.plot) {
|
||||
controller.plot.setCursor({ left: -10, top: -10 });
|
||||
}
|
||||
scheduleRender(isPinnedBeforeDismiss);
|
||||
}
|
||||
|
||||
// Build the React node to be rendered inside the tooltip by
|
||||
// delegating to the caller-provided `render` function.
|
||||
function createTooltipContents(): React.ReactNode {
|
||||
if (!controller.hoverActive || !controller.plot) {
|
||||
return null;
|
||||
}
|
||||
return renderRef.current({
|
||||
uPlotInstance: controller.plot,
|
||||
dataIndexes: controller.seriesIndexes,
|
||||
seriesIndex: controller.focusedSeriesIndex,
|
||||
isPinned: controller.pinned,
|
||||
dismiss: dismissTooltip,
|
||||
viaSync: controller.cursorDrivenBySync,
|
||||
});
|
||||
}
|
||||
|
||||
// Push the latest controller state into React so the tooltip's
|
||||
// DOM representation catches up with the interaction state.
|
||||
function performRender(): void {
|
||||
rafId.current = null;
|
||||
dismissTimeoutId.current = null;
|
||||
|
||||
if (controller.pendingPinnedUpdate) {
|
||||
applyPinnedSideEffects();
|
||||
controller.pendingPinnedUpdate = false;
|
||||
}
|
||||
|
||||
updateState({
|
||||
style: controller.style,
|
||||
isPinned: controller.pinned,
|
||||
isHovering: controller.hoverActive,
|
||||
contents: createTooltipContents(),
|
||||
dismiss: dismissTooltip,
|
||||
});
|
||||
}
|
||||
|
||||
// Cancel any pending render to prevent race conditions
|
||||
function cancelPendingRender(): void {
|
||||
if (rafId.current != null) {
|
||||
cancelAnimationFrame(rafId.current);
|
||||
rafId.current = null;
|
||||
}
|
||||
if (dismissTimeoutId.current != null) {
|
||||
clearTimeout(dismissTimeoutId.current);
|
||||
dismissTimeoutId.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Throttle React re-renders:
|
||||
// - use rAF while hovering for smooth updates
|
||||
// - use a small timeout when hiding to avoid flicker when
|
||||
// briefly leaving and re-entering the plot.
|
||||
//
|
||||
// Re-entering hover while a dismiss timeout is pending should
|
||||
// cancel that timeout so it cannot fire with stale state.
|
||||
function scheduleRender(updatePinned = false): void {
|
||||
// Always cancel any existing pending callback first so that
|
||||
// a newly scheduled render reflects the latest controller
|
||||
// state (e.g. when quickly re-entering after a brief leave).
|
||||
cancelPendingRender();
|
||||
|
||||
if (controller.hoverActive) {
|
||||
rafId.current = requestAnimationFrame(performRender);
|
||||
} else {
|
||||
dismissTimeoutId.current = setTimeout(
|
||||
performRender,
|
||||
HOVER_DISMISS_DELAY_MS,
|
||||
);
|
||||
}
|
||||
|
||||
if (updatePinned) {
|
||||
controller.pendingPinnedUpdate = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Keep controller's windowWidth / windowHeight in sync so that
|
||||
// tooltip positioning can respect the current viewport size.
|
||||
const handleWindowResize = (): void => {
|
||||
updateWindowSize(controller);
|
||||
};
|
||||
|
||||
// When the user scrolls, recompute plot visibility and hide
|
||||
// the tooltip if the scroll originated from inside the plot.
|
||||
const handleScroll = (event: Event): void => {
|
||||
updatePlotVisibility(controller);
|
||||
if (controller.hoverActive && isScrollEventInPlot(event, controller)) {
|
||||
dismissTooltip();
|
||||
}
|
||||
};
|
||||
|
||||
// When pinning is enabled, a click on the plot overlay while
|
||||
// hovering converts the transient tooltip into a pinned one.
|
||||
const handleUPlotOverClick = (u: uPlot, event: MouseEvent): void => {
|
||||
if (
|
||||
event.target === u.over &&
|
||||
controller.hoverActive &&
|
||||
!controller.pinned &&
|
||||
controller.focusedSeriesIndex != null
|
||||
) {
|
||||
setTimeout(() => {
|
||||
controller.pinned = true;
|
||||
scheduleRender(true);
|
||||
}, 0);
|
||||
}
|
||||
};
|
||||
|
||||
let overClickHandler: ((event: MouseEvent) => void) | null = null;
|
||||
|
||||
// Called once per uPlot instance; used to store the instance
|
||||
// on the controller and optionally attach the pinning handler.
|
||||
const handleInit = (u: uPlot): void => {
|
||||
controller.plot = u;
|
||||
updateState({ plot: u });
|
||||
if (canPinTooltip) {
|
||||
overClickHandler = (event: MouseEvent): void =>
|
||||
handleUPlotOverClick(u, event);
|
||||
u.over.addEventListener('click', overClickHandler);
|
||||
}
|
||||
};
|
||||
|
||||
// If the underlying data changes we drop any pinned tooltip,
|
||||
// since the contents may no longer match the new series data.
|
||||
const handleSetData = (): void => {
|
||||
if (controller.pinned) {
|
||||
dismissTooltip();
|
||||
}
|
||||
};
|
||||
|
||||
// Shared context object passed down into all uPlot hook
|
||||
// handlers so they can interact with the controller and
|
||||
// schedule React updates when needed.
|
||||
const ctx: TooltipControllerContext = {
|
||||
controller,
|
||||
layoutRef,
|
||||
containerRef,
|
||||
rafId,
|
||||
updateState,
|
||||
renderRef,
|
||||
syncMode,
|
||||
syncKey,
|
||||
canPinTooltip,
|
||||
createTooltipContents,
|
||||
scheduleRender,
|
||||
dismissTooltip,
|
||||
};
|
||||
|
||||
const handleSetSeries = createSetSeriesHandler(ctx, syncTooltipWithDashboard);
|
||||
const handleSetLegend = createSetLegendHandler(ctx, syncTooltipWithDashboard);
|
||||
const handleSetCursor = createSetCursorHandler(ctx);
|
||||
|
||||
handleWindowResize();
|
||||
|
||||
const removeReadyHook = config.addHook('ready', (): void =>
|
||||
updatePlotVisibility(controller),
|
||||
);
|
||||
const removeInitHook = config.addHook('init', handleInit);
|
||||
const removeSetDataHook = config.addHook('setData', handleSetData);
|
||||
const removeSetSeriesHook = config.addHook('setSeries', handleSetSeries);
|
||||
const removeSetLegendHook = config.addHook('setLegend', handleSetLegend);
|
||||
const removeSetCursorHook = config.addHook('setCursor', handleSetCursor);
|
||||
|
||||
window.addEventListener('resize', handleWindowResize);
|
||||
window.addEventListener('scroll', handleScroll, true);
|
||||
|
||||
return (): void => {
|
||||
layoutRef.current?.observer.disconnect();
|
||||
window.removeEventListener('resize', handleWindowResize);
|
||||
window.removeEventListener('scroll', handleScroll, true);
|
||||
document.removeEventListener('mousedown', onOutsideInteraction, true);
|
||||
document.removeEventListener('keydown', onOutsideInteraction, true);
|
||||
cancelPendingRender();
|
||||
removeReadyHook();
|
||||
removeInitHook();
|
||||
removeSetDataHook();
|
||||
removeSetSeriesHook();
|
||||
removeSetLegendHook();
|
||||
removeSetCursorHook();
|
||||
if (controller.plot && overClickHandler) {
|
||||
controller.plot.over.removeEventListener('click', overClickHandler);
|
||||
overClickHandler = null;
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [config]);
|
||||
|
||||
useLayoutEffect((): void => {
|
||||
if (!plot || !layoutRef.current) {
|
||||
return;
|
||||
}
|
||||
const layout = layoutRef.current;
|
||||
if (containerRef.current) {
|
||||
layout.observer.disconnect();
|
||||
layout.observer.observe(containerRef.current);
|
||||
const { width, height } = containerRef.current.getBoundingClientRect();
|
||||
layout.width = width;
|
||||
layout.height = height;
|
||||
} else {
|
||||
layout.width = 0;
|
||||
layout.height = 0;
|
||||
}
|
||||
}, [isHovering, plot]);
|
||||
|
||||
if (!plot || !isHovering) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className={cx('tooltip-plugin-container', { pinned: isPinned })}
|
||||
style={{
|
||||
...style,
|
||||
maxWidth: `${maxWidth}px`,
|
||||
maxHeight: `${maxHeight}px`,
|
||||
width: '100%',
|
||||
}}
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
ref={containerRef}
|
||||
>
|
||||
{contents}
|
||||
</div>,
|
||||
portalRoot.current,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import { isEqual } from 'lodash-es';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { TooltipControllerContext, TooltipControllerState } from './types';
|
||||
import {
|
||||
buildTransform,
|
||||
calculateTooltipOffset,
|
||||
isPlotInViewport,
|
||||
} from './utils';
|
||||
|
||||
const WINDOW_OFFSET = 16;
|
||||
|
||||
export function createInitialControllerState(): TooltipControllerState {
|
||||
return {
|
||||
plot: null,
|
||||
hoverActive: false,
|
||||
isAnySeriesActive: false,
|
||||
pinned: false,
|
||||
style: { transform: '', pointerEvents: 'none' },
|
||||
horizontalOffset: 0,
|
||||
verticalOffset: 0,
|
||||
seriesIndexes: [],
|
||||
focusedSeriesIndex: null,
|
||||
cursorDrivenBySync: false,
|
||||
plotWithinViewport: false,
|
||||
windowWidth: window.innerWidth - WINDOW_OFFSET,
|
||||
windowHeight: window.innerHeight - WINDOW_OFFSET,
|
||||
pendingPinnedUpdate: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep track of the current window size and clear hover state
|
||||
* when the user resizes while hovering (to avoid an orphan tooltip).
|
||||
*/
|
||||
export function updateWindowSize(controller: TooltipControllerState): void {
|
||||
if (controller.hoverActive && !controller.pinned) {
|
||||
controller.hoverActive = false;
|
||||
}
|
||||
controller.windowWidth = window.innerWidth - WINDOW_OFFSET;
|
||||
controller.windowHeight = window.innerHeight - WINDOW_OFFSET;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark whether the plot is currently inside the viewport.
|
||||
* This is used to decide if a synced tooltip should be shown at all.
|
||||
*/
|
||||
export function updatePlotVisibility(controller: TooltipControllerState): void {
|
||||
if (!controller.plot) {
|
||||
controller.plotWithinViewport = false;
|
||||
return;
|
||||
}
|
||||
controller.plotWithinViewport = isPlotInViewport(
|
||||
controller.plot.rect,
|
||||
controller.windowWidth,
|
||||
controller.windowHeight,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to detect whether a scroll event actually happened inside
|
||||
* the plot container. Used so we only dismiss the tooltip when the
|
||||
* user scrolls the chart, not the whole page.
|
||||
*/
|
||||
export function isScrollEventInPlot(
|
||||
event: Event,
|
||||
controller: TooltipControllerState,
|
||||
): boolean {
|
||||
return (
|
||||
event.target instanceof Node &&
|
||||
controller.plot !== null &&
|
||||
event.target.contains(controller.plot.root)
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldShowTooltipForSync(
|
||||
controller: TooltipControllerState,
|
||||
syncTooltipWithDashboard: boolean,
|
||||
): boolean {
|
||||
return (
|
||||
controller.plotWithinViewport &&
|
||||
controller.isAnySeriesActive &&
|
||||
syncTooltipWithDashboard
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldShowTooltipForInteraction(
|
||||
controller: TooltipControllerState,
|
||||
): boolean {
|
||||
return controller.focusedSeriesIndex != null || controller.isAnySeriesActive;
|
||||
}
|
||||
|
||||
export function updateHoverState(
|
||||
controller: TooltipControllerState,
|
||||
syncTooltipWithDashboard: boolean,
|
||||
): void {
|
||||
// When the cursor is driven by dashboard‑level sync, we only show
|
||||
// the tooltip if the plot is in viewport and at least one series
|
||||
// is active. Otherwise we fall back to local interaction logic.
|
||||
controller.hoverActive = controller.cursorDrivenBySync
|
||||
? shouldShowTooltipForSync(controller, syncTooltipWithDashboard)
|
||||
: shouldShowTooltipForInteraction(controller);
|
||||
}
|
||||
|
||||
export function createSetCursorHandler(
|
||||
ctx: TooltipControllerContext,
|
||||
): (u: uPlot) => void {
|
||||
return (u: uPlot): void => {
|
||||
const { controller, layoutRef, containerRef } = ctx;
|
||||
controller.cursorDrivenBySync = u.cursor.event == null;
|
||||
|
||||
if (!controller.hoverActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { left = -10, top = -10 } = u.cursor;
|
||||
if (left < 0 && top < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const clientX = u.rect.left + left;
|
||||
const clientY = u.rect.top + top;
|
||||
const layout = layoutRef.current;
|
||||
if (!layout) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { width: layoutWidth, height: layoutHeight } = layout;
|
||||
const offsets = calculateTooltipOffset(
|
||||
clientX,
|
||||
clientY,
|
||||
layoutWidth,
|
||||
layoutHeight,
|
||||
controller.horizontalOffset,
|
||||
controller.verticalOffset,
|
||||
controller.windowWidth,
|
||||
controller.windowHeight,
|
||||
);
|
||||
|
||||
controller.horizontalOffset = offsets.horizontalOffset;
|
||||
controller.verticalOffset = offsets.verticalOffset;
|
||||
|
||||
const transform = buildTransform(
|
||||
clientX,
|
||||
clientY,
|
||||
controller.horizontalOffset,
|
||||
controller.verticalOffset,
|
||||
);
|
||||
|
||||
// If the DOM node is mounted we move it directly to avoid
|
||||
// going through React; otherwise we cache the transform in
|
||||
// controller.style and ask the plugin to re‑render.
|
||||
if (containerRef.current) {
|
||||
containerRef.current.style.transform = transform;
|
||||
} else {
|
||||
controller.style = { ...controller.style, transform };
|
||||
ctx.scheduleRender();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function createSetLegendHandler(
|
||||
ctx: TooltipControllerContext,
|
||||
syncTooltipWithDashboard: boolean,
|
||||
): (u: uPlot) => void {
|
||||
return (u: uPlot): void => {
|
||||
const { controller } = ctx;
|
||||
if (!controller.plot?.cursor?.idxs) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newSeriesIndexes = controller.plot.cursor.idxs.slice();
|
||||
const isAnySeriesActive = newSeriesIndexes.some((v, i) => i > 0 && v != null);
|
||||
|
||||
const previousCursorDrivenBySync = controller.cursorDrivenBySync;
|
||||
|
||||
// Skip scheduling if legend data is unchanged
|
||||
const seriesIndexesChanged = !isEqual(
|
||||
controller.seriesIndexes,
|
||||
newSeriesIndexes,
|
||||
);
|
||||
|
||||
controller.seriesIndexes = newSeriesIndexes;
|
||||
controller.isAnySeriesActive = isAnySeriesActive;
|
||||
controller.cursorDrivenBySync = u.cursor.event == null;
|
||||
|
||||
const previousHover = controller.hoverActive;
|
||||
updateHoverState(controller, syncTooltipWithDashboard);
|
||||
const hoverStateChanged = controller.hoverActive !== previousHover;
|
||||
|
||||
const cursorDrivenBySyncChanged =
|
||||
controller.cursorDrivenBySync !== previousCursorDrivenBySync;
|
||||
|
||||
// Only schedule when legend data, hover state, or sync‑driven state has changed
|
||||
if (seriesIndexesChanged || hoverStateChanged || cursorDrivenBySyncChanged) {
|
||||
ctx.scheduleRender();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function createSetSeriesHandler(
|
||||
ctx: TooltipControllerContext,
|
||||
syncTooltipWithDashboard: boolean,
|
||||
): (u: uPlot, seriesIdx: number | null, opts: uPlot.Series) => void {
|
||||
return (u: uPlot, seriesIdx: number | null, opts: uPlot.Series): void => {
|
||||
const { controller } = ctx;
|
||||
if (!('focus' in opts)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Remember which series is focused so we can drive hover
|
||||
// logic even when the tooltip is being synced externally.
|
||||
controller.focusedSeriesIndex = seriesIdx ?? null;
|
||||
controller.cursorDrivenBySync = u.cursor.event == null;
|
||||
updateHoverState(controller, syncTooltipWithDashboard);
|
||||
ctx.scheduleRender();
|
||||
};
|
||||
}
|
||||
91
frontend/src/lib/uPlotV2/plugins/TooltipPlugin/types.ts
Normal file
91
frontend/src/lib/uPlotV2/plugins/TooltipPlugin/types.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { CSSProperties } from 'react';
|
||||
|
||||
import { TooltipRenderArgs } from '../../components/types';
|
||||
import { UPlotConfigBuilder } from '../../config/UPlotConfigBuilder';
|
||||
|
||||
export const TOOLTIP_OFFSET = 10;
|
||||
|
||||
export enum DashboardCursorSync {
|
||||
Crosshair,
|
||||
None,
|
||||
Tooltip,
|
||||
}
|
||||
|
||||
export interface TooltipViewState {
|
||||
plot?: uPlot | null;
|
||||
style: Partial<CSSProperties>;
|
||||
isHovering: boolean;
|
||||
isPinned: boolean;
|
||||
dismiss: () => void;
|
||||
contents?: React.ReactNode;
|
||||
}
|
||||
|
||||
export interface TooltipLayoutInfo {
|
||||
observer: ResizeObserver;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface TooltipPluginProps {
|
||||
config: UPlotConfigBuilder;
|
||||
canPinTooltip?: boolean;
|
||||
syncMode?: DashboardCursorSync;
|
||||
syncKey?: string;
|
||||
render: (args: TooltipRenderArgs) => React.ReactNode;
|
||||
maxWidth?: number;
|
||||
maxHeight?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutable, non-React state that drives tooltip behaviour:
|
||||
* - whether the tooltip is active / pinned
|
||||
* - where it should be positioned
|
||||
* - which series / data indexes are active
|
||||
*
|
||||
* This state lives outside of React so that uPlot hooks and DOM
|
||||
* event handlers can update it freely without causing re‑renders
|
||||
* on every tiny interaction. React is only updated when a render
|
||||
* is explicitly scheduled from the plugin.
|
||||
*/
|
||||
export interface TooltipControllerState {
|
||||
plot: uPlot | null;
|
||||
hoverActive: boolean;
|
||||
isAnySeriesActive: boolean;
|
||||
pinned: boolean;
|
||||
style: TooltipViewState['style'];
|
||||
horizontalOffset: number;
|
||||
verticalOffset: number;
|
||||
seriesIndexes: Array<number | null>;
|
||||
focusedSeriesIndex: number | null;
|
||||
cursorDrivenBySync: boolean;
|
||||
plotWithinViewport: boolean;
|
||||
windowWidth: number;
|
||||
windowHeight: number;
|
||||
pendingPinnedUpdate: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Context passed to uPlot hook handlers.
|
||||
*
|
||||
* It gives the handlers access to:
|
||||
* - the shared controller state
|
||||
* - layout / container refs
|
||||
* - the React `updateState` function
|
||||
* - render & dismiss helpers from the plugin
|
||||
*/
|
||||
export interface TooltipControllerContext {
|
||||
controller: TooltipControllerState;
|
||||
layoutRef: React.MutableRefObject<TooltipLayoutInfo | undefined>;
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
rafId: React.MutableRefObject<number | null>;
|
||||
updateState: (updates: Partial<TooltipViewState>) => void;
|
||||
renderRef: React.MutableRefObject<
|
||||
(args: TooltipRenderArgs) => React.ReactNode
|
||||
>;
|
||||
syncMode: DashboardCursorSync;
|
||||
syncKey: string;
|
||||
canPinTooltip: boolean;
|
||||
createTooltipContents: () => React.ReactNode;
|
||||
scheduleRender: (updatePinned?: boolean) => void;
|
||||
dismissTooltip: () => void;
|
||||
}
|
||||
159
frontend/src/lib/uPlotV2/plugins/TooltipPlugin/utils.ts
Normal file
159
frontend/src/lib/uPlotV2/plugins/TooltipPlugin/utils.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { TOOLTIP_OFFSET, TooltipLayoutInfo, TooltipViewState } from './types';
|
||||
|
||||
export function isPlotInViewport(
|
||||
rect: uPlot.BBox,
|
||||
windowWidth: number,
|
||||
windowHeight: number,
|
||||
): boolean {
|
||||
return (
|
||||
rect.top + rect.height <= windowHeight &&
|
||||
rect.top >= 0 &&
|
||||
rect.left >= 0 &&
|
||||
rect.left + rect.width <= windowWidth
|
||||
);
|
||||
}
|
||||
|
||||
export function calculateVerticalOffset(
|
||||
currentOffset: number,
|
||||
clientY: number,
|
||||
tooltipHeight: number,
|
||||
windowHeight: number,
|
||||
): number {
|
||||
const height = tooltipHeight + TOOLTIP_OFFSET;
|
||||
|
||||
if (currentOffset !== 0) {
|
||||
if (clientY + height < windowHeight || clientY - height < 0) {
|
||||
return 0;
|
||||
}
|
||||
if (currentOffset !== -height) {
|
||||
return -height;
|
||||
}
|
||||
return currentOffset;
|
||||
}
|
||||
|
||||
if (clientY + height > windowHeight && clientY - height >= 0) {
|
||||
return -height;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function calculateHorizontalOffset(
|
||||
currentOffset: number,
|
||||
clientX: number,
|
||||
tooltipWidth: number,
|
||||
windowWidth: number,
|
||||
): number {
|
||||
const width = tooltipWidth + TOOLTIP_OFFSET;
|
||||
|
||||
if (currentOffset !== 0) {
|
||||
if (clientX + width < windowWidth || clientX - width < 0) {
|
||||
return 0;
|
||||
}
|
||||
if (currentOffset !== -width) {
|
||||
return -width;
|
||||
}
|
||||
return currentOffset;
|
||||
}
|
||||
|
||||
if (clientX + width > windowWidth && clientX - width >= 0) {
|
||||
return -width;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function calculateTooltipOffset(
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
tooltipWidth: number,
|
||||
tooltipHeight: number,
|
||||
currentHorizontalOffset: number,
|
||||
currentVerticalOffset: number,
|
||||
windowWidth: number,
|
||||
windowHeight: number,
|
||||
): { horizontalOffset: number; verticalOffset: number } {
|
||||
return {
|
||||
horizontalOffset: calculateHorizontalOffset(
|
||||
currentHorizontalOffset,
|
||||
clientX,
|
||||
tooltipWidth,
|
||||
windowWidth,
|
||||
),
|
||||
verticalOffset: calculateVerticalOffset(
|
||||
currentVerticalOffset,
|
||||
clientY,
|
||||
tooltipHeight,
|
||||
windowHeight,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildTransform(
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
hOffset: number,
|
||||
vOffset: number,
|
||||
): string {
|
||||
const translateX =
|
||||
clientX + (hOffset === 0 ? TOOLTIP_OFFSET : -TOOLTIP_OFFSET);
|
||||
const translateY =
|
||||
clientY + (vOffset === 0 ? TOOLTIP_OFFSET : -TOOLTIP_OFFSET);
|
||||
const reflectX = hOffset === 0 ? '' : 'translateX(-100%)';
|
||||
const reflectY = vOffset === 0 ? '' : 'translateY(-100%)';
|
||||
|
||||
return `translateX(${translateX}px) ${reflectX} translateY(${translateY}px) ${reflectY}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* React view state for the tooltip.
|
||||
*
|
||||
* This is the minimal data needed to render:
|
||||
* - current position / CSS style
|
||||
* - whether the tooltip is visible or pinned
|
||||
* - the React node to show as contents
|
||||
* - the associated uPlot instance (for children)
|
||||
*
|
||||
* All interaction logic lives in the controller; that logic calls
|
||||
* `updateState` to push the latest snapshot into React.
|
||||
*/
|
||||
export function createInitialViewState(): TooltipViewState {
|
||||
return {
|
||||
style: { transform: '', pointerEvents: 'none' },
|
||||
isHovering: false,
|
||||
isPinned: false,
|
||||
contents: null,
|
||||
plot: null,
|
||||
dismiss: (): void => {},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates and wires a ResizeObserver that keeps track of the rendered
|
||||
* tooltip size. This is used by the controller to place the tooltip
|
||||
* on the correct side of the cursor and avoid clipping the viewport.
|
||||
*/
|
||||
export function createLayoutObserver(
|
||||
layoutRef: React.MutableRefObject<TooltipLayoutInfo | undefined>,
|
||||
): TooltipLayoutInfo {
|
||||
const layout: TooltipLayoutInfo = {
|
||||
width: 0,
|
||||
height: 0,
|
||||
observer: new ResizeObserver((entries) => {
|
||||
const current = layoutRef.current;
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (entry.borderBoxSize?.length) {
|
||||
current.width = entry.borderBoxSize[0].inlineSize;
|
||||
current.height = entry.borderBoxSize[0].blockSize;
|
||||
} else {
|
||||
current.width = entry.contentRect.width;
|
||||
current.height = entry.contentRect.height;
|
||||
}
|
||||
}
|
||||
}),
|
||||
};
|
||||
return layout;
|
||||
}
|
||||
11
frontend/src/lib/uPlotV2/utils/seriesVisibility.ts
Normal file
11
frontend/src/lib/uPlotV2/utils/seriesVisibility.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export function resolveSeriesVisibility(
|
||||
label: string,
|
||||
seriesShow: boolean | undefined | null,
|
||||
visibilityMap: Map<string, boolean> | null,
|
||||
isAnySeriesHidden: boolean,
|
||||
): boolean {
|
||||
if (isAnySeriesHidden) {
|
||||
return visibilityMap?.get(label) ?? false;
|
||||
}
|
||||
return seriesShow ?? true;
|
||||
}
|
||||
@@ -1,15 +1,17 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { Breadcrumb, Button, Divider, Empty } from 'antd';
|
||||
import { Breadcrumb, Button, Divider } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import classNames from 'classnames';
|
||||
import { Filters } from 'components/AlertDetailsFilters/Filters';
|
||||
import RouteTab from 'components/RouteTab';
|
||||
import Spinner from 'components/Spinner';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { CreateAlertProvider } from 'container/CreateAlertV2/context';
|
||||
import { getCreateAlertLocalStateFromAlertDef } from 'container/CreateAlertV2/utils';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import history from 'lib/history';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
import {
|
||||
@@ -18,6 +20,7 @@ import {
|
||||
} from 'types/api/alerts/alertTypesV2';
|
||||
|
||||
import AlertHeader from './AlertHeader/AlertHeader';
|
||||
import AlertNotFound from './AlertNotFound';
|
||||
import { useGetAlertRuleDetails, useRouteTabUtils } from './hooks';
|
||||
import { AlertDetailsStatusRendererProps } from './types';
|
||||
|
||||
@@ -77,7 +80,7 @@ BreadCrumbItem.defaultProps = {
|
||||
function AlertDetails(): JSX.Element {
|
||||
const { pathname } = useLocation();
|
||||
const { routes } = useRouteTabUtils();
|
||||
const { t } = useTranslation(['alerts']);
|
||||
const params = useUrlQuery();
|
||||
|
||||
const {
|
||||
isLoading,
|
||||
@@ -88,10 +91,27 @@ function AlertDetails(): JSX.Element {
|
||||
alertDetailsResponse,
|
||||
} = useGetAlertRuleDetails();
|
||||
|
||||
const isTestAlert = useMemo(() => {
|
||||
return params.get(QueryParams.isTestAlert) === 'true';
|
||||
}, [params]);
|
||||
|
||||
const getDocumentTitle = useMemo(() => {
|
||||
const alertTitle = alertDetailsResponse?.payload?.data?.alert;
|
||||
if (alertTitle) {
|
||||
return alertTitle;
|
||||
}
|
||||
if (isTestAlert) {
|
||||
return 'Test Alert';
|
||||
}
|
||||
if (isLoading) {
|
||||
return document.title;
|
||||
}
|
||||
return 'Alert Not Found';
|
||||
}, [alertDetailsResponse?.payload?.data?.alert, isTestAlert, isLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
const alertTitle = alertDetailsResponse?.payload?.data.alert;
|
||||
document.title = alertTitle || document.title;
|
||||
}, [alertDetailsResponse?.payload?.data.alert, isRefetching]);
|
||||
document.title = getDocumentTitle;
|
||||
}, [getDocumentTitle]);
|
||||
|
||||
const alertRuleDetails = useMemo(
|
||||
() => alertDetailsResponse?.payload?.data as PostableAlertRuleV2 | undefined,
|
||||
@@ -106,13 +126,10 @@ function AlertDetails(): JSX.Element {
|
||||
if (
|
||||
isError ||
|
||||
!isValidRuleId ||
|
||||
(alertDetailsResponse && alertDetailsResponse.statusCode !== 200)
|
||||
(alertDetailsResponse && alertDetailsResponse.statusCode !== 200) ||
|
||||
(!isLoading && !alertRuleDetails)
|
||||
) {
|
||||
return (
|
||||
<div className="alert-empty-card">
|
||||
<Empty description={t('alert_rule_not_found')} />
|
||||
</div>
|
||||
);
|
||||
return <AlertNotFound isTestAlert={isTestAlert} />;
|
||||
}
|
||||
|
||||
const handleTabChange = (route: string): void => {
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
.alert-not-found {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 60vh;
|
||||
width: 500px;
|
||||
gap: 24px;
|
||||
margin: 0 auto;
|
||||
|
||||
.description {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
.not-found-img {
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
}
|
||||
|
||||
.not-found-text {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.reasons {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
|
||||
.reason {
|
||||
display: flex;
|
||||
padding: 12px;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
border-radius: 4px;
|
||||
background: rgba(171, 189, 255, 0.04);
|
||||
|
||||
.construction-img,
|
||||
.broom-img {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
.text {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.none-of-above {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
.text {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.action-btns {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
width: 160px;
|
||||
padding: 4px 8px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
background: var(--bg-slate-500);
|
||||
box-shadow: none;
|
||||
|
||||
.ant-btn-icon {
|
||||
margin-inline-end: 0px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.alert-not-found {
|
||||
.description {
|
||||
.not-found-text {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
|
||||
.reasons {
|
||||
.reason {
|
||||
background: var(--bg-vanilla-300);
|
||||
.text {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.none-of-above {
|
||||
.text {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
|
||||
.action-btns {
|
||||
.action-btn {
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
background: var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { Button, Typography } from 'antd';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { LifeBuoy, List } from 'lucide-react';
|
||||
import { handleContactSupport } from 'pages/Integrations/utils';
|
||||
|
||||
import './AlertNotFound.styles.scss';
|
||||
|
||||
interface AlertNotFoundProps {
|
||||
isTestAlert: boolean;
|
||||
}
|
||||
|
||||
function AlertNotFound({ isTestAlert }: AlertNotFoundProps): JSX.Element {
|
||||
const { isCloudUser: isCloudUserVal } = useGetTenantLicense();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
|
||||
const checkAllRulesHandler = (): void => {
|
||||
safeNavigate(ROUTES.LIST_ALL_ALERT);
|
||||
};
|
||||
|
||||
const contactSupportHandler = (): void => {
|
||||
handleContactSupport(isCloudUserVal);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="alert-not-found">
|
||||
<section className="description">
|
||||
<img src="/Icons/no-data.svg" alt="no-data" className="not-found-img" />
|
||||
<Typography.Text className="not-found-text">
|
||||
Uh-oh! We couldn't find the given alert rule.
|
||||
</Typography.Text>
|
||||
<Typography.Text className="not-found-text">
|
||||
{isTestAlert
|
||||
? 'This can happen in the following scenario -'
|
||||
: 'This can happen in either of the following scenarios -'}
|
||||
</Typography.Text>
|
||||
</section>
|
||||
<section className="reasons">
|
||||
{!isTestAlert && (
|
||||
<>
|
||||
<div className="reason">
|
||||
<img
|
||||
src="/Icons/construction.svg"
|
||||
alt="no-data"
|
||||
className="construction-img"
|
||||
/>
|
||||
<Typography.Text className="text">
|
||||
The alert rule link is incorrect, please verify it once.
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className="reason">
|
||||
<img src="/Icons/broom.svg" alt="no-data" className="broom-img" />
|
||||
<Typography.Text className="text">
|
||||
The alert rule you're trying to check has been deleted.
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{isTestAlert && (
|
||||
<div className="reason">
|
||||
<img src="/Icons/broom.svg" alt="no-data" className="broom-img" />
|
||||
<Typography.Text className="text">
|
||||
You clicked on the Alert notification link received when testing a new
|
||||
Alert rule. Once the alert rule is saved, future notifications will link
|
||||
to actual alerts.
|
||||
</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
<section className="none-of-above">
|
||||
<Typography.Text className="text">
|
||||
If you feel the issue is none of the above, please contact support.
|
||||
</Typography.Text>
|
||||
<div className="action-btns">
|
||||
<Button
|
||||
className="action-btn"
|
||||
icon={<List size={14} />}
|
||||
onClick={checkAllRulesHandler}
|
||||
>
|
||||
Check all rules
|
||||
</Button>
|
||||
<Button
|
||||
className="action-btn"
|
||||
icon={<LifeBuoy size={14} />}
|
||||
onClick={contactSupportHandler}
|
||||
>
|
||||
Contact Support
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AlertNotFound;
|
||||
3
frontend/src/pages/AlertDetails/AlertNotFound/index.ts
Normal file
3
frontend/src/pages/AlertDetails/AlertNotFound/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import AlertNotFound from './AlertNotFound';
|
||||
|
||||
export default AlertNotFound;
|
||||
@@ -0,0 +1,92 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import ROUTES from 'constants/routes';
|
||||
import * as useGetTenantLicense from 'hooks/useGetTenantLicense';
|
||||
import * as useSafeNavigate from 'hooks/useSafeNavigate';
|
||||
import { userEvent } from 'tests/test-utils';
|
||||
|
||||
import AlertNotFound from '../AlertNotFound';
|
||||
|
||||
jest.mock('lib/history', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
push: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import history from 'lib/history';
|
||||
|
||||
const mockSafeNavigate = jest.fn();
|
||||
const useGetTenantLicenseSpy = jest.spyOn(
|
||||
useGetTenantLicense,
|
||||
'useGetTenantLicense',
|
||||
);
|
||||
const useSafeNavigateSpy = jest.spyOn(useSafeNavigate, 'useSafeNavigate');
|
||||
|
||||
describe('AlertNotFound', () => {
|
||||
beforeEach(() => {
|
||||
mockSafeNavigate.mockClear();
|
||||
window.open = jest.fn();
|
||||
useGetTenantLicenseSpy.mockReturnValue({
|
||||
isCloudUser: false,
|
||||
} as ReturnType<typeof useGetTenantLicense.useGetTenantLicense>);
|
||||
useSafeNavigateSpy.mockReturnValue({
|
||||
safeNavigate: mockSafeNavigate,
|
||||
});
|
||||
});
|
||||
|
||||
it('should render the correct error message for test alerts', () => {
|
||||
render(<AlertNotFound isTestAlert />);
|
||||
expect(
|
||||
screen.getByText("Uh-oh! We couldn't find the given alert rule."),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('This can happen in the following scenario -'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
'You clicked on the Alert notification link received when testing a new Alert rule. Once the alert rule is saved, future notifications will link to actual alerts.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render the correct error message for non-existing alerts', () => {
|
||||
render(<AlertNotFound isTestAlert={false} />);
|
||||
expect(
|
||||
screen.getByText("Uh-oh! We couldn't find the given alert rule."),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('This can happen in either of the following scenarios -'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('The alert rule link is incorrect, please verify it once.'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("The alert rule you're trying to check has been deleted."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should navigate to the list all alerts page when the check all rules button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AlertNotFound isTestAlert={false} />);
|
||||
await user.click(screen.getByText('Check all rules'));
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith(ROUTES.LIST_ALL_ALERT);
|
||||
});
|
||||
|
||||
it('should navigate to the correct support page for cloud users when button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
useGetTenantLicenseSpy.mockReturnValueOnce({
|
||||
isCloudUser: true,
|
||||
} as ReturnType<typeof useGetTenantLicense.useGetTenantLicense>);
|
||||
|
||||
render(<AlertNotFound isTestAlert={false} />);
|
||||
await user.click(screen.getByText('Contact Support'));
|
||||
expect(history.push).toHaveBeenCalledWith('/support');
|
||||
});
|
||||
|
||||
it('should navigate to the support page for self-hosted users when the contact support button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AlertNotFound isTestAlert={false} />);
|
||||
await user.click(screen.getByText('Contact Support'));
|
||||
expect(window.open).toHaveBeenCalledWith('https://signoz.io/slack', '_blank');
|
||||
});
|
||||
});
|
||||
@@ -33,6 +33,19 @@
|
||||
height: calc(100vh - 48px);
|
||||
border-right: 1px solid var(--Slate-500, #161922);
|
||||
background: var(--Ink-500, #0b0c0e);
|
||||
margin-top: 4px;
|
||||
|
||||
.nav-item {
|
||||
.nav-item-data {
|
||||
margin: 0px 8px 0px 4px;
|
||||
}
|
||||
|
||||
&.active {
|
||||
.nav-item-data .nav-item-label {
|
||||
color: var(--bg-vanilla-100, #fff);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.settings-page-content {
|
||||
@@ -81,6 +94,14 @@
|
||||
.settings-page-sidenav {
|
||||
border-right: 1px solid var(--bg-vanilla-300);
|
||||
background: var(--bg-vanilla-100);
|
||||
|
||||
.nav-item {
|
||||
&.active {
|
||||
.nav-item-data .nav-item-label {
|
||||
color: var(--bg-ink-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.settings-page-content {
|
||||
|
||||
@@ -13,7 +13,7 @@ import { SidebarItem } from 'container/SideNav/sideNav.types';
|
||||
import useComponentPermission from 'hooks/useComponentPermission';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import history from 'lib/history';
|
||||
import { Wrench } from 'lucide-react';
|
||||
import { Cog } from 'lucide-react';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
|
||||
@@ -236,7 +236,7 @@ function SettingsPage(): JSX.Element {
|
||||
className="settings-page-header-title"
|
||||
data-testid="settings-page-title"
|
||||
>
|
||||
<Wrench size={16} />
|
||||
<Cog size={16} />
|
||||
Settings
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
letter-spacing: 0;
|
||||
color: var(--levels-l1-foreground, #eceef2);
|
||||
color: var(--l1-foreground);
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.065px;
|
||||
color: var(--semantic-secondary-foreground, #adb4c2);
|
||||
color: var(--l2-foreground);
|
||||
max-width: 360px;
|
||||
margin: 0 !important;
|
||||
text-align: center;
|
||||
@@ -52,8 +52,8 @@
|
||||
|
||||
.signup-form-container {
|
||||
width: 100%;
|
||||
background: var(--semantic-secondary-background, #121317);
|
||||
border: 1px solid var(--semantic-secondary-border, #23262e);
|
||||
background: var(--bg-neutral-dark-950);
|
||||
border: 1px solid var(--l3-background);
|
||||
border-radius: 4px;
|
||||
padding: 24px;
|
||||
|
||||
@@ -81,8 +81,8 @@
|
||||
&.ant-input-affix-wrapper {
|
||||
height: 32px;
|
||||
border-radius: 2px;
|
||||
background: var(--levels-l3-background, #23262e);
|
||||
border-color: var(--levels-l3-border, #2c303a);
|
||||
background: var(--l3-background);
|
||||
border-color: var(--l3-border);
|
||||
}
|
||||
|
||||
&.ant-input-affix-wrapper {
|
||||
@@ -95,8 +95,8 @@
|
||||
|
||||
.signup-form-input {
|
||||
height: 32px;
|
||||
background: var(--levels-l3-background, #23262e);
|
||||
border: 1px solid var(--levels-l3-border, #2c303a);
|
||||
background: var(--l3-background);
|
||||
border: 1px solid var(--l3-border);
|
||||
border-radius: 2px;
|
||||
padding: 6px 8px;
|
||||
font-family: Inter, sans-serif;
|
||||
@@ -104,18 +104,18 @@
|
||||
font-weight: 400;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.065px;
|
||||
color: var(--levels-l1-foreground, #eceef2);
|
||||
color: var(--l1-foreground);
|
||||
|
||||
&::placeholder {
|
||||
color: var(--levels-l3-foreground, #747b8b);
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: var(--levels-l3-border, #2c303a);
|
||||
border-color: var(--l3-border);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--semantic-primary-background, #4e74f8);
|
||||
border-color: var(--primary);
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
@@ -134,7 +134,7 @@
|
||||
}
|
||||
|
||||
.signup-info-message {
|
||||
color: var(--semantic-secondary-foreground);
|
||||
color: var(--l2-foreground);
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
line-height: 1.45;
|
||||
@@ -178,51 +178,51 @@
|
||||
}
|
||||
|
||||
.signup-header-subtitle {
|
||||
color: var(--text-neutral-light-200, #80828d);
|
||||
color: var(--text-neutral-light-200);
|
||||
}
|
||||
}
|
||||
|
||||
.signup-form {
|
||||
.signup-form-container {
|
||||
background: var(--bg-base-white, #ffffff);
|
||||
border: 1px solid var(--bg-vanilla-300, #e9e9e9);
|
||||
background: var(--bg-base-white);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
|
||||
.signup-form-input,
|
||||
.signup-antd-input {
|
||||
&.ant-input,
|
||||
&.ant-input-password,
|
||||
&.ant-input-affix-wrapper {
|
||||
background: var(--bg-vanilla-200, #f5f5f5);
|
||||
border-color: var(--bg-vanilla-300, #e9e9e9);
|
||||
background: var(--bg-vanilla-200);
|
||||
border-color: var(--bg-vanilla-300);
|
||||
color: var(--text-ink-500);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--text-neutral-light-200, #80828d);
|
||||
color: var(--text-neutral-light-200);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--semantic-primary-background, #4e74f8);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
.signup-form-input {
|
||||
background: var(--bg-vanilla-200, #f5f5f5);
|
||||
border-color: var(--bg-vanilla-300, #e9e9e9);
|
||||
background: var(--bg-vanilla-200);
|
||||
border-color: var(--bg-vanilla-300);
|
||||
color: var(--text-ink-500);
|
||||
|
||||
&::placeholder {
|
||||
color: var(--text-neutral-light-200, #80828d);
|
||||
color: var(--text-neutral-light-200);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--semantic-primary-background, #4e74f8);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.signup-info-message {
|
||||
color: var(--text-neutral-light-200, #80828d);
|
||||
color: var(--text-neutral-light-200);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user