diff --git a/spec/openapi.infra.yaml b/spec/openapi.infra.yaml index f49e8f789..da38a82c6 100644 --- a/spec/openapi.infra.yaml +++ b/spec/openapi.infra.yaml @@ -306,6 +306,17 @@ components: items: $ref: '#/components/schemas/SandboxLogEntry' + SandboxLogsV2Response: + required: + - logs + properties: + logs: + default: [] + description: Sandbox logs structured + type: array + items: + $ref: "#/components/schemas/SandboxLogEntry" + SandboxMetric: description: Metric entry with timestamp and line required: @@ -1914,6 +1925,50 @@ paths: '500': $ref: '#/components/responses/500' + /v2/sandboxes/{sandboxID}/logs: + get: + description: Get sandbox logs (v2) + tags: [sandboxes] + security: + - ApiKeyAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + parameters: + - $ref: "#/components/parameters/sandboxID" + - in: query + name: cursor + schema: + type: integer + format: int64 + minimum: 0 + description: Starting timestamp of the logs that should be returned in milliseconds + - in: query + name: limit + schema: + default: 1000 + type: integer + format: int32 + minimum: 0 + maximum: 1000 + description: Maximum number of logs that should be returned + - in: query + name: direction + schema: + $ref: "#/components/schemas/LogsDirection" + responses: + "200": + description: Successfully returned the sandbox logs + content: + application/json: + schema: + $ref: "#/components/schemas/SandboxLogsV2Response" + "401": + $ref: "#/components/responses/401" + "404": + $ref: "#/components/responses/404" + "500": + $ref: "#/components/responses/500" + /sandboxes/{sandboxID}: get: description: Get a sandbox by id diff --git a/src/app/dashboard/[teamIdOrSlug]/sandboxes/[sandboxId]/inspect/loading.tsx b/src/app/dashboard/[teamIdOrSlug]/sandboxes/[sandboxId]/filesystem/loading.tsx similarity index 100% rename from src/app/dashboard/[teamIdOrSlug]/sandboxes/[sandboxId]/inspect/loading.tsx rename to src/app/dashboard/[teamIdOrSlug]/sandboxes/[sandboxId]/filesystem/loading.tsx diff --git a/src/app/dashboard/[teamIdOrSlug]/sandboxes/[sandboxId]/inspect/page.tsx b/src/app/dashboard/[teamIdOrSlug]/sandboxes/[sandboxId]/filesystem/page.tsx similarity index 100% rename from src/app/dashboard/[teamIdOrSlug]/sandboxes/[sandboxId]/inspect/page.tsx rename to src/app/dashboard/[teamIdOrSlug]/sandboxes/[sandboxId]/filesystem/page.tsx diff --git a/src/app/dashboard/[teamIdOrSlug]/sandboxes/[sandboxId]/layout.tsx b/src/app/dashboard/[teamIdOrSlug]/sandboxes/[sandboxId]/layout.tsx index c02420fce..c551841db 100644 --- a/src/app/dashboard/[teamIdOrSlug]/sandboxes/[sandboxId]/layout.tsx +++ b/src/app/dashboard/[teamIdOrSlug]/sandboxes/[sandboxId]/layout.tsx @@ -1,4 +1,5 @@ import { SandboxProvider } from '@/features/dashboard/sandbox/context' +import SandboxDetailsControls from '@/features/dashboard/sandbox/header/controls' import SandboxDetailsHeader from '@/features/dashboard/sandbox/header/header' import SandboxLayoutClient from '@/features/dashboard/sandbox/layout' import { getSandboxDetails } from '@/server/sandboxes/get-sandbox-details' @@ -30,12 +31,8 @@ export default async function SandboxLayout({ - } + tabsHeaderAccessory={} + header={} > {children} diff --git a/src/app/dashboard/[teamIdOrSlug]/sandboxes/[sandboxId]/logs/loading.tsx b/src/app/dashboard/[teamIdOrSlug]/sandboxes/[sandboxId]/logs/loading.tsx new file mode 100644 index 000000000..249f11404 --- /dev/null +++ b/src/app/dashboard/[teamIdOrSlug]/sandboxes/[sandboxId]/logs/loading.tsx @@ -0,0 +1 @@ +export { default } from '@/features/dashboard/loading-layout' diff --git a/src/app/dashboard/[teamIdOrSlug]/sandboxes/[sandboxId]/logs/page.tsx b/src/app/dashboard/[teamIdOrSlug]/sandboxes/[sandboxId]/logs/page.tsx new file mode 100644 index 000000000..d42f68b53 --- /dev/null +++ b/src/app/dashboard/[teamIdOrSlug]/sandboxes/[sandboxId]/logs/page.tsx @@ -0,0 +1,11 @@ +import SandboxLogsView from '@/features/dashboard/sandbox/logs/view' + +export default async function SandboxLogsPage({ + params, +}: { + params: Promise<{ teamIdOrSlug: string; sandboxId: string }> +}) { + const { teamIdOrSlug, sandboxId } = await params + + return +} diff --git a/src/app/sbx/new/route.ts b/src/app/sbx/new/route.ts index 6b4cb9e13..985b86efb 100644 --- a/src/app/sbx/new/route.ts +++ b/src/app/sbx/new/route.ts @@ -44,12 +44,12 @@ export const GET = async (req: NextRequest) => { }, }) - const inspectUrl = PROTECTED_URLS.SANDBOX_INSPECT( + const filesystemUrl = PROTECTED_URLS.SANDBOX_FILESYSTEM( defaultTeam.slug, sbx.sandboxId ) - return NextResponse.redirect(new URL(inspectUrl, req.url)) + return NextResponse.redirect(new URL(filesystemUrl, req.url)) } catch (error) { l.warn( { diff --git a/src/configs/layout.ts b/src/configs/layout.ts index 5e8656cc9..e73df4833 100644 --- a/src/configs/layout.ts +++ b/src/configs/layout.ts @@ -13,6 +13,7 @@ export interface TitleSegment { export interface DashboardLayoutConfig { title: string | TitleSegment[] type: 'default' | 'custom' + copyValue?: string custom?: { includeHeaderBottomStyles: boolean } @@ -27,10 +28,24 @@ const DASHBOARD_LAYOUT_CONFIGS: Record< title: 'Sandboxes', type: 'custom', }), - '/dashboard/*/sandboxes/**/*': () => ({ - title: 'Sandbox', - type: 'custom', - }), + '/dashboard/*/sandboxes/*/*': (pathname) => { + const parts = pathname.split('/') + const teamIdOrSlug = parts[2]! + const sandboxId = parts[4]! + const sandboxIdSliced = `${sandboxId.slice(0, 6)}...${sandboxId.slice(-6)}` + + return { + title: [ + { + label: 'Sandboxes', + href: PROTECTED_URLS.SANDBOXES_LIST(teamIdOrSlug), + }, + { label: sandboxIdSliced }, + ], + type: 'custom', + copyValue: sandboxId, + } + }, '/dashboard/*/templates': () => ({ title: 'Templates', type: 'custom', @@ -50,6 +65,7 @@ const DASHBOARD_LAYOUT_CONFIGS: Record< { label: `Build ${buildIdSliced}` }, ], type: 'custom', + copyValue: buildId, custom: { includeHeaderBottomStyles: true, }, diff --git a/src/configs/urls.ts b/src/configs/urls.ts index 95b5a8363..aac15712c 100644 --- a/src/configs/urls.ts +++ b/src/configs/urls.ts @@ -30,8 +30,10 @@ export const PROTECTED_URLS = { SANDBOX: (teamIdOrSlug: string, sandboxId: string) => `/dashboard/${teamIdOrSlug}/sandboxes/${sandboxId}`, - SANDBOX_INSPECT: (teamIdOrSlug: string, sandboxId: string) => - `/dashboard/${teamIdOrSlug}/sandboxes/${sandboxId}/inspect`, + SANDBOX_FILESYSTEM: (teamIdOrSlug: string, sandboxId: string) => + `/dashboard/${teamIdOrSlug}/sandboxes/${sandboxId}/filesystem`, + SANDBOX_LOGS: (teamIdOrSlug: string, sandboxId: string) => + `/dashboard/${teamIdOrSlug}/sandboxes/${sandboxId}/logs`, WEBHOOKS: (teamIdOrSlug: string) => `/dashboard/${teamIdOrSlug}/webhooks`, diff --git a/src/features/dashboard/layouts/header.tsx b/src/features/dashboard/layouts/header.tsx index 1dce270a8..c579fcc49 100644 --- a/src/features/dashboard/layouts/header.tsx +++ b/src/features/dashboard/layouts/header.tsx @@ -3,6 +3,7 @@ import { getDashboardLayoutConfig, TitleSegment } from '@/configs/layout' import { cn } from '@/lib/utils' import ClientOnly from '@/ui/client-only' +import CopyButton from '@/ui/copy-button' import { SidebarTrigger } from '@/ui/primitives/sidebar' import { ThemeSwitcher } from '@/ui/theme-switcher' import Link from 'next/link' @@ -20,6 +21,7 @@ export default function DashboardLayoutHeader({ }: DashboardLayoutHeaderProps) { const pathname = usePathname() const config = getDashboardLayoutConfig(pathname) + const copyableValue = config.copyValue ?? null return (
-

- -

- - {/* Ghost element - reserves width but not height */} -
- {children} +
+

+ +

+ {copyableValue && ( + + )}
+ + {/* Ghost element - reserves width but not height */} +
+ {children} +
) @@ -65,7 +78,9 @@ function HeaderTitle({ title }: { title: string | TitleSegment[] }) { {title.map((segment, index) => ( - {index > 0 && /} + {index > 0 && ( + / + )} {segment.href ? ( + + + + ) +} diff --git a/src/features/dashboard/sandbox/header/header.tsx b/src/features/dashboard/sandbox/header/header.tsx index 64b384bc3..5b495b0bb 100644 --- a/src/features/dashboard/sandbox/header/header.tsx +++ b/src/features/dashboard/sandbox/header/header.tsx @@ -1,62 +1,22 @@ -import { COOKIE_KEYS } from '@/configs/cookies' -import { PROTECTED_URLS } from '@/configs/urls' import { SandboxInfo } from '@/types/api.types' -import { ChevronLeftIcon } from 'lucide-react' -import { cookies } from 'next/headers' -import Link from 'next/link' import { DetailsItem, DetailsRow } from '../../layouts/details-row' -import KillButton from './kill-button' import Metadata from './metadata' import RanFor from './ran-for' -import RefreshControl from './refresh' import RemainingTime from './remaining-time' import { ResourceUsageClient } from './resource-usage-client' import StartedAt from './started-at' import Status from './status' import TemplateId from './template-id' -import SandboxDetailsTitle from './title' interface SandboxDetailsHeaderProps { - teamIdOrSlug: string state: SandboxInfo['state'] } export default async function SandboxDetailsHeader({ - teamIdOrSlug, state, }: SandboxDetailsHeaderProps) { - const initialPollingInterval = (await cookies()).get( - COOKIE_KEYS.SANDBOX_INSPECT_POLLING_INTERVAL - )?.value - return ( -
-
-
- - - Sandboxes - - -
-
- - -
-
- +
diff --git a/src/features/dashboard/sandbox/header/kill-button.tsx b/src/features/dashboard/sandbox/header/kill-button.tsx index 62c17f780..4410bb7d1 100644 --- a/src/features/dashboard/sandbox/header/kill-button.tsx +++ b/src/features/dashboard/sandbox/header/kill-button.tsx @@ -1,5 +1,6 @@ 'use client' +import { cn } from '@/lib/utils/ui' import { killSandboxAction } from '@/server/sandboxes/sandbox-actions' import { AlertPopover } from '@/ui/alert-popover' import { Button } from '@/ui/primitives/button' @@ -50,12 +51,12 @@ export default function KillButton({ className }: KillButtonProps) { confirm="Kill Sandbox" trigger={ } diff --git a/src/features/dashboard/sandbox/header/title.tsx b/src/features/dashboard/sandbox/header/title.tsx deleted file mode 100644 index 216d1de0a..000000000 --- a/src/features/dashboard/sandbox/header/title.tsx +++ /dev/null @@ -1,24 +0,0 @@ -'use client' - -import CopyButton from '@/ui/copy-button' -import { useSandboxContext } from '../context' - -export default function Title() { - const { sandboxInfo } = useSandboxContext() - - if (!sandboxInfo) { - return null - } - - return ( -
-

{sandboxInfo.sandboxID}

- -
- ) -} diff --git a/src/features/dashboard/sandbox/layout.tsx b/src/features/dashboard/sandbox/layout.tsx index 68f94cf6d..252b9bf09 100644 --- a/src/features/dashboard/sandbox/layout.tsx +++ b/src/features/dashboard/sandbox/layout.tsx @@ -3,6 +3,7 @@ import { SANDBOX_INSPECT_MINIMUM_ENVD_VERSION } from '@/configs/versioning' import { isVersionCompatible } from '@/lib/utils/version' import { DashboardTab, DashboardTabs } from '@/ui/dashboard-tabs' +import { ListIcon, StorageIcon } from '@/ui/primitives/icons' import { notFound } from 'next/navigation' import { useSandboxContext } from './context' import SandboxInspectIncompatible from './inspect/incompatible' @@ -11,12 +12,14 @@ interface SandboxLayoutProps { children: React.ReactNode header: React.ReactNode teamIdOrSlug: string + tabsHeaderAccessory?: React.ReactNode } export default function SandboxLayout({ teamIdOrSlug, children, header, + tabsHeaderAccessory, }: SandboxLayoutProps) { const { sandboxInfo } = useSandboxContext() @@ -36,11 +39,16 @@ export default function SandboxLayout({
{header} - + } > {isEnvdVersionCompatibleForInspect ? ( children @@ -51,6 +59,14 @@ export default function SandboxLayout({ /> )} + } + > + {children} +
) diff --git a/src/features/dashboard/sandbox/logs/logs-cells.tsx b/src/features/dashboard/sandbox/logs/logs-cells.tsx new file mode 100644 index 000000000..6f01d723f --- /dev/null +++ b/src/features/dashboard/sandbox/logs/logs-cells.tsx @@ -0,0 +1,70 @@ +import { SandboxLogDTO } from '@/server/api/models/sandboxes.models' +import CopyButtonInline from '@/ui/copy-button-inline' +import { Badge, BadgeProps } from '@/ui/primitives/badge' + +interface LogLevelProps { + level: SandboxLogDTO['level'] +} + +const mapLogLevelToBadgeProps: Record = { + debug: { + variant: 'default', + }, + info: { + variant: 'info', + }, + warn: { + variant: 'warning', + }, + error: { + variant: 'error', + }, +} + +export const LogLevel = ({ level }: LogLevelProps) => { + return ( + + {level} + + ) +} + +interface TimestampProps { + timestampUnix: number +} + +export const Timestamp = ({ timestampUnix }: TimestampProps) => { + const date = new Date(timestampUnix) + + const centiseconds = Math.floor((date.getMilliseconds() / 10) % 100) + .toString() + .padStart(2, '0') + const localDatePart = new Intl.DateTimeFormat(undefined, { + month: 'short', + day: '2-digit', + }).format(date) + const localTimePart = new Intl.DateTimeFormat(undefined, { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }).format(date) + + return ( + + {localDatePart} {localTimePart}. + {centiseconds} + + ) +} + +interface MessageProps { + message: SandboxLogDTO['message'] +} + +export const Message = ({ message }: MessageProps) => { + return {message} +} diff --git a/src/features/dashboard/sandbox/logs/logs.tsx b/src/features/dashboard/sandbox/logs/logs.tsx new file mode 100644 index 000000000..a43dda2ad --- /dev/null +++ b/src/features/dashboard/sandbox/logs/logs.tsx @@ -0,0 +1,631 @@ +'use client' + +import type { SandboxLogDTO } from '@/server/api/models/sandboxes.models' +import { ArrowDownIcon, ListIcon } from '@/ui/primitives/icons' +import { Loader } from '@/ui/primitives/loader' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/ui/primitives/table' +import { + useVirtualizer, + VirtualItem, + Virtualizer, +} from '@tanstack/react-virtual' +import { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from 'react' +import { useSandboxContext } from '../context' +import { LogLevel, Message, Timestamp } from './logs-cells' +import { useSandboxLogs } from './use-sandbox-logs' + +// column widths are calculated as max width of the content + padding +const COLUMN_WIDTHS_PX = { timestamp: 142 + 16, level: 48 + 16 } as const +const ROW_HEIGHT_PX = 26 +const VIRTUAL_OVERSCAN = 16 +const SCROLL_LOAD_THRESHOLD_PX = 200 + +interface LogsProps { + teamIdOrSlug: string + sandboxId: string +} + +export default function SandboxLogs({ teamIdOrSlug, sandboxId }: LogsProps) { + 'use no memo' + + const { sandboxInfo, isRunning } = useSandboxContext() + + if (!sandboxInfo) { + return ( +
+
+ + + +
+
+
+ ) + } + + return ( + + ) +} + +interface LogsContentProps { + teamIdOrSlug: string + sandboxId: string + isRunning: boolean +} + +function LogsContent({ teamIdOrSlug, sandboxId, isRunning }: LogsContentProps) { + const [scrollContainerElement, setScrollContainerElement] = + useState(null) + + const { + logs, + isInitialized, + hasNextPage, + isFetchingNextPage, + isFetching, + fetchNextPage, + } = useSandboxLogs({ + teamIdOrSlug, + sandboxId, + isRunning, + }) + + const hasLogs = logs.length > 0 + const showLoader = isFetching && !hasLogs + const showEmpty = !isFetching && !hasLogs + + const handleLoadMore = useCallback(() => { + if (hasNextPage && !isFetchingNextPage) { + fetchNextPage() + } + }, [fetchNextPage, hasNextPage, isFetchingNextPage]) + + return ( +
+
+ + + + {showLoader && } + {showEmpty && } + {hasLogs && scrollContainerElement && ( + + )} +
+
+
+ ) +} + +function LogsTableHeader() { + return ( + + + + Timestamp + + + Level + + + Message + + + + ) +} + +function LoaderBody() { + return ( + + + +
+ +
+
+
+
+ ) +} + +function EmptyBody() { + return ( + + + +
+
+ +

No logs found

+
+

+ Sandbox logs will appear here once available. +

+
+
+
+
+ ) +} + +interface VirtualizedLogsBodyProps { + logs: SandboxLogDTO[] + scrollContainerElement: HTMLDivElement + onLoadMore: () => void + hasNextPage: boolean + isFetchingNextPage: boolean + isInitialized: boolean + isRunning: boolean +} + +function VirtualizedLogsBody({ + logs, + scrollContainerElement, + onLoadMore, + hasNextPage, + isFetchingNextPage, + isInitialized, + isRunning, +}: VirtualizedLogsBodyProps) { + const tbodyRef = useRef(null) + const maxWidthRef = useRef(0) + + useScrollLoadMore({ + scrollContainerElement, + hasNextPage, + isFetchingNextPage, + onLoadMore, + }) + + useMaintainScrollOnPrepend({ + scrollContainerElement, + logsCount: logs.length, + isFetchingNextPage, + }) + + const showLoadMoreStatusRow = hasNextPage || isFetchingNextPage + const logsStartIndex = showLoadMoreStatusRow ? 1 : 0 + const spacerRowIndex = logsStartIndex + logs.length + const liveStatusRowIndex = spacerRowIndex + 1 + const virtualRowsCount = logs.length + (showLoadMoreStatusRow ? 1 : 0) + 2 + + const virtualizer = useVirtualizer({ + count: virtualRowsCount, + estimateSize: () => ROW_HEIGHT_PX, + getScrollElement: () => scrollContainerElement, + overscan: VIRTUAL_OVERSCAN, + paddingStart: 8, + }) + + const scrollToLatestLog = useCallback(() => { + if (logs.length === 0) return + virtualizer.scrollToIndex(liveStatusRowIndex, { align: 'end' }) + }, [logs.length, liveStatusRowIndex, logsStartIndex, virtualizer]) + + useAutoScrollToBottom({ + scrollContainerElement, + logsCount: logs.length, + isInitialized, + isRunning, + scrollToLatestLog, + }) + + const containerWidth = scrollContainerElement.clientWidth + const contentWidth = scrollContainerElement.scrollWidth + const SCROLLBAR_BUFFER_PX = 20 + const hasHorizontalOverflow = + contentWidth > containerWidth + SCROLLBAR_BUFFER_PX + + if (hasHorizontalOverflow && contentWidth > maxWidthRef.current) { + maxWidthRef.current = contentWidth + } + + return ( + + {virtualizer.getVirtualItems().map((virtualRow) => { + const isLoadMoreStatusRow = + showLoadMoreStatusRow && virtualRow.index === 0 + + if (isLoadMoreStatusRow) { + return ( + + ) + } + + const isSpacerRow = virtualRow.index === spacerRowIndex + + if (isSpacerRow) { + return ( + + ) + } + + const isLiveStatusRow = virtualRow.index === liveStatusRowIndex + + if (isLiveStatusRow) { + return ( + + ) + } + + const logIndex = virtualRow.index - logsStartIndex + + return ( + + ) + })} + + ) +} + +interface UseScrollLoadMoreParams { + scrollContainerElement: HTMLDivElement + hasNextPage: boolean + isFetchingNextPage: boolean + onLoadMore: () => void +} + +function useScrollLoadMore({ + scrollContainerElement, + hasNextPage, + isFetchingNextPage, + onLoadMore, +}: UseScrollLoadMoreParams) { + useEffect(() => { + const handleScroll = () => { + if ( + scrollContainerElement.scrollTop < SCROLL_LOAD_THRESHOLD_PX && + hasNextPage && + !isFetchingNextPage + ) { + onLoadMore() + } + } + + scrollContainerElement.addEventListener('scroll', handleScroll) + return () => + scrollContainerElement.removeEventListener('scroll', handleScroll) + }, [scrollContainerElement, hasNextPage, isFetchingNextPage, onLoadMore]) +} + +interface UseMaintainScrollOnPrependParams { + scrollContainerElement: HTMLDivElement + logsCount: number + isFetchingNextPage: boolean +} + +function useMaintainScrollOnPrepend({ + scrollContainerElement, + logsCount, + isFetchingNextPage, +}: UseMaintainScrollOnPrependParams) { + const prevLogsCountRef = useRef(logsCount) + const wasFetchingRef = useRef(false) + + useEffect(() => { + const justFinishedFetching = wasFetchingRef.current && !isFetchingNextPage + const logsWerePrepended = logsCount > prevLogsCountRef.current + + if (justFinishedFetching && logsWerePrepended) { + const addedCount = logsCount - prevLogsCountRef.current + scrollContainerElement.scrollTop += addedCount * ROW_HEIGHT_PX + } + + wasFetchingRef.current = isFetchingNextPage + prevLogsCountRef.current = logsCount + }, [scrollContainerElement, logsCount, isFetchingNextPage]) +} + +interface UseAutoScrollToBottomParams { + scrollContainerElement: HTMLDivElement + logsCount: number + isInitialized: boolean + isRunning: boolean + scrollToLatestLog: () => void +} + +function useAutoScrollToBottom({ + scrollContainerElement, + logsCount, + isInitialized, + isRunning, + scrollToLatestLog, +}: UseAutoScrollToBottomParams) { + const isAutoScrollEnabledRef = useRef(true) + const prevLogsCountRef = useRef(0) + const prevIsRunningRef = useRef(isRunning) + const hasInitialScrolled = useRef(false) + + useEffect(() => { + const handleScroll = () => { + const distanceFromBottom = + scrollContainerElement.scrollHeight - + scrollContainerElement.scrollTop - + scrollContainerElement.clientHeight + isAutoScrollEnabledRef.current = distanceFromBottom < ROW_HEIGHT_PX * 2 + } + + scrollContainerElement.addEventListener('scroll', handleScroll) + return () => + scrollContainerElement.removeEventListener('scroll', handleScroll) + }, [scrollContainerElement]) + + useLayoutEffect(() => { + if (isInitialized && !hasInitialScrolled.current && logsCount > 0) { + hasInitialScrolled.current = true + prevLogsCountRef.current = logsCount + scrollToLatestLog() + } + }, [isInitialized, logsCount, scrollToLatestLog]) + + useEffect(() => { + if (prevIsRunningRef.current !== isRunning) { + prevIsRunningRef.current = isRunning + hasInitialScrolled.current = false + prevLogsCountRef.current = 0 + } + }, [isRunning]) + + useEffect(() => { + if (!hasInitialScrolled.current) return + + const newLogsCount = logsCount - prevLogsCountRef.current + + if (newLogsCount > 0 && isAutoScrollEnabledRef.current) { + scrollContainerElement.scrollTop += newLogsCount * ROW_HEIGHT_PX + } + + prevLogsCountRef.current = logsCount + }, [logsCount, scrollContainerElement]) +} + +interface LogRowProps { + log: SandboxLogDTO + virtualRow: VirtualItem + virtualizer: Virtualizer +} + +function LogRow({ log, virtualRow, virtualizer }: LogRowProps) { + return ( + virtualizer.measureElement(node)} + style={{ + display: 'flex', + position: 'absolute', + left: 0, + transform: `translateY(${virtualRow.start}px)`, + minWidth: '100%', + height: ROW_HEIGHT_PX, + }} + > + + + + + + + + + + + ) +} + +interface StatusRowProps { + virtualRow: VirtualItem + virtualizer: Virtualizer + isFetchingNextPage: boolean +} + +function StatusRow({ + virtualRow, + virtualizer, + isFetchingNextPage, +}: StatusRowProps) { + return ( + virtualizer.measureElement(node)} + className="animate-pulse" + style={{ + display: 'flex', + position: 'absolute', + left: 0, + transform: `translateY(${virtualRow.start}px)`, + minWidth: '100%', + height: ROW_HEIGHT_PX, + }} + > + + + {isFetchingNextPage ? ( + + Loading more logs + + + ) : ( + 'Scroll to load more' + )} + + + + ) +} + +interface LiveStatusRowProps { + virtualRow: VirtualItem + virtualizer: Virtualizer + isRunning: boolean +} + +interface SpacerRowProps { + virtualRow: VirtualItem + virtualizer: Virtualizer +} + +function SpacerRow({ virtualRow, virtualizer }: SpacerRowProps) { + return ( + virtualizer.measureElement(node)} + style={{ + display: 'flex', + position: 'absolute', + left: 0, + transform: `translateY(${virtualRow.start}px)`, + minWidth: '100%', + height: ROW_HEIGHT_PX, + }} + > + + + ) +} + +function LiveStatusRow({ + virtualRow, + virtualizer, + isRunning, +}: LiveStatusRowProps) { + return ( + virtualizer.measureElement(node)} + style={{ + display: 'flex', + position: 'absolute', + left: 0, + transform: `translateY(${virtualRow.start}px)`, + minWidth: '100%', + height: ROW_HEIGHT_PX, + }} + > + + + [ + + {isRunning ? 'live' : 'end'} + + ] + + {isRunning + ? 'No more logs to show. Wating for new entries...' + : 'No more logs to show'} + + + + + ) +} diff --git a/src/features/dashboard/sandbox/logs/sandbox-logs-store.ts b/src/features/dashboard/sandbox/logs/sandbox-logs-store.ts new file mode 100644 index 000000000..8a4e505d7 --- /dev/null +++ b/src/features/dashboard/sandbox/logs/sandbox-logs-store.ts @@ -0,0 +1,257 @@ +'use client' + +import type { SandboxLogDTO } from '@/server/api/models/sandboxes.models' +import type { useTRPCClient } from '@/trpc/client' +import { create } from 'zustand' +import { immer } from 'zustand/middleware/immer' + +const FORWARD_CURSOR_PADDING_MS = 1 + +interface SandboxLogsParams { + teamIdOrSlug: string + sandboxId: string +} + +type TRPCClient = ReturnType + +interface SandboxLogsState { + logs: SandboxLogDTO[] + hasMoreBackwards: boolean + isLoadingBackwards: boolean + isLoadingForwards: boolean + backwardsCursor: number | null + isInitialized: boolean + + _trpcClient: TRPCClient | null + _params: SandboxLogsParams | null + _initVersion: number +} + +interface SandboxLogsMutations { + init: (trpcClient: TRPCClient, params: SandboxLogsParams) => Promise + fetchMoreBackwards: () => Promise + fetchMoreForwards: () => Promise<{ logsCount: number }> + reset: () => void +} + +interface SandboxLogsComputed { + getNewestTimestamp: () => number | undefined + getOldestTimestamp: () => number | undefined +} + +export type SandboxLogsStoreData = SandboxLogsState & + SandboxLogsMutations & + SandboxLogsComputed + +function getLogKey(log: SandboxLogDTO): string { + return `${log.timestampUnix}:${log.level}:${log.message}` +} + +function deduplicateLogs( + existingLogs: SandboxLogDTO[], + newLogs: SandboxLogDTO[] +): SandboxLogDTO[] { + const existingKeys = new Set(existingLogs.map(getLogKey)) + return newLogs.filter((log) => !existingKeys.has(getLogKey(log))) +} + +const initialState: SandboxLogsState = { + logs: [], + hasMoreBackwards: true, + isLoadingBackwards: false, + isLoadingForwards: false, + backwardsCursor: null, + isInitialized: false, + _trpcClient: null, + _params: null, + _initVersion: 0, +} + +export const createSandboxLogsStore = () => + create()( + immer((set, get) => ({ + ...initialState, + + reset: () => { + set((state) => { + state.logs = [] + state.hasMoreBackwards = true + state.isLoadingBackwards = false + state.isLoadingForwards = false + state.backwardsCursor = null + state.isInitialized = false + }) + }, + + init: async (trpcClient, params) => { + const state = get() + + // reset if params changed + const paramsChanged = + state._params?.sandboxId !== params.sandboxId || + state._params?.teamIdOrSlug !== params.teamIdOrSlug + + if (paramsChanged || !state.isInitialized) { + get().reset() + } + + // increment version to invalidate any in-flight requests + const requestVersion = state._initVersion + 1 + + set((s) => { + s._trpcClient = trpcClient + s._params = params + s.isLoadingBackwards = true + s._initVersion = requestVersion + }) + + try { + const result = await trpcClient.sandbox.logsBackwards.query({ + teamIdOrSlug: params.teamIdOrSlug, + sandboxId: params.sandboxId, + cursor: Date.now(), + }) + + // ignore stale response if a newer init was called + if (get()._initVersion !== requestVersion) { + return + } + + set((s) => { + s.logs = result.logs + s.hasMoreBackwards = result.nextCursor !== null + s.backwardsCursor = result.nextCursor + s.isLoadingBackwards = false + s.isInitialized = true + }) + } catch { + // ignore errors from stale requests + if (get()._initVersion !== requestVersion) { + return + } + + set((s) => { + s.isLoadingBackwards = false + }) + } + }, + + fetchMoreBackwards: async () => { + const state = get() + + if ( + !state._trpcClient || + !state._params || + !state.hasMoreBackwards || + state.isLoadingBackwards + ) { + return + } + + const requestVersion = state._initVersion + + set((s) => { + s.isLoadingBackwards = true + }) + + try { + const cursor = + state.backwardsCursor ?? state.getOldestTimestamp() ?? Date.now() + + const result = await state._trpcClient.sandbox.logsBackwards.query({ + teamIdOrSlug: state._params.teamIdOrSlug, + sandboxId: state._params.sandboxId, + cursor, + }) + + // ignore stale response if init was called during fetch + if (get()._initVersion !== requestVersion) { + return + } + + set((s) => { + const uniqueNewLogs = deduplicateLogs(s.logs, result.logs) + s.logs = [...uniqueNewLogs, ...s.logs] + s.hasMoreBackwards = result.nextCursor !== null + s.backwardsCursor = result.nextCursor + s.isLoadingBackwards = false + }) + } catch { + if (get()._initVersion !== requestVersion) { + return + } + + set((s) => { + s.isLoadingBackwards = false + }) + } + }, + + fetchMoreForwards: async () => { + const state = get() + + if (!state._trpcClient || !state._params || state.isLoadingForwards) { + return { logsCount: 0 } + } + + const requestVersion = state._initVersion + + set((s) => { + s.isLoadingForwards = true + }) + + try { + const newestTimestamp = state.getNewestTimestamp() + const cursor = newestTimestamp + ? newestTimestamp + FORWARD_CURSOR_PADDING_MS + : Date.now() + + const result = await state._trpcClient.sandbox.logsForward.query({ + teamIdOrSlug: state._params.teamIdOrSlug, + sandboxId: state._params.sandboxId, + cursor, + }) + + // ignore stale response if init was called during fetch + if (get()._initVersion !== requestVersion) { + return { logsCount: 0 } + } + + let uniqueLogsCount = 0 + + set((s) => { + const uniqueNewLogs = deduplicateLogs(s.logs, result.logs) + uniqueLogsCount = uniqueNewLogs.length + if (uniqueLogsCount > 0) { + s.logs = [...s.logs, ...uniqueNewLogs] + } + s.isLoadingForwards = false + }) + + return { logsCount: uniqueLogsCount } + } catch { + if (get()._initVersion !== requestVersion) { + return { logsCount: 0 } + } + + set((s) => { + s.isLoadingForwards = false + }) + + return { logsCount: 0 } + } + }, + + getNewestTimestamp: () => { + const state = get() + return state.logs[state.logs.length - 1]?.timestampUnix + }, + + getOldestTimestamp: () => { + const state = get() + return state.logs[0]?.timestampUnix + }, + })) + ) + +export type SandboxLogsStore = ReturnType diff --git a/src/features/dashboard/sandbox/logs/use-sandbox-logs.ts b/src/features/dashboard/sandbox/logs/use-sandbox-logs.ts new file mode 100644 index 000000000..3c6147208 --- /dev/null +++ b/src/features/dashboard/sandbox/logs/use-sandbox-logs.ts @@ -0,0 +1,84 @@ +'use client' + +import { useTRPCClient } from '@/trpc/client' +import { useQuery } from '@tanstack/react-query' +import { useCallback, useEffect, useRef } from 'react' +import { useStore } from 'zustand' +import { + createSandboxLogsStore, + type SandboxLogsStore, +} from './sandbox-logs-store' + +const REFETCH_INTERVAL_MS = 3_000 + +interface UseSandboxLogsParams { + teamIdOrSlug: string + sandboxId: string + isRunning: boolean +} + +export function useSandboxLogs({ + teamIdOrSlug, + sandboxId, + isRunning, +}: UseSandboxLogsParams) { + const trpcClient = useTRPCClient() + const storeRef = useRef(null) + + if (!storeRef.current) { + storeRef.current = createSandboxLogsStore() + } + + const store = storeRef.current + + const logs = useStore(store, (s) => s.logs) + const isInitialized = useStore(store, (s) => s.isInitialized) + const hasMoreBackwards = useStore(store, (s) => s.hasMoreBackwards) + 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]) + + const isDraining = useRef(false) + + useEffect(() => { + if (isRunning) { + isDraining.current = true + } + }, [isRunning]) + + const shouldPoll = isInitialized && (isRunning || isDraining.current) + + const { isFetching: isPolling } = useQuery({ + queryKey: ['sandboxLogsForward', teamIdOrSlug, sandboxId], + queryFn: async () => { + const { logsCount } = await store.getState().fetchMoreForwards() + + if (!isRunning && logsCount === 0) { + isDraining.current = false + } + + return { logsCount } + }, + enabled: shouldPoll, + refetchInterval: shouldPoll ? REFETCH_INTERVAL_MS : false, + refetchIntervalInBackground: false, + refetchOnWindowFocus: 'always', + }) + + const fetchNextPage = useCallback(() => { + store.getState().fetchMoreBackwards() + }, [store]) + + return { + logs, + isInitialized, + hasNextPage: hasMoreBackwards, + isFetchingNextPage: isLoadingBackwards, + isRefetchingForwardLogs: isLoadingForwards || isPolling, + isFetching: isLoadingBackwards || isLoadingForwards || isPolling, + fetchNextPage, + } +} diff --git a/src/features/dashboard/sandbox/logs/view.tsx b/src/features/dashboard/sandbox/logs/view.tsx new file mode 100644 index 000000000..eb2e87c5c --- /dev/null +++ b/src/features/dashboard/sandbox/logs/view.tsx @@ -0,0 +1,25 @@ +'use client' + +import { cn } from '@/lib/utils' +import SandboxLogs from './logs' + +interface SandboxLogsViewProps { + teamIdOrSlug: string + sandboxId: string +} + +export default function SandboxLogsView({ + teamIdOrSlug, + sandboxId, +}: SandboxLogsViewProps) { + return ( +
+ +
+ ) +} diff --git a/src/features/dashboard/sandboxes/list/table-row.tsx b/src/features/dashboard/sandboxes/list/table-row.tsx index ae3da3df5..6cb0a24c6 100644 --- a/src/features/dashboard/sandboxes/list/table-row.tsx +++ b/src/features/dashboard/sandboxes/list/table-row.tsx @@ -19,10 +19,7 @@ export const TableRow = memo(function TableRow({ row }: TableRowProps) { return ( diff --git a/src/server/api/models/sandboxes.models.ts b/src/server/api/models/sandboxes.models.ts new file mode 100644 index 000000000..cbf5ca1ab --- /dev/null +++ b/src/server/api/models/sandboxes.models.ts @@ -0,0 +1,26 @@ +import type { components } from '@/types/infra-api.types' + +export type SandboxLogLevel = components['schemas']['LogLevel'] + +export interface SandboxLogDTO { + timestampUnix: number + level: SandboxLogLevel + message: string +} + +export interface SandboxLogsDTO { + logs: SandboxLogDTO[] + nextCursor: number | null +} + +// mappings + +export function mapInfraSandboxLogToDTO( + log: components['schemas']['SandboxLogEntry'] +): SandboxLogDTO { + return { + timestampUnix: new Date(log.timestamp).getTime(), + level: log.level, + message: log.message, + } +} diff --git a/src/server/api/repositories/sandboxes.repository.ts b/src/server/api/repositories/sandboxes.repository.ts new file mode 100644 index 000000000..d8937c417 --- /dev/null +++ b/src/server/api/repositories/sandboxes.repository.ts @@ -0,0 +1,69 @@ +import { SUPABASE_AUTH_HEADERS } from '@/configs/api' +import { infra } from '@/lib/clients/api' +import { l } from '@/lib/clients/logger/logger' +import { TRPCError } from '@trpc/server' +import { apiError } from '../errors' + +// get sandbox logs + +export interface GetSandboxLogsOptions { + cursor?: number + limit?: number + direction?: 'forward' | 'backward' +} + +export async function getSandboxLogs( + accessToken: string, + teamId: string, + sandboxId: string, + options: GetSandboxLogsOptions = {} +) { + const result = await infra.GET('/v2/sandboxes/{sandboxID}/logs', { + params: { + path: { + sandboxID: sandboxId, + }, + query: { + cursor: options.cursor, + limit: options.limit, + direction: options.direction, + }, + }, + headers: { + ...SUPABASE_AUTH_HEADERS(accessToken, teamId), + }, + }) + + if (!result.response.ok || result.error) { + const status = result.response.status + + l.error( + { + key: 'repositories:sandboxes:get_sandbox_logs:infra_error', + error: result.error, + team_id: teamId, + context: { + status, + path: '/v2/sandboxes/{sandboxID}/logs', + sandbox_id: sandboxId, + }, + }, + `failed to fetch /v2/sandboxes/{sandboxID}/logs: ${result.error?.message || 'Unknown error'}` + ) + + if (status === 404) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: "Sandbox not found or you don't have access to it", + }) + } + + throw apiError(status) + } + + return result.data +} + +export const sandboxesRepo = { + getSandboxLogs, +} diff --git a/src/server/api/routers/index.ts b/src/server/api/routers/index.ts index dffe803c8..239785ddd 100644 --- a/src/server/api/routers/index.ts +++ b/src/server/api/routers/index.ts @@ -1,10 +1,12 @@ import { createCallerFactory, createTRPCRouter } from '../init' import { billingRouter } from './billing' import { buildsRouter } from './builds' +import { sandboxRouter } from './sandbox' import { sandboxesRouter } from './sandboxes' import { templatesRouter } from './templates' export const trpcAppRouter = createTRPCRouter({ + sandbox: sandboxRouter, sandboxes: sandboxesRouter, templates: templatesRouter, builds: buildsRouter, diff --git a/src/server/api/routers/sandbox.ts b/src/server/api/routers/sandbox.ts new file mode 100644 index 000000000..81e7ed520 --- /dev/null +++ b/src/server/api/routers/sandbox.ts @@ -0,0 +1,94 @@ +import { z } from 'zod' +import { createTRPCRouter } from '../init' +import { + mapInfraSandboxLogToDTO, + SandboxLogDTO, + SandboxLogsDTO, +} from '../models/sandboxes.models' +import { protectedTeamProcedure } from '../procedures' +import { sandboxesRepo } from '../repositories/sandboxes.repository' + +export const sandboxRouter = createTRPCRouter({ + // QUERIES + + logsBackwards: protectedTeamProcedure + .input( + z.object({ + sandboxId: z.string(), + cursor: z.number().optional(), + }) + ) + .query(async ({ ctx, input }) => { + const { teamId, session } = ctx + const { sandboxId } = input + let { cursor } = input + + cursor ??= Date.now() + + const direction = 'backward' + const limit = 100 + + const sandboxLogs = await sandboxesRepo.getSandboxLogs( + session.access_token, + teamId, + sandboxId, + { cursor, limit, direction } + ) + + const logs: SandboxLogDTO[] = sandboxLogs.logs + .map(mapInfraSandboxLogToDTO) + .sort((a, b) => a.timestampUnix - b.timestampUnix) + + const hasMore = logs.length === limit + const cursorLog = logs[0] + const nextCursor = hasMore ? (cursorLog?.timestampUnix ?? null) : null + + const result: SandboxLogsDTO = { + logs, + nextCursor, + } + + return result + }), + + logsForward: protectedTeamProcedure + .input( + z.object({ + sandboxId: z.string(), + cursor: z.number().optional(), + }) + ) + .query(async ({ ctx, input }) => { + const { teamId, session } = ctx + const { sandboxId } = input + let { cursor } = input + + cursor ??= Date.now() + + const direction = 'forward' + const limit = 100 + + const sandboxLogs = await sandboxesRepo.getSandboxLogs( + session.access_token, + teamId, + sandboxId, + { cursor, limit, direction } + ) + + const logs: SandboxLogDTO[] = sandboxLogs.logs + .map(mapInfraSandboxLogToDTO) + .sort((a, b) => a.timestampUnix - b.timestampUnix) + + const newestLog = logs[logs.length - 1] + const nextCursor = newestLog?.timestampUnix ?? null + + const result: SandboxLogsDTO = { + logs, + nextCursor, + } + + return result + }), + + // MUTATIONS +}) diff --git a/src/types/infra-api.types.ts b/src/types/infra-api.types.ts index 1c4537e3a..7b1e800f5 100644 --- a/src/types/infra-api.types.ts +++ b/src/types/infra-api.types.ts @@ -376,6 +376,53 @@ export interface paths { patch?: never; trace?: never; }; + "/v2/sandboxes/{sandboxID}/logs": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** @description Get sandbox logs (v2) */ + get: { + parameters: { + query?: { + /** @description Starting timestamp of the logs that should be returned in milliseconds */ + cursor?: number; + /** @description Maximum number of logs that should be returned */ + limit?: number; + direction?: components["schemas"]["LogsDirection"]; + }; + header?: never; + path: { + sandboxID: components["parameters"]["sandboxID"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successfully returned the sandbox logs */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SandboxLogsV2Response"]; + }; + }; + 401: components["responses"]["401"]; + 404: components["responses"]["404"]; + 500: components["responses"]["500"]; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/sandboxes/{sandboxID}": { parameters: { query?: never; @@ -1865,6 +1912,13 @@ export interface components { /** @description Structured logs of the sandbox */ logEntries: components["schemas"]["SandboxLogEntry"][]; }; + SandboxLogsV2Response: { + /** + * @description Sandbox logs structured + * @default [] + */ + logs: components["schemas"]["SandboxLogEntry"][]; + }; /** @description Metric entry with timestamp and line */ SandboxMetric: { /** diff --git a/src/ui/dashboard-tabs.tsx b/src/ui/dashboard-tabs.tsx index 306bcbffb..3a8c2df56 100644 --- a/src/ui/dashboard-tabs.tsx +++ b/src/ui/dashboard-tabs.tsx @@ -13,6 +13,7 @@ export interface DashboardTabsProps { type: 'query' | 'path' children: Array | DashboardTabElement className?: string + headerAccessory?: ReactNode } // COMPONENT @@ -22,6 +23,7 @@ function DashboardTabsComponent({ type, children, className, + headerAccessory, }: DashboardTabsProps) { const searchParams = useSearchParams() const pathname = usePathname() @@ -65,22 +67,46 @@ function DashboardTabsComponent({ value={activeTabId} className={cn('min-h-0 w-full flex-1 h-full', className)} > - - {tabsWithHrefs.map((tab) => ( - - - {tab.icon} - {tab.label} - - - ))} - + {headerAccessory ? ( +
+
+ {headerAccessory} +
+ + {tabsWithHrefs.map((tab) => ( + + + {tab.icon} + {tab.label} + + + ))} + +
+ ) : ( + + {tabsWithHrefs.map((tab) => ( + + + {tab.icon} + {tab.label} + + + ))} + + )} {children} @@ -91,7 +117,8 @@ export const DashboardTabs = memo(DashboardTabsComponent, (prev, next) => { if ( prev.layoutKey !== next.layoutKey || prev.type !== next.type || - prev.className !== next.className + prev.className !== next.className || + prev.headerAccessory !== next.headerAccessory ) { return false } diff --git a/src/ui/primitives/icons.tsx b/src/ui/primitives/icons.tsx index c23459b26..97909eacf 100644 --- a/src/ui/primitives/icons.tsx +++ b/src/ui/primitives/icons.tsx @@ -1225,6 +1225,23 @@ export const ListIcon = ({ className, ...props }: IconProps) => ( ) +export const StorageIcon = ({ className, ...props }: IconProps) => ( + + + +) + export const InfoIcon = ({ className, ...props }: IconProps) => ( { @@ -46,21 +46,21 @@ const ThemeSwitcher = ({ className }: ThemeSwitcherProps) => { className="flex items-center gap-2" value="light" > - + Light - + Dark - + System