Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
189 changes: 183 additions & 6 deletions src/core/modules/sandboxes/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>
}

export interface SandboxLogsModel {
Expand Down Expand Up @@ -141,14 +149,183 @@ export function deriveSandboxLifecycleFromEvents(

// mappings

export function mapInfraSandboxLogToModel(
log: InfraComponents['schemas']['SandboxLogEntry']
): SandboxLogModel {
const LOG_LEVEL_ALIASES: Record<string, SandboxLogLevel> = {
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<string, unknown>
level?: SandboxLogLevel
logger?: string
message?: string
}

function visibleDataFields(data: Record<string, unknown>) {
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<string, unknown>
} 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<string, unknown>[] = []
for (const line of lines) {
const parsed = parseJsonObject(line)
if (!parsed) {
return undefined
}

parsedLines.push(parsed)
}

return parsedLines
}

function parseDataObject(data: Record<string, unknown>): 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<string, unknown>)]
} 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(
Expand Down
12 changes: 6 additions & 6 deletions src/core/server/api/routers/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
deriveSandboxLifecycleFromEvents,
mapApiSandboxRecordToModel,
mapInfraSandboxDetailsToModel,
mapInfraSandboxLogToModel,
mapInfraSandboxLogToModels,
type SandboxDetailsModel,
type SandboxLogModel,
type SandboxLogsModel,
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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]
Expand Down
55 changes: 51 additions & 4 deletions src/features/dashboard/common/log-viewer-ui.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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 (
Expand All @@ -28,6 +36,15 @@ export function LogsTableHeader({
style={{ display: 'grid', position: 'sticky', top: 0, zIndex: 1 }}
>
<TableRow style={{ display: 'flex', minWidth: '100%' }}>
{expanderWidth !== undefined ? (
<TableHead
aria-label="Log details"
className="px-0 h-min pb-3"
style={{ display: 'flex', width: expanderWidth }}
>
<span />
</TableHead>
) : null}
<TableHead
data-state="selected"
className="px-0 h-min pb-3 pr-4 text-fg"
Expand All @@ -46,12 +63,37 @@ export function LogsTableHeader({
>
Level
</TableHead>
{loggerWidth !== undefined ? (
<TableHead
className="px-0 h-min pb-3 pr-4"
style={{ display: 'flex', width: loggerWidth }}
>
Logger
</TableHead>
) : null}
<TableHead
className="px-0 h-min pb-3"
className="px-0 h-min pb-3 pr-4"
style={{ display: 'flex', flex: 1 }}
>
Message
</TableHead>
{actionsWidth !== undefined ? (
<TableHead
aria-label="Log actions"
className="px-0 h-min pb-3"
style={{ display: 'flex', width: actionsWidth }}
>
<span />
</TableHead>
) : null}
{dataWidth !== undefined ? (
<TableHead
className="px-0 h-min pb-3"
style={{ display: 'flex', width: dataWidth }}
>
Data
</TableHead>
) : null}
</TableRow>
</TableHeader>
)
Expand Down Expand Up @@ -109,10 +151,12 @@ export function getLogVirtualRowStyle(
}
}

interface LogVirtualRowProps {
interface LogVirtualRowProps
extends Omit<HTMLAttributes<HTMLTableRowElement>, 'children' | 'style'> {
virtualRow: VirtualItem
virtualizer: Virtualizer<HTMLDivElement, Element>
height: number
style?: CSSProperties
className?: string
children: ReactNode
}
Expand All @@ -121,15 +165,18 @@ export function LogVirtualRow({
virtualRow,
virtualizer,
height,
style,
className,
children,
...props
}: LogVirtualRowProps) {
return (
<TableRow
data-index={virtualRow.index}
ref={(node) => virtualizer.measureElement(node)}
className={className}
style={getLogVirtualRowStyle(virtualRow, height)}
style={{ ...getLogVirtualRowStyle(virtualRow, height), ...style }}
{...props}
>
{children}
</TableRow>
Expand Down
12 changes: 12 additions & 0 deletions src/features/dashboard/sandbox/logs/logs-cells.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,3 +102,15 @@ export const Message = ({ message, search, shouldHighlight }: MessageProps) => {
</span>
)
}

interface LoggerProps {
logger?: SandboxLogModel['logger']
}

export const Logger = ({ logger }: LoggerProps) => {
if (!logger) {
return <span className="text-fg-tertiary font-mono text-xs">-</span>
}

return <span className="font-mono text-xs whitespace-nowrap">{logger}</span>
}
Loading
Loading