diff --git a/ui/apps/pmm/src/pages/rta/overview/RealtimeOverview.navigation.test.tsx b/ui/apps/pmm/src/pages/rta/overview/RealtimeOverview.navigation.test.tsx
new file mode 100644
index 00000000000..ebc960f3521
--- /dev/null
+++ b/ui/apps/pmm/src/pages/rta/overview/RealtimeOverview.navigation.test.tsx
@@ -0,0 +1,197 @@
+/**
+ * Details-pane prev/next must follow the table's filtered rows (navigableQueries),
+ * not the full list returned by the API.
+ *
+ * In the app, OverviewTable syncs navigableQueries from Material React Table after
+ * filters are applied. RealtimeOverview uses that list for arrow navigation.
+ *
+ * We mock OverviewTable here so we can fix navigableQueries (query-1, query-2) while
+ * the API still returns an extra query (query-3) that would be next if we used the
+ * unfiltered array. RealtimeOverview.test.tsx keeps the real table for other behavior.
+ */
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { FC, useEffect } from 'react';
+import { MemoryRouter, Route, Routes } from 'react-router-dom';
+import { wrapWithQueryProvider } from 'utils/testUtils';
+import { TEST_MONGO_DB_QUERY_DATA, TEST_REAL_TIME_SESSION } from 'utils/testStubs';
+import { QueryData } from 'types/rta.types';
+import RealtimeOverview from './RealtimeOverview';
+
+const queryOne: QueryData = {
+ ...TEST_MONGO_DB_QUERY_DATA,
+ queryId: 'query-1',
+};
+
+const queryTwo: QueryData = {
+ ...TEST_MONGO_DB_QUERY_DATA,
+ queryId: 'query-2',
+};
+
+const queryFilteredOut: QueryData = {
+ ...TEST_MONGO_DB_QUERY_DATA,
+ queryId: 'query-3',
+};
+
+const navigableQueries = [queryOne, queryTwo];
+
+const { searchQueries, getRunningSessions, mockNavigableQueries } = vi.hoisted(() => {
+ let navigable: QueryData[] = [];
+
+ return {
+ searchQueries: vi.fn(),
+ getRunningSessions: vi.fn(),
+ mockNavigableQueries: {
+ get: () => navigable,
+ set: (queries: QueryData[]) => {
+ navigable = queries;
+ },
+ },
+ };
+});
+
+vi.mock('api/rta', () => ({
+ searchQueries,
+ getRunningSessions,
+}));
+
+vi.mock('./table/OverviewTable', () => {
+ const MockOverviewTable: FC<{
+ onQuerySelected: (query: QueryData) => void;
+ onNavigableQueriesChange: (queries: QueryData[]) => void;
+ }> = ({ onQuerySelected, onNavigableQueriesChange }) => {
+ useEffect(() => {
+ onNavigableQueriesChange(mockNavigableQueries.get());
+ }, [onNavigableQueriesChange]);
+
+ return (
+ <>
+
+
+ >
+ );
+ };
+
+ return { default: MockOverviewTable };
+});
+
+const renderComponent = () =>
+ render(
+ wrapWithQueryProvider(
+
+
+ } />
+
+
+ )
+ );
+
+const openDetailsPaneOnFirstQuery = async () => {
+ await waitFor(() =>
+ expect(screen.getByTestId('mock-select-first-query')).toBeInTheDocument()
+ );
+ fireEvent.click(screen.getByTestId('mock-select-first-query'));
+ await waitFor(() =>
+ expect(screen.getByTestId('query-details-pane')).toHaveAttribute(
+ 'aria-hidden',
+ 'false'
+ )
+ );
+};
+
+const getOperationId = () => screen.getByTestId('operation-id-value');
+
+describe('RealtimeOverview details pane navigation', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockNavigableQueries.set(navigableQueries);
+
+ searchQueries.mockResolvedValue({
+ queries: [...navigableQueries, queryFilteredOut],
+ });
+
+ getRunningSessions.mockResolvedValue([TEST_REAL_TIME_SESSION]);
+ });
+
+ it('navigates to the next visible query, not the next API query', async () => {
+ renderComponent();
+ await openDetailsPaneOnFirstQuery();
+
+ expect(getOperationId()).toHaveTextContent('query-1');
+
+ fireEvent.click(screen.getByTestId('details-pane-next-button'));
+
+ await waitFor(() => {
+ expect(getOperationId()).toHaveTextContent('query-2');
+ });
+ expect(getOperationId()).not.toHaveTextContent('query-3');
+ });
+
+ it('navigates to the previous visible query', async () => {
+ renderComponent();
+ await openDetailsPaneOnFirstQuery();
+
+ fireEvent.click(screen.getByTestId('details-pane-next-button'));
+ await waitFor(() => {
+ expect(getOperationId()).toHaveTextContent('query-2');
+ });
+
+ fireEvent.click(screen.getByTestId('details-pane-prev-button'));
+ await waitFor(() => {
+ expect(getOperationId()).toHaveTextContent('query-1');
+ });
+ });
+
+ it('disables prev on the first visible query and next on the last', async () => {
+ renderComponent();
+ await openDetailsPaneOnFirstQuery();
+
+ expect(screen.getByTestId('details-pane-prev-button')).toBeDisabled();
+ expect(screen.getByTestId('details-pane-next-button')).not.toBeDisabled();
+
+ fireEvent.click(screen.getByTestId('details-pane-next-button'));
+
+ await waitFor(() => {
+ expect(screen.getByTestId('details-pane-prev-button')).not.toBeDisabled();
+ expect(screen.getByTestId('details-pane-next-button')).toBeDisabled();
+ });
+ });
+
+ it('disables navigation when the selected query is not in navigableQueries', async () => {
+ renderComponent();
+ await openDetailsPaneOnFirstQuery();
+
+ expect(getOperationId()).toHaveTextContent('query-1');
+
+ fireEvent.click(screen.getByTestId('mock-drop-selected-from-navigable'));
+
+ await waitFor(() => {
+ expect(screen.getByTestId('details-pane-prev-button')).toBeDisabled();
+ expect(screen.getByTestId('details-pane-next-button')).toBeDisabled();
+ });
+
+ fireEvent.click(screen.getByTestId('details-pane-next-button'));
+
+ await waitFor(() => {
+ expect(getOperationId()).toHaveTextContent('query-1');
+ });
+ });
+});
diff --git a/ui/apps/pmm/src/pages/rta/overview/RealtimeOverview.test.tsx b/ui/apps/pmm/src/pages/rta/overview/RealtimeOverview.test.tsx
index ece1a0fc88a..1fdd9eda4b2 100644
--- a/ui/apps/pmm/src/pages/rta/overview/RealtimeOverview.test.tsx
+++ b/ui/apps/pmm/src/pages/rta/overview/RealtimeOverview.test.tsx
@@ -167,7 +167,7 @@ describe('RealtimeOverview', () => {
expect(screen.getByTestId('auto-refresh-button')).not.toBeDisabled();
- const clearButton = await waitFor(() => screen.findByTitle('Clear'));
+ const clearButton = await screen.findByTitle('Clear');
fireEvent.click(clearButton);
expect(screen.getByTestId('auto-refresh-button')).toBeDisabled();
@@ -234,7 +234,7 @@ describe('RealtimeOverview', () => {
expect(screen.getByTestId('auto-refresh-button')).toBeDisabled();
- const openButton = await waitFor(() => screen.findByTitle('Open'));
+ const openButton = await screen.findByTitle('Open');
fireEvent.click(openButton);
const serviceOptionId =
@@ -265,7 +265,7 @@ describe('RealtimeOverview', () => {
expect(screen.getByTestId('auto-refresh-button')).toBeDisabled();
- const openButton = await waitFor(() => screen.findByTitle('Open'));
+ const openButton = await screen.findByTitle('Open');
fireEvent.click(openButton);
const serviceOptionId =
diff --git a/ui/apps/pmm/src/pages/rta/overview/RealtimeOverview.tsx b/ui/apps/pmm/src/pages/rta/overview/RealtimeOverview.tsx
index 7549e0162c2..38c8329f91f 100644
--- a/ui/apps/pmm/src/pages/rta/overview/RealtimeOverview.tsx
+++ b/ui/apps/pmm/src/pages/rta/overview/RealtimeOverview.tsx
@@ -17,51 +17,54 @@ import Button from '@mui/material/Button';
import { ServicesAutocompleteInput } from '../components/services-autocomplete-input';
import { AutoRefreshSelect } from './auto-refresh-select';
+const EMPTY_QUERIES: QueryData[] = [];
+
const RealtimeOverviewPage: FC = () => {
const [searchParams, setSearchParams] = useSearchParams();
const serviceIds = searchParams.getAll('serviceIds');
const [fetching, setFetching] = useState(serviceIds.length > 0);
const [refreshInterval, setRefreshInterval] = useState(2000);
- const { data: queries = [], refetch } = useRealtimeQueries(
+ const { data: queries, refetch } = useRealtimeQueries(
{ serviceIds },
{
enabled: fetching,
refetchInterval: refreshInterval,
}
);
- const [selectedQueryIndex, setSelectedQueryIndex] = useState();
+ const tableQueries = queries ?? EMPTY_QUERIES;
+ // Synced from the table after filters; details-pane arrows use this list, not the full API result.
+ const [navigableQueries, setNavigableQueries] = useState([]);
const [selectedQuery, setSelectedQuery] = useState();
// We need to store the previous fetching state to restore it when the details pane is closed
const previousFetchingState = useRef(fetching);
const { data: sessions = [], isLoading } = useRealtimeSessions();
- const handleQueryChange = (query: QueryData, index: number) => {
+ const selectedQueryIndex = selectedQuery
+ ? navigableQueries.findIndex(
+ (query) => query.queryId === selectedQuery.queryId
+ )
+ : -1;
+
+ const handleQuerySelected = (query: QueryData) => {
setSelectedQuery(query);
- setSelectedQueryIndex(index);
previousFetchingState.current = fetching;
setFetching(false);
};
const handleCloseDetails = () => {
setSelectedQuery(undefined);
- setSelectedQueryIndex(undefined);
setFetching(previousFetchingState.current);
};
- const handleNextQuery = () => {
- const idx = (selectedQueryIndex || 0) + 1;
- if (idx >= queries.length) {
+ const handleAdjacentQuery = (offset: -1 | 1) => {
+ if (selectedQueryIndex < 0) {
return;
}
- handleQueryChange(queries[idx], idx);
- };
-
- const handlePreviousQuery = () => {
- const idx = (selectedQueryIndex || 0) - 1;
- if (idx < 0) {
+ const nextIndex = selectedQueryIndex + offset;
+ if (nextIndex < 0 || nextIndex >= navigableQueries.length) {
return;
}
- handleQueryChange(queries[idx], idx);
+ handleQuerySelected(navigableQueries[nextIndex]);
};
const handleServiceIdsChange = (newServiceIds: string[]) => {
@@ -93,8 +96,9 @@ const RealtimeOverviewPage: FC = () => {
return (
(
{
= navigableQueries.length - 1
+ }
+ onNext={() => handleAdjacentQuery(1)}
+ onPrevious={() => handleAdjacentQuery(-1)}
/>
);
diff --git a/ui/apps/pmm/src/pages/rta/overview/table/OverviewTable.tsx b/ui/apps/pmm/src/pages/rta/overview/table/OverviewTable.tsx
index 006a961d9c2..e850965bc5b 100644
--- a/ui/apps/pmm/src/pages/rta/overview/table/OverviewTable.tsx
+++ b/ui/apps/pmm/src/pages/rta/overview/table/OverviewTable.tsx
@@ -1,7 +1,12 @@
-import { type MRT_Row } from 'material-react-table';
-import { MaterialReactTableProps } from 'material-react-table';
+import {
+ type MRT_ColumnFiltersState,
+ type MRT_Row,
+ type MRT_SortingState,
+ type MRT_TableInstance,
+ MaterialReactTableProps,
+} from 'material-react-table';
import { Table } from '@percona/percona-ui';
-import { FC } from 'react';
+import { FC, useCallback, useEffect, useRef, useState } from 'react';
import { QueryData } from 'types/rta.types';
import { OVERVIEW_TABLE_COLUMNS } from './OverviewTable.constants';
import { RealtimeTableWrapper } from 'pages/rta/components/rta-table-wrapper';
@@ -11,7 +16,8 @@ import { filterElapsedTime } from './OverviewTable.utils';
interface Props {
queries: QueryData[];
- onQuerySelected: (query: QueryData, idx: number) => void;
+ onQuerySelected: (query: QueryData) => void;
+ onNavigableQueriesChange: (queries: QueryData[]) => void;
actions?: MaterialReactTableProps['renderTopToolbarCustomActions'];
onRowHover?: () => void;
}
@@ -19,46 +25,77 @@ interface Props {
const OverviewTable: FC = ({
queries,
onQuerySelected,
+ onNavigableQueriesChange,
actions,
onRowHover,
-}) => (
-
- .${boxClasses.root}`]: {
- alignItems: 'center',
- flexDirection: 'row-reverse',
+}) => {
+ const tableRef = useRef | null>(null);
+ // Controlled table state is required to read the filtered/sorted row model via tableInstanceRef.
+ const [columnFilters, setColumnFilters] = useState([]);
+ const [sorting, setSorting] = useState([]);
+
+ // Pre-pagination so navigation covers all filtered rows, not only the current page.
+ const getNavigableQueries = useCallback(
+ () =>
+ tableRef.current?.getPrePaginationRowModel().rows.map((row) => row.original) ??
+ queries,
+ [queries]
+ );
+
+ const syncNavigableQueries = useCallback(() => {
+ onNavigableQueriesChange(getNavigableQueries());
+ }, [getNavigableQueries, onNavigableQueriesChange]);
+
+ useEffect(() => {
+ syncNavigableQueries();
+ }, [columnFilters, sorting, syncNavigableQueries]);
+
+ return (
+
+ .${boxClasses.root}`]: {
+ alignItems: 'center',
+ flexDirection: 'row-reverse',
+ },
},
- },
- }}
- enableGlobalFilter={false}
- enableHiding={false}
- enableRowHoverAction
- rowHoverAction={(row) => onQuerySelected(row.original, row.index)}
- renderTopToolbarCustomActions={actions}
- filterFns={{
- // default 'betweenInclusive' filter fails on values like '1.50', discarding the row that has 1.5 seconds
- timeRangeFilterFn: (row, id, filterValue) =>
- filterElapsedTime(row as MRT_Row, id, filterValue),
- }}
- muiTableBodyRowProps={({ row }) => ({
- onMouseEnter: onRowHover,
- 'data-testid': `query-${row.original.queryId}-row`,
- })}
- />
-
-);
+ }}
+ state={{ columnFilters, sorting }}
+ onColumnFiltersChange={setColumnFilters}
+ onSortingChange={setSorting}
+ enableGlobalFilter={false}
+ enableHiding={false}
+ enableRowHoverAction
+ tableInstanceRef={tableRef}
+ rowHoverAction={(row) => {
+ syncNavigableQueries();
+ onQuerySelected(row.original);
+ }}
+ renderTopToolbarCustomActions={actions}
+ filterFns={{
+ // default 'betweenInclusive' filter fails on values like '1.50', discarding the row that has 1.5 seconds
+ timeRangeFilterFn: (row, id, filterValue) =>
+ filterElapsedTime(row as MRT_Row, id, filterValue),
+ }}
+ muiTableBodyRowProps={({ row }) => ({
+ onMouseEnter: onRowHover,
+ 'data-testid': `query-${row.original.queryId}-row`,
+ })}
+ />
+
+ );
+};
export default OverviewTable;