mirror of
https://github.com/supabase/supabase.git
synced 2026-10-06 09:55:06 +03:00
feat: initial overview preset report
This commit is contained in:
1 parent
8ac9294368
commit
f115a6e53a
12 files changed
+868
-156
No files matched your search
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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'
|
||||
@@ -0,0 +1,3 @@
|
||||
export enum Presets {
|
||||
OVERVIEW = "overview"
|
||||
}
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user