Compare commits

..

2 Commits

Author SHA1 Message Date
Gaurav Tewari
d48dbec881 fix: minor issue 2026-06-03 14:06:04 +05:30
Gaurav Tewari
a81f66b995 fix: ui migration issues 2026-06-03 13:48:39 +05:30
145 changed files with 332 additions and 6837 deletions

View File

@@ -432,7 +432,7 @@ cloudintegration:
version: v0.0.8
##################### Trace Detail #####################
traces:
tracedetail:
waterfall:
# Number of spans returned per request when the trace is too large to show all at once.
span_page_size: 500

View File

@@ -190,7 +190,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.127.0
image: signoz/signoz:v0.126.1
ports:
- "8080:8080" # signoz port
# - "6060:6060" # pprof port
@@ -213,7 +213,7 @@ services:
retries: 3
otel-collector:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:v0.144.5
image: signoz/signoz-otel-collector:v0.144.4
entrypoint:
- /bin/sh
command:
@@ -241,7 +241,7 @@ services:
replicas: 3
signoz-telemetrystore-migrator:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:v0.144.5
image: signoz/signoz-otel-collector:v0.144.4
environment:
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_DSN=tcp://clickhouse:9000
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_CLUSTER=cluster

View File

@@ -117,7 +117,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.127.0
image: signoz/signoz:v0.126.1
ports:
- "8080:8080" # signoz port
volumes:
@@ -139,7 +139,7 @@ services:
retries: 3
otel-collector:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:v0.144.5
image: signoz/signoz-otel-collector:v0.144.4
entrypoint:
- /bin/sh
command:
@@ -167,7 +167,7 @@ services:
replicas: 3
signoz-telemetrystore-migrator:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:v0.144.5
image: signoz/signoz-otel-collector:v0.144.4
environment:
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_DSN=tcp://clickhouse:9000
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_CLUSTER=cluster

View File

@@ -181,7 +181,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.127.0}
image: signoz/signoz:${VERSION:-v0.126.1}
container_name: signoz
ports:
- "8080:8080" # signoz port
@@ -204,7 +204,7 @@ services:
retries: 3
otel-collector:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.5}
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.4}
container_name: signoz-otel-collector
entrypoint:
- /bin/sh
@@ -229,7 +229,7 @@ services:
- "4318:4318" # OTLP HTTP receiver
signoz-telemetrystore-migrator:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.5}
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.4}
container_name: signoz-telemetrystore-migrator
environment:
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_DSN=tcp://clickhouse:9000

View File

@@ -109,7 +109,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.127.0}
image: signoz/signoz:${VERSION:-v0.126.1}
container_name: signoz
ports:
- "8080:8080" # signoz port
@@ -132,7 +132,7 @@ services:
retries: 3
otel-collector:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.5}
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.4}
container_name: signoz-otel-collector
entrypoint:
- /bin/sh
@@ -157,7 +157,7 @@ services:
- "4318:4318" # OTLP HTTP receiver
signoz-telemetrystore-migrator:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.5}
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.4}
container_name: signoz-telemetrystore-migrator
environment:
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_DSN=tcp://clickhouse:9000

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

@@ -349,7 +349,7 @@ function convertV5DataByType(
*/
// eslint-disable-next-line sonarjs/cognitive-complexity
export function convertV5ResponseToLegacy(
v5Response: SuccessResponse<MetricRangePayloadV5, QueryRangeRequestV5>,
v5Response: SuccessResponse<MetricRangePayloadV5>,
legendMap: Record<string, string>,
formatForWeb?: boolean,
): SuccessResponse<MetricRangePayloadV3> & { warning?: Warning } {
@@ -357,7 +357,7 @@ export function convertV5ResponseToLegacy(
const v5Data = payload?.data;
const aggregationPerQuery =
params?.compositeQuery?.queries
(params as QueryRangeRequestV5)?.compositeQuery?.queries
?.filter((query) => query.type === 'builder_query')
.reduce(
(acc, query) => {

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,7 +1,8 @@
import { renderHook } from '@testing-library/react';
import { UseQueryResult } from 'react-query';
import { SuccessResponse } from 'types/api';
import { Widgets } from 'types/api/dashboard/getAll';
import { MetricQueryRangeSuccessResponse } from 'types/api/metrics/getQueryRange';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { usePanelContextMenu } from '../usePanelContextMenu';
@@ -46,7 +47,10 @@ const mockWidget = { id: 'w-1', query: {} } as unknown as Widgets;
const mockQueryResponse = {
data: undefined,
isLoading: false,
} as unknown as UseQueryResult<MetricQueryRangeSuccessResponse, Error>;
} as unknown as UseQueryResult<
SuccessResponse<MetricRangePayloadProps, unknown>,
Error
>;
describe('usePanelContextMenu', () => {
beforeEach(() => {

View File

@@ -10,13 +10,17 @@ import {
PopoverPosition,
useCoordinates,
} from 'periscope/components/ContextMenu';
import { SuccessResponse } from 'types/api';
import { Widgets } from 'types/api/dashboard/getAll';
import { MetricQueryRangeSuccessResponse } from 'types/api/metrics/getQueryRange';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { DataSource } from 'types/common/queryBuilder';
interface UseTimeSeriesContextMenuParams {
widget: Widgets;
queryResponse: UseQueryResult<MetricQueryRangeSuccessResponse, Error>;
queryResponse: UseQueryResult<
SuccessResponse<MetricRangePayloadProps, unknown>,
Error
>;
enableDrillDown?: boolean;
}

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

@@ -5,11 +5,9 @@ import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import { RowData } from 'lib/query/createTableColumnsFromQuery';
import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin';
import { IDashboardVariables } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreTypes';
import { SuccessResponse } from 'types/api';
import { Widgets } from 'types/api/dashboard/getAll';
import {
MetricQueryRangeSuccessResponse,
MetricRangePayloadProps,
} from 'types/api/metrics/getQueryRange';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { QueryData } from 'types/api/widgets/getQuery';
import uPlot from 'uplot';
@@ -23,7 +21,10 @@ export interface GraphVisibilityLegendEntryProps {
export interface WidgetGraphComponentProps {
widget: Widgets;
queryResponse: UseQueryResult<MetricQueryRangeSuccessResponse, Error>;
queryResponse: UseQueryResult<
SuccessResponse<MetricRangePayloadProps, unknown>,
Error
>;
errorMessage: string | undefined;
version?: string;
threshold?: ReactNode;

View File

@@ -1,7 +1,8 @@
import { UseQueryResult } from 'react-query';
import { ThresholdProps } from 'container/NewWidget/RightContainer/Threshold/types';
import { SuccessResponse } from 'types/api';
import { ContextLinksData, Widgets } from 'types/api/dashboard/getAll';
import { MetricQueryRangeSuccessResponse } from 'types/api/metrics/getQueryRange';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import uPlot from 'uplot';
export type GridValueComponentProps = {
@@ -12,7 +13,10 @@ export type GridValueComponentProps = {
thresholds?: ThresholdProps[];
// Context menu related props
widget?: Widgets;
queryResponse?: UseQueryResult<MetricQueryRangeSuccessResponse, Error>;
queryResponse?: UseQueryResult<
SuccessResponse<MetricRangePayloadProps, unknown>,
Error
>;
contextLinks?: ContextLinksData;
enableDrillDown?: boolean;
};

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

@@ -28,8 +28,9 @@ import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import GetMinMax from 'lib/getMinMax';
import getTimeString from 'lib/getTimeString';
import { UpdateTimeInterval } from 'store/actions';
import { SuccessResponse } from 'types/api';
import { Widgets } from 'types/api/dashboard/getAll';
import { MetricQueryRangeSuccessResponse } from 'types/api/metrics/getQueryRange';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { DataSource } from 'types/common/queryBuilder';
function WidgetGraph({
@@ -201,7 +202,10 @@ function WidgetGraph({
interface WidgetGraphProps {
selectedWidget: Widgets;
queryResponse: UseQueryResult<MetricQueryRangeSuccessResponse, Error>;
queryResponse: UseQueryResult<
SuccessResponse<MetricRangePayloadProps, unknown>,
Error
>;
setRequestData: Dispatch<SetStateAction<GetQueryResultsProps>>;
selectedGraph: PANEL_TYPES;
enableDrillDown?: boolean;

View File

@@ -1,12 +1,11 @@
import { QueryRangeRequestV5 } from 'api/v5/v5';
import { SuccessResponse } from 'types/api';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { Column, QueryData, QueryDataV3 } from 'types/api/widgets/getQuery';
// eslint-disable-next-line sonarjs/cognitive-complexity
export function populateMultipleResults(
responseData: SuccessResponse<MetricRangePayloadProps, QueryRangeRequestV5>,
): SuccessResponse<MetricRangePayloadProps, QueryRangeRequestV5> {
responseData: SuccessResponse<MetricRangePayloadProps, unknown>,
): SuccessResponse<MetricRangePayloadProps, unknown> {
const queryResults = responseData?.payload?.data?.newResult?.data?.result;
const allFormattedResults: QueryData[] = [];
@@ -67,19 +66,17 @@ export function populateMultipleResults(
}
// Create a copy instead of mutating the original
const updatedResponseData: SuccessResponse<
MetricRangePayloadProps,
QueryRangeRequestV5
> = {
...responseData,
payload: {
...responseData.payload,
data: {
...responseData.payload.data,
result: allFormattedResults,
const updatedResponseData: SuccessResponse<MetricRangePayloadProps, unknown> =
{
...responseData,
payload: {
...responseData.payload,
data: {
...responseData.payload.data,
result: allFormattedResults,
},
},
},
};
};
return updatedResponseData;
}

View File

@@ -52,6 +52,7 @@ import {
getSelectedWidgetIndex,
} from 'providers/Dashboard/util';
import { AppState } from 'store/reducers';
import { SuccessResponse } from 'types/api';
import {
ColumnUnit,
ContextLinksData,
@@ -60,7 +61,7 @@ import {
} from 'types/api/dashboard/getAll';
import { Props } from 'types/api/dashboard/update';
import { IField } from 'types/api/logs/fields';
import { MetricQueryRangeSuccessResponse } from 'types/api/metrics/getQueryRange';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
@@ -397,7 +398,7 @@ function NewWidget({
// State to hold query response for sharing between left and right containers
const [queryResponse, setQueryResponse] = useState<
UseQueryResult<MetricQueryRangeSuccessResponse, Error>
UseQueryResult<SuccessResponse<MetricRangePayloadProps, unknown>, Error>
>(null as any);
// request data should be handled by the parent and the child components should consume the same

View File

@@ -2,8 +2,9 @@ import { Dispatch, SetStateAction } from 'react';
import { UseQueryResult } from 'react-query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import { SuccessResponse, Warning } from 'types/api';
import { Dashboard, Widgets } from 'types/api/dashboard/getAll';
import { MetricQueryRangeSuccessResponse } from 'types/api/metrics/getQueryRange';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { timePreferance } from './RightContainer/timeItems';
@@ -28,7 +29,9 @@ export interface WidgetGraphProps {
setRequestData: Dispatch<SetStateAction<GetQueryResultsProps>>;
isLoadingPanelData: boolean;
setQueryResponse?: Dispatch<
SetStateAction<UseQueryResult<MetricQueryRangeSuccessResponse, Error>>
SetStateAction<
UseQueryResult<SuccessResponse<MetricRangePayloadProps, unknown>, Error>
>
>;
enableDrillDown?: boolean;
dashboardData: Dashboard | undefined;
@@ -36,7 +39,12 @@ export interface WidgetGraphProps {
}
export type WidgetGraphContainerProps = {
queryResponse: UseQueryResult<MetricQueryRangeSuccessResponse, Error>;
queryResponse: UseQueryResult<
SuccessResponse<MetricRangePayloadProps, unknown> & {
warning?: Warning;
},
Error
>;
setRequestData: Dispatch<SetStateAction<GetQueryResultsProps>>;
selectedGraph: PANEL_TYPES;
selectedWidget: Widgets;

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

@@ -1,6 +1,7 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
import GridTableComponent from 'container/GridTableComponent';
import { GRID_TABLE_CONFIG } from 'container/GridTableComponent/config';
import { QueryRangeRequestV5 } from 'types/api/v5/queryRange';
import { PanelWrapperProps } from './panelWrapper.types';
@@ -19,7 +20,7 @@ function TablePanelWrapper({
(queryResponse.data?.payload?.data?.result?.[0] as any)?.table || [];
const { thresholds } = widget;
const queryRangeRequest = queryResponse.data?.params;
const queryRangeRequest = queryResponse.data?.params as QueryRangeRequestV5;
return (
<GridTableComponent

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

@@ -233,8 +233,10 @@
background: var(--l1-background);
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
padding: 0px;
gap: 0;
margin: 4px;
.ant-typography {
.qb-tag-text {
color: var(--l1-foreground);
font-family: Inter;
font-size: 14px !important;
@@ -244,7 +246,7 @@
padding: 2px 6px;
}
.close-icon {
> button {
display: flex;
align-items: center;
justify-content: center;
@@ -259,26 +261,26 @@
&.resource {
border: 1px solid color-mix(in srgb, var(--bg-aqua-400) 13%, transparent);
.ant-typography {
.qb-tag-text {
color: var(--bg-aqua-400);
background: color-mix(in srgb, var(--bg-aqua-400) 6%, transparent);
font-size: 14px;
}
.close-icon {
> button {
background: color-mix(in srgb, var(--bg-aqua-400) 6%, transparent);
}
}
&.tag {
border: 1px solid color-mix(in srgb, var(--bg-sienna-400) 20%, transparent);
.ant-typography {
.qb-tag-text {
color: var(--bg-sienna-400);
background: color-mix(in srgb, var(--bg-sienna-400) 10%, transparent);
font-size: 14px;
}
.close-icon {
> button {
background: color-mix(in srgb, var(--bg-sienna-400) 10%, transparent);
}
}
@@ -286,13 +288,13 @@
&.scope {
border: 1px solid color-mix(in srgb, var(--bg-robin-400) 20%, transparent);
.ant-typography {
.qb-tag-text {
color: var(--bg-robin-400);
background: color-mix(in srgb, var(--bg-robin-400) 10%, transparent);
font-size: 14px;
}
.close-icon {
> button {
background: color-mix(in srgb, var(--bg-robin-400) 10%, transparent);
}
}

View File

@@ -966,6 +966,7 @@ function QueryBuilderSearchV2(
>
<Tooltip title={chipValue}>
<TypographyText
className="qb-tag-text"
$isInNin={isInNin}
$isEnabled={!!searchValue}
onClick={(): void => {

View File

@@ -2,8 +2,9 @@ import { useMemo } from 'react';
import { UseQueryResult } from 'react-query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import useDashboardVarConfig from 'container/QueryTable/Drilldown/useDashboardVarConfig';
import { SuccessResponse } from 'types/api';
import { ContextLinksData } from 'types/api/dashboard/getAll';
import { MetricQueryRangeSuccessResponse } from 'types/api/metrics/getQueryRange';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { getTimeRange } from 'utils/getTimeRange';
@@ -43,7 +44,10 @@ const useAggregateDrilldown = ({
aggregateData: AggregateData | null;
contextLinks?: ContextLinksData;
panelType?: PANEL_TYPES;
queryRange?: UseQueryResult<MetricQueryRangeSuccessResponse, Error>;
queryRange?: UseQueryResult<
SuccessResponse<MetricRangePayloadProps, unknown>,
Error
>;
}): {
aggregateDrilldownConfig: {
header?: string | React.ReactNode;

View File

@@ -2,8 +2,9 @@ import { useMemo } from 'react';
import { UseQueryResult } from 'react-query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
import { SuccessResponse } from 'types/api';
import { ContextLinksData } from 'types/api/dashboard/getAll';
import { MetricQueryRangeSuccessResponse } from 'types/api/metrics/getQueryRange';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { isValidQueryName } from './drilldownUtils';
@@ -19,7 +20,10 @@ interface UseGraphContextMenuProps {
setSubMenu: (subMenu: string) => void;
contextLinks?: ContextLinksData;
panelType?: PANEL_TYPES;
queryRange?: UseQueryResult<MetricQueryRangeSuccessResponse, Error>;
queryRange?: UseQueryResult<
SuccessResponse<MetricRangePayloadProps, unknown>,
Error
>;
}
export function useGraphContextMenu({

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

@@ -7,7 +7,8 @@ import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import { AppState } from 'store/reducers';
import { MetricQueryRangeSuccessResponse } from 'types/api/metrics/getQueryRange';
import { SuccessResponse, Warning } from 'types/api';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { GlobalReducer } from 'types/reducer/globalTime';
@@ -18,13 +19,16 @@ export const useGetExplorerQueryRange = (
requestData: Query | null,
panelType: PANEL_TYPES | null,
version: string,
options?: UseQueryOptions<MetricQueryRangeSuccessResponse, Error>,
options?: UseQueryOptions<SuccessResponse<MetricRangePayloadProps>, Error>,
params?: Record<string, unknown>,
isDependentOnQB = true,
keyRef?: MutableRefObject<any>,
headers?: Record<string, string>,
selectedTimeInterval?: GetQueryResultsProps['globalSelectedInterval'],
): UseQueryResult<MetricQueryRangeSuccessResponse, Error> => {
): UseQueryResult<
SuccessResponse<MetricRangePayloadProps> & { warning?: Warning },
Error
> => {
const { isEnabledQuery } = useQueryBuilder();
const {
selectedTime: globalSelectedInterval,

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

@@ -1,5 +0,0 @@
.container {
display: flex;
flex-direction: column;
height: 100%;
}

View File

@@ -1,198 +0,0 @@
import { useEffect, useState } from 'react';
import { FullScreenHandle } from 'react-full-screen';
import { useTranslation } from 'react-i18next';
import { useCopyToClipboard } from 'react-use';
import {
ClipboardCopy,
Ellipsis,
FileJson,
Fullscreen,
LockKeyhole,
PenLine,
Plus,
} from '@signozhq/icons';
import { Popover } from 'antd';
import { Button } from '@signozhq/ui/button';
import { toast } from '@signozhq/ui/sonner';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import { DeleteButton } from 'container/ListOfDashboard/TableComponents/DeleteButton';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import { useAppContext } from 'providers/App/App';
import { USER_ROLES } from 'types/roles';
import styles from '../DashboardDescription.module.scss';
interface Props {
dashboard: DashboardtypesGettableDashboardV2DTO;
handle: FullScreenHandle;
isDashboardLocked: boolean;
editDashboard: boolean;
isAuthor: boolean;
addPanelPermission: boolean;
onAddPanel: () => void;
onLockToggle: () => void;
onOpenRename: () => void;
}
function DashboardActions({
dashboard,
handle,
isDashboardLocked,
editDashboard,
isAuthor,
addPanelPermission,
onAddPanel,
onLockToggle,
onOpenRename,
}: Props): JSX.Element {
const { user } = useAppContext();
const { t } = useTranslation(['dashboard', 'common']);
const id = dashboard.id;
const title = dashboard.spec?.display?.name ?? '';
const [isDashboardSettingsOpen, setIsDashboardSettingsOpen] =
useState<boolean>(false);
const [state, setCopy] = useCopyToClipboard();
useEffect(() => {
if (state.error) {
toast.error(t('something_went_wrong', { ns: 'common' }));
}
if (state.value) {
toast.success(t('success', { ns: 'common' }));
}
}, [state.error, state.value, t]);
const dashboardDataJSON = (): string => JSON.stringify(dashboard, null, 2);
const exportJSON = (): void => {
const blob = new Blob([dashboardDataJSON()], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${title || 'dashboard'}.json`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
return (
<div className={styles.rightSection}>
<DateTimeSelectionV2 showAutoRefresh hideShareModal />
<Popover
open={isDashboardSettingsOpen}
arrow={false}
onOpenChange={(visible): void => setIsDashboardSettingsOpen(visible)}
rootClassName={styles.dashboardSettings}
content={
<div className={styles.menuContent}>
<section className={styles.section1}>
{(isAuthor || user.role === USER_ROLES.ADMIN) && (
<TooltipSimple
title={
dashboard.createdBy === 'integration'
? 'Dashboards created by integrations cannot be unlocked'
: ''
}
>
<Button
variant="ghost"
prefix={<LockKeyhole size={14} />}
disabled={dashboard.createdBy === 'integration'}
onClick={(): void => {
setIsDashboardSettingsOpen(false);
onLockToggle();
}}
testId="lock-unlock-dashboard"
>
{isDashboardLocked ? 'Unlock Dashboard' : 'Lock Dashboard'}
</Button>
</TooltipSimple>
)}
{!isDashboardLocked && editDashboard && (
<Button
variant="ghost"
prefix={<PenLine size={14} />}
onClick={(): void => {
onOpenRename();
setIsDashboardSettingsOpen(false);
}}
>
Rename
</Button>
)}
<Button
variant="ghost"
prefix={<Fullscreen size={14} />}
onClick={handle.enter}
>
Full screen
</Button>
</section>
<section className={styles.section2}>
<Button
variant="ghost"
prefix={<FileJson size={14} />}
onClick={(): void => {
exportJSON();
setIsDashboardSettingsOpen(false);
}}
>
Export JSON
</Button>
<Button
variant="ghost"
prefix={<ClipboardCopy size={14} />}
onClick={(): void => {
setCopy(dashboardDataJSON());
setIsDashboardSettingsOpen(false);
}}
>
Copy as JSON
</Button>
</section>
<section className={styles.deleteDashboard}>
<DeleteButton
createdBy={dashboard.createdBy || ''}
name={title}
id={id}
isLocked={isDashboardLocked}
routeToListPage
/>
</section>
</div>
}
trigger="click"
placement="bottomRight"
>
<Button
variant="ghost"
size="icon"
prefix={<Ellipsis size={14} />}
className={styles.icons}
testId="options"
/>
</Popover>
{!isDashboardLocked && addPanelPermission && (
<Button
variant="solid"
color="primary"
className={styles.addPanelBtn}
onClick={onAddPanel}
prefix={<Plus size="md" />}
testId="add-panel-header"
>
New Panel
</Button>
)}
</div>
);
}
export default DashboardActions;

View File

@@ -1,303 +0,0 @@
.dashboardDescriptionContainer {
box-shadow: none;
border: none;
background: unset;
color: var(--l2-foreground);
:global(.ant-card-body) {
padding: 0px;
}
.dashboardDetails {
display: flex;
justify-content: space-between;
gap: 8px;
padding: 16px 16px 0px 16px;
align-items: flex-start;
.leftSection {
display: flex;
align-items: center;
gap: 8px;
width: 45%;
.dashboardImg {
height: 16px;
width: 16px;
}
.dashboardTitle {
color: var(--l1-foreground);
font-family: Inter;
font-size: 16px;
font-style: normal;
font-weight: 500;
line-height: 24px; /* 150% */
letter-spacing: -0.08px;
max-width: 80%;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.publicDashboardIcon {
margin-right: 4px;
}
}
.rightSection {
display: flex;
width: 55%;
justify-content: flex-end;
flex-wrap: wrap;
align-items: center;
gap: 14px;
.icons {
display: flex;
align-items: center;
width: 32px;
height: 34px;
padding: 6px;
justify-content: center;
border-radius: 2px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
color: var(--l2-foreground);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 10px; /* 83.333% */
letter-spacing: 0.12px;
}
.icons:hover {
background-color: unset;
}
.configureButton {
display: flex;
align-items: center;
width: 93px;
height: 34px;
padding: 6px;
justify-content: center;
border-radius: 2px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
color: var(--l2-foreground);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 10px; /* 83.333% */
letter-spacing: 0.12px;
}
.addPanelBtn {
display: flex;
width: 119px;
height: 34px;
padding: 5.937px 11.875px;
justify-content: center;
align-items: center;
color: var(--primary-foreground);
background: var(--primary-background);
font-family: Inter;
font-size: 11.875px;
font-style: normal;
font-weight: 500;
line-height: 17.812px; /* 150% */
}
}
}
.dashboardTags {
display: flex;
gap: 6px;
padding: 16px 16px 0px 16px;
flex-wrap: wrap;
.tag {
display: flex;
padding: 4px 8px;
justify-content: center;
align-items: center;
border-radius: 20px;
border: 1px solid color-mix(in srgb, var(--bg-sienna-500) 20%, transparent);
background: color-mix(in srgb, var(--bg-sienna-500) 10%, transparent);
color: var(--bg-sienna-400);
text-align: center;
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 500;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
margin-inline-end: 0px;
}
}
.dashboardDescriptionSection {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 22px; /* 157.143% */
letter-spacing: -0.07px;
padding: 20px 16px 0px 16px;
}
}
.dashboardSettings {
width: 191px;
height: 302px;
flex-shrink: 0;
:global(.ant-popover-inner) {
padding: 0px;
border-radius: 4px;
border: 1px solid var(--l1-border);
background: linear-gradient(
139deg,
color-mix(in srgb, var(--card) 80%, transparent) 0%,
color-mix(in srgb, var(--card) 90%, transparent) 98.68%
) !important;
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(20px);
}
.menuContent {
display: flex;
flex-direction: column;
section {
display: flex;
flex-direction: column;
align-items: start;
button {
display: flex;
width: 100%;
height: unset;
padding: 8px;
align-items: center;
gap: 12px;
color: var(--l2-foreground);
font-family: Inter;
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: normal;
letter-spacing: 0.14px;
border-top: none;
}
}
.section1,
.section2 {
border-bottom: 1px solid var(--l1-border);
}
.deleteDashboard button {
color: var(--bg-cherry-400) !important;
}
}
}
.renameDashboard {
:global(.ant-modal-content) {
width: 384px;
flex-shrink: 0;
border-radius: 4px;
border: 1px solid var(--l1-border);
background: var(--l2-background);
box-shadow: 0px -4px 16px 2px rgba(0, 0, 0, 0.2);
padding: 0px;
:global(.ant-modal-header) {
height: 52px;
padding: 16px;
background: var(--l2-background);
border-bottom: 1px solid var(--l1-border);
margin-bottom: 0px;
:global(.ant-modal-title) {
color: var(--l1-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
width: 349px;
height: 20px;
}
}
:global(.ant-modal-body) {
padding: 16px;
.dashboardContent {
display: flex;
flex-direction: column;
gap: 8px;
.nameText {
color: var(--l1-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 500;
line-height: 20px; /* 142.857% */
}
.dashboardNameInput {
display: flex;
padding: 6px 6px 6px 8px;
align-items: center;
gap: 4px;
align-self: stretch;
border-radius: 0px 2px 2px 0px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
}
}
}
:global(.ant-modal-footer) {
padding: 16px;
margin-top: 0px;
.dashboardRename {
display: flex;
flex-direction: row-reverse;
gap: 12px;
.cancelBtn {
display: flex;
padding: 4px 8px;
justify-content: center;
align-items: center;
gap: 4px;
border-radius: 2px;
background: var(--l1-border);
}
.renameBtn {
display: flex;
align-items: center;
width: 169px;
padding: 4px 8px;
justify-content: center;
gap: 4px;
border-radius: 2px;
background: var(--primary-background);
}
}
}
}
}

View File

@@ -1,32 +0,0 @@
import { Badge } from '@signozhq/ui/badge';
import { isEmpty } from 'lodash-es';
import styles from '../DashboardDescription.module.scss';
interface Props {
tags: string[];
description: string;
}
function DashboardMeta({ tags, description }: Props): JSX.Element {
return (
<>
{tags.length > 0 && (
<div className={styles.dashboardTags}>
{tags.map((tag) => (
<Badge key={tag} className={styles.tag}>
{tag}
</Badge>
))}
</div>
)}
{!isEmpty(description) && (
<section className={styles.dashboardDescriptionSection}>
{description}
</section>
)}
</>
);
}
export default DashboardMeta;

View File

@@ -1,47 +0,0 @@
import { Globe, LockKeyhole } from '@signozhq/icons';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import styles from '../DashboardDescription.module.scss';
interface Props {
title: string;
image: string;
isPublicDashboard: boolean;
isDashboardLocked: boolean;
}
function DashboardTitle({
title,
image,
isPublicDashboard,
isDashboardLocked,
}: Props): JSX.Element {
return (
<div className={styles.leftSection}>
<img src={image} alt="dashboard-img" className={styles.dashboardImg} />
<TooltipSimple title={title.length > 30 ? title : ''}>
<Typography.Text
className={styles.dashboardTitle}
data-testid="dashboard-title"
>
{title}
</Typography.Text>
</TooltipSimple>
{isPublicDashboard && (
<TooltipSimple title="This dashboard is publicly accessible">
<Globe size={14} className={styles.publicDashboardIcon} />
</TooltipSimple>
)}
{isDashboardLocked && (
<TooltipSimple title="This dashboard is locked">
<LockKeyhole size={14} />
</TooltipSimple>
)}
</div>
);
}
export default DashboardTitle;

View File

@@ -1,70 +0,0 @@
import { Input, Modal } from 'antd';
import { Button } from '@signozhq/ui/button';
import { Check, X } from '@signozhq/icons';
import { Typography } from '@signozhq/ui/typography';
import styles from '../DashboardDescription.module.scss';
interface Props {
open: boolean;
value: string;
isLoading: boolean;
onChange: (value: string) => void;
onRename: () => void;
onClose: () => void;
}
function RenameDashboardModal({
open,
value,
isLoading,
onChange,
onRename,
onClose,
}: Props): JSX.Element {
return (
<Modal
open={open}
title="Rename Dashboard"
onOk={onRename}
onCancel={onClose}
rootClassName={styles.renameDashboard}
footer={
<div className={styles.dashboardRename}>
<Button
variant="solid"
color="primary"
prefix={<Check size={14} />}
className={styles.renameBtn}
onClick={onRename}
disabled={isLoading}
>
Rename Dashboard
</Button>
<Button
variant="ghost"
prefix={<X size={14} />}
className={styles.cancelBtn}
onClick={onClose}
>
Cancel
</Button>
</div>
}
>
<div className={styles.dashboardContent}>
<Typography.Text className={styles.nameText}>
Enter a new name
</Typography.Text>
<Input
data-testid="dashboard-name"
className={styles.dashboardNameInput}
value={value}
onChange={(e): void => onChange(e.target.value)}
/>
</div>
</Modal>
);
}
export default RenameDashboardModal;

View File

@@ -1,159 +0,0 @@
import { useEffect, useMemo, useState } from 'react';
import { FullScreenHandle } from 'react-full-screen';
import { Card } from 'antd';
import { toast } from '@signozhq/ui/sonner';
import logEvent from 'api/common/logEvent';
import {
lockDashboardV2,
patchDashboardV2,
unlockDashboardV2,
} from 'api/generated/services/dashboard';
import type {
DashboardtypesGettableDashboardV2DTO,
DashboardtypesJSONPatchOperationDTO,
} from 'api/generated/services/sigNoz.schemas';
import { Base64Icons } from 'container/DashboardContainer/DashboardSettings/General/utils';
import useComponentPermission from 'hooks/useComponentPermission';
import { useAppContext } from 'providers/App/App';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import DashboardHeader from '../components/DashboardHeader/DashboardHeader';
import DashboardActions from './DashboardActions/DashboardActions';
import DashboardMeta from './DashboardMeta/DashboardMeta';
import DashboardTitle from './DashboardTitle/DashboardTitle';
import RenameDashboardModal from './RenameDashboardModal/RenameDashboardModal';
import styles from './DashboardDescription.module.scss';
interface DashboardDescriptionProps {
dashboard: DashboardtypesGettableDashboardV2DTO;
handle: FullScreenHandle;
refetch: () => void;
}
function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
const { dashboard, handle, refetch } = props;
const id = dashboard.id;
const isDashboardLocked = !!dashboard.locked;
const title = dashboard.spec?.display?.name ?? '';
const description = dashboard.spec?.display?.description ?? '';
const image = dashboard.image || Base64Icons[0];
const tags = useMemo(
() =>
(dashboard.tags ?? []).map((t) =>
t.key === t.value ? t.key : `${t.key}:${t.value}`,
),
[dashboard.tags],
);
const { user } = useAppContext();
const [editDashboard] = useComponentPermission(['edit_dashboard'], user.role);
const { showErrorModal } = useErrorModal();
const isAuthor =
!!user?.email && !!dashboard.createdBy && dashboard.createdBy === user.email;
const addPanelPermission = !isDashboardLocked;
// V2 public dashboard wiring lives separately; treat as not-public for chrome.
const isPublicDashboard = false;
const [isRenameDashboardOpen, setIsRenameDashboardOpen] =
useState<boolean>(false);
const [updatedTitle, setUpdatedTitle] = useState<string>(title);
const [isRenameLoading, setIsRenameLoading] = useState<boolean>(false);
useEffect(() => {
setUpdatedTitle(title);
}, [title]);
const handleLockDashboardToggle = async (): Promise<void> => {
if (!id) {
return;
}
try {
if (isDashboardLocked) {
await unlockDashboardV2({ id });
toast.success('Dashboard unlocked');
} else {
await lockDashboardV2({ id });
toast.success('Dashboard locked');
}
refetch();
} catch (error) {
showErrorModal(error as APIError);
}
};
const onNameChangeHandler = async (): Promise<void> => {
const trimmed = updatedTitle.trim();
if (!id || !trimmed || trimmed === title) {
setIsRenameDashboardOpen(false);
return;
}
try {
setIsRenameLoading(true);
const patch: DashboardtypesJSONPatchOperationDTO[] = [
{
op: 'replace' as DashboardtypesJSONPatchOperationDTO['op'],
path: '/spec/display/name',
value: trimmed,
},
];
await patchDashboardV2({ id }, patch);
toast.success('Dashboard renamed successfully');
setIsRenameDashboardOpen(false);
refetch();
} catch (error) {
showErrorModal(error as APIError);
setIsRenameDashboardOpen(true);
} finally {
setIsRenameLoading(false);
}
};
const onEmptyWidgetHandler = (): void => {
void logEvent('Dashboard Detail V2: Add new panel clicked', {
dashboardId: id,
});
toast.info('V2 panel editor coming next');
};
return (
<Card className={styles.dashboardDescriptionContainer}>
<DashboardHeader title={title} image={image} />
<section className={styles.dashboardDetails}>
<DashboardTitle
title={title}
image={image}
isPublicDashboard={isPublicDashboard}
isDashboardLocked={isDashboardLocked}
/>
<DashboardActions
dashboard={dashboard}
handle={handle}
isDashboardLocked={isDashboardLocked}
editDashboard={editDashboard}
isAuthor={isAuthor}
addPanelPermission={addPanelPermission}
onAddPanel={onEmptyWidgetHandler}
onLockToggle={handleLockDashboardToggle}
onOpenRename={(): void => setIsRenameDashboardOpen(true)}
/>
</section>
<DashboardMeta tags={tags} description={description} />
<RenameDashboardModal
open={isRenameDashboardOpen}
value={updatedTitle}
isLoading={isRenameLoading}
onChange={setUpdatedTitle}
onRename={onNameChangeHandler}
onClose={(): void => setIsRenameDashboardOpen(false)}
/>
</Card>
);
}
export default DashboardDescription;

View File

@@ -1,52 +0,0 @@
.panel {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
background: var(--bg-ink-400, #0b0c0e);
border: 1px solid var(--bg-slate-400, #1d212d);
border-radius: 4px;
overflow: hidden;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border-bottom: 1px solid var(--bg-slate-400, #1d212d);
cursor: grab;
}
.headerLeft {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.headerTitle {
margin: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.badge {
margin-inline-end: 0;
}
.body {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 12px;
color: var(--bg-vanilla-400, #8993ae);
font-size: 12px;
text-align: center;
}
.bodyKind {
margin-bottom: 6px;
}

View File

@@ -1,94 +0,0 @@
import { useMemo } from 'react';
import { Badge } from '@signozhq/ui/badge';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import { EllipsisVertical } from '@signozhq/icons';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import cx from 'classnames';
import type { DashboardSection } from '../../utils';
import type { DeletePanelArgs } from './hooks/useDeletePanel';
import type { MovePanelArgs } from './hooks/useMovePanelToSection';
import PanelActionsMenu from './PanelActionsMenu/PanelActionsMenu';
import styles from './Panel.module.scss';
interface Props {
panel: DashboardtypesPanelDTO | undefined;
panelId: string;
/**
* Placeholder: true once this panel's section enters the viewport. The panel
* query-loading implementation (later PR) will consume this to lazily fetch
* data. Currently unused on purpose.
*/
isVisible?: boolean;
/** Section actions — present only in editable sectioned mode. */
currentLayoutIndex?: number;
sections?: DashboardSection[];
onMovePanel?: (args: MovePanelArgs) => void;
onDeletePanel?: (args: DeletePanelArgs) => void;
}
function Panel({
panel,
panelId,
isVisible,
currentLayoutIndex,
sections,
onMovePanel,
onDeletePanel,
}: Props): JSX.Element {
const name = panel?.spec?.display?.name || `Panel ${panelId.slice(0, 6)}`;
const description = panel?.spec?.display?.description;
const kind = panel?.spec?.plugin?.kind?.replace(/^signoz\//, '') ?? 'unknown';
const queryCount = panel?.spec?.queries?.length ?? 0;
const headerTitle = useMemo(() => {
if (!description) {
return name;
}
return (
<TooltipSimple title={description}>
<span>{name}</span>
</TooltipSimple>
);
}, [name, description]);
return (
<div
className={styles.panel}
data-panel-visible={isVisible ? 'true' : 'false'}
>
<div className={cx(styles.header, 'panel-drag-handle')}>
<div className={styles.headerLeft}>
<Typography.Text className={styles.headerTitle}>
{headerTitle}
</Typography.Text>
<Badge className={styles.badge}>{kind}</Badge>
</div>
{currentLayoutIndex !== undefined && (onMovePanel || onDeletePanel) ? (
<PanelActionsMenu
panelId={panelId}
currentLayoutIndex={currentLayoutIndex}
sections={sections ?? []}
onMovePanel={onMovePanel}
onDeletePanel={onDeletePanel}
/>
) : (
<EllipsisVertical size={14} />
)}
</div>
<div className={styles.body}>
<div>
<div className={styles.bodyKind}>{kind} panel</div>
<div>
{queryCount} {queryCount === 1 ? 'query' : 'queries'} · chart rendering
coming next
</div>
</div>
</div>
</div>
);
}
export default Panel;

View File

@@ -1,16 +0,0 @@
.trigger {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 2px;
background: transparent;
border: none;
border-radius: 2px;
color: var(--bg-vanilla-400, #8993ae);
cursor: pointer;
&:hover {
color: var(--bg-vanilla-100, #fff);
background: var(--bg-slate-400, #1d212d);
}
}

View File

@@ -1,95 +0,0 @@
import { useMemo } from 'react';
import { EllipsisVertical, FolderInput, Trash2 } from '@signozhq/icons';
import { DropdownMenuSimple } from '@signozhq/ui/dropdown-menu';
import type { MenuItem } from '@signozhq/ui/dropdown-menu';
import type { DashboardSection } from '../../../utils';
import type { DeletePanelArgs } from '../hooks/useDeletePanel';
import type { MovePanelArgs } from '../hooks/useMovePanelToSection';
import styles from './PanelActionsMenu.module.scss';
interface Props {
panelId: string;
currentLayoutIndex: number;
sections: DashboardSection[];
onMovePanel?: (args: MovePanelArgs) => void;
onDeletePanel?: (args: DeletePanelArgs) => void;
}
function PanelActionsMenu({
panelId,
currentLayoutIndex,
sections,
onMovePanel,
onDeletePanel,
}: Props): JSX.Element {
const items = useMemo<MenuItem[]>(() => {
const result: MenuItem[] = [];
if (onMovePanel) {
const targets = sections.filter(
(s) => s.title && s.layoutIndex !== currentLayoutIndex,
);
if (targets.length === 0) {
result.push({
key: 'move',
label: 'Move to section',
icon: <FolderInput size={14} />,
disabled: true,
});
} else {
result.push({
key: 'move',
label: 'Move to section',
icon: <FolderInput size={14} />,
children: targets.map((s) => ({
key: `move-${s.layoutIndex}`,
label: s.title,
onClick: (): void =>
onMovePanel({
panelId,
fromLayoutIndex: currentLayoutIndex,
toLayoutIndex: s.layoutIndex,
}),
})),
});
}
}
if (onDeletePanel) {
if (result.length > 0) {
result.push({ type: 'divider' });
}
result.push({
key: 'delete-panel',
danger: true,
icon: <Trash2 size={14} />,
label: 'Delete panel',
onClick: (): void =>
onDeletePanel({ panelId, layoutIndex: currentLayoutIndex }),
});
}
return result;
}, [sections, currentLayoutIndex, panelId, onMovePanel, onDeletePanel]);
return (
<DropdownMenuSimple menu={{ items }}>
<button
type="button"
className={styles.trigger}
aria-label="Panel actions"
data-testid={`panel-actions-${panelId}`}
// Stop pointer/mouse down from reaching the RGL drag handle this
// button lives inside, so opening the menu never starts a panel drag.
onPointerDown={(e): void => e.stopPropagation()}
onMouseDown={(e): void => e.stopPropagation()}
onClick={(e): void => e.stopPropagation()}
>
<EllipsisVertical size={14} />
</button>
</DropdownMenuSimple>
);
}
export default PanelActionsMenu;

View File

@@ -1,22 +0,0 @@
.grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
.typeButton {
display: flex;
align-items: center;
gap: 8px;
padding: 12px;
background: var(--bg-ink-400, #0b0c0e);
border: 1px solid var(--bg-slate-400, #1d212d);
border-radius: 4px;
color: var(--bg-vanilla-100, #fff);
cursor: pointer;
text-align: left;
&:hover {
border-color: var(--bg-robin-500);
}
}

View File

@@ -1,82 +0,0 @@
import { Modal } from 'antd';
import {
BarChart,
ChartLine,
ChartPie,
Hash,
List,
Table,
} from '@signozhq/icons';
import styles from './PanelTypeSelectionModal.module.scss';
interface PanelType {
pluginKind: string;
label: string;
icon: JSX.Element;
}
const PANEL_TYPES: PanelType[] = [
{
pluginKind: 'signoz/TimeSeriesPanel',
label: 'Time Series',
icon: <ChartLine size={16} />,
},
{ pluginKind: 'signoz/NumberPanel', label: 'Value', icon: <Hash size={16} /> },
{ pluginKind: 'signoz/TablePanel', label: 'Table', icon: <Table size={16} /> },
{
pluginKind: 'signoz/BarChartPanel',
label: 'Bar Chart',
icon: <BarChart size={16} />,
},
{
pluginKind: 'signoz/PieChartPanel',
label: 'Pie Chart',
icon: <ChartPie size={16} />,
},
{
pluginKind: 'signoz/HistogramPanel',
label: 'Histogram',
icon: <BarChart size={16} />,
},
{ pluginKind: 'signoz/ListPanel', label: 'List', icon: <List size={16} /> },
];
interface Props {
open: boolean;
onClose: () => void;
onSelect: (pluginKind: string) => void;
}
function PanelTypeSelectionModal({
open,
onClose,
onSelect,
}: Props): JSX.Element {
return (
<Modal
open={open}
title="Select a panel type"
onCancel={onClose}
footer={null}
destroyOnClose
>
<div className={styles.grid}>
{PANEL_TYPES.map((type) => (
<button
key={type.pluginKind}
type="button"
className={styles.typeButton}
data-testid={`panel-type-${type.pluginKind}`}
onClick={(): void => onSelect(type.pluginKind)}
>
{type.icon}
{type.label}
</button>
))}
</div>
</Modal>
);
}
export default PanelTypeSelectionModal;

View File

@@ -1,76 +0,0 @@
import { useCallback } from 'react';
import { v4 as uuid } from 'uuid';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import {
addPanelToSectionOps,
createDefaultPanel,
panelRef,
} from '../../../patchOps';
import { useDashboardStore } from '../../../store/useDashboardStore';
import type { DashboardSection } from '../../../utils';
interface Params {
sections: DashboardSection[];
}
export interface AddPanelArgs {
layoutIndex: number;
pluginKind: string;
}
/**
* Creates a new panel and places its item ref at the bottom of the target
* section, as one atomic patch. Structure-only: the panel is a valid minimal
* placeholder (its query is filled in once the panel editor lands).
*/
export function useAddPanelToSection({
sections,
}: Params): (args: AddPanelArgs) => Promise<void> {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const refetch = useDashboardStore((s) => s.refetch);
const { showErrorModal } = useErrorModal();
return useCallback(
async ({ layoutIndex, pluginKind }: AddPanelArgs): Promise<void> => {
if (!dashboardId) {
return;
}
const target = sections.find((s) => s.layoutIndex === layoutIndex);
if (!target) {
return;
}
const panelId = uuid();
const nextY = target.items.reduce(
(max, i) => Math.max(max, i.y + i.height),
0,
);
try {
await patchDashboardV2(
{ id: dashboardId },
addPanelToSectionOps({
panelId,
panel: createDefaultPanel(pluginKind),
layoutIndex,
item: {
x: 0,
y: nextY,
width: 6,
height: 6,
content: { $ref: panelRef(panelId) },
},
}),
);
refetch();
} catch (error) {
showErrorModal(error as APIError);
}
},
[sections, dashboardId, refetch, showErrorModal],
);
}

View File

@@ -1,54 +0,0 @@
import { useCallback } from 'react';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { removePanelOp, replaceSectionItemsOp } from '../../../patchOps';
import { useDashboardStore } from '../../../store/useDashboardStore';
import type { DashboardSection } from '../../../utils';
interface Params {
sections: DashboardSection[];
}
export interface DeletePanelArgs {
panelId: string;
layoutIndex: number;
}
/**
* Removes a panel: drops its item ref from the section's items and deletes the
* panel from `spec.panels`, as one atomic patch.
*/
export function useDeletePanel({
sections,
}: Params): (args: DeletePanelArgs) => Promise<void> {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const refetch = useDashboardStore((s) => s.refetch);
const { showErrorModal } = useErrorModal();
return useCallback(
async ({ panelId, layoutIndex }: DeletePanelArgs): Promise<void> => {
if (!dashboardId) {
return;
}
const section = sections.find((s) => s.layoutIndex === layoutIndex);
if (!section) {
return;
}
const nextItems = section.items.filter((i) => i.id !== panelId);
try {
await patchDashboardV2({ id: dashboardId }, [
replaceSectionItemsOp(layoutIndex, nextItems),
removePanelOp(panelId),
]);
refetch();
} catch (error) {
showErrorModal(error as APIError);
}
},
[sections, dashboardId, refetch, showErrorModal],
);
}

View File

@@ -1,79 +0,0 @@
import { useCallback } from 'react';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { movePanelBetweenSectionsOps } from '../../../patchOps';
import { useDashboardStore } from '../../../store/useDashboardStore';
import type { DashboardSection } from '../../../utils';
export interface MovePanelArgs {
panelId: string;
fromLayoutIndex: number;
toLayoutIndex: number;
}
interface Params {
sections: DashboardSection[];
}
/**
* Relocates a panel's item ref from one section to another. The panel itself
* stays in `spec.panels`; only the grid item moves, dropped into a free row at
* the bottom of the target section. Persisted as one atomic patch.
*/
export function useMovePanelToSection({
sections,
}: Params): (args: MovePanelArgs) => Promise<void> {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const refetch = useDashboardStore((s) => s.refetch);
const { showErrorModal } = useErrorModal();
return useCallback(
async ({
panelId,
fromLayoutIndex,
toLayoutIndex,
}: MovePanelArgs): Promise<void> => {
if (!dashboardId || fromLayoutIndex === toLayoutIndex) {
return;
}
const source = sections.find((s) => s.layoutIndex === fromLayoutIndex);
const target = sections.find((s) => s.layoutIndex === toLayoutIndex);
if (!source || !target) {
return;
}
const moved = source.items.find((i) => i.id === panelId);
if (!moved) {
return;
}
const sourceItems = source.items.filter((i) => i.id !== panelId);
// Place at a fresh row at the bottom of the target section.
const nextY = target.items.reduce(
(max, i) => Math.max(max, i.y + i.height),
0,
);
const targetItems = [...target.items, { ...moved, x: 0, y: nextY }];
try {
await patchDashboardV2(
{ id: dashboardId },
movePanelBetweenSectionsOps({
sourceIndex: fromLayoutIndex,
sourceItems,
targetIndex: toLayoutIndex,
targetItems,
}),
);
refetch();
} catch (error) {
showErrorModal(error as APIError);
}
},
[sections, dashboardId, refetch, showErrorModal],
);
}

View File

@@ -1,10 +0,0 @@
.body {
flex: 1;
padding: 12px 24px;
overflow: auto;
}
.emptyState {
padding: 48px;
text-align: center;
}

View File

@@ -1,17 +0,0 @@
.addButton {
display: inline-flex;
align-items: center;
gap: 6px;
margin-top: 8px;
padding: 8px 12px;
background: transparent;
border: 1px dashed var(--bg-slate-400, #1d212d);
border-radius: 4px;
color: var(--bg-vanilla-400, #8993ae);
cursor: pointer;
&:hover {
border-color: var(--bg-robin-500);
color: var(--bg-vanilla-100, #fff);
}
}

View File

@@ -1,67 +0,0 @@
import { useState } from 'react';
import { Plus } from '@signozhq/icons';
import type { DashboardtypesLayoutDTO } from 'api/generated/services/sigNoz.schemas';
import type { DashboardSection } from '../../../utils';
import { useAddSection } from '../hooks/useAddSection';
import { useFirstSectionMigration } from '../hooks/useFirstSectionMigration';
import FirstSectionMigrationModal from '../FirstSectionMigrationModal';
import styles from './AddSectionControl.module.scss';
const DEFAULT_SECTION_TITLE = 'New section';
interface Props {
sections: DashboardSection[];
layouts: DashboardtypesLayoutDTO[] | undefined | null;
isSectioned: boolean;
}
function AddSectionControl({
sections,
layouts,
isSectioned,
}: Props): JSX.Element {
const [isMigrationOpen, setIsMigrationOpen] = useState(false);
const { addSection } = useAddSection({ layouts });
const { migrate, isSaving } = useFirstSectionMigration({ sections });
// Free-flowing dashboard with existing panels → must migrate before sections
// can coexist (every panel must belong to a section once any exists).
const needsMigration =
!isSectioned && sections.some((s) => s.items.length > 0);
const handleClick = (): void => {
if (needsMigration) {
setIsMigrationOpen(true);
return;
}
void addSection(DEFAULT_SECTION_TITLE);
};
const handleConfirmMigration = async (): Promise<void> => {
await migrate(DEFAULT_SECTION_TITLE);
setIsMigrationOpen(false);
};
return (
<>
<button
type="button"
className={styles.addButton}
onClick={handleClick}
data-testid="add-section"
>
<Plus size={14} />
Add section
</button>
<FirstSectionMigrationModal
open={isMigrationOpen}
isSaving={isSaving}
onClose={(): void => setIsMigrationOpen(false)}
onConfirm={handleConfirmMigration}
/>
</>
);
}
export default AddSectionControl;

View File

@@ -1,41 +0,0 @@
import { Modal } from 'antd';
import { Typography } from '@signozhq/ui/typography';
interface Props {
open: boolean;
isSaving: boolean;
onClose: () => void;
onConfirm: () => void;
}
/**
* Shown when the user adds the first section to a free-flowing dashboard that
* already has panels. Confirms grouping the existing panels into a section
* before proceeding.
*/
function FirstSectionMigrationModal({
open,
isSaving,
onClose,
onConfirm,
}: Props): JSX.Element {
return (
<Modal
open={open}
title="Group panels into sections?"
onCancel={onClose}
onOk={onConfirm}
okText="Continue"
okButtonProps={{ disabled: isSaving, 'data-testid': 'confirm-migration' }}
destroyOnClose
>
<Typography.Text>
This dashboard&apos;s panels are currently free-flowing. Adding a section
will move the existing panels into their own section, and a new empty
section will be added below. You can rename sections afterwards.
</Typography.Text>
</Modal>
);
}
export default FirstSectionMigrationModal;

View File

@@ -1,64 +0,0 @@
import { useEffect, useState } from 'react';
import { Modal } from 'antd';
import { Input } from '@signozhq/ui/input';
interface Props {
open: boolean;
initialValue: string;
isSaving: boolean;
onClose: () => void;
onSubmit: (title: string) => void;
}
function RenameSectionModal({
open,
initialValue,
isSaving,
onClose,
onSubmit,
}: Props): JSX.Element {
const [value, setValue] = useState<string>(initialValue);
// Reseed the field each time the modal opens.
useEffect(() => {
if (open) {
setValue(initialValue);
}
}, [open, initialValue]);
const submit = (): void => {
const trimmed = value.trim();
if (trimmed) {
onSubmit(trimmed);
}
};
return (
<Modal
open={open}
title="Rename section"
onCancel={onClose}
onOk={submit}
okText="Rename"
okButtonProps={{ disabled: isSaving || !value.trim() }}
destroyOnClose
>
<Input
testId="rename-section-input"
autoFocus
value={value}
maxLength={120}
placeholder="Section name"
onChange={(e): void => setValue(e.target.value)}
onKeyDown={(e): void => {
if (e.key === 'Enter') {
e.preventDefault();
submit();
}
}}
/>
</Modal>
);
}
export default RenameSectionModal;

View File

@@ -1,9 +0,0 @@
.section {
margin-bottom: 12px;
border: 1px solid var(--bg-slate-500);
border-radius: 4px;
}
.dragging {
opacity: 0.8;
}

View File

@@ -1,144 +0,0 @@
import { useRef, useState } from 'react';
import { Modal } from 'antd';
import { useIntersectionObserver } from 'hooks/useIntersectionObserver';
import type { DashboardSection } from '../../../utils';
import type { AddPanelArgs } from '../../Panel/hooks/useAddPanelToSection';
import type { DeletePanelArgs } from '../../Panel/hooks/useDeletePanel';
import type { MovePanelArgs } from '../../Panel/hooks/useMovePanelToSection';
import PanelTypeSelectionModal from '../../Panel/PanelTypeSelectionModal/PanelTypeSelectionModal';
import { useDashboardStore } from '../../../store/useDashboardStore';
import { useDeleteSection } from '../hooks/useDeleteSection';
import { useRenameSection } from '../hooks/useRenameSection';
import { useToggleSectionCollapse } from '../hooks/useToggleSectionCollapse';
import RenameSectionModal from '../RenameSectionModal';
import SectionGrid from '../SectionGrid/SectionGrid';
import SectionHeader, {
type SectionDragHandle,
} from '../SectionHeader/SectionHeader';
import styles from './Section.module.scss';
interface Props {
section: DashboardSection;
/** Adds a panel to this section; present only in editable sectioned mode. */
onAddPanel?: (args: AddPanelArgs) => void;
/** All sections + per-panel handlers, for the panel "Move to section" / delete actions. */
sections?: DashboardSection[];
onMovePanel?: (args: MovePanelArgs) => void;
onDeletePanel?: (args: DeletePanelArgs) => void;
/** Provided by SortableSection in sectioned mode; absent for untitled/free-flow. */
dragHandle?: SectionDragHandle;
}
function Section({
section,
onAddPanel,
sections,
onMovePanel,
onDeletePanel,
dragHandle,
}: Props): JSX.Element {
const isEditable = useDashboardStore((s) => s.isEditable);
const containerRef = useRef<HTMLDivElement>(null);
// Placeholder signal for lazy panel query-loading (consumed in a later PR):
// true once the section scrolls into (or near) the viewport.
const isVisible = useIntersectionObserver(containerRef, {
rootMargin: '200px',
});
const { open, toggle } = useToggleSectionCollapse({ sectionId: section.id });
const [isRenaming, setIsRenaming] = useState(false);
const { rename, isSaving } = useRenameSection({
layoutIndex: section.layoutIndex,
});
const handleRenameSubmit = async (title: string): Promise<void> => {
const ok = await rename(title);
if (ok) {
setIsRenaming(false);
}
};
const [isAddingPanel, setIsAddingPanel] = useState(false);
const handleSelectPanelType = (pluginKind: string): void => {
onAddPanel?.({ layoutIndex: section.layoutIndex, pluginKind });
setIsAddingPanel(false);
};
const { deleteSection } = useDeleteSection({ section });
const confirmDeleteSection = (): void => {
Modal.confirm({
title: `Delete section "${section.title ?? ''}"?`,
content: 'Panels in this section will be removed.',
okText: 'Delete',
okButtonProps: { danger: true },
centered: true,
onOk: () => deleteSection(),
});
};
const grid = (
<SectionGrid
items={section.items}
layoutIndex={section.layoutIndex}
isVisible={isVisible}
sections={sections}
onMovePanel={onMovePanel}
onDeletePanel={onDeletePanel}
/>
);
if (!section.title) {
// Untitled section — just the grid (no header chrome), but still observed
// for the viewport signal.
return (
<div
ref={containerRef}
data-testid={`dashboard-section-${section.id}`}
data-section-layout-index={section.layoutIndex}
>
{grid}
</div>
);
}
return (
<div
ref={containerRef}
className={styles.section}
data-testid={`dashboard-section-${section.id}`}
data-section-layout-index={section.layoutIndex}
>
<SectionHeader
sectionId={section.id}
title={section.title}
open={open}
onToggle={toggle}
repeatVariable={section.repeatVariable}
dragHandle={dragHandle}
onRename={isEditable ? (): void => setIsRenaming(true) : undefined}
onAddPanel={
isEditable && onAddPanel ? (): void => setIsAddingPanel(true) : undefined
}
onDeleteSection={isEditable ? confirmDeleteSection : undefined}
/>
{open ? grid : null}
<RenameSectionModal
open={isRenaming}
initialValue={section.title}
isSaving={isSaving}
onClose={(): void => setIsRenaming(false)}
onSubmit={handleRenameSubmit}
/>
<PanelTypeSelectionModal
open={isAddingPanel}
onClose={(): void => setIsAddingPanel(false)}
onSelect={handleSelectPanelType}
/>
</div>
);
}
export default Section;

View File

@@ -1,16 +0,0 @@
.trigger {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 2px;
background: transparent;
border: none;
border-radius: 2px;
color: var(--bg-vanilla-400, #8993ae);
cursor: pointer;
&:hover {
color: var(--bg-vanilla-100, #fff);
background: var(--bg-slate-400, #1d212d);
}
}

Some files were not shown because too many files have changed in this diff Show More