mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-25 01:20:32 +01:00
Compare commits
21 Commits
issue_1170
...
feat/trace
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07cdb2d4ae | ||
|
|
9318050f74 | ||
|
|
626b6c3153 | ||
|
|
8e9f533b58 | ||
|
|
3428d3a2e2 | ||
|
|
521f43a37a | ||
|
|
a90e706038 | ||
|
|
8a4b234ee7 | ||
|
|
bddc61d22b | ||
|
|
604b5e2a4a | ||
|
|
e2d840345b | ||
|
|
e6071a7cb8 | ||
|
|
d4b3a34d10 | ||
|
|
972cd00c68 | ||
|
|
9aff84c276 | ||
|
|
4dda1e0ab5 | ||
|
|
749943abe4 | ||
|
|
4f51ee37ba | ||
|
|
d5617657b5 | ||
|
|
5600576722 | ||
|
|
f84b818552 |
@@ -141,10 +141,6 @@ querier:
|
||||
flux_interval: 5m
|
||||
# The maximum number of concurrent queries for missing ranges.
|
||||
max_concurrent_queries: 4
|
||||
# When filtering logs by trace_id, clamp the query window to the trace time
|
||||
# range with padding to include slightly delayed log exports. Logs only; set
|
||||
# to 0 to disable.
|
||||
log_trace_id_window_padding: 5m
|
||||
|
||||
##################### TelemetryStore #####################
|
||||
telemetrystore:
|
||||
|
||||
@@ -647,8 +647,12 @@ components:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
transactionGroups:
|
||||
$ref: '#/components/schemas/AuthtypesTransactionGroups'
|
||||
required:
|
||||
- name
|
||||
- description
|
||||
- transactionGroups
|
||||
type: object
|
||||
AuthtypesPostableRotateToken:
|
||||
properties:
|
||||
@@ -703,6 +707,34 @@ components:
|
||||
useRoleAttribute:
|
||||
type: boolean
|
||||
type: object
|
||||
AuthtypesRoleWithTransactionGroups:
|
||||
properties:
|
||||
createdAt:
|
||||
format: date-time
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
orgId:
|
||||
type: string
|
||||
transactionGroups:
|
||||
$ref: '#/components/schemas/AuthtypesTransactionGroups'
|
||||
type:
|
||||
type: string
|
||||
updatedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
- name
|
||||
- description
|
||||
- type
|
||||
- orgId
|
||||
- transactionGroups
|
||||
type: object
|
||||
AuthtypesSamlConfig:
|
||||
properties:
|
||||
attributeMapping:
|
||||
@@ -736,11 +768,35 @@ components:
|
||||
- relation
|
||||
- object
|
||||
type: object
|
||||
AuthtypesTransactionGroup:
|
||||
properties:
|
||||
objectGroup:
|
||||
$ref: '#/components/schemas/CoretypesObjectGroup'
|
||||
relation:
|
||||
$ref: '#/components/schemas/AuthtypesRelation'
|
||||
required:
|
||||
- relation
|
||||
- objectGroup
|
||||
type: object
|
||||
AuthtypesTransactionGroups:
|
||||
items:
|
||||
$ref: '#/components/schemas/AuthtypesTransactionGroup'
|
||||
type: array
|
||||
AuthtypesUpdatableAuthDomain:
|
||||
properties:
|
||||
config:
|
||||
$ref: '#/components/schemas/AuthtypesAuthDomainConfig'
|
||||
type: object
|
||||
AuthtypesUpdatableRole:
|
||||
properties:
|
||||
description:
|
||||
type: string
|
||||
transactionGroups:
|
||||
$ref: '#/components/schemas/AuthtypesTransactionGroups'
|
||||
required:
|
||||
- description
|
||||
- transactionGroups
|
||||
type: object
|
||||
AuthtypesUserRole:
|
||||
properties:
|
||||
createdAt:
|
||||
@@ -10253,6 +10309,15 @@ paths:
|
||||
name: limit
|
||||
schema:
|
||||
type: integer
|
||||
- in: query
|
||||
name: q
|
||||
schema:
|
||||
type: string
|
||||
- in: query
|
||||
name: isOverride
|
||||
schema:
|
||||
nullable: true
|
||||
type: boolean
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
@@ -11058,7 +11123,7 @@ paths:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/AuthtypesRole'
|
||||
$ref: '#/components/schemas/AuthtypesRoleWithTransactionGroups'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
@@ -11093,7 +11158,7 @@ paths:
|
||||
tags:
|
||||
- role
|
||||
patch:
|
||||
deprecated: false
|
||||
deprecated: true
|
||||
description: This endpoint patches a role
|
||||
operationId: PatchRole
|
||||
parameters:
|
||||
@@ -11154,6 +11219,68 @@ paths:
|
||||
summary: Patch role
|
||||
tags:
|
||||
- role
|
||||
put:
|
||||
deprecated: false
|
||||
description: This endpoint updates a role
|
||||
operationId: UpdateRole
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AuthtypesUpdatableRole'
|
||||
responses:
|
||||
"204":
|
||||
description: No Content
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"451":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unavailable For Legal Reasons
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
"501":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Implemented
|
||||
security:
|
||||
- api_key:
|
||||
- role:update
|
||||
- tokenizer:
|
||||
- role:update
|
||||
summary: Update role
|
||||
tags:
|
||||
- role
|
||||
/api/v1/roles/{id}/relations/{relation}/objects:
|
||||
get:
|
||||
deprecated: false
|
||||
@@ -11233,7 +11360,7 @@ paths:
|
||||
tags:
|
||||
- role
|
||||
patch:
|
||||
deprecated: false
|
||||
deprecated: true
|
||||
description: Patches the objects connected to the specified role via a given
|
||||
relation type
|
||||
operationId: PatchObjects
|
||||
|
||||
@@ -179,13 +179,36 @@ func (provider *provider) CreateManagedUserRoleTransactions(ctx context.Context,
|
||||
return provider.Write(ctx, tuples, nil)
|
||||
}
|
||||
|
||||
func (provider *provider) Create(ctx context.Context, orgID valuer.UUID, role *authtypes.Role) error {
|
||||
func (provider *provider) Create(ctx context.Context, orgID valuer.UUID, role *authtypes.RoleWithTransactionGroups) error {
|
||||
_, err := provider.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())
|
||||
}
|
||||
|
||||
return provider.store.Create(ctx, role)
|
||||
existingRole, err := provider.GetByOrgIDAndName(ctx, orgID, role.Name)
|
||||
if err != nil && !errors.Asc(err, authtypes.ErrCodeRoleNotFound) {
|
||||
return err
|
||||
}
|
||||
|
||||
if existingRole != nil {
|
||||
return errors.Newf(errors.TypeAlreadyExists, authtypes.ErrCodeRoleAlreadyExists, "role with name: %s already exists", existingRole.Name)
|
||||
}
|
||||
|
||||
tuples, err := authtypes.NewTuplesFromTransactionGroups(role.Name, orgID, role.TransactionGroups)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = provider.Write(ctx, tuples, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := provider.store.Create(ctx, role.Role); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (provider *provider) GetOrCreate(ctx context.Context, orgID valuer.UUID, role *authtypes.Role) (*authtypes.Role, error) {
|
||||
@@ -213,6 +236,26 @@ func (provider *provider) GetOrCreate(ctx context.Context, orgID valuer.UUID, ro
|
||||
return role, nil
|
||||
}
|
||||
|
||||
func (provider *provider) GetWithTransactionGroups(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*authtypes.RoleWithTransactionGroups, error) {
|
||||
_, err := provider.licensing.GetActive(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
role, err := provider.store.Get(ctx, orgID, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tuples, err := provider.readAllTuplesForRole(ctx, role.Name, orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
transactionGroups := authtypes.MustNewTransactionGroupsFromTuples(tuples)
|
||||
return authtypes.MakeRoleWithTransactionGroups(role, transactionGroups), nil
|
||||
}
|
||||
|
||||
func (provider *provider) GetObjects(ctx context.Context, orgID valuer.UUID, id valuer.UUID, relation authtypes.Relation) ([]*coretypes.Object, error) {
|
||||
_, err := provider.licensing.GetActive(ctx, orgID)
|
||||
if err != nil {
|
||||
@@ -247,6 +290,36 @@ func (provider *provider) GetObjects(ctx context.Context, orgID valuer.UUID, id
|
||||
return objects, nil
|
||||
}
|
||||
|
||||
func (provider *provider) Update(ctx context.Context, orgID valuer.UUID, updatedRole *authtypes.RoleWithTransactionGroups) error {
|
||||
_, err := provider.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())
|
||||
}
|
||||
|
||||
existingRole, err := provider.GetWithTransactionGroups(ctx, orgID, updatedRole.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
additions, deletions := existingRole.TransactionGroups.Diff(updatedRole.TransactionGroups)
|
||||
additionTuples, err := authtypes.NewTuplesFromTransactionGroups(existingRole.Name, orgID, additions)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
deletionTuples, err := authtypes.NewTuplesFromTransactionGroups(existingRole.Name, orgID, deletions)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = provider.Write(ctx, additionTuples, deletionTuples)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return provider.store.Update(ctx, orgID, updatedRole.Role)
|
||||
}
|
||||
|
||||
func (provider *provider) Patch(ctx context.Context, orgID valuer.UUID, role *authtypes.Role) error {
|
||||
_, err := provider.licensing.GetActive(ctx, orgID)
|
||||
if err != nil {
|
||||
@@ -286,7 +359,7 @@ func (provider *provider) Delete(ctx context.Context, orgID valuer.UUID, id valu
|
||||
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
role, err := provider.store.Get(ctx, orgID, id)
|
||||
role, err := provider.GetWithTransactionGroups(ctx, orgID, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -302,7 +375,12 @@ func (provider *provider) Delete(ctx context.Context, orgID valuer.UUID, id valu
|
||||
}
|
||||
}
|
||||
|
||||
if err := provider.deleteTuples(ctx, role.Name, orgID); err != nil {
|
||||
tuples, err := authtypes.NewTuplesFromTransactionGroups(role.Name, orgID, role.TransactionGroups)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := provider.Write(ctx, nil, tuples); err != nil {
|
||||
return errors.WithAdditionalf(err, "failed to delete tuples for the role: %s", role.Name)
|
||||
}
|
||||
|
||||
@@ -361,7 +439,7 @@ func (provider *provider) getManagedRoleTransactionTuples(orgID valuer.UUID) []*
|
||||
return tuples
|
||||
}
|
||||
|
||||
func (provider *provider) deleteTuples(ctx context.Context, roleName string, orgID valuer.UUID) error {
|
||||
func (provider *provider) readAllTuplesForRole(ctx context.Context, roleName string, orgID valuer.UUID) ([]*openfgav1.TupleKey, error) {
|
||||
subject := authtypes.MustNewSubject(coretypes.NewResourceRole(), roleName, orgID, &coretypes.VerbAssignee)
|
||||
|
||||
tuples := make([]*openfgav1.TupleKey, 0)
|
||||
@@ -371,26 +449,10 @@ func (provider *provider) deleteTuples(ctx context.Context, roleName string, org
|
||||
Object: objectType.StringValue() + ":",
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
tuples = append(tuples, typeTuples...)
|
||||
}
|
||||
|
||||
if len(tuples) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
for idx := 0; idx < len(tuples); idx += provider.config.OpenFGA.MaxTuplesPerWrite {
|
||||
end := idx + provider.config.OpenFGA.MaxTuplesPerWrite
|
||||
if end > len(tuples) {
|
||||
end = len(tuples)
|
||||
}
|
||||
|
||||
err := provider.Write(ctx, nil, tuples[idx:end])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
return tuples, nil
|
||||
}
|
||||
|
||||
@@ -57,13 +57,6 @@ export const TraceFilter = Loadable(
|
||||
() => import(/* webpackChunkName: "Trace Filter Page" */ 'pages/Trace'),
|
||||
);
|
||||
|
||||
export const TraceDetail = Loadable(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "TraceDetail Page" */ 'pages/TraceDetailV2/index'
|
||||
),
|
||||
);
|
||||
|
||||
export const TraceDetailOldRedirect = Loadable(
|
||||
() =>
|
||||
import(
|
||||
|
||||
@@ -20,6 +20,7 @@ import type {
|
||||
import type {
|
||||
AuthtypesPatchableRoleDTO,
|
||||
AuthtypesPostableRoleDTO,
|
||||
AuthtypesUpdatableRoleDTO,
|
||||
CoretypesPatchableObjectsDTO,
|
||||
CreateRole201,
|
||||
DeleteRolePathParameters,
|
||||
@@ -31,6 +32,7 @@ import type {
|
||||
PatchObjectsPathParameters,
|
||||
PatchRolePathParameters,
|
||||
RenderErrorResponseDTO,
|
||||
UpdateRolePathParameters,
|
||||
} from '../sigNoz.schemas';
|
||||
|
||||
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
|
||||
@@ -365,6 +367,7 @@ export const invalidateGetRole = async (
|
||||
|
||||
/**
|
||||
* This endpoint patches a role
|
||||
* @deprecated
|
||||
* @summary Patch role
|
||||
*/
|
||||
export const patchRole = (
|
||||
@@ -436,6 +439,7 @@ export type PatchRoleMutationBody =
|
||||
export type PatchRoleMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @summary Patch role
|
||||
*/
|
||||
export const usePatchRole = <
|
||||
@@ -462,6 +466,105 @@ export const usePatchRole = <
|
||||
> => {
|
||||
return useMutation(getPatchRoleMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint updates a role
|
||||
* @summary Update role
|
||||
*/
|
||||
export const updateRole = (
|
||||
{ id }: UpdateRolePathParameters,
|
||||
authtypesUpdatableRoleDTO?: BodyType<AuthtypesUpdatableRoleDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v1/roles/${id}`,
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: authtypesUpdatableRoleDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getUpdateRoleMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateRole>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateRolePathParameters;
|
||||
data?: BodyType<AuthtypesUpdatableRoleDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateRole>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateRolePathParameters;
|
||||
data?: BodyType<AuthtypesUpdatableRoleDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['updateRole'];
|
||||
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 updateRole>>,
|
||||
{
|
||||
pathParams: UpdateRolePathParameters;
|
||||
data?: BodyType<AuthtypesUpdatableRoleDTO>;
|
||||
}
|
||||
> = (props) => {
|
||||
const { pathParams, data } = props ?? {};
|
||||
|
||||
return updateRole(pathParams, data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type UpdateRoleMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof updateRole>>
|
||||
>;
|
||||
export type UpdateRoleMutationBody =
|
||||
| BodyType<AuthtypesUpdatableRoleDTO>
|
||||
| undefined;
|
||||
export type UpdateRoleMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Update role
|
||||
*/
|
||||
export const useUpdateRole = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateRole>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateRolePathParameters;
|
||||
data?: BodyType<AuthtypesUpdatableRoleDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof updateRole>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateRolePathParameters;
|
||||
data?: BodyType<AuthtypesUpdatableRoleDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getUpdateRoleMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Gets all objects connected to the specified role via a given relation type
|
||||
* @summary Get objects for a role by relation
|
||||
@@ -565,6 +668,7 @@ export const invalidateGetObjects = async (
|
||||
|
||||
/**
|
||||
* Patches the objects connected to the specified role via a given relation type
|
||||
* @deprecated
|
||||
* @summary Patch objects for a role by relation
|
||||
*/
|
||||
export const patchObjects = (
|
||||
@@ -636,6 +740,7 @@ export type PatchObjectsMutationBody =
|
||||
export type PatchObjectsMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @summary Patch objects for a role by relation
|
||||
*/
|
||||
export const usePatchObjects = <
|
||||
|
||||
@@ -2224,15 +2224,31 @@ export interface AuthtypesPostableEmailPasswordSessionDTO {
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export interface CoretypesObjectGroupDTO {
|
||||
resource: CoretypesResourceRefDTO;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
selectors: string[];
|
||||
}
|
||||
|
||||
export interface AuthtypesTransactionGroupDTO {
|
||||
objectGroup: CoretypesObjectGroupDTO;
|
||||
relation: AuthtypesRelationDTO;
|
||||
}
|
||||
|
||||
export type AuthtypesTransactionGroupsDTO = AuthtypesTransactionGroupDTO[];
|
||||
|
||||
export interface AuthtypesPostableRoleDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
description?: string;
|
||||
description: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
transactionGroups: AuthtypesTransactionGroupsDTO;
|
||||
}
|
||||
|
||||
export interface AuthtypesPostableRotateTokenDTO {
|
||||
@@ -2275,6 +2291,40 @@ export interface AuthtypesRoleDTO {
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface AuthtypesRoleWithTransactionGroupsDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
description: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
orgId: string;
|
||||
transactionGroups: AuthtypesTransactionGroupsDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
type: string;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface AuthtypesSessionContextDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
@@ -2295,6 +2345,14 @@ export interface AuthtypesUpdatableAuthDomainDTO {
|
||||
config?: AuthtypesAuthDomainConfigDTO;
|
||||
}
|
||||
|
||||
export interface AuthtypesUpdatableRoleDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
description: string;
|
||||
transactionGroups: AuthtypesTransactionGroupsDTO;
|
||||
}
|
||||
|
||||
export interface AuthtypesUserRoleDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -3065,14 +3123,6 @@ export interface CommonJSONRefDTO {
|
||||
$ref?: string;
|
||||
}
|
||||
|
||||
export interface CoretypesObjectGroupDTO {
|
||||
resource: CoretypesResourceRefDTO;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
selectors: string[];
|
||||
}
|
||||
|
||||
export interface CoretypesPatchableObjectsDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
@@ -9450,6 +9500,16 @@ export type ListLLMPricingRulesParams = {
|
||||
* @description undefined
|
||||
*/
|
||||
limit?: number;
|
||||
/**
|
||||
* @type string
|
||||
* @description undefined
|
||||
*/
|
||||
q?: string;
|
||||
/**
|
||||
* @type boolean,null
|
||||
* @description undefined
|
||||
*/
|
||||
isOverride?: boolean | null;
|
||||
};
|
||||
|
||||
export type ListLLMPricingRules200 = {
|
||||
@@ -9559,7 +9619,7 @@ export type GetRolePathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type GetRole200 = {
|
||||
data: AuthtypesRoleDTO;
|
||||
data: AuthtypesRoleWithTransactionGroupsDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -9569,6 +9629,9 @@ export type GetRole200 = {
|
||||
export type PatchRolePathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type UpdateRolePathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type GetObjectsPathParameters = {
|
||||
id: string;
|
||||
relation: string;
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import { ApiV2Instance as axios } from 'api';
|
||||
import { omit } from 'lodash-es';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import {
|
||||
GetTraceV2PayloadProps,
|
||||
GetTraceV2SuccessResponse,
|
||||
} from 'types/api/trace/getTraceV2';
|
||||
|
||||
const getTraceV2 = async (
|
||||
props: GetTraceV2PayloadProps,
|
||||
): Promise<SuccessResponse<GetTraceV2SuccessResponse> | ErrorResponse> => {
|
||||
let uncollapsedSpans = [...props.uncollapsedSpans];
|
||||
if (!props.isSelectedSpanIDUnCollapsed) {
|
||||
uncollapsedSpans = uncollapsedSpans.filter(
|
||||
(node) => node !== props.selectedSpanId,
|
||||
);
|
||||
}
|
||||
const postData: GetTraceV2PayloadProps = {
|
||||
...props,
|
||||
uncollapsedSpans,
|
||||
};
|
||||
const response = await axios.post<GetTraceV2SuccessResponse>(
|
||||
`/traces/waterfall/${props.traceId}`,
|
||||
omit(postData, 'traceId'),
|
||||
);
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: 'Success',
|
||||
payload: response.data,
|
||||
};
|
||||
};
|
||||
|
||||
export default getTraceV2;
|
||||
@@ -41,6 +41,7 @@ const getTraceV4 = async (
|
||||
> & { spans: WireSpan[] | null };
|
||||
|
||||
// Derive 'service.name' from resource for convenience — only derived field
|
||||
// todo(tech-debt): to remove use of this and to directly use service.name from resources.
|
||||
const spans: SpanV3[] = (rawPayload.spans || []).map((span) => ({
|
||||
...span,
|
||||
'service.name': span.resource?.['service.name'] || '',
|
||||
|
||||
@@ -274,4 +274,110 @@ describe('convertV5ResponseToLegacy', () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('clickhouse_sql scalar keeps each value column distinct (regression: all-"A" collapse)', () => {
|
||||
const scalar: ScalarData = {
|
||||
columns: [
|
||||
{
|
||||
name: 'service.name',
|
||||
queryName: 'A',
|
||||
aggregationIndex: 0,
|
||||
columnType: 'group',
|
||||
} as unknown as ScalarData['columns'][number],
|
||||
{
|
||||
name: 'current_availability',
|
||||
queryName: 'A',
|
||||
aggregationIndex: 0,
|
||||
columnType: 'aggregation',
|
||||
} as unknown as ScalarData['columns'][number],
|
||||
{
|
||||
name: 'error_budget_remaining',
|
||||
queryName: 'A',
|
||||
aggregationIndex: 1,
|
||||
columnType: 'aggregation',
|
||||
} as unknown as ScalarData['columns'][number],
|
||||
{
|
||||
name: 'budget_status',
|
||||
queryName: 'A',
|
||||
aggregationIndex: 2,
|
||||
columnType: 'group',
|
||||
} as unknown as ScalarData['columns'][number],
|
||||
{
|
||||
name: 'total_requests',
|
||||
queryName: 'A',
|
||||
aggregationIndex: 4,
|
||||
columnType: 'aggregation',
|
||||
} as unknown as ScalarData['columns'][number],
|
||||
],
|
||||
data: [['kuja-api_gateway-service', 99.985, 0.985, 'Healthy ✅', 2181216]],
|
||||
};
|
||||
|
||||
const v5Data: QueryRangeResponseV5 = {
|
||||
type: 'scalar',
|
||||
data: { results: [scalar] },
|
||||
meta: { rowsScanned: 0, bytesScanned: 0, durationMs: 0, stepIntervals: {} },
|
||||
};
|
||||
|
||||
// A clickhouse_sql envelope contributes no aggregation metadata.
|
||||
const params = makeBaseParams('scalar', [
|
||||
{
|
||||
type: 'clickhouse_sql',
|
||||
spec: {
|
||||
name: 'A',
|
||||
query: 'SELECT ...',
|
||||
disabled: false,
|
||||
},
|
||||
} as unknown as QueryRangeRequestV5['compositeQuery']['queries'][number],
|
||||
]);
|
||||
|
||||
const input: SuccessResponse<MetricRangePayloadV5, QueryRangeRequestV5> =
|
||||
makeBaseSuccess({ data: v5Data }, params);
|
||||
// formatForWeb=true is the table-panel path.
|
||||
const result = convertV5ResponseToLegacy(input, { A: '' }, true);
|
||||
|
||||
const [tableEntry] = result.payload.data.result;
|
||||
// Headers keep their real names instead of collapsing to "A".
|
||||
expect(tableEntry.table?.columns).toStrictEqual([
|
||||
{
|
||||
name: 'service.name',
|
||||
queryName: 'A',
|
||||
isValueColumn: false,
|
||||
id: 'service.name',
|
||||
},
|
||||
{
|
||||
name: 'current_availability',
|
||||
queryName: 'A',
|
||||
isValueColumn: true,
|
||||
id: 'current_availability',
|
||||
},
|
||||
{
|
||||
name: 'error_budget_remaining',
|
||||
queryName: 'A',
|
||||
isValueColumn: true,
|
||||
id: 'error_budget_remaining',
|
||||
},
|
||||
{
|
||||
name: 'budget_status',
|
||||
queryName: 'A',
|
||||
isValueColumn: false,
|
||||
id: 'budget_status',
|
||||
},
|
||||
{
|
||||
name: 'total_requests',
|
||||
queryName: 'A',
|
||||
isValueColumn: true,
|
||||
id: 'total_requests',
|
||||
},
|
||||
]);
|
||||
// Ids are unique, so value columns don't overwrite each other in the row.
|
||||
expect(tableEntry.table?.rows?.[0]).toStrictEqual({
|
||||
data: {
|
||||
'service.name': 'kuja-api_gateway-service',
|
||||
current_availability: 99.985,
|
||||
error_budget_remaining: 0.985,
|
||||
budget_status: 'Healthy ✅',
|
||||
total_requests: 2181216,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ function getColName(
|
||||
col: ScalarData['columns'][number],
|
||||
legendMap: Record<string, string>,
|
||||
aggregationPerQuery: Record<string, any>,
|
||||
clickhouseQueryNames: Set<string>,
|
||||
): string {
|
||||
if (col.columnType === 'group') {
|
||||
return col.name;
|
||||
@@ -39,16 +40,32 @@ function getColName(
|
||||
return alias || expression || col.queryName;
|
||||
}
|
||||
|
||||
// clickhouse_sql value columns carry their real SQL alias in col.name — use
|
||||
// it so each value column keeps its own header instead of collapsing onto
|
||||
// the query name. Formulas/promql use placeholder names, so they fall back
|
||||
// to legend || queryName.
|
||||
if (clickhouseQueryNames.has(col.queryName)) {
|
||||
return col.name;
|
||||
}
|
||||
return legend || col.queryName;
|
||||
}
|
||||
|
||||
function getColId(
|
||||
col: ScalarData['columns'][number],
|
||||
aggregationPerQuery: Record<string, any>,
|
||||
clickhouseQueryNames: Set<string>,
|
||||
): string {
|
||||
if (col.columnType === 'group') {
|
||||
return col.name;
|
||||
}
|
||||
|
||||
// clickhouse_sql value columns are keyed by their real SQL alias so multiple
|
||||
// value columns stay unique instead of all collapsing onto the query name
|
||||
// (which would overwrite every cell in the row with the last column's value).
|
||||
if (clickhouseQueryNames.has(col.queryName)) {
|
||||
return col.name;
|
||||
}
|
||||
|
||||
const aggregation =
|
||||
aggregationPerQuery?.[col.queryName]?.[col.aggregationIndex];
|
||||
const expression = aggregation?.expression || '';
|
||||
@@ -141,6 +158,7 @@ function convertScalarDataArrayToTable(
|
||||
scalarDataArray: ScalarData[],
|
||||
legendMap: Record<string, string>,
|
||||
aggregationPerQuery: Record<string, any>,
|
||||
clickhouseQueryNames: Set<string>,
|
||||
): QueryDataV3[] {
|
||||
// If no scalar data, return empty structure
|
||||
|
||||
@@ -166,10 +184,10 @@ function convertScalarDataArrayToTable(
|
||||
|
||||
// Collect columns for this specific query
|
||||
const columns = scalarData?.columns?.map((col) => ({
|
||||
name: getColName(col, legendMap, aggregationPerQuery),
|
||||
name: getColName(col, legendMap, aggregationPerQuery, clickhouseQueryNames),
|
||||
queryName: col.queryName,
|
||||
isValueColumn: col.columnType === 'aggregation',
|
||||
id: getColId(col, aggregationPerQuery),
|
||||
id: getColId(col, aggregationPerQuery, clickhouseQueryNames),
|
||||
}));
|
||||
|
||||
// Process rows for this specific query
|
||||
@@ -177,8 +195,13 @@ function convertScalarDataArrayToTable(
|
||||
const rowData: Record<string, any> = {};
|
||||
|
||||
scalarData?.columns?.forEach((col, colIndex) => {
|
||||
const columnName = getColName(col, legendMap, aggregationPerQuery);
|
||||
const columnId = getColId(col, aggregationPerQuery);
|
||||
const columnName = getColName(
|
||||
col,
|
||||
legendMap,
|
||||
aggregationPerQuery,
|
||||
clickhouseQueryNames,
|
||||
);
|
||||
const columnId = getColId(col, aggregationPerQuery, clickhouseQueryNames);
|
||||
rowData[columnId || columnName] = dataRow[colIndex];
|
||||
});
|
||||
|
||||
@@ -202,6 +225,7 @@ function convertScalarWithFormatForWeb(
|
||||
scalarDataArray: ScalarData[],
|
||||
legendMap: Record<string, string>,
|
||||
aggregationPerQuery: Record<string, any>,
|
||||
clickhouseQueryNames: Set<string>,
|
||||
): QueryDataV3[] {
|
||||
if (!scalarDataArray || scalarDataArray.length === 0) {
|
||||
return [];
|
||||
@@ -210,13 +234,18 @@ function convertScalarWithFormatForWeb(
|
||||
return scalarDataArray.map((scalarData) => {
|
||||
const columns =
|
||||
scalarData.columns?.map((col) => {
|
||||
const colName = getColName(col, legendMap, aggregationPerQuery);
|
||||
const colName = getColName(
|
||||
col,
|
||||
legendMap,
|
||||
aggregationPerQuery,
|
||||
clickhouseQueryNames,
|
||||
);
|
||||
|
||||
return {
|
||||
name: colName,
|
||||
queryName: col.queryName,
|
||||
isValueColumn: col.columnType === 'aggregation',
|
||||
id: getColId(col, aggregationPerQuery),
|
||||
id: getColId(col, aggregationPerQuery, clickhouseQueryNames),
|
||||
};
|
||||
}) || [];
|
||||
|
||||
@@ -289,6 +318,7 @@ function convertV5DataByType(
|
||||
v5Data: any,
|
||||
legendMap: Record<string, string>,
|
||||
aggregationPerQuery: Record<string, any>,
|
||||
clickhouseQueryNames: Set<string>,
|
||||
): MetricRangePayloadV3['data'] {
|
||||
switch (v5Data?.type) {
|
||||
case 'time_series': {
|
||||
@@ -307,6 +337,7 @@ function convertV5DataByType(
|
||||
scalarData,
|
||||
legendMap,
|
||||
aggregationPerQuery,
|
||||
clickhouseQueryNames,
|
||||
);
|
||||
return {
|
||||
resultType: 'scalar',
|
||||
@@ -373,6 +404,15 @@ export function convertV5ResponseToLegacy(
|
||||
{} as Record<string, any>,
|
||||
) || {};
|
||||
|
||||
// clickhouse_sql queries have no aggregation metadata; their value columns
|
||||
// are named/keyed by the real SQL alias the response carries (see getColId).
|
||||
const clickhouseQueryNames = new Set<string>(
|
||||
(params?.compositeQuery?.queries ?? [])
|
||||
.filter((query) => query.type === 'clickhouse_sql')
|
||||
.map((query) => (query.spec as { name?: string })?.name)
|
||||
.filter((name): name is string => !!name),
|
||||
);
|
||||
|
||||
// If formatForWeb is true, return as-is (like existing logic)
|
||||
if (formatForWeb && v5Data?.type === 'scalar') {
|
||||
const scalarData = v5Data.data.results as ScalarData[];
|
||||
@@ -380,6 +420,7 @@ export function convertV5ResponseToLegacy(
|
||||
scalarData,
|
||||
legendMap,
|
||||
aggregationPerQuery,
|
||||
clickhouseQueryNames,
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -402,6 +443,7 @@ export function convertV5ResponseToLegacy(
|
||||
v5Data,
|
||||
legendMap,
|
||||
aggregationPerQuery,
|
||||
clickhouseQueryNames,
|
||||
);
|
||||
|
||||
// Create legacy-compatible response structure
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
|
||||
function FlamegraphImg(): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
return (
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M8 3c1 3 2.5 3.5 3.5 4.5A5 5 0 0113 11a5 5 0 11-10 0c0-.3 0-.6.1-.9a2 2 0 103.3-2C4 5.5 7 3 8 3zM21 4h-8M20 14.5h-3M20 9.5h-3M21 20H4"
|
||||
stroke={isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_500}
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default FlamegraphImg;
|
||||
@@ -1,106 +0,0 @@
|
||||
.span-hover-card {
|
||||
.ant-popover-inner {
|
||||
background: linear-gradient(
|
||||
139deg,
|
||||
color-mix(in srgb, var(--card) 32%, transparent) 0%,
|
||||
color-mix(in srgb, var(--card) 36%, transparent) 98.68%
|
||||
);
|
||||
padding: 12px 16px;
|
||||
border: 1px solid var(--l1-border);
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(
|
||||
139deg,
|
||||
color-mix(in srgb, var(--card) 32%, transparent) 0%,
|
||||
color-mix(in srgb, var(--card) 36%, transparent) 98.68%
|
||||
);
|
||||
backdrop-filter: blur(20px);
|
||||
border-radius: 4px;
|
||||
z-index: -1;
|
||||
will-change: background-color, backdrop-filter;
|
||||
}
|
||||
}
|
||||
|
||||
&__title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
&__operation {
|
||||
color: var(--l1-foreground);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
letter-spacing: 0.48px;
|
||||
}
|
||||
|
||||
&__service {
|
||||
font-size: 0.875rem;
|
||||
color: var(--muted-foreground);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
&__error {
|
||||
font-size: 0.75rem;
|
||||
color: var(--danger-background);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&__row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
&__label {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
&__value {
|
||||
color: var(--l1-foreground);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
&__relative-time {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 4px;
|
||||
gap: 8px;
|
||||
border-radius: 1px 0 0 1px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
hsla(358, 75%, 59%, 0.2) 0%,
|
||||
transparent 100%
|
||||
);
|
||||
|
||||
&-icon {
|
||||
width: 2px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 2px;
|
||||
background: var(--danger-background);
|
||||
}
|
||||
}
|
||||
|
||||
&__relative-text {
|
||||
color: var(--bg-cherry-300);
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
}
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { Popover } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { convertTimeToRelevantUnit } from 'container/TraceDetail/utils';
|
||||
import dayjs from 'dayjs';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { Span } from 'types/api/trace/getTraceV2';
|
||||
import { toFixed } from 'utils/toFixed';
|
||||
|
||||
import './SpanHoverCard.styles.scss';
|
||||
|
||||
interface ITraceMetadata {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
}
|
||||
|
||||
interface SpanHoverCardProps {
|
||||
span: Span;
|
||||
traceMetadata: ITraceMetadata;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function SpanHoverCard({
|
||||
span,
|
||||
traceMetadata,
|
||||
children,
|
||||
}: SpanHoverCardProps): JSX.Element {
|
||||
const duration = span.durationNano / 1e6; // Convert nanoseconds to milliseconds
|
||||
const { time: formattedDuration, timeUnitName } =
|
||||
convertTimeToRelevantUnit(duration);
|
||||
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
// Calculate relative start time from trace start
|
||||
const relativeStartTime = span.timestamp - traceMetadata.startTime;
|
||||
const { time: relativeTime, timeUnitName: relativeTimeUnit } =
|
||||
convertTimeToRelevantUnit(relativeStartTime);
|
||||
|
||||
// Format absolute start time
|
||||
const startTimeFormatted = dayjs(span.timestamp)
|
||||
.tz(timezone.value)
|
||||
.format(DATE_TIME_FORMATS.DD_MMM_YYYY_HH_MM_SS);
|
||||
|
||||
const getContent = (): JSX.Element => (
|
||||
<div className="span-hover-card">
|
||||
<div className="span-hover-card__row">
|
||||
<Typography.Text className="span-hover-card__label">
|
||||
Duration:
|
||||
</Typography.Text>
|
||||
<Typography.Text className="span-hover-card__value">
|
||||
{toFixed(formattedDuration, 2)}
|
||||
{timeUnitName}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className="span-hover-card__row">
|
||||
<Typography.Text className="span-hover-card__label">
|
||||
Events:
|
||||
</Typography.Text>
|
||||
<Typography.Text className="span-hover-card__value">
|
||||
{span.event?.length || 0}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className="span-hover-card__row">
|
||||
<Typography.Text className="span-hover-card__label">
|
||||
Start time:
|
||||
</Typography.Text>
|
||||
<Typography.Text className="span-hover-card__value">
|
||||
{startTimeFormatted}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className="span-hover-card__relative-time">
|
||||
<div className="span-hover-card__relative-time-icon" />
|
||||
<Typography.Text className="span-hover-card__relative-text">
|
||||
{toFixed(relativeTime, 2)}
|
||||
{relativeTimeUnit} after trace start
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
title={
|
||||
<div className="span-hover-card__title">
|
||||
<Typography.Text className="span-hover-card__operation">
|
||||
{span.name}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
}
|
||||
mouseEnterDelay={0.2}
|
||||
content={getContent()}
|
||||
trigger="hover"
|
||||
rootClassName="span-hover-card"
|
||||
autoAdjustOverflow
|
||||
arrow={false}
|
||||
>
|
||||
{children}
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export default SpanHoverCard;
|
||||
@@ -1,291 +0,0 @@
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { TimezoneContextType } from 'providers/Timezone';
|
||||
import { Span } from 'types/api/trace/getTraceV2';
|
||||
|
||||
import SpanHoverCard from '../SpanHoverCard';
|
||||
|
||||
// Mock timezone provider so SpanHoverCard can use useTimezone without a real context
|
||||
jest.mock('providers/Timezone', () => ({
|
||||
__esModule: true,
|
||||
useTimezone: (): TimezoneContextType => ({
|
||||
timezone: {
|
||||
name: 'Coordinated Universal Time — UTC, GMT',
|
||||
value: 'UTC',
|
||||
offset: 'UTC',
|
||||
searchIndex: 'UTC',
|
||||
},
|
||||
browserTimezone: {
|
||||
name: 'Coordinated Universal Time — UTC, GMT',
|
||||
value: 'UTC',
|
||||
offset: 'UTC',
|
||||
searchIndex: 'UTC',
|
||||
},
|
||||
updateTimezone: jest.fn(),
|
||||
formatTimezoneAdjustedTimestamp: jest.fn(() => 'mock-date'),
|
||||
isAdaptationEnabled: true,
|
||||
setIsAdaptationEnabled: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock dayjs for testing, including timezone helpers used in timezoneUtils
|
||||
jest.mock('dayjs', () => {
|
||||
const mockDayjsInstance: any = {};
|
||||
|
||||
mockDayjsInstance.format = jest.fn((formatString: string) =>
|
||||
// Match the DD_MMM_YYYY_HH_MM_SS format: 'DD MMM YYYY, HH:mm:ss'
|
||||
formatString === 'DD MMM YYYY, HH:mm:ss'
|
||||
? '15 Mar 2024, 14:23:45'
|
||||
: 'mock-date',
|
||||
);
|
||||
|
||||
// Support chaining: dayjs().tz(timezone).format(...) and dayjs().tz(timezone).utcOffset()
|
||||
mockDayjsInstance.tz = jest.fn(() => mockDayjsInstance);
|
||||
mockDayjsInstance.utcOffset = jest.fn(() => 0);
|
||||
|
||||
const mockDayjs = jest.fn(() => mockDayjsInstance);
|
||||
|
||||
Object.assign(mockDayjs, {
|
||||
extend: jest.fn(),
|
||||
// Support dayjs.tz.guess()
|
||||
tz: { guess: jest.fn(() => 'UTC') },
|
||||
});
|
||||
|
||||
return mockDayjs;
|
||||
});
|
||||
|
||||
const HOVER_ELEMENT_ID = 'hover-element';
|
||||
|
||||
const mockSpan: Span = {
|
||||
spanId: 'test-span-id',
|
||||
traceId: 'test-trace-id',
|
||||
rootSpanId: 'root-span-id',
|
||||
parentSpanId: 'parent-span-id',
|
||||
name: 'GET /api/users',
|
||||
timestamp: 1679748225000000,
|
||||
durationNano: 150000000,
|
||||
serviceName: 'user-service',
|
||||
kind: 1,
|
||||
hasError: false,
|
||||
level: 1,
|
||||
references: [],
|
||||
tagMap: {},
|
||||
event: [
|
||||
{
|
||||
name: 'event1',
|
||||
timeUnixNano: 1679748225100000,
|
||||
attributeMap: {},
|
||||
isError: false,
|
||||
},
|
||||
{
|
||||
name: 'event2',
|
||||
timeUnixNano: 1679748225200000,
|
||||
attributeMap: {},
|
||||
isError: false,
|
||||
},
|
||||
],
|
||||
rootName: 'root-span',
|
||||
statusMessage: '',
|
||||
statusCodeString: 'OK',
|
||||
spanKind: 'server',
|
||||
hasChildren: false,
|
||||
hasSibling: false,
|
||||
subTreeNodeCount: 1,
|
||||
};
|
||||
|
||||
const mockTraceMetadata = {
|
||||
startTime: 1679748225000000,
|
||||
endTime: 1679748226000000,
|
||||
};
|
||||
|
||||
describe('SpanHoverCard', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('renders child element correctly', () => {
|
||||
render(
|
||||
<SpanHoverCard span={mockSpan} traceMetadata={mockTraceMetadata}>
|
||||
<div data-testid="child-element">Hover me</div>
|
||||
</SpanHoverCard>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('child-element')).toBeInTheDocument();
|
||||
expect(screen.getByText('Hover me')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows popover after 0.2 second delay on hover', async () => {
|
||||
render(
|
||||
<SpanHoverCard span={mockSpan} traceMetadata={mockTraceMetadata}>
|
||||
<div data-testid={HOVER_ELEMENT_ID}>Hover for details</div>
|
||||
</SpanHoverCard>,
|
||||
);
|
||||
|
||||
const hoverElement = screen.getByTestId(HOVER_ELEMENT_ID);
|
||||
|
||||
// Hover over the element
|
||||
fireEvent.mouseEnter(hoverElement);
|
||||
|
||||
// Popover should NOT appear immediately
|
||||
expect(screen.queryByText('Duration:')).not.toBeInTheDocument();
|
||||
|
||||
// Advance time by 0.5 seconds
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(200);
|
||||
});
|
||||
|
||||
// Now popover should appear
|
||||
expect(screen.getByText('Duration:')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show popover if hover is too brief', async () => {
|
||||
render(
|
||||
<SpanHoverCard span={mockSpan} traceMetadata={mockTraceMetadata}>
|
||||
<div data-testid={HOVER_ELEMENT_ID}>Quick hover test</div>
|
||||
</SpanHoverCard>,
|
||||
);
|
||||
|
||||
const hoverElement = screen.getByTestId(HOVER_ELEMENT_ID);
|
||||
|
||||
// Quick hover and unhover (less than the 0.2s delay)
|
||||
fireEvent.mouseEnter(hoverElement);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(100); // Only 0.1 seconds
|
||||
});
|
||||
fireEvent.mouseLeave(hoverElement);
|
||||
|
||||
// Advance past the full delay
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(400);
|
||||
});
|
||||
|
||||
// Popover should not appear
|
||||
expect(screen.queryByText('Duration:')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays span information in popover content after delay', async () => {
|
||||
render(
|
||||
<SpanHoverCard span={mockSpan} traceMetadata={mockTraceMetadata}>
|
||||
<div data-testid={HOVER_ELEMENT_ID}>Test span</div>
|
||||
</SpanHoverCard>,
|
||||
);
|
||||
|
||||
const hoverElement = screen.getByTestId(HOVER_ELEMENT_ID);
|
||||
|
||||
// Hover and wait for popover
|
||||
fireEvent.mouseEnter(hoverElement);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(500);
|
||||
});
|
||||
|
||||
// Check that popover shows span operation name in title
|
||||
expect(screen.getByText('GET /api/users')).toBeInTheDocument();
|
||||
|
||||
// Check duration information
|
||||
expect(screen.getByText('Duration:')).toBeInTheDocument();
|
||||
expect(screen.getByText('150ms')).toBeInTheDocument();
|
||||
|
||||
// Check events count
|
||||
expect(screen.getByText('Events:')).toBeInTheDocument();
|
||||
expect(screen.getByText('2')).toBeInTheDocument();
|
||||
|
||||
// Check start time label
|
||||
expect(screen.getByText('Start time:')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays date in DD MMM YYYY, HH:mm:ss format with seconds', async () => {
|
||||
render(
|
||||
<SpanHoverCard span={mockSpan} traceMetadata={mockTraceMetadata}>
|
||||
<div data-testid={HOVER_ELEMENT_ID}>Date format test</div>
|
||||
</SpanHoverCard>,
|
||||
);
|
||||
|
||||
const hoverElement = screen.getByTestId(HOVER_ELEMENT_ID);
|
||||
|
||||
// Hover and wait for popover
|
||||
fireEvent.mouseEnter(hoverElement);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(500);
|
||||
});
|
||||
|
||||
// Verify the DD MMM YYYY, HH:mm:ss format is displayed
|
||||
expect(screen.getByText('15 Mar 2024, 14:23:45')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays relative time information', async () => {
|
||||
const spanWithRelativeTime: Span = {
|
||||
...mockSpan,
|
||||
timestamp: mockTraceMetadata.startTime + 1000000, // 1 second later
|
||||
};
|
||||
|
||||
render(
|
||||
<SpanHoverCard span={spanWithRelativeTime} traceMetadata={mockTraceMetadata}>
|
||||
<div data-testid={HOVER_ELEMENT_ID}>Relative time test</div>
|
||||
</SpanHoverCard>,
|
||||
);
|
||||
|
||||
const hoverElement = screen.getByTestId(HOVER_ELEMENT_ID);
|
||||
|
||||
// Hover and wait for popover
|
||||
fireEvent.mouseEnter(hoverElement);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(500);
|
||||
});
|
||||
|
||||
// Check relative time display
|
||||
expect(screen.getByText(/after trace start/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles spans with no events correctly', async () => {
|
||||
const spanWithoutEvents: Span = {
|
||||
...mockSpan,
|
||||
event: [],
|
||||
};
|
||||
|
||||
render(
|
||||
<SpanHoverCard span={spanWithoutEvents} traceMetadata={mockTraceMetadata}>
|
||||
<div data-testid={HOVER_ELEMENT_ID}>No events test</div>
|
||||
</SpanHoverCard>,
|
||||
);
|
||||
|
||||
const hoverElement = screen.getByTestId(HOVER_ELEMENT_ID);
|
||||
|
||||
// Hover and wait for popover
|
||||
fireEvent.mouseEnter(hoverElement);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(500);
|
||||
});
|
||||
|
||||
expect(screen.getByText('Events:')).toBeInTheDocument();
|
||||
expect(screen.getByText('0')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('verifies mouseEnterDelay prop is set to 0.5', () => {
|
||||
const { container } = render(
|
||||
<SpanHoverCard span={mockSpan} traceMetadata={mockTraceMetadata}>
|
||||
<div data-testid={HOVER_ELEMENT_ID}>Delay test</div>
|
||||
</SpanHoverCard>,
|
||||
);
|
||||
|
||||
// The mouseEnterDelay prop should be set on the Popover component
|
||||
// This test verifies the implementation includes the delay
|
||||
const popover = container.querySelector('.ant-popover');
|
||||
expect(popover).not.toBeInTheDocument(); // Initially not visible
|
||||
|
||||
// Hover to trigger delay mechanism
|
||||
const hoverElement = screen.getByTestId(HOVER_ELEMENT_ID);
|
||||
fireEvent.mouseEnter(hoverElement);
|
||||
|
||||
// Should not appear before delay
|
||||
expect(screen.queryByText('Duration:')).not.toBeInTheDocument();
|
||||
|
||||
// Should appear after delay
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(500);
|
||||
});
|
||||
expect(screen.getByText('Duration:')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -43,4 +43,5 @@ export enum LOCALSTORAGE {
|
||||
DASHBOARD_PREFERENCES = 'DASHBOARD_PREFERENCES',
|
||||
ACTIVE_SIGNOZ_INSTANCE_URL = 'ACTIVE_SIGNOZ_INSTANCE_URL',
|
||||
DASHBOARDS_LIST_VISIBLE_COLUMNS = 'DASHBOARDS_LIST_VISIBLE_COLUMNS',
|
||||
DASHBOARDS_LIST_VIEWS = 'DASHBOARDS_LIST_VIEWS',
|
||||
}
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
.flamegraph {
|
||||
display: flex;
|
||||
height: 30vh;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
|
||||
.flamegraph-chart {
|
||||
padding: 15px;
|
||||
|
||||
.loading-skeleton {
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.flamegraph-stats {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid var(--l1-border);
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 16px 20px;
|
||||
|
||||
.exec-time-service {
|
||||
display: flex;
|
||||
height: 30px;
|
||||
flex-shrink: 0;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 2px 0px 0px 2px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l1-border);
|
||||
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
|
||||
margin-bottom: 16px;
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 18px; /* 150% */
|
||||
letter-spacing: -0.06px;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0rem;
|
||||
}
|
||||
|
||||
.value-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
.service-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 80%;
|
||||
|
||||
.service-text {
|
||||
color: var(--l2-foreground);
|
||||
font-family: 'Inter';
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: normal;
|
||||
width: 80%;
|
||||
}
|
||||
|
||||
.square-box {
|
||||
height: 8px;
|
||||
width: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.progress-service {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100px;
|
||||
gap: 8px;
|
||||
justify-content: flex-start;
|
||||
flex-shrink: 0;
|
||||
|
||||
.service-progress-indicator {
|
||||
width: fit-content;
|
||||
--progress-width: 30px;
|
||||
}
|
||||
|
||||
.percent-value {
|
||||
color: var(--l1-foreground);
|
||||
text-align: right;
|
||||
font-family: 'Inter';
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: normal;
|
||||
letter-spacing: 0.48px;
|
||||
font-variant-numeric: lining-nums tabular-nums slashed-zero;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Skeleton, Tooltip } from 'antd';
|
||||
import { Progress } from '@signozhq/ui/progress';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { AxiosError } from 'axios';
|
||||
import Spinner from 'components/Spinner';
|
||||
import { themeColors } from 'constants/theme';
|
||||
import useGetTraceFlamegraph from 'hooks/trace/useGetTraceFlamegraph';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
|
||||
import { TraceDetailFlamegraphURLProps } from 'types/api/trace/getTraceFlamegraph';
|
||||
import { Span } from 'types/api/trace/getTraceV2';
|
||||
|
||||
import { TraceFlamegraphStates } from './constants';
|
||||
import Error from './TraceFlamegraphStates/Error/Error';
|
||||
import NoData from './TraceFlamegraphStates/NoData/NoData';
|
||||
import Success from './TraceFlamegraphStates/Success/Success';
|
||||
|
||||
import './PaginatedTraceFlamegraph.styles.scss';
|
||||
|
||||
interface ITraceFlamegraphProps {
|
||||
serviceExecTime: Record<string, number>;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
traceFlamegraphStatsWidth: number;
|
||||
selectedSpan: Span | undefined;
|
||||
}
|
||||
|
||||
function TraceFlamegraph(props: ITraceFlamegraphProps): JSX.Element {
|
||||
const {
|
||||
serviceExecTime,
|
||||
startTime,
|
||||
endTime,
|
||||
traceFlamegraphStatsWidth,
|
||||
selectedSpan,
|
||||
} = props;
|
||||
const { id: traceId } = useParams<TraceDetailFlamegraphURLProps>();
|
||||
const urlQuery = useUrlQuery();
|
||||
const [firstSpanAtFetchLevel, setFirstSpanAtFetchLevel] = useState<string>(
|
||||
urlQuery.get('spanId') || '',
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setFirstSpanAtFetchLevel(urlQuery.get('spanId') || '');
|
||||
}, [urlQuery]);
|
||||
|
||||
const { data, isFetching, error } = useGetTraceFlamegraph({
|
||||
traceId,
|
||||
selectedSpanId: firstSpanAtFetchLevel,
|
||||
});
|
||||
|
||||
// get the current state of trace flamegraph based on the API lifecycle
|
||||
const traceFlamegraphState = useMemo(() => {
|
||||
if (isFetching) {
|
||||
if (
|
||||
data &&
|
||||
data.payload &&
|
||||
data.payload.spans &&
|
||||
data.payload.spans.length > 0
|
||||
) {
|
||||
return TraceFlamegraphStates.FETCHING_WITH_OLD_DATA_PRESENT;
|
||||
}
|
||||
return TraceFlamegraphStates.LOADING;
|
||||
}
|
||||
if (error) {
|
||||
return TraceFlamegraphStates.ERROR;
|
||||
}
|
||||
if (
|
||||
data &&
|
||||
data.payload &&
|
||||
data.payload.spans &&
|
||||
data.payload.spans.length === 0
|
||||
) {
|
||||
return TraceFlamegraphStates.NO_DATA;
|
||||
}
|
||||
|
||||
return TraceFlamegraphStates.SUCCESS;
|
||||
}, [error, isFetching, data]);
|
||||
|
||||
// capture the spans from the response, since we do not need to do any manipulation on the same we will keep this as a simple constant [ memoized ]
|
||||
const spans = useMemo(
|
||||
() => data?.payload?.spans || [],
|
||||
[data?.payload?.spans],
|
||||
);
|
||||
|
||||
// get the content based on the current state of the trace waterfall
|
||||
const getContent = useMemo(() => {
|
||||
switch (traceFlamegraphState) {
|
||||
case TraceFlamegraphStates.LOADING:
|
||||
return (
|
||||
<div className="loading-skeleton">
|
||||
<Skeleton active paragraph={{ rows: 3 }} />
|
||||
</div>
|
||||
);
|
||||
case TraceFlamegraphStates.ERROR:
|
||||
return <Error error={error as AxiosError} />;
|
||||
case TraceFlamegraphStates.NO_DATA:
|
||||
return <NoData id={traceId} />;
|
||||
case TraceFlamegraphStates.SUCCESS:
|
||||
case TraceFlamegraphStates.FETCHING_WITH_OLD_DATA_PRESENT:
|
||||
return (
|
||||
<Success
|
||||
spans={spans}
|
||||
firstSpanAtFetchLevel={firstSpanAtFetchLevel}
|
||||
setFirstSpanAtFetchLevel={setFirstSpanAtFetchLevel}
|
||||
traceMetadata={{
|
||||
startTime: data?.payload?.startTimestampMillis || 0,
|
||||
endTime: data?.payload?.endTimestampMillis || 0,
|
||||
}}
|
||||
selectedSpan={selectedSpan}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return <Spinner tip="Fetching the trace!" />;
|
||||
}
|
||||
}, [
|
||||
data?.payload?.endTimestampMillis,
|
||||
data?.payload?.startTimestampMillis,
|
||||
error,
|
||||
firstSpanAtFetchLevel,
|
||||
selectedSpan,
|
||||
spans,
|
||||
traceFlamegraphState,
|
||||
traceId,
|
||||
]);
|
||||
|
||||
const spread = useMemo(() => endTime - startTime, [endTime, startTime]);
|
||||
|
||||
return (
|
||||
<div className="flamegraph">
|
||||
<div
|
||||
className="flamegraph-stats"
|
||||
style={{ width: `${traceFlamegraphStatsWidth + 22}px` }}
|
||||
>
|
||||
<div className="exec-time-service">% exec time</div>
|
||||
<div className="stats">
|
||||
{Object.keys(serviceExecTime)
|
||||
.sort((a, b) => {
|
||||
if (spread <= 0) {
|
||||
return 0;
|
||||
}
|
||||
const aValue = (serviceExecTime[a] * 100) / spread;
|
||||
const bValue = (serviceExecTime[b] * 100) / spread;
|
||||
return bValue - aValue;
|
||||
})
|
||||
.map((service) => {
|
||||
const value =
|
||||
spread <= 0 ? 0 : (serviceExecTime[service] * 100) / spread;
|
||||
const color = generateColor(service, themeColors.traceDetailColors);
|
||||
return (
|
||||
<div key={service} className="value-row">
|
||||
<section className="service-name">
|
||||
<div className="square-box" style={{ backgroundColor: color }} />
|
||||
<Tooltip title={service}>
|
||||
<Typography.Text className="service-text" truncate={1}>
|
||||
{service}
|
||||
</Typography.Text>
|
||||
</Tooltip>
|
||||
</section>
|
||||
<section className="progress-service">
|
||||
<Progress
|
||||
percent={parseFloat(value.toFixed(2))}
|
||||
className="service-progress-indicator"
|
||||
showInfo={false}
|
||||
/>
|
||||
<Typography.Text className="percent-value">
|
||||
{parseFloat(value.toFixed(2))}%
|
||||
</Typography.Text>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="flamegraph-chart"
|
||||
style={{ width: `calc(100% - ${traceFlamegraphStatsWidth + 22}px)` }}
|
||||
>
|
||||
{getContent}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TraceFlamegraph;
|
||||
@@ -1,23 +0,0 @@
|
||||
.error-flamegraph {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 15vh;
|
||||
|
||||
.error-flamegraph-img {
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
}
|
||||
|
||||
.no-data-text {
|
||||
color: var(--muted-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 18px; /* 128.571% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { Tooltip } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { AxiosError } from 'axios';
|
||||
|
||||
import noDataUrl from '@/assets/Icons/no-data.svg';
|
||||
|
||||
import './Error.styles.scss';
|
||||
|
||||
interface IErrorProps {
|
||||
error: AxiosError;
|
||||
}
|
||||
|
||||
function Error(props: IErrorProps): JSX.Element {
|
||||
const { error } = props;
|
||||
|
||||
return (
|
||||
<div className="error-flamegraph">
|
||||
<img
|
||||
src={noDataUrl}
|
||||
alt="error-flamegraph"
|
||||
className="error-flamegraph-img"
|
||||
/>
|
||||
<Tooltip title={error?.message}>
|
||||
<Typography.Text className="no-data-text">
|
||||
{error?.message || 'Something went wrong!'}
|
||||
</Typography.Text>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Error;
|
||||
@@ -1,12 +0,0 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
interface INoDataProps {
|
||||
id: string;
|
||||
}
|
||||
|
||||
function NoData(props: INoDataProps): JSX.Element {
|
||||
const { id } = props;
|
||||
return <Typography.Text>No Trace found with the id: {id} </Typography.Text>;
|
||||
}
|
||||
|
||||
export default NoData;
|
||||
@@ -1,49 +0,0 @@
|
||||
.trace-flamegraph {
|
||||
height: 90%;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
|
||||
.trace-flamegraph-virtuoso {
|
||||
overflow-x: hidden;
|
||||
|
||||
.flamegraph-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 18px;
|
||||
padding-bottom: 6px;
|
||||
|
||||
.span-item {
|
||||
position: absolute;
|
||||
height: 12px;
|
||||
background-color: yellow;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
|
||||
.event-dot {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%) rotate(45deg);
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background-color: var(--primary-background);
|
||||
border: 1px solid var(--bg-robin-600);
|
||||
cursor: pointer;
|
||||
z-index: 1;
|
||||
|
||||
&.error {
|
||||
background-color: var(--danger-background);
|
||||
border-color: var(--bg-cherry-600);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
transform: translate(-50%, -50%) rotate(45deg) scale(1.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
import {
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useHistory, useLocation } from 'react-router-dom';
|
||||
import { ListRange, Virtuoso, VirtuosoHandle } from 'react-virtuoso';
|
||||
import { Tooltip } from 'antd';
|
||||
import Color from 'color';
|
||||
import TimelineV2 from 'components/TimelineV2/TimelineV2';
|
||||
import { themeColors } from 'constants/theme';
|
||||
import { convertTimeToRelevantUnit } from 'container/TraceDetail/utils';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
|
||||
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
|
||||
import { Span } from 'types/api/trace/getTraceV2';
|
||||
import { toFixed } from 'utils/toFixed';
|
||||
|
||||
import './Success.styles.scss';
|
||||
|
||||
interface ITraceMetadata {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
}
|
||||
|
||||
interface ISuccessProps {
|
||||
spans: FlamegraphSpan[][];
|
||||
firstSpanAtFetchLevel: string;
|
||||
setFirstSpanAtFetchLevel: Dispatch<SetStateAction<string>>;
|
||||
traceMetadata: ITraceMetadata;
|
||||
selectedSpan: Span | undefined;
|
||||
}
|
||||
|
||||
function Success(props: ISuccessProps): JSX.Element {
|
||||
const {
|
||||
spans,
|
||||
setFirstSpanAtFetchLevel,
|
||||
traceMetadata,
|
||||
firstSpanAtFetchLevel,
|
||||
selectedSpan,
|
||||
} = props;
|
||||
const { search } = useLocation();
|
||||
const history = useHistory();
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const virtuosoRef = useRef<VirtuosoHandle>(null);
|
||||
const [hoveredSpanId, setHoveredSpanId] = useState<string>('');
|
||||
const renderSpanLevel = useCallback(
|
||||
(_: number, spans: FlamegraphSpan[]): JSX.Element => (
|
||||
<div className="flamegraph-row">
|
||||
{spans.map((span) => {
|
||||
const spread = traceMetadata.endTime - traceMetadata.startTime;
|
||||
const leftOffset =
|
||||
((span.timestamp - traceMetadata.startTime) * 100) / spread;
|
||||
let width = ((span.durationNano / 1e6) * 100) / spread;
|
||||
if (width > 100) {
|
||||
width = 100;
|
||||
}
|
||||
const toolTipText = `${span.name}`;
|
||||
const searchParams = new URLSearchParams(search);
|
||||
|
||||
let color = generateColor(span.serviceName, themeColors.traceDetailColors);
|
||||
|
||||
const selectedSpanColor = isDarkMode
|
||||
? Color(color).lighten(0.7)
|
||||
: Color(color).darken(0.7);
|
||||
|
||||
if (span.hasError) {
|
||||
color = `var(--danger-background)`;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip title={toolTipText} key={span.spanId}>
|
||||
<div
|
||||
className="span-item"
|
||||
style={{
|
||||
left: `${leftOffset}%`,
|
||||
width: `${width}%`,
|
||||
backgroundColor:
|
||||
selectedSpan?.spanId === span.spanId || hoveredSpanId === span.spanId
|
||||
? `${selectedSpanColor}`
|
||||
: color,
|
||||
}}
|
||||
onMouseEnter={(): void => setHoveredSpanId(span.spanId)}
|
||||
onMouseLeave={(): void => setHoveredSpanId('')}
|
||||
onClick={(event): void => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
searchParams.set('spanId', span.spanId);
|
||||
history.replace({ search: searchParams.toString() });
|
||||
}}
|
||||
>
|
||||
{span.event?.map((event) => {
|
||||
const eventTimeMs = event.timeUnixNano / 1e6;
|
||||
const eventOffsetPercent =
|
||||
((eventTimeMs - span.timestamp) / (span.durationNano / 1e6)) * 100;
|
||||
const clampedOffset = Math.max(1, Math.min(eventOffsetPercent, 99));
|
||||
const { isError } = event;
|
||||
const { time, timeUnitName } = convertTimeToRelevantUnit(
|
||||
eventTimeMs - span.timestamp,
|
||||
);
|
||||
return (
|
||||
<Tooltip
|
||||
key={`${span.spanId}-event-${event.name}-${event.timeUnixNano}`}
|
||||
title={`${event.name} @ ${toFixed(time, 2)} ${timeUnitName}`}
|
||||
>
|
||||
<div
|
||||
className={`event-dot ${isError ? 'error' : ''}`}
|
||||
style={{
|
||||
left: `${clampedOffset}%`,
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[traceMetadata.endTime, traceMetadata.startTime, selectedSpan, hoveredSpanId],
|
||||
);
|
||||
|
||||
const handleRangeChanged = useCallback(
|
||||
(range: ListRange) => {
|
||||
// if there are less than 50 levels on any load that means a single API call is sufficient
|
||||
if (spans.length < 50) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { startIndex, endIndex } = range;
|
||||
if (startIndex === 0 && spans[0][0].level !== 0) {
|
||||
setFirstSpanAtFetchLevel(spans[0][0].spanId);
|
||||
}
|
||||
|
||||
if (endIndex === spans.length - 1) {
|
||||
setFirstSpanAtFetchLevel(spans[spans.length - 1][0].spanId);
|
||||
}
|
||||
},
|
||||
[setFirstSpanAtFetchLevel, spans],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const index = spans.findIndex(
|
||||
(span) => span[0].spanId === firstSpanAtFetchLevel,
|
||||
);
|
||||
|
||||
virtuosoRef.current?.scrollToIndex({
|
||||
index,
|
||||
behavior: 'auto',
|
||||
});
|
||||
}, [firstSpanAtFetchLevel, spans]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="trace-flamegraph">
|
||||
<Virtuoso
|
||||
ref={virtuosoRef}
|
||||
className="trace-flamegraph-virtuoso"
|
||||
data={spans}
|
||||
itemContent={renderSpanLevel}
|
||||
rangeChanged={handleRangeChanged}
|
||||
/>
|
||||
</div>
|
||||
<TimelineV2
|
||||
startTimestamp={traceMetadata.startTime}
|
||||
endTimestamp={traceMetadata.endTime}
|
||||
timelineHeight={22}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Success;
|
||||
@@ -1,7 +0,0 @@
|
||||
export enum TraceFlamegraphStates {
|
||||
LOADING = 'LOADING',
|
||||
SUCCESS = 'SUCCESS',
|
||||
NO_DATA = 'NO_DATA',
|
||||
ERROR = 'ERROR',
|
||||
FETCHING_WITH_OLD_DATA_PRESENT = 'FETCHING_WTIH_OLD_DATA_PRESENT',
|
||||
}
|
||||
@@ -116,7 +116,8 @@ function CreateRoleModal({
|
||||
} else {
|
||||
const data: AuthtypesPostableRoleDTO = {
|
||||
name: values.name,
|
||||
...(values.description ? { description: values.description } : {}),
|
||||
description: values.description || '',
|
||||
transactionGroups: [],
|
||||
};
|
||||
createRole({ data });
|
||||
}
|
||||
|
||||
@@ -1,202 +0,0 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { Button, Popover, Spin, Tooltip } from 'antd';
|
||||
import GroupByIcon from 'assets/CustomIcons/GroupByIcon';
|
||||
import cx from 'classnames';
|
||||
import { OPERATORS } from 'constants/antlrQueryConstants';
|
||||
import { useTraceActions } from 'hooks/trace/useTraceActions';
|
||||
import {
|
||||
ArrowDownToDot,
|
||||
ArrowUpFromDot,
|
||||
Copy,
|
||||
Ellipsis,
|
||||
Pin,
|
||||
} from '@signozhq/icons';
|
||||
|
||||
interface AttributeRecord {
|
||||
field: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface AttributeActionsProps {
|
||||
record: AttributeRecord;
|
||||
isPinned?: boolean;
|
||||
onTogglePin?: (fieldKey: string) => void;
|
||||
showPinned?: boolean;
|
||||
showCopyOptions?: boolean;
|
||||
}
|
||||
|
||||
export default function AttributeActions({
|
||||
record,
|
||||
isPinned,
|
||||
onTogglePin,
|
||||
showPinned = true,
|
||||
showCopyOptions = true,
|
||||
}: AttributeActionsProps): JSX.Element {
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
const [isFilterInLoading, setIsFilterInLoading] = useState<boolean>(false);
|
||||
const [isFilterOutLoading, setIsFilterOutLoading] = useState<boolean>(false);
|
||||
|
||||
const { onAddToQuery, onGroupByAttribute, onCopyFieldName, onCopyFieldValue } =
|
||||
useTraceActions();
|
||||
|
||||
const textToCopy = useMemo(() => {
|
||||
const str = record.value == null ? '' : String(record.value);
|
||||
// Remove surrounding double-quotes only (e.g., JSON-encoded string values)
|
||||
return str.replace(/^"|"$/g, '');
|
||||
}, [record.value]);
|
||||
|
||||
const handleFilterIn = useCallback(async (): Promise<void> => {
|
||||
if (!onAddToQuery || isFilterInLoading) {
|
||||
return;
|
||||
}
|
||||
setIsFilterInLoading(true);
|
||||
try {
|
||||
await Promise.resolve(
|
||||
onAddToQuery(record.field, record.value, OPERATORS['=']),
|
||||
);
|
||||
} finally {
|
||||
setIsFilterInLoading(false);
|
||||
}
|
||||
}, [onAddToQuery, record.field, record.value, isFilterInLoading]);
|
||||
|
||||
const handleFilterOut = useCallback(async (): Promise<void> => {
|
||||
if (!onAddToQuery || isFilterOutLoading) {
|
||||
return;
|
||||
}
|
||||
setIsFilterOutLoading(true);
|
||||
try {
|
||||
await Promise.resolve(
|
||||
onAddToQuery(record.field, record.value, OPERATORS['!=']),
|
||||
);
|
||||
} finally {
|
||||
setIsFilterOutLoading(false);
|
||||
}
|
||||
}, [onAddToQuery, record.field, record.value, isFilterOutLoading]);
|
||||
|
||||
const handleGroupBy = useCallback((): void => {
|
||||
if (onGroupByAttribute) {
|
||||
onGroupByAttribute(record.field);
|
||||
}
|
||||
setIsOpen(false);
|
||||
}, [onGroupByAttribute, record.field]);
|
||||
|
||||
const handleCopyFieldName = useCallback((): void => {
|
||||
if (onCopyFieldName) {
|
||||
onCopyFieldName(record.field);
|
||||
}
|
||||
setIsOpen(false);
|
||||
}, [onCopyFieldName, record.field]);
|
||||
|
||||
const handleCopyFieldValue = useCallback((): void => {
|
||||
if (onCopyFieldValue) {
|
||||
onCopyFieldValue(textToCopy);
|
||||
}
|
||||
setIsOpen(false);
|
||||
}, [onCopyFieldValue, textToCopy]);
|
||||
|
||||
const handleTogglePin = useCallback((): void => {
|
||||
onTogglePin?.(record.field);
|
||||
}, [onTogglePin, record.field]);
|
||||
|
||||
const moreActionsContent = (
|
||||
<div className="attribute-actions-menu">
|
||||
<Button
|
||||
className="group-by-clause"
|
||||
type="text"
|
||||
icon={<GroupByIcon />}
|
||||
onClick={handleGroupBy}
|
||||
block
|
||||
>
|
||||
Group By Attribute
|
||||
</Button>
|
||||
{showCopyOptions && (
|
||||
<>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<Copy size={14} />}
|
||||
onClick={handleCopyFieldName}
|
||||
block
|
||||
>
|
||||
Copy Field Name
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<Copy size={14} />}
|
||||
onClick={handleCopyFieldValue}
|
||||
block
|
||||
>
|
||||
Copy Field Value
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cx('action-btn', { 'action-btn--is-open': isOpen })}>
|
||||
{showPinned && (
|
||||
<Tooltip title={isPinned ? 'Unpin attribute' : 'Pin attribute'}>
|
||||
<Button
|
||||
className={`filter-btn periscope-btn ${isPinned ? 'pinned' : ''}`}
|
||||
aria-label={isPinned ? 'Unpin attribute' : 'Pin attribute'}
|
||||
icon={<Pin size={14} fill={isPinned ? 'currentColor' : 'none'} />}
|
||||
onClick={handleTogglePin}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip title="Filter for value">
|
||||
<Button
|
||||
className="filter-btn periscope-btn"
|
||||
aria-label="Filter for value"
|
||||
disabled={isFilterInLoading}
|
||||
icon={
|
||||
isFilterInLoading ? (
|
||||
<Spin size="small" />
|
||||
) : (
|
||||
<ArrowDownToDot size={14} style={{ transform: 'rotate(90deg)' }} />
|
||||
)
|
||||
}
|
||||
onClick={handleFilterIn}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="Filter out value">
|
||||
<Button
|
||||
className="filter-btn periscope-btn"
|
||||
aria-label="Filter out value"
|
||||
disabled={isFilterOutLoading}
|
||||
icon={
|
||||
isFilterOutLoading ? (
|
||||
<Spin size="small" />
|
||||
) : (
|
||||
<ArrowUpFromDot size={14} style={{ transform: 'rotate(90deg)' }} />
|
||||
)
|
||||
}
|
||||
onClick={handleFilterOut}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Popover
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
arrow={false}
|
||||
content={moreActionsContent}
|
||||
rootClassName="attribute-actions-content"
|
||||
trigger="hover"
|
||||
placement="bottomLeft"
|
||||
>
|
||||
<Button
|
||||
data-testid="attribute-actions-more"
|
||||
aria-label="More attribute actions"
|
||||
icon={<Ellipsis size={14} />}
|
||||
className="filter-btn periscope-btn"
|
||||
/>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
AttributeActions.defaultProps = {
|
||||
isPinned: false,
|
||||
showPinned: true,
|
||||
showCopyOptions: true,
|
||||
onTogglePin: undefined,
|
||||
};
|
||||
@@ -1,151 +0,0 @@
|
||||
.attributes-corner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.no-data {
|
||||
height: 400px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
margin: 12px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.attributes-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding-block: 12px;
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
justify-content: flex-start;
|
||||
position: relative;
|
||||
padding: 2px 12px;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--l1-border);
|
||||
.action-btn {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.item-key-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
.pin-icon {
|
||||
color: var(--bg-robin-400);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.item-key {
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.value-wrapper {
|
||||
display: flex;
|
||||
padding: 2px 8px;
|
||||
align-items: center;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
gap: 8px;
|
||||
border-radius: 50px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l1-border);
|
||||
|
||||
.copy-wrapper {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.item-value {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: 0.56px;
|
||||
}
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: none;
|
||||
|
||||
&--is-open {
|
||||
display: flex;
|
||||
}
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
gap: 4px;
|
||||
border-radius: 4px;
|
||||
padding: 2px;
|
||||
|
||||
.filter-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
border-radius: 2px;
|
||||
background: var(--l1-border);
|
||||
padding: 4px;
|
||||
gap: 3px;
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
|
||||
&:hover {
|
||||
background: var(--l3-background);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.border-top {
|
||||
border-top: 1px solid var(--l1-border);
|
||||
}
|
||||
}
|
||||
|
||||
.attribute-actions-menu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
.ant-btn {
|
||||
text-align: left;
|
||||
height: auto;
|
||||
padding: 6px 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--l1-border);
|
||||
}
|
||||
}
|
||||
|
||||
.group-by-clause {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.attribute-actions-content {
|
||||
.ant-popover-inner {
|
||||
padding: 8px;
|
||||
min-width: 160px;
|
||||
}
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
import CopyClipboardHOC from 'components/Logs/CopyClipboardHOC';
|
||||
import { flattenObject } from 'container/LogDetailedView/utils';
|
||||
import { usePinnedAttributes } from 'hooks/spanDetails/usePinnedAttributes';
|
||||
import { Pin } from '@signozhq/icons';
|
||||
import { Span } from 'types/api/trace/getTraceV2';
|
||||
|
||||
import NoData from '../NoData/NoData';
|
||||
import AttributeActions from './AttributeActions';
|
||||
|
||||
import './Attributes.styles.scss';
|
||||
|
||||
interface AttributeRecord {
|
||||
field: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface IAttributesProps {
|
||||
span: Span;
|
||||
isSearchVisible: boolean;
|
||||
shouldFocusOnToggle?: boolean;
|
||||
}
|
||||
|
||||
function Attributes(props: IAttributesProps): JSX.Element {
|
||||
const { span, isSearchVisible, shouldFocusOnToggle } = props;
|
||||
const [fieldSearchInput, setFieldSearchInput] = useState<string>('');
|
||||
|
||||
const flattenSpanData: Record<string, string> = useMemo(
|
||||
() => (span.tagMap ? flattenObject(span.tagMap) : {}),
|
||||
[span],
|
||||
);
|
||||
|
||||
const availableAttributes = useMemo(
|
||||
() => Object.keys(flattenSpanData),
|
||||
[flattenSpanData],
|
||||
);
|
||||
|
||||
const { pinnedAttributes, togglePin } =
|
||||
usePinnedAttributes(availableAttributes);
|
||||
|
||||
const sortPinnedAttributes = useCallback(
|
||||
(data: AttributeRecord[]): AttributeRecord[] =>
|
||||
data.sort((a, b) => {
|
||||
const aIsPinned = pinnedAttributes[a.field];
|
||||
const bIsPinned = pinnedAttributes[b.field];
|
||||
|
||||
if (aIsPinned && !bIsPinned) {
|
||||
return -1;
|
||||
}
|
||||
if (!aIsPinned && bIsPinned) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Within same pinning status, maintain alphabetical order
|
||||
return a.field.localeCompare(b.field);
|
||||
}),
|
||||
[pinnedAttributes],
|
||||
);
|
||||
|
||||
const datasource = useMemo(() => {
|
||||
const filtered = Object.keys(flattenSpanData)
|
||||
.filter((attribute) =>
|
||||
attribute.toLowerCase().includes(fieldSearchInput.toLowerCase()),
|
||||
)
|
||||
.map((key) => ({ field: key, value: flattenSpanData[key] }));
|
||||
|
||||
return sortPinnedAttributes(filtered);
|
||||
}, [flattenSpanData, fieldSearchInput, sortPinnedAttributes]);
|
||||
|
||||
return (
|
||||
<div className="attributes-corner">
|
||||
{isSearchVisible &&
|
||||
(datasource.length > 0 || fieldSearchInput.length > 0) && (
|
||||
<Input
|
||||
autoFocus={shouldFocusOnToggle}
|
||||
placeholder="Search for attribute..."
|
||||
className="search-input"
|
||||
value={fieldSearchInput}
|
||||
onChange={(e): void => setFieldSearchInput(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
{datasource.length === 0 && fieldSearchInput.length === 0 && (
|
||||
<NoData name="attributes" />
|
||||
)}
|
||||
<section
|
||||
className={cx('attributes-container', isSearchVisible ? 'border-top' : '')}
|
||||
>
|
||||
{datasource
|
||||
.filter((item) => !!item.value && item.value !== '-')
|
||||
.map((item) => (
|
||||
<div
|
||||
className={cx('item', { pinned: pinnedAttributes[item.field] })}
|
||||
key={`${item.field} + ${item.value}`}
|
||||
>
|
||||
<div className="item-key-wrapper">
|
||||
<Typography.Text className="item-key" truncate={1}>
|
||||
{item.field}
|
||||
</Typography.Text>
|
||||
{pinnedAttributes[item.field] && (
|
||||
<Pin size={14} className="pin-icon" fill="currentColor" />
|
||||
)}
|
||||
</div>
|
||||
<div className="value-wrapper">
|
||||
<div className="copy-wrapper">
|
||||
<CopyClipboardHOC
|
||||
entityKey={item.value}
|
||||
textToCopy={item.value}
|
||||
tooltipText={item.value}
|
||||
>
|
||||
<Typography.Text className="item-value" truncate={1}>
|
||||
{item.value}
|
||||
</Typography.Text>
|
||||
</CopyClipboardHOC>
|
||||
</div>
|
||||
<AttributeActions
|
||||
record={item}
|
||||
isPinned={pinnedAttributes[item.field]}
|
||||
onTogglePin={togglePin}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Attributes.defaultProps = {
|
||||
shouldFocusOnToggle: false,
|
||||
};
|
||||
|
||||
export default Attributes;
|
||||
@@ -1,142 +0,0 @@
|
||||
.events-table {
|
||||
.no-events {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 400px;
|
||||
}
|
||||
.events-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
|
||||
.event {
|
||||
.ant-collapse {
|
||||
border: none;
|
||||
}
|
||||
.ant-collapse-content {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.ant-collapse-item {
|
||||
border-bottom: 0px;
|
||||
}
|
||||
|
||||
.ant-collapse-content-box {
|
||||
border: 1px solid var(--l1-border);
|
||||
border-top: none;
|
||||
}
|
||||
.ant-collapse-header {
|
||||
display: flex;
|
||||
padding: 8px 6px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 18px; /* 128.571% */
|
||||
letter-spacing: -0.07px;
|
||||
|
||||
.ant-collapse-expand-icon {
|
||||
padding-inline-start: 0px;
|
||||
padding-inline-end: 0px;
|
||||
}
|
||||
|
||||
.collapse-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
.diamond {
|
||||
fill: var(--accent-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
.event-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
|
||||
.attribute-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
.attribute-key {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.timestamp-container {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
|
||||
.timestamp-text {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.attribute-value {
|
||||
display: flex;
|
||||
padding: 2px 8px;
|
||||
width: fit-content;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border-radius: 50px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l1-border);
|
||||
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
display: flex;
|
||||
padding: 2px 8px;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border-radius: 50px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l1-border);
|
||||
.attribute-value {
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
.attribute-with-expandable-popover {
|
||||
&__popover {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-width: 50vw;
|
||||
}
|
||||
|
||||
&__preview {
|
||||
max-height: 40vh;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
&__expand-button {
|
||||
align-self: flex-end;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-grow: 0;
|
||||
}
|
||||
|
||||
&__full-view {
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
.no-linked-spans {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 400px;
|
||||
}
|
||||
|
||||
.linked-spans-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
justify-content: flex-start;
|
||||
|
||||
.item-key {
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.value-wrapper {
|
||||
display: flex;
|
||||
padding: 2px 8px;
|
||||
align-items: center;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
gap: 8px;
|
||||
border-radius: 50px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l1-border);
|
||||
|
||||
.item-value {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: 0.56px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { formUrlParams } from 'container/TraceDetail/utils';
|
||||
import { Span } from 'types/api/trace/getTraceV2';
|
||||
import { withBasePath } from 'utils/basePath';
|
||||
|
||||
import NoData from '../NoData/NoData';
|
||||
|
||||
import './LinkedSpans.styles.scss';
|
||||
|
||||
interface LinkedSpansProps {
|
||||
span: Span;
|
||||
}
|
||||
|
||||
interface SpanReference {
|
||||
traceId: string;
|
||||
spanId: string;
|
||||
refType: string;
|
||||
}
|
||||
|
||||
function LinkedSpans(props: LinkedSpansProps): JSX.Element {
|
||||
const { span } = props;
|
||||
|
||||
const getLink = useCallback((item: SpanReference): string | null => {
|
||||
if (!item.traceId || !item.spanId) {
|
||||
return null;
|
||||
}
|
||||
return withBasePath(
|
||||
`${ROUTES.TRACE}/${item.traceId}${formUrlParams({
|
||||
spanId: item.spanId,
|
||||
levelUp: 0,
|
||||
levelDown: 0,
|
||||
})}`,
|
||||
);
|
||||
}, []);
|
||||
|
||||
// Filter out CHILD_OF references as they are parent-child relationships
|
||||
const linkedSpans =
|
||||
span.references?.filter((ref: SpanReference) => ref.refType !== 'CHILD_OF') ||
|
||||
[];
|
||||
|
||||
if (linkedSpans.length === 0) {
|
||||
return (
|
||||
<div className="no-linked-spans">
|
||||
<NoData name="linked spans" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="linked-spans-container">
|
||||
{linkedSpans.map((item: SpanReference) => {
|
||||
const link = getLink(item);
|
||||
return (
|
||||
<div className="item" key={item.spanId}>
|
||||
<Typography.Text className="item-key" truncate={1}>
|
||||
Linked Span ID
|
||||
</Typography.Text>
|
||||
<div className="value-wrapper">
|
||||
<Tooltip title={item.spanId}>
|
||||
{link ? (
|
||||
<Typography.Link href={link} className="item-value" truncate={1}>
|
||||
{item.spanId}
|
||||
</Typography.Link>
|
||||
) : (
|
||||
<Button type="link" className="item-value" disabled>
|
||||
{item.spanId}
|
||||
</Button>
|
||||
)}
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LinkedSpans;
|
||||
@@ -1,20 +0,0 @@
|
||||
.no-data {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-direction: column;
|
||||
|
||||
.no-data-img {
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
}
|
||||
|
||||
.no-data-text {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 18px; /* 128.571% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
@@ -1,687 +0,0 @@
|
||||
.span-details-drawer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 44px); //44px -> trace details top bar
|
||||
border-left: 1px solid var(--l1-border);
|
||||
overflow-y: auto !important;
|
||||
&:not(&-docked) {
|
||||
min-width: 450px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0.1rem;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 48px;
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
|
||||
.heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.dot {
|
||||
height: 8px;
|
||||
width: 8px;
|
||||
border-radius: 2px;
|
||||
background: var(--danger-background);
|
||||
}
|
||||
|
||||
.text {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.description {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 10px 0px;
|
||||
|
||||
.item {
|
||||
padding: 8px 12px;
|
||||
&,
|
||||
.attribute-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
position: relative; // ensure absolutely-positioned children anchor to the row
|
||||
}
|
||||
|
||||
// Show attribute actions on hover for hardcoded rows
|
||||
.attribute-actions-wrapper {
|
||||
display: none;
|
||||
gap: 8px;
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
border-radius: 4px;
|
||||
padding: 2px;
|
||||
|
||||
// style the action button group
|
||||
.action-btn {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
.filter-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
border-radius: 2px;
|
||||
background: var(--l1-border);
|
||||
padding: 4px;
|
||||
gap: 3px;
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
|
||||
&:hover {
|
||||
background: var(--l3-background);
|
||||
}
|
||||
}
|
||||
}
|
||||
&:hover {
|
||||
background-color: var(--l1-border);
|
||||
.attribute-actions-wrapper {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.span-name-wrapper {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.loading-spinner-container {
|
||||
padding: 4px 8px;
|
||||
line-height: 18px; /* 128.571% */
|
||||
letter-spacing: -0.07px;
|
||||
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.span-percentile-value-container {
|
||||
.span-percentile-value {
|
||||
color: var(--bg-sakura-400);
|
||||
font-variant-numeric: lining-nums tabular-nums stacked-fractions
|
||||
slashed-zero;
|
||||
font-feature-settings:
|
||||
'dlig' on,
|
||||
'salt' on;
|
||||
|
||||
border-radius: 0 50px 50px 0;
|
||||
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
min-width: 48px;
|
||||
padding-left: 8px;
|
||||
padding-right: 8px;
|
||||
|
||||
border-left: 1px solid var(--l1-border);
|
||||
|
||||
cursor: pointer;
|
||||
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
word-break: normal;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
&.span-percentile-value-container-open {
|
||||
.span-percentile-value {
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l1-border);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.span-percentiles-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
|
||||
fill: linear-gradient(
|
||||
139deg,
|
||||
color-mix(in srgb, var(--card) 32%, transparent) 0%,
|
||||
color-mix(in srgb, var(--card) 36%, transparent) 98.68%
|
||||
);
|
||||
|
||||
stroke-width: 1px;
|
||||
stroke: var(--l1-border);
|
||||
filter: drop-shadow(2px 4px 16px rgba(0, 0, 0, 0.2));
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 4px;
|
||||
|
||||
.span-percentiles-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 8px 12px 8px 12px;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
|
||||
.span-percentiles-header-text {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.span-percentile-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
|
||||
.span-percentile-content-title {
|
||||
.span-percentile-value {
|
||||
color: var(--bg-sakura-400);
|
||||
font-variant-numeric: lining-nums tabular-nums stacked-fractions
|
||||
slashed-zero;
|
||||
font-feature-settings:
|
||||
'dlig' on,
|
||||
'salt' on;
|
||||
}
|
||||
|
||||
.span-percentile-value-loader {
|
||||
display: inline-flex;
|
||||
align-items: flex-end;
|
||||
justify-content: flex-end;
|
||||
margin-right: 4px;
|
||||
margin-left: 4px;
|
||||
line-height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.span-percentile-timerange {
|
||||
width: 100%;
|
||||
|
||||
.span-percentile-timerange-select {
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.ant-select-selector {
|
||||
border-radius: 50px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l1-background);
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: 0.28px;
|
||||
|
||||
height: 32px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.span-percentile-values-table {
|
||||
.span-percentile-values-table-header-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
|
||||
.span-percentile-values-table-header {
|
||||
color: var(--l2-foreground);
|
||||
text-align: right;
|
||||
font-family: Inter;
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 20px; /* 181.818% */
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
|
||||
.span-percentile-values-table-data-rows {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
.span-percentile-values-table-data-rows-skeleton {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
.ant-skeleton-title {
|
||||
width: 100% !important;
|
||||
margin-top: 0px !important;
|
||||
}
|
||||
|
||||
.ant-skeleton-paragraph {
|
||||
margin-top: 8px;
|
||||
|
||||
& > li + li {
|
||||
margin-top: 10px;
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.span-percentile-values-table-data-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 0px 4px;
|
||||
|
||||
.span-percentile-values-table-data-row-key {
|
||||
flex: 0 0 auto;
|
||||
color: var(--l1-foreground);
|
||||
text-align: right;
|
||||
font-variant-numeric: lining-nums tabular-nums slashed-zero;
|
||||
font-feature-settings:
|
||||
'dlig' on,
|
||||
'salt' on;
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 20px; /* 166.667% */
|
||||
}
|
||||
|
||||
.span-percentile-values-table-data-row-value {
|
||||
color: var(--l2-foreground);
|
||||
font-variant-numeric: lining-nums tabular-nums stacked-fractions
|
||||
slashed-zero;
|
||||
font-feature-settings:
|
||||
'dlig' on,
|
||||
'salt' on,
|
||||
'ss02' on;
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 166.667% */
|
||||
}
|
||||
|
||||
.dashed-line {
|
||||
flex: 1;
|
||||
height: 0; /* line only */
|
||||
margin: 0 8px;
|
||||
border-top: 1px dashed var(--l1-border);
|
||||
|
||||
/* Use border image to control dash length & spacing */
|
||||
border-top-width: 1px;
|
||||
border-top-style: solid; /* temporary solid for image */
|
||||
border-image: repeating-linear-gradient(
|
||||
to right,
|
||||
#1d212d 0,
|
||||
#1d212d 10px,
|
||||
transparent 10px,
|
||||
transparent 20px
|
||||
)
|
||||
1 stretch;
|
||||
}
|
||||
}
|
||||
|
||||
.current-span-percentile-row {
|
||||
border-radius: 2px;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--primary-background) 20%,
|
||||
transparent
|
||||
);
|
||||
|
||||
.span-percentile-values-table-data-row-key {
|
||||
color: var(--text-robin-300);
|
||||
}
|
||||
|
||||
.dashed-line {
|
||||
flex: 1;
|
||||
height: 0; /* line only */
|
||||
margin: 0 8px;
|
||||
border-top: 1px dashed #abbdff;
|
||||
|
||||
/* Use border image to control dash length & spacing */
|
||||
border-top-width: 1px;
|
||||
border-top-style: solid; /* temporary solid for image */
|
||||
border-image: repeating-linear-gradient(
|
||||
to right,
|
||||
#abbdff 0,
|
||||
#abbdff 10px,
|
||||
transparent 10px,
|
||||
transparent 20px
|
||||
)
|
||||
1 stretch;
|
||||
}
|
||||
|
||||
.span-percentile-values-table-data-row-value {
|
||||
color: var(--text-robin-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.resource-attributes-select-container {
|
||||
overflow: hidden;
|
||||
width: calc(100% + 16px);
|
||||
|
||||
position: absolute;
|
||||
top: 32px;
|
||||
left: -8px;
|
||||
z-index: 1000;
|
||||
|
||||
.resource-attributes-select-container-header {
|
||||
.resource-attributes-select-container-input {
|
||||
border-radius: 0px;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
height: 36px;
|
||||
|
||||
border-bottom: 1px solid var(--l1-border) !important;
|
||||
}
|
||||
}
|
||||
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: linear-gradient(139deg, var(--card) 0%, var(--card) 98.68%);
|
||||
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
|
||||
backdrop-filter: blur(20px);
|
||||
|
||||
.ant-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.resource-attributes-items {
|
||||
height: 200px;
|
||||
overflow-y: auto;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0.3rem;
|
||||
height: 0.3rem;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--l3-background);
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--l3-background);
|
||||
}
|
||||
}
|
||||
|
||||
.resource-attributes-select-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px 8px 12px;
|
||||
|
||||
.resource-attributes-select-item-checkbox {
|
||||
.ant-checkbox-disabled {
|
||||
background-color: var(--primary-background);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.resource-attributes-select-item-value {
|
||||
color: var(--l1-foreground);
|
||||
|
||||
font-family: Inter;
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.attribute-key {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 18px; /* 163.636% */
|
||||
letter-spacing: 0.44px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.attribute-container .wrapper,
|
||||
.value-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
border-radius: 50px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l1-border);
|
||||
|
||||
.attribute-value {
|
||||
padding: 2px 8px;
|
||||
color: var(--l2-foreground);
|
||||
font-family: 'Inter';
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
width: 100%;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: 0.28px;
|
||||
}
|
||||
}
|
||||
|
||||
.service {
|
||||
display: flex;
|
||||
padding: 2px 8px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border-radius: 50px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l1-border);
|
||||
width: fit-content;
|
||||
|
||||
.dot {
|
||||
height: 4px;
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.value-wrapper {
|
||||
display: flex;
|
||||
padding: 0px;
|
||||
align-items: center;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
border-radius: 0px;
|
||||
border: none;
|
||||
background: var(--l1-border);
|
||||
|
||||
.service-value {
|
||||
color: var(--l2-foreground);
|
||||
font-family: 'Inter';
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: 0.28px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.related-signals-section {
|
||||
.view-title {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
line-height: 30px;
|
||||
}
|
||||
.ant-btn.ant-btn-default {
|
||||
padding: 0 15px;
|
||||
&:not(:hover) {
|
||||
border: 1px solid var(--l1-border);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.attributes-events {
|
||||
.details-drawer-tabs {
|
||||
.ant-tabs-extra-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.search-icon {
|
||||
width: 33px;
|
||||
padding-right: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-tabs-nav::before {
|
||||
border-bottom: 1px solid var(--l1-border) !important;
|
||||
}
|
||||
|
||||
.ant-tabs-tab {
|
||||
margin: 0 !important;
|
||||
padding: 0 2px !important;
|
||||
min-width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.attributes-tab-btn,
|
||||
.events-tab-btn,
|
||||
.linked-spans-tab-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
padding: 4px 8px;
|
||||
margin-right: 8px;
|
||||
gap: 4px;
|
||||
|
||||
.tab-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.count-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
padding: 0 6px;
|
||||
border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--bg-robin-200) 10%, transparent);
|
||||
color: var(--l2-foreground);
|
||||
font-variant-numeric: lining-nums tabular-nums slashed-zero;
|
||||
font-feature-settings:
|
||||
'dlig' on,
|
||||
'salt' on;
|
||||
font-family: Inter;
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.065px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
|
||||
.attributes-tab-btn:hover,
|
||||
.events-tab-btn:hover,
|
||||
.linked-spans-tab-btn:hover {
|
||||
background: unset;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.span-percentile-tooltip {
|
||||
.ant-tooltip-content {
|
||||
width: 300px;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.span-percentile-tooltip-text {
|
||||
color: var(--l2-foreground);
|
||||
font-variant-numeric: lining-nums tabular-nums stacked-fractions ordinal
|
||||
slashed-zero;
|
||||
font-feature-settings:
|
||||
'dlig' on,
|
||||
'salt' on;
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 166.667% */
|
||||
letter-spacing: -0.06px;
|
||||
|
||||
.span-percentile-tooltip-text-percentile {
|
||||
color: var(--text-sakura-500);
|
||||
font-variant-numeric: lining-nums tabular-nums stacked-fractions slashed-zero;
|
||||
font-feature-settings:
|
||||
'dlig' on,
|
||||
'salt' on;
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.span-percentile-tooltip-text-link {
|
||||
color: var(--l2-foreground);
|
||||
text-align: right;
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 20px; /* 166.667% */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.span-details-drawer-docked {
|
||||
width: 48px;
|
||||
flex: 0 48px !important;
|
||||
|
||||
.header {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.resizable-handle {
|
||||
box-sizing: border-box;
|
||||
border: 2px solid transparent;
|
||||
&:hover,
|
||||
&[data-resize-handle-state='drag'],
|
||||
&[data-resize-handle-state='hover'] {
|
||||
border-color: color-mix(in srgb, var(--bg-aqua-500) 20%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.linked-spans-tab-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,76 +0,0 @@
|
||||
.span-logs {
|
||||
margin-inline: 16px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&-virtuoso {
|
||||
background: color-mix(in srgb, var(--bg-robin-200) 4%, transparent);
|
||||
}
|
||||
&-list-container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
|
||||
.logs-loading-skeleton {
|
||||
height: 100%;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-top: none;
|
||||
color: var(--l2-foreground);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 8px 0;
|
||||
}
|
||||
}
|
||||
|
||||
&-empty-content {
|
||||
height: 100%;
|
||||
border-top: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding-top: 96px;
|
||||
gap: 12px;
|
||||
|
||||
.description {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
width: 320px;
|
||||
|
||||
.no-data-img {
|
||||
height: 2rem;
|
||||
width: 2rem;
|
||||
}
|
||||
|
||||
.no-data-text-1 {
|
||||
color: var(--l2-foreground);
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
.no-data-text-2 {
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.action-section {
|
||||
width: 320px;
|
||||
|
||||
.action-btn {
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l1-border);
|
||||
color: var(--l2-foreground);
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
.span-related-signals-drawer {
|
||||
.ant-drawer-body {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.ant-drawer-header {
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
padding: 16px 15px;
|
||||
.title {
|
||||
color: var(--l2-foreground);
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
|
||||
&__divider {
|
||||
--divider-vertical-margin: 10px;
|
||||
}
|
||||
.ant-drawer-close {
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.span-related-signals-drawer__content {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.view-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.views-tabs-container {
|
||||
padding: 16px 15px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
.open-in-explorer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 30px;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l3-background);
|
||||
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.ant-radio-button-wrapper {
|
||||
width: 114px;
|
||||
height: 32px;
|
||||
|
||||
.view-title {
|
||||
gap: 6px;
|
||||
color: var(--l1-foreground);
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
letter-spacing: -0.06px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.span-related-signals-drawer__applied-filters {
|
||||
padding: 11px;
|
||||
margin-inline: 16px;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.span-related-signals-drawer__filters-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.span-related-signals-drawer__filter-tag {
|
||||
padding: 2px 6px;
|
||||
border-radius: 2px;
|
||||
background: var(--l3-background);
|
||||
cursor: default;
|
||||
|
||||
.ant-typography {
|
||||
color: var(--l2-foreground);
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
|
||||
.infra-metrics-container {
|
||||
padding-inline: 16px;
|
||||
.infra-metrics-card {
|
||||
border: 1px solid var(--l1-border);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,257 +0,0 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Button, Drawer } from 'antd';
|
||||
import { Divider } from '@signozhq/ui/divider';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import LogsIcon from 'assets/AlertHistory/LogsIcon';
|
||||
import SignozRadioGroup from 'components/SignozRadioGroup/SignozRadioGroup';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import {
|
||||
initialQueryBuilderFormValuesMap,
|
||||
initialQueryState,
|
||||
} from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import InfraMetrics from 'container/LogDetailedView/InfraMetrics/InfraMetrics';
|
||||
import { getEmptyLogsListConfig } from 'container/LogsExplorerList/utils';
|
||||
import dayjs from 'dayjs';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { BarChart, Compass, X } from '@signozhq/icons';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { Span } from 'types/api/trace/getTraceV2';
|
||||
import { DataSource, LogsAggregatorOperator } from 'types/common/queryBuilder';
|
||||
import { getAbsoluteUrl } from 'utils/basePath';
|
||||
|
||||
import { RelatedSignalsViews } from '../constants';
|
||||
import SpanLogs from '../SpanLogs/SpanLogs';
|
||||
import { useSpanContextLogs } from '../SpanLogs/useSpanContextLogs';
|
||||
import { hasInfraMetadata } from '../utils';
|
||||
|
||||
import './SpanRelatedSignals.styles.scss';
|
||||
|
||||
const FIVE_MINUTES_IN_MS = 5 * 60 * 1000;
|
||||
|
||||
interface SpanRelatedSignalsProps {
|
||||
selectedSpan: Span;
|
||||
traceStartTime: number;
|
||||
traceEndTime: number;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
initialView: RelatedSignalsViews;
|
||||
}
|
||||
|
||||
function SpanRelatedSignals({
|
||||
selectedSpan,
|
||||
traceStartTime,
|
||||
traceEndTime,
|
||||
isOpen,
|
||||
onClose,
|
||||
initialView,
|
||||
}: SpanRelatedSignalsProps): JSX.Element {
|
||||
const [selectedView, setSelectedView] =
|
||||
useState<RelatedSignalsViews>(initialView);
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
// Extract infrastructure metadata from span attributes
|
||||
const infraMetadata = useMemo(() => {
|
||||
// Only return metadata if span has infrastructure metadata
|
||||
if (!hasInfraMetadata(selectedSpan)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
clusterName: selectedSpan.tagMap['k8s.cluster.name'] || '',
|
||||
podName: selectedSpan.tagMap['k8s.pod.name'] || '',
|
||||
nodeName: selectedSpan.tagMap['k8s.node.name'] || '',
|
||||
hostName: selectedSpan.tagMap['host.name'] || '',
|
||||
spanTimestamp: dayjs(selectedSpan.timestamp).format(),
|
||||
};
|
||||
}, [selectedSpan]);
|
||||
const {
|
||||
logs,
|
||||
isLoading,
|
||||
isError,
|
||||
isFetching,
|
||||
isLogSpanRelated,
|
||||
hasTraceIdLogs,
|
||||
} = useSpanContextLogs({
|
||||
traceId: selectedSpan.traceId,
|
||||
spanId: selectedSpan.spanId,
|
||||
timeRange: {
|
||||
startTime: traceStartTime - FIVE_MINUTES_IN_MS,
|
||||
endTime: traceEndTime + FIVE_MINUTES_IN_MS,
|
||||
},
|
||||
isDrawerOpen: isOpen,
|
||||
});
|
||||
|
||||
const handleTabChange = useCallback((value: string): void => {
|
||||
setSelectedView(value as RelatedSignalsViews);
|
||||
}, []);
|
||||
|
||||
const tabOptions = useMemo(() => {
|
||||
const baseOptions = [
|
||||
{
|
||||
label: (
|
||||
<div className="view-title">
|
||||
<LogsIcon width={14} height={14} />
|
||||
Logs
|
||||
</div>
|
||||
),
|
||||
value: RelatedSignalsViews.LOGS,
|
||||
},
|
||||
];
|
||||
|
||||
// Add Infra option if infrastructure metadata is available
|
||||
if (infraMetadata) {
|
||||
baseOptions.push({
|
||||
label: (
|
||||
<div className="view-title">
|
||||
<BarChart size={14} />
|
||||
Metrics
|
||||
</div>
|
||||
),
|
||||
value: RelatedSignalsViews.INFRA,
|
||||
});
|
||||
}
|
||||
|
||||
return baseOptions;
|
||||
}, [infraMetadata]);
|
||||
|
||||
const handleExplorerPageRedirect = useCallback((): void => {
|
||||
const startTimeMs = traceStartTime - FIVE_MINUTES_IN_MS;
|
||||
const endTimeMs = traceEndTime + FIVE_MINUTES_IN_MS;
|
||||
|
||||
const traceIdFilter = {
|
||||
op: 'AND',
|
||||
items: [
|
||||
{
|
||||
id: 'trace-id-filter',
|
||||
key: {
|
||||
key: 'trace_id',
|
||||
id: 'trace-id-key',
|
||||
dataType: 'string' as const,
|
||||
isColumn: true,
|
||||
type: '',
|
||||
isJSON: false,
|
||||
} as BaseAutocompleteData,
|
||||
op: '=',
|
||||
value: selectedSpan.traceId,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const compositeQuery = {
|
||||
...initialQueryState,
|
||||
queryType: 'builder',
|
||||
builder: {
|
||||
...initialQueryState.builder,
|
||||
queryData: [
|
||||
{
|
||||
...initialQueryBuilderFormValuesMap.logs,
|
||||
aggregateOperator: LogsAggregatorOperator.NOOP,
|
||||
filters: traceIdFilter,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set(QueryParams.compositeQuery, JSON.stringify(compositeQuery));
|
||||
searchParams.set(QueryParams.startTime, startTimeMs.toString());
|
||||
searchParams.set(QueryParams.endTime, endTimeMs.toString());
|
||||
|
||||
window.open(
|
||||
getAbsoluteUrl(`${ROUTES.LOGS_EXPLORER}?${searchParams.toString()}`),
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
);
|
||||
}, [selectedSpan.traceId, traceStartTime, traceEndTime]);
|
||||
|
||||
const emptyStateConfig = useMemo(
|
||||
() => ({
|
||||
...getEmptyLogsListConfig(() => {}),
|
||||
showClearFiltersButton: false,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
width="50%"
|
||||
title={
|
||||
<>
|
||||
<Divider
|
||||
type="vertical"
|
||||
className="span-related-signals-drawer__divider"
|
||||
/>
|
||||
<Typography.Text className="title">
|
||||
Related Signals - {selectedSpan.name}
|
||||
</Typography.Text>
|
||||
</>
|
||||
}
|
||||
placement="right"
|
||||
onClose={onClose}
|
||||
open={isOpen}
|
||||
style={{
|
||||
overscrollBehavior: 'contain',
|
||||
background: isDarkMode ? Color.BG_INK_400 : Color.BG_VANILLA_100,
|
||||
}}
|
||||
className="span-related-signals-drawer"
|
||||
destroyOnClose
|
||||
closeIcon={<X size={16} />}
|
||||
>
|
||||
{selectedSpan && (
|
||||
<div className="span-related-signals-drawer__content">
|
||||
<div className="views-tabs-container">
|
||||
<SignozRadioGroup
|
||||
value={selectedView}
|
||||
options={tabOptions}
|
||||
onChange={handleTabChange}
|
||||
className="related-signals-radio"
|
||||
/>
|
||||
{selectedView === RelatedSignalsViews.LOGS && (
|
||||
<Button
|
||||
icon={<Compass size={18} />}
|
||||
className="open-in-explorer"
|
||||
onClick={handleExplorerPageRedirect}
|
||||
data-testid="open-in-explorer-button"
|
||||
>
|
||||
Open in Logs Explorer
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedView === RelatedSignalsViews.LOGS && (
|
||||
<SpanLogs
|
||||
traceId={selectedSpan.traceId}
|
||||
spanId={selectedSpan.spanId}
|
||||
timeRange={{
|
||||
startTime: traceStartTime - FIVE_MINUTES_IN_MS,
|
||||
endTime: traceEndTime + FIVE_MINUTES_IN_MS,
|
||||
}}
|
||||
logs={logs}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
isFetching={isFetching}
|
||||
isLogSpanRelated={isLogSpanRelated}
|
||||
handleExplorerPageRedirect={handleExplorerPageRedirect}
|
||||
emptyStateConfig={!hasTraceIdLogs ? emptyStateConfig : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedView === RelatedSignalsViews.INFRA && infraMetadata && (
|
||||
<InfraMetrics
|
||||
clusterName={infraMetadata.clusterName}
|
||||
podName={infraMetadata.podName}
|
||||
nodeName={infraMetadata.nodeName}
|
||||
hostName={infraMetadata.hostName}
|
||||
timestamp={infraMetadata.spanTimestamp}
|
||||
dataSource={DataSource.TRACES}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
export default SpanRelatedSignals;
|
||||
@@ -1,240 +0,0 @@
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
userEvent,
|
||||
waitFor,
|
||||
} from 'tests/test-utils';
|
||||
|
||||
import AttributeActions from '../Attributes/AttributeActions';
|
||||
|
||||
// Mock only Popover from antd to simplify hover/open behavior while keeping other components real
|
||||
jest.mock('antd', () => {
|
||||
const actual = jest.requireActual('antd');
|
||||
const MockPopover = ({
|
||||
content,
|
||||
children,
|
||||
open,
|
||||
onOpenChange,
|
||||
...rest
|
||||
}: any): JSX.Element => (
|
||||
<div
|
||||
data-testid="mock-popover-wrapper"
|
||||
onMouseEnter={(): void => onOpenChange?.(true)}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
{open ? <div data-testid="mock-popover-content">{content}</div> : null}
|
||||
</div>
|
||||
);
|
||||
return { ...actual, Popover: MockPopover };
|
||||
});
|
||||
|
||||
// Mock getAggregateKeys API used inside useTraceActions to resolve autocomplete keys
|
||||
jest.mock('api/queryBuilder/getAttributeKeys', () => ({
|
||||
getAggregateKeys: jest.fn().mockResolvedValue({
|
||||
payload: {
|
||||
attributeKeys: [
|
||||
{
|
||||
key: 'http.method',
|
||||
dataType: 'string',
|
||||
type: 'tag',
|
||||
isColumn: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const record = { field: 'http.method', value: 'GET' };
|
||||
|
||||
describe('AttributeActions (unit)', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders core action buttons (pin, filter in/out, more)', async () => {
|
||||
render(<AttributeActions record={record} isPinned={false} />);
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Pin attribute' }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Filter for value' }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Filter out value' }),
|
||||
).toBeInTheDocument();
|
||||
// more actions (ellipsis) button
|
||||
expect(screen.getByTestId('attribute-actions-more')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies "Filter for" and calls redirectWithQueryBuilderData with correct query', async () => {
|
||||
const redirectWithQueryBuilderData = jest.fn();
|
||||
const currentQuery = {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
aggregateOperator: 'count',
|
||||
aggregateAttribute: { key: 'signoz_span_duration' },
|
||||
filters: { items: [], op: 'AND' },
|
||||
filter: { expression: '' },
|
||||
groupBy: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
} as any;
|
||||
|
||||
render(<AttributeActions record={record} />, undefined, {
|
||||
queryBuilderOverrides: { currentQuery, redirectWithQueryBuilderData },
|
||||
});
|
||||
|
||||
const filterForBtn = screen.getByRole('button', { name: 'Filter for value' });
|
||||
|
||||
await userEvent.click(filterForBtn);
|
||||
await waitFor(() => {
|
||||
expect(redirectWithQueryBuilderData).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
builder: expect.objectContaining({
|
||||
queryData: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
filters: expect.objectContaining({
|
||||
items: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
key: expect.objectContaining({ key: 'http.method' }),
|
||||
op: '=',
|
||||
value: 'GET',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
{},
|
||||
expect.any(String),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('applies "Filter out" and calls redirectWithQueryBuilderData with correct query', async () => {
|
||||
const redirectWithQueryBuilderData = jest.fn();
|
||||
const currentQuery = {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
aggregateOperator: 'count',
|
||||
aggregateAttribute: { key: 'signoz_span_duration' },
|
||||
filters: { items: [], op: 'AND' },
|
||||
filter: { expression: '' },
|
||||
groupBy: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
} as any;
|
||||
|
||||
render(<AttributeActions record={record} />, undefined, {
|
||||
queryBuilderOverrides: { currentQuery, redirectWithQueryBuilderData },
|
||||
});
|
||||
|
||||
const filterOutBtn = screen.getByRole('button', { name: 'Filter out value' });
|
||||
|
||||
await userEvent.click(filterOutBtn);
|
||||
await waitFor(() => {
|
||||
expect(redirectWithQueryBuilderData).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
builder: expect.objectContaining({
|
||||
queryData: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
filters: expect.objectContaining({
|
||||
items: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
key: expect.objectContaining({ key: 'http.method' }),
|
||||
op: '!=',
|
||||
value: 'GET',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
{},
|
||||
expect.any(String),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('opens more actions on hover and calls Group By handler; closes after click', async () => {
|
||||
const redirectWithQueryBuilderData = jest.fn();
|
||||
const currentQuery = {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
aggregateOperator: 'count',
|
||||
aggregateAttribute: { key: 'signoz_span_duration' },
|
||||
filters: { items: [], op: 'AND' },
|
||||
filter: { expression: '' },
|
||||
groupBy: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
} as any;
|
||||
render(<AttributeActions record={record} />, undefined, {
|
||||
queryBuilderOverrides: { currentQuery, redirectWithQueryBuilderData },
|
||||
});
|
||||
|
||||
const ellipsisBtn = screen.getByTestId('attribute-actions-more');
|
||||
expect(ellipsisBtn).toBeInTheDocument();
|
||||
|
||||
// hover to trigger Popover open via mock
|
||||
fireEvent.mouseEnter(ellipsisBtn.parentElement as Element);
|
||||
|
||||
// content appears
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('Group By Attribute')).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByText('Group By Attribute'));
|
||||
await waitFor(() => {
|
||||
expect(redirectWithQueryBuilderData).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
builder: expect.objectContaining({
|
||||
queryData: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
groupBy: expect.arrayContaining([
|
||||
expect.objectContaining({ key: 'http.method' }),
|
||||
]),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
{},
|
||||
expect.any(String),
|
||||
);
|
||||
});
|
||||
|
||||
// After clicking group by, popover should close
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId('mock-popover-content')).not.toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it('hides pin button when showPinned=false', async () => {
|
||||
render(<AttributeActions record={record} showPinned={false} />);
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /pin attribute/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides copy options when showCopyOptions=false', async () => {
|
||||
render(<AttributeActions record={record} showCopyOptions={false} />);
|
||||
const ellipsisBtn = screen.getByTestId('attribute-actions-more');
|
||||
fireEvent.mouseEnter(ellipsisBtn.parentElement as Element);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText('Copy Field Name')).not.toBeInTheDocument(),
|
||||
);
|
||||
expect(screen.queryByText('Copy Field Value')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,383 +0,0 @@
|
||||
import { MemoryRouter, Route } from 'react-router-dom';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { SPAN_ATTRIBUTES } from 'container/ApiMonitoring/Explorer/Domains/DomainDetails/constants';
|
||||
import { AppProvider } from 'providers/App/App';
|
||||
import MockQueryClientProvider from 'providers/test/MockQueryClientProvider';
|
||||
import { Span } from 'types/api/trace/getTraceV2';
|
||||
|
||||
import SpanDetailsDrawer from '../SpanDetailsDrawer';
|
||||
|
||||
// Mock external dependencies
|
||||
const mockRedirectWithQueryBuilderData = jest.fn();
|
||||
const mockNotifications = {
|
||||
success: jest.fn(),
|
||||
error: jest.fn(),
|
||||
};
|
||||
const mockSetCopy = jest.fn();
|
||||
const mockQueryClient = {
|
||||
fetchQuery: jest.fn(),
|
||||
};
|
||||
|
||||
jest.mock('uplot', () => {
|
||||
const paths = {
|
||||
spline: jest.fn(),
|
||||
bars: jest.fn(),
|
||||
};
|
||||
const uplotMock = jest.fn(() => ({
|
||||
paths,
|
||||
}));
|
||||
return {
|
||||
paths,
|
||||
default: uplotMock,
|
||||
};
|
||||
});
|
||||
|
||||
// Mock the hooks
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: (): any => ({
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
aggregateOperator: 'count',
|
||||
aggregateAttribute: { key: 'signoz_span_duration' },
|
||||
filters: { items: [], op: 'AND' },
|
||||
filter: { expression: '' },
|
||||
groupBy: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
redirectWithQueryBuilderData: mockRedirectWithQueryBuilderData,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useNotifications', () => ({
|
||||
useNotifications: (): any => ({ notifications: mockNotifications }),
|
||||
}));
|
||||
|
||||
jest.mock('react-use', () => ({
|
||||
...jest.requireActual('react-use'),
|
||||
useCopyToClipboard: (): any => [{ value: '' }, mockSetCopy],
|
||||
}));
|
||||
|
||||
jest.mock('react-query', () => ({
|
||||
...jest.requireActual('react-query'),
|
||||
useQueryClient: (): any => mockQueryClient,
|
||||
}));
|
||||
|
||||
jest.mock('@signozhq/ui/sonner', () => ({
|
||||
...jest.requireActual('@signozhq/ui/sonner'),
|
||||
toast: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock the API response for getAggregateKeys
|
||||
const mockAggregateKeysResponse = {
|
||||
payload: {
|
||||
attributeKeys: [
|
||||
{
|
||||
key: 'http.method',
|
||||
dataType: 'string',
|
||||
type: 'tag',
|
||||
isColumn: true,
|
||||
},
|
||||
{
|
||||
key: 'service.name',
|
||||
dataType: 'string',
|
||||
type: 'resource',
|
||||
isColumn: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockQueryClient.fetchQuery.mockResolvedValue(mockAggregateKeysResponse);
|
||||
});
|
||||
|
||||
// Mock trace data with realistic span attributes
|
||||
const createMockSpan = (): Span => ({
|
||||
spanId: '28a8a67365d0bd8b',
|
||||
traceId: '000000000000000071dc9b0a338729b4',
|
||||
name: 'HTTP GET /api/users',
|
||||
timestamp: 1699872000000000,
|
||||
durationNano: 150000000,
|
||||
serviceName: 'frontend-service',
|
||||
spanKind: 'server',
|
||||
statusCodeString: 'OK',
|
||||
statusMessage: '',
|
||||
tagMap: {
|
||||
'http.method': 'GET',
|
||||
[SPAN_ATTRIBUTES.HTTP_URL]: '/api/users?page=1',
|
||||
'http.status_code': '200',
|
||||
'service.name': 'frontend-service',
|
||||
'span.kind': 'server',
|
||||
'user.id': '12345',
|
||||
'request.id': 'req-abc-123',
|
||||
},
|
||||
event: [],
|
||||
references: [],
|
||||
hasError: false,
|
||||
rootSpanId: '',
|
||||
parentSpanId: '',
|
||||
kind: 0,
|
||||
rootName: '',
|
||||
hasChildren: false,
|
||||
hasSibling: false,
|
||||
subTreeNodeCount: 0,
|
||||
level: 0,
|
||||
});
|
||||
const renderSpanDetailsDrawer = (span: Span = createMockSpan()): any => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
const component = render(
|
||||
<MockQueryClientProvider>
|
||||
<AppProvider>
|
||||
<MemoryRouter>
|
||||
<Route>
|
||||
<SpanDetailsDrawer
|
||||
isSpanDetailsDocked={false}
|
||||
setIsSpanDetailsDocked={jest.fn()}
|
||||
selectedSpan={span}
|
||||
traceStartTime={span.timestamp}
|
||||
traceEndTime={span.timestamp + span.durationNano}
|
||||
/>
|
||||
</Route>
|
||||
</MemoryRouter>
|
||||
</AppProvider>
|
||||
</MockQueryClientProvider>,
|
||||
);
|
||||
|
||||
return { ...component, user };
|
||||
};
|
||||
|
||||
describe('AttributeActions User Flow Tests', () => {
|
||||
// Todo: to fixed properly - failing with - due to timeout > 5000ms
|
||||
describe.skip('Complete Attribute Actions User Flow', () => {
|
||||
it('should allow user to interact with span attribute actions from trace detail page', async () => {
|
||||
renderSpanDetailsDrawer();
|
||||
|
||||
// Verify Attributes tab is displayed with table view
|
||||
expect(screen.getByText('Attributes')).toBeInTheDocument();
|
||||
|
||||
// Verify attributes are displayed
|
||||
expect(screen.getByText('http.method')).toBeInTheDocument();
|
||||
expect(screen.getByText('GET')).toBeInTheDocument();
|
||||
expect(screen.getByText('service.name')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('frontend-service')[0]).toBeInTheDocument();
|
||||
|
||||
// Find an attribute row to test actions on
|
||||
const httpMethodRow = screen.getByText('http.method').closest('.item');
|
||||
expect(httpMethodRow).toBeInTheDocument();
|
||||
|
||||
// Action buttons are always mounted in the DOM (only CSS-hidden until :hover),
|
||||
// so we can query them directly without simulating a pointer hover.
|
||||
const actionButtons = httpMethodRow!.querySelector('.action-btn');
|
||||
expect(actionButtons).toBeInTheDocument();
|
||||
|
||||
const filterForButton = httpMethodRow!.querySelector(
|
||||
'[aria-label="Filter for value"]',
|
||||
) as HTMLElement;
|
||||
const filterOutButton = httpMethodRow!.querySelector(
|
||||
'[aria-label="Filter out value"]',
|
||||
) as HTMLElement;
|
||||
expect(filterForButton).toBeInTheDocument();
|
||||
expect(filterOutButton).toBeInTheDocument();
|
||||
|
||||
// Test "Filter for" action — use fireEvent to skip userEvent's pointer
|
||||
// simulation and the Antd Tooltip mouseEnterDelay timers it triggers.
|
||||
fireEvent.click(filterForButton);
|
||||
|
||||
// Verify navigation to traces explorer with inclusive filter
|
||||
await waitFor(() => {
|
||||
expect(mockRedirectWithQueryBuilderData).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
builder: expect.objectContaining({
|
||||
queryData: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
dataSource: 'traces',
|
||||
filters: expect.objectContaining({
|
||||
items: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
key: expect.objectContaining({ key: 'http.method' }),
|
||||
op: '=',
|
||||
value: 'GET',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
{},
|
||||
ROUTES.TRACES_EXPLORER,
|
||||
);
|
||||
});
|
||||
|
||||
// Reset mock for next test
|
||||
mockRedirectWithQueryBuilderData.mockClear();
|
||||
|
||||
// Test "Filter out" action
|
||||
fireEvent.click(filterOutButton);
|
||||
|
||||
// Verify navigation to traces explorer with exclusive filter
|
||||
await waitFor(() => {
|
||||
expect(mockRedirectWithQueryBuilderData).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
builder: expect.objectContaining({
|
||||
queryData: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
dataSource: 'traces',
|
||||
filters: expect.objectContaining({
|
||||
items: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
key: expect.objectContaining({ key: 'http.method' }),
|
||||
op: '!=',
|
||||
value: 'GET',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
{},
|
||||
ROUTES.TRACES_EXPLORER,
|
||||
);
|
||||
});
|
||||
|
||||
// Verify more actions button exists (popover functionality is tested in unit tests)
|
||||
const moreActionsButton = httpMethodRow!
|
||||
.querySelector('.lucide-ellipsis')
|
||||
?.closest('button');
|
||||
expect(moreActionsButton).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// Todo: to fixed properly - failing with - due to timeout > 5000ms
|
||||
describe.skip('Filter Replacement Flow', () => {
|
||||
it('should replace previous filter when applying multiple filters on same field', async () => {
|
||||
renderSpanDetailsDrawer();
|
||||
|
||||
// Find the http.method attribute row
|
||||
const httpMethodRow = screen.getByText('http.method').closest('.item');
|
||||
expect(httpMethodRow).toBeInTheDocument();
|
||||
|
||||
// Action buttons are always mounted (CSS-hidden until :hover, which jsdom
|
||||
// doesn't evaluate), so we can click them directly via fireEvent and skip
|
||||
// userEvent's pointer simulation + Antd Tooltip mouseEnterDelay timers.
|
||||
const filterForButton = httpMethodRow!.querySelector(
|
||||
'[aria-label="Filter for value"]',
|
||||
) as HTMLElement;
|
||||
expect(filterForButton).toBeInTheDocument();
|
||||
fireEvent.click(filterForButton);
|
||||
|
||||
// Verify first filter was applied
|
||||
await waitFor(() => {
|
||||
expect(mockRedirectWithQueryBuilderData).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
builder: expect.objectContaining({
|
||||
queryData: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
filters: expect.objectContaining({
|
||||
items: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
key: expect.objectContaining({ key: 'http.method' }),
|
||||
op: '=',
|
||||
value: 'GET',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
{},
|
||||
ROUTES.TRACES_EXPLORER,
|
||||
);
|
||||
});
|
||||
|
||||
// Reset and simulate existing filter in current query
|
||||
mockRedirectWithQueryBuilderData.mockClear();
|
||||
|
||||
// Apply second filter on same field (should replace, not accumulate)
|
||||
const filterOutButton = httpMethodRow!.querySelector(
|
||||
'[aria-label="Filter out value"]',
|
||||
) as HTMLElement;
|
||||
expect(filterOutButton).toBeInTheDocument();
|
||||
fireEvent.click(filterOutButton);
|
||||
|
||||
// Verify the new call contains only the new filter (replacement behavior)
|
||||
await waitFor(() => {
|
||||
const lastCall =
|
||||
mockRedirectWithQueryBuilderData.mock.calls[
|
||||
mockRedirectWithQueryBuilderData.mock.calls.length - 1
|
||||
];
|
||||
const queryData = lastCall[0].builder.queryData[0];
|
||||
const httpMethodFilters = queryData.filters.items.filter(
|
||||
(item: any) => item.key.key === 'http.method',
|
||||
);
|
||||
|
||||
// Should have only one filter for http.method (the new one)
|
||||
expect(httpMethodFilters).toHaveLength(1);
|
||||
expect(httpMethodFilters[0].op).toBe('!=');
|
||||
expect(httpMethodFilters[0].value).toBe('GET');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle attributes with special characters and JSON values', async () => {
|
||||
const spanWithSpecialAttrs = createMockSpan();
|
||||
spanWithSpecialAttrs.tagMap = {
|
||||
'request.headers.content-type': 'application/json',
|
||||
'response.body': '{"status":"success","data":[]}',
|
||||
'trace.annotation': '"quoted_string_value"',
|
||||
};
|
||||
|
||||
const { user } = renderSpanDetailsDrawer(spanWithSpecialAttrs);
|
||||
|
||||
// Test attribute with dashes
|
||||
expect(screen.getByText('request.headers.content-type')).toBeInTheDocument();
|
||||
expect(screen.getByText('application/json')).toBeInTheDocument();
|
||||
|
||||
// Test JSON value
|
||||
expect(screen.getByText('response.body')).toBeInTheDocument();
|
||||
|
||||
// Test quoted string value - should remove surrounding quotes when copying
|
||||
const quotedAttrRow = screen.getByText('trace.annotation').closest('.item');
|
||||
await user.hover(quotedAttrRow!);
|
||||
|
||||
const actionButtons = quotedAttrRow!.querySelectorAll('.action-btn button');
|
||||
const moreActionsButton = actionButtons[actionButtons.length - 1];
|
||||
await user.hover(moreActionsButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Copy Field Value')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const copyFieldValueButton = screen.getByText('Copy Field Value');
|
||||
fireEvent.click(copyFieldValueButton);
|
||||
|
||||
// Verify quotes are stripped from copied value
|
||||
await waitFor(() => {
|
||||
expect(mockSetCopy).toHaveBeenCalledWith('quoted_string_value');
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty attributes gracefully', async () => {
|
||||
const spanWithNoAttrs = createMockSpan();
|
||||
spanWithNoAttrs.tagMap = {};
|
||||
|
||||
renderSpanDetailsDrawer(spanWithNoAttrs);
|
||||
|
||||
// Verify no attributes message is displayed
|
||||
expect(
|
||||
screen.getByText('No attributes found for selected span'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,495 +0,0 @@
|
||||
import ROUTES from 'constants/routes';
|
||||
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { QueryBuilderContext } from 'providers/QueryBuilder';
|
||||
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import SpanDetailsDrawer from '../SpanDetailsDrawer';
|
||||
import {
|
||||
expectedHostOnlyMetadata,
|
||||
expectedInfraMetadata,
|
||||
expectedNodeOnlyMetadata,
|
||||
expectedPodOnlyMetadata,
|
||||
mockEmptyMetricsResponse,
|
||||
mockNodeMetricsResponse,
|
||||
mockPodMetricsResponse,
|
||||
mockSpanWithHostOnly,
|
||||
mockSpanWithInfraMetadata,
|
||||
mockSpanWithNodeOnly,
|
||||
mockSpanWithoutInfraMetadata,
|
||||
mockSpanWithPodOnly,
|
||||
} from './infraMetricsTestData';
|
||||
|
||||
// Mock external dependencies
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: (): { pathname: string } => ({
|
||||
pathname: `${ROUTES.TRACE_DETAIL}`,
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockSafeNavigate = jest.fn();
|
||||
jest.mock('hooks/useSafeNavigate', () => ({
|
||||
useSafeNavigate: (): any => ({
|
||||
safeNavigate: mockSafeNavigate,
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockUpdateAllQueriesOperators = jest.fn().mockReturnValue({
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
dataSource: 'logs',
|
||||
queryName: 'A',
|
||||
aggregateOperator: 'noop',
|
||||
filters: { items: [], op: 'AND' },
|
||||
expression: 'A',
|
||||
disabled: false,
|
||||
orderBy: [{ columnName: 'timestamp', order: 'desc' }],
|
||||
groupBy: [],
|
||||
limit: null,
|
||||
having: [],
|
||||
},
|
||||
],
|
||||
queryFormulas: [],
|
||||
},
|
||||
queryType: 'builder',
|
||||
});
|
||||
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: (): any => ({
|
||||
updateAllQueriesOperators: mockUpdateAllQueriesOperators,
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
dataSource: 'logs',
|
||||
queryName: 'A',
|
||||
filters: { items: [], op: 'AND' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockWindowOpen = jest.fn();
|
||||
Object.defineProperty(window, 'open', {
|
||||
writable: true,
|
||||
value: mockWindowOpen,
|
||||
});
|
||||
|
||||
// Mock uplot to avoid rendering issues
|
||||
jest.mock('uplot', () => {
|
||||
const paths = {
|
||||
spline: jest.fn(),
|
||||
bars: jest.fn(),
|
||||
};
|
||||
const uplotMock = jest.fn(() => ({
|
||||
paths,
|
||||
}));
|
||||
return {
|
||||
paths,
|
||||
default: uplotMock,
|
||||
};
|
||||
});
|
||||
|
||||
// Mock GetMetricQueryRange to track API calls
|
||||
jest.mock('lib/dashboard/getQueryResults', () => ({
|
||||
GetMetricQueryRange: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock generateColor
|
||||
jest.mock('lib/uPlotLib/utils/generateColor', () => ({
|
||||
generateColor: jest.fn().mockReturnValue('#1f77b4'),
|
||||
}));
|
||||
|
||||
// Mock OverlayScrollbar
|
||||
jest.mock(
|
||||
'components/OverlayScrollbar/OverlayScrollbar',
|
||||
() =>
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
|
||||
function OverlayScrollbar({ children }: any) {
|
||||
return <div data-testid="overlay-scrollbar">{children}</div>;
|
||||
},
|
||||
);
|
||||
|
||||
// Mock Virtuoso
|
||||
jest.mock('react-virtuoso', () => ({
|
||||
Virtuoso: jest.fn(({ data, itemContent }) => (
|
||||
<div data-testid="virtuoso">
|
||||
{data?.map((item: any, index: number) => (
|
||||
<div key={item.id || index} data-testid={`log-item-${item.id}`}>
|
||||
{itemContent(index, item)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)),
|
||||
}));
|
||||
|
||||
// Mock InfraMetrics component for focused testing
|
||||
jest.mock(
|
||||
'container/LogDetailedView/InfraMetrics/InfraMetrics',
|
||||
() =>
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
|
||||
function MockInfraMetrics({
|
||||
podName,
|
||||
nodeName,
|
||||
hostName,
|
||||
clusterName,
|
||||
timestamp,
|
||||
dataSource,
|
||||
}: any) {
|
||||
return (
|
||||
<div data-testid="infra-metrics">
|
||||
<div data-testid="infra-pod-name">{podName}</div>
|
||||
<div data-testid="infra-node-name">{nodeName}</div>
|
||||
<div data-testid="infra-host-name">{hostName}</div>
|
||||
<div data-testid="infra-cluster-name">{clusterName}</div>
|
||||
<div data-testid="infra-timestamp">{timestamp}</div>
|
||||
<div data-testid="infra-data-source">{dataSource}</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Mock PreferenceContextProvider
|
||||
jest.mock('providers/preferences/context/PreferenceContextProvider', () => ({
|
||||
PreferenceContextProvider: ({ children }: any): JSX.Element => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
describe('SpanDetailsDrawer - Infra Metrics', () => {
|
||||
// eslint-disable-next-line sonarjs/no-unused-collection
|
||||
let apiCallHistory: any[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
apiCallHistory = [];
|
||||
mockSafeNavigate.mockClear();
|
||||
mockWindowOpen.mockClear();
|
||||
mockUpdateAllQueriesOperators.mockClear();
|
||||
|
||||
// Setup API call tracking for infra metrics
|
||||
(GetMetricQueryRange as jest.Mock).mockImplementation((query) => {
|
||||
apiCallHistory.push(query);
|
||||
|
||||
// Return mock responses for different query types
|
||||
if (
|
||||
query?.query?.builder?.queryData?.[0]?.filters?.items?.some(
|
||||
(item: any) => item.key?.key === 'k8s_pod_name',
|
||||
)
|
||||
) {
|
||||
return Promise.resolve(mockPodMetricsResponse);
|
||||
}
|
||||
|
||||
if (
|
||||
query?.query?.builder?.queryData?.[0]?.filters?.items?.some(
|
||||
(item: any) => item.key?.key === 'k8s_node_name',
|
||||
)
|
||||
) {
|
||||
return Promise.resolve(mockNodeMetricsResponse);
|
||||
}
|
||||
|
||||
return Promise.resolve(mockEmptyMetricsResponse);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
// Mock QueryBuilder context value
|
||||
const mockQueryBuilderContextValue = {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
dataSource: 'logs',
|
||||
queryName: 'A',
|
||||
filters: { items: [], op: 'AND' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
stagedQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
dataSource: 'logs',
|
||||
queryName: 'A',
|
||||
filters: { items: [], op: 'AND' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
updateAllQueriesOperators: mockUpdateAllQueriesOperators,
|
||||
panelType: 'list',
|
||||
redirectWithQuery: jest.fn(),
|
||||
handleRunQuery: jest.fn(),
|
||||
handleStageQuery: jest.fn(),
|
||||
resetQuery: jest.fn(),
|
||||
};
|
||||
|
||||
const renderSpanDetailsDrawer = (props = {}): void => {
|
||||
render(
|
||||
<QueryBuilderContext.Provider value={mockQueryBuilderContextValue as any}>
|
||||
<SpanDetailsDrawer
|
||||
isSpanDetailsDocked={false}
|
||||
setIsSpanDetailsDocked={jest.fn()}
|
||||
selectedSpan={mockSpanWithInfraMetadata}
|
||||
traceStartTime={1640995200000} // 2022-01-01 00:00:00
|
||||
traceEndTime={1640995260000} // 2022-01-01 00:01:00
|
||||
{...props}
|
||||
/>
|
||||
</QueryBuilderContext.Provider>,
|
||||
);
|
||||
};
|
||||
|
||||
it('should detect infra metadata from span attributes', async () => {
|
||||
renderSpanDetailsDrawer();
|
||||
|
||||
// Click on metrics tab
|
||||
const infraMetricsButton = screen.getByRole('button', { name: /metrics/i });
|
||||
expect(infraMetricsButton).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(infraMetricsButton);
|
||||
|
||||
// Wait for infra metrics to load
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('infra-metrics')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Verify metadata extraction
|
||||
expect(screen.getByTestId('infra-pod-name')).toHaveTextContent(
|
||||
expectedInfraMetadata.podName,
|
||||
);
|
||||
expect(screen.getByTestId('infra-node-name')).toHaveTextContent(
|
||||
expectedInfraMetadata.nodeName,
|
||||
);
|
||||
expect(screen.getByTestId('infra-host-name')).toHaveTextContent(
|
||||
expectedInfraMetadata.hostName,
|
||||
);
|
||||
expect(screen.getByTestId('infra-cluster-name')).toHaveTextContent(
|
||||
expectedInfraMetadata.clusterName,
|
||||
);
|
||||
expect(screen.getByTestId('infra-data-source')).toHaveTextContent(
|
||||
DataSource.TRACES,
|
||||
);
|
||||
});
|
||||
|
||||
it('should not show infra tab when span lacks infra metadata', async () => {
|
||||
render(
|
||||
<QueryBuilderContext.Provider value={mockQueryBuilderContextValue as any}>
|
||||
<SpanDetailsDrawer
|
||||
isSpanDetailsDocked={false}
|
||||
setIsSpanDetailsDocked={jest.fn()}
|
||||
selectedSpan={mockSpanWithoutInfraMetadata}
|
||||
traceStartTime={1640995200000}
|
||||
traceEndTime={1640995260000}
|
||||
/>
|
||||
</QueryBuilderContext.Provider>,
|
||||
);
|
||||
|
||||
// Should NOT show infra tab, only logs tab
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /metrics/i }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /logs/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show infra tab when span has infra metadata', async () => {
|
||||
renderSpanDetailsDrawer();
|
||||
|
||||
// Should show both logs and infra tabs
|
||||
expect(screen.getByRole('button', { name: /metrics/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /logs/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle pod-only metadata correctly', async () => {
|
||||
render(
|
||||
<QueryBuilderContext.Provider value={mockQueryBuilderContextValue as any}>
|
||||
<SpanDetailsDrawer
|
||||
isSpanDetailsDocked={false}
|
||||
setIsSpanDetailsDocked={jest.fn()}
|
||||
selectedSpan={mockSpanWithPodOnly}
|
||||
traceStartTime={1640995200000}
|
||||
traceEndTime={1640995260000}
|
||||
/>
|
||||
</QueryBuilderContext.Provider>,
|
||||
);
|
||||
|
||||
// Click on infra tab
|
||||
const infraMetricsButton = screen.getByRole('button', { name: /metrics/i });
|
||||
fireEvent.click(infraMetricsButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('infra-metrics')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Verify pod-only metadata
|
||||
expect(screen.getByTestId('infra-pod-name')).toHaveTextContent(
|
||||
expectedPodOnlyMetadata.podName,
|
||||
);
|
||||
expect(screen.getByTestId('infra-cluster-name')).toHaveTextContent(
|
||||
expectedPodOnlyMetadata.clusterName,
|
||||
);
|
||||
expect(screen.getByTestId('infra-node-name')).toHaveTextContent(
|
||||
expectedPodOnlyMetadata.nodeName,
|
||||
);
|
||||
expect(screen.getByTestId('infra-host-name')).toHaveTextContent(
|
||||
expectedPodOnlyMetadata.hostName,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle node-only metadata correctly', async () => {
|
||||
render(
|
||||
<QueryBuilderContext.Provider value={mockQueryBuilderContextValue as any}>
|
||||
<SpanDetailsDrawer
|
||||
isSpanDetailsDocked={false}
|
||||
setIsSpanDetailsDocked={jest.fn()}
|
||||
selectedSpan={mockSpanWithNodeOnly}
|
||||
traceStartTime={1640995200000}
|
||||
traceEndTime={1640995260000}
|
||||
/>
|
||||
</QueryBuilderContext.Provider>,
|
||||
);
|
||||
|
||||
// Click on infra tab
|
||||
const infraMetricsButton = screen.getByRole('button', { name: /metrics/i });
|
||||
fireEvent.click(infraMetricsButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('infra-metrics')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Verify node-only metadata
|
||||
expect(screen.getByTestId('infra-node-name')).toHaveTextContent(
|
||||
expectedNodeOnlyMetadata.nodeName,
|
||||
);
|
||||
expect(screen.getByTestId('infra-pod-name')).toHaveTextContent(
|
||||
expectedNodeOnlyMetadata.podName,
|
||||
);
|
||||
expect(screen.getByTestId('infra-cluster-name')).toHaveTextContent(
|
||||
expectedNodeOnlyMetadata.clusterName,
|
||||
);
|
||||
expect(screen.getByTestId('infra-host-name')).toHaveTextContent(
|
||||
expectedNodeOnlyMetadata.hostName,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle host-only metadata correctly', async () => {
|
||||
render(
|
||||
<QueryBuilderContext.Provider value={mockQueryBuilderContextValue as any}>
|
||||
<SpanDetailsDrawer
|
||||
isSpanDetailsDocked={false}
|
||||
setIsSpanDetailsDocked={jest.fn()}
|
||||
selectedSpan={mockSpanWithHostOnly}
|
||||
traceStartTime={1640995200000}
|
||||
traceEndTime={1640995260000}
|
||||
/>
|
||||
</QueryBuilderContext.Provider>,
|
||||
);
|
||||
|
||||
// Click on infra tab
|
||||
const infraMetricsButton = screen.getByRole('button', { name: /metrics/i });
|
||||
fireEvent.click(infraMetricsButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('infra-metrics')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Verify host-only metadata
|
||||
expect(screen.getByTestId('infra-host-name')).toHaveTextContent(
|
||||
expectedHostOnlyMetadata.hostName,
|
||||
);
|
||||
expect(screen.getByTestId('infra-pod-name')).toHaveTextContent(
|
||||
expectedHostOnlyMetadata.podName,
|
||||
);
|
||||
expect(screen.getByTestId('infra-node-name')).toHaveTextContent(
|
||||
expectedHostOnlyMetadata.nodeName,
|
||||
);
|
||||
expect(screen.getByTestId('infra-cluster-name')).toHaveTextContent(
|
||||
expectedHostOnlyMetadata.clusterName,
|
||||
);
|
||||
});
|
||||
|
||||
it('should switch between logs and infra tabs correctly', async () => {
|
||||
renderSpanDetailsDrawer();
|
||||
|
||||
// Initially should show logs tab content
|
||||
const logsButton = screen.getByRole('button', { name: /logs/i });
|
||||
const infraMetricsButton = screen.getByRole('button', { name: /metrics/i });
|
||||
|
||||
expect(logsButton).toBeInTheDocument();
|
||||
expect(infraMetricsButton).toBeInTheDocument();
|
||||
|
||||
// Ensure logs tab is active and wait for content to load
|
||||
fireEvent.click(logsButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('open-in-explorer-button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click on infra tab
|
||||
fireEvent.click(infraMetricsButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('infra-metrics')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Should not show logs content anymore
|
||||
expect(
|
||||
screen.queryByTestId('open-in-explorer-button'),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// Switch back to logs tab
|
||||
fireEvent.click(logsButton);
|
||||
|
||||
// Should not show infra metrics anymore
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('infra-metrics')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Verify logs content is shown again
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('open-in-explorer-button')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass correct data source and handle multiple infra identifiers', async () => {
|
||||
renderSpanDetailsDrawer();
|
||||
|
||||
// Should show infra tab when span has any of: clusterName, podName, nodeName, hostName
|
||||
expect(screen.getByRole('button', { name: /metrics/i })).toBeInTheDocument();
|
||||
|
||||
// Click on infra tab
|
||||
const infraMetricsButton = screen.getByRole('button', { name: /metrics/i });
|
||||
fireEvent.click(infraMetricsButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('infra-metrics')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Verify TRACES data source is passed
|
||||
expect(screen.getByTestId('infra-data-source')).toHaveTextContent(
|
||||
DataSource.TRACES,
|
||||
);
|
||||
|
||||
// All infra identifiers should be passed through
|
||||
expect(screen.getByTestId('infra-pod-name')).toHaveTextContent(
|
||||
'test-pod-abc123',
|
||||
);
|
||||
expect(screen.getByTestId('infra-node-name')).toHaveTextContent(
|
||||
'test-node-456',
|
||||
);
|
||||
expect(screen.getByTestId('infra-host-name')).toHaveTextContent(
|
||||
'test-host.example.com',
|
||||
);
|
||||
expect(screen.getByTestId('infra-cluster-name')).toHaveTextContent(
|
||||
'test-cluster',
|
||||
);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,167 +0,0 @@
|
||||
import { Span } from 'types/api/trace/getTraceV2';
|
||||
|
||||
// Constants
|
||||
const TEST_TRACE_ID = 'test-trace-id';
|
||||
const TEST_CLUSTER_NAME = 'test-cluster';
|
||||
const TEST_POD_NAME = 'test-pod-abc123';
|
||||
const TEST_NODE_NAME = 'test-node-456';
|
||||
const TEST_HOST_NAME = 'test-host.example.com';
|
||||
|
||||
// Mock span with infrastructure metadata (pod + node + host)
|
||||
export const mockSpanWithInfraMetadata: Span = {
|
||||
spanId: 'infra-span-id',
|
||||
traceId: TEST_TRACE_ID,
|
||||
name: 'api-service',
|
||||
serviceName: 'api-service',
|
||||
timestamp: 1640995200000000, // 2022-01-01 00:00:00 in microseconds
|
||||
durationNano: 2000000000, // 2 seconds in nanoseconds
|
||||
spanKind: 'server',
|
||||
statusCodeString: 'STATUS_CODE_OK',
|
||||
statusMessage: '',
|
||||
parentSpanId: '',
|
||||
references: [],
|
||||
event: [],
|
||||
tagMap: {
|
||||
'k8s.cluster.name': TEST_CLUSTER_NAME,
|
||||
'k8s.pod.name': TEST_POD_NAME,
|
||||
'k8s.node.name': TEST_NODE_NAME,
|
||||
'host.name': TEST_HOST_NAME,
|
||||
'service.name': 'api-service',
|
||||
'http.method': 'GET',
|
||||
},
|
||||
hasError: false,
|
||||
rootSpanId: '',
|
||||
kind: 0,
|
||||
rootName: '',
|
||||
hasChildren: false,
|
||||
hasSibling: false,
|
||||
subTreeNodeCount: 0,
|
||||
level: 0,
|
||||
};
|
||||
|
||||
// Mock span with only pod metadata
|
||||
export const mockSpanWithPodOnly: Span = {
|
||||
...mockSpanWithInfraMetadata,
|
||||
spanId: 'pod-only-span-id',
|
||||
tagMap: {
|
||||
'k8s.cluster.name': TEST_CLUSTER_NAME,
|
||||
'k8s.pod.name': TEST_POD_NAME,
|
||||
'service.name': 'api-service',
|
||||
},
|
||||
};
|
||||
|
||||
// Mock span with only node metadata
|
||||
export const mockSpanWithNodeOnly: Span = {
|
||||
...mockSpanWithInfraMetadata,
|
||||
spanId: 'node-only-span-id',
|
||||
tagMap: {
|
||||
'k8s.node.name': TEST_NODE_NAME,
|
||||
'service.name': 'api-service',
|
||||
},
|
||||
};
|
||||
|
||||
// Mock span with only host metadata
|
||||
export const mockSpanWithHostOnly: Span = {
|
||||
...mockSpanWithInfraMetadata,
|
||||
spanId: 'host-only-span-id',
|
||||
tagMap: {
|
||||
'host.name': TEST_HOST_NAME,
|
||||
'service.name': 'api-service',
|
||||
},
|
||||
};
|
||||
|
||||
// Mock span without any infrastructure metadata
|
||||
export const mockSpanWithoutInfraMetadata: Span = {
|
||||
...mockSpanWithInfraMetadata,
|
||||
spanId: 'no-infra-span-id',
|
||||
tagMap: {
|
||||
'service.name': 'api-service',
|
||||
'http.method': 'GET',
|
||||
'http.status_code': '200',
|
||||
},
|
||||
};
|
||||
|
||||
// Mock infrastructure metrics API responses
|
||||
export const mockPodMetricsResponse = {
|
||||
payload: {
|
||||
data: {
|
||||
newResult: {
|
||||
data: {
|
||||
result: [
|
||||
{
|
||||
metric: { pod_name: TEST_POD_NAME },
|
||||
values: [
|
||||
[1640995200, '0.5'], // CPU usage
|
||||
[1640995260, '0.6'],
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const mockNodeMetricsResponse = {
|
||||
payload: {
|
||||
data: {
|
||||
newResult: {
|
||||
data: {
|
||||
result: [
|
||||
{
|
||||
metric: { node_name: TEST_NODE_NAME },
|
||||
values: [
|
||||
[1640995200, '2.1'], // Memory usage
|
||||
[1640995260, '2.3'],
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const mockEmptyMetricsResponse = {
|
||||
payload: {
|
||||
data: {
|
||||
newResult: {
|
||||
data: {
|
||||
result: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Expected infrastructure metadata extractions
|
||||
export const expectedInfraMetadata = {
|
||||
clusterName: TEST_CLUSTER_NAME,
|
||||
podName: TEST_POD_NAME,
|
||||
nodeName: TEST_NODE_NAME,
|
||||
hostName: TEST_HOST_NAME,
|
||||
};
|
||||
|
||||
export const expectedPodOnlyMetadata = {
|
||||
clusterName: TEST_CLUSTER_NAME,
|
||||
podName: TEST_POD_NAME,
|
||||
nodeName: '',
|
||||
hostName: '',
|
||||
spanTimestamp: '2022-01-01T00:00:00.000Z',
|
||||
};
|
||||
|
||||
export const expectedNodeOnlyMetadata = {
|
||||
clusterName: '',
|
||||
podName: '',
|
||||
nodeName: TEST_NODE_NAME,
|
||||
hostName: '',
|
||||
spanTimestamp: '2022-01-01T00:00:00.000Z',
|
||||
};
|
||||
|
||||
export const expectedHostOnlyMetadata = {
|
||||
clusterName: '',
|
||||
podName: '',
|
||||
nodeName: '',
|
||||
hostName: TEST_HOST_NAME,
|
||||
spanTimestamp: '2022-01-01T00:00:00.000Z',
|
||||
};
|
||||
@@ -1,224 +0,0 @@
|
||||
import { SPAN_ATTRIBUTES } from 'container/ApiMonitoring/Explorer/Domains/DomainDetails/constants';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
import { Span } from 'types/api/trace/getTraceV2';
|
||||
|
||||
// Constants
|
||||
const TEST_SPAN_ID = 'test-span-id';
|
||||
const TEST_TRACE_ID = 'test-trace-id';
|
||||
const TEST_SERVICE = 'test-service';
|
||||
|
||||
// Mock span data
|
||||
export const mockSpan: Span = {
|
||||
spanId: TEST_SPAN_ID,
|
||||
traceId: TEST_TRACE_ID,
|
||||
name: TEST_SERVICE,
|
||||
serviceName: TEST_SERVICE,
|
||||
timestamp: 1640995200000, // 2022-01-01 00:00:00 in milliseconds
|
||||
durationNano: 1000000000, // 1 second in nanoseconds
|
||||
spanKind: 'server',
|
||||
statusCodeString: 'STATUS_CODE_OK',
|
||||
statusMessage: '',
|
||||
parentSpanId: '',
|
||||
references: [],
|
||||
event: [],
|
||||
tagMap: {
|
||||
'http.method': 'GET',
|
||||
[SPAN_ATTRIBUTES.HTTP_URL]: '/api/test',
|
||||
'http.status_code': '200',
|
||||
},
|
||||
hasError: false,
|
||||
rootSpanId: '',
|
||||
kind: 0,
|
||||
rootName: '',
|
||||
hasChildren: false,
|
||||
hasSibling: false,
|
||||
subTreeNodeCount: 0,
|
||||
level: 0,
|
||||
};
|
||||
|
||||
// Mock span with long status message (> 100 characters) for testing truncation
|
||||
export const mockSpanWithLongStatusMessage: Span = {
|
||||
...mockSpan,
|
||||
statusMessage:
|
||||
'Error: Connection timeout occurred while trying to reach the database server. The connection pool was exhausted and all retry attempts failed after 30 seconds.',
|
||||
};
|
||||
|
||||
// Mock span with short status message (<= 100 characters)
|
||||
export const mockSpanWithShortStatusMessage: Span = {
|
||||
...mockSpan,
|
||||
statusMessage: 'Connection successful',
|
||||
};
|
||||
|
||||
// Mock logs with proper relationships
|
||||
export const mockSpanLogs: ILog[] = [
|
||||
{
|
||||
id: 'span-log-1',
|
||||
timestamp: '2022-01-01T00:00:01.000Z',
|
||||
body: 'Processing request in span',
|
||||
severity_text: 'INFO',
|
||||
severity_number: 9,
|
||||
spanID: TEST_SPAN_ID,
|
||||
span_id: TEST_SPAN_ID,
|
||||
date: '',
|
||||
traceId: TEST_TRACE_ID,
|
||||
traceFlags: 0,
|
||||
severityText: '',
|
||||
severityNumber: 0,
|
||||
resources_string: {},
|
||||
scope_string: {},
|
||||
attributesString: {},
|
||||
attributes_string: {},
|
||||
attributesInt: {},
|
||||
attributesFloat: {},
|
||||
},
|
||||
{
|
||||
id: 'span-log-2',
|
||||
timestamp: '2022-01-01T00:00:02.000Z',
|
||||
body: 'Span operation completed',
|
||||
severity_text: 'INFO',
|
||||
severity_number: 9,
|
||||
spanID: TEST_SPAN_ID,
|
||||
span_id: TEST_SPAN_ID,
|
||||
date: '',
|
||||
traceId: TEST_TRACE_ID,
|
||||
traceFlags: 0,
|
||||
severityText: '',
|
||||
severityNumber: 0,
|
||||
resources_string: {},
|
||||
scope_string: {},
|
||||
attributesString: {},
|
||||
attributes_string: {},
|
||||
attributesInt: {},
|
||||
attributesFloat: {},
|
||||
},
|
||||
];
|
||||
|
||||
export const mockContextLogs: ILog[] = [
|
||||
{
|
||||
id: 'context-log-before',
|
||||
timestamp: '2021-12-31T23:59:59.000Z',
|
||||
body: 'Context log before span',
|
||||
severity_text: 'INFO',
|
||||
severity_number: 9,
|
||||
spanID: 'different-span-id',
|
||||
span_id: 'different-span-id',
|
||||
date: '',
|
||||
traceId: TEST_TRACE_ID,
|
||||
traceFlags: 0,
|
||||
severityText: '',
|
||||
severityNumber: 0,
|
||||
resources_string: {},
|
||||
scope_string: {},
|
||||
attributesString: {},
|
||||
attributes_string: {},
|
||||
attributesInt: {},
|
||||
attributesFloat: {},
|
||||
},
|
||||
{
|
||||
id: 'context-log-after',
|
||||
timestamp: '2022-01-01T00:00:03.000Z',
|
||||
body: 'Context log after span',
|
||||
severity_text: 'INFO',
|
||||
severity_number: 9,
|
||||
spanID: 'another-different-span-id',
|
||||
span_id: 'another-different-span-id',
|
||||
date: '',
|
||||
traceId: TEST_TRACE_ID,
|
||||
traceFlags: 0,
|
||||
severityText: '',
|
||||
severityNumber: 0,
|
||||
resources_string: {},
|
||||
scope_string: {},
|
||||
attributesString: {},
|
||||
attributes_string: {},
|
||||
attributesInt: {},
|
||||
attributesFloat: {},
|
||||
},
|
||||
];
|
||||
|
||||
// Combined logs in chronological order
|
||||
export const mockAllLogs: ILog[] = [
|
||||
mockContextLogs[0], // before
|
||||
...mockSpanLogs, // span logs
|
||||
mockContextLogs[1], // after
|
||||
];
|
||||
|
||||
// Mock API responses
|
||||
export const mockSpanLogsResponse = {
|
||||
payload: {
|
||||
data: {
|
||||
newResult: {
|
||||
data: {
|
||||
result: [
|
||||
{
|
||||
list: mockSpanLogs.map((log) => ({
|
||||
data: log,
|
||||
timestamp: log.timestamp,
|
||||
})),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const mockBeforeLogsResponse = {
|
||||
payload: {
|
||||
data: {
|
||||
newResult: {
|
||||
data: {
|
||||
result: [
|
||||
{
|
||||
list: [mockContextLogs[0]].map((log) => ({
|
||||
data: log,
|
||||
timestamp: log.timestamp,
|
||||
})),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const mockAfterLogsResponse = {
|
||||
payload: {
|
||||
data: {
|
||||
newResult: {
|
||||
data: {
|
||||
result: [
|
||||
{
|
||||
list: [mockContextLogs[1]].map((log) => ({
|
||||
data: log,
|
||||
timestamp: log.timestamp,
|
||||
})),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const mockEmptyLogsResponse = {
|
||||
payload: {
|
||||
data: {
|
||||
newResult: {
|
||||
data: {
|
||||
result: [
|
||||
{
|
||||
list: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Expected v5 filter expressions
|
||||
export const expectedSpanFilterExpression = `trace_id = '${TEST_TRACE_ID}' AND span_id = '${TEST_SPAN_ID}'`;
|
||||
export const expectedBeforeFilterExpression = `trace_id = '${TEST_TRACE_ID}' AND id < 'span-log-1'`;
|
||||
export const expectedAfterFilterExpression = `trace_id = '${TEST_TRACE_ID}' AND id > 'span-log-2'`;
|
||||
export const expectedTraceOnlyFilterExpression = `trace_id = '${TEST_TRACE_ID}'`;
|
||||
@@ -1,11 +0,0 @@
|
||||
export enum RelatedSignalsViews {
|
||||
LOGS = 'logs',
|
||||
// METRICS = 'metrics',
|
||||
INFRA = 'infra',
|
||||
}
|
||||
|
||||
export const RELATED_SIGNALS_VIEW_TYPES = {
|
||||
LOGS: RelatedSignalsViews.LOGS,
|
||||
// METRICS: RelatedSignalsViews.METRICS,
|
||||
INFRA: RelatedSignalsViews.INFRA,
|
||||
};
|
||||
@@ -1,24 +0,0 @@
|
||||
import { Span } from 'types/api/trace/getTraceV2';
|
||||
|
||||
/**
|
||||
* Infrastructure metadata keys that indicate infra signals are available
|
||||
*/
|
||||
export const INFRA_METADATA_KEYS = [
|
||||
'k8s.cluster.name',
|
||||
'k8s.pod.name',
|
||||
'k8s.node.name',
|
||||
'host.name',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Checks if a span has any infrastructure metadata attributes
|
||||
* @param span - The span to check for infrastructure metadata
|
||||
* @returns true if the span has at least one infrastructure metadata key, false otherwise
|
||||
*/
|
||||
export function hasInfraMetadata(span: Span | undefined): boolean {
|
||||
if (!span?.tagMap) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return INFRA_METADATA_KEYS.some((key) => span.tagMap?.[key]);
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
.trace-metadata {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0px 16px 0px 16px;
|
||||
|
||||
.metadata-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
|
||||
.first-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.previous-btn {
|
||||
display: flex;
|
||||
height: 30px;
|
||||
padding: 6px 8px;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
border-radius: 4px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.trace-name {
|
||||
display: flex;
|
||||
padding: 6px 8px;
|
||||
margin-left: 6px;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 4px 0px 0px 4px;
|
||||
background: var(--l2-background);
|
||||
|
||||
.drafting {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.trace-id {
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 18px; /* 128.571% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
|
||||
.trace-id-value {
|
||||
display: flex;
|
||||
padding: 6px 8px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: var(--l3-background);
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 18px; /* 128.571% */
|
||||
letter-spacing: -0.07px;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-left: unset;
|
||||
border-radius: 0px 4px 4px 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.second-row {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
|
||||
.service-entry-info {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
color: var(--l2-foreground);
|
||||
align-items: center;
|
||||
|
||||
.text {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.root-span-name {
|
||||
display: flex;
|
||||
padding: 2px 8px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border-radius: 50px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l1-border);
|
||||
}
|
||||
}
|
||||
|
||||
.trace-duration {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
color: var(--l2-foreground);
|
||||
align-items: center;
|
||||
|
||||
.text {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
|
||||
.start-time-info {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
color: var(--l2-foreground);
|
||||
align-items: center;
|
||||
|
||||
.text {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.datapoints-info {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
|
||||
.separator {
|
||||
width: 1px;
|
||||
background: var(--l3-background);
|
||||
}
|
||||
|
||||
.data-point {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
gap: 4px;
|
||||
|
||||
.text {
|
||||
color: var(--l2-foreground);
|
||||
text-align: center;
|
||||
font-family: Inter;
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 18px; /* 128.571% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.value {
|
||||
color: var(--l1-foreground);
|
||||
font-variant-numeric: lining-nums tabular-nums stacked-fractions
|
||||
slashed-zero;
|
||||
font-feature-settings:
|
||||
'case' on,
|
||||
'cpsp' on,
|
||||
'dlig' on,
|
||||
'salt' on;
|
||||
font-family: Inter;
|
||||
font-size: 20px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 28px; /* 140% */
|
||||
letter-spacing: -0.1px;
|
||||
text-transform: uppercase;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useLocation, useRouteMatch } from 'react-router-dom';
|
||||
import { Skeleton, Tooltip } from 'antd';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import removeLocalStorageKey from 'api/browser/localstorage/remove';
|
||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import ROUTES from 'constants/routes';
|
||||
import dayjs from 'dayjs';
|
||||
import history from 'lib/history';
|
||||
import {
|
||||
ArrowLeft,
|
||||
BetweenHorizontalStart,
|
||||
CalendarClock,
|
||||
DraftingCompass,
|
||||
Timer,
|
||||
} from '@signozhq/icons';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
|
||||
import './TraceMetadata.styles.scss';
|
||||
|
||||
export interface ITraceMetadataProps {
|
||||
traceID: string;
|
||||
rootServiceName: string;
|
||||
rootSpanName: string;
|
||||
startTime: number;
|
||||
duration: number;
|
||||
totalSpans: number;
|
||||
totalErrorSpans: number;
|
||||
notFound: boolean;
|
||||
isDataLoading: boolean;
|
||||
}
|
||||
|
||||
function TraceMetadata(props: ITraceMetadataProps): JSX.Element {
|
||||
const {
|
||||
traceID,
|
||||
rootServiceName,
|
||||
rootSpanName,
|
||||
startTime,
|
||||
duration,
|
||||
totalErrorSpans,
|
||||
totalSpans,
|
||||
notFound,
|
||||
isDataLoading,
|
||||
} = props;
|
||||
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
const startTimeInMs = useMemo(
|
||||
() =>
|
||||
dayjs(startTime * 1e3)
|
||||
.tz(timezone.value)
|
||||
.format(DATE_TIME_FORMATS.DD_MMM_YYYY_HH_MM_SS),
|
||||
[startTime, timezone.value],
|
||||
);
|
||||
|
||||
const handlePreviousBtnClick = (): void => {
|
||||
if (window.history.length > 1) {
|
||||
history.goBack();
|
||||
} else {
|
||||
history.push(ROUTES.TRACES_EXPLORER);
|
||||
}
|
||||
};
|
||||
|
||||
const isOnOldRoute = !!useRouteMatch({
|
||||
path: ROUTES.TRACE_DETAIL_OLD,
|
||||
exact: true,
|
||||
});
|
||||
|
||||
const location = useLocation();
|
||||
|
||||
const handleSwitchToNewView = (): void => {
|
||||
removeLocalStorageKey(LOCALSTORAGE.TRACE_DETAILS_PREFER_OLD_VIEW);
|
||||
history.replace({
|
||||
pathname: `/trace/${traceID}`,
|
||||
search: location.search,
|
||||
hash: location.hash,
|
||||
state: location.state,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="trace-metadata">
|
||||
<section className="metadata-info">
|
||||
<div className="first-row">
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
className="previous-btn"
|
||||
prefix={<ArrowLeft size={14} />}
|
||||
onClick={handlePreviousBtnClick}
|
||||
/>
|
||||
<div className="trace-name">
|
||||
<DraftingCompass size={14} className="drafting" />
|
||||
<Typography.Text className="trace-id">Trace ID</Typography.Text>
|
||||
</div>
|
||||
<Typography.Text className="trace-id-value">{traceID}</Typography.Text>
|
||||
{isOnOldRoute && (
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="md"
|
||||
className="new-view-btn"
|
||||
onClick={handleSwitchToNewView}
|
||||
>
|
||||
Try new experience
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isDataLoading && (
|
||||
<div className="second-row">
|
||||
<div className="service-entry-info">
|
||||
<BetweenHorizontalStart size={14} />
|
||||
<Skeleton.Input active className="skeleton-input" size="small" />
|
||||
<Skeleton.Input active className="skeleton-input" size="small" />
|
||||
<Skeleton.Input active className="skeleton-input" size="small" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!isDataLoading && !notFound && (
|
||||
<div className="second-row">
|
||||
<div className="service-entry-info">
|
||||
<BetweenHorizontalStart size={14} />
|
||||
<Typography.Text className="text">{rootServiceName}</Typography.Text>
|
||||
—
|
||||
<Typography.Text className="text root-span-name">
|
||||
{rootSpanName}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className="trace-duration">
|
||||
<Tooltip title="Duration of trace">
|
||||
<Timer size={14} />
|
||||
</Tooltip>
|
||||
<Typography.Text className="text">
|
||||
{getYAxisFormattedValue(`${duration}`, 'ms')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className="start-time-info">
|
||||
<Tooltip title="Start timestamp">
|
||||
<CalendarClock size={14} />
|
||||
</Tooltip>
|
||||
|
||||
<Typography.Text className="text">
|
||||
{startTimeInMs || 'N/A'}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
{!notFound && (
|
||||
<section className="datapoints-info">
|
||||
<div className="data-point">
|
||||
<Typography.Text className="text">Total Spans</Typography.Text>
|
||||
<Typography.Text className="value">{totalSpans}</Typography.Text>
|
||||
</div>
|
||||
<div className="separator" />
|
||||
<div className="data-point">
|
||||
<Typography.Text className="text">Error Spans</Typography.Text>
|
||||
<Typography.Text className="value">{totalErrorSpans}</Typography.Text>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TraceMetadata;
|
||||
@@ -1,239 +0,0 @@
|
||||
// Modal base styles
|
||||
.add-span-to-funnel-modal {
|
||||
&__loading-spinner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 400px;
|
||||
}
|
||||
&-container {
|
||||
.ant-modal {
|
||||
&-content,
|
||||
&-header {
|
||||
background: var(--l1-background);
|
||||
}
|
||||
|
||||
&-header {
|
||||
border-bottom: none;
|
||||
|
||||
.ant-modal-title {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
&-body {
|
||||
padding: 14px 16px !important;
|
||||
padding-bottom: 0 !important;
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
|
||||
&-footer {
|
||||
margin-top: 0;
|
||||
background: var(--l2-background);
|
||||
border-top: 1px solid var(--l1-border);
|
||||
padding: 16px !important;
|
||||
.add-span-to-funnel-modal {
|
||||
&__save-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
color: var(--l1-foreground);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 24px;
|
||||
width: 135px;
|
||||
|
||||
.ant-btn-icon {
|
||||
display: flex;
|
||||
}
|
||||
&:disabled {
|
||||
color: var(--l2-foreground);
|
||||
.ant-btn-icon {
|
||||
svg {
|
||||
stroke: var(--l2-foreground);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
&__discard-button {
|
||||
background: var(--l1-border);
|
||||
}
|
||||
}
|
||||
.ant-btn {
|
||||
border-radius: 2px;
|
||||
padding: 4px 8px;
|
||||
margin: 0 !important;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Main modal styles
|
||||
.add-span-to-funnel-modal {
|
||||
// Common button styles
|
||||
%button-base {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-family: Inter;
|
||||
}
|
||||
|
||||
// Details view styles
|
||||
&--details {
|
||||
.traces-funnel-details {
|
||||
height: unset;
|
||||
|
||||
&__steps-config {
|
||||
width: unset;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.funnel-step-wrapper {
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.steps-content {
|
||||
padding: 0 16px;
|
||||
max-height: 500px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Search section
|
||||
&__search {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
align-items: center;
|
||||
|
||||
&-input {
|
||||
flex: 1;
|
||||
padding: 6px 8px;
|
||||
background: var(--l3-background);
|
||||
|
||||
.ant-input-prefix {
|
||||
height: 18px;
|
||||
margin-inline-end: 6px;
|
||||
svg {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
&,
|
||||
input {
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
line-height: 18px;
|
||||
letter-spacing: -0.07px;
|
||||
font-weight: 400;
|
||||
background: var(--l3-background);
|
||||
}
|
||||
|
||||
input::placeholder {
|
||||
color: var(--l2-foreground);
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create button
|
||||
&__create-button {
|
||||
@extend %button-base;
|
||||
width: 153px;
|
||||
padding: 4px 8px;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 2px;
|
||||
background: var(--l1-border);
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
color: var(--l2-foreground);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 24px;
|
||||
}
|
||||
.funnel-item {
|
||||
padding: 8px 8px 12px 16px;
|
||||
&,
|
||||
&:first-child {
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
&__header {
|
||||
line-height: 20px;
|
||||
}
|
||||
&__details {
|
||||
line-height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
// List section
|
||||
&__list {
|
||||
max-height: 400px;
|
||||
overflow-y: scroll;
|
||||
.funnels-empty {
|
||||
&__content {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.funnels-list {
|
||||
gap: 8px;
|
||||
|
||||
.funnel-item {
|
||||
padding: 8px 16px 12px;
|
||||
|
||||
&__details {
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__spinner {
|
||||
height: 400px;
|
||||
}
|
||||
|
||||
// Back button
|
||||
&__back-button {
|
||||
@extend %button-base;
|
||||
gap: 6px;
|
||||
color: var(--l2-foreground);
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
// Details section
|
||||
&__details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
|
||||
.funnel-configuration__steps {
|
||||
padding: 0;
|
||||
|
||||
.funnel-step {
|
||||
&__content .filters__service-and-span .ant-select {
|
||||
width: 170px;
|
||||
}
|
||||
|
||||
&__footer .error {
|
||||
width: 25%;
|
||||
}
|
||||
}
|
||||
|
||||
.inter-step-config {
|
||||
width: calc(100% - 104px);
|
||||
}
|
||||
}
|
||||
.funnel-item__actions-popover {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,294 +0,0 @@
|
||||
import { ChangeEvent, useEffect, useMemo, useState } from 'react';
|
||||
import { ArrowLeft, Check, Loader, Plus, Search } from '@signozhq/icons';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Button, Spin } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import SignozModal from 'components/SignozModal/SignozModal';
|
||||
import {
|
||||
useFunnelDetails,
|
||||
useFunnelsList,
|
||||
} from 'hooks/TracesFunnels/useFunnels';
|
||||
import { isEqual } from 'lodash-es';
|
||||
import FunnelConfiguration from 'pages/TracesFunnelDetails/components/FunnelConfiguration/FunnelConfiguration';
|
||||
import { TracesFunnelsContentRenderer } from 'pages/TracesFunnels';
|
||||
import CreateFunnel from 'pages/TracesFunnels/components/CreateFunnel/CreateFunnel';
|
||||
import { FunnelListItem } from 'pages/TracesFunnels/components/FunnelsList/FunnelsList';
|
||||
import {
|
||||
FunnelProvider,
|
||||
useFunnelContext,
|
||||
} from 'pages/TracesFunnels/FunnelContext';
|
||||
import { filterFunnelsByQuery } from 'pages/TracesFunnels/utils';
|
||||
import { Span } from 'types/api/trace/getTraceV2';
|
||||
import { FunnelData } from 'types/api/traceFunnels';
|
||||
|
||||
import './AddSpanToFunnelModal.styles.scss';
|
||||
|
||||
enum ModalView {
|
||||
LIST = 'list',
|
||||
DETAILS = 'details',
|
||||
}
|
||||
|
||||
function FunnelDetailsView({
|
||||
funnel,
|
||||
span,
|
||||
triggerAutoSave,
|
||||
showNotifications,
|
||||
onChangesDetected,
|
||||
triggerDiscard,
|
||||
}: {
|
||||
funnel: FunnelData;
|
||||
span: Span;
|
||||
triggerAutoSave: boolean;
|
||||
showNotifications: boolean;
|
||||
onChangesDetected: (hasChanges: boolean) => void;
|
||||
triggerDiscard: boolean;
|
||||
}): JSX.Element {
|
||||
const { handleRestoreSteps, steps } = useFunnelContext();
|
||||
|
||||
// Track changes between current steps and original steps
|
||||
useEffect(() => {
|
||||
const hasChanges = !isEqual(steps, funnel.steps);
|
||||
if (onChangesDetected) {
|
||||
onChangesDetected(hasChanges);
|
||||
}
|
||||
}, [steps, funnel.steps, onChangesDetected]);
|
||||
|
||||
// Handle discard when triggered from parent
|
||||
useEffect(() => {
|
||||
if (triggerDiscard && funnel.steps) {
|
||||
handleRestoreSteps(funnel.steps);
|
||||
}
|
||||
}, [triggerDiscard, funnel.steps, handleRestoreSteps]);
|
||||
|
||||
return (
|
||||
<div className="add-span-to-funnel-modal__details">
|
||||
<FunnelListItem
|
||||
funnel={funnel}
|
||||
shouldRedirectToTracesListOnDeleteSuccess={false}
|
||||
isSpanDetailsPage
|
||||
/>
|
||||
<FunnelConfiguration
|
||||
funnel={funnel}
|
||||
isTraceDetailsPage
|
||||
span={span}
|
||||
triggerAutoSave={triggerAutoSave}
|
||||
showNotifications={showNotifications}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface AddSpanToFunnelModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
span: Span;
|
||||
}
|
||||
|
||||
function AddSpanToFunnelModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
span,
|
||||
}: AddSpanToFunnelModalProps): JSX.Element {
|
||||
const [activeView, setActiveView] = useState<ModalView>(ModalView.LIST);
|
||||
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||
const [selectedFunnelId, setSelectedFunnelId] = useState<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState<boolean>(false);
|
||||
const [triggerSave, setTriggerSave] = useState<boolean>(false);
|
||||
const [isUnsavedChanges, setIsUnsavedChanges] = useState<boolean>(false);
|
||||
const [triggerDiscard, setTriggerDiscard] = useState<boolean>(false);
|
||||
const [isCreatedFromSpan, setIsCreatedFromSpan] = useState<boolean>(false);
|
||||
|
||||
const handleSearch = (e: ChangeEvent<HTMLInputElement>): void => {
|
||||
setSearchQuery(e.target.value);
|
||||
};
|
||||
|
||||
const { data, isLoading, isError, isFetching } = useFunnelsList();
|
||||
|
||||
const filteredData = useMemo(
|
||||
() =>
|
||||
filterFunnelsByQuery(data?.payload || [], searchQuery).sort(
|
||||
(a, b) =>
|
||||
new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
|
||||
),
|
||||
[data?.payload, searchQuery],
|
||||
);
|
||||
|
||||
const {
|
||||
data: funnelDetails,
|
||||
isLoading: isFunnelDetailsLoading,
|
||||
isFetching: isFunnelDetailsFetching,
|
||||
} = useFunnelDetails({
|
||||
funnelId: selectedFunnelId,
|
||||
});
|
||||
|
||||
const handleFunnelClick = (funnel: FunnelData): void => {
|
||||
setSelectedFunnelId(funnel.funnel_id);
|
||||
setActiveView(ModalView.DETAILS);
|
||||
setIsCreatedFromSpan(false);
|
||||
};
|
||||
|
||||
const handleBack = (): void => {
|
||||
setActiveView(ModalView.LIST);
|
||||
setSelectedFunnelId(undefined);
|
||||
setIsUnsavedChanges(false);
|
||||
setTriggerSave(false);
|
||||
setIsCreatedFromSpan(false);
|
||||
};
|
||||
|
||||
const handleCreateNewClick = (): void => {
|
||||
setIsCreateModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSaveFunnel = (): void => {
|
||||
setTriggerSave(true);
|
||||
// Reset trigger after a brief moment to allow the save to be processed
|
||||
setTimeout(() => {
|
||||
setTriggerSave(false);
|
||||
onClose();
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const handleDiscard = (): void => {
|
||||
setTriggerDiscard(true);
|
||||
// Reset trigger after a brief moment
|
||||
setTimeout(() => {
|
||||
setTriggerDiscard(false);
|
||||
onClose();
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const renderListView = (): JSX.Element => (
|
||||
<div className="add-span-to-funnel-modal">
|
||||
{!!filteredData?.length && (
|
||||
<div className="add-span-to-funnel-modal__search">
|
||||
<Input
|
||||
className="add-span-to-funnel-modal__search-input"
|
||||
placeholder="Search by name, description, or tags..."
|
||||
prefix={<Search size={12} />}
|
||||
value={searchQuery}
|
||||
onChange={handleSearch}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="add-span-to-funnel-modal__list">
|
||||
<OverlayScrollbar>
|
||||
<TracesFunnelsContentRenderer
|
||||
isError={isError}
|
||||
isLoading={isLoading || isFetching}
|
||||
data={filteredData || []}
|
||||
onCreateFunnel={handleCreateNewClick}
|
||||
onFunnelClick={(funnel: FunnelData): void => handleFunnelClick(funnel)}
|
||||
shouldRedirectToTracesListOnDeleteSuccess={false}
|
||||
/>
|
||||
</OverlayScrollbar>
|
||||
</div>
|
||||
<CreateFunnel
|
||||
isOpen={isCreateModalOpen}
|
||||
onClose={(funnelId): void => {
|
||||
if (funnelId) {
|
||||
setSelectedFunnelId(funnelId);
|
||||
setActiveView(ModalView.DETAILS);
|
||||
setIsCreatedFromSpan(true);
|
||||
}
|
||||
setIsCreateModalOpen(false);
|
||||
}}
|
||||
redirectToDetails={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderDetailsView = ({ span }: { span: Span }): JSX.Element => (
|
||||
<div className="add-span-to-funnel-modal add-span-to-funnel-modal--details">
|
||||
<Button
|
||||
type="text"
|
||||
className="add-span-to-funnel-modal__back-button"
|
||||
onClick={handleBack}
|
||||
>
|
||||
<ArrowLeft size={14} />
|
||||
All funnels
|
||||
</Button>
|
||||
<div className="traces-funnel-details">
|
||||
<div className="traces-funnel-details__steps-config">
|
||||
<Spin
|
||||
className="add-span-to-funnel-modal__loading-spinner"
|
||||
spinning={isFunnelDetailsLoading || isFunnelDetailsFetching}
|
||||
indicator={<Loader className="animate-spin" size="md" />}
|
||||
>
|
||||
{selectedFunnelId && funnelDetails?.payload && (
|
||||
<FunnelProvider
|
||||
funnelId={selectedFunnelId}
|
||||
hasSingleStep={isCreatedFromSpan}
|
||||
>
|
||||
<FunnelDetailsView
|
||||
funnel={funnelDetails.payload}
|
||||
span={span}
|
||||
triggerAutoSave={triggerSave}
|
||||
showNotifications
|
||||
onChangesDetected={setIsUnsavedChanges}
|
||||
triggerDiscard={triggerDiscard}
|
||||
/>
|
||||
</FunnelProvider>
|
||||
)}
|
||||
</Spin>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<SignozModal
|
||||
open={isOpen}
|
||||
onCancel={onClose}
|
||||
width={570}
|
||||
title="Add span to funnel"
|
||||
className={cx('add-span-to-funnel-modal-container', {
|
||||
'add-span-to-funnel-modal-container--details':
|
||||
activeView === ModalView.DETAILS,
|
||||
})}
|
||||
footer={
|
||||
activeView === ModalView.DETAILS
|
||||
? [
|
||||
<Button
|
||||
type="default"
|
||||
key="discard"
|
||||
onClick={handleDiscard}
|
||||
className="add-span-to-funnel-modal__discard-button"
|
||||
disabled={!isUnsavedChanges}
|
||||
>
|
||||
Discard
|
||||
</Button>,
|
||||
<Button
|
||||
key="save"
|
||||
type="primary"
|
||||
className="add-span-to-funnel-modal__save-button"
|
||||
onClick={handleSaveFunnel}
|
||||
disabled={!isUnsavedChanges}
|
||||
icon={<Check size={14} color="var(--bg-vanilla-100)" />}
|
||||
>
|
||||
Save Funnel
|
||||
</Button>,
|
||||
]
|
||||
: [
|
||||
<Button
|
||||
key="create"
|
||||
type="default"
|
||||
className="add-span-to-funnel-modal__create-button"
|
||||
onClick={handleCreateNewClick}
|
||||
icon={<Plus size={14} />}
|
||||
>
|
||||
Create new funnel
|
||||
</Button>,
|
||||
]
|
||||
}
|
||||
>
|
||||
{activeView === ModalView.LIST
|
||||
? renderListView()
|
||||
: renderDetailsView({ span })}
|
||||
</SignozModal>
|
||||
);
|
||||
}
|
||||
|
||||
export default AddSpanToFunnelModal;
|
||||
@@ -1,28 +0,0 @@
|
||||
.span-line-action-buttons {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
transform: translate(-50%, -50%);
|
||||
top: 50%;
|
||||
right: 0;
|
||||
cursor: pointer;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
|
||||
.ant-btn-default {
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
padding: 9px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
|
||||
&.active-tab {
|
||||
background-color: var(--l1-border);
|
||||
}
|
||||
}
|
||||
|
||||
.copy-span-btn {
|
||||
border-color: var(--l1-border) !important;
|
||||
}
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
import { fireEvent, screen } from '@testing-library/react';
|
||||
import { useCopySpanLink } from 'hooks/trace/useCopySpanLink';
|
||||
import { render } from 'tests/test-utils';
|
||||
import { Span } from 'types/api/trace/getTraceV2';
|
||||
|
||||
import SpanLineActionButtons from '../index';
|
||||
|
||||
// Mock the useCopySpanLink hook
|
||||
jest.mock('hooks/trace/useCopySpanLink');
|
||||
|
||||
const mockSpan: Span = {
|
||||
spanId: 'test-span-id',
|
||||
name: 'test-span',
|
||||
serviceName: 'test-service',
|
||||
durationNano: 1000,
|
||||
timestamp: 1234567890,
|
||||
rootSpanId: 'test-root-span-id',
|
||||
parentSpanId: 'test-parent-span-id',
|
||||
traceId: 'test-trace-id',
|
||||
hasError: false,
|
||||
kind: 0,
|
||||
references: [],
|
||||
tagMap: {},
|
||||
event: [],
|
||||
rootName: 'test-root-name',
|
||||
statusMessage: 'test-status-message',
|
||||
statusCodeString: 'test-status-code-string',
|
||||
spanKind: 'test-span-kind',
|
||||
hasChildren: false,
|
||||
hasSibling: false,
|
||||
subTreeNodeCount: 0,
|
||||
level: 0,
|
||||
};
|
||||
|
||||
describe('SpanLineActionButtons', () => {
|
||||
beforeEach(() => {
|
||||
// Clear mock before each test
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders copy link button with correct icon', () => {
|
||||
(useCopySpanLink as jest.Mock).mockReturnValue({
|
||||
onSpanCopy: jest.fn(),
|
||||
});
|
||||
|
||||
render(<SpanLineActionButtons span={mockSpan} />);
|
||||
|
||||
// Check if the button is rendered with an icon
|
||||
const copyButton = screen.getByRole('button');
|
||||
expect(copyButton).toBeInTheDocument();
|
||||
expect(copyButton.querySelector('svg')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onSpanCopy when copy button is clicked', () => {
|
||||
const mockOnSpanCopy = jest.fn();
|
||||
(useCopySpanLink as jest.Mock).mockReturnValue({
|
||||
onSpanCopy: mockOnSpanCopy,
|
||||
});
|
||||
|
||||
render(<SpanLineActionButtons span={mockSpan} />);
|
||||
|
||||
// Click the copy button
|
||||
const copyButton = screen.getByRole('button');
|
||||
fireEvent.click(copyButton);
|
||||
|
||||
// Verify the copy function was called
|
||||
expect(mockOnSpanCopy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('applies correct styling classes', () => {
|
||||
(useCopySpanLink as jest.Mock).mockReturnValue({
|
||||
onSpanCopy: jest.fn(),
|
||||
});
|
||||
|
||||
render(<SpanLineActionButtons span={mockSpan} />);
|
||||
|
||||
// Check if the main container has the correct class
|
||||
const container = screen
|
||||
.getByRole('button')
|
||||
.closest('.span-line-action-buttons');
|
||||
expect(container).toHaveClass('span-line-action-buttons');
|
||||
|
||||
// Check if the button has the correct class
|
||||
const copyButton = screen.getByRole('button');
|
||||
expect(copyButton).toHaveClass('copy-span-btn');
|
||||
});
|
||||
|
||||
it('copies span link to clipboard when copy button is clicked', () => {
|
||||
const mockSetCopy = jest.fn();
|
||||
const mockUrlQuery = {
|
||||
delete: jest.fn(),
|
||||
set: jest.fn(),
|
||||
toString: jest.fn().mockReturnValue('spanId=test-span-id'),
|
||||
};
|
||||
const mockPathname = '/test-path';
|
||||
const mockLocation = {
|
||||
origin: 'http://localhost:3000',
|
||||
};
|
||||
|
||||
// Mock window.location
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: mockLocation,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
// Mock useCopySpanLink hook
|
||||
(useCopySpanLink as jest.Mock).mockReturnValue({
|
||||
onSpanCopy: (event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
mockUrlQuery.delete('spanId');
|
||||
mockUrlQuery.set('spanId', mockSpan.spanId);
|
||||
const link = `${
|
||||
window.location.origin
|
||||
}${mockPathname}?${mockUrlQuery.toString()}`;
|
||||
mockSetCopy(link);
|
||||
},
|
||||
});
|
||||
|
||||
render(<SpanLineActionButtons span={mockSpan} />);
|
||||
|
||||
// Click the copy button
|
||||
const copyButton = screen.getByRole('button');
|
||||
fireEvent.click(copyButton);
|
||||
|
||||
// Verify the copy function was called with correct link
|
||||
expect(mockSetCopy).toHaveBeenCalledWith(
|
||||
'http://localhost:3000/test-path?spanId=test-span-id',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,28 +0,0 @@
|
||||
import { Link } from '@signozhq/icons';
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import { useCopySpanLink } from 'hooks/trace/useCopySpanLink';
|
||||
import { Span } from 'types/api/trace/getTraceV2';
|
||||
|
||||
import './SpanLineActionButtons.styles.scss';
|
||||
|
||||
export interface SpanLineActionButtonsProps {
|
||||
span: Span;
|
||||
}
|
||||
export default function SpanLineActionButtons({
|
||||
span,
|
||||
}: SpanLineActionButtonsProps): JSX.Element {
|
||||
const { onSpanCopy } = useCopySpanLink(span);
|
||||
|
||||
return (
|
||||
<div className="span-line-action-buttons">
|
||||
<Tooltip title="Copy Span Link">
|
||||
<Button
|
||||
size="small"
|
||||
icon={<Link size={14} />}
|
||||
onClick={onSpanCopy}
|
||||
className="copy-span-btn"
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
.trace-waterfall {
|
||||
height: calc(70vh - 236px);
|
||||
|
||||
.loading-skeleton {
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
import { Dispatch, SetStateAction, useMemo } from 'react';
|
||||
import { Skeleton } from 'antd';
|
||||
import { AxiosError } from 'axios';
|
||||
import Spinner from 'components/Spinner';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import { GetTraceV2SuccessResponse, Span } from 'types/api/trace/getTraceV2';
|
||||
|
||||
import { TraceWaterfallStates } from './constants';
|
||||
import Error from './TraceWaterfallStates/Error/Error';
|
||||
import NoData from './TraceWaterfallStates/NoData/NoData';
|
||||
import Success from './TraceWaterfallStates/Success/Success';
|
||||
|
||||
import './TraceWaterfall.styles.scss';
|
||||
|
||||
export interface IInterestedSpan {
|
||||
spanId: string;
|
||||
isUncollapsed: boolean;
|
||||
}
|
||||
|
||||
interface ITraceWaterfallProps {
|
||||
traceId: string;
|
||||
uncollapsedNodes: string[];
|
||||
traceData:
|
||||
| SuccessResponse<GetTraceV2SuccessResponse, unknown>
|
||||
| ErrorResponse
|
||||
| undefined;
|
||||
isFetchingTraceData: boolean;
|
||||
errorFetchingTraceData: unknown;
|
||||
interestedSpanId: IInterestedSpan;
|
||||
setInterestedSpanId: Dispatch<SetStateAction<IInterestedSpan>>;
|
||||
setTraceFlamegraphStatsWidth: Dispatch<SetStateAction<number>>;
|
||||
selectedSpan: Span | undefined;
|
||||
setSelectedSpan: Dispatch<SetStateAction<Span | undefined>>;
|
||||
}
|
||||
|
||||
function TraceWaterfall(props: ITraceWaterfallProps): JSX.Element {
|
||||
const {
|
||||
traceData,
|
||||
isFetchingTraceData,
|
||||
errorFetchingTraceData,
|
||||
interestedSpanId,
|
||||
traceId,
|
||||
uncollapsedNodes,
|
||||
setInterestedSpanId,
|
||||
setTraceFlamegraphStatsWidth,
|
||||
setSelectedSpan,
|
||||
selectedSpan,
|
||||
} = props;
|
||||
// get the current state of trace waterfall based on the API lifecycle
|
||||
const traceWaterfallState = useMemo(() => {
|
||||
if (isFetchingTraceData) {
|
||||
if (
|
||||
traceData &&
|
||||
traceData.payload &&
|
||||
traceData.payload.spans &&
|
||||
traceData.payload.spans.length > 0
|
||||
) {
|
||||
return TraceWaterfallStates.FETCHING_WITH_OLD_DATA_PRESENT;
|
||||
}
|
||||
return TraceWaterfallStates.LOADING;
|
||||
}
|
||||
if (errorFetchingTraceData) {
|
||||
return TraceWaterfallStates.ERROR;
|
||||
}
|
||||
if (
|
||||
traceData &&
|
||||
traceData.payload &&
|
||||
traceData.payload.spans &&
|
||||
traceData.payload.spans.length === 0
|
||||
) {
|
||||
return TraceWaterfallStates.NO_DATA;
|
||||
}
|
||||
|
||||
return TraceWaterfallStates.SUCCESS;
|
||||
}, [errorFetchingTraceData, isFetchingTraceData, traceData]);
|
||||
|
||||
// capture the spans from the response, since we do not need to do any manipulation on the same we will keep this as a simple constant [ memoized ]
|
||||
const spans = useMemo(
|
||||
() => traceData?.payload?.spans || [],
|
||||
[traceData?.payload?.spans],
|
||||
);
|
||||
|
||||
// get the content based on the current state of the trace waterfall
|
||||
const getContent = useMemo(() => {
|
||||
switch (traceWaterfallState) {
|
||||
case TraceWaterfallStates.LOADING:
|
||||
return (
|
||||
<div className="loading-skeleton">
|
||||
<Skeleton active paragraph={{ rows: 6 }} />
|
||||
</div>
|
||||
);
|
||||
case TraceWaterfallStates.ERROR:
|
||||
return <Error error={errorFetchingTraceData as AxiosError} />;
|
||||
case TraceWaterfallStates.NO_DATA:
|
||||
return <NoData id={traceId} />;
|
||||
case TraceWaterfallStates.SUCCESS:
|
||||
case TraceWaterfallStates.FETCHING_WITH_OLD_DATA_PRESENT:
|
||||
return (
|
||||
<Success
|
||||
spans={spans}
|
||||
traceMetadata={{
|
||||
traceId,
|
||||
startTime: traceData?.payload?.startTimestampMillis || 0,
|
||||
endTime: traceData?.payload?.endTimestampMillis || 0,
|
||||
hasMissingSpans: traceData?.payload?.hasMissingSpans || false,
|
||||
}}
|
||||
interestedSpanId={interestedSpanId || ''}
|
||||
uncollapsedNodes={uncollapsedNodes}
|
||||
setInterestedSpanId={setInterestedSpanId}
|
||||
setTraceFlamegraphStatsWidth={setTraceFlamegraphStatsWidth}
|
||||
selectedSpan={selectedSpan}
|
||||
setSelectedSpan={setSelectedSpan}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return <Spinner tip="Fetching the trace!" />;
|
||||
}
|
||||
}, [
|
||||
errorFetchingTraceData,
|
||||
interestedSpanId,
|
||||
selectedSpan,
|
||||
setInterestedSpanId,
|
||||
setSelectedSpan,
|
||||
setTraceFlamegraphStatsWidth,
|
||||
spans,
|
||||
traceData?.payload?.endTimestampMillis,
|
||||
traceData?.payload?.hasMissingSpans,
|
||||
traceData?.payload?.startTimestampMillis,
|
||||
traceId,
|
||||
traceWaterfallState,
|
||||
uncollapsedNodes,
|
||||
]);
|
||||
|
||||
return <div className="trace-waterfall">{getContent}</div>;
|
||||
}
|
||||
|
||||
export default TraceWaterfall;
|
||||
@@ -1,30 +0,0 @@
|
||||
.error-waterfall {
|
||||
display: flex;
|
||||
padding: 12px;
|
||||
margin: 20px;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
border-radius: 4px;
|
||||
background: var(--danger-background);
|
||||
|
||||
.text {
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.value {
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import { Tooltip } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { AxiosError } from 'axios';
|
||||
|
||||
import './Error.styles.scss';
|
||||
|
||||
interface IErrorProps {
|
||||
error: AxiosError;
|
||||
}
|
||||
|
||||
function Error(props: IErrorProps): JSX.Element {
|
||||
const { error } = props;
|
||||
|
||||
return (
|
||||
<div className="error-waterfall">
|
||||
<Typography.Text className="text">Something went wrong!</Typography.Text>
|
||||
<Tooltip title={error?.message}>
|
||||
<Typography.Text className="value" truncate={1}>
|
||||
{error?.message}
|
||||
</Typography.Text>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Error;
|
||||
@@ -1,12 +0,0 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
interface INoDataProps {
|
||||
id: string;
|
||||
}
|
||||
|
||||
function NoData(props: INoDataProps): JSX.Element {
|
||||
const { id } = props;
|
||||
return <Typography.Text>No Trace found with the id: {id} </Typography.Text>;
|
||||
}
|
||||
|
||||
export default NoData;
|
||||
@@ -1,46 +0,0 @@
|
||||
.filter-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16px 20px 0px 20px;
|
||||
gap: 12px;
|
||||
|
||||
.query-builder-search-v2 {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pre-next-toggle {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
gap: 12px;
|
||||
|
||||
.ant-typography {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--l2-foreground);
|
||||
font-family: 'Geist Mono';
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 18px; /* 150% */
|
||||
}
|
||||
|
||||
.ant-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
.no-results {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
color: var(--l2-foreground);
|
||||
font-family: 'Geist Mono';
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 18px; /* 150% */
|
||||
}
|
||||
}
|
||||
@@ -1,214 +0,0 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useHistory, useLocation } from 'react-router-dom';
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Loader,
|
||||
SolidInfoCircle,
|
||||
} from '@signozhq/icons';
|
||||
import { Button, Spin, Tooltip } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { AxiosError } from 'axios';
|
||||
import { DEFAULT_ENTITY_VERSION } from 'constants/app';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import QueryBuilderSearchV2 from 'container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2';
|
||||
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
|
||||
import { uniqBy } from 'lodash-es';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { TracesAggregatorOperator } from 'types/common/queryBuilder';
|
||||
|
||||
import { BASE_FILTER_QUERY } from './constants';
|
||||
|
||||
import './Filters.styles.scss';
|
||||
|
||||
function prepareQuery(filters: TagFilter, traceID: string): Query {
|
||||
return {
|
||||
...initialQueriesMap.traces,
|
||||
builder: {
|
||||
...initialQueriesMap.traces.builder,
|
||||
queryData: [
|
||||
{
|
||||
...initialQueriesMap.traces.builder.queryData[0],
|
||||
aggregateOperator: TracesAggregatorOperator.NOOP,
|
||||
orderBy: [{ columnName: 'timestamp', order: 'asc' }],
|
||||
filters: {
|
||||
...filters,
|
||||
items: [
|
||||
...filters.items,
|
||||
{
|
||||
id: '5ab8e1cf',
|
||||
key: {
|
||||
key: 'trace_id',
|
||||
dataType: DataTypes.String,
|
||||
type: '',
|
||||
id: 'trace_id--string----true',
|
||||
},
|
||||
op: '=',
|
||||
value: traceID,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function Filters({
|
||||
startTime,
|
||||
endTime,
|
||||
traceID,
|
||||
onFilteredSpansChange = (): void => {},
|
||||
}: {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
traceID: string;
|
||||
onFilteredSpansChange?: (spanIds: string[], isFilterActive: boolean) => void;
|
||||
}): JSX.Element {
|
||||
const [filters, setFilters] = useState<TagFilter>(
|
||||
BASE_FILTER_QUERY.filters || { items: [], op: 'AND' },
|
||||
);
|
||||
const [noData, setNoData] = useState<boolean>(false);
|
||||
const [filteredSpanIds, setFilteredSpanIds] = useState<string[]>([]);
|
||||
const [currentSearchedIndex, setCurrentSearchedIndex] = useState<number>(0);
|
||||
|
||||
const handleFilterChange = useCallback(
|
||||
(value: TagFilter): void => {
|
||||
if (value.items.length === 0) {
|
||||
setFilteredSpanIds([]);
|
||||
onFilteredSpansChange?.([], false);
|
||||
setCurrentSearchedIndex(0);
|
||||
setNoData(false);
|
||||
}
|
||||
setFilters(value);
|
||||
},
|
||||
[onFilteredSpansChange],
|
||||
);
|
||||
const { search } = useLocation();
|
||||
const history = useHistory();
|
||||
|
||||
const handlePrevNext = useCallback(
|
||||
(index: number, spanId?: string): void => {
|
||||
const searchParams = new URLSearchParams(search);
|
||||
if (spanId) {
|
||||
searchParams.set('spanId', spanId);
|
||||
} else {
|
||||
searchParams.set('spanId', filteredSpanIds[index]);
|
||||
}
|
||||
|
||||
history.replace({ search: searchParams.toString() });
|
||||
},
|
||||
[filteredSpanIds, history, search],
|
||||
);
|
||||
|
||||
const { isFetching, error } = useGetQueryRange(
|
||||
{
|
||||
query: prepareQuery(filters, traceID),
|
||||
graphType: PANEL_TYPES.LIST,
|
||||
selectedTime: 'GLOBAL_TIME',
|
||||
start: startTime,
|
||||
end: endTime,
|
||||
params: {
|
||||
dataSource: 'traces',
|
||||
},
|
||||
tableParams: {
|
||||
pagination: {
|
||||
offset: 0,
|
||||
limit: 200,
|
||||
},
|
||||
selectColumns: [
|
||||
{
|
||||
key: 'name',
|
||||
dataType: 'string',
|
||||
type: 'tag',
|
||||
id: 'name--string--tag--true',
|
||||
isIndexed: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
DEFAULT_ENTITY_VERSION,
|
||||
{
|
||||
queryKey: [filters],
|
||||
enabled: filters.items.length > 0,
|
||||
onSuccess: (data) => {
|
||||
const isFilterActive = filters.items.length > 0;
|
||||
if (data?.payload.data.newResult.data.result[0].list) {
|
||||
const uniqueSpans = uniqBy(
|
||||
data?.payload.data.newResult.data.result[0].list,
|
||||
'data.spanID',
|
||||
);
|
||||
|
||||
const spanIds = uniqueSpans.map((val) => val.data.spanID);
|
||||
setFilteredSpanIds(spanIds);
|
||||
onFilteredSpansChange?.(spanIds, isFilterActive);
|
||||
handlePrevNext(0, spanIds[0]);
|
||||
setNoData(false);
|
||||
} else {
|
||||
setNoData(true);
|
||||
setFilteredSpanIds([]);
|
||||
onFilteredSpansChange?.([], isFilterActive);
|
||||
setCurrentSearchedIndex(0);
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="filter-row">
|
||||
<QueryBuilderSearchV2
|
||||
query={{
|
||||
...BASE_FILTER_QUERY,
|
||||
filters,
|
||||
}}
|
||||
onChange={handleFilterChange}
|
||||
hideSpanScopeSelector={false}
|
||||
skipQueryBuilderRedirect
|
||||
selectProps={{ listHeight: 125 }}
|
||||
/>
|
||||
{filteredSpanIds.length > 0 && (
|
||||
<div className="pre-next-toggle">
|
||||
<Typography.Text>
|
||||
{currentSearchedIndex + 1} / {filteredSpanIds.length}
|
||||
</Typography.Text>
|
||||
<Button
|
||||
icon={<ChevronUp size={14} />}
|
||||
disabled={currentSearchedIndex === 0}
|
||||
type="text"
|
||||
onClick={(): void => {
|
||||
handlePrevNext(currentSearchedIndex - 1);
|
||||
setCurrentSearchedIndex((prev) => prev - 1);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
icon={<ChevronDown size={14} />}
|
||||
type="text"
|
||||
disabled={currentSearchedIndex === filteredSpanIds.length - 1}
|
||||
onClick={(): void => {
|
||||
handlePrevNext(currentSearchedIndex + 1);
|
||||
setCurrentSearchedIndex((prev) => prev + 1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{isFetching && (
|
||||
<Spin indicator={<Loader className="animate-spin" />} size="small" />
|
||||
)}
|
||||
{error && (
|
||||
<Tooltip title={(error as AxiosError)?.message || 'Something went wrong'}>
|
||||
<SolidInfoCircle size={14} />
|
||||
</Tooltip>
|
||||
)}
|
||||
{noData && (
|
||||
<Typography.Text className="no-results">No results found</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Filters.defaultProps = {
|
||||
onFilteredSpansChange: undefined,
|
||||
};
|
||||
|
||||
export default Filters;
|
||||
@@ -1,38 +0,0 @@
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
|
||||
|
||||
export const BASE_FILTER_QUERY: IBuilderQuery = {
|
||||
queryName: 'A',
|
||||
dataSource: DataSource.TRACES,
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: {
|
||||
id: '------false',
|
||||
dataType: DataTypes.EMPTY,
|
||||
key: '',
|
||||
type: '',
|
||||
},
|
||||
timeAggregation: 'rate',
|
||||
spaceAggregation: 'sum',
|
||||
functions: [],
|
||||
filters: {
|
||||
items: [],
|
||||
op: 'AND',
|
||||
},
|
||||
expression: 'A',
|
||||
disabled: false,
|
||||
stepInterval: 60,
|
||||
having: [],
|
||||
limit: 200,
|
||||
orderBy: [
|
||||
{
|
||||
columnName: 'timestamp',
|
||||
order: 'desc',
|
||||
},
|
||||
],
|
||||
groupBy: [],
|
||||
legend: '',
|
||||
reduceTo: ReduceOperators.AVG,
|
||||
offset: 0,
|
||||
selectColumns: [],
|
||||
};
|
||||
@@ -1,416 +0,0 @@
|
||||
.success-content {
|
||||
overflow-y: hidden;
|
||||
overflow-x: hidden;
|
||||
max-width: 100%;
|
||||
|
||||
.missing-spans {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 44px;
|
||||
margin: 16px;
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--bg-robin-600) 10%, transparent);
|
||||
|
||||
.left-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--bg-robin-400);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
|
||||
.text {
|
||||
color: var(--bg-robin-400);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
|
||||
.right-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: row-reverse;
|
||||
gap: 8px;
|
||||
color: var(--bg-robin-400);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.right-info:hover {
|
||||
background-color: unset;
|
||||
color: var(--bg-robin-200);
|
||||
}
|
||||
}
|
||||
|
||||
.waterfall-table {
|
||||
height: calc(70vh - 236px);
|
||||
overflow: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 0px 20px 20px 20px;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0.1rem;
|
||||
}
|
||||
|
||||
// default table overrides css for table v3
|
||||
.div-table {
|
||||
width: 100% !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.div-thead {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
background-color: var(--l1-background) !important;
|
||||
|
||||
.div-tr {
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.div-tr {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
height: 54px;
|
||||
}
|
||||
|
||||
.div-tr:hover {
|
||||
border-radius: 4px;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--bg-robin-200) 6%,
|
||||
transparent
|
||||
) !important;
|
||||
|
||||
.div-td .span-overview .second-row .add-funnel-button {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.span-overview {
|
||||
background: unset !important;
|
||||
|
||||
.span-overview-content {
|
||||
background: unset !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.div-th,
|
||||
.div-td {
|
||||
box-shadow: none;
|
||||
padding: 0px !important;
|
||||
}
|
||||
|
||||
.div-th {
|
||||
padding: 2px 4px;
|
||||
position: relative;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.div-td {
|
||||
display: flex;
|
||||
height: 54px;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
|
||||
.span-overview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
|
||||
.connector-lines {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.span-overview-content {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 5px;
|
||||
width: 100%;
|
||||
background-color: var(--l1-background);
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
&:not(:first-child) {
|
||||
.first-row {
|
||||
width: calc(100% - 28px);
|
||||
}
|
||||
}
|
||||
|
||||
.first-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 20px;
|
||||
width: 100%;
|
||||
|
||||
.span-det {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
|
||||
.collapse-uncollapse-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px 4px;
|
||||
gap: 4px;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l1-border);
|
||||
box-shadow: none;
|
||||
height: 20px;
|
||||
|
||||
.children-count {
|
||||
color: var(--l2-foreground);
|
||||
font-family: 'Space Mono';
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: 0.28px;
|
||||
}
|
||||
}
|
||||
|
||||
.span-name {
|
||||
color: var(--l1-foreground);
|
||||
font-family: 'Inter';
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: 0.28px;
|
||||
}
|
||||
}
|
||||
|
||||
.status-code-container {
|
||||
display: flex;
|
||||
padding-right: 10px;
|
||||
|
||||
.status-code {
|
||||
display: flex;
|
||||
height: 20px;
|
||||
padding: 3px;
|
||||
align-items: center;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.success {
|
||||
border: 1px solid var(--primary-background);
|
||||
background: var(--primary-background);
|
||||
}
|
||||
|
||||
.error {
|
||||
border: 1px solid var(--danger-background);
|
||||
background: var(--danger-background);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.second-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 18px;
|
||||
width: 100%;
|
||||
.service-name {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 18px; /* 128.571% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
.add-funnel-button {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
opacity: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
transition: opacity 0.1s ease-in-out;
|
||||
|
||||
&__separator {
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
&__button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.span-duration {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 54px;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
padding-left: 15px;
|
||||
cursor: pointer;
|
||||
|
||||
.span-line {
|
||||
position: relative;
|
||||
height: 12px;
|
||||
top: 35%;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.event-dot {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%) rotate(45deg);
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background-color: var(--primary-background);
|
||||
border: 1px solid var(--bg-robin-600);
|
||||
cursor: pointer;
|
||||
z-index: 1;
|
||||
|
||||
&.error {
|
||||
background-color: var(--danger-background);
|
||||
border-color: var(--bg-cherry-600);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
transform: translate(-50%, -50%) rotate(45deg) scale(1.5);
|
||||
}
|
||||
}
|
||||
|
||||
.span-line-text {
|
||||
position: relative;
|
||||
top: 40%;
|
||||
font-variant-numeric: lining-nums tabular-nums stacked-fractions
|
||||
slashed-zero;
|
||||
font-feature-settings:
|
||||
'case' on,
|
||||
'cpsp' on,
|
||||
'dlig' on,
|
||||
'salt' on;
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 18px; /* 128.571% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
|
||||
.interested-span,
|
||||
.selected-non-matching-span {
|
||||
border-radius: 4px;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--bg-robin-200) 6%,
|
||||
transparent
|
||||
) !important;
|
||||
|
||||
.span-overview-content {
|
||||
background: unset;
|
||||
}
|
||||
}
|
||||
|
||||
.dimmed-span {
|
||||
opacity: 0.4;
|
||||
}
|
||||
.highlighted-span {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.selected-non-matching-span {
|
||||
.span-overview-content,
|
||||
.span-line-text {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.div-td + .div-td {
|
||||
border-left: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
.div-th + .div-th {
|
||||
border-left: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
.div-tr .div-th:nth-child(2) {
|
||||
width: calc(100% - var(--header-span-name-size) * 1px) !important;
|
||||
}
|
||||
.div-tr .div-td:nth-child(2) {
|
||||
width: calc(100% - var(--header-span-name-size) * 1px) !important;
|
||||
}
|
||||
.resizer {
|
||||
width: 10px !important;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
height: calc(70vh - 236px);
|
||||
right: 0;
|
||||
width: 2px;
|
||||
background: color-mix(in srgb, var(--bg-aqua-500) 20%, transparent);
|
||||
cursor: col-resize;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.resizer.isResizing {
|
||||
background: color-mix(in srgb, var(--bg-aqua-500) 20%, transparent);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.resizer {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
*:hover > .resizer {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.missing-spans-waterfall-table {
|
||||
height: calc(70vh - 312px);
|
||||
}
|
||||
}
|
||||
|
||||
.span-dets {
|
||||
.related-logs {
|
||||
display: flex;
|
||||
width: 160px;
|
||||
padding: 4px 8px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l1-border);
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
@@ -1,591 +0,0 @@
|
||||
import {
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { ColumnDef, createColumnHelper } from '@tanstack/react-table';
|
||||
import { Virtualizer } from '@tanstack/react-virtual';
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
import HttpStatusBadge from 'components/HttpStatusBadge/HttpStatusBadge';
|
||||
import SpanHoverCard from 'components/SpanHoverCard/SpanHoverCard';
|
||||
import { TableV3 } from 'components/TableV3/TableV3';
|
||||
import { themeColors } from 'constants/theme';
|
||||
import { convertTimeToRelevantUnit } from 'container/TraceDetail/utils';
|
||||
import AddSpanToFunnelModal from 'container/TraceWaterfall/AddSpanToFunnelModal/AddSpanToFunnelModal';
|
||||
import SpanLineActionButtons from 'container/TraceWaterfall/SpanLineActionButtons';
|
||||
import { IInterestedSpan } from 'container/TraceWaterfall/TraceWaterfall';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
|
||||
import {
|
||||
ArrowUpRight,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
CircleAlert,
|
||||
Leaf,
|
||||
} from '@signozhq/icons';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { Span } from 'types/api/trace/getTraceV2';
|
||||
import { toFixed } from 'utils/toFixed';
|
||||
|
||||
import funnelAddUrl from '@/assets/Icons/funnel-add.svg';
|
||||
|
||||
import Filters from './Filters/Filters';
|
||||
|
||||
import './Success.styles.scss';
|
||||
|
||||
// css config
|
||||
const CONNECTOR_WIDTH = 28;
|
||||
const VERTICAL_CONNECTOR_WIDTH = 1;
|
||||
|
||||
interface ITraceMetadata {
|
||||
traceId: string;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
hasMissingSpans: boolean;
|
||||
}
|
||||
interface ISuccessProps {
|
||||
spans: Span[];
|
||||
traceMetadata: ITraceMetadata;
|
||||
interestedSpanId: IInterestedSpan;
|
||||
uncollapsedNodes: string[];
|
||||
setInterestedSpanId: Dispatch<SetStateAction<IInterestedSpan>>;
|
||||
setTraceFlamegraphStatsWidth: Dispatch<SetStateAction<number>>;
|
||||
selectedSpan: Span | undefined;
|
||||
setSelectedSpan: Dispatch<SetStateAction<Span | undefined>>;
|
||||
}
|
||||
|
||||
function SpanOverview({
|
||||
span,
|
||||
isSpanCollapsed,
|
||||
handleCollapseUncollapse,
|
||||
handleSpanClick,
|
||||
handleAddSpanToFunnel,
|
||||
selectedSpan,
|
||||
filteredSpanIds,
|
||||
isFilterActive,
|
||||
traceMetadata,
|
||||
}: {
|
||||
span: Span;
|
||||
isSpanCollapsed: boolean;
|
||||
handleCollapseUncollapse: (id: string, collapse: boolean) => void;
|
||||
selectedSpan: Span | undefined;
|
||||
handleSpanClick: (span: Span) => void;
|
||||
handleAddSpanToFunnel: (span: Span) => void;
|
||||
filteredSpanIds: string[];
|
||||
isFilterActive: boolean;
|
||||
traceMetadata: ITraceMetadata;
|
||||
}): JSX.Element {
|
||||
const isRootSpan = span.level === 0;
|
||||
const { hasEditPermission } = useAppContext();
|
||||
|
||||
let color = generateColor(span.serviceName, themeColors.traceDetailColors);
|
||||
if (span.hasError) {
|
||||
color = `var(--danger-background)`;
|
||||
}
|
||||
|
||||
// Smart highlighting logic
|
||||
const isMatching =
|
||||
isFilterActive && (filteredSpanIds || []).includes(span.spanId);
|
||||
const isSelected = selectedSpan?.spanId === span.spanId;
|
||||
const isDimmed = isFilterActive && !isMatching && !isSelected;
|
||||
const isHighlighted = isFilterActive && isMatching && !isSelected;
|
||||
const isSelectedNonMatching = isSelected && isFilterActive && !isMatching;
|
||||
|
||||
return (
|
||||
<SpanHoverCard span={span} traceMetadata={traceMetadata}>
|
||||
<div
|
||||
className={cx('span-overview', {
|
||||
'interested-span': isSelected && (!isFilterActive || isMatching),
|
||||
'highlighted-span': isHighlighted,
|
||||
'selected-non-matching-span': isSelectedNonMatching,
|
||||
'dimmed-span': isDimmed,
|
||||
})}
|
||||
style={{
|
||||
paddingLeft: `${
|
||||
isRootSpan
|
||||
? span.level * CONNECTOR_WIDTH
|
||||
: (span.level - 1) * (CONNECTOR_WIDTH + VERTICAL_CONNECTOR_WIDTH)
|
||||
}px`,
|
||||
backgroundImage: `url('data:image/svg+xml;charset=UTF-8,<svg xmlns="http://www.w3.org/2000/svg" width="28" height="54"><line x1="0" y1="0" x2="0" y2="54" stroke="rgb(29 33 45)" stroke-width="1" /></svg>')`,
|
||||
backgroundRepeat: 'repeat',
|
||||
backgroundSize: `${CONNECTOR_WIDTH + 1}px 54px`,
|
||||
}}
|
||||
onClick={(): void => handleSpanClick(span)}
|
||||
>
|
||||
{!isRootSpan && (
|
||||
<div className="connector-lines">
|
||||
<div
|
||||
style={{
|
||||
width: `${CONNECTOR_WIDTH}px`,
|
||||
height: '1px',
|
||||
borderTop: '1px solid var(--bg-slate-400)',
|
||||
display: 'flex',
|
||||
flexShrink: 0,
|
||||
position: 'relative',
|
||||
top: '-10px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="span-overview-content">
|
||||
<section className="first-row">
|
||||
<div className="span-det">
|
||||
{span.hasChildren ? (
|
||||
<Button
|
||||
onClick={(event): void => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
handleCollapseUncollapse(span.spanId, !isSpanCollapsed);
|
||||
}}
|
||||
className="collapse-uncollapse-button"
|
||||
>
|
||||
{isSpanCollapsed ? (
|
||||
<ChevronRight size={14} />
|
||||
) : (
|
||||
<ChevronDown size={14} />
|
||||
)}
|
||||
<Typography.Text className="children-count">
|
||||
{span.subTreeNodeCount}
|
||||
</Typography.Text>
|
||||
</Button>
|
||||
) : (
|
||||
<Button className="collapse-uncollapse-button">
|
||||
<Leaf size={14} />
|
||||
</Button>
|
||||
)}
|
||||
<Typography.Text className="span-name">{span.name}</Typography.Text>
|
||||
</div>
|
||||
<HttpStatusBadge statusCode={span.tagMap?.['http.status_code']} />
|
||||
</section>
|
||||
<section className="second-row">
|
||||
<div style={{ width: '2px', background: color, height: '100%' }} />
|
||||
<Typography.Text className="service-name">
|
||||
{span.serviceName}
|
||||
</Typography.Text>
|
||||
{!!span.serviceName && !!span.name && (
|
||||
<div className="add-funnel-button">
|
||||
<span className="add-funnel-button__separator">·</span>
|
||||
<Tooltip
|
||||
title={
|
||||
!hasEditPermission
|
||||
? 'You need editor or admin access to add spans to funnels'
|
||||
: ''
|
||||
}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
className="add-funnel-button__button"
|
||||
onClick={(e): void => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleAddSpanToFunnel(span);
|
||||
}}
|
||||
disabled={!hasEditPermission}
|
||||
icon={
|
||||
<img
|
||||
className="add-funnel-button__icon"
|
||||
src={funnelAddUrl}
|
||||
alt="funnel-icon"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</SpanHoverCard>
|
||||
);
|
||||
}
|
||||
|
||||
export function SpanDuration({
|
||||
span,
|
||||
traceMetadata,
|
||||
handleSpanClick,
|
||||
selectedSpan,
|
||||
filteredSpanIds,
|
||||
isFilterActive,
|
||||
}: {
|
||||
span: Span;
|
||||
traceMetadata: ITraceMetadata;
|
||||
selectedSpan: Span | undefined;
|
||||
handleSpanClick: (span: Span) => void;
|
||||
filteredSpanIds: string[];
|
||||
isFilterActive: boolean;
|
||||
}): JSX.Element {
|
||||
const { time, timeUnitName } = convertTimeToRelevantUnit(
|
||||
span.durationNano / 1e6,
|
||||
);
|
||||
|
||||
const spread = traceMetadata.endTime - traceMetadata.startTime;
|
||||
const leftOffset = ((span.timestamp - traceMetadata.startTime) * 1e2) / spread;
|
||||
const width = (span.durationNano * 1e2) / (spread * 1e6);
|
||||
|
||||
let color = generateColor(span.serviceName, themeColors.traceDetailColors);
|
||||
|
||||
if (span.hasError) {
|
||||
color = `var(--danger-background)`;
|
||||
}
|
||||
|
||||
const [hasActionButtons, setHasActionButtons] = useState(false);
|
||||
|
||||
const isMatching =
|
||||
isFilterActive && (filteredSpanIds || []).includes(span.spanId);
|
||||
const isSelected = selectedSpan?.spanId === span.spanId;
|
||||
const isDimmed = isFilterActive && !isMatching && !isSelected;
|
||||
const isHighlighted = isFilterActive && isMatching && !isSelected;
|
||||
const isSelectedNonMatching = isSelected && isFilterActive && !isMatching;
|
||||
|
||||
const handleMouseEnter = (): void => {
|
||||
setHasActionButtons(true);
|
||||
};
|
||||
|
||||
const handleMouseLeave = (): void => {
|
||||
setHasActionButtons(false);
|
||||
};
|
||||
|
||||
// Calculate text positioning to handle overflow cases
|
||||
const textStyle = useMemo(() => {
|
||||
const spanRightEdge = leftOffset + width;
|
||||
const textWidthApprox = 8; // Approximate text width in percentage
|
||||
|
||||
// If span would cause text overflow, right-align text to span end
|
||||
if (leftOffset > 100 - textWidthApprox) {
|
||||
return {
|
||||
right: `${100 - spanRightEdge}%`,
|
||||
color,
|
||||
textAlign: 'right' as const,
|
||||
};
|
||||
}
|
||||
|
||||
// Default: left-align text to span start
|
||||
return {
|
||||
left: `${leftOffset}%`,
|
||||
color,
|
||||
};
|
||||
}, [leftOffset, width, color]);
|
||||
|
||||
return (
|
||||
<SpanHoverCard span={span} traceMetadata={traceMetadata}>
|
||||
<div
|
||||
className={cx('span-duration', {
|
||||
'interested-span': isSelected && (!isFilterActive || isMatching),
|
||||
'highlighted-span': isHighlighted,
|
||||
'selected-non-matching-span': isSelectedNonMatching,
|
||||
'dimmed-span': isDimmed,
|
||||
})}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onClick={(): void => handleSpanClick(span)}
|
||||
>
|
||||
<div
|
||||
className="span-line"
|
||||
style={{
|
||||
left: `${leftOffset}%`,
|
||||
width: `${width}%`,
|
||||
backgroundColor: color,
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{span.event?.map((event) => {
|
||||
const eventTimeMs = event.timeUnixNano / 1e6;
|
||||
const eventOffsetPercent =
|
||||
((eventTimeMs - span.timestamp) / (span.durationNano / 1e6)) * 100;
|
||||
const clampedOffset = Math.max(1, Math.min(eventOffsetPercent, 99));
|
||||
const { isError } = event;
|
||||
const { time, timeUnitName } = convertTimeToRelevantUnit(
|
||||
eventTimeMs - span.timestamp,
|
||||
);
|
||||
return (
|
||||
<Tooltip
|
||||
key={`${span.spanId}-event-${event.name}-${event.timeUnixNano}`}
|
||||
title={`${event.name} @ ${toFixed(time, 2)} ${timeUnitName}`}
|
||||
>
|
||||
<div
|
||||
className={`event-dot ${isError ? 'error' : ''}`}
|
||||
style={{
|
||||
left: `${clampedOffset}%`,
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{hasActionButtons && <SpanLineActionButtons span={span} />}
|
||||
<Typography.Text
|
||||
className="span-line-text"
|
||||
truncate={1}
|
||||
style={textStyle}
|
||||
>{`${toFixed(time, 2)} ${timeUnitName}`}</Typography.Text>
|
||||
</div>
|
||||
</SpanHoverCard>
|
||||
);
|
||||
}
|
||||
|
||||
// table config
|
||||
const columnDefHelper = createColumnHelper<Span>();
|
||||
|
||||
function getWaterfallColumns({
|
||||
handleCollapseUncollapse,
|
||||
uncollapsedNodes,
|
||||
traceMetadata,
|
||||
selectedSpan,
|
||||
handleSpanClick,
|
||||
handleAddSpanToFunnel,
|
||||
filteredSpanIds,
|
||||
isFilterActive,
|
||||
}: {
|
||||
handleCollapseUncollapse: (id: string, collapse: boolean) => void;
|
||||
uncollapsedNodes: string[];
|
||||
traceMetadata: ITraceMetadata;
|
||||
selectedSpan: Span | undefined;
|
||||
handleSpanClick: (span: Span) => void;
|
||||
handleAddSpanToFunnel: (span: Span) => void;
|
||||
filteredSpanIds: string[];
|
||||
isFilterActive: boolean;
|
||||
}): ColumnDef<Span, any>[] {
|
||||
const waterfallColumns: ColumnDef<Span, any>[] = [
|
||||
columnDefHelper.display({
|
||||
id: 'span-name',
|
||||
header: '',
|
||||
cell: (props): JSX.Element => (
|
||||
<SpanOverview
|
||||
span={props.row.original}
|
||||
handleCollapseUncollapse={handleCollapseUncollapse}
|
||||
isSpanCollapsed={!uncollapsedNodes.includes(props.row.original.spanId)}
|
||||
selectedSpan={selectedSpan}
|
||||
handleSpanClick={handleSpanClick}
|
||||
handleAddSpanToFunnel={handleAddSpanToFunnel}
|
||||
traceMetadata={traceMetadata}
|
||||
filteredSpanIds={filteredSpanIds}
|
||||
isFilterActive={isFilterActive}
|
||||
/>
|
||||
),
|
||||
size: 450,
|
||||
/**
|
||||
* Note: The TanStack table currently does not support percentage-based column sizing.
|
||||
* Therefore, we specify both `minSize` and `maxSize` for the "span-name" column to ensure
|
||||
* that its width remains between 240px and 900px. Setting a `maxSize` here is important
|
||||
* because the "span-duration" column has column resizing disabled, making it difficult
|
||||
* to enforce a minimum width for that column. By constraining the "span-name" column,
|
||||
* we indirectly control the minimum width available for the "span-duration" column.
|
||||
*/
|
||||
minSize: 240,
|
||||
maxSize: 900,
|
||||
}),
|
||||
columnDefHelper.display({
|
||||
id: 'span-duration',
|
||||
header: () => <div />,
|
||||
enableResizing: false,
|
||||
cell: (props): JSX.Element => (
|
||||
<SpanDuration
|
||||
span={props.row.original}
|
||||
traceMetadata={traceMetadata}
|
||||
selectedSpan={selectedSpan}
|
||||
handleSpanClick={handleSpanClick}
|
||||
filteredSpanIds={filteredSpanIds}
|
||||
isFilterActive={isFilterActive}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
];
|
||||
|
||||
return waterfallColumns;
|
||||
}
|
||||
|
||||
function Success(props: ISuccessProps): JSX.Element {
|
||||
const {
|
||||
spans,
|
||||
traceMetadata,
|
||||
interestedSpanId,
|
||||
uncollapsedNodes,
|
||||
setInterestedSpanId,
|
||||
setTraceFlamegraphStatsWidth,
|
||||
setSelectedSpan,
|
||||
selectedSpan,
|
||||
} = props;
|
||||
|
||||
const [filteredSpanIds, setFilteredSpanIds] = useState<string[]>([]);
|
||||
const [isFilterActive, setIsFilterActive] = useState<boolean>(false);
|
||||
const virtualizerRef = useRef<Virtualizer<HTMLDivElement, Element>>();
|
||||
|
||||
const handleFilteredSpansChange = useCallback(
|
||||
(spanIds: string[], isActive: boolean) => {
|
||||
setFilteredSpanIds(spanIds);
|
||||
setIsFilterActive(isActive);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleCollapseUncollapse = useCallback(
|
||||
(spanId: string, collapse: boolean) => {
|
||||
setInterestedSpanId({ spanId, isUncollapsed: !collapse });
|
||||
},
|
||||
[setInterestedSpanId],
|
||||
);
|
||||
|
||||
const handleVirtualizerInstanceChanged = (
|
||||
instance: Virtualizer<HTMLDivElement, Element>,
|
||||
): void => {
|
||||
const { range } = instance;
|
||||
// when there are less than 500 elements in the API call that means there is nothing to fetch on top and bottom so
|
||||
// do not trigger the API call
|
||||
if (spans.length < 500) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (range?.startIndex === 0 && instance.isScrolling) {
|
||||
// do not trigger for trace root as nothing to fetch above
|
||||
if (spans[0].level !== 0) {
|
||||
setInterestedSpanId({ spanId: spans[0].spanId, isUncollapsed: false });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (range?.endIndex === spans.length - 1 && instance.isScrolling) {
|
||||
setInterestedSpanId({
|
||||
spanId: spans[spans.length - 1].spanId,
|
||||
isUncollapsed: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const [isAddSpanToFunnelModalOpen, setIsAddSpanToFunnelModalOpen] =
|
||||
useState(false);
|
||||
const [selectedSpanToAddToFunnel, setSelectedSpanToAddToFunnel] = useState<
|
||||
Span | undefined
|
||||
>(undefined);
|
||||
const handleAddSpanToFunnel = useCallback((span: Span): void => {
|
||||
setIsAddSpanToFunnelModalOpen(true);
|
||||
setSelectedSpanToAddToFunnel(span);
|
||||
}, []);
|
||||
|
||||
const urlQuery = useUrlQuery();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
|
||||
const handleSpanClick = useCallback(
|
||||
(span: Span): void => {
|
||||
setSelectedSpan(span);
|
||||
if (span?.spanId) {
|
||||
urlQuery.set('spanId', span?.spanId);
|
||||
}
|
||||
|
||||
safeNavigate({ search: urlQuery.toString() });
|
||||
},
|
||||
[setSelectedSpan, urlQuery, safeNavigate],
|
||||
);
|
||||
|
||||
const columns = useMemo(
|
||||
() =>
|
||||
getWaterfallColumns({
|
||||
handleCollapseUncollapse,
|
||||
uncollapsedNodes,
|
||||
traceMetadata,
|
||||
selectedSpan,
|
||||
handleSpanClick,
|
||||
handleAddSpanToFunnel,
|
||||
filteredSpanIds,
|
||||
isFilterActive,
|
||||
}),
|
||||
[
|
||||
handleCollapseUncollapse,
|
||||
uncollapsedNodes,
|
||||
traceMetadata,
|
||||
selectedSpan,
|
||||
handleSpanClick,
|
||||
handleAddSpanToFunnel,
|
||||
filteredSpanIds,
|
||||
isFilterActive,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (interestedSpanId.spanId !== '' && virtualizerRef.current) {
|
||||
const idx = spans.findIndex(
|
||||
(span) => span.spanId === interestedSpanId.spanId,
|
||||
);
|
||||
if (idx !== -1) {
|
||||
setTimeout(() => {
|
||||
virtualizerRef.current?.scrollToIndex(idx, {
|
||||
align: 'center',
|
||||
behavior: 'auto',
|
||||
});
|
||||
}, 400);
|
||||
|
||||
setSelectedSpan(spans[idx]);
|
||||
}
|
||||
} else {
|
||||
setSelectedSpan((prev) => {
|
||||
if (!prev) {
|
||||
return spans[0];
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
}, [interestedSpanId, setSelectedSpan, spans]);
|
||||
|
||||
return (
|
||||
<div className="success-content">
|
||||
{traceMetadata.hasMissingSpans && (
|
||||
<div className="missing-spans">
|
||||
<section className="left-info">
|
||||
<CircleAlert size={14} />
|
||||
<Typography.Text className="text">
|
||||
This trace has missing spans
|
||||
</Typography.Text>
|
||||
</section>
|
||||
<Button
|
||||
icon={<ArrowUpRight size={14} />}
|
||||
className="right-info"
|
||||
type="text"
|
||||
onClick={(): WindowProxy | null =>
|
||||
window.open(
|
||||
'https://signoz.io/docs/userguide/traces/#missing-spans',
|
||||
'_blank',
|
||||
)
|
||||
}
|
||||
>
|
||||
Learn More
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<Filters
|
||||
startTime={traceMetadata.startTime / 1e3}
|
||||
endTime={traceMetadata.endTime / 1e3}
|
||||
traceID={traceMetadata.traceId}
|
||||
onFilteredSpansChange={handleFilteredSpansChange}
|
||||
/>
|
||||
<TableV3
|
||||
columns={columns}
|
||||
data={spans}
|
||||
config={{
|
||||
handleVirtualizerInstanceChanged,
|
||||
}}
|
||||
customClassName={cx(
|
||||
'waterfall-table',
|
||||
traceMetadata.hasMissingSpans ? 'missing-spans-waterfall-table' : '',
|
||||
)}
|
||||
virtualiserRef={virtualizerRef}
|
||||
setColumnWidths={setTraceFlamegraphStatsWidth}
|
||||
/>
|
||||
{selectedSpanToAddToFunnel && (
|
||||
<AddSpanToFunnelModal
|
||||
span={selectedSpanToAddToFunnel}
|
||||
isOpen={isAddSpanToFunnelModalOpen}
|
||||
onClose={(): void => setIsAddSpanToFunnelModalOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Success;
|
||||
@@ -1,264 +0,0 @@
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { fireEvent, render, screen } from 'tests/test-utils';
|
||||
import { Span } from 'types/api/trace/getTraceV2';
|
||||
|
||||
import { SpanDuration } from '../Success';
|
||||
|
||||
// Constants to avoid string duplication
|
||||
const SPAN_DURATION_TEXT = '1.16 ms';
|
||||
const SPAN_DURATION_CLASS = '.span-duration';
|
||||
const INTERESTED_SPAN_CLASS = 'interested-span';
|
||||
const HIGHLIGHTED_SPAN_CLASS = 'highlighted-span';
|
||||
const DIMMED_SPAN_CLASS = 'dimmed-span';
|
||||
const SELECTED_NON_MATCHING_SPAN_CLASS = 'selected-non-matching-span';
|
||||
|
||||
// Mock the hooks
|
||||
jest.mock('hooks/useUrlQuery');
|
||||
jest.mock('@signozhq/ui/badge', () => ({
|
||||
...jest.requireActual('@signozhq/ui/badge'),
|
||||
Badge: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockSpan: Span = {
|
||||
spanId: 'test-span-id',
|
||||
name: 'test-span',
|
||||
serviceName: 'test-service',
|
||||
durationNano: 1160000, // 1ms in nano
|
||||
timestamp: 1234567890,
|
||||
rootSpanId: 'test-root-span-id',
|
||||
parentSpanId: 'test-parent-span-id',
|
||||
traceId: 'test-trace-id',
|
||||
hasError: false,
|
||||
kind: 0,
|
||||
references: [],
|
||||
tagMap: {},
|
||||
event: [],
|
||||
rootName: 'test-root-name',
|
||||
statusMessage: 'test-status-message',
|
||||
statusCodeString: 'test-status-code-string',
|
||||
spanKind: 'test-span-kind',
|
||||
hasChildren: false,
|
||||
hasSibling: false,
|
||||
subTreeNodeCount: 0,
|
||||
level: 0,
|
||||
};
|
||||
|
||||
const mockTraceMetadata = {
|
||||
traceId: 'test-trace-id',
|
||||
startTime: 1234567000,
|
||||
endTime: 1234569000,
|
||||
hasMissingSpans: false,
|
||||
};
|
||||
|
||||
const mockSafeNavigate = jest.fn();
|
||||
|
||||
jest.mock('hooks/useSafeNavigate', () => ({
|
||||
useSafeNavigate: (): any => ({
|
||||
safeNavigate: mockSafeNavigate,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('SpanDuration', () => {
|
||||
const mockSetSelectedSpan = jest.fn();
|
||||
const mockUrlQuerySet = jest.fn();
|
||||
const mockUrlQueryGet = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Mock URL query hook
|
||||
(useUrlQuery as jest.Mock).mockReturnValue({
|
||||
set: mockUrlQuerySet,
|
||||
get: mockUrlQueryGet,
|
||||
toString: () => 'spanId=test-span-id',
|
||||
});
|
||||
});
|
||||
|
||||
it('calls handleSpanClick when clicked', () => {
|
||||
const mockHandleSpanClick = jest.fn();
|
||||
|
||||
render(
|
||||
<SpanDuration
|
||||
span={mockSpan}
|
||||
traceMetadata={mockTraceMetadata}
|
||||
selectedSpan={undefined}
|
||||
handleSpanClick={mockHandleSpanClick}
|
||||
filteredSpanIds={[]}
|
||||
isFilterActive={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Find and click the span duration element
|
||||
const spanElement = screen.getByText(SPAN_DURATION_TEXT);
|
||||
fireEvent.click(spanElement);
|
||||
|
||||
// Verify handleSpanClick was called with the correct span
|
||||
expect(mockHandleSpanClick).toHaveBeenCalledWith(mockSpan);
|
||||
});
|
||||
|
||||
it('shows action buttons on hover', () => {
|
||||
render(
|
||||
<SpanDuration
|
||||
span={mockSpan}
|
||||
traceMetadata={mockTraceMetadata}
|
||||
selectedSpan={undefined}
|
||||
handleSpanClick={mockSetSelectedSpan}
|
||||
filteredSpanIds={[]}
|
||||
isFilterActive={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
const spanElement = screen.getByText(SPAN_DURATION_TEXT);
|
||||
|
||||
// Initially, action buttons should not be visible
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
||||
|
||||
// Hover over the span
|
||||
fireEvent.mouseEnter(spanElement);
|
||||
|
||||
// Action buttons should now be visible
|
||||
expect(screen.getByRole('button')).toBeInTheDocument();
|
||||
|
||||
// Mouse leave should hide the buttons
|
||||
fireEvent.mouseLeave(spanElement);
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies interested-span class when span is selected', () => {
|
||||
render(
|
||||
<SpanDuration
|
||||
span={mockSpan}
|
||||
traceMetadata={mockTraceMetadata}
|
||||
selectedSpan={mockSpan}
|
||||
handleSpanClick={mockSetSelectedSpan}
|
||||
filteredSpanIds={[]}
|
||||
isFilterActive={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
const spanElement = screen
|
||||
.getByText(SPAN_DURATION_TEXT)
|
||||
.closest(SPAN_DURATION_CLASS);
|
||||
expect(spanElement).toHaveClass(INTERESTED_SPAN_CLASS);
|
||||
});
|
||||
|
||||
it('applies highlighted-span class when span matches filter', () => {
|
||||
render(
|
||||
<SpanDuration
|
||||
span={mockSpan}
|
||||
traceMetadata={mockTraceMetadata}
|
||||
selectedSpan={undefined}
|
||||
handleSpanClick={mockSetSelectedSpan}
|
||||
filteredSpanIds={[mockSpan.spanId]}
|
||||
isFilterActive
|
||||
/>,
|
||||
);
|
||||
|
||||
const spanElement = screen
|
||||
.getByText(SPAN_DURATION_TEXT)
|
||||
.closest(SPAN_DURATION_CLASS);
|
||||
expect(spanElement).toHaveClass(HIGHLIGHTED_SPAN_CLASS);
|
||||
expect(spanElement).not.toHaveClass(INTERESTED_SPAN_CLASS);
|
||||
});
|
||||
|
||||
it('applies dimmed-span class when span does not match filter', () => {
|
||||
render(
|
||||
<SpanDuration
|
||||
span={mockSpan}
|
||||
traceMetadata={mockTraceMetadata}
|
||||
selectedSpan={undefined}
|
||||
handleSpanClick={mockSetSelectedSpan}
|
||||
filteredSpanIds={['other-span-id']}
|
||||
isFilterActive
|
||||
/>,
|
||||
);
|
||||
|
||||
const spanElement = screen
|
||||
.getByText(SPAN_DURATION_TEXT)
|
||||
.closest(SPAN_DURATION_CLASS);
|
||||
expect(spanElement).toHaveClass(DIMMED_SPAN_CLASS);
|
||||
expect(spanElement).not.toHaveClass(HIGHLIGHTED_SPAN_CLASS);
|
||||
});
|
||||
|
||||
it('prioritizes interested-span over highlighted-span when span is selected and matches filter', () => {
|
||||
render(
|
||||
<SpanDuration
|
||||
span={mockSpan}
|
||||
traceMetadata={mockTraceMetadata}
|
||||
selectedSpan={mockSpan}
|
||||
handleSpanClick={mockSetSelectedSpan}
|
||||
filteredSpanIds={[mockSpan.spanId]}
|
||||
isFilterActive
|
||||
/>,
|
||||
);
|
||||
|
||||
const spanElement = screen
|
||||
.getByText(SPAN_DURATION_TEXT)
|
||||
.closest(SPAN_DURATION_CLASS);
|
||||
expect(spanElement).toHaveClass(INTERESTED_SPAN_CLASS);
|
||||
expect(spanElement).not.toHaveClass(HIGHLIGHTED_SPAN_CLASS);
|
||||
expect(spanElement).not.toHaveClass(DIMMED_SPAN_CLASS);
|
||||
});
|
||||
|
||||
it('applies selected-non-matching-span class when span is selected but does not match filter', () => {
|
||||
render(
|
||||
<SpanDuration
|
||||
span={mockSpan}
|
||||
traceMetadata={mockTraceMetadata}
|
||||
selectedSpan={mockSpan}
|
||||
handleSpanClick={mockSetSelectedSpan}
|
||||
filteredSpanIds={['different-span-id']}
|
||||
isFilterActive
|
||||
/>,
|
||||
);
|
||||
|
||||
const spanElement = screen
|
||||
.getByText(SPAN_DURATION_TEXT)
|
||||
.closest(SPAN_DURATION_CLASS);
|
||||
expect(spanElement).toHaveClass(SELECTED_NON_MATCHING_SPAN_CLASS);
|
||||
expect(spanElement).not.toHaveClass(INTERESTED_SPAN_CLASS);
|
||||
expect(spanElement).not.toHaveClass(HIGHLIGHTED_SPAN_CLASS);
|
||||
expect(spanElement).not.toHaveClass(DIMMED_SPAN_CLASS);
|
||||
});
|
||||
|
||||
it('applies interested-span class when span is selected and no filter is active', () => {
|
||||
render(
|
||||
<SpanDuration
|
||||
span={mockSpan}
|
||||
traceMetadata={mockTraceMetadata}
|
||||
selectedSpan={mockSpan}
|
||||
handleSpanClick={mockSetSelectedSpan}
|
||||
filteredSpanIds={[]}
|
||||
isFilterActive={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
const spanElement = screen
|
||||
.getByText(SPAN_DURATION_TEXT)
|
||||
.closest(SPAN_DURATION_CLASS);
|
||||
expect(spanElement).toHaveClass(INTERESTED_SPAN_CLASS);
|
||||
expect(spanElement).not.toHaveClass(SELECTED_NON_MATCHING_SPAN_CLASS);
|
||||
expect(spanElement).not.toHaveClass(HIGHLIGHTED_SPAN_CLASS);
|
||||
expect(spanElement).not.toHaveClass(DIMMED_SPAN_CLASS);
|
||||
});
|
||||
|
||||
it('dims span when filter is active but no matches found', () => {
|
||||
render(
|
||||
<SpanDuration
|
||||
span={mockSpan}
|
||||
traceMetadata={mockTraceMetadata}
|
||||
selectedSpan={undefined}
|
||||
handleSpanClick={mockSetSelectedSpan}
|
||||
filteredSpanIds={[]} // Empty array but filter is active
|
||||
isFilterActive // This is the key difference
|
||||
/>,
|
||||
);
|
||||
|
||||
const spanElement = screen
|
||||
.getByText(SPAN_DURATION_TEXT)
|
||||
.closest(SPAN_DURATION_CLASS);
|
||||
expect(spanElement).toHaveClass(DIMMED_SPAN_CLASS);
|
||||
expect(spanElement).not.toHaveClass(HIGHLIGHTED_SPAN_CLASS);
|
||||
expect(spanElement).not.toHaveClass(INTERESTED_SPAN_CLASS);
|
||||
});
|
||||
});
|
||||
@@ -1,447 +0,0 @@
|
||||
import React from 'react';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import { Span } from 'types/api/trace/getTraceV2';
|
||||
|
||||
import Success from '../Success';
|
||||
|
||||
// Mock the required hooks with proper typing
|
||||
const mockSafeNavigate = jest.fn() as jest.MockedFunction<
|
||||
(params: { search: string }) => void
|
||||
>;
|
||||
const mockUrlQuery = new URLSearchParams();
|
||||
|
||||
jest.mock('hooks/useSafeNavigate', () => ({
|
||||
useSafeNavigate: (): { safeNavigate: typeof mockSafeNavigate } => ({
|
||||
safeNavigate: mockSafeNavigate,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useUrlQuery', () => (): URLSearchParams => mockUrlQuery);
|
||||
|
||||
// App provider is already handled by test-utils
|
||||
|
||||
// React Router is already globally mocked
|
||||
|
||||
// Mock complex external dependencies that cause provider issues
|
||||
jest.mock('components/SpanHoverCard/SpanHoverCard', () => {
|
||||
function SpanHoverCard({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}): JSX.Element {
|
||||
return <div>{children}</div>;
|
||||
}
|
||||
SpanHoverCard.displayName = 'SpanHoverCard';
|
||||
return SpanHoverCard;
|
||||
});
|
||||
|
||||
// Mock the Filters component that's causing React Query issues
|
||||
jest.mock('../Filters/Filters', () => {
|
||||
function Filters(): null {
|
||||
return null;
|
||||
}
|
||||
Filters.displayName = 'Filters';
|
||||
return Filters;
|
||||
});
|
||||
|
||||
// Mock other potential dependencies
|
||||
jest.mock(
|
||||
'container/TraceWaterfall/AddSpanToFunnelModal/AddSpanToFunnelModal',
|
||||
() => {
|
||||
function AddSpanToFunnelModal(): null {
|
||||
return null;
|
||||
}
|
||||
AddSpanToFunnelModal.displayName = 'AddSpanToFunnelModal';
|
||||
return AddSpanToFunnelModal;
|
||||
},
|
||||
);
|
||||
|
||||
jest.mock('container/TraceWaterfall/SpanLineActionButtons', () => {
|
||||
function SpanLineActionButtons(): null {
|
||||
return null;
|
||||
}
|
||||
SpanLineActionButtons.displayName = 'SpanLineActionButtons';
|
||||
return SpanLineActionButtons;
|
||||
});
|
||||
|
||||
jest.mock('components/HttpStatusBadge/HttpStatusBadge', () => {
|
||||
function HttpStatusBadge(): null {
|
||||
return null;
|
||||
}
|
||||
HttpStatusBadge.displayName = 'HttpStatusBadge';
|
||||
return HttpStatusBadge;
|
||||
});
|
||||
|
||||
// Mock other utilities that might cause issues
|
||||
jest.mock('lib/uPlotLib/utils/generateColor', () => ({
|
||||
generateColor: (): string => '#1890ff',
|
||||
}));
|
||||
|
||||
jest.mock('container/TraceDetail/utils', () => ({
|
||||
convertTimeToRelevantUnit: (
|
||||
value: number,
|
||||
): { time: number; timeUnitName: string } => ({
|
||||
time: value < 1000 ? value : value / 1000,
|
||||
timeUnitName: value < 1000 ? 'ms' : 's',
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('utils/toFixed', () => ({
|
||||
toFixed: (value: number, decimals: number): string => value.toFixed(decimals),
|
||||
}));
|
||||
|
||||
// Create a simplified mock TableV3 that renders the actual column components
|
||||
jest.mock('components/TableV3/TableV3', () => ({
|
||||
TableV3: ({
|
||||
columns,
|
||||
data,
|
||||
}: {
|
||||
columns: unknown[];
|
||||
data: Span[];
|
||||
}): JSX.Element => {
|
||||
// Get the current props from the columns (which contain the current state)
|
||||
const spanOverviewColumn = columns[0] as {
|
||||
cell?: (props: any) => JSX.Element;
|
||||
};
|
||||
const spanDurationColumn = columns[1] as {
|
||||
cell?: (props: any) => JSX.Element;
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-testid="trace-table">
|
||||
{data.map((row: Span) => {
|
||||
// Create proper cell props that match what TanStack Table expects
|
||||
const cellProps = {
|
||||
row: {
|
||||
original: row,
|
||||
getValue: (): Span => row,
|
||||
getAllCells: (): any[] => [],
|
||||
getVisibleCells: (): any[] => [],
|
||||
getUniqueValues: (): any[] => [],
|
||||
getIsSelected: (): boolean => false,
|
||||
getIsSomeSelected: (): boolean => false,
|
||||
getIsAllSelected: (): boolean => false,
|
||||
getCanSelect: (): boolean => true,
|
||||
getCanSelectSubRows: (): boolean => true,
|
||||
getCanSelectAll: (): boolean => true,
|
||||
toggleSelected: (): void => {},
|
||||
getToggleSelectedHandler: (): (() => void) => (): void => {},
|
||||
},
|
||||
column: { id: 'span-name' },
|
||||
table: {},
|
||||
cell: {},
|
||||
renderValue: (): Span => row,
|
||||
getValue: (): Span => row,
|
||||
};
|
||||
|
||||
const durationCellProps = {
|
||||
...cellProps,
|
||||
column: { id: 'span-duration' },
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={row.spanId} data-testid={`table-row-${row.spanId}`}>
|
||||
{/* Render span overview column */}
|
||||
<div data-testid={`cell-0-${row.spanId}`}>
|
||||
{spanOverviewColumn?.cell?.(cellProps)}
|
||||
</div>
|
||||
{/* Render span duration column */}
|
||||
<div data-testid={`cell-1-${row.spanId}`}>
|
||||
{spanDurationColumn?.cell?.(durationCellProps)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
const mockTraceMetadata = {
|
||||
traceId: 'test-trace-id',
|
||||
startTime: 1679748225000000,
|
||||
endTime: 1679748226000000,
|
||||
hasMissingSpans: false,
|
||||
};
|
||||
|
||||
const createMockSpan = (spanId: string, level = 1): Span => ({
|
||||
spanId,
|
||||
traceId: 'test-trace-id',
|
||||
rootSpanId: 'span-1',
|
||||
parentSpanId: level === 0 ? '' : 'span-1',
|
||||
name: `Test Span ${spanId}`,
|
||||
serviceName: 'test-service',
|
||||
timestamp: mockTraceMetadata.startTime + level * 100000,
|
||||
durationNano: 50000000,
|
||||
level,
|
||||
hasError: false,
|
||||
kind: 1,
|
||||
references: [],
|
||||
tagMap: {},
|
||||
event: [],
|
||||
rootName: 'Test Root Span',
|
||||
statusMessage: '',
|
||||
statusCodeString: 'OK',
|
||||
spanKind: 'server',
|
||||
hasChildren: false,
|
||||
hasSibling: false,
|
||||
subTreeNodeCount: 1,
|
||||
});
|
||||
|
||||
const mockSpans = [
|
||||
createMockSpan('span-1', 0),
|
||||
createMockSpan('span-2', 1),
|
||||
createMockSpan('span-3', 1),
|
||||
];
|
||||
|
||||
// Shared TestComponent for all tests
|
||||
function TestComponent(): JSX.Element {
|
||||
const [selectedSpan, setSelectedSpan] = React.useState<Span | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
return (
|
||||
<Success
|
||||
spans={mockSpans}
|
||||
traceMetadata={mockTraceMetadata}
|
||||
interestedSpanId={{ spanId: '', isUncollapsed: false }}
|
||||
uncollapsedNodes={mockSpans.map((s) => s.spanId)}
|
||||
setInterestedSpanId={jest.fn()}
|
||||
setTraceFlamegraphStatsWidth={jest.fn()}
|
||||
selectedSpan={selectedSpan}
|
||||
setSelectedSpan={setSelectedSpan}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe('Span Click User Flows', () => {
|
||||
const FIRST_SPAN_TEST_ID = 'cell-0-span-1';
|
||||
const FIRST_SPAN_DURATION_TEST_ID = 'cell-1-span-1';
|
||||
const SECOND_SPAN_TEST_ID = 'cell-0-span-2';
|
||||
const SPAN_OVERVIEW_CLASS = '.span-overview';
|
||||
const SPAN_DURATION_CLASS = '.span-duration';
|
||||
const INTERESTED_SPAN_CLASS = 'interested-span';
|
||||
const SECOND_SPAN_DURATION_TEST_ID = 'cell-1-span-2';
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
// Clear all URL parameters
|
||||
Array.from(mockUrlQuery.keys()).forEach((key) => mockUrlQuery.delete(key));
|
||||
});
|
||||
|
||||
it('clicking span updates URL with spanId parameter', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<Success
|
||||
spans={mockSpans}
|
||||
traceMetadata={mockTraceMetadata}
|
||||
interestedSpanId={{ spanId: '', isUncollapsed: false }}
|
||||
uncollapsedNodes={mockSpans.map((s) => s.spanId)}
|
||||
setInterestedSpanId={jest.fn()}
|
||||
setTraceFlamegraphStatsWidth={jest.fn()}
|
||||
selectedSpan={undefined}
|
||||
setSelectedSpan={jest.fn()}
|
||||
/>,
|
||||
undefined,
|
||||
{ initialRoute: '/trace' },
|
||||
);
|
||||
|
||||
// Initially URL should not have spanId
|
||||
expect(mockUrlQuery.get('spanId')).toBeNull();
|
||||
|
||||
// Click on the actual span element (not the wrapper)
|
||||
const spanOverview = screen.getByTestId(FIRST_SPAN_TEST_ID);
|
||||
const spanElement = spanOverview.querySelector(
|
||||
SPAN_OVERVIEW_CLASS,
|
||||
) as HTMLElement;
|
||||
await user.click(spanElement);
|
||||
|
||||
// Verify URL was updated with spanId
|
||||
expect(mockUrlQuery.get('spanId')).toBe('span-1');
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith({
|
||||
search: expect.stringContaining('spanId=span-1'),
|
||||
});
|
||||
});
|
||||
|
||||
it('clicking span duration visually selects the span', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<TestComponent />, undefined, {
|
||||
initialRoute: '/trace',
|
||||
});
|
||||
|
||||
// Wait for initial render and selection
|
||||
await waitFor(() => {
|
||||
const spanDuration = screen.getByTestId(FIRST_SPAN_DURATION_TEST_ID);
|
||||
const spanDurationElement = spanDuration.querySelector(
|
||||
SPAN_DURATION_CLASS,
|
||||
) as HTMLElement;
|
||||
expect(spanDurationElement).toHaveClass(INTERESTED_SPAN_CLASS);
|
||||
});
|
||||
|
||||
// Click on span-2 to test selection change
|
||||
const span2Duration = screen.getByTestId(SECOND_SPAN_DURATION_TEST_ID);
|
||||
const span2DurationElement = span2Duration.querySelector(
|
||||
SPAN_DURATION_CLASS,
|
||||
) as HTMLElement;
|
||||
await user.click(span2DurationElement);
|
||||
|
||||
// Wait for the state update and re-render
|
||||
await waitFor(() => {
|
||||
const spanDuration = screen.getByTestId(FIRST_SPAN_DURATION_TEST_ID);
|
||||
const spanDurationElement = spanDuration.querySelector(
|
||||
SPAN_DURATION_CLASS,
|
||||
) as HTMLElement;
|
||||
const span2Duration = screen.getByTestId(SECOND_SPAN_DURATION_TEST_ID);
|
||||
const span2DurationElement = span2Duration.querySelector(
|
||||
SPAN_DURATION_CLASS,
|
||||
) as HTMLElement;
|
||||
|
||||
expect(spanDurationElement).not.toHaveClass(INTERESTED_SPAN_CLASS);
|
||||
expect(span2DurationElement).toHaveClass(INTERESTED_SPAN_CLASS);
|
||||
});
|
||||
});
|
||||
|
||||
it('both click areas produce the same visual result', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<TestComponent />, undefined, {
|
||||
initialRoute: '/trace',
|
||||
});
|
||||
|
||||
// Wait for initial render and selection
|
||||
await waitFor(() => {
|
||||
const spanOverview = screen.getByTestId(FIRST_SPAN_TEST_ID);
|
||||
const spanDuration = screen.getByTestId(FIRST_SPAN_DURATION_TEST_ID);
|
||||
const spanOverviewElement = spanOverview.querySelector(
|
||||
SPAN_OVERVIEW_CLASS,
|
||||
) as HTMLElement;
|
||||
const spanDurationElement = spanDuration.querySelector(
|
||||
SPAN_DURATION_CLASS,
|
||||
) as HTMLElement;
|
||||
|
||||
// Initially both areas should show the same visual selection (first span is auto-selected)
|
||||
expect(spanOverviewElement).toHaveClass(INTERESTED_SPAN_CLASS);
|
||||
expect(spanDurationElement).toHaveClass(INTERESTED_SPAN_CLASS);
|
||||
});
|
||||
|
||||
// Click span-2 to test selection change
|
||||
const span2Overview = screen.getByTestId(SECOND_SPAN_TEST_ID);
|
||||
const span2Element = span2Overview.querySelector(
|
||||
SPAN_OVERVIEW_CLASS,
|
||||
) as HTMLElement;
|
||||
await user.click(span2Element);
|
||||
|
||||
// Wait for the state update and re-render
|
||||
await waitFor(() => {
|
||||
const spanOverview = screen.getByTestId(FIRST_SPAN_TEST_ID);
|
||||
const spanDuration = screen.getByTestId(FIRST_SPAN_DURATION_TEST_ID);
|
||||
const spanOverviewElement = spanOverview.querySelector(
|
||||
SPAN_OVERVIEW_CLASS,
|
||||
) as HTMLElement;
|
||||
const spanDurationElement = spanDuration.querySelector(
|
||||
SPAN_DURATION_CLASS,
|
||||
) as HTMLElement;
|
||||
|
||||
// Now span-2 should be selected, span-1 should not
|
||||
expect(spanOverviewElement).not.toHaveClass(INTERESTED_SPAN_CLASS);
|
||||
expect(spanDurationElement).not.toHaveClass(INTERESTED_SPAN_CLASS);
|
||||
|
||||
// Check that span-2 is selected
|
||||
const span2Overview = screen.getByTestId(SECOND_SPAN_TEST_ID);
|
||||
const span2Duration = screen.getByTestId(SECOND_SPAN_DURATION_TEST_ID);
|
||||
const span2OverviewElement = span2Overview.querySelector(
|
||||
SPAN_OVERVIEW_CLASS,
|
||||
) as HTMLElement;
|
||||
const span2DurationElement = span2Duration.querySelector(
|
||||
SPAN_DURATION_CLASS,
|
||||
) as HTMLElement;
|
||||
|
||||
expect(span2OverviewElement).toHaveClass(INTERESTED_SPAN_CLASS);
|
||||
expect(span2DurationElement).toHaveClass(INTERESTED_SPAN_CLASS);
|
||||
});
|
||||
});
|
||||
|
||||
it('clicking different spans updates selection correctly', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<TestComponent />, undefined, {
|
||||
initialRoute: '/trace',
|
||||
});
|
||||
|
||||
// Wait for initial render and selection
|
||||
await waitFor(() => {
|
||||
const span1Overview = screen.getByTestId(FIRST_SPAN_TEST_ID);
|
||||
const span1Element = span1Overview.querySelector(
|
||||
SPAN_OVERVIEW_CLASS,
|
||||
) as HTMLElement;
|
||||
expect(span1Element).toHaveClass(INTERESTED_SPAN_CLASS);
|
||||
});
|
||||
|
||||
// Click second span
|
||||
const span2Overview = screen.getByTestId(SECOND_SPAN_TEST_ID);
|
||||
const span2Element = span2Overview.querySelector(
|
||||
SPAN_OVERVIEW_CLASS,
|
||||
) as HTMLElement;
|
||||
await user.click(span2Element);
|
||||
|
||||
// Wait for the state update and re-render
|
||||
await waitFor(() => {
|
||||
const span1Overview = screen.getByTestId(FIRST_SPAN_TEST_ID);
|
||||
const span1Element = span1Overview.querySelector(
|
||||
SPAN_OVERVIEW_CLASS,
|
||||
) as HTMLElement;
|
||||
const span2Overview = screen.getByTestId(SECOND_SPAN_TEST_ID);
|
||||
const span2Element = span2Overview.querySelector(
|
||||
SPAN_OVERVIEW_CLASS,
|
||||
) as HTMLElement;
|
||||
|
||||
// Second span should be selected, first should not
|
||||
expect(span1Element).not.toHaveClass(INTERESTED_SPAN_CLASS);
|
||||
expect(span2Element).toHaveClass(INTERESTED_SPAN_CLASS);
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves existing URL parameters when selecting spans', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
// Pre-populate URL with existing parameters
|
||||
mockUrlQuery.set('existingParam', 'existingValue');
|
||||
mockUrlQuery.set('anotherParam', 'anotherValue');
|
||||
|
||||
render(
|
||||
<Success
|
||||
spans={mockSpans}
|
||||
traceMetadata={mockTraceMetadata}
|
||||
interestedSpanId={{ spanId: '', isUncollapsed: false }}
|
||||
uncollapsedNodes={mockSpans.map((s) => s.spanId)}
|
||||
setInterestedSpanId={jest.fn()}
|
||||
setTraceFlamegraphStatsWidth={jest.fn()}
|
||||
selectedSpan={undefined}
|
||||
setSelectedSpan={jest.fn()}
|
||||
/>,
|
||||
undefined,
|
||||
{ initialRoute: '/trace' },
|
||||
);
|
||||
|
||||
// Click on the actual span element (not the wrapper)
|
||||
const spanOverview = screen.getByTestId(FIRST_SPAN_TEST_ID);
|
||||
const spanElement = spanOverview.querySelector(
|
||||
SPAN_OVERVIEW_CLASS,
|
||||
) as HTMLElement;
|
||||
await user.click(spanElement);
|
||||
|
||||
// Verify existing parameters are preserved and spanId is added
|
||||
expect(mockUrlQuery.get('existingParam')).toBe('existingValue');
|
||||
expect(mockUrlQuery.get('anotherParam')).toBe('anotherValue');
|
||||
expect(mockUrlQuery.get('spanId')).toBe('span-1');
|
||||
|
||||
// Verify navigation was called with all parameters
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith({
|
||||
search: expect.stringMatching(
|
||||
/existingParam=existingValue.*anotherParam=anotherValue.*spanId=span-1/,
|
||||
),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +0,0 @@
|
||||
export enum TraceWaterfallStates {
|
||||
LOADING = 'LOADING',
|
||||
SUCCESS = 'SUCCESS',
|
||||
NO_DATA = 'NO_DATA',
|
||||
ERROR = 'ERROR',
|
||||
FETCHING_WITH_OLD_DATA_PRESENT = 'FETCHING_WTIH_OLD_DATA_PRESENT',
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { useQuery, UseQueryResult } from 'react-query';
|
||||
import getTraceFlamegraph from 'api/trace/getTraceFlamegraph';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import {
|
||||
GetTraceFlamegraphPayloadProps,
|
||||
GetTraceFlamegraphSuccessResponse,
|
||||
} from 'types/api/trace/getTraceFlamegraph';
|
||||
|
||||
const useGetTraceFlamegraph = (
|
||||
props: GetTraceFlamegraphPayloadProps,
|
||||
): UseLicense =>
|
||||
useQuery({
|
||||
queryFn: () => getTraceFlamegraph(props),
|
||||
queryKey: [
|
||||
REACT_QUERY_KEY.GET_TRACE_V2_FLAMEGRAPH,
|
||||
props.traceId,
|
||||
props.selectedSpanId,
|
||||
props.selectFields,
|
||||
],
|
||||
enabled: !!props.traceId,
|
||||
keepPreviousData: true,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
type UseLicense = UseQueryResult<
|
||||
SuccessResponse<GetTraceFlamegraphSuccessResponse> | ErrorResponse,
|
||||
unknown
|
||||
>;
|
||||
|
||||
export default useGetTraceFlamegraph;
|
||||
@@ -1,30 +0,0 @@
|
||||
import { useQuery, UseQueryResult } from 'react-query';
|
||||
import getTraceV2 from 'api/trace/getTraceV2';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import {
|
||||
GetTraceV2PayloadProps,
|
||||
GetTraceV2SuccessResponse,
|
||||
} from 'types/api/trace/getTraceV2';
|
||||
|
||||
const useGetTraceV2 = (props: GetTraceV2PayloadProps): UseLicense =>
|
||||
useQuery({
|
||||
queryFn: () => getTraceV2(props),
|
||||
// if any of the props changes then we need to trigger an API call as the older data will be obsolete
|
||||
queryKey: [
|
||||
REACT_QUERY_KEY.GET_TRACE_V2_WATERFALL,
|
||||
props.traceId,
|
||||
props.selectedSpanId,
|
||||
props.isSelectedSpanIDUnCollapsed,
|
||||
],
|
||||
enabled: !!props.traceId,
|
||||
keepPreviousData: true,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
type UseLicense = UseQueryResult<
|
||||
SuccessResponse<GetTraceV2SuccessResponse> | ErrorResponse,
|
||||
unknown
|
||||
>;
|
||||
|
||||
export default useGetTraceV2;
|
||||
@@ -20,7 +20,7 @@ import APIError from 'types/api/error';
|
||||
import DashboardActions from './DashboardActions/DashboardActions';
|
||||
import DashboardInfo from './DashboardInfo/DashboardInfo';
|
||||
import { useEditableTitle } from './DashboardInfo/useEditableTitle';
|
||||
import { usePublicDashboardMeta } from '../DashboardSettings/PublicDashboard/usePublicDashboardMeta';
|
||||
import VariablesBar from '../VariablesBar/VariablesBar';
|
||||
|
||||
import styles from './DashboardPageToolbar.module.scss';
|
||||
|
||||
@@ -53,10 +53,6 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
(s) => s.setIsPanelTypeSelectionModalOpen,
|
||||
);
|
||||
|
||||
// Single global fetch of the public-sharing meta (the drawer reuses this cache);
|
||||
// drives the public-access badge.
|
||||
const { isPublic: isPublicDashboard } = usePublicDashboardMeta(id);
|
||||
|
||||
const isAuthor =
|
||||
!!user?.email && !!dashboard.createdBy && dashboard.createdBy === user.email;
|
||||
|
||||
@@ -122,7 +118,7 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
image={image}
|
||||
tags={tags}
|
||||
description={description}
|
||||
isPublicDashboard={isPublicDashboard}
|
||||
isPublicDashboard={false}
|
||||
isDashboardLocked={isDashboardLocked}
|
||||
isEditing={isEditing}
|
||||
draft={draft}
|
||||
@@ -142,6 +138,8 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
onOpenRename={startEdit}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<VariablesBar dashboard={dashboard} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { sortBy } from 'lodash-es';
|
||||
import type { VariableDefaultValueDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
/**
|
||||
@@ -76,3 +77,26 @@ export function emptyVariableFormModel(): VariableFormModel {
|
||||
dynamicSignal: 'traces',
|
||||
};
|
||||
}
|
||||
|
||||
/** Maps the dynamic-variable signal to the field-values API signal. */
|
||||
export function signalForApi(
|
||||
signal: TelemetrySignal,
|
||||
): TelemetrySignal | undefined {
|
||||
return signal;
|
||||
}
|
||||
|
||||
type SortableValues = (string | number | boolean)[];
|
||||
|
||||
/** Sorts option/preview values by the variable's chosen order (no-op when disabled). */
|
||||
export function sortValuesByOrder(
|
||||
values: SortableValues,
|
||||
sort: VariableSort,
|
||||
): SortableValues {
|
||||
if (sort === 'ASC') {
|
||||
return sortBy(values);
|
||||
}
|
||||
if (sort === 'DESC') {
|
||||
return sortBy(values).reverse();
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useMemo } from 'react';
|
||||
import { SolidInfoCircle } from '@signozhq/icons';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
// eslint-disable-next-line signoz/no-antd-components -- lightweight description tooltip, matches V1
|
||||
import { Tooltip } from 'antd';
|
||||
import { commaValuesParser } from 'lib/dashboardVariables/customCommaValuesParser';
|
||||
|
||||
import { sortValuesByOrder } from '../DashboardSettings/Variables/variableModel';
|
||||
import type { VariableFormModel } from '../DashboardSettings/Variables/variableModel';
|
||||
import type { VariableSelection, VariableSelectionMap } from './selectionTypes';
|
||||
import DynamicSelector from './selectors/DynamicSelector';
|
||||
import QuerySelector from './selectors/QuerySelector';
|
||||
import TextSelector from './selectors/TextSelector';
|
||||
import ValueSelector from './selectors/ValueSelector';
|
||||
import styles from './VariablesBar.module.scss';
|
||||
|
||||
interface VariableSelectorProps {
|
||||
variable: VariableFormModel;
|
||||
/** All variables (Dynamic uses them to scope options by sibling selections). */
|
||||
variables: VariableFormModel[];
|
||||
/** Names this variable depends on (for Query gating). */
|
||||
parents: string[];
|
||||
/** All current selections (Query passes them as the request payload). */
|
||||
selections: VariableSelectionMap;
|
||||
selection: VariableSelection;
|
||||
onChange: (selection: VariableSelection) => void;
|
||||
}
|
||||
|
||||
/** One labelled variable control; dispatches on the variable type. */
|
||||
function VariableSelector({
|
||||
variable,
|
||||
variables,
|
||||
parents,
|
||||
selections,
|
||||
selection,
|
||||
onChange,
|
||||
}: VariableSelectorProps): JSX.Element {
|
||||
const customOptions = useMemo(
|
||||
() =>
|
||||
variable.type === 'CUSTOM'
|
||||
? sortValuesByOrder(
|
||||
commaValuesParser(variable.customValue),
|
||||
variable.sort,
|
||||
).map(String)
|
||||
: [],
|
||||
[variable],
|
||||
);
|
||||
|
||||
const renderControl = (): JSX.Element => {
|
||||
switch (variable.type) {
|
||||
case 'TEXT':
|
||||
return (
|
||||
<TextSelector
|
||||
selection={selection}
|
||||
defaultValue={variable.textValue}
|
||||
onChange={onChange}
|
||||
testId={`variable-input-${variable.name}`}
|
||||
/>
|
||||
);
|
||||
case 'QUERY':
|
||||
return (
|
||||
<QuerySelector
|
||||
variable={variable}
|
||||
parents={parents}
|
||||
selections={selections}
|
||||
selection={selection}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
case 'DYNAMIC':
|
||||
return (
|
||||
<DynamicSelector
|
||||
variable={variable}
|
||||
variables={variables}
|
||||
selections={selections}
|
||||
selection={selection}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
case 'CUSTOM':
|
||||
default:
|
||||
return (
|
||||
<ValueSelector
|
||||
options={customOptions}
|
||||
multiSelect={variable.multiSelect}
|
||||
showAllOption={variable.showAllOption}
|
||||
selection={selection}
|
||||
onChange={onChange}
|
||||
testId={`variable-select-${variable.name}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.variableItem}
|
||||
data-testid={`variable-${variable.name}`}
|
||||
>
|
||||
<Typography.Text className={styles.variableName}>
|
||||
${variable.name}
|
||||
{variable.description ? (
|
||||
<Tooltip title={variable.description}>
|
||||
<SolidInfoCircle className={styles.infoIcon} size="md" />
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</Typography.Text>
|
||||
|
||||
<div className={styles.variableValue}>{renderControl()}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default VariableSelector;
|
||||
@@ -0,0 +1,71 @@
|
||||
/* Mirrors the V1 dashboard variable bar: each variable is a connected pill —
|
||||
a robin `$name` segment joined to a value segment. */
|
||||
/* Sits inside the already-padded sticky toolbar section, so it only needs a top
|
||||
gap from the tags — horizontal/bottom padding comes from the toolbar. */
|
||||
.bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.variableItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.variableName {
|
||||
display: flex;
|
||||
min-width: 56px;
|
||||
height: 32px;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 6px 6px 8px;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 2px 0 0 2px;
|
||||
background: var(--l3-background);
|
||||
color: var(--bg-robin-300);
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 16px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.infoIcon {
|
||||
margin-left: 4px;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.variableValue {
|
||||
display: flex;
|
||||
min-width: 120px;
|
||||
height: 32px;
|
||||
align-items: center;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-left: none;
|
||||
border-radius: 0 2px 2px 0;
|
||||
background: var(--l2-background);
|
||||
color: var(--l2-foreground);
|
||||
font-size: 12px;
|
||||
|
||||
&:hover,
|
||||
&:focus-within {
|
||||
outline: 1px solid var(--bg-robin-400);
|
||||
}
|
||||
}
|
||||
|
||||
/* Inner control fills the value segment; the segment provides the frame, so the
|
||||
control itself is borderless/transparent. */
|
||||
.control {
|
||||
width: 100%;
|
||||
min-width: 120px;
|
||||
|
||||
:global(.ant-select-selector),
|
||||
:global(.ant-input),
|
||||
&:global(.ant-input) {
|
||||
border: none !important;
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { useVariableSelection } from './useVariableSelection';
|
||||
import VariableSelector from './VariableSelector';
|
||||
import styles from './VariablesBar.module.scss';
|
||||
|
||||
interface VariablesBarProps {
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime variable selector bar shown above the panels. Renders one control per
|
||||
* dashboard variable; selections live in the store + URL (never the spec).
|
||||
*/
|
||||
function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
|
||||
const { variables, dependencyData, selection, setSelection } =
|
||||
useVariableSelection(dashboard);
|
||||
|
||||
if (variables.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.bar} data-testid="dashboard-variables-bar">
|
||||
{variables.map((variable) => (
|
||||
<VariableSelector
|
||||
key={variable.name}
|
||||
variable={variable}
|
||||
variables={variables}
|
||||
parents={dependencyData.parentGraph[variable.name] ?? []}
|
||||
selections={selection}
|
||||
selection={
|
||||
selection[variable.name] ?? {
|
||||
value: variable.multiSelect ? [] : '',
|
||||
allSelected: false,
|
||||
}
|
||||
}
|
||||
onChange={(next): void => setSelection(variable.name, next)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default VariablesBar;
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { VariableFormModel } from '../DashboardSettings/Variables/variableModel';
|
||||
import type { VariableSelectionMap } from './selectionTypes';
|
||||
|
||||
function formatQueryValue(val: string): string {
|
||||
const num = Number(val);
|
||||
if (!Number.isNaN(num) && Number.isFinite(num)) {
|
||||
return val;
|
||||
}
|
||||
return `'${val.replace(/'/g, "\\'")}'`;
|
||||
}
|
||||
|
||||
function buildQueryPart(attribute: string, values: string[]): string {
|
||||
const formatted = values.map(formatQueryValue);
|
||||
if (formatted.length === 1) {
|
||||
return `${attribute} = ${formatted[0]}`;
|
||||
}
|
||||
return `${attribute} IN [${formatted.join(', ')}]`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a filter expression from the OTHER dynamic variables' current
|
||||
* selections (e.g. `k8s.namespace.name IN ['prod'] AND service = 'api'`), so a
|
||||
* dynamic variable's option list is scoped by its sibling selections. Variables
|
||||
* in the ALL state, with no selection, or non-dynamic are skipped. Ported from
|
||||
* the V1 dynamic-variable runtime.
|
||||
*/
|
||||
export function buildExistingDynamicVariableQuery(
|
||||
variables: VariableFormModel[],
|
||||
selections: VariableSelectionMap,
|
||||
currentName: string,
|
||||
): string {
|
||||
const parts: string[] = [];
|
||||
variables.forEach((variable) => {
|
||||
if (
|
||||
variable.name === currentName ||
|
||||
variable.type !== 'DYNAMIC' ||
|
||||
!variable.dynamicAttribute
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const selection = selections[variable.name];
|
||||
if (!selection || selection.allSelected) {
|
||||
return;
|
||||
}
|
||||
const raw = Array.isArray(selection.value)
|
||||
? selection.value
|
||||
: [selection.value];
|
||||
const valid = raw
|
||||
.filter((v) => v !== null && v !== undefined && v !== '')
|
||||
.map((v) => String(v));
|
||||
if (valid.length > 0) {
|
||||
parts.push(buildQueryPart(variable.dynamicAttribute, valid));
|
||||
}
|
||||
});
|
||||
return parts.join(' AND ');
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/** A user-selected variable value at runtime (not persisted to the spec). */
|
||||
export type SelectedVariableValue =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| (string | number | boolean)[]
|
||||
| null;
|
||||
|
||||
export interface VariableSelection {
|
||||
value: SelectedVariableValue;
|
||||
/** True when every option is selected ("ALL"); for dynamic vars value may be null. */
|
||||
allSelected: boolean;
|
||||
}
|
||||
|
||||
/** Selected values for a dashboard's variables, keyed by variable name. */
|
||||
export type VariableSelectionMap = Record<string, VariableSelection>;
|
||||
@@ -0,0 +1,31 @@
|
||||
import type {
|
||||
SelectedVariableValue,
|
||||
VariableSelection,
|
||||
VariableSelectionMap,
|
||||
} from './selectionTypes';
|
||||
|
||||
/** A selection counts as resolved (usable as a parent value) when it's non-empty. */
|
||||
export function isResolved(selection?: VariableSelection): boolean {
|
||||
if (!selection) {
|
||||
return false;
|
||||
}
|
||||
if (selection.allSelected) {
|
||||
return true;
|
||||
}
|
||||
const { value } = selection;
|
||||
if (Array.isArray(value)) {
|
||||
return value.length > 0;
|
||||
}
|
||||
return value !== '' && value !== null && value !== undefined;
|
||||
}
|
||||
|
||||
/** Flatten the selection map into the `{ name: value }` payload a query expects. */
|
||||
export function selectionToPayload(
|
||||
selection: VariableSelectionMap,
|
||||
): Record<string, SelectedVariableValue> {
|
||||
const payload: Record<string, SelectedVariableValue> = {};
|
||||
Object.entries(selection).forEach(([name, sel]) => {
|
||||
payload[name] = sel.value;
|
||||
});
|
||||
return payload;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useMemo } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useGetFieldValues } from 'hooks/dynamicVariables/useGetFieldValues';
|
||||
import type { AppState } from 'store/reducers';
|
||||
import type { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import {
|
||||
signalForApi,
|
||||
sortValuesByOrder,
|
||||
} from '../../DashboardSettings/Variables/variableModel';
|
||||
import type { VariableFormModel } from '../../DashboardSettings/Variables/variableModel';
|
||||
import { buildExistingDynamicVariableQuery } from '../dynamicFilter';
|
||||
import type {
|
||||
VariableSelection,
|
||||
VariableSelectionMap,
|
||||
} from '../selectionTypes';
|
||||
import { useAutoSelect } from '../useAutoSelect';
|
||||
import ValueSelector from './ValueSelector';
|
||||
|
||||
interface DynamicSelectorProps {
|
||||
variable: VariableFormModel;
|
||||
/** All variables + current selections, to scope options by sibling dynamics. */
|
||||
variables: VariableFormModel[];
|
||||
selections: VariableSelectionMap;
|
||||
selection: VariableSelection;
|
||||
onChange: (selection: VariableSelection) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamic-variable options sourced from live telemetry field values for the
|
||||
* chosen signal + attribute, scoped by the other dynamic variables' selections
|
||||
* (so e.g. `pod` narrows to the chosen `namespace`).
|
||||
*/
|
||||
function DynamicSelector({
|
||||
variable,
|
||||
variables,
|
||||
selections,
|
||||
selection,
|
||||
onChange,
|
||||
}: DynamicSelectorProps): JSX.Element {
|
||||
const { minTime, maxTime } = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
|
||||
const existingQuery = useMemo(
|
||||
() => buildExistingDynamicVariableQuery(variables, selections, variable.name),
|
||||
[variables, selections, variable.name],
|
||||
);
|
||||
|
||||
const { data, isFetching } = useGetFieldValues({
|
||||
signal: signalForApi(variable.dynamicSignal),
|
||||
name: variable.dynamicAttribute,
|
||||
startUnixMilli: minTime,
|
||||
endUnixMilli: maxTime,
|
||||
existingQuery: existingQuery || undefined,
|
||||
enabled: !!variable.dynamicAttribute,
|
||||
});
|
||||
|
||||
const options = useMemo(() => {
|
||||
const payload = data?.data;
|
||||
const values =
|
||||
payload?.normalizedValues ?? payload?.values?.StringValues ?? [];
|
||||
return sortValuesByOrder(values, variable.sort).map(String);
|
||||
}, [data, variable.sort]);
|
||||
|
||||
useAutoSelect(variable, options, selection, onChange);
|
||||
|
||||
return (
|
||||
<ValueSelector
|
||||
options={options}
|
||||
multiSelect={variable.multiSelect}
|
||||
showAllOption={variable.showAllOption}
|
||||
loading={isFetching}
|
||||
selection={selection}
|
||||
onChange={onChange}
|
||||
testId={`variable-select-${variable.name}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default DynamicSelector;
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import dashboardVariablesQuery from 'api/dashboard/variables/dashboardVariablesQuery';
|
||||
import type { AppState } from 'store/reducers';
|
||||
import type { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import { sortValuesByOrder } from '../../DashboardSettings/Variables/variableModel';
|
||||
import type { VariableFormModel } from '../../DashboardSettings/Variables/variableModel';
|
||||
import type {
|
||||
VariableSelection,
|
||||
VariableSelectionMap,
|
||||
} from '../selectionTypes';
|
||||
import { isResolved, selectionToPayload } from '../selectionUtils';
|
||||
import { useAutoSelect } from '../useAutoSelect';
|
||||
import ValueSelector from './ValueSelector';
|
||||
|
||||
interface QuerySelectorProps {
|
||||
variable: VariableFormModel;
|
||||
/** Names this variable's query references; it waits until they're resolved. */
|
||||
parents: string[];
|
||||
/** All current selections, fed to the query as `{ name: value }`. */
|
||||
selections: VariableSelectionMap;
|
||||
selection: VariableSelection;
|
||||
onChange: (selection: VariableSelection) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query-driven options. Dependency orchestration is declarative: the query is
|
||||
* `enabled` only once every parent is resolved, and the parent values are in the
|
||||
* query key — so it refetches automatically when a parent changes (and a cyclic
|
||||
* dependency is simply never enabled).
|
||||
*/
|
||||
function QuerySelector({
|
||||
variable,
|
||||
parents,
|
||||
selections,
|
||||
selection,
|
||||
onChange,
|
||||
}: QuerySelectorProps): JSX.Element {
|
||||
const { minTime, maxTime } = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
const payload = useMemo(() => selectionToPayload(selections), [selections]);
|
||||
const enabled = parents.every((parent) => isResolved(selections[parent]));
|
||||
|
||||
const { data, isFetching } = useQuery(
|
||||
[
|
||||
'dashboard-variable',
|
||||
variable.name,
|
||||
variable.queryValue,
|
||||
payload,
|
||||
minTime,
|
||||
maxTime,
|
||||
],
|
||||
() =>
|
||||
dashboardVariablesQuery({
|
||||
query: variable.queryValue,
|
||||
variables: payload,
|
||||
}),
|
||||
{ enabled, refetchOnWindowFocus: false },
|
||||
);
|
||||
|
||||
const options = useMemo(() => {
|
||||
if (!data || data.statusCode !== 200 || !data.payload) {
|
||||
return [] as string[];
|
||||
}
|
||||
return sortValuesByOrder(
|
||||
data.payload.variableValues ?? [],
|
||||
variable.sort,
|
||||
).map(String);
|
||||
}, [data, variable.sort]);
|
||||
|
||||
useAutoSelect(variable, options, selection, onChange);
|
||||
|
||||
return (
|
||||
<ValueSelector
|
||||
options={options}
|
||||
multiSelect={variable.multiSelect}
|
||||
showAllOption={variable.showAllOption}
|
||||
loading={isFetching}
|
||||
selection={selection}
|
||||
onChange={onChange}
|
||||
testId={`variable-select-${variable.name}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default QuerySelector;
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import type { InputRef } from 'antd';
|
||||
// eslint-disable-next-line signoz/no-antd-components -- match V1 textbox behaviour (commit on blur/Enter, borderless)
|
||||
import { Input } from 'antd';
|
||||
|
||||
import type { VariableSelection } from '../selectionTypes';
|
||||
import styles from '../VariablesBar.module.scss';
|
||||
|
||||
interface TextSelectorProps {
|
||||
selection: VariableSelection;
|
||||
/** Configured default; an emptied input falls back to it (V1 behaviour). */
|
||||
defaultValue?: string;
|
||||
onChange: (selection: VariableSelection) => void;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Free-text variable input. Mirrors V1: edits are local and only committed on
|
||||
* blur / Enter (not per keystroke), and clearing the field restores the default.
|
||||
*/
|
||||
function TextSelector({
|
||||
selection,
|
||||
defaultValue,
|
||||
onChange,
|
||||
testId,
|
||||
}: TextSelectorProps): JSX.Element {
|
||||
const inputRef = useRef<InputRef>(null);
|
||||
const [value, setValue] = useState<string>(
|
||||
typeof selection.value === 'string' ? selection.value : (defaultValue ?? ''),
|
||||
);
|
||||
|
||||
const commit = useCallback(
|
||||
(next: string): void => onChange({ value: next, allSelected: false }),
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const handleBlur = useCallback(
|
||||
(event: React.FocusEvent<HTMLInputElement>): void => {
|
||||
const trimmed = event.target.value.trim();
|
||||
if (!trimmed && defaultValue) {
|
||||
setValue(defaultValue);
|
||||
commit(defaultValue);
|
||||
} else {
|
||||
commit(trimmed);
|
||||
}
|
||||
},
|
||||
[commit, defaultValue],
|
||||
);
|
||||
|
||||
return (
|
||||
<Input
|
||||
ref={inputRef}
|
||||
className={styles.control}
|
||||
bordered={false}
|
||||
placeholder="Enter value"
|
||||
value={value}
|
||||
title={value}
|
||||
onChange={(e): void => setValue(e.target.value)}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Enter') {
|
||||
inputRef.current?.blur();
|
||||
}
|
||||
}}
|
||||
data-testid={testId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default TextSelector;
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useMemo } from 'react';
|
||||
import { CustomMultiSelect, CustomSelect } from 'components/NewSelect';
|
||||
import type { OptionData } from 'components/NewSelect/types';
|
||||
import { ALL_SELECT_VALUE } from 'container/DashboardContainer/utils';
|
||||
|
||||
import type { VariableSelection } from '../selectionTypes';
|
||||
import styles from '../VariablesBar.module.scss';
|
||||
|
||||
interface ValueSelectorProps {
|
||||
options: string[];
|
||||
multiSelect: boolean;
|
||||
showAllOption: boolean;
|
||||
loading?: boolean;
|
||||
selection: VariableSelection;
|
||||
onChange: (selection: VariableSelection) => void;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single/multi value picker for Custom/Query/Dynamic variables. Reuses the
|
||||
* shared NewSelect components, which provide search, the "ALL" option and
|
||||
* apply-on-close batching (so multi-select edits don't cascade per toggle).
|
||||
*/
|
||||
function ValueSelector({
|
||||
options,
|
||||
multiSelect,
|
||||
showAllOption,
|
||||
loading,
|
||||
selection,
|
||||
onChange,
|
||||
testId,
|
||||
}: ValueSelectorProps): JSX.Element {
|
||||
const optionData = useMemo<OptionData[]>(
|
||||
() => options.map((option) => ({ label: option, value: option })),
|
||||
[options],
|
||||
);
|
||||
|
||||
if (multiSelect) {
|
||||
const value = selection.allSelected
|
||||
? ALL_SELECT_VALUE
|
||||
: (Array.isArray(selection.value) ? selection.value : []).map(String);
|
||||
return (
|
||||
<CustomMultiSelect
|
||||
className={styles.control}
|
||||
data-testid={testId}
|
||||
options={optionData}
|
||||
value={value}
|
||||
loading={loading}
|
||||
showSearch
|
||||
placeholder="Select value"
|
||||
enableAllSelection={showAllOption}
|
||||
onChange={(next): void => {
|
||||
const values = Array.isArray(next)
|
||||
? next.map(String)
|
||||
: next
|
||||
? [String(next)]
|
||||
: [];
|
||||
if (values.length === 0) {
|
||||
onChange({ value: [], allSelected: false });
|
||||
return;
|
||||
}
|
||||
// CustomMultiSelect emits the full value set when ALL is picked.
|
||||
const isAll =
|
||||
showAllOption &&
|
||||
options.length > 0 &&
|
||||
options.every((option) => values.includes(option));
|
||||
onChange({ value: values, allSelected: isAll });
|
||||
}}
|
||||
onClear={(): void => onChange({ value: [], allSelected: false })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CustomSelect
|
||||
className={styles.select}
|
||||
data-testid={testId}
|
||||
options={optionData}
|
||||
value={
|
||||
selection.value == null || Array.isArray(selection.value)
|
||||
? undefined
|
||||
: String(selection.value)
|
||||
}
|
||||
loading={loading}
|
||||
showSearch
|
||||
placeholder="Select value"
|
||||
onChange={(next): void =>
|
||||
onChange({ value: next == null ? '' : String(next), allSelected: false })
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default ValueSelector;
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import type { VariableFormModel } from '../DashboardSettings/Variables/variableModel';
|
||||
import type { VariableSelection } from './selectionTypes';
|
||||
|
||||
/**
|
||||
* When fetched options arrive and the current selection isn't one of them,
|
||||
* auto-pick the variable's default (if present in the options) or the first
|
||||
* option — so dependent children always have a usable parent value.
|
||||
*/
|
||||
export function useAutoSelect(
|
||||
variable: VariableFormModel,
|
||||
options: string[],
|
||||
selection: VariableSelection,
|
||||
onChange: (selection: VariableSelection) => void,
|
||||
): void {
|
||||
useEffect(() => {
|
||||
if (options.length === 0 || selection.allSelected) {
|
||||
return;
|
||||
}
|
||||
const current = selection.value;
|
||||
const isValid = Array.isArray(current)
|
||||
? current.length > 0 && current.every((c) => options.includes(String(c)))
|
||||
: current !== '' &&
|
||||
current !== null &&
|
||||
current !== undefined &&
|
||||
options.includes(String(current));
|
||||
if (isValid) {
|
||||
return;
|
||||
}
|
||||
const fallback = (variable.defaultValue as { value?: string } | undefined)
|
||||
?.value;
|
||||
const initial =
|
||||
fallback && options.includes(fallback) ? fallback : options[0];
|
||||
onChange({
|
||||
value: variable.multiSelect ? [initial] : initial,
|
||||
allSelected: false,
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [options]);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { parseAsJson, useQueryState } from 'nuqs';
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { dtoToFormModel } from '../DashboardSettings/Variables/variableAdapters';
|
||||
import type { VariableFormModel } from '../DashboardSettings/Variables/variableModel';
|
||||
import { selectVariableValues } from '../store/slices/variableSelectionSlice';
|
||||
import { useDashboardStore } from '../store/useDashboardStore';
|
||||
import type {
|
||||
SelectedVariableValue,
|
||||
VariableSelection,
|
||||
VariableSelectionMap,
|
||||
} from './selectionTypes';
|
||||
import {
|
||||
computeVariableDependencies,
|
||||
type VariableDependencyData,
|
||||
} from './variableDependencies';
|
||||
|
||||
/** URL sentinel for an "ALL values selected" state (matches V1). */
|
||||
export const ALL_SELECTED = '__ALL__';
|
||||
|
||||
/** `?variables=` holds `{ [name]: value }` (ALL encoded as the sentinel). */
|
||||
const variablesUrlParser = parseAsJson<Record<string, SelectedVariableValue>>(
|
||||
(v) =>
|
||||
typeof v === 'object' && v !== null
|
||||
? (v as Record<string, SelectedVariableValue>)
|
||||
: null,
|
||||
);
|
||||
|
||||
function defaultSelection(model: VariableFormModel): VariableSelection {
|
||||
const def = (
|
||||
model.defaultValue as { value?: SelectedVariableValue } | undefined
|
||||
)?.value;
|
||||
if (def !== undefined && def !== null && def !== '') {
|
||||
return { value: def, allSelected: false };
|
||||
}
|
||||
return { value: model.multiSelect ? [] : '', allSelected: false };
|
||||
}
|
||||
|
||||
function fromUrlValue(raw: SelectedVariableValue): VariableSelection {
|
||||
return raw === ALL_SELECTED
|
||||
? { value: null, allSelected: true }
|
||||
: { value: raw, allSelected: false };
|
||||
}
|
||||
|
||||
interface UseVariableSelection {
|
||||
variables: VariableFormModel[];
|
||||
dependencyData: VariableDependencyData;
|
||||
selection: VariableSelectionMap;
|
||||
setSelection: (name: string, selection: VariableSelection) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime variable selection: derives the variable list from the spec, seeds
|
||||
* each value from URL → localStorage(store) → default, and persists changes to
|
||||
* both the store and the URL. Never writes to the dashboard spec.
|
||||
*/
|
||||
export function useVariableSelection(
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO,
|
||||
): UseVariableSelection {
|
||||
const dashboardId = dashboard.id ?? '';
|
||||
|
||||
const variables = useMemo(
|
||||
() => (dashboard.spec?.variables ?? []).map(dtoToFormModel),
|
||||
[dashboard.spec?.variables],
|
||||
);
|
||||
const dependencyData = useMemo(
|
||||
() => computeVariableDependencies(variables),
|
||||
[variables],
|
||||
);
|
||||
|
||||
const selection = useDashboardStore(selectVariableValues(dashboardId));
|
||||
const setVariableValue = useDashboardStore((s) => s.setVariableValue);
|
||||
const setVariableValues = useDashboardStore((s) => s.setVariableValues);
|
||||
|
||||
const [urlValues, setUrlValues] = useQueryState(
|
||||
'variables',
|
||||
variablesUrlParser.withOptions({ history: 'replace' }),
|
||||
);
|
||||
|
||||
// Seed selections for this dashboard: URL wins, then persisted store, then default.
|
||||
useEffect(() => {
|
||||
if (!dashboardId || variables.length === 0) {
|
||||
return;
|
||||
}
|
||||
// `selection` here is the persisted (localStorage) map on mount — the
|
||||
// effect deliberately doesn't depend on it, so seeding runs once per set.
|
||||
const stored = selection;
|
||||
const seeded: VariableSelectionMap = {};
|
||||
variables.forEach((variable) => {
|
||||
const urlValue = urlValues?.[variable.name];
|
||||
if (urlValue !== undefined) {
|
||||
seeded[variable.name] = fromUrlValue(urlValue);
|
||||
} else if (stored[variable.name]) {
|
||||
seeded[variable.name] = stored[variable.name];
|
||||
} else {
|
||||
seeded[variable.name] = defaultSelection(variable);
|
||||
}
|
||||
});
|
||||
setVariableValues(dashboardId, seeded);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dashboardId, variables]);
|
||||
|
||||
const setSelection = useCallback(
|
||||
(name: string, next: VariableSelection): void => {
|
||||
setVariableValue(dashboardId, name, next);
|
||||
void setUrlValues((prev) => ({
|
||||
...(prev ?? {}),
|
||||
[name]: next.allSelected ? ALL_SELECTED : next.value,
|
||||
}));
|
||||
},
|
||||
[dashboardId, setVariableValue, setUrlValues],
|
||||
);
|
||||
|
||||
return { variables, dependencyData, selection, setSelection };
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import { textContainsVariableReference } from 'lib/dashboardVariables/variableReference';
|
||||
|
||||
import type { VariableFormModel } from '../DashboardSettings/Variables/variableModel';
|
||||
|
||||
/**
|
||||
* Inter-variable dependency graph for runtime selection. A QUERY variable
|
||||
* "depends on" another variable when its query text references that variable
|
||||
* (`{{.name}}`, `{{name}}`, `$name`, `[[name]]`). When a variable's value
|
||||
* changes, its dependent QUERY variables must refetch. Ported from the V1
|
||||
* dashboard-variables runtime; operates on the V2 flat variable model.
|
||||
*/
|
||||
|
||||
export type VariableGraph = Record<string, string[]>;
|
||||
|
||||
export interface VariableDependencyData {
|
||||
/** Topological order of variables (parents before children). */
|
||||
order: string[];
|
||||
/** Direct children (dependents) of each variable. */
|
||||
graph: VariableGraph;
|
||||
/** Direct parents of each variable. */
|
||||
parentGraph: VariableGraph;
|
||||
/** All transitive descendants of each variable (precomputed). */
|
||||
transitiveDescendants: VariableGraph;
|
||||
hasCycle: boolean;
|
||||
cycleNodes?: string[];
|
||||
}
|
||||
|
||||
/** Names of QUERY variables whose query references `variableName`. */
|
||||
function getDependents(
|
||||
variableName: string,
|
||||
variables: VariableFormModel[],
|
||||
): string[] {
|
||||
return variables
|
||||
.filter(
|
||||
(v) =>
|
||||
v.type === 'QUERY' &&
|
||||
!!v.name &&
|
||||
textContainsVariableReference(v.queryValue || '', variableName),
|
||||
)
|
||||
.map((v) => v.name);
|
||||
}
|
||||
|
||||
/** variable name → its direct dependents (children). */
|
||||
export function buildDependencies(
|
||||
variables: VariableFormModel[],
|
||||
): VariableGraph {
|
||||
const graph: VariableGraph = {};
|
||||
variables.forEach((v) => {
|
||||
if (v.name) {
|
||||
graph[v.name] = getDependents(v.name, variables);
|
||||
}
|
||||
});
|
||||
return graph;
|
||||
}
|
||||
|
||||
/** Invert a child graph into a parent graph. */
|
||||
export function buildParentGraph(graph: VariableGraph): VariableGraph {
|
||||
const parents: VariableGraph = {};
|
||||
Object.keys(graph).forEach((node) => {
|
||||
parents[node] = parents[node] ?? [];
|
||||
});
|
||||
Object.entries(graph).forEach(([node, children]) => {
|
||||
children.forEach((child) => {
|
||||
parents[child] = parents[child] ?? [];
|
||||
parents[child].push(node);
|
||||
});
|
||||
});
|
||||
return parents;
|
||||
}
|
||||
|
||||
function collectCyclePath(
|
||||
graph: VariableGraph,
|
||||
start: string,
|
||||
end: string,
|
||||
): string[] {
|
||||
const path: string[] = [];
|
||||
let current = start;
|
||||
const findParent = (node: string): string | undefined =>
|
||||
Object.keys(graph).find((key) => graph[key]?.includes(node));
|
||||
while (current !== end) {
|
||||
const parent = findParent(current);
|
||||
if (!parent) {
|
||||
break;
|
||||
}
|
||||
path.push(parent);
|
||||
current = parent;
|
||||
}
|
||||
return [start, ...path];
|
||||
}
|
||||
|
||||
function detectCycle(
|
||||
graph: VariableGraph,
|
||||
node: string,
|
||||
visited: Set<string>,
|
||||
recStack: Set<string>,
|
||||
): string[] | null {
|
||||
if (!visited.has(node)) {
|
||||
visited.add(node);
|
||||
recStack.add(node);
|
||||
let cycleNodes: string[] | null = null;
|
||||
(graph[node] || []).some((neighbor) => {
|
||||
if (!visited.has(neighbor)) {
|
||||
const found = detectCycle(graph, neighbor, visited, recStack);
|
||||
if (found) {
|
||||
cycleNodes = found;
|
||||
return true;
|
||||
}
|
||||
} else if (recStack.has(neighbor)) {
|
||||
cycleNodes = collectCyclePath(graph, node, neighbor);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (cycleNodes) {
|
||||
return cycleNodes;
|
||||
}
|
||||
}
|
||||
recStack.delete(node);
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Build the full dependency data (topo order, parents, transitive descendants, cycle info). */
|
||||
export function buildDependencyData(
|
||||
dependencies: VariableGraph,
|
||||
): VariableDependencyData {
|
||||
const inDegree: Record<string, number> = {};
|
||||
const adjList: VariableGraph = {};
|
||||
|
||||
Object.keys(dependencies).forEach((node) => {
|
||||
inDegree[node] = inDegree[node] ?? 0;
|
||||
adjList[node] = adjList[node] ?? [];
|
||||
(dependencies[node] || []).forEach((child) => {
|
||||
inDegree[child] = inDegree[child] ?? 0;
|
||||
inDegree[child] += 1;
|
||||
adjList[node].push(child);
|
||||
});
|
||||
});
|
||||
|
||||
const visited = new Set<string>();
|
||||
const recStack = new Set<string>();
|
||||
let cycleNodes: string[] | undefined;
|
||||
Object.keys(dependencies).some((node) => {
|
||||
if (!visited.has(node)) {
|
||||
const found = detectCycle(dependencies, node, visited, recStack);
|
||||
if (found) {
|
||||
cycleNodes = found;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
// Topological sort (Kahn's algorithm).
|
||||
const queue = Object.keys(inDegree).filter((n) => inDegree[n] === 0);
|
||||
const order: string[] = [];
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift();
|
||||
if (current === undefined) {
|
||||
break;
|
||||
}
|
||||
order.push(current);
|
||||
(adjList[current] || []).forEach((neighbor) => {
|
||||
inDegree[neighbor] -= 1;
|
||||
if (inDegree[neighbor] === 0) {
|
||||
queue.push(neighbor);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const hasCycle = order.length !== Object.keys(dependencies).length;
|
||||
|
||||
// Transitive descendants: walk topo order in reverse.
|
||||
const transitiveDescendants: VariableGraph = {};
|
||||
for (let i = order.length - 1; i >= 0; i--) {
|
||||
const node = order[i];
|
||||
const desc = new Set<string>();
|
||||
(adjList[node] || []).forEach((child) => {
|
||||
desc.add(child);
|
||||
(transitiveDescendants[child] || []).forEach((d) => desc.add(d));
|
||||
});
|
||||
transitiveDescendants[node] = Array.from(desc);
|
||||
}
|
||||
|
||||
return {
|
||||
order,
|
||||
graph: adjList,
|
||||
parentGraph: buildParentGraph(adjList),
|
||||
transitiveDescendants,
|
||||
hasCycle,
|
||||
cycleNodes,
|
||||
};
|
||||
}
|
||||
|
||||
/** Compute the full dependency data straight from the variable list. */
|
||||
export function computeVariableDependencies(
|
||||
variables: VariableFormModel[],
|
||||
): VariableDependencyData {
|
||||
return buildDependencyData(buildDependencies(variables));
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
|
||||
import {
|
||||
extractAggregationsPerQuery,
|
||||
extractClickhouseQueryNames,
|
||||
prepareScalarTables,
|
||||
} from '../prepareScalarTables';
|
||||
|
||||
@@ -56,6 +57,24 @@ describe('extractAggregationsPerQuery', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractClickhouseQueryNames', () => {
|
||||
it('collects names of clickhouse_sql queries, ignoring other envelope types', () => {
|
||||
const request = requestWith([
|
||||
{ type: 'clickhouse_sql', spec: { name: 'A', query: 'SELECT 1' } },
|
||||
{
|
||||
type: 'builder_query',
|
||||
spec: { name: 'B', aggregations: [{ expression: 'count()' }] },
|
||||
},
|
||||
{ type: 'promql', spec: { name: 'P', query: 'up' } },
|
||||
]);
|
||||
expect(extractClickhouseQueryNames(request)).toStrictEqual(new Set(['A']));
|
||||
});
|
||||
|
||||
it('returns an empty set for an undefined payload', () => {
|
||||
expect(extractClickhouseQueryNames(undefined)).toStrictEqual(new Set());
|
||||
});
|
||||
});
|
||||
|
||||
describe('prepareScalarTables', () => {
|
||||
it('builds keyed rows with group + aggregation columns (V1 getColName/getColId parity)', () => {
|
||||
const [table] = prepareScalarTables({
|
||||
@@ -194,18 +213,115 @@ describe('prepareScalarTables', () => {
|
||||
expect(tables.map((t) => t.queryName)).toStrictEqual(['A', 'B']);
|
||||
});
|
||||
|
||||
it('queries without aggregation metadata fall back to legend || queryName', () => {
|
||||
it('clickhouse_sql single value column uses the SQL alias over the legend', () => {
|
||||
const [table] = prepareScalarTables({
|
||||
results: [
|
||||
scalarResult(
|
||||
[
|
||||
{
|
||||
name: 'current_availability',
|
||||
queryName: 'A',
|
||||
columnType: 'aggregation',
|
||||
},
|
||||
],
|
||||
[],
|
||||
),
|
||||
],
|
||||
legendMap: { A: 'Legend' },
|
||||
requestPayload: requestWith([
|
||||
{ type: 'clickhouse_sql', spec: { name: 'A', query: 'SELECT ...' } },
|
||||
]),
|
||||
});
|
||||
// The query is clickhouse_sql, so the response column's real SQL alias is
|
||||
// used for both header and key (a single legend can't be the column name).
|
||||
expect(table.columns[0].name).toBe('current_availability');
|
||||
expect(table.columns[0].id).toBe('current_availability');
|
||||
});
|
||||
|
||||
it('non-clickhouse query without aggregation metadata falls back to legend || queryName', () => {
|
||||
const [table] = prepareScalarTables({
|
||||
results: [
|
||||
// Formulas/promql carry placeholder names and are not clickhouse_sql,
|
||||
// so they must not adopt the response column name.
|
||||
scalarResult(
|
||||
[{ name: '__result_0', queryName: 'A', columnType: 'aggregation' }],
|
||||
[],
|
||||
),
|
||||
],
|
||||
legendMap: { A: 'Legend' },
|
||||
requestPayload: requestWith([]),
|
||||
requestPayload: requestWith([
|
||||
{ type: 'promql', spec: { name: 'A', query: 'up' } },
|
||||
]),
|
||||
});
|
||||
expect(table.columns[0].name).toBe('Legend');
|
||||
expect(table.columns[0].id).toBe('A');
|
||||
});
|
||||
|
||||
it('clickhouse_sql query keeps each value column distinct (regression: all-"A" collapse)', () => {
|
||||
const [table] = prepareScalarTables({
|
||||
results: [
|
||||
scalarResult(
|
||||
[
|
||||
{ name: 'service.name', queryName: 'A', columnType: 'group' },
|
||||
{
|
||||
name: 'current_availability',
|
||||
queryName: 'A',
|
||||
columnType: 'aggregation',
|
||||
aggregationIndex: 0,
|
||||
},
|
||||
{
|
||||
name: 'error_budget_remaining',
|
||||
queryName: 'A',
|
||||
columnType: 'aggregation',
|
||||
aggregationIndex: 1,
|
||||
},
|
||||
{ name: 'budget_status', queryName: 'A', columnType: 'group' },
|
||||
{
|
||||
name: 'total_requests',
|
||||
queryName: 'A',
|
||||
columnType: 'aggregation',
|
||||
aggregationIndex: 4,
|
||||
},
|
||||
],
|
||||
[['kuja-api_gateway-service', 99.985, 0.985, 'Healthy ✅', 2181216]],
|
||||
),
|
||||
],
|
||||
legendMap: { A: '' },
|
||||
// A clickhouse_sql envelope contributes no aggregation metadata.
|
||||
requestPayload: requestWith([
|
||||
{
|
||||
type: 'clickhouse_sql',
|
||||
spec: { name: 'A', query: 'SELECT ...' },
|
||||
},
|
||||
]),
|
||||
});
|
||||
|
||||
// Headers keep their real names instead of collapsing to "A".
|
||||
expect(table.columns.map((col) => col.name)).toStrictEqual([
|
||||
'service.name',
|
||||
'current_availability',
|
||||
'error_budget_remaining',
|
||||
'budget_status',
|
||||
'total_requests',
|
||||
]);
|
||||
// Ids are unique, so value columns don't overwrite each other in the row.
|
||||
expect(table.columns.map((col) => col.id)).toStrictEqual([
|
||||
'service.name',
|
||||
'current_availability',
|
||||
'error_budget_remaining',
|
||||
'budget_status',
|
||||
'total_requests',
|
||||
]);
|
||||
expect(table.rows).toStrictEqual([
|
||||
{
|
||||
data: {
|
||||
'service.name': 'kuja-api_gateway-service',
|
||||
current_availability: 99.985,
|
||||
error_budget_remaining: 0.985,
|
||||
budget_status: 'Healthy ✅',
|
||||
total_requests: 2181216,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
Querybuildertypesv5ColumnDescriptorDTO,
|
||||
Querybuildertypesv5QueryEnvelopeClickHouseSQLDTO,
|
||||
Querybuildertypesv5QueryRangeRequestDTO,
|
||||
Querybuildertypesv5ScalarDataDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
@@ -44,16 +45,43 @@ export function extractAggregationsPerQuery(
|
||||
return perQuery;
|
||||
}
|
||||
|
||||
/**
|
||||
* Names of the request's clickhouse_sql queries. These have no aggregation
|
||||
* metadata, but their value columns carry the user's real SQL alias in the
|
||||
* response `col.name` — so columns of these queries are named/keyed by that
|
||||
* alias rather than collapsing onto the query name. Builder/formula/promql use
|
||||
* placeholder names (`__result`/`__result_N`) and are excluded here.
|
||||
*/
|
||||
export function extractClickhouseQueryNames(
|
||||
requestPayload: Querybuildertypesv5QueryRangeRequestDTO | undefined,
|
||||
): Set<string> {
|
||||
const names = new Set<string>();
|
||||
(requestPayload?.compositeQuery?.queries ?? []).forEach((envelope) => {
|
||||
if (envelope.type !== Querybuildertypesv5QueryTypeDTO.clickhouse_sql) {
|
||||
return;
|
||||
}
|
||||
const spec = (envelope as Querybuildertypesv5QueryEnvelopeClickHouseSQLDTO)
|
||||
.spec;
|
||||
if (spec?.name) {
|
||||
names.add(spec.name);
|
||||
}
|
||||
});
|
||||
return names;
|
||||
}
|
||||
|
||||
/**
|
||||
* Column display name. Group columns keep their field name; aggregation
|
||||
* columns resolve alias > legend > expression > queryName — with the legend
|
||||
* skipped when the query has multiple aggregations, because one legend can't
|
||||
* label several value columns. (Port of V1 `getColName`.)
|
||||
* label several value columns. clickhouse_sql columns have no aggregation
|
||||
* metadata, so their value columns are named by the real SQL alias the
|
||||
* response carries in `col.name`. (Port of V1 `getColName`.)
|
||||
*/
|
||||
function getColName(
|
||||
col: Querybuildertypesv5ColumnDescriptorDTO,
|
||||
legendMap: Record<string, string>,
|
||||
aggregationsPerQuery: AggregationsPerQuery,
|
||||
clickhouseQueryNames: Set<string>,
|
||||
): string {
|
||||
if (col.columnType === 'group') {
|
||||
return col.name;
|
||||
@@ -74,6 +102,13 @@ function getColName(
|
||||
return alias || expression || queryName;
|
||||
}
|
||||
|
||||
// clickhouse_sql value columns carry their real SQL alias in col.name — use
|
||||
// it so each value column keeps its own header instead of collapsing onto
|
||||
// the query name. Formulas/promql use placeholder names, so they fall back
|
||||
// to legend || queryName.
|
||||
if (clickhouseQueryNames.has(queryName)) {
|
||||
return col.name;
|
||||
}
|
||||
return legend || queryName;
|
||||
}
|
||||
|
||||
@@ -85,15 +120,23 @@ function getColName(
|
||||
function getColId(
|
||||
col: Querybuildertypesv5ColumnDescriptorDTO,
|
||||
aggregationsPerQuery: AggregationsPerQuery,
|
||||
clickhouseQueryNames: Set<string>,
|
||||
): string {
|
||||
if (col.columnType === 'group') {
|
||||
return col.name;
|
||||
}
|
||||
|
||||
const queryName = col.queryName ?? '';
|
||||
|
||||
// clickhouse_sql value columns are keyed by their real SQL alias so multiple
|
||||
// value columns stay unique instead of all collapsing onto the query name
|
||||
// (which would overwrite every cell in the row with the last column's value).
|
||||
if (clickhouseQueryNames.has(queryName)) {
|
||||
return col.name;
|
||||
}
|
||||
|
||||
const aggregations = aggregationsPerQuery[queryName];
|
||||
const expression = aggregations?.[col.aggregationIndex ?? 0]?.expression || '';
|
||||
|
||||
if ((aggregations?.length || 0) > 1 && expression) {
|
||||
return `${queryName}.${expression}`;
|
||||
}
|
||||
@@ -119,6 +162,7 @@ export function prepareScalarTables({
|
||||
requestPayload,
|
||||
}: PrepareScalarTablesArgs): PanelTable[] {
|
||||
const aggregationsPerQuery = extractAggregationsPerQuery(requestPayload);
|
||||
const clickhouseQueryNames = extractClickhouseQueryNames(requestPayload);
|
||||
|
||||
return results.map((scalarData) => {
|
||||
if (!scalarData) {
|
||||
@@ -132,10 +176,10 @@ export function prepareScalarTables({
|
||||
const queryName = scalarData.columns?.[0]?.queryName ?? '';
|
||||
|
||||
const columns: PanelTableColumn[] = (scalarData.columns ?? []).map((col) => ({
|
||||
name: getColName(col, legendMap, aggregationsPerQuery),
|
||||
name: getColName(col, legendMap, aggregationsPerQuery, clickhouseQueryNames),
|
||||
queryName: col.queryName ?? '',
|
||||
isValueColumn: col.columnType === 'aggregation',
|
||||
id: getColId(col, aggregationsPerQuery),
|
||||
id: getColId(col, aggregationsPerQuery, clickhouseQueryNames),
|
||||
}));
|
||||
|
||||
const rows = (scalarData.data ?? []).map((dataRow) => {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { StateCreator } from 'zustand';
|
||||
|
||||
import type {
|
||||
VariableSelection,
|
||||
VariableSelectionMap,
|
||||
} from '../../VariablesBar/selectionTypes';
|
||||
import type { DashboardStore } from '../useDashboardStore';
|
||||
|
||||
/**
|
||||
* Runtime variable selection — the values the user picks in the variable bar.
|
||||
* Keyed by dashboardId → variable name. Frontend-only and persisted to
|
||||
* localStorage (mirrored to the URL by the bar for shareable links); it is
|
||||
* deliberately NOT part of the dashboard spec, so selecting a value never
|
||||
* patches the dashboard.
|
||||
*/
|
||||
export interface VariableSelectionSlice {
|
||||
variableValues: Record<string, VariableSelectionMap>;
|
||||
setVariableValue: (
|
||||
dashboardId: string,
|
||||
name: string,
|
||||
selection: VariableSelection,
|
||||
) => void;
|
||||
/** Bulk set (used to seed from URL/localStorage/defaults on load). */
|
||||
setVariableValues: (dashboardId: string, values: VariableSelectionMap) => void;
|
||||
}
|
||||
|
||||
export const createVariableSelectionSlice: StateCreator<
|
||||
DashboardStore,
|
||||
[['zustand/persist', unknown]],
|
||||
[],
|
||||
VariableSelectionSlice
|
||||
> = (set, get) => ({
|
||||
variableValues: {},
|
||||
setVariableValue: (dashboardId, name, selection): void => {
|
||||
const { variableValues } = get();
|
||||
set({
|
||||
variableValues: {
|
||||
...variableValues,
|
||||
[dashboardId]: { ...variableValues[dashboardId], [name]: selection },
|
||||
},
|
||||
});
|
||||
},
|
||||
setVariableValues: (dashboardId, values): void => {
|
||||
const { variableValues } = get();
|
||||
set({
|
||||
variableValues: { ...variableValues, [dashboardId]: values },
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
/** Selector: the selection map for a dashboard (empty if none). */
|
||||
export const selectVariableValues =
|
||||
(dashboardId: string) =>
|
||||
(state: DashboardStore): VariableSelectionMap =>
|
||||
state.variableValues[dashboardId] ?? {};
|
||||
@@ -9,25 +9,36 @@ import {
|
||||
createCollapseSlice,
|
||||
type CollapseSlice,
|
||||
} from './slices/collapseSlice';
|
||||
import {
|
||||
createVariableSelectionSlice,
|
||||
type VariableSelectionSlice,
|
||||
} from './slices/variableSelectionSlice';
|
||||
|
||||
export type DashboardStore = EditContextSlice & CollapseSlice;
|
||||
export type DashboardStore = EditContextSlice &
|
||||
CollapseSlice &
|
||||
VariableSelectionSlice;
|
||||
|
||||
/**
|
||||
* V2 dashboard session store. Holds cross-cutting client state only — never the
|
||||
* dashboard spec (that stays in react-query via useGetDashboardV2). Two slices:
|
||||
* dashboard spec (that stays in react-query via useGetDashboardV2). Slices:
|
||||
* - edit-context: dashboardId / isEditable / refetch (set once, not persisted).
|
||||
* - collapse: per-section open state (frontend-only, persisted to localStorage).
|
||||
* - variable-selection: runtime variable values (frontend-only, persisted).
|
||||
*/
|
||||
export const useDashboardStore = create<DashboardStore>()(
|
||||
persist(
|
||||
(...a) => ({
|
||||
...createEditContextSlice(...a),
|
||||
...createCollapseSlice(...a),
|
||||
...createVariableSelectionSlice(...a),
|
||||
}),
|
||||
{
|
||||
name: '@signoz/dashboard-v2',
|
||||
// Persist only the collapse map — context (incl. the refetch fn) is transient.
|
||||
partialize: (state) => ({ collapsed: state.collapsed }),
|
||||
// Persist UI-only state (context incl. the refetch fn is transient).
|
||||
partialize: (state) => ({
|
||||
collapsed: state.collapsed,
|
||||
variableValues: state.variableValues,
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 8px;
|
||||
padding: 0 16px;
|
||||
gap: 8px;
|
||||
height: 48px;
|
||||
flex: none;
|
||||
border-bottom: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
.headerLeft {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useState } from 'react';
|
||||
import { AnnouncementBanner } from '@signozhq/ui/announcement-banner';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { LayoutGrid } from '@signozhq/icons';
|
||||
|
||||
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
|
||||
import DashboardsList from './components/DashboardsList';
|
||||
import DashboardsList from './components/DashboardsList/DashboardsList';
|
||||
|
||||
import styles from './DashboardsListPageV2.module.scss';
|
||||
import { BreadcrumbLink } from '@signozhq/ui/breadcrumb';
|
||||
|
||||
function DashboardsListPageV2(): JSX.Element {
|
||||
const [showBanner, setShowBanner] = useState(true);
|
||||
@@ -24,8 +24,7 @@ function DashboardsListPageV2(): JSX.Element {
|
||||
)}
|
||||
<div className={styles.header}>
|
||||
<div className={styles.headerLeft}>
|
||||
<LayoutGrid size={14} className={styles.icon} />
|
||||
<Typography.Text className={styles.text}>Dashboards</Typography.Text>
|
||||
<BreadcrumbLink icon={<LayoutGrid size={14} />}>Dashboard</BreadcrumbLink>
|
||||
</div>
|
||||
<HeaderRightSection
|
||||
enableAnnouncements={false}
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
import { useMutation } from 'react-query';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
import { Popover } from 'antd';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import {
|
||||
Copy,
|
||||
Expand,
|
||||
EllipsisVertical,
|
||||
Link2,
|
||||
SquareArrowOutUpRight,
|
||||
} from '@signozhq/icons';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { cloneDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
import { getAbsoluteUrl } from 'utils/basePath';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
|
||||
@@ -31,6 +40,23 @@ function ActionsPopover({
|
||||
onView,
|
||||
}: Props): JSX.Element {
|
||||
const [, setCopy] = useCopyToClipboard();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
// Clone keeps the source's name/panels/tags as a new unlocked dashboard owned
|
||||
// by the caller; open the copy so it can be tweaked right away.
|
||||
const { mutate: runClone, isLoading: isCloning } = useMutation({
|
||||
mutationFn: () => cloneDashboardV2({ id: dashboardId }),
|
||||
onSuccess: (response) => {
|
||||
toast.success(`Duplicated "${dashboardName}"`);
|
||||
safeNavigate(
|
||||
generatePath(ROUTES.DASHBOARD, { dashboardId: response.data.id }),
|
||||
);
|
||||
},
|
||||
onError: (error: APIError) => {
|
||||
showErrorModal(error);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Popover
|
||||
@@ -71,6 +97,20 @@ function ActionsPopover({
|
||||
>
|
||||
Copy Link
|
||||
</Button>
|
||||
<Button
|
||||
color="secondary"
|
||||
className={styles.menuItem}
|
||||
prefix={<Copy size={14} />}
|
||||
loading={isCloning}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
runClone();
|
||||
}}
|
||||
testId="dashboard-action-duplicate"
|
||||
>
|
||||
Duplicate
|
||||
</Button>
|
||||
<DeleteActionItem
|
||||
dashboardId={dashboardId}
|
||||
dashboardName={dashboardName}
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.preview {
|
||||
display: flex;
|
||||
padding: 12px 14.634px;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 7.317px;
|
||||
border-radius: 4px;
|
||||
border: 0.915px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
}
|
||||
|
||||
.previewHeader {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.previewIcon {
|
||||
height: 14px;
|
||||
width: 14px;
|
||||
}
|
||||
|
||||
.previewTitle {
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 12.805px;
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: 18.293px;
|
||||
letter-spacing: -0.064px;
|
||||
}
|
||||
|
||||
.previewDetails {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.previewRow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.formattedTime {
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.formattedTimeText {
|
||||
font-family: Inter;
|
||||
font-size: 12.805px;
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: 16.463px;
|
||||
letter-spacing: -0.064px;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.userTag {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: var(--l2-foreground);
|
||||
font-size: 8px;
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: normal;
|
||||
letter-spacing: -0.05px;
|
||||
border-radius: 12.805px;
|
||||
background-color: var(--l1-background);
|
||||
}
|
||||
|
||||
.userLabel {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 12.805px;
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: 16.463px;
|
||||
letter-spacing: -0.064px;
|
||||
}
|
||||
|
||||
.action {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0px 0px 0px 14.634px;
|
||||
}
|
||||
|
||||
.actionLeft {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.connectionLine {
|
||||
border-top: 1px dashed var(--l1-border);
|
||||
min-width: 20px;
|
||||
flex-grow: 1;
|
||||
margin: 0px 8px;
|
||||
}
|
||||
|
||||
.actionRight {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.saveChanges {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
padding: 8px 16px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l1-border);
|
||||
}
|
||||
|
||||
:global(.configureMetadataModalRoot) {
|
||||
:global(.ant-modal-content) {
|
||||
width: 500px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--card);
|
||||
box-shadow: 0px -4px 16px 2px rgba(0, 0, 0, 0.2);
|
||||
padding: 0px;
|
||||
}
|
||||
|
||||
:global(.ant-modal-header) {
|
||||
background: var(--card);
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
:global(.ant-modal-body) {
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
:global(.ant-modal-footer) {
|
||||
margin-top: 0px;
|
||||
padding: 4px 16px 16px 16px;
|
||||
}
|
||||
}
|
||||
@@ -1,218 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button, Modal } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
import { CalendarClock, Check, Clock4 } from '@signozhq/icons';
|
||||
import { get } from 'lodash-es';
|
||||
import { Base64Icons } from 'container/DashboardContainer/DashboardSettings/General/utils';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
|
||||
import { lastUpdatedLabel, type DashboardListItem } from '../../utils';
|
||||
import {
|
||||
DynamicColumns,
|
||||
useDashboardsListVisibleColumnsStore,
|
||||
type DashboardDynamicColumns,
|
||||
} from './useDynamicColumns';
|
||||
|
||||
import styles from './ConfigureMetadataModal.module.scss';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
previewDashboard: DashboardListItem | undefined;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function ConfigureMetadataModal({
|
||||
open,
|
||||
previewDashboard,
|
||||
onClose,
|
||||
}: Props): JSX.Element {
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
|
||||
const storedColumns = useDashboardsListVisibleColumnsStore(
|
||||
(s) => s.visibleColumns,
|
||||
);
|
||||
const setStoredColumns = useDashboardsListVisibleColumnsStore(
|
||||
(s) => s.setVisibleColumns,
|
||||
);
|
||||
const [draftColumns, setDraftColumns] =
|
||||
useState<DashboardDynamicColumns>(storedColumns);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setDraftColumns(storedColumns);
|
||||
}
|
||||
}, [open, storedColumns]);
|
||||
|
||||
const handleSave = (): void => {
|
||||
setStoredColumns(draftColumns);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const previewImage = previewDashboard?.image || Base64Icons[0];
|
||||
const previewName = previewDashboard?.spec?.display?.name;
|
||||
const previewCreatedBy = previewDashboard?.createdBy;
|
||||
const previewUpdatedBy = previewDashboard?.updatedBy;
|
||||
const previewUpdatedAt = previewDashboard?.updatedAt;
|
||||
|
||||
const formattedCreatedAt = previewDashboard
|
||||
? formatTimezoneAdjustedTimestamp(
|
||||
get(previewDashboard, 'createdAt', '') as string,
|
||||
DATE_TIME_FORMATS.DASH_DATETIME_UTC,
|
||||
)
|
||||
: '';
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
title="Configure Metadata"
|
||||
footer={
|
||||
<Button
|
||||
type="text"
|
||||
icon={<Check size={14} />}
|
||||
className={styles.saveChanges}
|
||||
onClick={handleSave}
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
}
|
||||
rootClassName="configureMetadataModalRoot"
|
||||
>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.preview}>
|
||||
<section className={styles.previewHeader}>
|
||||
<img
|
||||
src={previewImage}
|
||||
alt="dashboard-image"
|
||||
className={styles.previewIcon}
|
||||
/>
|
||||
<Typography.Text className={styles.previewTitle}>
|
||||
{previewName}
|
||||
</Typography.Text>
|
||||
</section>
|
||||
<section className={styles.previewDetails}>
|
||||
<section className={styles.previewRow}>
|
||||
{draftColumns.createdAt && (
|
||||
<span className={styles.formattedTime}>
|
||||
<CalendarClock size={14} />
|
||||
<Typography.Text className={styles.formattedTimeText}>
|
||||
{formattedCreatedAt}
|
||||
</Typography.Text>
|
||||
</span>
|
||||
)}
|
||||
{draftColumns.createdBy && (
|
||||
<div className={styles.user}>
|
||||
<Typography.Text className={styles.userTag}>
|
||||
{previewCreatedBy?.substring(0, 1).toUpperCase()}
|
||||
</Typography.Text>
|
||||
<Typography.Text className={styles.userLabel}>
|
||||
{previewCreatedBy}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
<section className={styles.previewRow}>
|
||||
{draftColumns.updatedAt && (
|
||||
<span className={styles.formattedTime}>
|
||||
<CalendarClock size={14} />
|
||||
<Typography.Text className={styles.formattedTimeText}>
|
||||
{lastUpdatedLabel(previewUpdatedAt)}
|
||||
</Typography.Text>
|
||||
</span>
|
||||
)}
|
||||
{draftColumns.updatedBy && (
|
||||
<div className={styles.user}>
|
||||
<Typography.Text className={styles.userTag}>
|
||||
{previewUpdatedBy?.substring(0, 1).toUpperCase()}
|
||||
</Typography.Text>
|
||||
<Typography.Text className={styles.userLabel}>
|
||||
{previewUpdatedBy}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className={styles.action}>
|
||||
<div className={styles.actionLeft}>
|
||||
<CalendarClock size={14} />
|
||||
<Typography.Text>Created at</Typography.Text>
|
||||
</div>
|
||||
<div className={styles.connectionLine} />
|
||||
<div className={styles.actionRight}>
|
||||
<Switch
|
||||
value
|
||||
disabled
|
||||
onChange={(check): void =>
|
||||
setDraftColumns((prev) => ({
|
||||
...prev,
|
||||
[DynamicColumns.CREATED_AT]: check,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.action}>
|
||||
<div className={styles.actionLeft}>
|
||||
<CalendarClock size={14} />
|
||||
<Typography.Text>Created by</Typography.Text>
|
||||
</div>
|
||||
<div className={styles.connectionLine} />
|
||||
<div className={styles.actionRight}>
|
||||
<Switch
|
||||
value
|
||||
disabled
|
||||
onChange={(check): void =>
|
||||
setDraftColumns((prev) => ({
|
||||
...prev,
|
||||
[DynamicColumns.CREATED_BY]: check,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.action}>
|
||||
<div className={styles.actionLeft}>
|
||||
<Clock4 size={14} />
|
||||
<Typography.Text>Updated at</Typography.Text>
|
||||
</div>
|
||||
<div className={styles.connectionLine} />
|
||||
<div className={styles.actionRight}>
|
||||
<Switch
|
||||
value={draftColumns.updatedAt}
|
||||
onChange={(check): void =>
|
||||
setDraftColumns((prev) => ({
|
||||
...prev,
|
||||
[DynamicColumns.UPDATED_AT]: check,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.action}>
|
||||
<div className={styles.actionLeft}>
|
||||
<Clock4 size={14} />
|
||||
<Typography.Text>Updated by</Typography.Text>
|
||||
</div>
|
||||
<div className={styles.connectionLine} />
|
||||
<div className={styles.actionRight}>
|
||||
<Switch
|
||||
value={draftColumns.updatedBy}
|
||||
onChange={(check): void =>
|
||||
setDraftColumns((prev) => ({
|
||||
...prev,
|
||||
[DynamicColumns.UPDATED_BY]: check,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConfigureMetadataModal;
|
||||
@@ -1,34 +0,0 @@
|
||||
.menuItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.templatesItem {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.primaryButton {
|
||||
padding: 6px 12px;
|
||||
}
|
||||
|
||||
.textButton {
|
||||
display: flex;
|
||||
width: 153px;
|
||||
align-items: center;
|
||||
height: 32px;
|
||||
padding: 6px 12px;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
border-radius: 2px;
|
||||
background: var(--primary-background);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
:global(.createDashboardMenuOverlay) {
|
||||
width: 200px;
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
// eslint-disable-next-line signoz/no-antd-components -- TODO: migrate Dropdown to @signozhq/ui/dropdown-menu
|
||||
import { Button, Dropdown, MenuProps } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import {
|
||||
ExternalLink,
|
||||
Github,
|
||||
LayoutGrid,
|
||||
Plus,
|
||||
Radius,
|
||||
} from '@signozhq/icons';
|
||||
|
||||
import styles from './CreateDashboardDropdown.module.scss';
|
||||
|
||||
interface Props {
|
||||
canCreate: boolean;
|
||||
onCreate: () => void;
|
||||
onImportJSON: () => void;
|
||||
variant?: 'primary' | 'text';
|
||||
}
|
||||
|
||||
const TEMPLATES_HREF =
|
||||
'https://signoz.io/docs/dashboards/dashboard-templates/overview/';
|
||||
|
||||
function CreateDashboardDropdown({
|
||||
canCreate,
|
||||
onCreate,
|
||||
onImportJSON,
|
||||
variant = 'primary',
|
||||
}: Props): JSX.Element {
|
||||
const items: MenuProps['items'] = useMemo(() => {
|
||||
const menuItems: MenuProps['items'] = [
|
||||
{
|
||||
key: 'import-json',
|
||||
label: (
|
||||
<div
|
||||
className={styles.menuItem}
|
||||
data-testid="import-json-menu-cta"
|
||||
onClick={onImportJSON}
|
||||
>
|
||||
<Radius size={14} /> Import JSON
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'view-templates',
|
||||
label: (
|
||||
<a
|
||||
href={TEMPLATES_HREF}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
data-testid="view-templates-menu-cta"
|
||||
>
|
||||
<div className={styles.templatesItem}>
|
||||
<div className={styles.menuItem}>
|
||||
<Github size={14} /> View templates
|
||||
</div>
|
||||
<ExternalLink size={14} />
|
||||
</div>
|
||||
</a>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
if (canCreate) {
|
||||
menuItems.unshift({
|
||||
key: 'create-dashboard',
|
||||
label: (
|
||||
<div
|
||||
className={styles.menuItem}
|
||||
data-testid="create-dashboard-menu-cta"
|
||||
onClick={onCreate}
|
||||
>
|
||||
<LayoutGrid size={14} /> Create dashboard
|
||||
</div>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return menuItems;
|
||||
}, [canCreate, onCreate, onImportJSON]);
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
overlayClassName="createDashboardMenuOverlay"
|
||||
menu={{ items }}
|
||||
placement="bottomRight"
|
||||
trigger={['click']}
|
||||
>
|
||||
{variant === 'primary' ? (
|
||||
<Button
|
||||
type="primary"
|
||||
className={cx('periscope-btn primary', styles.primaryButton)}
|
||||
icon={<Plus size={14} />}
|
||||
data-testid="new-dashboard-cta"
|
||||
onClick={(): void => {
|
||||
logEvent('Dashboard List: New dashboard clicked', {});
|
||||
}}
|
||||
>
|
||||
New dashboard
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="text"
|
||||
className={styles.textButton}
|
||||
icon={<Plus size={14} />}
|
||||
onClick={(): void => {
|
||||
logEvent('Dashboard List: New dashboard clicked', {});
|
||||
}}
|
||||
>
|
||||
New Dashboard
|
||||
</Button>
|
||||
)}
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
|
||||
export default CreateDashboardDropdown;
|
||||
@@ -1,9 +1,14 @@
|
||||
.row {
|
||||
padding: 12px 16px 16px 16px;
|
||||
border: 1px solid var(--l1-border);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-top: none;
|
||||
background: var(--l2-background);
|
||||
background: var(--l1-background);
|
||||
cursor: pointer;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
|
||||
.row:hover {
|
||||
background: var(--l2-background);
|
||||
}
|
||||
|
||||
.titleWithAction {
|
||||
@@ -57,6 +62,40 @@
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.favBtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex: none;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
background: transparent;
|
||||
color: transparent;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.12s,
|
||||
color 0.12s;
|
||||
}
|
||||
|
||||
.row:hover .favBtn {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.favBtn:hover {
|
||||
background: var(--l1-background);
|
||||
color: var(--bg-amber-500);
|
||||
}
|
||||
|
||||
.favBtnOn {
|
||||
color: var(--bg-amber-500);
|
||||
|
||||
svg {
|
||||
fill: currentColor;
|
||||
}
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Tooltip } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { CalendarClock } from '@signozhq/icons';
|
||||
import { CalendarClock, Star } from '@signozhq/icons';
|
||||
import cx from 'classnames';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
import { Base64Icons } from 'container/DashboardContainer/DashboardSettings/General/utils';
|
||||
@@ -11,6 +12,7 @@ import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { isModifierKeyPressed } from 'utils/app';
|
||||
|
||||
import { useDashboardViewsStore } from '../../store/useDashboardViewsStore';
|
||||
import type { DashboardListItem } from '../../utils';
|
||||
import { lastUpdatedLabel, tagsToStrings } from '../../utils';
|
||||
import ActionsPopover from '../ActionsPopover/ActionsPopover';
|
||||
@@ -35,6 +37,12 @@ function DashboardRow({
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
|
||||
const isFavorite = useDashboardViewsStore((s) =>
|
||||
s.favorites.includes(dashboard.id),
|
||||
);
|
||||
const toggleFavorite = useDashboardViewsStore((s) => s.toggleFavorite);
|
||||
const markViewed = useDashboardViewsStore((s) => s.markViewed);
|
||||
|
||||
const id = dashboard.id;
|
||||
const name = dashboard.spec?.display?.name ?? '';
|
||||
const image = dashboard.image || Base64Icons[0];
|
||||
@@ -53,6 +61,7 @@ function DashboardRow({
|
||||
|
||||
const onClickHandler = (event: React.MouseEvent<HTMLElement>): void => {
|
||||
event.stopPropagation();
|
||||
markViewed(id);
|
||||
safeNavigate(link, { newTab: isModifierKeyPressed(event) });
|
||||
logEvent('Dashboard List: Clicked on dashboard', {
|
||||
dashboardId: id,
|
||||
@@ -60,6 +69,11 @@ function DashboardRow({
|
||||
});
|
||||
};
|
||||
|
||||
const onToggleFavorite = (event: React.MouseEvent<HTMLElement>): void => {
|
||||
event.stopPropagation();
|
||||
toggleFavorite(id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.row} onClick={onClickHandler}>
|
||||
<div className={styles.titleWithAction}>
|
||||
@@ -98,6 +112,17 @@ function DashboardRow({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={cx(styles.favBtn, { [styles.favBtnOn]: isFavorite })}
|
||||
aria-label={isFavorite ? 'Remove from favorites' : 'Add to favorites'}
|
||||
title={isFavorite ? 'Remove from favorites' : 'Add to favorites'}
|
||||
data-testid={`dashboard-favorite-${index}`}
|
||||
onClick={onToggleFavorite}
|
||||
>
|
||||
<Star size={14} />
|
||||
</button>
|
||||
|
||||
{canAct && (
|
||||
<ActionsPopover
|
||||
link={link}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import NewDashboardButton from './NewDashboardButton';
|
||||
|
||||
import styles from './DashboardsList.module.scss';
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
count: number;
|
||||
canCreate: boolean;
|
||||
onCreate: () => void;
|
||||
}
|
||||
|
||||
function CommandHeader({
|
||||
label,
|
||||
count,
|
||||
canCreate,
|
||||
onCreate,
|
||||
}: Props): JSX.Element {
|
||||
return (
|
||||
<div className={styles.commandHeader}>
|
||||
<div className={styles.headingBlock}>
|
||||
<Typography.Title className={styles.title}>{label}</Typography.Title>
|
||||
<span className={styles.countPill}>{count}</span>
|
||||
</div>
|
||||
<div className={styles.grow} />
|
||||
{canCreate && <NewDashboardButton onClick={onCreate} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CommandHeader;
|
||||
@@ -1,14 +1,43 @@
|
||||
.container {
|
||||
margin-top: 30px;
|
||||
margin-bottom: 30px;
|
||||
.layout {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: stretch;
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
// Deepest layer — the results canvas, so the lighter header zone and the
|
||||
// row cards read with clear contrast (matches the design's list surface).
|
||||
background: var(--l1-background);
|
||||
}
|
||||
|
||||
.mainScroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.headerZone {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
padding: 20px 24px;
|
||||
background: var(--l2-background);
|
||||
border-bottom: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
.emptyWrap {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.viewContent {
|
||||
width: calc(100% - 30px);
|
||||
max-width: 836px;
|
||||
width: 100%;
|
||||
|
||||
:global(.ant-table-wrapper) :global(.ant-table-cell) {
|
||||
padding: 0 !important;
|
||||
@@ -16,14 +45,6 @@
|
||||
background: var(--l1-background) !important;
|
||||
}
|
||||
|
||||
:global(.ant-table-wrapper)
|
||||
:global(.ant-table-tbody)
|
||||
:global(.ant-table-row)
|
||||
:global(.ant-table-cell)
|
||||
> div {
|
||||
// Row content is the only child of the td; it carries the borders.
|
||||
}
|
||||
|
||||
:global(.ant-table-wrapper)
|
||||
:global(.ant-table-tbody)
|
||||
:global(.ant-table-row:last-child)
|
||||
@@ -55,19 +76,43 @@
|
||||
}
|
||||
}
|
||||
|
||||
.titleContainer {
|
||||
.commandHeader {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.headingBlock {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.grow {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.countPill {
|
||||
padding: 2px 9px;
|
||||
border-radius: 999px;
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--l2-border);
|
||||
color: var(--l2-foreground);
|
||||
font-size: var(--font-size-xs);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.title {
|
||||
color: var(--l1-foreground);
|
||||
font-size: var(--font-size-lg);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-normal);
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: 28px;
|
||||
letter-spacing: -0.09px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
@@ -80,17 +125,16 @@
|
||||
}
|
||||
|
||||
.integrationsContainer {
|
||||
margin: 16px 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.integrationsContent {
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 16px 0;
|
||||
// The shared request banner ships a 12px margin; drop it so the banner's
|
||||
// left edge lines up with the heading and filters above/below it.
|
||||
:global(.request-entity-container) {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,55 +1,45 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { AxiosError } from 'axios';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import {
|
||||
createDashboardV2,
|
||||
useListDashboardsV2,
|
||||
} from 'api/generated/services/dashboard';
|
||||
import { useListDashboardsV2 } from 'api/generated/services/dashboard';
|
||||
import {
|
||||
DashboardtypesListOrderDTO,
|
||||
DashboardtypesListSortDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { RequestDashboardBtn } from 'container/ListOfDashboard/RequestDashboardBtn';
|
||||
import useComponentPermission from 'hooks/useComponentPermission';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
import { toAPIError } from 'utils/errorUtils';
|
||||
|
||||
import { combineQueries } from '../../filterQuery';
|
||||
import { useActiveView } from '../../hooks/useActiveView';
|
||||
import { useDashboardFilters } from '../../hooks/useDashboardFilters';
|
||||
import {
|
||||
usePage,
|
||||
useSearch,
|
||||
useSortColumn,
|
||||
useSortOrder,
|
||||
} from '../../hooks/useDashboardsListQueryParams';
|
||||
import { useDashboardViewsStore } from '../../store/useDashboardViewsStore';
|
||||
import { useDashboardsListVisibleColumnsStore } from '../../store/useVisibleColumnsStore';
|
||||
import type { UpdatedWindow } from '../../types';
|
||||
import type { DashboardListItem } from '../../utils';
|
||||
import ConfigureMetadataModal from '../ConfigureMetadataModal/ConfigureMetadataModal';
|
||||
import { useDashboardsListVisibleColumnsStore } from '../ConfigureMetadataModal/useDynamicColumns';
|
||||
import CreateDashboardDropdown from '../CreateDashboardDropdown/CreateDashboardDropdown';
|
||||
import ImportJSONModal from '../ImportJSONModal/ImportJSONModal';
|
||||
import ListHeader from '../ListHeader/ListHeader';
|
||||
import EmptyState from '../states/EmptyState/EmptyState';
|
||||
import ErrorState from '../states/ErrorState/ErrorState';
|
||||
import LoadingState from '../states/LoadingState/LoadingState';
|
||||
import NoResultsState from '../states/NoResultsState/NoResultsState';
|
||||
import SearchBar from '../SearchBar/SearchBar';
|
||||
import DashboardsListContent from './DashboardsListContent';
|
||||
import { applyClientView } from '../../views';
|
||||
import type { CreatorOption } from '../FilterZone/FilterChips';
|
||||
import FilterZone from '../FilterZone/FilterZone';
|
||||
import NewDashboardModal from '../NewDashboardModal/NewDashboardModal';
|
||||
import StatusBar from '../StatusBar/StatusBar';
|
||||
import ViewsRail from '../ViewsRail/ViewsRail';
|
||||
import CommandHeader from './CommandHeader';
|
||||
import DashboardsResults from './DashboardsResults';
|
||||
import WorkspaceEmptyState from './WorkspaceEmptyState';
|
||||
|
||||
import styles from './DashboardsList.module.scss';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
// Favorites / recently-viewed are filtered client-side (no server id filter), so
|
||||
// we pull a single large page and constrain it in-memory.
|
||||
const CLIENT_VIEW_LIMIT = 200;
|
||||
|
||||
function DashboardsList(): JSX.Element {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const { t } = useTranslation('dashboard');
|
||||
const { showErrorModal } = useErrorModal();
|
||||
const { isCloudUser } = useGetTenantLicense();
|
||||
|
||||
const { user } = useAppContext();
|
||||
@@ -58,38 +48,100 @@ function DashboardsList(): JSX.Element {
|
||||
user.role,
|
||||
);
|
||||
|
||||
const [searchString, setSearchString] = useSearch();
|
||||
const {
|
||||
filters,
|
||||
query,
|
||||
isEmpty: filtersEmpty,
|
||||
setSearch,
|
||||
setCreatedBy,
|
||||
setUpdated,
|
||||
applyFilters,
|
||||
clearAll,
|
||||
} = useDashboardFilters();
|
||||
const [sortColumn, setSortColumn] = useSortColumn();
|
||||
const [sortOrder, setSortOrder] = useSortOrder();
|
||||
const [page, setPage] = usePage();
|
||||
|
||||
const [searchInput, setSearchInput] = useState(searchString);
|
||||
const {
|
||||
activeViewId,
|
||||
builtinViews,
|
||||
customViews,
|
||||
isCustomActive,
|
||||
isModified,
|
||||
viewQuery,
|
||||
clientView,
|
||||
selectView,
|
||||
saveView,
|
||||
saveActiveView,
|
||||
resetView,
|
||||
removeView,
|
||||
} = useActiveView({ filters, applyFilters, userEmail: user.email });
|
||||
|
||||
// Keep the local input in sync with external searchString changes
|
||||
// (browser back/forward, deep link). User typing only mutates
|
||||
// searchInput, so this won't fight with in-flight edits.
|
||||
useEffect(() => {
|
||||
setSearchInput(searchString);
|
||||
}, [searchString]);
|
||||
const railCollapsed = useDashboardViewsStore((s) => s.railCollapsed);
|
||||
const setRailCollapsed = useDashboardViewsStore((s) => s.setRailCollapsed);
|
||||
const favorites = useDashboardViewsStore((s) => s.favorites);
|
||||
const recent = useDashboardViewsStore((s) => s.recent);
|
||||
|
||||
const handleSubmitSearch = useCallback((): void => {
|
||||
const next = searchInput.trim();
|
||||
if (next === searchString) {
|
||||
return;
|
||||
}
|
||||
void setSearchString(next);
|
||||
// Any filter change resets to the first page so the user isn't stranded on a
|
||||
// now-out-of-range offset.
|
||||
const handleSearchChange = useCallback(
|
||||
(value: string): void => {
|
||||
setSearch(value);
|
||||
void setPage(1);
|
||||
},
|
||||
[setSearch, setPage],
|
||||
);
|
||||
const handleCreatedByChange = useCallback(
|
||||
(emails: string[]): void => {
|
||||
setCreatedBy(emails);
|
||||
void setPage(1);
|
||||
},
|
||||
[setCreatedBy, setPage],
|
||||
);
|
||||
const handleUpdatedChange = useCallback(
|
||||
(window: UpdatedWindow): void => {
|
||||
setUpdated(window);
|
||||
void setPage(1);
|
||||
},
|
||||
[setUpdated, setPage],
|
||||
);
|
||||
const handleClearAll = useCallback((): void => {
|
||||
clearAll();
|
||||
void setPage(1);
|
||||
}, [searchInput, searchString, setSearchString, setPage]);
|
||||
}, [clearAll, setPage]);
|
||||
|
||||
// View actions that change the result set reset pagination too.
|
||||
const handleSelectView = useCallback(
|
||||
(id: string): void => {
|
||||
selectView(id);
|
||||
void setPage(1);
|
||||
},
|
||||
[selectView, setPage],
|
||||
);
|
||||
const handleResetView = useCallback((): void => {
|
||||
resetView();
|
||||
void setPage(1);
|
||||
}, [resetView, setPage]);
|
||||
const handleRemoveView = useCallback(
|
||||
(id: string): void => {
|
||||
removeView(id);
|
||||
void setPage(1);
|
||||
},
|
||||
[removeView, setPage],
|
||||
);
|
||||
const toggleRail = useCallback((): void => {
|
||||
setRailCollapsed(!railCollapsed);
|
||||
}, [setRailCollapsed, railCollapsed]);
|
||||
|
||||
const listParams = useMemo(
|
||||
() => ({
|
||||
query: searchString.trim() || undefined,
|
||||
query: combineQueries(viewQuery, query) || undefined,
|
||||
sort: sortColumn,
|
||||
order: sortOrder,
|
||||
limit: PAGE_SIZE,
|
||||
offset: (page - 1) * PAGE_SIZE,
|
||||
limit: clientView ? CLIENT_VIEW_LIMIT : PAGE_SIZE,
|
||||
offset: clientView ? 0 : (page - 1) * PAGE_SIZE,
|
||||
}),
|
||||
[searchString, sortColumn, sortOrder, page],
|
||||
[viewQuery, query, sortColumn, sortOrder, page, clientView],
|
||||
);
|
||||
|
||||
const {
|
||||
@@ -107,52 +159,49 @@ function DashboardsList(): JSX.Element {
|
||||
const errorHttpStatus = apiError?.getHttpStatusCode();
|
||||
const errorMessage = apiError?.getErrorMessage();
|
||||
|
||||
const dashboards = useMemo<DashboardListItem[]>(
|
||||
const rawDashboards = useMemo<DashboardListItem[]>(
|
||||
() => response?.data?.dashboards ?? [],
|
||||
[response],
|
||||
);
|
||||
const total = response?.data?.total ?? 0;
|
||||
|
||||
const [isImportOpen, setIsImportOpen] = useState(false);
|
||||
const [isConfigureOpen, setIsConfigureOpen] = useState(false);
|
||||
// Favorites / recently-viewed constrain the fetched rows by a client-side id
|
||||
// set; all other views are already constrained server-side.
|
||||
const dashboards = useMemo<DashboardListItem[]>(
|
||||
() =>
|
||||
clientView
|
||||
? applyClientView(rawDashboards, activeViewId, favorites, recent)
|
||||
: rawDashboards,
|
||||
[clientView, rawDashboards, activeViewId, favorites, recent],
|
||||
);
|
||||
const total = clientView ? dashboards.length : (response?.data?.total ?? 0);
|
||||
|
||||
// Creator filter options: distinct authors on the loaded page plus the
|
||||
// current user (so "me" is always selectable). Page-scoped until a members
|
||||
// source backs this.
|
||||
const creatorOptions = useMemo<CreatorOption[]>(() => {
|
||||
const emails = new Set<string>();
|
||||
if (user.email) {
|
||||
emails.add(user.email);
|
||||
}
|
||||
rawDashboards.forEach((d) => {
|
||||
if (d.createdBy) {
|
||||
emails.add(d.createdBy);
|
||||
}
|
||||
});
|
||||
return [...emails].sort().map((email) => ({
|
||||
email,
|
||||
label: email === user.email ? `${email} (me)` : email,
|
||||
}));
|
||||
}, [rawDashboards, user.email]);
|
||||
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||
const visibleColumns = useDashboardsListVisibleColumnsStore(
|
||||
(s) => s.visibleColumns,
|
||||
);
|
||||
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
const handleCreateNew = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
logEvent('Dashboard List: Create dashboard clicked', {});
|
||||
setCreating(true);
|
||||
const created = await createDashboardV2({
|
||||
schemaVersion: 'v6',
|
||||
// Backend requires `name` (immutable, server-side identifier);
|
||||
// asking it to generate one keeps the UI's "new dashboard" flow.
|
||||
generateName: true,
|
||||
tags: null,
|
||||
spec: {
|
||||
display: { name: t('new_dashboard_title', { ns: 'dashboard' }) },
|
||||
layouts: [],
|
||||
panels: {},
|
||||
variables: [],
|
||||
// TODO(@AshwinBhatkal): duration and refresh interval need to be integrated
|
||||
},
|
||||
});
|
||||
safeNavigate(
|
||||
generatePath(ROUTES.DASHBOARD, { dashboardId: created.data.id }),
|
||||
);
|
||||
} catch (e) {
|
||||
showErrorModal(e as APIError);
|
||||
toast.error((e as AxiosError).toString() || 'Failed to create dashboard');
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}, [safeNavigate, showErrorModal, t]);
|
||||
|
||||
const handleImportToggle = useCallback((): void => {
|
||||
logEvent('Dashboard List V2: Import JSON clicked', {});
|
||||
setIsImportOpen((s) => !s);
|
||||
const openCreate = useCallback((): void => {
|
||||
logEvent('Dashboard List: New dashboard clicked', {});
|
||||
setIsCreateOpen(true);
|
||||
}, []);
|
||||
|
||||
const onSortChange = useCallback(
|
||||
@@ -180,102 +229,109 @@ function DashboardsList(): JSX.Element {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isLoading]);
|
||||
|
||||
const activeLabel =
|
||||
customViews.find((v) => v.id === activeViewId)?.name ??
|
||||
builtinViews.find((v) => v.id === activeViewId)?.label ??
|
||||
'Dashboards';
|
||||
|
||||
// The workspace-empty CTA ("create your first dashboard") belongs only to the
|
||||
// unfiltered All view; every other view's zero result is a no-results state.
|
||||
const showWorkspaceEmpty =
|
||||
!error &&
|
||||
dashboards.length === 0 &&
|
||||
activeViewId === 'all' &&
|
||||
filtersEmpty &&
|
||||
page === 1;
|
||||
|
||||
const isWorkspaceEmpty = showWorkspaceEmpty && !isLoading;
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.viewContent}>
|
||||
<div className={styles.titleContainer}>
|
||||
<Typography.Title className={styles.title}>Dashboards</Typography.Title>
|
||||
<Typography.Text className={styles.subtitle}>
|
||||
Create and manage dashboards for your workspace.
|
||||
</Typography.Text>
|
||||
{isCloudUser && (
|
||||
<div className={styles.integrationsContainer}>
|
||||
<div className={styles.integrationsContent}>
|
||||
<RequestDashboardBtn />
|
||||
<div className={styles.layout}>
|
||||
<ViewsRail
|
||||
activeViewId={activeViewId}
|
||||
builtinViews={builtinViews}
|
||||
customViews={customViews}
|
||||
isCustomActive={isCustomActive}
|
||||
isModified={isModified}
|
||||
collapsed={railCollapsed}
|
||||
onSelect={handleSelectView}
|
||||
onSave={saveView}
|
||||
onSaveChanges={saveActiveView}
|
||||
onReset={handleResetView}
|
||||
onClearFilters={handleClearAll}
|
||||
onDelete={handleRemoveView}
|
||||
/>
|
||||
<div className={styles.main}>
|
||||
<div className={styles.mainScroll}>
|
||||
{isWorkspaceEmpty ? (
|
||||
<WorkspaceEmptyState
|
||||
canCreate={canCreateNewDashboard}
|
||||
onCreate={openCreate}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className={styles.headerZone}>
|
||||
<CommandHeader
|
||||
label={activeLabel}
|
||||
count={total}
|
||||
canCreate={canCreateNewDashboard}
|
||||
onCreate={openCreate}
|
||||
/>
|
||||
<FilterZone
|
||||
search={filters.search}
|
||||
createdBy={filters.createdBy}
|
||||
updated={filters.updated}
|
||||
creatorOptions={creatorOptions}
|
||||
isEmpty={filtersEmpty}
|
||||
onSearchChange={handleSearchChange}
|
||||
onCreatedByChange={handleCreatedByChange}
|
||||
onUpdatedChange={handleUpdatedChange}
|
||||
onClearAll={handleClearAll}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<LoadingState />
|
||||
) : !error && dashboards.length === 0 && !searchString && page === 1 ? (
|
||||
<EmptyState
|
||||
createDropdown={
|
||||
canCreateNewDashboard ? (
|
||||
<CreateDashboardDropdown
|
||||
canCreate={!!canCreateNewDashboard}
|
||||
onCreate={handleCreateNew}
|
||||
onImportJSON={handleImportToggle}
|
||||
variant="text"
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className={styles.toolbar}>
|
||||
<SearchBar
|
||||
value={searchInput}
|
||||
onChange={setSearchInput}
|
||||
onSubmit={handleSubmitSearch}
|
||||
/>
|
||||
{canCreateNewDashboard && (
|
||||
<CreateDashboardDropdown
|
||||
canCreate={!!canCreateNewDashboard}
|
||||
onCreate={handleCreateNew}
|
||||
onImportJSON={handleImportToggle}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<ErrorState
|
||||
isCloudUser={!!isCloudUser}
|
||||
onRetry={(): void => {
|
||||
refetch();
|
||||
}}
|
||||
httpStatus={errorHttpStatus}
|
||||
errorMessage={errorMessage}
|
||||
/>
|
||||
) : dashboards.length === 0 ? (
|
||||
<NoResultsState searchString={searchInput} />
|
||||
) : (
|
||||
<>
|
||||
<ListHeader
|
||||
<div className={styles.viewContent}>
|
||||
<DashboardsResults
|
||||
isLoading={isLoading}
|
||||
hasError={!!error}
|
||||
isCloudUser={!!isCloudUser}
|
||||
onRetry={(): void => {
|
||||
refetch();
|
||||
}}
|
||||
errorHttpStatus={errorHttpStatus}
|
||||
errorMessage={errorMessage}
|
||||
dashboards={dashboards}
|
||||
activeViewId={activeViewId}
|
||||
searchValue={filters.search}
|
||||
hasFilters={!filtersEmpty}
|
||||
sortColumn={sortColumn}
|
||||
onSortChange={onSortChange}
|
||||
sortOrder={sortOrder}
|
||||
onOrderChange={onOrderChange}
|
||||
onConfigureMetadata={(): void => setIsConfigureOpen(true)}
|
||||
/>
|
||||
<DashboardsListContent
|
||||
dashboards={dashboards}
|
||||
page={page}
|
||||
pageSize={PAGE_SIZE}
|
||||
pageSize={clientView ? CLIENT_VIEW_LIMIT : PAGE_SIZE}
|
||||
total={total}
|
||||
onPageChange={setPage}
|
||||
canAct={!!action}
|
||||
showUpdatedAt={visibleColumns.updatedAt}
|
||||
showUpdatedBy={visibleColumns.updatedBy}
|
||||
loading={creating || isFetching}
|
||||
loading={isFetching}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<ImportJSONModal
|
||||
open={isImportOpen}
|
||||
onClose={(): void => setIsImportOpen(false)}
|
||||
/>
|
||||
|
||||
<ConfigureMetadataModal
|
||||
open={isConfigureOpen}
|
||||
previewDashboard={dashboards[0]}
|
||||
onClose={(): void => setIsConfigureOpen(false)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<StatusBar
|
||||
collapsed={railCollapsed}
|
||||
onToggleCollapse={toggleRail}
|
||||
count={dashboards.length}
|
||||
total={total}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<NewDashboardModal
|
||||
open={isCreateOpen}
|
||||
onClose={(): void => setIsCreateOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import {
|
||||
DashboardtypesListOrderDTO,
|
||||
DashboardtypesListSortDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { DashboardListItem } from '../../utils';
|
||||
import { noResultsCopy } from '../../views';
|
||||
import ListHeader from '../ListHeader/ListHeader';
|
||||
import ErrorState from '../states/ErrorState/ErrorState';
|
||||
import LoadingState from '../states/LoadingState/LoadingState';
|
||||
import NoResultsState from '../states/NoResultsState/NoResultsState';
|
||||
import DashboardsListContent from './DashboardsListContent';
|
||||
|
||||
interface Props {
|
||||
isLoading: boolean;
|
||||
hasError: boolean;
|
||||
isCloudUser: boolean;
|
||||
onRetry: () => void;
|
||||
errorHttpStatus?: number;
|
||||
errorMessage?: string;
|
||||
dashboards: DashboardListItem[];
|
||||
activeViewId: string;
|
||||
searchValue: string;
|
||||
hasFilters: boolean;
|
||||
sortColumn: DashboardtypesListSortDTO;
|
||||
onSortChange: (column: DashboardtypesListSortDTO) => void;
|
||||
sortOrder: DashboardtypesListOrderDTO;
|
||||
onOrderChange: (order: DashboardtypesListOrderDTO) => void;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
onPageChange: (page: number) => void;
|
||||
canAct: boolean;
|
||||
showUpdatedAt: boolean;
|
||||
showUpdatedBy: boolean;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
function DashboardsResults({
|
||||
isLoading,
|
||||
hasError,
|
||||
isCloudUser,
|
||||
onRetry,
|
||||
errorHttpStatus,
|
||||
errorMessage,
|
||||
dashboards,
|
||||
activeViewId,
|
||||
searchValue,
|
||||
hasFilters,
|
||||
sortColumn,
|
||||
onSortChange,
|
||||
sortOrder,
|
||||
onOrderChange,
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
onPageChange,
|
||||
canAct,
|
||||
showUpdatedAt,
|
||||
showUpdatedBy,
|
||||
loading,
|
||||
}: Props): JSX.Element {
|
||||
if (isLoading) {
|
||||
return <LoadingState />;
|
||||
}
|
||||
if (hasError) {
|
||||
return (
|
||||
<ErrorState
|
||||
isCloudUser={isCloudUser}
|
||||
onRetry={onRetry}
|
||||
httpStatus={errorHttpStatus}
|
||||
errorMessage={errorMessage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (dashboards.length === 0) {
|
||||
const copy = noResultsCopy(activeViewId, searchValue, hasFilters);
|
||||
return <NoResultsState title={copy.title} description={copy.description} />;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<ListHeader
|
||||
sortColumn={sortColumn}
|
||||
onSortChange={onSortChange}
|
||||
sortOrder={sortOrder}
|
||||
onOrderChange={onOrderChange}
|
||||
/>
|
||||
<DashboardsListContent
|
||||
dashboards={dashboards}
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
total={total}
|
||||
onPageChange={onPageChange}
|
||||
canAct={canAct}
|
||||
showUpdatedAt={showUpdatedAt}
|
||||
showUpdatedBy={showUpdatedBy}
|
||||
loading={loading}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardsResults;
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Plus } from '@signozhq/icons';
|
||||
|
||||
interface Props {
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
function NewDashboardButton({ onClick }: Props): JSX.Element {
|
||||
return (
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
prefix={<Plus size={14} />}
|
||||
onClick={onClick}
|
||||
testId="new-dashboard-cta"
|
||||
>
|
||||
New dashboard
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export default NewDashboardButton;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user