ApiRenderers.tsx635 lines · main
1import { geoCentroid } from 'd3-geo'
2import sumBy from 'lodash/sumBy'
3import { ChevronRight } from 'lucide-react'
4import { useTheme } from 'next-themes'
5import { Fragment, useRef, useState, type ReactNode } from 'react'
6import { ComposableMap, Geographies, Geography, Marker, ZoomableGroup } from 'react-simple-maps'
7import {
8 Alert,
9 AlertDescription,
10 AlertTitle,
11 Button,
12 Collapsible,
13 CollapsibleContent,
14 CollapsibleTrigger,
15 WarningIcon,
16} from 'ui'
17import * as z from 'zod'
18
19import { queryParamsToObject } from '../Reports.utils'
20import { ReportWidgetProps, ReportWidgetRendererProps } from '../ReportWidget'
21import { COUNTRY_LAT_LON } from '@/components/interfaces/ProjectCreation/ProjectCreation.constants'
22import {
23 buildCountsByIso2,
24 computeMarkerRadius,
25 extractIso2FromFeatureProps,
26 getFillColor,
27 getFillOpacity,
28 isKnownCountryCode,
29 isMicroCountry,
30 iso2ToCountryName,
31 MAP_CHART_THEME,
32} from '@/components/interfaces/Reports/utils/geo'
33import {
34 jsonSyntaxHighlight,
35 TextFormatter,
36} from '@/components/interfaces/Settings/Logs/LogsFormatters'
37import Table from '@/components/to-be-cleaned/Table'
38import AlertError from '@/components/ui/AlertError'
39import BarChart from '@/components/ui/Charts/BarChart'
40import { DataTableColumnStatusCode } from '@/components/ui/DataTable/DataTableColumn/DataTableColumnStatusCode'
41import { useFillTimeseriesSorted } from '@/hooks/analytics/useFillTimeseriesSorted'
42import { BASE_PATH } from '@/lib/constants'
43import type { ResponseError } from '@/types'
44
45export const NetworkTrafficRenderer = (
46 props: ReportWidgetProps<{
47 timestamp: string
48 ingress: number
49 egress: number
50 }>
51) => {
52 const { data, error, isError } = useFillTimeseriesSorted({
53 data: props.data,
54 timestampKey: 'timestamp',
55 valueKey: ['ingress_mb', 'egress_mb'],
56 defaultValue: 0,
57 startDate: props.params?.iso_timestamp_start,
58 endDate: props.params?.iso_timestamp_end,
59 })
60
61 const totalIngress = sumBy(props.data, 'ingress_mb')
62 const totalEgress = sumBy(props.data, 'egress_mb')
63
64 function determinePrecision(valueInMb: number) {
65 return valueInMb < 0.001 ? 7 : totalIngress > 1 ? 2 : 4
66 }
67
68 if (!!props.error) {
69 const error = (
70 typeof props.error === 'string' ? { message: props.error } : props.error
71 ) as ResponseError
72 return <AlertError subject="Failed to retrieve network traffic" error={error} />
73 } else if (isError) {
74 return (
75 <Alert variant="warning">
76 <WarningIcon />
77 <AlertTitle>Failed to retrieve network traffic</AlertTitle>
78 <AlertDescription>{error?.message ?? 'Unknown error'}</AlertDescription>
79 </Alert>
80 )
81 }
82
83 return (
84 <div className="flex flex-col gap-12 w-full">
85 <BarChart
86 size="small"
87 title="Ingress"
88 highlightedValue={sumBy(props.data, 'ingress_mb')}
89 format="MB"
90 className="w-full"
91 valuePrecision={determinePrecision(totalIngress)}
92 data={data}
93 yAxisKey="ingress_mb"
94 xAxisKey="timestamp"
95 displayDateInUtc
96 />
97
98 <BarChart
99 size="small"
100 title="Egress"
101 highlightedValue={totalEgress}
102 format="MB"
103 valuePrecision={determinePrecision(totalEgress)}
104 className="w-full"
105 data={data}
106 yAxisKey="egress_mb"
107 xAxisKey="timestamp"
108 displayDateInUtc
109 />
110 </div>
111 )
112}
113
114export const TotalRequestsChartRenderer = (
115 props: ReportWidgetProps<{
116 timestamp: string
117 count: number
118 }>
119) => {
120 const total = props.data.reduce((acc, datum) => {
121 return acc + datum.count
122 }, 0)
123 const { data, error, isError } = useFillTimeseriesSorted({
124 data: props.data,
125 timestampKey: 'timestamp',
126 valueKey: 'count',
127 defaultValue: 0,
128 startDate: props.params?.iso_timestamp_start,
129 endDate: props.params?.iso_timestamp_end,
130 })
131
132 if (!!props.error) {
133 const error = (
134 typeof props.error === 'string' ? { message: props.error } : props.error
135 ) as ResponseError
136 return <AlertError subject="Failed to retrieve total requests" error={error} />
137 } else if (isError) {
138 return (
139 <Alert variant="warning">
140 <WarningIcon />
141 <AlertTitle>Failed to retrieve total requests</AlertTitle>
142 <AlertDescription>{error?.message ?? 'Unknown error'}</AlertDescription>
143 </Alert>
144 )
145 }
146
147 return (
148 <BarChart
149 size="small"
150 minimalHeader
151 highlightedValue={total}
152 className="w-full"
153 data={data}
154 yAxisKey="count"
155 xAxisKey="timestamp"
156 displayDateInUtc
157 />
158 )
159}
160
161export const TopApiRoutesRenderer = (
162 props: ReportWidgetRendererProps<{
163 method: string
164 // shown for error table but not all requests table
165 status_code?: number
166 path: string
167 search: string
168 count: number
169 // used for response speed table only
170 avg?: number
171 }>
172) => {
173 const [showMore, setShowMore] = useState(false)
174
175 const headerClasses = 'text-xs! py-2! p-0 font-bold bg-surface-200! border-x-0! rounded-none!'
176 const cellClasses = 'text-xs! py-2! border-x-0! rounded-none! align-middle'
177
178 if (props.data.length === 0) return null
179
180 return (
181 <>
182 <Table
183 className="rounded-t-none"
184 containerClassName="overflow-x-auto"
185 head={
186 <>
187 <Table.th className={headerClasses}>Request</Table.th>
188 <Table.th className={headerClasses + ' text-right'}>Count</Table.th>
189 {props.data[0].avg !== undefined && (
190 <Table.th className={headerClasses + ' text-right'}>Avg</Table.th>
191 )}
192 </>
193 }
194 body={
195 <>
196 {props.data.map((datum, index) => (
197 <Fragment key={index + datum.method + datum.path + (datum.search || '')}>
198 <Table.tr
199 className={[
200 'p-0 transition transform cursor-pointer hover:bg-surface-200',
201 showMore && index >= 3 ? 'w-full h-full opacity-100' : '',
202 !showMore && index >= 3 ? ' w-0 h-0 translate-y-10 opacity-0' : '',
203 ].join(' ')}
204 >
205 {(!showMore && index < 3) || showMore ? (
206 <>
207 <Table.td className={[cellClasses].join(' ')}>
208 <RouteTdContent {...datum} />
209 </Table.td>
210 <Table.td className={[cellClasses, 'text-right'].join(' ')}>
211 {datum.count}
212 </Table.td>
213 {props.data[0].avg !== undefined && (
214 <Table.td className={[cellClasses, 'text-right'].join(' ')}>
215 {Number(datum.avg).toFixed(2)}ms
216 </Table.td>
217 )}
218 </>
219 ) : null}
220 </Table.tr>
221 </Fragment>
222 ))}
223 </>
224 }
225 />
226 <div className="flex flex-row justify-end w-full gap-2 p-1">
227 <Button
228 type="text"
229 onClick={() => setShowMore(!showMore)}
230 className={[
231 'transition',
232 showMore ? 'text-foreground' : 'text-foreground-lighter',
233 props.data.length <= 3 ? 'hidden' : '',
234 ].join(' ')}
235 >
236 {!showMore ? 'Show more' : 'Show less'}
237 </Button>
238 </div>
239 </>
240 )
241}
242
243export const ErrorCountsChartRenderer = (
244 props: ReportWidgetProps<{
245 timestamp: string
246 count: number
247 }>
248) => {
249 const total = props.data.reduce((acc, datum) => {
250 return acc + datum.count
251 }, 0)
252
253 const { data, error, isError } = useFillTimeseriesSorted({
254 data: props.data,
255 timestampKey: 'timestamp',
256 valueKey: 'count',
257 defaultValue: 0,
258 startDate: props.params?.iso_timestamp_start,
259 endDate: props.params?.iso_timestamp_end,
260 })
261
262 if (!!props.error) {
263 const error = (
264 typeof props.error === 'string' ? { message: props.error } : props.error
265 ) as ResponseError
266 return <AlertError subject="Failed to retrieve request errors" error={error} />
267 } else if (isError) {
268 return (
269 <Alert variant="warning">
270 <WarningIcon />
271 <AlertTitle>Failed to retrieve request errors</AlertTitle>
272 <AlertDescription>{error?.message ?? 'Unknown error'}</AlertDescription>
273 </Alert>
274 )
275 }
276
277 return (
278 <BarChart
279 size="small"
280 minimalHeader
281 className="w-full"
282 highlightedValue={total}
283 data={data}
284 yAxisKey="count"
285 xAxisKey="timestamp"
286 displayDateInUtc
287 />
288 )
289}
290
291export const ResponseSpeedChartRenderer = (
292 props: ReportWidgetProps<{
293 timestamp: string
294 avg: number
295 }>
296) => {
297 const transformedData = props.data.map((datum) => ({
298 timestamp: datum.timestamp,
299 avg: datum.avg,
300 }))
301
302 const { data, error, isError } = useFillTimeseriesSorted({
303 data: transformedData,
304 timestampKey: 'timestamp',
305 valueKey: 'avg',
306 defaultValue: 0,
307 startDate: props.params?.iso_timestamp_start,
308 endDate: props.params?.iso_timestamp_end,
309 })
310
311 const lastAvg = props.data[props.data.length - 1]?.avg
312
313 if (!!props.error) {
314 const error = (
315 typeof props.error === 'string' ? { message: props.error } : props.error
316 ) as ResponseError
317 return <AlertError subject="Failed to retrieve response speeds" error={error} />
318 } else if (isError) {
319 return (
320 <Alert variant="warning">
321 <WarningIcon />
322 <AlertTitle>Failed to retrieve response speeds</AlertTitle>
323 <AlertDescription>{error?.message ?? 'Unknown error'}</AlertDescription>
324 </Alert>
325 )
326 }
327
328 return (
329 <BarChart
330 size="small"
331 highlightedValue={lastAvg}
332 format="ms"
333 minimalHeader
334 className="w-full"
335 data={data}
336 yAxisKey="avg"
337 xAxisKey="timestamp"
338 displayDateInUtc
339 />
340 )
341}
342
343interface RouteTdContentProps {
344 method: string
345 status_code?: number
346 path: string
347 search: string
348}
349const RouteTdContent = (datum: RouteTdContentProps) => (
350 <Collapsible>
351 <CollapsibleTrigger asChild>
352 <div className="flex gap-2 items-center">
353 <Button asChild type="text" className=" py-0! p-1!" title="Show more route details">
354 <span>
355 <ChevronRight
356 size={14}
357 className="transition data-open-parent:rotate-90 data-closed-parent:rotate-0"
358 />
359 </span>
360 </Button>
361 <TextFormatter
362 className="w-10 h-4 text-center rounded-sm bg-surface-300"
363 value={datum.method}
364 />
365 {datum.status_code && (
366 <DataTableColumnStatusCode
367 value={datum.status_code}
368 level={String(Math.floor(datum.status_code / 100))}
369 />
370 )}
371 <div className=" truncate max-w-sm lg:max-w-lg">
372 <TextFormatter className="text-foreground-light" value={datum.path} />
373 <TextFormatter
374 className="max-w-sm text-foreground-lighter truncate "
375 value={decodeURIComponent(datum.search || '')}
376 />
377 </div>
378 </div>
379 </CollapsibleTrigger>
380 <CollapsibleContent className="pt-2">
381 {datum.search ? (
382 <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!">
383 <div
384 className="text-wrap"
385 dangerouslySetInnerHTML={{
386 __html: jsonSyntaxHighlight(queryParamsToObject(datum.search)),
387 }}
388 />
389 </pre>
390 ) : (
391 <p className="text-xs text-foreground-lighter">No query parameters in this request</p>
392 )}
393 </CollapsibleContent>
394 </Collapsible>
395)
396export const RequestsByCountryMapRenderer = (
397 props: ReportWidgetProps<{
398 country: string | null
399 count: number
400 }>
401) => {
402 const WORLD_TOPO_URL = `${BASE_PATH}/json/worldmap.json`
403 const containerRef = useRef<HTMLDivElement | null>(null)
404 const [hoverInfo, setHoverInfo] = useState<{
405 x: number
406 y: number
407 title: string
408 subtitle: string
409 visible: boolean
410 }>({ x: 0, y: 0, title: '', subtitle: '', visible: false })
411
412 const countsByIso2 = buildCountsByIso2(props.data)
413 const max = Object.values(countsByIso2).reduce((m, v) => (v > m ? v : m), 0)
414 const { resolvedTheme } = useTheme()
415 const theme = resolvedTheme === 'dark' ? MAP_CHART_THEME.dark : MAP_CHART_THEME.light
416
417 if (!!props.error) {
418 const AlertErrorSchema = z.object({ message: z.string() })
419 const parsed =
420 typeof props.error === 'string'
421 ? { success: true, data: { message: props.error } }
422 : AlertErrorSchema.safeParse(props.error)
423 const alertError = parsed.success ? parsed.data : null
424 return <AlertError subject="Failed to retrieve requests by geography" error={alertError} />
425 }
426
427 return (
428 <div ref={containerRef} className="w-full h-[420px] relative border-t">
429 <ComposableMap
430 projection="geoMercator"
431 projectionConfig={{ scale: 155 }}
432 className="w-full h-full"
433 style={{ backgroundColor: theme.oceanFill }}
434 >
435 <ZoomableGroup minZoom={1} maxZoom={5} zoom={1.3}>
436 <Geographies geography={WORLD_TOPO_URL}>
437 {({ geographies }) => (
438 <>
439 {geographies.map((geo) => {
440 const title =
441 (geo.properties?.name as string) ||
442 (geo.properties?.NAME as string) ||
443 'Unknown'
444 const iso2 = extractIso2FromFeatureProps(
445 (geo.properties || undefined) as Record<string, unknown> | undefined
446 )
447 const value = iso2 ? countsByIso2[iso2] || 0 : 0
448 const baseOpacity = getFillOpacity(value, max, theme)
449 const tooltipTitle = title
450 const tooltipSubtitle = `${value.toLocaleString()} requests`
451 return (
452 <Geography
453 key={geo.rsmKey}
454 geography={geo}
455 onMouseMove={(e) => {
456 const rect = containerRef.current?.getBoundingClientRect()
457 const x = (rect ? e.clientX - rect.left : e.clientX) + 12
458 const y = (rect ? e.clientY - rect.top : e.clientY) + 12
459 setHoverInfo({
460 x,
461 y,
462 title: tooltipTitle,
463 subtitle: tooltipSubtitle,
464 visible: true,
465 })
466 }}
467 onMouseEnter={(e) => {
468 const rect = containerRef.current?.getBoundingClientRect()
469 const x = (rect ? e.clientX - rect.left : e.clientX) + 12
470 const y = (rect ? e.clientY - rect.top : e.clientY) + 12
471 setHoverInfo({
472 x,
473 y,
474 title: tooltipTitle,
475 subtitle: tooltipSubtitle,
476 visible: true,
477 })
478 }}
479 onMouseLeave={() => setHoverInfo((prev) => ({ ...prev, visible: false }))}
480 style={{
481 default: {
482 fill: getFillColor(value, max, theme),
483 stroke: theme.boundaryStroke,
484 strokeWidth: 0.4,
485 opacity: baseOpacity,
486 outline: 'none',
487 cursor: 'default',
488 },
489 hover: {
490 fill: getFillColor(value, max, theme),
491 stroke: 'transparent',
492 strokeWidth: 0,
493 opacity: Math.max(0, baseOpacity * 0.8),
494 outline: 'none',
495 cursor: 'default',
496 },
497 pressed: {
498 fill: getFillColor(value, max, theme),
499 stroke: 'transparent',
500 strokeWidth: 0,
501 opacity: Math.max(0, baseOpacity * 0.8),
502 outline: 'none',
503 cursor: 'default',
504 },
505 }}
506 aria-label={`${tooltipTitle} — ${tooltipSubtitle}`}
507 />
508 )
509 })}
510
511 {geographies.map((geo) => {
512 const title =
513 (geo.properties?.name as string) ||
514 (geo.properties?.NAME as string) ||
515 'Unknown'
516 if (!isMicroCountry(title)) return null
517 const iso2 = extractIso2FromFeatureProps(
518 (geo.properties || undefined) as Record<string, unknown> | undefined
519 )
520 const value = iso2 ? countsByIso2[iso2] || 0 : 0
521 if (value <= 0) return null
522 const [lon, lat] = geoCentroid(geo)
523 const r = computeMarkerRadius(value, max)
524 const tooltipTitle = title
525 const tooltipSubtitle = `${value.toLocaleString()} requests`
526 return (
527 <Marker
528 key={`marker-${geo.rsmKey}`}
529 coordinates={[lon, lat]}
530 onMouseMove={(e) => {
531 const rect = containerRef.current?.getBoundingClientRect()
532 const x = (rect ? e.clientX - rect.left : e.clientX) + 12
533 const y = (rect ? e.clientY - rect.top : e.clientY) + 12
534 setHoverInfo({
535 x,
536 y,
537 title: tooltipTitle,
538 subtitle: tooltipSubtitle,
539 visible: true,
540 })
541 }}
542 onMouseEnter={(e) => {
543 const rect = containerRef.current?.getBoundingClientRect()
544 const x = (rect ? e.clientX - rect.left : e.clientX) + 12
545 const y = (rect ? e.clientY - rect.top : e.clientY) + 12
546 setHoverInfo({
547 x,
548 y,
549 title: tooltipTitle,
550 subtitle: tooltipSubtitle,
551 visible: true,
552 })
553 }}
554 onMouseLeave={() => setHoverInfo((prev) => ({ ...prev, visible: false }))}
555 >
556 <circle r={r} fill={theme.markerFill} />
557 </Marker>
558 )
559 })}
560
561 {(() => {
562 const present = new Set<string>()
563 for (const g of geographies) {
564 const code = extractIso2FromFeatureProps(
565 (g.properties || undefined) as Record<string, unknown> | undefined
566 )
567 if (code) present.add(code)
568 }
569
570 const markers: ReactNode[] = []
571 for (const iso2 in countsByIso2) {
572 const count = countsByIso2[iso2]
573 if (count <= 0) continue
574 // Do not render Antarctica
575 if (iso2.toUpperCase() === 'AQ') continue
576 if (present.has(iso2)) continue
577 if (!isKnownCountryCode(iso2)) continue
578 const ll = COUNTRY_LAT_LON[iso2]
579 const r = computeMarkerRadius(count, max)
580 const tooltipTitle = iso2ToCountryName(iso2)
581 const tooltipSubtitle = `${count.toLocaleString()} requests`
582 markers.push(
583 <Marker
584 key={`fallback-${iso2}`}
585 coordinates={[ll.lon, ll.lat]}
586 onMouseMove={(e) => {
587 const rect = containerRef.current?.getBoundingClientRect()
588 const x = (rect ? e.clientX - rect.left : e.clientX) + 12
589 const y = (rect ? e.clientY - rect.top : e.clientY) + 12
590 setHoverInfo({
591 x,
592 y,
593 title: tooltipTitle,
594 subtitle: tooltipSubtitle,
595 visible: true,
596 })
597 }}
598 onMouseEnter={(e) => {
599 const rect = containerRef.current?.getBoundingClientRect()
600 const x = (rect ? e.clientX - rect.left : e.clientX) + 12
601 const y = (rect ? e.clientY - rect.top : e.clientY) + 12
602 setHoverInfo({
603 x,
604 y,
605 title: tooltipTitle,
606 subtitle: tooltipSubtitle,
607 visible: true,
608 })
609 }}
610 onMouseLeave={() => setHoverInfo((prev) => ({ ...prev, visible: false }))}
611 >
612 <circle r={r} fill={theme.markerFill} />
613 </Marker>
614 )
615 }
616
617 return markers
618 })()}
619 </>
620 )}
621 </Geographies>
622 </ZoomableGroup>
623 </ComposableMap>
624 {hoverInfo.visible && (
625 <div
626 className="pointer-events-none absolute z-10 rounded-sm bg-surface-100 p-1.5 border border-surface-200 text-sm"
627 style={{ left: hoverInfo.x, top: hoverInfo.y }}
628 >
629 <h3 className="text-foreground-lighter text-sm">{hoverInfo.title}</h3>
630 <p className="text-foreground text-sm">{hoverInfo.subtitle}</p>
631 </div>
632 )}
633 </div>
634 )
635}