diff --git a/studio/components/interfaces/Settings/Logs/LogPanel.tsx b/studio/components/interfaces/Settings/Logs/LogPanel.tsx index cdb21ef08d1..2eb82e391c5 100644 --- a/studio/components/interfaces/Settings/Logs/LogPanel.tsx +++ b/studio/components/interfaces/Settings/Logs/LogPanel.tsx @@ -17,6 +17,7 @@ import dayjs from 'dayjs' import utc from 'dayjs/plugin/utc' interface Props { defaultSearchValue?: string + defaultFromValue?: string defaultToValue?: string templates?: any isLoading: boolean @@ -43,6 +44,7 @@ const LogPanel: FC = ({ onRefresh, onSearch = () => {}, defaultSearchValue = '', + defaultFromValue = '', defaultToValue = '', onCustomClick, onSelectTemplate, @@ -51,6 +53,7 @@ const LogPanel: FC = ({ }) => { const [search, setSearch] = useState('') const [to, setTo] = useState({ value: '', error: '' }) + const [from, setFrom] = useState({ value: '', error: '' }) const [defaultTimestamp, setDefaultTimestamp] = useState(dayjs().utc().toISOString()) // Sync local state with provided default value @@ -64,9 +67,12 @@ const LogPanel: FC = ({ if (to.value !== defaultToValue) { setTo({ value: defaultToValue, error: '' }) } - }, [defaultToValue]) + if (from.value !== defaultFromValue) { + setFrom({ value: defaultFromValue, error: '' }) + } + }, [defaultToValue, defaultFromValue]) - const handleFromChange = (e: React.ChangeEvent) => { + const handleToChange = (e: React.ChangeEvent) => { const value = e.target.value if (value !== '' && isNaN(Date.parse(value))) { setTo({ value, error: 'Invalid ISO 8601 timestamp' }) @@ -74,16 +80,26 @@ const LogPanel: FC = ({ setTo({ value, error: '' }) } } - const handleFromReset = async () => { + + const handleFromChange = (e: React.ChangeEvent) => { + const value = e.target.value + if (value !== '' && isNaN(Date.parse(value))) { + setFrom({ value, error: 'Invalid ISO 8601 timestamp' }) + } else { + setFrom({ value, error: '' }) + } + } + const handleReset = async () => { setTo({ value: '', error: '' }) + setFrom({ value: '', error: '' }) const value = dayjs().utc().toISOString() setDefaultTimestamp(value) - onSearch({ query: search, to: '' }) + onSearch({ query: search, to: '', from: '' }) } - const handleSearch = () => onSearch({ query: search, to: to.value }) + const handleSearch = () => onSearch({ query: search, to: to.value, from: from.value }) - const showFromReset = to.value !== '' + const showReset = to.value !== '' || from.value !== '' return (
@@ -147,14 +163,24 @@ const LogPanel: FC = ({ align="end" portalled overlay={ - + + +
, - ]} - /> + +
+ } > - {showFromReset && ( + {showReset && (
diff --git a/studio/components/interfaces/Settings/Logs/Logs.types.ts b/studio/components/interfaces/Settings/Logs/Logs.types.ts index 74d7b16f790..06b868a0ff1 100644 --- a/studio/components/interfaces/Settings/Logs/Logs.types.ts +++ b/studio/components/interfaces/Settings/Logs/Logs.types.ts @@ -1,7 +1,7 @@ interface Metadata { [key: string]: string | number | Object | Object[] } -export type LogSearchCallback = (filters: { query: string; to?: string; toMicro?: number }) => void +export type LogSearchCallback = (filters: { query: string; to?: string; from?: string; fromMicro?: number, toMicro?: number }) => void export interface LogsEndpointParams { // project ref diff --git a/studio/hooks/analytics/useLogsPreview.tsx b/studio/hooks/analytics/useLogsPreview.tsx index 3924868b2ef..9dac008861e 100644 --- a/studio/hooks/analytics/useLogsPreview.tsx +++ b/studio/hooks/analytics/useLogsPreview.tsx @@ -23,6 +23,7 @@ interface Data { pageSize: number filters: T params: LogsEndpointParams + oldestTimestamp?: string } interface Handlers { loadOlder: () => void @@ -58,6 +59,8 @@ function useLogsPreview( rawSql: genDefaultQuery(table), period_start: '', period_end: '', + timestamp_start: '', + timestamp_end: '', }) const [filters, setFilters] = useState(options.initialFilters) @@ -122,9 +125,18 @@ function useLogsPreview( error = response.error } }) - + const oldestTimestamp = logData[logData.length - 1]?.timestamp return [ - { newCount, logData, isLoading: isValidating, pageSize: size, error, filters, params }, + { + newCount, + logData, + isLoading: isValidating, + pageSize: size, + error, + filters, + params, + oldestTimestamp: oldestTimestamp ? String(oldestTimestamp) : undefined, + }, { setFrom: (value) => setParams((prev) => ({ ...prev, timestamp_start: value })), setTo: (value) => setParams((prev) => ({ ...prev, timestamp_end: value })), diff --git a/studio/pages/project/[ref]/settings/logs/[type].tsx b/studio/pages/project/[ref]/settings/logs/[type].tsx index a0ee70ebacf..b2daaad4b80 100644 --- a/studio/pages/project/[ref]/settings/logs/[type].tsx +++ b/studio/pages/project/[ref]/settings/logs/[type].tsx @@ -54,13 +54,13 @@ import useLogsPreview from 'hooks/analytics/useLogsPreview' */ export const LogPage: NextPage = () => { const router = useRouter() - const { ref, type, s, te } = router.query + const { ref, type, s, te, ts } = router.query const [showChart, setShowChart] = useState(true) const table = type === 'api' ? LogsTableName.EDGE : LogsTableName.POSTGRES const [ - { error, logData, params, newCount, filters, isLoading }, - { loadOlder, setFilters, refresh, setTo }, + { error, logData, params, newCount, filters, isLoading, oldestTimestamp }, + { loadOlder, setFilters, refresh, setTo, setFrom }, ] = useLogsPreview(ref as string, table, { initialFilters: { search_query: s as string }, whereStatementFactory: (filterObj) => @@ -78,8 +78,25 @@ export const LogPage: NextPage = () => { } else { setTo('') } - }, [s, te]) + if (ts) { + setFrom(ts as string) + } else { + setFrom('') + } + }, [s, te, ts]) + useEffect(() => { + router.push({ + pathname: router.pathname, + query: { + ...router.query, + q: undefined, + s: filters.search_query || '', + ts: params.timestamp_start, + te: params.timestamp_end, + }, + }) + }, [params.timestamp_end, params.timestamp_start, filters.search_query]) const onSelectTemplate = (template: LogTemplate) => { setFilters((prev) => ({ ...prev, search_query: template.searchString })) } @@ -91,23 +108,23 @@ export const LogPage: NextPage = () => { query: { ...router.query, te: undefined, + ts: undefined, }, }) } - const handleSearch: LogSearchCallback = ({ query, to, toMicro }) => { - const unixMicro = toMicro ? toMicro : dayjs(to).valueOf() * 1000 - setTo(unixMicro ? String(unixMicro) : '') + const handleSearch: LogSearchCallback = ({ query, to, from, fromMicro, toMicro }) => { + let toValue, fromValue + if (to || toMicro) { + toValue = toMicro ? toMicro : dayjs(to).valueOf() * 1000 + + setTo(String(toValue)) + } + if (from || fromMicro) { + const fromValue = fromMicro ? fromMicro : dayjs(from).valueOf() * 1000 + setFrom(String(fromValue)) + } setFilters((prev) => ({ ...prev, search_query: query || '' })) - router.push({ - pathname: router.pathname, - query: { - ...router.query, - q: undefined, - s: query || '', - te: unixMicro, - }, - }) } return ( @@ -128,6 +145,13 @@ export const LogPage: NextPage = () => { defaultToValue={ params.timestamp_end ? dayjs(Number(params.timestamp_end) / 1000).toISOString() : '' } + defaultFromValue={ + params.timestamp_start + ? dayjs(Number(params.timestamp_start) / 1000).toISOString() + : oldestTimestamp + ? dayjs(Number(oldestTimestamp) / 1000).toISOString() + : '' + } onCustomClick={() => { router.push(`/project/${ref}/settings/logs/explorer?q=${params.rawSql}`) }} diff --git a/studio/tests/pages/projects/LogPanel.test.js b/studio/tests/pages/projects/LogPanel.test.js index ce74819d6f5..251a0fc8678 100644 --- a/studio/tests/pages/projects/LogPanel.test.js +++ b/studio/tests/pages/projects/LogPanel.test.js @@ -78,42 +78,47 @@ test('reset search filter', async () => { expect(() => screen.getByDisplayValue(/something123/)).toThrow() }) -test('timestamp To filter default value', async () => { +test('timestamp to/from filter default value', async () => { render() userEvent.click(await screen.findByText('Custom')) await screen.findByDisplayValue('2022-01-18T10:43:39+0000') // TODO: use screen.findByLabelText when https://github.com/supabase/ui/issues/310 is resolved await screen.findByText('To') + await screen.findByText('From') }) -test('timestamp from filter error handling', async () => { +test('timestamp to/from filter error handling', async () => { const mockFn = jest.fn() render() userEvent.click(await screen.findByText(/Now/)) // display iso timestamp const year = new Date().getFullYear() - const input = await screen.findByDisplayValue(RegExp(year)) + const inputs = await screen.findAllByDisplayValue(RegExp(year)) + expect(inputs.length).toBe(2) + const input = inputs[0] userEvent.clear(input) userEvent.type(input, '123456') await screen.findByText(/[iI]nvalid ISO 8601 timestamp/) }) -test('timestamp To filter value change', async () => { +test('timestamp to/from filter value change', async () => { const mockFn = jest.fn() render() userEvent.click(await screen.findByText(/Now/)) // display iso timestamp const year = new Date().getFullYear() - const input = await screen.findByDisplayValue(RegExp(year)) + const inputs = await screen.findAllByDisplayValue(RegExp(year)) - // replace the input's value - userEvent.clear(input) + for (const input of inputs) { + // replace the input's value + userEvent.clear(input) - // get time 20 mins before - const newDate = new Date() - newDate.setMinutes(new Date().getMinutes() - 20) - userEvent.type(input, newDate.toISOString()) + // get time 20 mins before + const newDate = new Date() + newDate.setMinutes(new Date().getMinutes() - 20) + userEvent.type(input, newDate.toISOString()) + } // input actions const set = await screen.findByRole('button', { name: 'Set' }) diff --git a/studio/tests/pages/projects/logs-preview.test.js b/studio/tests/pages/projects/logs-preview.test.js index f7579add0db..2b1dfb19960 100644 --- a/studio/tests/pages/projects/logs-preview.test.js +++ b/studio/tests/pages/projects/logs-preview.test.js @@ -178,7 +178,7 @@ test('s= query param will populate the search bar', async () => { expect(get).toHaveBeenCalledWith(expect.stringContaining('someSearch')) }) -test('te= query param will populate the timestamp from input', async () => { +test('te= query param will populate the timestamp to input', async () => { // get time 20 mins before const newDate = new Date() newDate.setMinutes(new Date().getMinutes() - 20) @@ -197,6 +197,26 @@ test('te= query param will populate the timestamp from input', async () => { userEvent.click(await screen.findByText('Custom')) await screen.findByDisplayValue(isoString) }) +test('ts= query param will populate the timestamp from input', async () => { + // get time 20 mins before + const newDate = new Date() + newDate.setMinutes(new Date().getMinutes() - 20) + const isoString = newDate.toISOString() + const unixMicro = newDate.getTime() * 1000 //microseconds + const router = defaultRouterMock() + router.query = { ...router.query, ts: unixMicro } + useRouter.mockReturnValue(router) + render() + + await waitFor(() => { + expect(get).toHaveBeenCalledWith( + expect.stringContaining(`timestamp_start=${encodeURIComponent(unixMicro)}`) + ) + }) + userEvent.click(await screen.findByText('Custom')) + await screen.findByDisplayValue(isoString) +}) + test('load older btn will fetch older logs', async () => { get.mockImplementation((url) => {