diff --git a/src/core/modules/sandboxes/models.ts b/src/core/modules/sandboxes/models.ts index 36044f9b3..d485fcefe 100644 --- a/src/core/modules/sandboxes/models.ts +++ b/src/core/modules/sandboxes/models.ts @@ -43,7 +43,15 @@ export type SandboxDetailsModel = export interface SandboxLogModel { timestampUnix: number level: SandboxLogLevel + logger?: string message: string + origin?: 'user' | 'platform' + capturedBy?: { + logger?: string + message?: string + event_type?: string + } + fields?: Record } export interface SandboxLogsModel { @@ -141,14 +149,183 @@ export function deriveSandboxLifecycleFromEvents( // mappings -export function mapInfraSandboxLogToModel( - log: InfraComponents['schemas']['SandboxLogEntry'] -): SandboxLogModel { +const LOG_LEVEL_ALIASES: Record = { + trace: 'debug', + debug: 'debug', + info: 'info', + warning: 'warn', + warn: 'warn', + error: 'error', + fatal: 'error', + panic: 'error', +} + +const PROMOTED_DATA_FIELDS = new Set([ + 'level', + 'severity', + 'logger', + 'name', + 'message', + 'msg', +]) + +function getStringField(value: unknown) { + return typeof value === 'string' && value.trim() !== '' + ? value.trim() + : undefined +} + +function normalizeLogLevel(value?: string) { + if (!value) return undefined + + return LOG_LEVEL_ALIASES[value.toLowerCase()] +} + +interface ParsedDataField { + fields?: Record + level?: SandboxLogLevel + logger?: string + message?: string +} + +function visibleDataFields(data: Record) { + const visibleEntries = Object.entries(data).filter( + ([key]) => !PROMOTED_DATA_FIELDS.has(key) + ) + + return visibleEntries.length > 0 + ? Object.fromEntries(visibleEntries) + : undefined +} + +function parseJsonObject(value: string) { + try { + const parsed = JSON.parse(value) + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return undefined + } + + return parsed as Record + } catch { + return undefined + } +} + +function parseJsonLineObjects(value: string) { + const lines = value + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + + if (lines.length <= 1) { + return undefined + } + + const parsedLines: Record[] = [] + for (const line of lines) { + const parsed = parseJsonObject(line) + if (!parsed) { + return undefined + } + + parsedLines.push(parsed) + } + + return parsedLines +} + +function parseDataObject(data: Record): ParsedDataField { return { - timestampUnix: new Date(log.timestamp).getTime(), - level: log.level, - message: log.message, + fields: visibleDataFields(data), + level: normalizeLogLevel( + getStringField(data.level) ?? getStringField(data.severity) + ), + logger: getStringField(data.logger) ?? getStringField(data.name), + message: getStringField(data.message) ?? getStringField(data.msg), + } +} + +function parseDataFields(value?: string): ParsedDataField[] | undefined { + const trimmed = value?.trim() + if (!trimmed) return undefined + + const jsonLines = parseJsonLineObjects(trimmed) + if (jsonLines) { + return jsonLines.map(parseDataObject) } + + try { + const parsed = JSON.parse(trimmed) + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return [{ fields: { data: parsed } }] + } + + return [parseDataObject(parsed as Record)] + } catch { + return [{ fields: { data: trimmed } }] + } +} + +function isCapturedUserLog(log: InfraComponents['schemas']['SandboxLogEntry']) { + return Boolean( + getStringField(log.fields.captured_by_logger) || + getStringField(log.fields.captured_by_message) || + getStringField(log.fields.captured_by_event_type) || + getStringField(log.fields.event_type) === 'stdout' || + getStringField(log.fields.event_type) === 'stderr' || + (getStringField(log.fields.logger) === 'process' && + getStringField(log.message) === 'Streaming process event') + ) +} + +function getCapturedBy(log: InfraComponents['schemas']['SandboxLogEntry']) { + if (!isCapturedUserLog(log)) { + return undefined + } + + const capturedBy = { + logger: + getStringField(log.fields.captured_by_logger) ?? + getStringField(log.fields.logger), + message: + getStringField(log.fields.captured_by_message) ?? + getStringField(log.message), + event_type: + getStringField(log.fields.captured_by_event_type) ?? + getStringField(log.fields.event_type), + } + + return Object.values(capturedBy).some(Boolean) ? capturedBy : undefined +} + +export function mapInfraSandboxLogToModels( + log: InfraComponents['schemas']['SandboxLogEntry'] +): SandboxLogModel[] { + const parsedDataFields = parseDataFields(log.fields.data) + if (!parsedDataFields) { + return [ + { + timestampUnix: new Date(log.timestamp).getTime(), + level: log.level, + logger: log.fields.logger, + message: log.message, + fields: undefined, + }, + ] + } + + const capturedBy = getCapturedBy(log) + const origin = capturedBy ? 'user' : undefined + + return parsedDataFields.map((data) => ({ + timestampUnix: new Date(log.timestamp).getTime(), + level: data?.level ?? log.level, + logger: data?.logger ?? (origin ? undefined : log.fields.logger), + message: data?.message ?? log.message, + origin, + capturedBy, + fields: data?.fields, + })) } export function mapInfraSandboxDetailsToModel( diff --git a/src/core/server/api/routers/sandbox.ts b/src/core/server/api/routers/sandbox.ts index 948367c59..be10a7777 100644 --- a/src/core/server/api/routers/sandbox.ts +++ b/src/core/server/api/routers/sandbox.ts @@ -4,7 +4,7 @@ import { deriveSandboxLifecycleFromEvents, mapApiSandboxRecordToModel, mapInfraSandboxDetailsToModel, - mapInfraSandboxLogToModel, + mapInfraSandboxLogToModels, type SandboxDetailsModel, type SandboxLogModel, type SandboxLogsModel, @@ -102,11 +102,11 @@ export const sandboxRouter = createTRPCRouter({ } const sandboxLogs = sandboxLogsResult.data - const logs: SandboxLogModel[] = sandboxLogs.logs - .map(mapInfraSandboxLogToModel) + const logs: SandboxLogModel[] = [...sandboxLogs.logs] .reverse() + .flatMap(mapInfraSandboxLogToModels) - const hasMore = logs.length === limit + const hasMore = sandboxLogs.logs.length === limit const cursorLog = logs[0] const nextCursor = hasMore ? (cursorLog?.timestampUnix ?? null) : null @@ -145,8 +145,8 @@ export const sandboxRouter = createTRPCRouter({ } const sandboxLogs = sandboxLogsResult.data - const logs: SandboxLogModel[] = sandboxLogs.logs.map( - mapInfraSandboxLogToModel + const logs: SandboxLogModel[] = sandboxLogs.logs.flatMap( + mapInfraSandboxLogToModels ) const newestLog = logs[logs.length - 1] diff --git a/src/features/dashboard/common/log-viewer-ui.tsx b/src/features/dashboard/common/log-viewer-ui.tsx index c62a62209..1e117af6d 100644 --- a/src/features/dashboard/common/log-viewer-ui.tsx +++ b/src/features/dashboard/common/log-viewer-ui.tsx @@ -1,5 +1,5 @@ import type { VirtualItem, Virtualizer } from '@tanstack/react-virtual' -import type { CSSProperties, ReactNode } from 'react' +import type { CSSProperties, HTMLAttributes, ReactNode } from 'react' import { cn } from '@/lib/utils' import { ArrowDownIcon, ListIcon } from '@/ui/primitives/icons' import { Loader } from '@/ui/primitives/loader' @@ -12,14 +12,22 @@ import { } from '@/ui/primitives/table' interface LogsTableHeaderProps { + expanderWidth?: number timestampWidth: number levelWidth: number + loggerWidth?: number + actionsWidth?: number + dataWidth?: number timestampSortDirection?: 'asc' | 'desc' } export function LogsTableHeader({ + expanderWidth, timestampWidth, levelWidth, + loggerWidth, + actionsWidth, + dataWidth, timestampSortDirection = 'desc', }: LogsTableHeaderProps) { return ( @@ -28,6 +36,15 @@ export function LogsTableHeader({ style={{ display: 'grid', position: 'sticky', top: 0, zIndex: 1 }} > + {expanderWidth !== undefined ? ( + + + + ) : null} Level + {loggerWidth !== undefined ? ( + + Logger + + ) : null} Message + {actionsWidth !== undefined ? ( + + + + ) : null} + {dataWidth !== undefined ? ( + + Data + + ) : null} ) @@ -109,10 +151,12 @@ export function getLogVirtualRowStyle( } } -interface LogVirtualRowProps { +interface LogVirtualRowProps + extends Omit, 'children' | 'style'> { virtualRow: VirtualItem virtualizer: Virtualizer height: number + style?: CSSProperties className?: string children: ReactNode } @@ -121,15 +165,18 @@ export function LogVirtualRow({ virtualRow, virtualizer, height, + style, className, children, + ...props }: LogVirtualRowProps) { return ( virtualizer.measureElement(node)} className={className} - style={getLogVirtualRowStyle(virtualRow, height)} + style={{ ...getLogVirtualRowStyle(virtualRow, height), ...style }} + {...props} > {children} diff --git a/src/features/dashboard/sandbox/logs/logs-cells.tsx b/src/features/dashboard/sandbox/logs/logs-cells.tsx index 15c138a55..db9bccfe9 100644 --- a/src/features/dashboard/sandbox/logs/logs-cells.tsx +++ b/src/features/dashboard/sandbox/logs/logs-cells.tsx @@ -102,3 +102,15 @@ export const Message = ({ message, search, shouldHighlight }: MessageProps) => { ) } + +interface LoggerProps { + logger?: SandboxLogModel['logger'] +} + +export const Logger = ({ logger }: LoggerProps) => { + if (!logger) { + return - + } + + return {logger} +} diff --git a/src/features/dashboard/sandbox/logs/logs.tsx b/src/features/dashboard/sandbox/logs/logs.tsx index d9e276a8e..11faf67d6 100644 --- a/src/features/dashboard/sandbox/logs/logs.tsx +++ b/src/features/dashboard/sandbox/logs/logs.tsx @@ -6,6 +6,11 @@ import { type Virtualizer, } from '@tanstack/react-virtual' import { + type Dispatch, + type KeyboardEvent, + type MouseEvent, + type ReactNode, + type SetStateAction, useCallback, useEffect, useLayoutEffect, @@ -26,23 +31,56 @@ import { LogsTableHeader, LogVirtualRow, } from '@/features/dashboard/common/log-viewer-ui' +import { useClipboard } from '@/lib/hooks/use-clipboard' import { cn } from '@/lib/utils' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, +} from '@/ui/primitives/dropdown-menu' +import { IconButton } from '@/ui/primitives/icon-button' +import { + CheckIcon, + ChevronRightIcon, + CopyIcon, + ExternalLinkIcon, + IndicatorDotsIcon, +} from '@/ui/primitives/icons' import { DebouncedInput } from '@/ui/primitives/input' import { Loader } from '@/ui/primitives/loader' import { Table, TableBody, TableCell } from '@/ui/primitives/table' import { useSandboxContext } from '../context' -import { LogLevel, Message, Timestamp } from './logs-cells' +import { Logger, LogLevel, Message, Timestamp } from './logs-cells' import type { LogLevelFilter as SandboxLogLevelFilter } from './logs-filter-params' import useLogFilters from './use-log-filters' import { useSandboxLogs } from './use-sandbox-logs' // column widths are calculated as max width of the content + padding -const COLUMN_WIDTHS_PX = { timestamp: 148 + 16, level: 48 + 16 } as const +const COLUMN_WIDTHS_PX = { + expander: 28, + timestamp: 148 + 16, + level: 48 + 16, + logger: 180, +} as const const ROW_HEIGHT_PX = 26 +const LOG_DETAILS_MIN_HEIGHT_PX = 96 +const LOG_DETAILS_PADDING_Y_PX = 24 +const LOG_DETAILS_ACTIONS_HEIGHT_PX = 24 +const LOG_DETAILS_ACTIONS_GAP_PX = 8 +const LOG_DETAILS_LINE_HEIGHT_PX = 20 +const LOG_DETAILS_FIELD_GAP_PX = 6 +const LOG_DETAILS_ENTRY_HEADER_HEIGHT_PX = 28 +const LOG_DETAILS_ENTRY_GAP_PX = 12 const LIVE_STATUS_ROW_HEIGHT_PX = ROW_HEIGHT_PX + 16 const VIRTUAL_OVERSCAN = 16 const SCROLL_LOAD_THRESHOLD_PX = 200 const LOG_RETENTION_DAYS = LOG_RETENTION_MS / 24 / 60 / 60 / 1000 +const STRUCTURED_LOG_ENTRIES_FIELD = 'entries' +const LOG_DETAILS_APPROX_CHARS_PER_LINE = 110 +const LOG_DEEP_LINK_PARAM = 'log' interface LogsProps { teamSlug: string @@ -77,8 +115,10 @@ export default function SandboxLogs({ teamSlug, sandboxId }: LogsProps) {
@@ -199,8 +239,10 @@ function LogsContent({ >
@@ -336,6 +378,9 @@ function VirtualizedLogsBody({ search, }: VirtualizedLogsBodyProps) { const maxWidthRef = useRef(0) + const [expandedLogIds, setExpandedLogIds] = useState>( + () => new Set() + ) useScrollLoadMore({ scrollContainerElement, @@ -355,15 +400,56 @@ function VirtualizedLogsBody({ const liveStatusRowIndex = logsStartIndex + logs.length const virtualRowsCount = logs.length + (showLoadMoreStatusRow ? 1 : 0) + 1 + const toggleLogExpanded = useCallback((logId: string) => { + setExpandedLogIds((current) => { + const next = new Set(current) + if (next.has(logId)) { + next.delete(logId) + } else { + next.add(logId) + } + + return next + }) + }, []) + const virtualizer = useVirtualizer({ count: virtualRowsCount, - estimateSize: (index) => - index === liveStatusRowIndex ? LIVE_STATUS_ROW_HEIGHT_PX : ROW_HEIGHT_PX, + estimateSize: (index) => { + if (index === liveStatusRowIndex) { + return LIVE_STATUS_ROW_HEIGHT_PX + } + + if (showLoadMoreStatusRow && index === 0) { + return ROW_HEIGHT_PX + } + + const logIndex = index - logsStartIndex + const log = logs[logIndex] + if (!log) { + return ROW_HEIGHT_PX + } + + const logId = getLogRowId(log, logIndex) + return getLogRowHeight(log, expandedLogIds.has(logId)) + }, getScrollElement: () => scrollContainerElement, overscan: VIRTUAL_OVERSCAN, paddingStart: 8, }) + useEffect(() => { + virtualizer.measure() + }, [expandedLogIds, virtualizer]) + + useScrollToDeepLinkedLog({ + logs, + logsStartIndex, + scrollContainerElement, + setExpandedLogIds, + virtualizer, + }) + const scrollToLatestLog = useCallback(() => { if (logs.length === 0) return virtualizer.scrollToIndex(liveStatusRowIndex, { align: 'end' }) @@ -437,14 +523,19 @@ function VirtualizedLogsBody({ if (!log) { return null } + const logId = getLogRowId(log, logIndex) + const isExpanded = expandedLogIds.has(logId) return ( @@ -611,37 +702,350 @@ function useAutoScrollToBottom({ } interface LogRowProps { + logId: string log: SandboxLogModel search: string shouldHighlight: boolean isZebraRow: boolean + isExpanded: boolean + onToggleExpanded: (logId: string) => void virtualRow: VirtualItem virtualizer: Virtualizer } +function getLogRowId(log: SandboxLogModel, logIndex: number) { + return getLogFingerprint(log) || `${log.timestampUnix}:${logIndex}` +} + +function getLogFingerprint(log: SandboxLogModel) { + return `${log.timestampUnix}:${hashString( + stableStringify({ + fields: log.fields, + capturedBy: log.capturedBy, + level: log.level, + logger: log.logger, + message: log.message, + origin: log.origin, + }) + )}` +} + +function stableStringify(value: unknown): string { + if (Array.isArray(value)) { + return `[${value.map(stableStringify).join(',')}]` + } + + if (isRecord(value)) { + return `{${Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`) + .join(',')}}` + } + + return JSON.stringify(value) +} + +function hashString(value: string) { + let hash = 5381 + + for (let index = 0; index < value.length; index += 1) { + hash = (hash * 33) ^ value.charCodeAt(index) + } + + return (hash >>> 0).toString(36) +} + +function getLogFieldEntries(log: SandboxLogModel) { + const entries: [string, unknown][] = [] + + if (log.origin) { + entries.push(['origin', log.origin]) + } + + if (log.capturedBy) { + entries.push(['captured_by', log.capturedBy]) + } + + if (!log.fields) { + return entries + } + + const structuredEntries = getStructuredLogEntries(log) + + return [ + ...entries, + ...Object.entries(log.fields).filter( + ([key, value]) => + value !== undefined && + !(key === STRUCTURED_LOG_ENTRIES_FIELD && structuredEntries.length > 0) + ), + ] +} + +function getStructuredLogEntries(log: SandboxLogModel) { + const entries = log.fields?.[STRUCTURED_LOG_ENTRIES_FIELD] + + return Array.isArray(entries) ? entries : [] +} + +function hasLogFields(log: SandboxLogModel) { + return ( + getLogFieldEntries(log).length > 0 || + getStructuredLogEntries(log).length > 0 + ) +} + +function getLogDetailsHeight(log: SandboxLogModel) { + const fieldEntries = getLogFieldEntries(log) + const structuredEntries = getStructuredLogEntries(log) + if (fieldEntries.length === 0 && structuredEntries.length === 0) { + return 0 + } + + const structuredEntriesHeight = structuredEntries.reduce( + (totalHeight, entry) => totalHeight + getStructuredEntryHeight(entry), + 0 + ) + + const structuredEntriesGapHeight = + structuredEntries.length > 1 + ? (structuredEntries.length - 1) * LOG_DETAILS_ENTRY_GAP_PX + : 0 + + const separateFieldListGapHeight = + structuredEntries.length > 0 && fieldEntries.length > 0 + ? LOG_DETAILS_ENTRY_GAP_PX + : 0 + + return Math.max( + LOG_DETAILS_MIN_HEIGHT_PX, + LOG_DETAILS_PADDING_Y_PX + + LOG_DETAILS_ACTIONS_HEIGHT_PX + + LOG_DETAILS_ACTIONS_GAP_PX + + structuredEntriesHeight + + structuredEntriesGapHeight + + separateFieldListGapHeight + + getLogFieldListHeight(fieldEntries) + ) +} + +function getLogRowHeight(log: SandboxLogModel, isExpanded: boolean) { + return ROW_HEIGHT_PX + (isExpanded ? getLogDetailsHeight(log) : 0) +} + +function isInteractiveTarget(target: EventTarget | null) { + return ( + target instanceof HTMLElement && + Boolean(target.closest('button,a,input,select,textarea,[role="button"]')) + ) +} + +function formatLogFieldValue(value: unknown) { + if (typeof value === 'string') { + return value + } + + return JSON.stringify(value, null, 2) +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value) +} + +function getFormattedValueLineCount(value: unknown) { + return formatLogFieldValue(value) + .split('\n') + .reduce( + (lineCount, line) => + lineCount + + Math.max(1, Math.ceil(line.length / LOG_DETAILS_APPROX_CHARS_PER_LINE)), + 0 + ) +} + +function getLogFieldHeight([, value]: [string, unknown]) { + return getFormattedValueLineCount(value) * LOG_DETAILS_LINE_HEIGHT_PX +} + +function getLogFieldListHeight(entries: [string, unknown][]) { + if (entries.length === 0) { + return 0 + } + + return ( + entries.reduce( + (totalHeight, entry) => totalHeight + getLogFieldHeight(entry), + 0 + ) + + (entries.length - 1) * LOG_DETAILS_FIELD_GAP_PX + ) +} + +function getStructuredEntryHeight(entry: unknown) { + const bodyHeight = isRecord(entry) + ? getLogFieldListHeight(Object.entries(entry)) + : getFormattedValueLineCount(entry) * LOG_DETAILS_LINE_HEIGHT_PX + + return LOG_DETAILS_ENTRY_HEADER_HEIGHT_PX + bodyHeight +} + +interface UseScrollToDeepLinkedLogParams { + logs: SandboxLogModel[] + logsStartIndex: number + scrollContainerElement: HTMLDivElement + setExpandedLogIds: Dispatch>> + virtualizer: Virtualizer +} + +function useScrollToDeepLinkedLog({ + logs, + logsStartIndex, + scrollContainerElement, + setExpandedLogIds, + virtualizer, +}: UseScrollToDeepLinkedLogParams) { + const didScrollRef = useRef(false) + + useEffect(() => { + if (didScrollRef.current || logs.length === 0) { + return + } + + const targetLogId = new URLSearchParams(window.location.search).get( + LOG_DEEP_LINK_PARAM + ) + if (!targetLogId) { + return + } + + const logIndex = logs.findIndex( + (log, index) => getLogRowId(log, index) === targetLogId + ) + if (logIndex === -1) { + return + } + + didScrollRef.current = true + setExpandedLogIds((current) => new Set(current).add(targetLogId)) + + requestAnimationFrame(() => { + virtualizer.scrollToIndex(logsStartIndex + logIndex, { align: 'center' }) + scrollContainerElement.focus({ preventScroll: true }) + }) + }, [ + logs, + logsStartIndex, + scrollContainerElement, + setExpandedLogIds, + virtualizer, + ]) +} + function LogRow({ + logId, log, search, shouldHighlight, isZebraRow, + isExpanded, + onToggleExpanded, virtualRow, virtualizer, }: LogRowProps) { + const canExpand = hasLogFields(log) + const rowHeight = getLogRowHeight(log, isExpanded) + + const toggleExpanded = useCallback(() => { + if (canExpand) { + onToggleExpanded(logId) + } + }, [canExpand, logId, onToggleExpanded]) + + const onRowClick = useCallback( + (event: MouseEvent) => { + if (isInteractiveTarget(event.target)) { + return + } + + toggleExpanded() + }, + [toggleExpanded] + ) + + const onRowKeyDown = useCallback( + (event: KeyboardEvent) => { + if (!canExpand || isInteractiveTarget(event.target)) { + return + } + + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + toggleExpanded() + } + }, + [canExpand, toggleExpanded] + ) + return ( + + {canExpand ? ( + + ) : null} + @@ -651,14 +1055,27 @@ function LogRow({ style={{ display: 'flex', alignItems: 'center', - width: COLUMN_WIDTHS_PX.level, }} > + + + + {isExpanded ? : null} ) } +interface LogDetailsActionsProps { + log: SandboxLogModel + logId: string +} + +function LogDetailsActions({ log, logId }: LogDetailsActionsProps) { + const link = getLogDeepLink(logId) + const json = getLogJson(log) + + return ( + + + + + + + + + Options + } + /> + } + /> + + + + ) +} + +interface LogActionMenuItemProps { + value: string + label: string + copiedLabel: string + icon: ReactNode +} + +function LogActionMenuItem({ + value, + label, + copiedLabel, + icon, +}: LogActionMenuItemProps) { + const [wasCopied, copy] = useClipboard() + + const handleSelect = useCallback( + (event: Event) => { + event.preventDefault() + copy(value) + }, + [copy, value] + ) + + return ( + + {wasCopied ? : icon} + {wasCopied ? copiedLabel : label} + + ) +} + +function getLogDeepLink(logId: string) { + if (typeof window === 'undefined') { + return '' + } + + const url = new URL(window.location.href) + url.searchParams.set(LOG_DEEP_LINK_PARAM, logId) + url.hash = '' + return url.toString() +} + +function getLogJson(log: SandboxLogModel) { + const jsonValue: Record = { + timestamp: new Date(log.timestampUnix).toISOString(), + level: log.level, + message: log.message, + } + + if (log.origin) { + jsonValue.origin = log.origin + } + + if (log.logger) { + jsonValue.logger = log.logger + } + + if (log.capturedBy) { + jsonValue.captured_by = log.capturedBy + } + + if (log.fields) { + jsonValue.fields = log.fields + } + + return JSON.stringify(jsonValue, null, 2) +} + +interface LogFieldsDetailsProps { + log: SandboxLogModel + logId: string +} + +function LogFieldsDetails({ log, logId }: LogFieldsDetailsProps) { + const entries = getLogFieldEntries(log) + const structuredEntries = getStructuredLogEntries(log) + if (entries.length === 0 && structuredEntries.length === 0) { + return null + } + + return ( + event.stopPropagation()} + > +
+
+ +
+
+ {structuredEntries.length > 0 ? ( +
+ {structuredEntries.map((entry, index) => ( + + ))} +
+ ) : null} + {entries.length > 0 ? ( + + ) : null} +
+
+
+ ) +} + +interface StructuredLogEntryProps { + index: number + entry: unknown +} + +function StructuredLogEntry({ index, entry }: StructuredLogEntryProps) { + return ( +
+
+ Entry {index + 1} +
+ {isRecord(entry) ? ( + + ) : ( +
+          {formatLogFieldValue(entry)}
+        
+ )} +
+ ) +} + +interface LogFieldListProps { + className?: string + entries: [string, unknown][] +} + +function LogFieldList({ className, entries }: LogFieldListProps) { + return ( +
+ {entries.map(([key, value]) => ( +
+
+ {key} +
+
+
+              {formatLogFieldValue(value)}
+            
+
+
+ ))} +
+ ) +} + interface StatusRowProps { virtualRow: VirtualItem virtualizer: Virtualizer