mirror of
https://github.com/SigNoz/signoz.git
synced 2026-11-05 07:50:54 +00:00
Compare commits
4 Commits
fix/query-
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13440740c4 | ||
|
|
711f0b6beb | ||
|
|
98f9850c53 | ||
|
|
07fd9ac5b8 |
4
.github/CODEOWNERS
vendored
4
.github/CODEOWNERS
vendored
@@ -24,7 +24,7 @@ go.mod @therealpandey
|
||||
/pkg/config/ @therealpandey
|
||||
/pkg/errors/ @therealpandey
|
||||
/pkg/factory/ @therealpandey
|
||||
/pkg/types/ @therealpandey
|
||||
/pkg/types/ @therealpandey @srikanthccv
|
||||
/pkg/valuer/ @therealpandey
|
||||
/cmd/ @therealpandey
|
||||
.golangci.yml @therealpandey
|
||||
@@ -136,7 +136,7 @@ go.mod @therealpandey
|
||||
|
||||
# Integration tests
|
||||
|
||||
/tests/integration/ @therealpandey
|
||||
/tests/integration/ @therealpandey @srikanthccv
|
||||
|
||||
# e2e tests
|
||||
/tests/e2e/ @AshwinBhatkal
|
||||
|
||||
@@ -46,5 +46,4 @@ export enum LOCALSTORAGE {
|
||||
DASHBOARDS_LIST_VIEWS = 'DASHBOARDS_LIST_VIEWS',
|
||||
DASHBOARD_V2_PANEL_COLUMN_WIDTHS = 'DASHBOARD_V2_PANEL_COLUMN_WIDTHS',
|
||||
LLM_ATTRIBUTE_MAPPING_TEST_SPAN = 'LLM_ATTRIBUTE_MAPPING_TEST_SPAN',
|
||||
TRACES_SPAN_DRAWER_ENABLED = 'TRACES_SPAN_DRAWER_ENABLED',
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Tooltip } from 'antd';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { ArrowDownLeft, ArrowUpRight, Calendar } from '@signozhq/icons';
|
||||
import { useAlertHistoryQueryParams } from 'pages/AlertDetails/hooks';
|
||||
import { AlertRuleStats } from 'types/api/alerts/def';
|
||||
import { calculateChange } from 'utils/calculateChange';
|
||||
|
||||
@@ -86,7 +87,7 @@ function StatsCard({
|
||||
}: StatsCardProps): JSX.Element {
|
||||
const urlQuery = useUrlQuery();
|
||||
|
||||
const relativeTime = urlQuery.get('relativeTime');
|
||||
const { relativeTime } = useAlertHistoryQueryParams();
|
||||
|
||||
const { changePercentage, changeDirection } = calculateChange(
|
||||
totalCurrentCount,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useMemo } from 'react';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { useGetAlertRuleDetailsTimelineGraphData } from 'pages/AlertDetails/hooks';
|
||||
import {
|
||||
useAlertHistoryQueryParams,
|
||||
useGetAlertRuleDetailsTimelineGraphData,
|
||||
} from 'pages/AlertDetails/hooks';
|
||||
import DataStateRenderer from 'periscope/components/DataStateRenderer/DataStateRenderer';
|
||||
import { AlertRuleTimelineGraphResponse } from 'types/api/alerts/def';
|
||||
|
||||
@@ -13,9 +15,7 @@ function GraphWrapper({
|
||||
}: {
|
||||
totalCurrentTriggers: number;
|
||||
}): JSX.Element {
|
||||
const urlQuery = useUrlQuery();
|
||||
|
||||
const relativeTime = urlQuery.get('relativeTime');
|
||||
const { relativeTime } = useAlertHistoryQueryParams();
|
||||
|
||||
const { isLoading, isRefetching, isError, data, isValidRuleId, ruleId } =
|
||||
useGetAlertRuleDetailsTimelineGraphData();
|
||||
|
||||
@@ -2,81 +2,48 @@ import { memo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Settings } from '@signozhq/icons';
|
||||
import FieldsSelector from 'components/FieldsSelector';
|
||||
import Controls, { ControlsProps } from 'container/Controls';
|
||||
import { OptionsMenuConfig } from 'container/OptionsMenu/types';
|
||||
import useQueryPagination from 'hooks/queryPagination/useQueryPagination';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import styles from './Controls.module.scss';
|
||||
|
||||
function TraceExplorerControls({
|
||||
isLoading,
|
||||
totalCount,
|
||||
perPageOptions,
|
||||
config,
|
||||
showSizeChanger = true,
|
||||
}: TraceExplorerControlsProps): JSX.Element | null {
|
||||
const { t } = useTranslation(['trace']);
|
||||
const [isFieldsSelectorOpen, setIsFieldsSelectorOpen] = useState(false);
|
||||
|
||||
const {
|
||||
pagination,
|
||||
handleCountItemsPerPageChange,
|
||||
handleNavigateNext,
|
||||
handleNavigatePrevious,
|
||||
} = useQueryPagination(totalCount, perPageOptions);
|
||||
if (!config?.fieldsSelector) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{config?.fieldsSelector && (
|
||||
<>
|
||||
<div
|
||||
className={styles.optionsTrigger}
|
||||
onClick={(): void => setIsFieldsSelectorOpen(true)}
|
||||
>
|
||||
{t('options_menu.options')}
|
||||
<Settings size="md" />
|
||||
</div>
|
||||
<FieldsSelector
|
||||
isOpen={isFieldsSelectorOpen}
|
||||
title="Edit columns"
|
||||
fields={config.fieldsSelector.value}
|
||||
onFieldsChange={config.fieldsSelector.onFieldsChange}
|
||||
onClose={(): void => setIsFieldsSelectorOpen(false)}
|
||||
signal={DataSource.TRACES}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Controls
|
||||
isLoading={isLoading}
|
||||
totalCount={totalCount}
|
||||
offset={pagination.offset}
|
||||
countPerPage={pagination.limit}
|
||||
perPageOptions={perPageOptions}
|
||||
handleCountItemsPerPageChange={handleCountItemsPerPageChange}
|
||||
handleNavigateNext={handleNavigateNext}
|
||||
handleNavigatePrevious={handleNavigatePrevious}
|
||||
showSizeChanger={showSizeChanger}
|
||||
<div
|
||||
className={styles.optionsTrigger}
|
||||
onClick={(): void => setIsFieldsSelectorOpen(true)}
|
||||
>
|
||||
{t('options_menu.options')}
|
||||
<Settings size="md" />
|
||||
</div>
|
||||
<FieldsSelector
|
||||
isOpen={isFieldsSelectorOpen}
|
||||
title="Edit columns"
|
||||
fields={config.fieldsSelector.value}
|
||||
onFieldsChange={config.fieldsSelector.onFieldsChange}
|
||||
onClose={(): void => setIsFieldsSelectorOpen(false)}
|
||||
signal={DataSource.TRACES}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type TraceExplorerControlsProps = {
|
||||
config?: OptionsMenuConfig | null;
|
||||
};
|
||||
|
||||
TraceExplorerControls.defaultProps = {
|
||||
config: null,
|
||||
};
|
||||
|
||||
type TraceExplorerControlsProps = Pick<
|
||||
ControlsProps,
|
||||
'isLoading' | 'totalCount' | 'perPageOptions'
|
||||
> & {
|
||||
config?: OptionsMenuConfig | null;
|
||||
showSizeChanger?: boolean;
|
||||
};
|
||||
|
||||
TraceExplorerControls.defaultProps = {
|
||||
showSizeChanger: true,
|
||||
};
|
||||
|
||||
export default memo(TraceExplorerControls);
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { screen } from 'tests/test-utils';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { QueryRangeRequestV5 } from 'types/api/v5/queryRange';
|
||||
|
||||
import {
|
||||
listResponse,
|
||||
listRows,
|
||||
QUERY_RANGE_URL,
|
||||
renderListView,
|
||||
} from './testUtils';
|
||||
|
||||
// globalTime starts with loading:true, which gates the list query. Force just that
|
||||
// slice's loading to false so the query fires; every other selector is untouched.
|
||||
jest.mock('react-redux', () => {
|
||||
const actual = jest.requireActual('react-redux');
|
||||
return {
|
||||
...actual,
|
||||
useSelector: (selector: (state: unknown) => unknown): unknown => {
|
||||
const result = actual.useSelector(selector);
|
||||
if (result && typeof result === 'object' && 'loading' in result) {
|
||||
return { ...result, loading: false };
|
||||
}
|
||||
return result;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// List columns come from the options menu (server-synced preferences). Pin them
|
||||
// so the expected columns render, independent of that API.
|
||||
jest.mock('container/OptionsMenu/useOptionsMenu', () => ({
|
||||
__esModule: true,
|
||||
default: (): unknown => ({
|
||||
options: {
|
||||
selectColumns: [
|
||||
{ name: 'service.name', fieldContext: 'resource' },
|
||||
{ name: 'name', fieldContext: 'span' },
|
||||
{ name: 'duration_nano', fieldContext: 'span' },
|
||||
{ name: 'http_method', fieldContext: 'span' },
|
||||
{ name: 'response_status_code', fieldContext: 'span' },
|
||||
{ name: 'http.route', fieldContext: 'attribute' },
|
||||
{ name: 'cache.hit', fieldContext: 'attribute' },
|
||||
],
|
||||
},
|
||||
config: { addColumn: { onRemove: jest.fn() } },
|
||||
}),
|
||||
}));
|
||||
|
||||
type ListRequest = {
|
||||
offset: number;
|
||||
limit: number;
|
||||
start: number;
|
||||
end: number;
|
||||
};
|
||||
|
||||
// Answers each offset with `rowsPerOffset[offset]` rows and records every request.
|
||||
const mockListPages = (
|
||||
rowsPerOffset: Record<number, number>,
|
||||
firstPageDelayMs = 0,
|
||||
): ListRequest[] => {
|
||||
const requests: ListRequest[] = [];
|
||||
server.use(
|
||||
rest.post(QUERY_RANGE_URL, async (req, res, ctx) => {
|
||||
const body = await req.json<QueryRangeRequestV5>();
|
||||
const spec = body.compositeQuery.queries[0].spec as {
|
||||
offset?: number;
|
||||
limit: number;
|
||||
};
|
||||
const offset = spec.offset ?? 0;
|
||||
requests.push({
|
||||
offset,
|
||||
limit: spec.limit,
|
||||
start: body.start,
|
||||
end: body.end,
|
||||
});
|
||||
const length = Math.min(rowsPerOffset[offset] ?? 0, spec.limit);
|
||||
const rows = Array.from({ length }, (_, i) => ({
|
||||
...listRows[0],
|
||||
data: {
|
||||
...listRows[0].data,
|
||||
name: `span-${offset + i}`,
|
||||
span_id: `span-${offset + i}`,
|
||||
},
|
||||
}));
|
||||
return res(
|
||||
ctx.delay(offset === 0 ? firstPageDelayMs : 0),
|
||||
ctx.status(200),
|
||||
ctx.json(listResponse(rows)),
|
||||
);
|
||||
}),
|
||||
);
|
||||
return requests;
|
||||
};
|
||||
|
||||
// Tall enough that the mocked virtuoso renders every row and reaches the end.
|
||||
const FULL_HEIGHT = 200 * 54;
|
||||
|
||||
describe('Traces ListView - infinite scroll', () => {
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('loads the next page at the end of the list over the same window', async () => {
|
||||
// Past the one-second resolution of the window, so a recomputed "now" would show.
|
||||
const requests = mockListPages({ 0: 100, 100: 5 }, 1100);
|
||||
renderListView(initialQueriesMap.traces, FULL_HEIGHT);
|
||||
|
||||
await expect(
|
||||
screen.findByText('span-104', undefined, { timeout: 3000 }),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(screen.getByText('span-0')).toBeInTheDocument();
|
||||
expect(requests.map(({ offset }) => offset)).toStrictEqual([0, 100]);
|
||||
expect(requests[1].start).toBe(requests[0].start);
|
||||
expect(requests[1].end).toBe(requests[0].end);
|
||||
});
|
||||
|
||||
it('stops after a short page', async () => {
|
||||
const requests = mockListPages({ 0: 5 });
|
||||
renderListView(initialQueriesMap.traces, FULL_HEIGHT);
|
||||
|
||||
await expect(screen.findByText('span-4')).resolves.toBeInTheDocument();
|
||||
expect(requests.map(({ offset }) => offset)).toStrictEqual([0]);
|
||||
});
|
||||
|
||||
it('stops at the query limit', async () => {
|
||||
const requests = mockListPages({ 0: 100, 100: 100 });
|
||||
const { builder } = initialQueriesMap.traces;
|
||||
renderListView(
|
||||
{
|
||||
...initialQueriesMap.traces,
|
||||
builder: {
|
||||
...builder,
|
||||
queryData: [{ ...builder.queryData[0], limit: 150 }],
|
||||
},
|
||||
} as Query,
|
||||
FULL_HEIGHT,
|
||||
);
|
||||
|
||||
await expect(screen.findByText('span-149')).resolves.toBeInTheDocument();
|
||||
expect(
|
||||
requests.map(({ offset, limit }) => ({ offset, limit })),
|
||||
).toStrictEqual([
|
||||
{ offset: 0, limit: 100 },
|
||||
{ offset: 100, limit: 50 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,12 @@
|
||||
import remove from 'api/browser/localstorage/remove';
|
||||
import set from 'api/browser/localstorage/set';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { getCurrentLocation } from 'lib/router/navigation';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { screen, userEvent, waitFor, within } from 'tests/test-utils';
|
||||
import { mockSuccess, mockTraceSummary, renderListView } from './testUtils';
|
||||
import {
|
||||
listRows,
|
||||
mockSuccess,
|
||||
mockTraceSummary,
|
||||
renderListView,
|
||||
} from './testUtils';
|
||||
|
||||
// globalTime starts with loading:true, which gates the list query. Force just that
|
||||
// slice's loading to false so the query fires; every other selector is untouched.
|
||||
@@ -43,14 +46,13 @@ jest.mock('container/OptionsMenu/useOptionsMenu', () => ({
|
||||
|
||||
describe('Traces ListView - span drawer navigation', () => {
|
||||
beforeEach(() => {
|
||||
set(LOCALSTORAGE.TRACES_SPAN_DRAWER_ENABLED, 'true');
|
||||
mockSuccess();
|
||||
mockTraceSummary({});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
remove(LOCALSTORAGE.TRACES_SPAN_DRAWER_ENABLED);
|
||||
server.resetHandlers();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('opens the clicked span and steps through rows with the arrow keys', async () => {
|
||||
@@ -110,4 +112,41 @@ describe('Traces ListView - span drawer navigation', () => {
|
||||
expect(screen.queryByTestId('span-details-drawer')).not.toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it('opens the trace page in a new tab from the row icon without opening the drawer', async () => {
|
||||
const open = jest.spyOn(window, 'open').mockImplementation(() => null);
|
||||
// user-event leaves the row on its way to the button, which clears the hover.
|
||||
const user = userEvent.setup({ skipHover: true });
|
||||
renderListView();
|
||||
|
||||
const row = (await screen.findByText('HTTP GET')).closest(
|
||||
'tr',
|
||||
) as HTMLElement;
|
||||
expect(screen.queryByTestId('span-row-open-trace')).not.toBeInTheDocument();
|
||||
await user.hover(row);
|
||||
await user.click(await screen.findByTestId('span-row-open-trace'));
|
||||
|
||||
expect(open).toHaveBeenCalledWith(
|
||||
expect.stringMatching(
|
||||
/\/trace\/0000000000000000344ded1387b08a7e\?.*772c4d29dd9076ac/,
|
||||
),
|
||||
'_blank',
|
||||
);
|
||||
expect(getCurrentLocation().pathname).toBe('/traces-explorer');
|
||||
expect(screen.queryByTestId('span-details-drawer')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the row icon only on the hovered copy of a duplicate span', async () => {
|
||||
mockSuccess([listRows[0], listRows[0]]);
|
||||
// user-event leaves the row on its way to the button, which clears the hover.
|
||||
const user = userEvent.setup({ skipHover: true });
|
||||
renderListView();
|
||||
|
||||
const [first] = await screen.findAllByText('HTTP GET');
|
||||
await user.hover(first.closest('tr') as HTMLElement);
|
||||
|
||||
await expect(
|
||||
screen.findAllByTestId('span-row-open-trace'),
|
||||
).resolves.toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import remove from 'api/browser/localstorage/remove';
|
||||
import set from 'api/browser/localstorage/set';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { screen, userEvent, waitFor, within } from 'tests/test-utils';
|
||||
@@ -54,13 +51,11 @@ jest.mock('container/OptionsMenu/useOptionsMenu', () => ({
|
||||
|
||||
describe('Traces ListView - span drawer trace bounds', () => {
|
||||
beforeEach(() => {
|
||||
set(LOCALSTORAGE.TRACES_SPAN_DRAWER_ENABLED, 'true');
|
||||
mockSuccess();
|
||||
mockTraceSummary({});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
remove(LOCALSTORAGE.TRACES_SPAN_DRAWER_ENABLED);
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
|
||||
@@ -88,9 +88,10 @@ export const mockSuccess = (rows: unknown[] = listRows): void => {
|
||||
|
||||
export const renderListView = (
|
||||
stagedQuery: Query = initialQueriesMap.traces,
|
||||
viewportHeight = 500,
|
||||
): ReturnType<typeof render> =>
|
||||
render(
|
||||
<VirtuosoMockContext.Provider value={{ viewportHeight: 500, itemHeight: 54 }}>
|
||||
<VirtuosoMockContext.Provider value={{ viewportHeight, itemHeight: 54 }}>
|
||||
<ListView
|
||||
isFilterApplied={false}
|
||||
setWarning={jest.fn()}
|
||||
|
||||
@@ -78,8 +78,8 @@ describe('Traces ListView - Data Loaded', () => {
|
||||
'200',
|
||||
);
|
||||
|
||||
// empty status fields render "-"
|
||||
expect(screen.getAllByText('-').length).toBeGreaterThanOrEqual(1);
|
||||
// empty status fields render the dash icon
|
||||
expect(screen.getAllByTestId('empty-value').length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('requests every span field instead of the selected columns', async () => {
|
||||
|
||||
@@ -59,14 +59,6 @@ const verifyControlsVisibility = (): void => {
|
||||
// Order by controls
|
||||
expect(screen.getByText(/Order by/i)).toBeInTheDocument();
|
||||
|
||||
// Pagination controls
|
||||
expect(screen.getByRole('button', { name: /previous/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /next/i })).toBeInTheDocument();
|
||||
|
||||
// Items per page selector (there are multiple comboboxes, so we check for at least 2)
|
||||
const comboboxes = screen.getAllByRole('combobox');
|
||||
expect(comboboxes.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// Options menu (settings button) - check for translation key or actual text
|
||||
expect(screen.getByText(/options_menu.options|options/i)).toBeInTheDocument();
|
||||
};
|
||||
@@ -153,14 +145,7 @@ describe('Traces ListView - Error and Empty States', () => {
|
||||
});
|
||||
|
||||
// Order by controls should be interactive
|
||||
const comboboxes = screen.getAllByRole('combobox');
|
||||
expect(comboboxes.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// Pagination controls should be present
|
||||
const previousButton = screen.getByRole('button', { name: /previous/i });
|
||||
const nextButton = screen.getByRole('button', { name: /next/i });
|
||||
expect(previousButton).toBeInTheDocument();
|
||||
expect(nextButton).toBeInTheDocument();
|
||||
expect(screen.getAllByRole('combobox').length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Options menu should be clickable
|
||||
const optionsButton = screen.getByText(/options_menu.options|options/i);
|
||||
@@ -176,8 +161,7 @@ describe('Traces ListView - Error and Empty States', () => {
|
||||
});
|
||||
|
||||
// All controls should be interactive
|
||||
const comboboxes = screen.getAllByRole('combobox');
|
||||
expect(comboboxes.length).toBeGreaterThanOrEqual(2);
|
||||
expect(screen.getAllByRole('combobox').length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Options menu should be clickable
|
||||
const optionsButton = screen.getByText(/options_menu.options|options/i);
|
||||
|
||||
@@ -20,22 +20,15 @@ import {
|
||||
getFieldColumn,
|
||||
TracesTableRow,
|
||||
} from 'container/TracesExplorer/TracesTable/getFieldColumn';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { useOptionsMenu } from 'container/OptionsMenu';
|
||||
import { CustomTimeType } from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import TraceExplorerControls from 'container/TracesExplorer/Controls';
|
||||
import { getListViewQuery } from 'container/TracesExplorer/explorerUtils';
|
||||
import SpanDrawer from 'container/TracesExplorer/SpanDrawer/SpanDrawer';
|
||||
import { useSpanDrawer } from 'container/TracesExplorer/SpanDrawer/useSpanDrawer';
|
||||
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { Pagination } from 'hooks/queryPagination';
|
||||
import { getDefaultPaginationConfig } from 'hooks/queryPagination/utils';
|
||||
import useUrlQueryData from 'hooks/useUrlQueryData';
|
||||
import { getListOrderBy, setListOrderBy } from 'utils/explorerUtils';
|
||||
import { ArrowUp10, Minus } from '@signozhq/icons';
|
||||
import { AppState } from 'store/reducers';
|
||||
@@ -43,12 +36,9 @@ import { Warning } from 'types/api';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import {
|
||||
defaultSelectedColumns,
|
||||
PER_PAGE_OPTIONS,
|
||||
TIMESTAMP_FIELD,
|
||||
} from './configs';
|
||||
import { getTraceLink, transformSpanRows } from './utils';
|
||||
import { defaultSelectedColumns, TIMESTAMP_FIELD } from './configs';
|
||||
import { useListViewInfiniteQuery } from './useListViewInfiniteQuery';
|
||||
import { getTraceLink } from './utils';
|
||||
|
||||
import './ListView.styles.scss';
|
||||
|
||||
@@ -96,12 +86,6 @@ function ListView({
|
||||
},
|
||||
});
|
||||
|
||||
const { queryData: paginationQueryData } = useUrlQueryData<Pagination>(
|
||||
QueryParams.pagination,
|
||||
);
|
||||
const paginationConfig =
|
||||
paginationQueryData ?? getDefaultPaginationConfig(PER_PAGE_OPTIONS);
|
||||
|
||||
// Empty selectFields makes the API return every span field.
|
||||
const requestQuery = useMemo(() => {
|
||||
const query = getListViewQuery(
|
||||
@@ -125,51 +109,26 @@ function ListView({
|
||||
minTime,
|
||||
stagedQuery,
|
||||
panelType,
|
||||
paginationConfig,
|
||||
],
|
||||
[
|
||||
stagedQuery,
|
||||
panelType,
|
||||
globalSelectedTime,
|
||||
paginationConfig,
|
||||
maxTime,
|
||||
minTime,
|
||||
],
|
||||
[stagedQuery, panelType, globalSelectedTime, maxTime, minTime],
|
||||
);
|
||||
|
||||
if (queryKeyRef) {
|
||||
queryKeyRef.current = queryKey;
|
||||
}
|
||||
|
||||
const { data, isFetching, isLoading, isError, error } = useGetQueryRange(
|
||||
{
|
||||
const { rows, warning, isFetching, isLoading, isError, error, loadMore } =
|
||||
useListViewInfiniteQuery({
|
||||
query: requestQuery,
|
||||
graphType: panelType,
|
||||
selectedTime: 'GLOBAL_TIME' as const,
|
||||
globalSelectedInterval: globalSelectedTime as CustomTimeType,
|
||||
params: {
|
||||
dataSource: 'traces',
|
||||
},
|
||||
tableParams: {
|
||||
pagination: paginationConfig,
|
||||
},
|
||||
},
|
||||
// ENTITY_VERSION_V4,
|
||||
ENTITY_VERSION_V5,
|
||||
{
|
||||
queryKey,
|
||||
enabled:
|
||||
// don't make api call while the time range state in redux is loading
|
||||
!timeRangeUpdateLoading && !!stagedQuery && panelType === PANEL_TYPES.LIST,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.payload) {
|
||||
setWarning(data?.warning);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data?.payload, data?.warning]);
|
||||
setWarning(warning);
|
||||
}, [warning, setWarning]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || isFetching) {
|
||||
@@ -179,15 +138,8 @@ function ListView({
|
||||
}
|
||||
}, [isLoading, isFetching, setIsLoadingQueries]);
|
||||
|
||||
const dataLength =
|
||||
data?.payload?.data?.newResult?.data?.result[0]?.list?.length;
|
||||
const totalCount = useMemo(() => dataLength || 0, [dataLength]);
|
||||
|
||||
const queryTableDataResult = data?.payload?.data?.newResult?.data?.result;
|
||||
const queryTableData = useMemo(
|
||||
() => queryTableDataResult || [],
|
||||
[queryTableDataResult],
|
||||
);
|
||||
const { tableProps, openTraceColumn, drawerProps, onPressInside } =
|
||||
useSpanDrawer(rows);
|
||||
|
||||
const columns = useMemo<TableColumnDef<TracesTableRow>[]>(() => {
|
||||
const fields = [
|
||||
@@ -196,15 +148,9 @@ function ListView({
|
||||
(field) => field.name !== TIMESTAMP_FIELD.name,
|
||||
),
|
||||
];
|
||||
return fields.map((field) => getFieldColumn(field));
|
||||
}, [options?.selectColumns]);
|
||||
|
||||
const rows = useMemo(
|
||||
() => transformSpanRows(queryTableData),
|
||||
[queryTableData],
|
||||
);
|
||||
|
||||
const { tableProps, drawerProps, onPressInside } = useSpanDrawer(rows);
|
||||
const fieldColumns = fields.map((field) => getFieldColumn(field));
|
||||
return [openTraceColumn, ...fieldColumns];
|
||||
}, [options?.selectColumns, openTraceColumn]);
|
||||
|
||||
const handleColumnOrderChange = useCallback(
|
||||
(cols: TableColumnDef<TracesTableRow>[]): void => {
|
||||
@@ -249,12 +195,7 @@ function ListView({
|
||||
selectedColumns={options?.selectColumns}
|
||||
/>
|
||||
|
||||
<TraceExplorerControls
|
||||
isLoading={isFetching}
|
||||
totalCount={totalCount}
|
||||
config={config}
|
||||
perPageOptions={PER_PAGE_OPTIONS}
|
||||
/>
|
||||
<TraceExplorerControls config={config} />
|
||||
</div>
|
||||
|
||||
{/* A press in the table or the drawer (a portal) keeps the drawer open. */}
|
||||
@@ -273,9 +214,10 @@ function ListView({
|
||||
isFilterApplied={isFilterApplied}
|
||||
onColumnOrderChange={handleColumnOrderChange}
|
||||
onColumnRemove={config?.addColumn?.onRemove}
|
||||
onEndReached={loadMore}
|
||||
/>
|
||||
|
||||
{drawerProps && <SpanDrawer {...drawerProps} />}
|
||||
<SpanDrawer {...drawerProps} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { QueryKey, useInfiniteQuery } from 'react-query';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { DEFAULT_PER_PAGE_VALUE } from 'container/Controls/config';
|
||||
import { TracesTableRow } from 'container/TracesExplorer/TracesTable/getFieldColumn';
|
||||
import { useKeepRowsForSameQuery } from 'container/TracesExplorer/TracesTable/useKeepRowsForSameQuery';
|
||||
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
|
||||
import { Warning } from 'types/api';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { getListLimit, getNextPageOffset, transformSpanRows } from './utils';
|
||||
|
||||
type UseListViewInfiniteQueryParams = {
|
||||
query: Query;
|
||||
queryKey: QueryKey;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
type UseListViewInfiniteQueryResult = {
|
||||
rows: TracesTableRow[];
|
||||
warning: Warning | undefined;
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
isError: boolean;
|
||||
error: Error | null;
|
||||
loadMore: () => void;
|
||||
};
|
||||
|
||||
export function useListViewInfiniteQuery({
|
||||
query,
|
||||
queryKey,
|
||||
enabled,
|
||||
}: UseListViewInfiniteQueryParams): UseListViewInfiniteQueryResult {
|
||||
const limit = getListLimit(query);
|
||||
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
isFetching,
|
||||
isFetchingNextPage,
|
||||
isError,
|
||||
error,
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
isPreviousData,
|
||||
} = useInfiniteQuery({
|
||||
queryKey,
|
||||
queryFn: ({ pageParam = 0, signal }) =>
|
||||
GetMetricQueryRange(
|
||||
{
|
||||
query,
|
||||
graphType: PANEL_TYPES.LIST,
|
||||
selectedTime: 'GLOBAL_TIME',
|
||||
// Every page reads the window in redux, so later pages cannot drift.
|
||||
globalSelectedInterval: 'custom',
|
||||
params: { dataSource: 'traces' },
|
||||
tableParams: {
|
||||
pagination: {
|
||||
offset: pageParam as number,
|
||||
limit: limit
|
||||
? Math.min(DEFAULT_PER_PAGE_VALUE, limit - (pageParam as number))
|
||||
: DEFAULT_PER_PAGE_VALUE,
|
||||
},
|
||||
},
|
||||
},
|
||||
ENTITY_VERSION_V5,
|
||||
undefined,
|
||||
signal,
|
||||
),
|
||||
getNextPageParam: (_lastPage, allPages) =>
|
||||
getNextPageOffset(
|
||||
allPages.map(
|
||||
(page) => page.payload.data.newResult.data.result?.[0]?.list?.length ?? 0,
|
||||
),
|
||||
DEFAULT_PER_PAGE_VALUE,
|
||||
limit,
|
||||
),
|
||||
keepPreviousData: true,
|
||||
enabled,
|
||||
});
|
||||
|
||||
const keepRows = useKeepRowsForSameQuery(isPreviousData);
|
||||
|
||||
const rows = useMemo(
|
||||
() =>
|
||||
keepRows
|
||||
? (data?.pages ?? []).flatMap((page) =>
|
||||
transformSpanRows(page.payload.data.newResult.data.result ?? []),
|
||||
)
|
||||
: [],
|
||||
[data?.pages, keepRows],
|
||||
);
|
||||
|
||||
const loadMore = useCallback((): void => {
|
||||
if (hasNextPage && !isFetchingNextPage) {
|
||||
void fetchNextPage();
|
||||
}
|
||||
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
|
||||
return {
|
||||
rows,
|
||||
warning: data?.pages[data.pages.length - 1]?.warning,
|
||||
isLoading,
|
||||
isFetching,
|
||||
isError,
|
||||
error: error as Error | null,
|
||||
loadMore,
|
||||
};
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { TimestampInput } from 'hooks/useTimezoneFormatter/useTimezoneFormatter'
|
||||
import { RowData } from 'lib/query/createTableColumnsFromQuery';
|
||||
import LineClampedText from 'periscope/components/LineClampedText/LineClampedText';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { QueryDataV3 } from 'types/api/widgets/getQuery';
|
||||
|
||||
export function BlockLink({
|
||||
@@ -178,3 +179,23 @@ export const transformSpanRows = (data: QueryDataV3[]): TracesTableRow[] => {
|
||||
};
|
||||
}) as TracesTableRow[];
|
||||
};
|
||||
|
||||
export const getListLimit = (query: Query): number | null =>
|
||||
query.builder.queryTraceOperator?.[0]?.limit ??
|
||||
query.builder.queryData[0]?.limit ??
|
||||
null;
|
||||
|
||||
/** Offset of the next page, or undefined once a short page or the limit ends the list. */
|
||||
export const getNextPageOffset = (
|
||||
pageLengths: number[],
|
||||
pageSize: number,
|
||||
limit: number | null,
|
||||
): number | undefined => {
|
||||
const loaded = pageLengths.reduce((sum, length) => sum + length, 0);
|
||||
const lastLength = pageLengths[pageLengths.length - 1] ?? 0;
|
||||
|
||||
if (lastLength < pageSize || (limit && loaded >= limit)) {
|
||||
return undefined;
|
||||
}
|
||||
return loaded;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
// One text line tall, so the taller button overflows into the cell padding
|
||||
// instead of growing the row on hover.
|
||||
.slot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: var(--tanstack-plain-cell-line-height, 20px);
|
||||
overflow: visible;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { MouseEvent } from 'react';
|
||||
import { CornerUpRight } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Tooltip } from '@signozhq/ui/tooltip';
|
||||
import { useIsRowHovered } from 'components/TanStackTableView/TanStackTableStateContext';
|
||||
import { getTraceLink } from 'container/TracesExplorer/ListView/utils';
|
||||
import { TracesTableRow } from 'container/TracesExplorer/TracesTable/getFieldColumn';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
|
||||
import styles from './OpenTraceButton.module.scss';
|
||||
|
||||
type OpenTraceButtonProps = {
|
||||
row: TracesTableRow;
|
||||
rowId: string;
|
||||
};
|
||||
|
||||
function OpenTraceButton({
|
||||
row,
|
||||
rowId,
|
||||
}: OpenTraceButtonProps): JSX.Element | null {
|
||||
const isHovered = useIsRowHovered(rowId);
|
||||
|
||||
const handleClick = (event: MouseEvent): void => {
|
||||
// The row click would open the drawer instead.
|
||||
event.stopPropagation();
|
||||
openInNewTab(getTraceLink(row));
|
||||
};
|
||||
|
||||
if (!isHovered) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={styles.slot}>
|
||||
<Tooltip title="Open trace details">
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
icon
|
||||
onClick={handleClick}
|
||||
aria-label="Open trace details"
|
||||
testId="span-row-open-trace"
|
||||
>
|
||||
<CornerUpRight size={14} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default OpenTraceButton;
|
||||
@@ -1,20 +1,18 @@
|
||||
import { act, renderHook } from 'tests/test-utils';
|
||||
import remove from 'api/browser/localstorage/remove';
|
||||
import set from 'api/browser/localstorage/set';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { TracesTableRow } from 'container/TracesExplorer/TracesTable/getFieldColumn';
|
||||
|
||||
import { useSpanDrawer } from '../useSpanDrawer';
|
||||
|
||||
let mockQueryId = 'query-1';
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: (): unknown => ({ stagedQuery: { id: mockQueryId } }),
|
||||
}));
|
||||
|
||||
const rows: TracesTableRow[] = [{ id: 'span-a' }, { id: 'span-b' }];
|
||||
|
||||
describe('useSpanDrawer', () => {
|
||||
beforeEach(() => {
|
||||
set(LOCALSTORAGE.TRACES_SPAN_DRAWER_ENABLED, 'true');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
remove(LOCALSTORAGE.TRACES_SPAN_DRAWER_ENABLED);
|
||||
mockQueryId = 'query-1';
|
||||
});
|
||||
|
||||
it('does not reopen a span that a refetch dropped and a later one brings back', () => {
|
||||
@@ -32,4 +30,15 @@ describe('useSpanDrawer', () => {
|
||||
rerender({ current: rows });
|
||||
expect(result.current.drawerProps?.row).toBeNull();
|
||||
});
|
||||
|
||||
it('closes on a new query while the old rows are still shown', () => {
|
||||
const { result, rerender } = renderHook(() => useSpanDrawer(rows));
|
||||
|
||||
act(() => result.current.tableProps.onRowClick?.(rows[1]));
|
||||
expect(result.current.drawerProps?.row).toBe(rows[1]);
|
||||
|
||||
mockQueryId = 'query-2';
|
||||
rerender();
|
||||
expect(result.current.drawerProps?.row).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { TableColumnDef } from 'components/TanStackTableView/types';
|
||||
import { TracesTableRow } from 'container/TracesExplorer/TracesTable/getFieldColumn';
|
||||
|
||||
import OpenTraceButton from './OpenTraceButton';
|
||||
|
||||
export const OPEN_TRACE_COLUMN: TableColumnDef<TracesTableRow> = {
|
||||
id: 'open-trace',
|
||||
header: '',
|
||||
pin: 'left',
|
||||
enableMove: false,
|
||||
enableResize: false,
|
||||
enableRemove: false,
|
||||
canBeHidden: false,
|
||||
width: { fixed: 32 },
|
||||
cell: ({ row, rowId }): JSX.Element => (
|
||||
<OpenTraceButton row={row} rowId={rowId} />
|
||||
),
|
||||
};
|
||||
@@ -1,11 +1,20 @@
|
||||
import { PointerEvent, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
PointerEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import type { TableColumnDef } from 'components/TanStackTableView/types';
|
||||
import { useRowNavigation } from 'components/TanStackTableView/useRowNavigation';
|
||||
import { TracesTableRow } from 'container/TracesExplorer/TracesTable/getFieldColumn';
|
||||
import type { TracesTableProps } from 'container/TracesExplorer/TracesTable/TracesTable';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useCloseOnOutsidePress } from 'hooks/useCloseOnOutsidePress';
|
||||
|
||||
import { OPEN_TRACE_COLUMN } from './openTraceColumn';
|
||||
import type { SpanDrawerProps } from './SpanDrawer';
|
||||
import { isSpanDrawerEnabled } from './utils';
|
||||
|
||||
type ActiveSpan = { id: string; index: number };
|
||||
|
||||
@@ -14,14 +23,14 @@ type UseSpanDrawerResult = {
|
||||
TracesTableProps,
|
||||
'onRowClick' | 'isRowActive' | 'activeRowIndex'
|
||||
>;
|
||||
/** Null while the drawer flag is off. */
|
||||
drawerProps: SpanDrawerProps | null;
|
||||
/** Leading column that opens the trace page. */
|
||||
openTraceColumn: TableColumnDef<TracesTableRow>;
|
||||
drawerProps: SpanDrawerProps;
|
||||
/** `onPointerDownCapture` for the area a press keeps the drawer open in. */
|
||||
onPressInside: (event: PointerEvent) => void;
|
||||
};
|
||||
|
||||
export function useSpanDrawer(rows: TracesTableRow[]): UseSpanDrawerResult {
|
||||
const [isEnabled] = useState(isSpanDrawerEnabled);
|
||||
// The index lets navigation step past a duplicate span; the id survives a refetch.
|
||||
const [activeSpan, setActiveSpan] = useState<ActiveSpan | null>(null);
|
||||
|
||||
@@ -60,8 +69,19 @@ export function useSpanDrawer(rows: TracesTableRow[]): UseSpanDrawerResult {
|
||||
}
|
||||
}, [activeSpan, activeRowIndex]);
|
||||
|
||||
// A new query closes the drawer at once instead of waiting for its rows.
|
||||
const { stagedQuery } = useQueryBuilder();
|
||||
const queryId = stagedQuery?.id;
|
||||
const lastQueryIdRef = useRef(queryId);
|
||||
useEffect(() => {
|
||||
if (lastQueryIdRef.current && lastQueryIdRef.current !== queryId) {
|
||||
setActiveSpan(null);
|
||||
}
|
||||
lastQueryIdRef.current = queryId;
|
||||
}, [queryId]);
|
||||
|
||||
const onPressInside = useCloseOnOutsidePress(
|
||||
isEnabled && activeRowIndex !== -1,
|
||||
activeRowIndex !== -1,
|
||||
clearActiveSpan,
|
||||
);
|
||||
|
||||
@@ -71,12 +91,13 @@ export function useSpanDrawer(rows: TracesTableRow[]): UseSpanDrawerResult {
|
||||
onNavigate: handleNavigate,
|
||||
});
|
||||
|
||||
if (!isEnabled) {
|
||||
return { tableProps: {}, drawerProps: null, onPressInside };
|
||||
}
|
||||
|
||||
return {
|
||||
tableProps: { onRowClick: selectRow, isRowActive, activeRowIndex },
|
||||
tableProps: {
|
||||
onRowClick: selectRow,
|
||||
isRowActive,
|
||||
activeRowIndex,
|
||||
},
|
||||
openTraceColumn: OPEN_TRACE_COLUMN,
|
||||
drawerProps: {
|
||||
row: rows[activeRowIndex] ?? null,
|
||||
onClose: clearActiveSpan,
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import get from 'api/browser/localstorage/get';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { TracesTableRow } from 'container/TracesExplorer/TracesTable/getFieldColumn';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
/** Off by default; set `TRACES_SPAN_DRAWER_ENABLED` to `true` in localStorage to try it. */
|
||||
export const isSpanDrawerEnabled = (): boolean =>
|
||||
get(LOCALSTORAGE.TRACES_SPAN_DRAWER_ENABLED) === 'true';
|
||||
|
||||
type ListSpanEvent = {
|
||||
name: string;
|
||||
timeUnixNano: number;
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
.emptyValue {
|
||||
display: block;
|
||||
color: var(--l2-border);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { AppLink } from 'lib/router/AppLink';
|
||||
import { buildRoutePath } from 'lib/router/buildRoutePath';
|
||||
import { Minus } from '@signozhq/icons';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
@@ -15,6 +16,8 @@ import {
|
||||
} from './constants';
|
||||
import { stringifyCellValue } from './utils';
|
||||
|
||||
import styles from './FieldCell.module.scss';
|
||||
|
||||
type FieldCellProps = {
|
||||
name: string;
|
||||
value: unknown;
|
||||
@@ -37,7 +40,11 @@ function FieldCell({ name, value }: FieldCellProps): JSX.Element {
|
||||
}
|
||||
|
||||
if (value === '' || value == null) {
|
||||
return <TanStackTable.Text data-testid={name}>-</TanStackTable.Text>;
|
||||
return (
|
||||
<TanStackTable.Text data-testid={name}>
|
||||
<Minus size={14} className={styles.emptyValue} data-testid="empty-value" />
|
||||
</TanStackTable.Text>
|
||||
);
|
||||
}
|
||||
|
||||
const text = stringifyCellValue(value);
|
||||
|
||||
@@ -6,21 +6,6 @@
|
||||
}
|
||||
|
||||
.tracesTable {
|
||||
--tanstack-table-row-height: 54px;
|
||||
--tanstack-table-header-height: 54px;
|
||||
|
||||
--tanstack-cell-padding-top-override: 5px;
|
||||
--tanstack-cell-padding-bottom-override: 5px;
|
||||
--tanstack-cell-padding-right-override: 15px;
|
||||
|
||||
--tanstack-cell-padding-left-override: 15px;
|
||||
--tanstack-cell-header-padding-left-override: 5px;
|
||||
|
||||
--tanstack-cell-header-padding-left-first-column: 15px;
|
||||
|
||||
--tanstack-plain-body-line-clamp: 1;
|
||||
|
||||
--tanstack-table-cell-bg: var(--l2-background);
|
||||
--tanstack-table-header-cell-bg: var(--l1-background-hover);
|
||||
--tanstack-table-row-hover-bg: var(--l1-background-hover);
|
||||
--tanstack-table-row-hover-bg: var(--l2-background-hover);
|
||||
--tanstack-table-row-active-bg: var(--l3-background-60);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { useAppNavigate } from 'lib/router/useAppNavigate';
|
||||
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
@@ -6,14 +6,20 @@ import type {
|
||||
CellTypographySize,
|
||||
TableColumnDef,
|
||||
} from 'components/TanStackTableView/types';
|
||||
import { useRecoverFromEmptyPage } from 'components/TanStackTableView/useRecoverFromEmptyPage';
|
||||
import EmptyLogsSearch from 'container/EmptyLogsSearch/EmptyLogsSearch';
|
||||
import NoLogs from 'container/NoLogs/NoLogs';
|
||||
import { TracesLoading } from 'container/TracesExplorer/TraceLoading/TraceLoading';
|
||||
import APIError from 'types/api/error';
|
||||
import { DataSource, PanelTypeKeys } from 'types/common/queryBuilder';
|
||||
import { noop } from 'lodash-es';
|
||||
import { getAbsoluteUrl } from 'utils/basePath';
|
||||
|
||||
import { TRACES_TABLE_QUERY_PARAMS } from './constants';
|
||||
import type { TracesTableRow } from './getFieldColumn';
|
||||
import type { TracesTablePagination } from './useTracesTablePagination';
|
||||
import { getRollingTotal, getRowKey } from './utils';
|
||||
|
||||
import styles from './TracesTable.module.scss';
|
||||
|
||||
export type TracesTableProps = {
|
||||
@@ -36,6 +42,9 @@ export type TracesTableProps = {
|
||||
onColumnOrderChange?: (cols: TableColumnDef<TracesTableRow>[]) => void;
|
||||
onColumnRemove?: (columnId: string) => void;
|
||||
cellTypographySize?: CellTypographySize;
|
||||
pagination?: TracesTablePagination;
|
||||
/** Loads the next page when the scroll reaches the last row; hides the pager. */
|
||||
onEndReached?: () => void;
|
||||
};
|
||||
|
||||
function TracesTable({
|
||||
@@ -56,9 +65,33 @@ function TracesTable({
|
||||
onColumnOrderChange,
|
||||
onColumnRemove,
|
||||
cellTypographySize = 'medium',
|
||||
pagination,
|
||||
onEndReached,
|
||||
}: TracesTableProps): JSX.Element {
|
||||
const navigate = useAppNavigate();
|
||||
|
||||
// While the next page loads, `data` is still the previous page, so its rows
|
||||
// cannot size the total for the new offset.
|
||||
const settledTotalRef = useRef(0);
|
||||
if (pagination && !isFetching) {
|
||||
settledTotalRef.current = getRollingTotal(
|
||||
pagination.request.offset,
|
||||
data.length,
|
||||
pagination.limit,
|
||||
);
|
||||
}
|
||||
const total = settledTotalRef.current;
|
||||
|
||||
useRecoverFromEmptyPage({
|
||||
page: pagination?.page ?? 1,
|
||||
pageSize: pagination?.limit ?? 0,
|
||||
rowCount: data.length,
|
||||
total,
|
||||
isFetching: isLoading || isFetching,
|
||||
isDisabled: !pagination || isError,
|
||||
setPage: pagination?.setPage ?? noop,
|
||||
});
|
||||
|
||||
const isDataAbsent =
|
||||
!isLoading && !isFetching && !isError && data.length === 0;
|
||||
|
||||
@@ -110,8 +143,19 @@ function TracesTable({
|
||||
onRowClickNewTab={handleRowClickNewTab}
|
||||
isRowActive={isRowActive}
|
||||
activeRowIndex={activeRowIndex}
|
||||
getRowKey={getRowKey}
|
||||
getRowTestId={(row): string => `traces-table-row-${row.id}`}
|
||||
testId={`traces-table-${panelType.toLowerCase()}`}
|
||||
onEndReached={onEndReached}
|
||||
enableQueryParams={pagination ? TRACES_TABLE_QUERY_PARAMS : undefined}
|
||||
pagination={
|
||||
pagination && {
|
||||
total,
|
||||
defaultLimit: pagination.limit,
|
||||
showTotalCount: false,
|
||||
onLimitChange: pagination.setLimit,
|
||||
}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -128,6 +172,8 @@ TracesTable.defaultProps = {
|
||||
onColumnOrderChange: undefined,
|
||||
onColumnRemove: undefined,
|
||||
cellTypographySize: 'medium',
|
||||
pagination: undefined,
|
||||
onEndReached: undefined,
|
||||
};
|
||||
|
||||
export default TracesTable;
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { renderHook } from 'tests/test-utils';
|
||||
|
||||
import { useKeepRowsForSameQuery } from '../useKeepRowsForSameQuery';
|
||||
|
||||
let mockQueryId = 'query-1';
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: (): unknown => ({ stagedQuery: { id: mockQueryId } }),
|
||||
}));
|
||||
|
||||
describe('useKeepRowsForSameQuery', () => {
|
||||
beforeEach(() => {
|
||||
mockQueryId = 'query-1';
|
||||
});
|
||||
|
||||
it('keeps the old rows while the same query refetches', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ isPreviousData }: { isPreviousData: boolean }) =>
|
||||
useKeepRowsForSameQuery(isPreviousData),
|
||||
{ initialProps: { isPreviousData: false } },
|
||||
);
|
||||
|
||||
rerender({ isPreviousData: true });
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
|
||||
it('drops the old rows once a new query runs, until its rows land', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ isPreviousData }: { isPreviousData: boolean }) =>
|
||||
useKeepRowsForSameQuery(isPreviousData),
|
||||
{ initialProps: { isPreviousData: false } },
|
||||
);
|
||||
|
||||
mockQueryId = 'query-2';
|
||||
rerender({ isPreviousData: true });
|
||||
expect(result.current).toBe(false);
|
||||
|
||||
rerender({ isPreviousData: false });
|
||||
expect(result.current).toBe(true);
|
||||
|
||||
rerender({ isPreviousData: true });
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { NuqsTestingAdapter, UrlUpdateEvent } from 'nuqs/adapters/testing';
|
||||
import { renderHook, waitFor } from 'tests/test-utils';
|
||||
|
||||
import { useTracesTablePagination } from '../useTracesTablePagination';
|
||||
|
||||
let mockQueryId = 'query-1';
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: (): unknown => ({ stagedQuery: { id: mockQueryId } }),
|
||||
}));
|
||||
|
||||
const renderPagination = (
|
||||
searchParams: Record<string, string>,
|
||||
): {
|
||||
offsets: number[];
|
||||
urlUpdates: UrlUpdateEvent[];
|
||||
rerender: () => void;
|
||||
} => {
|
||||
const offsets: number[] = [];
|
||||
const urlUpdates: UrlUpdateEvent[] = [];
|
||||
const wrapper = ({ children }: { children: ReactNode }): JSX.Element => (
|
||||
<NuqsTestingAdapter
|
||||
searchParams={searchParams}
|
||||
onUrlUpdate={(event): void => {
|
||||
urlUpdates.push(event);
|
||||
}}
|
||||
hasMemory
|
||||
>
|
||||
{children}
|
||||
</NuqsTestingAdapter>
|
||||
);
|
||||
const { rerender } = renderHook(
|
||||
() => {
|
||||
const pagination = useTracesTablePagination();
|
||||
offsets.push(pagination.request.offset);
|
||||
return pagination;
|
||||
},
|
||||
{ wrapper },
|
||||
);
|
||||
return { offsets, urlUpdates, rerender };
|
||||
};
|
||||
|
||||
describe('useTracesTablePagination', () => {
|
||||
beforeEach(() => {
|
||||
mockQueryId = 'query-1';
|
||||
});
|
||||
|
||||
it('requests the page in the URL for the query it was picked on', () => {
|
||||
const { offsets, rerender } = renderPagination({ page: '3', limit: '10' });
|
||||
|
||||
rerender();
|
||||
|
||||
expect(new Set(offsets)).toStrictEqual(new Set([20]));
|
||||
});
|
||||
|
||||
it('requests the first page as soon as a new query runs, then resets the URL', async () => {
|
||||
const { offsets, urlUpdates, rerender } = renderPagination({
|
||||
page: '3',
|
||||
limit: '10',
|
||||
});
|
||||
const before = offsets.length;
|
||||
|
||||
mockQueryId = 'query-2';
|
||||
rerender();
|
||||
|
||||
expect(offsets.slice(before)).not.toContain(20);
|
||||
// Page 1 is the default, so the URL drops the param.
|
||||
await waitFor(() =>
|
||||
expect(urlUpdates.at(-1)?.searchParams.has('page')).toBe(false),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -16,3 +16,12 @@ export const STATUS_FIELD_NAMES = new Set([
|
||||
export const DURATION_FIELD_NAMES = new Set(['durationNano', 'duration_nano']);
|
||||
|
||||
export const TRACE_ID_FIELD_NAMES = new Set(['traceID', 'trace_id']);
|
||||
|
||||
export const TRACES_TABLE_QUERY_PARAMS = {
|
||||
page: 'page',
|
||||
limit: 'limit',
|
||||
} as const;
|
||||
|
||||
export const TRACES_TABLE_DEFAULT_LIMIT = 20;
|
||||
|
||||
export const TRACES_TABLE_PAGE_SIZE_KEY = 'traces-explorer';
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
|
||||
/**
|
||||
* Whether rows kept from the previous request may stay on screen: yes while the
|
||||
* same query refetches (refresh, next page), no once a new query runs, so the
|
||||
* table shows its loader instead of the old query's rows.
|
||||
*/
|
||||
export function useKeepRowsForSameQuery(isPreviousData: boolean): boolean {
|
||||
const { stagedQuery } = useQueryBuilder();
|
||||
const queryId = stagedQuery?.id;
|
||||
const [loadedQueryId, setLoadedQueryId] = useState(queryId);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPreviousData) {
|
||||
setLoadedQueryId(queryId);
|
||||
}
|
||||
}, [isPreviousData, queryId]);
|
||||
|
||||
return !isPreviousData || loadedQueryId === queryId;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
SetPageOptions,
|
||||
useTableParams,
|
||||
} from 'components/TanStackTableView/useTableParams';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { Pagination } from 'hooks/queryPagination';
|
||||
|
||||
import {
|
||||
TRACES_TABLE_DEFAULT_LIMIT,
|
||||
TRACES_TABLE_PAGE_SIZE_KEY,
|
||||
TRACES_TABLE_QUERY_PARAMS,
|
||||
} from './constants';
|
||||
|
||||
export type TracesTablePagination = {
|
||||
page: number;
|
||||
limit: number;
|
||||
request: Pagination;
|
||||
setPage: (page: number, options?: SetPageOptions) => void;
|
||||
setLimit: (limit: number) => void;
|
||||
};
|
||||
|
||||
export function useTracesTablePagination(): TracesTablePagination {
|
||||
const { page, limit, setPage, setLimit } = useTableParams(
|
||||
TRACES_TABLE_QUERY_PARAMS,
|
||||
{
|
||||
page: 1,
|
||||
limit: TRACES_TABLE_DEFAULT_LIMIT,
|
||||
storageKey: TRACES_TABLE_PAGE_SIZE_KEY,
|
||||
},
|
||||
);
|
||||
|
||||
const { stagedQuery } = useQueryBuilder();
|
||||
const queryId = stagedQuery?.id;
|
||||
const [pageQueryId, setPageQueryId] = useState(queryId);
|
||||
// A page number only means something for the query it was picked on, so a
|
||||
// new query reads page 1 before the URL catches up.
|
||||
const isNewQuery = !!pageQueryId && pageQueryId !== queryId;
|
||||
const currentPage = isNewQuery ? 1 : page;
|
||||
|
||||
useEffect(() => {
|
||||
if (isNewQuery) {
|
||||
setPage(1, { history: 'replace' });
|
||||
}
|
||||
setPageQueryId(queryId);
|
||||
}, [isNewQuery, queryId, setPage]);
|
||||
|
||||
const request = useMemo(
|
||||
() => ({ offset: (currentPage - 1) * limit, limit }),
|
||||
[currentPage, limit],
|
||||
);
|
||||
|
||||
return { page: currentPage, limit, request, setPage, setLimit };
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { TracesTableRow } from './getFieldColumn';
|
||||
|
||||
export function stringifyCellValue(value: unknown): string {
|
||||
if (value == null) {
|
||||
return '';
|
||||
@@ -10,3 +12,20 @@ export function stringifyCellValue(value: unknown): string {
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* The list API returns no total, so a full page counts one row past itself:
|
||||
* the pager offers the next page only while the last one came back full.
|
||||
*/
|
||||
export function getRollingTotal(
|
||||
offset: number,
|
||||
rowCount: number,
|
||||
limit: number,
|
||||
): number {
|
||||
return offset + rowCount + (rowCount >= limit ? 1 : 0);
|
||||
}
|
||||
|
||||
/** The API can return the same span twice; the table suffixes repeated keys so each row hovers on its own. */
|
||||
export function getRowKey(row: TracesTableRow): string {
|
||||
return row.id;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { screen, userEvent, waitFor, within } from 'tests/test-utils';
|
||||
import { QueryRangeRequestV5 } from 'types/api/v5/queryRange';
|
||||
|
||||
import {
|
||||
groupedResponse,
|
||||
groupedRows,
|
||||
QUERY_RANGE_URL,
|
||||
renderTracesView,
|
||||
} from './testUtils';
|
||||
|
||||
// Answers each offset with `rowsPerOffset[offset]` rows and records the offsets asked for.
|
||||
// An offset in `held` answers once its promise resolves.
|
||||
const mockPages = (
|
||||
rowsPerOffset: Record<number, number>,
|
||||
held?: Record<number, Promise<void>>,
|
||||
): number[] => {
|
||||
const offsets: number[] = [];
|
||||
server.use(
|
||||
rest.post(QUERY_RANGE_URL, async (req, res, ctx) => {
|
||||
const body = (await req.json()) as QueryRangeRequestV5;
|
||||
const spec = body.compositeQuery.queries[0].spec as { offset?: number };
|
||||
const offset = spec.offset ?? 0;
|
||||
offsets.push(offset);
|
||||
await held?.[offset];
|
||||
const rows = Array.from({ length: rowsPerOffset[offset] ?? 0 }, (_, i) => ({
|
||||
...groupedRows[0],
|
||||
data: { ...groupedRows[0].data, trace_id: `trace-${offset + i}` },
|
||||
}));
|
||||
return res(ctx.status(200), ctx.json(groupedResponse(rows)));
|
||||
}),
|
||||
);
|
||||
return offsets;
|
||||
};
|
||||
|
||||
describe('TracesView pagination', () => {
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('offers the next page after a full page and requests its offset', async () => {
|
||||
const offsets = mockPages({ 0: 20, 20: 3 });
|
||||
const user = userEvent.setup();
|
||||
renderTracesView();
|
||||
|
||||
const next = await screen.findByRole('button', { name: 'Go to next page' });
|
||||
await waitFor(() => expect(next).toBeEnabled());
|
||||
await user.click(next);
|
||||
|
||||
await expect(screen.findByText('trace-22')).resolves.toBeInTheDocument();
|
||||
expect(offsets).toStrictEqual([0, 20]);
|
||||
});
|
||||
|
||||
it('offers no page past the next one until that page arrives', async () => {
|
||||
let release = (): void => {};
|
||||
const pageTwo = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
mockPages({ 0: 20, 20: 20 }, { 20: pageTwo });
|
||||
const user = userEvent.setup();
|
||||
renderTracesView();
|
||||
|
||||
const next = await screen.findByRole('button', { name: 'Go to next page' });
|
||||
await waitFor(() => expect(next).toBeEnabled());
|
||||
await user.click(next);
|
||||
|
||||
const pager = screen.getByLabelText('pagination');
|
||||
expect(within(pager).queryByText('3')).not.toBeInTheDocument();
|
||||
|
||||
release();
|
||||
await expect(within(pager).findByText('3')).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('offers no next page after a short page', async () => {
|
||||
mockPages({ 0: 3 });
|
||||
renderTracesView();
|
||||
|
||||
await screen.findAllByText('frontend');
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Go to next page' }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { ENVIRONMENT } from 'constants/env';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { VirtuosoMockContext } from 'react-virtuoso';
|
||||
import { render } from 'tests/test-utils';
|
||||
|
||||
import TracesView from '../index';
|
||||
|
||||
export const QUERY_RANGE_URL = `${ENVIRONMENT.baseURL}/api/v5/query_range`;
|
||||
|
||||
export const groupedRows = [
|
||||
{
|
||||
timestamp: '2024-07-19T08:39:58.735245Z',
|
||||
data: {
|
||||
'service.name': 'frontend',
|
||||
name: 'HTTP GET',
|
||||
duration_nano: 55306000,
|
||||
span_count: 8,
|
||||
trace_id: '0000000000000000344ded1387b08a7e',
|
||||
},
|
||||
},
|
||||
{
|
||||
timestamp: '2024-07-19T08:39:59.949129915Z',
|
||||
data: {
|
||||
'service.name': 'demo-app',
|
||||
// intentionally empty to assert the "-" cell
|
||||
name: '',
|
||||
duration_nano: 790949390,
|
||||
span_count: 3,
|
||||
trace_id: 'a364a8e15af3e9a8c866e0528db8b637',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const groupedResponse = (rows: unknown[]): Record<string, unknown> => ({
|
||||
data: { type: 'trace', data: { results: [{ queryName: 'A', rows }] } },
|
||||
});
|
||||
|
||||
export const renderTracesView = (
|
||||
props: Record<string, unknown> = {},
|
||||
): ReturnType<typeof render> =>
|
||||
render(
|
||||
<VirtuosoMockContext.Provider value={{ viewportHeight: 500, itemHeight: 54 }}>
|
||||
<TracesView
|
||||
isFilterApplied={false}
|
||||
setWarning={jest.fn()}
|
||||
setIsLoadingQueries={jest.fn()}
|
||||
{...props}
|
||||
/>
|
||||
</VirtuosoMockContext.Provider>,
|
||||
{},
|
||||
{
|
||||
initialRoute: '/traces-explorer',
|
||||
queryBuilderOverrides: {
|
||||
panelType: PANEL_TYPES.TRACE,
|
||||
stagedQuery: initialQueriesMap.traces,
|
||||
currentQuery: initialQueriesMap.traces,
|
||||
} as any,
|
||||
},
|
||||
);
|
||||
@@ -4,9 +4,6 @@ import {
|
||||
getFieldColumn,
|
||||
TracesTableRow,
|
||||
} from 'container/TracesExplorer/TracesTable/getFieldColumn';
|
||||
import { DEFAULT_PER_PAGE_OPTIONS } from 'hooks/queryPagination';
|
||||
|
||||
export const PER_PAGE_OPTIONS: number[] = [10, ...DEFAULT_PER_PAGE_OPTIONS];
|
||||
|
||||
const TRACE_FIELDS = [
|
||||
{ name: 'service.name', fieldContext: 'resource' },
|
||||
|
||||
@@ -1,42 +1,13 @@
|
||||
import { ENVIRONMENT } from 'constants/env';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { VirtuosoMockContext } from 'react-virtuoso';
|
||||
import { render, screen, waitFor } from 'tests/test-utils';
|
||||
import { screen, waitFor } from 'tests/test-utils';
|
||||
|
||||
import TracesView from './index';
|
||||
|
||||
const BASE_URL = ENVIRONMENT.baseURL;
|
||||
const QUERY_RANGE_URL = `${BASE_URL}/api/v5/query_range`;
|
||||
|
||||
const groupedRows = [
|
||||
{
|
||||
timestamp: '2024-07-19T08:39:58.735245Z',
|
||||
data: {
|
||||
'service.name': 'frontend',
|
||||
name: 'HTTP GET',
|
||||
duration_nano: 55306000,
|
||||
span_count: 8,
|
||||
trace_id: '0000000000000000344ded1387b08a7e',
|
||||
},
|
||||
},
|
||||
{
|
||||
timestamp: '2024-07-19T08:39:59.949129915Z',
|
||||
data: {
|
||||
'service.name': 'demo-app',
|
||||
// intentionally empty to assert the "-" cell
|
||||
name: '',
|
||||
duration_nano: 790949390,
|
||||
span_count: 3,
|
||||
trace_id: 'a364a8e15af3e9a8c866e0528db8b637',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const groupedResponse = (rows: unknown[]): Record<string, unknown> => ({
|
||||
data: { type: 'trace', data: { results: [{ queryName: 'A', rows }] } },
|
||||
});
|
||||
import {
|
||||
groupedResponse,
|
||||
groupedRows,
|
||||
QUERY_RANGE_URL,
|
||||
renderTracesView,
|
||||
} from './__tests__/testUtils';
|
||||
|
||||
const mockSuccess = (rows: unknown[] = groupedRows): void => {
|
||||
server.use(
|
||||
@@ -54,29 +25,6 @@ const mockError = (): void => {
|
||||
);
|
||||
};
|
||||
|
||||
const renderTracesView = (
|
||||
props: Record<string, unknown> = {},
|
||||
): ReturnType<typeof render> =>
|
||||
render(
|
||||
<VirtuosoMockContext.Provider value={{ viewportHeight: 500, itemHeight: 54 }}>
|
||||
<TracesView
|
||||
isFilterApplied={false}
|
||||
setWarning={jest.fn()}
|
||||
setIsLoadingQueries={jest.fn()}
|
||||
{...props}
|
||||
/>
|
||||
</VirtuosoMockContext.Provider>,
|
||||
{},
|
||||
{
|
||||
initialRoute: '/traces-explorer',
|
||||
queryBuilderOverrides: {
|
||||
panelType: PANEL_TYPES.TRACE,
|
||||
stagedQuery: initialQueriesMap.traces,
|
||||
currentQuery: initialQueriesMap.traces,
|
||||
} as any,
|
||||
},
|
||||
);
|
||||
|
||||
describe('TracesView (grouped root-span table)', () => {
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
@@ -96,8 +44,8 @@ describe('TracesView (grouped root-span table)', () => {
|
||||
// span_count renders as text
|
||||
expect(screen.getByText('8')).toBeInTheDocument();
|
||||
|
||||
// empty field renders "-"
|
||||
expect(screen.getAllByText('-').length).toBeGreaterThanOrEqual(1);
|
||||
// empty field renders the dash icon
|
||||
expect(screen.getAllByTestId('empty-value').length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// trace_id renders as a link to the trace detail
|
||||
const traceLinks = screen.getAllByTestId('trace-id');
|
||||
@@ -115,8 +63,6 @@ describe('TracesView (grouped root-span table)', () => {
|
||||
expect(
|
||||
screen.getByText(/This tab only shows Root Spans/i),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /previous/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /next/i })).toBeInTheDocument();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/No traces yet/i)).toBeInTheDocument();
|
||||
@@ -130,7 +76,5 @@ describe('TracesView (grouped root-span table)', () => {
|
||||
expect(
|
||||
screen.getByText(/This tab only shows Root Spans/i),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /previous/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /next/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,25 +14,23 @@ import logEvent from 'api/common/logEvent';
|
||||
import DownloadOptionsMenu from 'components/DownloadOptionsMenu/DownloadOptionsMenu';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { getListViewQuery } from 'container/TracesExplorer/explorerUtils';
|
||||
import { getTraceLink } from 'container/TracesExplorer/ListView/utils';
|
||||
import TracesTable from 'container/TracesExplorer/TracesTable/TracesTable';
|
||||
import { TracesTableRow } from 'container/TracesExplorer/TracesTable/getFieldColumn';
|
||||
import { useKeepRowsForSameQuery } from 'container/TracesExplorer/TracesTable/useKeepRowsForSameQuery';
|
||||
import { useTracesTablePagination } from 'container/TracesExplorer/TracesTable/useTracesTablePagination';
|
||||
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { Pagination } from 'hooks/queryPagination';
|
||||
import useUrlQueryData from 'hooks/useUrlQueryData';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { Warning } from 'types/api';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import DOCLINKS from 'utils/docLinks';
|
||||
|
||||
import TraceExplorerControls from '../Controls';
|
||||
import { columns, PER_PAGE_OPTIONS } from './configs';
|
||||
import { columns } from './configs';
|
||||
|
||||
import styles from './TracesView.module.scss';
|
||||
|
||||
@@ -59,9 +57,7 @@ function TracesView({
|
||||
minTime,
|
||||
} = useSelector<AppState, GlobalReducer>((state) => state.globalTime);
|
||||
|
||||
const { queryData: paginationQueryData } = useUrlQueryData<Pagination>(
|
||||
QueryParams.pagination,
|
||||
);
|
||||
const pagination = useTracesTablePagination();
|
||||
|
||||
const transformedQuery = useMemo(
|
||||
() => getListViewQuery(stagedQuery || initialQueriesMap.traces),
|
||||
@@ -76,7 +72,7 @@ function TracesView({
|
||||
minTime,
|
||||
stagedQuery,
|
||||
panelType,
|
||||
paginationQueryData,
|
||||
pagination.request,
|
||||
],
|
||||
[
|
||||
globalSelectedTime,
|
||||
@@ -84,7 +80,7 @@ function TracesView({
|
||||
minTime,
|
||||
stagedQuery,
|
||||
panelType,
|
||||
paginationQueryData,
|
||||
pagination.request,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -92,25 +88,27 @@ function TracesView({
|
||||
queryKeyRef.current = queryKey;
|
||||
}
|
||||
|
||||
const { data, isLoading, isFetching, isError, error } = useGetQueryRange(
|
||||
{
|
||||
query: transformedQuery,
|
||||
graphType: panelType || PANEL_TYPES.TRACE,
|
||||
selectedTime: 'GLOBAL_TIME',
|
||||
globalSelectedInterval: globalSelectedTime,
|
||||
params: {
|
||||
dataSource: 'traces',
|
||||
const { data, isLoading, isFetching, isError, error, isPreviousData } =
|
||||
useGetQueryRange(
|
||||
{
|
||||
query: transformedQuery,
|
||||
graphType: panelType || PANEL_TYPES.TRACE,
|
||||
selectedTime: 'GLOBAL_TIME',
|
||||
globalSelectedInterval: globalSelectedTime,
|
||||
params: {
|
||||
dataSource: 'traces',
|
||||
},
|
||||
tableParams: {
|
||||
pagination: pagination.request,
|
||||
},
|
||||
},
|
||||
tableParams: {
|
||||
pagination: paginationQueryData,
|
||||
ENTITY_VERSION_V5,
|
||||
{
|
||||
queryKey,
|
||||
keepPreviousData: true,
|
||||
enabled: !!stagedQuery && panelType === PANEL_TYPES.TRACE,
|
||||
},
|
||||
},
|
||||
ENTITY_VERSION_V5,
|
||||
{
|
||||
queryKey,
|
||||
enabled: !!stagedQuery && panelType === PANEL_TYPES.TRACE,
|
||||
},
|
||||
);
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.payload) {
|
||||
@@ -119,7 +117,10 @@ function TracesView({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data?.payload, data?.warning]);
|
||||
|
||||
const responseData = data?.payload?.data?.newResult?.data?.result[0]?.list;
|
||||
const keepRows = useKeepRowsForSameQuery(isPreviousData);
|
||||
const responseData = keepRows
|
||||
? data?.payload?.data?.newResult?.data?.result[0]?.list
|
||||
: undefined;
|
||||
|
||||
const rows = useMemo<TracesTableRow[]>(
|
||||
() =>
|
||||
@@ -163,12 +164,6 @@ function TracesView({
|
||||
dataSource={DataSource.TRACES}
|
||||
panelType={PANEL_TYPES.TRACE}
|
||||
/>
|
||||
|
||||
<TraceExplorerControls
|
||||
isLoading={isLoading}
|
||||
totalCount={rows.length}
|
||||
perPageOptions={PER_PAGE_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -184,6 +179,7 @@ function TracesView({
|
||||
isError={isError}
|
||||
error={error}
|
||||
isFilterApplied={isFilterApplied}
|
||||
pagination={pagination}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
25
frontend/src/lib/getMinMax.test.ts
Normal file
25
frontend/src/lib/getMinMax.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import GetMinMax from './getMinMax';
|
||||
|
||||
const DAY_NS = 24 * 60 * 60 * 1000 * 1e6;
|
||||
|
||||
function rangeInDays(interval: string): number {
|
||||
const { minTime, maxTime } = GetMinMax(interval);
|
||||
return Math.round((maxTime - minTime) / DAY_NS);
|
||||
}
|
||||
|
||||
describe('GetMinMax', () => {
|
||||
it.each([
|
||||
['1month', 30],
|
||||
['2months', 60],
|
||||
['3months', 90],
|
||||
['6month', 180],
|
||||
['10d', 10],
|
||||
['3w', 21],
|
||||
])('resolves %p to %p days', (interval, days) => {
|
||||
expect(rangeInDays(interval)).toBe(days);
|
||||
});
|
||||
|
||||
it('throws on an unknown interval', () => {
|
||||
expect(() => GetMinMax('1y')).toThrow('invalid time type');
|
||||
});
|
||||
});
|
||||
@@ -14,7 +14,7 @@ export const isValidShortHandDateTimeFormat = (time: string): boolean =>
|
||||
|
||||
const extractTimeAndUnit = (time: string): { time: number; unit: string } => {
|
||||
// Match the pattern
|
||||
const match = /^(\d+)([mhdw])$/.exec(time);
|
||||
const match = validCustomTimeRegex.exec(time);
|
||||
|
||||
if (match) {
|
||||
return { time: parseInt(match[1], 10), unit: match[2] };
|
||||
@@ -39,13 +39,16 @@ export const getMinTimeForRelativeTimes = (
|
||||
return getMinAgo({ minutes: 24 * 60 * time }).getTime();
|
||||
case 'w':
|
||||
return getMinAgo({ minutes: 24 * 60 * 7 * time }).getTime();
|
||||
case 'month':
|
||||
case 'months':
|
||||
return getMinAgo({ minutes: 24 * 60 * 30 * time }).getTime();
|
||||
default:
|
||||
return getMinAgo({ minutes: 1 }).getTime();
|
||||
}
|
||||
};
|
||||
|
||||
const GetMinMax = (
|
||||
interval: Time | string,
|
||||
interval: string,
|
||||
dateTimeRange?: [number, number],
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
): GetMinMaxPayload => {
|
||||
|
||||
@@ -21,7 +21,8 @@ import { fromRuleDTOToPostableRuleV2 } from 'types/api/alerts/convert';
|
||||
|
||||
import AlertHeader from './AlertHeader/AlertHeader';
|
||||
import AlertNotFound from './AlertNotFound';
|
||||
import { useGetAlertRuleDetails, useRouteTabUtils } from './hooks';
|
||||
import { useGetAlertRuleDetails } from './hooks';
|
||||
import { useRouteTabUtils } from './useRouteTabUtils';
|
||||
|
||||
import './AlertDetails.styles.scss';
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
|
||||
import { useAlertHistoryQueryParams } from '../hooks';
|
||||
|
||||
jest.mock('hooks/useUrlQuery', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
const THIRTY_MINUTES_MS = 30 * 60 * 1000;
|
||||
|
||||
function renderWithSearch(
|
||||
search: string,
|
||||
): ReturnType<typeof useAlertHistoryQueryParams> {
|
||||
jest.mocked(useUrlQuery).mockReturnValue(new URLSearchParams(search));
|
||||
return renderHook(() => useAlertHistoryQueryParams()).result.current;
|
||||
}
|
||||
|
||||
describe('useAlertHistoryQueryParams', () => {
|
||||
it.each(['1y', '90s', 'now-1h', 'custom', 'null', ''])(
|
||||
'falls back to the default range for relativeTime=%p',
|
||||
(param) => {
|
||||
const { startTime, endTime, relativeTime } = renderWithSearch(
|
||||
`ruleId=1&relativeTime=${param}`,
|
||||
);
|
||||
|
||||
expect(endTime - startTime).toBeCloseTo(THIRTY_MINUTES_MS, -3);
|
||||
expect(relativeTime).toBe('30m');
|
||||
},
|
||||
);
|
||||
|
||||
it('uses a valid relativeTime', () => {
|
||||
const { startTime, endTime, relativeTime } = renderWithSearch(
|
||||
'ruleId=1&relativeTime=6h',
|
||||
);
|
||||
|
||||
expect(endTime - startTime).toBeCloseTo(6 * 60 * 60 * 1000, -3);
|
||||
expect(relativeTime).toBe('6h');
|
||||
});
|
||||
|
||||
it('prefers startTime and endTime over relativeTime', () => {
|
||||
const { startTime, endTime, relativeTime } = renderWithSearch(
|
||||
'ruleId=1&relativeTime=1y&startTime=1000&endTime=2000',
|
||||
);
|
||||
|
||||
expect({ startTime, endTime, relativeTime }).toStrictEqual({
|
||||
startTime: 1000,
|
||||
endTime: 2000,
|
||||
relativeTime: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -29,27 +29,21 @@ import {
|
||||
type RuletypesPostableRuleDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { AxiosError } from 'axios';
|
||||
import { TabRoutes } from 'components/RouteTab/types';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import ROUTES from 'constants/routes';
|
||||
import AlertHistory from 'container/AlertHistory';
|
||||
import { TIMELINE_TABLE_PAGE_SIZE } from 'container/AlertHistory/constants';
|
||||
import {
|
||||
computeCursorForPage,
|
||||
useTimelineTableOrder,
|
||||
useTimelineTablePage,
|
||||
} from 'container/AlertHistory/Timeline/Table/useTimelineTableCursor';
|
||||
import { AlertDetailsTab, TimelineFilter } from 'container/AlertHistory/types';
|
||||
import { TimelineFilter } from 'container/AlertHistory/types';
|
||||
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import GetMinMax from 'lib/getMinMax';
|
||||
import { buildRoutePath } from 'lib/router/buildRoutePath';
|
||||
import GetMinMax, { isValidShortHandDateTimeFormat } from 'lib/getMinMax';
|
||||
import { navigate } from 'lib/router/navigation';
|
||||
import { History, Table } from '@signozhq/icons';
|
||||
import EditRules from 'pages/EditRules';
|
||||
import BetaTag from 'periscope/components/BetaTag/BetaTag';
|
||||
import { useAlertRule } from 'providers/Alert';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import { toPostableRuleDTOFromAlertDef } from 'types/api/alerts/convert';
|
||||
@@ -63,6 +57,7 @@ export const useAlertHistoryQueryParams = (): {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
hasStartAndEndParams: boolean;
|
||||
relativeTime: string | null;
|
||||
params: URLSearchParams;
|
||||
} => {
|
||||
const params = useUrlQuery();
|
||||
@@ -72,8 +67,9 @@ export const useAlertHistoryQueryParams = (): {
|
||||
const relativeTimeParam = params.get(QueryParams.relativeTime);
|
||||
|
||||
const relativeTime =
|
||||
(relativeTimeParam === 'null' ? null : relativeTimeParam) ||
|
||||
DEFAULT_TIME_RANGE;
|
||||
relativeTimeParam && isValidShortHandDateTimeFormat(relativeTimeParam)
|
||||
? relativeTimeParam
|
||||
: DEFAULT_TIME_RANGE;
|
||||
|
||||
const intStartTime = parseInt(startTime || '0', 10);
|
||||
const intEndTime = parseInt(endTime || '0', 10);
|
||||
@@ -93,63 +89,10 @@ export const useAlertHistoryQueryParams = (): {
|
||||
startTime: Math.floor(nanoToMilli(minTime)),
|
||||
endTime: Math.floor(nanoToMilli(maxTime)),
|
||||
hasStartAndEndParams,
|
||||
relativeTime: hasStartAndEndParams ? null : relativeTime,
|
||||
params,
|
||||
};
|
||||
};
|
||||
export const useRouteTabUtils = (): { routes: TabRoutes[] } => {
|
||||
const urlQuery = useUrlQuery();
|
||||
|
||||
const getRouteUrl = (tab: AlertDetailsTab): string => {
|
||||
let route = '';
|
||||
let params = urlQuery.toString();
|
||||
const ruleIdKey = QueryParams.ruleId;
|
||||
const relativeTimeKey = QueryParams.relativeTime;
|
||||
|
||||
switch (tab) {
|
||||
case AlertDetailsTab.OVERVIEW:
|
||||
route = ROUTES.ALERT_OVERVIEW;
|
||||
break;
|
||||
case AlertDetailsTab.HISTORY:
|
||||
params = `${ruleIdKey}=${urlQuery.get(
|
||||
ruleIdKey,
|
||||
)}&${relativeTimeKey}=${urlQuery.get(relativeTimeKey)}`;
|
||||
route = ROUTES.ALERT_HISTORY;
|
||||
break;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
|
||||
return `${buildRoutePath(route)}?${params}`;
|
||||
};
|
||||
|
||||
const routes = [
|
||||
{
|
||||
Component: EditRules,
|
||||
name: (
|
||||
<div className="tab-item" data-testid="alert-details-tab-overview">
|
||||
<Table size={14} />
|
||||
Overview
|
||||
</div>
|
||||
),
|
||||
route: getRouteUrl(AlertDetailsTab.OVERVIEW),
|
||||
key: ROUTES.ALERT_OVERVIEW,
|
||||
},
|
||||
{
|
||||
Component: AlertHistory,
|
||||
name: (
|
||||
<div className="tab-item" data-testid="alert-details-tab-history">
|
||||
<History size={14} />
|
||||
History
|
||||
<BetaTag />
|
||||
</div>
|
||||
),
|
||||
route: getRouteUrl(AlertDetailsTab.HISTORY),
|
||||
key: ROUTES.ALERT_HISTORY,
|
||||
},
|
||||
];
|
||||
|
||||
return { routes };
|
||||
};
|
||||
type Props = {
|
||||
ruleId: string | null;
|
||||
isValidRuleId: boolean;
|
||||
@@ -356,12 +299,7 @@ export const useTimelineTable = ({
|
||||
nextCursor?: string;
|
||||
}): {
|
||||
paginationConfig: TablePaginationConfig;
|
||||
onChangeHandler: (
|
||||
pagination: TablePaginationConfig,
|
||||
sorter: any,
|
||||
filters: any,
|
||||
extra: any,
|
||||
) => void;
|
||||
onChangeHandler: TableProps<AlertRuleTimelineTableResponse>['onChange'];
|
||||
handleNextPage: () => void;
|
||||
handlePrevPage: () => void;
|
||||
hasNextPage: boolean;
|
||||
@@ -448,8 +386,11 @@ export const useAlertRuleStatusToggle = ({
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
setAlertRuleState(data.data.state);
|
||||
invalidateGetRuleByID(queryClient, { id: ruleId });
|
||||
queryClient.refetchQueries([REACT_QUERY_KEY.ALERT_RULE_DETAILS, ruleId]);
|
||||
void invalidateGetRuleByID(queryClient, { id: ruleId });
|
||||
void queryClient.refetchQueries([
|
||||
REACT_QUERY_KEY.ALERT_RULE_DETAILS,
|
||||
ruleId,
|
||||
]);
|
||||
notifications.success({
|
||||
message: `Alert has been ${
|
||||
data.data.state === 'disabled' ? 'disabled' : 'enabled'
|
||||
@@ -457,8 +398,11 @@ export const useAlertRuleStatusToggle = ({
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
invalidateGetRuleByID(queryClient, { id: ruleId });
|
||||
queryClient.refetchQueries([REACT_QUERY_KEY.ALERT_RULE_DETAILS, ruleId]);
|
||||
void invalidateGetRuleByID(queryClient, { id: ruleId });
|
||||
void queryClient.refetchQueries([
|
||||
REACT_QUERY_KEY.ALERT_RULE_DETAILS,
|
||||
ruleId,
|
||||
]);
|
||||
showErrorModal(
|
||||
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) as APIError,
|
||||
);
|
||||
|
||||
65
frontend/src/pages/AlertDetails/useRouteTabUtils.tsx
Normal file
65
frontend/src/pages/AlertDetails/useRouteTabUtils.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { TabRoutes } from 'components/RouteTab/types';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import AlertHistory from 'container/AlertHistory';
|
||||
import { AlertDetailsTab } from 'container/AlertHistory/types';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { buildRoutePath } from 'lib/router/buildRoutePath';
|
||||
import { History, Table } from '@signozhq/icons';
|
||||
import EditRules from 'pages/EditRules';
|
||||
import BetaTag from 'periscope/components/BetaTag/BetaTag';
|
||||
|
||||
export const useRouteTabUtils = (): { routes: TabRoutes[] } => {
|
||||
const urlQuery = useUrlQuery();
|
||||
|
||||
const getRouteUrl = (tab: AlertDetailsTab): string => {
|
||||
let route = '';
|
||||
let params = urlQuery.toString();
|
||||
const ruleIdKey = QueryParams.ruleId;
|
||||
const relativeTimeKey = QueryParams.relativeTime;
|
||||
|
||||
switch (tab) {
|
||||
case AlertDetailsTab.OVERVIEW:
|
||||
route = ROUTES.ALERT_OVERVIEW;
|
||||
break;
|
||||
case AlertDetailsTab.HISTORY:
|
||||
params = `${ruleIdKey}=${urlQuery.get(
|
||||
ruleIdKey,
|
||||
)}&${relativeTimeKey}=${urlQuery.get(relativeTimeKey)}`;
|
||||
route = ROUTES.ALERT_HISTORY;
|
||||
break;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
|
||||
return `${buildRoutePath(route)}?${params}`;
|
||||
};
|
||||
|
||||
const routes = [
|
||||
{
|
||||
Component: EditRules,
|
||||
name: (
|
||||
<div className="tab-item" data-testid="alert-details-tab-overview">
|
||||
<Table size={14} />
|
||||
Overview
|
||||
</div>
|
||||
),
|
||||
route: getRouteUrl(AlertDetailsTab.OVERVIEW),
|
||||
key: ROUTES.ALERT_OVERVIEW,
|
||||
},
|
||||
{
|
||||
Component: AlertHistory,
|
||||
name: (
|
||||
<div className="tab-item" data-testid="alert-details-tab-history">
|
||||
<History size={14} />
|
||||
History
|
||||
<BetaTag />
|
||||
</div>
|
||||
),
|
||||
route: getRouteUrl(AlertDetailsTab.HISTORY),
|
||||
key: ROUTES.ALERT_HISTORY,
|
||||
},
|
||||
];
|
||||
|
||||
return { routes };
|
||||
};
|
||||
@@ -110,7 +110,6 @@
|
||||
&.active {
|
||||
background: var(--l1-background-hover);
|
||||
color: var(--l1-foreground);
|
||||
font-weight: var(--font-weight-medium, 500);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
|
||||
13
frontend/src/pages/TracesExplorer/TracesExplorer.module.scss
Normal file
13
frontend/src/pages/TracesExplorer/TracesExplorer.module.scss
Normal file
@@ -0,0 +1,13 @@
|
||||
.views {
|
||||
padding: 8px 0;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.tableView {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.insetView {
|
||||
flex: 1;
|
||||
padding-inline: 8px;
|
||||
}
|
||||
@@ -21,15 +21,6 @@
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.traces-explorer-views {
|
||||
padding: 8px;
|
||||
margin-bottom: 24px;
|
||||
|
||||
.ant-tabs-tabpane {
|
||||
padding: 0 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.qb-search-view-container {
|
||||
padding: 8px;
|
||||
|
||||
@@ -47,22 +38,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.trace-explorer-list-view {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.trace-explorer-traces-view {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.trace-explorer-table-view {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.trace-explorer-time-series-view {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.trace-explorer-page {
|
||||
// Meant to fix the query builder colors
|
||||
--input-background: var(--l2-background);
|
||||
|
||||
@@ -175,9 +175,8 @@ describe('TracesExplorer -', () => {
|
||||
await screen.findByText('demo-app');
|
||||
expect(getByText('options_menu.options')).toBeInTheDocument();
|
||||
|
||||
// test if pagination is there
|
||||
expect(getByText('Previous')).toBeInTheDocument();
|
||||
expect(getByText('Next')).toBeInTheDocument();
|
||||
// the list scrolls instead of paging
|
||||
expect(screen.queryByTestId('pagination-page-size')).not.toBeInTheDocument();
|
||||
|
||||
// column interaction is covered in E2E tests as its a complex interaction
|
||||
});
|
||||
|
||||
@@ -55,6 +55,8 @@ import {
|
||||
} from './aiActions';
|
||||
import TimeSeriesView from './TimeSeriesView';
|
||||
|
||||
import styles from './TracesExplorer.module.scss';
|
||||
|
||||
import './TracesExplorer.styles.scss';
|
||||
|
||||
function TracesExplorer(): JSX.Element {
|
||||
@@ -272,13 +274,13 @@ function TracesExplorer(): JSX.Element {
|
||||
<QuerySection />
|
||||
</div>
|
||||
|
||||
<div className="traces-explorer-views">
|
||||
<div className={styles.views}>
|
||||
{isCancelled && (
|
||||
<QueryCancelledPlaceholder subText='Click "Run Query" to load traces.' />
|
||||
)}
|
||||
|
||||
{!isCancelled && selectedView === ExplorerViews.LIST && (
|
||||
<div className="trace-explorer-list-view">
|
||||
<div className={styles.tableView}>
|
||||
<ListView
|
||||
isFilterApplied={isFilterApplied}
|
||||
setWarning={setWarning}
|
||||
@@ -290,7 +292,7 @@ function TracesExplorer(): JSX.Element {
|
||||
)}
|
||||
|
||||
{!isCancelled && selectedView === ExplorerViews.TRACE && (
|
||||
<div className="trace-explorer-traces-view">
|
||||
<div className={styles.tableView}>
|
||||
<TracesView
|
||||
isFilterApplied={isFilterApplied}
|
||||
setWarning={setWarning}
|
||||
@@ -302,7 +304,7 @@ function TracesExplorer(): JSX.Element {
|
||||
)}
|
||||
|
||||
{!isCancelled && selectedView === ExplorerViews.TIMESERIES && (
|
||||
<div className="trace-explorer-time-series-view">
|
||||
<div className={styles.insetView}>
|
||||
<TimeSeriesView
|
||||
dataSource={DataSource.TRACES}
|
||||
isFilterApplied={isFilterApplied}
|
||||
@@ -315,7 +317,7 @@ function TracesExplorer(): JSX.Element {
|
||||
)}
|
||||
|
||||
{!isCancelled && selectedView === ExplorerViews.TABLE && (
|
||||
<div className="trace-explorer-table-view">
|
||||
<div className={styles.insetView}>
|
||||
<TableView
|
||||
setWarning={setWarning}
|
||||
setIsLoadingQueries={setIsLoadingQueries}
|
||||
|
||||
@@ -4,9 +4,6 @@
|
||||
*/
|
||||
|
||||
import { rest } from 'msw';
|
||||
import remove from 'api/browser/localstorage/remove';
|
||||
import set from 'api/browser/localstorage/set';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { USER_PREFERENCES } from 'constants/userPreferences';
|
||||
@@ -109,9 +106,9 @@ export const tracesMocks = defineStoryMocks({
|
||||
spans: countControl('Spans', {
|
||||
group: LIST,
|
||||
description:
|
||||
'Spans the endpoint has, one root span per trace in the Trace view. The table asks for a page at a time, so a higher count paginates.',
|
||||
'Spans the endpoint has, one root span per trace in the Trace view. The list loads 100 more as it scrolls to the end; the Trace view pages 20 at a time.',
|
||||
value: 24,
|
||||
max: 40,
|
||||
max: 250,
|
||||
}),
|
||||
errors: toggleControl('Failing spans', {
|
||||
group: LIST,
|
||||
@@ -119,12 +116,6 @@ export const tracesMocks = defineStoryMocks({
|
||||
'Answers 503 on the spans of the services that fail, which is what the status column and the error colouring read.',
|
||||
value: true,
|
||||
}),
|
||||
spanDrawer: toggleControl('Span drawer', {
|
||||
group: LIST,
|
||||
description:
|
||||
'The `TRACES_SPAN_DRAWER_ENABLED` localStorage flag the span drawer ships behind. Off, a row click opens the trace page.',
|
||||
value: false,
|
||||
}),
|
||||
traceSummary: choiceControl<TraceSummaryState>('Trace summary', {
|
||||
group: LIST,
|
||||
description:
|
||||
@@ -362,11 +353,4 @@ export const tracesMocks = defineStoryMocks({
|
||||
),
|
||||
],
|
||||
config: (values) => ({ route: tracesRoute(values) }),
|
||||
effect: (values) => {
|
||||
if (values.spanDrawer) {
|
||||
set(LOCALSTORAGE.TRACES_SPAN_DRAWER_ENABLED, 'true');
|
||||
} else {
|
||||
remove(LOCALSTORAGE.TRACES_SPAN_DRAWER_ENABLED);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
typeFilter,
|
||||
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/stories/__story_mockdata__/querySearch.play';
|
||||
import { ExplorerViews } from 'pages/LogsExplorer/utils';
|
||||
import { expect, screen, userEvent, waitFor } from 'storybook/test';
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
@@ -58,19 +58,56 @@ const openQuickFiltersSettings = async (): Promise<void> => {
|
||||
|
||||
/**
|
||||
* The Explorer tab on a full window of spans: the org's quick filters down the
|
||||
* left, the query builder and the view switcher above, and the list paging ten
|
||||
* spans at a time with the failing ones answering 503.
|
||||
* left, the query builder and the view switcher above, and the span list with
|
||||
* the failing ones answering 503.
|
||||
*/
|
||||
export const Default: Story = {};
|
||||
|
||||
/**
|
||||
* The Trace view, which shows one row per trace: its root span, the root
|
||||
* duration and how many spans the trace has.
|
||||
* duration and how many spans the trace has, twenty traces to a page.
|
||||
*/
|
||||
export const RootSpans: Story = {
|
||||
args: { view: ExplorerViews.TRACE },
|
||||
};
|
||||
|
||||
/**
|
||||
* The Trace view on its second page. The API sends no total, so the pager
|
||||
* offers one more page after each full one.
|
||||
*/
|
||||
export const RootSpansNextPage: Story = {
|
||||
args: { view: ExplorerViews.TRACE, spans: 50 },
|
||||
play: async (): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await screen.findByRole('button', { name: 'Go to next page' }, untilLoaded),
|
||||
);
|
||||
const pager = screen.getByLabelText('pagination');
|
||||
|
||||
await expect(
|
||||
await within(pager).findByText('3', undefined, untilLoaded),
|
||||
).toBeVisible();
|
||||
},
|
||||
};
|
||||
|
||||
/** The span list scrolled to its end, with the next 100 spans loaded under the first. */
|
||||
export const ListNextPage: Story = {
|
||||
args: { spans: 150 },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
// Scrolls again on every attempt, since each loaded page moves the end.
|
||||
await waitFor(async (): Promise<void> => {
|
||||
const scroller = canvasElement.querySelector<HTMLElement>(
|
||||
'[data-virtuoso-scroller]',
|
||||
);
|
||||
|
||||
await expect(scroller).not.toBeNull();
|
||||
(scroller as HTMLElement).scrollTop = (scroller as HTMLElement).scrollHeight;
|
||||
await expect(
|
||||
canvasElement.querySelector('tr[data-index="120"]'),
|
||||
).not.toBeNull();
|
||||
}, untilLoaded);
|
||||
},
|
||||
};
|
||||
|
||||
/** The charted view of the same trace query. */
|
||||
export const Timeseries: Story = {
|
||||
args: { view: ExplorerViews.TIMESERIES },
|
||||
|
||||
@@ -25,7 +25,6 @@ const meta = {
|
||||
component: TracesModulePage,
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
args: { spanDrawer: true },
|
||||
} satisfies Meta<TracesArgs>;
|
||||
|
||||
export default meta;
|
||||
@@ -37,8 +36,8 @@ const untilLoaded = { timeout: 15_000 };
|
||||
|
||||
// The quick filters list the same span name, so the row is found by its test
|
||||
// id; the table renders its rows a few at a time, so it waits for this one.
|
||||
const openDrawer = async (): Promise<HTMLElement> => {
|
||||
const row = await waitFor((): HTMLElement => {
|
||||
const findOrdersRow = (): Promise<HTMLElement> =>
|
||||
waitFor((): HTMLElement => {
|
||||
const match = screen
|
||||
.getAllByTestId(/^traces-table-row-/)
|
||||
.find((candidate) => candidate.textContent?.includes('POST /api/orders'));
|
||||
@@ -48,6 +47,9 @@ const openDrawer = async (): Promise<HTMLElement> => {
|
||||
return match as HTMLElement;
|
||||
}, untilLoaded);
|
||||
|
||||
const openDrawer = async (): Promise<HTMLElement> => {
|
||||
const row = await findOrdersRow();
|
||||
|
||||
await userEvent.click(within(row).getByText('POST /api/orders'));
|
||||
|
||||
return screen.findByTestId('span-details-drawer');
|
||||
@@ -80,6 +82,19 @@ export const Default: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* A hovered row with the icon at its start that opens the trace page in a new
|
||||
* tab, without opening the drawer.
|
||||
*/
|
||||
export const OpenTraceIcon: Story = {
|
||||
play: async ({ mount }): Promise<void> => {
|
||||
await mount();
|
||||
await userEvent.hover(await findOrdersRow());
|
||||
|
||||
await expect(await screen.findByTestId('span-row-open-trace')).toBeVisible();
|
||||
},
|
||||
};
|
||||
|
||||
/** The Logs tab with the span's logs, between the logs just before and after them. */
|
||||
export const Logs: Story = {
|
||||
args: { spanLogs: 'span' },
|
||||
|
||||
32
tests/e2e/drivers/traces-explorer.ts
generated
32
tests/e2e/drivers/traces-explorer.ts
generated
@@ -74,8 +74,12 @@ export interface TracesExplorer {
|
||||
/** Re-run the query until `key`'s row renders: the list fetches once. */
|
||||
waitForRow(key: string): Promise<void>;
|
||||
cell(rowKey: string, field: string): Locator;
|
||||
/** Click a list row and wait for its trace-details URL. */
|
||||
openRow(rowKey: string, traceId: string): Promise<void>;
|
||||
/** Click a list row and wait for its span drawer. */
|
||||
openSpanDrawer(rowKey: string): Promise<void>;
|
||||
spanDrawer(): Locator;
|
||||
spanDrawerTraceId(): Locator;
|
||||
/** "View full trace" in the drawer, replayed on this page. */
|
||||
openFullTrace(traceId: string, spanId: string): Promise<void>;
|
||||
/** A waterfall row on the trace-details page. */
|
||||
traceDetailsRow(spanId: string): Locator;
|
||||
timeSeriesGraph(): Locator;
|
||||
@@ -197,11 +201,27 @@ export function tracesExplorerDriver(page: Page): TracesExplorer {
|
||||
|
||||
cell: (rowKey, field) => explorer.row(rowKey).getByTestId(field).first(),
|
||||
|
||||
openRow: async (rowKey, traceId) => {
|
||||
openSpanDrawer: async (rowKey) => {
|
||||
await explorer.cell(rowKey, 'name').click();
|
||||
await expect(page).toHaveURL(
|
||||
new RegExp(`/trace/${traceId}\\?.*spanId=${rowKey}`),
|
||||
);
|
||||
await expect(explorer.spanDrawer()).toBeVisible();
|
||||
},
|
||||
|
||||
spanDrawer: () => page.getByTestId('span-details-drawer'),
|
||||
|
||||
spanDrawerTraceId: () =>
|
||||
explorer.spanDrawer().getByTestId('span-details-trace-id'),
|
||||
|
||||
openFullTrace: async (traceId, spanId) => {
|
||||
// The button opens trace details in a new tab; replay its URL here so
|
||||
// the driver stays on this page.
|
||||
const [tab] = await Promise.all([
|
||||
page.context().waitForEvent('page'),
|
||||
page.getByTestId('span-drawer-view-full-trace').click(),
|
||||
]);
|
||||
await tab.waitForURL(new RegExp(`/trace/${traceId}\\?.*spanId=${spanId}`));
|
||||
const { pathname, search } = new URL(tab.url());
|
||||
await tab.close();
|
||||
await page.goto(`${pathname}${search}`);
|
||||
},
|
||||
|
||||
traceDetailsRow: (spanId) => page.getByTestId(`cell-0-${spanId}`),
|
||||
|
||||
@@ -27,7 +27,7 @@ test.describe('Traces explorer, views', () => {
|
||||
expect(noise.failedResponses).toEqual([]);
|
||||
});
|
||||
|
||||
test('TC-02 clicking a list row opens that span in trace details', async ({
|
||||
test('TC-02 a list row opens its span drawer, and View full trace opens that span in trace details', async ({
|
||||
traces,
|
||||
tracesExplorerSeed: seed,
|
||||
}) => {
|
||||
@@ -35,7 +35,10 @@ test.describe('Traces explorer, views', () => {
|
||||
await traces.filterService(seed.service);
|
||||
await traces.waitForRow(seed.errorSpanId);
|
||||
|
||||
await traces.openRow(seed.errorSpanId, seed.traceId);
|
||||
await traces.openSpanDrawer(seed.errorSpanId);
|
||||
await expect(traces.spanDrawerTraceId()).toHaveText(seed.traceId);
|
||||
|
||||
await traces.openFullTrace(seed.traceId, seed.errorSpanId);
|
||||
|
||||
await expect(traces.traceDetailsRow(seed.errorSpanId)).toBeVisible();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user