mirror of
https://github.com/supabase/supabase.git
synced 2026-07-03 06:14:29 +08:00
feat: initial overview preset report
This commit is contained in:
@@ -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>
|
||||
)
|
||||
|
||||
228
studio/components/interfaces/Reports/PresetReport.tsx
Normal file
228
studio/components/interfaces/Reports/PresetReport.tsx
Normal 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
|
||||
83
studio/components/interfaces/Reports/Reports.constants.ts
Normal file
83
studio/components/interfaces/Reports/Reports.constants.ts
Normal 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'
|
||||
3
studio/components/interfaces/Reports/Reports.types.ts
Normal file
3
studio/components/interfaces/Reports/Reports.types.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export enum Presets {
|
||||
OVERVIEW = "overview"
|
||||
}
|
||||
56
studio/components/layouts/ReportsLayout/ReportsLayout.tsx
Normal file
56
studio/components/layouts/ReportsLayout/ReportsLayout.tsx
Normal 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))
|
||||
@@ -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>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
export interface ProductMenuGroup {
|
||||
title: string
|
||||
title?: string
|
||||
isPreview?: boolean
|
||||
items: ProductMenuGroupItem[]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
91
studio/pages/project/[ref]/reports/dashboard.tsx
Normal file
91
studio/pages/project/[ref]/reports/dashboard.tsx
Normal 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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
372
studio/tests/components/Reports/PresetReport.test.js
Normal file
372
studio/tests/components/Reports/PresetReport.test.js
Normal 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'))
|
||||
// })
|
||||
// })
|
||||
Reference in New Issue
Block a user