diff --git a/src/commands/devbox/list.tsx b/src/commands/devbox/list.tsx index 15b3535c..88b4c772 100644 --- a/src/commands/devbox/list.tsx +++ b/src/commands/devbox/list.tsx @@ -8,6 +8,7 @@ import { SpinnerComponent } from "../../components/Spinner.js"; import { ErrorMessage } from "../../components/ErrorMessage.js"; import { getStatusDisplay } from "../../components/StatusBadge.js"; import { Breadcrumb } from "../../components/Breadcrumb.js"; +import type { Column } from "../../components/Table.js"; import { Table, createTextColumn } from "../../components/Table.js"; import { formatTimeAgo } from "../../components/ResourceListView.js"; import { output, outputError } from "../../utils/output.js"; @@ -202,12 +203,36 @@ const ListDevboxesUI = ({ const ABSOLUTE_MAX_NAME = 80; const ABSOLUTE_MAX_ID = 50; - const columns = [ + const columns: Column[] = [ + // Status icon column - visual indicator for quick scanning + { + key: "statusIcon", + label: "", + width: statusIconWidth, + render: (devbox: Devbox, _index: number, isSelected: boolean) => { + const statusDisplay = getStatusDisplay(devbox?.status); + const statusColor = + statusDisplay.color === colors.textDim + ? colors.info + : statusDisplay.color; + return ( + + {statusDisplay.icon}{" "} + + ); + }, + }, createTextColumn( "name", "Name", (devbox: Devbox) => { - const name = String(devbox?.name || devbox?.id || ""); + const name = String(devbox?.name || ""); const safeMax = Math.min(nameWidth || 15, ABSOLUTE_MAX_NAME); return name.length > safeMax ? name.substring(0, Math.max(1, safeMax - 3)) + "..." @@ -235,19 +260,33 @@ const ListDevboxesUI = ({ bold: false, }, ), - createTextColumn( - "status", - "Status", - (devbox: Devbox) => { + // Status text column with color matching the icon + { + key: "status", + label: "Status", + width: statusTextWidth, + render: (devbox: Devbox, _index: number, isSelected: boolean) => { const statusDisplay = getStatusDisplay(devbox?.status); - const text = String(statusDisplay?.text || "-"); - return text.length > 20 ? text.substring(0, 17) + "..." : text; - }, - { - width: statusTextWidth, - dimColor: false, + const statusColor = + statusDisplay.color === colors.textDim + ? colors.info + : statusDisplay.color; + const safeWidth = Math.max(1, statusTextWidth); + const truncated = statusDisplay.text.slice(0, safeWidth); + const padded = truncated.padEnd(safeWidth, " "); + return ( + + {padded} + + ); }, - ), + }, createTextColumn( "created", "Created", @@ -308,6 +347,7 @@ const ListDevboxesUI = ({ return columns; }, [ + statusIconWidth, nameWidth, idWidth, statusTextWidth, diff --git a/src/components/DevboxCreatePage.tsx b/src/components/DevboxCreatePage.tsx index 3482803c..0b33dfca 100644 --- a/src/components/DevboxCreatePage.tsx +++ b/src/components/DevboxCreatePage.tsx @@ -66,12 +66,12 @@ export const DevboxCreatePage = ({ const [currentField, setCurrentField] = React.useState("create"); const [formData, setFormData] = React.useState({ name: "", - architecture: "arm64", + architecture: "x86_64", resource_size: "SMALL", custom_cpu: "", custom_memory: "", custom_disk: "", - keep_alive: "3600", + keep_alive: "3600", // 1 hour metadata: {}, blueprint_id: initialBlueprintId || "", snapshot_id: initialSnapshotId || "", @@ -632,6 +632,7 @@ export const DevboxCreatePage = ({ )} diff --git a/src/components/DevboxDetailPage.tsx b/src/components/DevboxDetailPage.tsx index 5b04a7c5..829f64e5 100644 --- a/src/components/DevboxDetailPage.tsx +++ b/src/components/DevboxDetailPage.tsx @@ -3,7 +3,6 @@ import { Box, Text, useInput } from "ink"; import figures from "figures"; import { Header } from "./Header.js"; import { StatusBadge } from "./StatusBadge.js"; -import { MetadataDisplay } from "./MetadataDisplay.js"; import { Breadcrumb } from "./Breadcrumb.js"; import { DevboxActionsMenu } from "./DevboxActionsMenu.js"; import { StateHistory } from "./StateHistory.js"; @@ -41,6 +40,12 @@ const formatTimeAgo = (timestamp: number): string => { return `${years}y ago`; }; +// Truncate long strings to prevent layout issues +const truncateString = (str: string, maxLength: number): string => { + if (str.length <= maxLength) return str; + return str.substring(0, maxLength - 3) + "..."; +}; + export const DevboxDetailPage = ({ devbox: initialDevbox, onBack, @@ -548,7 +553,7 @@ export const DevboxDetailPage = ({ lines.push( {" "} - Shutdown Reason: {selectedDevbox.shutdown_reason} + Shutdown Initiator: {selectedDevbox.shutdown_reason} , ); } @@ -723,133 +728,250 @@ export const DevboxDetailPage = ({ {/* Main info section */} - + - {selectedDevbox.name || selectedDevbox.id} + {truncateString( + selectedDevbox.name || selectedDevbox.id, + Math.max(20, detailViewport.terminalWidth - 35), + )} - - - • {selectedDevbox.id} + {/* Only show ID separately if there's a name */} + {selectedDevbox.name && ( + • {selectedDevbox.id} + )} - - {formattedCreateTime} - - - {" "} - ({createTimeAgo}) - - - {uptime !== null && selectedDevbox.status === "running" && ( - + + {uptime !== null && selectedDevbox.status === "running" && ( - Uptime:{" "} + {" "} + • Uptime:{" "} {uptime < 60 ? `${uptime}m` : `${Math.floor(uptime / 60)}h ${uptime % 60}m`} - {lp?.keep_alive_time_seconds && ( + )} + {selectedDevbox.status !== "running" && + selectedDevbox.create_time_ms && + selectedDevbox.end_time_ms && ( {" "} - • Keep-alive: {Math.floor(lp.keep_alive_time_seconds / 60)}m + • Ran for:{" "} + {(() => { + const runtime = Math.floor( + (selectedDevbox.end_time_ms - + selectedDevbox.create_time_ms) / + 1000, + ); + if (runtime < 60) return `${runtime}s`; + const mins = Math.floor(runtime / 60); + if (mins < 60) return `${mins}m ${runtime % 60}s`; + const hours = Math.floor(mins / 60); + return `${hours}h ${mins % 60}m`; + })()} )} - - )} + - {/* Resources + capabilities + source in one row */} - - {/* Resources */} - {(lp?.resource_size_request || - lp?.custom_cpu_cores || - lp?.custom_gb_memory || - lp?.custom_disk_size || - lp?.architecture) && ( - - - {figures.squareSmallFilled} Resources - - - {lp?.resource_size_request && `${lp.resource_size_request}`} - {lp?.architecture && ` • ${lp.architecture}`} - {lp?.custom_cpu_cores && ` • ${lp.custom_cpu_cores}VCPU`} - {lp?.custom_gb_memory && ` • ${lp.custom_gb_memory}GB RAM`} - {lp?.custom_disk_size && ` • ${lp.custom_disk_size}GB DISC`} - - - )} - - {/* Capabilities */} - {hasCapabilities && ( - - - {figures.tick} Capabilities - - - {selectedDevbox.capabilities - .filter((c: string) => c !== "unknown") - .join(", ")} - - - )} - - {/* Source */} - {(selectedDevbox.blueprint_id || selectedDevbox.snapshot_id) && ( - - - {figures.circleFilled} Source - - {selectedDevbox.blueprint_id && ( - <> - BP: - - {selectedDevbox.blueprint_id} + {/* Details section */} + + + {figures.squareSmallFilled} Details + + + {/* Created / Ended */} + {selectedDevbox.create_time_ms && ( + + Created + {formattedCreateTime} + {selectedDevbox.end_time_ms ? ( + + {" "} + {figures.arrowRight}{" "} + {new Date(selectedDevbox.end_time_ms).toLocaleString()} - - )} - {selectedDevbox.snapshot_id && ( - <> - Snap: - {selectedDevbox.snapshot_id} - - )} - - )} + ) : ( + ({createTimeAgo}) + )} + + )} + + {/* Resources */} + {(lp?.resource_size_request || + lp?.custom_cpu_cores || + lp?.custom_gb_memory || + lp?.custom_disk_size || + lp?.architecture) && ( + + Resources + + {[ + lp?.resource_size_request, + lp?.architecture, + lp?.custom_cpu_cores && `${lp.custom_cpu_cores}VCPU`, + lp?.custom_gb_memory && `${lp.custom_gb_memory}GB RAM`, + lp?.custom_disk_size && `${lp.custom_disk_size}GB DISC`, + ] + .filter(Boolean) + .join(" • ")} + + + )} + + {/* Lifetime and User on same line */} + {(lp?.keep_alive_time_seconds || lp?.user_parameters) && ( + + {lp?.keep_alive_time_seconds && ( + <> + Lifetime + + {lp.keep_alive_time_seconds < 3600 + ? `${Math.floor(lp.keep_alive_time_seconds / 60)}m` + : `${Math.floor(lp.keep_alive_time_seconds / 3600)}h ${Math.floor((lp.keep_alive_time_seconds % 3600) / 60)}m`} + + {uptime !== null && selectedDevbox.status === "running" && ( + + {" "} + •{" "} + {(() => { + const maxLifetimeMinutes = Math.floor( + lp.keep_alive_time_seconds / 60, + ); + const remainingMinutes = maxLifetimeMinutes - uptime; + if (remainingMinutes <= 0) { + return Expired; + } else if (remainingMinutes < 5) { + return ( + + {remainingMinutes}m remaining + + ); + } else if (remainingMinutes < 15) { + return ( + + {remainingMinutes}m remaining + + ); + } else if (remainingMinutes < 60) { + return ( + + {remainingMinutes}m remaining + + ); + } else { + const hours = Math.floor(remainingMinutes / 60); + const mins = remainingMinutes % 60; + return ( + + {hours}h {mins}m remaining + + ); + } + })()} + + )} + {lp?.user_parameters && ( + + )} + + )} + {lp?.user_parameters && ( + <> + {!lp?.keep_alive_time_seconds && ( + User + )} + User: + + {lp.user_parameters.username || "default"} + {lp.user_parameters.uid != null && + lp.user_parameters.uid !== 0 && + ` (UID: ${lp.user_parameters.uid})`} + + + )} + + )} + + {/* Source */} + {(selectedDevbox.blueprint_id || selectedDevbox.snapshot_id) && ( + + Source + + {selectedDevbox.blueprint_id || selectedDevbox.snapshot_id} + + + )} + + {/* Initiator */} + {selectedDevbox.initiator_id && ( + + Initiator + + {selectedDevbox.initiator_id} + + + )} + + {/* Capabilities */} + {hasCapabilities && ( + + Capabilities + + {selectedDevbox.capabilities + .filter((c: string) => c !== "unknown") + .join(", ")} + + + )} + - {/* Metadata */} + {/* Metadata section */} {selectedDevbox.metadata && Object.keys(selectedDevbox.metadata).length > 0 && ( - - + + + {figures.identical} Metadata + + + {Object.entries(selectedDevbox.metadata).map(([key, value]) => ( + + {key} + : + + {value as string} + + + ))} + )} {/* Failure */} {selectedDevbox.failure_reason && ( - + - {figures.cross}{" "} - - - {selectedDevbox.failure_reason} + {figures.cross} Error + + {selectedDevbox.failure_reason} + )} {/* State History */} - + - {/* Operations - inline display */} + {/* Actions section */} {figures.play} Actions - + {operations.map((op, index) => { const isSelected = index === selectedOperation; return ( diff --git a/src/components/MetadataDisplay.tsx b/src/components/MetadataDisplay.tsx index eb57b904..3c6d625b 100644 --- a/src/components/MetadataDisplay.tsx +++ b/src/components/MetadataDisplay.tsx @@ -8,6 +8,7 @@ interface MetadataDisplayProps { title?: string; showBorder?: boolean; selectedKey?: string; + compact?: boolean; } const renderKeyValueBadge = (keyText: string, value: string, color: string) => ( @@ -15,11 +16,27 @@ const renderKeyValueBadge = (keyText: string, value: string, color: string) => ( {keyText} - : + = {value} ); +const renderCompactKeyValue = ( + keyText: string, + value: string, + color: string, + isLast: boolean, +) => ( + + + {keyText} + + = + {value} + {!isLast && · } + +); + // Generate color for each key based on hash const getColorForKey = (key: string, index: number): string => { const colorList = [ @@ -38,6 +55,7 @@ export const MetadataDisplay = ({ title = "Metadata", showBorder = false, selectedKey, + compact = false, }: MetadataDisplayProps) => { const entries = Object.entries(metadata); @@ -45,6 +63,40 @@ export const MetadataDisplay = ({ return null; } + if (compact) { + return ( + + {title && ( + + {figures.identical} {title} + + )} + + {entries.map(([key, value], index) => { + const color = getColorForKey(key, index); + const isSelected = selectedKey === key; + const isLast = index === entries.length - 1; + return ( + + {isSelected && ( + + {figures.pointer} + + )} + {renderCompactKeyValue( + key, + value as string, + isSelected ? colors.primary : color, + isLast, + )} + + ); + })} + + + ); + } + const content = ( {title && ( diff --git a/src/components/ResourceListView.tsx b/src/components/ResourceListView.tsx index ac7457e9..42a3ea00 100644 --- a/src/components/ResourceListView.tsx +++ b/src/components/ResourceListView.tsx @@ -10,26 +10,44 @@ import { colors } from "../utils/theme.js"; import { useViewportHeight } from "../hooks/useViewportHeight.js"; import { useExitOnCtrlC } from "../hooks/useExitOnCtrlC.js"; -// Format time ago in a succinct way +// Format time ago - concise with ISO-style date for older items export const formatTimeAgo = (timestamp: number): string => { const seconds = Math.floor((Date.now() - timestamp) / 1000); + const date = new Date(timestamp); + const now = new Date(); - if (seconds < 60) return `${seconds}s ago`; + // Format time as HH:MM:SS (24h) + const time = date.toTimeString().slice(0, 8); + + // Less than 1 minute + if (seconds < 60) return `${time} (${seconds}s ago)`; const minutes = Math.floor(seconds / 60); - if (minutes < 60) return `${minutes}m ago`; + // Less than 1 hour + if (minutes < 60) return `${time} (${minutes}m ago)`; const hours = Math.floor(minutes / 60); - if (hours < 24) return `${hours}h ago`; + // Less than 24 hours - show time + relative + if (hours < 24) return `${time} (${hours}hr ago)`; const days = Math.floor(hours / 24); - if (days < 30) return `${days}d ago`; + const sameYear = date.getFullYear() === now.getFullYear(); + + // Format date parts + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + const year = date.getFullYear(); - const months = Math.floor(days / 30); - if (months < 12) return `${months}mo ago`; + // Date format: MM-DD or YYYY-MM-DD if different year + const dateStr = `${month}-${day}`; + + // 1-7 days - show date + time + relative + if (days <= 7) { + return `${dateStr} ${time} (${days}d)`; + } - const years = Math.floor(months / 12); - return `${years}y ago`; + // More than 7 days - just date + time, no relative + return `${dateStr} ${time}`; }; export interface ResourceListConfig { diff --git a/src/components/StateHistory.tsx b/src/components/StateHistory.tsx index 54498e35..66c507f9 100644 --- a/src/components/StateHistory.tsx +++ b/src/components/StateHistory.tsx @@ -2,11 +2,48 @@ import React from "react"; import { Box, Text } from "ink"; import figures from "figures"; import { colors } from "../utils/theme.js"; +import { getStatusDisplay } from "./StatusBadge.js"; +import type { DevboxView } from "@runloop/api-client/resources/devboxes/devboxes"; + +type DevboxStatus = DevboxView["status"]; +type StateTransition = DevboxView.StateTransition; interface StateHistoryProps { - stateTransitions?: any; + stateTransitions?: StateTransition[]; + shutdownReason?: string; } +// Format shutdown reason into human-readable text +const formatShutdownReason = (reason: string): string => { + switch (reason) { + case "api_shutdown": + return "API call"; + case "idle_timeout": + return "Idle timeout"; + case "keep_alive_timeout": + return "Max lifetime expired"; + case "max_lifetime": + case "max_lifetime_exceeded": + return "Max lifetime exceeded"; + case "user_initiated": + return "User initiated"; + case "system_maintenance": + return "System maintenance"; + case "resource_limit": + return "Resource limits"; + case "entrypoint_exit": + return "Entrypoint exited"; + case "idle": + return "Idle"; + case "error": + case "failure": + return "Error"; + default: + // Convert snake_case to readable text + return reason.replace(/_/g, " "); + } +}; + // Format time ago in a succinct way const formatTimeAgo = (timestamp: number): string => { const seconds = Math.floor((Date.now() - timestamp) / 1000); @@ -47,36 +84,40 @@ const formatDuration = (milliseconds: number): string => { // Capitalize first letter of a string const capitalize = (str: string) => str.charAt(0).toUpperCase() + str.slice(1); -export const StateHistory = ({ stateTransitions }: StateHistoryProps) => { - if ( - !stateTransitions || - !Array.isArray(stateTransitions) || - stateTransitions.length === 0 - ) { +// Terminal states that don't need duration shown (no new state coming) +const TERMINAL_STATES: DevboxStatus[] = ["shutdown", "failure"]; + +export const StateHistory = ({ + stateTransitions, + shutdownReason, +}: StateHistoryProps) => { + if (!stateTransitions || stateTransitions.length === 0) { return null; } - // Get last 3 transitions (most recent first) - const lastThree = ( - stateTransitions as Array<{ - status: string; - transition_time_ms?: number; - }> - ) - .slice(-3) - .reverse() + // Check if there are more than 5 transitions + const totalTransitions = stateTransitions.length; + const hasMore = totalTransitions > 5; + + // Get last 5 transitions (oldest first - chronological order) + const lastFive = stateTransitions + .slice(-5) .map((transition, idx, arr) => { - const transitionTime = transition.transition_time_ms; - // Calculate duration: time until next transition, or until now if it's the current state + const transitionTime = transition.transition_time_ms as + | number + | undefined; + // Calculate duration: time until next transition, or until now if it's the last state let duration = 0; if (transitionTime) { - if (idx === 0) { + if (idx === arr.length - 1) { // Most recent state - duration is from transition time to now duration = Date.now() - transitionTime; } else { - // Previous state - duration is from this transition to the next one - const nextTransition = arr[idx - 1]; - const nextTransitionTime = nextTransition.transition_time_ms; + // Earlier state - duration is from this transition to the next one + const nextTransition = arr[idx + 1]; + const nextTransitionTime = nextTransition.transition_time_ms as + | number + | undefined; if (nextTransitionTime) { duration = nextTransitionTime - transitionTime; } @@ -90,41 +131,72 @@ export const StateHistory = ({ stateTransitions }: StateHistoryProps) => { }) .filter((state) => state.transitionTime); // Only show states with valid timestamps - if (lastThree.length === 0) { + if (lastFive.length === 0) { return null; } return ( - - - {figures.circleFilled} State History + + + {figures.info} State History + {hasMore && ( + + {" "} + ({totalTransitions - 5} earlier) + + )} - - {lastThree.map((state, idx) => ( - - - {capitalize(state.status)} + + {lastFive.map((state, idx) => { + const statusDisplay = getStatusDisplay(state.status || ""); + const isLastState = idx === lastFive.length - 1; + const isTerminalState = TERMINAL_STATES.includes( + state.status as DevboxStatus, + ); + const showDuration = + state.duration > 0 && !(isLastState && isTerminalState); + const isShutdownState = state.status === "shutdown"; + + return ( + + {statusDisplay.icon} + + {capitalize(state.status || "unknown")} + {state.transitionTime && ( <> - {" "} - at {new Date(state.transitionTime).toLocaleString()}{" "} - - ({formatTimeAgo(state.transitionTime)}) + + {" "} + at {new Date(state.transitionTime).toLocaleString()}{" "} + + ({formatTimeAgo(state.transitionTime)}) + + {showDuration && ( + <> + {" "} + • Duration:{" "} + + {formatDuration(state.duration)} + + + )} - {state.duration > 0 && ( + {isShutdownState && shutdownReason && ( <> - {" "} - • Duration:{" "} - - {formatDuration(state.duration)} + due to + + {formatShutdownReason(shutdownReason)} )} )} - - - ))} + + ); + })} ); diff --git a/src/components/StatusBadge.tsx b/src/components/StatusBadge.tsx index c2d9e748..c1fecd6f 100644 --- a/src/components/StatusBadge.tsx +++ b/src/components/StatusBadge.tsx @@ -6,12 +6,17 @@ import { colors } from "../utils/theme.js"; interface StatusBadgeProps { status: string; showText?: boolean; + /** Show full human-readable text instead of truncated table column text */ + fullText?: boolean; } export interface StatusDisplay { icon: string; color: string; + /** Truncated/padded text for table columns (10 chars) */ text: string; + /** Full human-readable status text */ + label: string; } export const getStatusDisplay = (status: string): StatusDisplay => { @@ -20,90 +25,131 @@ export const getStatusDisplay = (status: string): StatusDisplay => { icon: figures.questionMarkPrefix, color: colors.textDim, text: "UNKNOWN ", + label: "Unknown", }; } switch (status) { + // === ACTIVE STATE === case "running": return { icon: figures.circleFilled, color: colors.success, text: "RUNNING ", + label: "Running", }; + + // === STARTING UP (transitioning to active) === case "provisioning": return { - icon: figures.ellipsis, + icon: figures.arrowUp, color: colors.warning, text: "PROVISION ", + label: "Provisioning", }; case "initializing": return { - icon: figures.ellipsis, + icon: figures.arrowUp, color: colors.primary, text: "INITIALIZE", + label: "Initializing", + }; + case "resuming": + return { + icon: figures.arrowUp, + color: colors.primary, + text: "RESUMING ", + label: "Resuming", + }; + + // === SHUTTING DOWN (transitioning to inactive) === + case "suspending": + return { + icon: figures.arrowDown, + color: colors.warning, + text: "SUSPENDING", + label: "Suspending", }; + + // === INACTIVE STATES === case "suspended": return { icon: figures.circleDotted, color: colors.warning, text: "SUSPENDED ", + label: "Suspended", }; - case "failure": - return { icon: figures.cross, color: colors.error, text: "FAILED " }; case "shutdown": return { icon: figures.circle, color: colors.textDim, text: "SHUTDOWN ", + label: "Shutdown", }; - case "resuming": + + // === ERROR STATES === + case "failure": return { - icon: figures.ellipsis, - color: colors.primary, - text: "RESUMING ", + icon: figures.cross, + color: colors.error, + text: "FAILED ", + label: "Failed", }; - case "suspending": + case "build_failed": + case "failed": return { - icon: figures.ellipsis, - color: colors.warning, - text: "SUSPENDING", + icon: figures.cross, + color: colors.error, + text: "FAILED ", + label: "Failed", }; + + // === BUILD STATES (for blueprints) === case "ready": return { - icon: figures.bullet, + icon: figures.tick, color: colors.success, text: "READY ", + label: "Ready", }; case "build_complete": case "building_complete": return { - icon: figures.bullet, + icon: figures.tick, color: colors.success, text: "COMPLETE ", + label: "Build Complete", }; case "building": return { - icon: figures.ellipsis, + icon: figures.arrowUp, color: colors.warning, text: "BUILDING ", + label: "Building: In Progress", }; - case "build_failed": - case "failed": - return { icon: figures.cross, color: colors.error, text: "FAILED " }; + default: // Truncate and pad any unknown status to 10 chars to match column width const truncated = status.toUpperCase().slice(0, 10); const padded = truncated.padEnd(10, " "); + // Capitalize first letter for label + const label = status.charAt(0).toUpperCase() + status.slice(1); return { icon: figures.questionMarkPrefix, color: colors.textDim, text: padded, + label: label, }; } }; -export const StatusBadge = ({ status, showText = true }: StatusBadgeProps) => { +export const StatusBadge = ({ + status, + showText = true, + fullText = false, +}: StatusBadgeProps) => { const statusDisplay = getStatusDisplay(status); + const displayText = fullText ? statusDisplay.label : statusDisplay.text; return ( <> @@ -111,7 +157,7 @@ export const StatusBadge = ({ status, showText = true }: StatusBadgeProps) => { {showText && ( <> - {statusDisplay.text} + {displayText} )} diff --git a/tests/__tests__/components/DevboxCreatePage.test.tsx b/tests/__tests__/components/DevboxCreatePage.test.tsx index 1bdb246d..1dce46f3 100644 --- a/tests/__tests__/components/DevboxCreatePage.test.tsx +++ b/tests/__tests__/components/DevboxCreatePage.test.tsx @@ -45,7 +45,7 @@ describe('DevboxCreatePage', () => { const { lastFrame } = render( {}} /> ); - expect(lastFrame()).toContain('arm64'); + expect(lastFrame()).toContain('x86_64'); }); it('displays default resource size value', () => { diff --git a/tests/__tests__/components/DevboxDetailPage.test.tsx b/tests/__tests__/components/DevboxDetailPage.test.tsx index 8bfb4ad0..7dfd759a 100644 --- a/tests/__tests__/components/DevboxDetailPage.test.tsx +++ b/tests/__tests__/components/DevboxDetailPage.test.tsx @@ -55,7 +55,7 @@ describe('DevboxDetailPage', () => { onBack={() => {}} /> ); - expect(lastFrame()).toContain('RUNNING'); + expect(lastFrame()).toContain('Running'); }); it('shows Actions section', () => { diff --git a/tests/__tests__/components/ResourceListView.test.tsx b/tests/__tests__/components/ResourceListView.test.tsx index d202acfa..95ab0b75 100644 --- a/tests/__tests__/components/ResourceListView.test.tsx +++ b/tests/__tests__/components/ResourceListView.test.tsx @@ -102,31 +102,43 @@ describe("ResourceListView", () => { describe("formatTimeAgo", () => { it("formats seconds ago", () => { const timestamp = Date.now() - 30 * 1000; - expect(formatTimeAgo(timestamp)).toBe("30s ago"); + const result = formatTimeAgo(timestamp); + // New format includes time + relative: "HH:MM:SS (30s ago)" + expect(result).toContain("30s ago"); }); it("formats minutes ago", () => { const timestamp = Date.now() - 5 * 60 * 1000; - expect(formatTimeAgo(timestamp)).toBe("5m ago"); + const result = formatTimeAgo(timestamp); + // New format includes time + relative: "HH:MM:SS (5m ago)" + expect(result).toContain("5m ago"); }); it("formats hours ago", () => { const timestamp = Date.now() - 3 * 60 * 60 * 1000; - expect(formatTimeAgo(timestamp)).toBe("3h ago"); + const result = formatTimeAgo(timestamp); + // New format includes time + relative: "HH:MM:SS (3hr ago)" + expect(result).toContain("3hr ago"); }); it("formats days ago", () => { const timestamp = Date.now() - 7 * 24 * 60 * 60 * 1000; - expect(formatTimeAgo(timestamp)).toBe("7d ago"); + const result = formatTimeAgo(timestamp); + // New format includes date + time + relative: "MM-DD HH:MM:SS (7d)" + expect(result).toMatch(/\d{2}-\d{2}.*\(7d\)/); }); - it("formats months ago", () => { + it("formats older dates without relative", () => { const timestamp = Date.now() - 60 * 24 * 60 * 60 * 1000; - expect(formatTimeAgo(timestamp)).toBe("2mo ago"); + const result = formatTimeAgo(timestamp); + // More than 7 days just shows date + time, no relative + expect(result).toMatch(/\d{2}-\d{2} \d{2}:\d{2}:\d{2}/); }); - it("formats years ago", () => { + it("formats very old dates without relative", () => { const timestamp = Date.now() - 400 * 24 * 60 * 60 * 1000; - expect(formatTimeAgo(timestamp)).toBe("1y ago"); + const result = formatTimeAgo(timestamp); + // Very old dates just show date + time + expect(result).toMatch(/\d{2}-\d{2} \d{2}:\d{2}:\d{2}/); }); }); diff --git a/tests/__tests__/components/StatusBadge.test.tsx b/tests/__tests__/components/StatusBadge.test.tsx index f312a734..0ad6270d 100644 --- a/tests/__tests__/components/StatusBadge.test.tsx +++ b/tests/__tests__/components/StatusBadge.test.tsx @@ -89,7 +89,7 @@ describe('getStatusDisplay', () => { it('returns correct display for provisioning', () => { const display = getStatusDisplay('provisioning'); expect(display.text.trim()).toBe('PROVISION'); - expect(display.icon).toBe('…'); + expect(display.icon).toBe('↑'); }); it('returns correct display for initializing', () => {