diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts index 1cdef08c1b..26c37a9e0a 100644 --- a/frontend/src/components/index.ts +++ b/frontend/src/components/index.ts @@ -59,6 +59,8 @@ export type { AvatarProps } from '@cloudscape-design/chat-components/avatar'; export { default as LineChart } from '@cloudscape-design/components/line-chart'; export { default as PropertyFilter } from '@cloudscape-design/components/property-filter'; export type { PropertyFilterProps } from '@cloudscape-design/components/property-filter'; +export { default as DateRangePicker } from '@cloudscape-design/components/date-range-picker'; +export type { DateRangePickerProps } from '@cloudscape-design/components/date-range-picker'; export type { LineChartProps } from '@cloudscape-design/components/line-chart/interfaces'; export type { ModalProps } from '@cloudscape-design/components/modal'; export { default as AnchorNavigation } from '@cloudscape-design/components/anchor-navigation'; diff --git a/frontend/src/hooks/useInfiniteScroll.ts b/frontend/src/hooks/useInfiniteScroll.ts index 39391e8418..3e38697d5e 100644 --- a/frontend/src/hooks/useInfiniteScroll.ts +++ b/frontend/src/hooks/useInfiniteScroll.ts @@ -18,6 +18,9 @@ type UseInfinityParams = { args: { limit?: number } & Args; getResponseItems?: (listItem: DataItem) => Partial; getPaginationParams: (listItem: DataItem) => Partial; + // Items starting from the first one matching isPastEnd are dropped and no more items are loaded. + // Changing isPastEnd reloads the list, so it should be memoized. + isPastEnd?: (listItem: DataItem) => boolean; skip?: boolean; // options?: UseQueryStateOptions, Record>; }; @@ -27,6 +30,7 @@ export const useInfiniteScroll = ({ getPaginationParams, // options, args, + isPastEnd, skip, }: UseInfinityParams) => { const [data, setData] = useState>([]); @@ -37,6 +41,7 @@ export const useInfiniteScroll = ({ const lastRequestParams = useRef(undefined); const { limit, ...argsProp } = args; const lastArgsProps = useRef | null>(null); + const lastIsPastEnd = useRef(undefined); // Incremented on every list reset, so that responses to outdated requests can be ignored const requestGenerationRef = useRef(0); @@ -55,6 +60,17 @@ export const useInfiniteScroll = ({ return result; }; + const truncatePastEnd = (items: ListResponse): ListResponse => { + const endIndex = isPastEnd ? items.findIndex(isPastEnd) : -1; + + if (endIndex === -1) { + return items; + } + + isDisabledMoreRef.current = true; + return items.slice(0, endIndex); + }; + const getEmptyList = () => { const requestGeneration = ++requestGenerationRef.current; isLoadingRef.current = true; @@ -70,10 +86,10 @@ export const useInfiniteScroll = ({ isDisabledMoreRef.current = false; if ('data' in result) { - setData(result.data as ListResponse); + setData(truncatePastEnd(result.data as ListResponse)); setTotalCount(result.total_count); } else { - setData(result as ListResponse); + setData(truncatePastEnd(result as ListResponse)); setTotalCount(); } }) @@ -94,11 +110,12 @@ export const useInfiniteScroll = ({ }; useEffect(() => { - if (!isEqual(argsProp, lastArgsProps.current) && !skip) { + if ((!isEqual(argsProp, lastArgsProps.current) || isPastEnd !== lastIsPastEnd.current) && !skip) { getEmptyList(); lastArgsProps.current = argsProp as Args; + lastIsPastEnd.current = isPastEnd; } - }, [argsProp, lastArgsProps, skip]); + }, [argsProp, lastArgsProps, isPastEnd, skip]); const getMore = async () => { if (isLoadingRef.current || isDisabledMoreRef.current || skip || !data.length) { @@ -125,10 +142,10 @@ export const useInfiniteScroll = ({ let listResponse: ListResponse; if ('data' in result) { - listResponse = result.data; + listResponse = truncatePastEnd(result.data); setTotalCount(result.total_count); } else { - listResponse = result; + listResponse = truncatePastEnd(result); setTotalCount(); } diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index d476ea5737..eb293495b2 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -741,7 +741,11 @@ "recorded_at": "Recorded At", "actor": "Actor", "targets": "Targets", - "message": "Message" + "message": "Message", + "time_range_placeholder": "Filter by time range", + "time_range_incomplete": "Select a start and end date for the time range.", + "time_range_start_after_end": "The start date must be before the end date.", + "time_range_invalid_duration": "The duration must be a positive number." }, "users": { diff --git a/frontend/src/pages/Events/List/hooks/useFilters.ts b/frontend/src/pages/Events/List/hooks/useFilters.ts index ca791e67c3..6d914c1831 100644 --- a/frontend/src/pages/Events/List/hooks/useFilters.ts +++ b/frontend/src/pages/Events/List/hooks/useFilters.ts @@ -223,7 +223,7 @@ export const useFilters = ({ 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 + // Keep search params that are not managed by the property filter, e.g. the sort order and time range prevSearchParams.forEach((value, key) => { if (!filterKeysValues.includes(key)) { searchParams.append(key, value); diff --git a/frontend/src/pages/Events/List/hooks/useTimeRange.ts b/frontend/src/pages/Events/List/hooks/useTimeRange.ts new file mode 100644 index 0000000000..64ba02c1c8 --- /dev/null +++ b/frontend/src/pages/Events/List/hooks/useTimeRange.ts @@ -0,0 +1,181 @@ +import { useCallback, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useSearchParams } from 'react-router-dom'; +import { sub } from 'date-fns'; + +import type { DateRangePickerProps } from 'components'; + +const searchParamKeys = { + FROM: 'from', + TO: 'to', + LAST: 'last', +}; + +const timeUnitCodes: Record = { + second: 's', + minute: 'min', + hour: 'h', + day: 'd', + week: 'w', + month: 'mo', + year: 'y', +}; + +export const relativeOptions: DateRangePickerProps.RelativeOption[] = [ + { key: 'last-15-minutes', amount: 15, unit: 'minute', type: 'relative' }, + { key: 'last-1-hour', amount: 1, unit: 'hour', type: 'relative' }, + { key: 'last-1-day', amount: 1, unit: 'day', type: 'relative' }, + { key: 'last-7-days', amount: 7, unit: 'day', type: 'relative' }, + { key: 'last-30-days', amount: 30, unit: 'day', type: 'relative' }, +]; + +export const useTimeRange = ({ withSearchParams, ascending }: { withSearchParams?: boolean; ascending: boolean }) => { + const { t } = useTranslation(); + const [searchParams, setSearchParams] = useSearchParams(); + + const [timeRange, setTimeRange] = useState(() => + withSearchParams ? searchParamsToTimeRange(searchParams) : null, + ); + const [now, setNow] = useState(() => new Date()); + + const updateSearchParams = (update: (searchParams: URLSearchParams) => void) => { + if (!withSearchParams) { + return; + } + + setSearchParams((prevSearchParams) => { + const nextSearchParams = new URLSearchParams(prevSearchParams); + update(nextSearchParams); + return nextSearchParams; + }); + }; + + const onChangeTimeRange: DateRangePickerProps['onChange'] = ({ detail }) => { + setTimeRange(detail.value); + setNow(new Date()); + updateSearchParams((params) => timeRangeToSearchParams(detail.value, params)); + }; + + // Relative time range bounds are calculated from the current time, + // so updating it reloads the list with the new bounds. + const refreshRelativeTimeRange = (): boolean => { + if (timeRange?.type !== 'relative') { + return false; + } + + setNow(new Date()); + return true; + }; + + const { start, end } = useMemo(() => getTimeRangeBounds(timeRange, now), [timeRange, now]); + + // The list starts from the time range bound that comes first in the sort order. + // The other bound is enforced on the client side by isPastEnd. + const requestParams = useMemo>(() => { + // prev_recorded_at is exclusive, so shift the start bound to include events recorded exactly at it + const firstRecordedAt = ascending ? start && new Date(start.getTime() - 1) : end; + + return { + ascending, + ...(firstRecordedAt ? { prev_recorded_at: firstRecordedAt.toISOString() } : {}), + }; + }, [ascending, start, end]); + + const isPastEnd = useCallback( + (event: IEvent) => { + const recordedAt = new Date(event.recorded_at); + + if (ascending) { + return end !== undefined && recordedAt >= end; + } + + return start !== undefined && recordedAt < start; + }, + [ascending, start, end], + ); + + const isValidRange: DateRangePickerProps.ValidationFunction = (range) => { + if (range?.type === 'absolute') { + if (!range.startDate || !range.endDate) { + return { valid: false, errorMessage: t('events.time_range_incomplete') }; + } + + if (new Date(range.startDate) > new Date(range.endDate)) { + return { valid: false, errorMessage: t('events.time_range_start_after_end') }; + } + } + + if (range?.type === 'relative' && !(range.amount > 0)) { + return { valid: false, errorMessage: t('events.time_range_invalid_duration') }; + } + + return { valid: true }; + }; + + return { + timeRange, + onChangeTimeRange, + isValidRange, + refreshRelativeTimeRange, + requestParams, + isPastEnd, + } as const; +}; + +const getTimeRangeBounds = (timeRange: DateRangePickerProps.Value | null, now: Date): { start?: Date; end?: Date } => { + if (!timeRange) { + return {}; + } + + if (timeRange.type === 'relative') { + return { start: sub(now, { [`${timeRange.unit}s`]: timeRange.amount }) }; + } + + // The picker sets the end time with second precision, e.g. 23:59:59, + // so include all events recorded within that second. + return { + start: new Date(timeRange.startDate), + end: new Date(new Date(timeRange.endDate).getTime() + 1000), + }; +}; + +const searchParamsToTimeRange = (searchParams: URLSearchParams): DateRangePickerProps.Value | null => { + const from = searchParams.get(searchParamKeys.FROM); + const to = searchParams.get(searchParamKeys.TO); + + if (from && to) { + return { type: 'absolute', startDate: from, endDate: to }; + } + + const last = searchParams.get(searchParamKeys.LAST); + const match = last?.match(/^(\d+)([a-z]+)$/); + const unit = Object.entries(timeUnitCodes).find(([, code]) => code === match?.[2])?.[0]; + + if (!match || !unit) { + return null; + } + + const value: DateRangePickerProps.RelativeValue = { + type: 'relative', + amount: Number(match[1]), + unit: unit as DateRangePickerProps.TimeUnit, + }; + const option = relativeOptions.find(({ amount, unit }) => amount === value.amount && unit === value.unit); + + return option ? { ...value, key: option.key } : value; +}; + +const timeRangeToSearchParams = (timeRange: DateRangePickerProps.Value | null, searchParams: URLSearchParams) => { + searchParams.delete(searchParamKeys.FROM); + searchParams.delete(searchParamKeys.TO); + searchParams.delete(searchParamKeys.LAST); + + if (timeRange?.type === 'absolute') { + searchParams.set(searchParamKeys.FROM, timeRange.startDate); + searchParams.set(searchParamKeys.TO, timeRange.endDate); + } + + if (timeRange?.type === 'relative') { + searchParams.set(searchParamKeys.LAST, `${timeRange.amount}${timeUnitCodes[timeRange.unit]}`); + } +}; diff --git a/frontend/src/pages/Events/List/index.tsx b/frontend/src/pages/Events/List/index.tsx index ac6338daf8..6837af5c51 100644 --- a/frontend/src/pages/Events/List/index.tsx +++ b/frontend/src/pages/Events/List/index.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; -import { Loader, PropertyFilter, Table } from 'components'; +import { DateRangePicker, Loader, PropertyFilter, Table } from 'components'; import { TableProps } from 'components'; import { DEFAULT_TABLE_PAGE_SIZE } from 'consts'; @@ -13,8 +13,9 @@ import { useLazyGetAllEventsQuery } from 'services/events'; import { useColumnsDefinitions } from './hooks/useColumnDefinitions'; import { useEventsOrder } from './hooks/useEventsOrder'; import { useFilters } from './hooks/useFilters'; +import { relativeOptions, useTimeRange } from './hooks/useTimeRange'; -import styles from 'pages/Runs/List/styles.module.scss'; +import styles from './styles.module.scss'; type RenderHeaderArgs = { refreshAction?: () => void; @@ -57,9 +58,19 @@ export const EventList: React.FC = ({ const { ascending, sortingProps } = useEventsOrder({ withSearchParams }); + const { + timeRange, + onChangeTimeRange, + isValidRange, + refreshRelativeTimeRange, + requestParams: timeRangeRequestParams, + isPastEnd, + } = useTimeRange({ withSearchParams, ascending }); + const { data, isLoading, refreshList, isLoadingMore } = useInfiniteScroll({ useLazyQuery: useLazyGetAllEventsQuery, - args: { ...filteringRequestParams, ascending, limit: DEFAULT_TABLE_PAGE_SIZE }, + args: { ...filteringRequestParams, ...timeRangeRequestParams, limit: DEFAULT_TABLE_PAGE_SIZE }, + isPastEnd, getPaginationParams: (lastEvent) => ({ prev_recorded_at: lastEvent.recorded_at, @@ -77,6 +88,12 @@ export const EventList: React.FC = ({ const { columns } = useColumnsDefinitions(); + const refreshAction = () => { + if (!refreshRelativeTimeRange()) { + refreshList(); + } + }; + const loading = isLoading; return ( @@ -89,7 +106,7 @@ export const EventList: React.FC = ({ loading={loading} loadingText={t('common.loading')} stickyHeader={true} - header={renderHeader?.({ refreshAction: refreshList, disabledRefresh: loading, ascending })} + header={renderHeader?.({ refreshAction, disabledRefresh: loading, ascending })} filter={ showFilters && (
@@ -112,6 +129,15 @@ export const EventList: React.FC = ({ onLoadItems={handleLoadItems} />
+ + ) } diff --git a/frontend/src/pages/Events/List/styles.module.scss b/frontend/src/pages/Events/List/styles.module.scss new file mode 100644 index 0000000000..237e71a1ed --- /dev/null +++ b/frontend/src/pages/Events/List/styles.module.scss @@ -0,0 +1,11 @@ +.selectFilters { + display: flex; + flex-wrap: wrap; + gap: 8px 20px; + + .propertyFilter { + max-width: 640px; + flex-grow: 1; + min-width: 0; + } +}