refactor: remove the dependency of services from redux (#2998)

* refactor: remove the dependency of services using redux

* refactor: seperated columns and unit test case

* refactor: move the constant to other file

* refactor: updated test case

* refactor: removed the duplicate enum

* fix: removed the inline function

* fix: removed the inline function

* refactor: removed the magic string

* fix: change the name from matrics to metrics

* fix: one on one mapping of props

* refactor: created a hook to getting services through api call

* fix: linter error

* refactor: renamed the file according to functionality

* refactor: renamed more file according to functionality

* refactor: removed unwanted interfaces and renamed files

* refactor: separated types

* refactor: shifted mock data and completed review changes

* chore: updated test cases

* refactor: added useEffect in errornotification

* chore: updated service test

* chore: shifted loading to table level

* chore: updated test cases

---------

Co-authored-by: Vishal Sharma <makeavish786@gmail.com>
This commit is contained in:
Rajat Dabade 2023-07-25 20:45:12 +05:30 committed by GitHub
parent e6fa1383f3
commit b9409820cc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 380 additions and 382 deletions

View File

@ -1,7 +1,7 @@
import Loadable from 'components/Loadable';
export const ServicesTablePage = Loadable(
() => import(/* webpackChunkName: "ServicesTablePage" */ 'pages/Metrics'),
() => import(/* webpackChunkName: "ServicesTablePage" */ 'pages/Services'),
);
export const ServiceMetricsPage = Loadable(

View File

@ -1,28 +1,13 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps, Props } from 'types/api/metrics/getService';
const getService = async (
props: Props,
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
try {
const response = await axios.post(`/services`, {
start: `${props.start}`,
end: `${props.end}`,
tags: props.selectedTags,
});
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
const getService = async (props: Props): Promise<PayloadProps> => {
const response = await axios.post(`/services`, {
start: `${props.start}`,
end: `${props.end}`,
tags: props.selectedTags,
});
return response.data;
};
export default getService;

View File

@ -1,70 +0,0 @@
import { render, RenderResult, screen, waitFor } from '@testing-library/react';
import { ReactElement } from 'react';
import { Provider } from 'react-redux';
import { BrowserRouter } from 'react-router-dom';
import {
combineReducers,
legacy_createStore as createStore,
Store,
} from 'redux';
import { InitialValue } from '../../store/reducers/metric';
import Metrics from './index';
const rootReducer = combineReducers({
metrics: (state = InitialValue) => state,
});
const mockStore = createStore(rootReducer);
const renderWithReduxAndRouter = (mockStore: Store) => (
component: ReactElement,
): RenderResult =>
render(
<BrowserRouter>
<Provider store={mockStore}>{component}</Provider>
</BrowserRouter>,
);
describe('Metrics Component', () => {
it('renders without errors', async () => {
renderWithReduxAndRouter(mockStore)(<Metrics />);
await waitFor(() => {
expect(screen.getByText(/application/i)).toBeInTheDocument();
expect(screen.getByText(/p99 latency \(in ms\)/i)).toBeInTheDocument();
expect(screen.getByText(/error rate \(% of total\)/i)).toBeInTheDocument();
expect(screen.getByText(/operations per second/i)).toBeInTheDocument();
});
});
it('renders loading when required conditions are met', async () => {
const customStore = createStore(rootReducer, {
metrics: {
services: [],
loading: true,
error: false,
},
});
const { container } = renderWithReduxAndRouter(customStore)(<Metrics />);
const spinner = container.querySelector('.ant-spin-nested-loading');
expect(spinner).toBeInTheDocument();
});
it('renders no data when required conditions are met', async () => {
const customStore = createStore(rootReducer, {
metrics: {
services: [],
loading: false,
error: false,
},
});
renderWithReduxAndRouter(customStore)(<Metrics />);
expect(screen.getByText('No data')).toBeInTheDocument();
});
});

View File

@ -1,169 +0,0 @@
import { blue } from '@ant-design/colors';
import { SearchOutlined } from '@ant-design/icons';
import { Button, Card, Input, Space } from 'antd';
import type { ColumnsType, ColumnType } from 'antd/es/table';
import type {
FilterConfirmProps,
FilterDropdownProps,
} from 'antd/es/table/interface';
import localStorageGet from 'api/browser/localstorage/get';
import localStorageSet from 'api/browser/localstorage/set';
import { ResizeTable } from 'components/ResizeTable';
import { SKIP_ONBOARDING } from 'constants/onboarding';
import ROUTES from 'constants/routes';
import { routeConfig } from 'container/SideNav/config';
import { getQueryString } from 'container/SideNav/helper';
import { useCallback, useMemo, useState } from 'react';
import { useSelector } from 'react-redux';
import { Link, useLocation } from 'react-router-dom';
import { AppState } from 'store/reducers';
import { ServicesList } from 'types/api/metrics/getService';
import MetricReducer from 'types/reducer/metrics';
import SkipBoardModal from './SkipOnBoardModal';
import { Container, Name } from './styles';
function Metrics(): JSX.Element {
const { search } = useLocation();
const [skipOnboarding, setSkipOnboarding] = useState(
localStorageGet(SKIP_ONBOARDING) === 'true',
);
const { services, loading, error } = useSelector<AppState, MetricReducer>(
(state) => state.metrics,
);
const onContinueClick = (): void => {
localStorageSet(SKIP_ONBOARDING, 'true');
setSkipOnboarding(true);
};
const handleSearch = (confirm: (param?: FilterConfirmProps) => void): void => {
confirm();
};
const FilterIcon: ColumnType<DataProps>['filterIcon'] = useCallback(
(filtered: boolean) => (
<SearchOutlined
style={{
color: filtered ? blue[6] : undefined,
}}
/>
),
[],
);
const filterDropdown = useCallback(
({ setSelectedKeys, selectedKeys, confirm }: FilterDropdownProps) => (
<Card size="small">
<Space align="start" direction="vertical">
<Input
placeholder="Search by service"
value={selectedKeys[0]}
onChange={(e): void =>
setSelectedKeys(e.target.value ? [e.target.value] : [])
}
allowClear
onPressEnter={(): void => handleSearch(confirm)}
/>
<Button
type="primary"
onClick={(): void => handleSearch(confirm)}
icon={<SearchOutlined />}
size="small"
>
Search
</Button>
</Space>
</Card>
),
[],
);
type DataIndex = keyof ServicesList;
const getColumnSearchProps = useCallback(
(dataIndex: DataIndex): ColumnType<DataProps> => ({
filterDropdown,
filterIcon: FilterIcon,
onFilter: (value: string | number | boolean, record: DataProps): boolean =>
record[dataIndex]
.toString()
.toLowerCase()
.includes(value.toString().toLowerCase()),
render: (metrics: string): JSX.Element => {
const urlParams = new URLSearchParams(search);
const avialableParams = routeConfig[ROUTES.SERVICE_METRICS];
const queryString = getQueryString(avialableParams, urlParams);
return (
<Link to={`${ROUTES.APPLICATION}/${metrics}?${queryString.join('')}`}>
<Name>{metrics}</Name>
</Link>
);
},
}),
[filterDropdown, FilterIcon, search],
);
const columns: ColumnsType<DataProps> = useMemo(
() => [
{
title: 'Application',
dataIndex: 'serviceName',
width: 200,
key: 'serviceName',
...getColumnSearchProps('serviceName'),
},
{
title: 'P99 latency (in ms)',
dataIndex: 'p99',
key: 'p99',
width: 150,
defaultSortOrder: 'descend',
sorter: (a: DataProps, b: DataProps): number => a.p99 - b.p99,
render: (value: number): string => (value / 1000000).toFixed(2),
},
{
title: 'Error Rate (% of total)',
dataIndex: 'errorRate',
key: 'errorRate',
width: 150,
sorter: (a: DataProps, b: DataProps): number => a.errorRate - b.errorRate,
render: (value: number): string => value.toFixed(2),
},
{
title: 'Operations Per Second',
dataIndex: 'callRate',
key: 'callRate',
width: 150,
sorter: (a: DataProps, b: DataProps): number => a.callRate - b.callRate,
render: (value: number): string => value.toFixed(2),
},
],
[getColumnSearchProps],
);
if (
services.length === 0 &&
loading === false &&
!skipOnboarding &&
error === true
) {
return <SkipBoardModal onContinueClick={onContinueClick} />;
}
return (
<Container>
<ResizeTable
columns={columns}
loading={loading}
dataSource={services}
rowKey="serviceName"
/>
</Container>
);
}
type DataProps = ServicesList;
export default Metrics;

View File

@ -0,0 +1,26 @@
export enum ColumnKey {
Application = 'serviceName',
P99 = 'p99',
ErrorRate = 'errorRate',
Operations = 'callRate',
}
export const ColumnTitle: {
[key in ColumnKey]: string;
} = {
[ColumnKey.Application]: 'Application',
[ColumnKey.P99]: 'P99 latency (in ms)',
[ColumnKey.ErrorRate]: 'Error Rate (% of total)',
[ColumnKey.Operations]: 'Operations Per Second',
};
export enum ColumnWidth {
Application = 200,
P99 = 150,
ErrorRate = 150,
Operations = 150,
}
export const SORTING_ORDER = 'descend';
export const SEARCH_PLACEHOLDER = 'Search by service';

View File

@ -0,0 +1,34 @@
import { SearchOutlined } from '@ant-design/icons';
import type { ColumnType } from 'antd/es/table';
import ROUTES from 'constants/routes';
import { routeConfig } from 'container/SideNav/config';
import { getQueryString } from 'container/SideNav/helper';
import { Link } from 'react-router-dom';
import { ServicesList } from 'types/api/metrics/getService';
import { filterDropdown } from '../Filter/FilterDropdown';
import { Name } from '../styles';
export const getColumnSearchProps = (
dataIndex: keyof ServicesList,
search: string,
): ColumnType<ServicesList> => ({
filterDropdown,
filterIcon: <SearchOutlined />,
onFilter: (value: string | number | boolean, record: ServicesList): boolean =>
record[dataIndex]
.toString()
.toLowerCase()
.includes(value.toString().toLowerCase()),
render: (metrics: string): JSX.Element => {
const urlParams = new URLSearchParams(search);
const avialableParams = routeConfig[ROUTES.SERVICE_METRICS];
const queryString = getQueryString(avialableParams, urlParams);
return (
<Link to={`${ROUTES.APPLICATION}/${metrics}?${queryString.join('')}`}>
<Name>{metrics}</Name>
</Link>
);
},
});

View File

@ -0,0 +1,46 @@
import type { ColumnsType } from 'antd/es/table';
import { ServicesList } from 'types/api/metrics/getService';
import {
ColumnKey,
ColumnTitle,
ColumnWidth,
SORTING_ORDER,
} from './ColumnContants';
import { getColumnSearchProps } from './GetColumnSearchProps';
export const getColumns = (search: string): ColumnsType<ServicesList> => [
{
title: ColumnTitle[ColumnKey.Application],
dataIndex: ColumnKey.Application,
width: ColumnWidth.Application,
key: ColumnKey.Application,
...getColumnSearchProps('serviceName', search),
},
{
title: ColumnTitle[ColumnKey.P99],
dataIndex: ColumnKey.P99,
key: ColumnKey.P99,
width: ColumnWidth.P99,
defaultSortOrder: SORTING_ORDER,
sorter: (a: ServicesList, b: ServicesList): number => a.p99 - b.p99,
render: (value: number): string => (value / 1000000).toFixed(2),
},
{
title: ColumnTitle[ColumnKey.ErrorRate],
dataIndex: ColumnKey.ErrorRate,
key: ColumnKey.ErrorRate,
width: 150,
sorter: (a: ServicesList, b: ServicesList): number =>
a.errorRate - b.errorRate,
render: (value: number): string => value.toFixed(2),
},
{
title: ColumnTitle[ColumnKey.Operations],
dataIndex: ColumnKey.Operations,
key: ColumnKey.Operations,
width: ColumnWidth.Operations,
sorter: (a: ServicesList, b: ServicesList): number => a.callRate - b.callRate,
render: (value: number): string => value.toFixed(2),
},
];

View File

@ -0,0 +1,41 @@
import { SearchOutlined } from '@ant-design/icons';
import { Button, Card, Input, Space } from 'antd';
import type { FilterDropdownProps } from 'antd/es/table/interface';
import { SEARCH_PLACEHOLDER } from '../Columns/ColumnContants';
export const filterDropdown = ({
setSelectedKeys,
selectedKeys,
confirm,
}: FilterDropdownProps): JSX.Element => {
const handleSearch = (): void => {
confirm();
};
const selectedKeysHandler = (e: React.ChangeEvent<HTMLInputElement>): void => {
setSelectedKeys(e.target.value ? [e.target.value] : []);
};
return (
<Card size="small">
<Space align="start" direction="vertical">
<Input
placeholder={SEARCH_PLACEHOLDER}
value={selectedKeys[0]}
onChange={selectedKeysHandler}
allowClear
onPressEnter={handleSearch}
/>
<Button
type="primary"
onClick={handleSearch}
icon={<SearchOutlined />}
size="small"
>
Search
</Button>
</Space>
</Card>
);
};

View File

@ -0,0 +1,50 @@
import { render, screen, waitFor } from '@testing-library/react';
import ROUTES from 'constants/routes';
import { BrowserRouter } from 'react-router-dom';
import { Services } from './__mock__/servicesListMock';
import Metrics from './index';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: (): { pathname: string } => ({
pathname: `${process.env.FRONTEND_API_ENDPOINT}/${ROUTES.APPLICATION}/`,
}),
}));
describe('Metrics Component', () => {
it('renders without errors', async () => {
render(
<BrowserRouter>
<Metrics services={Services} isLoading={false} />
</BrowserRouter>,
);
await waitFor(() => {
expect(screen.getByText(/application/i)).toBeInTheDocument();
expect(screen.getByText(/p99 latency \(in ms\)/i)).toBeInTheDocument();
expect(screen.getByText(/error rate \(% of total\)/i)).toBeInTheDocument();
expect(screen.getByText(/operations per second/i)).toBeInTheDocument();
});
});
it('renders if the data is loaded in the table', async () => {
render(
<BrowserRouter>
<Metrics services={Services} isLoading={false} />
</BrowserRouter>,
);
expect(screen.getByText('frontend')).toBeInTheDocument();
});
it('renders no data when required conditions are met', async () => {
render(
<BrowserRouter>
<Metrics services={[]} isLoading={false} />
</BrowserRouter>,
);
expect(screen.getByText('No data')).toBeInTheDocument();
});
});

View File

@ -0,0 +1,22 @@
import { ServicesList } from 'types/api/metrics/getService';
export const Services: ServicesList[] = [
{
serviceName: 'frontend',
p99: 1261498140,
avgDuration: 768497850.9803921,
numCalls: 255,
callRate: 0.9444444444444444,
numErrors: 0,
errorRate: 0,
},
{
serviceName: 'customer',
p99: 890150740.0000001,
avgDuration: 369612035.2941176,
numCalls: 255,
callRate: 0.9444444444444444,
numErrors: 0,
errorRate: 0,
},
];

View File

@ -0,0 +1,26 @@
import { ResizeTable } from 'components/ResizeTable';
import { useMemo } from 'react';
import { useLocation } from 'react-router-dom';
import { getColumns } from './Columns/ServiceColumn';
import { Container } from './styles';
import ServiceTableProp from './types';
function Services({ services, isLoading }: ServiceTableProp): JSX.Element {
const { search } = useLocation();
const tableColumns = useMemo(() => getColumns(search), [search]);
return (
<Container>
<ResizeTable
columns={tableColumns}
dataSource={services}
loading={isLoading}
rowKey="serviceName"
/>
</Container>
);
}
export default Services;

View File

@ -0,0 +1,6 @@
import { ServicesList } from 'types/api/metrics/getService';
export default interface ServiceTableProp {
services: ServicesList[];
isLoading: boolean;
}

View File

@ -0,0 +1,17 @@
import { AxiosError } from 'axios';
import { useEffect } from 'react';
import { useNotifications } from './useNotifications';
const useErrorNotification = (error: AxiosError | null): void => {
const { notifications } = useNotifications();
useEffect(() => {
if (error) {
notifications.error({
message: error.message,
});
}
}, [error, notifications]);
};
export default useErrorNotification;

View File

@ -0,0 +1,29 @@
import getService from 'api/metrics/getService';
import { AxiosError } from 'axios';
import { Time } from 'container/TopNav/DateTimeSelection/config';
import { useQuery, UseQueryResult } from 'react-query';
import { PayloadProps } from 'types/api/metrics/getService';
import { Tags } from 'types/reducer/trace';
export const useQueryService = ({
minTime,
maxTime,
selectedTime,
selectedTags,
}: UseQueryServiceProps): UseQueryResult<PayloadProps, AxiosError> => {
const queryKey = [minTime, maxTime, selectedTime, selectedTags];
return useQuery<PayloadProps, AxiosError>(queryKey, () =>
getService({
end: maxTime,
start: minTime,
selectedTags,
}),
);
};
interface UseQueryServiceProps {
minTime: number;
maxTime: number;
selectedTime: Time;
selectedTags: Tags[];
}

View File

@ -1,116 +0,0 @@
import { Space } from 'antd';
import getLocalStorageKey from 'api/browser/localstorage/get';
import ReleaseNote from 'components/ReleaseNote';
import Spinner from 'components/Spinner';
import { SKIP_ONBOARDING } from 'constants/onboarding';
import MetricTable from 'container/MetricsTable';
import ResourceAttributesFilter from 'container/ResourceAttributesFilter';
import { useNotifications } from 'hooks/useNotifications';
import useResourceAttribute from 'hooks/useResourceAttribute';
import { convertRawQueriesToTraceSelectedTags } from 'hooks/useResourceAttribute/utils';
import { useEffect, useMemo } from 'react';
import { connect, useSelector } from 'react-redux';
import { useLocation } from 'react-router-dom';
import { bindActionCreators, Dispatch } from 'redux';
import { ThunkDispatch } from 'redux-thunk';
import { GetService, GetServiceProps } from 'store/actions/metrics';
import { AppState } from 'store/reducers';
import AppActions from 'types/actions';
import { GlobalReducer } from 'types/reducer/globalTime';
import MetricReducer from 'types/reducer/metrics';
import { Tags } from 'types/reducer/trace';
function Metrics({ getService }: MetricsProps): JSX.Element {
const { minTime, maxTime, loading, selectedTime } = useSelector<
AppState,
GlobalReducer
>((state) => state.globalTime);
const location = useLocation();
const { services, error, errorMessage } = useSelector<AppState, MetricReducer>(
(state) => state.metrics,
);
const { notifications } = useNotifications();
useEffect(() => {
if (error) {
notifications.error({
message: errorMessage,
});
}
}, [error, errorMessage, notifications]);
const { queries } = useResourceAttribute();
const selectedTags = useMemo(
() => (convertRawQueriesToTraceSelectedTags(queries, '') as Tags[]) || [],
[queries],
);
const isSkipped = getLocalStorageKey(SKIP_ONBOARDING) === 'true';
useEffect(() => {
if (loading === false) {
getService({
maxTime,
minTime,
selectedTags,
});
}
}, [getService, loading, maxTime, minTime, selectedTags]);
useEffect(() => {
let timeInterval: NodeJS.Timeout;
if (loading === false && !isSkipped && services.length === 0) {
timeInterval = setInterval(() => {
getService({
maxTime,
minTime,
selectedTags,
});
}, 50000);
}
return (): void => {
clearInterval(timeInterval);
};
}, [
getService,
isSkipped,
loading,
maxTime,
minTime,
services,
selectedTime,
selectedTags,
]);
if (loading) {
return <Spinner tip="Loading..." />;
}
return (
<Space direction="vertical" style={{ width: '100%' }}>
<ReleaseNote path={location.pathname} />
<ResourceAttributesFilter />
<MetricTable />
</Space>
);
}
interface DispatchProps {
getService: (
props: GetServiceProps,
) => (dispatch: Dispatch<AppActions>, getState: () => AppState) => void;
}
const mapDispatchToProps = (
dispatch: ThunkDispatch<unknown, unknown, AppActions>,
): DispatchProps => ({
getService: bindActionCreators(GetService, dispatch),
});
type MetricsProps = DispatchProps;
export default connect(null, mapDispatchToProps)(Metrics);

View File

@ -0,0 +1,70 @@
import { Space } from 'antd';
import localStorageGet from 'api/browser/localstorage/get';
import localStorageSet from 'api/browser/localstorage/set';
import ReleaseNote from 'components/ReleaseNote';
import { SKIP_ONBOARDING } from 'constants/onboarding';
import ResourceAttributesFilter from 'container/ResourceAttributesFilter';
import ServicesTable from 'container/ServiceTable';
import SkipOnBoardingModal from 'container/ServiceTable/SkipOnBoardModal';
import useErrorNotification from 'hooks/useErrorNotification';
import { useQueryService } from 'hooks/useQueryService';
import useResourceAttribute from 'hooks/useResourceAttribute';
import { convertRawQueriesToTraceSelectedTags } from 'hooks/useResourceAttribute/utils';
import { useMemo, useState } from 'react';
import { useSelector } from 'react-redux';
import { useLocation } from 'react-router-dom';
import { AppState } from 'store/reducers';
import { GlobalReducer } from 'types/reducer/globalTime';
import { Tags } from 'types/reducer/trace';
function Metrics(): JSX.Element {
const { minTime, maxTime, selectedTime } = useSelector<
AppState,
GlobalReducer
>((state) => state.globalTime);
const location = useLocation();
const { queries } = useResourceAttribute();
const [skipOnboarding, setSkipOnboarding] = useState(
localStorageGet(SKIP_ONBOARDING) === 'true',
);
const onContinueClick = (): void => {
localStorageSet(SKIP_ONBOARDING, 'true');
setSkipOnboarding(true);
};
const selectedTags = useMemo(
() => (convertRawQueriesToTraceSelectedTags(queries, '') as Tags[]) || [],
[queries],
);
const { data, error, isLoading, isError } = useQueryService({
minTime,
maxTime,
selectedTime,
selectedTags,
});
useErrorNotification(error);
if (
data?.length === 0 &&
isLoading === false &&
!skipOnboarding &&
isError === true
) {
return <SkipOnBoardingModal onContinueClick={onContinueClick} />;
}
return (
<Space direction="vertical" style={{ width: '100%' }}>
<ReleaseNote path={location.pathname} />
<ResourceAttributesFilter />
<ServicesTable services={data || []} isLoading={isLoading} />
</Space>
);
}
export default Metrics;

View File

@ -1,5 +1,6 @@
import getService from 'api/metrics/getService';
import { AxiosError } from 'axios';
import { SOMETHING_WENT_WRONG } from 'constants/api';
import GetMinMax from 'lib/getMinMax';
import { Dispatch } from 'redux';
import { AppState } from 'store/reducers';
@ -38,16 +39,16 @@ export const GetService = (
selectedTags: props.selectedTags,
});
if (response.statusCode === 200) {
if (response.length > 0) {
dispatch({
type: 'GET_SERVICE_LIST_SUCCESS',
payload: response.payload,
payload: response,
});
} else {
dispatch({
type: 'GET_SERVICE_LIST_ERROR',
payload: {
errorMessage: response.error || 'Something went wrong',
errorMessage: SOMETHING_WENT_WRONG,
},
});
}
@ -55,7 +56,7 @@ export const GetService = (
dispatch({
type: 'GET_SERVICE_LIST_ERROR',
payload: {
errorMessage: (error as AxiosError).toString() || 'Something went wrong',
errorMessage: (error as AxiosError).toString() || SOMETHING_WENT_WRONG,
},
});
}