ApiRenderers.tsx 22 KB

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