mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-04 08:00:26 +01:00
Compare commits
6 Commits
issue_5201
...
billing-gr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
840914ae58 | ||
|
|
7fb222b6ee | ||
|
|
fdb22e6669 | ||
|
|
95adbc31cc | ||
|
|
c5288fc1ea | ||
|
|
86e71151d7 |
@@ -1261,7 +1261,6 @@ components:
|
||||
- sqs
|
||||
- storageaccountsblob
|
||||
- cdnprofile
|
||||
- appservice
|
||||
type: string
|
||||
CloudintegrationtypesServiceMetadata:
|
||||
properties:
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
// Mock for useSafeNavigate hook to avoid React Router version conflicts in tests
|
||||
interface SafeNavigateOptions {
|
||||
replace?: boolean;
|
||||
state?: unknown;
|
||||
newTab?: boolean;
|
||||
}
|
||||
|
||||
interface SafeNavigateTo {
|
||||
pathname?: string;
|
||||
search?: string;
|
||||
hash?: string;
|
||||
}
|
||||
|
||||
type SafeNavigateToType = string | SafeNavigateTo;
|
||||
|
||||
interface UseSafeNavigateReturn {
|
||||
safeNavigate: jest.MockedFunction<
|
||||
(to: SafeNavigateToType, options?: SafeNavigateOptions) => void
|
||||
>;
|
||||
}
|
||||
|
||||
export const useSafeNavigate = (): UseSafeNavigateReturn => ({
|
||||
safeNavigate: jest.fn(
|
||||
(_to: SafeNavigateToType, _options?: SafeNavigateOptions) => {},
|
||||
) as jest.MockedFunction<
|
||||
(to: SafeNavigateToType, options?: SafeNavigateOptions) => void
|
||||
>,
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { Config } from '@jest/types';
|
||||
|
||||
const USE_SAFE_NAVIGATE_MOCK_PATH = '<rootDir>/__mocks__/useSafeNavigate.ts';
|
||||
const USE_SAFE_NAVIGATE_MOCK_PATH =
|
||||
'<rootDir>/src/__tests__/safeNavigateMock.ts';
|
||||
const LOG_EVENT_MOCK_PATH = '<rootDir>/src/__tests__/logEventMock.ts';
|
||||
|
||||
const config: Config.InitialOptions = {
|
||||
silent: true,
|
||||
@@ -22,6 +24,8 @@ const config: Config.InitialOptions = {
|
||||
'^hooks/useSafeNavigate$': USE_SAFE_NAVIGATE_MOCK_PATH,
|
||||
'^src/hooks/useSafeNavigate$': USE_SAFE_NAVIGATE_MOCK_PATH,
|
||||
'^.*/useSafeNavigate$': USE_SAFE_NAVIGATE_MOCK_PATH,
|
||||
'^api/common/logEvent$': LOG_EVENT_MOCK_PATH,
|
||||
'^src/api/common/logEvent$': LOG_EVENT_MOCK_PATH,
|
||||
'^constants/env$': '<rootDir>/__mocks__/env.ts',
|
||||
'^src/constants/env$': '<rootDir>/__mocks__/env.ts',
|
||||
'^@signozhq/icons$': '<rootDir>/__mocks__/signozhqIconsMock.tsx',
|
||||
|
||||
11
frontend/src/__tests__/logEventMock.ts
Normal file
11
frontend/src/__tests__/logEventMock.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
// Shared mock for `api/common/logEvent`.
|
||||
// Wired into jest.config.ts moduleNameMapper, so any import of
|
||||
// `api/common/logEvent` in test code resolves to this file.
|
||||
// Tests can import `logEventMock` to assert analytics calls — Jest's
|
||||
// `clearMocks: true` resets call history between tests.
|
||||
|
||||
export const logEventMock: jest.MockedFunction<
|
||||
(eventName: string, attributes?: Record<string, unknown>) => void
|
||||
> = jest.fn();
|
||||
|
||||
export default logEventMock;
|
||||
29
frontend/src/__tests__/safeNavigateMock.ts
Normal file
29
frontend/src/__tests__/safeNavigateMock.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
// Shared mock for `hooks/useSafeNavigate`.
|
||||
// Wired into jest.config.ts moduleNameMapper, so any import of
|
||||
// `hooks/useSafeNavigate` in test code resolves to this file.
|
||||
// Tests can import `safeNavigateMock` to assert navigation calls — Jest's
|
||||
// `clearMocks: true` resets call history between tests.
|
||||
|
||||
interface SafeNavigateOptions {
|
||||
replace?: boolean;
|
||||
state?: unknown;
|
||||
newTab?: boolean;
|
||||
}
|
||||
|
||||
interface SafeNavigateTo {
|
||||
pathname?: string;
|
||||
search?: string;
|
||||
hash?: string;
|
||||
}
|
||||
|
||||
type SafeNavigateToType = string | SafeNavigateTo;
|
||||
|
||||
export const safeNavigateMock: jest.MockedFunction<
|
||||
(to: SafeNavigateToType, options?: SafeNavigateOptions) => void
|
||||
> = jest.fn();
|
||||
|
||||
export const useSafeNavigate = (): {
|
||||
safeNavigate: typeof safeNavigateMock;
|
||||
} => ({
|
||||
safeNavigate: safeNavigateMock,
|
||||
});
|
||||
@@ -3,13 +3,29 @@ import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
|
||||
export interface DayBreakdownEntry {
|
||||
timestamp: number;
|
||||
total: number;
|
||||
quantity: number;
|
||||
count: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface BreakdownEntry {
|
||||
type: string;
|
||||
unit: string;
|
||||
dayWiseBreakdown: {
|
||||
breakdown: DayBreakdownEntry[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface UsageResponsePayloadProps {
|
||||
billingPeriodStart: Date;
|
||||
billingPeriodEnd: Date;
|
||||
billingPeriodStart: number;
|
||||
billingPeriodEnd: number;
|
||||
details: {
|
||||
total: number;
|
||||
baseFee: number;
|
||||
breakdown: [];
|
||||
breakdown: BreakdownEntry[];
|
||||
billTotal: number;
|
||||
};
|
||||
discount: number;
|
||||
|
||||
@@ -2574,7 +2574,6 @@ export enum CloudintegrationtypesServiceIDDTO {
|
||||
sqs = 'sqs',
|
||||
storageaccountsblob = 'storageaccountsblob',
|
||||
cdnprofile = 'cdnprofile',
|
||||
appservice = 'appservice',
|
||||
}
|
||||
export type CloudintegrationtypesCloudIntegrationServiceDTOAnyOf = {
|
||||
/**
|
||||
|
||||
@@ -3,17 +3,12 @@ import { useLocation } from 'react-router-dom';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { logEventMock } from '__tests__/logEventMock';
|
||||
import { handleContactSupport } from 'container/Integrations/utils';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
|
||||
import FeedbackModal from '../FeedbackModal';
|
||||
|
||||
jest.mock('api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => Promise.resolve()),
|
||||
}));
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: jest.fn(),
|
||||
@@ -35,7 +30,6 @@ jest.mock('container/Integrations/utils', () => ({
|
||||
handleContactSupport: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockLogEvent = logEvent as jest.MockedFunction<typeof logEvent>;
|
||||
const mockUseLocation = useLocation as jest.Mock;
|
||||
const mockUseGetTenantLicense = useGetTenantLicense as jest.Mock;
|
||||
const mockHandleContactSupport = handleContactSupport as jest.Mock;
|
||||
@@ -50,6 +44,7 @@ const mockLocation = {
|
||||
describe('FeedbackModal', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
logEventMock.mockReturnValue(Promise.resolve() as never);
|
||||
mockUseLocation.mockReturnValue(mockLocation);
|
||||
mockUseGetTenantLicense.mockReturnValue({
|
||||
isCloudUser: false,
|
||||
@@ -116,7 +111,7 @@ describe('FeedbackModal', () => {
|
||||
await user.type(textarea, testFeedback);
|
||||
await user.click(submitButton);
|
||||
|
||||
expect(mockLogEvent).toHaveBeenCalledWith('Feedback: Submitted', {
|
||||
expect(logEventMock).toHaveBeenCalledWith('Feedback: Submitted', {
|
||||
data: testFeedback,
|
||||
type: 'feedback',
|
||||
page: mockLocation.pathname,
|
||||
@@ -149,7 +144,7 @@ describe('FeedbackModal', () => {
|
||||
await user.type(textarea, testFeedback);
|
||||
await user.click(submitButton);
|
||||
|
||||
expect(mockLogEvent).toHaveBeenCalledWith('Feedback: Submitted', {
|
||||
expect(logEventMock).toHaveBeenCalledWith('Feedback: Submitted', {
|
||||
data: testFeedback,
|
||||
type: 'reportBug',
|
||||
page: mockLocation.pathname,
|
||||
@@ -182,7 +177,7 @@ describe('FeedbackModal', () => {
|
||||
await user.type(textarea, testFeedback);
|
||||
await user.click(submitButton);
|
||||
|
||||
expect(mockLogEvent).toHaveBeenCalledWith('Feedback: Submitted', {
|
||||
expect(logEventMock).toHaveBeenCalledWith('Feedback: Submitted', {
|
||||
data: testFeedback,
|
||||
type: 'featureRequest',
|
||||
page: mockLocation.pathname,
|
||||
|
||||
@@ -2,16 +2,11 @@
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { logEventMock } from '__tests__/logEventMock';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
|
||||
import HeaderRightSection from '../HeaderRightSection';
|
||||
|
||||
jest.mock('api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: jest.fn(),
|
||||
@@ -50,7 +45,6 @@ jest.mock('hooks/useIsAIAssistantEnabled', () => ({
|
||||
useIsAIAssistantEnabled: (): boolean => false,
|
||||
}));
|
||||
|
||||
const mockLogEvent = logEvent as jest.Mock;
|
||||
const mockUseLocation = useLocation as jest.Mock;
|
||||
const mockUseGetTenantLicense = useGetTenantLicense as jest.Mock;
|
||||
|
||||
@@ -120,7 +114,7 @@ describe('HeaderRightSection', () => {
|
||||
|
||||
await user.click(feedbackButton!);
|
||||
|
||||
expect(mockLogEvent).toHaveBeenCalledWith('Feedback: Clicked', {
|
||||
expect(logEventMock).toHaveBeenCalledWith('Feedback: Clicked', {
|
||||
page: mockLocation.pathname,
|
||||
});
|
||||
expect(screen.getByTestId('feedback-modal')).toBeInTheDocument();
|
||||
@@ -133,7 +127,7 @@ describe('HeaderRightSection', () => {
|
||||
const shareButton = screen.getByRole('button', { name: /share/i });
|
||||
await user.click(shareButton);
|
||||
|
||||
expect(mockLogEvent).toHaveBeenCalledWith('Share: Clicked', {
|
||||
expect(logEventMock).toHaveBeenCalledWith('Share: Clicked', {
|
||||
page: mockLocation.pathname,
|
||||
});
|
||||
expect(screen.getByTestId('share-modal')).toBeInTheDocument();
|
||||
@@ -150,7 +144,7 @@ describe('HeaderRightSection', () => {
|
||||
|
||||
await user.click(announcementsButton!);
|
||||
|
||||
expect(mockLogEvent).toHaveBeenCalledWith('Announcements: Clicked', {
|
||||
expect(logEventMock).toHaveBeenCalledWith('Announcements: Clicked', {
|
||||
page: mockLocation.pathname,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,18 +5,13 @@ import { matchPath, useLocation } from 'react-router-dom';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { logEventMock } from '__tests__/logEventMock';
|
||||
import ROUTES from 'constants/routes';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import GetMinMax from 'lib/getMinMax';
|
||||
|
||||
import ShareURLModal from '../ShareURLModal';
|
||||
|
||||
jest.mock('api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: jest.fn(),
|
||||
@@ -53,7 +48,6 @@ Object.defineProperty(window, 'location', {
|
||||
writable: true,
|
||||
});
|
||||
|
||||
const mockLogEvent = logEvent as jest.Mock;
|
||||
const mockUseLocation = useLocation as jest.Mock;
|
||||
const mockUseUrlQuery = useUrlQuery as jest.Mock;
|
||||
const mockUseSelector = useSelector as jest.Mock;
|
||||
@@ -125,7 +119,7 @@ describe('ShareURLModal', () => {
|
||||
await user.click(copyButton);
|
||||
|
||||
expect(mockHandleCopyToClipboard).toHaveBeenCalled();
|
||||
expect(mockLogEvent).toHaveBeenCalledWith('Share: Copy link clicked', {
|
||||
expect(logEventMock).toHaveBeenCalledWith('Share: Copy link clicked', {
|
||||
page: TEST_PATH,
|
||||
URL: expect.any(String),
|
||||
});
|
||||
|
||||
@@ -721,6 +721,53 @@ export const removeKeysFromExpression = (
|
||||
return result?.text ?? '';
|
||||
};
|
||||
|
||||
const escapeRegExp = (value: string): string =>
|
||||
value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
|
||||
export const createVariablePlaceholderRegExp = (
|
||||
variableName: string,
|
||||
): RegExp => {
|
||||
const escapedName = escapeRegExp(variableName);
|
||||
// (?![\w.]) prevents $env from matching inside $environment or $env.attr
|
||||
return new RegExp(
|
||||
`(\\$${escapedName}(?![\\w.])|\\{\\{\\s*\\.?${escapedName}\\s*\\}\\}|\\[\\[\\s*${escapedName}\\s*\\]\\])`,
|
||||
'g',
|
||||
);
|
||||
};
|
||||
|
||||
const matchesVariablePlaceholder = (
|
||||
text: string,
|
||||
variableName: string,
|
||||
): boolean => createVariablePlaceholderRegExp(variableName).test(text);
|
||||
|
||||
export const removeVariableFromExpression = (
|
||||
expression: string | undefined,
|
||||
variableName: string,
|
||||
): string => {
|
||||
if (!expression) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const queryPairs = extractQueryPairs(expression);
|
||||
|
||||
const keysToRemove = queryPairs
|
||||
.filter((pair) => {
|
||||
const singleValue = pair.value?.toString() ?? '';
|
||||
const listValues = (pair.valueList ?? []).join(' ');
|
||||
return (
|
||||
matchesVariablePlaceholder(singleValue, variableName) ||
|
||||
matchesVariablePlaceholder(listValues, variableName)
|
||||
);
|
||||
})
|
||||
.map((pair) => pair.key);
|
||||
|
||||
if (keysToRemove.length === 0) {
|
||||
return expression;
|
||||
}
|
||||
|
||||
return removeKeysFromExpression(expression, keysToRemove, `$${variableName}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert old having format to new having format
|
||||
* @param having - Array of old having objects with columnName, op, and value
|
||||
|
||||
@@ -139,7 +139,6 @@ jest.mock('react-query', (): unknown => {
|
||||
});
|
||||
|
||||
// mock other side-effecty modules
|
||||
jest.mock('api/common/logEvent', () => jest.fn());
|
||||
jest.mock('api/browser/localstorage/set', () => jest.fn());
|
||||
jest.mock('utils/error', () => ({ showErrorNotification: jest.fn() }));
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { logEventMock } from '__tests__/logEventMock';
|
||||
import { GlobalShortcuts } from 'constants/shortcuts/globalShortcuts';
|
||||
import { USER_PREFERENCES } from 'constants/userPreferences';
|
||||
import {
|
||||
@@ -24,8 +24,6 @@ jest.mock('providers/cmdKProvider', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('api/common/logEvent', () => jest.fn());
|
||||
|
||||
// Mock the AppContext
|
||||
const mockUpdateUserPreferenceInContext = jest.fn();
|
||||
|
||||
@@ -139,7 +137,7 @@ describe('Sidebar Toggle Shortcut', () => {
|
||||
it('should log the toggle event with correct parameters', async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockHandleShortcut = jest.fn(() => {
|
||||
logEvent('Global Shortcut: Sidebar Toggle', {
|
||||
logEventMock('Global Shortcut: Sidebar Toggle', {
|
||||
previousState: false,
|
||||
newState: true,
|
||||
});
|
||||
@@ -155,10 +153,13 @@ describe('Sidebar Toggle Shortcut', () => {
|
||||
|
||||
await user.keyboard(SHIFT_B_KEYBOARD_SHORTCUT);
|
||||
|
||||
expect(logEvent).toHaveBeenCalledWith('Global Shortcut: Sidebar Toggle', {
|
||||
previousState: false,
|
||||
newState: true,
|
||||
});
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
'Global Shortcut: Sidebar Toggle',
|
||||
{
|
||||
previousState: false,
|
||||
newState: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should update user preference in context', async () => {
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
.headerRow {
|
||||
padding: var(--spacing-8);
|
||||
}
|
||||
|
||||
.itemList {
|
||||
overflow-y: auto;
|
||||
max-height: 300px;
|
||||
padding: 12px;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useMemo } from 'react';
|
||||
import cx from 'classnames';
|
||||
import TooltipHeader from 'lib/uPlotV2/components/Tooltip/components/TooltipHeader/TooltipHeader';
|
||||
import TooltipItem from 'lib/uPlotV2/components/Tooltip/components/TooltipItem/TooltipItem';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { getToolTipValue } from 'components/Graph/yAxisConfig';
|
||||
import { buildTooltipContent } from 'lib/uPlotV2/components/Tooltip/utils';
|
||||
import {
|
||||
TooltipContentItem,
|
||||
TooltipRenderArgs,
|
||||
} from 'lib/uPlotV2/components/types';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
|
||||
import TooltipStyles from 'lib/uPlotV2/components/Tooltip/Tooltip.module.scss';
|
||||
import Styles from './BillingBarChartTooltip.module.scss';
|
||||
|
||||
interface BillingBarChartTooltipProps extends TooltipRenderArgs {
|
||||
billingApiResponse: MetricRangePayloadProps;
|
||||
}
|
||||
|
||||
export function BillingBarChartTooltip({
|
||||
billingApiResponse,
|
||||
uPlotInstance,
|
||||
dataIndexes,
|
||||
seriesIndex,
|
||||
isPinned,
|
||||
}: BillingBarChartTooltipProps): JSX.Element {
|
||||
const content = useMemo((): TooltipContentItem[] => {
|
||||
const baseItems = buildTooltipContent({
|
||||
data: uPlotInstance.data,
|
||||
series: uPlotInstance.series,
|
||||
dataIndexes,
|
||||
activeSeriesIndex: seriesIndex,
|
||||
uPlotInstance,
|
||||
yAxisUnit: '',
|
||||
isStackedBarChart: true,
|
||||
});
|
||||
|
||||
return baseItems.map((item) => {
|
||||
const match = billingApiResponse.data.result.find(
|
||||
(r) => (r.legend || r.queryName) === item.label,
|
||||
);
|
||||
|
||||
if (!match) {
|
||||
return item;
|
||||
}
|
||||
|
||||
const seriesIdx = uPlotInstance.series.findIndex(
|
||||
(s) => s.label === item.label,
|
||||
);
|
||||
if (seriesIdx === -1) {
|
||||
return item;
|
||||
}
|
||||
|
||||
const dataIndex = dataIndexes[seriesIdx];
|
||||
const quantity = dataIndex != null ? match.quantity?.[dataIndex] : null;
|
||||
const unit = match.unit ?? '';
|
||||
const quantityStr =
|
||||
quantity != null ? ` - ${getToolTipValue(quantity)} ${unit}` : '';
|
||||
|
||||
return {
|
||||
...item,
|
||||
tooltipValue: `$${getToolTipValue(item.value, '')}${quantityStr}`,
|
||||
};
|
||||
});
|
||||
}, [uPlotInstance, seriesIndex, dataIndexes, billingApiResponse]);
|
||||
|
||||
const activeItem = content.find((item) => item.isActive) ?? null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(TooltipStyles.container, {
|
||||
[TooltipStyles.pinned]: isPinned,
|
||||
})}
|
||||
data-testid="uplot-tooltip-container"
|
||||
>
|
||||
<TooltipHeader
|
||||
uPlotInstance={uPlotInstance}
|
||||
showTooltipHeader
|
||||
isPinned={isPinned}
|
||||
activeItem={null}
|
||||
headerRowClassName={Styles.headerRow}
|
||||
dateFormat={DATE_TIME_FORMATS.MONTH_DATE}
|
||||
/>
|
||||
{activeItem != null && <span className={TooltipStyles.divider} />}
|
||||
<div className={Styles.itemList} data-testid="uplot-tooltip-list">
|
||||
{content.map((item) => (
|
||||
<TooltipItem key={item.label} item={item} isItemActive={item.isActive} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,14 @@
|
||||
.graph-container {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.billing-graph-card {
|
||||
.uplot-no-data {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.ant-card-body {
|
||||
height: 40vh;
|
||||
.uplot-graph-container {
|
||||
@@ -6,7 +16,7 @@
|
||||
}
|
||||
}
|
||||
.total-spent {
|
||||
font-family: 'SF Mono' monospace;
|
||||
font-family: 'SF Mono', monospace;
|
||||
font-size: 16px;
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
|
||||
@@ -1,206 +1,120 @@
|
||||
import { useMemo, useRef } from 'react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import { Card, Flex } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import Uplot from 'components/Uplot';
|
||||
import BarChart from 'container/DashboardContainer/visualization/charts/BarChart/BarChart';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
import tooltipPlugin from 'lib/uPlotLib/plugins/tooltipPlugin';
|
||||
import getAxes from 'lib/uPlotLib/utils/getAxes';
|
||||
import getRenderer from 'lib/uPlotLib/utils/getRenderer';
|
||||
import { getUPlotChartData } from 'lib/uPlotLib/utils/getUplotChartData';
|
||||
import { getXAxisScale } from 'lib/uPlotLib/utils/getXAxisScale';
|
||||
import { getYAxisScale } from 'lib/uPlotLib/utils/getYAxisScale';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils';
|
||||
import {
|
||||
LegendPosition,
|
||||
TooltipRenderArgs,
|
||||
} from 'lib/uPlotV2/components/types';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import uPlot from 'uplot';
|
||||
import { UsageResponsePayloadProps } from 'api/billing/getUsage';
|
||||
|
||||
import { BillingBarChartTooltip } from './BillingBarChartTooltip';
|
||||
import { prepareBillingBarConfig } from './prepareBillingBarConfig';
|
||||
import {
|
||||
calculateStartEndTime,
|
||||
convertDataToMetricRangePayload,
|
||||
fillMissingValuesForQuantities,
|
||||
} from './utils';
|
||||
|
||||
import './BillingUsageGraph.styles.scss';
|
||||
import '../../../lib/uPlotLib/uPlotLib.styles.scss';
|
||||
|
||||
interface BillingUsageGraphProps {
|
||||
data: any;
|
||||
data: Partial<UsageResponsePayloadProps>;
|
||||
billAmount: number;
|
||||
}
|
||||
const paths = (
|
||||
u: any,
|
||||
seriesIdx: number,
|
||||
idx0: number,
|
||||
idx1: number,
|
||||
extendGap: boolean,
|
||||
buildClip: boolean,
|
||||
): uPlot.Series.PathBuilder => {
|
||||
const s = u.series[seriesIdx];
|
||||
const style = s.drawStyle;
|
||||
const interp = s.lineInterpolation;
|
||||
|
||||
const renderer = getRenderer(style, interp);
|
||||
|
||||
return renderer(u, seriesIdx, idx0, idx1, extendGap, buildClip);
|
||||
};
|
||||
|
||||
const calculateStartEndTime = (
|
||||
data: any,
|
||||
): { startTime: number; endTime: number } => {
|
||||
const timestamps: number[] = [];
|
||||
data?.details?.breakdown?.forEach((breakdown: any) => {
|
||||
breakdown?.dayWiseBreakdown?.breakdown?.forEach((entry: any) => {
|
||||
timestamps.push(entry?.timestamp);
|
||||
});
|
||||
});
|
||||
const billingTime = [data?.billingPeriodStart, data?.billingPeriodEnd];
|
||||
const startTime: number = Math.min(...timestamps, ...billingTime);
|
||||
const endTime: number = Math.max(...timestamps, ...billingTime);
|
||||
return { startTime, endTime };
|
||||
};
|
||||
const numberFormatter = new Intl.NumberFormat('en-US');
|
||||
|
||||
export function BillingUsageGraph(props: BillingUsageGraphProps): JSX.Element {
|
||||
const { data, billAmount } = props;
|
||||
// Added this to fix the issue where breakdown with one day data are causing the bars to spread across multiple days
|
||||
data?.details?.breakdown?.forEach((breakdown: any) => {
|
||||
if (breakdown?.dayWiseBreakdown?.breakdown?.length === 1) {
|
||||
const currentDay = breakdown.dayWiseBreakdown.breakdown[0];
|
||||
const nextDay = {
|
||||
...currentDay,
|
||||
timestamp: currentDay.timestamp + 86400,
|
||||
count: 0,
|
||||
size: 0,
|
||||
quantity: 0,
|
||||
total: 0,
|
||||
};
|
||||
breakdown.dayWiseBreakdown.breakdown.push(nextDay);
|
||||
}
|
||||
});
|
||||
const graphCompatibleData = useMemo(
|
||||
() => convertDataToMetricRangePayload(data),
|
||||
[data],
|
||||
);
|
||||
const chartData = getUPlotChartData(graphCompatibleData);
|
||||
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const containerDimensions = useResizeObserver(graphRef);
|
||||
|
||||
const { startTime, endTime } = useMemo(
|
||||
() => calculateStartEndTime(data),
|
||||
[data],
|
||||
);
|
||||
|
||||
const getGraphSeries = (color: string, label: string): any => ({
|
||||
drawStyle: 'bars',
|
||||
paths,
|
||||
lineInterpolation: 'spline',
|
||||
show: true,
|
||||
label,
|
||||
fill: color,
|
||||
stroke: color,
|
||||
width: 2,
|
||||
spanGaps: true,
|
||||
points: {
|
||||
size: 5,
|
||||
show: false,
|
||||
stroke: color,
|
||||
},
|
||||
});
|
||||
|
||||
const uPlotSeries: any = useMemo(
|
||||
() => [
|
||||
{ label: 'Timestamp', stroke: 'purple' },
|
||||
getGraphSeries(
|
||||
'#7CEDBE',
|
||||
graphCompatibleData.data.result[0]?.legend as string,
|
||||
),
|
||||
getGraphSeries(
|
||||
'#4E74F8',
|
||||
graphCompatibleData.data.result[1]?.legend as string,
|
||||
),
|
||||
getGraphSeries(
|
||||
'#F24769',
|
||||
graphCompatibleData.data.result[2]?.legend as string,
|
||||
),
|
||||
],
|
||||
[graphCompatibleData.data.result],
|
||||
);
|
||||
|
||||
const axesOptions = getAxes({ isDarkMode, yAxisUnit: '' });
|
||||
|
||||
const optionsForChart: uPlot.Options = useMemo(
|
||||
() => ({
|
||||
id: 'billing-usage-breakdown',
|
||||
series: uPlotSeries,
|
||||
width: containerDimensions.width,
|
||||
height: containerDimensions.height - 30,
|
||||
axes: [
|
||||
{
|
||||
...axesOptions[0],
|
||||
grid: {
|
||||
...axesOptions.grid,
|
||||
show: false,
|
||||
stroke: isDarkMode ? Color.BG_VANILLA_400 : Color.BG_INK_400,
|
||||
},
|
||||
},
|
||||
{
|
||||
...axesOptions[1],
|
||||
stroke: isDarkMode ? Color.BG_SLATE_200 : Color.BG_INK_400,
|
||||
},
|
||||
],
|
||||
scales: {
|
||||
x: {
|
||||
...getXAxisScale(startTime - 86400, endTime), // Minus 86400 from startTime to decrease a day to have a buffer start
|
||||
},
|
||||
y: {
|
||||
...getYAxisScale({
|
||||
series: graphCompatibleData?.data?.newResult?.data?.result,
|
||||
yAxisUnit: '',
|
||||
softMax: null,
|
||||
softMin: null,
|
||||
}),
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
show: true,
|
||||
live: false,
|
||||
isolate: true,
|
||||
},
|
||||
cursor: {
|
||||
lock: false,
|
||||
focus: {
|
||||
prox: 1e6,
|
||||
bias: 1,
|
||||
},
|
||||
},
|
||||
focus: {
|
||||
alpha: 0.3,
|
||||
},
|
||||
padding: [32, 32, 16, 16],
|
||||
plugins: [
|
||||
tooltipPlugin({
|
||||
apiResponse: fillMissingValuesForQuantities(
|
||||
graphCompatibleData,
|
||||
chartData[0],
|
||||
),
|
||||
yAxisUnit: '',
|
||||
isBillingUsageGraphs: true,
|
||||
isDarkMode,
|
||||
// Single-day data causes bars to span multiple days — add a synthetic
|
||||
// zero-value next-day entry so uPlot renders a correctly-sized single-day bar.
|
||||
const normalizedData = useMemo(() => {
|
||||
if (!data?.details?.breakdown) {
|
||||
return data;
|
||||
}
|
||||
return {
|
||||
...data,
|
||||
details: {
|
||||
...data.details,
|
||||
breakdown: data.details.breakdown.map((breakdown) => {
|
||||
if (breakdown?.dayWiseBreakdown?.breakdown?.length !== 1) {
|
||||
return breakdown;
|
||||
}
|
||||
const currentDay = breakdown.dayWiseBreakdown.breakdown[0];
|
||||
const nextDay = {
|
||||
...currentDay,
|
||||
timestamp: currentDay.timestamp + 86400,
|
||||
count: 0,
|
||||
size: 0,
|
||||
quantity: 0,
|
||||
total: 0,
|
||||
};
|
||||
return {
|
||||
...breakdown,
|
||||
dayWiseBreakdown: {
|
||||
...breakdown.dayWiseBreakdown,
|
||||
breakdown: [...breakdown.dayWiseBreakdown.breakdown, nextDay],
|
||||
},
|
||||
};
|
||||
}),
|
||||
],
|
||||
}),
|
||||
[
|
||||
axesOptions,
|
||||
chartData,
|
||||
containerDimensions.height,
|
||||
containerDimensions.width,
|
||||
endTime,
|
||||
graphCompatibleData,
|
||||
isDarkMode,
|
||||
startTime,
|
||||
uPlotSeries,
|
||||
],
|
||||
},
|
||||
};
|
||||
}, [data]);
|
||||
|
||||
const graphCompatibleData = useMemo(
|
||||
() => convertDataToMetricRangePayload(normalizedData),
|
||||
[normalizedData],
|
||||
);
|
||||
|
||||
const numberFormatter = new Intl.NumberFormat('en-US');
|
||||
const chartData = useMemo(
|
||||
() => prepareChartData(graphCompatibleData) as uPlot.AlignedData,
|
||||
[graphCompatibleData],
|
||||
);
|
||||
|
||||
const filledApiResponse = useMemo(
|
||||
(): MetricRangePayloadProps =>
|
||||
fillMissingValuesForQuantities(
|
||||
graphCompatibleData,
|
||||
chartData[0] as number[],
|
||||
),
|
||||
[graphCompatibleData, chartData],
|
||||
);
|
||||
|
||||
const { startTime, endTime } = useMemo(
|
||||
() =>
|
||||
calculateStartEndTime(normalizedData as Partial<UsageResponsePayloadProps>),
|
||||
[normalizedData],
|
||||
);
|
||||
|
||||
const config = useMemo(
|
||||
() =>
|
||||
prepareBillingBarConfig({
|
||||
isDarkMode,
|
||||
// Subtract 86400s (one day) from startTime to add a buffer before first bar
|
||||
minTimeScale: startTime !== undefined ? startTime - 86400 : undefined,
|
||||
maxTimeScale: endTime,
|
||||
apiResponse: graphCompatibleData,
|
||||
}),
|
||||
[isDarkMode, startTime, endTime, graphCompatibleData],
|
||||
);
|
||||
|
||||
const renderBillingTooltip = useCallback(
|
||||
(args: TooltipRenderArgs) => (
|
||||
<BillingBarChartTooltip billingApiResponse={filledApiResponse} {...args} />
|
||||
),
|
||||
[filledApiResponse],
|
||||
);
|
||||
|
||||
return (
|
||||
<Card bordered={false} className="billing-graph-card">
|
||||
@@ -214,8 +128,19 @@ export function BillingUsageGraph(props: BillingUsageGraphProps): JSX.Element {
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
<div ref={graphRef} style={{ height: '100%', paddingBottom: 48 }}>
|
||||
<Uplot data={chartData} options={optionsForChart} />
|
||||
<div ref={graphRef} className="graph-container">
|
||||
{containerDimensions.width > 0 && containerDimensions.height > 0 && (
|
||||
<BarChart
|
||||
config={config}
|
||||
data={chartData}
|
||||
isStackedBarChart
|
||||
legendConfig={{ position: LegendPosition.BOTTOM }}
|
||||
customTooltip={renderBillingTooltip}
|
||||
width={containerDimensions.width}
|
||||
height={containerDimensions.height - 30}
|
||||
canPinTooltip
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import React from 'react';
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { BillingBarChartTooltip } from '../BillingBarChartTooltip';
|
||||
|
||||
// Mock buildTooltipContent so tests don't depend on uPlot stacking math
|
||||
jest.mock('lib/uPlotV2/components/Tooltip/utils', () => ({
|
||||
buildTooltipContent: jest.fn().mockReturnValue([
|
||||
{
|
||||
label: 'Logs',
|
||||
value: 100,
|
||||
tooltipValue: '$100.00',
|
||||
color: '#7CEDBE',
|
||||
isActive: true,
|
||||
isHighlighted: false,
|
||||
},
|
||||
{
|
||||
label: 'Traces',
|
||||
value: 50,
|
||||
tooltipValue: '$50.00',
|
||||
color: '#4E74F8',
|
||||
isActive: false,
|
||||
isHighlighted: false,
|
||||
},
|
||||
]),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useDarkMode', () => ({
|
||||
useIsDarkMode: jest.fn().mockReturnValue(false),
|
||||
}));
|
||||
|
||||
function makeUPlotInstance(seriesLabels: string[]): uPlot {
|
||||
return {
|
||||
data: [
|
||||
[1000, 2000],
|
||||
[100, 200],
|
||||
[50, 80],
|
||||
],
|
||||
cursor: { idx: 0 },
|
||||
series: [
|
||||
{ label: 'Timestamp', show: true, stroke: '#000' },
|
||||
...seriesLabels.map((label) => ({
|
||||
label,
|
||||
show: true,
|
||||
stroke: '#aabbcc',
|
||||
})),
|
||||
],
|
||||
} as unknown as uPlot;
|
||||
}
|
||||
|
||||
function makeBillingApiResponse(
|
||||
entries: { legend: string; quantity: (number | null)[]; unit: string }[],
|
||||
): MetricRangePayloadProps {
|
||||
return {
|
||||
data: {
|
||||
result: entries.map((e) => ({
|
||||
legend: e.legend,
|
||||
queryName: e.legend,
|
||||
metric: {},
|
||||
values: [[1000, '10']] as [number, string][],
|
||||
quantity: e.quantity as number[],
|
||||
unit: e.unit,
|
||||
})),
|
||||
resultType: '',
|
||||
newResult: { data: { result: [], resultType: '' } },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const baseTooltipArgs = {
|
||||
isPinned: false,
|
||||
dismiss: jest.fn(),
|
||||
viaSync: false,
|
||||
seriesIndex: 1,
|
||||
dataIndexes: [null, 0, 0],
|
||||
};
|
||||
|
||||
describe('BillingBarChartTooltip', () => {
|
||||
it('augments tooltipValue with quantity and unit for each series', () => {
|
||||
const uPlotInstance = makeUPlotInstance(['Logs', 'Traces']);
|
||||
const billingApiResponse = makeBillingApiResponse([
|
||||
{ legend: 'Logs', quantity: [1.5, 2.0], unit: 'GB' },
|
||||
{ legend: 'Traces', quantity: [500, 800], unit: 'spans' },
|
||||
]);
|
||||
|
||||
render(
|
||||
<BillingBarChartTooltip
|
||||
{...baseTooltipArgs}
|
||||
uPlotInstance={uPlotInstance}
|
||||
billingApiResponse={billingApiResponse}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getAllByText(/1\.5 GB/i).length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText(/500 spans/i).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('omits quantity line when quantity at dataIndex is null', () => {
|
||||
const uPlotInstance = makeUPlotInstance(['Logs', 'Traces']);
|
||||
const billingApiResponse = makeBillingApiResponse([
|
||||
{ legend: 'Logs', quantity: [null, null], unit: 'GB' },
|
||||
{ legend: 'Traces', quantity: [null, null], unit: 'spans' },
|
||||
]);
|
||||
|
||||
render(
|
||||
<BillingBarChartTooltip
|
||||
{...baseTooltipArgs}
|
||||
uPlotInstance={uPlotInstance}
|
||||
billingApiResponse={billingApiResponse}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText(/null GB/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/null spans/i)).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('uplot-tooltip-container')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('formats dollar value via getToolTipValue — strips trailing zeros (0.3076 → $0.3)', () => {
|
||||
const uPlotInstance = makeUPlotInstance(['Logs']);
|
||||
const { buildTooltipContent } = jest.requireMock(
|
||||
'lib/uPlotV2/components/Tooltip/utils',
|
||||
) as { buildTooltipContent: jest.Mock };
|
||||
buildTooltipContent.mockReturnValueOnce([
|
||||
{
|
||||
label: 'Logs',
|
||||
value: 0.3076171875,
|
||||
tooltipValue: '$0.31',
|
||||
color: '#7CEDBE',
|
||||
isActive: true,
|
||||
isHighlighted: false,
|
||||
},
|
||||
]);
|
||||
const billingApiResponse = makeBillingApiResponse([
|
||||
{ legend: 'Logs', quantity: [1.23], unit: 'GB' },
|
||||
]);
|
||||
|
||||
render(
|
||||
<BillingBarChartTooltip
|
||||
{...baseTooltipArgs}
|
||||
uPlotInstance={uPlotInstance}
|
||||
billingApiResponse={billingApiResponse}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getAllByText(/\$0\.3 -/i).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('passes through base tooltipValue when series is not in billingApiResponse', () => {
|
||||
const uPlotInstance = makeUPlotInstance(['Logs', 'Traces']);
|
||||
const billingApiResponse = makeBillingApiResponse([]);
|
||||
|
||||
render(
|
||||
<BillingBarChartTooltip
|
||||
{...baseTooltipArgs}
|
||||
uPlotInstance={uPlotInstance}
|
||||
billingApiResponse={billingApiResponse}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getAllByText('$100.00').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('$50.00').length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
|
||||
import { prepareBillingBarConfig } from '../prepareBillingBarConfig';
|
||||
|
||||
const makeApiResponse = (legends: string[]): MetricRangePayloadProps => ({
|
||||
data: {
|
||||
result: legends.map((legend) => ({
|
||||
legend,
|
||||
queryName: legend,
|
||||
metric: {},
|
||||
values: [[1000, '10']],
|
||||
})),
|
||||
resultType: '',
|
||||
newResult: { data: { result: [], resultType: '' } },
|
||||
},
|
||||
});
|
||||
|
||||
describe('prepareBillingBarConfig', () => {
|
||||
const baseProps = { isDarkMode: false };
|
||||
|
||||
it('returns a builder with no series when apiResponse is undefined', () => {
|
||||
const builder = prepareBillingBarConfig(baseProps);
|
||||
const config = builder.getConfig();
|
||||
expect(config.series).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('returns a builder with no series when result is empty', () => {
|
||||
const builder = prepareBillingBarConfig({
|
||||
...baseProps,
|
||||
apiResponse: makeApiResponse([]),
|
||||
});
|
||||
const config = builder.getConfig();
|
||||
expect(config.series).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('adds one series per result entry with correct labels and colors', () => {
|
||||
const builder = prepareBillingBarConfig({
|
||||
...baseProps,
|
||||
apiResponse: makeApiResponse(['Logs', 'Traces', 'Metrics']),
|
||||
});
|
||||
const config = builder.getConfig();
|
||||
expect(config.series).toHaveLength(4);
|
||||
expect(config.series?.[1]?.label).toBe('Logs');
|
||||
expect(config.series?.[1]?.stroke).toBe(Color.BG_FOREST_300);
|
||||
expect(config.series?.[2]?.label).toBe('Traces');
|
||||
expect(config.series?.[2]?.stroke).toBe(Color.BG_ROBIN_500);
|
||||
expect(config.series?.[3]?.label).toBe('Metrics');
|
||||
expect(config.series?.[3]?.stroke).toBe(Color.BG_SAKURA_500);
|
||||
});
|
||||
|
||||
it('assigns fallback color (Amber500) for signals beyond the 3-color palette', () => {
|
||||
const builder = prepareBillingBarConfig({
|
||||
...baseProps,
|
||||
apiResponse: makeApiResponse(['A', 'B', 'C', 'D']),
|
||||
});
|
||||
const config = builder.getConfig();
|
||||
expect(config.series?.[4]?.stroke).toBe(Color.BG_AMBER_500);
|
||||
});
|
||||
|
||||
it('sets stacking bands, padding, and focus alpha for behavioral parity', () => {
|
||||
const builder = prepareBillingBarConfig({
|
||||
...baseProps,
|
||||
apiResponse: makeApiResponse(['Logs', 'Traces', 'Metrics']),
|
||||
});
|
||||
const config = builder.getConfig();
|
||||
expect(config.bands).toStrictEqual([{ series: [1, 2] }, { series: [2, 3] }]);
|
||||
expect(config.padding).toStrictEqual([32, 32, 16, 16]);
|
||||
expect(config.focus).toStrictEqual({ alpha: 0.3 });
|
||||
});
|
||||
|
||||
it('sets no bands when result is empty', () => {
|
||||
const builder = prepareBillingBarConfig({
|
||||
...baseProps,
|
||||
apiResponse: makeApiResponse([]),
|
||||
});
|
||||
const config = builder.getConfig();
|
||||
expect(config.bands).toBeUndefined();
|
||||
});
|
||||
|
||||
it('uses queryName as label when legend is undefined', () => {
|
||||
const apiResponse: MetricRangePayloadProps = {
|
||||
data: {
|
||||
result: [
|
||||
{
|
||||
legend: undefined as any,
|
||||
queryName: 'Logs',
|
||||
metric: {},
|
||||
values: [[1000, '10']],
|
||||
},
|
||||
],
|
||||
resultType: '',
|
||||
newResult: { data: { result: [], resultType: '' } },
|
||||
},
|
||||
};
|
||||
const builder = prepareBillingBarConfig({ isDarkMode: false, apiResponse });
|
||||
const config = builder.getConfig();
|
||||
expect(config.series?.[1]?.label).toBe('Logs');
|
||||
expect(config.series?.[1]?.stroke).toBe(Color.BG_FOREST_300);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { calculateStartEndTime } from '../utils';
|
||||
|
||||
const makeData = (
|
||||
timestamps: number[],
|
||||
billingPeriodStart?: number,
|
||||
billingPeriodEnd?: number,
|
||||
) => ({
|
||||
billingPeriodStart,
|
||||
billingPeriodEnd,
|
||||
details: {
|
||||
total: 0,
|
||||
baseFee: 0,
|
||||
billTotal: 0,
|
||||
breakdown: [
|
||||
{
|
||||
type: 'Logs',
|
||||
unit: 'GB',
|
||||
dayWiseBreakdown: {
|
||||
breakdown: timestamps.map((timestamp) => ({
|
||||
timestamp,
|
||||
total: 0,
|
||||
quantity: 0,
|
||||
count: 0,
|
||||
size: 0,
|
||||
})),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
describe('calculateStartEndTime', () => {
|
||||
it('returns min/max of all breakdown timestamps', () => {
|
||||
const data = makeData([1000, 3000, 2000]);
|
||||
expect(calculateStartEndTime(data)).toStrictEqual({
|
||||
startTime: 1000,
|
||||
endTime: 3000,
|
||||
});
|
||||
});
|
||||
|
||||
it('includes billingPeriodStart and billingPeriodEnd in the range', () => {
|
||||
const data = makeData([2000, 3000], 500, 4000);
|
||||
expect(calculateStartEndTime(data)).toStrictEqual({
|
||||
startTime: 500,
|
||||
endTime: 4000,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns undefined when there are no timestamps and no billing period', () => {
|
||||
expect(calculateStartEndTime({})).toStrictEqual({
|
||||
startTime: undefined,
|
||||
endTime: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns undefined when breakdown is empty', () => {
|
||||
const data = makeData([]);
|
||||
expect(calculateStartEndTime(data)).toStrictEqual({
|
||||
startTime: undefined,
|
||||
endTime: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('filters out non-finite billingPeriod values', () => {
|
||||
const data = makeData([1000], NaN, Infinity);
|
||||
expect(calculateStartEndTime(data)).toStrictEqual({
|
||||
startTime: 1000,
|
||||
endTime: 1000,
|
||||
});
|
||||
});
|
||||
|
||||
it('works when details is missing', () => {
|
||||
expect(
|
||||
calculateStartEndTime({ billingPeriodStart: 100, billingPeriodEnd: 200 }),
|
||||
).toStrictEqual({
|
||||
startTime: 100,
|
||||
endTime: 200,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { getInitialStackedBands } from 'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils';
|
||||
import { buildBaseConfig } from 'container/DashboardContainer/visualization/panels/utils/baseConfigBuilder';
|
||||
import { DrawStyle } from 'lib/uPlotV2/config/types';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
|
||||
const BILLING_SERIES_COLORS = [
|
||||
Color.BG_FOREST_300,
|
||||
Color.BG_ROBIN_500,
|
||||
Color.BG_SAKURA_500,
|
||||
];
|
||||
|
||||
export interface PrepareBillingBarConfigProps {
|
||||
isDarkMode: boolean;
|
||||
timezone?: Timezone;
|
||||
minTimeScale?: number;
|
||||
maxTimeScale?: number;
|
||||
apiResponse?: MetricRangePayloadProps;
|
||||
}
|
||||
|
||||
export function prepareBillingBarConfig({
|
||||
isDarkMode,
|
||||
timezone,
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
apiResponse,
|
||||
}: PrepareBillingBarConfigProps): UPlotConfigBuilder {
|
||||
const builder = buildBaseConfig({
|
||||
id: 'billing-usage-breakdown',
|
||||
isDarkMode,
|
||||
timezone,
|
||||
panelType: PANEL_TYPES.BAR,
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
});
|
||||
|
||||
const results = apiResponse?.data?.result;
|
||||
if (!results?.length) {
|
||||
return builder;
|
||||
}
|
||||
|
||||
const labels = results.map((s) => s.legend || s.queryName || '');
|
||||
|
||||
const colorMapping = labels.reduce<Record<string, string>>(
|
||||
(acc, label, index) => {
|
||||
acc[label] = BILLING_SERIES_COLORS[index] ?? Color.BG_AMBER_500;
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
labels.forEach((label) => {
|
||||
builder.addSeries({
|
||||
scaleKey: 'y',
|
||||
drawStyle: DrawStyle.Bar,
|
||||
label,
|
||||
colorMapping,
|
||||
isDarkMode,
|
||||
metric: {},
|
||||
});
|
||||
});
|
||||
|
||||
builder.setBands(getInitialStackedBands(results.length));
|
||||
builder.setPadding([32, 32, 16, 16]);
|
||||
builder.setFocus({ alpha: 0.3 });
|
||||
|
||||
return builder;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { UsageResponsePayloadProps } from 'api/billing/getUsage';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import dayjs from 'dayjs';
|
||||
import { getUPlotChartData } from 'lib/uPlotLib/utils/getUplotChartData';
|
||||
import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils';
|
||||
import { isEmpty, isNull } from 'lodash-es';
|
||||
import { unparse } from 'papaparse';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
@@ -120,11 +120,40 @@ export function prepareCsvData(data: Partial<UsageResponsePayloadProps>): {
|
||||
fileName: string;
|
||||
} {
|
||||
const graphCompatibleData = convertDataToMetricRangePayload(data);
|
||||
const chartData = getUPlotChartData(graphCompatibleData);
|
||||
const quantityMapArr = quantityDataArr(graphCompatibleData, chartData[0]);
|
||||
const chartData = prepareChartData(graphCompatibleData);
|
||||
const quantityMapArr = quantityDataArr(
|
||||
graphCompatibleData,
|
||||
chartData[0] as number[],
|
||||
);
|
||||
|
||||
return {
|
||||
csvData: unparse(generateCsvData(quantityMapArr)),
|
||||
fileName: csvFileName(quantityMapArr),
|
||||
};
|
||||
}
|
||||
|
||||
export function calculateStartEndTime(
|
||||
data: Partial<UsageResponsePayloadProps>,
|
||||
): { startTime: number | undefined; endTime: number | undefined } {
|
||||
const timestamps: number[] = [];
|
||||
data?.details?.breakdown?.forEach((breakdown) => {
|
||||
breakdown?.dayWiseBreakdown?.breakdown?.forEach((entry) => {
|
||||
timestamps.push(entry.timestamp);
|
||||
});
|
||||
});
|
||||
|
||||
const billingTime: number[] = [
|
||||
data?.billingPeriodStart,
|
||||
data?.billingPeriodEnd,
|
||||
].filter((t): t is number => typeof t === 'number' && Number.isFinite(t));
|
||||
|
||||
const allTimes = [...timestamps, ...billingTime];
|
||||
if (allTimes.length === 0) {
|
||||
return { startTime: undefined, endTime: undefined };
|
||||
}
|
||||
|
||||
return {
|
||||
startTime: Math.min(...allTimes),
|
||||
endTime: Math.max(...allTimes),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { Dashboard } from 'types/api/dashboard/getAll';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { removeVariableReferencesFromDashboard } from './addTagFiltersToDashboard';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared fixture helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const EMPTY_BUILDER = {
|
||||
queryData: [] as any,
|
||||
queryFormulas: [],
|
||||
queryTraceOperator: [],
|
||||
};
|
||||
|
||||
const BASE_WIDGET = {
|
||||
opacity: '1',
|
||||
nullZeroValues: 'null',
|
||||
timePreferance: 'GLOBAL_TIME' as const,
|
||||
softMin: null,
|
||||
softMax: null,
|
||||
selectedLogFields: null,
|
||||
selectedTracesFields: null,
|
||||
};
|
||||
|
||||
const DEFAULT_QUERY_DATA = {
|
||||
queryName: 'q1',
|
||||
// In QB v5, expression holds the query label (A/B/C), not a filter expression
|
||||
expression: 'A',
|
||||
dataSource: DataSource.METRICS,
|
||||
functions: [],
|
||||
groupBy: [],
|
||||
filters: { items: [] as any[], op: 'AND' as const },
|
||||
legend: '',
|
||||
disabled: false,
|
||||
having: [],
|
||||
limit: null,
|
||||
stepInterval: null,
|
||||
orderBy: [],
|
||||
selectColumns: [],
|
||||
source: '' as const,
|
||||
};
|
||||
|
||||
/**
|
||||
* Build a dashboard with a single builder widget.
|
||||
* Only supply the fields your test actually cares about.
|
||||
*/
|
||||
const buildBuilderDashboard = (
|
||||
filterExpression: string,
|
||||
queryDataOverrides: Record<string, any> = {},
|
||||
): Dashboard => ({
|
||||
id: 'dash1',
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
createdBy: '',
|
||||
updatedBy: '',
|
||||
data: {
|
||||
title: 'Test Dashboard',
|
||||
widgets: [
|
||||
{
|
||||
...BASE_WIDGET,
|
||||
id: 'widget-1',
|
||||
panelTypes: PANEL_TYPES.TIME_SERIES,
|
||||
title: 'Widget 1',
|
||||
description: '',
|
||||
query: {
|
||||
id: 'query1',
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
promql: [],
|
||||
clickhouse_sql: [],
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
...DEFAULT_QUERY_DATA,
|
||||
...queryDataOverrides,
|
||||
filter: { expression: filterExpression },
|
||||
},
|
||||
],
|
||||
queryFormulas: [],
|
||||
queryTraceOperator: [],
|
||||
},
|
||||
unit: '',
|
||||
},
|
||||
},
|
||||
],
|
||||
variables: {},
|
||||
},
|
||||
});
|
||||
|
||||
const buildClickhouseDashboard = (query: string): Dashboard => ({
|
||||
id: 'dash-ch',
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
createdBy: '',
|
||||
updatedBy: '',
|
||||
data: {
|
||||
title: 'CH',
|
||||
widgets: [
|
||||
{
|
||||
...BASE_WIDGET,
|
||||
id: 'w1',
|
||||
panelTypes: PANEL_TYPES.TIME_SERIES,
|
||||
title: '',
|
||||
description: '',
|
||||
query: {
|
||||
id: 'q1',
|
||||
queryType: EQueryType.CLICKHOUSE,
|
||||
promql: [],
|
||||
clickhouse_sql: [{ name: 'A', query, legend: '', disabled: false }],
|
||||
builder: EMPTY_BUILDER,
|
||||
unit: '',
|
||||
},
|
||||
},
|
||||
],
|
||||
variables: {},
|
||||
},
|
||||
});
|
||||
|
||||
const buildPromqlDashboard = (query: string): Dashboard => ({
|
||||
id: 'dash-prom',
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
createdBy: '',
|
||||
updatedBy: '',
|
||||
data: {
|
||||
title: 'PromQL Dashboard',
|
||||
widgets: [
|
||||
{
|
||||
...BASE_WIDGET,
|
||||
id: 'widget-prom',
|
||||
panelTypes: PANEL_TYPES.TIME_SERIES,
|
||||
title: 'PromQL Widget',
|
||||
description: '',
|
||||
query: {
|
||||
id: 'query-prom',
|
||||
queryType: EQueryType.PROM,
|
||||
promql: [{ name: 'A', query, legend: '', disabled: false }],
|
||||
clickhouse_sql: [],
|
||||
builder: EMPTY_BUILDER,
|
||||
unit: '',
|
||||
},
|
||||
},
|
||||
],
|
||||
variables: {},
|
||||
},
|
||||
});
|
||||
|
||||
/** Run removeVariableReferencesFromDashboard on a single-widget clickhouse dashboard and return the cleaned SQL. */
|
||||
const chQuery = (sql: string, varName: string): string => {
|
||||
const result = removeVariableReferencesFromDashboard(
|
||||
buildClickhouseDashboard(sql),
|
||||
varName,
|
||||
);
|
||||
return (result!.data.widgets![0] as any).query.clickhouse_sql[0].query;
|
||||
};
|
||||
|
||||
/** Extract the first builder queryData from a cleaned dashboard. */
|
||||
const firstBuilderQueryData = (dashboard: Dashboard | undefined): any =>
|
||||
(dashboard!.data.widgets![0] as any).query.builder.queryData[0];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('removeVariableReferencesFromDashboard', () => {
|
||||
describe('builder filter expression cleanup', () => {
|
||||
it('removes a variable clause from filter.expression', () => {
|
||||
const dashboard = buildBuilderDashboard(
|
||||
"service.name IN $service AND env = 'prod'",
|
||||
);
|
||||
|
||||
const result = removeVariableReferencesFromDashboard(dashboard, 'service');
|
||||
|
||||
expect(firstBuilderQueryData(result).filter.expression).toBe("env = 'prod'");
|
||||
});
|
||||
|
||||
it('leaves no dangling AND/OR after removing a variable clause', () => {
|
||||
const dashboard = buildBuilderDashboard(
|
||||
"service.name IN $service AND env = 'prod'",
|
||||
);
|
||||
|
||||
const result = removeVariableReferencesFromDashboard(dashboard, 'service');
|
||||
const { expression } = firstBuilderQueryData(result).filter;
|
||||
|
||||
expect(expression).toBe("env = 'prod'");
|
||||
expect(expression).not.toMatch(/^\s*(AND|OR)/i);
|
||||
expect(expression).not.toMatch(/(AND|OR)\s*$/i);
|
||||
});
|
||||
|
||||
it('does not remove $environment clause when deleting $env', () => {
|
||||
const dashboard = buildBuilderDashboard(
|
||||
'env = $env AND deployment.environment = $environment',
|
||||
);
|
||||
|
||||
const result = removeVariableReferencesFromDashboard(dashboard, 'env');
|
||||
|
||||
expect(firstBuilderQueryData(result).filter.expression).toBe(
|
||||
'deployment.environment = $environment',
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves literal filter expressions untouched when removing a variable', () => {
|
||||
const dashboard = buildBuilderDashboard(
|
||||
"service.name = 'api-gateway' AND env = 'prod'",
|
||||
);
|
||||
|
||||
const result = removeVariableReferencesFromDashboard(dashboard, 'service');
|
||||
|
||||
expect(firstBuilderQueryData(result).filter.expression).toBe(
|
||||
"service.name = 'api-gateway' AND env = 'prod'",
|
||||
);
|
||||
});
|
||||
|
||||
it('removes only the variable clause, preserving a literal clause on the same key', () => {
|
||||
const dashboard = buildBuilderDashboard(
|
||||
"service.name IN $service AND service.name = 'api-gateway'",
|
||||
);
|
||||
|
||||
const result = removeVariableReferencesFromDashboard(dashboard, 'service');
|
||||
|
||||
expect(firstBuilderQueryData(result).filter.expression).toBe(
|
||||
"service.name = 'api-gateway'",
|
||||
);
|
||||
});
|
||||
|
||||
it('returns filter.expression unchanged when the variable has no clauses in it', () => {
|
||||
const dashboard = buildBuilderDashboard("env = 'prod'");
|
||||
|
||||
const result = removeVariableReferencesFromDashboard(dashboard, 'service');
|
||||
|
||||
expect(firstBuilderQueryData(result).filter.expression).toBe("env = 'prod'");
|
||||
});
|
||||
});
|
||||
|
||||
describe('PromQL query cleanup', () => {
|
||||
it('removes variable placeholder from a promql query', () => {
|
||||
const result = removeVariableReferencesFromDashboard(
|
||||
buildPromqlDashboard('sum(rate(http_requests_total{$service}[5m]))'),
|
||||
'service',
|
||||
);
|
||||
|
||||
const widget = result!.data.widgets![0] as any;
|
||||
expect(widget.query.promql[0].query).toBe(
|
||||
'sum(rate(http_requests_total{}[5m]))',
|
||||
);
|
||||
});
|
||||
|
||||
it('strips only the variable token inside a PromQL label matcher (token-only path)', () => {
|
||||
const result = removeVariableReferencesFromDashboard(
|
||||
buildPromqlDashboard('up{env="$env", job="api"}'),
|
||||
'env',
|
||||
);
|
||||
|
||||
const widget = result!.data.widgets![0] as any;
|
||||
expect(widget.query.promql[0].query).toBe('up{env="", job="api"}');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ClickHouse SQL query cleanup', () => {
|
||||
it('removes a quoted variable clause and its WHERE keyword', () => {
|
||||
expect(
|
||||
chQuery(
|
||||
"SELECT count() FROM signoz_logs WHERE service_name = '$service'",
|
||||
'service',
|
||||
),
|
||||
).toBe('SELECT count() FROM signoz_logs');
|
||||
});
|
||||
|
||||
it('removes a middle clause: AND env={{.env}} AND', () => {
|
||||
expect(
|
||||
chQuery('SELECT count() FROM t WHERE a=1 AND env={{.env}} AND b=2', 'env'),
|
||||
).toBe('SELECT count() FROM t WHERE a=1 AND b=2');
|
||||
});
|
||||
|
||||
it('removes the first clause: env={{.env}} AND rest', () => {
|
||||
expect(
|
||||
chQuery('SELECT count() FROM t WHERE env={{.env}} AND b=2', 'env'),
|
||||
).toBe('SELECT count() FROM t WHERE b=2');
|
||||
});
|
||||
|
||||
it('removes the last clause: rest AND env=$env', () => {
|
||||
expect(chQuery('SELECT count() FROM t WHERE a=1 AND env=$env', 'env')).toBe(
|
||||
'SELECT count() FROM t WHERE a=1',
|
||||
);
|
||||
});
|
||||
|
||||
it('removes a clause with double-bracket syntax: service=[[svc]]', () => {
|
||||
expect(chQuery('SELECT count() FROM t WHERE service=[[svc]]', 'svc')).toBe(
|
||||
'SELECT count() FROM t',
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to token-only strip for a bare variable in SELECT', () => {
|
||||
expect(chQuery('SELECT $metric FROM table', 'metric')).toBe(
|
||||
'SELECT FROM table',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('is idempotent — calling twice produces the same result', () => {
|
||||
const dashboard = buildBuilderDashboard(
|
||||
"service.name IN $service AND env = 'prod'",
|
||||
);
|
||||
|
||||
const once = removeVariableReferencesFromDashboard(dashboard, 'service');
|
||||
const twice = removeVariableReferencesFromDashboard(once, 'service');
|
||||
|
||||
expect(twice).toStrictEqual(once);
|
||||
});
|
||||
|
||||
it('handles a dashboard with no widgets without throwing', () => {
|
||||
const dashboard: Dashboard = {
|
||||
id: 'dash-empty',
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
createdBy: '',
|
||||
updatedBy: '',
|
||||
data: { title: 'Empty Dashboard', widgets: undefined, variables: {} },
|
||||
};
|
||||
|
||||
expect(() =>
|
||||
removeVariableReferencesFromDashboard(dashboard, 'service'),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
import {
|
||||
convertFiltersToExpressionWithExistingQuery,
|
||||
createVariablePlaceholderRegExp,
|
||||
removeKeysFromExpression,
|
||||
removeVariableFromExpression,
|
||||
} from 'components/QueryBuilderV2/utils';
|
||||
import { cloneDeep, isArray, isEmpty } from 'lodash-es';
|
||||
import { Dashboard, Widgets } from 'types/api/dashboard/getAll';
|
||||
@@ -157,6 +159,139 @@ const updateAfterRemoval = (
|
||||
};
|
||||
};
|
||||
|
||||
const removeVariablePlaceholders = (
|
||||
text: string | undefined,
|
||||
variableName: string,
|
||||
): string => {
|
||||
if (!text) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const tokenPattern = createVariablePlaceholderRegExp(variableName);
|
||||
|
||||
// Step 1: attempt clause-aware removal for SQL WHERE patterns.
|
||||
// Strips the entire `key op $var` unit plus its adjacent AND/OR so we
|
||||
// never leave a dangling `key = ` in unquoted ClickHouse SQL clauses.
|
||||
// Handles three shapes:
|
||||
// (a) preceding conjunction: AND key = $var
|
||||
// (b) following conjunction: key = $var AND
|
||||
// (c) standalone clause: key = $var (end of expression)
|
||||
const escapedToken = tokenPattern.source;
|
||||
const clausePattern = new RegExp(
|
||||
// (a) conjunction before the clause
|
||||
`\\s*\\b(?:AND|OR)\\b\\s+[\\w."'\\[\\]]+\\s*(?:=|!=|<>|LIKE|ILIKE|IN|NOT\\s+IN)\\s*'?${escapedToken}'?` +
|
||||
// (b)+(c) clause first, optional conjunction after
|
||||
`|[\\w."'\\[\\]]+\\s*(?:=|!=|<>|LIKE|ILIKE|IN|NOT\\s+IN)\\s*'?${escapedToken}'?(?:\\s*\\b(?:AND|OR)\\b)?`,
|
||||
'gi',
|
||||
);
|
||||
|
||||
const withClauseRemoval = text.replace(clausePattern, '');
|
||||
if (withClauseRemoval !== text) {
|
||||
return withClauseRemoval
|
||||
.replace(/\s{2,}/g, ' ')
|
||||
.replace(/\bWHERE\s*$/i, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
// Step 2: fallback — bare variable usage outside a key-op-value pattern
|
||||
// (e.g. SELECT $metric, LIMIT $n). Token-only removal is correct here.
|
||||
return text
|
||||
.replace(tokenPattern, '')
|
||||
.replace(/\s{2,}/g, ' ')
|
||||
.trim();
|
||||
};
|
||||
|
||||
const removeVariableReferencesFromQueryData = (
|
||||
queryData: IBuilderQuery,
|
||||
variableName: string,
|
||||
): IBuilderQuery => {
|
||||
const updatedFilter = queryData.filter?.expression
|
||||
? {
|
||||
...queryData.filter,
|
||||
expression: removeVariableFromExpression(
|
||||
queryData.filter.expression,
|
||||
variableName,
|
||||
),
|
||||
}
|
||||
: queryData.filter;
|
||||
|
||||
return { ...queryData, filter: updatedFilter };
|
||||
};
|
||||
|
||||
const removeVariableReferencesFromWidget = (
|
||||
widget: Widgets,
|
||||
variableName: string,
|
||||
): Widgets => {
|
||||
let updatedWidget = { ...widget };
|
||||
|
||||
if (updatedWidget.query?.builder?.queryData) {
|
||||
updatedWidget = {
|
||||
...updatedWidget,
|
||||
query: {
|
||||
...updatedWidget.query,
|
||||
builder: {
|
||||
...updatedWidget.query.builder,
|
||||
queryData: updatedWidget.query.builder.queryData.map((queryData) =>
|
||||
removeVariableReferencesFromQueryData(queryData, variableName),
|
||||
),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (updatedWidget.query?.promql) {
|
||||
updatedWidget = {
|
||||
...updatedWidget,
|
||||
query: {
|
||||
...updatedWidget.query,
|
||||
promql: updatedWidget.query.promql.map((promqlQuery) => ({
|
||||
...promqlQuery,
|
||||
query: removeVariablePlaceholders(promqlQuery.query, variableName),
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (updatedWidget.query?.clickhouse_sql) {
|
||||
updatedWidget = {
|
||||
...updatedWidget,
|
||||
query: {
|
||||
...updatedWidget.query,
|
||||
clickhouse_sql: updatedWidget.query.clickhouse_sql.map((sqlQuery) => ({
|
||||
...sqlQuery,
|
||||
query: removeVariablePlaceholders(sqlQuery.query, variableName),
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return updatedWidget;
|
||||
};
|
||||
|
||||
export const removeVariableReferencesFromDashboard = (
|
||||
dashboard: Dashboard | undefined,
|
||||
variableName: string,
|
||||
): Dashboard | undefined => {
|
||||
if (!dashboard || !variableName) {
|
||||
return dashboard;
|
||||
}
|
||||
|
||||
const updatedDashboard = cloneDeep(dashboard);
|
||||
|
||||
if (updatedDashboard.data.widgets) {
|
||||
updatedDashboard.data.widgets = updatedDashboard.data.widgets.map(
|
||||
(widget) => {
|
||||
if ('query' in widget) {
|
||||
return removeVariableReferencesFromWidget(widget as Widgets, variableName);
|
||||
}
|
||||
return widget;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return updatedDashboard;
|
||||
};
|
||||
|
||||
/**
|
||||
* A function that takes a dashboard configuration and a list of tag filters
|
||||
* and returns an updated dashboard with the filters appended to widget queries.
|
||||
|
||||
@@ -18,10 +18,11 @@ import { convertVariablesToDbFormat } from 'container/DashboardContainer/Dashboa
|
||||
import { useAddDynamicVariableToPanels } from 'hooks/dashboard/useAddDynamicVariableToPanels';
|
||||
import { useDashboardVariables } from 'hooks/dashboard/useDashboardVariables';
|
||||
import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { IDashboardVariables } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreTypes';
|
||||
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
|
||||
import { IDashboardVariable } from 'types/api/dashboard/getAll';
|
||||
import { removeVariableReferencesFromDashboard } from './addTagFiltersToDashboard';
|
||||
|
||||
import { TVariableMode } from './types';
|
||||
import VariableItem from './VariableItem/VariableItem';
|
||||
@@ -92,8 +93,6 @@ function VariablesSettings({
|
||||
const { dashboardData, setDashboardData } = useDashboardStore();
|
||||
const { dashboardVariables } = useDashboardVariables();
|
||||
|
||||
const { notifications } = useNotifications();
|
||||
|
||||
const [variablesTableData, setVariablesTableData] = useState<any>([]);
|
||||
const [variblesOrderArr, setVariablesOrderArr] = useState<number[]>([]);
|
||||
const [existingVariableNamesMap, setExistingVariableNamesMap] = useState<
|
||||
@@ -201,9 +200,7 @@ function VariablesSettings({
|
||||
onSuccess: (updatedDashboard) => {
|
||||
if (updatedDashboard.data) {
|
||||
setDashboardData(updatedDashboard.data);
|
||||
notifications.success({
|
||||
message: t('variable_updated_successfully'),
|
||||
});
|
||||
toast.success(t('variable_updated_successfully'));
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -256,6 +253,11 @@ function VariablesSettings({
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = (): void => {
|
||||
if (!dashboardData || !variableToDelete.current) {
|
||||
setDeleteVariableModal(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const newVariablesArr = variablesTableData.filter(
|
||||
(variable: IDashboardVariable) =>
|
||||
variable.id !== variableToDelete?.current?.id,
|
||||
@@ -263,7 +265,31 @@ function VariablesSettings({
|
||||
|
||||
const updatedVariables = convertVariablesToDbFormat(newVariablesArr);
|
||||
|
||||
updateVariables(updatedVariables);
|
||||
const cleanedDashboard =
|
||||
removeVariableReferencesFromDashboard(
|
||||
dashboardData,
|
||||
variableToDelete.current.name || '',
|
||||
) || dashboardData;
|
||||
|
||||
updateMutation.mutateAsync(
|
||||
{
|
||||
id: dashboardData.id,
|
||||
|
||||
data: {
|
||||
...cleanedDashboard.data,
|
||||
variables: updatedVariables,
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: (updatedDashboard) => {
|
||||
if (updatedDashboard.data) {
|
||||
setDashboardData(updatedDashboard.data);
|
||||
toast.success(t('variable_updated_successfully'));
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
variableToDelete.current = null;
|
||||
setDeleteVariableModal(false);
|
||||
};
|
||||
@@ -476,6 +502,7 @@ function VariablesSettings({
|
||||
open={deleteVariableModal}
|
||||
onOk={handleDeleteConfirm}
|
||||
onCancel={handleDeleteCancel}
|
||||
okButtonProps={{ loading: updateMutation.isLoading }}
|
||||
>
|
||||
<Typography.Text>
|
||||
Are you sure you want to delete variable{' '}
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
import { logEventMock } from '__tests__/logEventMock';
|
||||
import { Events } from 'constants/events';
|
||||
import { DEFAULT_PIN_TOOLTIP_KEY } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import TooltipFooter from '../TooltipFooter';
|
||||
|
||||
const mockLogEvent = jest.fn();
|
||||
|
||||
jest.mock('api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: (...args: unknown[]): unknown => mockLogEvent(...args),
|
||||
}));
|
||||
|
||||
describe('TooltipFooter', () => {
|
||||
const defaultProps = {
|
||||
id: 'panel-123',
|
||||
@@ -84,7 +78,7 @@ describe('TooltipFooter', () => {
|
||||
|
||||
await user.click(screen.getByTestId('uplot-tooltip-unpin'));
|
||||
|
||||
expect(mockLogEvent).toHaveBeenCalledWith(Events.TOOLTIP_UNPINNED, {
|
||||
expect(logEventMock).toHaveBeenCalledWith(Events.TOOLTIP_UNPINNED, {
|
||||
id: 'panel-123',
|
||||
});
|
||||
expect(dismiss).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { memo, useEffect, useMemo, useRef, useState } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import * as Sentry from '@sentry/react';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { DEFAULT_ENTITY_VERSION, ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { QueryParams } from 'constants/query';
|
||||
@@ -67,20 +66,6 @@ function GridCardGraph({
|
||||
const [errorMessage, setErrorMessage] = useState<string>();
|
||||
const [isInternalServerError, setIsInternalServerError] =
|
||||
useState<boolean>(false);
|
||||
const queryRangeCalledRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (!queryRangeCalledRef.current) {
|
||||
Sentry.captureEvent({
|
||||
message: `Dashboard query range not called within expected timeframe for widget ${widget?.id}`,
|
||||
level: 'warning',
|
||||
});
|
||||
}
|
||||
}, 120000);
|
||||
return (): void => clearTimeout(timeoutId);
|
||||
}, [widget?.id]);
|
||||
|
||||
const {
|
||||
minTime,
|
||||
maxTime,
|
||||
@@ -271,14 +256,12 @@ function GridCardGraph({
|
||||
});
|
||||
}
|
||||
}
|
||||
queryRangeCalledRef.current = true;
|
||||
},
|
||||
onSettled: (data) => {
|
||||
dataAvailable?.(
|
||||
isDataAvailableByPanelType(data?.payload?.data, widget?.panelTypes),
|
||||
);
|
||||
getGraphData?.(data?.payload?.data);
|
||||
queryRangeCalledRef.current = true;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -89,7 +89,7 @@ export function AlertsEmptyState({
|
||||
onClick={onClickNewAlertHandler}
|
||||
disabled={!addNewAlert}
|
||||
loading={loading}
|
||||
data-testid="add-alert"
|
||||
testId="add-alert"
|
||||
>
|
||||
<span className={styles.buttonContent}>
|
||||
<Plus size="md" />
|
||||
@@ -97,7 +97,12 @@ export function AlertsEmptyState({
|
||||
</span>
|
||||
</Button>
|
||||
{onRefresh && (
|
||||
<Button onClick={onRefresh} prefix={<RefreshCw />} color="secondary">
|
||||
<Button
|
||||
onClick={onRefresh}
|
||||
prefix={<RefreshCw />}
|
||||
color="secondary"
|
||||
testId="list-alerts-empty-refresh-button"
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { logEventMock } from '__tests__/logEventMock';
|
||||
import { safeNavigateMock } from '__tests__/safeNavigateMock';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { screen, waitFor } from 'tests/test-utils';
|
||||
|
||||
import { findAlertRow, renderListAlertRules } from './_helpers';
|
||||
|
||||
async function openActionsMenu(row: HTMLElement): Promise<void> {
|
||||
const trigger = row.querySelector(
|
||||
'[data-testid="alert-actions"]',
|
||||
) as HTMLElement | null;
|
||||
expect(trigger).not.toBeNull();
|
||||
const user = userEvent.setup({ delay: null });
|
||||
await user.click(trigger as HTMLElement);
|
||||
// Radix renders the menu items in a portal once the trigger is activated.
|
||||
await screen.findByRole('menu');
|
||||
}
|
||||
|
||||
async function clickMenuItem(label: string): Promise<void> {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
const item = await screen.findByRole('menuitem', { name: label });
|
||||
await user.click(item);
|
||||
}
|
||||
|
||||
describe('ListAlertRules — actions menu', () => {
|
||||
beforeEach(() => {
|
||||
jest.setSystemTime(new Date('2023-10-20T12:00:00Z'));
|
||||
});
|
||||
|
||||
it('renders Enable/Disable/Edit/Edit in New Tab/Clone/Delete items after opening the menu', async () => {
|
||||
renderListAlertRules();
|
||||
const row = await findAlertRow('High CPU Alert');
|
||||
|
||||
expect(screen.queryByRole('menu')).not.toBeInTheDocument();
|
||||
|
||||
await openActionsMenu(row);
|
||||
|
||||
const items = screen.getAllByRole('menuitem');
|
||||
const labels = items.map((it) => it.textContent);
|
||||
expect(labels).toStrictEqual(
|
||||
expect.arrayContaining([
|
||||
'Edit',
|
||||
'Edit in New Tab',
|
||||
'Clone',
|
||||
'Delete',
|
||||
'Disable',
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('disabled rule (rule-4) shows "Enable" instead of "Disable"', async () => {
|
||||
renderListAlertRules();
|
||||
const row = await findAlertRow('Disabled Alert');
|
||||
await openActionsMenu(row);
|
||||
|
||||
const items = screen.getAllByRole('menuitem');
|
||||
const labels = items.map((it) => it.textContent);
|
||||
expect(labels).toContain('Enable');
|
||||
expect(labels).not.toContain('Disable');
|
||||
});
|
||||
|
||||
it('toggle action: clicking Disable sends PATCH with disabled:true', async () => {
|
||||
let capturedBody: unknown = null;
|
||||
let capturedPath: string | null = null;
|
||||
server.use(
|
||||
rest.patch('http://localhost/api/v2/rules/:id', async (req, res, ctx) => {
|
||||
capturedBody = await req.json();
|
||||
capturedPath = req.params.id as string;
|
||||
return res(ctx.status(200), ctx.json({ status: 'success' }));
|
||||
}),
|
||||
);
|
||||
|
||||
renderListAlertRules();
|
||||
const row = await findAlertRow('High CPU Alert');
|
||||
await openActionsMenu(row);
|
||||
await clickMenuItem('Disable');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(capturedBody).toStrictEqual(
|
||||
expect.objectContaining({ disabled: true }),
|
||||
);
|
||||
});
|
||||
expect(capturedPath).toBe('rule-1');
|
||||
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
'Alert: Action',
|
||||
expect.objectContaining({ action: 'Enable/Disable', ruleId: 'rule-1' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('edit action: clicking Edit navigates via safeNavigate and logs event', async () => {
|
||||
renderListAlertRules();
|
||||
const row = await findAlertRow('High CPU Alert');
|
||||
await openActionsMenu(row);
|
||||
await clickMenuItem('Edit');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(safeNavigateMock).toHaveBeenCalled();
|
||||
});
|
||||
expect(safeNavigateMock.mock.calls[0][0]).toContain('ruleId=rule-1');
|
||||
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
'Alert: Action',
|
||||
expect.objectContaining({ action: 'Edit', ruleId: 'rule-1' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('edit in new tab action: clicking opens with newTab:true', async () => {
|
||||
renderListAlertRules();
|
||||
const row = await findAlertRow('High CPU Alert');
|
||||
await openActionsMenu(row);
|
||||
await clickMenuItem('Edit in New Tab');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(safeNavigateMock).toHaveBeenCalled();
|
||||
});
|
||||
const [url, options] = safeNavigateMock.mock.calls[0];
|
||||
expect(url).toContain('ruleId=rule-1');
|
||||
expect(options).toStrictEqual(expect.objectContaining({ newTab: true }));
|
||||
});
|
||||
|
||||
it('clone action: sends POST with " - Copy" suffix and opens the cloned rule returned by the API', async () => {
|
||||
let capturedPostBody: unknown = null;
|
||||
server.use(
|
||||
rest.post('http://localhost/api/v2/rules', async (req, res, ctx) => {
|
||||
capturedPostBody = await req.json();
|
||||
return res(
|
||||
ctx.status(201),
|
||||
ctx.json({
|
||||
data: {
|
||||
...(capturedPostBody as Record<string, unknown>),
|
||||
id: 'cloned-from-server',
|
||||
},
|
||||
status: 'success',
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
renderListAlertRules();
|
||||
const row = await findAlertRow('High CPU Alert');
|
||||
await openActionsMenu(row);
|
||||
await clickMenuItem('Clone');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(capturedPostBody).toStrictEqual(
|
||||
expect.objectContaining({ alert: 'High CPU Alert - Copy' }),
|
||||
);
|
||||
});
|
||||
|
||||
// The id from the server response round-trips into the navigate URL — this
|
||||
// protects against a regression where the code hardcodes the id.
|
||||
await waitFor(() => {
|
||||
expect(safeNavigateMock).toHaveBeenCalled();
|
||||
});
|
||||
expect(safeNavigateMock.mock.calls[0][0]).toContain(
|
||||
'ruleId=cloned-from-server',
|
||||
);
|
||||
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
'Alert: Action',
|
||||
expect.objectContaining({ action: 'Clone', ruleId: 'rule-1' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('delete action: sends DELETE for the rule id', async () => {
|
||||
let deletedId: string | null = null;
|
||||
server.use(
|
||||
rest.delete('http://localhost/api/v2/rules/:id', (req, res, ctx) => {
|
||||
deletedId = req.params.id as string;
|
||||
return res(ctx.status(200), ctx.json({ status: 'success' }));
|
||||
}),
|
||||
);
|
||||
|
||||
renderListAlertRules();
|
||||
const row = await findAlertRow('High CPU Alert');
|
||||
await openActionsMenu(row);
|
||||
await clickMenuItem('Delete');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(deletedId).toBe('rule-1');
|
||||
});
|
||||
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
'Alert: Action',
|
||||
expect.objectContaining({ action: 'Delete', ruleId: 'rule-1' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('error path: PATCH is still attempted when server returns 500', async () => {
|
||||
let patchAttempted = false;
|
||||
server.use(
|
||||
rest.patch('http://localhost/api/v2/rules/:id', (_, res, ctx) => {
|
||||
patchAttempted = true;
|
||||
return res(ctx.status(500), ctx.json({ status: 'error' }));
|
||||
}),
|
||||
);
|
||||
|
||||
renderListAlertRules();
|
||||
const row = await findAlertRow('High CPU Alert');
|
||||
await openActionsMenu(row);
|
||||
await clickMenuItem('Disable');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(patchAttempted).toBe(true);
|
||||
});
|
||||
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
'Alert: Action',
|
||||
expect.objectContaining({ action: 'Enable/Disable', ruleId: 'rule-1' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { screen, waitFor } from 'tests/test-utils';
|
||||
|
||||
import { renderListAlertRules } from './_helpers';
|
||||
|
||||
const COLUMN_STORAGE_KEY = '@signoz/table-columns/alert-rules-columns';
|
||||
|
||||
describe('ListAlertRules — columns selector', () => {
|
||||
beforeEach(() => {
|
||||
jest.setSystemTime(new Date('2023-10-20T12:00:00Z'));
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('opens columns popover and lists toggleable columns', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderListAlertRules();
|
||||
|
||||
await screen.findByText('High CPU Alert');
|
||||
|
||||
await user.click(screen.getByTestId('alert-columns-button'));
|
||||
|
||||
// Popover should reveal "Toggle Columns" heading + per-column labels.
|
||||
await screen.findByText('Toggle Columns');
|
||||
expect(screen.getByText('Created At')).toBeInTheDocument();
|
||||
expect(screen.getByText('Created By')).toBeInTheDocument();
|
||||
expect(screen.getByText('Updated At')).toBeInTheDocument();
|
||||
expect(screen.getByText('Updated By')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('default-hidden columns (Created At/By, Updated At/By) are not in the table header', async () => {
|
||||
renderListAlertRules();
|
||||
|
||||
await screen.findByText('High CPU Alert');
|
||||
|
||||
const headers = document.querySelectorAll('th');
|
||||
const headerTexts = Array.from(headers).map((h) => h.textContent || '');
|
||||
expect(headerTexts.some((t) => t.includes('Created At'))).toBe(false);
|
||||
expect(headerTexts.some((t) => t.includes('Created By'))).toBe(false);
|
||||
expect(headerTexts.some((t) => t.includes('Updated At'))).toBe(false);
|
||||
expect(headerTexts.some((t) => t.includes('Updated By'))).toBe(false);
|
||||
});
|
||||
|
||||
it('toggling Created At on writes to localStorage and adds the header', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderListAlertRules();
|
||||
|
||||
await screen.findByText('High CPU Alert');
|
||||
|
||||
const headersBefore = Array.from(document.querySelectorAll('th')).map(
|
||||
(h) => h.textContent ?? '',
|
||||
);
|
||||
expect(headersBefore.some((t) => t.includes('Created At'))).toBe(false);
|
||||
|
||||
await user.click(screen.getByTestId('alert-columns-button'));
|
||||
await screen.findByText('Toggle Columns');
|
||||
|
||||
const checkbox = document.getElementById('col-createdAt');
|
||||
expect(checkbox).not.toBeNull();
|
||||
await user.click(checkbox as HTMLElement);
|
||||
|
||||
await waitFor(() => {
|
||||
const stored = window.localStorage.getItem(COLUMN_STORAGE_KEY);
|
||||
expect(stored).not.toBeNull();
|
||||
const parsed = JSON.parse(stored as string);
|
||||
expect(parsed.hiddenColumnIds).not.toContain('createdAt');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const headersAfter = Array.from(document.querySelectorAll('th')).map(
|
||||
(h) => h.textContent ?? '',
|
||||
);
|
||||
expect(headersAfter.some((t) => t.includes('Created At'))).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { safeNavigateMock } from '__tests__/safeNavigateMock';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { alertRulesFixture } from 'mocks-server/__mockdata__/alert_rules';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { screen } from 'tests/test-utils';
|
||||
|
||||
import { renderListAlertRules } from './_helpers';
|
||||
|
||||
describe('ListAlertRules — empty states', () => {
|
||||
beforeEach(() => {
|
||||
jest.setSystemTime(new Date('2023-10-20T12:00:00Z'));
|
||||
});
|
||||
|
||||
it('renders AlertsEmptyState when API returns no rules', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v2/rules', (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data: [], status: 'success' })),
|
||||
),
|
||||
);
|
||||
|
||||
renderListAlertRules();
|
||||
|
||||
await screen.findByText('No Alert rules yet.');
|
||||
expect(
|
||||
screen.getByText('Create an Alert Rule to get started'),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// New Alert Rule button is visible and triggers safeNavigate to ALERTS_NEW.
|
||||
await user.click(screen.getByTestId('add-alert'));
|
||||
expect(safeNavigateMock).toHaveBeenCalledWith(
|
||||
ROUTES.ALERTS_NEW,
|
||||
expect.objectContaining({ newTab: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it('renders ErrorEmptyState when API returns 500; refresh triggers a refetch', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
|
||||
let callCount = 0;
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v2/rules', (_, res, ctx) => {
|
||||
callCount += 1;
|
||||
if (callCount === 1) {
|
||||
return res(ctx.status(500), ctx.json({ status: 'error' }));
|
||||
}
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({ data: alertRulesFixture, status: 'success' }),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
renderListAlertRules();
|
||||
|
||||
await screen.findByTestId('error-empty-state');
|
||||
|
||||
await user.click(screen.getByTestId('error-refresh-button'));
|
||||
|
||||
const rule = await screen.findByText('High CPU Alert');
|
||||
expect(rule).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders NoResultsEmptyState when search yields no match; Clear Search resets', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
|
||||
renderListAlertRules();
|
||||
|
||||
await screen.findByText('High CPU Alert');
|
||||
|
||||
const searchInput = screen.getByTestId('list-alerts-search-input');
|
||||
await user.clear(searchInput);
|
||||
await user.type(searchInput, 'totally-not-found');
|
||||
|
||||
await screen.findByTestId('no-results-empty-state');
|
||||
expect(screen.getByTestId('no-results-title')).toHaveTextContent(
|
||||
'No matching alert rules',
|
||||
);
|
||||
expect(screen.getByTestId('no-results-subtitle')).toHaveTextContent(
|
||||
'No alert rules match your search. Try adjusting your search criteria.',
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('no-results-clear-button'));
|
||||
|
||||
const rule = await screen.findByText('High CPU Alert');
|
||||
expect(rule).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import { screen, waitFor } from 'tests/test-utils';
|
||||
|
||||
import { renderListAlertRules } from './_helpers';
|
||||
|
||||
describe('ListAlertRules — list rendering', () => {
|
||||
beforeEach(() => {
|
||||
jest.setSystemTime(new Date('2023-10-20T12:00:00Z'));
|
||||
});
|
||||
|
||||
it('renders alert rules from API', async () => {
|
||||
renderListAlertRules();
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('alert-row-rule-1-name'),
|
||||
).resolves.toHaveTextContent('High CPU Alert');
|
||||
expect(screen.getByTestId('alert-row-rule-2-name')).toHaveTextContent(
|
||||
'Memory Pending Alert',
|
||||
);
|
||||
expect(screen.getByTestId('alert-row-rule-3-name')).toHaveTextContent(
|
||||
'Healthy Alert',
|
||||
);
|
||||
expect(screen.getByTestId('alert-row-rule-4-name')).toHaveTextContent(
|
||||
'Disabled Alert',
|
||||
);
|
||||
});
|
||||
|
||||
it('renders state badges via STATE_CONFIG mapping', async () => {
|
||||
renderListAlertRules();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('alert-row-rule-1-state')).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('alert-row-rule-1-state')).toHaveTextContent(
|
||||
'Firing',
|
||||
);
|
||||
expect(screen.getByTestId('alert-row-rule-2-state')).toHaveTextContent(
|
||||
'Pending',
|
||||
);
|
||||
expect(screen.getByTestId('alert-row-rule-3-state')).toHaveTextContent('OK');
|
||||
expect(screen.getByTestId('alert-row-rule-4-state')).toHaveTextContent(
|
||||
'Disabled',
|
||||
);
|
||||
expect(screen.getByTestId('alert-row-rule-5-state')).toHaveTextContent('OK');
|
||||
});
|
||||
|
||||
it('renders state badges with semantic colors', async () => {
|
||||
renderListAlertRules();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('alert-row-rule-1-state')).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('alert-row-rule-1-state')).toHaveAttribute(
|
||||
'data-color',
|
||||
'cherry',
|
||||
);
|
||||
expect(screen.getByTestId('alert-row-rule-2-state')).toHaveAttribute(
|
||||
'data-color',
|
||||
'amber',
|
||||
);
|
||||
expect(screen.getByTestId('alert-row-rule-3-state')).toHaveAttribute(
|
||||
'data-color',
|
||||
'forest',
|
||||
);
|
||||
expect(screen.getByTestId('alert-row-rule-4-state')).toHaveAttribute(
|
||||
'data-color',
|
||||
'vanilla',
|
||||
);
|
||||
});
|
||||
|
||||
it('renders severity badges for rules with severity', async () => {
|
||||
renderListAlertRules();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('alert-row-rule-1-severity')).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('alert-row-rule-1-severity')).toHaveTextContent(
|
||||
'critical',
|
||||
);
|
||||
expect(screen.getByTestId('alert-row-rule-2-severity')).toHaveTextContent(
|
||||
'warning',
|
||||
);
|
||||
expect(screen.getByTestId('alert-row-rule-3-severity')).toHaveTextContent(
|
||||
'info',
|
||||
);
|
||||
expect(screen.getByTestId('alert-row-rule-4-severity')).toHaveTextContent(
|
||||
'critical',
|
||||
);
|
||||
expect(screen.getByTestId('alert-row-rule-5-severity')).toHaveTextContent(
|
||||
'-',
|
||||
);
|
||||
expect(screen.getByTestId('alert-row-rule-1-severity')).toHaveAttribute(
|
||||
'data-color',
|
||||
'cherry',
|
||||
);
|
||||
expect(screen.getByTestId('alert-row-rule-2-severity')).toHaveAttribute(
|
||||
'data-color',
|
||||
'amber',
|
||||
);
|
||||
});
|
||||
|
||||
it('renders header controls (search, columns, new alert)', async () => {
|
||||
renderListAlertRules();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('alert-row-rule-1-name')).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('list-alerts-search-input')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByPlaceholderText('Search by Alert Name, Severity and Labels'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId('alert-columns-button')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('list-alerts-new-alert-button'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /new alert/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { logEventMock } from '__tests__/logEventMock';
|
||||
import { safeNavigateMock } from '__tests__/safeNavigateMock';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { screen, waitFor } from 'tests/test-utils';
|
||||
|
||||
import { renderListAlertRules } from './_helpers';
|
||||
|
||||
describe('ListAlertRules — new alert button', () => {
|
||||
beforeEach(() => {
|
||||
jest.setSystemTime(new Date('2023-10-20T12:00:00Z'));
|
||||
});
|
||||
|
||||
it('plain click navigates to ALERTS_NEW with newTab:false', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderListAlertRules();
|
||||
|
||||
await screen.findByText('High CPU Alert');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /new alert/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(safeNavigateMock).toHaveBeenCalled();
|
||||
});
|
||||
expect(safeNavigateMock).toHaveBeenCalledWith(
|
||||
ROUTES.ALERTS_NEW,
|
||||
expect.objectContaining({ newTab: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it('logs Alert: New alert button clicked', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderListAlertRules();
|
||||
|
||||
await screen.findByText('High CPU Alert');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /new alert/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
'Alert: New alert button clicked',
|
||||
expect.objectContaining({ layout: 'new' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('ctrl+click on New Alert opens in a new tab (newTab:true)', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderListAlertRules();
|
||||
|
||||
await screen.findByText('High CPU Alert');
|
||||
|
||||
await user.keyboard('{Control>}');
|
||||
await user.click(screen.getByRole('button', { name: /new alert/i }));
|
||||
await user.keyboard('{/Control}');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(safeNavigateMock).toHaveBeenCalled();
|
||||
});
|
||||
expect(safeNavigateMock).toHaveBeenCalledWith(
|
||||
ROUTES.ALERTS_NEW,
|
||||
expect.objectContaining({ newTab: true }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { alertRulesPaginationFixture } from 'mocks-server/__mockdata__/alert_rules';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { screen, waitFor } from 'tests/test-utils';
|
||||
import { getCurrentNuqsQueryString } from 'tests/nuqs-helpers';
|
||||
|
||||
import { renderListAlertRules } from './_helpers';
|
||||
|
||||
describe('ListAlertRules — pagination', () => {
|
||||
beforeEach(() => {
|
||||
jest.setSystemTime(new Date('2023-10-20T12:00:00Z'));
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v2/rules', (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({ data: alertRulesPaginationFixture, status: 'success' }),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('shows first 10 rows on page 1 (default limit)', async () => {
|
||||
renderListAlertRules();
|
||||
|
||||
await screen.findByText('Pag Rule 0');
|
||||
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
expect(screen.getByText(`Pag Rule ${i}`)).toBeInTheDocument();
|
||||
}
|
||||
expect(screen.queryByText('Pag Rule 10')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Pag Rule 14')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows total count when showTotalCount is enabled', async () => {
|
||||
renderListAlertRules();
|
||||
|
||||
await screen.findByText('Pag Rule 0');
|
||||
|
||||
const totalCount = await screen.findByTestId('pagination-total-count');
|
||||
expect(totalCount.textContent).toContain('Showing');
|
||||
expect(totalCount.textContent).toContain('of 15');
|
||||
});
|
||||
|
||||
it('navigates to page 2 and shows remaining rows', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderListAlertRules();
|
||||
|
||||
await screen.findByText('Pag Rule 0');
|
||||
|
||||
const nextBtn = screen.getByLabelText('Go to next page');
|
||||
await user.click(nextBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Pag Rule 10')).toBeInTheDocument();
|
||||
expect(screen.getByText('Pag Rule 14')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Pag Rule 0')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getCurrentNuqsQueryString()).toContain('page=2');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { screen, waitFor } from 'tests/test-utils';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
|
||||
import { renderListAlertRules } from './_helpers';
|
||||
|
||||
describe('ListAlertRules — permissions', () => {
|
||||
beforeEach(() => {
|
||||
jest.setSystemTime(new Date('2023-10-20T12:00:00Z'));
|
||||
});
|
||||
|
||||
it('VIEWER role hides "New Alert" button and "Actions" column', async () => {
|
||||
renderListAlertRules({ role: USER_ROLES.VIEWER });
|
||||
|
||||
await screen.findByText('High CPU Alert');
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('list-alerts-new-alert-button'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /new alert/i }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
const headers = Array.from(document.querySelectorAll('th')).map(
|
||||
(h) => h.textContent ?? '',
|
||||
);
|
||||
expect(headers.some((t) => t.includes('Actions'))).toBe(false);
|
||||
expect(screen.queryByTestId('alert-actions')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('ADMIN role shows "New Alert" button and "Actions" column', async () => {
|
||||
renderListAlertRules({ role: USER_ROLES.ADMIN });
|
||||
|
||||
await screen.findByText('High CPU Alert');
|
||||
|
||||
expect(
|
||||
screen.getByTestId('list-alerts-new-alert-button'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /new alert/i }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await waitFor(() => {
|
||||
const headers = Array.from(document.querySelectorAll('th')).map(
|
||||
(h) => h.textContent ?? '',
|
||||
);
|
||||
expect(headers.some((t) => t.includes('Actions'))).toBe(true);
|
||||
});
|
||||
expect(screen.getAllByTestId('alert-actions').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('EDITOR role behaves like ADMIN (New Alert + Actions visible)', async () => {
|
||||
renderListAlertRules({ role: USER_ROLES.EDITOR });
|
||||
|
||||
await screen.findByText('High CPU Alert');
|
||||
|
||||
expect(
|
||||
screen.getByTestId('list-alerts-new-alert-button'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /new alert/i }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await waitFor(() => {
|
||||
const headers = Array.from(document.querySelectorAll('th')).map(
|
||||
(h) => h.textContent ?? '',
|
||||
);
|
||||
expect(headers.some((t) => t.includes('Actions'))).toBe(true);
|
||||
});
|
||||
expect(screen.getAllByTestId('alert-actions').length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { safeNavigateMock } from '__tests__/safeNavigateMock';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { screen, waitFor } from 'tests/test-utils';
|
||||
|
||||
import { renderListAlertRules } from './_helpers';
|
||||
|
||||
describe('ListAlertRules — row click navigation', () => {
|
||||
beforeEach(() => {
|
||||
jest.setSystemTime(new Date('2023-10-20T12:00:00Z'));
|
||||
});
|
||||
|
||||
it('clicking a row calls safeNavigate to alerts/overview with composite query + ruleId', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderListAlertRules();
|
||||
|
||||
const ruleCell = await screen.findByText('High CPU Alert');
|
||||
|
||||
const td = ruleCell.closest('td');
|
||||
expect(td).not.toBeNull();
|
||||
await user.click(td as HTMLElement);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(safeNavigateMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const [url] = safeNavigateMock.mock.calls[0];
|
||||
expect(url).toContain('/alerts/overview?');
|
||||
expect(url).toContain('ruleId=rule-1');
|
||||
expect(url).toContain('panelTypes=graph');
|
||||
expect(url).toContain('compositeQuery=');
|
||||
});
|
||||
|
||||
it('ctrl+click on a row navigates with newTab option', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderListAlertRules();
|
||||
|
||||
const ruleCell = await screen.findByText('High CPU Alert');
|
||||
|
||||
const td = ruleCell.closest('td');
|
||||
await user.keyboard('{Control>}');
|
||||
await user.click(td as HTMLElement);
|
||||
await user.keyboard('{/Control}');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(safeNavigateMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const [url, options] = safeNavigateMock.mock.calls[0];
|
||||
expect(url).toContain('ruleId=rule-1');
|
||||
expect(options).toStrictEqual(expect.objectContaining({ newTab: true }));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { screen, waitFor } from 'tests/test-utils';
|
||||
import { getCurrentNuqsQueryString } from 'tests/nuqs-helpers';
|
||||
|
||||
import { renderListAlertRules } from './_helpers';
|
||||
|
||||
function getSearchInput(): HTMLInputElement {
|
||||
return screen.getByTestId('list-alerts-search-input') as HTMLInputElement;
|
||||
}
|
||||
|
||||
describe('ListAlertRules — search', () => {
|
||||
beforeEach(() => {
|
||||
jest.setSystemTime(new Date('2023-10-20T12:00:00Z'));
|
||||
});
|
||||
|
||||
it('filters rows by alert name with debounce', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderListAlertRules();
|
||||
|
||||
await screen.findByText('High CPU Alert');
|
||||
|
||||
await user.clear(getSearchInput());
|
||||
await user.type(getSearchInput(), 'CPU');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('High CPU Alert')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Memory Pending Alert')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('filters rows by label values (severity)', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderListAlertRules();
|
||||
|
||||
await screen.findByText('High CPU Alert');
|
||||
|
||||
await user.clear(getSearchInput());
|
||||
await user.type(getSearchInput(), 'warning');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Memory Pending Alert')).toBeInTheDocument();
|
||||
expect(screen.queryByText('High CPU Alert')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('restores all rows when search is cleared', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderListAlertRules();
|
||||
|
||||
await screen.findByText('High CPU Alert');
|
||||
|
||||
await user.clear(getSearchInput());
|
||||
await user.type(getSearchInput(), 'CPU');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Memory Pending Alert')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.clear(getSearchInput());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('High CPU Alert')).toBeInTheDocument();
|
||||
expect(screen.getByText('Memory Pending Alert')).toBeInTheDocument();
|
||||
expect(screen.getByText('Healthy Alert')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows no-results state when no match', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderListAlertRules();
|
||||
|
||||
await screen.findByText('High CPU Alert');
|
||||
|
||||
await user.clear(getSearchInput());
|
||||
await user.type(getSearchInput(), 'zzzzzz-no-match');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('no-results-empty-state')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('no-results-title')).toHaveTextContent(
|
||||
'No matching alert rules',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('resets page to 1 when search debounce fires', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderListAlertRules({ initialRoute: '/?page=2' });
|
||||
|
||||
// Page 2 of the 4-rule fixture has no rows; we only need the search input
|
||||
// to be mounted, which happens before data is fetched.
|
||||
const input = await screen.findByTestId('list-alerts-search-input');
|
||||
await user.clear(input);
|
||||
await user.type(input, 'CPU');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getCurrentNuqsQueryString()).not.toContain('page=2');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,232 @@
|
||||
import { logEventMock } from '__tests__/logEventMock';
|
||||
import { RuletypesAlertStateDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { SortState } from 'components/TanStackTableView/types';
|
||||
|
||||
import type { AlertRule } from '../types';
|
||||
import {
|
||||
ALERT_ACTIONS,
|
||||
alertActionLogEvent,
|
||||
filterRulesByFilters,
|
||||
getAlertSortValue,
|
||||
sortRules,
|
||||
} from '../utils';
|
||||
|
||||
const baseRule = {
|
||||
id: 'r1',
|
||||
alert: 'Rule 1',
|
||||
alertType: 'METRIC_BASED_ALERT',
|
||||
state: 'inactive',
|
||||
labels: { severity: 'info' },
|
||||
condition: {},
|
||||
createdAt: '2023-10-15T10:00:00Z',
|
||||
updatedAt: '2023-10-19T10:00:00Z',
|
||||
} as unknown as AlertRule;
|
||||
|
||||
const makeRule = (overrides: Partial<AlertRule>): AlertRule => ({
|
||||
...baseRule,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('getAlertSortValue', () => {
|
||||
it('returns state for "state"', () => {
|
||||
expect(
|
||||
getAlertSortValue(
|
||||
makeRule({ state: RuletypesAlertStateDTO.firing }),
|
||||
'state',
|
||||
),
|
||||
).toBe('firing');
|
||||
});
|
||||
|
||||
it('returns alert name for "name"', () => {
|
||||
expect(getAlertSortValue(makeRule({ alert: 'My Rule' }), 'name')).toBe(
|
||||
'My Rule',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns severity label for "severity"', () => {
|
||||
expect(
|
||||
getAlertSortValue(
|
||||
makeRule({ labels: { severity: 'critical' } }),
|
||||
'severity',
|
||||
),
|
||||
).toBe('critical');
|
||||
});
|
||||
|
||||
it('returns createdAt as ms', () => {
|
||||
const rule = makeRule({ createdAt: '2023-10-15T10:00:00Z' });
|
||||
const result = getAlertSortValue(rule, 'createdAt');
|
||||
expect(result).toBe(new Date('2023-10-15T10:00:00Z').getTime());
|
||||
});
|
||||
|
||||
it('returns updatedAt as ms', () => {
|
||||
const rule = makeRule({ updatedAt: '2023-10-19T10:00:00Z' });
|
||||
const result = getAlertSortValue(rule, 'updatedAt');
|
||||
expect(result).toBe(new Date('2023-10-19T10:00:00Z').getTime());
|
||||
});
|
||||
|
||||
it('returns 0 when createdAt missing', () => {
|
||||
expect(
|
||||
getAlertSortValue(makeRule({ createdAt: undefined }), 'createdAt'),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it('returns empty for unknown column', () => {
|
||||
expect(getAlertSortValue(baseRule, 'xxx')).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty for missing fields', () => {
|
||||
expect(
|
||||
getAlertSortValue(
|
||||
makeRule({ state: undefined, labels: undefined }),
|
||||
'state',
|
||||
),
|
||||
).toBe('');
|
||||
expect(
|
||||
getAlertSortValue(
|
||||
makeRule({ state: undefined, labels: undefined }),
|
||||
'severity',
|
||||
),
|
||||
).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sortRules', () => {
|
||||
const r1 = makeRule({ id: '1', alert: 'A' });
|
||||
const r2 = makeRule({ id: '2', alert: 'B' });
|
||||
const r3 = makeRule({ id: '3', alert: 'C' });
|
||||
|
||||
it('sorts ascending by name', () => {
|
||||
const order: SortState = { columnName: 'name', order: 'asc' };
|
||||
const result = sortRules([r3, r1, r2], order);
|
||||
expect(result.map((r) => r.alert)).toStrictEqual(['A', 'B', 'C']);
|
||||
});
|
||||
|
||||
it('sorts descending by name', () => {
|
||||
const order: SortState = { columnName: 'name', order: 'desc' };
|
||||
const result = sortRules([r1, r2, r3], order);
|
||||
expect(result.map((r) => r.alert)).toStrictEqual(['C', 'B', 'A']);
|
||||
});
|
||||
|
||||
it('returns unsorted when orderBy is null', () => {
|
||||
const result = sortRules([r3, r1, r2], null);
|
||||
expect(result.map((r) => r.alert)).toStrictEqual(['C', 'A', 'B']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterRulesByFilters', () => {
|
||||
const r1 = makeRule({
|
||||
id: '1',
|
||||
alert: 'R1',
|
||||
state: RuletypesAlertStateDTO.firing,
|
||||
labels: { severity: 'critical' },
|
||||
});
|
||||
const r2 = makeRule({
|
||||
id: '2',
|
||||
alert: 'R2',
|
||||
state: RuletypesAlertStateDTO.inactive,
|
||||
labels: { severity: 'warning' },
|
||||
});
|
||||
const r3 = makeRule({
|
||||
id: '3',
|
||||
alert: 'R3',
|
||||
state: RuletypesAlertStateDTO.firing,
|
||||
labels: { severity: 'warning' },
|
||||
});
|
||||
const rules = [r1, r2, r3];
|
||||
|
||||
it('returns input when filters empty', () => {
|
||||
expect(filterRulesByFilters(rules, [])).toStrictEqual(rules);
|
||||
});
|
||||
|
||||
it('filters by state', () => {
|
||||
const result = filterRulesByFilters(rules, ['state:firing']);
|
||||
expect(result.map((r) => r.id)).toStrictEqual(['1', '3']);
|
||||
});
|
||||
|
||||
it('filters by severity', () => {
|
||||
const result = filterRulesByFilters(rules, ['severity:warning']);
|
||||
expect(result.map((r) => r.id)).toStrictEqual(['2', '3']);
|
||||
});
|
||||
|
||||
it('combines state AND severity', () => {
|
||||
const result = filterRulesByFilters(rules, [
|
||||
'state:firing',
|
||||
'severity:warning',
|
||||
]);
|
||||
expect(result.map((r) => r.id)).toStrictEqual(['3']);
|
||||
});
|
||||
|
||||
it('OR within same key (state)', () => {
|
||||
const result = filterRulesByFilters(rules, [
|
||||
'state:firing',
|
||||
'state:inactive',
|
||||
]);
|
||||
expect(result.map((r) => r.id)).toStrictEqual(['1', '2', '3']);
|
||||
});
|
||||
|
||||
it('matches values case-insensitively', () => {
|
||||
const result = filterRulesByFilters(rules, ['state:FIRING']);
|
||||
expect(result.map((r) => r.id)).toStrictEqual(['1', '3']);
|
||||
});
|
||||
|
||||
it('ignores prefixes with wrong case (state: is required lowercase)', () => {
|
||||
const result = filterRulesByFilters(rules, ['STATE:FIRING']);
|
||||
expect(result).toStrictEqual(rules);
|
||||
});
|
||||
|
||||
it('returns empty when no rule matches', () => {
|
||||
expect(filterRulesByFilters(rules, ['state:nonexistent'])).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('ignores unknown prefix', () => {
|
||||
expect(filterRulesByFilters(rules, ['foo:bar'])).toStrictEqual(rules);
|
||||
});
|
||||
});
|
||||
|
||||
describe('alertActionLogEvent', () => {
|
||||
it('logs with mapped action label', () => {
|
||||
const rule = makeRule({
|
||||
id: 'rule-1',
|
||||
alert: 'My Rule',
|
||||
alertType: 'METRIC_BASED_ALERT' as AlertRule['alertType'],
|
||||
});
|
||||
alertActionLogEvent(ALERT_ACTIONS.EDIT, rule);
|
||||
expect(logEventMock).toHaveBeenCalledWith('Alert: Action', {
|
||||
ruleId: 'rule-1',
|
||||
dataSource: expect.any(String),
|
||||
name: 'My Rule',
|
||||
action: 'Edit',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to raw action when unmapped', () => {
|
||||
alertActionLogEvent('custom', baseRule);
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
'Alert: Action',
|
||||
expect.objectContaining({ action: 'custom' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('maps TOGGLE action', () => {
|
||||
alertActionLogEvent(ALERT_ACTIONS.TOGGLE, baseRule);
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
'Alert: Action',
|
||||
expect.objectContaining({ action: 'Enable/Disable' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('maps DELETE and CLONE', () => {
|
||||
alertActionLogEvent(ALERT_ACTIONS.DELETE, baseRule);
|
||||
alertActionLogEvent(ALERT_ACTIONS.CLONE, baseRule);
|
||||
expect(logEventMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'Alert: Action',
|
||||
expect.objectContaining({ action: 'Delete' }),
|
||||
);
|
||||
expect(logEventMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'Alert: Action',
|
||||
expect.objectContaining({ action: 'Clone' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
65
frontend/src/container/ListAlertRules/__tests__/_helpers.tsx
Normal file
65
frontend/src/container/ListAlertRules/__tests__/_helpers.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { VirtuosoMockContext } from 'react-virtuoso';
|
||||
import { render, RenderResult, screen } from '@testing-library/react';
|
||||
import ListAlertRules from 'container/ListAlertRules';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import { AppContext } from 'providers/App/App';
|
||||
import TimezoneProvider from 'providers/Timezone';
|
||||
import { onNuqsUrlUpdate, resetNuqsState } from 'tests/nuqs-helpers';
|
||||
import { getAppContextMock } from 'tests/test-utils';
|
||||
|
||||
interface RenderOptions {
|
||||
role?: string;
|
||||
initialRoute?: string;
|
||||
}
|
||||
|
||||
export function renderListAlertRules(
|
||||
options: RenderOptions = {},
|
||||
): RenderResult {
|
||||
const { role = 'ADMIN', initialRoute = '/' } = options;
|
||||
|
||||
const initialSearch = initialRoute.includes('?')
|
||||
? initialRoute.slice(initialRoute.indexOf('?'))
|
||||
: '';
|
||||
resetNuqsState(initialSearch);
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { refetchOnWindowFocus: false, retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[initialRoute]}>
|
||||
<NuqsTestingAdapter
|
||||
searchParams={initialSearch}
|
||||
onUrlUpdate={onNuqsUrlUpdate}
|
||||
rateLimitFactor={0}
|
||||
hasMemory
|
||||
>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AppContext.Provider value={getAppContextMock(role)}>
|
||||
<TimezoneProvider>
|
||||
<VirtuosoMockContext.Provider
|
||||
value={{ viewportHeight: 800, itemHeight: 46 }}
|
||||
>
|
||||
<ListAlertRules />
|
||||
</VirtuosoMockContext.Provider>
|
||||
</TimezoneProvider>
|
||||
</AppContext.Provider>
|
||||
</QueryClientProvider>
|
||||
</NuqsTestingAdapter>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
export async function findAlertRow(alertName: string): Promise<HTMLElement> {
|
||||
const cell = await screen.findByText(alertName, {}, { timeout: 5000 });
|
||||
const row = cell.closest('tr');
|
||||
if (!row) {
|
||||
throw new Error(`Row not found for alert "${alertName}"`);
|
||||
}
|
||||
return row as HTMLElement;
|
||||
}
|
||||
@@ -47,6 +47,7 @@ function ColumnSelector<TData>({
|
||||
size="sm"
|
||||
color="secondary"
|
||||
prefix={<Columns3 size={14} />}
|
||||
data-testid="alert-columns-button"
|
||||
>
|
||||
Columns
|
||||
</Button>
|
||||
|
||||
@@ -136,6 +136,7 @@ function ListAlertRules(): JSX.Element {
|
||||
prefix={<Plus size={14} />}
|
||||
onClick={handleNewAlert}
|
||||
color="primary"
|
||||
testId="list-alerts-new-alert-button"
|
||||
>
|
||||
New Alert
|
||||
</Button>
|
||||
@@ -157,6 +158,7 @@ function ListAlertRules(): JSX.Element {
|
||||
value={searchText}
|
||||
onChange={handleSearchChange}
|
||||
suffix={<Search size={14} className={styles.searchIcon} />}
|
||||
testId="list-alerts-search-input"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -26,14 +26,18 @@ export function getAlertRuleColumns(
|
||||
enableSort: true,
|
||||
enableRemove: false,
|
||||
enableMove: false,
|
||||
cell: ({ value }): JSX.Element => {
|
||||
cell: ({ row, 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">
|
||||
<Badge
|
||||
color={config.color}
|
||||
variant="outline"
|
||||
testId={`alert-row-${row.id ?? ''}-state`}
|
||||
>
|
||||
{config.label}
|
||||
</Badge>
|
||||
);
|
||||
@@ -47,8 +51,11 @@ export function getAlertRuleColumns(
|
||||
enableSort: true,
|
||||
enableRemove: false,
|
||||
enableMove: false,
|
||||
cell: ({ value }): JSX.Element => (
|
||||
<TanStackTable.Text title={value}>
|
||||
cell: ({ row, value }): JSX.Element => (
|
||||
<TanStackTable.Text
|
||||
title={value}
|
||||
data-testid={`alert-row-${row.id ?? ''}-name`}
|
||||
>
|
||||
{String(value ?? '-')}
|
||||
</TanStackTable.Text>
|
||||
),
|
||||
@@ -60,15 +67,20 @@ export function getAlertRuleColumns(
|
||||
width: { fixed: '120px' },
|
||||
enableSort: true,
|
||||
enableMove: false,
|
||||
cell: ({ value }): JSX.Element => {
|
||||
cell: ({ row, value }): JSX.Element => {
|
||||
const severity = String(value ?? '').toLowerCase();
|
||||
if (!severity) {
|
||||
return <TanStackTable.Text>-</TanStackTable.Text>;
|
||||
return (
|
||||
<TanStackTable.Text data-testid={`alert-row-${row.id ?? ''}-severity`}>
|
||||
-
|
||||
</TanStackTable.Text>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Badge
|
||||
color={SEVERITY_BADGE_COLORS[severity] ?? 'secondary'}
|
||||
variant="outline"
|
||||
testId={`alert-row-${row.id ?? ''}-severity`}
|
||||
>
|
||||
{severity}
|
||||
</Badge>
|
||||
|
||||
@@ -232,7 +232,7 @@ function DashboardsList(): JSX.Element {
|
||||
isLocked: !!e.locked || false,
|
||||
lastUpdatedBy: e.updatedBy,
|
||||
image: e.data.image || Base64Icons[0],
|
||||
variables: e.data.variables,
|
||||
variables: e.data.variables ?? {},
|
||||
widgets: e.data.widgets,
|
||||
layout: e.data.layout,
|
||||
panelMap: e.data.panelMap,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { logEventMock } from '__tests__/logEventMock';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import MCPServerSettings from './MCPServerSettings';
|
||||
|
||||
const mockLogEvent = jest.fn();
|
||||
const mockCopyToClipboard = jest.fn();
|
||||
const mockHistoryPush = jest.fn();
|
||||
const mockUseGetGlobalConfig = jest.fn();
|
||||
@@ -11,11 +11,6 @@ const mockUseGetTenantLicense = jest.fn();
|
||||
const mockToastSuccess = jest.fn();
|
||||
const mockToastWarning = jest.fn();
|
||||
|
||||
jest.mock('api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: (...args: unknown[]): unknown => mockLogEvent(...args),
|
||||
}));
|
||||
|
||||
jest.mock('api/generated/services/global', () => ({
|
||||
useGetGlobalConfig: (...args: unknown[]): unknown =>
|
||||
mockUseGetGlobalConfig(...args),
|
||||
@@ -148,7 +143,7 @@ describe('MCPServerSettings', () => {
|
||||
|
||||
render(<MCPServerSettings />, undefined, { role: 'ADMIN' });
|
||||
|
||||
expect(mockLogEvent).toHaveBeenCalledWith('MCP Settings: Page viewed', {
|
||||
expect(logEventMock).toHaveBeenCalledWith('MCP Settings: Page viewed', {
|
||||
role: 'ADMIN',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import MySettingsContainer from 'container/MySettings';
|
||||
import { logEventMock } from '__tests__/logEventMock';
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
@@ -12,7 +13,6 @@ import APIError from 'types/api/error';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
|
||||
const toggleThemeFunction = jest.fn();
|
||||
const logEventFunction = jest.fn();
|
||||
const copyToClipboardFn = jest.fn();
|
||||
const editUserFn = jest.fn();
|
||||
const updateMyPasswordFn = jest.fn();
|
||||
@@ -62,11 +62,6 @@ jest.mock('hooks/useDarkMode', () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn((eventName, data) => logEventFunction(eventName, data)),
|
||||
}));
|
||||
|
||||
const errorNotification = jest.fn();
|
||||
const successNotification = jest.fn();
|
||||
jest.mock('hooks/useNotifications', () => ({
|
||||
@@ -135,7 +130,7 @@ describe('MySettings Flows', () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toggleThemeFunction).toHaveBeenCalled();
|
||||
expect(logEventFunction).toHaveBeenCalledWith(
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
'Account Settings: Theme Changed',
|
||||
{
|
||||
theme: 'light',
|
||||
|
||||
@@ -9,11 +9,6 @@ import {
|
||||
|
||||
import InviteTeamMembers from '../InviteTeamMembers';
|
||||
|
||||
jest.mock('api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockNotificationSuccess = jest.fn() as jest.MockedFunction<
|
||||
(args: { message: string }) => void
|
||||
>;
|
||||
|
||||
@@ -4,11 +4,6 @@ import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import OnboardingQuestionaire from '../index';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('lib/history', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
|
||||
@@ -4,15 +4,13 @@ import { Provider } from 'react-redux';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { render } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { logEventMock } from '__tests__/logEventMock';
|
||||
import i18n from 'ReactI18';
|
||||
import store from 'store';
|
||||
|
||||
import CreatePipelineButton from '../Layouts/Pipeline/CreatePipelineButton';
|
||||
import { pipelineApiResponseMockData } from '../mocks/pipeline';
|
||||
|
||||
jest.mock('api/common/logEvent');
|
||||
|
||||
describe('PipelinePage container test', () => {
|
||||
it('should render CreatePipelineButton section', async () => {
|
||||
const { asFragment } = render(
|
||||
@@ -53,9 +51,12 @@ describe('PipelinePage container test', () => {
|
||||
expect(editButton).toBeInTheDocument();
|
||||
await userEvent.click(editButton);
|
||||
|
||||
expect(logEvent).toHaveBeenCalledWith('Logs: Pipelines: Entered Edit Mode', {
|
||||
source: 'signoz-ui',
|
||||
});
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
'Logs: Pipelines: Entered Edit Mode',
|
||||
{
|
||||
source: 'signoz-ui',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('CreatePipelineButton - add new mode & tracking', async () => {
|
||||
@@ -78,7 +79,7 @@ describe('PipelinePage container test', () => {
|
||||
expect(editButton).toBeInTheDocument();
|
||||
await userEvent.click(editButton);
|
||||
|
||||
expect(logEvent).toHaveBeenCalledWith(
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
'Logs: Pipelines: Clicked Add New Pipeline',
|
||||
{
|
||||
source: 'signoz-ui',
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { safeNavigateMock } from '__tests__/safeNavigateMock';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { triggeredAlertsFixture } from 'mocks-server/__mockdata__/triggered_alerts';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { screen, waitFor } from 'tests/test-utils';
|
||||
|
||||
import { renderTriggeredAlerts } from './_helpers';
|
||||
|
||||
describe('TriggeredAlerts — empty / error states', () => {
|
||||
it('shows the "No alerts firing" empty state when the API returns []', async () => {
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v1/alerts', (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data: [], status: 'success' })),
|
||||
),
|
||||
);
|
||||
|
||||
renderTriggeredAlerts();
|
||||
|
||||
await screen.findByText('No alerts firing');
|
||||
expect(
|
||||
screen.getByTestId('triggered-alerts-empty-create-button'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('triggered-alerts-empty-refresh-button'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('navigates to ROUTES.ALERTS_NEW when "Create Alert Rule" is clicked', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v1/alerts', (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data: [], status: 'success' })),
|
||||
),
|
||||
);
|
||||
|
||||
renderTriggeredAlerts();
|
||||
|
||||
await screen.findByText('No alerts firing');
|
||||
|
||||
await user.click(screen.getByTestId('triggered-alerts-empty-create-button'));
|
||||
expect(safeNavigateMock).toHaveBeenCalledWith(
|
||||
ROUTES.ALERTS_NEW,
|
||||
expect.objectContaining({ newTab: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it('shows ErrorEmptyState when the API returns 500', async () => {
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v1/alerts', (_, res, ctx) =>
|
||||
res(ctx.status(500)),
|
||||
),
|
||||
);
|
||||
|
||||
renderTriggeredAlerts();
|
||||
|
||||
await screen.findByTestId('error-empty-state');
|
||||
expect(screen.getByTestId('error-refresh-button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('refetches on refresh button click after an initial error', async () => {
|
||||
let callCount = 0;
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v1/alerts', (_, res, ctx) => {
|
||||
callCount += 1;
|
||||
if (callCount === 1) {
|
||||
return res(ctx.status(500));
|
||||
}
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({ data: triggeredAlertsFixture, status: 'success' }),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderTriggeredAlerts();
|
||||
|
||||
await screen.findByTestId('error-refresh-button');
|
||||
|
||||
await user.click(screen.getByTestId('error-refresh-button'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('High CPU Usage')).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it('shows NoResultsEmptyState when filters yield zero matches', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderTriggeredAlerts();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('High CPU Usage')).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
const input = screen.getByTestId('triggered-alerts-search-input');
|
||||
await user.type(input, 'this-matches-nothing-xyz');
|
||||
|
||||
await screen.findByTestId('no-results-empty-state');
|
||||
expect(screen.getByTestId('no-results-title')).toHaveTextContent(
|
||||
'No matching alerts',
|
||||
);
|
||||
expect(screen.getByTestId('no-results-subtitle')).toHaveTextContent(
|
||||
'No alerts match your current filters. Try adjusting your search criteria.',
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('no-results-clear-button'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('High CPU Usage')).toBeInTheDocument(),
|
||||
);
|
||||
expect(
|
||||
(screen.getByTestId('triggered-alerts-search-input') as HTMLInputElement)
|
||||
.value,
|
||||
).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { screen, waitFor } from 'tests/test-utils';
|
||||
|
||||
import { renderTriggeredAlerts } from './_helpers';
|
||||
|
||||
describe('TriggeredAlerts — severity filter', () => {
|
||||
it('filters to only critical-severity rows when "Critical" is selected', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderTriggeredAlerts();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('High CPU Usage')).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('triggered-alerts-filter-combobox'));
|
||||
|
||||
const criticalOption = await screen.findByText(
|
||||
'Critical (severity:critical)',
|
||||
);
|
||||
await user.click(criticalOption);
|
||||
|
||||
await user.keyboard('{Escape}');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('High CPU Usage')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Memory Warning')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Disk Slow')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Network Hiccup')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows union when multiple severities are selected', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderTriggeredAlerts();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('High CPU Usage')).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('triggered-alerts-filter-combobox'));
|
||||
const critical = await screen.findByText('Critical (severity:critical)');
|
||||
await user.click(critical);
|
||||
|
||||
const warning = await screen.findByText('Warning (severity:warning)');
|
||||
await user.click(warning);
|
||||
|
||||
await user.keyboard('{Escape}');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('High CPU Usage')).toBeInTheDocument();
|
||||
expect(screen.getByText('Memory Warning')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Disk Slow')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Network Hiccup')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('clearing the filter shows all rows again', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderTriggeredAlerts();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('High CPU Usage')).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('triggered-alerts-filter-combobox'));
|
||||
const critical = await screen.findByText('Critical (severity:critical)');
|
||||
await user.click(critical);
|
||||
await user.keyboard('{Escape}');
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText('Memory Warning')).not.toBeInTheDocument(),
|
||||
);
|
||||
|
||||
// Reopen the filter combobox and deselect Critical (clicking again toggles).
|
||||
await user.click(screen.getByTestId('triggered-alerts-filter-combobox'));
|
||||
const criticalAgain = await screen.findByText('Critical (severity:critical)');
|
||||
await user.click(criticalAgain);
|
||||
await user.keyboard('{Escape}');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('High CPU Usage')).toBeInTheDocument();
|
||||
expect(screen.getByText('Memory Warning')).toBeInTheDocument();
|
||||
expect(screen.getByText('Disk Slow')).toBeInTheDocument();
|
||||
expect(screen.getByText('Network Hiccup')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { screen, waitFor } from 'tests/test-utils';
|
||||
|
||||
import { renderTriggeredAlerts } from './_helpers';
|
||||
|
||||
describe('TriggeredAlerts — group by', () => {
|
||||
it('renders a flat table when no group-by is selected', async () => {
|
||||
renderTriggeredAlerts();
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('High CPU Usage')).toBeInTheDocument(),
|
||||
);
|
||||
// No "Group" column header in flat mode.
|
||||
expect(screen.queryByText('Group')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('groups by service when "service" is selected', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderTriggeredAlerts();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('High CPU Usage')).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('triggered-alerts-groupby-combobox'));
|
||||
|
||||
const serviceOption = await screen.findByText('service');
|
||||
await user.click(serviceOption);
|
||||
await user.keyboard('{Escape}');
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Group')).toBeInTheDocument());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('service:frontend')).toBeInTheDocument();
|
||||
expect(screen.getByText('service:backend')).toBeInTheDocument();
|
||||
expect(screen.getByText('service:misc')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('expands and collapses a group row to reveal nested alerts', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderTriggeredAlerts();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('High CPU Usage')).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('triggered-alerts-groupby-combobox'));
|
||||
const serviceOption = await screen.findByText('service');
|
||||
await user.click(serviceOption);
|
||||
await user.keyboard('{Escape}');
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('service:frontend')).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
// Nested rows aren't shown yet.
|
||||
expect(screen.queryByText('High CPU Usage')).not.toBeInTheDocument();
|
||||
|
||||
// The "frontend" group sits first in the table; its expand toggle is the
|
||||
// first `group-expand-toggle` in DOM order. Targeting by testid is safe
|
||||
// against design changes that add other buttons to the row.
|
||||
const expandToggles = screen.getAllByTestId('group-expand-toggle');
|
||||
expect(expandToggles.length).toBeGreaterThan(0);
|
||||
const frontendGroupBadge = screen.getByText('service:frontend');
|
||||
const frontendRow = frontendGroupBadge.closest('tr');
|
||||
expect(frontendRow).not.toBeNull();
|
||||
const frontendToggle = (frontendRow as HTMLElement).querySelector(
|
||||
'[data-testid="group-expand-toggle"]',
|
||||
) as HTMLElement | null;
|
||||
expect(frontendToggle).not.toBeNull();
|
||||
|
||||
await user.click(frontendToggle as HTMLElement);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('High CPU Usage')).toBeInTheDocument(),
|
||||
);
|
||||
expect(screen.getByText('Disk Slow')).toBeInTheDocument();
|
||||
|
||||
await user.click(frontendToggle as HTMLElement);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText('High CPU Usage')).not.toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import { screen, waitFor } from 'tests/test-utils';
|
||||
|
||||
import { renderTriggeredAlerts } from './_helpers';
|
||||
|
||||
describe('TriggeredAlerts — list rendering', () => {
|
||||
beforeEach(() => {
|
||||
jest.setSystemTime(new Date('2023-10-20T12:00:00Z'));
|
||||
});
|
||||
|
||||
it('renders alerts from the API', async () => {
|
||||
renderTriggeredAlerts();
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('alert-row-fp-critical-1-name'),
|
||||
).resolves.toHaveTextContent('High CPU Usage');
|
||||
expect(screen.getByTestId('alert-row-fp-warning-1-name')).toHaveTextContent(
|
||||
'Memory Warning',
|
||||
);
|
||||
expect(screen.getByTestId('alert-row-fp-info-1-name')).toHaveTextContent(
|
||||
'Disk Slow',
|
||||
);
|
||||
});
|
||||
|
||||
it('renders severity badges for alerts that have severity', async () => {
|
||||
renderTriggeredAlerts();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByTestId('alert-row-fp-critical-1-name'),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByTestId('alert-row-fp-critical-1-severity'),
|
||||
).toHaveTextContent('critical');
|
||||
expect(
|
||||
screen.getByTestId('alert-row-fp-warning-1-severity'),
|
||||
).toHaveTextContent('warning');
|
||||
expect(screen.getByTestId('alert-row-fp-info-1-severity')).toHaveTextContent(
|
||||
'info',
|
||||
);
|
||||
expect(
|
||||
screen.getByTestId('alert-row-fp-noseverity-severity'),
|
||||
).toHaveTextContent('-');
|
||||
});
|
||||
|
||||
it('renders status tags reflecting the alert state', async () => {
|
||||
renderTriggeredAlerts();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByTestId('alert-row-fp-critical-1-name'),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByTestId('alert-row-fp-critical-1-status'),
|
||||
).toHaveTextContent('Firing');
|
||||
expect(screen.getByTestId('alert-row-fp-info-1-status')).toHaveTextContent(
|
||||
'Unprocessed',
|
||||
);
|
||||
expect(
|
||||
screen.getByTestId('alert-row-fp-suppressed-1-status'),
|
||||
).toHaveTextContent('Suppressed');
|
||||
});
|
||||
|
||||
it('renders status badges with semantic colors', async () => {
|
||||
renderTriggeredAlerts();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByTestId('alert-row-fp-critical-1-status'),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('alert-row-fp-critical-1-status')).toHaveAttribute(
|
||||
'data-color',
|
||||
'cherry',
|
||||
);
|
||||
expect(screen.getByTestId('alert-row-fp-info-1-status')).toHaveAttribute(
|
||||
'data-color',
|
||||
'forest',
|
||||
);
|
||||
expect(
|
||||
screen.getByTestId('alert-row-fp-critical-1-severity'),
|
||||
).toHaveAttribute('data-color', 'cherry');
|
||||
expect(screen.getByTestId('alert-row-fp-warning-1-severity')).toHaveAttribute(
|
||||
'data-color',
|
||||
'amber',
|
||||
);
|
||||
});
|
||||
|
||||
it('renders the search input and filter comboboxes', async () => {
|
||||
renderTriggeredAlerts();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByTestId('alert-row-fp-critical-1-name'),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByTestId('triggered-alerts-search-input'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByPlaceholderText('Search alerts by name'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('triggered-alerts-filter-combobox'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('triggered-alerts-groupby-combobox'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { triggeredAlertsPaginationFixture } from 'mocks-server/__mockdata__/triggered_alerts';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { screen, waitFor } from 'tests/test-utils';
|
||||
import { getCurrentNuqsQueryString } from 'tests/nuqs-helpers';
|
||||
|
||||
import { renderTriggeredAlerts } from './_helpers';
|
||||
|
||||
function usePaginationHandler(): void {
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v1/alerts', (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
data: triggeredAlertsPaginationFixture,
|
||||
status: 'success',
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
describe('TriggeredAlerts — pagination', () => {
|
||||
// Default sort is duration ascending = newest startsAt first. Fixture indices
|
||||
// 0..14 use startsAt 2023-10-01..15, so index 14 (newest) appears first and
|
||||
// index 0 (oldest) appears last. Page 1 (limit 10) = items 14..5. Page 2 = 4..0.
|
||||
|
||||
it('shows the first 10 rows on page 1 by default', async () => {
|
||||
usePaginationHandler();
|
||||
renderTriggeredAlerts();
|
||||
|
||||
await screen.findByText('Pag Alert 14');
|
||||
|
||||
expect(screen.getByText('Pag Alert 5')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Pag Alert 4')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Pag Alert 0')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a "page 2" pagination button reflecting total=15 with pageSize=10', async () => {
|
||||
usePaginationHandler();
|
||||
renderTriggeredAlerts();
|
||||
|
||||
await screen.findByText('Pag Alert 14');
|
||||
|
||||
const nav = screen.getByRole('navigation');
|
||||
const page2 = Array.from(nav.querySelectorAll('button')).find(
|
||||
(b) => b.textContent?.trim() === '2',
|
||||
);
|
||||
expect(page2).toBeDefined();
|
||||
});
|
||||
|
||||
it('navigates to page 2 and shows the next batch of alerts', async () => {
|
||||
usePaginationHandler();
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderTriggeredAlerts();
|
||||
|
||||
await screen.findByText('Pag Alert 14');
|
||||
|
||||
const nav = screen.getByRole('navigation');
|
||||
const page2Button = Array.from(nav.querySelectorAll('button')).find(
|
||||
(b) => b.textContent?.trim() === '2',
|
||||
);
|
||||
if (!page2Button) {
|
||||
throw new Error('Page 2 button not found');
|
||||
}
|
||||
await user.click(page2Button);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('Pag Alert 0')).toBeInTheDocument(),
|
||||
);
|
||||
expect(screen.getByText('Pag Alert 4')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Pag Alert 14')).not.toBeInTheDocument();
|
||||
await waitFor(() => expect(getCurrentNuqsQueryString()).toContain('page=2'));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { logEventMock } from '__tests__/logEventMock';
|
||||
import { safeNavigateMock } from '__tests__/safeNavigateMock';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { screen, waitFor } from 'tests/test-utils';
|
||||
|
||||
import { getTriggeredAlertRowTestId, renderTriggeredAlerts } from './_helpers';
|
||||
|
||||
describe('TriggeredAlerts — row click', () => {
|
||||
it('navigates to the alert overview with the rule id from labels on row click', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderTriggeredAlerts();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByTestId(getTriggeredAlertRowTestId('fp-critical-1', 'name')),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
await user.click(
|
||||
screen.getByTestId(getTriggeredAlertRowTestId('fp-critical-1', 'name')),
|
||||
);
|
||||
|
||||
expect(safeNavigateMock).toHaveBeenCalledWith(
|
||||
'/alerts/overview?ruleId=rule-1',
|
||||
);
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
'Alert: Triggered alert clicked',
|
||||
expect.objectContaining({
|
||||
ruleId: 'rule-1',
|
||||
alertName: 'High CPU Usage',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('opens in a new tab when ctrl+clicked', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderTriggeredAlerts();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByTestId(getTriggeredAlertRowTestId('fp-warning-1', 'name')),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
await user.keyboard('{Control>}');
|
||||
await user.click(
|
||||
screen.getByTestId(getTriggeredAlertRowTestId('fp-warning-1', 'name')),
|
||||
);
|
||||
await user.keyboard('{/Control}');
|
||||
|
||||
expect(safeNavigateMock).toHaveBeenCalledWith(
|
||||
'/alerts/overview?ruleId=rule-2',
|
||||
{ newTab: true },
|
||||
);
|
||||
});
|
||||
|
||||
it('navigates correctly when ruleId is parsed from generatorURL', async () => {
|
||||
// Override fixture so the alert has no labels.ruleId but a valid
|
||||
// generatorURL → getRuleId() falls back to parsing the URL.
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v1/alerts', (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
data: [
|
||||
{
|
||||
fingerprint: 'fp-no-rule-label',
|
||||
startsAt: '2023-10-19T10:00:00Z',
|
||||
endsAt: '0001-01-01T00:00:00Z',
|
||||
updatedAt: '2023-10-20T00:00:00Z',
|
||||
generatorURL: 'http://localhost/alerts/edit?ruleId=rule-from-url',
|
||||
labels: { alertname: 'URL Rule Alert' },
|
||||
annotations: {},
|
||||
status: { state: 'active', silencedBy: [], inhibitedBy: [] },
|
||||
receivers: [],
|
||||
},
|
||||
],
|
||||
status: 'success',
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderTriggeredAlerts();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByTestId(getTriggeredAlertRowTestId('fp-no-rule-label', 'name')),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
await user.click(
|
||||
screen.getByTestId(getTriggeredAlertRowTestId('fp-no-rule-label', 'name')),
|
||||
);
|
||||
|
||||
expect(safeNavigateMock).toHaveBeenCalledWith(
|
||||
'/alerts/overview?ruleId=rule-from-url',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not navigate when the row has no ruleId anywhere', async () => {
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v1/alerts', (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
data: [
|
||||
{
|
||||
fingerprint: 'fp-no-rule',
|
||||
startsAt: '2023-10-19T10:00:00Z',
|
||||
endsAt: '0001-01-01T00:00:00Z',
|
||||
updatedAt: '2023-10-20T00:00:00Z',
|
||||
labels: { alertname: 'No Rule Alert' },
|
||||
annotations: {},
|
||||
status: { state: 'active', silencedBy: [], inhibitedBy: [] },
|
||||
receivers: [],
|
||||
},
|
||||
],
|
||||
status: 'success',
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderTriggeredAlerts();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByTestId(getTriggeredAlertRowTestId('fp-no-rule', 'name')),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
await user.click(
|
||||
screen.getByTestId(getTriggeredAlertRowTestId('fp-no-rule', 'name')),
|
||||
);
|
||||
|
||||
expect(safeNavigateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { screen, waitFor } from 'tests/test-utils';
|
||||
|
||||
import { renderTriggeredAlerts } from './_helpers';
|
||||
|
||||
describe('TriggeredAlerts — search', () => {
|
||||
it('filters rows by alertname when typing in the search input', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderTriggeredAlerts();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('High CPU Usage')).toBeInTheDocument(),
|
||||
);
|
||||
expect(screen.getByText('Memory Warning')).toBeInTheDocument();
|
||||
|
||||
const input = screen.getByTestId('triggered-alerts-search-input');
|
||||
await user.type(input, 'CPU');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('High CPU Usage')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Memory Warning')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows all rows again when search is cleared', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderTriggeredAlerts();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('High CPU Usage')).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
const input = screen.getByTestId('triggered-alerts-search-input');
|
||||
await user.type(input, 'CPU');
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText('Memory Warning')).not.toBeInTheDocument(),
|
||||
);
|
||||
|
||||
await user.clear(input);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('High CPU Usage')).toBeInTheDocument();
|
||||
expect(screen.getByText('Memory Warning')).toBeInTheDocument();
|
||||
expect(screen.getByText('Disk Slow')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('matches rows by label value (case-insensitive)', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderTriggeredAlerts();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('High CPU Usage')).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
const input = screen.getByTestId('triggered-alerts-search-input');
|
||||
// "backend" matches `service: backend` labels on Memory Warning and Network Hiccup.
|
||||
await user.type(input, 'backend');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Memory Warning')).toBeInTheDocument();
|
||||
expect(screen.getByText('Network Hiccup')).toBeInTheDocument();
|
||||
expect(screen.queryByText('High CPU Usage')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Disk Slow')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('matches rows by label key', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderTriggeredAlerts();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('High CPU Usage')).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
const input = screen.getByTestId('triggered-alerts-search-input');
|
||||
await user.type(input, 'staging');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Disk Slow')).toBeInTheDocument();
|
||||
expect(screen.queryByText('High CPU Usage')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Memory Warning')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the no-results empty state when the search matches nothing', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderTriggeredAlerts();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('High CPU Usage')).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
const input = screen.getByTestId('triggered-alerts-search-input');
|
||||
await user.type(input, 'zzzzznever-matches-anything');
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('no-results-empty-state')).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it('resets pagination to page 1 when the search changes', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderTriggeredAlerts({ initialRoute: '/?page=2&limit=2' });
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('Disk Slow')).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
const input = screen.getByTestId('triggered-alerts-search-input');
|
||||
await user.type(input, 'CPU');
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('High CPU Usage')).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
import type { SortState } from 'components/TanStackTableView/types';
|
||||
|
||||
import type { Alert } from '../types';
|
||||
import {
|
||||
getAlertSortValue,
|
||||
getRuleId,
|
||||
normalizeAlerts,
|
||||
sortAlerts,
|
||||
} from '../utils';
|
||||
|
||||
const alertWithFingerprint: Alert = {
|
||||
fingerprint: 'fp-existing',
|
||||
labels: { alertname: 'Test', severity: 'critical' },
|
||||
annotations: {},
|
||||
startsAt: '2023-10-19T10:00:00Z',
|
||||
endsAt: '0001-01-01T00:00:00Z',
|
||||
status: { state: 'active', silencedBy: [], inhibitedBy: [] },
|
||||
receivers: [],
|
||||
};
|
||||
|
||||
const alertWithoutFingerprint: Alert = {
|
||||
...alertWithFingerprint,
|
||||
fingerprint: undefined,
|
||||
};
|
||||
|
||||
describe('normalizeAlerts', () => {
|
||||
it('returns empty array when given undefined', () => {
|
||||
expect(normalizeAlerts(undefined)).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('preserves existing fingerprints', () => {
|
||||
const result = normalizeAlerts([alertWithFingerprint]);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].fingerprint).toBe('fp-existing');
|
||||
});
|
||||
|
||||
it('mints fingerprint when missing', () => {
|
||||
const result = normalizeAlerts([alertWithoutFingerprint]);
|
||||
expect(result[0].fingerprint).toBeDefined();
|
||||
expect(typeof result[0].fingerprint).toBe('string');
|
||||
expect(result[0].fingerprint?.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('does not mutate the input array', () => {
|
||||
const input = [alertWithFingerprint];
|
||||
const copy = JSON.parse(JSON.stringify(input));
|
||||
normalizeAlerts(input);
|
||||
expect(input).toStrictEqual(copy);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAlertSortValue', () => {
|
||||
const alert: Alert = {
|
||||
fingerprint: 'fp',
|
||||
labels: { alertname: 'CPU', severity: 'critical' },
|
||||
annotations: {},
|
||||
startsAt: '2023-10-19T10:00:00Z',
|
||||
endsAt: '0001-01-01T00:00:00Z',
|
||||
status: { state: 'active', silencedBy: [], inhibitedBy: [] },
|
||||
receivers: [],
|
||||
};
|
||||
|
||||
it('returns status.state for "status"', () => {
|
||||
expect(getAlertSortValue(alert, 'status')).toBe('active');
|
||||
});
|
||||
|
||||
it('returns alertname for "alertName"', () => {
|
||||
expect(getAlertSortValue(alert, 'alertName')).toBe('CPU');
|
||||
});
|
||||
|
||||
it('returns severity for "severity"', () => {
|
||||
expect(getAlertSortValue(alert, 'severity')).toBe('critical');
|
||||
});
|
||||
|
||||
it('returns elapsed ms for "firingSince"', () => {
|
||||
const result = getAlertSortValue(alert, 'firingSince');
|
||||
expect(typeof result).toBe('number');
|
||||
expect(result).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('returns elapsed ms for "duration"', () => {
|
||||
const result = getAlertSortValue(alert, 'duration');
|
||||
expect(typeof result).toBe('number');
|
||||
});
|
||||
|
||||
it('returns empty string for unknown columns', () => {
|
||||
expect(getAlertSortValue(alert, 'unknown')).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty string for missing fields', () => {
|
||||
const empty = { ...alert, status: undefined, labels: undefined };
|
||||
expect(getAlertSortValue(empty, 'status')).toBe('');
|
||||
expect(getAlertSortValue(empty, 'alertName')).toBe('');
|
||||
expect(getAlertSortValue(empty, 'severity')).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty for firingSince with no startsAt', () => {
|
||||
const empty = { ...alert, startsAt: undefined };
|
||||
expect(getAlertSortValue(empty, 'firingSince')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sortAlerts', () => {
|
||||
const a: Alert = {
|
||||
fingerprint: 'a',
|
||||
labels: { alertname: 'A' },
|
||||
annotations: {},
|
||||
startsAt: '2023-10-19T10:00:00Z',
|
||||
endsAt: '0001-01-01T00:00:00Z',
|
||||
status: { state: 'active', silencedBy: [], inhibitedBy: [] },
|
||||
receivers: [],
|
||||
};
|
||||
const b: Alert = { ...a, fingerprint: 'b', labels: { alertname: 'B' } };
|
||||
const c: Alert = { ...a, fingerprint: 'c', labels: { alertname: 'C' } };
|
||||
|
||||
it('sorts ascending when given orderBy', () => {
|
||||
const order: SortState = { columnName: 'alertName', order: 'asc' };
|
||||
const result = sortAlerts([c, a, b], order);
|
||||
expect(result.map((x) => x.labels?.alertname)).toStrictEqual(['A', 'B', 'C']);
|
||||
});
|
||||
|
||||
it('sorts descending', () => {
|
||||
const order: SortState = { columnName: 'alertName', order: 'desc' };
|
||||
const result = sortAlerts([a, b, c], order);
|
||||
expect(result.map((x) => x.labels?.alertname)).toStrictEqual(['C', 'B', 'A']);
|
||||
});
|
||||
|
||||
it('falls back to default duration asc when orderBy is null', () => {
|
||||
const result = sortAlerts([a, b, c], null);
|
||||
expect(result).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRuleId', () => {
|
||||
const base: Alert = {
|
||||
labels: {},
|
||||
annotations: {},
|
||||
startsAt: '2023-10-19T10:00:00Z',
|
||||
endsAt: '0001-01-01T00:00:00Z',
|
||||
status: { state: 'active', silencedBy: [], inhibitedBy: [] },
|
||||
receivers: [],
|
||||
fingerprint: 'fp',
|
||||
};
|
||||
|
||||
it('returns labels.ruleId when present', () => {
|
||||
expect(getRuleId({ ...base, labels: { ruleId: 'rule-1' } })).toBe('rule-1');
|
||||
});
|
||||
|
||||
it('falls back to generatorURL when ruleId label missing', () => {
|
||||
expect(
|
||||
getRuleId({
|
||||
...base,
|
||||
generatorURL: 'http://localhost/foo?ruleId=rule-42',
|
||||
}),
|
||||
).toBe('rule-42');
|
||||
});
|
||||
|
||||
it('prefers labels.ruleId over generatorURL', () => {
|
||||
expect(
|
||||
getRuleId({
|
||||
...base,
|
||||
labels: { ruleId: 'from-label' },
|
||||
generatorURL: 'http://localhost/foo?ruleId=from-url',
|
||||
}),
|
||||
).toBe('from-label');
|
||||
});
|
||||
|
||||
it('returns null when generatorURL has no ruleId param', () => {
|
||||
expect(
|
||||
getRuleId({ ...base, generatorURL: 'http://localhost/foo' }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when generatorURL is invalid', () => {
|
||||
expect(getRuleId({ ...base, generatorURL: 'not-a-url' })).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when no source available', () => {
|
||||
expect(getRuleId(base)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { VirtuosoMockContext } from 'react-virtuoso';
|
||||
import { render, RenderResult, screen } from '@testing-library/react';
|
||||
import TriggeredAlerts from 'container/TriggeredAlerts';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import { AppContext } from 'providers/App/App';
|
||||
import TimezoneProvider from 'providers/Timezone';
|
||||
import { onNuqsUrlUpdate, resetNuqsState } from 'tests/nuqs-helpers';
|
||||
import { getAppContextMock } from 'tests/test-utils';
|
||||
|
||||
interface RenderOptions {
|
||||
initialRoute?: string;
|
||||
}
|
||||
|
||||
export function renderTriggeredAlerts(
|
||||
options: RenderOptions = {},
|
||||
): RenderResult {
|
||||
const { initialRoute = '/' } = options;
|
||||
|
||||
const initialSearch = initialRoute.includes('?')
|
||||
? initialRoute.slice(initialRoute.indexOf('?'))
|
||||
: '';
|
||||
resetNuqsState(initialSearch);
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { refetchOnWindowFocus: false, retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[initialRoute]}>
|
||||
<NuqsTestingAdapter
|
||||
searchParams={initialSearch}
|
||||
onUrlUpdate={onNuqsUrlUpdate}
|
||||
rateLimitFactor={0}
|
||||
hasMemory
|
||||
>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AppContext.Provider value={getAppContextMock('ADMIN')}>
|
||||
<TimezoneProvider>
|
||||
<VirtuosoMockContext.Provider
|
||||
value={{ viewportHeight: 800, itemHeight: 46 }}
|
||||
>
|
||||
<TriggeredAlerts />
|
||||
</VirtuosoMockContext.Provider>
|
||||
</TimezoneProvider>
|
||||
</AppContext.Provider>
|
||||
</QueryClientProvider>
|
||||
</NuqsTestingAdapter>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
export async function findAlertRow(alertName: string): Promise<HTMLElement> {
|
||||
const cell = await screen.findByText(alertName, {}, { timeout: 5000 });
|
||||
const row = cell.closest('tr');
|
||||
if (!row) {
|
||||
throw new Error(`Row not found for alert "${alertName}"`);
|
||||
}
|
||||
return row as HTMLElement;
|
||||
}
|
||||
|
||||
export function getTriggeredAlertRowTestId(
|
||||
fingerprint: string,
|
||||
column: 'name' | 'severity' | 'status',
|
||||
): string {
|
||||
return `alert-row-${fingerprint}-${column}`;
|
||||
}
|
||||
@@ -2,31 +2,32 @@ import { Badge } from '@signozhq/ui/badge';
|
||||
|
||||
interface AlertStatusTagProps {
|
||||
state: string;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
function AlertStatusTag({ state }: AlertStatusTagProps): JSX.Element {
|
||||
function AlertStatusTag({ state, testId }: AlertStatusTagProps): JSX.Element {
|
||||
switch (state) {
|
||||
case 'unprocessed':
|
||||
return (
|
||||
<Badge color="success" variant="outline">
|
||||
<Badge color="success" variant="outline" testId={testId}>
|
||||
Unprocessed
|
||||
</Badge>
|
||||
);
|
||||
case 'active':
|
||||
return (
|
||||
<Badge color="error" variant="outline">
|
||||
<Badge color="error" variant="outline" testId={testId}>
|
||||
Firing
|
||||
</Badge>
|
||||
);
|
||||
case 'suppressed':
|
||||
return (
|
||||
<Badge color="error" variant="outline">
|
||||
<Badge color="error" variant="outline" testId={testId}>
|
||||
Suppressed
|
||||
</Badge>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Badge color="secondary" variant="outline">
|
||||
<Badge color="secondary" variant="outline" testId={testId}>
|
||||
Unknown
|
||||
</Badge>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { CircleCheck, Plus, RefreshCw } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { isModifierKeyPressed } from 'utils/app';
|
||||
|
||||
import styles from './EmptyStates.module.scss';
|
||||
|
||||
@@ -13,9 +14,12 @@ interface EmptyStateProps {
|
||||
export function EmptyState({ onRefresh }: EmptyStateProps): JSX.Element {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
|
||||
const handleCreateAlert = useCallback((): void => {
|
||||
safeNavigate(ROUTES.ALERTS_NEW);
|
||||
}, [safeNavigate]);
|
||||
const handleCreateAlert = useCallback(
|
||||
(e: React.MouseEvent): void => {
|
||||
safeNavigate(ROUTES.ALERTS_NEW, { newTab: isModifierKeyPressed(e) });
|
||||
},
|
||||
[safeNavigate],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.emptyState}>
|
||||
@@ -30,6 +34,7 @@ export function EmptyState({ onRefresh }: EmptyStateProps): JSX.Element {
|
||||
color="primary"
|
||||
prefix={<Plus size={14} />}
|
||||
onClick={handleCreateAlert}
|
||||
testId="triggered-alerts-empty-create-button"
|
||||
>
|
||||
Create Alert Rule
|
||||
</Button>
|
||||
@@ -39,6 +44,7 @@ export function EmptyState({ onRefresh }: EmptyStateProps): JSX.Element {
|
||||
color="secondary"
|
||||
prefix={<RefreshCw size={14} />}
|
||||
onClick={onRefresh}
|
||||
testId="triggered-alerts-empty-refresh-button"
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
|
||||
@@ -44,6 +44,7 @@ export function GroupTagsCell({
|
||||
prefix={
|
||||
localIsExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />
|
||||
}
|
||||
testId="group-expand-toggle"
|
||||
/>
|
||||
<div className={styles.tagsContainer}>
|
||||
{tags.map((tag) => (
|
||||
|
||||
@@ -175,6 +175,7 @@ function TriggeredAlerts(): JSX.Element {
|
||||
value={searchText}
|
||||
onChange={handleSearchChange}
|
||||
suffix={<Search size={14} className={styles.searchIcon} />}
|
||||
testId="triggered-alerts-search-input"
|
||||
/>
|
||||
<ComboboxSimple
|
||||
className={styles.filterSelect}
|
||||
@@ -186,6 +187,7 @@ function TriggeredAlerts(): JSX.Element {
|
||||
allowCreate
|
||||
items={severyFilters}
|
||||
maxDisplayedPills={2}
|
||||
testId="triggered-alerts-filter-combobox"
|
||||
/>
|
||||
<ComboboxSimple
|
||||
className={styles.filterSelect}
|
||||
@@ -196,6 +198,7 @@ function TriggeredAlerts(): JSX.Element {
|
||||
items={labelOptions}
|
||||
multiple
|
||||
maxDisplayedPills={2}
|
||||
testId="triggered-alerts-groupby-combobox"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -21,8 +21,11 @@ export function getAlertColumns(
|
||||
width: { fixed: '100px' },
|
||||
enableSort: false,
|
||||
enableMove: false,
|
||||
cell: ({ value }): JSX.Element => (
|
||||
<AlertStatusTag state={String(value ?? '')} />
|
||||
cell: ({ row, value }): JSX.Element => (
|
||||
<AlertStatusTag
|
||||
state={String(value ?? '')}
|
||||
testId={`alert-row-${row.fingerprint ?? ''}-status`}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -32,8 +35,11 @@ export function getAlertColumns(
|
||||
width: { default: '100%' },
|
||||
enableSort: true,
|
||||
enableMove: false,
|
||||
cell: ({ value }): JSX.Element => (
|
||||
<TanStackTable.Text title={value}>
|
||||
cell: ({ row, value }): JSX.Element => (
|
||||
<TanStackTable.Text
|
||||
title={value}
|
||||
data-testid={`alert-row-${row.fingerprint ?? ''}-name`}
|
||||
>
|
||||
{String(value ?? '-')}
|
||||
</TanStackTable.Text>
|
||||
),
|
||||
@@ -45,15 +51,17 @@ export function getAlertColumns(
|
||||
width: { fixed: '120px' },
|
||||
enableSort: true,
|
||||
enableMove: false,
|
||||
cell: ({ value }): JSX.Element => {
|
||||
cell: ({ row, value }): JSX.Element => {
|
||||
const severity = String(value ?? '').toLowerCase();
|
||||
const testId = `alert-row-${row.fingerprint ?? ''}-severity`;
|
||||
if (!severity) {
|
||||
return <TanStackTable.Text>-</TanStackTable.Text>;
|
||||
return <TanStackTable.Text data-testid={testId}>-</TanStackTable.Text>;
|
||||
}
|
||||
return (
|
||||
<Badge
|
||||
color={SEVERITY_BADGE_COLORS[severity] ?? 'secondary'}
|
||||
variant="outline"
|
||||
testId={testId}
|
||||
>
|
||||
{severity}
|
||||
</Badge>
|
||||
|
||||
@@ -71,7 +71,7 @@ describe('useTransformDashboardVariables', () => {
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
const orders = Object.values(result.data.variables).map((v) => v.order);
|
||||
const orders = Object.values(result.data.variables!).map((v) => v.order);
|
||||
expect(orders).toContain(0);
|
||||
expect(orders).toContain(1);
|
||||
});
|
||||
@@ -84,7 +84,7 @@ describe('useTransformDashboardVariables', () => {
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables.v1.order).toBe(5);
|
||||
expect(result.data.variables!.v1.order).toBe(5);
|
||||
});
|
||||
|
||||
it('assigns unique orders across multiple variables that all lack an order', () => {
|
||||
@@ -97,7 +97,7 @@ describe('useTransformDashboardVariables', () => {
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
const orders = Object.values(result.data.variables).map((v) => v.order);
|
||||
const orders = Object.values(result.data.variables!).map((v) => v.order);
|
||||
// All three newly assigned orders must be distinct
|
||||
expect(new Set(orders).size).toBe(3);
|
||||
});
|
||||
@@ -112,7 +112,7 @@ describe('useTransformDashboardVariables', () => {
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables.v1.id).toMatch(
|
||||
expect(result.data.variables!.v1.id).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
|
||||
);
|
||||
});
|
||||
@@ -125,7 +125,7 @@ describe('useTransformDashboardVariables', () => {
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables.v1.id).toBe('keep-me');
|
||||
expect(result.data.variables!.v1.id).toBe('keep-me');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -145,7 +145,7 @@ describe('useTransformDashboardVariables', () => {
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables.v1.defaultValue).toBe('hello');
|
||||
expect(result.data.variables!.v1.defaultValue).toBe('hello');
|
||||
});
|
||||
|
||||
it('does not overwrite an existing defaultValue', () => {
|
||||
@@ -163,7 +163,7 @@ describe('useTransformDashboardVariables', () => {
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables.v1.defaultValue).toBe('keep');
|
||||
expect(result.data.variables!.v1.defaultValue).toBe('keep');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -178,7 +178,7 @@ describe('useTransformDashboardVariables', () => {
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables.v1.selectedValue).toBe('staging');
|
||||
expect(result.data.variables!.v1.selectedValue).toBe('staging');
|
||||
});
|
||||
|
||||
it('applies localStorage allSelected over DB value', () => {
|
||||
@@ -196,7 +196,7 @@ describe('useTransformDashboardVariables', () => {
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables.v1.allSelected).toBe(true);
|
||||
expect(result.data.variables!.v1.allSelected).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -217,7 +217,7 @@ describe('useTransformDashboardVariables', () => {
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables.v1.allSelected).toBe(true);
|
||||
expect(result.data.variables!.v1.allSelected).toBe(true);
|
||||
});
|
||||
|
||||
it('sets selectedValue from URL and clears allSelected when showALLOption is true', () => {
|
||||
@@ -237,8 +237,8 @@ describe('useTransformDashboardVariables', () => {
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables.v1.selectedValue).toBe('dev');
|
||||
expect(result.data.variables.v1.allSelected).toBe(false);
|
||||
expect(result.data.variables!.v1.selectedValue).toBe('dev');
|
||||
expect(result.data.variables!.v1.allSelected).toBe(false);
|
||||
});
|
||||
|
||||
it('does not set allSelected=false when showALLOption is false', () => {
|
||||
@@ -258,8 +258,8 @@ describe('useTransformDashboardVariables', () => {
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables.v1.selectedValue).toBe('dev');
|
||||
expect(result.data.variables.v1.allSelected).toBe(true);
|
||||
expect(result.data.variables!.v1.selectedValue).toBe('dev');
|
||||
expect(result.data.variables!.v1.allSelected).toBe(true);
|
||||
});
|
||||
|
||||
it('normalizes array URL value to single value for single-select variable', () => {
|
||||
@@ -277,7 +277,7 @@ describe('useTransformDashboardVariables', () => {
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables.v1.selectedValue).toBe('prod');
|
||||
expect(result.data.variables!.v1.selectedValue).toBe('prod');
|
||||
});
|
||||
|
||||
it('wraps single URL value in array for multi-select variable', () => {
|
||||
@@ -292,7 +292,7 @@ describe('useTransformDashboardVariables', () => {
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables.v1.selectedValue).toStrictEqual(['prod']);
|
||||
expect(result.data.variables!.v1.selectedValue).toStrictEqual(['prod']);
|
||||
});
|
||||
|
||||
it('looks up URL variable by variable id when name is absent', () => {
|
||||
@@ -306,7 +306,7 @@ describe('useTransformDashboardVariables', () => {
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables.v1.selectedValue).toBe('fallback');
|
||||
expect(result.data.variables!.v1.selectedValue).toBe('fallback');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -327,11 +327,11 @@ describe('useTransformDashboardVariables', () => {
|
||||
const dashboard = makeDashboard({
|
||||
v1: makeVariable({ id: 'id1', name: 'env', selectedValue: 'prod' }),
|
||||
});
|
||||
const originalValue = dashboard.data.variables.v1.selectedValue;
|
||||
const originalValue = dashboard.data.variables!.v1.selectedValue;
|
||||
|
||||
transformDashboardVariables(dashboard);
|
||||
|
||||
expect(dashboard.data.variables.v1.selectedValue).toBe(originalValue);
|
||||
expect(dashboard.data.variables!.v1.selectedValue).toBe(originalValue);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,11 +22,12 @@ export function useTransformDashboardVariables(dashboardId: string): Pick<
|
||||
localStorageVariables: any,
|
||||
): Dashboard => {
|
||||
const updatedData = data;
|
||||
if (data && localStorageVariables) {
|
||||
const updatedVariables = data.data.variables;
|
||||
const variables = data?.data?.variables;
|
||||
if (data && localStorageVariables && variables) {
|
||||
const updatedVariables = variables;
|
||||
const variablesFromUrl = getUrlVariables();
|
||||
Object.keys(data.data.variables).forEach((variable) => {
|
||||
const variableData = data.data.variables[variable];
|
||||
Object.keys(variables).forEach((variable) => {
|
||||
const variableData = variables[variable];
|
||||
|
||||
// values from url
|
||||
const urlVariable = variableData?.name
|
||||
@@ -34,7 +35,7 @@ export function useTransformDashboardVariables(dashboardId: string): Pick<
|
||||
: variablesFromUrl[variableData.id];
|
||||
|
||||
let updatedVariable = {
|
||||
...data.data.variables[variable],
|
||||
...variables[variable],
|
||||
...localStorageVariables[variableData.name as any],
|
||||
};
|
||||
|
||||
|
||||
@@ -17,11 +17,6 @@ jest.mock('react-redux', () => ({
|
||||
useSelector: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('api/dashboard/substitute_vars', () => ({
|
||||
getSubstituteVars: jest.fn(),
|
||||
}));
|
||||
|
||||
@@ -18,6 +18,8 @@ interface TooltipHeaderProps {
|
||||
showTooltipHeader: boolean;
|
||||
isPinned: boolean;
|
||||
activeItem: TooltipContentItem | null;
|
||||
headerRowClassName?: string;
|
||||
dateFormat?: string;
|
||||
}
|
||||
|
||||
export default function TooltipHeader({
|
||||
@@ -26,6 +28,8 @@ export default function TooltipHeader({
|
||||
showTooltipHeader,
|
||||
isPinned,
|
||||
activeItem,
|
||||
headerRowClassName,
|
||||
dateFormat = DATE_TIME_FORMATS.MONTH_DATETIME_SECONDS,
|
||||
}: TooltipHeaderProps): JSX.Element {
|
||||
const { timezone: userTimezone } = useTimezone();
|
||||
const resolvedTimezone = timezone?.value ?? userTimezone.value;
|
||||
@@ -44,12 +48,13 @@ export default function TooltipHeader({
|
||||
}
|
||||
return dayjs(timestamp * 1000)
|
||||
.tz(resolvedTimezone)
|
||||
.format(DATE_TIME_FORMATS.MONTH_DATETIME_SECONDS);
|
||||
.format(dateFormat);
|
||||
}, [
|
||||
resolvedTimezone,
|
||||
uPlotInstance.data,
|
||||
uPlotInstance.cursor.idx,
|
||||
showTooltipHeader,
|
||||
dateFormat,
|
||||
]);
|
||||
|
||||
return (
|
||||
@@ -58,7 +63,7 @@ export default function TooltipHeader({
|
||||
data-testid="uplot-tooltip-header-container"
|
||||
>
|
||||
{showTooltipHeader && headerTitle && (
|
||||
<div className={Styles.headerRow}>
|
||||
<div className={cx(Styles.headerRow, headerRowClassName)}>
|
||||
<span>{headerTitle}</span>
|
||||
{isPinned && (
|
||||
<div className={cx(Styles.status)} data-testid="uplot-tooltip-status">
|
||||
|
||||
85
frontend/src/mocks-server/__mockdata__/alert_rules.ts
Normal file
85
frontend/src/mocks-server/__mockdata__/alert_rules.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import {
|
||||
RuletypesAlertStateDTO,
|
||||
RuletypesAlertTypeDTO,
|
||||
RuletypesPanelTypeDTO,
|
||||
RuletypesQueryTypeDTO,
|
||||
RuletypesRuleTypeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type {
|
||||
RuletypesRuleConditionDTO,
|
||||
RuletypesRuleDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
const baseCondition: RuletypesRuleConditionDTO = {
|
||||
compositeQuery: {
|
||||
queryType: RuletypesQueryTypeDTO.builder,
|
||||
panelType: RuletypesPanelTypeDTO.graph,
|
||||
queries: null,
|
||||
},
|
||||
} as unknown as RuletypesRuleConditionDTO;
|
||||
|
||||
const make = (
|
||||
id: string,
|
||||
overrides: Partial<RuletypesRuleDTO>,
|
||||
): RuletypesRuleDTO => ({
|
||||
id,
|
||||
alert: `Alert ${id}`,
|
||||
alertType: RuletypesAlertTypeDTO.METRIC_BASED_ALERT,
|
||||
condition: baseCondition,
|
||||
createdAt: '2023-10-15T10:00:00Z',
|
||||
updatedAt: '2023-10-19T10:00:00Z',
|
||||
createdBy: 'alice@signoz.io',
|
||||
updatedBy: 'alice@signoz.io',
|
||||
disabled: false,
|
||||
state: RuletypesAlertStateDTO.inactive,
|
||||
labels: { severity: 'info' },
|
||||
annotations: {},
|
||||
source: '',
|
||||
evalWindow: '5m0s',
|
||||
frequency: '1m0s',
|
||||
ruleType: RuletypesRuleTypeDTO.threshold_rule,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
export const alertRulesFixture: RuletypesRuleDTO[] = [
|
||||
make('rule-1', {
|
||||
alert: 'High CPU Alert',
|
||||
state: RuletypesAlertStateDTO.firing,
|
||||
labels: { severity: 'critical', team: 'infra' },
|
||||
}),
|
||||
make('rule-2', {
|
||||
alert: 'Memory Pending Alert',
|
||||
state: RuletypesAlertStateDTO.pending,
|
||||
labels: { severity: 'warning', team: 'backend' },
|
||||
}),
|
||||
make('rule-3', {
|
||||
alert: 'Healthy Alert',
|
||||
state: RuletypesAlertStateDTO.inactive,
|
||||
labels: { severity: 'info', team: 'infra' },
|
||||
}),
|
||||
make('rule-4', {
|
||||
alert: 'Disabled Alert',
|
||||
state: RuletypesAlertStateDTO.disabled,
|
||||
disabled: true,
|
||||
labels: { severity: 'critical', team: 'frontend' },
|
||||
}),
|
||||
make('rule-5', {
|
||||
alert: 'No Labels Alert',
|
||||
state: RuletypesAlertStateDTO.inactive,
|
||||
labels: {},
|
||||
}),
|
||||
];
|
||||
|
||||
export const alertRulesPaginationFixture: RuletypesRuleDTO[] = Array.from(
|
||||
{ length: 15 },
|
||||
(_, i) =>
|
||||
make(`rule-pag-${i}`, {
|
||||
alert: `Pag Rule ${i}`,
|
||||
state:
|
||||
i % 2 === 0
|
||||
? RuletypesAlertStateDTO.firing
|
||||
: RuletypesAlertStateDTO.inactive,
|
||||
labels: { severity: i % 2 === 0 ? 'critical' : 'warning' },
|
||||
createdAt: `2023-10-${String(i + 1).padStart(2, '0')}T10:00:00Z`,
|
||||
}),
|
||||
);
|
||||
102
frontend/src/mocks-server/__mockdata__/triggered_alerts.ts
Normal file
102
frontend/src/mocks-server/__mockdata__/triggered_alerts.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import type { AlertmanagertypesDeprecatedGettableAlertDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
export const triggeredAlertsFixture: AlertmanagertypesDeprecatedGettableAlertDTO[] =
|
||||
[
|
||||
{
|
||||
fingerprint: 'fp-critical-1',
|
||||
startsAt: '2023-10-19T10:00:00Z',
|
||||
endsAt: '0001-01-01T00:00:00Z',
|
||||
generatorURL: 'http://localhost/alerts/edit?ruleId=rule-1&panelTypes=graph',
|
||||
labels: {
|
||||
alertname: 'High CPU Usage',
|
||||
severity: 'critical',
|
||||
ruleId: 'rule-1',
|
||||
service: 'frontend',
|
||||
env: 'prod',
|
||||
},
|
||||
annotations: {
|
||||
summary: 'CPU above 90%',
|
||||
description: 'Frontend CPU usage critical',
|
||||
},
|
||||
status: { state: 'active', silencedBy: [], inhibitedBy: [] },
|
||||
receivers: ['slack'],
|
||||
},
|
||||
{
|
||||
fingerprint: 'fp-warning-1',
|
||||
startsAt: '2023-10-19T09:00:00Z',
|
||||
endsAt: '0001-01-01T00:00:00Z',
|
||||
generatorURL: 'http://localhost/alerts/edit?ruleId=rule-2',
|
||||
labels: {
|
||||
alertname: 'Memory Warning',
|
||||
severity: 'warning',
|
||||
ruleId: 'rule-2',
|
||||
service: 'backend',
|
||||
env: 'prod',
|
||||
},
|
||||
annotations: { summary: 'Memory high' },
|
||||
status: { state: 'active', silencedBy: [], inhibitedBy: [] },
|
||||
receivers: ['slack'],
|
||||
},
|
||||
{
|
||||
fingerprint: 'fp-info-1',
|
||||
startsAt: '2023-10-19T08:00:00Z',
|
||||
endsAt: '0001-01-01T00:00:00Z',
|
||||
generatorURL: 'http://localhost/alerts/edit?ruleId=rule-3',
|
||||
labels: {
|
||||
alertname: 'Disk Slow',
|
||||
severity: 'info',
|
||||
ruleId: 'rule-3',
|
||||
service: 'frontend',
|
||||
env: 'staging',
|
||||
},
|
||||
annotations: { summary: 'Disk slow' },
|
||||
status: { state: 'unprocessed', silencedBy: [], inhibitedBy: [] },
|
||||
receivers: ['email'],
|
||||
},
|
||||
{
|
||||
fingerprint: 'fp-suppressed-1',
|
||||
startsAt: '2023-10-19T07:00:00Z',
|
||||
endsAt: '0001-01-01T00:00:00Z',
|
||||
generatorURL: 'http://localhost/alerts/edit?ruleId=rule-4',
|
||||
labels: {
|
||||
alertname: 'Network Hiccup',
|
||||
severity: 'error',
|
||||
ruleId: 'rule-4',
|
||||
service: 'backend',
|
||||
env: 'dev',
|
||||
},
|
||||
annotations: { summary: 'Network errors' },
|
||||
status: { state: 'suppressed', silencedBy: ['s-1'], inhibitedBy: [] },
|
||||
receivers: ['pagerduty'],
|
||||
},
|
||||
{
|
||||
fingerprint: 'fp-noseverity',
|
||||
startsAt: '2023-10-19T06:00:00Z',
|
||||
endsAt: '0001-01-01T00:00:00Z',
|
||||
labels: {
|
||||
alertname: 'Unknown Alert',
|
||||
service: 'misc',
|
||||
},
|
||||
annotations: {},
|
||||
status: { state: 'active', silencedBy: [], inhibitedBy: [] },
|
||||
receivers: [],
|
||||
},
|
||||
];
|
||||
|
||||
// Bigger fixture for pagination tests (15 entries → 2 pages at limit=10).
|
||||
export const triggeredAlertsPaginationFixture: AlertmanagertypesDeprecatedGettableAlertDTO[] =
|
||||
Array.from({ length: 15 }, (_, i) => ({
|
||||
fingerprint: `fp-pag-${i}`,
|
||||
startsAt: `2023-10-${String(i + 1).padStart(2, '0')}T10:00:00Z`,
|
||||
endsAt: '0001-01-01T00:00:00Z',
|
||||
generatorURL: `http://localhost/alerts/edit?ruleId=rule-pag-${i}`,
|
||||
labels: {
|
||||
alertname: `Pag Alert ${i}`,
|
||||
severity: i % 2 === 0 ? 'critical' : 'warning',
|
||||
ruleId: `rule-pag-${i}`,
|
||||
service: 'frontend',
|
||||
},
|
||||
annotations: {},
|
||||
status: { state: 'active', silencedBy: [], inhibitedBy: [] },
|
||||
receivers: [],
|
||||
}));
|
||||
@@ -3,6 +3,8 @@ import { rest } from 'msw';
|
||||
import commonEnTranslation from '../../public/locales/en/common.json';
|
||||
import enTranslation from '../../public/locales/en/translation.json';
|
||||
import { allAlertChannels } from './__mockdata__/alerts';
|
||||
import { alertRulesFixture } from './__mockdata__/alert_rules';
|
||||
import { triggeredAlertsFixture } from './__mockdata__/triggered_alerts';
|
||||
import { billingSuccessResponse } from './__mockdata__/billing';
|
||||
import {
|
||||
dashboardSuccessResponse,
|
||||
@@ -236,6 +238,38 @@ export const handlers = [
|
||||
rest.get('http://localhost/api/v1/channels', (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data: allAlertChannels, status: 'success' })),
|
||||
),
|
||||
rest.get('http://localhost/api/v1/alerts', (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({ data: triggeredAlertsFixture, status: 'success' }),
|
||||
),
|
||||
),
|
||||
rest.get('http://localhost/api/v2/rules', (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({ data: alertRulesFixture, status: 'success' }),
|
||||
),
|
||||
),
|
||||
rest.post('http://localhost/api/v2/rules', async (req, res, ctx) => {
|
||||
const body = (await req.json()) as { alert?: string };
|
||||
return res(
|
||||
ctx.status(201),
|
||||
ctx.json({
|
||||
data: {
|
||||
...alertRulesFixture[0],
|
||||
id: 'new-rule-id',
|
||||
alert: body?.alert ?? 'New Rule',
|
||||
},
|
||||
status: 'success',
|
||||
}),
|
||||
);
|
||||
}),
|
||||
rest.patch('http://localhost/api/v2/rules/:id', (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success' })),
|
||||
),
|
||||
rest.delete('http://localhost/api/v2/rules/:id', (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success' })),
|
||||
),
|
||||
rest.delete('http://localhost/api/v1/channels/:id', (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
|
||||
@@ -86,7 +86,7 @@ function DashboardWidgetInternal({
|
||||
setDashboardData(updatedDashboardData);
|
||||
setDashboardVariablesStore({
|
||||
dashboardId,
|
||||
variables: updatedDashboardData.data.variables,
|
||||
variables: updatedDashboardData.data.variables ?? {},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -10,11 +10,6 @@ jest.mock('components/MarkdownRenderer/MarkdownRenderer', () => ({
|
||||
children,
|
||||
}));
|
||||
|
||||
jest.mock('api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('lib/history', () => ({
|
||||
push: jest.fn(),
|
||||
listen: jest.fn(() => jest.fn()),
|
||||
|
||||
@@ -14,11 +14,6 @@ jest.mock('AppRoutes/utils', () => ({
|
||||
|
||||
const mockAfterLogin = jest.mocked(afterLogin);
|
||||
|
||||
jest.mock('api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('lib/history', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
|
||||
@@ -41,6 +41,20 @@ describe('dashboardVariablesStoreUtils', () => {
|
||||
expect(result).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty array when variables is undefined', () => {
|
||||
const result = buildSortedVariablesArray(
|
||||
undefined as unknown as IDashboardVariables,
|
||||
);
|
||||
expect(result).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty array when variables is null', () => {
|
||||
const result = buildSortedVariablesArray(
|
||||
null as unknown as IDashboardVariables,
|
||||
);
|
||||
expect(result).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('should create copies of variables (not references)', () => {
|
||||
const original = createVariable({ name: 'a', order: 0 });
|
||||
const variables: IDashboardVariables = { a: original };
|
||||
|
||||
@@ -17,11 +17,11 @@ import {
|
||||
* Build a sorted array of variables by their order property
|
||||
*/
|
||||
export function buildSortedVariablesArray(
|
||||
variables: IDashboardVariables,
|
||||
variables?: IDashboardVariables,
|
||||
): IDashboardVariable[] {
|
||||
const sortedVariablesArray: IDashboardVariable[] = [];
|
||||
|
||||
Object.values(variables).forEach((value) => {
|
||||
Object.values(variables ?? {}).forEach((value) => {
|
||||
sortedVariablesArray.push({ ...value });
|
||||
});
|
||||
|
||||
|
||||
41
frontend/src/tests/nuqs-helpers.ts
Normal file
41
frontend/src/tests/nuqs-helpers.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
// Helpers for tests that drive components built with `nuqs`.
|
||||
//
|
||||
// We replace `NuqsAdapter` (which throttles URL writes to `window.history`)
|
||||
// with `NuqsTestingAdapter`. The testing adapter applies URL updates
|
||||
// synchronously (`rateLimitFactor: 0`) and stores state in memory
|
||||
// (`hasMemory: true`) so subsequent reads see the latest value without any
|
||||
// `flushNuqsUrl` sleep. Each test gets a fresh queue because
|
||||
// `resetUrlUpdateQueueOnMount` defaults to `true`.
|
||||
//
|
||||
// Reads on `window.location.search` are no longer authoritative since the
|
||||
// adapter does not push to the browser history. Use
|
||||
// `getCurrentNuqsQueryString()` (or assert on `lastNuqsUrlUpdate`) instead.
|
||||
|
||||
import type { OnUrlUpdateFunction } from 'nuqs/adapters/testing';
|
||||
|
||||
let lastUrlUpdate: { searchParams: URLSearchParams; queryString: string } = {
|
||||
searchParams: new URLSearchParams(),
|
||||
queryString: '',
|
||||
};
|
||||
|
||||
export function resetNuqsState(initialQuery = ''): void {
|
||||
lastUrlUpdate = {
|
||||
searchParams: new URLSearchParams(initialQuery),
|
||||
queryString: initialQuery,
|
||||
};
|
||||
}
|
||||
|
||||
export const onNuqsUrlUpdate: OnUrlUpdateFunction = (event) => {
|
||||
lastUrlUpdate = {
|
||||
searchParams: event.searchParams,
|
||||
queryString: event.queryString,
|
||||
};
|
||||
};
|
||||
|
||||
export function getCurrentNuqsQueryString(): string {
|
||||
return lastUrlUpdate.queryString;
|
||||
}
|
||||
|
||||
export function getCurrentNuqsSearchParams(): URLSearchParams {
|
||||
return lastUrlUpdate.searchParams;
|
||||
}
|
||||
@@ -95,7 +95,7 @@ export interface DashboardData {
|
||||
title: string;
|
||||
layout?: Layout[];
|
||||
panelMap?: Record<string, { widgets: Layout[]; collapsed: boolean }>;
|
||||
variables: Record<string, IDashboardVariable>;
|
||||
variables?: Record<string, IDashboardVariable>;
|
||||
version?: string;
|
||||
image?: string;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1 +0,0 @@
|
||||
<svg id="b70acf0a-34b4-4bdf-9024-7496043ff915" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 18"><defs><radialGradient id="e2cf8746-c9a8-4eee-86c2-4951983c6032" cx="13428.81" cy="3518.86" r="56.67" gradientTransform="translate(-2005.33 -518.83) scale(0.15)" gradientUnits="userSpaceOnUse"><stop offset="0.18" stop-color="#5ea0ef"/><stop offset="1" stop-color="#0078d4"/></radialGradient><linearGradient id="bdd213dd-d313-473c-8ff4-0133fd3a9033" x1="4.4" y1="11.48" x2="4.37" y2="7.53" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ccc"/><stop offset="1" stop-color="#fcfcfc"/></linearGradient><linearGradient id="afcc63c5-3649-4476-a742-bcb53a569f3c" x1="10.13" y1="15.45" x2="10.13" y2="11.9" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ccc"/><stop offset="1" stop-color="#fcfcfc"/></linearGradient><linearGradient id="bd873f0b-9954-4aa5-a3df-9f4c64e8729d" x1="14.18" y1="11.15" x2="14.18" y2="7.38" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ccc"/><stop offset="1" stop-color="#fcfcfc"/></linearGradient></defs><title>Icon-web-41</title><path id="ee75dd06-1aca-4f76-9d11-d05a284020ad" d="M14.21,15.72A8.5,8.5,0,0,1,3.79,2.28l.09-.06a8.5,8.5,0,0,1,10.33,13.5" fill="url(#e2cf8746-c9a8-4eee-86c2-4951983c6032)"/><path d="M6.69,7.23A13,13,0,0,1,15.6,3.65a8.47,8.47,0,0,0-1.49-1.44,14.34,14.34,0,0,0-4.69,1.1A12.54,12.54,0,0,0,5.34,6.13,2.76,2.76,0,0,1,6.69,7.23Z" fill="#fff" opacity="0.6"/><path d="M2.48,10.65a17.86,17.86,0,0,0-.83,2.62,7.82,7.82,0,0,0,.62.92c.18.23.35.44.55.65A17.94,17.94,0,0,1,3.9,11.37,2.76,2.76,0,0,1,2.48,10.65Z" fill="#fff" opacity="0.6"/><path d="M3.46,6.11a12,12,0,0,1-.69-2.94,8.15,8.15,0,0,0-1.1,1.45A12.69,12.69,0,0,0,2.24,7,2.69,2.69,0,0,1,3.46,6.11Z" fill="#f2f2f2" opacity="0.55"/><circle cx="4.38" cy="8.68" r="2.73" fill="url(#bdd213dd-d313-473c-8ff4-0133fd3a9033)"/><path d="M8.36,13.67A1.77,1.77,0,0,1,8.9,12.4a11.88,11.88,0,0,1-2.53-1.86,2.74,2.74,0,0,1-1.49.83,13.1,13.1,0,0,0,1.45,1.28A12.12,12.12,0,0,0,8.38,13.9,1.79,1.79,0,0,1,8.36,13.67Z" fill="#f2f2f2" opacity="0.55"/><path d="M14.66,13.88a12,12,0,0,1-2.76-.32.41.41,0,0,1,0,.11,1.75,1.75,0,0,1-.51,1.24,13.69,13.69,0,0,0,3.42.24A8.21,8.21,0,0,0,16,13.81,11.5,11.5,0,0,1,14.66,13.88Z" fill="#f2f2f2" opacity="0.55"/><circle cx="10.13" cy="13.67" r="1.78" fill="url(#afcc63c5-3649-4476-a742-bcb53a569f3c)"/><path d="M12.32,8.93a1.83,1.83,0,0,1,.61-1A25.5,25.5,0,0,1,8.47,3.79a16.91,16.91,0,0,1-2-2.92,7.64,7.64,0,0,0-1.09.42A18.14,18.14,0,0,0,7.53,4.47,26.44,26.44,0,0,0,12.32,8.93Z" fill="#f2f2f2" opacity="0.7"/><circle cx="14.18" cy="9.27" r="1.89" fill="url(#bd873f0b-9954-4aa5-a3df-9f4c64e8729d)"/><path d="M17.35,10.54,17,10.37l0,0-.3-.16-.06,0L16.38,10l-.07,0L16,9.8a1.76,1.76,0,0,1-.64.92c.12.08.25.15.38.22l.08.05.35.19,0,0,.86.45h0a8.63,8.63,0,0,0,.29-1.11Z" fill="#f2f2f2" opacity="0.55"/><circle cx="4.38" cy="8.68" r="2.73" fill="url(#bdd213dd-d313-473c-8ff4-0133fd3a9033)"/><circle cx="10.13" cy="13.67" r="1.78" fill="url(#afcc63c5-3649-4476-a742-bcb53a569f3c)"/></svg>
|
||||
|
Before Width: | Height: | Size: 3.0 KiB |
@@ -1,272 +0,0 @@
|
||||
{
|
||||
"id": "appservice",
|
||||
"title": "App Services",
|
||||
"icon": "file://icon.svg",
|
||||
"overview": "file://overview.md",
|
||||
"supportedSignals": {
|
||||
"metrics": true,
|
||||
"logs": true
|
||||
},
|
||||
"dataCollected": {
|
||||
"metrics": [
|
||||
{
|
||||
"name": "azure_averagememoryworkingset_average",
|
||||
"unit": "Bytes",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "azure_bytesreceived_total",
|
||||
"unit": "Bytes",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "azure_bytessent_total",
|
||||
"unit": "Bytes",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "azure_backendrequestcount_total",
|
||||
"unit": "Count",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "azure_cputime_count",
|
||||
"unit": "Milliseconds",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "azure_cputime_total",
|
||||
"unit": "Milliseconds",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "azure_cputime_minimum",
|
||||
"unit": "Milliseconds",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "azure_cputime_maximum",
|
||||
"unit": "Milliseconds",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "azure_currentassemblies_average",
|
||||
"unit": "Count",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "azure_filesystemusage_average",
|
||||
"unit": "Bytes",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "azure_gen0collections_total",
|
||||
"unit": "Count",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "azure_ge10collections_total",
|
||||
"unit": "Count",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "azure_gen2collections_total",
|
||||
"unit": "Count",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "azure_handles_average",
|
||||
"unit": "Count",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "azure_healthcheckstatus_average",
|
||||
"unit": "Count",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "azure_http101_total",
|
||||
"unit": "Count",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
|
||||
{
|
||||
"name": "azure_http2xx_total",
|
||||
"unit": "Count",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
|
||||
{
|
||||
"name": "azure_http3xx_total",
|
||||
"unit": "Count",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
|
||||
{
|
||||
"name": "azure_http401_total",
|
||||
"unit": "Count",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
|
||||
{
|
||||
"name": "azure_http403_total",
|
||||
"unit": "Count",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
|
||||
{
|
||||
"name": "azure_http404_total",
|
||||
"unit": "Count",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
|
||||
{
|
||||
"name": "azure_http406_total",
|
||||
"unit": "Count",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
|
||||
{
|
||||
"name": "azure_http4xx_total",
|
||||
"unit": "Count",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
|
||||
{
|
||||
"name": "azure_http5xx_total",
|
||||
"unit": "Count",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
|
||||
{
|
||||
"name": "azure_httpresponsetime_average",
|
||||
"unit": "Milliseconds",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "azure_iootherbytespersecond_total",
|
||||
"unit": "BytesPerSecond",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "azure_iootheroperationspersecond_total",
|
||||
"unit": "BytesPerSecond",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "azure_ioreadbytespersecond_total",
|
||||
"unit": "BytesPerSecond",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "azure_ioreadoperationspersecond_total",
|
||||
"unit": "BytesPerSecond",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "azure_iowritebytespersecond_total",
|
||||
"unit": "BytesPerSecond",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "azure_iowriteoperationspersecond_total",
|
||||
"unit": "BytesPerSecond",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "azure_privatebytes_average",
|
||||
"unit": "Bytes",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "azure_requests_total",
|
||||
"unit": "Count",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "azure_requestsinapplicationqueue_average",
|
||||
"unit": "Count",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "azure_thread_average",
|
||||
"unit": "Count",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "azure_totalappdomains_average",
|
||||
"unit": "Count",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "azure_totalappdomainsunloaded_average",
|
||||
"unit": "Count",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
}
|
||||
],
|
||||
"logs": [
|
||||
{
|
||||
"name": "Resource ID",
|
||||
"path": "resources.azure.resource.id",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"telemetryCollectionStrategy": {
|
||||
"azure": {
|
||||
"resourceProvider": "Microsoft.Web",
|
||||
"resourceType": "sites",
|
||||
"metrics": {},
|
||||
"logs": {
|
||||
"categoryGroups": ["allLogs"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"assets": {
|
||||
"dashboards": [
|
||||
{
|
||||
"id": "overview",
|
||||
"title": "App Services Overview",
|
||||
"description": "Overview of App Services metrics",
|
||||
"definition": "file://assets/dashboards/overview.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
### Monitor Azure App Services with SigNoz
|
||||
|
||||
Collect key App Services metrics and view them with an out of the box dashboard.
|
||||
|
||||
Note: This integration DO NOT collect metrics for any database that was setup with your App Service (if any).
|
||||
@@ -27,7 +27,6 @@ var (
|
||||
// Azure services.
|
||||
AzureServiceStorageAccountsBlob = ServiceID{valuer.NewString("storageaccountsblob")}
|
||||
AzureServiceCDNProfile = ServiceID{valuer.NewString("cdnprofile")}
|
||||
AzureServiceAppService = ServiceID{valuer.NewString("appservice")}
|
||||
)
|
||||
|
||||
func (ServiceID) Enum() []any {
|
||||
@@ -47,7 +46,6 @@ func (ServiceID) Enum() []any {
|
||||
AWSServiceSQS,
|
||||
AzureServiceStorageAccountsBlob,
|
||||
AzureServiceCDNProfile,
|
||||
AzureServiceAppService,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,7 +69,6 @@ var SupportedServices = map[CloudProviderType][]ServiceID{
|
||||
CloudProviderTypeAzure: {
|
||||
AzureServiceStorageAccountsBlob,
|
||||
AzureServiceCDNProfile,
|
||||
AzureServiceAppService,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user