mirror of
https://github.com/SigNoz/signoz.git
synced 2025-12-17 15:36:48 +00:00
feat: add support for entrypoint spans toggle in top operations table (#8175)
* feat: add support for entrypoint spans toggle in top operations table * fix: write tests for entry point toggle * chore: entry point -> entrypoint * fix: add info icon and tooltip for entrypoint spans toggle * fix: fix the copy and link for entrypoint toggle in top operations * chore: update the tooltip text * Update frontend/src/container/MetricsApplication/TopOperationsTable.tsx Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com> * chore: fix the failing build * chore: update the entry point spans docs link --------- Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
This commit is contained in:
parent
507dc86af2
commit
41661a5e28
@ -2,13 +2,20 @@ import axios from 'api';
|
||||
import { PayloadProps, Props } from 'types/api/metrics/getTopOperations';
|
||||
|
||||
const getTopOperations = async (props: Props): Promise<PayloadProps> => {
|
||||
const response = await axios.post(`/service/top_operations`, {
|
||||
const endpoint = props.isEntryPoint
|
||||
? '/service/entry_point_operations'
|
||||
: '/service/top_operations';
|
||||
|
||||
const response = await axios.post(endpoint, {
|
||||
start: `${props.start}`,
|
||||
end: `${props.end}`,
|
||||
service: props.service,
|
||||
tags: props.selectedTags,
|
||||
});
|
||||
|
||||
if (props.isEntryPoint) {
|
||||
return response.data.data;
|
||||
}
|
||||
return response.data;
|
||||
};
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@ import getTopOperations from 'api/metrics/getTopOperations';
|
||||
import TopOperationsTable from 'container/MetricsApplication/TopOperationsTable';
|
||||
import useResourceAttribute from 'hooks/useResourceAttribute';
|
||||
import { convertRawQueriesToTraceSelectedTags } from 'hooks/useResourceAttribute/utils';
|
||||
import { useMemo } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useParams } from 'react-router-dom';
|
||||
@ -20,6 +20,8 @@ function TopOperation(): JSX.Element {
|
||||
}>();
|
||||
const servicename = decodeURIComponent(encodedServiceName || '');
|
||||
|
||||
const [isEntryPoint, setIsEntryPoint] = useState<boolean>(false);
|
||||
|
||||
const { queries } = useResourceAttribute();
|
||||
const selectedTags = useMemo(
|
||||
() => (convertRawQueriesToTraceSelectedTags(queries) as Tags[]) || [],
|
||||
@ -27,19 +29,27 @@ function TopOperation(): JSX.Element {
|
||||
);
|
||||
|
||||
const { data, isLoading } = useQuery<PayloadProps>({
|
||||
queryKey: [minTime, maxTime, servicename, selectedTags],
|
||||
queryKey: [minTime, maxTime, servicename, selectedTags, isEntryPoint],
|
||||
queryFn: (): Promise<PayloadProps> =>
|
||||
getTopOperations({
|
||||
service: servicename || '',
|
||||
start: minTime,
|
||||
end: maxTime,
|
||||
selectedTags,
|
||||
isEntryPoint,
|
||||
}),
|
||||
});
|
||||
|
||||
const topOperationData = data || [];
|
||||
|
||||
return <TopOperationsTable data={topOperationData} isLoading={isLoading} />;
|
||||
return (
|
||||
<TopOperationsTable
|
||||
data={topOperationData}
|
||||
isLoading={isLoading}
|
||||
isEntryPoint={isEntryPoint}
|
||||
onEntryPointToggle={setIsEntryPoint}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default TopOperation;
|
||||
|
||||
@ -1,9 +1,24 @@
|
||||
.top-operation {
|
||||
position: relative;
|
||||
.top-operation--download {
|
||||
|
||||
&__controls {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
position: absolute;
|
||||
top: 15px;
|
||||
right: 0px;
|
||||
z-index: 1;
|
||||
gap: 8px;
|
||||
}
|
||||
&__entry-point {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
&__download {
|
||||
.ant-btn-icon {
|
||||
margin: 0 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
import './TopOperationsTable.styles.scss';
|
||||
|
||||
import { SearchOutlined } from '@ant-design/icons';
|
||||
import { InputRef, Tooltip, Typography } from 'antd';
|
||||
import { InputRef, Switch, Tooltip, Typography } from 'antd';
|
||||
import { ColumnsType, ColumnType } from 'antd/lib/table';
|
||||
import { ResizeTable } from 'components/ResizeTable';
|
||||
import TextToolTip from 'components/TextToolTip';
|
||||
import Download from 'container/Download/Download';
|
||||
import { filterDropdown } from 'container/ServiceApplication/Filter/FilterDropdown';
|
||||
import useResourceAttribute from 'hooks/useResourceAttribute';
|
||||
@ -29,6 +30,8 @@ import {
|
||||
function TopOperationsTable({
|
||||
data,
|
||||
isLoading,
|
||||
isEntryPoint,
|
||||
onEntryPointToggle,
|
||||
}: TopOperationsTableProps): JSX.Element {
|
||||
const searchInput = useRef<InputRef>(null);
|
||||
const { servicename: encodedServiceName } = useParams<IServiceName>();
|
||||
@ -174,20 +177,45 @@ function TopOperationsTable({
|
||||
hideOnSinglePage: true,
|
||||
};
|
||||
|
||||
const entryPointSpanInfo = {
|
||||
text: 'Shows the spans where requests enter new services for the first time',
|
||||
url:
|
||||
'https://signoz.io/docs/traces-management/guides/entry-point-spans-service-overview/',
|
||||
urlText: 'Learn more about Entrypoint Spans.',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="top-operation">
|
||||
<div className="top-operation--download">
|
||||
<Download
|
||||
data={downloadableData}
|
||||
isLoading={isLoading}
|
||||
fileName={`top-operations-${servicename}`}
|
||||
/>
|
||||
<div className="top-operation__controls">
|
||||
<div className="top-operation__download">
|
||||
<Download
|
||||
data={downloadableData}
|
||||
isLoading={isLoading}
|
||||
fileName={`top-operations-${servicename}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="top-operation__entry-point">
|
||||
<Switch
|
||||
checked={isEntryPoint}
|
||||
onChange={onEntryPointToggle}
|
||||
size="small"
|
||||
/>
|
||||
<span className="top-operation__entry-point-label">Entrypoint Spans</span>
|
||||
<TextToolTip
|
||||
text={entryPointSpanInfo.text}
|
||||
url={entryPointSpanInfo.url}
|
||||
useFilledIcon={false}
|
||||
urlText={entryPointSpanInfo.urlText}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ResizeTable
|
||||
columns={columns}
|
||||
loading={isLoading}
|
||||
showHeader
|
||||
title={(): string => 'Key Operations'}
|
||||
title={(): string =>
|
||||
isEntryPoint ? 'Key Entrypoint Operations' : 'Key Operations'
|
||||
}
|
||||
tableLayout="fixed"
|
||||
dataSource={data}
|
||||
rowKey="name"
|
||||
@ -209,6 +237,8 @@ export interface TopOperationList {
|
||||
interface TopOperationsTableProps {
|
||||
data: TopOperationList[];
|
||||
isLoading: boolean;
|
||||
isEntryPoint: boolean;
|
||||
onEntryPointToggle: (checked: boolean) => void;
|
||||
}
|
||||
|
||||
export default TopOperationsTable;
|
||||
|
||||
@ -1,3 +1,6 @@
|
||||
import { QueryClient } from 'react-query';
|
||||
import configureStore from 'redux-mock-store';
|
||||
|
||||
import { TopOperationList } from '../TopOperationsTable';
|
||||
|
||||
interface TopOperation {
|
||||
@ -17,3 +20,59 @@ export const getTopOperationList = ({
|
||||
p95: 0,
|
||||
p99: 0,
|
||||
} as TopOperationList);
|
||||
|
||||
export const defaultApiCallExpectation = {
|
||||
service: 'test-service',
|
||||
start: 1640995200000,
|
||||
end: 1641081600000,
|
||||
selectedTags: [],
|
||||
isEntryPoint: false,
|
||||
};
|
||||
|
||||
export const mockStore = configureStore([]);
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const mockTopOperationsData: TopOperationList[] = [
|
||||
{
|
||||
name: 'GET /api/users',
|
||||
p50: 1000000,
|
||||
p95: 2000000,
|
||||
p99: 3000000,
|
||||
numCalls: 100,
|
||||
errorCount: 5,
|
||||
},
|
||||
{
|
||||
name: 'POST /api/orders',
|
||||
p50: 1500000,
|
||||
p95: 2500000,
|
||||
p99: 3500000,
|
||||
numCalls: 80,
|
||||
errorCount: 2,
|
||||
},
|
||||
];
|
||||
|
||||
export const mockEntryPointData: TopOperationList[] = [
|
||||
{
|
||||
name: 'GET /api/health',
|
||||
p50: 500000,
|
||||
p95: 1000000,
|
||||
p99: 1500000,
|
||||
numCalls: 200,
|
||||
errorCount: 0,
|
||||
},
|
||||
];
|
||||
|
||||
export const createMockStore = (): any =>
|
||||
mockStore({
|
||||
globalTime: {
|
||||
minTime: 1640995200000,
|
||||
maxTime: 1641081600000,
|
||||
},
|
||||
});
|
||||
|
||||
@ -0,0 +1,300 @@
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from '@testing-library/react';
|
||||
import useResourceAttribute from 'hooks/useResourceAttribute';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { QueryClientProvider } from 'react-query';
|
||||
import { Provider } from 'react-redux';
|
||||
|
||||
import {
|
||||
createMockStore,
|
||||
defaultApiCallExpectation,
|
||||
mockEntryPointData,
|
||||
mockTopOperationsData,
|
||||
queryClient,
|
||||
} from '../__mocks__/getTopOperation';
|
||||
import TopOperation from '../Tabs/Overview/TopOperation';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('hooks/useResourceAttribute');
|
||||
jest.mock('hooks/useResourceAttribute/utils', () => ({
|
||||
convertRawQueriesToTraceSelectedTags: (): any[] => [],
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useSafeNavigate', () => ({
|
||||
useSafeNavigate: (): { safeNavigate: jest.Mock } => ({
|
||||
safeNavigate: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useParams: (): { servicename: string } => ({
|
||||
servicename: encodeURIComponent('test-service'),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock the util functions that TopOperationsTable uses
|
||||
jest.mock('../Tabs/util', () => ({
|
||||
useGetAPMToTracesQueries: (): any => ({
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock the resourceAttributesToTracesFilterItems function
|
||||
jest.mock('container/TraceDetail/utils', () => ({
|
||||
resourceAttributesToTracesFilterItems: (): any[] => [],
|
||||
}));
|
||||
|
||||
const mockedUseResourceAttribute = useResourceAttribute as jest.MockedFunction<
|
||||
typeof useResourceAttribute
|
||||
>;
|
||||
|
||||
// Constants
|
||||
const KEY_OPERATIONS_TEXT = 'Key Operations';
|
||||
const KEY_ENTRY_POINT_OPERATIONS_TEXT = 'Key Entrypoint Operations';
|
||||
const ENTRY_POINT_SPANS_TEXT = 'Entrypoint Spans';
|
||||
const TOP_OPERATIONS_ENDPOINT = 'top_operations';
|
||||
const ENTRY_POINT_OPERATIONS_ENDPOINT = 'entry_point_operations';
|
||||
|
||||
const renderComponent = (store = createMockStore()): any =>
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TopOperation />
|
||||
</QueryClientProvider>
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
// Helper function to wait for initial render and verify basic functionality
|
||||
const waitForInitialRender = async (): Promise<void> => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(KEY_OPERATIONS_TEXT)).toBeInTheDocument();
|
||||
});
|
||||
};
|
||||
|
||||
// Helper function to click toggle and wait for data to load
|
||||
const clickToggleAndWaitForDataLoad = async (): Promise<HTMLElement> => {
|
||||
const toggleSwitch = screen.getByRole('switch');
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(toggleSwitch);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(KEY_ENTRY_POINT_OPERATIONS_TEXT)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
return toggleSwitch;
|
||||
};
|
||||
|
||||
describe('TopOperation API Integration', () => {
|
||||
let apiCalls: { endpoint: string; body: any }[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
queryClient.clear();
|
||||
apiCalls = [];
|
||||
|
||||
mockedUseResourceAttribute.mockReturnValue({
|
||||
queries: [],
|
||||
} as any);
|
||||
|
||||
server.use(
|
||||
rest.post(
|
||||
'http://localhost/api/v1/service/top_operations',
|
||||
async (req, res, ctx) => {
|
||||
const body = await req.json();
|
||||
apiCalls.push({ endpoint: TOP_OPERATIONS_ENDPOINT, body });
|
||||
return res(ctx.status(200), ctx.json(mockTopOperationsData));
|
||||
},
|
||||
),
|
||||
rest.post(
|
||||
'http://localhost/api/v1/service/entry_point_operations',
|
||||
async (req, res, ctx) => {
|
||||
const body = await req.json();
|
||||
apiCalls.push({ endpoint: ENTRY_POINT_OPERATIONS_ENDPOINT, body });
|
||||
return res(ctx.status(200), ctx.json({ data: mockEntryPointData }));
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('renders with default key operations on initial load', async () => {
|
||||
renderComponent();
|
||||
|
||||
await waitForInitialRender();
|
||||
|
||||
// Verify the toggle is present and unchecked
|
||||
const toggleSwitch = screen.getByRole('switch');
|
||||
expect(toggleSwitch).not.toBeChecked();
|
||||
expect(screen.getByText(ENTRY_POINT_SPANS_TEXT)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls top_operations API on initial render', async () => {
|
||||
renderComponent();
|
||||
|
||||
await waitForInitialRender();
|
||||
|
||||
// Wait a bit more for API calls to be captured
|
||||
await waitFor(() => {
|
||||
expect(apiCalls.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// Verify that only the top_operations endpoint was called
|
||||
expect(apiCalls).toHaveLength(1);
|
||||
expect(apiCalls[0].endpoint).toBe(TOP_OPERATIONS_ENDPOINT);
|
||||
expect(apiCalls[0].body).toEqual({
|
||||
start: `${defaultApiCallExpectation.start}`,
|
||||
end: `${defaultApiCallExpectation.end}`,
|
||||
service: defaultApiCallExpectation.service,
|
||||
tags: defaultApiCallExpectation.selectedTags,
|
||||
});
|
||||
});
|
||||
|
||||
it('calls entry_point_operations API when toggle is switched to entry point', async () => {
|
||||
renderComponent();
|
||||
|
||||
// Wait for initial render
|
||||
await waitForInitialRender();
|
||||
|
||||
// Wait for initial API call
|
||||
await waitFor(() => {
|
||||
expect(apiCalls.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// Clear previous API calls
|
||||
apiCalls = [];
|
||||
|
||||
// Toggle to entry point
|
||||
await clickToggleAndWaitForDataLoad();
|
||||
|
||||
// Wait for the API call to be captured
|
||||
await waitFor(() => {
|
||||
expect(apiCalls.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// Verify that the entry_point_operations endpoint was called
|
||||
expect(apiCalls).toHaveLength(1);
|
||||
expect(apiCalls[0].endpoint).toBe(ENTRY_POINT_OPERATIONS_ENDPOINT);
|
||||
expect(apiCalls[0].body).toEqual({
|
||||
start: `${defaultApiCallExpectation.start}`,
|
||||
end: `${defaultApiCallExpectation.end}`,
|
||||
service: defaultApiCallExpectation.service,
|
||||
tags: defaultApiCallExpectation.selectedTags,
|
||||
});
|
||||
});
|
||||
|
||||
it('switches to entry point operations when toggle is clicked', async () => {
|
||||
renderComponent();
|
||||
|
||||
// Wait for initial render
|
||||
await waitForInitialRender();
|
||||
|
||||
// Find and click the toggle switch
|
||||
const toggleSwitch = screen.getByRole('switch');
|
||||
expect(toggleSwitch).not.toBeChecked();
|
||||
|
||||
await clickToggleAndWaitForDataLoad();
|
||||
|
||||
// Check that the switch is now checked and title updates
|
||||
expect(toggleSwitch).toBeChecked();
|
||||
expect(screen.getByText(KEY_ENTRY_POINT_OPERATIONS_TEXT)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls correct APIs when toggling multiple times', async () => {
|
||||
renderComponent();
|
||||
|
||||
// Wait for initial render
|
||||
await waitForInitialRender();
|
||||
|
||||
// Wait for initial API call
|
||||
await waitFor(() => {
|
||||
expect(apiCalls.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// Should have called top_operations initially
|
||||
expect(apiCalls).toHaveLength(1);
|
||||
expect(apiCalls[0].endpoint).toBe(TOP_OPERATIONS_ENDPOINT);
|
||||
|
||||
// Toggle to entry point
|
||||
await clickToggleAndWaitForDataLoad();
|
||||
|
||||
// Wait for the second API call
|
||||
await waitFor(() => {
|
||||
expect(apiCalls.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
// Should now have called entry_point_operations
|
||||
expect(apiCalls).toHaveLength(2);
|
||||
expect(apiCalls[1].endpoint).toBe(ENTRY_POINT_OPERATIONS_ENDPOINT);
|
||||
|
||||
// Toggle back to regular operations
|
||||
const toggleSwitch = screen.getByRole('switch');
|
||||
act(() => {
|
||||
fireEvent.click(toggleSwitch);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(KEY_OPERATIONS_TEXT)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Wait for the third API call
|
||||
await waitFor(() => {
|
||||
expect(apiCalls.length).toBeGreaterThan(2);
|
||||
});
|
||||
|
||||
// Should have called top_operations again
|
||||
expect(apiCalls).toHaveLength(3);
|
||||
expect(apiCalls[2].endpoint).toBe(TOP_OPERATIONS_ENDPOINT);
|
||||
|
||||
expect(toggleSwitch).not.toBeChecked();
|
||||
});
|
||||
|
||||
it('displays entry point toggle with correct label', async () => {
|
||||
renderComponent();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(ENTRY_POINT_SPANS_TEXT)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const toggleSwitch = screen.getByRole('switch');
|
||||
expect(toggleSwitch).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('switches back to key operations when toggle is clicked twice', async () => {
|
||||
renderComponent();
|
||||
|
||||
// Wait for initial render
|
||||
await waitForInitialRender();
|
||||
|
||||
// Toggle on (to entry point)
|
||||
await clickToggleAndWaitForDataLoad();
|
||||
expect(screen.getByText(KEY_ENTRY_POINT_OPERATIONS_TEXT)).toBeInTheDocument();
|
||||
|
||||
// Toggle off (back to key operations)
|
||||
const toggleSwitch = screen.getByRole('switch');
|
||||
act(() => {
|
||||
fireEvent.click(toggleSwitch);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(KEY_OPERATIONS_TEXT)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(toggleSwitch).not.toBeChecked();
|
||||
});
|
||||
});
|
||||
@ -6,6 +6,7 @@ export interface Props {
|
||||
start: number;
|
||||
end: number;
|
||||
selectedTags: Tags[];
|
||||
isEntryPoint?: boolean;
|
||||
}
|
||||
|
||||
export type PayloadProps = TopOperationList[];
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user