diff --git a/src/features/dashboard/build/logs.tsx b/src/features/dashboard/build/logs.tsx
index 6b15c15e8..7843380cd 100644
--- a/src/features/dashboard/build/logs.tsx
+++ b/src/features/dashboard/build/logs.tsx
@@ -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,
@@ -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'
@@ -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
@@ -86,7 +58,11 @@ export default function Logs({
if (!buildDetails) {
return (
-
+
}
+ />
void
+ level: BuildLogLevelFilter | null
+ setLevel: (level: BuildLogLevelFilter | null) => void
}
function LogsContent({
@@ -131,6 +107,7 @@ function LogsContent({
setLevel,
}: LogsContentProps) {
const scrollContainerRef = useRef(null)
+ const [lastNonEmptyLogs, setLastNonEmptyLogs] = useState([])
const { isRefetchingFromFilterChange, onFetchComplete } =
useFilterRefetchTracking(level)
@@ -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
@@ -170,7 +158,11 @@ function LogsContent({
return (
-
+
}
+ />
@@ -186,7 +178,7 @@ function LogsContent({
)}
{hasLogs && (
(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), [])
@@ -233,62 +231,6 @@ function EmptyBody({ hasRetainedLogs }: EmptyBodyProps) {
return
}
-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 (
-
-
-
-
-
-
- onLevelChange(value as LogLevelFilter)}
- >
- {LEVEL_OPTIONS.map((option) => (
-
-
-
- ))}
-
-
-
-
- )
-}
-
-function LevelIndicator({ level }: { level: LogLevelFilter }) {
- return (
-
- )
-}
-
interface VirtualizedLogsBodyProps {
logs: BuildLogDTO[]
scrollContainerRef: RefObject
@@ -298,7 +240,7 @@ interface VirtualizedLogsBodyProps {
isFetchingNextPage: boolean
showRefetchOverlay: boolean
isInitialized: boolean
- level: LogLevelFilter | null
+ level: BuildLogLevelFilter | null
isBuilding: boolean
}
@@ -402,11 +344,15 @@ function VirtualizedLogsBody({
}
const logIndex = virtualRow.index - logsStartIndex
+ const log = logs[logIndex]
+ if (!log) {
+ return null
+ }
return (
logsCount: number
isInitialized: boolean
- level: LogLevelFilter | null
+ level: BuildLogLevelFilter | null
}
function useAutoScrollToBottom({
diff --git a/src/features/dashboard/build/use-build-logs.ts b/src/features/dashboard/build/use-build-logs.ts
index 244f70121..a1499bf3f 100644
--- a/src/features/dashboard/build/use-build-logs.ts
+++ b/src/features/dashboard/build/use-build-logs.ts
@@ -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
diff --git a/src/features/dashboard/common/log-level-filter.tsx b/src/features/dashboard/common/log-level-filter.tsx
new file mode 100644
index 000000000..b2818e2dc
--- /dev/null
+++ b/src/features/dashboard/common/log-level-filter.tsx
@@ -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 (
+
+
+
+
+
+
+ onLevelChange(value as LogLevelValue)}
+ >
+ {options.map((option) => (
+
+ {renderOption ? renderOption(option.value) : option.label}
+
+ ))}
+
+
+
+
+ )
+}
+
+function LevelIndicator({ level }: { level: LogLevelValue }) {
+ return (
+
+ )
+}
diff --git a/src/features/dashboard/sandbox/logs/logs-cells.tsx b/src/features/dashboard/sandbox/logs/logs-cells.tsx
index 031fb903d..29412fd9d 100644
--- a/src/features/dashboard/sandbox/logs/logs-cells.tsx
+++ b/src/features/dashboard/sandbox/logs/logs-cells.tsx
@@ -1,7 +1,4 @@
-import {
- LogLevelBadge,
- LogMessage,
-} from '@/features/dashboard/common/log-cells'
+import { LogLevelBadge } from '@/features/dashboard/common/log-cells'
import type { SandboxLogDTO } from '@/server/api/models/sandboxes.models'
import CopyButtonInline from '@/ui/copy-button-inline'
@@ -47,8 +44,61 @@ export const Timestamp = ({ timestampUnix }: TimestampProps) => {
interface MessageProps {
message: SandboxLogDTO['message']
+ search: string
+ shouldHighlight: boolean
}
-export const Message = ({ message }: MessageProps) => {
- return
+function getMessageSegments(message: string, search: string) {
+ if (!search) {
+ return [{ text: message, isMatch: false }]
+ }
+
+ const segments: Array<{ text: string; isMatch: boolean }> = []
+ let startIndex = 0
+
+ while (startIndex < message.length) {
+ const matchIndex = message.indexOf(search, startIndex)
+ if (matchIndex === -1) {
+ segments.push({ text: message.slice(startIndex), isMatch: false })
+ break
+ }
+
+ if (matchIndex > startIndex) {
+ segments.push({
+ text: message.slice(startIndex, matchIndex),
+ isMatch: false,
+ })
+ }
+
+ segments.push({
+ text: message.slice(matchIndex, matchIndex + search.length),
+ isMatch: true,
+ })
+ startIndex = matchIndex + search.length
+ }
+
+ return segments
+}
+
+export const Message = ({ message, search, shouldHighlight }: MessageProps) => {
+ const segments = shouldHighlight
+ ? getMessageSegments(message, search)
+ : [{ text: message, isMatch: false }]
+
+ return (
+
+ {segments.map((segment, index) =>
+ segment.isMatch ? (
+
+ {segment.text}
+
+ ) : (
+ {segment.text}
+ )
+ )}
+
+ )
}
diff --git a/src/features/dashboard/sandbox/logs/logs-filter-params.ts b/src/features/dashboard/sandbox/logs/logs-filter-params.ts
new file mode 100644
index 000000000..912decd32
--- /dev/null
+++ b/src/features/dashboard/sandbox/logs/logs-filter-params.ts
@@ -0,0 +1,13 @@
+import { createLoader, parseAsString, parseAsStringEnum } from 'nuqs/server'
+import type { SandboxLogDTO } from '@/server/api/models/sandboxes.models'
+
+export type LogLevelFilter = SandboxLogDTO['level']
+
+export const LOG_LEVELS: LogLevelFilter[] = ['debug', 'info', 'warn', 'error']
+
+export const sandboxLogsFilterParams = {
+ level: parseAsStringEnum(['debug', 'info', 'warn', 'error'] as const),
+ search: parseAsString,
+}
+
+export const loadSandboxLogsFilters = createLoader(sandboxLogsFilterParams)
diff --git a/src/features/dashboard/sandbox/logs/logs.tsx b/src/features/dashboard/sandbox/logs/logs.tsx
index ab994420f..cf4e2968e 100644
--- a/src/features/dashboard/sandbox/logs/logs.tsx
+++ b/src/features/dashboard/sandbox/logs/logs.tsx
@@ -17,6 +17,7 @@ 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,
@@ -24,11 +25,15 @@ import {
LogsTableHeader,
LogVirtualRow,
} from '@/features/dashboard/common/log-viewer-ui'
+import { cn } from '@/lib/utils'
import type { SandboxLogDTO } from '@/server/api/models/sandboxes.models'
+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 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
@@ -58,10 +63,17 @@ export default function SandboxLogs({ teamIdOrSlug, sandboxId }: LogsProps) {
'use no memo'
const { sandboxInfo, isRunning } = useSandboxContext()
+ const { level, setLevel, search, setSearch } = useLogFilters()
if (!sandboxInfo) {
return (
-
+
+
)
}
@@ -93,6 +109,10 @@ interface LogsContentProps {
sandboxId: string
isRunning: boolean
hasRetainedLogs: boolean
+ level: SandboxLogLevelFilter | null
+ search: string
+ setLevel: (level: SandboxLogLevelFilter | null) => void
+ setSearch: (search: string) => void
}
function LogsContent({
@@ -100,9 +120,14 @@ function LogsContent({
sandboxId,
isRunning,
hasRetainedLogs,
+ level,
+ search,
+ setLevel,
+ setSearch,
}: LogsContentProps) {
const [scrollContainerElement, setScrollContainerElement] =
useState(null)
+ const [lastNonEmptyLogs, setLastNonEmptyLogs] = useState([])
const {
logs,
@@ -117,11 +142,38 @@ function LogsContent({
teamIdOrSlug,
sandboxId,
isRunning,
+ level,
+ search,
})
+ const { isRefetchingFromFilterChange, onFetchComplete } =
+ useFilterRefetchTracking(level, search)
- const hasLogs = logs.length > 0
+ useEffect(() => {
+ if (!isFetching && isRefetchingFromFilterChange) {
+ onFetchComplete()
+ }
+ }, [isFetching, isRefetchingFromFilterChange, onFetchComplete])
+
+ useEffect(() => {
+ if (logs.length > 0) {
+ setLastNonEmptyLogs(logs)
+ }
+ }, [logs])
+
+ const renderedLogs =
+ logs.length > 0
+ ? logs
+ : isRefetchingFromFilterChange
+ ? lastNonEmptyLogs
+ : []
+ const hasLogs = renderedLogs.length > 0
const showLoader = (!hasCompletedInitialLoad || isFetching) && !hasLogs
- const showEmpty = hasCompletedInitialLoad && !isFetching && !hasLogs
+ const showEmpty =
+ hasCompletedInitialLoad &&
+ !isFetching &&
+ !hasLogs &&
+ !isRefetchingFromFilterChange
+ const showRefetchOverlay = isRefetchingFromFilterChange && hasLogs
const handleLoadMore = useCallback(() => {
if (hasNextPage && !isFetchingNextPage) {
@@ -130,7 +182,13 @@ function LogsContent({
}, [fetchNextPage, hasNextPage, isFetchingNextPage])
return (
-
@@ -166,6 +227,38 @@ function LogsContent({
)
}
+function useFilterRefetchTracking(
+ level: SandboxLogLevelFilter | null,
+ search: string
+) {
+ const [isRefetchingFromFilterChange, setIsRefetching] = useState(false)
+ const isInitialRender = useRef(true)
+ const previousLevelRef = useRef
(level)
+ const previousSearchRef = useRef(search)
+
+ useEffect(() => {
+ if (isInitialRender.current) {
+ isInitialRender.current = false
+ previousLevelRef.current = level
+ previousSearchRef.current = search
+ return
+ }
+
+ const levelChanged = previousLevelRef.current !== level
+ const searchChanged = previousSearchRef.current !== search
+
+ if (levelChanged || searchChanged) {
+ previousLevelRef.current = level
+ previousSearchRef.current = search
+ setIsRefetching(true)
+ }
+ }, [level, search])
+
+ const onFetchComplete = useCallback(() => setIsRefetching(false), [])
+
+ return { isRefetchingFromFilterChange, onFetchComplete }
+}
+
interface EmptyBodyProps {
hasRetainedLogs: boolean
errorMessage: string | null
@@ -181,6 +274,38 @@ function EmptyBody({ hasRetainedLogs, errorMessage }: EmptyBodyProps) {
return
}
+interface FiltersRowProps {
+ level: SandboxLogLevelFilter | null
+ onLevelChange: (level: SandboxLogLevelFilter | null) => void
+ search: string
+ onSearchChange: (search: string) => void
+}
+
+function FiltersRow({
+ level,
+ onLevelChange,
+ search,
+ onSearchChange,
+}: FiltersRowProps) {
+ return (
+
+ }
+ />
+ onSearchChange(String(value))}
+ placeholder="Filter log message..."
+ maxLength={256}
+ className="h-9 max-w-sm"
+ />
+
+ )
+}
+
interface VirtualizedLogsBodyProps {
logs: SandboxLogDTO[]
scrollContainerElement: HTMLDivElement
@@ -189,6 +314,9 @@ interface VirtualizedLogsBodyProps {
isFetchingNextPage: boolean
isInitialized: boolean
isRunning: boolean
+ showRefetchOverlay: boolean
+ level: SandboxLogLevelFilter | null
+ search: string
}
function VirtualizedLogsBody({
@@ -199,6 +327,9 @@ function VirtualizedLogsBody({
isFetchingNextPage,
isInitialized,
isRunning,
+ showRefetchOverlay,
+ level,
+ search,
}: VirtualizedLogsBodyProps) {
const maxWidthRef = useRef(0)
@@ -240,6 +371,8 @@ function VirtualizedLogsBody({
isFetchingNextPage,
isInitialized,
isRunning,
+ level,
+ search,
scrollToLatestLog,
})
@@ -255,7 +388,10 @@ function VirtualizedLogsBody({
return (
void
}
@@ -384,11 +527,15 @@ function useAutoScrollToBottom({
isFetchingNextPage,
isInitialized,
isRunning,
+ level,
+ search,
scrollToLatestLog,
}: UseAutoScrollToBottomParams) {
const isAutoScrollEnabledRef = useRef(true)
const prevLogsCountRef = useRef(0)
const prevIsRunningRef = useRef(isRunning)
+ const prevLevelRef = useRef(level)
+ const prevSearchRef = useRef(search)
const wasFetchingNextPageRef = useRef(isFetchingNextPage)
const hasInitialScrolled = useRef(false)
@@ -424,6 +571,15 @@ function useAutoScrollToBottom({
}
}, [isRunning])
+ useEffect(() => {
+ if (prevLevelRef.current !== level || prevSearchRef.current !== search) {
+ prevLevelRef.current = level
+ prevSearchRef.current = search
+ hasInitialScrolled.current = false
+ prevLogsCountRef.current = 0
+ }
+ }, [level, search])
+
useEffect(() => {
if (!hasInitialScrolled.current) {
wasFetchingNextPageRef.current = isFetchingNextPage
@@ -452,12 +608,21 @@ function useAutoScrollToBottom({
interface LogRowProps {
log: SandboxLogDTO
+ search: string
+ shouldHighlight: boolean
isZebraRow: boolean
virtualRow: VirtualItem
virtualizer: Virtualizer
}
-function LogRow({ log, isZebraRow, virtualRow, virtualizer }: LogRowProps) {
+function LogRow({
+ log,
+ search,
+ shouldHighlight,
+ isZebraRow,
+ virtualRow,
+ virtualizer,
+}: LogRowProps) {
return (
-
+
)
diff --git a/src/features/dashboard/sandbox/logs/sandbox-logs-store.ts b/src/features/dashboard/sandbox/logs/sandbox-logs-store.ts
index e0790fe36..50a430229 100644
--- a/src/features/dashboard/sandbox/logs/sandbox-logs-store.ts
+++ b/src/features/dashboard/sandbox/logs/sandbox-logs-store.ts
@@ -10,6 +10,7 @@ import {
dropLeadingAtTimestamp,
dropTrailingAtTimestamp,
} from '../../common/log-timestamp-utils'
+import type { LogLevelFilter } from './logs-filter-params'
interface SandboxLogsParams {
teamIdOrSlug: string
@@ -30,6 +31,8 @@ interface SandboxLogsState {
isInitialized: boolean
hasCompletedInitialLoad: boolean
initialLoadError: string | null
+ level: LogLevelFilter | null
+ search: string
_trpcClient: TRPCClient | null
_params: SandboxLogsParams | null
@@ -37,7 +40,12 @@ interface SandboxLogsState {
}
interface SandboxLogsMutations {
- init: (trpcClient: TRPCClient, params: SandboxLogsParams) => Promise
+ init: (
+ trpcClient: TRPCClient,
+ params: SandboxLogsParams,
+ level: LogLevelFilter | null,
+ search: string
+ ) => Promise
fetchMoreBackwards: () => Promise
fetchMoreForwards: () => Promise<{ logsCount: number }>
reset: () => void
@@ -58,6 +66,8 @@ const initialState: SandboxLogsState = {
isInitialized: false,
hasCompletedInitialLoad: false,
initialLoadError: null,
+ level: null,
+ search: '',
_trpcClient: null,
_params: null,
_initVersion: 0,
@@ -81,18 +91,21 @@ export const createSandboxLogsStore = () =>
state.isInitialized = false
state.hasCompletedInitialLoad = false
state.initialLoadError = null
+ state.level = null
+ state.search = ''
})
},
- init: async (trpcClient, params) => {
+ init: async (trpcClient, params, level, search) => {
const state = get()
// reset if params changed
const paramsChanged =
state._params?.sandboxId !== params.sandboxId ||
state._params?.teamIdOrSlug !== params.teamIdOrSlug
+ const filterChanged = state.level !== level || state.search !== search
- if (paramsChanged || !state.isInitialized) {
+ if (paramsChanged || filterChanged || !state.isInitialized) {
get().reset()
}
@@ -102,6 +115,8 @@ export const createSandboxLogsStore = () =>
set((s) => {
s._trpcClient = trpcClient
s._params = params
+ s.level = level
+ s.search = search
s.isLoadingBackwards = true
s.initialLoadError = null
s._initVersion = requestVersion
@@ -114,6 +129,8 @@ export const createSandboxLogsStore = () =>
teamIdOrSlug: params.teamIdOrSlug,
sandboxId: params.sandboxId,
cursor: initCursor,
+ level: level ?? undefined,
+ search: search || undefined,
})
// ignore stale response if a newer init was called
@@ -194,6 +211,8 @@ export const createSandboxLogsStore = () =>
teamIdOrSlug: state._params.teamIdOrSlug,
sandboxId: state._params.sandboxId,
cursor,
+ level: state.level ?? undefined,
+ search: state.search || undefined,
})
// ignore stale response if init was called during fetch
@@ -252,6 +271,8 @@ export const createSandboxLogsStore = () =>
teamIdOrSlug: state._params.teamIdOrSlug,
sandboxId: state._params.sandboxId,
cursor,
+ level: state.level ?? undefined,
+ search: state.search || undefined,
})
// ignore stale response if init was called during fetch
@@ -270,7 +291,12 @@ export const createSandboxLogsStore = () =>
if (logsCount > 0) {
s.logs = [...s.logs, ...newLogs]
- const newestTimestamp = newLogs[logsCount - 1]!.timestampUnix
+ const newestLog = newLogs[logsCount - 1]
+ if (!newestLog) {
+ s.isLoadingForwards = false
+ return
+ }
+ const newestTimestamp = newestLog.timestampUnix
const trailingAtNewest = countTrailingAtTimestamp(
newLogs,
newestTimestamp
diff --git a/src/features/dashboard/sandbox/logs/use-log-filters.ts b/src/features/dashboard/sandbox/logs/use-log-filters.ts
new file mode 100644
index 000000000..694742b11
--- /dev/null
+++ b/src/features/dashboard/sandbox/logs/use-log-filters.ts
@@ -0,0 +1,38 @@
+'use client'
+
+import { useQueryStates } from 'nuqs'
+import { useCallback } from 'react'
+import {
+ type LogLevelFilter,
+ sandboxLogsFilterParams,
+} from './logs-filter-params'
+
+export default function useLogFilters() {
+ const [filters, setFilters] = useQueryStates(sandboxLogsFilterParams, {
+ shallow: true,
+ })
+
+ const level = filters.level as LogLevelFilter | null
+ const search = filters.search ?? ''
+
+ const setLevel = useCallback(
+ (level: LogLevelFilter | null) => {
+ setFilters({ level })
+ },
+ [setFilters]
+ )
+
+ const setSearch = useCallback(
+ (search: string) => {
+ setFilters({ search: search || null })
+ },
+ [setFilters]
+ )
+
+ return {
+ level,
+ search,
+ setLevel,
+ setSearch,
+ }
+}
diff --git a/src/features/dashboard/sandbox/logs/use-sandbox-logs.ts b/src/features/dashboard/sandbox/logs/use-sandbox-logs.ts
index e9330f899..5f897560d 100644
--- a/src/features/dashboard/sandbox/logs/use-sandbox-logs.ts
+++ b/src/features/dashboard/sandbox/logs/use-sandbox-logs.ts
@@ -1,9 +1,10 @@
'use client'
import { useQuery } from '@tanstack/react-query'
-import { useCallback, useEffect, useRef } from 'react'
+import { useCallback, useEffect, useLayoutEffect, useRef } from 'react'
import { useStore } from 'zustand'
import { useTRPCClient } from '@/trpc/client'
+import type { LogLevelFilter } from './logs-filter-params'
import {
createSandboxLogsStore,
type SandboxLogsStore,
@@ -17,12 +18,16 @@ interface UseSandboxLogsParams {
teamIdOrSlug: string
sandboxId: string
isRunning: boolean
+ level: LogLevelFilter | null
+ search: string
}
export function useSandboxLogs({
teamIdOrSlug,
sandboxId,
isRunning,
+ level,
+ search,
}: UseSandboxLogsParams) {
const trpcClient = useTRPCClient()
const storeRef = useRef(null)
@@ -44,9 +49,11 @@ export function useSandboxLogs({
const isLoadingBackwards = useStore(store, (s) => s.isLoadingBackwards)
const isLoadingForwards = useStore(store, (s) => s.isLoadingForwards)
- useEffect(() => {
- store.getState().init(trpcClient, { teamIdOrSlug, sandboxId })
- }, [store, trpcClient, teamIdOrSlug, sandboxId])
+ useLayoutEffect(() => {
+ store
+ .getState()
+ .init(trpcClient, { teamIdOrSlug, sandboxId }, level, search)
+ }, [store, trpcClient, teamIdOrSlug, sandboxId, level, search])
const isDraining = useRef(false)
const prevIsRunningRef = useRef(isRunning)
@@ -74,7 +81,7 @@ export function useSandboxLogs({
const shouldPoll = isInitialized && (isRunning || isDraining.current)
const { isFetching: isPolling } = useQuery({
- queryKey: ['sandboxLogsForward', teamIdOrSlug, sandboxId],
+ queryKey: ['sandboxLogsForward', teamIdOrSlug, sandboxId, level, search],
queryFn: async () => {
const { logsCount } = await store.getState().fetchMoreForwards()
diff --git a/src/features/dashboard/templates/builds/constants.ts b/src/features/dashboard/templates/builds/constants.ts
index 9c19e9ec5..1669ce53d 100644
--- a/src/features/dashboard/templates/builds/constants.ts
+++ b/src/features/dashboard/templates/builds/constants.ts
@@ -1,5 +1,5 @@
import { millisecondsInDay } from 'date-fns/constants'
-import { BuildStatus } from '@/server/api/models/builds.models'
+import type { BuildStatus } from '@/server/api/models/builds.models'
export const LOG_RETENTION_MS = 7 * millisecondsInDay // 7 days
diff --git a/src/features/dashboard/templates/builds/header.tsx b/src/features/dashboard/templates/builds/header.tsx
index 4322d2b38..99f79d676 100644
--- a/src/features/dashboard/templates/builds/header.tsx
+++ b/src/features/dashboard/templates/builds/header.tsx
@@ -2,7 +2,7 @@
import { useEffect, useState } from 'react'
import { cn } from '@/lib/utils'
-import { BuildStatus } from '@/server/api/models/builds.models'
+import type { BuildStatus } from '@/server/api/models/builds.models'
import { Button } from '@/ui/primitives/button'
import {
DropdownMenu,
diff --git a/src/features/dashboard/templates/builds/use-filters.tsx b/src/features/dashboard/templates/builds/use-filters.tsx
index ef220e8b3..26950e3ea 100644
--- a/src/features/dashboard/templates/builds/use-filters.tsx
+++ b/src/features/dashboard/templates/builds/use-filters.tsx
@@ -3,7 +3,7 @@
import { useQueryStates } from 'nuqs'
import { useMemo } from 'react'
import { useDebounceCallback } from 'usehooks-ts'
-import { BuildStatus } from '@/server/api/models/builds.models'
+import type { BuildStatus } from '@/server/api/models/builds.models'
import { INITIAL_BUILD_STATUSES } from './constants'
import { templateBuildsFilterParams } from './filter-params'
diff --git a/src/lib/clients/api.ts b/src/lib/clients/api.ts
index 89db1e524..60b906e96 100644
--- a/src/lib/clients/api.ts
+++ b/src/lib/clients/api.ts
@@ -20,7 +20,7 @@ export const infra = createClient({
headers,
body,
method,
- duplex: !!body ? 'half' : undefined,
+ duplex: body ? 'half' : undefined,
...options,
} as RequestInit)
},
diff --git a/src/server/api/repositories/builds.repository.ts b/src/server/api/repositories/builds.repository.ts
index 8c2a08c02..55a875e22 100644
--- a/src/server/api/repositories/builds.repository.ts
+++ b/src/server/api/repositories/builds.repository.ts
@@ -2,10 +2,10 @@ import { SUPABASE_AUTH_HEADERS } from '@/configs/api'
import { INITIAL_BUILD_STATUSES } from '@/features/dashboard/templates/builds/constants'
import { api, infra } from '@/lib/clients/api'
import { handleDashboardApiError, handleInfraApiError } from '../errors'
-import {
+import type {
BuildStatus,
ListedBuildDTO,
- type RunningBuildStatusDTO,
+ RunningBuildStatusDTO,
} from '../models/builds.models'
// helpers
diff --git a/src/server/api/repositories/sandboxes.repository.ts b/src/server/api/repositories/sandboxes.repository.ts
index 94b849996..be16f717c 100644
--- a/src/server/api/repositories/sandboxes.repository.ts
+++ b/src/server/api/repositories/sandboxes.repository.ts
@@ -14,6 +14,8 @@ export interface GetSandboxLogsOptions {
cursor?: number
limit?: number
direction?: 'forward' | 'backward'
+ level?: 'debug' | 'info' | 'warn' | 'error'
+ search?: string
}
export async function getSandboxLogs(
@@ -31,6 +33,8 @@ export async function getSandboxLogs(
cursor: options.cursor,
limit: options.limit,
direction: options.direction,
+ level: options.level,
+ search: options.search,
},
},
headers: {
@@ -121,7 +125,7 @@ export async function getSandboxDetails(
if (dashboardResult.response.ok && dashboardResult.data) {
return {
- source: 'dashboard-log' as const,
+ source: 'database-record' as const,
details: dashboardResult.data,
}
}
diff --git a/src/server/api/routers/builds.ts b/src/server/api/routers/builds.ts
index 31a9ab089..327447c4a 100644
--- a/src/server/api/routers/builds.ts
+++ b/src/server/api/routers/builds.ts
@@ -3,9 +3,9 @@ import { LOG_RETENTION_MS } from '@/features/dashboard/templates/builds/constant
import { buildsRepo } from '@/server/api/repositories/builds.repository'
import { createTRPCRouter } from '../init'
import {
- BuildDetailsDTO,
- BuildLogDTO,
- BuildLogsDTO,
+ type BuildDetailsDTO,
+ type BuildLogDTO,
+ type BuildLogsDTO,
BuildStatusSchema,
} from '../models/builds.models'
import { protectedTeamProcedure } from '../procedures'
diff --git a/src/server/api/routers/sandbox.ts b/src/server/api/routers/sandbox.ts
index 65d629724..b6ed87c0a 100644
--- a/src/server/api/routers/sandbox.ts
+++ b/src/server/api/routers/sandbox.ts
@@ -43,11 +43,13 @@ export const sandboxRouter = createTRPCRouter({
z.object({
sandboxId: z.string(),
cursor: z.number().optional(),
+ level: z.enum(['debug', 'info', 'warn', 'error']).optional(),
+ search: z.string().max(256).optional(),
})
)
.query(async ({ ctx, input }) => {
const { teamId, session } = ctx
- const { sandboxId } = input
+ const { sandboxId, level, search } = input
let { cursor } = input
cursor ??= Date.now()
@@ -59,7 +61,7 @@ export const sandboxRouter = createTRPCRouter({
session.access_token,
teamId,
sandboxId,
- { cursor, limit, direction }
+ { cursor, limit, direction, level, search }
)
const logs: SandboxLogDTO[] = sandboxLogs.logs
@@ -83,11 +85,13 @@ export const sandboxRouter = createTRPCRouter({
z.object({
sandboxId: z.string(),
cursor: z.number().optional(),
+ level: z.enum(['debug', 'info', 'warn', 'error']).optional(),
+ search: z.string().max(256).optional(),
})
)
.query(async ({ ctx, input }) => {
const { teamId, session } = ctx
- const { sandboxId } = input
+ const { sandboxId, level, search } = input
let { cursor } = input
cursor ??= Date.now()
@@ -99,7 +103,7 @@ export const sandboxRouter = createTRPCRouter({
session.access_token,
teamId,
sandboxId,
- { cursor, limit, direction }
+ { cursor, limit, direction, level, search }
)
const logs: SandboxLogDTO[] = sandboxLogs.logs.map(