DefaultPreviewSelectionRenderer.tsx262 lines · main
| 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 | |
| 15 | import { ErrorCodeDialog } from '../ErrorCodeDialog' |
| 16 | import type { LogSearchCallback, PreviewLogData } from '../Logs.types' |
| 17 | import { ResponseCodeFormatter } from '../LogsFormatters' |
| 18 | import { ErrorCodeTooltip } from '@/components/ui/ErrorCodeTooltip/ErrorCodeTooltip' |
| 19 | import { Service } from '@/data/graphql/graphql' |
| 20 | import { useLogsUrlState } from '@/hooks/analytics/useLogsUrlState' |
| 21 | |
| 22 | const 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 | |
| 33 | const LogRowSeparator = () => <Separator className="bg-border my-1" /> |
| 34 | |
| 35 | const 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 | |
| 235 | const 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 | |
| 262 | export default DefaultPreviewSelectionRenderer |