mirror of
https://github.com/SigNoz/signoz.git
synced 2026-05-11 20:50:35 +01:00
Compare commits
3 Commits
main
...
feat/alert
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
670cb7aca5 | ||
|
|
0daf7a12da | ||
|
|
cc7d7017ae |
@@ -50,7 +50,7 @@
|
||||
"@signozhq/design-tokens": "2.1.4",
|
||||
"@signozhq/icons": "0.1.0",
|
||||
"@signozhq/resizable": "0.0.2",
|
||||
"@signozhq/ui": "0.0.18",
|
||||
"@signozhq/ui": "0.0.19",
|
||||
"@tanstack/react-table": "8.21.3",
|
||||
"@tanstack/react-virtual": "3.13.22",
|
||||
"@uiw/codemirror-theme-copilot": "4.23.11",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
123
frontend/src/components/Alerts/LabelColumn/LabelColumn.tsx
Normal file
123
frontend/src/components/Alerts/LabelColumn/LabelColumn.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import {
|
||||
Badge,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
TooltipSimple,
|
||||
} from '@signozhq/ui';
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
function getLabelRenderingValue(label: string, value?: string): JSX.Element {
|
||||
const title = value ? `${label}: ${value}` : label;
|
||||
const content = value ? `${label}: ${value}` : label;
|
||||
|
||||
return (
|
||||
<span title={title} className={styles.labelValue}>
|
||||
{content}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function getLabelAndValueContent(label: string, value?: string): string {
|
||||
return value ? `${label}: ${value}` : label;
|
||||
}
|
||||
|
||||
function LabelTag({
|
||||
label,
|
||||
value,
|
||||
color,
|
||||
}: {
|
||||
label: string;
|
||||
color?: LabelColumnProps['color'];
|
||||
value?: LabelColumnProps['value'];
|
||||
}): JSX.Element {
|
||||
const tooltipTitle = value?.[label] ? `${label}: ${value[label]}` : label;
|
||||
|
||||
return (
|
||||
<TooltipSimple title={tooltipTitle}>
|
||||
<Badge
|
||||
color={color}
|
||||
className={styles.labelBadge}
|
||||
variant="outline"
|
||||
data-testid={`label-tag-${label}`}
|
||||
>
|
||||
{getLabelRenderingValue(label, value?.[label])}
|
||||
</Badge>
|
||||
</TooltipSimple>
|
||||
);
|
||||
}
|
||||
|
||||
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} />
|
||||
))}
|
||||
{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}`}
|
||||
>
|
||||
{getLabelAndValueContent(label, value?.[label])}
|
||||
</Badge>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LabelColumn;
|
||||
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,4 @@
|
||||
.lastUpdated {
|
||||
font-size: 12px;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { render, screen, act } from '@testing-library/react';
|
||||
|
||||
import LastUpdatedText from './LastUpdatedText';
|
||||
|
||||
describe('LastUpdatedText', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('should return null when updatedAt is null', () => {
|
||||
const { container } = render(<LastUpdatedText updatedAt={null} />);
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('should render formatted time distance', () => {
|
||||
const now = Date.now();
|
||||
const fiveMinutesAgo = now - 5 * 60 * 1000;
|
||||
|
||||
jest.setSystemTime(now);
|
||||
|
||||
render(<LastUpdatedText updatedAt={fiveMinutesAgo} />);
|
||||
|
||||
expect(screen.getByTestId('last-updated-text')).toHaveTextContent(
|
||||
/Updated.*5 minutes ago/,
|
||||
);
|
||||
});
|
||||
|
||||
it('should have title with ISO formatted date', () => {
|
||||
const now = Date.now();
|
||||
const fiveMinutesAgo = now - 5 * 60 * 1000;
|
||||
|
||||
jest.setSystemTime(now);
|
||||
|
||||
render(<LastUpdatedText updatedAt={fiveMinutesAgo} />);
|
||||
|
||||
expect(screen.getByTestId('last-updated-text').title).toMatch(
|
||||
/^\d{4}-\d{2}-\d{2}/,
|
||||
);
|
||||
});
|
||||
|
||||
it('should update text periodically', () => {
|
||||
const now = Date.now();
|
||||
|
||||
jest.setSystemTime(now);
|
||||
|
||||
render(<LastUpdatedText updatedAt={now} />);
|
||||
|
||||
expect(screen.getByTestId('last-updated-text')).toHaveTextContent(
|
||||
/Updated.*less than a minute ago/,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(61000);
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('last-updated-text')).toHaveTextContent(
|
||||
/Updated.*1 minute ago/,
|
||||
);
|
||||
});
|
||||
|
||||
it('should cleanup interval on unmount', () => {
|
||||
const clearIntervalSpy = jest.spyOn(global, 'clearInterval');
|
||||
const now = Date.now();
|
||||
|
||||
jest.setSystemTime(now);
|
||||
|
||||
const { unmount } = render(<LastUpdatedText updatedAt={now} />);
|
||||
|
||||
unmount();
|
||||
|
||||
expect(clearIntervalSpy).toHaveBeenCalled();
|
||||
clearIntervalSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should render with recent timestamp', () => {
|
||||
const now = Date.now();
|
||||
const tenSecondsAgo = now - 10 * 1000;
|
||||
|
||||
jest.setSystemTime(now);
|
||||
|
||||
render(<LastUpdatedText updatedAt={tenSecondsAgo} />);
|
||||
|
||||
expect(screen.getByTestId('last-updated-text')).toHaveTextContent(
|
||||
/Updated.*less than a minute ago/,
|
||||
);
|
||||
});
|
||||
|
||||
it('should render with hour-old timestamp', () => {
|
||||
const now = Date.now();
|
||||
const oneHourAgo = now - 60 * 60 * 1000;
|
||||
|
||||
jest.setSystemTime(now);
|
||||
|
||||
render(<LastUpdatedText updatedAt={oneHourAgo} />);
|
||||
|
||||
expect(screen.getByTestId('last-updated-text')).toHaveTextContent(
|
||||
/Updated.*1 hour ago/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { memo, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { formatDistanceToNow, formatISO } from 'date-fns';
|
||||
import styles from './LastUpdatedText.module.scss';
|
||||
|
||||
interface LastUpdatedTextProps {
|
||||
updatedAt: number | null;
|
||||
}
|
||||
|
||||
const LastUpdatedText = memo(function LastUpdatedText({
|
||||
updatedAt,
|
||||
}: LastUpdatedTextProps): JSX.Element | null {
|
||||
const [text, setText] = useState('');
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const lastUpdatedAtDate = useMemo(() => {
|
||||
if (!updatedAt) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
try {
|
||||
return formatISO(updatedAt);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
|
||||
return 'Failed to parse date.';
|
||||
}
|
||||
}, [updatedAt]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!updatedAt) {
|
||||
setText('');
|
||||
return;
|
||||
}
|
||||
|
||||
const updateText = (): void => {
|
||||
setText(formatDistanceToNow(updatedAt, { addSuffix: true }));
|
||||
};
|
||||
|
||||
updateText();
|
||||
intervalRef.current = setInterval(updateText, 1000);
|
||||
|
||||
return (): void => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
};
|
||||
}, [updatedAt]);
|
||||
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className={styles.lastUpdated}
|
||||
title={lastUpdatedAtDate}
|
||||
data-testid="last-updated-text"
|
||||
>
|
||||
Updated {text}
|
||||
</span>
|
||||
);
|
||||
});
|
||||
|
||||
export default LastUpdatedText;
|
||||
1
frontend/src/components/Alerts/LastUpdatedText/index.ts
Normal file
1
frontend/src/components/Alerts/LastUpdatedText/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './LastUpdatedText';
|
||||
50
frontend/src/components/Alerts/StatCard/StatCard.module.scss
Normal file
50
frontend/src/components/Alerts/StatCard/StatCard.module.scss
Normal file
@@ -0,0 +1,50 @@
|
||||
.statCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-1);
|
||||
padding: var(--spacing-4) var(--spacing-7);
|
||||
background: var(--l2-background);
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--l1-border);
|
||||
min-width: 80px;
|
||||
height: 58px;
|
||||
box-sizing: border-box;
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
background-color 0.15s ease;
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
margin: 0;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.statCardClickable {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--l2-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.statCardActive {
|
||||
border-color: var(--primary);
|
||||
background: color-mix(in srgb, var(--primary) 10%, var(--l2-background));
|
||||
}
|
||||
|
||||
.statLabel {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--l2-foreground);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.statValue {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--l1-foreground);
|
||||
line-height: 1.2;
|
||||
}
|
||||
101
frontend/src/components/Alerts/StatCard/StatCard.test.tsx
Normal file
101
frontend/src/components/Alerts/StatCard/StatCard.test.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import StatCard from './StatCard';
|
||||
|
||||
describe('StatCard', () => {
|
||||
it('should render label and value', () => {
|
||||
render(<StatCard label="Firing" value={5} />);
|
||||
|
||||
expect(screen.getByTestId('stat-card-label')).toHaveTextContent('Firing');
|
||||
expect(screen.getByTestId('stat-card-value')).toHaveTextContent('5');
|
||||
});
|
||||
|
||||
it('should apply custom color to value', () => {
|
||||
render(<StatCard label="Firing" value={5} color="red" />);
|
||||
|
||||
expect(screen.getByTestId('stat-card-value')).toHaveStyle({ color: 'red' });
|
||||
});
|
||||
|
||||
it('should not have button role when onClick is not provided', () => {
|
||||
render(<StatCard label="Firing" value={5} />);
|
||||
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have button role when onClick is provided', () => {
|
||||
const onClick = jest.fn();
|
||||
|
||||
render(<StatCard label="Firing" value={5} onClick={onClick} />);
|
||||
|
||||
expect(screen.getByRole('button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call onClick with exclusive: false on regular click', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClick = jest.fn();
|
||||
|
||||
render(<StatCard label="Firing" value={5} onClick={onClick} />);
|
||||
|
||||
await user.click(screen.getByTestId('stat-card'));
|
||||
|
||||
expect(onClick).toHaveBeenCalledWith({ exclusive: false });
|
||||
});
|
||||
|
||||
it('should call onClick with exclusive: true on alt+click', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClick = jest.fn();
|
||||
|
||||
render(<StatCard label="Firing" value={5} onClick={onClick} />);
|
||||
|
||||
await user.keyboard('{Alt>}');
|
||||
await user.click(screen.getByTestId('stat-card'));
|
||||
await user.keyboard('{/Alt}');
|
||||
|
||||
expect(onClick).toHaveBeenCalledWith({ exclusive: true });
|
||||
});
|
||||
|
||||
it('should call onClick on Enter key press', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClick = jest.fn();
|
||||
|
||||
render(<StatCard label="Firing" value={5} onClick={onClick} />);
|
||||
|
||||
const card = screen.getByTestId('stat-card');
|
||||
card.focus();
|
||||
await user.keyboard('{Enter}');
|
||||
|
||||
expect(onClick).toHaveBeenCalledWith({ exclusive: false });
|
||||
});
|
||||
|
||||
it('should call onClick on Space key press', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClick = jest.fn();
|
||||
|
||||
render(<StatCard label="Firing" value={5} onClick={onClick} />);
|
||||
|
||||
const card = screen.getByTestId('stat-card');
|
||||
card.focus();
|
||||
await user.keyboard(' ');
|
||||
|
||||
expect(onClick).toHaveBeenCalledWith({ exclusive: false });
|
||||
});
|
||||
|
||||
it('should be focusable when onClick is provided', () => {
|
||||
render(<StatCard label="Firing" value={5} onClick={jest.fn()} />);
|
||||
|
||||
expect(screen.getByTestId('stat-card')).toHaveAttribute('tabindex', '0');
|
||||
});
|
||||
|
||||
it('should not be focusable when onClick is not provided', () => {
|
||||
render(<StatCard label="Firing" value={5} />);
|
||||
|
||||
expect(screen.getByTestId('stat-card')).not.toHaveAttribute('tabindex');
|
||||
});
|
||||
|
||||
it('should not have color style when color prop is not provided', () => {
|
||||
render(<StatCard label="Firing" value={5} />);
|
||||
|
||||
expect(screen.getByTestId('stat-card-value')).not.toHaveAttribute('style');
|
||||
});
|
||||
});
|
||||
66
frontend/src/components/Alerts/StatCard/StatCard.tsx
Normal file
66
frontend/src/components/Alerts/StatCard/StatCard.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import styles from './StatCard.module.scss';
|
||||
|
||||
export interface StatCardClickEvent {
|
||||
exclusive: boolean;
|
||||
}
|
||||
|
||||
interface StatCardProps {
|
||||
label: string;
|
||||
value: number;
|
||||
color?: string;
|
||||
onClick?: (event: StatCardClickEvent) => void;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
label,
|
||||
value,
|
||||
color,
|
||||
onClick,
|
||||
isActive,
|
||||
}: StatCardProps): JSX.Element {
|
||||
const cardClassName = [
|
||||
styles.statCard,
|
||||
onClick && styles.statCardClickable,
|
||||
isActive && styles.statCardActive,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
const handleClick = (e: React.MouseEvent): void => {
|
||||
if (onClick) {
|
||||
onClick({ exclusive: e.altKey });
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent): void => {
|
||||
if (onClick && (e.key === 'Enter' || e.key === ' ')) {
|
||||
e.preventDefault();
|
||||
onClick({ exclusive: e.altKey });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cardClassName}
|
||||
onClick={onClick ? handleClick : undefined}
|
||||
onKeyDown={onClick ? handleKeyDown : undefined}
|
||||
role={onClick ? 'button' : undefined}
|
||||
tabIndex={onClick ? 0 : undefined}
|
||||
data-testid="stat-card"
|
||||
>
|
||||
<span className={styles.statLabel} data-testid="stat-card-label">
|
||||
{label}
|
||||
</span>
|
||||
<span
|
||||
className={styles.statValue}
|
||||
style={color ? { color } : undefined}
|
||||
data-testid="stat-card-value"
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default StatCard;
|
||||
2
frontend/src/components/Alerts/StatCard/index.ts
Normal file
2
frontend/src/components/Alerts/StatCard/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default } from './StatCard';
|
||||
export type { StatCardClickEvent } from './StatCard';
|
||||
87
frontend/src/components/Alerts/constants.test.ts
Normal file
87
frontend/src/components/Alerts/constants.test.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
STATE_ORDER,
|
||||
SEVERITY_ORDER,
|
||||
STATE_LABELS,
|
||||
STATE_COLORS,
|
||||
SEVERITY_COLORS,
|
||||
} from './constants';
|
||||
|
||||
describe('Alerts constants', () => {
|
||||
describe('STATE_ORDER', () => {
|
||||
it('should have correct order of states', () => {
|
||||
expect(STATE_ORDER).toStrictEqual([
|
||||
'firing',
|
||||
'pending',
|
||||
'inactive',
|
||||
'disabled',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should have firing as highest priority', () => {
|
||||
expect(STATE_ORDER[0]).toBe('firing');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SEVERITY_ORDER', () => {
|
||||
it('should have correct order of severities', () => {
|
||||
expect(SEVERITY_ORDER).toStrictEqual([
|
||||
'critical',
|
||||
'error',
|
||||
'warning',
|
||||
'info',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should have critical as highest priority', () => {
|
||||
expect(SEVERITY_ORDER[0]).toBe('critical');
|
||||
});
|
||||
});
|
||||
|
||||
describe('STATE_LABELS', () => {
|
||||
it('should map firing to Firing', () => {
|
||||
expect(STATE_LABELS.firing).toBe('Firing');
|
||||
});
|
||||
|
||||
it('should map pending to Pending', () => {
|
||||
expect(STATE_LABELS.pending).toBe('Pending');
|
||||
});
|
||||
|
||||
it('should map inactive to OK', () => {
|
||||
expect(STATE_LABELS.inactive).toBe('OK');
|
||||
});
|
||||
|
||||
it('should map disabled to Disabled', () => {
|
||||
expect(STATE_LABELS.disabled).toBe('Disabled');
|
||||
});
|
||||
});
|
||||
|
||||
describe('STATE_COLORS', () => {
|
||||
it('should have colors for all states', () => {
|
||||
expect(STATE_COLORS).toHaveProperty('firing');
|
||||
expect(STATE_COLORS).toHaveProperty('pending');
|
||||
expect(STATE_COLORS).toHaveProperty('inactive');
|
||||
expect(STATE_COLORS).toHaveProperty('disabled');
|
||||
});
|
||||
|
||||
it('should use CSS variables for colors', () => {
|
||||
Object.values(STATE_COLORS).forEach((color) => {
|
||||
expect(color).toMatch(/^var\(--/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('SEVERITY_COLORS', () => {
|
||||
it('should have colors for all severities', () => {
|
||||
expect(SEVERITY_COLORS).toHaveProperty('critical');
|
||||
expect(SEVERITY_COLORS).toHaveProperty('error');
|
||||
expect(SEVERITY_COLORS).toHaveProperty('warning');
|
||||
expect(SEVERITY_COLORS).toHaveProperty('info');
|
||||
});
|
||||
|
||||
it('should use CSS variables for colors', () => {
|
||||
Object.values(SEVERITY_COLORS).forEach((color) => {
|
||||
expect(color).toMatch(/^var\(--/);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
23
frontend/src/components/Alerts/constants.ts
Normal file
23
frontend/src/components/Alerts/constants.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
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)',
|
||||
};
|
||||
12
frontend/src/components/Alerts/index.ts
Normal file
12
frontend/src/components/Alerts/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export { default as StatCard } from './StatCard';
|
||||
export type { StatCardClickEvent } from './StatCard';
|
||||
export { default as LastUpdatedText } from './LastUpdatedText';
|
||||
export { default as LabelColumn } from './LabelColumn';
|
||||
export type { LabelColumnProps } from './LabelColumn';
|
||||
export {
|
||||
STATE_ORDER,
|
||||
SEVERITY_ORDER,
|
||||
STATE_LABELS,
|
||||
STATE_COLORS,
|
||||
SEVERITY_COLORS,
|
||||
} from './constants';
|
||||
@@ -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,30 @@ 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);
|
||||
pagination?.onLimitChange?.(l);
|
||||
},
|
||||
[internalSetLimit, pagination],
|
||||
);
|
||||
|
||||
const setOrderBy = useCallback(
|
||||
(sort: SortState | null) => {
|
||||
internalSetOrderBy(sort);
|
||||
onSort?.(sort);
|
||||
},
|
||||
[internalSetOrderBy, onSort],
|
||||
);
|
||||
|
||||
const isGrouped = (groupBy?.length ?? 0) > 0;
|
||||
|
||||
const {
|
||||
@@ -607,14 +633,16 @@ 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
|
||||
value={limit?.toString()}
|
||||
defaultValue="10"
|
||||
onChange={(value): void => setLimit(+value)}
|
||||
items={paginationPageSizeItems}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{suffixPaginationContent}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Tooltip, TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import { TooltipSimple } from '@signozhq/ui';
|
||||
import { Copy } from '@signozhq/icons';
|
||||
import './CopyIconButton.styles.scss';
|
||||
|
||||
@@ -19,22 +19,20 @@ function CopyIconButton({
|
||||
: 'Copy to clipboard';
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip title={tooltipTitle}>
|
||||
<span>
|
||||
<Button
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={ariaLabel}
|
||||
disabled={disabled}
|
||||
className="mcp-copy-btn"
|
||||
prefix={<Copy size={14} />}
|
||||
onClick={onCopy}
|
||||
/>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<TooltipSimple title={tooltipTitle}>
|
||||
<span>
|
||||
<Button
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={ariaLabel}
|
||||
disabled={disabled}
|
||||
className="mcp-copy-btn"
|
||||
prefix={<Copy size={14} />}
|
||||
onClick={onCopy}
|
||||
/>
|
||||
</span>
|
||||
</TooltipSimple>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,12 +6,7 @@ import {
|
||||
TabsRoot,
|
||||
TabsTrigger,
|
||||
} from '@signozhq/ui/tabs';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@signozhq/ui/tooltip';
|
||||
import { TooltipSimple } from '@signozhq/ui';
|
||||
import {
|
||||
Bookmark,
|
||||
CalendarClock,
|
||||
@@ -497,27 +492,22 @@ function SpanDetailsPanel({
|
||||
actions.push({
|
||||
key: 'dock-toggle',
|
||||
component: (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
color="secondary"
|
||||
onClick={(): void =>
|
||||
onVariantChange(
|
||||
isDocked ? SpanDetailVariant.DIALOG : SpanDetailVariant.DOCKED,
|
||||
)
|
||||
}
|
||||
>
|
||||
{isDocked ? <Dock size={14} /> : <PanelBottom size={14} />}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="dock-toggle-tooltip">
|
||||
{isDocked ? 'Open as floating panel' : 'Dock at the bottom'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<TooltipSimple
|
||||
title={isDocked ? 'Open as floating panel' : 'Dock at the bottom'}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
color="secondary"
|
||||
onClick={(): void =>
|
||||
onVariantChange(
|
||||
isDocked ? SpanDetailVariant.DIALOG : SpanDetailVariant.DOCKED,
|
||||
)
|
||||
}
|
||||
>
|
||||
{isDocked ? <Dock size={14} /> : <PanelBottom size={14} />}
|
||||
</Button>
|
||||
</TooltipSimple>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@signozhq/ui/tooltip';
|
||||
import { TooltipSimple } from '@signozhq/ui';
|
||||
import { useCopySpanLink } from 'hooks/trace/useCopySpanLink';
|
||||
import { Link } from 'lucide-react';
|
||||
import { Span } from 'types/api/trace/getTraceV2';
|
||||
@@ -21,24 +16,17 @@ export default function SpanLineActionButtons({
|
||||
|
||||
return (
|
||||
<div className="span-line-action-buttons">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
color="secondary"
|
||||
onClick={onSpanCopy}
|
||||
className="copy-span-btn"
|
||||
>
|
||||
<Link size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="span-line-action-tooltip">
|
||||
Copy Span Link
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<TooltipSimple title="Copy Span Link">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
color="secondary"
|
||||
onClick={onSpanCopy}
|
||||
className="copy-span-btn"
|
||||
>
|
||||
<Link size={14} />
|
||||
</Button>
|
||||
</TooltipSimple>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,12 +11,7 @@ import {
|
||||
} from 'react';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@signozhq/ui/tooltip';
|
||||
import { TooltipSimple } from '@signozhq/ui';
|
||||
import {
|
||||
createColumnHelper,
|
||||
flexRender,
|
||||
@@ -109,26 +104,24 @@ const LazyEventDotPopover = memo(function LazyEventDotPopover({
|
||||
const eventTimeMs = event.timeUnixNano / 1e6;
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip
|
||||
open
|
||||
onOpenChange={(open): void => {
|
||||
if (!open) {
|
||||
setShowPopover(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<TooltipTrigger asChild>{dot}</TooltipTrigger>
|
||||
<TooltipContent className="span-hover-card-popover">
|
||||
<EventTooltipContent
|
||||
eventName={event.name}
|
||||
timeOffsetMs={eventTimeMs - spanTimestamp}
|
||||
isError={isError}
|
||||
attributeMap={event.attributeMap || {}}
|
||||
/>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<TooltipSimple
|
||||
open
|
||||
onOpenChange={(open: boolean): void => {
|
||||
if (!open) {
|
||||
setShowPopover(false);
|
||||
}
|
||||
}}
|
||||
title={
|
||||
<EventTooltipContent
|
||||
eventName={event.name}
|
||||
timeOffsetMs={eventTimeMs - spanTimestamp}
|
||||
isError={isError}
|
||||
attributeMap={event.attributeMap || {}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{dot}
|
||||
</TooltipSimple>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -323,40 +316,28 @@ const SpanOverview = memo(function SpanOverview({
|
||||
|
||||
{/* Action buttons — shown on hover via CSS, right-aligned */}
|
||||
<span className="span-row-actions">
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
color="secondary"
|
||||
className="span-action-btn"
|
||||
onClick={onSpanCopy}
|
||||
>
|
||||
<Link size={12} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="span-action-tooltip">
|
||||
Copy Span Link
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
color="secondary"
|
||||
className="span-action-btn"
|
||||
onClick={handleFunnelClick}
|
||||
>
|
||||
<ListPlus size={12} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="span-action-tooltip">
|
||||
Add to Trace Funnel
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<TooltipSimple title="Copy Span Link">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
color="secondary"
|
||||
className="span-action-btn"
|
||||
onClick={onSpanCopy}
|
||||
>
|
||||
<Link size={12} />
|
||||
</Button>
|
||||
</TooltipSimple>
|
||||
<TooltipSimple title="Add to Trace Funnel">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
color="secondary"
|
||||
className="span-action-btn"
|
||||
onClick={handleFunnelClick}
|
||||
>
|
||||
<ListPlus size={12} />
|
||||
</Button>
|
||||
</TooltipSimple>
|
||||
</span>
|
||||
</div>
|
||||
</SpanHoverCard>
|
||||
|
||||
@@ -5614,10 +5614,10 @@
|
||||
tailwind-merge "^2.5.2"
|
||||
tailwindcss-animate "^1.0.7"
|
||||
|
||||
"@signozhq/ui@0.0.18":
|
||||
version "0.0.18"
|
||||
resolved "https://registry.yarnpkg.com/@signozhq/ui/-/ui-0.0.18.tgz#a96f843aea87d2a435ed0efc68d0a94eaae98baa"
|
||||
integrity sha512-1p3ALh76kafiz5yX7ReNKVcHDt2od7CcZD/Vx9i2adTwTeynkLJcEfVoXoJD3oh1kKTleooOiOjRyxlA7VzmSA==
|
||||
"@signozhq/ui@0.0.19":
|
||||
version "0.0.19"
|
||||
resolved "https://registry.yarnpkg.com/@signozhq/ui/-/ui-0.0.19.tgz#125cbfb9c6bc39ace7f9a99b2b3fdd291a6bf76e"
|
||||
integrity sha512-2q6aRxN/PR4PlR2xJZAREEuvLPiDFggfFKzCW2Z5vHVVbrgnvZHWD1jPUuwszfEg0ceH3UvkwqceO7wN4uRJAA==
|
||||
dependencies:
|
||||
"@chenglou/pretext" "^0.0.5"
|
||||
"@radix-ui/react-checkbox" "^1.2.3"
|
||||
|
||||
Reference in New Issue
Block a user