From aad01113618b91f6629f416d9cbf292f8351ea67 Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Tue, 23 Dec 2025 16:59:13 +0100 Subject: [PATCH 01/54] update infra oapi spec --- spec/openapi.infra.yaml | 55 ++++++++++++++++++++++++++++++++++++ src/types/infra-api.types.ts | 54 +++++++++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+) diff --git a/spec/openapi.infra.yaml b/spec/openapi.infra.yaml index 8979f5ee5..ff62a99d5 100644 --- a/spec/openapi.infra.yaml +++ b/spec/openapi.infra.yaml @@ -289,6 +289,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: @@ -1805,6 +1816,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/types/infra-api.types.ts b/src/types/infra-api.types.ts index c86ee374f..9db24efc6 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; @@ -1701,6 +1748,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: { /** From 3489b2b6f6bc00d547dec5f2b3f1303e907f84ae Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Mon, 29 Dec 2025 14:30:03 +0100 Subject: [PATCH 02/54] wip: first version of logs viewer --- .../{inspect => filesystem}/loading.tsx | 0 .../{inspect => filesystem}/page.tsx | 0 .../sandboxes/[sandboxId]/logs/loading.tsx | 1 + .../sandboxes/[sandboxId]/logs/page.tsx | 11 + src/app/sbx/new/route.ts | 4 +- src/configs/urls.ts | 6 +- src/features/dashboard/sandbox/layout.tsx | 11 +- .../dashboard/sandbox/logs/logs-cells.tsx | 62 +++ src/features/dashboard/sandbox/logs/logs.tsx | 511 ++++++++++++++++++ .../sandbox/logs/sandbox-logs-store.ts | 257 +++++++++ .../sandbox/logs/use-sandbox-logs.ts | 83 +++ src/features/dashboard/sandbox/logs/view.tsx | 25 + .../dashboard/sandboxes/list/table-row.tsx | 5 +- src/server/api/models/sandboxes.models.ts | 26 + .../api/repositories/sandboxes.repository.ts | 69 +++ src/server/api/routers/index.ts | 2 + src/server/api/routers/sandbox.ts | 94 ++++ 17 files changed, 1157 insertions(+), 10 deletions(-) rename src/app/dashboard/[teamIdOrSlug]/sandboxes/[sandboxId]/{inspect => filesystem}/loading.tsx (100%) rename src/app/dashboard/[teamIdOrSlug]/sandboxes/[sandboxId]/{inspect => filesystem}/page.tsx (100%) create mode 100644 src/app/dashboard/[teamIdOrSlug]/sandboxes/[sandboxId]/logs/loading.tsx create mode 100644 src/app/dashboard/[teamIdOrSlug]/sandboxes/[sandboxId]/logs/page.tsx create mode 100644 src/features/dashboard/sandbox/logs/logs-cells.tsx create mode 100644 src/features/dashboard/sandbox/logs/logs.tsx create mode 100644 src/features/dashboard/sandbox/logs/sandbox-logs-store.ts create mode 100644 src/features/dashboard/sandbox/logs/use-sandbox-logs.ts create mode 100644 src/features/dashboard/sandbox/logs/view.tsx create mode 100644 src/server/api/models/sandboxes.models.ts create mode 100644 src/server/api/repositories/sandboxes.repository.ts create mode 100644 src/server/api/routers/sandbox.ts 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]/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/urls.ts b/src/configs/urls.ts index 924746238..f6ca14c4e 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/sandbox/layout.tsx b/src/features/dashboard/sandbox/layout.tsx index 68f94cf6d..a3d6ce65b 100644 --- a/src/features/dashboard/sandbox/layout.tsx +++ b/src/features/dashboard/sandbox/layout.tsx @@ -38,8 +38,8 @@ export default function SandboxLayout({ {isEnvdVersionCompatibleForInspect ? ( @@ -51,6 +51,13 @@ 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..26ac14d1f --- /dev/null +++ b/src/features/dashboard/sandbox/logs/logs-cells.tsx @@ -0,0 +1,62 @@ +import { SandboxLogDTO } from '@/server/api/models/sandboxes.models' +import CopyButtonInline from '@/ui/copy-button-inline' +import { Badge, BadgeProps } from '@/ui/primitives/badge' +import { format } from 'date-fns' +import { enUS } from 'date-fns/locale/en-US' + +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) + + // format: "Feb 13 09:39:01.17" + const centiseconds = Math.floor((date.getMilliseconds() / 10) % 100) + .toString() + .padStart(2, '0') + + return ( + + {format(date, 'MMM dd HH:mm:ss', { locale: enUS })}.{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..24e82b51d --- /dev/null +++ b/src/features/dashboard/sandbox/logs/logs.tsx @@ -0,0 +1,511 @@ +'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 { RefObject, useCallback, useEffect, useReducer, useRef } 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: 176 + 16, level: 52 + 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 scrollContainerRef = useRef(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 && ( + + )} +
+
+
+ ) +} + +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[] + scrollContainerRef: RefObject + onLoadMore: () => void + hasNextPage: boolean + isFetchingNextPage: boolean + isInitialized: boolean + isRunning: boolean +} + +function VirtualizedLogsBody({ + logs, + scrollContainerRef, + onLoadMore, + hasNextPage, + isFetchingNextPage, + isInitialized, + isRunning, +}: VirtualizedLogsBodyProps) { + const tbodyRef = useRef(null) + const maxWidthRef = useRef(0) + const [, forceRerender] = useReducer(() => ({}), {}) + + useEffect(() => { + if (scrollContainerRef.current) forceRerender() + }, [scrollContainerRef]) + + useScrollLoadMore({ + scrollContainerRef, + hasNextPage, + isFetchingNextPage, + onLoadMore, + }) + + useAutoScrollToBottom({ + scrollContainerRef, + logsCount: logs.length, + isInitialized, + isRunning, + }) + + useMaintainScrollOnPrepend({ + scrollContainerRef, + logsCount: logs.length, + isFetchingNextPage, + }) + + const showStatusRow = hasNextPage || isFetchingNextPage + + const virtualizer = useVirtualizer({ + count: logs.length + (showStatusRow ? 1 : 0), + estimateSize: () => ROW_HEIGHT_PX, + getScrollElement: () => scrollContainerRef.current, + overscan: VIRTUAL_OVERSCAN, + paddingStart: 8, + }) + + const containerWidth = scrollContainerRef.current?.clientWidth ?? 0 + const contentWidth = scrollContainerRef.current?.scrollWidth ?? 0 + 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 isStatusRow = showStatusRow && virtualRow.index === 0 + + if (isStatusRow) { + return ( + + ) + } + + const logIndex = showStatusRow ? virtualRow.index - 1 : virtualRow.index + + return ( + + ) + })} + + ) +} + +interface UseScrollLoadMoreParams { + scrollContainerRef: RefObject + hasNextPage: boolean + isFetchingNextPage: boolean + onLoadMore: () => void +} + +function useScrollLoadMore({ + scrollContainerRef, + hasNextPage, + isFetchingNextPage, + onLoadMore, +}: UseScrollLoadMoreParams) { + useEffect(() => { + const scrollContainer = scrollContainerRef.current + if (!scrollContainer) return + + const handleScroll = () => { + if ( + scrollContainer.scrollTop < SCROLL_LOAD_THRESHOLD_PX && + hasNextPage && + !isFetchingNextPage + ) { + onLoadMore() + } + } + + scrollContainer.addEventListener('scroll', handleScroll) + return () => scrollContainer.removeEventListener('scroll', handleScroll) + }, [scrollContainerRef, hasNextPage, isFetchingNextPage, onLoadMore]) +} + +interface UseMaintainScrollOnPrependParams { + scrollContainerRef: RefObject + logsCount: number + isFetchingNextPage: boolean +} + +function useMaintainScrollOnPrepend({ + scrollContainerRef, + logsCount, + isFetchingNextPage, +}: UseMaintainScrollOnPrependParams) { + const prevLogsCountRef = useRef(logsCount) + const wasFetchingRef = useRef(false) + + useEffect(() => { + const el = scrollContainerRef.current + if (!el) return + + const justFinishedFetching = wasFetchingRef.current && !isFetchingNextPage + const logsWerePrepended = logsCount > prevLogsCountRef.current + + if (justFinishedFetching && logsWerePrepended) { + const addedCount = logsCount - prevLogsCountRef.current + el.scrollTop += addedCount * ROW_HEIGHT_PX + } + + wasFetchingRef.current = isFetchingNextPage + prevLogsCountRef.current = logsCount + }, [scrollContainerRef, logsCount, isFetchingNextPage]) +} + +interface UseAutoScrollToBottomParams { + scrollContainerRef: RefObject + logsCount: number + isInitialized: boolean + isRunning: boolean +} + +function useAutoScrollToBottom({ + scrollContainerRef, + logsCount, + isInitialized, + isRunning, +}: UseAutoScrollToBottomParams) { + const isAutoScrollEnabledRef = useRef(true) + const prevLogsCountRef = useRef(0) + const hasInitialScrolled = useRef(false) + + useEffect(() => { + const el = scrollContainerRef.current + if (!el) return + + const handleScroll = () => { + const distanceFromBottom = + el.scrollHeight - el.scrollTop - el.clientHeight + isAutoScrollEnabledRef.current = distanceFromBottom < ROW_HEIGHT_PX * 2 + } + + el.addEventListener('scroll', handleScroll) + return () => el.removeEventListener('scroll', handleScroll) + }, [scrollContainerRef]) + + useEffect(() => { + if (isInitialized && !hasInitialScrolled.current && logsCount > 0) { + hasInitialScrolled.current = true + prevLogsCountRef.current = logsCount + requestAnimationFrame(() => { + const el = scrollContainerRef.current + if (el) el.scrollTop = el.scrollHeight + }) + } + }, [isInitialized, logsCount, scrollContainerRef]) + + // reset scroll state when sandbox stops/starts + useEffect(() => { + hasInitialScrolled.current = false + prevLogsCountRef.current = 0 + }, [isRunning]) + + useEffect(() => { + if (!hasInitialScrolled.current) return + + const newLogsCount = logsCount - prevLogsCountRef.current + + if (newLogsCount > 0 && isAutoScrollEnabledRef.current) { + const el = scrollContainerRef.current + if (el) el.scrollTop += newLogsCount * ROW_HEIGHT_PX + } + + prevLogsCountRef.current = logsCount + }, [logsCount, scrollContainerRef]) +} + +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' + )} + + + + ) +} 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..1b17e44e0 --- /dev/null +++ b/src/features/dashboard/sandbox/logs/use-sandbox-logs.ts @@ -0,0 +1,83 @@ +'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 = 1_500 + +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, + 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 85602dca8..34700cb9a 100644 --- a/src/server/api/routers/index.ts +++ b/src/server/api/routers/index.ts @@ -1,9 +1,11 @@ import { createCallerFactory, createTRPCRouter } from '../init' 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..aa1439339 --- /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 + ) + + 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 + ) + + const newestLog = logs[logs.length - 1] + const nextCursor = newestLog?.timestampUnix ?? null + + const result: SandboxLogsDTO = { + logs, + nextCursor, + } + + return result + }), + + // MUTATIONS +}) From 4b2a5865eda63d9ea3f11469ece55ad0474ce662 Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Tue, 10 Feb 2026 13:40:59 -0800 Subject: [PATCH 03/54] improve: sandboxes details title --- src/configs/layout.ts | 20 +++++++++++++++---- .../dashboard/sandbox/header/header.tsx | 12 ----------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/configs/layout.ts b/src/configs/layout.ts index 5e8656cc9..2d7070a1d 100644 --- a/src/configs/layout.ts +++ b/src/configs/layout.ts @@ -27,10 +27,22 @@ 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]! + + return { + title: [ + { + label: 'Sandboxes', + href: PROTECTED_URLS.SANDBOXES_LIST(teamIdOrSlug), + }, + { label: parts.pop()! }, + ], + type: 'custom', + } + }, '/dashboard/*/templates': () => ({ title: 'Templates', type: 'custom', diff --git a/src/features/dashboard/sandbox/header/header.tsx b/src/features/dashboard/sandbox/header/header.tsx index 64b384bc3..3da76c749 100644 --- a/src/features/dashboard/sandbox/header/header.tsx +++ b/src/features/dashboard/sandbox/header/header.tsx @@ -1,9 +1,6 @@ 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' @@ -33,15 +30,6 @@ export default async function SandboxDetailsHeader({
- - - Sandboxes -
From 656aab1d00ae31e5cca6d2d164b351d47b2e281a Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Tue, 10 Feb 2026 14:31:23 -0800 Subject: [PATCH 04/54] fix: initial logs ordering --- src/features/dashboard/sandbox/logs/logs.tsx | 15 +++++++++------ .../dashboard/sandbox/logs/use-sandbox-logs.ts | 2 +- src/server/api/routers/sandbox.ts | 12 ++++++------ 3 files changed, 16 insertions(+), 13 deletions(-) diff --git a/src/features/dashboard/sandbox/logs/logs.tsx b/src/features/dashboard/sandbox/logs/logs.tsx index 24e82b51d..2dc192f2f 100644 --- a/src/features/dashboard/sandbox/logs/logs.tsx +++ b/src/features/dashboard/sandbox/logs/logs.tsx @@ -124,11 +124,11 @@ function LogsTableHeader() { > - Timestamp - + Timestamp { @@ -391,10 +392,12 @@ function useAutoScrollToBottom({ } }, [isInitialized, logsCount, scrollContainerRef]) - // reset scroll state when sandbox stops/starts useEffect(() => { - hasInitialScrolled.current = false - prevLogsCountRef.current = 0 + if (prevIsRunningRef.current !== isRunning) { + prevIsRunningRef.current = isRunning + hasInitialScrolled.current = false + prevLogsCountRef.current = 0 + } }, [isRunning]) useEffect(() => { diff --git a/src/features/dashboard/sandbox/logs/use-sandbox-logs.ts b/src/features/dashboard/sandbox/logs/use-sandbox-logs.ts index 1b17e44e0..c2f576dee 100644 --- a/src/features/dashboard/sandbox/logs/use-sandbox-logs.ts +++ b/src/features/dashboard/sandbox/logs/use-sandbox-logs.ts @@ -9,7 +9,7 @@ import { type SandboxLogsStore, } from './sandbox-logs-store' -const REFETCH_INTERVAL_MS = 1_500 +const REFETCH_INTERVAL_MS = 3_000 interface UseSandboxLogsParams { teamIdOrSlug: string diff --git a/src/server/api/routers/sandbox.ts b/src/server/api/routers/sandbox.ts index aa1439339..81e7ed520 100644 --- a/src/server/api/routers/sandbox.ts +++ b/src/server/api/routers/sandbox.ts @@ -35,9 +35,9 @@ export const sandboxRouter = createTRPCRouter({ { cursor, limit, direction } ) - const logs: SandboxLogDTO[] = sandboxLogs.logs.map( - mapInfraSandboxLogToDTO - ) + const logs: SandboxLogDTO[] = sandboxLogs.logs + .map(mapInfraSandboxLogToDTO) + .sort((a, b) => a.timestampUnix - b.timestampUnix) const hasMore = logs.length === limit const cursorLog = logs[0] @@ -75,9 +75,9 @@ export const sandboxRouter = createTRPCRouter({ { cursor, limit, direction } ) - const logs: SandboxLogDTO[] = sandboxLogs.logs.map( - mapInfraSandboxLogToDTO - ) + 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 From edc5f95beabfe2763aed646307b0fccd6d6fbd83 Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Tue, 10 Feb 2026 15:34:28 -0800 Subject: [PATCH 05/54] refactor: streamline header components and improve logs handling - Removed redundant wrapper in SandboxDetailsHeader for SandboxDetailsTitle. - Updated DashboardLayoutHeader to ensure consistent rendering of ThemeSwitcher. - Refactored logs handling in VirtualizedLogsBody to utilize state for scroll container, enhancing performance and readability. - Adjusted event listeners for scroll handling to improve responsiveness. --- src/features/dashboard/layouts/header.tsx | 12 +- .../dashboard/sandbox/header/header.tsx | 6 +- src/features/dashboard/sandbox/logs/logs.tsx | 129 +++++++++--------- 3 files changed, 76 insertions(+), 71 deletions(-) diff --git a/src/features/dashboard/layouts/header.tsx b/src/features/dashboard/layouts/header.tsx index 1dce270a8..d7c6ef73c 100644 --- a/src/features/dashboard/layouts/header.tsx +++ b/src/features/dashboard/layouts/header.tsx @@ -43,14 +43,14 @@ export default function DashboardLayoutHeader({ + + + + {/* Ghost element - reserves width but not height */}
{children}
- - - -
) @@ -65,7 +65,9 @@ function HeaderTitle({ title }: { title: string | TitleSegment[] }) { {title.map((segment, index) => ( - {index > 0 && /} + {index > 0 && ( + / + )} {segment.href ? ( +
-
- -
+
(null) + const [scrollContainerElement, setScrollContainerElement] = + useState(null) const { logs, @@ -93,16 +100,16 @@ function LogsContent({ teamIdOrSlug, sandboxId, isRunning }: LogsContentProps) { return (
-
+
{showLoader && } {showEmpty && } - {hasLogs && ( + {hasLogs && scrollContainerElement && ( - Timestamp + Timestamp Level - + Message @@ -180,7 +190,7 @@ function EmptyBody() { interface VirtualizedLogsBodyProps { logs: SandboxLogDTO[] - scrollContainerRef: RefObject + scrollContainerElement: HTMLDivElement onLoadMore: () => void hasNextPage: boolean isFetchingNextPage: boolean @@ -190,7 +200,7 @@ interface VirtualizedLogsBodyProps { function VirtualizedLogsBody({ logs, - scrollContainerRef, + scrollContainerElement, onLoadMore, hasNextPage, isFetchingNextPage, @@ -199,28 +209,16 @@ function VirtualizedLogsBody({ }: VirtualizedLogsBodyProps) { const tbodyRef = useRef(null) const maxWidthRef = useRef(0) - const [, forceRerender] = useReducer(() => ({}), {}) - - useEffect(() => { - if (scrollContainerRef.current) forceRerender() - }, [scrollContainerRef]) useScrollLoadMore({ - scrollContainerRef, + scrollContainerElement, hasNextPage, isFetchingNextPage, onLoadMore, }) - useAutoScrollToBottom({ - scrollContainerRef, - logsCount: logs.length, - isInitialized, - isRunning, - }) - useMaintainScrollOnPrepend({ - scrollContainerRef, + scrollContainerElement, logsCount: logs.length, isFetchingNextPage, }) @@ -230,13 +228,27 @@ function VirtualizedLogsBody({ const virtualizer = useVirtualizer({ count: logs.length + (showStatusRow ? 1 : 0), estimateSize: () => ROW_HEIGHT_PX, - getScrollElement: () => scrollContainerRef.current, + getScrollElement: () => scrollContainerElement, overscan: VIRTUAL_OVERSCAN, paddingStart: 8, }) - const containerWidth = scrollContainerRef.current?.clientWidth ?? 0 - const contentWidth = scrollContainerRef.current?.scrollWidth ?? 0 + const scrollToLatestLog = useCallback(() => { + if (logs.length === 0) return + const lastLogIndex = logs.length - 1 + (showStatusRow ? 1 : 0) + virtualizer.scrollToIndex(lastLogIndex, { align: 'end' }) + }, [logs.length, showStatusRow, 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 @@ -287,25 +299,22 @@ function VirtualizedLogsBody({ } interface UseScrollLoadMoreParams { - scrollContainerRef: RefObject + scrollContainerElement: HTMLDivElement hasNextPage: boolean isFetchingNextPage: boolean onLoadMore: () => void } function useScrollLoadMore({ - scrollContainerRef, + scrollContainerElement, hasNextPage, isFetchingNextPage, onLoadMore, }: UseScrollLoadMoreParams) { useEffect(() => { - const scrollContainer = scrollContainerRef.current - if (!scrollContainer) return - const handleScroll = () => { if ( - scrollContainer.scrollTop < SCROLL_LOAD_THRESHOLD_PX && + scrollContainerElement.scrollTop < SCROLL_LOAD_THRESHOLD_PX && hasNextPage && !isFetchingNextPage ) { @@ -313,19 +322,20 @@ function useScrollLoadMore({ } } - scrollContainer.addEventListener('scroll', handleScroll) - return () => scrollContainer.removeEventListener('scroll', handleScroll) - }, [scrollContainerRef, hasNextPage, isFetchingNextPage, onLoadMore]) + scrollContainerElement.addEventListener('scroll', handleScroll) + return () => + scrollContainerElement.removeEventListener('scroll', handleScroll) + }, [scrollContainerElement, hasNextPage, isFetchingNextPage, onLoadMore]) } interface UseMaintainScrollOnPrependParams { - scrollContainerRef: RefObject + scrollContainerElement: HTMLDivElement logsCount: number isFetchingNextPage: boolean } function useMaintainScrollOnPrepend({ - scrollContainerRef, + scrollContainerElement, logsCount, isFetchingNextPage, }: UseMaintainScrollOnPrependParams) { @@ -333,34 +343,33 @@ function useMaintainScrollOnPrepend({ const wasFetchingRef = useRef(false) useEffect(() => { - const el = scrollContainerRef.current - if (!el) return - const justFinishedFetching = wasFetchingRef.current && !isFetchingNextPage const logsWerePrepended = logsCount > prevLogsCountRef.current if (justFinishedFetching && logsWerePrepended) { const addedCount = logsCount - prevLogsCountRef.current - el.scrollTop += addedCount * ROW_HEIGHT_PX + scrollContainerElement.scrollTop += addedCount * ROW_HEIGHT_PX } wasFetchingRef.current = isFetchingNextPage prevLogsCountRef.current = logsCount - }, [scrollContainerRef, logsCount, isFetchingNextPage]) + }, [scrollContainerElement, logsCount, isFetchingNextPage]) } interface UseAutoScrollToBottomParams { - scrollContainerRef: RefObject + scrollContainerElement: HTMLDivElement logsCount: number isInitialized: boolean isRunning: boolean + scrollToLatestLog: () => void } function useAutoScrollToBottom({ - scrollContainerRef, + scrollContainerElement, logsCount, isInitialized, isRunning, + scrollToLatestLog, }: UseAutoScrollToBottomParams) { const isAutoScrollEnabledRef = useRef(true) const prevLogsCountRef = useRef(0) @@ -368,29 +377,26 @@ function useAutoScrollToBottom({ const hasInitialScrolled = useRef(false) useEffect(() => { - const el = scrollContainerRef.current - if (!el) return - const handleScroll = () => { const distanceFromBottom = - el.scrollHeight - el.scrollTop - el.clientHeight + scrollContainerElement.scrollHeight - + scrollContainerElement.scrollTop - + scrollContainerElement.clientHeight isAutoScrollEnabledRef.current = distanceFromBottom < ROW_HEIGHT_PX * 2 } - el.addEventListener('scroll', handleScroll) - return () => el.removeEventListener('scroll', handleScroll) - }, [scrollContainerRef]) + scrollContainerElement.addEventListener('scroll', handleScroll) + return () => + scrollContainerElement.removeEventListener('scroll', handleScroll) + }, [scrollContainerElement]) - useEffect(() => { + useLayoutEffect(() => { if (isInitialized && !hasInitialScrolled.current && logsCount > 0) { hasInitialScrolled.current = true prevLogsCountRef.current = logsCount - requestAnimationFrame(() => { - const el = scrollContainerRef.current - if (el) el.scrollTop = el.scrollHeight - }) + scrollToLatestLog() } - }, [isInitialized, logsCount, scrollContainerRef]) + }, [isInitialized, logsCount, scrollToLatestLog]) useEffect(() => { if (prevIsRunningRef.current !== isRunning) { @@ -406,12 +412,11 @@ function useAutoScrollToBottom({ const newLogsCount = logsCount - prevLogsCountRef.current if (newLogsCount > 0 && isAutoScrollEnabledRef.current) { - const el = scrollContainerRef.current - if (el) el.scrollTop += newLogsCount * ROW_HEIGHT_PX + scrollContainerElement.scrollTop += newLogsCount * ROW_HEIGHT_PX } prevLogsCountRef.current = logsCount - }, [logsCount, scrollContainerRef]) + }, [logsCount, scrollContainerElement]) } interface LogRowProps { From 956058206a519102c18551836b1968aeee038d36 Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Tue, 10 Feb 2026 17:05:12 -0800 Subject: [PATCH 06/54] add: storage icon --- src/features/dashboard/sandbox/layout.tsx | 3 +++ src/ui/primitives/icons.tsx | 17 +++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/src/features/dashboard/sandbox/layout.tsx b/src/features/dashboard/sandbox/layout.tsx index a3d6ce65b..cbc9e0d86 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' @@ -41,6 +42,7 @@ export default function SandboxLayout({ id="filesystem" label="Filesystem" className="flex flex-col max-h-full" + icon={} > {isEnvdVersionCompatibleForInspect ? ( children @@ -55,6 +57,7 @@ export default function SandboxLayout({ id="logs" label="Logs" className="flex flex-col max-h-full" + icon={} > {children} 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) => ( Date: Tue, 10 Feb 2026 17:12:25 -0800 Subject: [PATCH 07/54] improve: timestamps --- .../dashboard/sandbox/logs/logs-cells.tsx | 16 ++++++++++++---- src/features/dashboard/sandbox/logs/logs.tsx | 18 +++++++++++++----- 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/src/features/dashboard/sandbox/logs/logs-cells.tsx b/src/features/dashboard/sandbox/logs/logs-cells.tsx index 26ac14d1f..6f01d723f 100644 --- a/src/features/dashboard/sandbox/logs/logs-cells.tsx +++ b/src/features/dashboard/sandbox/logs/logs-cells.tsx @@ -1,8 +1,6 @@ import { SandboxLogDTO } from '@/server/api/models/sandboxes.models' import CopyButtonInline from '@/ui/copy-button-inline' import { Badge, BadgeProps } from '@/ui/primitives/badge' -import { format } from 'date-fns' -import { enUS } from 'date-fns/locale/en-US' interface LogLevelProps { level: SandboxLogDTO['level'] @@ -38,17 +36,27 @@ interface TimestampProps { export const Timestamp = ({ timestampUnix }: TimestampProps) => { const date = new Date(timestampUnix) - // format: "Feb 13 09:39:01.17" 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 ( - {format(date, 'MMM dd HH:mm:ss', { locale: enUS })}.{centiseconds} + {localDatePart} {localTimePart}. + {centiseconds} ) } diff --git a/src/features/dashboard/sandbox/logs/logs.tsx b/src/features/dashboard/sandbox/logs/logs.tsx index ff21cebf8..21360b988 100644 --- a/src/features/dashboard/sandbox/logs/logs.tsx +++ b/src/features/dashboard/sandbox/logs/logs.tsx @@ -28,7 +28,7 @@ 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: 176 + 16, level: 52 + 16 } as const +const COLUMN_WIDTHS_PX = { timestamp: 142 + 16, level: 52 + 16 } as const const ROW_HEIGHT_PX = 26 const VIRTUAL_OVERSCAN = 16 const SCROLL_LOAD_THRESHOLD_PX = 200 @@ -100,7 +100,10 @@ function LogsContent({ teamIdOrSlug, sandboxId, isRunning }: LogsContentProps) { return (
-
+
@@ -138,8 +141,12 @@ function LogsTableHeader() { Timestamp Level @@ -450,11 +457,12 @@ function LogRow({ log, virtualRow, virtualizer }: LogRowProps) { From dbebfec6f9889806736232333a9cbee49ad545ed Mon Sep 17 00:00:00 2001 From: Ben Fornefeld Date: Thu, 12 Feb 2026 02:56:58 +0900 Subject: [PATCH 08/54] improve: log level column --- src/features/dashboard/sandbox/logs/logs.tsx | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/src/features/dashboard/sandbox/logs/logs.tsx b/src/features/dashboard/sandbox/logs/logs.tsx index 21360b988..0e4d7d191 100644 --- a/src/features/dashboard/sandbox/logs/logs.tsx +++ b/src/features/dashboard/sandbox/logs/logs.tsx @@ -28,7 +28,7 @@ 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: 52 + 16 } as const +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 @@ -141,12 +141,8 @@ function LogsTableHeader() { Timestamp Level @@ -457,12 +453,11 @@ function LogRow({ log, virtualRow, virtualizer }: LogRowProps) { From bb06a7db57fee1ddbe61d189330d521a046e5861 Mon Sep 17 00:00:00 2001 From: Ben Fornefeld Date: Thu, 12 Feb 2026 04:28:02 +0900 Subject: [PATCH 09/54] add: dashboard layout copyable header title value --- src/configs/layout.ts | 6 +++++- src/features/dashboard/layouts/header.tsx | 19 ++++++++++++++++--- src/ui/theme-switcher.tsx | 10 +++++----- 3 files changed, 26 insertions(+), 9 deletions(-) diff --git a/src/configs/layout.ts b/src/configs/layout.ts index 2d7070a1d..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 } @@ -31,6 +32,7 @@ const DASHBOARD_LAYOUT_CONFIGS: Record< const parts = pathname.split('/') const teamIdOrSlug = parts[2]! const sandboxId = parts[4]! + const sandboxIdSliced = `${sandboxId.slice(0, 6)}...${sandboxId.slice(-6)}` return { title: [ @@ -38,9 +40,10 @@ const DASHBOARD_LAYOUT_CONFIGS: Record< label: 'Sandboxes', href: PROTECTED_URLS.SANDBOXES_LIST(teamIdOrSlug), }, - { label: parts.pop()! }, + { label: sandboxIdSliced }, ], type: 'custom', + copyValue: sandboxId, } }, '/dashboard/*/templates': () => ({ @@ -62,6 +65,7 @@ const DASHBOARD_LAYOUT_CONFIGS: Record< { label: `Build ${buildIdSliced}` }, ], type: 'custom', + copyValue: buildId, custom: { includeHeaderBottomStyles: true, }, diff --git a/src/features/dashboard/layouts/header.tsx b/src/features/dashboard/layouts/header.tsx index d7c6ef73c..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 (
-

- -

+
+

+ +

+ {copyableValue && ( + + )} +
diff --git a/src/ui/theme-switcher.tsx b/src/ui/theme-switcher.tsx index 3b6bc2763..9ac3855ee 100644 --- a/src/ui/theme-switcher.tsx +++ b/src/ui/theme-switcher.tsx @@ -31,9 +31,9 @@ const ThemeSwitcher = ({ className }: ThemeSwitcherProps) => { @@ -46,21 +46,21 @@ const ThemeSwitcher = ({ className }: ThemeSwitcherProps) => { className="flex items-center gap-2" value="light" > - + Light - + Dark - + System From 6ad9e7e35fd891a02dd4e155c08bdf9204e4e00d Mon Sep 17 00:00:00 2001 From: Ben Fornefeld Date: Thu, 12 Feb 2026 05:12:14 +0900 Subject: [PATCH 10/54] refactor: header layout --- .../sandboxes/[sandboxId]/layout.tsx | 9 +- .../dashboard/sandbox/header/controls.tsx | 21 +++ .../dashboard/sandbox/header/header.tsx | 28 +--- .../dashboard/sandbox/header/kill-button.tsx | 9 +- .../dashboard/sandbox/header/title.tsx | 24 ---- src/features/dashboard/sandbox/layout.tsx | 8 +- src/features/dashboard/sandbox/logs/logs.tsx | 127 ++++++++++++++++-- .../sandbox/logs/use-sandbox-logs.ts | 1 + src/ui/dashboard-tabs.tsx | 61 ++++++--- 9 files changed, 200 insertions(+), 88 deletions(-) create mode 100644 src/features/dashboard/sandbox/header/controls.tsx delete mode 100644 src/features/dashboard/sandbox/header/title.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/features/dashboard/sandbox/header/controls.tsx b/src/features/dashboard/sandbox/header/controls.tsx new file mode 100644 index 000000000..174aa26a4 --- /dev/null +++ b/src/features/dashboard/sandbox/header/controls.tsx @@ -0,0 +1,21 @@ +import { COOKIE_KEYS } from '@/configs/cookies' +import { cookies } from 'next/headers' +import KillButton from './kill-button' +import RefreshControl from './refresh' + +export default async function SandboxDetailsControls() { + const initialPollingInterval = (await cookies()).get( + COOKIE_KEYS.SANDBOX_INSPECT_POLLING_INTERVAL + )?.value + + return ( +
+ + +
+ ) +} diff --git a/src/features/dashboard/sandbox/header/header.tsx b/src/features/dashboard/sandbox/header/header.tsx index 92c267aa4..5b495b0bb 100644 --- a/src/features/dashboard/sandbox/header/header.tsx +++ b/src/features/dashboard/sandbox/header/header.tsx @@ -1,48 +1,22 @@ -import { COOKIE_KEYS } from '@/configs/cookies' import { SandboxInfo } from '@/types/api.types' -import { cookies } from 'next/headers' 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 ( -
-
- -
- - -
-
- +
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 cbc9e0d86..252b9bf09 100644 --- a/src/features/dashboard/sandbox/layout.tsx +++ b/src/features/dashboard/sandbox/layout.tsx @@ -12,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() @@ -37,7 +39,11 @@ export default function SandboxLayout({
{header} - + ROW_HEIGHT_PX, getScrollElement: () => scrollContainerElement, overscan: VIRTUAL_OVERSCAN, @@ -238,9 +242,8 @@ function VirtualizedLogsBody({ const scrollToLatestLog = useCallback(() => { if (logs.length === 0) return - const lastLogIndex = logs.length - 1 + (showStatusRow ? 1 : 0) - virtualizer.scrollToIndex(lastLogIndex, { align: 'end' }) - }, [logs.length, showStatusRow, virtualizer]) + virtualizer.scrollToIndex(liveStatusRowIndex, { align: 'end' }) + }, [logs.length, liveStatusRowIndex, logsStartIndex, virtualizer]) useAutoScrollToBottom({ scrollContainerElement, @@ -273,12 +276,13 @@ function VirtualizedLogsBody({ }} > {virtualizer.getVirtualItems().map((virtualRow) => { - const isStatusRow = showStatusRow && virtualRow.index === 0 + const isLoadMoreStatusRow = + showLoadMoreStatusRow && virtualRow.index === 0 - if (isStatusRow) { + if (isLoadMoreStatusRow) { return ( + ) + } + + const isLiveStatusRow = virtualRow.index === liveStatusRowIndex + + if (isLiveStatusRow) { + return ( + + ) + } + + const logIndex = virtualRow.index - logsStartIndex return ( ) } + +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/use-sandbox-logs.ts b/src/features/dashboard/sandbox/logs/use-sandbox-logs.ts index c2f576dee..3c6147208 100644 --- a/src/features/dashboard/sandbox/logs/use-sandbox-logs.ts +++ b/src/features/dashboard/sandbox/logs/use-sandbox-logs.ts @@ -77,6 +77,7 @@ export function useSandboxLogs({ isInitialized, hasNextPage: hasMoreBackwards, isFetchingNextPage: isLoadingBackwards, + isRefetchingForwardLogs: isLoadingForwards || isPolling, isFetching: isLoadingBackwards || isLoadingForwards || isPolling, fetchNextPage, } 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 } From 3ea7053d7d960df0fc397d025e25460de3d256e0 Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Thu, 12 Feb 2026 15:32:24 -0800 Subject: [PATCH 11/54] improve: logs readability --- .gitignore | 12 --- src/configs/urls.ts | 2 +- src/features/dashboard/sandbox/layout.tsx | 16 ++-- src/features/dashboard/sandbox/logs/logs.tsx | 85 ++++++++------------ src/ui/primitives/loader.tsx | 2 +- 5 files changed, 42 insertions(+), 75 deletions(-) diff --git a/.gitignore b/.gitignore index efa128ee0..b6b85df6d 100644 --- a/.gitignore +++ b/.gitignore @@ -40,18 +40,6 @@ next-env.d.ts # AI agents and related files CLAUDE.md .agent -.cursor/ -.claude/ -.agents/ -.vscode/ -.idea/ -.agents/ -.ai/ -.codex/ -.copilot/ -.github/copilot/ -.anthropic/ -.openai/ # tooling diff --git a/src/configs/urls.ts b/src/configs/urls.ts index aac15712c..76d9df9ed 100644 --- a/src/configs/urls.ts +++ b/src/configs/urls.ts @@ -29,7 +29,7 @@ export const PROTECTED_URLS = { `/dashboard/${teamIdOrSlug}/sandboxes?tab=list`, SANDBOX: (teamIdOrSlug: string, sandboxId: string) => - `/dashboard/${teamIdOrSlug}/sandboxes/${sandboxId}`, + `/dashboard/${teamIdOrSlug}/sandboxes/${sandboxId}/logs`, SANDBOX_FILESYSTEM: (teamIdOrSlug: string, sandboxId: string) => `/dashboard/${teamIdOrSlug}/sandboxes/${sandboxId}/filesystem`, SANDBOX_LOGS: (teamIdOrSlug: string, sandboxId: string) => diff --git a/src/features/dashboard/sandbox/layout.tsx b/src/features/dashboard/sandbox/layout.tsx index 252b9bf09..6e2b69801 100644 --- a/src/features/dashboard/sandbox/layout.tsx +++ b/src/features/dashboard/sandbox/layout.tsx @@ -44,6 +44,14 @@ export default function SandboxLayout({ layoutKey="tabs-indicator-sandbox" headerAccessory={tabsHeaderAccessory} > + } + > + {children} + )} - } - > - {children} -
) diff --git a/src/features/dashboard/sandbox/logs/logs.tsx b/src/features/dashboard/sandbox/logs/logs.tsx index a43dda2ad..30bf5c829 100644 --- a/src/features/dashboard/sandbox/logs/logs.tsx +++ b/src/features/dashboard/sandbox/logs/logs.tsx @@ -28,8 +28,9 @@ 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 COLUMN_WIDTHS_PX = { timestamp: 148 + 16, level: 48 + 16 } as const const ROW_HEIGHT_PX = 26 +const LIVE_STATUS_ROW_HEIGHT_PX = ROW_HEIGHT_PX + 16 const VIRTUAL_OVERSCAN = 16 const SCROLL_LOAD_THRESHOLD_PX = 200 @@ -228,13 +229,13 @@ function VirtualizedLogsBody({ 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 liveStatusRowIndex = logsStartIndex + logs.length + const virtualRowsCount = logs.length + (showLoadMoreStatusRow ? 1 : 0) + 1 const virtualizer = useVirtualizer({ count: virtualRowsCount, - estimateSize: () => ROW_HEIGHT_PX, + estimateSize: (index) => + index === liveStatusRowIndex ? LIVE_STATUS_ROW_HEIGHT_PX : ROW_HEIGHT_PX, getScrollElement: () => scrollContainerElement, overscan: VIRTUAL_OVERSCAN, paddingStart: 8, @@ -243,7 +244,7 @@ function VirtualizedLogsBody({ const scrollToLatestLog = useCallback(() => { if (logs.length === 0) return virtualizer.scrollToIndex(liveStatusRowIndex, { align: 'end' }) - }, [logs.length, liveStatusRowIndex, logsStartIndex, virtualizer]) + }, [logs.length, liveStatusRowIndex, virtualizer]) useAutoScrollToBottom({ scrollContainerElement, @@ -290,18 +291,6 @@ function VirtualizedLogsBody({ ) } - const isSpacerRow = virtualRow.index === spacerRowIndex - - if (isSpacerRow) { - return ( - - ) - } - const isLiveStatusRow = virtualRow.index === liveStatusRowIndex if (isLiveStatusRow) { @@ -321,6 +310,7 @@ function VirtualizedLogsBody({ @@ -453,15 +443,26 @@ function useAutoScrollToBottom({ interface LogRowProps { log: SandboxLogDTO + logIndex: number virtualRow: VirtualItem virtualizer: Virtualizer } -function LogRow({ log, virtualRow, virtualizer }: LogRowProps) { +function LogRow({ log, logIndex, virtualRow, virtualizer }: LogRowProps) { + const logLevelBorderClass: Record = { + debug: '', + info: 'border-accent-info-highlight!', + warn: 'border-accent-warning-highlight!', + error: 'border-accent-error-highlight!', + } + return ( virtualizer.measureElement(node)} + className={`${logIndex % 2 === 1 ? 'bg-bg-1 ' : ''}border-l ${ + logLevelBorderClass[log.level] + }`} style={{ display: 'flex', position: 'absolute', @@ -472,7 +473,7 @@ function LogRow({ log, virtualRow, virtualizer }: LogRowProps) { }} > virtualizer.measureElement(node)} - className="animate-pulse" style={{ display: 'flex', position: 'absolute', @@ -528,19 +528,22 @@ function StatusRow({ > - + {isFetchingNextPage ? ( - - Loading more logs - - + <> + [ + loading + ] + retrieving older logs + + ) : ( 'Scroll to load more' )} @@ -556,30 +559,6 @@ interface LiveStatusRowProps { 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, @@ -595,12 +574,12 @@ function LiveStatusRow({ left: 0, transform: `translateY(${virtualRow.start}px)`, minWidth: '100%', - height: ROW_HEIGHT_PX, + height: LIVE_STATUS_ROW_HEIGHT_PX, }} > ` case 'dots': return css` content: '.'; - animation: dotsAnimation 0.9s step-end infinite; + animation: dotsAnimation 0.6s step-end infinite; ` default: return css` From d53c53e63b40569ba7338025bb09d1059700e893 Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Fri, 13 Feb 2026 10:25:31 -0800 Subject: [PATCH 12/54] improve: loading / error state handling + config + state management --- src/configs/logs.ts | 1 + src/features/dashboard/sandbox/logs/logs.tsx | 63 ++++++++++++++++--- .../sandbox/logs/sandbox-logs-store.ts | 57 ++++++++--------- .../sandbox/logs/use-sandbox-logs.ts | 4 ++ .../dashboard/templates/builds/constants.ts | 5 +- src/server/api/models/builds.models.ts | 2 +- src/server/api/routers/sandbox.ts | 2 +- 7 files changed, 92 insertions(+), 42 deletions(-) create mode 100644 src/configs/logs.ts diff --git a/src/configs/logs.ts b/src/configs/logs.ts new file mode 100644 index 000000000..6aaf97828 --- /dev/null +++ b/src/configs/logs.ts @@ -0,0 +1 @@ +export const LOG_RETENTION_MS = 7 * 24 * 60 * 60 * 1000 // 7 days diff --git a/src/features/dashboard/sandbox/logs/logs.tsx b/src/features/dashboard/sandbox/logs/logs.tsx index 30bf5c829..be011bb90 100644 --- a/src/features/dashboard/sandbox/logs/logs.tsx +++ b/src/features/dashboard/sandbox/logs/logs.tsx @@ -1,5 +1,6 @@ 'use client' +import { LOG_RETENTION_MS } from '@/configs/logs' import type { SandboxLogDTO } from '@/server/api/models/sandboxes.models' import { ArrowDownIcon, ListIcon } from '@/ui/primitives/icons' import { Loader } from '@/ui/primitives/loader' @@ -33,12 +34,23 @@ const ROW_HEIGHT_PX = 26 const LIVE_STATUS_ROW_HEIGHT_PX = ROW_HEIGHT_PX + 16 const VIRTUAL_OVERSCAN = 16 const SCROLL_LOAD_THRESHOLD_PX = 200 +const LOG_RETENTION_DAYS = LOG_RETENTION_MS / 24 / 60 / 60 / 1000 interface LogsProps { teamIdOrSlug: string sandboxId: string } +function checkIfSandboxStillHasLogs(startedAtIso: string) { + const startedAtUnix = new Date(startedAtIso).getTime() + + if (Number.isNaN(startedAtUnix)) { + return true + } + + return Date.now() - startedAtUnix < LOG_RETENTION_MS +} + export default function SandboxLogs({ teamIdOrSlug, sandboxId }: LogsProps) { 'use no memo' @@ -57,11 +69,14 @@ export default function SandboxLogs({ teamIdOrSlug, sandboxId }: LogsProps) { ) } + const hasRetainedLogs = checkIfSandboxStillHasLogs(sandboxInfo.startedAt) + return ( ) } @@ -70,15 +85,23 @@ interface LogsContentProps { teamIdOrSlug: string sandboxId: string isRunning: boolean + hasRetainedLogs: boolean } -function LogsContent({ teamIdOrSlug, sandboxId, isRunning }: LogsContentProps) { +function LogsContent({ + teamIdOrSlug, + sandboxId, + isRunning, + hasRetainedLogs, +}: LogsContentProps) { const [scrollContainerElement, setScrollContainerElement] = useState(null) const { logs, isInitialized, + hasCompletedInitialLoad, + initialLoadError, hasNextPage, isFetchingNextPage, isFetching, @@ -90,8 +113,8 @@ function LogsContent({ teamIdOrSlug, sandboxId, isRunning }: LogsContentProps) { }) const hasLogs = logs.length > 0 - const showLoader = isFetching && !hasLogs - const showEmpty = !isFetching && !hasLogs + const showLoader = (!hasCompletedInitialLoad || isFetching) && !hasLogs + const showEmpty = hasCompletedInitialLoad && !isFetching && !hasLogs const handleLoadMore = useCallback(() => { if (hasNextPage && !isFetchingNextPage) { @@ -109,7 +132,12 @@ function LogsContent({ teamIdOrSlug, sandboxId, isRunning }: LogsContentProps) { {showLoader && } - {showEmpty && } + {showEmpty && ( + + )} {hasLogs && scrollContainerElement && ( @@ -182,9 +215,18 @@ function EmptyBody() {

No logs found

-

- Sandbox logs will appear here once available. -

+ {errorMessage ? ( +

{errorMessage}

+ ) : !hasRetainedLogs ? ( +

+ This sandbox has exceeded the {LOG_RETENTION_DAYS} day retention + limit. +

+ ) : ( +

+ Sandbox logs will appear here once available. +

+ )}
@@ -305,11 +347,12 @@ function VirtualizedLogsBody({ } const logIndex = virtualRow.index - logsStartIndex + const log = logs[logIndex]! return ( !existingKeys.has(getLogKey(log))) -} - const initialState: SandboxLogsState = { logs: [], hasMoreBackwards: true, isLoadingBackwards: false, isLoadingForwards: false, backwardsCursor: null, + forwardCursor: null, isInitialized: false, + hasCompletedInitialLoad: false, + initialLoadError: null, _trpcClient: null, _params: null, _initVersion: 0, @@ -79,7 +71,10 @@ export const createSandboxLogsStore = () => state.isLoadingBackwards = false state.isLoadingForwards = false state.backwardsCursor = null + state.forwardCursor = null state.isInitialized = false + state.hasCompletedInitialLoad = false + state.initialLoadError = null }) }, @@ -102,14 +97,17 @@ export const createSandboxLogsStore = () => s._trpcClient = trpcClient s._params = params s.isLoadingBackwards = true + s.initialLoadError = null s._initVersion = requestVersion }) try { + const initCursor = Date.now() + const result = await trpcClient.sandbox.logsBackwards.query({ teamIdOrSlug: params.teamIdOrSlug, sandboxId: params.sandboxId, - cursor: Date.now(), + cursor: initCursor, }) // ignore stale response if a newer init was called @@ -121,10 +119,13 @@ export const createSandboxLogsStore = () => s.logs = result.logs s.hasMoreBackwards = result.nextCursor !== null s.backwardsCursor = result.nextCursor + s.forwardCursor = initCursor s.isLoadingBackwards = false s.isInitialized = true + s.hasCompletedInitialLoad = true + s.initialLoadError = null }) - } catch { + } catch (error) { // ignore errors from stale requests if (get()._initVersion !== requestVersion) { return @@ -132,6 +133,11 @@ export const createSandboxLogsStore = () => set((s) => { s.isLoadingBackwards = false + s.hasCompletedInitialLoad = true + s.initialLoadError = + error instanceof Error + ? error.message + : 'Failed to load sandbox logs.' }) } }, @@ -170,8 +176,7 @@ export const createSandboxLogsStore = () => } set((s) => { - const uniqueNewLogs = deduplicateLogs(s.logs, result.logs) - s.logs = [...uniqueNewLogs, ...s.logs] + s.logs = [...result.logs, ...s.logs] s.hasMoreBackwards = result.nextCursor !== null s.backwardsCursor = result.nextCursor s.isLoadingBackwards = false @@ -201,10 +206,7 @@ export const createSandboxLogsStore = () => }) try { - const newestTimestamp = state.getNewestTimestamp() - const cursor = newestTimestamp - ? newestTimestamp + FORWARD_CURSOR_PADDING_MS - : Date.now() + const cursor = state.forwardCursor ?? Date.now() const result = await state._trpcClient.sandbox.logsForward.query({ teamIdOrSlug: state._params.teamIdOrSlug, @@ -217,18 +219,17 @@ export const createSandboxLogsStore = () => return { logsCount: 0 } } - let uniqueLogsCount = 0 + const logsCount = result.logs.length set((s) => { - const uniqueNewLogs = deduplicateLogs(s.logs, result.logs) - uniqueLogsCount = uniqueNewLogs.length - if (uniqueLogsCount > 0) { - s.logs = [...s.logs, ...uniqueNewLogs] + if (logsCount > 0) { + s.logs = [...s.logs, ...result.logs] } + s.forwardCursor = result.nextCursor ?? cursor s.isLoadingForwards = false }) - return { logsCount: uniqueLogsCount } + return { logsCount } } catch { if (get()._initVersion !== requestVersion) { return { logsCount: 0 } diff --git a/src/features/dashboard/sandbox/logs/use-sandbox-logs.ts b/src/features/dashboard/sandbox/logs/use-sandbox-logs.ts index 3c6147208..b9c2a82f1 100644 --- a/src/features/dashboard/sandbox/logs/use-sandbox-logs.ts +++ b/src/features/dashboard/sandbox/logs/use-sandbox-logs.ts @@ -33,6 +33,8 @@ export function useSandboxLogs({ const logs = useStore(store, (s) => s.logs) const isInitialized = useStore(store, (s) => s.isInitialized) + const hasCompletedInitialLoad = useStore(store, (s) => s.hasCompletedInitialLoad) + const initialLoadError = useStore(store, (s) => s.initialLoadError) const hasMoreBackwards = useStore(store, (s) => s.hasMoreBackwards) const isLoadingBackwards = useStore(store, (s) => s.isLoadingBackwards) const isLoadingForwards = useStore(store, (s) => s.isLoadingForwards) @@ -75,6 +77,8 @@ export function useSandboxLogs({ return { logs, isInitialized, + hasCompletedInitialLoad, + initialLoadError, hasNextPage: hasMoreBackwards, isFetchingNextPage: isLoadingBackwards, isRefetchingForwardLogs: isLoadingForwards || isPolling, diff --git a/src/features/dashboard/templates/builds/constants.ts b/src/features/dashboard/templates/builds/constants.ts index 5759af715..6387be65c 100644 --- a/src/features/dashboard/templates/builds/constants.ts +++ b/src/features/dashboard/templates/builds/constants.ts @@ -1,6 +1,7 @@ -import { BuildStatusDTO } from '@/server/api/models/builds.models' +import { LOG_RETENTION_MS } from '@/configs/logs' +import type { BuildStatusDTO } from '@/server/api/models/builds.models' -export const LOG_RETENTION_MS = 7 * 24 * 60 * 60 * 1000 // 7 days +export { LOG_RETENTION_MS } export const INITIAL_BUILD_STATUSES: BuildStatusDTO[] = [ 'building', diff --git a/src/server/api/models/builds.models.ts b/src/server/api/models/builds.models.ts index 48023c77f..f021e71e7 100644 --- a/src/server/api/models/builds.models.ts +++ b/src/server/api/models/builds.models.ts @@ -1,4 +1,4 @@ -import { LOG_RETENTION_MS } from '@/features/dashboard/templates/builds/constants' +import { LOG_RETENTION_MS } from '@/configs/logs' import type { components } from '@/types/infra-api.types' import z from 'zod' diff --git a/src/server/api/routers/sandbox.ts b/src/server/api/routers/sandbox.ts index 81e7ed520..02cda4cea 100644 --- a/src/server/api/routers/sandbox.ts +++ b/src/server/api/routers/sandbox.ts @@ -80,7 +80,7 @@ export const sandboxRouter = createTRPCRouter({ .sort((a, b) => a.timestampUnix - b.timestampUnix) const newestLog = logs[logs.length - 1] - const nextCursor = newestLog?.timestampUnix ?? null + const nextCursor = newestLog ? newestLog.timestampUnix + 1 : cursor const result: SandboxLogsDTO = { logs, From 97057b532d43e9125c160ab9dc47a40a09ec0422 Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Fri, 13 Feb 2026 10:50:43 -0800 Subject: [PATCH 13/54] fix: address cursor comments --- src/features/dashboard/sandbox/logs/logs.tsx | 25 +++++++++-- src/ui/dashboard-tabs.tsx | 47 +++++++------------- 2 files changed, 38 insertions(+), 34 deletions(-) diff --git a/src/features/dashboard/sandbox/logs/logs.tsx b/src/features/dashboard/sandbox/logs/logs.tsx index be011bb90..e5f2265c3 100644 --- a/src/features/dashboard/sandbox/logs/logs.tsx +++ b/src/features/dashboard/sandbox/logs/logs.tsx @@ -291,6 +291,7 @@ function VirtualizedLogsBody({ useAutoScrollToBottom({ scrollContainerElement, logsCount: logs.length, + isFetchingNextPage, isInitialized, isRunning, scrollToLatestLog, @@ -424,6 +425,7 @@ function useMaintainScrollOnPrepend({ interface UseAutoScrollToBottomParams { scrollContainerElement: HTMLDivElement logsCount: number + isFetchingNextPage: boolean isInitialized: boolean isRunning: boolean scrollToLatestLog: () => void @@ -432,6 +434,7 @@ interface UseAutoScrollToBottomParams { function useAutoScrollToBottom({ scrollContainerElement, logsCount, + isFetchingNextPage, isInitialized, isRunning, scrollToLatestLog, @@ -439,6 +442,7 @@ function useAutoScrollToBottom({ const isAutoScrollEnabledRef = useRef(true) const prevLogsCountRef = useRef(0) const prevIsRunningRef = useRef(isRunning) + const wasFetchingNextPageRef = useRef(isFetchingNextPage) const hasInitialScrolled = useRef(false) useEffect(() => { @@ -472,16 +476,29 @@ function useAutoScrollToBottom({ }, [isRunning]) useEffect(() => { - if (!hasInitialScrolled.current) return + if (!hasInitialScrolled.current) { + wasFetchingNextPageRef.current = isFetchingNextPage + return + } - const newLogsCount = logsCount - prevLogsCountRef.current + const previousLogsCount = prevLogsCountRef.current + const newLogsCount = logsCount - previousLogsCount + const justFinishedBackwardFetch = + wasFetchingNextPageRef.current && !isFetchingNextPage + + if (justFinishedBackwardFetch && newLogsCount > 0) { + prevLogsCountRef.current = logsCount + wasFetchingNextPageRef.current = isFetchingNextPage + return + } if (newLogsCount > 0 && isAutoScrollEnabledRef.current) { scrollContainerElement.scrollTop += newLogsCount * ROW_HEIGHT_PX } prevLogsCountRef.current = logsCount - }, [logsCount, scrollContainerElement]) + wasFetchingNextPageRef.current = isFetchingNextPage + }, [isFetchingNextPage, logsCount, scrollContainerElement]) } interface LogRowProps { @@ -643,7 +660,7 @@ function LiveStatusRow({ ] {isRunning - ? 'No more logs to show. Wating for new entries...' + ? 'No more logs to show. Waiting for new entries...' : 'No more logs to show'} diff --git a/src/ui/dashboard-tabs.tsx b/src/ui/dashboard-tabs.tsx index 3a8c2df56..25e7594da 100644 --- a/src/ui/dashboard-tabs.tsx +++ b/src/ui/dashboard-tabs.tsx @@ -62,6 +62,21 @@ function DashboardTabsComponent({ [tabs, hrefForId] ) + const tabTriggers = tabsWithHrefs.map((tab) => ( + + + {tab.icon} + {tab.label} + + + )) + return ( - {tabsWithHrefs.map((tab) => ( - - - {tab.icon} - {tab.label} - - - ))} + {tabTriggers} ) : ( - - {tabsWithHrefs.map((tab) => ( - - - {tab.icon} - {tab.label} - - - ))} - + {tabTriggers} )} {children} From c1aee930423f6e177f1439004f8bc70e1dd944fc Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Fri, 13 Feb 2026 12:19:41 -0800 Subject: [PATCH 14/54] fix: log state management --- .../sandbox/logs/sandbox-logs-store.ts | 131 ++++++++++++++++-- src/server/api/routers/sandbox.ts | 2 +- 2 files changed, 124 insertions(+), 9 deletions(-) diff --git a/src/features/dashboard/sandbox/logs/sandbox-logs-store.ts b/src/features/dashboard/sandbox/logs/sandbox-logs-store.ts index 1cc0a8406..e5cc9e34e 100644 --- a/src/features/dashboard/sandbox/logs/sandbox-logs-store.ts +++ b/src/features/dashboard/sandbox/logs/sandbox-logs-store.ts @@ -18,7 +18,9 @@ interface SandboxLogsState { isLoadingBackwards: boolean isLoadingForwards: boolean backwardsCursor: number | null + backwardsSeenAtCursor: number forwardCursor: number | null + forwardSeenAtCursor: number isInitialized: boolean hasCompletedInitialLoad: boolean initialLoadError: string | null @@ -44,13 +46,85 @@ export type SandboxLogsStoreData = SandboxLogsState & SandboxLogsMutations & SandboxLogsComputed +function countLeadingAtTimestamp(logs: SandboxLogDTO[], timestamp: number) { + let count = 0 + + while (count < logs.length && logs[count]!.timestampUnix === timestamp) { + count += 1 + } + + return count +} + +function countTrailingAtTimestamp(logs: SandboxLogDTO[], timestamp: number) { + let count = 0 + let index = logs.length - 1 + + while (index >= 0 && logs[index]!.timestampUnix === timestamp) { + count += 1 + index -= 1 + } + + return count +} + +function dropLeadingAtTimestamp( + logs: SandboxLogDTO[], + timestamp: number, + dropCount: number +) { + if (dropCount <= 0) { + return logs + } + + let index = 0 + let remainingToDrop = dropCount + + while ( + index < logs.length && + remainingToDrop > 0 && + logs[index]!.timestampUnix === timestamp + ) { + index += 1 + remainingToDrop -= 1 + } + + return logs.slice(index) +} + +function dropTrailingAtTimestamp( + logs: SandboxLogDTO[], + timestamp: number, + dropCount: number +) { + if (dropCount <= 0) { + return logs + } + + let end = logs.length + let remainingToDrop = dropCount + + while ( + end > 0 && + remainingToDrop > 0 && + logs[end - 1]!.timestampUnix === timestamp + ) { + end -= 1 + remainingToDrop -= 1 + } + + return logs.slice(0, end) +} + const initialState: SandboxLogsState = { logs: [], hasMoreBackwards: true, isLoadingBackwards: false, isLoadingForwards: false, backwardsCursor: null, + backwardsSeenAtCursor: 0, forwardCursor: null, + forwardSeenAtCursor: 0, isInitialized: false, hasCompletedInitialLoad: false, initialLoadError: null, @@ -71,7 +145,9 @@ export const createSandboxLogsStore = () => state.isLoadingBackwards = false state.isLoadingForwards = false state.backwardsCursor = null + state.backwardsSeenAtCursor = 0 state.forwardCursor = null + state.forwardSeenAtCursor = 0 state.isInitialized = false state.hasCompletedInitialLoad = false state.initialLoadError = null @@ -116,10 +192,17 @@ export const createSandboxLogsStore = () => } set((s) => { + const backwardsCursor = result.nextCursor + s.logs = result.logs - s.hasMoreBackwards = result.nextCursor !== null - s.backwardsCursor = result.nextCursor + s.hasMoreBackwards = backwardsCursor !== null + s.backwardsCursor = backwardsCursor + s.backwardsSeenAtCursor = + backwardsCursor === null + ? 0 + : countLeadingAtTimestamp(result.logs, backwardsCursor) s.forwardCursor = initCursor + s.forwardSeenAtCursor = 0 s.isLoadingBackwards = false s.isInitialized = true s.hasCompletedInitialLoad = true @@ -176,9 +259,21 @@ export const createSandboxLogsStore = () => } set((s) => { - s.logs = [...result.logs, ...s.logs] - s.hasMoreBackwards = result.nextCursor !== null - s.backwardsCursor = result.nextCursor + const newLogs = dropTrailingAtTimestamp( + result.logs, + cursor, + state.backwardsSeenAtCursor + ) + const nextLogs = newLogs.length > 0 ? [...newLogs, ...s.logs] : s.logs + const backwardsCursor = result.nextCursor + + s.logs = nextLogs + s.hasMoreBackwards = backwardsCursor !== null + s.backwardsCursor = backwardsCursor + s.backwardsSeenAtCursor = + backwardsCursor === null + ? 0 + : countLeadingAtTimestamp(nextLogs, backwardsCursor) s.isLoadingBackwards = false }) } catch { @@ -207,6 +302,7 @@ export const createSandboxLogsStore = () => try { const cursor = state.forwardCursor ?? Date.now() + const seenAtCursor = state.forwardSeenAtCursor const result = await state._trpcClient.sandbox.logsForward.query({ teamIdOrSlug: state._params.teamIdOrSlug, @@ -219,13 +315,32 @@ export const createSandboxLogsStore = () => return { logsCount: 0 } } - const logsCount = result.logs.length + const newLogs = dropLeadingAtTimestamp( + result.logs, + cursor, + seenAtCursor + ) + const logsCount = newLogs.length set((s) => { if (logsCount > 0) { - s.logs = [...s.logs, ...result.logs] + s.logs = [...s.logs, ...newLogs] + + const newestTimestamp = newLogs[logsCount - 1]!.timestampUnix + const trailingAtNewest = countTrailingAtTimestamp( + newLogs, + newestTimestamp + ) + + s.forwardCursor = newestTimestamp + s.forwardSeenAtCursor = + newestTimestamp === cursor + ? seenAtCursor + trailingAtNewest + : trailingAtNewest + } else { + s.forwardCursor = cursor + s.forwardSeenAtCursor = seenAtCursor } - s.forwardCursor = result.nextCursor ?? cursor s.isLoadingForwards = false }) diff --git a/src/server/api/routers/sandbox.ts b/src/server/api/routers/sandbox.ts index 02cda4cea..3ebdb6572 100644 --- a/src/server/api/routers/sandbox.ts +++ b/src/server/api/routers/sandbox.ts @@ -80,7 +80,7 @@ export const sandboxRouter = createTRPCRouter({ .sort((a, b) => a.timestampUnix - b.timestampUnix) const newestLog = logs[logs.length - 1] - const nextCursor = newestLog ? newestLog.timestampUnix + 1 : cursor + const nextCursor = newestLog?.timestampUnix ?? cursor const result: SandboxLogsDTO = { logs, From 967f78a4b021742f01b3352e33ea4e0ae0b7e301 Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Fri, 13 Feb 2026 12:37:21 -0800 Subject: [PATCH 15/54] cleanup: sandbox logs --- .../dashboard/sandbox/logs/logs-cells.tsx | 32 ++++--- src/features/dashboard/sandbox/logs/logs.tsx | 89 ++++++++----------- .../sandbox/logs/sandbox-logs-store.ts | 25 ++---- .../sandbox/logs/use-sandbox-logs.ts | 1 - src/features/dashboard/sandbox/logs/view.tsx | 8 +- 5 files changed, 63 insertions(+), 92 deletions(-) diff --git a/src/features/dashboard/sandbox/logs/logs-cells.tsx b/src/features/dashboard/sandbox/logs/logs-cells.tsx index 6f01d723f..ded42f398 100644 --- a/src/features/dashboard/sandbox/logs/logs-cells.tsx +++ b/src/features/dashboard/sandbox/logs/logs-cells.tsx @@ -1,12 +1,12 @@ -import { SandboxLogDTO } from '@/server/api/models/sandboxes.models' +import type { SandboxLogDTO } from '@/server/api/models/sandboxes.models' import CopyButtonInline from '@/ui/copy-button-inline' -import { Badge, BadgeProps } from '@/ui/primitives/badge' +import { Badge, type BadgeProps } from '@/ui/primitives/badge' interface LogLevelProps { level: SandboxLogDTO['level'] } -const mapLogLevelToBadgeProps: Record = { +const LOG_LEVEL_BADGE_PROPS: Record = { debug: { variant: 'default', }, @@ -21,9 +21,21 @@ const mapLogLevelToBadgeProps: Record = { }, } +const LOCAL_DATE_FORMATTER = new Intl.DateTimeFormat(undefined, { + month: 'short', + day: '2-digit', +}) + +const LOCAL_TIME_FORMATTER = new Intl.DateTimeFormat(undefined, { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, +}) + export const LogLevel = ({ level }: LogLevelProps) => { return ( - + {level} ) @@ -39,16 +51,8 @@ export const Timestamp = ({ timestampUnix }: TimestampProps) => { 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) + const localDatePart = LOCAL_DATE_FORMATTER.format(date) + const localTimePart = LOCAL_TIME_FORMATTER.format(date) return ( = { + debug: '', + info: 'border-accent-info-highlight!', + warn: 'border-accent-warning-highlight!', + error: 'border-accent-error-highlight!', +} +const STATUS_ROW_CELL_STYLE: CSSProperties = { + display: 'flex', + alignItems: 'center', + justifyContent: 'start', +} + +function getVirtualRowStyle(virtualRow: VirtualItem, height: number): CSSProperties { + return { + display: 'flex', + position: 'absolute', + left: 0, + transform: `translateY(${virtualRow.start}px)`, + minWidth: '100%', + height, + } +} interface LogsProps { teamIdOrSlug: string @@ -253,7 +276,6 @@ function VirtualizedLogsBody({ isInitialized, isRunning, }: VirtualizedLogsBodyProps) { - const tbodyRef = useRef(null) const maxWidthRef = useRef(0) useScrollLoadMore({ @@ -309,7 +331,6 @@ function VirtualizedLogsBody({ return ( @@ -388,7 +409,9 @@ function useScrollLoadMore({ } } - scrollContainerElement.addEventListener('scroll', handleScroll) + scrollContainerElement.addEventListener('scroll', handleScroll, { + passive: true, + }) return () => scrollContainerElement.removeEventListener('scroll', handleScroll) }, [scrollContainerElement, hasNextPage, isFetchingNextPage, onLoadMore]) @@ -454,7 +477,9 @@ function useAutoScrollToBottom({ isAutoScrollEnabledRef.current = distanceFromBottom < ROW_HEIGHT_PX * 2 } - scrollContainerElement.addEventListener('scroll', handleScroll) + scrollContainerElement.addEventListener('scroll', handleScroll, { + passive: true, + }) return () => scrollContainerElement.removeEventListener('scroll', handleScroll) }, [scrollContainerElement]) @@ -503,34 +528,20 @@ function useAutoScrollToBottom({ interface LogRowProps { log: SandboxLogDTO - logIndex: number + isZebraRow: boolean virtualRow: VirtualItem virtualizer: Virtualizer } -function LogRow({ log, logIndex, virtualRow, virtualizer }: LogRowProps) { - const logLevelBorderClass: Record = { - debug: '', - info: 'border-accent-info-highlight!', - warn: 'border-accent-warning-highlight!', - error: 'border-accent-error-highlight!', - } - +function LogRow({ log, isZebraRow, virtualRow, virtualizer }: LogRowProps) { return ( virtualizer.measureElement(node)} - className={`${logIndex % 2 === 1 ? 'bg-bg-1 ' : ''}border-l ${ - logLevelBorderClass[log.level] + className={`${isZebraRow ? 'bg-bg-1/80 ' : ''}border-l ${ + LOG_LEVEL_BORDER_CLASS[log.level] }`} - style={{ - display: 'flex', - position: 'absolute', - left: 0, - transform: `translateY(${virtualRow.start}px)`, - minWidth: '100%', - height: ROW_HEIGHT_PX, - }} + style={getVirtualRowStyle(virtualRow, ROW_HEIGHT_PX)} > virtualizer.measureElement(node)} - style={{ - display: 'flex', - position: 'absolute', - left: 0, - transform: `translateY(${virtualRow.start}px)`, - minWidth: '100%', - height: ROW_HEIGHT_PX, - }} + style={getVirtualRowStyle(virtualRow, ROW_HEIGHT_PX)} > {isFetchingNextPage ? ( @@ -628,23 +628,12 @@ function LiveStatusRow({ virtualizer.measureElement(node)} - style={{ - display: 'flex', - position: 'absolute', - left: 0, - transform: `translateY(${virtualRow.start}px)`, - minWidth: '100%', - height: LIVE_STATUS_ROW_HEIGHT_PX, - }} + style={getVirtualRowStyle(virtualRow, LIVE_STATUS_ROW_HEIGHT_PX)} > [ diff --git a/src/features/dashboard/sandbox/logs/sandbox-logs-store.ts b/src/features/dashboard/sandbox/logs/sandbox-logs-store.ts index e5cc9e34e..1b82a3429 100644 --- a/src/features/dashboard/sandbox/logs/sandbox-logs-store.ts +++ b/src/features/dashboard/sandbox/logs/sandbox-logs-store.ts @@ -37,14 +37,7 @@ interface SandboxLogsMutations { reset: () => void } -interface SandboxLogsComputed { - getNewestTimestamp: () => number | undefined - getOldestTimestamp: () => number | undefined -} - -export type SandboxLogsStoreData = SandboxLogsState & - SandboxLogsMutations & - SandboxLogsComputed +export type SandboxLogsStoreData = SandboxLogsState & SandboxLogsMutations function countLeadingAtTimestamp(logs: SandboxLogDTO[], timestamp: number) { let count = 0 @@ -202,7 +195,9 @@ export const createSandboxLogsStore = () => ? 0 : countLeadingAtTimestamp(result.logs, backwardsCursor) s.forwardCursor = initCursor - s.forwardSeenAtCursor = 0 + // Initial backward snapshot can include logs exactly at initCursor. + // Track how many were already consumed so first forward poll does not replay them. + s.forwardSeenAtCursor = countTrailingAtTimestamp(result.logs, initCursor) s.isLoadingBackwards = false s.isInitialized = true s.hasCompletedInitialLoad = true @@ -245,7 +240,7 @@ export const createSandboxLogsStore = () => try { const cursor = - state.backwardsCursor ?? state.getOldestTimestamp() ?? Date.now() + state.backwardsCursor ?? state.logs[0]?.timestampUnix ?? Date.now() const result = await state._trpcClient.sandbox.logsBackwards.query({ teamIdOrSlug: state._params.teamIdOrSlug, @@ -357,16 +352,6 @@ export const createSandboxLogsStore = () => return { logsCount: 0 } } }, - - getNewestTimestamp: () => { - const state = get() - return state.logs[state.logs.length - 1]?.timestampUnix - }, - - getOldestTimestamp: () => { - const state = get() - return state.logs[0]?.timestampUnix - }, })) ) diff --git a/src/features/dashboard/sandbox/logs/use-sandbox-logs.ts b/src/features/dashboard/sandbox/logs/use-sandbox-logs.ts index b9c2a82f1..2aea10e33 100644 --- a/src/features/dashboard/sandbox/logs/use-sandbox-logs.ts +++ b/src/features/dashboard/sandbox/logs/use-sandbox-logs.ts @@ -81,7 +81,6 @@ export function useSandboxLogs({ initialLoadError, 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 index eb2e87c5c..d7657d16b 100644 --- a/src/features/dashboard/sandbox/logs/view.tsx +++ b/src/features/dashboard/sandbox/logs/view.tsx @@ -1,6 +1,5 @@ 'use client' -import { cn } from '@/lib/utils' import SandboxLogs from './logs' interface SandboxLogsViewProps { @@ -13,12 +12,7 @@ export default function SandboxLogsView({ sandboxId, }: SandboxLogsViewProps) { return ( -
+
) From 1ab6870fd10fa77b05d310e2d8acbafc593402bc Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Fri, 13 Feb 2026 12:53:34 -0800 Subject: [PATCH 16/54] chore: sync sandbox and build logs styling --- src/features/dashboard/build/logs-cells.tsx | 33 +-- src/features/dashboard/build/logs.tsx | 195 ++++++------------ src/features/dashboard/common/log-cells.tsx | 45 ++++ .../dashboard/common/log-viewer-ui.tsx | 156 ++++++++++++++ .../dashboard/sandbox/logs/logs-cells.tsx | 31 +-- src/features/dashboard/sandbox/logs/logs.tsx | 184 +++++------------ 6 files changed, 327 insertions(+), 317 deletions(-) create mode 100644 src/features/dashboard/common/log-cells.tsx create mode 100644 src/features/dashboard/common/log-viewer-ui.tsx diff --git a/src/features/dashboard/build/logs-cells.tsx b/src/features/dashboard/build/logs-cells.tsx index 7413cea3f..13f2da0a1 100644 --- a/src/features/dashboard/build/logs-cells.tsx +++ b/src/features/dashboard/build/logs-cells.tsx @@ -1,35 +1,12 @@ import { formatDurationCompact } from '@/lib/utils/formatting' -import { BuildLogDTO } from '@/server/api/models/builds.models' +import { LogLevelBadge, LogMessage } from '@/features/dashboard/common/log-cells' +import type { BuildLogDTO } from '@/server/api/models/builds.models' import CopyButtonInline from '@/ui/copy-button-inline' -import { Badge, BadgeProps } from '@/ui/primitives/badge' import { format } from 'date-fns' import { enUS } from 'date-fns/locale/en-US' -interface LogLevelProps { - level: BuildLogDTO['level'] -} - -const mapLogLevelToBadgeProps: Record = { - debug: { - variant: 'default', - }, - info: { - variant: 'info', - }, - warn: { - variant: 'warning', - }, - error: { - variant: 'error', - }, -} - -export const LogLevel = ({ level }: LogLevelProps) => { - return ( - - {level} - - ) +export const LogLevel = ({ level }: { level: BuildLogDTO['level'] }) => { + return } interface TimestampProps { @@ -63,5 +40,5 @@ interface MessageProps { } export const Message = ({ message }: MessageProps) => { - return {message} + return } diff --git a/src/features/dashboard/build/logs.tsx b/src/features/dashboard/build/logs.tsx index 9f52fd8a6..7eeb6f51a 100644 --- a/src/features/dashboard/build/logs.tsx +++ b/src/features/dashboard/build/logs.tsx @@ -1,6 +1,17 @@ 'use client' import { cn } from '@/lib/utils' +import { + LOG_LEVEL_LEFT_BORDER_CLASS, + type LogLevelValue, +} from '@/features/dashboard/common/log-cells' +import { + LogsEmptyBody, + LogsLoaderBody, + LogsTableHeader, + LogStatusCell, + LogVirtualRow, +} from '@/features/dashboard/common/log-viewer-ui' import type { BuildDetailsDTO, BuildLogDTO, @@ -13,26 +24,17 @@ import { DropdownMenuRadioItem, DropdownMenuTrigger, } from '@/ui/primitives/dropdown-menu' -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 { Table, TableBody, TableCell } from '@/ui/primitives/table' import { useVirtualizer, VirtualItem, Virtualizer, } from '@tanstack/react-virtual' import { - RefObject, + type RefObject, useCallback, useEffect, - useReducer, useRef, useState, } from 'react' @@ -78,8 +80,12 @@ export default function Logs({
- - + +
@@ -158,9 +164,13 @@ function LogsContent({
- + - {showLoader && } + {showLoader && } {showEmpty && ( )} @@ -200,72 +210,17 @@ function useFilterRefetchTracking(level: LogLevelFilter | null) { return { isRefetchingFromFilterChange, onFetchComplete } } -function LogsTableHeader() { - return ( - - - - Timestamp - - - Level - - - Message - - - - ) -} - -function LoaderBody() { - return ( - - - -
- -
-
-
-
- ) -} - interface EmptyBodyProps { hasRetainedLogs: boolean } function EmptyBody({ hasRetainedLogs }: EmptyBodyProps) { + const description = hasRetainedLogs + ? undefined + : `This build has exceeded the ${LOG_RETENTION_MS / 24 / 60 / 60 / 1000} day retention limit.` + return ( - - - -
-
- -

No logs found

-
- {!hasRetainedLogs && ( -

- This build has exceeded the{' '} - {LOG_RETENTION_MS / 24 / 60 / 60 / 1000} day retention limit. -

- )} -
-
-
-
+ ) } @@ -348,13 +303,7 @@ function VirtualizedLogsBody({ isInitialized, level, }: VirtualizedLogsBodyProps) { - const tbodyRef = useRef(null) const maxWidthRef = useRef(0) - const [, forceRerender] = useReducer(() => ({}), {}) - - useEffect(() => { - if (scrollContainerRef.current) forceRerender() - }, [scrollContainerRef]) useScrollLoadMore({ scrollContainerRef, @@ -398,7 +347,6 @@ function VirtualizedLogsBody({ return ( scrollContainer.removeEventListener('scroll', handleScroll) }, [scrollContainerRef, hasNextPage, isFetchingNextPage, onLoadMore]) } @@ -532,7 +483,9 @@ function useAutoScrollToBottom({ isAutoScrollEnabledRef.current = distanceFromBottom < ROW_HEIGHT_PX * 2 } - el.addEventListener('scroll', handleScroll) + el.addEventListener('scroll', handleScroll, { + passive: true, + }) return () => el.removeEventListener('scroll', handleScroll) }, [scrollContainerRef]) @@ -571,29 +524,32 @@ function useAutoScrollToBottom({ interface LogRowProps { log: BuildLogDTO + isZebraRow: boolean virtualRow: VirtualItem virtualizer: Virtualizer startedAt: number } -function LogRow({ log, virtualRow, virtualizer, startedAt }: LogRowProps) { +function LogRow({ + log, + isZebraRow, + virtualRow, + virtualizer, + startedAt, +}: LogRowProps) { const millisAfterStart = log.timestampUnix - startedAt return ( - virtualizer.measureElement(node)} - style={{ - display: 'flex', - position: 'absolute', - left: 0, - transform: `translateY(${virtualRow.start}px)`, - minWidth: '100%', - height: ROW_HEIGHT_PX, - }} + - + ) } @@ -637,39 +593,22 @@ function StatusRow({ 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 - - + <> + [ + loading + ] + retrieving older logs + + ) : ( 'Scroll to load more' )} - - + + ) } diff --git a/src/features/dashboard/common/log-cells.tsx b/src/features/dashboard/common/log-cells.tsx new file mode 100644 index 000000000..a896af4ae --- /dev/null +++ b/src/features/dashboard/common/log-cells.tsx @@ -0,0 +1,45 @@ +import { Badge, type BadgeProps } from '@/ui/primitives/badge' + +export type LogLevelValue = 'debug' | 'info' | 'warn' | 'error' + +const LOG_LEVEL_BADGE_PROPS: Record = { + debug: { + variant: 'default', + }, + info: { + variant: 'info', + }, + warn: { + variant: 'warning', + }, + error: { + variant: 'error', + }, +} + +export const LOG_LEVEL_LEFT_BORDER_CLASS: Record = { + debug: '', + info: 'border-accent-info-highlight!', + warn: 'border-accent-warning-highlight!', + error: 'border-accent-error-highlight!', +} + +interface LogLevelBadgeProps { + level: LogLevelValue +} + +export function LogLevelBadge({ level }: LogLevelBadgeProps) { + return ( + + {level} + + ) +} + +interface LogMessageProps { + message: string +} + +export function LogMessage({ message }: LogMessageProps) { + return {message} +} diff --git a/src/features/dashboard/common/log-viewer-ui.tsx b/src/features/dashboard/common/log-viewer-ui.tsx new file mode 100644 index 000000000..966975a19 --- /dev/null +++ b/src/features/dashboard/common/log-viewer-ui.tsx @@ -0,0 +1,156 @@ +import { cn } from '@/lib/utils' +import { ArrowDownIcon, ListIcon } from '@/ui/primitives/icons' +import { Loader } from '@/ui/primitives/loader' +import { + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/ui/primitives/table' +import type { VirtualItem, Virtualizer } from '@tanstack/react-virtual' +import type { CSSProperties, ReactNode } from 'react' + +interface LogsTableHeaderProps { + timestampWidth: number + levelWidth: number + timestampSortDirection?: 'asc' | 'desc' +} + +export function LogsTableHeader({ + timestampWidth, + levelWidth, + timestampSortDirection = 'desc', +}: LogsTableHeaderProps) { + return ( + + + + Timestamp{' '} + + + + Level + + + Message + + + + ) +} + +export function LogsLoaderBody() { + return ( + + + +
+ +
+
+
+
+ ) +} + +interface LogsEmptyBodyProps { + description?: ReactNode +} + +export function LogsEmptyBody({ description }: LogsEmptyBodyProps) { + return ( + + + +
+
+ +

No logs found

+
+ {description ?

{description}

: null} +
+
+
+
+ ) +} + +export function getLogVirtualRowStyle( + virtualRow: VirtualItem, + height: number +): CSSProperties { + return { + display: 'flex', + position: 'absolute', + left: 0, + transform: `translateY(${virtualRow.start}px)`, + minWidth: '100%', + height, + } +} + +interface LogVirtualRowProps { + virtualRow: VirtualItem + virtualizer: Virtualizer + height: number + className?: string + children: ReactNode +} + +export function LogVirtualRow({ + virtualRow, + virtualizer, + height, + className, + children, +}: LogVirtualRowProps) { + return ( + virtualizer.measureElement(node)} + className={className} + style={getLogVirtualRowStyle(virtualRow, height)} + > + {children} + + ) +} + +const STATUS_ROW_CELL_STYLE: CSSProperties = { + display: 'flex', + alignItems: 'center', + justifyContent: 'start', +} + +interface LogStatusCellProps { + className?: string + children: ReactNode +} + +export function LogStatusCell({ className, children }: LogStatusCellProps) { + return ( + + {children} + + ) +} diff --git a/src/features/dashboard/sandbox/logs/logs-cells.tsx b/src/features/dashboard/sandbox/logs/logs-cells.tsx index ded42f398..56c279618 100644 --- a/src/features/dashboard/sandbox/logs/logs-cells.tsx +++ b/src/features/dashboard/sandbox/logs/logs-cells.tsx @@ -1,25 +1,6 @@ +import { LogLevelBadge, LogMessage } from '@/features/dashboard/common/log-cells' import type { SandboxLogDTO } from '@/server/api/models/sandboxes.models' import CopyButtonInline from '@/ui/copy-button-inline' -import { Badge, type BadgeProps } from '@/ui/primitives/badge' - -interface LogLevelProps { - level: SandboxLogDTO['level'] -} - -const LOG_LEVEL_BADGE_PROPS: Record = { - debug: { - variant: 'default', - }, - info: { - variant: 'info', - }, - warn: { - variant: 'warning', - }, - error: { - variant: 'error', - }, -} const LOCAL_DATE_FORMATTER = new Intl.DateTimeFormat(undefined, { month: 'short', @@ -33,12 +14,8 @@ const LOCAL_TIME_FORMATTER = new Intl.DateTimeFormat(undefined, { hour12: false, }) -export const LogLevel = ({ level }: LogLevelProps) => { - return ( - - {level} - - ) +export const LogLevel = ({ level }: { level: SandboxLogDTO['level'] }) => { + return } interface TimestampProps { @@ -70,5 +47,5 @@ interface MessageProps { } export const Message = ({ message }: MessageProps) => { - return {message} + return } diff --git a/src/features/dashboard/sandbox/logs/logs.tsx b/src/features/dashboard/sandbox/logs/logs.tsx index a9df07fb0..a495e9e94 100644 --- a/src/features/dashboard/sandbox/logs/logs.tsx +++ b/src/features/dashboard/sandbox/logs/logs.tsx @@ -1,24 +1,26 @@ 'use client' import { LOG_RETENTION_MS } from '@/configs/logs' +import { + LOG_LEVEL_LEFT_BORDER_CLASS, + type LogLevelValue, +} from '@/features/dashboard/common/log-cells' +import { + LogsEmptyBody, + LogsLoaderBody, + LogsTableHeader, + LogStatusCell, + LogVirtualRow, +} from '@/features/dashboard/common/log-viewer-ui' 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 { Table, TableBody, TableCell } from '@/ui/primitives/table' import { useVirtualizer, VirtualItem, Virtualizer, } from '@tanstack/react-virtual' import { - type CSSProperties, useCallback, useEffect, useLayoutEffect, @@ -36,28 +38,6 @@ const LIVE_STATUS_ROW_HEIGHT_PX = ROW_HEIGHT_PX + 16 const VIRTUAL_OVERSCAN = 16 const SCROLL_LOAD_THRESHOLD_PX = 200 const LOG_RETENTION_DAYS = LOG_RETENTION_MS / 24 / 60 / 60 / 1000 -const LOG_LEVEL_BORDER_CLASS: Record = { - debug: '', - info: 'border-accent-info-highlight!', - warn: 'border-accent-warning-highlight!', - error: 'border-accent-error-highlight!', -} -const STATUS_ROW_CELL_STYLE: CSSProperties = { - display: 'flex', - alignItems: 'center', - justifyContent: 'start', -} - -function getVirtualRowStyle(virtualRow: VirtualItem, height: number): CSSProperties { - return { - display: 'flex', - position: 'absolute', - left: 0, - transform: `translateY(${virtualRow.start}px)`, - minWidth: '100%', - height, - } -} interface LogsProps { teamIdOrSlug: string @@ -84,8 +64,11 @@ export default function SandboxLogs({ teamIdOrSlug, sandboxId }: LogsProps) {
- - + +
@@ -152,9 +135,12 @@ function LogsContent({ className="min-h-0 flex-1 overflow-auto" > - + - {showLoader && } + {showLoader && } {showEmpty && ( - - - Timestamp - - - Level - - - Message - - - - ) -} - -function LoaderBody() { - return ( - - - -
- -
-
-
-
- ) -} - interface EmptyBodyProps { hasRetainedLogs: boolean errorMessage: string | null } function EmptyBody({ hasRetainedLogs, errorMessage }: EmptyBodyProps) { + const description = errorMessage + ? errorMessage + : !hasRetainedLogs + ? `This sandbox has exceeded the ${LOG_RETENTION_DAYS} day retention limit.` + : 'Sandbox logs will appear here once available.' + return ( - - - -
-
- -

No logs found

-
- {errorMessage ? ( -

{errorMessage}

- ) : !hasRetainedLogs ? ( -

- This sandbox has exceeded the {LOG_RETENTION_DAYS} day retention - limit. -

- ) : ( -

- Sandbox logs will appear here once available. -

- )} -
-
-
-
+ ) } @@ -535,13 +459,13 @@ interface LogRowProps { function LogRow({ log, isZebraRow, virtualRow, virtualizer }: LogRowProps) { return ( - virtualizer.measureElement(node)} - className={`${isZebraRow ? 'bg-bg-1/80 ' : ''}border-l ${ - LOG_LEVEL_BORDER_CLASS[log.level] + - + ) } @@ -585,16 +509,12 @@ function StatusRow({ isFetchingNextPage, }: StatusRowProps) { return ( - virtualizer.measureElement(node)} - style={getVirtualRowStyle(virtualRow, ROW_HEIGHT_PX)} + - + {isFetchingNextPage ? ( <> @@ -608,8 +528,8 @@ function StatusRow({ 'Scroll to load more' )} - - + + ) } @@ -625,16 +545,12 @@ function LiveStatusRow({ isRunning, }: LiveStatusRowProps) { return ( - virtualizer.measureElement(node)} - style={getVirtualRowStyle(virtualRow, LIVE_STATUS_ROW_HEIGHT_PX)} + - + [ - - + + ) } From aecda0fa72a97f3a168a9d27cf086d257279cea6 Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Fri, 13 Feb 2026 14:16:43 -0800 Subject: [PATCH 17/54] fix: refactor init cursor logic for forward fetching logs --- .../sandbox/logs/sandbox-logs-store.ts | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/src/features/dashboard/sandbox/logs/sandbox-logs-store.ts b/src/features/dashboard/sandbox/logs/sandbox-logs-store.ts index 1b82a3429..57265d9e1 100644 --- a/src/features/dashboard/sandbox/logs/sandbox-logs-store.ts +++ b/src/features/dashboard/sandbox/logs/sandbox-logs-store.ts @@ -38,6 +38,7 @@ interface SandboxLogsMutations { } export type SandboxLogsStoreData = SandboxLogsState & SandboxLogsMutations +const EMPTY_INIT_FORWARD_LOOKBACK_MS = 5_000 function countLeadingAtTimestamp(logs: SandboxLogDTO[], timestamp: number) { let count = 0 @@ -186,6 +187,8 @@ export const createSandboxLogsStore = () => set((s) => { const backwardsCursor = result.nextCursor + const newestInitialTimestamp = + result.logs[result.logs.length - 1]?.timestampUnix s.logs = result.logs s.hasMoreBackwards = backwardsCursor !== null @@ -194,10 +197,18 @@ export const createSandboxLogsStore = () => backwardsCursor === null ? 0 : countLeadingAtTimestamp(result.logs, backwardsCursor) - s.forwardCursor = initCursor - // Initial backward snapshot can include logs exactly at initCursor. - // Track how many were already consumed so first forward poll does not replay them. - s.forwardSeenAtCursor = countTrailingAtTimestamp(result.logs, initCursor) + if (newestInitialTimestamp !== undefined) { + s.forwardCursor = newestInitialTimestamp + s.forwardSeenAtCursor = countTrailingAtTimestamp( + result.logs, + newestInitialTimestamp + ) + } else { + // If the initial snapshot is empty, start slightly in the past so + // delayed-ingestion logs around page load are not skipped. + s.forwardCursor = initCursor - EMPTY_INIT_FORWARD_LOOKBACK_MS + s.forwardSeenAtCursor = 0 + } s.isLoadingBackwards = false s.isInitialized = true s.hasCompletedInitialLoad = true From 719f8cb10a1bc8620f9bedc4cf8a570d34143fad Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Fri, 13 Feb 2026 15:15:48 -0800 Subject: [PATCH 18/54] improve: sandbox stopped log forward fetch draining --- .../sandbox/logs/use-sandbox-logs.ts | 43 ++++++++++++++++++- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/src/features/dashboard/sandbox/logs/use-sandbox-logs.ts b/src/features/dashboard/sandbox/logs/use-sandbox-logs.ts index 2aea10e33..78ffeac20 100644 --- a/src/features/dashboard/sandbox/logs/use-sandbox-logs.ts +++ b/src/features/dashboard/sandbox/logs/use-sandbox-logs.ts @@ -10,6 +10,8 @@ import { } from './sandbox-logs-store' const REFETCH_INTERVAL_MS = 3_000 +const DRAIN_AFTER_STOP_WINDOW_MS = 10_000 +const MIN_EMPTY_DRAIN_POLLS = 2 interface UseSandboxLogsParams { teamIdOrSlug: string @@ -44,11 +46,26 @@ export function useSandboxLogs({ }, [store, trpcClient, teamIdOrSlug, sandboxId]) const isDraining = useRef(false) + const prevIsRunningRef = useRef(isRunning) + const drainUntilTimestampMs = useRef(null) + const consecutiveEmptyDrainPolls = useRef(0) useEffect(() => { if (isRunning) { isDraining.current = true + drainUntilTimestampMs.current = null + consecutiveEmptyDrainPolls.current = 0 + prevIsRunningRef.current = true + return } + + if (prevIsRunningRef.current) { + isDraining.current = true + drainUntilTimestampMs.current = Date.now() + DRAIN_AFTER_STOP_WINDOW_MS + consecutiveEmptyDrainPolls.current = 0 + } + + prevIsRunningRef.current = false }, [isRunning]) const shouldPoll = isInitialized && (isRunning || isDraining.current) @@ -58,8 +75,30 @@ export function useSandboxLogs({ queryFn: async () => { const { logsCount } = await store.getState().fetchMoreForwards() - if (!isRunning && logsCount === 0) { - isDraining.current = false + if (!isRunning) { + if (logsCount > 0) { + consecutiveEmptyDrainPolls.current = 0 + + if (drainUntilTimestampMs.current !== null) { + drainUntilTimestampMs.current = + Date.now() + DRAIN_AFTER_STOP_WINDOW_MS + } + } else { + consecutiveEmptyDrainPolls.current += 1 + + const drainWindowElapsed = + drainUntilTimestampMs.current !== null && + Date.now() >= drainUntilTimestampMs.current + + if ( + drainWindowElapsed && + consecutiveEmptyDrainPolls.current >= MIN_EMPTY_DRAIN_POLLS + ) { + isDraining.current = false + drainUntilTimestampMs.current = null + consecutiveEmptyDrainPolls.current = 0 + } + } } return { logsCount } From c02c4a3a5725d3af4008f013cc008a3af714074e Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Fri, 13 Feb 2026 16:25:42 -0800 Subject: [PATCH 19/54] refactor: build logs to match sandbox logs behavior --- .../dashboard/build/build-logs-store.ts | 199 +++++++++++++----- src/features/dashboard/build/logs.tsx | 75 ++++++- .../dashboard/build/use-build-logs.ts | 43 +++- src/server/api/routers/builds.ts | 28 +-- 4 files changed, 272 insertions(+), 73 deletions(-) diff --git a/src/features/dashboard/build/build-logs-store.ts b/src/features/dashboard/build/build-logs-store.ts index 7d85d6b3e..7de57fce6 100644 --- a/src/features/dashboard/build/build-logs-store.ts +++ b/src/features/dashboard/build/build-logs-store.ts @@ -6,7 +6,7 @@ import { create } from 'zustand' import { immer } from 'zustand/middleware/immer' import type { LogLevelFilter } from './logs-filter-params' -const FORWARD_CURSOR_PADDING_MS = 1 +const EMPTY_INIT_FORWARD_LOOKBACK_MS = 5_000 interface BuildLogsParams { teamIdOrSlug: string @@ -22,6 +22,9 @@ interface BuildLogsState { isLoadingBackwards: boolean isLoadingForwards: boolean backwardsCursor: number | null + backwardsSeenAtCursor: number + forwardCursor: number | null + forwardSeenAtCursor: number level: LogLevelFilter | null isInitialized: boolean @@ -41,25 +44,76 @@ interface BuildLogsMutations { reset: () => void } -interface BuildLogsComputed { - getNewestTimestamp: () => number | undefined - getOldestTimestamp: () => number | undefined +export type BuildLogsStoreData = BuildLogsState & BuildLogsMutations + +function countLeadingAtTimestamp(logs: BuildLogDTO[], timestamp: number) { + let count = 0 + + while (count < logs.length && logs[count]!.timestampUnix === timestamp) { + count += 1 + } + + return count } -export type BuildLogsStoreData = BuildLogsState & - BuildLogsMutations & - BuildLogsComputed +function countTrailingAtTimestamp(logs: BuildLogDTO[], timestamp: number) { + let count = 0 + let index = logs.length - 1 + + while (index >= 0 && logs[index]!.timestampUnix === timestamp) { + count += 1 + index -= 1 + } -function getLogKey(log: BuildLogDTO): string { - return `${log.timestampUnix}:${log.level}:${log.message}` + return count } -function deduplicateLogs( - existingLogs: BuildLogDTO[], - newLogs: BuildLogDTO[] -): BuildLogDTO[] { - const existingKeys = new Set(existingLogs.map(getLogKey)) - return newLogs.filter((log) => !existingKeys.has(getLogKey(log))) +function dropLeadingAtTimestamp( + logs: BuildLogDTO[], + timestamp: number, + dropCount: number +) { + if (dropCount <= 0) { + return logs + } + + let index = 0 + let remainingToDrop = dropCount + + while ( + index < logs.length && + remainingToDrop > 0 && + logs[index]!.timestampUnix === timestamp + ) { + index += 1 + remainingToDrop -= 1 + } + + return logs.slice(index) +} + +function dropTrailingAtTimestamp( + logs: BuildLogDTO[], + timestamp: number, + dropCount: number +) { + if (dropCount <= 0) { + return logs + } + + let end = logs.length + let remainingToDrop = dropCount + + while ( + end > 0 && + remainingToDrop > 0 && + logs[end - 1]!.timestampUnix === timestamp + ) { + end -= 1 + remainingToDrop -= 1 + } + + return logs.slice(0, end) } const initialState: BuildLogsState = { @@ -68,6 +122,9 @@ const initialState: BuildLogsState = { isLoadingBackwards: false, isLoadingForwards: false, backwardsCursor: null, + backwardsSeenAtCursor: 0, + forwardCursor: null, + forwardSeenAtCursor: 0, level: null, isInitialized: false, _trpcClient: null, @@ -87,6 +144,10 @@ export const createBuildLogsStore = () => state.isLoadingBackwards = false state.isLoadingForwards = false state.backwardsCursor = null + state.backwardsSeenAtCursor = 0 + state.forwardCursor = null + state.forwardSeenAtCursor = 0 + state.level = null state.isInitialized = false }) }, @@ -117,12 +178,14 @@ export const createBuildLogsStore = () => }) try { + const initCursor = Date.now() + const result = await trpcClient.builds.buildLogsBackwards.query({ teamIdOrSlug: params.teamIdOrSlug, templateId: params.templateId, buildId: params.buildId, level: level ?? undefined, - cursor: Date.now(), + cursor: initCursor, }) // Ignore stale response if a newer init was called @@ -131,9 +194,29 @@ export const createBuildLogsStore = () => } set((s) => { + const backwardsCursor = result.nextCursor + const newestInitialTimestamp = + result.logs[result.logs.length - 1]?.timestampUnix + s.logs = result.logs - s.hasMoreBackwards = result.nextCursor !== null - s.backwardsCursor = result.nextCursor + s.hasMoreBackwards = backwardsCursor !== null + s.backwardsCursor = backwardsCursor + s.backwardsSeenAtCursor = + backwardsCursor === null + ? 0 + : countLeadingAtTimestamp(result.logs, backwardsCursor) + + if (newestInitialTimestamp !== undefined) { + s.forwardCursor = newestInitialTimestamp + s.forwardSeenAtCursor = countTrailingAtTimestamp( + result.logs, + newestInitialTimestamp + ) + } else { + s.forwardCursor = initCursor - EMPTY_INIT_FORWARD_LOOKBACK_MS + s.forwardSeenAtCursor = 0 + } + s.isLoadingBackwards = false s.isInitialized = true }) @@ -169,16 +252,15 @@ export const createBuildLogsStore = () => try { const cursor = - state.backwardsCursor ?? state.getOldestTimestamp() ?? Date.now() - - const result = - await state._trpcClient.builds.buildLogsBackwards.query({ - teamIdOrSlug: state._params.teamIdOrSlug, - templateId: state._params.templateId, - buildId: state._params.buildId, - level: state.level ?? undefined, - cursor, - }) + state.backwardsCursor ?? state.logs[0]?.timestampUnix ?? Date.now() + + const result = await state._trpcClient.builds.buildLogsBackwards.query({ + teamIdOrSlug: state._params.teamIdOrSlug, + templateId: state._params.templateId, + buildId: state._params.buildId, + level: state.level ?? undefined, + cursor, + }) // Ignore stale response if init was called during fetch if (get()._initVersion !== requestVersion) { @@ -186,10 +268,21 @@ export const createBuildLogsStore = () => } set((s) => { - const uniqueNewLogs = deduplicateLogs(s.logs, result.logs) - s.logs = [...uniqueNewLogs, ...s.logs] - s.hasMoreBackwards = result.nextCursor !== null - s.backwardsCursor = result.nextCursor + const newLogs = dropTrailingAtTimestamp( + result.logs, + cursor, + state.backwardsSeenAtCursor + ) + const nextLogs = newLogs.length > 0 ? [...newLogs, ...s.logs] : s.logs + const backwardsCursor = result.nextCursor + + s.logs = nextLogs + s.hasMoreBackwards = backwardsCursor !== null + s.backwardsCursor = backwardsCursor + s.backwardsSeenAtCursor = + backwardsCursor === null + ? 0 + : countLeadingAtTimestamp(nextLogs, backwardsCursor) s.isLoadingBackwards = false }) } catch { @@ -217,10 +310,8 @@ export const createBuildLogsStore = () => }) try { - const newestTimestamp = state.getNewestTimestamp() - const cursor = newestTimestamp - ? newestTimestamp + FORWARD_CURSOR_PADDING_MS - : Date.now() + const cursor = state.forwardCursor ?? Date.now() + const seenAtCursor = state.forwardSeenAtCursor const result = await state._trpcClient.builds.buildLogsForward.query({ teamIdOrSlug: state._params.teamIdOrSlug, @@ -235,18 +326,32 @@ export const createBuildLogsStore = () => return { logsCount: 0 } } - let uniqueLogsCount = 0 + const newLogs = dropLeadingAtTimestamp(result.logs, cursor, seenAtCursor) + const logsCount = newLogs.length set((s) => { - const uniqueNewLogs = deduplicateLogs(s.logs, result.logs) - uniqueLogsCount = uniqueNewLogs.length - if (uniqueLogsCount > 0) { - s.logs = [...s.logs, ...uniqueNewLogs] + if (logsCount > 0) { + s.logs = [...s.logs, ...newLogs] + + const newestTimestamp = newLogs[logsCount - 1]!.timestampUnix + const trailingAtNewest = countTrailingAtTimestamp( + newLogs, + newestTimestamp + ) + + s.forwardCursor = newestTimestamp + s.forwardSeenAtCursor = + newestTimestamp === cursor + ? seenAtCursor + trailingAtNewest + : trailingAtNewest + } else { + s.forwardCursor = cursor + s.forwardSeenAtCursor = seenAtCursor } s.isLoadingForwards = false }) - return { logsCount: uniqueLogsCount } + return { logsCount } } catch { if (get()._initVersion !== requestVersion) { return { logsCount: 0 } @@ -259,16 +364,6 @@ export const createBuildLogsStore = () => return { logsCount: 0 } } }, - - getNewestTimestamp: () => { - const state = get() - return state.logs[state.logs.length - 1]?.timestampUnix - }, - - getOldestTimestamp: () => { - const state = get() - return state.logs[0]?.timestampUnix - }, })) ) diff --git a/src/features/dashboard/build/logs.tsx b/src/features/dashboard/build/logs.tsx index 7eeb6f51a..45b066281 100644 --- a/src/features/dashboard/build/logs.tsx +++ b/src/features/dashboard/build/logs.tsx @@ -47,6 +47,7 @@ import useLogFilters from './use-log-filters' // Column width are calculated as max width of the content + padding const COLUMN_WIDTHS_PX = { timestamp: 176 + 16, level: 52 + 16 } as const const ROW_HEIGHT_PX = 26 +const LIVE_STATUS_ROW_HEIGHT_PX = ROW_HEIGHT_PX + 16 const VIRTUAL_OVERSCAN = 16 const SCROLL_LOAD_THRESHOLD_PX = 200 @@ -151,6 +152,7 @@ function LogsContent({ const showLoader = (isFetching || isRefetchingFromFilterChange) && !hasLogs const showEmpty = !isFetching && !hasLogs && !isRefetchingFromFilterChange const showRefetchOverlay = isRefetchingFromFilterChange && hasLogs + const isBuilding = buildDetails.status === 'building' const handleLoadMore = useCallback(() => { if (hasNextPage && !isFetchingNextPage) { @@ -185,6 +187,7 @@ function LogsContent({ showRefetchOverlay={showRefetchOverlay} isInitialized={isInitialized} level={level} + isBuilding={isBuilding} /> )}
@@ -290,6 +293,7 @@ interface VirtualizedLogsBodyProps { showRefetchOverlay: boolean isInitialized: boolean level: LogLevelFilter | null + isBuilding: boolean } function VirtualizedLogsBody({ @@ -302,6 +306,7 @@ function VirtualizedLogsBody({ showRefetchOverlay, isInitialized, level, + isBuilding, }: VirtualizedLogsBodyProps) { const maxWidthRef = useRef(0) @@ -326,10 +331,14 @@ function VirtualizedLogsBody({ }) const showStatusRow = hasNextPage || isFetchingNextPage + const logsStartIndex = showStatusRow ? 1 : 0 + const liveStatusRowIndex = logsStartIndex + logs.length + const virtualRowsCount = logs.length + (showStatusRow ? 1 : 0) + 1 const virtualizer = useVirtualizer({ - count: logs.length + (showStatusRow ? 1 : 0), - estimateSize: () => ROW_HEIGHT_PX, + count: virtualRowsCount, + estimateSize: (index) => + index === liveStatusRowIndex ? LIVE_STATUS_ROW_HEIGHT_PX : ROW_HEIGHT_PX, getScrollElement: () => scrollContainerRef.current, overscan: VIRTUAL_OVERSCAN, paddingStart: 8, @@ -365,7 +374,7 @@ function VirtualizedLogsBody({ if (isStatusRow) { return ( + ) + } + + const logIndex = virtualRow.index - logsStartIndex return ( [ loading ] - retrieving older logs + retrieving older build logs ) : ( - 'Scroll to load more' + 'Scroll to load older build logs' )} ) } + +interface LiveStatusRowProps { + virtualRow: VirtualItem + virtualizer: Virtualizer + isBuilding: boolean +} + +function LiveStatusRow({ + virtualRow, + virtualizer, + isBuilding, +}: LiveStatusRowProps) { + return ( + + + + [ + + {isBuilding ? 'live' : 'end'} + + ] + + {isBuilding + ? 'No more build logs to show. Waiting for new entries...' + : 'No more build logs to show'} + + + + + ) +} diff --git a/src/features/dashboard/build/use-build-logs.ts b/src/features/dashboard/build/use-build-logs.ts index 632d70e43..589613088 100644 --- a/src/features/dashboard/build/use-build-logs.ts +++ b/src/features/dashboard/build/use-build-logs.ts @@ -9,6 +9,8 @@ import { createBuildLogsStore, type BuildLogsStore } from './build-logs-store' import { type LogLevelFilter } from './logs-filter-params' const REFETCH_INTERVAL_MS = 1_500 +const DRAIN_AFTER_BUILD_STOP_WINDOW_MS = 10_000 +const MIN_EMPTY_DRAIN_POLLS = 2 interface UseBuildLogsParams { teamIdOrSlug: string @@ -48,11 +50,26 @@ export function useBuildLogs({ const isBuilding = buildStatus === 'building' const isDraining = useRef(false) + const prevIsBuildingRef = useRef(isBuilding) + const drainUntilTimestampMs = useRef(null) + const consecutiveEmptyDrainPolls = useRef(0) useEffect(() => { if (isBuilding) { isDraining.current = true + drainUntilTimestampMs.current = null + consecutiveEmptyDrainPolls.current = 0 + prevIsBuildingRef.current = true + return } + + if (prevIsBuildingRef.current) { + isDraining.current = true + drainUntilTimestampMs.current = Date.now() + DRAIN_AFTER_BUILD_STOP_WINDOW_MS + consecutiveEmptyDrainPolls.current = 0 + } + + prevIsBuildingRef.current = false }, [isBuilding]) const shouldPoll = isInitialized && (isBuilding || isDraining.current) @@ -62,8 +79,30 @@ export function useBuildLogs({ queryFn: async () => { const { logsCount } = await store.getState().fetchMoreForwards() - if (!isBuilding && logsCount === 0) { - isDraining.current = false + if (!isBuilding) { + if (logsCount > 0) { + consecutiveEmptyDrainPolls.current = 0 + + if (drainUntilTimestampMs.current !== null) { + drainUntilTimestampMs.current = + Date.now() + DRAIN_AFTER_BUILD_STOP_WINDOW_MS + } + } else { + consecutiveEmptyDrainPolls.current += 1 + + const drainWindowElapsed = + drainUntilTimestampMs.current !== null && + Date.now() >= drainUntilTimestampMs.current + + if ( + drainWindowElapsed && + consecutiveEmptyDrainPolls.current >= MIN_EMPTY_DRAIN_POLLS + ) { + isDraining.current = false + drainUntilTimestampMs.current = null + consecutiveEmptyDrainPolls.current = 0 + } + } } return { logsCount } diff --git a/src/server/api/routers/builds.ts b/src/server/api/routers/builds.ts index aa2c94dcc..a473ecf09 100644 --- a/src/server/api/routers/builds.ts +++ b/src/server/api/routers/builds.ts @@ -104,13 +104,13 @@ export const buildsRouter = createTRPCRouter({ { cursor, limit, direction, level } ) - const logsToReturn = buildLogs.logs - - const logs: BuildLogDTO[] = logsToReturn.map((log) => ({ - timestampUnix: new Date(log.timestamp).getTime(), - level: log.level, - message: log.message, - })) + const logs: BuildLogDTO[] = buildLogs.logs + .map((log) => ({ + timestampUnix: new Date(log.timestamp).getTime(), + level: log.level, + message: log.message, + })) + .sort((a, b) => a.timestampUnix - b.timestampUnix) const hasMore = logs.length === limit const cursorLog = logs[0] @@ -151,14 +151,16 @@ export const buildsRouter = createTRPCRouter({ { cursor, limit, direction, level } ) - const logs: BuildLogDTO[] = buildLogs.logs.map((log) => ({ - timestampUnix: new Date(log.timestamp).getTime(), - level: log.level, - message: log.message, - })) + const logs: BuildLogDTO[] = buildLogs.logs + .map((log) => ({ + timestampUnix: new Date(log.timestamp).getTime(), + level: log.level, + message: log.message, + })) + .sort((a, b) => a.timestampUnix - b.timestampUnix) const newestLog = logs[logs.length - 1] - const nextCursor = newestLog?.timestampUnix ?? null + const nextCursor = newestLog?.timestampUnix ?? cursor const result: BuildLogsDTO = { logs, From 0442b2106d9125c4d68e40ad8a27f79503ad064c Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Fri, 13 Feb 2026 17:29:00 -0800 Subject: [PATCH 20/54] chore: improve dashboard tabs memo --- src/ui/dashboard-tabs.tsx | 30 +----------------------------- 1 file changed, 1 insertion(+), 29 deletions(-) diff --git a/src/ui/dashboard-tabs.tsx b/src/ui/dashboard-tabs.tsx index 25e7594da..ebdc03006 100644 --- a/src/ui/dashboard-tabs.tsx +++ b/src/ui/dashboard-tabs.tsx @@ -100,35 +100,7 @@ function DashboardTabsComponent({ ) } -export const DashboardTabs = memo(DashboardTabsComponent, (prev, next) => { - if ( - prev.layoutKey !== next.layoutKey || - prev.type !== next.type || - prev.className !== next.className || - prev.headerAccessory !== next.headerAccessory - ) { - return false - } - - const prevChildren = Array.isArray(prev.children) - ? prev.children - : [prev.children] - const nextChildren = Array.isArray(next.children) - ? next.children - : [next.children] - - if (prevChildren.length !== nextChildren.length) return false - - return prevChildren.every((prevChild, index) => { - const nextChild = nextChildren[index] - if (!nextChild) return false - return ( - prevChild.props.id === nextChild.props.id && - prevChild.props.label === nextChild.props.label && - prevChild.props.icon === nextChild.props.icon - ) - }) -}) +export const DashboardTabs = memo(DashboardTabsComponent) export interface DashboardTabProps { id: string From 4ad367bb58e0c1ec14d7cc4a1d8a435616d7fc6d Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Wed, 18 Feb 2026 15:58:10 -0800 Subject: [PATCH 21/54] refactor: use reverse in backwards logs router and cut unecessary sorting --- src/server/api/repositories/builds.repository.ts | 1 + src/server/api/routers/builds.ts | 4 +--- src/server/api/routers/sandbox.ts | 4 +--- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/src/server/api/repositories/builds.repository.ts b/src/server/api/repositories/builds.repository.ts index 25d3d462a..a541a8c0a 100644 --- a/src/server/api/repositories/builds.repository.ts +++ b/src/server/api/repositories/builds.repository.ts @@ -330,6 +330,7 @@ export async function getInfraBuildLogs( limit: options.limit, direction: options.direction, level: options.level, + source: 'persistent' }, }, headers: { diff --git a/src/server/api/routers/builds.ts b/src/server/api/routers/builds.ts index a473ecf09..e3911e933 100644 --- a/src/server/api/routers/builds.ts +++ b/src/server/api/routers/builds.ts @@ -109,8 +109,7 @@ export const buildsRouter = createTRPCRouter({ timestampUnix: new Date(log.timestamp).getTime(), level: log.level, message: log.message, - })) - .sort((a, b) => a.timestampUnix - b.timestampUnix) + })).reverse() const hasMore = logs.length === limit const cursorLog = logs[0] @@ -157,7 +156,6 @@ export const buildsRouter = createTRPCRouter({ level: log.level, message: log.message, })) - .sort((a, b) => a.timestampUnix - b.timestampUnix) const newestLog = logs[logs.length - 1] const nextCursor = newestLog?.timestampUnix ?? cursor diff --git a/src/server/api/routers/sandbox.ts b/src/server/api/routers/sandbox.ts index 3ebdb6572..90229761d 100644 --- a/src/server/api/routers/sandbox.ts +++ b/src/server/api/routers/sandbox.ts @@ -36,8 +36,7 @@ export const sandboxRouter = createTRPCRouter({ ) const logs: SandboxLogDTO[] = sandboxLogs.logs - .map(mapInfraSandboxLogToDTO) - .sort((a, b) => a.timestampUnix - b.timestampUnix) + .map(mapInfraSandboxLogToDTO).reverse() const hasMore = logs.length === limit const cursorLog = logs[0] @@ -77,7 +76,6 @@ export const sandboxRouter = createTRPCRouter({ const logs: SandboxLogDTO[] = sandboxLogs.logs .map(mapInfraSandboxLogToDTO) - .sort((a, b) => a.timestampUnix - b.timestampUnix) const newestLog = logs[logs.length - 1] const nextCursor = newestLog?.timestampUnix ?? cursor From f062c48f93afb387cf3f6b75ec784f18dea239a0 Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Thu, 19 Feb 2026 13:22:15 -0800 Subject: [PATCH 22/54] chore: let log source be chosen automatically in infra --- src/server/api/repositories/builds.repository.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/server/api/repositories/builds.repository.ts b/src/server/api/repositories/builds.repository.ts index a541a8c0a..25d3d462a 100644 --- a/src/server/api/repositories/builds.repository.ts +++ b/src/server/api/repositories/builds.repository.ts @@ -330,7 +330,6 @@ export async function getInfraBuildLogs( limit: options.limit, direction: options.direction, level: options.level, - source: 'persistent' }, }, headers: { From ce58dd9b8bbd559224d5ca2502bcceb6e7088338 Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Thu, 19 Feb 2026 15:52:59 -0800 Subject: [PATCH 23/54] improve: fix mobile responsiveness for sandbox details --- src/configs/layout.ts | 3 +++ src/features/dashboard/sandbox/inspect/view.tsx | 8 +------- src/features/dashboard/sandbox/layout.tsx | 7 ++++--- src/features/dashboard/sandbox/logs/view.tsx | 2 +- src/ui/dashboard-tabs.tsx | 9 ++++----- 5 files changed, 13 insertions(+), 16 deletions(-) diff --git a/src/configs/layout.ts b/src/configs/layout.ts index e73df4833..b9299a04f 100644 --- a/src/configs/layout.ts +++ b/src/configs/layout.ts @@ -44,6 +44,9 @@ const DASHBOARD_LAYOUT_CONFIGS: Record< ], type: 'custom', copyValue: sandboxId, + custom: { + includeHeaderBottomStyles: true, + }, } }, '/dashboard/*/templates': () => ({ diff --git a/src/features/dashboard/sandbox/inspect/view.tsx b/src/features/dashboard/sandbox/inspect/view.tsx index ccc3f9867..b2586cfee 100644 --- a/src/features/dashboard/sandbox/inspect/view.tsx +++ b/src/features/dashboard/sandbox/inspect/view.tsx @@ -3,7 +3,6 @@ import SandboxInspectProvider from '@/features/dashboard/sandbox/inspect/context' import SandboxInspectFilesystem from '@/features/dashboard/sandbox/inspect/filesystem' import SandboxInspectViewer from '@/features/dashboard/sandbox/inspect/viewer' -import { cn } from '@/lib/utils' import type { EntryInfo } from 'e2b' interface SandboxInspectViewProps { @@ -17,12 +16,7 @@ export default function SandboxInspectView({ }: SandboxInspectViewProps) { return ( -
+
diff --git a/src/features/dashboard/sandbox/layout.tsx b/src/features/dashboard/sandbox/layout.tsx index 6e2b69801..c60e5d6ff 100644 --- a/src/features/dashboard/sandbox/layout.tsx +++ b/src/features/dashboard/sandbox/layout.tsx @@ -36,18 +36,19 @@ export default function SandboxLayout({ } return ( -
+
{header} } > {children} @@ -55,7 +56,7 @@ export default function SandboxLayout({ } > {isEnvdVersionCompatibleForInspect ? ( diff --git a/src/features/dashboard/sandbox/logs/view.tsx b/src/features/dashboard/sandbox/logs/view.tsx index d7657d16b..246070451 100644 --- a/src/features/dashboard/sandbox/logs/view.tsx +++ b/src/features/dashboard/sandbox/logs/view.tsx @@ -12,7 +12,7 @@ export default function SandboxLogsView({ sandboxId, }: SandboxLogsViewProps) { return ( -
+
) diff --git a/src/ui/dashboard-tabs.tsx b/src/ui/dashboard-tabs.tsx index ebdc03006..40dee6aeb 100644 --- a/src/ui/dashboard-tabs.tsx +++ b/src/ui/dashboard-tabs.tsx @@ -92,7 +92,9 @@ function DashboardTabsComponent({
) : ( - {tabTriggers} + + {tabTriggers} + )} {children} @@ -114,10 +116,7 @@ export function DashboardTab(props: DashboardTabProps) { return ( {props.children} From 470f89ed21c0bc0ab7e11653f08ada0480b72913 Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Thu, 19 Feb 2026 15:54:31 -0800 Subject: [PATCH 24/54] chore: require persistent log source for log order regression on build logs --- src/server/api/repositories/builds.repository.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/server/api/repositories/builds.repository.ts b/src/server/api/repositories/builds.repository.ts index 25d3d462a..768e29585 100644 --- a/src/server/api/repositories/builds.repository.ts +++ b/src/server/api/repositories/builds.repository.ts @@ -330,6 +330,8 @@ export async function getInfraBuildLogs( limit: options.limit, direction: options.direction, level: options.level, + // TODO: remove once template manger is deployed ~ Thu, Feb 19, 2026 + source: 'persistent' }, }, headers: { From f479c7c837e575de11160a0bd9f928b3cb53ec1d Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Fri, 20 Feb 2026 15:16:27 -0800 Subject: [PATCH 25/54] chore: rename backward logs queries --- .../dashboard/build/build-logs-store.ts | 39 +++++++++++-------- .../sandbox/logs/sandbox-logs-store.ts | 16 ++++---- src/server/api/routers/builds.ts | 16 ++++---- src/server/api/routers/sandbox.ts | 10 +++-- 4 files changed, 46 insertions(+), 35 deletions(-) diff --git a/src/features/dashboard/build/build-logs-store.ts b/src/features/dashboard/build/build-logs-store.ts index 7de57fce6..e26c0adf9 100644 --- a/src/features/dashboard/build/build-logs-store.ts +++ b/src/features/dashboard/build/build-logs-store.ts @@ -180,13 +180,14 @@ export const createBuildLogsStore = () => try { const initCursor = Date.now() - const result = await trpcClient.builds.buildLogsBackwards.query({ - teamIdOrSlug: params.teamIdOrSlug, - templateId: params.templateId, - buildId: params.buildId, - level: level ?? undefined, - cursor: initCursor, - }) + const result = + await trpcClient.builds.buildLogsBackwardsReversed.query({ + teamIdOrSlug: params.teamIdOrSlug, + templateId: params.templateId, + buildId: params.buildId, + level: level ?? undefined, + cursor: initCursor, + }) // Ignore stale response if a newer init was called if (get()._initVersion !== requestVersion) { @@ -254,13 +255,14 @@ export const createBuildLogsStore = () => const cursor = state.backwardsCursor ?? state.logs[0]?.timestampUnix ?? Date.now() - const result = await state._trpcClient.builds.buildLogsBackwards.query({ - teamIdOrSlug: state._params.teamIdOrSlug, - templateId: state._params.templateId, - buildId: state._params.buildId, - level: state.level ?? undefined, - cursor, - }) + const result = + await state._trpcClient.builds.buildLogsBackwardsReversed.query({ + teamIdOrSlug: state._params.teamIdOrSlug, + templateId: state._params.templateId, + buildId: state._params.buildId, + level: state.level ?? undefined, + cursor, + }) // Ignore stale response if init was called during fetch if (get()._initVersion !== requestVersion) { @@ -273,7 +275,8 @@ export const createBuildLogsStore = () => cursor, state.backwardsSeenAtCursor ) - const nextLogs = newLogs.length > 0 ? [...newLogs, ...s.logs] : s.logs + const nextLogs = + newLogs.length > 0 ? [...newLogs, ...s.logs] : s.logs const backwardsCursor = result.nextCursor s.logs = nextLogs @@ -326,7 +329,11 @@ export const createBuildLogsStore = () => return { logsCount: 0 } } - const newLogs = dropLeadingAtTimestamp(result.logs, cursor, seenAtCursor) + const newLogs = dropLeadingAtTimestamp( + result.logs, + cursor, + seenAtCursor + ) const logsCount = newLogs.length set((s) => { diff --git a/src/features/dashboard/sandbox/logs/sandbox-logs-store.ts b/src/features/dashboard/sandbox/logs/sandbox-logs-store.ts index 57265d9e1..3b4a61d40 100644 --- a/src/features/dashboard/sandbox/logs/sandbox-logs-store.ts +++ b/src/features/dashboard/sandbox/logs/sandbox-logs-store.ts @@ -174,7 +174,7 @@ export const createSandboxLogsStore = () => try { const initCursor = Date.now() - const result = await trpcClient.sandbox.logsBackwards.query({ + const result = await trpcClient.sandbox.logsBackwardsReversed.query({ teamIdOrSlug: params.teamIdOrSlug, sandboxId: params.sandboxId, cursor: initCursor, @@ -253,11 +253,12 @@ export const createSandboxLogsStore = () => const cursor = state.backwardsCursor ?? state.logs[0]?.timestampUnix ?? Date.now() - const result = await state._trpcClient.sandbox.logsBackwards.query({ - teamIdOrSlug: state._params.teamIdOrSlug, - sandboxId: state._params.sandboxId, - cursor, - }) + const result = + await state._trpcClient.sandbox.logsBackwardsReversed.query({ + teamIdOrSlug: state._params.teamIdOrSlug, + sandboxId: state._params.sandboxId, + cursor, + }) // ignore stale response if init was called during fetch if (get()._initVersion !== requestVersion) { @@ -270,7 +271,8 @@ export const createSandboxLogsStore = () => cursor, state.backwardsSeenAtCursor ) - const nextLogs = newLogs.length > 0 ? [...newLogs, ...s.logs] : s.logs + const nextLogs = + newLogs.length > 0 ? [...newLogs, ...s.logs] : s.logs const backwardsCursor = result.nextCursor s.logs = nextLogs diff --git a/src/server/api/routers/builds.ts b/src/server/api/routers/builds.ts index e3911e933..472d75b15 100644 --- a/src/server/api/routers/builds.ts +++ b/src/server/api/routers/builds.ts @@ -77,7 +77,7 @@ export const buildsRouter = createTRPCRouter({ return result }), - buildLogsBackwards: protectedTeamProcedure + buildLogsBackwardsReversed: protectedTeamProcedure .input( z.object({ templateId: z.string(), @@ -109,7 +109,8 @@ export const buildsRouter = createTRPCRouter({ timestampUnix: new Date(log.timestamp).getTime(), level: log.level, message: log.message, - })).reverse() + })) + .reverse() const hasMore = logs.length === limit const cursorLog = logs[0] @@ -150,12 +151,11 @@ export const buildsRouter = createTRPCRouter({ { cursor, limit, direction, level } ) - const logs: BuildLogDTO[] = buildLogs.logs - .map((log) => ({ - timestampUnix: new Date(log.timestamp).getTime(), - level: log.level, - message: log.message, - })) + const logs: BuildLogDTO[] = buildLogs.logs.map((log) => ({ + timestampUnix: new Date(log.timestamp).getTime(), + level: log.level, + message: log.message, + })) const newestLog = logs[logs.length - 1] const nextCursor = newestLog?.timestampUnix ?? cursor diff --git a/src/server/api/routers/sandbox.ts b/src/server/api/routers/sandbox.ts index 90229761d..728ce615c 100644 --- a/src/server/api/routers/sandbox.ts +++ b/src/server/api/routers/sandbox.ts @@ -11,7 +11,7 @@ import { sandboxesRepo } from '../repositories/sandboxes.repository' export const sandboxRouter = createTRPCRouter({ // QUERIES - logsBackwards: protectedTeamProcedure + logsBackwardsReversed: protectedTeamProcedure .input( z.object({ sandboxId: z.string(), @@ -36,7 +36,8 @@ export const sandboxRouter = createTRPCRouter({ ) const logs: SandboxLogDTO[] = sandboxLogs.logs - .map(mapInfraSandboxLogToDTO).reverse() + .map(mapInfraSandboxLogToDTO) + .reverse() const hasMore = logs.length === limit const cursorLog = logs[0] @@ -74,8 +75,9 @@ export const sandboxRouter = createTRPCRouter({ { cursor, limit, direction } ) - const logs: SandboxLogDTO[] = sandboxLogs.logs - .map(mapInfraSandboxLogToDTO) + const logs: SandboxLogDTO[] = sandboxLogs.logs.map( + mapInfraSandboxLogToDTO + ) const newestLog = logs[logs.length - 1] const nextCursor = newestLog?.timestampUnix ?? cursor From feb4f711e4d25e3ab6da7a9b31a245f45e55a2b1 Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Fri, 20 Feb 2026 15:17:54 -0800 Subject: [PATCH 26/54] chore: fix auth test depending on env --- src/__test__/integration/auth.test.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/__test__/integration/auth.test.ts b/src/__test__/integration/auth.test.ts index 5e3c6aaed..0d40e928b 100644 --- a/src/__test__/integration/auth.test.ts +++ b/src/__test__/integration/auth.test.ts @@ -16,6 +16,10 @@ const { validateEmail, shouldWarnAboutAlternateEmail } = vi.hoisted(() => ({ shouldWarnAboutAlternateEmail: vi.fn(), })) +const { verifyTurnstileToken } = vi.hoisted(() => ({ + verifyTurnstileToken: vi.fn(), +})) + // Mock console.error to prevent output during tests const originalConsoleError = console.error console.error = vi.fn() @@ -78,6 +82,10 @@ vi.mock('@/server/auth/validate-email', () => ({ shouldWarnAboutAlternateEmail, })) +vi.mock('@/lib/captcha/turnstile', () => ({ + verifyTurnstileToken, +})) + describe('Auth Actions - Integration Tests', () => { beforeEach(() => { vi.resetAllMocks() @@ -86,6 +94,7 @@ describe('Auth Actions - Integration Tests', () => { ok: true, json: () => Promise.resolve({ version: 'v2.60.7', name: 'GoTrue' }), }) + verifyTurnstileToken.mockResolvedValue(true) }) afterEach(() => { @@ -150,7 +159,6 @@ describe('Auth Actions - Integration Tests', () => { expect(result?.validationErrors?.fieldErrors.returnTo).toBeDefined() }) - it('should throw validation error if returnTo is a malicious URL', async () => { mockSupabaseClient.auth.signInWithPassword.mockResolvedValue({ data: { user: { id: 'user-123' } }, @@ -220,6 +228,7 @@ describe('Auth Actions - Integration Tests', () => { email: 'newuser@example.com', password: 'Password123!', confirmPassword: 'Password123!', + captchaToken: 'test-captcha-token', }) // Verify: Check that encodedRedirect was called with success message @@ -238,6 +247,7 @@ describe('Auth Actions - Integration Tests', () => { email: 'newuser@example.com', password: 'Password123!', confirmPassword: 'DifferentPassword!', + captchaToken: 'test-captcha-token', }) // Verify: Check that encodedRedirect was called with error message @@ -292,6 +302,7 @@ describe('Auth Actions - Integration Tests', () => { email: 'newuser@example.com', password: 'Password123!', confirmPassword: 'Password123!', + captchaToken: 'test-captcha-token', }) // Verify: Check that encodedRedirect was called with error message From f1115467f7518e1c4bd0385215e5472f3addf118 Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Fri, 20 Feb 2026 15:21:31 -0800 Subject: [PATCH 27/54] chore: abstract timestamp cursor utils --- .../dashboard/build/build-logs-store.ts | 76 ++---------------- .../dashboard/common/log-timestamp-utils.ts | 79 +++++++++++++++++++ .../sandbox/logs/sandbox-logs-store.ts | 76 ++---------------- 3 files changed, 91 insertions(+), 140 deletions(-) create mode 100644 src/features/dashboard/common/log-timestamp-utils.ts diff --git a/src/features/dashboard/build/build-logs-store.ts b/src/features/dashboard/build/build-logs-store.ts index e26c0adf9..5a5dbcad6 100644 --- a/src/features/dashboard/build/build-logs-store.ts +++ b/src/features/dashboard/build/build-logs-store.ts @@ -4,6 +4,12 @@ import type { BuildLogDTO } from '@/server/api/models/builds.models' import type { useTRPCClient } from '@/trpc/client' import { create } from 'zustand' import { immer } from 'zustand/middleware/immer' +import { + countLeadingAtTimestamp, + countTrailingAtTimestamp, + dropLeadingAtTimestamp, + dropTrailingAtTimestamp, +} from '../common/log-timestamp-utils' import type { LogLevelFilter } from './logs-filter-params' const EMPTY_INIT_FORWARD_LOOKBACK_MS = 5_000 @@ -46,76 +52,6 @@ interface BuildLogsMutations { export type BuildLogsStoreData = BuildLogsState & BuildLogsMutations -function countLeadingAtTimestamp(logs: BuildLogDTO[], timestamp: number) { - let count = 0 - - while (count < logs.length && logs[count]!.timestampUnix === timestamp) { - count += 1 - } - - return count -} - -function countTrailingAtTimestamp(logs: BuildLogDTO[], timestamp: number) { - let count = 0 - let index = logs.length - 1 - - while (index >= 0 && logs[index]!.timestampUnix === timestamp) { - count += 1 - index -= 1 - } - - return count -} - -function dropLeadingAtTimestamp( - logs: BuildLogDTO[], - timestamp: number, - dropCount: number -) { - if (dropCount <= 0) { - return logs - } - - let index = 0 - let remainingToDrop = dropCount - - while ( - index < logs.length && - remainingToDrop > 0 && - logs[index]!.timestampUnix === timestamp - ) { - index += 1 - remainingToDrop -= 1 - } - - return logs.slice(index) -} - -function dropTrailingAtTimestamp( - logs: BuildLogDTO[], - timestamp: number, - dropCount: number -) { - if (dropCount <= 0) { - return logs - } - - let end = logs.length - let remainingToDrop = dropCount - - while ( - end > 0 && - remainingToDrop > 0 && - logs[end - 1]!.timestampUnix === timestamp - ) { - end -= 1 - remainingToDrop -= 1 - } - - return logs.slice(0, end) -} - const initialState: BuildLogsState = { logs: [], hasMoreBackwards: true, diff --git a/src/features/dashboard/common/log-timestamp-utils.ts b/src/features/dashboard/common/log-timestamp-utils.ts new file mode 100644 index 000000000..10b77a138 --- /dev/null +++ b/src/features/dashboard/common/log-timestamp-utils.ts @@ -0,0 +1,79 @@ +interface TimestampedLog { + timestampUnix: number +} + +export function countLeadingAtTimestamp( + logs: T[], + timestamp: number +) { + let count = 0 + + while (count < logs.length && logs[count]!.timestampUnix === timestamp) { + count += 1 + } + + return count +} + +export function countTrailingAtTimestamp( + logs: T[], + timestamp: number +) { + let count = 0 + let index = logs.length - 1 + + while (index >= 0 && logs[index]!.timestampUnix === timestamp) { + count += 1 + index -= 1 + } + + return count +} + +export function dropLeadingAtTimestamp( + logs: T[], + timestamp: number, + dropCount: number +) { + if (dropCount <= 0) { + return logs + } + + let index = 0 + let remainingToDrop = dropCount + + while ( + index < logs.length && + remainingToDrop > 0 && + logs[index]!.timestampUnix === timestamp + ) { + index += 1 + remainingToDrop -= 1 + } + + return logs.slice(index) +} + +export function dropTrailingAtTimestamp( + logs: T[], + timestamp: number, + dropCount: number +) { + if (dropCount <= 0) { + return logs + } + + let end = logs.length + let remainingToDrop = dropCount + + while ( + end > 0 && + remainingToDrop > 0 && + logs[end - 1]!.timestampUnix === timestamp + ) { + end -= 1 + remainingToDrop -= 1 + } + + return logs.slice(0, end) +} diff --git a/src/features/dashboard/sandbox/logs/sandbox-logs-store.ts b/src/features/dashboard/sandbox/logs/sandbox-logs-store.ts index 3b4a61d40..580370697 100644 --- a/src/features/dashboard/sandbox/logs/sandbox-logs-store.ts +++ b/src/features/dashboard/sandbox/logs/sandbox-logs-store.ts @@ -4,6 +4,12 @@ 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' +import { + countLeadingAtTimestamp, + countTrailingAtTimestamp, + dropLeadingAtTimestamp, + dropTrailingAtTimestamp, +} from '../../common/log-timestamp-utils' interface SandboxLogsParams { teamIdOrSlug: string @@ -40,76 +46,6 @@ interface SandboxLogsMutations { export type SandboxLogsStoreData = SandboxLogsState & SandboxLogsMutations const EMPTY_INIT_FORWARD_LOOKBACK_MS = 5_000 -function countLeadingAtTimestamp(logs: SandboxLogDTO[], timestamp: number) { - let count = 0 - - while (count < logs.length && logs[count]!.timestampUnix === timestamp) { - count += 1 - } - - return count -} - -function countTrailingAtTimestamp(logs: SandboxLogDTO[], timestamp: number) { - let count = 0 - let index = logs.length - 1 - - while (index >= 0 && logs[index]!.timestampUnix === timestamp) { - count += 1 - index -= 1 - } - - return count -} - -function dropLeadingAtTimestamp( - logs: SandboxLogDTO[], - timestamp: number, - dropCount: number -) { - if (dropCount <= 0) { - return logs - } - - let index = 0 - let remainingToDrop = dropCount - - while ( - index < logs.length && - remainingToDrop > 0 && - logs[index]!.timestampUnix === timestamp - ) { - index += 1 - remainingToDrop -= 1 - } - - return logs.slice(index) -} - -function dropTrailingAtTimestamp( - logs: SandboxLogDTO[], - timestamp: number, - dropCount: number -) { - if (dropCount <= 0) { - return logs - } - - let end = logs.length - let remainingToDrop = dropCount - - while ( - end > 0 && - remainingToDrop > 0 && - logs[end - 1]!.timestampUnix === timestamp - ) { - end -= 1 - remainingToDrop -= 1 - } - - return logs.slice(0, end) -} - const initialState: SandboxLogsState = { logs: [], hasMoreBackwards: true, From 82400a2650962ce3f50fe73f52e50494fb23791d Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Fri, 20 Feb 2026 15:22:44 -0800 Subject: [PATCH 28/54] fix: logs timestamp arrow showing --- src/features/dashboard/sandbox/logs/logs.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/features/dashboard/sandbox/logs/logs.tsx b/src/features/dashboard/sandbox/logs/logs.tsx index a495e9e94..44409e527 100644 --- a/src/features/dashboard/sandbox/logs/logs.tsx +++ b/src/features/dashboard/sandbox/logs/logs.tsx @@ -67,6 +67,7 @@ export default function SandboxLogs({ teamIdOrSlug, sandboxId }: LogsProps) { @@ -138,6 +139,7 @@ function LogsContent({ {showLoader && } From 7608d3b5d317b807ab68016b3c2a2999a8852b33 Mon Sep 17 00:00:00 2001 From: Ben Fornefeld Date: Mon, 23 Feb 2026 16:34:26 -0800 Subject: [PATCH 29/54] add: sandbox metrics api layer --- src/server/api/models/sandboxes.models.ts | 4 ++ .../api/repositories/sandboxes.repository.ts | 68 +++++++++++++++++++ src/server/api/routers/sandbox.ts | 33 ++++++++- 3 files changed, 103 insertions(+), 2 deletions(-) diff --git a/src/server/api/models/sandboxes.models.ts b/src/server/api/models/sandboxes.models.ts index cbf5ca1ab..9d3d673ef 100644 --- a/src/server/api/models/sandboxes.models.ts +++ b/src/server/api/models/sandboxes.models.ts @@ -13,6 +13,10 @@ export interface SandboxLogsDTO { nextCursor: number | null } +export interface SandboxMetricsDTO { + metrics: components['schemas']['SandboxMetric'][] +} + // mappings export function mapInfraSandboxLogToDTO( diff --git a/src/server/api/repositories/sandboxes.repository.ts b/src/server/api/repositories/sandboxes.repository.ts index d8937c417..4cf49eae3 100644 --- a/src/server/api/repositories/sandboxes.repository.ts +++ b/src/server/api/repositories/sandboxes.repository.ts @@ -3,6 +3,7 @@ import { infra } from '@/lib/clients/api' import { l } from '@/lib/clients/logger/logger' import { TRPCError } from '@trpc/server' import { apiError } from '../errors' +import { SandboxMetricsDTO } from '../models/sandboxes.models' // get sandbox logs @@ -64,6 +65,73 @@ export async function getSandboxLogs( return result.data } +// get sandbox metrics + +export interface GetSandboxMetricsOptions { + startUnixMs: number + endUnixMs: number +} + +export async function getSandboxMetrics( + accessToken: string, + teamId: string, + sandboxId: string, + options: GetSandboxMetricsOptions +) { + // convert milliseconds to seconds for the API + const startUnixSeconds = Math.floor(options.startUnixMs / 1000) + const endUnixSeconds = Math.floor(options.endUnixMs / 1000) + + const result = await infra.GET('/sandboxes/{sandboxID}/metrics', { + params: { + path: { + sandboxID: sandboxId, + }, + query: { + start: startUnixSeconds, + end: endUnixSeconds, + }, + }, + headers: { + ...SUPABASE_AUTH_HEADERS(accessToken, teamId), + }, + }) + + if (!result.response.ok || result.error) { + const status = result.response.status + + l.error( + { + key: 'repositories:sandboxes:get_sandbox_metrics:infra_error', + error: result.error, + team_id: teamId, + context: { + status, + path: '/sandboxes/{sandboxID}/metrics', + sandbox_id: sandboxId, + }, + }, + `failed to fetch /sandboxes/{sandboxID}/metrics: ${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) + } + + const dto: SandboxMetricsDTO = { + metrics: result.data, + } + + return dto +} + export const sandboxesRepo = { getSandboxLogs, + getSandboxMetrics, } diff --git a/src/server/api/routers/sandbox.ts b/src/server/api/routers/sandbox.ts index 728ce615c..865c4ea73 100644 --- a/src/server/api/routers/sandbox.ts +++ b/src/server/api/routers/sandbox.ts @@ -1,3 +1,4 @@ +import { SandboxIdSchema } from '@/lib/schemas/api' import { z } from 'zod' import { createTRPCRouter } from '../init' import { @@ -14,7 +15,7 @@ export const sandboxRouter = createTRPCRouter({ logsBackwardsReversed: protectedTeamProcedure .input( z.object({ - sandboxId: z.string(), + sandboxId: SandboxIdSchema, cursor: z.number().optional(), }) ) @@ -54,7 +55,7 @@ export const sandboxRouter = createTRPCRouter({ logsForward: protectedTeamProcedure .input( z.object({ - sandboxId: z.string(), + sandboxId: SandboxIdSchema, cursor: z.number().optional(), }) ) @@ -90,5 +91,33 @@ export const sandboxRouter = createTRPCRouter({ return result }), + resourceMetrics: protectedTeamProcedure + .input( + z.object({ + sandboxId: SandboxIdSchema, + startMs: z.number(), + endMs: z.number(), + }) + ) + .query(async ({ ctx, input }) => { + const { teamId, session } = ctx + const { sandboxId } = input + const { startMs, endMs } = input + + const metrics = await sandboxesRepo.getSandboxMetrics( + session.access_token, + teamId, + sandboxId, + { + startUnixMs: startMs, + endUnixMs: endMs, + } + ) + + return { + metrics, + } + }), + // MUTATIONS }) From 910cc3def95129b7822438d5ea4c0074fe6deed8 Mon Sep 17 00:00:00 2001 From: Ben Fornefeld Date: Mon, 23 Feb 2026 17:51:09 -0800 Subject: [PATCH 30/54] add: sandbox metrics chart + base hooks --- src/configs/intervals.ts | 2 + .../charts/sandbox-metrics-chart/index.tsx | 301 ++++++++++++++++++ .../charts/sandbox-metrics-chart/types.ts | 20 ++ .../metrics/hooks/use-sandbox-metrics.ts | 34 ++ .../sandbox/metrics/hooks/use-timeframe.ts | 187 +++++++++++ .../dashboard/sandbox/metrics/utils.ts | 62 ++++ src/server/api/models/sandboxes.models.ts | 4 +- 7 files changed, 609 insertions(+), 1 deletion(-) create mode 100644 src/features/dashboard/sandbox/metrics/charts/sandbox-metrics-chart/index.tsx create mode 100644 src/features/dashboard/sandbox/metrics/charts/sandbox-metrics-chart/types.ts create mode 100644 src/features/dashboard/sandbox/metrics/hooks/use-sandbox-metrics.ts create mode 100644 src/features/dashboard/sandbox/metrics/hooks/use-timeframe.ts create mode 100644 src/features/dashboard/sandbox/metrics/utils.ts diff --git a/src/configs/intervals.ts b/src/configs/intervals.ts index f6fa9a02e..d4faaf7c2 100644 --- a/src/configs/intervals.ts +++ b/src/configs/intervals.ts @@ -12,3 +12,5 @@ export const TEAM_METRICS_TIMEFRAME_UPDATE_MS = 10_000 // backend metrics collection interval export const TEAM_METRICS_BACKEND_COLLECTION_INTERVAL_MS = 30_000 + +export const SINGLE_SANDBOX_RESOURCE_METRICS_TIMEFRAME_UPDATE_MS = 10_000 diff --git a/src/features/dashboard/sandbox/metrics/charts/sandbox-metrics-chart/index.tsx b/src/features/dashboard/sandbox/metrics/charts/sandbox-metrics-chart/index.tsx new file mode 100644 index 000000000..33cad2cb9 --- /dev/null +++ b/src/features/dashboard/sandbox/metrics/charts/sandbox-metrics-chart/index.tsx @@ -0,0 +1,301 @@ +'use client' + +import { useCssVars } from '@/lib/hooks/use-css-vars' +import { calculateAxisMax } from '@/lib/utils/chart' +import { formatAxisNumber } from '@/lib/utils/formatting' +import { EChartsOption, SeriesOption } from 'echarts' +import ReactEChartsCore from 'echarts-for-react/lib/core' +import { LineChart } from 'echarts/charts' +import { + BrushComponent, + GridComponent, + ToolboxComponent, + TooltipComponent, +} from 'echarts/components' +import * as echarts from 'echarts/core' +import { CanvasRenderer } from 'echarts/renderers' +import { useTheme } from 'next-themes' +import { memo, useCallback, useMemo, useRef } from 'react' +import type { + SandboxMetricsChartConfig, + SandboxMetricsChartProps, + SandboxMetricsChartType, +} from './types' + +export const SANDBOX_METRICS_CHART_CONFIG: Record< + SandboxMetricsChartType, + SandboxMetricsChartConfig +> = { + cpu: { + id: 'cpu-metric', + lineColorVar: '--bg-inverted', + yAxisScaleFactor: 1.8, + yAxisFormatter: formatAxisNumber, + }, + ram: { + id: 'ram-metric', + lineColorVar: '--bg-inverted', + yAxisScaleFactor: 1.8, + yAxisFormatter: formatAxisNumber, + }, + disk: { + id: 'disk-metric', + lineColorVar: '--bg-inverted', + yAxisScaleFactor: 1.8, + yAxisFormatter: formatAxisNumber, + }, +} as const + +echarts.use([ + LineChart, + GridComponent, + TooltipComponent, + BrushComponent, + CanvasRenderer, + ToolboxComponent, +]) + +function SandboxMetricsChart({ + type, + data, + className, + onBrushEnd, +}: SandboxMetricsChartProps) { + const chartRef = useRef(null) + const chartInstanceRef = useRef(null) + const { resolvedTheme } = useTheme() + + const onBrushEndRef = useRef(onBrushEnd) + + onBrushEndRef.current = onBrushEnd + + const config = SANDBOX_METRICS_CHART_CONFIG[type] + + const cssVars = useCssVars([ + config.lineColorVar, + '--stroke', + '--fg-tertiary', + '--bg-inverted', + '--font-mono', + ] as const) + + const barColor = cssVars[config.lineColorVar] || '#000' + const stroke = cssVars['--stroke'] || '#000' + const fgTertiary = cssVars['--fg-tertiary'] || '#666' + const bgInverted = cssVars['--bg-inverted'] || '#fff' + const fontMono = cssVars['--font-mono'] || 'monospace' + + const handleBrushEnd = useCallback( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (params: any) => { + const areas = params.areas + if (areas && areas.length > 0) { + const area = areas[0] + const coordRange = area.coordRange + + if (coordRange && coordRange.length === 2 && onBrushEndRef.current) { + const startIndex = coordRange[0] + const endIndex = coordRange[1] + + onBrushEndRef.current(startIndex, endIndex) + + chartInstanceRef.current?.dispatchAction({ + type: 'brush', + command: 'clear', + areas: [], + }) + } + } + }, + [] + ) + + const handleChartReady = useCallback((chart: echarts.ECharts) => { + chartInstanceRef.current = chart + + // activate brush selection mode + chart.dispatchAction( + { + type: 'takeGlobalCursor', + key: 'brush', + brushOption: { + brushType: 'lineX', + brushMode: 'single', + }, + }, + { flush: true } + ) + + chart.group = 'usage' + echarts.connect('usage') + }, []) + + const option = useMemo(() => { + const yAxisMax = calculateAxisMax( + data.map((d) => d.y), + config.yAxisScaleFactor + ) + + const seriesItem: SeriesOption = { + id: config.id, + type: 'bar', + itemStyle: { + color: 'transparent', + borderColor: barColor, + borderWidth: 0.25, + borderCap: 'square', + opacity: 0.8, + decal: { + symbol: 'line', + symbolSize: 0.8, + rotation: -Math.PI / 4, + dashArrayX: [1, 0], + dashArrayY: [1, 1.5], + color: barColor, + }, + }, + barCategoryGap: '28%', + emphasis: { + itemStyle: { + opacity: 1, + }, + }, + data: data.map((d) => d.y), + } + + const series: EChartsOption['series'] = [seriesItem] + + return { + backgroundColor: 'transparent', + animation: false, + toolbox: { + show: false, + }, + brush: { + brushType: 'lineX', + brushMode: 'single', + xAxisIndex: 0, + brushLink: 'all', + brushStyle: { + color: bgInverted, + opacity: 0.2, + borderType: 'solid', + borderWidth: 1, + borderColor: bgInverted, + }, + inBrush: { + opacity: 1, + }, + outOfBrush: { + color: 'transparent', + opacity: 0.6, + }, + }, + grid: { + top: 10, + bottom: 20, + left: 0, + right: 0, + }, + xAxis: [ + { + type: 'category', + data: data.map((d) => d.x), + axisPointer: { + show: true, + type: 'line', + lineStyle: { color: stroke, type: 'solid', width: 1 }, + snap: false, + }, + axisLine: { + show: true, + lineStyle: { color: stroke }, + }, + axisTick: { show: false }, + splitLine: { show: false }, + axisLabel: { + show: true, + color: fgTertiary, + fontFamily: fontMono, + fontSize: 12, + showMinLabel: true, + showMaxLabel: true, + alignMinLabel: 'center', + alignMaxLabel: 'center', + formatter: (value: string, index: number) => { + const isStartOfPeriod = index === 0 + const isEndOfPeriod = index === data.length - 1 + + if (isStartOfPeriod || isEndOfPeriod) { + return value + } + return '' + }, + }, + }, + ], + yAxis: [ + { + type: 'value', + min: 0, + max: yAxisMax, + interval: yAxisMax / 2, + axisLine: { + show: false, + }, + axisTick: { show: false }, + splitLine: { + show: true, + lineStyle: { color: stroke, type: 'dashed' }, + interval: 0, + }, + axisLabel: { + show: true, + color: fgTertiary, + fontFamily: fontMono, + fontSize: 12, + interval: 0, + formatter: config.yAxisFormatter, + }, + axisPointer: { + show: false, + }, + }, + ], + series, + } + }, [data, config, barColor, bgInverted, stroke, fgTertiary, fontMono]) + + return ( + + ) +} + +const MemoizedSandboxMetricsChart = memo( + SandboxMetricsChart, + (prevProps, nextProps) => { + return ( + prevProps.type === nextProps.type && + prevProps.data === nextProps.data && + prevProps.className === nextProps.className + // exclude onHover and onHoverEnd - they're handled via refs + ) + } +) + +MemoizedSandboxMetricsChart.displayName = 'SandboxMetricsChart' + +export default MemoizedSandboxMetricsChart diff --git a/src/features/dashboard/sandbox/metrics/charts/sandbox-metrics-chart/types.ts b/src/features/dashboard/sandbox/metrics/charts/sandbox-metrics-chart/types.ts new file mode 100644 index 000000000..0c5b81532 --- /dev/null +++ b/src/features/dashboard/sandbox/metrics/charts/sandbox-metrics-chart/types.ts @@ -0,0 +1,20 @@ +export type SandboxMetricsDataPoint = { + x: number // timestamp + y: number // value +} + +export type SandboxMetricsChartType = 'cpu' | 'ram' | 'disk' + +export interface SandboxMetricsChartConfig { + id: string + lineColorVar: string + yAxisScaleFactor: number + yAxisFormatter: (value: number) => string +} + +export interface SandboxMetricsChartProps { + type: SandboxMetricsChartType + data: SandboxMetricsDataPoint[] + className?: string + onBrushEnd?: (startIndex: number, endIndex: number) => void +} diff --git a/src/features/dashboard/sandbox/metrics/hooks/use-sandbox-metrics.ts b/src/features/dashboard/sandbox/metrics/hooks/use-sandbox-metrics.ts new file mode 100644 index 000000000..cf60ad49c --- /dev/null +++ b/src/features/dashboard/sandbox/metrics/hooks/use-sandbox-metrics.ts @@ -0,0 +1,34 @@ +import { useDashboard } from '@/features/dashboard/context' +import { useTimeframe } from '@/features/dashboard/sandboxes/monitoring/hooks/use-timeframe' +import { useTRPC } from '@/trpc/client' +import { keepPreviousData, useQuery } from '@tanstack/react-query' + +export function useSandboxMetrics(sandboxId: string) { + const trpc = useTRPC() + const { team } = useDashboard() + const { timeframe, setTimeRange, setCustomRange } = useTimeframe() + + const { data, error, isLoading } = useQuery( + trpc.sandbox.resourceMetrics.queryOptions( + { + teamIdOrSlug: team.id, + sandboxId, + startMs: timeframe.start, + endMs: timeframe.end, + }, + { + refetchOnWindowFocus: false, + placeholderData: keepPreviousData, + } + ) + ) + + return { + data, + error, + isLoading, + + setTimeRange, + setCustomRange, + } +} diff --git a/src/features/dashboard/sandbox/metrics/hooks/use-timeframe.ts b/src/features/dashboard/sandbox/metrics/hooks/use-timeframe.ts new file mode 100644 index 000000000..e3c30d0f3 --- /dev/null +++ b/src/features/dashboard/sandbox/metrics/hooks/use-timeframe.ts @@ -0,0 +1,187 @@ +'use client' + +import { + SINGLE_SANDBOX_RESOURCE_METRICS_TIMEFRAME_UPDATE_MS, + TEAM_METRICS_INITIAL_RANGE_MS, +} from '@/configs/intervals' +import { TIME_RANGES, TimeRangeKey } from '@/lib/utils/timeframe' +import { parseAsInteger, useQueryStates } from 'nuqs' +import { useCallback, useEffect, useMemo, useRef } from 'react' +import { calculateIsLive } from '../utils' + +const MAX_DAYS_AGO = 31 * 24 * 60 * 60 * 1000 +const MIN_RANGE_MS = 1.5 * 60 * 1000 + +const getStableNow = () => { + const now = Date.now() + return Math.floor(now / 1_000) * 1_000 +} + +function validateTimeRange( + start: number, + end: number +): { start: number; end: number } { + const now = getStableNow() + + let validStart = Math.floor(start) + let validEnd = Math.floor(end) + + if (validStart < now - MAX_DAYS_AGO) { + validStart = now - MAX_DAYS_AGO + } + + if (validEnd > now) { + validEnd = now + } + + const range = validEnd - validStart + if (range < MIN_RANGE_MS) { + const midpoint = (validStart + validEnd) / 2 + validStart = Math.floor(midpoint - MIN_RANGE_MS / 2) + validEnd = Math.floor(midpoint + MIN_RANGE_MS / 2) + + if (validEnd > now) { + validEnd = now + validStart = validEnd - MIN_RANGE_MS + } + if (validStart < now - MAX_DAYS_AGO) { + validStart = now - MAX_DAYS_AGO + validEnd = validStart + MIN_RANGE_MS + } + } + + return { start: validStart, end: validEnd } +} + +const timeframeParams = { + start: parseAsInteger, + end: parseAsInteger, +} + +export const useTimeframe = () => { + const initialNowRef = useRef(getStableNow()) + const lastUpdateRef = useRef(0) + + const [params, setParams] = useQueryStates(timeframeParams, { + history: 'push', + shallow: true, + }) + + const start = useMemo( + () => params.start ?? initialNowRef.current - TEAM_METRICS_INITIAL_RANGE_MS, + [params.start] + ) + const end = useMemo(() => params.end ?? initialNowRef.current, [params.end]) + + // calculate isLive at the time params were set, not at render time + const timeframe = useMemo(() => { + const duration = end - start + const now = getStableNow() + const isLive = calculateIsLive(start, end, now) + + return { + start, + end, + isLive, + duration, + } + }, [start, end]) + + const setTimeRange = useCallback( + (range: TimeRangeKey) => { + const rangeMs = TIME_RANGES[range] + const now = getStableNow() + const validated = validateTimeRange(now - rangeMs, now) + setParams(validated) + lastUpdateRef.current = now + }, + [setParams] + ) + + const setCustomRange = useCallback( + (start: number, end: number) => { + const validated = validateTimeRange(start, end) + setParams(validated) + lastUpdateRef.current = Date.now() + }, + [setParams] + ) + + // stable ref for the update function to prevent interval restarts + const updateTimeframeRef = useRef<(() => void) | null>(null) + updateTimeframeRef.current = () => { + const now = getStableNow() + + // prevent updates faster than the interval + const timeSinceLastUpdate = now - lastUpdateRef.current + if ( + timeSinceLastUpdate < + SINGLE_SANDBOX_RESOURCE_METRICS_TIMEFRAME_UPDATE_MS - 100 + ) { + return + } + + const duration = timeframe.duration + const validated = validateTimeRange(now - duration, now) + + // only update if values actually changed + if (validated.start !== start || validated.end !== end) { + setParams(validated) + lastUpdateRef.current = now + } + } + + useEffect(() => { + if (!timeframe.isLive) return + + let intervalId: NodeJS.Timeout | null = null + let isVisible = !document.hidden + + const startInterval = () => { + if (intervalId) return + intervalId = setInterval(() => { + updateTimeframeRef.current?.() + }, SINGLE_SANDBOX_RESOURCE_METRICS_TIMEFRAME_UPDATE_MS) + } + + const stopInterval = () => { + if (intervalId) { + clearInterval(intervalId) + intervalId = null + } + } + + const handleVisibilityChange = () => { + const nowVisible = !document.hidden + + if (nowVisible && !isVisible) { + // tab became visible - do immediate update then start interval + isVisible = true + updateTimeframeRef.current?.() + startInterval() + } else if (!nowVisible && isVisible) { + // tab became hidden - stop interval + isVisible = false + stopInterval() + } + } + + // start interval if visible + if (isVisible) { + startInterval() + } + + document.addEventListener('visibilitychange', handleVisibilityChange) + + return () => { + stopInterval() + document.removeEventListener('visibilitychange', handleVisibilityChange) + } + }, [timeframe.isLive]) + + return { + timeframe, + setTimeRange, + setCustomRange, + } +} diff --git a/src/features/dashboard/sandbox/metrics/utils.ts b/src/features/dashboard/sandbox/metrics/utils.ts new file mode 100644 index 000000000..2f5a86cc3 --- /dev/null +++ b/src/features/dashboard/sandbox/metrics/utils.ts @@ -0,0 +1,62 @@ +export function calculateStepForRange(startMs: number, endMs: number): number { + const duration = endMs - startMs + return calculateStepForDuration(duration) +} + +// this function comes from e2b-dev/infra and is used to calculate the step for a given duration for /teams/$teamId/metrics +export function calculateStepForDuration(durationMs: number): number { + const hour = 60 * 60 * 1000 + const minute = 60 * 1000 + const second = 1000 + + switch (true) { + case durationMs < hour: + return 5 * second + case durationMs < 6 * hour: + return 30 * second + case durationMs < 12 * hour: + return minute + case durationMs < 24 * hour: + return 2 * minute + case durationMs < 7 * 24 * hour: + return 5 * minute + default: + return 15 * minute + } +} + +// TIMEFRAME LIVE STATE CALCULATION + +const LIVE_THRESHOLD_PERCENT = 0.02 +const LIVE_THRESHOLD_MIN_MS = 2_000 + +/** + * Determines if a timeframe should be considered "live" based on how + * recent the end timestamp is relative to current time. + * + * A timeframe is considered live if the end timestamp is within a threshold + * of the current time. The threshold is the maximum of: + * - 2% of the timeframe duration + * - 60 seconds (minimum threshold) + * + * @param start - Start timestamp in milliseconds (or null) + * @param end - End timestamp in milliseconds (or null) + * @param now - Current timestamp in milliseconds (defaults to Date.now()) + * @returns True if the timeframe should be considered live + */ +export function calculateIsLive( + start: number | null, + end: number | null, + now: number = Date.now() +): boolean { + // default to live if params missing + if (!start || !end) return true + + const duration = end - start + const threshold = Math.max( + duration * LIVE_THRESHOLD_PERCENT, + LIVE_THRESHOLD_MIN_MS + ) + + return now - end < threshold +} diff --git a/src/server/api/models/sandboxes.models.ts b/src/server/api/models/sandboxes.models.ts index 9d3d673ef..c8a7b7c49 100644 --- a/src/server/api/models/sandboxes.models.ts +++ b/src/server/api/models/sandboxes.models.ts @@ -13,8 +13,10 @@ export interface SandboxLogsDTO { nextCursor: number | null } +export type SandboxMetric = components['schemas']['SandboxMetric'] + export interface SandboxMetricsDTO { - metrics: components['schemas']['SandboxMetric'][] + metrics: SandboxMetric[] } // mappings From 752287da96b46905c8301110be0322453f58a48d Mon Sep 17 00:00:00 2001 From: Ben Fornefeld Date: Tue, 24 Feb 2026 13:38:37 -0800 Subject: [PATCH 31/54] wip: monitoring page + components --- .../[sandboxId]/monitoring/loading.tsx | 1 + .../sandboxes/[sandboxId]/monitoring/page.tsx | 11 + src/configs/urls.ts | 2 + src/features/dashboard/sandbox/layout.tsx | 10 +- src/features/dashboard/sandbox/logs/logs.tsx | 4 +- .../charts/sandbox-metrics-chart/index.tsx | 301 ------------- .../charts/sandbox-metrics-chart/types.ts | 20 - .../metrics/hooks/use-sandbox-metrics.ts | 34 -- .../sandbox/metrics/hooks/use-timeframe.ts | 187 -------- .../monitoring/charts/chart-section.tsx | 21 + .../monitoring/charts/disk-chart-header.tsx | 67 +++ .../sandbox/monitoring/charts/index.tsx | 136 ++++++ .../charts/resource-chart-header.tsx | 113 +++++ .../charts/sandbox-metrics-chart/index.tsx | 426 ++++++++++++++++++ .../charts/sandbox-metrics-chart/types.ts | 27 ++ .../sandbox/monitoring/charts/utils.ts | 160 +++++++ .../use-sandbox-monitoring-controller.ts | 136 ++++++ .../dashboard/sandbox/monitoring/store.ts | 160 +++++++ .../sandbox/{metrics => monitoring}/utils.ts | 0 .../dashboard/sandbox/monitoring/view.tsx | 17 + .../dashboard/sandboxes/list/table-row.tsx | 2 +- .../api/repositories/sandboxes.repository.ts | 7 +- src/server/api/routers/sandbox.ts | 4 +- 23 files changed, 1290 insertions(+), 556 deletions(-) create mode 100644 src/app/dashboard/[teamIdOrSlug]/sandboxes/[sandboxId]/monitoring/loading.tsx create mode 100644 src/app/dashboard/[teamIdOrSlug]/sandboxes/[sandboxId]/monitoring/page.tsx delete mode 100644 src/features/dashboard/sandbox/metrics/charts/sandbox-metrics-chart/index.tsx delete mode 100644 src/features/dashboard/sandbox/metrics/charts/sandbox-metrics-chart/types.ts delete mode 100644 src/features/dashboard/sandbox/metrics/hooks/use-sandbox-metrics.ts delete mode 100644 src/features/dashboard/sandbox/metrics/hooks/use-timeframe.ts create mode 100644 src/features/dashboard/sandbox/monitoring/charts/chart-section.tsx create mode 100644 src/features/dashboard/sandbox/monitoring/charts/disk-chart-header.tsx create mode 100644 src/features/dashboard/sandbox/monitoring/charts/index.tsx create mode 100644 src/features/dashboard/sandbox/monitoring/charts/resource-chart-header.tsx create mode 100644 src/features/dashboard/sandbox/monitoring/charts/sandbox-metrics-chart/index.tsx create mode 100644 src/features/dashboard/sandbox/monitoring/charts/sandbox-metrics-chart/types.ts create mode 100644 src/features/dashboard/sandbox/monitoring/charts/utils.ts create mode 100644 src/features/dashboard/sandbox/monitoring/hooks/use-sandbox-monitoring-controller.ts create mode 100644 src/features/dashboard/sandbox/monitoring/store.ts rename src/features/dashboard/sandbox/{metrics => monitoring}/utils.ts (100%) create mode 100644 src/features/dashboard/sandbox/monitoring/view.tsx diff --git a/src/app/dashboard/[teamIdOrSlug]/sandboxes/[sandboxId]/monitoring/loading.tsx b/src/app/dashboard/[teamIdOrSlug]/sandboxes/[sandboxId]/monitoring/loading.tsx new file mode 100644 index 000000000..249f11404 --- /dev/null +++ b/src/app/dashboard/[teamIdOrSlug]/sandboxes/[sandboxId]/monitoring/loading.tsx @@ -0,0 +1 @@ +export { default } from '@/features/dashboard/loading-layout' diff --git a/src/app/dashboard/[teamIdOrSlug]/sandboxes/[sandboxId]/monitoring/page.tsx b/src/app/dashboard/[teamIdOrSlug]/sandboxes/[sandboxId]/monitoring/page.tsx new file mode 100644 index 000000000..35caf6c42 --- /dev/null +++ b/src/app/dashboard/[teamIdOrSlug]/sandboxes/[sandboxId]/monitoring/page.tsx @@ -0,0 +1,11 @@ +import SandboxMonitoringView from '@/features/dashboard/sandbox/monitoring/view' + +export default async function SandboxMonitoringPage({ + params, +}: { + params: Promise<{ teamIdOrSlug: string; sandboxId: string }> +}) { + const { sandboxId } = await params + + return +} diff --git a/src/configs/urls.ts b/src/configs/urls.ts index 972f17244..67e49bd88 100644 --- a/src/configs/urls.ts +++ b/src/configs/urls.ts @@ -30,6 +30,8 @@ export const PROTECTED_URLS = { SANDBOX: (teamIdOrSlug: string, sandboxId: string) => `/dashboard/${teamIdOrSlug}/sandboxes/${sandboxId}/logs`, + SANDBOX_MONITORING: (teamIdOrSlug: string, sandboxId: string) => + `/dashboard/${teamIdOrSlug}/sandboxes/${sandboxId}/monitoring`, SANDBOX_FILESYSTEM: (teamIdOrSlug: string, sandboxId: string) => `/dashboard/${teamIdOrSlug}/sandboxes/${sandboxId}/filesystem`, SANDBOX_LOGS: (teamIdOrSlug: string, sandboxId: string) => diff --git a/src/features/dashboard/sandbox/layout.tsx b/src/features/dashboard/sandbox/layout.tsx index c60e5d6ff..dfe7109b9 100644 --- a/src/features/dashboard/sandbox/layout.tsx +++ b/src/features/dashboard/sandbox/layout.tsx @@ -3,7 +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 { ListIcon, StorageIcon, TrendIcon } from '@/ui/primitives/icons' import { notFound } from 'next/navigation' import { useSandboxContext } from './context' import SandboxInspectIncompatible from './inspect/incompatible' @@ -53,6 +53,14 @@ export default function SandboxLayout({ > {children}
+ } + > + {children} + - ) + return } interface VirtualizedLogsBodyProps { diff --git a/src/features/dashboard/sandbox/metrics/charts/sandbox-metrics-chart/index.tsx b/src/features/dashboard/sandbox/metrics/charts/sandbox-metrics-chart/index.tsx deleted file mode 100644 index 33cad2cb9..000000000 --- a/src/features/dashboard/sandbox/metrics/charts/sandbox-metrics-chart/index.tsx +++ /dev/null @@ -1,301 +0,0 @@ -'use client' - -import { useCssVars } from '@/lib/hooks/use-css-vars' -import { calculateAxisMax } from '@/lib/utils/chart' -import { formatAxisNumber } from '@/lib/utils/formatting' -import { EChartsOption, SeriesOption } from 'echarts' -import ReactEChartsCore from 'echarts-for-react/lib/core' -import { LineChart } from 'echarts/charts' -import { - BrushComponent, - GridComponent, - ToolboxComponent, - TooltipComponent, -} from 'echarts/components' -import * as echarts from 'echarts/core' -import { CanvasRenderer } from 'echarts/renderers' -import { useTheme } from 'next-themes' -import { memo, useCallback, useMemo, useRef } from 'react' -import type { - SandboxMetricsChartConfig, - SandboxMetricsChartProps, - SandboxMetricsChartType, -} from './types' - -export const SANDBOX_METRICS_CHART_CONFIG: Record< - SandboxMetricsChartType, - SandboxMetricsChartConfig -> = { - cpu: { - id: 'cpu-metric', - lineColorVar: '--bg-inverted', - yAxisScaleFactor: 1.8, - yAxisFormatter: formatAxisNumber, - }, - ram: { - id: 'ram-metric', - lineColorVar: '--bg-inverted', - yAxisScaleFactor: 1.8, - yAxisFormatter: formatAxisNumber, - }, - disk: { - id: 'disk-metric', - lineColorVar: '--bg-inverted', - yAxisScaleFactor: 1.8, - yAxisFormatter: formatAxisNumber, - }, -} as const - -echarts.use([ - LineChart, - GridComponent, - TooltipComponent, - BrushComponent, - CanvasRenderer, - ToolboxComponent, -]) - -function SandboxMetricsChart({ - type, - data, - className, - onBrushEnd, -}: SandboxMetricsChartProps) { - const chartRef = useRef(null) - const chartInstanceRef = useRef(null) - const { resolvedTheme } = useTheme() - - const onBrushEndRef = useRef(onBrushEnd) - - onBrushEndRef.current = onBrushEnd - - const config = SANDBOX_METRICS_CHART_CONFIG[type] - - const cssVars = useCssVars([ - config.lineColorVar, - '--stroke', - '--fg-tertiary', - '--bg-inverted', - '--font-mono', - ] as const) - - const barColor = cssVars[config.lineColorVar] || '#000' - const stroke = cssVars['--stroke'] || '#000' - const fgTertiary = cssVars['--fg-tertiary'] || '#666' - const bgInverted = cssVars['--bg-inverted'] || '#fff' - const fontMono = cssVars['--font-mono'] || 'monospace' - - const handleBrushEnd = useCallback( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (params: any) => { - const areas = params.areas - if (areas && areas.length > 0) { - const area = areas[0] - const coordRange = area.coordRange - - if (coordRange && coordRange.length === 2 && onBrushEndRef.current) { - const startIndex = coordRange[0] - const endIndex = coordRange[1] - - onBrushEndRef.current(startIndex, endIndex) - - chartInstanceRef.current?.dispatchAction({ - type: 'brush', - command: 'clear', - areas: [], - }) - } - } - }, - [] - ) - - const handleChartReady = useCallback((chart: echarts.ECharts) => { - chartInstanceRef.current = chart - - // activate brush selection mode - chart.dispatchAction( - { - type: 'takeGlobalCursor', - key: 'brush', - brushOption: { - brushType: 'lineX', - brushMode: 'single', - }, - }, - { flush: true } - ) - - chart.group = 'usage' - echarts.connect('usage') - }, []) - - const option = useMemo(() => { - const yAxisMax = calculateAxisMax( - data.map((d) => d.y), - config.yAxisScaleFactor - ) - - const seriesItem: SeriesOption = { - id: config.id, - type: 'bar', - itemStyle: { - color: 'transparent', - borderColor: barColor, - borderWidth: 0.25, - borderCap: 'square', - opacity: 0.8, - decal: { - symbol: 'line', - symbolSize: 0.8, - rotation: -Math.PI / 4, - dashArrayX: [1, 0], - dashArrayY: [1, 1.5], - color: barColor, - }, - }, - barCategoryGap: '28%', - emphasis: { - itemStyle: { - opacity: 1, - }, - }, - data: data.map((d) => d.y), - } - - const series: EChartsOption['series'] = [seriesItem] - - return { - backgroundColor: 'transparent', - animation: false, - toolbox: { - show: false, - }, - brush: { - brushType: 'lineX', - brushMode: 'single', - xAxisIndex: 0, - brushLink: 'all', - brushStyle: { - color: bgInverted, - opacity: 0.2, - borderType: 'solid', - borderWidth: 1, - borderColor: bgInverted, - }, - inBrush: { - opacity: 1, - }, - outOfBrush: { - color: 'transparent', - opacity: 0.6, - }, - }, - grid: { - top: 10, - bottom: 20, - left: 0, - right: 0, - }, - xAxis: [ - { - type: 'category', - data: data.map((d) => d.x), - axisPointer: { - show: true, - type: 'line', - lineStyle: { color: stroke, type: 'solid', width: 1 }, - snap: false, - }, - axisLine: { - show: true, - lineStyle: { color: stroke }, - }, - axisTick: { show: false }, - splitLine: { show: false }, - axisLabel: { - show: true, - color: fgTertiary, - fontFamily: fontMono, - fontSize: 12, - showMinLabel: true, - showMaxLabel: true, - alignMinLabel: 'center', - alignMaxLabel: 'center', - formatter: (value: string, index: number) => { - const isStartOfPeriod = index === 0 - const isEndOfPeriod = index === data.length - 1 - - if (isStartOfPeriod || isEndOfPeriod) { - return value - } - return '' - }, - }, - }, - ], - yAxis: [ - { - type: 'value', - min: 0, - max: yAxisMax, - interval: yAxisMax / 2, - axisLine: { - show: false, - }, - axisTick: { show: false }, - splitLine: { - show: true, - lineStyle: { color: stroke, type: 'dashed' }, - interval: 0, - }, - axisLabel: { - show: true, - color: fgTertiary, - fontFamily: fontMono, - fontSize: 12, - interval: 0, - formatter: config.yAxisFormatter, - }, - axisPointer: { - show: false, - }, - }, - ], - series, - } - }, [data, config, barColor, bgInverted, stroke, fgTertiary, fontMono]) - - return ( - - ) -} - -const MemoizedSandboxMetricsChart = memo( - SandboxMetricsChart, - (prevProps, nextProps) => { - return ( - prevProps.type === nextProps.type && - prevProps.data === nextProps.data && - prevProps.className === nextProps.className - // exclude onHover and onHoverEnd - they're handled via refs - ) - } -) - -MemoizedSandboxMetricsChart.displayName = 'SandboxMetricsChart' - -export default MemoizedSandboxMetricsChart diff --git a/src/features/dashboard/sandbox/metrics/charts/sandbox-metrics-chart/types.ts b/src/features/dashboard/sandbox/metrics/charts/sandbox-metrics-chart/types.ts deleted file mode 100644 index 0c5b81532..000000000 --- a/src/features/dashboard/sandbox/metrics/charts/sandbox-metrics-chart/types.ts +++ /dev/null @@ -1,20 +0,0 @@ -export type SandboxMetricsDataPoint = { - x: number // timestamp - y: number // value -} - -export type SandboxMetricsChartType = 'cpu' | 'ram' | 'disk' - -export interface SandboxMetricsChartConfig { - id: string - lineColorVar: string - yAxisScaleFactor: number - yAxisFormatter: (value: number) => string -} - -export interface SandboxMetricsChartProps { - type: SandboxMetricsChartType - data: SandboxMetricsDataPoint[] - className?: string - onBrushEnd?: (startIndex: number, endIndex: number) => void -} diff --git a/src/features/dashboard/sandbox/metrics/hooks/use-sandbox-metrics.ts b/src/features/dashboard/sandbox/metrics/hooks/use-sandbox-metrics.ts deleted file mode 100644 index cf60ad49c..000000000 --- a/src/features/dashboard/sandbox/metrics/hooks/use-sandbox-metrics.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { useDashboard } from '@/features/dashboard/context' -import { useTimeframe } from '@/features/dashboard/sandboxes/monitoring/hooks/use-timeframe' -import { useTRPC } from '@/trpc/client' -import { keepPreviousData, useQuery } from '@tanstack/react-query' - -export function useSandboxMetrics(sandboxId: string) { - const trpc = useTRPC() - const { team } = useDashboard() - const { timeframe, setTimeRange, setCustomRange } = useTimeframe() - - const { data, error, isLoading } = useQuery( - trpc.sandbox.resourceMetrics.queryOptions( - { - teamIdOrSlug: team.id, - sandboxId, - startMs: timeframe.start, - endMs: timeframe.end, - }, - { - refetchOnWindowFocus: false, - placeholderData: keepPreviousData, - } - ) - ) - - return { - data, - error, - isLoading, - - setTimeRange, - setCustomRange, - } -} diff --git a/src/features/dashboard/sandbox/metrics/hooks/use-timeframe.ts b/src/features/dashboard/sandbox/metrics/hooks/use-timeframe.ts deleted file mode 100644 index e3c30d0f3..000000000 --- a/src/features/dashboard/sandbox/metrics/hooks/use-timeframe.ts +++ /dev/null @@ -1,187 +0,0 @@ -'use client' - -import { - SINGLE_SANDBOX_RESOURCE_METRICS_TIMEFRAME_UPDATE_MS, - TEAM_METRICS_INITIAL_RANGE_MS, -} from '@/configs/intervals' -import { TIME_RANGES, TimeRangeKey } from '@/lib/utils/timeframe' -import { parseAsInteger, useQueryStates } from 'nuqs' -import { useCallback, useEffect, useMemo, useRef } from 'react' -import { calculateIsLive } from '../utils' - -const MAX_DAYS_AGO = 31 * 24 * 60 * 60 * 1000 -const MIN_RANGE_MS = 1.5 * 60 * 1000 - -const getStableNow = () => { - const now = Date.now() - return Math.floor(now / 1_000) * 1_000 -} - -function validateTimeRange( - start: number, - end: number -): { start: number; end: number } { - const now = getStableNow() - - let validStart = Math.floor(start) - let validEnd = Math.floor(end) - - if (validStart < now - MAX_DAYS_AGO) { - validStart = now - MAX_DAYS_AGO - } - - if (validEnd > now) { - validEnd = now - } - - const range = validEnd - validStart - if (range < MIN_RANGE_MS) { - const midpoint = (validStart + validEnd) / 2 - validStart = Math.floor(midpoint - MIN_RANGE_MS / 2) - validEnd = Math.floor(midpoint + MIN_RANGE_MS / 2) - - if (validEnd > now) { - validEnd = now - validStart = validEnd - MIN_RANGE_MS - } - if (validStart < now - MAX_DAYS_AGO) { - validStart = now - MAX_DAYS_AGO - validEnd = validStart + MIN_RANGE_MS - } - } - - return { start: validStart, end: validEnd } -} - -const timeframeParams = { - start: parseAsInteger, - end: parseAsInteger, -} - -export const useTimeframe = () => { - const initialNowRef = useRef(getStableNow()) - const lastUpdateRef = useRef(0) - - const [params, setParams] = useQueryStates(timeframeParams, { - history: 'push', - shallow: true, - }) - - const start = useMemo( - () => params.start ?? initialNowRef.current - TEAM_METRICS_INITIAL_RANGE_MS, - [params.start] - ) - const end = useMemo(() => params.end ?? initialNowRef.current, [params.end]) - - // calculate isLive at the time params were set, not at render time - const timeframe = useMemo(() => { - const duration = end - start - const now = getStableNow() - const isLive = calculateIsLive(start, end, now) - - return { - start, - end, - isLive, - duration, - } - }, [start, end]) - - const setTimeRange = useCallback( - (range: TimeRangeKey) => { - const rangeMs = TIME_RANGES[range] - const now = getStableNow() - const validated = validateTimeRange(now - rangeMs, now) - setParams(validated) - lastUpdateRef.current = now - }, - [setParams] - ) - - const setCustomRange = useCallback( - (start: number, end: number) => { - const validated = validateTimeRange(start, end) - setParams(validated) - lastUpdateRef.current = Date.now() - }, - [setParams] - ) - - // stable ref for the update function to prevent interval restarts - const updateTimeframeRef = useRef<(() => void) | null>(null) - updateTimeframeRef.current = () => { - const now = getStableNow() - - // prevent updates faster than the interval - const timeSinceLastUpdate = now - lastUpdateRef.current - if ( - timeSinceLastUpdate < - SINGLE_SANDBOX_RESOURCE_METRICS_TIMEFRAME_UPDATE_MS - 100 - ) { - return - } - - const duration = timeframe.duration - const validated = validateTimeRange(now - duration, now) - - // only update if values actually changed - if (validated.start !== start || validated.end !== end) { - setParams(validated) - lastUpdateRef.current = now - } - } - - useEffect(() => { - if (!timeframe.isLive) return - - let intervalId: NodeJS.Timeout | null = null - let isVisible = !document.hidden - - const startInterval = () => { - if (intervalId) return - intervalId = setInterval(() => { - updateTimeframeRef.current?.() - }, SINGLE_SANDBOX_RESOURCE_METRICS_TIMEFRAME_UPDATE_MS) - } - - const stopInterval = () => { - if (intervalId) { - clearInterval(intervalId) - intervalId = null - } - } - - const handleVisibilityChange = () => { - const nowVisible = !document.hidden - - if (nowVisible && !isVisible) { - // tab became visible - do immediate update then start interval - isVisible = true - updateTimeframeRef.current?.() - startInterval() - } else if (!nowVisible && isVisible) { - // tab became hidden - stop interval - isVisible = false - stopInterval() - } - } - - // start interval if visible - if (isVisible) { - startInterval() - } - - document.addEventListener('visibilitychange', handleVisibilityChange) - - return () => { - stopInterval() - document.removeEventListener('visibilitychange', handleVisibilityChange) - } - }, [timeframe.isLive]) - - return { - timeframe, - setTimeRange, - setCustomRange, - } -} diff --git a/src/features/dashboard/sandbox/monitoring/charts/chart-section.tsx b/src/features/dashboard/sandbox/monitoring/charts/chart-section.tsx new file mode 100644 index 000000000..522427f07 --- /dev/null +++ b/src/features/dashboard/sandbox/monitoring/charts/chart-section.tsx @@ -0,0 +1,21 @@ +import { cn } from '@/lib/utils' +import type { ReactNode } from 'react' + +interface MonitoringChartSectionProps { + children: ReactNode + className?: string + header?: ReactNode +} + +export default function MonitoringChartSection({ + children, + className, + header, +}: MonitoringChartSectionProps) { + return ( +
+ {header ? header : null} +
{children}
+
+ ) +} diff --git a/src/features/dashboard/sandbox/monitoring/charts/disk-chart-header.tsx b/src/features/dashboard/sandbox/monitoring/charts/disk-chart-header.tsx new file mode 100644 index 000000000..be5ffd991 --- /dev/null +++ b/src/features/dashboard/sandbox/monitoring/charts/disk-chart-header.tsx @@ -0,0 +1,67 @@ +'use client' + +import type { SandboxMetric } from '@/server/api/models/sandboxes.models' +import { StorageIcon } from '@/ui/primitives/icons' + +interface DiskChartHeaderProps { + metric?: SandboxMetric + hovered?: { + diskPercent: number | null + timestampMs: number + } | null +} + +function formatPercent(value: number | null) { + if (value === null || Number.isNaN(value)) { + return '--' + } + + return `${Math.round(value)}%` +} + +function formatDiskTotalGb(bytes: number) { + const gb = bytes / 1024 / 1024 / 1024 + const rounded = gb >= 10 ? gb.toFixed(0) : gb.toFixed(1) + return `${rounded.replace(/\.0$/, '')} GB` +} + +function formatHoverTimestamp(timestampMs: number) { + return new Intl.DateTimeFormat(undefined, { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }).format(new Date(timestampMs)) +} + +export default function DiskChartHeader({ metric, hovered }: DiskChartHeaderProps) { + const diskPercent = hovered + ? hovered.diskPercent + : metric + ? metric.diskTotal > 0 + ? (metric.diskUsed / metric.diskTotal) * 100 + : 0 + : 0 + + const diskTotalGb = formatDiskTotalGb(metric?.diskTotal ?? 0) + const contextLabel = hovered ? formatHoverTimestamp(hovered.timestampMs) : null + + return ( +
+
+ + + + DISK + + {`${formatPercent(diskPercent)} · ${diskTotalGb}`} + +
+ {contextLabel ? ( + + {contextLabel} + + ) : null} +
+ ) +} diff --git a/src/features/dashboard/sandbox/monitoring/charts/index.tsx b/src/features/dashboard/sandbox/monitoring/charts/index.tsx new file mode 100644 index 000000000..241dee3f1 --- /dev/null +++ b/src/features/dashboard/sandbox/monitoring/charts/index.tsx @@ -0,0 +1,136 @@ +'use client' + +import { useMemo, useState } from 'react' +import { useSandboxMonitoringController } from '../hooks/use-sandbox-monitoring-controller' +import MonitoringChartSection from './chart-section' +import DiskChartHeader from './disk-chart-header' +import ResourceChartHeader from './resource-chart-header' +import SandboxMetricsChart from './sandbox-metrics-chart' +import { + buildDiskSeries, + buildResourceSeries, + buildTimelineCategories, + filterSandboxMetricsByTimeRange, +} from './utils' + +interface SandboxMetricsChartsProps { + sandboxId: string +} + +export default function SandboxMetricsCharts({ + sandboxId, +}: SandboxMetricsChartsProps) { + const { metrics, timeframe, setTimeframe } = + useSandboxMonitoringController(sandboxId) + const [hoveredIndex, setHoveredIndex] = useState(null) + + const constrainedMetrics = useMemo( + () => + filterSandboxMetricsByTimeRange(metrics, timeframe.start, timeframe.end), + [metrics, timeframe.end, timeframe.start] + ) + const categories = useMemo( + () => buildTimelineCategories(timeframe.start, timeframe.end), + [timeframe.end, timeframe.start] + ) + + const resourceSeries = useMemo( + () => buildResourceSeries(constrainedMetrics, categories), + [categories, constrainedMetrics] + ) + const diskSeries = useMemo( + () => buildDiskSeries(constrainedMetrics, categories), + [categories, constrainedMetrics] + ) + const latestMetric = constrainedMetrics[constrainedMetrics.length - 1] + const hoveredTimestamp = + hoveredIndex !== null ? categories[hoveredIndex] : undefined + const hoveredCpuPercent = + hoveredIndex !== null + ? (resourceSeries.find((item) => item.id === 'cpu')?.data[hoveredIndex]?.y ?? + null) + : null + const hoveredRamPercent = + hoveredIndex !== null + ? (resourceSeries.find((item) => item.id === 'ram')?.data[hoveredIndex]?.y ?? + null) + : null + const hoveredDiskPercent = + hoveredIndex !== null + ? (diskSeries.find((item) => item.id === 'disk')?.data[hoveredIndex]?.y ?? + null) + : null + + const resourceHoveredContext = + hoveredTimestamp !== undefined + ? { + cpuPercent: hoveredCpuPercent, + ramPercent: hoveredRamPercent, + timestampMs: hoveredTimestamp, + } + : null + const diskHoveredContext = + hoveredTimestamp !== undefined + ? { + diskPercent: hoveredDiskPercent, + timestampMs: hoveredTimestamp, + } + : null + + const handleBrushEnd = (startTimestamp: number, endTimestamp: number) => { + if (startTimestamp === timeframe.start && endTimestamp === timeframe.end) { + return + } + + setHoveredIndex(null) + setTimeframe(startTimestamp, endTimestamp) + } + + return ( +
+ + } + > + setHoveredIndex(null)} + onBrushEnd={handleBrushEnd} + /> + + + + } + > + setHoveredIndex(null)} + onBrushEnd={handleBrushEnd} + /> + +
+ ) +} diff --git a/src/features/dashboard/sandbox/monitoring/charts/resource-chart-header.tsx b/src/features/dashboard/sandbox/monitoring/charts/resource-chart-header.tsx new file mode 100644 index 000000000..4221a7be5 --- /dev/null +++ b/src/features/dashboard/sandbox/monitoring/charts/resource-chart-header.tsx @@ -0,0 +1,113 @@ +'use client' + +import type { SandboxMetric } from '@/server/api/models/sandboxes.models' +import { CpuIcon, MemoryIcon } from '@/ui/primitives/icons' +import { cn } from '@/lib/utils' +import type { ReactNode } from 'react' + +interface ResourceChartHeaderProps { + metric?: SandboxMetric + hovered?: { + cpuPercent: number | null + ramPercent: number | null + timestampMs: number + } | null +} + +function formatPercent(value: number | null) { + if (value === null || Number.isNaN(value)) { + return '--' + } + + return `${Math.round(value)}%` +} + +function formatCores(value: number) { + const normalized = Math.max(0, Math.round(value)) + return `${normalized} ${normalized === 1 ? 'CORE' : 'CORES'}` +} + +function formatRamTotalGb(bytes: number) { + const gb = bytes / 1024 / 1024 / 1024 + const rounded = gb >= 10 ? gb.toFixed(0) : gb.toFixed(1) + return `${rounded.replace(/\.0$/, '')} GB` +} + +function formatHoverTimestamp(timestampMs: number) { + return new Intl.DateTimeFormat(undefined, { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }).format(new Date(timestampMs)) +} + +interface MetricItemProps { + label: string + value: string + indicatorClassName: string + icon: ReactNode +} + +function MetricItem({ + label, + value, + indicatorClassName, + icon, +}: MetricItemProps) { + return ( +
+ + {icon} + + {label} + + {value} + +
+ ) +} + +export default function ResourceChartHeader({ + metric, + hovered, +}: ResourceChartHeaderProps) { + const cpuPercent = hovered ? hovered.cpuPercent : (metric?.cpuUsedPct ?? 0) + const cpuValue = `${formatPercent(cpuPercent)} · ${formatCores(metric?.cpuCount ?? 0)}` + + const ramPercent = hovered + ? hovered.ramPercent + : metric + ? metric.memTotal > 0 + ? (metric.memUsed / metric.memTotal) * 100 + : 0 + : 0 + const ramTotalGb = formatRamTotalGb(metric?.memTotal ?? 0) + const ramValue = `${formatPercent(ramPercent)} · ${ramTotalGb}` + const contextLabel = hovered ? formatHoverTimestamp(hovered.timestampMs) : null + + return ( +
+
+ } + /> + + } + /> +
+ {contextLabel ? ( + + {contextLabel} + + ) : null} +
+ ) +} diff --git a/src/features/dashboard/sandbox/monitoring/charts/sandbox-metrics-chart/index.tsx b/src/features/dashboard/sandbox/monitoring/charts/sandbox-metrics-chart/index.tsx new file mode 100644 index 000000000..634c055fb --- /dev/null +++ b/src/features/dashboard/sandbox/monitoring/charts/sandbox-metrics-chart/index.tsx @@ -0,0 +1,426 @@ +'use client' + +import { useCssVars } from '@/lib/hooks/use-css-vars' +import { calculateAxisMax } from '@/lib/utils/chart' +import { formatAxisNumber } from '@/lib/utils/formatting' +import { EChartsOption, SeriesOption } from 'echarts' +import ReactEChartsCore from 'echarts-for-react/lib/core' +import { LineChart } from 'echarts/charts' +import { + AxisPointerComponent, + BrushComponent, + GridComponent, +} from 'echarts/components' +import * as echarts from 'echarts/core' +import { CanvasRenderer } from 'echarts/renderers' +import { useTheme } from 'next-themes' +import { memo, useCallback, useMemo, useRef } from 'react' +import type { SandboxMetricsChartProps } from './types' + +echarts.use([ + LineChart, + GridComponent, + BrushComponent, + CanvasRenderer, + AxisPointerComponent, +]) + +function SandboxMetricsChart({ + categories, + series, + className, + stacked = false, + showXAxisLabels = true, + xAxisMin, + xAxisMax, + yAxisMax, + yAxisFormatter = formatAxisNumber, + onHover, + onHoverEnd, + onBrushEnd, +}: SandboxMetricsChartProps) { + const chartRef = useRef(null) + const chartInstanceRef = useRef(null) + const { resolvedTheme } = useTheme() + + const onHoverRef = useRef(onHover) + const onHoverEndRef = useRef(onHoverEnd) + const onBrushEndRef = useRef(onBrushEnd) + + onHoverRef.current = onHover + onHoverEndRef.current = onHoverEnd + onBrushEndRef.current = onBrushEnd + + const cssVarNames = useMemo(() => { + const dynamicVars = series.flatMap((item) => + [item.lineColorVar, item.areaColorVar].filter( + (name): name is string => Boolean(name) + ) + ) + + return Array.from( + new Set([ + '--stroke', + '--fg-tertiary', + '--font-mono', + ...dynamicVars, + ]) + ) + }, [series]) + + const cssVars = useCssVars(cssVarNames) + + const stroke = cssVars['--stroke'] || '#000' + const fgTertiary = cssVars['--fg-tertiary'] || '#666' + const fontMono = cssVars['--font-mono'] || 'monospace' + + const handleAxisPointer = useCallback( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (params: any) => { + if (!onHoverRef.current) { + return '' + } + + const dataIndex = params?.seriesData?.[0]?.dataIndex + if (typeof dataIndex === 'number') { + onHoverRef.current(dataIndex) + return '' + } + + const pointerValue = params?.value ?? params?.axisValue + const normalizedValue = + typeof pointerValue === 'number' + ? pointerValue + : typeof pointerValue === 'string' + ? Number(pointerValue) + : NaN + + if (Number.isNaN(normalizedValue)) { + return '' + } + + const matchedIndex = categories.findIndex((value) => value === normalizedValue) + if (matchedIndex >= 0) { + onHoverRef.current(matchedIndex) + } + + return '' + }, + [categories] + ) + + const handleGlobalOut = useCallback(() => { + if (onHoverEndRef.current) { + onHoverEndRef.current() + } + }, []) + + const handleBrushEnd = useCallback( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (params: any) => { + const areas = params.areas + if (areas && areas.length > 0) { + const area = areas[0] + const coordRange = area.coordRange + + if (coordRange && coordRange.length === 2 && onBrushEndRef.current) { + const resolveStartIndex = (value: unknown) => { + if (categories.length === 0) return 0 + + const numericValue = + typeof value === 'number' + ? value + : typeof value === 'string' + ? Number(value) + : NaN + + if (Number.isNaN(numericValue)) { + return 0 + } + + if (numericValue >= 0 && numericValue <= categories.length - 1) { + return Math.max( + 0, + Math.min(categories.length - 1, Math.floor(numericValue)) + ) + } + + const foundIndex = categories.findIndex((ts) => ts >= numericValue) + return foundIndex === -1 ? categories.length - 1 : foundIndex + } + + const resolveEndIndex = (value: unknown) => { + if (categories.length === 0) return 0 + + const numericValue = + typeof value === 'number' + ? value + : typeof value === 'string' + ? Number(value) + : NaN + + if (Number.isNaN(numericValue)) { + return categories.length - 1 + } + + if (numericValue >= 0 && numericValue <= categories.length - 1) { + return Math.max( + 0, + Math.min(categories.length - 1, Math.ceil(numericValue)) + ) + } + + for (let index = categories.length - 1; index >= 0; index -= 1) { + const ts = categories[index] + if (ts !== undefined && ts <= numericValue) { + return index + } + } + + return 0 + } + + const rawStartIndex = resolveStartIndex(coordRange[0]) + const rawEndIndex = resolveEndIndex(coordRange[1]) + const startIndex = Math.min(rawStartIndex, rawEndIndex) + const endIndex = Math.max(rawStartIndex, rawEndIndex) + const startTimestamp = categories[startIndex] + const endTimestamp = categories[endIndex] + + if (startTimestamp !== undefined && endTimestamp !== undefined) { + onBrushEndRef.current(startTimestamp, endTimestamp) + } + + chartInstanceRef.current?.dispatchAction({ + type: 'brush', + command: 'clear', + areas: [], + }) + } + } + }, + [categories] + ) + + const handleChartReady = useCallback((chart: echarts.ECharts) => { + chartInstanceRef.current = chart + + // activate brush selection mode + chart.dispatchAction( + { + type: 'takeGlobalCursor', + key: 'brush', + brushOption: { + brushType: 'lineX', + brushMode: 'single', + }, + }, + { flush: true } + ) + + chart.group = 'sandbox-monitoring' + echarts.connect('sandbox-monitoring') + }, []) + + const option = useMemo(() => { + const values = series.flatMap((line) => + line.data + .map((point) => point.y) + .filter((value): value is number => value !== null) + ) + const computedYAxisMax = + yAxisMax ?? calculateAxisMax(values.length > 0 ? values : [0], 1.5) + + const findMaxCategoryIndex = () => { + if (xAxisMax === undefined) { + return Math.max(0, categories.length - 1) + } + + for (let index = categories.length - 1; index >= 0; index -= 1) { + const value = categories[index] + if (value !== undefined && value <= xAxisMax) { + return Math.max(minCategoryIndex, index) + } + } + + return minCategoryIndex + } + + const minCategoryIndex = + xAxisMin !== undefined + ? Math.max( + 0, + categories.findIndex((value) => value >= xAxisMin) + ) + : 0 + const maxCategoryIndex = findMaxCategoryIndex() + + const seriesItems: SeriesOption[] = series.map((line) => { + const lineColor = line.lineColorVar + ? cssVars[line.lineColorVar] + : undefined + const areaColor = line.areaColorVar + ? cssVars[line.areaColorVar] + : undefined + + return { + id: line.id, + name: line.name, + type: 'line', + symbol: 'none', + showSymbol: false, + smooth: false, + emphasis: { + disabled: true, + }, + stack: stacked ? 'sandbox-resource' : undefined, + areaStyle: stacked + ? { + opacity: 0.18, + color: areaColor || lineColor, + } + : undefined, + lineStyle: { + width: 1, + color: lineColor, + }, + data: line.data.map((point) => point.y ?? '-'), + } + }) + + return { + backgroundColor: 'transparent', + animation: false, + brush: { + brushType: 'lineX', + brushMode: 'single', + xAxisIndex: 0, + brushLink: 'all', + brushStyle: { borderWidth: 1 }, + outOfBrush: { colorAlpha: 0.25 }, + }, + grid: { + top: 10, + bottom: showXAxisLabels ? 24 : 10, + left: 36, + right: 8, + }, + xAxis: { + type: 'category', + data: categories, + min: minCategoryIndex, + max: maxCategoryIndex, + boundaryGap: false, + axisLine: { show: true, lineStyle: { color: stroke } }, + axisTick: { show: false }, + splitLine: { show: false }, + axisLabel: { + show: showXAxisLabels, + color: fgTertiary, + fontFamily: fontMono, + fontSize: 12, + hideOverlap: true, + formatter: (value: number | string) => { + const timestamp = Number(value) + if (Number.isNaN(timestamp)) { + return '' + } + + const date = new Date(timestamp) + const hours = date.getHours().toString().padStart(2, '0') + const minutes = date.getMinutes().toString().padStart(2, '0') + + return `${hours}:${minutes}` + }, + }, + axisPointer: { + show: true, + type: 'line', + lineStyle: { color: stroke, type: 'solid', width: 1 }, + snap: false, + label: { + backgroundColor: 'transparent', + formatter: handleAxisPointer, + }, + }, + }, + yAxis: { + type: 'value', + min: 0, + max: computedYAxisMax, + interval: computedYAxisMax / 2, + axisLine: { show: false }, + axisTick: { show: false }, + splitLine: { + show: true, + lineStyle: { color: stroke, type: 'dashed' }, + interval: 0, + }, + axisLabel: { + show: true, + color: fgTertiary, + fontFamily: fontMono, + fontSize: 12, + interval: 0, + formatter: yAxisFormatter, + }, + axisPointer: { show: false }, + }, + series: seriesItems, + } + }, [ + fontMono, + fgTertiary, + categories, + series, + cssVars, + showXAxisLabels, + stacked, + stroke, + handleAxisPointer, + xAxisMax, + xAxisMin, + yAxisFormatter, + yAxisMax, + ]) + + return ( + + ) +} + +const MemoizedSandboxMetricsChart = memo( + SandboxMetricsChart, + (prevProps, nextProps) => { + return ( + prevProps.categories === nextProps.categories && + prevProps.series === nextProps.series && + prevProps.className === nextProps.className && + prevProps.stacked === nextProps.stacked && + prevProps.showXAxisLabels === nextProps.showXAxisLabels && + prevProps.xAxisMin === nextProps.xAxisMin && + prevProps.xAxisMax === nextProps.xAxisMax && + prevProps.yAxisMax === nextProps.yAxisMax && + prevProps.yAxisFormatter === nextProps.yAxisFormatter + // callbacks are handled via refs + ) + } +) + +MemoizedSandboxMetricsChart.displayName = 'SandboxMetricsChart' + +export default MemoizedSandboxMetricsChart diff --git a/src/features/dashboard/sandbox/monitoring/charts/sandbox-metrics-chart/types.ts b/src/features/dashboard/sandbox/monitoring/charts/sandbox-metrics-chart/types.ts new file mode 100644 index 000000000..d7f73c2fc --- /dev/null +++ b/src/features/dashboard/sandbox/monitoring/charts/sandbox-metrics-chart/types.ts @@ -0,0 +1,27 @@ +export type SandboxMetricsDataPoint = { + x: number // timestamp + y: number | null // value +} + +export interface SandboxMetricsSeries { + id: string + name: string + data: SandboxMetricsDataPoint[] + lineColorVar?: string + areaColorVar?: string +} + +export interface SandboxMetricsChartProps { + categories: number[] + series: SandboxMetricsSeries[] + className?: string + stacked?: boolean + showXAxisLabels?: boolean + xAxisMin?: number + xAxisMax?: number + yAxisMax?: number + yAxisFormatter?: (value: number) => string + onHover?: (index: number) => void + onHoverEnd?: () => void + onBrushEnd?: (startTimestamp: number, endTimestamp: number) => void +} diff --git a/src/features/dashboard/sandbox/monitoring/charts/utils.ts b/src/features/dashboard/sandbox/monitoring/charts/utils.ts new file mode 100644 index 000000000..1ddc73943 --- /dev/null +++ b/src/features/dashboard/sandbox/monitoring/charts/utils.ts @@ -0,0 +1,160 @@ +import type { SandboxMetric } from '@/server/api/models/sandboxes.models' +import { calculateStepForRange } from '../utils' +import type { SandboxMetricsSeries } from './sandbox-metrics-chart/types' + +export const SANDBOX_MONITORING_CPU_LINE_COLOR_VAR = '--graph-3' +export const SANDBOX_MONITORING_CPU_AREA_COLOR_VAR = '--graph-area-3-from' +export const SANDBOX_MONITORING_RAM_LINE_COLOR_VAR = '--graph-1' +export const SANDBOX_MONITORING_RAM_AREA_COLOR_VAR = '--graph-area-1-from' +export const SANDBOX_MONITORING_DISK_LINE_COLOR_VAR = '--graph-2' + +function clampPercent(value: number): number { + if (Number.isNaN(value)) return 0 + return Math.max(0, Math.min(100, value)) +} + +function getMetricTimestampMs(metric: SandboxMetric): number { + if (typeof metric.timestampUnix === 'number') { + return metric.timestampUnix * 1000 + } + + return new Date(metric.timestamp).getTime() +} + +export function filterSandboxMetricsByTimeRange( + metrics: SandboxMetric[], + startMs: number, + endMs: number +): SandboxMetric[] { + return metrics.filter((metric) => { + const timestampMs = getMetricTimestampMs(metric) + return timestampMs >= startMs && timestampMs <= endMs + }) +} + +export function buildTimelineCategories( + startMs: number, + endMs: number +): number[] { + const normalizedStart = Math.floor(startMs) + const normalizedEnd = Math.floor(endMs) + + if (normalizedEnd <= normalizedStart) { + return [normalizedStart] + } + + const stepMs = Math.max( + 1_000, + Math.floor(calculateStepForRange(normalizedStart, normalizedEnd) / 1000) * + 1000 + ) + + const categories: number[] = [] + + for (let ts = normalizedStart; ts <= normalizedEnd; ts += stepMs) { + categories.push(ts) + } + + if (categories[categories.length - 1] !== normalizedEnd) { + categories.push(normalizedEnd) + } + + return categories +} + +function toPercent(used: number, total: number): number { + if (!total || total <= 0) return 0 + return clampPercent((used / total) * 100) +} + +export function sortSandboxMetricsByTime( + metrics: SandboxMetric[] +): SandboxMetric[] { + return [...metrics].sort( + (a, b) => getMetricTimestampMs(a) - getMetricTimestampMs(b) + ) +} + +function createSeriesData( + metrics: SandboxMetric[], + categories: number[], + getValue: (metric: SandboxMetric) => number +) { + const sums = Array(categories.length).fill(0) + const counts = Array(categories.length).fill(0) + + if (categories.length === 0) { + return [] + } + + const start = categories[0]! + const end = categories[categories.length - 1]! + const step = categories.length > 1 ? categories[1]! - categories[0]! : 1 + + for (const metric of metrics) { + const ts = getMetricTimestampMs(metric) + + if (ts < start || ts > end) { + continue + } + + const index = Math.max( + 0, + Math.min(categories.length - 1, Math.round((ts - start) / step)) + ) + + sums[index] = (sums[index] ?? 0) + getValue(metric) + counts[index] = (counts[index] ?? 0) + 1 + } + + return categories.map((x, idx) => ({ + x, + y: counts[idx]! > 0 ? sums[idx]! / counts[idx]! : null, + })) +} + +export function buildResourceSeries( + metrics: SandboxMetric[], + categories: number[] +): SandboxMetricsSeries[] { + const sorted = sortSandboxMetricsByTime(metrics) + + return [ + { + id: 'cpu', + name: 'CPU', + lineColorVar: SANDBOX_MONITORING_CPU_LINE_COLOR_VAR, + areaColorVar: SANDBOX_MONITORING_CPU_AREA_COLOR_VAR, + data: createSeriesData(sorted, categories, (metric) => + clampPercent(metric.cpuUsedPct) + ), + }, + { + id: 'ram', + name: 'RAM', + lineColorVar: SANDBOX_MONITORING_RAM_LINE_COLOR_VAR, + areaColorVar: SANDBOX_MONITORING_RAM_AREA_COLOR_VAR, + data: createSeriesData(sorted, categories, (metric) => + toPercent(metric.memUsed, metric.memTotal) + ), + }, + ] +} + +export function buildDiskSeries( + metrics: SandboxMetric[], + categories: number[] +): SandboxMetricsSeries[] { + const sorted = sortSandboxMetricsByTime(metrics) + + return [ + { + id: 'disk', + name: 'DISK', + lineColorVar: SANDBOX_MONITORING_DISK_LINE_COLOR_VAR, + data: createSeriesData(sorted, categories, (metric) => + toPercent(metric.diskUsed, metric.diskTotal) + ), + }, + ] +} diff --git a/src/features/dashboard/sandbox/monitoring/hooks/use-sandbox-monitoring-controller.ts b/src/features/dashboard/sandbox/monitoring/hooks/use-sandbox-monitoring-controller.ts new file mode 100644 index 000000000..c8b5fe4b0 --- /dev/null +++ b/src/features/dashboard/sandbox/monitoring/hooks/use-sandbox-monitoring-controller.ts @@ -0,0 +1,136 @@ +'use client' + +import { SANDBOXES_DETAILS_METRICS_POLLING_MS } from '@/configs/intervals' +import { useDashboard } from '@/features/dashboard/context' +import { useTRPCClient } from '@/trpc/client' +import { usePathname, useRouter, useSearchParams } from 'next/navigation' +import { useCallback, useEffect, useMemo } from 'react' +import { useSandboxMonitoringStore } from '../store' + +function parseQueryInteger(value: string | null): number | null { + if (!value) return null + const parsed = Number.parseInt(value, 10) + return Number.isNaN(parsed) ? null : parsed +} + +export function useSandboxMonitoringController(sandboxId: string) { + const trpcClient = useTRPCClient() + const { team } = useDashboard() + const router = useRouter() + const pathname = usePathname() + const searchParams = useSearchParams() + + const timeframe = useSandboxMonitoringStore((state) => state.timeframe) + const metrics = useSandboxMonitoringStore((state) => state.metrics) + const isLoading = useSandboxMonitoringStore((state) => state.isLoading) + const error = useSandboxMonitoringStore((state) => state.error) + const isInitialized = useSandboxMonitoringStore( + (state) => state.isInitialized + ) + const initialize = useSandboxMonitoringStore((state) => state.initialize) + const setTimeframe = useSandboxMonitoringStore((state) => state.setTimeframe) + const setMetrics = useSandboxMonitoringStore((state) => state.setMetrics) + const setLoading = useSandboxMonitoringStore((state) => state.setLoading) + const setError = useSandboxMonitoringStore((state) => state.setError) + + const urlTimeframe = useMemo( + () => ({ + start: parseQueryInteger(searchParams.get('start')), + end: parseQueryInteger(searchParams.get('end')), + }), + [searchParams] + ) + + useEffect(() => { + initialize(sandboxId, urlTimeframe) + }, [initialize, sandboxId, urlTimeframe]) + + useEffect(() => { + if (!isInitialized) return + + const currentStart = searchParams.get('start') + const currentEnd = searchParams.get('end') + const nextStart = String(timeframe.start) + const nextEnd = String(timeframe.end) + + if (currentStart === nextStart && currentEnd === nextEnd) { + return + } + + const nextParams = new URLSearchParams(searchParams.toString()) + nextParams.set('start', nextStart) + nextParams.set('end', nextEnd) + + router.replace(`${pathname}?${nextParams.toString()}`, { scroll: false }) + }, [ + isInitialized, + pathname, + router, + searchParams, + timeframe.end, + timeframe.start, + ]) + + const fetchMetrics = useCallback(async () => { + if (!team?.id) { + return + } + + setLoading(true) + setError(null) + + try { + const result = await trpcClient.sandbox.resourceMetrics.query({ + teamIdOrSlug: team.id, + sandboxId, + startMs: timeframe.start, + endMs: timeframe.end, + }) + + setMetrics(result) + setError(null) + } catch (error) { + setError( + error instanceof Error + ? error.message + : 'Failed to fetch sandbox monitoring metrics.' + ) + } finally { + setLoading(false) + } + }, [ + sandboxId, + setError, + setLoading, + setMetrics, + team?.id, + timeframe.end, + timeframe.start, + trpcClient, + ]) + + useEffect(() => { + if (!isInitialized) return + void fetchMetrics() + }, [fetchMetrics, isInitialized]) + + useEffect(() => { + if (!isInitialized || !timeframe.isLive) return + + const intervalId = setInterval(() => { + void fetchMetrics() + }, SANDBOXES_DETAILS_METRICS_POLLING_MS) + + return () => { + clearInterval(intervalId) + } + }, [fetchMetrics, isInitialized, timeframe.isLive]) + + return { + timeframe, + metrics, + isLoading, + error, + setTimeframe, + } +} diff --git a/src/features/dashboard/sandbox/monitoring/store.ts b/src/features/dashboard/sandbox/monitoring/store.ts new file mode 100644 index 000000000..b740bef09 --- /dev/null +++ b/src/features/dashboard/sandbox/monitoring/store.ts @@ -0,0 +1,160 @@ +'use client' + +import type { SandboxMetric } from '@/server/api/models/sandboxes.models' +import { create } from 'zustand' +import { calculateIsLive } from './utils' + +const DEFAULT_RANGE_MS = 1 * 60 * 60 * 1000 // 1 hour +const MAX_DAYS_AGO_MS = 31 * 24 * 60 * 60 * 1000 +const MIN_RANGE_MS = 90 * 1000 + +const getStableNow = () => Math.floor(Date.now() / 1000) * 1000 + +function normalizeTimeframe(start: number, end: number) { + const now = getStableNow() + + let safeStart = Math.floor(start) + let safeEnd = Math.floor(end) + + if (safeEnd > now) { + safeEnd = now + } + + if (safeStart < now - MAX_DAYS_AGO_MS) { + safeStart = now - MAX_DAYS_AGO_MS + } + + if (safeEnd <= safeStart) { + safeEnd = safeStart + MIN_RANGE_MS + } + + const range = safeEnd - safeStart + if (range < MIN_RANGE_MS) { + safeStart = safeEnd - MIN_RANGE_MS + } + + return { + start: safeStart, + end: safeEnd, + duration: safeEnd - safeStart, + isLive: calculateIsLive(safeStart, safeEnd, now), + } +} + +function getDefaultTimeframe() { + const now = getStableNow() + return normalizeTimeframe(now - DEFAULT_RANGE_MS, now) +} + +interface SandboxMonitoringStoreState { + sandboxId: string | null + timeframe: { + start: number + end: number + duration: number + isLive: boolean + } + metrics: SandboxMetric[] + isLoading: boolean + error: string | null + isInitialized: boolean +} + +interface SandboxMonitoringStoreActions { + initialize: ( + sandboxId: string, + params: { start?: number | null; end?: number | null } + ) => void + setTimeframe: (start: number, end: number) => void + setMetrics: (metrics: SandboxMetric[]) => void + setLoading: (isLoading: boolean) => void + setError: (error: string | null) => void +} + +type SandboxMonitoringStore = SandboxMonitoringStoreState & + SandboxMonitoringStoreActions + +const initialTimeframe = getDefaultTimeframe() + +const initialState: SandboxMonitoringStoreState = { + sandboxId: null, + timeframe: initialTimeframe, + metrics: [], + isLoading: false, + error: null, + isInitialized: false, +} + +function sortMetricsByTime(metrics: SandboxMetric[]): SandboxMetric[] { + return [...metrics].sort((a, b) => { + const timeA = + typeof a.timestampUnix === 'number' + ? a.timestampUnix * 1000 + : new Date(a.timestamp).getTime() + const timeB = + typeof b.timestampUnix === 'number' + ? b.timestampUnix * 1000 + : new Date(b.timestamp).getTime() + return timeA - timeB + }) +} + +export const useSandboxMonitoringStore = create()( + (set, get) => ({ + ...initialState, + + initialize: (sandboxId, params) => { + const now = getStableNow() + const current = get() + const isNewSandbox = current.sandboxId !== sandboxId + + const fallbackStart = now - DEFAULT_RANGE_MS + const fallbackEnd = now + const start = params.start ?? fallbackStart + const end = params.end ?? fallbackEnd + const normalized = normalizeTimeframe(start, end) + + set((state) => { + const shouldUpdateTimeframe = + !state.isInitialized || + isNewSandbox || + state.timeframe.start !== normalized.start || + state.timeframe.end !== normalized.end + + if (!shouldUpdateTimeframe) { + return state + } + + return { + sandboxId, + timeframe: normalized, + metrics: isNewSandbox ? [] : state.metrics, + isLoading: isNewSandbox ? false : state.isLoading, + error: isNewSandbox ? null : state.error, + isInitialized: true, + } + }) + }, + + setTimeframe: (start, end) => { + set((state) => ({ + timeframe: normalizeTimeframe(start, end), + error: state.error, + })) + }, + + setMetrics: (metrics) => { + set({ + metrics: sortMetricsByTime(metrics), + }) + }, + + setLoading: (isLoading) => { + set({ isLoading }) + }, + + setError: (error) => { + set({ error }) + }, + }) +) diff --git a/src/features/dashboard/sandbox/metrics/utils.ts b/src/features/dashboard/sandbox/monitoring/utils.ts similarity index 100% rename from src/features/dashboard/sandbox/metrics/utils.ts rename to src/features/dashboard/sandbox/monitoring/utils.ts diff --git a/src/features/dashboard/sandbox/monitoring/view.tsx b/src/features/dashboard/sandbox/monitoring/view.tsx new file mode 100644 index 000000000..0f6250eb7 --- /dev/null +++ b/src/features/dashboard/sandbox/monitoring/view.tsx @@ -0,0 +1,17 @@ +'use client' + +import SandboxMetricsCharts from './charts' + +interface SandboxMonitoringViewProps { + sandboxId: string +} + +export default function SandboxMonitoringView({ + sandboxId, +}: SandboxMonitoringViewProps) { + return ( +
+ +
+ ) +} diff --git a/src/features/dashboard/sandboxes/list/table-row.tsx b/src/features/dashboard/sandboxes/list/table-row.tsx index e38fbcd54..f995abbda 100644 --- a/src/features/dashboard/sandboxes/list/table-row.tsx +++ b/src/features/dashboard/sandboxes/list/table-row.tsx @@ -18,7 +18,7 @@ export const SandboxesTableRow = memo(function SandboxesTableRow({ return ( Date: Tue, 24 Feb 2026 21:23:56 -0800 Subject: [PATCH 32/54] mv: dir --- .../inspect/sandbox/[sandboxId]/route.ts | 43 +- .../sandboxes/[sandboxId]/monitoring/page.tsx | 2 +- src/configs/intervals.ts | 2 - .../monitoring/charts/disk-chart-header.tsx | 67 --- .../sandbox/monitoring/charts/index.tsx | 136 ----- .../charts/sandbox-metrics-chart/index.tsx | 426 -------------- .../monitoring-chart-section.tsx} | 2 +- .../components/monitoring-charts.tsx | 245 ++++++++ .../monitoring-disk-chart-header.tsx | 66 +++ .../monitoring-resource-chart-header.tsx} | 88 ++- .../monitoring-sandbox-metrics-chart.tsx | 526 ++++++++++++++++++ .../monitoring-time-range-controls.tsx | 324 +++++++++++ .../monitoring-view.tsx} | 2 +- .../use-sandbox-monitoring-controller.ts | 136 ----- .../sandbox/monitoring/state/store.ts | 206 +++++++ .../use-sandbox-monitoring-controller.ts | 195 +++++++ .../dashboard/sandbox/monitoring/store.ts | 160 ------ .../sandbox-metrics-chart.ts} | 8 +- .../dashboard/sandbox/monitoring/utils.ts | 62 --- .../sandbox/monitoring/utils/constants.ts | 90 +++ .../sandbox/monitoring/utils/formatters.ts | 56 ++ .../{charts/utils.ts => utils/metrics.ts} | 68 ++- .../sandbox/monitoring/utils/timeframe.ts | 124 +++++ src/styles/theme.css | 18 +- src/ui/time-range-picker.tsx | 160 +++++- src/ui/time-range-presets.tsx | 1 + 26 files changed, 2097 insertions(+), 1116 deletions(-) delete mode 100644 src/features/dashboard/sandbox/monitoring/charts/disk-chart-header.tsx delete mode 100644 src/features/dashboard/sandbox/monitoring/charts/index.tsx delete mode 100644 src/features/dashboard/sandbox/monitoring/charts/sandbox-metrics-chart/index.tsx rename src/features/dashboard/sandbox/monitoring/{charts/chart-section.tsx => components/monitoring-chart-section.tsx} (93%) create mode 100644 src/features/dashboard/sandbox/monitoring/components/monitoring-charts.tsx create mode 100644 src/features/dashboard/sandbox/monitoring/components/monitoring-disk-chart-header.tsx rename src/features/dashboard/sandbox/monitoring/{charts/resource-chart-header.tsx => components/monitoring-resource-chart-header.tsx} (51%) create mode 100644 src/features/dashboard/sandbox/monitoring/components/monitoring-sandbox-metrics-chart.tsx create mode 100644 src/features/dashboard/sandbox/monitoring/components/monitoring-time-range-controls.tsx rename src/features/dashboard/sandbox/monitoring/{view.tsx => components/monitoring-view.tsx} (85%) delete mode 100644 src/features/dashboard/sandbox/monitoring/hooks/use-sandbox-monitoring-controller.ts create mode 100644 src/features/dashboard/sandbox/monitoring/state/store.ts create mode 100644 src/features/dashboard/sandbox/monitoring/state/use-sandbox-monitoring-controller.ts delete mode 100644 src/features/dashboard/sandbox/monitoring/store.ts rename src/features/dashboard/sandbox/monitoring/{charts/sandbox-metrics-chart/types.ts => types/sandbox-metrics-chart.ts} (85%) delete mode 100644 src/features/dashboard/sandbox/monitoring/utils.ts create mode 100644 src/features/dashboard/sandbox/monitoring/utils/constants.ts create mode 100644 src/features/dashboard/sandbox/monitoring/utils/formatters.ts rename src/features/dashboard/sandbox/monitoring/{charts/utils.ts => utils/metrics.ts} (62%) create mode 100644 src/features/dashboard/sandbox/monitoring/utils/timeframe.ts diff --git a/src/app/dashboard/(resolvers)/inspect/sandbox/[sandboxId]/route.ts b/src/app/dashboard/(resolvers)/inspect/sandbox/[sandboxId]/route.ts index 3a305029c..878741a9f 100644 --- a/src/app/dashboard/(resolvers)/inspect/sandbox/[sandboxId]/route.ts +++ b/src/app/dashboard/(resolvers)/inspect/sandbox/[sandboxId]/route.ts @@ -120,15 +120,20 @@ export async function GET( const parsedSandboxId = SandboxIdSchema.safeParse(requestedSandboxId) if (!parsedSandboxId.success) { - return redirectToDashboardWithWarning(request, 'inspect_sandbox:invalid_id', { - sandbox_id: requestedSandboxId, - validation_errors: parsedSandboxId.error.flatten(), - }) + return redirectToDashboardWithWarning( + request, + 'inspect_sandbox:invalid_id', + { + sandbox_id: requestedSandboxId, + validation_errors: parsedSandboxId.error.flatten(), + } + ) } const sandboxId = parsedSandboxId.data const supabase = await createClient() - const { data: userResponse, error: userError } = await supabase.auth.getUser() + const { data: userResponse, error: userError } = + await supabase.auth.getUser() if (userError || !userResponse.user) { l.info({ @@ -167,10 +172,14 @@ export async function GET( error: teamQueryError, }) - return redirectToDashboardWithWarning(request, 'inspect_sandbox:no_teams', { - user_id: userId, - sandbox_id: sandboxId, - }) + return redirectToDashboardWithWarning( + request, + 'inspect_sandbox:no_teams', + { + user_id: userId, + sandbox_id: sandboxId, + } + ) } const userTeams: UserTeam[] = userTeamRows.map((row) => ({ @@ -188,15 +197,19 @@ export async function GET( ) if (!selectedTeam) { - return redirectToDashboardWithWarning(request, 'inspect_sandbox:not_found', { - user_id: userId, - sandbox_id: sandboxId, - teams_checked: userTeams.map((team) => team.id), - }) + return redirectToDashboardWithWarning( + request, + 'inspect_sandbox:not_found', + { + user_id: userId, + sandbox_id: sandboxId, + teams_checked: userTeams.map((team) => team.id), + } + ) } const redirectUrl = new URL( - PROTECTED_URLS.SANDBOX_INSPECT(selectedTeam.slug, sandboxId), + PROTECTED_URLS.SANDBOX(selectedTeam.slug, sandboxId), request.url ) diff --git a/src/app/dashboard/[teamIdOrSlug]/sandboxes/[sandboxId]/monitoring/page.tsx b/src/app/dashboard/[teamIdOrSlug]/sandboxes/[sandboxId]/monitoring/page.tsx index 35caf6c42..2b0dbc4b2 100644 --- a/src/app/dashboard/[teamIdOrSlug]/sandboxes/[sandboxId]/monitoring/page.tsx +++ b/src/app/dashboard/[teamIdOrSlug]/sandboxes/[sandboxId]/monitoring/page.tsx @@ -1,4 +1,4 @@ -import SandboxMonitoringView from '@/features/dashboard/sandbox/monitoring/view' +import SandboxMonitoringView from '@/features/dashboard/sandbox/monitoring/components/monitoring-view' export default async function SandboxMonitoringPage({ params, diff --git a/src/configs/intervals.ts b/src/configs/intervals.ts index d4faaf7c2..f6fa9a02e 100644 --- a/src/configs/intervals.ts +++ b/src/configs/intervals.ts @@ -12,5 +12,3 @@ export const TEAM_METRICS_TIMEFRAME_UPDATE_MS = 10_000 // backend metrics collection interval export const TEAM_METRICS_BACKEND_COLLECTION_INTERVAL_MS = 30_000 - -export const SINGLE_SANDBOX_RESOURCE_METRICS_TIMEFRAME_UPDATE_MS = 10_000 diff --git a/src/features/dashboard/sandbox/monitoring/charts/disk-chart-header.tsx b/src/features/dashboard/sandbox/monitoring/charts/disk-chart-header.tsx deleted file mode 100644 index be5ffd991..000000000 --- a/src/features/dashboard/sandbox/monitoring/charts/disk-chart-header.tsx +++ /dev/null @@ -1,67 +0,0 @@ -'use client' - -import type { SandboxMetric } from '@/server/api/models/sandboxes.models' -import { StorageIcon } from '@/ui/primitives/icons' - -interface DiskChartHeaderProps { - metric?: SandboxMetric - hovered?: { - diskPercent: number | null - timestampMs: number - } | null -} - -function formatPercent(value: number | null) { - if (value === null || Number.isNaN(value)) { - return '--' - } - - return `${Math.round(value)}%` -} - -function formatDiskTotalGb(bytes: number) { - const gb = bytes / 1024 / 1024 / 1024 - const rounded = gb >= 10 ? gb.toFixed(0) : gb.toFixed(1) - return `${rounded.replace(/\.0$/, '')} GB` -} - -function formatHoverTimestamp(timestampMs: number) { - return new Intl.DateTimeFormat(undefined, { - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - }).format(new Date(timestampMs)) -} - -export default function DiskChartHeader({ metric, hovered }: DiskChartHeaderProps) { - const diskPercent = hovered - ? hovered.diskPercent - : metric - ? metric.diskTotal > 0 - ? (metric.diskUsed / metric.diskTotal) * 100 - : 0 - : 0 - - const diskTotalGb = formatDiskTotalGb(metric?.diskTotal ?? 0) - const contextLabel = hovered ? formatHoverTimestamp(hovered.timestampMs) : null - - return ( -
-
- - - - DISK - - {`${formatPercent(diskPercent)} · ${diskTotalGb}`} - -
- {contextLabel ? ( - - {contextLabel} - - ) : null} -
- ) -} diff --git a/src/features/dashboard/sandbox/monitoring/charts/index.tsx b/src/features/dashboard/sandbox/monitoring/charts/index.tsx deleted file mode 100644 index 241dee3f1..000000000 --- a/src/features/dashboard/sandbox/monitoring/charts/index.tsx +++ /dev/null @@ -1,136 +0,0 @@ -'use client' - -import { useMemo, useState } from 'react' -import { useSandboxMonitoringController } from '../hooks/use-sandbox-monitoring-controller' -import MonitoringChartSection from './chart-section' -import DiskChartHeader from './disk-chart-header' -import ResourceChartHeader from './resource-chart-header' -import SandboxMetricsChart from './sandbox-metrics-chart' -import { - buildDiskSeries, - buildResourceSeries, - buildTimelineCategories, - filterSandboxMetricsByTimeRange, -} from './utils' - -interface SandboxMetricsChartsProps { - sandboxId: string -} - -export default function SandboxMetricsCharts({ - sandboxId, -}: SandboxMetricsChartsProps) { - const { metrics, timeframe, setTimeframe } = - useSandboxMonitoringController(sandboxId) - const [hoveredIndex, setHoveredIndex] = useState(null) - - const constrainedMetrics = useMemo( - () => - filterSandboxMetricsByTimeRange(metrics, timeframe.start, timeframe.end), - [metrics, timeframe.end, timeframe.start] - ) - const categories = useMemo( - () => buildTimelineCategories(timeframe.start, timeframe.end), - [timeframe.end, timeframe.start] - ) - - const resourceSeries = useMemo( - () => buildResourceSeries(constrainedMetrics, categories), - [categories, constrainedMetrics] - ) - const diskSeries = useMemo( - () => buildDiskSeries(constrainedMetrics, categories), - [categories, constrainedMetrics] - ) - const latestMetric = constrainedMetrics[constrainedMetrics.length - 1] - const hoveredTimestamp = - hoveredIndex !== null ? categories[hoveredIndex] : undefined - const hoveredCpuPercent = - hoveredIndex !== null - ? (resourceSeries.find((item) => item.id === 'cpu')?.data[hoveredIndex]?.y ?? - null) - : null - const hoveredRamPercent = - hoveredIndex !== null - ? (resourceSeries.find((item) => item.id === 'ram')?.data[hoveredIndex]?.y ?? - null) - : null - const hoveredDiskPercent = - hoveredIndex !== null - ? (diskSeries.find((item) => item.id === 'disk')?.data[hoveredIndex]?.y ?? - null) - : null - - const resourceHoveredContext = - hoveredTimestamp !== undefined - ? { - cpuPercent: hoveredCpuPercent, - ramPercent: hoveredRamPercent, - timestampMs: hoveredTimestamp, - } - : null - const diskHoveredContext = - hoveredTimestamp !== undefined - ? { - diskPercent: hoveredDiskPercent, - timestampMs: hoveredTimestamp, - } - : null - - const handleBrushEnd = (startTimestamp: number, endTimestamp: number) => { - if (startTimestamp === timeframe.start && endTimestamp === timeframe.end) { - return - } - - setHoveredIndex(null) - setTimeframe(startTimestamp, endTimestamp) - } - - return ( -
- - } - > - setHoveredIndex(null)} - onBrushEnd={handleBrushEnd} - /> - - - - } - > - setHoveredIndex(null)} - onBrushEnd={handleBrushEnd} - /> - -
- ) -} diff --git a/src/features/dashboard/sandbox/monitoring/charts/sandbox-metrics-chart/index.tsx b/src/features/dashboard/sandbox/monitoring/charts/sandbox-metrics-chart/index.tsx deleted file mode 100644 index 634c055fb..000000000 --- a/src/features/dashboard/sandbox/monitoring/charts/sandbox-metrics-chart/index.tsx +++ /dev/null @@ -1,426 +0,0 @@ -'use client' - -import { useCssVars } from '@/lib/hooks/use-css-vars' -import { calculateAxisMax } from '@/lib/utils/chart' -import { formatAxisNumber } from '@/lib/utils/formatting' -import { EChartsOption, SeriesOption } from 'echarts' -import ReactEChartsCore from 'echarts-for-react/lib/core' -import { LineChart } from 'echarts/charts' -import { - AxisPointerComponent, - BrushComponent, - GridComponent, -} from 'echarts/components' -import * as echarts from 'echarts/core' -import { CanvasRenderer } from 'echarts/renderers' -import { useTheme } from 'next-themes' -import { memo, useCallback, useMemo, useRef } from 'react' -import type { SandboxMetricsChartProps } from './types' - -echarts.use([ - LineChart, - GridComponent, - BrushComponent, - CanvasRenderer, - AxisPointerComponent, -]) - -function SandboxMetricsChart({ - categories, - series, - className, - stacked = false, - showXAxisLabels = true, - xAxisMin, - xAxisMax, - yAxisMax, - yAxisFormatter = formatAxisNumber, - onHover, - onHoverEnd, - onBrushEnd, -}: SandboxMetricsChartProps) { - const chartRef = useRef(null) - const chartInstanceRef = useRef(null) - const { resolvedTheme } = useTheme() - - const onHoverRef = useRef(onHover) - const onHoverEndRef = useRef(onHoverEnd) - const onBrushEndRef = useRef(onBrushEnd) - - onHoverRef.current = onHover - onHoverEndRef.current = onHoverEnd - onBrushEndRef.current = onBrushEnd - - const cssVarNames = useMemo(() => { - const dynamicVars = series.flatMap((item) => - [item.lineColorVar, item.areaColorVar].filter( - (name): name is string => Boolean(name) - ) - ) - - return Array.from( - new Set([ - '--stroke', - '--fg-tertiary', - '--font-mono', - ...dynamicVars, - ]) - ) - }, [series]) - - const cssVars = useCssVars(cssVarNames) - - const stroke = cssVars['--stroke'] || '#000' - const fgTertiary = cssVars['--fg-tertiary'] || '#666' - const fontMono = cssVars['--font-mono'] || 'monospace' - - const handleAxisPointer = useCallback( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (params: any) => { - if (!onHoverRef.current) { - return '' - } - - const dataIndex = params?.seriesData?.[0]?.dataIndex - if (typeof dataIndex === 'number') { - onHoverRef.current(dataIndex) - return '' - } - - const pointerValue = params?.value ?? params?.axisValue - const normalizedValue = - typeof pointerValue === 'number' - ? pointerValue - : typeof pointerValue === 'string' - ? Number(pointerValue) - : NaN - - if (Number.isNaN(normalizedValue)) { - return '' - } - - const matchedIndex = categories.findIndex((value) => value === normalizedValue) - if (matchedIndex >= 0) { - onHoverRef.current(matchedIndex) - } - - return '' - }, - [categories] - ) - - const handleGlobalOut = useCallback(() => { - if (onHoverEndRef.current) { - onHoverEndRef.current() - } - }, []) - - const handleBrushEnd = useCallback( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (params: any) => { - const areas = params.areas - if (areas && areas.length > 0) { - const area = areas[0] - const coordRange = area.coordRange - - if (coordRange && coordRange.length === 2 && onBrushEndRef.current) { - const resolveStartIndex = (value: unknown) => { - if (categories.length === 0) return 0 - - const numericValue = - typeof value === 'number' - ? value - : typeof value === 'string' - ? Number(value) - : NaN - - if (Number.isNaN(numericValue)) { - return 0 - } - - if (numericValue >= 0 && numericValue <= categories.length - 1) { - return Math.max( - 0, - Math.min(categories.length - 1, Math.floor(numericValue)) - ) - } - - const foundIndex = categories.findIndex((ts) => ts >= numericValue) - return foundIndex === -1 ? categories.length - 1 : foundIndex - } - - const resolveEndIndex = (value: unknown) => { - if (categories.length === 0) return 0 - - const numericValue = - typeof value === 'number' - ? value - : typeof value === 'string' - ? Number(value) - : NaN - - if (Number.isNaN(numericValue)) { - return categories.length - 1 - } - - if (numericValue >= 0 && numericValue <= categories.length - 1) { - return Math.max( - 0, - Math.min(categories.length - 1, Math.ceil(numericValue)) - ) - } - - for (let index = categories.length - 1; index >= 0; index -= 1) { - const ts = categories[index] - if (ts !== undefined && ts <= numericValue) { - return index - } - } - - return 0 - } - - const rawStartIndex = resolveStartIndex(coordRange[0]) - const rawEndIndex = resolveEndIndex(coordRange[1]) - const startIndex = Math.min(rawStartIndex, rawEndIndex) - const endIndex = Math.max(rawStartIndex, rawEndIndex) - const startTimestamp = categories[startIndex] - const endTimestamp = categories[endIndex] - - if (startTimestamp !== undefined && endTimestamp !== undefined) { - onBrushEndRef.current(startTimestamp, endTimestamp) - } - - chartInstanceRef.current?.dispatchAction({ - type: 'brush', - command: 'clear', - areas: [], - }) - } - } - }, - [categories] - ) - - const handleChartReady = useCallback((chart: echarts.ECharts) => { - chartInstanceRef.current = chart - - // activate brush selection mode - chart.dispatchAction( - { - type: 'takeGlobalCursor', - key: 'brush', - brushOption: { - brushType: 'lineX', - brushMode: 'single', - }, - }, - { flush: true } - ) - - chart.group = 'sandbox-monitoring' - echarts.connect('sandbox-monitoring') - }, []) - - const option = useMemo(() => { - const values = series.flatMap((line) => - line.data - .map((point) => point.y) - .filter((value): value is number => value !== null) - ) - const computedYAxisMax = - yAxisMax ?? calculateAxisMax(values.length > 0 ? values : [0], 1.5) - - const findMaxCategoryIndex = () => { - if (xAxisMax === undefined) { - return Math.max(0, categories.length - 1) - } - - for (let index = categories.length - 1; index >= 0; index -= 1) { - const value = categories[index] - if (value !== undefined && value <= xAxisMax) { - return Math.max(minCategoryIndex, index) - } - } - - return minCategoryIndex - } - - const minCategoryIndex = - xAxisMin !== undefined - ? Math.max( - 0, - categories.findIndex((value) => value >= xAxisMin) - ) - : 0 - const maxCategoryIndex = findMaxCategoryIndex() - - const seriesItems: SeriesOption[] = series.map((line) => { - const lineColor = line.lineColorVar - ? cssVars[line.lineColorVar] - : undefined - const areaColor = line.areaColorVar - ? cssVars[line.areaColorVar] - : undefined - - return { - id: line.id, - name: line.name, - type: 'line', - symbol: 'none', - showSymbol: false, - smooth: false, - emphasis: { - disabled: true, - }, - stack: stacked ? 'sandbox-resource' : undefined, - areaStyle: stacked - ? { - opacity: 0.18, - color: areaColor || lineColor, - } - : undefined, - lineStyle: { - width: 1, - color: lineColor, - }, - data: line.data.map((point) => point.y ?? '-'), - } - }) - - return { - backgroundColor: 'transparent', - animation: false, - brush: { - brushType: 'lineX', - brushMode: 'single', - xAxisIndex: 0, - brushLink: 'all', - brushStyle: { borderWidth: 1 }, - outOfBrush: { colorAlpha: 0.25 }, - }, - grid: { - top: 10, - bottom: showXAxisLabels ? 24 : 10, - left: 36, - right: 8, - }, - xAxis: { - type: 'category', - data: categories, - min: minCategoryIndex, - max: maxCategoryIndex, - boundaryGap: false, - axisLine: { show: true, lineStyle: { color: stroke } }, - axisTick: { show: false }, - splitLine: { show: false }, - axisLabel: { - show: showXAxisLabels, - color: fgTertiary, - fontFamily: fontMono, - fontSize: 12, - hideOverlap: true, - formatter: (value: number | string) => { - const timestamp = Number(value) - if (Number.isNaN(timestamp)) { - return '' - } - - const date = new Date(timestamp) - const hours = date.getHours().toString().padStart(2, '0') - const minutes = date.getMinutes().toString().padStart(2, '0') - - return `${hours}:${minutes}` - }, - }, - axisPointer: { - show: true, - type: 'line', - lineStyle: { color: stroke, type: 'solid', width: 1 }, - snap: false, - label: { - backgroundColor: 'transparent', - formatter: handleAxisPointer, - }, - }, - }, - yAxis: { - type: 'value', - min: 0, - max: computedYAxisMax, - interval: computedYAxisMax / 2, - axisLine: { show: false }, - axisTick: { show: false }, - splitLine: { - show: true, - lineStyle: { color: stroke, type: 'dashed' }, - interval: 0, - }, - axisLabel: { - show: true, - color: fgTertiary, - fontFamily: fontMono, - fontSize: 12, - interval: 0, - formatter: yAxisFormatter, - }, - axisPointer: { show: false }, - }, - series: seriesItems, - } - }, [ - fontMono, - fgTertiary, - categories, - series, - cssVars, - showXAxisLabels, - stacked, - stroke, - handleAxisPointer, - xAxisMax, - xAxisMin, - yAxisFormatter, - yAxisMax, - ]) - - return ( - - ) -} - -const MemoizedSandboxMetricsChart = memo( - SandboxMetricsChart, - (prevProps, nextProps) => { - return ( - prevProps.categories === nextProps.categories && - prevProps.series === nextProps.series && - prevProps.className === nextProps.className && - prevProps.stacked === nextProps.stacked && - prevProps.showXAxisLabels === nextProps.showXAxisLabels && - prevProps.xAxisMin === nextProps.xAxisMin && - prevProps.xAxisMax === nextProps.xAxisMax && - prevProps.yAxisMax === nextProps.yAxisMax && - prevProps.yAxisFormatter === nextProps.yAxisFormatter - // callbacks are handled via refs - ) - } -) - -MemoizedSandboxMetricsChart.displayName = 'SandboxMetricsChart' - -export default MemoizedSandboxMetricsChart diff --git a/src/features/dashboard/sandbox/monitoring/charts/chart-section.tsx b/src/features/dashboard/sandbox/monitoring/components/monitoring-chart-section.tsx similarity index 93% rename from src/features/dashboard/sandbox/monitoring/charts/chart-section.tsx rename to src/features/dashboard/sandbox/monitoring/components/monitoring-chart-section.tsx index 522427f07..dea36922a 100644 --- a/src/features/dashboard/sandbox/monitoring/charts/chart-section.tsx +++ b/src/features/dashboard/sandbox/monitoring/components/monitoring-chart-section.tsx @@ -14,7 +14,7 @@ export default function MonitoringChartSection({ }: MonitoringChartSectionProps) { return (
- {header ? header : null} + {header}
{children}
) diff --git a/src/features/dashboard/sandbox/monitoring/components/monitoring-charts.tsx b/src/features/dashboard/sandbox/monitoring/components/monitoring-charts.tsx new file mode 100644 index 000000000..1dff58d0b --- /dev/null +++ b/src/features/dashboard/sandbox/monitoring/components/monitoring-charts.tsx @@ -0,0 +1,245 @@ +'use client' + +import { useSandboxContext } from '@/features/dashboard/sandbox/context' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { useSandboxMonitoringController } from '../state/use-sandbox-monitoring-controller' +import { + SANDBOX_MONITORING_CPU_SERIES_ID, + SANDBOX_MONITORING_DISK_SERIES_ID, + SANDBOX_MONITORING_PERCENT_MAX, + SANDBOX_MONITORING_RAM_SERIES_ID, +} from '../utils/constants' +import { + buildDiskSeries, + buildResourceSeries, + buildTimelineCategories, + filterSandboxMetricsByTimeRange, + sortSandboxMetricsByTime, +} from '../utils/metrics' +import { + clampTimeframeToBounds, + getSandboxLifecycleBounds, +} from '../utils/timeframe' +import MonitoringChartSection from './monitoring-chart-section' +import DiskChartHeader from './monitoring-disk-chart-header' +import ResourceChartHeader from './monitoring-resource-chart-header' +import SandboxMetricsChart from './monitoring-sandbox-metrics-chart' +import SandboxMonitoringTimeRangeControls from './monitoring-time-range-controls' + +interface SandboxMetricsChartsProps { + sandboxId: string +} + +export default function SandboxMetricsCharts({ + sandboxId, +}: SandboxMetricsChartsProps) { + const { + metrics, + timeframe, + isLiveUpdating, + setTimeframe, + setLiveUpdating, + } = useSandboxMonitoringController(sandboxId) + const { sandboxInfo } = useSandboxContext() + const [hoveredIndex, setHoveredIndex] = useState(null) + + const lifecycleBounds = useMemo(() => { + if (!sandboxInfo) return null + return getSandboxLifecycleBounds(sandboxInfo) + }, [sandboxInfo]) + + const applyTimeframe = useCallback( + ( + startTimestamp: number, + endTimestamp: number, + options?: { isLiveUpdating?: boolean } + ) => { + const maxBoundMs = + lifecycleBounds && lifecycleBounds.isRunning + ? Date.now() + : lifecycleBounds?.anchorEndMs + const next = lifecycleBounds + ? clampTimeframeToBounds( + startTimestamp, + endTimestamp, + lifecycleBounds.startMs, + maxBoundMs ?? lifecycleBounds.anchorEndMs + ) + : { start: startTimestamp, end: endTimestamp } + const nextLiveUpdating = options?.isLiveUpdating ?? false + + if ( + next.start === timeframe.start && + next.end === timeframe.end && + nextLiveUpdating === isLiveUpdating + ) { + return + } + + setHoveredIndex(null) + setTimeframe(next.start, next.end, { + isLiveUpdating: nextLiveUpdating, + }) + }, + [ + isLiveUpdating, + lifecycleBounds, + setTimeframe, + timeframe.end, + timeframe.start, + ] + ) + + useEffect(() => { + if (!lifecycleBounds) return + + const maxBoundMs = lifecycleBounds.isRunning + ? Date.now() + : lifecycleBounds.anchorEndMs + const next = clampTimeframeToBounds( + timeframe.start, + timeframe.end, + lifecycleBounds.startMs, + maxBoundMs + ) + + if (next.start === timeframe.start && next.end === timeframe.end) { + return + } + + setTimeframe(next.start, next.end, { + isLiveUpdating, + }) + }, [ + isLiveUpdating, + lifecycleBounds, + setTimeframe, + timeframe.end, + timeframe.start, + ]) + + useEffect(() => { + if (!lifecycleBounds?.isRunning && isLiveUpdating) { + setLiveUpdating(false) + } + }, [isLiveUpdating, lifecycleBounds?.isRunning, setLiveUpdating]) + + const constrainedMetrics = useMemo( + () => + filterSandboxMetricsByTimeRange(metrics, timeframe.start, timeframe.end), + [metrics, timeframe.end, timeframe.start] + ) + const sortedMetrics = useMemo( + () => sortSandboxMetricsByTime(constrainedMetrics), + [constrainedMetrics] + ) + const categories = useMemo( + () => buildTimelineCategories(timeframe.start, timeframe.end), + [timeframe.end, timeframe.start] + ) + + const resourceSeries = useMemo( + () => buildResourceSeries(sortedMetrics, categories), + [categories, sortedMetrics] + ) + const diskSeries = useMemo( + () => buildDiskSeries(sortedMetrics, categories), + [categories, sortedMetrics] + ) + const latestMetric = constrainedMetrics[constrainedMetrics.length - 1] + const hoveredTimestamp = + hoveredIndex !== null ? categories[hoveredIndex] : undefined + const hoveredCpuPercent = + hoveredIndex === null + ? null + : (resourceSeries.find( + (item) => item.id === SANDBOX_MONITORING_CPU_SERIES_ID + )?.data[hoveredIndex]?.y ?? null) + const hoveredRamPercent = + hoveredIndex === null + ? null + : (resourceSeries.find( + (item) => item.id === SANDBOX_MONITORING_RAM_SERIES_ID + )?.data[hoveredIndex]?.y ?? null) + const hoveredDiskPercent = + hoveredIndex === null + ? null + : (diskSeries.find( + (item) => item.id === SANDBOX_MONITORING_DISK_SERIES_ID + )?.data[hoveredIndex]?.y ?? null) + + const resourceHoveredContext = + hoveredTimestamp !== undefined + ? { + cpuPercent: hoveredCpuPercent, + ramPercent: hoveredRamPercent, + timestampMs: hoveredTimestamp, + } + : null + const diskHoveredContext = + hoveredTimestamp !== undefined + ? { + diskPercent: hoveredDiskPercent, + timestampMs: hoveredTimestamp, + } + : null + const handleHoverEnd = useCallback(() => { + setHoveredIndex(null) + }, []) + + return ( +
+ + ) : null + } + /> + } + > + + + + + } + > + + +
+ ) +} diff --git a/src/features/dashboard/sandbox/monitoring/components/monitoring-disk-chart-header.tsx b/src/features/dashboard/sandbox/monitoring/components/monitoring-disk-chart-header.tsx new file mode 100644 index 000000000..86828626e --- /dev/null +++ b/src/features/dashboard/sandbox/monitoring/components/monitoring-disk-chart-header.tsx @@ -0,0 +1,66 @@ +'use client' + +import { cn } from '@/lib/utils' +import type { SandboxMetric } from '@/server/api/models/sandboxes.models' +import { StorageIcon } from '@/ui/primitives/icons' +import { + SANDBOX_MONITORING_DISK_INDICATOR_CLASS, + SANDBOX_MONITORING_DISK_SERIES_LABEL, +} from '../utils/constants' +import { + calculateRatioPercent, + formatBytesToGb, + formatHoverTimestamp, + formatMetricValue, + formatPercent, +} from '../utils/formatters' + +interface DiskChartHeaderProps { + metric?: SandboxMetric + hovered?: { + diskPercent: number | null + timestampMs: number + } | null +} + +export default function DiskChartHeader({ + metric, + hovered, +}: DiskChartHeaderProps) { + const diskPercent = hovered + ? hovered.diskPercent + : metric + ? calculateRatioPercent(metric.diskUsed, metric.diskTotal) + : 0 + + const diskTotalGb = formatBytesToGb(metric?.diskTotal ?? 0) + const contextLabel = hovered + ? formatHoverTimestamp(hovered.timestampMs) + : null + + return ( +
+
+ + + + + {SANDBOX_MONITORING_DISK_SERIES_LABEL} + + + {formatMetricValue(formatPercent(diskPercent), diskTotalGb)} + +
+ {contextLabel ? ( + + {contextLabel} + + ) : null} +
+ ) +} diff --git a/src/features/dashboard/sandbox/monitoring/charts/resource-chart-header.tsx b/src/features/dashboard/sandbox/monitoring/components/monitoring-resource-chart-header.tsx similarity index 51% rename from src/features/dashboard/sandbox/monitoring/charts/resource-chart-header.tsx rename to src/features/dashboard/sandbox/monitoring/components/monitoring-resource-chart-header.tsx index 4221a7be5..65cf3c89c 100644 --- a/src/features/dashboard/sandbox/monitoring/charts/resource-chart-header.tsx +++ b/src/features/dashboard/sandbox/monitoring/components/monitoring-resource-chart-header.tsx @@ -1,9 +1,23 @@ 'use client' +import { cn } from '@/lib/utils' import type { SandboxMetric } from '@/server/api/models/sandboxes.models' import { CpuIcon, MemoryIcon } from '@/ui/primitives/icons' -import { cn } from '@/lib/utils' import type { ReactNode } from 'react' +import { + SANDBOX_MONITORING_CPU_INDICATOR_CLASS, + SANDBOX_MONITORING_CPU_SERIES_LABEL, + SANDBOX_MONITORING_RAM_INDICATOR_CLASS, + SANDBOX_MONITORING_RAM_SERIES_LABEL, +} from '../utils/constants' +import { + calculateRatioPercent, + formatBytesToGb, + formatCoreCount, + formatHoverTimestamp, + formatMetricValue, + formatPercent, +} from '../utils/formatters' interface ResourceChartHeaderProps { metric?: SandboxMetric @@ -12,34 +26,7 @@ interface ResourceChartHeaderProps { ramPercent: number | null timestampMs: number } | null -} - -function formatPercent(value: number | null) { - if (value === null || Number.isNaN(value)) { - return '--' - } - - return `${Math.round(value)}%` -} - -function formatCores(value: number) { - const normalized = Math.max(0, Math.round(value)) - return `${normalized} ${normalized === 1 ? 'CORE' : 'CORES'}` -} - -function formatRamTotalGb(bytes: number) { - const gb = bytes / 1024 / 1024 / 1024 - const rounded = gb >= 10 ? gb.toFixed(0) : gb.toFixed(1) - return `${rounded.replace(/\.0$/, '')} GB` -} - -function formatHoverTimestamp(timestampMs: number) { - return new Intl.DateTimeFormat(undefined, { - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - }).format(new Date(timestampMs)) + suffix?: ReactNode } interface MetricItemProps { @@ -57,7 +44,9 @@ function MetricItem({ }: MetricItemProps) { return (
- + {icon} {label} @@ -71,43 +60,50 @@ function MetricItem({ export default function ResourceChartHeader({ metric, hovered, + suffix, }: ResourceChartHeaderProps) { const cpuPercent = hovered ? hovered.cpuPercent : (metric?.cpuUsedPct ?? 0) - const cpuValue = `${formatPercent(cpuPercent)} · ${formatCores(metric?.cpuCount ?? 0)}` + const cpuValue = formatMetricValue( + formatPercent(cpuPercent), + formatCoreCount(metric?.cpuCount ?? 0) + ) const ramPercent = hovered ? hovered.ramPercent : metric - ? metric.memTotal > 0 - ? (metric.memUsed / metric.memTotal) * 100 - : 0 + ? calculateRatioPercent(metric.memUsed, metric.memTotal) : 0 - const ramTotalGb = formatRamTotalGb(metric?.memTotal ?? 0) - const ramValue = `${formatPercent(ramPercent)} · ${ramTotalGb}` - const contextLabel = hovered ? formatHoverTimestamp(hovered.timestampMs) : null + const ramTotalGb = formatBytesToGb(metric?.memTotal ?? 0) + const ramValue = formatMetricValue(formatPercent(ramPercent), ramTotalGb) + const contextLabel = hovered + ? formatHoverTimestamp(hovered.timestampMs) + : null return (
} /> } />
- {contextLabel ? ( - - {contextLabel} - - ) : null} +
+ {contextLabel ? ( + + {contextLabel} + + ) : null} + {suffix} +
) } diff --git a/src/features/dashboard/sandbox/monitoring/components/monitoring-sandbox-metrics-chart.tsx b/src/features/dashboard/sandbox/monitoring/components/monitoring-sandbox-metrics-chart.tsx new file mode 100644 index 000000000..3334cf37c --- /dev/null +++ b/src/features/dashboard/sandbox/monitoring/components/monitoring-sandbox-metrics-chart.tsx @@ -0,0 +1,526 @@ +'use client' + +import { + SANDBOX_MONITORING_CHART_AREA_OPACITY, + SANDBOX_MONITORING_CHART_AXIS_LABEL_FONT_SIZE, + SANDBOX_MONITORING_CHART_BRUSH_MODE, + SANDBOX_MONITORING_CHART_BRUSH_TYPE, + SANDBOX_MONITORING_CHART_FALLBACK_FG_TERTIARY, + SANDBOX_MONITORING_CHART_FALLBACK_FONT_MONO, + SANDBOX_MONITORING_CHART_FALLBACK_STROKE, + SANDBOX_MONITORING_CHART_FG_TERTIARY_VAR, + SANDBOX_MONITORING_CHART_FONT_MONO_VAR, + SANDBOX_MONITORING_CHART_GROUP, + SANDBOX_MONITORING_CHART_LINE_WIDTH, + SANDBOX_MONITORING_CHART_LIVE_INNER_DOT_SIZE, + SANDBOX_MONITORING_CHART_LIVE_MIDDLE_DOT_SIZE, + SANDBOX_MONITORING_CHART_LIVE_OUTER_DOT_SIZE, + SANDBOX_MONITORING_CHART_LIVE_WINDOW_MS, + SANDBOX_MONITORING_CHART_OUT_OF_BRUSH_ALPHA, + SANDBOX_MONITORING_CHART_STACK_ID, + SANDBOX_MONITORING_CHART_STROKE_VAR, + SANDBOX_MONITORING_CHART_Y_AXIS_SCALE_FACTOR, +} from '@/features/dashboard/sandbox/monitoring/utils/constants' +import { useCssVars } from '@/lib/hooks/use-css-vars' +import { calculateAxisMax } from '@/lib/utils/chart' +import { formatAxisNumber } from '@/lib/utils/formatting' +import { EChartsOption, MarkPointComponentOption, SeriesOption } from 'echarts' +import ReactEChartsCore from 'echarts-for-react/lib/core' +import { LineChart } from 'echarts/charts' +import { + AxisPointerComponent, + BrushComponent, + GridComponent, + MarkPointComponent, +} from 'echarts/components' +import * as echarts from 'echarts/core' +import { CanvasRenderer } from 'echarts/renderers' +import { useTheme } from 'next-themes' +import { memo, useCallback, useMemo, useRef } from 'react' +import type { + SandboxMetricsChartProps, + SandboxMetricsDataPoint, +} from '../types/sandbox-metrics-chart' + +echarts.use([ + LineChart, + GridComponent, + BrushComponent, + MarkPointComponent, + CanvasRenderer, + AxisPointerComponent, +]) + +interface AxisPointerEventParams { + seriesData?: Array<{ dataIndex?: number }> + value?: unknown + axisValue?: unknown +} + +interface BrushArea { + coordRange?: [unknown, unknown] | unknown[] +} + +interface BrushEndEventParams { + areas?: BrushArea[] +} + +function toNumericValue(value: unknown): number { + if (typeof value === 'number') { + return value + } + + if (typeof value === 'string') { + return Number(value) + } + + return Number.NaN +} + +function resolveStartIndex(categories: number[], value: unknown): number { + if (categories.length === 0) { + return 0 + } + + const numericValue = toNumericValue(value) + if (Number.isNaN(numericValue)) { + return 0 + } + + if (numericValue >= 0 && numericValue <= categories.length - 1) { + return Math.max(0, Math.min(categories.length - 1, Math.floor(numericValue))) + } + + const foundIndex = categories.findIndex((timestamp) => timestamp >= numericValue) + return foundIndex === -1 ? categories.length - 1 : foundIndex +} + +function resolveEndIndex(categories: number[], value: unknown): number { + if (categories.length === 0) { + return 0 + } + + const numericValue = toNumericValue(value) + if (Number.isNaN(numericValue)) { + return categories.length - 1 + } + + if (numericValue >= 0 && numericValue <= categories.length - 1) { + return Math.max(0, Math.min(categories.length - 1, Math.ceil(numericValue))) + } + + for (let index = categories.length - 1; index >= 0; index -= 1) { + const timestamp = categories[index] + + if (timestamp !== undefined && timestamp <= numericValue) { + return index + } + } + + return 0 +} + +function formatXAxisLabel(value: number | string): string { + const timestamp = Number(value) + if (Number.isNaN(timestamp)) { + return '' + } + + const date = new Date(timestamp) + const hours = date.getHours().toString().padStart(2, '0') + const minutes = date.getMinutes().toString().padStart(2, '0') + + return `${hours}:${minutes}` +} + +function findLivePoint( + categories: number[], + data: SandboxMetricsDataPoint[], + now: number = Date.now() +): { x: number; y: number } | null { + const liveBoundary = now - SANDBOX_MONITORING_CHART_LIVE_WINDOW_MS + + for (let index = data.length - 1; index >= 0; index -= 1) { + const point = data[index] + const timestamp = categories[index] + + if (!point || typeof point.y !== 'number' || timestamp === undefined) { + continue + } + + if (timestamp > now) { + continue + } + + if (timestamp < liveBoundary) { + return null + } + + return { + x: timestamp, + y: point.y, + } + } + + return null +} + +function createLiveIndicators( + point: { x: number; y: number }, + lineColor: string +) { + return { + silent: true, + animation: false, + data: [ + { + coord: [point.x, point.y], + symbol: 'circle', + symbolSize: SANDBOX_MONITORING_CHART_LIVE_OUTER_DOT_SIZE, + itemStyle: { + color: 'transparent', + borderColor: lineColor, + borderWidth: 1, + shadowBlur: 8, + shadowColor: lineColor, + opacity: 0.4, + }, + emphasis: { disabled: true }, + label: { show: false }, + }, + { + coord: [point.x, point.y], + symbol: 'circle', + symbolSize: SANDBOX_MONITORING_CHART_LIVE_MIDDLE_DOT_SIZE, + itemStyle: { + color: lineColor, + opacity: 0.3, + borderWidth: 0, + }, + emphasis: { disabled: true }, + label: { show: false }, + }, + { + coord: [point.x, point.y], + symbol: 'circle', + symbolSize: SANDBOX_MONITORING_CHART_LIVE_INNER_DOT_SIZE, + itemStyle: { + color: lineColor, + borderWidth: 0, + shadowBlur: 4, + shadowColor: lineColor, + }, + emphasis: { disabled: true }, + label: { show: false }, + }, + ], + } +} + +function SandboxMetricsChart({ + categories, + series, + className, + stacked = false, + showArea = false, + showXAxisLabels = true, + yAxisMax, + yAxisFormatter = formatAxisNumber, + onHover, + onHoverEnd, + onBrushEnd, +}: SandboxMetricsChartProps) { + const chartInstanceRef = useRef(null) + const { resolvedTheme } = useTheme() + + const cssVarNames = useMemo(() => { + const dynamicVarNames = series.flatMap((line) => + [line.lineColorVar, line.areaColorVar, line.areaToColorVar].filter( + (name): name is string => Boolean(name) + ) + ) + + return Array.from( + new Set([ + SANDBOX_MONITORING_CHART_STROKE_VAR, + SANDBOX_MONITORING_CHART_FG_TERTIARY_VAR, + SANDBOX_MONITORING_CHART_FONT_MONO_VAR, + ...dynamicVarNames, + ]) + ) + }, [series]) + + const cssVars = useCssVars(cssVarNames) + + const stroke = + cssVars[SANDBOX_MONITORING_CHART_STROKE_VAR] || + SANDBOX_MONITORING_CHART_FALLBACK_STROKE + const fgTertiary = + cssVars[SANDBOX_MONITORING_CHART_FG_TERTIARY_VAR] || + SANDBOX_MONITORING_CHART_FALLBACK_FG_TERTIARY + const fontMono = + cssVars[SANDBOX_MONITORING_CHART_FONT_MONO_VAR] || + SANDBOX_MONITORING_CHART_FALLBACK_FONT_MONO + + const handleAxisPointer = useCallback( + (params: AxisPointerEventParams) => { + if (!onHover) { + return '' + } + + const dataIndex = params.seriesData?.[0]?.dataIndex + if (typeof dataIndex === 'number') { + onHover(dataIndex) + return '' + } + + const pointerValue = params.value ?? params.axisValue + const normalizedValue = toNumericValue(pointerValue) + if (Number.isNaN(normalizedValue)) { + return '' + } + + const matchedIndex = categories.findIndex((value) => value === normalizedValue) + if (matchedIndex >= 0) { + onHover(matchedIndex) + } + + return '' + }, + [categories, onHover] + ) + + const handleGlobalOut = useCallback(() => { + onHoverEnd?.() + }, [onHoverEnd]) + + const handleBrushEnd = useCallback( + (params: BrushEndEventParams) => { + const coordRange = params.areas?.[0]?.coordRange + if (!coordRange || coordRange.length !== 2 || !onBrushEnd) { + return + } + + const rawStartIndex = resolveStartIndex(categories, coordRange[0]) + const rawEndIndex = resolveEndIndex(categories, coordRange[1]) + const startIndex = Math.min(rawStartIndex, rawEndIndex) + const endIndex = Math.max(rawStartIndex, rawEndIndex) + const startTimestamp = categories[startIndex] + const endTimestamp = categories[endIndex] + + if (startTimestamp !== undefined && endTimestamp !== undefined) { + onBrushEnd(startTimestamp, endTimestamp) + } + + chartInstanceRef.current?.dispatchAction({ + type: 'brush', + command: 'clear', + areas: [], + }) + }, + [categories, onBrushEnd] + ) + + const handleChartReady = useCallback((chart: echarts.ECharts) => { + chartInstanceRef.current = chart + + chart.dispatchAction( + { + type: 'takeGlobalCursor', + key: 'brush', + brushOption: { + brushType: SANDBOX_MONITORING_CHART_BRUSH_TYPE, + brushMode: SANDBOX_MONITORING_CHART_BRUSH_MODE, + }, + }, + { flush: true } + ) + + chart.group = SANDBOX_MONITORING_CHART_GROUP + echarts.connect(SANDBOX_MONITORING_CHART_GROUP) + }, []) + + const option = useMemo(() => { + const values = series.flatMap((line) => + line.data + .map((point) => point.y) + .filter((value): value is number => value !== null) + ) + const computedYAxisMax = + yAxisMax ?? + calculateAxisMax( + values.length > 0 ? values : [0], + SANDBOX_MONITORING_CHART_Y_AXIS_SCALE_FACTOR + ) + + const seriesItems: SeriesOption[] = series.map((line) => { + const lineColor = line.lineColorVar + ? cssVars[line.lineColorVar] + : undefined + const areaFromColor = line.areaColorVar + ? cssVars[line.areaColorVar] + : undefined + const areaToColor = line.areaToColorVar + ? cssVars[line.areaToColorVar] + : undefined + const resolvedLineColor = lineColor || stroke + const livePoint = findLivePoint(categories, line.data) + const shouldShowArea = stacked || showArea + const areaFillColor = + areaFromColor && areaToColor + ? { + type: 'linear' as const, + x: 0, + y: 0, + x2: 0, + y2: 1, + colorStops: [ + { offset: 0, color: areaFromColor }, + { offset: 1, color: areaToColor }, + ], + } + : areaFromColor || resolvedLineColor + const areaOpacity = + areaFromColor || areaToColor ? 1 : SANDBOX_MONITORING_CHART_AREA_OPACITY + + const seriesItem: SeriesOption = { + id: line.id, + name: line.name, + type: 'line', + symbol: 'none', + showSymbol: false, + smooth: false, + emphasis: { + disabled: true, + }, + stack: stacked ? SANDBOX_MONITORING_CHART_STACK_ID : undefined, + areaStyle: shouldShowArea + ? { + opacity: areaOpacity, + color: areaFillColor, + } + : undefined, + lineStyle: { + width: SANDBOX_MONITORING_CHART_LINE_WIDTH, + color: resolvedLineColor, + }, + data: line.data.map((point) => point.y ?? '-'), + } + + if (livePoint) { + seriesItem.markPoint = createLiveIndicators( + livePoint, + resolvedLineColor + ) as MarkPointComponentOption + } + + return seriesItem + }) + + return { + backgroundColor: 'transparent', + animation: false, + brush: { + brushType: SANDBOX_MONITORING_CHART_BRUSH_TYPE, + brushMode: SANDBOX_MONITORING_CHART_BRUSH_MODE, + xAxisIndex: 0, + brushLink: 'all', + brushStyle: { borderWidth: SANDBOX_MONITORING_CHART_LINE_WIDTH }, + outOfBrush: { colorAlpha: SANDBOX_MONITORING_CHART_OUT_OF_BRUSH_ALPHA }, + }, + grid: { + top: 10, + bottom: showXAxisLabels ? 24 : 10, + left: 36, + right: 8, + }, + xAxis: { + type: 'category', + data: categories, + boundaryGap: false, + axisLine: { show: true, lineStyle: { color: stroke } }, + axisTick: { show: false }, + splitLine: { show: false }, + axisLabel: { + show: showXAxisLabels, + color: fgTertiary, + fontFamily: fontMono, + fontSize: SANDBOX_MONITORING_CHART_AXIS_LABEL_FONT_SIZE, + hideOverlap: true, + formatter: formatXAxisLabel, + }, + axisPointer: { + show: true, + type: 'line', + lineStyle: { + color: stroke, + type: 'solid', + width: SANDBOX_MONITORING_CHART_LINE_WIDTH, + }, + snap: false, + label: { + backgroundColor: 'transparent', + formatter: handleAxisPointer, + }, + }, + }, + yAxis: { + type: 'value', + min: 0, + max: computedYAxisMax, + interval: computedYAxisMax / 2, + axisLine: { show: false }, + axisTick: { show: false }, + splitLine: { + show: true, + lineStyle: { color: stroke, type: 'dashed' }, + interval: 0, + }, + axisLabel: { + show: true, + color: fgTertiary, + fontFamily: fontMono, + fontSize: SANDBOX_MONITORING_CHART_AXIS_LABEL_FONT_SIZE, + interval: 0, + formatter: yAxisFormatter, + }, + axisPointer: { show: false }, + }, + series: seriesItems, + } + }, [ + categories, + cssVars, + fgTertiary, + fontMono, + handleAxisPointer, + series, + showXAxisLabels, + showArea, + stacked, + stroke, + yAxisFormatter, + yAxisMax, + ]) + + return ( + + ) +} + +const MemoizedSandboxMetricsChart = memo(SandboxMetricsChart) + +MemoizedSandboxMetricsChart.displayName = 'SandboxMetricsChart' + +export default MemoizedSandboxMetricsChart diff --git a/src/features/dashboard/sandbox/monitoring/components/monitoring-time-range-controls.tsx b/src/features/dashboard/sandbox/monitoring/components/monitoring-time-range-controls.tsx new file mode 100644 index 000000000..b9cee2f7c --- /dev/null +++ b/src/features/dashboard/sandbox/monitoring/components/monitoring-time-range-controls.tsx @@ -0,0 +1,324 @@ +'use client' + +import { cn } from '@/lib/utils' +import { findMatchingPreset } from '@/lib/utils/time-range' +import { LiveDot } from '@/ui/live' +import { Button } from '@/ui/primitives/button' +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/ui/primitives/popover' +import { Separator } from '@/ui/primitives/separator' +import { TimeRangePicker, type TimeRangeValues } from '@/ui/time-range-picker' +import { TimeRangePresets, type TimeRangePreset } from '@/ui/time-range-presets' +import { + millisecondsInHour, + millisecondsInMinute, +} from 'date-fns/constants' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { + SANDBOX_MONITORING_CUSTOM_RANGE_END_TIME, + SANDBOX_MONITORING_CUSTOM_RANGE_START_TIME, + SANDBOX_MONITORING_FIRST_15_MINUTES_PRESET_ID, + SANDBOX_MONITORING_FIRST_15_MINUTES_PRESET_SHORTCUT, + SANDBOX_MONITORING_FIRST_HOUR_PRESET_ID, + SANDBOX_MONITORING_FIRST_HOUR_PRESET_SHORTCUT, + SANDBOX_MONITORING_FULL_LIFECYCLE_PRESET_ID, + SANDBOX_MONITORING_FULL_LIFECYCLE_PRESET_SHORTCUT, + SANDBOX_MONITORING_LAST_15_MINUTES_PRESET_ID, + SANDBOX_MONITORING_LAST_15_MINUTES_PRESET_SHORTCUT, + SANDBOX_MONITORING_LAST_6_HOURS_PRESET_ID, + SANDBOX_MONITORING_LAST_6_HOURS_PRESET_SHORTCUT, + SANDBOX_MONITORING_LAST_HOUR_PRESET_ID, + SANDBOX_MONITORING_LAST_HOUR_PRESET_SHORTCUT, + SANDBOX_MONITORING_PRESET_MATCH_TOLERANCE_MS, + SANDBOX_MONITORING_TIME_LABEL_FORMAT_OPTIONS, +} from '../utils/constants' +import { + clampTimeframeToBounds, + type SandboxLifecycleBounds, +} from '../utils/timeframe' + +interface SandboxMonitoringTimeRangeControlsProps { + timeframe: { + start: number + end: number + } + lifecycle: SandboxLifecycleBounds + isLiveUpdating: boolean + onLiveChange: (isLiveUpdating: boolean) => void + onTimeRangeChange: ( + start: number, + end: number, + options?: { isLiveUpdating?: boolean } + ) => void + className?: string +} + +export default function SandboxMonitoringTimeRangeControls({ + timeframe, + lifecycle, + isLiveUpdating, + onLiveChange, + onTimeRangeChange, + className, +}: SandboxMonitoringTimeRangeControlsProps) { + const [isOpen, setIsOpen] = useState(false) + const [pickerMaxDateMs, setPickerMaxDateMs] = useState(() => Date.now()) + + const clampToLifecycle = useCallback( + (start: number, end: number) => { + const maxBoundMs = lifecycle.isRunning + ? Date.now() + : lifecycle.anchorEndMs + + return clampTimeframeToBounds( + start, + end, + lifecycle.startMs, + maxBoundMs + ) + }, + [lifecycle.anchorEndMs, lifecycle.isRunning, lifecycle.startMs] + ) + + const presets = useMemo(() => { + const makeTrailing = ( + id: string, + label: string, + shortcut: string, + rangeMs: number + ): TimeRangePreset => ({ + id, + label, + shortcut, + isLiveUpdating: lifecycle.isRunning, + getValue: () => { + const anchorEndMs = lifecycle.isRunning + ? Date.now() + : lifecycle.anchorEndMs + const lifecycleDuration = anchorEndMs - lifecycle.startMs + + return clampToLifecycle( + anchorEndMs - Math.min(rangeMs, lifecycleDuration), + anchorEndMs + ) + }, + }) + + const makeLeading = ( + id: string, + label: string, + shortcut: string, + rangeMs: number + ): TimeRangePreset => ({ + id, + label, + shortcut, + isLiveUpdating: false, + getValue: () => { + const anchorEndMs = lifecycle.isRunning + ? Date.now() + : lifecycle.anchorEndMs + const lifecycleDuration = anchorEndMs - lifecycle.startMs + + return clampToLifecycle( + lifecycle.startMs, + lifecycle.startMs + Math.min(rangeMs, lifecycleDuration) + ) + }, + }) + + return [ + { + id: SANDBOX_MONITORING_FULL_LIFECYCLE_PRESET_ID, + label: lifecycle.isRunning ? 'From start to now' : 'Full lifecycle', + shortcut: SANDBOX_MONITORING_FULL_LIFECYCLE_PRESET_SHORTCUT, + isLiveUpdating: lifecycle.isRunning, + getValue: () => { + const anchorEndMs = lifecycle.isRunning + ? Date.now() + : lifecycle.anchorEndMs + return clampToLifecycle(lifecycle.startMs, anchorEndMs) + }, + }, + makeLeading( + SANDBOX_MONITORING_FIRST_15_MINUTES_PRESET_ID, + 'First 15 min', + SANDBOX_MONITORING_FIRST_15_MINUTES_PRESET_SHORTCUT, + 15 * millisecondsInMinute + ), + makeLeading( + SANDBOX_MONITORING_FIRST_HOUR_PRESET_ID, + 'First 1 hour', + SANDBOX_MONITORING_FIRST_HOUR_PRESET_SHORTCUT, + millisecondsInHour + ), + makeTrailing( + SANDBOX_MONITORING_LAST_15_MINUTES_PRESET_ID, + 'Last 15 min', + SANDBOX_MONITORING_LAST_15_MINUTES_PRESET_SHORTCUT, + 15 * millisecondsInMinute + ), + makeTrailing( + SANDBOX_MONITORING_LAST_HOUR_PRESET_ID, + 'Last 1 hour', + SANDBOX_MONITORING_LAST_HOUR_PRESET_SHORTCUT, + millisecondsInHour + ), + makeTrailing( + SANDBOX_MONITORING_LAST_6_HOURS_PRESET_ID, + 'Last 6 hours', + SANDBOX_MONITORING_LAST_6_HOURS_PRESET_SHORTCUT, + 6 * millisecondsInHour + ), + ] + }, [ + clampToLifecycle, + lifecycle.anchorEndMs, + lifecycle.isRunning, + lifecycle.startMs, + ]) + + const selectedPresetId = useMemo( + () => + findMatchingPreset( + presets, + timeframe.start, + timeframe.end, + SANDBOX_MONITORING_PRESET_MATCH_TOLERANCE_MS + ), + [presets, timeframe.end, timeframe.start] + ) + + const rangeLabel = useMemo(() => { + const formatter = new Intl.DateTimeFormat( + undefined, + SANDBOX_MONITORING_TIME_LABEL_FORMAT_OPTIONS + ) + + return `${formatter.format(timeframe.start)} - ${formatter.format(timeframe.end)}` + }, [timeframe.end, timeframe.start]) + + useEffect(() => { + if (isOpen && lifecycle.isRunning) { + setPickerMaxDateMs(Date.now()) + } + }, [isOpen, lifecycle.isRunning]) + + const pickerMaxDate = useMemo( + () => + lifecycle.isRunning + ? new Date(pickerMaxDateMs) + : new Date(lifecycle.anchorEndMs), + [lifecycle.anchorEndMs, lifecycle.isRunning, pickerMaxDateMs] + ) + + const handlePresetSelect = useCallback( + (preset: TimeRangePreset) => { + const { start, end } = preset.getValue() + onTimeRangeChange(start, end, { + isLiveUpdating: preset.isLiveUpdating, + }) + setIsOpen(false) + }, + [onTimeRangeChange] + ) + + const handleApply = useCallback( + (values: TimeRangeValues) => { + const startTime = values.startTime || SANDBOX_MONITORING_CUSTOM_RANGE_START_TIME + const endTime = values.endTime || SANDBOX_MONITORING_CUSTOM_RANGE_END_TIME + + const start = new Date(`${values.startDate} ${startTime}`).getTime() + const end = new Date(`${values.endDate} ${endTime}`).getTime() + const next = clampToLifecycle(start, end) + + onTimeRangeChange(next.start, next.end, { + isLiveUpdating: false, + }) + setIsOpen(false) + }, + [clampToLifecycle, onTimeRangeChange] + ) + + const handleLiveToggle = useCallback(() => { + if (!lifecycle.isRunning) { + onLiveChange(false) + return + } + + if (isLiveUpdating) { + onLiveChange(false) + return + } + + const duration = timeframe.end - timeframe.start + const anchorEndMs = lifecycle.isRunning + ? Date.now() + : lifecycle.anchorEndMs + const next = clampToLifecycle( + anchorEndMs - duration, + anchorEndMs + ) + + onTimeRangeChange(next.start, next.end, { + isLiveUpdating: true, + }) + }, [ + clampToLifecycle, + isLiveUpdating, + lifecycle.anchorEndMs, + lifecycle.isRunning, + onLiveChange, + onTimeRangeChange, + timeframe.end, + timeframe.start, + ]) + + return ( +
+ + + + + + +
+ + + + +
+
+
+
+ ) +} diff --git a/src/features/dashboard/sandbox/monitoring/view.tsx b/src/features/dashboard/sandbox/monitoring/components/monitoring-view.tsx similarity index 85% rename from src/features/dashboard/sandbox/monitoring/view.tsx rename to src/features/dashboard/sandbox/monitoring/components/monitoring-view.tsx index 0f6250eb7..114e87229 100644 --- a/src/features/dashboard/sandbox/monitoring/view.tsx +++ b/src/features/dashboard/sandbox/monitoring/components/monitoring-view.tsx @@ -1,6 +1,6 @@ 'use client' -import SandboxMetricsCharts from './charts' +import SandboxMetricsCharts from './monitoring-charts' interface SandboxMonitoringViewProps { sandboxId: string diff --git a/src/features/dashboard/sandbox/monitoring/hooks/use-sandbox-monitoring-controller.ts b/src/features/dashboard/sandbox/monitoring/hooks/use-sandbox-monitoring-controller.ts deleted file mode 100644 index c8b5fe4b0..000000000 --- a/src/features/dashboard/sandbox/monitoring/hooks/use-sandbox-monitoring-controller.ts +++ /dev/null @@ -1,136 +0,0 @@ -'use client' - -import { SANDBOXES_DETAILS_METRICS_POLLING_MS } from '@/configs/intervals' -import { useDashboard } from '@/features/dashboard/context' -import { useTRPCClient } from '@/trpc/client' -import { usePathname, useRouter, useSearchParams } from 'next/navigation' -import { useCallback, useEffect, useMemo } from 'react' -import { useSandboxMonitoringStore } from '../store' - -function parseQueryInteger(value: string | null): number | null { - if (!value) return null - const parsed = Number.parseInt(value, 10) - return Number.isNaN(parsed) ? null : parsed -} - -export function useSandboxMonitoringController(sandboxId: string) { - const trpcClient = useTRPCClient() - const { team } = useDashboard() - const router = useRouter() - const pathname = usePathname() - const searchParams = useSearchParams() - - const timeframe = useSandboxMonitoringStore((state) => state.timeframe) - const metrics = useSandboxMonitoringStore((state) => state.metrics) - const isLoading = useSandboxMonitoringStore((state) => state.isLoading) - const error = useSandboxMonitoringStore((state) => state.error) - const isInitialized = useSandboxMonitoringStore( - (state) => state.isInitialized - ) - const initialize = useSandboxMonitoringStore((state) => state.initialize) - const setTimeframe = useSandboxMonitoringStore((state) => state.setTimeframe) - const setMetrics = useSandboxMonitoringStore((state) => state.setMetrics) - const setLoading = useSandboxMonitoringStore((state) => state.setLoading) - const setError = useSandboxMonitoringStore((state) => state.setError) - - const urlTimeframe = useMemo( - () => ({ - start: parseQueryInteger(searchParams.get('start')), - end: parseQueryInteger(searchParams.get('end')), - }), - [searchParams] - ) - - useEffect(() => { - initialize(sandboxId, urlTimeframe) - }, [initialize, sandboxId, urlTimeframe]) - - useEffect(() => { - if (!isInitialized) return - - const currentStart = searchParams.get('start') - const currentEnd = searchParams.get('end') - const nextStart = String(timeframe.start) - const nextEnd = String(timeframe.end) - - if (currentStart === nextStart && currentEnd === nextEnd) { - return - } - - const nextParams = new URLSearchParams(searchParams.toString()) - nextParams.set('start', nextStart) - nextParams.set('end', nextEnd) - - router.replace(`${pathname}?${nextParams.toString()}`, { scroll: false }) - }, [ - isInitialized, - pathname, - router, - searchParams, - timeframe.end, - timeframe.start, - ]) - - const fetchMetrics = useCallback(async () => { - if (!team?.id) { - return - } - - setLoading(true) - setError(null) - - try { - const result = await trpcClient.sandbox.resourceMetrics.query({ - teamIdOrSlug: team.id, - sandboxId, - startMs: timeframe.start, - endMs: timeframe.end, - }) - - setMetrics(result) - setError(null) - } catch (error) { - setError( - error instanceof Error - ? error.message - : 'Failed to fetch sandbox monitoring metrics.' - ) - } finally { - setLoading(false) - } - }, [ - sandboxId, - setError, - setLoading, - setMetrics, - team?.id, - timeframe.end, - timeframe.start, - trpcClient, - ]) - - useEffect(() => { - if (!isInitialized) return - void fetchMetrics() - }, [fetchMetrics, isInitialized]) - - useEffect(() => { - if (!isInitialized || !timeframe.isLive) return - - const intervalId = setInterval(() => { - void fetchMetrics() - }, SANDBOXES_DETAILS_METRICS_POLLING_MS) - - return () => { - clearInterval(intervalId) - } - }, [fetchMetrics, isInitialized, timeframe.isLive]) - - return { - timeframe, - metrics, - isLoading, - error, - setTimeframe, - } -} diff --git a/src/features/dashboard/sandbox/monitoring/state/store.ts b/src/features/dashboard/sandbox/monitoring/state/store.ts new file mode 100644 index 000000000..a201fffe7 --- /dev/null +++ b/src/features/dashboard/sandbox/monitoring/state/store.ts @@ -0,0 +1,206 @@ +'use client' + +import type { SandboxMetric } from '@/server/api/models/sandboxes.models' +import { create } from 'zustand' +import { + SANDBOX_MONITORING_DEFAULT_RANGE_MS, + SANDBOX_MONITORING_MIN_RANGE_MS, +} from '../utils/constants' + +interface SandboxMonitoringTimeframe { + start: number + end: number + duration: number +} + +function normalizeTimeframe( + start: number, + end: number, + now: number = Date.now() +): SandboxMonitoringTimeframe { + const minimumRange = SANDBOX_MONITORING_MIN_RANGE_MS + + let safeStart = Math.floor(start) + let safeEnd = Math.floor(end) + + if (safeEnd > now) { + safeEnd = now + } + + if (safeEnd <= safeStart) { + safeEnd = safeStart + minimumRange + } + + const range = safeEnd - safeStart + if (range < minimumRange) { + safeStart = safeEnd - minimumRange + } + + return { + start: safeStart, + end: safeEnd, + duration: safeEnd - safeStart, + } +} + +function getDefaultTimeframe() { + const now = Date.now() + + return normalizeTimeframe(now - SANDBOX_MONITORING_DEFAULT_RANGE_MS, now, now) +} + +interface SandboxMonitoringStoreState { + sandboxId: string | null + timeframe: SandboxMonitoringTimeframe + isLiveUpdating: boolean + metrics: SandboxMetric[] + isInitialized: boolean +} + +interface SandboxMonitoringStoreActions { + initialize: ( + sandboxId: string, + params: { + start?: number | null + end?: number | null + live?: boolean | null + } + ) => void + setTimeframe: ( + start: number, + end: number, + options?: { isLiveUpdating?: boolean } + ) => void + setLiveUpdating: (isLiveUpdating: boolean, now?: number) => void + syncLiveTimeframe: (now?: number) => SandboxMonitoringTimeframe + setMetrics: (metrics: SandboxMetric[]) => void +} + +type SandboxMonitoringStore = SandboxMonitoringStoreState & + SandboxMonitoringStoreActions + +const initialTimeframe = getDefaultTimeframe() + +const initialState: SandboxMonitoringStoreState = { + sandboxId: null, + timeframe: initialTimeframe, + isLiveUpdating: true, + metrics: [], + isInitialized: false, +} + +export const useSandboxMonitoringStore = create()( + (set, get) => ({ + ...initialState, + + initialize: (sandboxId, params) => { + const now = Date.now() + const current = get() + const isNewSandbox = current.sandboxId !== sandboxId + + const fallbackStart = now - SANDBOX_MONITORING_DEFAULT_RANGE_MS + const fallbackEnd = now + const start = params.start ?? fallbackStart + const end = params.end ?? fallbackEnd + const isLiveUpdating = params.live ?? true + const normalized = normalizeTimeframe(start, end) + + set((state) => { + const shouldUpdate = + !state.isInitialized || + isNewSandbox || + state.isLiveUpdating !== isLiveUpdating || + state.timeframe.start !== normalized.start || + state.timeframe.end !== normalized.end + + if (!shouldUpdate) { + return state + } + + return { + sandboxId, + timeframe: normalized, + isLiveUpdating, + metrics: isNewSandbox ? [] : state.metrics, + isInitialized: true, + } + }) + }, + + setTimeframe: (start, end, options) => { + set((state) => { + const normalized = normalizeTimeframe(start, end) + const nextLiveUpdating = + options?.isLiveUpdating ?? state.isLiveUpdating + + if ( + state.timeframe.start === normalized.start && + state.timeframe.end === normalized.end && + state.isLiveUpdating === nextLiveUpdating + ) { + return state + } + + return { + timeframe: normalized, + isLiveUpdating: nextLiveUpdating, + } + }) + }, + + setLiveUpdating: (isLiveUpdating, now = Date.now()) => { + set((state) => { + if (state.isLiveUpdating === isLiveUpdating) { + return state + } + + if (!isLiveUpdating) { + return { isLiveUpdating: false } + } + + const nextTimeframe = normalizeTimeframe( + now - state.timeframe.duration, + now, + now + ) + + return { + isLiveUpdating: true, + timeframe: nextTimeframe, + } + }) + }, + + syncLiveTimeframe: (now = Date.now()) => { + const state = get() + if (!state.isLiveUpdating) { + return state.timeframe + } + + const nextTimeframe = normalizeTimeframe( + now - state.timeframe.duration, + now, + now + ) + + if ( + nextTimeframe.start === state.timeframe.start && + nextTimeframe.end === state.timeframe.end + ) { + return state.timeframe + } + + set({ + timeframe: nextTimeframe, + }) + + return nextTimeframe + }, + + setMetrics: (metrics) => { + set({ + metrics, + }) + }, + }) +) diff --git a/src/features/dashboard/sandbox/monitoring/state/use-sandbox-monitoring-controller.ts b/src/features/dashboard/sandbox/monitoring/state/use-sandbox-monitoring-controller.ts new file mode 100644 index 000000000..4b48b2f26 --- /dev/null +++ b/src/features/dashboard/sandbox/monitoring/state/use-sandbox-monitoring-controller.ts @@ -0,0 +1,195 @@ +'use client' + +import { useDashboard } from '@/features/dashboard/context' +import type { SandboxMetric } from '@/server/api/models/sandboxes.models' +import { useTRPCClient } from '@/trpc/client' +import { keepPreviousData, useQuery } from '@tanstack/react-query' +import { usePathname, useRouter, useSearchParams } from 'next/navigation' +import { useEffect, useMemo } from 'react' +import { + SANDBOX_MONITORING_LIVE_POLLING_MS, + SANDBOX_MONITORING_METRICS_FETCH_ERROR_MESSAGE, + SANDBOX_MONITORING_QUERY_END_PARAM, + SANDBOX_MONITORING_QUERY_LIVE_FALSE, + SANDBOX_MONITORING_QUERY_LIVE_PARAM, + SANDBOX_MONITORING_QUERY_LIVE_TRUE, + SANDBOX_MONITORING_QUERY_START_PARAM, +} from '../utils/constants' +import { useSandboxMonitoringStore } from './store' + +function parseQueryInteger(value: string | null): number | null { + if (!value) return null + const parsed = Number.parseInt(value, 10) + return Number.isNaN(parsed) ? null : parsed +} + +function parseQueryBoolean(value: string | null): boolean | null { + if (value === null) { + return null + } + + if (value === SANDBOX_MONITORING_QUERY_LIVE_TRUE || value === 'true') { + return true + } + + if (value === SANDBOX_MONITORING_QUERY_LIVE_FALSE || value === 'false') { + return false + } + + return null +} + +export function useSandboxMonitoringController(sandboxId: string) { + const trpcClient = useTRPCClient() + const { team } = useDashboard() + const router = useRouter() + const pathname = usePathname() + const searchParams = useSearchParams() + + const timeframe = useSandboxMonitoringStore((state) => state.timeframe) + const metrics = useSandboxMonitoringStore((state) => state.metrics) + const isLiveUpdating = useSandboxMonitoringStore( + (state) => state.isLiveUpdating + ) + const isInitialized = useSandboxMonitoringStore( + (state) => state.isInitialized + ) + const initialize = useSandboxMonitoringStore((state) => state.initialize) + const setTimeframe = useSandboxMonitoringStore((state) => state.setTimeframe) + const setMetrics = useSandboxMonitoringStore((state) => state.setMetrics) + const setLiveUpdating = useSandboxMonitoringStore( + (state) => state.setLiveUpdating + ) + + const urlState = useMemo( + () => ({ + start: parseQueryInteger( + searchParams.get(SANDBOX_MONITORING_QUERY_START_PARAM) + ), + end: parseQueryInteger( + searchParams.get(SANDBOX_MONITORING_QUERY_END_PARAM) + ), + live: parseQueryBoolean( + searchParams.get(SANDBOX_MONITORING_QUERY_LIVE_PARAM) + ), + }), + [searchParams] + ) + + useEffect(() => { + initialize(sandboxId, urlState) + }, [initialize, sandboxId, urlState]) + + useEffect(() => { + if (!isInitialized) return + + const currentStart = searchParams.get(SANDBOX_MONITORING_QUERY_START_PARAM) + const currentEnd = searchParams.get(SANDBOX_MONITORING_QUERY_END_PARAM) + const currentLive = searchParams.get(SANDBOX_MONITORING_QUERY_LIVE_PARAM) + const nextStart = String(timeframe.start) + const nextEnd = String(timeframe.end) + const nextLive = isLiveUpdating + ? SANDBOX_MONITORING_QUERY_LIVE_TRUE + : SANDBOX_MONITORING_QUERY_LIVE_FALSE + + if ( + currentStart === nextStart && + currentEnd === nextEnd && + currentLive === nextLive + ) { + return + } + + const nextParams = new URLSearchParams(searchParams.toString()) + nextParams.set(SANDBOX_MONITORING_QUERY_START_PARAM, nextStart) + nextParams.set(SANDBOX_MONITORING_QUERY_END_PARAM, nextEnd) + nextParams.set(SANDBOX_MONITORING_QUERY_LIVE_PARAM, nextLive) + + router.replace(`${pathname}?${nextParams.toString()}`, { scroll: false }) + }, [ + isInitialized, + isLiveUpdating, + pathname, + router, + searchParams, + timeframe.end, + timeframe.start, + ]) + + const queryKey = useMemo(() => { + if (isLiveUpdating) { + return [ + 'sandboxMonitoringMetrics', + team?.id ?? '', + sandboxId, + 'live', + timeframe.duration, + ] as const + } + + return [ + 'sandboxMonitoringMetrics', + team?.id ?? '', + sandboxId, + 'static', + timeframe.start, + timeframe.end, + ] as const + }, [ + isLiveUpdating, + sandboxId, + team?.id, + timeframe.duration, + timeframe.end, + timeframe.start, + ]) + + const metricsQuery = useQuery({ + queryKey, + enabled: isInitialized && Boolean(team?.id), + placeholderData: keepPreviousData, + refetchInterval: isLiveUpdating + ? SANDBOX_MONITORING_LIVE_POLLING_MS + : false, + refetchIntervalInBackground: false, + refetchOnWindowFocus: isLiveUpdating ? 'always' : false, + queryFn: async () => { + if (!team?.id) { + return [] + } + + const queryTimeframe = isLiveUpdating + ? useSandboxMonitoringStore.getState().syncLiveTimeframe() + : useSandboxMonitoringStore.getState().timeframe + + return trpcClient.sandbox.resourceMetrics.query({ + teamIdOrSlug: team.id, + sandboxId, + startMs: queryTimeframe.start, + endMs: queryTimeframe.end, + }) + }, + }) + + useEffect(() => { + if (metricsQuery.data) { + setMetrics(metricsQuery.data) + } + }, [metricsQuery.data, setMetrics]) + + const error = metricsQuery.error + ? metricsQuery.error instanceof Error + ? metricsQuery.error.message + : SANDBOX_MONITORING_METRICS_FETCH_ERROR_MESSAGE + : null + + return { + timeframe, + metrics, + isLiveUpdating, + isLoading: metricsQuery.isLoading || metricsQuery.isFetching, + error, + setTimeframe, + setLiveUpdating, + } +} diff --git a/src/features/dashboard/sandbox/monitoring/store.ts b/src/features/dashboard/sandbox/monitoring/store.ts deleted file mode 100644 index b740bef09..000000000 --- a/src/features/dashboard/sandbox/monitoring/store.ts +++ /dev/null @@ -1,160 +0,0 @@ -'use client' - -import type { SandboxMetric } from '@/server/api/models/sandboxes.models' -import { create } from 'zustand' -import { calculateIsLive } from './utils' - -const DEFAULT_RANGE_MS = 1 * 60 * 60 * 1000 // 1 hour -const MAX_DAYS_AGO_MS = 31 * 24 * 60 * 60 * 1000 -const MIN_RANGE_MS = 90 * 1000 - -const getStableNow = () => Math.floor(Date.now() / 1000) * 1000 - -function normalizeTimeframe(start: number, end: number) { - const now = getStableNow() - - let safeStart = Math.floor(start) - let safeEnd = Math.floor(end) - - if (safeEnd > now) { - safeEnd = now - } - - if (safeStart < now - MAX_DAYS_AGO_MS) { - safeStart = now - MAX_DAYS_AGO_MS - } - - if (safeEnd <= safeStart) { - safeEnd = safeStart + MIN_RANGE_MS - } - - const range = safeEnd - safeStart - if (range < MIN_RANGE_MS) { - safeStart = safeEnd - MIN_RANGE_MS - } - - return { - start: safeStart, - end: safeEnd, - duration: safeEnd - safeStart, - isLive: calculateIsLive(safeStart, safeEnd, now), - } -} - -function getDefaultTimeframe() { - const now = getStableNow() - return normalizeTimeframe(now - DEFAULT_RANGE_MS, now) -} - -interface SandboxMonitoringStoreState { - sandboxId: string | null - timeframe: { - start: number - end: number - duration: number - isLive: boolean - } - metrics: SandboxMetric[] - isLoading: boolean - error: string | null - isInitialized: boolean -} - -interface SandboxMonitoringStoreActions { - initialize: ( - sandboxId: string, - params: { start?: number | null; end?: number | null } - ) => void - setTimeframe: (start: number, end: number) => void - setMetrics: (metrics: SandboxMetric[]) => void - setLoading: (isLoading: boolean) => void - setError: (error: string | null) => void -} - -type SandboxMonitoringStore = SandboxMonitoringStoreState & - SandboxMonitoringStoreActions - -const initialTimeframe = getDefaultTimeframe() - -const initialState: SandboxMonitoringStoreState = { - sandboxId: null, - timeframe: initialTimeframe, - metrics: [], - isLoading: false, - error: null, - isInitialized: false, -} - -function sortMetricsByTime(metrics: SandboxMetric[]): SandboxMetric[] { - return [...metrics].sort((a, b) => { - const timeA = - typeof a.timestampUnix === 'number' - ? a.timestampUnix * 1000 - : new Date(a.timestamp).getTime() - const timeB = - typeof b.timestampUnix === 'number' - ? b.timestampUnix * 1000 - : new Date(b.timestamp).getTime() - return timeA - timeB - }) -} - -export const useSandboxMonitoringStore = create()( - (set, get) => ({ - ...initialState, - - initialize: (sandboxId, params) => { - const now = getStableNow() - const current = get() - const isNewSandbox = current.sandboxId !== sandboxId - - const fallbackStart = now - DEFAULT_RANGE_MS - const fallbackEnd = now - const start = params.start ?? fallbackStart - const end = params.end ?? fallbackEnd - const normalized = normalizeTimeframe(start, end) - - set((state) => { - const shouldUpdateTimeframe = - !state.isInitialized || - isNewSandbox || - state.timeframe.start !== normalized.start || - state.timeframe.end !== normalized.end - - if (!shouldUpdateTimeframe) { - return state - } - - return { - sandboxId, - timeframe: normalized, - metrics: isNewSandbox ? [] : state.metrics, - isLoading: isNewSandbox ? false : state.isLoading, - error: isNewSandbox ? null : state.error, - isInitialized: true, - } - }) - }, - - setTimeframe: (start, end) => { - set((state) => ({ - timeframe: normalizeTimeframe(start, end), - error: state.error, - })) - }, - - setMetrics: (metrics) => { - set({ - metrics: sortMetricsByTime(metrics), - }) - }, - - setLoading: (isLoading) => { - set({ isLoading }) - }, - - setError: (error) => { - set({ error }) - }, - }) -) diff --git a/src/features/dashboard/sandbox/monitoring/charts/sandbox-metrics-chart/types.ts b/src/features/dashboard/sandbox/monitoring/types/sandbox-metrics-chart.ts similarity index 85% rename from src/features/dashboard/sandbox/monitoring/charts/sandbox-metrics-chart/types.ts rename to src/features/dashboard/sandbox/monitoring/types/sandbox-metrics-chart.ts index d7f73c2fc..d58307596 100644 --- a/src/features/dashboard/sandbox/monitoring/charts/sandbox-metrics-chart/types.ts +++ b/src/features/dashboard/sandbox/monitoring/types/sandbox-metrics-chart.ts @@ -1,6 +1,6 @@ export type SandboxMetricsDataPoint = { - x: number // timestamp - y: number | null // value + x: number + y: number | null } export interface SandboxMetricsSeries { @@ -9,6 +9,7 @@ export interface SandboxMetricsSeries { data: SandboxMetricsDataPoint[] lineColorVar?: string areaColorVar?: string + areaToColorVar?: string } export interface SandboxMetricsChartProps { @@ -16,9 +17,8 @@ export interface SandboxMetricsChartProps { series: SandboxMetricsSeries[] className?: string stacked?: boolean + showArea?: boolean showXAxisLabels?: boolean - xAxisMin?: number - xAxisMax?: number yAxisMax?: number yAxisFormatter?: (value: number) => string onHover?: (index: number) => void diff --git a/src/features/dashboard/sandbox/monitoring/utils.ts b/src/features/dashboard/sandbox/monitoring/utils.ts deleted file mode 100644 index 2f5a86cc3..000000000 --- a/src/features/dashboard/sandbox/monitoring/utils.ts +++ /dev/null @@ -1,62 +0,0 @@ -export function calculateStepForRange(startMs: number, endMs: number): number { - const duration = endMs - startMs - return calculateStepForDuration(duration) -} - -// this function comes from e2b-dev/infra and is used to calculate the step for a given duration for /teams/$teamId/metrics -export function calculateStepForDuration(durationMs: number): number { - const hour = 60 * 60 * 1000 - const minute = 60 * 1000 - const second = 1000 - - switch (true) { - case durationMs < hour: - return 5 * second - case durationMs < 6 * hour: - return 30 * second - case durationMs < 12 * hour: - return minute - case durationMs < 24 * hour: - return 2 * minute - case durationMs < 7 * 24 * hour: - return 5 * minute - default: - return 15 * minute - } -} - -// TIMEFRAME LIVE STATE CALCULATION - -const LIVE_THRESHOLD_PERCENT = 0.02 -const LIVE_THRESHOLD_MIN_MS = 2_000 - -/** - * Determines if a timeframe should be considered "live" based on how - * recent the end timestamp is relative to current time. - * - * A timeframe is considered live if the end timestamp is within a threshold - * of the current time. The threshold is the maximum of: - * - 2% of the timeframe duration - * - 60 seconds (minimum threshold) - * - * @param start - Start timestamp in milliseconds (or null) - * @param end - End timestamp in milliseconds (or null) - * @param now - Current timestamp in milliseconds (defaults to Date.now()) - * @returns True if the timeframe should be considered live - */ -export function calculateIsLive( - start: number | null, - end: number | null, - now: number = Date.now() -): boolean { - // default to live if params missing - if (!start || !end) return true - - const duration = end - start - const threshold = Math.max( - duration * LIVE_THRESHOLD_PERCENT, - LIVE_THRESHOLD_MIN_MS - ) - - return now - end < threshold -} diff --git a/src/features/dashboard/sandbox/monitoring/utils/constants.ts b/src/features/dashboard/sandbox/monitoring/utils/constants.ts new file mode 100644 index 000000000..ccd4b56dc --- /dev/null +++ b/src/features/dashboard/sandbox/monitoring/utils/constants.ts @@ -0,0 +1,90 @@ +import { + millisecondsInHour, + millisecondsInMinute, + millisecondsInSecond, +} from 'date-fns/constants' + +export const SANDBOX_MONITORING_DEFAULT_RANGE_MS = millisecondsInHour +export const SANDBOX_MONITORING_MIN_RANGE_MS = 90 * millisecondsInSecond +export const SANDBOX_MONITORING_LIVE_POLLING_MS = 10_000 + +export const SANDBOX_MONITORING_QUERY_START_PARAM = 'start' +export const SANDBOX_MONITORING_QUERY_END_PARAM = 'end' +export const SANDBOX_MONITORING_QUERY_LIVE_PARAM = 'live' +export const SANDBOX_MONITORING_QUERY_LIVE_TRUE = '1' +export const SANDBOX_MONITORING_QUERY_LIVE_FALSE = '0' +export const SANDBOX_MONITORING_METRICS_FETCH_ERROR_MESSAGE = + 'Failed to fetch sandbox monitoring metrics.' + +export const SANDBOX_MONITORING_CUSTOM_RANGE_START_TIME = '00:00:00' +export const SANDBOX_MONITORING_CUSTOM_RANGE_END_TIME = '23:59:59' +export const SANDBOX_MONITORING_PRESET_MATCH_TOLERANCE_MS = millisecondsInMinute + +export const SANDBOX_MONITORING_FULL_LIFECYCLE_PRESET_ID = 'full-lifecycle' +export const SANDBOX_MONITORING_FULL_LIFECYCLE_PRESET_SHORTCUT = 'FULL' +export const SANDBOX_MONITORING_FIRST_15_MINUTES_PRESET_ID = 'first-15m' +export const SANDBOX_MONITORING_FIRST_15_MINUTES_PRESET_SHORTCUT = 'F15' +export const SANDBOX_MONITORING_FIRST_HOUR_PRESET_ID = 'first-1h' +export const SANDBOX_MONITORING_FIRST_HOUR_PRESET_SHORTCUT = 'F1H' +export const SANDBOX_MONITORING_LAST_15_MINUTES_PRESET_ID = 'last-15m' +export const SANDBOX_MONITORING_LAST_15_MINUTES_PRESET_SHORTCUT = 'L15' +export const SANDBOX_MONITORING_LAST_HOUR_PRESET_ID = 'last-1h' +export const SANDBOX_MONITORING_LAST_HOUR_PRESET_SHORTCUT = 'L1H' +export const SANDBOX_MONITORING_LAST_6_HOURS_PRESET_ID = 'last-6h' +export const SANDBOX_MONITORING_LAST_6_HOURS_PRESET_SHORTCUT = 'L6H' + +export const SANDBOX_MONITORING_CPU_SERIES_ID = 'cpu' +export const SANDBOX_MONITORING_RAM_SERIES_ID = 'ram' +export const SANDBOX_MONITORING_DISK_SERIES_ID = 'disk' +export const SANDBOX_MONITORING_CPU_SERIES_LABEL = 'CPU' +export const SANDBOX_MONITORING_RAM_SERIES_LABEL = 'RAM' +export const SANDBOX_MONITORING_DISK_SERIES_LABEL = 'DISK' +export const SANDBOX_MONITORING_CORE_LABEL_SINGULAR = 'CORE' +export const SANDBOX_MONITORING_CORE_LABEL_PLURAL = 'CORES' +export const SANDBOX_MONITORING_VALUE_UNAVAILABLE = '--' +export const SANDBOX_MONITORING_GIGABYTE_UNIT = 'GB' +export const SANDBOX_MONITORING_METRIC_VALUE_SEPARATOR = ' · ' + +export const SANDBOX_MONITORING_CPU_INDICATOR_CLASS = 'bg-graph-3' +export const SANDBOX_MONITORING_RAM_INDICATOR_CLASS = 'bg-graph-1' +export const SANDBOX_MONITORING_DISK_INDICATOR_CLASS = 'bg-graph-2' +export const SANDBOX_MONITORING_CPU_LINE_COLOR_VAR = '--graph-3' +export const SANDBOX_MONITORING_CPU_AREA_COLOR_VAR = '--graph-area-3-from' +export const SANDBOX_MONITORING_CPU_AREA_TO_COLOR_VAR = '--graph-area-3-to' +export const SANDBOX_MONITORING_RAM_LINE_COLOR_VAR = '--graph-1' +export const SANDBOX_MONITORING_RAM_AREA_COLOR_VAR = '--graph-area-1-from' +export const SANDBOX_MONITORING_RAM_AREA_TO_COLOR_VAR = '--graph-area-1-to' +export const SANDBOX_MONITORING_DISK_LINE_COLOR_VAR = '--graph-2' +export const SANDBOX_MONITORING_DISK_AREA_COLOR_VAR = '--graph-area-2-from' +export const SANDBOX_MONITORING_DISK_AREA_TO_COLOR_VAR = '--graph-area-2-to' + +export const SANDBOX_MONITORING_CHART_STROKE_VAR = '--stroke' +export const SANDBOX_MONITORING_CHART_FG_TERTIARY_VAR = '--fg-tertiary' +export const SANDBOX_MONITORING_CHART_FONT_MONO_VAR = '--font-mono' +export const SANDBOX_MONITORING_CHART_FALLBACK_STROKE = '#000' +export const SANDBOX_MONITORING_CHART_FALLBACK_FG_TERTIARY = '#666' +export const SANDBOX_MONITORING_CHART_FALLBACK_FONT_MONO = 'monospace' +export const SANDBOX_MONITORING_CHART_GROUP = 'sandbox-monitoring' +export const SANDBOX_MONITORING_CHART_STACK_ID = 'sandbox-resource' +export const SANDBOX_MONITORING_CHART_BRUSH_TYPE = 'lineX' +export const SANDBOX_MONITORING_CHART_BRUSH_MODE = 'single' +export const SANDBOX_MONITORING_CHART_LINE_WIDTH = 1 +export const SANDBOX_MONITORING_CHART_AREA_OPACITY = 0.18 +export const SANDBOX_MONITORING_CHART_OUT_OF_BRUSH_ALPHA = 0.25 +export const SANDBOX_MONITORING_CHART_AXIS_LABEL_FONT_SIZE = 12 +export const SANDBOX_MONITORING_CHART_Y_AXIS_SCALE_FACTOR = 1.5 +export const SANDBOX_MONITORING_CHART_MIN_STEP_MS = millisecondsInSecond +export const SANDBOX_MONITORING_CHART_LIVE_WINDOW_MS = 2 * millisecondsInMinute +export const SANDBOX_MONITORING_CHART_LIVE_OUTER_DOT_SIZE = 16 +export const SANDBOX_MONITORING_CHART_LIVE_MIDDLE_DOT_SIZE = 10 +export const SANDBOX_MONITORING_CHART_LIVE_INNER_DOT_SIZE = 6 +export const SANDBOX_MONITORING_PERCENT_MAX = 100 +export const SANDBOX_MONITORING_BYTES_IN_GIGABYTE = 1024 * 1024 * 1024 + +export const SANDBOX_MONITORING_TIME_LABEL_FORMAT_OPTIONS: Intl.DateTimeFormatOptions = + { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + } diff --git a/src/features/dashboard/sandbox/monitoring/utils/formatters.ts b/src/features/dashboard/sandbox/monitoring/utils/formatters.ts new file mode 100644 index 000000000..4b425aab7 --- /dev/null +++ b/src/features/dashboard/sandbox/monitoring/utils/formatters.ts @@ -0,0 +1,56 @@ +import { + SANDBOX_MONITORING_BYTES_IN_GIGABYTE, + SANDBOX_MONITORING_CORE_LABEL_PLURAL, + SANDBOX_MONITORING_CORE_LABEL_SINGULAR, + SANDBOX_MONITORING_GIGABYTE_UNIT, + SANDBOX_MONITORING_METRIC_VALUE_SEPARATOR, + SANDBOX_MONITORING_TIME_LABEL_FORMAT_OPTIONS, + SANDBOX_MONITORING_VALUE_UNAVAILABLE, +} from './constants' + +const hoverTimestampFormatter = new Intl.DateTimeFormat( + undefined, + SANDBOX_MONITORING_TIME_LABEL_FORMAT_OPTIONS +) + +export function formatPercent(value: number | null): string { + if (value === null || Number.isNaN(value)) { + return SANDBOX_MONITORING_VALUE_UNAVAILABLE + } + + return `${Math.round(value)}%` +} + +export function formatCoreCount(value: number): string { + const normalized = Math.max(0, Math.round(value)) + const label = + normalized === 1 + ? SANDBOX_MONITORING_CORE_LABEL_SINGULAR + : SANDBOX_MONITORING_CORE_LABEL_PLURAL + + return `${normalized} ${label}` +} + +export function formatBytesToGb(bytes: number): string { + const gigabytes = bytes / SANDBOX_MONITORING_BYTES_IN_GIGABYTE + const rounded = gigabytes >= 10 ? gigabytes.toFixed(0) : gigabytes.toFixed(1) + const normalized = rounded.replace(/\.0$/, '') + + return `${normalized} ${SANDBOX_MONITORING_GIGABYTE_UNIT}` +} + +export function formatHoverTimestamp(timestampMs: number): string { + return hoverTimestampFormatter.format(new Date(timestampMs)) +} + +export function formatMetricValue(primary: string, secondary: string): string { + return `${primary}${SANDBOX_MONITORING_METRIC_VALUE_SEPARATOR}${secondary}` +} + +export function calculateRatioPercent(used: number, total: number): number { + if (total <= 0) { + return 0 + } + + return (used / total) * 100 +} diff --git a/src/features/dashboard/sandbox/monitoring/charts/utils.ts b/src/features/dashboard/sandbox/monitoring/utils/metrics.ts similarity index 62% rename from src/features/dashboard/sandbox/monitoring/charts/utils.ts rename to src/features/dashboard/sandbox/monitoring/utils/metrics.ts index 1ddc73943..f5878b4f7 100644 --- a/src/features/dashboard/sandbox/monitoring/charts/utils.ts +++ b/src/features/dashboard/sandbox/monitoring/utils/metrics.ts @@ -1,21 +1,35 @@ import type { SandboxMetric } from '@/server/api/models/sandboxes.models' -import { calculateStepForRange } from '../utils' -import type { SandboxMetricsSeries } from './sandbox-metrics-chart/types' - -export const SANDBOX_MONITORING_CPU_LINE_COLOR_VAR = '--graph-3' -export const SANDBOX_MONITORING_CPU_AREA_COLOR_VAR = '--graph-area-3-from' -export const SANDBOX_MONITORING_RAM_LINE_COLOR_VAR = '--graph-1' -export const SANDBOX_MONITORING_RAM_AREA_COLOR_VAR = '--graph-area-1-from' -export const SANDBOX_MONITORING_DISK_LINE_COLOR_VAR = '--graph-2' +import { millisecondsInSecond } from 'date-fns/constants' +import { + SANDBOX_MONITORING_CHART_MIN_STEP_MS, + SANDBOX_MONITORING_CPU_AREA_COLOR_VAR, + SANDBOX_MONITORING_CPU_AREA_TO_COLOR_VAR, + SANDBOX_MONITORING_CPU_LINE_COLOR_VAR, + SANDBOX_MONITORING_CPU_SERIES_ID, + SANDBOX_MONITORING_CPU_SERIES_LABEL, + SANDBOX_MONITORING_DISK_AREA_COLOR_VAR, + SANDBOX_MONITORING_DISK_AREA_TO_COLOR_VAR, + SANDBOX_MONITORING_DISK_LINE_COLOR_VAR, + SANDBOX_MONITORING_DISK_SERIES_ID, + SANDBOX_MONITORING_DISK_SERIES_LABEL, + SANDBOX_MONITORING_PERCENT_MAX, + SANDBOX_MONITORING_RAM_AREA_COLOR_VAR, + SANDBOX_MONITORING_RAM_AREA_TO_COLOR_VAR, + SANDBOX_MONITORING_RAM_LINE_COLOR_VAR, + SANDBOX_MONITORING_RAM_SERIES_ID, + SANDBOX_MONITORING_RAM_SERIES_LABEL, +} from './constants' +import type { SandboxMetricsSeries } from '../types/sandbox-metrics-chart' +import { calculateStepForRange } from './timeframe' function clampPercent(value: number): number { if (Number.isNaN(value)) return 0 - return Math.max(0, Math.min(100, value)) + return Math.max(0, Math.min(SANDBOX_MONITORING_PERCENT_MAX, value)) } function getMetricTimestampMs(metric: SandboxMetric): number { if (typeof metric.timestampUnix === 'number') { - return metric.timestampUnix * 1000 + return metric.timestampUnix * millisecondsInSecond } return new Date(metric.timestamp).getTime() @@ -44,9 +58,11 @@ export function buildTimelineCategories( } const stepMs = Math.max( - 1_000, - Math.floor(calculateStepForRange(normalizedStart, normalizedEnd) / 1000) * - 1000 + SANDBOX_MONITORING_CHART_MIN_STEP_MS, + Math.floor( + calculateStepForRange(normalizedStart, normalizedEnd) / + millisecondsInSecond + ) * millisecondsInSecond ) const categories: number[] = [] @@ -117,24 +133,24 @@ export function buildResourceSeries( metrics: SandboxMetric[], categories: number[] ): SandboxMetricsSeries[] { - const sorted = sortSandboxMetricsByTime(metrics) - return [ { - id: 'cpu', - name: 'CPU', + id: SANDBOX_MONITORING_CPU_SERIES_ID, + name: SANDBOX_MONITORING_CPU_SERIES_LABEL, lineColorVar: SANDBOX_MONITORING_CPU_LINE_COLOR_VAR, areaColorVar: SANDBOX_MONITORING_CPU_AREA_COLOR_VAR, - data: createSeriesData(sorted, categories, (metric) => + areaToColorVar: SANDBOX_MONITORING_CPU_AREA_TO_COLOR_VAR, + data: createSeriesData(metrics, categories, (metric) => clampPercent(metric.cpuUsedPct) ), }, { - id: 'ram', - name: 'RAM', + id: SANDBOX_MONITORING_RAM_SERIES_ID, + name: SANDBOX_MONITORING_RAM_SERIES_LABEL, lineColorVar: SANDBOX_MONITORING_RAM_LINE_COLOR_VAR, areaColorVar: SANDBOX_MONITORING_RAM_AREA_COLOR_VAR, - data: createSeriesData(sorted, categories, (metric) => + areaToColorVar: SANDBOX_MONITORING_RAM_AREA_TO_COLOR_VAR, + data: createSeriesData(metrics, categories, (metric) => toPercent(metric.memUsed, metric.memTotal) ), }, @@ -145,14 +161,14 @@ export function buildDiskSeries( metrics: SandboxMetric[], categories: number[] ): SandboxMetricsSeries[] { - const sorted = sortSandboxMetricsByTime(metrics) - return [ { - id: 'disk', - name: 'DISK', + id: SANDBOX_MONITORING_DISK_SERIES_ID, + name: SANDBOX_MONITORING_DISK_SERIES_LABEL, lineColorVar: SANDBOX_MONITORING_DISK_LINE_COLOR_VAR, - data: createSeriesData(sorted, categories, (metric) => + areaColorVar: SANDBOX_MONITORING_DISK_AREA_COLOR_VAR, + areaToColorVar: SANDBOX_MONITORING_DISK_AREA_TO_COLOR_VAR, + data: createSeriesData(metrics, categories, (metric) => toPercent(metric.diskUsed, metric.diskTotal) ), }, diff --git a/src/features/dashboard/sandbox/monitoring/utils/timeframe.ts b/src/features/dashboard/sandbox/monitoring/utils/timeframe.ts new file mode 100644 index 000000000..6304a0630 --- /dev/null +++ b/src/features/dashboard/sandbox/monitoring/utils/timeframe.ts @@ -0,0 +1,124 @@ +import type { SandboxInfo } from '@/types/api.types' +import { + millisecondsInDay, + millisecondsInHour, + millisecondsInMinute, + millisecondsInSecond, +} from 'date-fns/constants' +import { SANDBOX_MONITORING_MIN_RANGE_MS } from './constants' + +export function calculateStepForRange(startMs: number, endMs: number): number { + const duration = endMs - startMs + return calculateStepForDuration(duration) +} + +export function calculateStepForDuration(durationMs: number): number { + switch (true) { + case durationMs < millisecondsInHour: + return 5 * millisecondsInSecond + case durationMs < 6 * millisecondsInHour: + return 30 * millisecondsInSecond + case durationMs < 12 * millisecondsInHour: + return millisecondsInMinute + case durationMs < 24 * millisecondsInHour: + return 2 * millisecondsInMinute + case durationMs < 7 * millisecondsInDay: + return 5 * millisecondsInMinute + default: + return 15 * millisecondsInMinute + } +} + +export interface SandboxLifecycleBounds { + startMs: number + anchorEndMs: number + isRunning: boolean +} + +export function getSandboxLifecycleBounds( + sandboxInfo: Pick, + now: number = Date.now() +): SandboxLifecycleBounds | null { + const startMs = new Date(sandboxInfo.startedAt).getTime() + const endMs = new Date(sandboxInfo.endAt).getTime() + + if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) { + return null + } + + const isRunning = sandboxInfo.state === 'running' + const anchorEndMs = isRunning ? Math.min(now, endMs) : endMs + + const normalizedStart = Math.floor(Math.min(startMs, anchorEndMs)) + const normalizedEnd = Math.floor(Math.max(startMs, anchorEndMs)) + + return { + startMs: normalizedStart, + anchorEndMs: normalizedEnd, + isRunning, + } +} + +export function clampTimeframeToBounds( + start: number, + end: number, + minBoundMs: number, + maxBoundMs: number, + minRangeMs: number = SANDBOX_MONITORING_MIN_RANGE_MS +) { + const safeMin = Math.floor(Math.min(minBoundMs, maxBoundMs)) + const safeMax = Math.floor(Math.max(minBoundMs, maxBoundMs)) + const boundsDuration = safeMax - safeMin + + if (boundsDuration <= minRangeMs) { + return { start: safeMin, end: safeMax } + } + + let safeStart = Math.floor(start) + let safeEnd = Math.floor(end) + + if (!Number.isFinite(safeStart)) { + safeStart = safeMin + } + + if (!Number.isFinite(safeEnd)) { + safeEnd = safeMax + } + + if (safeEnd <= safeStart) { + safeEnd = safeStart + minRangeMs + } + + const requestedDuration = safeEnd - safeStart + if (requestedDuration >= boundsDuration) { + return { start: safeMin, end: safeMax } + } + + if (safeEnd > safeMax) { + const shift = safeEnd - safeMax + safeStart -= shift + safeEnd -= shift + } + + if (safeStart < safeMin) { + const shift = safeMin - safeStart + safeStart += shift + safeEnd += shift + } + + safeStart = Math.max(safeMin, safeStart) + safeEnd = Math.min(safeMax, safeEnd) + + if (safeEnd - safeStart < minRangeMs) { + if (safeEnd + minRangeMs <= safeMax) { + safeEnd = safeStart + minRangeMs + } else { + safeStart = safeEnd - minRangeMs + } + } + + safeStart = Math.max(safeMin, safeStart) + safeEnd = Math.min(safeMax, safeEnd) + + return { start: safeStart, end: safeEnd } +} diff --git a/src/styles/theme.css b/src/styles/theme.css index 75f4c92f2..a202d2adc 100644 --- a/src/styles/theme.css +++ b/src/styles/theme.css @@ -92,9 +92,9 @@ --accent-secondary-error-highlight: #ff8763; --accent-secondary-error-bg: rgb(255, 135, 99, 0.16); - --graph-1: #fce8d8; - --graph-2: #f7b076; - --graph-3: #e27c1d; + --graph-1: #f3cfb0; + --graph-2: #eea064; + --graph-3: #d5751c; --graph-4: #c09bb8; /* Pastel purple-peach */ --graph-5: #a67fa9; /* Pastel purple leaning to graph-6 */ --graph-6: #8c5ca5; /* Purple with orange contrast */ @@ -116,14 +116,14 @@ --graph-area-fg-from: rgba(108, 108, 108, 0.2); --graph-area-fg-to: rgba(250, 250, 250, 0.2); - --graph-area-1-from: rgba(252, 232, 216, 0.25); /* Peachy area gradient */ - --graph-area-1-to: rgba(250, 250, 250, 0.2); + --graph-area-1-from: rgba(243, 207, 176, 0.32); /* Peachy area gradient */ + --graph-area-1-to: rgba(250, 250, 250, 0.16); - --graph-area-2-from: rgba(247, 176, 118, 0.2); /* Orange area gradient */ - --graph-area-2-to: rgba(250, 250, 250, 0.2); + --graph-area-2-from: rgba(238, 160, 100, 0.26); /* Orange area gradient */ + --graph-area-2-to: rgba(250, 250, 250, 0.16); - --graph-area-3-from: rgba(226, 124, 29, 0.18); /* Dark orange area gradient */ - --graph-area-3-to: rgba(250, 250, 250, 0.2); + --graph-area-3-from: rgba(213, 117, 28, 0.22); /* Dark orange area gradient */ + --graph-area-3-to: rgba(250, 250, 250, 0.16); --graph-area-4-from: rgba( 192, diff --git a/src/ui/time-range-picker.tsx b/src/ui/time-range-picker.tsx index 36dec25cb..54f7a7661 100644 --- a/src/ui/time-range-picker.tsx +++ b/src/ui/time-range-picker.tsx @@ -6,6 +6,7 @@ 'use client' import { zodResolver } from '@hookform/resolvers/zod' +import { endOfDay, startOfDay } from 'date-fns' import { useCallback, useEffect, useMemo } from 'react' import { useForm } from 'react-hook-form' import { z } from 'zod' @@ -53,6 +54,65 @@ interface TimeRangePickerProps { hideTime?: boolean } +function toSecondPrecision(date: Date): Date { + return new Date(Math.floor(date.getTime() / 1000) * 1000) +} + +function clampDateTimeToBounds( + date: string, + time: string | null, + options: { + fallbackTime: string + minDate?: Date + maxDate?: Date + } +): { date: string; time: string | null } { + const timestamp = tryParseDatetime( + `${date} ${time || options.fallbackTime}` + )?.getTime() + + if (!timestamp) { + return { date, time } + } + + const minTimestamp = options.minDate?.getTime() + const maxTimestamp = options.maxDate?.getTime() + let clampedTimestamp = timestamp + + if (minTimestamp !== undefined && clampedTimestamp < minTimestamp) { + clampedTimestamp = minTimestamp + } + + if (maxTimestamp !== undefined && clampedTimestamp > maxTimestamp) { + clampedTimestamp = maxTimestamp + } + + if (clampedTimestamp === timestamp) { + return { date, time } + } + + const next = parseDateTimeComponents(new Date(clampedTimestamp).toISOString()) + return { + date: next.date, + time: next.time || null, + } +} + +function formatBoundaryDateTime(date: Date, hideTime: boolean): string { + if (hideTime) { + return date.toLocaleDateString() + } + + return date.toLocaleString(undefined, { + year: 'numeric', + month: 'numeric', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }) +} + export function TimeRangePicker({ startDateTime, endDateTime, @@ -73,23 +133,25 @@ export function TimeRangePicker({ () => parseDateTimeComponents(endDateTime), [endDateTime] ) + const calendarMinDate = useMemo( + () => (minDate ? startOfDay(minDate) : undefined), + [minDate] + ) + const calendarMaxDate = useMemo( + () => (maxDate ? endOfDay(maxDate) : undefined), + [maxDate] + ) + const minDateValue = useMemo( + () => (minDate ? toSecondPrecision(minDate) : undefined), + [minDate] + ) + const maxDateValue = useMemo( + () => (maxDate ? toSecondPrecision(maxDate) : undefined), + [maxDate] + ) // Create dynamic zod schema based on min/max dates const schema = useMemo(() => { - // When hideTime is true, allow dates up to end of today - // Otherwise, allow up to now + 10 seconds (for time drift) - const defaultMaxDate = hideTime - ? (() => { - const endOfToday = new Date() - endOfToday.setDate(endOfToday.getDate() + 1) - endOfToday.setHours(0, 0, 0, 0) - return endOfToday - })() - : new Date(Date.now() + 10000) - - const maxDateValue = maxDate || defaultMaxDate - const minDateValue = minDate - return z .object({ startDate: z.string().min(1, 'Start date is required'), @@ -129,11 +191,19 @@ export function TimeRangePicker({ if (minDateValue && startTimestamp < minDateValue.getTime()) { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: `Start date cannot be before ${minDateValue.toLocaleDateString()}`, + message: `Start date cannot be before ${formatBoundaryDateTime(minDateValue, hideTime)}`, path: ['startDate'], }) } + if (maxDateValue && endTimestamp > maxDateValue.getTime()) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `End date cannot be after ${formatBoundaryDateTime(maxDateValue, hideTime)}`, + path: ['endDate'], + }) + } + // validate end date is not before start date if (endTimestamp < startTimestamp) { ctx.addIssue({ @@ -143,7 +213,25 @@ export function TimeRangePicker({ }) } }) - }, [minDate, maxDate, hideTime]) + }, [hideTime, maxDateValue, minDateValue]) + + const clampStartDateTime = useCallback( + (date: string, time: string | null) => + clampDateTimeToBounds(date, time, { + fallbackTime: '00:00:00', + minDate: minDateValue, + }), + [minDateValue] + ) + + const clampEndDateTime = useCallback( + (date: string, time: string | null) => + clampDateTimeToBounds(date, time, { + fallbackTime: '23:59:59', + maxDate: maxDateValue, + }), + [maxDateValue] + ) const form = useForm({ resolver: zodResolver(schema), @@ -234,14 +322,26 @@ export function TimeRangePicker({ { - dateField.onChange(value) + const currentTime = form.getValues('startTime') || null + const next = clampStartDateTime(value, currentTime) + dateField.onChange(next.date) + form.setValue('startTime', next.time, { + shouldValidate: true, + shouldDirty: true, + }) form.trigger(['startDate', 'endDate']) }} onTimeChange={(value) => { - form.setValue('startTime', value || null, { + const currentDate = form.getValues('startDate') + const next = clampStartDateTime(currentDate, value || null) + form.setValue('startDate', next.date, { + shouldValidate: true, + shouldDirty: true, + }) + form.setValue('startTime', next.time, { shouldValidate: true, shouldDirty: true, }) @@ -268,14 +368,26 @@ export function TimeRangePicker({ { - dateField.onChange(value) + const currentTime = form.getValues('endTime') || null + const next = clampEndDateTime(value, currentTime) + dateField.onChange(next.date) + form.setValue('endTime', next.time, { + shouldValidate: true, + shouldDirty: true, + }) form.trigger(['startDate', 'endDate']) }} onTimeChange={(value) => { - form.setValue('endTime', value || null, { + const currentDate = form.getValues('endDate') + const next = clampEndDateTime(currentDate, value || null) + form.setValue('endDate', next.date, { + shouldValidate: true, + shouldDirty: true, + }) + form.setValue('endTime', next.time, { shouldValidate: true, shouldDirty: true, }) diff --git a/src/ui/time-range-presets.tsx b/src/ui/time-range-presets.tsx index 803c60fa8..0dfa542f5 100644 --- a/src/ui/time-range-presets.tsx +++ b/src/ui/time-range-presets.tsx @@ -16,6 +16,7 @@ export interface TimeRangePreset { label: string shortcut?: string description?: string + isLiveUpdating?: boolean getValue: () => { start: number; end: number } } From e2efcdd44114859dd46d79348937b08c4a9ac55f Mon Sep 17 00:00:00 2001 From: Ben Fornefeld Date: Tue, 24 Feb 2026 21:45:51 -0800 Subject: [PATCH 33/54] improve: time picker abstractions --- .../unit/time-range-picker-logic.test.ts | 158 ++++++++ .../monitoring-time-range-controls.tsx | 24 +- .../sandbox/monitoring/utils/constants.ts | 2 - .../usage/usage-time-range-controls.tsx | 20 +- src/ui/time-range-picker.logic.ts | 354 ++++++++++++++++++ src/ui/time-range-picker.tsx | 334 ++++------------- 6 files changed, 611 insertions(+), 281 deletions(-) create mode 100644 src/__test__/unit/time-range-picker-logic.test.ts create mode 100644 src/ui/time-range-picker.logic.ts diff --git a/src/__test__/unit/time-range-picker-logic.test.ts b/src/__test__/unit/time-range-picker-logic.test.ts new file mode 100644 index 000000000..d347cccbb --- /dev/null +++ b/src/__test__/unit/time-range-picker-logic.test.ts @@ -0,0 +1,158 @@ +import { + createTimeRangeSchema, + normalizeTimeRangeValues, + parsePickerDateTime, + parseTimeRangeValuesToTimestamps, + validateTimeRangeValues, + type TimeRangeValues, +} from '@/ui/time-range-picker.logic' +import { describe, expect, it } from 'vitest' + +const baseValues: TimeRangeValues = { + startDate: '2026/02/18', + startTime: '00:00:00', + endDate: '2026/02/24', + endTime: '23:59:59', +} + +describe('time-range-picker logic', () => { + describe('parsePickerDateTime', () => { + it('returns null when date is missing, even if time exists', () => { + const parsed = parsePickerDateTime('', '18:00:00', '23:59:59') + expect(parsed).toBeNull() + }) + + it('parses canonical and display date formats', () => { + const canonical = parsePickerDateTime( + '2026/02/24', + '18:17:41', + '23:59:59' + ) + const display = parsePickerDateTime( + '24 / 02 / 2026', + '18 : 17 : 41', + '23:59:59' + ) + + expect(canonical).not.toBeNull() + expect(display).not.toBeNull() + expect(canonical?.getTime()).toBe(display?.getTime()) + }) + }) + + describe('normalizeTimeRangeValues', () => { + it('normalizes date and time strings without changing semantic values', () => { + const normalized = normalizeTimeRangeValues({ + startDate: '18 / 02 / 2026', + startTime: '09 : 05', + endDate: '2026-02-24', + endTime: '23:59:59', + }) + + expect(normalized).toEqual({ + startDate: '2026/02/18', + startTime: '09:05:00', + endDate: '2026/02/24', + endTime: '23:59:59', + }) + }) + }) + + describe('validateTimeRangeValues', () => { + it('does not enforce an implicit max boundary', () => { + const validation = validateTimeRangeValues( + { + ...baseValues, + endDate: '2026/12/31', + endTime: '23:59:59', + }, + { + hideTime: false, + bounds: { + min: new Date(2023, 0, 1, 0, 0, 0), + }, + } + ) + + expect(validation.issues).toEqual([]) + }) + + it('validates against explicit max boundary', () => { + const validation = validateTimeRangeValues(baseValues, { + hideTime: false, + bounds: { + max: new Date(2026, 1, 24, 18, 17, 41), + }, + }) + + expect(validation.issues).toHaveLength(1) + expect(validation.issues[0]).toEqual( + expect.objectContaining({ + field: 'endDate', + }) + ) + expect(validation.issues[0]?.message).toContain( + 'End date cannot be after' + ) + }) + + it('validates end is not before start', () => { + const validation = validateTimeRangeValues( + { + ...baseValues, + startDate: '2026/02/24', + startTime: '20:00:00', + endDate: '2026/02/24', + endTime: '19:00:00', + }, + { + hideTime: false, + } + ) + + expect(validation.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + field: 'endDate', + message: 'End date cannot be before start date', + }), + ]) + ) + }) + }) + + describe('parseTimeRangeValuesToTimestamps', () => { + it('converts values to start and end timestamps with fallback times', () => { + const timestamps = parseTimeRangeValuesToTimestamps({ + startDate: '2026/02/18', + startTime: null, + endDate: '2026/02/24', + endTime: null, + }) + + expect(timestamps).not.toBeNull() + expect(timestamps?.start).toBe(new Date(2026, 1, 18, 0, 0, 0).getTime()) + expect(timestamps?.end).toBe(new Date(2026, 1, 24, 23, 59, 59).getTime()) + }) + }) + + describe('createTimeRangeSchema', () => { + it('applies the same boundary validation as logic helpers', () => { + const schema = createTimeRangeSchema({ + hideTime: false, + bounds: { + max: new Date(2026, 1, 24, 18, 17, 41), + }, + }) + + const parsed = schema.safeParse(baseValues) + + expect(parsed.success).toBe(false) + if (!parsed.success) { + expect(parsed.error.issues[0]?.message).toContain( + 'End date cannot be after' + ) + } + }) + }) +}) diff --git a/src/features/dashboard/sandbox/monitoring/components/monitoring-time-range-controls.tsx b/src/features/dashboard/sandbox/monitoring/components/monitoring-time-range-controls.tsx index b9cee2f7c..f6353dcb4 100644 --- a/src/features/dashboard/sandbox/monitoring/components/monitoring-time-range-controls.tsx +++ b/src/features/dashboard/sandbox/monitoring/components/monitoring-time-range-controls.tsx @@ -10,6 +10,7 @@ import { PopoverTrigger, } from '@/ui/primitives/popover' import { Separator } from '@/ui/primitives/separator' +import { parseTimeRangeValuesToTimestamps } from '@/ui/time-range-picker.logic' import { TimeRangePicker, type TimeRangeValues } from '@/ui/time-range-picker' import { TimeRangePresets, type TimeRangePreset } from '@/ui/time-range-presets' import { @@ -18,8 +19,6 @@ import { } from 'date-fns/constants' import { useCallback, useEffect, useMemo, useState } from 'react' import { - SANDBOX_MONITORING_CUSTOM_RANGE_END_TIME, - SANDBOX_MONITORING_CUSTOM_RANGE_START_TIME, SANDBOX_MONITORING_FIRST_15_MINUTES_PRESET_ID, SANDBOX_MONITORING_FIRST_15_MINUTES_PRESET_SHORTCUT, SANDBOX_MONITORING_FIRST_HOUR_PRESET_ID, @@ -215,6 +214,14 @@ export default function SandboxMonitoringTimeRangeControls({ [lifecycle.anchorEndMs, lifecycle.isRunning, pickerMaxDateMs] ) + const pickerBounds = useMemo( + () => ({ + min: new Date(lifecycle.startMs), + max: pickerMaxDate, + }), + [lifecycle.startMs, pickerMaxDate] + ) + const handlePresetSelect = useCallback( (preset: TimeRangePreset) => { const { start, end } = preset.getValue() @@ -228,12 +235,12 @@ export default function SandboxMonitoringTimeRangeControls({ const handleApply = useCallback( (values: TimeRangeValues) => { - const startTime = values.startTime || SANDBOX_MONITORING_CUSTOM_RANGE_START_TIME - const endTime = values.endTime || SANDBOX_MONITORING_CUSTOM_RANGE_END_TIME + const timestamps = parseTimeRangeValuesToTimestamps(values) + if (!timestamps) { + return + } - const start = new Date(`${values.startDate} ${startTime}`).getTime() - const end = new Date(`${values.endDate} ${endTime}`).getTime() - const next = clampToLifecycle(start, end) + const next = clampToLifecycle(timestamps.start, timestamps.end) onTimeRangeChange(next.start, next.end, { isLiveUpdating: false, @@ -303,8 +310,7 @@ export default function SandboxMonitoringTimeRangeControls({ diff --git a/src/features/dashboard/sandbox/monitoring/utils/constants.ts b/src/features/dashboard/sandbox/monitoring/utils/constants.ts index ccd4b56dc..f49011f45 100644 --- a/src/features/dashboard/sandbox/monitoring/utils/constants.ts +++ b/src/features/dashboard/sandbox/monitoring/utils/constants.ts @@ -16,8 +16,6 @@ export const SANDBOX_MONITORING_QUERY_LIVE_FALSE = '0' export const SANDBOX_MONITORING_METRICS_FETCH_ERROR_MESSAGE = 'Failed to fetch sandbox monitoring metrics.' -export const SANDBOX_MONITORING_CUSTOM_RANGE_START_TIME = '00:00:00' -export const SANDBOX_MONITORING_CUSTOM_RANGE_END_TIME = '23:59:59' export const SANDBOX_MONITORING_PRESET_MATCH_TOLERANCE_MS = millisecondsInMinute export const SANDBOX_MONITORING_FULL_LIFECYCLE_PRESET_ID = 'full-lifecycle' diff --git a/src/features/dashboard/usage/usage-time-range-controls.tsx b/src/features/dashboard/usage/usage-time-range-controls.tsx index 647e09d1b..3bb31d2c8 100644 --- a/src/features/dashboard/usage/usage-time-range-controls.tsx +++ b/src/features/dashboard/usage/usage-time-range-controls.tsx @@ -4,6 +4,7 @@ import { cn } from '@/lib/utils' import { findMatchingPreset } from '@/lib/utils/time-range' import { formatTimeframeAsISO8601Interval } from '@/lib/utils/timeframe' import CopyButton from '@/ui/copy-button' +import { parseTimeRangeValuesToTimestamps } from '@/ui/time-range-picker.logic' import { Button } from '@/ui/primitives/button' import { Popover, @@ -22,6 +23,10 @@ import { normalizeToStartOfSamplingPeriod, } from './sampling-utils' +const USAGE_TIME_RANGE_BOUNDS = { + min: new Date('2023-01-01'), +} + interface UsageTimeRangeControlsProps { timeframe: { start: number @@ -106,15 +111,12 @@ export function UsageTimeRangeControls({ const handleTimeRangeApply = useCallback( (values: TimeRangeValues) => { - const startTime = values.startTime || '00:00:00' - const endTime = values.endTime || '23:59:59' - - const startTimestamp = new Date( - `${values.startDate} ${startTime}` - ).getTime() - const endTimestamp = new Date(`${values.endDate} ${endTime}`).getTime() + const timestamps = parseTimeRangeValuesToTimestamps(values) + if (!timestamps) { + return + } - onTimeRangeChange(startTimestamp, endTimestamp) + onTimeRangeChange(timestamps.start, timestamps.end) setIsTimePickerOpen(false) }, [onTimeRangeChange] @@ -166,7 +168,7 @@ export function UsageTimeRangeControls({ diff --git a/src/ui/time-range-picker.logic.ts b/src/ui/time-range-picker.logic.ts new file mode 100644 index 000000000..f243c0cef --- /dev/null +++ b/src/ui/time-range-picker.logic.ts @@ -0,0 +1,354 @@ +import { z } from 'zod' + +export interface TimeRangeValues { + startDate: string + startTime: string | null + endDate: string + endTime: string | null +} + +export interface TimeRangePickerBounds { + min?: Date + max?: Date +} + +type TimeRangeField = 'startDate' | 'endDate' + +export interface TimeRangeIssue { + field: TimeRangeField + message: string +} + +export interface TimeRangeValidationResult { + startDateTime: Date | null + endDateTime: Date | null + issues: TimeRangeIssue[] +} + +interface TimeRangeValidationOptions { + hideTime: boolean + bounds?: TimeRangePickerBounds +} + +function normalizeDateInput(value: string): string { + return value.trim().replaceAll(' ', '').replaceAll('-', '/') +} + +function parseDateInput(value: string): Date | null { + const normalized = normalizeDateInput(value) + if (!normalized) { + return null + } + + const parts = normalized.split('/') + if (parts.length !== 3) { + return null + } + + const [first, second, third] = parts + if (!first || !second || !third) { + return null + } + + const firstValue = Number.parseInt(first, 10) + const secondValue = Number.parseInt(second, 10) + const thirdValue = Number.parseInt(third, 10) + + if ( + Number.isNaN(firstValue) || + Number.isNaN(secondValue) || + Number.isNaN(thirdValue) + ) { + return null + } + + let year: number + let month: number + let day: number + + if (first.length === 4) { + year = firstValue + month = secondValue + day = thirdValue + } else if (third.length === 4) { + day = firstValue + month = secondValue + year = thirdValue + } else { + return null + } + + if (month < 1 || month > 12 || day < 1 || day > 31) { + return null + } + + const parsed = new Date(year, month - 1, day) + + if ( + parsed.getFullYear() !== year || + parsed.getMonth() !== month - 1 || + parsed.getDate() !== day + ) { + return null + } + + return parsed +} + +function parseTimeInput(value: string): { + hours: number + minutes: number + seconds: number +} | null { + const normalized = value.trim().replaceAll(' ', '') + if (!normalized) { + return null + } + + const parts = normalized.split(':') + if (parts.length === 2) { + parts.push('0') + } + + if (parts.length !== 3) { + return null + } + + const [hourPart, minutePart, secondPart] = parts + if (!hourPart || !minutePart || !secondPart) { + return null + } + + const hours = Number.parseInt(hourPart, 10) + const minutes = Number.parseInt(minutePart, 10) + const seconds = Number.parseInt(secondPart, 10) + + if ( + Number.isNaN(hours) || + Number.isNaN(minutes) || + Number.isNaN(seconds) + ) { + return null + } + + if (hours < 0 || hours > 23) { + return null + } + + if (minutes < 0 || minutes > 59) { + return null + } + + if (seconds < 0 || seconds > 59) { + return null + } + + return { hours, minutes, seconds } +} + +export function toSecondPrecision(date: Date): Date { + return new Date(Math.floor(date.getTime() / 1000) * 1000) +} + +function formatDateValue(date: Date): string { + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + return `${year}/${month}/${day}` +} + +function formatTimeValue(hours: number, minutes: number, seconds: number): string { + const hh = String(hours).padStart(2, '0') + const mm = String(minutes).padStart(2, '0') + const ss = String(seconds).padStart(2, '0') + return `${hh}:${mm}:${ss}` +} + +export function parsePickerDateTime( + dateInput: string, + timeInput: string | null | undefined, + fallbackTime: string +): Date | null { + const parsedDate = parseDateInput(dateInput) + if (!parsedDate) { + return null + } + + const effectiveTime = + timeInput && timeInput.trim().length > 0 ? timeInput : fallbackTime + const parsedTime = parseTimeInput(effectiveTime) + if (!parsedTime) { + return null + } + + return new Date( + parsedDate.getFullYear(), + parsedDate.getMonth(), + parsedDate.getDate(), + parsedTime.hours, + parsedTime.minutes, + parsedTime.seconds, + 0 + ) +} + +function formatBoundaryDateTime(date: Date, hideTime: boolean): string { + if (hideTime) { + return date.toLocaleDateString() + } + + return date.toLocaleString(undefined, { + year: 'numeric', + month: 'numeric', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }) +} + +function normalizeTimeValue(time: string | null): string | null { + if (!time) { + return null + } + + const parsedTime = parseTimeInput(time) + if (!parsedTime) { + return time.trim() + } + + return formatTimeValue( + parsedTime.hours, + parsedTime.minutes, + parsedTime.seconds + ) +} + +export function normalizeTimeRangeValues(values: TimeRangeValues): TimeRangeValues { + const parsedStartDate = parseDateInput(values.startDate) + const parsedEndDate = parseDateInput(values.endDate) + + return { + startDate: parsedStartDate + ? formatDateValue(parsedStartDate) + : values.startDate.trim(), + startTime: normalizeTimeValue(values.startTime), + endDate: parsedEndDate ? formatDateValue(parsedEndDate) : values.endDate.trim(), + endTime: normalizeTimeValue(values.endTime), + } +} + +export function parseTimeRangeValuesToTimestamps( + values: TimeRangeValues +): { start: number; end: number } | null { + const startDateTime = parsePickerDateTime( + values.startDate, + values.startTime, + '00:00:00' + ) + const endDateTime = parsePickerDateTime( + values.endDate, + values.endTime, + '23:59:59' + ) + + if (!startDateTime || !endDateTime) { + return null + } + + return { + start: startDateTime.getTime(), + end: endDateTime.getTime(), + } +} + +export function validateTimeRangeValues( + values: TimeRangeValues, + { bounds, hideTime }: TimeRangeValidationOptions +): TimeRangeValidationResult { + const issues: TimeRangeIssue[] = [] + + const startDateTime = parsePickerDateTime( + values.startDate, + hideTime ? null : values.startTime, + '00:00:00' + ) + const endDateTime = parsePickerDateTime( + values.endDate, + hideTime ? null : values.endTime, + '23:59:59' + ) + + if (!startDateTime) { + issues.push({ + field: 'startDate', + message: 'Invalid start date format', + }) + } + + if (!endDateTime) { + issues.push({ + field: 'endDate', + message: 'Invalid end date format', + }) + } + + if (!startDateTime || !endDateTime) { + return { + startDateTime, + endDateTime, + issues, + } + } + + const minBoundary = bounds?.min ? toSecondPrecision(bounds.min) : undefined + const maxBoundary = bounds?.max ? toSecondPrecision(bounds.max) : undefined + + if (minBoundary && startDateTime.getTime() < minBoundary.getTime()) { + issues.push({ + field: 'startDate', + message: `Start date cannot be before ${formatBoundaryDateTime(minBoundary, hideTime)}`, + }) + } + + if (maxBoundary && endDateTime.getTime() > maxBoundary.getTime()) { + issues.push({ + field: 'endDate', + message: `End date cannot be after ${formatBoundaryDateTime(maxBoundary, hideTime)}`, + }) + } + + if (endDateTime.getTime() < startDateTime.getTime()) { + issues.push({ + field: 'endDate', + message: 'End date cannot be before start date', + }) + } + + return { + startDateTime, + endDateTime, + issues, + } +} + +export function createTimeRangeSchema(options: TimeRangeValidationOptions) { + return z + .object({ + startDate: z.string().min(1, 'Start date is required'), + startTime: z.string().nullable(), + endDate: z.string().min(1, 'End date is required'), + endTime: z.string().nullable(), + }) + .superRefine((data, ctx) => { + const validation = validateTimeRangeValues(data, options) + + for (const issue of validation.issues) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: issue.message, + path: [issue.field], + }) + } + }) +} diff --git a/src/ui/time-range-picker.tsx b/src/ui/time-range-picker.tsx index 54f7a7661..65e5f675d 100644 --- a/src/ui/time-range-picker.tsx +++ b/src/ui/time-range-picker.tsx @@ -1,21 +1,12 @@ -/** - * General-purpose time range selection component - * A simplified abstraction for picking start and end date/time ranges - */ - 'use client' import { zodResolver } from '@hookform/resolvers/zod' import { endOfDay, startOfDay } from 'date-fns' import { useCallback, useEffect, useMemo } from 'react' import { useForm } from 'react-hook-form' -import { z } from 'zod' import { cn } from '@/lib/utils' -import { - parseDateTimeComponents, - tryParseDatetime, -} from '@/lib/utils/formatting' +import { parseDateTimeComponents } from '@/lib/utils/formatting' import { Button } from './primitives/button' import { @@ -27,97 +18,28 @@ import { FormMessage, } from './primitives/form' import { TimeInput } from './time-input' - -export interface TimeRangeValues { - startDate: string - startTime: string | null - endDate: string - endTime: string | null -} +import { + createTimeRangeSchema, + normalizeTimeRangeValues, + type TimeRangePickerBounds, + type TimeRangeValues, +} from './time-range-picker.logic' +export type { TimeRangeValues } from './time-range-picker.logic' interface TimeRangePickerProps { - /** Initial start datetime in any parseable format */ startDateTime: string - /** Initial end datetime in any parseable format */ endDateTime: string - /** Optional minimum selectable date */ - minDate?: Date - /** Optional maximum selectable date */ - maxDate?: Date - /** Called when Apply button is clicked */ + bounds?: TimeRangePickerBounds onApply?: (values: TimeRangeValues) => void - /** Called whenever values change (real-time) */ onChange?: (values: TimeRangeValues) => void - /** Custom className for the container */ className?: string - /** Hide time inputs and only show date pickers (default: false) */ hideTime?: boolean } -function toSecondPrecision(date: Date): Date { - return new Date(Math.floor(date.getTime() / 1000) * 1000) -} - -function clampDateTimeToBounds( - date: string, - time: string | null, - options: { - fallbackTime: string - minDate?: Date - maxDate?: Date - } -): { date: string; time: string | null } { - const timestamp = tryParseDatetime( - `${date} ${time || options.fallbackTime}` - )?.getTime() - - if (!timestamp) { - return { date, time } - } - - const minTimestamp = options.minDate?.getTime() - const maxTimestamp = options.maxDate?.getTime() - let clampedTimestamp = timestamp - - if (minTimestamp !== undefined && clampedTimestamp < minTimestamp) { - clampedTimestamp = minTimestamp - } - - if (maxTimestamp !== undefined && clampedTimestamp > maxTimestamp) { - clampedTimestamp = maxTimestamp - } - - if (clampedTimestamp === timestamp) { - return { date, time } - } - - const next = parseDateTimeComponents(new Date(clampedTimestamp).toISOString()) - return { - date: next.date, - time: next.time || null, - } -} - -function formatBoundaryDateTime(date: Date, hideTime: boolean): string { - if (hideTime) { - return date.toLocaleDateString() - } - - return date.toLocaleString(undefined, { - year: 'numeric', - month: 'numeric', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - }) -} - export function TimeRangePicker({ startDateTime, endDateTime, - minDate, - maxDate, + bounds, onApply, onChange, className, @@ -125,6 +47,9 @@ export function TimeRangePicker({ }: TimeRangePickerProps) { 'use no memo' + const minBoundMs = bounds?.min?.getTime() + const maxBoundMs = bounds?.max?.getTime() + const startParts = useMemo( () => parseDateTimeComponents(startDateTime), [startDateTime] @@ -133,163 +58,64 @@ export function TimeRangePicker({ () => parseDateTimeComponents(endDateTime), [endDateTime] ) + const calendarMinDate = useMemo( - () => (minDate ? startOfDay(minDate) : undefined), - [minDate] + () => + minBoundMs !== undefined ? startOfDay(new Date(minBoundMs)) : undefined, + [minBoundMs] ) + const calendarMaxDate = useMemo( - () => (maxDate ? endOfDay(maxDate) : undefined), - [maxDate] - ) - const minDateValue = useMemo( - () => (minDate ? toSecondPrecision(minDate) : undefined), - [minDate] - ) - const maxDateValue = useMemo( - () => (maxDate ? toSecondPrecision(maxDate) : undefined), - [maxDate] + () => + maxBoundMs !== undefined ? endOfDay(new Date(maxBoundMs)) : undefined, + [maxBoundMs] ) - // Create dynamic zod schema based on min/max dates const schema = useMemo(() => { - return z - .object({ - startDate: z.string().min(1, 'Start date is required'), - startTime: z.string().nullable(), - endDate: z.string().min(1, 'End date is required'), - endTime: z.string().nullable(), - }) - .superRefine((data, ctx) => { - const startTimeStr = data.startTime || '00:00:00' - const endTimeStr = data.endTime || '23:59:59' - const startTimestamp = tryParseDatetime( - `${data.startDate} ${startTimeStr}` - )?.getTime() - const endTimestamp = tryParseDatetime( - `${data.endDate} ${endTimeStr}` - )?.getTime() - - if (!startTimestamp) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'Invalid start date format', - path: ['startDate'], - }) - return - } - - if (!endTimestamp) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'Invalid end date format', - path: ['endDate'], - }) - return - } - - // validate against min date - if (minDateValue && startTimestamp < minDateValue.getTime()) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `Start date cannot be before ${formatBoundaryDateTime(minDateValue, hideTime)}`, - path: ['startDate'], - }) - } - - if (maxDateValue && endTimestamp > maxDateValue.getTime()) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `End date cannot be after ${formatBoundaryDateTime(maxDateValue, hideTime)}`, - path: ['endDate'], - }) - } - - // validate end date is not before start date - if (endTimestamp < startTimestamp) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'End date cannot be before start date', - path: ['endDate'], - }) - } - }) - }, [hideTime, maxDateValue, minDateValue]) - - const clampStartDateTime = useCallback( - (date: string, time: string | null) => - clampDateTimeToBounds(date, time, { - fallbackTime: '00:00:00', - minDate: minDateValue, - }), - [minDateValue] - ) - - const clampEndDateTime = useCallback( - (date: string, time: string | null) => - clampDateTimeToBounds(date, time, { - fallbackTime: '23:59:59', - maxDate: maxDateValue, - }), - [maxDateValue] - ) + return createTimeRangeSchema({ + hideTime, + bounds: { + min: minBoundMs !== undefined ? new Date(minBoundMs) : undefined, + max: maxBoundMs !== undefined ? new Date(maxBoundMs) : undefined, + }, + }) + }, [hideTime, maxBoundMs, minBoundMs]) - const form = useForm({ - resolver: zodResolver(schema), - defaultValues: { + const defaultValues = useMemo( + () => ({ startDate: startParts.date || '', startTime: startParts.time || null, endDate: endParts.date || '', endTime: endParts.time || null, - }, - mode: 'onChange', + }), + [endParts.date, endParts.time, startParts.date, startParts.time] + ) + + const form = useForm({ + resolver: zodResolver(schema), + defaultValues, + mode: 'onSubmit', + reValidateMode: 'onChange', }) - // sync with external props when they change useEffect(() => { - const currentStartTime = form.getValues('startDate') - ? tryParseDatetime( - `${form.getValues('startDate')} ${form.getValues('startTime')}` - )?.getTime() - : undefined - const currentEndTime = form.getValues('endDate') - ? tryParseDatetime( - `${form.getValues('endDate')} ${form.getValues('endTime')}` - )?.getTime() - : undefined - - const propStartTime = startDateTime - ? tryParseDatetime(startDateTime)?.getTime() - : undefined - const propEndTime = endDateTime - ? tryParseDatetime(endDateTime)?.getTime() - : undefined - - // detect meaningful external changes (>1s difference) - const startChanged = - propStartTime && - currentStartTime && - Math.abs(propStartTime - currentStartTime) > 1000 - const endChanged = - propEndTime && - currentEndTime && - Math.abs(propEndTime - currentEndTime) > 1000 - - const isExternalChange = startChanged || endChanged - - if (isExternalChange && !form.formState.isDirty) { - const newStartParts = parseDateTimeComponents(startDateTime) - const newEndParts = parseDateTimeComponents(endDateTime) + if (form.formState.isDirty) { + return + } - form.reset({ - startDate: newStartParts.date || '', - startTime: newStartParts.time || null, - endDate: newEndParts.date || '', - endTime: newEndParts.time || null, - }) + const currentValues = form.getValues() + if ( + currentValues.startDate === defaultValues.startDate && + currentValues.startTime === defaultValues.startTime && + currentValues.endDate === defaultValues.endDate && + currentValues.endTime === defaultValues.endTime + ) { + return } - }, [startDateTime, endDateTime, form]) - // Notify on changes + form.reset(defaultValues) + }, [defaultValues, form, form.formState.isDirty]) + useEffect(() => { const subscription = form.watch((values) => { onChange?.(values as TimeRangeValues) @@ -299,11 +125,15 @@ export function TimeRangePicker({ const handleSubmit = useCallback( (values: TimeRangeValues) => { - onApply?.(values) + const normalizedValues = normalizeTimeRangeValues(values) + onApply?.(normalizedValues) + form.reset(normalizedValues) }, - [onApply] + [form, onApply] ) + const shouldValidateOnChange = form.formState.submitCount > 0 + return (
{ - const currentTime = form.getValues('startTime') || null - const next = clampStartDateTime(value, currentTime) - dateField.onChange(next.date) - form.setValue('startTime', next.time, { - shouldValidate: true, + form.setValue('startDate', value, { shouldDirty: true, + shouldTouch: true, + shouldValidate: shouldValidateOnChange, }) - form.trigger(['startDate', 'endDate']) }} onTimeChange={(value) => { - const currentDate = form.getValues('startDate') - const next = clampStartDateTime(currentDate, value || null) - form.setValue('startDate', next.date, { - shouldValidate: true, - shouldDirty: true, - }) - form.setValue('startTime', next.time, { - shouldValidate: true, + form.setValue('startTime', value || null, { shouldDirty: true, + shouldTouch: true, + shouldValidate: shouldValidateOnChange, }) - form.trigger(['startDate', 'endDate']) }} disabled={false} hideTime={hideTime} @@ -371,27 +192,18 @@ export function TimeRangePicker({ minDate={calendarMinDate} maxDate={calendarMaxDate} onDateChange={(value) => { - const currentTime = form.getValues('endTime') || null - const next = clampEndDateTime(value, currentTime) - dateField.onChange(next.date) - form.setValue('endTime', next.time, { - shouldValidate: true, + form.setValue('endDate', value, { shouldDirty: true, + shouldTouch: true, + shouldValidate: shouldValidateOnChange, }) - form.trigger(['startDate', 'endDate']) }} onTimeChange={(value) => { - const currentDate = form.getValues('endDate') - const next = clampEndDateTime(currentDate, value || null) - form.setValue('endDate', next.date, { - shouldValidate: true, - shouldDirty: true, - }) - form.setValue('endTime', next.time, { - shouldValidate: true, + form.setValue('endTime', value || null, { shouldDirty: true, + shouldTouch: true, + shouldValidate: shouldValidateOnChange, }) - form.trigger(['startDate', 'endDate']) }} disabled={false} hideTime={hideTime} @@ -404,7 +216,7 @@ export function TimeRangePicker({
diff --git a/src/features/dashboard/sandbox/monitoring/components/monitoring-time-range-controls.tsx b/src/features/dashboard/sandbox/monitoring/components/monitoring-time-range-controls.tsx index f6353dcb4..8a6a195c0 100644 --- a/src/features/dashboard/sandbox/monitoring/components/monitoring-time-range-controls.tsx +++ b/src/features/dashboard/sandbox/monitoring/components/monitoring-time-range-controls.tsx @@ -39,6 +39,22 @@ import { type SandboxLifecycleBounds, } from '../utils/timeframe' +function isValidDate(date: Date): boolean { + return Number.isFinite(date.getTime()) +} + +function toSafeIsoDateTime( + timestampMs: number, + fallbackTimestampMs: number = Date.now() +): string { + const candidate = new Date(timestampMs) + if (isValidDate(candidate)) { + return candidate.toISOString() + } + + return new Date(fallbackTimestampMs).toISOString() +} + interface SandboxMonitoringTimeRangeControlsProps { timeframe: { start: number @@ -197,7 +213,13 @@ export default function SandboxMonitoringTimeRangeControls({ SANDBOX_MONITORING_TIME_LABEL_FORMAT_OPTIONS ) - return `${formatter.format(timeframe.start)} - ${formatter.format(timeframe.end)}` + const startDate = new Date(timeframe.start) + const endDate = new Date(timeframe.end) + if (!isValidDate(startDate) || !isValidDate(endDate)) { + return '--' + } + + return `${formatter.format(startDate)} - ${formatter.format(endDate)}` }, [timeframe.end, timeframe.start]) useEffect(() => { @@ -256,32 +278,11 @@ export default function SandboxMonitoringTimeRangeControls({ return } - if (isLiveUpdating) { - onLiveChange(false) - return - } - - const duration = timeframe.end - timeframe.start - const anchorEndMs = lifecycle.isRunning - ? Date.now() - : lifecycle.anchorEndMs - const next = clampToLifecycle( - anchorEndMs - duration, - anchorEndMs - ) - - onTimeRangeChange(next.start, next.end, { - isLiveUpdating: true, - }) + onLiveChange(!isLiveUpdating) }, [ - clampToLifecycle, isLiveUpdating, - lifecycle.anchorEndMs, lifecycle.isRunning, onLiveChange, - onTimeRangeChange, - timeframe.end, - timeframe.start, ]) return ( @@ -308,8 +309,8 @@ export default function SandboxMonitoringTimeRangeControls({
now) { - safeEnd = now - } - - if (safeEnd <= safeStart) { - safeEnd = safeStart + minimumRange - } - - const range = safeEnd - safeStart - if (range < minimumRange) { - safeStart = safeEnd - minimumRange - } - - return { - start: safeStart, - end: safeEnd, - duration: safeEnd - safeStart, - } -} - -function getDefaultTimeframe() { - const now = Date.now() - - return normalizeTimeframe(now - SANDBOX_MONITORING_DEFAULT_RANGE_MS, now, now) -} - -interface SandboxMonitoringStoreState { - sandboxId: string | null - timeframe: SandboxMonitoringTimeframe - isLiveUpdating: boolean - metrics: SandboxMetric[] - isInitialized: boolean -} - -interface SandboxMonitoringStoreActions { - initialize: ( - sandboxId: string, - params: { - start?: number | null - end?: number | null - live?: boolean | null - } - ) => void - setTimeframe: ( - start: number, - end: number, - options?: { isLiveUpdating?: boolean } - ) => void - setLiveUpdating: (isLiveUpdating: boolean, now?: number) => void - syncLiveTimeframe: (now?: number) => SandboxMonitoringTimeframe - setMetrics: (metrics: SandboxMetric[]) => void -} - -type SandboxMonitoringStore = SandboxMonitoringStoreState & - SandboxMonitoringStoreActions - -const initialTimeframe = getDefaultTimeframe() - -const initialState: SandboxMonitoringStoreState = { - sandboxId: null, - timeframe: initialTimeframe, - isLiveUpdating: true, - metrics: [], - isInitialized: false, -} - -export const useSandboxMonitoringStore = create()( - (set, get) => ({ - ...initialState, - - initialize: (sandboxId, params) => { - const now = Date.now() - const current = get() - const isNewSandbox = current.sandboxId !== sandboxId - - const fallbackStart = now - SANDBOX_MONITORING_DEFAULT_RANGE_MS - const fallbackEnd = now - const start = params.start ?? fallbackStart - const end = params.end ?? fallbackEnd - const isLiveUpdating = params.live ?? true - const normalized = normalizeTimeframe(start, end) - - set((state) => { - const shouldUpdate = - !state.isInitialized || - isNewSandbox || - state.isLiveUpdating !== isLiveUpdating || - state.timeframe.start !== normalized.start || - state.timeframe.end !== normalized.end - - if (!shouldUpdate) { - return state - } - - return { - sandboxId, - timeframe: normalized, - isLiveUpdating, - metrics: isNewSandbox ? [] : state.metrics, - isInitialized: true, - } - }) - }, - - setTimeframe: (start, end, options) => { - set((state) => { - const normalized = normalizeTimeframe(start, end) - const nextLiveUpdating = - options?.isLiveUpdating ?? state.isLiveUpdating - - if ( - state.timeframe.start === normalized.start && - state.timeframe.end === normalized.end && - state.isLiveUpdating === nextLiveUpdating - ) { - return state - } - - return { - timeframe: normalized, - isLiveUpdating: nextLiveUpdating, - } - }) - }, - - setLiveUpdating: (isLiveUpdating, now = Date.now()) => { - set((state) => { - if (state.isLiveUpdating === isLiveUpdating) { - return state - } - - if (!isLiveUpdating) { - return { isLiveUpdating: false } - } - - const nextTimeframe = normalizeTimeframe( - now - state.timeframe.duration, - now, - now - ) - - return { - isLiveUpdating: true, - timeframe: nextTimeframe, - } - }) - }, - - syncLiveTimeframe: (now = Date.now()) => { - const state = get() - if (!state.isLiveUpdating) { - return state.timeframe - } - - const nextTimeframe = normalizeTimeframe( - now - state.timeframe.duration, - now, - now - ) - - if ( - nextTimeframe.start === state.timeframe.start && - nextTimeframe.end === state.timeframe.end - ) { - return state.timeframe - } - - set({ - timeframe: nextTimeframe, - }) - - return nextTimeframe - }, - - setMetrics: (metrics) => { - set({ - metrics, - }) - }, - }) -) diff --git a/src/features/dashboard/sandbox/monitoring/state/use-sandbox-monitoring-controller.ts b/src/features/dashboard/sandbox/monitoring/state/use-sandbox-monitoring-controller.ts index 4b48b2f26..565d4c052 100644 --- a/src/features/dashboard/sandbox/monitoring/state/use-sandbox-monitoring-controller.ts +++ b/src/features/dashboard/sandbox/monitoring/state/use-sandbox-monitoring-controller.ts @@ -1,182 +1,428 @@ 'use client' import { useDashboard } from '@/features/dashboard/context' +import { useSandboxContext } from '@/features/dashboard/sandbox/context' import type { SandboxMetric } from '@/server/api/models/sandboxes.models' import { useTRPCClient } from '@/trpc/client' import { keepPreviousData, useQuery } from '@tanstack/react-query' import { usePathname, useRouter, useSearchParams } from 'next/navigation' -import { useEffect, useMemo } from 'react' +import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react' import { + SANDBOX_MONITORING_DEFAULT_RANGE_MS, SANDBOX_MONITORING_LIVE_POLLING_MS, + SANDBOX_MONITORING_MAX_RANGE_MS, SANDBOX_MONITORING_METRICS_FETCH_ERROR_MESSAGE, + SANDBOX_MONITORING_MIN_RANGE_MS, SANDBOX_MONITORING_QUERY_END_PARAM, SANDBOX_MONITORING_QUERY_LIVE_FALSE, SANDBOX_MONITORING_QUERY_LIVE_PARAM, SANDBOX_MONITORING_QUERY_LIVE_TRUE, SANDBOX_MONITORING_QUERY_START_PARAM, } from '../utils/constants' -import { useSandboxMonitoringStore } from './store' +import { + clampTimeframeToBounds, + getSandboxLifecycleBounds, + normalizeMonitoringTimeframe, + parseMonitoringQueryState, + type SandboxLifecycleBounds, +} from '../utils/timeframe' + +interface SandboxMonitoringTimeframe { + start: number + end: number + duration: number +} -function parseQueryInteger(value: string | null): number | null { - if (!value) return null - const parsed = Number.parseInt(value, 10) - return Number.isNaN(parsed) ? null : parsed +interface SandboxMonitoringControllerState { + sandboxId: string | null + timeframe: SandboxMonitoringTimeframe + isLiveUpdating: boolean + isInitialized: boolean } -function parseQueryBoolean(value: string | null): boolean | null { - if (value === null) { - return null +type SandboxMonitoringControllerAction = + | { + type: 'initialize' + payload: { + sandboxId: string + timeframe: SandboxMonitoringTimeframe + isLiveUpdating: boolean + } + } + | { + type: 'setTimeframe' + payload: { + timeframe: SandboxMonitoringTimeframe + isLiveUpdating: boolean + } + } + | { + type: 'setLiveUpdating' + payload: { + isLiveUpdating: boolean + } + } + +function toTimeframe(start: number, end: number): SandboxMonitoringTimeframe { + return { + start, + end, + duration: end - start, } +} - if (value === SANDBOX_MONITORING_QUERY_LIVE_TRUE || value === 'true') { - return true +function getDefaultTimeframe(now: number = Date.now()): SandboxMonitoringTimeframe { + const normalized = normalizeMonitoringTimeframe({ + start: now - SANDBOX_MONITORING_DEFAULT_RANGE_MS, + end: now, + now, + minRangeMs: SANDBOX_MONITORING_MIN_RANGE_MS, + maxRangeMs: SANDBOX_MONITORING_MAX_RANGE_MS, + }) + + return toTimeframe(normalized.start, normalized.end) +} + +function resolveTimeframe( + start: number, + end: number, + now: number, + lifecycleBounds: SandboxLifecycleBounds | null +): SandboxMonitoringTimeframe { + const normalized = normalizeMonitoringTimeframe({ + start, + end, + now, + minRangeMs: SANDBOX_MONITORING_MIN_RANGE_MS, + maxRangeMs: SANDBOX_MONITORING_MAX_RANGE_MS, + }) + + if (!lifecycleBounds) { + return toTimeframe(normalized.start, normalized.end) } - if (value === SANDBOX_MONITORING_QUERY_LIVE_FALSE || value === 'false') { - return false + const maxBoundMs = lifecycleBounds.isRunning + ? now + : lifecycleBounds.anchorEndMs + const clamped = clampTimeframeToBounds( + normalized.start, + normalized.end, + lifecycleBounds.startMs, + maxBoundMs + ) + + return toTimeframe(clamped.start, clamped.end) +} + +function sandboxMonitoringControllerReducer( + state: SandboxMonitoringControllerState, + action: SandboxMonitoringControllerAction +): SandboxMonitoringControllerState { + switch (action.type) { + case 'initialize': { + const { sandboxId, timeframe, isLiveUpdating } = action.payload + + if ( + state.isInitialized && + state.sandboxId === sandboxId && + state.isLiveUpdating === isLiveUpdating && + state.timeframe.start === timeframe.start && + state.timeframe.end === timeframe.end + ) { + return state + } + + return { + sandboxId, + timeframe, + isLiveUpdating, + isInitialized: true, + } + } + + case 'setTimeframe': { + const { timeframe, isLiveUpdating } = action.payload + if ( + state.timeframe.start === timeframe.start && + state.timeframe.end === timeframe.end && + state.isLiveUpdating === isLiveUpdating + ) { + return state + } + + return { + ...state, + timeframe, + isLiveUpdating, + } + } + + case 'setLiveUpdating': { + if (state.isLiveUpdating === action.payload.isLiveUpdating) { + return state + } + + return { + ...state, + isLiveUpdating: action.payload.isLiveUpdating, + } + } + + default: + return state } +} - return null +function createInitialState(): SandboxMonitoringControllerState { + return { + sandboxId: null, + timeframe: getDefaultTimeframe(), + isLiveUpdating: true, + isInitialized: false, + } } export function useSandboxMonitoringController(sandboxId: string) { const trpcClient = useTRPCClient() const { team } = useDashboard() + const { sandboxInfo } = useSandboxContext() const router = useRouter() const pathname = usePathname() const searchParams = useSearchParams() - const timeframe = useSandboxMonitoringStore((state) => state.timeframe) - const metrics = useSandboxMonitoringStore((state) => state.metrics) - const isLiveUpdating = useSandboxMonitoringStore( - (state) => state.isLiveUpdating + const [state, dispatch] = useReducer( + sandboxMonitoringControllerReducer, + undefined, + createInitialState ) - const isInitialized = useSandboxMonitoringStore( - (state) => state.isInitialized + const durationRef = useRef(state.timeframe.duration) + + const queryStart = searchParams.get(SANDBOX_MONITORING_QUERY_START_PARAM) + const queryEnd = searchParams.get(SANDBOX_MONITORING_QUERY_END_PARAM) + const queryLive = searchParams.get(SANDBOX_MONITORING_QUERY_LIVE_PARAM) + const searchParamsString = searchParams.toString() + + const queryState = useMemo( + () => + parseMonitoringQueryState({ + start: queryStart, + end: queryEnd, + live: queryLive, + }), + [queryEnd, queryLive, queryStart] ) - const initialize = useSandboxMonitoringStore((state) => state.initialize) - const setTimeframe = useSandboxMonitoringStore((state) => state.setTimeframe) - const setMetrics = useSandboxMonitoringStore((state) => state.setMetrics) - const setLiveUpdating = useSandboxMonitoringStore( - (state) => state.setLiveUpdating + + const lifecycleStartedAt = sandboxInfo?.startedAt + const lifecycleEndAt = sandboxInfo?.endAt + const lifecycleState = sandboxInfo?.state + const lifecycleBounds = useMemo(() => { + if (!lifecycleStartedAt || !lifecycleState) { + return null + } + + return getSandboxLifecycleBounds({ + startedAt: lifecycleStartedAt, + endAt: lifecycleEndAt ?? null, + state: lifecycleState, + }) + }, [lifecycleEndAt, lifecycleStartedAt, lifecycleState]) + + const applyTimeframe = useCallback( + ( + start: number, + end: number, + options?: { + isLiveUpdating?: boolean + } + ) => { + const now = Date.now() + const timeframe = resolveTimeframe(start, end, now, lifecycleBounds) + const requestedLiveUpdating = + options?.isLiveUpdating ?? state.isLiveUpdating + const nextLiveUpdating = lifecycleBounds?.isRunning + ? requestedLiveUpdating + : lifecycleBounds + ? false + : requestedLiveUpdating + + dispatch({ + type: 'setTimeframe', + payload: { + timeframe, + isLiveUpdating: nextLiveUpdating, + }, + }) + }, + [lifecycleBounds, state.isLiveUpdating] ) - const urlState = useMemo( - () => ({ - start: parseQueryInteger( - searchParams.get(SANDBOX_MONITORING_QUERY_START_PARAM) - ), - end: parseQueryInteger( - searchParams.get(SANDBOX_MONITORING_QUERY_END_PARAM) - ), - live: parseQueryBoolean( - searchParams.get(SANDBOX_MONITORING_QUERY_LIVE_PARAM) - ), - }), - [searchParams] + const setLiveUpdating = useCallback( + (isLiveUpdating: boolean) => { + if (!isLiveUpdating) { + dispatch({ + type: 'setLiveUpdating', + payload: { isLiveUpdating: false }, + }) + + return + } + + if (lifecycleBounds && !lifecycleBounds.isRunning) { + dispatch({ + type: 'setLiveUpdating', + payload: { isLiveUpdating: false }, + }) + + return + } + + const now = Date.now() + const anchorEndMs = lifecycleBounds?.isRunning + ? now + : (lifecycleBounds?.anchorEndMs ?? now) + + applyTimeframe( + anchorEndMs - state.timeframe.duration, + anchorEndMs, + { + isLiveUpdating: true, + } + ) + }, + [applyTimeframe, lifecycleBounds, state.timeframe.duration] ) useEffect(() => { - initialize(sandboxId, urlState) - }, [initialize, sandboxId, urlState]) + durationRef.current = state.timeframe.duration + }, [state.timeframe.duration]) + + useEffect(() => { + const now = Date.now() + const requestedLiveUpdating = queryState.live ?? true + const start = queryState.start ?? now - SANDBOX_MONITORING_DEFAULT_RANGE_MS + const end = queryState.end ?? now + const timeframe = resolveTimeframe(start, end, now, lifecycleBounds) + + dispatch({ + type: 'initialize', + payload: { + sandboxId, + timeframe, + isLiveUpdating: + lifecycleBounds && !lifecycleBounds.isRunning + ? false + : requestedLiveUpdating, + }, + }) + }, [ + lifecycleBounds, + queryState.end, + queryState.live, + queryState.start, + sandboxId, + ]) + + useEffect(() => { + if (!state.isInitialized || !state.isLiveUpdating) { + return + } + + if (lifecycleBounds && !lifecycleBounds.isRunning) { + return + } + + const tick = () => { + const now = Date.now() + const anchorEndMs = lifecycleBounds?.isRunning + ? now + : (lifecycleBounds?.anchorEndMs ?? now) + + applyTimeframe(anchorEndMs - durationRef.current, anchorEndMs, { + isLiveUpdating: true, + }) + } + + const intervalId = window.setInterval(tick, SANDBOX_MONITORING_LIVE_POLLING_MS) + + return () => { + window.clearInterval(intervalId) + } + }, [ + applyTimeframe, + lifecycleBounds, + state.isInitialized, + state.isLiveUpdating, + ]) useEffect(() => { - if (!isInitialized) return - - const currentStart = searchParams.get(SANDBOX_MONITORING_QUERY_START_PARAM) - const currentEnd = searchParams.get(SANDBOX_MONITORING_QUERY_END_PARAM) - const currentLive = searchParams.get(SANDBOX_MONITORING_QUERY_LIVE_PARAM) - const nextStart = String(timeframe.start) - const nextEnd = String(timeframe.end) - const nextLive = isLiveUpdating + if (!state.isInitialized) { + return + } + + const nextStart = String(state.timeframe.start) + const nextEnd = String(state.timeframe.end) + const nextLive = state.isLiveUpdating ? SANDBOX_MONITORING_QUERY_LIVE_TRUE : SANDBOX_MONITORING_QUERY_LIVE_FALSE if ( - currentStart === nextStart && - currentEnd === nextEnd && - currentLive === nextLive + queryStart === nextStart && + queryEnd === nextEnd && + queryLive === nextLive ) { return } - const nextParams = new URLSearchParams(searchParams.toString()) + const nextParams = new URLSearchParams(searchParamsString) nextParams.set(SANDBOX_MONITORING_QUERY_START_PARAM, nextStart) nextParams.set(SANDBOX_MONITORING_QUERY_END_PARAM, nextEnd) nextParams.set(SANDBOX_MONITORING_QUERY_LIVE_PARAM, nextLive) - router.replace(`${pathname}?${nextParams.toString()}`, { scroll: false }) + router.replace(`${pathname}?${nextParams.toString()}`, { + scroll: false, + }) }, [ - isInitialized, - isLiveUpdating, pathname, + queryEnd, + queryLive, + queryStart, router, - searchParams, - timeframe.end, - timeframe.start, + searchParamsString, + state.isInitialized, + state.isLiveUpdating, + state.timeframe.end, + state.timeframe.start, ]) - const queryKey = useMemo(() => { - if (isLiveUpdating) { - return [ - 'sandboxMonitoringMetrics', - team?.id ?? '', - sandboxId, - 'live', - timeframe.duration, - ] as const - } - - return [ + const queryKey = useMemo( + () => [ 'sandboxMonitoringMetrics', team?.id ?? '', sandboxId, - 'static', - timeframe.start, - timeframe.end, - ] as const - }, [ - isLiveUpdating, - sandboxId, - team?.id, - timeframe.duration, - timeframe.end, - timeframe.start, - ]) + state.timeframe.start, + state.timeframe.end, + ] as const, + [sandboxId, state.timeframe.end, state.timeframe.start, team?.id] + ) const metricsQuery = useQuery({ queryKey, - enabled: isInitialized && Boolean(team?.id), + enabled: state.isInitialized && Boolean(team?.id), placeholderData: keepPreviousData, - refetchInterval: isLiveUpdating - ? SANDBOX_MONITORING_LIVE_POLLING_MS - : false, - refetchIntervalInBackground: false, - refetchOnWindowFocus: isLiveUpdating ? 'always' : false, queryFn: async () => { if (!team?.id) { return [] } - const queryTimeframe = isLiveUpdating - ? useSandboxMonitoringStore.getState().syncLiveTimeframe() - : useSandboxMonitoringStore.getState().timeframe - return trpcClient.sandbox.resourceMetrics.query({ teamIdOrSlug: team.id, sandboxId, - startMs: queryTimeframe.start, - endMs: queryTimeframe.end, + startMs: state.timeframe.start, + endMs: state.timeframe.end, }) }, }) - useEffect(() => { - if (metricsQuery.data) { - setMetrics(metricsQuery.data) - } - }, [metricsQuery.data, setMetrics]) - const error = metricsQuery.error ? metricsQuery.error instanceof Error ? metricsQuery.error.message @@ -184,12 +430,13 @@ export function useSandboxMonitoringController(sandboxId: string) { : null return { - timeframe, - metrics, - isLiveUpdating, + lifecycleBounds, + timeframe: state.timeframe, + metrics: metricsQuery.data ?? [], + isLiveUpdating: state.isLiveUpdating, isLoading: metricsQuery.isLoading || metricsQuery.isFetching, error, - setTimeframe, + setTimeframe: applyTimeframe, setLiveUpdating, } } diff --git a/src/features/dashboard/sandbox/monitoring/types/sandbox-metrics-chart.ts b/src/features/dashboard/sandbox/monitoring/types/sandbox-metrics-chart.ts index d58307596..e0649f9fc 100644 --- a/src/features/dashboard/sandbox/monitoring/types/sandbox-metrics-chart.ts +++ b/src/features/dashboard/sandbox/monitoring/types/sandbox-metrics-chart.ts @@ -1,3 +1,5 @@ +import type { SandboxMetric } from '@/server/api/models/sandboxes.models' + export type SandboxMetricsDataPoint = { x: number y: number | null @@ -25,3 +27,23 @@ export interface SandboxMetricsChartProps { onHoverEnd?: () => void onBrushEnd?: (startTimestamp: number, endTimestamp: number) => void } + +export interface MonitoringResourceHoveredContext { + cpuPercent: number | null + ramPercent: number | null + timestampMs: number +} + +export interface MonitoringDiskHoveredContext { + diskPercent: number | null + timestampMs: number +} + +export interface MonitoringChartModel { + categories: number[] + latestMetric: SandboxMetric | undefined + resourceSeries: SandboxMetricsSeries[] + diskSeries: SandboxMetricsSeries[] + resourceHoveredContext: MonitoringResourceHoveredContext | null + diskHoveredContext: MonitoringDiskHoveredContext | null +} diff --git a/src/features/dashboard/sandbox/monitoring/utils/metrics.ts b/src/features/dashboard/sandbox/monitoring/utils/chart-model.ts similarity index 55% rename from src/features/dashboard/sandbox/monitoring/utils/metrics.ts rename to src/features/dashboard/sandbox/monitoring/utils/chart-model.ts index f5878b4f7..71fc81d50 100644 --- a/src/features/dashboard/sandbox/monitoring/utils/metrics.ts +++ b/src/features/dashboard/sandbox/monitoring/utils/chart-model.ts @@ -1,41 +1,49 @@ import type { SandboxMetric } from '@/server/api/models/sandboxes.models' import { millisecondsInSecond } from 'date-fns/constants' +import type { MonitoringChartModel } from '../types/sandbox-metrics-chart' import { + SANDBOX_MONITORING_CHART_MAX_POINTS, SANDBOX_MONITORING_CHART_MIN_STEP_MS, SANDBOX_MONITORING_CPU_AREA_COLOR_VAR, - SANDBOX_MONITORING_CPU_AREA_TO_COLOR_VAR, - SANDBOX_MONITORING_CPU_LINE_COLOR_VAR, SANDBOX_MONITORING_CPU_SERIES_ID, SANDBOX_MONITORING_CPU_SERIES_LABEL, - SANDBOX_MONITORING_DISK_AREA_COLOR_VAR, - SANDBOX_MONITORING_DISK_AREA_TO_COLOR_VAR, - SANDBOX_MONITORING_DISK_LINE_COLOR_VAR, + SANDBOX_MONITORING_CPU_LINE_COLOR_VAR, + SANDBOX_MONITORING_CPU_AREA_TO_COLOR_VAR, SANDBOX_MONITORING_DISK_SERIES_ID, SANDBOX_MONITORING_DISK_SERIES_LABEL, + SANDBOX_MONITORING_DISK_LINE_COLOR_VAR, + SANDBOX_MONITORING_DISK_AREA_COLOR_VAR, + SANDBOX_MONITORING_DISK_AREA_TO_COLOR_VAR, SANDBOX_MONITORING_PERCENT_MAX, SANDBOX_MONITORING_RAM_AREA_COLOR_VAR, - SANDBOX_MONITORING_RAM_AREA_TO_COLOR_VAR, - SANDBOX_MONITORING_RAM_LINE_COLOR_VAR, SANDBOX_MONITORING_RAM_SERIES_ID, SANDBOX_MONITORING_RAM_SERIES_LABEL, + SANDBOX_MONITORING_RAM_LINE_COLOR_VAR, + SANDBOX_MONITORING_RAM_AREA_TO_COLOR_VAR, } from './constants' -import type { SandboxMetricsSeries } from '../types/sandbox-metrics-chart' import { calculateStepForRange } from './timeframe' function clampPercent(value: number): number { - if (Number.isNaN(value)) return 0 + if (!Number.isFinite(value)) { + return 0 + } + return Math.max(0, Math.min(SANDBOX_MONITORING_PERCENT_MAX, value)) } -function getMetricTimestampMs(metric: SandboxMetric): number { - if (typeof metric.timestampUnix === 'number') { - return metric.timestampUnix * millisecondsInSecond +function toPercent(used: number, total: number): number { + if (!total || total <= 0) { + return 0 } - return new Date(metric.timestamp).getTime() + return clampPercent((used / total) * 100) } -export function filterSandboxMetricsByTimeRange( +function getMetricTimestampMs(metric: SandboxMetric): number { + return Math.floor(metric.timestampUnix * millisecondsInSecond) +} + +function filterSandboxMetricsByTimeRange( metrics: SandboxMetric[], startMs: number, endMs: number @@ -46,46 +54,7 @@ export function filterSandboxMetricsByTimeRange( }) } -export function buildTimelineCategories( - startMs: number, - endMs: number -): number[] { - const normalizedStart = Math.floor(startMs) - const normalizedEnd = Math.floor(endMs) - - if (normalizedEnd <= normalizedStart) { - return [normalizedStart] - } - - const stepMs = Math.max( - SANDBOX_MONITORING_CHART_MIN_STEP_MS, - Math.floor( - calculateStepForRange(normalizedStart, normalizedEnd) / - millisecondsInSecond - ) * millisecondsInSecond - ) - - const categories: number[] = [] - - for (let ts = normalizedStart; ts <= normalizedEnd; ts += stepMs) { - categories.push(ts) - } - - if (categories[categories.length - 1] !== normalizedEnd) { - categories.push(normalizedEnd) - } - - return categories -} - -function toPercent(used: number, total: number): number { - if (!total || total <= 0) return 0 - return clampPercent((used / total) * 100) -} - -export function sortSandboxMetricsByTime( - metrics: SandboxMetric[] -): SandboxMetric[] { +function sortSandboxMetricsByTime(metrics: SandboxMetric[]): SandboxMetric[] { return [...metrics].sort( (a, b) => getMetricTimestampMs(a) - getMetricTimestampMs(b) ) @@ -98,7 +67,6 @@ function createSeriesData( ) { const sums = Array(categories.length).fill(0) const counts = Array(categories.length).fill(0) - if (categories.length === 0) { return [] } @@ -108,15 +76,14 @@ function createSeriesData( const step = categories.length > 1 ? categories[1]! - categories[0]! : 1 for (const metric of metrics) { - const ts = getMetricTimestampMs(metric) - - if (ts < start || ts > end) { + const timestampMs = getMetricTimestampMs(metric) + if (timestampMs < start || timestampMs > end) { continue } const index = Math.max( 0, - Math.min(categories.length - 1, Math.round((ts - start) / step)) + Math.min(categories.length - 1, Math.round((timestampMs - start) / step)) ) sums[index] = (sums[index] ?? 0) + getValue(metric) @@ -129,10 +96,7 @@ function createSeriesData( })) } -export function buildResourceSeries( - metrics: SandboxMetric[], - categories: number[] -): SandboxMetricsSeries[] { +function buildResourceSeries(metrics: SandboxMetric[], categories: number[]) { return [ { id: SANDBOX_MONITORING_CPU_SERIES_ID, @@ -157,10 +121,7 @@ export function buildResourceSeries( ] } -export function buildDiskSeries( - metrics: SandboxMetric[], - categories: number[] -): SandboxMetricsSeries[] { +function buildDiskSeries(metrics: SandboxMetric[], categories: number[]) { return [ { id: SANDBOX_MONITORING_DISK_SERIES_ID, @@ -174,3 +135,117 @@ export function buildDiskSeries( }, ] } + +interface BuildMonitoringChartModelOptions { + metrics: SandboxMetric[] + startMs: number + endMs: number + hoveredIndex: number | null +} + +export function buildTimelineCategories(startMs: number, endMs: number): number[] { + if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) { + return [] + } + + const normalizedStart = Math.floor(startMs) + const normalizedEnd = Math.floor(endMs) + if (normalizedEnd <= normalizedStart) { + return [normalizedStart] + } + + const baseStepMs = Math.max( + SANDBOX_MONITORING_CHART_MIN_STEP_MS, + Math.floor( + calculateStepForRange(normalizedStart, normalizedEnd) / + millisecondsInSecond + ) * millisecondsInSecond + ) + + const duration = normalizedEnd - normalizedStart + const estimatedPoints = Math.floor(duration / baseStepMs) + 1 + const needsCapping = estimatedPoints > SANDBOX_MONITORING_CHART_MAX_POINTS + const stepMs = needsCapping + ? Math.max( + baseStepMs, + Math.ceil( + duration / (SANDBOX_MONITORING_CHART_MAX_POINTS - 1) / + millisecondsInSecond + ) * millisecondsInSecond + ) + : baseStepMs + + const categories: number[] = [] + for (let timestamp = normalizedStart; timestamp <= normalizedEnd; timestamp += stepMs) { + categories.push(timestamp) + } + + if (categories[categories.length - 1] !== normalizedEnd) { + categories.push(normalizedEnd) + } + + return categories +} + +export function buildMonitoringChartModel({ + metrics, + startMs, + endMs, + hoveredIndex, +}: BuildMonitoringChartModelOptions): MonitoringChartModel { + const constrainedMetrics = filterSandboxMetricsByTimeRange(metrics, startMs, endMs) + const sortedMetrics = sortSandboxMetricsByTime(constrainedMetrics) + const categories = buildTimelineCategories(startMs, endMs) + const resourceSeries = buildResourceSeries(sortedMetrics, categories) + const diskSeries = buildDiskSeries(sortedMetrics, categories) + const latestMetric = sortedMetrics[sortedMetrics.length - 1] + + if (hoveredIndex === null) { + return { + categories, + latestMetric, + resourceSeries, + diskSeries, + resourceHoveredContext: null, + diskHoveredContext: null, + } + } + + const hoveredTimestamp = categories[hoveredIndex] + if (hoveredTimestamp === undefined) { + return { + categories, + latestMetric, + resourceSeries, + diskSeries, + resourceHoveredContext: null, + diskHoveredContext: null, + } + } + + const cpuSeries = resourceSeries.find( + (series) => series.id === SANDBOX_MONITORING_CPU_SERIES_ID + ) + const ramSeries = resourceSeries.find( + (series) => series.id === SANDBOX_MONITORING_RAM_SERIES_ID + ) + const diskSeriesItem = diskSeries.find( + (series) => series.id === SANDBOX_MONITORING_DISK_SERIES_ID + ) + + return { + categories, + latestMetric, + resourceSeries, + diskSeries, + resourceHoveredContext: { + cpuPercent: cpuSeries?.data[hoveredIndex]?.y ?? null, + ramPercent: ramSeries?.data[hoveredIndex]?.y ?? null, + timestampMs: hoveredTimestamp, + }, + diskHoveredContext: { + diskPercent: diskSeriesItem?.data[hoveredIndex]?.y ?? null, + timestampMs: hoveredTimestamp, + }, + } +} diff --git a/src/features/dashboard/sandbox/monitoring/utils/constants.ts b/src/features/dashboard/sandbox/monitoring/utils/constants.ts index f49011f45..422be26b1 100644 --- a/src/features/dashboard/sandbox/monitoring/utils/constants.ts +++ b/src/features/dashboard/sandbox/monitoring/utils/constants.ts @@ -1,4 +1,5 @@ import { + millisecondsInDay, millisecondsInHour, millisecondsInMinute, millisecondsInSecond, @@ -6,7 +7,10 @@ import { export const SANDBOX_MONITORING_DEFAULT_RANGE_MS = millisecondsInHour export const SANDBOX_MONITORING_MIN_RANGE_MS = 90 * millisecondsInSecond +export const SANDBOX_MONITORING_MAX_RANGE_MS = 31 * millisecondsInDay export const SANDBOX_MONITORING_LIVE_POLLING_MS = 10_000 +export const SANDBOX_MONITORING_MIN_TIMESTAMP_MS = -8_640_000_000_000_000 +export const SANDBOX_MONITORING_MAX_TIMESTAMP_MS = 8_640_000_000_000_000 export const SANDBOX_MONITORING_QUERY_START_PARAM = 'start' export const SANDBOX_MONITORING_QUERY_END_PARAM = 'end' @@ -72,6 +76,7 @@ export const SANDBOX_MONITORING_CHART_OUT_OF_BRUSH_ALPHA = 0.25 export const SANDBOX_MONITORING_CHART_AXIS_LABEL_FONT_SIZE = 12 export const SANDBOX_MONITORING_CHART_Y_AXIS_SCALE_FACTOR = 1.5 export const SANDBOX_MONITORING_CHART_MIN_STEP_MS = millisecondsInSecond +export const SANDBOX_MONITORING_CHART_MAX_POINTS = 2_000 export const SANDBOX_MONITORING_CHART_LIVE_WINDOW_MS = 2 * millisecondsInMinute export const SANDBOX_MONITORING_CHART_LIVE_OUTER_DOT_SIZE = 16 export const SANDBOX_MONITORING_CHART_LIVE_MIDDLE_DOT_SIZE = 10 diff --git a/src/features/dashboard/sandbox/monitoring/utils/timeframe.ts b/src/features/dashboard/sandbox/monitoring/utils/timeframe.ts index 4627cbf88..80b09ed2c 100644 --- a/src/features/dashboard/sandbox/monitoring/utils/timeframe.ts +++ b/src/features/dashboard/sandbox/monitoring/utils/timeframe.ts @@ -5,7 +5,163 @@ import { millisecondsInMinute, millisecondsInSecond, } from 'date-fns/constants' -import { SANDBOX_MONITORING_MIN_RANGE_MS } from './constants' +import { + SANDBOX_MONITORING_DEFAULT_RANGE_MS, + SANDBOX_MONITORING_MAX_RANGE_MS, + SANDBOX_MONITORING_MAX_TIMESTAMP_MS, + SANDBOX_MONITORING_MIN_RANGE_MS, + SANDBOX_MONITORING_MIN_TIMESTAMP_MS, + SANDBOX_MONITORING_QUERY_LIVE_FALSE, + SANDBOX_MONITORING_QUERY_LIVE_TRUE, +} from './constants' + +export interface NormalizedMonitoringTimeframe { + start: number + end: number +} + +export interface MonitoringQueryState { + start: number | null + end: number | null + live: boolean | null +} + +interface NormalizeMonitoringTimeframeInput { + start: number + end: number + now?: number + minRangeMs?: number + maxRangeMs?: number +} + +interface ParseMonitoringQueryStateInput { + start: string | null + end: string | null + live: string | null +} + +function clampToBounds(value: number, min: number, max: number): number { + if (!Number.isFinite(value)) { + return max + } + + return Math.max(min, Math.min(max, Math.floor(value))) +} + +function parseTimestampParam(value: string | null): number | null { + if (!value) { + return null + } + + const normalizedValue = value.trim() + if (!/^-?\d+$/.test(normalizedValue)) { + return null + } + + const parsed = Number(normalizedValue) + if (!Number.isFinite(parsed)) { + return null + } + + if ( + parsed < SANDBOX_MONITORING_MIN_TIMESTAMP_MS || + parsed > SANDBOX_MONITORING_MAX_TIMESTAMP_MS + ) { + return null + } + + return parsed +} + +function parseLiveParam(value: string | null): boolean | null { + if (value === SANDBOX_MONITORING_QUERY_LIVE_TRUE) { + return true + } + + if (value === SANDBOX_MONITORING_QUERY_LIVE_FALSE) { + return false + } + + return null +} + +function parseDateTimestampMs(value: string | null | undefined): number | null { + if (!value) { + return null + } + + const parsed = new Date(value).getTime() + if (!Number.isFinite(parsed)) { + return null + } + + return parsed +} + +export function parseMonitoringQueryState({ + start, + end, + live, +}: ParseMonitoringQueryStateInput): MonitoringQueryState { + return { + start: parseTimestampParam(start), + end: parseTimestampParam(end), + live: parseLiveParam(live), + } +} + +export function normalizeMonitoringTimeframe({ + start, + end, + now = Date.now(), + minRangeMs = SANDBOX_MONITORING_MIN_RANGE_MS, + maxRangeMs = SANDBOX_MONITORING_MAX_RANGE_MS, +}: NormalizeMonitoringTimeframeInput): NormalizedMonitoringTimeframe { + const safeNow = clampToBounds( + now, + SANDBOX_MONITORING_MIN_TIMESTAMP_MS, + SANDBOX_MONITORING_MAX_TIMESTAMP_MS + ) + const safeMinBound = SANDBOX_MONITORING_MIN_TIMESTAMP_MS + const safeMaxBound = safeNow + const fallbackEnd = safeNow + const fallbackStart = fallbackEnd - SANDBOX_MONITORING_DEFAULT_RANGE_MS + + let safeStart = Number.isFinite(start) ? start : fallbackStart + let safeEnd = Number.isFinite(end) ? end : fallbackEnd + + safeStart = clampToBounds(safeStart, safeMinBound, safeMaxBound) + safeEnd = clampToBounds(safeEnd, safeMinBound, safeMaxBound) + + if (safeEnd < safeStart) { + ;[safeStart, safeEnd] = [safeEnd, safeStart] + } + + if (safeEnd - safeStart > maxRangeMs) { + safeStart = safeEnd - maxRangeMs + } + + if (safeEnd - safeStart < minRangeMs) { + safeStart = safeEnd - minRangeMs + } + + safeStart = clampToBounds(safeStart, safeMinBound, safeMaxBound) + safeEnd = clampToBounds(safeEnd, safeMinBound, safeMaxBound) + + if (safeEnd - safeStart < minRangeMs) { + safeEnd = clampToBounds(safeStart + minRangeMs, safeMinBound, safeMaxBound) + safeStart = clampToBounds(safeEnd - minRangeMs, safeMinBound, safeMaxBound) + } + + if (safeEnd - safeStart > maxRangeMs) { + safeStart = safeEnd - maxRangeMs + } + + return { + start: safeStart, + end: safeEnd, + } +} export function calculateStepForRange(startMs: number, endMs: number): number { const duration = endMs - startMs @@ -36,33 +192,27 @@ export interface SandboxLifecycleBounds { } export function getSandboxLifecycleBounds( - sandboxInfo: SandboxDetailsDTO, + sandboxInfo: Pick, now: number = Date.now() ): SandboxLifecycleBounds | null { - const startMs = new Date(sandboxInfo.startedAt).getTime() + const startMs = parseDateTimestampMs(sandboxInfo.startedAt) const isRunning = sandboxInfo.state === 'running' - if (!Number.isFinite(startMs)) { + if (startMs === null) { return null } - let endAt: string - if (sandboxInfo.state === 'killed') { - if (!sandboxInfo.endAt) { - return null - } - endAt = sandboxInfo.endAt - } else { - endAt = sandboxInfo.endAt - } - - const endMs = new Date(endAt).getTime() - - if (!Number.isFinite(endMs)) { + const endMs = parseDateTimestampMs(sandboxInfo.endAt) + if (endMs === null) { return null } - const anchorEndMs = isRunning ? Math.min(now, endMs) : endMs + const safeNow = clampToBounds( + now, + SANDBOX_MONITORING_MIN_TIMESTAMP_MS, + SANDBOX_MONITORING_MAX_TIMESTAMP_MS + ) + const anchorEndMs = Math.min(safeNow, endMs) const normalizedStart = Math.floor(Math.min(startMs, anchorEndMs)) const normalizedEnd = Math.floor(Math.max(startMs, anchorEndMs)) From 9d57012c78e21392c3d6496cbeec9e80bd6887b7 Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Mon, 2 Mar 2026 14:20:33 -0800 Subject: [PATCH 37/54] chore: tests --- src/__test__/unit/sandbox-monitoring-timeframe.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/__test__/unit/sandbox-monitoring-timeframe.test.ts b/src/__test__/unit/sandbox-monitoring-timeframe.test.ts index 923ed932a..8340647b7 100644 --- a/src/__test__/unit/sandbox-monitoring-timeframe.test.ts +++ b/src/__test__/unit/sandbox-monitoring-timeframe.test.ts @@ -1,11 +1,11 @@ import { describe, expect, it } from 'vitest' +import { buildTimelineCategories } from '@/features/dashboard/sandbox/monitoring/utils/chart-model' import { + SANDBOX_MONITORING_CHART_MAX_POINTS, SANDBOX_MONITORING_DEFAULT_RANGE_MS, SANDBOX_MONITORING_MAX_RANGE_MS, SANDBOX_MONITORING_MIN_RANGE_MS, - SANDBOX_MONITORING_CHART_MAX_POINTS, } from '@/features/dashboard/sandbox/monitoring/utils/constants' -import { buildTimelineCategories } from '@/features/dashboard/sandbox/monitoring/utils/chart-model' import { getSandboxLifecycleBounds, normalizeMonitoringTimeframe, From b70311de24570b3e7746fccc6e382e14b781883f Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Tue, 3 Mar 2026 14:22:54 -0800 Subject: [PATCH 38/54] refactor: update monitoring chart model and tests - Renamed test descriptions for clarity and consistency. - Updated the `buildMonitoringChartModel` function to use `hoveredTimestampMs` instead of `hoveredIndex`. - Enhanced test cases to validate the output structure and data filtering based on timestamps. - Removed unused test cases and improved the overall test coverage for the monitoring chart model. - Adjusted the constants for minimum range settings in the monitoring utils. --- .../sandbox-monitoring-chart-model.test.ts | 49 +-- .../unit/sandbox-monitoring-timeframe.test.ts | 21 -- .../components/monitoring-charts.tsx | 93 ++++-- .../monitoring-resource-chart-header.tsx | 4 +- .../monitoring-sandbox-metrics-chart.tsx | 169 ++++------- .../monitoring-time-range-controls.tsx | 200 +++++++++++-- .../use-sandbox-monitoring-controller.ts | 110 +++++-- .../monitoring/types/sandbox-metrics-chart.ts | 14 +- .../sandbox/monitoring/utils/chart-model.ts | 280 +++++++++--------- .../sandbox/monitoring/utils/constants.ts | 4 +- .../sandbox/monitoring/utils/timeframe.ts | 28 -- src/styles/theme.css | 4 +- 12 files changed, 555 insertions(+), 421 deletions(-) diff --git a/src/__test__/unit/sandbox-monitoring-chart-model.test.ts b/src/__test__/unit/sandbox-monitoring-chart-model.test.ts index cab56cf0c..e580d4e91 100644 --- a/src/__test__/unit/sandbox-monitoring-chart-model.test.ts +++ b/src/__test__/unit/sandbox-monitoring-chart-model.test.ts @@ -13,7 +13,7 @@ const baseMetric = { > describe('buildMonitoringChartModel', () => { - it('should build deterministic chart data and hovered contexts', () => { + it('builds deterministic time-series data sorted by timestamp', () => { const metrics: SandboxMetric[] = [ { ...baseMetric, @@ -42,13 +42,27 @@ describe('buildMonitoringChartModel', () => { metrics, startMs: 0, endMs: 10_000, - hoveredIndex: 1, + hoveredTimestampMs: 6_000, }) - expect(result.categories).toEqual([0, 5_000, 10_000]) expect(result.latestMetric?.timestampUnix).toBe(10) expect(result.resourceSeries).toHaveLength(2) expect(result.diskSeries).toHaveLength(1) + expect(result.resourceSeries[0]?.data).toEqual([ + [0, 10], + [5_000, 20], + [10_000, 30], + ]) + expect(result.resourceSeries[1]?.data).toEqual([ + [0, 10], + [5_000, 20], + [10_000, 30], + ]) + expect(result.diskSeries[0]?.data).toEqual([ + [0, 10], + [5_000, 20], + [10_000, 30], + ]) expect(result.resourceHoveredContext).toEqual({ cpuPercent: 20, ramPercent: 20, @@ -60,29 +74,19 @@ describe('buildMonitoringChartModel', () => { }) }) - it('should return null hovered contexts when hovered index is invalid', () => { - const metrics: SandboxMetric[] = [ - { - ...baseMetric, - timestampUnix: 1, - cpuUsedPct: 10, - memUsed: 100, - diskUsed: 200, - }, - ] - + it('returns null hovered contexts when no data is available', () => { const result = buildMonitoringChartModel({ - metrics, + metrics: [], startMs: 0, endMs: 10_000, - hoveredIndex: 10, + hoveredTimestampMs: 1_000, }) expect(result.resourceHoveredContext).toBeNull() expect(result.diskHoveredContext).toBeNull() }) - it('should ignore metrics outside selected timeframe', () => { + it('filters out metrics outside range and invalid timestamps', () => { const metrics: SandboxMetric[] = [ { ...baseMetric, @@ -91,6 +95,13 @@ describe('buildMonitoringChartModel', () => { memUsed: 50, diskUsed: 100, }, + { + ...baseMetric, + timestampUnix: Number.NaN, + cpuUsedPct: 55, + memUsed: 550, + diskUsed: 1_100, + }, { ...baseMetric, timestampUnix: 20, @@ -104,9 +115,11 @@ describe('buildMonitoringChartModel', () => { metrics, startMs: 0, endMs: 10_000, - hoveredIndex: null, + hoveredTimestampMs: null, }) expect(result.latestMetric?.timestampUnix).toBe(1) + expect(result.resourceSeries[0]?.data).toEqual([[1_000, 5]]) + expect(result.diskSeries[0]?.data).toEqual([[1_000, 5]]) }) }) diff --git a/src/__test__/unit/sandbox-monitoring-timeframe.test.ts b/src/__test__/unit/sandbox-monitoring-timeframe.test.ts index 8340647b7..096cd0313 100644 --- a/src/__test__/unit/sandbox-monitoring-timeframe.test.ts +++ b/src/__test__/unit/sandbox-monitoring-timeframe.test.ts @@ -1,7 +1,5 @@ import { describe, expect, it } from 'vitest' -import { buildTimelineCategories } from '@/features/dashboard/sandbox/monitoring/utils/chart-model' import { - SANDBOX_MONITORING_CHART_MAX_POINTS, SANDBOX_MONITORING_DEFAULT_RANGE_MS, SANDBOX_MONITORING_MAX_RANGE_MS, SANDBOX_MONITORING_MIN_RANGE_MS, @@ -66,25 +64,6 @@ describe('sandbox-monitoring-timeframe', () => { }) }) - describe('buildTimelineCategories', () => { - it('should cap category count for very large ranges', () => { - const end = 31 * 24 * 60 * 60 * 1_000 - - const categories = buildTimelineCategories(0, end) - - expect(categories.length).toBeLessThanOrEqual( - SANDBOX_MONITORING_CHART_MAX_POINTS - ) - expect(categories[0]).toBe(0) - expect(categories[categories.length - 1]).toBe(end) - }) - - it('should return empty categories for invalid timestamps', () => { - const categories = buildTimelineCategories(Number.NaN, Number.NaN) - expect(categories).toEqual([]) - }) - }) - describe('parseMonitoringQueryState', () => { it('should parse canonical query params', () => { const result = parseMonitoringQueryState({ diff --git a/src/features/dashboard/sandbox/monitoring/components/monitoring-charts.tsx b/src/features/dashboard/sandbox/monitoring/components/monitoring-charts.tsx index 1375e6afb..573789b96 100644 --- a/src/features/dashboard/sandbox/monitoring/components/monitoring-charts.tsx +++ b/src/features/dashboard/sandbox/monitoring/components/monitoring-charts.tsx @@ -1,9 +1,10 @@ 'use client' -import { useCallback, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { cn } from '@/lib/utils' import { useSandboxMonitoringController } from '../state/use-sandbox-monitoring-controller' -import { SANDBOX_MONITORING_PERCENT_MAX } from '../utils/constants' import { buildMonitoringChartModel } from '../utils/chart-model' +import { SANDBOX_MONITORING_PERCENT_MAX } from '../utils/constants' import MonitoringChartSection from './monitoring-chart-section' import DiskChartHeader from './monitoring-disk-chart-header' import ResourceChartHeader from './monitoring-resource-chart-header' @@ -21,11 +22,18 @@ export default function SandboxMetricsCharts({ metrics, timeframe, isLiveUpdating, + isRefetching, setTimeframe, setLiveUpdating, lifecycleBounds, } = useSandboxMonitoringController(sandboxId) - const [hoveredIndex, setHoveredIndex] = useState(null) + const [hoveredTimestampMs, setHoveredTimestampMs] = useState( + null + ) + const [renderedTimeframe, setRenderedTimeframe] = useState(() => ({ + start: timeframe.start, + end: timeframe.end, + })) const handleTimeRangeChange = useCallback( ( @@ -43,7 +51,7 @@ export default function SandboxMetricsCharts({ return } - setHoveredIndex(null) + setHoveredTimestampMs(null) setTimeframe(startTimestamp, endTimestamp, { isLiveUpdating: nextLiveUpdating, }) @@ -55,47 +63,74 @@ export default function SandboxMetricsCharts({ () => buildMonitoringChartModel({ metrics, - startMs: timeframe.start, - endMs: timeframe.end, - hoveredIndex, + startMs: renderedTimeframe.start, + endMs: renderedTimeframe.end, + hoveredTimestampMs, }), - [hoveredIndex, metrics, timeframe.end, timeframe.start] + [ + hoveredTimestampMs, + metrics, + renderedTimeframe.end, + renderedTimeframe.start, + ] ) + useEffect(() => { + if (isRefetching) { + return + } + + setRenderedTimeframe((previous) => { + if ( + previous.start === timeframe.start && + previous.end === timeframe.end + ) { + return previous + } + + return { + start: timeframe.start, + end: timeframe.end, + } + }) + }, [isRefetching, timeframe.end, timeframe.start]) + const handleHoverEnd = useCallback(() => { - setHoveredIndex(null) + setHoveredTimestampMs(null) }, []) return ( -
+
+ {lifecycleBounds ? ( +
+ +
+ ) : null} + - ) : null - } /> } > @@ -111,13 +146,15 @@ export default function SandboxMetricsCharts({ } > diff --git a/src/features/dashboard/sandbox/monitoring/components/monitoring-resource-chart-header.tsx b/src/features/dashboard/sandbox/monitoring/components/monitoring-resource-chart-header.tsx index 65cf3c89c..956b6f5bf 100644 --- a/src/features/dashboard/sandbox/monitoring/components/monitoring-resource-chart-header.tsx +++ b/src/features/dashboard/sandbox/monitoring/components/monitoring-resource-chart-header.tsx @@ -1,9 +1,9 @@ 'use client' +import type { ReactNode } from 'react' import { cn } from '@/lib/utils' import type { SandboxMetric } from '@/server/api/models/sandboxes.models' import { CpuIcon, MemoryIcon } from '@/ui/primitives/icons' -import type { ReactNode } from 'react' import { SANDBOX_MONITORING_CPU_INDICATOR_CLASS, SANDBOX_MONITORING_CPU_SERIES_LABEL, @@ -80,7 +80,7 @@ export default function ResourceChartHeader({ : null return ( -
+
+interface AxisPointerInfo { + value?: unknown +} + +interface UpdateAxisPointerEventParams { + axesInfo?: AxisPointerInfo[] + xAxisInfo?: AxisPointerInfo[] value?: unknown - axisValue?: unknown } interface BrushArea { @@ -77,49 +85,6 @@ function toNumericValue(value: unknown): number { return Number.NaN } -function resolveStartIndex(categories: number[], value: unknown): number { - if (categories.length === 0) { - return 0 - } - - const numericValue = toNumericValue(value) - if (Number.isNaN(numericValue)) { - return 0 - } - - if (numericValue >= 0 && numericValue <= categories.length - 1) { - return Math.max(0, Math.min(categories.length - 1, Math.floor(numericValue))) - } - - const foundIndex = categories.findIndex((timestamp) => timestamp >= numericValue) - return foundIndex === -1 ? categories.length - 1 : foundIndex -} - -function resolveEndIndex(categories: number[], value: unknown): number { - if (categories.length === 0) { - return 0 - } - - const numericValue = toNumericValue(value) - if (Number.isNaN(numericValue)) { - return categories.length - 1 - } - - if (numericValue >= 0 && numericValue <= categories.length - 1) { - return Math.max(0, Math.min(categories.length - 1, Math.ceil(numericValue))) - } - - for (let index = categories.length - 1; index >= 0; index -= 1) { - const timestamp = categories[index] - - if (timestamp !== undefined && timestamp <= numericValue) { - return index - } - } - - return 0 -} - function formatXAxisLabel(value: number | string): string { const timestamp = Number(value) if (Number.isNaN(timestamp)) { @@ -134,7 +99,6 @@ function formatXAxisLabel(value: number | string): string { } function findLivePoint( - categories: number[], data: SandboxMetricsDataPoint[], now: number = Date.now() ): { x: number; y: number } | null { @@ -142,9 +106,12 @@ function findLivePoint( for (let index = data.length - 1; index >= 0; index -= 1) { const point = data[index] - const timestamp = categories[index] + if (!point) { + continue + } - if (!point || typeof point.y !== 'number' || timestamp === undefined) { + const [timestamp, value] = point + if (typeof value !== 'number' || !Number.isFinite(timestamp)) { continue } @@ -158,7 +125,7 @@ function findLivePoint( return { x: timestamp, - y: point.y, + y: value, } } @@ -218,7 +185,6 @@ function createLiveIndicators( } function SandboxMetricsChart({ - categories, series, className, stacked = false, @@ -262,32 +228,24 @@ function SandboxMetricsChart({ cssVars[SANDBOX_MONITORING_CHART_FONT_MONO_VAR] || SANDBOX_MONITORING_CHART_FALLBACK_FONT_MONO - const handleAxisPointer = useCallback( - (params: AxisPointerEventParams) => { + const handleUpdateAxisPointer = useCallback( + (params: UpdateAxisPointerEventParams) => { if (!onHover) { - return '' - } - - const dataIndex = params.seriesData?.[0]?.dataIndex - if (typeof dataIndex === 'number') { - onHover(dataIndex) - return '' - } - - const pointerValue = params.value ?? params.axisValue - const normalizedValue = toNumericValue(pointerValue) - if (Number.isNaN(normalizedValue)) { - return '' + return } - const matchedIndex = categories.findIndex((value) => value === normalizedValue) - if (matchedIndex >= 0) { - onHover(matchedIndex) + const pointerValue = + params.axesInfo?.[0]?.value ?? + params.xAxisInfo?.[0]?.value ?? + params.value + const timestampMs = toNumericValue(pointerValue) + if (Number.isNaN(timestampMs)) { + return } - return '' + onHover(Math.floor(timestampMs)) }, - [categories, onHover] + [onHover] ) const handleGlobalOut = useCallback(() => { @@ -301,24 +259,24 @@ function SandboxMetricsChart({ return } - const rawStartIndex = resolveStartIndex(categories, coordRange[0]) - const rawEndIndex = resolveEndIndex(categories, coordRange[1]) - const startIndex = Math.min(rawStartIndex, rawEndIndex) - const endIndex = Math.max(rawStartIndex, rawEndIndex) - const startTimestamp = categories[startIndex] - const endTimestamp = categories[endIndex] - - if (startTimestamp !== undefined && endTimestamp !== undefined) { - onBrushEnd(startTimestamp, endTimestamp) + const startTimestamp = toNumericValue(coordRange[0]) + const endTimestamp = toNumericValue(coordRange[1]) + if (Number.isNaN(startTimestamp) || Number.isNaN(endTimestamp)) { + return } + onBrushEnd( + Math.floor(Math.min(startTimestamp, endTimestamp)), + Math.floor(Math.max(startTimestamp, endTimestamp)) + ) + chartInstanceRef.current?.dispatchAction({ type: 'brush', command: 'clear', areas: [], }) }, - [categories, onBrushEnd] + [onBrushEnd] ) const handleChartReady = useCallback((chart: echarts.ECharts) => { @@ -343,7 +301,7 @@ function SandboxMetricsChart({ const option = useMemo(() => { const values = series.flatMap((line) => line.data - .map((point) => point.y) + .map((point) => point[1]) .filter((value): value is number => value !== null) ) const computedYAxisMax = @@ -364,8 +322,8 @@ function SandboxMetricsChart({ ? cssVars[line.areaToColorVar] : undefined const resolvedLineColor = lineColor || stroke - const livePoint = findLivePoint(categories, line.data) - const shouldShowArea = stacked || showArea + const livePoint = findLivePoint(line.data) + const shouldShowArea = line.showArea ?? (stacked || showArea) const areaFillColor = areaFromColor && areaToColor ? { @@ -387,6 +345,7 @@ function SandboxMetricsChart({ id: line.id, name: line.name, type: 'line', + z: line.zIndex, symbol: 'none', showSymbol: false, smooth: false, @@ -404,7 +363,8 @@ function SandboxMetricsChart({ width: SANDBOX_MONITORING_CHART_LINE_WIDTH, color: resolvedLineColor, }, - data: line.data.map((point) => point.y ?? '-'), + connectNulls: false, + data: line.data, } if (livePoint) { @@ -435,9 +395,8 @@ function SandboxMetricsChart({ right: 8, }, xAxis: { - type: 'category', - data: categories, - boundaryGap: false, + type: 'time', + boundaryGap: [0, 0], axisLine: { show: true, lineStyle: { color: stroke } }, axisTick: { show: false }, splitLine: { show: false }, @@ -457,10 +416,9 @@ function SandboxMetricsChart({ type: 'solid', width: SANDBOX_MONITORING_CHART_LINE_WIDTH, }, - snap: false, + snap: true, label: { - backgroundColor: 'transparent', - formatter: handleAxisPointer, + show: false, }, }, }, @@ -489,11 +447,9 @@ function SandboxMetricsChart({ series: seriesItems, } }, [ - categories, cssVars, fgTertiary, fontMono, - handleAxisPointer, series, showXAxisLabels, showArea, @@ -514,6 +470,7 @@ function SandboxMetricsChart({ onEvents={{ globalout: handleGlobalOut, brushEnd: handleBrushEnd, + updateAxisPointer: handleUpdateAxisPointer, }} /> ) diff --git a/src/features/dashboard/sandbox/monitoring/components/monitoring-time-range-controls.tsx b/src/features/dashboard/sandbox/monitoring/components/monitoring-time-range-controls.tsx index 8a6a195c0..2babc953c 100644 --- a/src/features/dashboard/sandbox/monitoring/components/monitoring-time-range-controls.tsx +++ b/src/features/dashboard/sandbox/monitoring/components/monitoring-time-range-controls.tsx @@ -1,5 +1,8 @@ 'use client' +import { millisecondsInHour, millisecondsInMinute } from 'date-fns/constants' +import { ChevronLeft, ChevronRight } from 'lucide-react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { cn } from '@/lib/utils' import { findMatchingPreset } from '@/lib/utils/time-range' import { LiveDot } from '@/ui/live' @@ -10,14 +13,9 @@ import { PopoverTrigger, } from '@/ui/primitives/popover' import { Separator } from '@/ui/primitives/separator' -import { parseTimeRangeValuesToTimestamps } from '@/ui/time-range-picker.logic' import { TimeRangePicker, type TimeRangeValues } from '@/ui/time-range-picker' -import { TimeRangePresets, type TimeRangePreset } from '@/ui/time-range-presets' -import { - millisecondsInHour, - millisecondsInMinute, -} from 'date-fns/constants' -import { useCallback, useEffect, useMemo, useState } from 'react' +import { parseTimeRangeValuesToTimestamps } from '@/ui/time-range-picker.logic' +import { type TimeRangePreset, TimeRangePresets } from '@/ui/time-range-presets' import { SANDBOX_MONITORING_FIRST_15_MINUTES_PRESET_ID, SANDBOX_MONITORING_FIRST_15_MINUTES_PRESET_SHORTCUT, @@ -25,10 +23,10 @@ import { SANDBOX_MONITORING_FIRST_HOUR_PRESET_SHORTCUT, SANDBOX_MONITORING_FULL_LIFECYCLE_PRESET_ID, SANDBOX_MONITORING_FULL_LIFECYCLE_PRESET_SHORTCUT, - SANDBOX_MONITORING_LAST_15_MINUTES_PRESET_ID, - SANDBOX_MONITORING_LAST_15_MINUTES_PRESET_SHORTCUT, SANDBOX_MONITORING_LAST_6_HOURS_PRESET_ID, SANDBOX_MONITORING_LAST_6_HOURS_PRESET_SHORTCUT, + SANDBOX_MONITORING_LAST_15_MINUTES_PRESET_ID, + SANDBOX_MONITORING_LAST_15_MINUTES_PRESET_SHORTCUT, SANDBOX_MONITORING_LAST_HOUR_PRESET_ID, SANDBOX_MONITORING_LAST_HOUR_PRESET_SHORTCUT, SANDBOX_MONITORING_PRESET_MATCH_TOLERANCE_MS, @@ -71,6 +69,32 @@ interface SandboxMonitoringTimeRangeControlsProps { className?: string } +interface TimeRangeHistoryEntry { + start: number + end: number + isLiveUpdating: boolean +} + +interface TimeRangeHistoryState { + entries: TimeRangeHistoryEntry[] + index: number +} + +function isSameHistoryEntry( + a: TimeRangeHistoryEntry | undefined, + b: TimeRangeHistoryEntry +): boolean { + if (!a) { + return false + } + + return ( + a.start === b.start && + a.end === b.end && + a.isLiveUpdating === b.isLiveUpdating + ) +} + export default function SandboxMonitoringTimeRangeControls({ timeframe, lifecycle, @@ -81,6 +105,19 @@ export default function SandboxMonitoringTimeRangeControls({ }: SandboxMonitoringTimeRangeControlsProps) { const [isOpen, setIsOpen] = useState(false) const [pickerMaxDateMs, setPickerMaxDateMs] = useState(() => Date.now()) + const [historyState, setHistoryState] = useState( + () => ({ + entries: [ + { + start: timeframe.start, + end: timeframe.end, + isLiveUpdating, + }, + ], + index: 0, + }) + ) + const isHistoryNavigationRef = useRef(false) const clampToLifecycle = useCallback( (start: number, end: number) => { @@ -88,12 +125,7 @@ export default function SandboxMonitoringTimeRangeControls({ ? Date.now() : lifecycle.anchorEndMs - return clampTimeframeToBounds( - start, - end, - lifecycle.startMs, - maxBoundMs - ) + return clampTimeframeToBounds(start, end, lifecycle.startMs, maxBoundMs) }, [lifecycle.anchorEndMs, lifecycle.isRunning, lifecycle.startMs] ) @@ -228,6 +260,51 @@ export default function SandboxMonitoringTimeRangeControls({ } }, [isOpen, lifecycle.isRunning]) + useEffect(() => { + const snapshot: TimeRangeHistoryEntry = { + start: timeframe.start, + end: timeframe.end, + isLiveUpdating, + } + + setHistoryState((previous) => { + const currentEntry = previous.entries[previous.index] + + if (isSameHistoryEntry(currentEntry, snapshot)) { + isHistoryNavigationRef.current = false + return previous + } + + if (isHistoryNavigationRef.current) { + isHistoryNavigationRef.current = false + const nextEntries = [...previous.entries] + nextEntries[previous.index] = snapshot + return { + entries: nextEntries, + index: previous.index, + } + } + + if (currentEntry?.isLiveUpdating && snapshot.isLiveUpdating) { + return previous + } + + const trimmedEntries = previous.entries.slice(0, previous.index + 1) + const lastEntry = trimmedEntries[trimmedEntries.length - 1] + if (isSameHistoryEntry(lastEntry, snapshot)) { + return { + entries: trimmedEntries, + index: trimmedEntries.length - 1, + } + } + + return { + entries: [...trimmedEntries, snapshot], + index: trimmedEntries.length, + } + }) + }, [isLiveUpdating, timeframe.end, timeframe.start]) + const pickerMaxDate = useMemo( () => lifecycle.isRunning @@ -279,23 +356,48 @@ export default function SandboxMonitoringTimeRangeControls({ } onLiveChange(!isLiveUpdating) - }, [ - isLiveUpdating, - lifecycle.isRunning, - onLiveChange, - ]) + }, [isLiveUpdating, lifecycle.isRunning, onLiveChange]) + + const canGoBackward = historyState.index > 0 + const canGoForward = historyState.index < historyState.entries.length - 1 + + const handleHistoryNavigation = useCallback( + (targetIndex: number) => { + const target = historyState.entries[targetIndex] + if (!target) { + return + } + + isHistoryNavigationRef.current = true + setHistoryState((previous) => ({ + entries: previous.entries, + index: targetIndex, + })) + onTimeRangeChange(target.start, target.end, { + isLiveUpdating: target.isLiveUpdating, + }) + }, + [historyState.entries, onTimeRangeChange] + ) + + const handleGoBackward = useCallback(() => { + if (!canGoBackward) { + return + } + + handleHistoryNavigation(historyState.index - 1) + }, [canGoBackward, handleHistoryNavigation, historyState.index]) + + const handleGoForward = useCallback(() => { + if (!canGoForward) { + return + } + + handleHistoryNavigation(historyState.index + 1) + }, [canGoForward, handleHistoryNavigation, historyState.index]) return ( -
- +
+ +
+ + +
) } diff --git a/src/features/dashboard/sandbox/monitoring/state/use-sandbox-monitoring-controller.ts b/src/features/dashboard/sandbox/monitoring/state/use-sandbox-monitoring-controller.ts index 565d4c052..822b3c0ec 100644 --- a/src/features/dashboard/sandbox/monitoring/state/use-sandbox-monitoring-controller.ts +++ b/src/features/dashboard/sandbox/monitoring/state/use-sandbox-monitoring-controller.ts @@ -1,12 +1,12 @@ 'use client' +import { keepPreviousData, useQuery } from '@tanstack/react-query' +import { usePathname, useRouter, useSearchParams } from 'next/navigation' +import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react' import { useDashboard } from '@/features/dashboard/context' import { useSandboxContext } from '@/features/dashboard/sandbox/context' import type { SandboxMetric } from '@/server/api/models/sandboxes.models' import { useTRPCClient } from '@/trpc/client' -import { keepPreviousData, useQuery } from '@tanstack/react-query' -import { usePathname, useRouter, useSearchParams } from 'next/navigation' -import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react' import { SANDBOX_MONITORING_DEFAULT_RANGE_MS, SANDBOX_MONITORING_LIVE_POLLING_MS, @@ -71,7 +71,9 @@ function toTimeframe(start: number, end: number): SandboxMonitoringTimeframe { } } -function getDefaultTimeframe(now: number = Date.now()): SandboxMonitoringTimeframe { +function getDefaultTimeframe( + now: number = Date.now() +): SandboxMonitoringTimeframe { const normalized = normalizeMonitoringTimeframe({ start: now - SANDBOX_MONITORING_DEFAULT_RANGE_MS, end: now, @@ -195,6 +197,7 @@ export function useSandboxMonitoringController(sandboxId: string) { undefined, createInitialState ) + const stateRef = useRef(state) const durationRef = useRef(state.timeframe.duration) const queryStart = searchParams.get(SANDBOX_MONITORING_QUERY_START_PARAM) @@ -227,6 +230,10 @@ export function useSandboxMonitoringController(sandboxId: string) { }) }, [lifecycleEndAt, lifecycleStartedAt, lifecycleState]) + useEffect(() => { + stateRef.current = state + }, [state]) + const applyTimeframe = useCallback( ( start: number, @@ -235,16 +242,25 @@ export function useSandboxMonitoringController(sandboxId: string) { isLiveUpdating?: boolean } ) => { + const currentState = stateRef.current const now = Date.now() const timeframe = resolveTimeframe(start, end, now, lifecycleBounds) const requestedLiveUpdating = - options?.isLiveUpdating ?? state.isLiveUpdating + options?.isLiveUpdating ?? currentState.isLiveUpdating const nextLiveUpdating = lifecycleBounds?.isRunning ? requestedLiveUpdating : lifecycleBounds ? false : requestedLiveUpdating + if ( + currentState.timeframe.start === timeframe.start && + currentState.timeframe.end === timeframe.end && + currentState.isLiveUpdating === nextLiveUpdating + ) { + return + } + dispatch({ type: 'setTimeframe', payload: { @@ -253,7 +269,7 @@ export function useSandboxMonitoringController(sandboxId: string) { }, }) }, - [lifecycleBounds, state.isLiveUpdating] + [lifecycleBounds] ) const setLiveUpdating = useCallback( @@ -281,15 +297,11 @@ export function useSandboxMonitoringController(sandboxId: string) { ? now : (lifecycleBounds?.anchorEndMs ?? now) - applyTimeframe( - anchorEndMs - state.timeframe.duration, - anchorEndMs, - { - isLiveUpdating: true, - } - ) + applyTimeframe(anchorEndMs - durationRef.current, anchorEndMs, { + isLiveUpdating: true, + }) }, - [applyTimeframe, lifecycleBounds, state.timeframe.duration] + [applyTimeframe, lifecycleBounds] ) useEffect(() => { @@ -298,9 +310,29 @@ export function useSandboxMonitoringController(sandboxId: string) { useEffect(() => { const now = Date.now() + const currentState = stateRef.current + const hasExplicitRange = + queryState.start !== null && queryState.end !== null const requestedLiveUpdating = queryState.live ?? true - const start = queryState.start ?? now - SANDBOX_MONITORING_DEFAULT_RANGE_MS - const end = queryState.end ?? now + const start = hasExplicitRange + ? queryState.start + : currentState.isInitialized && currentState.sandboxId === sandboxId + ? requestedLiveUpdating + ? now - durationRef.current + : currentState.timeframe.start + : now - SANDBOX_MONITORING_DEFAULT_RANGE_MS + const end = hasExplicitRange + ? queryState.end + : currentState.isInitialized && currentState.sandboxId === sandboxId + ? requestedLiveUpdating + ? now + : currentState.timeframe.end + : now + + if (start === null || end === null) { + return + } + const timeframe = resolveTimeframe(start, end, now, lifecycleBounds) dispatch({ @@ -342,7 +374,10 @@ export function useSandboxMonitoringController(sandboxId: string) { }) } - const intervalId = window.setInterval(tick, SANDBOX_MONITORING_LIVE_POLLING_MS) + const intervalId = window.setInterval( + tick, + SANDBOX_MONITORING_LIVE_POLLING_MS + ) return () => { window.clearInterval(intervalId) @@ -359,25 +394,33 @@ export function useSandboxMonitoringController(sandboxId: string) { return } - const nextStart = String(state.timeframe.start) - const nextEnd = String(state.timeframe.end) const nextLive = state.isLiveUpdating ? SANDBOX_MONITORING_QUERY_LIVE_TRUE : SANDBOX_MONITORING_QUERY_LIVE_FALSE + const nextStart = String(state.timeframe.start) + const nextEnd = String(state.timeframe.end) + const shouldPersistExplicitRange = !state.isLiveUpdating if ( - queryStart === nextStart && - queryEnd === nextEnd && - queryLive === nextLive + queryLive === nextLive && + (shouldPersistExplicitRange + ? queryStart === nextStart && queryEnd === nextEnd + : queryStart === null && queryEnd === null) ) { return } const nextParams = new URLSearchParams(searchParamsString) - nextParams.set(SANDBOX_MONITORING_QUERY_START_PARAM, nextStart) - nextParams.set(SANDBOX_MONITORING_QUERY_END_PARAM, nextEnd) nextParams.set(SANDBOX_MONITORING_QUERY_LIVE_PARAM, nextLive) + if (shouldPersistExplicitRange) { + nextParams.set(SANDBOX_MONITORING_QUERY_START_PARAM, nextStart) + nextParams.set(SANDBOX_MONITORING_QUERY_END_PARAM, nextEnd) + } else { + nextParams.delete(SANDBOX_MONITORING_QUERY_START_PARAM) + nextParams.delete(SANDBOX_MONITORING_QUERY_END_PARAM) + } + router.replace(`${pathname}?${nextParams.toString()}`, { scroll: false, }) @@ -395,13 +438,14 @@ export function useSandboxMonitoringController(sandboxId: string) { ]) const queryKey = useMemo( - () => [ - 'sandboxMonitoringMetrics', - team?.id ?? '', - sandboxId, - state.timeframe.start, - state.timeframe.end, - ] as const, + () => + [ + 'sandboxMonitoringMetrics', + team?.id ?? '', + sandboxId, + state.timeframe.start, + state.timeframe.end, + ] as const, [sandboxId, state.timeframe.end, state.timeframe.start, team?.id] ) @@ -409,6 +453,9 @@ export function useSandboxMonitoringController(sandboxId: string) { queryKey, enabled: state.isInitialized && Boolean(team?.id), placeholderData: keepPreviousData, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + staleTime: SANDBOX_MONITORING_LIVE_POLLING_MS, queryFn: async () => { if (!team?.id) { return [] @@ -434,6 +481,7 @@ export function useSandboxMonitoringController(sandboxId: string) { timeframe: state.timeframe, metrics: metricsQuery.data ?? [], isLiveUpdating: state.isLiveUpdating, + isRefetching: metricsQuery.isFetching, isLoading: metricsQuery.isLoading || metricsQuery.isFetching, error, setTimeframe: applyTimeframe, diff --git a/src/features/dashboard/sandbox/monitoring/types/sandbox-metrics-chart.ts b/src/features/dashboard/sandbox/monitoring/types/sandbox-metrics-chart.ts index e0649f9fc..3893afaac 100644 --- a/src/features/dashboard/sandbox/monitoring/types/sandbox-metrics-chart.ts +++ b/src/features/dashboard/sandbox/monitoring/types/sandbox-metrics-chart.ts @@ -1,9 +1,9 @@ import type { SandboxMetric } from '@/server/api/models/sandboxes.models' -export type SandboxMetricsDataPoint = { - x: number - y: number | null -} +export type SandboxMetricsDataPoint = [ + timestampMs: number, + value: number | null, +] export interface SandboxMetricsSeries { id: string @@ -12,10 +12,11 @@ export interface SandboxMetricsSeries { lineColorVar?: string areaColorVar?: string areaToColorVar?: string + showArea?: boolean + zIndex?: number } export interface SandboxMetricsChartProps { - categories: number[] series: SandboxMetricsSeries[] className?: string stacked?: boolean @@ -23,7 +24,7 @@ export interface SandboxMetricsChartProps { showXAxisLabels?: boolean yAxisMax?: number yAxisFormatter?: (value: number) => string - onHover?: (index: number) => void + onHover?: (timestampMs: number) => void onHoverEnd?: () => void onBrushEnd?: (startTimestamp: number, endTimestamp: number) => void } @@ -40,7 +41,6 @@ export interface MonitoringDiskHoveredContext { } export interface MonitoringChartModel { - categories: number[] latestMetric: SandboxMetric | undefined resourceSeries: SandboxMetricsSeries[] diskSeries: SandboxMetricsSeries[] diff --git a/src/features/dashboard/sandbox/monitoring/utils/chart-model.ts b/src/features/dashboard/sandbox/monitoring/utils/chart-model.ts index 71fc81d50..e4b6a1490 100644 --- a/src/features/dashboard/sandbox/monitoring/utils/chart-model.ts +++ b/src/features/dashboard/sandbox/monitoring/utils/chart-model.ts @@ -1,27 +1,42 @@ -import type { SandboxMetric } from '@/server/api/models/sandboxes.models' import { millisecondsInSecond } from 'date-fns/constants' -import type { MonitoringChartModel } from '../types/sandbox-metrics-chart' +import type { SandboxMetric } from '@/server/api/models/sandboxes.models' +import type { + MonitoringChartModel, + SandboxMetricsDataPoint, +} from '../types/sandbox-metrics-chart' import { - SANDBOX_MONITORING_CHART_MAX_POINTS, - SANDBOX_MONITORING_CHART_MIN_STEP_MS, SANDBOX_MONITORING_CPU_AREA_COLOR_VAR, + SANDBOX_MONITORING_CPU_AREA_TO_COLOR_VAR, + SANDBOX_MONITORING_CPU_LINE_COLOR_VAR, SANDBOX_MONITORING_CPU_SERIES_ID, SANDBOX_MONITORING_CPU_SERIES_LABEL, - SANDBOX_MONITORING_CPU_LINE_COLOR_VAR, - SANDBOX_MONITORING_CPU_AREA_TO_COLOR_VAR, - SANDBOX_MONITORING_DISK_SERIES_ID, - SANDBOX_MONITORING_DISK_SERIES_LABEL, - SANDBOX_MONITORING_DISK_LINE_COLOR_VAR, SANDBOX_MONITORING_DISK_AREA_COLOR_VAR, SANDBOX_MONITORING_DISK_AREA_TO_COLOR_VAR, + SANDBOX_MONITORING_DISK_LINE_COLOR_VAR, + SANDBOX_MONITORING_DISK_SERIES_ID, + SANDBOX_MONITORING_DISK_SERIES_LABEL, SANDBOX_MONITORING_PERCENT_MAX, SANDBOX_MONITORING_RAM_AREA_COLOR_VAR, + SANDBOX_MONITORING_RAM_AREA_TO_COLOR_VAR, + SANDBOX_MONITORING_RAM_LINE_COLOR_VAR, SANDBOX_MONITORING_RAM_SERIES_ID, SANDBOX_MONITORING_RAM_SERIES_LABEL, - SANDBOX_MONITORING_RAM_LINE_COLOR_VAR, - SANDBOX_MONITORING_RAM_AREA_TO_COLOR_VAR, } from './constants' -import { calculateStepForRange } from './timeframe' + +interface NormalizedSandboxMetric { + metric: SandboxMetric + timestampMs: number + cpuPercent: number + ramPercent: number + diskPercent: number +} + +interface BuildMonitoringChartModelOptions { + metrics: SandboxMetric[] + startMs: number + endMs: number + hoveredTimestampMs: number | null +} function clampPercent(value: number): number { if (!Number.isFinite(value)) { @@ -39,64 +54,93 @@ function toPercent(used: number, total: number): number { return clampPercent((used / total) * 100) } -function getMetricTimestampMs(metric: SandboxMetric): number { - return Math.floor(metric.timestampUnix * millisecondsInSecond) +function getMetricTimestampMs(metric: SandboxMetric): number | null { + const timestampMs = Math.floor(metric.timestampUnix * millisecondsInSecond) + + if (!Number.isFinite(timestampMs)) { + return null + } + + return timestampMs } -function filterSandboxMetricsByTimeRange( - metrics: SandboxMetric[], - startMs: number, - endMs: number -): SandboxMetric[] { - return metrics.filter((metric) => { - const timestampMs = getMetricTimestampMs(metric) - return timestampMs >= startMs && timestampMs <= endMs - }) +function normalizeMetric( + metric: SandboxMetric +): NormalizedSandboxMetric | null { + const timestampMs = getMetricTimestampMs(metric) + if (timestampMs === null) { + return null + } + + return { + metric, + timestampMs, + cpuPercent: clampPercent(metric.cpuUsedPct), + ramPercent: toPercent(metric.memUsed, metric.memTotal), + diskPercent: toPercent(metric.diskUsed, metric.diskTotal), + } } -function sortSandboxMetricsByTime(metrics: SandboxMetric[]): SandboxMetric[] { - return [...metrics].sort( - (a, b) => getMetricTimestampMs(a) - getMetricTimestampMs(b) - ) +function sortMetricsByTimestamp( + metrics: NormalizedSandboxMetric[] +): NormalizedSandboxMetric[] { + return [...metrics].sort((a, b) => a.timestampMs - b.timestampMs) +} + +function buildSeriesData( + metrics: NormalizedSandboxMetric[], + getValue: (metric: NormalizedSandboxMetric) => number +): SandboxMetricsDataPoint[] { + return metrics.map((metric) => [metric.timestampMs, getValue(metric)]) } -function createSeriesData( - metrics: SandboxMetric[], - categories: number[], - getValue: (metric: SandboxMetric) => number -) { - const sums = Array(categories.length).fill(0) - const counts = Array(categories.length).fill(0) - if (categories.length === 0) { - return [] +function findClosestMetric( + metrics: NormalizedSandboxMetric[], + timestampMs: number +): NormalizedSandboxMetric | null { + if (metrics.length === 0 || !Number.isFinite(timestampMs)) { + return null } - const start = categories[0]! - const end = categories[categories.length - 1]! - const step = categories.length > 1 ? categories[1]! - categories[0]! : 1 + let low = 0 + let high = metrics.length - 1 - for (const metric of metrics) { - const timestampMs = getMetricTimestampMs(metric) - if (timestampMs < start || timestampMs > end) { - continue + while (low <= high) { + const middle = Math.floor((low + high) / 2) + const middleTimestamp = metrics[middle]?.timestampMs + if (middleTimestamp === undefined) { + break } - const index = Math.max( - 0, - Math.min(categories.length - 1, Math.round((timestampMs - start) / step)) - ) + if (middleTimestamp === timestampMs) { + return metrics[middle] ?? null + } - sums[index] = (sums[index] ?? 0) + getValue(metric) - counts[index] = (counts[index] ?? 0) + 1 + if (middleTimestamp < timestampMs) { + low = middle + 1 + } else { + high = middle - 1 + } } - return categories.map((x, idx) => ({ - x, - y: counts[idx]! > 0 ? sums[idx]! / counts[idx]! : null, - })) + const nextMetric = metrics[low] + const previousMetric = metrics[low - 1] + + if (!nextMetric) { + return previousMetric ?? null + } + + if (!previousMetric) { + return nextMetric + } + + const nextDistance = Math.abs(nextMetric.timestampMs - timestampMs) + const previousDistance = Math.abs(previousMetric.timestampMs - timestampMs) + + return previousDistance <= nextDistance ? previousMetric : nextMetric } -function buildResourceSeries(metrics: SandboxMetric[], categories: number[]) { +function buildResourceSeries(metrics: NormalizedSandboxMetric[]) { return [ { id: SANDBOX_MONITORING_CPU_SERIES_ID, @@ -104,9 +148,9 @@ function buildResourceSeries(metrics: SandboxMetric[], categories: number[]) { lineColorVar: SANDBOX_MONITORING_CPU_LINE_COLOR_VAR, areaColorVar: SANDBOX_MONITORING_CPU_AREA_COLOR_VAR, areaToColorVar: SANDBOX_MONITORING_CPU_AREA_TO_COLOR_VAR, - data: createSeriesData(metrics, categories, (metric) => - clampPercent(metric.cpuUsedPct) - ), + showArea: false, + zIndex: 2, + data: buildSeriesData(metrics, (metric) => metric.cpuPercent), }, { id: SANDBOX_MONITORING_RAM_SERIES_ID, @@ -114,14 +158,14 @@ function buildResourceSeries(metrics: SandboxMetric[], categories: number[]) { lineColorVar: SANDBOX_MONITORING_RAM_LINE_COLOR_VAR, areaColorVar: SANDBOX_MONITORING_RAM_AREA_COLOR_VAR, areaToColorVar: SANDBOX_MONITORING_RAM_AREA_TO_COLOR_VAR, - data: createSeriesData(metrics, categories, (metric) => - toPercent(metric.memUsed, metric.memTotal) - ), + showArea: false, + zIndex: 1, + data: buildSeriesData(metrics, (metric) => metric.ramPercent), }, ] } -function buildDiskSeries(metrics: SandboxMetric[], categories: number[]) { +function buildDiskSeries(metrics: NormalizedSandboxMetric[]) { return [ { id: SANDBOX_MONITORING_DISK_SERIES_ID, @@ -129,80 +173,40 @@ function buildDiskSeries(metrics: SandboxMetric[], categories: number[]) { lineColorVar: SANDBOX_MONITORING_DISK_LINE_COLOR_VAR, areaColorVar: SANDBOX_MONITORING_DISK_AREA_COLOR_VAR, areaToColorVar: SANDBOX_MONITORING_DISK_AREA_TO_COLOR_VAR, - data: createSeriesData(metrics, categories, (metric) => - toPercent(metric.diskUsed, metric.diskTotal) - ), + data: buildSeriesData(metrics, (metric) => metric.diskPercent), }, ] } -interface BuildMonitoringChartModelOptions { - metrics: SandboxMetric[] - startMs: number - endMs: number - hoveredIndex: number | null -} - -export function buildTimelineCategories(startMs: number, endMs: number): number[] { - if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) { - return [] - } - - const normalizedStart = Math.floor(startMs) - const normalizedEnd = Math.floor(endMs) - if (normalizedEnd <= normalizedStart) { - return [normalizedStart] - } - - const baseStepMs = Math.max( - SANDBOX_MONITORING_CHART_MIN_STEP_MS, - Math.floor( - calculateStepForRange(normalizedStart, normalizedEnd) / - millisecondsInSecond - ) * millisecondsInSecond - ) - - const duration = normalizedEnd - normalizedStart - const estimatedPoints = Math.floor(duration / baseStepMs) + 1 - const needsCapping = estimatedPoints > SANDBOX_MONITORING_CHART_MAX_POINTS - const stepMs = needsCapping - ? Math.max( - baseStepMs, - Math.ceil( - duration / (SANDBOX_MONITORING_CHART_MAX_POINTS - 1) / - millisecondsInSecond - ) * millisecondsInSecond - ) - : baseStepMs - - const categories: number[] = [] - for (let timestamp = normalizedStart; timestamp <= normalizedEnd; timestamp += stepMs) { - categories.push(timestamp) - } - - if (categories[categories.length - 1] !== normalizedEnd) { - categories.push(normalizedEnd) - } - - return categories -} - export function buildMonitoringChartModel({ metrics, startMs, endMs, - hoveredIndex, + hoveredTimestampMs, }: BuildMonitoringChartModelOptions): MonitoringChartModel { - const constrainedMetrics = filterSandboxMetricsByTimeRange(metrics, startMs, endMs) - const sortedMetrics = sortSandboxMetricsByTime(constrainedMetrics) - const categories = buildTimelineCategories(startMs, endMs) - const resourceSeries = buildResourceSeries(sortedMetrics, categories) - const diskSeries = buildDiskSeries(sortedMetrics, categories) - const latestMetric = sortedMetrics[sortedMetrics.length - 1] - - if (hoveredIndex === null) { + const rangeStart = Math.min(startMs, endMs) + const rangeEnd = Math.max(startMs, endMs) + + const normalizedMetrics = sortMetricsByTimestamp( + metrics + .map(normalizeMetric) + .filter((metric): metric is NormalizedSandboxMetric => { + if (!metric) { + return false + } + + return ( + metric.timestampMs >= rangeStart && metric.timestampMs <= rangeEnd + ) + }) + ) + + const resourceSeries = buildResourceSeries(normalizedMetrics) + const diskSeries = buildDiskSeries(normalizedMetrics) + const latestMetric = normalizedMetrics[normalizedMetrics.length - 1]?.metric + + if (hoveredTimestampMs === null) { return { - categories, latestMetric, resourceSeries, diskSeries, @@ -211,10 +215,9 @@ export function buildMonitoringChartModel({ } } - const hoveredTimestamp = categories[hoveredIndex] - if (hoveredTimestamp === undefined) { + const hoveredMetric = findClosestMetric(normalizedMetrics, hoveredTimestampMs) + if (!hoveredMetric) { return { - categories, latestMetric, resourceSeries, diskSeries, @@ -223,29 +226,18 @@ export function buildMonitoringChartModel({ } } - const cpuSeries = resourceSeries.find( - (series) => series.id === SANDBOX_MONITORING_CPU_SERIES_ID - ) - const ramSeries = resourceSeries.find( - (series) => series.id === SANDBOX_MONITORING_RAM_SERIES_ID - ) - const diskSeriesItem = diskSeries.find( - (series) => series.id === SANDBOX_MONITORING_DISK_SERIES_ID - ) - return { - categories, latestMetric, resourceSeries, diskSeries, resourceHoveredContext: { - cpuPercent: cpuSeries?.data[hoveredIndex]?.y ?? null, - ramPercent: ramSeries?.data[hoveredIndex]?.y ?? null, - timestampMs: hoveredTimestamp, + cpuPercent: hoveredMetric.cpuPercent, + ramPercent: hoveredMetric.ramPercent, + timestampMs: hoveredMetric.timestampMs, }, diskHoveredContext: { - diskPercent: diskSeriesItem?.data[hoveredIndex]?.y ?? null, - timestampMs: hoveredTimestamp, + diskPercent: hoveredMetric.diskPercent, + timestampMs: hoveredMetric.timestampMs, }, } } diff --git a/src/features/dashboard/sandbox/monitoring/utils/constants.ts b/src/features/dashboard/sandbox/monitoring/utils/constants.ts index 422be26b1..eea2a105f 100644 --- a/src/features/dashboard/sandbox/monitoring/utils/constants.ts +++ b/src/features/dashboard/sandbox/monitoring/utils/constants.ts @@ -6,7 +6,7 @@ import { } from 'date-fns/constants' export const SANDBOX_MONITORING_DEFAULT_RANGE_MS = millisecondsInHour -export const SANDBOX_MONITORING_MIN_RANGE_MS = 90 * millisecondsInSecond +export const SANDBOX_MONITORING_MIN_RANGE_MS = 10 * millisecondsInSecond export const SANDBOX_MONITORING_MAX_RANGE_MS = 31 * millisecondsInDay export const SANDBOX_MONITORING_LIVE_POLLING_MS = 10_000 export const SANDBOX_MONITORING_MIN_TIMESTAMP_MS = -8_640_000_000_000_000 @@ -75,8 +75,6 @@ export const SANDBOX_MONITORING_CHART_AREA_OPACITY = 0.18 export const SANDBOX_MONITORING_CHART_OUT_OF_BRUSH_ALPHA = 0.25 export const SANDBOX_MONITORING_CHART_AXIS_LABEL_FONT_SIZE = 12 export const SANDBOX_MONITORING_CHART_Y_AXIS_SCALE_FACTOR = 1.5 -export const SANDBOX_MONITORING_CHART_MIN_STEP_MS = millisecondsInSecond -export const SANDBOX_MONITORING_CHART_MAX_POINTS = 2_000 export const SANDBOX_MONITORING_CHART_LIVE_WINDOW_MS = 2 * millisecondsInMinute export const SANDBOX_MONITORING_CHART_LIVE_OUTER_DOT_SIZE = 16 export const SANDBOX_MONITORING_CHART_LIVE_MIDDLE_DOT_SIZE = 10 diff --git a/src/features/dashboard/sandbox/monitoring/utils/timeframe.ts b/src/features/dashboard/sandbox/monitoring/utils/timeframe.ts index 80b09ed2c..3c82b153e 100644 --- a/src/features/dashboard/sandbox/monitoring/utils/timeframe.ts +++ b/src/features/dashboard/sandbox/monitoring/utils/timeframe.ts @@ -1,10 +1,4 @@ import type { SandboxDetailsDTO } from '@/server/api/models/sandboxes.models' -import { - millisecondsInDay, - millisecondsInHour, - millisecondsInMinute, - millisecondsInSecond, -} from 'date-fns/constants' import { SANDBOX_MONITORING_DEFAULT_RANGE_MS, SANDBOX_MONITORING_MAX_RANGE_MS, @@ -163,28 +157,6 @@ export function normalizeMonitoringTimeframe({ } } -export function calculateStepForRange(startMs: number, endMs: number): number { - const duration = endMs - startMs - return calculateStepForDuration(duration) -} - -export function calculateStepForDuration(durationMs: number): number { - switch (true) { - case durationMs < millisecondsInHour: - return 5 * millisecondsInSecond - case durationMs < 6 * millisecondsInHour: - return 30 * millisecondsInSecond - case durationMs < 12 * millisecondsInHour: - return millisecondsInMinute - case durationMs < 24 * millisecondsInHour: - return 2 * millisecondsInMinute - case durationMs < 7 * millisecondsInDay: - return 5 * millisecondsInMinute - default: - return 15 * millisecondsInMinute - } -} - export interface SandboxLifecycleBounds { startMs: number anchorEndMs: number diff --git a/src/styles/theme.css b/src/styles/theme.css index 4a984c0d7..2058328ae 100644 --- a/src/styles/theme.css +++ b/src/styles/theme.css @@ -92,7 +92,7 @@ --accent-secondary-error-highlight: #ff8763; --accent-secondary-error-bg: rgb(255, 135, 99, 0.16); - --graph-1: #f3cfb0; + --graph-1: #3b1d05; --graph-2: #eea064; --graph-3: #d5751c; --graph-4: #c09bb8; /* Pastel purple-peach */ @@ -116,7 +116,7 @@ --graph-area-fg-from: rgba(108, 108, 108, 0.2); --graph-area-fg-to: rgba(250, 250, 250, 0.2); - --graph-area-1-from: rgba(243, 207, 176, 0.32); /* Peachy area gradient */ + --graph-area-1-from: rgba(59, 29, 5, 0.26); /* Dark orange area gradient */ --graph-area-1-to: rgba(250, 250, 250, 0.16); --graph-area-2-from: rgba(238, 160, 100, 0.26); /* Orange area gradient */ From 130522d211bf6be922d9bf824ac2d58d1e57beb3 Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Tue, 3 Mar 2026 16:59:22 -0800 Subject: [PATCH 39/54] feat: enhance monitoring charts with marker value formatters - Added marker value formatters for CPU, RAM, and Disk series in the SandboxMetricsCharts component. - Introduced functions to format percentage and usage markers for better visualization. - Updated the resource and disk series to utilize the new marker formatters, improving the clarity of displayed metrics. - Adjusted layout properties for monitoring chart sections to enhance UI responsiveness. --- .../sandbox/monitoring/components/monitoring-charts.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/dashboard/sandbox/monitoring/components/monitoring-charts.tsx b/src/features/dashboard/sandbox/monitoring/components/monitoring-charts.tsx index 573789b96..778e6f7fa 100644 --- a/src/features/dashboard/sandbox/monitoring/components/monitoring-charts.tsx +++ b/src/features/dashboard/sandbox/monitoring/components/monitoring-charts.tsx @@ -114,7 +114,7 @@ export default function SandboxMetricsCharts({ ) : null} Date: Tue, 3 Mar 2026 16:59:50 -0800 Subject: [PATCH 40/54] feat: add chart tooltips --- .../sandbox-monitoring-chart-model.test.ts | 22 +- .../components/monitoring-charts.tsx | 92 +++- .../monitoring-resource-chart-header.tsx | 2 +- .../monitoring-sandbox-metrics-chart.tsx | 420 +++++++++++++++++- .../monitoring-time-range-controls.tsx | 38 +- .../monitoring/components/monitoring-view.tsx | 10 +- .../monitoring/types/sandbox-metrics-chart.ts | 14 + .../sandbox/monitoring/utils/chart-model.ts | 49 +- .../sandbox/monitoring/utils/constants.ts | 6 +- 9 files changed, 593 insertions(+), 60 deletions(-) diff --git a/src/__test__/unit/sandbox-monitoring-chart-model.test.ts b/src/__test__/unit/sandbox-monitoring-chart-model.test.ts index e580d4e91..3955ce9df 100644 --- a/src/__test__/unit/sandbox-monitoring-chart-model.test.ts +++ b/src/__test__/unit/sandbox-monitoring-chart-model.test.ts @@ -49,19 +49,19 @@ describe('buildMonitoringChartModel', () => { expect(result.resourceSeries).toHaveLength(2) expect(result.diskSeries).toHaveLength(1) expect(result.resourceSeries[0]?.data).toEqual([ - [0, 10], - [5_000, 20], - [10_000, 30], + [0, 10, null], + [5_000, 20, null], + [10_000, 30, null], ]) expect(result.resourceSeries[1]?.data).toEqual([ - [0, 10], - [5_000, 20], - [10_000, 30], + [0, 10, 0], + [5_000, 20, 0], + [10_000, 30, 0], ]) expect(result.diskSeries[0]?.data).toEqual([ - [0, 10], - [5_000, 20], - [10_000, 30], + [0, 10, 0], + [5_000, 20, 0], + [10_000, 30, 0], ]) expect(result.resourceHoveredContext).toEqual({ cpuPercent: 20, @@ -119,7 +119,7 @@ describe('buildMonitoringChartModel', () => { }) expect(result.latestMetric?.timestampUnix).toBe(1) - expect(result.resourceSeries[0]?.data).toEqual([[1_000, 5]]) - expect(result.diskSeries[0]?.data).toEqual([[1_000, 5]]) + expect(result.resourceSeries[0]?.data).toEqual([[1_000, 5, null]]) + expect(result.diskSeries[0]?.data).toEqual([[1_000, 5, 0]]) }) }) diff --git a/src/features/dashboard/sandbox/monitoring/components/monitoring-charts.tsx b/src/features/dashboard/sandbox/monitoring/components/monitoring-charts.tsx index 778e6f7fa..3fcd0ee08 100644 --- a/src/features/dashboard/sandbox/monitoring/components/monitoring-charts.tsx +++ b/src/features/dashboard/sandbox/monitoring/components/monitoring-charts.tsx @@ -3,8 +3,14 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { cn } from '@/lib/utils' import { useSandboxMonitoringController } from '../state/use-sandbox-monitoring-controller' +import type { SandboxMetricsMarkerValueFormatterInput } from '../types/sandbox-metrics-chart' import { buildMonitoringChartModel } from '../utils/chart-model' -import { SANDBOX_MONITORING_PERCENT_MAX } from '../utils/constants' +import { + SANDBOX_MONITORING_CPU_SERIES_ID, + SANDBOX_MONITORING_DISK_SERIES_ID, + SANDBOX_MONITORING_PERCENT_MAX, + SANDBOX_MONITORING_RAM_SERIES_ID, +} from '../utils/constants' import MonitoringChartSection from './monitoring-chart-section' import DiskChartHeader from './monitoring-disk-chart-header' import ResourceChartHeader from './monitoring-resource-chart-header' @@ -15,6 +21,34 @@ interface SandboxMetricsChartsProps { sandboxId: string } +function formatMarkerPercent(value: number) { + return Math.round(value) +} + +function renderPercentMarker(value: number) { + return ( + <> + {formatMarkerPercent(value)} + % + + ) +} + +function renderUsageMarker(usedMb: number | null, value: number) { + const normalizedUsedMb = + usedMb === null || !Number.isFinite(usedMb) ? 0 : Math.round(usedMb) + + return ( + <> + {normalizedUsedMb} + MB + · + {formatMarkerPercent(value)} + % + + ) +} + export default function SandboxMetricsCharts({ sandboxId, }: SandboxMetricsChartsProps) { @@ -74,6 +108,52 @@ export default function SandboxMetricsCharts({ renderedTimeframe.start, ] ) + const resourceSeriesWithMarkerFormatters = useMemo( + () => + chartModel.resourceSeries.map((line) => { + if (line.id === SANDBOX_MONITORING_CPU_SERIES_ID) { + return { + ...line, + markerValueFormatter: ({ + value, + }: SandboxMetricsMarkerValueFormatterInput) => + renderPercentMarker(value), + } + } + + if (line.id === SANDBOX_MONITORING_RAM_SERIES_ID) { + return { + ...line, + markerValueFormatter: ({ + markerValue, + value, + }: SandboxMetricsMarkerValueFormatterInput) => + renderUsageMarker(markerValue, value), + } + } + + return line + }), + [chartModel.resourceSeries] + ) + const diskSeriesWithMarkerFormatters = useMemo( + () => + chartModel.diskSeries.map((line) => { + if (line.id !== SANDBOX_MONITORING_DISK_SERIES_ID) { + return line + } + + return { + ...line, + markerValueFormatter: ({ + markerValue, + value, + }: SandboxMetricsMarkerValueFormatterInput) => + renderUsageMarker(markerValue, value), + } + }), + [chartModel.diskSeries] + ) useEffect(() => { if (isRefetching) { @@ -114,7 +194,7 @@ export default function SandboxMetricsCharts({ ) : null} +
`${char}${char}`) + .join('') + : value + + if (expanded.length !== 6 && expanded.length !== 8) { + return color + } + + const r = Number.parseInt(expanded.slice(0, 2), 16) + const g = Number.parseInt(expanded.slice(2, 4), 16) + const b = Number.parseInt(expanded.slice(4, 6), 16) + + if ([r, g, b].some((value) => Number.isNaN(value))) { + return color + } + + return `rgba(${r}, ${g}, ${b}, ${normalizedOpacity})` +} + +function normalizeOpacity( + opacity: number | undefined, + fallback: number +): number { + if (opacity === undefined || !Number.isFinite(opacity)) { + return fallback + } + + return Math.max(0, Math.min(1, opacity)) +} + function toNumericValue(value: unknown): number { if (typeof value === 'number') { return value @@ -98,6 +164,20 @@ function formatXAxisLabel(value: number | string): string { return `${hours}:${minutes}` } +function formatXAxisHoverLabel(value: number | string): string { + const timestamp = Number(value) + if (Number.isNaN(timestamp)) { + return '' + } + + const date = new Date(timestamp) + const hours = date.getHours().toString().padStart(2, '0') + const minutes = date.getMinutes().toString().padStart(2, '0') + const seconds = date.getSeconds().toString().padStart(2, '0') + + return `${hours}:${minutes}:${seconds}` +} + function findLivePoint( data: SandboxMetricsDataPoint[], now: number = Date.now() @@ -132,6 +212,108 @@ function findLivePoint( return null } +function findClosestValidPoint( + points: SandboxMetricsDataPoint[], + targetTimestampMs: number +): { timestampMs: number; value: number; markerValue: number | null } | null { + let closestPoint: { + timestampMs: number + value: number + markerValue: number | null + } | null = null + let closestDistance = Number.POSITIVE_INFINITY + + for (const point of points) { + if (!point) { + continue + } + + const [timestampMs, value, markerValue] = point + if (value === null || !Number.isFinite(timestampMs)) { + continue + } + + const distance = Math.abs(timestampMs - targetTimestampMs) + if (distance >= closestDistance) { + continue + } + + closestDistance = distance + closestPoint = { + timestampMs, + value, + markerValue: markerValue ?? null, + } + } + + return closestPoint +} + +function findFirstValidPointTimestampMs( + points: SandboxMetricsDataPoint[] +): number | null { + for (const point of points) { + if (!point) { + continue + } + + const [timestampMs, value] = point + if (value === null || !Number.isFinite(timestampMs)) { + continue + } + + return timestampMs + } + + return null +} + +function applyMarkerLabelOffsets( + markers: CrosshairMarker[] +): CrosshairMarker[] { + if (markers.length < 2) { + return markers + } + + const sortedMarkers = [...markers].sort((a, b) => a.yPx - b.yPx) + const offsetsByMarkerKey = new Map() + let clusterStart = 0 + + for (let index = 1; index <= sortedMarkers.length; index += 1) { + const previousMarker = sortedMarkers[index - 1] + const currentMarker = sortedMarkers[index] + if (!previousMarker) { + continue + } + + const shouldSplitCluster = + !currentMarker || + Math.abs(currentMarker.yPx - previousMarker.yPx) > + SANDBOX_MONITORING_CHART_MARKER_OVERLAP_THRESHOLD_PX + + if (!shouldSplitCluster) { + continue + } + + const cluster = sortedMarkers.slice(clusterStart, index) + const halfIndex = (cluster.length - 1) / 2 + + cluster.forEach((marker, clusterIndex) => { + const offset = + (clusterIndex - halfIndex) * + SANDBOX_MONITORING_CHART_MARKER_LABEL_VERTICAL_GAP_PX + offsetsByMarkerKey.set(marker.key, offset) + }) + + clusterStart = index + } + + return markers.map((marker) => ({ + ...marker, + labelOffsetYPx: offsetsByMarkerKey.get(marker.key) ?? marker.labelOffsetYPx, + })) +} + function createLiveIndicators( point: { x: number; y: number }, lineColor: string @@ -186,9 +368,11 @@ function createLiveIndicators( function SandboxMetricsChart({ series, + hoveredTimestampMs = null, className, stacked = false, showArea = false, + showCrosshairBadges = true, showXAxisLabels = true, yAxisMax, yAxisFormatter = formatAxisNumber, @@ -209,6 +393,7 @@ function SandboxMetricsChart({ return Array.from( new Set([ SANDBOX_MONITORING_CHART_STROKE_VAR, + SANDBOX_MONITORING_CHART_FG_VAR, SANDBOX_MONITORING_CHART_FG_TERTIARY_VAR, SANDBOX_MONITORING_CHART_FONT_MONO_VAR, ...dynamicVarNames, @@ -224,6 +409,8 @@ function SandboxMetricsChart({ const fgTertiary = cssVars[SANDBOX_MONITORING_CHART_FG_TERTIARY_VAR] || SANDBOX_MONITORING_CHART_FALLBACK_FG_TERTIARY + const fg = cssVars[SANDBOX_MONITORING_CHART_FG_VAR] || stroke + const axisPointerColor = withOpacity(fg, 0.7) const fontMono = cssVars[SANDBOX_MONITORING_CHART_FONT_MONO_VAR] || SANDBOX_MONITORING_CHART_FALLBACK_FONT_MONO @@ -243,14 +430,32 @@ function SandboxMetricsChart({ return } - onHover(Math.floor(timestampMs)) + const normalizedTimestampMs = Math.floor(timestampMs) + onHover(normalizedTimestampMs) }, [onHover] ) - const handleGlobalOut = useCallback(() => { + const clearAxisPointer = useCallback(() => { + chartInstanceRef.current?.dispatchAction({ type: 'hideTip' }) + chartInstanceRef.current?.dispatchAction({ + type: 'updateAxisPointer', + currTrigger: 'leave', + }) + }, []) + + const handleHoverLeave = useCallback(() => { + clearAxisPointer() onHoverEnd?.() - }, [onHoverEnd]) + }, [clearAxisPointer, onHoverEnd]) + + useEffect(() => { + if (hoveredTimestampMs !== null) { + return + } + + clearAxisPointer() + }, [clearAxisPointer, hoveredTimestampMs]) const handleBrushEnd = useCallback( (params: BrushEndEventParams) => { @@ -338,8 +543,9 @@ function SandboxMetricsChart({ ], } : areaFromColor || resolvedLineColor - const areaOpacity = + const defaultAreaOpacity = areaFromColor || areaToColor ? 1 : SANDBOX_MONITORING_CHART_AREA_OPACITY + const areaOpacity = normalizeOpacity(line.areaOpacity, defaultAreaOpacity) const seriesItem: SeriesOption = { id: line.id, @@ -389,7 +595,7 @@ function SandboxMetricsChart({ outOfBrush: { colorAlpha: SANDBOX_MONITORING_CHART_OUT_OF_BRUSH_ALPHA }, }, grid: { - top: 10, + top: 12, bottom: showXAxisLabels ? 24 : 10, left: 36, right: 8, @@ -412,7 +618,7 @@ function SandboxMetricsChart({ show: true, type: 'line', lineStyle: { - color: stroke, + color: axisPointerColor, type: 'solid', width: SANDBOX_MONITORING_CHART_LINE_WIDTH, }, @@ -448,6 +654,7 @@ function SandboxMetricsChart({ } }, [ cssVars, + axisPointerColor, fgTertiary, fontMono, series, @@ -459,20 +666,193 @@ function SandboxMetricsChart({ yAxisMax, ]) + const crosshairMarkers = useMemo(() => { + if (!showCrosshairBadges || hoveredTimestampMs === null) { + return [] + } + + const chart = chartInstanceRef.current + if (!chart) { + return [] + } + + const firstTimestamps = series + .map((line) => findFirstValidPointTimestampMs(line.data)) + .filter((value): value is number => value !== null) + const firstTimestampMs = + firstTimestamps.length > 0 ? Math.min(...firstTimestamps) : null + const firstPointPixel = + firstTimestampMs !== null + ? chart.convertToPixel({ xAxisIndex: 0, yAxisIndex: 0 }, [ + firstTimestampMs, + 0, + ]) + : null + const firstPointPx = + Array.isArray(firstPointPixel) && + firstPointPixel.length > 0 && + typeof firstPointPixel[0] === 'number' && + Number.isFinite(firstPointPixel[0]) + ? firstPointPixel[0] + : null + + const markers = series.flatMap((line) => { + const closestPoint = findClosestValidPoint(line.data, hoveredTimestampMs) + if (!closestPoint) { + return [] + } + + const pixel = chart.convertToPixel({ xAxisIndex: 0, yAxisIndex: 0 }, [ + closestPoint.timestampMs, + closestPoint.value, + ]) + if (!Array.isArray(pixel) || pixel.length < 2) { + return [] + } + + const xPx = pixel[0] + const yPx = pixel[1] + if ( + typeof xPx !== 'number' || + typeof yPx !== 'number' || + !Number.isFinite(xPx) || + !Number.isFinite(yPx) + ) { + return [] + } + + return [ + { + key: `${line.id}-${closestPoint.timestampMs}`, + xPx, + yPx, + valueContent: line.markerValueFormatter + ? line.markerValueFormatter({ + value: closestPoint.value, + markerValue: closestPoint.markerValue, + point: [ + closestPoint.timestampMs, + closestPoint.value, + closestPoint.markerValue, + ], + }) + : yAxisFormatter(closestPoint.value), + dotColor: line.lineColorVar + ? (cssVars[line.lineColorVar] ?? stroke) + : stroke, + placeValueOnRight: + firstPointPx !== null && + xPx - firstPointPx <= + SANDBOX_MONITORING_CHART_MARKER_RIGHT_THRESHOLD_PX, + labelOffsetYPx: 0, + }, + ] + }) + + return applyMarkerLabelOffsets(markers) + }, [ + cssVars, + hoveredTimestampMs, + series, + showCrosshairBadges, + stroke, + yAxisFormatter, + ]) + + const xAxisHoverBadge = useMemo(() => { + if ( + !showCrosshairBadges || + !showXAxisLabels || + hoveredTimestampMs === null + ) { + return null + } + + const chart = chartInstanceRef.current + if (!chart) { + return null + } + + const pixel = chart.convertToPixel({ xAxisIndex: 0, yAxisIndex: 0 }, [ + hoveredTimestampMs, + 0, + ]) + if (!Array.isArray(pixel) || pixel.length < 1) { + return null + } + + const xPx = pixel[0] + if (typeof xPx !== 'number' || !Number.isFinite(xPx)) { + return null + } + + return { + xPx, + label: formatXAxisHoverLabel(hoveredTimestampMs), + } + }, [hoveredTimestampMs, showCrosshairBadges, showXAxisLabels]) + + const showOverlay = crosshairMarkers.length > 0 || xAxisHoverBadge !== null + return ( - +
+ + {showCrosshairBadges && showOverlay ? ( +
+ {crosshairMarkers.map((marker) => ( +
+ +
+ {marker.valueContent} +
+
+ ))} + + {xAxisHoverBadge ? ( +
+ {xAxisHoverBadge.label} +
+ ) : null} +
+ ) : null} +
) } diff --git a/src/features/dashboard/sandbox/monitoring/components/monitoring-time-range-controls.tsx b/src/features/dashboard/sandbox/monitoring/components/monitoring-time-range-controls.tsx index 2babc953c..cc6c0e8b9 100644 --- a/src/features/dashboard/sandbox/monitoring/components/monitoring-time-range-controls.tsx +++ b/src/features/dashboard/sandbox/monitoring/components/monitoring-time-range-controls.tsx @@ -17,12 +17,16 @@ import { TimeRangePicker, type TimeRangeValues } from '@/ui/time-range-picker' import { parseTimeRangeValuesToTimestamps } from '@/ui/time-range-picker.logic' import { type TimeRangePreset, TimeRangePresets } from '@/ui/time-range-presets' import { + SANDBOX_MONITORING_FIRST_5_MINUTES_PRESET_ID, + SANDBOX_MONITORING_FIRST_5_MINUTES_PRESET_SHORTCUT, SANDBOX_MONITORING_FIRST_15_MINUTES_PRESET_ID, SANDBOX_MONITORING_FIRST_15_MINUTES_PRESET_SHORTCUT, SANDBOX_MONITORING_FIRST_HOUR_PRESET_ID, SANDBOX_MONITORING_FIRST_HOUR_PRESET_SHORTCUT, SANDBOX_MONITORING_FULL_LIFECYCLE_PRESET_ID, SANDBOX_MONITORING_FULL_LIFECYCLE_PRESET_SHORTCUT, + SANDBOX_MONITORING_LAST_5_MINUTES_PRESET_ID, + SANDBOX_MONITORING_LAST_5_MINUTES_PRESET_SHORTCUT, SANDBOX_MONITORING_LAST_6_HOURS_PRESET_ID, SANDBOX_MONITORING_LAST_6_HOURS_PRESET_SHORTCUT, SANDBOX_MONITORING_LAST_15_MINUTES_PRESET_ID, @@ -190,6 +194,12 @@ export default function SandboxMonitoringTimeRangeControls({ return clampToLifecycle(lifecycle.startMs, anchorEndMs) }, }, + makeLeading( + SANDBOX_MONITORING_FIRST_5_MINUTES_PRESET_ID, + 'First 5 min', + SANDBOX_MONITORING_FIRST_5_MINUTES_PRESET_SHORTCUT, + 5 * millisecondsInMinute + ), makeLeading( SANDBOX_MONITORING_FIRST_15_MINUTES_PRESET_ID, 'First 15 min', @@ -202,6 +212,12 @@ export default function SandboxMonitoringTimeRangeControls({ SANDBOX_MONITORING_FIRST_HOUR_PRESET_SHORTCUT, millisecondsInHour ), + makeTrailing( + SANDBOX_MONITORING_LAST_5_MINUTES_PRESET_ID, + 'Last 5 min', + SANDBOX_MONITORING_LAST_5_MINUTES_PRESET_SHORTCUT, + 5 * millisecondsInMinute + ), makeTrailing( SANDBOX_MONITORING_LAST_15_MINUTES_PRESET_ID, 'Last 15 min', @@ -397,14 +413,10 @@ export default function SandboxMonitoringTimeRangeControls({ }, [canGoForward, handleHistoryNavigation, historyState.index]) return ( -
+
- @@ -433,32 +445,32 @@ export default function SandboxMonitoringTimeRangeControls({ -
+
@@ -431,7 +436,7 @@ export default function SandboxMonitoringTimeRangeControls({ /> {isLiveUpdating && lifecycle.isRunning ? 'Live' : 'Paused'} diff --git a/src/features/dashboard/sandbox/monitoring/utils/constants.ts b/src/features/dashboard/sandbox/monitoring/utils/constants.ts index 51db4d43c..da034b85f 100644 --- a/src/features/dashboard/sandbox/monitoring/utils/constants.ts +++ b/src/features/dashboard/sandbox/monitoring/utils/constants.ts @@ -88,8 +88,8 @@ export const SANDBOX_MONITORING_BYTES_IN_GIGABYTE = 1024 * 1024 * 1024 export const SANDBOX_MONITORING_TIME_LABEL_FORMAT_OPTIONS: Intl.DateTimeFormatOptions = { - month: 'short', - day: 'numeric', hour: '2-digit', minute: '2-digit', + second: '2-digit', + hour12: false, } From 00e4ffbda195cdbe6dc6b3e7bc14250fbc3dd258 Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Tue, 3 Mar 2026 18:00:12 -0800 Subject: [PATCH 42/54] chore: only show loading layout when sandboxInfo is missing --- .../sandbox/monitoring/components/monitoring-view.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/features/dashboard/sandbox/monitoring/components/monitoring-view.tsx b/src/features/dashboard/sandbox/monitoring/components/monitoring-view.tsx index bf1329d54..05696d5a7 100644 --- a/src/features/dashboard/sandbox/monitoring/components/monitoring-view.tsx +++ b/src/features/dashboard/sandbox/monitoring/components/monitoring-view.tsx @@ -11,9 +11,9 @@ interface SandboxMonitoringViewProps { export default function SandboxMonitoringView({ sandboxId, }: SandboxMonitoringViewProps) { - const { isSandboxInfoLoading } = useSandboxContext() + const { isSandboxInfoLoading, sandboxInfo } = useSandboxContext() - if (isSandboxInfoLoading) { + if (isSandboxInfoLoading && !sandboxInfo) { return } From 03e010dc60e06189b7b3fe1723e298650cdd89b4 Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Tue, 3 Mar 2026 19:38:43 -0800 Subject: [PATCH 43/54] chore: cleanup --- .../components/monitoring-chart-section.tsx | 2 +- .../components/monitoring-charts.tsx | 1 - .../monitoring-disk-chart-header.tsx | 4 +- .../monitoring-resource-chart-header.tsx | 3 -- .../monitoring-sandbox-metrics-chart.tsx | 54 ++++++------------- .../monitoring-time-range-controls.tsx | 14 ++--- .../use-sandbox-monitoring-controller.ts | 9 ---- .../monitoring/types/sandbox-metrics-chart.ts | 3 -- .../sandbox/monitoring/utils/chart-model.ts | 1 + .../sandbox/monitoring/utils/constants.ts | 3 -- 10 files changed, 29 insertions(+), 65 deletions(-) diff --git a/src/features/dashboard/sandbox/monitoring/components/monitoring-chart-section.tsx b/src/features/dashboard/sandbox/monitoring/components/monitoring-chart-section.tsx index dea36922a..868b4df1d 100644 --- a/src/features/dashboard/sandbox/monitoring/components/monitoring-chart-section.tsx +++ b/src/features/dashboard/sandbox/monitoring/components/monitoring-chart-section.tsx @@ -1,5 +1,5 @@ -import { cn } from '@/lib/utils' import type { ReactNode } from 'react' +import { cn } from '@/lib/utils' interface MonitoringChartSectionProps { children: ReactNode diff --git a/src/features/dashboard/sandbox/monitoring/components/monitoring-charts.tsx b/src/features/dashboard/sandbox/monitoring/components/monitoring-charts.tsx index 4922c29a7..9cf21fe91 100644 --- a/src/features/dashboard/sandbox/monitoring/components/monitoring-charts.tsx +++ b/src/features/dashboard/sandbox/monitoring/components/monitoring-charts.tsx @@ -229,7 +229,6 @@ export default function SandboxMetricsCharts({ - {formatMetricValue(formatPercent(diskPercent), diskTotalGb)} + + {formatMetricValue(formatPercent(diskPercent), diskTotalGb)} +
{contextLabel ? ( diff --git a/src/features/dashboard/sandbox/monitoring/components/monitoring-resource-chart-header.tsx b/src/features/dashboard/sandbox/monitoring/components/monitoring-resource-chart-header.tsx index f03de8f9b..cee8734f2 100644 --- a/src/features/dashboard/sandbox/monitoring/components/monitoring-resource-chart-header.tsx +++ b/src/features/dashboard/sandbox/monitoring/components/monitoring-resource-chart-header.tsx @@ -26,7 +26,6 @@ interface ResourceChartHeaderProps { ramPercent: number | null timestampMs: number } | null - suffix?: ReactNode } interface MetricItemProps { @@ -60,7 +59,6 @@ function MetricItem({ export default function ResourceChartHeader({ metric, hovered, - suffix, }: ResourceChartHeaderProps) { const cpuPercent = hovered ? hovered.cpuPercent : (metric?.cpuUsedPct ?? 0) const cpuValue = formatMetricValue( @@ -102,7 +100,6 @@ export default function ResourceChartHeader({ {contextLabel} ) : null} - {suffix}
) diff --git a/src/features/dashboard/sandbox/monitoring/components/monitoring-sandbox-metrics-chart.tsx b/src/features/dashboard/sandbox/monitoring/components/monitoring-sandbox-metrics-chart.tsx index 00f4ebd73..31c599d7e 100644 --- a/src/features/dashboard/sandbox/monitoring/components/monitoring-sandbox-metrics-chart.tsx +++ b/src/features/dashboard/sandbox/monitoring/components/monitoring-sandbox-metrics-chart.tsx @@ -41,7 +41,6 @@ import { SANDBOX_MONITORING_CHART_LIVE_OUTER_DOT_SIZE, SANDBOX_MONITORING_CHART_LIVE_WINDOW_MS, SANDBOX_MONITORING_CHART_OUT_OF_BRUSH_ALPHA, - SANDBOX_MONITORING_CHART_STACK_ID, SANDBOX_MONITORING_CHART_STROKE_VAR, SANDBOX_MONITORING_CHART_Y_AXIS_SCALE_FACTOR, } from '@/features/dashboard/sandbox/monitoring/utils/constants' @@ -151,7 +150,10 @@ function toNumericValue(value: unknown): number { return Number.NaN } -function formatXAxisLabel(value: number | string): string { +function formatXAxisLabel( + value: number | string, + includeSeconds: boolean = false +): string { const timestamp = Number(value) if (Number.isNaN(timestamp)) { return '' @@ -160,22 +162,15 @@ function formatXAxisLabel(value: number | string): string { const date = new Date(timestamp) const hours = date.getHours().toString().padStart(2, '0') const minutes = date.getMinutes().toString().padStart(2, '0') + const base = `${hours}:${minutes}` - return `${hours}:${minutes}` -} - -function formatXAxisHoverLabel(value: number | string): string { - const timestamp = Number(value) - if (Number.isNaN(timestamp)) { - return '' + if (!includeSeconds) { + return base } - const date = new Date(timestamp) - const hours = date.getHours().toString().padStart(2, '0') - const minutes = date.getMinutes().toString().padStart(2, '0') const seconds = date.getSeconds().toString().padStart(2, '0') - return `${hours}:${minutes}:${seconds}` + return `${base}:${seconds}` } function findLivePoint( @@ -370,9 +365,6 @@ function SandboxMetricsChart({ series, hoveredTimestampMs = null, className, - stacked = false, - showArea = false, - showCrosshairBadges = true, showXAxisLabels = true, yAxisMax, yAxisFormatter = formatAxisNumber, @@ -528,7 +520,7 @@ function SandboxMetricsChart({ : undefined const resolvedLineColor = lineColor || stroke const livePoint = findLivePoint(line.data) - const shouldShowArea = line.showArea ?? (stacked || showArea) + const shouldShowArea = line.showArea ?? false const areaFillColor = areaFromColor && areaToColor ? { @@ -558,7 +550,6 @@ function SandboxMetricsChart({ emphasis: { disabled: true, }, - stack: stacked ? SANDBOX_MONITORING_CHART_STACK_ID : undefined, areaStyle: shouldShowArea ? { opacity: areaOpacity, @@ -612,7 +603,7 @@ function SandboxMetricsChart({ fontFamily: fontMono, fontSize: SANDBOX_MONITORING_CHART_AXIS_LABEL_FONT_SIZE, hideOverlap: true, - formatter: formatXAxisLabel, + formatter: (value: number | string) => formatXAxisLabel(value), }, axisPointer: { show: true, @@ -659,15 +650,13 @@ function SandboxMetricsChart({ fontMono, series, showXAxisLabels, - showArea, - stacked, stroke, yAxisFormatter, yAxisMax, ]) const crosshairMarkers = useMemo(() => { - if (!showCrosshairBadges || hoveredTimestampMs === null) { + if (hoveredTimestampMs === null) { return [] } @@ -750,21 +739,10 @@ function SandboxMetricsChart({ }) return applyMarkerLabelOffsets(markers) - }, [ - cssVars, - hoveredTimestampMs, - series, - showCrosshairBadges, - stroke, - yAxisFormatter, - ]) + }, [cssVars, hoveredTimestampMs, series, stroke, yAxisFormatter]) const xAxisHoverBadge = useMemo(() => { - if ( - !showCrosshairBadges || - !showXAxisLabels || - hoveredTimestampMs === null - ) { + if (!showXAxisLabels || hoveredTimestampMs === null) { return null } @@ -788,9 +766,9 @@ function SandboxMetricsChart({ return { xPx, - label: formatXAxisHoverLabel(hoveredTimestampMs), + label: formatXAxisLabel(hoveredTimestampMs, true), } - }, [hoveredTimestampMs, showCrosshairBadges, showXAxisLabels]) + }, [hoveredTimestampMs, showXAxisLabels]) const showOverlay = crosshairMarkers.length > 0 || xAxisHoverBadge !== null @@ -809,7 +787,7 @@ function SandboxMetricsChart({ updateAxisPointer: handleUpdateAxisPointer, }} /> - {showCrosshairBadges && showOverlay ? ( + {showOverlay ? (
{crosshairMarkers.map((marker) => (
{ - const formatter = new Intl.DateTimeFormat( - undefined, - SANDBOX_MONITORING_TIME_LABEL_FORMAT_OPTIONS - ) - const startDate = new Date(timeframe.start) const endDate = new Date(timeframe.end) if (!isValidDate(startDate) || !isValidDate(endDate)) { return '--' } - return `${formatter.format(startDate)} - ${formatter.format(endDate)}` + return `${rangeLabelFormatter.format(startDate)} - ${rangeLabelFormatter.format( + endDate + )}` }, [timeframe.end, timeframe.start]) useEffect(() => { diff --git a/src/features/dashboard/sandbox/monitoring/state/use-sandbox-monitoring-controller.ts b/src/features/dashboard/sandbox/monitoring/state/use-sandbox-monitoring-controller.ts index 822b3c0ec..4c981a31b 100644 --- a/src/features/dashboard/sandbox/monitoring/state/use-sandbox-monitoring-controller.ts +++ b/src/features/dashboard/sandbox/monitoring/state/use-sandbox-monitoring-controller.ts @@ -11,7 +11,6 @@ import { SANDBOX_MONITORING_DEFAULT_RANGE_MS, SANDBOX_MONITORING_LIVE_POLLING_MS, SANDBOX_MONITORING_MAX_RANGE_MS, - SANDBOX_MONITORING_METRICS_FETCH_ERROR_MESSAGE, SANDBOX_MONITORING_MIN_RANGE_MS, SANDBOX_MONITORING_QUERY_END_PARAM, SANDBOX_MONITORING_QUERY_LIVE_FALSE, @@ -470,20 +469,12 @@ export function useSandboxMonitoringController(sandboxId: string) { }, }) - const error = metricsQuery.error - ? metricsQuery.error instanceof Error - ? metricsQuery.error.message - : SANDBOX_MONITORING_METRICS_FETCH_ERROR_MESSAGE - : null - return { lifecycleBounds, timeframe: state.timeframe, metrics: metricsQuery.data ?? [], isLiveUpdating: state.isLiveUpdating, isRefetching: metricsQuery.isFetching, - isLoading: metricsQuery.isLoading || metricsQuery.isFetching, - error, setTimeframe: applyTimeframe, setLiveUpdating, } diff --git a/src/features/dashboard/sandbox/monitoring/types/sandbox-metrics-chart.ts b/src/features/dashboard/sandbox/monitoring/types/sandbox-metrics-chart.ts index e9aa02be9..b6d661953 100644 --- a/src/features/dashboard/sandbox/monitoring/types/sandbox-metrics-chart.ts +++ b/src/features/dashboard/sandbox/monitoring/types/sandbox-metrics-chart.ts @@ -32,9 +32,6 @@ export interface SandboxMetricsChartProps { series: SandboxMetricsSeries[] hoveredTimestampMs?: number | null className?: string - stacked?: boolean - showArea?: boolean - showCrosshairBadges?: boolean showXAxisLabels?: boolean yAxisMax?: number yAxisFormatter?: (value: number) => string diff --git a/src/features/dashboard/sandbox/monitoring/utils/chart-model.ts b/src/features/dashboard/sandbox/monitoring/utils/chart-model.ts index 77e67b3b3..081868e0f 100644 --- a/src/features/dashboard/sandbox/monitoring/utils/chart-model.ts +++ b/src/features/dashboard/sandbox/monitoring/utils/chart-model.ts @@ -201,6 +201,7 @@ function buildDiskSeries( lineColorVar: SANDBOX_MONITORING_DISK_LINE_COLOR_VAR, areaColorVar: SANDBOX_MONITORING_DISK_AREA_COLOR_VAR, areaToColorVar: SANDBOX_MONITORING_DISK_AREA_TO_COLOR_VAR, + showArea: true, areaOpacity: 0.5, data: buildSeriesData( metrics, diff --git a/src/features/dashboard/sandbox/monitoring/utils/constants.ts b/src/features/dashboard/sandbox/monitoring/utils/constants.ts index da034b85f..f414f3d45 100644 --- a/src/features/dashboard/sandbox/monitoring/utils/constants.ts +++ b/src/features/dashboard/sandbox/monitoring/utils/constants.ts @@ -17,8 +17,6 @@ export const SANDBOX_MONITORING_QUERY_END_PARAM = 'end' export const SANDBOX_MONITORING_QUERY_LIVE_PARAM = 'live' export const SANDBOX_MONITORING_QUERY_LIVE_TRUE = '1' export const SANDBOX_MONITORING_QUERY_LIVE_FALSE = '0' -export const SANDBOX_MONITORING_METRICS_FETCH_ERROR_MESSAGE = - 'Failed to fetch sandbox monitoring metrics.' export const SANDBOX_MONITORING_PRESET_MATCH_TOLERANCE_MS = millisecondsInMinute @@ -71,7 +69,6 @@ export const SANDBOX_MONITORING_CHART_FALLBACK_STROKE = '#000' export const SANDBOX_MONITORING_CHART_FALLBACK_FG_TERTIARY = '#666' export const SANDBOX_MONITORING_CHART_FALLBACK_FONT_MONO = 'monospace' export const SANDBOX_MONITORING_CHART_GROUP = 'sandbox-monitoring' -export const SANDBOX_MONITORING_CHART_STACK_ID = 'sandbox-resource' export const SANDBOX_MONITORING_CHART_BRUSH_TYPE = 'lineX' export const SANDBOX_MONITORING_CHART_BRUSH_MODE = 'single' export const SANDBOX_MONITORING_CHART_LINE_WIDTH = 1 From 2a3db011f6cd8eb4e8291098ddb370f3f468747e Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Wed, 4 Mar 2026 11:34:00 -0800 Subject: [PATCH 44/54] chore: ui, svg renderer charts --- .../components/monitoring-charts.tsx | 39 ++++++++++++------- .../monitoring-disk-chart-header.tsx | 2 +- .../monitoring-resource-chart-header.tsx | 2 +- .../monitoring-sandbox-metrics-chart.tsx | 13 +++++-- .../sandbox/monitoring/utils/constants.ts | 3 ++ 5 files changed, 40 insertions(+), 19 deletions(-) diff --git a/src/features/dashboard/sandbox/monitoring/components/monitoring-charts.tsx b/src/features/dashboard/sandbox/monitoring/components/monitoring-charts.tsx index 9cf21fe91..d8dbbd439 100644 --- a/src/features/dashboard/sandbox/monitoring/components/monitoring-charts.tsx +++ b/src/features/dashboard/sandbox/monitoring/components/monitoring-charts.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { cn } from '@/lib/utils' +import { CpuIcon, MemoryIcon, StorageIcon } from '@/ui/primitives/icons' import { useSandboxMonitoringController } from '../state/use-sandbox-monitoring-controller' import type { SandboxMetricsMarkerValueFormatterInput } from '../types/sandbox-metrics-chart' import { buildMonitoringChartModel } from '../utils/chart-model' @@ -29,7 +30,7 @@ function renderPercentMarker(value: number) { return ( <> {formatMarkerPercent(value)} - % + % ) } @@ -41,10 +42,10 @@ function renderUsageMarker(usedMb: number | null, value: number) { return ( <> {normalizedUsedMb.toLocaleString()} - MB - · + MB + · {formatMarkerPercent(value)} - % + % ) } @@ -116,8 +117,12 @@ export default function SandboxMetricsCharts({ ...line, markerValueFormatter: ({ value, - }: SandboxMetricsMarkerValueFormatterInput) => - renderPercentMarker(value), + }: SandboxMetricsMarkerValueFormatterInput) => ( +
+ {renderPercentMarker(value)} + +
+ ), } } @@ -127,8 +132,12 @@ export default function SandboxMetricsCharts({ markerValueFormatter: ({ markerValue, value, - }: SandboxMetricsMarkerValueFormatterInput) => - renderUsageMarker(markerValue, value), + }: SandboxMetricsMarkerValueFormatterInput) => ( +
+ {renderUsageMarker(markerValue, value)} + +
+ ), } } @@ -148,8 +157,12 @@ export default function SandboxMetricsCharts({ markerValueFormatter: ({ markerValue, value, - }: SandboxMetricsMarkerValueFormatterInput) => - renderUsageMarker(markerValue, value), + }: SandboxMetricsMarkerValueFormatterInput) => ( +
+ {renderUsageMarker(markerValue, value)} + +
+ ), } }), [chartModel.diskSeries] @@ -180,9 +193,9 @@ export default function SandboxMetricsCharts({ }, []) return ( -
+
{lifecycleBounds ? ( -
+
+
+
Date: Wed, 4 Mar 2026 11:42:34 -0800 Subject: [PATCH 45/54] chore: format --- .../unit/time-range-picker-logic.test.ts | 4 ++-- .../usage/usage-time-range-controls.tsx | 2 +- src/server/api/routers/sandbox.ts | 2 +- src/ui/time-range-picker.logic.ts | 20 +++++++++++-------- src/ui/time-range-picker.tsx | 1 + 5 files changed, 17 insertions(+), 12 deletions(-) diff --git a/src/__test__/unit/time-range-picker-logic.test.ts b/src/__test__/unit/time-range-picker-logic.test.ts index d347cccbb..73f17cb6b 100644 --- a/src/__test__/unit/time-range-picker-logic.test.ts +++ b/src/__test__/unit/time-range-picker-logic.test.ts @@ -1,12 +1,12 @@ +import { describe, expect, it } from 'vitest' import { createTimeRangeSchema, normalizeTimeRangeValues, parsePickerDateTime, parseTimeRangeValuesToTimestamps, - validateTimeRangeValues, type TimeRangeValues, + validateTimeRangeValues, } from '@/ui/time-range-picker.logic' -import { describe, expect, it } from 'vitest' const baseValues: TimeRangeValues = { startDate: '2026/02/18', diff --git a/src/features/dashboard/usage/usage-time-range-controls.tsx b/src/features/dashboard/usage/usage-time-range-controls.tsx index b19bd1957..84bcaddd8 100644 --- a/src/features/dashboard/usage/usage-time-range-controls.tsx +++ b/src/features/dashboard/usage/usage-time-range-controls.tsx @@ -6,7 +6,6 @@ import { cn } from '@/lib/utils' import { findMatchingPreset } from '@/lib/utils/time-range' import { formatTimeframeAsISO8601Interval } from '@/lib/utils/timeframe' import CopyButton from '@/ui/copy-button' -import { parseTimeRangeValuesToTimestamps } from '@/ui/time-range-picker.logic' import { Button } from '@/ui/primitives/button' import { Popover, @@ -15,6 +14,7 @@ import { } from '@/ui/primitives/popover' import { Separator } from '@/ui/primitives/separator' import { TimeRangePicker, type TimeRangeValues } from '@/ui/time-range-picker' +import { parseTimeRangeValuesToTimestamps } from '@/ui/time-range-picker.logic' import { type TimeRangePreset, TimeRangePresets } from '@/ui/time-range-presets' import { TIME_RANGE_PRESETS } from './constants' import { diff --git a/src/server/api/routers/sandbox.ts b/src/server/api/routers/sandbox.ts index 2f2725208..d9a2e4a7c 100644 --- a/src/server/api/routers/sandbox.ts +++ b/src/server/api/routers/sandbox.ts @@ -1,5 +1,5 @@ -import { SandboxIdSchema } from '@/lib/schemas/api' import { z } from 'zod' +import { SandboxIdSchema } from '@/lib/schemas/api' import { createTRPCRouter } from '../init' import { mapApiSandboxRecordToDTO, diff --git a/src/ui/time-range-picker.logic.ts b/src/ui/time-range-picker.logic.ts index f243c0cef..36f571791 100644 --- a/src/ui/time-range-picker.logic.ts +++ b/src/ui/time-range-picker.logic.ts @@ -123,11 +123,7 @@ function parseTimeInput(value: string): { const minutes = Number.parseInt(minutePart, 10) const seconds = Number.parseInt(secondPart, 10) - if ( - Number.isNaN(hours) || - Number.isNaN(minutes) || - Number.isNaN(seconds) - ) { + if (Number.isNaN(hours) || Number.isNaN(minutes) || Number.isNaN(seconds)) { return null } @@ -157,7 +153,11 @@ function formatDateValue(date: Date): string { return `${year}/${month}/${day}` } -function formatTimeValue(hours: number, minutes: number, seconds: number): string { +function formatTimeValue( + hours: number, + minutes: number, + seconds: number +): string { const hh = String(hours).padStart(2, '0') const mm = String(minutes).padStart(2, '0') const ss = String(seconds).padStart(2, '0') @@ -224,7 +224,9 @@ function normalizeTimeValue(time: string | null): string | null { ) } -export function normalizeTimeRangeValues(values: TimeRangeValues): TimeRangeValues { +export function normalizeTimeRangeValues( + values: TimeRangeValues +): TimeRangeValues { const parsedStartDate = parseDateInput(values.startDate) const parsedEndDate = parseDateInput(values.endDate) @@ -233,7 +235,9 @@ export function normalizeTimeRangeValues(values: TimeRangeValues): TimeRangeValu ? formatDateValue(parsedStartDate) : values.startDate.trim(), startTime: normalizeTimeValue(values.startTime), - endDate: parsedEndDate ? formatDateValue(parsedEndDate) : values.endDate.trim(), + endDate: parsedEndDate + ? formatDateValue(parsedEndDate) + : values.endDate.trim(), endTime: normalizeTimeValue(values.endTime), } } diff --git a/src/ui/time-range-picker.tsx b/src/ui/time-range-picker.tsx index 65e5f675d..e57b0a9b4 100644 --- a/src/ui/time-range-picker.tsx +++ b/src/ui/time-range-picker.tsx @@ -24,6 +24,7 @@ import { type TimeRangePickerBounds, type TimeRangeValues, } from './time-range-picker.logic' + export type { TimeRangeValues } from './time-range-picker.logic' interface TimeRangePickerProps { From 60c0ea2f5d60f02306d1b39b897c113d70740089 Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Wed, 4 Mar 2026 16:51:31 -0800 Subject: [PATCH 46/54] address comments --- .../unit/sandbox-monitoring-timeframe.test.ts | 21 +++++++++++++++++-- .../sandbox/monitoring/utils/timeframe.ts | 6 +----- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/src/__test__/unit/sandbox-monitoring-timeframe.test.ts b/src/__test__/unit/sandbox-monitoring-timeframe.test.ts index 096cd0313..bcc3d11e1 100644 --- a/src/__test__/unit/sandbox-monitoring-timeframe.test.ts +++ b/src/__test__/unit/sandbox-monitoring-timeframe.test.ts @@ -116,7 +116,22 @@ describe('sandbox-monitoring-timeframe', () => { expect(bounds?.isRunning).toBe(false) }) - it('should return null for killed sandbox without end timestamp', () => { + it('should fall back to now for running sandbox without endAt', () => { + const now = 1_700_000_000_000 + const sandboxInfo = { + startedAt: new Date(now - 60_000).toISOString(), + endAt: null, + state: 'running' as const, + } + + const bounds = getSandboxLifecycleBounds(sandboxInfo, now) + + expect(bounds?.startMs).toBe(now - 60_000) + expect(bounds?.anchorEndMs).toBe(now) + expect(bounds?.isRunning).toBe(true) + }) + + it('should fall back to now for killed sandbox without end timestamp', () => { const now = 1_700_000_000_000 const sandboxInfo: SandboxDetailsDTO = { templateID: 'template-id', @@ -132,7 +147,9 @@ describe('sandbox-monitoring-timeframe', () => { const bounds = getSandboxLifecycleBounds(sandboxInfo, now) - expect(bounds).toBeNull() + expect(bounds?.startMs).toBe(now - 60_000) + expect(bounds?.anchorEndMs).toBe(now) + expect(bounds?.isRunning).toBe(false) }) }) }) diff --git a/src/features/dashboard/sandbox/monitoring/utils/timeframe.ts b/src/features/dashboard/sandbox/monitoring/utils/timeframe.ts index 3c82b153e..0223933fa 100644 --- a/src/features/dashboard/sandbox/monitoring/utils/timeframe.ts +++ b/src/features/dashboard/sandbox/monitoring/utils/timeframe.ts @@ -174,16 +174,12 @@ export function getSandboxLifecycleBounds( return null } - const endMs = parseDateTimestampMs(sandboxInfo.endAt) - if (endMs === null) { - return null - } - const safeNow = clampToBounds( now, SANDBOX_MONITORING_MIN_TIMESTAMP_MS, SANDBOX_MONITORING_MAX_TIMESTAMP_MS ) + const endMs = parseDateTimestampMs(sandboxInfo.endAt) ?? safeNow const anchorEndMs = Math.min(safeNow, endMs) const normalizedStart = Math.floor(Math.min(startMs, anchorEndMs)) From cbe449c91ed8c43ff9c482fafa7e024b962c6ef8 Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Wed, 4 Mar 2026 17:06:32 -0800 Subject: [PATCH 47/54] fix: clamp timeframe bounds comment --- src/features/dashboard/sandbox/monitoring/utils/timeframe.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/dashboard/sandbox/monitoring/utils/timeframe.ts b/src/features/dashboard/sandbox/monitoring/utils/timeframe.ts index 0223933fa..c4eca1930 100644 --- a/src/features/dashboard/sandbox/monitoring/utils/timeframe.ts +++ b/src/features/dashboard/sandbox/monitoring/utils/timeframe.ts @@ -243,7 +243,7 @@ export function clampTimeframeToBounds( safeEnd = Math.min(safeMax, safeEnd) if (safeEnd - safeStart < minRangeMs) { - if (safeEnd + minRangeMs <= safeMax) { + if (safeStart + minRangeMs <= safeMax) { safeEnd = safeStart + minRangeMs } else { safeStart = safeEnd - minRangeMs From be852df82af6a4bb0db1293d68f4ad0c3c9a3066 Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Wed, 4 Mar 2026 18:34:26 -0800 Subject: [PATCH 48/54] chore: padding right + fix: percent max formatter --- .../components/monitoring-resource-chart-header.tsx | 5 ++++- .../components/monitoring-sandbox-metrics-chart.tsx | 3 ++- .../dashboard/sandbox/monitoring/utils/chart-model.ts | 10 +--------- .../dashboard/sandbox/monitoring/utils/constants.ts | 1 + .../dashboard/sandbox/monitoring/utils/formatters.ts | 11 ++++++++++- 5 files changed, 18 insertions(+), 12 deletions(-) diff --git a/src/features/dashboard/sandbox/monitoring/components/monitoring-resource-chart-header.tsx b/src/features/dashboard/sandbox/monitoring/components/monitoring-resource-chart-header.tsx index b6e4f4bc8..de86d9576 100644 --- a/src/features/dashboard/sandbox/monitoring/components/monitoring-resource-chart-header.tsx +++ b/src/features/dashboard/sandbox/monitoring/components/monitoring-resource-chart-header.tsx @@ -12,6 +12,7 @@ import { } from '../utils/constants' import { calculateRatioPercent, + clampPercent, formatBytesToGb, formatCoreCount, formatHoverTimestamp, @@ -60,7 +61,9 @@ export default function ResourceChartHeader({ metric, hovered, }: ResourceChartHeaderProps) { - const cpuPercent = hovered ? hovered.cpuPercent : (metric?.cpuUsedPct ?? 0) + const cpuPercent = hovered + ? hovered.cpuPercent + : clampPercent(metric?.cpuUsedPct ?? 0) const cpuValue = formatMetricValue( formatPercent(cpuPercent), formatCoreCount(metric?.cpuCount ?? 0) diff --git a/src/features/dashboard/sandbox/monitoring/components/monitoring-sandbox-metrics-chart.tsx b/src/features/dashboard/sandbox/monitoring/components/monitoring-sandbox-metrics-chart.tsx index 8e9ed3772..5c08aa8bb 100644 --- a/src/features/dashboard/sandbox/monitoring/components/monitoring-sandbox-metrics-chart.tsx +++ b/src/features/dashboard/sandbox/monitoring/components/monitoring-sandbox-metrics-chart.tsx @@ -36,6 +36,7 @@ import { SANDBOX_MONITORING_CHART_FONT_MONO_VAR, SANDBOX_MONITORING_CHART_GRID_BOTTOM, SANDBOX_MONITORING_CHART_GRID_BOTTOM_WITH_X_AXIS, + SANDBOX_MONITORING_CHART_GRID_RIGHT, SANDBOX_MONITORING_CHART_GRID_TOP, SANDBOX_MONITORING_CHART_GROUP, SANDBOX_MONITORING_CHART_LINE_WIDTH, @@ -594,7 +595,7 @@ function SandboxMetricsChart({ ? SANDBOX_MONITORING_CHART_GRID_BOTTOM_WITH_X_AXIS : SANDBOX_MONITORING_CHART_GRID_BOTTOM, left: 36, - right: 8, + right: SANDBOX_MONITORING_CHART_GRID_RIGHT, }, xAxis: { type: 'time', diff --git a/src/features/dashboard/sandbox/monitoring/utils/chart-model.ts b/src/features/dashboard/sandbox/monitoring/utils/chart-model.ts index 081868e0f..7f552d3bc 100644 --- a/src/features/dashboard/sandbox/monitoring/utils/chart-model.ts +++ b/src/features/dashboard/sandbox/monitoring/utils/chart-model.ts @@ -16,13 +16,13 @@ import { SANDBOX_MONITORING_DISK_LINE_COLOR_VAR, SANDBOX_MONITORING_DISK_SERIES_ID, SANDBOX_MONITORING_DISK_SERIES_LABEL, - SANDBOX_MONITORING_PERCENT_MAX, SANDBOX_MONITORING_RAM_AREA_COLOR_VAR, SANDBOX_MONITORING_RAM_AREA_TO_COLOR_VAR, SANDBOX_MONITORING_RAM_LINE_COLOR_VAR, SANDBOX_MONITORING_RAM_SERIES_ID, SANDBOX_MONITORING_RAM_SERIES_LABEL, } from './constants' +import { clampPercent } from './formatters' interface NormalizedSandboxMetric { metric: SandboxMetric @@ -41,14 +41,6 @@ interface BuildMonitoringChartModelOptions { hoveredTimestampMs: number | null } -function clampPercent(value: number): number { - if (!Number.isFinite(value)) { - return 0 - } - - return Math.max(0, Math.min(SANDBOX_MONITORING_PERCENT_MAX, value)) -} - function toPercent(used: number, total: number): number { if (!total || total <= 0) { return 0 diff --git a/src/features/dashboard/sandbox/monitoring/utils/constants.ts b/src/features/dashboard/sandbox/monitoring/utils/constants.ts index 2a0d29100..584ac1a10 100644 --- a/src/features/dashboard/sandbox/monitoring/utils/constants.ts +++ b/src/features/dashboard/sandbox/monitoring/utils/constants.ts @@ -73,6 +73,7 @@ export const SANDBOX_MONITORING_CHART_BRUSH_TYPE = 'lineX' export const SANDBOX_MONITORING_CHART_BRUSH_MODE = 'single' export const SANDBOX_MONITORING_CHART_LINE_WIDTH = 1 export const SANDBOX_MONITORING_CHART_GRID_TOP = 28 +export const SANDBOX_MONITORING_CHART_GRID_RIGHT = 28 export const SANDBOX_MONITORING_CHART_GRID_BOTTOM = 28 export const SANDBOX_MONITORING_CHART_GRID_BOTTOM_WITH_X_AXIS = 28 export const SANDBOX_MONITORING_CHART_AREA_OPACITY = 0.18 diff --git a/src/features/dashboard/sandbox/monitoring/utils/formatters.ts b/src/features/dashboard/sandbox/monitoring/utils/formatters.ts index 4b425aab7..3c77b67bb 100644 --- a/src/features/dashboard/sandbox/monitoring/utils/formatters.ts +++ b/src/features/dashboard/sandbox/monitoring/utils/formatters.ts @@ -4,6 +4,7 @@ import { SANDBOX_MONITORING_CORE_LABEL_SINGULAR, SANDBOX_MONITORING_GIGABYTE_UNIT, SANDBOX_MONITORING_METRIC_VALUE_SEPARATOR, + SANDBOX_MONITORING_PERCENT_MAX, SANDBOX_MONITORING_TIME_LABEL_FORMAT_OPTIONS, SANDBOX_MONITORING_VALUE_UNAVAILABLE, } from './constants' @@ -47,10 +48,18 @@ export function formatMetricValue(primary: string, secondary: string): string { return `${primary}${SANDBOX_MONITORING_METRIC_VALUE_SEPARATOR}${secondary}` } +export function clampPercent(value: number): number { + if (!Number.isFinite(value)) { + return 0 + } + + return Math.max(0, Math.min(SANDBOX_MONITORING_PERCENT_MAX, value)) +} + export function calculateRatioPercent(used: number, total: number): number { if (total <= 0) { return 0 } - return (used / total) * 100 + return clampPercent((used / total) * 100) } From cf22f60416a13f437a47913935b49a19eb9da20b Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Wed, 4 Mar 2026 19:38:40 -0800 Subject: [PATCH 49/54] chore: use sandbox id schema for details router --- src/server/api/routers/sandbox.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server/api/routers/sandbox.ts b/src/server/api/routers/sandbox.ts index d9a2e4a7c..fd66a8555 100644 --- a/src/server/api/routers/sandbox.ts +++ b/src/server/api/routers/sandbox.ts @@ -18,7 +18,7 @@ export const sandboxRouter = createTRPCRouter({ details: protectedTeamProcedure .input( z.object({ - sandboxId: z.string(), + sandboxId: SandboxIdSchema, }) ) .query(async ({ ctx, input }) => { From 46a89a3ce65f7390a6bc5948c3b1be7ed35bee44 Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Wed, 4 Mar 2026 20:21:01 -0800 Subject: [PATCH 50/54] chore: fix lifecycle bounds --- .../unit/sandbox-monitoring-timeframe.test.ts | 22 +++++++++++++++++++ .../use-sandbox-monitoring-controller.ts | 5 ++++- .../sandbox/monitoring/utils/timeframe.ts | 9 ++++++-- 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/src/__test__/unit/sandbox-monitoring-timeframe.test.ts b/src/__test__/unit/sandbox-monitoring-timeframe.test.ts index bcc3d11e1..2c1b9384e 100644 --- a/src/__test__/unit/sandbox-monitoring-timeframe.test.ts +++ b/src/__test__/unit/sandbox-monitoring-timeframe.test.ts @@ -131,6 +131,28 @@ describe('sandbox-monitoring-timeframe', () => { expect(bounds?.isRunning).toBe(true) }) + it('should use stoppedAt when endAt is null for killed sandbox', () => { + const now = 1_700_000_000_000 + const stoppedAt = now - 30_000 + const sandboxInfo: SandboxDetailsDTO = { + templateID: 'template-id', + sandboxID: 'sandbox-id', + startedAt: new Date(now - 60_000).toISOString(), + endAt: null, + stoppedAt: new Date(stoppedAt).toISOString(), + cpuCount: 2, + memoryMB: 512, + diskSizeMB: 1_024, + state: 'killed', + } + + const bounds = getSandboxLifecycleBounds(sandboxInfo, now) + + expect(bounds?.startMs).toBe(now - 60_000) + expect(bounds?.anchorEndMs).toBe(stoppedAt) + expect(bounds?.isRunning).toBe(false) + }) + it('should fall back to now for killed sandbox without end timestamp', () => { const now = 1_700_000_000_000 const sandboxInfo: SandboxDetailsDTO = { diff --git a/src/features/dashboard/sandbox/monitoring/state/use-sandbox-monitoring-controller.ts b/src/features/dashboard/sandbox/monitoring/state/use-sandbox-monitoring-controller.ts index 4c981a31b..0249666f7 100644 --- a/src/features/dashboard/sandbox/monitoring/state/use-sandbox-monitoring-controller.ts +++ b/src/features/dashboard/sandbox/monitoring/state/use-sandbox-monitoring-controller.ts @@ -216,6 +216,8 @@ export function useSandboxMonitoringController(sandboxId: string) { const lifecycleStartedAt = sandboxInfo?.startedAt const lifecycleEndAt = sandboxInfo?.endAt + const lifecycleStoppedAt = + sandboxInfo && 'stoppedAt' in sandboxInfo ? sandboxInfo.stoppedAt : null const lifecycleState = sandboxInfo?.state const lifecycleBounds = useMemo(() => { if (!lifecycleStartedAt || !lifecycleState) { @@ -225,9 +227,10 @@ export function useSandboxMonitoringController(sandboxId: string) { return getSandboxLifecycleBounds({ startedAt: lifecycleStartedAt, endAt: lifecycleEndAt ?? null, + stoppedAt: lifecycleStoppedAt ?? null, state: lifecycleState, }) - }, [lifecycleEndAt, lifecycleStartedAt, lifecycleState]) + }, [lifecycleEndAt, lifecycleStartedAt, lifecycleStoppedAt, lifecycleState]) useEffect(() => { stateRef.current = state diff --git a/src/features/dashboard/sandbox/monitoring/utils/timeframe.ts b/src/features/dashboard/sandbox/monitoring/utils/timeframe.ts index c4eca1930..8e0189199 100644 --- a/src/features/dashboard/sandbox/monitoring/utils/timeframe.ts +++ b/src/features/dashboard/sandbox/monitoring/utils/timeframe.ts @@ -164,7 +164,9 @@ export interface SandboxLifecycleBounds { } export function getSandboxLifecycleBounds( - sandboxInfo: Pick, + sandboxInfo: Pick & { + stoppedAt?: string | null + }, now: number = Date.now() ): SandboxLifecycleBounds | null { const startMs = parseDateTimestampMs(sandboxInfo.startedAt) @@ -179,7 +181,10 @@ export function getSandboxLifecycleBounds( SANDBOX_MONITORING_MIN_TIMESTAMP_MS, SANDBOX_MONITORING_MAX_TIMESTAMP_MS ) - const endMs = parseDateTimestampMs(sandboxInfo.endAt) ?? safeNow + const endMs = + parseDateTimestampMs(sandboxInfo.endAt) ?? + parseDateTimestampMs(sandboxInfo.stoppedAt) ?? + safeNow const anchorEndMs = Math.min(safeNow, endMs) const normalizedStart = Math.floor(Math.min(startMs, anchorEndMs)) From c56e639a0ce130b28fb4da648418d5964865946a Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Thu, 5 Mar 2026 10:19:14 -0800 Subject: [PATCH 51/54] chore: responsiveness --- .../components/monitoring-charts.tsx | 4 +- .../monitoring-time-range-controls.tsx | 119 +++++++++++------- .../use-sandbox-monitoring-controller.ts | 22 ++-- 3 files changed, 88 insertions(+), 57 deletions(-) diff --git a/src/features/dashboard/sandbox/monitoring/components/monitoring-charts.tsx b/src/features/dashboard/sandbox/monitoring/components/monitoring-charts.tsx index d8dbbd439..d50995eaa 100644 --- a/src/features/dashboard/sandbox/monitoring/components/monitoring-charts.tsx +++ b/src/features/dashboard/sandbox/monitoring/components/monitoring-charts.tsx @@ -195,7 +195,7 @@ export default function SandboxMetricsCharts({ return (
{lifecycleBounds ? ( -
+
- - - - - -
- - - - -
-
-
- - - -
+
+
+ + + + + +
+ + + + +
+
+
+ + +
+ +
diff --git a/src/features/dashboard/sandbox/monitoring/state/use-sandbox-monitoring-controller.ts b/src/features/dashboard/sandbox/monitoring/state/use-sandbox-monitoring-controller.ts index 0249666f7..2978f55da 100644 --- a/src/features/dashboard/sandbox/monitoring/state/use-sandbox-monitoring-controller.ts +++ b/src/features/dashboard/sandbox/monitoring/state/use-sandbox-monitoring-controller.ts @@ -39,6 +39,10 @@ interface SandboxMonitoringControllerState { isInitialized: boolean } +interface ApplyTimeframeOptions { + isLiveUpdating?: boolean +} + type SandboxMonitoringControllerAction = | { type: 'initialize' @@ -237,13 +241,7 @@ export function useSandboxMonitoringController(sandboxId: string) { }, [state]) const applyTimeframe = useCallback( - ( - start: number, - end: number, - options?: { - isLiveUpdating?: boolean - } - ) => { + (start: number, end: number, options?: ApplyTimeframeOptions) => { const currentState = stateRef.current const now = Date.now() const timeframe = resolveTimeframe(start, end, now, lifecycleBounds) @@ -276,7 +274,13 @@ export function useSandboxMonitoringController(sandboxId: string) { const setLiveUpdating = useCallback( (isLiveUpdating: boolean) => { + const currentState = stateRef.current + if (!isLiveUpdating) { + if (!currentState.isLiveUpdating) { + return + } + dispatch({ type: 'setLiveUpdating', payload: { isLiveUpdating: false }, @@ -286,6 +290,10 @@ export function useSandboxMonitoringController(sandboxId: string) { } if (lifecycleBounds && !lifecycleBounds.isRunning) { + if (!currentState.isLiveUpdating) { + return + } + dispatch({ type: 'setLiveUpdating', payload: { isLiveUpdating: false }, From 45cf87ce97a4adcb76d61c4eb938e1bb9f1f724f Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Thu, 5 Mar 2026 10:55:11 -0800 Subject: [PATCH 52/54] fix: server side timestamp validation --- .../sandbox/monitoring/utils/constants.ts | 1 + src/server/api/routers/sandbox.ts | 29 +++++++++++++++---- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/src/features/dashboard/sandbox/monitoring/utils/constants.ts b/src/features/dashboard/sandbox/monitoring/utils/constants.ts index 584ac1a10..ad0001c5f 100644 --- a/src/features/dashboard/sandbox/monitoring/utils/constants.ts +++ b/src/features/dashboard/sandbox/monitoring/utils/constants.ts @@ -5,6 +5,7 @@ import { millisecondsInSecond, } from 'date-fns/constants' +export const SANDBOX_MONITORING_METRICS_RETENTION_MS = 7 * millisecondsInDay export const SANDBOX_MONITORING_DEFAULT_RANGE_MS = millisecondsInHour export const SANDBOX_MONITORING_MIN_RANGE_MS = 30 * millisecondsInSecond export const SANDBOX_MONITORING_MAX_RANGE_MS = 31 * millisecondsInDay diff --git a/src/server/api/routers/sandbox.ts b/src/server/api/routers/sandbox.ts index fd66a8555..92b2d540a 100644 --- a/src/server/api/routers/sandbox.ts +++ b/src/server/api/routers/sandbox.ts @@ -1,4 +1,6 @@ +import { millisecondsInDay } from 'date-fns/constants' import { z } from 'zod' +import { SANDBOX_MONITORING_METRICS_RETENTION_MS } from '@/features/dashboard/sandbox/monitoring/utils/constants' import { SandboxIdSchema } from '@/lib/schemas/api' import { createTRPCRouter } from '../init' import { @@ -124,11 +126,28 @@ export const sandboxRouter = createTRPCRouter({ resourceMetrics: protectedTeamProcedure .input( - z.object({ - sandboxId: SandboxIdSchema, - startMs: z.number(), - endMs: z.number(), - }) + z + .object({ + sandboxId: SandboxIdSchema, + startMs: z.number().int().positive(), + endMs: z.number().int().positive(), + }) + .refine(({ startMs, endMs }) => startMs < endMs, { + message: 'startMs must be before endMs', + }) + .refine( + ({ startMs, endMs }) => { + const now = Date.now() + return ( + startMs >= now - SANDBOX_MONITORING_METRICS_RETENTION_MS && + endMs <= now + millisecondsInDay + ) + }, + { + message: + 'Time range must be within metrics retention window (7 days) and 1 day from now', + } + ) ) .query(async ({ ctx, input }) => { const { teamId, session } = ctx From c57edfcc9b7ad52b80e1bfebf2f3b8f884a3c980 Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Thu, 5 Mar 2026 11:17:16 -0800 Subject: [PATCH 53/54] address comments --- .../monitoring-sandbox-metrics-chart.tsx | 15 +++++++++++---- .../components/monitoring-time-range-controls.tsx | 12 +++++++++++- .../sandbox/monitoring/utils/constants.ts | 1 + 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/src/features/dashboard/sandbox/monitoring/components/monitoring-sandbox-metrics-chart.tsx b/src/features/dashboard/sandbox/monitoring/components/monitoring-sandbox-metrics-chart.tsx index 5c08aa8bb..6c527b421 100644 --- a/src/features/dashboard/sandbox/monitoring/components/monitoring-sandbox-metrics-chart.tsx +++ b/src/features/dashboard/sandbox/monitoring/components/monitoring-sandbox-metrics-chart.tsx @@ -23,6 +23,7 @@ import { useEffect, useMemo, useRef, + useState, } from 'react' import { SANDBOX_MONITORING_CHART_AREA_OPACITY, @@ -377,6 +378,7 @@ function SandboxMetricsChart({ onBrushEnd, }: SandboxMetricsChartProps) { const chartInstanceRef = useRef(null) + const [chartRevision, setChartRevision] = useState(0) const { resolvedTheme } = useTheme() const cssVarNames = useMemo(() => { @@ -482,6 +484,7 @@ function SandboxMetricsChart({ const handleChartReady = useCallback((chart: echarts.ECharts) => { chartInstanceRef.current = chart + setChartRevision((v) => v + 1) chart.dispatchAction( { @@ -662,12 +665,14 @@ function SandboxMetricsChart({ ]) const crosshairMarkers = useMemo(() => { + void chartRevision + if (hoveredTimestampMs === null) { return [] } const chart = chartInstanceRef.current - if (!chart) { + if (!chart || chart.isDisposed()) { return [] } @@ -745,15 +750,17 @@ function SandboxMetricsChart({ }) return applyMarkerLabelOffsets(markers) - }, [cssVars, hoveredTimestampMs, series, stroke, yAxisFormatter]) + }, [chartRevision, cssVars, hoveredTimestampMs, series, stroke, yAxisFormatter]) const xAxisHoverBadge = useMemo(() => { + void chartRevision + if (!showXAxisLabels || hoveredTimestampMs === null) { return null } const chart = chartInstanceRef.current - if (!chart) { + if (!chart || chart.isDisposed()) { return null } @@ -774,7 +781,7 @@ function SandboxMetricsChart({ xPx, label: formatXAxisLabel(hoveredTimestampMs, true), } - }, [hoveredTimestampMs, showXAxisLabels]) + }, [chartRevision, hoveredTimestampMs, showXAxisLabels]) const showOverlay = crosshairMarkers.length > 0 || xAxisHoverBadge !== null diff --git a/src/features/dashboard/sandbox/monitoring/components/monitoring-time-range-controls.tsx b/src/features/dashboard/sandbox/monitoring/components/monitoring-time-range-controls.tsx index ea8ee62d8..701111e8d 100644 --- a/src/features/dashboard/sandbox/monitoring/components/monitoring-time-range-controls.tsx +++ b/src/features/dashboard/sandbox/monitoring/components/monitoring-time-range-controls.tsx @@ -34,6 +34,7 @@ import { SANDBOX_MONITORING_LAST_15_MINUTES_PRESET_SHORTCUT, SANDBOX_MONITORING_LAST_HOUR_PRESET_ID, SANDBOX_MONITORING_LAST_HOUR_PRESET_SHORTCUT, + SANDBOX_MONITORING_MAX_HISTORY_ENTRIES, SANDBOX_MONITORING_PRESET_MATCH_TOLERANCE_MS, SANDBOX_MONITORING_TIME_LABEL_FORMAT_OPTIONS, } from '../utils/constants' @@ -317,8 +318,17 @@ export default function SandboxMonitoringTimeRangeControls({ } } + const nextEntries = [...trimmedEntries, snapshot] + const overflow = nextEntries.length - SANDBOX_MONITORING_MAX_HISTORY_ENTRIES + if (overflow > 0) { + return { + entries: nextEntries.slice(overflow), + index: trimmedEntries.length - overflow, + } + } + return { - entries: [...trimmedEntries, snapshot], + entries: nextEntries, index: trimmedEntries.length, } }) diff --git a/src/features/dashboard/sandbox/monitoring/utils/constants.ts b/src/features/dashboard/sandbox/monitoring/utils/constants.ts index ad0001c5f..81fbbc0b8 100644 --- a/src/features/dashboard/sandbox/monitoring/utils/constants.ts +++ b/src/features/dashboard/sandbox/monitoring/utils/constants.ts @@ -20,6 +20,7 @@ export const SANDBOX_MONITORING_QUERY_LIVE_TRUE = '1' export const SANDBOX_MONITORING_QUERY_LIVE_FALSE = '0' export const SANDBOX_MONITORING_PRESET_MATCH_TOLERANCE_MS = millisecondsInMinute +export const SANDBOX_MONITORING_MAX_HISTORY_ENTRIES = 50 export const SANDBOX_MONITORING_FULL_LIFECYCLE_PRESET_ID = 'full-lifecycle' export const SANDBOX_MONITORING_FULL_LIFECYCLE_PRESET_SHORTCUT = 'FULL' From 3a7bcc4afb24b7369f8233faf37e8ae3d25f19a1 Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Thu, 5 Mar 2026 11:18:03 -0800 Subject: [PATCH 54/54] chore: format --- .../components/monitoring-sandbox-metrics-chart.tsx | 9 ++++++++- .../components/monitoring-time-range-controls.tsx | 3 ++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/features/dashboard/sandbox/monitoring/components/monitoring-sandbox-metrics-chart.tsx b/src/features/dashboard/sandbox/monitoring/components/monitoring-sandbox-metrics-chart.tsx index 6c527b421..dd62f71ca 100644 --- a/src/features/dashboard/sandbox/monitoring/components/monitoring-sandbox-metrics-chart.tsx +++ b/src/features/dashboard/sandbox/monitoring/components/monitoring-sandbox-metrics-chart.tsx @@ -750,7 +750,14 @@ function SandboxMetricsChart({ }) return applyMarkerLabelOffsets(markers) - }, [chartRevision, cssVars, hoveredTimestampMs, series, stroke, yAxisFormatter]) + }, [ + chartRevision, + cssVars, + hoveredTimestampMs, + series, + stroke, + yAxisFormatter, + ]) const xAxisHoverBadge = useMemo(() => { void chartRevision diff --git a/src/features/dashboard/sandbox/monitoring/components/monitoring-time-range-controls.tsx b/src/features/dashboard/sandbox/monitoring/components/monitoring-time-range-controls.tsx index 701111e8d..92c1b9340 100644 --- a/src/features/dashboard/sandbox/monitoring/components/monitoring-time-range-controls.tsx +++ b/src/features/dashboard/sandbox/monitoring/components/monitoring-time-range-controls.tsx @@ -319,7 +319,8 @@ export default function SandboxMonitoringTimeRangeControls({ } const nextEntries = [...trimmedEntries, snapshot] - const overflow = nextEntries.length - SANDBOX_MONITORING_MAX_HISTORY_ENTRIES + const overflow = + nextEntries.length - SANDBOX_MONITORING_MAX_HISTORY_ENTRIES if (overflow > 0) { return { entries: nextEntries.slice(overflow),