diff --git a/studio/components/interfaces/Settings/Logs/LogPanel.tsx b/studio/components/interfaces/Settings/Logs/LogPanel.tsx index 7d0f6d0abc4..ed80630b45e 100644 --- a/studio/components/interfaces/Settings/Logs/LogPanel.tsx +++ b/studio/components/interfaces/Settings/Logs/LogPanel.tsx @@ -1,4 +1,4 @@ -import { FC } from 'react' +import { FC, useEffect, useState } from 'react' import { Button, Input, @@ -13,13 +13,11 @@ import { import { LogTemplate } from '.' interface Props { - searchValue?: string + defaultSearchValue?: string templates?: any isLoading: boolean isCustomQuery: boolean newCount: number - showReset: boolean - onReset: () => void onRefresh?: () => void onSearch?: (query: string) => void onCustomClick?: () => void @@ -30,18 +28,25 @@ interface Props { * Logs control panel header + wrapper */ const LogPanel: FC = ({ - searchValue, templates = [], isLoading, isCustomQuery, newCount, - showReset, - onReset, onRefresh, onSearch = () => {}, + defaultSearchValue = '', onCustomClick, onSelectTemplate, }) => { + const [search, setSearch] = useState('') + + // sync local state with provided default value + useEffect(() => { + if (search !== defaultSearchValue) { + setSearch(defaultSearchValue) + } + }, [defaultSearchValue]) + return (
@@ -94,16 +99,43 @@ const LogPanel: FC = ({
- } - placeholder="Search event messages" - onChange={(e) => onSearch(e.target.value)} - value={searchValue} - actions={ - showReset && - } - /> + {/* wrap with form so that if user presses enter, the search value will submit automatically */} +
diff --git a/studio/components/interfaces/Settings/Logs/LogTable.tsx b/studio/components/interfaces/Settings/Logs/LogTable.tsx index fb02efb0c98..9709dc6965d 100644 --- a/studio/components/interfaces/Settings/Logs/LogTable.tsx +++ b/studio/components/interfaces/Settings/Logs/LogTable.tsx @@ -1,5 +1,5 @@ import dayjs from 'dayjs' -import { useEffect, useState } from 'react' +import { useEffect, useState, useMemo } from 'react' import { Typography } from '@supabase/ui' import DataGrid from '@supabase/react-data-grid' @@ -43,10 +43,12 @@ const LogTable = ({ isCustomQuery, data }: Props) => { }, })) - const logMap = (data || []).reduce((acc: any, d) => { - acc[d.id] = d - return acc - }, {}) + const logMap: { [id: string]: LogData } = useMemo(() => { + return (data || []).reduce((acc: any, d) => { + acc[d.id] = d + return acc + }, {}) + }, [JSON.stringify(data)]) useEffect(() => { if (!data) return @@ -60,6 +62,9 @@ const LogTable = ({ isCustomQuery, data }: Props) => { // [Joshen] Hmm quite hacky now, but will do const maxHeight = isCustomQuery ? 'calc(100vh - 42px - 10rem)' : 'calc(100vh - 42px - 3rem)' + const logDataRows = useMemo(() => { + return Object.values(logMap).sort((a, b) => a.timestamp - b.timestamp) + }, [JSON.stringify(Object.keys(logMap))]) return (
{ } columns={columns as any} rowClass={(r) => `${r.id === focusedLog?.id ? 'bg-green-800' : 'cursor-pointer'}`} - rows={data} + rows={logDataRows} rowKeyGetter={(r) => r.id} onRowClick={(r) => setFocusedLog(logMap[r.id])} /> diff --git a/studio/pages/project/[ref]/settings/logs/[type].tsx b/studio/pages/project/[ref]/settings/logs/[type].tsx index ea65f2bcb66..75c7af9cfd2 100644 --- a/studio/pages/project/[ref]/settings/logs/[type].tsx +++ b/studio/pages/project/[ref]/settings/logs/[type].tsx @@ -1,10 +1,9 @@ -import useSWR from 'swr' -import debounce from 'lodash/debounce' -import { useEffect, useRef, useState } from 'react' +import useSWR, { KeyLoader } from 'swr' +import React, { useEffect, useRef, useState } from 'react' import { NextPage } from 'next' import { useRouter } from 'next/router' import { observer } from 'mobx-react-lite' -import { Typography, IconLoader, IconAlertCircle } from '@supabase/ui' +import { Typography, IconLoader, IconAlertCircle, IconRewind, Button } from '@supabase/ui' import { withAuth } from 'hooks' import { get } from 'lib/common/fetch' @@ -18,54 +17,99 @@ import { Logs, LogTemplate, TEMPLATES, + LogData, } from 'components/interfaces/Settings/Logs' import { uuidv4 } from 'lib/helpers' +import useSWRInfinite from 'swr/infinite' +import { isUndefined } from 'lodash' +import Flag from 'components/ui/Flag/Flag' /** * Acts as a container component for the entire log display + * + * */ export const LogPage: NextPage = () => { const router = useRouter() const { ref, type } = router.query const [editorId, setEditorId] = useState(uuidv4()) - const [search, setSearch] = useState('') - const [queryParams, setQueryParams] = useState('') - const [where, setWhere] = useState('') + const [editorValue, setEditorValue] = useState('') const [mode, setMode] = useState<'simple' | 'custom'>('simple') const [latestRefresh, setLatestRefresh] = useState(new Date().toISOString()) - + const [params, setParams] = useState({ + type: '', + search_query: '', + where: '', + timestamp_start: '', + timestamp_end: '', + }) const title = `Logs - ${LOG_TYPE_LABEL_MAPPING[type as string]}` - const debouncedQueryParams = useRef(debounce(setQueryParams, 600)).current useEffect(() => { - const params = { - type: type as string, - search_query: search || '', - where: mode === 'custom' ? where || '' : '', + setParams({ ...params, type: type as string }) + }, [type]) + + const genQueryParams = (params: { [k: string]: string }) => { + // remove keys which are empty strings, null, or undefined + for (const k in params) { + const v = params[k] + if (v === null || v === '' || isUndefined(v)) { + delete params[k] + } } const qs = new URLSearchParams(params).toString() - debouncedQueryParams(qs) - return () => debouncedQueryParams.cancel() - }, [mode, search, where, type]) - + return qs + } // handle log fetching - const logUrl = `${API_URL}/projects/${ref}/logs?${queryParams}` - const { data, isValidating, mutate } = useSWR(logUrl, get, { revalidateOnFocus: false }) - const { data: logData, error } = data || {} + const getKeyLogs: KeyLoader = (_pageIndex: number, prevPageData) => { + let queryParams + // if prev page data is 100 items, could possibly have more records that are not yet fetched within this interval + if (prevPageData === null) { + // reduce interval window limit by using the timestamp of the last log + queryParams = genQueryParams(params) + } else { + const len = prevPageData.data.length + const { timestamp: tsLimit }: LogData = prevPageData.data[len - 1] + // create new key from params + queryParams = genQueryParams({ ...params, timestamp_end: String(tsLimit) }) + } - const countUrl = `${API_URL}/projects/${ref}/logs?${queryParams}&count=true&period_start=${latestRefresh}` + const logUrl = `${API_URL}/projects/${ref}/logs?${queryParams}` + return logUrl + } + const { + data = [], + isValidating, + mutate, + size, + setSize, + } = useSWRInfinite(getKeyLogs, get, { revalidateOnFocus: false }) + + // const { data, isValidating, mutate } = useSWR(logUrl, get, { revalidateOnFocus: false }) + let logData: LogData[] = [] + let error: null | string = null + + data.forEach((response: Logs) => { + if (response && response.data) { + logData = [...logData, ...response.data] + } + if (!error && response && response.error) { + error = response.error + } + }) + + const countUrl = `${API_URL}/projects/${ref}/logs?${genQueryParams({ + ...params, + count: String(true), + period_start: String(latestRefresh), + })}` const { data: countData } = useSWR(countUrl, get, { refreshInterval: 5000 }) const newCount = countData?.data?.[0]?.count ?? 0 - const handleReset = () => { - setWhere('') - setSearch('') - setEditorId(uuidv4()) - } - const handleRefresh = () => { setLatestRefresh(new Date().toISOString()) + setSize(1) mutate() } @@ -81,14 +125,19 @@ export const LogPage: NextPage = () => { const onSelectTemplate = (template: LogTemplate) => { setMode(template.mode) if (template.mode === 'simple') { - setSearch(template.searchString) - setWhere('') + setParams((prev) => ({ ...prev, search_query: template.searchString, where: '' })) } else { - setWhere(template.searchString) - setSearch('') + setEditorValue(template.searchString) + setParams((prev) => ({ ...prev, where: template.searchString, search_query: '' })) setEditorId(uuidv4()) } } + const handleEditorSubmit = () => { + setParams((prev) => ({ ...prev, where: editorValue })) + } + const handleSearch = (v: string) => { + setParams((prev) => ({ ...prev, search_query: v || '' })) + } return ( @@ -97,25 +146,41 @@ export const LogPage: NextPage = () => { isCustomQuery={mode === 'custom'} isLoading={isValidating} newCount={newCount} - showReset={search.length > 0} - searchValue={search} templates={TEMPLATES} - onReset={handleReset} onRefresh={handleRefresh} - onSearch={setSearch} + onSearch={handleSearch} + defaultSearchValue={params.search_query} onCustomClick={handleModeToggle} onSelectTemplate={onSelectTemplate} /> {mode === 'custom' && ( -
- setWhere(v || '')} - onInputRun={handleRefresh} - /> -
+ +
+ setEditorValue(v || '')} + onInputRun={handleRefresh} + /> +
+
+ {editorValue && ( + + )} + +
+
)}
{isValidating && ( @@ -135,6 +200,19 @@ export const LogPage: NextPage = () => {
)} + {/* Footer section of log ui, appears below table */} +
+ + + +
diff --git a/studio/tests/pages/projects/LogPanel.test.js b/studio/tests/pages/projects/LogPanel.test.js index 89b629e7043..35afe931a27 100644 --- a/studio/tests/pages/projects/LogPanel.test.js +++ b/studio/tests/pages/projects/LogPanel.test.js @@ -4,12 +4,9 @@ import userEvent from '@testing-library/user-event' test('templates', async () => { const mockFn = jest.fn() - render() + render() const search = screen.getByPlaceholderText(/Search/) - userEvent.type(search, "12345") - + userEvent.type(search, '12345') // TODO templates dropdown interaction currently cannot be tested // https://github.com/supabase/ui/issues/299 @@ -21,21 +18,22 @@ test('templates', async () => { // expect(mockFn).toBeCalled() }) -test("filter input change", async () => { +test('filter input change and submit', async () => { const mockFn = jest.fn() render() const search = screen.getByPlaceholderText(/Search/) - userEvent.type(search, "12345") + userEvent.type(search, '12345') + expect(mockFn).not.toBeCalled() + userEvent.click(screen.getByText('Go')) expect(mockFn).toBeCalled() }) -test("filter input value", async () => { - render() - screen.getByDisplayValue("1234") +test('filter input value', async () => { + render() + screen.getByDisplayValue('1234') }) - -test("Manual refresh", async () => { +test('Manual refresh', async () => { const mockFn = jest.fn() render() let btn @@ -46,12 +44,13 @@ test("Manual refresh", async () => { expect(mockFn).toBeCalled() }) -test("reset filters", () => { - const mockFn = jest.fn() - const { rerender } = render() - expect(() => screen.getByText(/Clear search/)).toThrow() +test('reset search filter', async () => { + const { rerender } = render() + expect(() => screen.getByTitle(/Clear search/)).toThrow() - rerender() + rerender() + await waitFor(() => screen.getByDisplayValue(/something123/)) userEvent.click(screen.getByTitle(/Clear search/)) - expect(mockFn).toBeCalled() -}) \ No newline at end of file + expect(() => screen.getByTitle(/Clear search/)).toThrow() + expect(() => screen.getByDisplayValue(/something123/)).toThrow() +}) diff --git a/studio/tests/pages/projects/LogTable.test.js b/studio/tests/pages/projects/LogTable.test.js index 51e2b439968..0bdcefa489d 100644 --- a/studio/tests/pages/projects/LogTable.test.js +++ b/studio/tests/pages/projects/LogTable.test.js @@ -16,3 +16,18 @@ test('can display log data', async () => { await waitFor(() => screen.getByText(/my_key/)) await waitFor(() => screen.getByText(/something_value/)) }) + +test ("dedupes log lines with exact id", async ()=>{ + render() + + // should only have one element, this line will fail if there are >1 element + await waitFor(() => screen.getByText(/happened/)) +}) \ No newline at end of file diff --git a/studio/tests/pages/projects/logs.test.js b/studio/tests/pages/projects/logs.test.js index 48a1bc444d8..5d963b3df15 100644 --- a/studio/tests/pages/projects/logs.test.js +++ b/studio/tests/pages/projects/logs.test.js @@ -28,12 +28,29 @@ Editor.mockImplementation((props) => { }) useMonaco.mockImplementation((v) => v) +jest.mock('components/ui/Flag/Flag') +import Flag from 'components/ui/Flag/Flag' +Flag.mockImplementation(({ children }) => <>{children}) + +import { SWRConfig } from 'swr' +jest.mock('pages/project/[ref]/settings/logs/[type]') import { LogPage } from 'pages/project/[ref]/settings/logs/[type]' +LogPage.mockImplementation((props) => { + const Page = jest.requireActual('pages/project/[ref]/settings/logs/[type]').LogPage + // wrap with SWR to reset the cache each time + return ( + new Map() }}> + + + ) +}) + import { render, fireEvent, waitFor, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import {getToggleByText} from "../../helpers" +import { getToggleByText } from '../../helpers' beforeEach(() => { + // reset mocks between tests get.mockReset() }) test('can display log data and metadata', async () => { @@ -57,7 +74,7 @@ test('can display log data and metadata', async () => { await waitFor(() => screen.getByText(/something_value/)) }) -test('Refresh', async () => { +test('Refreshpage', async () => { const data = [ { id: 'some-uuid', @@ -68,9 +85,13 @@ test('Refresh', async () => { }, }, ] - get.mockResolvedValueOnce({ data }).mockResolvedValueOnce({ data: [] }) + get.mockImplementation((url) => { + if (url.includes('count')) return { count: 0 } + return { data } + }) render() - + await waitFor(() => screen.getByText(/happened/)) + get.mockResolvedValueOnce({ data: [] }) const row = screen.getByText(/happened/) fireEvent.click(row) await waitFor(() => screen.getByText(/my_key/)) @@ -101,6 +122,8 @@ test('Search will trigger a log refresh', async () => { render() userEvent.type(screen.getByPlaceholderText(/Search/), 'something') + userEvent.click(screen.getByText('Go')) + await waitFor( () => { expect(get).toHaveBeenCalledWith(expect.stringContaining('search_query')) @@ -167,6 +190,7 @@ test('where clause will trigger a log refresh', async () => { }) editor = container.querySelector('.monaco-editor') userEvent.type(editor, 'metadata.field = something') + userEvent.click(screen.getByText('Run')) await waitFor( () => { expect(get).toHaveBeenCalledWith(expect.stringContaining('where')) @@ -177,3 +201,43 @@ test('where clause will trigger a log refresh', async () => { await waitFor(() => screen.getByText(/happened/)) }) + +test('load older btn will fetch older logs', async () => { + get.mockImplementation((url) => { + if (url.includes('count')) { + return {} + } + return { + data: [ + { + id: 'some-uuid', + timestamp: 1621323232312, + event_message: 'first event', + metadata: {}, + }, + ], + } + }) + render() + // should display first log but not second + await waitFor(() => screen.getByText('first event')) + expect(() => screen.getByText('second event')).toThrow() + + get.mockResolvedValueOnce({ + data: [ + { + id: 'some-uuid2', + timestamp: 1621323232310, + event_message: 'second event', + metadata: {}, + }, + ], + }) + // should display first and second log + userEvent.click(screen.getByText('Load older')) + await waitFor(() => screen.getByText('first event')) + await waitFor(() => { + expect(get).toHaveBeenCalledWith(expect.stringContaining('timestamp_end=1')) + }) + await waitFor(() => screen.getByText('second event')) +})