From 4f69dcd526d5fcd37b9cebf0adc517ee22e2c157 Mon Sep 17 00:00:00 2001 From: Alexander Dines Date: Mon, 12 Jan 2026 14:59:51 -0800 Subject: [PATCH 1/8] cp dines --- src/commands/devbox/list.tsx | 66 +++++++++--- src/components/DevboxCreatePage.tsx | 4 +- src/components/DevboxDetailPage.tsx | 83 ++++++++++++--- src/components/StateHistory.tsx | 159 +++++++++++++++++++--------- src/components/StatusBadge.tsx | 86 +++++++++++---- 5 files changed, 303 insertions(+), 95 deletions(-) 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..ead94d7b 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 || "", diff --git a/src/components/DevboxDetailPage.tsx b/src/components/DevboxDetailPage.tsx index 5b04a7c5..724635b5 100644 --- a/src/components/DevboxDetailPage.tsx +++ b/src/components/DevboxDetailPage.tsx @@ -727,13 +727,16 @@ export const DevboxDetailPage = ({ {selectedDevbox.name || selectedDevbox.id} - - - • {selectedDevbox.id} + {/* Only show ID separately if there's a name */} + {selectedDevbox.name && ( + • {selectedDevbox.id} + )} + - {formattedCreateTime} + {" "} + • {formattedCreateTime} {" "} @@ -748,12 +751,6 @@ export const DevboxDetailPage = ({ ? `${uptime}m` : `${Math.floor(uptime / 60)}h ${uptime % 60}m`} - {lp?.keep_alive_time_seconds && ( - - {" "} - • Keep-alive: {Math.floor(lp.keep_alive_time_seconds / 60)}m - - )} )} @@ -765,7 +762,9 @@ export const DevboxDetailPage = ({ lp?.custom_cpu_cores || lp?.custom_gb_memory || lp?.custom_disk_size || - lp?.architecture) && ( + lp?.architecture || + lp?.keep_alive_time_seconds || + lp?.user_parameters) && ( {figures.squareSmallFilled} Resources @@ -777,6 +776,63 @@ export const DevboxDetailPage = ({ {lp?.custom_gb_memory && ` • ${lp.custom_gb_memory}GB RAM`} {lp?.custom_disk_size && ` • ${lp.custom_disk_size}GB DISC`} + {lp?.keep_alive_time_seconds && ( + + + Max 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 && ( + + User: {lp.user_parameters.username || "default"} + {lp.user_parameters.uid && ` (UID: ${lp.user_parameters.uid})`} + + )} )} @@ -842,7 +898,10 @@ export const DevboxDetailPage = ({ )} {/* State History */} - + {/* Operations - inline display */} diff --git a/src/components/StateHistory.tsx b/src/components/StateHistory.tsx index 54498e35..0da691ab 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,7 +131,7 @@ 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; } @@ -98,33 +139,55 @@ export const StateHistory = ({ stateTransitions }: StateHistoryProps) => { {figures.circleFilled} State History + {hasMore && ( + + {" "} + ({totalTransitions - 5} earlier states not shown) + + )} - {lastThree.map((state, idx) => ( - - - {capitalize(state.status)} - {state.transitionTime && ( - <> - {" "} - at {new Date(state.transitionTime).toLocaleString()}{" "} - - ({formatTimeAgo(state.transitionTime)}) - - {state.duration > 0 && ( - <> - {" "} - • Duration:{" "} - - {formatDuration(state.duration)} - - - )} - - )} - - - ))} + {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); + + return ( + + + {statusDisplay.icon}{" "} + {capitalize(state.status || "unknown")} + {state.transitionTime && ( + <> + {" "} + at {new Date(state.transitionTime).toLocaleString()}{" "} + + ({formatTimeAgo(state.transitionTime)}) + + {showDuration && ( + <> + {" "} + • Duration:{" "} + + {formatDuration(state.duration)} + + + )} + + )} + + + ); + })} + {shutdownReason && ( + + Shutdown reason: {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} )} From 582b00e0a342a437922d918541faa59fa45884b7 Mon Sep 17 00:00:00 2001 From: Alexander Dines Date: Mon, 12 Jan 2026 15:40:07 -0800 Subject: [PATCH 2/8] cp dines --- src/components/DevboxCreatePage.tsx | 1 + src/components/DevboxDetailPage.tsx | 5 ++- src/components/MetadataDisplay.tsx | 52 +++++++++++++++++++++++++++++ src/components/ResourceListView.tsx | 36 +++++++++++++++----- 4 files changed, 82 insertions(+), 12 deletions(-) diff --git a/src/components/DevboxCreatePage.tsx b/src/components/DevboxCreatePage.tsx index ead94d7b..0b33dfca 100644 --- a/src/components/DevboxCreatePage.tsx +++ b/src/components/DevboxCreatePage.tsx @@ -632,6 +632,7 @@ export const DevboxCreatePage = ({ )} diff --git a/src/components/DevboxDetailPage.tsx b/src/components/DevboxDetailPage.tsx index 724635b5..e66e0a42 100644 --- a/src/components/DevboxDetailPage.tsx +++ b/src/components/DevboxDetailPage.tsx @@ -767,7 +767,7 @@ export const DevboxDetailPage = ({ lp?.user_parameters) && ( - {figures.squareSmallFilled} Resources + {figures.squareSmallFilled} Details {lp?.resource_size_request && `${lp.resource_size_request}`} @@ -858,7 +858,6 @@ export const DevboxDetailPage = ({ {selectedDevbox.blueprint_id && ( <> - BP: {selectedDevbox.blueprint_id} @@ -866,7 +865,6 @@ export const DevboxDetailPage = ({ )} {selectedDevbox.snapshot_id && ( <> - Snap: {selectedDevbox.snapshot_id} )} @@ -881,6 +879,7 @@ export const DevboxDetailPage = ({ )} diff --git a/src/components/MetadataDisplay.tsx b/src/components/MetadataDisplay.tsx index eb57b904..e1a0e49b 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) => ( @@ -20,6 +21,22 @@ const renderKeyValueBadge = (keyText: string, value: string, color: string) => ( ); +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 { From 9cc8771ce70d014de55f3479154ae3d3efce27a7 Mon Sep 17 00:00:00 2001 From: Alexander Dines Date: Mon, 12 Jan 2026 15:44:22 -0800 Subject: [PATCH 3/8] cp dines --- src/components/DevboxDetailPage.tsx | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/components/DevboxDetailPage.tsx b/src/components/DevboxDetailPage.tsx index e66e0a42..f332a72f 100644 --- a/src/components/DevboxDetailPage.tsx +++ b/src/components/DevboxDetailPage.tsx @@ -41,6 +41,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, @@ -723,9 +729,12 @@ export const DevboxDetailPage = ({ {/* Main info section */} - + - {selectedDevbox.name || selectedDevbox.id} + {truncateString( + selectedDevbox.name || selectedDevbox.id, + Math.max(20, detailViewport.terminalWidth - 35), + )} {/* Only show ID separately if there's a name */} {selectedDevbox.name && ( @@ -830,7 +839,9 @@ export const DevboxDetailPage = ({ {lp?.user_parameters && ( User: {lp.user_parameters.username || "default"} - {lp.user_parameters.uid && ` (UID: ${lp.user_parameters.uid})`} + {lp.user_parameters.uid != null && + lp.user_parameters.uid !== 0 && + ` (UID: ${lp.user_parameters.uid})`} )} From d42d16653b4d8fc302b988251c95a36a03710d99 Mon Sep 17 00:00:00 2001 From: Alexander Dines Date: Mon, 12 Jan 2026 15:55:54 -0800 Subject: [PATCH 4/8] cp dines --- src/components/DevboxDetailPage.tsx | 12 ++++++++++++ src/components/MetadataDisplay.tsx | 4 ++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/components/DevboxDetailPage.tsx b/src/components/DevboxDetailPage.tsx index f332a72f..d7561d25 100644 --- a/src/components/DevboxDetailPage.tsx +++ b/src/components/DevboxDetailPage.tsx @@ -881,6 +881,18 @@ export const DevboxDetailPage = ({ )} )} + + {/* Initiator */} + {selectedDevbox.initiator_type && ( + + + {figures.pointer} Initiator + + {selectedDevbox.initiator_id && ( + {selectedDevbox.initiator_id} + )} + + )} {/* Metadata */} diff --git a/src/components/MetadataDisplay.tsx b/src/components/MetadataDisplay.tsx index e1a0e49b..f4bc94cf 100644 --- a/src/components/MetadataDisplay.tsx +++ b/src/components/MetadataDisplay.tsx @@ -31,9 +31,9 @@ const renderCompactKeyValue = ( {keyText} - = + = {value} - {!isLast && · } + {!isLast && · } ); From 6e16ffe16cffe387329f6a669a81a66fdc7f0067 Mon Sep 17 00:00:00 2001 From: Alexander Dines Date: Mon, 12 Jan 2026 16:42:41 -0800 Subject: [PATCH 5/8] cp dines --- src/components/DevboxDetailPage.tsx | 216 ++++++++++++++++------------ src/components/StateHistory.tsx | 40 +++--- 2 files changed, 150 insertions(+), 106 deletions(-) diff --git a/src/components/DevboxDetailPage.tsx b/src/components/DevboxDetailPage.tsx index d7561d25..dd081edc 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"; @@ -554,7 +553,7 @@ export const DevboxDetailPage = ({ lines.push( {" "} - Shutdown Reason: {selectedDevbox.shutdown_reason} + Shutdown Initiator: {selectedDevbox.shutdown_reason} , ); } @@ -743,52 +742,89 @@ export const DevboxDetailPage = ({ - - {" "} - • {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`} + )} + {selectedDevbox.status !== "running" && + selectedDevbox.create_time_ms && + selectedDevbox.end_time_ms && ( + + {" "} + • 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`; + })()} + + )} + + + + {/* 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()} + + ) : ( + ({createTimeAgo}) + )} )} - - {/* 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 || - lp?.keep_alive_time_seconds || - lp?.user_parameters) && ( - - - {figures.squareSmallFilled} Details - + lp?.architecture) && ( + + 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`} + {[ + 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 - Max 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`} @@ -834,88 +870,90 @@ export const DevboxDetailPage = ({ })()} )} - + {lp?.user_parameters && ( + + )} + )} {lp?.user_parameters && ( - - User: {lp.user_parameters.username || "default"} - {lp.user_parameters.uid != null && - lp.user_parameters.uid !== 0 && - ` (UID: ${lp.user_parameters.uid})`} - + <> + {!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})`} + + )} )} - {/* Capabilities */} - {hasCapabilities && ( - - - {figures.tick} Capabilities - - - {selectedDevbox.capabilities - .filter((c: string) => c !== "unknown") - .join(", ")} - - - )} - {/* Source */} {(selectedDevbox.blueprint_id || selectedDevbox.snapshot_id) && ( - - - {figures.circleFilled} Source + + Source + + {selectedDevbox.blueprint_id || selectedDevbox.snapshot_id} - {selectedDevbox.blueprint_id && ( - <> - - {selectedDevbox.blueprint_id} - - - )} - {selectedDevbox.snapshot_id && ( - <> - {selectedDevbox.snapshot_id} - - )} )} {/* Initiator */} - {selectedDevbox.initiator_type && ( - - - {figures.pointer} Initiator - - {selectedDevbox.initiator_id && ( - {selectedDevbox.initiator_id} - )} + {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} + + )} @@ -925,12 +963,12 @@ export const DevboxDetailPage = ({ shutdownReason={selectedDevbox.shutdown_reason ?? undefined} /> - {/* Operations - inline display */} + {/* Actions section */} {figures.play} Actions - + {operations.map((op, index) => { const isSelected = index === selectedOperation; return ( diff --git a/src/components/StateHistory.tsx b/src/components/StateHistory.tsx index 0da691ab..547812e6 100644 --- a/src/components/StateHistory.tsx +++ b/src/components/StateHistory.tsx @@ -136,17 +136,17 @@ export const StateHistory = ({ } return ( - + {figures.circleFilled} State History {hasMore && ( {" "} - ({totalTransitions - 5} earlier states not shown) + ({totalTransitions - 5} earlier) )} - + {lastFive.map((state, idx) => { const statusDisplay = getStatusDisplay(state.status || ""); const isLastState = idx === lastFive.length - 1; @@ -155,17 +155,20 @@ export const StateHistory = ({ ); const showDuration = state.duration > 0 && !(isLastState && isTerminalState); + const isShutdownState = state.status === "shutdown"; return ( - - - {statusDisplay.icon}{" "} + + {statusDisplay.icon} + {capitalize(state.status || "unknown")} - {state.transitionTime && ( - <> + + {state.transitionTime && ( + <> + {" "} at {new Date(state.transitionTime).toLocaleString()}{" "} - + ({formatTimeAgo(state.transitionTime)}) {showDuration && ( @@ -177,17 +180,20 @@ export const StateHistory = ({ )} - - )} - + + {isShutdownState && shutdownReason && ( + <> + due to + + {formatShutdownReason(shutdownReason)} + + + )} + + )} ); })} - {shutdownReason && ( - - Shutdown reason: {formatShutdownReason(shutdownReason)} - - )} ); From f4fd60b4fd3853a69d4e42b09a9a1be7b624626d Mon Sep 17 00:00:00 2001 From: Alexander Dines Date: Mon, 12 Jan 2026 16:42:55 -0800 Subject: [PATCH 6/8] cp dines --- src/components/DevboxDetailPage.tsx | 247 ++++++++++++++-------------- src/components/StateHistory.tsx | 5 +- 2 files changed, 127 insertions(+), 125 deletions(-) diff --git a/src/components/DevboxDetailPage.tsx b/src/components/DevboxDetailPage.tsx index dd081edc..b18ff1e0 100644 --- a/src/components/DevboxDetailPage.tsx +++ b/src/components/DevboxDetailPage.tsx @@ -781,134 +781,135 @@ export const DevboxDetailPage = ({ {/* Created / Ended */} - {selectedDevbox.create_time_ms && ( - - Created - {formattedCreateTime} - {selectedDevbox.end_time_ms ? ( + {selectedDevbox.create_time_ms && ( + + Created + {formattedCreateTime} + {selectedDevbox.end_time_ms ? ( + + {" "} + {figures.arrowRight}{" "} + {new Date(selectedDevbox.end_time_ms).toLocaleString()} + + ) : ( + ({createTimeAgo}) + )} + + )} + + {/* Resources */} + {(lp?.resource_size_request || + lp?.custom_cpu_cores || + lp?.custom_gb_memory || + lp?.custom_disk_size || + lp?.architecture) && ( + + Resources - {" "} - {figures.arrowRight} {new Date(selectedDevbox.end_time_ms).toLocaleString()} + {[ + 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(" • ")} - ) : ( - ({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 - + {/* 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})`} - )} - {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} - - - )} + {/* Source */} + {(selectedDevbox.blueprint_id || selectedDevbox.snapshot_id) && ( + + Source + + {selectedDevbox.blueprint_id || selectedDevbox.snapshot_id} + + + )} - {/* Initiator */} - {selectedDevbox.initiator_id && ( - - Initiator - {selectedDevbox.initiator_id} - - )} + {/* Initiator */} + {selectedDevbox.initiator_id && ( + + Initiator + {selectedDevbox.initiator_id} + + )} {/* Capabilities */} {hasCapabilities && ( @@ -950,9 +951,7 @@ export const DevboxDetailPage = ({ {figures.cross} Error - - {selectedDevbox.failure_reason} - + {selectedDevbox.failure_reason} )} diff --git a/src/components/StateHistory.tsx b/src/components/StateHistory.tsx index 547812e6..49222668 100644 --- a/src/components/StateHistory.tsx +++ b/src/components/StateHistory.tsx @@ -160,7 +160,10 @@ export const StateHistory = ({ return ( {statusDisplay.icon} - + {capitalize(state.status || "unknown")} {state.transitionTime && ( From 7df6957e12ae4f6beebb9400b59ff1c95184f815 Mon Sep 17 00:00:00 2001 From: Alexander Dines Date: Mon, 12 Jan 2026 16:44:16 -0800 Subject: [PATCH 7/8] cp dines --- src/components/DevboxDetailPage.tsx | 2 +- src/components/StateHistory.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/DevboxDetailPage.tsx b/src/components/DevboxDetailPage.tsx index b18ff1e0..495ebad9 100644 --- a/src/components/DevboxDetailPage.tsx +++ b/src/components/DevboxDetailPage.tsx @@ -967,7 +967,7 @@ export const DevboxDetailPage = ({ {figures.play} Actions - + {operations.map((op, index) => { const isSelected = index === selectedOperation; return ( diff --git a/src/components/StateHistory.tsx b/src/components/StateHistory.tsx index 49222668..a70b451e 100644 --- a/src/components/StateHistory.tsx +++ b/src/components/StateHistory.tsx @@ -146,7 +146,7 @@ export const StateHistory = ({ )} - + {lastFive.map((state, idx) => { const statusDisplay = getStatusDisplay(state.status || ""); const isLastState = idx === lastFive.length - 1; From ebadb0164a152946b6ad7c646bca4be0482e1edd Mon Sep 17 00:00:00 2001 From: Alexander Dines Date: Mon, 12 Jan 2026 17:04:34 -0800 Subject: [PATCH 8/8] cp dines --- src/components/DevboxDetailPage.tsx | 16 +++++++---- src/components/MetadataDisplay.tsx | 2 +- src/components/StateHistory.tsx | 6 ++-- .../components/DevboxCreatePage.test.tsx | 2 +- .../components/DevboxDetailPage.test.tsx | 2 +- .../components/ResourceListView.test.tsx | 28 +++++++++++++------ .../__tests__/components/StatusBadge.test.tsx | 2 +- 7 files changed, 37 insertions(+), 21 deletions(-) diff --git a/src/components/DevboxDetailPage.tsx b/src/components/DevboxDetailPage.tsx index 495ebad9..829f64e5 100644 --- a/src/components/DevboxDetailPage.tsx +++ b/src/components/DevboxDetailPage.tsx @@ -897,7 +897,7 @@ export const DevboxDetailPage = ({ {(selectedDevbox.blueprint_id || selectedDevbox.snapshot_id) && ( Source - + {selectedDevbox.blueprint_id || selectedDevbox.snapshot_id} @@ -907,7 +907,9 @@ export const DevboxDetailPage = ({ {selectedDevbox.initiator_id && ( Initiator - {selectedDevbox.initiator_id} + + {selectedDevbox.initiator_id} + )} @@ -929,15 +931,17 @@ export const DevboxDetailPage = ({ {selectedDevbox.metadata && Object.keys(selectedDevbox.metadata).length > 0 && ( - + {figures.identical} Metadata {Object.entries(selectedDevbox.metadata).map(([key, value]) => ( - {key} - = - {value as string} + {key} + : + + {value as string} + ))} diff --git a/src/components/MetadataDisplay.tsx b/src/components/MetadataDisplay.tsx index f4bc94cf..3c6d625b 100644 --- a/src/components/MetadataDisplay.tsx +++ b/src/components/MetadataDisplay.tsx @@ -16,7 +16,7 @@ const renderKeyValueBadge = (keyText: string, value: string, color: string) => ( {keyText} - : + = {value} ); diff --git a/src/components/StateHistory.tsx b/src/components/StateHistory.tsx index a70b451e..66c507f9 100644 --- a/src/components/StateHistory.tsx +++ b/src/components/StateHistory.tsx @@ -137,8 +137,8 @@ export const StateHistory = ({ return ( - - {figures.circleFilled} State History + + {figures.info} State History {hasMore && ( {" "} @@ -178,7 +178,7 @@ export const StateHistory = ({ <> {" "} • Duration:{" "} - + {formatDuration(state.duration)} 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', () => {