Compare commits

..

18 Commits

Author SHA1 Message Date
ahmadshaheer
6fe5178dad chore: fix the failing tests in CI 2025-07-22 19:32:56 +04:30
ahmadshaheer
9c67d36e57 chore: fix the failing CI check 2025-07-22 11:04:15 +04:30
ahmadshaheer
197aaae9c5 chore: fix the failing tests 2025-07-22 10:44:36 +04:30
ahmadshaheer
58e91bc31b chore: assert whether add span to funnel is in the document 2025-07-20 16:24:35 +04:30
ahmadshaheer
dba3eb732d chore: refined trace funnels tests 2025-07-20 16:24:35 +04:30
ahmadshaheer
40b9ee48ac chore: fix linter issues 2025-07-20 16:24:35 +04:30
ahmadshaheer
12abfe5e43 chore: revert the unintended removal of dev env check for trace funnels 2025-07-20 16:24:35 +04:30
ahmadshaheer
373f26098f chore: improve row key 2025-07-20 16:24:35 +04:30
ahmadshaheer
d70be77f40 chore: fix the failing tests by adjusting the tests with latest changes 2025-07-20 16:24:35 +04:30
ahmadshaheer
7f230cb44f chore: fix the errors in trace funnels tests due to modified API response 2025-07-20 16:24:35 +04:30
ahmadshaheer
7075375537 chore: overall improvements to the existing trace funnels tests 2025-07-20 16:23:44 +04:30
ahmadshaheer
7046349294 chore: add span to funnel from trace details page tests 2025-07-20 16:23:44 +04:30
ahmadshaheer
e685b5e3ed chore: funnel details -> graph tests 2025-07-20 16:22:53 +04:30
ahmadshaheer
f993773295 chore: fix the warnings in trace funnels 2025-07-20 16:22:53 +04:30
ahmadshaheer
25ae3c8d27 chore: funnel details flows tests 2025-07-20 16:00:15 +04:30
ahmadshaheer
e264e3c576 chore: improve the tests for funnel creation flows 2025-07-20 16:00:15 +04:30
ahmadshaheer
4fdb74a341 chore: writing create and run funnel tests 2025-07-20 16:00:15 +04:30
ahmadshaheer
329c0e7fc6 chore: trace funnels list page tests 2025-07-20 16:00:15 +04:30
69 changed files with 2299 additions and 2178 deletions

View File

@@ -1,6 +1,10 @@
name: prereleaser
on:
# schedule every wednesday 6:30 AM UTC (12:00 PM IST)
schedule:
- cron: '30 6 * * 3'
# allow manual triggering of the workflow by a maintainer
workflow_dispatch:
inputs:

View File

@@ -217,6 +217,7 @@
"imagemin": "^8.0.1",
"imagemin-svgo": "^10.0.1",
"is-ci": "^3.0.1",
"jest-canvas-mock": "2.5.2",
"jest-styled-components": "^7.0.8",
"lint-staged": "^12.5.0",
"msw": "1.3.2",

View File

@@ -3,7 +3,6 @@ import { ConfigProvider } from 'antd';
import getLocalStorageApi from 'api/browser/localstorage/get';
import setLocalStorageApi from 'api/browser/localstorage/set';
import logEvent from 'api/common/logEvent';
import AppLoading from 'components/AppLoading/AppLoading';
import NotFound from 'components/NotFound';
import Spinner from 'components/Spinner';
import UserpilotRouteTracker from 'components/UserpilotRouteTracker/UserpilotRouteTracker';
@@ -343,7 +342,7 @@ function App(): JSX.Element {
if (isLoggedInState) {
// if the setup calls are loading then return a spinner
if (isFetchingActiveLicense || isFetchingUser || isFetchingFeatureFlags) {
return <AppLoading />;
return <Spinner tip="Loading..." />;
}
// if the required calls fails then return a something went wrong error

View File

@@ -1,152 +0,0 @@
.app-loading-container {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
background-color: var(--bg-ink-400, #121317); // Dark theme background
.app-loading-content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
.brand {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-bottom: 12px;
.brand-logo {
width: 40px;
height: 40px;
}
.brand-title {
font-size: 20px;
font-weight: 600;
color: var(--bg-vanilla-100, #ffffff); // White text for dark theme
margin: 0;
}
}
.brand-tagline {
margin-bottom: 24px;
.ant-typography {
color: var(--bg-vanilla-400, #c0c1c3); // Light gray text for dark theme
}
}
/* HTML: <div class="loader"></div> */
.loader {
width: 150px;
height: 12px;
border-radius: 2px;
color: var(--bg-robin-500, #4e74f8); // Primary blue color
border: 2px solid;
position: relative;
}
.loader::before {
content: '';
position: absolute;
margin: 2px;
inset: 0 100% 0 0;
border-radius: inherit;
background: currentColor;
animation: l6 2s infinite;
}
@keyframes l6 {
100% {
inset: 0;
}
}
}
}
// Light theme styles - more specific selector
.app-loading-container.lightMode {
background-color: var(
--bg-vanilla-100,
#ffffff
) !important; // White background for light theme
.app-loading-content {
.brand {
.brand-title {
color: var(--bg-ink-400, #121317) !important; // Dark text for light theme
}
}
.brand-tagline {
.ant-typography {
color: var(
--bg-ink-300,
#6b7280
) !important; // Dark gray text for light theme
}
}
.loader {
color: var(
--bg-robin-500,
#4e74f8
) !important; // Keep primary blue color for consistency
}
}
}
.perilin-bg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: radial-gradient(circle, #fff 10%, transparent 0);
background-size: 12px 12px;
opacity: 1;
mask-image: radial-gradient(
circle at 50% 0,
rgba(11, 12, 14, 0.1) 0,
rgba(11, 12, 14, 0) 100%
);
-webkit-mask-image: radial-gradient(
circle at 50% 0,
rgba(11, 12, 14, 0.1) 0,
rgba(11, 12, 14, 0) 100%
);
}
// Dark theme styles - ensure dark theme is properly applied
.app-loading-container.dark {
background-color: var(--bg-ink-400, #121317) !important; // Dark background
.app-loading-content {
.brand {
.brand-title {
color: var(
--bg-vanilla-100,
#ffffff
) !important; // White text for dark theme
}
}
.brand-tagline {
.ant-typography {
color: var(
--bg-vanilla-400,
#c0c1c3
) !important; // Light gray text for dark theme
}
}
.loader {
color: var(--bg-robin-500, #4e74f8) !important; // Primary blue color
}
}
}

View File

@@ -1,50 +0,0 @@
import './AppLoading.styles.scss';
import { Typography } from 'antd';
import get from 'api/browser/localstorage/get';
import { LOCALSTORAGE } from 'constants/localStorage';
import { THEME_MODE } from 'hooks/useDarkMode/constant';
function AppLoading(): JSX.Element {
// Get theme from localStorage directly to avoid context dependency
const getThemeFromStorage = (): boolean => {
try {
const theme = get(LOCALSTORAGE.THEME);
return theme !== THEME_MODE.LIGHT; // Return true for dark, false for light
} catch (error) {
// If localStorage is not available, default to dark theme
return true;
}
};
const isDarkMode = getThemeFromStorage();
return (
<div className={`app-loading-container ${isDarkMode ? 'dark' : 'lightMode'}`}>
<div className="perilin-bg" />
<div className="app-loading-content">
<div className="brand">
<img
src="/Logos/signoz-brand-logo.svg"
alt="SigNoz"
className="brand-logo"
/>
<Typography.Title level={2} className="brand-title">
SigNoz
</Typography.Title>
</div>
<div className="brand-tagline">
<Typography.Text>
OpenTelemetry-Native Logs, Metrics and Traces in a single pane
</Typography.Text>
</div>
<div className="loader" />
</div>
</div>
);
}
export default AppLoading;

View File

@@ -1,76 +0,0 @@
import { render, screen } from '@testing-library/react';
import AppLoading from '../AppLoading';
// Mock the localStorage API
const mockGet = jest.fn();
jest.mock('api/browser/localstorage/get', () => ({
__esModule: true,
default: mockGet,
}));
describe('AppLoading', () => {
const SIGNOZ_TEXT = 'SigNoz';
const TAGLINE_TEXT =
'OpenTelemetry-Native Logs, Metrics and Traces in a single pane';
const CONTAINER_SELECTOR = '.app-loading-container';
beforeEach(() => {
jest.clearAllMocks();
});
it('should render loading screen with dark theme by default', () => {
// Mock localStorage to return dark theme (or undefined for default)
mockGet.mockReturnValue(undefined);
render(<AppLoading />);
// Check if main elements are rendered
expect(screen.getByAltText(SIGNOZ_TEXT)).toBeInTheDocument();
expect(screen.getByText(SIGNOZ_TEXT)).toBeInTheDocument();
expect(screen.getByText(TAGLINE_TEXT)).toBeInTheDocument();
// Check if dark theme class is applied
const container = screen.getByText(SIGNOZ_TEXT).closest(CONTAINER_SELECTOR);
expect(container).toHaveClass('dark');
expect(container).not.toHaveClass('lightMode');
});
it('should have proper structure and content', () => {
// Mock localStorage to return dark theme
mockGet.mockReturnValue(undefined);
render(<AppLoading />);
// Check for brand logo
const logo = screen.getByAltText(SIGNOZ_TEXT);
expect(logo).toBeInTheDocument();
expect(logo).toHaveAttribute('src', '/Logos/signoz-brand-logo.svg');
// Check for brand title
const title = screen.getByText(SIGNOZ_TEXT);
expect(title).toBeInTheDocument();
// Check for tagline
const tagline = screen.getByText(TAGLINE_TEXT);
expect(tagline).toBeInTheDocument();
// Check for loader
const loader = document.querySelector('.loader');
expect(loader).toBeInTheDocument();
});
it('should handle localStorage errors gracefully', () => {
// Mock localStorage to throw an error
mockGet.mockImplementation(() => {
throw new Error('localStorage not available');
});
render(<AppLoading />);
// Should still render with dark theme as fallback
expect(screen.getByText(SIGNOZ_TEXT)).toBeInTheDocument();
const container = screen.getByText(SIGNOZ_TEXT).closest(CONTAINER_SELECTOR);
expect(container).toHaveClass('dark');
});
});

View File

@@ -22,6 +22,7 @@ function ChangePercentagePill({
'change-percentage-pill--positive': isPositive,
'change-percentage-pill--negative': !isPositive,
})}
data-testid="change-percentage-pill"
>
<div className="change-percentage-pill__icon">
{isPositive ? (

View File

@@ -370,7 +370,6 @@ function CustomTimePicker({
onFocus={handleFocus}
onBlur={handleBlur}
onChange={handleInputChange}
data-1p-ignore
prefix={
inputValue && inputStatus === 'success' ? (
<CheckCircle size={14} color="#51E7A8" />

View File

@@ -84,7 +84,6 @@ function RangePickerModal(props: RangePickerModalProps): JSX.Element {
date.tz(timezone.value).format(DATE_TIME_FORMATS.ISO_DATETIME)
}
onOk={onModalOkHandler}
data-1p-ignore
{...(selectedTime === 'custom' &&
!onTimeChange && {
value: rangeValue,

View File

@@ -72,7 +72,6 @@ function SearchBar({
onKeyDown={handleKeyDown}
tabIndex={0}
autoFocus
data-1p-ignore
/>
</div>
<kbd className="timezone-picker__esc-key">esc</kbd>

View File

@@ -9,7 +9,6 @@ import cx from 'classnames';
import { LogType } from 'components/Logs/LogStateIndicator/LogStateIndicator';
import { LOCALSTORAGE } from 'constants/localStorage';
import { QueryParams } from 'constants/query';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import ContextView from 'container/LogDetailedView/ContextView/ContextView';
import InfraMetrics from 'container/LogDetailedView/InfraMetrics/InfraMetrics';
@@ -27,7 +26,7 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useNotifications } from 'hooks/useNotifications';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import createQueryParams from 'lib/createQueryParams';
import useUrlQuery from 'hooks/useUrlQuery';
import {
BarChart2,
Braces,
@@ -72,7 +71,7 @@ function LogDetail({
const [contextQuery, setContextQuery] = useState<Query | undefined>();
const [filters, setFilters] = useState<TagFilter | null>(null);
const [isEdit, setIsEdit] = useState<boolean>(false);
const { stagedQuery, updateAllQueriesOperators } = useQueryBuilder();
const { stagedQuery } = useQueryBuilder();
const listQuery = useMemo(() => {
if (!stagedQuery || stagedQuery.builder.queryData.length < 1) return null;
@@ -89,6 +88,7 @@ function LogDetail({
const isDarkMode = useIsDarkMode();
const location = useLocation();
const { safeNavigate } = useSafeNavigate();
const urlQuery = useUrlQuery();
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
@@ -136,19 +136,10 @@ function LogDetail({
// Go to logs explorer page with the log data
const handleOpenInExplorer = (): void => {
const queryParams = {
[QueryParams.activeLogId]: `"${log?.id}"`,
[QueryParams.startTime]: minTime?.toString() || '',
[QueryParams.endTime]: maxTime?.toString() || '',
[QueryParams.compositeQuery]: JSON.stringify(
updateAllQueriesOperators(
initialQueriesMap[DataSource.LOGS],
PANEL_TYPES.LIST,
DataSource.LOGS,
),
),
};
safeNavigate(`${ROUTES.LOGS_EXPLORER}?${createQueryParams(queryParams)}`);
urlQuery.set(QueryParams.activeLogId, `"${log?.id}"`);
urlQuery.set(QueryParams.startTime, minTime?.toString() || '');
urlQuery.set(QueryParams.endTime, maxTime?.toString() || '');
safeNavigate(`${ROUTES.LOGS_EXPLORER}?${urlQuery.toString()}`);
};
// Only show when opened from infra monitoring page

View File

@@ -4,7 +4,6 @@ export enum LOCALSTORAGE {
AUTH_TOKEN = 'AUTH_TOKEN',
REFRESH_AUTH_TOKEN = 'REFRESH_AUTH_TOKEN',
THEME = 'THEME',
THEME_AUTO_SWITCH = 'THEME_AUTO_SWITCH',
LOGS_VIEW_MODE = 'LOGS_VIEW_MODE',
LOGS_LINES_PER_ROW = 'LOGS_LINES_PER_ROW',
LOGS_LIST_OPTIONS = 'LOGS_LIST_OPTIONS',

View File

@@ -196,7 +196,6 @@ function GridCardGraph({
[requestData.query],
);
console.log('requestData', requestData);
const queryResponse = useGetQueryRange(
{
...requestData,

View File

@@ -58,7 +58,6 @@ export interface ActionItemProps {
operator: string,
isJSON?: boolean,
dataType?: DataTypes,
fieldType?: string,
) => void;
}

View File

@@ -14,7 +14,6 @@ import { ResizeTable } from 'components/ResizeTable';
import { OPERATORS } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { RESTRICTED_SELECTED_FIELDS } from 'container/LogsFilters/config';
import { MetricsType } from 'container/MetricsApplication/constant';
import { FontSize, OptionsQuery } from 'container/OptionsMenu/types';
import { useIsDarkMode } from 'hooks/useDarkMode';
import history from 'lib/history';
@@ -114,7 +113,6 @@ function TableView({
fieldKey: string,
fieldValue: string,
dataType: string | undefined,
fieldType: string | undefined,
): void => {
const validatedFieldValue = removeJSONStringifyQuotes(fieldValue);
if (onClickActionItem) {
@@ -124,7 +122,6 @@ function TableView({
operator,
undefined,
dataType as DataTypes,
fieldType,
);
}
};
@@ -134,9 +131,8 @@ function TableView({
fieldKey: string,
fieldValue: string,
dataType: string | undefined,
fieldType: MetricsType | undefined,
) => (): void => {
handleClick(operator, fieldKey, fieldValue, dataType, fieldType);
handleClick(operator, fieldKey, fieldValue, dataType);
if (operator === OPERATORS['=']) {
setIsFilterInLoading(true);
}

View File

@@ -11,7 +11,6 @@ import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { OPERATORS } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { RESTRICTED_SELECTED_FIELDS } from 'container/LogsFilters/config';
import { MetricsType } from 'container/MetricsApplication/constant';
import dompurify from 'dompurify';
import { ArrowDownToDot, ArrowUpFromDot, Ellipsis } from 'lucide-react';
import { useTimezone } from 'providers/Timezone';
@@ -47,7 +46,6 @@ interface ITableViewActionsProps {
fieldKey: string,
fieldValue: string,
dataType: string | undefined,
logType: MetricsType | undefined,
) => () => void;
}
@@ -129,7 +127,7 @@ export default function TableViewActions(
} = props;
const { pathname } = useLocation();
const { dataType, logType: fieldType } = getFieldAttributes(record.field);
const { dataType } = getFieldAttributes(record.field);
// there is no option for where clause in old logs explorer and live logs page
const isOldLogsExplorerOrLiveLogsPage = useMemo(
@@ -236,7 +234,6 @@ export default function TableViewActions(
fieldFilterKey,
parseFieldValue(fieldData.value),
dataType,
fieldType,
)}
/>
</Tooltip>
@@ -255,7 +252,6 @@ export default function TableViewActions(
fieldFilterKey,
parseFieldValue(fieldData.value),
dataType,
fieldType,
)}
/>
</Tooltip>
@@ -316,7 +312,6 @@ export default function TableViewActions(
fieldFilterKey,
parseFieldValue(fieldData.value),
dataType,
fieldType,
)}
/>
</Tooltip>
@@ -335,7 +330,6 @@ export default function TableViewActions(
fieldFilterKey,
parseFieldValue(fieldData.value),
dataType,
fieldType,
)}
/>
</Tooltip>

View File

@@ -1,14 +1,11 @@
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { ILog } from 'types/api/logs/log';
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { getFiltersFromResources } from './utils';
const RESOURCE_STARTS_WITH_REGEX = /^(k8s|cloud|host|deployment)/; // regex to filter out resources that start with the specified keywords
const RESOURCE_CONTAINS_REGEX = /(env|service|file|container|tenant)/; // regex to filter out resources that contains the spefied keywords
const useInitialQuery = (log: ILog): Query => {
const { updateAllQueriesOperators } = useQueryBuilder();
const resourcesFilters = getFiltersFromResources(log.resources_string);
@@ -19,33 +16,17 @@ const useInitialQuery = (log: ILog): Query => {
DataSource.LOGS,
);
const updateFilters = (filters: TagFilter): TagFilter => ({
...filters,
items: filters.items.filter(
(filterItem) =>
filterItem.key?.key &&
(RESOURCE_STARTS_WITH_REGEX.test(filterItem.key.key) ||
RESOURCE_CONTAINS_REGEX.test(filterItem.key.key)),
),
});
const data: Query = {
...updatedAllQueriesOperator,
builder: {
...updatedAllQueriesOperator.builder,
queryData: updatedAllQueriesOperator.builder.queryData.map((item) => {
const filters = {
queryData: updatedAllQueriesOperator.builder.queryData.map((item) => ({
...item,
filters: {
...item.filters,
items: [...item.filters.items, ...resourcesFilters],
};
const updatedFilters = updateFilters(filters);
return {
...item,
filters: updatedFilters,
};
}),
},
})),
},
};

View File

@@ -104,7 +104,6 @@ function Metadata({
if (field.key === 'metric_type') {
return (
<Select
data-testid="metric-type-select"
options={Object.entries(METRIC_TYPE_VALUES_MAP).map(([key]) => ({
value: key,
label: METRIC_TYPE_LABEL_MAP[key as MetricType],
@@ -122,7 +121,6 @@ function Metadata({
if (field.key === 'temporality') {
return (
<Select
data-testid="temporality-select"
options={Object.values(Temporality).map((key) => ({
value: key,
label: key,
@@ -139,7 +137,6 @@ function Metadata({
}
return (
<Input
data-testid="description-input"
name={field.key}
defaultValue={
metricMetadata[

View File

@@ -0,0 +1,67 @@
import { Button, Collapse, Typography } from 'antd';
import { useMemo, useState } from 'react';
import { TopAttributesProps } from './types';
function TopAttributes({
items,
title,
loadMore,
hideLoadMore,
}: TopAttributesProps): JSX.Element {
const [activeKey, setActiveKey] = useState<string | string[]>(
'top-attributes',
);
const collapseItems = useMemo(
() => [
{
label: (
<div className="metrics-accordion-header">
<Typography.Text>{title}</Typography.Text>
</div>
),
key: 'top-attributes',
children: (
<div className="top-attributes-content">
{items.map((item) => (
<div className="top-attributes-item" key={item.key}>
<div className="top-attributes-item-progress">
<div className="top-attributes-item-key">{item.key}</div>
<div className="top-attributes-item-count">{item.count}</div>
<div
className="top-attributes-item-progress-bar"
style={{ width: `${item.percentage}%` }}
/>
</div>
<div className="top-attributes-item-percentage">
{item.percentage.toFixed(2)}%
</div>
</div>
))}
{loadMore && !hideLoadMore && (
<div className="top-attributes-load-more">
<Button type="link" onClick={loadMore}>
Load more
</Button>
</div>
)}
</div>
),
},
],
[title, items, loadMore, hideLoadMore],
);
return (
<Collapse
bordered
className="metrics-accordion"
activeKey={activeKey}
onChange={(keys): void => setActiveKey(keys)}
items={collapseItems}
/>
);
}
export default TopAttributes;

View File

@@ -1,7 +1,6 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { MetricType } from 'api/metricsExplorer/getMetricsList';
import * as useHandleExplorerTabChange from 'hooks/useHandleExplorerTabChange';
import * as reactUseHooks from 'react-use';
import { MetricDetailsAttribute } from '../../../../api/metricsExplorer/getMetricDetails';
import ROUTES from '../../../../constants/routes';
@@ -35,11 +34,6 @@ const mockAttributes: MetricDetailsAttribute[] = [
},
];
const mockUseCopyToClipboard = jest.fn();
jest
.spyOn(reactUseHooks, 'useCopyToClipboard')
.mockReturnValue([{ value: 'value1' }, mockUseCopyToClipboard] as any);
describe('AllAttributes', () => {
it('renders attributes section with title', () => {
render(
@@ -171,42 +165,4 @@ describe('AllAttributesValue', () => {
);
expect(screen.queryByText('Show More')).not.toBeInTheDocument();
});
it('copy button should copy the attribute value to the clipboard', () => {
render(
<AllAttributesValue
filterKey="attribute1"
filterValue={['value1', 'value2']}
goToMetricsExploreWithAppliedAttribute={
mockGoToMetricsExploreWithAppliedAttribute
}
/>,
);
expect(screen.getByText('value1')).toBeInTheDocument();
fireEvent.click(screen.getByText('value1'));
expect(screen.getByText('Copy Attribute')).toBeInTheDocument();
fireEvent.click(screen.getByText('Copy Attribute'));
expect(mockUseCopyToClipboard).toHaveBeenCalledWith('value1');
});
it('explorer button should go to metrics explore with the attribute filter applied', () => {
render(
<AllAttributesValue
filterKey="attribute1"
filterValue={['value1', 'value2']}
goToMetricsExploreWithAppliedAttribute={
mockGoToMetricsExploreWithAppliedAttribute
}
/>,
);
expect(screen.getByText('value1')).toBeInTheDocument();
fireEvent.click(screen.getByText('value1'));
expect(screen.getByText('Open in Explorer')).toBeInTheDocument();
fireEvent.click(screen.getByText('Open in Explorer'));
expect(mockGoToMetricsExploreWithAppliedAttribute).toHaveBeenCalledWith(
'attribute1',
'value1',
);
});
});

View File

@@ -1,162 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { QueryParams } from 'constants/query';
import * as useSafeNavigate from 'hooks/useSafeNavigate';
import DashboardsAndAlertsPopover from '../DashboardsAndAlertsPopover';
const mockAlert1 = {
alert_id: '1',
alert_name: 'Alert 1',
};
const mockAlert2 = {
alert_id: '2',
alert_name: 'Alert 2',
};
const mockDashboard1 = {
dashboard_id: '1',
dashboard_name: 'Dashboard 1',
};
const mockDashboard2 = {
dashboard_id: '2',
dashboard_name: 'Dashboard 2',
};
const mockAlerts = [mockAlert1, mockAlert2];
const mockDashboards = [mockDashboard1, mockDashboard2];
const mockSafeNavigate = jest.fn();
jest.spyOn(useSafeNavigate, 'useSafeNavigate').mockReturnValue({
safeNavigate: mockSafeNavigate,
});
const mockSetQuery = jest.fn();
const mockUrlQuery = {
set: mockSetQuery,
toString: jest.fn(),
};
jest.mock('hooks/useUrlQuery', () => ({
__esModule: true,
default: jest.fn(() => mockUrlQuery),
}));
describe('DashboardsAndAlertsPopover', () => {
it('renders the popover correctly with multiple dashboards and alerts', () => {
render(
<DashboardsAndAlertsPopover
alerts={mockAlerts}
dashboards={mockDashboards}
/>,
);
expect(
screen.getByText(`${mockDashboards.length} dashboards`),
).toBeInTheDocument();
expect(
screen.getByText(`${mockAlerts.length} alert rules`),
).toBeInTheDocument();
});
it('renders null with no dashboards and alerts', () => {
const { container } = render(
<DashboardsAndAlertsPopover alerts={[]} dashboards={[]} />,
);
expect(container).toBeEmptyDOMElement();
});
it('renders popover with single dashboard and alert', () => {
render(
<DashboardsAndAlertsPopover
alerts={[mockAlert1]}
dashboards={[mockDashboard1]}
/>,
);
expect(screen.getByText(`1 dashboard`)).toBeInTheDocument();
expect(screen.getByText(`1 alert rule`)).toBeInTheDocument();
});
it('renders popover with dashboard id if name is not available', () => {
render(
<DashboardsAndAlertsPopover
alerts={mockAlerts}
dashboards={[{ ...mockDashboard1, dashboard_name: undefined } as any]}
/>,
);
fireEvent.click(screen.getByText(`1 dashboard`));
expect(screen.getByText(mockDashboard1.dashboard_id)).toBeInTheDocument();
});
it('renders popover with alert id if name is not available', () => {
render(
<DashboardsAndAlertsPopover
alerts={[{ ...mockAlert1, alert_name: undefined } as any]}
dashboards={mockDashboards}
/>,
);
fireEvent.click(screen.getByText(`1 alert rule`));
expect(screen.getByText(mockAlert1.alert_id)).toBeInTheDocument();
});
it('navigates to the dashboard when the dashboard is clicked', () => {
render(
<DashboardsAndAlertsPopover
alerts={mockAlerts}
dashboards={mockDashboards}
/>,
);
// Click on 2 dashboards button
fireEvent.click(screen.getByText(`${mockDashboards.length} dashboards`));
// Popover showing list of 2 dashboards should be visible
expect(screen.getByText(mockDashboard1.dashboard_name)).toBeInTheDocument();
expect(screen.getByText(mockDashboard2.dashboard_name)).toBeInTheDocument();
// Click on the first dashboard
fireEvent.click(screen.getByText(mockDashboard1.dashboard_name));
// Should navigate to the dashboard
expect(mockSafeNavigate).toHaveBeenCalledWith(
`/dashboard/${mockDashboard1.dashboard_id}`,
);
});
it('navigates to the alert when the alert is clicked', () => {
render(
<DashboardsAndAlertsPopover
alerts={mockAlerts}
dashboards={mockDashboards}
/>,
);
// Click on 2 alert rules button
fireEvent.click(screen.getByText(`${mockAlerts.length} alert rules`));
// Popover showing list of 2 alert rules should be visible
expect(screen.getByText(mockAlert1.alert_name)).toBeInTheDocument();
expect(screen.getByText(mockAlert2.alert_name)).toBeInTheDocument();
// Click on the first alert rule
fireEvent.click(screen.getByText(mockAlert1.alert_name));
// Should navigate to the alert rule
expect(mockSetQuery).toHaveBeenCalledWith(
QueryParams.ruleId,
mockAlert1.alert_id,
);
});
it('renders unique dashboards even when there are duplicates', () => {
render(
<DashboardsAndAlertsPopover
alerts={mockAlerts}
dashboards={[...mockDashboards, mockDashboard1]}
/>,
);
expect(
screen.getByText(`${mockDashboards.length} dashboards`),
).toBeInTheDocument();
fireEvent.click(screen.getByText(`${mockDashboards.length} dashboards`));
expect(screen.getByText(mockDashboard1.dashboard_name)).toBeInTheDocument();
expect(screen.getByText(mockDashboard2.dashboard_name)).toBeInTheDocument();
});
});

View File

@@ -1,222 +0,0 @@
/* eslint-disable sonarjs/no-duplicate-string */
import { fireEvent, render, screen } from '@testing-library/react';
import { Temporality } from 'api/metricsExplorer/getMetricDetails';
import { MetricType } from 'api/metricsExplorer/getMetricsList';
import * as useUpdateMetricMetadataHooks from 'hooks/metricsExplorer/useUpdateMetricMetadata';
import * as useNotificationsHooks from 'hooks/useNotifications';
import Metadata from '../Metadata';
const mockUseUpdateMetricMetadata = jest.fn();
jest
.spyOn(useUpdateMetricMetadataHooks, 'useUpdateMetricMetadata')
.mockReturnValue({
mutate: mockUseUpdateMetricMetadata,
isLoading: false,
} as any);
const mockErrorNotification = jest.fn();
const mockSuccessNotification = jest.fn();
jest.spyOn(useNotificationsHooks, 'useNotifications').mockReturnValue({
notifications: {
error: mockErrorNotification,
success: mockSuccessNotification,
},
} as any);
const mockMetricName = 'test_metric';
const mockMetricMetadata = {
metric_type: MetricType.GAUGE,
description: 'test_description',
unit: 'test_unit',
temporality: Temporality.DELTA,
};
const mockRefetchMetricDetails = jest.fn();
describe('Metadata', () => {
it('should render the metadata properly', () => {
render(
<Metadata
metricName={mockMetricName}
metadata={mockMetricMetadata}
refetchMetricDetails={mockRefetchMetricDetails}
/>,
);
expect(screen.getByText('Metric Type')).toBeInTheDocument();
expect(screen.getByText(mockMetricMetadata.metric_type)).toBeInTheDocument();
expect(screen.getByText('Description')).toBeInTheDocument();
expect(screen.getByText(mockMetricMetadata.description)).toBeInTheDocument();
expect(screen.getByText('Unit')).toBeInTheDocument();
expect(screen.getByText(mockMetricMetadata.unit)).toBeInTheDocument();
expect(screen.getByText('Temporality')).toBeInTheDocument();
expect(screen.getByText(mockMetricMetadata.temporality)).toBeInTheDocument();
});
it('editing the metadata should show the form inputs', () => {
render(
<Metadata
metricName={mockMetricName}
metadata={mockMetricMetadata}
refetchMetricDetails={mockRefetchMetricDetails}
/>,
);
const editButton = screen.getByText('Edit');
expect(editButton).toBeInTheDocument();
fireEvent.click(editButton);
expect(screen.getByTestId('metric-type-select')).toBeInTheDocument();
expect(screen.getByTestId('temporality-select')).toBeInTheDocument();
expect(screen.getByTestId('description-input')).toBeInTheDocument();
});
it('should update the metadata when the form is submitted', async () => {
render(
<Metadata
metricName={mockMetricName}
metadata={mockMetricMetadata}
refetchMetricDetails={mockRefetchMetricDetails}
/>,
);
const editButton = screen.getByText('Edit');
expect(editButton).toBeInTheDocument();
fireEvent.click(editButton);
const metricDescriptionInput = screen.getByTestId('description-input');
expect(metricDescriptionInput).toBeInTheDocument();
fireEvent.change(metricDescriptionInput, {
target: { value: 'Updated description' },
});
const saveButton = screen.getByText('Save');
expect(saveButton).toBeInTheDocument();
fireEvent.click(saveButton);
expect(mockUseUpdateMetricMetadata).toHaveBeenCalledWith(
expect.objectContaining({
metricName: mockMetricName,
payload: expect.objectContaining({
description: 'Updated description',
}),
}),
expect.objectContaining({
onSuccess: expect.any(Function),
onError: expect.any(Function),
}),
);
});
it('should show success notification when metadata is updated successfully', async () => {
render(
<Metadata
metricName={mockMetricName}
metadata={mockMetricMetadata}
refetchMetricDetails={mockRefetchMetricDetails}
/>,
);
const editButton = screen.getByText('Edit');
fireEvent.click(editButton);
const metricDescriptionInput = screen.getByTestId('description-input');
fireEvent.change(metricDescriptionInput, {
target: { value: 'Updated description' },
});
const saveButton = screen.getByText('Save');
fireEvent.click(saveButton);
const onSuccessCallback =
mockUseUpdateMetricMetadata.mock.calls[0][1].onSuccess;
onSuccessCallback({ statusCode: 200 });
expect(mockSuccessNotification).toHaveBeenCalledWith({
message: 'Metadata updated successfully',
});
expect(mockRefetchMetricDetails).toHaveBeenCalled();
});
it('should show error notification when metadata update fails with non-200 response', async () => {
render(
<Metadata
metricName={mockMetricName}
metadata={mockMetricMetadata}
refetchMetricDetails={mockRefetchMetricDetails}
/>,
);
const editButton = screen.getByText('Edit');
fireEvent.click(editButton);
const metricDescriptionInput = screen.getByTestId('description-input');
fireEvent.change(metricDescriptionInput, {
target: { value: 'Updated description' },
});
const saveButton = screen.getByText('Save');
fireEvent.click(saveButton);
const onSuccessCallback =
mockUseUpdateMetricMetadata.mock.calls[0][1].onSuccess;
onSuccessCallback({ statusCode: 500 });
expect(mockErrorNotification).toHaveBeenCalledWith({
message:
'Failed to update metadata, please try again. If the issue persists, please contact support.',
});
});
it('should show error notification when metadata update fails', async () => {
render(
<Metadata
metricName={mockMetricName}
metadata={mockMetricMetadata}
refetchMetricDetails={mockRefetchMetricDetails}
/>,
);
const editButton = screen.getByText('Edit');
fireEvent.click(editButton);
const metricDescriptionInput = screen.getByTestId('description-input');
fireEvent.change(metricDescriptionInput, {
target: { value: 'Updated description' },
});
const saveButton = screen.getByText('Save');
fireEvent.click(saveButton);
const onErrorCallback = mockUseUpdateMetricMetadata.mock.calls[0][1].onError;
const error = new Error('Failed to update metadata');
onErrorCallback(error);
expect(mockErrorNotification).toHaveBeenCalledWith({
message:
'Failed to update metadata, please try again. If the issue persists, please contact support.',
});
});
it('cancel button should cancel the edit mode', () => {
render(
<Metadata
metricName={mockMetricName}
metadata={mockMetricMetadata}
refetchMetricDetails={mockRefetchMetricDetails}
/>,
);
const editButton = screen.getByText('Edit');
expect(editButton).toBeInTheDocument();
fireEvent.click(editButton);
const cancelButton = screen.getByText('Cancel');
expect(cancelButton).toBeInTheDocument();
fireEvent.click(cancelButton);
const editButton2 = screen.getByText('Edit');
expect(editButton2).toBeInTheDocument();
});
});

View File

@@ -1,16 +1,16 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { MetricDetails as MetricDetailsType } from 'api/metricsExplorer/getMetricDetails';
import { MetricDetails } from 'api/metricsExplorer/getMetricDetails';
import { MetricType } from 'api/metricsExplorer/getMetricsList';
import ROUTES from 'constants/routes';
import * as useGetMetricDetails from 'hooks/metricsExplorer/useGetMetricDetails';
import * as useUpdateMetricMetadata from 'hooks/metricsExplorer/useUpdateMetricMetadata';
import * as useHandleExplorerTabChange from 'hooks/useHandleExplorerTabChange';
import MetricDetails from '../MetricDetails';
import MetricDetailsView from '../MetricDetails';
const mockMetricName = 'test-metric';
const mockMetricDescription = 'description for a test metric';
const mockMetricData: MetricDetailsType = {
const mockMetricData: MetricDetails = {
name: mockMetricName,
description: mockMetricDescription,
unit: 'count',
@@ -84,7 +84,7 @@ jest.mock('hooks/useSafeNavigate', () => ({
describe('MetricDetails', () => {
it('renders metric details correctly', () => {
render(
<MetricDetails
<MetricDetailsView
onClose={mockOnClose}
isOpen
isModalTimeSelection
@@ -114,7 +114,7 @@ describe('MetricDetails', () => {
},
} as any);
render(
<MetricDetails
<MetricDetailsView
onClose={mockOnClose}
isOpen
metricName={mockMetricName}
@@ -143,7 +143,7 @@ describe('MetricDetails', () => {
} as any);
render(
<MetricDetails
<MetricDetailsView
onClose={mockOnClose}
isOpen
metricName={mockMetricName}
@@ -162,7 +162,7 @@ describe('MetricDetails', () => {
} as any);
render(
<MetricDetails
<MetricDetailsView
onClose={mockOnClose}
isOpen
metricName={mockMetricName}
@@ -179,7 +179,7 @@ describe('MetricDetails', () => {
.spyOn(useGetMetricDetails, 'useGetMetricDetails')
.mockReturnValue(mockUseGetMetricDetailsData as any);
render(
<MetricDetails
<MetricDetailsView
onClose={mockOnClose}
isOpen
metricName={mockMetricName}
@@ -204,7 +204,7 @@ describe('MetricDetails', () => {
},
} as any);
render(
<MetricDetails
<MetricDetailsView
onClose={mockOnClose}
isOpen
metricName={mockMetricName}

View File

@@ -1,259 +0,0 @@
import { Temporality } from 'api/metricsExplorer/getMetricDetails';
import { MetricType } from 'api/metricsExplorer/getMetricsList';
import {
determineIsMonotonic,
formatTimestampToReadableDate,
getMetricDetailsQuery,
} from '../utils';
describe('MetricDetails utils', () => {
describe('determineIsMonotonic', () => {
it('should return true for histogram metrics', () => {
expect(determineIsMonotonic(MetricType.HISTOGRAM)).toBe(true);
});
it('should return true for exponential histogram metrics', () => {
expect(determineIsMonotonic(MetricType.EXPONENTIAL_HISTOGRAM)).toBe(true);
});
it('should return false for gauge metrics', () => {
expect(determineIsMonotonic(MetricType.GAUGE)).toBe(false);
});
it('should return false for summary metrics', () => {
expect(determineIsMonotonic(MetricType.SUMMARY)).toBe(false);
});
it('should return true for sum metrics with cumulative temporality', () => {
expect(determineIsMonotonic(MetricType.SUM, Temporality.CUMULATIVE)).toBe(
true,
);
});
it('should return false for sum metrics with delta temporality', () => {
expect(determineIsMonotonic(MetricType.SUM, Temporality.DELTA)).toBe(false);
});
it('should return false by default', () => {
expect(determineIsMonotonic('' as MetricType, '' as Temporality)).toBe(
false,
);
});
});
describe('formatTimestampToReadableDate', () => {
const FEW_SECONDS_AGO = 'Few seconds ago';
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2024-01-15T12:00:00.000Z'));
});
afterEach(() => {
jest.useRealTimers();
});
it('should return "Few seconds ago" for timestamps less than 60 seconds ago', () => {
const timestamp = '2024-01-15T11:59:30.000Z';
expect(formatTimestampToReadableDate(timestamp)).toBe(FEW_SECONDS_AGO);
});
it('should return "1 minute ago" for exactly 1 minute ago', () => {
const timestamp = '2024-01-15T11:59:00.000Z';
expect(formatTimestampToReadableDate(timestamp)).toBe('1 minute ago');
});
it('should return "X minutes ago" for multiple minutes ago', () => {
const timestamp = '2024-01-15T11:55:00.000Z';
expect(formatTimestampToReadableDate(timestamp)).toBe('5 minutes ago');
});
it('should return "1 hour ago" for exactly 1 hour ago', () => {
const timestamp = '2024-01-15T11:00:00.000Z';
expect(formatTimestampToReadableDate(timestamp)).toBe('1 hour ago');
});
it('should return "X hours ago" for multiple hours ago', () => {
const timestamp = '2024-01-15T09:00:00.000Z';
expect(formatTimestampToReadableDate(timestamp)).toBe('3 hours ago');
});
it('should return "Yesterday at HH:MM" for exactly 1 day ago', () => {
const timestamp = '2024-01-14T12:00:00.000Z';
const expectedTime = new Date(timestamp).toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
expect(formatTimestampToReadableDate(timestamp)).toBe(
`Yesterday at ${expectedTime}`,
);
});
it('should return "X days ago" for multiple days ago (less than 7 days)', () => {
const timestamp = '2024-01-12T12:00:00.000Z';
expect(formatTimestampToReadableDate(timestamp)).toBe('3 days ago');
});
it('should return localized date string for dates 7 or more days ago', () => {
const oldTimestamp = '2024-01-01T12:00:00.000Z';
const result = formatTimestampToReadableDate(oldTimestamp);
expect(result).not.toBe(FEW_SECONDS_AGO);
expect(typeof result).toBe('string');
});
it('should handle future timestamps correctly', () => {
const timestamp = '2024-01-16T12:00:00.000Z';
const result = formatTimestampToReadableDate(timestamp);
expect(result).toBe(FEW_SECONDS_AGO);
});
});
describe('getMetricDetailsQuery', () => {
const TEST_METRIC_NAME = 'test_metric';
const API_GATEWAY = 'api-gateway';
it('should create correct query for SUM metric type', () => {
const query = getMetricDetailsQuery(TEST_METRIC_NAME, MetricType.SUM);
expect(query.builder.queryData[0]?.aggregateAttribute.key).toBe(
TEST_METRIC_NAME,
);
expect(query.builder.queryData[0]?.aggregateAttribute.type).toBe(
MetricType.SUM,
);
expect(query.builder.queryData[0]?.aggregateOperator).toBe('rate');
expect(query.builder.queryData[0]?.timeAggregation).toBe('rate');
expect(query.builder.queryData[0]?.spaceAggregation).toBe('sum');
});
it('should create correct query for GAUGE metric type', () => {
const query = getMetricDetailsQuery(TEST_METRIC_NAME, MetricType.GAUGE);
expect(query.builder.queryData[0]?.aggregateAttribute.key).toBe(
TEST_METRIC_NAME,
);
expect(query.builder.queryData[0]?.aggregateAttribute.type).toBe(
MetricType.GAUGE,
);
expect(query.builder.queryData[0]?.aggregateOperator).toBe('avg');
expect(query.builder.queryData[0]?.timeAggregation).toBe('avg');
expect(query.builder.queryData[0]?.spaceAggregation).toBe('avg');
});
it('should create correct query for SUMMARY metric type', () => {
const query = getMetricDetailsQuery(TEST_METRIC_NAME, MetricType.SUMMARY);
expect(query.builder.queryData[0]?.aggregateAttribute.key).toBe(
TEST_METRIC_NAME,
);
expect(query.builder.queryData[0]?.aggregateAttribute.type).toBe(
MetricType.SUMMARY,
);
expect(query.builder.queryData[0]?.aggregateOperator).toBe('noop');
expect(query.builder.queryData[0]?.timeAggregation).toBe('noop');
expect(query.builder.queryData[0]?.spaceAggregation).toBe('sum');
});
it('should create correct query for HISTOGRAM metric type', () => {
const query = getMetricDetailsQuery(TEST_METRIC_NAME, MetricType.HISTOGRAM);
expect(query.builder.queryData[0]?.aggregateAttribute.key).toBe(
TEST_METRIC_NAME,
);
expect(query.builder.queryData[0]?.aggregateAttribute.type).toBe(
MetricType.HISTOGRAM,
);
expect(query.builder.queryData[0]?.aggregateOperator).toBe('noop');
expect(query.builder.queryData[0]?.timeAggregation).toBe('noop');
expect(query.builder.queryData[0]?.spaceAggregation).toBe('p90');
});
it('should create correct query for EXPONENTIAL_HISTOGRAM metric type', () => {
const query = getMetricDetailsQuery(
TEST_METRIC_NAME,
MetricType.EXPONENTIAL_HISTOGRAM,
);
expect(query.builder.queryData[0]?.aggregateAttribute.key).toBe(
TEST_METRIC_NAME,
);
expect(query.builder.queryData[0]?.aggregateAttribute.type).toBe(
MetricType.EXPONENTIAL_HISTOGRAM,
);
expect(query.builder.queryData[0]?.aggregateOperator).toBe('noop');
expect(query.builder.queryData[0]?.timeAggregation).toBe('noop');
expect(query.builder.queryData[0]?.spaceAggregation).toBe('p90');
});
it('should create query with default values for unknown metric type', () => {
const query = getMetricDetailsQuery(TEST_METRIC_NAME, undefined);
expect(query.builder.queryData[0]?.aggregateAttribute.key).toBe(
TEST_METRIC_NAME,
);
expect(query.builder.queryData[0]?.aggregateAttribute.type).toBe('');
expect(query.builder.queryData[0]?.aggregateOperator).toBe('noop');
expect(query.builder.queryData[0]?.timeAggregation).toBe('noop');
expect(query.builder.queryData[0]?.spaceAggregation).toBe('noop');
});
it('should include filter when provided', () => {
const filter = { key: 'service', value: API_GATEWAY };
const query = getMetricDetailsQuery(
TEST_METRIC_NAME,
MetricType.SUM,
filter,
);
expect(query.builder.queryData[0]?.filters.items).toHaveLength(1);
expect(query.builder.queryData[0]?.filters.items[0]?.key?.key).toBe(
'service',
);
expect(query.builder.queryData[0]?.filters.items[0]?.value).toBe(
API_GATEWAY,
);
expect(query.builder.queryData[0]?.filters.items[0]?.op).toBe('=');
});
it('should include groupBy when provided', () => {
const groupBy = 'service';
const query = getMetricDetailsQuery(
TEST_METRIC_NAME,
MetricType.SUM,
undefined,
groupBy,
);
expect(query.builder.queryData[0]?.groupBy).toHaveLength(1);
expect(query.builder.queryData[0]?.groupBy[0]?.key).toBe('service');
expect(query.builder.queryData[0]?.groupBy[0]?.type).toBe('tag');
});
it('should include both filter and groupBy when provided', () => {
const filter = { key: 'service', value: API_GATEWAY };
const groupBy = 'endpoint';
const query = getMetricDetailsQuery(
TEST_METRIC_NAME,
MetricType.SUM,
filter,
groupBy,
);
expect(query.builder.queryData[0]?.filters.items).toHaveLength(1);
expect(query.builder.queryData[0]?.groupBy).toHaveLength(1);
expect(query.builder.queryData[0]?.filters.items[0]?.key?.key).toBe(
'service',
);
expect(query.builder.queryData[0]?.groupBy[0]?.key).toBe('endpoint');
});
it('should not include filters or groupBy when not provided', () => {
const query = getMetricDetailsQuery(TEST_METRIC_NAME, MetricType.SUM);
expect(query.builder.queryData[0]?.filters.items).toHaveLength(0);
expect(query.builder.queryData[0]?.groupBy).toHaveLength(0);
});
});
});

View File

@@ -36,3 +36,14 @@ export interface AllAttributesValueProps {
filterValue: string[];
goToMetricsExploreWithAppliedAttribute: (key: string, value: string) => void;
}
export interface TopAttributesProps {
items: Array<{
key: string;
count: number;
percentage: number;
}>;
title: string;
loadMore?: () => void;
hideLoadMore?: boolean;
}

View File

@@ -108,7 +108,6 @@ export function getMetricDetailsQuery(
id: `${metricName}----${metricType}---string--`,
isColumn: true,
isJSON: false,
dataType: DataTypes.String,
},
aggregateOperator,
timeAggregation,

View File

@@ -120,28 +120,6 @@
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
.auto-theme-info {
margin-top: 8px;
padding: 8px 12px;
border-radius: 4px;
background: var(--bg-slate-400, #1d212d);
border: 1px solid var(--bg-slate-500, #161922);
.auto-theme-status {
color: var(--bg-vanilla-400, #c0c1c3);
font-family: Inter;
font-size: 11px;
font-style: normal;
line-height: 16px;
letter-spacing: -0.07px;
strong {
color: var(--bg-robin-400, #4e74f8);
font-weight: 600;
}
}
}
}
}
}
@@ -190,19 +168,6 @@
.user-preference-section-content-item-description {
color: var(--bg-ink-300);
}
.auto-theme-info {
background: var(--bg-vanilla-200);
border: 1px solid var(--bg-vanilla-300);
.auto-theme-status {
color: var(--bg-ink-300);
strong {
color: var(--bg-robin-500);
}
}
}
}
}
}

View File

@@ -7,11 +7,8 @@ const logEventFunction = jest.fn();
jest.mock('hooks/useDarkMode', () => ({
__esModule: true,
useIsDarkMode: jest.fn(() => true),
useSystemTheme: jest.fn(() => 'dark'),
default: jest.fn(() => ({
toggleTheme: toggleThemeFunction,
autoSwitch: false,
setAutoSwitch: jest.fn(),
})),
}));
@@ -48,7 +45,7 @@ describe('MySettings Flows', () => {
});
describe('Dark/Light Theme Switch', () => {
it('Should display Dark, Light, and System theme options properly', async () => {
it('Should display Dark and Light theme options properly', async () => {
// Check Dark theme option
expect(screen.getByText('Dark')).toBeInTheDocument();
const darkThemeIcon = screen.getByTestId('dark-theme-icon');
@@ -61,12 +58,6 @@ describe('MySettings Flows', () => {
expect(lightThemeIcon).toBeInTheDocument();
expect(lightThemeIcon.tagName).toBe('svg');
expect(screen.getByText('Beta')).toBeInTheDocument();
// Check System theme option
expect(screen.getByText('System')).toBeInTheDocument();
const autoThemeIcon = screen.getByTestId('auto-theme-icon');
expect(autoThemeIcon).toBeInTheDocument();
expect(autoThemeIcon.tagName).toBe('svg');
});
it('Should have Dark theme selected by default', async () => {

View File

@@ -5,9 +5,9 @@ import logEvent from 'api/common/logEvent';
import updateUserPreference from 'api/v1/user/preferences/name/update';
import { AxiosError } from 'axios';
import { USER_PREFERENCES } from 'constants/userPreferences';
import useThemeMode, { useIsDarkMode, useSystemTheme } from 'hooks/useDarkMode';
import useThemeMode, { useIsDarkMode } from 'hooks/useDarkMode';
import { useNotifications } from 'hooks/useNotifications';
import { MonitorCog, Moon, Sun } from 'lucide-react';
import { Moon, Sun } from 'lucide-react';
import { useAppContext } from 'providers/App/App';
import { useEffect, useState } from 'react';
import { useMutation } from 'react-query';
@@ -19,9 +19,8 @@ import UserInfo from './UserInfo';
function MySettings(): JSX.Element {
const isDarkMode = useIsDarkMode();
const { toggleTheme } = useThemeMode();
const { userPreferences, updateUserPreferenceInContext } = useAppContext();
const { toggleTheme, autoSwitch, setAutoSwitch } = useThemeMode();
const systemTheme = useSystemTheme();
const { notifications } = useNotifications();
const [sideNavPinned, setSideNavPinned] = useState(false);
@@ -69,37 +68,16 @@ function MySettings(): JSX.Element {
),
value: 'light',
},
{
label: (
<div className="theme-option">
<MonitorCog size={12} data-testid="auto-theme-icon" /> System{' '}
</div>
),
value: 'auto',
},
];
const [theme, setTheme] = useState(() => {
if (autoSwitch) return 'auto';
return isDarkMode ? 'dark' : 'light';
});
const [theme, setTheme] = useState(isDarkMode ? 'dark' : 'light');
const handleThemeChange = ({ target: { value } }: RadioChangeEvent): void => {
logEvent('Account Settings: Theme Changed', {
theme: value,
});
setTheme(value);
if (value === 'auto') {
setAutoSwitch(true);
} else {
setAutoSwitch(false);
// Only toggle if the current theme is different from the target
const targetIsDark = value === 'dark';
if (targetIsDark !== isDarkMode) {
toggleTheme();
}
}
toggleTheme();
};
const handleSideNavPinnedChange = (checked: boolean): void => {
@@ -172,23 +150,13 @@ function MySettings(): JSX.Element {
optionType="button"
buttonStyle="solid"
data-testid="theme-selector"
size="middle"
size="small"
/>
</div>
<div className="user-preference-section-content-item-description">
Select if SigNoz&apos;s appearance should be light, dark, or
automatically follow your system preference
Select if SigNoz&apos;s appearance should be light or dark
</div>
{autoSwitch && (
<div className="auto-theme-info">
<div className="auto-theme-status">
Currently following system theme:{' '}
<strong>{systemTheme === 'dark' ? 'Dark' : 'Light'}</strong>
</div>
</div>
)}
</div>
<TimezoneAdaptation />

View File

@@ -1,74 +1,66 @@
// Modal base styles
.add-span-to-funnel-modal {
&__loading-spinner {
display: flex;
align-items: center;
justify-content: center;
height: 400px;
}
&-container {
.ant-modal {
&-content,
&-header {
background: var(--bg-ink-500);
.add-span-to-funnel-modal-container {
.ant-modal {
&-content,
&-header {
background: var(--bg-ink-500);
}
&-header {
border-bottom: none;
.ant-modal-title {
color: var(--bg-vanilla-100);
}
}
&-header {
border-bottom: none;
&-body {
padding: 14px 16px !important;
padding-bottom: 0 !important;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
.ant-modal-title {
&-footer {
margin-top: 0;
background: var(--bg-ink-400);
border-top: 1px solid var(--bg-slate-500);
padding: 16px !important;
.add-span-to-funnel-modal {
&__save-button {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
color: var(--bg-vanilla-100);
}
}
font-size: 12px;
font-weight: 500;
line-height: 24px;
width: 135px;
&-body {
padding: 14px 16px !important;
padding-bottom: 0 !important;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
&-footer {
margin-top: 0;
background: var(--bg-ink-400);
border-top: 1px solid var(--bg-slate-500);
padding: 16px !important;
.add-span-to-funnel-modal {
&__save-button {
.ant-btn-icon {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
color: var(--bg-vanilla-100);
font-size: 12px;
font-weight: 500;
line-height: 24px;
width: 135px;
}
&:disabled {
color: var(--bg-vanilla-400);
.ant-btn-icon {
display: flex;
}
&:disabled {
color: var(--bg-vanilla-400);
.ant-btn-icon {
svg {
stroke: var(--bg-vanilla-400);
}
svg {
stroke: var(--bg-vanilla-400);
}
}
}
&__discard-button {
background: var(--bg-slate-500);
}
}
.ant-btn {
border-radius: 2px;
padding: 4px 8px;
margin: 0 !important;
border: none;
box-shadow: none;
&__discard-button {
background: var(--bg-slate-500);
}
}
.ant-btn {
border-radius: 2px;
padding: 4px 8px;
margin: 0 !important;
border: none;
box-shadow: none;
}
}
}
}
@@ -97,7 +89,7 @@
}
.steps-content {
max-height: 500px;
height: 500px;
}
}
}

View File

@@ -99,7 +99,6 @@ function AddSpanToFunnelModal({
const [triggerSave, setTriggerSave] = useState<boolean>(false);
const [isUnsavedChanges, setIsUnsavedChanges] = useState<boolean>(false);
const [triggerDiscard, setTriggerDiscard] = useState<boolean>(false);
const [isCreatedFromSpan, setIsCreatedFromSpan] = useState<boolean>(false);
const handleSearch = (e: ChangeEvent<HTMLInputElement>): void => {
setSearchQuery(e.target.value);
@@ -127,7 +126,6 @@ function AddSpanToFunnelModal({
const handleFunnelClick = (funnel: FunnelData): void => {
setSelectedFunnelId(funnel.funnel_id);
setActiveView(ModalView.DETAILS);
setIsCreatedFromSpan(false);
};
const handleBack = (): void => {
@@ -135,7 +133,6 @@ function AddSpanToFunnelModal({
setSelectedFunnelId(undefined);
setIsUnsavedChanges(false);
setTriggerSave(false);
setIsCreatedFromSpan(false);
};
const handleCreateNewClick = (): void => {
@@ -191,7 +188,6 @@ function AddSpanToFunnelModal({
if (funnelId) {
setSelectedFunnelId(funnelId);
setActiveView(ModalView.DETAILS);
setIsCreatedFromSpan(true);
}
setIsCreateModalOpen(false);
}}
@@ -210,18 +206,15 @@ function AddSpanToFunnelModal({
<ArrowLeft size={14} />
All funnels
</Button>
<div className="traces-funnel-details">
<div className="traces-funnel-details__steps-config">
<Spin
className="add-span-to-funnel-modal__loading-spinner"
spinning={isFunnelDetailsLoading || isFunnelDetailsFetching}
indicator={<LoadingOutlined spin />}
>
<Spin
style={{ height: 400 }}
spinning={isFunnelDetailsLoading || isFunnelDetailsFetching}
indicator={<LoadingOutlined spin />}
>
<div className="traces-funnel-details">
<div className="traces-funnel-details__steps-config">
{selectedFunnelId && funnelDetails?.payload && (
<FunnelProvider
funnelId={selectedFunnelId}
hasSingleStep={isCreatedFromSpan}
>
<FunnelProvider funnelId={selectedFunnelId}>
<FunnelDetailsView
funnel={funnelDetails.payload}
span={span}
@@ -232,9 +225,9 @@ function AddSpanToFunnelModal({
/>
</FunnelProvider>
)}
</Spin>
</div>
</div>
</div>
</Spin>
</div>
);

View File

@@ -47,7 +47,7 @@ interface ITraceMetadata {
endTime: number;
hasMissingSpans: boolean;
}
interface ISuccessProps {
export interface ISuccessProps {
spans: Span[];
traceMetadata: ITraceMetadata;
interestedSpanId: IInterestedSpan;
@@ -164,6 +164,7 @@ function SpanOverview({
type="text"
size="small"
className="add-funnel-button__button"
data-testid="add-to-funnel-button"
onClick={(e): void => {
e.preventDefault();
e.stopPropagation();

View File

@@ -210,7 +210,11 @@ function useFunnelGraph({
const totalSpans = successSpans + errorSpans;
return (
<div key={step} className="funnel-graph__legend-column">
<div
key={step}
className="funnel-graph__legend-column"
data-testid="funnel-graph-legend-column"
>
<div
className="legend-item"
onMouseEnter={legendHoverHandlers?.onTotalHover}

View File

@@ -2,7 +2,6 @@ import { getAggregateKeys } from 'api/queryBuilder/getAttributeKeys';
import { SOMETHING_WENT_WRONG } from 'constants/api';
import { OPERATORS, QueryBuilderKeys } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { MetricsType } from 'container/MetricsApplication/constant';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useNotifications } from 'hooks/useNotifications';
@@ -83,7 +82,6 @@ export const useActiveLog = (): UseActiveLog => {
operator: string,
isJSON?: boolean,
dataType?: DataTypes,
fieldType?: MetricsType | undefined,
): Promise<void> => {
try {
const keysAutocompleteResponse = await queryClient.fetchQuery(
@@ -106,7 +104,6 @@ export const useActiveLog = (): UseActiveLog => {
fieldKey,
isJSON,
dataType,
fieldType,
);
const currentOperator = getOperatorValue(operator);

View File

@@ -1,169 +0,0 @@
import { act, renderHook } from '@testing-library/react';
import {
ThemeProvider,
useIsDarkMode,
useSystemTheme,
useThemeMode,
} from '../index';
// Mock localStorage
const localStorageMock = {
getItem: jest.fn(),
setItem: jest.fn(),
clear: jest.fn(),
};
Object.defineProperty(window, 'localStorage', {
value: localStorageMock,
});
// Helper function to create matchMedia mock
const createMatchMediaMock = (prefersDark: boolean): jest.Mock =>
jest.fn().mockImplementation((query: string) => ({
matches:
query === '(prefers-color-scheme: dark)' ? prefersDark : !prefersDark,
media: query,
onchange: null,
addListener: jest.fn(),
removeListener: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
}));
// Mock matchMedia
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: createMatchMediaMock(true), // Default to dark theme
});
describe('useDarkMode', () => {
beforeEach(() => {
jest.clearAllMocks();
localStorageMock.getItem.mockReturnValue(null);
});
describe('useSystemTheme', () => {
it('should return dark theme by default', () => {
const { result } = renderHook(() => useSystemTheme());
expect(result.current).toBe('dark');
});
it('should return light theme when system prefers light', () => {
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: createMatchMediaMock(false), // Light theme
});
const { result } = renderHook(() => useSystemTheme());
expect(result.current).toBe('light');
});
});
describe('ThemeProvider', () => {
it('should provide theme context with default values', () => {
const wrapper = ({
children,
}: {
children: React.ReactNode;
}): JSX.Element => <ThemeProvider>{children}</ThemeProvider>;
const { result } = renderHook(() => useThemeMode(), { wrapper });
expect(result.current.theme).toBe('dark');
expect(typeof result.current.toggleTheme).toBe('function');
expect(result.current.autoSwitch).toBe(false);
expect(typeof result.current.setAutoSwitch).toBe('function');
});
it('should load theme from localStorage', () => {
localStorageMock.getItem.mockImplementation((key: string) => {
if (key === 'THEME') return 'light';
if (key === 'THEME_AUTO_SWITCH') return 'true';
return null;
});
const wrapper = ({
children,
}: {
children: React.ReactNode;
}): JSX.Element => <ThemeProvider>{children}</ThemeProvider>;
const { result } = renderHook(() => useThemeMode(), { wrapper });
expect(result.current.theme).toBe('light');
expect(result.current.autoSwitch).toBe(true);
});
it('should toggle theme correctly', () => {
const wrapper = ({
children,
}: {
children: React.ReactNode;
}): JSX.Element => <ThemeProvider>{children}</ThemeProvider>;
const { result } = renderHook(() => useThemeMode(), { wrapper });
act(() => {
result.current.toggleTheme();
});
expect(result.current.theme).toBe('light');
expect(localStorageMock.setItem).toHaveBeenCalledWith('THEME', 'light');
});
it('should handle auto-switch functionality', () => {
// Mock system theme as light
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: createMatchMediaMock(false), // Light theme
});
const wrapper = ({
children,
}: {
children: React.ReactNode;
}): JSX.Element => <ThemeProvider>{children}</ThemeProvider>;
const { result } = renderHook(() => useThemeMode(), { wrapper });
act(() => {
result.current.setAutoSwitch(true);
});
expect(result.current.autoSwitch).toBe(true);
expect(localStorageMock.setItem).toHaveBeenCalledWith(
'THEME_AUTO_SWITCH',
'true',
);
});
});
describe('useIsDarkMode', () => {
it('should return true for dark theme', () => {
localStorageMock.getItem.mockReturnValue('dark');
const wrapper = ({
children,
}: {
children: React.ReactNode;
}): JSX.Element => <ThemeProvider>{children}</ThemeProvider>;
const { result } = renderHook(() => useIsDarkMode(), { wrapper });
expect(result.current).toBe(true);
});
it('should return false for light theme', () => {
localStorageMock.getItem.mockReturnValue('light');
const wrapper = ({
children,
}: {
children: React.ReactNode;
}): JSX.Element => <ThemeProvider>{children}</ThemeProvider>;
const { result } = renderHook(() => useIsDarkMode(), { wrapper });
expect(result.current).toBe(false);
});
});
});

View File

@@ -5,12 +5,9 @@ import set from 'api/browser/localstorage/set';
import { LOCALSTORAGE } from 'constants/localStorage';
import {
createContext,
Dispatch,
ReactNode,
SetStateAction,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react';
@@ -19,54 +16,13 @@ import { THEME_MODE } from './constant';
export const ThemeContext = createContext({
theme: THEME_MODE.DARK,
toggleTheme: (): void => {},
autoSwitch: false,
setAutoSwitch: ((): void => {}) as Dispatch<SetStateAction<boolean>>,
toggleTheme: () => {},
});
// Hook to detect system theme preference
export const useSystemTheme = (): 'light' | 'dark' => {
const [systemTheme, setSystemTheme] = useState<'light' | 'dark'>('dark');
useEffect(() => {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
setSystemTheme(mediaQuery.matches ? 'dark' : 'light');
const handler = (e: MediaQueryListEvent): void => {
setSystemTheme(e.matches ? 'dark' : 'light');
};
mediaQuery.addEventListener('change', handler);
return (): void => mediaQuery.removeEventListener('change', handler);
}, []);
return systemTheme;
};
export function ThemeProvider({ children }: ThemeProviderProps): JSX.Element {
const [theme, setTheme] = useState(get(LOCALSTORAGE.THEME) || THEME_MODE.DARK);
const [autoSwitch, setAutoSwitch] = useState(
get(LOCALSTORAGE.THEME_AUTO_SWITCH) === 'true',
);
const systemTheme = useSystemTheme();
// Handle auto-switch functionality
useEffect(() => {
if (autoSwitch) {
const newTheme = systemTheme === 'dark' ? THEME_MODE.DARK : THEME_MODE.LIGHT;
if (newTheme !== theme) {
setTheme(newTheme);
set(LOCALSTORAGE.THEME, newTheme);
}
}
}, [systemTheme, autoSwitch, theme]);
// Save auto-switch preference
useEffect(() => {
set(LOCALSTORAGE.THEME_AUTO_SWITCH, autoSwitch.toString());
}, [autoSwitch]);
const toggleTheme = useCallback((): void => {
const toggleTheme = useCallback(() => {
if (theme === THEME_MODE.LIGHT) {
setTheme(THEME_MODE.DARK);
set(LOCALSTORAGE.THEME, THEME_MODE.DARK);
@@ -81,10 +37,8 @@ export function ThemeProvider({ children }: ThemeProviderProps): JSX.Element {
() => ({
theme,
toggleTheme,
autoSwitch,
setAutoSwitch,
}),
[theme, toggleTheme, autoSwitch, setAutoSwitch],
[theme, toggleTheme],
);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
@@ -97,16 +51,12 @@ interface ThemeProviderProps {
interface ThemeMode {
theme: string;
toggleTheme: () => void;
autoSwitch: boolean;
setAutoSwitch: Dispatch<SetStateAction<boolean>>;
}
export const useThemeMode = (): ThemeMode => {
const { theme, toggleTheme, autoSwitch, setAutoSwitch } = useContext(
ThemeContext,
);
const { theme, toggleTheme } = useContext(ThemeContext);
return { theme, toggleTheme, autoSwitch, setAutoSwitch };
return { theme, toggleTheme };
};
export const useIsDarkMode = (): boolean => {

View File

@@ -1,14 +1,9 @@
import {
baseAutoCompleteIdKeysOrder,
initialAutocompleteData,
} from 'constants/queryBuilder';
import { MetricsType } from 'container/MetricsApplication/constant';
import { initialAutocompleteData } from 'constants/queryBuilder';
import {
BaseAutocompleteData,
DataTypes,
} from 'types/api/queryBuilder/queryAutocompleteResponse';
import { createIdFromObjectFields } from '../createIdFromObjectFields';
import { chooseAutocompleteFromCustomValue } from '../newQueryBuilder/chooseAutocompleteFromCustomValue';
describe('chooseAutocompleteFromCustomValue', () => {
@@ -18,108 +13,36 @@ describe('chooseAutocompleteFromCustomValue', () => {
key: 'string_key',
dataType: DataTypes.String,
isJSON: false,
isColumn: false,
type: '',
id: createIdFromObjectFields(
{
dataType: DataTypes.String,
key: 'string_key',
isColumn: false,
type: '',
},
baseAutoCompleteIdKeysOrder,
),
},
{
key: 'number_key',
dataType: DataTypes.Float64,
isJSON: false,
isColumn: false,
type: '',
id: createIdFromObjectFields(
{
dataType: DataTypes.Float64,
key: 'number_key',
isColumn: false,
type: '',
},
baseAutoCompleteIdKeysOrder,
),
},
{
key: 'bool_key',
dataType: DataTypes.bool,
isJSON: false,
isColumn: false,
type: '',
id: createIdFromObjectFields(
{ dataType: DataTypes.bool, key: 'bool_key', isColumn: false, type: '' },
baseAutoCompleteIdKeysOrder,
),
},
{
key: 'float_key',
dataType: DataTypes.Float64,
isJSON: false,
isColumn: false,
type: '',
id: createIdFromObjectFields(
{
dataType: DataTypes.Float64,
key: 'float_key',
isColumn: false,
type: '',
},
baseAutoCompleteIdKeysOrder,
),
},
{
key: 'unknown_key',
dataType: DataTypes.EMPTY,
isJSON: false,
isColumn: false,
type: '',
id: createIdFromObjectFields(
{
dataType: DataTypes.EMPTY,
key: 'unknown_key',
isColumn: false,
type: '',
},
baseAutoCompleteIdKeysOrder,
),
},
{
key: 'duplicate_key',
dataType: DataTypes.String,
isJSON: false,
isColumn: false,
type: '',
id: createIdFromObjectFields(
{
dataType: DataTypes.String,
key: 'duplicate_key',
isColumn: false,
type: '',
},
baseAutoCompleteIdKeysOrder,
),
},
{
key: 'duplicate_key',
dataType: DataTypes.Float64,
isJSON: false,
isColumn: false,
type: '',
id: createIdFromObjectFields(
{
dataType: DataTypes.Float64,
key: 'duplicate_key',
isColumn: false,
type: '',
},
baseAutoCompleteIdKeysOrder,
),
},
] as BaseAutocompleteData[];
@@ -192,23 +115,7 @@ describe('chooseAutocompleteFromCustomValue', () => {
// Test case: Perfect match with isJSON true in sourceList
it('should return matching element with isJSON true', () => {
const jsonSourceList = [
{
key: 'json_key',
dataType: DataTypes.String,
isJSON: true,
isColumn: false,
type: '',
id: createIdFromObjectFields(
{
dataType: DataTypes.String,
key: 'json_key',
isColumn: false,
type: '',
isJSON: true,
},
baseAutoCompleteIdKeysOrder,
),
},
{ key: 'json_key', dataType: DataTypes.String, isJSON: true },
];
const result = chooseAutocompleteFromCustomValue(
jsonSourceList as BaseAutocompleteData[],
@@ -386,258 +293,4 @@ describe('chooseAutocompleteFromCustomValue', () => {
});
});
});
describe('when element with same value, same data type, and same fieldType found in sourceList', () => {
const fieldTypeMockSourceList = [
{
key: 'tag_key',
dataType: DataTypes.String,
isJSON: false,
type: MetricsType.Tag,
isColumn: false,
id: createIdFromObjectFields(
{
dataType: DataTypes.String,
key: 'tag_key',
isColumn: false,
type: MetricsType.Tag,
},
baseAutoCompleteIdKeysOrder,
),
},
{
key: 'resource_key',
dataType: DataTypes.Float64,
isJSON: false,
type: MetricsType.Resource,
isColumn: false,
id: createIdFromObjectFields(
{
dataType: DataTypes.Float64,
key: 'resource_key',
isColumn: false,
type: MetricsType.Resource,
},
baseAutoCompleteIdKeysOrder,
),
},
{
key: 'scope_key',
dataType: DataTypes.bool,
isJSON: false,
type: MetricsType.Scope,
isColumn: false,
id: createIdFromObjectFields(
{
dataType: DataTypes.bool,
key: 'scope_key',
isColumn: false,
type: MetricsType.Scope,
},
baseAutoCompleteIdKeysOrder,
),
},
{
key: 'tag_key_duplicate',
dataType: DataTypes.String,
isJSON: false,
type: MetricsType.Tag,
isColumn: false,
id: createIdFromObjectFields(
{
dataType: DataTypes.String,
key: 'tag_key_duplicate',
isColumn: false,
type: MetricsType.Tag,
},
baseAutoCompleteIdKeysOrder,
),
},
{
key: 'tag_key_duplicate',
dataType: DataTypes.String,
isJSON: false,
type: MetricsType.Resource,
isColumn: false,
id: createIdFromObjectFields(
{
dataType: DataTypes.String,
key: 'tag_key_duplicate',
isColumn: false,
type: MetricsType.Resource,
},
baseAutoCompleteIdKeysOrder,
),
},
] as BaseAutocompleteData[];
it('should return matching element for Tag fieldType', () => {
const result = chooseAutocompleteFromCustomValue(
fieldTypeMockSourceList,
'tag_key',
false,
'string' as DataTypes,
MetricsType.Tag,
);
expect(result).toEqual(fieldTypeMockSourceList[0]);
});
it('should return matching element for Resource fieldType', () => {
const result = chooseAutocompleteFromCustomValue(
fieldTypeMockSourceList,
'resource_key',
false,
'number' as DataTypes,
MetricsType.Resource,
);
expect(result).toEqual(fieldTypeMockSourceList[1]);
});
it('should return matching element for Scope fieldType', () => {
const result = chooseAutocompleteFromCustomValue(
fieldTypeMockSourceList,
'scope_key',
false,
'bool' as DataTypes,
MetricsType.Scope,
);
expect(result).toEqual(fieldTypeMockSourceList[2]);
});
it('should return the correct duplicate with matching fieldType', () => {
const result = chooseAutocompleteFromCustomValue(
fieldTypeMockSourceList,
'tag_key_duplicate',
false,
'string' as DataTypes,
MetricsType.Resource,
);
expect(result).toEqual(fieldTypeMockSourceList[4]);
});
});
describe('when element with same value and data type but different fieldType found in sourceList', () => {
const fieldTypeMockSourceList = [
{
key: 'test_key',
dataType: DataTypes.String,
isJSON: false,
type: MetricsType.Tag,
isColumn: false,
id: createIdFromObjectFields(
{
dataType: DataTypes.String,
key: 'test_key',
isColumn: false,
type: MetricsType.Tag,
},
baseAutoCompleteIdKeysOrder,
),
},
] as BaseAutocompleteData[];
it('should return new object with updated fieldType when existing element has different fieldType', () => {
const result = chooseAutocompleteFromCustomValue(
fieldTypeMockSourceList,
'test_key',
false,
'string' as DataTypes,
MetricsType.Resource,
);
expect(result).toEqual({
...initialAutocompleteData,
key: 'test_key',
dataType: DataTypes.String,
isJSON: false,
type: MetricsType.Resource,
});
});
});
describe('when element not found in sourceList but fieldType is provided', () => {
it('should return new object with Tag fieldType', () => {
const result = chooseAutocompleteFromCustomValue(
mockSourceList,
'new_key_with_tag_type',
false,
'string' as DataTypes,
MetricsType.Tag,
);
expect(result).toEqual({
...initialAutocompleteData,
key: 'new_key_with_tag_type',
dataType: DataTypes.String,
isJSON: false,
type: MetricsType.Tag,
});
});
it('should return new object with Resource fieldType', () => {
const result = chooseAutocompleteFromCustomValue(
mockSourceList,
'new_key_with_resource_type',
false,
'number' as DataTypes,
MetricsType.Resource,
);
expect(result).toEqual({
...initialAutocompleteData,
key: 'new_key_with_resource_type',
dataType: DataTypes.Float64,
isJSON: false,
type: MetricsType.Resource,
});
});
it('should return new object with Scope fieldType', () => {
const result = chooseAutocompleteFromCustomValue(
mockSourceList,
'new_key_with_scope_type',
false,
'bool' as DataTypes,
MetricsType.Scope,
);
expect(result).toEqual({
...initialAutocompleteData,
key: 'new_key_with_scope_type',
dataType: DataTypes.bool,
isJSON: false,
type: MetricsType.Scope,
});
});
it('should return new object with empty fieldType when undefined is passed', () => {
const result = chooseAutocompleteFromCustomValue(
mockSourceList,
'new_key_with_undefined_type',
false,
'string' as DataTypes,
undefined,
);
expect(result).toEqual({
...initialAutocompleteData,
key: 'new_key_with_undefined_type',
dataType: DataTypes.String,
isJSON: false,
type: '',
});
});
it('should return new object with isJSON true and fieldType when not found', () => {
const result = chooseAutocompleteFromCustomValue(
mockSourceList,
'json_not_found_with_type',
true,
'string' as DataTypes,
MetricsType.Tag,
);
expect(result).toEqual({
...initialAutocompleteData,
key: 'json_not_found_with_type',
dataType: DataTypes.String,
isJSON: true,
type: MetricsType.Tag,
});
});
});
});

View File

@@ -1,5 +1,4 @@
import { initialAutocompleteData } from 'constants/queryBuilder';
import { MetricsType } from 'container/MetricsApplication/constant';
import {
BaseAutocompleteData,
DataTypes,
@@ -26,15 +25,12 @@ export const chooseAutocompleteFromCustomValue = (
value: string,
isJSON?: boolean,
dataType?: DataTypes | 'number',
fieldType?: MetricsType | undefined,
): BaseAutocompleteData => {
const dataTypeToUse = getDataTypeForCustomValue(dataType);
const firstBaseAutoCompleteValue = sourceList.find(
(sourceAutoComplete) =>
value === sourceAutoComplete.key &&
(dataType === undefined || dataTypeToUse === sourceAutoComplete.dataType) &&
((fieldType === undefined && sourceAutoComplete.type === '') ||
(fieldType !== undefined && fieldType === sourceAutoComplete.type)),
(dataType === undefined || dataTypeToUse === sourceAutoComplete.dataType),
);
if (!firstBaseAutoCompleteValue) {
@@ -42,7 +38,6 @@ export const chooseAutocompleteFromCustomValue = (
...initialAutocompleteData,
key: value,
dataType: dataTypeToUse,
type: fieldType || '',
isJSON,
};
}

View File

@@ -0,0 +1,7 @@
import { createMockFunnel } from 'pages/TracesFunnels/__tests__/mockFunnelsData';
import { FunnelData } from 'types/api/traceFunnels';
export const mockSingleFunnelData: FunnelData = createMockFunnel(
'funnel-1',
'Checkout Process Funnel',
);

View File

@@ -1,4 +1,16 @@
import {
FunnelOverviewPayload,
FunnelOverviewResponse,
} from 'api/traceFunnels';
import { rest } from 'msw';
import {
mockErrorTracesData,
mockFunnelsListData,
mockOverviewData,
mockSlowTracesData,
mockStepsData,
} from 'pages/TracesFunnels/__tests__/mockFunnelsData';
import { FunnelData } from 'types/api/traceFunnels';
import commonEnTranslation from '../../public/locales/en/common.json';
import enTranslation from '../../public/locales/en/translation.json';
@@ -15,8 +27,26 @@ import { membersResponse } from './__mockdata__/members';
import { queryRangeSuccessResponse } from './__mockdata__/query_range';
import { serviceSuccessResponse } from './__mockdata__/services';
import { topLevelOperationSuccessResponse } from './__mockdata__/top_level_operations';
import { mockSingleFunnelData } from './__mockdata__/trace_funnels';
import { traceDetailResponse } from './__mockdata__/tracedetail';
// Define mock data specifically for step transitions
const mockStepTransitionOverviewData: FunnelOverviewResponse = {
status: 'success',
data: [
{
timestamp: '2024-01-01T00:00:00Z',
data: {
avg_duration: 55000000,
avg_rate: 8.5,
conversion_rate: 92.0,
errors: 1,
latency: 150000000,
},
},
],
};
export const handlers = [
rest.post('http://localhost/api/v3/query_range', (req, res, ctx) =>
res(ctx.status(200), ctx.json(queryRangeSuccessResponse)),
@@ -263,4 +293,54 @@ export const handlers = [
rest.get('http://localhost/locales/en-US/common.json', (_, res, ctx) =>
res(ctx.status(200), ctx.json(commonEnTranslation)),
),
rest.get('http://localhost/api/v1/trace-funnels/list', (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({ status: 'success', payload: mockFunnelsListData }),
),
),
rest.get(
'http://localhost/api/v1/trace-funnels/get/:funnelId',
(req, res, ctx) => {
const { funnelId } = req.params;
// Ensure the mock data always uses the requested funnelId
const responseData: FunnelData = {
...mockSingleFunnelData,
funnel_id: funnelId as string,
};
return res(ctx.status(200), ctx.json(responseData));
},
),
rest.post<FunnelOverviewPayload, { funnelId: string }, FunnelOverviewResponse>(
`http://localhost/api/v1/trace-funnels/:funnelId/analytics/overview`,
async (req, res, ctx) => {
const body = await req.json<FunnelOverviewPayload>();
// Check if step_start and step_end are provided in the payload
if (body.step_start !== undefined && body.step_end !== undefined) {
// Return mock data for step transition
return res(ctx.status(200), ctx.json(mockStepTransitionOverviewData));
}
// Otherwise, return the default overall mock data
return res(ctx.status(200), ctx.json(mockOverviewData));
},
),
rest.post(
// Use :funnelId to match any funnel ID requested in tests
`http://localhost/api/v1/trace-funnels/:funnelId/analytics/steps`,
(_, res, ctx) => res(ctx.status(200), ctx.json(mockStepsData)),
),
rest.post(
// Use :funnelId
`http://localhost/api/v1/trace-funnels/:funnelId/analytics/slow-traces`,
(_, res, ctx) => res(ctx.status(200), ctx.json(mockSlowTracesData)),
),
rest.post(
// Use :funnelId
`http://localhost/api/v1/trace-funnels/:funnelId/analytics/error-traces`,
(_, res, ctx) => res(ctx.status(200), ctx.json(mockErrorTracesData)),
),
];

View File

@@ -14,10 +14,6 @@ function TracesFunnelDetails(): JSX.Element {
const { funnelId } = useParams<{ funnelId: string }>();
const { data, isLoading, isError } = useFunnelDetails({ funnelId });
if (isLoading || !data?.payload) {
return <Spinner size="large" tip="Loading..." />;
}
if (isError) {
return (
<NotFoundContainer>
@@ -26,6 +22,10 @@ function TracesFunnelDetails(): JSX.Element {
);
}
if (isLoading || !data?.payload) {
return <Spinner size="large" />;
}
return (
<FunnelProvider funnelId={funnelId}>
<div className="traces-funnel-details">

View File

@@ -0,0 +1,418 @@
import { act, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import ROUTES from 'constants/routes';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { AppProvider } from 'providers/App/App';
import { QueryClient, QueryClientProvider } from 'react-query';
import { MemoryRouter, Route } from 'react-router-dom';
import TracesFunnelDetails from '../TracesFunnelDetails';
// Mock external dependencies
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
jest.mock('components/OverlayScrollbar/OverlayScrollbar', () => ({
__esModule: true,
default: ({ children }: { children: React.ReactNode }): JSX.Element => (
<div>{children}</div>
),
}));
jest.mock(
'container/TopNav/DateTimeSelectionV2/index.tsx',
() =>
function MockDateTimeSelection(): JSX.Element {
return <div>MockDateTimeSelection</div>;
},
);
jest.mock(
'container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2',
() =>
function MockQueryBuilderSearchV2(): JSX.Element {
return <div>MockQueryBuilderSearchV2</div>;
},
);
jest.mock(
'components/CeleryOverview/CeleryOverviewConfigOptions/CeleryOverviewConfigOptions',
() => ({
FilterSelect: ({
placeholder,
onChange,
values,
}: {
placeholder: string;
onChange: (value: string) => void;
values: string;
}): JSX.Element => (
<input
placeholder={placeholder}
value={values || ''}
onChange={(e): void => onChange?.(e.target.value)}
data-testid={`filter-select-${placeholder}`}
/>
),
}),
);
const successNotification = jest.fn();
const errorNotification = jest.fn();
jest.mock('hooks/useNotifications', () => ({
__esModule: true,
useNotifications: jest.fn(() => ({
notifications: {
success: successNotification,
error: errorNotification,
},
})),
}));
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
useSelector: (): { selectedTime: string; loading: boolean } => ({
selectedTime: '1h',
loading: false,
}),
}));
const REACT_ROUTER_DOM = 'react-router-dom';
const mockUseParams = jest.fn();
jest.mock(REACT_ROUTER_DOM, () => ({
...jest.requireActual(REACT_ROUTER_DOM),
useParams: mockUseParams,
useLocation: jest.fn().mockReturnValue({
pathname: '/traces/funnels/test-funnel-id',
}),
}));
jest.mock('providers/App/utils', () => ({
getUserDefaults: jest.fn(() => ({
accessJwt: 'mock-access-token',
refreshJwt: 'mock-refresh-token',
id: 'mock-user-id',
email: 'editor@example.com',
displayName: 'Test Editor',
createdAt: Date.now(),
organization: 'Test Organization',
orgId: 'mock-org-id',
role: 'EDITOR',
})),
}));
// Mock data
const mockFunnelId = 'test-funnel-id';
const MOCK_FUNNEL_NAME = 'Test Funnel';
const mockFunnelData = {
funnel_id: mockFunnelId,
funnel_name: MOCK_FUNNEL_NAME,
user_email: 'test@example.com',
created_at: Date.now() - 86400000,
updated_at: Date.now(),
description: 'Test funnel description',
steps: [
{
id: 'step-1',
step_order: 1,
service_name: 'auth-service',
span_name: 'user-login',
filters: { items: [], op: 'AND' },
latency_pointer: 'start',
latency_type: 'p99',
has_errors: false,
name: 'Login Step',
description: 'User login step',
},
{
id: 'step-2',
step_order: 2,
service_name: 'payment-service',
span_name: 'process-payment',
filters: { items: [], op: 'AND' },
latency_pointer: 'start',
latency_type: 'p99',
has_errors: false,
name: 'Payment Step',
description: 'Payment processing step',
},
],
};
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
staleTime: 0,
cacheTime: 0,
},
mutations: {
retry: false,
},
},
});
// Test render helper
const renderTracesFunnelDetails = (): ReturnType<typeof render> =>
render(
<QueryClientProvider client={queryClient}>
<AppProvider>
<MemoryRouter initialEntries={[`/traces/funnels/${mockFunnelId}`]}>
<Route path={ROUTES.TRACES_FUNNELS_DETAIL}>
<TracesFunnelDetails />
</Route>
</MemoryRouter>
</AppProvider>
</QueryClientProvider>,
);
// Shared setup helper
const setupTest = async (): Promise<void> => {
await act(async () => {
renderTracesFunnelDetails();
});
// Wait for page to load
await waitFor(() => {
expect(screen.getAllByText(MOCK_FUNNEL_NAME)).toHaveLength(2);
});
};
describe('TracesFunnelDetails', () => {
const user = userEvent.setup();
beforeEach(() => {
jest.clearAllMocks();
jest.spyOn(console, 'error').mockImplementation(() => {});
// Mock console.error to track error logs
jest.spyOn(console, 'error').mockImplementation(() => {});
// Mock useParams to return the funnel ID
mockUseParams.mockReturnValue({
funnelId: mockFunnelId,
});
// Setup comprehensive API mocks
server.use(
// Mock funnel details fetch
rest.get(
`http://localhost/api/v1/trace-funnels/${mockFunnelId}`,
(_, res, ctx) => res(ctx.status(200), ctx.json({ data: mockFunnelData })),
),
// Mock all analytics endpoints to avoid errors
rest.post(
'http://localhost/api/v1/trace-funnels/analytics/validate',
(_, res, ctx) => res(ctx.status(200), ctx.json({ data: [{ count: 150 }] })),
),
rest.post(
'http://localhost/api/v1/trace-funnels/analytics/overview',
(_, res, ctx) => res(ctx.status(200), ctx.json({ data: [] })),
),
rest.post(
'http://localhost/api/v1/trace-funnels/analytics/steps',
(_, res, ctx) => res(ctx.status(200), ctx.json({ data: [] })),
),
rest.post(
'http://localhost/api/v1/trace-funnels/analytics/steps/overview',
(_, res, ctx) => res(ctx.status(200), ctx.json({ data: [] })),
),
rest.post(
'http://localhost/api/v1/trace-funnels/analytics/slow-traces',
(_, res, ctx) => res(ctx.status(200), ctx.json({ data: [] })),
),
rest.post(
'http://localhost/api/v1/trace-funnels/analytics/error-traces',
(_, res, ctx) => res(ctx.status(200), ctx.json({ data: [] })),
),
);
});
afterEach(() => {
jest.restoreAllMocks();
queryClient.clear();
// Restore console.error
(console.error as jest.Mock).mockRestore?.();
});
describe('Basic Page Loading', () => {
it('should load and display funnel information', async () => {
await setupTest();
// Check that the page loads with basic funnel info (appears in breadcrumb + title)
await waitFor(() => {
expect(screen.getAllByText(MOCK_FUNNEL_NAME)).toHaveLength(2);
});
// Check breadcrumb navigation
expect(screen.getByText('All funnels')).toBeInTheDocument();
// Check funnel steps section header
expect(screen.getByText('FUNNEL STEPS')).toBeInTheDocument();
});
it('should show loading spinner initially', async () => {
// Mock slow API response
server.use(
rest.get(
`http://localhost/api/v1/trace-funnels/${mockFunnelId}`,
(_, res, ctx) =>
res(ctx.delay(100), ctx.status(200), ctx.json({ data: mockFunnelData })),
),
);
await setupTest();
// Should show Ant Design loading spinner
expect(screen.getByRole('img', { name: /loading/i })).toBeInTheDocument();
// Wait for data to load
await waitFor(() => {
expect(screen.getAllByText(MOCK_FUNNEL_NAME)).toHaveLength(2);
});
});
it('should handle API errors (currently shows loading spinner due to component logic)', async () => {
// Mock API error with proper HTTP error status
server.use(
rest.get(
`http://localhost/api/v1/trace-funnels/${mockFunnelId}`,
(_, res, ctx) =>
res(ctx.status(404), ctx.json({ message: 'Funnel not found' })),
),
);
await act(async () => {
renderTracesFunnelDetails();
});
await waitFor(() => {
expect(
screen.getByText('Error loading funnel details'),
).toBeInTheDocument();
});
// Verify that the API error was actually triggered
expect(console.error).toHaveBeenCalled();
});
});
describe('Step Configuration Display', () => {
beforeEach(async () => {
await setupTest();
});
it('should display funnel steps with their names', async () => {
// Check step names are displayed
expect(screen.getByText('Login Step')).toBeInTheDocument();
expect(screen.getByText('Payment Step')).toBeInTheDocument();
});
it('should show step configuration interface', async () => {
// Check that service and span selectors are present by placeholder text
expect(screen.getAllByPlaceholderText('Select Service')).toHaveLength(2);
expect(screen.getAllByPlaceholderText('Select Span name')).toHaveLength(2);
});
it('should display add step button when under step limit', async () => {
// Find "Add Funnel Step" button (only shown if less than 3 steps)
const addStepButton = screen.getByRole('button', {
name: /add funnel step/i,
});
expect(addStepButton).toBeInTheDocument();
});
it('should show step actions via ellipsis menu', async () => {
// Find ellipsis icons for step actions (they are SVG elements with specific class)
const ellipsisElements = document.querySelectorAll(
'.funnel-item__action-icon',
);
expect(ellipsisElements.length).toBeGreaterThan(0);
// Click on ellipsis to open popover
await user.click(ellipsisElements[0] as Element);
// Check that step action options appear
await waitFor(() => {
expect(screen.getByText('Delete')).toBeInTheDocument();
});
});
});
describe('Footer and Save Functionality', () => {
beforeEach(async () => {
await setupTest();
});
it('should display step count in footer', async () => {
// Check step count display
expect(screen.getByText('2 steps')).toBeInTheDocument();
});
it('should show save button in footer', async () => {
// Find save button
const saveButton = screen.getByRole('button', { name: /save funnel/i });
expect(saveButton).toBeInTheDocument();
});
it('should display footer validation section', async () => {
// Check that the footer exists with validation status
const footer = screen.getByTestId('steps-footer');
expect(footer).toBeInTheDocument();
});
});
describe('Navigation Flow', () => {
beforeEach(async () => {
await setupTest();
});
it('should display correct breadcrumb navigation', async () => {
// Check breadcrumb links
const allFunnelsLink = screen.getByRole('link', { name: /all funnels/i });
expect(allFunnelsLink).toHaveAttribute('href', '/traces/funnels');
// Check current funnel name in breadcrumb
expect(screen.getAllByText(MOCK_FUNNEL_NAME)).toHaveLength(2);
});
});
describe('Error Handling', () => {
it('should handle API validation errors gracefully', async () => {
// Mock validation API error
server.use(
rest.post(
'http://localhost/api/v1/trace-funnels/analytics/validate',
(_, res, ctx) =>
res(ctx.status(500), ctx.json({ message: 'Validation failed' })),
),
);
await setupTest();
// Should still load the main funnel configuration
await waitFor(() => {
expect(screen.getAllByText(MOCK_FUNNEL_NAME)).toHaveLength(2);
});
// Configuration section should still work
expect(screen.getByText('FUNNEL STEPS')).toBeInTheDocument();
});
});
});

View File

@@ -2,17 +2,16 @@ import './StepsContent.styles.scss';
import { Button, Steps, Tooltip } from 'antd';
import logEvent from 'api/common/logEvent';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { PlusIcon, Undo2 } from 'lucide-react';
import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext';
import { useAppContext } from 'providers/App/App';
import { memo, useCallback } from 'react';
import { memo, useCallback, useMemo } from 'react';
import { Span } from 'types/api/trace/getTraceV2';
import FunnelStep from './FunnelStep';
import InterStepConfig from './InterStepConfig';
const { Step } = Steps;
function StepsContent({
isTraceDetailsPage,
span,
@@ -36,57 +35,60 @@ function StepsContent({
);
}, [span, handleAddStep, handleReplaceStep, steps.length, hasEditPermission]);
const stepItems = useMemo(
() =>
steps.map((step, index) => ({
key: `step-${index + 1}`,
description: (
<div className="steps-content__description">
<div className="funnel-step-wrapper">
<FunnelStep stepData={step} index={index} stepsCount={steps.length} />
{isTraceDetailsPage && span && (
<Tooltip
title={
!hasEditPermission
? 'You need editor or admin access to replace steps'
: ''
}
>
<Button
type="default"
className="funnel-step-wrapper__replace-button"
icon={<Undo2 size={12} />}
disabled={
(step.service_name === span.serviceName &&
step.span_name === span.name) ||
!hasEditPermission
}
onClick={(): void =>
handleReplaceStep(index, span.serviceName, span.name)
}
>
Replace
</Button>
</Tooltip>
)}
</div>
{/* Display InterStepConfig only between steps */}
{index < steps.length - 1 && (
<InterStepConfig index={index} step={step} />
)}
</div>
),
})),
[steps, isTraceDetailsPage, span, hasEditPermission, handleReplaceStep],
);
return (
<div className="steps-content">
<Steps direction="vertical">
{steps.map((step, index) => (
<Step
key={`step-${index + 1}`}
description={
<div className="steps-content__description">
<div className="funnel-step-wrapper">
<FunnelStep stepData={step} index={index} stepsCount={steps.length} />
{isTraceDetailsPage && span && (
<Tooltip
title={
!hasEditPermission
? 'You need editor or admin access to replace steps'
: ''
}
>
<Button
type="default"
className="funnel-step-wrapper__replace-button"
icon={<Undo2 size={12} />}
disabled={
(step.service_name === span.serviceName &&
step.span_name === span.name) ||
!hasEditPermission
}
onClick={(): void =>
handleReplaceStep(index, span.serviceName, span.name)
}
>
Replace
</Button>
</Tooltip>
)}
</div>
{/* Display InterStepConfig only between steps */}
{index < steps.length - 1 && (
// the latency type should be sent with the n+1th step
<InterStepConfig index={index + 1} step={steps[index + 1]} />
)}
</div>
}
/>
))}
{/* For now we are only supporting 3 steps */}
{steps.length < 3 && (
<Step
className="steps-content__add-step"
description={
!isTraceDetailsPage ? (
<OverlayScrollbar>
<>
<Steps direction="vertical" items={stepItems} />
{/* For now we are only supporting 3 steps */}
{steps.length < 3 && (
<div className="steps-content__add-step">
{!isTraceDetailsPage ? (
<Tooltip
title={
!hasEditPermission
@@ -122,11 +124,11 @@ function StepsContent({
Add for new Step
</Button>
</Tooltip>
)
}
/>
)}
</Steps>
)}
</div>
)}
</>
</OverlayScrollbar>
</div>
);
}

View File

@@ -62,7 +62,7 @@ function StepsFooter({ stepsCount, isSaving }: StepsFooterProps): JSX.Element {
} = useFunnelContext();
return (
<div className="steps-footer">
<div className="steps-footer" data-testid="steps-footer">
<div className="steps-footer__left">
<Cone className="funnel-icon" size={14} />
<span>{stepsCount} steps</span>

View File

@@ -103,10 +103,13 @@ function FunnelGraph(): JSX.Element {
return (
<Spin spinning={isFetching} indicator={<LoadingOutlined spin />}>
<div className={cx('funnel-graph', `funnel-graph--${totalSteps}-columns`)}>
<div className="funnel-graph__chart-container">
<div
className="funnel-graph__chart-container"
data-testid="funnel-graph-canvas"
>
<canvas ref={canvasRef} />
</div>
<div className="funnel-graph__legends">
<div className="funnel-graph__legends" data-testid="funnel-graph-legend">
{Array.from({ length: totalSteps }, (_, index) => {
const prevTotalSpans =
index > 0

View File

@@ -18,6 +18,7 @@ interface FunnelMetricsTableProps {
isLoading?: boolean;
isError?: boolean;
emptyState?: JSX.Element;
testId: string;
}
function FunnelMetricsContentRenderer({
@@ -71,9 +72,10 @@ function FunnelMetricsTable({
isLoading,
isError,
emptyState,
testId,
}: FunnelMetricsTableProps): JSX.Element {
return (
<div className="funnel-metrics">
<div className="funnel-metrics" data-testid={testId}>
<div className="funnel-metrics__header">
<div className="funnel-metrics__title">{title}</div>
{subtitle && (

View File

@@ -9,6 +9,7 @@ interface FunnelTableProps {
columns: Array<ColumnProps<any>>;
title: string;
tooltip?: string;
testId: string;
}
function FunnelTable({
@@ -17,9 +18,10 @@ function FunnelTable({
columns = [],
title,
tooltip,
testId,
}: FunnelTableProps): JSX.Element {
return (
<div className="funnel-table">
<div className="funnel-table" data-testid={testId}>
<div className="funnel-table__header">
<div className="funnel-table__title">{title}</div>
<div className="funnel-table__actions">
@@ -41,6 +43,7 @@ function FunnelTable({
rowClassName={(_, index): string =>
index % 2 === 0 ? 'table-row-dark' : 'table-row-light'
}
rowKey={(record): string => record.id}
/>
</div>
);

View File

@@ -26,6 +26,7 @@ interface FunnelTopTracesTableProps {
Error
>;
steps: FunnelStepData[];
testId: string;
}
function FunnelTopTracesTable({
@@ -36,6 +37,7 @@ function FunnelTopTracesTable({
tooltip,
steps,
useQueryHook,
testId,
}: FunnelTopTracesTableProps): JSX.Element {
const { startTime, endTime } = useFunnelContext();
const payload = useMemo(
@@ -65,6 +67,7 @@ function FunnelTopTracesTable({
return (
<FunnelTable
testId={testId}
title={title}
tooltip={tooltip}
columns={topTracesTableColumns}

View File

@@ -12,6 +12,7 @@ function OverallMetrics(): JSX.Element {
return (
<FunnelMetricsTable
title="Overall Funnel Metrics"
testId="overall-funnel-metrics"
subtitle={{
label: 'Conversion rate',
value: `${conversionRate.toFixed(2)}%`,

View File

@@ -35,6 +35,7 @@ function StepsTransitionMetrics({
return (
<FunnelMetricsTable
title={currentTransition.label}
testId="step-transition-metrics"
subtitle={{
label: 'Conversion rate',
value: `${conversionRate.toFixed(2)}%`,

View File

@@ -18,6 +18,7 @@ function TopSlowestTraces(props: TopSlowestTracesProps): JSX.Element {
title="Slowest 5 traces"
tooltip="A list of the slowest traces in the funnel"
useQueryHook={useFunnelSlowTraces}
testId="top-slowest-traces-table"
/>
);
}

View File

@@ -18,6 +18,7 @@ function TopTracesWithErrors(props: TopTracesWithErrorsProps): JSX.Element {
title="Traces with errors"
tooltip="A list of the traces with errors in the funnel"
useQueryHook={useFunnelErrorTraces}
testId="top-traces-with-errors-table"
/>
);
}

View File

@@ -1,7 +1,7 @@
import { FunnelStepData, LatencyOptions } from 'types/api/traceFunnels';
import { v4 } from 'uuid';
export const createInitialStepsData = (): FunnelStepData[] => [
export const initialStepsData: FunnelStepData[] = [
{
id: v4(),
step_order: 1,
@@ -12,6 +12,7 @@ export const createInitialStepsData = (): FunnelStepData[] => [
op: 'and',
},
latency_pointer: 'start',
latency_type: undefined,
has_errors: false,
},
{
@@ -29,21 +30,6 @@ export const createInitialStepsData = (): FunnelStepData[] => [
},
];
export const createSingleStepData = (): FunnelStepData[] => [
{
id: v4(),
step_order: 1,
service_name: '',
span_name: '',
filters: {
items: [],
op: 'and',
},
latency_pointer: 'start',
has_errors: false,
},
];
export const LatencyPointers: {
value: FunnelStepData['latency_pointer'];
key: string;

View File

@@ -10,10 +10,7 @@ import { normalizeSteps } from 'hooks/TracesFunnels/useFunnelConfiguration';
import { useValidateFunnelSteps } from 'hooks/TracesFunnels/useFunnels';
import getStartEndRangeTime from 'lib/getStartEndRangeTime';
import { isEqual } from 'lodash-es';
import {
createInitialStepsData,
createSingleStepData,
} from 'pages/TracesFunnelDetails/constants';
import { initialStepsData } from 'pages/TracesFunnelDetails/constants';
import {
createContext,
Dispatch,
@@ -31,7 +28,7 @@ import { FunnelData, FunnelStepData } from 'types/api/traceFunnels';
import { GlobalReducer } from 'types/reducer/globalTime';
import { v4 } from 'uuid';
interface FunnelContextType {
export interface FunnelContextType {
startTime: number;
endTime: number;
selectedTime: CustomTimeType | Time | TimeV2;
@@ -71,11 +68,9 @@ const FunnelContext = createContext<FunnelContextType | undefined>(undefined);
export function FunnelProvider({
children,
funnelId,
hasSingleStep = false,
}: {
children: React.ReactNode;
funnelId: string;
hasSingleStep?: boolean;
}): JSX.Element {
const { selectedTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
@@ -94,13 +89,7 @@ export function FunnelProvider({
funnelId,
]);
const funnel = data?.payload;
const defaultSteps = useMemo(
() => (hasSingleStep ? createSingleStepData() : createInitialStepsData()),
[hasSingleStep],
);
const initialSteps = funnel?.steps?.length ? funnel.steps : defaultSteps;
const initialSteps = funnel?.steps?.length ? funnel.steps : initialStepsData;
const [steps, setSteps] = useState<FunnelStepData[]>(initialSteps);
const [triggerSave, setTriggerSave] = useState<boolean>(false);
const [isUpdatingFunnel, setIsUpdatingFunnel] = useState<boolean>(false);
@@ -166,7 +155,7 @@ export function FunnelProvider({
setSteps((prev) => [
...prev,
{
...createInitialStepsData()[0],
...initialStepsData[0],
id: v4(),
step_order: prev.length + 1,
},
@@ -307,10 +296,6 @@ export function FunnelProvider({
);
}
FunnelProvider.defaultProps = {
hasSingleStep: false,
};
export function useFunnelContext(): FunnelContextType {
const context = useContext(FunnelContext);
if (context === undefined) {

View File

@@ -0,0 +1,414 @@
/* eslint-disable sonarjs/no-duplicate-string */
/* eslint-disable react/jsx-props-no-spreading */
import {
fireEvent,
render,
RenderResult,
screen,
waitFor,
within,
} from '@testing-library/react';
import ROUTES from 'constants/routes';
import Success, {
ISuccessProps,
} from 'container/TraceWaterfall/TraceWaterfallStates/Success/Success';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { AppProvider } from 'providers/App/App';
import MockQueryClientProvider from 'providers/test/MockQueryClientProvider';
import { act } from 'react-dom/test-utils';
import { MemoryRouter } from 'react-router-dom';
import { FunnelProvider } from '../FunnelContext';
import {
mockFunnelsListData,
mockSpanSuccessComponentProps,
} from './mockFunnelsData';
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
const firstFunnel = mockFunnelsListData[0];
const secondFunnel = mockFunnelsListData[1];
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: (): { search: string } => ({
search: '',
}),
}));
const renderTraceWaterfallSuccess = (
props: Partial<ISuccessProps> = {},
): RenderResult =>
render(
<MockQueryClientProvider>
<AppProvider>
<FunnelProvider funnelId={firstFunnel.funnel_id}>
<MemoryRouter initialEntries={[ROUTES.TRACES_FUNNELS_DETAIL]}>
<Success {...mockSpanSuccessComponentProps} {...props} />
</MemoryRouter>
</FunnelProvider>
</AppProvider>
</MockQueryClientProvider>,
);
window.Element.prototype.getBoundingClientRect = jest
.fn()
.mockReturnValue({ height: 1000, width: 1000 });
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): { safeNavigate: jest.Mock } => ({
safeNavigate: jest.fn(),
}),
}));
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
useSelector: (): { selectedTime: string; loading: boolean } => ({
selectedTime: '1h',
loading: false,
}),
}));
const mockUseFunnelsList = jest.fn();
const mockUseValidateFunnelSteps = jest.fn(() => ({
data: { payload: { data: [] } },
isLoading: false,
isFetching: false,
}));
const mockUseUpdateFunnelSteps = jest.fn(() => ({
mutate: jest.fn(),
isLoading: false,
}));
jest.mock('hooks/TracesFunnels/useFunnels', () => ({
...jest.requireActual('hooks/TracesFunnels/useFunnels'),
useFunnelsList: (): void => mockUseFunnelsList(),
useValidateFunnelSteps: (): {
data: { payload: { data: unknown[] } };
isLoading: boolean;
} => mockUseValidateFunnelSteps(),
useUpdateFunnelSteps: (): { mutate: jest.Mock; isLoading: boolean } =>
mockUseUpdateFunnelSteps(),
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useHistory: jest.fn(() => ({
location: {
pathname: '',
search: '',
},
})),
useLocation: jest.fn(() => ({
pathname: '',
search: '',
})),
}));
jest.mock(
'container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2',
() =>
function MockQueryBuilderSearchV2(): JSX.Element {
return <div>MockQueryBuilderSearchV2</div>;
},
);
jest.mock(
'components/OverlayScrollbar/OverlayScrollbar',
() =>
function MockOverlayScrollbar({
children,
}: {
children: React.ReactNode;
}): React.ReactNode {
return children;
},
);
jest.mock('providers/App/utils', () => ({
getUserDefaults: jest.fn(() => ({
accessJwt: 'mock-access-token',
refreshJwt: 'mock-refresh-token',
id: 'mock-user-id',
email: 'editor@example.com',
displayName: 'Test Editor',
createdAt: Date.now(),
organization: 'Test Organization',
orgId: 'mock-org-id',
role: 'EDITOR',
})),
}));
describe('Add span to funnel from trace details page', () => {
// Set NODE_ENV to development for modal to render
const originalNodeEnv = process.env.NODE_ENV;
beforeAll(() => {
process.env.NODE_ENV = 'development';
});
afterAll(() => {
process.env.NODE_ENV = originalNodeEnv;
});
it('displays add to funnel icon for spans with valid service and span names', async () => {
await act(() => renderTraceWaterfallSuccess());
expect(await screen.findByTestId('add-to-funnel-button')).toBeInTheDocument();
});
it("doesn't display add to funnel icon for spans with invalid service and span names", async () => {
await act(() =>
renderTraceWaterfallSuccess({
spans: [
{
...mockSpanSuccessComponentProps.spans[0],
serviceName: '',
name: '',
},
],
}),
);
await waitFor(() => {
expect(screen.queryByTestId('add-to-funnel-button')).not.toBeInTheDocument();
});
});
describe('add span to funnel modal tests', () => {
beforeEach(async () => {
mockUseFunnelsList.mockReturnValue({
data: { payload: mockFunnelsListData },
isLoading: false,
isError: false,
});
server.use(
rest.get(
`http://localhost/api/v1/trace-funnels/${firstFunnel.funnel_id}`,
(_, res, ctx) => res(ctx.status(200), ctx.json({ data: firstFunnel })),
),
);
await act(() => renderTraceWaterfallSuccess());
const addFunnelButton = await screen.findByTestId('add-to-funnel-button');
await act(() => {
fireEvent.click(addFunnelButton);
});
// Wait for modal to appear and content to load
// Wait for funnel list content to appear in modal
await waitFor(async () => {
expect(
await screen.findByText(firstFunnel.funnel_name),
).toBeInTheDocument();
});
});
it('should display the add to funnel modal when the add to funnel icon is clicked', async () => {
const addSpanToFunnelModal = await screen.findByRole('dialog');
expect(
within(addSpanToFunnelModal).getByText('Add span to funnel'),
).toBeInTheDocument();
expect(
within(addSpanToFunnelModal).getByPlaceholderText(
'Search by name, description, or tags...',
),
).toBeInTheDocument();
expect(
within(addSpanToFunnelModal).getByText('Create new funnel'),
).toBeInTheDocument();
expect(
within(addSpanToFunnelModal).getByText(firstFunnel.funnel_name),
).toBeInTheDocument();
expect(
within(addSpanToFunnelModal).getByText(secondFunnel.funnel_name),
).toBeInTheDocument();
expect(
within(addSpanToFunnelModal).getByText(firstFunnel.user_email),
).toBeInTheDocument();
expect(
within(addSpanToFunnelModal).getByText(secondFunnel.user_email),
).toBeInTheDocument();
});
it('should search / filter when the user types in the search input', async () => {
const addSpanToFunnelModal = await screen.findByRole('dialog');
const searchInput = within(addSpanToFunnelModal).getByPlaceholderText(
'Search by name, description, or tags...',
);
await act(() =>
fireEvent.change(searchInput, {
target: { value: firstFunnel.funnel_name },
}),
);
await waitFor(() => {
expect(searchInput).toHaveValue(firstFunnel.funnel_name);
expect(
within(addSpanToFunnelModal).getByText(firstFunnel.funnel_name),
).toBeInTheDocument();
expect(
within(addSpanToFunnelModal).queryByText(secondFunnel.funnel_name),
).not.toBeInTheDocument();
});
});
describe('funnel details view tests', () => {
beforeEach(async () => {
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
const addSpanToFunnelModal = await screen.findByRole('dialog');
const firstFunnelButton = await within(addSpanToFunnelModal).findByText(
firstFunnel.funnel_name,
);
act(() => {
fireEvent.click(firstFunnelButton);
});
// Wait for the modal to transition to details view
await waitFor(async () => {
expect(
await within(addSpanToFunnelModal).findByRole('button', {
name: 'All funnels',
}),
).toBeInTheDocument();
});
});
it('should go to funnels details view of modal when a funnel is clicked, and go back to list view on clicking all funnels button', async () => {
const addSpanToFunnelModal = await screen.findByRole('dialog');
expect(
within(addSpanToFunnelModal).getByRole('button', {
name: 'All funnels',
}),
).toBeInTheDocument();
expect(
within(addSpanToFunnelModal).queryByRole('button', {
name: 'Create new funnel',
}),
).not.toBeInTheDocument();
const allFunnelsButton = await within(addSpanToFunnelModal).getByText(
/all funnels/i,
);
await act(() => {
fireEvent.click(allFunnelsButton);
});
await within(addSpanToFunnelModal).findByRole('button', {
name: 'Create new funnel',
});
expect(
within(addSpanToFunnelModal).getByRole('button', {
name: 'Create new funnel',
}),
).toBeInTheDocument();
expect(
within(addSpanToFunnelModal).queryByRole('button', {
name: 'All funnels',
}),
).not.toBeInTheDocument();
});
it('should render the funnel preview card correctly', async () => {
const addSpanToFunnelModal = await screen.findByRole('dialog');
expect(
within(addSpanToFunnelModal).getByText(firstFunnel.funnel_name),
).toBeInTheDocument();
expect(
within(addSpanToFunnelModal).getByText(firstFunnel.user_email),
).toBeInTheDocument();
});
it('should render the funnel steps correctly', async () => {
const addSpanToFunnelModal = await screen.findByRole('dialog');
const expectTextWithCount = async (
text: string,
count: number,
): Promise<void> => {
expect(
await within(addSpanToFunnelModal).findAllByText(text),
).toHaveLength(count);
};
await expectTextWithCount('Step 1', 1);
await expectTextWithCount('Step 2', 1);
await expectTextWithCount('ServiceA', 1);
await expectTextWithCount('SpanA', 1);
await expectTextWithCount('ServiceB', 1);
await expectTextWithCount('SpanB', 1);
await expectTextWithCount('Where', 2);
await expectTextWithCount('Errors', 2);
await expectTextWithCount('Latency type', 1);
await expectTextWithCount('P99', 1);
await expectTextWithCount('P95', 1);
await expectTextWithCount('P90', 1);
await expectTextWithCount('Replace', 2);
});
it('should replace the selected span and service names on clicking the replace button', async () => {
const addSpanToFunnelModal = await screen.findByRole('dialog');
expect(within(addSpanToFunnelModal).getByText('SpanA')).toBeInTheDocument();
expect(
within(addSpanToFunnelModal).getByText('ServiceA'),
).toBeInTheDocument();
const replaceButtons = await within(
addSpanToFunnelModal,
).findAllByRole('button', { name: /replace/i });
expect(replaceButtons[0]).toBeEnabled();
await act(() => {
fireEvent.click(replaceButtons[0]);
});
expect(
within(addSpanToFunnelModal).getByText('producer-svc-3'),
).toBeInTheDocument();
expect(
within(addSpanToFunnelModal).getByText('topic2 publish'),
).toBeInTheDocument();
expect(replaceButtons[0]).toBeDisabled();
});
it('should add the span as a new step on clicking the add for a new step button', async () => {
const addSpanToFunnelModal = await screen.findByRole('dialog');
const addNewStepButton = await within(
addSpanToFunnelModal,
).findByRole('button', { name: /add for new step/i });
await act(() => {
fireEvent.click(addNewStepButton);
});
expect(
await within(addSpanToFunnelModal).queryByText('Add for new Step'),
).not.toBeInTheDocument();
expect(
await within(addSpanToFunnelModal).findAllByText('Where'),
).toHaveLength(3);
});
});
});
});

View File

@@ -0,0 +1,278 @@
import {
act,
render,
RenderResult,
screen,
waitFor,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import ROUTES from 'constants/routes';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import TracesFunnelDetails from 'pages/TracesFunnelDetails';
import { AppProvider } from 'providers/App/App';
import { QueryClient, QueryClientProvider } from 'react-query';
import { MemoryRouter, Route } from 'react-router-dom';
import TracesFunnels from '..';
jest.mock('components/OverlayScrollbar/OverlayScrollbar', () => ({
__esModule: true,
default: ({ children }: { children: React.ReactNode }): JSX.Element => (
<div>{children}</div>
),
}));
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
jest.mock('providers/App/utils', () => ({
getUserDefaults: jest.fn(() => ({
accessJwt: 'mock-access-token',
refreshJwt: 'mock-refresh-token',
id: 'mock-user-id',
email: 'editor@example.com',
displayName: 'Test Editor',
createdAt: Date.now(),
organization: 'Test Organization',
orgId: 'mock-org-id',
role: 'EDITOR',
})),
}));
const successNotification = jest.fn();
const errorNotification = jest.fn();
jest.mock('hooks/useNotifications', () => ({
__esModule: true,
useNotifications: jest.fn(() => ({
notifications: {
success: successNotification,
error: errorNotification,
},
})),
}));
const mockNavigate = jest.fn();
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): { safeNavigate: jest.Mock } => ({
safeNavigate: mockNavigate,
}),
}));
const createdFunnelId = `newly-created-funnel-id`;
const newFunnelName = 'My Test Funnel';
export const FUNNELS_LIST_URL = 'http://localhost/api/v1/trace-funnels/list';
const CREATE_FUNNEL_URL = 'http://localhost/api/v1/trace-funnels/new';
// Helper function to encapsulate opening the create funnel modal
const openCreateFunnelModal = async (
user: ReturnType<typeof userEvent.setup>,
): Promise<void> => {
await screen.findByText(/no funnels yet/i);
await user.click(screen.getAllByText(/new funnel/i)[1]);
await screen.findByRole('dialog', { name: /create new funnel/i });
};
// eslint-disable-next-line sonarjs/no-duplicate-string
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: jest.fn().mockReturnValue({
pathname: `${ROUTES.LOGS_EXPLORER}`,
}),
useParams: jest.fn(() => ({
funnelId: createdFunnelId,
})),
}));
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
useSelector: (): { selectedTime: string; loading: boolean } => ({
selectedTime: '1h',
loading: false,
}),
}));
jest.mock(
'container/TopNav/DateTimeSelectionV2/index.tsx',
() =>
function MockDateTimeSelection(): JSX.Element {
return <div>MockDateTimeSelection</div>;
},
);
jest.mock(
'container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2',
() =>
function MockQueryBuilderSearchV2(): JSX.Element {
return <div>MockQueryBuilderSearchV2</div>;
},
);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
export const renderTraceFunnelRoutes = (
initialEntries: string[] = [ROUTES.TRACES_FUNNELS],
): RenderResult =>
render(
<QueryClientProvider client={queryClient}>
<AppProvider>
<MemoryRouter initialEntries={initialEntries}>
<Route path={ROUTES.TRACES_FUNNELS} exact>
<TracesFunnels />
</Route>
<Route path={ROUTES.TRACES_FUNNELS_DETAIL}>
<TracesFunnelDetails />
</Route>
</MemoryRouter>
</AppProvider>
</QueryClientProvider>,
);
const renderFunnelRoutesWithAct = async (
initialEntries: string[] = [ROUTES.TRACES_FUNNELS],
): Promise<void> => {
await act(async () => {
renderTraceFunnelRoutes(initialEntries);
});
};
describe('Funnel Creation Flow', () => {
const user = userEvent.setup();
beforeEach(() => {
jest.clearAllMocks();
(jest.requireMock('react-router-dom').useParams as jest.Mock).mockReturnValue(
{},
);
});
afterEach(() => {
jest.restoreAllMocks();
queryClient.clear(); // Clear react-query cache between tests
});
describe('Navigating to Funnel Creation Modal', () => {
// Setup: Mock empty list and render the list page
beforeEach(async () => {
server.use(
rest.get(FUNNELS_LIST_URL, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ payload: [] })),
),
);
await renderFunnelRoutesWithAct();
await screen.findByText(/no funnels yet/i);
});
it('should render the "New Funnel" button when the funnel list is empty', async () => {
expect(screen.getAllByText(/new funnel/i).length).toBe(2);
});
it('should open the "Create New Funnel" modal when the "New Funnel" button is clicked', async () => {
await user.click(screen.getAllByText(/new funnel/i)[1]);
await screen.findByRole('dialog', {
name: /create new funnel/i,
});
});
});
describe('Creating a New Funnel', () => {
// Setup: Render list page with mocked empty list
beforeEach(async () => {
server.use(
rest.get(FUNNELS_LIST_URL, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ payload: [] })),
),
);
await renderFunnelRoutesWithAct();
});
it('should create a new funnel and navigate to its details page upon successful API response', async () => {
// Mock specific API calls for successful creation
server.use(
rest.post(CREATE_FUNNEL_URL, async (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data: { funnel_id: createdFunnelId } })),
),
);
// Mock useParams for the target details page
(jest.requireMock('react-router-dom')
.useParams as jest.Mock).mockReturnValue({
funnelId: createdFunnelId,
});
await openCreateFunnelModal(user);
const nameInput = screen.getByPlaceholderText(
/eg\. checkout dropoff funnel/i,
);
await user.type(nameInput, newFunnelName);
const createButton = screen.getByRole('button', { name: /create funnel/i });
await user.click(createButton);
await waitFor(() => {
expect(successNotification).toHaveBeenCalledWith(
expect.objectContaining({ message: 'Funnel created successfully' }),
);
});
await waitFor(() => {
const expectedPath = ROUTES.TRACES_FUNNELS_DETAIL.replace(
':funnelId',
createdFunnelId,
);
expect(mockNavigate).toHaveBeenCalledWith(expectedPath);
});
});
it('should display an error notification if the funnel creation API call fails', async () => {
// Temporarily suppress console.error for expected error log
const consoleErrorSpy = jest
.spyOn(console, 'error')
.mockImplementation(() => {});
// Mock specific API calls for creation failure
server.use(
rest.post(CREATE_FUNNEL_URL, async (_, res, ctx) =>
res(ctx.status(500), ctx.json({ message: 'Failed to create funnel' })),
),
);
await openCreateFunnelModal(user);
const nameInput = screen.getByPlaceholderText(
/eg\. checkout dropoff funnel/i,
);
await user.type(nameInput, newFunnelName);
const createButton = screen.getByRole('button', { name: /create funnel/i });
await user.click(createButton);
await waitFor(() => {
expect(errorNotification).toHaveBeenCalledWith({
message: { message: 'Failed to create funnel' },
});
});
// Ensure navigation did not occur
expect(mockNavigate).not.toHaveBeenCalled();
consoleErrorSpy.mockRestore();
});
});
});

View File

@@ -0,0 +1,409 @@
// eslint-disable-next-line import/no-unresolved
import 'jest-canvas-mock';
import { screen, waitFor, within } from '@testing-library/react';
import ROUTES from 'constants/routes';
import * as FunnelsHooksModule from 'hooks/TracesFunnels/useFunnels';
import { mockSingleFunnelData } from 'mocks-server/__mockdata__/trace_funnels';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import * as FunnelContextModule from 'pages/TracesFunnels/FunnelContext';
import { act } from 'react-dom/test-utils';
import { renderTraceFunnelRoutes } from './CreateFunnel.test';
import {
defaultMockFunnelContext,
mockErrorTracesData,
mockOverviewData,
mockSlowTracesData,
mockStepsData,
mockStepsOverviewData,
} from './mockFunnelsData';
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
useSelector: (): { selectedTime: string; loading: boolean } => ({
selectedTime: '1h',
loading: false,
}),
}));
const mockUseParams = jest.requireMock('react-router-dom')
.useParams as jest.Mock;
const renderFunnelDetailsWithAct = async (): Promise<void> => {
await act(async () => {
await renderTraceFunnelRoutes([
ROUTES.TRACES_FUNNELS_DETAIL.replace(
':funnelId',
mockSingleFunnelData.funnel_id,
),
]);
});
};
window.ResizeObserver =
window.ResizeObserver ||
jest.fn().mockImplementation(() => ({
disconnect: jest.fn(),
observe: jest.fn(),
unobserve: jest.fn(),
}));
describe('Viewing Funnel Details', () => {
beforeEach(() => {
mockUseParams.mockReturnValue({
funnelId: mockSingleFunnelData.funnel_id,
});
});
it('should render the Funnel Details page and display the funnel name', async () => {
// Mock the API call to fetch funnel details
server.use(
rest.get(
`http://localhost/api/v1/trace-funnels/${mockSingleFunnelData.funnel_id}`,
(_, res, ctx) =>
res(ctx.status(200), ctx.json({ data: mockSingleFunnelData })),
),
// Mock validate endpoint as it might be called on load
rest.post(
// eslint-disable-next-line sonarjs/no-duplicate-string
'http://localhost/api/v1/trace-funnels/analytics/validate',
(_, res, ctx) => res(ctx.status(200), ctx.json({ data: [] })),
),
);
// Render the component for this test
await renderFunnelDetailsWithAct();
// Assertions for the general funnel details page render
await waitFor(() => {
expect(screen.getByText(/all funnels/i)).toBeInTheDocument();
});
await screen.findAllByText(mockSingleFunnelData.funnel_name);
});
it('should display the total number of steps based on the fetched funnel data', async () => {
// Mock API calls
server.use(
rest.get(
`http://localhost/api/v1/trace-funnels/${mockSingleFunnelData.funnel_id}`,
(_, res, ctx) =>
res(ctx.status(200), ctx.json({ data: mockSingleFunnelData })),
),
rest.post(
'http://localhost/api/v1/trace-funnels/analytics/validate',
(_, res, ctx) => res(ctx.status(200), ctx.json({ data: [] })),
),
);
// Render
await renderFunnelDetailsWithAct();
await waitFor(() => {
// Check if the step count is displayed correctly
expect(
screen.getByText(`${mockSingleFunnelData.steps?.length || 0} steps`),
).toBeInTheDocument();
});
});
// Nested describe for tests requiring FunnelContext mocks
describe('when FunnelContext state is mocked', () => {
let useFunnelsContextSpy: jest.SpyInstance;
let useFunnelStepsGraphDataSpy: jest.SpyInstance;
beforeEach(() => {
useFunnelsContextSpy = jest.spyOn(FunnelContextModule, 'useFunnelContext');
useFunnelStepsGraphDataSpy = jest.spyOn(
FunnelsHooksModule,
'useFunnelStepsGraphData',
);
server.use(
rest.post(
'http://localhost/api/v1/trace-funnels/analytics/validate',
(_, res, ctx) => res(ctx.status(200), ctx.json({ data: [] })),
),
rest.get(
`http://localhost/api/v1/trace-funnels/${mockSingleFunnelData.funnel_id}`,
(_, res, ctx) =>
res(ctx.status(200), ctx.json({ data: mockSingleFunnelData })),
),
);
});
afterEach(() => {
useFunnelsContextSpy.mockRestore();
useFunnelStepsGraphDataSpy.mockRestore();
});
it('should show empty state UI when no services or spans are selected in steps', async () => {
// Apply specific context mock *before* rendering
useFunnelsContextSpy.mockReturnValue({
...defaultMockFunnelContext,
hasAllEmptyStepFields: true,
isValidateStepsLoading: false,
});
// Render *after* setting the context mock for this specific test
await renderFunnelDetailsWithAct();
// Assertions specific to the empty steps scenario
await waitFor(() => {
expect(screen.getByText('No spans selected yet.')).toBeInTheDocument();
expect(screen.getByText('No service / span names')).toBeInTheDocument();
});
});
it('should show missing fields UI when steps have incomplete service/span selections', async () => {
// Apply specific context mock
useFunnelsContextSpy.mockReturnValue({
...defaultMockFunnelContext,
hasIncompleteStepFields: true,
isValidateStepsLoading: false,
});
// Render *after* setting the context mock
await renderFunnelDetailsWithAct();
// Check if the missing services / spans message is shown in footer and results
await waitFor(() => {
expect(screen.getAllByText('Missing service / span names').length).toBe(2);
});
});
it('should show empty results state when no traces match the funnel steps', async () => {
// Apply specific context mock
useFunnelsContextSpy.mockReturnValue({
...defaultMockFunnelContext,
validTracesCount: 0,
isValidateStepsLoading: false,
});
// Render *after* setting the context mock
await renderFunnelDetailsWithAct();
await waitFor(() => {
expect(
screen.getByText('There are no traces that match the funnel steps.'),
).toBeInTheDocument();
expect(screen.getByText('No valid traces found')).toBeInTheDocument();
});
});
// Describe block for tests when valid traces exist based on context
describe('when valid traces exist', () => {
beforeEach(async () => {
// Apply the common context mock for this scenario
useFunnelsContextSpy.mockReturnValue({
...defaultMockFunnelContext, // Use the imported mock data
validTracesCount: 1,
isValidateStepsLoading: false,
});
// Mock all the API endpoints that the components will call
server.use(
// Mock funnel overview endpoint
rest.post(
'http://localhost/api/v1/trace-funnels/analytics/overview',
(_, res, ctx) => res(ctx.status(200), ctx.json(mockOverviewData)),
),
// Mock funnel steps overview endpoint
rest.post(
'http://localhost/api/v1/trace-funnels/analytics/steps/overview',
(_, res, ctx) => res(ctx.status(200), ctx.json(mockStepsOverviewData)),
),
// Mock funnel slow traces endpoint
rest.post(
'http://localhost/api/v1/trace-funnels/analytics/slow-traces',
(_, res, ctx) => res(ctx.status(200), ctx.json(mockSlowTracesData)),
),
// Mock funnel error traces endpoint
rest.post(
'http://localhost/api/v1/trace-funnels/analytics/error-traces',
(_, res, ctx) => res(ctx.status(200), ctx.json(mockErrorTracesData)),
),
// Mock funnel steps graph data endpoint
rest.post(
'http://localhost/api/v1/trace-funnels/analytics/steps',
(_, res, ctx) => res(ctx.status(200), ctx.json(mockStepsData)),
),
);
// eslint-disable-next-line sonarjs/no-identical-functions
await act(async () => {
renderTraceFunnelRoutes([
ROUTES.TRACES_FUNNELS_DETAIL.replace(
':funnelId',
mockSingleFunnelData.funnel_id,
),
]);
});
});
it('should display the "Valid traces found" and steps', async () => {
await waitFor(() => {
expect(screen.getByText('Valid traces found')).toBeInTheDocument();
expect(screen.getByText('2 steps')).toBeInTheDocument();
});
});
it('should display the overall funnel metrics based on context data', async () => {
await waitFor(() => {
const overallFunnelMetrics = screen.getByTestId('overall-funnel-metrics');
const expectedTexts = [
'Overall Funnel Metrics',
'Conversion rate',
'⎯',
'80.00%',
'Avg. Rate',
'10.5 req/s',
'Errors',
'2',
'Avg. Duration',
'123 ms',
'P99 Latency',
'250 ms',
];
expectedTexts.forEach((text) => {
expect(within(overallFunnelMetrics).getByText(text)).toBeInTheDocument();
});
});
});
it('should display step transition metrics based on context data', async () => {
await waitFor(() => {
const stepTransitionMetrics = screen.getByTestId(
'step-transition-metrics',
);
const expectedTexts = [
'Step 1 → Step 2',
'Conversion rate',
'⎯',
'92.00%',
'Avg. Rate',
'8.5 req/s',
'Errors',
'1',
'Avg. Duration',
'55 µs',
'P99 Latency',
'150 µs',
];
expectedTexts.forEach((text) => {
expect(within(stepTransitionMetrics).getByText(text)).toBeInTheDocument();
});
});
});
it('should display the slowest traces table based on context data', async () => {
await waitFor(() => {
const slowTracesTable = screen.getByTestId('top-slowest-traces-table');
const expectedTexts = [
'Slowest 5 traces',
'TRACE ID',
'STEP TRANSITION DURATION',
'slow-trace-1',
'500 ms',
];
expectedTexts.forEach((text) => {
expect(within(slowTracesTable).getByText(text)).toBeInTheDocument();
});
});
});
it('should display the traces with errors table based on context data', async () => {
await waitFor(() => {
const errorTracesTable = screen.getByTestId(
'top-traces-with-errors-table',
);
const expectedTexts = [
'Traces with errors',
'TRACE ID',
'STEP TRANSITION DURATION',
'error-trace-1',
'151 ms',
];
expectedTexts.forEach((text) => {
expect(within(errorTracesTable).getByText(text)).toBeInTheDocument();
});
});
});
// Updated test for Funnel Graph elements
it('should display the funnel graph and legend based on mocked graph data', async () => {
await waitFor(() => {
// Check for the canvas element (assuming data-testid="funnel-graph-canvas" exists)
expect(screen.getByTestId('funnel-graph-canvas')).toBeInTheDocument();
// Check for the legend container (assuming data-testid="funnel-graph-legend" exists)
const legendContainer = screen.getByTestId('funnel-graph-legend');
expect(legendContainer).toBeInTheDocument();
// Get the actual graph data from our mock
const graphMetrics = mockStepsData.data[0].data;
const successSteps: number[] = [];
const errorSteps: number[] = [];
let stepCount = 1;
while (
graphMetrics?.[`total_s${stepCount}_spans`] !== undefined &&
graphMetrics?.[`total_s${stepCount}_errored_spans`] !== undefined
) {
const total = graphMetrics[`total_s${stepCount}_spans`];
const errors = graphMetrics[`total_s${stepCount}_errored_spans`];
successSteps.push(total - errors);
errorSteps.push(errors);
stepCount += 1;
}
const totalSteps = stepCount - 1;
// Assert number of legend columns based on calculated totalSteps
const legendColumns = within(legendContainer).getAllByTestId(
'funnel-graph-legend-column',
);
expect(legendColumns).toHaveLength(totalSteps); // Should be 2 based on mock data
// Check content of the first legend column (Step 1)
const step1Total = successSteps[0] + errorSteps[0];
expect(
within(legendColumns[0]).getByText('Total spans'),
).toBeInTheDocument();
expect(
within(legendColumns[0]).getByText(step1Total.toString()),
).toBeInTheDocument(); // 100
expect(
within(legendColumns[0]).getByText('Error spans'),
).toBeInTheDocument();
expect(
within(legendColumns[0]).getByText(errorSteps[0].toString()),
).toBeInTheDocument(); // 10
// Check content of the second legend column (Step 2)
const step2Total = successSteps[1] + errorSteps[1];
expect(
within(legendColumns[1]).getByText('Total spans'),
).toBeInTheDocument();
expect(
within(legendColumns[1]).getByText(step2Total.toString()),
).toBeInTheDocument(); // 80
expect(
within(legendColumns[1]).getByText('Error spans'),
).toBeInTheDocument();
expect(
within(legendColumns[1]).getByText(errorSteps[1].toString()),
).toBeInTheDocument(); // 8
// Check for the percentage change pill in the second column
expect(
within(legendColumns[1]).getByTestId('change-percentage-pill'),
).toBeInTheDocument();
});
});
});
});
});

View File

@@ -0,0 +1,178 @@
import { render, RenderResult, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import ROUTES from 'constants/routes';
import dayjs from 'dayjs';
import { AppProvider } from 'providers/App/App';
import MockQueryClientProvider from 'providers/test/MockQueryClientProvider';
import { MemoryRouter, Route } from 'react-router-dom';
import TracesFunnels from '..';
import { mockFunnelsListData, mockSingleFunnelData } from './mockFunnelsData';
const mockUseFunnelsList = jest.fn();
jest.mock('hooks/TracesFunnels/useFunnels', () => ({
...jest.requireActual('hooks/TracesFunnels/useFunnels'),
useFunnelsList: (): void => mockUseFunnelsList(),
useFunnelDetails: jest.fn(() => ({
// Mock for details page test
data: { payload: mockSingleFunnelData },
isLoading: false,
isError: false,
})),
}));
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): any => ({
safeNavigate: jest.fn(),
}),
}));
jest.mock('hooks/useUrlQuery', () => ({
__esModule: true,
default: jest.fn(() => new URLSearchParams()),
}));
describe('Viewing and Navigating Funnels', () => {
beforeEach(() => {
// Reset mocks before each test
jest.clearAllMocks();
// Default successful fetch for list
mockUseFunnelsList.mockReturnValue({
data: { payload: mockFunnelsListData },
isLoading: false,
isError: false,
});
});
describe('TracesFunnels List View', () => {
const renderListComponent = (): RenderResult =>
render(
<MockQueryClientProvider>
<AppProvider>
<MemoryRouter initialEntries={[ROUTES.TRACES_FUNNELS]}>
<Route path={ROUTES.TRACES_FUNNELS}>
<TracesFunnels />
</Route>
{/* Add a dummy route for detail page to test navigation link */}
<Route path={ROUTES.TRACES_FUNNELS_DETAIL}>
<div>Mock Details Page</div>
</Route>
</MemoryRouter>
</AppProvider>
</MockQueryClientProvider>,
);
it('should display the list of funnels when data is loaded', async () => {
renderListComponent();
// Wait for the list items to appear (use findBy for async)
await screen.findByText(mockFunnelsListData[0].funnel_name);
// Verify both funnel names are present
expect(
screen.getByText(mockFunnelsListData[0].funnel_name),
).toBeInTheDocument();
expect(
screen.getByText(mockFunnelsListData[1].funnel_name),
).toBeInTheDocument();
});
it('should display funnel details like creation date and user', async () => {
renderListComponent();
const firstFunnel = mockFunnelsListData[0];
await screen.findByText(firstFunnel.funnel_name); // Ensure rendering is complete
// Check for formatted date (adjust format if needed)
const expectedDateFormat = DATE_TIME_FORMATS.FUNNELS_LIST_DATE;
const expectedDate = dayjs(firstFunnel.created_at).format(
expectedDateFormat,
);
expect(screen.getByText(expectedDate)).toBeInTheDocument();
// Check for user
expect(
screen.getByText(firstFunnel.user_email as string),
).toBeInTheDocument();
// Find the first funnel item container and check within it
const firstFunnelItem = screen
.getByText(firstFunnel.funnel_name)
.closest('.funnel-item');
// Get the expected initial
const expectedInitial = firstFunnel.user_email
?.substring(0, 1)
.toUpperCase();
// Look for the avatar initial specifically within the first funnel item
const avatarElement = within(firstFunnelItem as HTMLElement).getByText(
expectedInitial as string,
);
expect(avatarElement).toBeInTheDocument();
});
it('should render links for each funnel item pointing to the details page', async () => {
renderListComponent();
await screen.findByText(mockFunnelsListData[0].funnel_name); // Ensure rendering
const firstFunnelLink = screen.getByRole('link', {
name: new RegExp(mockFunnelsListData[0].funnel_name),
}); // Find link associated with the funnel item content
const secondFunnelLink = screen.getByRole('link', {
name: new RegExp(mockFunnelsListData[1].funnel_name),
});
const expectedPath1 = ROUTES.TRACES_FUNNELS_DETAIL.replace(
':funnelId',
mockFunnelsListData[0].funnel_id,
);
const expectedPath2 = ROUTES.TRACES_FUNNELS_DETAIL.replace(
':funnelId',
mockFunnelsListData[1].funnel_id,
);
expect(firstFunnelLink).toHaveAttribute('href', expectedPath1);
expect(secondFunnelLink).toHaveAttribute('href', expectedPath2);
// Optional: Simulate click and verify navigation (less common now, asserting link is often enough)
await userEvent.click(firstFunnelLink);
expect(screen.getByText('Mock Details Page')).toBeInTheDocument(); // If you setup the route element
});
it('should display loading skeletons when isLoading is true', () => {
mockUseFunnelsList.mockReturnValue({
data: null, // No data yet
isLoading: true,
isError: false,
});
const { container } = renderListComponent();
expect(
container.querySelectorAll('.ant-skeleton-active').length,
).toBeGreaterThan(0);
});
it('should display error message when isError is true', () => {
mockUseFunnelsList.mockReturnValue({
data: null,
isLoading: false,
isError: true,
});
renderListComponent();
expect(screen.getByText(/something went wrong/i)).toBeInTheDocument();
});
it('should display empty state when data is empty', () => {
mockUseFunnelsList.mockReturnValue({
data: { payload: [] }, // Empty array
isLoading: false,
isError: false,
});
renderListComponent();
expect(screen.getByText(/No Funnels yet/i)).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,211 @@
import {
ErrorTraceData,
FunnelOverviewResponse,
FunnelStepsResponse,
SlowTraceData,
} from 'api/traceFunnels';
import { getRandomNumber } from 'lib/getRandomColor';
import { FunnelData } from 'types/api/traceFunnels';
import { FunnelContextType } from '../FunnelContext';
// Helper to create consistent mock data
export const createMockFunnel = (id: string, name: string): FunnelData => ({
funnel_id: id,
funnel_name: name,
created_at: Date.now() - getRandomNumber(10000, 50000), // Mock timestamp
updated_at: Date.now(),
steps: [
{
id: 'step-1',
step_order: 1,
service_name: 'ServiceA',
span_name: 'SpanA',
filters: {
items: [],
op: 'AND',
},
latency_pointer: 'start',
latency_type: 'p99',
has_errors: false,
name: 'Step 1',
description: 'First step',
},
{
id: 'step-2',
step_order: 2,
service_name: 'ServiceB',
span_name: 'SpanB',
filters: {
items: [],
op: 'AND',
},
latency_pointer: 'start',
latency_type: 'p99',
has_errors: false,
name: 'Step 2',
description: 'Second step',
},
],
user_email: `user-${id}`,
description: `Description for ${name}`,
});
export const mockFunnelsListData: FunnelData[] = [
createMockFunnel('funnel-1', 'Checkout Process Funnel'),
createMockFunnel('funnel-2', 'User Signup Flow'),
];
export const mockSingleFunnelData: FunnelData = createMockFunnel(
'funnel-1',
'Checkout Process Funnel',
);
export const defaultMockFunnelContext: FunnelContextType = {
startTime: 0,
endTime: 0,
selectedTime: '1h',
validTracesCount: 0,
funnelId: 'default-mock-id',
steps: mockSingleFunnelData.steps || [],
setSteps: jest.fn(),
initialSteps: [],
handleAddStep: jest.fn(),
handleStepChange: jest.fn(),
handleStepRemoval: jest.fn(),
handleRunFunnel: jest.fn(),
validationResponse: undefined,
isValidateStepsLoading: false,
hasIncompleteStepFields: false,
hasAllEmptyStepFields: false,
handleReplaceStep: jest.fn(),
handleRestoreSteps: jest.fn(),
handleSaveFunnel: jest.fn(),
triggerSave: false,
hasUnsavedChanges: false,
isUpdatingFunnel: false,
setIsUpdatingFunnel: jest.fn(),
lastUpdatedSteps: [],
setLastUpdatedSteps: jest.fn(),
};
export const mockOverviewData: FunnelOverviewResponse = {
status: 'success',
data: [
{
timestamp: '1678886400000',
data: {
avg_duration: 123, // in milliseconds for proper formatting
avg_rate: 10.5,
conversion_rate: 80.0,
errors: 2,
latency: 250, // in milliseconds for proper formatting
},
},
],
};
export const mockStepsData: FunnelStepsResponse = {
status: 'success',
data: [
{
timestamp: '1678886400000',
data: {
total_s1_spans: 100,
total_s1_errored_spans: 10,
total_s2_spans: 80,
total_s2_errored_spans: 8,
},
},
],
};
export const mockStepsOverviewData = {
status: 'success',
data: [
{
timestamp: '1678886400000',
data: {
avg_duration: 0.055, // in milliseconds for steps overview
avg_rate: 8.5,
conversion_rate: 92.0,
errors: 1,
latency: 0.15, // in milliseconds for steps overview
},
},
],
};
export const mockSlowTracesData: SlowTraceData = {
status: 'success',
data: [
{
timestamp: '1678886400000',
data: {
duration_ms: '500.12',
span_count: 15,
trace_id: 'slow-trace-1',
},
},
],
};
export const mockErrorTracesData: ErrorTraceData = {
status: 'success',
data: [
{
timestamp: '1678886400000',
data: {
duration_ms: '150.67',
span_count: 10,
trace_id: 'error-trace-1',
},
},
],
};
export const mockSpanSuccessComponentProps = {
spans: [
{
timestamp: 1683245912789,
durationNano: 28934567,
spanId: 'c84bb52145b55f85',
rootSpanId: '',
traceId: '29fe8bbf8515f9fc4dd2g917c97c2b16',
hasError: false,
kind: 2,
event: [],
rootName: '',
statusMessage: '',
statusCodeString: 'Unset',
spanKind: 'Producer',
serviceName: 'producer-svc-3',
name: 'topic2 publish',
children: [],
subTreeNodeCount: 3,
hasChildren: false,
hasSiblings: false,
level: 0,
parentSpanId: '',
references: [],
tagMap: { 'http.method': 'POST' },
hasSibling: false,
},
],
traceMetadata: {
traceId: '29fe8bbf8515f9fc4dd2g917c97c2b16',
startTime: 1683245912789,
endTime: 1683245912817,
hasMissingSpans: false,
},
interestedSpanId: {
spanId: 'c84bb52145b55f85',
isUncollapsed: true,
},
uncollapsedNodes: [],
setInterestedSpanId: jest.fn(),
setTraceFlamegraphStatsWidth: jest.fn(),
selectedSpan: undefined,
setSelectedSpan: jest.fn(),
};

View File

@@ -16,25 +16,6 @@ body {
box-sizing: border-box;
}
// Theme transition animations
* {
transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease,
box-shadow 0.3s ease;
}
// For components that shouldn't transition (like loading spinners, animations)
.no-transition,
.no-transition * {
transition: none !important;
}
// Respect user's reduced motion preference
@media (prefers-reduced-motion: reduce) {
* {
transition: none !important;
}
}
.u-legend {
max-height: 30px; // Default height for backward compatibility
overflow-y: auto;

View File

@@ -6702,7 +6702,7 @@ color-name@1.1.3:
resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz"
integrity sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==
color-name@^1.0.0, color-name@~1.1.4:
color-name@^1.0.0, color-name@^1.1.4, color-name@~1.1.4:
version "1.1.4"
resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz"
integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
@@ -7158,6 +7158,11 @@ cssfilter@0.0.10:
resolved "https://registry.npmjs.org/cssfilter/-/cssfilter-0.0.10.tgz"
integrity sha512-FAaLDaplstoRsDR8XGYH51znUN0UY7nMc6Z9/fvE8EXGwvJE9hu7W2vHwx1+bd6gCYnln9nLbzxFTrcO9YQDZw==
cssfontparser@^1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/cssfontparser/-/cssfontparser-1.2.1.tgz#f4022fc8f9700c68029d542084afbaf425a3f3e3"
integrity sha512-6tun4LoZnj7VN6YeegOVb67KBX/7JJsqvj+pv3ZA7F878/eN33AbGa5b/S/wXxS/tcp8nc40xRUrsPlxIyNUPg==
cssnano-preset-default@^6.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/cssnano-preset-default/-/cssnano-preset-default-6.0.1.tgz#2a93247140d214ddb9f46bc6a3562fa9177fe301"
@@ -10483,6 +10488,14 @@ jerrypick@^1.1.1:
resolved "https://registry.npmjs.org/jerrypick/-/jerrypick-1.1.1.tgz"
integrity sha512-XTtedPYEyVp4t6hJrXuRKr/jHj8SC4z+4K0b396PMkov6muL+i8IIamJIvZWe3jUspgIJak0P+BaWKawMYNBLg==
jest-canvas-mock@2.5.2:
version "2.5.2"
resolved "https://registry.yarnpkg.com/jest-canvas-mock/-/jest-canvas-mock-2.5.2.tgz#7e21ebd75e05ab41c890497f6ba8a77f915d2ad6"
integrity sha512-vgnpPupjOL6+L5oJXzxTxFrlGEIbHdZqFU+LFNdtLxZ3lRDCl17FlTMM7IatoRQkrcyOTMlDinjUguqmQ6bR2A==
dependencies:
cssfontparser "^1.2.1"
moo-color "^1.0.2"
jest-changed-files@^27.5.1:
version "27.5.1"
resolved "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-27.5.1.tgz"
@@ -12441,6 +12454,13 @@ moment@^2.29.4:
resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.4.tgz#3dbe052889fe7c1b2ed966fcb3a77328964ef108"
integrity sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==
moo-color@^1.0.2:
version "1.0.3"
resolved "https://registry.yarnpkg.com/moo-color/-/moo-color-1.0.3.tgz#d56435f8359c8284d83ac58016df7427febece74"
integrity sha512-i/+ZKXMDf6aqYtBhuOcej71YSlbjT3wCO/4H1j8rPvxDJEifdwgg5MaFyu6iYAT8GBZJg2z0dkgK4YMzvURALQ==
dependencies:
color-name "^1.1.4"
motion-dom@^12.4.11:
version "12.4.11"
resolved "https://registry.yarnpkg.com/motion-dom/-/motion-dom-12.4.11.tgz#0419c8686cda4d523f08249deeb8fa6683a9b9d3"

View File

@@ -151,7 +151,7 @@ func (client *client) getFingerprintsFromClickhouseQuery(ctx context.Context, qu
return nil, err
}
labels, _, err := unmarshalLabels(labelString, fingerprint)
labels, _, err := unmarshalLabels(labelString)
if err != nil {
return nil, err
}

View File

@@ -4,8 +4,6 @@ import (
"context"
"github.com/SigNoz/signoz/pkg/telemetrystore/telemetrystoretest"
cmock "github.com/srikanthccv/ClickHouse-go-mock"
"github.com/stretchr/testify/require"
"sort"
"testing"
"github.com/DATA-DOG/go-sqlmock"
@@ -113,97 +111,3 @@ func TestClient_QuerySamples(t *testing.T) {
})
}
}
func TestClient_getFingerprintsFromClickhouseQuery(t *testing.T) {
cols := []cmock.ColumnType{
{Name: "fingerprint", Type: "UInt64"},
{Name: "labels", Type: "String"},
}
sortLabels := func(ls []prompb.Label) {
sort.Slice(ls, func(i, j int) bool {
if ls[i].Name == ls[j].Name {
return ls[i].Value < ls[j].Value
}
return ls[i].Name < ls[j].Name
})
}
tests := []struct {
name string
start, end int64
metricName string
subQuery string
args []any
setupMock func(m cmock.ClickConnMockCommon, args ...any)
want map[uint64][]prompb.Label
wantErr bool
}{
{
name: "happy-path - two fingerprints",
start: 1000,
end: 2000,
metricName: "cpu_usage",
subQuery: `SELECT fingerprint,labels`,
// args slice is empty here, but testcase still owns it
args: []any{},
setupMock: func(m cmock.ClickConnMockCommon, args ...any) {
rows := [][]any{
{uint64(123), `{"t1":"s1","t2":"s2"}`},
{uint64(234), `{"t1":"s1","t2":"s2"}`},
}
m.ExpectQuery(`SELECT fingerprint,labels`).WithArgs(args...).WillReturnRows(
cmock.NewRows(cols, rows),
)
},
want: map[uint64][]prompb.Label{
123: {
{Name: "fingerprint", Value: "123"},
{Name: "t1", Value: "s1"},
{Name: "t2", Value: "s2"},
},
234: {
{Name: "fingerprint", Value: "234"},
{Name: "t1", Value: "s1"},
{Name: "t2", Value: "s2"},
},
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
ctx := context.Background()
store := telemetrystoretest.New(
telemetrystore.Config{Provider: "clickhouse"},
sqlmock.QueryMatcherRegexp,
)
if tc.setupMock != nil {
tc.setupMock(store.Mock(), tc.args...)
}
c := client{telemetryStore: store}
got, err := c.getFingerprintsFromClickhouseQuery(ctx, tc.subQuery, tc.args)
if tc.wantErr {
require.Error(t, err)
require.Nil(t, got)
return
}
require.NoError(t, err)
require.Equal(t, len(tc.want), len(got), "fingerprint map length mismatch")
for fp, expLabels := range tc.want {
gotLabels, ok := got[fp]
require.Truef(t, ok, "missing fingerprint %d", fp)
sortLabels(expLabels)
sortLabels(gotLabels)
assert.Equalf(t, expLabels, gotLabels, "labels mismatch for fingerprint %d", fp)
}
})
}
}

View File

@@ -2,14 +2,12 @@ package clickhouseprometheus
import (
"encoding/json"
"strconv"
"github.com/SigNoz/signoz/pkg/prometheus"
"github.com/prometheus/prometheus/prompb"
)
// Unmarshals JSON into Prometheus labels. It does not preserve order.
func unmarshalLabels(s string, fingerprint uint64) ([]prompb.Label, string, error) {
func unmarshalLabels(s string) ([]prompb.Label, string, error) {
var metricName string
m := make(map[string]string)
if err := json.Unmarshal([]byte(s), &m); err != nil {
@@ -26,9 +24,5 @@ func unmarshalLabels(s string, fingerprint uint64) ([]prompb.Label, string, erro
Value: v,
})
}
res = append(res, prompb.Label{
Name: prometheus.FingerprintAsPromLabelName,
Value: strconv.FormatUint(fingerprint, 10),
})
return res, metricName, nil
}

View File

@@ -1,3 +0,0 @@
package prometheus
const FingerprintAsPromLabelName = "fingerprint"

View File

@@ -1,106 +0,0 @@
package prometheustest
import (
"github.com/SigNoz/signoz/pkg/prometheus"
"github.com/prometheus/prometheus/model/labels"
"github.com/prometheus/prometheus/promql"
"testing"
)
func TestRemoveExtraLabels(t *testing.T) {
tests := []struct {
name string
res *promql.Result
remove []string
wantErr bool
verify func(t *testing.T, result *promql.Result, removed []string)
}{
{
name: "vector label removed",
res: &promql.Result{
Value: promql.Vector{
promql.Sample{
Metric: labels.FromStrings(
"__name__", "http_requests_total",
"job", "demo",
"drop_me", "dropped",
),
},
},
},
remove: []string{"drop_me"},
verify: func(t *testing.T, result *promql.Result, removed []string) {
k := result.Value.(promql.Vector)
for _, str := range removed {
get := k[0].Metric.Get(str)
if get != "" {
t.Fatalf("label not removed")
}
}
},
},
{
name: "scalar nothing to strip",
res: &promql.Result{
Value: promql.Scalar{V: 99, T: 1},
},
remove: []string{"irrelevant"},
verify: func(t *testing.T, result *promql.Result, removed []string) {
sc := result.Value.(promql.Scalar)
if sc.V != 99 || sc.T != 1 {
t.Fatalf("scalar unexpectedly modified: got %+v", sc)
}
},
},
{
name: "matrix label removed",
res: &promql.Result{
Value: promql.Matrix{
promql.Series{
Metric: labels.FromStrings(
"__name__", "http_requests_total",
"pod", "p0",
"drop_me", "dropped",
),
Floats: []promql.FPoint{{T: 0, F: 1}, {T: 1, F: 2}},
},
promql.Series{
Metric: labels.FromStrings(
"__name__", "http_requests_total",
"pod", "p0",
"drop_me", "dropped",
),
Floats: []promql.FPoint{{T: 0, F: 1}, {T: 1, F: 2}},
},
},
},
remove: []string{"drop_me"},
verify: func(t *testing.T, result *promql.Result, removed []string) {
mat := result.Value.(promql.Matrix)
for _, str := range removed {
for _, k := range mat {
if k.Metric.Get(str) != "" {
t.Fatalf("label not removed")
}
}
}
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := prometheus.RemoveExtraLabels(tc.res, tc.remove...)
if tc.wantErr && err == nil {
t.Fatalf("expected error, got nil")
}
if !tc.wantErr && err != nil {
t.Fatalf("unexpected error: %v", err)
}
if tc.verify != nil {
tc.verify(t, tc.res, tc.remove)
}
})
}
}

View File

@@ -1,49 +0,0 @@
package prometheus
import (
"fmt"
"github.com/prometheus/prometheus/promql"
)
func RemoveExtraLabels(res *promql.Result, labelsToRemove ...string) error {
if len(labelsToRemove) == 0 || res == nil {
return nil
}
toRemove := make(map[string]struct{}, len(labelsToRemove))
for _, l := range labelsToRemove {
toRemove[l] = struct{}{}
}
switch res.Value.(type) {
case promql.Vector:
value := res.Value.(promql.Vector)
for i := range value {
series := &(value)[i]
dst := series.Metric[:0]
for _, lbl := range series.Metric {
if _, drop := toRemove[lbl.Name]; !drop {
dst = append(dst, lbl)
}
}
series.Metric = dst
}
case promql.Matrix:
value := res.Value.(promql.Matrix)
for i := range value {
series := &(value)[i]
dst := series.Metric[:0]
for _, lbl := range series.Metric {
if _, drop := toRemove[lbl.Name]; !drop {
dst = append(dst, lbl)
}
}
series.Metric = dst
}
case promql.Scalar:
return nil
default:
return fmt.Errorf("rule result is not a vector or scalar or matrix")
}
return nil
}

View File

@@ -239,10 +239,6 @@ func (r *ClickHouseReader) GetInstantQueryMetricsResult(ctx context.Context, que
}
qry.Close()
err = prometheus.RemoveExtraLabels(res, prometheus.FingerprintAsPromLabelName)
if err != nil {
return nil, nil, &model.ApiError{Typ: model.ErrorInternal, Err: err}
}
return res, &qs, nil
}
@@ -263,10 +259,6 @@ func (r *ClickHouseReader) GetQueryRangeResult(ctx context.Context, query *model
}
qry.Close()
err = prometheus.RemoveExtraLabels(res, prometheus.FingerprintAsPromLabelName)
if err != nil {
return nil, nil, &model.ApiError{Typ: model.ErrorInternal, Err: err}
}
return res, &qs, nil
}

View File

@@ -321,11 +321,6 @@ func (r *PromRule) RunAlertQuery(ctx context.Context, qs string, start, end time
return nil, res.Err
}
err = prometheus.RemoveExtraLabels(res, prometheus.FingerprintAsPromLabelName)
if err != nil {
return nil, err
}
switch typ := res.Value.(type) {
case promql.Vector:
series := make([]promql.Series, 0, len(typ))