Compare commits

..

13 Commits

Author SHA1 Message Date
Gaurav Tewari
1b90f44bba fix: tag container styles in qb 2026-05-29 19:54:55 +05:30
Gaurav Tewari
516e490567 fix: different color in badge (#11492)
Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-05-29 13:59:19 +00:00
Vikrant Gupta
407d969cd3 feat(user): add support for token verification (#11496)
* feat(user): add support for token validation

* feat(user): update openapi specs

* feat(user): update verify endpoint

* feat(user): use binding package

* feat(user): update openapi specs
2026-05-29 13:35:30 +00:00
Vinicius Lourenço
fa3ab0c197 chore(oxlint): disable max-params, jsdoc, and more rules for tests (#11467) 2026-05-29 13:02:36 +00:00
Vinicius Lourenço
457ceb8fe7 feat(alerts): rewrite list alerts & triggered alerts to use new table (#11277)
* feat(tanstack-table): add showPageSize flag and callbacks to pagination

* chore(signozhq/ui): bump to v0.0.19

* feat(components): added shared components for alerts

* feat(hooks): add shared hooks for alerts

* feat(time-utils): add little helper to get elapsed time in ms

* feat(alerts-components): add badge map colors

* feat(list-alerts): rewrite page to use new table component (#11276)

* feat(triggered-alerts): rewrite page to use new table component (#11260)

* feat(triggered-alerts): rewrite page

* chore(triggered-alerts): move reason tooltip content to own component

* chore(pr-comments): address PR comments

* chore(lint): fix signozhq/ui imports

* refactor(alerts): removed stats card

* refactor(alerts): move table to use pagination instead of infinity load

* refactor(alerts): remove extra columns & add back info tooltip

* refactor(alerts): disable move columns

* refactor(alerts): cleanup dead components

* refactor(alerts): standardize errors and empty states

* refactor(alerts): missing pagination class on group

* fix(tanstack): preserve page from URL on refresh page

* refactor(alerts): ensure no empty has correct colors

* refactor(alerts): remove barrel imports

* fix(alerts): missing including error empty state

* fix(alerts): ensure the params are removed on page change

* fix(alerts): address comments related to UI/UX

* feat(tanstack): add auto page size

* fix(alert): use calculated page size

* fix(alerts): address tiny issues in ui

* fix(tests): label column tests

* fix(actions-menu): ensure callbacks are memoized

* fix(alerts): ensure tabs reset the url state (#11380)

* fix(pr): address issue with expand button

* fix(list-alerts): reduce size of state/severity, add line clamp for table

* fix(triggered-alerts): reduce size of state/severity, add line clamp for table

* fix(expanded-alerts): remove min-height when no page

* fix(triggered-alerts): increase row height for triggered alerts

* fix(list-alerts-triggered-alerts): reset page to 1 when change search

* fix(use-url-search): add missing test case for clear search
2026-05-29 12:53:55 +00:00
SagarRajput-7
1928de7452 feat(web): gate sentry and pylon init behind boot settings flags (#11498) 2026-05-29 12:31:44 +00:00
Vikrant Gupta
c6b2fe47d5 feat(web): add support for sentry and pylon (#11495)
Some checks failed
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
* feat(web): add support for sentry and pylon

* feat(web): add support for sentry and pylon

* feat(web): add support for sentry and pylon

* feat(web): removed bootdata.ts and maintaining websettings as single source of truth

* feat(web): update the example.yaml

---------

Co-authored-by: SagarRajput-7 <sagar@signoz.io>
2026-05-29 12:08:00 +00:00
Tushar Vats
1d6fa6e507 feat: extend error fields (#11228)
* fix: extended error fields

* fix: remove stale comment

* fix: removed retry policy enum and added example

* fix: generate openapi

* fix: stale comments
2026-05-29 11:43:35 +00:00
swapnil-signoz
910645516d chore: remove cloud integration service cascade delete constraint (#11480)
* chore: remove cloud integration service cascade delete constraint

* refactor: manually recreate table
2026-05-29 11:21:05 +00:00
Tushar Vats
edc1278769 fix(querybuilder): return PreparedWhereClause by value so warnings propagate when clause is empty (#11395)
* fix: propage dropped warnings from where clause visitor

* fix: added integration test

* fix: make py-fmt

* fix: remove stale comment
2026-05-29 10:09:09 +00:00
Yunus M
da1b09c479 refactor: replace antd Tabs with @signozhq/ui Tabs (#11392)
* refactor: replace antd Tabs with @signozhq/ui Tabs

Migrates Tabs usage from antd to the @signozhq/ui Tabs component across
dashboard settings, integration details, metrics application, pipelines,
trace detail, and workspace-locked pages. API differences (activeKey →
value, defaultActiveKey → defaultValue, TabsProps['items'] → TabItemProps[])
are updated to match the new component.

* refactor: enhance RouteTab component with new styling and functionality
2026-05-29 09:53:51 +00:00
Gaurav Tewari
1f406823d8 refactor(frontend): migrate plain antd Input to @signozhq/ui/input (#11401)
* chore: refactor input

* chore: remove markdown

* chore: update markdown

* fix: minior comments for input

* chore: remove comments

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-05-29 08:37:54 +00:00
Nikhil Mantri
f626380b1a fix(infra-monitoring): align v2 custom queries' bounds with QBv5 querier step adjustment (#11397)
* chore: updated logic and use centralized function in the module

* chore: filter metric groups

* chore: filter metric groups

* chore: formula correction

* chore: added step flooring note

* chore: comment correction

* chore: comment correction

* chore: removed function

* chore: renamed variables

* chore: fix for surfacing meta for pods custom group by

* chore: added todo and note

* chore: added changes based on comments

---------

Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
2026-05-29 07:14:43 +00:00
253 changed files with 7237 additions and 5029 deletions

View File

@@ -68,6 +68,12 @@ 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:

View File

@@ -3237,8 +3237,20 @@ 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:
@@ -3250,6 +3262,11 @@ components:
message:
type: string
type: object
ErrorsResponseretryjson:
properties:
delay:
$ref: '#/components/schemas/TimeDuration'
type: object
FactoryResponse:
properties:
healthy:
@@ -5945,22 +5962,6 @@ components:
- start
- end
type: object
RuletypesActiveMute:
properties:
description:
type: string
end:
format: date-time
nullable: true
type: string
id:
type: string
name:
type: string
start:
format: date-time
type: string
type: object
RuletypesAlertCompositeQuery:
properties:
panelType:
@@ -6242,11 +6243,6 @@ components:
additionalProperties:
type: string
type: object
mutes:
items:
$ref: '#/components/schemas/RuletypesActiveMute'
nullable: true
type: array
notificationSettings:
$ref: '#/components/schemas/RuletypesNotificationSettings'
preferredChannels:
@@ -7089,6 +7085,13 @@ components:
required:
- name
type: object
TypesPostableVerifyResetPasswordToken:
properties:
token:
type: string
required:
- token
type: object
TypesResetPasswordToken:
properties:
expiresAt:
@@ -14870,6 +14873,41 @@ 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

View File

@@ -1,7 +1,9 @@
{
"required": [
"posthog",
"appcues"
"appcues",
"sentry",
"pylon"
],
"additionalProperties": false,
"definitions": {
@@ -28,6 +30,30 @@
}
},
"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": {
@@ -36,6 +62,12 @@
},
"posthog": {
"$ref": "#/definitions/Posthog"
},
"pylon": {
"$ref": "#/definitions/Pylon"
},
"sentry": {
"$ref": "#/definitions/Sentry"
}
},
"type": "object"

View File

@@ -123,6 +123,7 @@
"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",
@@ -197,10 +198,7 @@
]
}
],
"max-params": [
"warn",
3
],
"max-params": "off",
// Warns when functions have more than 3 parameters
"@typescript-eslint/explicit-function-return-type": "error",
// Requires explicit return types on functions
@@ -538,7 +536,10 @@
"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"
"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
}
},
{

View File

@@ -112,7 +112,10 @@
<script>
var PYLON_APP_ID = '<%- PYLON_APP_ID %>';
if (PYLON_APP_ID) {
var pylonSettings =
((window.signozBootData || {}).settings || {}).pylon || {};
var pylonEnabled = pylonSettings.enabled !== false;
if (PYLON_APP_ID && pylonEnabled) {
(function () {
var e = window;
var t = document;

View File

@@ -35,7 +35,6 @@ 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';
@@ -292,7 +291,8 @@ function App(): JSX.Element {
isLoggedInState &&
isChatSupportEnabled &&
!showAddCreditCardModal &&
(isCloudUser || isEnterpriseSelfHostedUser)
(isCloudUser || isEnterpriseSelfHostedUser) &&
(window.signozBootData?.settings?.pylon.enabled ?? true)
) {
const email = user.email || '';
const secret = process.env.PYLON_IDENTITY_SECRET || '';
@@ -334,14 +334,20 @@ function App(): JSX.Element {
useEffect(() => {
if (isCloudUser || isEnterpriseSelfHostedUser) {
if (bootSettings.posthog.enabled && process.env.POSTHOG_KEY) {
if (
(window.signozBootData?.settings?.posthog.enabled ?? true) &&
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) {
if (
!isSentryInitialized &&
(window.signozBootData?.settings?.sentry.enabled ?? true)
) {
Sentry.init({
dsn: process.env.SENTRY_DSN,
tunnel: process.env.TUNNEL_URL,

View File

@@ -2051,6 +2051,10 @@ export interface ErrorsResponseerroradditionalDTO {
message?: string;
}
export interface ErrorsResponseretryjsonDTO {
delay?: TimeDurationDTO;
}
export interface ErrorsJSONDTO {
/**
* @type string
@@ -2060,10 +2064,23 @@ 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
*/
@@ -7051,31 +7068,6 @@ export interface RulestatehistorytypesGettableRuleStateWindowDTO {
state: RuletypesAlertStateDTO;
}
export interface RuletypesActiveMuteDTO {
/**
* @type string
*/
description?: string;
/**
* @type string,null
* @format date-time
*/
end?: string | null;
/**
* @type string
*/
id?: string;
/**
* @type string
*/
name?: string;
/**
* @type string
* @format date-time
*/
start?: string;
}
export enum RuletypesPanelTypeDTO {
value = 'value',
table = 'table',
@@ -7444,10 +7436,6 @@ export interface RuletypesRuleDTO {
* @type object
*/
labels?: RuletypesRuleDTOLabels;
/**
* @type array,null
*/
mutes?: RuletypesActiveMuteDTO[] | null;
notificationSettings?: RuletypesNotificationSettingsDTO;
/**
* @type array
@@ -8337,6 +8325,13 @@ export interface TypesPostableRoleDTO {
name: string;
}
export interface TypesPostableVerifyResetPasswordTokenDTO {
/**
* @type string
*/
token: string;
}
export interface TypesResetPasswordTokenDTO {
/**
* @type string

View File

@@ -48,6 +48,7 @@ import type {
TypesPostableInviteDTO,
TypesPostableResetPasswordDTO,
TypesPostableRoleDTO,
TypesPostableVerifyResetPasswordTokenDTO,
TypesUpdatableUserDTO,
UpdateUserDeprecated200,
UpdateUserDeprecatedPathParameters,
@@ -947,6 +948,90 @@ 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

View File

@@ -0,0 +1,31 @@
.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;
}

View File

@@ -0,0 +1,61 @@
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;

View File

@@ -0,0 +1 @@
export { default } from './ErrorEmptyState';

View File

@@ -0,0 +1,68 @@
.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;
}
}

View File

@@ -0,0 +1,142 @@
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();
});
});

View File

@@ -0,0 +1,150 @@
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;

View File

@@ -0,0 +1,30 @@
.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;
}
}

View File

@@ -0,0 +1,74 @@
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;

View File

@@ -0,0 +1,2 @@
export { default } from './LabelColumn';
export type { LabelColumnProps } from './LabelColumn';

View File

@@ -0,0 +1,14 @@
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,
);
}

View File

@@ -0,0 +1,30 @@
.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;
}

View File

@@ -0,0 +1,71 @@
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);
});
});

View File

@@ -0,0 +1,57 @@
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;

View File

@@ -0,0 +1 @@
export { default } from './NoResultsEmptyState';

View File

@@ -0,0 +1,32 @@
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',
};

View File

@@ -0,0 +1,7 @@
export interface FilterValue {
value: string;
}
export interface AlertWithLabels {
labels?: Record<string, string>;
}

View File

@@ -0,0 +1,287 @@
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']);
});
});

View File

@@ -0,0 +1,116 @@
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);
});
});
}

View File

@@ -41,14 +41,22 @@ $item-spacing: 8px;
width: 100%;
background: transparent;
border: none;
border-radius: 0;
box-shadow: none;
outline: none;
height: auto;
color: var(--l1-foreground);
font-size: 14px;
line-height: 20px;
letter-spacing: -0.07px;
padding: 0;
&.ant-input:focus {
&:focus,
&:focus-visible,
&:hover {
border: none;
box-shadow: none;
outline: none;
}
&::placeholder {

View File

@@ -6,7 +6,7 @@ import {
useState,
} from 'react';
import { Color } from '@signozhq/design-tokens';
import { Input } from 'antd';
import { Input } from '@signozhq/ui/input';
import logEvent from 'api/common/logEvent';
import cx from 'classnames';
import { TimezonePickerShortcuts } from 'constants/shortcuts/TimezonePickerShortcuts';

View File

@@ -1,5 +1,6 @@
import { useTranslation } from 'react-i18next';
import { Card, Form, Input } from 'antd';
import { Input } from '@signozhq/ui/input';
import { Card, Form } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';

View File

@@ -1,5 +1,6 @@
import { useState } from 'react';
import { Button, Input } from 'antd';
import { Input } from '@signozhq/ui/input';
import { Button } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import { X } from '@signozhq/icons';

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Button, Input, InputNumber, Popover, Tooltip } from 'antd';
import { Input } from '@signozhq/ui/input';
import { Button, InputNumber, Popover, Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import type { DefaultOptionType } from 'antd/es/select';
import cx from 'classnames';

View File

@@ -266,6 +266,14 @@
border-left: transparent;
border-top-left-radius: 0px;
border-bottom-left-radius: 0px;
&:focus:not(:focus-visible),
&.ant-btn:focus:not(:focus-visible) {
border-color: var(--l2-border);
border-left-color: transparent;
outline: none;
box-shadow: none;
}
}
}
}
@@ -291,5 +299,21 @@
.cm-placeholder {
font-size: 12px !important;
}
$add-on-row-height: 38px;
.periscope-input-with-label {
.input {
.ant-select {
height: $add-on-row-height;
}
}
}
.input-with-label {
.input {
height: $add-on-row-height;
}
}
}
}

View File

@@ -4,6 +4,23 @@
padding: 12px;
gap: 12px;
border-bottom: 1px solid var(--l1-border);
.search {
input {
--input-background: var(--l2-background);
--input-hover-background: var(--l2-background);
--input-focus-background: var(--l2-background);
&::placeholder {
color: var(--l3-foreground);
}
--input-font-size: 14px;
--input-border-color: var(--l1-border);
--input-focus-border-color: var(--primary-background);
--input-focus-outline-width: 0;
--input-focus-outline-offset: 0;
}
}
.filter-header-checkbox {
display: flex;
align-items: center;

View File

@@ -1,6 +1,7 @@
/* eslint-disable sonarjs/no-identical-functions */
import { Fragment, useMemo, useState } from 'react';
import { Button, Input, Skeleton } from 'antd';
import { Input } from '@signozhq/ui/input';
import { Button, Skeleton } from 'antd';
import { Checkbox } from '@signozhq/ui/checkbox';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';

View File

@@ -1,5 +1,6 @@
import { useMemo } from 'react';
import { Button, Input } from 'antd';
import { Input } from '@signozhq/ui/input';
import { Button } from 'antd';
import { Check, TableColumnsSplit, X } from '@signozhq/icons';
import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';

View File

@@ -0,0 +1,12 @@
.route-tab-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 4px;
}
.route-tab-extra {
display: flex;
align-items: center;
}

View File

@@ -70,7 +70,7 @@ describe('RouteTab component', () => {
</Router>,
);
expect(history.location.pathname).toBe('/');
fireEvent.click(screen.getByRole('tab', { name: 'Tab2' }));
fireEvent.mouseDown(screen.getByRole('tab', { name: 'Tab2' }));
expect(history.location.pathname).toBe('/tab2');
});
@@ -87,7 +87,7 @@ describe('RouteTab component', () => {
/>
</Router>,
);
fireEvent.click(screen.getByRole('tab', { name: 'Tab2' }));
fireEvent.mouseDown(screen.getByRole('tab', { name: 'Tab2' }));
expect(onChangeHandler).toHaveBeenCalled();
});
});

View File

@@ -1,10 +1,17 @@
import './RouteTab.styles.scss';
import {
generatePath,
matchPath,
useLocation,
useParams,
} from 'react-router-dom';
import { Tabs, TabsProps } from 'antd';
import {
TabsContent,
TabsList,
TabsRoot,
TabsTrigger,
} from '@signozhq/ui/tabs';
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
import { RouteTabProps } from './types';
@@ -16,11 +23,13 @@ interface Params {
function RouteTab({
routes,
activeKey,
defaultActiveKey,
onChangeHandler,
history,
showRightSection,
...rest
}: RouteTabProps & TabsProps): JSX.Element {
showRightSection = true,
tabBarExtraContent,
hideTabBar = false,
}: RouteTabProps): JSX.Element {
const params = useParams<Params>();
const location = useLocation();
@@ -46,38 +55,38 @@ function RouteTab({
}
};
const items = routes.map(({ Component, name, route, key }) => ({
label: name,
key,
tabKey: route,
children: <Component />,
}));
const resolvedActiveKey = currentRoute?.key || activeKey;
const extraContent =
tabBarExtraContent ??
(showRightSection && (
<HeaderRightSection enableAnnouncements={false} enableShare enableFeedback />
));
return (
<Tabs
onChange={onChange}
destroyInactiveTabPane
activeKey={currentRoute?.key || activeKey}
defaultActiveKey={currentRoute?.key || activeKey}
animated
items={items}
tabBarExtraContent={
showRightSection && (
<HeaderRightSection
enableAnnouncements={false}
enableShare
enableFeedback
/>
)
}
{...rest}
/>
<TabsRoot
value={resolvedActiveKey}
defaultValue={defaultActiveKey ?? resolvedActiveKey}
onValueChange={onChange}
>
{!hideTabBar && (
<div className="route-tab-header">
<TabsList>
{routes.map(({ name, key }) => (
<TabsTrigger key={key} value={key}>
{name}
</TabsTrigger>
))}
</TabsList>
{extraContent && <div className="route-tab-extra">{extraContent}</div>}
</div>
)}
{routes.map(({ key, Component }) => (
<TabsContent key={key} value={key}>
<Component />
</TabsContent>
))}
</TabsRoot>
);
}
RouteTab.defaultProps = {
onChangeHandler: undefined,
showRightSection: true,
};
export default RouteTab;

View File

@@ -1,5 +1,5 @@
import { TabsProps } from 'antd';
import { History } from 'history';
import { ReactNode } from 'react';
export type TabRoutes = {
name: React.ReactNode;
@@ -10,8 +10,11 @@ export type TabRoutes = {
export interface RouteTabProps {
routes: TabRoutes[];
activeKey: TabsProps['activeKey'];
activeKey: string | undefined;
defaultActiveKey?: string;
onChangeHandler?: (key: string) => void;
history: History<unknown>;
showRightSection: boolean;
showRightSection?: boolean;
tabBarExtraContent?: ReactNode;
hideTabBar?: boolean;
}

View File

@@ -16,7 +16,7 @@ import {
horizontalListSortingStrategy,
SortableContext,
} from '@dnd-kit/sortable';
import { ComboboxSimple, ComboboxSimpleItem } from '@signozhq/ui/combobox';
import { ComboboxSimple } 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 { buildTanstackColumnDef } from './utils';
import { buildPageSizeItems, buildTanstackColumnDef } from './utils';
import { VirtuosoTableColGroup } from './VirtuosoTableColGroup';
import tableStyles from './TanStackTable.module.scss';
@@ -66,14 +66,6 @@ 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>(
{
@@ -89,7 +81,6 @@ function TanStackTableInner<TData>(
enableQueryParams,
pagination,
paginationClassname,
onSort,
onEndReached,
getRowKey,
getItemKey,
@@ -102,6 +93,7 @@ function TanStackTableInner<TData>(
onRowClick,
onRowClickNewTab,
onRowDeactivate,
onSort,
activeRowIndex,
renderExpandedRow,
getRowCanExpand,
@@ -129,17 +121,22 @@ function TanStackTableInner<TData>(
const {
page,
limit,
setPage,
setLimit,
setPage: internalSetPage,
setLimit: internalSetLimit,
orderBy,
setOrderBy: internalSetOrderBy,
expanded,
setExpanded,
} = useTableParams(enableQueryParams, {
page: pagination?.defaultPage,
limit: pagination?.defaultLimit,
limit: pagination?.defaultLimit ?? pagination?.calculatedPageSize ?? 10,
});
const pageSizeItems = useMemo(
() => buildPageSizeItems(pagination?.calculatedPageSize),
[pagination?.calculatedPageSize],
);
const setOrderBy = useCallback(
(sort: SortState | null) => {
internalSetOrderBy(sort);
@@ -148,6 +145,23 @@ 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 {
@@ -621,6 +635,7 @@ function TanStackTableInner<TData>(
{pagination.showPageSize !== false && (
<div className={viewStyles.paginationPageSize}>
<ComboboxSimple
testId="pagination-page-size"
value={limit?.toString()}
defaultValue="10"
onChange={(value): void => {
@@ -631,7 +646,7 @@ function TanStackTableInner<TData>(
pagination.onPageChange?.(1);
}
}}
items={paginationPageSizeItems}
items={pageSizeItems}
/>
</div>
)}

View File

@@ -1,4 +1,4 @@
import { fireEvent, screen, waitFor } from '@testing-library/react';
import { fireEvent, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { UrlUpdateEvent } from 'nuqs/adapters/testing';
@@ -23,12 +23,13 @@ jest.mock('../TanStackTable.module.scss', () => ({
},
}));
// Mock ResizeObserver for combobox tests
global.ResizeObserver = class ResizeObserver {
observe(): void {}
unobserve(): void {}
disconnect(): void {}
};
beforeAll(() => {
window.ResizeObserver = jest.fn().mockImplementation(() => ({
disconnect: jest.fn(),
observe: jest.fn(),
unobserve: jest.fn(),
}));
});
describe('TanStackTableView Integration', () => {
describe('rendering', () => {
@@ -402,6 +403,22 @@ 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]>();

View File

@@ -0,0 +1,25 @@
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();
});
});

View File

@@ -0,0 +1,89 @@
/* 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);
});
});

View File

@@ -7,6 +7,7 @@ import {
} from 'nuqs/adapters/testing';
import { useTableParams } from '../useTableParams';
import { usePreferredPageSizeStore } from '../usePreferredPageSize.store';
function createNuqsWrapper(
queryParams?: Record<string, string>,
@@ -543,3 +544,406 @@ 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);
});
});

View File

@@ -0,0 +1,199 @@
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
});
});

View File

@@ -0,0 +1,385 @@
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');
});
});
});

View File

@@ -3,8 +3,10 @@ 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';
/**
@@ -192,6 +194,67 @@ 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,

View File

@@ -74,6 +74,7 @@ export type TableColumnDef<
min?: number | string;
default?: number | string;
max?: number | string;
ignoreLastColumnFill?: boolean;
};
};
@@ -111,6 +112,14 @@ 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;
@@ -123,6 +132,12 @@ 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 = {

View File

@@ -0,0 +1,76 @@
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 };
}

View File

@@ -0,0 +1,91 @@
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;
}

View File

@@ -4,6 +4,7 @@ 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;
@@ -20,9 +21,15 @@ 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;
};
type TableParamsResult = {
export type TableParamsResult = {
page: number;
limit: number;
orderBy: SortState | null;
@@ -99,15 +106,23 @@ 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>(
@@ -120,9 +135,71 @@ export function useTableParams(
pageQueryParam,
parseAsInteger.withDefault(pageDefault).withOptions(NUQS_OPTIONS),
);
const [urlLimit, setUrlLimit] = useQueryState(
const [urlLimitRaw, setUrlLimitRaw] = useQueryState(
limitQueryParam,
parseAsInteger.withDefault(limitDefault).withOptions(NUQS_OPTIONS),
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],
);
const [urlOrderBy, setUrlOrderBy] = useQueryState(
orderByQueryParam,
@@ -155,7 +232,7 @@ export function useTableParams(
typeof updaterOrValue === 'function'
? updaterOrValue(urlExpandedRef.current)
: updaterOrValue;
setUrlExpandedArray(expandedStateToArray(newState));
void setUrlExpandedArray(expandedStateToArray(newState));
},
[setUrlExpandedArray],
);
@@ -172,21 +249,53 @@ export function useTableParams(
[],
);
const orderByDefaultMemoKey = `${orderByDefault?.columnName}${orderByDefault?.order}`;
const orderByUrlMemoKey = `${urlOrderBy?.columnName}${urlOrderBy?.order}`;
const prevOrderByRef = useRef<string | null>(null);
useEffect(() => {
if (useUrlForPage) {
setUrlPage(pageDefault);
} else {
setLocalPage(pageDefault);
// 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);
}
}
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,
orderByDefaultMemoKey,
orderByUrlMemoKey,
pageDefault,
useUrlForLimit,
useUrlForOrderBy,
useUrlForExpanded,
setUrlPage,
setUrlLimitRaw,
setUrlOrderBy,
setUrlExpandedArray,
]);
return {
@@ -195,7 +304,7 @@ export function useTableParams(
orderBy: (useUrlForOrderBy ? urlOrderBy : localOrderBy) as SortState | null,
expanded: useUrlForExpanded ? urlExpanded : localExpanded,
setPage: useUrlForPage ? setUrlPage : setLocalPage,
setLimit: useUrlForLimit ? setUrlLimit : setLocalLimit,
setLimit: useUrlForLimit ? setUrlLimit : setLocalLimitWithPersist,
setOrderBy: useUrlForOrderBy ? setUrlOrderBy : setLocalOrderBy,
setExpanded: useUrlForExpanded ? setUrlExpanded : handleSetLocalExpanded,
};

View File

@@ -2,6 +2,7 @@ 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;
@@ -34,7 +35,7 @@ export const getColumnWidthStyle = <TData>(
isLastColumn?: boolean,
): CSSProperties => {
// Last column always fills remaining space
if (isLastColumn) {
if (isLastColumn && column?.width?.ignoreLastColumnFill !== true) {
return {
width: '100%',
minWidth: persistedWidth ?? column?.width?.min,
@@ -145,3 +146,31 @@ 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;
}

View File

@@ -14,7 +14,7 @@ import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQue
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { AlertListTabs } from 'pages/AlertList/types';
import { CalendarClock, GalleryVerticalEnd, Pyramid } from '@signozhq/icons';
import { GalleryVerticalEnd, Pyramid } from '@signozhq/icons';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { AlertDef } from 'types/api/alerts/def';
@@ -172,24 +172,14 @@ function CreateRules(): JSX.Element {
</div>
),
},
{
label: (
<div className="periscope-tab top-level-tab">
<CalendarClock size={14} />
Planned Downtime
</div>
),
key: AlertListTabs.PLANNED_DOWNTIME,
children: null,
},
{
label: (
<div className="periscope-tab top-level-tab">
<ConfigureIcon width={14} height={14} />
Routing Policies
Configuration
</div>
),
key: AlertListTabs.ROUTING_POLICIES,
key: AlertListTabs.CONFIGURATION,
children: null,
},
];

View File

@@ -1,5 +1,6 @@
import { useMemo, useState } from 'react';
import { Button, Input, Select, Tooltip } from 'antd';
import { Input } from '@signozhq/ui/input';
import { Button, Select, Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { CircleX, Trash } from '@signozhq/icons';
import { useAppContext } from 'providers/App/App';

View File

@@ -4,4 +4,5 @@ export const THRESHOLD_TAB_TOOLTIP =
export const ANOMALY_TAB_TOOLTIP =
'An alert is triggered whenever the metric deviates from an expected pattern.';
export const ROUTING_POLICIES_ROUTE = '/alerts?tab=RoutingPolicies';
export const ROUTING_POLICIES_ROUTE =
'/alerts?tab=Configuration&subTab=routing-policies';

View File

@@ -1,4 +1,5 @@
import { Collapse, Input } from 'antd';
import { Input } from '@signozhq/ui/input';
import { Collapse } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { useCreateAlertState } from '../context';

View File

@@ -1,5 +1,6 @@
import { useMemo } from 'react';
import { Input, Select } from 'antd';
import { Input } from '@signozhq/ui/input';
import { Select } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { ADVANCED_OPTIONS_TIME_UNIT_OPTIONS } from '../../context/constants';

View File

@@ -1,6 +1,5 @@
import React, { useEffect, useState } from 'react';
import { Input } from 'antd';
import { Input } from '@signozhq/ui/input';
import './TimeInput.scss';
export interface TimeInputProps {

View File

@@ -1,4 +1,5 @@
import { Input, Select } from 'antd';
import { Input } from '@signozhq/ui/input';
import { Select } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { useCreateAlertState } from '../context';

View File

@@ -16,9 +16,10 @@ import {
Plus,
X,
} from '@signozhq/icons';
import { Button, Card, Input, Modal, Popover, Tooltip } from 'antd';
import { Button, Card, Modal, Popover, Tooltip } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import { Typography } from '@signozhq/ui/typography';
import { Input } from '@signozhq/ui/input';
import logEvent from 'api/common/logEvent';
import ConfigureIcon from 'assets/Integrations/ConfigureIcon';
import { PANEL_GROUP_TYPES, PANEL_TYPES } from 'constants/queryBuilder';

View File

@@ -1,70 +0,0 @@
.settings-tabs {
.ant-tabs-nav-list {
height: 32px;
flex-shrink: 0;
border-radius: 2px;
border: 1px solid var(--l1-border);
background: var(--l2-background);
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
transition: opacity 0.1s !important;
.ant-tabs-tab + .ant-tabs-tab {
margin: 0px;
}
.ant-tabs-tab:not(:last-child) {
border-right: 1px solid var(--l1-border) !important;
}
.overview-btn {
width: 114px;
display: flex;
align-items: center;
justify-content: center;
}
.variables-btn {
width: 114px;
display: flex;
align-items: center;
justify-content: center;
}
.public-dashboard-btn {
width: 150px;
display: flex;
align-items: center;
justify-content: center;
&.disabled-btn {
opacity: 0.5;
cursor: not-allowed;
}
}
.ant-tabs-ink-bar {
display: none;
}
.ant-tabs-tab-active {
.overview-btn {
border-radius: 2px 0px 0px 2px;
background: var(--l1-border);
}
.variables-btn {
border-radius: 2px 0px 0px 2px;
background: var(--l1-border);
}
.public-dashboard-btn {
border-radius: 2px 0px 0px 2px;
background: var(--l1-border);
}
}
}
.ant-tabs-nav::before {
border-bottom: none;
}
}

View File

@@ -1,4 +1,4 @@
import { Button, Tabs, Tooltip } from 'antd';
import { Tabs, TabItemProps } from '@signozhq/ui/tabs';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { Braces, Globe, Table } from '@signozhq/icons';
import { useAppContext } from 'providers/App/App';
@@ -9,8 +9,6 @@ import DashboardVariableSettings from './DashboardVariableSettings';
import GeneralDashboardSettings from './General';
import PublicDashboardSetting from './PublicDashboard';
import './DashboardSettingsContent.styles.scss';
function DashboardSettings({
variablesSettingsTabHandle,
}: {
@@ -21,49 +19,26 @@ function DashboardSettings({
const enablePublicDashboard = isCloudUser || isEnterpriseSelfHostedUser;
const publicDashboardItem = {
label: (
<Tooltip
title={
user?.role !== USER_ROLES.ADMIN
? 'Only admins can publish / manage public dashboards'
: ''
}
placement="right"
>
<Button
type="text"
icon={<Globe size={14} />}
className={`public-dashboard-btn ${
user?.role !== USER_ROLES.ADMIN ? 'disabled-btn' : ''
}`}
>
Publish
</Button>
</Tooltip>
),
const publicDashboardItem: TabItemProps = {
key: 'public-dashboard',
label: 'Publish',
prefixIcon: <Globe size={14} />,
children: <PublicDashboardSetting />,
disabled: user?.role !== USER_ROLES.ADMIN,
disabledReason: 'Only admins can publish / manage public dashboards',
};
const items = [
const items: TabItemProps[] = [
{
label: (
<Button type="text" icon={<Table size={14} />} className="overview-btn">
Overview
</Button>
),
key: 'general',
label: 'Overview',
prefixIcon: <Table size={14} />,
children: <GeneralDashboardSettings />,
},
{
label: (
<Button type="text" icon={<Braces size={14} />} className="variables-btn">
Variables
</Button>
),
key: 'variables',
label: 'Variables',
prefixIcon: <Braces size={14} />,
children: (
<DashboardVariableSettings
variablesSettingsTabHandle={variablesSettingsTabHandle}
@@ -73,7 +48,7 @@ function DashboardSettings({
...(enablePublicDashboard ? [publicDashboardItem] : []),
];
return <Tabs items={items} animated className="settings-tabs" />;
return <Tabs items={items} />;
}
export default DashboardSettings;

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Button, Input } from 'antd';
import { Input } from '@signozhq/ui/input';
import { Button } from 'antd';
import { PrecisionOption, PrecisionOptionsEnum } from 'components/Graph/types';
import { ResizeTable } from 'components/ResizeTable';
import { useNotifications } from 'hooks/useNotifications';

View File

@@ -22,7 +22,6 @@ import { Color } from '@signozhq/design-tokens';
import {
Button,
ColorPicker,
Input,
Modal,
RefSelectProps,
Select,
@@ -30,6 +29,7 @@ import {
} from 'antd';
import { Divider } from '@signozhq/ui/divider';
import { Typography } from '@signozhq/ui/typography';
import { Input } from '@signozhq/ui/input';
import getLocalStorageKey from 'api/browser/localstorage/get';
import setLocalStorageKey from 'api/browser/localstorage/set';
import logEvent from 'api/common/logEvent';

View File

@@ -1,7 +1,7 @@
import { Dispatch, SetStateAction } from 'react';
import { useTranslation } from 'react-i18next';
import { Form, Input } from 'antd';
import { Input } from '@signozhq/ui/input';
import { Form } from 'antd';
import { EmailChannel } from '../../CreateAlertChannels/config';
function EmailForm({ setSelectedConfig }: EmailFormProps): JSX.Element {

View File

@@ -1,6 +1,7 @@
import { Dispatch, SetStateAction } from 'react';
import { useTranslation } from 'react-i18next';
import { Form, Input } from 'antd';
import { Input } from '@signozhq/ui/input';
import { Form } from 'antd';
import { MarkdownRenderer } from 'components/MarkdownRenderer/MarkdownRenderer';
import { WebhookChannel } from '../../CreateAlertChannels/config';

View File

@@ -1,7 +1,8 @@
import { Dispatch, ReactElement, SetStateAction } from 'react';
import { useTranslation } from 'react-i18next';
import { Form, FormInstance, Input, Select } from 'antd';
import { Input } from '@signozhq/ui/input';
import { Switch } from '@signozhq/ui/switch';
import { Form, FormInstance, Select } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import type { Store } from 'antd/lib/form/interface';
import ROUTES from 'constants/routes';

View File

@@ -6,7 +6,8 @@ import { useIsFetching } from 'react-query';
import { useDispatch } from 'react-redux';
import { useLocation } from 'react-router-dom';
import { Color } from '@signozhq/design-tokens';
import { Button, Form, Input, Modal } from 'antd';
import { Input } from '@signozhq/ui/input';
import { Button, Form, Modal } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import cx from 'classnames';

View File

@@ -5,12 +5,12 @@ import { useCopyToClipboard } from 'react-use';
import { Color } from '@signozhq/design-tokens';
import { Badge } from '@signozhq/ui/badge';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import {
Col,
Collapse,
DatePicker,
Form,
Input,
InputNumber,
Modal,
Row,

View File

@@ -1,4 +1,5 @@
import { Form, Input } from 'antd';
import { Input } from '@signozhq/ui/input';
import { Form } from 'antd';
import { CloudintegrationtypesCredentialsDTO } from 'api/generated/services/sigNoz.schemas';
function RenderConnectionFields({

View File

@@ -1,5 +1,4 @@
import { Button, Tabs, TabsProps } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { Tabs, TabItemProps } from '@signozhq/ui/tabs';
import ConfigureIcon from 'assets/Integrations/ConfigureIcon';
import { CableCar, Group } from '@signozhq/icons';
import { IntegrationDetailedProps } from 'types/api/integrations/types';
@@ -22,18 +21,11 @@ function IntegrationDetailContent(
): JSX.Element {
const { activeDetailTab, integrationData, integrationId, setActiveDetailTab } =
props;
const items: TabsProps['items'] = [
const items: TabItemProps[] = [
{
key: 'overview',
label: (
<Button
type="text"
className="integration-tab-btns"
icon={<CableCar size={14} />}
>
<Typography.Text className="typography">Overview</Typography.Text>
</Button>
),
label: 'Overview',
prefixIcon: <CableCar size={14} />,
children: (
<Overview
categories={integrationData.categories}
@@ -44,15 +36,8 @@ function IntegrationDetailContent(
},
{
key: 'configuration',
label: (
<Button
type="text"
className="integration-tab-btns"
icon={<ConfigureIcon />}
>
<Typography.Text className="typography">Configure</Typography.Text>
</Button>
),
label: 'Configure',
prefixIcon: <ConfigureIcon />,
children: (
<Configure
configuration={integrationData.configuration}
@@ -62,15 +47,8 @@ function IntegrationDetailContent(
},
{
key: 'dataCollected',
label: (
<Button
type="text"
className="integration-tab-btns"
icon={<Group size={14} />}
>
<Typography.Text className="typography">Data Collected</Typography.Text>
</Button>
),
label: 'Data Collected',
prefixIcon: <Group size={14} />,
children: (
<DataCollected
logsData={integrationData.data_collected.logs}
@@ -81,11 +59,7 @@ function IntegrationDetailContent(
];
return (
<div className="integration-detail-container">
<Tabs
activeKey={activeDetailTab}
items={items}
onChange={setActiveDetailTab}
/>
<Tabs value={activeDetailTab} items={items} onChange={setActiveDetailTab} />
</div>
);
}

View File

@@ -168,45 +168,6 @@
padding: 10px 16px;
border: 1px solid var(--l1-border);
background: var(--l1-background);
.integration-tab-btns {
display: flex;
align-items: center;
justify-content: center;
padding: 8px 8px 18px 8px !important;
.typography {
color: var(--l1-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
}
.integration-tab-btns:hover {
&.ant-btn-text {
background-color: unset !important;
}
}
.ant-tabs-nav-list {
gap: 24px;
}
.ant-tabs-nav {
padding: 0px !important;
}
.ant-tabs-tab {
padding: 0 !important;
}
.ant-tabs-tab + .ant-tabs-tab {
margin: 0px !important;
}
}
.uninstall-integration-bar {

View File

@@ -1,6 +1,7 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Form, Input } from 'antd';
import { Input } from '@signozhq/ui/input';
import { Button, Form } from 'antd';
import apply from 'api/v3/licenses/post';
import { useNotifications } from 'hooks/useNotifications';
import APIError from 'types/api/error';

View File

@@ -2,6 +2,8 @@ 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;
@@ -17,17 +19,17 @@ function AlertInfoCard({
}: AlertInfoCardProps): JSX.Element {
return (
<div
className="alert-info-card"
className={styles.alertInfoCard}
onClick={(): void => {
onClick();
openInNewTab(link);
}}
>
<div className="alert-card-text">
<Typography.Text className="alert-card-text-header">
<div className={styles.alertCardText}>
<Typography.Text className={styles.alertCardTextHeader}>
{header}
</Typography.Text>
<Typography.Text className="alert-card-text-subheader">
<Typography.Text className={styles.alertCardTextSubheader}>
{subheader}
</Typography.Text>
</div>

View File

@@ -0,0 +1,189 @@
.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;
}
}

View File

@@ -1,175 +0,0 @@
.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;
}
}

View File

@@ -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 './AlertsEmptyState.styles.scss';
import styles from './AlertsEmptyState.module.scss';
const alertLogEvents = (
title: string,
@@ -29,10 +29,16 @@ const alertLogEvents = (
page: 'Alert empty state page',
};
logEvent(title, dataSource ? { ...attributes, dataSource } : attributes);
void logEvent(title, dataSource ? { ...attributes, dataSource } : attributes);
};
export function AlertsEmptyState(): JSX.Element {
interface AlertsEmptyStateProps {
onRefresh?: () => void;
}
export function AlertsEmptyState({
onRefresh,
}: AlertsEmptyStateProps): JSX.Element {
const { user } = useAppContext();
const { safeNavigate } = useSafeNavigate();
const [addNewAlert] = useComponentPermission(
@@ -51,45 +57,51 @@ export function AlertsEmptyState(): JSX.Element {
);
return (
<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">
<div className={styles.alertListContainer}>
<div className={styles.alertListViewContent}>
<div>
<Typography.Title className={styles.title}>Alert Rules</Typography.Title>
<Typography.Text className={styles.subtitle}>
Create and manage alert rules for your resources.
</Typography.Text>
</div>
<section className="empty-alert-info-container">
<div className="alert-content">
<section className="heading">
<section className={styles.emptyAlertInfoContainer}>
<div className={styles.alertContent}>
<section className={styles.heading}>
<img
src={alertEmojiUrl}
alt="alert-header"
style={{ height: '32px', width: '32px' }}
/>
<div>
<Typography.Text className="empty-info">
<Typography.Text className={styles.emptyInfo}>
No Alert rules yet.{' '}
</Typography.Text>
<Typography.Text className="empty-alert-action">
<br />
<Typography.Text className={styles.emptyAlertAction}>
Create an Alert Rule to get started
</Typography.Text>
</div>
</section>
<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>
<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>
<InfoLinkText
infoText="Watch a tutorial on creating a sample alert"
link="https://youtu.be/xjxNIqiv4_M"
@@ -124,11 +136,9 @@ export function AlertsEmptyState(): JSX.Element {
})}
</div>
</section>
<div className="get-started-text">
<div className={styles.getStartedText}>
<Divider className="get-started-text__divider">
<Typography.Text className="get-started-text">
Or get started with these sample alerts
</Typography.Text>
<Typography.Text>Or get started with these sample alerts</Typography.Text>
</Divider>
</div>

View File

@@ -3,6 +3,8 @@ 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;
@@ -24,12 +26,12 @@ function InfoLinkText({
onClick();
openInNewTab(link);
}}
className="info-link-container"
className={styles.infoLinkContainer}
>
{leftIconVisible && <CirclePlay size="md" />}
<Typography.Text className="info-text">{infoText}</Typography.Text>
{leftIconVisible && <CirclePlay size={16} />}
<Typography.Text className={styles.infoText}>{infoText}</Typography.Text>
{rightIconVisible && (
<ArrowRight size="md" style={{ transform: 'rotate(315deg)' }} />
<ArrowRight size={16} style={{ transform: 'rotate(315deg)' }} />
)}
</Flex>
);

View File

@@ -1,86 +0,0 @@
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;

View File

@@ -1,447 +0,0 @@
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 MutedBadge from './TableComponents/MutedBadge';
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) });
};
const isMuted = Boolean(record.mutes?.length);
return (
<span className="alert-list-name-cell">
<Typography.Link onClick={onClickHandler}>{value}</Typography.Link>
{isMuted && <MutedBadge muteEndTime={record.mutes![0].end} />}
</span>
);
},
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;

View File

@@ -0,0 +1,92 @@
.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;
}

View File

@@ -1,20 +0,0 @@
.alert-list-name-cell {
display: inline-flex;
align-items: center;
gap: 8px;
}
.alert-list-muted-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 7px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--bg-amber-500);
background: rgba(255, 205, 86, 0.12);
border: 1px solid rgba(255, 205, 86, 0.25);
border-radius: 4px;
}

View File

@@ -1,43 +0,0 @@
import { BellOff } from '@signozhq/icons';
import dayjs from 'dayjs';
import './MutedBadge.styles.scss';
const formatRemaining = (endTime: string | undefined | null): string | null => {
if (!endTime) {
return null;
}
const end = dayjs(endTime);
const now = dayjs();
const diffMs = end.diff(now);
if (diffMs <= 0) {
return null;
}
const totalMinutes = Math.floor(diffMs / 60000);
const days = Math.floor(totalMinutes / (60 * 24));
const hours = Math.floor((totalMinutes % (60 * 24)) / 60);
const minutes = totalMinutes % 60;
if (days > 0) {
return `${days}d ${hours}h`;
}
if (hours > 0) {
return `${hours}h ${minutes}m`;
}
return `${minutes}m`;
};
interface MutedBadgeProps {
muteEndTime: string | undefined | null;
}
function MutedBadge({ muteEndTime }: MutedBadgeProps): JSX.Element | null {
const remaining = formatRemaining(muteEndTime);
return (
<span className="alert-list-muted-badge">
<BellOff size={10} />
<span>MUTED{remaining ? ` · ${remaining}` : ''}</span>
</span>
);
}
export default MutedBadge;

View File

@@ -1,52 +0,0 @@
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;

View File

@@ -1,103 +0,0 @@
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;

View File

@@ -1,147 +0,0 @@
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');
});
});

View File

@@ -0,0 +1,16 @@
.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);
}
}

View File

@@ -0,0 +1,183 @@
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;

View File

@@ -0,0 +1,34 @@
.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);
}
}

View File

@@ -0,0 +1,78 @@
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;

View File

@@ -0,0 +1,2 @@
export { default as ActionsMenu } from './ActionsMenu';
export { default as ColumnSelector } from './ColumnSelector';

View File

@@ -0,0 +1,24 @@
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),
);

View File

@@ -1,67 +1,202 @@
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 { 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 { AlertsEmptyState } from './AlertsEmptyState/AlertsEmptyState';
import ListAlert from './ListAlert';
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;
function ListAlertRules(): JSX.Element {
const { t } = useTranslation('common');
const { data, isError, isLoading, refetch, error } = useListRules({
query: { cacheTime: 0 },
});
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 { user } = useAppContext();
const [addNewAlert, action] = useComponentPermission(
['add_new_alert', 'action'],
user.role,
);
useEffect(() => {
if (!logEventCalledRef.current && hasLoaded) {
logEvent('Alert: List page visited', {
number: rules.length,
});
logEventCalledRef.current = true;
const [filterValues, setFilterValues] = useAlertRulesFilters();
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const { containerRef, calculatedPageSize } = useCalculatedPageSize({
rowHeight: 46,
});
const { orderBy, page, limit, setLimit, setPage } = useTableParams(
QUERY_PARAMS_CONFIG,
{
page: DEFAULT_PAGE,
limit: DEFAULT_LIMIT,
storageKey: 'alert-rules',
calculatedPageSize,
cleanupOnUnmount: true,
},
);
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;
}
}, [hasLoaded, rules.length]);
useEffect(() => {
if (isError) {
notifications.error({
message: apiError?.getErrorMessage() || t('something_went_wrong'),
});
}
}, [isError, apiError, t, notifications]);
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]);
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 />;
}
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;
return (
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<ListAlert allAlertRules={rules} refetch={refetch} />
</Space>
<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>
);
}

View File

@@ -1,28 +0,0 @@
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;
}
`;

View File

@@ -0,0 +1,156 @@
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>
),
},
];
}

View File

@@ -0,0 +1,3 @@
import type { RuletypesRuleDTO } from 'api/generated/services/sigNoz.schemas';
export type AlertRule = RuletypesRuleDTO;

View File

@@ -0,0 +1,55 @@
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,
};
}

View File

@@ -0,0 +1,82 @@
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,
};
}

View File

@@ -1,59 +1,92 @@
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';
export const filterAlerts = (
allAlertRules: RuletypesRuleDTO[],
filter: string,
): RuletypesRuleDTO[] => {
if (!filter.trim()) {
return allAlertRules;
}
import type { AlertRule } from './types';
const value = filter.trim().toLowerCase();
return allAlertRules.filter((alert) => {
const alertName = alert.alert.toLowerCase();
const severity = alert.labels?.severity?.toLowerCase();
export const ALERT_RULES_REFRESH_INTERVAL = 30_000;
// Create a string of all label keys and values for searching
const labelSearchString = Object.entries(alert.labels || {})
.map(([key, val]) => `${key} ${val}`)
.join(' ')
.toLowerCase();
export const ALERT_ACTIONS = {
TOGGLE: 'toggle',
EDIT: 'edit',
CLONE: 'clone',
DELETE: 'delete',
} as const;
return (
alertName.includes(value) ||
severity?.includes(value) ||
labelSearchString.includes(value)
);
});
const ACTION_LABELS: Record<string, string> = {
[ALERT_ACTIONS.TOGGLE]: 'Enable/Disable',
[ALERT_ACTIONS.EDIT]: 'Edit',
[ALERT_ACTIONS.CLONE]: 'Clone',
[ALERT_ACTIONS.DELETE]: 'Delete',
};
export const alertActionLogEvent = (
action: string,
record: RuletypesRuleDTO,
): void => {
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', {
const actionValue = ACTION_LABELS[action] ?? action;
void 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;
});
}

View File

@@ -1,6 +1,7 @@
/* eslint-disable @typescript-eslint/explicit-function-return-type */
import { ChangeEvent, useState } from 'react';
import { Button, Input, Modal } from 'antd';
import { Input } from '@signozhq/ui/input';
import { Button, Modal } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import ApacheIcon from 'assets/CustomIcons/ApacheIcon';
import DockerIcon from 'assets/CustomIcons/DockerIcon';

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