Compare commits

...

28 Commits

Author SHA1 Message Date
Vinícius Lourenço
eef2b6a961 fix(alert): use calculated page size 2026-05-15 18:30:49 -03:00
Vinícius Lourenço
330038a35f feat(tanstack): add auto page size 2026-05-15 18:29:52 -03:00
Vinícius Lourenço
d4dea81bb6 fix(alerts): address comments related to UI/UX 2026-05-15 16:04:24 -03:00
Vinícius Lourenço
dfd7d8a871 Merge branch 'main' into feat/alerts-revamp 2026-05-15 12:23:10 -03:00
Vinícius Lourenço
40d2906835 fix(alerts): ensure the params are removed on page change 2026-05-14 11:11:13 -03:00
Vinícius Lourenço
3fcb6b3b00 Merge branch 'main' into feat/alerts-revamp 2026-05-14 10:56:40 -03:00
Vinícius Lourenço
5982c0854d fix(alerts): missing including error empty state 2026-05-13 16:35:47 -03:00
Vinícius Lourenço
687b40ffbb refactor(alerts): remove barrel imports 2026-05-13 13:23:01 -03:00
Vinícius Lourenço
4e111c6b83 refactor(alerts): ensure no empty has correct colors 2026-05-13 12:51:21 -03:00
Vinícius Lourenço
3f5eb62494 fix(tanstack): preserve page from URL on refresh page 2026-05-13 12:50:28 -03:00
Vinícius Lourenço
cd7b6a1d05 refactor(alerts): missing pagination class on group 2026-05-13 12:45:52 -03:00
Vinícius Lourenço
faee2f032f refactor(alerts): standardize errors and empty states 2026-05-13 12:42:40 -03:00
Vinícius Lourenço
0402cc0273 refactor(alerts): cleanup dead components 2026-05-13 12:22:57 -03:00
Vinícius Lourenço
b70f057adc refactor(alerts): disable move columns 2026-05-13 11:48:09 -03:00
Vinícius Lourenço
3b7b7202e9 refactor(alerts): remove extra columns & add back info tooltip 2026-05-13 11:45:55 -03:00
Vinícius Lourenço
e3c9babfe5 refactor(alerts): move table to use pagination instead of infinity load 2026-05-13 11:34:13 -03:00
Vinícius Lourenço
226e40cbcd refactor(alerts): removed stats card 2026-05-13 11:12:14 -03:00
Vinícius Lourenço
0f4d007104 chore(lint): fix signozhq/ui imports 2026-05-13 09:57:01 -03:00
Vinícius Lourenço
86b88eb10b chore(pr-comments): address PR comments 2026-05-13 09:55:59 -03:00
Vinícius Lourenço
0b21197689 Merge branch 'main' into feat/alerts-revamp 2026-05-13 09:35:44 -03:00
Vinicius Lourenço
6c02fe107f 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
2026-05-13 09:24:50 -03:00
Vinicius Lourenço
a90e915fa3 feat(list-alerts): rewrite page to use new table component (#11276) 2026-05-13 09:14:32 -03:00
Vinícius Lourenço
1a4de4328b feat(alerts-components): add badge map colors 2026-05-11 17:08:44 -03:00
Vinícius Lourenço
c53adf365a feat(time-utils): add little helper to get elapsed time in ms 2026-05-11 16:40:41 -03:00
Vinícius Lourenço
0fc16e02fa feat(hooks): add shared hooks for alerts 2026-05-11 16:37:42 -03:00
Vinícius Lourenço
fb6a29e6fa feat(components): added shared components for alerts 2026-05-11 15:52:26 -03:00
Vinícius Lourenço
0daf7a12da chore(signozhq/ui): bump to v0.0.19 2026-05-11 14:47:09 -03:00
Vinícius Lourenço
cc7d7017ae feat(tanstack-table): add showPageSize flag and callbacks to pagination 2026-05-11 14:45:20 -03:00
84 changed files with 5412 additions and 2144 deletions

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,139 @@
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 (500px = 3 badges + overflow)
triggerResize(500);
// 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)
triggerResize(500);
// 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,144 @@
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';
export interface LabelColumnProps {
labels: string[];
color?:
| 'primary'
| 'secondary'
| 'success'
| 'error'
| 'warning'
| 'robin'
| 'forest'
| 'amber'
| 'sienna'
| 'cherry'
| 'sakura'
| 'aqua'
| 'vanilla';
value?: { [key: string]: string };
}
const BADGE_GAP = 4;
const OVERFLOW_BADGE_WIDTH = 40;
const ESTIMATED_BADGE_WIDTH = 120;
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;
const badgeSlotWidth = ESTIMATED_BADGE_WIDTH + BADGE_GAP;
const maxFit = Math.max(1, Math.floor(availableWidth / badgeSlotWidth));
return Math.min(maxFit, labels.length);
},
[labels.length],
);
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,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

@@ -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,13 +635,14 @@ function TanStackTableInner<TData>(
{pagination.showPageSize !== false && (
<div className={viewStyles.paginationPageSize}>
<ComboboxSimple
testId="pagination-page-size"
value={limit?.toString()}
defaultValue="10"
onChange={(value): void => {
setLimit(+value);
pagination.onLimitChange?.(+value);
}}
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', () => {
@@ -401,6 +402,54 @@ describe('TanStackTableView Integration', () => {
expect(onLimitChange).toHaveBeenCalledWith(20);
});
});
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();
renderTanStackTable({
props: {
pagination: { total: 100, defaultPage: 1, defaultLimit: 5 },
enableQueryParams: true,
},
});
const nav = await screen.findByRole('navigation');
const page1Button = within(nav).getByRole('button', { name: '1' });
const page2Button = within(nav).getByRole('button', { name: '2' });
expect(page1Button).toHaveAttribute('aria-current', 'page');
await user.click(page2Button);
await waitFor(() => {
expect(page2Button).toHaveAttribute('aria-current', 'page');
});
await user.click(screen.getByTestId('pagination-page-size'));
const option10 = await screen.findByRole('option', { name: '10' });
await user.click(option10);
await waitFor(() => {
expect(page1Button).toHaveAttribute('aria-current', 'page');
});
});
});
describe('sorting', () => {

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

@@ -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,178 +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%;
.ant-divider::before,
.ant-divider::after {
border-bottom: 2px dotted var(--l1-border);
border-top: 2px dotted var(--l1-border);
height: 8px;
}
.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,6 +1,7 @@
import React, { useCallback, useState } from 'react';
import { Plus } from '@signozhq/icons';
import { Button, Divider, Flex } from 'antd';
import { Plus, RefreshCw } from '@signozhq/icons';
import { Divider } from 'antd';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import ROUTES from 'constants/routes';
@@ -16,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,
@@ -28,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(
@@ -50,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"
@@ -123,11 +136,9 @@ export function AlertsEmptyState(): JSX.Element {
})}
</div>
</section>
<div className="get-started-text">
<div className={styles.getStartedText}>
<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,429 +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 { Plus } from '@signozhq/icons';
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 DropDown from 'components/DropDown/DropDown';
import {
DynamicColumnsKey,
TableDataSource,
} from 'components/ResizeTable/contants';
import DynamicColumnTable from 'components/ResizeTable/DynamicColumnTable';
import DateComponent from 'components/ResizeTable/TableComponent/DateComponent';
import LabelColumn from 'components/TableRenderer/LabelColumn';
import TextToolTip from 'components/TextToolTip';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { sanitizeDefaultAlertQuery } from 'container/EditAlertV2/utils';
import useSortableTable from 'hooks/ResizeTable/useSortableTable';
import useComponentPermission from 'hooks/useComponentPermission';
import useDebouncedFn from 'hooks/useDebouncedFunction';
import useInterval from 'hooks/useInterval';
import { useNotifications } from 'hooks/useNotifications';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import { useAppContext } from 'providers/App/App';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { toCompositeMetricQuery } from 'types/api/alerts/convert';
import APIError from 'types/api/error';
import { isModifierKeyPressed } from 'utils/app';
import DeleteAlert from './DeleteAlert';
import { ColumnButton, SearchContainer } from './styles';
import Status from './TableComponents/Status';
import ToggleAlertState from './ToggleAlertState';
import { alertActionLogEvent, filterAlerts } from './utils';
const { Search } = Input;
function ListAlert({ allAlertRules, refetch }: ListAlertProps): JSX.Element {
const { t } = useTranslation('common');
const { safeNavigate } = useSafeNavigate();
const { user } = useAppContext();
const [addNewAlert, action] = useComponentPermission(
['add_new_alert', 'action'],
user.role,
);
const [editLoader, setEditLoader] = useState<boolean>(false);
const [cloneLoader, setCloneLoader] = useState<boolean>(false);
const params = useUrlQuery();
const orderColumnParam = params.get('columnKey');
const orderQueryParam = params.get('order');
const paginationParam = params.get('page');
const searchParams = params.get('search');
const [searchString, setSearchString] = useState<string>(searchParams || '');
const [data, setData] = useState<RuletypesRuleDTO[]>(() => {
const value = searchString.toLowerCase();
const filteredData = filterAlerts(allAlertRules, value);
return filteredData || [];
});
// Type asuring
const sortingOrder: 'ascend' | 'descend' | null =
orderQueryParam === 'ascend' || orderQueryParam === 'descend'
? orderQueryParam
: null;
const { sortedInfo, handleChange } = useSortableTable<RuletypesRuleDTO>(
sortingOrder,
orderColumnParam || '',
searchString,
);
const { notifications: notificationsApi } = useNotifications();
useInterval(() => {
(async (): Promise<void> => {
const { data: refetchData, status } = await refetch();
if (status === 'success') {
const value = searchString.toLowerCase();
const filteredData = filterAlerts(refetchData?.data ?? [], value);
setData(filteredData || []);
}
if (status === 'error') {
notificationsApi.error({
message: t('something_went_wrong'),
});
}
})();
}, 30000);
const { showErrorModal } = useErrorModal();
const onClickNewAlertHandler = useCallback(
(e: React.MouseEvent): void => {
logEvent('Alert: New alert button clicked', {
number: allAlertRules?.length,
layout: 'new',
});
safeNavigate(ROUTES.ALERT_TYPE_SELECTION, {
newTab: isModifierKeyPressed(e),
});
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
const onEditHandler = (
record: RuletypesRuleDTO,
options?: { newTab?: boolean },
): void => {
const compositeQuery = sanitizeDefaultAlertQuery(
mapQueryDataFromApi(toCompositeMetricQuery(record.condition.compositeQuery)),
record.alertType,
);
params.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(compositeQuery)),
);
const panelType = record.condition.compositeQuery.panelType;
if (panelType) {
params.set(QueryParams.panelTypes, panelType);
}
params.set(QueryParams.ruleId, record.id);
setEditLoader(false);
safeNavigate(`${ROUTES.ALERT_OVERVIEW}?${params.toString()}`, {
newTab: options?.newTab,
});
};
const onCloneHandler =
(originalAlert: RuletypesRuleDTO) => async (): Promise<void> => {
const copyAlert: RuletypesRuleDTO = {
...originalAlert,
alert: `${originalAlert.alert} - Copy`,
};
try {
setCloneLoader(true);
await createRule(copyAlert);
notificationsApi.success({
message: 'Success',
description: 'Alert cloned successfully',
});
const { data: refetchData, status } = await refetch();
const rules = refetchData?.data;
if (status === 'success' && rules) {
setData(rules);
setTimeout(() => {
const clonedAlert = rules[rules.length - 1];
params.set(QueryParams.ruleId, String(clonedAlert.id));
safeNavigate(`${ROUTES.EDIT_ALERTS}?${params.toString()}`);
}, 2000);
}
if (status === 'error') {
notificationsApi.error({
message: t('something_went_wrong'),
});
}
} catch (error) {
showErrorModal(
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) as APIError,
);
} finally {
setCloneLoader(false);
}
};
const handleSearch = useDebouncedFn((e: unknown) => {
const value = (e as React.BaseSyntheticEvent).target.value.toLowerCase();
setSearchString(value);
const filteredData = filterAlerts(allAlertRules, value);
setData(filteredData);
});
const dynamicColumns: ColumnsType<RuletypesRuleDTO> = [
{
title: 'Created At',
dataIndex: 'createdAt',
width: 80,
key: DynamicColumnsKey.CreatedAt,
align: 'center',
sorter: (a: RuletypesRuleDTO, b: RuletypesRuleDTO): number => {
const prev = a.createdAt ? new Date(a.createdAt).getTime() : 0;
const next = b.createdAt ? new Date(b.createdAt).getTime() : 0;
return prev - next;
},
render: DateComponent,
sortOrder:
sortedInfo.columnKey === DynamicColumnsKey.CreatedAt
? sortedInfo.order
: null,
},
{
title: 'Created By',
dataIndex: 'createdBy',
width: 80,
key: DynamicColumnsKey.CreatedBy,
align: 'center',
},
{
title: 'Updated At',
dataIndex: 'updatedAt',
width: 80,
key: DynamicColumnsKey.UpdatedAt,
align: 'center',
sorter: (a: RuletypesRuleDTO, b: RuletypesRuleDTO): number => {
const prev = a.updatedAt ? new Date(a.updatedAt).getTime() : 0;
const next = b.updatedAt ? new Date(b.updatedAt).getTime() : 0;
return prev - next;
},
render: DateComponent,
sortOrder:
sortedInfo.columnKey === DynamicColumnsKey.UpdatedAt
? sortedInfo.order
: null,
},
{
title: 'Updated By',
dataIndex: 'updatedBy',
width: 80,
key: DynamicColumnsKey.UpdatedBy,
align: 'center',
},
];
const columns: ColumnsType<RuletypesRuleDTO> = [
{
title: 'Status',
dataIndex: 'state',
width: 80,
key: 'state',
sorter: (a, b): number =>
(b.state ? b.state.charCodeAt(0) : 1000) -
(a.state ? a.state.charCodeAt(0) : 1000),
render: (value): JSX.Element => <Status status={value} />,
sortOrder: sortedInfo.columnKey === 'state' ? sortedInfo.order : null,
},
{
title: 'Alert Name',
dataIndex: 'alert',
width: 100,
key: 'name',
sorter: (alertA, alertB): number => {
if (alertA.alert && alertB.alert) {
return alertA.alert.localeCompare(alertB.alert);
}
return 0;
},
render: (value, record): JSX.Element => {
const onClickHandler = (e: React.MouseEvent<HTMLElement>): void => {
e.stopPropagation();
e.preventDefault();
onEditHandler(record, { newTab: isModifierKeyPressed(e) });
};
return <Typography.Link onClick={onClickHandler}>{value}</Typography.Link>;
},
sortOrder: sortedInfo.columnKey === 'name' ? sortedInfo.order : null,
},
{
title: 'Severity',
dataIndex: 'labels',
width: 80,
key: 'severity',
sorter: (a, b): number =>
(a?.labels?.severity?.length || 0) - (b?.labels?.severity?.length || 0),
render: (value): JSX.Element => {
const objectKeys = value ? Object.keys(value) : [];
const withSeverityKey = objectKeys.find((e) => e === 'severity') || '';
const severityValue = withSeverityKey ? value[withSeverityKey] : '-';
return <Typography>{severityValue}</Typography>;
},
sortOrder: sortedInfo.columnKey === 'severity' ? sortedInfo.order : null,
},
{
title: 'Labels',
dataIndex: 'labels',
key: 'tags',
align: 'center',
width: 100,
render: (value): JSX.Element => {
const objectKeys = value ? Object.keys(value) : [];
const withOutSeverityKeys = objectKeys.filter((e) => e !== 'severity');
if (withOutSeverityKeys.length === 0) {
return <Typography>-</Typography>;
}
return (
<LabelColumn labels={withOutSeverityKeys} value={value} color="magenta" />
);
},
},
];
if (action) {
columns.push({
title: 'Action',
dataIndex: 'id',
key: 'action',
width: 10,
render: (id: RuletypesRuleDTO['id'], record): JSX.Element => (
<div data-testid="alert-actions">
<DropDown
onDropDownItemClick={(item): void =>
alertActionLogEvent(item.key, record)
}
element={[
<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"
onClick={(): void => onEditHandler(record, { newTab: true })}
type="link"
loading={editLoader}
>
Edit in New Tab
</ColumnButton>,
<ColumnButton
key="3"
onClick={onCloneHandler(record)}
type="link"
loading={cloneLoader}
>
Clone
</ColumnButton>,
<DeleteAlert
key="4"
notifications={notificationsApi}
setData={setData}
id={id ?? ''}
/>,
]}
/>
</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,32 +0,0 @@
import { Tag } from 'antd';
import type { RuletypesRuleDTO } from 'api/generated/services/sigNoz.schemas';
function Status({ status }: StatusProps): JSX.Element {
switch (status) {
case 'inactive': {
return <Tag color="green">OK</Tag>;
}
case 'pending': {
return <Tag color="orange">Pending</Tag>;
}
case 'firing': {
return <Tag color="red">Firing</Tag>;
}
case 'disabled': {
return <Tag>Disabled</Tag>;
}
default: {
return <Tag color="default">Unknown</Tag>;
}
}
}
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: new Date('2024-01-01T00:00:00Z'),
createdBy: 'test-user',
updatedAt: new Date('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,172 @@
import { 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 handleClone = (): 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',
},
);
};
const handleDelete = (): 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',
},
);
};
const handleToggle = (): 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',
},
);
};
const menuItems = useMemo(
() => [
{
key: 'toggle',
label: rule.disabled ? 'Enable' : 'Disable',
disabled: externalLoading,
onClick: handleToggle,
},
{
key: 'edit',
label: 'Edit',
disabled: externalLoading,
onClick: (): void => {
alertActionLogEvent(ALERT_ACTIONS.EDIT, rule);
onEdit(rule);
},
},
{
key: 'edit-new-tab',
label: 'Edit in New Tab',
disabled: externalLoading,
onClick: (): void => {
alertActionLogEvent(ALERT_ACTIONS.EDIT, rule);
onEdit(rule, { newTab: true });
},
},
{
key: 'clone',
label: 'Clone',
disabled: externalLoading,
onClick: handleClone,
},
{ key: 'divider', type: 'divider' as const },
{
key: 'delete',
label: 'Delete',
disabled: externalLoading,
danger: true,
onClick: handleDelete,
},
],
// eslint-disable-next-line react-hooks/exhaustive-deps
[rule, externalLoading, onEdit],
);
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,46 @@
import {
Options,
parseAsInteger,
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 useAlertRulesPage = (): UseQueryStateReturn<number, number> =>
useQueryState(
ALERT_RULES_PARAMS.PAGE,
parseAsInteger.withDefault(1).withOptions(defaultNuqsOptions),
);
export const useAlertRulesRuleType = (): UseQueryStateReturn<
string[],
string[]
> =>
useQueryState(
ALERT_RULES_PARAMS.RULE_TYPE,
parseAsJsonNoValidate<string[]>()
.withDefault([])
.withOptions(defaultNuqsOptions),
);
export const useAlertRulesFilters = (): UseQueryStateReturn<
string[],
string[]
> =>
useQueryState(
ALERT_RULES_PARAMS.FILTERS,
parseAsJsonNoValidate<string[]>()
.withDefault([])
.withOptions(defaultNuqsOptions),
);

View File

@@ -1,67 +1,194 @@
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 { searchText, debouncedSearch, handleSearchChange, clearSearch } =
useUrlSearchState(ALERT_RULES_PARAMS.SEARCH);
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const { containerRef, calculatedPageSize } = useCalculatedPageSize({
rowHeight: 42,
});
const { orderBy, page, limit, setLimit } = useTableParams(
QUERY_PARAMS_CONFIG,
{
page: DEFAULT_PAGE,
limit: DEFAULT_LIMIT,
storageKey: 'alert-rules',
calculatedPageSize,
cleanupOnUnmount: true,
},
);
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
/>
)}
</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,154 @@
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: { default: '100%' },
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>{String(value ?? '-')}</TanStackTable.Text>
),
},
{
id: 'severity',
header: 'Severity',
accessorFn: (row) => row.labels?.severity ?? '',
width: { default: '100%' },
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.ALERT_TYPE_SELECTION, {
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,137 +0,0 @@
import { useCallback, useMemo, useRef } from 'react';
import type { SelectProps } from 'antd';
import { Tag, Tooltip } from 'antd';
import type { BaseOptionType } from 'antd/es/select';
import { Alerts } from 'types/api/alerts/getTriggered';
import { Container, Select } from './styles';
function TextOverflowTooltip({
option,
}: {
option: BaseOptionType;
}): JSX.Element {
const contentRef = useRef<HTMLDivElement | null>(null);
const isOverflow = contentRef.current
? contentRef.current?.offsetWidth < contentRef.current?.scrollWidth
: false;
return (
<Tooltip
placement="left"
title={option.value}
{...(!isOverflow ? { open: false } : {})}
>
<div className="ant-select-item-option-content" ref={contentRef}>
{option.value}
</div>
</Tooltip>
);
}
function Filter({
onSelectedFilterChange,
onSelectedGroupChange,
allAlerts,
selectedGroup,
selectedFilter,
}: FilterProps): JSX.Element {
const onChangeSelectGroupHandler = useCallback(
(value: unknown) => {
if (typeof value === 'object' && Array.isArray(value)) {
onSelectedGroupChange(
value.map((e) => ({
value: e,
})),
);
}
},
[onSelectedGroupChange],
);
const onChangeSelectedFilterHandler = useCallback(
(value: unknown) => {
if (typeof value === 'object' && Array.isArray(value)) {
onSelectedFilterChange(
value.map((e) => ({
value: e,
})),
);
}
},
[onSelectedFilterChange],
);
const uniqueLabels: Array<string> = useMemo(() => {
const allLabelsSet = new Set<string>();
allAlerts.forEach((e) => {
if (!e.labels) {
return;
}
Object.keys(e.labels).forEach((e) => {
allLabelsSet.add(e);
});
});
return [...allLabelsSet];
}, [allAlerts]);
const options = uniqueLabels.map((e) => ({
value: e,
title: '',
}));
const getTags: SelectProps['tagRender'] = (props): JSX.Element => {
const { closable, onClose, label } = props;
return (
<Tag
color="magenta"
closable={closable}
onClose={onClose}
style={{ marginRight: 3 }}
>
{label}
</Tag>
);
};
return (
<Container>
<Select
allowClear
onChange={onChangeSelectedFilterHandler}
mode="tags"
value={selectedFilter.map((e) => e.value)}
placeholder="Filter by Tags - e.g. severity:warning, alertname:Sample Alert"
tagRender={(props): JSX.Element => getTags(props)}
options={[]}
/>
<Select
allowClear
onChange={onChangeSelectGroupHandler}
mode="tags"
defaultValue={selectedGroup.map((e) => e.value)}
showArrow
placeholder="Group by any tag"
tagRender={(props): JSX.Element => getTags(props)}
options={options}
optionRender={(option): JSX.Element => (
<TextOverflowTooltip option={option} />
)}
/>
</Container>
);
}
interface FilterProps {
onSelectedFilterChange: (value: Array<Value>) => void;
onSelectedGroupChange: (value: Array<Value>) => void;
allAlerts: Alerts[];
selectedGroup: Array<Value>;
selectedFilter: Array<Value>;
}
export interface Value {
value: string;
}
export default Filter;

View File

@@ -1,77 +0,0 @@
import { Tag } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { useTimezone } from 'providers/Timezone';
import { Alerts } from 'types/api/alerts/getTriggered';
import Status from '../TableComponents/AlertStatus';
import { TableCell, TableRow } from './styles';
function ExapandableRow({ allAlerts }: ExapandableRowProps): JSX.Element {
const { formatTimezoneAdjustedTimestamp } = useTimezone();
return (
<>
{allAlerts.map((alert) => {
const { labels = {} } = alert;
const labelsObject = Object.keys(labels);
const tags = labelsObject.filter((e) => e !== 'severity');
const formatedDate = new Date(alert.startsAt);
return (
<TableRow
bodyStyle={{
minHeight: '5rem',
marginLeft: '2rem',
}}
translate="yes"
hoverable
key={alert.fingerprint}
>
<TableCell minWidth="90px">
<Status severity={alert.status.state} />
</TableCell>
<TableCell minWidth="90px" overflowX="scroll">
<Typography>{labels.alertname || '-'}</Typography>
</TableCell>
<TableCell minWidth="90px">
<Typography>{labels.severity || '-'}</Typography>
</TableCell>
<TableCell minWidth="90px">
<Typography>{`${formatTimezoneAdjustedTimestamp(
formatedDate,
DATE_TIME_FORMATS.UTC_US,
)}`}</Typography>
</TableCell>
<TableCell minWidth="90px" overflowX="scroll">
<div>
{tags.map((e) => (
<Tag key={e}>{`${e}:${labels[e]}`}</Tag>
))}
</div>
</TableCell>
{/* <TableCell>
<TableHeaderContainer>
<Button type="link">Edit</Button>
<Button type="link">Delete</Button>
<Button type="link">Pause</Button>
</TableHeaderContainer>
</TableCell> */}
</TableRow>
);
})}
</>
);
}
interface ExapandableRowProps {
allAlerts: Alerts[];
}
export default ExapandableRow;

View File

@@ -1,54 +0,0 @@
import { useState } from 'react';
import { SquareMinus, SquarePlus } from '@signozhq/icons';
import { Tag } from 'antd';
import { Alerts } from 'types/api/alerts/getTriggered';
import ExapandableRow from './ExapandableRow';
import { IconContainer, StatusContainer, TableCell, TableRow } from './styles';
function TableRowComponent({
tags,
tagsAlert,
}: TableRowComponentProps): JSX.Element {
const [isClicked, setIsClicked] = useState<boolean>(false);
const onClickHandler = (): void => {
setIsClicked((state) => !state);
};
return (
<div>
<TableRow>
<TableCell minWidth="90px">
<StatusContainer>
<IconContainer onClick={onClickHandler}>
{!isClicked ? <SquarePlus size="md" /> : <SquareMinus size="md" />}
</IconContainer>
<>
{tags.map((tag) => (
<Tag color="magenta" key={tag}>
{tag}
</Tag>
))}
</>
</StatusContainer>
</TableCell>
<TableCell minWidth="90px" />
<TableCell minWidth="90px" />
<TableCell minWidth="90px" />
<TableCell minWidth="90px" />
{/* <TableCell minWidth="200px">
<Button type="primary">Resume Group</Button>
</TableCell> */}
</TableRow>
{isClicked && <ExapandableRow allAlerts={tagsAlert} />}
</div>
);
}
interface TableRowComponentProps {
tags: string[];
tagsAlert: Alerts[];
}
export default TableRowComponent;

View File

@@ -1,77 +0,0 @@
import { useMemo } from 'react';
import groupBy from 'lodash-es/groupBy';
import { Alerts } from 'types/api/alerts/getTriggered';
import { Value } from '../Filter';
import { FilterAlerts } from '../utils';
import { Container, TableHeader, TableHeaderContainer } from './styles';
import TableRowComponent from './TableRow';
function FilteredTable({
selectedGroup,
allAlerts,
selectedFilter,
}: FilteredTableProps): JSX.Element {
const allGroupsAlerts = useMemo(
() =>
groupBy(FilterAlerts(allAlerts, selectedFilter), (obj) =>
selectedGroup.map((e) => obj.labels?.[`${e.value}`]).join('+'),
),
[selectedGroup, allAlerts, selectedFilter],
);
const tags = Object.keys(allGroupsAlerts);
const tagsAlerts = Object.values(allGroupsAlerts);
const headers = [
'Status',
'Alert Name',
'Severity',
'Firing Since',
'Tags',
// 'Actions',
];
return (
<Container>
<TableHeaderContainer>
{headers.map((header) => (
<TableHeader key={header} minWidth="90px">
{header}
</TableHeader>
))}
</TableHeaderContainer>
{tags.map((e, index) => {
const tagsValue = e.split('+').filter((e) => e);
const tagsAlert: Alerts[] = tagsAlerts[index];
if (tagsAlert.length === 0) {
return null;
}
const { labels = {} } = tagsAlert[0];
const keysArray = Object.keys(labels);
const valueArray: string[] = [];
keysArray.forEach((e) => {
valueArray.push(labels[e]);
});
const tags = tagsValue
.map((e) => keysArray[valueArray.findIndex((value) => value === e) || 0])
.map((e, index) => `${e}:${tagsValue[index]}`);
return <TableRowComponent key={e} tagsAlert={tagsAlert} tags={tags} />;
})}
</Container>
);
}
interface FilteredTableProps {
selectedGroup: Value[];
allAlerts: Alerts[];
selectedFilter: Value[];
}
export default FilteredTable;

View File

@@ -1,70 +0,0 @@
import { Card } from 'antd';
import styled from 'styled-components';
export const TableHeader = styled(Card)<Props>`
&&& {
flex: 1;
text-align: center;
.ant-card-body {
padding: 1rem;
}
min-width: ${(props): string => props.minWidth || ''};
}
`;
export const TableHeaderContainer = styled.div`
display: flex;
`;
export const Container = styled.div`
&&& {
display: flex;
margin-top: 1rem;
flex-direction: column;
}
`;
export const TableRow = styled(Card)`
&&& {
flex: 1;
.ant-card-body {
padding: 0rem;
display: flex;
min-height: 3rem;
}
}
`;
interface Props {
minWidth?: string;
overflowX?: string;
}
export const TableCell = styled.div<Props>`
&&& {
flex: 1;
min-width: ${(props): string => props.minWidth || ''};
display: flex;
justify-content: flex-start;
align-items: center;
overflow-x: ${(props): string => props.overflowX || 'none'};
::-webkit-scrollbar {
height: ${(props): string => (props.overflowX ? '2px' : '8px')};
}
}
`;
export const StatusContainer = styled.div`
&&& {
display: flex;
align-items: center;
height: 100%;
}
`;
export const IconContainer = styled.div`
&&& {
margin-left: 1rem;
margin-right: 1rem;
}
`;

View File

@@ -1,110 +0,0 @@
import { TableColumnsType as ColumnsType } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { ResizeTable } from 'components/ResizeTable';
import LabelColumn from 'components/TableRenderer/LabelColumn';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import AlertStatus from 'container/TriggeredAlerts/TableComponents/AlertStatus';
import { useTimezone } from 'providers/Timezone';
import { Alerts } from 'types/api/alerts/getTriggered';
import { Value } from './Filter';
import { FilterAlerts } from './utils';
const severitySorter = (a: Alerts, b: Alerts): number => {
const severityLengthOfA = a.labels?.severity?.length || 0;
const severityLengthOfB = b.labels?.severity?.length || 0;
return severityLengthOfB - severityLengthOfA;
};
function NoFilterTable({
allAlerts,
selectedFilter,
}: NoFilterTableProps): JSX.Element {
const filteredAlerts = FilterAlerts(allAlerts, selectedFilter);
const { formatTimezoneAdjustedTimestamp } = useTimezone();
// need to add the filter
const columns: ColumnsType<Alerts> = [
{
title: 'Status',
dataIndex: 'status',
width: 80,
key: 'status',
sorter: (a, b): number => severitySorter(a, b),
render: (value): JSX.Element => <AlertStatus severity={value.state} />,
},
{
title: 'Alert Name',
dataIndex: 'labels',
key: 'alertName',
width: 100,
sorter: (a, b): number =>
(a.labels?.alertname?.charCodeAt(0) || 0) -
(b.labels?.alertname?.charCodeAt(0) || 0),
render: (data): JSX.Element => {
const name = data?.alertname || '';
return <Typography>{name}</Typography>;
},
},
{
title: 'Tags',
dataIndex: 'labels',
key: 'tags',
width: 100,
render: (labels): JSX.Element => {
const objectKeys = Object.keys(labels);
const withOutSeverityKeys = objectKeys.filter((e) => e !== 'severity');
if (withOutSeverityKeys.length === 0) {
return <Typography>-</Typography>;
}
return (
<LabelColumn labels={withOutSeverityKeys} value={labels} color="magenta" />
);
},
},
{
title: 'Severity',
dataIndex: 'labels',
key: 'severity',
width: 100,
sorter: (a, b): number => severitySorter(a, b),
render: (value): JSX.Element => {
const objectKeys = Object.keys(value);
const withSeverityKey = objectKeys.find((e) => e === 'severity') || '';
const severityValue = value[withSeverityKey];
return <Typography>{severityValue}</Typography>;
},
},
{
title: 'Firing Since',
dataIndex: 'startsAt',
width: 100,
sorter: (a, b): number =>
new Date(a.startsAt).getTime() - new Date(b.startsAt).getTime(),
render: (date): JSX.Element => (
<Typography>{`${formatTimezoneAdjustedTimestamp(
date,
DATE_TIME_FORMATS.UTC_US,
)}`}</Typography>
),
},
];
return (
<ResizeTable
columns={columns}
rowKey={(record): string => `${record.startsAt}-${record.fingerprint}`}
dataSource={filteredAlerts}
/>
);
}
interface NoFilterTableProps {
allAlerts: Alerts[];
selectedFilter: Value[];
}
export default NoFilterTable;

View File

@@ -1,27 +0,0 @@
import { Tag } from 'antd';
function Severity({ severity }: SeverityProps): JSX.Element {
switch (severity) {
case 'unprocessed': {
return <Tag color="green">UnProcessed</Tag>;
}
case 'active': {
return <Tag color="red">Firing</Tag>;
}
case 'suppressed': {
return <Tag color="red">Suppressed</Tag>;
}
default: {
return <Tag color="default">Unknown Status</Tag>;
}
}
}
interface SeverityProps {
severity: string;
}
export default Severity;

View File

@@ -1,56 +0,0 @@
import { Alerts } from 'types/api/alerts/getTriggered';
import Filter, { Value } from './Filter';
import FilteredTable from './FilteredTable';
import NoFilterTable from './NoFilterTable';
import { NoTableContainer } from './styles';
function TriggeredAlerts({
allAlerts,
selectedFilter,
selectedGroup,
onSelectedFilterChange,
onSelectedGroupChange,
}: TriggeredAlertsProps): JSX.Element {
return (
<div>
<Filter
allAlerts={allAlerts}
selectedFilter={selectedFilter}
selectedGroup={selectedGroup}
onSelectedFilterChange={onSelectedFilterChange}
onSelectedGroupChange={onSelectedGroupChange}
/>
{selectedFilter.length === 0 && selectedGroup.length === 0 ? (
<NoTableContainer>
<NoFilterTable selectedFilter={selectedFilter} allAlerts={allAlerts} />
</NoTableContainer>
) : (
<div>
{selectedFilter.length !== 0 && selectedGroup.length === 0 ? (
<NoTableContainer>
<NoFilterTable selectedFilter={selectedFilter} allAlerts={allAlerts} />
</NoTableContainer>
) : (
<FilteredTable
allAlerts={allAlerts}
selectedFilter={selectedFilter}
selectedGroup={selectedGroup}
/>
)}
</div>
)}
</div>
);
}
interface TriggeredAlertsProps {
allAlerts: Alerts[];
selectedFilter: Array<Value>;
selectedGroup: Array<Value>;
onSelectedFilterChange: (value: Array<Value>) => void;
onSelectedGroupChange: (value: Array<Value>) => void;
}
export default TriggeredAlerts;

View File

@@ -0,0 +1,123 @@
.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: space-between;
align-items: flex-start;
gap: 1rem;
flex-shrink: 0;
padding: 0 var(--spacing-8);
}
.filtersRow {
position: relative;
z-index: 10;
display: flex;
align-items: center;
gap: 1rem;
flex-shrink: 0;
padding: 0 var(--spacing-8);
--combobox-trigger-height: 2rem;
}
.searchInput {
min-width: 250px;
}
.filterSelect {
min-width: 300px;
flex: 1;
}
.tableContainer {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
--tanstack-table-header-cell-bg: var(--l2-background);
--tanstack-table-header-cell-color: var(--l2-foreground);
--tanstack-table-cell-bg: var(--l2-background);
--tanstack-table-cell-color: var(--l2-foreground);
--tanstack-table-row-hover-bg: var(--l2-background-hover);
--tanstack-table-row-active-bg: var(--l2-background-active);
--tanstack-table-resize-handle-bg: var(--l2-background);
--tanstack-table-resize-handle-hover-bg: var(--l2-border);
--tanstack-table-row-height: 42px;
--tanstack-cell-padding-top-override: 5px;
--tanstack-cell-padding-bottom-override: 5px;
--tanstack-cell-padding-left-override: 5px;
--tanstack-cell-padding-right-override: 5px;
--tanstack-cell-padding-left-override: 16px;
--tanstack-cell-padding-right-override: 16px;
--tanstack-table-row-odd-bg: color-mix(
in srgb,
var(--l1-foreground) 2%,
transparent
);
--tanstack-table-row-even-bg: color-mix(
in srgb,
var(--l1-foreground) 1%,
transparent
);
--tanstack-expansion-first-col-padding-left: 20px;
--badge-cursor: pointer;
}
.groupHeader {
display: flex;
align-items: center;
gap: 6px;
}
.groupCell {
display: flex;
align-items: center;
gap: 8px;
}
.tagsContainer {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.searchIcon {
color: var(--l2-foreground);
}
.filterBadge {
display: inline-flex;
align-items: center;
gap: 4px;
margin-right: 3px;
}
.filterBadgeClose {
cursor: pointer;
opacity: 0.7;
transition: opacity 0.15s ease;
&:hover {
opacity: 1;
}
}
.paginationContainer {
padding-right: var(--spacing-12);
height: 62px;
}

View File

@@ -1,84 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react';
import NoFilterTable from '../NoFilterTable';
import { createAlert } from './mockUtils';
jest.mock('providers/Timezone', () => ({
useTimezone: jest.requireActual('./mockUtils').useMockTimezone,
}));
const allAlerts = [
createAlert({
name: 'Alert B',
labels: {
severity: 'warning',
alertname: 'Alert B',
},
}),
createAlert({
name: 'Alert C',
labels: {
severity: 'info',
alertname: 'Alert C',
},
}),
createAlert({
name: 'Alert A',
labels: {
severity: 'critical',
alertname: 'Alert A',
},
}),
];
describe('NoFilterTable', () => {
it('should render the no filter table with correct rows', () => {
render(<NoFilterTable allAlerts={allAlerts} selectedFilter={[]} />);
const rows = screen.getAllByRole('row');
expect(rows).toHaveLength(4); // 1 header row + 2 data rows
const [headerRow, dataRow1, dataRow2, dataRow3] = rows;
// Verify header row
expect(headerRow).toHaveTextContent('Status');
expect(headerRow).toHaveTextContent('Alert Name');
expect(headerRow).toHaveTextContent('Tags');
expect(headerRow).toHaveTextContent('Severity');
expect(headerRow).toHaveTextContent('Firing Since');
// Verify 1st data row
expect(dataRow1).toHaveTextContent('Alert B');
// Verify 2nd data row
expect(dataRow2).toHaveTextContent('Alert C');
// Verify 3rd data row
expect(dataRow3).toHaveTextContent('Alert A');
});
it('should sort the table by severity when header is clicked', () => {
render(<NoFilterTable allAlerts={allAlerts} selectedFilter={[]} />);
const headers = screen.getAllByRole('columnheader');
const severityHeader = headers.find((header) =>
header.textContent?.includes('Severity'),
);
expect(severityHeader).toBeInTheDocument();
if (severityHeader) {
const initialRows = screen.getAllByRole('row');
expect(initialRows).toHaveLength(4);
expect(initialRows[1]).toHaveTextContent('Alert B');
expect(initialRows[2]).toHaveTextContent('Alert C');
expect(initialRows[3]).toHaveTextContent('Alert A');
fireEvent.click(severityHeader);
const sortedRows = screen.getAllByRole('row');
expect(sortedRows).toHaveLength(4);
expect(sortedRows[1]).toHaveTextContent('Alert A');
expect(sortedRows[2]).toHaveTextContent('Alert B');
expect(sortedRows[3]).toHaveTextContent('Alert C');
}
});
});

View File

@@ -1,53 +0,0 @@
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { Alerts } from 'types/api/alerts/getTriggered';
export function createAlert(overrides: Partial<Alerts> = {}): Alerts {
return {
labels: undefined,
annotations: {
description: 'Test Description',
summary: 'Test Summary',
},
state: 'firing',
name: 'Test Alert',
id: 1,
endsAt: '2021-01-02T00:00:00Z',
fingerprint: '1234567890',
generatorURL: 'https://test.com',
receivers: [{ name: 'Test Receiver' }],
startsAt: '2021-01-03T00:00:00Z',
status: {
inhibitedBy: [],
silencedBy: [],
state: 'firing',
},
updatedAt: '2021-01-01T00:00:00Z',
...overrides,
};
}
export function useMockTimezone(): {
timezone: Timezone;
browserTimezone: Timezone;
updateTimezone: (timezone: Timezone) => void;
formatTimezoneAdjustedTimestamp: (input: string, format?: string) => string;
isAdaptationEnabled: boolean;
setIsAdaptationEnabled: (enabled: boolean) => void;
} {
const mockTimezone: Timezone = {
name: 'timezone',
value: 'mock-timezone',
offset: '+1.30',
searchIndex: '1',
};
return {
timezone: mockTimezone,
browserTimezone: mockTimezone,
updateTimezone: jest.fn(),
formatTimezoneAdjustedTimestamp: jest
.fn()
.mockImplementation((date: string) => new Date(date).toISOString()),
isAdaptationEnabled: true,
setIsAdaptationEnabled: jest.fn(),
};
}

View File

@@ -1,85 +0,0 @@
import type { Value } from '../Filter';
import { FilterAlerts } from '../utils';
import { createAlert } from './mockUtils';
describe('FilterAlerts', () => {
it('returns all alerts when no filters are selected', () => {
const alerts = [
createAlert({ fingerprint: 'fp-1' }),
createAlert({ fingerprint: 'fp-2' }),
];
const filters: Value[] = [];
const result = FilterAlerts(alerts, filters);
expect(result).toBe(alerts);
});
it('filters alerts that have matching label key and value', () => {
const warningAlert = createAlert({
fingerprint: 'warning',
labels: { severity: 'warning' },
});
const criticalAlert = createAlert({
fingerprint: 'critical',
labels: { severity: 'critical' },
});
const alerts = [warningAlert, criticalAlert];
const filters: Value[] = [{ value: 'severity:critical' }];
const result = FilterAlerts(alerts, filters);
expect(result).toStrictEqual([criticalAlert]);
});
it('includes alerts when any filter matches', () => {
const severityAlert = createAlert({
fingerprint: 'severity',
labels: { severity: 'warning' },
});
const teamAlert = createAlert({
fingerprint: 'team',
labels: { team: 'core-observability' },
});
const otherAlert = createAlert({
fingerprint: 'other',
labels: { service: 'ingestor' },
});
const alerts = [severityAlert, teamAlert, otherAlert];
const filters: Value[] = [
{ value: 'severity:warning' },
{ value: 'team:core-observability' },
];
const result = FilterAlerts(alerts, filters);
expect(result).toHaveLength(2);
expect(result).toStrictEqual([severityAlert, teamAlert]);
});
it('matches labels even when filters contain surrounding whitespace', () => {
const alert = createAlert({
fingerprint: 'trim-test',
labels: { severity: 'critical' },
});
const alerts = [alert];
const filters: Value[] = [{ value: ' severity : critical ' }];
const result = FilterAlerts(alerts, filters);
expect(result).toStrictEqual([alert]);
});
it('ignores filters that do not contain a key/value delimiter', () => {
const alert = createAlert({
fingerprint: 'invalid-filter',
labels: { severity: 'warning' },
});
const alerts = [alert];
const filters: Value[] = [{ value: 'severitywarning' }];
const result = FilterAlerts(alerts, filters);
expect(result).toStrictEqual([]);
});
});

View File

@@ -0,0 +1,36 @@
import { Badge } from '@signozhq/ui/badge';
interface AlertStatusTagProps {
state: string;
}
function AlertStatusTag({ state }: AlertStatusTagProps): JSX.Element {
switch (state) {
case 'unprocessed':
return (
<Badge color="success" variant="outline">
Unprocessed
</Badge>
);
case 'active':
return (
<Badge color="error" variant="outline">
Firing
</Badge>
);
case 'suppressed':
return (
<Badge color="error" variant="outline">
Suppressed
</Badge>
);
default:
return (
<Badge color="secondary" variant="outline">
Unknown
</Badge>
);
}
}
export default AlertStatusTag;

View File

@@ -0,0 +1,36 @@
.emptyState {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 24px;
text-align: center;
gap: 16px;
}
.emptyStateIcon {
font-size: 48px;
color: var(--bg-forest-500);
}
.emptyStateIconMuted {
font-size: 48px;
color: var(--l2-foreground);
}
.emptyStateTitle {
font-size: 18px;
font-weight: 600;
color: var(--l1-foreground);
}
.emptyStateSubtitle {
font-size: 14px;
color: var(--l2-foreground);
max-width: 400px;
}
.emptyStateActions {
display: flex;
gap: 8px;
}

View File

@@ -0,0 +1,49 @@
import { useCallback } from 'react';
import { CircleCheck, Plus, RefreshCw } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import ROUTES from 'constants/routes';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import styles from './EmptyStates.module.scss';
interface EmptyStateProps {
onRefresh?: () => void;
}
export function EmptyState({ onRefresh }: EmptyStateProps): JSX.Element {
const { safeNavigate } = useSafeNavigate();
const handleCreateAlert = useCallback((): void => {
safeNavigate(ROUTES.ALERTS_NEW);
}, [safeNavigate]);
return (
<div className={styles.emptyState}>
<CircleCheck className={styles.emptyStateIcon} size={16} />
<div className={styles.emptyStateTitle}>No alerts firing</div>
<div className={styles.emptyStateSubtitle}>
All systems are healthy. No alerts are currently triggered.
</div>
<div className={styles.emptyStateActions}>
<Button
variant="solid"
color="primary"
prefix={<Plus size={14} />}
onClick={handleCreateAlert}
>
Create Alert Rule
</Button>
{onRefresh && (
<Button
variant="outlined"
color="secondary"
prefix={<RefreshCw size={14} />}
onClick={onRefresh}
>
Refresh
</Button>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,31 @@
.expandedRowContainer {
overflow-x: auto;
--tanstack-table-header-cell-bg: var(--l1-background);
--tanstack-table-header-cell-color: var(--l1-foreground);
--tanstack-table-cell-bg: var(--l1-background);
--tanstack-table-cell-color: var(--l1-foreground);
--tanstack-table-row-hover-bg: var(--l1-background-hover);
--tanstack-table-row-active-bg: var(--l1-background-active);
--tanstack-table-resize-handle-bg: var(--l1-background);
--tanstack-table-resize-handle-hover-bg: var(--l1-border);
--tanstack-table-row-height: 36px;
--tanstack-cell-padding-left-override: 15px;
--tanstack-cell-padding-right-override: 15px;
th {
position: unset;
}
background-color: var(--l1-background);
}
.expandedTable {
min-height: 290px;
}
.expandedPagination {
padding-right: var(--spacing-8);
min-height: 62px;
}

View File

@@ -0,0 +1,79 @@
import { useCallback, useMemo, useState } from 'react';
import TanStackTable, {
SortState,
TableColumnDef,
} from 'components/TanStackTableView';
import type { Alert } from '../types';
import { sortAlerts } from '../utils';
import styles from './ExpandedAlertsTable.module.scss';
const EXPANDED_PAGE_SIZE = 5;
interface ExpandedAlertsTableProps {
alerts: Alert[];
columns: TableColumnDef<Alert>[];
onRowClick: (alert: Alert) => void;
onRowClickNewTab: (alert: Alert) => void;
isLoading?: boolean;
}
function ExpandedAlertsTable({
alerts,
columns,
onRowClick,
onRowClickNewTab,
isLoading,
}: ExpandedAlertsTableProps): JSX.Element {
const [page, setPage] = useState(1);
const [orderBy, setOrderBy] = useState<SortState | null>(null);
const handlePageChange = useCallback((newPage: number) => {
setPage(newPage);
}, []);
const handleSort = useCallback((sort: SortState | null) => {
setOrderBy(sort);
setPage(1);
}, []);
const sortedAlerts = useMemo(
() => sortAlerts(alerts, orderBy),
[alerts, orderBy],
);
const paginatedAlerts = useMemo(() => {
const start = (page - 1) * EXPANDED_PAGE_SIZE;
return sortedAlerts.slice(start, start + EXPANDED_PAGE_SIZE);
}, [sortedAlerts, page]);
return (
<div className={styles.expandedRowContainer}>
<TanStackTable<Alert>
className={styles.expandedTable}
data={paginatedAlerts}
columns={columns}
isLoading={isLoading}
getRowKey={(row): string => row.fingerprint ?? ''}
getItemKey={(row): string => row.fingerprint ?? ''}
onRowClick={onRowClick}
onRowClickNewTab={onRowClickNewTab}
onSort={handleSort}
disableVirtualScroll
pagination={{
total: alerts.length,
defaultPage: page,
defaultLimit: EXPANDED_PAGE_SIZE,
showTotalCount: true,
totalCountLabel: 'Alerts',
showPageSize: false,
onPageChange: handlePageChange,
}}
paginationClassname={styles.expandedPagination}
enableAlternatingRowColors
/>
</div>
);
}
export default ExpandedAlertsTable;

View File

@@ -0,0 +1,34 @@
import { Options, useQueryState, UseQueryStateReturn } from 'nuqs';
import { parseAsJsonNoValidate } from 'utils/nuqsParsers';
const defaultNuqsOptions: Options = {
history: 'push',
};
export const TRIGGERED_ALERTS_PARAMS = {
FILTERS: 'alertFilters',
GROUP_BY: 'alertGroupBy',
SEARCH: 'alertSearch',
} as const;
export const useTriggeredAlertsFilters = (): UseQueryStateReturn<
string[],
string[]
> =>
useQueryState(
TRIGGERED_ALERTS_PARAMS.FILTERS,
parseAsJsonNoValidate<string[]>()
.withDefault([])
.withOptions(defaultNuqsOptions),
);
export const useTriggeredAlertsGroupBy = (): UseQueryStateReturn<
string[],
string[]
> =>
useQueryState(
TRIGGERED_ALERTS_PARAMS.GROUP_BY,
parseAsJsonNoValidate<string[]>()
.withDefault([])
.withOptions(defaultNuqsOptions),
);

View File

@@ -1,82 +1,247 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useQuery } from 'react-query';
import getTriggeredApi from 'api/alerts/getTriggered';
import logEvent from 'api/common/logEvent';
import Spinner from 'components/Spinner';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import useAxiosError from 'hooks/useAxiosError';
import { isUndefined } from 'lodash-es';
import { useAppContext } from 'providers/App/App';
import type { ReactNode } from 'react';
import { useCallback, useMemo } from 'react';
import { Search } from '@signozhq/icons';
import { Input } from '@signozhq/ui/input';
import { ComboboxSimple, ComboboxSimpleItem } from '@signozhq/ui/combobox';
import ErrorEmptyState from 'components/Alerts/ErrorEmptyState';
import NoResultsEmptyState from 'components/Alerts/NoResultsEmptyState';
import type { FilterValue } from 'components/Alerts/types';
import TanStackTable from 'components/TanStackTableView';
import { useCalculatedPageSize } from 'components/TanStackTableView/useCalculatedPageSize';
import { useTableParams } from 'components/TanStackTableView/useTableParams';
import { useUrlSearchState } from 'hooks/useUrlSearchState';
import { useTimezone } from 'providers/Timezone';
import { Value } from './Filter';
import TriggerComponent from './TriggeredAlert';
import { EmptyState } from './components/EmptyStates';
import ExpandedAlertsTable from './components/ExpandedAlertsTable';
import {
TRIGGERED_ALERTS_PARAMS,
useTriggeredAlertsFilters,
useTriggeredAlertsGroupBy,
} from './hooks';
import { getAlertColumns, groupedColumns } from './table.config';
import styles from './TriggeredAlerts.module.scss';
import type { Alert, GroupedAlert } from './types';
import { useTriggeredAlertsData } from './useTriggeredAlertsData';
import { useTriggeredAlertsHandlers } from './useTriggeredAlertsHandlers';
const QUERY_PARAMS_CONFIG = {
orderBy: 'orderBy',
page: 'page',
limit: 'limit',
} as const;
const DEFAULT_PAGE = 1;
const DEFAULT_LIMIT = 10;
const severyFilters: ComboboxSimpleItem[] = [
{
value: 'severity:critical',
label: 'Critical (severity:critical)',
displayValue: 'Critical',
},
{
value: 'severity:error',
label: 'Error (severity:error)',
displayValue: 'Error',
},
{
value: 'severity:warning',
label: 'Warning (severity:warning)',
displayValue: 'Warning',
},
{
value: 'severity:info',
label: 'Info (severity:info)',
displayValue: 'Info',
},
];
function TriggeredAlerts(): JSX.Element {
const [selectedGroup, setSelectedGroup] = useState<Value[]>([]);
const [selectedFilter, setSelectedFilter] = useState<Value[]>([]);
const [filterValues, setFilterValues] = useTriggeredAlertsFilters();
const [selectedGroupBy, setSelectedGroupBy] = useTriggeredAlertsGroupBy();
const { searchText, debouncedSearch, handleSearchChange, clearSearch } =
useUrlSearchState(TRIGGERED_ALERTS_PARAMS.SEARCH);
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const { user } = useAppContext();
const { containerRef, calculatedPageSize } = useCalculatedPageSize({
rowHeight: 42,
});
const hasLoggedEvent = useRef(false); // Track if logEvent has been called
const handleError = useAxiosError();
const alertsResponse = useQuery(
[REACT_QUERY_KEY.GET_TRIGGERED_ALERTS, user.id],
const { page, limit, setLimit, orderBy } = useTableParams(
QUERY_PARAMS_CONFIG,
{
queryFn: () =>
getTriggeredApi({
active: true,
inhibited: true,
silenced: false,
}),
refetchInterval: 30000,
onError: handleError,
page: DEFAULT_PAGE,
limit: DEFAULT_LIMIT,
storageKey: 'triggered-alerts',
calculatedPageSize,
cleanupOnUnmount: true,
},
);
const handleSelectedFilterChange = useCallback((newFilter: Value[]) => {
setSelectedFilter(newFilter);
}, []);
const selectedFilter = useMemo(
(): FilterValue[] => (filterValues ?? []).map((v: string) => ({ value: v })),
[filterValues],
);
const handleSelectedGroupChange = useCallback((newGroup: Value[]) => {
setSelectedGroup(newGroup);
}, []);
const {
filteredAlerts,
groupedData,
uniqueLabels,
isFetching,
isError,
isGrouped,
allAlerts,
refetch,
} = useTriggeredAlertsData(
selectedFilter,
selectedGroupBy,
orderBy,
debouncedSearch,
);
useEffect(() => {
if (!hasLoggedEvent.current && !isUndefined(alertsResponse.data?.payload)) {
logEvent('Alert: Triggered alert list page visited', {
number: alertsResponse.data?.payload?.length,
});
hasLoggedEvent.current = true;
}
}, [alertsResponse.data?.payload]);
const handleFilterChange = useCallback(
(values: unknown): void => {
if (Array.isArray(values)) {
void setFilterValues(values.length ? values : null);
}
},
[setFilterValues],
);
if (alertsResponse.error) {
return (
<TriggerComponent
allAlerts={[]}
selectedFilter={selectedFilter}
selectedGroup={selectedGroup}
onSelectedFilterChange={handleSelectedFilterChange}
onSelectedGroupChange={handleSelectedGroupChange}
const { handleGroupByChange, handleRowClick, handleRowClickNewTab } =
useTriggeredAlertsHandlers(setSelectedGroupBy);
const columns = useMemo(
() => getAlertColumns(formatTimezoneAdjustedTimestamp),
[formatTimezoneAdjustedTimestamp],
);
const labelOptions: ComboboxSimpleItem[] = uniqueLabels.map((label) => ({
value: label,
label,
}));
const paginatedAlerts = useMemo(() => {
const start = (page - 1) * limit;
return filteredAlerts.slice(start, start + limit);
}, [filteredAlerts, page, limit]);
const paginatedGroupedData = useMemo(() => {
const start = (page - 1) * limit;
return groupedData.slice(start, start + limit);
}, [groupedData, page, limit]);
const renderExpandedRow = useCallback(
(group: GroupedAlert): ReactNode => (
<ExpandedAlertsTable
alerts={group.alerts}
columns={columns}
onRowClick={handleRowClick}
onRowClickNewTab={handleRowClickNewTab}
isLoading={isFetching}
/>
);
}
),
[columns, handleRowClick, handleRowClickNewTab, isFetching],
);
if (alertsResponse.isFetching || alertsResponse?.data?.payload === undefined) {
return <Spinner height="75vh" tip="Loading Alerts..." />;
}
const hasActiveFilters = selectedFilter.length > 0 || searchText.length > 0;
const isEmptyDueToFilters =
!isFetching &&
filteredAlerts.length === 0 &&
hasActiveFilters &&
allAlerts.length > 0;
const isEmptyNoAlerts = !isFetching && !isError && allAlerts.length === 0;
const handleClearFilters = useCallback((): void => {
void setFilterValues(null);
clearSearch();
}, [setFilterValues, clearSearch]);
return (
<div className="triggered-alerts-container">
<TriggerComponent
allAlerts={alertsResponse?.data?.payload || []}
selectedFilter={selectedFilter}
selectedGroup={selectedGroup}
onSelectedFilterChange={handleSelectedFilterChange}
onSelectedGroupChange={handleSelectedGroupChange}
/>
<div className={styles.container}>
<div className={styles.filtersRow}>
<Input
className={styles.searchInput}
placeholder="Search alerts by name"
value={searchText}
onChange={handleSearchChange}
suffix={<Search size={14} className={styles.searchIcon} />}
/>
<ComboboxSimple
className={styles.filterSelect}
multiple
value={selectedFilter.map((f) => f.value)}
onChange={handleFilterChange}
placeholder="Filter by tags"
inputPlaceholder="Create new filters with 'label:value'"
allowCreate
items={severyFilters}
maxDisplayedPills={2}
/>
<ComboboxSimple
className={styles.filterSelect}
value={selectedGroupBy}
onChange={handleGroupByChange}
placeholder="Group by tag"
inputPlaceholder="Select one or more"
items={labelOptions}
multiple
maxDisplayedPills={2}
/>
</div>
<div ref={containerRef} className={styles.tableContainer}>
{isError ? (
<ErrorEmptyState title="Failed to load alerts" onRefresh={refetch} />
) : isEmptyDueToFilters ? (
<NoResultsEmptyState
title="No matching alerts"
subtitle="No alerts match your current filters. Try adjusting your search criteria."
onClear={handleClearFilters}
/>
) : isEmptyNoAlerts ? (
<EmptyState onRefresh={refetch} />
) : isGrouped ? (
<TanStackTable<GroupedAlert>
data={paginatedGroupedData}
columns={groupedColumns}
isLoading={isFetching}
getRowKey={(row): string => row.groupKey}
getItemKey={(row): string => row.groupKey}
renderExpandedRow={renderExpandedRow}
getRowCanExpand={(): boolean => true}
columnStorageKey="triggered-alerts-grouped-columns"
enableQueryParams={QUERY_PARAMS_CONFIG}
pagination={{
total: groupedData.length,
calculatedPageSize,
onLimitChange: setLimit,
}}
paginationClassname={styles.paginationContainer}
/>
) : (
<TanStackTable<Alert>
data={paginatedAlerts}
columns={columns}
isLoading={isFetching}
getRowKey={(row): string => row.fingerprint ?? ''}
getItemKey={(row): string => row.fingerprint ?? ''}
onRowClick={handleRowClick}
onRowClickNewTab={handleRowClickNewTab}
columnStorageKey="triggered-alerts-columns"
enableQueryParams={QUERY_PARAMS_CONFIG}
pagination={{
total: filteredAlerts.length,
calculatedPageSize,
onLimitChange: setLimit,
}}
paginationClassname={styles.paginationContainer}
enableAlternatingRowColors
/>
)}
</div>
</div>
);
}

View File

@@ -1,29 +0,0 @@
import { Select as SelectComponent } from 'antd';
import styled from 'styled-components';
export const Select = styled(SelectComponent)`
&&& {
min-width: 350px;
}
`;
export const Container = styled.div`
&&& {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 1rem;
}
`;
export const TableContainer = styled.div`
&&& {
margin-top: 2rem;
}
`;
export const NoTableContainer = styled.div`
&&& {
margin-top: 2rem;
}
`;

View File

@@ -0,0 +1,153 @@
import { BellDot, ChevronDown, ChevronRight } from '@signozhq/icons';
import { Badge } from '@signozhq/ui/badge';
import { Button } from '@signozhq/ui/button';
import { SEVERITY_BADGE_COLORS } from 'components/Alerts/constants';
import TanStackTable from 'components/TanStackTableView';
import type { TableColumnDef } from 'components/TanStackTableView';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import AlertStatusTag from './components/AlertStatusTag';
import LabelColumn from 'components/Alerts/LabelColumn';
import styles from './TriggeredAlerts.module.scss';
import type { Alert, GroupedAlert } from './types';
export function getAlertColumns(
formatTimezoneAdjustedTimestamp: (date: string, format: string) => string,
): TableColumnDef<Alert>[] {
return [
{
id: 'status',
header: 'Status',
accessorFn: (row) => row.status?.state,
width: { min: 120, default: 120 },
enableSort: false,
enableMove: false,
cell: ({ value }): JSX.Element => (
<AlertStatusTag state={String(value ?? '')} />
),
},
{
id: 'alertName',
header: 'Alert Name',
accessorFn: (row) => row.labels?.alertname ?? '',
width: { min: 200, default: 330 },
enableSort: true,
enableMove: false,
cell: ({ value }): JSX.Element => (
<TanStackTable.Text>{String(value ?? '-')}</TanStackTable.Text>
),
},
{
id: 'severity',
header: 'Severity',
accessorFn: (row) => row.labels?.severity ?? '',
width: { min: 150, default: 150 },
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: 'firingSince',
header: 'Firing Since',
accessorKey: 'startsAt',
width: { min: 280, default: 280 },
enableSort: true,
enableMove: false,
cell: ({ value }): JSX.Element => (
<TanStackTable.Text>
{value
? formatTimezoneAdjustedTimestamp(String(value), DATE_TIME_FORMATS.UTC_US)
: '-'}
</TanStackTable.Text>
),
},
{
id: 'labels',
header: 'Labels',
accessorKey: 'labels',
width: { min: 200, default: 300 },
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" />;
},
},
];
}
export const groupedColumns: TableColumnDef<GroupedAlert>[] = [
{
id: 'groupTags',
header: (): JSX.Element => (
<div className={styles.groupHeader}>
<BellDot size={14} />
<span>Group</span>
</div>
),
accessorFn: (row) => row.groupKey,
width: { default: '100%' },
enableRemove: false,
enableMove: false,
pin: 'left',
cell: ({ row: groupRow, isExpanded, toggleExpanded }): JSX.Element => {
const tags = Object.entries(groupRow.groupLabels)
.filter(([, v]) => v)
.map(([k, v]) => `${k}:${v}`);
return (
<div className={styles.groupCell}>
<Button
variant="ghost"
color="secondary"
size="icon"
onClick={(e): void => {
e.stopPropagation();
toggleExpanded();
}}
prefix={
isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />
}
/>
<div className={styles.tagsContainer}>
{tags.map((tag) => (
<Badge color="error" key={tag} variant="outline">
{tag}
</Badge>
))}
</div>
</div>
);
},
},
{
id: 'alertCount',
header: 'Alerts',
accessorFn: (row) => row.alerts.length,
width: { min: 80, default: 100 },
enableMove: false,
cell: ({ value }): JSX.Element => (
<TanStackTable.Text>{String(value)}</TanStackTable.Text>
),
},
];

View File

@@ -0,0 +1,10 @@
import type { AlertmanagertypesDeprecatedGettableAlertDTO } from 'api/generated/services/sigNoz.schemas';
export type Alert = AlertmanagertypesDeprecatedGettableAlertDTO;
export interface GroupedAlert {
groupKey: string;
groupLabels: Record<string, string>;
alerts: Alert[];
firstAlert: Alert;
}

View File

@@ -0,0 +1,107 @@
import { useEffect, useMemo, useRef } from 'react';
import logEvent from 'api/common/logEvent';
import { useGetAlerts } from 'api/generated/services/alerts';
import type { FilterValue } from 'components/Alerts/types';
import { filterByLabels, searchByLabels } from 'components/Alerts/utils';
import type { SortState } from 'components/TanStackTableView/types';
import { groupBy as lodashGroupBy, isUndefined } from 'lodash-es';
import type { Alert, GroupedAlert } from './types';
import { normalizeAlerts, sortAlerts } from './utils';
interface UseTriggeredAlertsDataReturn {
allAlerts: Alert[];
filteredAlerts: Alert[];
groupedData: GroupedAlert[];
uniqueLabels: string[];
isFetching: boolean;
isError: boolean;
isGrouped: boolean;
refetch: () => void;
}
const TRIGGERED_ALERTS_REFRESH_INTERVAL = 30_000;
export function useTriggeredAlertsData(
selectedFilter: FilterValue[],
selectedGroupBy: string[],
orderBy: SortState | null,
searchText = '',
): UseTriggeredAlertsDataReturn {
const hasLoggedEvent = useRef(false);
const alertsResponse = useGetAlerts({
query: {
refetchInterval: TRIGGERED_ALERTS_REFRESH_INTERVAL,
},
});
useEffect(() => {
const alerts = alertsResponse.data?.data;
if (!hasLoggedEvent.current && !isUndefined(alerts)) {
logEvent('Alert: Triggered alert list page visited', {
number: alerts?.length,
});
hasLoggedEvent.current = true;
}
}, [alertsResponse.data]);
const allAlerts = useMemo(
() => normalizeAlerts(alertsResponse.data?.data),
[alertsResponse.data],
);
const filteredAlerts = useMemo(() => {
let result = filterByLabels(allAlerts, selectedFilter);
result = searchByLabels(result, searchText, (a) => a.labels?.alertname ?? '');
return sortAlerts(result, orderBy);
}, [allAlerts, selectedFilter, searchText, orderBy]);
const uniqueLabels = useMemo(() => {
const labelsSet = new Set<string>();
allAlerts.forEach((alert) => {
if (alert.labels) {
Object.keys(alert.labels).forEach((key) => labelsSet.add(key));
}
});
return Array.from(labelsSet);
}, [allAlerts]);
const groupedData = useMemo((): GroupedAlert[] => {
if (!selectedGroupBy.length) {
return [];
}
const grouped = lodashGroupBy(filteredAlerts, (alert) =>
selectedGroupBy.map((key) => alert.labels?.[key] ?? '').join('+'),
);
return Object.entries(grouped)
.filter(([, alerts]) => alerts.length > 0)
.map(([groupKey, alerts]) => {
const firstAlert = alerts[0];
const groupLabels: Record<string, string> = {};
selectedGroupBy.forEach((key) => {
groupLabels[key] = firstAlert.labels?.[key] ?? '';
});
return {
groupKey,
groupLabels,
alerts,
firstAlert,
};
});
}, [filteredAlerts, selectedGroupBy]);
return {
allAlerts,
filteredAlerts,
groupedData,
uniqueLabels,
isFetching: alertsResponse.isFetching,
isError: alertsResponse.isError,
isGrouped: selectedGroupBy.length > 0,
refetch: alertsResponse.refetch,
};
}

View File

@@ -0,0 +1,58 @@
import { useCallback } from 'react';
import logEvent from 'api/common/logEvent';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { useTableRowClick } from 'hooks/useTableRowClick';
import type { Alert } from './types';
import { getRuleId } from './utils';
interface UseTriggeredAlertsHandlersReturn {
handleGroupByChange: (values: unknown) => void;
handleRowClick: (alert: Alert) => void;
handleRowClickNewTab: (alert: Alert) => void;
}
export function useTriggeredAlertsHandlers(
setSelectedGroupBy: (groupBy: string[]) => void,
): UseTriggeredAlertsHandlersReturn {
const { safeNavigate } = useSafeNavigate();
const handleGroupByChange = useCallback(
(values: unknown) => {
if (Array.isArray(values)) {
setSelectedGroupBy(values);
}
},
[setSelectedGroupBy],
);
const getAlertUrl = useCallback((alert: Alert): string | null => {
const ruleId = getRuleId(alert);
if (!ruleId) {
return null;
}
return `${ROUTES.ALERT_OVERVIEW}?${QueryParams.ruleId}=${ruleId}`;
}, []);
const onBeforeNavigate = useCallback((alert: Alert): void => {
const ruleId = getRuleId(alert);
logEvent('Alert: Triggered alert clicked', {
ruleId,
alertName: alert.labels?.alertname,
});
}, []);
const { handleRowClick, handleRowClickNewTab } = useTableRowClick<Alert>({
getUrl: getAlertUrl,
onNavigate: safeNavigate,
onBeforeNavigate,
});
return {
handleGroupByChange,
handleRowClick,
handleRowClickNewTab,
};
}

View File

@@ -1,54 +1,78 @@
import { Alerts } from 'types/api/alerts/getTriggered';
import { v4 as uuidv4 } from 'uuid';
import { Value } from './Filter';
import type { FilterValue } from 'components/Alerts/types';
import {
filterByLabels,
searchByLabels,
sortByColumn,
} from 'components/Alerts/utils';
import type { SortState } from 'components/TanStackTableView/types';
export const FilterAlerts = (
allAlerts: Alerts[],
selectedFilter: Value[],
): Alerts[] => {
// also we need to update the alerts
// [[key,value]]
import { getElapsedMs } from 'utils/timeUtils';
if (selectedFilter?.length === 0 || selectedFilter === undefined) {
return allAlerts;
import type { Alert } from './types';
export function normalizeAlerts(rawAlerts: Alert[] | undefined): Alert[] {
if (!rawAlerts) {
return [];
}
return rawAlerts.map((alert) => ({
...alert,
fingerprint: alert.fingerprint ?? uuidv4(),
}));
}
export function getAlertSortValue(
alert: Alert,
columnName: string,
): string | number {
switch (columnName) {
case 'status':
return alert.status?.state ?? '';
case 'alertName':
return alert.labels?.alertname ?? '';
case 'severity':
return alert.labels?.severity ?? '';
case 'firingSince':
return alert.startsAt ? getElapsedMs(alert.startsAt) : '';
case 'duration':
return getElapsedMs(alert.startsAt);
default:
return '';
}
}
export function sortAlerts(
alerts: Alert[],
orderBy: SortState | null,
): Alert[] {
return sortByColumn(alerts, orderBy, getAlertSortValue, {
columnName: 'duration',
order: 'asc',
});
}
export { filterByLabels as filterAlerts, searchByLabels as searchAlerts };
export type { FilterValue };
export function getRuleId(alert: Alert): string | null {
// Primary: labels.ruleId
if (alert.labels?.ruleId) {
return alert.labels.ruleId;
}
const filter: string[] = [];
// filtering the value
selectedFilter.forEach((e) => {
const valueKey = e.value.split(':');
if (valueKey.length === 2) {
filter.push(e.value);
}
});
const tags = filter.map((e) => e.split(':'));
const objectMap = new Map();
const filteredKey = tags.reduce((acc, curr) => [...acc, curr[0]], []);
const filteredValue = tags.reduce((acc, curr) => [...acc, curr[1]], []);
filteredKey.forEach((key, index) =>
objectMap.set(key.trim(), filteredValue[index].trim()),
);
const filteredAlerts: Set<string> = new Set();
allAlerts.forEach((alert) => {
const { labels } = alert;
if (!labels) {
return;
}
Object.keys(labels).forEach((e) => {
const selectedKey = objectMap.get(e);
// alerts which does not have the key with value
if (selectedKey && labels[e] === selectedKey) {
filteredAlerts.add(alert.fingerprint);
// Fallback: parse from generatorURL
if (alert.generatorURL) {
try {
const url = new URL(alert.generatorURL);
const ruleId = url.searchParams.get('ruleId');
if (ruleId) {
return ruleId;
}
});
});
} catch {
// Invalid URL, ignore
}
}
return allAlerts.filter((e) => filteredAlerts.has(e.fingerprint));
};
return null;
}

View File

@@ -0,0 +1,65 @@
import { useCallback } from 'react';
import { useIsTextSelected } from './useIsTextSelected';
interface UseTableRowClickOptions<T> {
getUrl: (item: T) => string | null;
onNavigate: (url: string, options?: { newTab?: boolean }) => void;
onBeforeNavigate?: (item: T) => void;
}
interface UseTableRowClickReturn<T> {
handleRowClick: (item: T) => void;
handleRowClickNewTab: (item: T) => void;
}
/**
* Hook for handling table row clicks with text selection check
* Prevents navigation when user is selecting text
*/
export function useTableRowClick<T>({
getUrl,
onNavigate,
onBeforeNavigate,
}: UseTableRowClickOptions<T>): UseTableRowClickReturn<T> {
const isTextSelected = useIsTextSelected();
const handleRowClick = useCallback(
(item: T): void => {
if (isTextSelected()) {
return;
}
const url = getUrl(item);
if (!url) {
return;
}
onBeforeNavigate?.(item);
onNavigate(url);
},
[isTextSelected, getUrl, onNavigate, onBeforeNavigate],
);
const handleRowClickNewTab = useCallback(
(item: T): void => {
if (isTextSelected()) {
return;
}
const url = getUrl(item);
if (!url) {
return;
}
onBeforeNavigate?.(item);
onNavigate(url, { newTab: true });
},
[isTextSelected, getUrl, onNavigate, onBeforeNavigate],
);
return {
handleRowClick,
handleRowClickNewTab,
};
}

View File

@@ -0,0 +1,266 @@
import { act, renderHook } from '@testing-library/react';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import { useUrlSearchState } from './useUrlSearchState';
jest.useFakeTimers();
const DEFAULT_DEBOUNCE_MS = 300;
function createWrapper(searchParams?: string) {
return function Wrapper({
children,
}: {
children: React.ReactNode;
}): JSX.Element {
return (
<NuqsTestingAdapter searchParams={searchParams}>
{children}
</NuqsTestingAdapter>
);
};
}
describe('useUrlSearchState', () => {
afterEach(() => {
jest.clearAllTimers();
});
describe('initialization', () => {
it('initializes with empty string when no URL param', () => {
const { result } = renderHook(() => useUrlSearchState('search'), {
wrapper: createWrapper(),
});
expect(result.current.searchText).toBe('');
expect(result.current.debouncedSearch).toBe('');
});
it('initializes from URL param', () => {
const { result } = renderHook(() => useUrlSearchState('search'), {
wrapper: createWrapper('?search=hello'),
});
expect(result.current.searchText).toBe('hello');
expect(result.current.debouncedSearch).toBe('hello');
});
});
describe('user typing', () => {
it('updates searchText immediately on handleSearchChange', () => {
const { result } = renderHook(() => useUrlSearchState('search'), {
wrapper: createWrapper(),
});
act(() => {
result.current.handleSearchChange({
target: { value: 'test' },
} as React.ChangeEvent<HTMLInputElement>);
});
expect(result.current.searchText).toBe('test');
expect(result.current.debouncedSearch).toBe('');
});
it('updates searchText immediately on setSearchText', () => {
const { result } = renderHook(() => useUrlSearchState('search'), {
wrapper: createWrapper(),
});
act(() => {
result.current.setSearchText('direct');
});
expect(result.current.searchText).toBe('direct');
});
it('updates debouncedSearch after debounce delay', () => {
const { result } = renderHook(() => useUrlSearchState('search'), {
wrapper: createWrapper(),
});
act(() => {
result.current.setSearchText('delayed');
});
expect(result.current.debouncedSearch).toBe('');
act(() => {
jest.advanceTimersByTime(DEFAULT_DEBOUNCE_MS);
});
expect(result.current.debouncedSearch).toBe('delayed');
});
it('respects custom debounce delay', () => {
const customDelay = 500;
const { result } = renderHook(
() => useUrlSearchState('search', { debounceMs: customDelay }),
{ wrapper: createWrapper() },
);
act(() => {
result.current.setSearchText('custom');
});
act(() => {
jest.advanceTimersByTime(DEFAULT_DEBOUNCE_MS);
});
expect(result.current.debouncedSearch).toBe('');
act(() => {
jest.advanceTimersByTime(customDelay - DEFAULT_DEBOUNCE_MS);
});
expect(result.current.debouncedSearch).toBe('custom');
});
});
describe('debounce behavior', () => {
it('does not update debouncedSearch before delay', () => {
const { result } = renderHook(() => useUrlSearchState('search'), {
wrapper: createWrapper(),
});
act(() => {
result.current.setSearchText('urltest');
});
// Advance less than debounce time
act(() => {
jest.advanceTimersByTime(DEFAULT_DEBOUNCE_MS - 50);
});
expect(result.current.debouncedSearch).toBe('');
});
it('updates debouncedSearch exactly at delay', () => {
const { result } = renderHook(() => useUrlSearchState('search'), {
wrapper: createWrapper(),
});
act(() => {
result.current.setSearchText('urltest');
});
act(() => {
jest.advanceTimersByTime(DEFAULT_DEBOUNCE_MS);
});
expect(result.current.debouncedSearch).toBe('urltest');
});
it('resets debounce timer on rapid typing', () => {
const { result } = renderHook(() => useUrlSearchState('search'), {
wrapper: createWrapper(),
});
act(() => {
result.current.setSearchText('a');
});
act(() => {
jest.advanceTimersByTime(200);
});
act(() => {
result.current.setSearchText('ab');
});
act(() => {
jest.advanceTimersByTime(200);
});
// Still hasn't debounced because timer reset
expect(result.current.debouncedSearch).toBe('');
act(() => {
jest.advanceTimersByTime(100);
});
// Now it should have debounced
expect(result.current.debouncedSearch).toBe('ab');
});
});
describe('clearSearch', () => {
it('clears searchText immediately', () => {
const { result } = renderHook(() => useUrlSearchState('search'), {
wrapper: createWrapper('?search=toclear'),
});
expect(result.current.searchText).toBe('toclear');
act(() => {
result.current.clearSearch();
});
expect(result.current.searchText).toBe('');
});
it('clears debouncedSearch after delay', () => {
const { result } = renderHook(() => useUrlSearchState('search'), {
wrapper: createWrapper('?search=toclear'),
});
act(() => {
result.current.clearSearch();
});
expect(result.current.debouncedSearch).toBe('toclear');
act(() => {
jest.advanceTimersByTime(DEFAULT_DEBOUNCE_MS);
});
expect(result.current.debouncedSearch).toBe('');
});
});
describe('browser navigation (back/forward)', () => {
it('syncs local state when URL changes externally', () => {
const { result, rerender } = renderHook(() => useUrlSearchState('search'), {
wrapper: createWrapper('?search=first'),
});
expect(result.current.searchText).toBe('first');
// Simulate user typing "second"
act(() => {
result.current.setSearchText('second');
});
act(() => {
jest.advanceTimersByTime(DEFAULT_DEBOUNCE_MS);
});
expect(result.current.searchText).toBe('second');
expect(result.current.debouncedSearch).toBe('second');
// Simulate browser back - URL changes externally to "first"
rerender();
// Note: In real scenario, NuqsTestingAdapter would update searchParam
// This test verifies the hook's internal logic is correct
});
});
describe('different query keys', () => {
it('reads from correct URL param key', () => {
const { result } = renderHook(() => useUrlSearchState('mySearch'), {
wrapper: createWrapper('?mySearch=fromurl&other=ignored'),
});
expect(result.current.searchText).toBe('fromurl');
});
it('ignores other URL params', () => {
const { result } = renderHook(() => useUrlSearchState('search'), {
wrapper: createWrapper('?other=value&search=correct&another=test'),
});
expect(result.current.searchText).toBe('correct');
});
});
});

View File

@@ -0,0 +1,75 @@
import type { ChangeEvent } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { parseAsString, useQueryState } from 'nuqs';
import useDebounce from './useDebounce';
interface UseUrlSearchStateOptions {
debounceMs?: number;
}
interface UseUrlSearchStateReturn {
searchText: string;
debouncedSearch: string;
setSearchText: (value: string) => void;
handleSearchChange: (e: ChangeEvent<HTMLInputElement>) => void;
clearSearch: () => void;
}
/**
* Hook for managing search state synced with URL query params.
* Uses ref to track last synced value, preventing race conditions
* when browser back/forward changes URL externally.
*/
export function useUrlSearchState(
key: string,
options: UseUrlSearchStateOptions = {},
): UseUrlSearchStateReturn {
const { debounceMs = 300 } = options;
const [searchParam, setSearchParam] = useQueryState(
key,
parseAsString.withDefault('').withOptions({ history: 'push' }),
);
const [searchText, setSearchText] = useState(searchParam);
const debouncedSearch = useDebounce(searchText, debounceMs);
// Track what we last synced to URL to detect external changes
const lastSyncedToUrl = useRef(searchParam);
// Sync debounced value to URL (user typing -> URL)
useEffect(() => {
if (debouncedSearch !== lastSyncedToUrl.current) {
lastSyncedToUrl.current = debouncedSearch;
void setSearchParam(debouncedSearch || null);
}
}, [debouncedSearch, setSearchParam]);
// Sync URL to local state (browser back/forward -> input)
useEffect(() => {
if (searchParam !== lastSyncedToUrl.current) {
lastSyncedToUrl.current = searchParam;
setSearchText(searchParam);
}
}, [searchParam]);
const handleSearchChange = useCallback(
(e: ChangeEvent<HTMLInputElement>): void => {
setSearchText(e.target.value);
},
[],
);
const clearSearch = useCallback((): void => {
setSearchText('');
}, []);
return {
searchText,
debouncedSearch,
setSearchText,
handleSearchChange,
clearSearch,
};
}

View File

@@ -114,8 +114,11 @@ function AllAlertList(): JSX.Element {
urlQuery.delete('subTab');
}
// Clear search when navigating to any tab
// Clear search and table params when navigating to any tab
urlQuery.delete('search');
urlQuery.delete('page');
urlQuery.delete('limit');
urlQuery.delete('orderBy');
safeNavigate(`/alerts?${urlQuery.toString()}`);
}}

View File

@@ -171,6 +171,24 @@ export const normalizeTimeToMs = (timestamp: number | string): number => {
return isNanoSeconds ? Math.floor(ts / 1_000_000) : ts;
};
/**
* Calculates milliseconds elapsed since the given timestamp.
* Returns 0 for undefined/invalid timestamps.
*/
export function getElapsedMs(startsAt: Date | string | undefined): number {
if (!startsAt) {
return 0;
}
const timestamp =
typeof startsAt === 'string'
? new Date(startsAt).getTime()
: startsAt.getTime();
if (Number.isNaN(timestamp)) {
return 0;
}
return Date.now() - timestamp;
}
export const hasDatePassed = (expiresAt: string): boolean => {
const date = dayjs(expiresAt);