Compare commits

..

3 Commits

Author SHA1 Message Date
swapnil-signoz
3ecc3e4594 chore: updating dashboard title 2026-06-04 00:03:33 +05:30
swapnil-signoz
1a2238d7b0 chore: generating openapi spec 2026-06-03 23:59:19 +05:30
swapnil-signoz
6033a77fa1 feat: adding supoprt for virtual machines to azure one click integration 2026-06-03 23:56:11 +05:30
73 changed files with 1988 additions and 3540 deletions

View File

@@ -1261,7 +1261,7 @@ components:
- sqs
- storageaccountsblob
- cdnprofile
- containerapp
- virtualmachine
type: string
CloudintegrationtypesServiceMetadata:
properties:

View File

@@ -0,0 +1,28 @@
// 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
>,
});

View File

@@ -1,8 +1,6 @@
import type { Config } from '@jest/types';
const USE_SAFE_NAVIGATE_MOCK_PATH =
'<rootDir>/src/__tests__/safeNavigateMock.ts';
const LOG_EVENT_MOCK_PATH = '<rootDir>/src/__tests__/logEventMock.ts';
const USE_SAFE_NAVIGATE_MOCK_PATH = '<rootDir>/__mocks__/useSafeNavigate.ts';
const config: Config.InitialOptions = {
silent: true,
@@ -24,8 +22,6 @@ 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',

View File

@@ -1,11 +0,0 @@
// 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;

View File

@@ -1,29 +0,0 @@
// 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,
});

View File

@@ -2574,7 +2574,7 @@ export enum CloudintegrationtypesServiceIDDTO {
sqs = 'sqs',
storageaccountsblob = 'storageaccountsblob',
cdnprofile = 'cdnprofile',
containerapp = 'containerapp',
virtualmachine = 'virtualmachine',
}
export type CloudintegrationtypesCloudIntegrationServiceDTOAnyOf = {
/**

View File

@@ -3,12 +3,17 @@ 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 { logEventMock } from '__tests__/logEventMock';
import logEvent from 'api/common/logEvent';
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(),
@@ -30,6 +35,7 @@ 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;
@@ -44,7 +50,6 @@ const mockLocation = {
describe('FeedbackModal', () => {
beforeEach(() => {
jest.clearAllMocks();
logEventMock.mockReturnValue(Promise.resolve() as never);
mockUseLocation.mockReturnValue(mockLocation);
mockUseGetTenantLicense.mockReturnValue({
isCloudUser: false,
@@ -111,7 +116,7 @@ describe('FeedbackModal', () => {
await user.type(textarea, testFeedback);
await user.click(submitButton);
expect(logEventMock).toHaveBeenCalledWith('Feedback: Submitted', {
expect(mockLogEvent).toHaveBeenCalledWith('Feedback: Submitted', {
data: testFeedback,
type: 'feedback',
page: mockLocation.pathname,
@@ -144,7 +149,7 @@ describe('FeedbackModal', () => {
await user.type(textarea, testFeedback);
await user.click(submitButton);
expect(logEventMock).toHaveBeenCalledWith('Feedback: Submitted', {
expect(mockLogEvent).toHaveBeenCalledWith('Feedback: Submitted', {
data: testFeedback,
type: 'reportBug',
page: mockLocation.pathname,
@@ -177,7 +182,7 @@ describe('FeedbackModal', () => {
await user.type(textarea, testFeedback);
await user.click(submitButton);
expect(logEventMock).toHaveBeenCalledWith('Feedback: Submitted', {
expect(mockLogEvent).toHaveBeenCalledWith('Feedback: Submitted', {
data: testFeedback,
type: 'featureRequest',
page: mockLocation.pathname,

View File

@@ -2,11 +2,16 @@
import { useLocation } from 'react-router-dom';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { logEventMock } from '__tests__/logEventMock';
import logEvent from 'api/common/logEvent';
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(),
@@ -45,6 +50,7 @@ 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;
@@ -114,7 +120,7 @@ describe('HeaderRightSection', () => {
await user.click(feedbackButton!);
expect(logEventMock).toHaveBeenCalledWith('Feedback: Clicked', {
expect(mockLogEvent).toHaveBeenCalledWith('Feedback: Clicked', {
page: mockLocation.pathname,
});
expect(screen.getByTestId('feedback-modal')).toBeInTheDocument();
@@ -127,7 +133,7 @@ describe('HeaderRightSection', () => {
const shareButton = screen.getByRole('button', { name: /share/i });
await user.click(shareButton);
expect(logEventMock).toHaveBeenCalledWith('Share: Clicked', {
expect(mockLogEvent).toHaveBeenCalledWith('Share: Clicked', {
page: mockLocation.pathname,
});
expect(screen.getByTestId('share-modal')).toBeInTheDocument();
@@ -144,7 +150,7 @@ describe('HeaderRightSection', () => {
await user.click(announcementsButton!);
expect(logEventMock).toHaveBeenCalledWith('Announcements: Clicked', {
expect(mockLogEvent).toHaveBeenCalledWith('Announcements: Clicked', {
page: mockLocation.pathname,
});
});

View File

@@ -5,13 +5,18 @@ 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 { logEventMock } from '__tests__/logEventMock';
import logEvent from 'api/common/logEvent';
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(),
@@ -48,6 +53,7 @@ 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;
@@ -119,7 +125,7 @@ describe('ShareURLModal', () => {
await user.click(copyButton);
expect(mockHandleCopyToClipboard).toHaveBeenCalled();
expect(logEventMock).toHaveBeenCalledWith('Share: Copy link clicked', {
expect(mockLogEvent).toHaveBeenCalledWith('Share: Copy link clicked', {
page: TEST_PATH,
URL: expect.any(String),
});

View File

@@ -721,53 +721,6 @@ 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

View File

@@ -139,6 +139,7 @@ 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() }));

View File

@@ -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 { logEventMock } from '__tests__/logEventMock';
import logEvent from 'api/common/logEvent';
import { GlobalShortcuts } from 'constants/shortcuts/globalShortcuts';
import { USER_PREFERENCES } from 'constants/userPreferences';
import {
@@ -24,6 +24,8 @@ jest.mock('providers/cmdKProvider', () => ({
}),
}));
jest.mock('api/common/logEvent', () => jest.fn());
// Mock the AppContext
const mockUpdateUserPreferenceInContext = jest.fn();
@@ -137,7 +139,7 @@ describe('Sidebar Toggle Shortcut', () => {
it('should log the toggle event with correct parameters', async () => {
const user = userEvent.setup();
const mockHandleShortcut = jest.fn(() => {
logEventMock('Global Shortcut: Sidebar Toggle', {
logEvent('Global Shortcut: Sidebar Toggle', {
previousState: false,
newState: true,
});
@@ -153,13 +155,10 @@ describe('Sidebar Toggle Shortcut', () => {
await user.keyboard(SHIFT_B_KEYBOARD_SHORTCUT);
expect(logEventMock).toHaveBeenCalledWith(
'Global Shortcut: Sidebar Toggle',
{
previousState: false,
newState: true,
},
);
expect(logEvent).toHaveBeenCalledWith('Global Shortcut: Sidebar Toggle', {
previousState: false,
newState: true,
});
});
it('should update user preference in context', async () => {

View File

@@ -1,328 +0,0 @@
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();
});
});
});

View File

@@ -1,8 +1,6 @@
import {
convertFiltersToExpressionWithExistingQuery,
createVariablePlaceholderRegExp,
removeKeysFromExpression,
removeVariableFromExpression,
} from 'components/QueryBuilderV2/utils';
import { cloneDeep, isArray, isEmpty } from 'lodash-es';
import { Dashboard, Widgets } from 'types/api/dashboard/getAll';
@@ -159,139 +157,6 @@ 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.

View File

@@ -18,11 +18,10 @@ 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 { toast } from '@signozhq/ui/sonner';
import { useNotifications } from 'hooks/useNotifications';
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';
@@ -93,6 +92,8 @@ 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<
@@ -200,7 +201,9 @@ function VariablesSettings({
onSuccess: (updatedDashboard) => {
if (updatedDashboard.data) {
setDashboardData(updatedDashboard.data);
toast.success(t('variable_updated_successfully'));
notifications.success({
message: t('variable_updated_successfully'),
});
}
},
},
@@ -253,11 +256,6 @@ function VariablesSettings({
};
const handleDeleteConfirm = (): void => {
if (!dashboardData || !variableToDelete.current) {
setDeleteVariableModal(false);
return;
}
const newVariablesArr = variablesTableData.filter(
(variable: IDashboardVariable) =>
variable.id !== variableToDelete?.current?.id,
@@ -265,31 +263,7 @@ function VariablesSettings({
const updatedVariables = convertVariablesToDbFormat(newVariablesArr);
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'));
}
},
},
);
updateVariables(updatedVariables);
variableToDelete.current = null;
setDeleteVariableModal(false);
};
@@ -502,7 +476,6 @@ function VariablesSettings({
open={deleteVariableModal}
onOk={handleDeleteConfirm}
onCancel={handleDeleteCancel}
okButtonProps={{ loading: updateMutation.isLoading }}
>
<Typography.Text>
Are you sure you want to delete variable{' '}

View File

@@ -1,10 +1,16 @@
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',
@@ -78,7 +84,7 @@ describe('TooltipFooter', () => {
await user.click(screen.getByTestId('uplot-tooltip-unpin'));
expect(logEventMock).toHaveBeenCalledWith(Events.TOOLTIP_UNPINNED, {
expect(mockLogEvent).toHaveBeenCalledWith(Events.TOOLTIP_UNPINNED, {
id: 'panel-123',
});
expect(dismiss).toHaveBeenCalledTimes(1);

View File

@@ -1,6 +1,7 @@
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';
@@ -66,6 +67,20 @@ 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,
@@ -256,12 +271,14 @@ function GridCardGraph({
});
}
}
queryRangeCalledRef.current = true;
},
onSettled: (data) => {
dataAvailable?.(
isDataAvailableByPanelType(data?.payload?.data, widget?.panelTypes),
);
getGraphData?.(data?.payload?.data);
queryRangeCalledRef.current = true;
},
},
);

View File

@@ -89,7 +89,7 @@ export function AlertsEmptyState({
onClick={onClickNewAlertHandler}
disabled={!addNewAlert}
loading={loading}
testId="add-alert"
data-testid="add-alert"
>
<span className={styles.buttonContent}>
<Plus size="md" />
@@ -97,12 +97,7 @@ export function AlertsEmptyState({
</span>
</Button>
{onRefresh && (
<Button
onClick={onRefresh}
prefix={<RefreshCw />}
color="secondary"
testId="list-alerts-empty-refresh-button"
>
<Button onClick={onRefresh} prefix={<RefreshCw />} color="secondary">
Refresh
</Button>
)}

View File

@@ -1,215 +0,0 @@
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' }),
);
});
});

View File

@@ -1,79 +0,0 @@
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);
});
});
});

View File

@@ -1,91 +0,0 @@
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();
});
});

View File

@@ -1,123 +0,0 @@
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();
});
});

View File

@@ -1,65 +0,0 @@
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 }),
);
});
});

View File

@@ -1,64 +0,0 @@
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');
});
});
});

View File

@@ -1,71 +0,0 @@
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);
});
});

View File

@@ -1,52 +0,0 @@
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 }));
});
});

View File

@@ -1,99 +0,0 @@
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');
});
});
});

View File

@@ -1,232 +0,0 @@
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' }),
);
});
});

View File

@@ -1,65 +0,0 @@
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;
}

View File

@@ -47,7 +47,6 @@ function ColumnSelector<TData>({
size="sm"
color="secondary"
prefix={<Columns3 size={14} />}
data-testid="alert-columns-button"
>
Columns
</Button>

View File

@@ -136,7 +136,6 @@ function ListAlertRules(): JSX.Element {
prefix={<Plus size={14} />}
onClick={handleNewAlert}
color="primary"
testId="list-alerts-new-alert-button"
>
New Alert
</Button>
@@ -158,7 +157,6 @@ function ListAlertRules(): JSX.Element {
value={searchText}
onChange={handleSearchChange}
suffix={<Search size={14} className={styles.searchIcon} />}
testId="list-alerts-search-input"
/>
</div>
)}

View File

@@ -26,18 +26,14 @@ export function getAlertRuleColumns(
enableSort: true,
enableRemove: false,
enableMove: false,
cell: ({ row, value }): JSX.Element => {
cell: ({ value }): JSX.Element => {
const state = String(value ?? '').toLowerCase();
const config = STATE_CONFIG[state] ?? {
color: 'secondary' as BadgeColor,
label: 'Unknown',
};
return (
<Badge
color={config.color}
variant="outline"
testId={`alert-row-${row.id ?? ''}-state`}
>
<Badge color={config.color} variant="outline">
{config.label}
</Badge>
);
@@ -51,11 +47,8 @@ export function getAlertRuleColumns(
enableSort: true,
enableRemove: false,
enableMove: false,
cell: ({ row, value }): JSX.Element => (
<TanStackTable.Text
title={value}
data-testid={`alert-row-${row.id ?? ''}-name`}
>
cell: ({ value }): JSX.Element => (
<TanStackTable.Text title={value}>
{String(value ?? '-')}
</TanStackTable.Text>
),
@@ -67,20 +60,15 @@ export function getAlertRuleColumns(
width: { fixed: '120px' },
enableSort: true,
enableMove: false,
cell: ({ row, value }): JSX.Element => {
cell: ({ value }): JSX.Element => {
const severity = String(value ?? '').toLowerCase();
if (!severity) {
return (
<TanStackTable.Text data-testid={`alert-row-${row.id ?? ''}-severity`}>
-
</TanStackTable.Text>
);
return <TanStackTable.Text>-</TanStackTable.Text>;
}
return (
<Badge
color={SEVERITY_BADGE_COLORS[severity] ?? 'secondary'}
variant="outline"
testId={`alert-row-${row.id ?? ''}-severity`}
>
{severity}
</Badge>

View File

@@ -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,

View File

@@ -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,6 +11,11 @@ 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),
@@ -143,7 +148,7 @@ describe('MCPServerSettings', () => {
render(<MCPServerSettings />, undefined, { role: 'ADMIN' });
expect(logEventMock).toHaveBeenCalledWith('MCP Settings: Page viewed', {
expect(mockLogEvent).toHaveBeenCalledWith('MCP Settings: Page viewed', {
role: 'ADMIN',
});
});

View File

@@ -1,6 +1,5 @@
import userEvent from '@testing-library/user-event';
import MySettingsContainer from 'container/MySettings';
import { logEventMock } from '__tests__/logEventMock';
import {
act,
fireEvent,
@@ -13,6 +12,7 @@ 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,6 +62,11 @@ 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', () => ({
@@ -130,7 +135,7 @@ describe('MySettings Flows', () => {
await waitFor(() => {
expect(toggleThemeFunction).toHaveBeenCalled();
expect(logEventMock).toHaveBeenCalledWith(
expect(logEventFunction).toHaveBeenCalledWith(
'Account Settings: Theme Changed',
{
theme: 'light',

View File

@@ -9,6 +9,11 @@ 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
>;

View File

@@ -4,6 +4,11 @@ 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: {

View File

@@ -4,13 +4,15 @@ 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 { logEventMock } from '__tests__/logEventMock';
import logEvent from 'api/common/logEvent';
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(
@@ -51,12 +53,9 @@ describe('PipelinePage container test', () => {
expect(editButton).toBeInTheDocument();
await userEvent.click(editButton);
expect(logEventMock).toHaveBeenCalledWith(
'Logs: Pipelines: Entered Edit Mode',
{
source: 'signoz-ui',
},
);
expect(logEvent).toHaveBeenCalledWith('Logs: Pipelines: Entered Edit Mode', {
source: 'signoz-ui',
});
});
it('CreatePipelineButton - add new mode & tracking', async () => {
@@ -79,7 +78,7 @@ describe('PipelinePage container test', () => {
expect(editButton).toBeInTheDocument();
await userEvent.click(editButton);
expect(logEventMock).toHaveBeenCalledWith(
expect(logEvent).toHaveBeenCalledWith(
'Logs: Pipelines: Clicked Add New Pipeline',
{
source: 'signoz-ui',

View File

@@ -1,118 +0,0 @@
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('');
});
});

View File

@@ -1,87 +0,0 @@
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();
});
});
});

View File

@@ -1,85 +0,0 @@
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(),
);
});
});

View File

@@ -1,115 +0,0 @@
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();
});
});

View File

@@ -1,76 +0,0 @@
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'));
});
});

View File

@@ -1,143 +0,0 @@
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();
});
});

View File

@@ -1,118 +0,0 @@
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(),
);
});
});

View File

@@ -1,181 +0,0 @@
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();
});
});

View File

@@ -1,71 +0,0 @@
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}`;
}

View File

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

View File

@@ -3,7 +3,6 @@ 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';
@@ -14,12 +13,9 @@ interface EmptyStateProps {
export function EmptyState({ onRefresh }: EmptyStateProps): JSX.Element {
const { safeNavigate } = useSafeNavigate();
const handleCreateAlert = useCallback(
(e: React.MouseEvent): void => {
safeNavigate(ROUTES.ALERTS_NEW, { newTab: isModifierKeyPressed(e) });
},
[safeNavigate],
);
const handleCreateAlert = useCallback((): void => {
safeNavigate(ROUTES.ALERTS_NEW);
}, [safeNavigate]);
return (
<div className={styles.emptyState}>
@@ -34,7 +30,6 @@ 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>
@@ -44,7 +39,6 @@ export function EmptyState({ onRefresh }: EmptyStateProps): JSX.Element {
color="secondary"
prefix={<RefreshCw size={14} />}
onClick={onRefresh}
testId="triggered-alerts-empty-refresh-button"
>
Refresh
</Button>

View File

@@ -44,7 +44,6 @@ export function GroupTagsCell({
prefix={
localIsExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />
}
testId="group-expand-toggle"
/>
<div className={styles.tagsContainer}>
{tags.map((tag) => (

View File

@@ -175,7 +175,6 @@ function TriggeredAlerts(): JSX.Element {
value={searchText}
onChange={handleSearchChange}
suffix={<Search size={14} className={styles.searchIcon} />}
testId="triggered-alerts-search-input"
/>
<ComboboxSimple
className={styles.filterSelect}
@@ -187,7 +186,6 @@ function TriggeredAlerts(): JSX.Element {
allowCreate
items={severyFilters}
maxDisplayedPills={2}
testId="triggered-alerts-filter-combobox"
/>
<ComboboxSimple
className={styles.filterSelect}
@@ -198,7 +196,6 @@ function TriggeredAlerts(): JSX.Element {
items={labelOptions}
multiple
maxDisplayedPills={2}
testId="triggered-alerts-groupby-combobox"
/>
</div>

View File

@@ -21,11 +21,8 @@ export function getAlertColumns(
width: { fixed: '100px' },
enableSort: false,
enableMove: false,
cell: ({ row, value }): JSX.Element => (
<AlertStatusTag
state={String(value ?? '')}
testId={`alert-row-${row.fingerprint ?? ''}-status`}
/>
cell: ({ value }): JSX.Element => (
<AlertStatusTag state={String(value ?? '')} />
),
},
{
@@ -35,11 +32,8 @@ export function getAlertColumns(
width: { default: '100%' },
enableSort: true,
enableMove: false,
cell: ({ row, value }): JSX.Element => (
<TanStackTable.Text
title={value}
data-testid={`alert-row-${row.fingerprint ?? ''}-name`}
>
cell: ({ value }): JSX.Element => (
<TanStackTable.Text title={value}>
{String(value ?? '-')}
</TanStackTable.Text>
),
@@ -51,17 +45,15 @@ export function getAlertColumns(
width: { fixed: '120px' },
enableSort: true,
enableMove: false,
cell: ({ row, value }): JSX.Element => {
cell: ({ value }): JSX.Element => {
const severity = String(value ?? '').toLowerCase();
const testId = `alert-row-${row.fingerprint ?? ''}-severity`;
if (!severity) {
return <TanStackTable.Text data-testid={testId}>-</TanStackTable.Text>;
return <TanStackTable.Text>-</TanStackTable.Text>;
}
return (
<Badge
color={SEVERITY_BADGE_COLORS[severity] ?? 'secondary'}
variant="outline"
testId={testId}
>
{severity}
</Badge>

View File

@@ -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);
});
});
});

View File

@@ -22,12 +22,11 @@ export function useTransformDashboardVariables(dashboardId: string): Pick<
localStorageVariables: any,
): Dashboard => {
const updatedData = data;
const variables = data?.data?.variables;
if (data && localStorageVariables && variables) {
const updatedVariables = variables;
if (data && localStorageVariables) {
const updatedVariables = data.data.variables;
const variablesFromUrl = getUrlVariables();
Object.keys(variables).forEach((variable) => {
const variableData = variables[variable];
Object.keys(data.data.variables).forEach((variable) => {
const variableData = data.data.variables[variable];
// values from url
const urlVariable = variableData?.name
@@ -35,7 +34,7 @@ export function useTransformDashboardVariables(dashboardId: string): Pick<
: variablesFromUrl[variableData.id];
let updatedVariable = {
...variables[variable],
...data.data.variables[variable],
...localStorageVariables[variableData.name as any],
};

View File

@@ -17,6 +17,11 @@ 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(),
}));

View File

@@ -1,85 +0,0 @@
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`,
}),
);

View File

@@ -1,102 +0,0 @@
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: [],
}));

View File

@@ -3,8 +3,6 @@ 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,
@@ -238,38 +236,6 @@ 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),

View File

@@ -86,7 +86,7 @@ function DashboardWidgetInternal({
setDashboardData(updatedDashboardData);
setDashboardVariablesStore({
dashboardId,
variables: updatedDashboardData.data.variables ?? {},
variables: updatedDashboardData.data.variables,
});
},
});

View File

@@ -10,6 +10,11 @@ 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()),

View File

@@ -14,6 +14,11 @@ 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: {

View File

@@ -41,20 +41,6 @@ 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 };

View File

@@ -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 });
});

View File

@@ -1,41 +0,0 @@
// 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;
}

View File

@@ -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;
}

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 18"><defs><linearGradient id="b27f1ad0-7d11-4247-9da3-91bce6211f32" x1="8.798" y1="8.703" x2="14.683" y2="8.703" gradientUnits="userSpaceOnUse"><stop offset="0.001" stop-color="#773adc"/><stop offset="1" stop-color="#552f99"/></linearGradient><linearGradient id="b2f92112-4ca9-4b17-a019-c9f26c1a4a8f" x1="5.764" y1="3.777" x2="5.764" y2="13.78" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#a67af4"/><stop offset="0.999" stop-color="#773adc"/></linearGradient></defs><g id="b8a0486a-5501-4d92-b540-a766c4b3b548"><g><g><g><path d="M16.932,11.578a8.448,8.448,0,0,1-7.95,5.59,8.15,8.15,0,0,1-2.33-.33,2.133,2.133,0,0,0,.18-.83c.01,0,.03.01.04.01a7.422,7.422,0,0,0,2.11.3,7.646,7.646,0,0,0,6.85-4.28l.01-.01Z" fill="#32bedd"/><path d="M3.582,14.068a2.025,2.025,0,0,0-.64.56,8.6,8.6,0,0,1-1.67-2.44l1.04.23v.26a.6.6,0,0,0,.47.59l.14.03a6.136,6.136,0,0,0,.62.73Z" fill="#32bedd"/><path d="M12.352.958a2.28,2.28,0,0,0-.27.81c-.02-.01-.05-.02-.07-.03a7.479,7.479,0,0,0-3.03-.63,7.643,7.643,0,0,0-5.9,2.8l-.29.06a.6.6,0,0,0-.48.58v.46l-1.02.19A8.454,8.454,0,0,1,8.982.268,8.6,8.6,0,0,1,12.352.958Z" fill="#32bedd"/><path d="M16.872,5.7l-1.09-.38a6.6,6.6,0,0,0-.72-1.16c-.02-.03-.04-.05-.05-.07a2.083,2.083,0,0,0,.72-.45A7.81,7.81,0,0,1,16.872,5.7Z" fill="#32bedd"/><path d="M10.072,11.908l2.54.56L8.672,14.1c-.02,0-.03.01-.05.01a.154.154,0,0,1-.15-.15V3.448a.154.154,0,0,1,.15-.15.09.09,0,0,1,.05.01l4.46,1.56-3.05.57a.565.565,0,0,0-.44.54v5.4A.537.537,0,0,0,10.072,11.908Z" fill="#fff"/><g><g id="e918f286-5032-4942-ad29-ea17e6f1cc90"><path d="M1.1,5.668l1.21-.23v6.55l-1.23-.27-.99-.22a.111.111,0,0,1-.09-.12v-5.4a.12.12,0,0,1,.09-.12Z" fill="#a67af4"/></g><g><g id="a47a99dd-4d47-4c70-8c42-c5ac274ce496"><g><path d="M10.072,11.908l2.54.56L8.672,14.1c-.02,0-.03.01-.05.01a.154.154,0,0,1-.15-.15V3.448a.154.154,0,0,1,.15-.15.09.09,0,0,1,.05.01l4.46,1.56-3.05.57a.565.565,0,0,0-.44.54v5.4A.537.537,0,0,0,10.072,11.908Z" fill="url(#b27f1ad0-7d11-4247-9da3-91bce6211f32)"/><path d="M8.586,3.3,2.878,4.378a.177.177,0,0,0-.14.175V12.68a.177.177,0,0,0,.137.174L8.581,14.1a.176.176,0,0,0,.21-.174V3.478A.175.175,0,0,0,8.619,3.3Z" fill="url(#b2f92112-4ca9-4b17-a019-c9f26c1a4a8f)"/></g></g><polygon points="5.948 4.921 5.948 12.483 7.934 12.814 7.934 4.564 5.948 4.921" fill="#b796f9" opacity="0.5"/><polygon points="3.509 5.329 3.509 11.954 5.238 12.317 5.238 5.031 3.509 5.329" fill="#b796f9" opacity="0.5"/></g></g></g><path d="M16,2.048a1.755,1.755,0,1,1-1.76-1.76A1.756,1.756,0,0,1,16,2.048Z" fill="#32bedd"/><circle cx="4.65" cy="15.973" r="1.759" fill="#32bedd"/></g><path d="M18,6.689v3.844a.222.222,0,0,1-.133.2l-.766.316-3.07,1.268-.011,0a.126.126,0,0,1-.038,0,.1.1,0,0,1-.1-.1V5.234a.1.1,0,0,1,.054-.088l0,0,.019,0a.031.031,0,0,1,.019,0,.055.055,0,0,1,.034.008l.011,0,.012,0L17.05,6.2l.8.282A.213.213,0,0,1,18,6.689Z" fill="#773adc"/><path d="M13.959,5.14l-3.8.715a.118.118,0,0,0-.093.117v5.409a.118.118,0,0,0,.091.116l3.8.831a.115.115,0,0,0,.137-.09.109.109,0,0,0,0-.026V5.256a.117.117,0,0,0-.115-.118A.082.082,0,0,0,13.959,5.14Z" fill="#a67af4"/></g></g></svg>

Before

Width:  |  Height:  |  Size: 3.1 KiB

View File

@@ -1,263 +0,0 @@
{
"id": "containerapp",
"title": "Container App",
"icon": "file://icon.svg",
"overview": "file://overview.md",
"supportedSignals": {
"metrics": true,
"logs": true
},
"dataCollected": {
"metrics": [
{
"name": "azure_rxbytes_average",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_rxbytes_maximum",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_rxbytes_minimum",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_rxbytes_total",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_txbytes_average",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_txbytes_maximum",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_txbytes_minimum",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_txbytes_total",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_restartcount_average",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_restartcount_maximum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_restartcount_minimum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_restartcount_total",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_replicas_average",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_replicas_maximum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_replicas_minimum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_replicas_total",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_cpupercentage_average",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_cpupercentage_maximum",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_cpupercentage_minimum",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_cpupercentage_total",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_memorypercentage_average",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_memorypercentage_maximum",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_memorypercentage_minimum",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_memorypercentage_total",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_usagenanocores_average",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_usagenanocores_maximum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_usagenanocores_minimum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_usagenanocores_total",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_workingsetbytes_average",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_workingsetbytes_maximum",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_workingsetbytes_minimum",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_workingsetbytes_total",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_coresquotaused_maximum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_coresquotaused_minimum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_totalcoresquotaused_average",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_totalcoresquotaused_maximum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_totalcoresquotaused_minimum",
"unit": "Count",
"type": "Gauge",
"description": ""
}
],
"logs": [
{
"name": "Resource ID",
"path": "resources.azure.resource.id",
"type": "string"
}
]
},
"telemetryCollectionStrategy": {
"azure": {
"resourceProvider": "Microsoft.App",
"resourceType": "containerApps",
"metrics": {},
"logs": {
"categoryGroups": ["ContainerAppConsoleLogs", "ContainerAppSystemLogs"]
}
}
},
"assets": {
"dashboards": [
{
"id": "overview",
"title": "Container App Overview",
"description": "Overview of Container App metrics",
"definition": "file://assets/dashboards/overview.json"
}
]
}
}

View File

@@ -1,7 +0,0 @@
### Monitor Container Apps with SigNoz
Collect key Container App metrics and view them with an out of the box dashboard.
To collect logs, you need to make sure that you have chosen "Azure Monitor" as the logging option for Container's App Environment.
Note: This integration ingests logs for only "ContainerAppConsoleLogs" and "ContainerAppSystemLogs" diagnostic settings categories.

View File

@@ -0,0 +1 @@
<svg id="fd454f1c-5506-44b8-874e-8814b8b2f70b" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 18"><defs><linearGradient id="f34d9569-2bd0-4002-8f16-3d01d8106cb5" x1="8.88" y1="12.21" x2="8.88" y2="0.21" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#0078d4"/><stop offset="0.82" stop-color="#5ea0ef"/></linearGradient><linearGradient id="bdb45a0b-eb58-4970-a60a-fb2ce314f866" x1="8.88" y1="16.84" x2="8.88" y2="12.21" gradientUnits="userSpaceOnUse"><stop offset="0.15" stop-color="#ccc"/><stop offset="1" stop-color="#707070"/></linearGradient></defs><title>Icon-compute-21</title><rect x="-0.12" y="0.21" width="18" height="12" rx="0.6" fill="url(#f34d9569-2bd0-4002-8f16-3d01d8106cb5)"/><polygon points="11.88 4.46 11.88 7.95 8.88 9.71 8.88 6.21 11.88 4.46" fill="#50e6ff"/><polygon points="11.88 4.46 8.88 6.22 5.88 4.46 8.88 2.71 11.88 4.46" fill="#c3f1ff"/><polygon points="8.88 6.22 8.88 9.71 5.88 7.95 5.88 4.46 8.88 6.22" fill="#9cebff"/><polygon points="5.88 7.95 8.88 6.21 8.88 9.71 5.88 7.95" fill="#c3f1ff"/><polygon points="11.88 7.95 8.88 6.21 8.88 9.71 11.88 7.95" fill="#9cebff"/><path d="M12.49,15.84c-1.78-.28-1.85-1.56-1.85-3.63H7.11c0,2.07-.06,3.35-1.84,3.63a1,1,0,0,0-.89,1h9A1,1,0,0,0,12.49,15.84Z" fill="url(#bdb45a0b-eb58-4970-a60a-fb2ce314f866)"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1,3 @@
### Monitor Azure Virtual Machines with SigNoz
Collect key Virtual Machines metrics and view them with an out of the box dashboard.

View File

@@ -27,7 +27,7 @@ var (
// Azure services.
AzureServiceStorageAccountsBlob = ServiceID{valuer.NewString("storageaccountsblob")}
AzureServiceCDNProfile = ServiceID{valuer.NewString("cdnprofile")}
AzureServiceContainerApp = ServiceID{valuer.NewString("containerapp")}
AzureServiceVirtualMachine = ServiceID{valuer.NewString("virtualmachine")}
)
func (ServiceID) Enum() []any {
@@ -47,7 +47,7 @@ func (ServiceID) Enum() []any {
AWSServiceSQS,
AzureServiceStorageAccountsBlob,
AzureServiceCDNProfile,
AzureServiceContainerApp,
AzureServiceVirtualMachine,
}
}
@@ -71,7 +71,7 @@ var SupportedServices = map[CloudProviderType][]ServiceID{
CloudProviderTypeAzure: {
AzureServiceStorageAccountsBlob,
AzureServiceCDNProfile,
AzureServiceContainerApp,
AzureServiceVirtualMachine,
},
}