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