diff --git a/spec/openapi.dashboard-api.yaml b/spec/openapi.dashboard-api.yaml index 3e9e4b1d5..09ddf6d70 100644 --- a/spec/openapi.dashboard-api.yaml +++ b/spec/openapi.dashboard-api.yaml @@ -565,6 +565,7 @@ components: - cpuCount - memoryMB - diskSizeMB + - retentionExpired properties: templateID: type: string @@ -594,6 +595,9 @@ components: $ref: "#/components/schemas/MemoryMB" diskSizeMB: $ref: "#/components/schemas/DiskSizeMB" + retentionExpired: + type: boolean + description: Whether the sandbox ended more than the retention window ago, so its monitoring, events, and logs data is no longer available HealthResponse: type: object diff --git a/src/core/modules/sandboxes/models.ts b/src/core/modules/sandboxes/models.ts index 253eb1c94..a359f378b 100644 --- a/src/core/modules/sandboxes/models.ts +++ b/src/core/modules/sandboxes/models.ts @@ -20,6 +20,9 @@ interface SandboxDetailsBaseModel { cpuCount: number memoryMB: number diskSizeMB: number + // True when the sandbox ended more than the retention window ago, so its + // monitoring, events, and logs data is no longer available. + retentionExpired: boolean lifecycle?: SandboxLifecycleModel } @@ -169,6 +172,8 @@ export function mapInfraSandboxDetailsToModel( diskSizeMB: sandbox.diskSizeMB, metadata: sandbox.metadata, state: sandbox.state, + // Infra only serves live sandboxes, whose data is never past retention. + retentionExpired: false, } } @@ -189,5 +194,6 @@ export function mapApiSandboxRecordToModel( memoryMB: sandbox.memoryMB, diskSizeMB: sandbox.diskSizeMB, state: 'killed', + retentionExpired: sandbox.retentionExpired, } } diff --git a/src/core/server/api/routers/sandbox.ts b/src/core/server/api/routers/sandbox.ts index b85b46e67..7a50ba0a3 100644 --- a/src/core/server/api/routers/sandbox.ts +++ b/src/core/server/api/routers/sandbox.ts @@ -20,6 +20,17 @@ import { SandboxIdSchema } from '@/core/shared/schemas/api' import { SANDBOX_MONITORING_METRICS_RETENTION_MS } from '@/features/dashboard/sandbox/monitoring/utils/constants' import { TERMINAL_SANDBOX_TIMEOUT_MS } from '@/features/dashboard/terminal/constants' +function isPastRetention(timestamp: string | null): boolean { + if (!timestamp) { + return false + } + const timestampMs = new Date(timestamp).getTime() + if (Number.isNaN(timestampMs)) { + return false + } + return Date.now() - timestampMs > SANDBOX_MONITORING_METRICS_RETENTION_MS +} + const sandboxRepositoryProcedure = protectedTeamProcedure.use( withTeamAuthedRequestRepository( createSandboxesRepository, @@ -67,11 +78,21 @@ export const sandboxRouter = createTRPCRouter({ ? (mappedDetails.stoppedAt ?? mappedDetails.endAt) : null + const pausedAt = derivedLifecycle.pausedAt ?? fallbackPausedAt + + // A paused sandbox keeps no fresh data, so once it has been paused longer + // than the retention window its monitoring/events/logs are gone too. The + // killed case is already flagged by the backend (mappedDetails). + const retentionExpired = + mappedDetails.retentionExpired || + (mappedDetails.state === 'paused' && isPastRetention(pausedAt)) + return { ...mappedDetails, + retentionExpired, lifecycle: { createdAt: derivedLifecycle.createdAt ?? mappedDetails.startedAt, - pausedAt: derivedLifecycle.pausedAt ?? fallbackPausedAt, + pausedAt, endedAt: derivedLifecycle.endedAt ?? fallbackEndedAt, events: derivedLifecycle.events, }, diff --git a/src/core/shared/contracts/dashboard-api.types.ts b/src/core/shared/contracts/dashboard-api.types.ts index 0f7821b27..022031f81 100644 --- a/src/core/shared/contracts/dashboard-api.types.ts +++ b/src/core/shared/contracts/dashboard-api.types.ts @@ -1280,6 +1280,8 @@ export interface components { cpuCount: components['schemas']['CPUCount'] memoryMB: components['schemas']['MemoryMB'] diskSizeMB: components['schemas']['DiskSizeMB'] + /** @description Whether the sandbox ended more than the retention window ago, so its monitoring, events, and logs data is no longer available */ + retentionExpired: boolean } HealthResponse: { /** @description Human-readable health check result. */ diff --git a/src/features/dashboard/sandbox/common/data-retention-expired.tsx b/src/features/dashboard/sandbox/common/data-retention-expired.tsx new file mode 100644 index 000000000..417120d01 --- /dev/null +++ b/src/features/dashboard/sandbox/common/data-retention-expired.tsx @@ -0,0 +1,19 @@ +import { LOG_RETENTION_MS } from '@/configs/logs' +import { ExpiryIcon } from '@/ui/primitives/icons' + +const RETENTION_DAYS = LOG_RETENTION_MS / 24 / 60 / 60 / 1000 + +export function DataRetentionExpired() { + return ( +
+
+ +

Data retention is over

+
+

+ This sandbox ended more than {RETENTION_DAYS} days ago, so its + monitoring, events, and logs data is no longer available. +

+
+ ) +} diff --git a/src/features/dashboard/sandbox/events/view.tsx b/src/features/dashboard/sandbox/events/view.tsx index 6b587122c..70d031436 100644 --- a/src/features/dashboard/sandbox/events/view.tsx +++ b/src/features/dashboard/sandbox/events/view.tsx @@ -1,6 +1,7 @@ 'use client' import { useMemo, useState } from 'react' +import { DataRetentionExpired } from '@/features/dashboard/sandbox/common/data-retention-expired' import { useSandboxContext } from '../context' import { EventTypeFilter } from './event-type-filter' import { SandboxEventsTable } from './table' @@ -9,7 +10,8 @@ import { useSandboxEventFilters } from './use-sandbox-event-filters' export const SandboxEventsView = () => { 'use no memo' - const { sandboxLifecycle, isSandboxInfoLoading } = useSandboxContext() + const { sandboxInfo, sandboxLifecycle, isSandboxInfoLoading } = + useSandboxContext() const { order, orderAsc, setOrder, setTypes, types } = useSandboxEventFilters() const [scrollContainer, setScrollContainer] = useState( @@ -30,6 +32,10 @@ export const SandboxEventsView = () => { return orderedEvents }, [orderAsc, sandboxLifecycle?.events, types]) + if (sandboxInfo?.retentionExpired) { + return + } + return (
diff --git a/src/features/dashboard/sandbox/logs/logs.tsx b/src/features/dashboard/sandbox/logs/logs.tsx index c418f637c..095a01d99 100644 --- a/src/features/dashboard/sandbox/logs/logs.tsx +++ b/src/features/dashboard/sandbox/logs/logs.tsx @@ -12,7 +12,6 @@ import { useRef, useState, } from 'react' -import { LOG_RETENTION_MS } from '@/configs/logs' import type { SandboxLogModel } from '@/core/modules/sandboxes/models' import { LOG_LEVEL_LEFT_BORDER_CLASS, @@ -28,6 +27,7 @@ import { VirtualizedTableLoaderBody, VirtualizedTableRow, } from '@/features/dashboard/common/virtualized-table-ui' +import { DataRetentionExpired } from '@/features/dashboard/sandbox/common/data-retention-expired' import { cn } from '@/lib/utils' import { DebouncedInput } from '@/ui/primitives/input' import { Loader } from '@/ui/primitives/loader' @@ -44,29 +44,22 @@ 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 { teamSlug: 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({ teamSlug, sandboxId }: LogsProps) { 'use no memo' - const { sandboxInfo, sandboxLifecycle, isRunning } = useSandboxContext() + const { sandboxInfo, isRunning } = useSandboxContext() const { level, setLevel, search, setSearch } = useLogFilters() + if (sandboxInfo?.retentionExpired) { + return + } + if (!sandboxInfo) { return (
@@ -90,16 +83,11 @@ export default function SandboxLogs({ teamSlug, sandboxId }: LogsProps) { ) } - const hasRetainedLogs = checkIfSandboxStillHasLogs( - sandboxLifecycle?.createdAt ?? sandboxInfo.startedAt - ) - return ( void @@ -123,7 +110,6 @@ function LogsContent({ teamSlug, sandboxId, isRunning, - hasRetainedLogs, level, search, setLevel, @@ -207,12 +193,7 @@ function LogsContent({ /> {showLoader && } - {showEmpty && ( - - )} + {showEmpty && } {hasLogs && scrollContainerElement && ( } diff --git a/src/features/dashboard/sandbox/monitoring/components/monitoring-view.tsx b/src/features/dashboard/sandbox/monitoring/components/monitoring-view.tsx index cca8344f8..99df96b47 100644 --- a/src/features/dashboard/sandbox/monitoring/components/monitoring-view.tsx +++ b/src/features/dashboard/sandbox/monitoring/components/monitoring-view.tsx @@ -1,6 +1,7 @@ 'use client' import LoadingLayout from '@/features/dashboard/loading-layout' +import { DataRetentionExpired } from '@/features/dashboard/sandbox/common/data-retention-expired' import { useSandboxContext } from '@/features/dashboard/sandbox/context' import SandboxMetricsCharts from './monitoring-charts' @@ -17,6 +18,10 @@ export default function SandboxMonitoringView({ return } + if (sandboxInfo?.retentionExpired) { + return + } + return (