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