mirror of
https://github.com/SigNoz/signoz.git
synced 2026-05-18 07:50:32 +01:00
Compare commits
28 Commits
ns/fg-sele
...
feat/alert
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eef2b6a961 | ||
|
|
330038a35f | ||
|
|
d4dea81bb6 | ||
|
|
dfd7d8a871 | ||
|
|
40d2906835 | ||
|
|
3fcb6b3b00 | ||
|
|
5982c0854d | ||
|
|
687b40ffbb | ||
|
|
4e111c6b83 | ||
|
|
3f5eb62494 | ||
|
|
cd7b6a1d05 | ||
|
|
faee2f032f | ||
|
|
0402cc0273 | ||
|
|
b70f057adc | ||
|
|
3b7b7202e9 | ||
|
|
e3c9babfe5 | ||
|
|
226e40cbcd | ||
|
|
0f4d007104 | ||
|
|
86b88eb10b | ||
|
|
0b21197689 | ||
|
|
6c02fe107f | ||
|
|
a90e915fa3 | ||
|
|
1a4de4328b | ||
|
|
c53adf365a | ||
|
|
0fc16e02fa | ||
|
|
fb6a29e6fa | ||
|
|
0daf7a12da | ||
|
|
cc7d7017ae |
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
1
frontend/src/components/Alerts/ErrorEmptyState/index.ts
Normal file
1
frontend/src/components/Alerts/ErrorEmptyState/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './ErrorEmptyState';
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
139
frontend/src/components/Alerts/LabelColumn/LabelColumn.test.tsx
Normal file
139
frontend/src/components/Alerts/LabelColumn/LabelColumn.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
144
frontend/src/components/Alerts/LabelColumn/LabelColumn.tsx
Normal file
144
frontend/src/components/Alerts/LabelColumn/LabelColumn.tsx
Normal 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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
74
frontend/src/components/Alerts/LabelColumn/LabelTag.tsx
Normal file
74
frontend/src/components/Alerts/LabelColumn/LabelTag.tsx
Normal 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;
|
||||
2
frontend/src/components/Alerts/LabelColumn/index.ts
Normal file
2
frontend/src/components/Alerts/LabelColumn/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default } from './LabelColumn';
|
||||
export type { LabelColumnProps } from './LabelColumn';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './NoResultsEmptyState';
|
||||
32
frontend/src/components/Alerts/constants.ts
Normal file
32
frontend/src/components/Alerts/constants.ts
Normal 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',
|
||||
};
|
||||
7
frontend/src/components/Alerts/types.ts
Normal file
7
frontend/src/components/Alerts/types.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export interface FilterValue {
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface AlertWithLabels {
|
||||
labels?: Record<string, string>;
|
||||
}
|
||||
287
frontend/src/components/Alerts/utils.test.ts
Normal file
287
frontend/src/components/Alerts/utils.test.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
116
frontend/src/components/Alerts/utils.ts
Normal file
116
frontend/src/components/Alerts/utils.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
172
frontend/src/container/ListAlertRules/components/ActionsMenu.tsx
Normal file
172
frontend/src/container/ListAlertRules/components/ActionsMenu.tsx
Normal 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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as ActionsMenu } from './ActionsMenu';
|
||||
export { default as ColumnSelector } from './ColumnSelector';
|
||||
46
frontend/src/container/ListAlertRules/hooks.ts
Normal file
46
frontend/src/container/ListAlertRules/hooks.ts
Normal 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),
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
`;
|
||||
154
frontend/src/container/ListAlertRules/table.config.tsx
Normal file
154
frontend/src/container/ListAlertRules/table.config.tsx
Normal 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>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
3
frontend/src/container/ListAlertRules/types.ts
Normal file
3
frontend/src/container/ListAlertRules/types.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import type { RuletypesRuleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
export type AlertRule = RuletypesRuleDTO;
|
||||
55
frontend/src/container/ListAlertRules/useAlertRulesData.ts
Normal file
55
frontend/src/container/ListAlertRules/useAlertRulesData.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
`;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
34
frontend/src/container/TriggeredAlerts/hooks.ts
Normal file
34
frontend/src/container/TriggeredAlerts/hooks.ts
Normal 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),
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
`;
|
||||
153
frontend/src/container/TriggeredAlerts/table.config.tsx
Normal file
153
frontend/src/container/TriggeredAlerts/table.config.tsx
Normal 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>
|
||||
),
|
||||
},
|
||||
];
|
||||
10
frontend/src/container/TriggeredAlerts/types.ts
Normal file
10
frontend/src/container/TriggeredAlerts/types.ts
Normal 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;
|
||||
}
|
||||
107
frontend/src/container/TriggeredAlerts/useTriggeredAlertsData.ts
Normal file
107
frontend/src/container/TriggeredAlerts/useTriggeredAlertsData.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
65
frontend/src/hooks/useTableRowClick.ts
Normal file
65
frontend/src/hooks/useTableRowClick.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
266
frontend/src/hooks/useUrlSearchState.test.tsx
Normal file
266
frontend/src/hooks/useUrlSearchState.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
75
frontend/src/hooks/useUrlSearchState.ts
Normal file
75
frontend/src/hooks/useUrlSearchState.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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()}`);
|
||||
}}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user