feat: initial overview preset report

This commit is contained in:
TzeYiing
2022-08-16 17:12:24 +08:00
committed by Ziinc
parent 8ac9294368
commit f115a6e53a
12 changed files with 868 additions and 156 deletions

View File

@@ -40,7 +40,6 @@ interface Props {
}
const ProjectUsage: FC<Props> = ({ project }) => {
const logsUsageCodesPaths = useFlag('logsUsageCodesPaths')
const [interval, setInterval] = useState<string>('hourly')
const router = useRouter()
const { ref } = router.query
@@ -50,20 +49,6 @@ const ProjectUsage: FC<Props> = ({ project }) => {
get
)
const { data: codesData, error: codesFetchError } = useSWR<EndpointResponse<StatusCodesDatum>>(
logsUsageCodesPaths
? `${API_URL}/projects/${ref}/analytics/endpoints/usage.api-codes?interval=${interval}`
: null,
get
)
const { data: pathsData, error: _pathsFetchError }: any = useSWR<EndpointResponse<PathsDatum>>(
logsUsageCodesPaths
? `${API_URL}/projects/${ref}/analytics/endpoints/usage.api-paths?interval=${interval}`
: null,
get
)
const selectedInterval = CHART_INTERVALS.find((i) => i.key === interval) || CHART_INTERVALS[1]
const startDate = dayjs()
.subtract(selectedInterval.startValue, selectedInterval.startUnit)
@@ -223,70 +208,6 @@ const ProjectUsage: FC<Props> = ({ project }) => {
</div>
</>
)}
{logsUsageCodesPaths && (
<div className="grid md:gap-4 lg:grid-cols-4 lg:gap-8">
<Panel
key="api-status-codes"
className="col-span-2 col-start-1"
wrapWithLoading={false}
>
<Panel.Content className="space-y-4">
<PanelHeader title="API Status Codes" />
<StackedAreaChart
dateFormat={datetimeFormat}
data={codesData?.result}
stackKey="status_code"
xAxisKey="timestamp"
yAxisKey="count"
isLoading={!codesData && !codesFetchError ? true : false}
xAxisFormatAsDate
size="large"
styleMap={{
200: { stroke: USAGE_COLORS['200'], fill: USAGE_COLORS['200'] },
201: { stroke: USAGE_COLORS['201'], fill: USAGE_COLORS['201'] },
400: { stroke: USAGE_COLORS['400'], fill: USAGE_COLORS['400'] },
401: { stroke: USAGE_COLORS['401'], fill: USAGE_COLORS['401'] },
404: { stroke: USAGE_COLORS['404'], fill: USAGE_COLORS['404'] },
500: { stroke: USAGE_COLORS['500'], fill: USAGE_COLORS['500'] },
}}
/>
</Panel.Content>
</Panel>
<Panel
key="top-routes"
className="col-span-2 col-start-3 pb-0"
bodyClassName="h-full"
wrapWithLoading={false}
>
<Panel.Content className="space-y-4">
<PanelHeader title="Top Routes" />
<Table
head={
<>
<Table.th>Path</Table.th>
<Table.th>Count</Table.th>
<Table.th>Avg. Latency (ms)</Table.th>
</>
}
body={
<>
{(pathsData?.result ?? []).map((row: PathsDatum) => (
<Table.tr>
<Table.td className="flex items-center space-x-2">
<p className="text-scale-1200 font-mono text-sm">{row.method}</p>
<p className="font-mono text-sm">{row.path}</p>
</Table.td>
<Table.td>{row.count}</Table.td>
<Table.td>{Number(row.avg_origin_time).toFixed(2)}</Table.td>
</Table.tr>
))}
</>
}
/>
</Panel.Content>
</Panel>
</div>
)}
</div>
</div>
)

View File

@@ -0,0 +1,228 @@
import {
Button,
Collapsible,
IconChevronLeft,
IconChevronRight,
IconChevronUp,
IconRefreshCw,
} from '@supabase/ui'
import Table from 'components/to-be-cleaned/Table'
import { USAGE_COLORS } from 'components/ui/Charts/Charts.constants'
import StackedAreaChart from 'components/ui/Charts/StackedAreaChart'
import Panel from 'components/ui/Panel'
import useLogsQuery from 'hooks/analytics/useLogsQuery'
import { useState } from 'react'
import { DatePickerToFrom } from '../Settings/Logs'
import DatePickers from '../Settings/Logs/Logs.DatePickers'
import { jsonSyntaxHighlight } from '../Settings/Logs/LogsFormatters'
import { DATETIME_FORMAT, PRESET_CONFIG, REPORTS_DATEPICKER_HELPERS } from './Reports.constants'
import { Presets } from './Reports.types'
interface Props {
preset: Presets
projectRef: string
}
const DEFAULT_PARAMS = {
iso_timestamp_start: REPORTS_DATEPICKER_HELPERS[0].calcFrom(),
iso_timestamp_end: REPORTS_DATEPICKER_HELPERS[0].calcTo(),
}
const PresetReport: React.FC<Props> = ({ projectRef, preset }) => {
const config = PRESET_CONFIG[preset]
const [statusCodes, statusCodesHandlers] = useLogsQuery(projectRef, {
sql: config.sql.statusCodes(),
...DEFAULT_PARAMS,
})
const [requestPaths, requestPathsHandlers] = useLogsQuery(projectRef, {
sql: config.sql.requestPaths(12),
...DEFAULT_PARAMS,
})
const handleRefresh = () => {
statusCodesHandlers.runQuery()
requestPathsHandlers.runQuery()
}
const handleDatepickerChange = ({ to, from }: DatePickerToFrom) => {
const newParams = { iso_timestamp_start: from || '', iso_timestamp_end: to || '' }
statusCodesHandlers.setParams((prev) => ({ ...prev, ...newParams }))
requestPathsHandlers.setParams((prev) => ({ ...prev, ...newParams }))
}
const isLoading = statusCodes.isLoading || requestPaths.isLoading
const requestPathsSums = requestPaths.logData.map((v) => v.sum) as number[]
const requestPathsSumMax = Math.max(...requestPathsSums)
const requestPathsSumMin = Math.min(...requestPathsSums)
return (
<div className="mx-auto flex flex-col gap-4 px-5 lg:px-16 xl:px-24 1xl:px-28 2xl:px-32 py-6">
<h1 className="text-2xl text-scale-1200">{config.title}</h1>
<div className="flex flex-row justify-between">
<DatePickers
onChange={handleDatepickerChange}
to={statusCodes.params.iso_timestamp_end || ''}
from={statusCodes.params.iso_timestamp_end || ''}
helpers={REPORTS_DATEPICKER_HELPERS}
/>
<Button
type="default"
size="tiny"
onClick={handleRefresh}
disabled={isLoading ? true : false}
icon={
<IconRefreshCw
size="tiny"
className={`text-scale-1100 ${isLoading ? 'animate-spin' : ''}`}
/>
}
>
Refresh
</Button>
</div>
<div className="grid md:gap-4 lg:grid-cols-4 lg:gap-8">
<Panel key="api-status-codes" className="col-span-4 col-start-1" wrapWithLoading={false}>
<Panel.Content className="space-y-4">
<h3>API Status Codes</h3>
<StackedAreaChart
dateFormat={DATETIME_FORMAT}
data={statusCodes.logData}
stackKey="status_code"
xAxisKey="timestamp"
yAxisKey="count"
isLoading={statusCodes.isLoading}
xAxisFormatAsDate
size="large"
styleMap={{
200: { stroke: USAGE_COLORS['200'], fill: USAGE_COLORS['200'] },
201: { stroke: USAGE_COLORS['201'], fill: USAGE_COLORS['201'] },
400: { stroke: USAGE_COLORS['400'], fill: USAGE_COLORS['400'] },
401: { stroke: USAGE_COLORS['401'], fill: USAGE_COLORS['401'] },
404: { stroke: USAGE_COLORS['404'], fill: USAGE_COLORS['404'] },
500: { stroke: USAGE_COLORS['500'], fill: USAGE_COLORS['500'] },
}}
/>
</Panel.Content>
</Panel>
<Panel
key="top-routes"
className="col-span-4 col-start-1 pb-0"
bodyClassName="h-full"
wrapWithLoading={false}
>
<Panel.Content className="space-y-4">
<h3>Slow API Requests</h3>
<Table
className="border border-scale-600 rounded"
head={
<>
<Table.th>Path</Table.th>
<Table.th>Count</Table.th>
<Table.th>Avg. Time (ms)</Table.th>
<Table.th>Total Query Time</Table.th>
</>
}
body={
<>
{requestPaths.logData.map((row: any, index) => {
const totalQueryTimePercentage =
((row.sum - requestPathsSumMin) / requestPathsSumMax) * 100
return (
<Table.tr key={index}>
<Table.td className="max-w-sm lg:max-w-lg" style={{ padding: '0.3rem' }}>
<Collapsible className="w-full flex flex-col gap-2">
<Collapsible.Trigger asChild>
<button
className="w-full text-scale-1200 flex justify-start space-x-1"
type="button"
>
<IconChevronRight
size="tiny"
className="transition data-open-parent:rotate-90 data-closed-parent:rotate-0"
/>
<div className="flex space-x-2 items-center overflow-x-none">
<p className="text-scale-1200 font-mono text-xs">{row.method}</p>
<p className="font-mono text-scale-1200 text-xs truncate max-w-xs ">
{row.path}
<span className="text-scale-1000">{row.query_params}</span>
</p>
</div>
</button>
</Collapsible.Trigger>
<Collapsible.Content className="bg-scale-300 p-2 rounded">
<pre className="text-xs syntax-highlight overflow-x-auto">
<div
className="text-wrap"
dangerouslySetInnerHTML={{
__html: jsonSyntaxHighlight(
queryParamsToObject(row.query_params)
),
}}
/>
</pre>
</Collapsible.Content>
</Collapsible>
</Table.td>
<Table.td style={{ padding: '0.5rem' }} className="text-xs align-top">
{row.count}
</Table.td>
<Table.td style={{ padding: '0.5rem' }} className="text-xs align-top">
{Number(row.avg_origin_time).toFixed(2)}
</Table.td>
<Table.td className="align-top py-1">
<div
className={`mt-1 h-2 rounded w-full bg-green-1100`}
style={{
width: `${totalQueryTimePercentage}%`,
}}
/>
</Table.td>
</Table.tr>
)
})}
</>
}
/>
</Panel.Content>
</Panel>
</div>
</div>
)
}
const queryParamsToObject = (params: string) => {
return Object.fromEntries(new URLSearchParams(params))
}
// const usePresetReport = (projectRef: string, preset: Presets) => {
// const params: LogsEndpointParams = { ...paramsToMerge, project: projectRef, sql }
// const endpointUrl = `${API_URL}/projects/${projectRef}/analytics/endpoints/logs.all?${genQueryParams(
// params as any
// )}`
// const {
// data,
// error: swrError,
// isValidating,
// mutate,
// } = useSWR<any>(endpointUrl, get, {
// revalidateOnFocus: false,
// revalidateIfStale: false,
// revalidateOnReconnect: false,
// dedupingInterval: 5000,
// })
// let error: null | string | object = swrError ? swrError.message : null
// return [
// {
// logData: data?.result ? data.result[0] : undefined,
// isLoading: isValidating,
// error,
// },
// {
// refresh: () => mutate(),
// },
// ]
// }
// const useWidgetQuery = (sql: string) => {}
export default PresetReport

View File

@@ -0,0 +1,83 @@
import dayjs from 'dayjs'
import { DatetimeHelper } from '../Settings/Logs'
import { Presets } from './Reports.types'
export const REPORTS_DATEPICKER_HELPERS: DatetimeHelper[] = [
// {
// text: 'Last 1 day',
// calcFrom: () => dayjs().subtract(1, 'day').startOf('day').toISOString(),
// calcTo: () => '',
// },
{
text: 'Last 7 days',
calcFrom: () => dayjs().subtract(7, 'day').startOf('day').toISOString(),
calcTo: () => '',
default: true,
},
{
text: 'Last 14 days',
calcFrom: () => dayjs().subtract(7, 'day').startOf('day').toISOString(),
calcTo: () => '',
},
{
text: 'Last 30 days',
calcFrom: () => dayjs().subtract(30, 'day').startOf('day').toISOString(),
calcTo: () => '',
},
{
text: 'Last 90 days',
calcFrom: () => dayjs().subtract(90, 'day').startOf('day').toISOString(),
calcTo: () => '',
},
]
export const PRESET_CONFIG = {
[Presets.OVERVIEW]: {
title: 'Overview',
sql: {
statusCodes: () => `
select
timestamp_trunc(timestamp, hour) as timestamp,
r.status_code as status_code,
count(status_code) as count
FROM
edge_logs
CROSS JOIN UNNEST(metadata) AS m
CROSS JOIN UNNEST(m.response) AS r
GROUP BY
timestamp,
status_code
ORDER BY
timestamp ASC`,
requestPaths: (limit: number) => `
select
f2.path as path,
f2.search as query_params,
f2.method as method,
f3.status_code as status_code,
avg(f3.origin_time) as avg_origin_time,
sum(f3.origin_time) as sum,
--APPROX_QUANTILES(f3.origin_time, 100) as p95_array,
--APPROX_QUANTILES(f3.origin_time, 100) as p99_array,
count(timestamp) as count
FROM
edge_logs
LEFT JOIN UNNEST(metadata) AS f1 ON TRUE
LEFT JOIN UNNEST(f1.request) AS f2 ON TRUE
LEFT JOIN UNNEST(f1.response) AS f3 ON TRUE
GROUP BY
path,
query_params,
method,
status_code
ORDER BY
sum DESC,
count desc,
avg_origin_time DESC
LIMIT ${limit}
`
},
},
}
export const DATETIME_FORMAT = 'MMM D, ha'

View File

@@ -0,0 +1,3 @@
export enum Presets {
OVERVIEW = "overview"
}

View File

@@ -0,0 +1,56 @@
import { FC, ReactNode } from 'react'
import { observer } from 'mobx-react-lite'
import { useRouter } from 'next/router'
import { useStore, withAuth } from 'hooks'
import { generateSettingsMenu } from './SettingsMenu.utils'
import BaseLayout from '..'
import ProductMenu from 'components/ui/ProductMenu'
interface Props {
title: string
children: ReactNode
}
const ReportsLayout: FC<Props> = ({ title, children }) => {
const { ui } = useStore()
const projectRef = ui.selectedProjectRef as string
const router = useRouter()
const page = router.pathname.split('/')[4] || ''
const menuRoutes = [
{
items: [
{
name: 'Overview',
key: '',
url: `/project/${projectRef}/reports`,
items: [],
},
],
},
{
items: [
{
name: 'Dashboard',
key: 'dashboard',
url: `/project/${projectRef}/reports/dashboard`,
items: [],
},
],
},
]
return (
<BaseLayout
title={title}
product="Reports"
productMenu={<ProductMenu page={page} menu={menuRoutes} />}
>
<main style={{ maxHeight: '100vh' }} className="flex-1 overflow-y-auto">
{children}
</main>
</BaseLayout>
)
}
export default withAuth(observer(ReportsLayout))

View File

@@ -19,10 +19,12 @@ const ProductMenu: FC<Props> = ({ page, menu }) => {
<Menu.Group
//@ts-ignore
title={
<div className="flex flex-col space-y-2">
<span>{group.title}</span>
{group.isPreview && <Badge color="amber">Not production ready</Badge>}
</div>
group.title ? (
<div className="flex flex-col space-y-2">
<span>{group.title}</span>
{group.isPreview && <Badge color="amber">Not production ready</Badge>}
</div>
) : null
}
/>
<div>

View File

@@ -1,7 +1,7 @@
import { ReactNode } from 'react'
export interface ProductMenuGroup {
title: string
title?: string
isPreview?: boolean
items: ProductMenuGroupItem[]
}

View File

@@ -26,7 +26,7 @@ const useLogsQuery = (
): [Data, Handlers] => {
const defaultHelper = getDefaultHelper(EXPLORER_DATEPICKER_HELPERS)
const [params, setParams] = useState<LogsEndpointParams>({
sql: '',
sql: initialParams?.sql || '',
project: projectRef,
iso_timestamp_start: initialParams.iso_timestamp_start
? initialParams.iso_timestamp_start

View File

@@ -0,0 +1,91 @@
import { useEffect, useState } from 'react'
import { observer } from 'mobx-react-lite'
import { useRouter } from 'next/router'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { NextPageWithLayout } from 'types'
import { checkPermissions, useFlag, useStore } from 'hooks'
import { post } from 'lib/common/fetch'
import { API_URL, PROJECT_STATUS } from 'lib/constants'
import { useProjectContentStore } from 'stores/projectContentStore'
import Loading from 'components/ui/Loading'
import { ProjectLayoutWithAuth } from 'components/layouts'
import ProductEmptyState from 'components/to-be-cleaned/ProductEmptyState'
import { createReport } from 'components/to-be-cleaned/Reports/Reports.utils'
import ReportsLayout from 'components/layouts/ReportsLayout/ReportsLayout'
export const DashboardReportPage: NextPageWithLayout = () => {
const [loading, setLoading] = useState(true)
const reportsOverview = useFlag('reportsOverview')
const Layout = reportsOverview ? ReportsLayout : ProjectLayoutWithAuth
const router = useRouter()
const { ref } = router.query
const { ui } = useStore()
const project = ui.selectedProject
// const canCreateReport = checkPermissions(PermissionAction.CREATE, 'user_content', {
// resource: { type: 'report' },
// })
const contentStore = useProjectContentStore(ref)
useEffect(() => {
if (project && project.status === PROJECT_STATUS.INACTIVE) {
post(`${API_URL}/projects/${ref}/restore`, {})
}
}, [project])
async function loadReports() {
await contentStore.load()
const reports = contentStore.reports()
if (reports.length >= 1) {
router.push(`/project/${ref}/reports/${reports[0].id}`)
} else {
setLoading(false)
}
}
useEffect(() => {
loadReports()
}, [ref])
return (
// TODO: use .getLayout when reportsOverview flag is removed
<Layout>
<div className="mx-auto my-16 w-full max-w-7xl flex-grow space-y-16">
{loading ? (
<Loading />
) : (
<ProductEmptyState
title="Reports"
ctaButtonLabel="Create report"
onClickCta={() => {
try {
createReport({ router })
} catch (error: any) {
ui.setNotification({
category: 'error',
message: `Failed to create report: ${error.message}`,
})
}
}}
>
<p className="text-scale-1100 text-sm">Create custom reports for your projects.</p>
<p className="text-scale-1100 text-sm">
Get a high level overview of your network traffic, user actions, and infrastructure
health.
</p>
</ProductEmptyState>
)}
</div>
</Layout>
)
}
// TODO: uncomment when reportsOverview flag is removed
// hooks do not work with next.js .getLayout
// DashboardReportPage.getLayout = (page) => <ReportsLayout>{page}</ReportsLayout>
export default observer(DashboardReportPage)

View File

@@ -17,7 +17,7 @@ import {
IconSettings,
} from '@supabase/ui'
import { checkPermissions } from 'hooks'
import { checkPermissions, useFlag } from 'hooks'
import { uuidv4 } from 'lib/helpers'
import { METRIC_CATEGORIES, METRICS, TIME_PERIODS_REPORTS } from 'lib/constants'
import { useProjectContentStore } from 'stores/projectContentStore'
@@ -28,6 +28,7 @@ import ChartHandler from 'components/to-be-cleaned/Charts/ChartHandler'
import DateRangePicker from 'components/to-be-cleaned/DateRangePicker'
import { NextPageWithLayout } from 'types'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import ReportsLayout from 'components/layouts/ReportsLayout/ReportsLayout'
const ReactGridLayout = WidthProvider(RGL)
@@ -35,21 +36,22 @@ const LAYOUT_COLUMN_COUNT = 24
const DEFAULT_CHART_COLUMN_COUNT = 12
const DEFAULT_CHART_ROW_COUNT = 4
/*
* PageLayout is used to setup layout - as usual it will requires inject global store
*/
const PageLayout: NextPageWithLayout = () => {
const reportsOverview = useFlag('reportsOverview')
const Layout = reportsOverview ? ReportsLayout : ProjectLayoutWithAuth
return (
<>
// TODO: use .getLayout when reportsOverview flag is removed
<Layout>
<div className="mx-auto my-8 w-full max-w-7xl">
<Reports />
</div>
<EditReportModal />
</>
</Layout>
)
}
PageLayout.getLayout = (page) => <ProjectLayoutWithAuth>{page}</ProjectLayoutWithAuth>
// TODO: uncomment when reportsOverview flag is removed
// PageLayout.getLayout = (page) => <ReportsLayout>{page}</ReportsLayout>
export default observer(PageLayout)

View File

@@ -1,81 +1,35 @@
import { useEffect, useState } from 'react'
import { useState } from 'react'
import { observer } from 'mobx-react-lite'
import { useRouter } from 'next/router'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { NextPageWithLayout } from 'types'
import { checkPermissions, useStore } from 'hooks'
import { post } from 'lib/common/fetch'
import { API_URL, PROJECT_STATUS } from 'lib/constants'
import { useProjectContentStore } from 'stores/projectContentStore'
import Loading from 'components/ui/Loading'
import { useFlag, useStore } from 'hooks'
import { ProjectLayoutWithAuth } from 'components/layouts'
import ProductEmptyState from 'components/to-be-cleaned/ProductEmptyState'
import { createReport } from 'components/to-be-cleaned/Reports/Reports.utils'
const PageLayout: NextPageWithLayout = () => {
const [loading, setLoading] = useState(true)
import PresetReport from 'components/interfaces/Reports/PresetReport'
import { Presets } from 'components/interfaces/Reports/Reports.types'
import ReportsLayout from 'components/layouts/ReportsLayout/ReportsLayout'
import { DashboardReportPage } from './dashboard'
export const ReportsOverviewPage: NextPageWithLayout = () => {
const router = useRouter()
const { ref } = router.query
const reportsOverview = useFlag('reportsOverview')
const { ui } = useStore()
const project = ui.selectedProject
// const canCreateReport = checkPermissions(PermissionAction.CR
const contentStore = useProjectContentStore(ref)
useEffect(() => {
if (project && project.status === PROJECT_STATUS.INACTIVE) {
post(`${API_URL}/projects/${ref}/restore`, {})
}
}, [project])
async function loadReports() {
await contentStore.load()
const reports = contentStore.reports()
if (reports.length >= 1) {
router.push(`/project/${ref}/reports/${reports[0].id}`)
} else {
setLoading(false)
}
}
useEffect(() => {
loadReports()
}, [ref])
const Layout = reportsOverview ? ReportsLayout : ProjectLayoutWithAuth
return (
<div className="mx-auto my-16 w-full max-w-7xl flex-grow space-y-16">
{loading ? (
<Loading />
<Layout>
{reportsOverview ? (
<PresetReport preset={Presets.OVERVIEW} projectRef={ref as string} />
) : (
<ProductEmptyState
title="Reports"
ctaButtonLabel="Create report"
onClickCta={() => {
try {
createReport({ router })
} catch (error: any) {
ui.setNotification({
category: 'error',
message: `Failed to create report: ${error.message}`,
})
}
}}
>
<p className="text-scale-1100 text-sm">Create custom reports for your projects.</p>
<p className="text-scale-1100 text-sm">
Get a high level overview of your network traffic, user actions, and infrastructure
health.
</p>
</ProductEmptyState>
<DashboardReportPage />
)}
</div>
</Layout>
)
}
PageLayout.getLayout = (page) => <ProjectLayoutWithAuth>{page}</ProjectLayoutWithAuth>
// TODO: uncomment when reportsOverview flag is removed
// hooks do not work with next.js .getLayout
// ReportsOverviewPage.getLayout = (page) => <ReportsLayout>{page}</ReportsLayout>
export default observer(PageLayout)
export default observer(ReportsOverviewPage)

View File

@@ -0,0 +1,372 @@
import dayjs from 'dayjs'
import utc from 'dayjs/plugin/utc'
dayjs.extend(utc)
// mock the fetch function
jest.mock('lib/common/fetch')
import { get } from 'lib/common/fetch'
// mock mobx
jest.mock('mobx-react-lite')
import { observer } from 'mobx-react-lite'
observer.mockImplementation((v) => v)
// mock the router
jest.mock('next/router')
import { useRouter } from 'next/router'
const defaultRouterMock = () => {
const router = jest.fn()
router.query = {}
router.push = jest.fn()
router.pathname = 'logs/path'
return router
}
useRouter.mockReturnValue(defaultRouterMock())
// mock monaco editor
jest.mock('@monaco-editor/react')
import Editor, { useMonaco } from '@monaco-editor/react'
Editor = jest.fn()
Editor.mockImplementation((props) => {
return (
<textarea className="monaco-editor" onChange={(e) => props.onChange(e.target.value)}></textarea>
)
})
useMonaco.mockImplementation((v) => v)
// mock usage flags
jest.mock('components/ui/Flag/Flag')
import Flag from 'components/ui/Flag/Flag'
Flag.mockImplementation(({ children }) => <>{children}</>)
jest.mock('hooks')
import { useFlag } from 'hooks'
useFlag.mockReturnValue(true)
import { SWRConfig } from 'swr'
jest.mock('components/interfaces/Reports/PresetReport')
import PresetReport from 'components/interfaces/Reports/PresetReport'
PresetReport.mockImplementation((props) => {
const Comp = jest.requireActual('components/interfaces/Reports/PresetReport').default
// wrap with SWR to reset the cache each time
return (
<SWRConfig
value={{
provider: () => new Map(),
shouldRetryOnError: false,
}}
>
<Comp {...props} />
</SWRConfig>
)
})
jest.mock('hooks')
import { useProjectSubscription } from 'hooks'
useProjectSubscription = jest.fn((ref) => ({
subscription: {
tier: {
supabase_prod_id: 'tier_free',
},
},
}))
import { render, fireEvent, waitFor, screen, act } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { wait } from '@testing-library/user-event/dist/utils'
import { logDataFixture } from '../../fixtures'
import { LogsTableName } from 'components/interfaces/Settings/Logs'
import { Presets } from 'components/interfaces/Reports/Reports.types'
beforeEach(() => {
// reset mocks between tests
get.mockReset()
useRouter.mockReset()
useRouter.mockReturnValue(defaultRouterMock())
})
test('static elements', async () => {
render(<PresetReport preset={Presets.OVERVIEW} />)
await screen.findByText(/Last 7 days/)
await screen.findAllByText(/Overview/)
await screen.findAllByText(/Refresh/)
})
test('changing date range triggers query refresh', async () => {
render(<PresetReport preset={Presets.OVERVIEW} />)
await waitFor(() => expect(get).toBeCalled())
get.mockReset()
const refresh = await screen.findByText(/Refresh/)
fireEvent.click(refresh)
await waitFor(() => expect(get).toBeCalled())
})
// test.each([
// {
// queryType: 'api',
// tableName: undefined,
// allLog: logDataFixture({
// id: 'some-id',
// request: { path: 'some-path', method: 'POST' },
// status_code: '400',
// metadata: undefined,
// }),
// singleLog: {
// id: 'some-id',
// metadata: [{ request: [{ method: 'POST' }] }],
// },
// tableTexts: [/POST/, /some\-path/, /400/],
// selectionTexts: [/POST/],
// },
// // TODO: add more tests for each type of ui
// ])(
// 'selection $queryType $tableName , can display log data and metadata',
// async ({ queryType, tableName, allLog, singleLog, tableTexts, selectionTexts }) => {
// get.mockImplementation((url) => {
// // counts
// if (url.includes('count')) {
// return { result: [{ count: 0 }] }
// }
// // single
// if (url.includes('where+id')) {
// return { result: [singleLog] }
// }
// // all
// return { result: [allLog] }
// })
// render(<PresetReport projectRef="123" queryType={queryType} tableName={tableName} />)
// await waitFor(() => {
// expect(get).toHaveBeenCalledWith(expect.stringContaining('iso_timestamp_start'))
// expect(get).not.toHaveBeenCalledWith(expect.stringContaining('iso_timestamp_end'))
// })
// // reset mock so that we can check for selection call
// get.mockClear()
// for (const text of tableTexts) {
// await screen.findByText(text)
// }
// const row = await screen.findByText(tableTexts[0])
// fireEvent.click(row)
// await waitFor(() => {
// expect(get).toHaveBeenCalledWith(expect.stringContaining('iso_timestamp_start'))
// expect(get).not.toHaveBeenCalledWith(expect.stringContaining('iso_timestamp_end'))
// })
// for (const text of selectionTexts) {
// await screen.findAllByText(text)
// }
// }
// )
// test('Search will trigger a log refresh', async () => {
// get.mockImplementation((url) => {
// if (url.includes('something')) {
// return {
// result: [logDataFixture({ id: 'some-event-id' })],
// }
// }
// return { result: [] }
// })
// render(<PresetReport projectRef="123" tableName={LogsTableName.EDGE} />)
// userEvent.type(screen.getByPlaceholderText(/Search events/), 'something{enter}')
// await waitFor(
// () => {
// expect(get).toHaveBeenCalledWith(expect.stringContaining('something'))
// // updates router query params
// const router = useRouter()
// expect(router.push).toHaveBeenCalledWith(
// expect.objectContaining({
// pathname: expect.any(String),
// query: expect.objectContaining({
// s: expect.stringContaining('something'),
// }),
// })
// )
// },
// { timeout: 1500 }
// )
// await screen.findByText(/some-event-id/)
// })
// test('poll count for new messages', async () => {
// get.mockImplementation((url) => {
// if (url.includes('count')) {
// return { result: [{ count: 125 }] }
// }
// return {
// result: [logDataFixture({ id: 'some-uuid123' })],
// }
// })
// render(<PresetReport projectRef="123" tableName={LogsTableName.EDGE} />)
// await waitFor(() => screen.queryByText(/some-uuid123/) === null)
// // should display new logs count
// await waitFor(() => screen.getByText(/125/))
// userEvent.click(screen.getByText(/Refresh/))
// await waitFor(() => screen.queryByText(/125/) === null)
// await screen.findByText(/some-uuid123/)
// })
// test('s= query param will populate the search bar', async () => {
// const router = defaultRouterMock()
// router.query = { ...router.query, s: 'someSearch' }
// useRouter.mockReturnValue(router)
// render(<PresetReport projectRef="123" tableName={LogsTableName.EDGE} />)
// // should populate search input with the search param
// await screen.findByDisplayValue('someSearch')
// expect(get).toHaveBeenCalledWith(expect.stringContaining('someSearch'))
// })
// 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)
// const iso = newDate.toISOString()
// const router = defaultRouterMock()
// router.query = { ...router.query, ite: iso }
// useRouter.mockReturnValue(router)
// render(<PresetReport projectRef="123" tableName={LogsTableName.EDGE} />)
// await waitFor(() => {
// expect(get).toHaveBeenCalledWith(
// expect.stringContaining(`iso_timestamp_end=${encodeURIComponent(iso)}`)
// )
// })
// userEvent.click(await screen.findByText('Custom'))
// })
// 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 iso = newDate.toISOString()
// const router = defaultRouterMock()
// router.query = { ...router.query, its: iso }
// useRouter.mockReturnValue(router)
// render(<PresetReport projectRef="123" tableName={LogsTableName.EDGE} />)
// await waitFor(() => {
// expect(get).toHaveBeenCalledWith(
// expect.stringContaining(`iso_timestamp_start=${encodeURIComponent(iso)}`)
// )
// })
// userEvent.click(await screen.findByText('Custom'))
// await screen.findByText(new RegExp(newDate.getFullYear()))
// })
// test('load older btn will fetch older logs', async () => {
// get.mockImplementation((url) => {
// if (url.includes('count')) {
// return {}
// }
// return {
// result: [logDataFixture({ id: 'first event' })],
// }
// })
// render(<PresetReport projectRef="123" tableName={LogsTableName.EDGE} />)
// // should display first log but not second
// await waitFor(() => screen.getByText('first event'))
// await expect(screen.findByText('second event')).rejects.toThrow()
// get.mockResolvedValueOnce({
// result: [logDataFixture({ id: 'second event' })],
// })
// // should display first and second log
// userEvent.click(await screen.findByText('Load older'))
// await screen.findByText('first event')
// await screen.findByText('second event')
// expect(get).toHaveBeenCalledWith(expect.stringContaining('timestamp_end='))
// })
// test('bug: load older btn does not error out when previous page is empty', async () => {
// // bugfix for https://sentry.io/organizations/supabase/issues/2903331460/?project=5459134&referrer=slack
// get.mockImplementation((url) => {
// if (url.includes('count')) {
// return {}
// }
// return { result: [] }
// })
// render(<PresetReport projectRef="123" tableName={LogsTableName.EDGE} />)
// userEvent.click(await screen.findByText('Load older'))
// // NOTE: potential race condition, since we are asserting that something DOES NOT EXIST
// // wait for 500s to make sure all ui logic is complete
// // need to wrap in act because internal react state is changing during this time.
// await act(async () => await wait(100))
// // clicking load older multiple times should not give error
// await waitFor(() => {
// expect(screen.queryByText(/Sorry/)).toBeNull()
// expect(screen.queryByText(/An error occured/)).toBeNull()
// expect(screen.queryByText(/undefined/)).toBeNull()
// })
// })
// test('log event chart hide', async () => {
// render(<PresetReport projectRef="123" tableName={LogsTableName.EDGE} />)
// await screen.findByText('Events')
// const toggle = await screen.findByText(/Chart/)
// userEvent.click(toggle)
// await expect(screen.findByText('Events')).rejects.toThrow()
// })
// test('bug: nav backwards with params change results in ui changing', async () => {
// // bugfix for https://sentry.io/organizations/supabase/issues/2903331460/?project=5459134&referrer=slack
// get.mockImplementation((url) => {
// if (url.includes('count')) {
// return {}
// }
// return { data: [] }
// })
// const { container, rerender } = render(
// <PresetReport projectRef="123" tableName={LogsTableName.EDGE} />
// )
// await expect(screen.findByDisplayValue('simple-query')).rejects.toThrow()
// const router = defaultRouterMock()
// router.query = { ...router.query, s: 'simple-query' }
// useRouter.mockReturnValue(router)
// rerender(<PresetReport projectRef="123" tableName={LogsTableName.EDGE} />)
// await screen.findByDisplayValue('simple-query')
// })
// test('bug: nav to explorer preserves newlines', async () => {
// render(<PresetReport projectRef="123" tableName={LogsTableName.EDGE} />)
// const router = useRouter()
// userEvent.click(await screen.findByText(/Explore/))
// await expect(router.push).toBeCalledWith(expect.stringContaining(encodeURIComponent('\n')))
// })
// test('filters alter generated query', async () => {
// render(<PresetReport projectRef="123" tableName={LogsTableName.EDGE} />)
// userEvent.click(await screen.findByRole('button', { name: 'Status' }))
// userEvent.click(await screen.findByText(/500 error codes/))
// userEvent.click(await screen.findByText(/200 codes/))
// userEvent.click(await screen.findByText(/Save/))
// await waitFor(() => {
// expect(get).toHaveBeenCalledWith(expect.stringContaining('select'))
// expect(get).toHaveBeenCalledWith(expect.stringContaining('500'))
// expect(get).toHaveBeenCalledWith(expect.stringContaining('200'))
// expect(get).toHaveBeenCalledWith(expect.stringContaining('where'))
// expect(get).toHaveBeenCalledWith(expect.stringContaining('and'))
// })
// })
// test('filters accept filterOverride', async () => {
// render(
// <PresetReport
// projectRef="123"
// tableName={LogsTableName.FUNCTIONS}
// filterOverride={{ 'my.nestedkey': 'myvalue' }}
// />
// )
// await waitFor(() => {
// expect(get).toHaveBeenCalledWith(expect.stringContaining('my.nestedkey'))
// expect(get).toHaveBeenCalledWith(expect.stringContaining('myvalue'))
// })
// })