feat: created a component for apdex (#3283)

* feat: created a component for apdex

* refactor: added get and set apDexSetting API support

* refactor: done with ApDexSetting

* feat: done with the traces graph for ApDex

* refactor: separated traces using feature flag

* refactor: restucture the component level

* feat: done with metrics ApDex application

* refactor: removed unwanted logs

* fix: some css part

* refactor: handle error state

* refactor: made use of constants and handleGraphClick for apDex

* refactor: shifted type to type.ts

* chore: css fixes

* refactor: handle error and loading state

* refactor: one on one mapping

* refactor: according to review comments

* refactor: removed unwanted color from local theme colors

* refactor: one on one mapping for queryKey

* refactor: updated css for view traces button issue

* chore: commented out the ExcludeStatusCode feature

* refactor: updated some css part of ApDexSetting popover

* test: added test case for ApDexApplication and ApDexSettings

* refactor: test cases

* refactor: review comments

* refactor: remove the checked for threshold size upto 1

* refactor: changed some text part of ApDex

* refactor: only ApDexMetrics inuse

* refactor: changes due to merge conflicts

* fix: build pipeline

* chore: change the type of the threshold

* feat: widget header as ReactNode

* chore: error for the title is updated

* refactor: widget header as Reactnode

* refactor: show tooltip when hover over the question icon

* refactor: review changes

* refactor: convert threadhold to ReactNode

* refactor: updated test cases

* refactor: move allow threshold a level up

* fix: build pipeline

* fix: input number issue for value 0

---------

Co-authored-by: Palash Gupta <palashgdev@gmail.com>
Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
This commit is contained in:
Rajat Dabade 2023-08-16 12:18:56 +05:30 committed by GitHub
parent f8ec850670
commit 8844144c01
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
51 changed files with 1398 additions and 51 deletions

View File

@ -0,0 +1,16 @@
import axios from 'api';
import {
ApDexPayloadAndSettingsProps,
SetApDexPayloadProps,
} from 'types/api/metrics/getApDex';
export const setApDexSettings = async ({
servicename,
threshold,
excludeStatusCode,
}: ApDexPayloadAndSettingsProps): Promise<SetApDexPayloadProps> =>
axios.post('/settings/apdex', {
servicename,
threshold,
excludeStatusCode,
});

View File

@ -0,0 +1,8 @@
import axios from 'api';
import { AxiosResponse } from 'axios';
import { ApDexPayloadAndSettingsProps } from 'types/api/metrics/getApDex';
export const getApDexSettings = (
servicename: string,
): Promise<AxiosResponse<ApDexPayloadAndSettingsProps[]>> =>
axios.get(`/settings/apdex?services=${servicename}`);

View File

@ -0,0 +1,8 @@
import axios from 'api';
import { AxiosResponse } from 'axios';
import { MetricMetaProps } from 'types/api/metrics/getApDex';
export const getMetricMeta = (
metricName: string,
): Promise<AxiosResponse<MetricMetaProps>> =>
axios.get(`/metric_meta?metricName=${metricName}`);

View File

@ -3,7 +3,6 @@ import {
BarElement, BarElement,
CategoryScale, CategoryScale,
Chart, Chart,
ChartType,
Decimation, Decimation,
Filler, Filler,
Legend, Legend,
@ -18,6 +17,7 @@ import {
Tooltip, Tooltip,
} from 'chart.js'; } from 'chart.js';
import annotationPlugin from 'chartjs-plugin-annotation'; import annotationPlugin from 'chartjs-plugin-annotation';
import { generateGridTitle } from 'container/GridPanelSwitch/utils';
import { useIsDarkMode } from 'hooks/useDarkMode'; import { useIsDarkMode } from 'hooks/useDarkMode';
import isEqual from 'lodash-es/isEqual'; import isEqual from 'lodash-es/isEqual';
import { import {
@ -26,6 +26,7 @@ import {
useCallback, useCallback,
useEffect, useEffect,
useImperativeHandle, useImperativeHandle,
useMemo,
useRef, useRef,
} from 'react'; } from 'react';
@ -83,6 +84,7 @@ const Graph = forwardRef<ToggleGraphProps | undefined, GraphProps>(
const nearestDatasetIndex = useRef<null | number>(null); const nearestDatasetIndex = useRef<null | number>(null);
const chartRef = useRef<HTMLCanvasElement>(null); const chartRef = useRef<HTMLCanvasElement>(null);
const isDarkMode = useIsDarkMode(); const isDarkMode = useIsDarkMode();
const gridTitle = useMemo(() => generateGridTitle(title), [title]);
const currentTheme = isDarkMode ? 'dark' : 'light'; const currentTheme = isDarkMode ? 'dark' : 'light';
const xAxisTimeUnit = useXAxisTimeUnit(data); // Computes the relevant time unit for x axis by analyzing the time stamp data const xAxisTimeUnit = useXAxisTimeUnit(data); // Computes the relevant time unit for x axis by analyzing the time stamp data
@ -119,7 +121,7 @@ const Graph = forwardRef<ToggleGraphProps | undefined, GraphProps>(
const options: CustomChartOptions = getGraphOptions( const options: CustomChartOptions = getGraphOptions(
animate, animate,
staticLine, staticLine,
title, gridTitle,
nearestDatasetIndex, nearestDatasetIndex,
yAxisUnit, yAxisUnit,
onDragSelect, onDragSelect,
@ -154,7 +156,7 @@ const Graph = forwardRef<ToggleGraphProps | undefined, GraphProps>(
}, [ }, [
animate, animate,
staticLine, staticLine,
title, gridTitle,
yAxisUnit, yAxisUnit,
onDragSelect, onDragSelect,
dragSelectColor, dragSelectColor,

View File

@ -7,7 +7,7 @@ import {
ChartType, ChartType,
TimeUnit, TimeUnit,
} from 'chart.js'; } from 'chart.js';
import { ForwardedRef } from 'react'; import { ForwardedRef, ReactNode } from 'react';
import { import {
dragSelectPluginId, dragSelectPluginId,
@ -49,7 +49,7 @@ export interface GraphProps {
animate?: boolean; animate?: boolean;
type: ChartType; type: ChartType;
data: Chart['data']; data: Chart['data'];
title?: string; title?: ReactNode;
isStacked?: boolean; isStacked?: boolean;
onClickHandler?: GraphOnClickHandler; onClickHandler?: GraphOnClickHandler;
name: string; name: string;

View File

@ -1,5 +1,8 @@
import { grey } from '@ant-design/colors'; import { blue, grey } from '@ant-design/colors';
import { QuestionCircleFilled } from '@ant-design/icons'; import {
QuestionCircleFilled,
QuestionCircleOutlined,
} from '@ant-design/icons';
import { Tooltip } from 'antd'; import { Tooltip } from 'antd';
import { themeColors } from 'constants/theme'; import { themeColors } from 'constants/theme';
import { useIsDarkMode } from 'hooks/useDarkMode'; import { useIsDarkMode } from 'hooks/useDarkMode';
@ -7,7 +10,12 @@ import { useMemo } from 'react';
import { style } from './styles'; import { style } from './styles';
function TextToolTip({ text, url }: TextToolTipProps): JSX.Element { function TextToolTip({
text,
url,
useFilledIcon = true,
urlText,
}: TextToolTipProps): JSX.Element {
const isDarkMode = useIsDarkMode(); const isDarkMode = useIsDarkMode();
const overlay = useMemo( const overlay = useMemo(
@ -16,12 +24,12 @@ function TextToolTip({ text, url }: TextToolTipProps): JSX.Element {
{`${text} `} {`${text} `}
{url && ( {url && (
<a href={url} rel="noopener noreferrer" target="_blank"> <a href={url} rel="noopener noreferrer" target="_blank">
here {urlText || 'here'}
</a> </a>
)} )}
</div> </div>
), ),
[text, url], [text, url, urlText],
); );
const iconStyle = useMemo( const iconStyle = useMemo(
@ -32,19 +40,35 @@ function TextToolTip({ text, url }: TextToolTipProps): JSX.Element {
[isDarkMode], [isDarkMode],
); );
const iconOutlinedStyle = useMemo(
() => ({
...style,
color: isDarkMode ? themeColors.navyBlue : blue[0],
}),
[isDarkMode],
);
return ( return (
<Tooltip overlay={overlay}> <Tooltip overlay={overlay}>
<QuestionCircleFilled style={iconStyle} /> {useFilledIcon ? (
<QuestionCircleFilled style={iconStyle} />
) : (
<QuestionCircleOutlined style={iconOutlinedStyle} />
)}
</Tooltip> </Tooltip>
); );
} }
TextToolTip.defaultProps = { TextToolTip.defaultProps = {
url: '', url: '',
urlText: '',
useFilledIcon: true,
}; };
interface TextToolTipProps { interface TextToolTipProps {
url?: string; url?: string;
text: string; text: string;
useFilledIcon?: boolean;
urlText?: string;
} }
export default TextToolTip; export default TextToolTip;

View File

@ -0,0 +1,5 @@
export const apDexToolTipText =
"Apdex is a way to measure your users' satisfaction with the response time of your web service. It's represented as a score from 0-1.";
export const apDexToolTipUrl =
'https://signoz.io/docs/userguide/metrics/#apdex?utm_source=product&utm_medium=frontend&utm_campaign=apdex';
export const apDexToolTipUrlText = 'Learn more about Apdex.';

View File

@ -53,6 +53,7 @@ function WidgetGraphComponent({
setLayout, setLayout,
onDragSelect, onDragSelect,
onClickHandler, onClickHandler,
threshold,
headerMenuList, headerMenuList,
}: WidgetGraphComponentProps): JSX.Element { }: WidgetGraphComponentProps): JSX.Element {
const [deleteModal, setDeleteModal] = useState(false); const [deleteModal, setDeleteModal] = useState(false);
@ -279,6 +280,7 @@ function WidgetGraphComponent({
onClone={onCloneHandler} onClone={onCloneHandler}
queryResponse={queryResponse} queryResponse={queryResponse}
errorMessage={errorMessage} errorMessage={errorMessage}
threshold={threshold}
headerMenuList={headerMenuList} headerMenuList={headerMenuList}
/> />
</div> </div>

View File

@ -29,6 +29,7 @@ function GridCardGraph({
onClickHandler, onClickHandler,
headerMenuList = [MenuItemKeys.View], headerMenuList = [MenuItemKeys.View],
isQueryEnabled, isQueryEnabled,
threshold,
}: GridCardGraphProps): JSX.Element { }: GridCardGraphProps): JSX.Element {
const { isAddWidget } = useSelector<AppState, DashboardReducer>( const { isAddWidget } = useSelector<AppState, DashboardReducer>(
(state) => state.dashboards, (state) => state.dashboards,
@ -70,11 +71,12 @@ function GridCardGraph({
{ {
queryKey: [ queryKey: [
`GetMetricsQueryRange-${widget?.timePreferance}-${globalSelectedInterval}-${widget?.id}`, `GetMetricsQueryRange-${widget?.timePreferance}-${globalSelectedInterval}-${widget?.id}`,
widget,
maxTime, maxTime,
minTime, minTime,
globalSelectedInterval, globalSelectedInterval,
variables, variables,
widget?.query,
widget?.panelTypes,
], ],
keepPreviousData: true, keepPreviousData: true,
enabled: isGraphVisible && !isEmptyWidget && isQueryEnabled && !isAddWidget, enabled: isGraphVisible && !isEmptyWidget && isQueryEnabled && !isAddWidget,
@ -105,7 +107,7 @@ function GridCardGraph({
return <Spinner height="20vh" tip="Loading..." />; return <Spinner height="20vh" tip="Loading..." />;
} }
if (queryResponse.isError && !isEmptyLayout) { if ((queryResponse.isError && !isEmptyLayout) || !isQueryEnabled) {
return ( return (
<span ref={graphRef}> <span ref={graphRef}>
{!isEmpty(widget) && prevChartDataSetRef && ( {!isEmpty(widget) && prevChartDataSetRef && (
@ -120,6 +122,7 @@ function GridCardGraph({
yAxisUnit={yAxisUnit} yAxisUnit={yAxisUnit}
layout={layout} layout={layout}
setLayout={setLayout} setLayout={setLayout}
threshold={threshold}
headerMenuList={headerMenuList} headerMenuList={headerMenuList}
/> />
)} )}
@ -141,6 +144,7 @@ function GridCardGraph({
yAxisUnit={yAxisUnit} yAxisUnit={yAxisUnit}
layout={layout} layout={layout}
setLayout={setLayout} setLayout={setLayout}
threshold={threshold}
headerMenuList={headerMenuList} headerMenuList={headerMenuList}
onClickHandler={onClickHandler} onClickHandler={onClickHandler}
/> />
@ -161,6 +165,7 @@ function GridCardGraph({
name={name} name={name}
yAxisUnit={yAxisUnit} yAxisUnit={yAxisUnit}
onDragSelect={onDragSelect} onDragSelect={onDragSelect}
threshold={threshold}
headerMenuList={headerMenuList} headerMenuList={headerMenuList}
onClickHandler={onClickHandler} onClickHandler={onClickHandler}
/> />
@ -175,6 +180,7 @@ GridCardGraph.defaultProps = {
onDragSelect: undefined, onDragSelect: undefined,
onClickHandler: undefined, onClickHandler: undefined,
isQueryEnabled: true, isQueryEnabled: true,
threshold: undefined,
headerMenuList: [MenuItemKeys.View], headerMenuList: [MenuItemKeys.View],
}; };

View File

@ -1,6 +1,6 @@
import { ChartData } from 'chart.js'; import { ChartData } from 'chart.js';
import { GraphOnClickHandler, ToggleGraphProps } from 'components/Graph/types'; import { GraphOnClickHandler, ToggleGraphProps } from 'components/Graph/types';
import { Dispatch, MutableRefObject, SetStateAction } from 'react'; import { Dispatch, MutableRefObject, ReactNode, SetStateAction } from 'react';
import { Layout } from 'react-grid-layout'; import { Layout } from 'react-grid-layout';
import { UseQueryResult } from 'react-query'; import { UseQueryResult } from 'react-query';
import { DeleteWidgetProps } from 'store/actions/dashboard/deleteWidget'; import { DeleteWidgetProps } from 'store/actions/dashboard/deleteWidget';
@ -39,6 +39,7 @@ export interface WidgetGraphComponentProps extends DispatchProps {
setLayout?: Dispatch<SetStateAction<LayoutProps[]>>; setLayout?: Dispatch<SetStateAction<LayoutProps[]>>;
onDragSelect?: (start: number, end: number) => void; onDragSelect?: (start: number, end: number) => void;
onClickHandler?: GraphOnClickHandler; onClickHandler?: GraphOnClickHandler;
threshold?: ReactNode;
headerMenuList: MenuItemKeys[]; headerMenuList: MenuItemKeys[];
} }
@ -50,6 +51,7 @@ export interface GridCardGraphProps {
setLayout?: Dispatch<SetStateAction<LayoutProps[]>>; setLayout?: Dispatch<SetStateAction<LayoutProps[]>>;
onDragSelect?: (start: number, end: number) => void; onDragSelect?: (start: number, end: number) => void;
onClickHandler?: GraphOnClickHandler; onClickHandler?: GraphOnClickHandler;
threshold?: ReactNode;
headerMenuList?: WidgetGraphComponentProps['headerMenuList']; headerMenuList?: WidgetGraphComponentProps['headerMenuList'];
isQueryEnabled: boolean; isQueryEnabled: boolean;
} }

View File

@ -0,0 +1,19 @@
import { InfoCircleOutlined } from '@ant-design/icons';
import {
DisplayThresholdContainer,
TypographHeading,
Typography,
} from './styles';
import { DisplayThresholdProps } from './types';
function DisplayThreshold({ threshold }: DisplayThresholdProps): JSX.Element {
return (
<DisplayThresholdContainer>
<TypographHeading>Threshold </TypographHeading>
<Typography>{threshold || <InfoCircleOutlined />}</Typography>
</DisplayThresholdContainer>
);
}
export default DisplayThreshold;

View File

@ -12,7 +12,7 @@ import { queryParamNamesMap } from 'constants/queryBuilderQueryNames';
import ROUTES from 'constants/routes'; import ROUTES from 'constants/routes';
import useComponentPermission from 'hooks/useComponentPermission'; import useComponentPermission from 'hooks/useComponentPermission';
import history from 'lib/history'; import history from 'lib/history';
import { useCallback, useMemo, useState } from 'react'; import { ReactNode, useCallback, useMemo, useState } from 'react';
import { UseQueryResult } from 'react-query'; import { UseQueryResult } from 'react-query';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers'; import { AppState } from 'store/reducers';
@ -32,12 +32,14 @@ import {
ArrowContainer, ArrowContainer,
HeaderContainer, HeaderContainer,
HeaderContentContainer, HeaderContentContainer,
ThesholdContainer,
WidgetHeaderContainer,
} from './styles'; } from './styles';
import { MenuItem } from './types'; import { MenuItem } from './types';
import { generateMenuList, isTWidgetOptions } from './utils'; import { generateMenuList, isTWidgetOptions } from './utils';
interface IWidgetHeaderProps { interface IWidgetHeaderProps {
title: string; title: ReactNode;
widget: Widgets; widget: Widgets;
onView: VoidFunction; onView: VoidFunction;
onDelete?: VoidFunction; onDelete?: VoidFunction;
@ -47,6 +49,7 @@ interface IWidgetHeaderProps {
SuccessResponse<MetricRangePayloadProps> | ErrorResponse SuccessResponse<MetricRangePayloadProps> | ErrorResponse
>; >;
errorMessage: string | undefined; errorMessage: string | undefined;
threshold?: ReactNode;
headerMenuList?: MenuItemKeys[]; headerMenuList?: MenuItemKeys[];
} }
@ -59,6 +62,7 @@ function WidgetHeader({
parentHover, parentHover,
queryResponse, queryResponse,
errorMessage, errorMessage,
threshold,
headerMenuList, headerMenuList,
}: IWidgetHeaderProps): JSX.Element { }: IWidgetHeaderProps): JSX.Element {
const [localHover, setLocalHover] = useState(false); const [localHover, setLocalHover] = useState(false);
@ -171,7 +175,7 @@ function WidgetHeader({
); );
return ( return (
<div> <WidgetHeaderContainer>
<Dropdown <Dropdown
destroyPopupOnHide destroyPopupOnHide
open={isOpen} open={isOpen}
@ -196,6 +200,7 @@ function WidgetHeader({
</HeaderContentContainer> </HeaderContentContainer>
</HeaderContainer> </HeaderContainer>
</Dropdown> </Dropdown>
<ThesholdContainer>{threshold}</ThesholdContainer>
{queryResponse.isFetching && !queryResponse.isError && ( {queryResponse.isFetching && !queryResponse.isError && (
<Spinner height="5vh" style={spinnerStyles} /> <Spinner height="5vh" style={spinnerStyles} />
)} )}
@ -204,13 +209,14 @@ function WidgetHeader({
<ExclamationCircleOutlined style={tooltipStyles} /> <ExclamationCircleOutlined style={tooltipStyles} />
</Tooltip> </Tooltip>
)} )}
</div> </WidgetHeaderContainer>
); );
} }
WidgetHeader.defaultProps = { WidgetHeader.defaultProps = {
onDelete: undefined, onDelete: undefined,
onClone: undefined, onClone: undefined,
threshold: undefined,
headerMenuList: [MenuItemKeys.View], headerMenuList: [MenuItemKeys.View],
}; };

View File

@ -1,4 +1,6 @@
import { grey } from '@ant-design/colors'; import { grey } from '@ant-design/colors';
import { Typography as TypographyComponent } from 'antd';
import { themeColors } from 'constants/theme';
import styled from 'styled-components'; import styled from 'styled-components';
export const HeaderContainer = styled.div<{ hover: boolean }>` export const HeaderContainer = styled.div<{ hover: boolean }>`
@ -24,3 +26,34 @@ export const ArrowContainer = styled.span<{ hover: boolean }>`
position: absolute; position: absolute;
right: -1rem; right: -1rem;
`; `;
export const ThesholdContainer = styled.span`
margin-top: -0.3rem;
`;
export const DisplayThresholdContainer = styled.div`
display: flex;
align-items: center;
width: auto;
justify-content: space-between;
`;
export const WidgetHeaderContainer = styled.div`
display: flex;
flex-direction: row-reverse;
align-items: center;
`;
export const Typography = styled(TypographyComponent)`
&&& {
color: ${themeColors.white};
width: auto;
margin-left: 0.2rem;
}
`;
export const TypographHeading = styled(TypographyComponent)`
&&& {
color: ${grey[2]};
}
`;

View File

@ -10,3 +10,7 @@ export interface MenuItem {
disabled: boolean; disabled: boolean;
danger?: boolean; danger?: boolean;
} }
export interface DisplayThresholdProps {
threshold: ReactNode;
}

View File

@ -6,6 +6,7 @@ import {
} from 'components/Graph/types'; } from 'components/Graph/types';
import { GridTableComponentProps } from 'container/GridTableComponent/types'; import { GridTableComponentProps } from 'container/GridTableComponent/types';
import { GridValueComponentProps } from 'container/GridValueComponent/types'; import { GridValueComponentProps } from 'container/GridValueComponent/types';
import { Widgets } from 'types/api/dashboard/getAll';
import { Query } from 'types/api/queryBuilder/queryBuilderData'; import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { QueryDataV3 } from 'types/api/widgets/getQuery'; import { QueryDataV3 } from 'types/api/widgets/getQuery';
@ -14,7 +15,7 @@ import { PANEL_TYPES } from '../../constants/queryBuilder';
export type GridPanelSwitchProps = { export type GridPanelSwitchProps = {
panelType: PANEL_TYPES; panelType: PANEL_TYPES;
data: ChartData; data: ChartData;
title?: string; title?: Widgets['title'];
opacity?: string; opacity?: string;
isStacked?: boolean; isStacked?: boolean;
onClickHandler?: GraphOnClickHandler; onClickHandler?: GraphOnClickHandler;

View File

@ -0,0 +1,12 @@
import React, { ReactNode } from 'react';
export const generateGridTitle = (title: ReactNode): string => {
if (React.isValidElement(title)) {
return Array.isArray(title.props.children)
? title.props.children
.map((child: ReactNode) => (typeof child === 'string' ? child : ''))
.join(' ')
: title.props.children;
}
return title?.toString() || '';
};

View File

@ -1,7 +1,8 @@
import { Typography } from 'antd'; import { Typography } from 'antd';
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig'; import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import ValueGraph from 'components/ValueGraph'; import ValueGraph from 'components/ValueGraph';
import { memo } from 'react'; import { generateGridTitle } from 'container/GridPanelSwitch/utils';
import { memo, useMemo } from 'react';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import { TitleContainer, ValueContainer } from './styles'; import { TitleContainer, ValueContainer } from './styles';
@ -15,6 +16,7 @@ function GridValueComponent({
const value = (((data.datasets[0] || []).data || [])[0] || 0) as number; const value = (((data.datasets[0] || []).data || [])[0] || 0) as number;
const location = useLocation(); const location = useLocation();
const gridTitle = useMemo(() => generateGridTitle(title), [title]);
const isDashboardPage = location.pathname.split('/').length === 3; const isDashboardPage = location.pathname.split('/').length === 3;
@ -29,7 +31,7 @@ function GridValueComponent({
return ( return (
<> <>
<TitleContainer isDashboardPage={isDashboardPage}> <TitleContainer isDashboardPage={isDashboardPage}>
<Typography>{title}</Typography> <Typography>{gridTitle}</Typography>
</TitleContainer> </TitleContainer>
<ValueContainer isDashboardPage={isDashboardPage}> <ValueContainer isDashboardPage={isDashboardPage}>
<ValueGraph <ValueGraph

View File

@ -1,7 +1,8 @@
import { ChartData } from 'chart.js'; import { ChartData } from 'chart.js';
import { ReactNode } from 'react';
export type GridValueComponentProps = { export type GridValueComponentProps = {
data: ChartData; data: ChartData;
title?: string; title?: ReactNode;
yAxisUnit?: string; yAxisUnit?: string;
}; };

View File

@ -18,7 +18,13 @@ import {
QUERYNAME_AND_EXPRESSION, QUERYNAME_AND_EXPRESSION,
WidgetKeys, WidgetKeys,
} from '../constant'; } from '../constant';
import { LatencyProps, OperationPerSecProps } from '../Tabs/types'; import {
ApDexMetricsQueryBuilderQueriesProps,
ApDexProps,
LatencyProps,
OperationPerSecProps,
} from '../Tabs/types';
import { convertMilSecToNanoSec, getNearestHighestBucketValue } from '../utils';
import { import {
getQueryBuilderQueries, getQueryBuilderQueries,
getQueryBuilderQuerieswithFormula, getQueryBuilderQuerieswithFormula,
@ -85,6 +91,365 @@ export const latency = ({
}); });
}; };
export const apDexTracesQueryBuilderQueries = ({
servicename,
tagFilterItems,
topLevelOperationsRoute,
threashold,
}: ApDexProps): QueryBuilderData => {
const autoCompleteDataA: BaseAutocompleteData = {
dataType: DataType.FLOAT64,
isColumn: true,
key: '',
type: null,
};
const autoCompleteDataB: BaseAutocompleteData = {
dataType: DataType.FLOAT64,
isColumn: true,
key: '',
type: null,
};
const autoCompleteDataC: BaseAutocompleteData = {
dataType: DataType.FLOAT64,
isColumn: true,
key: '',
type: null,
};
const filterItemA: TagFilterItem[] = [
{
id: '',
key: {
key: WidgetKeys.ServiceName,
dataType: DataType.STRING,
isColumn: true,
type: MetricsType.Tag,
},
op: OPERATORS['='],
value: servicename,
},
{
id: '',
key: {
key: WidgetKeys.Name,
dataType: DataType.STRING,
isColumn: true,
type: MetricsType.Tag,
},
op: OPERATORS.IN,
value: [...topLevelOperationsRoute],
},
...tagFilterItems,
];
const filterItemB: TagFilterItem[] = [
{
id: '',
key: {
key: WidgetKeys.HasError,
dataType: DataType.BOOL,
isColumn: true,
type: MetricsType.Tag,
},
op: OPERATORS['='],
value: false,
},
{
id: '',
key: {
key: WidgetKeys.DurationNano,
dataType: DataType.FLOAT64,
isColumn: true,
type: MetricsType.Tag,
},
op: OPERATORS['<='],
value: convertMilSecToNanoSec(threashold),
},
{
id: '',
key: {
key: WidgetKeys.ServiceName,
dataType: DataType.STRING,
isColumn: true,
type: MetricsType.Tag,
},
op: OPERATORS['='],
value: servicename,
},
{
id: '',
key: {
key: WidgetKeys.Name,
dataType: DataType.STRING,
isColumn: true,
type: MetricsType.Tag,
},
op: OPERATORS.IN,
value: [...topLevelOperationsRoute],
},
];
const filterItemC: TagFilterItem[] = [
{
id: '',
key: {
key: WidgetKeys.DurationNano,
dataType: DataType.FLOAT64,
isColumn: true,
type: MetricsType.Tag,
},
op: OPERATORS['<='],
value: convertMilSecToNanoSec(threashold * 4),
},
{
id: '',
key: {
key: WidgetKeys.HasError,
dataType: DataType.BOOL,
isColumn: true,
type: MetricsType.Tag,
},
op: OPERATORS['='],
value: false,
},
{
id: '',
key: {
key: WidgetKeys.ServiceName,
dataType: DataType.STRING,
isColumn: true,
type: MetricsType.Tag,
},
op: OPERATORS['='],
value: servicename,
},
{
id: '',
key: {
key: WidgetKeys.Name,
dataType: DataType.STRING,
isColumn: true,
type: MetricsType.Tag,
},
op: OPERATORS.IN,
value: [...topLevelOperationsRoute],
},
];
const autocompleteData = [
autoCompleteDataA,
autoCompleteDataB,
autoCompleteDataC,
];
const additionalItems = [filterItemA, filterItemB, filterItemC];
const legends = [GraphTitle.APDEX];
const disabled = Array(3).fill(true);
const expressions = [FORMULA.APDEX_TRACES];
const legendFormulas = [GraphTitle.APDEX];
const aggregateOperators = [
MetricAggregateOperator.COUNT,
MetricAggregateOperator.COUNT,
MetricAggregateOperator.COUNT,
];
const dataSource = DataSource.TRACES;
return getQueryBuilderQuerieswithFormula({
autocompleteData,
additionalItems,
legends,
disabled,
expressions,
legendFormulas,
aggregateOperators,
dataSource,
});
};
export const apDexMetricsQueryBuilderQueries = ({
servicename,
tagFilterItems,
topLevelOperationsRoute,
threashold,
delta,
metricsBuckets,
}: ApDexMetricsQueryBuilderQueriesProps): QueryBuilderData => {
const autoCompleteDataA: BaseAutocompleteData = {
key: WidgetKeys.SignozLatencyCount,
dataType: DataType.FLOAT64,
isColumn: true,
type: null,
};
const autoCompleteDataB: BaseAutocompleteData = {
key: WidgetKeys.Signoz_latency_bucket,
dataType: DataType.FLOAT64,
isColumn: true,
type: null,
};
const autoCompleteDataC: BaseAutocompleteData = {
key: WidgetKeys.Signoz_latency_bucket,
dataType: DataType.FLOAT64,
isColumn: true,
type: null,
};
const filterItemA: TagFilterItem[] = [
{
id: '',
key: {
key: WidgetKeys.Service_name,
dataType: DataType.STRING,
isColumn: false,
type: MetricsType.Tag,
},
op: OPERATORS['='],
value: servicename,
},
{
id: '',
key: {
key: WidgetKeys.Operation,
dataType: DataType.STRING,
isColumn: false,
type: MetricsType.Tag,
},
op: OPERATORS.IN,
value: [...topLevelOperationsRoute],
},
...tagFilterItems,
];
const filterItemB: TagFilterItem[] = [
{
id: '',
key: {
key: WidgetKeys.StatusCode,
dataType: DataType.STRING,
isColumn: false,
type: MetricsType.Tag,
},
op: OPERATORS['='],
value: 'STATUS_CODE_UNSET',
},
{
id: '',
key: {
key: WidgetKeys.Le,
dataType: DataType.STRING,
isColumn: false,
type: MetricsType.Tag,
},
op: OPERATORS['='],
value: getNearestHighestBucketValue(threashold * 1000, metricsBuckets),
},
{
id: '',
key: {
key: WidgetKeys.Service_name,
dataType: DataType.STRING,
isColumn: false,
type: MetricsType.Tag,
},
op: OPERATORS['='],
value: servicename,
},
{
id: '',
key: {
key: WidgetKeys.Operation,
dataType: DataType.STRING,
isColumn: false,
type: MetricsType.Tag,
},
op: OPERATORS.IN,
value: [...topLevelOperationsRoute],
},
...tagFilterItems,
];
const filterItemC: TagFilterItem[] = [
{
id: '',
key: {
key: WidgetKeys.Le,
dataType: DataType.STRING,
isColumn: false,
type: MetricsType.Tag,
},
op: OPERATORS['='],
value: getNearestHighestBucketValue(threashold * 1000 * 4, metricsBuckets),
},
{
id: '',
key: {
key: WidgetKeys.StatusCode,
dataType: DataType.STRING,
isColumn: false,
type: MetricsType.Tag,
},
op: OPERATORS['='],
value: 'STATUS_CODE_UNSET',
},
{
id: '',
key: {
key: WidgetKeys.Service_name,
dataType: DataType.STRING,
isColumn: false,
type: MetricsType.Tag,
},
op: OPERATORS['='],
value: servicename,
},
{
id: '',
key: {
key: WidgetKeys.Operation,
dataType: DataType.STRING,
isColumn: false,
type: MetricsType.Tag,
},
op: OPERATORS.IN,
value: [...topLevelOperationsRoute],
},
...tagFilterItems,
];
const autocompleteData = [
autoCompleteDataA,
autoCompleteDataB,
autoCompleteDataC,
];
const additionalItems = [filterItemA, filterItemB, filterItemC];
const legends = [GraphTitle.APDEX];
const disabled = Array(3).fill(true);
const expressions = delta
? [FORMULA.APDEX_DELTA_SPAN_METRICS]
: [FORMULA.APDEX_CUMULATIVE_SPAN_METRICS];
const legendFormulas = [GraphTitle.APDEX];
const aggregateOperators = [
MetricAggregateOperator.SUM_RATE,
MetricAggregateOperator.SUM_RATE,
MetricAggregateOperator.SUM_RATE,
];
const dataSource = DataSource.METRICS;
return getQueryBuilderQuerieswithFormula({
autocompleteData,
additionalItems,
legends,
disabled,
expressions,
legendFormulas,
aggregateOperators,
dataSource,
});
};
export const operationPerSec = ({ export const operationPerSec = ({
servicename, servicename,
tagFilterItems, tagFilterItems,

View File

@ -32,7 +32,14 @@ import {
errorPercentage, errorPercentage,
operationPerSec, operationPerSec,
} from '../MetricsPageQueries/OverviewQueries'; } from '../MetricsPageQueries/OverviewQueries';
import { Card, Col, Row } from '../styles'; import {
Card,
Col,
ColApDexContainer,
ColErrorContainer,
Row,
} from '../styles';
import ApDex from './Overview/ApDex';
import ServiceOverview from './Overview/ServiceOverview'; import ServiceOverview from './Overview/ServiceOverview';
import TopLevelOperation from './Overview/TopLevelOperations'; import TopLevelOperation from './Overview/TopLevelOperations';
import TopOperation from './Overview/TopOperation'; import TopOperation from './Overview/TopOperation';
@ -160,7 +167,7 @@ function Application(): JSX.Element {
[dispatch], [dispatch],
); );
const onErrorTrackHandler = (timestamp: number): void => { const onErrorTrackHandler = (timestamp: number): (() => void) => (): void => {
const currentTime = timestamp; const currentTime = timestamp;
const tPlusOne = timestamp + 60 * 1000; const tPlusOne = timestamp + 60 * 1000;
@ -190,6 +197,7 @@ function Application(): JSX.Element {
selectedTimeStamp={selectedTimeStamp} selectedTimeStamp={selectedTimeStamp}
selectedTraceTags={selectedTraceTags} selectedTraceTags={selectedTraceTags}
topLevelOperationsRoute={topLevelOperationsRoute} topLevelOperationsRoute={topLevelOperationsRoute}
topLevelOperationsLoading={topLevelOperationsLoading}
/> />
</Col> </Col>
@ -221,28 +229,48 @@ function Application(): JSX.Element {
</Row> </Row>
<Row gutter={24}> <Row gutter={24}>
<Col span={12}> <Col span={12}>
<Button <ColApDexContainer>
type="default" <Button
size="small" type="default"
id="Error_button" size="small"
onClick={(): void => { id="ApDex_button"
onErrorTrackHandler(selectedTimeStamp); onClick={onViewTracePopupClick({
}} servicename,
> selectedTraceTags,
View Traces timestamp: selectedTimeStamp,
</Button> })}
>
View Traces
</Button>
<ApDex
handleGraphClick={handleGraphClick}
onDragSelect={onDragSelect}
topLevelOperationsRoute={topLevelOperationsRoute}
tagFilterItems={tagFilterItems}
/>
</ColApDexContainer>
<ColErrorContainer>
<Button
type="default"
size="small"
id="Error_button"
onClick={onErrorTrackHandler(selectedTimeStamp)}
>
View Traces
</Button>
<TopLevelOperation <TopLevelOperation
handleGraphClick={handleGraphClick} handleGraphClick={handleGraphClick}
onDragSelect={onDragSelect} onDragSelect={onDragSelect}
topLevelOperationsError={topLevelOperationsError} topLevelOperationsError={topLevelOperationsError}
topLevelOperationsLoading={topLevelOperationsLoading} topLevelOperationsLoading={topLevelOperationsLoading}
topLevelOperationsIsError={topLevelOperationsIsError} topLevelOperationsIsError={topLevelOperationsIsError}
name="error_percentage_%" name="error_percentage_%"
widget={errorPercentageWidget} widget={errorPercentageWidget}
yAxisUnit="%" yAxisUnit="%"
opName="Error" opName="Error"
/> />
</ColErrorContainer>
</Col> </Col>
<Col span={12}> <Col span={12}>

View File

@ -0,0 +1,102 @@
import { Space, Typography } from 'antd';
import TextToolTip from 'components/TextToolTip';
import {
apDexToolTipText,
apDexToolTipUrl,
apDexToolTipUrlText,
} from 'constants/apDex';
import { PANEL_TYPES } from 'constants/queryBuilder';
import Graph from 'container/GridGraphLayout/Graph';
import DisplayThreshold from 'container/GridGraphLayout/WidgetHeader/DisplayThreshold';
import { GraphTitle } from 'container/MetricsApplication/constant';
import { getWidgetQueryBuilder } from 'container/MetricsApplication/MetricsApplication.factory';
import { apDexMetricsQueryBuilderQueries } from 'container/MetricsApplication/MetricsPageQueries/OverviewQueries';
import { ReactNode, useMemo } from 'react';
import { useParams } from 'react-router-dom';
import { EQueryType } from 'types/common/dashboard';
import { v4 as uuid } from 'uuid';
import { IServiceName } from '../../types';
import { ApDexMetricsProps } from './types';
function ApDexMetrics({
delta,
metricsBuckets,
thresholdValue,
onDragSelect,
tagFilterItems,
topLevelOperationsRoute,
handleGraphClick,
}: ApDexMetricsProps): JSX.Element {
const { servicename } = useParams<IServiceName>();
const apDexMetricsWidget = useMemo(
() =>
getWidgetQueryBuilder({
query: {
queryType: EQueryType.QUERY_BUILDER,
promql: [],
builder: apDexMetricsQueryBuilderQueries({
servicename,
tagFilterItems,
topLevelOperationsRoute,
threashold: thresholdValue || 0,
delta: delta || false,
metricsBuckets: metricsBuckets || [],
}),
clickhouse_sql: [],
id: uuid(),
},
title: (
<Space>
<Typography>{GraphTitle.APDEX}</Typography>
<TextToolTip
text={apDexToolTipText}
url={apDexToolTipUrl}
useFilledIcon={false}
urlText={apDexToolTipUrlText}
/>
</Space>
),
panelTypes: PANEL_TYPES.TIME_SERIES,
}),
[
delta,
metricsBuckets,
servicename,
tagFilterItems,
thresholdValue,
topLevelOperationsRoute,
],
);
const threshold: ReactNode = useMemo(() => {
if (thresholdValue) return <DisplayThreshold threshold={thresholdValue} />;
return null;
}, [thresholdValue]);
const isQueryEnabled =
topLevelOperationsRoute.length > 0 &&
metricsBuckets &&
metricsBuckets?.length > 0 &&
delta !== undefined;
return (
<Graph
name="apdex"
widget={apDexMetricsWidget}
onDragSelect={onDragSelect}
onClickHandler={handleGraphClick('ApDex')}
yAxisUnit=""
threshold={threshold}
isQueryEnabled={isQueryEnabled}
/>
);
}
ApDexMetrics.defaultProps = {
delta: undefined,
le: undefined,
};
export default ApDexMetrics;

View File

@ -0,0 +1,36 @@
import Spinner from 'components/Spinner';
import { useGetMetricMeta } from 'hooks/apDex/useGetMetricMeta';
import useErrorNotification from 'hooks/useErrorNotification';
import ApDexMetrics from './ApDexMetrics';
import { metricMeta } from './constants';
import { ApDexDataSwitcherProps } from './types';
function ApDexMetricsApplication({
handleGraphClick,
onDragSelect,
tagFilterItems,
topLevelOperationsRoute,
thresholdValue,
}: ApDexDataSwitcherProps): JSX.Element {
const { data, isLoading, error } = useGetMetricMeta(metricMeta);
useErrorNotification(error);
if (isLoading) {
return <Spinner height="40vh" tip="Loading..." />;
}
return (
<ApDexMetrics
handleGraphClick={handleGraphClick}
delta={data?.data.delta}
metricsBuckets={data?.data.le}
onDragSelect={onDragSelect}
topLevelOperationsRoute={topLevelOperationsRoute}
tagFilterItems={tagFilterItems}
thresholdValue={thresholdValue}
/>
);
}
export default ApDexMetricsApplication;

View File

@ -0,0 +1,62 @@
// This component is not been used in the application as we support only metrics for ApDex as of now.
// This component is been kept for future reference.
import { PANEL_TYPES } from 'constants/queryBuilder';
import Graph from 'container/GridGraphLayout/Graph';
import { GraphTitle } from 'container/MetricsApplication/constant';
import { getWidgetQueryBuilder } from 'container/MetricsApplication/MetricsApplication.factory';
import { apDexTracesQueryBuilderQueries } from 'container/MetricsApplication/MetricsPageQueries/OverviewQueries';
import { useMemo } from 'react';
import { useParams } from 'react-router-dom';
import { EQueryType } from 'types/common/dashboard';
import { v4 as uuid } from 'uuid';
import { IServiceName } from '../../types';
import { ApDexDataSwitcherProps } from './types';
function ApDexTraces({
handleGraphClick,
onDragSelect,
topLevelOperationsRoute,
tagFilterItems,
thresholdValue,
}: ApDexDataSwitcherProps): JSX.Element {
const { servicename } = useParams<IServiceName>();
const apDexTracesWidget = useMemo(
() =>
getWidgetQueryBuilder({
query: {
queryType: EQueryType.QUERY_BUILDER,
promql: [],
builder: apDexTracesQueryBuilderQueries({
servicename,
tagFilterItems,
topLevelOperationsRoute,
threashold: thresholdValue || 0,
}),
clickhouse_sql: [],
id: uuid(),
},
title: GraphTitle.APDEX,
panelTypes: PANEL_TYPES.TIME_SERIES,
}),
[servicename, tagFilterItems, thresholdValue, topLevelOperationsRoute],
);
const isQueryEnabled =
topLevelOperationsRoute.length > 0 && thresholdValue !== undefined;
return (
<Graph
name="apdex"
widget={apDexTracesWidget}
onDragSelect={onDragSelect}
onClickHandler={handleGraphClick('ApDex')}
yAxisUnit=""
threshold={thresholdValue}
isQueryEnabled={isQueryEnabled}
/>
);
}
export default ApDexTraces;

View File

@ -0,0 +1 @@
export const metricMeta = 'signoz_latency_bucket';

View File

@ -0,0 +1,47 @@
import Spinner from 'components/Spinner';
import { Card, GraphContainer } from 'container/MetricsApplication/styles';
import { useGetApDexSettings } from 'hooks/apDex/useGetApDexSettings';
import useErrorNotification from 'hooks/useErrorNotification';
import { memo } from 'react';
import { useParams } from 'react-router-dom';
import { IServiceName } from '../../types';
import ApDexMetricsApplication from './ApDexMetricsApplication';
import { ApDexApplicationProps } from './types';
function ApDexApplication({
handleGraphClick,
onDragSelect,
topLevelOperationsRoute,
tagFilterItems,
}: ApDexApplicationProps): JSX.Element {
const { servicename } = useParams<IServiceName>();
const { data, isLoading, error, isRefetching } = useGetApDexSettings(
servicename,
);
useErrorNotification(error);
if (isLoading || isRefetching) {
return (
<Card>
<Spinner height="40vh" tip="Loading..." />
</Card>
);
}
return (
<Card>
<GraphContainer>
<ApDexMetricsApplication
handleGraphClick={handleGraphClick}
onDragSelect={onDragSelect}
topLevelOperationsRoute={topLevelOperationsRoute}
tagFilterItems={tagFilterItems}
thresholdValue={data?.data[0].threshold}
/>
</GraphContainer>
</Card>
);
}
export default memo(ApDexApplication);

View File

@ -0,0 +1,19 @@
import { TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
import { ClickHandlerType } from '../../Overview';
export interface ApDexApplicationProps {
handleGraphClick: (type: string) => ClickHandlerType;
onDragSelect: (start: number, end: number) => void;
topLevelOperationsRoute: string[];
tagFilterItems: TagFilterItem[];
}
export interface ApDexDataSwitcherProps extends ApDexApplicationProps {
thresholdValue?: number;
}
export interface ApDexMetricsProps extends ApDexDataSwitcherProps {
delta?: boolean;
metricsBuckets?: number[];
}

View File

@ -1,3 +1,4 @@
import Spinner from 'components/Spinner';
import { FeatureKeys } from 'constants/features'; import { FeatureKeys } from 'constants/features';
import { PANEL_TYPES } from 'constants/queryBuilder'; import { PANEL_TYPES } from 'constants/queryBuilder';
import Graph from 'container/GridGraphLayout/Graph/'; import Graph from 'container/GridGraphLayout/Graph/';
@ -24,6 +25,7 @@ function ServiceOverview({
selectedTraceTags, selectedTraceTags,
selectedTimeStamp, selectedTimeStamp,
topLevelOperationsRoute, topLevelOperationsRoute,
topLevelOperationsLoading,
}: ServiceOverviewProps): JSX.Element { }: ServiceOverviewProps): JSX.Element {
const { servicename } = useParams<IServiceName>(); const { servicename } = useParams<IServiceName>();
@ -63,6 +65,14 @@ function ServiceOverview({
const isQueryEnabled = topLevelOperationsRoute.length > 0; const isQueryEnabled = topLevelOperationsRoute.length > 0;
if (topLevelOperationsLoading) {
return (
<Card>
<Spinner height="40vh" tip="Loading..." />
</Card>
);
}
return ( return (
<> <>
<Button <Button
@ -99,6 +109,7 @@ interface ServiceOverviewProps {
onDragSelect: (start: number, end: number) => void; onDragSelect: (start: number, end: number) => void;
handleGraphClick: (type: string) => ClickHandlerType; handleGraphClick: (type: string) => ClickHandlerType;
topLevelOperationsRoute: string[]; topLevelOperationsRoute: string[];
topLevelOperationsLoading: boolean;
} }
export default ServiceOverview; export default ServiceOverview;

View File

@ -56,7 +56,19 @@ export interface LatencyProps {
topLevelOperationsRoute: string[]; topLevelOperationsRoute: string[];
} }
export interface ApDexProps {
servicename: IServiceName['servicename'];
tagFilterItems: TagFilterItem[];
topLevelOperationsRoute: string[];
threashold: number;
}
export interface TableRendererProps { export interface TableRendererProps {
columnName: string; columnName: string;
renderFunction: (record: RowData) => ReactNode; renderFunction: (record: RowData) => ReactNode;
} }
export interface ApDexMetricsQueryBuilderQueriesProps extends ApDexProps {
delta: boolean;
metricsBuckets: number[];
}

View File

@ -14,9 +14,13 @@ export const OPERATION_LEGENDS = ['Operations'];
export enum FORMULA { export enum FORMULA {
ERROR_PERCENTAGE = 'A*100/B', ERROR_PERCENTAGE = 'A*100/B',
DATABASE_CALLS_AVG_DURATION = 'A/B', DATABASE_CALLS_AVG_DURATION = 'A/B',
APDEX_TRACES = '((B + C)/2)/A',
APDEX_DELTA_SPAN_METRICS = '(B + C/2)/A',
APDEX_CUMULATIVE_SPAN_METRICS = '((B + C)/2)/A',
} }
export enum GraphTitle { export enum GraphTitle {
APDEX = 'Apdex',
LATENCY = 'Latency', LATENCY = 'Latency',
RATE_PER_OPS = 'Rate (ops/s)', RATE_PER_OPS = 'Rate (ops/s)',
ERROR_PERCENTAGE = 'Error Percentage', ERROR_PERCENTAGE = 'Error Percentage',
@ -41,6 +45,7 @@ export enum DataType {
STRING = 'string', STRING = 'string',
FLOAT64 = 'float64', FLOAT64 = 'float64',
INT64 = 'int64', INT64 = 'int64',
BOOL = 'bool',
} }
export enum MetricsType { export enum MetricsType {
@ -49,7 +54,9 @@ export enum MetricsType {
} }
export enum WidgetKeys { export enum WidgetKeys {
Le = 'le',
Name = 'name', Name = 'name',
HasError = 'hasError',
Address = 'address', Address = 'address',
DurationNano = 'durationNano', DurationNano = 'durationNano',
StatusCode = 'status_code', StatusCode = 'status_code',

View File

@ -29,6 +29,14 @@ export const Col = styled(ColComponent)`
} }
`; `;
export const ColApDexContainer = styled(ColComponent)`
padding: 0 !important;
`;
export const ColErrorContainer = styled(ColComponent)`
padding: 2rem 0 !important;
`;
export const GraphContainer = styled.div` export const GraphContainer = styled.div`
height: 40vh; height: 40vh;
`; `;

View File

@ -1,3 +1,4 @@
import { ReactNode } from 'react';
import { Widgets } from 'types/api/dashboard/getAll'; import { Widgets } from 'types/api/dashboard/getAll';
import { TagFilterItem } from 'types/api/queryBuilder/queryBuilderData'; import { TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
@ -5,7 +6,7 @@ import { IServiceName } from './Tabs/types';
export interface GetWidgetQueryBuilderProps { export interface GetWidgetQueryBuilderProps {
query: Widgets['query']; query: Widgets['query'];
title?: string; title?: ReactNode;
panelTypes: Widgets['panelTypes']; panelTypes: Widgets['panelTypes'];
} }

View File

@ -24,3 +24,14 @@ export const navigateToTrace = ({
}?${urlParams.toString()}&selected={"serviceName":["${servicename}"],"operation":["${operation}"]}&filterToFetchData=["duration","status","serviceName","operation"]&spanAggregateCurrentPage=1&selectedTags=${selectedTraceTags}&&isFilterExclude={"serviceName":false,"operation":false}&userSelectedFilter={"status":["error","ok"],"serviceName":["${servicename}"],"operation":["${operation}"]}&spanAggregateCurrentPage=1`, }?${urlParams.toString()}&selected={"serviceName":["${servicename}"],"operation":["${operation}"]}&filterToFetchData=["duration","status","serviceName","operation"]&spanAggregateCurrentPage=1&selectedTags=${selectedTraceTags}&&isFilterExclude={"serviceName":false,"operation":false}&userSelectedFilter={"status":["error","ok"],"serviceName":["${servicename}"],"operation":["${operation}"]}&spanAggregateCurrentPage=1`,
); );
}; };
export const getNearestHighestBucketValue = (
value: number,
buckets: number[],
): string => {
const nearestBucket = buckets.find((bucket) => bucket >= value);
return nearestBucket?.toString() || '+Inf';
};
export const convertMilSecToNanoSec = (value: number): number =>
value * 1000000000;

View File

@ -67,7 +67,9 @@ function NewWidget({ selectedGraph, saveSettingOfPanel }: Props): JSX.Element {
const selectedWidget = getWidget(); const selectedWidget = getWidget();
const [title, setTitle] = useState<string>(selectedWidget?.title || ''); const [title, setTitle] = useState<string>(
selectedWidget?.title?.toString() || '',
);
const [description, setDescription] = useState<string>( const [description, setDescription] = useState<string>(
selectedWidget?.description || '', selectedWidget?.description || '',
); );

View File

@ -0,0 +1,12 @@
import { getApDexSettings } from 'api/metrics/ApDex/getApDexSettings';
import { AxiosError, AxiosResponse } from 'axios';
import { useQuery, UseQueryResult } from 'react-query';
import { ApDexPayloadAndSettingsProps } from 'types/api/metrics/getApDex';
export const useGetApDexSettings = (
servicename: string,
): UseQueryResult<AxiosResponse<ApDexPayloadAndSettingsProps[]>, AxiosError> =>
useQuery<AxiosResponse<ApDexPayloadAndSettingsProps[]>, AxiosError>({
queryKey: [{ servicename }],
queryFn: async () => getApDexSettings(servicename),
});

View File

@ -0,0 +1,12 @@
import { getMetricMeta } from 'api/metrics/ApDex/getMetricMeta';
import { AxiosError, AxiosResponse } from 'axios';
import { useQuery, UseQueryResult } from 'react-query';
import { MetricMetaProps } from 'types/api/metrics/getApDex';
export const useGetMetricMeta = (
metricName: string,
): UseQueryResult<AxiosResponse<MetricMetaProps>, AxiosError> =>
useQuery<AxiosResponse<MetricMetaProps>, AxiosError>({
queryKey: [{ metricName }],
queryFn: async () => getMetricMeta(metricName),
});

View File

@ -0,0 +1,21 @@
import { setApDexSettings } from 'api/metrics/ApDex/apDexSettings';
import { useMutation, UseMutationResult } from 'react-query';
import {
ApDexPayloadAndSettingsProps,
SetApDexPayloadProps,
} from 'types/api/metrics/getApDex';
export const useSetApDexSettings = ({
servicename,
threshold,
excludeStatusCode,
}: ApDexPayloadAndSettingsProps): UseMutationResult<
SetApDexPayloadProps,
Error,
ApDexPayloadAndSettingsProps
> =>
useMutation<SetApDexPayloadProps, Error, ApDexPayloadAndSettingsProps>({
mutationKey: [servicename, threshold.toString(), excludeStatusCode],
mutationFn: async () =>
setApDexSettings({ servicename, threshold, excludeStatusCode }),
});

View File

@ -0,0 +1,65 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { APPLICATION_SETTINGS } from '../constants';
import { thresholdMockData } from './__mock__/thresholdMockData';
import ApDexApplication from './ApDexApplication';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useParams: (): {
servicename: string;
} => ({ servicename: 'mockServiceName' }),
}));
jest.mock('hooks/apDex/useGetApDexSettings', () => ({
__esModule: true,
useGetApDexSettings: jest.fn().mockReturnValue({
data: thresholdMockData,
isLoading: false,
error: null,
refetch: jest.fn(),
}),
}));
jest.mock('hooks/apDex/useSetApDexSettings', () => ({
__esModule: true,
useSetApDexSettings: jest.fn().mockReturnValue({
mutateAsync: jest.fn(),
isLoading: false,
error: null,
}),
}));
describe('ApDexApplication', () => {
it('should render the component', () => {
render(<ApDexApplication />);
expect(screen.getByText('Settings')).toBeInTheDocument();
});
it('should open the popover when the settings button is clicked', async () => {
render(<ApDexApplication />);
const button = screen.getByText('Settings');
fireEvent.click(button);
await waitFor(() => {
expect(screen.getByText(APPLICATION_SETTINGS)).toBeInTheDocument();
});
});
it('should close the popover when the close button is clicked', async () => {
render(<ApDexApplication />);
const button = screen.getByText('Settings');
fireEvent.click(button);
await waitFor(() => {
expect(screen.getByText(APPLICATION_SETTINGS)).toBeInTheDocument();
});
const closeButton = screen.getByText('Cancel');
fireEvent.click(closeButton);
await waitFor(() => {
expect(screen.queryByText(APPLICATION_SETTINGS)).not.toBeInTheDocument();
});
});
});

View File

@ -0,0 +1,56 @@
import { SettingOutlined } from '@ant-design/icons';
import { Popover } from 'antd';
import { IServiceName } from 'container/MetricsApplication/Tabs/types';
import { useGetApDexSettings } from 'hooks/apDex/useGetApDexSettings';
import useErrorNotification from 'hooks/useErrorNotification';
import { useState } from 'react';
import { useParams } from 'react-router-dom';
import { Button } from '../styles';
import ApDexSettings from './ApDexSettings';
function ApDexApplication(): JSX.Element {
const { servicename } = useParams<IServiceName>();
const {
data,
isLoading,
error,
refetch: refetchGetApDexSetting,
} = useGetApDexSettings(servicename);
const [isOpen, setIsOpen] = useState<boolean>(false);
useErrorNotification(error);
const handlePopOverClose = (): void => {
setIsOpen(false);
};
const handleOpenChange = (newOpen: boolean): void => {
setIsOpen(newOpen);
};
return (
<Popover
placement="bottomRight"
destroyTooltipOnHide
trigger={['click']}
showArrow={false}
open={isOpen}
onOpenChange={handleOpenChange}
content={
<ApDexSettings
servicename={servicename}
handlePopOverClose={handlePopOverClose}
isLoading={isLoading}
data={data}
refetchGetApDexSetting={refetchGetApDexSetting}
/>
}
>
<Button size="middle" icon={<SettingOutlined />}>
Settings
</Button>
</Popover>
);
}
export default ApDexApplication;

View File

@ -0,0 +1,62 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { axiosResponseThresholdData } from './__mock__/axiosResponseMockThresholdData';
import ApDexSettings from './ApDexSettings';
jest.mock('hooks/apDex/useSetApDexSettings', () => ({
__esModule: true,
useSetApDexSettings: jest.fn().mockReturnValue({
mutateAsync: jest.fn(),
isLoading: false,
error: null,
}),
}));
describe('ApDexSettings', () => {
it('should render the component', () => {
render(
<ApDexSettings
servicename="mockServiceName"
handlePopOverClose={jest.fn()}
isLoading={false}
data={axiosResponseThresholdData}
refetchGetApDexSetting={jest.fn()}
/>,
);
expect(screen.getByText('Application Settings')).toBeInTheDocument();
});
it('should render the spinner when the data is loading', () => {
render(
<ApDexSettings
servicename="mockServiceName"
handlePopOverClose={jest.fn()}
isLoading
data={axiosResponseThresholdData}
refetchGetApDexSetting={jest.fn()}
/>,
);
expect(screen.getByText('Loading...')).toBeInTheDocument();
});
it('should close the popover when the cancel button is clicked', async () => {
const mockHandlePopOverClose = jest.fn();
render(
<ApDexSettings
servicename="mockServiceName"
handlePopOverClose={mockHandlePopOverClose}
isLoading={false}
data={axiosResponseThresholdData}
refetchGetApDexSetting={jest.fn()}
/>,
);
const button = screen.getByText('Cancel');
fireEvent.click(button);
await waitFor(() => {
expect(mockHandlePopOverClose).toHaveBeenCalled();
});
});
});

View File

@ -0,0 +1,119 @@
import { CloseOutlined } from '@ant-design/icons';
import { Card, InputNumber } from 'antd';
import Spinner from 'components/Spinner';
import TextToolTip from 'components/TextToolTip';
import {
apDexToolTipText,
apDexToolTipUrl,
apDexToolTipUrlText,
} from 'constants/apDex';
import { themeColors } from 'constants/theme';
import { useSetApDexSettings } from 'hooks/apDex/useSetApDexSettings';
import { useNotifications } from 'hooks/useNotifications';
import { useState } from 'react';
import { APPLICATION_SETTINGS } from '../constants';
import {
AppDexThresholdContainer,
Button,
SaveAndCancelContainer,
SaveButton,
Typography,
} from '../styles';
import { onSaveApDexSettings } from '../utils';
import { ApDexSettingsProps } from './types';
function ApDexSettings({
servicename,
handlePopOverClose,
isLoading,
data,
refetchGetApDexSetting,
}: ApDexSettingsProps): JSX.Element {
const [thresholdValue, setThresholdValue] = useState(() => {
if (data) {
return data.data[0].threshold;
}
return 0;
});
const { notifications } = useNotifications();
const { isLoading: isApDexLoading, mutateAsync } = useSetApDexSettings({
servicename,
threshold: thresholdValue,
excludeStatusCode: '',
});
const handleThreadholdChange = (value: number | null): void => {
if (value !== null) {
setThresholdValue(value);
}
};
if (isLoading) {
return (
<Typography.Text style={{ color: themeColors.white }}>
<Spinner height="5vh" tip="Loading..." />
</Typography.Text>
);
}
return (
<Card
title={APPLICATION_SETTINGS}
extra={<CloseOutlined width={10} height={10} onClick={handlePopOverClose} />}
actions={[
<SaveAndCancelContainer key="SaveAndCancelContainer">
<Button onClick={handlePopOverClose}>Cancel</Button>
<SaveButton
onClick={onSaveApDexSettings({
handlePopOverClose,
mutateAsync,
notifications,
refetchGetApDexSetting,
servicename,
thresholdValue,
})}
type="primary"
loading={isApDexLoading}
>
Save
</SaveButton>
</SaveAndCancelContainer>,
]}
>
<AppDexThresholdContainer>
<Typography>
Apdex threshold (in seconds){' '}
<TextToolTip
text={apDexToolTipText}
url={apDexToolTipUrl}
useFilledIcon={false}
urlText={apDexToolTipUrlText}
/>
</Typography>
<InputNumber
value={thresholdValue}
onChange={handleThreadholdChange}
min={0}
step={0.1}
/>
</AppDexThresholdContainer>
{/* TODO: Add this feature later when backend is ready to support it. */}
{/* <ExcludeErrorCodeContainer>
<Typography.Text>
Exclude following error codes from error rate calculation
</Typography.Text>
<Input placeholder="e.g. 406, 418" />
</ExcludeErrorCodeContainer> */}
</Card>
);
}
ApDexSettings.defaultProps = {
isLoading: undefined,
data: undefined,
refetchGetApDexSetting: undefined,
};
export default ApDexSettings;

View File

@ -0,0 +1,9 @@
import { AxiosResponse } from 'axios';
export const axiosResponseThresholdData = {
data: [
{
threshold: 0.5,
},
],
} as AxiosResponse;

View File

@ -0,0 +1,7 @@
export const thresholdMockData = {
data: [
{
threshold: 0.5,
},
],
};

View File

@ -0,0 +1,10 @@
import { AxiosResponse } from 'axios';
import { ApDexPayloadAndSettingsProps } from 'types/api/metrics/getApDex';
export interface ApDexSettingsProps {
servicename: string;
handlePopOverClose: () => void;
isLoading?: boolean;
data?: AxiosResponse<ApDexPayloadAndSettingsProps[]>;
refetchGetApDexSetting?: () => void;
}

View File

@ -0,0 +1 @@
export const APPLICATION_SETTINGS = 'Application Settings';

View File

@ -8,6 +8,7 @@ import history from 'lib/history';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { generatePath, useParams } from 'react-router-dom'; import { generatePath, useParams } from 'react-router-dom';
import ApDexApplication from './ApDex/ApDexApplication';
import { MetricsApplicationTab, TAB_KEY_VS_LABEL } from './types'; import { MetricsApplicationTab, TAB_KEY_VS_LABEL } from './types';
import useMetricsApplicationTabKey from './useMetricsApplicationTabKey'; import useMetricsApplicationTabKey from './useMetricsApplicationTabKey';
@ -49,6 +50,7 @@ function MetricsApplication(): JSX.Element {
return ( return (
<> <>
<ResourceAttributesFilter /> <ResourceAttributesFilter />
<ApDexApplication />
<RouteTab routes={routes} history={history} activeKey={activeKey} /> <RouteTab routes={routes} history={history} activeKey={activeKey} />
</> </>
); );

View File

@ -0,0 +1,44 @@
import {
Button as ButtonComponent,
Typography as TypographyComponent,
} from 'antd';
import { themeColors } from 'constants/theme';
import styled from 'styled-components';
export const Button = styled(ButtonComponent)`
&&& {
width: min-content;
align-self: flex-end;
}
`;
export const AppDexThresholdContainer = styled.div`
display: flex;
align-items: center;
`;
export const Typography = styled(TypographyComponent)`
&&& {
width: 24rem;
margin: 0.5rem 0;
color: ${themeColors.white};
}
`;
export const SaveAndCancelContainer = styled.div`
display: flex;
justify-content: flex-end;
margin-right: 1rem;
`;
export const SaveButton = styled(ButtonComponent)`
&&& {
margin: 0 0.5rem;
display: flex;
align-items: center;
}
`;
export const ExcludeErrorCodeContainer = styled.div`
margin: 1rem 0;
`;

View File

@ -1,3 +1,10 @@
import { NotificationInstance } from 'antd/es/notification/interface';
import { UseMutateAsyncFunction } from 'react-query';
import {
ApDexPayloadAndSettingsProps,
SetApDexPayloadProps,
} from 'types/api/metrics/getApDex';
export enum MetricsApplicationTab { export enum MetricsApplicationTab {
OVER_METRICS = 'OVER_METRICS', OVER_METRICS = 'OVER_METRICS',
DB_CALL_METRICS = 'DB_CALL_METRICS', DB_CALL_METRICS = 'DB_CALL_METRICS',
@ -9,3 +16,16 @@ export const TAB_KEY_VS_LABEL = {
[MetricsApplicationTab.DB_CALL_METRICS]: 'DB Call Metrics', [MetricsApplicationTab.DB_CALL_METRICS]: 'DB Call Metrics',
[MetricsApplicationTab.EXTERNAL_METRICS]: 'External Metrics', [MetricsApplicationTab.EXTERNAL_METRICS]: 'External Metrics',
}; };
export interface OnSaveApDexSettingsProps {
thresholdValue: number;
servicename: string;
notifications: NotificationInstance;
refetchGetApDexSetting?: VoidFunction;
mutateAsync: UseMutateAsyncFunction<
SetApDexPayloadProps,
Error,
ApDexPayloadAndSettingsProps
>;
handlePopOverClose: VoidFunction;
}

View File

@ -1,5 +1,8 @@
import axios from 'axios';
import { SOMETHING_WENT_WRONG } from 'constants/api';
import { TAB_KEYS_VS_METRICS_APPLICATION_KEY } from './config'; import { TAB_KEYS_VS_METRICS_APPLICATION_KEY } from './config';
import { MetricsApplicationTab } from './types'; import { MetricsApplicationTab, OnSaveApDexSettingsProps } from './types';
export const isMetricsApplicationTab = ( export const isMetricsApplicationTab = (
tab: string, tab: string,
@ -15,3 +18,29 @@ export const getMetricsApplicationKey = (
return MetricsApplicationTab.OVER_METRICS; return MetricsApplicationTab.OVER_METRICS;
}; };
export const onSaveApDexSettings = ({
thresholdValue,
refetchGetApDexSetting,
mutateAsync,
notifications,
handlePopOverClose,
servicename,
}: OnSaveApDexSettingsProps) => async (): Promise<void> => {
if (!refetchGetApDexSetting) return;
try {
await mutateAsync({
servicename,
threshold: thresholdValue,
excludeStatusCode: '',
});
await refetchGetApDexSetting();
} catch (err) {
notifications.error({
message: axios.isAxiosError(err) ? err.message : SOMETHING_WENT_WRONG,
});
} finally {
handlePopOverClose();
}
};

View File

@ -1,5 +1,6 @@
import { PANEL_TYPES } from 'constants/queryBuilder'; import { PANEL_TYPES } from 'constants/queryBuilder';
import { timePreferenceType } from 'container/NewWidget/RightContainer/timeItems'; import { timePreferenceType } from 'container/NewWidget/RightContainer/timeItems';
import { ReactNode } from 'react';
import { Layout } from 'react-grid-layout'; import { Layout } from 'react-grid-layout';
import { Query } from 'types/api/queryBuilder/queryBuilderData'; import { Query } from 'types/api/queryBuilder/queryBuilderData';
@ -58,7 +59,7 @@ export interface IBaseWidget {
isStacked: boolean; isStacked: boolean;
id: string; id: string;
panelTypes: PANEL_TYPES; panelTypes: PANEL_TYPES;
title: string; title: ReactNode;
description: string; description: string;
opacity: string; opacity: string;
nullZeroValues: string; nullZeroValues: string;

View File

@ -0,0 +1,14 @@
export interface ApDexPayloadAndSettingsProps {
servicename: string;
threshold: number;
excludeStatusCode: string;
}
export interface SetApDexPayloadProps {
data: string;
}
export interface MetricMetaProps {
delta: boolean;
le: number[];
}

View File

@ -21,7 +21,7 @@ export interface TagFilterItem {
id: string; id: string;
key?: BaseAutocompleteData; key?: BaseAutocompleteData;
op: string; op: string;
value: string[] | string; value: string[] | string | number | boolean;
} }
export interface TagFilter { export interface TagFilter {