mirror of
https://github.com/SigNoz/signoz.git
synced 2026-05-15 14:40:30 +01:00
Compare commits
24 Commits
chore/tool
...
feat/alert
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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,36 @@
|
||||
.labelColumn {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
overflow-x: auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.labelBadge {
|
||||
cursor: default;
|
||||
font-size: 12px;
|
||||
|
||||
--badge-display: inline;
|
||||
|
||||
max-width: 180px;
|
||||
min-width: 100px;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.labelPopover {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 8px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.labelBadgePopover {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.labelValue {
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import LabelColumn from './LabelColumn';
|
||||
|
||||
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 more than 5 labels', () => {
|
||||
const labels = ['env', 'service', 'region', 'team', 'owner', 'version'];
|
||||
|
||||
renderWithProviders(<LabelColumn labels={labels} />);
|
||||
|
||||
// First 3 visible
|
||||
expect(screen.getByTestId('label-tag-env')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('label-tag-service')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('label-tag-region')).toBeInTheDocument();
|
||||
|
||||
// +3 badge for remaining
|
||||
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 popover with all labels when clicking +N badge', async () => {
|
||||
const user = userEvent.setup();
|
||||
const labels = ['env', 'service', 'region', 'team', 'owner', 'version'];
|
||||
|
||||
renderWithProviders(<LabelColumn labels={labels} />);
|
||||
|
||||
await user.click(screen.getByTestId('label-overflow-badge'));
|
||||
|
||||
// All labels should appear in popover
|
||||
expect(screen.getByTestId('label-popover')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('label-popover-item-env')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('label-popover-item-version')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
80
frontend/src/components/Alerts/LabelColumn/LabelColumn.tsx
Normal file
80
frontend/src/components/Alerts/LabelColumn/LabelColumn.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@signozhq/ui/popover';
|
||||
|
||||
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 MAX_LABELS_TO_DISPLAY = 5;
|
||||
|
||||
function LabelColumn({
|
||||
labels,
|
||||
value,
|
||||
color = 'primary',
|
||||
}: LabelColumnProps): JSX.Element {
|
||||
const visibleLabels =
|
||||
labels.length > MAX_LABELS_TO_DISPLAY ? labels.slice(0, 3) : labels;
|
||||
const remainingLabels =
|
||||
labels.length > MAX_LABELS_TO_DISPLAY ? labels.slice(3) : [];
|
||||
|
||||
return (
|
||||
<div className={styles.labelColumn} data-testid="label-column">
|
||||
{visibleLabels.map((label) => (
|
||||
<LabelTag key={label} label={label} color={color} value={value?.[label]} />
|
||||
))}
|
||||
{remainingLabels.length > 0 && (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Badge
|
||||
color={color}
|
||||
className={styles.labelBadge}
|
||||
variant="outline"
|
||||
data-testid="label-overflow-badge"
|
||||
>
|
||||
+{remainingLabels.length}
|
||||
</Badge>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="bottom"
|
||||
align="end"
|
||||
className={styles.labelPopover}
|
||||
data-testid="label-popover"
|
||||
>
|
||||
{labels.map((label) => (
|
||||
<Badge
|
||||
key={label}
|
||||
color={color}
|
||||
className={styles.labelBadgePopover}
|
||||
variant="outline"
|
||||
data-testid={`label-popover-item-${label}`}
|
||||
>
|
||||
{value?.[label] ? `${label}: ${value?.[label]}` : label}
|
||||
</Badge>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LabelColumn;
|
||||
@@ -0,0 +1,15 @@
|
||||
.labelBadge {
|
||||
cursor: default;
|
||||
font-size: 12px;
|
||||
|
||||
--badge-display: inline;
|
||||
|
||||
max-width: 180px;
|
||||
min-width: 100px;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.labelValue {
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
42
frontend/src/components/Alerts/LabelColumn/LabelTag.tsx
Normal file
42
frontend/src/components/Alerts/LabelColumn/LabelTag.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
|
||||
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 tooltipTitle = value ? `${label}: ${value}` : label;
|
||||
|
||||
return (
|
||||
<TooltipSimple title={tooltipTitle}>
|
||||
<Badge
|
||||
color={color}
|
||||
className={styles.labelBadge}
|
||||
variant="outline"
|
||||
data-testid={`label-tag-${label}`}
|
||||
>
|
||||
<span className={styles.labelValue}>{tooltipTitle}</span>
|
||||
</Badge>
|
||||
</TooltipSimple>
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
} from './TanStackTableStateContext';
|
||||
import {
|
||||
FlatItem,
|
||||
SortState,
|
||||
TableRowContext,
|
||||
TanStackTableHandle,
|
||||
TanStackTableProps,
|
||||
@@ -100,6 +101,7 @@ function TanStackTableInner<TData>(
|
||||
onRowClick,
|
||||
onRowClickNewTab,
|
||||
onRowDeactivate,
|
||||
onSort,
|
||||
activeRowIndex,
|
||||
renderExpandedRow,
|
||||
getRowCanExpand,
|
||||
@@ -127,10 +129,10 @@ function TanStackTableInner<TData>(
|
||||
const {
|
||||
page,
|
||||
limit,
|
||||
setPage,
|
||||
setLimit,
|
||||
setPage: internalSetPage,
|
||||
setLimit: internalSetLimit,
|
||||
orderBy,
|
||||
setOrderBy,
|
||||
setOrderBy: internalSetOrderBy,
|
||||
expanded,
|
||||
setExpanded,
|
||||
} = useTableParams(enableQueryParams, {
|
||||
@@ -138,6 +140,31 @@ function TanStackTableInner<TData>(
|
||||
limit: pagination?.defaultLimit,
|
||||
});
|
||||
|
||||
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 setOrderBy = useCallback(
|
||||
(sort: SortState | null) => {
|
||||
internalSetOrderBy(sort);
|
||||
onSort?.(sort);
|
||||
},
|
||||
[internalSetOrderBy, onSort],
|
||||
);
|
||||
|
||||
const isGrouped = (groupBy?.length ?? 0) > 0;
|
||||
|
||||
const {
|
||||
@@ -607,14 +634,17 @@ function TanStackTableInner<TData>(
|
||||
setPage(p);
|
||||
}}
|
||||
/>
|
||||
<div className={viewStyles.paginationPageSize}>
|
||||
<ComboboxSimple
|
||||
value={limit?.toString()}
|
||||
defaultValue="10"
|
||||
onChange={(value): void => setLimit(+value)}
|
||||
items={paginationPageSizeItems}
|
||||
/>
|
||||
</div>
|
||||
{(pagination.showPageSize ?? true) && (
|
||||
<div className={viewStyles.paginationPageSize}>
|
||||
<ComboboxSimple
|
||||
testId="pagination-page-size"
|
||||
value={limit?.toString()}
|
||||
defaultValue="10"
|
||||
onChange={(value): void => setLimit(+value)}
|
||||
items={paginationPageSizeItems}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{suffixPaginationContent}
|
||||
</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,6 +23,14 @@ jest.mock('../TanStackTable.module.scss', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
beforeAll(() => {
|
||||
window.ResizeObserver = jest.fn().mockImplementation(() => ({
|
||||
disconnect: jest.fn(),
|
||||
observe: jest.fn(),
|
||||
unobserve: jest.fn(),
|
||||
}));
|
||||
});
|
||||
|
||||
describe('TanStackTableView Integration', () => {
|
||||
describe('rendering', () => {
|
||||
it('renders all data rows', async () => {
|
||||
@@ -269,6 +277,54 @@ describe('TanStackTableView Integration', () => {
|
||||
screen.queryByTestId('pagination-total-count'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
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', () => {
|
||||
|
||||
@@ -117,6 +117,10 @@ export type PaginationProps = {
|
||||
defaultLimit?: number;
|
||||
showTotalCount?: boolean;
|
||||
totalCountLabel?: string;
|
||||
/** @default true */
|
||||
showPageSize?: boolean;
|
||||
onPageChange?: (page: number) => void;
|
||||
onLimitChange?: (limit: number) => void;
|
||||
};
|
||||
|
||||
export type TanstackTableQueryParamsConfig = {
|
||||
@@ -160,6 +164,8 @@ export type TanStackTableProps<TData> = {
|
||||
/** Called when ctrl+click or cmd+click on a row */
|
||||
onRowClickNewTab?: (row: TData, itemKey: string) => void;
|
||||
onRowDeactivate?: () => void;
|
||||
/** Called when sort state changes */
|
||||
onSort?: (sort: SortState | null) => void;
|
||||
activeRowIndex?: number;
|
||||
renderExpandedRow?: (
|
||||
row: TData,
|
||||
|
||||
@@ -172,22 +172,23 @@ 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) {
|
||||
setUrlPage(pageDefault);
|
||||
} else {
|
||||
setLocalPage(pageDefault);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
useUrlForPage,
|
||||
orderByDefaultMemoKey,
|
||||
orderByUrlMemoKey,
|
||||
pageDefault,
|
||||
setUrlPage,
|
||||
]);
|
||||
prevOrderByRef.current = orderByUrlMemoKey;
|
||||
}, [useUrlForPage, orderByUrlMemoKey, pageDefault, setUrlPage]);
|
||||
|
||||
return {
|
||||
page: useUrlForPage ? urlPage : localPage,
|
||||
|
||||
@@ -1,65 +1,16 @@
|
||||
import { Callout } from '@signozhq/ui/callout';
|
||||
import ClickHouseQueryBuilder from 'container/NewWidget/LeftContainer/QuerySection/QueryBuilder/ClickHouse/query';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
import DOCLINKS from 'utils/docLinks';
|
||||
|
||||
import 'container/NewWidget/LeftContainer/QuerySection/QueryBuilder/ClickHouse/ClickHouse.styles.scss';
|
||||
|
||||
const ALERT_TYPE_DOC_LINK: Partial<Record<AlertTypes, string>> = {
|
||||
[AlertTypes.LOGS_BASED_ALERT]: DOCLINKS.QUERY_CLICKHOUSE_LOGS,
|
||||
[AlertTypes.TRACES_BASED_ALERT]: DOCLINKS.QUERY_CLICKHOUSE_TRACES,
|
||||
[AlertTypes.EXCEPTIONS_BASED_ALERT]: DOCLINKS.QUERY_CLICKHOUSE_TRACES,
|
||||
[AlertTypes.METRICS_BASED_ALERT]: DOCLINKS.QUERY_CLICKHOUSE_METRICS,
|
||||
};
|
||||
|
||||
const ALERT_TYPES_WITH_AGENT_SKILL: AlertTypes[] = [
|
||||
AlertTypes.LOGS_BASED_ALERT,
|
||||
AlertTypes.TRACES_BASED_ALERT,
|
||||
AlertTypes.EXCEPTIONS_BASED_ALERT,
|
||||
];
|
||||
|
||||
interface ChQuerySectionProps {
|
||||
alertType: AlertTypes;
|
||||
}
|
||||
|
||||
function ChQuerySection({ alertType }: ChQuerySectionProps): JSX.Element {
|
||||
function ChQuerySection(): JSX.Element {
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
const docLink = ALERT_TYPE_DOC_LINK[alertType];
|
||||
const showAgentSkill = ALERT_TYPES_WITH_AGENT_SKILL.includes(alertType);
|
||||
|
||||
return (
|
||||
<>
|
||||
{docLink && (
|
||||
<div className="info-banner-wrapper">
|
||||
<Callout
|
||||
type="info"
|
||||
showIcon
|
||||
title={
|
||||
<span>
|
||||
<a href={docLink} target="_blank" rel="noopener">
|
||||
Learn to write faster, optimized queries
|
||||
</a>
|
||||
{showAgentSkill && (
|
||||
<>
|
||||
{' · Using AI? '}
|
||||
<a href={DOCLINKS.AGENT_SKILL_INSTALL} target="_blank" rel="noopener">
|
||||
Install the SigNoz ClickHouse query agent skill
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<ClickHouseQueryBuilder
|
||||
key="A"
|
||||
queryIndex={0}
|
||||
queryData={currentQuery.clickhouse_sql[0]}
|
||||
deletable={false}
|
||||
/>
|
||||
</>
|
||||
<ClickHouseQueryBuilder
|
||||
key="A"
|
||||
queryIndex={0}
|
||||
queryData={currentQuery.clickhouse_sql[0]}
|
||||
deletable={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -56,9 +56,7 @@ function QuerySection({
|
||||
|
||||
const renderPromqlUI = (): JSX.Element => <PromqlSection />;
|
||||
|
||||
const renderChQueryUI = (): JSX.Element => (
|
||||
<ChQuerySection alertType={alertType} />
|
||||
);
|
||||
const renderChQueryUI = (): JSX.Element => <ChQuerySection />;
|
||||
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
|
||||
@@ -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,82 @@
|
||||
.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: 5px;
|
||||
--tanstack-cell-padding-right-override: 5px;
|
||||
|
||||
--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,198 @@
|
||||
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, useEffect, useMemo } from 'react';
|
||||
import { useQueryStates, parseAsInteger } from 'nuqs';
|
||||
import { parseAsJsonNoValidate } from 'utils/nuqsParsers';
|
||||
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 { 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,
|
||||
const [filterValues, setFilterValues] = useAlertRulesFilters();
|
||||
const { searchText, debouncedSearch, handleSearchChange, clearSearch } =
|
||||
useUrlSearchState(ALERT_RULES_PARAMS.SEARCH);
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
const { orderBy, page, limit } = useTableParams(QUERY_PARAMS_CONFIG, {
|
||||
page: DEFAULT_PAGE,
|
||||
limit: DEFAULT_LIMIT,
|
||||
});
|
||||
|
||||
const [, setTableQueryParams] = useQueryStates({
|
||||
[QUERY_PARAMS_CONFIG.orderBy]: parseAsJsonNoValidate(),
|
||||
[QUERY_PARAMS_CONFIG.page]: parseAsInteger,
|
||||
[QUERY_PARAMS_CONFIG.limit]: parseAsInteger,
|
||||
});
|
||||
|
||||
useEffect(
|
||||
() => (): void => {
|
||||
void setTableQueryParams({
|
||||
[QUERY_PARAMS_CONFIG.orderBy]: null,
|
||||
[QUERY_PARAMS_CONFIG.page]: null,
|
||||
[QUERY_PARAMS_CONFIG.limit]: null,
|
||||
});
|
||||
logEventCalledRef.current = true;
|
||||
},
|
||||
[setTableQueryParams],
|
||||
);
|
||||
|
||||
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: '',
|
||||
accessorKey: 'id',
|
||||
width: { min: 50, default: 50 },
|
||||
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 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,
|
||||
defaultPage: DEFAULT_PAGE,
|
||||
defaultLimit: DEFAULT_LIMIT,
|
||||
showTotalCount: true,
|
||||
}}
|
||||
paginationClassname={styles.paginationContainer}
|
||||
/>
|
||||
)}
|
||||
</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: { min: 80, 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: { min: 200, default: 300 },
|
||||
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: { min: 120, default: 120 },
|
||||
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: { min: 150, default: 250 },
|
||||
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: { min: 180, default: 200 },
|
||||
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: { min: 100, default: 120 },
|
||||
enableSort: false,
|
||||
enableMove: false,
|
||||
defaultVisibility: false,
|
||||
cell: ({ value }): JSX.Element => (
|
||||
<TanStackTable.Text>{String(value ?? '-')}</TanStackTable.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'updatedAt',
|
||||
header: 'Updated At',
|
||||
accessorKey: 'updatedAt',
|
||||
width: { min: 180, default: 200 },
|
||||
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: { min: 100, default: 120 },
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -26,12 +26,12 @@ function ClickHouseQueryContainer(): JSX.Element | null {
|
||||
<a
|
||||
href={DOCLINKS.QUERY_CLICKHOUSE_TRACES}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Learn to write faster, optimized queries
|
||||
</a>
|
||||
{' · Using AI? '}
|
||||
<a href={DOCLINKS.AGENT_SKILL_INSTALL} target="_blank" rel="noopener">
|
||||
<a href={DOCLINKS.AGENT_SKILL_INSTALL} target="_blank" rel="noreferrer">
|
||||
Install the SigNoz ClickHouse query agent skill
|
||||
</a>
|
||||
</span>
|
||||
|
||||
@@ -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,109 @@
|
||||
.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-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,78 @@
|
||||
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}
|
||||
/>
|
||||
</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,254 @@
|
||||
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, useEffect, useMemo } from 'react';
|
||||
import { useQueryStates, parseAsInteger } from 'nuqs';
|
||||
import { parseAsJsonNoValidate } from 'utils/nuqsParsers';
|
||||
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 { 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 { orderBy, page, limit } = useTableParams(QUERY_PARAMS_CONFIG, {
|
||||
page: DEFAULT_PAGE,
|
||||
limit: DEFAULT_LIMIT,
|
||||
});
|
||||
|
||||
const { user } = useAppContext();
|
||||
const [, setTableQueryParams] = useQueryStates({
|
||||
[QUERY_PARAMS_CONFIG.orderBy]: parseAsJsonNoValidate(),
|
||||
[QUERY_PARAMS_CONFIG.page]: parseAsInteger,
|
||||
[QUERY_PARAMS_CONFIG.limit]: parseAsInteger,
|
||||
});
|
||||
|
||||
const hasLoggedEvent = useRef(false); // Track if logEvent has been called
|
||||
|
||||
const handleError = useAxiosError();
|
||||
|
||||
const alertsResponse = useQuery(
|
||||
[REACT_QUERY_KEY.GET_TRIGGERED_ALERTS, user.id],
|
||||
{
|
||||
queryFn: () =>
|
||||
getTriggeredApi({
|
||||
active: true,
|
||||
inhibited: true,
|
||||
silenced: false,
|
||||
}),
|
||||
refetchInterval: 30000,
|
||||
onError: handleError,
|
||||
useEffect(
|
||||
() => (): void => {
|
||||
void setTableQueryParams({
|
||||
[QUERY_PARAMS_CONFIG.orderBy]: null,
|
||||
[QUERY_PARAMS_CONFIG.page]: null,
|
||||
[QUERY_PARAMS_CONFIG.limit]: null,
|
||||
});
|
||||
},
|
||||
[setTableQueryParams],
|
||||
);
|
||||
|
||||
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 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,
|
||||
defaultPage: DEFAULT_PAGE,
|
||||
defaultLimit: DEFAULT_LIMIT,
|
||||
}}
|
||||
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,
|
||||
defaultPage: DEFAULT_PAGE,
|
||||
defaultLimit: DEFAULT_LIMIT,
|
||||
showTotalCount: true,
|
||||
}}
|
||||
paginationClassname={styles.paginationContainer}
|
||||
/>
|
||||
)}
|
||||
</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,
|
||||
};
|
||||
}
|
||||
@@ -189,7 +189,7 @@ describe('Tooltip utils', () => {
|
||||
];
|
||||
}
|
||||
|
||||
it('builds tooltip content sorted by value descending with isActive flag set correctly', () => {
|
||||
it('builds tooltip content in series-index order with isActive flag set correctly', () => {
|
||||
const data: AlignedData = [[0], [10], [20], [30]];
|
||||
const series = createSeriesConfig();
|
||||
const dataIndexes = [null, 0, 0, 0];
|
||||
@@ -206,21 +206,21 @@ describe('Tooltip utils', () => {
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
// Sorted by value descending: B (20) before A (10)
|
||||
// Series are returned in series-index order (A=index 1 before B=index 2)
|
||||
expect(result[0]).toMatchObject<Partial<TooltipContentItem>>({
|
||||
label: 'B',
|
||||
value: 20,
|
||||
tooltipValue: 'formatted-20',
|
||||
color: 'color-2',
|
||||
isActive: true,
|
||||
});
|
||||
expect(result[1]).toMatchObject<Partial<TooltipContentItem>>({
|
||||
label: 'A',
|
||||
value: 10,
|
||||
tooltipValue: 'formatted-10',
|
||||
color: '#ff0000',
|
||||
isActive: false,
|
||||
});
|
||||
expect(result[1]).toMatchObject<Partial<TooltipContentItem>>({
|
||||
label: 'B',
|
||||
value: 20,
|
||||
tooltipValue: 'formatted-20',
|
||||
color: 'color-2',
|
||||
isActive: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('skips series with null data index or non-finite values', () => {
|
||||
@@ -274,7 +274,7 @@ describe('Tooltip utils', () => {
|
||||
expect(result[1].value).toBe(30);
|
||||
});
|
||||
|
||||
it('returns items sorted by value descending', () => {
|
||||
it('returns items in series-index order', () => {
|
||||
// Series values in non-sorted order: 3, 1, 4, 2
|
||||
const data: AlignedData = [[0], [3], [1], [4], [2]];
|
||||
const series: Series[] = [
|
||||
@@ -297,7 +297,7 @@ describe('Tooltip utils', () => {
|
||||
decimalPrecision,
|
||||
});
|
||||
|
||||
expect(result.map((item) => item.value)).toStrictEqual([4, 3, 2, 1]);
|
||||
expect(result.map((item) => item.value)).toStrictEqual([3, 1, 4, 2]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -142,7 +142,5 @@ export function buildTooltipContent({
|
||||
}
|
||||
}
|
||||
|
||||
items.sort((a, b) => b.value - a.value);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
@@ -10,10 +10,6 @@ const DOCLINKS = {
|
||||
'https://signoz.io/docs/external-api-monitoring/overview/',
|
||||
QUERY_CLICKHOUSE_TRACES:
|
||||
'https://signoz.io/docs/userguide/writing-clickhouse-traces-query/#timestamp-bucketing-for-distributed_signoz_index_v3',
|
||||
QUERY_CLICKHOUSE_LOGS:
|
||||
'https://signoz.io/docs/userguide/logs_clickhouse_queries/',
|
||||
QUERY_CLICKHOUSE_METRICS:
|
||||
'https://signoz.io/docs/userguide/write-a-metrics-clickhouse-query/',
|
||||
AGENT_SKILL_INSTALL: 'https://signoz.io/docs/ai/agent-skills/#installation',
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
package impltag
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/modules/tag"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/tagtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type module struct {
|
||||
store tagtypes.Store
|
||||
}
|
||||
|
||||
func NewModule(store tagtypes.Store) tag.Module {
|
||||
return &module{store: store}
|
||||
}
|
||||
|
||||
func (m *module) SyncTags(ctx context.Context, orgID valuer.UUID, kind coretypes.Kind, resourceID valuer.UUID, postable []tagtypes.PostableTag) ([]*tagtypes.Tag, error) {
|
||||
var tags []*tagtypes.Tag
|
||||
err := m.store.RunInTx(ctx, func(ctx context.Context) error {
|
||||
resolved, err := m.createMany(ctx, orgID, kind, postable)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tagIDs := make([]valuer.UUID, len(resolved))
|
||||
for i, t := range resolved {
|
||||
tagIDs[i] = t.ID
|
||||
}
|
||||
if err := m.syncLinksForResource(ctx, orgID, kind, resourceID, tagIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
tags = resolved
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return tags, nil
|
||||
}
|
||||
|
||||
func (m *module) createMany(ctx context.Context, orgID valuer.UUID, kind coretypes.Kind, postable []tagtypes.PostableTag) ([]*tagtypes.Tag, error) {
|
||||
if len(postable) == 0 {
|
||||
return []*tagtypes.Tag{}, nil
|
||||
}
|
||||
|
||||
toCreate, matched, err := m.resolve(ctx, orgID, kind, postable)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
created, err := m.store.CreateOrGet(ctx, toCreate)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return append(matched, created...), nil
|
||||
}
|
||||
|
||||
func (m *module) syncLinksForResource(ctx context.Context, orgID valuer.UUID, kind coretypes.Kind, resourceID valuer.UUID, tagIDs []valuer.UUID) error {
|
||||
return m.store.RunInTx(ctx, func(ctx context.Context) error {
|
||||
if err := m.store.CreateRelations(ctx, tagtypes.NewTagRelations(kind, resourceID, tagIDs)); err != nil {
|
||||
return err
|
||||
}
|
||||
return m.store.DeleteRelationsExcept(ctx, orgID, kind, resourceID, tagIDs)
|
||||
})
|
||||
}
|
||||
|
||||
func (m *module) ListForResource(ctx context.Context, orgID valuer.UUID, kind coretypes.Kind, resourceID valuer.UUID) ([]*tagtypes.Tag, error) {
|
||||
return m.store.ListByResource(ctx, orgID, kind, resourceID)
|
||||
}
|
||||
|
||||
func (m *module) ListForResources(ctx context.Context, orgID valuer.UUID, kind coretypes.Kind, resourceIDs []valuer.UUID) (map[valuer.UUID][]*tagtypes.Tag, error) {
|
||||
return m.store.ListByResources(ctx, orgID, kind, resourceIDs)
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
package impltag
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/tagtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
// resolve canonicalizes a batch of user-supplied (key, value) tag pairs against
|
||||
// the existing tags for an org. Lookup is case-insensitive on both key and
|
||||
// value (matching the storage uniqueness rule); when an existing row matches,
|
||||
// its display casing is reused. Inputs are deduped on (LOWER(key), LOWER(value));
|
||||
// the first input's casing wins on collisions. Returns:
|
||||
// - toCreate: new Tag rows the caller should insert (with pre-generated IDs)
|
||||
// - matched: existing rows the caller's input already pointed to. They
|
||||
// already carry authoritative IDs from the store.
|
||||
func (m *module) resolve(ctx context.Context, orgID valuer.UUID, kind coretypes.Kind, postable []tagtypes.PostableTag) ([]*tagtypes.Tag, []*tagtypes.Tag, error) {
|
||||
if len(postable) == 0 {
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
existing, err := m.store.List(ctx, orgID, kind)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
lowercaseTagsMap := make(map[string]*tagtypes.Tag, len(existing))
|
||||
for _, t := range existing {
|
||||
mapKey := strings.ToLower(t.Key) + "\x00" + strings.ToLower(t.Value)
|
||||
lowercaseTagsMap[mapKey] = t
|
||||
}
|
||||
|
||||
seenInRequestAlready := make(map[string]struct{}, len(postable)) // postable can have the same tag multiple times
|
||||
toCreate := make([]*tagtypes.Tag, 0)
|
||||
matched := make([]*tagtypes.Tag, 0)
|
||||
|
||||
for _, p := range postable {
|
||||
key, value, err := tagtypes.ValidatePostableTag(p)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
lookup := strings.ToLower(key) + "\x00" + strings.ToLower(value)
|
||||
if _, dup := seenInRequestAlready[lookup]; dup {
|
||||
continue
|
||||
}
|
||||
seenInRequestAlready[lookup] = struct{}{}
|
||||
|
||||
if existingTag, ok := lowercaseTagsMap[lookup]; ok {
|
||||
matched = append(matched, existingTag)
|
||||
continue
|
||||
}
|
||||
toCreate = append(toCreate, tagtypes.NewTag(orgID, kind, key, value))
|
||||
}
|
||||
|
||||
return toCreate, matched, nil
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
package impltag
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/tagtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/tagtypes/tagtypestest"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
var testKind = coretypes.KindDashboard
|
||||
|
||||
func TestModule_Resolve(t *testing.T) {
|
||||
t.Run("empty input does not hit store", func(t *testing.T) {
|
||||
store := tagtypestest.NewStore()
|
||||
m := &module{store: store}
|
||||
|
||||
toCreate, matched, err := m.resolve(context.Background(), valuer.GenerateUUID(), testKind, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, toCreate)
|
||||
assert.Empty(t, matched)
|
||||
assert.Zero(t, store.ListCallCount, "should not hit store when input is empty")
|
||||
})
|
||||
|
||||
t.Run("creates missing pairs and reuses existing", func(t *testing.T) {
|
||||
orgID := valuer.GenerateUUID()
|
||||
dbTag := tagtypes.NewTag(orgID, testKind, "team", "Pulse")
|
||||
dbTag2 := tagtypes.NewTag(orgID, testKind, "Database", "redis")
|
||||
store := tagtypestest.NewStore()
|
||||
store.Tags = []*tagtypes.Tag{dbTag, dbTag2}
|
||||
m := &module{store: store}
|
||||
|
||||
toCreate, matched, err := m.resolve(context.Background(), orgID, testKind, []tagtypes.PostableTag{
|
||||
{Key: "team", Value: "events"}, // new
|
||||
{Key: "DATABASE", Value: "REDIS"}, // case-only conflict
|
||||
{Key: "Brand", Value: "New"}, // new
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
createdLowerKVs := []string{}
|
||||
for _, tg := range toCreate {
|
||||
createdLowerKVs = append(createdLowerKVs, strings.ToLower(tg.Key)+"\x00"+strings.ToLower(tg.Value))
|
||||
}
|
||||
assert.ElementsMatch(t, []string{"team\x00events", "brand\x00new"}, createdLowerKVs,
|
||||
"only the two missing pairs should be returned for insertion")
|
||||
|
||||
require.Len(t, matched, 1, "DATABASE:REDIS should hit the existing 'Database:redis' tag")
|
||||
assert.Same(t, dbTag2, matched[0], "matched should return the existing pointer with its authoritative ID")
|
||||
})
|
||||
|
||||
t.Run("dedupes inputs that map to the same lower(key)+lower(value)", func(t *testing.T) {
|
||||
orgID := valuer.GenerateUUID()
|
||||
store := tagtypestest.NewStore()
|
||||
m := &module{store: store}
|
||||
|
||||
toCreate, matched, err := m.resolve(context.Background(), orgID, testKind, []tagtypes.PostableTag{
|
||||
{Key: "Foo", Value: "Bar"},
|
||||
{Key: "foo", Value: "bar"},
|
||||
{Key: "FOO", Value: "BAR"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Empty(t, matched)
|
||||
require.Len(t, toCreate, 1, "duplicate inputs must collapse into a single insert")
|
||||
assert.Equal(t, "Foo", toCreate[0].Key, "first input's casing wins")
|
||||
assert.Equal(t, "Bar", toCreate[0].Value, "first input's casing wins")
|
||||
})
|
||||
|
||||
t.Run("preserves existing casing on case-only match", func(t *testing.T) {
|
||||
orgID := valuer.GenerateUUID()
|
||||
dbTag := tagtypes.NewTag(orgID, testKind, "Team", "Pulse")
|
||||
store := tagtypestest.NewStore()
|
||||
store.Tags = []*tagtypes.Tag{dbTag}
|
||||
m := &module{store: store}
|
||||
|
||||
toCreate, matched, err := m.resolve(context.Background(), orgID, testKind, []tagtypes.PostableTag{
|
||||
{Key: "team", Value: "PULSE"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Empty(t, toCreate)
|
||||
require.Len(t, matched, 1)
|
||||
assert.Equal(t, "Team", matched[0].Key)
|
||||
assert.Equal(t, "Pulse", matched[0].Value)
|
||||
})
|
||||
|
||||
t.Run("propagates validation error from any input", func(t *testing.T) {
|
||||
store := tagtypestest.NewStore()
|
||||
m := &module{store: store}
|
||||
|
||||
_, _, err := m.resolve(context.Background(), valuer.GenerateUUID(), testKind, []tagtypes.PostableTag{
|
||||
{Key: "team", Value: "pulse"},
|
||||
{Key: "", Value: "x"},
|
||||
})
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("propagates regex validation error", func(t *testing.T) {
|
||||
store := tagtypestest.NewStore()
|
||||
m := &module{store: store}
|
||||
|
||||
_, _, err := m.resolve(context.Background(), valuer.GenerateUUID(), testKind, []tagtypes.PostableTag{
|
||||
{Key: "team!eng", Value: "pulse"},
|
||||
})
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
package impltag
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/tagtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
type store struct {
|
||||
sqlstore sqlstore.SQLStore
|
||||
}
|
||||
|
||||
func NewStore(sqlstore sqlstore.SQLStore) tagtypes.Store {
|
||||
return &store{sqlstore: sqlstore}
|
||||
}
|
||||
|
||||
func (s *store) List(ctx context.Context, orgID valuer.UUID, kind coretypes.Kind) ([]*tagtypes.Tag, error) {
|
||||
tags := make([]*tagtypes.Tag, 0)
|
||||
err := s.sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
Model(&tags).
|
||||
Where("org_id = ?", orgID).
|
||||
Where("kind = ?", kind).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return tags, nil
|
||||
}
|
||||
|
||||
func (s *store) ListByResource(ctx context.Context, orgID valuer.UUID, kind coretypes.Kind, resourceID valuer.UUID) ([]*tagtypes.Tag, error) {
|
||||
tags := make([]*tagtypes.Tag, 0)
|
||||
err := s.sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
Model(&tags).
|
||||
Join("JOIN tag_relation AS tr ON tr.tag_id = tag.id").
|
||||
Where("tr.kind = ?", kind).
|
||||
Where("tr.resource_id = ?", resourceID).
|
||||
Where("tag.org_id = ?", orgID).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return tags, nil
|
||||
}
|
||||
|
||||
func (s *store) ListByResources(ctx context.Context, orgID valuer.UUID, kind coretypes.Kind, resourceIDs []valuer.UUID) (map[valuer.UUID][]*tagtypes.Tag, error) {
|
||||
if len(resourceIDs) == 0 {
|
||||
return map[valuer.UUID][]*tagtypes.Tag{}, nil
|
||||
}
|
||||
|
||||
type joinedRow struct {
|
||||
tagtypes.Tag `bun:",extend"`
|
||||
ResourceID valuer.UUID `bun:"resource_id"`
|
||||
}
|
||||
|
||||
rows := make([]*joinedRow, 0)
|
||||
err := s.sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
Model(&rows).
|
||||
ColumnExpr("tag.*, tr.resource_id").
|
||||
Join("JOIN tag_relation AS tr ON tr.tag_id = tag.id").
|
||||
Where("tr.kind = ?", kind).
|
||||
Where("tr.resource_id IN (?)", bun.In(resourceIDs)).
|
||||
Where("tag.org_id = ?", orgID).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make(map[valuer.UUID][]*tagtypes.Tag)
|
||||
for _, r := range rows {
|
||||
tag := r.Tag
|
||||
out[r.ResourceID] = append(out[r.ResourceID], &tag)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *store) CreateOrGet(ctx context.Context, tags []*tagtypes.Tag) ([]*tagtypes.Tag, error) {
|
||||
if len(tags) == 0 {
|
||||
return tags, nil
|
||||
}
|
||||
// DO UPDATE on a self-set is a deliberate no-op write whose only purpose
|
||||
// is to make RETURNING fire on conflicting rows. Without it, RETURNING is
|
||||
// silent on the conflict path and we'd have to refetch by (key, value) to
|
||||
// learn the existing rows' IDs after a concurrent-insert race. Setting
|
||||
// key = tag.key (the existing row's value) preserves the first writer's
|
||||
// casing on case-only collisions.
|
||||
err := s.sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewInsert().
|
||||
Model(&tags).
|
||||
// On("CONFLICT (org_id, kind, (LOWER(key)), (LOWER(value))) DO UPDATE").
|
||||
Set("key = tag.key").
|
||||
Returning("*").
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return tags, nil
|
||||
}
|
||||
|
||||
func (s *store) CreateRelations(ctx context.Context, relations []*tagtypes.TagRelation) error {
|
||||
if len(relations) == 0 {
|
||||
return nil
|
||||
}
|
||||
_, err := s.sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewInsert().
|
||||
Model(&relations).
|
||||
On("CONFLICT (kind, resource_id, tag_id) DO NOTHING").
|
||||
Exec(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *store) DeleteRelationsExcept(ctx context.Context, orgID valuer.UUID, kind coretypes.Kind, resourceID valuer.UUID, keepTagIDs []valuer.UUID) error {
|
||||
// Scope the delete to the caller's org via a subquery on tag — bun's
|
||||
// DELETE-with-JOIN syntax isn't uniformly portable across Postgres/SQLite.
|
||||
tagIDsToDelete := s.sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
TableExpr("tag").
|
||||
Column("id").
|
||||
Where("org_id = ?", orgID)
|
||||
if len(keepTagIDs) > 0 {
|
||||
tagIDsToDelete = tagIDsToDelete.Where("id NOT IN (?)", bun.In(keepTagIDs))
|
||||
}
|
||||
_, err := s.sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewDelete().
|
||||
Model((*tagtypes.TagRelation)(nil)).
|
||||
Where("kind = ?", kind).
|
||||
Where("resource_id = ?", resourceID).
|
||||
Where("tag_id IN (?)", tagIDsToDelete).
|
||||
Exec(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *store) RunInTx(ctx context.Context, cb func(ctx context.Context) error) error {
|
||||
return s.sqlstore.RunInTxCtx(ctx, nil, cb)
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
package impltag
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory/factorytest"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore/sqlitesqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/tagtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
func newTestStore(t *testing.T) sqlstore.SQLStore {
|
||||
t.Helper()
|
||||
dbPath := filepath.Join(t.TempDir(), "test.db")
|
||||
store, err := sqlitesqlstore.New(context.Background(), factorytest.NewSettings(), sqlstore.Config{
|
||||
Provider: "sqlite",
|
||||
Connection: sqlstore.ConnectionConfig{
|
||||
MaxOpenConns: 1,
|
||||
MaxConnLifetime: 0,
|
||||
},
|
||||
Sqlite: sqlstore.SqliteConfig{
|
||||
Path: dbPath,
|
||||
Mode: "wal",
|
||||
BusyTimeout: 5 * time.Second,
|
||||
TransactionMode: "deferred",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = store.BunDB().NewCreateTable().
|
||||
Model((*tagtypes.Tag)(nil)).
|
||||
IfNotExists().
|
||||
Exec(context.Background())
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = store.BunDB().Exec(`CREATE UNIQUE INDEX IF NOT EXISTS uq_tag_org_kind_lower_key_lower_value ON tag (org_id, kind, LOWER(key), LOWER(value))`)
|
||||
require.NoError(t, err)
|
||||
return store
|
||||
}
|
||||
|
||||
var dashboardKind = coretypes.KindDashboard
|
||||
|
||||
func tagsByLowerKeyValue(t *testing.T, db *bun.DB) map[string]*tagtypes.Tag {
|
||||
t.Helper()
|
||||
all := make([]*tagtypes.Tag, 0)
|
||||
require.NoError(t, db.NewSelect().Model(&all).Scan(context.Background()))
|
||||
out := map[string]*tagtypes.Tag{}
|
||||
for _, tag := range all {
|
||||
out[strings.ToLower(tag.Key)+"\x00"+strings.ToLower(tag.Value)] = tag
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestStore_Create_PopulatesIDsOnFreshInsert(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
sqlstore := newTestStore(t)
|
||||
s := NewStore(sqlstore)
|
||||
|
||||
orgID := valuer.GenerateUUID()
|
||||
tagA := tagtypes.NewTag(orgID, dashboardKind, "tag", "Database")
|
||||
tagB := tagtypes.NewTag(orgID, dashboardKind, "team", "BLR")
|
||||
preIDA := tagA.ID
|
||||
preIDB := tagB.ID
|
||||
|
||||
got, err := s.CreateOrGet(ctx, []*tagtypes.Tag{tagA, tagB})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, got, 2)
|
||||
|
||||
// No race → pre-generated IDs stand. The slice is what we passed in,
|
||||
// confirming Scan didn't reallocate.
|
||||
assert.Equal(t, preIDA, got[0].ID)
|
||||
assert.Equal(t, preIDB, got[1].ID)
|
||||
|
||||
// And the rows are in the DB.
|
||||
stored := tagsByLowerKeyValue(t, sqlstore.BunDB())
|
||||
require.Contains(t, stored, "tag\x00database")
|
||||
require.Contains(t, stored, "team\x00blr")
|
||||
assert.Equal(t, preIDA, stored["tag\x00database"].ID)
|
||||
assert.Equal(t, preIDB, stored["team\x00blr"].ID)
|
||||
}
|
||||
|
||||
// todo (@namanverma): uncomment once unique index is there.
|
||||
//
|
||||
// func TestStore_Create_ConflictReturnsExistingRowID(t *testing.T) {
|
||||
// ctx := context.Background()
|
||||
// sqlstore := newTestStore(t)
|
||||
// s := NewStore(sqlstore)
|
||||
|
||||
// orgID := valuer.GenerateUUID()
|
||||
|
||||
// // Simulate a concurrent insert: someone else has already inserted "tag:Database".
|
||||
// winner := tagtypes.NewTag(orgID, dashboardKind, "tag", "Database")
|
||||
// _, err := s.CreateOrGet(ctx, []*tagtypes.Tag{winner})
|
||||
// require.NoError(t, err)
|
||||
// winnerID := winner.ID
|
||||
|
||||
// // Now our request runs with a different pre-generated ID for the same
|
||||
// // (key, value) — case differs but the functional unique index collapses
|
||||
// // them. RETURNING should overwrite our stale ID with winner's ID.
|
||||
// loser := tagtypes.NewTag(orgID, dashboardKind, "TAG", "DATABASE")
|
||||
// loserPreID := loser.ID
|
||||
// require.NotEqual(t, winnerID, loserPreID, "pre-generated IDs must differ for this test to be meaningful")
|
||||
|
||||
// got, err := s.CreateOrGet(ctx, []*tagtypes.Tag{loser})
|
||||
// require.NoError(t, err)
|
||||
// require.Len(t, got, 1)
|
||||
|
||||
// assert.Equal(t, winnerID, got[0].ID, "returned slice should carry the existing row's ID, not our stale one")
|
||||
// assert.Equal(t, winnerID, loser.ID, "input slice element is mutated in place")
|
||||
|
||||
// // And the DB still has exactly one row for that (lower(key), lower(value)) — winner's, with winner's casing.
|
||||
// stored := tagsByLowerKeyValue(t, sqlstore.BunDB())
|
||||
// require.Len(t, stored, 1)
|
||||
// assert.Equal(t, winnerID, stored["tag\x00database"].ID)
|
||||
// assert.Equal(t, "tag", stored["tag\x00database"].Key, "winner's casing preserved in key")
|
||||
// assert.Equal(t, "Database", stored["tag\x00database"].Value, "winner's casing preserved in value")
|
||||
// }
|
||||
|
||||
// func TestStore_Create_MixedFreshAndConflict(t *testing.T) {
|
||||
// ctx := context.Background()
|
||||
// sqlstore := newTestStore(t)
|
||||
// s := NewStore(sqlstore)
|
||||
|
||||
// orgID := valuer.GenerateUUID()
|
||||
// pre := tagtypes.NewTag(orgID, dashboardKind, "tag", "Database")
|
||||
// _, err := s.CreateOrGet(ctx, []*tagtypes.Tag{pre})
|
||||
// require.NoError(t, err)
|
||||
// preExistingID := pre.ID
|
||||
|
||||
// conflict := tagtypes.NewTag(orgID, dashboardKind, "tag", "Database")
|
||||
// fresh := tagtypes.NewTag(orgID, dashboardKind, "team", "BLR")
|
||||
// freshPreID := fresh.ID
|
||||
|
||||
// got, err := s.CreateOrGet(ctx, []*tagtypes.Tag{conflict, fresh})
|
||||
// require.NoError(t, err)
|
||||
// require.Len(t, got, 2)
|
||||
|
||||
// assert.Equal(t, preExistingID, got[0].ID, "conflicting row's ID overwritten with the existing row's")
|
||||
// assert.Equal(t, freshPreID, got[1].ID, "fresh row's pre-generated ID is preserved")
|
||||
// }
|
||||
@@ -1,20 +0,0 @@
|
||||
package tag
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/tagtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type Module interface {
|
||||
// SyncTags resolves the given postable tags (creating new rows as needed)
|
||||
// and reconciles the resource's links to exactly that set, all in one transaction.
|
||||
SyncTags(ctx context.Context, orgID valuer.UUID, kind coretypes.Kind, resourceID valuer.UUID, postable []tagtypes.PostableTag) ([]*tagtypes.Tag, error)
|
||||
|
||||
ListForResource(ctx context.Context, orgID valuer.UUID, kind coretypes.Kind, resourceID valuer.UUID) ([]*tagtypes.Tag, error)
|
||||
|
||||
// Resources with no tags are absent from the returned map.
|
||||
ListForResources(ctx context.Context, orgID valuer.UUID, kind coretypes.Kind, resourceIDs []valuer.UUID) (map[valuer.UUID][]*tagtypes.Tag, error)
|
||||
}
|
||||
@@ -17,7 +17,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization/implorganization"
|
||||
"github.com/SigNoz/signoz/pkg/modules/retention/implretention"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tag/impltag"
|
||||
"github.com/SigNoz/signoz/pkg/modules/user/impluser"
|
||||
"github.com/SigNoz/signoz/pkg/querier"
|
||||
"github.com/SigNoz/signoz/pkg/queryparser"
|
||||
@@ -46,7 +45,6 @@ func TestNewHandlers(t *testing.T) {
|
||||
emailing := emailingtest.New()
|
||||
queryParser := queryparser.New(providerSettings)
|
||||
require.NoError(t, err)
|
||||
tagModule := impltag.NewModule(impltag.NewStore(sqlstore))
|
||||
dashboardModule := impldashboard.NewModule(impldashboard.NewStore(sqlstore), providerSettings, nil, orgGetter, queryParser)
|
||||
|
||||
flagger, err := flagger.New(context.Background(), instrumentationtest.New().ToProviderSettings(), flagger.Config{}, flagger.MustNewRegistry())
|
||||
@@ -55,9 +53,8 @@ func TestNewHandlers(t *testing.T) {
|
||||
userRoleStore := impluser.NewUserRoleStore(sqlstore, providerSettings)
|
||||
|
||||
userGetter := impluser.NewGetter(impluser.NewStore(sqlstore, providerSettings), userRoleStore, flagger)
|
||||
|
||||
retentionGetter := implretention.NewGetter(implretention.NewStore(sqlstore))
|
||||
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter, userRoleStore, nil, nil, retentionGetter, flagger, tagModule)
|
||||
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter, userRoleStore, nil, nil, retentionGetter, flagger)
|
||||
|
||||
querierHandler := querier.NewHandler(providerSettings, nil, nil)
|
||||
registryHandler := factory.NewHandler(nil)
|
||||
|
||||
@@ -45,7 +45,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/spanmapper/implspanmapper"
|
||||
"github.com/SigNoz/signoz/pkg/modules/spanpercentile"
|
||||
"github.com/SigNoz/signoz/pkg/modules/spanpercentile/implspanpercentile"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tag"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tracedetail"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tracedetail/impltracedetail"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tracefunnel"
|
||||
@@ -89,7 +88,6 @@ type Modules struct {
|
||||
TraceDetail tracedetail.Module
|
||||
SpanMapper spanmapper.Module
|
||||
LLMPricingRule llmpricingrule.Module
|
||||
Tag tag.Module
|
||||
}
|
||||
|
||||
func NewModules(
|
||||
@@ -115,7 +113,6 @@ func NewModules(
|
||||
cloudIntegrationModule cloudintegration.Module,
|
||||
retentionGetter retention.Getter,
|
||||
fl flagger.Flagger,
|
||||
tagModule tag.Module,
|
||||
) Modules {
|
||||
quickfilter := implquickfilter.NewModule(implquickfilter.NewStore(sqlstore))
|
||||
orgSetter := implorganization.NewSetter(implorganization.NewStore(sqlstore), alertmanager, quickfilter)
|
||||
@@ -148,6 +145,5 @@ func NewModules(
|
||||
TraceDetail: impltracedetail.NewModule(impltracedetail.NewTraceStore(telemetryStore), providerSettings, config.TraceDetail),
|
||||
SpanMapper: implspanmapper.NewModule(implspanmapper.NewStore(sqlstore)),
|
||||
LLMPricingRule: impllmpricingrule.NewModule(impllmpricingrule.NewStore(sqlstore)),
|
||||
Tag: tagModule,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/retention/implretention"
|
||||
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
|
||||
"github.com/SigNoz/signoz/pkg/modules/serviceaccount/implserviceaccount"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tag/impltag"
|
||||
"github.com/SigNoz/signoz/pkg/modules/user/impluser"
|
||||
"github.com/SigNoz/signoz/pkg/queryparser"
|
||||
"github.com/SigNoz/signoz/pkg/sharder"
|
||||
@@ -47,7 +46,6 @@ func TestNewModules(t *testing.T) {
|
||||
emailing := emailingtest.New()
|
||||
queryParser := queryparser.New(providerSettings)
|
||||
require.NoError(t, err)
|
||||
tagModule := impltag.NewModule(impltag.NewStore(sqlstore))
|
||||
dashboardModule := impldashboard.NewModule(impldashboard.NewStore(sqlstore), providerSettings, nil, orgGetter, queryParser)
|
||||
|
||||
flagger, err := flagger.New(context.Background(), instrumentationtest.New().ToProviderSettings(), flagger.Config{}, flagger.MustNewRegistry())
|
||||
@@ -60,8 +58,7 @@ func TestNewModules(t *testing.T) {
|
||||
serviceAccount := implserviceaccount.NewModule(implserviceaccount.NewStore(sqlstore), nil, nil, nil, providerSettings, serviceaccount.Config{})
|
||||
|
||||
retentionGetter := implretention.NewGetter(implretention.NewStore(sqlstore))
|
||||
|
||||
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter, userRoleStore, serviceAccount, implcloudintegration.NewModule(), retentionGetter, flagger, tagModule)
|
||||
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter, userRoleStore, serviceAccount, implcloudintegration.NewModule(), retentionGetter, flagger)
|
||||
|
||||
reflectVal := reflect.ValueOf(modules)
|
||||
for i := 0; i < reflectVal.NumField(); i++ {
|
||||
|
||||
@@ -201,7 +201,6 @@ func NewSQLMigrationProviderFactories(
|
||||
sqlmigration.NewAddSpanMapperFactory(sqlstore, sqlschema),
|
||||
sqlmigration.NewAddLLMPricingRulesFactory(sqlstore, sqlschema),
|
||||
sqlmigration.NewMigrateMetaresourcesTuplesFactory(sqlstore),
|
||||
sqlmigration.NewAddTagsFactory(sqlstore, sqlschema),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -32,7 +32,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/rulestatehistory"
|
||||
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
|
||||
"github.com/SigNoz/signoz/pkg/modules/serviceaccount/implserviceaccount"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tag/impltag"
|
||||
"github.com/SigNoz/signoz/pkg/modules/user/impluser"
|
||||
"github.com/SigNoz/signoz/pkg/prometheus"
|
||||
"github.com/SigNoz/signoz/pkg/querier"
|
||||
@@ -333,11 +332,6 @@ func New(
|
||||
// Initialize query parser (needed for dashboard module)
|
||||
queryParser := queryparser.New(providerSettings)
|
||||
|
||||
// Initialize tag module — shared across modules that link entities to tags
|
||||
// (currently dashboard; future: alerts, RBAC). Built once here and injected
|
||||
// where needed.
|
||||
tagModule := impltag.NewModule(impltag.NewStore(sqlstore))
|
||||
|
||||
// Initialize dashboard module
|
||||
dashboard := dashboardModuleCallback(sqlstore, providerSettings, analytics, orgGetter, queryParser, querier, licensing)
|
||||
|
||||
@@ -461,7 +455,7 @@ func New(
|
||||
}
|
||||
|
||||
// Initialize all modules
|
||||
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, analytics, querier, telemetrystore, telemetryMetadataStore, authNs, authz, cache, queryParser, config, dashboard, userGetter, userRoleStore, serviceAccount, cloudIntegrationModule, retentionGetter, flagger, tagModule)
|
||||
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, analytics, querier, telemetrystore, telemetryMetadataStore, authNs, authz, cache, queryParser, config, dashboard, userGetter, userRoleStore, serviceAccount, cloudIntegrationModule, retentionGetter, flagger)
|
||||
|
||||
// Initialize ruler from the variant-specific provider factories
|
||||
rulerInstance, err := factory.NewProviderFromNamedMap(ctx, providerSettings, config.Ruler, rulerProviderFactories(cache, alertmanager, sqlstore, telemetrystore, telemetryMetadataStore, prometheus, orgGetter, modules.RuleStateHistory, querier, queryParser), "signoz")
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
package sqlmigration
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/sqlschema"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/uptrace/bun"
|
||||
"github.com/uptrace/bun/migrate"
|
||||
)
|
||||
|
||||
type addTags struct {
|
||||
sqlstore sqlstore.SQLStore
|
||||
sqlschema sqlschema.SQLSchema
|
||||
}
|
||||
|
||||
func NewAddTagsFactory(sqlstore sqlstore.SQLStore, sqlschema sqlschema.SQLSchema) factory.ProviderFactory[SQLMigration, Config] {
|
||||
return factory.NewProviderFactory(factory.MustNewName("add_tags"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
|
||||
return &addTags{
|
||||
sqlstore: sqlstore,
|
||||
sqlschema: sqlschema,
|
||||
}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (migration *addTags) Register(migrations *migrate.Migrations) error {
|
||||
return migrations.Register(migration.Up, migration.Down)
|
||||
}
|
||||
|
||||
func (migration *addTags) Up(ctx context.Context, db *bun.DB) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
_ = tx.Rollback()
|
||||
}()
|
||||
|
||||
sqls := [][]byte{}
|
||||
|
||||
tagTableSQLs := migration.sqlschema.Operator().CreateTable(&sqlschema.Table{
|
||||
Name: "tag",
|
||||
Columns: []*sqlschema.Column{
|
||||
{Name: "id", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "key", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "value", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "org_id", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "kind", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "created_at", DataType: sqlschema.DataTypeTimestamp, Nullable: false},
|
||||
{Name: "updated_at", DataType: sqlschema.DataTypeTimestamp, Nullable: false},
|
||||
},
|
||||
PrimaryKeyConstraint: &sqlschema.PrimaryKeyConstraint{ColumnNames: []sqlschema.ColumnName{"id"}},
|
||||
ForeignKeyConstraints: []*sqlschema.ForeignKeyConstraint{
|
||||
{
|
||||
ReferencingColumnName: sqlschema.ColumnName("org_id"),
|
||||
ReferencedTableName: sqlschema.TableName("organizations"),
|
||||
ReferencedColumnName: sqlschema.ColumnName("id"),
|
||||
},
|
||||
},
|
||||
})
|
||||
sqls = append(sqls, tagTableSQLs...)
|
||||
|
||||
// TODO (@namanverma): add a unique index for tags: (org_id, kind, (LOWER(key)), (LOWER(value)))
|
||||
|
||||
tagRelationsTableSQLs := migration.sqlschema.Operator().CreateTable(&sqlschema.Table{
|
||||
Name: "tag_relation",
|
||||
Columns: []*sqlschema.Column{
|
||||
{Name: "id", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "kind", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "resource_id", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "tag_id", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "created_at", DataType: sqlschema.DataTypeTimestamp, Nullable: false},
|
||||
},
|
||||
PrimaryKeyConstraint: &sqlschema.PrimaryKeyConstraint{ColumnNames: []sqlschema.ColumnName{"id"}},
|
||||
ForeignKeyConstraints: []*sqlschema.ForeignKeyConstraint{
|
||||
{
|
||||
ReferencingColumnName: sqlschema.ColumnName("tag_id"),
|
||||
ReferencedTableName: sqlschema.TableName("tag"),
|
||||
ReferencedColumnName: sqlschema.ColumnName("id"),
|
||||
},
|
||||
},
|
||||
})
|
||||
sqls = append(sqls, tagRelationsTableSQLs...)
|
||||
|
||||
tagRelationUniqueIndexSQLs := migration.sqlschema.Operator().CreateIndex(
|
||||
&sqlschema.UniqueIndex{
|
||||
TableName: "tag_relation",
|
||||
ColumnNames: []sqlschema.ColumnName{"kind", "resource_id", "tag_id"},
|
||||
},
|
||||
)
|
||||
sqls = append(sqls, tagRelationUniqueIndexSQLs...)
|
||||
|
||||
for _, sql := range sqls {
|
||||
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (migration *addTags) Down(_ context.Context, _ *bun.DB) error {
|
||||
return nil
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
package tagtypes
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type Store interface {
|
||||
List(ctx context.Context, orgID valuer.UUID, kind coretypes.Kind) ([]*Tag, error)
|
||||
|
||||
ListByResource(ctx context.Context, orgID valuer.UUID, kind coretypes.Kind, resourceID valuer.UUID) ([]*Tag, error)
|
||||
|
||||
ListByResources(ctx context.Context, orgID valuer.UUID, kind coretypes.Kind, resourceIDs []valuer.UUID) (map[valuer.UUID][]*Tag, error)
|
||||
|
||||
// CreateOrGet upserts the given tags and returns them with authoritative IDs.
|
||||
// On conflict on (org_id, kind, LOWER(key), LOWER(value)) — which
|
||||
// happens only when a concurrent insert raced ours, including casing-only
|
||||
// collisions — the returned entry carries the existing row's ID rather
|
||||
// than the pre-generated one in the input.
|
||||
CreateOrGet(ctx context.Context, tags []*Tag) ([]*Tag, error)
|
||||
|
||||
// CreateRelations inserts tag-resource relations. Conflicts on the composite primary key are ignored.
|
||||
CreateRelations(ctx context.Context, relations []*TagRelation) error
|
||||
|
||||
DeleteRelationsExcept(ctx context.Context, orgID valuer.UUID, kind coretypes.Kind, resourceID valuer.UUID, keepTagIDs []valuer.UUID) error
|
||||
|
||||
RunInTx(ctx context.Context, cb func(ctx context.Context) error) error
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
package tagtypes
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
const (
|
||||
MAX_LEN_TAG_KEY = 32
|
||||
MAX_LEN_TAG_VALUE = 32
|
||||
)
|
||||
|
||||
var (
|
||||
tagKeyRegex = regexp.MustCompile(`^[a-zA-Z$_@{#][a-zA-Z0-9$_@#{}:/-]*$`)
|
||||
tagValueRegex = regexp.MustCompile(`^[a-zA-Z0-9$_@#{}:.+=/-]*$`)
|
||||
|
||||
ErrCodeTagInvalidKey = errors.MustNewCode("tag_invalid_key")
|
||||
ErrCodeTagInvalidValue = errors.MustNewCode("tag_invalid_value")
|
||||
ErrCodeTagNotFound = errors.MustNewCode("tag_not_found")
|
||||
)
|
||||
|
||||
type Tag struct {
|
||||
bun.BaseModel `bun:"table:tag,alias:tag"`
|
||||
|
||||
types.Identifiable
|
||||
types.TimeAuditable
|
||||
Key string `json:"key" required:"true" bun:"key,type:text,notnull"`
|
||||
Value string `json:"value" required:"true" bun:"value,type:text,notnull"`
|
||||
OrgID valuer.UUID `json:"orgId" required:"true" bun:"org_id,type:text,notnull"`
|
||||
Kind coretypes.Kind `json:"kind" required:"true" bun:"kind,type:text,notnull"`
|
||||
}
|
||||
|
||||
type PostableTag struct {
|
||||
Key string `json:"key" required:"true"`
|
||||
Value string `json:"value" required:"true"`
|
||||
}
|
||||
|
||||
type GettableTag = PostableTag
|
||||
|
||||
func NewGettableTagFromTag(tag *Tag) *GettableTag {
|
||||
return &GettableTag{Key: tag.Key, Value: tag.Value}
|
||||
}
|
||||
|
||||
func NewGettableTagsFromTags(tags []*Tag) []*GettableTag {
|
||||
out := make([]*GettableTag, len(tags))
|
||||
for i, t := range tags {
|
||||
out[i] = NewGettableTagFromTag(t)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func NewPostableTagFromTag(tag *Tag) PostableTag {
|
||||
return PostableTag{Key: tag.Key, Value: tag.Value}
|
||||
}
|
||||
|
||||
func NewPostableTagsFromTags(tags []*Tag) []PostableTag {
|
||||
out := make([]PostableTag, len(tags))
|
||||
for i, t := range tags {
|
||||
out[i] = NewPostableTagFromTag(t)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func NewTag(orgID valuer.UUID, kind coretypes.Kind, key, value string) *Tag {
|
||||
now := time.Now()
|
||||
return &Tag{
|
||||
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
|
||||
TimeAuditable: types.TimeAuditable{
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
Key: key,
|
||||
Value: value,
|
||||
OrgID: orgID,
|
||||
Kind: kind,
|
||||
}
|
||||
}
|
||||
|
||||
// ValidatePostableTag trims and validates a user-supplied (key, value) pair.
|
||||
// Returns the cleaned values on success. Entity-specific reserved-key checks
|
||||
// (e.g. dashboard column names that would collide with the list-query DSL) are
|
||||
// the caller's responsibility — perform them before calling into the tag module.
|
||||
func ValidatePostableTag(p PostableTag) (string, string, error) {
|
||||
key := strings.TrimSpace(p.Key)
|
||||
value := strings.TrimSpace(p.Value)
|
||||
if key == "" {
|
||||
return "", "", errors.Newf(errors.TypeInvalidInput, ErrCodeTagInvalidKey, "tag key cannot be empty")
|
||||
}
|
||||
if value == "" {
|
||||
return "", "", errors.Newf(errors.TypeInvalidInput, ErrCodeTagInvalidValue, "tag value cannot be empty")
|
||||
}
|
||||
if !tagKeyRegex.MatchString(key) {
|
||||
return "", "", errors.Newf(errors.TypeInvalidInput, ErrCodeTagInvalidKey, "tag key %q contains disallowed characters", key)
|
||||
}
|
||||
if !tagValueRegex.MatchString(value) {
|
||||
return "", "", errors.Newf(errors.TypeInvalidInput, ErrCodeTagInvalidValue, "tag value %q contains disallowed characters", value)
|
||||
}
|
||||
if utf8.RuneCountInString(key) > MAX_LEN_TAG_KEY {
|
||||
return "", "", errors.Newf(errors.TypeInvalidInput, ErrCodeTagInvalidKey, "tag key %q exceeds the %d-character limit", key, MAX_LEN_TAG_KEY)
|
||||
}
|
||||
if utf8.RuneCountInString(value) > MAX_LEN_TAG_VALUE {
|
||||
return "", "", errors.Newf(errors.TypeInvalidInput, ErrCodeTagInvalidValue, "tag value %q exceeds the %d-character limit", value, MAX_LEN_TAG_VALUE)
|
||||
}
|
||||
return key, value, nil
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
package tagtypes
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
type TagRelation struct {
|
||||
bun.BaseModel `bun:"table:tag_relation,alias:tag_relation"`
|
||||
|
||||
types.Identifiable
|
||||
Kind coretypes.Kind `json:"kind" required:"true" bun:"kind,type:text,notnull"`
|
||||
ResourceID valuer.UUID `json:"resourceId" required:"true" bun:"resource_id,type:text,notnull"`
|
||||
TagID valuer.UUID `json:"tagId" required:"true" bun:"tag_id,type:text,notnull"`
|
||||
CreatedAt time.Time `json:"createdAt" bun:"created_at,notnull"`
|
||||
}
|
||||
|
||||
func NewTagRelation(kind coretypes.Kind, resourceID valuer.UUID, tagID valuer.UUID) *TagRelation {
|
||||
return &TagRelation{
|
||||
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
|
||||
Kind: kind,
|
||||
ResourceID: resourceID,
|
||||
TagID: tagID,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
func NewTagRelations(kind coretypes.Kind, resourceID valuer.UUID, tagIDs []valuer.UUID) []*TagRelation {
|
||||
relations := make([]*TagRelation, 0, len(tagIDs))
|
||||
for _, tagID := range tagIDs {
|
||||
relations = append(relations, NewTagRelation(kind, resourceID, tagID))
|
||||
}
|
||||
return relations
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
package tagtypes
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestValidatePostableTag(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input PostableTag
|
||||
wantKey string
|
||||
wantValue string
|
||||
wantError bool
|
||||
}{
|
||||
{name: "simple pair", input: PostableTag{Key: "team", Value: "pulse"}, wantKey: "team", wantValue: "pulse"},
|
||||
{name: "preserves casing", input: PostableTag{Key: "Team", Value: "Pulse"}, wantKey: "Team", wantValue: "Pulse"},
|
||||
{name: "trims key", input: PostableTag{Key: " team ", Value: "pulse"}, wantKey: "team", wantValue: "pulse"},
|
||||
{name: "trims value", input: PostableTag{Key: "team", Value: " pulse "}, wantKey: "team", wantValue: "pulse"},
|
||||
|
||||
{name: "empty key rejected", input: PostableTag{Key: "", Value: "pulse"}, wantError: true},
|
||||
{name: "empty value rejected", input: PostableTag{Key: "team", Value: ""}, wantError: true},
|
||||
{name: "whitespace-only key rejected", input: PostableTag{Key: " ", Value: "pulse"}, wantError: true},
|
||||
{name: "whitespace-only value rejected", input: PostableTag{Key: "team", Value: " "}, wantError: true},
|
||||
|
||||
{name: "slash accepted", input: PostableTag{Key: "team/eng", Value: "pulse/events"}, wantKey: "team/eng", wantValue: "pulse/events"},
|
||||
{name: "colon accepted", input: PostableTag{Key: "team:eng", Value: "env:prod"}, wantKey: "team:eng", wantValue: "env:prod"},
|
||||
{name: "extra punctuation accepted in both", input: PostableTag{Key: "a_b-c@d#e$f{g}h", Value: "a_b-c@d#e$f{g}h"}, wantKey: "a_b-c@d#e$f{g}h", wantValue: "a_b-c@d#e$f{g}h"},
|
||||
|
||||
// Key is strict; value allows the extra `. + =` plus leading digits.
|
||||
{name: "dot in key rejected", input: PostableTag{Key: "team.eng", Value: "pulse"}, wantError: true},
|
||||
{name: "dot in value accepted", input: PostableTag{Key: "team", Value: "pulse.events"}, wantKey: "team", wantValue: "pulse.events"},
|
||||
{name: "plus in key rejected", input: PostableTag{Key: "team+eng", Value: "pulse"}, wantError: true},
|
||||
{name: "plus in value accepted", input: PostableTag{Key: "team", Value: "a+b"}, wantKey: "team", wantValue: "a+b"},
|
||||
{name: "equals in key rejected", input: PostableTag{Key: "team=eng", Value: "pulse"}, wantError: true},
|
||||
{name: "equals in value accepted", input: PostableTag{Key: "team", Value: "a=b"}, wantKey: "team", wantValue: "a=b"},
|
||||
{name: "leading digit in key rejected", input: PostableTag{Key: "2024team", Value: "pulse"}, wantError: true},
|
||||
{name: "leading digit in value accepted", input: PostableTag{Key: "team", Value: "2024_team"}, wantKey: "team", wantValue: "2024_team"},
|
||||
|
||||
{name: "unicode letter in key rejected", input: PostableTag{Key: "チーム", Value: "pulse"}, wantError: true},
|
||||
{name: "unicode letter in value rejected", input: PostableTag{Key: "team", Value: "東京"}, wantError: true},
|
||||
|
||||
{name: "internal space in key rejected", input: PostableTag{Key: "team eng", Value: "pulse"}, wantError: true},
|
||||
{name: "internal space in value rejected", input: PostableTag{Key: "team", Value: "pulse two"}, wantError: true},
|
||||
|
||||
{name: "disallowed char in key rejected", input: PostableTag{Key: "team!eng", Value: "pulse"}, wantError: true},
|
||||
{name: "disallowed char in value rejected", input: PostableTag{Key: "team", Value: "pulse!one"}, wantError: true},
|
||||
{name: "control char rejected", input: PostableTag{Key: "team\tone", Value: "pulse"}, wantError: true},
|
||||
|
||||
{name: "key at the 32-char limit accepted", input: PostableTag{Key: "abcdefghijklmnopabcdefghijklmnop", Value: "pulse"}, wantKey: "abcdefghijklmnopabcdefghijklmnop", wantValue: "pulse"},
|
||||
{name: "value at the 32-char limit accepted", input: PostableTag{Key: "team", Value: "abcdefghijklmnopabcdefghijklmnop"}, wantKey: "team", wantValue: "abcdefghijklmnopabcdefghijklmnop"},
|
||||
{name: "key over the 32-char limit rejected", input: PostableTag{Key: "abcdefghijklmnopabcdefghijklmnopq", Value: "pulse"}, wantError: true},
|
||||
{name: "value over the 32-char limit rejected", input: PostableTag{Key: "team", Value: "abcdefghijklmnopabcdefghijklmnopq"}, wantError: true},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
gotKey, gotValue, err := ValidatePostableTag(tc.input)
|
||||
if tc.wantError {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.wantKey, gotKey)
|
||||
assert.Equal(t, tc.wantValue, gotValue)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
package tagtypestest
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/tagtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
// MockStore is an in-memory tagtypes.MockStore implementation for tests. Most methods
|
||||
// are inert no-ops; List returns the contents of Tags and increments
|
||||
// ListCallCount so tests can assert on lookup behavior. Set Tags directly to
|
||||
// preload fixtures.
|
||||
type MockStore struct {
|
||||
Tags []*tagtypes.Tag
|
||||
ListCallCount int
|
||||
}
|
||||
|
||||
func NewStore() *MockStore {
|
||||
return &MockStore{}
|
||||
}
|
||||
|
||||
func (s *MockStore) List(_ context.Context, _ valuer.UUID, _ coretypes.Kind) ([]*tagtypes.Tag, error) {
|
||||
s.ListCallCount++
|
||||
out := make([]*tagtypes.Tag, len(s.Tags))
|
||||
copy(out, s.Tags)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *MockStore) CreateOrGet(_ context.Context, tags []*tagtypes.Tag) ([]*tagtypes.Tag, error) {
|
||||
return tags, nil
|
||||
}
|
||||
|
||||
func (s *MockStore) CreateRelations(_ context.Context, _ []*tagtypes.TagRelation) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MockStore) ListByResource(_ context.Context, _ valuer.UUID, _ coretypes.Kind, _ valuer.UUID) ([]*tagtypes.Tag, error) {
|
||||
return []*tagtypes.Tag{}, nil
|
||||
}
|
||||
|
||||
func (s *MockStore) ListByResources(_ context.Context, _ valuer.UUID, _ coretypes.Kind, _ []valuer.UUID) (map[valuer.UUID][]*tagtypes.Tag, error) {
|
||||
return map[valuer.UUID][]*tagtypes.Tag{}, nil
|
||||
}
|
||||
|
||||
func (s *MockStore) DeleteRelationsExcept(_ context.Context, _ valuer.UUID, _ coretypes.Kind, _ valuer.UUID, _ []valuer.UUID) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MockStore) RunInTx(ctx context.Context, cb func(ctx context.Context) error) error {
|
||||
return cb(ctx)
|
||||
}
|
||||
Reference in New Issue
Block a user