Compare commits

..

4 Commits

Author SHA1 Message Date
nikhilmantri0902
fe74d501ad chore(ruler): regenerate openapi spec for clone rule 2026-10-09 14:33:40 +05:30
nikhilmantri0902
49208e7b7e fix(ruler): name the source rule when clone validation fails 2026-10-09 14:33:39 +05:30
nikhilmantri0902
475196503b chore(ruler): trim comments in clone rule code 2026-10-09 14:25:32 +05:30
nikhilmantri0902
bd6a4075c8 chore: clone alert rule API 2026-10-09 13:59:19 +05:30
160 changed files with 1549 additions and 8373 deletions

View File

@@ -7756,8 +7756,6 @@ components:
type: object
PromotetypesPromotePath:
properties:
context:
type: string
indexes:
items:
$ref: '#/components/schemas/PromotetypesWrappedIndex'
@@ -7766,12 +7764,6 @@ components:
type: string
promote:
type: boolean
signal:
type: string
required:
- signal
- context
- path
type: object
PromotetypesWrappedIndex:
properties:
@@ -13440,6 +13432,110 @@ paths:
tags:
- llmpricingrules
x-signoz-stability: alpha
/api/v1/logs/promote_paths:
get:
deprecated: false
description: This endpoints promotes and indexes paths
operationId: ListPromotedAndIndexedPaths
responses:
"200":
content:
application/json:
schema:
properties:
data:
items:
$ref: '#/components/schemas/PromotetypesPromotePath'
nullable: true
type: array
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: Promote and index paths
tags:
- logs
x-signoz-stability: alpha
post:
deprecated: false
description: This endpoints promotes and indexes paths
operationId: HandlePromoteAndIndexPaths
requestBody:
content:
application/json:
schema:
items:
$ref: '#/components/schemas/PromotetypesPromotePath'
nullable: true
type: array
responses:
"201":
description: Created
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- EDITOR
- tokenizer:
- EDITOR
summary: Promote and index paths
tags:
- logs
x-signoz-stability: alpha
/api/v1/org/preferences:
get:
deprecated: false
@@ -13611,152 +13707,6 @@ paths:
tags:
- preferences
x-signoz-stability: alpha
/api/v1/promoted_paths:
get:
deprecated: false
description: This endpoint lists the promoted paths of every JSON column, each
annotated with its signal and context. The signal, context, promoted and indexes
query parameters filter the listing.
operationId: ListPromotedPaths
parameters:
- in: query
name: signal
schema:
type: string
- in: query
name: context
schema:
type: string
- in: query
name: promoted
schema:
nullable: true
type: boolean
- in: query
name: indexes
schema:
nullable: true
type: boolean
responses:
"200":
content:
application/json:
schema:
properties:
data:
items:
$ref: '#/components/schemas/PromotetypesPromotePath'
nullable: true
type: array
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: List promoted paths
tags:
- promote
x-signoz-stability: alpha
post:
deprecated: false
description: This endpoint promotes paths of JSON columns to their promoted
columns. Each path names its promotion domain with its signal and context,
e.g. traces/attribute.
operationId: PromotePaths
requestBody:
content:
application/json:
examples:
logs_body:
summary: 'Logs body: promote a path and index it'
value:
- context: body
indexes:
- fieldDataType: string
granularity: 1
type: ngrambf_v1(4, 1024, 2, 0)
path: body.user.name
promote: true
signal: logs
traces_attribute:
summary: 'Traces attribute: promote a path'
value:
- context: attribute
path: http.method
promote: true
signal: traces
schema:
items:
$ref: '#/components/schemas/PromotetypesPromotePath'
nullable: true
type: array
responses:
"201":
description: Created
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- EDITOR
- tokenizer:
- EDITOR
summary: Promote paths
tags:
- promote
x-signoz-stability: alpha
/api/v1/roles:
get:
deprecated: false
@@ -24606,6 +24556,77 @@ paths:
tags:
- rules
x-signoz-stability: alpha
/api/v2/rules/{id}/clone:
post:
deprecated: false
description: This endpoint creates a new alert rule from the stored definition
of an existing one. The clone keeps every modeled field of the source, including
its enabled/disabled state, and gets the source's name suffixed with " - Copy"
(or a bumped " - Copy (n)" counter when the source is itself a copy). The
clone is owned by the caller. Cloning fails with 400 when the stored source
no longer passes create validation, for example when a channel it references
has been deleted.
operationId: CloneRuleByID
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"201":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/RuletypesRule'
status:
type: string
required:
- status
- data
type: object
description: Created
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- EDITOR
- tokenizer:
- EDITOR
summary: Clone alert rule
tags:
- rules
x-signoz-stability: alpha
/api/v2/rules/{id}/history/filter_keys:
get:
deprecated: false

View File

@@ -4,15 +4,23 @@
* * regenerate with 'pnpm generate:api'
* SigNoz
*/
import { useMutation } from 'react-query';
import { useMutation, useQuery } from 'react-query';
import type {
InvalidateOptions,
MutationFunction,
QueryClient,
QueryFunction,
QueryKey,
UseMutationOptions,
UseMutationResult,
UseQueryOptions,
UseQueryResult,
} from 'react-query';
import type {
HandleExportRawDataPOSTParams,
ListPromotedAndIndexedPaths200,
PromotetypesPromotePathDTO,
Querybuildertypesv5QueryRangeRequestDTO,
RenderErrorResponseDTO,
} from '../sigNoz.schemas';
@@ -20,6 +28,26 @@ import type {
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
const withQueryKey = <T extends object, K>(
query: T,
queryKey: K,
): T & { queryKey: K } => {
const result = { queryKey } as T & { queryKey: K };
for (const key of Object.keys(query)) {
// The explicit queryKey always wins, matching the previous
// `{ ...query, queryKey }` spread where it was set last.
if (key === 'queryKey') {
continue;
}
Object.defineProperty(result, key, {
enumerable: true,
configurable: true,
get: () => (query as Record<string, unknown>)[key],
});
}
return result;
};
/**
* This endpoints allows complex query exporting raw data for traces and logs
* @summary Export raw data
@@ -121,3 +149,175 @@ export const useHandleExportRawDataPOST = <
> => {
return useMutation(getHandleExportRawDataPOSTMutationOptions(options));
};
/**
* This endpoints promotes and indexes paths
* @summary Promote and index paths
*/
export const listPromotedAndIndexedPaths = (signal?: AbortSignal) => {
return GeneratedAPIInstance<ListPromotedAndIndexedPaths200>({
url: `/api/v1/logs/promote_paths`,
method: 'GET',
signal,
});
};
export const getListPromotedAndIndexedPathsQueryKey = () => {
return [`/api/v1/logs/promote_paths`] as const;
};
export const getListPromotedAndIndexedPathsQueryOptions = <
TData = Awaited<ReturnType<typeof listPromotedAndIndexedPaths>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listPromotedAndIndexedPaths>>,
TError,
TData
>;
}) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getListPromotedAndIndexedPathsQueryKey();
const queryFn: QueryFunction<
Awaited<ReturnType<typeof listPromotedAndIndexedPaths>>
> = ({ signal }) => listPromotedAndIndexedPaths(signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof listPromotedAndIndexedPaths>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type ListPromotedAndIndexedPathsQueryResult = NonNullable<
Awaited<ReturnType<typeof listPromotedAndIndexedPaths>>
>;
export type ListPromotedAndIndexedPathsQueryError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Promote and index paths
*/
export function useListPromotedAndIndexedPaths<
TData = Awaited<ReturnType<typeof listPromotedAndIndexedPaths>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listPromotedAndIndexedPaths>>,
TError,
TData
>;
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getListPromotedAndIndexedPathsQueryOptions(options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return withQueryKey(query, queryOptions.queryKey);
}
/**
* @summary Promote and index paths
*/
export const invalidateListPromotedAndIndexedPaths = async (
queryClient: QueryClient,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getListPromotedAndIndexedPathsQueryKey() },
options,
);
return queryClient;
};
/**
* This endpoints promotes and indexes paths
* @summary Promote and index paths
*/
export const handlePromoteAndIndexPaths = (
promotetypesPromotePathDTONull?: BodyType<
PromotetypesPromotePathDTO[] | null
> | null,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v1/logs/promote_paths`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: promotetypesPromotePathDTONull,
signal,
});
};
export const getHandlePromoteAndIndexPathsMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof handlePromoteAndIndexPaths>>,
TError,
{ data?: BodyType<PromotetypesPromotePathDTO[] | null> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof handlePromoteAndIndexPaths>>,
TError,
{ data?: BodyType<PromotetypesPromotePathDTO[] | null> },
TContext
> => {
const mutationKey = ['handlePromoteAndIndexPaths'];
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 handlePromoteAndIndexPaths>>,
{ data?: BodyType<PromotetypesPromotePathDTO[] | null> }
> = (props) => {
const { data } = props ?? {};
return handlePromoteAndIndexPaths(data);
};
return { mutationFn, ...mutationOptions };
};
export type HandlePromoteAndIndexPathsMutationResult = NonNullable<
Awaited<ReturnType<typeof handlePromoteAndIndexPaths>>
>;
export type HandlePromoteAndIndexPathsMutationBody =
| BodyType<PromotetypesPromotePathDTO[] | null>
| undefined;
export type HandlePromoteAndIndexPathsMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Promote and index paths
*/
export const useHandlePromoteAndIndexPaths = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof handlePromoteAndIndexPaths>>,
TError,
{ data?: BodyType<PromotetypesPromotePathDTO[] | null> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof handlePromoteAndIndexPaths>>,
TError,
{ data?: BodyType<PromotetypesPromotePathDTO[] | null> },
TContext
> => {
return useMutation(getHandlePromoteAndIndexPathsMutationOptions(options));
};

View File

@@ -1,232 +0,0 @@
/**
* ! Do not edit manually
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
*/
import { useMutation, useQuery } from 'react-query';
import type {
InvalidateOptions,
MutationFunction,
QueryClient,
QueryFunction,
QueryKey,
UseMutationOptions,
UseMutationResult,
UseQueryOptions,
UseQueryResult,
} from 'react-query';
import type {
ListPromotedPaths200,
ListPromotedPathsParams,
PromotetypesPromotePathDTO,
RenderErrorResponseDTO,
} from '../sigNoz.schemas';
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
const withQueryKey = <T extends object, K>(
query: T,
queryKey: K,
): T & { queryKey: K } => {
const result = { queryKey } as T & { queryKey: K };
for (const key of Object.keys(query)) {
// The explicit queryKey always wins, matching the previous
// `{ ...query, queryKey }` spread where it was set last.
if (key === 'queryKey') {
continue;
}
Object.defineProperty(result, key, {
enumerable: true,
configurable: true,
get: () => (query as Record<string, unknown>)[key],
});
}
return result;
};
/**
* This endpoint lists the promoted paths of every JSON column, each annotated with its signal and context. The signal, context, promoted and indexes query parameters filter the listing.
* @summary List promoted paths
*/
export const listPromotedPaths = (
params?: ListPromotedPathsParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<ListPromotedPaths200>({
url: `/api/v1/promoted_paths`,
method: 'GET',
params,
signal,
});
};
export const getListPromotedPathsQueryKey = (
params?: ListPromotedPathsParams,
) => {
return [`/api/v1/promoted_paths`, ...(params ? [params] : [])] as const;
};
export const getListPromotedPathsQueryOptions = <
TData = Awaited<ReturnType<typeof listPromotedPaths>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
params?: ListPromotedPathsParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listPromotedPaths>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getListPromotedPathsQueryKey(params);
const queryFn: QueryFunction<
Awaited<ReturnType<typeof listPromotedPaths>>
> = ({ signal }) => listPromotedPaths(params, signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof listPromotedPaths>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type ListPromotedPathsQueryResult = NonNullable<
Awaited<ReturnType<typeof listPromotedPaths>>
>;
export type ListPromotedPathsQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary List promoted paths
*/
export function useListPromotedPaths<
TData = Awaited<ReturnType<typeof listPromotedPaths>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
params?: ListPromotedPathsParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listPromotedPaths>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getListPromotedPathsQueryOptions(params, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return withQueryKey(query, queryOptions.queryKey);
}
/**
* @summary List promoted paths
*/
export const invalidateListPromotedPaths = async (
queryClient: QueryClient,
params?: ListPromotedPathsParams,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getListPromotedPathsQueryKey(params) },
options,
);
return queryClient;
};
/**
* This endpoint promotes paths of JSON columns to their promoted columns. Each path names its promotion domain with its signal and context, e.g. traces/attribute.
* @summary Promote paths
*/
export const promotePaths = (
promotetypesPromotePathDTONull?: BodyType<
PromotetypesPromotePathDTO[] | null
> | null,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v1/promoted_paths`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: promotetypesPromotePathDTONull,
signal,
});
};
export const getPromotePathsMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof promotePaths>>,
TError,
{ data?: BodyType<PromotetypesPromotePathDTO[] | null> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof promotePaths>>,
TError,
{ data?: BodyType<PromotetypesPromotePathDTO[] | null> },
TContext
> => {
const mutationKey = ['promotePaths'];
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 promotePaths>>,
{ data?: BodyType<PromotetypesPromotePathDTO[] | null> }
> = (props) => {
const { data } = props ?? {};
return promotePaths(data);
};
return { mutationFn, ...mutationOptions };
};
export type PromotePathsMutationResult = NonNullable<
Awaited<ReturnType<typeof promotePaths>>
>;
export type PromotePathsMutationBody =
| BodyType<PromotetypesPromotePathDTO[] | null>
| undefined;
export type PromotePathsMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Promote paths
*/
export const usePromotePaths = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof promotePaths>>,
TError,
{ data?: BodyType<PromotetypesPromotePathDTO[] | null> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof promotePaths>>,
TError,
{ data?: BodyType<PromotetypesPromotePathDTO[] | null> },
TContext
> => {
return useMutation(getPromotePathsMutationOptions(options));
};

View File

@@ -18,6 +18,8 @@ import type {
} from 'react-query';
import type {
CloneRuleByID201,
CloneRuleByIDPathParameters,
CreateRule201,
CreateRuleView201,
DeleteRuleByIDPathParameters,
@@ -963,6 +965,85 @@ export const useUpdateRuleByID = <
> => {
return useMutation(getUpdateRuleByIDMutationOptions(options));
};
/**
* This endpoint creates a new alert rule from the stored definition of an existing one. The clone keeps every modeled field of the source, including its enabled/disabled state, and gets the source's name suffixed with " - Copy" (or a bumped " - Copy (n)" counter when the source is itself a copy). The clone is owned by the caller. Cloning fails with 400 when the stored source no longer passes create validation, for example when a channel it references has been deleted.
* @summary Clone alert rule
*/
export const cloneRuleByID = (
{ id }: CloneRuleByIDPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<CloneRuleByID201>({
url: `/api/v2/rules/${id}/clone`,
method: 'POST',
signal,
});
};
export const getCloneRuleByIDMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof cloneRuleByID>>,
TError,
{ pathParams: CloneRuleByIDPathParameters },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof cloneRuleByID>>,
TError,
{ pathParams: CloneRuleByIDPathParameters },
TContext
> => {
const mutationKey = ['cloneRuleByID'];
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 cloneRuleByID>>,
{ pathParams: CloneRuleByIDPathParameters }
> = (props) => {
const { pathParams } = props ?? {};
return cloneRuleByID(pathParams);
};
return { mutationFn, ...mutationOptions };
};
export type CloneRuleByIDMutationResult = NonNullable<
Awaited<ReturnType<typeof cloneRuleByID>>
>;
export type CloneRuleByIDMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Clone alert rule
*/
export const useCloneRuleByID = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof cloneRuleByID>>,
TError,
{ pathParams: CloneRuleByIDPathParameters },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof cloneRuleByID>>,
TError,
{ pathParams: CloneRuleByIDPathParameters },
TContext
> => {
return useMutation(getCloneRuleByIDMutationOptions(options));
};
/**
* Returns distinct label keys from rule history entries for the selected range.
* @summary Get rule history filter keys

View File

@@ -9519,10 +9519,6 @@ export interface PromotetypesWrappedIndexDTO {
}
export interface PromotetypesPromotePathDTO {
/**
* @type string
*/
context: string;
/**
* @type array
*/
@@ -9530,15 +9526,11 @@ export interface PromotetypesPromotePathDTO {
/**
* @type string
*/
path: string;
path?: string;
/**
* @type boolean
*/
promote?: boolean;
/**
* @type string
*/
signal: string;
}
export interface Querybuildertypesv5AggregationMetaDTO {
@@ -12842,6 +12834,17 @@ export type ListUnmappedLLMModels200 = {
status: string;
};
export type ListPromotedAndIndexedPaths200 = {
/**
* @type array,null
*/
data: PromotetypesPromotePathDTO[] | null;
/**
* @type string
*/
status: string;
};
export type ListOrgPreferences200 = {
/**
* @type array
@@ -12867,40 +12870,6 @@ export type GetOrgPreference200 = {
export type UpdateOrgPreferencePathParameters = {
name: string;
};
export type ListPromotedPathsParams = {
/**
* @type string
* @description undefined
*/
signal?: string;
/**
* @type string
* @description undefined
*/
context?: string;
/**
* @type boolean,null
* @description undefined
*/
promoted?: boolean | null;
/**
* @type boolean,null
* @description undefined
*/
indexes?: boolean | null;
};
export type ListPromotedPaths200 = {
/**
* @type array,null
*/
data: PromotetypesPromotePathDTO[] | null;
/**
* @type string
*/
status: string;
};
export type ListRoles200 = {
/**
* @type array
@@ -14233,6 +14202,17 @@ export type PatchRuleByID200 = {
export type UpdateRuleByIDPathParameters = {
id: string;
};
export type CloneRuleByIDPathParameters = {
id: string;
};
export type CloneRuleByID201 = {
data: RuletypesRuleDTO;
/**
* @type string
*/
status: string;
};
export type GetRuleHistoryFilterKeysPathParameters = {
id: string;
};

View File

@@ -36,7 +36,6 @@ import {
filterOptionsBySearch,
findOptionLabelText,
handleScrollToBottom,
findOptionLabel,
prioritizeOrAddOptionForMultiSelect,
SPACEKEY,
} from './utils';
@@ -74,7 +73,6 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
onDropdownVisibleChange,
showIncompleteDataMessage = false,
showLabels = false,
preserveOptionOrder = false,
enableRegexOption = false,
isDynamicVariable = false,
showRetryButton = true,
@@ -313,7 +311,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
]);
} else {
setVisibleOptions(
selectedValues.length > 0 && isEmpty(searchText) && !preserveOptionOrder
selectedValues.length > 0 && isEmpty(searchText)
? prioritizeOrAddOptionForMultiSelect(filteredOptions, selectedValues)
: filteredOptions,
);
@@ -1477,7 +1475,6 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
const shouldPrioritize =
selectedValues.length > 0 &&
isEmpty(searchText) &&
!preserveOptionOrder &&
!(hasSections && (allOptionShown || isAllSelected));
const processedOptions = shouldPrioritize
@@ -1808,7 +1805,6 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
isDynamicVariable,
showRetryButton,
waitingMessage,
preserveOptionOrder,
]);
// Custom handler for dropdown visibility changes
@@ -1896,7 +1892,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
const { label: labelProp, value, closable, onClose } = props;
const label = showLabels
? findOptionLabel(options, value) || labelProp
? options.find((option) => option.value === value)?.label || labelProp
: labelProp;
// If the display value is the special ALL value, render the ALL tag

View File

@@ -1,70 +0,0 @@
import { findOptionLabel } from '../utils';
describe('findOptionLabel', () => {
const sectioned = [
{
label: 'Lifecycle',
options: [
{ label: 'Running', value: 'running' },
{ label: 'Pending', value: 'pending' },
],
},
{
label: 'Error status',
options: [{ label: 'CrashLoopBackOff', value: 'crashloopbackoff' }],
},
];
it('resolves a label nested inside a section', () => {
expect(findOptionLabel(sectioned, 'crashloopbackoff')).toBe(
'CrashLoopBackOff',
);
});
it('resolves a label on a flat option list', () => {
expect(findOptionLabel([{ label: 'Ready', value: 'ready' }], 'ready')).toBe(
'Ready',
);
});
it('returns undefined for a value no option declares', () => {
expect(findOptionLabel(sectioned, 'nope')).toBeUndefined();
});
});
describe('findOptionLabel for overflow tags', () => {
// The `+N` tooltip and the visible tag both resolve labels this way, so a
// sectioned list must not fall back to the raw enum value in either.
it('resolves every value in a sectioned list', () => {
const sections = [
{
label: 'Lifecycle',
options: [
{ label: 'Running', value: 'running' },
{ label: 'ContainerCreating', value: 'containercreating' },
],
},
{
label: 'Error status',
options: [
{ label: 'OOMKilled', value: 'oomkilled' },
{ label: 'NodeAffinity', value: 'nodeaffinity' },
],
},
];
const resolved = [
'running',
'containercreating',
'oomkilled',
'nodeaffinity',
].map((value) => findOptionLabel(sections, value) ?? value);
expect(resolved).toStrictEqual([
'Running',
'ContainerCreating',
'OOMKilled',
'NodeAffinity',
]);
});
});

View File

@@ -66,7 +66,6 @@ export interface CustomMultiSelectProps extends Omit<
maxTagTextLength?: number;
showIncompleteDataMessage?: boolean;
showLabels?: boolean;
preserveOptionOrder?: boolean;
enableRegexOption?: boolean;
isDynamicVariable?: boolean;
showRetryButton?: boolean;

View File

@@ -69,28 +69,6 @@ export const prioritizeOrAddOptionForSingleSelect = (
return [foundOption, ...filteredOptions];
};
/**
* Options may be grouped into sections, so resolving a value's label has to look
* one level down as well — a sectioned list has no label at the top level.
*/
export const findOptionLabel = (
options: OptionData[],
value: string,
): string | undefined => {
for (const option of options) {
if (option.value === value) {
return option.label;
}
const nested = option.options?.find((subOption) => subOption.value === value);
if (nested) {
return nested.label;
}
}
return undefined;
};
export const prioritizeOrAddOptionForMultiSelect = (
options: OptionData[],
values: string[], // Only supports multiple values (string[])

View File

@@ -1,7 +0,0 @@
.overflowValues {
max-width: 360px;
margin: 0;
padding-left: 14px;
list-style: disc outside;
overflow-wrap: anywhere;
}

View File

@@ -35,7 +35,7 @@
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing-2) 10.5px;
padding: 10.5px;
border-bottom: 1px solid var(--l1-border);
.left-actions {
@@ -46,9 +46,6 @@
justify-content: flex-start;
.text {
padding-left: var(--spacing-4);
white-space: nowrap;
flex-shrink: 0;
color: var(--l1-foreground);
font-family: Inter;
font-size: 14px;
@@ -81,8 +78,6 @@
}
.select-box {
--combobox-trigger-width: auto;
display: flex;
padding: 2px 5px;
flex-direction: row;
@@ -100,9 +95,10 @@
.right-actions {
display: flex;
align-items: center;
gap: var(--spacing-1);
flex-shrink: 0;
gap: 10px;
width: 100%;
justify-content: flex-end;
--button-variant-link-color: var(--l1-foreground);
.divider-filter {
width: 1px;
@@ -110,8 +106,26 @@
background: var(--l1-background);
}
.active {
background: var(--l2-background);
.sync-icon {
cursor: pointer;
}
.right-action-icon-container {
position: relative;
display: flex;
padding: 2px;
background-color: var(--l1-background);
.settings-icon {
height: 14px;
width: 14px;
cursor: pointer;
}
&.active,
&:hover {
background: var(--l2-background);
}
}
}
}

View File

@@ -1,6 +1,7 @@
import { useMemo, useState } from 'react';
import {
ArrowUpToLine,
Filter,
Frown,
RefreshCw,
Settings2 as SettingsIcon,
@@ -13,9 +14,8 @@ import {
ComboboxList,
ComboboxTrigger,
} from '@signozhq/ui/combobox';
import { Skeleton } from 'antd';
import { Skeleton, Tooltip } from 'antd';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Switch } from '@signozhq/ui/switch';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
@@ -180,6 +180,7 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
// Helpers to reduce cognitive complexity in main render
const renderLeftActions = (): JSX.Element => (
<section className="left-actions">
<Filter size="md" />
<Typography.Text className="text">
{displayedQueryName ? 'Filters for' : 'Filters'}
</Typography.Text>
@@ -216,13 +217,13 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
</Combobox>
) : (
displayedQueryName && (
<TooltipSimple
<Tooltip
title={`Filter currently in sync with query ${displayedQueryName}`}
>
<Typography.Text className="sync-tag">
{displayedQueryName}
</Typography.Text>
</TooltipSimple>
</Tooltip>
)
)}
</section>
@@ -230,54 +231,51 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
const renderRightActions = (): JSX.Element => (
<section className="right-actions">
<TooltipSimple title="Reset All">
<Tooltip title="Reset All">
<Button
variant="ghost"
variant="link"
color="secondary"
size="icon"
aria-label="Reset All"
className="right-action-icon-container"
onClick={handleReset}
prefix={<RefreshCw size={14} />}
prefix={<RefreshCw className="sync-icon" size="md" />}
/>
</TooltipSimple>
</Tooltip>
{showFilterCollapse && (
<TooltipSimple title="Collapse Filters">
<Tooltip title="Collapse Filters">
<Button
variant="ghost"
variant="link"
color="secondary"
size="icon"
aria-label="Collapse Filters"
className="right-action-icon-container"
onClick={handleFilterVisibilityChange}
prefix={<ArrowUpToLine style={{ rotate: '270deg' }} size={14} />}
prefix={<ArrowUpToLine style={{ rotate: '270deg' }} size="md" />}
/>
</TooltipSimple>
</Tooltip>
)}
{isDynamicFilters && (
<AuthZButton
checks={QuickFilterManagePermissions}
variant="link"
color="secondary"
aria-label="Settings"
className={classNames('right-action-icon-container', {
active: isSettingsOpen,
})}
onClick={(): void => setIsSettingsOpen(true)}
testId="settings-icon-container"
prefix={
<Tooltip title="Settings" open={isSettingsDisabled ? false : undefined}>
<SettingsIcon
className="settings-icon"
data-testid="settings-icon"
width={14}
height={14}
/>
</Tooltip>
}
/>
)}
{isDynamicFilters &&
(isSettingsDisabled ? (
<AuthZButton
checks={QuickFilterManagePermissions}
variant="ghost"
color="secondary"
size="icon"
aria-label="Settings"
onClick={(): void => setIsSettingsOpen(true)}
testId="settings-icon-container"
prefix={<SettingsIcon data-testid="settings-icon" size={14} />}
/>
) : (
<TooltipSimple title="Settings">
<Button
variant="ghost"
color="secondary"
size="icon"
aria-label="Settings"
className={classNames({ active: isSettingsOpen })}
onClick={(): void => setIsSettingsOpen(true)}
data-testid="settings-icon-container"
prefix={<SettingsIcon data-testid="settings-icon" size={14} />}
/>
</TooltipSimple>
))}
</section>
);

View File

@@ -3,7 +3,6 @@ import cx from 'classnames';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import SavedViewsHeader from 'container/SavedViews/SavedViewsHeader';
import SavedViewsPanel from 'container/SavedViews/SavedViewsPanel';
import SavedViewsRestore from 'container/SavedViews/SavedViewsRestore';
import { useSavedViewEnabled } from 'hooks/useSavedViewEnabled';
import QuickFilters from '../QuickFilters';
@@ -41,7 +40,7 @@ function QuickFiltersLayout({
testId,
children,
}: QuickFiltersLayoutProps): JSX.Element {
const [isViewsListOpen, setIsViewsListOpen] = useState(false);
const [isViewsOpen, setIsViewsOpen] = useState(false);
const isSavedViewEnabled = useSavedViewEnabled();
const hasQuickFilters = !!quickFilterProps;
@@ -49,23 +48,19 @@ function QuickFiltersLayout({
const showSidebar = showFilters && (hasQuickFilters || hasSavedViews);
const isViewsListPinned = hasSavedViews && !hasQuickFilters;
const isViewsListVisible = isViewsListPinned || isViewsListOpen;
const isPanelPinned = hasSavedViews && !hasQuickFilters;
const isSliding = hasQuickFilters && isViewsListOpen;
const isSliding = hasQuickFilters && isViewsOpen;
const savedViewsHeader = hasSavedViews ? (
<SavedViewsHeader
{...savedViewProps}
onOpenViews={
isViewsListVisible ? undefined : (): void => setIsViewsListOpen(true)
}
onOpenViews={isPanelPinned ? undefined : (): void => setIsViewsOpen(true)}
/>
) : undefined;
return (
<div className={cx(styles.layout, className)} data-testid={testId}>
{hasSavedViews && <SavedViewsRestore source={savedViewProps.source} />}
{showSidebar && (
<aside
className={cx(styles.sidebar, { [styles.isStatic]: !hasQuickFilters })}
@@ -85,12 +80,10 @@ function QuickFiltersLayout({
)}
</div>
<div className={styles.savedViews}>
{hasSavedViews && isViewsListVisible && (
{hasSavedViews && (isPanelPinned || isViewsOpen) && (
<SavedViewsPanel
{...savedViewProps}
onClose={
isViewsListPinned ? undefined : (): void => setIsViewsListOpen(false)
}
onClose={isPanelPinned ? undefined : (): void => setIsViewsOpen(false)}
/>
)}
</div>

View File

@@ -23,13 +23,6 @@ jest.mock('hooks/useSavedViewEnabled', () => ({
useSavedViewEnabled: jest.fn(() => true),
}));
jest.mock('container/SavedViews/SavedViewsRestore', () => ({
__esModule: true,
default: ({ source }: { source: string }): JSX.Element => (
<div data-testid="saved-views-restore" data-source={source} />
),
}));
jest.mock('../../QuickFilters', () => ({
__esModule: true,
default: ({
@@ -94,10 +87,6 @@ describe('QuickFiltersLayout', () => {
).not.toBeInTheDocument();
expect(screen.queryByTestId('quick-filters')).not.toBeInTheDocument();
expect(screen.getByText('content')).toBeInTheDocument();
expect(screen.getByTestId('saved-views-restore')).toHaveAttribute(
'data-source',
SavedviewtypesSourceDTO.traces,
);
});
it('renders no sidebar at all without quick filters or saved views', () => {
@@ -160,7 +149,6 @@ describe('QuickFiltersLayout', () => {
expect(
screen.queryByTestId('quick-filters-layout-filters'),
).not.toBeInTheDocument();
expect(screen.queryByTestId('saved-views-restore')).not.toBeInTheDocument();
});
it('opens the panel from the header and slides the quick filters drawer, keeping the same QuickFilters node', async () => {
@@ -188,14 +176,12 @@ describe('QuickFiltersLayout', () => {
expect(screen.getByTestId('saved-views-panel')).toBeInTheDocument();
expect(drawer()).toHaveClass('isOpen');
expect(screen.getByTestId('quick-filters')).toBe(quickFilters);
expect(screen.queryByTestId('saved-views-open')).not.toBeInTheDocument();
await user.click(screen.getByTestId('saved-views-close'));
expect(screen.queryByTestId('saved-views-panel')).not.toBeInTheDocument();
expect(drawer()).not.toHaveClass('isOpen');
expect(screen.getByTestId('quick-filters')).toBe(quickFilters);
expect(screen.getByTestId('saved-views-open')).toBeInTheDocument();
});
it('hands the source to the header and the panel', async () => {

View File

@@ -5,10 +5,12 @@ import {
import { SavedviewtypesSavedViewDTO } from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { SavedViewSourcePage } from 'container/SavedViews/types';
import { findSavedView } from 'container/SavedViews/utils/findSavedView';
import { getSavedViewQuery } from 'container/SavedViews/utils/getSavedViewQuery';
import { toSavedViewSource } from 'container/SavedViews/utils/toSavedViewSource';
import {
findSavedView,
getSavedViewQuery,
SavedViewSourcePage,
toSavedViewSource,
} from 'container/SavedViews/utils';
import { SOURCEPAGE_VS_ROUTES } from 'pages/SaveView/constants';
import { DataSource } from 'types/common/queryBuilder';
import { Query } from 'types/api/queryBuilder/queryBuilderData';

View File

@@ -8,7 +8,7 @@ import {
SavedviewtypesSourceDTO,
} from 'api/generated/services/sigNoz.schemas';
import ROUTES from 'constants/routes';
import { getSavedViewQuery } from 'container/SavedViews/utils/getSavedViewQuery';
import { getSavedViewQuery } from 'container/SavedViews/utils';
import { useHandleExplorerTabChange } from 'hooks/useHandleExplorerTabChange';
import { SOURCEPAGE_VS_ROUTES } from 'pages/SaveView/constants';
import Card from 'periscope/components/Card/Card';

View File

@@ -28,9 +28,6 @@ import {
useInfraMonitoringOrderBy,
useInfraMonitoringSelectedItemParams,
useInfraMonitoringStatusFilter,
useInfraMonitoringPodStatusFilter,
useInfraMonitoringNodeReadinessFilter,
useInfraMonitoringContainerStatusFilter,
} from '../hooks';
import {
useInfraMonitoringFontSize,
@@ -100,10 +97,6 @@ export type K8sBaseListProps<
detailsQueryKeyPrefix: string;
};
function emptyToUndefined<T>(values: T[]): T[] | undefined {
return values.length > 0 ? values : undefined;
}
export function K8sBaseList<
T extends K8sEntityData,
TItemKey extends string | SelectedItemParams = string,
@@ -127,9 +120,6 @@ export function K8sBaseList<
const [groupBy] = useInfraMonitoringGroupBy();
const [orderBy] = useInfraMonitoringOrderBy();
const [statusFilter] = useInfraMonitoringStatusFilter();
const [podStatusFilter] = useInfraMonitoringPodStatusFilter();
const [nodeReadinessFilter] = useInfraMonitoringNodeReadinessFilter();
const [containerStatusFilter] = useInfraMonitoringContainerStatusFilter();
const [selectedItemParams, setSelectedItemParams] =
useInfraMonitoringSelectedItemParams();
const selectedItem = selectedItemParams.selectedItem;
@@ -181,9 +171,6 @@ export function K8sBaseList<
JSON.stringify(orderBy),
JSON.stringify(groupBy),
statusFilter,
podStatusFilter.join(),
nodeReadinessFilter.join(),
containerStatusFilter.join(),
...extraQueryKeyParts,
);
}, [
@@ -196,9 +183,6 @@ export function K8sBaseList<
orderBy,
groupBy,
statusFilter,
podStatusFilter,
nodeReadinessFilter,
containerStatusFilter,
extraQueryKeyParts,
]);
@@ -219,9 +203,6 @@ export function K8sBaseList<
statusFilter === 'active' || statusFilter === 'inactive'
? statusFilter
: undefined,
filterByPodStatus: emptyToUndefined(podStatusFilter),
filterByNodeReadiness: emptyToUndefined(nodeReadinessFilter),
filterByContainerStatus: emptyToUndefined(containerStatusFilter),
},
groupBy:
groupBy && groupBy.length > 0

View File

@@ -285,9 +285,6 @@ describe('K8sBaseList', () => {
expect(filters.filter).toStrictEqual({
expression: '',
filterByStatus: undefined,
filterByPodStatus: undefined,
filterByNodeReadiness: undefined,
filterByContainerStatus: undefined,
});
expect(filters.groupBy).toBeUndefined();
expect(filters.orderBy).toBeUndefined();
@@ -1671,61 +1668,3 @@ describe('K8sBaseList', () => {
});
});
});
describe('K8sBaseList status filters', () => {
const fetchListDataMock = jest.fn<
ReturnType<NonNullable<K8sBaseListProps<TestItem>['fetchListData']>>,
Parameters<NonNullable<K8sBaseListProps<TestItem>['fetchListData']>>
>();
function renderWithParams(queryParams: Record<string, string>): void {
fetchListDataMock.mockClear();
fetchListDataMock.mockResolvedValue({ data: [], total: 0, error: null });
renderComponent<TestItem>({
queryParams,
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
tableColumns: createTestColumns(),
getRowKey: (row): string => row.id,
getItemKey: (row): string => row.id,
});
}
it('sends the statuses named in the URL', async () => {
renderWithParams({ podStatus: 'running,crashloopbackoff' });
await waitFor(() => {
expect(fetchListDataMock).toHaveBeenCalled();
});
const [filters] = fetchListDataMock.mock.calls[0];
expect(filters.filter.filterByPodStatus).toStrictEqual([
'running',
'crashloopbackoff',
]);
});
it('drops unknown statuses the API would reject', async () => {
renderWithParams({ podStatus: 'running,no_data,bogus' });
await waitFor(() => {
expect(fetchListDataMock).toHaveBeenCalled();
});
const [filters] = fetchListDataMock.mock.calls[0];
expect(filters.filter.filterByPodStatus).toStrictEqual(['running']);
});
it('omits the filter entirely when no status is selected', async () => {
renderWithParams({});
await waitFor(() => {
expect(fetchListDataMock).toHaveBeenCalled();
});
const [filters] = fetchListDataMock.mock.calls[0];
expect(filters.filter.filterByPodStatus).toBeUndefined();
});
});

View File

@@ -1,21 +0,0 @@
.statusFilterGroup {
display: flex;
align-items: center;
gap: 12px;
}
.statusFilter {
display: flex;
align-items: center;
gap: 8px;
}
.statusFilterLabel {
color: var(--muted-foreground);
font-size: var(--periscope-font-size-base);
white-space: nowrap;
}
.statusFilterControl {
min-width: 180px;
}

View File

@@ -1,121 +0,0 @@
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import {
FILTERABLE_CONTAINER_STATUSES,
FILTERABLE_NODE_CONDITIONS,
FILTERABLE_POD_STATUSES,
useInfraMonitoringCategory,
useInfraMonitoringContainerStatusFilter,
useInfraMonitoringNodeReadinessFilter,
useInfraMonitoringPodStatusFilter,
} from 'container/InfraMonitoringK8sV2/hooks';
import StatusMultiSelect from './StatusMultiSelect';
import { StatusFilterKind } from '../StatusFilterCells/useStatusFilter';
import {
CONTAINER_STATUS_FILTER_OPTIONS,
NODE_READINESS_FILTER_OPTIONS,
POD_STATUS_FILTER_OPTIONS,
} from './statusFilterOptions';
import styles from './EntityStatusFilter.module.scss';
interface StatusFilterSpec {
kind: StatusFilterKind;
/** Matches the entity's own status column header, so filter and column agree. */
label: string;
}
const POD_STATUS_SPEC: StatusFilterSpec = { kind: 'pod', label: 'Pod Status' };
const NODE_READINESS_SPEC: StatusFilterSpec = {
kind: 'node',
label: 'Node Readiness',
};
/**
* Which secondary status filters each list accepts — mirrors the filter structs in
* pkg/types/inframonitoringtypes. Volumes take none, so they are absent here.
*/
const STATUS_FILTERS_BY_ENTITY: Partial<
Record<InfraMonitoringEntity, StatusFilterSpec[]>
> = {
[InfraMonitoringEntity.PODS]: [{ kind: 'pod', label: 'Status' }],
[InfraMonitoringEntity.CONTAINERS]: [{ kind: 'container', label: 'Status' }],
[InfraMonitoringEntity.NODES]: [NODE_READINESS_SPEC, POD_STATUS_SPEC],
[InfraMonitoringEntity.CLUSTERS]: [NODE_READINESS_SPEC, POD_STATUS_SPEC],
[InfraMonitoringEntity.NAMESPACES]: [POD_STATUS_SPEC],
[InfraMonitoringEntity.DEPLOYMENTS]: [POD_STATUS_SPEC],
[InfraMonitoringEntity.DAEMONSETS]: [POD_STATUS_SPEC],
[InfraMonitoringEntity.STATEFULSETS]: [POD_STATUS_SPEC],
[InfraMonitoringEntity.JOBS]: [POD_STATUS_SPEC],
};
const FILTER_KEY_BY_KIND: Record<StatusFilterKind, string> = {
pod: 'pod_status',
node: 'node_readiness',
container: 'container_status',
};
const OPTIONS_BY_KIND = {
pod: POD_STATUS_FILTER_OPTIONS,
node: NODE_READINESS_FILTER_OPTIONS,
container: CONTAINER_STATUS_FILTER_OPTIONS,
};
const ALL_VALUES_BY_KIND = {
pod: FILTERABLE_POD_STATUSES,
node: FILTERABLE_NODE_CONDITIONS,
container: FILTERABLE_CONTAINER_STATUSES,
};
function EntityStatusFilter(): JSX.Element | null {
const [category] = useInfraMonitoringCategory();
const [podStatus, setPodStatus] = useInfraMonitoringPodStatusFilter();
const [nodeReadiness, setNodeReadiness] =
useInfraMonitoringNodeReadinessFilter();
const [containerStatus, setContainerStatus] =
useInfraMonitoringContainerStatusFilter();
const specs = STATUS_FILTERS_BY_ENTITY[category as InfraMonitoringEntity];
if (!specs) {
return null;
}
const selectedByKind: Record<StatusFilterKind, string[]> = {
pod: podStatus,
node: nodeReadiness,
container: containerStatus,
};
const setterByKind: Record<StatusFilterKind, (next: string[]) => void> = {
pod: (next): void =>
void setPodStatus(next.length > 0 ? (next as typeof podStatus) : null),
node: (next): void =>
void setNodeReadiness(
next.length > 0 ? (next as typeof nodeReadiness) : null,
),
container: (next): void =>
void setContainerStatus(
next.length > 0 ? (next as typeof containerStatus) : null,
),
};
return (
<div className={styles.statusFilterGroup}>
{specs.map((spec) => (
<StatusMultiSelect
key={spec.kind}
label={spec.label}
entity={category as InfraMonitoringEntity}
filterKey={FILTER_KEY_BY_KIND[spec.kind]}
options={OPTIONS_BY_KIND[spec.kind]}
allValues={ALL_VALUES_BY_KIND[spec.kind]}
selected={selectedByKind[spec.kind]}
onChange={setterByKind[spec.kind]}
testId={`${spec.kind}-status-filter`}
/>
))}
</div>
);
}
export default EntityStatusFilter;

View File

@@ -1,102 +0,0 @@
import { useMemo } from 'react';
import { CustomMultiSelect } from 'components/NewSelect';
import OverflowValuesTooltip from 'components/OverflowValuesTooltip/OverflowValuesTooltip';
import { OptionData } from 'components/NewSelect/types';
import { findOptionLabel } from 'components/NewSelect/utils';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import { useInfraMonitoringPageListing } from 'container/InfraMonitoringK8sV2/hooks';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { logInfraFilterCustomizedEvent } from '../../events';
import styles from './EntityStatusFilter.module.scss';
interface StatusMultiSelectProps<T extends string> {
label: string;
entity: InfraMonitoringEntity;
/** Key reported to analytics for this control, e.g. `pod_status`. */
filterKey: string;
options: OptionData[];
allValues: T[];
selected: T[];
onChange: (next: T[]) => void;
testId: string;
}
function StatusMultiSelect<T extends string>({
label,
entity,
filterKey,
options,
allValues,
selected,
onChange,
testId,
}: StatusMultiSelectProps<T>): JSX.Element {
const { currentQuery } = useQueryBuilder();
// No selection means no filter, which is every status — so show ALL as checked
// rather than an empty control that reads as "nothing matches".
const value = useMemo(
() => (selected.length > 0 ? selected : allValues),
[selected, allValues],
);
const handleChange = (next: string[] | string | undefined): void => {
const values = (Array.isArray(next) ? next : [next])
.filter((item): item is string => Boolean(item))
.filter((item): item is T => allValues.includes(item as T));
// Selecting everything is the same request as selecting nothing; store the
// empty form so the param drops out of the URL.
const isAll = values.length === allValues.length;
onChange(isAll ? [] : values);
logInfraFilterCustomizedEvent(
entity,
'status_filter',
currentQuery.builder.queryData[0]?.filter?.expression || '',
isAll ? [] : [filterKey],
);
};
const [, setCurrentPage] = useInfraMonitoringPageListing();
return (
<div className={styles.statusFilter}>
<span className={styles.statusFilterLabel}>{label}</span>
<CustomMultiSelect
className={styles.statusFilterControl}
data-testid={testId}
options={options}
value={value}
showSearch
showLabels
preserveOptionOrder
maxTagCount={1}
maxTagTextLength={12}
maxTagPlaceholder={(omitted): JSX.Element => (
<OverflowValuesTooltip
values={omitted.map((item) => {
// The omitted items carry the raw value as their label, since
// sections put the real one a level down.
const value = String(item.value ?? '');
return findOptionLabel(options, value) ?? value;
})}
/>
)}
placeholder={label}
onChange={(next): void => {
handleChange(next);
void setCurrentPage(1);
}}
onClear={(): void => {
onChange([]);
void setCurrentPage(1);
}}
/>
</div>
);
}
export default StatusMultiSelect;

View File

@@ -1,64 +0,0 @@
import { BadgeColor } from '@signozhq/ui/badge';
import {
InframonitoringtypesContainerStatusDTO,
InframonitoringtypesNodeConditionDTO,
InframonitoringtypesPodStatusDTO,
} from 'api/generated/services/sigNoz.schemas';
import { OptionData } from 'components/NewSelect/types';
import {
FILTERABLE_CONTAINER_STATUSES,
FILTERABLE_NODE_CONDITIONS,
FILTERABLE_POD_STATUSES,
} from '../../../hooks';
import { POD_STATUS_COLORS, POD_STATUS_LABELS } from '../../../commonUtils';
import {
CONTAINER_STATUS_COLORS,
CONTAINER_STATUS_LABELS,
} from '../../../Containers/utils';
import { NODE_CONDITION_LABELS } from '../../../Nodes/utils';
const ERROR_BADGE_COLOR: BadgeColor = 'cherry';
/**
* Splits statuses into the two sections the dropdown renders. The badge colour is
* already the severity signal in the table, so reusing it keeps the sections and
* the status column from drifting apart.
*/
function toSectionedOptions<T extends string>(
values: T[],
labels: Record<T, string>,
colors: Record<T, BadgeColor>,
): OptionData[] {
const toOption = (value: T): OptionData => ({
label: labels[value],
value,
});
const lifecycle = values.filter(
(value) => colors[value] !== ERROR_BADGE_COLOR,
);
const errors = values.filter((value) => colors[value] === ERROR_BADGE_COLOR);
return [
{ label: 'Lifecycle', options: lifecycle.map(toOption) },
{ label: 'Error status', options: errors.map(toOption) },
].filter((section) => section.options.length > 0);
}
export const POD_STATUS_FILTER_OPTIONS = toSectionedOptions(
FILTERABLE_POD_STATUSES as InframonitoringtypesPodStatusDTO[],
POD_STATUS_LABELS,
POD_STATUS_COLORS,
);
export const CONTAINER_STATUS_FILTER_OPTIONS = toSectionedOptions(
FILTERABLE_CONTAINER_STATUSES as InframonitoringtypesContainerStatusDTO[],
CONTAINER_STATUS_LABELS,
CONTAINER_STATUS_COLORS,
);
/** Only two values, so sections would add a heading per row. */
export const NODE_READINESS_FILTER_OPTIONS: OptionData[] = (
FILTERABLE_NODE_CONDITIONS as InframonitoringtypesNodeConditionDTO[]
).map((value) => ({ label: NODE_CONDITION_LABELS[value], value }));

View File

@@ -1,11 +0,0 @@
.statusBadgeButton {
display: inline-flex;
padding: 0;
background: none;
border: none;
cursor: pointer;
font: inherit;
// The badge sets its own cursor, so the button's never reaches it.
--badge-cursor: pointer;
}

View File

@@ -1,57 +0,0 @@
import { Badge, BadgeColor } from '@signozhq/ui/badge';
import TanStackTable from 'components/TanStackTableView';
import { StatusFilterKind, useStatusFilter } from './useStatusFilter';
import styles from './ClickableStatusBadge.module.scss';
interface ClickableStatusBadgeProps {
color: BadgeColor;
label: string;
/** The status this badge stands for, applied as the filter when clicked. */
status: string;
kind: StatusFilterKind;
rowId: string;
}
function ClickableStatusBadge({
color,
label,
status,
kind,
rowId,
}: ClickableStatusBadgeProps): JSX.Element {
const { selected, setStatusFilter } = useStatusFilter(kind);
// Clicking the status the list is already narrowed to has nothing left to
// narrow, so it undoes the filter instead — but only when this status is the
// whole filter, else it would silently drop the other selected statuses.
const isWholeFilter = selected.length === 1 && selected[0] === status;
return (
<TanStackTable.HoverTooltip
rowId={rowId}
title={isWholeFilter ? `Clear ${label} filter` : `Filter by ${label}`}
arrow
align="start"
delayDuration={300}
>
<button
type="button"
className={styles.statusBadgeButton}
data-testid={`status-badge-${status}`}
onClick={(e): void => {
e.preventDefault();
e.stopPropagation();
setStatusFilter(isWholeFilter ? [] : [status]);
}}
>
<Badge color={color} variant="outline">
{label}
</Badge>
</button>
</TanStackTable.HoverTooltip>
);
}
export default ClickableStatusBadge;

View File

@@ -1,33 +0,0 @@
import {
GroupedStatusCounts,
StatusCountItem,
} from 'container/InfraMonitoringK8sV2/components';
import { StatusFilterKind, useStatusFilter } from './useStatusFilter';
interface ClickableStatusCountsProps {
items: StatusCountItem[];
rowId: string;
kind: StatusFilterKind;
showZeroValues?: boolean;
}
function ClickableStatusCounts({
items,
rowId,
kind,
showZeroValues,
}: ClickableStatusCountsProps): JSX.Element {
const { setStatusFilter } = useStatusFilter(kind);
return (
<GroupedStatusCounts
items={items}
rowId={rowId}
showZeroValues={showZeroValues}
onSelectStatuses={setStatusFilter}
/>
);
}
export default ClickableStatusCounts;

View File

@@ -1,67 +0,0 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { NuqsTestingAdapter, UrlUpdateEvent } from 'nuqs/adapters/testing';
import ClickableStatusBadge from '../ClickableStatusBadge';
function renderBadge(
searchParams: Record<string, string>,
onUrlUpdate: jest.Mock<void, [UrlUpdateEvent]>,
): void {
render(
<NuqsTestingAdapter searchParams={searchParams} onUrlUpdate={onUrlUpdate}>
<ClickableStatusBadge
color="cherry"
label="CrashLoopBackOff"
status="crashloopbackoff"
kind="pod"
rowId="row-1"
/>
</NuqsTestingAdapter>,
);
}
describe('ClickableStatusBadge', () => {
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
beforeEach(() => {
onUrlUpdate.mockClear();
});
it('filters by its status when nothing is filtered', async () => {
renderBadge({}, onUrlUpdate);
await userEvent.click(screen.getByTestId('status-badge-crashloopbackoff'));
await waitFor(() => {
expect(onUrlUpdate.mock.calls[0][0].searchParams.get('podStatus')).toBe(
'crashloopbackoff',
);
});
});
it('clears the filter when its status is the whole filter', async () => {
renderBadge({ podStatus: 'crashloopbackoff' }, onUrlUpdate);
await userEvent.click(screen.getByTestId('status-badge-crashloopbackoff'));
await waitFor(() => {
expect(
onUrlUpdate.mock.calls[0][0].searchParams.get('podStatus'),
).toBeNull();
});
});
it('still filters when its status is only part of the filter', async () => {
// Clearing here would silently drop oomkilled, which the user also picked.
renderBadge({ podStatus: 'crashloopbackoff,oomkilled' }, onUrlUpdate);
await userEvent.click(screen.getByTestId('status-badge-crashloopbackoff'));
await waitFor(() => {
expect(onUrlUpdate.mock.calls[0][0].searchParams.get('podStatus')).toBe(
'crashloopbackoff',
);
});
});
});

View File

@@ -1,60 +0,0 @@
import { useCallback } from 'react';
import {
InframonitoringtypesContainerStatusDTO,
InframonitoringtypesNodeConditionDTO,
InframonitoringtypesPodStatusDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
useInfraMonitoringContainerStatusFilter,
useInfraMonitoringNodeReadinessFilter,
useInfraMonitoringPageListing,
useInfraMonitoringPodStatusFilter,
} from '../../../hooks';
export type StatusFilterKind = 'pod' | 'node' | 'container';
interface StatusFilter {
selected: string[];
/**
* Replaces the filter rather than adding to it — a click on a status means
* "show me these", not "add these to what I already have". Empty clears it.
*/
setStatusFilter: (statuses: string[]) => void;
}
export function useStatusFilter(kind: StatusFilterKind): StatusFilter {
const [podStatus, setPodStatus] = useInfraMonitoringPodStatusFilter();
const [nodeStatus, setNodeStatus] = useInfraMonitoringNodeReadinessFilter();
const [containerStatus, setContainerStatus] =
useInfraMonitoringContainerStatusFilter();
const [, setCurrentPage] = useInfraMonitoringPageListing();
const setStatusFilter = useCallback(
(statuses: string[]): void => {
const next = statuses.length > 0 ? statuses : null;
if (kind === 'pod') {
void setPodStatus(next as InframonitoringtypesPodStatusDTO[] | null);
} else if (kind === 'node') {
void setNodeStatus(next as InframonitoringtypesNodeConditionDTO[] | null);
} else {
void setContainerStatus(
next as InframonitoringtypesContainerStatusDTO[] | null,
);
}
void setCurrentPage(1);
},
[kind, setPodStatus, setNodeStatus, setContainerStatus, setCurrentPage],
);
let selected: string[] = containerStatus;
if (kind === 'pod') {
selected = podStatus;
} else if (kind === 'node') {
selected = nodeStatus;
}
return { selected, setStatusFilter };
}

View File

@@ -6,7 +6,7 @@ import { isCustomTimeRange } from 'store/globalTime';
export function logInfraFilterCustomizedEvent(
entityType: InfraMonitoringEntity,
source: 'quick_filter' | 'search' | 'host_status_toggle' | 'status_filter',
source: 'quick_filter' | 'search' | 'host_status_toggle',
expression: string,
extraKeys?: string[],
): void {

View File

@@ -1,9 +1,4 @@
import { ReactNode } from 'react';
import {
InframonitoringtypesContainerStatusDTO,
InframonitoringtypesNodeConditionDTO,
InframonitoringtypesPodStatusDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
CustomTimeType,
Time,
@@ -22,9 +17,6 @@ export type K8sBaseFilters = {
filter: {
expression: string;
filterByStatus?: 'active' | 'inactive' | '';
filterByPodStatus?: InframonitoringtypesPodStatusDTO[];
filterByNodeReadiness?: InframonitoringtypesNodeConditionDTO[];
filterByContainerStatus?: InframonitoringtypesContainerStatusDTO[];
};
groupBy?: Array<{ name: string }>;
offset?: number;

View File

@@ -38,11 +38,7 @@ async function fetchListData(
try {
const response = await listClusters(
{
filter: {
expression: filters.filter.expression,
filterByPodStatus: filters.filter.filterByPodStatus,
filterByNodeReadiness: filters.filter.filterByNodeReadiness,
},
filter: { expression: filters.filter.expression },
groupBy: filters.groupBy?.map((g) => ({ name: g.name })),
offset: filters.offset,
limit: filters.limit ?? 10,

View File

@@ -1,19 +1,19 @@
import { Color } from '@signozhq/design-tokens';
import { Boxes } from '@signozhq/icons';
import {
InframonitoringtypesClusterRecordDTO,
InframonitoringtypesNodeConditionDTO,
} from 'api/generated/services/sigNoz.schemas';
import { InframonitoringtypesClusterRecordDTO } from 'api/generated/services/sigNoz.schemas';
import { TableColumnDef } from 'components/TanStackTableView';
import TanStackTable from 'components/TanStackTableView';
import { ExpandButtonWrapper } from 'container/InfraMonitoringK8sV2/components';
import ColumnHeader from '../Base/ColumnHeader';
import ClickableStatusCounts from '../Base/components/StatusFilterCells/ClickableStatusCounts';
import EntityGroupHeader from '../Base/EntityGroupHeader';
import K8sGroupCell from '../Base/K8sGroupCell';
import { formatBytes, getPodStatusItems } from '../commonUtils';
import { TextNoData, ValidateColumnValueWrapper } from '../components';
import {
GroupedStatusCounts,
TextNoData,
ValidateColumnValueWrapper,
} from '../components';
import {
INFRA_MONITORING_ATTR_KEYS,
InfraMonitoringEntity,
@@ -98,21 +98,18 @@ export const k8sClustersColumnsConfig: ClusterTableColumnConfig[] = [
}
return (
<ClickableStatusCounts
kind="node"
<GroupedStatusCounts
rowId={rowId}
items={[
{
value: row.nodeCountsByReadiness.ready,
label: 'Ready',
color: Color.BG_FOREST_500,
statuses: [InframonitoringtypesNodeConditionDTO.ready],
},
{
value: row.nodeCountsByReadiness.notReady,
label: 'Not Ready',
color: Color.BG_AMBER_500,
statuses: [InframonitoringtypesNodeConditionDTO.not_ready],
},
]}
/>
@@ -138,8 +135,7 @@ export const k8sClustersColumnsConfig: ClusterTableColumnConfig[] = [
return <TextNoData type="tanstack" />;
}
return (
<ClickableStatusCounts
kind="pod"
<GroupedStatusCounts
rowId={rowId}
items={getPodStatusItems(row.podCountsByStatus)}
/>

View File

@@ -40,10 +40,7 @@ async function fetchListData(
try {
const response = await listContainers(
{
filter: {
expression: filters.filter.expression,
filterByContainerStatus: filters.filter.filterByContainerStatus,
},
filter: { expression: filters.filter.expression },
groupBy: filters.groupBy?.map((g) => ({ name: g.name })),
offset: filters.offset,
limit: filters.limit ?? 10,

View File

@@ -9,8 +9,6 @@ import TanStackTable, { TableColumnDef } from 'components/TanStackTableView';
import { ExpandButtonWrapper } from 'container/InfraMonitoringK8sV2/components';
import ColumnHeader from '../Base/ColumnHeader';
import ClickableStatusBadge from '../Base/components/StatusFilterCells/ClickableStatusBadge';
import ClickableStatusCounts from '../Base/components/StatusFilterCells/ClickableStatusCounts';
import EntityGroupHeader from '../Base/EntityGroupHeader';
import K8sGroupCell from '../Base/K8sGroupCell';
import { formatBytes } from '../commonUtils';
@@ -175,7 +173,7 @@ export const k8sContainerColumnsConfig: ContainerTableColumnConfig[] = [
width: { min: STATUS_COLUMN_WIDTH },
enableSort: false,
visibilityBehavior: 'hidden-on-expand',
cell: ({ row, rowId }): React.ReactNode => {
cell: ({ row }): React.ReactNode => {
if (
!row.status ||
row.status === InframonitoringtypesContainerStatusDTO.no_data
@@ -184,13 +182,9 @@ export const k8sContainerColumnsConfig: ContainerTableColumnConfig[] = [
}
return (
<ClickableStatusBadge
color={CONTAINER_STATUS_COLORS[row.status]}
label={CONTAINER_STATUS_LABELS[row.status]}
status={row.status}
kind="container"
rowId={rowId}
/>
<Badge color={CONTAINER_STATUS_COLORS[row.status]} variant="outline">
{CONTAINER_STATUS_LABELS[row.status]}
</Badge>
);
},
},
@@ -211,8 +205,7 @@ export const k8sContainerColumnsConfig: ContainerTableColumnConfig[] = [
return <TextNoData type="tanstack" />;
}
return (
<ClickableStatusCounts
kind="container"
<GroupedStatusCounts
items={getContainerStatusItems(row.containerCountsByStatus)}
rowId={rowId}
/>

View File

@@ -104,20 +104,6 @@ const CONTAINER_ERROR_STATUS_LABELS: Partial<
containerCannotRun: 'ContainerCannotRun',
};
const CONTAINER_STATUS_VALUES = new Set<string>(
Object.values(InframonitoringtypesContainerStatusDTO),
);
/** See countKeyToPodStatus in commonUtils — the counts DTO uses camelCase keys. */
function countKeyToContainerStatus(
key: string,
): InframonitoringtypesContainerStatusDTO | null {
const value = key.toLowerCase();
return CONTAINER_STATUS_VALUES.has(value)
? (value as InframonitoringtypesContainerStatusDTO)
: null;
}
export function getContainerStatusItems(
counts: InframonitoringtypesContainerCountsByStatusDTO,
): StatusCountItem[] {
@@ -125,67 +111,32 @@ export function getContainerStatusItems(
keyof typeof CONTAINER_ERROR_STATUS_LABELS
>;
const errorBreakdown = errorKeys.map((key) => ({
label: CONTAINER_ERROR_STATUS_LABELS[key] as string,
value: counts[key],
statuses: [countKeyToContainerStatus(key)].filter(
(status): status is InframonitoringtypesContainerStatusDTO =>
status !== null,
),
}));
return [
{
value: counts.running,
label: 'Running',
color: Color.BG_FOREST_500,
statuses: [InframonitoringtypesContainerStatusDTO.running],
},
{
value: counts.completed,
label: 'Completed',
color: Color.BG_ROBIN_500,
statuses: [InframonitoringtypesContainerStatusDTO.completed],
},
{ value: counts.running, label: 'Running', color: Color.BG_FOREST_500 },
{ value: counts.completed, label: 'Completed', color: Color.BG_ROBIN_500 },
{
value: counts.waiting + counts.containerCreating,
label: 'Waiting',
color: Color.BG_AMBER_500,
breakdown: [
{
label: 'Waiting',
value: counts.waiting,
statuses: [InframonitoringtypesContainerStatusDTO.waiting],
},
{
label: 'ContainerCreating',
value: counts.containerCreating,
statuses: [InframonitoringtypesContainerStatusDTO.containercreating],
},
],
statuses: [
InframonitoringtypesContainerStatusDTO.waiting,
InframonitoringtypesContainerStatusDTO.containercreating,
{ label: 'Waiting', value: counts.waiting },
{ label: 'ContainerCreating', value: counts.containerCreating },
],
},
{
value: counts.terminated,
label: 'Terminated',
color: Color.BG_SIENNA_500,
statuses: [InframonitoringtypesContainerStatusDTO.terminated],
},
{
value: counts.unknown,
label: 'Unknown',
color: Color.BG_SLATE_400,
statuses: [InframonitoringtypesContainerStatusDTO.unknown],
},
{ value: counts.unknown, label: 'Unknown', color: Color.BG_SLATE_400 },
{
value: errorKeys.reduce((sum, key) => sum + counts[key], 0),
label: 'Error Status',
color: Color.BG_CHERRY_500,
breakdown: errorBreakdown,
statuses: errorBreakdown.flatMap((item) => item.statuses),
breakdown: errorKeys.map((key) => ({
label: CONTAINER_ERROR_STATUS_LABELS[key] as string,
value: counts[key],
})),
},
];
}

View File

@@ -42,10 +42,7 @@ async function fetchListData(
try {
const response = await listDaemonSets(
{
filter: {
expression: filters.filter.expression,
filterByPodStatus: filters.filter.filterByPodStatus,
},
filter: { expression: filters.filter.expression },
groupBy: filters.groupBy?.map((g) => ({ name: g.name })),
offset: filters.offset,
limit: filters.limit ?? 10,

View File

@@ -4,7 +4,6 @@ import TanStackTable, { TableColumnDef } from 'components/TanStackTableView';
import { ExpandButtonWrapper } from 'container/InfraMonitoringK8sV2/components';
import ColumnHeader from '../Base/ColumnHeader';
import ClickableStatusCounts from '../Base/components/StatusFilterCells/ClickableStatusCounts';
import EntityGroupHeader from '../Base/EntityGroupHeader';
import K8sGroupCell from '../Base/K8sGroupCell';
import { SelectedItemParams } from '../hooks';
@@ -127,8 +126,7 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
return <TextNoData type="tanstack" />;
}
return (
<ClickableStatusCounts
kind="pod"
<GroupedStatusCounts
rowId={rowId}
items={getPodStatusItems(podCountsByStatus)}
/>

View File

@@ -46,10 +46,7 @@ async function fetchListData(
try {
const response = await listDeployments(
{
filter: {
expression: filters.filter.expression,
filterByPodStatus: filters.filter.filterByPodStatus,
},
filter: { expression: filters.filter.expression },
groupBy: filters.groupBy?.map((g) => ({ name: g.name })),
offset: filters.offset,
limit: filters.limit ?? 10,

View File

@@ -4,7 +4,6 @@ import TanStackTable, { TableColumnDef } from 'components/TanStackTableView';
import { ExpandButtonWrapper } from 'container/InfraMonitoringK8sV2/components';
import ColumnHeader from '../Base/ColumnHeader';
import ClickableStatusCounts from '../Base/components/StatusFilterCells/ClickableStatusCounts';
import EntityGroupHeader from '../Base/EntityGroupHeader';
import K8sGroupCell from '../Base/K8sGroupCell';
import { SelectedItemParams } from '../hooks';
@@ -125,8 +124,7 @@ export const k8sDeploymentsColumnsConfig: TableColumnDef<InframonitoringtypesDep
return <TextNoData type="tanstack" />;
}
return (
<ClickableStatusCounts
kind="pod"
<GroupedStatusCounts
rowId={rowId}
items={getPodStatusItems(podCountsByStatus)}
/>

View File

@@ -18,7 +18,6 @@ import { DataSource } from 'types/common/queryBuilder';
import { CategoryIcon } from './Base/categoryIcons';
import { K8sDynamicList } from './Base/K8sDynamicList';
import EntityStatusFilter from './Base/components/EntityStatusFilter/EntityStatusFilter';
import {
GetClustersQuickFiltersConfig,
GetContainersQuickFiltersConfig,
@@ -37,12 +36,9 @@ import {
} from './constants';
import {
useInfraMonitoringCategory,
useInfraMonitoringContainerStatusFilter,
useInfraMonitoringGroupBy,
useInfraMonitoringNodeReadinessFilter,
useInfraMonitoringOrderBy,
useInfraMonitoringPageListing,
useInfraMonitoringPodStatusFilter,
useInfraMonitoringSelectedItemParams,
} from './hooks';
@@ -64,9 +60,6 @@ export default function InfraMonitoringK8s(): JSX.Element {
const [, setOrderBy] = useInfraMonitoringOrderBy();
const [, setSelectedItemParams] = useInfraMonitoringSelectedItemParams();
const [, setCurrentPage] = useInfraMonitoringPageListing();
const [, setPodStatusFilter] = useInfraMonitoringPodStatusFilter();
const [, setNodeReadinessFilter] = useInfraMonitoringNodeReadinessFilter();
const [, setContainerStatusFilter] = useInfraMonitoringContainerStatusFilter();
const compositeQuery = useGetCompositeQueryParam();
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
@@ -226,9 +219,6 @@ export default function InfraMonitoringK8s(): JSX.Element {
void setGroupBy(null);
void setCurrentPage(null);
setSelectedItemParams(null);
void setPodStatusFilter(null);
void setNodeReadinessFilter(null);
void setContainerStatusFilter(null);
redirectWithQueryBuilderData({
...currentQuery,
builder: {
@@ -336,10 +326,7 @@ export default function InfraMonitoringK8s(): JSX.Element {
showFilters ? styles.listContainerFiltersVisible : ''
}`}
>
<K8sDynamicList
controlListPrefix={showFiltersComp}
leftFilters={<EntityStatusFilter />}
/>
<K8sDynamicList controlListPrefix={showFiltersComp} />
</div>
</div>
</div>

View File

@@ -42,10 +42,7 @@ async function fetchListData(
try {
const response = await listJobs(
{
filter: {
expression: filters.filter.expression,
filterByPodStatus: filters.filter.filterByPodStatus,
},
filter: { expression: filters.filter.expression },
groupBy: filters.groupBy?.map((g) => ({ name: g.name })),
offset: filters.offset,
limit: filters.limit ?? 10,

View File

@@ -4,7 +4,6 @@ import TanStackTable, { TableColumnDef } from 'components/TanStackTableView';
import { ExpandButtonWrapper } from 'container/InfraMonitoringK8sV2/components';
import ColumnHeader from '../Base/ColumnHeader';
import ClickableStatusCounts from '../Base/components/StatusFilterCells/ClickableStatusCounts';
import EntityGroupHeader from '../Base/EntityGroupHeader';
import K8sGroupCell from '../Base/K8sGroupCell';
import { SelectedItemParams } from '../hooks';
@@ -119,8 +118,7 @@ export const k8sJobsColumnsConfig: JobTableColumnConfig[] = [
return <TextNoData type="tanstack" />;
}
return (
<ClickableStatusCounts
kind="pod"
<GroupedStatusCounts
items={getPodStatusItems(podCountsByStatus)}
rowId={rowId}
/>

View File

@@ -44,10 +44,7 @@ async function fetchListData(
try {
const response = await listNamespaces(
{
filter: {
expression: filters.filter.expression,
filterByPodStatus: filters.filter.filterByPodStatus,
},
filter: { expression: filters.filter.expression },
groupBy: filters.groupBy?.map((g) => ({ name: g.name })),
offset: filters.offset,
limit: filters.limit ?? 10,

View File

@@ -4,11 +4,14 @@ import TanStackTable, { TableColumnDef } from 'components/TanStackTableView';
import { ExpandButtonWrapper } from 'container/InfraMonitoringK8sV2/components';
import ColumnHeader from '../Base/ColumnHeader';
import ClickableStatusCounts from '../Base/components/StatusFilterCells/ClickableStatusCounts';
import EntityGroupHeader from '../Base/EntityGroupHeader';
import K8sGroupCell from '../Base/K8sGroupCell';
import { formatBytes, getPodStatusItems } from '../commonUtils';
import { TextNoData, ValidateColumnValueWrapper } from '../components';
import {
GroupedStatusCounts,
TextNoData,
ValidateColumnValueWrapper,
} from '../components';
import {
INFRA_MONITORING_ATTR_KEYS,
InfraMonitoringEntity,
@@ -116,8 +119,7 @@ export const k8sNamespacesColumnsConfig: NamespaceTableColumnConfig[] = [
return <TextNoData type="tanstack" />;
}
return (
<ClickableStatusCounts
kind="pod"
<GroupedStatusCounts
items={getPodStatusItems(row.podCountsByStatus)}
rowId={rowId}
/>

View File

@@ -39,11 +39,7 @@ async function fetchListData(
try {
const response = await listNodes(
{
filter: {
expression: filters.filter.expression,
filterByPodStatus: filters.filter.filterByPodStatus,
filterByNodeReadiness: filters.filter.filterByNodeReadiness,
},
filter: { expression: filters.filter.expression },
groupBy: filters.groupBy?.map((g) => ({ name: g.name })),
offset: filters.offset,
limit: filters.limit ?? 10,

View File

@@ -1,21 +1,20 @@
import { Color } from '@signozhq/design-tokens';
import {
InframonitoringtypesNodeConditionDTO,
InframonitoringtypesNodeRecordDTO,
} from 'api/generated/services/sigNoz.schemas';
import { Badge, BadgeColor } from '@signozhq/ui/badge';
import { InframonitoringtypesNodeRecordDTO } from 'api/generated/services/sigNoz.schemas';
import TanStackTable, { TableColumnDef } from 'components/TanStackTableView';
import { ExpandButtonWrapper } from 'container/InfraMonitoringK8sV2/components';
import ColumnHeader from '../Base/ColumnHeader';
import EntityGroupHeader from '../Base/EntityGroupHeader';
import K8sGroupCell from '../Base/K8sGroupCell';
import ClickableStatusBadge from '../Base/components/StatusFilterCells/ClickableStatusBadge';
import ClickableStatusCounts from '../Base/components/StatusFilterCells/ClickableStatusCounts';
import { formatBytes, getPodStatusItems } from '../commonUtils';
import { INFRA_MONITORING_ATTR_KEYS } from '../constants';
import { TextNoData, ValidateColumnValueWrapper } from '../components';
import {
GroupedStatusCounts,
TextNoData,
ValidateColumnValueWrapper,
} from '../components';
import { InfraMonitoringEntity } from '../constants';
import { NODE_CONDITION_COLORS, NODE_CONDITION_LABELS } from './utils';
import { Workflow } from '@signozhq/icons';
export function getK8sNodeRowKey(
@@ -32,6 +31,18 @@ export function getK8sNodeItemKey(
return node.nodeName;
}
const NODE_CONDITION_COLORS: Record<string, BadgeColor> = {
ready: 'forest',
not_ready: 'amber',
no_data: 'secondary',
};
const NODE_CONDITION_LABEL_MAP: Record<string, string> = {
ready: 'Ready',
not_ready: 'Not Ready',
no_data: 'No Data',
};
export type NodeTableColumnConfig =
TableColumnDef<InframonitoringtypesNodeRecordDTO>;
export const k8sNodesColumnsConfig: NodeTableColumnConfig[] = [
@@ -96,31 +107,24 @@ export const k8sNodesColumnsConfig: NodeTableColumnConfig[] = [
}
return (
<ClickableStatusBadge
color={color}
label={NODE_CONDITION_LABELS[row.condition] || 'Unknown'}
status={row.condition}
kind="node"
rowId={rowId}
/>
<Badge color={color} variant="outline">
{NODE_CONDITION_LABEL_MAP[row.condition] || 'Unknown'}
</Badge>
);
}
return (
<ClickableStatusCounts
kind="node"
<GroupedStatusCounts
items={[
{
value: row.nodeCountsByReadiness?.ready ?? 0,
label: 'Ready',
color: Color.BG_FOREST_500,
statuses: [InframonitoringtypesNodeConditionDTO.ready],
},
{
value: row.nodeCountsByReadiness?.notReady ?? 0,
label: 'Not Ready',
color: Color.BG_AMBER_500,
statuses: [InframonitoringtypesNodeConditionDTO.not_ready],
},
]}
rowId={rowId}
@@ -145,8 +149,7 @@ export const k8sNodesColumnsConfig: NodeTableColumnConfig[] = [
return <TextNoData type="tanstack" />;
}
return (
<ClickableStatusCounts
kind="pod"
<GroupedStatusCounts
items={getPodStatusItems(row.podCountsByStatus)}
rowId={rowId}
/>

View File

@@ -1,20 +0,0 @@
import { BadgeColor } from '@signozhq/ui/badge';
import { InframonitoringtypesNodeConditionDTO } from 'api/generated/services/sigNoz.schemas';
export const NODE_CONDITION_COLORS: Record<
InframonitoringtypesNodeConditionDTO,
BadgeColor
> = {
[InframonitoringtypesNodeConditionDTO.ready]: 'forest',
[InframonitoringtypesNodeConditionDTO.not_ready]: 'amber',
[InframonitoringtypesNodeConditionDTO.no_data]: 'secondary',
};
export const NODE_CONDITION_LABELS: Record<
InframonitoringtypesNodeConditionDTO,
string
> = {
[InframonitoringtypesNodeConditionDTO.ready]: 'Ready',
[InframonitoringtypesNodeConditionDTO.not_ready]: 'Not Ready',
[InframonitoringtypesNodeConditionDTO.no_data]: 'No Data',
};

View File

@@ -36,10 +36,7 @@ async function fetchListData(
try {
const response = await listPods(
{
filter: {
expression: filters.filter.expression,
filterByPodStatus: filters.filter.filterByPodStatus,
},
filter: { expression: filters.filter.expression },
groupBy: filters.groupBy?.map((g) => ({ name: g.name })),
offset: filters.offset,
limit: filters.limit ?? 10,

View File

@@ -1,4 +1,5 @@
import { Container } from '@signozhq/icons';
import { Badge } from '@signozhq/ui/badge';
import {
InframonitoringtypesPodRecordDTO,
InframonitoringtypesPodStatusDTO,
@@ -9,17 +10,15 @@ import { ExpandButtonWrapper } from 'container/InfraMonitoringK8sV2/components';
import ColumnHeader from '../Base/ColumnHeader';
import EntityGroupHeader from '../Base/EntityGroupHeader';
import K8sGroupCell from '../Base/K8sGroupCell';
import ClickableStatusBadge from '../Base/components/StatusFilterCells/ClickableStatusBadge';
import ClickableStatusCounts from '../Base/components/StatusFilterCells/ClickableStatusCounts';
import {
formatBytes,
getPodStatusItems,
POD_STATUS_COLORS,
POD_STATUS_LABELS,
} from '../commonUtils';
import {
EntityProgressBar,
EntityProgressThresholds,
GroupedStatusCounts,
TextNoData,
ValidateColumnValueWrapper,
} from '../components';
@@ -101,7 +100,7 @@ export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
width: { min: 250 },
enableSort: false,
visibilityBehavior: 'hidden-on-expand',
cell: ({ row, rowId }): React.ReactNode => {
cell: ({ row }): React.ReactNode => {
if (!row.podStatus) {
return <></>;
}
@@ -111,14 +110,11 @@ export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
}
const color = POD_STATUS_COLORS[row.podStatus] || POD_STATUS_COLORS.unknown;
const label = row.podStatus.charAt(0).toUpperCase() + row.podStatus.slice(1);
return (
<ClickableStatusBadge
color={color}
label={POD_STATUS_LABELS[row.podStatus]}
status={row.podStatus}
kind="pod"
rowId={rowId}
/>
<Badge color={color} variant="outline">
{label}
</Badge>
);
},
},
@@ -140,8 +136,7 @@ export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
return <TextNoData type="tanstack" />;
}
return (
<ClickableStatusCounts
kind="pod"
<GroupedStatusCounts
items={getPodStatusItems(row.podCountsByStatus)}
rowId={rowId}
/>

View File

@@ -42,10 +42,7 @@ async function fetchListData(
try {
const response = await listStatefulSets(
{
filter: {
expression: filters.filter.expression,
filterByPodStatus: filters.filter.filterByPodStatus,
},
filter: { expression: filters.filter.expression },
groupBy: filters.groupBy?.map((g) => ({ name: g.name })),
offset: filters.offset,
limit: filters.limit ?? 10,

View File

@@ -4,7 +4,6 @@ import TanStackTable, { TableColumnDef } from 'components/TanStackTableView';
import { ExpandButtonWrapper } from 'container/InfraMonitoringK8sV2/components';
import ColumnHeader from '../Base/ColumnHeader';
import ClickableStatusCounts from '../Base/components/StatusFilterCells/ClickableStatusCounts';
import EntityGroupHeader from '../Base/EntityGroupHeader';
import K8sGroupCell from '../Base/K8sGroupCell';
import { SelectedItemParams } from '../hooks';
@@ -128,8 +127,7 @@ export const k8sStatefulSetsColumnsConfig: TableColumnDef<InframonitoringtypesSt
return <TextNoData type="tanstack" />;
}
return (
<ClickableStatusCounts
kind="pod"
<GroupedStatusCounts
items={getPodStatusItems(podCountsByStatus)}
rowId={rowId}
/>

View File

@@ -69,42 +69,6 @@ function renderPage(
}
describe('InfraMonitoringK8s', () => {
describe('when the category changes with a status filter applied', () => {
const onUrlUpdateMock = jest.fn<void, [UrlUpdateEvent]>();
beforeEach(async () => {
onUrlUpdateMock.mockClear();
renderPage(
{
category: K8sCategories.CLUSTERS,
podStatus: 'crashloopbackoff',
nodeReadiness: 'not_ready',
},
onUrlUpdateMock,
);
await screen.findByTestId(`category-${K8sCategories.CONTAINERS}`);
});
it('should drop the status filters the new category cannot apply', async () => {
fireEvent.click(screen.getByTestId(`category-${K8sCategories.CONTAINERS}`));
// Containers filter on their own statuses, so a pod status carried over from
// clusters would sit in the toolbar looking applied while matching nothing.
await waitFor(() => {
const categorySwitch = onUrlUpdateMock.mock.calls.find(
(call) =>
call[0].searchParams.get('category') === K8sCategories.CONTAINERS,
);
expect(categorySwitch).toBeDefined();
expect(categorySwitch?.[0].searchParams.get('podStatus')).toBeNull();
expect(categorySwitch?.[0].searchParams.get('nodeReadiness')).toBeNull();
});
});
});
describe('when the category changes from a page other than the first', () => {
const onUrlUpdateMock = jest.fn<void, [UrlUpdateEvent]>();

View File

@@ -1,87 +0,0 @@
import {
InframonitoringtypesContainerCountsByStatusDTO,
InframonitoringtypesContainerStatusDTO,
InframonitoringtypesPodCountsByStatusDTO,
InframonitoringtypesPodStatusDTO,
} from 'api/generated/services/sigNoz.schemas';
import { getPodStatusItems } from '../commonUtils';
import { getContainerStatusItems } from '../Containers/utils';
const POD_COUNT_KEYS: Array<keyof InframonitoringtypesPodCountsByStatusDTO> = [
'running',
'completed',
'pending',
'unknown',
'failed',
'crashLoopBackOff',
'imagePullBackOff',
'errImagePull',
'createContainerConfigError',
'containerCreating',
'oomKilled',
'error',
'containerCannotRun',
'evicted',
'nodeAffinity',
'nodeLost',
'shutdown',
'unexpectedAdmissionError',
];
function podCounts(): InframonitoringtypesPodCountsByStatusDTO {
return POD_COUNT_KEYS.reduce(
(acc, key) => ({ ...acc, [key]: 1 }),
{} as InframonitoringtypesPodCountsByStatusDTO,
);
}
function containerCounts(): InframonitoringtypesContainerCountsByStatusDTO {
return {
running: 1,
waiting: 1,
terminated: 1,
completed: 1,
unknown: 1,
containerCreating: 1,
crashLoopBackOff: 1,
imagePullBackOff: 1,
errImagePull: 1,
createContainerConfigError: 1,
oomKilled: 1,
error: 1,
containerCannotRun: 1,
} as InframonitoringtypesContainerCountsByStatusDTO;
}
describe('status counts carry filterable statuses', () => {
it('maps every pod count bucket to a real status enum member', () => {
const items = getPodStatusItems(podCounts());
const statuses = items.flatMap((item) => item.statuses ?? []);
const valid = Object.values(InframonitoringtypesPodStatusDTO) as string[];
expect(statuses.length).toBeGreaterThan(0);
statuses.forEach((status) => expect(valid).toContain(status));
});
it('gives the pod Error Status chip every error bucket it sums', () => {
const items = getPodStatusItems(podCounts());
const errorItem = items.find((item) => item.label === 'Error Status');
// The chip's own value is the sum of its breakdown, so selecting it must
// select every status counted in that sum.
expect(errorItem?.value).toBe(errorItem?.breakdown?.length);
expect(errorItem?.statuses).toHaveLength(errorItem?.breakdown?.length ?? 0);
});
it('maps every container count bucket to a real status enum member', () => {
const items = getContainerStatusItems(containerCounts());
const statuses = items.flatMap((item) => item.statuses ?? []);
const valid = Object.values(
InframonitoringtypesContainerStatusDTO,
) as string[];
expect(statuses.length).toBeGreaterThan(0);
statuses.forEach((status) => expect(valid).toContain(status));
});
});

View File

@@ -1,68 +0,0 @@
import * as inframonitoring from 'api/generated/services/inframonitoring';
import { entityRegistry } from '../Base/entity.registry';
import { K8sCategories } from '../constants';
jest.mock('api/generated/services/inframonitoring');
/**
* Every list the status filter offers must forward the flag, or the control is a
* no-op on that section. Mirrors the filter structs in pkg/types/inframonitoringtypes.
*/
const EXPECTED_FLAGS: Record<string, string[]> = {
[K8sCategories.PODS]: ['filterByPodStatus'],
[K8sCategories.CONTAINERS]: ['filterByContainerStatus'],
[K8sCategories.NODES]: ['filterByPodStatus', 'filterByNodeReadiness'],
[K8sCategories.CLUSTERS]: ['filterByPodStatus', 'filterByNodeReadiness'],
[K8sCategories.NAMESPACES]: ['filterByPodStatus'],
[K8sCategories.DEPLOYMENTS]: ['filterByPodStatus'],
[K8sCategories.DAEMONSETS]: ['filterByPodStatus'],
[K8sCategories.STATEFULSETS]: ['filterByPodStatus'],
[K8sCategories.JOBS]: ['filterByPodStatus'],
};
const SENTINELS: Record<string, string[]> = {
filterByPodStatus: ['crashloopbackoff'],
filterByNodeReadiness: ['not_ready'],
filterByContainerStatus: ['oomkilled'],
};
describe('status filters reach the list APIs', () => {
beforeEach(() => {
jest.clearAllMocks();
Object.values(inframonitoring).forEach((exported) => {
if (jest.isMockFunction(exported)) {
exported.mockResolvedValue({
data: { type: 'list', records: [], total: 0 },
});
}
});
});
Object.entries(EXPECTED_FLAGS).forEach(([category, flags]) => {
it(`forwards ${flags.join(' + ')} for ${category}`, async () => {
const config = entityRegistry[category];
expect(config).toBeDefined();
await config.list.fetchListData({
filter: {
expression: '',
...Object.fromEntries(flags.map((flag) => [flag, SENTINELS[flag]])),
},
start: 1,
end: 2,
limit: 10,
offset: 0,
});
const call = Object.values(inframonitoring)
.filter(jest.isMockFunction)
.flatMap((fn) => (fn as jest.Mock).mock.calls)[0];
expect(call).toBeDefined();
flags.forEach((flag) => {
expect(call[0].filter[flag]).toStrictEqual(SENTINELS[flag]);
});
});
});
});

View File

@@ -51,34 +51,6 @@ export const POD_STATUS_COLORS: Record<
[InframonitoringtypesPodStatusDTO.unexpectedadmissionerror]: 'cherry',
};
/** kubectl prints these as single CamelCase words, so the enum value alone is not a usable label. */
export const POD_STATUS_LABELS: Record<
InframonitoringtypesPodStatusDTO,
string
> = {
[InframonitoringtypesPodStatusDTO.running]: 'Running',
[InframonitoringtypesPodStatusDTO.completed]: 'Completed',
[InframonitoringtypesPodStatusDTO.pending]: 'Pending',
[InframonitoringtypesPodStatusDTO.unknown]: 'Unknown',
[InframonitoringtypesPodStatusDTO.no_data]: 'No data',
[InframonitoringtypesPodStatusDTO.failed]: 'Failed',
[InframonitoringtypesPodStatusDTO.crashloopbackoff]: 'CrashLoopBackOff',
[InframonitoringtypesPodStatusDTO.imagepullbackoff]: 'ImagePullBackOff',
[InframonitoringtypesPodStatusDTO.errimagepull]: 'ErrImagePull',
[InframonitoringtypesPodStatusDTO.createcontainerconfigerror]:
'CreateContainerConfigError',
[InframonitoringtypesPodStatusDTO.containercreating]: 'ContainerCreating',
[InframonitoringtypesPodStatusDTO.oomkilled]: 'OOMKilled',
[InframonitoringtypesPodStatusDTO.error]: 'Error',
[InframonitoringtypesPodStatusDTO.containercannotrun]: 'ContainerCannotRun',
[InframonitoringtypesPodStatusDTO.evicted]: 'Evicted',
[InframonitoringtypesPodStatusDTO.nodeaffinity]: 'NodeAffinity',
[InframonitoringtypesPodStatusDTO.nodelost]: 'NodeLost',
[InframonitoringtypesPodStatusDTO.shutdown]: 'Shutdown',
[InframonitoringtypesPodStatusDTO.unexpectedadmissionerror]:
'UnexpectedAdmissionError',
};
type PodStatusCategory =
| 'running'
| 'completed'
@@ -133,24 +105,6 @@ const ERROR_STATUS_LABELS: Record<ErrorStatusKey, string> = {
unexpectedAdmissionError: 'UnexpectedAdmissionError',
};
const POD_STATUS_VALUES = new Set<string>(
Object.values(InframonitoringtypesPodStatusDTO),
);
/**
* The count DTO spells each status in camelCase (`crashLoopBackOff`) where the
* status enum spells it lowercase (`crashloopbackoff`); anything that does not
* land on a real enum member is dropped rather than sent to the API.
*/
function countKeyToPodStatus(
key: string,
): InframonitoringtypesPodStatusDTO | null {
const value = key.toLowerCase();
return POD_STATUS_VALUES.has(value)
? (value as InframonitoringtypesPodStatusDTO)
: null;
}
export function getPodStatusItems(
counts: InframonitoringtypesPodCountsByStatusDTO,
): StatusCountItem[] {
@@ -160,42 +114,18 @@ export function getPodStatusItems(
const errorBreakdown = errorKeys.map((key) => ({
label: ERROR_STATUS_LABELS[key],
value: counts[key],
statuses: [countKeyToPodStatus(key)].filter(
(status): status is InframonitoringtypesPodStatusDTO => status !== null,
),
}));
return [
{
value: counts.running,
label: 'Running',
color: Color.BG_FOREST_500,
statuses: [InframonitoringtypesPodStatusDTO.running],
},
{
value: counts.completed,
label: 'Completed',
color: Color.BG_ROBIN_500,
statuses: [InframonitoringtypesPodStatusDTO.completed],
},
{
value: counts.pending,
label: 'Pending',
color: Color.BG_AMBER_500,
statuses: [InframonitoringtypesPodStatusDTO.pending],
},
{
value: counts.unknown,
label: 'Unknown',
color: Color.BG_SLATE_400,
statuses: [InframonitoringtypesPodStatusDTO.unknown],
},
{ value: counts.running, label: 'Running', color: Color.BG_FOREST_500 },
{ value: counts.completed, label: 'Completed', color: Color.BG_ROBIN_500 },
{ value: counts.pending, label: 'Pending', color: Color.BG_AMBER_500 },
{ value: counts.unknown, label: 'Unknown', color: Color.BG_SLATE_400 },
{
value: errorTotal,
label: 'Error Status',
color: Color.BG_CHERRY_500,
breakdown: errorBreakdown,
statuses: errorBreakdown.flatMap((item) => item.statuses),
},
];
}

View File

@@ -45,25 +45,3 @@
.tooltipValue {
font-variant-numeric: tabular-nums;
}
.itemSelectable {
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
.tooltipRowSelectable {
width: 100%;
background: none;
border: none;
padding: 0;
cursor: pointer;
font: inherit;
text-align: left;
&:hover {
text-decoration: underline;
}
}

View File

@@ -8,8 +8,6 @@ import { MouseEventHandler } from 'react';
export interface StatusBreakdownItem {
label: string;
value: number;
/** The status values this row stands for, for click-to-filter. */
statuses?: string[];
}
export interface StatusCountItem {
@@ -17,22 +15,15 @@ export interface StatusCountItem {
label: string;
color: string;
breakdown?: StatusBreakdownItem[];
/** The status values this count stands for, for click-to-filter. */
statuses?: string[];
}
interface GroupedStatusCountsProps {
items: StatusCountItem[];
rowId: string;
showZeroValues?: boolean;
/** Omit to leave the counts inert, as on entities with no status filter. */
onSelectStatuses?: (statuses: string[]) => void;
}
function buildTooltipContent(
item: StatusCountItem,
onSelectStatuses?: (statuses: string[]) => void,
): React.ReactNode {
function buildTooltipContent(item: StatusCountItem): React.ReactNode {
const onClickHandle: MouseEventHandler = (e) => {
e.preventDefault();
e.stopPropagation();
@@ -66,33 +57,14 @@ function buildTooltipContent(
<Typography.Text className={styles.tooltipHeader}>
{item.label}
</Typography.Text>
{nonZeroBreakdown.map((b) =>
onSelectStatuses && b.statuses?.length ? (
<button
key={b.label}
type="button"
className={`${styles.tooltipRow} ${styles.tooltipRowSelectable}`}
data-testid={`status-breakdown-${b.statuses.join('-')}`}
onClick={(e): void => {
e.preventDefault();
e.stopPropagation();
onSelectStatuses(b.statuses as string[]);
}}
>
<Typography.Text>{b.label}</Typography.Text>
<Typography.Text className={styles.tooltipValue}>
{b.value}
</Typography.Text>
</button>
) : (
<div key={b.label} className={styles.tooltipRow}>
<Typography.Text>{b.label}</Typography.Text>
<Typography.Text className={styles.tooltipValue}>
{b.value}
</Typography.Text>
</div>
),
)}
{nonZeroBreakdown.map((b) => (
<div key={b.label} className={styles.tooltipRow}>
<Typography.Text>{b.label}</Typography.Text>
<Typography.Text className={styles.tooltipValue}>
{b.value}
</Typography.Text>
</div>
))}
</div>
);
}
@@ -101,7 +73,6 @@ export function GroupedStatusCounts({
items,
rowId,
showZeroValues = true,
onSelectStatuses,
}: GroupedStatusCountsProps): JSX.Element {
const visibleItems =
showZeroValues === false ? items.filter((item) => item.value > 0) : items;
@@ -116,30 +87,14 @@ export function GroupedStatusCounts({
<TanStackTable.HoverTooltip
key={item.label}
rowId={rowId}
title={buildTooltipContent(item, onSelectStatuses)}
title={buildTooltipContent(item)}
arrow
align="start"
>
{item.value ? (
<TanStackTable.Text
className={`${styles.item} ${
onSelectStatuses && item.statuses?.length ? styles.itemSelectable : ''
}`}
className={styles.item}
style={{ '--gsc-color': item.color } as React.CSSProperties}
data-testid={
item.statuses?.length
? `status-count-${item.statuses.join('-')}`
: undefined
}
onClick={
onSelectStatuses && item.statuses?.length
? (e: React.MouseEvent): void => {
e.preventDefault();
e.stopPropagation();
onSelectStatuses(item.statuses as string[]);
}
: undefined
}
>
{item.value}
</TanStackTable.Text>

View File

@@ -971,9 +971,6 @@ export const INFRA_MONITORING_K8S_PARAMS_KEYS = {
EVENTS_FILTERS: 'eventsFilters',
HOSTS_FILTERS: 'hostsFilters',
STATUS_FILTER: 'statusFilter',
POD_STATUS_FILTER: 'podStatus',
NODE_READINESS_FILTER: 'nodeReadiness',
CONTAINER_STATUS_FILTER: 'containerStatus',
CURRENT_PAGE: 'currentPage',
PAGE: 'page',
PAGE_SIZE: 'pageSize',

View File

@@ -1,20 +1,13 @@
import {
createParser,
Options,
parseAsArrayOf,
parseAsInteger,
parseAsJson,
parseAsString,
parseAsStringEnum,
useQueryState,
useQueryStates,
UseQueryStateReturn,
} from 'nuqs';
import {
InframonitoringtypesContainerStatusDTO,
InframonitoringtypesNodeConditionDTO,
InframonitoringtypesPodStatusDTO,
} from 'api/generated/services/sigNoz.schemas';
import { useCallback, useMemo } from 'react';
import {
IBuilderQuery,
@@ -232,58 +225,3 @@ export const useInfraMonitoringStatusFilter = (): UseQueryStateReturn<
INFRA_MONITORING_K8S_PARAMS_KEYS.STATUS_FILTER,
parseAsString.withDefault('').withOptions(defaultNuqsOptions),
);
/**
* The list APIs reject the `no_data` sentinel (see IsFilterable in
* pkg/types/inframonitoringtypes), so it is never offered as a filter value.
*/
function withoutNoData<T extends string>(values: T[]): T[] {
return values.filter((value) => value !== 'no_data');
}
export const FILTERABLE_POD_STATUSES = withoutNoData(
Object.values(InframonitoringtypesPodStatusDTO),
);
export const FILTERABLE_NODE_CONDITIONS = withoutNoData(
Object.values(InframonitoringtypesNodeConditionDTO),
);
export const FILTERABLE_CONTAINER_STATUSES = withoutNoData(
Object.values(InframonitoringtypesContainerStatusDTO),
);
const EMPTY_FILTER: [] = [];
export const useInfraMonitoringPodStatusFilter = (): UseQueryStateReturn<
InframonitoringtypesPodStatusDTO[],
[]
> =>
useQueryState(
INFRA_MONITORING_K8S_PARAMS_KEYS.POD_STATUS_FILTER,
parseAsArrayOf(parseAsStringEnum(FILTERABLE_POD_STATUSES))
.withDefault(EMPTY_FILTER)
.withOptions(defaultNuqsOptions),
);
export const useInfraMonitoringNodeReadinessFilter = (): UseQueryStateReturn<
InframonitoringtypesNodeConditionDTO[],
[]
> =>
useQueryState(
INFRA_MONITORING_K8S_PARAMS_KEYS.NODE_READINESS_FILTER,
parseAsArrayOf(parseAsStringEnum(FILTERABLE_NODE_CONDITIONS))
.withDefault(EMPTY_FILTER)
.withOptions(defaultNuqsOptions),
);
export const useInfraMonitoringContainerStatusFilter = (): UseQueryStateReturn<
InframonitoringtypesContainerStatusDTO[],
[]
> =>
useQueryState(
INFRA_MONITORING_K8S_PARAMS_KEYS.CONTAINER_STATUS_FILTER,
parseAsArrayOf(parseAsStringEnum(FILTERABLE_CONTAINER_STATUSES))
.withDefault(EMPTY_FILTER)
.withOptions(defaultNuqsOptions),
);

View File

@@ -41,7 +41,7 @@ import { buildCompositeKey, getOptionsFromKeys } from './utils';
interface UseOptionsMenuProps {
storageKey?: string;
dataSource: DataSource;
aggregateOperator?: string;
aggregateOperator: string;
initialOptions?: InitialOptions;
}

View File

@@ -1,34 +0,0 @@
import { useMemo } from 'react';
import { Check } from '@signozhq/icons';
import { DropdownMenuSimple, type MenuItem } from '@signozhq/ui/dropdown-menu';
import SavedViewsIconButton from './SavedViewsIconButton';
import { SaveChangesMenuProps } from './types';
function SaveChangesMenu({
disabled,
onSaveAsNew,
onUpdate,
}: SaveChangesMenuProps): JSX.Element {
const items = useMemo<MenuItem[]>(
() => [
{ key: 'save-as-new', label: 'Save as new view', onClick: onSaveAsNew },
{ key: 'update', label: 'Update selected view', onClick: onUpdate },
],
[onSaveAsNew, onUpdate],
);
return (
<DropdownMenuSimple menu={{ items }} align="end">
<SavedViewsIconButton
title="Save changes"
icon={<Check size={14} />}
color="warning"
disabled={disabled}
testId="saved-views-save-changes"
/>
</DropdownMenuSimple>
);
}
export default SaveChangesMenu;

View File

@@ -1,18 +0,0 @@
.field {
display: flex;
flex-direction: column;
gap: var(--spacing-3);
}
.label {
font-size: var(--font-size-xs);
color: var(--l1-foreground);
}
.footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--spacing-4);
width: 100%;
}

View File

@@ -1,94 +0,0 @@
import { FormEvent, useState } from 'react';
import { Check, X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { DialogWrapper } from '@signozhq/ui/dialog';
import { Input } from '@signozhq/ui/input';
import { Typography } from '@signozhq/ui/typography';
import { SAVE_VIEW_MODAL_TITLE } from './constants';
import { SaveViewModalProps } from './types';
import styles from './SaveViewModal.module.scss';
const NAME_INPUT_ID = 'save-view-name';
function SaveViewModal({
mode,
isSaving,
onClose,
onSave,
}: SaveViewModalProps): JSX.Element {
const [name, setName] = useState('');
const displayName = name.trim();
const handleSave = async (): Promise<void> => {
if (!displayName || isSaving) {
return;
}
if (await onSave(displayName)) {
onClose();
}
};
const handleSubmit = (event: FormEvent<HTMLFormElement>): void => {
event.preventDefault();
void handleSave();
};
return (
<DialogWrapper
open
onOpenChange={(isOpen): void => {
if (!isOpen) {
onClose();
}
}}
title={SAVE_VIEW_MODAL_TITLE[mode]}
testId="save-view-modal"
footer={
<div className={styles.footer}>
<Button
variant="outlined"
color="secondary"
size="md"
prefix={<X size={14} />}
onClick={onClose}
testId="save-view-cancel"
>
Cancel
</Button>
<Button
color="primary"
size="md"
prefix={<Check size={14} />}
loading={isSaving}
disabled={!displayName}
onClick={(): void => {
void handleSave();
}}
testId="save-view-submit"
>
Save view
</Button>
</div>
}
>
<form className={styles.field} onSubmit={handleSubmit}>
<label htmlFor={NAME_INPUT_ID}>
<Typography.Text className={styles.label}>Enter view name</Typography.Text>
</label>
<Input
id={NAME_INPUT_ID}
placeholder="Eg. payments-view"
value={name}
onChange={(event): void => setName(event.target.value)}
autoFocus
autoComplete="off"
testId="save-view-name"
/>
</form>
</DialogWrapper>
);
}
export default SaveViewModal;

View File

@@ -3,59 +3,17 @@
align-items: center;
justify-content: space-between;
gap: var(--spacing-2);
padding: var(--spacing-2) var(--spacing-5);
padding: var(--spacing-2) var(--spacing-3);
border-bottom: 1px solid var(--l1-border);
color: var(--l1-foreground);
flex-shrink: 0;
&.isDirty {
background: linear-gradient(
to right,
var(--callout-warning-background),
transparent
);
}
}
.name {
display: inline-flex;
align-items: center;
gap: var(--spacing-3);
min-width: 0;
padding: var(--spacing-1) var(--spacing-4);
border-radius: var(--spacing-1);
background: var(--l3-background);
color: var(--l1-foreground);
font-size: var(--font-size-sm);
&.isDirty {
background: var(--callout-warning-background);
color: var(--callout-warning-title);
}
}
.nameText {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dot {
width: var(--spacing-3);
height: var(--spacing-3);
flex-shrink: 0;
border-radius: 50%;
background: var(--callout-warning-icon);
}
.skeleton {
flex: 1;
min-width: 0;
}
.actions {
display: flex;
align-items: center;
flex-shrink: 0;
gap: var(--spacing-1);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
}

View File

@@ -1,140 +1,39 @@
import { useCallback, useState } from 'react';
import cx from 'classnames';
import { Menu, Plus, Undo2, X } from '@signozhq/icons';
import { Skeleton } from '@signozhq/ui/skeleton';
import { toast } from '@signozhq/ui/sonner';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Menu } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { MY_VIEW_NAME, SAVED_VIEW_TOAST_POSITION } from './constants';
import { useActiveSavedView } from './hooks/useActiveSavedView';
import { useSavedViewActions } from './hooks/useSavedViewActions';
import SaveChangesMenu from './SaveChangesMenu';
import SavedViewsIconButton from './SavedViewsIconButton';
import SaveViewModal from './SaveViewModal';
import { SaveViewModalMode } from './types';
import styles from './SavedViewsHeader.module.scss';
// Temp Placeholder data till main functionality is plugged in
const PLACEHOLDER_VIEW_NAME = 'My view';
function SavedViewsHeader({
source,
onOpenViews,
}: {
source: SavedviewtypesSourceDTO;
// Absent while the list is on screen.
// Absent when the list is always on screen.
onOpenViews?: () => void;
}): JSX.Element {
const { view, isLoading, hasUnsavedChanges } = useActiveSavedView(source);
const { revertView, clearView, createView, updateView, isSaving } =
useSavedViewActions(source);
const [modalMode, setModalMode] = useState<SaveViewModalMode | null>(null);
const handleSave = useCallback(
async (displayName: string): Promise<boolean> => {
const id = await createView(displayName);
if (!id) {
return false;
}
toast.success('You have created a new view.', {
position: SAVED_VIEW_TOAST_POSITION,
...(onOpenViews && { action: { label: 'View', onClick: onOpenViews } }),
});
return true;
},
[createView, onOpenViews],
);
const handleUpdate = useCallback(async (): Promise<void> => {
if (view && (await updateView(view))) {
toast.success('View updated', { position: SAVED_VIEW_TOAST_POSITION });
}
}, [view, updateView]);
const openSaveAsNew = useCallback((): void => setModalMode('saveAsNew'), []);
const closeModal = useCallback((): void => setModalMode(null), []);
const name = view?.spec.displayName ?? MY_VIEW_NAME;
const isDirty = !!view && hasUnsavedChanges;
return (
<div
className={cx(styles.header, { [styles.isDirty]: isDirty })}
className={styles.header}
data-testid="saved-views-header"
data-source={source}
>
{isLoading ? (
<Skeleton.Input
active
size="small"
className={styles.skeleton}
testId="saved-views-loading"
/>
) : (
<TooltipSimple title={name}>
<span
className={cx(styles.name, { [styles.isDirty]: isDirty })}
data-testid="saved-views-name"
>
{isDirty && <span className={styles.dot} />}
<span className={styles.nameText}>{name}</span>
</span>
</TooltipSimple>
)}
<div className={styles.actions}>
{!view && (
<SavedViewsIconButton
title="Create new view"
icon={<Plus size={14} />}
disabled={isLoading}
onClick={(): void => setModalMode('create')}
testId="saved-views-create"
/>
)}
{view && !isDirty && (
<SavedViewsIconButton
title="Clear view"
icon={<Undo2 size={14} />}
onClick={clearView}
testId="saved-views-clear"
/>
)}
{view && isDirty && (
<>
<SaveChangesMenu
disabled={isSaving}
onSaveAsNew={openSaveAsNew}
onUpdate={(): void => {
void handleUpdate();
}}
/>
<SavedViewsIconButton
title="Discard changes"
icon={<X size={14} />}
color="warning"
disabled={isSaving}
onClick={(): void => revertView(view)}
testId="saved-views-discard"
/>
</>
)}
{!isDirty && onOpenViews && (
<SavedViewsIconButton
title="All views"
icon={<Menu size={14} />}
onClick={onOpenViews}
testId="saved-views-open"
/>
)}
</div>
{modalMode && (
<SaveViewModal
mode={modalMode}
isSaving={isSaving}
onClose={closeModal}
onSave={handleSave}
<Typography.Text className={styles.name}>
{PLACEHOLDER_VIEW_NAME}
</Typography.Text>
{onOpenViews && (
<Button
variant="ghost"
color="secondary"
size="icon"
aria-label="All views"
prefix={<Menu size={14} />}
onClick={onOpenViews}
data-testid="saved-views-open"
/>
)}
</div>

View File

@@ -1,41 +0,0 @@
import { forwardRef } from 'react';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { SavedViewsIconButtonProps } from './types';
// forwardRef and rest props so a menu can use it as its trigger.
const SavedViewsIconButton = forwardRef<
HTMLButtonElement,
SavedViewsIconButtonProps
>(function SavedViewsIconButton(
{
title,
icon,
testId,
color = 'secondary',
disabled,
onClick,
...triggerProps
},
ref,
): JSX.Element {
return (
<TooltipSimple title={title}>
<Button
ref={ref}
variant="ghost"
color={color}
size="icon"
aria-label={title}
prefix={icon}
disabled={disabled}
onClick={onClick}
{...triggerProps}
data-testid={testId}
/>
</TooltipSimple>
);
});
export default SavedViewsIconButton;

View File

@@ -1,17 +0,0 @@
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { useRestoreLastUsedView } from './hooks/useRestoreLastUsedView';
import { useSavedViewActions } from './hooks/useSavedViewActions';
// Outside the sidebar, so a collapsed sidebar still restores on a bare explorer.
function SavedViewsRestore({
source,
}: {
source: SavedviewtypesSourceDTO;
}): null {
const { selectView } = useSavedViewActions(source);
useRestoreLastUsedView({ source, selectView });
return null;
}
export default SavedViewsRestore;

View File

@@ -1,211 +0,0 @@
import { act, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { toast } from '@signozhq/ui/sonner';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { DataSource } from 'types/common/queryBuilder';
import SavedViewsHeader from '../SavedViewsHeader';
import {
explorerUrl,
makeView,
mockSavedViewsApi,
queryWith,
renderWithExplorerProviders,
urlParam,
viewUrl,
} from './savedViewsTestUtils';
jest.mock('@signozhq/ui/sonner', () => ({
...jest.requireActual('@signozhq/ui/sonner'),
toast: { success: jest.fn(), error: jest.fn() },
}));
const PATH = ROUTES.TRACES_EXPLORER;
function renderHeader(
url: string,
): ReturnType<typeof renderWithExplorerProviders> {
return renderWithExplorerProviders(
<SavedViewsHeader
source={SavedviewtypesSourceDTO.traces}
onOpenViews={jest.fn()}
/>,
url,
);
}
const modal = (): HTMLElement => screen.getByTestId('save-view-modal');
const isModalOpen = (): boolean => !!screen.queryByTestId('save-view-modal');
async function openDirtyView(
view: ReturnType<typeof makeView>,
): Promise<ReturnType<typeof renderWithExplorerProviders>> {
const rendered = renderHeader(viewUrl(PATH, view));
await waitFor(() =>
expect(screen.getByTestId('saved-views-name')).toHaveTextContent(
view.spec.displayName,
),
);
await waitFor(() =>
expect(screen.getByTestId('saved-views-clear')).toBeInTheDocument(),
);
act(() => {
rendered.history.push(
explorerUrl(PATH, {
query: queryWith(DataSource.TRACES, 'has_error = false'),
panelType: PANEL_TYPES.LIST,
viewKey: view.id,
}),
);
});
await waitFor(() =>
expect(screen.getByTestId('saved-views-discard')).toBeInTheDocument(),
);
return rendered;
}
async function chooseFromSaveMenu(item: string): Promise<void> {
await userEvent.click(screen.getByTestId('saved-views-save-changes'));
await userEvent.click(await screen.findByRole('menuitem', { name: item }));
}
async function submitName(name: string): Promise<void> {
await userEvent.type(screen.getByTestId('save-view-name'), name);
await userEvent.click(screen.getByTestId('save-view-submit'));
}
describe('SavedViewsHeader saving', () => {
beforeEach(() => {
localStorage.clear();
});
describe('create', () => {
it('saves the current state under a generated name and opens it', async () => {
const requests = mockSavedViewsApi([], { createdId: 'view-new' });
const { history } = renderHeader(explorerUrl(PATH, {}));
await userEvent.click(screen.getByTestId('saved-views-create'));
expect(within(modal()).getByText('Create new view')).toBeInTheDocument();
expect(screen.getByTestId('save-view-submit')).toBeDisabled();
await submitName('Slow checkout');
await waitFor(() => expect(requests.created).toHaveLength(1));
expect(requests.created[0]).toStrictEqual(
expect.objectContaining({
generateName: true,
source: SavedviewtypesSourceDTO.traces,
schemaVersion: 'v2',
spec: expect.objectContaining({
displayName: 'Slow checkout',
panelType: 'list',
}),
}),
);
await waitFor(() =>
expect(urlParam(history, QueryParams.viewKey)).toBe(
JSON.stringify('view-new'),
),
);
expect(toast.success).toHaveBeenCalledWith(
'You have created a new view.',
expect.objectContaining({ position: 'top-right' }),
);
await waitFor(() => expect(isModalOpen()).toBe(false));
await waitFor(() =>
expect(screen.getByTestId('saved-views-name')).toHaveTextContent(
'Slow checkout',
),
);
});
it('keeps the modal open and reports a failed save', async () => {
const requests = mockSavedViewsApi([], { createStatus: 500 });
const { history } = renderHeader(explorerUrl(PATH, {}));
await userEvent.click(screen.getByTestId('saved-views-create'));
await submitName('Slow checkout');
await waitFor(() => expect(toast.error).toHaveBeenCalled());
expect(requests.created).toHaveLength(1);
expect(isModalOpen()).toBe(true);
expect(screen.getByTestId('save-view-name')).toHaveValue('Slow checkout');
expect(urlParam(history, QueryParams.viewKey)).toBeNull();
expect(toast.success).not.toHaveBeenCalled();
});
it('cancel closes the modal without saving', async () => {
const requests = mockSavedViewsApi([]);
renderHeader(explorerUrl(PATH, {}));
await userEvent.click(screen.getByTestId('saved-views-create'));
await userEvent.type(screen.getByTestId('save-view-name'), 'Draft');
await userEvent.click(screen.getByTestId('save-view-cancel'));
await waitFor(() => expect(isModalOpen()).toBe(false));
expect(requests.created).toHaveLength(0);
});
});
it('save as new creates another view from the changed state', async () => {
const errors = makeView({ id: 'view-1', displayName: 'Errors' });
const requests = mockSavedViewsApi([errors], { createdId: 'view-copy' });
const { history } = await openDirtyView(errors);
await chooseFromSaveMenu('Save as new view');
expect(within(modal()).getByText('Save as new view')).toBeInTheDocument();
await submitName('Errors copy');
await waitFor(() => expect(requests.created).toHaveLength(1));
const body = requests.created[0] as {
spec: {
displayName: string;
queries: { spec: { filter: { expression: string } } }[];
};
};
expect(body.spec.displayName).toBe('Errors copy');
expect(body.spec.queries[0].spec.filter.expression).toBe('has_error = false');
expect(requests.updated).toHaveLength(0);
await waitFor(() =>
expect(urlParam(history, QueryParams.viewKey)).toBe(
JSON.stringify('view-copy'),
),
);
});
it('update stores the changed state on the open view, keeping its name', async () => {
const errors = makeView({ id: 'view-1', displayName: 'Errors' });
const requests = mockSavedViewsApi([errors]);
await openDirtyView(errors);
await chooseFromSaveMenu('Update selected view');
await waitFor(() => expect(requests.updated).toHaveLength(1));
const { id, body } = requests.updated[0] as {
id: string;
body: {
source: string;
spec: {
displayName: string;
queries: { spec: { filter: { expression: string } } }[];
};
};
};
expect(id).toBe('view-1');
expect(body.source).toBe(SavedviewtypesSourceDTO.traces);
expect(body.spec.displayName).toBe('Errors');
expect(body.spec.queries[0].spec.filter.expression).toBe('has_error = false');
await waitFor(() =>
expect(toast.success).toHaveBeenCalledWith(
'View updated',
expect.objectContaining({ position: 'top-right' }),
),
);
expect(requests.created).toHaveLength(0);
});
});

View File

@@ -1,327 +0,0 @@
import { act, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { LOCALSTORAGE } from 'constants/localStorage';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { DataSource } from 'types/common/queryBuilder';
import { useActiveSavedView } from '../hooks/useActiveSavedView';
import SavedViewsHeader from '../SavedViewsHeader';
import SavedViewsRestore from '../SavedViewsRestore';
import {
explorerUrl,
makeView,
mockSavedViewsApi,
queryWith,
renderWithExplorerProviders,
urlParam,
viewUrl,
} from './savedViewsTestUtils';
const PATH = ROUTES.TRACES_EXPLORER;
const errors = makeView({ id: 'view-1', displayName: 'Errors' });
const slow = makeView({
id: 'view-2',
displayName: 'Slow spans',
expression: 'duration_nano > 1000',
panelType: PANEL_TYPES.TIME_SERIES,
});
function renderHeader(
url: string,
onOpenViews: (() => void) | undefined = jest.fn(),
): ReturnType<typeof renderWithExplorerProviders> {
return renderWithExplorerProviders(
<>
<SavedViewsRestore source={SavedviewtypesSourceDTO.traces} />
<SavedViewsHeader
source={SavedviewtypesSourceDTO.traces}
onOpenViews={onOpenViews}
/>
<RenderRecorder />
</>,
url,
);
}
const chip = (): HTMLElement => screen.getByTestId('saved-views-name');
const isDirty = (): boolean => !!screen.queryByTestId('saved-views-discard');
const saveChanges = (): HTMLElement =>
screen.getByTestId('saved-views-save-changes');
async function waitForView(name: string): Promise<void> {
await waitFor(() => expect(chip()).toHaveTextContent(name));
await waitFor(() =>
expect(screen.getByTestId('saved-views-clear')).toBeInTheDocument(),
);
}
// Each render's answer from the hook the header reads, so a state that lasts a
// single render is seen too.
interface RenderRecord {
viewId?: string;
dirty: boolean;
// The staged query had not caught up with the url yet.
isStaging: boolean;
}
let renders: RenderRecord[] = [];
function RenderRecorder(): null {
const { view, hasUnsavedChanges } = useActiveSavedView(
SavedviewtypesSourceDTO.traces,
);
const { stagedQuery } = useQueryBuilder();
const compositeQuery = useGetCompositeQueryParam();
renders.push({
viewId: view?.id,
dirty: hasUnsavedChanges,
isStaging: stagedQuery?.id !== compositeQuery?.id,
});
return null;
}
async function recordRenders(change: () => void): Promise<RenderRecord[]> {
renders = [];
act(change);
await waitFor(() =>
expect(screen.queryByTestId('saved-views-loading')).toBeNull(),
);
await act(() => new Promise((resolve) => setTimeout(resolve, 100)));
return renders;
}
describe('SavedViewsHeader', () => {
beforeEach(() => {
localStorage.clear();
});
it('shows My view with create and all views when no view is open', async () => {
mockSavedViewsApi([errors]);
renderHeader(explorerUrl(PATH, {}));
expect(chip()).toHaveTextContent('My view');
expect(screen.getByTestId('saved-views-create')).toBeInTheDocument();
expect(screen.getByTestId('saved-views-open')).toBeInTheDocument();
expect(screen.queryByTestId('saved-views-clear')).toBeNull();
expect(screen.queryByTestId('saved-views-save-changes')).toBeNull();
expect(screen.queryByTestId('saved-views-discard')).toBeNull();
});
it('shows a skeleton while the view in the url loads', async () => {
mockSavedViewsApi([errors], { getDelay: 150 });
renderHeader(viewUrl(PATH, errors));
expect(screen.getByTestId('saved-views-loading')).toBeInTheDocument();
await waitForView('Errors');
expect(screen.queryByTestId('saved-views-loading')).toBeNull();
});
it('shows the open view clean, with clear and all views', async () => {
mockSavedViewsApi([errors]);
renderHeader(viewUrl(PATH, errors));
await waitForView('Errors');
expect(screen.getByTestId('saved-views-open')).toBeInTheDocument();
expect(screen.queryByTestId('saved-views-create')).toBeNull();
expect(isDirty()).toBe(false);
});
it('falls back to My view when the view in the url does not load', async () => {
mockSavedViewsApi([]);
renderHeader(viewUrl(PATH, errors));
await waitFor(() =>
expect(screen.queryByTestId('saved-views-loading')).toBeNull(),
);
expect(chip()).toHaveTextContent('My view');
});
it('marks the view dirty once the query changes, swapping in save and discard', async () => {
mockSavedViewsApi([errors]);
const { history } = renderHeader(viewUrl(PATH, errors));
await waitForView('Errors');
act(() => {
history.push(
explorerUrl(PATH, {
query: queryWith(DataSource.TRACES, 'has_error = false'),
panelType: PANEL_TYPES.LIST,
viewKey: errors.id,
}),
);
});
await waitFor(() => expect(isDirty()).toBe(true));
expect(saveChanges()).toBeInTheDocument();
expect(screen.queryByTestId('saved-views-clear')).toBeNull();
expect(screen.queryByTestId('saved-views-open')).toBeNull();
});
it('marks the view dirty when only the tab changes', async () => {
mockSavedViewsApi([errors]);
const { history } = renderHeader(viewUrl(PATH, errors));
await waitForView('Errors');
act(() => {
const params = new URLSearchParams(history.location.search);
params.set(QueryParams.panelTypes, JSON.stringify(PANEL_TYPES.TABLE));
history.push(`${PATH}?${params.toString()}`);
});
await waitFor(() => expect(isDirty()).toBe(true));
});
it('discard puts the saved query and tab back', async () => {
mockSavedViewsApi([slow]);
const { history } = renderHeader(viewUrl(PATH, slow));
await waitForView('Slow spans');
act(() => {
history.push(
explorerUrl(PATH, {
query: queryWith(DataSource.TRACES, 'has_error = true'),
panelType: PANEL_TYPES.LIST,
viewKey: slow.id,
}),
);
});
await waitFor(() => expect(isDirty()).toBe(true));
await userEvent.click(screen.getByTestId('saved-views-discard'));
await waitFor(() => expect(isDirty()).toBe(false));
expect(urlParam(history, QueryParams.panelTypes)).toBe(
JSON.stringify(PANEL_TYPES.TIME_SERIES),
);
expect(urlParam(history, QueryParams.viewKey)).toBe(JSON.stringify(slow.id));
expect(urlParam(history, QueryParams.viewName)).toBeNull();
const query = JSON.parse(
decodeURIComponent(urlParam(history, QueryParams.compositeQuery) ?? ''),
);
expect(query.builder.queryData[0].filter.expression).toBe(
'duration_nano > 1000',
);
});
it('clear drops the view from the url but keeps the time range', async () => {
localStorage.setItem(
LOCALSTORAGE.LAST_USED_SAVED_VIEWS,
JSON.stringify({ traces: { key: errors.id, value: 'Errors' } }),
);
mockSavedViewsApi([errors]);
const { history } = renderHeader(viewUrl(PATH, errors, '1h'));
await waitForView('Errors');
await userEvent.click(screen.getByTestId('saved-views-clear'));
await waitFor(() => expect(chip()).toHaveTextContent('My view'));
expect(urlParam(history, QueryParams.viewKey)).toBeNull();
expect(urlParam(history, QueryParams.panelTypes)).toBeNull();
expect(urlParam(history, QueryParams.relativeTime)).toBe('1h');
expect(
JSON.parse(localStorage.getItem(LOCALSTORAGE.LAST_USED_SAVED_VIEWS) ?? '{}'),
).toStrictEqual({});
});
describe('last used view', () => {
beforeEach(() => {
localStorage.setItem(
LOCALSTORAGE.LAST_USED_SAVED_VIEWS,
JSON.stringify({ traces: { key: errors.id, value: 'Errors' } }),
);
});
it('reopens on a bare explorer', async () => {
mockSavedViewsApi([errors]);
const { history } = renderHeader(explorerUrl(PATH, {}));
await waitForView('Errors');
expect(urlParam(history, QueryParams.viewKey)).toBe(
JSON.stringify(errors.id),
);
});
it('leaves a url with its own query alone', async () => {
mockSavedViewsApi([errors]);
const { history } = renderHeader(
explorerUrl(PATH, {
query: queryWith(DataSource.TRACES, 'service.name = "cart"'),
}),
);
await act(() => new Promise((resolve) => setTimeout(resolve, 200)));
expect(chip()).toHaveTextContent('My view');
expect(urlParam(history, QueryParams.viewKey)).toBeNull();
});
});
describe('unsaved changes indicator', () => {
it('never flashes while switching between views already loaded', async () => {
mockSavedViewsApi([errors, slow]);
const { history } = renderHeader(viewUrl(PATH, slow));
await waitForView('Slow spans');
act(() => history.push(viewUrl(PATH, errors)));
await waitForView('Errors');
const states = await recordRenders(() => history.push(viewUrl(PATH, slow)));
expect(chip()).toHaveTextContent('Slow spans');
expect(states.some((render) => render.isStaging)).toBe(true);
expect(states.filter((render) => render.dirty)).toStrictEqual([]);
});
it('holds while the query keeps changing on a dirty view', async () => {
mockSavedViewsApi([errors]);
const { history } = renderHeader(viewUrl(PATH, errors));
await waitForView('Errors');
act(() => {
history.push(
explorerUrl(PATH, {
query: queryWith(DataSource.TRACES, 'has_error = false'),
panelType: PANEL_TYPES.LIST,
viewKey: errors.id,
}),
);
});
await waitFor(() => expect(isDirty()).toBe(true));
const states = await recordRenders(() =>
history.push(
explorerUrl(PATH, {
query: queryWith(DataSource.TRACES, 'duration_nano > 5'),
panelType: PANEL_TYPES.LIST,
viewKey: errors.id,
}),
),
);
expect(states.some((render) => render.isStaging)).toBe(true);
expect(states.filter((render) => !render.dirty)).toStrictEqual([]);
});
});
it('on metrics compares no columns and has no all views button', async () => {
const cpu = makeView({
id: 'view-3',
displayName: 'CPU',
source: SavedviewtypesSourceDTO.metrics,
dataSource: DataSource.METRICS,
expression: '',
panelType: PANEL_TYPES.TIME_SERIES,
});
mockSavedViewsApi([cpu]);
renderWithExplorerProviders(
<SavedViewsHeader source={SavedviewtypesSourceDTO.metrics} />,
viewUrl(ROUTES.METRICS_EXPLORER_EXPLORER, cpu),
DataSource.METRICS,
);
await waitForView('CPU');
expect(isDirty()).toBe(false);
expect(screen.queryByTestId('saved-views-open')).toBeNull();
});
});

View File

@@ -1,304 +0,0 @@
import { ReactElement, useEffect, useMemo } from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { Provider } from 'react-redux';
import { Router } from 'react-router-dom';
import { CompatRouter } from 'react-router-dom-v5-compat';
import { render, RenderResult } from '@testing-library/react';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import { safeNavigateMock } from '__tests__/safeNavigateMock';
import {
SavedviewtypesSavedViewDTO,
SavedviewtypesSavedViewSpecDTO,
SavedviewtypesSchemaVersionDTO,
SavedviewtypesSourceDTO,
} from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { createMemoryHistory, MemoryHistory } from 'history';
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { NuqsAdapter } from 'nuqs/adapters/react';
import { AppContext } from 'providers/App/App';
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
import { QueryBuilderProvider } from 'providers/QueryBuilder';
import configureStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import store from 'store';
import { getAppContextMock } from 'tests/test-utils';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { __setSearchParamsGetterForTest } from 'utils/getUnstableCurrentSearchParams';
import { getSavedViewQuery } from '../utils/getSavedViewQuery';
import { toSavedViewSpec } from '../utils/toSavedViewSpec';
export const API = 'http://localhost/api/v2/saved_views';
// The server stores queries through typed structs: nulls, empty strings,
// zeros and empty lists do not come back.
function asStoredByServer(value: unknown): unknown {
if (Array.isArray(value)) {
const items = value.map(asStoredByServer).filter((v) => v !== undefined);
return items.length > 0 ? items : undefined;
}
if (value !== null && typeof value === 'object') {
return Object.fromEntries(
Object.entries(value)
.map(([key, v]) => [key, asStoredByServer(v)] as const)
.filter(([, v]) => v !== undefined),
);
}
return value === null || value === '' || value === 0 ? undefined : value;
}
export function queryWith(dataSource: DataSource, expression: string): Query {
const base = initialQueriesMap[dataSource];
return {
...base,
builder: {
...base.builder,
queryData: [
{
...base.builder.queryData[0],
filter: { expression },
aggregations: [{ expression: 'count()' }],
},
],
},
} as Query;
}
export function makeView({
id,
displayName,
source = SavedviewtypesSourceDTO.traces,
dataSource = DataSource.TRACES,
expression = 'has_error = true',
panelType = PANEL_TYPES.LIST,
}: {
id: string;
displayName: string;
source?: SavedviewtypesSourceDTO;
dataSource?: DataSource;
expression?: string;
panelType?: PANEL_TYPES;
}): SavedviewtypesSavedViewDTO {
const spec = toSavedViewSpec({
query: queryWith(dataSource, expression),
panelType,
displayName,
});
return {
id,
name: `${displayName.toLowerCase()}-abc`,
source,
schemaVersion: SavedviewtypesSchemaVersionDTO.v2,
createdBy: 'test@signoz.io',
updatedBy: 'test@signoz.io',
spec: {
...spec,
queries: asStoredByServer(
JSON.parse(JSON.stringify(spec.queries)),
) as SavedviewtypesSavedViewSpecDTO['queries'],
},
} as SavedviewtypesSavedViewDTO;
}
// Params written the way the query builder redirect writes them.
export function explorerUrl(
path: string,
{
query,
panelType,
viewKey,
relativeTime = '15m',
}: {
query?: Query;
panelType?: PANEL_TYPES;
viewKey?: string;
relativeTime?: string;
},
): string {
const params = new URLSearchParams();
if (query) {
params.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify({ ...query, id: `url-${Math.random()}` })),
);
}
if (panelType) {
params.set(QueryParams.panelTypes, JSON.stringify(panelType));
}
if (viewKey) {
params.set(QueryParams.viewKey, JSON.stringify(viewKey));
}
params.set(QueryParams.relativeTime, relativeTime);
return `${path}?${params.toString()}`;
}
export function viewUrl(
path: string,
view: SavedviewtypesSavedViewDTO,
relativeTime?: string,
): string {
return explorerUrl(path, {
query: getSavedViewQuery(view),
panelType: view.spec.panelType as unknown as PANEL_TYPES,
viewKey: view.id,
relativeTime,
});
}
export interface Requests {
created: unknown[];
updated: { id: string; body: unknown }[];
}
export function mockSavedViewsApi(
views: SavedviewtypesSavedViewDTO[],
{
createStatus = 201,
createdId = 'view-new',
getDelay = 0,
}: { createStatus?: number; createdId?: string; getDelay?: number } = {},
): Requests {
const requests: Requests = { created: [], updated: [] };
server.use(
rest.get(API, (req, res, ctx) => {
const source = req.url.searchParams.get('source');
return res(
ctx.status(200),
ctx.json({
status: 'success',
data: views.filter((view) => !source || view.source === source),
}),
);
}),
rest.get(`${API}/:id`, (req, res, ctx) => {
const view = views.find((v) => v.id === req.params.id);
if (!view) {
return res(ctx.status(404), ctx.json({ status: 'error' }));
}
return res(
ctx.delay(getDelay),
ctx.status(200),
ctx.json({ status: 'success', data: view }),
);
}),
rest.post(API, async (req, res, ctx) => {
const body = await req.json();
requests.created.push(body);
if (createStatus >= 400) {
return res(
ctx.status(createStatus),
ctx.json({
status: 'error',
error: { code: 'internal', message: 'boom' },
}),
);
}
views.push({ ...body, id: createdId } as SavedviewtypesSavedViewDTO);
return res(
ctx.status(createStatus),
ctx.json({ status: 'success', data: { id: createdId } }),
);
}),
rest.put(`${API}/:id`, async (req, res, ctx) => {
const body = await req.json();
requests.updated.push({ id: String(req.params.id), body });
return res(ctx.status(204));
}),
);
return requests;
}
const mockStore = configureStore([thunk]);
// What the explorer page does around the header: writes its default query to a
// bare url, and sets the provider's tab from the url (the provider alone seeds
// it from the raw, still JSON-quoted param).
function ExplorerShell({ dataSource }: { dataSource: DataSource }): null {
const panelType = useGetPanelTypesQueryParam(PANEL_TYPES.LIST);
const { handleSetConfig, updateAllQueriesOperators } = useQueryBuilder();
const defaultQuery = useMemo(
() =>
updateAllQueriesOperators(
initialQueriesMap[dataSource],
PANEL_TYPES.LIST,
dataSource,
),
[updateAllQueriesOperators, dataSource],
);
useShareBuilderUrl({ defaultValue: defaultQuery });
useEffect(() => {
handleSetConfig(panelType, dataSource);
}, [panelType, dataSource, handleSetConfig]);
return null;
}
export function renderWithExplorerProviders(
ui: ReactElement,
initialUrl: string,
dataSource: DataSource = DataSource.TRACES,
): RenderResult & { history: MemoryHistory } {
const history = createMemoryHistory({ initialEntries: [initialUrl] });
// Parts of the explorer read window.location directly, so it follows the
// router's history.
const syncWindowLocation = (): void =>
window.history.replaceState(
null,
'',
`${history.location.pathname}${history.location.search}`,
);
syncWindowLocation();
history.listen(syncWindowLocation);
__setSearchParamsGetterForTest(
() => new URLSearchParams(history.location.search),
);
safeNavigateMock.mockImplementation((to) => {
history.push(
typeof to === 'string'
? to
: `${to.pathname ?? history.location.pathname}${to.search ?? ''}`,
);
});
const queryClient = new QueryClient({
defaultOptions: {
queries: { refetchOnWindowFocus: false, retry: false },
mutations: { retry: false },
},
});
const result = render(
<Router history={history}>
<CompatRouter>
<NuqsAdapter>
<QueryClientProvider client={queryClient}>
<Provider store={mockStore(store.getState())}>
<AppContext.Provider value={getAppContextMock('ADMIN')}>
<TooltipProvider>
<PreferenceContextProvider>
<QueryBuilderProvider>
<ExplorerShell dataSource={dataSource} />
{ui}
</QueryBuilderProvider>
</PreferenceContextProvider>
</TooltipProvider>
</AppContext.Provider>
</Provider>
</QueryClientProvider>
</NuqsAdapter>
</CompatRouter>
</Router>,
);
return { ...result, history };
}
export function urlParam(history: MemoryHistory, key: string): string | null {
return new URLSearchParams(history.location.search).get(key);
}

View File

@@ -8,7 +8,7 @@ import { PANEL_TYPES } from 'constants/queryBuilder';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
import { getSavedViewQuery } from '../getSavedViewQuery';
import { findSavedView, getSavedViewQuery, toSavedViewSource } from '../utils';
jest.mock('uuid', () => ({
v4: (): string => 'test-id',
@@ -91,3 +91,36 @@ describe('getSavedViewQuery', () => {
expect(query).not.toHaveProperty('panelType', PANEL_TYPES.TIME_SERIES);
});
});
describe('toSavedViewSource', () => {
it('maps every explorer source page to the v2 source', () => {
expect(toSavedViewSource(DataSource.LOGS)).toBe(SavedviewtypesSourceDTO.logs);
expect(toSavedViewSource(DataSource.TRACES)).toBe(
SavedviewtypesSourceDTO.traces,
);
expect(toSavedViewSource(DataSource.METRICS)).toBe(
SavedviewtypesSourceDTO.metrics,
);
expect(toSavedViewSource('meter')).toBe(SavedviewtypesSourceDTO.meter);
});
});
describe('findSavedView', () => {
const views = [
{ ...makeView(), id: 'a' },
{ ...makeView(), id: 'b' },
];
it('returns the view with the matching id', () => {
expect(findSavedView(views, 'b')?.id).toBe('b');
});
it('returns undefined when the id is not in the list', () => {
expect(findSavedView(views, 'c')).toBeUndefined();
});
it('returns undefined for a null or not yet loaded list', () => {
expect(findSavedView(null, 'a')).toBeUndefined();
expect(findSavedView(undefined, 'a')).toBeUndefined();
});
});

View File

@@ -1,35 +0,0 @@
import {
SavedviewtypesSchemaVersionDTO,
SavedviewtypesSourceDTO,
} from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import { DataSource } from 'types/common/queryBuilder';
import { SaveViewModalMode } from './types';
export const SAVED_VIEW_SCHEMA_VERSION = SavedviewtypesSchemaVersionDTO.v2;
// Only the logs and traces explorers have columns and formatting to save.
export const SAVED_VIEW_OPTIONS_DATA_SOURCE: Partial<
Record<SavedviewtypesSourceDTO, DataSource>
> = {
[SavedviewtypesSourceDTO.logs]: DataSource.LOGS,
[SavedviewtypesSourceDTO.traces]: DataSource.TRACES,
};
// What selecting a view writes to the url, so clearing it removes exactly these.
export const SAVED_VIEW_URL_PARAMS = [
QueryParams.viewKey,
QueryParams.viewName,
QueryParams.compositeQuery,
QueryParams.panelTypes,
];
export const MY_VIEW_NAME = 'My view';
export const SAVE_VIEW_MODAL_TITLE: Record<SaveViewModalMode, string> = {
create: 'Create new view',
saveAsNew: 'Save as new view',
};
export const SAVED_VIEW_TOAST_POSITION = 'top-right' as const;

View File

@@ -1,76 +0,0 @@
import { useEffect, useMemo, useRef } from 'react';
import { useGetSavedView } from 'api/generated/services/saved-view';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import useOptionsMenu from 'container/OptionsMenu/useOptionsMenu';
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
import { DataSource } from 'types/common/queryBuilder';
import { SAVED_VIEW_OPTIONS_DATA_SOURCE } from '../constants';
import { UseActiveSavedViewResult } from '../types';
import { hasUnsavedViewChanges } from '../utils/hasUnsavedViewChanges';
export function useActiveSavedView(
source: SavedviewtypesSourceDTO,
): UseActiveSavedViewResult {
const { viewKey } = useGetSavedViewParams();
const { stagedQuery, panelType } = useQueryBuilder();
const optionsDataSource = SAVED_VIEW_OPTIONS_DATA_SOURCE[source];
const { options } = useOptionsMenu({
dataSource: optionsDataSource ?? DataSource.LOGS,
});
const { data, isLoading, isError } = useGetSavedView(
{ id: viewKey },
{ query: { enabled: !!viewKey } },
);
const view = viewKey ? data?.data : undefined;
// The query builder stages the url query a render later. Until the ids
// match, keep the last answer for this view; another view starts clean.
const compositeQuery = useGetCompositeQueryParam();
const isStagedQueryCurrent =
!!stagedQuery && stagedQuery.id === compositeQuery?.id;
const lastResult = useRef<{ viewId?: string; hasUnsavedChanges: boolean }>({
hasUnsavedChanges: false,
});
const hasUnsavedChanges = useMemo((): boolean => {
if (!view) {
return false;
}
if (!isStagedQueryCurrent) {
return (
lastResult.current.viewId === view.id &&
lastResult.current.hasUnsavedChanges
);
}
return hasUnsavedViewChanges({
view,
stagedQuery,
panelType: panelType ?? PANEL_TYPES.LIST,
options: optionsDataSource ? options : undefined,
});
}, [
view,
isStagedQueryCurrent,
stagedQuery,
panelType,
optionsDataSource,
options,
]);
useEffect(() => {
lastResult.current = { viewId: view?.id, hasUnsavedChanges };
}, [view?.id, hasUnsavedChanges]);
return {
view,
isLoading: !!viewKey && isLoading,
isError: !!viewKey && isError,
hasUnsavedChanges,
};
}

View File

@@ -1,55 +0,0 @@
import { useCallback } from 'react';
import getLocalStorageKey from 'api/browser/localstorage/get';
import setLocalStorageKey from 'api/browser/localstorage/set';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { LOCALSTORAGE } from 'constants/localStorage';
import {
PreservedViewsInLocalStorage,
PreservedViewType,
} from 'container/ExplorerOptions/types';
import { UseLastUsedViewResult } from '../types';
function readLastUsedViews(): PreservedViewsInLocalStorage {
try {
return JSON.parse(
getLocalStorageKey(LOCALSTORAGE.LAST_USED_SAVED_VIEWS) || '{}',
) as PreservedViewsInLocalStorage;
} catch {
return {};
}
}
// Read at call time, not held in state: the hook is used from more than one
// place and a cached copy would bring back a view another caller just cleared.
export function useLastUsedView(
savedViewSource: SavedviewtypesSourceDTO,
): UseLastUsedViewResult {
// The bar's enum carries the same four values as the v2 source.
const source = savedViewSource as unknown as PreservedViewType;
const getLastUsedViewKey = useCallback(
(): string | undefined => readLastUsedViews()[source]?.key,
[source],
);
const setLastUsedView = useCallback(
(id: string, displayName: string): void => {
setLocalStorageKey(
LOCALSTORAGE.LAST_USED_SAVED_VIEWS,
JSON.stringify({
...readLastUsedViews(),
[source]: { key: id, value: displayName },
}),
);
},
[source],
);
const clearLastUsedView = useCallback((): void => {
const { [source]: _removed, ...rest } = readLastUsedViews();
setLocalStorageKey(LOCALSTORAGE.LAST_USED_SAVED_VIEWS, JSON.stringify(rest));
}, [source]);
return { getLastUsedViewKey, setLastUsedView, clearLastUsedView };
}

View File

@@ -1,56 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { useGetSavedView } from 'api/generated/services/saved-view';
import { QueryParams } from 'constants/query';
import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
import { UseRestoreLastUsedViewArgs } from '../types';
import { useLastUsedView } from './useLastUsedView';
export function useRestoreLastUsedView({
source,
selectView,
}: UseRestoreLastUsedViewArgs): void {
const { viewKey } = useGetSavedViewParams();
const { getLastUsedViewKey, clearLastUsedView } = useLastUsedView(source);
// Decided on the first render, before the explorer writes its default query
// to the url; later an empty url means the view was cleared.
const [lastUsedViewKey] = useState(() => {
const params = getUnstableCurrentSearchParams();
const isBare =
!params.get(QueryParams.viewKey) && !params.get(QueryParams.compositeQuery);
return isBare ? getLastUsedViewKey() : undefined;
});
const hasRestored = useRef(false);
const shouldRestore = !!lastUsedViewKey && !viewKey && !hasRestored.current;
const { data, isError } = useGetSavedView(
{ id: lastUsedViewKey ?? '' },
{ query: { enabled: shouldRestore } },
);
const view = data?.data;
useEffect(() => {
if (!shouldRestore) {
return undefined;
}
if (isError) {
hasRestored.current = true;
clearLastUsedView();
return undefined;
}
if (!view) {
return undefined;
}
// Deferred like the bar's restore, so it lands after the explorer has
// written its default query to the url.
const timeoutId = setTimeout(() => {
hasRestored.current = true;
selectView(view);
}, 0);
return (): void => clearTimeout(timeoutId);
}, [shouldRestore, isError, view, clearLastUsedView, selectView]);
}

View File

@@ -1,193 +0,0 @@
import { useCallback } from 'react';
import { useQueryClient } from 'react-query';
import { useLocation } from 'react-router-dom';
import { toast } from '@signozhq/ui/sonner';
import {
invalidateGetSavedView,
invalidateListSavedViews,
useCreateSavedView,
useUpdateSavedView,
} from 'api/generated/services/saved-view';
import {
RenderErrorResponseDTO,
SavedviewtypesSavedViewDTO,
SavedviewtypesSavedViewSpecDTO,
SavedviewtypesSourceDTO,
} from 'api/generated/services/sigNoz.schemas';
import { ErrorType } from 'api/generatedAPIInstance';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import useOptionsMenu from 'container/OptionsMenu/useOptionsMenu';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useHandleExplorerTabChange } from 'hooks/useHandleExplorerTabChange';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { DataSource } from 'types/common/queryBuilder';
import { toAPIError } from 'utils/errorUtils';
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
import {
SAVED_VIEW_OPTIONS_DATA_SOURCE,
SAVED_VIEW_SCHEMA_VERSION,
SAVED_VIEW_TOAST_POSITION,
SAVED_VIEW_URL_PARAMS,
} from '../constants';
import { UseSavedViewActionsResult } from '../types';
import { getSavedViewQuery } from '../utils/getSavedViewQuery';
import { toSavedViewSpec } from '../utils/toSavedViewSpec';
import { useLastUsedView } from './useLastUsedView';
// Columns and formatting need no handling here: with a viewKey in the url the
// preference provider reads them from the view, and without one it falls back
// to the user's own.
export function useSavedViewActions(
source: SavedviewtypesSourceDTO,
): UseSavedViewActionsResult {
const queryClient = useQueryClient();
const { pathname } = useLocation();
const { safeNavigate } = useSafeNavigate();
const { currentQuery, stagedQuery, panelType, redirectWithQueryBuilderData } =
useQueryBuilder();
const { handleExplorerTabChange } = useHandleExplorerTabChange();
const { setLastUsedView, clearLastUsedView } = useLastUsedView(source);
const optionsDataSource = SAVED_VIEW_OPTIONS_DATA_SOURCE[source];
const { options } = useOptionsMenu({
dataSource: optionsDataSource ?? DataSource.LOGS,
});
const { mutateAsync: createSavedView, isLoading: isCreating } =
useCreateSavedView();
const { mutateAsync: updateSavedView, isLoading: isUpdating } =
useUpdateSavedView();
const invalidateViews = useCallback(
async (id?: string): Promise<void> => {
await Promise.all([
invalidateListSavedViews(queryClient),
id ? invalidateGetSavedView(queryClient, { id }) : undefined,
// The saved views bar still lists through v1.
queryClient.invalidateQueries([{ sourcepage: source }]),
]);
},
[queryClient, source],
);
const toCurrentSpec = useCallback(
(displayName: string, color?: string): SavedviewtypesSavedViewSpecDTO =>
toSavedViewSpec({
query: stagedQuery ?? currentQuery,
panelType: panelType ?? PANEL_TYPES.LIST,
displayName,
options: optionsDataSource ? options : undefined,
color,
}),
[stagedQuery, currentQuery, panelType, optionsDataSource, options],
);
const selectView = useCallback(
(view: SavedviewtypesSavedViewDTO): void => {
handleExplorerTabChange(view.spec.panelType, {
query: getSavedViewQuery(view),
viewKey: view.id,
});
setLastUsedView(view.id, view.spec.displayName);
},
[handleExplorerTabChange, setLastUsedView],
);
// The preference provider re-reads a view's columns only when its list
// changes, so the list is refetched as the view is applied again.
const revertView = useCallback(
(view: SavedviewtypesSavedViewDTO): void => {
void invalidateListSavedViews(queryClient);
selectView(view);
},
[queryClient, selectView],
);
// The query builder redirects only ever add params, so the url is rebuilt
// here; the time range and everything else a view does not set stays.
const clearView = useCallback((): void => {
clearLastUsedView();
const params = getUnstableCurrentSearchParams();
SAVED_VIEW_URL_PARAMS.forEach((param) => params.delete(param));
safeNavigate(`${pathname}?${params.toString()}`);
}, [clearLastUsedView, pathname, safeNavigate]);
const createView = useCallback(
async (displayName: string): Promise<string | undefined> => {
try {
const { data } = await createSavedView({
data: {
generateName: true,
source,
schemaVersion: SAVED_VIEW_SCHEMA_VERSION,
spec: toCurrentSpec(displayName),
},
});
await invalidateViews();
redirectWithQueryBuilderData(stagedQuery ?? currentQuery, {
[QueryParams.viewKey]: data.id,
});
setLastUsedView(data.id, displayName);
return data.id;
} catch (error) {
toast.error(
toAPIError(
error as ErrorType<RenderErrorResponseDTO>,
'Could not save the view',
).getErrorMessage(),
{ position: SAVED_VIEW_TOAST_POSITION },
);
return undefined;
}
},
[
createSavedView,
source,
toCurrentSpec,
invalidateViews,
redirectWithQueryBuilderData,
stagedQuery,
currentQuery,
setLastUsedView,
],
);
const updateView = useCallback(
async (view: SavedviewtypesSavedViewDTO): Promise<boolean> => {
try {
await updateSavedView({
pathParams: { id: view.id },
data: {
source,
schemaVersion: SAVED_VIEW_SCHEMA_VERSION,
spec: toCurrentSpec(view.spec.displayName, view.spec.display?.color),
},
});
await invalidateViews(view.id);
return true;
} catch (error) {
toast.error(
toAPIError(
error as ErrorType<RenderErrorResponseDTO>,
'Could not update the view',
).getErrorMessage(),
{ position: SAVED_VIEW_TOAST_POSITION },
);
return false;
}
},
[updateSavedView, source, toCurrentSpec, invalidateViews],
);
return {
selectView,
revertView,
clearView,
createView,
updateView,
isSaving: isCreating || isUpdating,
};
}

View File

@@ -1,83 +0,0 @@
import { ReactElement } from 'react';
import {
SavedviewtypesSavedViewDTO,
SavedviewtypesSourceDTO,
} from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { OptionsQuery } from 'container/OptionsMenu/types';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
export type SavedViewSourcePage = DataSource | 'meter';
export type SavedViewOptions = Pick<
OptionsQuery,
'selectColumns' | 'format' | 'maxLines' | 'fontSize'
>;
export interface ToSavedViewSpecArgs {
query: Query;
panelType: PANEL_TYPES;
displayName: string;
options?: SavedViewOptions;
color?: string;
}
export interface HasUnsavedViewChangesArgs {
view: SavedviewtypesSavedViewDTO;
stagedQuery: Query | null;
panelType: PANEL_TYPES;
options?: SavedViewOptions;
}
export interface UseLastUsedViewResult {
getLastUsedViewKey: () => string | undefined;
setLastUsedView: (id: string, displayName: string) => void;
clearLastUsedView: () => void;
}
export interface UseActiveSavedViewResult {
view: SavedviewtypesSavedViewDTO | undefined;
isLoading: boolean;
isError: boolean;
hasUnsavedChanges: boolean;
}
export interface UseSavedViewActionsResult {
selectView: (view: SavedviewtypesSavedViewDTO) => void;
revertView: (view: SavedviewtypesSavedViewDTO) => void;
clearView: () => void;
createView: (displayName: string) => Promise<string | undefined>;
updateView: (view: SavedviewtypesSavedViewDTO) => Promise<boolean>;
isSaving: boolean;
}
export interface UseRestoreLastUsedViewArgs {
source: SavedviewtypesSourceDTO;
selectView: UseSavedViewActionsResult['selectView'];
}
export type SaveViewModalMode = 'create' | 'saveAsNew';
export interface SaveViewModalProps {
mode: SaveViewModalMode;
isSaving: boolean;
onClose: () => void;
// Resolves true when saved; the modal stays open otherwise.
onSave: (displayName: string) => Promise<boolean>;
}
export interface SaveChangesMenuProps {
disabled: boolean;
onSaveAsNew: () => void;
onUpdate: () => void;
}
export interface SavedViewsIconButtonProps {
title: string;
icon: ReactElement;
testId: string;
color?: 'secondary' | 'warning';
disabled?: boolean;
onClick?: () => void;
}

View File

@@ -0,0 +1,49 @@
import {
SavedviewtypesSavedViewDTO,
SavedviewtypesSourceDTO,
} from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { QueryEnvelope } from 'types/api/v5/queryRange';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
export type SavedViewSourcePage = DataSource | 'meter';
// Explorers and the preferences module are keyed by DataSource (the signal),
// the api keys views by source page. Same values today, so this is the one
// place they meet. AI observability views will come with their own source and
// DataSource cannot tell them apart from traces, so preferences should move to
// source page at that point and this map goes with it.
const SAVED_VIEW_SOURCE: Record<SavedViewSourcePage, SavedviewtypesSourceDTO> =
{
[DataSource.LOGS]: SavedviewtypesSourceDTO.logs,
[DataSource.TRACES]: SavedviewtypesSourceDTO.traces,
[DataSource.METRICS]: SavedviewtypesSourceDTO.metrics,
meter: SavedviewtypesSourceDTO.meter,
};
export function toSavedViewSource(
sourcePage: SavedViewSourcePage,
): SavedviewtypesSourceDTO {
return SAVED_VIEW_SOURCE[sourcePage];
}
// Explorers only save builder queries; v2 carries no queryType, so it is fixed here.
export function getSavedViewQuery(view: SavedviewtypesSavedViewDTO): Query {
const { queries, panelType } = view.spec;
return mapQueryDataFromApi({
queries: queries as QueryEnvelope[],
panelType: panelType as unknown as PANEL_TYPES,
queryType: EQueryType.QUERY_BUILDER,
unit: undefined,
});
}
export function findSavedView(
views: SavedviewtypesSavedViewDTO[] | null | undefined,
id: string,
): SavedviewtypesSavedViewDTO | undefined {
return views?.find((view) => view.id === id);
}

View File

@@ -1,20 +0,0 @@
import { SavedviewtypesSavedViewDTO } from 'api/generated/services/sigNoz.schemas';
import { findSavedView } from '../findSavedView';
describe('findSavedView', () => {
const views = [{ id: 'a' }, { id: 'b' }] as SavedviewtypesSavedViewDTO[];
it('returns the view with the matching id', () => {
expect(findSavedView(views, 'b')?.id).toBe('b');
});
it('returns undefined when the id is not in the list', () => {
expect(findSavedView(views, 'c')).toBeUndefined();
});
it('returns undefined for a null or not yet loaded list', () => {
expect(findSavedView(null, 'a')).toBeUndefined();
expect(findSavedView(undefined, 'a')).toBeUndefined();
});
});

View File

@@ -1,217 +0,0 @@
import {
SavedviewtypesSavedViewDTO,
SavedviewtypesSavedViewSpecDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
initialQueriesMap,
initialQueryBuilderFormValuesMap,
PANEL_TYPES,
} from 'constants/queryBuilder';
import {
defaultLogsSelectedColumns,
defaultTraceSelectedColumns,
ensureLogsRequiredColumns,
} from 'container/OptionsMenu/constants';
import { FontSize, LogViewMode } from 'container/OptionsMenu/types';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { DataSource } from 'types/common/queryBuilder';
import { getSavedViewQuery } from '../getSavedViewQuery';
import { hasUnsavedViewChanges } from '../hasUnsavedViewChanges';
import { toSavedViewSpec } from '../toSavedViewSpec';
jest.mock('uuid', () => ({
v4: (): string => 'test-id',
}));
function query(dataSource: DataSource, expression = 'has_error = true'): Query {
const base = initialQueriesMap[dataSource];
return {
...base,
builder: {
...base.builder,
queryData: [
{
...base.builder.queryData[0],
filter: { expression },
groupBy: [{ key: 'service.name', dataType: 'string', type: 'resource' }],
},
],
},
} as Query;
}
// The server stores the queries through typed structs: nulls, empty strings,
// zeros and empty lists do not come back.
function asStoredByServer(value: unknown): unknown {
if (Array.isArray(value)) {
const items = value.map(asStoredByServer).filter((v) => v !== undefined);
return items.length > 0 ? items : undefined;
}
if (value !== null && typeof value === 'object') {
const entries = Object.entries(value)
.map(([key, v]) => [key, asStoredByServer(v)] as const)
.filter(([, v]) => v !== undefined);
return Object.fromEntries(entries);
}
return value === null || value === '' || value === 0 ? undefined : value;
}
function viewFrom(
spec: SavedviewtypesSavedViewSpecDTO,
): SavedviewtypesSavedViewDTO {
return {
id: 'view-1',
spec: {
...spec,
queries: asStoredByServer(
JSON.parse(JSON.stringify(spec.queries)),
) as SavedviewtypesSavedViewSpecDTO['queries'],
},
} as SavedviewtypesSavedViewDTO;
}
// What the query builder stages after the view is opened: the stored query,
// with its defaults filled in.
function stagedFrom(
view: SavedviewtypesSavedViewDTO,
dataSource: DataSource,
): Query {
const saved = getSavedViewQuery(view);
return {
...saved,
builder: {
...saved.builder,
queryData: saved.builder.queryData.map((queryData) => ({
...initialQueryBuilderFormValuesMap[dataSource],
...queryData,
})),
},
};
}
const columns = [
{ name: 'service.name', key: 'service.name' },
{ name: 'name', key: 'name' },
] as TelemetryFieldKey[];
const formatting = {
format: 'raw' as LogViewMode,
maxLines: 2,
fontSize: FontSize.SMALL,
};
describe('hasUnsavedViewChanges', () => {
const options = { selectColumns: columns, ...formatting };
const view = viewFrom(
toSavedViewSpec({
query: query(DataSource.TRACES),
panelType: PANEL_TYPES.LIST,
displayName: 'Errors',
options,
}),
);
const staged = stagedFrom(view, DataSource.TRACES);
it('is dirty when the columns change', () => {
expect(
hasUnsavedViewChanges({
view,
stagedQuery: staged,
panelType: PANEL_TYPES.LIST,
options: { ...options, selectColumns: [columns[0]] },
}),
).toBe(true);
});
it('reads a traces view without columns as the default columns', () => {
const bare = viewFrom(
toSavedViewSpec({
query: query(DataSource.TRACES),
panelType: PANEL_TYPES.LIST,
displayName: 'Errors',
}),
);
expect(
hasUnsavedViewChanges({
view: bare,
stagedQuery: stagedFrom(bare, DataSource.TRACES),
panelType: PANEL_TYPES.LIST,
options: { ...options, selectColumns: defaultTraceSelectedColumns },
}),
).toBe(false);
});
describe('on logs', () => {
const logsView = viewFrom(
toSavedViewSpec({
query: query(DataSource.LOGS),
panelType: PANEL_TYPES.LIST,
displayName: 'Errors',
options: { selectColumns: columns, ...formatting },
}),
);
const logsStaged = stagedFrom(logsView, DataSource.LOGS);
// The explorer always adds the required logs columns.
const shownColumns = ensureLogsRequiredColumns(columns);
it('is clean with the required columns the explorer adds', () => {
expect(
hasUnsavedViewChanges({
view: logsView,
stagedQuery: logsStaged,
panelType: PANEL_TYPES.LIST,
options: { selectColumns: shownColumns, ...formatting },
}),
).toBe(false);
});
it('is dirty when the formatting changes', () => {
expect(
hasUnsavedViewChanges({
view: logsView,
stagedQuery: logsStaged,
panelType: PANEL_TYPES.LIST,
options: { selectColumns: shownColumns, ...formatting, maxLines: 5 },
}),
).toBe(true);
});
it('reads a view without formatting as the default formatting', () => {
const bare = viewFrom(
toSavedViewSpec({
query: query(DataSource.LOGS),
panelType: PANEL_TYPES.LIST,
displayName: 'Errors',
}),
);
expect(
hasUnsavedViewChanges({
view: bare,
stagedQuery: stagedFrom(bare, DataSource.LOGS),
panelType: PANEL_TYPES.LIST,
options: {
selectColumns: defaultLogsSelectedColumns,
format: 'table',
maxLines: 1,
fontSize: FontSize.SMALL,
},
}),
).toBe(false);
});
});
it('ignores formatting on traces', () => {
expect(
hasUnsavedViewChanges({
view,
stagedQuery: staged,
panelType: PANEL_TYPES.LIST,
options: { ...options, ...formatting, maxLines: 5 },
}),
).toBe(false);
});
});

View File

@@ -1,17 +0,0 @@
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { DataSource } from 'types/common/queryBuilder';
import { toSavedViewSource } from '../toSavedViewSource';
describe('toSavedViewSource', () => {
it('maps every explorer source page to the v2 source', () => {
expect(toSavedViewSource(DataSource.LOGS)).toBe(SavedviewtypesSourceDTO.logs);
expect(toSavedViewSource(DataSource.TRACES)).toBe(
SavedviewtypesSourceDTO.traces,
);
expect(toSavedViewSource(DataSource.METRICS)).toBe(
SavedviewtypesSourceDTO.metrics,
);
expect(toSavedViewSource('meter')).toBe(SavedviewtypesSourceDTO.meter);
});
});

View File

@@ -1,95 +0,0 @@
import {
Querybuildertypesv5RequestTypeDTO,
SavedviewtypesPanelTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { FontSize, LogViewMode } from 'container/OptionsMenu/types';
import { toSavedViewPanelType, toSavedViewSpec } from '../toSavedViewSpec';
jest.mock('uuid', () => ({
v4: (): string => 'test-id',
}));
function tracesQuery(): Query {
const base = initialQueriesMap.traces;
return {
...base,
builder: {
...base.builder,
queryData: [
{
...base.builder.queryData[0],
filter: { expression: 'has_error = true' },
groupBy: [{ key: 'service.name', dataType: 'string', type: 'resource' }],
},
],
},
} as Query;
}
const columns = [
{
name: 'service.name',
key: 'service.name',
signal: 'traces',
fieldContext: 'resource',
fieldDataType: 'string',
isIndexed: true,
},
{ key: 'name' },
] as unknown as TelemetryFieldKey[];
describe('toSavedViewPanelType', () => {
it('folds a dashboard only panel type to graph', () => {
expect(toSavedViewPanelType(PANEL_TYPES.PIE)).toBe(
SavedviewtypesPanelTypeDTO.graph,
);
});
});
describe('toSavedViewSpec', () => {
it('stores only the field identity of the columns, naming key only ones', () => {
const spec = toSavedViewSpec({
query: tracesQuery(),
panelType: PANEL_TYPES.LIST,
displayName: 'Errors',
options: {
selectColumns: columns,
format: 'raw' as LogViewMode,
maxLines: 2,
fontSize: FontSize.SMALL,
},
});
expect(spec.selectedFields).toStrictEqual([
{
name: 'service.name',
signal: 'traces',
fieldContext: 'resource',
fieldDataType: 'string',
},
{ name: 'name' },
]);
expect(spec.display).toStrictEqual({
format: 'raw',
maxLines: 2,
fontSize: FontSize.SMALL,
});
});
it('keeps the colour in display', () => {
const spec = toSavedViewSpec({
query: tracesQuery(),
panelType: PANEL_TYPES.TIME_SERIES,
displayName: 'Errors',
color: 'red',
});
expect(spec.panelType).toBe(SavedviewtypesPanelTypeDTO.graph);
expect(spec.requestType).toBe(Querybuildertypesv5RequestTypeDTO.time_series);
expect(spec.display).toStrictEqual({ color: 'red' });
});
});

View File

@@ -1,8 +0,0 @@
import { SavedviewtypesSavedViewDTO } from 'api/generated/services/sigNoz.schemas';
export function findSavedView(
views: SavedviewtypesSavedViewDTO[] | null | undefined,
id: string,
): SavedviewtypesSavedViewDTO | undefined {
return views?.find((view) => view.id === id);
}

View File

@@ -1,17 +0,0 @@
import { SavedviewtypesSavedViewDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { QueryEnvelope } from 'types/api/v5/queryRange';
import { EQueryType } from 'types/common/dashboard';
// Explorers only save builder queries; v2 carries no queryType, so it is fixed here.
export function getSavedViewQuery(view: SavedviewtypesSavedViewDTO): Query {
const { queries, panelType } = view.spec;
return mapQueryDataFromApi({
queries: queries as QueryEnvelope[],
panelType: panelType as unknown as PANEL_TYPES,
queryType: EQueryType.QUERY_BUILDER,
unit: undefined,
});
}

View File

@@ -1,49 +0,0 @@
import { SavedviewtypesSavedViewSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { TelemetryFieldKey } from 'api/v5/v5';
import {
defaultLogsSelectedColumns,
defaultTraceSelectedColumns,
ensureLogsRequiredColumns,
} from 'container/OptionsMenu/constants';
import { FontSize, LogViewMode } from 'container/OptionsMenu/types';
import { Preferences } from 'providers/preferences/types';
import { DataSource } from 'types/common/queryBuilder';
function withColumnNames(
columns: TelemetryFieldKey[] | undefined,
): TelemetryFieldKey[] | null {
if (!columns) {
return null;
}
return columns.map((column) => ({
...column,
name: column.name ?? column.key,
}));
}
// The columns and formatting the explorer shows for a saved view, with the
// defaults filled in where the view has none.
export function getViewColumnsAndFormatting(
spec: SavedviewtypesSavedViewSpecDTO | undefined,
dataSource: DataSource,
): Preferences {
const selectedFields = spec?.selectedFields as TelemetryFieldKey[] | undefined;
if (dataSource === DataSource.LOGS) {
return {
columns: ensureLogsRequiredColumns(
withColumnNames(selectedFields) || defaultLogsSelectedColumns,
),
formatting: {
maxLines: spec?.display?.maxLines || 1,
format: (spec?.display?.format as LogViewMode) || 'table',
fontSize: (spec?.display?.fontSize as FontSize) || FontSize.SMALL,
version: 1,
},
};
}
if (dataSource === DataSource.TRACES) {
return { columns: selectedFields || defaultTraceSelectedColumns };
}
return { columns: [] };
}

View File

@@ -1,81 +0,0 @@
import { mapCompositeQueryFromQuery } from 'lib/newQueryBuilder/queryBuilderMappers/mapCompositeQueryFromQuery';
import isEqual from 'lodash-es/isEqual';
import { DataSource } from 'types/common/queryBuilder';
import { HasUnsavedViewChangesArgs } from '../types';
import { toSavedViewPanelType } from './toSavedViewSpec';
import { getViewColumnsAndFormatting } from './getViewColumnsAndFormatting';
// The server drops empty values when it stores a query, while the query
// builder fills them with defaults, so both sides lose them before comparing.
function withoutEmptyValues(value: unknown): unknown {
if (Array.isArray(value)) {
const items = value
.map(withoutEmptyValues)
.filter((item) => item !== undefined);
return items.length > 0 ? items : undefined;
}
if (value !== null && typeof value === 'object') {
const entries = Object.entries(value)
.map(([key, item]) => [key, withoutEmptyValues(item)] as const)
.filter(([, item]) => item !== undefined);
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
}
if (value === null || value === '' || value === 0) {
return undefined;
}
return value;
}
// Compared as stored (v5 envelopes): mapped back to the builder's shape, each
// side gets different defaults.
export function hasUnsavedViewChanges({
view,
stagedQuery,
panelType,
options,
}: HasUnsavedViewChangesArgs): boolean {
if (!stagedQuery) {
return false;
}
if (view.spec.panelType !== toSavedViewPanelType(panelType)) {
return true;
}
const { queries: stagedQueries } = mapCompositeQueryFromQuery(
stagedQuery,
panelType,
);
if (
!isEqual(
withoutEmptyValues(view.spec.queries),
withoutEmptyValues(stagedQueries),
)
) {
return true;
}
if (!options) {
return false;
}
const dataSource = stagedQuery.builder.queryData[0]?.dataSource;
const { columns, formatting } = getViewColumnsAndFormatting(
view.spec,
dataSource,
);
const toNames = (fields: { name?: string; key?: string }[]): unknown[] =>
fields.map((field) => field.name ?? field.key);
if (!isEqual(toNames(columns), toNames(options.selectColumns ?? []))) {
return true;
}
if (dataSource !== DataSource.LOGS || !formatting) {
return false;
}
return (
formatting.format !== options.format ||
formatting.maxLines !== options.maxLines ||
formatting.fontSize !== options.fontSize
);
}

View File

@@ -1,23 +0,0 @@
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { DataSource } from 'types/common/queryBuilder';
import { SavedViewSourcePage } from '../types';
// Explorers and the preferences module are keyed by DataSource (the signal),
// the api keys views by source page. Same values today, so this is the one
// place they meet. AI observability views will come with their own source and
// DataSource cannot tell them apart from traces, so preferences should move to
// source page at that point and this map goes with it.
const SAVED_VIEW_SOURCE: Record<SavedViewSourcePage, SavedviewtypesSourceDTO> =
{
[DataSource.LOGS]: SavedviewtypesSourceDTO.logs,
[DataSource.TRACES]: SavedviewtypesSourceDTO.traces,
[DataSource.METRICS]: SavedviewtypesSourceDTO.metrics,
meter: SavedviewtypesSourceDTO.meter,
};
export function toSavedViewSource(
sourcePage: SavedViewSourcePage,
): SavedviewtypesSourceDTO {
return SAVED_VIEW_SOURCE[sourcePage];
}

View File

@@ -1,80 +0,0 @@
import {
Querybuildertypesv5QueryEnvelopeDTO,
SavedviewtypesDisplayDTO,
SavedviewtypesPanelTypeDTO,
SavedviewtypesSavedViewSpecDTO,
TelemetrytypesFieldContextDTO,
TelemetrytypesFieldDataTypeDTO,
TelemetrytypesSignalDTO,
TelemetrytypesTelemetryFieldKeyDTO,
} from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { mapCompositeQueryFromQuery } from 'lib/newQueryBuilder/queryBuilderMappers/mapCompositeQueryFromQuery';
import { panelTypeToRequestType } from 'pages/DashboardPage/DashboardContainer/queryV5/persesQueryAdapters';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { ToSavedViewSpecArgs } from '../types';
const SAVED_VIEW_PANEL_TYPES = new Set<string>(
Object.values(SavedviewtypesPanelTypeDTO),
);
// Explorers only produce list / trace / graph / table; anything else a caller
// hands over (a dashboard panel type) is stored as a time series view.
export function toSavedViewPanelType(
panelType: PANEL_TYPES,
): SavedviewtypesPanelTypeDTO {
return SAVED_VIEW_PANEL_TYPES.has(panelType)
? (panelType as unknown as SavedviewtypesPanelTypeDTO)
: SavedviewtypesPanelTypeDTO.graph;
}
// The api stores the field identity only; `key` and the column flags are ui state.
function toFieldKey(
column: TelemetryFieldKey,
): TelemetrytypesTelemetryFieldKeyDTO {
return {
name: column.name ?? column.key ?? '',
...(column.signal && {
signal: column.signal as unknown as TelemetrytypesSignalDTO,
}),
...(column.fieldContext && {
fieldContext:
column.fieldContext as unknown as TelemetrytypesFieldContextDTO,
}),
...(column.fieldDataType && {
fieldDataType:
column.fieldDataType as unknown as TelemetrytypesFieldDataTypeDTO,
}),
...(column.description && { description: column.description }),
...(column.unit && { unit: column.unit }),
};
}
export function toSavedViewSpec({
query,
panelType,
displayName,
options,
color,
}: ToSavedViewSpecArgs): SavedviewtypesSavedViewSpecDTO {
const { queries } = mapCompositeQueryFromQuery(query, panelType);
const display: SavedviewtypesDisplayDTO = {
...(color && { color }),
...(options?.format && { format: options.format }),
...(options?.maxLines && { maxLines: options.maxLines }),
...(options?.fontSize && { fontSize: options.fontSize }),
};
return {
displayName,
panelType: toSavedViewPanelType(panelType),
requestType: panelTypeToRequestType(panelType),
queries: (queries ?? []) as unknown as Querybuildertypesv5QueryEnvelopeDTO[],
...(options?.selectColumns?.length && {
selectedFields: options.selectColumns.map(toFieldKey),
}),
...(Object.keys(display).length > 0 && { display }),
};
}

View File

@@ -73,20 +73,32 @@ export const useHandleExplorerTabChange = (): {
}
const query = currentQueryData?.query || getUpdateQuery(newPanelType);
const nextViewKey = currentQueryData?.viewKey || viewKey;
const nextViewName = currentQueryData?.viewName || viewName;
redirectWithQueryBuilderData(
query,
{
[QueryParams.panelTypes]: newPanelType,
...(nextViewKey ? { [QueryParams.viewKey]: nextViewKey } : {}),
...(nextViewName ? { [QueryParams.viewName]: nextViewName } : {}),
},
redirectToUrl,
undefined,
newTab,
);
if (redirectToUrl) {
redirectWithQueryBuilderData(
query,
{
[QueryParams.panelTypes]: newPanelType,
[QueryParams.viewName]: currentQueryData?.viewName || viewName,
[QueryParams.viewKey]: currentQueryData?.viewKey || viewKey,
},
redirectToUrl,
undefined,
newTab,
);
} else {
redirectWithQueryBuilderData(
query,
{
[QueryParams.panelTypes]: newPanelType,
[QueryParams.viewName]: currentQueryData?.viewName || viewName,
[QueryParams.viewKey]: currentQueryData?.viewKey || viewKey,
},
undefined,
undefined,
newTab,
);
}
},
[panelType, getUpdateQuery, redirectWithQueryBuilderData, viewName, viewKey],
);

View File

@@ -67,6 +67,16 @@
vertical-align: bottom;
}
// The values behind a pill's `+N`, one bullet each, width-capped so a long value
// wraps instead of stretching the tooltip off-screen.
.overflowValues {
max-width: 360px;
margin: 0;
padding-left: 14px;
list-style: disc outside;
overflow-wrap: anywhere;
}
// Shared by the Text and value selectors: strips the antd control chrome so the
// selector blends into the variable pill.
.control {

View File

@@ -3,7 +3,7 @@ import TooltipScrollArea, {
TOOLTIP_SCROLL_CONTENT_CLASS,
} from 'components/TooltipScrollArea/TooltipScrollArea';
import styles from './OverflowValuesTooltip.module.scss';
import styles from '../../VariablesBar.module.scss';
interface OverflowValuesTooltipProps {
/** The selected values the pill hides behind this `+N`. */

View File

@@ -9,7 +9,7 @@ import type { VariableSelection } from '../../selectionTypes';
import { dynamicVariableOptions } from '../../utils/dynamicVariableOptions';
import { areSelectionsEqual } from '../../utils/resolveVariableSelection';
import { selectionFromCommittedValues } from '../../utils/selectionUtils';
import OverflowValuesTooltip from 'components/OverflowValuesTooltip/OverflowValuesTooltip';
import OverflowValuesTooltip from './OverflowValuesTooltip';
import styles from '../../VariablesBar.module.scss';
interface ValueSelectorProps {

View File

@@ -1,130 +0,0 @@
import { MemoryRouter } from 'react-router-dom-v5-compat';
import * as panelTypesQueryParamHooks from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
import { fireEvent, render, screen } from 'tests/test-utils';
import LogsExplorer from '../index';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: (): { pathname: string } => ({
pathname: `${ROUTES.LOGS_EXPLORER}`,
}),
}));
jest.mock(
'container/Toolbar/Toolbar',
() =>
function Toolbar({
showLiveLogs,
onGoLive,
}: {
showLiveLogs?: boolean;
onGoLive?: () => void;
}): JSX.Element {
return (
<div>
<button type="button" data-testid="go-live" onClick={onGoLive}>
Live
</button>
<span data-testid="live-state">{showLiveLogs ? 'live' : 'not-live'}</span>
</div>
);
},
);
jest.mock(
'container/TimeSeriesView/TimeSeriesView',
() =>
function TimeSeriesView(): JSX.Element {
return <div>Time Series Chart</div>;
},
);
jest.mock(
'container/LogsExplorerChart',
() =>
function LogsExplorerChart(): JSX.Element {
return <div>Frequency chart content</div>;
},
);
jest.mock('container/LiveLogs/LiveLogsContainer', () => ({
__esModule: true,
default: (): JSX.Element => <div>Live logs</div>,
}));
jest.mock('constants/panelTypes', () => ({
AVAILABLE_EXPORT_PANEL_TYPES: ['graph', 'table'],
}));
jest.mock('d3-interpolate', () => ({
interpolate: jest.fn(),
}));
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): { safeNavigate: jest.Mock } => ({
safeNavigate: jest.fn(),
}),
}));
jest.mock('providers/preferences/sync/usePreferenceSync', () => ({
usePreferenceSync: (): unknown => ({
preferences: {
columns: [],
formatting: { maxLines: 1, format: 'table', fontSize: 'small', version: 1 },
},
loading: false,
error: null,
updateColumns: jest.fn(),
updateFormatting: jest.fn(),
}),
}));
function page(): JSX.Element {
return (
<MemoryRouter initialEntries={[ROUTES.LOGS_EXPLORER]}>
<PreferenceContextProvider>
<LogsExplorer />
</PreferenceContextProvider>
</MemoryRouter>
);
}
describe('Logs Explorer live mode', () => {
// useLocation is mocked without a search string, so the tab is driven
// through the hook that reads it from the url.
const panelTypeSpy = jest.spyOn(
panelTypesQueryParamHooks,
'useGetPanelTypesQueryParam',
);
afterAll(() => {
panelTypeSpy.mockRestore();
});
it('ends when the url moves off the list tab, whatever moved it', () => {
panelTypeSpy.mockReturnValue(PANEL_TYPES.LIST);
const { rerender } = render(page());
fireEvent.click(screen.getByTestId('go-live'));
expect(screen.getByTestId('live-state')).toHaveTextContent('live');
panelTypeSpy.mockReturnValue(PANEL_TYPES.TIME_SERIES);
rerender(page());
expect(screen.getByTestId('live-state')).toHaveTextContent('not-live');
});
it('stays on while the url keeps the list tab', () => {
panelTypeSpy.mockReturnValue(PANEL_TYPES.LIST);
const { rerender } = render(page());
fireEvent.click(screen.getByTestId('go-live'));
rerender(page());
expect(screen.getByTestId('live-state')).toHaveTextContent('live');
});
});

View File

@@ -1,5 +1,4 @@
import { MemoryRouter } from 'react-router-dom-v5-compat';
import * as panelTypesQueryParamHooks from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
// https://virtuoso.dev/mocking-in-tests/
import { VirtuosoMockContext } from 'react-virtuoso';
import {
@@ -131,30 +130,6 @@ describe('Logs Explorer Tests', () => {
// expect(oldLogsCTA).toBeInTheDocument();
});
it('renders the tab the url panelTypes param names', () => {
// useLocation is mocked without a search string in this file, so drive
// the param through the hook that reads it.
const panelTypeSpy = jest
.spyOn(panelTypesQueryParamHooks, 'useGetPanelTypesQueryParam')
.mockReturnValue(PANEL_TYPES.TIME_SERIES);
const { container } = render(
<MemoryRouter initialEntries={['/logs-explorer/?panelTypes=%22graph%22']}>
<PreferenceContextProvider>
<LogsExplorer />
</PreferenceContextProvider>
</MemoryRouter>,
);
expect(container.querySelector('.timeseries-view-tab')).toHaveClass(
'active-tab',
);
expect(container.querySelector('.list-view-tab')).not.toHaveClass(
'active-tab',
);
panelTypeSpy.mockRestore();
});
// update this test properly
it.skip('Logs Explorer Page should render with data', async () => {
// mocking the query range API to return the logs

View File

@@ -51,13 +51,9 @@ function LogsExplorer(): JSX.Element {
// Get panel type from URL
const panelTypesFromUrl = useGetPanelTypesQueryParam(PANEL_TYPES.LIST);
const selectedView = panelTypeToExplorerView[panelTypesFromUrl];
useEffect(() => {
if (selectedView !== ExplorerViews.LIST) {
setShowLiveLogs(false);
}
}, [selectedView]);
const [selectedView, setSelectedView] = useState<ExplorerViews>(
() => panelTypeToExplorerView[panelTypesFromUrl],
);
const [showFilters, setShowFilters] = useState<boolean>(() => {
const localStorageValue = getLocalStorageKey(
@@ -120,10 +116,15 @@ function LogsExplorer(): JSX.Element {
);
handleSetConfig(nextPanelType, DataSource.LOGS);
setSelectedView(view);
if (view !== ExplorerViews.LIST) {
setShowLiveLogs(false);
}
handleExplorerTabChange(nextPanelType, querySearchParameters);
},
[handleSetConfig, handleExplorerTabChange],
[handleSetConfig, handleExplorerTabChange, setSelectedView],
);
// ─── AI Assistant page actions (only when license feature is on) ───────────

View File

@@ -1,12 +1,11 @@
import { ReactNode } from 'react';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import TraceTooltip from '../TraceTooltip/TraceTooltip';
import styles from './EntityMetadataItem.module.scss';
interface EntityMetadataItemProps {
tooltip: ReactNode;
tooltip: string;
icon?: ReactNode;
children: ReactNode;
}
@@ -17,12 +16,12 @@ function EntityMetadataItem({
children,
}: EntityMetadataItemProps): JSX.Element {
return (
<TraceTooltip title={tooltip}>
<TooltipSimple title={tooltip}>
<span className={styles.item}>
{icon}
<Typography.Text as="span">{children}</Typography.Text>
</span>
</TraceTooltip>
</TooltipSimple>
);
}

View File

@@ -8,12 +8,3 @@
// Keep Typography.Text at the row's size (it reads this var, not inherited font-size).
--typography-font-size: var(--periscope-font-size-base);
}
.tokenTooltip {
composes: tooltipBody from '../shared.module.scss';
min-width: 180px;
}
.tokenTooltipTitle {
padding-bottom: var(--spacing-4);
}

View File

@@ -1,18 +1,10 @@
import { CalendarClock, Coins, Landmark, Server, Timer } from '@signozhq/icons';
import { CalendarClock, Server, Timer } from '@signozhq/icons';
import { Badge } from '@signozhq/ui/badge';
import cx from 'classnames';
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import HttpStatusBadge from 'components/HttpStatusBadge/HttpStatusBadge';
import {
AiTokenCounts,
formatCost,
formatTokens,
getTotalInputTokens,
hasValue,
} from '../utils/genAi';
import EntityMetadataItem from './EntityMetadataItem';
import TokenUsageTooltip from './TokenUsageTooltip';
import styles from './EntityMetadataRow.module.scss';
@@ -24,8 +16,6 @@ interface EntityMetadataRowProps {
execTimePercent?: number;
timestamp?: string;
statusCode?: string | number;
tokens?: AiTokenCounts;
cost?: number | null;
}
const ICON_SIZE = 14;
@@ -42,11 +32,8 @@ function EntityMetadataRow({
execTimePercent,
timestamp,
statusCode,
tokens,
cost,
}: EntityMetadataRowProps): JSX.Element {
const entityLabel = entity === 'trace' ? 'Trace' : 'Span';
const totalInput = tokens && getTotalInputTokens(tokens);
const durationTooltip =
entity === 'trace' ? 'Trace Duration' : 'Span Duration';
// Single source of duration formatting so both rows label units identically.
@@ -104,24 +91,6 @@ function EntityMetadataRow({
<HttpStatusBadge statusCode={statusCode} />
</EntityMetadataItem>
)}
{tokens && (
<EntityMetadataItem
tooltip={<TokenUsageTooltip tokens={tokens} />}
icon={<Coins size={ICON_SIZE} />}
>
Tokens: {formatTokens(totalInput)} → {formatTokens(tokens.output)}
</EntityMetadataItem>
)}
{hasValue(cost) && (
<EntityMetadataItem
tooltip="Total cost"
icon={<Landmark size={ICON_SIZE} />}
>
Cost — {formatCost(cost)}
</EntityMetadataItem>
)}
</div>
);
}
@@ -133,8 +102,6 @@ EntityMetadataRow.defaultProps = {
execTimePercent: undefined,
timestamp: undefined,
statusCode: undefined,
tokens: undefined,
cost: undefined,
};
export default EntityMetadataRow;

View File

@@ -1,35 +0,0 @@
import { Typography } from '@signozhq/ui/typography';
import TokenBreakdown from '../TokenBreakdown/TokenBreakdown';
import { AiTokenCounts } from '../utils/genAi';
import styles from './EntityMetadataRow.module.scss';
interface TokenUsageTooltipProps {
tokens: AiTokenCounts;
}
function TokenUsageTooltip({ tokens }: TokenUsageTooltipProps): JSX.Element {
const { input, output, cacheRead, cacheWrite, reasoning } = tokens;
return (
<div className={styles.tokenTooltip}>
<Typography.Text
size="small"
weight="medium"
className={styles.tokenTooltipTitle}
>
Usage Breakdown
</Typography.Text>
<TokenBreakdown
input={input}
output={output}
cacheRead={cacheRead}
cacheWrite={cacheWrite}
reasoning={reasoning}
/>
</div>
);
}
export default TokenUsageTooltip;

View File

@@ -1,11 +1,9 @@
import { useMemo } from 'react';
import { Link2 } from '@signozhq/icons';
import dayjs from 'dayjs';
import KeyValueLabel from 'periscope/components/KeyValueLabel';
import { SpanV3 } from 'types/api/trace/getTraceV3';
import EntityMetadataRow from '../EntityMetadata/EntityMetadataRow';
import { getAIUsageDetails, convertToAiTokens } from '../SpanHoverCard/aiUsage';
import { HIGHLIGHTED_OPTIONS } from './config';
import {
LinkedSpansPanel,
@@ -33,10 +31,6 @@ function SpanSummary({
}: SpanSummaryProps): JSX.Element {
const percentile = useSpanPercentile(selectedSpan);
const linkedSpans = useLinkedSpans((selectedSpan as any).references);
const aiUsage = useMemo(
() => getAIUsageDetails(selectedSpan)?.usage,
[selectedSpan],
);
return (
<>
@@ -69,8 +63,6 @@ function SpanSummary({
: undefined
}
timestamp={dayjs(selectedSpan.timestamp).format('HH:mm:ss — MMM D, YYYY')}
tokens={aiUsage && convertToAiTokens(aiUsage)}
cost={aiUsage?.cost}
/>
<div className={styles.spanInfoItem}>

View File

@@ -95,26 +95,4 @@ describe('SpanSummary', () => {
expect(screen.queryByText(/of total exec time/)).not.toBeInTheDocument();
});
it('renders tokens and cost from the gen_ai usage attributes', () => {
const span = {
...createMockSpan(),
attributes: {
'gen_ai.usage.input_tokens': 1000,
'gen_ai.usage.output_tokens': 9,
'signoz.gen_ai.usage.tokens.cost': 0.000009,
},
};
render(<SpanSummary selectedSpan={span} />);
expect(screen.getByText(/Tokens: 1,000 → 9/)).toBeInTheDocument();
expect(screen.getByText(/Cost — \$ 0.000009/)).toBeInTheDocument();
});
it('omits tokens and cost for non-AI spans', () => {
render(<SpanSummary selectedSpan={createMockSpan()} />);
expect(screen.queryByText(/Tokens:/)).not.toBeInTheDocument();
expect(screen.queryByText(/Cost —/)).not.toBeInTheDocument();
});
});

Some files were not shown because too many files have changed in this diff Show More