mirror of
https://github.com/SigNoz/signoz.git
synced 2026-09-20 02:10:41 +01:00
Compare commits
18 Commits
dummy-test
...
chore/trac
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6fe5178dad | ||
|
|
9c67d36e57 | ||
|
|
197aaae9c5 | ||
|
|
58e91bc31b | ||
|
|
dba3eb732d | ||
|
|
40b9ee48ac | ||
|
|
12abfe5e43 | ||
|
|
373f26098f | ||
|
|
d70be77f40 | ||
|
|
7f230cb44f | ||
|
|
7075375537 | ||
|
|
7046349294 | ||
|
|
e685b5e3ed | ||
|
|
f993773295 | ||
|
|
25ae3c8d27 | ||
|
|
e264e3c576 | ||
|
|
4fdb74a341 | ||
|
|
329c0e7fc6 |
4
.github/workflows/prereleaser.yaml
vendored
4
.github/workflows/prereleaser.yaml
vendored
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -370,7 +370,6 @@ function CustomTimePicker({
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onChange={handleInputChange}
|
||||
data-1p-ignore
|
||||
prefix={
|
||||
inputValue && inputStatus === 'success' ? (
|
||||
<CheckCircle size={14} color="#51E7A8" />
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -72,7 +72,6 @@ function SearchBar({
|
||||
onKeyDown={handleKeyDown}
|
||||
tabIndex={0}
|
||||
autoFocus
|
||||
data-1p-ignore
|
||||
/>
|
||||
</div>
|
||||
<kbd className="timezone-picker__esc-key">esc</kbd>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -196,7 +196,6 @@ function GridCardGraph({
|
||||
[requestData.query],
|
||||
);
|
||||
|
||||
console.log('requestData', requestData);
|
||||
const queryResponse = useGetQueryRange(
|
||||
{
|
||||
...requestData,
|
||||
|
||||
@@ -58,7 +58,6 @@ export interface ActionItemProps {
|
||||
operator: string,
|
||||
isJSON?: boolean,
|
||||
dataType?: DataTypes,
|
||||
fieldType?: string,
|
||||
) => void;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}),
|
||||
},
|
||||
})),
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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[
|
||||
|
||||
@@ -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;
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -108,7 +108,6 @@ export function getMetricDetailsQuery(
|
||||
id: `${metricName}----${metricType}---string--`,
|
||||
isColumn: true,
|
||||
isJSON: false,
|
||||
dataType: DataTypes.String,
|
||||
},
|
||||
aggregateOperator,
|
||||
timeAggregation,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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's appearance should be light, dark, or
|
||||
automatically follow your system preference
|
||||
Select if SigNoz'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 />
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
7
frontend/src/mocks-server/__mockdata__/trace_funnels.ts
Normal file
7
frontend/src/mocks-server/__mockdata__/trace_funnels.ts
Normal 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',
|
||||
);
|
||||
@@ -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)),
|
||||
),
|
||||
];
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)}%`,
|
||||
|
||||
@@ -35,6 +35,7 @@ function StepsTransitionMetrics({
|
||||
return (
|
||||
<FunnelMetricsTable
|
||||
title={currentTransition.label}
|
||||
testId="step-transition-metrics"
|
||||
subtitle={{
|
||||
label: 'Conversion rate',
|
||||
value: `${conversionRate.toFixed(2)}%`,
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
278
frontend/src/pages/TracesFunnels/__tests__/CreateFunnel.test.tsx
Normal file
278
frontend/src/pages/TracesFunnels/__tests__/CreateFunnel.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
211
frontend/src/pages/TracesFunnels/__tests__/mockFunnelsData.ts
Normal file
211
frontend/src/pages/TracesFunnels/__tests__/mockFunnelsData.ts
Normal 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(),
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 test‑case 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
package prometheus
|
||||
|
||||
const FingerprintAsPromLabelName = "fingerprint"
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user