mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-03 23:50:33 +01:00
Compare commits
2 Commits
feat/v2-da
...
fix/ui-mig
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d48dbec881 | ||
|
|
a81f66b995 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
28
frontend/__mocks__/useSafeNavigate.ts
Normal file
28
frontend/__mocks__/useSafeNavigate.ts
Normal 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
|
||||
>,
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() }));
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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{' '}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 }));
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -47,7 +47,6 @@ function ColumnSelector<TData>({
|
||||
size="sm"
|
||||
color="secondary"
|
||||
prefix={<Columns3 size={14} />}
|
||||
data-testid="alert-columns-button"
|
||||
>
|
||||
Columns
|
||||
</Button>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -232,7 +232,7 @@ function DashboardsList(): JSX.Element {
|
||||
isLocked: !!e.locked || false,
|
||||
lastUpdatedBy: e.updatedBy,
|
||||
image: e.data.image || Base64Icons[0],
|
||||
variables: e.data.variables ?? {},
|
||||
variables: e.data.variables,
|
||||
widgets: e.data.widgets,
|
||||
layout: e.data.layout,
|
||||
panelMap: e.data.panelMap,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { logEventMock } from '__tests__/logEventMock';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import MCPServerSettings from './MCPServerSettings';
|
||||
|
||||
const mockLogEvent = jest.fn();
|
||||
const mockCopyToClipboard = jest.fn();
|
||||
const mockHistoryPush = jest.fn();
|
||||
const mockUseGetGlobalConfig = jest.fn();
|
||||
@@ -11,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',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
>;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -966,6 +966,7 @@ function QueryBuilderSearchV2(
|
||||
>
|
||||
<Tooltip title={chipValue}>
|
||||
<TypographyText
|
||||
className="qb-tag-text"
|
||||
$isInNin={isInNin}
|
||||
$isEnabled={!!searchValue}
|
||||
onClick={(): void => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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'));
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -71,7 +71,7 @@ describe('useTransformDashboardVariables', () => {
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
const orders = Object.values(result.data.variables!).map((v) => v.order);
|
||||
const orders = Object.values(result.data.variables).map((v) => v.order);
|
||||
expect(orders).toContain(0);
|
||||
expect(orders).toContain(1);
|
||||
});
|
||||
@@ -84,7 +84,7 @@ describe('useTransformDashboardVariables', () => {
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables!.v1.order).toBe(5);
|
||||
expect(result.data.variables.v1.order).toBe(5);
|
||||
});
|
||||
|
||||
it('assigns unique orders across multiple variables that all lack an order', () => {
|
||||
@@ -97,7 +97,7 @@ describe('useTransformDashboardVariables', () => {
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
const orders = Object.values(result.data.variables!).map((v) => v.order);
|
||||
const orders = Object.values(result.data.variables).map((v) => v.order);
|
||||
// All three newly assigned orders must be distinct
|
||||
expect(new Set(orders).size).toBe(3);
|
||||
});
|
||||
@@ -112,7 +112,7 @@ describe('useTransformDashboardVariables', () => {
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables!.v1.id).toMatch(
|
||||
expect(result.data.variables.v1.id).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
|
||||
);
|
||||
});
|
||||
@@ -125,7 +125,7 @@ describe('useTransformDashboardVariables', () => {
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables!.v1.id).toBe('keep-me');
|
||||
expect(result.data.variables.v1.id).toBe('keep-me');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -145,7 +145,7 @@ describe('useTransformDashboardVariables', () => {
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables!.v1.defaultValue).toBe('hello');
|
||||
expect(result.data.variables.v1.defaultValue).toBe('hello');
|
||||
});
|
||||
|
||||
it('does not overwrite an existing defaultValue', () => {
|
||||
@@ -163,7 +163,7 @@ describe('useTransformDashboardVariables', () => {
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables!.v1.defaultValue).toBe('keep');
|
||||
expect(result.data.variables.v1.defaultValue).toBe('keep');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -178,7 +178,7 @@ describe('useTransformDashboardVariables', () => {
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables!.v1.selectedValue).toBe('staging');
|
||||
expect(result.data.variables.v1.selectedValue).toBe('staging');
|
||||
});
|
||||
|
||||
it('applies localStorage allSelected over DB value', () => {
|
||||
@@ -196,7 +196,7 @@ describe('useTransformDashboardVariables', () => {
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables!.v1.allSelected).toBe(true);
|
||||
expect(result.data.variables.v1.allSelected).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -217,7 +217,7 @@ describe('useTransformDashboardVariables', () => {
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables!.v1.allSelected).toBe(true);
|
||||
expect(result.data.variables.v1.allSelected).toBe(true);
|
||||
});
|
||||
|
||||
it('sets selectedValue from URL and clears allSelected when showALLOption is true', () => {
|
||||
@@ -237,8 +237,8 @@ describe('useTransformDashboardVariables', () => {
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables!.v1.selectedValue).toBe('dev');
|
||||
expect(result.data.variables!.v1.allSelected).toBe(false);
|
||||
expect(result.data.variables.v1.selectedValue).toBe('dev');
|
||||
expect(result.data.variables.v1.allSelected).toBe(false);
|
||||
});
|
||||
|
||||
it('does not set allSelected=false when showALLOption is false', () => {
|
||||
@@ -258,8 +258,8 @@ describe('useTransformDashboardVariables', () => {
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables!.v1.selectedValue).toBe('dev');
|
||||
expect(result.data.variables!.v1.allSelected).toBe(true);
|
||||
expect(result.data.variables.v1.selectedValue).toBe('dev');
|
||||
expect(result.data.variables.v1.allSelected).toBe(true);
|
||||
});
|
||||
|
||||
it('normalizes array URL value to single value for single-select variable', () => {
|
||||
@@ -277,7 +277,7 @@ describe('useTransformDashboardVariables', () => {
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables!.v1.selectedValue).toBe('prod');
|
||||
expect(result.data.variables.v1.selectedValue).toBe('prod');
|
||||
});
|
||||
|
||||
it('wraps single URL value in array for multi-select variable', () => {
|
||||
@@ -292,7 +292,7 @@ describe('useTransformDashboardVariables', () => {
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables!.v1.selectedValue).toStrictEqual(['prod']);
|
||||
expect(result.data.variables.v1.selectedValue).toStrictEqual(['prod']);
|
||||
});
|
||||
|
||||
it('looks up URL variable by variable id when name is absent', () => {
|
||||
@@ -306,7 +306,7 @@ describe('useTransformDashboardVariables', () => {
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables!.v1.selectedValue).toBe('fallback');
|
||||
expect(result.data.variables.v1.selectedValue).toBe('fallback');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -327,11 +327,11 @@ describe('useTransformDashboardVariables', () => {
|
||||
const dashboard = makeDashboard({
|
||||
v1: makeVariable({ id: 'id1', name: 'env', selectedValue: 'prod' }),
|
||||
});
|
||||
const originalValue = dashboard.data.variables!.v1.selectedValue;
|
||||
const originalValue = dashboard.data.variables.v1.selectedValue;
|
||||
|
||||
transformDashboardVariables(dashboard);
|
||||
|
||||
expect(dashboard.data.variables!.v1.selectedValue).toBe(originalValue);
|
||||
expect(dashboard.data.variables.v1.selectedValue).toBe(originalValue);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,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],
|
||||
};
|
||||
|
||||
|
||||
@@ -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(),
|
||||
}));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`,
|
||||
}),
|
||||
);
|
||||
@@ -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: [],
|
||||
}));
|
||||
@@ -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),
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
.body {
|
||||
flex: 1;
|
||||
padding: 12px 24px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.emptyState {
|
||||
padding: 48px;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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'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;
|
||||
@@ -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;
|
||||
@@ -1,9 +0,0 @@
|
||||
.section {
|
||||
margin-bottom: 12px;
|
||||
border: 1px solid var(--bg-slate-500);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.dragging {
|
||||
opacity: 0.8;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
Reference in New Issue
Block a user