mirror of
https://github.com/supabase/supabase.git
synced 2026-06-06 22:23:54 +08:00
## Problem Now that we migrated old components to their new shadcn alternatives, we don't need the `_Shadcn_` suffix anymore. ## Solution Remove it <img width="659" height="609" alt="image" src="https://github.com/user-attachments/assets/2d7271a9-066a-4dcc-92fe-729b106d2c2f" />
636 lines
22 KiB
TypeScript
636 lines
22 KiB
TypeScript
import { geoCentroid } from 'd3-geo'
|
|
import sumBy from 'lodash/sumBy'
|
|
import { ChevronRight } from 'lucide-react'
|
|
import { useTheme } from 'next-themes'
|
|
import { Fragment, useRef, useState, type ReactNode } from 'react'
|
|
import { ComposableMap, Geographies, Geography, Marker, ZoomableGroup } from 'react-simple-maps'
|
|
import {
|
|
Alert,
|
|
AlertDescription,
|
|
AlertTitle,
|
|
Button,
|
|
Collapsible,
|
|
CollapsibleContent,
|
|
CollapsibleTrigger,
|
|
WarningIcon,
|
|
} from 'ui'
|
|
import * as z from 'zod'
|
|
|
|
import { queryParamsToObject } from '../Reports.utils'
|
|
import { ReportWidgetProps, ReportWidgetRendererProps } from '../ReportWidget'
|
|
import { COUNTRY_LAT_LON } from '@/components/interfaces/ProjectCreation/ProjectCreation.constants'
|
|
import {
|
|
buildCountsByIso2,
|
|
computeMarkerRadius,
|
|
extractIso2FromFeatureProps,
|
|
getFillColor,
|
|
getFillOpacity,
|
|
isKnownCountryCode,
|
|
isMicroCountry,
|
|
iso2ToCountryName,
|
|
MAP_CHART_THEME,
|
|
} from '@/components/interfaces/Reports/utils/geo'
|
|
import {
|
|
jsonSyntaxHighlight,
|
|
TextFormatter,
|
|
} from '@/components/interfaces/Settings/Logs/LogsFormatters'
|
|
import Table from '@/components/to-be-cleaned/Table'
|
|
import AlertError from '@/components/ui/AlertError'
|
|
import BarChart from '@/components/ui/Charts/BarChart'
|
|
import { DataTableColumnStatusCode } from '@/components/ui/DataTable/DataTableColumn/DataTableColumnStatusCode'
|
|
import { useFillTimeseriesSorted } from '@/hooks/analytics/useFillTimeseriesSorted'
|
|
import { BASE_PATH } from '@/lib/constants'
|
|
import type { ResponseError } from '@/types'
|
|
|
|
export const NetworkTrafficRenderer = (
|
|
props: ReportWidgetProps<{
|
|
timestamp: string
|
|
ingress: number
|
|
egress: number
|
|
}>
|
|
) => {
|
|
const { data, error, isError } = useFillTimeseriesSorted({
|
|
data: props.data,
|
|
timestampKey: 'timestamp',
|
|
valueKey: ['ingress_mb', 'egress_mb'],
|
|
defaultValue: 0,
|
|
startDate: props.params?.iso_timestamp_start,
|
|
endDate: props.params?.iso_timestamp_end,
|
|
})
|
|
|
|
const totalIngress = sumBy(props.data, 'ingress_mb')
|
|
const totalEgress = sumBy(props.data, 'egress_mb')
|
|
|
|
function determinePrecision(valueInMb: number) {
|
|
return valueInMb < 0.001 ? 7 : totalIngress > 1 ? 2 : 4
|
|
}
|
|
|
|
if (!!props.error) {
|
|
const error = (
|
|
typeof props.error === 'string' ? { message: props.error } : props.error
|
|
) as ResponseError
|
|
return <AlertError subject="Failed to retrieve network traffic" error={error} />
|
|
} else if (isError) {
|
|
return (
|
|
<Alert variant="warning">
|
|
<WarningIcon />
|
|
<AlertTitle>Failed to retrieve network traffic</AlertTitle>
|
|
<AlertDescription>{error?.message ?? 'Unknown error'}</AlertDescription>
|
|
</Alert>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col gap-12 w-full">
|
|
<BarChart
|
|
size="small"
|
|
title="Ingress"
|
|
highlightedValue={sumBy(props.data, 'ingress_mb')}
|
|
format="MB"
|
|
className="w-full"
|
|
valuePrecision={determinePrecision(totalIngress)}
|
|
data={data}
|
|
yAxisKey="ingress_mb"
|
|
xAxisKey="timestamp"
|
|
displayDateInUtc
|
|
/>
|
|
|
|
<BarChart
|
|
size="small"
|
|
title="Egress"
|
|
highlightedValue={totalEgress}
|
|
format="MB"
|
|
valuePrecision={determinePrecision(totalEgress)}
|
|
className="w-full"
|
|
data={data}
|
|
yAxisKey="egress_mb"
|
|
xAxisKey="timestamp"
|
|
displayDateInUtc
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export const TotalRequestsChartRenderer = (
|
|
props: ReportWidgetProps<{
|
|
timestamp: string
|
|
count: number
|
|
}>
|
|
) => {
|
|
const total = props.data.reduce((acc, datum) => {
|
|
return acc + datum.count
|
|
}, 0)
|
|
const { data, error, isError } = useFillTimeseriesSorted({
|
|
data: props.data,
|
|
timestampKey: 'timestamp',
|
|
valueKey: 'count',
|
|
defaultValue: 0,
|
|
startDate: props.params?.iso_timestamp_start,
|
|
endDate: props.params?.iso_timestamp_end,
|
|
})
|
|
|
|
if (!!props.error) {
|
|
const error = (
|
|
typeof props.error === 'string' ? { message: props.error } : props.error
|
|
) as ResponseError
|
|
return <AlertError subject="Failed to retrieve total requests" error={error} />
|
|
} else if (isError) {
|
|
return (
|
|
<Alert variant="warning">
|
|
<WarningIcon />
|
|
<AlertTitle>Failed to retrieve total requests</AlertTitle>
|
|
<AlertDescription>{error?.message ?? 'Unknown error'}</AlertDescription>
|
|
</Alert>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<BarChart
|
|
size="small"
|
|
minimalHeader
|
|
highlightedValue={total}
|
|
className="w-full"
|
|
data={data}
|
|
yAxisKey="count"
|
|
xAxisKey="timestamp"
|
|
displayDateInUtc
|
|
/>
|
|
)
|
|
}
|
|
|
|
export const TopApiRoutesRenderer = (
|
|
props: ReportWidgetRendererProps<{
|
|
method: string
|
|
// shown for error table but not all requests table
|
|
status_code?: number
|
|
path: string
|
|
search: string
|
|
count: number
|
|
// used for response speed table only
|
|
avg?: number
|
|
}>
|
|
) => {
|
|
const [showMore, setShowMore] = useState(false)
|
|
|
|
const headerClasses = 'text-xs! py-2! p-0 font-bold bg-surface-200! border-x-0! rounded-none!'
|
|
const cellClasses = 'text-xs! py-2! border-x-0! rounded-none! align-middle'
|
|
|
|
if (props.data.length === 0) return null
|
|
|
|
return (
|
|
<>
|
|
<Table
|
|
className="rounded-t-none"
|
|
containerClassName="overflow-x-auto"
|
|
head={
|
|
<>
|
|
<Table.th className={headerClasses}>Request</Table.th>
|
|
<Table.th className={headerClasses + ' text-right'}>Count</Table.th>
|
|
{props.data[0].avg !== undefined && (
|
|
<Table.th className={headerClasses + ' text-right'}>Avg</Table.th>
|
|
)}
|
|
</>
|
|
}
|
|
body={
|
|
<>
|
|
{props.data.map((datum, index) => (
|
|
<Fragment key={index + datum.method + datum.path + (datum.search || '')}>
|
|
<Table.tr
|
|
className={[
|
|
'p-0 transition transform cursor-pointer hover:bg-surface-200',
|
|
showMore && index >= 3 ? 'w-full h-full opacity-100' : '',
|
|
!showMore && index >= 3 ? ' w-0 h-0 translate-y-10 opacity-0' : '',
|
|
].join(' ')}
|
|
>
|
|
{(!showMore && index < 3) || showMore ? (
|
|
<>
|
|
<Table.td className={[cellClasses].join(' ')}>
|
|
<RouteTdContent {...datum} />
|
|
</Table.td>
|
|
<Table.td className={[cellClasses, 'text-right'].join(' ')}>
|
|
{datum.count}
|
|
</Table.td>
|
|
{props.data[0].avg !== undefined && (
|
|
<Table.td className={[cellClasses, 'text-right'].join(' ')}>
|
|
{Number(datum.avg).toFixed(2)}ms
|
|
</Table.td>
|
|
)}
|
|
</>
|
|
) : null}
|
|
</Table.tr>
|
|
</Fragment>
|
|
))}
|
|
</>
|
|
}
|
|
/>
|
|
<div className="flex flex-row justify-end w-full gap-2 p-1">
|
|
<Button
|
|
type="text"
|
|
onClick={() => setShowMore(!showMore)}
|
|
className={[
|
|
'transition',
|
|
showMore ? 'text-foreground' : 'text-foreground-lighter',
|
|
props.data.length <= 3 ? 'hidden' : '',
|
|
].join(' ')}
|
|
>
|
|
{!showMore ? 'Show more' : 'Show less'}
|
|
</Button>
|
|
</div>
|
|
</>
|
|
)
|
|
}
|
|
|
|
export const ErrorCountsChartRenderer = (
|
|
props: ReportWidgetProps<{
|
|
timestamp: string
|
|
count: number
|
|
}>
|
|
) => {
|
|
const total = props.data.reduce((acc, datum) => {
|
|
return acc + datum.count
|
|
}, 0)
|
|
|
|
const { data, error, isError } = useFillTimeseriesSorted({
|
|
data: props.data,
|
|
timestampKey: 'timestamp',
|
|
valueKey: 'count',
|
|
defaultValue: 0,
|
|
startDate: props.params?.iso_timestamp_start,
|
|
endDate: props.params?.iso_timestamp_end,
|
|
})
|
|
|
|
if (!!props.error) {
|
|
const error = (
|
|
typeof props.error === 'string' ? { message: props.error } : props.error
|
|
) as ResponseError
|
|
return <AlertError subject="Failed to retrieve request errors" error={error} />
|
|
} else if (isError) {
|
|
return (
|
|
<Alert variant="warning">
|
|
<WarningIcon />
|
|
<AlertTitle>Failed to retrieve request errors</AlertTitle>
|
|
<AlertDescription>{error?.message ?? 'Unknown error'}</AlertDescription>
|
|
</Alert>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<BarChart
|
|
size="small"
|
|
minimalHeader
|
|
className="w-full"
|
|
highlightedValue={total}
|
|
data={data}
|
|
yAxisKey="count"
|
|
xAxisKey="timestamp"
|
|
displayDateInUtc
|
|
/>
|
|
)
|
|
}
|
|
|
|
export const ResponseSpeedChartRenderer = (
|
|
props: ReportWidgetProps<{
|
|
timestamp: string
|
|
avg: number
|
|
}>
|
|
) => {
|
|
const transformedData = props.data.map((datum) => ({
|
|
timestamp: datum.timestamp,
|
|
avg: datum.avg,
|
|
}))
|
|
|
|
const { data, error, isError } = useFillTimeseriesSorted({
|
|
data: transformedData,
|
|
timestampKey: 'timestamp',
|
|
valueKey: 'avg',
|
|
defaultValue: 0,
|
|
startDate: props.params?.iso_timestamp_start,
|
|
endDate: props.params?.iso_timestamp_end,
|
|
})
|
|
|
|
const lastAvg = props.data[props.data.length - 1]?.avg
|
|
|
|
if (!!props.error) {
|
|
const error = (
|
|
typeof props.error === 'string' ? { message: props.error } : props.error
|
|
) as ResponseError
|
|
return <AlertError subject="Failed to retrieve response speeds" error={error} />
|
|
} else if (isError) {
|
|
return (
|
|
<Alert variant="warning">
|
|
<WarningIcon />
|
|
<AlertTitle>Failed to retrieve response speeds</AlertTitle>
|
|
<AlertDescription>{error?.message ?? 'Unknown error'}</AlertDescription>
|
|
</Alert>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<BarChart
|
|
size="small"
|
|
highlightedValue={lastAvg}
|
|
format="ms"
|
|
minimalHeader
|
|
className="w-full"
|
|
data={data}
|
|
yAxisKey="avg"
|
|
xAxisKey="timestamp"
|
|
displayDateInUtc
|
|
/>
|
|
)
|
|
}
|
|
|
|
interface RouteTdContentProps {
|
|
method: string
|
|
status_code?: number
|
|
path: string
|
|
search: string
|
|
}
|
|
const RouteTdContent = (datum: RouteTdContentProps) => (
|
|
<Collapsible>
|
|
<CollapsibleTrigger asChild>
|
|
<div className="flex gap-2 items-center">
|
|
<Button asChild type="text" className=" py-0! p-1!" title="Show more route details">
|
|
<span>
|
|
<ChevronRight
|
|
size={14}
|
|
className="transition data-open-parent:rotate-90 data-closed-parent:rotate-0"
|
|
/>
|
|
</span>
|
|
</Button>
|
|
<TextFormatter
|
|
className="w-10 h-4 text-center rounded-sm bg-surface-300"
|
|
value={datum.method}
|
|
/>
|
|
{datum.status_code && (
|
|
<DataTableColumnStatusCode
|
|
value={datum.status_code}
|
|
level={String(Math.floor(datum.status_code / 100))}
|
|
/>
|
|
)}
|
|
<div className=" truncate max-w-sm lg:max-w-lg">
|
|
<TextFormatter className="text-foreground-light" value={datum.path} />
|
|
<TextFormatter
|
|
className="max-w-sm text-foreground-lighter truncate "
|
|
value={decodeURIComponent(datum.search || '')}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent className="pt-2">
|
|
{datum.search ? (
|
|
<pre className="syntax-highlight overflow-auto whitespace-pre-wrap wrap-break-word rounded-sm bg-surface-100 p-2 text-xs! [&_span]:whitespace-pre-wrap!">
|
|
<div
|
|
className="text-wrap"
|
|
dangerouslySetInnerHTML={{
|
|
__html: jsonSyntaxHighlight(queryParamsToObject(datum.search)),
|
|
}}
|
|
/>
|
|
</pre>
|
|
) : (
|
|
<p className="text-xs text-foreground-lighter">No query parameters in this request</p>
|
|
)}
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
)
|
|
export const RequestsByCountryMapRenderer = (
|
|
props: ReportWidgetProps<{
|
|
country: string | null
|
|
count: number
|
|
}>
|
|
) => {
|
|
const WORLD_TOPO_URL = `${BASE_PATH}/json/worldmap.json`
|
|
const containerRef = useRef<HTMLDivElement | null>(null)
|
|
const [hoverInfo, setHoverInfo] = useState<{
|
|
x: number
|
|
y: number
|
|
title: string
|
|
subtitle: string
|
|
visible: boolean
|
|
}>({ x: 0, y: 0, title: '', subtitle: '', visible: false })
|
|
|
|
const countsByIso2 = buildCountsByIso2(props.data)
|
|
const max = Object.values(countsByIso2).reduce((m, v) => (v > m ? v : m), 0)
|
|
const { resolvedTheme } = useTheme()
|
|
const theme = resolvedTheme === 'dark' ? MAP_CHART_THEME.dark : MAP_CHART_THEME.light
|
|
|
|
if (!!props.error) {
|
|
const AlertErrorSchema = z.object({ message: z.string() })
|
|
const parsed =
|
|
typeof props.error === 'string'
|
|
? { success: true, data: { message: props.error } }
|
|
: AlertErrorSchema.safeParse(props.error)
|
|
const alertError = parsed.success ? parsed.data : null
|
|
return <AlertError subject="Failed to retrieve requests by geography" error={alertError} />
|
|
}
|
|
|
|
return (
|
|
<div ref={containerRef} className="w-full h-[420px] relative border-t">
|
|
<ComposableMap
|
|
projection="geoMercator"
|
|
projectionConfig={{ scale: 155 }}
|
|
className="w-full h-full"
|
|
style={{ backgroundColor: theme.oceanFill }}
|
|
>
|
|
<ZoomableGroup minZoom={1} maxZoom={5} zoom={1.3}>
|
|
<Geographies geography={WORLD_TOPO_URL}>
|
|
{({ geographies }) => (
|
|
<>
|
|
{geographies.map((geo) => {
|
|
const title =
|
|
(geo.properties?.name as string) ||
|
|
(geo.properties?.NAME as string) ||
|
|
'Unknown'
|
|
const iso2 = extractIso2FromFeatureProps(
|
|
(geo.properties || undefined) as Record<string, unknown> | undefined
|
|
)
|
|
const value = iso2 ? countsByIso2[iso2] || 0 : 0
|
|
const baseOpacity = getFillOpacity(value, max, theme)
|
|
const tooltipTitle = title
|
|
const tooltipSubtitle = `${value.toLocaleString()} requests`
|
|
return (
|
|
<Geography
|
|
key={geo.rsmKey}
|
|
geography={geo}
|
|
onMouseMove={(e) => {
|
|
const rect = containerRef.current?.getBoundingClientRect()
|
|
const x = (rect ? e.clientX - rect.left : e.clientX) + 12
|
|
const y = (rect ? e.clientY - rect.top : e.clientY) + 12
|
|
setHoverInfo({
|
|
x,
|
|
y,
|
|
title: tooltipTitle,
|
|
subtitle: tooltipSubtitle,
|
|
visible: true,
|
|
})
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
const rect = containerRef.current?.getBoundingClientRect()
|
|
const x = (rect ? e.clientX - rect.left : e.clientX) + 12
|
|
const y = (rect ? e.clientY - rect.top : e.clientY) + 12
|
|
setHoverInfo({
|
|
x,
|
|
y,
|
|
title: tooltipTitle,
|
|
subtitle: tooltipSubtitle,
|
|
visible: true,
|
|
})
|
|
}}
|
|
onMouseLeave={() => setHoverInfo((prev) => ({ ...prev, visible: false }))}
|
|
style={{
|
|
default: {
|
|
fill: getFillColor(value, max, theme),
|
|
stroke: theme.boundaryStroke,
|
|
strokeWidth: 0.4,
|
|
opacity: baseOpacity,
|
|
outline: 'none',
|
|
cursor: 'default',
|
|
},
|
|
hover: {
|
|
fill: getFillColor(value, max, theme),
|
|
stroke: 'transparent',
|
|
strokeWidth: 0,
|
|
opacity: Math.max(0, baseOpacity * 0.8),
|
|
outline: 'none',
|
|
cursor: 'default',
|
|
},
|
|
pressed: {
|
|
fill: getFillColor(value, max, theme),
|
|
stroke: 'transparent',
|
|
strokeWidth: 0,
|
|
opacity: Math.max(0, baseOpacity * 0.8),
|
|
outline: 'none',
|
|
cursor: 'default',
|
|
},
|
|
}}
|
|
aria-label={`${tooltipTitle} — ${tooltipSubtitle}`}
|
|
/>
|
|
)
|
|
})}
|
|
|
|
{geographies.map((geo) => {
|
|
const title =
|
|
(geo.properties?.name as string) ||
|
|
(geo.properties?.NAME as string) ||
|
|
'Unknown'
|
|
if (!isMicroCountry(title)) return null
|
|
const iso2 = extractIso2FromFeatureProps(
|
|
(geo.properties || undefined) as Record<string, unknown> | undefined
|
|
)
|
|
const value = iso2 ? countsByIso2[iso2] || 0 : 0
|
|
if (value <= 0) return null
|
|
const [lon, lat] = geoCentroid(geo)
|
|
const r = computeMarkerRadius(value, max)
|
|
const tooltipTitle = title
|
|
const tooltipSubtitle = `${value.toLocaleString()} requests`
|
|
return (
|
|
<Marker
|
|
key={`marker-${geo.rsmKey}`}
|
|
coordinates={[lon, lat]}
|
|
onMouseMove={(e) => {
|
|
const rect = containerRef.current?.getBoundingClientRect()
|
|
const x = (rect ? e.clientX - rect.left : e.clientX) + 12
|
|
const y = (rect ? e.clientY - rect.top : e.clientY) + 12
|
|
setHoverInfo({
|
|
x,
|
|
y,
|
|
title: tooltipTitle,
|
|
subtitle: tooltipSubtitle,
|
|
visible: true,
|
|
})
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
const rect = containerRef.current?.getBoundingClientRect()
|
|
const x = (rect ? e.clientX - rect.left : e.clientX) + 12
|
|
const y = (rect ? e.clientY - rect.top : e.clientY) + 12
|
|
setHoverInfo({
|
|
x,
|
|
y,
|
|
title: tooltipTitle,
|
|
subtitle: tooltipSubtitle,
|
|
visible: true,
|
|
})
|
|
}}
|
|
onMouseLeave={() => setHoverInfo((prev) => ({ ...prev, visible: false }))}
|
|
>
|
|
<circle r={r} fill={theme.markerFill} />
|
|
</Marker>
|
|
)
|
|
})}
|
|
|
|
{(() => {
|
|
const present = new Set<string>()
|
|
for (const g of geographies) {
|
|
const code = extractIso2FromFeatureProps(
|
|
(g.properties || undefined) as Record<string, unknown> | undefined
|
|
)
|
|
if (code) present.add(code)
|
|
}
|
|
|
|
const markers: ReactNode[] = []
|
|
for (const iso2 in countsByIso2) {
|
|
const count = countsByIso2[iso2]
|
|
if (count <= 0) continue
|
|
// Do not render Antarctica
|
|
if (iso2.toUpperCase() === 'AQ') continue
|
|
if (present.has(iso2)) continue
|
|
if (!isKnownCountryCode(iso2)) continue
|
|
const ll = COUNTRY_LAT_LON[iso2]
|
|
const r = computeMarkerRadius(count, max)
|
|
const tooltipTitle = iso2ToCountryName(iso2)
|
|
const tooltipSubtitle = `${count.toLocaleString()} requests`
|
|
markers.push(
|
|
<Marker
|
|
key={`fallback-${iso2}`}
|
|
coordinates={[ll.lon, ll.lat]}
|
|
onMouseMove={(e) => {
|
|
const rect = containerRef.current?.getBoundingClientRect()
|
|
const x = (rect ? e.clientX - rect.left : e.clientX) + 12
|
|
const y = (rect ? e.clientY - rect.top : e.clientY) + 12
|
|
setHoverInfo({
|
|
x,
|
|
y,
|
|
title: tooltipTitle,
|
|
subtitle: tooltipSubtitle,
|
|
visible: true,
|
|
})
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
const rect = containerRef.current?.getBoundingClientRect()
|
|
const x = (rect ? e.clientX - rect.left : e.clientX) + 12
|
|
const y = (rect ? e.clientY - rect.top : e.clientY) + 12
|
|
setHoverInfo({
|
|
x,
|
|
y,
|
|
title: tooltipTitle,
|
|
subtitle: tooltipSubtitle,
|
|
visible: true,
|
|
})
|
|
}}
|
|
onMouseLeave={() => setHoverInfo((prev) => ({ ...prev, visible: false }))}
|
|
>
|
|
<circle r={r} fill={theme.markerFill} />
|
|
</Marker>
|
|
)
|
|
}
|
|
|
|
return markers
|
|
})()}
|
|
</>
|
|
)}
|
|
</Geographies>
|
|
</ZoomableGroup>
|
|
</ComposableMap>
|
|
{hoverInfo.visible && (
|
|
<div
|
|
className="pointer-events-none absolute z-10 rounded-sm bg-surface-100 p-1.5 border border-surface-200 text-sm"
|
|
style={{ left: hoverInfo.x, top: hoverInfo.y }}
|
|
>
|
|
<h3 className="text-foreground-lighter text-sm">{hoverInfo.title}</h3>
|
|
<p className="text-foreground text-sm">{hoverInfo.subtitle}</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|