From b06a7ef879d90161040e0aacf132e6c2b9b66316 Mon Sep 17 00:00:00 2001 From: Jvst Me Date: Fri, 23 Oct 2026 15:16:05 +0200 Subject: [PATCH] [UI]: Add order toggle to events tables Allow switching between newest-first and oldest-first order by clicking the "Recorded At" column header. The toggle is available on the project and user Events pages, and the fleet, instance, run, and job Events tabs. In `dstack` Enterprise, Factory, and Sky, the toggle is also available on the global Events page. --- .../List/hooks/useColumnDefinitions.tsx | 1 + .../pages/Events/List/hooks/useEventsOrder.ts | 49 +++++++++++++++++++ .../src/pages/Events/List/hooks/useFilters.ts | 28 +++++++---- frontend/src/pages/Events/List/index.tsx | 9 +++- .../src/pages/Fleets/Details/Events/index.tsx | 9 +++- .../pages/Instances/Details/Events/index.tsx | 12 ++++- .../pages/Project/Details/Events/index.tsx | 13 +++-- .../pages/Runs/Details/Events/List/index.tsx | 9 +++- .../pages/Runs/Details/Jobs/Events/index.tsx | 9 +++- .../src/pages/User/Details/Events/index.tsx | 13 +++-- 10 files changed, 125 insertions(+), 27 deletions(-) create mode 100644 frontend/src/pages/Events/List/hooks/useEventsOrder.ts diff --git a/frontend/src/pages/Events/List/hooks/useColumnDefinitions.tsx b/frontend/src/pages/Events/List/hooks/useColumnDefinitions.tsx index 61b7d7549b..ae0a1bc5f3 100644 --- a/frontend/src/pages/Events/List/hooks/useColumnDefinitions.tsx +++ b/frontend/src/pages/Events/List/hooks/useColumnDefinitions.tsx @@ -15,6 +15,7 @@ export const useColumnsDefinitions = () => { id: 'recorded_at', header: t('events.recorded_at'), cell: (item) => format(new Date(item.recorded_at), DATE_TIME_FORMAT), + sortingField: 'recorded_at', }, { id: 'actor', diff --git a/frontend/src/pages/Events/List/hooks/useEventsOrder.ts b/frontend/src/pages/Events/List/hooks/useEventsOrder.ts new file mode 100644 index 0000000000..04d8933678 --- /dev/null +++ b/frontend/src/pages/Events/List/hooks/useEventsOrder.ts @@ -0,0 +1,49 @@ +import { useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; + +import type { TableProps } from 'components'; + +const ORDER_SEARCH_PARAM_KEY = 'order'; + +// Search params that open the global Events page in the specified order +export const getEventsOrderSearchParams = (ascending: boolean): Record => + ascending ? { [ORDER_SEARCH_PARAM_KEY]: 'asc' } : {}; + +export const useEventsOrder = ({ withSearchParams }: { withSearchParams?: boolean } = {}) => { + const [searchParams, setSearchParams] = useSearchParams(); + const [localAscending, setLocalAscending] = useState(false); + + // Read from search params on every render to stay in sync with browser navigation + const ascending = withSearchParams ? searchParams.get(ORDER_SEARCH_PARAM_KEY) === 'asc' : localAscending; + + const onSortingChange: TableProps['onSortingChange'] = ({ detail }) => { + if (!withSearchParams) { + setLocalAscending(!detail.isDescending); + return; + } + + setSearchParams( + (prevSearchParams) => { + const nextSearchParams = new URLSearchParams(prevSearchParams); + + if (detail.isDescending) { + nextSearchParams.delete(ORDER_SEARCH_PARAM_KEY); + } else { + nextSearchParams.set(ORDER_SEARCH_PARAM_KEY, 'asc'); + } + + return nextSearchParams; + }, + { replace: true }, + ); + }; + + // Events are sorted on the server, so the table only displays and changes the order + const sortingProps: Pick, 'sortingColumn' | 'sortingDescending' | 'onSortingChange'> = { + sortingColumn: { sortingField: 'recorded_at' }, + sortingDescending: !ascending, + onSortingChange, + }; + + return { ascending, sortingProps } as const; +}; diff --git a/frontend/src/pages/Events/List/hooks/useFilters.ts b/frontend/src/pages/Events/List/hooks/useFilters.ts index 002ee7543e..ca791e67c3 100644 --- a/frontend/src/pages/Events/List/hooks/useFilters.ts +++ b/frontend/src/pages/Events/List/hooks/useFilters.ts @@ -204,13 +204,6 @@ export const useFilters = ({ requestParamsToTokens({ searchParams, filterKeys }), ); - const clearFilter = () => { - if (withSearchParams) { - setSearchParams({}); - } - setPropertyFilterQuery(EMPTY_QUERY); - }; - const filteringOptions = useMemo(() => { const options: PropertyFilterProps.FilteringOption[] = [...dynamicFilteringOptions]; @@ -226,9 +219,26 @@ export const useFilters = ({ }, [dynamicFilteringOptions]); const setSearchParamsHandle = ({ tokens }: { tokens: PropertyFilterProps.Query['tokens'] }) => { - const searchParams = tokensToSearchParams(tokens); + setSearchParams((prevSearchParams) => { + const searchParams = tokensToSearchParams(tokens); + const filterKeysValues: string[] = Object.values(filterKeys); + + // Keep search params that are not managed by the property filter, e.g. the sort order + prevSearchParams.forEach((value, key) => { + if (!filterKeysValues.includes(key)) { + searchParams.append(key, value); + } + }); - setSearchParams(searchParams); + return searchParams; + }); + }; + + const clearFilter = () => { + if (withSearchParams) { + setSearchParamsHandle({ tokens: [] }); + } + setPropertyFilterQuery(EMPTY_QUERY); }; const onChangePropertyFilterHandle = ({ tokens, operation }: PropertyFilterProps.Query) => { diff --git a/frontend/src/pages/Events/List/index.tsx b/frontend/src/pages/Events/List/index.tsx index 5b22d245ad..ac6338daf8 100644 --- a/frontend/src/pages/Events/List/index.tsx +++ b/frontend/src/pages/Events/List/index.tsx @@ -11,6 +11,7 @@ import { ROUTES } from 'routes'; import { useLazyGetAllEventsQuery } from 'services/events'; import { useColumnsDefinitions } from './hooks/useColumnDefinitions'; +import { useEventsOrder } from './hooks/useEventsOrder'; import { useFilters } from './hooks/useFilters'; import styles from 'pages/Runs/List/styles.module.scss'; @@ -18,6 +19,7 @@ import styles from 'pages/Runs/List/styles.module.scss'; type RenderHeaderArgs = { refreshAction?: () => void; disabledRefresh?: boolean; + ascending: boolean; }; type EventListProps = Pick & { @@ -53,9 +55,11 @@ export const EventList: React.FC = ({ handleLoadItems, } = useFilters({ permanentFilters, withSearchParams }); + const { ascending, sortingProps } = useEventsOrder({ withSearchParams }); + const { data, isLoading, refreshList, isLoadingMore } = useInfiniteScroll({ useLazyQuery: useLazyGetAllEventsQuery, - args: { ...filteringRequestParams, limit: DEFAULT_TABLE_PAGE_SIZE }, + args: { ...filteringRequestParams, ascending, limit: DEFAULT_TABLE_PAGE_SIZE }, getPaginationParams: (lastEvent) => ({ prev_recorded_at: lastEvent.recorded_at, @@ -79,12 +83,13 @@ export const EventList: React.FC = ({ diff --git a/frontend/src/pages/Fleets/Details/Events/index.tsx b/frontend/src/pages/Fleets/Details/Events/index.tsx index 0af37c4ace..0fbe33c54b 100644 --- a/frontend/src/pages/Fleets/Details/Events/index.tsx +++ b/frontend/src/pages/Fleets/Details/Events/index.tsx @@ -12,6 +12,7 @@ import { ROUTES } from 'routes'; import { useLazyGetAllEventsQuery } from 'services/events'; import { useColumnsDefinitions } from 'pages/Events/List/hooks/useColumnDefinitions'; +import { getEventsOrderSearchParams, useEventsOrder } from 'pages/Events/List/hooks/useEventsOrder'; export const EventsList = () => { const { t } = useTranslation(); @@ -19,9 +20,11 @@ export const EventsList = () => { const paramFleetId = params.fleetId ?? ''; const navigate = useNavigate(); + const { ascending, sortingProps } = useEventsOrder(); + const { data, isLoading, isLoadingMore } = useInfiniteScroll({ useLazyQuery: useLazyGetAllEventsQuery, - args: { limit: DEFAULT_TABLE_PAGE_SIZE, within_fleets: [paramFleetId] }, + args: { limit: DEFAULT_TABLE_PAGE_SIZE, within_fleets: [paramFleetId], ascending }, getPaginationParams: (lastEvent) => ({ prev_recorded_at: lastEvent.recorded_at, @@ -34,7 +37,8 @@ export const EventsList = () => { }); const goToFullView = () => { - navigate(ROUTES.EVENTS.LIST + `?within_fleets=${paramFleetId}`); + const searchParams = new URLSearchParams({ within_fleets: paramFleetId, ...getEventsOrderSearchParams(ascending) }); + navigate(`${ROUTES.EVENTS.LIST}?${searchParams}`); }; const { columns } = useColumnsDefinitions(); @@ -42,6 +46,7 @@ export const EventsList = () => { return (
{ const { t } = useTranslation(); @@ -19,9 +20,11 @@ export const EventsList = () => { const paramInstanceId = params.instanceId ?? ''; const navigate = useNavigate(); + const { ascending, sortingProps } = useEventsOrder(); + const { data, isLoading, isLoadingMore } = useInfiniteScroll({ useLazyQuery: useLazyGetAllEventsQuery, - args: { limit: DEFAULT_TABLE_PAGE_SIZE, target_instances: [paramInstanceId] }, + args: { limit: DEFAULT_TABLE_PAGE_SIZE, target_instances: [paramInstanceId], ascending }, getPaginationParams: (lastEvent) => ({ prev_recorded_at: lastEvent.recorded_at, @@ -34,7 +37,11 @@ export const EventsList = () => { }); const goToFullView = () => { - navigate(ROUTES.EVENTS.LIST + `?target_instances=${paramInstanceId}`); + const searchParams = new URLSearchParams({ + target_instances: paramInstanceId, + ...getEventsOrderSearchParams(ascending), + }); + navigate(`${ROUTES.EVENTS.LIST}?${searchParams}`); }; const { columns } = useColumnsDefinitions(); @@ -42,6 +49,7 @@ export const EventsList = () => { return (
{ const { t } = useTranslation(); @@ -32,8 +33,12 @@ export const Events: React.FC = () => { }, ]); - const goToEventsPage = () => { - navigate(ROUTES.EVENTS.LIST + `?within_projects=${data?.project_id}`); + const goToEventsPage = (ascending: boolean) => { + const searchParams = new URLSearchParams({ + within_projects: data?.project_id ?? '', + ...getEventsOrderSearchParams(ascending), + }); + navigate(`${ROUTES.EVENTS.LIST}?${searchParams}`); }; if (isLoading || !data) @@ -45,13 +50,13 @@ export const Events: React.FC = () => { return ( { + renderHeader={({ ascending }) => { return (
- + } /> diff --git a/frontend/src/pages/Runs/Details/Events/List/index.tsx b/frontend/src/pages/Runs/Details/Events/List/index.tsx index 05c3015a10..86863c27d6 100644 --- a/frontend/src/pages/Runs/Details/Events/List/index.tsx +++ b/frontend/src/pages/Runs/Details/Events/List/index.tsx @@ -13,6 +13,7 @@ import { ROUTES } from 'routes'; import { useLazyGetAllEventsQuery } from 'services/events'; import { useColumnsDefinitions } from 'pages/Events/List/hooks/useColumnDefinitions'; +import { getEventsOrderSearchParams, useEventsOrder } from 'pages/Events/List/hooks/useEventsOrder'; import { RUN_DETAILS_REFRESH_LIST_EVENT } from '../../constants'; @@ -22,9 +23,11 @@ export const EventsList = () => { const paramRunId = params.runId ?? ''; const navigate = useNavigate(); + const { ascending, sortingProps } = useEventsOrder(); + const { data, isLoading, isLoadingMore, refreshList } = useInfiniteScroll({ useLazyQuery: useLazyGetAllEventsQuery, - args: { limit: DEFAULT_TABLE_PAGE_SIZE, within_runs: [paramRunId] }, + args: { limit: DEFAULT_TABLE_PAGE_SIZE, within_runs: [paramRunId], ascending }, getPaginationParams: (lastEvent) => ({ prev_recorded_at: lastEvent.recorded_at, @@ -39,7 +42,8 @@ export const EventsList = () => { }); const goToFullView = () => { - navigate(ROUTES.EVENTS.LIST + `?within_runs=${paramRunId}`); + const searchParams = new URLSearchParams({ within_runs: paramRunId, ...getEventsOrderSearchParams(ascending) }); + navigate(`${ROUTES.EVENTS.LIST}?${searchParams}`); }; const { columns } = useColumnsDefinitions(); @@ -47,6 +51,7 @@ export const EventsList = () => { return (
{ return runData.jobs.find((job) => job.job_spec.job_name === paramJobName)?.job_submissions?.[0]?.id; }, [runData]); + const { ascending, sortingProps } = useEventsOrder(); + const { data, isLoading, isLoadingMore } = useInfiniteScroll({ useLazyQuery: useLazyGetAllEventsQuery, - args: { limit: DEFAULT_TABLE_PAGE_SIZE, target_jobs: jobId ? [jobId] : undefined }, + args: { limit: DEFAULT_TABLE_PAGE_SIZE, target_jobs: jobId ? [jobId] : undefined, ascending }, skip: !jobId, getPaginationParams: (lastEvent) => ({ @@ -46,7 +49,8 @@ export const EventsList = () => { }); const goToFullView = () => { - navigate(ROUTES.EVENTS.LIST + `?target_jobs=${jobId}`); + const searchParams = new URLSearchParams({ target_jobs: jobId ?? '', ...getEventsOrderSearchParams(ascending) }); + navigate(`${ROUTES.EVENTS.LIST}?${searchParams}`); }; const { items, collectionProps } = useCollection(data, { @@ -58,6 +62,7 @@ export const EventsList = () => { return (
{ const { t } = useTranslation(); @@ -33,8 +34,12 @@ export const Events: React.FC = () => { }, ]); - const goToEventsPage = () => { - navigate(ROUTES.EVENTS.LIST + `?${filterParamName}=${data?.id}`); + const goToEventsPage = (ascending: boolean) => { + const searchParams = new URLSearchParams({ + [filterParamName]: data?.id ?? '', + ...getEventsOrderSearchParams(ascending), + }); + navigate(`${ROUTES.EVENTS.LIST}?${searchParams}`); }; if (isLoading || !data) @@ -46,7 +51,7 @@ export const Events: React.FC = () => { return ( { + renderHeader={({ ascending }) => { return (
{ { text: 'Target user', id: 'target_users' }, ]} /> - + } />