Skip to content
Merged
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
146 changes: 46 additions & 100 deletions src/features/dashboard/build/logs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,12 @@ import {
type VirtualItem,
type Virtualizer,
} from '@tanstack/react-virtual'
import {
type RefObject,
useCallback,
useEffect,
useReducer,
useRef,
useState,
} from 'react'
import { type RefObject, useCallback, useEffect, useRef, useState } from 'react'
import {
LOG_LEVEL_LEFT_BORDER_CLASS,
type LogLevelValue,
} from '@/features/dashboard/common/log-cells'
import { LogLevelFilter } from '@/features/dashboard/common/log-level-filter'
import {
LogStatusCell,
LogsEmptyBody,
Expand All @@ -29,26 +23,11 @@ import type {
BuildDetailsDTO,
BuildLogDTO,
} from '@/server/api/models/builds.models'
import { Button } from '@/ui/primitives/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from '@/ui/primitives/dropdown-menu'
import { Loader } from '@/ui/primitives/loader'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/ui/primitives/table'
import { Table, TableBody, TableCell } from '@/ui/primitives/table'
import { LOG_RETENTION_MS } from '../templates/builds/constants'
import { LogLevel, Message, Timestamp } from './logs-cells'
import type { LogLevelFilter } from './logs-filter-params'
import type { LogLevelFilter as BuildLogLevelFilter } from './logs-filter-params'
import { useBuildLogs } from './use-build-logs'
import useLogFilters from './use-log-filters'

Expand All @@ -59,13 +38,6 @@ const LIVE_STATUS_ROW_HEIGHT_PX = ROW_HEIGHT_PX + 16
const VIRTUAL_OVERSCAN = 16
const SCROLL_LOAD_THRESHOLD_PX = 200

const LEVEL_OPTIONS: Array<{ value: LogLevelFilter; label: string }> = [
{ value: 'debug', label: 'Debug' },
{ value: 'info', label: 'Info' },
{ value: 'warn', label: 'Warn' },
{ value: 'error', label: 'Error' },
]

interface LogsProps {
buildDetails: BuildDetailsDTO | undefined
teamIdOrSlug: string
Expand All @@ -86,7 +58,11 @@ export default function Logs({
if (!buildDetails) {
return (
<div className="flex h-full min-h-0 flex-col overflow-hidden relative gap-3">
<LevelFilter level={level} onLevelChange={setLevel} />
<LogLevelFilter
level={level}
onLevelChange={setLevel}
renderOption={(optionLevel) => <LogLevel level={optionLevel} />}
/>
<div className="min-h-0 flex-1 overflow-auto">
<Table style={{ display: 'grid', minWidth: 'min-content' }}>
<LogsTableHeader
Expand Down Expand Up @@ -118,8 +94,8 @@ interface LogsContentProps {
teamIdOrSlug: string
templateId: string
buildId: string
level: LogLevelFilter | null
setLevel: (level: LogLevelFilter | null) => void
level: BuildLogLevelFilter | null
setLevel: (level: BuildLogLevelFilter | null) => void
}

function LogsContent({
Expand All @@ -131,6 +107,7 @@ function LogsContent({
setLevel,
}: LogsContentProps) {
const scrollContainerRef = useRef<HTMLDivElement>(null)
const [lastNonEmptyLogs, setLastNonEmptyLogs] = useState<BuildLogDTO[]>([])

const { isRefetchingFromFilterChange, onFetchComplete } =
useFilterRefetchTracking(level)
Expand All @@ -155,8 +132,19 @@ function LogsContent({
onFetchComplete()
}
}, [isFetching, isRefetchingFromFilterChange, onFetchComplete])

const hasLogs = logs.length > 0
useEffect(() => {
if (logs.length > 0) {
setLastNonEmptyLogs(logs)
}
}, [logs])

const renderedLogs =
logs.length > 0
? logs
: isRefetchingFromFilterChange
? lastNonEmptyLogs
: []
const hasLogs = renderedLogs.length > 0
const showLoader = (isFetching || isRefetchingFromFilterChange) && !hasLogs
const showEmpty = !isFetching && !hasLogs && !isRefetchingFromFilterChange
const showRefetchOverlay = isRefetchingFromFilterChange && hasLogs
Expand All @@ -170,7 +158,11 @@ function LogsContent({

return (
<div className="flex h-full min-h-0 flex-col overflow-hidden relative gap-3">
<LevelFilter level={level} onLevelChange={setLevel} />
<LogLevelFilter
level={level}
onLevelChange={setLevel}
renderOption={(optionLevel) => <LogLevel level={optionLevel} />}
/>

<div ref={scrollContainerRef} className="min-h-0 flex-1 overflow-auto">
<Table style={{ display: 'grid', minWidth: 'min-content' }}>
Expand All @@ -186,7 +178,7 @@ function LogsContent({
)}
{hasLogs && (
<VirtualizedLogsBody
logs={logs}
logs={renderedLogs}
scrollContainerRef={scrollContainerRef}
startedAt={buildDetails.startedAt}
onLoadMore={handleLoadMore}
Expand All @@ -204,16 +196,22 @@ function LogsContent({
)
}

function useFilterRefetchTracking(level: LogLevelFilter | null) {
function useFilterRefetchTracking(level: BuildLogLevelFilter | null) {
const [isRefetchingFromFilterChange, setIsRefetching] = useState(false)
const isInitialRender = useRef(true)
const previousLevelRef = useRef<BuildLogLevelFilter | null>(level)

useEffect(() => {
if (isInitialRender.current) {
isInitialRender.current = false
previousLevelRef.current = level
return
}
setIsRefetching(true)

if (previousLevelRef.current !== level) {
previousLevelRef.current = level
setIsRefetching(true)
}
}, [level])

const onFetchComplete = useCallback(() => setIsRefetching(false), [])
Expand All @@ -233,62 +231,6 @@ function EmptyBody({ hasRetainedLogs }: EmptyBodyProps) {
return <LogsEmptyBody description={description} />
}

interface LevelFilterProps {
level: LogLevelFilter | null
onLevelChange: (level: LogLevelFilter | null) => void
}

function LevelFilter({ level, onLevelChange }: LevelFilterProps) {
const selectedLevel = level ?? 'debug'
const selectedLabel = LEVEL_OPTIONS.find(
(o) => o.value === selectedLevel
)?.label

return (
<div className="flex w-full min-h-0 justify-between gap-3">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
className="font-sans w-min normal-case prose-body-highlight h-9"
>
<LevelIndicator level={selectedLevel} />
Min Level · {selectedLabel}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuRadioGroup
value={selectedLevel}
onValueChange={(value) => onLevelChange(value as LogLevelFilter)}
>
{LEVEL_OPTIONS.map((option) => (
<DropdownMenuRadioItem key={option.value} value={option.value}>
<LogLevel level={option.value} />
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}

function LevelIndicator({ level }: { level: LogLevelFilter }) {
return (
<div
className={cn(
'size-3.5 rounded-full bg-bg border-[1.5px] border-dashed',
{
'border-fg-tertiary': level === 'debug',
'border-accent-info-highlight': level === 'info',
'border-accent-warning-highlight': level === 'warn',
'border-accent-error-highlight': level === 'error',
}
)}
/>
)
}

interface VirtualizedLogsBodyProps {
logs: BuildLogDTO[]
scrollContainerRef: RefObject<HTMLDivElement | null>
Expand All @@ -298,7 +240,7 @@ interface VirtualizedLogsBodyProps {
isFetchingNextPage: boolean
showRefetchOverlay: boolean
isInitialized: boolean
level: LogLevelFilter | null
level: BuildLogLevelFilter | null
isBuilding: boolean
}

Expand Down Expand Up @@ -402,11 +344,15 @@ function VirtualizedLogsBody({
}

const logIndex = virtualRow.index - logsStartIndex
const log = logs[logIndex]
if (!log) {
return null
}

return (
<LogRow
key={virtualRow.key}
log={logs[logIndex]!}
log={log}
isZebraRow={logIndex % 2 === 1}
virtualRow={virtualRow}
virtualizer={virtualizer}
Expand Down Expand Up @@ -487,7 +433,7 @@ interface UseAutoScrollToBottomParams {
scrollContainerRef: RefObject<HTMLDivElement | null>
logsCount: number
isInitialized: boolean
level: LogLevelFilter | null
level: BuildLogLevelFilter | null
}

function useAutoScrollToBottom({
Expand Down
2 changes: 1 addition & 1 deletion src/features/dashboard/build/use-build-logs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { useStore } from 'zustand'
import type { BuildStatus } from '@/server/api/models/builds.models'
import { useTRPCClient } from '@/trpc/client'
import { type BuildLogsStore, createBuildLogsStore } from './build-logs-store'
import { type LogLevelFilter } from './logs-filter-params'
import type { LogLevelFilter } from './logs-filter-params'

const REFETCH_INTERVAL_MS = 1_500
const DRAIN_AFTER_BUILD_STOP_WINDOW_MS = 10_000
Expand Down
81 changes: 81 additions & 0 deletions src/features/dashboard/common/log-level-filter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import type { ReactNode } from 'react'
import { cn } from '@/lib/utils'
import { Button } from '@/ui/primitives/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from '@/ui/primitives/dropdown-menu'
import type { LogLevelValue } from './log-cells'

const DEFAULT_OPTIONS: Array<{ value: LogLevelValue; label: string }> = [
{ value: 'debug', label: 'Debug' },
{ value: 'info', label: 'Info' },
{ value: 'warn', label: 'Warn' },
{ value: 'error', label: 'Error' },
]

interface LogLevelFilterProps {
level: LogLevelValue | null
onLevelChange: (level: LogLevelValue | null) => void
options?: Array<{ value: LogLevelValue; label: string }>
renderOption?: (level: LogLevelValue) => ReactNode
className?: string
}

export function LogLevelFilter({
level,
onLevelChange,
options = DEFAULT_OPTIONS,
renderOption,
className,
}: LogLevelFilterProps) {
const selectedLevel = level ?? 'debug'
const selectedLabel = options.find((o) => o.value === selectedLevel)?.label

return (
<div className={cn('flex w-full min-h-0 justify-between gap-3', className)}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
className="font-sans w-min normal-case prose-body-highlight h-9"
>
<LevelIndicator level={selectedLevel} />
Min Level · {selectedLabel}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuRadioGroup
value={selectedLevel}
onValueChange={(value) => onLevelChange(value as LogLevelValue)}
>
{options.map((option) => (
<DropdownMenuRadioItem key={option.value} value={option.value}>
{renderOption ? renderOption(option.value) : option.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}

function LevelIndicator({ level }: { level: LogLevelValue }) {
return (
<div
className={cn(
'size-3.5 rounded-full bg-bg border-[1.5px] border-dashed',
{
'border-fg-tertiary': level === 'debug',
'border-accent-info-highlight': level === 'info',
'border-accent-warning-highlight': level === 'warn',
'border-accent-error-highlight': level === 'error',
}
)}
/>
)
}
Loading
Loading