mirror of
https://github.com/SigNoz/signoz.git
synced 2026-05-30 13:50:32 +01:00
Compare commits
2 Commits
main
...
refactor/c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb26cc36c8 | ||
|
|
9ed3486d2c |
2
.github/workflows/build-enterprise.yaml
vendored
2
.github/workflows/build-enterprise.yaml
vendored
@@ -58,6 +58,8 @@ jobs:
|
||||
run: |
|
||||
mkdir -p frontend
|
||||
echo 'CI=1' > frontend/.env
|
||||
echo 'VITE_INTERCOM_APP_ID="${{ secrets.INTERCOM_APP_ID }}"' >> frontend/.env
|
||||
echo 'VITE_SEGMENT_ID="${{ secrets.SEGMENT_ID }}"' >> frontend/.env
|
||||
echo 'VITE_SENTRY_AUTH_TOKEN="${{ secrets.SENTRY_AUTH_TOKEN }}"' >> frontend/.env
|
||||
echo 'VITE_SENTRY_ORG="${{ secrets.SENTRY_ORG }}"' >> frontend/.env
|
||||
echo 'VITE_SENTRY_PROJECT_ID="${{ secrets.SENTRY_PROJECT_ID }}"' >> frontend/.env
|
||||
|
||||
2
.github/workflows/gor-signoz.yaml
vendored
2
.github/workflows/gor-signoz.yaml
vendored
@@ -24,6 +24,8 @@ jobs:
|
||||
- name: dotenv-frontend
|
||||
working-directory: frontend
|
||||
run: |
|
||||
echo 'VITE_INTERCOM_APP_ID="${{ secrets.INTERCOM_APP_ID }}"' > .env
|
||||
echo 'VITE_SEGMENT_ID="${{ secrets.SEGMENT_ID }}"' >> .env
|
||||
echo 'VITE_SENTRY_AUTH_TOKEN="${{ secrets.SENTRY_AUTH_TOKEN }}"' >> .env
|
||||
echo 'VITE_SENTRY_ORG="${{ secrets.SENTRY_ORG }}"' >> .env
|
||||
echo 'VITE_SENTRY_PROJECT_ID="${{ secrets.SENTRY_PROJECT_ID }}"' >> .env
|
||||
|
||||
@@ -68,12 +68,6 @@ web:
|
||||
appcues:
|
||||
# Whether to enable Appcues in web.
|
||||
enabled: true
|
||||
sentry:
|
||||
# Whether to enable Sentry in web.
|
||||
enabled: true
|
||||
pylon:
|
||||
# Whether to enable Pylon in web.
|
||||
enabled: true
|
||||
|
||||
##################### Cache #####################
|
||||
cache:
|
||||
|
||||
@@ -3237,20 +3237,8 @@ components:
|
||||
items:
|
||||
$ref: '#/components/schemas/ErrorsResponseerroradditional'
|
||||
type: array
|
||||
invalidReferences:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
message:
|
||||
type: string
|
||||
retry:
|
||||
$ref: '#/components/schemas/ErrorsResponseretryjson'
|
||||
suggestions:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
type:
|
||||
type: string
|
||||
url:
|
||||
type: string
|
||||
required:
|
||||
@@ -3262,11 +3250,6 @@ components:
|
||||
message:
|
||||
type: string
|
||||
type: object
|
||||
ErrorsResponseretryjson:
|
||||
properties:
|
||||
delay:
|
||||
$ref: '#/components/schemas/TimeDuration'
|
||||
type: object
|
||||
FactoryResponse:
|
||||
properties:
|
||||
healthy:
|
||||
@@ -7085,13 +7068,6 @@ components:
|
||||
required:
|
||||
- name
|
||||
type: object
|
||||
TypesPostableVerifyResetPasswordToken:
|
||||
properties:
|
||||
token:
|
||||
type: string
|
||||
required:
|
||||
- token
|
||||
type: object
|
||||
TypesResetPasswordToken:
|
||||
properties:
|
||||
expiresAt:
|
||||
@@ -7989,6 +7965,80 @@ paths:
|
||||
tags:
|
||||
- cloudintegration
|
||||
/api/v1/cloud_integrations/{cloud_provider}/accounts/{id}/services/{service_id}:
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoint gets a service and its configuration for the specified
|
||||
cloud integration account
|
||||
operationId: GetAccountService
|
||||
parameters:
|
||||
- in: path
|
||||
name: cloud_provider
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- in: path
|
||||
name: service_id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesService'
|
||||
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
|
||||
"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:
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
summary: Get service for account
|
||||
tags:
|
||||
- cloudintegration
|
||||
put:
|
||||
deprecated: false
|
||||
description: This endpoint updates a service for the specified cloud provider
|
||||
@@ -14873,41 +14923,6 @@ paths:
|
||||
summary: Readiness check
|
||||
tags:
|
||||
- health
|
||||
/api/v2/reset_password_tokens/verify:
|
||||
post:
|
||||
deprecated: false
|
||||
description: This endpoint verifies whether a reset password token exists and
|
||||
is not expired
|
||||
operationId: VerifyResetPasswordToken
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TypesPostableVerifyResetPasswordToken'
|
||||
responses:
|
||||
"204":
|
||||
description: No Content
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"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
|
||||
summary: Verify a reset password token
|
||||
tags:
|
||||
- users
|
||||
/api/v2/roles/{id}/users:
|
||||
get:
|
||||
deprecated: false
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
{
|
||||
"required": [
|
||||
"posthog",
|
||||
"appcues",
|
||||
"sentry",
|
||||
"pylon"
|
||||
"appcues"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"definitions": {
|
||||
@@ -30,30 +28,6 @@
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"Pylon": {
|
||||
"required": [
|
||||
"enabled"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"Sentry": {
|
||||
"required": [
|
||||
"enabled"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
@@ -62,12 +36,6 @@
|
||||
},
|
||||
"posthog": {
|
||||
"$ref": "#/definitions/Posthog"
|
||||
},
|
||||
"pylon": {
|
||||
"$ref": "#/definitions/Pylon"
|
||||
},
|
||||
"sentry": {
|
||||
"$ref": "#/definitions/Sentry"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
|
||||
@@ -26,7 +26,7 @@ import (
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
cmock "github.com/SigNoz/clickhouse-go-mock"
|
||||
cmock "github.com/srikanthccv/ClickHouse-go-mock"
|
||||
)
|
||||
|
||||
func TestManager_TestNotification_SendUnmatched_ThresholdRule(t *testing.T) {
|
||||
|
||||
@@ -123,7 +123,6 @@
|
||||
"react/require-render-return": "error",
|
||||
"react/no-unsafe": "off",
|
||||
"no-array-constructor": "error",
|
||||
"jsdoc/check-tag-names": "off",
|
||||
"@typescript-eslint/no-duplicate-enum-values": "warn",
|
||||
// TODO: Change to error after migration to oxlint
|
||||
"@typescript-eslint/no-empty-object-type": "error",
|
||||
@@ -198,7 +197,10 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"max-params": "off",
|
||||
"max-params": [
|
||||
"warn",
|
||||
3
|
||||
],
|
||||
// Warns when functions have more than 3 parameters
|
||||
"@typescript-eslint/explicit-function-return-type": "error",
|
||||
// Requires explicit return types on functions
|
||||
@@ -536,10 +538,7 @@
|
||||
"signoz/no-raw-absolute-path":"off",
|
||||
"no-restricted-globals": "off",
|
||||
// Tests need raw localStorage/sessionStorage to seed DOM state for isolation
|
||||
"signoz/no-zustand-getstate-in-hooks": "off",
|
||||
"@typescript-eslint/no-explicit-any": "off", // Tests often need any for mocks/stubs
|
||||
"@typescript-eslint/explicit-module-boundary-types": "off", // Return types not required in tests
|
||||
"@typescript-eslint/explicit-function-return-type": "off" // Same as above rule, don't need to care about return type
|
||||
"signoz/no-zustand-getstate-in-hooks": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -112,10 +112,7 @@
|
||||
|
||||
<script>
|
||||
var PYLON_APP_ID = '<%- PYLON_APP_ID %>';
|
||||
var pylonSettings =
|
||||
((window.signozBootData || {}).settings || {}).pylon || {};
|
||||
var pylonEnabled = pylonSettings.enabled !== false;
|
||||
if (PYLON_APP_ID && pylonEnabled) {
|
||||
if (PYLON_APP_ID) {
|
||||
(function () {
|
||||
var e = window;
|
||||
var t = document;
|
||||
|
||||
@@ -35,6 +35,7 @@ import { PreferenceContextProvider } from 'providers/preferences/context/Prefere
|
||||
import { QueryBuilderProvider } from 'providers/QueryBuilder';
|
||||
import { LicenseStatus } from 'types/api/licensesV3/getActive';
|
||||
import { extractDomain } from 'utils/app';
|
||||
import { bootSettings } from 'utils/bootData';
|
||||
|
||||
import { Home } from './pageComponents';
|
||||
import PrivateRoute from './Private';
|
||||
@@ -291,8 +292,7 @@ function App(): JSX.Element {
|
||||
isLoggedInState &&
|
||||
isChatSupportEnabled &&
|
||||
!showAddCreditCardModal &&
|
||||
(isCloudUser || isEnterpriseSelfHostedUser) &&
|
||||
(window.signozBootData?.settings?.pylon.enabled ?? true)
|
||||
(isCloudUser || isEnterpriseSelfHostedUser)
|
||||
) {
|
||||
const email = user.email || '';
|
||||
const secret = process.env.PYLON_IDENTITY_SECRET || '';
|
||||
@@ -334,20 +334,14 @@ function App(): JSX.Element {
|
||||
|
||||
useEffect(() => {
|
||||
if (isCloudUser || isEnterpriseSelfHostedUser) {
|
||||
if (
|
||||
(window.signozBootData?.settings?.posthog.enabled ?? true) &&
|
||||
process.env.POSTHOG_KEY
|
||||
) {
|
||||
if (bootSettings.posthog.enabled && process.env.POSTHOG_KEY) {
|
||||
posthog.init(process.env.POSTHOG_KEY, {
|
||||
api_host: 'https://us.i.posthog.com',
|
||||
person_profiles: 'identified_only', // or 'always' to create profiles for anonymous users as well
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
!isSentryInitialized &&
|
||||
(window.signozBootData?.settings?.sentry.enabled ?? true)
|
||||
) {
|
||||
if (!isSentryInitialized) {
|
||||
Sentry.init({
|
||||
dsn: process.env.SENTRY_DSN,
|
||||
tunnel: process.env.TUNNEL_URL,
|
||||
|
||||
@@ -31,6 +31,8 @@ import type {
|
||||
DisconnectAccountPathParameters,
|
||||
GetAccount200,
|
||||
GetAccountPathParameters,
|
||||
GetAccountService200,
|
||||
GetAccountServicePathParameters,
|
||||
GetConnectionCredentials200,
|
||||
GetConnectionCredentialsPathParameters,
|
||||
GetService200,
|
||||
@@ -631,6 +633,117 @@ export const useUpdateAccount = <
|
||||
> => {
|
||||
return useMutation(getUpdateAccountMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint gets a service and its configuration for the specified cloud integration account
|
||||
* @summary Get service for account
|
||||
*/
|
||||
export const getAccountService = (
|
||||
{ cloudProvider, id, serviceId }: GetAccountServicePathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetAccountService200>({
|
||||
url: `/api/v1/cloud_integrations/${cloudProvider}/accounts/${id}/services/${serviceId}`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetAccountServiceQueryKey = ({
|
||||
cloudProvider,
|
||||
id,
|
||||
serviceId,
|
||||
}: GetAccountServicePathParameters) => {
|
||||
return [
|
||||
`/api/v1/cloud_integrations/${cloudProvider}/accounts/${id}/services/${serviceId}`,
|
||||
] as const;
|
||||
};
|
||||
|
||||
export const getGetAccountServiceQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getAccountService>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ cloudProvider, id, serviceId }: GetAccountServicePathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getAccountService>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ??
|
||||
getGetAccountServiceQueryKey({ cloudProvider, id, serviceId });
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof getAccountService>>
|
||||
> = ({ signal }) =>
|
||||
getAccountService({ cloudProvider, id, serviceId }, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: !!(cloudProvider && id && serviceId),
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getAccountService>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetAccountServiceQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getAccountService>>
|
||||
>;
|
||||
export type GetAccountServiceQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Get service for account
|
||||
*/
|
||||
|
||||
export function useGetAccountService<
|
||||
TData = Awaited<ReturnType<typeof getAccountService>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ cloudProvider, id, serviceId }: GetAccountServicePathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getAccountService>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetAccountServiceQueryOptions(
|
||||
{ cloudProvider, id, serviceId },
|
||||
options,
|
||||
);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get service for account
|
||||
*/
|
||||
export const invalidateGetAccountService = async (
|
||||
queryClient: QueryClient,
|
||||
{ cloudProvider, id, serviceId }: GetAccountServicePathParameters,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetAccountServiceQueryKey({ cloudProvider, id, serviceId }) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint updates a service for the specified cloud provider
|
||||
* @summary Update service
|
||||
|
||||
@@ -2051,10 +2051,6 @@ export interface ErrorsResponseerroradditionalDTO {
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface ErrorsResponseretryjsonDTO {
|
||||
delay?: TimeDurationDTO;
|
||||
}
|
||||
|
||||
export interface ErrorsJSONDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -2064,23 +2060,10 @@ export interface ErrorsJSONDTO {
|
||||
* @type array
|
||||
*/
|
||||
errors?: ErrorsResponseerroradditionalDTO[];
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
invalidReferences?: string[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
message: string;
|
||||
retry?: ErrorsResponseretryjsonDTO;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
suggestions?: string[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
type?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -8325,13 +8308,6 @@ export interface TypesPostableRoleDTO {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface TypesPostableVerifyResetPasswordTokenDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
token: string;
|
||||
}
|
||||
|
||||
export interface TypesResetPasswordTokenDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -8590,6 +8566,19 @@ export type UpdateAccountPathParameters = {
|
||||
cloudProvider: string;
|
||||
id: string;
|
||||
};
|
||||
export type GetAccountServicePathParameters = {
|
||||
cloudProvider: string;
|
||||
id: string;
|
||||
serviceId: string;
|
||||
};
|
||||
export type GetAccountService200 = {
|
||||
data: CloudintegrationtypesServiceDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type UpdateServicePathParameters = {
|
||||
cloudProvider: string;
|
||||
id: string;
|
||||
|
||||
@@ -48,7 +48,6 @@ import type {
|
||||
TypesPostableInviteDTO,
|
||||
TypesPostableResetPasswordDTO,
|
||||
TypesPostableRoleDTO,
|
||||
TypesPostableVerifyResetPasswordTokenDTO,
|
||||
TypesUpdatableUserDTO,
|
||||
UpdateUserDeprecated200,
|
||||
UpdateUserDeprecatedPathParameters,
|
||||
@@ -948,90 +947,6 @@ export const useForgotPassword = <
|
||||
> => {
|
||||
return useMutation(getForgotPasswordMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint verifies whether a reset password token exists and is not expired
|
||||
* @summary Verify a reset password token
|
||||
*/
|
||||
export const verifyResetPasswordToken = (
|
||||
typesPostableVerifyResetPasswordTokenDTO?: BodyType<TypesPostableVerifyResetPasswordTokenDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v2/reset_password_tokens/verify`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: typesPostableVerifyResetPasswordTokenDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getVerifyResetPasswordTokenMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof verifyResetPasswordToken>>,
|
||||
TError,
|
||||
{ data?: BodyType<TypesPostableVerifyResetPasswordTokenDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof verifyResetPasswordToken>>,
|
||||
TError,
|
||||
{ data?: BodyType<TypesPostableVerifyResetPasswordTokenDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['verifyResetPasswordToken'];
|
||||
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 verifyResetPasswordToken>>,
|
||||
{ data?: BodyType<TypesPostableVerifyResetPasswordTokenDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return verifyResetPasswordToken(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type VerifyResetPasswordTokenMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof verifyResetPasswordToken>>
|
||||
>;
|
||||
export type VerifyResetPasswordTokenMutationBody =
|
||||
| BodyType<TypesPostableVerifyResetPasswordTokenDTO>
|
||||
| undefined;
|
||||
export type VerifyResetPasswordTokenMutationError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Verify a reset password token
|
||||
*/
|
||||
export const useVerifyResetPasswordToken = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof verifyResetPasswordToken>>,
|
||||
TError,
|
||||
{ data?: BodyType<TypesPostableVerifyResetPasswordTokenDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof verifyResetPasswordToken>>,
|
||||
TError,
|
||||
{ data?: BodyType<TypesPostableVerifyResetPasswordTokenDTO> },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getVerifyResetPasswordTokenMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint returns the users having the role by role id
|
||||
* @summary Get users by role id
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
.emptyState {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 48px 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.icon {
|
||||
color: var(--danger-background);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: var(--text-vanilla-100);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 14px;
|
||||
color: var(--text-vanilla-400);
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
import { LifeBuoy, RefreshCw, TriangleAlert } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { handleContactSupport } from 'container/Integrations/utils';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
|
||||
import styles from './ErrorEmptyState.module.scss';
|
||||
|
||||
interface ErrorEmptyStateProps {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
onRefresh?: () => void;
|
||||
}
|
||||
|
||||
function ErrorEmptyState({
|
||||
title = 'Something went wrong',
|
||||
subtitle = 'Our team is getting on top to resolve this. Please reach out to support if the issue persists.',
|
||||
onRefresh,
|
||||
}: ErrorEmptyStateProps): JSX.Element {
|
||||
const { isCloudUser } = useGetTenantLicense();
|
||||
|
||||
const onContactSupport = useCallback((): void => {
|
||||
handleContactSupport(isCloudUser);
|
||||
}, [isCloudUser]);
|
||||
|
||||
return (
|
||||
<div className={styles.emptyState} data-testid="error-empty-state">
|
||||
<TriangleAlert className={styles.icon} size={32} />
|
||||
<div className={styles.title} data-testid="error-title">
|
||||
{title}
|
||||
</div>
|
||||
<div className={styles.subtitle} data-testid="error-subtitle">
|
||||
{subtitle}
|
||||
</div>
|
||||
<div className={styles.actions}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
prefix={<LifeBuoy size={14} />}
|
||||
onClick={onContactSupport}
|
||||
data-testid="error-contact-support-button"
|
||||
>
|
||||
Contact Support
|
||||
</Button>
|
||||
{onRefresh && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
prefix={<RefreshCw size={14} />}
|
||||
onClick={onRefresh}
|
||||
data-testid="error-refresh-button"
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ErrorEmptyState;
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from './ErrorEmptyState';
|
||||
@@ -1,68 +0,0 @@
|
||||
.labelColumn {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.labelBadge {
|
||||
cursor: default;
|
||||
font-size: 12px;
|
||||
|
||||
--badge-display: inline;
|
||||
|
||||
max-width: 180px;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.overflowTrigger {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.overflowBadge {
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.labelPopover {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 8px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.labelTooltip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.labelValue {
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tooltipContent {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.copyButton {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
opacity: 0.7;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
|
||||
import LabelColumn from './LabelColumn';
|
||||
|
||||
let resizeCallback: ResizeObserverCallback | null = null;
|
||||
|
||||
class MockResizeObserver {
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
resizeCallback = callback;
|
||||
}
|
||||
|
||||
observe = jest.fn();
|
||||
unobserve = jest.fn();
|
||||
disconnect = jest.fn();
|
||||
}
|
||||
|
||||
function triggerResize(width: number): void {
|
||||
if (resizeCallback) {
|
||||
act(() => {
|
||||
resizeCallback?.(
|
||||
[{ contentRect: { width } } as ResizeObserverEntry],
|
||||
{} as ResizeObserver,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
global.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resizeCallback = null;
|
||||
});
|
||||
|
||||
function renderWithProviders(
|
||||
ui: React.ReactElement,
|
||||
): ReturnType<typeof render> {
|
||||
return render(<TooltipProvider>{ui}</TooltipProvider>);
|
||||
}
|
||||
|
||||
describe('LabelColumn', () => {
|
||||
it('should render all labels when 5 or fewer', () => {
|
||||
const labels = ['env', 'service', 'region'];
|
||||
|
||||
renderWithProviders(<LabelColumn labels={labels} />);
|
||||
|
||||
expect(screen.getByTestId('label-tag-env')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('label-tag-service')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('label-tag-region')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should truncate labels and show +N badge when container is narrow', () => {
|
||||
const labels = ['env', 'service', 'region', 'team', 'owner', 'version'];
|
||||
|
||||
renderWithProviders(<LabelColumn labels={labels} />);
|
||||
|
||||
// Simulate narrow container that fits ~3 badges
|
||||
// Badge widths: env=37, service=65, region=58, team=44, owner=51, version=65
|
||||
// 220px available = 3 badges (160px) + gaps (8px) + overflow (44px)
|
||||
triggerResize(220);
|
||||
|
||||
// First 3 visible
|
||||
expect(screen.getByTestId('label-tag-env')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('label-tag-service')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('label-tag-region')).toBeInTheDocument();
|
||||
|
||||
// Remaining in overflow badge
|
||||
expect(screen.getByTestId('label-overflow-badge')).toHaveTextContent('+3');
|
||||
});
|
||||
|
||||
it('should render label with value when value prop provided', () => {
|
||||
const labels = ['env'];
|
||||
const value = { env: 'production' };
|
||||
|
||||
renderWithProviders(<LabelColumn labels={labels} value={value} />);
|
||||
|
||||
expect(screen.getByTestId('label-tag-env')).toHaveTextContent(
|
||||
'env: production',
|
||||
);
|
||||
});
|
||||
|
||||
it('should render labels without value when value is not provided for that label', () => {
|
||||
const labels = ['env', 'service'];
|
||||
const value = { env: 'production' };
|
||||
|
||||
renderWithProviders(<LabelColumn labels={labels} value={value} />);
|
||||
|
||||
expect(screen.getByTestId('label-tag-env')).toHaveTextContent(
|
||||
'env: production',
|
||||
);
|
||||
expect(screen.getByTestId('label-tag-service')).toHaveTextContent('service');
|
||||
});
|
||||
|
||||
it('should show overflow badge with remaining count when container is narrow', () => {
|
||||
const labels = ['env', 'service', 'region', 'team', 'owner', 'version'];
|
||||
|
||||
renderWithProviders(<LabelColumn labels={labels} />);
|
||||
|
||||
// Simulate narrow container to trigger overflow (shows 3 labels)
|
||||
// 220px fits first 3 badges before overflow
|
||||
triggerResize(220);
|
||||
|
||||
// Overflow badge shows +3 (remaining labels)
|
||||
const overflowBadge = screen.getByTestId('label-overflow-badge');
|
||||
expect(overflowBadge).toBeInTheDocument();
|
||||
expect(overflowBadge).toHaveTextContent('+3');
|
||||
});
|
||||
|
||||
it('should render empty when no labels provided', () => {
|
||||
renderWithProviders(<LabelColumn labels={[]} />);
|
||||
|
||||
const column = screen.getByTestId('label-column');
|
||||
expect(column.children).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should use primary color by default', () => {
|
||||
const labels = ['env'];
|
||||
|
||||
renderWithProviders(<LabelColumn labels={labels} />);
|
||||
|
||||
expect(screen.getByTestId('label-tag-env')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show all labels when container is wide enough', () => {
|
||||
const labels = ['env', 'service', 'region', 'team', 'owner', 'version'];
|
||||
|
||||
renderWithProviders(<LabelColumn labels={labels} />);
|
||||
|
||||
// Simulate wide container
|
||||
triggerResize(1000);
|
||||
|
||||
// All labels visible
|
||||
labels.forEach((label) => {
|
||||
expect(screen.getByTestId(`label-tag-${label}`)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// No overflow badge
|
||||
expect(screen.queryByTestId('label-overflow-badge')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,150 +0,0 @@
|
||||
import { Copy } from '@signozhq/icons';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import {
|
||||
TooltipContent,
|
||||
TooltipRoot,
|
||||
TooltipTrigger,
|
||||
} from '@signozhq/ui/tooltip';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
|
||||
import LabelTag from './LabelTag';
|
||||
|
||||
import styles from './LabelColumn.module.scss';
|
||||
import { BADGE_GAP, estimateBadgeWidth, OVERFLOW_BADGE_WIDTH } from './utils';
|
||||
|
||||
export interface LabelColumnProps {
|
||||
labels: string[];
|
||||
color?:
|
||||
| 'primary'
|
||||
| 'secondary'
|
||||
| 'success'
|
||||
| 'error'
|
||||
| 'warning'
|
||||
| 'robin'
|
||||
| 'forest'
|
||||
| 'amber'
|
||||
| 'sienna'
|
||||
| 'cherry'
|
||||
| 'sakura'
|
||||
| 'aqua'
|
||||
| 'vanilla';
|
||||
value?: { [key: string]: string };
|
||||
}
|
||||
|
||||
function LabelColumn({
|
||||
labels,
|
||||
value,
|
||||
color = 'primary',
|
||||
}: LabelColumnProps): JSX.Element {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [maxVisibleCount, setMaxVisibleCount] = useState(labels.length);
|
||||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
|
||||
const calculateMaxVisible = useCallback(
|
||||
(width: number): number => {
|
||||
if (width <= 0) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
const availableWidth = width - OVERFLOW_BADGE_WIDTH - BADGE_GAP;
|
||||
let usedWidth = 0;
|
||||
let count = 0;
|
||||
|
||||
for (const label of labels) {
|
||||
const badgeWidth = estimateBadgeWidth(label, value?.[label]) + BADGE_GAP;
|
||||
if (usedWidth + badgeWidth > availableWidth && count > 0) {
|
||||
break;
|
||||
}
|
||||
usedWidth += badgeWidth;
|
||||
count++;
|
||||
}
|
||||
|
||||
return Math.max(1, count);
|
||||
},
|
||||
[labels, value],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
if (entry && entry.contentRect.width > 0) {
|
||||
setMaxVisibleCount(calculateMaxVisible(entry.contentRect.width));
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(container);
|
||||
|
||||
if (container.clientWidth > 0) {
|
||||
setMaxVisibleCount(calculateMaxVisible(container.clientWidth));
|
||||
}
|
||||
|
||||
return (): void => observer.disconnect();
|
||||
}, [calculateMaxVisible]);
|
||||
|
||||
const needsOverflow = labels.length > maxVisibleCount;
|
||||
const visibleLabels = needsOverflow
|
||||
? labels.slice(0, maxVisibleCount)
|
||||
: labels;
|
||||
const remainingLabels = needsOverflow ? labels.slice(maxVisibleCount) : [];
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={styles.labelColumn}
|
||||
data-testid="label-column"
|
||||
>
|
||||
{visibleLabels.map((label) => (
|
||||
<LabelTag key={label} label={label} color={color} value={value?.[label]} />
|
||||
))}
|
||||
{remainingLabels.length > 0 && (
|
||||
<TooltipRoot>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<Badge
|
||||
color={color}
|
||||
className={styles.overflowBadge}
|
||||
variant="outline"
|
||||
data-testid="label-overflow-badge"
|
||||
>
|
||||
+{remainingLabels.length}
|
||||
</Badge>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="end">
|
||||
<div className={styles.tooltipContent}>
|
||||
<span>
|
||||
{remainingLabels
|
||||
.map((label) => (value?.[label] ? `${label}: ${value[label]}` : label))
|
||||
.join(', ')}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.copyButton}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
const searchFormat = remainingLabels
|
||||
.map((label) => (value?.[label] ? `${label} ${value[label]}` : label))
|
||||
.join(' ');
|
||||
copyToClipboard(searchFormat);
|
||||
toast.success('Copied! Use in search to filter alerts.');
|
||||
}}
|
||||
aria-label="Copy to clipboard"
|
||||
>
|
||||
<Copy size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</TooltipRoot>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LabelColumn;
|
||||
@@ -1,30 +0,0 @@
|
||||
.labelBadge {
|
||||
cursor: default;
|
||||
font-size: 12px;
|
||||
|
||||
max-width: 180px;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.labelValue {
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tooltipContent {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.copyButton {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
opacity: 0.7;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
import { Copy } from '@signozhq/icons';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import {
|
||||
TooltipContent,
|
||||
TooltipRoot,
|
||||
TooltipTrigger,
|
||||
} from '@signozhq/ui/tooltip';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
|
||||
import styles from './LabelTag.module.scss';
|
||||
|
||||
export interface LabelTagProps {
|
||||
label: string;
|
||||
color?:
|
||||
| 'primary'
|
||||
| 'secondary'
|
||||
| 'success'
|
||||
| 'error'
|
||||
| 'warning'
|
||||
| 'robin'
|
||||
| 'forest'
|
||||
| 'amber'
|
||||
| 'sienna'
|
||||
| 'cherry'
|
||||
| 'sakura'
|
||||
| 'aqua'
|
||||
| 'vanilla';
|
||||
value?: string;
|
||||
}
|
||||
|
||||
function LabelTag({ label, value, color }: LabelTagProps): JSX.Element {
|
||||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
const displayText = value ? `${label}: ${value}` : label;
|
||||
const searchFormat = value ? `${label} ${value}` : label;
|
||||
|
||||
const handleCopy = (e: React.MouseEvent): void => {
|
||||
e.stopPropagation();
|
||||
copyToClipboard(searchFormat);
|
||||
toast.success('Copied! Use in search to filter alerts.');
|
||||
};
|
||||
|
||||
return (
|
||||
<TooltipRoot>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<Badge
|
||||
color={color}
|
||||
className={styles.labelBadge}
|
||||
variant="outline"
|
||||
data-testid={`label-tag-${label}`}
|
||||
>
|
||||
<span className={styles.labelValue}>{displayText}</span>
|
||||
</Badge>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className={styles.tooltipContent}>
|
||||
<span>{displayText}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.copyButton}
|
||||
onClick={handleCopy}
|
||||
aria-label="Copy to clipboard"
|
||||
>
|
||||
<Copy size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</TooltipRoot>
|
||||
);
|
||||
}
|
||||
|
||||
export default LabelTag;
|
||||
@@ -1,2 +0,0 @@
|
||||
export { default } from './LabelColumn';
|
||||
export type { LabelColumnProps } from './LabelColumn';
|
||||
@@ -1,14 +0,0 @@
|
||||
export const BADGE_GAP = 4;
|
||||
export const OVERFLOW_BADGE_WIDTH = 40;
|
||||
|
||||
export const BADGE_MAX_WIDTH = 180;
|
||||
export const BADGE_PADDING = 16;
|
||||
export const CHAR_WIDTH = 7;
|
||||
|
||||
export function estimateBadgeWidth(label: string, value?: string): number {
|
||||
const displayText = value ? `${label}: ${value}` : label;
|
||||
return Math.min(
|
||||
displayText.length * CHAR_WIDTH + BADGE_PADDING,
|
||||
BADGE_MAX_WIDTH,
|
||||
);
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
.emptyState {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 48px 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.icon {
|
||||
color: var(--text-vanilla-400);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: var(--text-vanilla-100);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 14px;
|
||||
color: var(--text-vanilla-400);
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import NoResultsEmptyState from './NoResultsEmptyState';
|
||||
|
||||
describe('NoResultsEmptyState', () => {
|
||||
it('should render with default props', () => {
|
||||
render(<NoResultsEmptyState />);
|
||||
|
||||
expect(screen.getByTestId('no-results-empty-state')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('no-results-title')).toHaveTextContent(
|
||||
'No matching results',
|
||||
);
|
||||
expect(screen.getByTestId('no-results-subtitle')).toHaveTextContent(
|
||||
'No items match your current filters. Try adjusting your search criteria.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should render with custom title and subtitle', () => {
|
||||
render(
|
||||
<NoResultsEmptyState title="Custom Title" subtitle="Custom Subtitle" />,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('no-results-title')).toHaveTextContent(
|
||||
'Custom Title',
|
||||
);
|
||||
expect(screen.getByTestId('no-results-subtitle')).toHaveTextContent(
|
||||
'Custom Subtitle',
|
||||
);
|
||||
});
|
||||
|
||||
it('should not render clear button when onClear is not provided', () => {
|
||||
render(<NoResultsEmptyState />);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('no-results-clear-button'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render clear button when onClear is provided', () => {
|
||||
const onClear = jest.fn();
|
||||
|
||||
render(<NoResultsEmptyState onClear={onClear} />);
|
||||
|
||||
expect(screen.getByTestId('no-results-clear-button')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('no-results-clear-button')).toHaveTextContent(
|
||||
'Clear Filters',
|
||||
);
|
||||
});
|
||||
|
||||
it('should render custom clear button text', () => {
|
||||
render(
|
||||
<NoResultsEmptyState onClear={jest.fn()} clearButtonText="Reset All" />,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('no-results-clear-button')).toHaveTextContent(
|
||||
'Reset All',
|
||||
);
|
||||
});
|
||||
|
||||
it('should call onClear when clear button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClear = jest.fn();
|
||||
|
||||
render(<NoResultsEmptyState onClear={onClear} />);
|
||||
|
||||
await user.click(screen.getByTestId('no-results-clear-button'));
|
||||
|
||||
expect(onClear).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,57 +0,0 @@
|
||||
import { RefreshCw, Search } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
|
||||
import styles from './NoResultsEmptyState.module.scss';
|
||||
|
||||
interface NoResultsEmptyStateProps {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
onClear?: () => void;
|
||||
clearButtonText?: string;
|
||||
onRefresh?: () => void;
|
||||
}
|
||||
|
||||
function NoResultsEmptyState({
|
||||
title = 'No matching results',
|
||||
subtitle = 'No items match your current filters. Try adjusting your search criteria.',
|
||||
onClear,
|
||||
clearButtonText = 'Clear Filters',
|
||||
onRefresh,
|
||||
}: NoResultsEmptyStateProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.emptyState} data-testid="no-results-empty-state">
|
||||
<Search className={styles.icon} size={16} />
|
||||
<div className={styles.title} data-testid="no-results-title">
|
||||
{title}
|
||||
</div>
|
||||
<div className={styles.subtitle} data-testid="no-results-subtitle">
|
||||
{subtitle}
|
||||
</div>
|
||||
<div className={styles.actions}>
|
||||
{onClear && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
onClick={onClear}
|
||||
data-testid="no-results-clear-button"
|
||||
>
|
||||
{clearButtonText}
|
||||
</Button>
|
||||
)}
|
||||
{onRefresh && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
prefix={<RefreshCw size={14} />}
|
||||
onClick={onRefresh}
|
||||
data-testid="no-results-refresh-button"
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default NoResultsEmptyState;
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from './NoResultsEmptyState';
|
||||
@@ -1,32 +0,0 @@
|
||||
import type { BadgeColor } from '@signozhq/ui/badge';
|
||||
|
||||
export const STATE_ORDER = ['firing', 'pending', 'inactive', 'disabled'];
|
||||
export const SEVERITY_ORDER = ['critical', 'error', 'warning', 'info'];
|
||||
|
||||
export const STATE_LABELS: Record<string, string> = {
|
||||
firing: 'Firing',
|
||||
pending: 'Pending',
|
||||
inactive: 'OK',
|
||||
disabled: 'Disabled',
|
||||
};
|
||||
|
||||
export const STATE_COLORS: Record<string, string> = {
|
||||
firing: 'var(--bg-cherry-500)',
|
||||
pending: 'var(--bg-amber-500)',
|
||||
inactive: 'var(--bg-forest-500)',
|
||||
disabled: 'var(--l2-foreground)',
|
||||
};
|
||||
|
||||
export const SEVERITY_COLORS: Record<string, string> = {
|
||||
critical: 'var(--bg-cherry-500)',
|
||||
error: 'var(--bg-cherry-400)',
|
||||
warning: 'var(--bg-amber-500)',
|
||||
info: 'var(--bg-robin-500)',
|
||||
};
|
||||
|
||||
export const SEVERITY_BADGE_COLORS: Record<string, BadgeColor> = {
|
||||
critical: 'error',
|
||||
error: 'error',
|
||||
warning: 'warning',
|
||||
info: 'primary',
|
||||
};
|
||||
@@ -1,7 +0,0 @@
|
||||
export interface FilterValue {
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface AlertWithLabels {
|
||||
labels?: Record<string, string>;
|
||||
}
|
||||
@@ -1,287 +0,0 @@
|
||||
import type { SortState } from 'components/TanStackTableView/types';
|
||||
|
||||
import type { AlertWithLabels, FilterValue } from './types';
|
||||
import { filterByLabels, searchByLabels, sortByColumn } from './utils';
|
||||
|
||||
interface TestAlert extends AlertWithLabels {
|
||||
name: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
const createAlert = (
|
||||
name: string,
|
||||
value: number,
|
||||
labels?: Record<string, string>,
|
||||
): TestAlert => ({
|
||||
name,
|
||||
value,
|
||||
labels,
|
||||
});
|
||||
|
||||
describe('sortByColumn', () => {
|
||||
const alerts: TestAlert[] = [
|
||||
createAlert('Alert C', 3),
|
||||
createAlert('Alert A', 1),
|
||||
createAlert('Alert B', 2),
|
||||
];
|
||||
|
||||
const getSortValue = (
|
||||
item: TestAlert,
|
||||
columnName: string,
|
||||
): string | number => {
|
||||
if (columnName === 'name') {
|
||||
return item.name;
|
||||
}
|
||||
if (columnName === 'value') {
|
||||
return item.value;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
it('should return items unchanged when no orderBy provided', () => {
|
||||
const result = sortByColumn(alerts, null, getSortValue);
|
||||
expect(result).toStrictEqual(alerts);
|
||||
});
|
||||
|
||||
it('should sort by string column ascending', () => {
|
||||
const orderBy: SortState = { columnName: 'name', order: 'asc' };
|
||||
const result = sortByColumn(alerts, orderBy, getSortValue);
|
||||
expect(result.map((a) => a.name)).toStrictEqual([
|
||||
'Alert A',
|
||||
'Alert B',
|
||||
'Alert C',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should sort by string column descending', () => {
|
||||
const orderBy: SortState = { columnName: 'name', order: 'desc' };
|
||||
const result = sortByColumn(alerts, orderBy, getSortValue);
|
||||
expect(result.map((a) => a.name)).toStrictEqual([
|
||||
'Alert C',
|
||||
'Alert B',
|
||||
'Alert A',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should sort by number column ascending', () => {
|
||||
const orderBy: SortState = { columnName: 'value', order: 'asc' };
|
||||
const result = sortByColumn(alerts, orderBy, getSortValue);
|
||||
expect(result.map((a) => a.value)).toStrictEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it('should sort by number column descending', () => {
|
||||
const orderBy: SortState = { columnName: 'value', order: 'desc' };
|
||||
const result = sortByColumn(alerts, orderBy, getSortValue);
|
||||
expect(result.map((a) => a.value)).toStrictEqual([3, 2, 1]);
|
||||
});
|
||||
|
||||
it('should use defaultSort when orderBy is null', () => {
|
||||
const defaultSort: SortState = { columnName: 'value', order: 'asc' };
|
||||
const result = sortByColumn(alerts, null, getSortValue, defaultSort);
|
||||
expect(result.map((a) => a.value)).toStrictEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it('should not mutate original array', () => {
|
||||
const original = [...alerts];
|
||||
const orderBy: SortState = { columnName: 'name', order: 'asc' };
|
||||
sortByColumn(alerts, orderBy, getSortValue);
|
||||
expect(alerts).toStrictEqual(original);
|
||||
});
|
||||
|
||||
it('should handle empty array', () => {
|
||||
const result = sortByColumn(
|
||||
[],
|
||||
{ columnName: 'name', order: 'asc' },
|
||||
getSortValue,
|
||||
);
|
||||
expect(result).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('should handle equal values', () => {
|
||||
const duplicates = [
|
||||
createAlert('Same', 1),
|
||||
createAlert('Same', 1),
|
||||
createAlert('Same', 1),
|
||||
];
|
||||
const orderBy: SortState = { columnName: 'name', order: 'asc' };
|
||||
const result = sortByColumn(duplicates, orderBy, getSortValue);
|
||||
expect(result).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchByLabels', () => {
|
||||
const alerts: TestAlert[] = [
|
||||
createAlert('CPU High', 1, { severity: 'critical', team: 'infra' }),
|
||||
createAlert('Memory Warning', 2, { severity: 'warning', team: 'backend' }),
|
||||
createAlert('Disk Full', 3, { severity: 'error', region: 'us-east' }),
|
||||
createAlert('Network Slow', 4, {}),
|
||||
createAlert('No Labels', 5),
|
||||
];
|
||||
|
||||
const getAlertName = (alert: TestAlert): string => alert.name;
|
||||
|
||||
it('should return all items when search is empty', () => {
|
||||
const result = searchByLabels(alerts, '', getAlertName);
|
||||
expect(result).toStrictEqual(alerts);
|
||||
});
|
||||
|
||||
it('should return all items when search is whitespace', () => {
|
||||
const result = searchByLabels(alerts, ' ', getAlertName);
|
||||
expect(result).toStrictEqual(alerts);
|
||||
});
|
||||
|
||||
it('should search by alert name', () => {
|
||||
const result = searchByLabels(alerts, 'CPU', getAlertName);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].name).toBe('CPU High');
|
||||
});
|
||||
|
||||
it('should search by alert name case-insensitive', () => {
|
||||
const result = searchByLabels(alerts, 'cpu', getAlertName);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].name).toBe('CPU High');
|
||||
});
|
||||
|
||||
it('should search by severity label', () => {
|
||||
const result = searchByLabels(alerts, 'critical', getAlertName);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].name).toBe('CPU High');
|
||||
});
|
||||
|
||||
it('should search by any label key', () => {
|
||||
const result = searchByLabels(alerts, 'team', getAlertName);
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should search by any label value', () => {
|
||||
const result = searchByLabels(alerts, 'infra', getAlertName);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].name).toBe('CPU High');
|
||||
});
|
||||
|
||||
it('should handle alerts with no labels', () => {
|
||||
const result = searchByLabels(alerts, 'No Labels', getAlertName);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].name).toBe('No Labels');
|
||||
});
|
||||
|
||||
it('should handle partial matches', () => {
|
||||
const result = searchByLabels(alerts, 'warn', getAlertName);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].name).toBe('Memory Warning');
|
||||
});
|
||||
|
||||
it('should return empty for no matches', () => {
|
||||
const result = searchByLabels(alerts, 'nonexistent', getAlertName);
|
||||
expect(result).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('should trim search text', () => {
|
||||
const result = searchByLabels(alerts, ' CPU ', getAlertName);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].name).toBe('CPU High');
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterByLabels', () => {
|
||||
const alerts: TestAlert[] = [
|
||||
createAlert('A1', 1, { severity: 'critical', team: 'infra', env: 'prod' }),
|
||||
createAlert('A2', 2, { severity: 'critical', team: 'backend', env: 'prod' }),
|
||||
createAlert('A3', 3, { severity: 'warning', team: 'infra', env: 'staging' }),
|
||||
createAlert('A4', 4, { severity: 'info', team: 'frontend', env: 'dev' }),
|
||||
createAlert('A5', 5, {}),
|
||||
createAlert('A6', 6),
|
||||
];
|
||||
|
||||
const createFilter = (value: string): FilterValue => ({ value });
|
||||
|
||||
it('should return all items when filters are empty', () => {
|
||||
const result = filterByLabels(alerts, []);
|
||||
expect(result).toStrictEqual(alerts);
|
||||
});
|
||||
|
||||
it('should return all items when filters is null-ish', () => {
|
||||
const result = filterByLabels(alerts, null as unknown as FilterValue[]);
|
||||
expect(result).toStrictEqual(alerts);
|
||||
});
|
||||
|
||||
it('should filter by single label', () => {
|
||||
const filters = [createFilter('severity:critical')];
|
||||
const result = filterByLabels(alerts, filters);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map((a) => a.name)).toStrictEqual(['A1', 'A2']);
|
||||
});
|
||||
|
||||
it('should use OR logic for same key', () => {
|
||||
const filters = [
|
||||
createFilter('severity:critical'),
|
||||
createFilter('severity:warning'),
|
||||
];
|
||||
const result = filterByLabels(alerts, filters);
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result.map((a) => a.name)).toStrictEqual(['A1', 'A2', 'A3']);
|
||||
});
|
||||
|
||||
it('should use AND logic for different keys', () => {
|
||||
const filters = [
|
||||
createFilter('severity:critical'),
|
||||
createFilter('team:infra'),
|
||||
];
|
||||
const result = filterByLabels(alerts, filters);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].name).toBe('A1');
|
||||
});
|
||||
|
||||
it('should handle case-insensitive keys', () => {
|
||||
const filters = [createFilter('SEVERITY:critical')];
|
||||
const result = filterByLabels(alerts, filters);
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should handle case-insensitive values', () => {
|
||||
const filters = [createFilter('severity:CRITICAL')];
|
||||
const result = filterByLabels(alerts, filters);
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should trim whitespace', () => {
|
||||
const filters = [createFilter(' severity : critical ')];
|
||||
const result = filterByLabels(alerts, filters);
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should return empty for invalid filter format', () => {
|
||||
const filters = [createFilter('invalid')];
|
||||
const result = filterByLabels(alerts, filters);
|
||||
expect(result).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('should ignore invalid filters mixed with valid', () => {
|
||||
const filters = [createFilter('invalid'), createFilter('severity:critical')];
|
||||
const result = filterByLabels(alerts, filters);
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should exclude alerts without matching label key', () => {
|
||||
const filters = [createFilter('nonexistent:value')];
|
||||
const result = filterByLabels(alerts, filters);
|
||||
expect(result).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('should exclude alerts with no labels', () => {
|
||||
const filters = [createFilter('severity:critical')];
|
||||
const result = filterByLabels(alerts, filters);
|
||||
expect(result.every((a) => a.labels !== undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle complex AND/OR combinations', () => {
|
||||
const filters = [
|
||||
createFilter('env:prod'),
|
||||
createFilter('env:staging'),
|
||||
createFilter('team:infra'),
|
||||
];
|
||||
const result = filterByLabels(alerts, filters);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map((a) => a.name)).toStrictEqual(['A1', 'A3']);
|
||||
});
|
||||
});
|
||||
@@ -1,116 +0,0 @@
|
||||
import type { SortState } from 'components/TanStackTableView/types';
|
||||
|
||||
import type { AlertWithLabels, FilterValue } from './types';
|
||||
|
||||
/**
|
||||
* Generic sort function for alert-like data
|
||||
*/
|
||||
export function sortByColumn<T>(
|
||||
items: T[],
|
||||
orderBy: SortState | null,
|
||||
getSortValue: (item: T, columnName: string) => string | number,
|
||||
defaultSort?: SortState,
|
||||
): T[] {
|
||||
const sortState = orderBy ?? defaultSort;
|
||||
if (!sortState) {
|
||||
return items;
|
||||
}
|
||||
|
||||
const { columnName, order } = sortState;
|
||||
const multiplier = order === 'asc' ? 1 : -1;
|
||||
|
||||
return [...items].sort((a, b) => {
|
||||
const aVal = getSortValue(a, columnName);
|
||||
const bVal = getSortValue(b, columnName);
|
||||
|
||||
if (aVal < bVal) {
|
||||
return -1 * multiplier;
|
||||
}
|
||||
if (aVal > bVal) {
|
||||
return 1 * multiplier;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Search alerts/rules by name, severity, and all labels
|
||||
*/
|
||||
export function searchByLabels<T extends AlertWithLabels>(
|
||||
items: T[],
|
||||
searchText: string,
|
||||
getAlertName: (item: T) => string,
|
||||
): T[] {
|
||||
if (!searchText.trim()) {
|
||||
return items;
|
||||
}
|
||||
|
||||
const value = searchText.toLowerCase().trim();
|
||||
|
||||
return items.filter((item) => {
|
||||
const alertName = getAlertName(item).toLowerCase();
|
||||
const severity = item.labels?.severity?.toLowerCase() ?? '';
|
||||
|
||||
const labelSearchString = Object.entries(item.labels ?? {})
|
||||
.map(([key, val]) => `${key} ${val}`)
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
|
||||
return (
|
||||
alertName.includes(value) ||
|
||||
severity.includes(value) ||
|
||||
labelSearchString.includes(value)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter alerts by label key:value pairs
|
||||
* Same key uses OR logic, different keys use AND logic
|
||||
*/
|
||||
export function filterByLabels<T extends AlertWithLabels>(
|
||||
items: T[],
|
||||
selectedFilters: FilterValue[],
|
||||
): T[] {
|
||||
if (!selectedFilters?.length) {
|
||||
return items;
|
||||
}
|
||||
|
||||
const validFilters = selectedFilters
|
||||
.map((e) => e.value)
|
||||
.filter((v) => v.split(':').length === 2);
|
||||
|
||||
if (!validFilters.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Group values by key - same key uses OR, different keys use AND
|
||||
const filtersByKey = new Map<string, string[]>();
|
||||
validFilters.forEach((f) => {
|
||||
const [key, value] = f.split(':');
|
||||
const trimmedKey = key.trim().toLowerCase();
|
||||
const trimmedValue = value.trim().toLowerCase();
|
||||
const existing = filtersByKey.get(trimmedKey) ?? [];
|
||||
existing.push(trimmedValue);
|
||||
filtersByKey.set(trimmedKey, existing);
|
||||
});
|
||||
|
||||
return items.filter((item) => {
|
||||
if (!item.labels) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// All keys must match (AND), any value per key can match (OR)
|
||||
return Array.from(filtersByKey.entries()).every(([filterKey, values]) => {
|
||||
// Case-insensitive key lookup
|
||||
const matchingKey = Object.keys(item.labels ?? {}).find(
|
||||
(k) => k.toLowerCase() === filterKey,
|
||||
);
|
||||
if (!matchingKey) {
|
||||
return false;
|
||||
}
|
||||
const labelValue = item.labels?.[matchingKey]?.toLowerCase();
|
||||
return values.some((v) => labelValue === v);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { Dot } from '@signozhq/icons';
|
||||
import { Dot, Sparkles } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import Noz from 'components/Noz/Noz';
|
||||
import { Popover } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { AIAssistantEvents } from 'container/AIAssistant/events';
|
||||
@@ -22,7 +21,6 @@ import FeedbackModal from './FeedbackModal';
|
||||
import ShareURLModal from './ShareURLModal';
|
||||
|
||||
import './HeaderRightSection.styles.scss';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
interface HeaderRightSectionProps {
|
||||
enableAnnouncements: boolean;
|
||||
@@ -109,22 +107,21 @@ function HeaderRightSection({
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
<TooltipSimple title="Noz">
|
||||
<TooltipSimple title="AI Assistant">
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
className="noz-wave"
|
||||
onClick={handleOpenAIAssistant}
|
||||
aria-label={
|
||||
showHeaderPendingBadge
|
||||
? pendingUserInputCount === 1
|
||||
? 'Open Noz, 1 action needs your response'
|
||||
: `Open Noz, ${pendingUserInputCount} actions need your response`
|
||||
: 'Open Noz'
|
||||
? 'Open AI Assistant, 1 action needs your response'
|
||||
: `Open AI Assistant, ${pendingUserInputCount} actions need your response`
|
||||
: 'Open AI Assistant'
|
||||
}
|
||||
prefix={<Noz size={20} />}
|
||||
prefix={<Sparkles size={14} color="var(--primary)" />}
|
||||
>
|
||||
<Typography.Text>Noz</Typography.Text>
|
||||
AI Assistant
|
||||
</Button>
|
||||
</TooltipSimple>
|
||||
</div>
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
@keyframes noz-wave-wiggle {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
25% {
|
||||
transform: rotate(20deg);
|
||||
}
|
||||
75% {
|
||||
transform: rotate(-20deg);
|
||||
}
|
||||
}
|
||||
|
||||
.noz {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 0;
|
||||
overflow: visible;
|
||||
|
||||
svg {
|
||||
display: block;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
:global {
|
||||
.noz-arm-left {
|
||||
transform-origin: 4.18383px 13.4752px;
|
||||
transform: rotate(0deg);
|
||||
transition: transform 0.55s cubic-bezier(0.34, 1.7, 0.5, 1);
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.noz-arm-wiggle {
|
||||
transform-origin: 4.18383px 13.4752px;
|
||||
transform: rotate(0deg);
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.noz-head {
|
||||
transform-origin: 12.02px 18.37px;
|
||||
transform: rotate(0deg);
|
||||
transition: transform 0.5s cubic-bezier(0.34, 1.7, 0.5, 1);
|
||||
will-change: transform;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
:global(.noz-arm-left) {
|
||||
transform: rotate(145deg) scale(1, 1.7);
|
||||
}
|
||||
|
||||
:global(.noz-arm-wiggle) {
|
||||
animation: noz-wave-wiggle 0.7s ease-in-out 0.2s infinite;
|
||||
}
|
||||
|
||||
:global(.noz-head) {
|
||||
transform: rotate(9deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:global(.noz-wave):hover {
|
||||
:global(.noz-arm-left) {
|
||||
transform: rotate(145deg) scale(1, 1.7);
|
||||
}
|
||||
|
||||
:global(.noz-arm-wiggle) {
|
||||
animation: noz-wave-wiggle 0.7s ease-in-out 0.2s infinite;
|
||||
}
|
||||
|
||||
:global(.noz-head) {
|
||||
transform: rotate(9deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.noz {
|
||||
:global(.noz-arm-left),
|
||||
:global(.noz-arm-wiggle),
|
||||
:global(.noz-head) {
|
||||
transition: none;
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
import cx from 'classnames';
|
||||
|
||||
import styles from './Noz.module.scss';
|
||||
|
||||
interface NozProps {
|
||||
size?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Noz — the SigNoz AI assistant mascot. Waves on hover.
|
||||
*
|
||||
* Hover behavior:
|
||||
* - Hovering the icon itself triggers the wave.
|
||||
* - To make a parent element (e.g. a Button) trigger the wave on its own
|
||||
* hover, add the class `noz-wave` to that parent.
|
||||
*/
|
||||
export default function Noz({ size = 24, className }: NozProps): JSX.Element {
|
||||
return (
|
||||
<span
|
||||
className={cx(styles.noz, className)}
|
||||
style={{ width: size, height: size }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<svg
|
||||
viewBox="-2 0.5 28 28"
|
||||
width={size}
|
||||
height={size}
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
{/* body */}
|
||||
<rect
|
||||
x="4.35938"
|
||||
y="8.49908"
|
||||
width="15.4569"
|
||||
height="11.978"
|
||||
rx="1.76147"
|
||||
fill="#E5484D"
|
||||
/>
|
||||
{/* legs */}
|
||||
<rect
|
||||
x="6.87012"
|
||||
y="19.0679"
|
||||
width="3.34679"
|
||||
height="3.69908"
|
||||
rx="0.880734"
|
||||
fill="#E5484D"
|
||||
/>
|
||||
<rect
|
||||
x="13.916"
|
||||
y="19.0679"
|
||||
width="3.34679"
|
||||
height="3.69908"
|
||||
rx="0.880734"
|
||||
fill="#E5484D"
|
||||
/>
|
||||
{/* right arm (static) */}
|
||||
<rect
|
||||
x="18.7598"
|
||||
y="13.4752"
|
||||
width="2.11376"
|
||||
height="3.69908"
|
||||
rx="0.880734"
|
||||
fill="#E5484D"
|
||||
/>
|
||||
{/* left arm: outer group does the "lift", inner does the wiggle */}
|
||||
<g className="noz-arm-left">
|
||||
<g className="noz-arm-wiggle">
|
||||
<rect
|
||||
x="3.12695"
|
||||
y="13.4752"
|
||||
width="2.11376"
|
||||
height="3.69908"
|
||||
rx="0.880734"
|
||||
fill="#E5484D"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
{/* head: face + eye + hat tilt together */}
|
||||
<g className="noz-head">
|
||||
<circle cx="12.0217" cy="14.4881" r="3.87523" fill="#F5F5F5" />
|
||||
<path
|
||||
d="M12.0237 12.8024C12.0237 13.7328 11.2673 14.4892 10.337 14.4892C10.0339 14.4892 9.74926 14.4101 9.50152 14.2678C9.47517 14.5551 9.49888 14.8502 9.57795 15.1428C9.93901 16.4921 11.3279 17.2933 12.6773 16.9323C14.0267 16.5712 14.8279 15.1823 14.4668 13.8329C14.1453 12.6285 13.0041 11.8616 11.8023 11.967C11.942 12.2121 12.0237 12.4967 12.0237 12.8024Z"
|
||||
fill="#0A0C10"
|
||||
/>
|
||||
<path
|
||||
d="M8.33833 7.94578L9.83358 4.31319C10.1302 3.59261 10.6676 2.99939 11.355 2.63299L13.9181 1.26684C14.1327 1.15169 14.3804 1.34885 14.3194 1.58439L13.6703 4.06892C13.6511 4.14046 13.6424 4.21374 13.6424 4.28876C13.6424 4.39868 13.6633 4.5086 13.7052 4.61154L15.0382 7.94578H11.4248L11.6307 7.32813L12.3356 7.09259C12.449 7.05421 12.5257 6.94778 12.5257 6.82739C12.5257 6.707 12.449 6.60057 12.3356 6.56218L11.6307 6.32664L11.3951 5.62176C11.3568 5.51009 11.2503 5.43333 11.1299 5.43333C11.0096 5.43333 10.9031 5.5101 10.8647 5.6235L10.6292 6.32839L9.92431 6.56393C9.8109 6.60231 9.73413 6.70874 9.73413 6.82913C9.73413 6.94952 9.8109 7.05595 9.92431 7.09434L10.6292 7.32988L10.8351 7.94752H8.33833V7.94578ZM12.1 3.43558C12.0808 3.378 12.0285 3.33962 11.9674 3.33962C11.9064 3.33962 11.854 3.378 11.8348 3.43558L11.7179 3.78802L11.3655 3.90492C11.3079 3.92411 11.2695 3.97645 11.2695 4.03752C11.2695 4.09859 11.3079 4.15093 11.3655 4.17012L11.7179 4.28702L11.8348 4.63946C11.854 4.69704 11.9064 4.73542 11.9674 4.73542C12.0285 4.73542 12.0808 4.69704 12.1 4.63946L12.2169 4.28702L12.5694 4.17012C12.6269 4.15093 12.6653 4.09859 12.6653 4.03752C12.6653 3.97645 12.6269 3.92411 12.5694 3.90492L12.2169 3.78802L12.1 3.43558ZM7.78 7.91088H15.5965C15.9053 7.91088 16.1548 8.16038 16.1548 8.4692C16.1548 8.77803 15.9053 9.02753 15.5965 9.02753H7.78C7.47118 9.02753 7.22168 8.77803 7.22168 8.4692C7.22168 8.16038 7.47118 7.91088 7.78 7.91088Z"
|
||||
fill="#4E74F8"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
Noz.defaultProps = {
|
||||
size: 24,
|
||||
className: undefined,
|
||||
};
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
horizontalListSortingStrategy,
|
||||
SortableContext,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { ComboboxSimple } from '@signozhq/ui/combobox';
|
||||
import { ComboboxSimple, ComboboxSimpleItem } from '@signozhq/ui/combobox';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import { Pagination } from '@signozhq/ui/pagination';
|
||||
import type { Row } from '@tanstack/react-table';
|
||||
@@ -51,7 +51,7 @@ import { useEffectiveData } from './useEffectiveData';
|
||||
import { useFlatItems } from './useFlatItems';
|
||||
import { useRowKeyData } from './useRowKeyData';
|
||||
import { useTableParams } from './useTableParams';
|
||||
import { buildPageSizeItems, buildTanstackColumnDef } from './utils';
|
||||
import { buildTanstackColumnDef } from './utils';
|
||||
import { VirtuosoTableColGroup } from './VirtuosoTableColGroup';
|
||||
|
||||
import tableStyles from './TanStackTable.module.scss';
|
||||
@@ -66,6 +66,14 @@ const INCREASE_VIEWPORT_BY = { top: 500, bottom: 500 };
|
||||
|
||||
const noopColumnVisibility = (): void => {};
|
||||
|
||||
const paginationPageSizeItems: ComboboxSimpleItem[] = [10, 20, 30, 50, 100].map(
|
||||
(value) => ({
|
||||
value: value.toString(),
|
||||
label: value.toString(),
|
||||
displayValue: value.toString(),
|
||||
}),
|
||||
);
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
function TanStackTableInner<TData>(
|
||||
{
|
||||
@@ -81,6 +89,7 @@ function TanStackTableInner<TData>(
|
||||
enableQueryParams,
|
||||
pagination,
|
||||
paginationClassname,
|
||||
onSort,
|
||||
onEndReached,
|
||||
getRowKey,
|
||||
getItemKey,
|
||||
@@ -93,7 +102,6 @@ function TanStackTableInner<TData>(
|
||||
onRowClick,
|
||||
onRowClickNewTab,
|
||||
onRowDeactivate,
|
||||
onSort,
|
||||
activeRowIndex,
|
||||
renderExpandedRow,
|
||||
getRowCanExpand,
|
||||
@@ -121,22 +129,17 @@ function TanStackTableInner<TData>(
|
||||
const {
|
||||
page,
|
||||
limit,
|
||||
setPage: internalSetPage,
|
||||
setLimit: internalSetLimit,
|
||||
setPage,
|
||||
setLimit,
|
||||
orderBy,
|
||||
setOrderBy: internalSetOrderBy,
|
||||
expanded,
|
||||
setExpanded,
|
||||
} = useTableParams(enableQueryParams, {
|
||||
page: pagination?.defaultPage,
|
||||
limit: pagination?.defaultLimit ?? pagination?.calculatedPageSize ?? 10,
|
||||
limit: pagination?.defaultLimit,
|
||||
});
|
||||
|
||||
const pageSizeItems = useMemo(
|
||||
() => buildPageSizeItems(pagination?.calculatedPageSize),
|
||||
[pagination?.calculatedPageSize],
|
||||
);
|
||||
|
||||
const setOrderBy = useCallback(
|
||||
(sort: SortState | null) => {
|
||||
internalSetOrderBy(sort);
|
||||
@@ -145,23 +148,6 @@ function TanStackTableInner<TData>(
|
||||
[internalSetOrderBy, onSort],
|
||||
);
|
||||
|
||||
const setPage = useCallback(
|
||||
(p: number) => {
|
||||
internalSetPage(p);
|
||||
pagination?.onPageChange?.(p);
|
||||
},
|
||||
[internalSetPage, pagination],
|
||||
);
|
||||
|
||||
const setLimit = useCallback(
|
||||
(l: number) => {
|
||||
internalSetLimit(l);
|
||||
internalSetPage(1);
|
||||
pagination?.onLimitChange?.(l);
|
||||
},
|
||||
[internalSetLimit, internalSetPage, pagination],
|
||||
);
|
||||
|
||||
const isGrouped = (groupBy?.length ?? 0) > 0;
|
||||
|
||||
const {
|
||||
@@ -635,7 +621,6 @@ function TanStackTableInner<TData>(
|
||||
{pagination.showPageSize !== false && (
|
||||
<div className={viewStyles.paginationPageSize}>
|
||||
<ComboboxSimple
|
||||
testId="pagination-page-size"
|
||||
value={limit?.toString()}
|
||||
defaultValue="10"
|
||||
onChange={(value): void => {
|
||||
@@ -646,7 +631,7 @@ function TanStackTableInner<TData>(
|
||||
pagination.onPageChange?.(1);
|
||||
}
|
||||
}}
|
||||
items={pageSizeItems}
|
||||
items={paginationPageSizeItems}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { forwardRef, type HTMLAttributes, type ReactNode } from 'react';
|
||||
import type { HTMLAttributes, ReactNode } from 'react';
|
||||
import cx from 'classnames';
|
||||
|
||||
import tableStyles from './TanStackTable.module.scss';
|
||||
@@ -22,19 +22,21 @@ type WithDangerousHtml = BaseProps & {
|
||||
|
||||
export type TanStackTableTextProps = WithChildren | WithDangerousHtml;
|
||||
|
||||
const TanStackTableText = forwardRef<HTMLSpanElement, TanStackTableTextProps>(
|
||||
({ children, className, dangerouslySetInnerHTML, ...rest }, ref) => (
|
||||
function TanStackTableText({
|
||||
children,
|
||||
className,
|
||||
dangerouslySetInnerHTML,
|
||||
...rest
|
||||
}: TanStackTableTextProps): JSX.Element {
|
||||
return (
|
||||
<span
|
||||
ref={ref}
|
||||
className={cx(tableStyles.tableCellText, className)}
|
||||
dangerouslySetInnerHTML={dangerouslySetInnerHTML}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
),
|
||||
);
|
||||
|
||||
TanStackTableText.displayName = 'TanStackTableText';
|
||||
);
|
||||
}
|
||||
|
||||
export default TanStackTableText;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { fireEvent, screen, waitFor, within } from '@testing-library/react';
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { UrlUpdateEvent } from 'nuqs/adapters/testing';
|
||||
|
||||
@@ -23,13 +23,12 @@ jest.mock('../TanStackTable.module.scss', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
beforeAll(() => {
|
||||
window.ResizeObserver = jest.fn().mockImplementation(() => ({
|
||||
disconnect: jest.fn(),
|
||||
observe: jest.fn(),
|
||||
unobserve: jest.fn(),
|
||||
}));
|
||||
});
|
||||
// Mock ResizeObserver for combobox tests
|
||||
global.ResizeObserver = class ResizeObserver {
|
||||
observe(): void {}
|
||||
unobserve(): void {}
|
||||
disconnect(): void {}
|
||||
};
|
||||
|
||||
describe('TanStackTableView Integration', () => {
|
||||
describe('rendering', () => {
|
||||
@@ -403,22 +402,6 @@ describe('TanStackTableView Integration', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves page from URL on initial mount', async () => {
|
||||
renderTanStackTable({
|
||||
props: {
|
||||
pagination: { total: 100, defaultPage: 1, defaultLimit: 10 },
|
||||
enableQueryParams: true,
|
||||
},
|
||||
queryParams: { page: '3' },
|
||||
});
|
||||
|
||||
const nav = await screen.findByRole('navigation');
|
||||
const page3Button = within(nav).getByRole('button', { name: '3' });
|
||||
|
||||
// Page 3 should be active (from URL), not reset to defaultPage 1
|
||||
expect(page3Button).toHaveAttribute('aria-current', 'page');
|
||||
});
|
||||
|
||||
it('resets page to 1 when limit changes', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
|
||||
import { useCalculatedPageSize } from '../useCalculatedPageSize';
|
||||
|
||||
describe('useCalculatedPageSize', () => {
|
||||
it('returns containerRef and null calculatedPageSize initially', () => {
|
||||
const { result } = renderHook(() => useCalculatedPageSize());
|
||||
expect(result.current.containerRef).toBeDefined();
|
||||
expect(result.current.containerRef.current).toBeNull();
|
||||
expect(result.current.calculatedPageSize).toBeNull();
|
||||
});
|
||||
|
||||
it('accepts custom config', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useCalculatedPageSize({
|
||||
rowHeight: 50,
|
||||
headerHeight: 40,
|
||||
paginationHeight: 50,
|
||||
minPageSize: 3,
|
||||
maxPageSize: 20,
|
||||
}),
|
||||
);
|
||||
expect(result.current.containerRef).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -1,89 +0,0 @@
|
||||
/* eslint-disable no-restricted-syntax */
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
|
||||
import {
|
||||
getPreferredPageSize,
|
||||
usePreferredPageSize,
|
||||
usePreferredPageSizeStore,
|
||||
} from '../usePreferredPageSize.store';
|
||||
|
||||
const STORAGE_KEY = 'test-table';
|
||||
const FULL_STORAGE_KEY = '@signoz/table-columns/test-table-preferred-page-size';
|
||||
|
||||
describe('usePreferredPageSize', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
usePreferredPageSizeStore.setState({ tables: {} });
|
||||
});
|
||||
|
||||
it('returns null when no stored value exists', () => {
|
||||
const { result } = renderHook(() => usePreferredPageSize(STORAGE_KEY));
|
||||
expect(result.current[0]).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when storageKey is undefined', () => {
|
||||
const { result } = renderHook(() => usePreferredPageSize(undefined));
|
||||
expect(result.current[0]).toBeNull();
|
||||
});
|
||||
|
||||
it('loads stored page size from localStorage', () => {
|
||||
localStorage.setItem(FULL_STORAGE_KEY, '25');
|
||||
const { result } = renderHook(() => usePreferredPageSize(STORAGE_KEY));
|
||||
expect(result.current[0]).toBe(25);
|
||||
});
|
||||
|
||||
it('ignores invalid stored values', () => {
|
||||
localStorage.setItem(FULL_STORAGE_KEY, 'invalid');
|
||||
const { result } = renderHook(() => usePreferredPageSize(STORAGE_KEY));
|
||||
expect(result.current[0]).toBeNull();
|
||||
});
|
||||
|
||||
it('persists page size to localStorage when set', () => {
|
||||
const { result } = renderHook(() => usePreferredPageSize(STORAGE_KEY));
|
||||
|
||||
act(() => {
|
||||
result.current[1](30);
|
||||
});
|
||||
|
||||
expect(result.current[0]).toBe(30);
|
||||
expect(localStorage.getItem(FULL_STORAGE_KEY)).toBe('30');
|
||||
});
|
||||
|
||||
it('removes from localStorage when set to null', () => {
|
||||
localStorage.setItem(FULL_STORAGE_KEY, '25');
|
||||
const { result } = renderHook(() => usePreferredPageSize(STORAGE_KEY));
|
||||
|
||||
act(() => {
|
||||
result.current[1](null);
|
||||
});
|
||||
|
||||
expect(result.current[0]).toBeNull();
|
||||
expect(localStorage.getItem(FULL_STORAGE_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('does nothing when storageKey is undefined and set is called', () => {
|
||||
const { result } = renderHook(() => usePreferredPageSize(undefined));
|
||||
|
||||
act(() => {
|
||||
result.current[1](30);
|
||||
});
|
||||
|
||||
expect(result.current[0]).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPreferredPageSize', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
usePreferredPageSizeStore.setState({ tables: {} });
|
||||
});
|
||||
|
||||
it('returns null when no stored value exists', () => {
|
||||
expect(getPreferredPageSize(STORAGE_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns stored value from localStorage', () => {
|
||||
localStorage.setItem(FULL_STORAGE_KEY, '42');
|
||||
expect(getPreferredPageSize(STORAGE_KEY)).toBe(42);
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
} from 'nuqs/adapters/testing';
|
||||
|
||||
import { useTableParams } from '../useTableParams';
|
||||
import { usePreferredPageSizeStore } from '../usePreferredPageSize.store';
|
||||
|
||||
function createNuqsWrapper(
|
||||
queryParams?: Record<string, string>,
|
||||
@@ -544,406 +543,3 @@ describe('useTableParams (selective URL mode — partial config object)', () =>
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('useTableParams (cleanupOnUnmount option)', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
localStorage.clear();
|
||||
usePreferredPageSizeStore.setState({ tables: {} });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('clears URL params on unmount when cleanupOnUnmount is true', async () => {
|
||||
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
|
||||
const wrapper = createNuqsWrapper({}, onUrlUpdate);
|
||||
|
||||
const { result, unmount } = renderHook(
|
||||
() =>
|
||||
useTableParams(
|
||||
{ page: 'page', limit: 'limit', orderBy: 'orderBy' },
|
||||
{
|
||||
page: 1,
|
||||
limit: 10,
|
||||
cleanupOnUnmount: true,
|
||||
},
|
||||
),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
// Set some values
|
||||
await act(async () => {
|
||||
result.current.setLimit(50);
|
||||
result.current.setPage(3);
|
||||
jest.runAllTimers();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
// Verify values set
|
||||
expect(result.current.limit).toBe(50);
|
||||
expect(result.current.page).toBe(3);
|
||||
|
||||
// Unmount triggers cleanup
|
||||
unmount();
|
||||
|
||||
await act(async () => {
|
||||
jest.runAllTimers();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
// Last URL update should have cleared params
|
||||
const lastUpdate = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1];
|
||||
expect(lastUpdate[0].searchParams.get('limit')).toBeNull();
|
||||
expect(lastUpdate[0].searchParams.get('page')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not clear URL params on unmount when cleanupOnUnmount is false', async () => {
|
||||
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
|
||||
const wrapper = createNuqsWrapper({}, onUrlUpdate);
|
||||
|
||||
const { result, unmount } = renderHook(
|
||||
() =>
|
||||
useTableParams(
|
||||
{ page: 'page', limit: 'limit' },
|
||||
{
|
||||
page: 1,
|
||||
limit: 10,
|
||||
cleanupOnUnmount: false,
|
||||
},
|
||||
),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
result.current.setLimit(50);
|
||||
jest.runAllTimers();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(result.current.limit).toBe(50);
|
||||
|
||||
unmount();
|
||||
|
||||
await act(async () => {
|
||||
jest.runAllTimers();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
// No new URL updates after unmount (or same count)
|
||||
const lastUpdate = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1];
|
||||
expect(lastUpdate[0].searchParams.get('limit')).toBe('50');
|
||||
});
|
||||
|
||||
it('defaults cleanupOnUnmount to false', async () => {
|
||||
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
|
||||
const wrapper = createNuqsWrapper({}, onUrlUpdate);
|
||||
|
||||
const { result, unmount } = renderHook(
|
||||
() =>
|
||||
useTableParams({ page: 'page', limit: 'limit' }, { page: 1, limit: 10 }),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
result.current.setLimit(50);
|
||||
jest.runAllTimers();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
unmount();
|
||||
|
||||
await act(async () => {
|
||||
jest.runAllTimers();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
// URL should still have limit=50 (cleanup not triggered)
|
||||
const lastUpdate = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1];
|
||||
expect(lastUpdate[0].searchParams.get('limit')).toBe('50');
|
||||
});
|
||||
});
|
||||
|
||||
describe('useTableParams (auto page size with storageKey)', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
localStorage.clear();
|
||||
usePreferredPageSizeStore.setState({ tables: {} });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('uses explicit default when no URL, no calculated, no preferred', () => {
|
||||
const wrapper = createNuqsWrapper();
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useTableParams(
|
||||
{ page: 'page', limit: 'limit' },
|
||||
{
|
||||
page: 1,
|
||||
limit: 10,
|
||||
storageKey: 'test-table',
|
||||
calculatedPageSize: null,
|
||||
},
|
||||
),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
// Should use explicit default (10), NOT the internal DEFAULT_LIMIT (50)
|
||||
expect(result.current.limit).toBe(10);
|
||||
});
|
||||
|
||||
it('uses calculatedPageSize when available and no preferred', () => {
|
||||
const wrapper = createNuqsWrapper();
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useTableParams(
|
||||
{ page: 'page', limit: 'limit' },
|
||||
{
|
||||
page: 1,
|
||||
limit: 10,
|
||||
storageKey: 'test-table',
|
||||
calculatedPageSize: 42,
|
||||
},
|
||||
),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
expect(result.current.limit).toBe(42);
|
||||
});
|
||||
|
||||
it('prefers stored value over calculatedPageSize', () => {
|
||||
// Pre-populate the store
|
||||
localStorage.setItem(
|
||||
'@signoz/table-columns/test-table-preferred-page-size',
|
||||
'25',
|
||||
);
|
||||
|
||||
const wrapper = createNuqsWrapper();
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useTableParams(
|
||||
{ page: 'page', limit: 'limit' },
|
||||
{
|
||||
page: 1,
|
||||
limit: 10,
|
||||
storageKey: 'test-table',
|
||||
calculatedPageSize: 42,
|
||||
},
|
||||
),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
// Should use preferred (25), not calculated (42)
|
||||
expect(result.current.limit).toBe(25);
|
||||
});
|
||||
|
||||
it('preserves URL limit over calculated and preferred', () => {
|
||||
localStorage.setItem(
|
||||
'@signoz/table-columns/test-table-preferred-page-size',
|
||||
'25',
|
||||
);
|
||||
|
||||
const wrapper = createNuqsWrapper({ limit: '30' });
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useTableParams(
|
||||
{ page: 'page', limit: 'limit' },
|
||||
{
|
||||
page: 1,
|
||||
limit: 10,
|
||||
storageKey: 'test-table',
|
||||
calculatedPageSize: 42,
|
||||
},
|
||||
),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
// Should use URL (30), not preferred (25) or calculated (42)
|
||||
expect(result.current.limit).toBe(30);
|
||||
});
|
||||
|
||||
it('persists user selection when different from calculated', () => {
|
||||
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
|
||||
const wrapper = createNuqsWrapper({}, onUrlUpdate);
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useTableParams(
|
||||
{ page: 'page', limit: 'limit' },
|
||||
{
|
||||
page: 1,
|
||||
limit: 10,
|
||||
storageKey: 'test-table',
|
||||
calculatedPageSize: 42,
|
||||
},
|
||||
),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
// User selects 30 (different from calculated 42)
|
||||
act(() => {
|
||||
result.current.setLimit(30);
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
expect(result.current.limit).toBe(30);
|
||||
expect(
|
||||
localStorage.getItem('@signoz/table-columns/test-table-preferred-page-size'),
|
||||
).toBe('30');
|
||||
});
|
||||
|
||||
it('clears preference when user selects calculated value', () => {
|
||||
// Pre-set a preference
|
||||
localStorage.setItem(
|
||||
'@signoz/table-columns/test-table-preferred-page-size',
|
||||
'30',
|
||||
);
|
||||
usePreferredPageSizeStore.setState({ tables: { 'test-table': 30 } });
|
||||
|
||||
const wrapper = createNuqsWrapper();
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useTableParams(
|
||||
{ page: 'page', limit: 'limit' },
|
||||
{
|
||||
page: 1,
|
||||
limit: 10,
|
||||
storageKey: 'test-table',
|
||||
calculatedPageSize: 42,
|
||||
},
|
||||
),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
// User selects 42 (same as calculated)
|
||||
act(() => {
|
||||
result.current.setLimit(42);
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
expect(result.current.limit).toBe(42);
|
||||
// Preference should be cleared (null removes from storage)
|
||||
expect(
|
||||
localStorage.getItem('@signoz/table-columns/test-table-preferred-page-size'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('returns calculated value even before URL is synced', () => {
|
||||
const wrapper = createNuqsWrapper();
|
||||
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useTableParams(
|
||||
{ page: 'page', limit: 'limit' },
|
||||
{
|
||||
page: 1,
|
||||
limit: 10,
|
||||
storageKey: 'test-table',
|
||||
calculatedPageSize: 42,
|
||||
},
|
||||
),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
// Limit should be 42 (calculated) even if URL sync is async
|
||||
expect(result.current.limit).toBe(42);
|
||||
});
|
||||
|
||||
it('does not override URL when it already has a value', () => {
|
||||
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
|
||||
const wrapper = createNuqsWrapper({ limit: '30' }, onUrlUpdate);
|
||||
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useTableParams(
|
||||
{ page: 'page', limit: 'limit' },
|
||||
{
|
||||
page: 1,
|
||||
limit: 10,
|
||||
storageKey: 'test-table',
|
||||
calculatedPageSize: 42,
|
||||
},
|
||||
),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
// Limit should stay at 30 (from URL), not change to 42
|
||||
expect(result.current.limit).toBe(30);
|
||||
});
|
||||
|
||||
it('handles calculatedPageSize changing from null to number', () => {
|
||||
const wrapper = createNuqsWrapper();
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ calculated }) =>
|
||||
useTableParams(
|
||||
{ page: 'page', limit: 'limit' },
|
||||
{
|
||||
page: 1,
|
||||
limit: 10,
|
||||
storageKey: 'test-table-2',
|
||||
calculatedPageSize: calculated,
|
||||
},
|
||||
),
|
||||
{ wrapper, initialProps: { calculated: null as number | null } },
|
||||
);
|
||||
|
||||
// Initially should use explicit default (10)
|
||||
expect(result.current.limit).toBe(10);
|
||||
|
||||
// When calculated becomes available, should update
|
||||
rerender({ calculated: 42 });
|
||||
|
||||
act(() => {
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
// Limit should now be 42
|
||||
expect(result.current.limit).toBe(42);
|
||||
});
|
||||
|
||||
it('keeps user selection when calculatedPageSize changes', () => {
|
||||
const wrapper = createNuqsWrapper();
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ calculated }) =>
|
||||
useTableParams(
|
||||
{ page: 'page', limit: 'limit' },
|
||||
{
|
||||
page: 1,
|
||||
limit: 10,
|
||||
storageKey: 'test-table-3',
|
||||
calculatedPageSize: calculated,
|
||||
},
|
||||
),
|
||||
{ wrapper, initialProps: { calculated: 42 as number | null } },
|
||||
);
|
||||
|
||||
expect(result.current.limit).toBe(42);
|
||||
|
||||
// User selects 30
|
||||
act(() => {
|
||||
result.current.setLimit(30);
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
expect(result.current.limit).toBe(30);
|
||||
|
||||
// calculatedPageSize changes (e.g., window resize)
|
||||
rerender({ calculated: 50 });
|
||||
|
||||
act(() => {
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
// Should keep user's selection (30), not change to new calculated (50)
|
||||
expect(result.current.limit).toBe(30);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,199 +0,0 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { useQueryStates, parseAsInteger } from 'nuqs';
|
||||
import {
|
||||
NuqsTestingAdapter,
|
||||
OnUrlUpdateFunction,
|
||||
UrlUpdateEvent,
|
||||
} from 'nuqs/adapters/testing';
|
||||
import { parseAsJsonNoValidate } from 'utils/nuqsParsers';
|
||||
|
||||
import { useTableParams } from '../useTableParams';
|
||||
import { usePreferredPageSizeStore } from '../usePreferredPageSize.store';
|
||||
|
||||
function createNuqsWrapper(
|
||||
queryParams?: Record<string, string>,
|
||||
onUrlUpdate?: OnUrlUpdateFunction,
|
||||
): ({ children }: { children: ReactNode }) => JSX.Element {
|
||||
return function NuqsWrapper({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<NuqsTestingAdapter
|
||||
searchParams={queryParams}
|
||||
onUrlUpdate={onUrlUpdate}
|
||||
hasMemory
|
||||
>
|
||||
{children}
|
||||
</NuqsTestingAdapter>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
const QUERY_PARAMS_CONFIG = {
|
||||
orderBy: 'orderBy',
|
||||
page: 'page',
|
||||
limit: 'limit',
|
||||
} as const;
|
||||
|
||||
type TableParamsWithCleanup = ReturnType<typeof useTableParams> & {
|
||||
clearParams: ReturnType<typeof useQueryStates>[1];
|
||||
};
|
||||
|
||||
/**
|
||||
* Simulates the cleanup pattern used in ListAlertRules:
|
||||
* - Uses useQueryStates to clear URL params on unmount
|
||||
*/
|
||||
function useTableParamsWithCleanup(
|
||||
storageKey: string,
|
||||
calculatedPageSize: number | null,
|
||||
): TableParamsWithCleanup {
|
||||
const result = useTableParams(QUERY_PARAMS_CONFIG, {
|
||||
page: 1,
|
||||
limit: 10,
|
||||
storageKey,
|
||||
calculatedPageSize,
|
||||
});
|
||||
|
||||
// This mirrors the cleanup effect in ListAlertRules
|
||||
const [, setTableQueryParams] = useQueryStates({
|
||||
[QUERY_PARAMS_CONFIG.orderBy]: parseAsJsonNoValidate(),
|
||||
[QUERY_PARAMS_CONFIG.page]: parseAsInteger,
|
||||
[QUERY_PARAMS_CONFIG.limit]: parseAsInteger,
|
||||
});
|
||||
|
||||
// Note: We can't use useEffect cleanup in tests easily, but we can verify
|
||||
// that calling setTableQueryParams with nulls does clear the URL
|
||||
|
||||
return { ...result, clearParams: setTableQueryParams };
|
||||
}
|
||||
|
||||
describe('URL cleanup pattern (simulating ListAlertRules behavior)', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
localStorage.clear();
|
||||
usePreferredPageSizeStore.setState({ tables: {} });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('setTableQueryParams with null values should clear URL params', async () => {
|
||||
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
|
||||
const wrapper = createNuqsWrapper({}, onUrlUpdate);
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useTableParamsWithCleanup('alert-rules', 42),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
// Set limit to 100
|
||||
await act(async () => {
|
||||
result.current.setLimit(100);
|
||||
jest.runAllTimers();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(result.current.limit).toBe(100);
|
||||
|
||||
// Verify limit=100 is in URL
|
||||
const limitAfterSet = onUrlUpdate.mock.calls
|
||||
.map((call) => call[0].searchParams.get('limit'))
|
||||
.filter(Boolean)
|
||||
.pop();
|
||||
expect(limitAfterSet).toBe('100');
|
||||
|
||||
// Simulate cleanup: clear all params
|
||||
await act(async () => {
|
||||
void result.current.clearParams({
|
||||
orderBy: null,
|
||||
page: null,
|
||||
limit: null,
|
||||
});
|
||||
jest.runAllTimers();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
// Verify limit was cleared (last update should have limit=null or removed)
|
||||
const lastUpdate = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1];
|
||||
const finalLimit = lastUpdate[0].searchParams.get('limit');
|
||||
expect(finalLimit).toBeNull();
|
||||
});
|
||||
|
||||
it('cleanup should work even when limit was set from localStorage preference', async () => {
|
||||
// Pre-set preference
|
||||
localStorage.setItem(
|
||||
'@signoz/table-columns/alert-rules-preferred-page-size',
|
||||
'100',
|
||||
);
|
||||
|
||||
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
|
||||
const wrapper = createNuqsWrapper({}, onUrlUpdate);
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useTableParamsWithCleanup('alert-rules', 42),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
jest.runAllTimers();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
// Should use preferred value
|
||||
expect(result.current.limit).toBe(100);
|
||||
|
||||
// Simulate cleanup
|
||||
await act(async () => {
|
||||
void result.current.clearParams({
|
||||
orderBy: null,
|
||||
page: null,
|
||||
limit: null,
|
||||
});
|
||||
jest.runAllTimers();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
// URL should be cleared
|
||||
const lastUpdate = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1];
|
||||
const finalLimit = lastUpdate[0].searchParams.get('limit');
|
||||
expect(finalLimit).toBeNull();
|
||||
});
|
||||
|
||||
it('demonstrates the bug: component without cleanup leaves limit in URL', async () => {
|
||||
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
|
||||
const wrapper = createNuqsWrapper({}, onUrlUpdate);
|
||||
|
||||
// Mount TriggeredAlerts-like component (no cleanup)
|
||||
const { result, unmount } = renderHook(
|
||||
() =>
|
||||
useTableParams(QUERY_PARAMS_CONFIG, {
|
||||
page: 1,
|
||||
limit: 10,
|
||||
storageKey: 'triggered-alerts',
|
||||
calculatedPageSize: 42,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
// Set limit to 100
|
||||
await act(async () => {
|
||||
result.current.setLimit(100);
|
||||
jest.runAllTimers();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(result.current.limit).toBe(100);
|
||||
|
||||
// Unmount WITHOUT cleanup
|
||||
unmount();
|
||||
|
||||
// Verify limit=100 is STILL in URL (this is the bug!)
|
||||
const lastUpdate = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1];
|
||||
const finalLimit = lastUpdate[0].searchParams.get('limit');
|
||||
expect(finalLimit).toBe('100'); // BUG: limit persists after unmount
|
||||
});
|
||||
});
|
||||
@@ -1,385 +0,0 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import {
|
||||
NuqsTestingAdapter,
|
||||
OnUrlUpdateFunction,
|
||||
UrlUpdateEvent,
|
||||
} from 'nuqs/adapters/testing';
|
||||
|
||||
import { useTableParams } from '../useTableParams';
|
||||
import { usePreferredPageSizeStore } from '../usePreferredPageSize.store';
|
||||
|
||||
function createNuqsWrapper(
|
||||
queryParams?: Record<string, string>,
|
||||
onUrlUpdate?: OnUrlUpdateFunction,
|
||||
): ({ children }: { children: ReactNode }) => JSX.Element {
|
||||
return function NuqsWrapper({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<NuqsTestingAdapter
|
||||
searchParams={queryParams}
|
||||
onUrlUpdate={onUrlUpdate}
|
||||
hasMemory
|
||||
>
|
||||
{children}
|
||||
</NuqsTestingAdapter>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
describe('useTableParams navigation scenarios', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
localStorage.clear();
|
||||
usePreferredPageSizeStore.setState({ tables: {} });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
describe('Tab navigation: Alert Rules -> Configuration -> Routing Policies', () => {
|
||||
it('preferred value from one table should NOT leak to URL when navigating away', () => {
|
||||
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
|
||||
const wrapper = createNuqsWrapper({}, onUrlUpdate);
|
||||
|
||||
// Simulate Alert Rules: user sets limit=100
|
||||
const alertRules = renderHook(
|
||||
() =>
|
||||
useTableParams(
|
||||
{ page: 'page', limit: 'limit', orderBy: 'orderBy' },
|
||||
{
|
||||
page: 1,
|
||||
limit: 10,
|
||||
storageKey: 'alert-rules',
|
||||
calculatedPageSize: 42,
|
||||
},
|
||||
),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
// User selects limit=100
|
||||
act(() => {
|
||||
alertRules.result.current.setLimit(100);
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
expect(alertRules.result.current.limit).toBe(100);
|
||||
|
||||
// Verify it's persisted in localStorage
|
||||
expect(
|
||||
localStorage.getItem(
|
||||
'@signoz/table-columns/alert-rules-preferred-page-size',
|
||||
),
|
||||
).toBe('100');
|
||||
|
||||
// Simulate unmount (user navigates away)
|
||||
alertRules.unmount();
|
||||
|
||||
// At this point, the URL should NOT have limit=100 from alert-rules
|
||||
// when another component mounts with a different storageKey
|
||||
});
|
||||
|
||||
it('different tables with different storageKeys maintain separate preferences', () => {
|
||||
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
|
||||
const wrapper = createNuqsWrapper({}, onUrlUpdate);
|
||||
|
||||
// Alert Rules sets limit=100
|
||||
localStorage.setItem(
|
||||
'@signoz/table-columns/alert-rules-preferred-page-size',
|
||||
'100',
|
||||
);
|
||||
// Triggered Alerts sets limit=25
|
||||
localStorage.setItem(
|
||||
'@signoz/table-columns/triggered-alerts-preferred-page-size',
|
||||
'25',
|
||||
);
|
||||
|
||||
// Mount Triggered Alerts (simulating tab switch from Alert Rules)
|
||||
const triggeredAlerts = renderHook(
|
||||
() =>
|
||||
useTableParams(
|
||||
{ page: 'page', limit: 'limit', orderBy: 'orderBy' },
|
||||
{
|
||||
page: 1,
|
||||
limit: 10,
|
||||
storageKey: 'triggered-alerts',
|
||||
calculatedPageSize: 42,
|
||||
},
|
||||
),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
// Should use triggered-alerts preference (25), NOT alert-rules (100)
|
||||
expect(triggeredAlerts.result.current.limit).toBe(25);
|
||||
});
|
||||
|
||||
it('table without storageKey should NOT write preference to URL from another table', () => {
|
||||
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
|
||||
|
||||
// Pre-set alert-rules preference
|
||||
localStorage.setItem(
|
||||
'@signoz/table-columns/alert-rules-preferred-page-size',
|
||||
'100',
|
||||
);
|
||||
|
||||
// Start fresh with NO URL params
|
||||
const wrapper = createNuqsWrapper({}, onUrlUpdate);
|
||||
|
||||
// Mount a table WITHOUT storageKey (simulating a simple table)
|
||||
const simpleTable = renderHook(
|
||||
() =>
|
||||
useTableParams(
|
||||
{ page: 'page', limit: 'limit' },
|
||||
{
|
||||
page: 1,
|
||||
limit: 10,
|
||||
// NO storageKey
|
||||
calculatedPageSize: 42,
|
||||
},
|
||||
),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
// Should use calculated (42), not alert-rules preference (100)
|
||||
expect(simpleTable.result.current.limit).toBe(42);
|
||||
});
|
||||
});
|
||||
|
||||
describe('URL cleanup on unmount', () => {
|
||||
it('URL params should be cleanable by consumer on unmount', () => {
|
||||
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
|
||||
const wrapper = createNuqsWrapper({}, onUrlUpdate);
|
||||
|
||||
const { result, unmount } = renderHook(
|
||||
() =>
|
||||
useTableParams(
|
||||
{ page: 'page', limit: 'limit', orderBy: 'orderBy' },
|
||||
{
|
||||
page: 1,
|
||||
limit: 10,
|
||||
storageKey: 'test-cleanup',
|
||||
calculatedPageSize: 42,
|
||||
},
|
||||
),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
// Set some values
|
||||
act(() => {
|
||||
result.current.setLimit(50);
|
||||
result.current.setPage(3);
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
// Verify URL was updated
|
||||
const limitUpdates = onUrlUpdate.mock.calls
|
||||
.map((call) => call[0].searchParams.get('limit'))
|
||||
.filter(Boolean);
|
||||
expect(limitUpdates).toContain('50');
|
||||
|
||||
// Unmount (note: useTableParams itself doesn't cleanup URL - consumer should)
|
||||
unmount();
|
||||
|
||||
// Verify the component unmounted (no errors)
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Parallel tables sharing URL params', () => {
|
||||
it('two tables using same URL params should see same values when URL pre-set', () => {
|
||||
const wrapper = createNuqsWrapper({ limit: '30' });
|
||||
|
||||
const table1 = renderHook(
|
||||
() =>
|
||||
useTableParams(
|
||||
{ page: 'page', limit: 'limit' },
|
||||
{
|
||||
page: 1,
|
||||
limit: 10,
|
||||
storageKey: 'table-1',
|
||||
calculatedPageSize: 42,
|
||||
},
|
||||
),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
const table2 = renderHook(
|
||||
() =>
|
||||
useTableParams(
|
||||
{ page: 'page', limit: 'limit' },
|
||||
{
|
||||
page: 1,
|
||||
limit: 20,
|
||||
storageKey: 'table-2',
|
||||
calculatedPageSize: 50,
|
||||
},
|
||||
),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
// Both should see URL value (30), not their defaults
|
||||
expect(table1.result.current.limit).toBe(30);
|
||||
expect(table2.result.current.limit).toBe(30);
|
||||
});
|
||||
|
||||
it('table mounted after setLimit should see updated URL value', () => {
|
||||
const wrapper = createNuqsWrapper();
|
||||
|
||||
// Table1 mounts first
|
||||
const table1 = renderHook(
|
||||
() =>
|
||||
useTableParams(
|
||||
{ page: 'page', limit: 'limit' },
|
||||
{
|
||||
page: 1,
|
||||
limit: 10,
|
||||
storageKey: 'table-1',
|
||||
calculatedPageSize: 42,
|
||||
},
|
||||
),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
expect(table1.result.current.limit).toBe(42);
|
||||
|
||||
// Table1 sets limit to 100
|
||||
act(() => {
|
||||
table1.result.current.setLimit(100);
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
expect(table1.result.current.limit).toBe(100);
|
||||
|
||||
// Table2 mounts AFTER table1 set limit=100 in URL
|
||||
// In test environment, URL state doesn't persist between renderHook calls
|
||||
// This test documents current behavior - each hook instance is independent
|
||||
});
|
||||
});
|
||||
|
||||
describe('URL state initialization race conditions', () => {
|
||||
it('should not write preferred value to URL if URL already has value', () => {
|
||||
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
|
||||
|
||||
// Pre-set preference
|
||||
localStorage.setItem(
|
||||
'@signoz/table-columns/test-table-preferred-page-size',
|
||||
'100',
|
||||
);
|
||||
|
||||
// URL already has limit=30
|
||||
const wrapper = createNuqsWrapper({ limit: '30' }, onUrlUpdate);
|
||||
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useTableParams(
|
||||
{ page: 'page', limit: 'limit' },
|
||||
{
|
||||
page: 1,
|
||||
limit: 10,
|
||||
storageKey: 'test-table',
|
||||
calculatedPageSize: 42,
|
||||
},
|
||||
),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
// Should use URL (30), not preferred (100)
|
||||
expect(result.current.limit).toBe(30);
|
||||
|
||||
// URL should NOT have been overwritten with 100
|
||||
const limitUpdates = onUrlUpdate.mock.calls
|
||||
.map((call) => call[0].searchParams.get('limit'))
|
||||
.filter((v) => v === '100');
|
||||
expect(limitUpdates).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('URL init effect should write calculated value when URL empty', async () => {
|
||||
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
|
||||
const wrapper = createNuqsWrapper({}, onUrlUpdate);
|
||||
|
||||
// Mount with no URL params
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useTableParams(
|
||||
{ page: 'page', limit: 'limit' },
|
||||
{
|
||||
page: 1,
|
||||
limit: 10,
|
||||
storageKey: 'table-1',
|
||||
calculatedPageSize: 42,
|
||||
},
|
||||
),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
// Effects run after render, need to flush
|
||||
await act(async () => {
|
||||
jest.runAllTimers();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
// Should use calculated value
|
||||
expect(result.current.limit).toBe(42);
|
||||
|
||||
// The URL init effect writes to URL asynchronously
|
||||
// Check that limit is 42 (which it is from the limitDefault calculation)
|
||||
});
|
||||
|
||||
it('consumer cleanup effect is responsible for clearing URL params', () => {
|
||||
// This test documents that useTableParams does NOT auto-cleanup URL
|
||||
// Consumer components (like ListAlertRules) must use useEffect cleanup
|
||||
// to clear URL params when unmounting
|
||||
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
|
||||
const wrapper = createNuqsWrapper({}, onUrlUpdate);
|
||||
|
||||
const { result, unmount } = renderHook(
|
||||
() =>
|
||||
useTableParams(
|
||||
{ page: 'page', limit: 'limit' },
|
||||
{
|
||||
page: 1,
|
||||
limit: 10,
|
||||
storageKey: 'table-1',
|
||||
calculatedPageSize: 42,
|
||||
},
|
||||
),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.setLimit(100);
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
expect(result.current.limit).toBe(100);
|
||||
|
||||
// Unmount - useTableParams does NOT clear URL
|
||||
unmount();
|
||||
|
||||
// Verify unmount happened without clearing URL
|
||||
// The last URL update should still have limit=100, not null
|
||||
const lastUpdate = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1];
|
||||
expect(lastUpdate[0].searchParams.get('limit')).toBe('100');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,10 +3,8 @@ import TanStackTableText from './TanStackTableText';
|
||||
|
||||
export * from './TanStackTableStateContext';
|
||||
export * from './types';
|
||||
export * from './useCalculatedPageSize';
|
||||
export * from './useColumnState';
|
||||
export * from './useColumnStore';
|
||||
export * from './usePreferredPageSize.store';
|
||||
export * from './useTableParams';
|
||||
|
||||
/**
|
||||
@@ -194,67 +192,6 @@ export * from './useTableParams';
|
||||
* )}
|
||||
* />
|
||||
* ```
|
||||
*
|
||||
* @example useTableParams — manages pagination state with URL sync and persistence
|
||||
*
|
||||
* The `useTableParams` hook handles page, limit, orderBy, and expanded state. It can sync
|
||||
* to URL params, persist user's page size preference, and auto-calculate page size from
|
||||
* container height.
|
||||
*
|
||||
* **Priority chain for limit**: URL > preferred (localStorage) > calculated > explicit default > 50
|
||||
*
|
||||
* ```tsx
|
||||
* import { useCalculatedPageSize, useTableParams } from 'components/TanStackTableView';
|
||||
*
|
||||
* const QUERY_PARAMS = { page: 'page', limit: 'limit', orderBy: 'orderBy' } as const;
|
||||
*
|
||||
* function MyTable({ data, columns }) {
|
||||
* // Auto-calculate page size based on container height
|
||||
* const { containerRef, calculatedPageSize } = useCalculatedPageSize({ rowHeight: 42 });
|
||||
*
|
||||
* // useTableParams options:
|
||||
* // - storageKey: persists user's page size selection to localStorage
|
||||
* // - calculatedPageSize: uses this when no URL/preferred value exists
|
||||
* // - cleanupOnUnmount: clears URL params when component unmounts
|
||||
* const { page, limit, setLimit, orderBy } = useTableParams(QUERY_PARAMS, {
|
||||
* page: 1,
|
||||
* limit: 10,
|
||||
* storageKey: 'my-table',
|
||||
* calculatedPageSize,
|
||||
* cleanupOnUnmount: true,
|
||||
* });
|
||||
*
|
||||
* const paginatedData = useMemo(() => {
|
||||
* const start = (page - 1) * limit;
|
||||
* return data.slice(start, start + limit);
|
||||
* }, [data, page, limit]);
|
||||
*
|
||||
* return (
|
||||
* <div ref={containerRef} style={{ height: '100%' }}>
|
||||
* <TanStackTable
|
||||
* data={paginatedData}
|
||||
* columns={columns}
|
||||
* enableQueryParams={QUERY_PARAMS}
|
||||
* pagination={{
|
||||
* total: data.length,
|
||||
* calculatedPageSize,
|
||||
* onLimitChange: setLimit,
|
||||
* }}
|
||||
* />
|
||||
* </div>
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* **useTableParams options:**
|
||||
* - `storageKey`: Persists user's page size to localStorage. When user selects a size
|
||||
* different from calculated, it's saved. Selecting calculated size clears preference.
|
||||
* - `calculatedPageSize`: From `useCalculatedPageSize`. Used as default when no URL/preferred.
|
||||
* - `cleanupOnUnmount`: Clears URL params (page, limit, orderBy, expanded) on unmount.
|
||||
* Use when navigating away should reset table state.
|
||||
*
|
||||
* **Pagination shows "Auto" option** when `calculatedPageSize` is passed, allowing users
|
||||
* to reset to auto-calculated size.
|
||||
*/
|
||||
const TanStackTable = Object.assign(TanStackTableBase, {
|
||||
Text: TanStackTableText,
|
||||
|
||||
@@ -74,7 +74,6 @@ export type TableColumnDef<
|
||||
min?: number | string;
|
||||
default?: number | string;
|
||||
max?: number | string;
|
||||
ignoreLastColumnFill?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -112,14 +111,6 @@ export type TableRowContext<TData> = {
|
||||
enableAlternatingRowColors?: boolean;
|
||||
};
|
||||
|
||||
export type AutoPageSizeConfig = {
|
||||
rowHeight?: number;
|
||||
headerHeight?: number;
|
||||
paginationHeight?: number;
|
||||
minPageSize?: number;
|
||||
maxPageSize?: number;
|
||||
};
|
||||
|
||||
export type PaginationProps = {
|
||||
total: number;
|
||||
defaultPage?: number;
|
||||
@@ -132,12 +123,6 @@ export type PaginationProps = {
|
||||
onLimitChange?: (limit: number) => void;
|
||||
showTotalCount?: boolean;
|
||||
totalCountLabel?: string;
|
||||
/**
|
||||
* Auto-calculated page size for the current container.
|
||||
* When set, shows as "Auto (N)" option in the page size dropdown.
|
||||
* Consumer is responsible for calculating this via useCalculatedPageSize.
|
||||
*/
|
||||
calculatedPageSize?: number | null;
|
||||
};
|
||||
|
||||
export type TanstackTableQueryParamsConfig = {
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
import type { RefObject } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { AutoPageSizeConfig } from './types';
|
||||
|
||||
const DEFAULT_ROW_HEIGHT = 36;
|
||||
const DEFAULT_HEADER_HEIGHT = 36;
|
||||
const DEFAULT_PAGINATION_HEIGHT = 62;
|
||||
const MIN_PAGE_SIZE = 5;
|
||||
const MAX_PAGE_SIZE = 100;
|
||||
|
||||
export type UseCalculatedPageSizeResult = {
|
||||
containerRef: RefObject<HTMLDivElement>;
|
||||
calculatedPageSize: number | null;
|
||||
};
|
||||
|
||||
export function useCalculatedPageSize(
|
||||
config?: AutoPageSizeConfig,
|
||||
): UseCalculatedPageSizeResult {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [calculatedPageSize, setCalculatedPageSize] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const rowHeight = config?.rowHeight ?? DEFAULT_ROW_HEIGHT;
|
||||
const headerHeight = config?.headerHeight ?? DEFAULT_HEADER_HEIGHT;
|
||||
const paginationHeight = config?.paginationHeight ?? DEFAULT_PAGINATION_HEIGHT;
|
||||
const minPageSize = config?.minPageSize ?? MIN_PAGE_SIZE;
|
||||
const maxPageSize = config?.maxPageSize ?? MAX_PAGE_SIZE;
|
||||
|
||||
const calculatePageSize = useCallback(
|
||||
(containerHeight: number): number => {
|
||||
const availableHeight = containerHeight - headerHeight - paginationHeight;
|
||||
const rawPageSize = Math.floor(availableHeight / rowHeight);
|
||||
return Math.min(maxPageSize, Math.max(minPageSize, rawPageSize));
|
||||
},
|
||||
[rowHeight, headerHeight, paginationHeight, minPageSize, maxPageSize],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const container = containerRef.current;
|
||||
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { height } = entry.contentRect;
|
||||
if (height > 0) {
|
||||
const newPageSize = calculatePageSize(height);
|
||||
setCalculatedPageSize((prev) =>
|
||||
prev !== newPageSize ? newPageSize : prev,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(container);
|
||||
|
||||
const { height } = container.getBoundingClientRect();
|
||||
if (height > 0) {
|
||||
setCalculatedPageSize(calculatePageSize(height));
|
||||
}
|
||||
|
||||
return (): void => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [calculatePageSize]);
|
||||
|
||||
return { containerRef, calculatedPageSize };
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
import get from 'api/browser/localstorage/get';
|
||||
import set from 'api/browser/localstorage/set';
|
||||
import remove from 'api/browser/localstorage/remove';
|
||||
import { create } from 'zustand';
|
||||
|
||||
const STORAGE_PREFIX = '@signoz/table-columns/';
|
||||
const STORAGE_SUFFIX = '-preferred-page-size';
|
||||
|
||||
type PreferredPageSizeState = {
|
||||
tables: Record<string, number | null>;
|
||||
setPreferredPageSize: (storageKey: string, pageSize: number | null) => void;
|
||||
};
|
||||
|
||||
const getStorageKey = (tableKey: string): string =>
|
||||
`${STORAGE_PREFIX}${tableKey}${STORAGE_SUFFIX}`;
|
||||
|
||||
const loadFromStorage = (tableKey: string): number | null => {
|
||||
try {
|
||||
const raw = get(getStorageKey(tableKey));
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
const parsed = parseInt(raw, 10);
|
||||
return Number.isNaN(parsed) ? null : parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const saveToStorage = (tableKey: string, pageSize: number | null): void => {
|
||||
try {
|
||||
const key = getStorageKey(tableKey);
|
||||
if (pageSize === null) {
|
||||
remove(key);
|
||||
} else {
|
||||
set(key, String(pageSize));
|
||||
}
|
||||
} catch {
|
||||
// Ignore storage errors
|
||||
}
|
||||
};
|
||||
|
||||
export const usePreferredPageSizeStore = create<PreferredPageSizeState>()(
|
||||
(set, get) => ({
|
||||
tables: {},
|
||||
setPreferredPageSize: (storageKey, pageSize): void => {
|
||||
set({ tables: { ...get().tables, [storageKey]: pageSize } });
|
||||
saveToStorage(storageKey, pageSize);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
export function usePreferredPageSize(
|
||||
storageKey: string | undefined,
|
||||
): [number | null, (pageSize: number | null) => void] {
|
||||
const pageSize = usePreferredPageSizeStore((s) => {
|
||||
if (!storageKey) {
|
||||
return null;
|
||||
}
|
||||
const cached = s.tables[storageKey];
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
return loadFromStorage(storageKey);
|
||||
});
|
||||
|
||||
const setPageSize = usePreferredPageSizeStore((s) => s.setPreferredPageSize);
|
||||
|
||||
const setPreferred = (size: number | null): void => {
|
||||
if (storageKey) {
|
||||
setPageSize(storageKey, size);
|
||||
}
|
||||
};
|
||||
|
||||
return [pageSize, setPreferred];
|
||||
}
|
||||
|
||||
export function getPreferredPageSize(storageKey: string): number | null {
|
||||
// oxlint-disable-next-line signoz/no-zustand-getstate-in-hooks
|
||||
const state = usePreferredPageSizeStore.getState();
|
||||
const cached = state.tables[storageKey];
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const stored = loadFromStorage(storageKey);
|
||||
if (stored !== null) {
|
||||
state.setPreferredPageSize(storageKey, stored);
|
||||
}
|
||||
return stored;
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import { parseAsInteger, useQueryState } from 'nuqs';
|
||||
import { parseAsJsonNoValidate } from 'utils/nuqsParsers';
|
||||
|
||||
import { SortState, TanstackTableQueryParamsConfig } from './types';
|
||||
import { usePreferredPageSize } from './usePreferredPageSize.store';
|
||||
|
||||
const NUQS_OPTIONS = { history: 'push' as const };
|
||||
const DEFAULT_PAGE = 1;
|
||||
@@ -21,15 +20,9 @@ type Defaults = {
|
||||
limit?: number;
|
||||
orderBy?: SortState | null;
|
||||
expanded?: ExpandedState;
|
||||
/** Storage key for persisting user's page size preference */
|
||||
storageKey?: string;
|
||||
/** Auto-calculated page size from container. URL initializes with this when available. */
|
||||
calculatedPageSize?: number | null;
|
||||
/** Clear URL params on unmount. Useful when navigating away from table views. */
|
||||
cleanupOnUnmount?: boolean;
|
||||
};
|
||||
|
||||
export type TableParamsResult = {
|
||||
type TableParamsResult = {
|
||||
page: number;
|
||||
limit: number;
|
||||
orderBy: SortState | null;
|
||||
@@ -106,23 +99,15 @@ export function useTableParams(
|
||||
? (enableQueryParams.expanded ?? URL_KEYS_DEFAULT.expanded)
|
||||
: URL_KEYS_DEFAULT.expanded;
|
||||
const pageDefault = defaults?.page ?? DEFAULT_PAGE;
|
||||
const limitDefault = defaults?.limit ?? DEFAULT_LIMIT;
|
||||
const orderByDefault = defaults?.orderBy ?? null;
|
||||
const expandedDefault = defaults?.expanded ?? {};
|
||||
const storageKey = defaults?.storageKey;
|
||||
const calculatedPageSize = defaults?.calculatedPageSize;
|
||||
const cleanupOnUnmount = defaults?.cleanupOnUnmount ?? false;
|
||||
const expandedDefaultArray = useMemo(
|
||||
() => expandedStateToArray(expandedDefault),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[],
|
||||
);
|
||||
|
||||
const [preferredPageSize, setPreferredPageSize] =
|
||||
usePreferredPageSize(storageKey);
|
||||
|
||||
const limitDefault =
|
||||
preferredPageSize ?? calculatedPageSize ?? defaults?.limit ?? DEFAULT_LIMIT;
|
||||
|
||||
const [localPage, setLocalPage] = useState(pageDefault);
|
||||
const [localLimit, setLocalLimit] = useState(limitDefault);
|
||||
const [localOrderBy, setLocalOrderBy] = useState<SortState | null>(
|
||||
@@ -135,71 +120,9 @@ export function useTableParams(
|
||||
pageQueryParam,
|
||||
parseAsInteger.withDefault(pageDefault).withOptions(NUQS_OPTIONS),
|
||||
);
|
||||
const [urlLimitRaw, setUrlLimitRaw] = useQueryState(
|
||||
const [urlLimit, setUrlLimit] = useQueryState(
|
||||
limitQueryParam,
|
||||
parseAsInteger.withOptions(NUQS_OPTIONS),
|
||||
);
|
||||
|
||||
// Track if URL had limit on initial mount
|
||||
const hadUrlLimitOnMountRef = useRef<boolean | null>(null);
|
||||
if (hadUrlLimitOnMountRef.current === null) {
|
||||
hadUrlLimitOnMountRef.current = urlLimitRaw !== null;
|
||||
}
|
||||
const hadUrlLimit = hadUrlLimitOnMountRef.current ?? false;
|
||||
|
||||
const urlLimit = urlLimitRaw ?? limitDefault;
|
||||
|
||||
// Initialize URL with preferred/calculated when available (only if URL was empty)
|
||||
const hasInitializedUrlRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!useUrlForLimit || hasInitializedUrlRef.current || hadUrlLimit) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (preferredPageSize !== null) {
|
||||
hasInitializedUrlRef.current = true;
|
||||
void setUrlLimitRaw(preferredPageSize);
|
||||
return;
|
||||
}
|
||||
if (calculatedPageSize != null) {
|
||||
hasInitializedUrlRef.current = true;
|
||||
void setUrlLimitRaw(calculatedPageSize);
|
||||
}
|
||||
}, [
|
||||
useUrlForLimit,
|
||||
calculatedPageSize,
|
||||
preferredPageSize,
|
||||
hadUrlLimit,
|
||||
setUrlLimitRaw,
|
||||
]);
|
||||
|
||||
// Wrapped setLimit that persists preference when different from calculated
|
||||
const setUrlLimit = useCallback(
|
||||
(newLimit: number): void => {
|
||||
if (storageKey) {
|
||||
if (newLimit !== calculatedPageSize) {
|
||||
setPreferredPageSize(newLimit);
|
||||
} else {
|
||||
setPreferredPageSize(null);
|
||||
}
|
||||
}
|
||||
void setUrlLimitRaw(newLimit);
|
||||
},
|
||||
[storageKey, calculatedPageSize, setPreferredPageSize, setUrlLimitRaw],
|
||||
);
|
||||
|
||||
const setLocalLimitWithPersist = useCallback(
|
||||
(newLimit: number): void => {
|
||||
if (storageKey) {
|
||||
if (newLimit !== calculatedPageSize) {
|
||||
setPreferredPageSize(newLimit);
|
||||
} else {
|
||||
setPreferredPageSize(null);
|
||||
}
|
||||
}
|
||||
setLocalLimit(newLimit);
|
||||
},
|
||||
[storageKey, calculatedPageSize, setPreferredPageSize],
|
||||
parseAsInteger.withDefault(limitDefault).withOptions(NUQS_OPTIONS),
|
||||
);
|
||||
const [urlOrderBy, setUrlOrderBy] = useQueryState(
|
||||
orderByQueryParam,
|
||||
@@ -232,7 +155,7 @@ export function useTableParams(
|
||||
typeof updaterOrValue === 'function'
|
||||
? updaterOrValue(urlExpandedRef.current)
|
||||
: updaterOrValue;
|
||||
void setUrlExpandedArray(expandedStateToArray(newState));
|
||||
setUrlExpandedArray(expandedStateToArray(newState));
|
||||
},
|
||||
[setUrlExpandedArray],
|
||||
);
|
||||
@@ -249,53 +172,21 @@ export function useTableParams(
|
||||
[],
|
||||
);
|
||||
|
||||
const orderByDefaultMemoKey = `${orderByDefault?.columnName}${orderByDefault?.order}`;
|
||||
const orderByUrlMemoKey = `${urlOrderBy?.columnName}${urlOrderBy?.order}`;
|
||||
const prevOrderByRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Only reset page when orderBy actually changes, not on initial mount
|
||||
if (
|
||||
prevOrderByRef.current !== null &&
|
||||
prevOrderByRef.current !== orderByUrlMemoKey
|
||||
) {
|
||||
if (useUrlForPage) {
|
||||
void setUrlPage(pageDefault);
|
||||
} else {
|
||||
setLocalPage(pageDefault);
|
||||
}
|
||||
if (useUrlForPage) {
|
||||
setUrlPage(pageDefault);
|
||||
} else {
|
||||
setLocalPage(pageDefault);
|
||||
}
|
||||
prevOrderByRef.current = orderByUrlMemoKey;
|
||||
}, [useUrlForPage, orderByUrlMemoKey, pageDefault, setUrlPage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!cleanupOnUnmount) {
|
||||
return;
|
||||
}
|
||||
|
||||
return (): void => {
|
||||
if (useUrlForPage) {
|
||||
void setUrlPage(null);
|
||||
}
|
||||
if (useUrlForLimit) {
|
||||
void setUrlLimitRaw(null);
|
||||
}
|
||||
if (useUrlForOrderBy) {
|
||||
void setUrlOrderBy(null);
|
||||
}
|
||||
if (useUrlForExpanded) {
|
||||
void setUrlExpandedArray(null);
|
||||
}
|
||||
};
|
||||
}, [
|
||||
cleanupOnUnmount,
|
||||
useUrlForPage,
|
||||
useUrlForLimit,
|
||||
useUrlForOrderBy,
|
||||
useUrlForExpanded,
|
||||
orderByDefaultMemoKey,
|
||||
orderByUrlMemoKey,
|
||||
pageDefault,
|
||||
setUrlPage,
|
||||
setUrlLimitRaw,
|
||||
setUrlOrderBy,
|
||||
setUrlExpandedArray,
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -304,7 +195,7 @@ export function useTableParams(
|
||||
orderBy: (useUrlForOrderBy ? urlOrderBy : localOrderBy) as SortState | null,
|
||||
expanded: useUrlForExpanded ? urlExpanded : localExpanded,
|
||||
setPage: useUrlForPage ? setUrlPage : setLocalPage,
|
||||
setLimit: useUrlForLimit ? setUrlLimit : setLocalLimitWithPersist,
|
||||
setLimit: useUrlForLimit ? setUrlLimit : setLocalLimit,
|
||||
setOrderBy: useUrlForOrderBy ? setUrlOrderBy : setLocalOrderBy,
|
||||
setExpanded: useUrlForExpanded ? setUrlExpanded : handleSetLocalExpanded,
|
||||
};
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { CSSProperties, ReactNode } from 'react';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
|
||||
import { RowKeyData, TableColumnDef } from './types';
|
||||
import { ComboboxSimpleItem } from '@signozhq/ui/combobox';
|
||||
|
||||
export const getColumnId = <TData>(column: TableColumnDef<TData>): string =>
|
||||
column.id;
|
||||
@@ -35,7 +34,7 @@ export const getColumnWidthStyle = <TData>(
|
||||
isLastColumn?: boolean,
|
||||
): CSSProperties => {
|
||||
// Last column always fills remaining space
|
||||
if (isLastColumn && column?.width?.ignoreLastColumnFill !== true) {
|
||||
if (isLastColumn) {
|
||||
return {
|
||||
width: '100%',
|
||||
minWidth: persistedWidth ?? column?.width?.min,
|
||||
@@ -146,31 +145,3 @@ export function buildTanstackColumnDef<TData>(
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const DEFAULT_PAGE_SIZES = [10, 20, 30, 50, 100];
|
||||
|
||||
export function buildPageSizeItems(
|
||||
calculatedSize?: number | null,
|
||||
): ComboboxSimpleItem[] {
|
||||
const items: ComboboxSimpleItem[] = [];
|
||||
|
||||
if (calculatedSize) {
|
||||
items.push({
|
||||
value: calculatedSize.toString(),
|
||||
label: `Auto (${calculatedSize})`,
|
||||
displayValue: calculatedSize.toString(),
|
||||
});
|
||||
}
|
||||
|
||||
for (const size of DEFAULT_PAGE_SIZES) {
|
||||
if (size !== calculatedSize) {
|
||||
items.push({
|
||||
value: size.toString(),
|
||||
label: size.toString(),
|
||||
displayValue: size.toString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
@@ -76,19 +76,4 @@
|
||||
|
||||
.cmd-item-icon {
|
||||
margin-right: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
svg {
|
||||
height: 16px !important;
|
||||
width: 16px !important;
|
||||
}
|
||||
|
||||
&.noz-icon {
|
||||
svg {
|
||||
height: 20px !important;
|
||||
width: 20px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import cx from 'classnames';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import {
|
||||
CommandDialog,
|
||||
@@ -163,11 +162,7 @@ export function CmdKPalette({
|
||||
value={it.name}
|
||||
className={theme === 'light' ? 'cmdk-item-light' : 'cmdk-item'}
|
||||
>
|
||||
<span
|
||||
className={cx('cmd-item-icon', it.id === 'ai-assistant' && 'noz-icon')}
|
||||
>
|
||||
{it.icon}
|
||||
</span>
|
||||
<span className="cmd-item-icon">{it.icon}</span>
|
||||
{it.name}
|
||||
{it.shortcut && it.shortcut.length > 0 && (
|
||||
<CommandShortcut>{it.shortcut.join(' • ')}</CommandShortcut>
|
||||
|
||||
@@ -15,10 +15,10 @@ import {
|
||||
ListMinus,
|
||||
ScrollText,
|
||||
Settings,
|
||||
Sparkles,
|
||||
TowerControl,
|
||||
Workflow,
|
||||
} from '@signozhq/icons';
|
||||
import Noz from 'components/Noz/Noz';
|
||||
import { ROLES } from 'types/roles';
|
||||
|
||||
export type CmdAction = {
|
||||
@@ -292,11 +292,11 @@ export function createShortcutActions(deps: ActionDeps): CmdAction[] {
|
||||
if (aiAssistant) {
|
||||
actions.unshift({
|
||||
id: 'ai-assistant',
|
||||
name: 'Open Noz',
|
||||
name: 'Open AI Assistant',
|
||||
shortcut: ['cmd+j'],
|
||||
keywords: 'noz ai assistant chat ask sparkles copilot',
|
||||
section: 'Noz',
|
||||
icon: <Noz size={16} />,
|
||||
keywords: 'ai assistant chat ask sparkles copilot',
|
||||
section: 'AI Assistant',
|
||||
icon: <Sparkles size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
perform: aiAssistant.open,
|
||||
});
|
||||
|
||||
@@ -4,8 +4,7 @@ import { Button } from '@signozhq/ui/button';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Drawer } from 'antd';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { Maximize2, Plus, X } from '@signozhq/icons';
|
||||
import Noz from 'components/Noz/Noz';
|
||||
import { Maximize2, MessageSquare, Plus, X } from '@signozhq/icons';
|
||||
|
||||
import ConversationView from '../ConversationView';
|
||||
import { useAIAssistantStore } from '../store/useAIAssistantStore';
|
||||
@@ -47,9 +46,9 @@ export default function AIAssistantDrawer(): JSX.Element {
|
||||
closeIcon={null}
|
||||
title={
|
||||
<div>
|
||||
<div className="noz-wave">
|
||||
<Noz size={16} />
|
||||
<span>Noz</span>
|
||||
<div>
|
||||
<MessageSquare size={16} />
|
||||
<span>AI Assistant</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -4,8 +4,7 @@ import { useHistory, useLocation } from 'react-router-dom';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { History, Maximize2, Minus, Plus, X } from '@signozhq/icons';
|
||||
import Noz from 'components/Noz/Noz';
|
||||
import { History, Maximize2, Minus, Plus, Sparkles, X } from '@signozhq/icons';
|
||||
|
||||
import logEvent from 'api/common/logEvent';
|
||||
|
||||
@@ -143,15 +142,15 @@ export default function AIAssistantModal(): JSX.Element | null {
|
||||
className={styles.backdrop}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Noz"
|
||||
aria-label="AI Assistant"
|
||||
onClick={handleBackdropClick}
|
||||
>
|
||||
<div className={styles.modal}>
|
||||
{/* Header */}
|
||||
<div className={styles.header}>
|
||||
<div className={`${styles.title} noz-wave`}>
|
||||
<Noz size={16} />
|
||||
<span>Noz</span>
|
||||
<div className={styles.title}>
|
||||
<Sparkles size={16} color="var(--primary)" />
|
||||
<span>AI Assistant</span>
|
||||
<kbd className={styles.shortcut}>
|
||||
<span>⌘</span>
|
||||
<span>J</span>
|
||||
|
||||
@@ -3,8 +3,7 @@ import { matchPath, useHistory, useLocation } from 'react-router-dom';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { History, Maximize2, Plus, X } from '@signozhq/icons';
|
||||
import Noz from 'components/Noz/Noz';
|
||||
import { History, Maximize2, Plus, Sparkles, X } from '@signozhq/icons';
|
||||
|
||||
import logEvent from 'api/common/logEvent';
|
||||
|
||||
@@ -138,9 +137,9 @@ export default function AIAssistantPanel(): JSX.Element | null {
|
||||
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
|
||||
<div className={styles.resizeHandle} onMouseDown={handleResizeMouseDown} />
|
||||
<div className={styles.header}>
|
||||
<div className={`${styles.title} noz-wave`}>
|
||||
<Noz size={18} />
|
||||
<span>Noz</span>
|
||||
<div className={styles.title}>
|
||||
<Sparkles size={18} color="var(--primary)" />
|
||||
<span>AI Assistant</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.actions}>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Button } from '@signozhq/ui/button';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import ROUTES from 'constants/routes';
|
||||
import Noz from 'components/Noz/Noz';
|
||||
import { Bot } from '@signozhq/icons';
|
||||
|
||||
import { AIAssistantEvents, AIAssistantOpenSource } from '../events';
|
||||
import { normalizePage } from '../hooks/useAIAssistantAnalyticsContext';
|
||||
@@ -42,15 +42,15 @@ export default function AIAssistantTrigger(): JSX.Element | null {
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipSimple title="Noz">
|
||||
<TooltipSimple title="AI Assistant">
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
className={`${styles.trigger} noz-wave`}
|
||||
className={styles.trigger}
|
||||
onClick={handleOpen}
|
||||
aria-label="Open Noz"
|
||||
aria-label="Open AI Assistant"
|
||||
>
|
||||
<Noz size={24} />
|
||||
<Bot size={20} />
|
||||
</Button>
|
||||
</TooltipSimple>
|
||||
);
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
|
||||
.emptyIcon {
|
||||
margin-bottom: 4px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.emptyTitle {
|
||||
|
||||
@@ -7,8 +7,8 @@ import {
|
||||
ChartBar,
|
||||
Search,
|
||||
Zap,
|
||||
Sparkles,
|
||||
} from '@signozhq/icons';
|
||||
import Noz from 'components/Noz/Noz';
|
||||
|
||||
import logEvent from 'api/common/logEvent';
|
||||
|
||||
@@ -177,10 +177,10 @@ export default function VirtualizedMessages({
|
||||
if (messages.length === 0 && !showStreamingSlot) {
|
||||
return (
|
||||
<div className={styles.empty}>
|
||||
<div className={`${styles.emptyIcon} noz-wave`}>
|
||||
<Noz size={48} />
|
||||
<div className={styles.emptyIcon}>
|
||||
<Sparkles size={24} color="var(--primary)" />
|
||||
</div>
|
||||
<h3 className={styles.emptyTitle}>Noz</h3>
|
||||
<h3 className={styles.emptyTitle}>SigNoz AI Assistant</h3>
|
||||
<p className={styles.emptySubtitle}>
|
||||
Ask questions about your traces, logs, metrics, and infrastructure.
|
||||
</p>
|
||||
|
||||
@@ -2,8 +2,6 @@ import { ArrowRight } from '@signozhq/icons';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
|
||||
import styles from './AlertsEmptyState.module.scss';
|
||||
|
||||
interface AlertInfoCardProps {
|
||||
header: string;
|
||||
subheader: string;
|
||||
@@ -19,17 +17,17 @@ function AlertInfoCard({
|
||||
}: AlertInfoCardProps): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
className={styles.alertInfoCard}
|
||||
className="alert-info-card"
|
||||
onClick={(): void => {
|
||||
onClick();
|
||||
openInNewTab(link);
|
||||
}}
|
||||
>
|
||||
<div className={styles.alertCardText}>
|
||||
<Typography.Text className={styles.alertCardTextHeader}>
|
||||
<div className="alert-card-text">
|
||||
<Typography.Text className="alert-card-text-header">
|
||||
{header}
|
||||
</Typography.Text>
|
||||
<Typography.Text className={styles.alertCardTextSubheader}>
|
||||
<Typography.Text className="alert-card-text-subheader">
|
||||
{subheader}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
@@ -1,189 +0,0 @@
|
||||
.alertListContainer {
|
||||
margin-top: auto;
|
||||
margin-bottom: auto;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.alertListViewContent {
|
||||
width: calc(100% - 30px);
|
||||
max-width: 836px;
|
||||
}
|
||||
|
||||
.title {
|
||||
color: var(--l1-foreground);
|
||||
font-size: var(--font-size-lg);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: 28px;
|
||||
letter-spacing: -0.09px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--l2-foreground);
|
||||
font-size: var(--font-size-sm);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.emptyAlertInfoContainer {
|
||||
display: flex;
|
||||
padding: 71px 193.5px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 6px;
|
||||
border: 1px dashed var(--l1-border);
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.alertContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.heading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.icons {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.emptyAlertAction {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 24px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.emptyInfo {
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 24px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.actionContainer {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
align-items: center;
|
||||
padding-top: 24px;
|
||||
padding-bottom: 24px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.buttonGroup {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.buttonContent {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.getStartedText {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-top: 24px;
|
||||
margin-bottom: 24px;
|
||||
width: 100%;
|
||||
|
||||
:global(.ant-divider)::before,
|
||||
:global(.ant-divider)::after {
|
||||
border-bottom: 2px dotted var(--l1-border);
|
||||
border-top: 2px dotted var(--l1-border);
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
:global(.ant-typography) {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
letter-spacing: 0.48px;
|
||||
text-transform: uppercase;
|
||||
padding-top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.alertInfoCard {
|
||||
display: flex;
|
||||
padding: 16px;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
margin-bottom: 16px;
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.alertCardText {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.alertCardTextHeader {
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.alertCardTextSubheader {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.infoText {
|
||||
color: var(--primary);
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 16px;
|
||||
letter-spacing: -0.06px;
|
||||
margin: 0 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.infoLinkContainer {
|
||||
svg {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
.alert-list-container {
|
||||
margin-top: 104px;
|
||||
margin-bottom: 30px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
|
||||
.alert-list-view-content {
|
||||
width: calc(100% - 30px);
|
||||
max-width: 836px;
|
||||
|
||||
.alert-list-title-container {
|
||||
.title {
|
||||
color: var(--l1-foreground);
|
||||
font-size: var(--font-size-lg);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: 28px; /* 155.556% */
|
||||
letter-spacing: -0.09px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--l2-foreground);
|
||||
font-size: var(--font-size-sm);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-alert-info-container {
|
||||
display: flex;
|
||||
padding: 71px 193.5px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 6px;
|
||||
border: 1px dashed var(--l1-border);
|
||||
margin-top: 16px;
|
||||
|
||||
.alert-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
|
||||
.heading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
.icons {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.empty-alert-action {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 24px; /* 171.429% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.empty-info {
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 24px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
|
||||
.action-container {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
align-items: center;
|
||||
padding-top: 24px;
|
||||
padding-bottom: 24px;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.get-started-text {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-top: 24px;
|
||||
margin-bottom: 24px;
|
||||
width: 100%;
|
||||
|
||||
&__divider {
|
||||
--divider-border-width: 1px;
|
||||
}
|
||||
|
||||
.ant-typography {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 20px; /* 166.667% */
|
||||
letter-spacing: 0.48px;
|
||||
text-transform: uppercase;
|
||||
padding-top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.alert-info-card {
|
||||
display: flex;
|
||||
padding: 16px;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
margin-bottom: 16px;
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.alert-card-text {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
flex-direction: column;
|
||||
|
||||
.alert-card-text-header {
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.alert-card-text-subheader {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 18px; /* 150% */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.info-text {
|
||||
color: var(--bg-robin-400) !important;
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 16px; /* 133.333% */
|
||||
letter-spacing: -0.06px;
|
||||
}
|
||||
|
||||
.info-link-container {
|
||||
.anticon {
|
||||
color: var(--bg-robin-400);
|
||||
}
|
||||
|
||||
:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import { Button, Flex } from 'antd';
|
||||
import { Divider } from '@signozhq/ui/divider';
|
||||
import { Plus, RefreshCw } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import ROUTES from 'constants/routes';
|
||||
@@ -17,7 +17,7 @@ import AlertInfoCard from './AlertInfoCard';
|
||||
import { ALERT_CARDS, ALERT_INFO_LINKS } from './alertLinks';
|
||||
import InfoLinkText from './InfoLinkText';
|
||||
|
||||
import styles from './AlertsEmptyState.module.scss';
|
||||
import './AlertsEmptyState.styles.scss';
|
||||
|
||||
const alertLogEvents = (
|
||||
title: string,
|
||||
@@ -29,16 +29,10 @@ const alertLogEvents = (
|
||||
page: 'Alert empty state page',
|
||||
};
|
||||
|
||||
void logEvent(title, dataSource ? { ...attributes, dataSource } : attributes);
|
||||
logEvent(title, dataSource ? { ...attributes, dataSource } : attributes);
|
||||
};
|
||||
|
||||
interface AlertsEmptyStateProps {
|
||||
onRefresh?: () => void;
|
||||
}
|
||||
|
||||
export function AlertsEmptyState({
|
||||
onRefresh,
|
||||
}: AlertsEmptyStateProps): JSX.Element {
|
||||
export function AlertsEmptyState(): JSX.Element {
|
||||
const { user } = useAppContext();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const [addNewAlert] = useComponentPermission(
|
||||
@@ -57,51 +51,45 @@ export function AlertsEmptyState({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.alertListContainer}>
|
||||
<div className={styles.alertListViewContent}>
|
||||
<div>
|
||||
<Typography.Title className={styles.title}>Alert Rules</Typography.Title>
|
||||
<Typography.Text className={styles.subtitle}>
|
||||
<div className="alert-list-container">
|
||||
<div className="alert-list-view-content">
|
||||
<div className="alert-list-title-container">
|
||||
<Typography.Title className="title">Alert Rules</Typography.Title>
|
||||
<Typography.Text className="subtitle">
|
||||
Create and manage alert rules for your resources.
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<section className={styles.emptyAlertInfoContainer}>
|
||||
<div className={styles.alertContent}>
|
||||
<section className={styles.heading}>
|
||||
<section className="empty-alert-info-container">
|
||||
<div className="alert-content">
|
||||
<section className="heading">
|
||||
<img
|
||||
src={alertEmojiUrl}
|
||||
alt="alert-header"
|
||||
style={{ height: '32px', width: '32px' }}
|
||||
/>
|
||||
<div>
|
||||
<Typography.Text className={styles.emptyInfo}>
|
||||
<Typography.Text className="empty-info">
|
||||
No Alert rules yet.{' '}
|
||||
</Typography.Text>
|
||||
<br />
|
||||
<Typography.Text className={styles.emptyAlertAction}>
|
||||
<Typography.Text className="empty-alert-action">
|
||||
Create an Alert Rule to get started
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</section>
|
||||
<div className={styles.actionContainer}>
|
||||
<div className={styles.buttonGroup}>
|
||||
<Button
|
||||
onClick={onClickNewAlertHandler}
|
||||
disabled={!addNewAlert}
|
||||
loading={loading}
|
||||
data-testid="add-alert"
|
||||
>
|
||||
<span className={styles.buttonContent}>
|
||||
<Plus size="md" />
|
||||
New Alert Rule
|
||||
</span>
|
||||
</Button>
|
||||
{onRefresh && (
|
||||
<Button onClick={onRefresh} prefix={<RefreshCw />} color="secondary">
|
||||
Refresh
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="action-container">
|
||||
<Button
|
||||
className="add-alert-btn"
|
||||
onClick={onClickNewAlertHandler}
|
||||
disabled={!addNewAlert}
|
||||
loading={loading}
|
||||
type="primary"
|
||||
data-testid="add-alert"
|
||||
>
|
||||
<Flex align="center" justify="center" gap={4}>
|
||||
<Plus size="md" />
|
||||
New Alert Rule
|
||||
</Flex>
|
||||
</Button>
|
||||
<InfoLinkText
|
||||
infoText="Watch a tutorial on creating a sample alert"
|
||||
link="https://youtu.be/xjxNIqiv4_M"
|
||||
@@ -136,9 +124,11 @@ export function AlertsEmptyState({
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
<div className={styles.getStartedText}>
|
||||
<div className="get-started-text">
|
||||
<Divider className="get-started-text__divider">
|
||||
<Typography.Text>Or get started with these sample alerts</Typography.Text>
|
||||
<Typography.Text className="get-started-text">
|
||||
Or get started with these sample alerts
|
||||
</Typography.Text>
|
||||
</Divider>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -3,8 +3,6 @@ import { Flex } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
|
||||
import styles from './AlertsEmptyState.module.scss';
|
||||
|
||||
interface InfoLinkTextProps {
|
||||
infoText: string;
|
||||
link: string;
|
||||
@@ -26,12 +24,12 @@ function InfoLinkText({
|
||||
onClick();
|
||||
openInNewTab(link);
|
||||
}}
|
||||
className={styles.infoLinkContainer}
|
||||
className="info-link-container"
|
||||
>
|
||||
{leftIconVisible && <CirclePlay size={16} />}
|
||||
<Typography.Text className={styles.infoText}>{infoText}</Typography.Text>
|
||||
{leftIconVisible && <CirclePlay size="md" />}
|
||||
<Typography.Text className="info-text">{infoText}</Typography.Text>
|
||||
{rightIconVisible && (
|
||||
<ArrowRight size={16} style={{ transform: 'rotate(315deg)' }} />
|
||||
<ArrowRight size="md" style={{ transform: 'rotate(315deg)' }} />
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
|
||||
86
frontend/src/container/ListAlertRules/DeleteAlert.tsx
Normal file
86
frontend/src/container/ListAlertRules/DeleteAlert.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { Dispatch, SetStateAction, useState } from 'react';
|
||||
import type { NotificationInstance } from 'antd/es/notification/interface';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import { deleteRuleByID } from 'api/generated/services/rules';
|
||||
import type {
|
||||
RenderErrorResponseDTO,
|
||||
RuletypesRuleDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { AxiosError } from 'axios';
|
||||
import { State } from 'hooks/useFetch';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import { PayloadProps as DeleteAlertPayloadProps } from 'types/api/alerts/delete';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { ColumnButton } from './styles';
|
||||
|
||||
function DeleteAlert({
|
||||
id,
|
||||
setData,
|
||||
notifications,
|
||||
}: DeleteAlertProps): JSX.Element {
|
||||
const [deleteAlertState, setDeleteAlertState] = useState<
|
||||
State<DeleteAlertPayloadProps>
|
||||
>({
|
||||
error: false,
|
||||
errorMessage: '',
|
||||
loading: false,
|
||||
success: false,
|
||||
payload: undefined,
|
||||
});
|
||||
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
const onDeleteHandler = async (id: string): Promise<void> => {
|
||||
try {
|
||||
await deleteRuleByID({ id });
|
||||
|
||||
setData((state) => state.filter((alert) => alert.id !== id));
|
||||
|
||||
setDeleteAlertState((state) => ({
|
||||
...state,
|
||||
loading: false,
|
||||
}));
|
||||
notifications.success({
|
||||
message: 'Success',
|
||||
});
|
||||
} catch (error) {
|
||||
setDeleteAlertState((state) => ({
|
||||
...state,
|
||||
loading: false,
|
||||
error: true,
|
||||
}));
|
||||
|
||||
showErrorModal(
|
||||
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) as APIError,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const onClickHandler = (): void => {
|
||||
setDeleteAlertState((state) => ({
|
||||
...state,
|
||||
loading: true,
|
||||
}));
|
||||
onDeleteHandler(id);
|
||||
};
|
||||
|
||||
return (
|
||||
<ColumnButton
|
||||
disabled={deleteAlertState.loading || false}
|
||||
loading={deleteAlertState.loading || false}
|
||||
onClick={onClickHandler}
|
||||
type="link"
|
||||
>
|
||||
Delete
|
||||
</ColumnButton>
|
||||
);
|
||||
}
|
||||
|
||||
interface DeleteAlertProps {
|
||||
id: string;
|
||||
setData: Dispatch<SetStateAction<RuletypesRuleDTO[]>>;
|
||||
notifications: NotificationInstance;
|
||||
}
|
||||
|
||||
export default DeleteAlert;
|
||||
439
frontend/src/container/ListAlertRules/ListAlert.tsx
Normal file
439
frontend/src/container/ListAlertRules/ListAlert.tsx
Normal file
@@ -0,0 +1,439 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { UseQueryResult } from 'react-query';
|
||||
import { Button, Flex, Input } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Ellipsis, Plus } from '@signozhq/icons';
|
||||
import { DropdownMenuSimple } from '@signozhq/ui/dropdown-menu';
|
||||
import type { ColumnsType } from 'antd/es/table/interface';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import { createRule } from 'api/generated/services/rules';
|
||||
import type {
|
||||
ListRules200,
|
||||
RenderErrorResponseDTO,
|
||||
RuletypesRuleDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { ErrorType } from 'api/generatedAPIInstance';
|
||||
import { AxiosError } from 'axios';
|
||||
import {
|
||||
DynamicColumnsKey,
|
||||
TableDataSource,
|
||||
} from 'components/ResizeTable/contants';
|
||||
import DynamicColumnTable from 'components/ResizeTable/DynamicColumnTable';
|
||||
import DateComponent from 'components/ResizeTable/TableComponent/DateComponent';
|
||||
import LabelColumn from 'components/TableRenderer/LabelColumn';
|
||||
import TextToolTip from 'components/TextToolTip';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { sanitizeDefaultAlertQuery } from 'container/EditAlertV2/utils';
|
||||
import useSortableTable from 'hooks/ResizeTable/useSortableTable';
|
||||
import useComponentPermission from 'hooks/useComponentPermission';
|
||||
import useDebouncedFn from 'hooks/useDebouncedFunction';
|
||||
import useInterval from 'hooks/useInterval';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import { toCompositeMetricQuery } from 'types/api/alerts/convert';
|
||||
import APIError from 'types/api/error';
|
||||
import { isModifierKeyPressed } from 'utils/app';
|
||||
|
||||
import DeleteAlert from './DeleteAlert';
|
||||
import { ColumnButton, SearchContainer } from './styles';
|
||||
import Status from './TableComponents/Status';
|
||||
import ToggleAlertState from './ToggleAlertState';
|
||||
import { alertActionLogEvent, filterAlerts } from './utils';
|
||||
|
||||
const { Search } = Input;
|
||||
|
||||
function ListAlert({ allAlertRules, refetch }: ListAlertProps): JSX.Element {
|
||||
const { t } = useTranslation('common');
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const { user } = useAppContext();
|
||||
const [addNewAlert, action] = useComponentPermission(
|
||||
['add_new_alert', 'action'],
|
||||
user.role,
|
||||
);
|
||||
|
||||
const [editLoader, setEditLoader] = useState<boolean>(false);
|
||||
const [cloneLoader, setCloneLoader] = useState<boolean>(false);
|
||||
|
||||
const params = useUrlQuery();
|
||||
const orderColumnParam = params.get('columnKey');
|
||||
const orderQueryParam = params.get('order');
|
||||
const paginationParam = params.get('page');
|
||||
const searchParams = params.get('search');
|
||||
const [searchString, setSearchString] = useState<string>(searchParams || '');
|
||||
const [data, setData] = useState<RuletypesRuleDTO[]>(() => {
|
||||
const value = searchString.toLowerCase();
|
||||
const filteredData = filterAlerts(allAlertRules, value);
|
||||
return filteredData || [];
|
||||
});
|
||||
|
||||
// Type asuring
|
||||
const sortingOrder: 'ascend' | 'descend' | null =
|
||||
orderQueryParam === 'ascend' || orderQueryParam === 'descend'
|
||||
? orderQueryParam
|
||||
: null;
|
||||
|
||||
const { sortedInfo, handleChange } = useSortableTable<RuletypesRuleDTO>(
|
||||
sortingOrder,
|
||||
orderColumnParam || '',
|
||||
searchString,
|
||||
);
|
||||
|
||||
const { notifications: notificationsApi } = useNotifications();
|
||||
|
||||
useInterval(() => {
|
||||
(async (): Promise<void> => {
|
||||
const { data: refetchData, status } = await refetch();
|
||||
if (status === 'success') {
|
||||
const value = searchString.toLowerCase();
|
||||
const filteredData = filterAlerts(refetchData?.data ?? [], value);
|
||||
setData(filteredData || []);
|
||||
}
|
||||
if (status === 'error') {
|
||||
notificationsApi.error({
|
||||
message: t('something_went_wrong'),
|
||||
});
|
||||
}
|
||||
})();
|
||||
}, 30000);
|
||||
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
const onClickNewAlertHandler = useCallback(
|
||||
(e: React.MouseEvent): void => {
|
||||
logEvent('Alert: New alert button clicked', {
|
||||
number: allAlertRules?.length,
|
||||
layout: 'new',
|
||||
});
|
||||
safeNavigate(ROUTES.ALERTS_NEW, {
|
||||
newTab: isModifierKeyPressed(e),
|
||||
});
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[],
|
||||
);
|
||||
|
||||
const onEditHandler = (
|
||||
record: RuletypesRuleDTO,
|
||||
options?: { newTab?: boolean },
|
||||
): void => {
|
||||
const compositeQuery = sanitizeDefaultAlertQuery(
|
||||
mapQueryDataFromApi(toCompositeMetricQuery(record.condition.compositeQuery)),
|
||||
record.alertType,
|
||||
);
|
||||
params.set(
|
||||
QueryParams.compositeQuery,
|
||||
encodeURIComponent(JSON.stringify(compositeQuery)),
|
||||
);
|
||||
|
||||
const panelType = record.condition.compositeQuery.panelType;
|
||||
if (panelType) {
|
||||
params.set(QueryParams.panelTypes, panelType);
|
||||
}
|
||||
|
||||
params.set(QueryParams.ruleId, record.id);
|
||||
|
||||
setEditLoader(false);
|
||||
|
||||
safeNavigate(`${ROUTES.ALERT_OVERVIEW}?${params.toString()}`, {
|
||||
newTab: options?.newTab,
|
||||
});
|
||||
};
|
||||
|
||||
const onCloneHandler =
|
||||
(originalAlert: RuletypesRuleDTO) => async (): Promise<void> => {
|
||||
const copyAlert: RuletypesRuleDTO = {
|
||||
...originalAlert,
|
||||
alert: `${originalAlert.alert} - Copy`,
|
||||
};
|
||||
|
||||
try {
|
||||
setCloneLoader(true);
|
||||
await createRule(copyAlert);
|
||||
|
||||
notificationsApi.success({
|
||||
message: 'Success',
|
||||
description: 'Alert cloned successfully',
|
||||
});
|
||||
|
||||
const { data: refetchData, status } = await refetch();
|
||||
const rules = refetchData?.data;
|
||||
if (status === 'success' && rules) {
|
||||
setData(rules);
|
||||
setTimeout(() => {
|
||||
const clonedAlert = rules[rules.length - 1];
|
||||
params.set(QueryParams.ruleId, String(clonedAlert.id));
|
||||
safeNavigate(`${ROUTES.EDIT_ALERTS}?${params.toString()}`);
|
||||
}, 2000);
|
||||
}
|
||||
if (status === 'error') {
|
||||
notificationsApi.error({
|
||||
message: t('something_went_wrong'),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
showErrorModal(
|
||||
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) as APIError,
|
||||
);
|
||||
} finally {
|
||||
setCloneLoader(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = useDebouncedFn((e: unknown) => {
|
||||
const value = (e as React.BaseSyntheticEvent).target.value.toLowerCase();
|
||||
setSearchString(value);
|
||||
const filteredData = filterAlerts(allAlertRules, value);
|
||||
setData(filteredData);
|
||||
});
|
||||
|
||||
const dynamicColumns: ColumnsType<RuletypesRuleDTO> = [
|
||||
{
|
||||
title: 'Created At',
|
||||
dataIndex: 'createdAt',
|
||||
width: 80,
|
||||
key: DynamicColumnsKey.CreatedAt,
|
||||
align: 'center',
|
||||
sorter: (a: RuletypesRuleDTO, b: RuletypesRuleDTO): number => {
|
||||
const prev = a.createdAt ? new Date(a.createdAt).getTime() : 0;
|
||||
const next = b.createdAt ? new Date(b.createdAt).getTime() : 0;
|
||||
|
||||
return prev - next;
|
||||
},
|
||||
render: DateComponent,
|
||||
sortOrder:
|
||||
sortedInfo.columnKey === DynamicColumnsKey.CreatedAt
|
||||
? sortedInfo.order
|
||||
: null,
|
||||
},
|
||||
{
|
||||
title: 'Created By',
|
||||
dataIndex: 'createdBy',
|
||||
width: 80,
|
||||
key: DynamicColumnsKey.CreatedBy,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: 'Updated At',
|
||||
dataIndex: 'updatedAt',
|
||||
width: 80,
|
||||
key: DynamicColumnsKey.UpdatedAt,
|
||||
align: 'center',
|
||||
sorter: (a: RuletypesRuleDTO, b: RuletypesRuleDTO): number => {
|
||||
const prev = a.updatedAt ? new Date(a.updatedAt).getTime() : 0;
|
||||
const next = b.updatedAt ? new Date(b.updatedAt).getTime() : 0;
|
||||
|
||||
return prev - next;
|
||||
},
|
||||
render: DateComponent,
|
||||
sortOrder:
|
||||
sortedInfo.columnKey === DynamicColumnsKey.UpdatedAt
|
||||
? sortedInfo.order
|
||||
: null,
|
||||
},
|
||||
{
|
||||
title: 'Updated By',
|
||||
dataIndex: 'updatedBy',
|
||||
width: 80,
|
||||
key: DynamicColumnsKey.UpdatedBy,
|
||||
align: 'center',
|
||||
},
|
||||
];
|
||||
|
||||
const columns: ColumnsType<RuletypesRuleDTO> = [
|
||||
{
|
||||
title: 'Status',
|
||||
dataIndex: 'state',
|
||||
width: 80,
|
||||
key: 'state',
|
||||
sorter: (a, b): number =>
|
||||
(b.state ? b.state.charCodeAt(0) : 1000) -
|
||||
(a.state ? a.state.charCodeAt(0) : 1000),
|
||||
render: (value): JSX.Element => <Status status={value} />,
|
||||
sortOrder: sortedInfo.columnKey === 'state' ? sortedInfo.order : null,
|
||||
},
|
||||
{
|
||||
title: 'Alert Name',
|
||||
dataIndex: 'alert',
|
||||
width: 100,
|
||||
key: 'name',
|
||||
sorter: (alertA, alertB): number => {
|
||||
if (alertA.alert && alertB.alert) {
|
||||
return alertA.alert.localeCompare(alertB.alert);
|
||||
}
|
||||
return 0;
|
||||
},
|
||||
render: (value, record): JSX.Element => {
|
||||
const onClickHandler = (e: React.MouseEvent<HTMLElement>): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
onEditHandler(record, { newTab: isModifierKeyPressed(e) });
|
||||
};
|
||||
|
||||
return <Typography.Link onClick={onClickHandler}>{value}</Typography.Link>;
|
||||
},
|
||||
sortOrder: sortedInfo.columnKey === 'name' ? sortedInfo.order : null,
|
||||
},
|
||||
{
|
||||
title: 'Severity',
|
||||
dataIndex: 'labels',
|
||||
width: 80,
|
||||
key: 'severity',
|
||||
sorter: (a, b): number =>
|
||||
(a?.labels?.severity?.length || 0) - (b?.labels?.severity?.length || 0),
|
||||
render: (value): JSX.Element => {
|
||||
const objectKeys = value ? Object.keys(value) : [];
|
||||
const withSeverityKey = objectKeys.find((e) => e === 'severity') || '';
|
||||
const severityValue = withSeverityKey ? value[withSeverityKey] : '-';
|
||||
|
||||
return <Typography>{severityValue}</Typography>;
|
||||
},
|
||||
sortOrder: sortedInfo.columnKey === 'severity' ? sortedInfo.order : null,
|
||||
},
|
||||
{
|
||||
title: 'Labels',
|
||||
dataIndex: 'labels',
|
||||
key: 'tags',
|
||||
align: 'center',
|
||||
width: 100,
|
||||
render: (value): JSX.Element => {
|
||||
const objectKeys = value ? Object.keys(value) : [];
|
||||
const withOutSeverityKeys = objectKeys.filter((e) => e !== 'severity');
|
||||
|
||||
if (withOutSeverityKeys.length === 0) {
|
||||
return <Typography>-</Typography>;
|
||||
}
|
||||
|
||||
return <LabelColumn labels={withOutSeverityKeys} value={value} />;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
if (action) {
|
||||
columns.push({
|
||||
title: 'Action',
|
||||
dataIndex: 'id',
|
||||
key: 'action',
|
||||
width: 10,
|
||||
render: (id: RuletypesRuleDTO['id'], record): JSX.Element => {
|
||||
const actionItems = [
|
||||
<ToggleAlertState
|
||||
key="1"
|
||||
disabled={record.disabled ?? false}
|
||||
setData={setData}
|
||||
id={id ?? ''}
|
||||
/>,
|
||||
<ColumnButton
|
||||
key="2"
|
||||
onClick={(e: React.MouseEvent): void =>
|
||||
onEditHandler(record, { newTab: isModifierKeyPressed(e) })
|
||||
}
|
||||
type="link"
|
||||
loading={editLoader}
|
||||
>
|
||||
Edit
|
||||
</ColumnButton>,
|
||||
<ColumnButton
|
||||
key="3-new-tab"
|
||||
onClick={(): void => onEditHandler(record, { newTab: true })}
|
||||
type="link"
|
||||
loading={editLoader}
|
||||
>
|
||||
Edit in New Tab
|
||||
</ColumnButton>,
|
||||
<ColumnButton
|
||||
key="3-clone"
|
||||
onClick={onCloneHandler(record)}
|
||||
type="link"
|
||||
loading={cloneLoader}
|
||||
>
|
||||
Clone
|
||||
</ColumnButton>,
|
||||
<DeleteAlert
|
||||
key="4"
|
||||
notifications={notificationsApi}
|
||||
setData={setData}
|
||||
id={id ?? ''}
|
||||
/>,
|
||||
];
|
||||
return (
|
||||
<div data-testid="alert-actions">
|
||||
<DropdownMenuSimple
|
||||
menu={{
|
||||
items: actionItems.map((element, index) => ({
|
||||
key: String(index),
|
||||
label: element,
|
||||
onClick: ({ key }): void => alertActionLogEvent(key, record),
|
||||
})),
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
type="link"
|
||||
style={{ color: 'var(--l1-foreground)' }}
|
||||
icon={<Ellipsis size={16} />}
|
||||
/>
|
||||
</DropdownMenuSimple>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const paginationConfig = {
|
||||
defaultCurrent: Number(paginationParam) || 1,
|
||||
};
|
||||
return (
|
||||
<div className="alert-rules-list-container">
|
||||
<SearchContainer>
|
||||
<Search
|
||||
placeholder="Search by Alert Name, Severity and Labels"
|
||||
onChange={handleSearch}
|
||||
defaultValue={searchString}
|
||||
/>
|
||||
<Flex gap={12} align="center">
|
||||
{addNewAlert && (
|
||||
<Button type="primary" onClick={onClickNewAlertHandler}>
|
||||
<Flex align="center" gap={4}>
|
||||
<Plus size="md" />
|
||||
New Alert
|
||||
</Flex>
|
||||
</Button>
|
||||
)}
|
||||
<TextToolTip
|
||||
{...{
|
||||
text: `More details on how to create alerts`,
|
||||
url: 'https://signoz.io/docs/alerts/?utm_source=product&utm_medium=list-alerts',
|
||||
urlText: 'Learn More',
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
</SearchContainer>
|
||||
<DynamicColumnTable
|
||||
tablesource={TableDataSource.Alert}
|
||||
columns={columns}
|
||||
rowKey="id"
|
||||
dataSource={data}
|
||||
shouldSendAlertsLogEvent
|
||||
dynamicColumns={dynamicColumns}
|
||||
onChange={handleChange}
|
||||
pagination={paginationConfig}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ListAlertProps {
|
||||
allAlertRules: RuletypesRuleDTO[];
|
||||
refetch: UseQueryResult<
|
||||
ListRules200,
|
||||
ErrorType<RenderErrorResponseDTO>
|
||||
>['refetch'];
|
||||
}
|
||||
|
||||
export default ListAlert;
|
||||
@@ -1,92 +0,0 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
height: calc(100vh - 62px);
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.header {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 1rem;
|
||||
flex-shrink: 0;
|
||||
padding: 0 var(--spacing-8);
|
||||
}
|
||||
|
||||
.refreshRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.filtersRow {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
flex-shrink: 0;
|
||||
padding: 0 var(--spacing-8);
|
||||
|
||||
--combobox-trigger-height: 2rem;
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
min-width: 250px;
|
||||
}
|
||||
|
||||
.filterSelect {
|
||||
min-width: 300px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.tableContainer {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
--tanstack-table-header-cell-bg: var(--l2-background);
|
||||
--tanstack-table-header-cell-color: var(--l2-foreground);
|
||||
--tanstack-table-cell-bg: var(--l2-background);
|
||||
--tanstack-table-cell-color: var(--l2-foreground);
|
||||
--tanstack-table-row-hover-bg: var(--l2-background-hover);
|
||||
--tanstack-table-row-active-bg: var(--l2-background-active);
|
||||
--tanstack-table-resize-handle-bg: var(--l2-background);
|
||||
--tanstack-table-resize-handle-hover-bg: var(--l2-border);
|
||||
--tanstack-table-row-height: 42px;
|
||||
|
||||
--tanstack-cell-padding-top-override: 5px;
|
||||
--tanstack-cell-padding-bottom-override: 5px;
|
||||
--tanstack-cell-padding-left-override: 16px;
|
||||
--tanstack-cell-padding-right-override: 16px;
|
||||
|
||||
--tanstack-table-row-odd-bg: color-mix(
|
||||
in srgb,
|
||||
var(--l1-foreground) 2%,
|
||||
transparent
|
||||
);
|
||||
--tanstack-table-row-even-bg: color-mix(
|
||||
in srgb,
|
||||
var(--l1-foreground) 1%,
|
||||
transparent
|
||||
);
|
||||
--badge-cursor: pointer;
|
||||
}
|
||||
|
||||
.searchIcon {
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.actionsColumn {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.paginationContainer {
|
||||
padding-right: var(--spacing-12);
|
||||
height: 62px;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import type { RuletypesRuleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
function Status({ status }: StatusProps): JSX.Element {
|
||||
switch (status) {
|
||||
case 'inactive': {
|
||||
return (
|
||||
<Badge color="forest" variant="outline">
|
||||
OK
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
case 'pending': {
|
||||
return (
|
||||
<Badge color="amber" variant="outline">
|
||||
Pending
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
case 'firing': {
|
||||
return (
|
||||
<Badge color="cherry" variant="outline">
|
||||
Firing
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
case 'disabled': {
|
||||
return (
|
||||
<Badge color="vanilla" variant="outline">
|
||||
Disabled
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
default: {
|
||||
return (
|
||||
<Badge color="vanilla" variant="outline">
|
||||
Unknown
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface StatusProps {
|
||||
status: RuletypesRuleDTO['state'];
|
||||
}
|
||||
|
||||
export default Status;
|
||||
103
frontend/src/container/ListAlertRules/ToggleAlertState.tsx
Normal file
103
frontend/src/container/ListAlertRules/ToggleAlertState.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { Dispatch, SetStateAction, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { patchRulePartial } from 'api/alerts/patchRulePartial';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import { invalidateGetRuleByID } from 'api/generated/services/rules';
|
||||
import type {
|
||||
RenderErrorResponseDTO,
|
||||
RuletypesRuleDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { AxiosError } from 'axios';
|
||||
import { State } from 'hooks/useFetch';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { ColumnButton } from './styles';
|
||||
|
||||
function ToggleAlertState({
|
||||
id,
|
||||
disabled,
|
||||
setData,
|
||||
}: ToggleAlertStateProps): JSX.Element {
|
||||
const [apiStatus, setAPIStatus] = useState<State<RuletypesRuleDTO>>({
|
||||
error: false,
|
||||
errorMessage: '',
|
||||
loading: false,
|
||||
success: false,
|
||||
payload: undefined,
|
||||
});
|
||||
|
||||
const { notifications } = useNotifications();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const onToggleHandler = async (
|
||||
id: string,
|
||||
disabled: boolean,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
setAPIStatus((state) => ({
|
||||
...state,
|
||||
loading: true,
|
||||
}));
|
||||
|
||||
const response = await patchRulePartial(id, { disabled });
|
||||
const { data: updatedRule } = response;
|
||||
|
||||
setData((state) =>
|
||||
state.map((alert) => {
|
||||
if (alert.id === id) {
|
||||
return {
|
||||
...alert,
|
||||
disabled: updatedRule.disabled,
|
||||
state: updatedRule.state,
|
||||
};
|
||||
}
|
||||
return alert;
|
||||
}),
|
||||
);
|
||||
|
||||
setAPIStatus((state) => ({
|
||||
...state,
|
||||
loading: false,
|
||||
payload: updatedRule,
|
||||
}));
|
||||
|
||||
invalidateGetRuleByID(queryClient, { id });
|
||||
|
||||
notifications.success({
|
||||
message: 'Success',
|
||||
});
|
||||
} catch (error) {
|
||||
setAPIStatus((state) => ({
|
||||
...state,
|
||||
loading: false,
|
||||
error: true,
|
||||
}));
|
||||
|
||||
showErrorModal(
|
||||
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) as APIError,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ColumnButton
|
||||
disabled={apiStatus.loading || false}
|
||||
loading={apiStatus.loading || false}
|
||||
onClick={(): Promise<void> => onToggleHandler(id, !disabled)}
|
||||
type="link"
|
||||
>
|
||||
{disabled ? 'Enable' : 'Disable'}
|
||||
</ColumnButton>
|
||||
);
|
||||
}
|
||||
|
||||
interface ToggleAlertStateProps {
|
||||
id: string;
|
||||
disabled: boolean;
|
||||
setData: Dispatch<SetStateAction<RuletypesRuleDTO[]>>;
|
||||
}
|
||||
|
||||
export default ToggleAlertState;
|
||||
147
frontend/src/container/ListAlertRules/__test__/utils.test.ts
Normal file
147
frontend/src/container/ListAlertRules/__test__/utils.test.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import type {
|
||||
RuletypesAlertStateDTO,
|
||||
RuletypesCompareOperatorDTO,
|
||||
RuletypesMatchTypeDTO,
|
||||
RuletypesPanelTypeDTO,
|
||||
RuletypesQueryTypeDTO,
|
||||
RuletypesRuleDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { filterAlerts } from '../utils';
|
||||
|
||||
describe('filterAlerts', () => {
|
||||
const mockAlertBase: Partial<RuletypesRuleDTO> = {
|
||||
state: 'active' as RuletypesAlertStateDTO,
|
||||
disabled: false,
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
createdBy: 'test-user',
|
||||
updatedAt: '2024-01-01T00:00:00Z',
|
||||
updatedBy: 'test-user',
|
||||
version: '1',
|
||||
condition: {
|
||||
compositeQuery: {
|
||||
queries: [],
|
||||
panelType: 'graph' as RuletypesPanelTypeDTO,
|
||||
queryType: 'builder' as RuletypesQueryTypeDTO,
|
||||
},
|
||||
matchType: 'at_least_once' as RuletypesMatchTypeDTO,
|
||||
op: 'above' as RuletypesCompareOperatorDTO,
|
||||
},
|
||||
ruleType: 'threshold_rule' as RuletypesRuleDTO['ruleType'],
|
||||
};
|
||||
|
||||
const mockAlerts: RuletypesRuleDTO[] = [
|
||||
{
|
||||
...mockAlertBase,
|
||||
id: '1',
|
||||
alert: 'High CPU Usage',
|
||||
alertType: 'METRIC_BASED_ALERT',
|
||||
labels: {
|
||||
severity: 'warning',
|
||||
status: 'ok',
|
||||
environment: 'production',
|
||||
},
|
||||
} as RuletypesRuleDTO,
|
||||
{
|
||||
...mockAlertBase,
|
||||
id: '2',
|
||||
alert: 'Memory Leak Detected',
|
||||
alertType: 'METRIC_BASED_ALERT',
|
||||
labels: {
|
||||
severity: 'critical',
|
||||
status: 'firing',
|
||||
environment: 'staging',
|
||||
},
|
||||
} as RuletypesRuleDTO,
|
||||
{
|
||||
...mockAlertBase,
|
||||
id: '3',
|
||||
alert: 'Database Connection Error',
|
||||
alertType: 'METRIC_BASED_ALERT',
|
||||
labels: {
|
||||
severity: 'error',
|
||||
status: 'pending',
|
||||
environment: 'production',
|
||||
},
|
||||
} as RuletypesRuleDTO,
|
||||
];
|
||||
|
||||
it('should return all alerts when filter is empty', () => {
|
||||
const result = filterAlerts(mockAlerts, '');
|
||||
expect(result).toStrictEqual(mockAlerts);
|
||||
});
|
||||
|
||||
it('should return all alerts when filter is only whitespace', () => {
|
||||
const result = filterAlerts(mockAlerts, ' ');
|
||||
expect(result).toStrictEqual(mockAlerts);
|
||||
});
|
||||
|
||||
it('should filter alerts by alert name', () => {
|
||||
const result = filterAlerts(mockAlerts, 'CPU');
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].alert).toBe('High CPU Usage');
|
||||
});
|
||||
|
||||
it('should filter alerts by severity', () => {
|
||||
const result = filterAlerts(mockAlerts, 'warning');
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].labels?.severity).toBe('warning');
|
||||
});
|
||||
|
||||
it('should filter alerts by label key', () => {
|
||||
const result = filterAlerts(mockAlerts, 'environment');
|
||||
expect(result).toHaveLength(3); // All alerts have environment label
|
||||
});
|
||||
|
||||
it('should filter alerts by label value', () => {
|
||||
const result = filterAlerts(mockAlerts, 'production');
|
||||
expect(result).toHaveLength(2);
|
||||
expect(
|
||||
result.every((alert) => alert.labels?.environment === 'production'),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should be case insensitive', () => {
|
||||
const result = filterAlerts(mockAlerts, 'cpu');
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].alert).toBe('High CPU Usage');
|
||||
});
|
||||
|
||||
it('should handle partial matches', () => {
|
||||
const result = filterAlerts(mockAlerts, 'mem');
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].alert).toBe('Memory Leak Detected');
|
||||
});
|
||||
|
||||
it('should handle alerts with missing labels', () => {
|
||||
const alertsWithMissingLabels: RuletypesRuleDTO[] = [
|
||||
{
|
||||
...mockAlertBase,
|
||||
id: '4',
|
||||
alert: 'Test Alert',
|
||||
alertType: 'METRIC_BASED_ALERT',
|
||||
labels: undefined,
|
||||
} as RuletypesRuleDTO,
|
||||
];
|
||||
const result = filterAlerts(alertsWithMissingLabels, 'test');
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].alert).toBe('Test Alert');
|
||||
});
|
||||
|
||||
it('should handle alerts with missing alert name', () => {
|
||||
const alertsWithMissingName: RuletypesRuleDTO[] = [
|
||||
{
|
||||
...mockAlertBase,
|
||||
id: '5',
|
||||
alert: '',
|
||||
alertType: 'METRIC_BASED_ALERT',
|
||||
labels: {
|
||||
severity: 'warning',
|
||||
},
|
||||
} as RuletypesRuleDTO,
|
||||
];
|
||||
const result = filterAlerts(alertsWithMissingName, 'warning');
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].labels?.severity).toBe('warning');
|
||||
});
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
.actionButton {
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.deleteItem {
|
||||
color: var(--bg-cherry-500);
|
||||
|
||||
&:hover {
|
||||
background: color-mix(in srgb, var(--bg-cherry-500) 10%, transparent);
|
||||
}
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { Ellipsis } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DropdownMenuSimple } from '@signozhq/ui/dropdown-menu';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import {
|
||||
createRule,
|
||||
deleteRuleByID,
|
||||
invalidateListRules,
|
||||
patchRuleByID,
|
||||
} from 'api/generated/services/rules';
|
||||
import type {
|
||||
RenderErrorResponseDTO,
|
||||
RuletypesPostableRuleDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { AxiosError } from 'axios';
|
||||
|
||||
import type { AlertRule } from '../types';
|
||||
import { ALERT_ACTIONS, alertActionLogEvent } from '../utils';
|
||||
import styles from './ActionsMenu.module.scss';
|
||||
|
||||
interface ActionsMenuProps {
|
||||
rule: AlertRule;
|
||||
onEdit: (rule: AlertRule, options?: { newTab?: boolean }) => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
function ActionsMenu({
|
||||
rule,
|
||||
onEdit,
|
||||
isLoading: externalLoading = false,
|
||||
}: ActionsMenuProps): JSX.Element {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const handleToggle = useCallback((): void => {
|
||||
alertActionLogEvent(ALERT_ACTIONS.TOGGLE, rule);
|
||||
const newDisabled = !rule.disabled;
|
||||
toast.promise(
|
||||
patchRuleByID({ id: rule.id ?? '' }, {
|
||||
disabled: newDisabled,
|
||||
} as RuletypesPostableRuleDTO).then(() => invalidateListRules(queryClient)),
|
||||
{
|
||||
loading: newDisabled ? 'Disabling alert...' : 'Enabling alert...',
|
||||
success: newDisabled ? 'Alert disabled' : 'Alert enabled',
|
||||
error: (error): string => {
|
||||
const apiError = convertToApiError(
|
||||
error as AxiosError<RenderErrorResponseDTO>,
|
||||
);
|
||||
return apiError?.getErrorMessage() || 'Failed to toggle alert state';
|
||||
},
|
||||
position: 'top-right',
|
||||
},
|
||||
);
|
||||
}, [rule, queryClient]);
|
||||
|
||||
const handleEdit = useCallback((): void => {
|
||||
alertActionLogEvent(ALERT_ACTIONS.EDIT, rule);
|
||||
onEdit(rule);
|
||||
}, [rule, onEdit]);
|
||||
|
||||
const handleEditNewTab = useCallback((): void => {
|
||||
alertActionLogEvent(ALERT_ACTIONS.EDIT, rule);
|
||||
onEdit(rule, { newTab: true });
|
||||
}, [rule, onEdit]);
|
||||
|
||||
const handleClone = useCallback((): void => {
|
||||
alertActionLogEvent(ALERT_ACTIONS.CLONE, rule);
|
||||
toast.promise(
|
||||
createRule({
|
||||
...rule,
|
||||
alert: `${rule.alert} - Copy`,
|
||||
} as RuletypesPostableRuleDTO).then(async (response) => {
|
||||
await invalidateListRules(queryClient);
|
||||
const newRule = response.data;
|
||||
if (newRule) {
|
||||
onEdit(newRule as AlertRule);
|
||||
}
|
||||
}),
|
||||
{
|
||||
loading: 'Cloning alert...',
|
||||
success: 'Alert cloned successfully',
|
||||
error: (error): string => {
|
||||
const apiError = convertToApiError(
|
||||
error as AxiosError<RenderErrorResponseDTO>,
|
||||
);
|
||||
return apiError?.getErrorMessage() || 'Failed to clone alert';
|
||||
},
|
||||
position: 'top-right',
|
||||
},
|
||||
);
|
||||
}, [rule, queryClient, onEdit]);
|
||||
|
||||
const handleDelete = useCallback((): void => {
|
||||
alertActionLogEvent(ALERT_ACTIONS.DELETE, rule);
|
||||
toast.promise(
|
||||
deleteRuleByID({ id: rule.id ?? '' }).then(() =>
|
||||
invalidateListRules(queryClient),
|
||||
),
|
||||
{
|
||||
loading: 'Deleting alert...',
|
||||
success: 'Alert deleted successfully',
|
||||
error: (error): string => {
|
||||
const apiError = convertToApiError(
|
||||
error as AxiosError<RenderErrorResponseDTO>,
|
||||
);
|
||||
return apiError?.getErrorMessage() || 'Failed to delete alert';
|
||||
},
|
||||
position: 'top-right',
|
||||
},
|
||||
);
|
||||
}, [rule, queryClient]);
|
||||
|
||||
const menuItems = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'toggle',
|
||||
label: rule.disabled ? 'Enable' : 'Disable',
|
||||
disabled: externalLoading,
|
||||
onClick: handleToggle,
|
||||
},
|
||||
{
|
||||
key: 'edit',
|
||||
label: 'Edit',
|
||||
disabled: externalLoading,
|
||||
onClick: handleEdit,
|
||||
},
|
||||
{
|
||||
key: 'edit-new-tab',
|
||||
label: 'Edit in New Tab',
|
||||
disabled: externalLoading,
|
||||
onClick: handleEditNewTab,
|
||||
},
|
||||
{
|
||||
key: 'clone',
|
||||
label: 'Clone',
|
||||
disabled: externalLoading,
|
||||
onClick: handleClone,
|
||||
},
|
||||
{ key: 'divider', type: 'divider' as const },
|
||||
{
|
||||
key: 'delete',
|
||||
label: 'Delete',
|
||||
disabled: externalLoading,
|
||||
danger: true,
|
||||
onClick: handleDelete,
|
||||
},
|
||||
],
|
||||
[
|
||||
rule.disabled,
|
||||
externalLoading,
|
||||
handleToggle,
|
||||
handleEdit,
|
||||
handleEditNewTab,
|
||||
handleClone,
|
||||
handleDelete,
|
||||
],
|
||||
);
|
||||
|
||||
const handleClick = (e: React.MouseEvent): void => {
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
|
||||
<div onClick={handleClick}>
|
||||
<DropdownMenuSimple menu={{ items: menuItems }} align="end">
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
className={styles.actionButton}
|
||||
data-testid="alert-actions"
|
||||
>
|
||||
<Ellipsis size={16} />
|
||||
</Button>
|
||||
</DropdownMenuSimple>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ActionsMenu;
|
||||
@@ -1,34 +0,0 @@
|
||||
.popoverContent {
|
||||
min-width: 180px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--l2-foreground);
|
||||
padding: 4px 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.columnList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.columnItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: var(--l1-foreground);
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--l2-background-hover);
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Columns3 } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Checkbox } from '@signozhq/ui/checkbox';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@signozhq/ui/popover';
|
||||
import type { TableColumnDef } from 'components/TanStackTableView';
|
||||
import {
|
||||
hideColumn,
|
||||
showColumn,
|
||||
useHiddenColumnIds,
|
||||
} from 'components/TanStackTableView';
|
||||
|
||||
import styles from './ColumnSelector.module.scss';
|
||||
|
||||
interface ColumnSelectorProps<TData> {
|
||||
columns: TableColumnDef<TData>[];
|
||||
storageKey: string;
|
||||
}
|
||||
|
||||
function ColumnSelector<TData>({
|
||||
columns,
|
||||
storageKey,
|
||||
}: ColumnSelectorProps<TData>): JSX.Element {
|
||||
const hiddenColumnIds = useHiddenColumnIds(storageKey);
|
||||
|
||||
const selectableColumns = useMemo(
|
||||
() =>
|
||||
columns.filter(
|
||||
(col) => col.canBeHidden !== false && col.enableRemove !== false,
|
||||
),
|
||||
[columns],
|
||||
);
|
||||
|
||||
const handleToggle = (columnId: string, checked: boolean): void => {
|
||||
if (checked) {
|
||||
showColumn(storageKey, columnId);
|
||||
} else {
|
||||
hideColumn(storageKey, columnId);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
prefix={<Columns3 size={14} />}
|
||||
>
|
||||
Columns
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className={styles.popoverContent}>
|
||||
<div className={styles.title}>Toggle Columns</div>
|
||||
<div className={styles.columnList}>
|
||||
{selectableColumns.map((col) => {
|
||||
const isVisible = !hiddenColumnIds.includes(col.id);
|
||||
const label = typeof col.header === 'string' ? col.header : col.id;
|
||||
|
||||
return (
|
||||
<label key={col.id} className={styles.columnItem}>
|
||||
<Checkbox
|
||||
id={`col-${col.id}`}
|
||||
value={isVisible}
|
||||
onChange={(): void => handleToggle(col.id, !isVisible)}
|
||||
/>
|
||||
<span>{label}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export default ColumnSelector;
|
||||
@@ -1,2 +0,0 @@
|
||||
export { default as ActionsMenu } from './ActionsMenu';
|
||||
export { default as ColumnSelector } from './ColumnSelector';
|
||||
@@ -1,24 +0,0 @@
|
||||
import { Options, useQueryState, UseQueryStateReturn } from 'nuqs';
|
||||
import { parseAsJsonNoValidate } from 'utils/nuqsParsers';
|
||||
|
||||
const defaultNuqsOptions: Options = {
|
||||
history: 'push',
|
||||
};
|
||||
|
||||
export const ALERT_RULES_PARAMS = {
|
||||
SEARCH: 'search',
|
||||
PAGE: 'page',
|
||||
RULE_TYPE: 'ruleType',
|
||||
FILTERS: 'alertRulesFilters',
|
||||
} as const;
|
||||
|
||||
export const useAlertRulesFilters = (): UseQueryStateReturn<
|
||||
string[],
|
||||
string[]
|
||||
> =>
|
||||
useQueryState(
|
||||
ALERT_RULES_PARAMS.FILTERS,
|
||||
parseAsJsonNoValidate<string[]>()
|
||||
.withDefault([])
|
||||
.withOptions(defaultNuqsOptions),
|
||||
);
|
||||
@@ -1,202 +1,67 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { Plus, Search } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import ErrorEmptyState from 'components/Alerts/ErrorEmptyState';
|
||||
import NoResultsEmptyState from 'components/Alerts/NoResultsEmptyState';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
import { useCalculatedPageSize } from 'components/TanStackTableView/useCalculatedPageSize';
|
||||
import { useTableParams } from 'components/TanStackTableView/useTableParams';
|
||||
import useComponentPermission from 'hooks/useComponentPermission';
|
||||
import { useUrlSearchState } from 'hooks/useUrlSearchState';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
|
||||
import TextToolTip from 'components/TextToolTip';
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Space } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import { useListRules } from 'api/generated/services/rules';
|
||||
import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { AxiosError } from 'axios';
|
||||
import Spinner from 'components/Spinner';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
|
||||
import { AlertsEmptyState } from './AlertsEmptyState/AlertsEmptyState';
|
||||
import { ActionsMenu, ColumnSelector } from './components';
|
||||
import { ALERT_RULES_PARAMS, useAlertRulesFilters } from './hooks';
|
||||
import styles from './ListAlertRules.module.scss';
|
||||
import { getAlertRuleColumns } from './table.config';
|
||||
import type { AlertRule } from './types';
|
||||
import { useAlertRulesData } from './useAlertRulesData';
|
||||
import { useAlertRulesHandlers } from './useAlertRulesHandlers';
|
||||
|
||||
const QUERY_PARAMS_CONFIG = {
|
||||
orderBy: 'orderBy',
|
||||
page: 'page',
|
||||
limit: 'limit',
|
||||
} as const;
|
||||
|
||||
const DEFAULT_PAGE = 1;
|
||||
const DEFAULT_LIMIT = 10;
|
||||
import ListAlert from './ListAlert';
|
||||
|
||||
function ListAlertRules(): JSX.Element {
|
||||
const { user } = useAppContext();
|
||||
const [addNewAlert, action] = useComponentPermission(
|
||||
['add_new_alert', 'action'],
|
||||
user.role,
|
||||
);
|
||||
|
||||
const [filterValues, setFilterValues] = useAlertRulesFilters();
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
|
||||
const { containerRef, calculatedPageSize } = useCalculatedPageSize({
|
||||
rowHeight: 46,
|
||||
const { t } = useTranslation('common');
|
||||
const { data, isError, isLoading, refetch, error } = useListRules({
|
||||
query: { cacheTime: 0 },
|
||||
});
|
||||
|
||||
const { orderBy, page, limit, setLimit, setPage } = useTableParams(
|
||||
QUERY_PARAMS_CONFIG,
|
||||
{
|
||||
page: DEFAULT_PAGE,
|
||||
limit: DEFAULT_LIMIT,
|
||||
storageKey: 'alert-rules',
|
||||
calculatedPageSize,
|
||||
cleanupOnUnmount: true,
|
||||
},
|
||||
const rules = data?.data ?? [];
|
||||
const hasLoaded = !isLoading && data !== undefined;
|
||||
const logEventCalledRef = useRef(false);
|
||||
|
||||
const { notifications } = useNotifications();
|
||||
|
||||
const apiError = useMemo(
|
||||
() => convertToApiError(error as AxiosError<RenderErrorResponseDTO> | null),
|
||||
[error],
|
||||
);
|
||||
|
||||
const resetPageOnSearch = useCallback((): void => {
|
||||
setPage(1);
|
||||
}, [setPage]);
|
||||
|
||||
const { searchText, debouncedSearch, handleSearchChange, clearSearch } =
|
||||
useUrlSearchState(ALERT_RULES_PARAMS.SEARCH, {
|
||||
onDebouncedChange: resetPageOnSearch,
|
||||
});
|
||||
|
||||
const { filteredRules, isFetching, isError, allRules, refetch } =
|
||||
useAlertRulesData(orderBy, debouncedSearch, filterValues ?? []);
|
||||
|
||||
const { handleEdit, handleNewAlert, handleRowClick, handleRowClickNewTab } =
|
||||
useAlertRulesHandlers(allRules.length);
|
||||
|
||||
const handleClearFilters = useCallback((): void => {
|
||||
void setFilterValues(null);
|
||||
clearSearch();
|
||||
}, [setFilterValues, clearSearch]);
|
||||
|
||||
const columns = useMemo(
|
||||
() => getAlertRuleColumns(formatTimezoneAdjustedTimestamp),
|
||||
[formatTimezoneAdjustedTimestamp],
|
||||
);
|
||||
|
||||
const paginatedRules = useMemo(() => {
|
||||
const start = (page - 1) * limit;
|
||||
return filteredRules.slice(start, start + limit);
|
||||
}, [filteredRules, page, limit]);
|
||||
|
||||
const columnsWithActions = useMemo(() => {
|
||||
if (!action) {
|
||||
return columns;
|
||||
useEffect(() => {
|
||||
if (!logEventCalledRef.current && hasLoaded) {
|
||||
logEvent('Alert: List page visited', {
|
||||
number: rules.length,
|
||||
});
|
||||
logEventCalledRef.current = true;
|
||||
}
|
||||
}, [hasLoaded, rules.length]);
|
||||
|
||||
return [
|
||||
...columns,
|
||||
{
|
||||
id: 'actions',
|
||||
header: (): JSX.Element => (
|
||||
<span style={{ textAlign: 'right', display: 'block' }}>Actions</span>
|
||||
),
|
||||
accessorKey: 'id',
|
||||
width: { fixed: '80px', ignoreLastColumnFill: true },
|
||||
enableSort: false,
|
||||
enableRemove: false,
|
||||
enableMove: false,
|
||||
pin: 'right' as const,
|
||||
cell: ({ row }: { row: AlertRule }): JSX.Element => (
|
||||
<div className={styles.actionsColumn}>
|
||||
<ActionsMenu rule={row} onEdit={handleEdit} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
}, [action, columns, handleEdit]);
|
||||
useEffect(() => {
|
||||
if (isError) {
|
||||
notifications.error({
|
||||
message: apiError?.getErrorMessage() || t('something_went_wrong'),
|
||||
});
|
||||
}
|
||||
}, [isError, apiError, t, notifications]);
|
||||
|
||||
const hasActiveFilters =
|
||||
searchText.length > 0 || (filterValues ?? []).length > 0;
|
||||
const isEmptyDueToFilters =
|
||||
!isFetching &&
|
||||
filteredRules.length === 0 &&
|
||||
hasActiveFilters &&
|
||||
allRules.length > 0;
|
||||
const isEmptyNoRules = !isFetching && !isError && allRules.length === 0;
|
||||
if (isError) {
|
||||
return <div>{apiError?.getErrorMessage() || t('something_went_wrong')}</div>;
|
||||
}
|
||||
|
||||
if (isLoading || !data) {
|
||||
return <Spinner height="75vh" tip="Loading Rules..." />;
|
||||
}
|
||||
|
||||
if (rules.length === 0) {
|
||||
return <AlertsEmptyState />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{!isEmptyNoRules && (
|
||||
<div className={styles.header}>
|
||||
<div className={styles.refreshRow}>
|
||||
<ColumnSelector columns={columns} storageKey="alert-rules-columns" />
|
||||
{addNewAlert && (
|
||||
<Button
|
||||
variant="solid"
|
||||
size="sm"
|
||||
prefix={<Plus size={14} />}
|
||||
onClick={handleNewAlert}
|
||||
color="primary"
|
||||
>
|
||||
New Alert
|
||||
</Button>
|
||||
)}
|
||||
<TextToolTip
|
||||
text="More details on how to create alerts"
|
||||
url="https://signoz.io/docs/alerts/?utm_source=product&utm_medium=list-alerts"
|
||||
urlText="Learn More"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isEmptyNoRules && (
|
||||
<div className={styles.filtersRow}>
|
||||
<Input
|
||||
className={styles.searchInput}
|
||||
placeholder="Search by Alert Name, Severity and Labels"
|
||||
value={searchText}
|
||||
onChange={handleSearchChange}
|
||||
suffix={<Search size={14} className={styles.searchIcon} />}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={containerRef} className={styles.tableContainer}>
|
||||
{isError ? (
|
||||
<ErrorEmptyState title="Failed to load alert rules" onRefresh={refetch} />
|
||||
) : isEmptyDueToFilters ? (
|
||||
<NoResultsEmptyState
|
||||
title="No matching alert rules"
|
||||
subtitle="No alert rules match your search. Try adjusting your search criteria."
|
||||
onClear={handleClearFilters}
|
||||
clearButtonText="Clear Search"
|
||||
/>
|
||||
) : isEmptyNoRules ? (
|
||||
<AlertsEmptyState onRefresh={refetch} />
|
||||
) : (
|
||||
<TanStackTable<AlertRule>
|
||||
data={paginatedRules}
|
||||
columns={columnsWithActions}
|
||||
isLoading={isFetching}
|
||||
getRowKey={(row): string => row.id ?? ''}
|
||||
getItemKey={(row): string => row.id ?? ''}
|
||||
columnStorageKey="alert-rules-columns"
|
||||
enableQueryParams={QUERY_PARAMS_CONFIG}
|
||||
onRowClick={handleRowClick}
|
||||
onRowClickNewTab={handleRowClickNewTab}
|
||||
pagination={{
|
||||
total: filteredRules.length,
|
||||
calculatedPageSize,
|
||||
onLimitChange: setLimit,
|
||||
showTotalCount: true,
|
||||
}}
|
||||
paginationClassname={styles.paginationContainer}
|
||||
enableAlternatingRowColors
|
||||
plainTextCellLineClamp={2}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
<ListAlert allAlertRules={rules} refetch={refetch} />
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
28
frontend/src/container/ListAlertRules/styles.ts
Normal file
28
frontend/src/container/ListAlertRules/styles.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Button as ButtonComponent } from 'antd';
|
||||
import styled from 'styled-components';
|
||||
|
||||
export const SearchContainer = styled.div`
|
||||
&&& {
|
||||
display: flex;
|
||||
margin-bottom: 2rem;
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
}
|
||||
`;
|
||||
|
||||
export const Button = styled(ButtonComponent)`
|
||||
&&& {
|
||||
margin-left: 1em;
|
||||
}
|
||||
`;
|
||||
|
||||
export const ColumnButton = styled(ButtonComponent)`
|
||||
&&& {
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
margin-right: 1.5em;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
`;
|
||||
@@ -1,156 +0,0 @@
|
||||
import { Badge, BadgeColor } from '@signozhq/ui/badge';
|
||||
import { SEVERITY_BADGE_COLORS } from 'components/Alerts/constants';
|
||||
import LabelColumn from 'components/Alerts/LabelColumn';
|
||||
import type { TableColumnDef } from 'components/TanStackTableView';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
|
||||
import type { AlertRule } from './types';
|
||||
|
||||
const STATE_CONFIG: Record<string, { color: BadgeColor; label: string }> = {
|
||||
firing: { color: 'error', label: 'Firing' },
|
||||
inactive: { color: 'success', label: 'OK' },
|
||||
pending: { color: 'warning', label: 'Pending' },
|
||||
disabled: { color: 'secondary', label: 'Disabled' },
|
||||
};
|
||||
|
||||
export function getAlertRuleColumns(
|
||||
formatTimezoneAdjustedTimestamp: (date: string, format: string) => string,
|
||||
): TableColumnDef<AlertRule>[] {
|
||||
return [
|
||||
{
|
||||
id: 'state',
|
||||
header: 'Status',
|
||||
accessorKey: 'state',
|
||||
width: { fixed: '100px' },
|
||||
enableSort: true,
|
||||
enableRemove: false,
|
||||
enableMove: false,
|
||||
cell: ({ value }): JSX.Element => {
|
||||
const state = String(value ?? '').toLowerCase();
|
||||
const config = STATE_CONFIG[state] ?? {
|
||||
color: 'secondary' as BadgeColor,
|
||||
label: 'Unknown',
|
||||
};
|
||||
return (
|
||||
<Badge color={config.color} variant="outline">
|
||||
{config.label}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'name',
|
||||
header: 'Alert Name',
|
||||
accessorKey: 'alert',
|
||||
width: { default: '100%' },
|
||||
enableSort: true,
|
||||
enableRemove: false,
|
||||
enableMove: false,
|
||||
cell: ({ value }): JSX.Element => (
|
||||
<TanStackTable.Text title={value}>
|
||||
{String(value ?? '-')}
|
||||
</TanStackTable.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'severity',
|
||||
header: 'Severity',
|
||||
accessorFn: (row) => row.labels?.severity ?? '',
|
||||
width: { fixed: '120px' },
|
||||
enableSort: true,
|
||||
enableMove: false,
|
||||
cell: ({ value }): JSX.Element => {
|
||||
const severity = String(value ?? '').toLowerCase();
|
||||
if (!severity) {
|
||||
return <TanStackTable.Text>-</TanStackTable.Text>;
|
||||
}
|
||||
return (
|
||||
<Badge
|
||||
color={SEVERITY_BADGE_COLORS[severity] ?? 'secondary'}
|
||||
variant="outline"
|
||||
>
|
||||
{severity}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'labels',
|
||||
header: 'Labels',
|
||||
accessorKey: 'labels',
|
||||
width: { default: '100%' },
|
||||
enableSort: false,
|
||||
enableMove: false,
|
||||
cell: ({ value }): JSX.Element => {
|
||||
const labels = value as Record<string, string> | undefined;
|
||||
if (!labels) {
|
||||
return <TanStackTable.Text>-</TanStackTable.Text>;
|
||||
}
|
||||
|
||||
const tagKeys = Object.keys(labels).filter((k) => k !== 'severity');
|
||||
if (!tagKeys.length) {
|
||||
return <TanStackTable.Text>-</TanStackTable.Text>;
|
||||
}
|
||||
|
||||
return <LabelColumn labels={tagKeys} value={labels} color="sakura" />;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'createdAt',
|
||||
header: 'Created At',
|
||||
accessorKey: 'createdAt',
|
||||
width: { default: '100%' },
|
||||
enableSort: true,
|
||||
enableMove: false,
|
||||
defaultVisibility: false,
|
||||
cell: ({ value }): JSX.Element => (
|
||||
<TanStackTable.Text>
|
||||
{value
|
||||
? formatTimezoneAdjustedTimestamp(String(value), DATE_TIME_FORMATS.UTC_US)
|
||||
: '-'}
|
||||
</TanStackTable.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'createdBy',
|
||||
header: 'Created By',
|
||||
accessorKey: 'createdBy',
|
||||
width: { default: '100%' },
|
||||
enableSort: false,
|
||||
enableMove: false,
|
||||
defaultVisibility: false,
|
||||
cell: ({ value }): JSX.Element => (
|
||||
<TanStackTable.Text>{String(value ?? '-')}</TanStackTable.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'updatedAt',
|
||||
header: 'Updated At',
|
||||
accessorKey: 'updatedAt',
|
||||
width: { default: '100%' },
|
||||
enableSort: true,
|
||||
enableMove: false,
|
||||
defaultVisibility: false,
|
||||
cell: ({ value }): JSX.Element => (
|
||||
<TanStackTable.Text>
|
||||
{value
|
||||
? formatTimezoneAdjustedTimestamp(String(value), DATE_TIME_FORMATS.UTC_US)
|
||||
: '-'}
|
||||
</TanStackTable.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'updatedBy',
|
||||
header: 'Updated By',
|
||||
accessorKey: 'updatedBy',
|
||||
width: { default: '100%' },
|
||||
enableSort: false,
|
||||
enableMove: false,
|
||||
defaultVisibility: false,
|
||||
cell: ({ value }): JSX.Element => (
|
||||
<TanStackTable.Text>{String(value ?? '-')}</TanStackTable.Text>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
import type { RuletypesRuleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
export type AlertRule = RuletypesRuleDTO;
|
||||
@@ -1,55 +0,0 @@
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { useListRules } from 'api/generated/services/rules';
|
||||
import { searchByLabels } from 'components/Alerts/utils';
|
||||
import type { SortState } from 'components/TanStackTableView/types';
|
||||
import { isUndefined } from 'lodash-es';
|
||||
|
||||
import type { AlertRule } from './types';
|
||||
import { filterRulesByFilters, sortRules } from './utils';
|
||||
|
||||
interface UseAlertRulesDataReturn {
|
||||
allRules: AlertRule[];
|
||||
filteredRules: AlertRule[];
|
||||
isFetching: boolean;
|
||||
isError: boolean;
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
export function useAlertRulesData(
|
||||
orderBy: SortState | null,
|
||||
searchText = '',
|
||||
filters: string[] = [],
|
||||
): UseAlertRulesDataReturn {
|
||||
const hasLoggedEvent = useRef(false);
|
||||
|
||||
const rulesResponse = useListRules();
|
||||
|
||||
const allRules = useMemo(
|
||||
() => rulesResponse.data?.data ?? [],
|
||||
[rulesResponse.data],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasLoggedEvent.current && !isUndefined(rulesResponse.data?.data)) {
|
||||
void logEvent('Alert: List page visited', {
|
||||
number: allRules.length,
|
||||
});
|
||||
hasLoggedEvent.current = true;
|
||||
}
|
||||
}, [rulesResponse.data, allRules.length]);
|
||||
|
||||
const filteredRules = useMemo(() => {
|
||||
const filtered = filterRulesByFilters(allRules, filters);
|
||||
const searched = searchByLabels(filtered, searchText, (r) => r.alert ?? '');
|
||||
return sortRules(searched, orderBy);
|
||||
}, [allRules, filters, searchText, orderBy]);
|
||||
|
||||
return {
|
||||
allRules,
|
||||
filteredRules,
|
||||
isFetching: rulesResponse.isFetching,
|
||||
isError: rulesResponse.isError,
|
||||
refetch: rulesResponse.refetch,
|
||||
};
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { sanitizeDefaultAlertQuery } from 'container/EditAlertV2/utils';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { useTableRowClick } from 'hooks/useTableRowClick';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
|
||||
import { toCompositeMetricQuery } from 'types/api/alerts/convert';
|
||||
import { isModifierKeyPressed } from 'utils/app';
|
||||
|
||||
import type { AlertRule } from './types';
|
||||
|
||||
interface UseAlertRulesHandlersReturn {
|
||||
handleEdit: (rule: AlertRule, options?: { newTab?: boolean }) => void;
|
||||
handleNewAlert: (e: React.MouseEvent) => void;
|
||||
handleRowClick: (rule: AlertRule) => void;
|
||||
handleRowClickNewTab: (rule: AlertRule) => void;
|
||||
}
|
||||
|
||||
export function useAlertRulesHandlers(
|
||||
allRulesCount: number,
|
||||
): UseAlertRulesHandlersReturn {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const params = useUrlQuery();
|
||||
|
||||
const getEditUrl = useCallback(
|
||||
(rule: AlertRule): string => {
|
||||
const compositeQuery = sanitizeDefaultAlertQuery(
|
||||
mapQueryDataFromApi(toCompositeMetricQuery(rule.condition.compositeQuery)),
|
||||
rule.alertType,
|
||||
);
|
||||
params.set(
|
||||
QueryParams.compositeQuery,
|
||||
encodeURIComponent(JSON.stringify(compositeQuery)),
|
||||
);
|
||||
|
||||
const panelType = rule.condition.compositeQuery.panelType;
|
||||
if (panelType) {
|
||||
params.set(QueryParams.panelTypes, panelType);
|
||||
}
|
||||
|
||||
params.set(QueryParams.ruleId, rule.id);
|
||||
|
||||
return `${ROUTES.ALERT_OVERVIEW}?${params.toString()}`;
|
||||
},
|
||||
[params],
|
||||
);
|
||||
|
||||
const handleEdit = useCallback(
|
||||
(rule: AlertRule, options?: { newTab?: boolean }): void => {
|
||||
safeNavigate(getEditUrl(rule), options);
|
||||
},
|
||||
[getEditUrl, safeNavigate],
|
||||
);
|
||||
|
||||
const handleNewAlert = useCallback(
|
||||
(e: React.MouseEvent): void => {
|
||||
void logEvent('Alert: New alert button clicked', {
|
||||
number: allRulesCount,
|
||||
layout: 'new',
|
||||
});
|
||||
safeNavigate(ROUTES.ALERTS_NEW, {
|
||||
newTab: isModifierKeyPressed(e),
|
||||
});
|
||||
},
|
||||
[allRulesCount, safeNavigate],
|
||||
);
|
||||
|
||||
const { handleRowClick, handleRowClickNewTab } = useTableRowClick<AlertRule>({
|
||||
getUrl: getEditUrl,
|
||||
onNavigate: safeNavigate,
|
||||
});
|
||||
|
||||
return {
|
||||
handleEdit,
|
||||
handleNewAlert,
|
||||
handleRowClick,
|
||||
handleRowClickNewTab,
|
||||
};
|
||||
}
|
||||
@@ -1,92 +1,59 @@
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import type { RuletypesRuleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { sortByColumn } from 'components/Alerts/utils';
|
||||
import type { SortState } from 'components/TanStackTableView/types';
|
||||
import { dataSourceForAlertType } from 'constants/alerts';
|
||||
|
||||
import type { AlertRule } from './types';
|
||||
export const filterAlerts = (
|
||||
allAlertRules: RuletypesRuleDTO[],
|
||||
filter: string,
|
||||
): RuletypesRuleDTO[] => {
|
||||
if (!filter.trim()) {
|
||||
return allAlertRules;
|
||||
}
|
||||
|
||||
export const ALERT_RULES_REFRESH_INTERVAL = 30_000;
|
||||
const value = filter.trim().toLowerCase();
|
||||
return allAlertRules.filter((alert) => {
|
||||
const alertName = alert.alert.toLowerCase();
|
||||
const severity = alert.labels?.severity?.toLowerCase();
|
||||
|
||||
export const ALERT_ACTIONS = {
|
||||
TOGGLE: 'toggle',
|
||||
EDIT: 'edit',
|
||||
CLONE: 'clone',
|
||||
DELETE: 'delete',
|
||||
} as const;
|
||||
// Create a string of all label keys and values for searching
|
||||
const labelSearchString = Object.entries(alert.labels || {})
|
||||
.map(([key, val]) => `${key} ${val}`)
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
|
||||
const ACTION_LABELS: Record<string, string> = {
|
||||
[ALERT_ACTIONS.TOGGLE]: 'Enable/Disable',
|
||||
[ALERT_ACTIONS.EDIT]: 'Edit',
|
||||
[ALERT_ACTIONS.CLONE]: 'Clone',
|
||||
[ALERT_ACTIONS.DELETE]: 'Delete',
|
||||
return (
|
||||
alertName.includes(value) ||
|
||||
severity?.includes(value) ||
|
||||
labelSearchString.includes(value)
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export const alertActionLogEvent = (
|
||||
action: string,
|
||||
record: RuletypesRuleDTO,
|
||||
): void => {
|
||||
const actionValue = ACTION_LABELS[action] ?? action;
|
||||
void logEvent('Alert: Action', {
|
||||
let actionValue = '';
|
||||
switch (action) {
|
||||
case '0':
|
||||
actionValue = 'Enable/Disable';
|
||||
break;
|
||||
case '1':
|
||||
actionValue = 'Edit';
|
||||
break;
|
||||
case '2':
|
||||
actionValue = 'Clone';
|
||||
break;
|
||||
case '3':
|
||||
actionValue = 'Delete';
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
logEvent('Alert: Action', {
|
||||
ruleId: record.id,
|
||||
dataSource: dataSourceForAlertType(record.alertType),
|
||||
name: record.alert,
|
||||
action: actionValue,
|
||||
});
|
||||
};
|
||||
|
||||
export function getAlertSortValue(
|
||||
rule: AlertRule,
|
||||
columnName: string,
|
||||
): string | number {
|
||||
switch (columnName) {
|
||||
case 'state':
|
||||
return rule.state ?? '';
|
||||
case 'name':
|
||||
return rule.alert ?? '';
|
||||
case 'severity':
|
||||
return rule.labels?.severity ?? '';
|
||||
case 'createdAt':
|
||||
return rule.createdAt ? new Date(rule.createdAt).getTime() : 0;
|
||||
case 'updatedAt':
|
||||
return rule.updatedAt ? new Date(rule.updatedAt).getTime() : 0;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function sortRules(
|
||||
rules: AlertRule[],
|
||||
orderBy: SortState | null,
|
||||
): AlertRule[] {
|
||||
return sortByColumn(rules, orderBy, getAlertSortValue);
|
||||
}
|
||||
|
||||
export function filterRulesByFilters(
|
||||
rules: AlertRule[],
|
||||
filters: string[],
|
||||
): AlertRule[] {
|
||||
if (filters.length === 0) {
|
||||
return rules;
|
||||
}
|
||||
|
||||
const stateFilters = filters
|
||||
.filter((f) => f.startsWith('state:'))
|
||||
.map((f) => f.replace('state:', '').toLowerCase());
|
||||
|
||||
const severityFilters = filters
|
||||
.filter((f) => f.startsWith('severity:'))
|
||||
.map((f) => f.replace('severity:', '').toLowerCase());
|
||||
|
||||
return rules.filter((rule) => {
|
||||
const state = rule.state?.toLowerCase() ?? '';
|
||||
const severity = rule.labels?.severity?.toLowerCase() ?? '';
|
||||
|
||||
const matchesState =
|
||||
stateFilters.length === 0 || stateFilters.includes(state);
|
||||
const matchesSeverity =
|
||||
severityFilters.length === 0 || severityFilters.includes(severity);
|
||||
|
||||
return matchesState && matchesSeverity;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ export const TagContainer = styled(Badge)`
|
||||
`;
|
||||
|
||||
export const TagLabel = styled.span`
|
||||
color: var(--foreground);
|
||||
font-weight: 400;
|
||||
font-size: 12px;
|
||||
`;
|
||||
|
||||
@@ -27,7 +27,7 @@ export default function NavItem({
|
||||
showIcon?: boolean;
|
||||
dataTestId?: string;
|
||||
}): JSX.Element {
|
||||
const { label, icon, isBeta, isNew, isEarlyAccess } = item;
|
||||
const { label, icon, isBeta, isNew } = item;
|
||||
|
||||
const handleTogglePinClick = (
|
||||
event: React.MouseEvent<SVGSVGElement, MouseEvent>,
|
||||
@@ -53,11 +53,7 @@ export default function NavItem({
|
||||
>
|
||||
{showIcon && <div className="nav-item-active-marker" />}
|
||||
<div className={cx('nav-item-data', isBeta ? 'beta-tag' : '')}>
|
||||
{showIcon && (
|
||||
<div className={cx('nav-item-icon', isEarlyAccess ? 'noz-wave' : '')}>
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
{showIcon && <div className="nav-item-icon">{icon}</div>}
|
||||
|
||||
<div className="nav-item-label">{label}</div>
|
||||
|
||||
@@ -77,12 +73,6 @@ export default function NavItem({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEarlyAccess && (
|
||||
<div className="nav-item-early-access">
|
||||
<Badge color="robin">Early Access</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{onTogglePin && !isPinned && (
|
||||
<Tooltip title="Add to shortcuts" placement="right">
|
||||
<Pin
|
||||
|
||||
@@ -579,8 +579,7 @@
|
||||
}
|
||||
|
||||
.nav-item-beta,
|
||||
.nav-item-new,
|
||||
.nav-item-early-access {
|
||||
.nav-item-new {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -624,23 +623,6 @@
|
||||
background: var(--l3-background);
|
||||
}
|
||||
|
||||
.sidenav-early-access-tag {
|
||||
font-variant-numeric: lining-nums tabular-nums slashed-zero;
|
||||
font-feature-settings:
|
||||
'case' on,
|
||||
'cpsp' on,
|
||||
'dlig' on,
|
||||
'salt' on;
|
||||
font-family: Inter;
|
||||
font-size: 9px;
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
line-height: 12px;
|
||||
letter-spacing: 0.3px;
|
||||
text-transform: uppercase;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
&:not(.pinned) {
|
||||
.nav-item {
|
||||
.nav-item-data {
|
||||
@@ -857,8 +839,7 @@
|
||||
}
|
||||
|
||||
.nav-item-beta,
|
||||
.nav-item-new,
|
||||
.nav-item-early-access {
|
||||
.nav-item-new {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
@@ -1031,8 +1012,7 @@
|
||||
}
|
||||
|
||||
.nav-item-beta,
|
||||
.nav-item-new,
|
||||
.nav-item-early-access {
|
||||
.nav-item-new {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,7 +44,6 @@ import {
|
||||
SidebarItem,
|
||||
} from './sideNav.types';
|
||||
import { Style } from '@signozhq/design-tokens';
|
||||
import Noz from 'components/Noz/Noz';
|
||||
|
||||
export const getStartedMenuItem = {
|
||||
key: ROUTES.GET_STARTED,
|
||||
@@ -93,10 +92,9 @@ const AI_ASSISTANT_NAV_KEY = '/ai-assistant/new';
|
||||
|
||||
export const aiAssistantMenuItem = {
|
||||
key: AI_ASSISTANT_NAV_KEY,
|
||||
label: 'Noz',
|
||||
icon: <Noz size={16} />,
|
||||
label: 'AI Assistant',
|
||||
icon: <Sparkles size={16} className="ai-assistant-icon" />,
|
||||
itemKey: 'ai-assistant',
|
||||
isEarlyAccess: true,
|
||||
};
|
||||
|
||||
export const shortcutMenuItem = {
|
||||
|
||||
@@ -14,7 +14,6 @@ export interface SidebarItem {
|
||||
label?: ReactNode;
|
||||
isBeta?: boolean;
|
||||
isNew?: boolean;
|
||||
isEarlyAccess?: boolean;
|
||||
isPinned?: boolean;
|
||||
children?: SidebarItem[];
|
||||
isExternal?: boolean;
|
||||
|
||||
141
frontend/src/container/TriggeredAlerts/Filter.tsx
Normal file
141
frontend/src/container/TriggeredAlerts/Filter.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import type { SelectProps } from 'antd';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Tooltip } from 'antd';
|
||||
import type { BaseOptionType } from 'antd/es/select';
|
||||
import { Alerts } from 'types/api/alerts/getTriggered';
|
||||
|
||||
import { Container, Select } from './styles';
|
||||
|
||||
function TextOverflowTooltip({
|
||||
option,
|
||||
}: {
|
||||
option: BaseOptionType;
|
||||
}): JSX.Element {
|
||||
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||
const isOverflow = contentRef.current
|
||||
? contentRef.current?.offsetWidth < contentRef.current?.scrollWidth
|
||||
: false;
|
||||
return (
|
||||
<Tooltip
|
||||
placement="left"
|
||||
title={option.value}
|
||||
{...(!isOverflow ? { open: false } : {})}
|
||||
>
|
||||
<div className="ant-select-item-option-content" ref={contentRef}>
|
||||
{option.value}
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function Filter({
|
||||
onSelectedFilterChange,
|
||||
onSelectedGroupChange,
|
||||
allAlerts,
|
||||
selectedGroup,
|
||||
selectedFilter,
|
||||
}: FilterProps): JSX.Element {
|
||||
const onChangeSelectGroupHandler = useCallback(
|
||||
(value: unknown) => {
|
||||
if (typeof value === 'object' && Array.isArray(value)) {
|
||||
onSelectedGroupChange(
|
||||
value.map((e) => ({
|
||||
value: e,
|
||||
})),
|
||||
);
|
||||
}
|
||||
},
|
||||
[onSelectedGroupChange],
|
||||
);
|
||||
|
||||
const onChangeSelectedFilterHandler = useCallback(
|
||||
(value: unknown) => {
|
||||
if (typeof value === 'object' && Array.isArray(value)) {
|
||||
onSelectedFilterChange(
|
||||
value.map((e) => ({
|
||||
value: e,
|
||||
})),
|
||||
);
|
||||
}
|
||||
},
|
||||
[onSelectedFilterChange],
|
||||
);
|
||||
|
||||
const uniqueLabels: Array<string> = useMemo(() => {
|
||||
const allLabelsSet = new Set<string>();
|
||||
allAlerts.forEach((e) => {
|
||||
if (!e.labels) {
|
||||
return;
|
||||
}
|
||||
Object.keys(e.labels).forEach((e) => {
|
||||
allLabelsSet.add(e);
|
||||
});
|
||||
});
|
||||
return [...allLabelsSet];
|
||||
}, [allAlerts]);
|
||||
|
||||
const options = uniqueLabels.map((e) => ({
|
||||
value: e,
|
||||
title: '',
|
||||
}));
|
||||
|
||||
const getTags: SelectProps['tagRender'] = (props): JSX.Element => {
|
||||
const { closable, onClose, label } = props;
|
||||
|
||||
return (
|
||||
<Badge
|
||||
color="sakura"
|
||||
style={{ marginRight: 3 }}
|
||||
closable={closable}
|
||||
onClose={(e): void => {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Select
|
||||
allowClear
|
||||
onChange={onChangeSelectedFilterHandler}
|
||||
mode="tags"
|
||||
value={selectedFilter.map((e) => e.value)}
|
||||
placeholder="Filter by Tags - e.g. severity:warning, alertname:Sample Alert"
|
||||
tagRender={(props): JSX.Element => getTags(props)}
|
||||
options={[]}
|
||||
/>
|
||||
<Select
|
||||
allowClear
|
||||
onChange={onChangeSelectGroupHandler}
|
||||
mode="tags"
|
||||
defaultValue={selectedGroup.map((e) => e.value)}
|
||||
showArrow
|
||||
placeholder="Group by any tag"
|
||||
tagRender={(props): JSX.Element => getTags(props)}
|
||||
options={options}
|
||||
optionRender={(option): JSX.Element => (
|
||||
<TextOverflowTooltip option={option} />
|
||||
)}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
interface FilterProps {
|
||||
onSelectedFilterChange: (value: Array<Value>) => void;
|
||||
onSelectedGroupChange: (value: Array<Value>) => void;
|
||||
allAlerts: Alerts[];
|
||||
selectedGroup: Array<Value>;
|
||||
selectedFilter: Array<Value>;
|
||||
}
|
||||
|
||||
export interface Value {
|
||||
value: string;
|
||||
}
|
||||
|
||||
export default Filter;
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { Alerts } from 'types/api/alerts/getTriggered';
|
||||
|
||||
import Status from '../TableComponents/AlertStatus';
|
||||
import { TableCell, TableRow } from './styles';
|
||||
|
||||
function ExapandableRow({ allAlerts }: ExapandableRowProps): JSX.Element {
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
return (
|
||||
<>
|
||||
{allAlerts.map((alert) => {
|
||||
const { labels = {} } = alert;
|
||||
const labelsObject = Object.keys(labels);
|
||||
|
||||
const tags = labelsObject.filter((e) => e !== 'severity');
|
||||
|
||||
const formatedDate = new Date(alert.startsAt);
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
bodyStyle={{
|
||||
minHeight: '5rem',
|
||||
marginLeft: '2rem',
|
||||
}}
|
||||
translate="yes"
|
||||
hoverable
|
||||
key={alert.fingerprint}
|
||||
>
|
||||
<TableCell minWidth="90px">
|
||||
<Status severity={alert.status.state} />
|
||||
</TableCell>
|
||||
|
||||
<TableCell minWidth="90px" overflowX="scroll">
|
||||
<Typography>{labels.alertname || '-'}</Typography>
|
||||
</TableCell>
|
||||
|
||||
<TableCell minWidth="90px">
|
||||
<Typography>{labels.severity || '-'}</Typography>
|
||||
</TableCell>
|
||||
|
||||
<TableCell minWidth="90px">
|
||||
<Typography>{`${formatTimezoneAdjustedTimestamp(
|
||||
formatedDate,
|
||||
DATE_TIME_FORMATS.UTC_US,
|
||||
)}`}</Typography>
|
||||
</TableCell>
|
||||
|
||||
<TableCell minWidth="90px" overflowX="scroll">
|
||||
<div>
|
||||
{tags.map((e) => (
|
||||
<Badge color="vanilla" key={e}>{`${e}:${labels[e]}`}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
{/* <TableCell>
|
||||
<TableHeaderContainer>
|
||||
<Button type="link">Edit</Button>
|
||||
<Button type="link">Delete</Button>
|
||||
<Button type="link">Pause</Button>
|
||||
</TableHeaderContainer>
|
||||
</TableCell> */}
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface ExapandableRowProps {
|
||||
allAlerts: Alerts[];
|
||||
}
|
||||
|
||||
export default ExapandableRow;
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useState } from 'react';
|
||||
import { SquareMinus, SquarePlus } from '@signozhq/icons';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Alerts } from 'types/api/alerts/getTriggered';
|
||||
|
||||
import ExapandableRow from './ExapandableRow';
|
||||
import { IconContainer, StatusContainer, TableCell, TableRow } from './styles';
|
||||
|
||||
function TableRowComponent({
|
||||
tags,
|
||||
tagsAlert,
|
||||
}: TableRowComponentProps): JSX.Element {
|
||||
const [isClicked, setIsClicked] = useState<boolean>(false);
|
||||
|
||||
const onClickHandler = (): void => {
|
||||
setIsClicked((state) => !state);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<TableRow>
|
||||
<TableCell minWidth="90px">
|
||||
<StatusContainer>
|
||||
<IconContainer onClick={onClickHandler}>
|
||||
{!isClicked ? <SquarePlus size="md" /> : <SquareMinus size="md" />}
|
||||
</IconContainer>
|
||||
<>
|
||||
{tags.map((tag) => (
|
||||
<Badge color="sakura" key={tag}>
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</>
|
||||
</StatusContainer>
|
||||
</TableCell>
|
||||
<TableCell minWidth="90px" />
|
||||
<TableCell minWidth="90px" />
|
||||
<TableCell minWidth="90px" />
|
||||
<TableCell minWidth="90px" />
|
||||
{/* <TableCell minWidth="200px">
|
||||
<Button type="primary">Resume Group</Button>
|
||||
</TableCell> */}
|
||||
</TableRow>
|
||||
{isClicked && <ExapandableRow allAlerts={tagsAlert} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface TableRowComponentProps {
|
||||
tags: string[];
|
||||
tagsAlert: Alerts[];
|
||||
}
|
||||
|
||||
export default TableRowComponent;
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useMemo } from 'react';
|
||||
import groupBy from 'lodash-es/groupBy';
|
||||
import { Alerts } from 'types/api/alerts/getTriggered';
|
||||
|
||||
import { Value } from '../Filter';
|
||||
import { FilterAlerts } from '../utils';
|
||||
import { Container, TableHeader, TableHeaderContainer } from './styles';
|
||||
import TableRowComponent from './TableRow';
|
||||
|
||||
function FilteredTable({
|
||||
selectedGroup,
|
||||
allAlerts,
|
||||
selectedFilter,
|
||||
}: FilteredTableProps): JSX.Element {
|
||||
const allGroupsAlerts = useMemo(
|
||||
() =>
|
||||
groupBy(FilterAlerts(allAlerts, selectedFilter), (obj) =>
|
||||
selectedGroup.map((e) => obj.labels?.[`${e.value}`]).join('+'),
|
||||
),
|
||||
[selectedGroup, allAlerts, selectedFilter],
|
||||
);
|
||||
|
||||
const tags = Object.keys(allGroupsAlerts);
|
||||
const tagsAlerts = Object.values(allGroupsAlerts);
|
||||
|
||||
const headers = [
|
||||
'Status',
|
||||
'Alert Name',
|
||||
'Severity',
|
||||
'Firing Since',
|
||||
'Tags',
|
||||
// 'Actions',
|
||||
];
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<TableHeaderContainer>
|
||||
{headers.map((header) => (
|
||||
<TableHeader key={header} minWidth="90px">
|
||||
{header}
|
||||
</TableHeader>
|
||||
))}
|
||||
</TableHeaderContainer>
|
||||
|
||||
{tags.map((e, index) => {
|
||||
const tagsValue = e.split('+').filter((e) => e);
|
||||
const tagsAlert: Alerts[] = tagsAlerts[index];
|
||||
|
||||
if (tagsAlert.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { labels = {} } = tagsAlert[0];
|
||||
const keysArray = Object.keys(labels);
|
||||
const valueArray: string[] = [];
|
||||
|
||||
keysArray.forEach((e) => {
|
||||
valueArray.push(labels[e]);
|
||||
});
|
||||
|
||||
const tags = tagsValue
|
||||
.map((e) => keysArray[valueArray.findIndex((value) => value === e) || 0])
|
||||
.map((e, index) => `${e}:${tagsValue[index]}`);
|
||||
|
||||
return <TableRowComponent key={e} tagsAlert={tagsAlert} tags={tags} />;
|
||||
})}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
interface FilteredTableProps {
|
||||
selectedGroup: Value[];
|
||||
allAlerts: Alerts[];
|
||||
selectedFilter: Value[];
|
||||
}
|
||||
|
||||
export default FilteredTable;
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Card } from 'antd';
|
||||
import styled from 'styled-components';
|
||||
|
||||
export const TableHeader = styled(Card)<Props>`
|
||||
&&& {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
.ant-card-body {
|
||||
padding: 1rem;
|
||||
}
|
||||
min-width: ${(props): string => props.minWidth || ''};
|
||||
}
|
||||
`;
|
||||
|
||||
export const TableHeaderContainer = styled.div`
|
||||
display: flex;
|
||||
`;
|
||||
|
||||
export const Container = styled.div`
|
||||
&&& {
|
||||
display: flex;
|
||||
margin-top: 1rem;
|
||||
flex-direction: column;
|
||||
}
|
||||
`;
|
||||
|
||||
export const TableRow = styled(Card)`
|
||||
&&& {
|
||||
flex: 1;
|
||||
.ant-card-body {
|
||||
padding: 0rem;
|
||||
display: flex;
|
||||
|
||||
min-height: 3rem;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
interface Props {
|
||||
minWidth?: string;
|
||||
overflowX?: string;
|
||||
}
|
||||
export const TableCell = styled.div<Props>`
|
||||
&&& {
|
||||
flex: 1;
|
||||
min-width: ${(props): string => props.minWidth || ''};
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
overflow-x: ${(props): string => props.overflowX || 'none'};
|
||||
::-webkit-scrollbar {
|
||||
height: ${(props): string => (props.overflowX ? '2px' : '8px')};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const StatusContainer = styled.div`
|
||||
&&& {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
}
|
||||
`;
|
||||
|
||||
export const IconContainer = styled.div`
|
||||
&&& {
|
||||
margin-left: 1rem;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
`;
|
||||
108
frontend/src/container/TriggeredAlerts/NoFilterTable.tsx
Normal file
108
frontend/src/container/TriggeredAlerts/NoFilterTable.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { TableColumnsType as ColumnsType } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { ResizeTable } from 'components/ResizeTable';
|
||||
import LabelColumn from 'components/TableRenderer/LabelColumn';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import AlertStatus from 'container/TriggeredAlerts/TableComponents/AlertStatus';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { Alerts } from 'types/api/alerts/getTriggered';
|
||||
|
||||
import { Value } from './Filter';
|
||||
import { FilterAlerts } from './utils';
|
||||
|
||||
const severitySorter = (a: Alerts, b: Alerts): number => {
|
||||
const severityLengthOfA = a.labels?.severity?.length || 0;
|
||||
const severityLengthOfB = b.labels?.severity?.length || 0;
|
||||
return severityLengthOfB - severityLengthOfA;
|
||||
};
|
||||
|
||||
function NoFilterTable({
|
||||
allAlerts,
|
||||
selectedFilter,
|
||||
}: NoFilterTableProps): JSX.Element {
|
||||
const filteredAlerts = FilterAlerts(allAlerts, selectedFilter);
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
|
||||
// need to add the filter
|
||||
const columns: ColumnsType<Alerts> = [
|
||||
{
|
||||
title: 'Status',
|
||||
dataIndex: 'status',
|
||||
width: 80,
|
||||
key: 'status',
|
||||
sorter: (a, b): number => severitySorter(a, b),
|
||||
render: (value): JSX.Element => <AlertStatus severity={value.state} />,
|
||||
},
|
||||
{
|
||||
title: 'Alert Name',
|
||||
dataIndex: 'labels',
|
||||
key: 'alertName',
|
||||
width: 100,
|
||||
sorter: (a, b): number =>
|
||||
(a.labels?.alertname?.charCodeAt(0) || 0) -
|
||||
(b.labels?.alertname?.charCodeAt(0) || 0),
|
||||
render: (data): JSX.Element => {
|
||||
const name = data?.alertname || '';
|
||||
return <Typography>{name}</Typography>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Tags',
|
||||
dataIndex: 'labels',
|
||||
key: 'tags',
|
||||
width: 100,
|
||||
render: (labels): JSX.Element => {
|
||||
const objectKeys = Object.keys(labels);
|
||||
const withOutSeverityKeys = objectKeys.filter((e) => e !== 'severity');
|
||||
|
||||
if (withOutSeverityKeys.length === 0) {
|
||||
return <Typography>-</Typography>;
|
||||
}
|
||||
|
||||
return <LabelColumn labels={withOutSeverityKeys} value={labels} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Severity',
|
||||
dataIndex: 'labels',
|
||||
key: 'severity',
|
||||
width: 100,
|
||||
sorter: (a, b): number => severitySorter(a, b),
|
||||
render: (value): JSX.Element => {
|
||||
const objectKeys = Object.keys(value);
|
||||
const withSeverityKey = objectKeys.find((e) => e === 'severity') || '';
|
||||
const severityValue = value[withSeverityKey];
|
||||
|
||||
return <Typography>{severityValue}</Typography>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Firing Since',
|
||||
dataIndex: 'startsAt',
|
||||
width: 100,
|
||||
sorter: (a, b): number =>
|
||||
new Date(a.startsAt).getTime() - new Date(b.startsAt).getTime(),
|
||||
render: (date): JSX.Element => (
|
||||
<Typography>{`${formatTimezoneAdjustedTimestamp(
|
||||
date,
|
||||
DATE_TIME_FORMATS.UTC_US,
|
||||
)}`}</Typography>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<ResizeTable
|
||||
columns={columns}
|
||||
rowKey={(record): string => `${record.startsAt}-${record.fingerprint}`}
|
||||
dataSource={filteredAlerts}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface NoFilterTableProps {
|
||||
allAlerts: Alerts[];
|
||||
selectedFilter: Value[];
|
||||
}
|
||||
|
||||
export default NoFilterTable;
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
|
||||
function Severity({ severity }: SeverityProps): JSX.Element {
|
||||
switch (severity) {
|
||||
case 'unprocessed': {
|
||||
return <Badge color="forest">UnProcessed</Badge>;
|
||||
}
|
||||
|
||||
case 'active': {
|
||||
return <Badge color="cherry">Firing</Badge>;
|
||||
}
|
||||
|
||||
case 'suppressed': {
|
||||
return <Badge color="cherry">Suppressed</Badge>;
|
||||
}
|
||||
|
||||
default: {
|
||||
return <Badge color="vanilla">Unknown Status</Badge>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface SeverityProps {
|
||||
severity: string;
|
||||
}
|
||||
|
||||
export default Severity;
|
||||
56
frontend/src/container/TriggeredAlerts/TriggeredAlert.tsx
Normal file
56
frontend/src/container/TriggeredAlerts/TriggeredAlert.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { Alerts } from 'types/api/alerts/getTriggered';
|
||||
|
||||
import Filter, { Value } from './Filter';
|
||||
import FilteredTable from './FilteredTable';
|
||||
import NoFilterTable from './NoFilterTable';
|
||||
import { NoTableContainer } from './styles';
|
||||
|
||||
function TriggeredAlerts({
|
||||
allAlerts,
|
||||
selectedFilter,
|
||||
selectedGroup,
|
||||
onSelectedFilterChange,
|
||||
onSelectedGroupChange,
|
||||
}: TriggeredAlertsProps): JSX.Element {
|
||||
return (
|
||||
<div>
|
||||
<Filter
|
||||
allAlerts={allAlerts}
|
||||
selectedFilter={selectedFilter}
|
||||
selectedGroup={selectedGroup}
|
||||
onSelectedFilterChange={onSelectedFilterChange}
|
||||
onSelectedGroupChange={onSelectedGroupChange}
|
||||
/>
|
||||
|
||||
{selectedFilter.length === 0 && selectedGroup.length === 0 ? (
|
||||
<NoTableContainer>
|
||||
<NoFilterTable selectedFilter={selectedFilter} allAlerts={allAlerts} />
|
||||
</NoTableContainer>
|
||||
) : (
|
||||
<div>
|
||||
{selectedFilter.length !== 0 && selectedGroup.length === 0 ? (
|
||||
<NoTableContainer>
|
||||
<NoFilterTable selectedFilter={selectedFilter} allAlerts={allAlerts} />
|
||||
</NoTableContainer>
|
||||
) : (
|
||||
<FilteredTable
|
||||
allAlerts={allAlerts}
|
||||
selectedFilter={selectedFilter}
|
||||
selectedGroup={selectedGroup}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface TriggeredAlertsProps {
|
||||
allAlerts: Alerts[];
|
||||
selectedFilter: Array<Value>;
|
||||
selectedGroup: Array<Value>;
|
||||
onSelectedFilterChange: (value: Array<Value>) => void;
|
||||
onSelectedGroupChange: (value: Array<Value>) => void;
|
||||
}
|
||||
|
||||
export default TriggeredAlerts;
|
||||
@@ -1,124 +0,0 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
height: calc(100vh - 62px);
|
||||
min-height: 400px;
|
||||
padding-top: var(--spacing-8);
|
||||
}
|
||||
|
||||
.header {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
flex-shrink: 0;
|
||||
padding: 0 var(--spacing-8);
|
||||
}
|
||||
|
||||
.filtersRow {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
flex-shrink: 0;
|
||||
padding: 0 var(--spacing-8);
|
||||
|
||||
--combobox-trigger-height: 2rem;
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
min-width: 250px;
|
||||
}
|
||||
|
||||
.filterSelect {
|
||||
min-width: 300px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.tableContainer {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
--tanstack-table-header-cell-bg: var(--l2-background);
|
||||
--tanstack-table-header-cell-color: var(--l2-foreground);
|
||||
--tanstack-table-cell-bg: var(--l2-background);
|
||||
--tanstack-table-cell-color: var(--l2-foreground);
|
||||
--tanstack-table-row-hover-bg: var(--l2-background-hover);
|
||||
--tanstack-table-row-active-bg: var(--l2-background-active);
|
||||
--tanstack-table-resize-handle-bg: var(--l2-background);
|
||||
--tanstack-table-resize-handle-hover-bg: var(--l2-border);
|
||||
--tanstack-table-row-height: 42px;
|
||||
|
||||
--tanstack-cell-padding-top-override: 5px;
|
||||
--tanstack-cell-padding-bottom-override: 5px;
|
||||
--tanstack-cell-padding-left-override: 5px;
|
||||
--tanstack-cell-padding-right-override: 5px;
|
||||
|
||||
--tanstack-cell-padding-left-override: 16px;
|
||||
--tanstack-cell-padding-right-override: 16px;
|
||||
|
||||
--tanstack-table-row-odd-bg: color-mix(
|
||||
in srgb,
|
||||
var(--l1-foreground) 2%,
|
||||
transparent
|
||||
);
|
||||
--tanstack-table-row-even-bg: color-mix(
|
||||
in srgb,
|
||||
var(--l1-foreground) 1%,
|
||||
transparent
|
||||
);
|
||||
|
||||
--tanstack-expansion-first-col-padding-left: 20px;
|
||||
|
||||
--badge-cursor: pointer;
|
||||
}
|
||||
|
||||
.groupHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.groupCell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tagsContainer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.searchIcon {
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.filterBadge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-right: 3px;
|
||||
}
|
||||
|
||||
.filterBadgeClose {
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.paginationContainer {
|
||||
padding-right: var(--spacing-12);
|
||||
height: 62px;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
|
||||
import NoFilterTable from '../NoFilterTable';
|
||||
import { createAlert } from './mockUtils';
|
||||
|
||||
jest.mock('providers/Timezone', () => ({
|
||||
useTimezone: jest.requireActual('./mockUtils').useMockTimezone,
|
||||
}));
|
||||
|
||||
const allAlerts = [
|
||||
createAlert({
|
||||
name: 'Alert B',
|
||||
labels: {
|
||||
severity: 'warning',
|
||||
alertname: 'Alert B',
|
||||
},
|
||||
}),
|
||||
createAlert({
|
||||
name: 'Alert C',
|
||||
labels: {
|
||||
severity: 'info',
|
||||
alertname: 'Alert C',
|
||||
},
|
||||
}),
|
||||
createAlert({
|
||||
name: 'Alert A',
|
||||
labels: {
|
||||
severity: 'critical',
|
||||
alertname: 'Alert A',
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
describe('NoFilterTable', () => {
|
||||
it('should render the no filter table with correct rows', () => {
|
||||
render(<NoFilterTable allAlerts={allAlerts} selectedFilter={[]} />);
|
||||
const rows = screen.getAllByRole('row');
|
||||
expect(rows).toHaveLength(4); // 1 header row + 2 data rows
|
||||
const [headerRow, dataRow1, dataRow2, dataRow3] = rows;
|
||||
|
||||
// Verify header row
|
||||
expect(headerRow).toHaveTextContent('Status');
|
||||
expect(headerRow).toHaveTextContent('Alert Name');
|
||||
expect(headerRow).toHaveTextContent('Tags');
|
||||
expect(headerRow).toHaveTextContent('Severity');
|
||||
expect(headerRow).toHaveTextContent('Firing Since');
|
||||
|
||||
// Verify 1st data row
|
||||
expect(dataRow1).toHaveTextContent('Alert B');
|
||||
|
||||
// Verify 2nd data row
|
||||
expect(dataRow2).toHaveTextContent('Alert C');
|
||||
|
||||
// Verify 3rd data row
|
||||
expect(dataRow3).toHaveTextContent('Alert A');
|
||||
});
|
||||
|
||||
it('should sort the table by severity when header is clicked', () => {
|
||||
render(<NoFilterTable allAlerts={allAlerts} selectedFilter={[]} />);
|
||||
|
||||
const headers = screen.getAllByRole('columnheader');
|
||||
const severityHeader = headers.find((header) =>
|
||||
header.textContent?.includes('Severity'),
|
||||
);
|
||||
|
||||
expect(severityHeader).toBeInTheDocument();
|
||||
|
||||
if (severityHeader) {
|
||||
const initialRows = screen.getAllByRole('row');
|
||||
expect(initialRows).toHaveLength(4);
|
||||
expect(initialRows[1]).toHaveTextContent('Alert B');
|
||||
expect(initialRows[2]).toHaveTextContent('Alert C');
|
||||
expect(initialRows[3]).toHaveTextContent('Alert A');
|
||||
|
||||
fireEvent.click(severityHeader);
|
||||
|
||||
const sortedRows = screen.getAllByRole('row');
|
||||
expect(sortedRows).toHaveLength(4);
|
||||
expect(sortedRows[1]).toHaveTextContent('Alert A');
|
||||
expect(sortedRows[2]).toHaveTextContent('Alert B');
|
||||
expect(sortedRows[3]).toHaveTextContent('Alert C');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { Alerts } from 'types/api/alerts/getTriggered';
|
||||
|
||||
export function createAlert(overrides: Partial<Alerts> = {}): Alerts {
|
||||
return {
|
||||
labels: undefined,
|
||||
annotations: {
|
||||
description: 'Test Description',
|
||||
summary: 'Test Summary',
|
||||
},
|
||||
state: 'firing',
|
||||
name: 'Test Alert',
|
||||
id: 1,
|
||||
endsAt: '2021-01-02T00:00:00Z',
|
||||
fingerprint: '1234567890',
|
||||
generatorURL: 'https://test.com',
|
||||
receivers: [{ name: 'Test Receiver' }],
|
||||
startsAt: '2021-01-03T00:00:00Z',
|
||||
status: {
|
||||
inhibitedBy: [],
|
||||
silencedBy: [],
|
||||
state: 'firing',
|
||||
},
|
||||
updatedAt: '2021-01-01T00:00:00Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function useMockTimezone(): {
|
||||
timezone: Timezone;
|
||||
browserTimezone: Timezone;
|
||||
updateTimezone: (timezone: Timezone) => void;
|
||||
formatTimezoneAdjustedTimestamp: (input: string, format?: string) => string;
|
||||
isAdaptationEnabled: boolean;
|
||||
setIsAdaptationEnabled: (enabled: boolean) => void;
|
||||
} {
|
||||
const mockTimezone: Timezone = {
|
||||
name: 'timezone',
|
||||
value: 'mock-timezone',
|
||||
offset: '+1.30',
|
||||
searchIndex: '1',
|
||||
};
|
||||
return {
|
||||
timezone: mockTimezone,
|
||||
browserTimezone: mockTimezone,
|
||||
updateTimezone: jest.fn(),
|
||||
formatTimezoneAdjustedTimestamp: jest
|
||||
.fn()
|
||||
.mockImplementation((date: string) => new Date(date).toISOString()),
|
||||
isAdaptationEnabled: true,
|
||||
setIsAdaptationEnabled: jest.fn(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { Value } from '../Filter';
|
||||
import { FilterAlerts } from '../utils';
|
||||
import { createAlert } from './mockUtils';
|
||||
|
||||
describe('FilterAlerts', () => {
|
||||
it('returns all alerts when no filters are selected', () => {
|
||||
const alerts = [
|
||||
createAlert({ fingerprint: 'fp-1' }),
|
||||
createAlert({ fingerprint: 'fp-2' }),
|
||||
];
|
||||
const filters: Value[] = [];
|
||||
|
||||
const result = FilterAlerts(alerts, filters);
|
||||
|
||||
expect(result).toBe(alerts);
|
||||
});
|
||||
|
||||
it('filters alerts that have matching label key and value', () => {
|
||||
const warningAlert = createAlert({
|
||||
fingerprint: 'warning',
|
||||
labels: { severity: 'warning' },
|
||||
});
|
||||
const criticalAlert = createAlert({
|
||||
fingerprint: 'critical',
|
||||
labels: { severity: 'critical' },
|
||||
});
|
||||
const alerts = [warningAlert, criticalAlert];
|
||||
const filters: Value[] = [{ value: 'severity:critical' }];
|
||||
|
||||
const result = FilterAlerts(alerts, filters);
|
||||
|
||||
expect(result).toStrictEqual([criticalAlert]);
|
||||
});
|
||||
|
||||
it('includes alerts when any filter matches', () => {
|
||||
const severityAlert = createAlert({
|
||||
fingerprint: 'severity',
|
||||
labels: { severity: 'warning' },
|
||||
});
|
||||
const teamAlert = createAlert({
|
||||
fingerprint: 'team',
|
||||
labels: { team: 'core-observability' },
|
||||
});
|
||||
const otherAlert = createAlert({
|
||||
fingerprint: 'other',
|
||||
labels: { service: 'ingestor' },
|
||||
});
|
||||
const alerts = [severityAlert, teamAlert, otherAlert];
|
||||
const filters: Value[] = [
|
||||
{ value: 'severity:warning' },
|
||||
{ value: 'team:core-observability' },
|
||||
];
|
||||
|
||||
const result = FilterAlerts(alerts, filters);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result).toStrictEqual([severityAlert, teamAlert]);
|
||||
});
|
||||
|
||||
it('matches labels even when filters contain surrounding whitespace', () => {
|
||||
const alert = createAlert({
|
||||
fingerprint: 'trim-test',
|
||||
labels: { severity: 'critical' },
|
||||
});
|
||||
const alerts = [alert];
|
||||
const filters: Value[] = [{ value: ' severity : critical ' }];
|
||||
|
||||
const result = FilterAlerts(alerts, filters);
|
||||
|
||||
expect(result).toStrictEqual([alert]);
|
||||
});
|
||||
|
||||
it('ignores filters that do not contain a key/value delimiter', () => {
|
||||
const alert = createAlert({
|
||||
fingerprint: 'invalid-filter',
|
||||
labels: { severity: 'warning' },
|
||||
});
|
||||
const alerts = [alert];
|
||||
const filters: Value[] = [{ value: 'severitywarning' }];
|
||||
|
||||
const result = FilterAlerts(alerts, filters);
|
||||
|
||||
expect(result).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,36 +0,0 @@
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
|
||||
interface AlertStatusTagProps {
|
||||
state: string;
|
||||
}
|
||||
|
||||
function AlertStatusTag({ state }: AlertStatusTagProps): JSX.Element {
|
||||
switch (state) {
|
||||
case 'unprocessed':
|
||||
return (
|
||||
<Badge color="success" variant="outline">
|
||||
Unprocessed
|
||||
</Badge>
|
||||
);
|
||||
case 'active':
|
||||
return (
|
||||
<Badge color="error" variant="outline">
|
||||
Firing
|
||||
</Badge>
|
||||
);
|
||||
case 'suppressed':
|
||||
return (
|
||||
<Badge color="error" variant="outline">
|
||||
Suppressed
|
||||
</Badge>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Badge color="secondary" variant="outline">
|
||||
Unknown
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default AlertStatusTag;
|
||||
@@ -1,36 +0,0 @@
|
||||
.emptyState {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 24px;
|
||||
text-align: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.emptyStateIcon {
|
||||
font-size: 48px;
|
||||
color: var(--bg-forest-500);
|
||||
}
|
||||
|
||||
.emptyStateIconMuted {
|
||||
font-size: 48px;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.emptyStateTitle {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.emptyStateSubtitle {
|
||||
font-size: 14px;
|
||||
color: var(--l2-foreground);
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.emptyStateActions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
import { CircleCheck, Plus, RefreshCw } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
|
||||
import styles from './EmptyStates.module.scss';
|
||||
|
||||
interface EmptyStateProps {
|
||||
onRefresh?: () => void;
|
||||
}
|
||||
|
||||
export function EmptyState({ onRefresh }: EmptyStateProps): JSX.Element {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
|
||||
const handleCreateAlert = useCallback((): void => {
|
||||
safeNavigate(ROUTES.ALERTS_NEW);
|
||||
}, [safeNavigate]);
|
||||
|
||||
return (
|
||||
<div className={styles.emptyState}>
|
||||
<CircleCheck className={styles.emptyStateIcon} size={16} />
|
||||
<div className={styles.emptyStateTitle}>No alerts firing</div>
|
||||
<div className={styles.emptyStateSubtitle}>
|
||||
All systems are healthy. No alerts are currently triggered.
|
||||
</div>
|
||||
<div className={styles.emptyStateActions}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
prefix={<Plus size={14} />}
|
||||
onClick={handleCreateAlert}
|
||||
>
|
||||
Create Alert Rule
|
||||
</Button>
|
||||
{onRefresh && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
prefix={<RefreshCw size={14} />}
|
||||
onClick={onRefresh}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user