DefaultPreviewSelectionRenderer.tsx 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262
  1. import { useEffect, useState } from 'react'
  2. import { toast } from 'sonner'
  3. import {
  4. Button,
  5. cn,
  6. copyToClipboard,
  7. DropdownMenu,
  8. DropdownMenuContent,
  9. DropdownMenuItem,
  10. DropdownMenuTrigger,
  11. Separator,
  12. } from 'ui'
  13. import { TimestampInfo } from 'ui-patterns'
  14. import { ErrorCodeDialog } from '../ErrorCodeDialog'
  15. import type { LogSearchCallback, PreviewLogData } from '../Logs.types'
  16. import { ResponseCodeFormatter } from '../LogsFormatters'
  17. import { ErrorCodeTooltip } from '@/components/ui/ErrorCodeTooltip/ErrorCodeTooltip'
  18. import { Service } from '@/data/graphql/graphql'
  19. import { useLogsUrlState } from '@/hooks/analytics/useLogsUrlState'
  20. const LogRowCodeBlock = ({ value, className }: { value: string; className?: string }) => (
  21. <pre
  22. className={cn(
  23. 'px-1 bg-surface-300 w-full pt-1 max-w-full border-none text-xs prose-sm transition-all overflow-auto rounded-md whitespace-pre-wrap',
  24. className
  25. )}
  26. >
  27. {typeof value === 'string' ? value : JSON.stringify(value, null, 2)}
  28. </pre>
  29. )
  30. const LogRowSeparator = () => <Separator className="bg-border my-1" />
  31. const PropertyRow = ({
  32. keyName,
  33. value,
  34. dataTestId,
  35. path,
  36. }: {
  37. keyName: string
  38. value: any
  39. dataTestId?: string
  40. path?: string
  41. }) => {
  42. const { setSearch } = useLogsUrlState()
  43. const [showErrorInfo, setShowErrorInfo] = useState(false)
  44. const service = path?.startsWith('/auth/') ? Service.Auth : undefined
  45. const handleSearch: LogSearchCallback = async (_event: string, { query }: { query?: string }) => {
  46. setSearch(query || '')
  47. }
  48. const isTimestamp =
  49. keyName === 'timestamp' || keyName === 'created_at' || keyName === 'updated_at'
  50. const isObject = typeof value === 'object' && value !== null
  51. const isStatus = keyName === 'status' || keyName === 'status_code'
  52. const isMethod = keyName === 'method'
  53. const isSearch = keyName === 'search'
  54. const isUserAgent = keyName === 'user_agent'
  55. const isEventMessage = keyName === 'event_message'
  56. const isPath = keyName === 'path'
  57. const isErrorCode = keyName === 'error_code'
  58. function getSearchPairs() {
  59. if (isSearch && typeof value === 'string') {
  60. const str = value.startsWith('?') ? value.slice(1) : value
  61. return str.split('&').filter(Boolean)
  62. }
  63. return []
  64. }
  65. const storageKey = `log-viewer-expanded-${keyName}`
  66. const [isExpanded, setIsExpanded] = useState(() => {
  67. try {
  68. // Storing in local storage so users dont have to click expand every time they change selected log
  69. return JSON.parse(localStorage.getItem(storageKey) ?? 'false')
  70. } catch (_) {
  71. return false
  72. }
  73. })
  74. const [isCopied, setIsCopied] = useState(false)
  75. useEffect(() => {
  76. localStorage.setItem(storageKey, JSON.stringify(isExpanded))
  77. }, [isExpanded, storageKey])
  78. const handleCopy = () => {
  79. copyToClipboard(String(value), () => {
  80. setIsCopied(true)
  81. toast.success('Copied to clipboard')
  82. })
  83. setTimeout(() => {
  84. setIsCopied(false)
  85. }, 1000)
  86. }
  87. if (isObject || isEventMessage) {
  88. return (
  89. <>
  90. <div className="flex flex-col gap-1">
  91. <h3 className="text-foreground-lighter text-sm pl-3 py-2">{keyName}</h3>
  92. <div>
  93. <LogRowCodeBlock
  94. className={cn('px-2.5', {
  95. 'max-h-[80px]': !isExpanded,
  96. 'max-h-[400px]': isExpanded,
  97. 'py-2': isEventMessage,
  98. })}
  99. value={value}
  100. />
  101. {!isEventMessage && (
  102. <Button
  103. className="mt-1 w-full"
  104. size="tiny"
  105. type="outline"
  106. onClick={() => setIsExpanded(!isExpanded)}
  107. >
  108. {isExpanded ? 'Collapse' : 'Expand'}
  109. </Button>
  110. )}
  111. </div>
  112. </div>
  113. <LogRowSeparator />
  114. </>
  115. )
  116. }
  117. return (
  118. <>
  119. <DropdownMenu>
  120. <DropdownMenuTrigger className="group w-full" data-testid={dataTestId}>
  121. <div className="rounded-md w-full overflow-hidden">
  122. <div
  123. className={cn('flex h-(--header-height) w-full', {
  124. 'flex-col gap-1.5 h-auto': isExpanded,
  125. 'items-center group-hover:bg-surface-300 gap-4': !isExpanded,
  126. })}
  127. >
  128. <h3
  129. className={cn('pl-3 text-foreground-lighter text-sm text-left', {
  130. 'h-(--header-height) flex items-center': isExpanded,
  131. })}
  132. >
  133. {keyName}
  134. </h3>
  135. <div
  136. className={cn('text-xs flex-1 font-mono text-foreground pr-3', {
  137. 'max-w-full text-left rounded-md p-2 bg-surface-300 text-xs w-full': isExpanded,
  138. 'truncate text-right': !isExpanded,
  139. 'text-brand-600': isCopied,
  140. })}
  141. >
  142. {isExpanded ? (
  143. <LogRowCodeBlock value={value} />
  144. ) : isTimestamp ? (
  145. <TimestampInfo className="text-sm" utcTimestamp={value} />
  146. ) : isStatus ? (
  147. <div className="flex items-center gap-1 justify-end">
  148. <ResponseCodeFormatter value={value} />
  149. </div>
  150. ) : isMethod ? (
  151. <div className="flex items-center gap-1 justify-end">
  152. <ResponseCodeFormatter value={value} />
  153. </div>
  154. ) : isErrorCode ? (
  155. <ErrorCodeTooltip errorCode={String(value)} service={service}>
  156. <div className="truncate">{value}</div>
  157. </ErrorCodeTooltip>
  158. ) : (
  159. <div className="truncate">{value}</div>
  160. )}
  161. </div>
  162. </div>
  163. </div>
  164. </DropdownMenuTrigger>
  165. <DropdownMenuContent align="start">
  166. {keyName === 'error_code' && (
  167. <DropdownMenuItem
  168. onClick={() => {
  169. setShowErrorInfo(true)
  170. }}
  171. >
  172. More information
  173. </DropdownMenuItem>
  174. )}
  175. <DropdownMenuItem onClick={handleCopy}>Copy {keyName}</DropdownMenuItem>
  176. {!isObject && (
  177. <DropdownMenuItem
  178. onClick={() => {
  179. setIsExpanded(!isExpanded)
  180. }}
  181. >
  182. {isExpanded ? 'Collapse' : 'Expand'} value
  183. </DropdownMenuItem>
  184. )}
  185. {(isMethod || isUserAgent || isStatus || isPath) && (
  186. <DropdownMenuItem
  187. onClick={() => {
  188. handleSearch('search-input-change', { query: value })
  189. }}
  190. >
  191. Search by {keyName}
  192. </DropdownMenuItem>
  193. )}
  194. {isSearch
  195. ? getSearchPairs().map((pair) => (
  196. <DropdownMenuItem
  197. key={pair}
  198. onClick={() => {
  199. handleSearch('search-input-change', { query: pair })
  200. }}
  201. >
  202. Search by {pair}
  203. </DropdownMenuItem>
  204. ))
  205. : null}
  206. </DropdownMenuContent>
  207. <LogRowSeparator />
  208. </DropdownMenu>
  209. {keyName === 'error_code' && (
  210. <ErrorCodeDialog
  211. open={showErrorInfo}
  212. onOpenChange={setShowErrorInfo}
  213. errorCode={String(value)}
  214. service={service}
  215. />
  216. )}
  217. </>
  218. )
  219. }
  220. const DefaultPreviewSelectionRenderer = ({ log }: { log: PreviewLogData }) => {
  221. const { timestamp, event_message, metadata, id, status, ...rest } = log
  222. const path = typeof log.path === 'string' ? log.path : undefined
  223. const log_file = log?.metadata?.[0]?.log_file
  224. return (
  225. <div data-testid="log-selection" className={`p-2 flex flex-col`}>
  226. {log?.id && (
  227. <PropertyRow dataTestId="log-selection-id" key={'id'} keyName={'id'} value={log.id} />
  228. )}
  229. {log?.status && <PropertyRow key={'status'} keyName={'status'} value={log.status} />}
  230. {log?.timestamp && (
  231. <PropertyRow key={'timestamp'} keyName={'timestamp'} value={log.timestamp} />
  232. )}
  233. {Object.entries(rest).map(([key, value]) => {
  234. return <PropertyRow key={key} keyName={key} value={value} path={path} />
  235. })}
  236. {log?.event_message && (
  237. <PropertyRow key="event_message" keyName="event_message" value={log.event_message} />
  238. )}
  239. {!!log_file && <PropertyRow key="log_file" keyName="log_file" value={log_file} />}
  240. {log?.metadata && <PropertyRow key="metadata" keyName="metadata" value={log.metadata} />}
  241. </div>
  242. )
  243. }
  244. export default DefaultPreviewSelectionRenderer