(
+ null
+ )
+ const [renderedTimeframe, setRenderedTimeframe] = useState(() => ({
+ start: timeframe.start,
+ end: timeframe.end,
+ }))
+
+ const handleTimeRangeChange = useCallback(
+ (
+ startTimestamp: number,
+ endTimestamp: number,
+ options?: { isLiveUpdating?: boolean }
+ ) => {
+ const nextLiveUpdating = options?.isLiveUpdating ?? false
+
+ if (
+ startTimestamp === timeframe.start &&
+ endTimestamp === timeframe.end &&
+ nextLiveUpdating === isLiveUpdating
+ ) {
+ return
+ }
+
+ setHoveredTimestampMs(null)
+ setTimeframe(startTimestamp, endTimestamp, {
+ isLiveUpdating: nextLiveUpdating,
+ })
+ },
+ [isLiveUpdating, setTimeframe, timeframe.end, timeframe.start]
+ )
+
+ const chartModel = useMemo(
+ () =>
+ buildMonitoringChartModel({
+ metrics,
+ startMs: renderedTimeframe.start,
+ endMs: renderedTimeframe.end,
+ hoveredTimestampMs,
+ }),
+ [
+ hoveredTimestampMs,
+ metrics,
+ renderedTimeframe.end,
+ 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) {
+ 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(() => {
+ setHoveredTimestampMs(null)
+ }, [])
+
+ return (
+
+ {lifecycleBounds ? (
+
+
+
+ ) : 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..4386bf44e
--- /dev/null
+++ b/src/features/dashboard/sandbox/monitoring/components/monitoring-disk-chart-header.tsx
@@ -0,0 +1,68 @@
+'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/components/monitoring-resource-chart-header.tsx b/src/features/dashboard/sandbox/monitoring/components/monitoring-resource-chart-header.tsx
new file mode 100644
index 000000000..de86d9576
--- /dev/null
+++ b/src/features/dashboard/sandbox/monitoring/components/monitoring-resource-chart-header.tsx
@@ -0,0 +1,109 @@
+'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 {
+ 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,
+ clampPercent,
+ formatBytesToGb,
+ formatCoreCount,
+ formatHoverTimestamp,
+ formatMetricValue,
+ formatPercent,
+} from '../utils/formatters'
+
+interface ResourceChartHeaderProps {
+ metric?: SandboxMetric
+ hovered?: {
+ cpuPercent: number | null
+ ramPercent: number | null
+ timestampMs: number
+ } | null
+}
+
+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
+ : clampPercent(metric?.cpuUsedPct ?? 0)
+ const cpuValue = formatMetricValue(
+ formatPercent(cpuPercent),
+ formatCoreCount(metric?.cpuCount ?? 0)
+ )
+
+ const ramPercent = hovered
+ ? hovered.ramPercent
+ : metric
+ ? calculateRatioPercent(metric.memUsed, metric.memTotal)
+ : 0
+ const ramTotalGb = formatBytesToGb(metric?.memTotal ?? 0)
+ const ramValue = formatMetricValue(formatPercent(ramPercent), ramTotalGb)
+ const contextLabel = hovered
+ ? formatHoverTimestamp(hovered.timestampMs)
+ : null
+
+ return (
+
+
+ }
+ />
+
+ }
+ />
+
+
+ {contextLabel ? (
+
+ {contextLabel}
+
+ ) : null}
+
+
+ )
+}
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..dd62f71ca
--- /dev/null
+++ b/src/features/dashboard/sandbox/monitoring/components/monitoring-sandbox-metrics-chart.tsx
@@ -0,0 +1,862 @@
+'use client'
+
+import type {
+ EChartsOption,
+ MarkPointComponentOption,
+ SeriesOption,
+} from 'echarts'
+import { LineChart } from 'echarts/charts'
+import {
+ AxisPointerComponent,
+ BrushComponent,
+ GridComponent,
+ MarkPointComponent,
+} from 'echarts/components'
+import * as echarts from 'echarts/core'
+import { SVGRenderer } from 'echarts/renderers'
+import ReactEChartsCore from 'echarts-for-react/lib/core'
+import { useTheme } from 'next-themes'
+import {
+ memo,
+ type ReactNode,
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from 'react'
+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_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,
+ 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_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 { cn } from '@/lib/utils'
+import { calculateAxisMax } from '@/lib/utils/chart'
+import { formatAxisNumber } from '@/lib/utils/formatting'
+import type {
+ SandboxMetricsChartProps,
+ SandboxMetricsDataPoint,
+} from '../types/sandbox-metrics-chart'
+
+echarts.use([
+ LineChart,
+ GridComponent,
+ BrushComponent,
+ MarkPointComponent,
+ SVGRenderer,
+ AxisPointerComponent,
+])
+
+interface AxisPointerInfo {
+ value?: unknown
+}
+
+interface UpdateAxisPointerEventParams {
+ axesInfo?: AxisPointerInfo[]
+ xAxisInfo?: AxisPointerInfo[]
+ value?: unknown
+}
+
+interface BrushArea {
+ coordRange?: [unknown, unknown] | unknown[]
+}
+
+interface BrushEndEventParams {
+ areas?: BrushArea[]
+}
+
+interface CrosshairMarker {
+ key: string
+ xPx: number
+ yPx: number
+ valueContent: ReactNode
+ dotColor: string
+ placeValueOnRight: boolean
+ labelOffsetYPx: number
+}
+
+const SANDBOX_MONITORING_CHART_FG_VAR = '--fg'
+const SANDBOX_MONITORING_CHART_MARKER_RIGHT_THRESHOLD_PX = 86
+const SANDBOX_MONITORING_CHART_MARKER_OVERLAP_THRESHOLD_PX = 24
+const SANDBOX_MONITORING_CHART_MARKER_LABEL_VERTICAL_GAP_PX = 20
+
+function withOpacity(color: string, opacity: number): string {
+ const normalizedOpacity = Math.max(0, Math.min(1, opacity))
+ const hex = color.trim()
+
+ if (!hex.startsWith('#')) {
+ return color
+ }
+
+ const value = hex.slice(1)
+ const expanded =
+ value.length === 3
+ ? value
+ .split('')
+ .map((char) => `${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
+ }
+
+ if (typeof value === 'string') {
+ return Number(value)
+ }
+
+ return Number.NaN
+}
+
+function formatXAxisLabel(
+ value: number | string,
+ includeSeconds: boolean = false
+): 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 base = `${hours}:${minutes}`
+
+ if (!includeSeconds) {
+ return base
+ }
+
+ const seconds = date.getSeconds().toString().padStart(2, '0')
+
+ return `${base}:${seconds}`
+}
+
+function findLivePoint(
+ 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]
+ if (!point) {
+ continue
+ }
+
+ const [timestamp, value] = point
+ if (typeof value !== 'number' || !Number.isFinite(timestamp)) {
+ continue
+ }
+
+ if (timestamp > now) {
+ continue
+ }
+
+ if (timestamp < liveBoundary) {
+ return null
+ }
+
+ return {
+ x: timestamp,
+ y: value,
+ }
+ }
+
+ 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
+) {
+ 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({
+ series,
+ hoveredTimestampMs = null,
+ className,
+ showXAxisLabels = true,
+ yAxisMax,
+ yAxisFormatter = formatAxisNumber,
+ onHover,
+ onHoverEnd,
+ onBrushEnd,
+}: SandboxMetricsChartProps) {
+ const chartInstanceRef = useRef(null)
+ const [chartRevision, setChartRevision] = useState(0)
+ 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_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 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
+
+ const handleUpdateAxisPointer = useCallback(
+ (params: UpdateAxisPointerEventParams) => {
+ if (!onHover) {
+ return
+ }
+
+ const pointerValue =
+ params.axesInfo?.[0]?.value ??
+ params.xAxisInfo?.[0]?.value ??
+ params.value
+ const timestampMs = toNumericValue(pointerValue)
+ if (Number.isNaN(timestampMs)) {
+ return
+ }
+
+ const normalizedTimestampMs = Math.floor(timestampMs)
+ onHover(normalizedTimestampMs)
+ },
+ [onHover]
+ )
+
+ const clearAxisPointer = useCallback(() => {
+ chartInstanceRef.current?.dispatchAction({ type: 'hideTip' })
+ chartInstanceRef.current?.dispatchAction({
+ type: 'updateAxisPointer',
+ currTrigger: 'leave',
+ })
+ }, [])
+
+ const handleHoverLeave = useCallback(() => {
+ clearAxisPointer()
+ onHoverEnd?.()
+ }, [clearAxisPointer, onHoverEnd])
+
+ useEffect(() => {
+ if (hoveredTimestampMs !== null) {
+ return
+ }
+
+ clearAxisPointer()
+ }, [clearAxisPointer, hoveredTimestampMs])
+
+ const handleBrushEnd = useCallback(
+ (params: BrushEndEventParams) => {
+ const coordRange = params.areas?.[0]?.coordRange
+ if (!coordRange || coordRange.length !== 2 || !onBrushEnd) {
+ return
+ }
+
+ 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: [],
+ })
+ },
+ [onBrushEnd]
+ )
+
+ const handleChartReady = useCallback((chart: echarts.ECharts) => {
+ chartInstanceRef.current = chart
+ setChartRevision((v) => v + 1)
+
+ 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[1])
+ .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(line.data)
+ const shouldShowArea = line.showArea ?? false
+ 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 defaultAreaOpacity =
+ areaFromColor || areaToColor ? 1 : SANDBOX_MONITORING_CHART_AREA_OPACITY
+ const areaOpacity = normalizeOpacity(line.areaOpacity, defaultAreaOpacity)
+
+ const seriesItem: SeriesOption = {
+ id: line.id,
+ name: line.name,
+ type: 'line',
+ z: line.zIndex,
+ symbol: 'none',
+ showSymbol: false,
+ smooth: false,
+ emphasis: {
+ disabled: true,
+ },
+ areaStyle: shouldShowArea
+ ? {
+ opacity: areaOpacity,
+ color: areaFillColor,
+ }
+ : undefined,
+ lineStyle: {
+ width: SANDBOX_MONITORING_CHART_LINE_WIDTH,
+ color: resolvedLineColor,
+ },
+ connectNulls: false,
+ data: line.data,
+ }
+
+ 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: SANDBOX_MONITORING_CHART_GRID_TOP,
+ bottom: showXAxisLabels
+ ? SANDBOX_MONITORING_CHART_GRID_BOTTOM_WITH_X_AXIS
+ : SANDBOX_MONITORING_CHART_GRID_BOTTOM,
+ left: 36,
+ right: SANDBOX_MONITORING_CHART_GRID_RIGHT,
+ },
+ xAxis: {
+ type: 'time',
+ boundaryGap: [0, 0],
+ 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: (value: number | string) => formatXAxisLabel(value),
+ },
+ axisPointer: {
+ show: true,
+ type: 'line',
+ lineStyle: {
+ color: axisPointerColor,
+ type: 'solid',
+ width: SANDBOX_MONITORING_CHART_LINE_WIDTH,
+ },
+ snap: true,
+ label: {
+ show: false,
+ },
+ },
+ },
+ 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,
+ }
+ }, [
+ cssVars,
+ axisPointerColor,
+ fgTertiary,
+ fontMono,
+ series,
+ showXAxisLabels,
+ stroke,
+ yAxisFormatter,
+ yAxisMax,
+ ])
+
+ const crosshairMarkers = useMemo(() => {
+ void chartRevision
+
+ if (hoveredTimestampMs === null) {
+ return []
+ }
+
+ const chart = chartInstanceRef.current
+ if (!chart || chart.isDisposed()) {
+ 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)
+ }, [
+ chartRevision,
+ cssVars,
+ hoveredTimestampMs,
+ series,
+ stroke,
+ yAxisFormatter,
+ ])
+
+ const xAxisHoverBadge = useMemo(() => {
+ void chartRevision
+
+ if (!showXAxisLabels || hoveredTimestampMs === null) {
+ return null
+ }
+
+ const chart = chartInstanceRef.current
+ if (!chart || chart.isDisposed()) {
+ 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: formatXAxisLabel(hoveredTimestampMs, true),
+ }
+ }, [chartRevision, hoveredTimestampMs, showXAxisLabels])
+
+ const showOverlay = crosshairMarkers.length > 0 || xAxisHoverBadge !== null
+
+ return (
+
+
+ {showOverlay ? (
+
+ {crosshairMarkers.map((marker) => (
+
+
+
+ {marker.valueContent}
+
+
+ ))}
+
+ {xAxisHoverBadge ? (
+
+ {xAxisHoverBadge.label}
+
+ ) : null}
+
+ ) : null}
+
+ )
+}
+
+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..92c1b9340
--- /dev/null
+++ b/src/features/dashboard/sandbox/monitoring/components/monitoring-time-range-controls.tsx
@@ -0,0 +1,522 @@
+'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'
+import { Button } from '@/ui/primitives/button'
+import { TimeIcon } from '@/ui/primitives/icons'
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} 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 {
+ 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,
+ 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'
+import {
+ clampTimeframeToBounds,
+ 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()
+}
+
+const rangeLabelFormatter = new Intl.DateTimeFormat(
+ undefined,
+ SANDBOX_MONITORING_TIME_LABEL_FORMAT_OPTIONS
+)
+
+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
+}
+
+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,
+ isLiveUpdating,
+ onLiveChange,
+ onTimeRangeChange,
+ className,
+}: 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) => {
+ 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_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',
+ 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_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',
+ 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 startDate = new Date(timeframe.start)
+ const endDate = new Date(timeframe.end)
+ if (!isValidDate(startDate) || !isValidDate(endDate)) {
+ return '--'
+ }
+
+ return `${rangeLabelFormatter.format(startDate)} - ${rangeLabelFormatter.format(
+ endDate
+ )}`
+ }, [timeframe.end, timeframe.start])
+
+ useEffect(() => {
+ if (isOpen && lifecycle.isRunning) {
+ setPickerMaxDateMs(Date.now())
+ }
+ }, [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,
+ }
+ }
+
+ 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: nextEntries,
+ index: trimmedEntries.length,
+ }
+ })
+ }, [isLiveUpdating, timeframe.end, timeframe.start])
+
+ const pickerMaxDate = useMemo(
+ () =>
+ lifecycle.isRunning
+ ? new Date(pickerMaxDateMs)
+ : new Date(lifecycle.anchorEndMs),
+ [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()
+ onTimeRangeChange(start, end, {
+ isLiveUpdating: preset.isLiveUpdating,
+ })
+ setIsOpen(false)
+ },
+ [onTimeRangeChange]
+ )
+
+ const handleApply = useCallback(
+ (values: TimeRangeValues) => {
+ const timestamps = parseTimeRangeValuesToTimestamps(values)
+ if (!timestamps) {
+ return
+ }
+
+ const next = clampToLifecycle(timestamps.start, timestamps.end)
+
+ onTimeRangeChange(next.start, next.end, {
+ isLiveUpdating: false,
+ })
+ setIsOpen(false)
+ },
+ [clampToLifecycle, onTimeRangeChange]
+ )
+
+ const handleLiveToggle = useCallback(() => {
+ if (!lifecycle.isRunning) {
+ onLiveChange(false)
+ return
+ }
+
+ onLiveChange(!isLiveUpdating)
+ }, [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/components/monitoring-view.tsx b/src/features/dashboard/sandbox/monitoring/components/monitoring-view.tsx
new file mode 100644
index 000000000..05696d5a7
--- /dev/null
+++ b/src/features/dashboard/sandbox/monitoring/components/monitoring-view.tsx
@@ -0,0 +1,25 @@
+'use client'
+
+import LoadingLayout from '@/features/dashboard/loading-layout'
+import { useSandboxContext } from '@/features/dashboard/sandbox/context'
+import SandboxMetricsCharts from './monitoring-charts'
+
+interface SandboxMonitoringViewProps {
+ sandboxId: string
+}
+
+export default function SandboxMonitoringView({
+ sandboxId,
+}: SandboxMonitoringViewProps) {
+ const { isSandboxInfoLoading, sandboxInfo } = useSandboxContext()
+
+ if (isSandboxInfoLoading && !sandboxInfo) {
+ return
+ }
+
+ 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
new file mode 100644
index 000000000..2978f55da
--- /dev/null
+++ b/src/features/dashboard/sandbox/monitoring/state/use-sandbox-monitoring-controller.ts
@@ -0,0 +1,492 @@
+'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 {
+ SANDBOX_MONITORING_DEFAULT_RANGE_MS,
+ SANDBOX_MONITORING_LIVE_POLLING_MS,
+ SANDBOX_MONITORING_MAX_RANGE_MS,
+ 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 {
+ clampTimeframeToBounds,
+ getSandboxLifecycleBounds,
+ normalizeMonitoringTimeframe,
+ parseMonitoringQueryState,
+ type SandboxLifecycleBounds,
+} from '../utils/timeframe'
+
+interface SandboxMonitoringTimeframe {
+ start: number
+ end: number
+ duration: number
+}
+
+interface SandboxMonitoringControllerState {
+ sandboxId: string | null
+ timeframe: SandboxMonitoringTimeframe
+ isLiveUpdating: boolean
+ isInitialized: boolean
+}
+
+interface ApplyTimeframeOptions {
+ isLiveUpdating?: boolean
+}
+
+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,
+ }
+}
+
+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)
+ }
+
+ 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
+ }
+}
+
+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 [state, dispatch] = useReducer(
+ sandboxMonitoringControllerReducer,
+ undefined,
+ createInitialState
+ )
+ const stateRef = useRef(state)
+ 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 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) {
+ return null
+ }
+
+ return getSandboxLifecycleBounds({
+ startedAt: lifecycleStartedAt,
+ endAt: lifecycleEndAt ?? null,
+ stoppedAt: lifecycleStoppedAt ?? null,
+ state: lifecycleState,
+ })
+ }, [lifecycleEndAt, lifecycleStartedAt, lifecycleStoppedAt, lifecycleState])
+
+ useEffect(() => {
+ stateRef.current = state
+ }, [state])
+
+ const applyTimeframe = useCallback(
+ (start: number, end: number, options?: ApplyTimeframeOptions) => {
+ const currentState = stateRef.current
+ const now = Date.now()
+ const timeframe = resolveTimeframe(start, end, now, lifecycleBounds)
+ const requestedLiveUpdating =
+ 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: {
+ timeframe,
+ isLiveUpdating: nextLiveUpdating,
+ },
+ })
+ },
+ [lifecycleBounds]
+ )
+
+ const setLiveUpdating = useCallback(
+ (isLiveUpdating: boolean) => {
+ const currentState = stateRef.current
+
+ if (!isLiveUpdating) {
+ if (!currentState.isLiveUpdating) {
+ return
+ }
+
+ dispatch({
+ type: 'setLiveUpdating',
+ payload: { isLiveUpdating: false },
+ })
+
+ return
+ }
+
+ if (lifecycleBounds && !lifecycleBounds.isRunning) {
+ if (!currentState.isLiveUpdating) {
+ return
+ }
+
+ dispatch({
+ type: 'setLiveUpdating',
+ payload: { isLiveUpdating: false },
+ })
+
+ return
+ }
+
+ const now = Date.now()
+ const anchorEndMs = lifecycleBounds?.isRunning
+ ? now
+ : (lifecycleBounds?.anchorEndMs ?? now)
+
+ applyTimeframe(anchorEndMs - durationRef.current, anchorEndMs, {
+ isLiveUpdating: true,
+ })
+ },
+ [applyTimeframe, lifecycleBounds]
+ )
+
+ useEffect(() => {
+ durationRef.current = state.timeframe.duration
+ }, [state.timeframe.duration])
+
+ useEffect(() => {
+ const now = Date.now()
+ const currentState = stateRef.current
+ const hasExplicitRange =
+ queryState.start !== null && queryState.end !== null
+ const requestedLiveUpdating = queryState.live ?? true
+ 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({
+ 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 (!state.isInitialized) {
+ return
+ }
+
+ 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 (
+ queryLive === nextLive &&
+ (shouldPersistExplicitRange
+ ? queryStart === nextStart && queryEnd === nextEnd
+ : queryStart === null && queryEnd === null)
+ ) {
+ return
+ }
+
+ const nextParams = new URLSearchParams(searchParamsString)
+ 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,
+ })
+ }, [
+ pathname,
+ queryEnd,
+ queryLive,
+ queryStart,
+ router,
+ searchParamsString,
+ state.isInitialized,
+ state.isLiveUpdating,
+ state.timeframe.end,
+ state.timeframe.start,
+ ])
+
+ const queryKey = useMemo(
+ () =>
+ [
+ 'sandboxMonitoringMetrics',
+ team?.id ?? '',
+ sandboxId,
+ state.timeframe.start,
+ state.timeframe.end,
+ ] as const,
+ [sandboxId, state.timeframe.end, state.timeframe.start, team?.id]
+ )
+
+ const metricsQuery = useQuery({
+ 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 []
+ }
+
+ return trpcClient.sandbox.resourceMetrics.query({
+ teamIdOrSlug: team.id,
+ sandboxId,
+ startMs: state.timeframe.start,
+ endMs: state.timeframe.end,
+ })
+ },
+ })
+
+ return {
+ lifecycleBounds,
+ timeframe: state.timeframe,
+ metrics: metricsQuery.data ?? [],
+ isLiveUpdating: state.isLiveUpdating,
+ isRefetching: metricsQuery.isFetching,
+ 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
new file mode 100644
index 000000000..b6d661953
--- /dev/null
+++ b/src/features/dashboard/sandbox/monitoring/types/sandbox-metrics-chart.ts
@@ -0,0 +1,60 @@
+import type { ReactNode } from 'react'
+import type { SandboxMetric } from '@/server/api/models/sandboxes.models'
+
+export type SandboxMetricsDataPoint = [
+ timestampMs: number,
+ value: number | null,
+ markerValue?: number | null,
+]
+
+export interface SandboxMetricsMarkerValueFormatterInput {
+ value: number
+ markerValue: number | null
+ point: SandboxMetricsDataPoint
+}
+
+export interface SandboxMetricsSeries {
+ id: string
+ name: string
+ data: SandboxMetricsDataPoint[]
+ markerValueFormatter?: (
+ input: SandboxMetricsMarkerValueFormatterInput
+ ) => ReactNode
+ lineColorVar?: string
+ areaColorVar?: string
+ areaToColorVar?: string
+ showArea?: boolean
+ areaOpacity?: number
+ zIndex?: number
+}
+
+export interface SandboxMetricsChartProps {
+ series: SandboxMetricsSeries[]
+ hoveredTimestampMs?: number | null
+ className?: string
+ showXAxisLabels?: boolean
+ yAxisMax?: number
+ yAxisFormatter?: (value: number) => string
+ onHover?: (timestampMs: number) => void
+ 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 {
+ latestMetric: SandboxMetric | undefined
+ resourceSeries: SandboxMetricsSeries[]
+ diskSeries: SandboxMetricsSeries[]
+ resourceHoveredContext: MonitoringResourceHoveredContext | null
+ diskHoveredContext: MonitoringDiskHoveredContext | null
+}
diff --git a/src/features/dashboard/sandbox/monitoring/utils/chart-model.ts b/src/features/dashboard/sandbox/monitoring/utils/chart-model.ts
new file mode 100644
index 000000000..7f552d3bc
--- /dev/null
+++ b/src/features/dashboard/sandbox/monitoring/utils/chart-model.ts
@@ -0,0 +1,269 @@
+import { millisecondsInSecond } from 'date-fns/constants'
+import type { SandboxMetric } from '@/server/api/models/sandboxes.models'
+import type {
+ MonitoringChartModel,
+ SandboxMetricsDataPoint,
+ SandboxMetricsSeries,
+} from '../types/sandbox-metrics-chart'
+import {
+ 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_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
+ timestampMs: number
+ cpuPercent: number
+ ramPercent: number
+ diskPercent: number
+ ramUsedMb: number
+ diskUsedMb: number
+}
+
+interface BuildMonitoringChartModelOptions {
+ metrics: SandboxMetric[]
+ startMs: number
+ endMs: number
+ hoveredTimestampMs: number | null
+}
+
+function toPercent(used: number, total: number): number {
+ if (!total || total <= 0) {
+ return 0
+ }
+
+ return clampPercent((used / total) * 100)
+}
+
+function toMegabytes(value: number): number {
+ if (!Number.isFinite(value) || value < 0) {
+ return 0
+ }
+
+ return Math.round(value / (1024 * 1024))
+}
+
+function getMetricTimestampMs(metric: SandboxMetric): number | null {
+ const timestampMs = Math.floor(metric.timestampUnix * millisecondsInSecond)
+
+ if (!Number.isFinite(timestampMs)) {
+ return null
+ }
+
+ return timestampMs
+}
+
+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),
+ ramUsedMb: toMegabytes(metric.memUsed),
+ diskUsedMb: toMegabytes(metric.diskUsed),
+ }
+}
+
+function sortMetricsByTimestamp(
+ metrics: NormalizedSandboxMetric[]
+): NormalizedSandboxMetric[] {
+ return [...metrics].sort((a, b) => a.timestampMs - b.timestampMs)
+}
+
+function buildSeriesData(
+ metrics: NormalizedSandboxMetric[],
+ getValue: (metric: NormalizedSandboxMetric) => number,
+ getMarkerValue?: (metric: NormalizedSandboxMetric) => number | null
+): SandboxMetricsDataPoint[] {
+ return metrics.map((metric) => [
+ metric.timestampMs,
+ getValue(metric),
+ getMarkerValue ? getMarkerValue(metric) : null,
+ ])
+}
+
+function findClosestMetric(
+ metrics: NormalizedSandboxMetric[],
+ timestampMs: number
+): NormalizedSandboxMetric | null {
+ if (metrics.length === 0 || !Number.isFinite(timestampMs)) {
+ return null
+ }
+
+ let low = 0
+ let high = metrics.length - 1
+
+ while (low <= high) {
+ const middle = Math.floor((low + high) / 2)
+ const middleTimestamp = metrics[middle]?.timestampMs
+ if (middleTimestamp === undefined) {
+ break
+ }
+
+ if (middleTimestamp === timestampMs) {
+ return metrics[middle] ?? null
+ }
+
+ if (middleTimestamp < timestampMs) {
+ low = middle + 1
+ } else {
+ high = middle - 1
+ }
+ }
+
+ 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: NormalizedSandboxMetric[]
+): SandboxMetricsSeries[] {
+ return [
+ {
+ 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,
+ areaToColorVar: SANDBOX_MONITORING_CPU_AREA_TO_COLOR_VAR,
+ showArea: true,
+ areaOpacity: 0.3,
+ zIndex: 2,
+ data: buildSeriesData(metrics, (metric) => metric.cpuPercent),
+ },
+ {
+ 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,
+ areaToColorVar: SANDBOX_MONITORING_RAM_AREA_TO_COLOR_VAR,
+ showArea: true,
+ areaOpacity: 0.3,
+ zIndex: 1,
+ data: buildSeriesData(
+ metrics,
+ (metric) => metric.ramPercent,
+ (metric) => metric.ramUsedMb
+ ),
+ },
+ ]
+}
+
+function buildDiskSeries(
+ metrics: NormalizedSandboxMetric[]
+): SandboxMetricsSeries[] {
+ return [
+ {
+ id: SANDBOX_MONITORING_DISK_SERIES_ID,
+ name: SANDBOX_MONITORING_DISK_SERIES_LABEL,
+ 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,
+ (metric) => metric.diskPercent,
+ (metric) => metric.diskUsedMb
+ ),
+ },
+ ]
+}
+
+export function buildMonitoringChartModel({
+ metrics,
+ startMs,
+ endMs,
+ hoveredTimestampMs,
+}: BuildMonitoringChartModelOptions): MonitoringChartModel {
+ 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 {
+ latestMetric,
+ resourceSeries,
+ diskSeries,
+ resourceHoveredContext: null,
+ diskHoveredContext: null,
+ }
+ }
+
+ const hoveredMetric = findClosestMetric(normalizedMetrics, hoveredTimestampMs)
+ if (!hoveredMetric) {
+ return {
+ latestMetric,
+ resourceSeries,
+ diskSeries,
+ resourceHoveredContext: null,
+ diskHoveredContext: null,
+ }
+ }
+
+ return {
+ latestMetric,
+ resourceSeries,
+ diskSeries,
+ resourceHoveredContext: {
+ cpuPercent: hoveredMetric.cpuPercent,
+ ramPercent: hoveredMetric.ramPercent,
+ timestampMs: hoveredMetric.timestampMs,
+ },
+ diskHoveredContext: {
+ 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
new file mode 100644
index 000000000..81fbbc0b8
--- /dev/null
+++ b/src/features/dashboard/sandbox/monitoring/utils/constants.ts
@@ -0,0 +1,98 @@
+import {
+ millisecondsInDay,
+ millisecondsInHour,
+ millisecondsInMinute,
+ 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
+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'
+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_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'
+export const SANDBOX_MONITORING_FIRST_5_MINUTES_PRESET_ID = 'first-5m'
+export const SANDBOX_MONITORING_FIRST_5_MINUTES_PRESET_SHORTCUT = 'F5'
+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_5_MINUTES_PRESET_ID = 'last-5m'
+export const SANDBOX_MONITORING_LAST_5_MINUTES_PRESET_SHORTCUT = 'L5'
+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_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
+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_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 =
+ {
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit',
+ hour12: false,
+ }
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..3c77b67bb
--- /dev/null
+++ b/src/features/dashboard/sandbox/monitoring/utils/formatters.ts
@@ -0,0 +1,65 @@
+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_PERCENT_MAX,
+ 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 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 clampPercent((used / total) * 100)
+}
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..8e0189199
--- /dev/null
+++ b/src/features/dashboard/sandbox/monitoring/utils/timeframe.ts
@@ -0,0 +1,262 @@
+import type { SandboxDetailsDTO } from '@/server/api/models/sandboxes.models'
+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 interface SandboxLifecycleBounds {
+ startMs: number
+ anchorEndMs: number
+ isRunning: boolean
+}
+
+export function getSandboxLifecycleBounds(
+ sandboxInfo: Pick & {
+ stoppedAt?: string | null
+ },
+ now: number = Date.now()
+): SandboxLifecycleBounds | null {
+ const startMs = parseDateTimestampMs(sandboxInfo.startedAt)
+ const isRunning = sandboxInfo.state === 'running'
+
+ if (startMs === null) {
+ return null
+ }
+
+ const safeNow = clampToBounds(
+ now,
+ SANDBOX_MONITORING_MIN_TIMESTAMP_MS,
+ SANDBOX_MONITORING_MAX_TIMESTAMP_MS
+ )
+ const endMs =
+ parseDateTimestampMs(sandboxInfo.endAt) ??
+ parseDateTimestampMs(sandboxInfo.stoppedAt) ??
+ safeNow
+ const anchorEndMs = Math.min(safeNow, 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 (safeStart + 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/features/dashboard/usage/usage-time-range-controls.tsx b/src/features/dashboard/usage/usage-time-range-controls.tsx
index 10e5bf012..84bcaddd8 100644
--- a/src/features/dashboard/usage/usage-time-range-controls.tsx
+++ b/src/features/dashboard/usage/usage-time-range-controls.tsx
@@ -14,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 {
@@ -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/server/api/models/sandboxes.models.ts b/src/server/api/models/sandboxes.models.ts
index 35826dd6b..69d128e4e 100644
--- a/src/server/api/models/sandboxes.models.ts
+++ b/src/server/api/models/sandboxes.models.ts
@@ -43,6 +43,8 @@ export interface SandboxLogsDTO {
nextCursor: number | null
}
+export type SandboxMetric = InfraComponents['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 be16f717c..28cd8d847 100644
--- a/src/server/api/repositories/sandboxes.repository.ts
+++ b/src/server/api/repositories/sandboxes.repository.ts
@@ -152,7 +152,70 @@ export async function getSandboxDetails(
})
}
+// 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)
+ }
+
+ return result.data
+}
+
export const sandboxesRepo = {
getSandboxLogs,
getSandboxDetails,
+ getSandboxMetrics,
}
diff --git a/src/server/api/routers/sandbox.ts b/src/server/api/routers/sandbox.ts
index b6ed87c0a..92b2d540a 100644
--- a/src/server/api/routers/sandbox.ts
+++ b/src/server/api/routers/sandbox.ts
@@ -1,4 +1,7 @@
+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 {
mapApiSandboxRecordToDTO,
@@ -17,7 +20,7 @@ export const sandboxRouter = createTRPCRouter({
details: protectedTeamProcedure
.input(
z.object({
- sandboxId: z.string(),
+ sandboxId: SandboxIdSchema,
})
)
.query(async ({ ctx, input }) => {
@@ -41,7 +44,7 @@ export const sandboxRouter = createTRPCRouter({
logsBackwardsReversed: protectedTeamProcedure
.input(
z.object({
- sandboxId: z.string(),
+ sandboxId: SandboxIdSchema,
cursor: z.number().optional(),
level: z.enum(['debug', 'info', 'warn', 'error']).optional(),
search: z.string().max(256).optional(),
@@ -83,7 +86,7 @@ export const sandboxRouter = createTRPCRouter({
logsForward: protectedTeamProcedure
.input(
z.object({
- sandboxId: z.string(),
+ sandboxId: SandboxIdSchema,
cursor: z.number().optional(),
level: z.enum(['debug', 'info', 'warn', 'error']).optional(),
search: z.string().max(256).optional(),
@@ -121,5 +124,48 @@ export const sandboxRouter = createTRPCRouter({
return result
}),
+ resourceMetrics: protectedTeamProcedure
+ .input(
+ 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
+ const { sandboxId } = input
+ const { startMs, endMs } = input
+
+ const metrics = await sandboxesRepo.getSandboxMetrics(
+ session.access_token,
+ teamId,
+ sandboxId,
+ {
+ startUnixMs: startMs,
+ endUnixMs: endMs,
+ }
+ )
+
+ return metrics
+ }),
+
// MUTATIONS
})
diff --git a/src/styles/theme.css b/src/styles/theme.css
index c58a72729..2058328ae 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: #3b1d05;
+ --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(59, 29, 5, 0.26); /* Dark orange 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.logic.ts b/src/ui/time-range-picker.logic.ts
new file mode 100644
index 000000000..36f571791
--- /dev/null
+++ b/src/ui/time-range-picker.logic.ts
@@ -0,0 +1,358 @@
+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 36dec25cb..e57b0a9b4 100644
--- a/src/ui/time-range-picker.tsx
+++ b/src/ui/time-range-picker.tsx
@@ -1,20 +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 {
@@ -26,38 +18,29 @@ import {
FormMessage,
} from './primitives/form'
import { TimeInput } from './time-input'
+import {
+ createTimeRangeSchema,
+ normalizeTimeRangeValues,
+ type TimeRangePickerBounds,
+ type TimeRangeValues,
+} from './time-range-picker.logic'
-export interface TimeRangeValues {
- startDate: string
- startTime: string | null
- endDate: string
- endTime: string | null
-}
+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
}
export function TimeRangePicker({
startDateTime,
endDateTime,
- minDate,
- maxDate,
+ bounds,
onApply,
onChange,
className,
@@ -65,6 +48,9 @@ export function TimeRangePicker({
}: TimeRangePickerProps) {
'use no memo'
+ const minBoundMs = bounds?.min?.getTime()
+ const maxBoundMs = bounds?.max?.getTime()
+
const startParts = useMemo(
() => parseDateTimeComponents(startDateTime),
[startDateTime]
@@ -74,134 +60,63 @@ export function TimeRangePicker({
[endDateTime]
)
- // 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'),
- 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
- }
+ const calendarMinDate = useMemo(
+ () =>
+ minBoundMs !== undefined ? startOfDay(new Date(minBoundMs)) : undefined,
+ [minBoundMs]
+ )
- // validate against min date
- if (minDateValue && startTimestamp < minDateValue.getTime()) {
- ctx.addIssue({
- code: z.ZodIssueCode.custom,
- message: `Start date cannot be before ${minDateValue.toLocaleDateString()}`,
- path: ['startDate'],
- })
- }
+ const calendarMaxDate = useMemo(
+ () =>
+ maxBoundMs !== undefined ? endOfDay(new Date(maxBoundMs)) : undefined,
+ [maxBoundMs]
+ )
- // 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'],
- })
- }
- })
- }, [minDate, maxDate, hideTime])
+ const schema = useMemo(() => {
+ 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)
@@ -211,11 +126,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 (