Compare commits

...

4 Commits

Author SHA1 Message Date
ashu8912
13440740c4 fix(alerts): keep configuration sidebar item weight constant when active (#13432)
Some checks are pending
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
cacheci / tests (push) Waiting to run
Release Drafter / update_release_draft (push) Waiting to run
#### Description

- Configuration sidebar items (Notification Channels, Routing Policies,
Planned Downtime) no longer turn bold when selected; only the colour
changes, so the label no longer shifts.

Closes: https://github.com/SigNoz/pulse-pod/issues/693
2026-11-05 06:13:01 +00:00
Aditya Singh
711f0b6beb feat(traces-explorer): add infinite scroll to list view; table ui update (#13392)
#### Description

- List view now loads spans on scroll, 100 at a time. It stops once a
page comes back short or the query limit is hit. Previous / Next
controls are removed from the list.
- Traces tab now uses the table's own pager. Page and page size are kept
in the URL. Default is 20 traces per page instead of 10 so the table
fills the space. API does not send a total, so the pager offers the next
page only after a full page.
- New query clears the old rows and shows the loader. It also resets the
traces tab to page 1 and closes the span drawer.
- Span drawer is now on for everyone. Removed the
`TRACES_SPAN_DRAWER_ENABLED` flag.
- Added a hover icon at the start of each row to open the trace in a new
tab. If the API returns the same span twice, only the hovered row shows
the icon.
- Table ui: list and traces tables run edge to edge, empty cells show a
dash in the border colour, rows use the table default height and the
hover / active colours from design.

#### Issues closed by this PR

Part of https://github.com/SigNoz/events-pod/issues/195

#### Screen recording 



https://github.com/user-attachments/assets/4f7897bd-0c6b-4ecf-aaf2-231834082547




https://claude.ai/artifact/LmXzEheZYZ1xdpshNjGBRf?sk=dOC3fM6uD0n_fKnzLAnZXQ

#### Additional Information

- If the data ends exactly on a full page, Next opens an empty page and
the pager steps back to the last one.
- A failed page request hides the pager. Same as other explorers, so
keeping it as is for now.
- Icon sits in a left gutter as discussed with design.
2026-11-05 05:23:11 +00:00
ashu8912
98f9850c53 fix(alerts): fall back to default range on invalid relativeTime (#13375)
#### Description

- Alert Details crashed to the error boundary when the URL
`relativeTime` couldn't be parsed (e.g. `?relativeTime=1y`). It now
falls back to the default 30m range.
- The "triggers in …" and stats card labels show the range actually
queried, not the raw param.
- `relativeTime=3months` (accepted by validation since #12402) silently
resolved to 30 minutes. Multi-month values now resolve to 30 days per
month.
- Moved `useRouteTabUtils` into its own file to break the `hooks.tsx` ↔
`container/AlertHistory` import cycle.

#### Issues closed by this PR

Closes SigNoz/pulse-pod#674

#### Additional Information

- Repro: open `/alerts/overview?ruleId=<id>&relativeTime=1y` (crashes on
`main`), or `relativeTime=3months` (shows 30m of history).
- No story change. The fixed page renders the same as the existing
AlertOverview story.
2026-11-05 03:12:55 +00:00
Pandey
07fd9ac5b8 chore: update codeowners for types and tests (#13427)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
- Update codeowners
2026-11-04 21:43:28 +00:00
51 changed files with 1249 additions and 460 deletions

4
.github/CODEOWNERS vendored
View File

@@ -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

View File

@@ -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',
}

View File

@@ -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,

View File

@@ -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();

View File

@@ -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);

View File

@@ -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 },
]);
});
});

View File

@@ -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);
});
});

View File

@@ -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();
});

View File

@@ -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()}

View File

@@ -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 () => {

View File

@@ -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);

View File

@@ -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>
);

View File

@@ -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,
};
}

View File

@@ -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;
};

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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();
});
});

View File

@@ -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} />
),
};

View File

@@ -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,

View File

@@ -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;

View File

@@ -0,0 +1,4 @@
.emptyValue {
display: block;
color: var(--l2-border);
}

View File

@@ -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);

View File

@@ -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);
}

View File

@@ -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;

View File

@@ -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);
});
});

View File

@@ -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),
);
});
});

View File

@@ -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';

View File

@@ -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;
}

View File

@@ -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 };
}

View File

@@ -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;
}

View File

@@ -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();
});
});

View File

@@ -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,
},
);

View File

@@ -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' },

View File

@@ -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();
});
});

View File

@@ -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>
);

View 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');
});
});

View File

@@ -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 => {

View File

@@ -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';

View File

@@ -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,
});
});
});

View File

@@ -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,
);

View 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 };
};

View File

@@ -110,7 +110,6 @@
&.active {
background: var(--l1-background-hover);
color: var(--l1-foreground);
font-weight: var(--font-weight-medium, 500);
}
&:focus-visible {

View File

@@ -0,0 +1,13 @@
.views {
padding: 8px 0;
margin-bottom: 24px;
}
.tableView {
flex: 1;
}
.insetView {
flex: 1;
padding-inline: 8px;
}

View File

@@ -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);

View File

@@ -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
});

View File

@@ -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}

View File

@@ -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);
}
},
});

View File

@@ -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 },

View File

@@ -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' },

View File

@@ -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}`),

View File

@@ -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();
});