Files
supabase/apps/studio/components/interfaces/Reports/renderers/ApiRenderers.tsx
Gildas Garcia 0713a1efc1 chore: remove shadcn suffix for Input, Textarea, Alert and Collapsible (#45867)
## 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"
/>
2026-05-15 14:55:37 +02:00

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>
)
}