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(