From 069062158f4f2350457f64b5fcab0fab775bcb1f Mon Sep 17 00:00:00 2001 From: Alexander Dines Date: Thu, 22 Jan 2026 14:13:13 -0800 Subject: [PATCH 01/24] cp dines --- package-lock.json | 8 +- package.json | 2 +- src/commands/blueprint/list.tsx | 77 +- src/commands/devbox/list.tsx | 5 + src/commands/network-policy/list.tsx | 653 ++++++++++++++++ src/commands/snapshot/list.tsx | 67 +- src/components/DevboxCreatePage.tsx | 221 +++--- src/components/DevboxDetailPage.tsx | 862 +++++++-------------- src/components/MainMenu.tsx | 13 +- src/components/NetworkPolicyCreatePage.tsx | 403 ++++++++++ src/components/ResourceDetailPage.tsx | 415 ++++++++++ src/components/Table.tsx | 23 +- src/components/form/FormActionButton.tsx | 33 + src/components/form/FormField.tsx | 44 ++ src/components/form/FormListManager.tsx | 272 +++++++ src/components/form/FormSelect.tsx | 71 ++ src/components/form/FormTextInput.tsx | 40 + src/components/form/index.ts | 18 + src/router/Router.tsx | 27 +- src/screens/BlueprintDetailScreen.tsx | 525 +++++++++++++ src/screens/MenuScreen.tsx | 3 + src/screens/NetworkPolicyCreateScreen.tsx | 19 + src/screens/NetworkPolicyDetailScreen.tsx | 382 +++++++++ src/screens/NetworkPolicyListScreen.tsx | 12 + src/screens/SnapshotDetailScreen.tsx | 329 ++++++++ src/services/blueprintService.ts | 59 +- src/services/networkPolicyService.ts | 140 ++++ src/services/snapshotService.ts | 29 + src/store/blueprintStore.ts | 20 + src/store/navigationStore.tsx | 4 + src/store/networkPolicyStore.ts | 171 ++++ src/store/snapshotStore.ts | 3 + 32 files changed, 4171 insertions(+), 779 deletions(-) create mode 100644 src/commands/network-policy/list.tsx create mode 100644 src/components/NetworkPolicyCreatePage.tsx create mode 100644 src/components/ResourceDetailPage.tsx create mode 100644 src/components/form/FormActionButton.tsx create mode 100644 src/components/form/FormField.tsx create mode 100644 src/components/form/FormListManager.tsx create mode 100644 src/components/form/FormSelect.tsx create mode 100644 src/components/form/FormTextInput.tsx create mode 100644 src/components/form/index.ts create mode 100644 src/screens/BlueprintDetailScreen.tsx create mode 100644 src/screens/NetworkPolicyCreateScreen.tsx create mode 100644 src/screens/NetworkPolicyDetailScreen.tsx create mode 100644 src/screens/NetworkPolicyListScreen.tsx create mode 100644 src/screens/SnapshotDetailScreen.tsx create mode 100644 src/services/networkPolicyService.ts create mode 100644 src/store/networkPolicyStore.ts diff --git a/package-lock.json b/package-lock.json index 0b95e92e..63c34051 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.19.1", - "@runloop/api-client": "^1.0.0", + "@runloop/api-client": "1.3.0", "@types/express": "^5.0.3", "chalk": "^5.3.0", "commander": "^14.0.1", @@ -2396,9 +2396,9 @@ } }, "node_modules/@runloop/api-client": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@runloop/api-client/-/api-client-1.0.0.tgz", - "integrity": "sha512-+vpPKRmTO6UdTF4ymz7J15pImwjrE1/bKBjJK3Lkgp6YALwu2UtYR22P/vxK5eOLTmAmdBv/7s1X25G1W/WeAg==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@runloop/api-client/-/api-client-1.3.0.tgz", + "integrity": "sha512-RXKtWy7pUWj29TgxXwihO3Xc5rywqgDH1ecXv8/3UgruQIQD/VfcnUbAgJcGFYaYtuBkDeJ+8kTPW0JfVUVk7Q==", "license": "MIT", "dependencies": { "@types/node": "^18.11.18", diff --git a/package.json b/package.json index b711568e..82ebbcd2 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,7 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "^1.19.1", - "@runloop/api-client": "^1.0.0", + "@runloop/api-client": "1.3.0", "@types/express": "^5.0.3", "chalk": "^5.3.0", "commander": "^14.0.1", diff --git a/src/commands/blueprint/list.tsx b/src/commands/blueprint/list.tsx index 93b96df2..afe3da91 100644 --- a/src/commands/blueprint/list.tsx +++ b/src/commands/blueprint/list.tsx @@ -25,7 +25,7 @@ import { useNavigation } from "../../store/navigationStore.js"; const DEFAULT_PAGE_SIZE = 10; -type OperationType = "create_devbox" | "delete" | "view_logs" | null; +type OperationType = "create_devbox" | "delete" | "view_logs" | "view_details" | null; // Local interface for blueprint data used in this component interface BlueprintListItem { @@ -273,6 +273,14 @@ const ListBlueprintsUI = ({ ): Operation[] => { const operations: Operation[] = []; + // View Details is always first + operations.push({ + key: "view_details", + label: "View Details", + color: colors.primary, + icon: figures.pointer, + }); + // View Logs is always available operations.push({ key: "view_logs", @@ -347,6 +355,15 @@ const ListBlueprintsUI = ({ try { setOperationLoading(true); switch (operation) { + case "view_details": + // Navigate to the detail screen + setOperationLoading(false); + setExecutingOperation(null); + navigate("blueprint-detail", { + blueprintId: blueprint.id, + }); + return; + case "view_logs": // Navigate to the logs screen setOperationLoading(false); @@ -438,7 +455,11 @@ const ListBlueprintsUI = ({ setShowPopup(false); const operationKey = allOperations[selectedOperation].key; - if (operationKey === "create_devbox") { + if (operationKey === "view_details") { + navigate("blueprint-detail", { + blueprintId: selectedBlueprintItem.id, + }); + } else if (operationKey === "create_devbox") { setSelectedBlueprint(selectedBlueprintItem); setShowCreateDevbox(true); } else { @@ -449,6 +470,12 @@ const ListBlueprintsUI = ({ operationKey as OperationType, ); } + } else if (input === "v" && selectedBlueprintItem) { + // View details hotkey + setShowPopup(false); + navigate("blueprint-detail", { + blueprintId: selectedBlueprintItem.id, + }); } else if (key.escape || input === "q") { setShowPopup(false); setSelectedOperation(0); @@ -509,6 +536,11 @@ const ListBlueprintsUI = ({ ) { prevPage(); setSelectedIndex(0); + } else if (key.return && selectedBlueprintItem) { + // Enter key navigates to detail view + navigate("blueprint-detail", { + blueprintId: selectedBlueprintItem.id, + }); } else if (input === "a") { setShowPopup(true); setSelectedOperation(0); @@ -688,22 +720,6 @@ const ListBlueprintsUI = ({ ); } - // Empty state - if (blueprints.length === 0) { - return ( - <> - - - {figures.info} - No blueprints found. Try: - - rli blueprint create - - - - ); - } - // List view return ( <> @@ -717,6 +733,11 @@ const ListBlueprintsUI = ({ selectedIndex={selectedIndex} title={`blueprints[${totalCount}]`} columns={blueprintColumns} + emptyState={ + + {figures.info} No blueprints found. Try: rli blueprint create + + } /> )} @@ -768,13 +789,15 @@ const ListBlueprintsUI = ({ color: op.color, icon: op.icon, shortcut: - op.key === "create_devbox" - ? "c" - : op.key === "delete" - ? "d" - : op.key === "view_logs" - ? "l" - : "", + op.key === "view_details" + ? "v" + : op.key === "create_devbox" + ? "c" + : op.key === "delete" + ? "d" + : op.key === "view_logs" + ? "l" + : "", }))} selectedOperation={selectedOperation} onClose={() => setShowPopup(false)} @@ -795,6 +818,10 @@ const ListBlueprintsUI = ({ {figures.arrowRight} Page )} + + {" "} + • [Enter] Details + {" "} • [a] Actions diff --git a/src/commands/devbox/list.tsx b/src/commands/devbox/list.tsx index 88b4c772..f5c5cf36 100644 --- a/src/commands/devbox/list.tsx +++ b/src/commands/devbox/list.tsx @@ -707,6 +707,11 @@ const ListDevboxesUI = ({ selectedIndex={selectedIndex} title="devboxes" columns={tableColumns} + emptyState={ + + {figures.info} No devboxes found. Press [c] to create one. + + } /> )} diff --git a/src/commands/network-policy/list.tsx b/src/commands/network-policy/list.tsx new file mode 100644 index 00000000..1839989f --- /dev/null +++ b/src/commands/network-policy/list.tsx @@ -0,0 +1,653 @@ +import React from "react"; +import { Box, Text, useInput, useApp } from "ink"; +import figures from "figures"; +import type { NetworkPoliciesCursorIDPage } from "@runloop/api-client/pagination"; +import { getClient } from "../../utils/client.js"; +import { Header } from "../../components/Header.js"; +import { SpinnerComponent } from "../../components/Spinner.js"; +import { ErrorMessage } from "../../components/ErrorMessage.js"; +import { SuccessMessage } from "../../components/SuccessMessage.js"; +import { Breadcrumb } from "../../components/Breadcrumb.js"; +import { Table, createTextColumn } from "../../components/Table.js"; +import { ActionsPopup } from "../../components/ActionsPopup.js"; +import { Operation } from "../../components/OperationsMenu.js"; +import { formatTimeAgo } from "../../components/ResourceListView.js"; +import { output, outputError } from "../../utils/output.js"; +import { colors } from "../../utils/theme.js"; +import { useViewportHeight } from "../../hooks/useViewportHeight.js"; +import { useExitOnCtrlC } from "../../hooks/useExitOnCtrlC.js"; +import { useCursorPagination } from "../../hooks/useCursorPagination.js"; +import { useNavigation } from "../../store/navigationStore.js"; +import { NetworkPolicyCreatePage } from "../../components/NetworkPolicyCreatePage.js"; + +interface ListOptions { + name?: string; + output?: string; +} + +// Local interface for network policy data used in this component +interface NetworkPolicyListItem { + id: string; + name: string; + description?: string; + create_time_ms: number; + update_time_ms: number; + egress: { + allow_all: boolean; + allow_devbox_to_devbox: boolean; + allowed_hostnames: string[]; + }; +} + +const DEFAULT_PAGE_SIZE = 10; + +/** + * Get a display label for the egress policy type + */ +function getEgressTypeLabel(egress: NetworkPolicyListItem["egress"]): string { + if (egress.allow_all) { + return "Allow All"; + } + if (egress.allowed_hostnames.length === 0) { + return "Deny All"; + } + return `Custom (${egress.allowed_hostnames.length})`; +} + +/** + * Get color for egress type + */ +function getEgressTypeColor(egress: NetworkPolicyListItem["egress"]): string { + if (egress.allow_all) { + return colors.success; + } + if (egress.allowed_hostnames.length === 0) { + return colors.error; + } + return colors.warning; +} + +const ListNetworkPoliciesUI = ({ + onBack, + onExit, +}: { + onBack?: () => void; + onExit?: () => void; +}) => { + const { exit: inkExit } = useApp(); + const { navigate } = useNavigation(); + const [selectedIndex, setSelectedIndex] = React.useState(0); + const [showPopup, setShowPopup] = React.useState(false); + const [selectedOperation, setSelectedOperation] = React.useState(0); + const [selectedPolicy, setSelectedPolicy] = + React.useState(null); + const [executingOperation, setExecutingOperation] = React.useState< + string | null + >(null); + const [operationResult, setOperationResult] = React.useState( + null, + ); + const [operationError, setOperationError] = React.useState( + null, + ); + const [operationLoading, setOperationLoading] = React.useState(false); + const [showCreatePolicy, setShowCreatePolicy] = React.useState(false); + + // Calculate overhead for viewport height + const overhead = 13; + const { viewportHeight, terminalWidth } = useViewportHeight({ + overhead, + minHeight: 5, + }); + + const PAGE_SIZE = viewportHeight; + + // All width constants + const idWidth = 25; + const nameWidth = Math.max(15, terminalWidth >= 120 ? 30 : 20); + const descriptionWidth = Math.max(20, terminalWidth >= 140 ? 40 : 25); + const egressWidth = 15; + const timeWidth = 15; + const showDescription = terminalWidth >= 100; + + // Fetch function for pagination hook + const fetchPage = React.useCallback( + async (params: { limit: number; startingAt?: string }) => { + const client = getClient(); + const pagePolicies: NetworkPolicyListItem[] = []; + + // Build query params + const queryParams: Record = { + limit: params.limit, + }; + if (params.startingAt) { + queryParams.starting_after = params.startingAt; + } + + // Fetch ONE page only + const page = (await client.networkPolicies.list( + queryParams, + )) as unknown as NetworkPoliciesCursorIDPage; + + // Extract data and create defensive copies + if (page.network_policies && Array.isArray(page.network_policies)) { + page.network_policies.forEach((p: NetworkPolicyListItem) => { + pagePolicies.push({ + id: p.id, + name: p.name, + description: p.description, + create_time_ms: p.create_time_ms, + update_time_ms: p.update_time_ms, + egress: { + allow_all: p.egress.allow_all, + allow_devbox_to_devbox: p.egress.allow_devbox_to_devbox, + allowed_hostnames: [...(p.egress.allowed_hostnames || [])], + }, + }); + }); + } + + const result = { + items: pagePolicies, + hasMore: page.has_more || false, + totalCount: page.total_count || pagePolicies.length, + }; + + return result; + }, + [], + ); + + // Use the shared pagination hook + const { + items: policies, + loading, + navigating, + error, + currentPage, + hasMore, + hasPrev, + totalCount, + nextPage, + prevPage, + } = useCursorPagination({ + fetchPage, + pageSize: PAGE_SIZE, + getItemId: (policy: NetworkPolicyListItem) => policy.id, + pollInterval: 5000, + pollingEnabled: !showPopup && !executingOperation && !showCreatePolicy, + deps: [PAGE_SIZE], + }); + + // Operations for a specific network policy (shown in popup) + const operations: Operation[] = React.useMemo( + () => [ + { + key: "view_details", + label: "View Details", + color: colors.primary, + icon: figures.pointer, + }, + { + key: "delete", + label: "Delete Network Policy", + color: colors.error, + icon: figures.cross, + }, + ], + [], + ); + + // Build columns + const columns = React.useMemo( + () => [ + createTextColumn( + "id", + "ID", + (policy: NetworkPolicyListItem) => policy.id, + { + width: idWidth + 1, + color: colors.idColor, + dimColor: false, + bold: false, + }, + ), + createTextColumn( + "name", + "Name", + (policy: NetworkPolicyListItem) => policy.name || "", + { + width: nameWidth, + }, + ), + ...(showDescription + ? [ + createTextColumn( + "description", + "Description", + (policy: NetworkPolicyListItem) => policy.description || "", + { + width: descriptionWidth, + color: colors.textDim, + dimColor: false, + bold: false, + }, + ), + ] + : []), + { + key: "egress", + label: "Egress", + width: egressWidth, + render: ( + policy: NetworkPolicyListItem, + _index: number, + isSelected: boolean, + ) => { + const label = getEgressTypeLabel(policy.egress); + const color = getEgressTypeColor(policy.egress); + const safeWidth = Math.max(1, egressWidth); + const truncated = label.slice(0, safeWidth); + const padded = truncated.padEnd(safeWidth, " "); + return ( + + {padded} + + ); + }, + }, + createTextColumn( + "created", + "Created", + (policy: NetworkPolicyListItem) => + policy.create_time_ms ? formatTimeAgo(policy.create_time_ms) : "", + { + width: timeWidth, + color: colors.textDim, + dimColor: false, + bold: false, + }, + ), + ], + [idWidth, nameWidth, descriptionWidth, egressWidth, timeWidth, showDescription], + ); + + // Handle Ctrl+C to exit + useExitOnCtrlC(); + + // Ensure selected index is within bounds + React.useEffect(() => { + if (policies.length > 0 && selectedIndex >= policies.length) { + setSelectedIndex(Math.max(0, policies.length - 1)); + } + }, [policies.length, selectedIndex]); + + const selectedPolicyItem = policies[selectedIndex]; + + // Calculate pagination info for display + const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE)); + const startIndex = currentPage * PAGE_SIZE; + const endIndex = startIndex + policies.length; + + const executeOperation = async () => { + const client = getClient(); + const policy = selectedPolicy; + + if (!policy) return; + + try { + setOperationLoading(true); + switch (executingOperation) { + case "delete": + await client.networkPolicies.delete(policy.id); + setOperationResult( + `Network policy "${policy.name}" deleted successfully`, + ); + break; + } + } catch (err) { + setOperationError(err as Error); + } finally { + setOperationLoading(false); + } + }; + + useInput((input, key) => { + // Handle operation result display + if (operationResult || operationError) { + if (input === "q" || key.escape || key.return) { + setOperationResult(null); + setOperationError(null); + setExecutingOperation(null); + setSelectedPolicy(null); + } + return; + } + + // Handle create policy screen + if (showCreatePolicy) { + return; + } + + // Handle popup navigation + if (showPopup) { + if (key.upArrow && selectedOperation > 0) { + setSelectedOperation(selectedOperation - 1); + } else if (key.downArrow && selectedOperation < operations.length - 1) { + setSelectedOperation(selectedOperation + 1); + } else if (key.return) { + setShowPopup(false); + const operationKey = operations[selectedOperation].key; + + if (operationKey === "create") { + setShowCreatePolicy(true); + } else if (operationKey === "view_details") { + navigate("network-policy-detail", { + networkPolicyId: selectedPolicyItem.id, + }); + } else { + setSelectedPolicy(selectedPolicyItem); + setExecutingOperation(operationKey); + // Execute immediately after state update + setTimeout(() => executeOperation(), 0); + } + } else if (input === "c") { + // Create hotkey + setShowPopup(false); + setShowCreatePolicy(true); + } else if (input === "v" && selectedPolicyItem) { + // View details hotkey + setShowPopup(false); + navigate("network-policy-detail", { + networkPolicyId: selectedPolicyItem.id, + }); + } else if (key.escape || input === "q") { + setShowPopup(false); + setSelectedOperation(0); + } else if (input === "d") { + // Delete hotkey + setShowPopup(false); + setSelectedPolicy(selectedPolicyItem); + setExecutingOperation("delete"); + setTimeout(() => executeOperation(), 0); + } + return; + } + + const pagePolicies = policies.length; + + // Handle list view navigation + if (key.upArrow && selectedIndex > 0) { + setSelectedIndex(selectedIndex - 1); + } else if (key.downArrow && selectedIndex < pagePolicies - 1) { + setSelectedIndex(selectedIndex + 1); + } else if ( + (input === "n" || key.rightArrow) && + !loading && + !navigating && + hasMore + ) { + nextPage(); + setSelectedIndex(0); + } else if ( + (input === "p" || key.leftArrow) && + !loading && + !navigating && + hasPrev + ) { + prevPage(); + setSelectedIndex(0); + } else if (key.return && selectedPolicyItem) { + // Enter key navigates to detail view + navigate("network-policy-detail", { + networkPolicyId: selectedPolicyItem.id, + }); + } else if (input === "a") { + setShowPopup(true); + setSelectedOperation(0); + } else if (input === "c") { + // Create shortcut + setShowCreatePolicy(true); + } else if (key.escape) { + if (onBack) { + onBack(); + } else if (onExit) { + onExit(); + } else { + inkExit(); + } + } + }); + + // Operation result display + if (operationResult || operationError) { + const operationLabel = + operations.find((o) => o.key === executingOperation)?.label || + "Operation"; + return ( + <> + +
+ {operationResult && } + {operationError && ( + + )} + + + Press [Enter], [q], or [esc] to continue + + + + ); + } + + // Operation loading state + if (operationLoading && selectedPolicy) { + const operationLabel = + operations.find((o) => o.key === executingOperation)?.label || + "Operation"; + const messages: Record = { + delete: "Deleting network policy...", + }; + return ( + <> + +
+ + + ); + } + + // Create policy screen + if (showCreatePolicy) { + return ( + setShowCreatePolicy(false)} + onCreate={(policy) => { + setShowCreatePolicy(false); + navigate("network-policy-detail", { networkPolicyId: policy.id }); + }} + /> + ); + } + + // Loading state + if (loading && policies.length === 0) { + return ( + <> + + + + ); + } + + // Error state + if (error) { + return ( + <> + + + + ); + } + + // Main list view + return ( + <> + + + {/* Table - hide when popup is shown */} + {!showPopup && ( + policy.id} + selectedIndex={selectedIndex} + title={`network_policies[${totalCount}]`} + columns={columns} + emptyState={ + + {figures.info} No network policies found. Press [c] to create one. + + } + /> + )} + + {/* Statistics Bar - hide when popup is shown */} + {!showPopup && ( + + + {figures.hamburger} {totalCount} + + + {" "} + total + + {totalPages > 1 && ( + <> + + {" "} + •{" "} + + {navigating ? ( + + {figures.pointer} Loading page {currentPage + 1}... + + ) : ( + + Page {currentPage + 1} of {totalPages} + + )} + + )} + + {" "} + •{" "} + + + Showing {startIndex + 1}-{endIndex} of {totalCount} + + + )} + + {/* Actions Popup */} + {showPopup && selectedPolicyItem && ( + + ({ + key: op.key, + label: op.label, + color: op.color, + icon: op.icon, + shortcut: op.key === "create" ? "c" : op.key === "view_details" ? "v" : op.key === "delete" ? "d" : "", + }))} + selectedOperation={selectedOperation} + onClose={() => setShowPopup(false)} + /> + + )} + + {/* Help Bar */} + + + {figures.arrowUp} + {figures.arrowDown} Navigate + + {(hasMore || hasPrev) && ( + + {" "} + • {figures.arrowLeft} + {figures.arrowRight} Page + + )} + + {" "} + • [Enter] Details + + + {" "} + • [c] Create + + + {" "} + • [a] Actions + + + {" "} + • [Esc] Back + + + + ); +}; + +// Export the UI component for use in the main menu +export { ListNetworkPoliciesUI }; + +export async function listNetworkPolicies(options: ListOptions = {}) { + try { + const client = getClient(); + + // Build query params + const queryParams: Record = { + limit: DEFAULT_PAGE_SIZE, + }; + if (options.name) { + queryParams.name = options.name; + } + + // Fetch network policies + const page = (await client.networkPolicies.list( + queryParams, + )) as NetworkPoliciesCursorIDPage<{ id: string }>; + + // Extract network policies array + const networkPolicies = page.network_policies || []; + + output(networkPolicies, { format: options.output, defaultFormat: "json" }); + } catch (error) { + outputError("Failed to list network policies", error); + } +} diff --git a/src/commands/snapshot/list.tsx b/src/commands/snapshot/list.tsx index 32fdeb59..00be14a4 100644 --- a/src/commands/snapshot/list.tsx +++ b/src/commands/snapshot/list.tsx @@ -153,6 +153,12 @@ const ListSnapshotsUI = ({ // Operations for snapshots const operations: Operation[] = React.useMemo( () => [ + { + key: "view_details", + label: "View Details", + color: colors.primary, + icon: figures.pointer, + }, { key: "create_devbox", label: "Create Devbox from Snapshot", @@ -284,7 +290,11 @@ const ListSnapshotsUI = ({ setShowPopup(false); const operationKey = operations[selectedOperation].key; - if (operationKey === "create_devbox") { + if (operationKey === "view_details") { + navigate("snapshot-detail", { + snapshotId: selectedSnapshotItem.id, + }); + } else if (operationKey === "create_devbox") { setSelectedSnapshot(selectedSnapshotItem); setShowCreateDevbox(true); } else { @@ -293,6 +303,12 @@ const ListSnapshotsUI = ({ // Execute immediately after state update setTimeout(() => executeOperation(), 0); } + } else if (input === "v" && selectedSnapshotItem) { + // View details hotkey + setShowPopup(false); + navigate("snapshot-detail", { + snapshotId: selectedSnapshotItem.id, + }); } else if (key.escape || input === "q") { setShowPopup(false); setSelectedOperation(0); @@ -334,6 +350,11 @@ const ListSnapshotsUI = ({ ) { prevPage(); setSelectedIndex(0); + } else if (key.return && selectedSnapshotItem) { + // Enter key navigates to detail view + navigate("snapshot-detail", { + snapshotId: selectedSnapshotItem.id, + }); } else if (input === "a" && selectedSnapshotItem) { setShowPopup(true); setSelectedOperation(0); @@ -456,29 +477,6 @@ const ListSnapshotsUI = ({ ); } - // Empty state - if (snapshots.length === 0) { - return ( - <> - - - {figures.info} - No snapshots found. Try: - - rli snapshot create {""} - - - - ); - } - // Main list view return ( <> @@ -497,6 +495,11 @@ const ListSnapshotsUI = ({ selectedIndex={selectedIndex} title={`snapshots[${totalCount}]`} columns={columns} + emptyState={ + + {figures.info} No snapshots found. Try: rli snapshot create {""} + + } /> )} @@ -548,11 +551,13 @@ const ListSnapshotsUI = ({ color: op.color, icon: op.icon, shortcut: - op.key === "create_devbox" - ? "c" - : op.key === "delete" - ? "d" - : "", + op.key === "view_details" + ? "v" + : op.key === "create_devbox" + ? "c" + : op.key === "delete" + ? "d" + : "", }))} selectedOperation={selectedOperation} onClose={() => setShowPopup(false)} @@ -573,6 +578,10 @@ const ListSnapshotsUI = ({ {figures.arrowRight} Page )} + + {" "} + • [Enter] Details + {" "} • [a] Actions diff --git a/src/components/DevboxCreatePage.tsx b/src/components/DevboxCreatePage.tsx index a1a98bf1..24824b0b 100644 --- a/src/components/DevboxCreatePage.tsx +++ b/src/components/DevboxCreatePage.tsx @@ -13,6 +13,12 @@ import { ErrorMessage } from "./ErrorMessage.js"; import { SuccessMessage } from "./SuccessMessage.js"; import { Breadcrumb } from "./Breadcrumb.js"; import { MetadataDisplay } from "./MetadataDisplay.js"; +import { + FormTextInput, + FormSelect, + FormActionButton, + useFormSelectNavigation, +} from "./form/index.js"; import { colors } from "../utils/theme.js"; import { useExitOnCtrlC } from "../hooks/useExitOnCtrlC.js"; @@ -57,6 +63,17 @@ interface FormData { snapshot_id: string; } +const architectures = ["arm64", "x86_64"] as const; +const resourceSizes = [ + "X_SMALL", + "SMALL", + "MEDIUM", + "LARGE", + "X_LARGE", + "XX_LARGE", + "CUSTOM_SIZE", +] as const; + export const DevboxCreatePage = ({ onBack, onCreate, @@ -82,7 +99,7 @@ export const DevboxCreatePage = ({ const [metadataInputMode, setMetadataInputMode] = React.useState< "key" | "value" | null >(null); - const [selectedMetadataIndex, setSelectedMetadataIndex] = React.useState(-1); // -1 means "add new" row + const [selectedMetadataIndex, setSelectedMetadataIndex] = React.useState(0); const [creating, setCreating] = React.useState(false); const [result, setResult] = React.useState(null); const [error, setError] = React.useState(null); @@ -91,9 +108,10 @@ export const DevboxCreatePage = ({ key: FormField; label: string; type: "text" | "select" | "metadata" | "action"; + placeholder?: string; }> = [ { key: "create", label: "Devbox Create", type: "action" }, - { key: "name", label: "Name", type: "text" }, + { key: "name", label: "Name", type: "text", placeholder: "my-devbox" }, { key: "architecture", label: "Architecture", type: "select" }, { key: "resource_size", label: "Resource Size", type: "select" }, ]; @@ -103,16 +121,13 @@ export const DevboxCreatePage = ({ key: FormField; label: string; type: "text" | "select" | "metadata" | "action"; + placeholder?: string; }> = formData.resource_size === "CUSTOM_SIZE" ? [ - { key: "custom_cpu", label: "CPU Cores (2-16, even)", type: "text" }, - { - key: "custom_memory", - label: "Memory GB (2-64, even)", - type: "text", - }, - { key: "custom_disk", label: "Disk GB (2-64, even)", type: "text" }, + { key: "custom_cpu", label: "CPU Cores (2-16, even)", type: "text", placeholder: "4" }, + { key: "custom_memory", label: "Memory GB (2-64, even)", type: "text", placeholder: "8" }, + { key: "custom_disk", label: "Disk GB (2-64, even)", type: "text", placeholder: "16" }, ] : []; @@ -120,31 +135,36 @@ export const DevboxCreatePage = ({ key: FormField; label: string; type: "text" | "select" | "metadata" | "action"; + placeholder?: string; }> = [ - { key: "keep_alive", label: "Keep Alive (seconds)", type: "text" }, - { key: "blueprint_id", label: "Blueprint ID (optional)", type: "text" }, - { key: "snapshot_id", label: "Snapshot ID (optional)", type: "text" }, + { key: "keep_alive", label: "Keep Alive (seconds)", type: "text", placeholder: "3600" }, + { key: "blueprint_id", label: "Blueprint ID (optional)", type: "text", placeholder: "bpt_xxx" }, + { key: "snapshot_id", label: "Snapshot ID (optional)", type: "text", placeholder: "snp_xxx" }, { key: "metadata", label: "Metadata (optional)", type: "metadata" }, ]; const fields = [...baseFields, ...customFields, ...remainingFields]; - const architectures = ["arm64", "x86_64"] as const; - const resourceSizes = [ - "X_SMALL", - "SMALL", - "MEDIUM", - "LARGE", - "X_LARGE", - "XX_LARGE", - "CUSTOM_SIZE", - ] as const; - const currentFieldIndex = fields.findIndex((f) => f.key === currentField); // Handle Ctrl+C to exit useExitOnCtrlC(); + // Select navigation handlers using shared hook + const handleArchitectureNav = useFormSelectNavigation( + formData.architecture, + architectures, + (value) => setFormData({ ...formData, architecture: value }), + currentField === "architecture", + ); + + const handleResourceSizeNav = useFormSelectNavigation( + formData.resource_size || "SMALL", + resourceSizes, + (value) => setFormData({ ...formData, resource_size: value }), + currentField === "resource_size", + ); + useInput((input, key) => { // Handle result screen if (result) { @@ -174,25 +194,7 @@ export const DevboxCreatePage = ({ return; } - // Back to list - if (input === "q" || key.escape) { - onBack(); - return; - } - - // Submit form - if (input === "s" && key.ctrl) { - handleCreate(); - return; - } - - // Handle Enter on create field - if (currentField === "create" && key.return) { - handleCreate(); - return; - } - - // Handle metadata section + // Handle metadata section FIRST (before general escape handler) if (inMetadataSection) { const metadataKeys = Object.keys(formData.metadata); // Selection model: 0 = "Add new", 1..n = Existing items, n+1 = "Done" @@ -292,8 +294,27 @@ export const DevboxCreatePage = ({ return; } - // Now safe to get field from list - const field = fields[currentFieldIndex]; + // Back to list (only when not in metadata section) + if (input === "q" || key.escape) { + onBack(); + return; + } + + // Submit form + if (input === "s" && key.ctrl) { + handleCreate(); + return; + } + + // Handle Enter on create field + if (currentField === "create" && key.return) { + handleCreate(); + return; + } + + // Handle select field navigation using shared hooks + if (handleArchitectureNav(input, key)) return; + if (handleResourceSizeNav(input, key)) return; // Navigation if (key.upArrow && currentFieldIndex > 0) { @@ -312,35 +333,6 @@ export const DevboxCreatePage = ({ setSelectedMetadataIndex(0); // Start at "add new" row return; } - - // Handle select fields - if (field && field.type === "select" && (key.leftArrow || key.rightArrow)) { - if (currentField === "architecture") { - const currentIndex = architectures.indexOf(formData.architecture); - const newIndex = key.leftArrow - ? Math.max(0, currentIndex - 1) - : Math.min(architectures.length - 1, currentIndex + 1); - setFormData({ - ...formData, - architecture: architectures[newIndex] as "arm64" | "x86_64", - }); - } else if (currentField === "resource_size") { - // Find current index, defaulting to 0 if not found (e.g., empty string) - const currentSize = formData.resource_size || "SMALL"; - const currentIndex = resourceSizes.indexOf( - currentSize as (typeof resourceSizes)[number], - ); - const safeIndex = currentIndex === -1 ? 0 : currentIndex; - const newIndex = key.leftArrow - ? Math.max(0, safeIndex - 1) - : Math.min(resourceSizes.length - 1, safeIndex + 1); - setFormData({ - ...formData, - resource_size: resourceSizes[newIndex], - }); - } - return; - } }); // Validate custom resource configuration @@ -533,78 +525,39 @@ export const DevboxCreatePage = ({ if (field.type === "action") { return ( - - - {isActive ? figures.pointer : " "} {field.label} - - {isActive && ( - - {" "} - [Enter to create] - - )} - + ); } if (field.type === "text") { return ( - - - {isActive ? figures.pointer : " "} {field.label}:{" "} - - {isActive ? ( - { - setFormData({ ...formData, [field.key]: value }); - }} - placeholder={ - field.key === "name" - ? "my-devbox" - : field.key === "keep_alive" - ? "3600" - : field.key === "blueprint_id" - ? "bp_xxx" - : field.key === "snapshot_id" - ? "snap_xxx" - : "" - } - /> - ) : ( - - {String(fieldData || "(empty)")} - - )} - + setFormData({ ...formData, [field.key]: value })} + isActive={isActive} + placeholder={field.placeholder} + /> ); } if (field.type === "select") { const value = fieldData as string; return ( - - - {isActive ? figures.pointer : " "} {field.label}: - - - {" "} - {value || "(none)"} - - {isActive && ( - - {" "} - [{figures.arrowLeft} - {figures.arrowRight} to change] - - )} - + setFormData({ ...formData, [field.key]: newValue })} + isActive={isActive} + /> ); } diff --git a/src/components/DevboxDetailPage.tsx b/src/components/DevboxDetailPage.tsx index 829f64e5..305ec349 100644 --- a/src/components/DevboxDetailPage.tsx +++ b/src/components/DevboxDetailPage.tsx @@ -1,15 +1,19 @@ +/** + * DevboxDetailPage - Detail page for devboxes + * Uses the generic ResourceDetailPage component with devbox-specific customizations + */ import React from "react"; -import { Box, Text, useInput } from "ink"; +import { Text } from "ink"; import figures from "figures"; -import { Header } from "./Header.js"; -import { StatusBadge } from "./StatusBadge.js"; -import { Breadcrumb } from "./Breadcrumb.js"; import { DevboxActionsMenu } from "./DevboxActionsMenu.js"; import { StateHistory } from "./StateHistory.js"; +import { + ResourceDetailPage, + type DetailSection, + type ResourceOperation, +} from "./ResourceDetailPage.js"; import { getDevboxUrl } from "../utils/url.js"; import { colors } from "../utils/theme.js"; -import { useViewportHeight } from "../hooks/useViewportHeight.js"; -import { useExitOnCtrlC } from "../hooks/useExitOnCtrlC.js"; import { getDevbox } from "../services/devboxService.js"; import type { Devbox } from "../store/devboxStore.js"; @@ -40,71 +44,18 @@ 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, }: DevboxDetailPageProps) => { - const isMounted = React.useRef(true); - - // Track mounted state - React.useEffect(() => { - isMounted.current = true; - return () => { - isMounted.current = false; - }; - }, []); - - // Local state for devbox data (updated by polling) - const [currentDevbox, setCurrentDevbox] = React.useState(initialDevbox); - - const [showDetailedInfo, setShowDetailedInfo] = React.useState(false); - const [detailScroll, setDetailScroll] = React.useState(0); const [showActions, setShowActions] = React.useState(false); - const [selectedOperation, setSelectedOperation] = React.useState(0); - - // Background polling for devbox details - React.useEffect(() => { - // Skip polling if showing actions, detailed info, or not mounted - if (showActions || showDetailedInfo) return; - - const interval = setInterval(async () => { - // Only poll when not in actions/detail mode and component is mounted - if (!showActions && !showDetailedInfo && isMounted.current) { - try { - const updatedDevbox = await getDevbox(initialDevbox.id); - - // Only update if still mounted - if (isMounted.current) { - setCurrentDevbox(updatedDevbox); - } - } catch { - // Silently ignore polling errors to avoid disrupting user experience - } - } - }, 3000); // Poll every 3 seconds - - return () => clearInterval(interval); - }, [initialDevbox.id, showActions, showDetailedInfo]); - - // Calculate viewport for detailed info view: - // - Breadcrumb (3 lines + marginBottom): 4 lines - // - Header (title + underline + marginBottom): 3 lines - // - Status box (content + marginBottom): 2 lines - // - Content box (marginTop + border + paddingY top/bottom + border + marginBottom): 6 lines - // - Help bar (marginTop + content): 2 lines - // - Safety buffer: 1 line - // Total: 18 lines - const detailViewport = useViewportHeight({ overhead: 18, minHeight: 10 }); - - const selectedDevbox = currentDevbox; + const [selectedOperationKey, setSelectedOperationKey] = React.useState< + string | null + >(null); + const [currentDevbox, setCurrentDevbox] = React.useState(initialDevbox); - const allOperations = [ + // All possible operations for devboxes + const allOperations: ResourceOperation[] = [ { key: "logs", label: "View Logs", @@ -171,128 +122,208 @@ export const DevboxDetailPage = ({ ]; // Filter operations based on devbox status - const operations = selectedDevbox - ? allOperations.filter((op) => { - const status = selectedDevbox.status; + const getFilteredOperations = (devbox: Devbox): ResourceOperation[] => { + return allOperations.filter((op) => { + const status = devbox.status; - // When suspended: logs and resume - if (status === "suspended") { - return op.key === "resume" || op.key === "logs"; - } + // When suspended: logs and resume + if (status === "suspended") { + return op.key === "resume" || op.key === "logs"; + } - // When not running (shutdown, failure, etc): only logs - if ( - status !== "running" && - status !== "provisioning" && - status !== "initializing" - ) { - return op.key === "logs"; - } + // When not running (shutdown, failure, etc): only logs + if ( + status !== "running" && + status !== "provisioning" && + status !== "initializing" + ) { + return op.key === "logs"; + } - // When running: everything except resume - if (status === "running") { - return op.key !== "resume"; - } + // When running: everything except resume + if (status === "running") { + return op.key !== "resume"; + } - // Default for transitional states (provisioning, initializing) - return op.key === "logs" || op.key === "delete"; - }) - : allOperations; + // Default for transitional states (provisioning, initializing) + return op.key === "logs" || op.key === "delete"; + }); + }; - const formattedCreateTime = selectedDevbox.create_time_ms - ? new Date(selectedDevbox.create_time_ms).toLocaleString() - : ""; + // Build detail sections for the devbox + const buildDetailSections = (devbox: Devbox): DetailSection[] => { + const sections: DetailSection[] = []; + const lp = devbox.launch_parameters; + + // Calculate uptime + const uptime = devbox.create_time_ms + ? Math.floor((Date.now() - devbox.create_time_ms) / 1000 / 60) + : null; + + // Details section + const detailFields = []; + + // Created / Ended time + if (devbox.create_time_ms) { + const createTime = new Date(devbox.create_time_ms).toLocaleString(); + const timeAgo = formatTimeAgo(devbox.create_time_ms); + if (devbox.end_time_ms) { + const endTime = new Date(devbox.end_time_ms).toLocaleString(); + detailFields.push({ + label: "Created", + value: `${createTime} → ${endTime}`, + }); + } else { + detailFields.push({ + label: "Created", + value: `${createTime} (${timeAgo})`, + }); + } + } - const createTimeAgo = selectedDevbox.create_time_ms - ? formatTimeAgo(selectedDevbox.create_time_ms) - : ""; + // Resources + if ( + lp?.resource_size_request || + lp?.custom_cpu_cores || + lp?.custom_gb_memory || + lp?.custom_disk_size || + lp?.architecture + ) { + const 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(" • "); + detailFields.push({ + label: "Resources", + value: resources, + }); + } - // Handle Ctrl+C to exit - useExitOnCtrlC(); + // Lifetime with remaining time + if (lp?.keep_alive_time_seconds) { + const lifetimeStr = + 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`; + + let remainingText = ""; + if (uptime !== null && devbox.status === "running") { + const maxLifetimeMinutes = Math.floor(lp.keep_alive_time_seconds / 60); + const remainingMinutes = maxLifetimeMinutes - uptime; + if (remainingMinutes <= 0) { + remainingText = " (Expired)"; + } else if (remainingMinutes < 60) { + remainingText = ` (${remainingMinutes}m remaining)`; + } else { + const hours = Math.floor(remainingMinutes / 60); + const mins = remainingMinutes % 60; + remainingText = ` (${hours}h ${mins}m remaining)`; + } + } - useInput((input, key) => { - // Don't process input if unmounting - if (!isMounted.current) return; + detailFields.push({ + label: "Lifetime", + value: lifetimeStr + remainingText, + }); + } - // Skip input handling when in actions view - if (showActions) { - return; + // User + if (lp?.user_parameters) { + const username = lp.user_parameters.username || "default"; + const uid = + lp.user_parameters.uid != null && lp.user_parameters.uid !== 0 + ? ` (UID: ${lp.user_parameters.uid})` + : ""; + detailFields.push({ + label: "User", + value: username + uid, + }); } - // Handle detailed info mode - if (showDetailedInfo) { - if (input === "q" || key.escape) { - setShowDetailedInfo(false); - setDetailScroll(0); - } else if (input === "j" || input === "s" || key.downArrow) { - // Scroll down in detailed info - setDetailScroll(detailScroll + 1); - } else if (input === "k" || input === "w" || key.upArrow) { - // Scroll up in detailed info - setDetailScroll(Math.max(0, detailScroll - 1)); - } else if (key.pageDown) { - // Page down - setDetailScroll(detailScroll + 10); - } else if (key.pageUp) { - // Page up - setDetailScroll(Math.max(0, detailScroll - 10)); - } - return; + // Source + if (devbox.blueprint_id || devbox.snapshot_id) { + detailFields.push({ + label: "Source", + value: ( + + {devbox.blueprint_id || devbox.snapshot_id} + + ), + }); } - // Main view input handling - if (input === "q" || key.escape) { - onBack(); - } else if (input === "i") { - setShowDetailedInfo(true); - setDetailScroll(0); - } else if (key.upArrow && selectedOperation > 0) { - setSelectedOperation(selectedOperation - 1); - } else if (key.downArrow && selectedOperation < operations.length - 1) { - setSelectedOperation(selectedOperation + 1); - } else if (key.return || input === "a") { - setShowActions(true); - } else if (input) { - // Check if input matches any operation shortcut - const matchedOpIndex = operations.findIndex( - (op) => op.shortcut === input, - ); - if (matchedOpIndex !== -1) { - setSelectedOperation(matchedOpIndex); - setShowActions(true); - } + // Initiator + if (devbox.initiator_id) { + detailFields.push({ + label: "Initiator", + value: ( + {devbox.initiator_id} + ), + }); } - if (input === "o") { - // Open in browser - const url = getDevboxUrl(selectedDevbox.id); - const openBrowser = async () => { - const { exec } = await import("child_process"); - const platform = process.platform; - - let openCommand: string; - if (platform === "darwin") { - openCommand = `open "${url}"`; - } else if (platform === "win32") { - openCommand = `start "${url}"`; - } else { - openCommand = `xdg-open "${url}"`; - } + // Capabilities + const hasCapabilities = + devbox.capabilities && + devbox.capabilities.filter((c: string) => c !== "unknown").length > 0; + if (hasCapabilities) { + detailFields.push({ + label: "Capabilities", + value: devbox.capabilities + .filter((c: string) => c !== "unknown") + .join(", "), + }); + } - exec(openCommand); - }; - openBrowser(); + if (detailFields.length > 0) { + sections.push({ + title: "Details", + icon: figures.squareSmallFilled, + color: colors.warning, + fields: detailFields, + }); } - }); - const uptime = selectedDevbox.create_time_ms - ? Math.floor((Date.now() - selectedDevbox.create_time_ms) / 1000 / 60) - : null; + // Metadata section + if (devbox.metadata && Object.keys(devbox.metadata).length > 0) { + sections.push({ + title: "Metadata", + icon: figures.identical, + color: colors.secondary, + fields: Object.entries(devbox.metadata).map(([key, value]) => ({ + label: key, + value: value as string, + })), + }); + } - // Build detailed info lines for scrolling - const buildDetailLines = (): React.ReactElement[] => { - const lines: React.ReactElement[] = []; + // Error section + if (devbox.failure_reason) { + sections.push({ + title: "Error", + icon: figures.cross, + color: colors.error, + fields: [ + { + label: "Failure Reason", + value: devbox.failure_reason, + color: colors.error, + }, + ], + }); + } + return sections; + }; + + // Build detailed info lines for full details view + const buildDetailLines = (devbox: Devbox): React.ReactElement[] => { + const lines: React.ReactElement[] = []; const capitalize = (str: string) => str.charAt(0).toUpperCase() + str.slice(1); @@ -305,47 +336,47 @@ export const DevboxDetailPage = ({ lines.push( {" "} - ID: {selectedDevbox.id} + ID: {devbox.id} , ); lines.push( {" "} - Name: {selectedDevbox.name || "(none)"} + Name: {devbox.name || "(none)"} , ); lines.push( {" "} - Status: {capitalize(selectedDevbox.status)} + Status: {capitalize(devbox.status)} , ); - if (selectedDevbox.create_time_ms) { + if (devbox.create_time_ms) { lines.push( {" "} - Created: {new Date(selectedDevbox.create_time_ms).toLocaleString()} + Created: {new Date(devbox.create_time_ms).toLocaleString()} , ); } - if (selectedDevbox.end_time_ms) { + if (devbox.end_time_ms) { lines.push( {" "} - Ended: {new Date(selectedDevbox.end_time_ms).toLocaleString()} + Ended: {new Date(devbox.end_time_ms).toLocaleString()} , ); } lines.push( ); // Capabilities - if (selectedDevbox.capabilities && selectedDevbox.capabilities.length > 0) { + if (devbox.capabilities && devbox.capabilities.length > 0) { lines.push( Capabilities , ); - selectedDevbox.capabilities.forEach((cap: string, idx: number) => { + devbox.capabilities.forEach((cap: string, idx: number) => { lines.push( {" "} @@ -357,14 +388,14 @@ export const DevboxDetailPage = ({ } // Launch Parameters - if (selectedDevbox.launch_parameters) { + if (devbox.launch_parameters) { lines.push( Launch Parameters , ); - const lp = selectedDevbox.launch_parameters; + const lp = devbox.launch_parameters; if (lp.resource_size_request) { lines.push( @@ -411,8 +442,7 @@ export const DevboxDetailPage = ({ {" "} Keep Alive: {lp.keep_alive_time_seconds}s ( - {Math.floor(lp.keep_alive_time_seconds / 60)} - m) + {Math.floor(lp.keep_alive_time_seconds / 60)}m) , ); } @@ -440,14 +470,14 @@ export const DevboxDetailPage = ({ Launch Commands: , ); - // lp.launch_commands.forEach((cmd: string, idx: number) => { - // lines.push( - // - // {" "} - // {figures.pointer} {cmd} - // , - // ); - // }); + lp.launch_commands.forEach((cmd: string, idx: number) => { + lines.push( + + {" "} + {figures.pointer} {cmd} + , + ); + }); } if (lp.required_services && lp.required_services.length > 0) { lines.push( @@ -467,7 +497,7 @@ export const DevboxDetailPage = ({ if (lp.user_parameters.username) { lines.push( - {" "} + {" "} Username: {lp.user_parameters.username} , ); @@ -475,7 +505,7 @@ export const DevboxDetailPage = ({ if (lp.user_parameters.uid) { lines.push( - {" "} + {" "} UID: {lp.user_parameters.uid} , ); @@ -485,25 +515,25 @@ export const DevboxDetailPage = ({ } // Source - if (selectedDevbox.blueprint_id || selectedDevbox.snapshot_id) { + if (devbox.blueprint_id || devbox.snapshot_id) { lines.push( Source , ); - if (selectedDevbox.blueprint_id) { + if (devbox.blueprint_id) { lines.push( {" "} - {selectedDevbox.blueprint_id} + Blueprint: {devbox.blueprint_id} , ); } - if (selectedDevbox.snapshot_id) { + if (devbox.snapshot_id) { lines.push( {" "} - {selectedDevbox.snapshot_id} + Snapshot: {devbox.snapshot_id} , ); } @@ -511,7 +541,7 @@ export const DevboxDetailPage = ({ } // Initiator - if (selectedDevbox.initiator_type) { + if (devbox.initiator_type) { lines.push( Initiator @@ -520,14 +550,14 @@ export const DevboxDetailPage = ({ lines.push( {" "} - Type: {selectedDevbox.initiator_type} + Type: {devbox.initiator_type} , ); - if (selectedDevbox.initiator_id) { + if (devbox.initiator_id) { lines.push( {" "} - ID: {selectedDevbox.initiator_id} + ID: {devbox.initiator_id} , ); } @@ -535,25 +565,25 @@ export const DevboxDetailPage = ({ } // Status Details - if (selectedDevbox.failure_reason || selectedDevbox.shutdown_reason) { + if (devbox.failure_reason || devbox.shutdown_reason) { lines.push( Status Details , ); - if (selectedDevbox.failure_reason) { + if (devbox.failure_reason) { lines.push( - + {" "} - Failure Reason: {selectedDevbox.failure_reason} + Failure Reason: {devbox.failure_reason} , ); } - if (selectedDevbox.shutdown_reason) { + if (devbox.shutdown_reason) { lines.push( {" "} - Shutdown Initiator: {selectedDevbox.shutdown_reason} + Shutdown Initiator: {devbox.shutdown_reason} , ); } @@ -561,16 +591,13 @@ export const DevboxDetailPage = ({ } // Metadata - if ( - selectedDevbox.metadata && - Object.keys(selectedDevbox.metadata).length > 0 - ) { + if (devbox.metadata && Object.keys(devbox.metadata).length > 0) { lines.push( Metadata , ); - Object.entries(selectedDevbox.metadata).forEach(([key, value], idx) => { + Object.entries(devbox.metadata).forEach(([key, value], idx) => { lines.push( {" "} @@ -582,17 +609,14 @@ export const DevboxDetailPage = ({ } // State Transitions - if ( - selectedDevbox.state_transitions && - selectedDevbox.state_transitions.length > 0 - ) { + if (devbox.state_transitions && devbox.state_transitions.length > 0) { lines.push( State History , ); ( - selectedDevbox.state_transitions as Array<{ + devbox.state_transitions as Array<{ status: string; transition_time_ms?: number; }> @@ -614,7 +638,7 @@ export const DevboxDetailPage = ({ Raw JSON , ); - const jsonLines = JSON.stringify(selectedDevbox, null, 2).split("\n"); + const jsonLines = JSON.stringify(devbox, null, 2).split("\n"); jsonLines.forEach((line, idx) => { lines.push( @@ -627,381 +651,67 @@ export const DevboxDetailPage = ({ return lines; }; - // Actions view - show the DevboxActionsMenu when an action is triggered + // Handle operation selection + const handleOperation = (operation: string, _devbox: Devbox) => { + setSelectedOperationKey(operation); + setShowActions(true); + }; + + // Polling function + const pollDevbox = React.useCallback(async () => { + const updated = await getDevbox(initialDevbox.id); + setCurrentDevbox(updated); + return updated; + }, [initialDevbox.id]); + + // Show DevboxActionsMenu when an action is selected if (showActions) { - const selectedOp = operations[selectedOperation]; return ( { setShowActions(false); - setSelectedOperation(0); + setSelectedOperationKey(null); }} breadcrumbItems={[ { label: "Devboxes" }, - { label: selectedDevbox.name || selectedDevbox.id }, + { label: currentDevbox.name || currentDevbox.id }, ]} - initialOperation={selectedOp?.key} + initialOperation={selectedOperationKey || undefined} skipOperationsMenu={true} /> ); } - // Detailed info mode - full screen - if (showDetailedInfo) { - const detailLines = buildDetailLines(); - const viewportHeight = detailViewport.viewportHeight; - const maxScroll = Math.max(0, detailLines.length - viewportHeight); - const actualScroll = Math.min(detailScroll, maxScroll); - const visibleLines = detailLines.slice( - actualScroll, - actualScroll + viewportHeight, - ); - const hasMore = actualScroll + viewportHeight < detailLines.length; - const hasLess = actualScroll > 0; - - return ( - <> - -
- - - - - {selectedDevbox.id} - - - - - {visibleLines} - - - - - {figures.arrowUp} - {figures.arrowDown} Scroll • Line {actualScroll + 1}- - {Math.min(actualScroll + viewportHeight, detailLines.length)} of{" "} - {detailLines.length} - - {hasLess && {figures.arrowUp}} - {hasMore && {figures.arrowDown}} - - {" "} - • [q or esc] Back to Details - - - - ); - } - - // Main detail view - const lp = selectedDevbox.launch_parameters; - const hasCapabilities = - selectedDevbox.capabilities && - selectedDevbox.capabilities.filter((c: string) => c !== "unknown").length > - 0; + // Determine if we should poll based on status + const shouldPoll = + currentDevbox.status === "running" || + currentDevbox.status === "provisioning" || + currentDevbox.status === "initializing" || + currentDevbox.status === "resuming" || + currentDevbox.status === "suspending"; return ( - <> - - - {/* Main info section */} - - - - {truncateString( - selectedDevbox.name || selectedDevbox.id, - Math.max(20, detailViewport.terminalWidth - 35), - )} - - {/* Only show ID separately if there's a name */} - {selectedDevbox.name && ( - • {selectedDevbox.id} - )} - - - - {uptime !== null && selectedDevbox.status === "running" && ( - - {" "} - • 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 */} - {(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 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} Error - - - {selectedDevbox.failure_reason} - - - )} - - {/* State History */} - - - {/* Actions section */} - - - {figures.play} Actions - - - {operations.map((op, index) => { - const isSelected = index === selectedOperation; - return ( - - - {isSelected ? figures.pointer : " "}{" "} - - - {op.icon} {op.label} - - - {" "} - [{op.shortcut}] - - - ); - })} - - - - - - {figures.arrowUp} - {figures.arrowDown} Navigate • [Enter] Execute • [i] Full Details • - [o] Browser • [q] Back - - - + d.name || d.id} + getId={(d) => d.id} + getStatus={(d) => d.status} + getUrl={(d) => getDevboxUrl(d.id)} + detailSections={buildDetailSections(currentDevbox)} + operations={getFilteredOperations(currentDevbox)} + onOperation={handleOperation} + onBack={onBack} + buildDetailLines={buildDetailLines} + additionalContent={ + + } + pollResource={shouldPoll ? pollDevbox : undefined} + pollInterval={3000} + /> ); }; diff --git a/src/components/MainMenu.tsx b/src/components/MainMenu.tsx index 115c2603..5a490c09 100644 --- a/src/components/MainMenu.tsx +++ b/src/components/MainMenu.tsx @@ -40,6 +40,13 @@ const menuItems: MenuItem[] = [ icon: "◈", color: colors.accent3, }, + { + key: "network-policies", + label: "Network Policies", + description: "Manage egress network access rules", + icon: "◇", + color: colors.info, + }, ]; interface MainMenuProps { @@ -74,6 +81,8 @@ export const MainMenu = ({ onSelect }: MainMenuProps) => { onSelect("blueprints"); } else if (input === "s" || input === "3") { onSelect("snapshots"); + } else if (input === "n" || input === "4") { + onSelect("network-policies"); } else if (input === "u" && updateAvailable) { // Release terminal and exec into update command (never returns) execCommand("sh", [ @@ -134,7 +143,7 @@ export const MainMenu = ({ onSelect }: MainMenuProps) => { {figures.arrowUp} - {figures.arrowDown} Navigate • [1-3] Quick select • [Enter] Select • + {figures.arrowDown} Navigate • [1-4] Quick select • [Enter] Select • [Esc] Quit {updateAvailable && " • [u] Update"} @@ -214,7 +223,7 @@ export const MainMenu = ({ onSelect }: MainMenuProps) => { {figures.arrowUp} - {figures.arrowDown} Navigate • [1-3] Quick select • [Enter] Select • + {figures.arrowDown} Navigate • [1-4] Quick select • [Enter] Select • [Esc] Quit {updateAvailable && " • [u] Update"} diff --git a/src/components/NetworkPolicyCreatePage.tsx b/src/components/NetworkPolicyCreatePage.tsx new file mode 100644 index 00000000..111e26d6 --- /dev/null +++ b/src/components/NetworkPolicyCreatePage.tsx @@ -0,0 +1,403 @@ +/** + * NetworkPolicyCreatePage - Form for creating a new network policy + */ +import React from "react"; +import { Box, Text, useInput } from "ink"; +import figures from "figures"; +import type { NetworkPolicyView } from "@runloop/api-client/resources/network-policies"; +import { getClient } from "../utils/client.js"; +import { SpinnerComponent } from "./Spinner.js"; +import { ErrorMessage } from "./ErrorMessage.js"; +import { SuccessMessage } from "./SuccessMessage.js"; +import { Breadcrumb } from "./Breadcrumb.js"; +import { + FormTextInput, + FormSelect, + FormActionButton, + FormListManager, + useFormSelectNavigation, +} from "./form/index.js"; +import { colors } from "../utils/theme.js"; +import { useExitOnCtrlC } from "../hooks/useExitOnCtrlC.js"; + +interface NetworkPolicyCreatePageProps { + onBack: () => void; + onCreate?: (policy: NetworkPolicyView) => void; +} + +type FormField = + | "create" + | "name" + | "description" + | "allow_all" + | "allow_devbox_to_devbox" + | "allowed_hostnames"; + +interface FormData { + name: string; + description: string; + allow_all: "Yes" | "No"; + allow_devbox_to_devbox: "Yes" | "No"; + allowed_hostnames: string[]; +} + +const BOOLEAN_OPTIONS = ["Yes", "No"] as const; + +export const NetworkPolicyCreatePage = ({ + onBack, + onCreate, +}: NetworkPolicyCreatePageProps) => { + const [currentField, setCurrentField] = React.useState("create"); + const [formData, setFormData] = React.useState({ + name: "", + description: "", + allow_all: "No", + allow_devbox_to_devbox: "No", + allowed_hostnames: [], + }); + const [hostnamesExpanded, setHostnamesExpanded] = React.useState(false); + const [creating, setCreating] = React.useState(false); + const [result, setResult] = React.useState(null); + const [error, setError] = React.useState(null); + const [validationError, setValidationError] = React.useState(null); + + const allFields: Array<{ + key: FormField; + label: string; + type: "text" | "select" | "list" | "action"; + }> = [ + { key: "create", label: "Create Network Policy", type: "action" }, + { key: "name", label: "Name (required)", type: "text" }, + { key: "description", label: "Description", type: "text" }, + { key: "allow_all", label: "Allow All Egress", type: "select" }, + { key: "allow_devbox_to_devbox", label: "Allow Devbox-to-Devbox", type: "select" }, + { key: "allowed_hostnames", label: "Allowed Hostnames", type: "list" }, + ]; + + // Hide allowed_hostnames when allow_all is enabled + const fields = formData.allow_all === "Yes" + ? allFields.filter((f) => f.key !== "allowed_hostnames") + : allFields; + + const currentFieldIndex = fields.findIndex((f) => f.key === currentField); + + // Handle Ctrl+C to exit + useExitOnCtrlC(); + + // Select navigation handlers + const handleAllowAllNav = useFormSelectNavigation( + formData.allow_all, + BOOLEAN_OPTIONS, + (value) => { + setFormData({ ...formData, allow_all: value }); + // If enabling allow_all and currently on hostnames field, move to previous field + if (value === "Yes" && currentField === "allowed_hostnames") { + setCurrentField("allow_devbox_to_devbox"); + setHostnamesExpanded(false); + } + }, + currentField === "allow_all", + ); + + const handleDevboxNav = useFormSelectNavigation( + formData.allow_devbox_to_devbox, + BOOLEAN_OPTIONS, + (value) => setFormData({ ...formData, allow_devbox_to_devbox: value }), + currentField === "allow_devbox_to_devbox", + ); + + useInput((input, key) => { + // Handle result screen + if (result) { + if (input === "q" || key.escape || key.return) { + if (onCreate) { + onCreate(result); + } + onBack(); + } + return; + } + + // Handle error screen + if (error) { + if (input === "r" || key.return) { + // Retry - clear error and return to form + setError(null); + } else if (input === "q" || key.escape) { + // Quit - go back to list + onBack(); + } + return; + } + + // Handle creating state + if (creating) { + return; + } + + // Handle hostnames expanded mode - let FormListManager handle input + if (hostnamesExpanded) { + return; + } + + // Back to list + if (input === "q" || key.escape) { + onBack(); + return; + } + + // Submit form with Ctrl+S + if (input === "s" && key.ctrl) { + handleCreate(); + return; + } + + // Handle Enter on create field + if (currentField === "create" && key.return) { + handleCreate(); + return; + } + + // Handle Enter on hostnames field to expand + if (currentField === "allowed_hostnames" && key.return) { + setHostnamesExpanded(true); + return; + } + + // Handle select field navigation + if (handleAllowAllNav(input, key)) return; + if (handleDevboxNav(input, key)) return; + + // Navigation between fields + if (key.upArrow && currentFieldIndex > 0) { + setCurrentField(fields[currentFieldIndex - 1].key); + return; + } + + if (key.downArrow && currentFieldIndex < fields.length - 1) { + setCurrentField(fields[currentFieldIndex + 1].key); + return; + } + }); + + const handleCreate = async () => { + // Validate required fields + if (!formData.name.trim()) { + setValidationError("Name is required"); + setCurrentField("name"); + return; + } + + setCreating(true); + setError(null); + setValidationError(null); + + try { + const client = getClient(); + + const createParams: { + name: string; + description?: string; + allow_all?: boolean; + allow_devbox_to_devbox?: boolean; + allowed_hostnames?: string[]; + } = { + name: formData.name.trim(), + }; + + if (formData.description.trim()) { + createParams.description = formData.description.trim(); + } + + createParams.allow_all = formData.allow_all === "Yes"; + createParams.allow_devbox_to_devbox = formData.allow_devbox_to_devbox === "Yes"; + + if (formData.allowed_hostnames.length > 0) { + createParams.allowed_hostnames = formData.allowed_hostnames; + } + + const policy = await client.networkPolicies.create(createParams); + setResult(policy); + } catch (err) { + setError(err as Error); + } finally { + setCreating(false); + } + }; + + // Result screen + if (result) { + return ( + <> + + + + + + ID:{" "} + + {result.id} + + + + Name: {result.name} + + + + + Allow All: {result.egress.allow_all ? "Yes" : "No"} + + + + + + Press [Enter], [q], or [esc] to return to list + + + + ); + } + + // Error screen + if (error) { + return ( + <> + + + + + Press [Enter] or [r] to retry • [q] or [esc] to cancel + + + + ); + } + + // Creating screen + if (creating) { + return ( + <> + + + + ); + } + + // Form screen + return ( + <> + + + + {fields.map((field) => { + const isActive = currentField === field.key; + + if (field.type === "action") { + return ( + + ); + } + + if (field.type === "text") { + const value = formData[field.key as keyof FormData] as string; + const hasError = field.key === "name" && validationError; + return ( + { + setFormData({ ...formData, [field.key]: newValue }); + // Clear validation error when typing in the field with error + if (field.key === "name" && validationError) { + setValidationError(null); + } + }} + isActive={isActive} + placeholder={ + field.key === "name" + ? "my-network-policy" + : field.key === "description" + ? "Policy description" + : "" + } + error={hasError ? validationError : undefined} + /> + ); + } + + if (field.type === "select") { + const value = formData[field.key as keyof FormData] as "Yes" | "No"; + return ( + + setFormData({ ...formData, [field.key]: newValue }) + } + isActive={isActive} + /> + ); + } + + if (field.type === "list") { + return ( + + setFormData({ ...formData, allowed_hostnames: items }) + } + isActive={isActive} + isExpanded={hostnamesExpanded} + onExpandedChange={setHostnamesExpanded} + itemPlaceholder="github.com or *.npmjs.org" + addLabel="+ Add hostname" + collapsedLabel="hostname(s)" + /> + ); + } + + return null; + })} + + + {!hostnamesExpanded && ( + + + {figures.arrowUp} + {figures.arrowDown} Navigate • [Enter] Create/Expand • [q] Cancel + + + )} + + ); +}; diff --git a/src/components/ResourceDetailPage.tsx b/src/components/ResourceDetailPage.tsx new file mode 100644 index 00000000..47244dc2 --- /dev/null +++ b/src/components/ResourceDetailPage.tsx @@ -0,0 +1,415 @@ +/** + * ResourceDetailPage - Generic detail page component for resources + * Can be used for devboxes, blueprints, snapshots, etc. + */ +import React from "react"; +import { Box, Text, useInput } from "ink"; +import figures from "figures"; +import { Header } from "./Header.js"; +import { StatusBadge } from "./StatusBadge.js"; +import { Breadcrumb } from "./Breadcrumb.js"; +import { colors } from "../utils/theme.js"; +import { useViewportHeight } from "../hooks/useViewportHeight.js"; +import { useExitOnCtrlC } from "../hooks/useExitOnCtrlC.js"; + +// Types for configurable detail sections +export interface DetailField { + label: string; + value: string | React.ReactNode | undefined | null; + color?: string; +} + +export interface DetailSection { + title: string; + icon?: string; + color?: string; + fields: DetailField[]; +} + +export interface ResourceOperation { + key: string; + label: string; + color: string; + icon: string; + shortcut: string; +} + +export interface ResourceDetailPageProps { + /** The resource being displayed */ + resource: T; + /** Resource type name for breadcrumbs (e.g., "Blueprints", "Snapshots") */ + resourceType: string; + /** Get display name for the resource */ + getDisplayName: (resource: T) => string; + /** Get resource ID */ + getId: (resource: T) => string; + /** Get resource status */ + getStatus: (resource: T) => string; + /** Optional: Get URL to open in browser */ + getUrl?: (resource: T) => string; + /** Breadcrumb items before the resource name */ + breadcrumbPrefix?: Array<{ label: string; active?: boolean }>; + /** Detail sections to display in main view */ + detailSections: DetailSection[]; + /** Available operations/actions */ + operations: ResourceOperation[]; + /** Callback when operation is selected */ + onOperation: (operation: string, resource: T) => void; + /** Callback to go back */ + onBack: () => void; + /** Optional: Build detailed info lines for full details view */ + buildDetailLines?: (resource: T) => React.ReactElement[]; + /** Optional: Additional content to render after details section */ + additionalContent?: React.ReactNode; + /** Optional: Polling function to refresh resource data */ + pollResource?: () => Promise; + /** Polling interval in ms (default: 3000) */ + pollInterval?: number; +} + +// Format time ago in a succinct way +const formatTimeAgo = (timestamp: number): string => { + const seconds = Math.floor((Date.now() - timestamp) / 1000); + + if (seconds < 60) return `${seconds}s ago`; + + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + + const days = Math.floor(hours / 24); + if (days < 30) return `${days}d ago`; + + const months = Math.floor(days / 30); + if (months < 12) return `${months}mo ago`; + + const years = Math.floor(months / 12); + 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 function ResourceDetailPage({ + resource: initialResource, + resourceType, + getDisplayName, + getId, + getStatus, + getUrl, + breadcrumbPrefix = [], + detailSections, + operations, + onOperation, + onBack, + buildDetailLines, + additionalContent, + pollResource, + pollInterval = 3000, +}: ResourceDetailPageProps) { + const isMounted = React.useRef(true); + + // Track mounted state + React.useEffect(() => { + isMounted.current = true; + return () => { + isMounted.current = false; + }; + }, []); + + // Local state for resource data (updated by polling) + const [currentResource, setCurrentResource] = React.useState(initialResource); + + const [showDetailedInfo, setShowDetailedInfo] = React.useState(false); + const [detailScroll, setDetailScroll] = React.useState(0); + const [selectedOperation, setSelectedOperation] = React.useState(0); + + // Background polling for resource details + React.useEffect(() => { + if (!pollResource || showDetailedInfo) return; + + const interval = setInterval(async () => { + if (isMounted.current) { + try { + const updatedResource = await pollResource(); + if (isMounted.current) { + setCurrentResource(updatedResource); + } + } catch { + // Silently ignore polling errors + } + } + }, pollInterval); + + return () => clearInterval(interval); + }, [pollResource, pollInterval, showDetailedInfo]); + + // Calculate viewport for detailed info view + const detailViewport = useViewportHeight({ overhead: 18, minHeight: 10 }); + + const displayName = getDisplayName(currentResource); + const resourceId = getId(currentResource); + const status = getStatus(currentResource); + + // Handle Ctrl+C to exit + useExitOnCtrlC(); + + useInput((input, key) => { + if (!isMounted.current) return; + + // Handle detailed info mode + if (showDetailedInfo) { + if (input === "q" || key.escape) { + setShowDetailedInfo(false); + setDetailScroll(0); + } else if (input === "j" || input === "s" || key.downArrow) { + setDetailScroll(detailScroll + 1); + } else if (input === "k" || input === "w" || key.upArrow) { + setDetailScroll(Math.max(0, detailScroll - 1)); + } else if (key.pageDown) { + setDetailScroll(detailScroll + 10); + } else if (key.pageUp) { + setDetailScroll(Math.max(0, detailScroll - 10)); + } + return; + } + + // Main view input handling + if (input === "q" || key.escape) { + onBack(); + } else if (input === "i" && buildDetailLines) { + setShowDetailedInfo(true); + setDetailScroll(0); + } else if (key.upArrow && selectedOperation > 0) { + setSelectedOperation(selectedOperation - 1); + } else if (key.downArrow && selectedOperation < operations.length - 1) { + setSelectedOperation(selectedOperation + 1); + } else if (key.return) { + const op = operations[selectedOperation]; + if (op) { + onOperation(op.key, currentResource); + } + } else if (input) { + // Check if input matches any operation shortcut + const matchedOpIndex = operations.findIndex( + (op) => op.shortcut === input, + ); + if (matchedOpIndex !== -1) { + setSelectedOperation(matchedOpIndex); + onOperation(operations[matchedOpIndex].key, currentResource); + } + } + + if (input === "o" && getUrl) { + const url = getUrl(currentResource); + const openBrowser = async () => { + const { exec } = await import("child_process"); + const platform = process.platform; + + let openCommand: string; + if (platform === "darwin") { + openCommand = `open "${url}"`; + } else if (platform === "win32") { + openCommand = `start "${url}"`; + } else { + openCommand = `xdg-open "${url}"`; + } + + exec(openCommand); + }; + openBrowser(); + } + }); + + // Detailed info mode - full screen + if (showDetailedInfo && buildDetailLines) { + const detailLines = buildDetailLines(currentResource); + const viewportHeight = detailViewport.viewportHeight; + const maxScroll = Math.max(0, detailLines.length - viewportHeight); + const actualScroll = Math.min(detailScroll, maxScroll); + const visibleLines = detailLines.slice( + actualScroll, + actualScroll + viewportHeight, + ); + const hasMore = actualScroll + viewportHeight < detailLines.length; + const hasLess = actualScroll > 0; + + return ( + <> + +
+ + + + + {resourceId} + + + + + {visibleLines} + + + + + {figures.arrowUp} + {figures.arrowDown} Scroll • Line {actualScroll + 1}- + {Math.min(actualScroll + viewportHeight, detailLines.length)} of{" "} + {detailLines.length} + + {hasLess && {figures.arrowUp}} + {hasMore && {figures.arrowDown}} + + {" "} + • [q or esc] Back to Details + + + + ); + } + + // Main detail view + return ( + <> + + + {/* Main info section */} + + + + {truncateString( + displayName, + Math.max(20, detailViewport.terminalWidth - 35), + )} + + {/* Only show ID separately if display name is different from ID */} + {displayName !== resourceId && ( + • {resourceId} + )} + + + + + + + {/* Detail sections */} + {detailSections.map((section, sectionIndex) => ( + + + {section.icon || figures.squareSmallFilled} {section.title} + + + {section.fields + .filter((field) => field.value !== undefined && field.value !== null) + .map((field, fieldIndex) => ( + + {field.label} + {typeof field.value === "string" ? ( + + {field.value} + + ) : ( + field.value + )} + + ))} + + + ))} + + {/* Additional content (e.g., StateHistory for devboxes) */} + {additionalContent} + + {/* Actions section */} + {operations.length > 0 && ( + + + {figures.play} Actions + + + {operations.map((op, index) => { + const isSelected = index === selectedOperation; + return ( + + + {isSelected ? figures.pointer : " "}{" "} + + + {op.icon} {op.label} + + + {" "} + [{op.shortcut}] + + + ); + })} + + + )} + + + + {figures.arrowUp} + {figures.arrowDown} Navigate • [Enter] Execute + {buildDetailLines && " • [i] Full Details"} + {getUrl && " • [o] Browser"} + {" • [q] Back"} + + + + ); +} + +// Helper to format timestamp as "time (ago)" +export function formatTimestamp( + timestamp: number | undefined, +): string | undefined { + if (!timestamp) return undefined; + const formatted = new Date(timestamp).toLocaleString(); + const ago = formatTimeAgo(timestamp); + return `${formatted} (${ago})`; +} + +// Helper to format create time with arrow to end time +export function formatTimeRange( + createTime: number | undefined, + endTime: number | undefined, +): string | undefined { + if (!createTime) return undefined; + const start = new Date(createTime).toLocaleString(); + if (endTime) { + const end = new Date(endTime).toLocaleString(); + return `${start} → ${end}`; + } + return `${start} (${formatTimeAgo(createTime)})`; +} diff --git a/src/components/Table.tsx b/src/components/Table.tsx index 9dc0d433..dd87dd54 100644 --- a/src/components/Table.tsx +++ b/src/components/Table.tsx @@ -48,12 +48,10 @@ export function Table({ }: TableProps) { // Safety: Handle null/undefined data if (!data || !Array.isArray(data)) { - return emptyState ? <>{emptyState} : null; + data = []; } - if (data.length === 0 && emptyState) { - return <>{emptyState}; - } + const isEmpty = data.length === 0; // Filter visible columns const visibleColumns = columns.filter((col) => col.visible !== false); @@ -98,6 +96,23 @@ export function Table({ })} + {/* Empty state row */} + {isEmpty && ( + + {showSelection && ( + <> + + + + )} + {emptyState || ( + + {figures.info} No items found + + )} + + )} + {/* Data rows */} {data.map((row, index) => { const isSelected = index === selectedIndex; diff --git a/src/components/form/FormActionButton.tsx b/src/components/form/FormActionButton.tsx new file mode 100644 index 00000000..d3cde299 --- /dev/null +++ b/src/components/form/FormActionButton.tsx @@ -0,0 +1,33 @@ +/** + * FormActionButton - Action button for forms (like "Create") + */ +import React from "react"; +import { Box, Text } from "ink"; +import figures from "figures"; +import { colors } from "../../utils/theme.js"; + +export interface FormActionButtonProps { + label: string; + isActive: boolean; + hint?: string; +} + +export const FormActionButton = ({ + label, + isActive, + hint = "[Enter to execute]", +}: FormActionButtonProps) => { + return ( + + + {isActive ? figures.pointer : " "} {label} + + {isActive && ( + + {" "} + {hint} + + )} + + ); +}; diff --git a/src/components/form/FormField.tsx b/src/components/form/FormField.tsx new file mode 100644 index 00000000..17c9e27a --- /dev/null +++ b/src/components/form/FormField.tsx @@ -0,0 +1,44 @@ +/** + * FormField - Base wrapper for form fields with label, active state, and pointer indicator + */ +import React from "react"; +import { Box, Text } from "ink"; +import figures from "figures"; +import { colors } from "../../utils/theme.js"; + +export interface FormFieldProps { + label: string; + isActive: boolean; + children: React.ReactNode; + hint?: string; + error?: string; +} + +export const FormField = ({ + label, + isActive, + children, + hint, + error, +}: FormFieldProps) => { + return ( + + + {isActive ? figures.pointer : " "} {label}:{" "} + + {children} + {error && ( + + {" "} + {figures.cross} {error} + + )} + {hint && isActive && !error && ( + + {" "} + {hint} + + )} + + ); +}; diff --git a/src/components/form/FormListManager.tsx b/src/components/form/FormListManager.tsx new file mode 100644 index 00000000..35556e1d --- /dev/null +++ b/src/components/form/FormListManager.tsx @@ -0,0 +1,272 @@ +/** + * FormListManager - List management component for forms (add/edit/delete items) + */ +import React from "react"; +import { Box, Text, useInput } from "ink"; +import TextInput from "ink-text-input"; +import figures from "figures"; +import { colors } from "../../utils/theme.js"; + +export interface FormListManagerProps { + title: string; + items: string[]; + onItemsChange: (items: string[]) => void; + isActive: boolean; + isExpanded: boolean; + onExpandedChange: (expanded: boolean) => void; + itemPlaceholder?: string; + addLabel?: string; + collapsedLabel?: string; +} + +export const FormListManager = ({ + title, + items, + onItemsChange, + isActive, + isExpanded, + onExpandedChange, + itemPlaceholder = "item", + addLabel = "+ Add new item", + collapsedLabel = "items", +}: FormListManagerProps) => { + const [selectedIndex, setSelectedIndex] = React.useState(0); + const [inputMode, setInputMode] = React.useState<"add" | "edit" | null>(null); + const [inputValue, setInputValue] = React.useState(""); + const [editingIndex, setEditingIndex] = React.useState(-1); + + // Selection model: 0 = "Add new", 1..n = Existing items, n+1 = "Done" + const maxIndex = items.length + 1; + + useInput( + (input, key) => { + if (!isExpanded) return; + + // Handle input mode (typing) + if (inputMode) { + if (key.return && inputValue.trim()) { + if (inputMode === "add") { + onItemsChange([...items, inputValue.trim()]); + } else if (inputMode === "edit" && editingIndex >= 0) { + const newItems = [...items]; + newItems[editingIndex] = inputValue.trim(); + onItemsChange(newItems); + } + setInputValue(""); + setInputMode(null); + setEditingIndex(-1); + setSelectedIndex(0); + return; + } else if (key.escape) { + // Cancel input - restore item if editing + if (inputMode === "edit" && editingIndex >= 0) { + // Item was already removed, add it back + const newItems = [...items]; + newItems.splice(editingIndex, 0, inputValue); + onItemsChange(newItems); + } + setInputValue(""); + setInputMode(null); + setEditingIndex(-1); + return; + } + return; + } + + // Navigation mode + if (key.upArrow && selectedIndex > 0) { + setSelectedIndex(selectedIndex - 1); + } else if (key.downArrow && selectedIndex < maxIndex) { + setSelectedIndex(selectedIndex + 1); + } else if (key.return) { + if (selectedIndex === 0) { + // Add new + setInputValue(""); + setInputMode("add"); + } else if (selectedIndex === maxIndex) { + // Done - exit expanded mode + onExpandedChange(false); + setSelectedIndex(0); + } else if (selectedIndex >= 1 && selectedIndex <= items.length) { + // Edit existing (selectedIndex - 1 gives array index) + const itemIndex = selectedIndex - 1; + const itemToEdit = items[itemIndex]; + setInputValue(itemToEdit); + setEditingIndex(itemIndex); + // Remove the item while editing + const newItems = items.filter((_, i) => i !== itemIndex); + onItemsChange(newItems); + setInputMode("edit"); + } + } else if ( + (input === "d" || key.delete) && + selectedIndex >= 1 && + selectedIndex <= items.length + ) { + // Delete selected item + const itemIndex = selectedIndex - 1; + const newItems = items.filter((_, i) => i !== itemIndex); + onItemsChange(newItems); + // Adjust selection + if (selectedIndex > newItems.length) { + setSelectedIndex(Math.max(0, newItems.length)); + } + } else if (key.escape || input === "q") { + // Exit expanded mode + onExpandedChange(false); + setSelectedIndex(0); + } + }, + { isActive: isExpanded }, + ); + + // Collapsed view + if (!isExpanded) { + return ( + + + + {isActive ? figures.pointer : " "} {title}:{" "} + + + {items.length} {collapsedLabel} + + {isActive && ( + + {" "} + [Enter to manage] + + )} + + {items.length > 0 && ( + + {items.slice(0, 3).map((item, idx) => ( + + {figures.pointer} {item} + + ))} + {items.length > 3 && ( + + ... and {items.length - 3} more + + )} + + )} + + ); + } + + // Expanded view + return ( + + + {figures.hamburger} {title} + + + {/* Input form - shown when adding or editing */} + {inputMode && ( + + + {inputMode === "add" ? "Adding New" : "Editing"} + + + Value: + + + + )} + + {/* Navigation menu - shown when not in input mode */} + {!inputMode && ( + <> + {/* Add new option */} + + + {selectedIndex === 0 ? figures.pointer : " "}{" "} + + + {addLabel} + + + + {/* Existing items */} + {items.length > 0 && ( + + {items.map((item, index) => { + const itemIndex = index + 1; + const isSelected = selectedIndex === itemIndex; + return ( + + + {isSelected ? figures.pointer : " "}{" "} + + + {item} + + + ); + })} + + )} + + {/* Done option */} + + + {selectedIndex === maxIndex ? figures.pointer : " "}{" "} + + + {figures.tick} Done + + + + )} + + {/* Help text */} + + + {inputMode + ? "[Enter] Save • [esc] Cancel" + : `${figures.arrowUp}${figures.arrowDown} Navigate • [Enter] ${selectedIndex === 0 ? "Add" : selectedIndex === maxIndex ? "Done" : "Edit"} • [d] Delete • [esc] Back`} + + + + ); +}; diff --git a/src/components/form/FormSelect.tsx b/src/components/form/FormSelect.tsx new file mode 100644 index 00000000..1b1d6974 --- /dev/null +++ b/src/components/form/FormSelect.tsx @@ -0,0 +1,71 @@ +/** + * FormSelect - Left/right arrow selection field for forms + */ +import React from "react"; +import { Text } from "ink"; +import figures from "figures"; +import { FormField } from "./FormField.js"; +import { colors } from "../../utils/theme.js"; + +export interface FormSelectProps { + label: string; + value: T; + options: readonly T[]; + onChange: (value: T) => void; + isActive: boolean; + getDisplayLabel?: (value: T) => string; +} + +export function FormSelect({ + label, + value, + options, + onChange, + isActive, + getDisplayLabel, +}: FormSelectProps) { + const displayValue = getDisplayLabel ? getDisplayLabel(value) : value; + + return ( + + + {displayValue || "(none)"} + + + ); +} + +/** + * Helper hook to handle left/right arrow navigation for select fields + */ +export function useFormSelectNavigation( + value: T, + options: readonly T[], + onChange: (value: T) => void, + isActive: boolean, +) { + const handleInput = React.useCallback( + (input: string, key: { leftArrow: boolean; rightArrow: boolean }) => { + if (!isActive) return false; + + if (key.leftArrow || key.rightArrow) { + const currentIndex = options.indexOf(value); + const safeIndex = currentIndex === -1 ? 0 : currentIndex; + // Wrap around: left from first goes to last, right from last goes to first + const newIndex = key.leftArrow + ? (safeIndex - 1 + options.length) % options.length + : (safeIndex + 1) % options.length; + onChange(options[newIndex]); + return true; + } + return false; + }, + [value, options, onChange, isActive], + ); + + return handleInput; +} diff --git a/src/components/form/FormTextInput.tsx b/src/components/form/FormTextInput.tsx new file mode 100644 index 00000000..0452f15e --- /dev/null +++ b/src/components/form/FormTextInput.tsx @@ -0,0 +1,40 @@ +/** + * FormTextInput - Text input field for forms + */ +import React from "react"; +import { Text } from "ink"; +import TextInput from "ink-text-input"; +import { FormField } from "./FormField.js"; +import { colors } from "../../utils/theme.js"; + +export interface FormTextInputProps { + label: string; + value: string; + onChange: (value: string) => void; + isActive: boolean; + placeholder?: string; + error?: string; +} + +export const FormTextInput = ({ + label, + value, + onChange, + isActive, + placeholder, + error, +}: FormTextInputProps) => { + return ( + + {isActive ? ( + + ) : ( + {value || "(empty)"} + )} + + ); +}; diff --git a/src/components/form/index.ts b/src/components/form/index.ts new file mode 100644 index 00000000..fd84d2f5 --- /dev/null +++ b/src/components/form/index.ts @@ -0,0 +1,18 @@ +/** + * Form Components - Reusable form primitives for create/edit pages + */ +export { FormField, type FormFieldProps } from "./FormField.js"; +export { FormTextInput, type FormTextInputProps } from "./FormTextInput.js"; +export { + FormSelect, + useFormSelectNavigation, + type FormSelectProps, +} from "./FormSelect.js"; +export { + FormActionButton, + type FormActionButtonProps, +} from "./FormActionButton.js"; +export { + FormListManager, + type FormListManagerProps, +} from "./FormListManager.js"; diff --git a/src/router/Router.tsx b/src/router/Router.tsx index c9de0025..30c6f0d4 100644 --- a/src/router/Router.tsx +++ b/src/router/Router.tsx @@ -7,6 +7,7 @@ import { useNavigation } from "../store/navigationStore.js"; import { useDevboxStore } from "../store/devboxStore.js"; import { useBlueprintStore } from "../store/blueprintStore.js"; import { useSnapshotStore } from "../store/snapshotStore.js"; +import { useNetworkPolicyStore } from "../store/networkPolicyStore.js"; import { ErrorBoundary } from "../components/ErrorBoundary.js"; import type { ScreenName } from "../router/types.js"; @@ -17,8 +18,13 @@ import { DevboxDetailScreen } from "../screens/DevboxDetailScreen.js"; import { DevboxActionsScreen } from "../screens/DevboxActionsScreen.js"; import { DevboxCreateScreen } from "../screens/DevboxCreateScreen.js"; import { BlueprintListScreen } from "../screens/BlueprintListScreen.js"; +import { BlueprintDetailScreen } from "../screens/BlueprintDetailScreen.js"; import { BlueprintLogsScreen } from "../screens/BlueprintLogsScreen.js"; import { SnapshotListScreen } from "../screens/SnapshotListScreen.js"; +import { SnapshotDetailScreen } from "../screens/SnapshotDetailScreen.js"; +import { NetworkPolicyListScreen } from "../screens/NetworkPolicyListScreen.js"; +import { NetworkPolicyDetailScreen } from "../screens/NetworkPolicyDetailScreen.js"; +import { NetworkPolicyCreateScreen } from "../screens/NetworkPolicyCreateScreen.js"; import { SSHSessionScreen } from "../screens/SSHSessionScreen.js"; /** @@ -64,6 +70,14 @@ export function Router() { useSnapshotStore.getState().clearAll(); } break; + + case "network-policy-list": + case "network-policy-detail": + case "network-policy-create": + if (!currentScreen.startsWith("network-policy")) { + useNetworkPolicyStore.getState().clearAll(); + } + break; } } @@ -95,7 +109,7 @@ export function Router() { )} {currentScreen === "blueprint-detail" && ( - + )} {currentScreen === "blueprint-logs" && ( @@ -104,7 +118,16 @@ export function Router() { )} {currentScreen === "snapshot-detail" && ( - + + )} + {currentScreen === "network-policy-list" && ( + + )} + {currentScreen === "network-policy-detail" && ( + + )} + {currentScreen === "network-policy-create" && ( + )} {currentScreen === "ssh-session" && ( diff --git a/src/screens/BlueprintDetailScreen.tsx b/src/screens/BlueprintDetailScreen.tsx new file mode 100644 index 00000000..a37785e1 --- /dev/null +++ b/src/screens/BlueprintDetailScreen.tsx @@ -0,0 +1,525 @@ +/** + * BlueprintDetailScreen - Detail page for blueprints + * Uses the generic ResourceDetailPage component + */ +import React from "react"; +import { Text } from "ink"; +import figures from "figures"; +import { useNavigation } from "../store/navigationStore.js"; +import { + useBlueprintStore, + type Blueprint, +} from "../store/blueprintStore.js"; +import { + ResourceDetailPage, + formatTimestamp, + type DetailSection, + type ResourceOperation, +} from "../components/ResourceDetailPage.js"; +import { getBlueprint } from "../services/blueprintService.js"; +import { getClient } from "../utils/client.js"; +import { SpinnerComponent } from "../components/Spinner.js"; +import { ErrorMessage } from "../components/ErrorMessage.js"; +import { Breadcrumb } from "../components/Breadcrumb.js"; +import { colors } from "../utils/theme.js"; + +interface BlueprintDetailScreenProps { + blueprintId?: string; +} + +export function BlueprintDetailScreen({ + blueprintId, +}: BlueprintDetailScreenProps) { + const { goBack, navigate } = useNavigation(); + const blueprints = useBlueprintStore((state) => state.blueprints); + + const [loading, setLoading] = React.useState(false); + const [error, setError] = React.useState(null); + const [fetchedBlueprint, setFetchedBlueprint] = + React.useState(null); + const [deleting, setDeleting] = React.useState(false); + + // Find blueprint in store first + const blueprintFromStore = blueprints.find((b) => b.id === blueprintId); + + // Polling function - must be defined before any early returns (Rules of Hooks) + const pollBlueprint = React.useCallback(async () => { + if (!blueprintId) return null as unknown as Blueprint; + return getBlueprint(blueprintId); + }, [blueprintId]); + + // Fetch blueprint from API if not in store or missing full details + React.useEffect(() => { + if (blueprintId && !loading && !fetchedBlueprint) { + // Always fetch full details since store may only have basic info + setLoading(true); + setError(null); + + getBlueprint(blueprintId) + .then((blueprint) => { + setFetchedBlueprint(blueprint); + setLoading(false); + }) + .catch((err) => { + setError(err as Error); + setLoading(false); + }); + } + }, [blueprintId, loading, fetchedBlueprint]); + + // Use fetched blueprint for full details, fall back to store for basic display + const blueprint = fetchedBlueprint || blueprintFromStore; + + // Show loading state while fetching + if (loading && !blueprint) { + return ( + <> + + + + ); + } + + // Show error state if fetch failed + if (error && !blueprint) { + return ( + <> + + + + ); + } + + // Show error if no blueprint found + if (!blueprint) { + return ( + <> + + + + ); + } + + // Build detail sections + const detailSections: DetailSection[] = []; + + // Basic details section + const basicFields = []; + if (blueprint.create_time_ms) { + basicFields.push({ + label: "Created", + value: formatTimestamp(blueprint.create_time_ms), + }); + } + if (blueprint.architecture) { + basicFields.push({ + label: "Architecture", + value: blueprint.architecture, + }); + } + if (blueprint.resources) { + basicFields.push({ + label: "Resources", + value: blueprint.resources, + }); + } + + if (basicFields.length > 0) { + detailSections.push({ + title: "Details", + icon: figures.squareSmallFilled, + color: colors.warning, + fields: basicFields, + }); + } + + // Launch parameters section + const lp = blueprint.parameters?.launch_parameters; + if (lp) { + const lpFields = []; + if (lp.custom_cpu_cores) { + lpFields.push({ + label: "CPU Cores", + value: String(lp.custom_cpu_cores), + }); + } + if (lp.custom_gb_memory) { + lpFields.push({ + label: "Memory", + value: `${lp.custom_gb_memory}GB`, + }); + } + if (lp.custom_disk_size) { + lpFields.push({ + label: "Disk Size", + value: `${lp.custom_disk_size}GB`, + }); + } + if (lp.keep_alive_time_seconds) { + const minutes = Math.floor(lp.keep_alive_time_seconds / 60); + const hours = Math.floor(minutes / 60); + lpFields.push({ + label: "Keep Alive", + value: + hours > 0 ? `${hours}h ${minutes % 60}m` : `${minutes}m`, + }); + } + if (lp.available_ports && lp.available_ports.length > 0) { + lpFields.push({ + label: "Available Ports", + value: lp.available_ports.join(", "), + }); + } + if (lp.required_services && lp.required_services.length > 0) { + lpFields.push({ + label: "Required Services", + value: lp.required_services.join(", "), + }); + } + + if (lpFields.length > 0) { + detailSections.push({ + title: "Launch Parameters", + icon: figures.arrowRight, + color: colors.secondary, + fields: lpFields, + }); + } + } + + // Setup section + const params = blueprint.parameters; + if (params) { + const setupFields = []; + if (params.dockerfile) { + const lineCount = params.dockerfile.split("\n").length; + setupFields.push({ + label: "Dockerfile", + value: ( + + {lineCount} lines + + ), + }); + } + if ( + params.system_setup_commands && + params.system_setup_commands.length > 0 + ) { + setupFields.push({ + label: "Setup Commands", + value: `${params.system_setup_commands.length} commands`, + }); + } + if (params.file_mounts && Object.keys(params.file_mounts).length > 0) { + setupFields.push({ + label: "File Mounts", + value: `${Object.keys(params.file_mounts).length} mounts`, + }); + } + + if (setupFields.length > 0) { + detailSections.push({ + title: "Build Configuration", + icon: figures.hamburger, + color: colors.info, + fields: setupFields, + }); + } + } + + // Operations available for blueprints + const operations: ResourceOperation[] = [ + { + key: "logs", + label: "View Build Logs", + color: colors.info, + icon: figures.info, + shortcut: "l", + }, + { + key: "create-devbox", + label: "Create Devbox from Blueprint", + color: colors.success, + icon: figures.play, + shortcut: "c", + }, + { + key: "delete", + label: "Delete Blueprint", + color: colors.error, + icon: figures.cross, + shortcut: "d", + }, + ]; + + // Handle operation selection + const handleOperation = async (operation: string, resource: Blueprint) => { + switch (operation) { + case "logs": + navigate("blueprint-logs", { blueprintId: resource.id }); + break; + case "create-devbox": + navigate("devbox-create", { blueprintId: resource.id }); + break; + case "delete": + setDeleting(true); + try { + const client = getClient(); + await client.blueprints.delete(resource.id); + goBack(); + } catch (err) { + setError(err as Error); + setDeleting(false); + } + break; + } + }; + + // Show deleting state + if (deleting) { + return ( + <> + + + + ); + } + + // Build detailed info lines for full details view + const buildDetailLines = (bp: Blueprint): React.ReactElement[] => { + const lines: React.ReactElement[] = []; + + // Core Information + lines.push( + + Blueprint Details + , + ); + lines.push( + + {" "} + ID: {bp.id} + , + ); + lines.push( + + {" "} + Name: {bp.name || "(none)"} + , + ); + lines.push( + + {" "} + Status: {bp.status} + , + ); + if (bp.create_time_ms) { + lines.push( + + {" "} + Created: {new Date(bp.create_time_ms).toLocaleString()} + , + ); + } + lines.push( ); + + // Launch Parameters + const lp = bp.parameters?.launch_parameters; + if (lp) { + lines.push( + + Launch Parameters + , + ); + if (lp.architecture) { + lines.push( + + {" "} + Architecture: {lp.architecture} + , + ); + } + if (lp.resource_size_request) { + lines.push( + + {" "} + Resource Size: {lp.resource_size_request} + , + ); + } + if (lp.custom_cpu_cores) { + lines.push( + + {" "} + CPU Cores: {lp.custom_cpu_cores} + , + ); + } + if (lp.custom_gb_memory) { + lines.push( + + {" "} + Memory: {lp.custom_gb_memory}GB + , + ); + } + if (lp.custom_disk_size) { + lines.push( + + {" "} + Disk Size: {lp.custom_disk_size}GB + , + ); + } + if (lp.keep_alive_time_seconds) { + lines.push( + + {" "} + Keep Alive: {lp.keep_alive_time_seconds}s + , + ); + } + if (lp.available_ports && lp.available_ports.length > 0) { + lines.push( + + {" "} + Available Ports: {lp.available_ports.join(", ")} + , + ); + } + if (lp.launch_commands && lp.launch_commands.length > 0) { + lines.push( + + {" "} + Launch Commands: + , + ); + lp.launch_commands.forEach((cmd, idx) => { + lines.push( + + {" "} + {figures.pointer} {cmd} + , + ); + }); + } + lines.push( ); + } + + // Build Configuration + const params = bp.parameters; + if (params) { + if (params.dockerfile) { + lines.push( + + Dockerfile + , + ); + params.dockerfile.split("\n").forEach((line, idx) => { + lines.push( + + {" "} + {line} + , + ); + }); + lines.push( ); + } + + if ( + params.system_setup_commands && + params.system_setup_commands.length > 0 + ) { + lines.push( + + System Setup Commands + , + ); + params.system_setup_commands.forEach((cmd, idx) => { + lines.push( + + {" "} + {idx + 1}. {cmd} + , + ); + }); + lines.push( ); + } + + if (params.file_mounts && Object.keys(params.file_mounts).length > 0) { + lines.push( + + File Mounts + , + ); + Object.entries(params.file_mounts).forEach(([path, _content], idx) => { + lines.push( + + {" "} + {path} + , + ); + }); + lines.push( ); + } + } + + // Raw JSON + lines.push( + + Raw JSON + , + ); + const jsonLines = JSON.stringify(bp, null, 2).split("\n"); + jsonLines.forEach((line, idx) => { + lines.push( + + {" "} + {line} + , + ); + }); + + return lines; + }; + + return ( + bp.name || bp.id} + getId={(bp) => bp.id} + getStatus={(bp) => bp.status} + detailSections={detailSections} + operations={operations} + onOperation={handleOperation} + onBack={goBack} + buildDetailLines={buildDetailLines} + pollResource={ + blueprint.status === "building" ? pollBlueprint : undefined + } + /> + ); +} diff --git a/src/screens/MenuScreen.tsx b/src/screens/MenuScreen.tsx index a9e61120..72a9512b 100644 --- a/src/screens/MenuScreen.tsx +++ b/src/screens/MenuScreen.tsx @@ -19,6 +19,9 @@ export function MenuScreen() { case "snapshots": navigate("snapshot-list"); break; + case "network-policies": + navigate("network-policy-list"); + break; default: // Fallback for any other screen names navigate(key as ScreenName); diff --git a/src/screens/NetworkPolicyCreateScreen.tsx b/src/screens/NetworkPolicyCreateScreen.tsx new file mode 100644 index 00000000..8ca3965f --- /dev/null +++ b/src/screens/NetworkPolicyCreateScreen.tsx @@ -0,0 +1,19 @@ +/** + * NetworkPolicyCreateScreen - Screen wrapper for network policy creation + */ +import React from "react"; +import { useNavigation } from "../store/navigationStore.js"; +import { NetworkPolicyCreatePage } from "../components/NetworkPolicyCreatePage.js"; + +export function NetworkPolicyCreateScreen() { + const { goBack, navigate } = useNavigation(); + + return ( + + navigate("network-policy-detail", { networkPolicyId: policy.id }) + } + /> + ); +} diff --git a/src/screens/NetworkPolicyDetailScreen.tsx b/src/screens/NetworkPolicyDetailScreen.tsx new file mode 100644 index 00000000..431eccfa --- /dev/null +++ b/src/screens/NetworkPolicyDetailScreen.tsx @@ -0,0 +1,382 @@ +/** + * NetworkPolicyDetailScreen - Detail page for network policies + * Uses the generic ResourceDetailPage component + */ +import React from "react"; +import { Text } from "ink"; +import figures from "figures"; +import { useNavigation } from "../store/navigationStore.js"; +import { + useNetworkPolicyStore, + type NetworkPolicy, +} from "../store/networkPolicyStore.js"; +import { + ResourceDetailPage, + formatTimestamp, + type DetailSection, + type ResourceOperation, +} from "../components/ResourceDetailPage.js"; +import { + getNetworkPolicy, + deleteNetworkPolicy, +} from "../services/networkPolicyService.js"; +import { SpinnerComponent } from "../components/Spinner.js"; +import { ErrorMessage } from "../components/ErrorMessage.js"; +import { Breadcrumb } from "../components/Breadcrumb.js"; +import { colors } from "../utils/theme.js"; + +interface NetworkPolicyDetailScreenProps { + networkPolicyId?: string; +} + +/** + * Get a display label for the egress policy type + */ +function getEgressTypeLabel(egress: NetworkPolicy["egress"]): string { + if (egress.allow_all) { + return "Allow All"; + } + if (egress.allowed_hostnames.length === 0) { + return "Deny All"; + } + return "Custom"; +} + +export function NetworkPolicyDetailScreen({ + networkPolicyId, +}: NetworkPolicyDetailScreenProps) { + const { goBack } = useNavigation(); + const networkPolicies = useNetworkPolicyStore( + (state) => state.networkPolicies, + ); + + const [loading, setLoading] = React.useState(false); + const [error, setError] = React.useState(null); + const [fetchedPolicy, setFetchedPolicy] = + React.useState(null); + const [deleting, setDeleting] = React.useState(false); + + // Find policy in store first + const policyFromStore = networkPolicies.find( + (p) => p.id === networkPolicyId, + ); + + // Fetch policy from API if not in store or missing full details + React.useEffect(() => { + if (networkPolicyId && !loading && !fetchedPolicy) { + // Always fetch full details since store may only have basic info + setLoading(true); + setError(null); + + getNetworkPolicy(networkPolicyId) + .then((policy) => { + setFetchedPolicy(policy); + setLoading(false); + }) + .catch((err) => { + setError(err as Error); + setLoading(false); + }); + } + }, [networkPolicyId, loading, fetchedPolicy]); + + // Use fetched policy for full details, fall back to store for basic display + const policy = fetchedPolicy || policyFromStore; + + // Show loading state while fetching + if (loading && !policy) { + return ( + <> + + + + ); + } + + // Show error state if fetch failed + if (error && !policy) { + return ( + <> + + + + ); + } + + // Show error if no policy found + if (!policy) { + return ( + <> + + + + ); + } + + // Build detail sections + const detailSections: DetailSection[] = []; + + // Basic details section + const basicFields = []; + if (policy.description) { + basicFields.push({ + label: "Description", + value: policy.description, + }); + } + if (policy.create_time_ms) { + basicFields.push({ + label: "Created", + value: formatTimestamp(policy.create_time_ms), + }); + } + if (policy.update_time_ms) { + basicFields.push({ + label: "Last Updated", + value: formatTimestamp(policy.update_time_ms), + }); + } + + if (basicFields.length > 0) { + detailSections.push({ + title: "Details", + icon: figures.squareSmallFilled, + color: colors.warning, + fields: basicFields, + }); + } + + // Egress rules section + const egressFields = []; + egressFields.push({ + label: "Policy Type", + value: ( + + {getEgressTypeLabel(policy.egress)} + + ), + }); + egressFields.push({ + label: "Allow Devbox-to-Devbox", + value: policy.egress.allow_devbox_to_devbox ? "Yes" : "No", + }); + + if ( + policy.egress.allowed_hostnames && + policy.egress.allowed_hostnames.length > 0 + ) { + egressFields.push({ + label: "Allowed Hostnames", + value: `${policy.egress.allowed_hostnames.length} hostname(s)`, + }); + } + + detailSections.push({ + title: "Egress Rules", + icon: figures.arrowRight, + color: colors.info, + fields: egressFields, + }); + + // Operations available for network policies + const operations: ResourceOperation[] = [ + { + key: "delete", + label: "Delete Network Policy", + color: colors.error, + icon: figures.cross, + shortcut: "d", + }, + ]; + + // Handle operation selection + const handleOperation = async ( + operation: string, + resource: NetworkPolicy, + ) => { + switch (operation) { + case "delete": + setDeleting(true); + try { + await deleteNetworkPolicy(resource.id); + goBack(); + } catch (err) { + setError(err as Error); + setDeleting(false); + } + break; + } + }; + + // Build detailed info lines for full details view + const buildDetailLines = (np: NetworkPolicy): React.ReactElement[] => { + const lines: React.ReactElement[] = []; + + // Core Information + lines.push( + + Network Policy Details + , + ); + lines.push( + + {" "} + ID: {np.id} + , + ); + lines.push( + + {" "} + Name: {np.name} + , + ); + if (np.description) { + lines.push( + + {" "} + Description: {np.description} + , + ); + } + if (np.create_time_ms) { + lines.push( + + {" "} + Created: {new Date(np.create_time_ms).toLocaleString()} + , + ); + } + if (np.update_time_ms) { + lines.push( + + {" "} + Last Updated: {new Date(np.update_time_ms).toLocaleString()} + , + ); + } + lines.push( ); + + // Egress Rules + lines.push( + + Egress Rules + , + ); + lines.push( + + {" "} + Policy Type: {getEgressTypeLabel(np.egress)} + , + ); + lines.push( + + {" "} + Allow All: {np.egress.allow_all ? "Yes" : "No"} + , + ); + lines.push( + + {" "} + Allow Devbox-to-Devbox: {np.egress.allow_devbox_to_devbox ? "Yes" : "No"} + , + ); + lines.push( ); + + // Allowed Hostnames + if (np.egress.allowed_hostnames && np.egress.allowed_hostnames.length > 0) { + lines.push( + + Allowed Hostnames ({np.egress.allowed_hostnames.length}) + , + ); + np.egress.allowed_hostnames.forEach((hostname, idx) => { + lines.push( + + {" "} + {figures.pointer} {hostname} + , + ); + }); + lines.push( ); + } + + // Raw JSON + lines.push( + + Raw JSON + , + ); + const jsonLines = JSON.stringify(np, null, 2).split("\n"); + jsonLines.forEach((line, idx) => { + lines.push( + + {" "} + {line} + , + ); + }); + + return lines; + }; + + // Show deleting state + if (deleting) { + return ( + <> + + + + ); + } + + return ( + np.name || np.id} + getId={(np) => np.id} + getStatus={() => "active"} // Network policies don't have a status field + detailSections={detailSections} + operations={operations} + onOperation={handleOperation} + onBack={goBack} + buildDetailLines={buildDetailLines} + /> + ); +} diff --git a/src/screens/NetworkPolicyListScreen.tsx b/src/screens/NetworkPolicyListScreen.tsx new file mode 100644 index 00000000..aa9a32c6 --- /dev/null +++ b/src/screens/NetworkPolicyListScreen.tsx @@ -0,0 +1,12 @@ +/** + * NetworkPolicyListScreen - Screen wrapper for network policy list + */ +import React from "react"; +import { useNavigation } from "../store/navigationStore.js"; +import { ListNetworkPoliciesUI } from "../commands/network-policy/list.js"; + +export function NetworkPolicyListScreen() { + const { goBack } = useNavigation(); + + return ; +} diff --git a/src/screens/SnapshotDetailScreen.tsx b/src/screens/SnapshotDetailScreen.tsx new file mode 100644 index 00000000..0380457a --- /dev/null +++ b/src/screens/SnapshotDetailScreen.tsx @@ -0,0 +1,329 @@ +/** + * SnapshotDetailScreen - Detail page for snapshots + * Uses the generic ResourceDetailPage component + */ +import React from "react"; +import { Text } from "ink"; +import figures from "figures"; +import { useNavigation } from "../store/navigationStore.js"; +import { useSnapshotStore, type Snapshot } from "../store/snapshotStore.js"; +import { + ResourceDetailPage, + formatTimestamp, + type DetailSection, + type ResourceOperation, +} from "../components/ResourceDetailPage.js"; +import { getSnapshot, deleteSnapshot } from "../services/snapshotService.js"; +import { SpinnerComponent } from "../components/Spinner.js"; +import { ErrorMessage } from "../components/ErrorMessage.js"; +import { Breadcrumb } from "../components/Breadcrumb.js"; +import { colors } from "../utils/theme.js"; + +interface SnapshotDetailScreenProps { + snapshotId?: string; +} + +export function SnapshotDetailScreen({ + snapshotId, +}: SnapshotDetailScreenProps) { + const { goBack, navigate } = useNavigation(); + const snapshots = useSnapshotStore((state) => state.snapshots); + + const [loading, setLoading] = React.useState(false); + const [error, setError] = React.useState(null); + const [fetchedSnapshot, setFetchedSnapshot] = + React.useState(null); + const [deleting, setDeleting] = React.useState(false); + + // Find snapshot in store first + const snapshotFromStore = snapshots.find((s) => s.id === snapshotId); + + // Polling function - must be defined before any early returns (Rules of Hooks) + const pollSnapshot = React.useCallback(async () => { + if (!snapshotId) return null as unknown as Snapshot; + return getSnapshot(snapshotId); + }, [snapshotId]); + + // Fetch snapshot from API if not in store or missing full details + React.useEffect(() => { + if (snapshotId && !loading && !fetchedSnapshot) { + // Always fetch full details since store may only have basic info + setLoading(true); + setError(null); + + getSnapshot(snapshotId) + .then((snapshot) => { + setFetchedSnapshot(snapshot); + setLoading(false); + }) + .catch((err) => { + setError(err as Error); + setLoading(false); + }); + } + }, [snapshotId, loading, fetchedSnapshot]); + + // Use fetched snapshot for full details, fall back to store for basic display + const snapshot = fetchedSnapshot || snapshotFromStore; + + // Show loading state while fetching + if (loading && !snapshot) { + return ( + <> + + + + ); + } + + // Show error state if fetch failed + if (error && !snapshot) { + return ( + <> + + + + ); + } + + // Show error if no snapshot found + if (!snapshot) { + return ( + <> + + + + ); + } + + // Build detail sections + const detailSections: DetailSection[] = []; + + // Basic details section + const basicFields = []; + if (snapshot.create_time_ms) { + basicFields.push({ + label: "Created", + value: formatTimestamp(snapshot.create_time_ms), + }); + } + if (snapshot.devbox_id) { + basicFields.push({ + label: "Source Devbox", + value: ( + {snapshot.devbox_id} + ), + }); + } + if (snapshot.disk_size_bytes) { + const sizeGB = (snapshot.disk_size_bytes / (1024 * 1024 * 1024)).toFixed(2); + basicFields.push({ + label: "Disk Size", + value: `${sizeGB} GB`, + }); + } + + if (basicFields.length > 0) { + detailSections.push({ + title: "Details", + icon: figures.squareSmallFilled, + color: colors.warning, + fields: basicFields, + }); + } + + // Metadata section + if (snapshot.metadata && Object.keys(snapshot.metadata).length > 0) { + const metadataFields = Object.entries(snapshot.metadata).map( + ([key, value]) => ({ + label: key, + value: value, + }), + ); + + detailSections.push({ + title: "Metadata", + icon: figures.identical, + color: colors.secondary, + fields: metadataFields, + }); + } + + // Operations available for snapshots + const operations: ResourceOperation[] = [ + { + key: "create-devbox", + label: "Create Devbox from Snapshot", + color: colors.success, + icon: figures.play, + shortcut: "c", + }, + { + key: "delete", + label: "Delete Snapshot", + color: colors.error, + icon: figures.cross, + shortcut: "d", + }, + ]; + + // Handle operation selection + const handleOperation = async (operation: string, resource: Snapshot) => { + switch (operation) { + case "create-devbox": + navigate("devbox-create", { snapshotId: resource.id }); + break; + case "delete": + setDeleting(true); + try { + await deleteSnapshot(resource.id); + goBack(); + } catch (err) { + setError(err as Error); + setDeleting(false); + } + break; + } + }; + + // Build detailed info lines for full details view + const buildDetailLines = (snap: Snapshot): React.ReactElement[] => { + const lines: React.ReactElement[] = []; + + // Core Information + lines.push( + + Snapshot Details + , + ); + lines.push( + + {" "} + ID: {snap.id} + , + ); + lines.push( + + {" "} + Name: {snap.name || "(none)"} + , + ); + lines.push( + + {" "} + Status: {snap.status} + , + ); + if (snap.devbox_id) { + lines.push( + + {" "} + Source Devbox: {snap.devbox_id} + , + ); + } + if (snap.create_time_ms) { + lines.push( + + {" "} + Created: {new Date(snap.create_time_ms).toLocaleString()} + , + ); + } + if (snap.disk_size_bytes) { + const sizeGB = (snap.disk_size_bytes / (1024 * 1024 * 1024)).toFixed(2); + lines.push( + + {" "} + Disk Size: {sizeGB} GB + , + ); + } + lines.push( ); + + // Metadata + if (snap.metadata && Object.keys(snap.metadata).length > 0) { + lines.push( + + Metadata + , + ); + Object.entries(snap.metadata).forEach(([key, value], idx) => { + lines.push( + + {" "} + {key}: {value} + , + ); + }); + lines.push( ); + } + + // Raw JSON + lines.push( + + Raw JSON + , + ); + const jsonLines = JSON.stringify(snap, null, 2).split("\n"); + jsonLines.forEach((line, idx) => { + lines.push( + + {" "} + {line} + , + ); + }); + + return lines; + }; + + // Show deleting state + if (deleting) { + return ( + <> + + + + ); + } + + return ( + snap.name || snap.id} + getId={(snap) => snap.id} + getStatus={(snap) => snap.status} + detailSections={detailSections} + operations={operations} + onOperation={handleOperation} + onBack={goBack} + buildDetailLines={buildDetailLines} + pollResource={ + snapshot.status === "pending" ? pollSnapshot : undefined + } + /> + ); +} diff --git a/src/services/blueprintService.ts b/src/services/blueprintService.ts index b4ead8e9..2f57608a 100644 --- a/src/services/blueprintService.ts +++ b/src/services/blueprintService.ts @@ -93,17 +93,72 @@ export async function getBlueprint(id: string): Promise { const client = getClient(); const blueprint = await client.blueprints.retrieve(id); + // Extract architecture and resources from launch_parameters + const launchParams = blueprint.parameters?.launch_parameters; + return { id: blueprint.id, name: blueprint.name, status: blueprint.status, create_time_ms: blueprint.create_time_ms, build_status: (blueprint as any).build_status, - architecture: (blueprint as any).architecture, - resources: (blueprint as any).resources, + architecture: launchParams?.architecture ?? undefined, + resources: launchParams?.resource_size_request ?? undefined, + parameters: blueprint.parameters + ? { + launch_parameters: launchParams + ? { + architecture: launchParams.architecture ?? undefined, + resource_size_request: + launchParams.resource_size_request ?? undefined, + custom_cpu_cores: launchParams.custom_cpu_cores ?? undefined, + custom_gb_memory: launchParams.custom_gb_memory ?? undefined, + custom_disk_size: launchParams.custom_disk_size ?? undefined, + keep_alive_time_seconds: + launchParams.keep_alive_time_seconds ?? undefined, + available_ports: launchParams.available_ports ?? undefined, + launch_commands: launchParams.launch_commands ?? undefined, + required_services: launchParams.required_services ?? undefined, + } + : undefined, + dockerfile: blueprint.parameters.dockerfile ?? undefined, + system_setup_commands: + blueprint.parameters.system_setup_commands ?? undefined, + file_mounts: (blueprint.parameters.file_mounts as + | Record + | undefined) ?? undefined, + } + : undefined, }; } +/** + * Get a single blueprint by ID or name + */ +export async function getBlueprintByIdOrName( + idOrName: string, +): Promise { + const client = getClient(); + + // Check if it's an ID (starts with bpt_) or a name + if (idOrName.startsWith("bpt_")) { + return getBlueprint(idOrName); + } + + // It's a name, search for it + const result = await client.blueprints.list({ name: idOrName }); + const blueprints = result.blueprints || []; + + if (blueprints.length === 0) { + return null; + } + + // Return the first exact match, or first result if no exact match + const blueprint = + blueprints.find((b) => b.name === idOrName) || blueprints[0]; + return getBlueprint(blueprint.id); +} + /** * Get blueprint logs * Returns the raw logs array from the API response diff --git a/src/services/networkPolicyService.ts b/src/services/networkPolicyService.ts new file mode 100644 index 00000000..2957ce68 --- /dev/null +++ b/src/services/networkPolicyService.ts @@ -0,0 +1,140 @@ +/** + * Network Policy Service - Handles all network policy API calls + */ +import { getClient } from "../utils/client.js"; +import type { NetworkPolicy } from "../store/networkPolicyStore.js"; +import type { + NetworkPolicyListParams, + NetworkPolicyView, +} from "@runloop/api-client/resources/network-policies"; +import type { NetworkPoliciesCursorIDPage } from "@runloop/api-client/pagination"; + +export interface ListNetworkPoliciesOptions { + limit: number; + startingAfter?: string; + search?: string; +} + +export interface ListNetworkPoliciesResult { + networkPolicies: NetworkPolicy[]; + totalCount: number; + hasMore: boolean; +} + +/** + * List network policies with pagination + */ +export async function listNetworkPolicies( + options: ListNetworkPoliciesOptions, +): Promise { + const client = getClient(); + + const queryParams: NetworkPolicyListParams = { + limit: options.limit, + }; + + if (options.startingAfter) { + queryParams.starting_after = options.startingAfter; + } + if (options.search) { + queryParams.name = options.search; + } + + const pagePromise = client.networkPolicies.list(queryParams); + const page = + (await pagePromise) as unknown as NetworkPoliciesCursorIDPage; + + const networkPolicies: NetworkPolicy[] = []; + + if (page.network_policies && Array.isArray(page.network_policies)) { + page.network_policies.forEach((p: NetworkPolicyView) => { + // CRITICAL: Truncate all strings to prevent Yoga crashes + const MAX_ID_LENGTH = 100; + const MAX_NAME_LENGTH = 200; + const MAX_DESC_LENGTH = 500; + + networkPolicies.push({ + id: String(p.id || "").substring(0, MAX_ID_LENGTH), + name: String(p.name || "").substring(0, MAX_NAME_LENGTH), + description: p.description + ? String(p.description).substring(0, MAX_DESC_LENGTH) + : undefined, + create_time_ms: p.create_time_ms, + update_time_ms: p.update_time_ms, + egress: { + allow_all: p.egress.allow_all, + allow_devbox_to_devbox: p.egress.allow_devbox_to_devbox, + allowed_hostnames: p.egress.allowed_hostnames || [], + }, + }); + }); + } + + const result = { + networkPolicies, + totalCount: page.total_count || networkPolicies.length, + hasMore: page.has_more || false, + }; + + return result; +} + +/** + * Get a single network policy by ID + */ +export async function getNetworkPolicy(id: string): Promise { + const client = getClient(); + const policy = await client.networkPolicies.retrieve(id); + + return { + id: policy.id, + name: policy.name, + description: policy.description ?? undefined, + create_time_ms: policy.create_time_ms, + update_time_ms: policy.update_time_ms, + egress: { + allow_all: policy.egress.allow_all, + allow_devbox_to_devbox: policy.egress.allow_devbox_to_devbox, + allowed_hostnames: policy.egress.allowed_hostnames || [], + }, + }; +} + +/** + * Delete a network policy + */ +export async function deleteNetworkPolicy(id: string): Promise { + const client = getClient(); + await client.networkPolicies.delete(id); +} + +/** + * Create a network policy + */ +export interface CreateNetworkPolicyParams { + name: string; + description?: string; + allow_all?: boolean; + allow_devbox_to_devbox?: boolean; + allowed_hostnames?: string[]; +} + +export async function createNetworkPolicy( + params: CreateNetworkPolicyParams, +): Promise { + const client = getClient(); + const policy = await client.networkPolicies.create(params); + + return { + id: policy.id, + name: policy.name, + description: policy.description ?? undefined, + create_time_ms: policy.create_time_ms, + update_time_ms: policy.update_time_ms, + egress: { + allow_all: policy.egress.allow_all, + allow_devbox_to_devbox: policy.egress.allow_devbox_to_devbox, + allowed_hostnames: policy.egress.allowed_hostnames || [], + }, + }; +} diff --git a/src/services/snapshotService.ts b/src/services/snapshotService.ts index 6fe6b1e5..aabc0ede 100644 --- a/src/services/snapshotService.ts +++ b/src/services/snapshotService.ts @@ -92,6 +92,35 @@ export async function getSnapshotStatus(id: string): Promise { return status; } +/** + * Get full snapshot details by ID + */ +export async function getSnapshot(id: string): Promise { + const client = getClient(); + const statusResponse = await client.devboxes.diskSnapshots.queryStatus(id); + + // The queryStatus returns a status wrapper with snapshot data inside + const snapshot = statusResponse.snapshot; + const operationStatus = statusResponse.status; // 'in_progress', 'error', 'complete', 'deleted' + + if (!snapshot) { + // If no snapshot data yet, return minimal info based on operation status + return { + id: id, + status: operationStatus === "in_progress" ? "pending" : operationStatus, + }; + } + + return { + id: snapshot.id, + name: snapshot.name || undefined, + devbox_id: snapshot.source_devbox_id || undefined, + status: operationStatus === "complete" ? "ready" : operationStatus, + create_time_ms: snapshot.create_time_ms, + metadata: snapshot.metadata as Record | undefined, + }; +} + /** * Create a snapshot */ diff --git a/src/store/blueprintStore.ts b/src/store/blueprintStore.ts index dea58e1d..bdb4d4b1 100644 --- a/src/store/blueprintStore.ts +++ b/src/store/blueprintStore.ts @@ -3,6 +3,23 @@ */ import { create } from "zustand"; +export interface BlueprintParameters { + launch_parameters?: { + architecture?: string; + resource_size_request?: string; + custom_cpu_cores?: number; + custom_gb_memory?: number; + custom_disk_size?: number; + keep_alive_time_seconds?: number; + available_ports?: number[]; + launch_commands?: string[]; + required_services?: string[]; + }; + dockerfile?: string; + system_setup_commands?: string[]; + file_mounts?: Record; +} + export interface Blueprint { id: string; name?: string; @@ -11,6 +28,9 @@ export interface Blueprint { build_status?: string; architecture?: string; resources?: string; + // Extended fields for detail view + parameters?: BlueprintParameters; + description?: string; } interface BlueprintState { diff --git a/src/store/navigationStore.tsx b/src/store/navigationStore.tsx index 5e31870a..0de5b70b 100644 --- a/src/store/navigationStore.tsx +++ b/src/store/navigationStore.tsx @@ -11,6 +11,9 @@ export type ScreenName = | "blueprint-logs" | "snapshot-list" | "snapshot-detail" + | "network-policy-list" + | "network-policy-detail" + | "network-policy-create" | "ssh-session"; export interface RouteParams { @@ -18,6 +21,7 @@ export interface RouteParams { blueprintId?: string; blueprintName?: string; snapshotId?: string; + networkPolicyId?: string; operation?: string; focusDevboxId?: string; status?: string; diff --git a/src/store/networkPolicyStore.ts b/src/store/networkPolicyStore.ts new file mode 100644 index 00000000..1f509d28 --- /dev/null +++ b/src/store/networkPolicyStore.ts @@ -0,0 +1,171 @@ +/** + * Network Policy Store - Manages network policy list state, pagination, and caching + */ +import { create } from "zustand"; + +export interface NetworkPolicyEgress { + allow_all: boolean; + allow_devbox_to_devbox: boolean; + allowed_hostnames: string[]; +} + +export interface NetworkPolicy { + id: string; + name: string; + description?: string; + create_time_ms: number; + update_time_ms: number; + egress: NetworkPolicyEgress; +} + +interface NetworkPolicyState { + // List data + networkPolicies: NetworkPolicy[]; + loading: boolean; + initialLoading: boolean; + error: Error | null; + + // Pagination + currentPage: number; + pageSize: number; + totalCount: number; + hasMore: boolean; + + // Caching + pageCache: Map; + lastIdCache: Map; + + // Search/filter + searchQuery: string; + + // Selection + selectedIndex: number; + + // Actions + setNetworkPolicies: (policies: NetworkPolicy[]) => void; + setLoading: (loading: boolean) => void; + setInitialLoading: (loading: boolean) => void; + setError: (error: Error | null) => void; + + setCurrentPage: (page: number) => void; + setPageSize: (size: number) => void; + setTotalCount: (count: number) => void; + setHasMore: (hasMore: boolean) => void; + + setSearchQuery: (query: string) => void; + setSelectedIndex: (index: number) => void; + + cachePageData: (page: number, data: NetworkPolicy[], lastId: string) => void; + getCachedPage: (page: number) => NetworkPolicy[] | undefined; + clearCache: () => void; + clearAll: () => void; + + getSelectedNetworkPolicy: () => NetworkPolicy | undefined; +} + +const MAX_CACHE_SIZE = 10; + +export const useNetworkPolicyStore = create((set, get) => ({ + networkPolicies: [], + loading: false, + initialLoading: true, + error: null, + + currentPage: 0, + pageSize: 10, + totalCount: 0, + hasMore: false, + + pageCache: new Map(), + lastIdCache: new Map(), + + searchQuery: "", + selectedIndex: 0, + + setNetworkPolicies: (policies) => set({ networkPolicies: policies }), + setLoading: (loading) => set({ loading }), + setInitialLoading: (loading) => set({ initialLoading: loading }), + setError: (error) => set({ error }), + + setCurrentPage: (page) => set({ currentPage: page }), + setPageSize: (size) => set({ pageSize: size }), + setTotalCount: (count) => set({ totalCount: count }), + setHasMore: (hasMore) => set({ hasMore }), + + setSearchQuery: (query) => set({ searchQuery: query }), + setSelectedIndex: (index) => set({ selectedIndex: index }), + + cachePageData: (page, data, lastId) => { + const state = get(); + const pageCache = state.pageCache; + const lastIdCache = state.lastIdCache; + + // Aggressive LRU eviction + if (pageCache.size >= MAX_CACHE_SIZE) { + const oldestKey = pageCache.keys().next().value; + if (oldestKey !== undefined) { + pageCache.delete(oldestKey); + lastIdCache.delete(oldestKey); + } + } + + // Create plain data objects to avoid SDK references + const plainData = data.map((p) => ({ + id: p.id, + name: p.name, + description: p.description, + create_time_ms: p.create_time_ms, + update_time_ms: p.update_time_ms, + egress: { + allow_all: p.egress.allow_all, + allow_devbox_to_devbox: p.egress.allow_devbox_to_devbox, + allowed_hostnames: [...p.egress.allowed_hostnames], + }, + })); + + pageCache.set(page, plainData); + lastIdCache.set(page, lastId); + + set({}); + }, + + getCachedPage: (page) => { + return get().pageCache.get(page); + }, + + clearCache: () => { + const state = get(); + state.pageCache.clear(); + state.lastIdCache.clear(); + + set({ + pageCache: new Map(), + lastIdCache: new Map(), + }); + }, + + clearAll: () => { + const state = get(); + state.pageCache.clear(); + state.lastIdCache.clear(); + + set({ + networkPolicies: [], + loading: false, + initialLoading: true, + error: null, + currentPage: 0, + totalCount: 0, + hasMore: false, + pageCache: new Map(), + lastIdCache: new Map(), + searchQuery: "", + selectedIndex: 0, + }); + }, + + getSelectedNetworkPolicy: () => { + const state = get(); + return state.networkPolicies[state.selectedIndex]; + }, +})); diff --git a/src/store/snapshotStore.ts b/src/store/snapshotStore.ts index 99a38760..f72b8b1c 100644 --- a/src/store/snapshotStore.ts +++ b/src/store/snapshotStore.ts @@ -9,6 +9,9 @@ export interface Snapshot { devbox_id?: string; status: string; create_time_ms?: number; + // Extended fields for detail view + metadata?: Record; + disk_size_bytes?: number; } interface SnapshotState { From 9189e7a4730aeb8d686bd1a06346a3e5c53e4ba8 Mon Sep 17 00:00:00 2001 From: Alexander Dines Date: Thu, 22 Jan 2026 14:17:25 -0800 Subject: [PATCH 02/24] cp dines --- src/commands/blueprint/list.tsx | 7 +- src/commands/network-policy/list.tsx | 18 +- src/commands/object/list.ts | 43 -- src/commands/object/list.tsx | 631 +++++++++++++++++++++ src/commands/snapshot/list.tsx | 3 +- src/components/DevboxCreatePage.tsx | 54 +- src/components/DevboxDetailPage.tsx | 4 +- src/components/MainMenu.tsx | 13 +- src/components/NetworkPolicyCreatePage.tsx | 20 +- src/components/ResourceDetailPage.tsx | 4 +- src/components/form/FormField.tsx | 6 +- src/components/form/FormListManager.tsx | 12 +- src/components/form/FormTextInput.tsx | 4 +- src/router/Router.tsx | 16 + src/screens/BlueprintDetailScreen.tsx | 23 +- src/screens/MenuScreen.tsx | 3 + src/screens/NetworkPolicyDetailScreen.tsx | 7 +- src/screens/ObjectDetailScreen.tsx | 386 +++++++++++++ src/screens/ObjectListScreen.tsx | 13 + src/screens/SnapshotDetailScreen.tsx | 18 +- src/services/blueprintService.ts | 7 +- src/services/objectService.ts | 140 +++++ src/store/index.ts | 3 + src/store/navigationStore.tsx | 3 + src/store/objectStore.ts | 182 ++++++ 25 files changed, 1511 insertions(+), 109 deletions(-) delete mode 100644 src/commands/object/list.ts create mode 100644 src/commands/object/list.tsx create mode 100644 src/screens/ObjectDetailScreen.tsx create mode 100644 src/screens/ObjectListScreen.tsx create mode 100644 src/services/objectService.ts create mode 100644 src/store/objectStore.ts diff --git a/src/commands/blueprint/list.tsx b/src/commands/blueprint/list.tsx index afe3da91..36260cd2 100644 --- a/src/commands/blueprint/list.tsx +++ b/src/commands/blueprint/list.tsx @@ -25,7 +25,12 @@ import { useNavigation } from "../../store/navigationStore.js"; const DEFAULT_PAGE_SIZE = 10; -type OperationType = "create_devbox" | "delete" | "view_logs" | "view_details" | null; +type OperationType = + | "create_devbox" + | "delete" + | "view_logs" + | "view_details" + | null; // Local interface for blueprint data used in this component interface BlueprintListItem { diff --git a/src/commands/network-policy/list.tsx b/src/commands/network-policy/list.tsx index 1839989f..ee02ed40 100644 --- a/src/commands/network-policy/list.tsx +++ b/src/commands/network-policy/list.tsx @@ -275,7 +275,14 @@ const ListNetworkPoliciesUI = ({ }, ), ], - [idWidth, nameWidth, descriptionWidth, egressWidth, timeWidth, showDescription], + [ + idWidth, + nameWidth, + descriptionWidth, + egressWidth, + timeWidth, + showDescription, + ], ); // Handle Ctrl+C to exit @@ -581,7 +588,14 @@ const ListNetworkPoliciesUI = ({ label: op.label, color: op.color, icon: op.icon, - shortcut: op.key === "create" ? "c" : op.key === "view_details" ? "v" : op.key === "delete" ? "d" : "", + shortcut: + op.key === "create" + ? "c" + : op.key === "view_details" + ? "v" + : op.key === "delete" + ? "d" + : "", }))} selectedOperation={selectedOperation} onClose={() => setShowPopup(false)} diff --git a/src/commands/object/list.ts b/src/commands/object/list.ts deleted file mode 100644 index a42b777f..00000000 --- a/src/commands/object/list.ts +++ /dev/null @@ -1,43 +0,0 @@ -/** - * List objects command - */ - -import { getClient } from "../../utils/client.js"; -import { output, outputError } from "../../utils/output.js"; - -interface ListObjectsOptions { - limit?: number; - startingAfter?: string; - name?: string; - contentType?: string; - state?: string; - search?: string; - public?: boolean; - output?: string; -} - -export async function listObjects(options: ListObjectsOptions = {}) { - try { - const client = getClient(); - - // Build params - const params: Record = {}; - if (options.limit) params.limit = options.limit; - if (options.startingAfter) params.startingAfter = options.startingAfter; - if (options.name) params.name = options.name; - if (options.contentType) params.contentType = options.contentType; - if (options.state) params.state = options.state; - if (options.search) params.search = options.search; - if (options.public) params.isPublic = true; - - const result = options.public - ? await client.objects.listPublic(params) - : await client.objects.list(params); - - const objects = result.objects || []; - - output(objects, { format: options.output, defaultFormat: "json" }); - } catch (error) { - outputError("Failed to list objects", error); - } -} diff --git a/src/commands/object/list.tsx b/src/commands/object/list.tsx new file mode 100644 index 00000000..13be016b --- /dev/null +++ b/src/commands/object/list.tsx @@ -0,0 +1,631 @@ +import React from "react"; +import { Box, Text, useInput, useApp } from "ink"; +import figures from "figures"; +import { getClient } from "../../utils/client.js"; +import { Header } from "../../components/Header.js"; +import { SpinnerComponent } from "../../components/Spinner.js"; +import { ErrorMessage } from "../../components/ErrorMessage.js"; +import { SuccessMessage } from "../../components/SuccessMessage.js"; +import { Breadcrumb } from "../../components/Breadcrumb.js"; +import { Table, createTextColumn } from "../../components/Table.js"; +import { ActionsPopup } from "../../components/ActionsPopup.js"; +import { Operation } from "../../components/OperationsMenu.js"; +import { formatTimeAgo } from "../../components/ResourceListView.js"; +import { output, outputError } from "../../utils/output.js"; +import { colors } from "../../utils/theme.js"; +import { useViewportHeight } from "../../hooks/useViewportHeight.js"; +import { useExitOnCtrlC } from "../../hooks/useExitOnCtrlC.js"; +import { useCursorPagination } from "../../hooks/useCursorPagination.js"; +import { useNavigation } from "../../store/navigationStore.js"; +import { formatFileSize } from "../../services/objectService.js"; + +interface ListOptions { + name?: string; + contentType?: string; + state?: string; + public?: boolean; + output?: string; +} + +// Local interface for object data used in this component +interface ObjectListItem { + id: string; + name?: string; + content_type?: string; + size_bytes?: number; + state?: string; + is_public?: boolean; + create_time_ms?: number; + [key: string]: unknown; +} + +const DEFAULT_PAGE_SIZE = 10; + +const ListObjectsUI = ({ + onBack, + onExit, +}: { + onBack?: () => void; + onExit?: () => void; +}) => { + const { exit: inkExit } = useApp(); + const { navigate } = useNavigation(); + const [selectedIndex, setSelectedIndex] = React.useState(0); + const [showPopup, setShowPopup] = React.useState(false); + const [selectedOperation, setSelectedOperation] = React.useState(0); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const [selectedObject, setSelectedObject] = React.useState(null); + const [executingOperation, setExecutingOperation] = React.useState< + string | null + >(null); + const [operationResult, setOperationResult] = React.useState( + null, + ); + const [operationError, setOperationError] = React.useState( + null, + ); + const [operationLoading, setOperationLoading] = React.useState(false); + + // Calculate overhead for viewport height + const overhead = 13; + const { viewportHeight, terminalWidth } = useViewportHeight({ + overhead, + minHeight: 5, + }); + + const PAGE_SIZE = viewportHeight; + + // All width constants + const idWidth = 25; + const nameWidth = Math.max(15, terminalWidth >= 120 ? 30 : 20); + const typeWidth = 15; + const sizeWidth = 12; + const timeWidth = 15; + const showTypeColumn = terminalWidth >= 100; + const showSizeColumn = terminalWidth >= 80; + + // Fetch function for pagination hook + const fetchPage = React.useCallback( + async (params: { limit: number; startingAt?: string }) => { + const client = getClient(); + const pageObjects: ObjectListItem[] = []; + + // Build query params + const queryParams: Record = { + limit: params.limit, + }; + if (params.startingAt) { + queryParams.starting_after = params.startingAt; + } + + // Fetch ONE page only + const result = await client.objects.list(queryParams); + + // Extract data and create defensive copies + if (result.objects && Array.isArray(result.objects)) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + result.objects.forEach((obj: any) => { + pageObjects.push({ + id: obj.id, + name: obj.name, + content_type: obj.content_type, + size_bytes: obj.size_bytes, + state: obj.state, + is_public: obj.is_public, + create_time_ms: obj.create_time_ms, + }); + }); + } + + // Access pagination properties from the result + const pageResult = result as unknown as { + objects: unknown[]; + total_count?: number; + has_more?: boolean; + }; + + return { + items: pageObjects, + hasMore: pageResult.has_more || false, + totalCount: pageResult.total_count || pageObjects.length, + }; + }, + [], + ); + + // Use the shared pagination hook + const { + items: objects, + loading, + navigating, + error, + currentPage, + hasMore, + hasPrev, + totalCount, + nextPage, + prevPage, + } = useCursorPagination({ + fetchPage, + pageSize: PAGE_SIZE, + getItemId: (obj: ObjectListItem) => obj.id, + pollInterval: 5000, + pollingEnabled: !showPopup && !executingOperation, + deps: [PAGE_SIZE], + }); + + // Operations for objects + const operations: Operation[] = React.useMemo( + () => [ + { + key: "view_details", + label: "View Details", + color: colors.primary, + icon: figures.pointer, + }, + { + key: "download", + label: "Download Object", + color: colors.success, + icon: figures.arrowDown, + }, + { + key: "delete", + label: "Delete Object", + color: colors.error, + icon: figures.cross, + }, + ], + [], + ); + + // Build columns + const columns = React.useMemo( + () => [ + createTextColumn("id", "ID", (obj: ObjectListItem) => obj.id, { + width: idWidth + 1, + color: colors.idColor, + dimColor: false, + bold: false, + }), + createTextColumn( + "name", + "Name", + (obj: ObjectListItem) => obj.name || "", + { + width: nameWidth, + }, + ), + createTextColumn( + "type", + "Type", + (obj: ObjectListItem) => obj.content_type || "", + { + width: typeWidth, + color: colors.textDim, + dimColor: false, + bold: false, + visible: showTypeColumn, + }, + ), + createTextColumn( + "size", + "Size", + (obj: ObjectListItem) => formatFileSize(obj.size_bytes), + { + width: sizeWidth, + color: colors.textDim, + dimColor: false, + bold: false, + visible: showSizeColumn, + }, + ), + createTextColumn( + "created", + "Created", + (obj: ObjectListItem) => + obj.create_time_ms ? formatTimeAgo(obj.create_time_ms) : "", + { + width: timeWidth, + color: colors.textDim, + dimColor: false, + bold: false, + }, + ), + ], + [ + idWidth, + nameWidth, + typeWidth, + sizeWidth, + timeWidth, + showTypeColumn, + showSizeColumn, + ], + ); + + // Handle Ctrl+C to exit + useExitOnCtrlC(); + + // Ensure selected index is within bounds + React.useEffect(() => { + if (objects.length > 0 && selectedIndex >= objects.length) { + setSelectedIndex(Math.max(0, objects.length - 1)); + } + }, [objects.length, selectedIndex]); + + const selectedObjectItem = objects[selectedIndex]; + + // Calculate pagination info for display + const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE)); + const startIndex = currentPage * PAGE_SIZE; + const endIndex = startIndex + objects.length; + + const executeOperation = async () => { + const client = getClient(); + const obj = selectedObject; + + if (!obj) return; + + try { + setOperationLoading(true); + switch (executingOperation) { + case "delete": + await client.objects.delete(obj.id); + setOperationResult(`Object ${obj.id} deleted successfully`); + break; + case "download": { + // Get download URL and open in browser + const objDetails = await client.objects.retrieve(obj.id); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const downloadUrl = (objDetails as any).download_url; + if (downloadUrl) { + const { exec } = await import("child_process"); + const platform = process.platform; + let openCommand: string; + if (platform === "darwin") { + openCommand = `open "${downloadUrl}"`; + } else if (platform === "win32") { + openCommand = `start "${downloadUrl}"`; + } else { + openCommand = `xdg-open "${downloadUrl}"`; + } + exec(openCommand); + setOperationResult(`Download started for ${obj.name || obj.id}`); + } else { + setOperationError(new Error("No download URL available")); + } + break; + } + } + } catch (err) { + setOperationError(err as Error); + } finally { + setOperationLoading(false); + } + }; + + useInput((input, key) => { + // Handle operation result display + if (operationResult || operationError) { + if (input === "q" || key.escape || key.return) { + setOperationResult(null); + setOperationError(null); + setExecutingOperation(null); + setSelectedObject(null); + } + return; + } + + // Handle popup navigation + if (showPopup) { + if (key.upArrow && selectedOperation > 0) { + setSelectedOperation(selectedOperation - 1); + } else if (key.downArrow && selectedOperation < operations.length - 1) { + setSelectedOperation(selectedOperation + 1); + } else if (key.return) { + setShowPopup(false); + const operationKey = operations[selectedOperation].key; + + if (operationKey === "view_details") { + navigate("object-detail", { + objectId: selectedObjectItem.id, + }); + } else { + setSelectedObject(selectedObjectItem); + setExecutingOperation(operationKey); + // Execute immediately after state update + setTimeout(() => executeOperation(), 0); + } + } else if (input === "v" && selectedObjectItem) { + // View details hotkey + setShowPopup(false); + navigate("object-detail", { + objectId: selectedObjectItem.id, + }); + } else if (key.escape || input === "q") { + setShowPopup(false); + setSelectedOperation(0); + } else if (input === "w") { + // Download hotkey + setShowPopup(false); + setSelectedObject(selectedObjectItem); + setExecutingOperation("download"); + setTimeout(() => executeOperation(), 0); + } else if (input === "d") { + // Delete hotkey + setShowPopup(false); + setSelectedObject(selectedObjectItem); + setExecutingOperation("delete"); + setTimeout(() => executeOperation(), 0); + } + return; + } + + const pageObjects = objects.length; + + // Handle list view navigation + if (key.upArrow && selectedIndex > 0) { + setSelectedIndex(selectedIndex - 1); + } else if (key.downArrow && selectedIndex < pageObjects - 1) { + setSelectedIndex(selectedIndex + 1); + } else if ( + (input === "n" || key.rightArrow) && + !loading && + !navigating && + hasMore + ) { + nextPage(); + setSelectedIndex(0); + } else if ( + (input === "p" || key.leftArrow) && + !loading && + !navigating && + hasPrev + ) { + prevPage(); + setSelectedIndex(0); + } else if (key.return && selectedObjectItem) { + // Enter key navigates to detail view + navigate("object-detail", { + objectId: selectedObjectItem.id, + }); + } else if (input === "a" && selectedObjectItem) { + setShowPopup(true); + setSelectedOperation(0); + } else if (key.escape) { + if (onBack) { + onBack(); + } else if (onExit) { + onExit(); + } else { + inkExit(); + } + } + }); + + // Operation result display + if (operationResult || operationError) { + const operationLabel = + operations.find((o) => o.key === executingOperation)?.label || + "Operation"; + return ( + <> + +
+ {operationResult && } + {operationError && ( + + )} + + + Press [Enter], [q], or [esc] to continue + + + + ); + } + + // Operation loading state + if (operationLoading && selectedObject) { + const operationLabel = + operations.find((o) => o.key === executingOperation)?.label || + "Operation"; + const messages: Record = { + delete: "Deleting object...", + download: "Starting download...", + }; + return ( + <> + +
+ + + ); + } + + // Loading state + if (loading && objects.length === 0) { + return ( + <> + + + + ); + } + + // Error state + if (error) { + return ( + <> + + + + ); + } + + // Main list view + return ( + <> + + + {/* Table - hide when popup is shown */} + {!showPopup && ( +
obj.id} + selectedIndex={selectedIndex} + title={`objects[${totalCount}]`} + columns={columns} + emptyState={ + + {figures.info} No storage objects found. Try: rli object upload{" "} + {""} + + } + /> + )} + + {/* Statistics Bar - hide when popup is shown */} + {!showPopup && ( + + + {figures.hamburger} {totalCount} + + + {" "} + total + + {totalPages > 1 && ( + <> + + {" "} + •{" "} + + {navigating ? ( + + {figures.pointer} Loading page {currentPage + 1}... + + ) : ( + + Page {currentPage + 1} of {totalPages} + + )} + + )} + + {" "} + •{" "} + + + Showing {startIndex + 1}-{endIndex} of {totalCount} + + + )} + + {/* Actions Popup */} + {showPopup && selectedObjectItem && ( + + ({ + key: op.key, + label: op.label, + color: op.color, + icon: op.icon, + shortcut: + op.key === "view_details" + ? "v" + : op.key === "download" + ? "w" + : op.key === "delete" + ? "d" + : "", + }))} + selectedOperation={selectedOperation} + onClose={() => setShowPopup(false)} + /> + + )} + + {/* Help Bar */} + + + {figures.arrowUp} + {figures.arrowDown} Navigate + + {(hasMore || hasPrev) && ( + + {" "} + • {figures.arrowLeft} + {figures.arrowRight} Page + + )} + + {" "} + • [Enter] Details + + + {" "} + • [a] Actions + + + {" "} + • [Esc] Back + + + + ); +}; + +// Export the UI component for use in the main menu +export { ListObjectsUI }; + +export async function listObjects(options: ListOptions) { + try { + const client = getClient(); + + // Build query params + const queryParams: Record = { + limit: DEFAULT_PAGE_SIZE, + }; + if (options.name) { + queryParams.name = options.name; + } + if (options.contentType) { + queryParams.content_type = options.contentType; + } + if (options.state) { + queryParams.state = options.state; + } + if (options.public !== undefined) { + queryParams.is_public = options.public; + } + + // Fetch objects + const result = await client.objects.list(queryParams); + + // Extract objects array + const objects = result.objects || []; + + output(objects, { format: options.output, defaultFormat: "json" }); + } catch (error) { + outputError("Failed to list objects", error); + } +} diff --git a/src/commands/snapshot/list.tsx b/src/commands/snapshot/list.tsx index 00be14a4..35e433ce 100644 --- a/src/commands/snapshot/list.tsx +++ b/src/commands/snapshot/list.tsx @@ -497,7 +497,8 @@ const ListSnapshotsUI = ({ columns={columns} emptyState={ - {figures.info} No snapshots found. Try: rli snapshot create {""} + {figures.info} No snapshots found. Try: rli snapshot create{" "} + {""} } /> diff --git a/src/components/DevboxCreatePage.tsx b/src/components/DevboxCreatePage.tsx index 24824b0b..7f2fdae3 100644 --- a/src/components/DevboxCreatePage.tsx +++ b/src/components/DevboxCreatePage.tsx @@ -125,9 +125,24 @@ export const DevboxCreatePage = ({ }> = formData.resource_size === "CUSTOM_SIZE" ? [ - { key: "custom_cpu", label: "CPU Cores (2-16, even)", type: "text", placeholder: "4" }, - { key: "custom_memory", label: "Memory GB (2-64, even)", type: "text", placeholder: "8" }, - { key: "custom_disk", label: "Disk GB (2-64, even)", type: "text", placeholder: "16" }, + { + key: "custom_cpu", + label: "CPU Cores (2-16, even)", + type: "text", + placeholder: "4", + }, + { + key: "custom_memory", + label: "Memory GB (2-64, even)", + type: "text", + placeholder: "8", + }, + { + key: "custom_disk", + label: "Disk GB (2-64, even)", + type: "text", + placeholder: "16", + }, ] : []; @@ -137,9 +152,24 @@ export const DevboxCreatePage = ({ type: "text" | "select" | "metadata" | "action"; placeholder?: string; }> = [ - { key: "keep_alive", label: "Keep Alive (seconds)", type: "text", placeholder: "3600" }, - { key: "blueprint_id", label: "Blueprint ID (optional)", type: "text", placeholder: "bpt_xxx" }, - { key: "snapshot_id", label: "Snapshot ID (optional)", type: "text", placeholder: "snp_xxx" }, + { + key: "keep_alive", + label: "Keep Alive (seconds)", + type: "text", + placeholder: "3600", + }, + { + key: "blueprint_id", + label: "Blueprint ID (optional)", + type: "text", + placeholder: "bpt_xxx", + }, + { + key: "snapshot_id", + label: "Snapshot ID (optional)", + type: "text", + placeholder: "snp_xxx", + }, { key: "metadata", label: "Metadata (optional)", type: "metadata" }, ]; @@ -540,7 +570,9 @@ export const DevboxCreatePage = ({ key={field.key} label={field.label} value={String(fieldData || "")} - onChange={(value) => setFormData({ ...formData, [field.key]: value })} + onChange={(value) => + setFormData({ ...formData, [field.key]: value }) + } isActive={isActive} placeholder={field.placeholder} /> @@ -554,8 +586,12 @@ export const DevboxCreatePage = ({ key={field.key} label={field.label} value={value || ""} - options={field.key === "architecture" ? architectures : resourceSizes} - onChange={(newValue) => setFormData({ ...formData, [field.key]: newValue })} + options={ + field.key === "architecture" ? architectures : resourceSizes + } + onChange={(newValue) => + setFormData({ ...formData, [field.key]: newValue }) + } isActive={isActive} /> ); diff --git a/src/components/DevboxDetailPage.tsx b/src/components/DevboxDetailPage.tsx index 305ec349..9278303f 100644 --- a/src/components/DevboxDetailPage.tsx +++ b/src/components/DevboxDetailPage.tsx @@ -261,9 +261,7 @@ export const DevboxDetailPage = ({ if (devbox.initiator_id) { detailFields.push({ label: "Initiator", - value: ( - {devbox.initiator_id} - ), + value: {devbox.initiator_id}, }); } diff --git a/src/components/MainMenu.tsx b/src/components/MainMenu.tsx index 5a490c09..dbbab390 100644 --- a/src/components/MainMenu.tsx +++ b/src/components/MainMenu.tsx @@ -47,6 +47,13 @@ const menuItems: MenuItem[] = [ icon: "◇", color: colors.info, }, + { + key: "objects", + label: "Objects", + description: "Manage storage objects and files", + icon: "▤", + color: colors.secondary, + }, ]; interface MainMenuProps { @@ -83,6 +90,8 @@ export const MainMenu = ({ onSelect }: MainMenuProps) => { onSelect("snapshots"); } else if (input === "n" || input === "4") { onSelect("network-policies"); + } else if (input === "o" || input === "5") { + onSelect("objects"); } else if (input === "u" && updateAvailable) { // Release terminal and exec into update command (never returns) execCommand("sh", [ @@ -143,7 +152,7 @@ export const MainMenu = ({ onSelect }: MainMenuProps) => { {figures.arrowUp} - {figures.arrowDown} Navigate • [1-4] Quick select • [Enter] Select • + {figures.arrowDown} Navigate • [1-5] Quick select • [Enter] Select • [Esc] Quit {updateAvailable && " • [u] Update"} @@ -223,7 +232,7 @@ export const MainMenu = ({ onSelect }: MainMenuProps) => { {figures.arrowUp} - {figures.arrowDown} Navigate • [1-4] Quick select • [Enter] Select • + {figures.arrowDown} Navigate • [1-5] Quick select • [Enter] Select • [Esc] Quit {updateAvailable && " • [u] Update"} diff --git a/src/components/NetworkPolicyCreatePage.tsx b/src/components/NetworkPolicyCreatePage.tsx index 111e26d6..5b6a0f18 100644 --- a/src/components/NetworkPolicyCreatePage.tsx +++ b/src/components/NetworkPolicyCreatePage.tsx @@ -59,7 +59,9 @@ export const NetworkPolicyCreatePage = ({ const [creating, setCreating] = React.useState(false); const [result, setResult] = React.useState(null); const [error, setError] = React.useState(null); - const [validationError, setValidationError] = React.useState(null); + const [validationError, setValidationError] = React.useState( + null, + ); const allFields: Array<{ key: FormField; @@ -70,14 +72,19 @@ export const NetworkPolicyCreatePage = ({ { key: "name", label: "Name (required)", type: "text" }, { key: "description", label: "Description", type: "text" }, { key: "allow_all", label: "Allow All Egress", type: "select" }, - { key: "allow_devbox_to_devbox", label: "Allow Devbox-to-Devbox", type: "select" }, + { + key: "allow_devbox_to_devbox", + label: "Allow Devbox-to-Devbox", + type: "select", + }, { key: "allowed_hostnames", label: "Allowed Hostnames", type: "list" }, ]; // Hide allowed_hostnames when allow_all is enabled - const fields = formData.allow_all === "Yes" - ? allFields.filter((f) => f.key !== "allowed_hostnames") - : allFields; + const fields = + formData.allow_all === "Yes" + ? allFields.filter((f) => f.key !== "allowed_hostnames") + : allFields; const currentFieldIndex = fields.findIndex((f) => f.key === currentField); @@ -210,7 +217,8 @@ export const NetworkPolicyCreatePage = ({ } createParams.allow_all = formData.allow_all === "Yes"; - createParams.allow_devbox_to_devbox = formData.allow_devbox_to_devbox === "Yes"; + createParams.allow_devbox_to_devbox = + formData.allow_devbox_to_devbox === "Yes"; if (formData.allowed_hostnames.length > 0) { createParams.allowed_hostnames = formData.allowed_hostnames; diff --git a/src/components/ResourceDetailPage.tsx b/src/components/ResourceDetailPage.tsx index 47244dc2..8e8ce8f9 100644 --- a/src/components/ResourceDetailPage.tsx +++ b/src/components/ResourceDetailPage.tsx @@ -326,7 +326,9 @@ export function ResourceDetailPage({ {section.fields - .filter((field) => field.value !== undefined && field.value !== null) + .filter( + (field) => field.value !== undefined && field.value !== null, + ) .map((field, fieldIndex) => ( {field.label} diff --git a/src/components/form/FormField.tsx b/src/components/form/FormField.tsx index 17c9e27a..f953e3d9 100644 --- a/src/components/form/FormField.tsx +++ b/src/components/form/FormField.tsx @@ -23,7 +23,11 @@ export const FormField = ({ }: FormFieldProps) => { return ( - + {isActive ? figures.pointer : " "} {label}:{" "} {children} diff --git a/src/components/form/FormListManager.tsx b/src/components/form/FormListManager.tsx index 35556e1d..a79a6325 100644 --- a/src/components/form/FormListManager.tsx +++ b/src/components/form/FormListManager.tsx @@ -201,9 +201,7 @@ export const FormListManager = ({ <> {/* Add new option */} - + {selectedIndex === 0 ? figures.pointer : " "}{" "} {selectedIndex === maxIndex ? figures.pointer : " "}{" "} {figures.tick} Done diff --git a/src/components/form/FormTextInput.tsx b/src/components/form/FormTextInput.tsx index 0452f15e..be2d933f 100644 --- a/src/components/form/FormTextInput.tsx +++ b/src/components/form/FormTextInput.tsx @@ -33,7 +33,9 @@ export const FormTextInput = ({ placeholder={placeholder} /> ) : ( - {value || "(empty)"} + + {value || "(empty)"} + )} ); diff --git a/src/router/Router.tsx b/src/router/Router.tsx index 30c6f0d4..cd63f0cb 100644 --- a/src/router/Router.tsx +++ b/src/router/Router.tsx @@ -8,6 +8,7 @@ import { useDevboxStore } from "../store/devboxStore.js"; import { useBlueprintStore } from "../store/blueprintStore.js"; import { useSnapshotStore } from "../store/snapshotStore.js"; import { useNetworkPolicyStore } from "../store/networkPolicyStore.js"; +import { useObjectStore } from "../store/objectStore.js"; import { ErrorBoundary } from "../components/ErrorBoundary.js"; import type { ScreenName } from "../router/types.js"; @@ -25,6 +26,8 @@ import { SnapshotDetailScreen } from "../screens/SnapshotDetailScreen.js"; import { NetworkPolicyListScreen } from "../screens/NetworkPolicyListScreen.js"; import { NetworkPolicyDetailScreen } from "../screens/NetworkPolicyDetailScreen.js"; import { NetworkPolicyCreateScreen } from "../screens/NetworkPolicyCreateScreen.js"; +import { ObjectListScreen } from "../screens/ObjectListScreen.js"; +import { ObjectDetailScreen } from "../screens/ObjectDetailScreen.js"; import { SSHSessionScreen } from "../screens/SSHSessionScreen.js"; /** @@ -78,6 +81,13 @@ export function Router() { useNetworkPolicyStore.getState().clearAll(); } break; + + case "object-list": + case "object-detail": + if (!currentScreen.startsWith("object")) { + useObjectStore.getState().clearAll(); + } + break; } } @@ -129,6 +139,12 @@ export function Router() { {currentScreen === "network-policy-create" && ( )} + {currentScreen === "object-list" && ( + + )} + {currentScreen === "object-detail" && ( + + )} {currentScreen === "ssh-session" && ( )} diff --git a/src/screens/BlueprintDetailScreen.tsx b/src/screens/BlueprintDetailScreen.tsx index a37785e1..ce98d625 100644 --- a/src/screens/BlueprintDetailScreen.tsx +++ b/src/screens/BlueprintDetailScreen.tsx @@ -6,10 +6,7 @@ import React from "react"; import { Text } from "ink"; import figures from "figures"; import { useNavigation } from "../store/navigationStore.js"; -import { - useBlueprintStore, - type Blueprint, -} from "../store/blueprintStore.js"; +import { useBlueprintStore, type Blueprint } from "../store/blueprintStore.js"; import { ResourceDetailPage, formatTimestamp, @@ -105,7 +102,10 @@ export function BlueprintDetailScreen({ return ( <> 0 ? `${hours}h ${minutes % 60}m` : `${minutes}m`, + value: hours > 0 ? `${hours}h ${minutes % 60}m` : `${minutes}m`, }); } if (lp.available_ports && lp.available_ports.length > 0) { @@ -210,11 +209,7 @@ export function BlueprintDetailScreen({ const lineCount = params.dockerfile.split("\n").length; setupFields.push({ label: "Dockerfile", - value: ( - - {lineCount} lines - - ), + value: {lineCount} lines, }); } if ( @@ -517,9 +512,7 @@ export function BlueprintDetailScreen({ onOperation={handleOperation} onBack={goBack} buildDetailLines={buildDetailLines} - pollResource={ - blueprint.status === "building" ? pollBlueprint : undefined - } + pollResource={blueprint.status === "building" ? pollBlueprint : undefined} /> ); } diff --git a/src/screens/MenuScreen.tsx b/src/screens/MenuScreen.tsx index 72a9512b..6ea22e4c 100644 --- a/src/screens/MenuScreen.tsx +++ b/src/screens/MenuScreen.tsx @@ -22,6 +22,9 @@ export function MenuScreen() { case "network-policies": navigate("network-policy-list"); break; + case "objects": + navigate("object-list"); + break; default: // Fallback for any other screen names navigate(key as ScreenName); diff --git a/src/screens/NetworkPolicyDetailScreen.tsx b/src/screens/NetworkPolicyDetailScreen.tsx index 431eccfa..72fc6296 100644 --- a/src/screens/NetworkPolicyDetailScreen.tsx +++ b/src/screens/NetworkPolicyDetailScreen.tsx @@ -57,9 +57,7 @@ export function NetworkPolicyDetailScreen({ const [deleting, setDeleting] = React.useState(false); // Find policy in store first - const policyFromStore = networkPolicies.find( - (p) => p.id === networkPolicyId, - ); + const policyFromStore = networkPolicies.find((p) => p.id === networkPolicyId); // Fetch policy from API if not in store or missing full details React.useEffect(() => { @@ -307,7 +305,8 @@ export function NetworkPolicyDetailScreen({ lines.push( {" "} - Allow Devbox-to-Devbox: {np.egress.allow_devbox_to_devbox ? "Yes" : "No"} + Allow Devbox-to-Devbox:{" "} + {np.egress.allow_devbox_to_devbox ? "Yes" : "No"} , ); lines.push( ); diff --git a/src/screens/ObjectDetailScreen.tsx b/src/screens/ObjectDetailScreen.tsx new file mode 100644 index 00000000..6568e08e --- /dev/null +++ b/src/screens/ObjectDetailScreen.tsx @@ -0,0 +1,386 @@ +/** + * ObjectDetailScreen - Detail page for storage objects + * Uses the generic ResourceDetailPage component + */ +import React from "react"; +import { Text } from "ink"; +import figures from "figures"; +import { useNavigation } from "../store/navigationStore.js"; +import { useObjectStore, type StorageObject } from "../store/objectStore.js"; +import { + ResourceDetailPage, + formatTimestamp, + type DetailSection, + type ResourceOperation, +} from "../components/ResourceDetailPage.js"; +import { + getObject, + deleteObject, + formatFileSize, +} from "../services/objectService.js"; +import { SpinnerComponent } from "../components/Spinner.js"; +import { ErrorMessage } from "../components/ErrorMessage.js"; +import { Breadcrumb } from "../components/Breadcrumb.js"; +import { colors } from "../utils/theme.js"; + +interface ObjectDetailScreenProps { + objectId?: string; +} + +export function ObjectDetailScreen({ objectId }: ObjectDetailScreenProps) { + const { goBack } = useNavigation(); + const objects = useObjectStore((state) => state.objects); + + const [loading, setLoading] = React.useState(false); + const [error, setError] = React.useState(null); + const [fetchedObject, setFetchedObject] = + React.useState(null); + const [deleting, setDeleting] = React.useState(false); + + // Find object in store first + const objectFromStore = objects.find((o) => o.id === objectId); + + // Polling function - must be defined before any early returns (Rules of Hooks) + const pollObject = React.useCallback(async () => { + if (!objectId) return null as unknown as StorageObject; + return getObject(objectId); + }, [objectId]); + + // Fetch object from API if not in store or missing full details + React.useEffect(() => { + if (objectId && !loading && !fetchedObject) { + // Always fetch full details since store may only have basic info + setLoading(true); + setError(null); + + getObject(objectId) + .then((obj) => { + setFetchedObject(obj); + setLoading(false); + }) + .catch((err) => { + setError(err as Error); + setLoading(false); + }); + } + }, [objectId, loading, fetchedObject]); + + // Use fetched object for full details, fall back to store for basic display + const storageObject = fetchedObject || objectFromStore; + + // Show loading state while fetching + if (loading && !storageObject) { + return ( + <> + + + + ); + } + + // Show error state if fetch failed + if (error && !storageObject) { + return ( + <> + + + + ); + } + + // Show error if no object found + if (!storageObject) { + return ( + <> + + + + ); + } + + // Build detail sections + const detailSections: DetailSection[] = []; + + // Basic details section + const basicFields = []; + if (storageObject.content_type) { + basicFields.push({ + label: "Content Type", + value: storageObject.content_type, + }); + } + if (storageObject.size_bytes !== undefined) { + basicFields.push({ + label: "Size", + value: formatFileSize(storageObject.size_bytes), + }); + } + if (storageObject.state) { + basicFields.push({ + label: "State", + value: storageObject.state, + }); + } + if (storageObject.is_public !== undefined) { + basicFields.push({ + label: "Public", + value: storageObject.is_public ? "Yes" : "No", + }); + } + if (storageObject.create_time_ms) { + basicFields.push({ + label: "Created", + value: formatTimestamp(storageObject.create_time_ms), + }); + } + + if (basicFields.length > 0) { + detailSections.push({ + title: "Details", + icon: figures.squareSmallFilled, + color: colors.warning, + fields: basicFields, + }); + } + + // Download URL section + if (storageObject.download_url) { + detailSections.push({ + title: "Download", + icon: figures.arrowDown, + color: colors.success, + fields: [ + { + label: "URL", + value: ( + + {storageObject.download_url.substring(0, 60)}... + + ), + }, + ], + }); + } + + // Metadata section + if ( + storageObject.metadata && + Object.keys(storageObject.metadata).length > 0 + ) { + const metadataFields = Object.entries(storageObject.metadata).map( + ([key, value]) => ({ + label: key, + value: value, + }), + ); + + detailSections.push({ + title: "Metadata", + icon: figures.identical, + color: colors.secondary, + fields: metadataFields, + }); + } + + // Operations available for objects + const operations: ResourceOperation[] = [ + { + key: "download", + label: "Download Object", + color: colors.success, + icon: figures.arrowDown, + shortcut: "w", + }, + { + key: "delete", + label: "Delete Object", + color: colors.error, + icon: figures.cross, + shortcut: "d", + }, + ]; + + // Handle operation selection + const handleOperation = async ( + operation: string, + resource: StorageObject, + ) => { + switch (operation) { + case "download": + if (resource.download_url) { + const { exec } = await import("child_process"); + const platform = process.platform; + let openCommand: string; + if (platform === "darwin") { + openCommand = `open "${resource.download_url}"`; + } else if (platform === "win32") { + openCommand = `start "${resource.download_url}"`; + } else { + openCommand = `xdg-open "${resource.download_url}"`; + } + exec(openCommand); + } + break; + case "delete": + setDeleting(true); + try { + await deleteObject(resource.id); + goBack(); + } catch (err) { + setError(err as Error); + setDeleting(false); + } + break; + } + }; + + // Build detailed info lines for full details view + const buildDetailLines = (obj: StorageObject): React.ReactElement[] => { + const lines: React.ReactElement[] = []; + + // Core Information + lines.push( + + Object Details + , + ); + lines.push( + + {" "} + ID: {obj.id} + , + ); + lines.push( + + {" "} + Name: {obj.name || "(none)"} + , + ); + lines.push( + + {" "} + Content Type: {obj.content_type || "(unknown)"} + , + ); + lines.push( + + {" "} + Size: {formatFileSize(obj.size_bytes)} + , + ); + lines.push( + + {" "} + State: {obj.state || "(unknown)"} + , + ); + lines.push( + + {" "} + Public: {obj.is_public ? "Yes" : "No"} + , + ); + if (obj.create_time_ms) { + lines.push( + + {" "} + Created: {new Date(obj.create_time_ms).toLocaleString()} + , + ); + } + lines.push( ); + + // Download URL + if (obj.download_url) { + lines.push( + + Download URL + , + ); + lines.push( + + {" "} + {obj.download_url} + , + ); + lines.push( ); + } + + // Metadata + if (obj.metadata && Object.keys(obj.metadata).length > 0) { + lines.push( + + Metadata + , + ); + Object.entries(obj.metadata).forEach(([key, value], idx) => { + lines.push( + + {" "} + {key}: {value} + , + ); + }); + lines.push( ); + } + + // Raw JSON + lines.push( + + Raw JSON + , + ); + const jsonLines = JSON.stringify(obj, null, 2).split("\n"); + jsonLines.forEach((line, idx) => { + lines.push( + + {" "} + {line} + , + ); + }); + + return lines; + }; + + // Show deleting state + if (deleting) { + return ( + <> + + + + ); + } + + return ( + obj.name || obj.id} + getId={(obj) => obj.id} + getStatus={(obj) => obj.state || "unknown"} + detailSections={detailSections} + operations={operations} + onOperation={handleOperation} + onBack={goBack} + buildDetailLines={buildDetailLines} + pollResource={pollObject} + /> + ); +} diff --git a/src/screens/ObjectListScreen.tsx b/src/screens/ObjectListScreen.tsx new file mode 100644 index 00000000..af54b5bb --- /dev/null +++ b/src/screens/ObjectListScreen.tsx @@ -0,0 +1,13 @@ +/** + * ObjectListScreen - Pure UI component using objectStore + * Simplified version for now - wraps existing component + */ +import React from "react"; +import { useNavigation } from "../store/navigationStore.js"; +import { ListObjectsUI } from "../commands/object/list.js"; + +export function ObjectListScreen() { + const { goBack } = useNavigation(); + + return ; +} diff --git a/src/screens/SnapshotDetailScreen.tsx b/src/screens/SnapshotDetailScreen.tsx index 0380457a..c1047318 100644 --- a/src/screens/SnapshotDetailScreen.tsx +++ b/src/screens/SnapshotDetailScreen.tsx @@ -31,8 +31,9 @@ export function SnapshotDetailScreen({ const [loading, setLoading] = React.useState(false); const [error, setError] = React.useState(null); - const [fetchedSnapshot, setFetchedSnapshot] = - React.useState(null); + const [fetchedSnapshot, setFetchedSnapshot] = React.useState( + null, + ); const [deleting, setDeleting] = React.useState(false); // Find snapshot in store first @@ -88,10 +89,7 @@ export function SnapshotDetailScreen({ - + ); } @@ -125,9 +123,7 @@ export function SnapshotDetailScreen({ if (snapshot.devbox_id) { basicFields.push({ label: "Source Devbox", - value: ( - {snapshot.devbox_id} - ), + value: {snapshot.devbox_id}, }); } if (snapshot.disk_size_bytes) { @@ -321,9 +317,7 @@ export function SnapshotDetailScreen({ onOperation={handleOperation} onBack={goBack} buildDetailLines={buildDetailLines} - pollResource={ - snapshot.status === "pending" ? pollSnapshot : undefined - } + pollResource={snapshot.status === "pending" ? pollSnapshot : undefined} /> ); } diff --git a/src/services/blueprintService.ts b/src/services/blueprintService.ts index 2f57608a..d06351cc 100644 --- a/src/services/blueprintService.ts +++ b/src/services/blueprintService.ts @@ -124,9 +124,10 @@ export async function getBlueprint(id: string): Promise { dockerfile: blueprint.parameters.dockerfile ?? undefined, system_setup_commands: blueprint.parameters.system_setup_commands ?? undefined, - file_mounts: (blueprint.parameters.file_mounts as - | Record - | undefined) ?? undefined, + file_mounts: + (blueprint.parameters.file_mounts as + | Record + | undefined) ?? undefined, } : undefined, }; diff --git a/src/services/objectService.ts b/src/services/objectService.ts new file mode 100644 index 00000000..74e06cc8 --- /dev/null +++ b/src/services/objectService.ts @@ -0,0 +1,140 @@ +/** + * Object Service - Handles all storage object API calls + */ +import { getClient } from "../utils/client.js"; +import type { StorageObject } from "../store/objectStore.js"; + +export interface ListObjectsOptions { + limit: number; + startingAfter?: string; + name?: string; + contentType?: string; + state?: string; + isPublic?: boolean; +} + +export interface ListObjectsResult { + objects: StorageObject[]; + totalCount: number; + hasMore: boolean; +} + +/** + * List storage objects with pagination + */ +export async function listObjects( + options: ListObjectsOptions, +): Promise { + const client = getClient(); + + const queryParams: Record = { + limit: options.limit, + }; + + if (options.startingAfter) { + queryParams.starting_after = options.startingAfter; + } + if (options.name) { + queryParams.name = options.name; + } + if (options.contentType) { + queryParams.content_type = options.contentType; + } + if (options.state) { + queryParams.state = options.state; + } + if (options.isPublic !== undefined) { + queryParams.is_public = options.isPublic; + } + + const result = await client.objects.list(queryParams); + + const objects: StorageObject[] = []; + + if (result.objects && Array.isArray(result.objects)) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + result.objects.forEach((obj: any) => { + // CRITICAL: Truncate all strings to prevent Yoga crashes + const MAX_ID_LENGTH = 100; + const MAX_NAME_LENGTH = 200; + const MAX_CONTENT_TYPE_LENGTH = 100; + const MAX_STATE_LENGTH = 50; + + objects.push({ + id: String(obj.id || "").substring(0, MAX_ID_LENGTH), + name: obj.name + ? String(obj.name).substring(0, MAX_NAME_LENGTH) + : undefined, + content_type: obj.content_type + ? String(obj.content_type).substring(0, MAX_CONTENT_TYPE_LENGTH) + : undefined, + size_bytes: obj.size_bytes, + state: obj.state + ? String(obj.state).substring(0, MAX_STATE_LENGTH) + : undefined, + is_public: obj.is_public, + create_time_ms: obj.create_time_ms, + }); + }); + } + + // Access pagination properties from the result + const pageResult = result as unknown as { + objects: unknown[]; + total_count?: number; + has_more?: boolean; + }; + + return { + objects, + totalCount: pageResult.total_count || objects.length, + hasMore: pageResult.has_more || false, + }; +} + +/** + * Get full object details by ID + */ +export async function getObject(id: string): Promise { + const client = getClient(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const obj: any = await client.objects.retrieve(id); + + return { + id: obj.id, + name: obj.name || undefined, + content_type: obj.content_type || undefined, + size_bytes: obj.size_bytes, + state: obj.state || undefined, + is_public: obj.is_public, + create_time_ms: obj.create_time_ms, + download_url: obj.download_url || undefined, + metadata: obj.metadata as Record | undefined, + }; +} + +/** + * Delete an object + */ +export async function deleteObject(id: string): Promise { + const client = getClient(); + await client.objects.delete(id); +} + +/** + * Format file size in human-readable format + */ +export function formatFileSize(bytes: number | undefined): string { + if (bytes === undefined || bytes === null) return "Unknown"; + + const units = ["B", "KB", "MB", "GB", "TB"]; + let size = bytes; + let unitIndex = 0; + + while (size >= 1024 && unitIndex < units.length - 1) { + size /= 1024; + unitIndex++; + } + + return `${size.toFixed(unitIndex > 0 ? 2 : 0)} ${units[unitIndex]}`; +} diff --git a/src/store/index.ts b/src/store/index.ts index f049d721..2ac1ecaa 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -21,3 +21,6 @@ export type { Blueprint } from "./blueprintStore.js"; export { useSnapshotStore } from "./snapshotStore.js"; export type { Snapshot } from "./snapshotStore.js"; + +export { useObjectStore } from "./objectStore.js"; +export type { StorageObject } from "./objectStore.js"; diff --git a/src/store/navigationStore.tsx b/src/store/navigationStore.tsx index 0de5b70b..959b66f0 100644 --- a/src/store/navigationStore.tsx +++ b/src/store/navigationStore.tsx @@ -14,6 +14,8 @@ export type ScreenName = | "network-policy-list" | "network-policy-detail" | "network-policy-create" + | "object-list" + | "object-detail" | "ssh-session"; export interface RouteParams { @@ -22,6 +24,7 @@ export interface RouteParams { blueprintName?: string; snapshotId?: string; networkPolicyId?: string; + objectId?: string; operation?: string; focusDevboxId?: string; status?: string; diff --git a/src/store/objectStore.ts b/src/store/objectStore.ts new file mode 100644 index 00000000..ee9254f1 --- /dev/null +++ b/src/store/objectStore.ts @@ -0,0 +1,182 @@ +/** + * Object Store - Manages storage object list state, pagination, and caching + */ +import { create } from "zustand"; + +export interface StorageObject { + id: string; + name?: string; + content_type?: string; + size_bytes?: number; + state?: string; + is_public?: boolean; + create_time_ms?: number; + // Extended fields for detail view + download_url?: string; + metadata?: Record; +} + +interface ObjectState { + // List data + objects: StorageObject[]; + loading: boolean; + initialLoading: boolean; + error: Error | null; + + // Pagination + currentPage: number; + pageSize: number; + totalCount: number; + hasMore: boolean; + + // Caching + pageCache: Map; + lastIdCache: Map; + + // Filters + nameFilter?: string; + contentTypeFilter?: string; + stateFilter?: string; + isPublicFilter?: boolean; + + // Selection + selectedIndex: number; + + // Actions + setObjects: (objects: StorageObject[]) => void; + setLoading: (loading: boolean) => void; + setInitialLoading: (loading: boolean) => void; + setError: (error: Error | null) => void; + + setCurrentPage: (page: number) => void; + setPageSize: (size: number) => void; + setTotalCount: (count: number) => void; + setHasMore: (hasMore: boolean) => void; + + setNameFilter: (name?: string) => void; + setContentTypeFilter: (contentType?: string) => void; + setStateFilter: (state?: string) => void; + setIsPublicFilter: (isPublic?: boolean) => void; + setSelectedIndex: (index: number) => void; + + cachePageData: (page: number, data: StorageObject[], lastId: string) => void; + getCachedPage: (page: number) => StorageObject[] | undefined; + clearCache: () => void; + clearAll: () => void; + + getSelectedObject: () => StorageObject | undefined; +} + +const MAX_CACHE_SIZE = 10; + +export const useObjectStore = create((set, get) => ({ + objects: [], + loading: false, + initialLoading: true, + error: null, + + currentPage: 0, + pageSize: 10, + totalCount: 0, + hasMore: false, + + pageCache: new Map(), + lastIdCache: new Map(), + + nameFilter: undefined, + contentTypeFilter: undefined, + stateFilter: undefined, + isPublicFilter: undefined, + selectedIndex: 0, + + setObjects: (objects) => set({ objects }), + setLoading: (loading) => set({ loading }), + setInitialLoading: (loading) => set({ initialLoading: loading }), + setError: (error) => set({ error }), + + setCurrentPage: (page) => set({ currentPage: page }), + setPageSize: (size) => set({ pageSize: size }), + setTotalCount: (count) => set({ totalCount: count }), + setHasMore: (hasMore) => set({ hasMore }), + + setNameFilter: (name) => set({ nameFilter: name }), + setContentTypeFilter: (contentType) => + set({ contentTypeFilter: contentType }), + setStateFilter: (state) => set({ stateFilter: state }), + setIsPublicFilter: (isPublic) => set({ isPublicFilter: isPublic }), + setSelectedIndex: (index) => set({ selectedIndex: index }), + + cachePageData: (page, data, lastId) => { + const state = get(); + const pageCache = state.pageCache; + const lastIdCache = state.lastIdCache; + + // Aggressive LRU eviction + if (pageCache.size >= MAX_CACHE_SIZE) { + const oldestKey = pageCache.keys().next().value; + if (oldestKey !== undefined) { + pageCache.delete(oldestKey); + lastIdCache.delete(oldestKey); + } + } + + // Create plain data objects to avoid SDK references + const plainData = data.map((obj) => ({ + id: obj.id, + name: obj.name, + content_type: obj.content_type, + size_bytes: obj.size_bytes, + state: obj.state, + is_public: obj.is_public, + create_time_ms: obj.create_time_ms, + })); + + pageCache.set(page, plainData); + lastIdCache.set(page, lastId); + + set({}); + }, + + getCachedPage: (page) => { + return get().pageCache.get(page); + }, + + clearCache: () => { + const state = get(); + state.pageCache.clear(); + state.lastIdCache.clear(); + + set({ + pageCache: new Map(), + lastIdCache: new Map(), + }); + }, + + clearAll: () => { + const state = get(); + state.pageCache.clear(); + state.lastIdCache.clear(); + + set({ + objects: [], + loading: false, + initialLoading: true, + error: null, + currentPage: 0, + totalCount: 0, + hasMore: false, + pageCache: new Map(), + lastIdCache: new Map(), + nameFilter: undefined, + contentTypeFilter: undefined, + stateFilter: undefined, + isPublicFilter: undefined, + selectedIndex: 0, + }); + }, + + getSelectedObject: () => { + const state = get(); + return state.objects[state.selectedIndex]; + }, +})); From 289d348375c4f69701cbc6af3b3692b822db777f Mon Sep 17 00:00:00 2001 From: Alexander Dines Date: Thu, 22 Jan 2026 14:50:14 -0800 Subject: [PATCH 03/24] cp dines --- src/commands/network-policy/list.tsx | 12 +- src/commands/object/list.tsx | 182 +++++++++++++++++++------- src/commands/snapshot/list.tsx | 12 +- src/components/MainMenu.tsx | 4 +- src/hooks/useListOperations.ts | 103 +++++++++++++++ src/screens/ObjectDetailScreen.tsx | 187 +++++++++++++++++++++++---- 6 files changed, 417 insertions(+), 83 deletions(-) create mode 100644 src/hooks/useListOperations.ts diff --git a/src/commands/network-policy/list.tsx b/src/commands/network-policy/list.tsx index ee02ed40..47cd6dd0 100644 --- a/src/commands/network-policy/list.tsx +++ b/src/commands/network-policy/list.tsx @@ -302,15 +302,14 @@ const ListNetworkPoliciesUI = ({ const startIndex = currentPage * PAGE_SIZE; const endIndex = startIndex + policies.length; - const executeOperation = async () => { + const executeOperation = async (policy: NetworkPolicyListItem, operationKey: string) => { const client = getClient(); - const policy = selectedPolicy; if (!policy) return; try { setOperationLoading(true); - switch (executingOperation) { + switch (operationKey) { case "delete": await client.networkPolicies.delete(policy.id); setOperationResult( @@ -361,8 +360,8 @@ const ListNetworkPoliciesUI = ({ } else { setSelectedPolicy(selectedPolicyItem); setExecutingOperation(operationKey); - // Execute immediately after state update - setTimeout(() => executeOperation(), 0); + // Execute immediately with values passed directly + executeOperation(selectedPolicyItem, operationKey); } } else if (input === "c") { // Create hotkey @@ -382,7 +381,8 @@ const ListNetworkPoliciesUI = ({ setShowPopup(false); setSelectedPolicy(selectedPolicyItem); setExecutingOperation("delete"); - setTimeout(() => executeOperation(), 0); + // Execute immediately with values passed directly + executeOperation(selectedPolicyItem, "delete"); } return; } diff --git a/src/commands/object/list.tsx b/src/commands/object/list.tsx index 13be016b..872838ef 100644 --- a/src/commands/object/list.tsx +++ b/src/commands/object/list.tsx @@ -1,6 +1,8 @@ import React from "react"; import { Box, Text, useInput, useApp } from "ink"; +import TextInput from "ink-text-input"; import figures from "figures"; +import { writeFile } from "fs/promises"; import { getClient } from "../../utils/client.js"; import { Header } from "../../components/Header.js"; import { SpinnerComponent } from "../../components/Spinner.js"; @@ -65,6 +67,8 @@ const ListObjectsUI = ({ null, ); const [operationLoading, setOperationLoading] = React.useState(false); + const [showDownloadPrompt, setShowDownloadPrompt] = React.useState(false); + const [downloadPath, setDownloadPath] = React.useState(""); // Calculate overhead for viewport height const overhead = 13; @@ -78,6 +82,7 @@ const ListObjectsUI = ({ // All width constants const idWidth = 25; const nameWidth = Math.max(15, terminalWidth >= 120 ? 30 : 20); + const stateWidth = 12; const typeWidth = 15; const sizeWidth = 12; const timeWidth = 15; @@ -145,12 +150,13 @@ const ListObjectsUI = ({ totalCount, nextPage, prevPage, + refresh, } = useCursorPagination({ fetchPage, pageSize: PAGE_SIZE, getItemId: (obj: ObjectListItem) => obj.id, - pollInterval: 5000, - pollingEnabled: !showPopup && !executingOperation, + pollInterval: 2000, + pollingEnabled: !showPopup && !executingOperation && !showDownloadPrompt, deps: [PAGE_SIZE], }); @@ -165,13 +171,13 @@ const ListObjectsUI = ({ }, { key: "download", - label: "Download Object", + label: "Download", color: colors.success, icon: figures.arrowDown, }, { key: "delete", - label: "Delete Object", + label: "Delete", color: colors.error, icon: figures.cross, }, @@ -196,6 +202,17 @@ const ListObjectsUI = ({ width: nameWidth, }, ), + createTextColumn( + "state", + "State", + (obj: ObjectListItem) => obj.state || "", + { + width: stateWidth, + color: colors.warning, + dimColor: false, + bold: false, + }, + ), createTextColumn( "type", "Type", @@ -236,6 +253,7 @@ const ListObjectsUI = ({ [ idWidth, nameWidth, + stateWidth, typeWidth, sizeWidth, timeWidth, @@ -261,40 +279,37 @@ const ListObjectsUI = ({ const startIndex = currentPage * PAGE_SIZE; const endIndex = startIndex + objects.length; - const executeOperation = async () => { + const executeOperation = async (obj: ObjectListItem, operationKey: string, targetPath?: string) => { const client = getClient(); - const obj = selectedObject; if (!obj) return; try { setOperationLoading(true); - switch (executingOperation) { + switch (operationKey) { case "delete": await client.objects.delete(obj.id); - setOperationResult(`Object ${obj.id} deleted successfully`); + setOperationResult(`Storage object ${obj.id} deleted successfully`); break; case "download": { - // Get download URL and open in browser - const objDetails = await client.objects.retrieve(obj.id); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const downloadUrl = (objDetails as any).download_url; - if (downloadUrl) { - const { exec } = await import("child_process"); - const platform = process.platform; - let openCommand: string; - if (platform === "darwin") { - openCommand = `open "${downloadUrl}"`; - } else if (platform === "win32") { - openCommand = `start "${downloadUrl}"`; - } else { - openCommand = `xdg-open "${downloadUrl}"`; - } - exec(openCommand); - setOperationResult(`Download started for ${obj.name || obj.id}`); - } else { - setOperationError(new Error("No download URL available")); + if (!targetPath) { + setOperationError(new Error("No download path specified")); + break; + } + // Get download URL + const downloadUrlResponse = await client.objects.download(obj.id, { + duration_seconds: 3600, + }); + // Download the file + const response = await fetch(downloadUrlResponse.download_url); + if (!response.ok) { + throw new Error(`Download failed: HTTP ${response.status}`); } + // Save the file + const arrayBuffer = await response.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + await writeFile(targetPath, buffer); + setOperationResult(`Downloaded to ${targetPath}`); break; } } @@ -305,14 +320,42 @@ const ListObjectsUI = ({ } }; + // Handle download submission + const handleDownloadSubmit = () => { + if (downloadPath.trim() && selectedObject) { + setShowDownloadPrompt(false); + setExecutingOperation("download"); + executeOperation(selectedObject, "download", downloadPath.trim()); + } + }; + useInput((input, key) => { // Handle operation result display if (operationResult || operationError) { if (input === "q" || key.escape || key.return) { + const wasDelete = executingOperation === "delete"; + const hadError = operationError !== null; setOperationResult(null); setOperationError(null); setExecutingOperation(null); setSelectedObject(null); + // Refresh the list after delete to show updated data + // Use setTimeout to ensure state updates are applied first + if (wasDelete && !hadError) { + setTimeout(() => refresh(), 0); + } + } + return; + } + + // Handle download prompt + if (showDownloadPrompt) { + if (key.escape) { + setShowDownloadPrompt(false); + setDownloadPath(""); + setSelectedObject(null); + } else if (key.return) { + handleDownloadSubmit(); } return; } @@ -331,11 +374,17 @@ const ListObjectsUI = ({ navigate("object-detail", { objectId: selectedObjectItem.id, }); + } else if (operationKey === "download") { + // Show download prompt + setSelectedObject(selectedObjectItem); + const defaultName = selectedObjectItem.name || selectedObjectItem.id; + setDownloadPath(`./${defaultName}`); + setShowDownloadPrompt(true); } else { setSelectedObject(selectedObjectItem); setExecutingOperation(operationKey); - // Execute immediately after state update - setTimeout(() => executeOperation(), 0); + // Execute immediately with the object and operation passed directly + executeOperation(selectedObjectItem, operationKey); } } else if (input === "v" && selectedObjectItem) { // View details hotkey @@ -347,17 +396,19 @@ const ListObjectsUI = ({ setShowPopup(false); setSelectedOperation(0); } else if (input === "w") { - // Download hotkey + // Download hotkey - show prompt setShowPopup(false); setSelectedObject(selectedObjectItem); - setExecutingOperation("download"); - setTimeout(() => executeOperation(), 0); + const defaultName = selectedObjectItem.name || selectedObjectItem.id; + setDownloadPath(`./${defaultName}`); + setShowDownloadPrompt(true); } else if (input === "d") { // Delete hotkey setShowPopup(false); setSelectedObject(selectedObjectItem); setExecutingOperation("delete"); - setTimeout(() => executeOperation(), 0); + // Execute immediately with the object and operation passed directly + executeOperation(selectedObjectItem, "delete"); } return; } @@ -413,7 +464,7 @@ const ListObjectsUI = ({ <> + +
+ + + {figures.arrowRight} Downloading:{" "} + {selectedObject.name || selectedObject.id} + + {selectedObject.size_bytes && ( + + {figures.info} Size: {formatFileSize(selectedObject.size_bytes)} + + )} + + + Save to path: + + {figures.pointer} + + + + + + [Enter] Download • [Esc] Cancel + + + + ); + } + // Operation loading state if (operationLoading && selectedObject) { const operationLabel = operations.find((o) => o.key === executingOperation)?.label || "Operation"; const messages: Record = { - delete: "Deleting object...", - download: "Starting download...", + delete: "Deleting storage object...", + download: "Downloading...", }; return ( <> - - + + ); } @@ -474,8 +568,8 @@ const ListObjectsUI = ({ if (error) { return ( <> - - + + ); } @@ -483,7 +577,7 @@ const ListObjectsUI = ({ // Main list view return ( <> - + {/* Table - hide when popup is shown */} {!showPopup && ( @@ -491,7 +585,7 @@ const ListObjectsUI = ({ data={objects} keyExtractor={(obj: ObjectListItem) => obj.id} selectedIndex={selectedIndex} - title={`objects[${totalCount}]`} + title={`storage_objects[${totalCount}]`} columns={columns} emptyState={ @@ -626,6 +720,6 @@ export async function listObjects(options: ListOptions) { output(objects, { format: options.output, defaultFormat: "json" }); } catch (error) { - outputError("Failed to list objects", error); + outputError("Failed to list storage objects", error); } } diff --git a/src/commands/snapshot/list.tsx b/src/commands/snapshot/list.tsx index 35e433ce..46574d06 100644 --- a/src/commands/snapshot/list.tsx +++ b/src/commands/snapshot/list.tsx @@ -242,15 +242,14 @@ const ListSnapshotsUI = ({ const startIndex = currentPage * PAGE_SIZE; const endIndex = startIndex + snapshots.length; - const executeOperation = async () => { + const executeOperation = async (snapshot: SnapshotListItem, operationKey: string) => { const client = getClient(); - const snapshot = selectedSnapshot; if (!snapshot) return; try { setOperationLoading(true); - switch (executingOperation) { + switch (operationKey) { case "delete": await client.devboxes.deleteDiskSnapshot(snapshot.id); setOperationResult(`Snapshot ${snapshot.id} deleted successfully`); @@ -300,8 +299,8 @@ const ListSnapshotsUI = ({ } else { setSelectedSnapshot(selectedSnapshotItem); setExecutingOperation(operationKey); - // Execute immediately after state update - setTimeout(() => executeOperation(), 0); + // Execute immediately with values passed directly + executeOperation(selectedSnapshotItem, operationKey); } } else if (input === "v" && selectedSnapshotItem) { // View details hotkey @@ -322,7 +321,8 @@ const ListSnapshotsUI = ({ setShowPopup(false); setSelectedSnapshot(selectedSnapshotItem); setExecutingOperation("delete"); - setTimeout(() => executeOperation(), 0); + // Execute immediately with values passed directly + executeOperation(selectedSnapshotItem, "delete"); } return; } diff --git a/src/components/MainMenu.tsx b/src/components/MainMenu.tsx index dbbab390..d8946f7d 100644 --- a/src/components/MainMenu.tsx +++ b/src/components/MainMenu.tsx @@ -49,8 +49,8 @@ const menuItems: MenuItem[] = [ }, { key: "objects", - label: "Objects", - description: "Manage storage objects and files", + label: "Storage Objects", + description: "Manage files and data in cloud storage", icon: "▤", color: colors.secondary, }, diff --git a/src/hooks/useListOperations.ts b/src/hooks/useListOperations.ts new file mode 100644 index 00000000..ecc47b5f --- /dev/null +++ b/src/hooks/useListOperations.ts @@ -0,0 +1,103 @@ +/** + * useListOperations - Reusable hook for handling operations in list views + * + * This hook solves the React state batching issue where setting state and immediately + * calling a function that reads from that state doesn't work because the state + * hasn't updated yet. Instead, we pass values directly to the execute function. + */ +import React from "react"; + +export interface OperationState { + /** The item currently being operated on */ + selectedItem: T | null; + /** The operation currently being executed */ + executingOperation: string | null; + /** Whether an operation is currently loading */ + isLoading: boolean; + /** Success result message */ + result: string | null; + /** Error from failed operation */ + error: Error | null; +} + +export interface UseListOperationsOptions { + /** + * Execute an operation on an item. + * Return a success message string, or throw an error on failure. + */ + onExecute: (item: T, operationKey: string, extra?: unknown) => Promise; + /** Optional callback after successful operation */ + onSuccess?: (operationKey: string) => void; +} + +export interface UseListOperationsReturn { + /** Current operation state */ + state: OperationState; + /** + * Execute an operation immediately. + * This function takes the item and operation key directly to avoid state timing issues. + */ + execute: (item: T, operationKey: string, extra?: unknown) => Promise; + /** Clear the result/error state and reset */ + clearResult: () => void; + /** Set the selected item without executing (for multi-step operations like download prompts) */ + setSelectedItem: (item: T | null) => void; + /** Set the executing operation name (for display purposes during multi-step operations) */ + setExecutingOperation: (operation: string | null) => void; +} + +export function useListOperations( + options: UseListOperationsOptions +): UseListOperationsReturn { + const { onExecute, onSuccess } = options; + + const [selectedItem, setSelectedItem] = React.useState(null); + const [executingOperation, setExecutingOperation] = React.useState(null); + const [isLoading, setIsLoading] = React.useState(false); + const [result, setResult] = React.useState(null); + const [error, setError] = React.useState(null); + + const execute = React.useCallback( + async (item: T, operationKey: string, extra?: unknown) => { + // Set state for UI display purposes + setSelectedItem(item); + setExecutingOperation(operationKey); + setIsLoading(true); + setError(null); + setResult(null); + + try { + // Execute with values passed directly (not from state) + const successMessage = await onExecute(item, operationKey, extra); + setResult(successMessage); + onSuccess?.(operationKey); + } catch (err) { + setError(err as Error); + } finally { + setIsLoading(false); + } + }, + [onExecute, onSuccess] + ); + + const clearResult = React.useCallback(() => { + setResult(null); + setError(null); + setExecutingOperation(null); + setSelectedItem(null); + }, []); + + return { + state: { + selectedItem, + executingOperation, + isLoading, + result, + error, + }, + execute, + clearResult, + setSelectedItem, + setExecutingOperation, + }; +} diff --git a/src/screens/ObjectDetailScreen.tsx b/src/screens/ObjectDetailScreen.tsx index 6568e08e..91ffd98f 100644 --- a/src/screens/ObjectDetailScreen.tsx +++ b/src/screens/ObjectDetailScreen.tsx @@ -3,10 +3,13 @@ * Uses the generic ResourceDetailPage component */ import React from "react"; -import { Text } from "ink"; +import { Box, Text, useInput } from "ink"; +import TextInput from "ink-text-input"; import figures from "figures"; +import { writeFile } from "fs/promises"; import { useNavigation } from "../store/navigationStore.js"; import { useObjectStore, type StorageObject } from "../store/objectStore.js"; +import { getClient } from "../utils/client.js"; import { ResourceDetailPage, formatTimestamp, @@ -20,7 +23,9 @@ import { } from "../services/objectService.js"; import { SpinnerComponent } from "../components/Spinner.js"; import { ErrorMessage } from "../components/ErrorMessage.js"; +import { SuccessMessage } from "../components/SuccessMessage.js"; import { Breadcrumb } from "../components/Breadcrumb.js"; +import { Header } from "../components/Header.js"; import { colors } from "../utils/theme.js"; interface ObjectDetailScreenProps { @@ -36,6 +41,11 @@ export function ObjectDetailScreen({ objectId }: ObjectDetailScreenProps) { const [fetchedObject, setFetchedObject] = React.useState(null); const [deleting, setDeleting] = React.useState(false); + const [showDownloadPrompt, setShowDownloadPrompt] = React.useState(false); + const [downloadPath, setDownloadPath] = React.useState(""); + const [downloading, setDownloading] = React.useState(false); + const [downloadResult, setDownloadResult] = React.useState(null); + const [downloadError, setDownloadError] = React.useState(null); // Find object in store first const objectFromStore = objects.find((o) => o.id === objectId); @@ -68,12 +78,64 @@ export function ObjectDetailScreen({ objectId }: ObjectDetailScreenProps) { // Use fetched object for full details, fall back to store for basic display const storageObject = fetchedObject || objectFromStore; + // Handle download submission + const handleDownloadSubmit = React.useCallback(async () => { + if (!downloadPath.trim() || !storageObject) return; + + setShowDownloadPrompt(false); + setDownloading(true); + + try { + const client = getClient(); + // Get download URL + const downloadUrlResponse = await client.objects.download(storageObject.id, { + duration_seconds: 3600, + }); + // Download the file + const response = await fetch(downloadUrlResponse.download_url); + if (!response.ok) { + throw new Error(`Download failed: HTTP ${response.status}`); + } + // Save the file + const arrayBuffer = await response.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + await writeFile(downloadPath.trim(), buffer); + setDownloadResult(`Downloaded to ${downloadPath.trim()}`); + } catch (err) { + setDownloadError(err as Error); + } finally { + setDownloading(false); + } + }, [downloadPath, storageObject]); + + // Handle input for download prompt and result screens - must be before early returns (Rules of Hooks) + useInput((input, key) => { + if (showDownloadPrompt) { + if (key.escape) { + setShowDownloadPrompt(false); + setDownloadPath(""); + } else if (key.return) { + handleDownloadSubmit(); + } + return; + } + + if (downloadResult || downloadError) { + if (input === "q" || key.escape || key.return) { + setDownloadResult(null); + setDownloadError(null); + setDownloadPath(""); + } + return; + } + }, { isActive: showDownloadPrompt || !!downloadResult || !!downloadError }); + // Show loading state while fetching if (loading && !storageObject) { return ( <> @@ -85,7 +147,7 @@ export function ObjectDetailScreen({ objectId }: ObjectDetailScreenProps) { return ( <> @@ -97,11 +159,11 @@ export function ObjectDetailScreen({ objectId }: ObjectDetailScreenProps) { return ( <> ); @@ -195,14 +257,14 @@ export function ObjectDetailScreen({ objectId }: ObjectDetailScreenProps) { const operations: ResourceOperation[] = [ { key: "download", - label: "Download Object", + label: "Download", color: colors.success, icon: figures.arrowDown, shortcut: "w", }, { key: "delete", - label: "Delete Object", + label: "Delete", color: colors.error, icon: figures.cross, shortcut: "d", @@ -216,19 +278,10 @@ export function ObjectDetailScreen({ objectId }: ObjectDetailScreenProps) { ) => { switch (operation) { case "download": - if (resource.download_url) { - const { exec } = await import("child_process"); - const platform = process.platform; - let openCommand: string; - if (platform === "darwin") { - openCommand = `open "${resource.download_url}"`; - } else if (platform === "win32") { - openCommand = `start "${resource.download_url}"`; - } else { - openCommand = `xdg-open "${resource.download_url}"`; - } - exec(openCommand); - } + // Show download prompt + const defaultName = resource.name || resource.id; + setDownloadPath(`./${defaultName}`); + setShowDownloadPrompt(true); break; case "delete": setDeleting(true); @@ -250,7 +303,7 @@ export function ObjectDetailScreen({ objectId }: ObjectDetailScreenProps) { // Core Information lines.push( - Object Details + Storage Object Details , ); lines.push( @@ -352,18 +405,102 @@ export function ObjectDetailScreen({ objectId }: ObjectDetailScreenProps) { return lines; }; + // Show download result + if (downloadResult || downloadError) { + return ( + <> + +
+ {downloadResult && } + {downloadError && ( + + )} + + + Press [Enter], [q], or [esc] to continue + + + + ); + } + + // Show downloading state + if (downloading) { + return ( + <> + + + + ); + } + + // Show download prompt + if (showDownloadPrompt) { + return ( + <> + +
+ + + {figures.arrowRight} Downloading:{" "} + {storageObject.name || storageObject.id} + + {storageObject.size_bytes && ( + + {figures.info} Size: {formatFileSize(storageObject.size_bytes)} + + )} + + + Save to path: + + {figures.pointer} + + + + + + [Enter] Download • [Esc] Cancel + + + + ); + } + // Show deleting state if (deleting) { return ( <> - + ); } @@ -371,7 +508,7 @@ export function ObjectDetailScreen({ objectId }: ObjectDetailScreenProps) { return ( obj.name || obj.id} getId={(obj) => obj.id} getStatus={(obj) => obj.state || "unknown"} From 569bd362035ca8b13168098138d9107c4cf0b285 Mon Sep 17 00:00:00 2001 From: Alexander Dines Date: Thu, 22 Jan 2026 14:50:34 -0800 Subject: [PATCH 04/24] cp dines --- src/commands/network-policy/list.tsx | 5 +- src/commands/object/list.tsx | 10 ++- src/commands/snapshot/list.tsx | 5 +- src/hooks/useListOperations.ts | 103 --------------------------- src/screens/ObjectDetailScreen.tsx | 75 +++++++++++-------- 5 files changed, 63 insertions(+), 135 deletions(-) delete mode 100644 src/hooks/useListOperations.ts diff --git a/src/commands/network-policy/list.tsx b/src/commands/network-policy/list.tsx index 47cd6dd0..98194bf8 100644 --- a/src/commands/network-policy/list.tsx +++ b/src/commands/network-policy/list.tsx @@ -302,7 +302,10 @@ const ListNetworkPoliciesUI = ({ const startIndex = currentPage * PAGE_SIZE; const endIndex = startIndex + policies.length; - const executeOperation = async (policy: NetworkPolicyListItem, operationKey: string) => { + const executeOperation = async ( + policy: NetworkPolicyListItem, + operationKey: string, + ) => { const client = getClient(); if (!policy) return; diff --git a/src/commands/object/list.tsx b/src/commands/object/list.tsx index 872838ef..9e6625be 100644 --- a/src/commands/object/list.tsx +++ b/src/commands/object/list.tsx @@ -279,7 +279,11 @@ const ListObjectsUI = ({ const startIndex = currentPage * PAGE_SIZE; const endIndex = startIndex + objects.length; - const executeOperation = async (obj: ObjectListItem, operationKey: string, targetPath?: string) => { + const executeOperation = async ( + obj: ObjectListItem, + operationKey: string, + targetPath?: string, + ) => { const client = getClient(); if (!obj) return; @@ -500,7 +504,9 @@ const ListObjectsUI = ({ {figures.arrowRight} Downloading:{" "} - {selectedObject.name || selectedObject.id} + + {selectedObject.name || selectedObject.id} + {selectedObject.size_bytes && ( diff --git a/src/commands/snapshot/list.tsx b/src/commands/snapshot/list.tsx index 46574d06..da82d98d 100644 --- a/src/commands/snapshot/list.tsx +++ b/src/commands/snapshot/list.tsx @@ -242,7 +242,10 @@ const ListSnapshotsUI = ({ const startIndex = currentPage * PAGE_SIZE; const endIndex = startIndex + snapshots.length; - const executeOperation = async (snapshot: SnapshotListItem, operationKey: string) => { + const executeOperation = async ( + snapshot: SnapshotListItem, + operationKey: string, + ) => { const client = getClient(); if (!snapshot) return; diff --git a/src/hooks/useListOperations.ts b/src/hooks/useListOperations.ts deleted file mode 100644 index ecc47b5f..00000000 --- a/src/hooks/useListOperations.ts +++ /dev/null @@ -1,103 +0,0 @@ -/** - * useListOperations - Reusable hook for handling operations in list views - * - * This hook solves the React state batching issue where setting state and immediately - * calling a function that reads from that state doesn't work because the state - * hasn't updated yet. Instead, we pass values directly to the execute function. - */ -import React from "react"; - -export interface OperationState { - /** The item currently being operated on */ - selectedItem: T | null; - /** The operation currently being executed */ - executingOperation: string | null; - /** Whether an operation is currently loading */ - isLoading: boolean; - /** Success result message */ - result: string | null; - /** Error from failed operation */ - error: Error | null; -} - -export interface UseListOperationsOptions { - /** - * Execute an operation on an item. - * Return a success message string, or throw an error on failure. - */ - onExecute: (item: T, operationKey: string, extra?: unknown) => Promise; - /** Optional callback after successful operation */ - onSuccess?: (operationKey: string) => void; -} - -export interface UseListOperationsReturn { - /** Current operation state */ - state: OperationState; - /** - * Execute an operation immediately. - * This function takes the item and operation key directly to avoid state timing issues. - */ - execute: (item: T, operationKey: string, extra?: unknown) => Promise; - /** Clear the result/error state and reset */ - clearResult: () => void; - /** Set the selected item without executing (for multi-step operations like download prompts) */ - setSelectedItem: (item: T | null) => void; - /** Set the executing operation name (for display purposes during multi-step operations) */ - setExecutingOperation: (operation: string | null) => void; -} - -export function useListOperations( - options: UseListOperationsOptions -): UseListOperationsReturn { - const { onExecute, onSuccess } = options; - - const [selectedItem, setSelectedItem] = React.useState(null); - const [executingOperation, setExecutingOperation] = React.useState(null); - const [isLoading, setIsLoading] = React.useState(false); - const [result, setResult] = React.useState(null); - const [error, setError] = React.useState(null); - - const execute = React.useCallback( - async (item: T, operationKey: string, extra?: unknown) => { - // Set state for UI display purposes - setSelectedItem(item); - setExecutingOperation(operationKey); - setIsLoading(true); - setError(null); - setResult(null); - - try { - // Execute with values passed directly (not from state) - const successMessage = await onExecute(item, operationKey, extra); - setResult(successMessage); - onSuccess?.(operationKey); - } catch (err) { - setError(err as Error); - } finally { - setIsLoading(false); - } - }, - [onExecute, onSuccess] - ); - - const clearResult = React.useCallback(() => { - setResult(null); - setError(null); - setExecutingOperation(null); - setSelectedItem(null); - }, []); - - return { - state: { - selectedItem, - executingOperation, - isLoading, - result, - error, - }, - execute, - clearResult, - setSelectedItem, - setExecutingOperation, - }; -} diff --git a/src/screens/ObjectDetailScreen.tsx b/src/screens/ObjectDetailScreen.tsx index 91ffd98f..e3b38979 100644 --- a/src/screens/ObjectDetailScreen.tsx +++ b/src/screens/ObjectDetailScreen.tsx @@ -44,7 +44,9 @@ export function ObjectDetailScreen({ objectId }: ObjectDetailScreenProps) { const [showDownloadPrompt, setShowDownloadPrompt] = React.useState(false); const [downloadPath, setDownloadPath] = React.useState(""); const [downloading, setDownloading] = React.useState(false); - const [downloadResult, setDownloadResult] = React.useState(null); + const [downloadResult, setDownloadResult] = React.useState( + null, + ); const [downloadError, setDownloadError] = React.useState(null); // Find object in store first @@ -81,16 +83,19 @@ export function ObjectDetailScreen({ objectId }: ObjectDetailScreenProps) { // Handle download submission const handleDownloadSubmit = React.useCallback(async () => { if (!downloadPath.trim() || !storageObject) return; - + setShowDownloadPrompt(false); setDownloading(true); - + try { const client = getClient(); // Get download URL - const downloadUrlResponse = await client.objects.download(storageObject.id, { - duration_seconds: 3600, - }); + const downloadUrlResponse = await client.objects.download( + storageObject.id, + { + duration_seconds: 3600, + }, + ); // Download the file const response = await fetch(downloadUrlResponse.download_url); if (!response.ok) { @@ -109,33 +114,39 @@ export function ObjectDetailScreen({ objectId }: ObjectDetailScreenProps) { }, [downloadPath, storageObject]); // Handle input for download prompt and result screens - must be before early returns (Rules of Hooks) - useInput((input, key) => { - if (showDownloadPrompt) { - if (key.escape) { - setShowDownloadPrompt(false); - setDownloadPath(""); - } else if (key.return) { - handleDownloadSubmit(); + useInput( + (input, key) => { + if (showDownloadPrompt) { + if (key.escape) { + setShowDownloadPrompt(false); + setDownloadPath(""); + } else if (key.return) { + handleDownloadSubmit(); + } + return; } - return; - } - - if (downloadResult || downloadError) { - if (input === "q" || key.escape || key.return) { - setDownloadResult(null); - setDownloadError(null); - setDownloadPath(""); + + if (downloadResult || downloadError) { + if (input === "q" || key.escape || key.return) { + setDownloadResult(null); + setDownloadError(null); + setDownloadPath(""); + } + return; } - return; - } - }, { isActive: showDownloadPrompt || !!downloadResult || !!downloadError }); + }, + { isActive: showDownloadPrompt || !!downloadResult || !!downloadError }, + ); // Show loading state while fetching if (loading && !storageObject) { return ( <> @@ -147,7 +158,10 @@ export function ObjectDetailScreen({ objectId }: ObjectDetailScreenProps) { return ( <> @@ -159,7 +173,10 @@ export function ObjectDetailScreen({ objectId }: ObjectDetailScreenProps) { return ( <> {figures.arrowRight} Downloading:{" "} - {storageObject.name || storageObject.id} + + {storageObject.name || storageObject.id} + {storageObject.size_bytes && ( From 0abff71acb10f0e1d19e34bf05c046fe670056bf Mon Sep 17 00:00:00 2001 From: Alexander Dines Date: Thu, 22 Jan 2026 14:52:29 -0800 Subject: [PATCH 05/24] cp dines --- src/commands/network-policy/list.tsx | 7 +++++++ src/commands/snapshot/list.tsx | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/src/commands/network-policy/list.tsx b/src/commands/network-policy/list.tsx index 98194bf8..b363bac5 100644 --- a/src/commands/network-policy/list.tsx +++ b/src/commands/network-policy/list.tsx @@ -170,6 +170,7 @@ const ListNetworkPoliciesUI = ({ totalCount, nextPage, prevPage, + refresh, } = useCursorPagination({ fetchPage, pageSize: PAGE_SIZE, @@ -331,10 +332,16 @@ const ListNetworkPoliciesUI = ({ // Handle operation result display if (operationResult || operationError) { if (input === "q" || key.escape || key.return) { + const wasDelete = executingOperation === "delete"; + const hadError = operationError !== null; setOperationResult(null); setOperationError(null); setExecutingOperation(null); setSelectedPolicy(null); + // Refresh the list after delete to show updated data + if (wasDelete && !hadError) { + setTimeout(() => refresh(), 0); + } } return; } diff --git a/src/commands/snapshot/list.tsx b/src/commands/snapshot/list.tsx index da82d98d..56f59d17 100644 --- a/src/commands/snapshot/list.tsx +++ b/src/commands/snapshot/list.tsx @@ -141,6 +141,7 @@ const ListSnapshotsUI = ({ totalCount, nextPage, prevPage, + refresh, } = useCursorPagination({ fetchPage, pageSize: PAGE_SIZE, @@ -269,10 +270,16 @@ const ListSnapshotsUI = ({ // Handle operation result display if (operationResult || operationError) { if (input === "q" || key.escape || key.return) { + const wasDelete = executingOperation === "delete"; + const hadError = operationError !== null; setOperationResult(null); setOperationError(null); setExecutingOperation(null); setSelectedSnapshot(null); + // Refresh the list after delete to show updated data + if (wasDelete && !hadError) { + setTimeout(() => refresh(), 0); + } } return; } From 49fa032b212fc1ac2b3b061a1f06789289c64093 Mon Sep 17 00:00:00 2001 From: Alexander Dines Date: Thu, 22 Jan 2026 14:54:28 -0800 Subject: [PATCH 06/24] cp dines --- src/components/MainMenu.tsx | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/components/MainMenu.tsx b/src/components/MainMenu.tsx index d8946f7d..2d88bf55 100644 --- a/src/components/MainMenu.tsx +++ b/src/components/MainMenu.tsx @@ -40,13 +40,6 @@ const menuItems: MenuItem[] = [ icon: "◈", color: colors.accent3, }, - { - key: "network-policies", - label: "Network Policies", - description: "Manage egress network access rules", - icon: "◇", - color: colors.info, - }, { key: "objects", label: "Storage Objects", @@ -54,6 +47,13 @@ const menuItems: MenuItem[] = [ icon: "▤", color: colors.secondary, }, + { + key: "network-policies", + label: "Network Policies", + description: "Manage egress network access rules", + icon: "◇", + color: colors.info, + }, ]; interface MainMenuProps { @@ -88,10 +88,10 @@ export const MainMenu = ({ onSelect }: MainMenuProps) => { onSelect("blueprints"); } else if (input === "s" || input === "3") { onSelect("snapshots"); - } else if (input === "n" || input === "4") { - onSelect("network-policies"); - } else if (input === "o" || input === "5") { + } else if (input === "o" || input === "4") { onSelect("objects"); + } else if (input === "n" || input === "5") { + onSelect("network-policies"); } else if (input === "u" && updateAvailable) { // Release terminal and exec into update command (never returns) execCommand("sh", [ From 12675bddd73ef97bde7d3769d90fef34f6aa3116 Mon Sep 17 00:00:00 2001 From: Alexander Dines Date: Thu, 22 Jan 2026 15:09:42 -0800 Subject: [PATCH 07/24] cp dines --- src/commands/blueprint/list.tsx | 37 +++++- src/commands/network-policy/list.tsx | 39 +++++- src/commands/object/list.tsx | 39 +++++- src/commands/snapshot/list.tsx | 39 +++++- src/components/ConfirmationPrompt.tsx | 132 +++++++++++++++++++++ src/components/DevboxActionsMenu.tsx | 36 +++++- src/components/DevboxCreatePage.tsx | 9 +- src/components/NetworkPolicyCreatePage.tsx | 9 +- src/screens/BlueprintDetailScreen.tsx | 46 +++++-- src/screens/NetworkPolicyDetailScreen.tsx | 46 +++++-- src/screens/ObjectDetailScreen.tsx | 44 +++++-- src/screens/SnapshotDetailScreen.tsx | 44 +++++-- 12 files changed, 460 insertions(+), 60 deletions(-) create mode 100644 src/components/ConfirmationPrompt.tsx diff --git a/src/commands/blueprint/list.tsx b/src/commands/blueprint/list.tsx index 36260cd2..a47f7633 100644 --- a/src/commands/blueprint/list.tsx +++ b/src/commands/blueprint/list.tsx @@ -22,6 +22,7 @@ import { useExitOnCtrlC } from "../../hooks/useExitOnCtrlC.js"; import { useViewportHeight } from "../../hooks/useViewportHeight.js"; import { useCursorPagination } from "../../hooks/useCursorPagination.js"; import { useNavigation } from "../../store/navigationStore.js"; +import { ConfirmationPrompt } from "../../components/ConfirmationPrompt.js"; const DEFAULT_PAGE_SIZE = 10; @@ -66,6 +67,7 @@ const ListBlueprintsUI = ({ ); const [operationLoading, setOperationLoading] = React.useState(false); const [showCreateDevbox, setShowCreateDevbox] = React.useState(false); + const [showDeleteConfirm, setShowDeleteConfirm] = React.useState(false); const [selectedIndex, setSelectedIndex] = React.useState(0); const [showPopup, setShowPopup] = React.useState(false); const { navigate } = useNavigation(); @@ -147,7 +149,7 @@ const ListBlueprintsUI = ({ pageSize: PAGE_SIZE, getItemId: (blueprint: BlueprintListItem) => blueprint.id, pollInterval: 2000, - pollingEnabled: !showPopup && !showCreateDevbox && !executingOperation, + pollingEnabled: !showPopup && !showCreateDevbox && !executingOperation && !showDeleteConfirm, deps: [PAGE_SIZE], }); @@ -467,6 +469,10 @@ const ListBlueprintsUI = ({ } else if (operationKey === "create_devbox") { setSelectedBlueprint(selectedBlueprintItem); setShowCreateDevbox(true); + } else if (operationKey === "delete") { + // Show delete confirmation + setSelectedBlueprint(selectedBlueprintItem); + setShowDeleteConfirm(true); } else { setSelectedBlueprint(selectedBlueprintItem); setExecutingOperation(operationKey as OperationType); @@ -499,10 +505,10 @@ const ListBlueprintsUI = ({ (op) => op.key === "delete", ); if (deleteIndex >= 0) { + // Show delete confirmation setShowPopup(false); setSelectedBlueprint(selectedBlueprintItem); - setExecutingOperation("delete"); - executeOperation(selectedBlueprintItem, "delete"); + setShowDeleteConfirm(true); } } else if (input === "l") { const logsIndex = allOperations.findIndex( @@ -580,6 +586,31 @@ const ListBlueprintsUI = ({ } }); + // Delete confirmation + if (showDeleteConfirm && selectedBlueprint) { + return ( + { + setShowDeleteConfirm(false); + setExecutingOperation("delete"); + executeOperation(selectedBlueprint, "delete"); + }} + onCancel={() => { + setShowDeleteConfirm(false); + setSelectedBlueprint(null); + }} + /> + ); + } + // Operation result display if (operationResult || operationError) { const operationLabel = diff --git a/src/commands/network-policy/list.tsx b/src/commands/network-policy/list.tsx index b363bac5..1607b9b8 100644 --- a/src/commands/network-policy/list.tsx +++ b/src/commands/network-policy/list.tsx @@ -19,6 +19,7 @@ import { useExitOnCtrlC } from "../../hooks/useExitOnCtrlC.js"; import { useCursorPagination } from "../../hooks/useCursorPagination.js"; import { useNavigation } from "../../store/navigationStore.js"; import { NetworkPolicyCreatePage } from "../../components/NetworkPolicyCreatePage.js"; +import { ConfirmationPrompt } from "../../components/ConfirmationPrompt.js"; interface ListOptions { name?: string; @@ -92,6 +93,7 @@ const ListNetworkPoliciesUI = ({ ); const [operationLoading, setOperationLoading] = React.useState(false); const [showCreatePolicy, setShowCreatePolicy] = React.useState(false); + const [showDeleteConfirm, setShowDeleteConfirm] = React.useState(false); // Calculate overhead for viewport height const overhead = 13; @@ -176,7 +178,7 @@ const ListNetworkPoliciesUI = ({ pageSize: PAGE_SIZE, getItemId: (policy: NetworkPolicyListItem) => policy.id, pollInterval: 5000, - pollingEnabled: !showPopup && !executingOperation && !showCreatePolicy, + pollingEnabled: !showPopup && !executingOperation && !showCreatePolicy && !showDeleteConfirm, deps: [PAGE_SIZE], }); @@ -367,6 +369,10 @@ const ListNetworkPoliciesUI = ({ navigate("network-policy-detail", { networkPolicyId: selectedPolicyItem.id, }); + } else if (operationKey === "delete") { + // Show delete confirmation + setSelectedPolicy(selectedPolicyItem); + setShowDeleteConfirm(true); } else { setSelectedPolicy(selectedPolicyItem); setExecutingOperation(operationKey); @@ -387,12 +393,10 @@ const ListNetworkPoliciesUI = ({ setShowPopup(false); setSelectedOperation(0); } else if (input === "d") { - // Delete hotkey + // Delete hotkey - show confirmation setShowPopup(false); setSelectedPolicy(selectedPolicyItem); - setExecutingOperation("delete"); - // Execute immediately with values passed directly - executeOperation(selectedPolicyItem, "delete"); + setShowDeleteConfirm(true); } return; } @@ -442,6 +446,31 @@ const ListNetworkPoliciesUI = ({ } }); + // Delete confirmation + if (showDeleteConfirm && selectedPolicy) { + return ( + { + setShowDeleteConfirm(false); + setExecutingOperation("delete"); + executeOperation(selectedPolicy, "delete"); + }} + onCancel={() => { + setShowDeleteConfirm(false); + setSelectedPolicy(null); + }} + /> + ); + } + // Operation result display if (operationResult || operationError) { const operationLabel = diff --git a/src/commands/object/list.tsx b/src/commands/object/list.tsx index 9e6625be..82cc9436 100644 --- a/src/commands/object/list.tsx +++ b/src/commands/object/list.tsx @@ -20,6 +20,7 @@ import { useExitOnCtrlC } from "../../hooks/useExitOnCtrlC.js"; import { useCursorPagination } from "../../hooks/useCursorPagination.js"; import { useNavigation } from "../../store/navigationStore.js"; import { formatFileSize } from "../../services/objectService.js"; +import { ConfirmationPrompt } from "../../components/ConfirmationPrompt.js"; interface ListOptions { name?: string; @@ -69,6 +70,7 @@ const ListObjectsUI = ({ const [operationLoading, setOperationLoading] = React.useState(false); const [showDownloadPrompt, setShowDownloadPrompt] = React.useState(false); const [downloadPath, setDownloadPath] = React.useState(""); + const [showDeleteConfirm, setShowDeleteConfirm] = React.useState(false); // Calculate overhead for viewport height const overhead = 13; @@ -156,7 +158,7 @@ const ListObjectsUI = ({ pageSize: PAGE_SIZE, getItemId: (obj: ObjectListItem) => obj.id, pollInterval: 2000, - pollingEnabled: !showPopup && !executingOperation && !showDownloadPrompt, + pollingEnabled: !showPopup && !executingOperation && !showDownloadPrompt && !showDeleteConfirm, deps: [PAGE_SIZE], }); @@ -384,6 +386,10 @@ const ListObjectsUI = ({ const defaultName = selectedObjectItem.name || selectedObjectItem.id; setDownloadPath(`./${defaultName}`); setShowDownloadPrompt(true); + } else if (operationKey === "delete") { + // Show delete confirmation + setSelectedObject(selectedObjectItem); + setShowDeleteConfirm(true); } else { setSelectedObject(selectedObjectItem); setExecutingOperation(operationKey); @@ -407,12 +413,10 @@ const ListObjectsUI = ({ setDownloadPath(`./${defaultName}`); setShowDownloadPrompt(true); } else if (input === "d") { - // Delete hotkey + // Delete hotkey - show confirmation setShowPopup(false); setSelectedObject(selectedObjectItem); - setExecutingOperation("delete"); - // Execute immediately with the object and operation passed directly - executeOperation(selectedObjectItem, "delete"); + setShowDeleteConfirm(true); } return; } @@ -534,6 +538,31 @@ const ListObjectsUI = ({ ); } + // Delete confirmation + if (showDeleteConfirm && selectedObject) { + return ( + { + setShowDeleteConfirm(false); + setExecutingOperation("delete"); + executeOperation(selectedObject, "delete"); + }} + onCancel={() => { + setShowDeleteConfirm(false); + setSelectedObject(null); + }} + /> + ); + } + // Operation loading state if (operationLoading && selectedObject) { const operationLabel = diff --git a/src/commands/snapshot/list.tsx b/src/commands/snapshot/list.tsx index 56f59d17..a65692e5 100644 --- a/src/commands/snapshot/list.tsx +++ b/src/commands/snapshot/list.tsx @@ -19,6 +19,7 @@ import { useExitOnCtrlC } from "../../hooks/useExitOnCtrlC.js"; import { useCursorPagination } from "../../hooks/useCursorPagination.js"; import { DevboxCreatePage } from "../../components/DevboxCreatePage.js"; import { useNavigation } from "../../store/navigationStore.js"; +import { ConfirmationPrompt } from "../../components/ConfirmationPrompt.js"; interface ListOptions { devbox?: string; @@ -66,6 +67,7 @@ const ListSnapshotsUI = ({ ); const [operationLoading, setOperationLoading] = React.useState(false); const [showCreateDevbox, setShowCreateDevbox] = React.useState(false); + const [showDeleteConfirm, setShowDeleteConfirm] = React.useState(false); // Calculate overhead for viewport height const overhead = 13; @@ -147,7 +149,7 @@ const ListSnapshotsUI = ({ pageSize: PAGE_SIZE, getItemId: (snapshot: SnapshotListItem) => snapshot.id, pollInterval: 2000, - pollingEnabled: !showPopup && !executingOperation && !showCreateDevbox, + pollingEnabled: !showPopup && !executingOperation && !showCreateDevbox && !showDeleteConfirm, deps: [devboxId, PAGE_SIZE], }); @@ -306,6 +308,10 @@ const ListSnapshotsUI = ({ } else if (operationKey === "create_devbox") { setSelectedSnapshot(selectedSnapshotItem); setShowCreateDevbox(true); + } else if (operationKey === "delete") { + // Show delete confirmation + setSelectedSnapshot(selectedSnapshotItem); + setShowDeleteConfirm(true); } else { setSelectedSnapshot(selectedSnapshotItem); setExecutingOperation(operationKey); @@ -327,12 +333,10 @@ const ListSnapshotsUI = ({ setSelectedSnapshot(selectedSnapshotItem); setShowCreateDevbox(true); } else if (input === "d") { - // Delete hotkey + // Delete hotkey - show confirmation setShowPopup(false); setSelectedSnapshot(selectedSnapshotItem); - setExecutingOperation("delete"); - // Execute immediately with values passed directly - executeOperation(selectedSnapshotItem, "delete"); + setShowDeleteConfirm(true); } return; } @@ -410,6 +414,31 @@ const ListSnapshotsUI = ({ ); } + // Delete confirmation + if (showDeleteConfirm && selectedSnapshot) { + return ( + { + setShowDeleteConfirm(false); + setExecutingOperation("delete"); + executeOperation(selectedSnapshot, "delete"); + }} + onCancel={() => { + setShowDeleteConfirm(false); + setSelectedSnapshot(null); + }} + /> + ); + } + // Operation loading state if (operationLoading && selectedSnapshot) { const operationLabel = diff --git a/src/components/ConfirmationPrompt.tsx b/src/components/ConfirmationPrompt.tsx new file mode 100644 index 00000000..32ee5048 --- /dev/null +++ b/src/components/ConfirmationPrompt.tsx @@ -0,0 +1,132 @@ +/** + * ConfirmationPrompt - Reusable confirmation dialog for destructive actions + */ +import React from "react"; +import { Box, Text, useInput } from "ink"; +import figures from "figures"; +import { Header } from "./Header.js"; +import { Breadcrumb } from "./Breadcrumb.js"; +import { colors } from "../utils/theme.js"; + +export interface ConfirmationPromptProps { + /** Title for the confirmation page */ + title: string; + /** Main message to display */ + message: string; + /** Optional additional details */ + details?: string; + /** Breadcrumb items */ + breadcrumbItems?: Array<{ label: string; active?: boolean }>; + /** Callback when confirmed (Yes selected) */ + onConfirm: () => void; + /** Callback when cancelled (No selected or escape) */ + onCancel: () => void; + /** Label for confirm button (default: "Yes, delete") */ + confirmLabel?: string; + /** Label for cancel button (default: "No, cancel") */ + cancelLabel?: string; + /** Color for confirm button (default: error/red) */ + confirmColor?: string; +} + +export const ConfirmationPrompt = ({ + title, + message, + details, + breadcrumbItems, + onConfirm, + onCancel, + confirmLabel = "Yes, delete", + cancelLabel = "No, cancel", + confirmColor = colors.error, +}: ConfirmationPromptProps) => { + // Default to "No" (index 1) + const [selectedIndex, setSelectedIndex] = React.useState(1); + + useInput((input, key) => { + if (key.upArrow || key.leftArrow) { + setSelectedIndex(0); + } else if (key.downArrow || key.rightArrow) { + setSelectedIndex(1); + } else if (key.return) { + if (selectedIndex === 0) { + onConfirm(); + } else { + onCancel(); + } + } else if (key.escape || input === "q") { + onCancel(); + } else if (input === "y" || input === "Y") { + onConfirm(); + } else if (input === "n" || input === "N") { + onCancel(); + } + }); + + return ( + <> + {breadcrumbItems && } +
+ + + + + {figures.warning} {message} + + + + {details && ( + + + {details} + + + )} + + + {/* Yes option */} + + + {selectedIndex === 0 ? figures.pointer : " "}{" "} + + + {confirmLabel} + + + {" "} + [y] + + + + {/* No option (default) */} + + + {selectedIndex === 1 ? figures.pointer : " "}{" "} + + + {cancelLabel} + + + {" "} + [n] + + + + + + + {figures.arrowUp} + {figures.arrowDown} Select • [Enter] Confirm • [y/n] Quick select • + [Esc] Cancel + + + + + ); +}; diff --git a/src/components/DevboxActionsMenu.tsx b/src/components/DevboxActionsMenu.tsx index a0fd0940..62e95b59 100644 --- a/src/components/DevboxActionsMenu.tsx +++ b/src/components/DevboxActionsMenu.tsx @@ -7,6 +7,7 @@ import { SpinnerComponent } from "./Spinner.js"; import { ErrorMessage } from "./ErrorMessage.js"; import { SuccessMessage } from "./SuccessMessage.js"; import { Breadcrumb } from "./Breadcrumb.js"; +import { ConfirmationPrompt } from "./ConfirmationPrompt.js"; import { colors } from "../utils/theme.js"; import { useViewportHeight } from "../hooks/useViewportHeight.js"; import { useNavigation } from "../store/navigationStore.js"; @@ -73,6 +74,7 @@ export const DevboxActionsMenu = ({ ); const [execScroll, setExecScroll] = React.useState(0); const [copyStatus, setCopyStatus] = React.useState(null); + const [showDeleteConfirm, setShowDeleteConfirm] = React.useState(false); // Calculate viewport for exec output: // - Breadcrumb (3 lines + marginBottom): 4 lines @@ -202,9 +204,9 @@ export const DevboxActionsMenu = ({ }) : allOperations; - // Auto-execute operations that don't need input + // Auto-execute operations that don't need input (except delete which needs confirmation) React.useEffect(() => { - const autoExecuteOps = ["delete", "ssh", "logs", "suspend", "resume"]; + const autoExecuteOps = ["ssh", "logs", "suspend", "resume"]; if ( executingOperation && autoExecuteOps.includes(executingOperation) && @@ -213,6 +215,10 @@ export const DevboxActionsMenu = ({ ) { executeOperation(); } + // Show confirmation for delete + if (executingOperation === "delete" && !loading && devbox && !showDeleteConfirm) { + setShowDeleteConfirm(true); + } }, [executingOperation]); // Handle Ctrl+C to exit @@ -516,6 +522,32 @@ export const DevboxActionsMenu = ({ const operationLabel = operations.find((o) => o.key === executingOperation)?.label || "Operation"; + // Show delete confirmation + if (showDeleteConfirm) { + return ( + { + setShowDeleteConfirm(false); + executeOperation(); + }} + onCancel={() => { + setShowDeleteConfirm(false); + setExecutingOperation(null); + onBack(); + }} + /> + ); + } + // Operation result display if (operationResult || operationError) { // Check for custom exec rendering diff --git a/src/components/DevboxCreatePage.tsx b/src/components/DevboxCreatePage.tsx index 7f2fdae3..8401a84e 100644 --- a/src/components/DevboxCreatePage.tsx +++ b/src/components/DevboxCreatePage.tsx @@ -346,13 +346,16 @@ export const DevboxCreatePage = ({ if (handleArchitectureNav(input, key)) return; if (handleResourceSizeNav(input, key)) return; - // Navigation - if (key.upArrow && currentFieldIndex > 0) { + // Navigation (up/down arrows and tab/shift+tab) + if ((key.upArrow || (key.tab && key.shift)) && currentFieldIndex > 0) { setCurrentField(fields[currentFieldIndex - 1].key); return; } - if (key.downArrow && currentFieldIndex < fields.length - 1) { + if ( + (key.downArrow || (key.tab && !key.shift)) && + currentFieldIndex < fields.length - 1 + ) { setCurrentField(fields[currentFieldIndex + 1].key); return; } diff --git a/src/components/NetworkPolicyCreatePage.tsx b/src/components/NetworkPolicyCreatePage.tsx index 5b6a0f18..f9616ba7 100644 --- a/src/components/NetworkPolicyCreatePage.tsx +++ b/src/components/NetworkPolicyCreatePage.tsx @@ -175,13 +175,16 @@ export const NetworkPolicyCreatePage = ({ if (handleAllowAllNav(input, key)) return; if (handleDevboxNav(input, key)) return; - // Navigation between fields - if (key.upArrow && currentFieldIndex > 0) { + // Navigation between fields (up/down arrows and tab/shift+tab) + if ((key.upArrow || (key.tab && key.shift)) && currentFieldIndex > 0) { setCurrentField(fields[currentFieldIndex - 1].key); return; } - if (key.downArrow && currentFieldIndex < fields.length - 1) { + if ( + (key.downArrow || (key.tab && !key.shift)) && + currentFieldIndex < fields.length - 1 + ) { setCurrentField(fields[currentFieldIndex + 1].key); return; } diff --git a/src/screens/BlueprintDetailScreen.tsx b/src/screens/BlueprintDetailScreen.tsx index ce98d625..5928c7d2 100644 --- a/src/screens/BlueprintDetailScreen.tsx +++ b/src/screens/BlueprintDetailScreen.tsx @@ -18,6 +18,7 @@ import { getClient } from "../utils/client.js"; import { SpinnerComponent } from "../components/Spinner.js"; import { ErrorMessage } from "../components/ErrorMessage.js"; import { Breadcrumb } from "../components/Breadcrumb.js"; +import { ConfirmationPrompt } from "../components/ConfirmationPrompt.js"; import { colors } from "../utils/theme.js"; interface BlueprintDetailScreenProps { @@ -35,6 +36,7 @@ export function BlueprintDetailScreen({ const [fetchedBlueprint, setFetchedBlueprint] = React.useState(null); const [deleting, setDeleting] = React.useState(false); + const [showDeleteConfirm, setShowDeleteConfirm] = React.useState(false); // Find blueprint in store first const blueprintFromStore = blueprints.find((b) => b.id === blueprintId); @@ -273,19 +275,45 @@ export function BlueprintDetailScreen({ navigate("devbox-create", { blueprintId: resource.id }); break; case "delete": - setDeleting(true); - try { - const client = getClient(); - await client.blueprints.delete(resource.id); - goBack(); - } catch (err) { - setError(err as Error); - setDeleting(false); - } + // Show confirmation dialog + setShowDeleteConfirm(true); break; } }; + // Execute delete after confirmation + const executeDelete = async () => { + if (!blueprint) return; + setShowDeleteConfirm(false); + setDeleting(true); + try { + const client = getClient(); + await client.blueprints.delete(blueprint.id); + goBack(); + } catch (err) { + setError(err as Error); + setDeleting(false); + } + }; + + // Show delete confirmation + if (showDeleteConfirm && blueprint) { + return ( + setShowDeleteConfirm(false)} + /> + ); + } + // Show deleting state if (deleting) { return ( diff --git a/src/screens/NetworkPolicyDetailScreen.tsx b/src/screens/NetworkPolicyDetailScreen.tsx index 72fc6296..4c76802c 100644 --- a/src/screens/NetworkPolicyDetailScreen.tsx +++ b/src/screens/NetworkPolicyDetailScreen.tsx @@ -23,6 +23,7 @@ import { import { SpinnerComponent } from "../components/Spinner.js"; import { ErrorMessage } from "../components/ErrorMessage.js"; import { Breadcrumb } from "../components/Breadcrumb.js"; +import { ConfirmationPrompt } from "../components/ConfirmationPrompt.js"; import { colors } from "../utils/theme.js"; interface NetworkPolicyDetailScreenProps { @@ -55,6 +56,7 @@ export function NetworkPolicyDetailScreen({ const [fetchedPolicy, setFetchedPolicy] = React.useState(null); const [deleting, setDeleting] = React.useState(false); + const [showDeleteConfirm, setShowDeleteConfirm] = React.useState(false); // Find policy in store first const policyFromStore = networkPolicies.find((p) => p.id === networkPolicyId); @@ -220,22 +222,30 @@ export function NetworkPolicyDetailScreen({ // Handle operation selection const handleOperation = async ( operation: string, - resource: NetworkPolicy, + _resource: NetworkPolicy, ) => { switch (operation) { case "delete": - setDeleting(true); - try { - await deleteNetworkPolicy(resource.id); - goBack(); - } catch (err) { - setError(err as Error); - setDeleting(false); - } + // Show confirmation dialog + setShowDeleteConfirm(true); break; } }; + // Execute delete after confirmation + const executeDelete = async () => { + if (!policy) return; + setShowDeleteConfirm(false); + setDeleting(true); + try { + await deleteNetworkPolicy(policy.id); + goBack(); + } catch (err) { + setError(err as Error); + setDeleting(false); + } + }; + // Build detailed info lines for full details view const buildDetailLines = (np: NetworkPolicy): React.ReactElement[] => { const lines: React.ReactElement[] = []; @@ -348,6 +358,24 @@ export function NetworkPolicyDetailScreen({ return lines; }; + // Show delete confirmation + if (showDeleteConfirm && policy) { + return ( + setShowDeleteConfirm(false)} + /> + ); + } + // Show deleting state if (deleting) { return ( diff --git a/src/screens/ObjectDetailScreen.tsx b/src/screens/ObjectDetailScreen.tsx index e3b38979..56fe38cd 100644 --- a/src/screens/ObjectDetailScreen.tsx +++ b/src/screens/ObjectDetailScreen.tsx @@ -26,6 +26,7 @@ import { ErrorMessage } from "../components/ErrorMessage.js"; import { SuccessMessage } from "../components/SuccessMessage.js"; import { Breadcrumb } from "../components/Breadcrumb.js"; import { Header } from "../components/Header.js"; +import { ConfirmationPrompt } from "../components/ConfirmationPrompt.js"; import { colors } from "../utils/theme.js"; interface ObjectDetailScreenProps { @@ -48,6 +49,7 @@ export function ObjectDetailScreen({ objectId }: ObjectDetailScreenProps) { null, ); const [downloadError, setDownloadError] = React.useState(null); + const [showDeleteConfirm, setShowDeleteConfirm] = React.useState(false); // Find object in store first const objectFromStore = objects.find((o) => o.id === objectId); @@ -301,18 +303,26 @@ export function ObjectDetailScreen({ objectId }: ObjectDetailScreenProps) { setShowDownloadPrompt(true); break; case "delete": - setDeleting(true); - try { - await deleteObject(resource.id); - goBack(); - } catch (err) { - setError(err as Error); - setDeleting(false); - } + // Show confirmation dialog + setShowDeleteConfirm(true); break; } }; + // Execute delete after confirmation + const executeDelete = async () => { + if (!storageObject) return; + setShowDeleteConfirm(false); + setDeleting(true); + try { + await deleteObject(storageObject.id); + goBack(); + } catch (err) { + setError(err as Error); + setDeleting(false); + } + }; + // Build detailed info lines for full details view const buildDetailLines = (obj: StorageObject): React.ReactElement[] => { const lines: React.ReactElement[] = []; @@ -508,6 +518,24 @@ export function ObjectDetailScreen({ objectId }: ObjectDetailScreenProps) { ); } + // Show delete confirmation + if (showDeleteConfirm && storageObject) { + return ( + setShowDeleteConfirm(false)} + /> + ); + } + // Show deleting state if (deleting) { return ( diff --git a/src/screens/SnapshotDetailScreen.tsx b/src/screens/SnapshotDetailScreen.tsx index c1047318..65a2f767 100644 --- a/src/screens/SnapshotDetailScreen.tsx +++ b/src/screens/SnapshotDetailScreen.tsx @@ -17,6 +17,7 @@ import { getSnapshot, deleteSnapshot } from "../services/snapshotService.js"; import { SpinnerComponent } from "../components/Spinner.js"; import { ErrorMessage } from "../components/ErrorMessage.js"; import { Breadcrumb } from "../components/Breadcrumb.js"; +import { ConfirmationPrompt } from "../components/ConfirmationPrompt.js"; import { colors } from "../utils/theme.js"; interface SnapshotDetailScreenProps { @@ -35,6 +36,7 @@ export function SnapshotDetailScreen({ null, ); const [deleting, setDeleting] = React.useState(false); + const [showDeleteConfirm, setShowDeleteConfirm] = React.useState(false); // Find snapshot in store first const snapshotFromStore = snapshots.find((s) => s.id === snapshotId); @@ -185,18 +187,26 @@ export function SnapshotDetailScreen({ navigate("devbox-create", { snapshotId: resource.id }); break; case "delete": - setDeleting(true); - try { - await deleteSnapshot(resource.id); - goBack(); - } catch (err) { - setError(err as Error); - setDeleting(false); - } + // Show confirmation dialog + setShowDeleteConfirm(true); break; } }; + // Execute delete after confirmation + const executeDelete = async () => { + if (!snapshot) return; + setShowDeleteConfirm(false); + setDeleting(true); + try { + await deleteSnapshot(snapshot.id); + goBack(); + } catch (err) { + setError(err as Error); + setDeleting(false); + } + }; + // Build detailed info lines for full details view const buildDetailLines = (snap: Snapshot): React.ReactElement[] => { const lines: React.ReactElement[] = []; @@ -289,6 +299,24 @@ export function SnapshotDetailScreen({ return lines; }; + // Show delete confirmation + if (showDeleteConfirm && snapshot) { + return ( + setShowDeleteConfirm(false)} + /> + ); + } + // Show deleting state if (deleting) { return ( From 2a92d530726aa721bc24c8f8333139bdf77f4e06 Mon Sep 17 00:00:00 2001 From: Alexander Dines Date: Thu, 22 Jan 2026 15:11:24 -0800 Subject: [PATCH 08/24] cp dines --- src/components/DevboxCreatePage.tsx | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/components/DevboxCreatePage.tsx b/src/components/DevboxCreatePage.tsx index 8401a84e..0ad35627 100644 --- a/src/components/DevboxCreatePage.tsx +++ b/src/components/DevboxCreatePage.tsx @@ -40,7 +40,8 @@ type FormField = | "keep_alive" | "metadata" | "blueprint_id" - | "snapshot_id"; + | "snapshot_id" + | "network_policy_id"; interface FormData { name: string; @@ -61,6 +62,7 @@ interface FormData { metadata: Record; blueprint_id: string; snapshot_id: string; + network_policy_id: string; } const architectures = ["arm64", "x86_64"] as const; @@ -92,6 +94,7 @@ export const DevboxCreatePage = ({ metadata: {}, blueprint_id: initialBlueprintId || "", snapshot_id: initialSnapshotId || "", + network_policy_id: "", }); const [metadataKey, setMetadataKey] = React.useState(""); const [metadataValue, setMetadataValue] = React.useState(""); @@ -170,6 +173,12 @@ export const DevboxCreatePage = ({ type: "text", placeholder: "snp_xxx", }, + { + key: "network_policy_id", + label: "Network Policy ID (optional)", + type: "text", + placeholder: "np_xxx", + }, { key: "metadata", label: "Metadata (optional)", type: "metadata" }, ]; @@ -467,6 +476,10 @@ export const DevboxCreatePage = ({ createParams.snapshot_id = formData.snapshot_id; } + if (formData.network_policy_id) { + createParams.network_policy_id = formData.network_policy_id; + } + if (Object.keys(launchParameters).length > 0) { createParams.launch_parameters = launchParameters; } From e946b2f80a3afa62104bf77925327b6ae50d4034 Mon Sep 17 00:00:00 2001 From: Alexander Dines Date: Thu, 22 Jan 2026 15:11:31 -0800 Subject: [PATCH 09/24] cp dines --- src/commands/blueprint/list.tsx | 6 +++++- src/commands/network-policy/list.tsx | 6 +++++- src/commands/object/list.tsx | 6 +++++- src/commands/snapshot/list.tsx | 6 +++++- src/components/DevboxActionsMenu.tsx | 7 ++++++- 5 files changed, 26 insertions(+), 5 deletions(-) diff --git a/src/commands/blueprint/list.tsx b/src/commands/blueprint/list.tsx index a47f7633..0e401890 100644 --- a/src/commands/blueprint/list.tsx +++ b/src/commands/blueprint/list.tsx @@ -149,7 +149,11 @@ const ListBlueprintsUI = ({ pageSize: PAGE_SIZE, getItemId: (blueprint: BlueprintListItem) => blueprint.id, pollInterval: 2000, - pollingEnabled: !showPopup && !showCreateDevbox && !executingOperation && !showDeleteConfirm, + pollingEnabled: + !showPopup && + !showCreateDevbox && + !executingOperation && + !showDeleteConfirm, deps: [PAGE_SIZE], }); diff --git a/src/commands/network-policy/list.tsx b/src/commands/network-policy/list.tsx index 1607b9b8..b7f7398b 100644 --- a/src/commands/network-policy/list.tsx +++ b/src/commands/network-policy/list.tsx @@ -178,7 +178,11 @@ const ListNetworkPoliciesUI = ({ pageSize: PAGE_SIZE, getItemId: (policy: NetworkPolicyListItem) => policy.id, pollInterval: 5000, - pollingEnabled: !showPopup && !executingOperation && !showCreatePolicy && !showDeleteConfirm, + pollingEnabled: + !showPopup && + !executingOperation && + !showCreatePolicy && + !showDeleteConfirm, deps: [PAGE_SIZE], }); diff --git a/src/commands/object/list.tsx b/src/commands/object/list.tsx index 82cc9436..14802ef6 100644 --- a/src/commands/object/list.tsx +++ b/src/commands/object/list.tsx @@ -158,7 +158,11 @@ const ListObjectsUI = ({ pageSize: PAGE_SIZE, getItemId: (obj: ObjectListItem) => obj.id, pollInterval: 2000, - pollingEnabled: !showPopup && !executingOperation && !showDownloadPrompt && !showDeleteConfirm, + pollingEnabled: + !showPopup && + !executingOperation && + !showDownloadPrompt && + !showDeleteConfirm, deps: [PAGE_SIZE], }); diff --git a/src/commands/snapshot/list.tsx b/src/commands/snapshot/list.tsx index a65692e5..04a4948b 100644 --- a/src/commands/snapshot/list.tsx +++ b/src/commands/snapshot/list.tsx @@ -149,7 +149,11 @@ const ListSnapshotsUI = ({ pageSize: PAGE_SIZE, getItemId: (snapshot: SnapshotListItem) => snapshot.id, pollInterval: 2000, - pollingEnabled: !showPopup && !executingOperation && !showCreateDevbox && !showDeleteConfirm, + pollingEnabled: + !showPopup && + !executingOperation && + !showCreateDevbox && + !showDeleteConfirm, deps: [devboxId, PAGE_SIZE], }); diff --git a/src/components/DevboxActionsMenu.tsx b/src/components/DevboxActionsMenu.tsx index 62e95b59..5e65b53d 100644 --- a/src/components/DevboxActionsMenu.tsx +++ b/src/components/DevboxActionsMenu.tsx @@ -216,7 +216,12 @@ export const DevboxActionsMenu = ({ executeOperation(); } // Show confirmation for delete - if (executingOperation === "delete" && !loading && devbox && !showDeleteConfirm) { + if ( + executingOperation === "delete" && + !loading && + devbox && + !showDeleteConfirm + ) { setShowDeleteConfirm(true); } }, [executingOperation]); From 63bbeaa76fc31088066d7dab7e996bd2d7110180 Mon Sep 17 00:00:00 2001 From: Alexander Dines Date: Thu, 22 Jan 2026 15:14:42 -0800 Subject: [PATCH 10/24] cp dines --- src/components/DevboxCreatePage.tsx | 2 +- src/components/DevboxDetailPage.tsx | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/components/DevboxCreatePage.tsx b/src/components/DevboxCreatePage.tsx index 0ad35627..4661d019 100644 --- a/src/components/DevboxCreatePage.tsx +++ b/src/components/DevboxCreatePage.tsx @@ -477,7 +477,7 @@ export const DevboxCreatePage = ({ } if (formData.network_policy_id) { - createParams.network_policy_id = formData.network_policy_id; + launchParameters.network_policy_id = formData.network_policy_id; } if (Object.keys(launchParameters).length > 0) { diff --git a/src/components/DevboxDetailPage.tsx b/src/components/DevboxDetailPage.tsx index 9278303f..aee4aba4 100644 --- a/src/components/DevboxDetailPage.tsx +++ b/src/components/DevboxDetailPage.tsx @@ -257,6 +257,16 @@ export const DevboxDetailPage = ({ }); } + // Network Policy + if (lp?.network_policy_id) { + detailFields.push({ + label: "Network Policy", + value: ( + {lp.network_policy_id} + ), + }); + } + // Initiator if (devbox.initiator_id) { detailFields.push({ From 118cfa57951cef5cc3bc49d2c453d7c06b3812cf Mon Sep 17 00:00:00 2001 From: Alexander Dines Date: Thu, 22 Jan 2026 15:28:56 -0800 Subject: [PATCH 11/24] cp dines --- src/commands/blueprint/list.tsx | 12 +---- src/commands/blueprint/prune.ts | 32 +++--------- src/commands/devbox/create.ts | 4 ++ src/commands/devbox/list.tsx | 12 +---- src/commands/network-policy/create.ts | 40 ++++++++++++++ src/commands/network-policy/delete.ts | 32 ++++++++++++ src/commands/network-policy/get.ts | 21 ++++++++ src/commands/object/list.tsx | 38 ++++++++++++++ src/screens/ObjectDetailScreen.tsx | 54 +++++++++++++++++++ src/screens/SnapshotDetailScreen.tsx | 2 +- src/services/blueprintService.ts | 44 +++------------- src/services/objectService.ts | 26 +++++----- src/services/snapshotService.ts | 26 +++++++--- src/store/blueprintStore.ts | 53 ++++++------------- src/store/networkPolicyStore.ts | 35 +++---------- src/store/objectStore.ts | 31 +++++------ src/store/snapshotStore.ts | 27 +++++----- src/utils/commands.ts | 75 +++++++++++++++++++++++++++ 18 files changed, 364 insertions(+), 200 deletions(-) create mode 100644 src/commands/network-policy/create.ts create mode 100644 src/commands/network-policy/delete.ts create mode 100644 src/commands/network-policy/get.ts diff --git a/src/commands/blueprint/list.tsx b/src/commands/blueprint/list.tsx index 0e401890..99accb82 100644 --- a/src/commands/blueprint/list.tsx +++ b/src/commands/blueprint/list.tsx @@ -170,13 +170,9 @@ const ListBlueprintsUI = ({ isSelected: boolean, ) => { const statusDisplay = getStatusDisplay(blueprint.status || ""); - const statusColor = - statusDisplay.color === colors.textDim - ? colors.info - : statusDisplay.color; return ( { const statusDisplay = getStatusDisplay(blueprint.status || ""); - 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 ( { */ function categorizeBlueprints(blueprints: Blueprint[], keepCount: number) { // Filter successful builds - const successful = blueprints.filter( - (b) => b.status === "build_complete" || b.status === "building_complete", - ); + const successful = blueprints.filter((b) => b.status === "build_complete"); - // Filter failed builds - const failed = blueprints.filter( - (b) => b.status !== "build_complete" && b.status !== "building_complete", - ); + // Filter failed/incomplete builds + const failed = blueprints.filter((b) => b.status !== "build_complete"); // Sort successful by create_time_ms descending (newest first) successful.sort((a, b) => (b.create_time_ms || 0) - (a.create_time_ms || 0)); @@ -158,16 +154,9 @@ function displaySummary( } else { // Show all blueprints without summarizing for (const blueprint of result.toDelete) { - const icon = - blueprint.status === "build_complete" || - blueprint.status === "building_complete" - ? "✓" - : "✗"; + const icon = blueprint.status === "build_complete" ? "✓" : "✗"; const statusLabel = - blueprint.status === "build_complete" || - blueprint.status === "building_complete" - ? "successful" - : "failed"; + blueprint.status === "build_complete" ? "successful" : "failed"; console.log( ` ${icon} ${blueprint.id} - Created ${formatTimestamp(blueprint.create_time_ms)} (${statusLabel})`, ); @@ -185,16 +174,9 @@ function displayDeletedBlueprints(deleted: Blueprint[]) { console.log("\nDeleted blueprints:"); for (const blueprint of deleted) { - const icon = - blueprint.status === "build_complete" || - blueprint.status === "building_complete" - ? "✓" - : "✗"; + const icon = blueprint.status === "build_complete" ? "✓" : "✗"; const statusLabel = - blueprint.status === "build_complete" || - blueprint.status === "building_complete" - ? "successful" - : "failed"; + blueprint.status === "build_complete" ? "successful" : "failed"; console.log( ` ${icon} ${blueprint.id} - Created ${formatTimestamp(blueprint.create_time_ms)} (${statusLabel})`, ); diff --git a/src/commands/devbox/create.ts b/src/commands/devbox/create.ts index bf9c7ce9..ea65aff9 100644 --- a/src/commands/devbox/create.ts +++ b/src/commands/devbox/create.ts @@ -21,6 +21,7 @@ interface CreateOptions { availablePorts?: string[]; root?: boolean; user?: string; + networkPolicy?: string; output?: string; } @@ -105,6 +106,9 @@ export async function createDevbox(options: CreateOptions = {}) { on_idle: options.idleAction, }; } + if (options.networkPolicy) { + launchParameters.network_policy_id = options.networkPolicy; + } // Build create request const createRequest: Record = { diff --git a/src/commands/devbox/list.tsx b/src/commands/devbox/list.tsx index f5c5cf36..2f75201d 100644 --- a/src/commands/devbox/list.tsx +++ b/src/commands/devbox/list.tsx @@ -211,13 +211,9 @@ const ListDevboxesUI = ({ 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 ( { const statusDisplay = getStatusDisplay(devbox?.status); - 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 ( = 100; const showSizeColumn = terminalWidth >= 80; + const showTtlColumn = terminalWidth >= 110; + + // Helper to format TTL remaining time + const formatTtl = (deleteAfterMs?: number | null): string => { + if (!deleteAfterMs) return ""; + const now = Date.now(); + const remainingMs = deleteAfterMs - now; + if (remainingMs <= 0) return "Expired"; + const remainingMinutes = Math.floor(remainingMs / 60000); + if (remainingMinutes < 60) { + return `${remainingMinutes}m left`; + } + const hours = Math.floor(remainingMinutes / 60); + const mins = remainingMinutes % 60; + if (hours < 24) { + return `${hours}h ${mins}m left`; + } + const days = Math.floor(hours / 24); + const remainingHours = hours % 24; + return `${days}d ${remainingHours}h left`; + }; // Fetch function for pagination hook const fetchPage = React.useCallback( @@ -255,6 +278,18 @@ const ListObjectsUI = ({ bold: false, }, ), + createTextColumn( + "ttl", + "Expires", + (obj: ObjectListItem) => formatTtl(obj.delete_after_time_ms), + { + width: ttlWidth, + color: colors.warning, + dimColor: false, + bold: false, + visible: showTtlColumn, + }, + ), ], [ idWidth, @@ -263,8 +298,11 @@ const ListObjectsUI = ({ typeWidth, sizeWidth, timeWidth, + ttlWidth, showTypeColumn, showSizeColumn, + showTtlColumn, + formatTtl, ], ); diff --git a/src/screens/ObjectDetailScreen.tsx b/src/screens/ObjectDetailScreen.tsx index 56fe38cd..758e0966 100644 --- a/src/screens/ObjectDetailScreen.tsx +++ b/src/screens/ObjectDetailScreen.tsx @@ -224,6 +224,35 @@ export function ObjectDetailScreen({ objectId }: ObjectDetailScreenProps) { }); } + // TTL / Expires - show remaining time before auto-deletion + if (storageObject.delete_after_time_ms) { + const now = Date.now(); + const remainingMs = storageObject.delete_after_time_ms - now; + + let ttlValue: string; + let ttlColor = colors.text; + + if (remainingMs <= 0) { + ttlValue = "Expired"; + ttlColor = colors.error; + } else { + const remainingMinutes = Math.floor(remainingMs / 60000); + if (remainingMinutes < 60) { + ttlValue = `${remainingMinutes}m remaining`; + ttlColor = remainingMinutes < 10 ? colors.warning : colors.text; + } else { + const hours = Math.floor(remainingMinutes / 60); + const mins = remainingMinutes % 60; + ttlValue = `${hours}h ${mins}m remaining`; + } + } + + basicFields.push({ + label: "Expires", + value: {ttlValue}, + }); + } + if (basicFields.length > 0) { detailSections.push({ title: "Details", @@ -377,6 +406,31 @@ export function ObjectDetailScreen({ objectId }: ObjectDetailScreenProps) { , ); } + if (obj.delete_after_time_ms) { + const now = Date.now(); + const remainingMs = obj.delete_after_time_ms - now; + let expiresText: string; + + if (remainingMs <= 0) { + expiresText = "Expired"; + } else { + const remainingMinutes = Math.floor(remainingMs / 60000); + if (remainingMinutes < 60) { + expiresText = `${remainingMinutes}m remaining`; + } else { + const hours = Math.floor(remainingMinutes / 60); + const mins = remainingMinutes % 60; + expiresText = `${hours}h ${mins}m remaining`; + } + } + + lines.push( + + {" "} + Expires: {expiresText} + , + ); + } lines.push( ); // Download URL diff --git a/src/screens/SnapshotDetailScreen.tsx b/src/screens/SnapshotDetailScreen.tsx index 65a2f767..d9fe9d0d 100644 --- a/src/screens/SnapshotDetailScreen.tsx +++ b/src/screens/SnapshotDetailScreen.tsx @@ -339,7 +339,7 @@ export function SnapshotDetailScreen({ resourceType="Snapshots" getDisplayName={(snap) => snap.name || snap.id} getId={(snap) => snap.id} - getStatus={(snap) => snap.status} + getStatus={(snap) => snap.status || "unknown"} detailSections={detailSections} operations={operations} onOperation={handleOperation} diff --git a/src/services/blueprintService.ts b/src/services/blueprintService.ts index d06351cc..7e1b8f19 100644 --- a/src/services/blueprintService.ts +++ b/src/services/blueprintService.ts @@ -62,11 +62,11 @@ export async function listBlueprints( blueprints.push({ id: String(b.id || "").substring(0, MAX_ID_LENGTH), name: String(b.name || "").substring(0, MAX_NAME_LENGTH), - status: String(b.status || "").substring(0, MAX_STATUS_LENGTH), + status: b.status, + state: b.state, create_time_ms: b.create_time_ms, - build_status: b.status - ? String(b.status).substring(0, MAX_STATUS_LENGTH) - : undefined, + parameters: b.parameters, + // UI-specific convenience fields architecture: architecture ? String(architecture).substring(0, MAX_ARCH_LENGTH) : undefined, @@ -93,43 +93,15 @@ export async function getBlueprint(id: string): Promise { const client = getClient(); const blueprint = await client.blueprints.retrieve(id); - // Extract architecture and resources from launch_parameters + // Extract architecture and resources from launch_parameters for convenience const launchParams = blueprint.parameters?.launch_parameters; return { - id: blueprint.id, - name: blueprint.name, - status: blueprint.status, - create_time_ms: blueprint.create_time_ms, - build_status: (blueprint as any).build_status, + // Spread all API fields + ...blueprint, + // UI-specific convenience fields architecture: launchParams?.architecture ?? undefined, resources: launchParams?.resource_size_request ?? undefined, - parameters: blueprint.parameters - ? { - launch_parameters: launchParams - ? { - architecture: launchParams.architecture ?? undefined, - resource_size_request: - launchParams.resource_size_request ?? undefined, - custom_cpu_cores: launchParams.custom_cpu_cores ?? undefined, - custom_gb_memory: launchParams.custom_gb_memory ?? undefined, - custom_disk_size: launchParams.custom_disk_size ?? undefined, - keep_alive_time_seconds: - launchParams.keep_alive_time_seconds ?? undefined, - available_ports: launchParams.available_ports ?? undefined, - launch_commands: launchParams.launch_commands ?? undefined, - required_services: launchParams.required_services ?? undefined, - } - : undefined, - dockerfile: blueprint.parameters.dockerfile ?? undefined, - system_setup_commands: - blueprint.parameters.system_setup_commands ?? undefined, - file_mounts: - (blueprint.parameters.file_mounts as - | Record - | undefined) ?? undefined, - } - : undefined, }; } diff --git a/src/services/objectService.ts b/src/services/objectService.ts index 74e06cc8..120a7e52 100644 --- a/src/services/objectService.ts +++ b/src/services/objectService.ts @@ -62,18 +62,14 @@ export async function listObjects( objects.push({ id: String(obj.id || "").substring(0, MAX_ID_LENGTH), - name: obj.name - ? String(obj.name).substring(0, MAX_NAME_LENGTH) - : undefined, - content_type: obj.content_type - ? String(obj.content_type).substring(0, MAX_CONTENT_TYPE_LENGTH) - : undefined, + name: String(obj.name || "").substring(0, MAX_NAME_LENGTH), + content_type: obj.content_type || "unspecified", + create_time_ms: obj.create_time_ms || 0, + state: obj.state || "UPLOADING", size_bytes: obj.size_bytes, - state: obj.state - ? String(obj.state).substring(0, MAX_STATE_LENGTH) - : undefined, + delete_after_time_ms: obj.delete_after_time_ms, + // UI-specific extended fields is_public: obj.is_public, - create_time_ms: obj.create_time_ms, }); }); } @@ -102,12 +98,14 @@ export async function getObject(id: string): Promise { return { id: obj.id, - name: obj.name || undefined, - content_type: obj.content_type || undefined, + name: obj.name || "", + content_type: obj.content_type || "unspecified", + create_time_ms: obj.create_time_ms || 0, + state: obj.state || "UPLOADING", size_bytes: obj.size_bytes, - state: obj.state || undefined, + delete_after_time_ms: obj.delete_after_time_ms, + // UI-specific extended fields is_public: obj.is_public, - create_time_ms: obj.create_time_ms, download_url: obj.download_url || undefined, metadata: obj.metadata as Record | undefined, }; diff --git a/src/services/snapshotService.ts b/src/services/snapshotService.ts index aabc0ede..45af5a9d 100644 --- a/src/services/snapshotService.ts +++ b/src/services/snapshotService.ts @@ -62,6 +62,13 @@ export async function listSnapshots( name: snapshotView.name ? String(snapshotView.name).substring(0, MAX_NAME_LENGTH) : undefined, + create_time_ms: snapshotView.create_time_ms, + metadata: snapshotView.metadata || {}, + source_devbox_id: String(snapshotView.source_devbox_id || "").substring( + 0, + MAX_ID_LENGTH, + ), + // UI-specific extended fields devbox_id: String(snapshotView.source_devbox_id || "").substring( 0, MAX_ID_LENGTH, @@ -69,7 +76,6 @@ export async function listSnapshots( status: snapshotView.status ? String(snapshotView.status).substring(0, MAX_STATUS_LENGTH) : "", - create_time_ms: snapshotView.create_time_ms, }); }); } @@ -107,6 +113,9 @@ export async function getSnapshot(id: string): Promise { // If no snapshot data yet, return minimal info based on operation status return { id: id, + create_time_ms: Date.now(), + metadata: {}, + source_devbox_id: "", status: operationStatus === "in_progress" ? "pending" : operationStatus, }; } @@ -114,10 +123,12 @@ export async function getSnapshot(id: string): Promise { return { id: snapshot.id, name: snapshot.name || undefined, + create_time_ms: snapshot.create_time_ms, + metadata: snapshot.metadata || {}, + source_devbox_id: snapshot.source_devbox_id || "", + // UI-specific extended fields devbox_id: snapshot.source_devbox_id || undefined, status: operationStatus === "complete" ? "ready" : operationStatus, - create_time_ms: snapshot.create_time_ms, - metadata: snapshot.metadata as Record | undefined, }; } @@ -136,9 +147,12 @@ export async function createSnapshot( return { id: snapshot.id, name: snapshot.name || undefined, - devbox_id: (snapshot as any).devbox_id || devboxId, - status: (snapshot as any).status || "pending", - create_time_ms: (snapshot as any).create_time_ms, + create_time_ms: snapshot.create_time_ms, + metadata: snapshot.metadata || {}, + source_devbox_id: snapshot.source_devbox_id || devboxId, + // UI-specific extended fields + devbox_id: snapshot.source_devbox_id || devboxId, + status: "pending", }; } diff --git a/src/store/blueprintStore.ts b/src/store/blueprintStore.ts index bdb4d4b1..4319299b 100644 --- a/src/store/blueprintStore.ts +++ b/src/store/blueprintStore.ts @@ -2,37 +2,22 @@ * Blueprint Store - Manages blueprint list state, pagination, and caching */ import { create } from "zustand"; - -export interface BlueprintParameters { - launch_parameters?: { - architecture?: string; - resource_size_request?: string; - custom_cpu_cores?: number; - custom_gb_memory?: number; - custom_disk_size?: number; - keep_alive_time_seconds?: number; - available_ports?: number[]; - launch_commands?: string[]; - required_services?: string[]; - }; - dockerfile?: string; - system_setup_commands?: string[]; - file_mounts?: Record; -} - -export interface Blueprint { - id: string; - name?: string; - status: string; - create_time_ms?: number; - build_status?: string; +import type { + BlueprintView, + BlueprintBuildParameters, +} from "@runloop/api-client/resources/blueprints"; + +// Extended type with UI-specific convenience fields +export interface Blueprint extends BlueprintView { + // Convenience field for architecture (extracted from parameters.launch_parameters) architecture?: string; + // Convenience field for resource size (extracted from parameters.launch_parameters) resources?: string; - // Extended fields for detail view - parameters?: BlueprintParameters; - description?: string; } +// Re-export for compatibility with existing code +export type BlueprintParameters = BlueprintBuildParameters; + interface BlueprintState { // List data blueprints: Blueprint[]; @@ -124,16 +109,10 @@ export const useBlueprintStore = create((set, get) => ({ } } - // Create plain data objects to avoid SDK references - const plainData = data.map((b) => ({ - id: b.id, - name: b.name, - status: b.status, - create_time_ms: b.create_time_ms, - build_status: b.build_status, - architecture: b.architecture, - resources: b.resources, - })); + // Deep copy all fields to avoid SDK references + const plainData = data.map((d) => { + return JSON.parse(JSON.stringify(d)) as Blueprint; + }); pageCache.set(page, plainData); lastIdCache.set(page, lastId); diff --git a/src/store/networkPolicyStore.ts b/src/store/networkPolicyStore.ts index 1f509d28..513244d9 100644 --- a/src/store/networkPolicyStore.ts +++ b/src/store/networkPolicyStore.ts @@ -2,21 +2,11 @@ * Network Policy Store - Manages network policy list state, pagination, and caching */ import { create } from "zustand"; +import type { NetworkPolicyView } from "@runloop/api-client/resources/network-policies"; -export interface NetworkPolicyEgress { - allow_all: boolean; - allow_devbox_to_devbox: boolean; - allowed_hostnames: string[]; -} - -export interface NetworkPolicy { - id: string; - name: string; - description?: string; - create_time_ms: number; - update_time_ms: number; - egress: NetworkPolicyEgress; -} +// Re-export for compatibility with existing code +export type NetworkPolicy = NetworkPolicyView; +export type NetworkPolicyEgress = NetworkPolicyView.Egress; interface NetworkPolicyState { // List data @@ -109,19 +99,10 @@ export const useNetworkPolicyStore = create((set, get) => ({ } } - // Create plain data objects to avoid SDK references - const plainData = data.map((p) => ({ - id: p.id, - name: p.name, - description: p.description, - create_time_ms: p.create_time_ms, - update_time_ms: p.update_time_ms, - egress: { - allow_all: p.egress.allow_all, - allow_devbox_to_devbox: p.egress.allow_devbox_to_devbox, - allowed_hostnames: [...p.egress.allowed_hostnames], - }, - })); + // Deep copy all fields to avoid SDK references + const plainData = data.map((d) => { + return JSON.parse(JSON.stringify(d)) as NetworkPolicy; + }); pageCache.set(page, plainData); lastIdCache.set(page, lastId); diff --git a/src/store/objectStore.ts b/src/store/objectStore.ts index ee9254f1..1b772fd0 100644 --- a/src/store/objectStore.ts +++ b/src/store/objectStore.ts @@ -2,18 +2,17 @@ * Object Store - Manages storage object list state, pagination, and caching */ import { create } from "zustand"; +import type { ObjectView } from "@runloop/api-client/resources/objects"; -export interface StorageObject { - id: string; - name?: string; - content_type?: string; - size_bytes?: number; - state?: string; - is_public?: boolean; - create_time_ms?: number; - // Extended fields for detail view +// Extended type with UI-specific fields +// The service transforms API responses to add computed/fetched fields +export interface StorageObject extends ObjectView { + // Presigned download URL (fetched separately via download endpoint) download_url?: string; + // User-defined metadata metadata?: Record; + // Public visibility flag + is_public?: boolean; } interface ObjectState { @@ -120,16 +119,10 @@ export const useObjectStore = create((set, get) => ({ } } - // Create plain data objects to avoid SDK references - const plainData = data.map((obj) => ({ - id: obj.id, - name: obj.name, - content_type: obj.content_type, - size_bytes: obj.size_bytes, - state: obj.state, - is_public: obj.is_public, - create_time_ms: obj.create_time_ms, - })); + // Deep copy all fields to avoid SDK references + const plainData = data.map((d) => { + return JSON.parse(JSON.stringify(d)) as StorageObject; + }); pageCache.set(page, plainData); lastIdCache.set(page, lastId); diff --git a/src/store/snapshotStore.ts b/src/store/snapshotStore.ts index f72b8b1c..bd5fc88d 100644 --- a/src/store/snapshotStore.ts +++ b/src/store/snapshotStore.ts @@ -2,15 +2,16 @@ * Snapshot Store - Manages snapshot list state, pagination, and caching */ import { create } from "zustand"; +import type { DevboxSnapshotView } from "@runloop/api-client/resources/devboxes/devboxes"; -export interface Snapshot { - id: string; - name?: string; +// Extended type with UI-specific fields +// The service transforms API responses to add these computed fields +export interface Snapshot extends DevboxSnapshotView { + // Alias for source_devbox_id for backward compatibility devbox_id?: string; - status: string; - create_time_ms?: number; - // Extended fields for detail view - metadata?: Record; + // Computed status from async status query (not a real API field) + status?: string; + // Optional disk size when available disk_size_bytes?: number; } @@ -105,14 +106,10 @@ export const useSnapshotStore = create((set, get) => ({ } } - // Create plain data objects to avoid SDK references - const plainData = data.map((s) => ({ - id: s.id, - name: s.name, - devbox_id: s.devbox_id, - status: s.status, - create_time_ms: s.create_time_ms, - })); + // Deep copy all fields to avoid SDK references + const plainData = data.map((d) => { + return JSON.parse(JSON.stringify(d)) as Snapshot; + }); pageCache.set(page, plainData); lastIdCache.set(page, lastId); diff --git a/src/utils/commands.ts b/src/utils/commands.ts index d0df281f..c627c1ec 100644 --- a/src/utils/commands.ts +++ b/src/utils/commands.ts @@ -54,6 +54,7 @@ export function createProgram(): Command { .option("--available-ports ", "Available ports") .option("--root", "Run as root") .option("--user ", "Run as this user (format: username:uid)") + .option("--network-policy ", "Network policy ID to apply") .option( "-o, --output [format]", "Output format: text|json|yaml (default: text)", @@ -571,6 +572,80 @@ export function createProgram(): Command { await deleteObject({ id, ...options }); }); + // Network policy commands + const networkPolicy = program + .command("network-policy") + .description("Manage network policies") + .alias("np"); + + networkPolicy + .command("list") + .description("List network policies") + .option("--limit ", "Max results", "20") + .option("--starting-after ", "Starting point for pagination") + .option("--name ", "Filter by name") + .option( + "-o, --output [format]", + "Output format: text|json|yaml (default: json)", + ) + .action(async (options) => { + const { listNetworkPolicies } = await import( + "../commands/network-policy/list.js" + ); + await listNetworkPolicies(options); + }); + + networkPolicy + .command("get ") + .description("Get network policy details") + .option( + "-o, --output [format]", + "Output format: text|json|yaml (default: json)", + ) + .action(async (id, options) => { + const { getNetworkPolicy } = await import( + "../commands/network-policy/get.js" + ); + await getNetworkPolicy({ id, ...options }); + }); + + networkPolicy + .command("create") + .description("Create a new network policy") + .requiredOption("--name ", "Policy name (required)") + .option("--description ", "Policy description") + .option("--allow-all", "Allow all egress traffic") + .option("--allow-devbox-to-devbox", "Allow devbox-to-devbox communication") + .option( + "--allowed-hostnames ", + "List of allowed hostnames for egress", + ) + .option( + "-o, --output [format]", + "Output format: text|json|yaml (default: text)", + ) + .action(async (options) => { + const { createNetworkPolicy } = await import( + "../commands/network-policy/create.js" + ); + await createNetworkPolicy(options); + }); + + networkPolicy + .command("delete ") + .description("Delete a network policy") + .alias("rm") + .option( + "-o, --output [format]", + "Output format: text|json|yaml (default: text)", + ) + .action(async (id, options) => { + const { deleteNetworkPolicy } = await import( + "../commands/network-policy/delete.js" + ); + await deleteNetworkPolicy(id, options); + }); + // MCP server commands const mcp = program .command("mcp") From 38f7032689a4b016d56519c8c043e72e9a1b9ee9 Mon Sep 17 00:00:00 2001 From: Alexander Dines Date: Thu, 22 Jan 2026 15:29:05 -0800 Subject: [PATCH 12/24] cp dines --- src/commands/network-policy/create.ts | 8 +++----- src/components/DevboxDetailPage.tsx | 4 +--- src/screens/ObjectDetailScreen.tsx | 5 ++++- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/src/commands/network-policy/create.ts b/src/commands/network-policy/create.ts index 99bd2611..461a9524 100644 --- a/src/commands/network-policy/create.ts +++ b/src/commands/network-policy/create.ts @@ -21,11 +21,9 @@ export async function createNetworkPolicy(options: CreateOptions) { const policy = await client.networkPolicies.create({ name: options.name, description: options.description, - egress: { - allow_all: options.allowAll ?? false, - allow_devbox_to_devbox: options.allowDevboxToDevbox ?? false, - allowed_hostnames: options.allowedHostnames ?? [], - }, + allow_all: options.allowAll ?? false, + allow_devbox_to_devbox: options.allowDevboxToDevbox ?? false, + allowed_hostnames: options.allowedHostnames ?? [], }); // Default: just output the ID for easy scripting diff --git a/src/components/DevboxDetailPage.tsx b/src/components/DevboxDetailPage.tsx index aee4aba4..45b3c472 100644 --- a/src/components/DevboxDetailPage.tsx +++ b/src/components/DevboxDetailPage.tsx @@ -261,9 +261,7 @@ export const DevboxDetailPage = ({ if (lp?.network_policy_id) { detailFields.push({ label: "Network Policy", - value: ( - {lp.network_policy_id} - ), + value: {lp.network_policy_id}, }); } diff --git a/src/screens/ObjectDetailScreen.tsx b/src/screens/ObjectDetailScreen.tsx index 758e0966..b1c786e2 100644 --- a/src/screens/ObjectDetailScreen.tsx +++ b/src/screens/ObjectDetailScreen.tsx @@ -425,7 +425,10 @@ export function ObjectDetailScreen({ objectId }: ObjectDetailScreenProps) { } lines.push( - + {" "} Expires: {expiresText} , From c0f15b9bef0ceebc2e9f258a028c05f1e1ab340d Mon Sep 17 00:00:00 2001 From: Alexander Dines Date: Thu, 22 Jan 2026 15:30:35 -0800 Subject: [PATCH 13/24] cp dines --- src/services/objectService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/objectService.ts b/src/services/objectService.ts index 120a7e52..5ecd1144 100644 --- a/src/services/objectService.ts +++ b/src/services/objectService.ts @@ -122,7 +122,7 @@ export async function deleteObject(id: string): Promise { /** * Format file size in human-readable format */ -export function formatFileSize(bytes: number | undefined): string { +export function formatFileSize(bytes: number | null | undefined): string { if (bytes === undefined || bytes === null) return "Unknown"; const units = ["B", "KB", "MB", "GB", "TB"]; From 235974f1963fe50d552dd7a5635da1f414ef059f Mon Sep 17 00:00:00 2001 From: Alexander Dines Date: Thu, 22 Jan 2026 15:39:51 -0800 Subject: [PATCH 14/24] cp dines --- README.md | 9 ++++ src/commands/blueprint/list.tsx | 9 +++- src/commands/blueprint/prune.ts | 4 +- src/commands/devbox/list.tsx | 50 ++++++------------- src/commands/network-policy/list.tsx | 10 +++- src/commands/object/list.tsx | 11 +++- src/commands/snapshot/list.tsx | 8 ++- src/components/DevboxCard.tsx | 2 +- src/components/StatusBadge.tsx | 4 +- src/components/Table.tsx | 15 ++++-- .../__tests__/components/DevboxCard.test.tsx | 2 +- .../__tests__/components/StatusBadge.test.tsx | 2 +- 12 files changed, 75 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index e985a77b..5d213646 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,15 @@ rli object upload # Upload a file as an object rli object delete # Delete an object (irreversible) ``` +### Network-policy Commands (alias: `np`) + +```bash +rli network-policy list # List network policies +rli network-policy get # Get network policy details +rli network-policy create # Create a new network policy +rli network-policy delete # Delete a network policy +``` + ### Mcp Commands ```bash diff --git a/src/commands/blueprint/list.tsx b/src/commands/blueprint/list.tsx index 99accb82..0b44c532 100644 --- a/src/commands/blueprint/list.tsx +++ b/src/commands/blueprint/list.tsx @@ -82,14 +82,21 @@ const ListBlueprintsUI = ({ const PAGE_SIZE = viewportHeight; // All width constants + const fixedWidth = 6; // border + padding const statusIconWidth = 2; const statusTextWidth = 10; const idWidth = 25; - const nameWidth = Math.max(15, terminalWidth >= 120 ? 30 : 25); const descriptionWidth = 40; const timeWidth = 20; const showDescription = terminalWidth >= 120; + // Name width uses remaining space after fixed columns + const baseWidth = + fixedWidth + statusIconWidth + statusTextWidth + idWidth + timeWidth; + const optionalWidth = showDescription ? descriptionWidth : 0; + const remainingWidth = terminalWidth - baseWidth - optionalWidth; + const nameWidth = Math.min(80, Math.max(15, remainingWidth)); + // Fetch function for pagination hook const fetchPage = React.useCallback( async (params: { limit: number; startingAt?: string }) => { diff --git a/src/commands/blueprint/prune.ts b/src/commands/blueprint/prune.ts index ee838e57..46e90909 100644 --- a/src/commands/blueprint/prune.ts +++ b/src/commands/blueprint/prune.ts @@ -154,7 +154,7 @@ function displaySummary( } else { // Show all blueprints without summarizing for (const blueprint of result.toDelete) { - const icon = blueprint.status === "build_complete" ? "✓" : "✗"; + const icon = blueprint.status === "build_complete" ? "✓" : "⚠"; const statusLabel = blueprint.status === "build_complete" ? "successful" : "failed"; console.log( @@ -174,7 +174,7 @@ function displayDeletedBlueprints(deleted: Blueprint[]) { console.log("\nDeleted blueprints:"); for (const blueprint of deleted) { - const icon = blueprint.status === "build_complete" ? "✓" : "✗"; + const icon = blueprint.status === "build_complete" ? "✓" : "⚠"; const statusLabel = blueprint.status === "build_complete" ? "successful" : "failed"; console.log( diff --git a/src/commands/devbox/list.tsx b/src/commands/devbox/list.tsx index 2f75201d..5f14ac8c 100644 --- a/src/commands/devbox/list.tsx +++ b/src/commands/devbox/list.tsx @@ -162,41 +162,21 @@ const ListDevboxesUI = ({ const ABSOLUTE_MAX_NAME_WIDTH = 80; // Name width is flexible and uses remaining space - let nameWidth = 15; - if (terminalWidth >= 120) { - const remainingWidth = - terminalWidth - - fixedWidth - - statusIconWidth - - idWidth - - statusTextWidth - - timeWidth - - capabilitiesWidth - - sourceWidth - - 12; - nameWidth = Math.min(ABSOLUTE_MAX_NAME_WIDTH, Math.max(15, remainingWidth)); - } else if (terminalWidth >= 110) { - const remainingWidth = - terminalWidth - - fixedWidth - - statusIconWidth - - idWidth - - statusTextWidth - - timeWidth - - sourceWidth - - 10; - nameWidth = Math.min(ABSOLUTE_MAX_NAME_WIDTH, Math.max(12, remainingWidth)); - } else { - const remainingWidth = - terminalWidth - - fixedWidth - - statusIconWidth - - idWidth - - statusTextWidth - - timeWidth - - 10; - nameWidth = Math.min(ABSOLUTE_MAX_NAME_WIDTH, Math.max(8, remainingWidth)); - } + // Only subtract widths of columns that are actually shown + const baseWidth = + fixedWidth + + statusIconWidth + + idWidth + + statusTextWidth + + timeWidth + + 6; // border + padding + const optionalWidth = + (showSource ? sourceWidth : 0) + (showCapabilities ? capabilitiesWidth : 0); + const remainingWidth = terminalWidth - baseWidth - optionalWidth; + const nameWidth = Math.min( + ABSOLUTE_MAX_NAME_WIDTH, + Math.max(15, remainingWidth), + ); // Build responsive column list (memoized to prevent recreating on every render) const tableColumns = React.useMemo(() => { diff --git a/src/commands/network-policy/list.tsx b/src/commands/network-policy/list.tsx index b7f7398b..fa82b81b 100644 --- a/src/commands/network-policy/list.tsx +++ b/src/commands/network-policy/list.tsx @@ -105,12 +105,18 @@ const ListNetworkPoliciesUI = ({ const PAGE_SIZE = viewportHeight; // All width constants + const fixedWidth = 6; // border + padding const idWidth = 25; - const nameWidth = Math.max(15, terminalWidth >= 120 ? 30 : 20); - const descriptionWidth = Math.max(20, terminalWidth >= 140 ? 40 : 25); const egressWidth = 15; const timeWidth = 15; const showDescription = terminalWidth >= 100; + const descriptionWidth = Math.max(20, terminalWidth >= 140 ? 40 : 25); + + // Name width uses remaining space after fixed columns + const baseWidth = fixedWidth + idWidth + egressWidth + timeWidth; + const optionalWidth = showDescription ? descriptionWidth : 0; + const remainingWidth = terminalWidth - baseWidth - optionalWidth; + const nameWidth = Math.min(80, Math.max(15, remainingWidth)); // Fetch function for pagination hook const fetchPage = React.useCallback( diff --git a/src/commands/object/list.tsx b/src/commands/object/list.tsx index 9aad455c..ca9b2aff 100644 --- a/src/commands/object/list.tsx +++ b/src/commands/object/list.tsx @@ -83,8 +83,8 @@ const ListObjectsUI = ({ const PAGE_SIZE = viewportHeight; // All width constants + const fixedWidth = 6; // border + padding const idWidth = 25; - const nameWidth = Math.max(15, terminalWidth >= 120 ? 30 : 20); const stateWidth = 12; const typeWidth = 15; const sizeWidth = 12; @@ -94,6 +94,15 @@ const ListObjectsUI = ({ const showSizeColumn = terminalWidth >= 80; const showTtlColumn = terminalWidth >= 110; + // Name width uses remaining space after fixed columns + const baseWidth = fixedWidth + idWidth + stateWidth + timeWidth; + const optionalWidth = + (showTypeColumn ? typeWidth : 0) + + (showSizeColumn ? sizeWidth : 0) + + (showTtlColumn ? ttlWidth : 0); + const remainingWidth = terminalWidth - baseWidth - optionalWidth; + const nameWidth = Math.min(80, Math.max(15, remainingWidth)); + // Helper to format TTL remaining time const formatTtl = (deleteAfterMs?: number | null): string => { if (!deleteAfterMs) return ""; diff --git a/src/commands/snapshot/list.tsx b/src/commands/snapshot/list.tsx index 04a4948b..ca671d3d 100644 --- a/src/commands/snapshot/list.tsx +++ b/src/commands/snapshot/list.tsx @@ -79,12 +79,18 @@ const ListSnapshotsUI = ({ const PAGE_SIZE = viewportHeight; // All width constants + const fixedWidth = 6; // border + padding const idWidth = 25; - const nameWidth = Math.max(15, terminalWidth >= 120 ? 30 : 25); const devboxWidth = 15; const timeWidth = 20; const showDevboxIdColumn = terminalWidth >= 100 && !devboxId; + // Name width uses remaining space after fixed columns + const baseWidth = fixedWidth + idWidth + timeWidth; + const optionalWidth = showDevboxIdColumn ? devboxWidth : 0; + const remainingWidth = terminalWidth - baseWidth - optionalWidth; + const nameWidth = Math.min(80, Math.max(15, remainingWidth)); + // Fetch function for pagination hook const fetchPage = React.useCallback( async (params: { limit: number; startingAt?: string }) => { diff --git a/src/components/DevboxCard.tsx b/src/components/DevboxCard.tsx index 33968e7c..74430562 100644 --- a/src/components/DevboxCard.tsx +++ b/src/components/DevboxCard.tsx @@ -28,7 +28,7 @@ export const DevboxCard = ({ case "suspended": return { icon: figures.circle, color: colors.textDim }; case "failed": - return { icon: figures.cross, color: colors.error }; + return { icon: figures.warning, color: colors.error }; default: return { icon: figures.circle, color: colors.textDim }; } diff --git a/src/components/StatusBadge.tsx b/src/components/StatusBadge.tsx index c1fecd6f..f40fa556 100644 --- a/src/components/StatusBadge.tsx +++ b/src/components/StatusBadge.tsx @@ -90,7 +90,7 @@ export const getStatusDisplay = (status: string): StatusDisplay => { // === ERROR STATES === case "failure": return { - icon: figures.cross, + icon: figures.warning, color: colors.error, text: "FAILED ", label: "Failed", @@ -98,7 +98,7 @@ export const getStatusDisplay = (status: string): StatusDisplay => { case "build_failed": case "failed": return { - icon: figures.cross, + icon: figures.warning, color: colors.error, text: "FAILED ", label: "Failed", diff --git a/src/components/Table.tsx b/src/components/Table.tsx index dd87dd54..dc7a2467 100644 --- a/src/components/Table.tsx +++ b/src/components/Table.tsx @@ -57,7 +57,7 @@ export function Table({ const visibleColumns = columns.filter((col) => col.visible !== false); return ( - + {/* Title bar (if provided) */} {title && ( @@ -70,12 +70,13 @@ export function Table({ {/* Header row */} - + {/* Space for selection pointer */} {showSelection && ( <> @@ -94,11 +95,13 @@ export function Table({ ); })} + {/* Spacer to fill remaining width */} + {/* Empty state row */} {isEmpty && ( - + {showSelection && ( <> @@ -110,6 +113,8 @@ export function Table({ {figures.info} No items found )} + {/* Spacer to fill remaining width */} + )} @@ -119,7 +124,7 @@ export function Table({ const rowKey = keyExtractor(row); return ( - + {/* Selection pointer */} {showSelection && ( <> @@ -136,6 +141,8 @@ export function Table({ {column.render(row, index, isSelected)} ))} + {/* Spacer to fill remaining width */} + ); })} diff --git a/tests/__tests__/components/DevboxCard.test.tsx b/tests/__tests__/components/DevboxCard.test.tsx index 5ad88d75..03534ce5 100644 --- a/tests/__tests__/components/DevboxCard.test.tsx +++ b/tests/__tests__/components/DevboxCard.test.tsx @@ -64,7 +64,7 @@ describe('DevboxCard', () => { status="failed" /> ); - expect(lastFrame()).toContain('✗'); // cross for failed + expect(lastFrame()).toContain('⚠'); // warning for failed }); it('displays created date when provided', () => { diff --git a/tests/__tests__/components/StatusBadge.test.tsx b/tests/__tests__/components/StatusBadge.test.tsx index 0ad6270d..21b2b126 100644 --- a/tests/__tests__/components/StatusBadge.test.tsx +++ b/tests/__tests__/components/StatusBadge.test.tsx @@ -105,7 +105,7 @@ describe('getStatusDisplay', () => { it('returns correct display for failure', () => { const display = getStatusDisplay('failure'); expect(display.text.trim()).toBe('FAILED'); - expect(display.icon).toBe('✗'); + expect(display.icon).toBe('⚠'); }); it('returns correct display for resuming', () => { From 02dc9fc2814df17deb53bfc4c3bb413bc99f684f Mon Sep 17 00:00:00 2001 From: Alexander Dines Date: Thu, 22 Jan 2026 15:39:59 -0800 Subject: [PATCH 15/24] cp dines --- src/commands/devbox/list.tsx | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/commands/devbox/list.tsx b/src/commands/devbox/list.tsx index 5f14ac8c..8bd462ce 100644 --- a/src/commands/devbox/list.tsx +++ b/src/commands/devbox/list.tsx @@ -164,12 +164,7 @@ const ListDevboxesUI = ({ // Name width is flexible and uses remaining space // Only subtract widths of columns that are actually shown const baseWidth = - fixedWidth + - statusIconWidth + - idWidth + - statusTextWidth + - timeWidth + - 6; // border + padding + fixedWidth + statusIconWidth + idWidth + statusTextWidth + timeWidth + 6; // border + padding const optionalWidth = (showSource ? sourceWidth : 0) + (showCapabilities ? capabilitiesWidth : 0); const remainingWidth = terminalWidth - baseWidth - optionalWidth; From 92fe8fca254effbab8f3661172cf6a1cf45b8b3e Mon Sep 17 00:00:00 2001 From: Alexander Dines Date: Thu, 22 Jan 2026 16:03:53 -0800 Subject: [PATCH 16/24] cp dines --- src/commands/devbox/list.tsx | 57 ++++++++++++------------ src/commands/object/list.tsx | 5 ++- src/components/Banner.tsx | 63 +++++++++++++++++++++----- src/hooks/useViewportHeight.ts | 71 +++++++++++++++++++++--------- src/screens/ObjectDetailScreen.tsx | 10 ++--- src/services/objectService.ts | 8 ++-- src/store/index.ts | 2 +- src/store/objectStore.ts | 20 ++++----- 8 files changed, 153 insertions(+), 83 deletions(-) diff --git a/src/commands/devbox/list.tsx b/src/commands/devbox/list.tsx index 8bd462ce..22d50d28 100644 --- a/src/commands/devbox/list.tsx +++ b/src/commands/devbox/list.tsx @@ -154,9 +154,11 @@ const ListDevboxesUI = ({ // ID is always full width (25 chars for dbx_31CYd5LLFbBxst8mqnUjO format) const idWidth = 26; - // Responsive layout based on terminal width (simplified like blueprint list) - const showCapabilities = terminalWidth >= 140; - const showSource = terminalWidth >= 120; + // Responsive layout - hide less important columns on smaller screens + // Priority (most to least important): ID, Name, Status, Created, Source, Capabilities + const showCapabilities = terminalWidth >= 160; + const showSource = terminalWidth >= 135; + const showCreated = terminalWidth >= 100; // CRITICAL: Absolute maximum column widths to prevent Yoga crashes const ABSOLUTE_MAX_NAME_WIDTH = 80; @@ -164,7 +166,12 @@ const ListDevboxesUI = ({ // Name width is flexible and uses remaining space // Only subtract widths of columns that are actually shown const baseWidth = - fixedWidth + statusIconWidth + idWidth + statusTextWidth + timeWidth + 6; // border + padding + fixedWidth + + statusIconWidth + + idWidth + + statusTextWidth + + (showCreated ? timeWidth : 0) + + 6; // border + padding const optionalWidth = (showSource ? sourceWidth : 0) + (showCapabilities ? capabilitiesWidth : 0); const remainingWidth = terminalWidth - baseWidth - optionalWidth; @@ -199,21 +206,6 @@ const ListDevboxesUI = ({ ); }, }, - createTextColumn( - "name", - "Name", - (devbox: Devbox) => { - 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)) + "..." - : name; - }, - { - width: Math.min(nameWidth || 15, ABSOLUTE_MAX_NAME), - dimColor: false, - }, - ), createTextColumn( "id", "ID", @@ -231,6 +223,21 @@ const ListDevboxesUI = ({ bold: false, }, ), + createTextColumn( + "name", + "Name", + (devbox: Devbox) => { + 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)) + "..." + : name; + }, + { + width: Math.min(nameWidth || 15, ABSOLUTE_MAX_NAME), + dimColor: false, + }, + ), // Status text column with color matching the icon { key: "status", @@ -266,6 +273,7 @@ const ListDevboxesUI = ({ width: timeWidth, color: colors.textDim, dimColor: false, + visible: showCreated, }, ), ]; @@ -275,15 +283,7 @@ const ListDevboxesUI = ({ createTextColumn( "source", "Source", - (devbox: Devbox) => { - if (devbox?.blueprint_id) { - const bpId = String(devbox.blueprint_id); - const truncated = bpId.slice(0, 16); - const text = `${truncated}`; - return text.length > 30 ? text.substring(0, 27) + "..." : text; - } - return "-"; - }, + (devbox: Devbox) => devbox?.blueprint_id || "-", { width: sourceWidth, color: colors.textDim, @@ -319,6 +319,7 @@ const ListDevboxesUI = ({ idWidth, statusTextWidth, timeWidth, + showCreated, showSource, sourceWidth, showCapabilities, diff --git a/src/commands/object/list.tsx b/src/commands/object/list.tsx index ca9b2aff..a3c7af63 100644 --- a/src/commands/object/list.tsx +++ b/src/commands/object/list.tsx @@ -91,8 +91,8 @@ const ListObjectsUI = ({ const timeWidth = 15; const ttlWidth = 14; const showTypeColumn = terminalWidth >= 100; - const showSizeColumn = terminalWidth >= 80; - const showTtlColumn = terminalWidth >= 110; + const showSizeColumn = terminalWidth >= 90; + const showTtlColumn = terminalWidth >= 80; // Name width uses remaining space after fixed columns const baseWidth = fixedWidth + idWidth + stateWidth + timeWidth; @@ -152,6 +152,7 @@ const ListObjectsUI = ({ state: obj.state, is_public: obj.is_public, create_time_ms: obj.create_time_ms, + delete_after_time_ms: obj.delete_after_time_ms, }); }); } diff --git a/src/components/Banner.tsx b/src/components/Banner.tsx index c949d869..7ad3f5dc 100644 --- a/src/components/Banner.tsx +++ b/src/components/Banner.tsx @@ -1,8 +1,9 @@ -import React, { useState, useEffect } from "react"; -import { Box } from "ink"; +import React, { useState, useEffect, useRef } from "react"; +import { Box, Text } from "ink"; import BigText from "ink-big-text"; import Gradient from "ink-gradient"; import { isLightMode } from "../utils/theme.js"; +import { useViewportHeight } from "../hooks/useViewportHeight.js"; // Dramatic shades of green shimmer - wide range const DARK_SHIMMER_COLORS = [ @@ -75,24 +76,62 @@ const LIGHT_SHIMMER_COLORS = [ "#045540", // ]; +// Pre-compute all rotated color frames at module load time +const precomputeFrames = (colors: string[]): string[][] => { + return colors.map((_, i) => [...colors.slice(i), ...colors.slice(0, i)]); +}; + +const DARK_FRAMES = precomputeFrames(DARK_SHIMMER_COLORS); +const LIGHT_FRAMES = precomputeFrames(LIGHT_SHIMMER_COLORS); + +// Minimum width to show the full BigText banner (simple3d font needs ~80 chars for "RUNLOOP.ai") +const MIN_WIDTH_FOR_BIG_BANNER = 90; + +// Animation interval in ms +const SHIMMER_INTERVAL = 150; + export const Banner = React.memo(() => { - const [offset, setOffset] = useState(0); - const colors = isLightMode() ? LIGHT_SHIMMER_COLORS : DARK_SHIMMER_COLORS; + const [frameIndex, setFrameIndex] = useState(0); + const frames = isLightMode() ? LIGHT_FRAMES : DARK_FRAMES; + const { terminalWidth } = useViewportHeight(); + const timeoutRef = useRef | null>(null); + + // Determine if we should show compact mode + const isCompact = terminalWidth < MIN_WIDTH_FOR_BIG_BANNER; useEffect(() => { - const interval = setInterval(() => { - setOffset((prev) => (prev - 1 + colors.length) % colors.length); - }, 250); // Slower, more subtle shimmer + const tick = () => { + setFrameIndex((prev) => (prev + 1) % frames.length); + timeoutRef.current = setTimeout(tick, SHIMMER_INTERVAL); + }; + + timeoutRef.current = setTimeout(tick, SHIMMER_INTERVAL); + + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + }, [frames.length]); - return () => clearInterval(interval); - }, [colors.length]); + // Use pre-computed frame - no array operations during render + const currentColors = frames[frameIndex]; - // Create a subtle shimmer by shifting the color array - const rotatedColors = [...colors.slice(offset), ...colors.slice(0, offset)]; + // Compact banner for narrow terminals + if (isCompact) { + return ( + + + ◆ RUNLOOP.ai + + + ); + } + // Full banner for wide terminals return ( - + diff --git a/src/hooks/useViewportHeight.ts b/src/hooks/useViewportHeight.ts index 9aa7f8fe..328c3f22 100644 --- a/src/hooks/useViewportHeight.ts +++ b/src/hooks/useViewportHeight.ts @@ -19,9 +19,27 @@ interface ViewportDimensions { terminalWidth: number; } +/** + * Get safe terminal dimensions with bounds checking + */ +function getSafeDimensions(stdout: NodeJS.WriteStream | undefined): { + width: number; + height: number; +} { + const sampledWidth = + stdout?.columns && stdout.columns > 0 ? stdout.columns : 120; + const sampledHeight = stdout?.rows && stdout.rows > 0 ? stdout.rows : 30; + + return { + width: Math.max(80, Math.min(300, sampledWidth)), + height: Math.max(20, Math.min(100, sampledHeight)), + }; +} + /** * Custom hook to calculate available viewport height for content rendering. * Ensures consistent layout calculations across all CLI screens and prevents overflow. + * Responds to terminal resize events to update layout dynamically. * * @param options Configuration for viewport calculation * @returns Viewport dimensions including available height for content @@ -38,37 +56,48 @@ export function useViewportHeight( const { overhead = 0, minHeight = 5, maxHeight = 100 } = options; const { stdout } = useStdout(); - // Sample terminal dimensions ONCE and use fixed values - no reactive dependencies - // This prevents re-renders and Yoga WASM crashes from dynamic resizing - // CRITICAL: Initialize with safe fallback values to prevent null/undefined - const dimensions = React.useRef<{ width: number; height: number }>({ - width: 120, - height: 30, - }); + // Use state to track dimensions so we can respond to resize events + const [dimensions, setDimensions] = React.useState(() => + getSafeDimensions(stdout), + ); + + // Listen for terminal resize events + React.useEffect(() => { + if (!stdout) return; + + const handleResize = () => { + const newDimensions = getSafeDimensions(stdout); + setDimensions((prev) => { + // Only update if dimensions actually changed + if ( + prev.width !== newDimensions.width || + prev.height !== newDimensions.height + ) { + return newDimensions; + } + return prev; + }); + }; + + // Listen for resize events + stdout.on("resize", handleResize); - // Only sample on first call when still at default values - if (dimensions.current.width === 120 && dimensions.current.height === 30) { - // Only sample if stdout has valid dimensions - const sampledWidth = - stdout?.columns && stdout.columns > 0 ? stdout.columns : 120; - const sampledHeight = stdout?.rows && stdout.rows > 0 ? stdout.rows : 30; + // Also check dimensions on mount in case they differ from initial + handleResize(); - // Always enforce safe bounds to prevent Yoga crashes - dimensions.current = { - width: Math.max(80, Math.min(200, sampledWidth)), - height: Math.max(20, Math.min(100, sampledHeight)), + return () => { + stdout.off("resize", handleResize); }; - } + }, [stdout]); - const terminalHeight = dimensions.current.height; - const terminalWidth = dimensions.current.width; + const terminalHeight = dimensions.height; + const terminalWidth = dimensions.width; // Calculate viewport height with bounds const viewportHeight = Math.max( minHeight, Math.min(maxHeight, terminalHeight - overhead), ); - // Removed console.logs to prevent rendering interference return { viewportHeight, diff --git a/src/screens/ObjectDetailScreen.tsx b/src/screens/ObjectDetailScreen.tsx index b1c786e2..5dc4c1e0 100644 --- a/src/screens/ObjectDetailScreen.tsx +++ b/src/screens/ObjectDetailScreen.tsx @@ -8,7 +8,7 @@ import TextInput from "ink-text-input"; import figures from "figures"; import { writeFile } from "fs/promises"; import { useNavigation } from "../store/navigationStore.js"; -import { useObjectStore, type StorageObject } from "../store/objectStore.js"; +import { useObjectStore, type StorageObjectView } from "../store/objectStore.js"; import { getClient } from "../utils/client.js"; import { ResourceDetailPage, @@ -40,7 +40,7 @@ export function ObjectDetailScreen({ objectId }: ObjectDetailScreenProps) { const [loading, setLoading] = React.useState(false); const [error, setError] = React.useState(null); const [fetchedObject, setFetchedObject] = - React.useState(null); + React.useState(null); const [deleting, setDeleting] = React.useState(false); const [showDownloadPrompt, setShowDownloadPrompt] = React.useState(false); const [downloadPath, setDownloadPath] = React.useState(""); @@ -56,7 +56,7 @@ export function ObjectDetailScreen({ objectId }: ObjectDetailScreenProps) { // Polling function - must be defined before any early returns (Rules of Hooks) const pollObject = React.useCallback(async () => { - if (!objectId) return null as unknown as StorageObject; + if (!objectId) return null as unknown as StorageObjectView; return getObject(objectId); }, [objectId]); @@ -322,7 +322,7 @@ export function ObjectDetailScreen({ objectId }: ObjectDetailScreenProps) { // Handle operation selection const handleOperation = async ( operation: string, - resource: StorageObject, + resource: StorageObjectView, ) => { switch (operation) { case "download": @@ -353,7 +353,7 @@ export function ObjectDetailScreen({ objectId }: ObjectDetailScreenProps) { }; // Build detailed info lines for full details view - const buildDetailLines = (obj: StorageObject): React.ReactElement[] => { + const buildDetailLines = (obj: StorageObjectView): React.ReactElement[] => { const lines: React.ReactElement[] = []; // Core Information diff --git a/src/services/objectService.ts b/src/services/objectService.ts index 5ecd1144..2195cb22 100644 --- a/src/services/objectService.ts +++ b/src/services/objectService.ts @@ -2,7 +2,7 @@ * Object Service - Handles all storage object API calls */ import { getClient } from "../utils/client.js"; -import type { StorageObject } from "../store/objectStore.js"; +import type { StorageObjectView } from "../store/objectStore.js"; export interface ListObjectsOptions { limit: number; @@ -14,7 +14,7 @@ export interface ListObjectsOptions { } export interface ListObjectsResult { - objects: StorageObject[]; + objects: StorageObjectView[]; totalCount: number; hasMore: boolean; } @@ -49,7 +49,7 @@ export async function listObjects( const result = await client.objects.list(queryParams); - const objects: StorageObject[] = []; + const objects: StorageObjectView[] = []; if (result.objects && Array.isArray(result.objects)) { // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -91,7 +91,7 @@ export async function listObjects( /** * Get full object details by ID */ -export async function getObject(id: string): Promise { +export async function getObject(id: string): Promise { const client = getClient(); // eslint-disable-next-line @typescript-eslint/no-explicit-any const obj: any = await client.objects.retrieve(id); diff --git a/src/store/index.ts b/src/store/index.ts index 2ac1ecaa..dd2eb0df 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -23,4 +23,4 @@ export { useSnapshotStore } from "./snapshotStore.js"; export type { Snapshot } from "./snapshotStore.js"; export { useObjectStore } from "./objectStore.js"; -export type { StorageObject } from "./objectStore.js"; +export type { StorageObjectView } from "./objectStore.js"; diff --git a/src/store/objectStore.ts b/src/store/objectStore.ts index 1b772fd0..3d2f1ad9 100644 --- a/src/store/objectStore.ts +++ b/src/store/objectStore.ts @@ -4,9 +4,9 @@ import { create } from "zustand"; import type { ObjectView } from "@runloop/api-client/resources/objects"; -// Extended type with UI-specific fields -// The service transforms API responses to add computed/fetched fields -export interface StorageObject extends ObjectView { +// Re-export ObjectView as StorageObjectView for UI use +// Includes optional fields that may be present in API responses +export interface StorageObjectView extends ObjectView { // Presigned download URL (fetched separately via download endpoint) download_url?: string; // User-defined metadata @@ -17,7 +17,7 @@ export interface StorageObject extends ObjectView { interface ObjectState { // List data - objects: StorageObject[]; + objects: StorageObjectView[]; loading: boolean; initialLoading: boolean; error: Error | null; @@ -29,7 +29,7 @@ interface ObjectState { hasMore: boolean; // Caching - pageCache: Map; + pageCache: Map; lastIdCache: Map; // Filters @@ -42,7 +42,7 @@ interface ObjectState { selectedIndex: number; // Actions - setObjects: (objects: StorageObject[]) => void; + setObjects: (objects: StorageObjectView[]) => void; setLoading: (loading: boolean) => void; setInitialLoading: (loading: boolean) => void; setError: (error: Error | null) => void; @@ -58,12 +58,12 @@ interface ObjectState { setIsPublicFilter: (isPublic?: boolean) => void; setSelectedIndex: (index: number) => void; - cachePageData: (page: number, data: StorageObject[], lastId: string) => void; - getCachedPage: (page: number) => StorageObject[] | undefined; + cachePageData: (page: number, data: StorageObjectView[], lastId: string) => void; + getCachedPage: (page: number) => StorageObjectView[] | undefined; clearCache: () => void; clearAll: () => void; - getSelectedObject: () => StorageObject | undefined; + getSelectedObject: () => StorageObjectView | undefined; } const MAX_CACHE_SIZE = 10; @@ -121,7 +121,7 @@ export const useObjectStore = create((set, get) => ({ // Deep copy all fields to avoid SDK references const plainData = data.map((d) => { - return JSON.parse(JSON.stringify(d)) as StorageObject; + return JSON.parse(JSON.stringify(d)) as StorageObjectView; }); pageCache.set(page, plainData); From b56eb6fcc7b7bae2721d005dcc1b069309e35c00 Mon Sep 17 00:00:00 2001 From: Alexander Dines Date: Thu, 22 Jan 2026 16:04:04 -0800 Subject: [PATCH 17/24] cp dines --- src/screens/ObjectDetailScreen.tsx | 5 ++++- src/store/objectStore.ts | 6 +++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/screens/ObjectDetailScreen.tsx b/src/screens/ObjectDetailScreen.tsx index 5dc4c1e0..0b278b34 100644 --- a/src/screens/ObjectDetailScreen.tsx +++ b/src/screens/ObjectDetailScreen.tsx @@ -8,7 +8,10 @@ import TextInput from "ink-text-input"; import figures from "figures"; import { writeFile } from "fs/promises"; import { useNavigation } from "../store/navigationStore.js"; -import { useObjectStore, type StorageObjectView } from "../store/objectStore.js"; +import { + useObjectStore, + type StorageObjectView, +} from "../store/objectStore.js"; import { getClient } from "../utils/client.js"; import { ResourceDetailPage, diff --git a/src/store/objectStore.ts b/src/store/objectStore.ts index 3d2f1ad9..6e28881a 100644 --- a/src/store/objectStore.ts +++ b/src/store/objectStore.ts @@ -58,7 +58,11 @@ interface ObjectState { setIsPublicFilter: (isPublic?: boolean) => void; setSelectedIndex: (index: number) => void; - cachePageData: (page: number, data: StorageObjectView[], lastId: string) => void; + cachePageData: ( + page: number, + data: StorageObjectView[], + lastId: string, + ) => void; getCachedPage: (page: number) => StorageObjectView[] | undefined; clearCache: () => void; clearAll: () => void; From 35cf84e191f948e2c8b9c7663907d82e5933c8b7 Mon Sep 17 00:00:00 2001 From: Alexander Dines Date: Thu, 22 Jan 2026 16:05:41 -0800 Subject: [PATCH 18/24] cp dines --- src/hooks/useViewportHeight.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/hooks/useViewportHeight.ts b/src/hooks/useViewportHeight.ts index 328c3f22..e322b01f 100644 --- a/src/hooks/useViewportHeight.ts +++ b/src/hooks/useViewportHeight.ts @@ -22,7 +22,9 @@ interface ViewportDimensions { /** * Get safe terminal dimensions with bounds checking */ -function getSafeDimensions(stdout: NodeJS.WriteStream | undefined): { +function getSafeDimensions( + stdout: { columns?: number; rows?: number } | undefined, +): { width: number; height: number; } { From e5108c1e5e1c30d40f219a6a81919afe43426b53 Mon Sep 17 00:00:00 2001 From: Alexander Dines Date: Thu, 22 Jan 2026 16:25:29 -0800 Subject: [PATCH 19/24] cp dines --- src/commands/blueprint/list.tsx | 65 ++++++++---------- src/commands/devbox/list.tsx | 56 +++++----------- src/commands/menu.tsx | 2 + src/commands/network-policy/list.tsx | 52 +++++---------- src/commands/object/list.tsx | 58 +++++++--------- src/commands/snapshot/list.tsx | 47 +++++-------- src/components/Banner.tsx | 7 +- src/components/ConfirmationPrompt.tsx | 19 ++++-- src/components/DevboxActionsMenu.tsx | 50 +++++++------- src/components/DevboxCreatePage.tsx | 33 +++++----- src/components/LogsViewer.tsx | 18 +++-- src/components/MainMenu.tsx | 45 +++++++------ src/components/NavigationTips.tsx | 77 ++++++++++++++++++++++ src/components/NetworkPolicyCreatePage.tsx | 33 +++++----- src/components/OperationsMenu.tsx | 23 ++++--- src/components/ResourceActionsMenu.tsx | 12 +++- src/components/ResourceDetailPage.tsx | 19 +++--- src/components/ResourceListView.tsx | 58 +++++++--------- 18 files changed, 348 insertions(+), 326 deletions(-) create mode 100644 src/components/NavigationTips.tsx diff --git a/src/commands/blueprint/list.tsx b/src/commands/blueprint/list.tsx index 0b44c532..91eab3bf 100644 --- a/src/commands/blueprint/list.tsx +++ b/src/commands/blueprint/list.tsx @@ -9,6 +9,7 @@ import { SpinnerComponent } from "../../components/Spinner.js"; import { ErrorMessage } from "../../components/ErrorMessage.js"; import { SuccessMessage } from "../../components/SuccessMessage.js"; import { Breadcrumb } from "../../components/Breadcrumb.js"; +import { NavigationTips } from "../../components/NavigationTips.js"; import { createTextColumn, Table } from "../../components/Table.js"; import { Operation } from "../../components/OperationsMenu.js"; import { ActionsPopup } from "../../components/ActionsPopup.js"; @@ -636,11 +637,9 @@ const ListBlueprintsUI = ({ {operationError && ( )} - - - Press [Enter], [q], or [esc] to continue - - + ); } @@ -709,11 +708,14 @@ const ListBlueprintsUI = ({ placeholder={currentOp?.inputPlaceholder || ""} /> - - - Press [Enter] to execute • [q or esc] Cancel - - + ); @@ -845,35 +847,20 @@ const ListBlueprintsUI = ({ )} {/* Help Bar */} - - - {figures.arrowUp} - {figures.arrowDown} Navigate - - {(hasMore || hasPrev) && ( - - {" "} - • {figures.arrowLeft} - {figures.arrowRight} Page - - )} - - {" "} - • [Enter] Details - - - {" "} - • [a] Actions - - - {" "} - • [o] Browser - - - {" "} - • [Esc] Back - - + ); }; diff --git a/src/commands/devbox/list.tsx b/src/commands/devbox/list.tsx index 22d50d28..06fad723 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 { NavigationTips } from "../../components/NavigationTips.js"; import type { Column } from "../../components/Table.js"; import { Table, createTextColumn } from "../../components/Table.js"; import { formatTimeAgo } from "../../components/ResourceListView.js"; @@ -744,45 +745,22 @@ const ListDevboxesUI = ({ )} {/* Help Bar */} - - - {figures.arrowUp} - {figures.arrowDown} Navigate - - {(hasMore || hasPrev) && ( - - {" "} - • {figures.arrowLeft} - {figures.arrowRight} Page - - )} - - {" "} - • [Enter] Details - - - {" "} - • [a] Actions - - - {" "} - • [c] Create - - {selectedDevbox && ( - - {" "} - • [o] Open in Browser - - )} - - {" "} - • [/] Search - - - {" "} - • [Esc] Back - - + ); }; diff --git a/src/commands/menu.tsx b/src/commands/menu.tsx index 70b48100..5c57efb9 100644 --- a/src/commands/menu.tsx +++ b/src/commands/menu.tsx @@ -3,6 +3,7 @@ import { render } from "ink"; import { enterAlternateScreenBuffer, exitAlternateScreenBuffer, + clearScreen, } from "../utils/screen.js"; import { processUtils } from "../utils/processUtils.js"; @@ -38,6 +39,7 @@ export async function runMainMenu( focusDevboxId?: string, ) { enterAlternateScreenBuffer(); + clearScreen(); // Ensure cursor is at top-left before Ink renders try { const { waitUntilExit } = render( diff --git a/src/commands/network-policy/list.tsx b/src/commands/network-policy/list.tsx index fa82b81b..04228853 100644 --- a/src/commands/network-policy/list.tsx +++ b/src/commands/network-policy/list.tsx @@ -8,6 +8,7 @@ import { SpinnerComponent } from "../../components/Spinner.js"; import { ErrorMessage } from "../../components/ErrorMessage.js"; import { SuccessMessage } from "../../components/SuccessMessage.js"; import { Breadcrumb } from "../../components/Breadcrumb.js"; +import { NavigationTips } from "../../components/NavigationTips.js"; import { Table, createTextColumn } from "../../components/Table.js"; import { ActionsPopup } from "../../components/ActionsPopup.js"; import { Operation } from "../../components/OperationsMenu.js"; @@ -502,11 +503,9 @@ const ListNetworkPoliciesUI = ({ {operationError && ( )} - - - Press [Enter], [q], or [esc] to continue - - + ); } @@ -653,35 +652,20 @@ const ListNetworkPoliciesUI = ({ )} {/* Help Bar */} - - - {figures.arrowUp} - {figures.arrowDown} Navigate - - {(hasMore || hasPrev) && ( - - {" "} - • {figures.arrowLeft} - {figures.arrowRight} Page - - )} - - {" "} - • [Enter] Details - - - {" "} - • [c] Create - - - {" "} - • [a] Actions - - - {" "} - • [Esc] Back - - + ); }; diff --git a/src/commands/object/list.tsx b/src/commands/object/list.tsx index a3c7af63..8b5409ae 100644 --- a/src/commands/object/list.tsx +++ b/src/commands/object/list.tsx @@ -9,6 +9,7 @@ import { SpinnerComponent } from "../../components/Spinner.js"; import { ErrorMessage } from "../../components/ErrorMessage.js"; import { SuccessMessage } from "../../components/SuccessMessage.js"; import { Breadcrumb } from "../../components/Breadcrumb.js"; +import { NavigationTips } from "../../components/NavigationTips.js"; import { Table, createTextColumn } from "../../components/Table.js"; import { ActionsPopup } from "../../components/ActionsPopup.js"; import { Operation } from "../../components/OperationsMenu.js"; @@ -536,11 +537,9 @@ const ListObjectsUI = ({ {operationError && ( )} - - - Press [Enter], [q], or [esc] to continue - - + ); } @@ -581,11 +580,12 @@ const ListObjectsUI = ({ /> - - - [Enter] Download • [Esc] Cancel - - + ); } @@ -746,31 +746,19 @@ const ListObjectsUI = ({ )} {/* Help Bar */} - - - {figures.arrowUp} - {figures.arrowDown} Navigate - - {(hasMore || hasPrev) && ( - - {" "} - • {figures.arrowLeft} - {figures.arrowRight} Page - - )} - - {" "} - • [Enter] Details - - - {" "} - • [a] Actions - - - {" "} - • [Esc] Back - - + ); }; diff --git a/src/commands/snapshot/list.tsx b/src/commands/snapshot/list.tsx index ca671d3d..925f614e 100644 --- a/src/commands/snapshot/list.tsx +++ b/src/commands/snapshot/list.tsx @@ -8,6 +8,7 @@ import { SpinnerComponent } from "../../components/Spinner.js"; import { ErrorMessage } from "../../components/ErrorMessage.js"; import { SuccessMessage } from "../../components/SuccessMessage.js"; import { Breadcrumb } from "../../components/Breadcrumb.js"; +import { NavigationTips } from "../../components/NavigationTips.js"; import { Table, createTextColumn } from "../../components/Table.js"; import { ActionsPopup } from "../../components/ActionsPopup.js"; import { Operation } from "../../components/OperationsMenu.js"; @@ -415,11 +416,9 @@ const ListSnapshotsUI = ({ {operationError && ( )} - - - Press [Enter], [q], or [esc] to continue - - + ); } @@ -616,31 +615,19 @@ const ListSnapshotsUI = ({ )} {/* Help Bar */} - - - {figures.arrowUp} - {figures.arrowDown} Navigate - - {(hasMore || hasPrev) && ( - - {" "} - • {figures.arrowLeft} - {figures.arrowRight} Page - - )} - - {" "} - • [Enter] Details - - - {" "} - • [a] Actions - - - {" "} - • [Esc] Back - - + ); }; diff --git a/src/components/Banner.tsx b/src/components/Banner.tsx index 7ad3f5dc..44a13ab7 100644 --- a/src/components/Banner.tsx +++ b/src/components/Banner.tsx @@ -81,14 +81,15 @@ const precomputeFrames = (colors: string[]): string[][] => { return colors.map((_, i) => [...colors.slice(i), ...colors.slice(0, i)]); }; -const DARK_FRAMES = precomputeFrames(DARK_SHIMMER_COLORS); -const LIGHT_FRAMES = precomputeFrames(LIGHT_SHIMMER_COLORS); +// Use every 2nd color to reduce frame count and minimize flickering +const DARK_FRAMES = precomputeFrames(DARK_SHIMMER_COLORS.filter((_, i) => i % 2 === 0)); +const LIGHT_FRAMES = precomputeFrames(LIGHT_SHIMMER_COLORS.filter((_, i) => i % 2 === 0)); // Minimum width to show the full BigText banner (simple3d font needs ~80 chars for "RUNLOOP.ai") const MIN_WIDTH_FOR_BIG_BANNER = 90; // Animation interval in ms -const SHIMMER_INTERVAL = 150; +const SHIMMER_INTERVAL = 400; export const Banner = React.memo(() => { const [frameIndex, setFrameIndex] = useState(0); diff --git a/src/components/ConfirmationPrompt.tsx b/src/components/ConfirmationPrompt.tsx index 32ee5048..28a9ef03 100644 --- a/src/components/ConfirmationPrompt.tsx +++ b/src/components/ConfirmationPrompt.tsx @@ -6,6 +6,7 @@ import { Box, Text, useInput } from "ink"; import figures from "figures"; import { Header } from "./Header.js"; import { Breadcrumb } from "./Breadcrumb.js"; +import { NavigationTips } from "./NavigationTips.js"; import { colors } from "../utils/theme.js"; export interface ConfirmationPromptProps { @@ -119,13 +120,17 @@ export const ConfirmationPrompt = ({ - - - {figures.arrowUp} - {figures.arrowDown} Select • [Enter] Confirm • [y/n] Quick select • - [Esc] Cancel - - + ); diff --git a/src/components/DevboxActionsMenu.tsx b/src/components/DevboxActionsMenu.tsx index 5e65b53d..68dfda2f 100644 --- a/src/components/DevboxActionsMenu.tsx +++ b/src/components/DevboxActionsMenu.tsx @@ -7,6 +7,7 @@ import { SpinnerComponent } from "./Spinner.js"; import { ErrorMessage } from "./ErrorMessage.js"; import { SuccessMessage } from "./SuccessMessage.js"; import { Breadcrumb } from "./Breadcrumb.js"; +import { NavigationTips } from "./NavigationTips.js"; import { ConfirmationPrompt } from "./ConfirmationPrompt.js"; import { colors } from "../utils/theme.js"; import { useViewportHeight } from "../hooks/useViewportHeight.js"; @@ -711,13 +712,15 @@ export const DevboxActionsMenu = ({ {/* Help bar */} - - - {figures.arrowUp} - {figures.arrowDown} Navigate • [g] Top • [G] Bottom • [c] Copy • - [Enter], [q], or [esc] Back - - + ); } @@ -765,11 +768,9 @@ export const DevboxActionsMenu = ({ {operationError && ( )} - - - Press [Enter], [q], or [esc] to continue - - + ); } @@ -863,11 +864,12 @@ export const DevboxActionsMenu = ({ } /> - - - Press [Enter] to execute • [q or esc] Cancel - - + ); @@ -906,12 +908,14 @@ export const DevboxActionsMenu = ({ - - - {figures.arrowUp} - {figures.arrowDown} Navigate • [Enter] Select • [q] Back - - + ); } diff --git a/src/components/DevboxCreatePage.tsx b/src/components/DevboxCreatePage.tsx index 4661d019..b830de2e 100644 --- a/src/components/DevboxCreatePage.tsx +++ b/src/components/DevboxCreatePage.tsx @@ -12,6 +12,7 @@ import { SpinnerComponent } from "./Spinner.js"; import { ErrorMessage } from "./ErrorMessage.js"; import { SuccessMessage } from "./SuccessMessage.js"; import { Breadcrumb } from "./Breadcrumb.js"; +import { NavigationTips } from "./NavigationTips.js"; import { MetadataDisplay } from "./MetadataDisplay.js"; import { FormTextInput, @@ -519,11 +520,9 @@ export const DevboxCreatePage = ({ - - - Press [Enter], [q], or [esc] to return to list - - + ); } @@ -536,11 +535,12 @@ export const DevboxCreatePage = ({ items={[{ label: "Devboxes" }, { label: "Create", active: true }]} /> - - - Press [Enter] or [r] to retry • [q] or [esc] to cancel - - + ); } @@ -845,12 +845,13 @@ export const DevboxCreatePage = ({ )} {!inMetadataSection && ( - - - {figures.arrowUp} - {figures.arrowDown} Navigate • [Enter] Create • [q] Cancel - - + )} ); diff --git a/src/components/LogsViewer.tsx b/src/components/LogsViewer.tsx index 550545c1..08327aa1 100644 --- a/src/components/LogsViewer.tsx +++ b/src/components/LogsViewer.tsx @@ -6,6 +6,7 @@ import React from "react"; import { Box, Text, useInput } from "ink"; import figures from "figures"; import { Breadcrumb } from "./Breadcrumb.js"; +import { NavigationTips } from "./NavigationTips.js"; import { colors } from "../utils/theme.js"; import { useViewportHeight } from "../hooks/useViewportHeight.js"; import { parseAnyLogEntry, type AnyLog } from "../utils/logFormatter.js"; @@ -317,13 +318,16 @@ export const LogsViewer = ({ )} - - - {figures.arrowUp} - {figures.arrowDown} Navigate • [g] Top • [G] Bottom • [w] Toggle Wrap - • [c] Copy • [Enter], [q], or [esc] Back - - + ); }; diff --git a/src/components/MainMenu.tsx b/src/components/MainMenu.tsx index 2d88bf55..0b6a0c63 100644 --- a/src/components/MainMenu.tsx +++ b/src/components/MainMenu.tsx @@ -3,6 +3,7 @@ import { Box, Text, useInput, useApp } from "ink"; import figures from "figures"; import { Banner } from "./Banner.js"; import { Breadcrumb } from "./Breadcrumb.js"; +import { NavigationTips } from "./NavigationTips.js"; import { VERSION } from "../version.js"; import { colors } from "../utils/theme.js"; import { execCommand } from "../utils/exec.js"; @@ -107,6 +108,11 @@ export const MainMenu = ({ onSelect }: MainMenuProps) => { if (useCompactLayout) { return ( + + RUNLOOP.ai @@ -149,14 +155,16 @@ export const MainMenu = ({ onSelect }: MainMenuProps) => { })} - - - {figures.arrowUp} - {figures.arrowDown} Navigate • [1-5] Quick select • [Enter] Select • - [Esc] Quit - {updateAvailable && " • [u] Update"} - - + ); } @@ -178,7 +186,7 @@ export const MainMenu = ({ onSelect }: MainMenuProps) => { - + Select a resource: @@ -228,16 +236,15 @@ export const MainMenu = ({ onSelect }: MainMenuProps) => { })} - - - - {figures.arrowUp} - {figures.arrowDown} Navigate • [1-5] Quick select • [Enter] Select • - [Esc] Quit - {updateAvailable && " • [u] Update"} - - - + ); }; diff --git a/src/components/NavigationTips.tsx b/src/components/NavigationTips.tsx new file mode 100644 index 00000000..b66a96ca --- /dev/null +++ b/src/components/NavigationTips.tsx @@ -0,0 +1,77 @@ +/** + * NavigationTips - Shared component for rendering keyboard navigation hints + * Supports responsive wrapping for small terminal widths + */ +import React from "react"; +import { Box, Text } from "ink"; +import figures from "figures"; +import { colors } from "../utils/theme.js"; + +export interface NavigationTip { + /** Keyboard key(s) - e.g., "Enter", "Esc", "q", "←→" */ + key?: string; + /** Description of the action - e.g., "Select", "Back" */ + label: string; + /** Icon to display instead of key - e.g., figures.arrowUp + figures.arrowDown */ + icon?: string; + /** Condition for showing this tip - tip is hidden if false */ + condition?: boolean; +} + +export interface NavigationTipsProps { + /** Array of navigation tips to display */ + tips: NavigationTip[]; + /** Show arrow navigation icons (↑↓) with optional label */ + showArrows?: boolean; + /** Label for arrow navigation (default: "Navigate") */ + arrowLabel?: string; + /** Additional padding on the sides */ + paddingX?: number; + /** Margin on top */ + marginTop?: number; +} + +/** + * Renders a responsive navigation tips bar that wraps on small screens + */ +export const NavigationTips = ({ + tips, + showArrows = false, + arrowLabel = "Navigate", + paddingX = 1, + marginTop = 1, +}: NavigationTipsProps) => { + // Filter tips by condition (undefined condition means always show) + const visibleTips = tips.filter( + (tip) => tip.condition === undefined || tip.condition === true, + ); + + // Build the tips array, prepending arrows if requested + const allTips: NavigationTip[] = []; + + if (showArrows) { + allTips.push({ + icon: `${figures.arrowUp}${figures.arrowDown}`, + label: arrowLabel, + }); + } + + allTips.push(...visibleTips); + + if (allTips.length === 0) { + return null; + } + + return ( + + {allTips.map((tip, index) => ( + + {index > 0 && " • "} + {tip.icon && `${tip.icon} `} + {tip.key && `[${tip.key}] `} + {tip.label} + + ))} + + ); +}; diff --git a/src/components/NetworkPolicyCreatePage.tsx b/src/components/NetworkPolicyCreatePage.tsx index f9616ba7..142955b2 100644 --- a/src/components/NetworkPolicyCreatePage.tsx +++ b/src/components/NetworkPolicyCreatePage.tsx @@ -10,6 +10,7 @@ import { SpinnerComponent } from "./Spinner.js"; import { ErrorMessage } from "./ErrorMessage.js"; import { SuccessMessage } from "./SuccessMessage.js"; import { Breadcrumb } from "./Breadcrumb.js"; +import { NavigationTips } from "./NavigationTips.js"; import { FormTextInput, FormSelect, @@ -265,11 +266,9 @@ export const NetworkPolicyCreatePage = ({ - - - Press [Enter], [q], or [esc] to return to list - - + ); } @@ -285,11 +284,12 @@ export const NetworkPolicyCreatePage = ({ ]} /> - - - Press [Enter] or [r] to retry • [q] or [esc] to cancel - - + ); } @@ -402,12 +402,13 @@ export const NetworkPolicyCreatePage = ({ {!hostnamesExpanded && ( - - - {figures.arrowUp} - {figures.arrowDown} Navigate • [Enter] Create/Expand • [q] Cancel - - + )} ); diff --git a/src/components/OperationsMenu.tsx b/src/components/OperationsMenu.tsx index 9fb509db..b84cdd3f 100644 --- a/src/components/OperationsMenu.tsx +++ b/src/components/OperationsMenu.tsx @@ -2,6 +2,7 @@ import React from "react"; import { Box, Text } from "ink"; import figures from "figures"; import { colors } from "../utils/theme.js"; +import { NavigationTips } from "./NavigationTips.js"; export interface Operation { key: string; @@ -66,16 +67,18 @@ export const OperationsMenu = ({ {/* Help text */} - - - {figures.arrowUp} - {figures.arrowDown} Navigate • [Enter] Select • - {additionalActions.map( - (action) => ` [${action.key}] ${action.label} •`, - )}{" "} - [q] Back - - + ({ + key: action.key, + label: action.label, + })), + { key: "q", label: "Back" }, + ]} + /> ); }; diff --git a/src/components/ResourceActionsMenu.tsx b/src/components/ResourceActionsMenu.tsx index 543b877f..cda6e85e 100644 --- a/src/components/ResourceActionsMenu.tsx +++ b/src/components/ResourceActionsMenu.tsx @@ -3,6 +3,7 @@ import { Box, Text, useInput } from "ink"; import figures from "figures"; import { colors } from "../utils/theme.js"; import { Breadcrumb } from "./Breadcrumb.js"; +import { NavigationTips } from "./NavigationTips.js"; import { ActionsPopup } from "./ActionsPopup.js"; import { DevboxActionsMenu } from "./DevboxActionsMenu.js"; @@ -201,9 +202,14 @@ export const ResourceActionsMenu = (props: ResourceActionsMenuProps) => { {selectedOp.inputPrompt || "Input:"}{" "} {operationInput} - - Press [Enter] to execute • [q or esc] Cancel - + ); diff --git a/src/components/ResourceDetailPage.tsx b/src/components/ResourceDetailPage.tsx index 8e8ce8f9..2c7e3d2e 100644 --- a/src/components/ResourceDetailPage.tsx +++ b/src/components/ResourceDetailPage.tsx @@ -8,6 +8,7 @@ import figures from "figures"; import { Header } from "./Header.js"; import { StatusBadge } from "./StatusBadge.js"; import { Breadcrumb } from "./Breadcrumb.js"; +import { NavigationTips } from "./NavigationTips.js"; import { colors } from "../utils/theme.js"; import { useViewportHeight } from "../hooks/useViewportHeight.js"; import { useExitOnCtrlC } from "../hooks/useExitOnCtrlC.js"; @@ -379,15 +380,15 @@ export function ResourceDetailPage({ )} - - - {figures.arrowUp} - {figures.arrowDown} Navigate • [Enter] Execute - {buildDetailLines && " • [i] Full Details"} - {getUrl && " • [o] Browser"} - {" • [q] Back"} - - + ); } diff --git a/src/components/ResourceListView.tsx b/src/components/ResourceListView.tsx index 42a3ea00..af79994f 100644 --- a/src/components/ResourceListView.tsx +++ b/src/components/ResourceListView.tsx @@ -3,6 +3,7 @@ import { Box, Text, useInput, useApp } from "ink"; import TextInput from "ink-text-input"; import figures from "figures"; import { Breadcrumb, BreadcrumbItem } from "./Breadcrumb.js"; +import { NavigationTips } from "./NavigationTips.js"; import { SpinnerComponent } from "./Spinner.js"; import { ErrorMessage } from "./ErrorMessage.js"; import { Table, Column } from "./Table.js"; @@ -465,42 +466,27 @@ export function ResourceListView({ config }: ResourceListViewProps) { {/* Help Bar */} - - - {figures.arrowUp} - {figures.arrowDown} Navigate - - {totalPages > 1 && ( - - {" "} - • {figures.arrowLeft} - {figures.arrowRight} Page - - )} - {config.onSelect && ( - - {" "} - • [Enter] Details - - )} - {config.searchConfig?.enabled && ( - - {" "} - • [/] Search - - )} - {config.additionalShortcuts && - config.additionalShortcuts.map((shortcut) => ( - - {" "} - • [{shortcut.key}] {shortcut.label} - - ))} - - {" "} - • [Esc] Back - - + 1, + }, + { key: "Enter", label: "Details", condition: !!config.onSelect }, + { + key: "/", + label: "Search", + condition: config.searchConfig?.enabled, + }, + ...(config.additionalShortcuts || []).map((shortcut) => ({ + key: shortcut.key, + label: shortcut.label, + })), + { key: "Esc", label: "Back" }, + ]} + /> ); } From 786253ef023776d24b7c54357794f5020b0d6807 Mon Sep 17 00:00:00 2001 From: Alexander Dines Date: Thu, 22 Jan 2026 16:25:40 -0800 Subject: [PATCH 20/24] cp dines --- src/commands/blueprint/list.tsx | 4 +--- src/commands/network-policy/list.tsx | 4 +--- src/commands/object/list.tsx | 4 +--- src/commands/snapshot/list.tsx | 4 +--- src/components/Banner.tsx | 8 ++++++-- src/components/DevboxActionsMenu.tsx | 4 +--- 6 files changed, 11 insertions(+), 17 deletions(-) diff --git a/src/commands/blueprint/list.tsx b/src/commands/blueprint/list.tsx index 91eab3bf..1a1647bc 100644 --- a/src/commands/blueprint/list.tsx +++ b/src/commands/blueprint/list.tsx @@ -637,9 +637,7 @@ const ListBlueprintsUI = ({ {operationError && ( )} - + ); } diff --git a/src/commands/network-policy/list.tsx b/src/commands/network-policy/list.tsx index 04228853..fe309235 100644 --- a/src/commands/network-policy/list.tsx +++ b/src/commands/network-policy/list.tsx @@ -503,9 +503,7 @@ const ListNetworkPoliciesUI = ({ {operationError && ( )} - + ); } diff --git a/src/commands/object/list.tsx b/src/commands/object/list.tsx index 8b5409ae..427755ce 100644 --- a/src/commands/object/list.tsx +++ b/src/commands/object/list.tsx @@ -537,9 +537,7 @@ const ListObjectsUI = ({ {operationError && ( )} - + ); } diff --git a/src/commands/snapshot/list.tsx b/src/commands/snapshot/list.tsx index 925f614e..218ddaa8 100644 --- a/src/commands/snapshot/list.tsx +++ b/src/commands/snapshot/list.tsx @@ -416,9 +416,7 @@ const ListSnapshotsUI = ({ {operationError && ( )} - + ); } diff --git a/src/components/Banner.tsx b/src/components/Banner.tsx index 44a13ab7..5ed25c3e 100644 --- a/src/components/Banner.tsx +++ b/src/components/Banner.tsx @@ -82,8 +82,12 @@ const precomputeFrames = (colors: string[]): string[][] => { }; // Use every 2nd color to reduce frame count and minimize flickering -const DARK_FRAMES = precomputeFrames(DARK_SHIMMER_COLORS.filter((_, i) => i % 2 === 0)); -const LIGHT_FRAMES = precomputeFrames(LIGHT_SHIMMER_COLORS.filter((_, i) => i % 2 === 0)); +const DARK_FRAMES = precomputeFrames( + DARK_SHIMMER_COLORS.filter((_, i) => i % 2 === 0), +); +const LIGHT_FRAMES = precomputeFrames( + LIGHT_SHIMMER_COLORS.filter((_, i) => i % 2 === 0), +); // Minimum width to show the full BigText banner (simple3d font needs ~80 chars for "RUNLOOP.ai") const MIN_WIDTH_FOR_BIG_BANNER = 90; diff --git a/src/components/DevboxActionsMenu.tsx b/src/components/DevboxActionsMenu.tsx index 68dfda2f..2cfa5871 100644 --- a/src/components/DevboxActionsMenu.tsx +++ b/src/components/DevboxActionsMenu.tsx @@ -768,9 +768,7 @@ export const DevboxActionsMenu = ({ {operationError && ( )} - + ); } From 440bdd4da9bc3f6d91a76ccd9014c9a98cdac3ff Mon Sep 17 00:00:00 2001 From: Alexander Dines Date: Thu, 22 Jan 2026 16:26:34 -0800 Subject: [PATCH 21/24] cp dines --- src/components/NetworkPolicyCreatePage.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/NetworkPolicyCreatePage.tsx b/src/components/NetworkPolicyCreatePage.tsx index 142955b2..9fc23751 100644 --- a/src/components/NetworkPolicyCreatePage.tsx +++ b/src/components/NetworkPolicyCreatePage.tsx @@ -3,7 +3,6 @@ */ import React from "react"; import { Box, Text, useInput } from "ink"; -import figures from "figures"; import type { NetworkPolicyView } from "@runloop/api-client/resources/network-policies"; import { getClient } from "../utils/client.js"; import { SpinnerComponent } from "./Spinner.js"; From cbf037eb15e3dc65dba25e4e2584a2145d304809 Mon Sep 17 00:00:00 2001 From: Alexander Dines Date: Thu, 22 Jan 2026 16:27:29 -0800 Subject: [PATCH 22/24] cp dines --- src/components/MainMenu.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/MainMenu.tsx b/src/components/MainMenu.tsx index 0b6a0c63..e4a43484 100644 --- a/src/components/MainMenu.tsx +++ b/src/components/MainMenu.tsx @@ -162,7 +162,7 @@ export const MainMenu = ({ onSelect }: MainMenuProps) => { { key: "1-5", label: "Quick select" }, { key: "Enter", label: "Select" }, { key: "Esc", label: "Quit" }, - { key: "u", label: "Update", condition: updateAvailable }, + { key: "u", label: "Update", condition: !!updateAvailable }, ]} /> @@ -242,7 +242,7 @@ export const MainMenu = ({ onSelect }: MainMenuProps) => { { key: "1-5", label: "Quick select" }, { key: "Enter", label: "Select" }, { key: "Esc", label: "Quit" }, - { key: "u", label: "Update", condition: updateAvailable }, + { key: "u", label: "Update", condition: !!updateAvailable }, ]} /> From a2d7ede67ee86c064d6b5022d743610e22914000 Mon Sep 17 00:00:00 2001 From: Alexander Dines Date: Thu, 22 Jan 2026 16:36:05 -0800 Subject: [PATCH 23/24] cp dines --- src/commands/network-policy/list.tsx | 58 ++++++- src/commands/object/list.tsx | 2 +- src/components/NetworkPolicyCreatePage.tsx | 169 +++++++++++++++------ src/screens/NetworkPolicyDetailScreen.tsx | 28 ++++ src/services/networkPolicyService.ts | 32 ++++ 5 files changed, 240 insertions(+), 49 deletions(-) diff --git a/src/commands/network-policy/list.tsx b/src/commands/network-policy/list.tsx index fe309235..a6008f8a 100644 --- a/src/commands/network-policy/list.tsx +++ b/src/commands/network-policy/list.tsx @@ -94,6 +94,9 @@ const ListNetworkPoliciesUI = ({ ); const [operationLoading, setOperationLoading] = React.useState(false); const [showCreatePolicy, setShowCreatePolicy] = React.useState(false); + const [showEditPolicy, setShowEditPolicy] = React.useState(false); + const [editingPolicy, setEditingPolicy] = + React.useState(null); const [showDeleteConfirm, setShowDeleteConfirm] = React.useState(false); // Calculate overhead for viewport height @@ -109,7 +112,7 @@ const ListNetworkPoliciesUI = ({ const fixedWidth = 6; // border + padding const idWidth = 25; const egressWidth = 15; - const timeWidth = 15; + const timeWidth = 20; const showDescription = terminalWidth >= 100; const descriptionWidth = Math.max(20, terminalWidth >= 140 ? 40 : 25); @@ -189,6 +192,7 @@ const ListNetworkPoliciesUI = ({ !showPopup && !executingOperation && !showCreatePolicy && + !showEditPolicy && !showDeleteConfirm, deps: [PAGE_SIZE], }); @@ -202,6 +206,12 @@ const ListNetworkPoliciesUI = ({ color: colors.primary, icon: figures.pointer, }, + { + key: "edit", + label: "Edit Network Policy", + color: colors.warning, + icon: figures.pointer, + }, { key: "delete", label: "Delete Network Policy", @@ -364,6 +374,11 @@ const ListNetworkPoliciesUI = ({ return; } + // Handle edit policy screen + if (showEditPolicy) { + return; + } + // Handle popup navigation if (showPopup) { if (key.upArrow && selectedOperation > 0) { @@ -380,6 +395,10 @@ const ListNetworkPoliciesUI = ({ navigate("network-policy-detail", { networkPolicyId: selectedPolicyItem.id, }); + } else if (operationKey === "edit") { + // Show edit form + setEditingPolicy(selectedPolicyItem); + setShowEditPolicy(true); } else if (operationKey === "delete") { // Show delete confirmation setSelectedPolicy(selectedPolicyItem); @@ -400,6 +419,11 @@ const ListNetworkPoliciesUI = ({ navigate("network-policy-detail", { networkPolicyId: selectedPolicyItem.id, }); + } else if (input === "e" && selectedPolicyItem) { + // Edit hotkey + setShowPopup(false); + setEditingPolicy(selectedPolicyItem); + setShowEditPolicy(true); } else if (key.escape || input === "q") { setShowPopup(false); setSelectedOperation(0); @@ -446,6 +470,10 @@ const ListNetworkPoliciesUI = ({ } else if (input === "c") { // Create shortcut setShowCreatePolicy(true); + } else if (input === "e" && selectedPolicyItem) { + // Edit shortcut + setEditingPolicy(selectedPolicyItem); + setShowEditPolicy(true); } else if (key.escape) { if (onBack) { onBack(); @@ -546,6 +574,25 @@ const ListNetworkPoliciesUI = ({ ); } + // Edit policy screen + if (showEditPolicy && editingPolicy) { + return ( + { + setShowEditPolicy(false); + setEditingPolicy(null); + }} + onCreate={() => { + setShowEditPolicy(false); + setEditingPolicy(null); + // Refresh the list to show updated data + setTimeout(() => refresh(), 0); + }} + initialPolicy={editingPolicy} + /> + ); + } + // Loading state if (loading && policies.length === 0) { return ( @@ -639,9 +686,11 @@ const ListNetworkPoliciesUI = ({ ? "c" : op.key === "view_details" ? "v" - : op.key === "delete" - ? "d" - : "", + : op.key === "edit" + ? "e" + : op.key === "delete" + ? "d" + : "", }))} selectedOperation={selectedOperation} onClose={() => setShowPopup(false)} @@ -660,6 +709,7 @@ const ListNetworkPoliciesUI = ({ }, { key: "Enter", label: "Details" }, { key: "c", label: "Create" }, + { key: "e", label: "Edit" }, { key: "a", label: "Actions" }, { key: "Esc", label: "Back" }, ]} diff --git a/src/commands/object/list.tsx b/src/commands/object/list.tsx index 427755ce..1f12b3f2 100644 --- a/src/commands/object/list.tsx +++ b/src/commands/object/list.tsx @@ -89,7 +89,7 @@ const ListObjectsUI = ({ const stateWidth = 12; const typeWidth = 15; const sizeWidth = 12; - const timeWidth = 15; + const timeWidth = 20; const ttlWidth = 14; const showTypeColumn = terminalWidth >= 100; const showSizeColumn = terminalWidth >= 90; diff --git a/src/components/NetworkPolicyCreatePage.tsx b/src/components/NetworkPolicyCreatePage.tsx index 9fc23751..a71073f5 100644 --- a/src/components/NetworkPolicyCreatePage.tsx +++ b/src/components/NetworkPolicyCreatePage.tsx @@ -1,8 +1,9 @@ /** - * NetworkPolicyCreatePage - Form for creating a new network policy + * NetworkPolicyCreatePage - Form for creating or editing a network policy */ import React from "react"; import { Box, Text, useInput } from "ink"; +import figures from "figures"; import type { NetworkPolicyView } from "@runloop/api-client/resources/network-policies"; import { getClient } from "../utils/client.js"; import { SpinnerComponent } from "./Spinner.js"; @@ -19,14 +20,17 @@ import { } from "./form/index.js"; import { colors } from "../utils/theme.js"; import { useExitOnCtrlC } from "../hooks/useExitOnCtrlC.js"; +import type { NetworkPolicy } from "../store/networkPolicyStore.js"; interface NetworkPolicyCreatePageProps { onBack: () => void; onCreate?: (policy: NetworkPolicyView) => void; + /** If provided, the form will be in edit mode with pre-filled values */ + initialPolicy?: NetworkPolicy; } type FormField = - | "create" + | "submit" | "name" | "description" | "allow_all" @@ -46,17 +50,32 @@ const BOOLEAN_OPTIONS = ["Yes", "No"] as const; export const NetworkPolicyCreatePage = ({ onBack, onCreate, + initialPolicy, }: NetworkPolicyCreatePageProps) => { - const [currentField, setCurrentField] = React.useState("create"); - const [formData, setFormData] = React.useState({ - name: "", - description: "", - allow_all: "No", - allow_devbox_to_devbox: "No", - allowed_hostnames: [], + const isEditMode = !!initialPolicy; + const [currentField, setCurrentField] = React.useState("submit"); + const [formData, setFormData] = React.useState(() => { + if (initialPolicy) { + return { + name: initialPolicy.name || "", + description: initialPolicy.description || "", + allow_all: initialPolicy.egress.allow_all ? "Yes" : "No", + allow_devbox_to_devbox: initialPolicy.egress.allow_devbox_to_devbox + ? "Yes" + : "No", + allowed_hostnames: initialPolicy.egress.allowed_hostnames || [], + }; + } + return { + name: "", + description: "", + allow_all: "No", + allow_devbox_to_devbox: "No", + allowed_hostnames: [], + }; }); const [hostnamesExpanded, setHostnamesExpanded] = React.useState(false); - const [creating, setCreating] = React.useState(false); + const [submitting, setSubmitting] = React.useState(false); const [result, setResult] = React.useState(null); const [error, setError] = React.useState(null); const [validationError, setValidationError] = React.useState( @@ -68,7 +87,11 @@ export const NetworkPolicyCreatePage = ({ label: string; type: "text" | "select" | "list" | "action"; }> = [ - { key: "create", label: "Create Network Policy", type: "action" }, + { + key: "submit", + label: isEditMode ? "Update Network Policy" : "Create Network Policy", + type: "action", + }, { key: "name", label: "Name (required)", type: "text" }, { key: "description", label: "Description", type: "text" }, { key: "allow_all", label: "Allow All Egress", type: "select" }, @@ -137,8 +160,8 @@ export const NetworkPolicyCreatePage = ({ return; } - // Handle creating state - if (creating) { + // Handle submitting state + if (submitting) { return; } @@ -155,13 +178,13 @@ export const NetworkPolicyCreatePage = ({ // Submit form with Ctrl+S if (input === "s" && key.ctrl) { - handleCreate(); + handleSubmit(); return; } - // Handle Enter on create field - if (currentField === "create" && key.return) { - handleCreate(); + // Handle Enter on submit field + if (currentField === "submit" && key.return) { + handleSubmit(); return; } @@ -190,7 +213,7 @@ export const NetworkPolicyCreatePage = ({ } }); - const handleCreate = async () => { + const handleSubmit = async () => { // Validate required fields if (!formData.name.trim()) { setValidationError("Name is required"); @@ -198,44 +221,66 @@ export const NetworkPolicyCreatePage = ({ return; } - setCreating(true); + setSubmitting(true); setError(null); setValidationError(null); try { const client = getClient(); - const createParams: { - name: string; + const params: { + name?: string; description?: string; allow_all?: boolean; allow_devbox_to_devbox?: boolean; allowed_hostnames?: string[]; - } = { - name: formData.name.trim(), - }; + } = {}; + // For create, name is always required + // For update, only include if changed + if (!isEditMode || formData.name.trim() !== initialPolicy?.name) { + params.name = formData.name.trim(); + } + + // Include description if set (or if clearing in edit mode) if (formData.description.trim()) { - createParams.description = formData.description.trim(); + params.description = formData.description.trim(); + } else if (isEditMode && initialPolicy?.description) { + // Clear description if it was set before but now empty + params.description = ""; } - createParams.allow_all = formData.allow_all === "Yes"; - createParams.allow_devbox_to_devbox = + params.allow_all = formData.allow_all === "Yes"; + params.allow_devbox_to_devbox = formData.allow_devbox_to_devbox === "Yes"; - if (formData.allowed_hostnames.length > 0) { - createParams.allowed_hostnames = formData.allowed_hostnames; + // For allowed_hostnames, always send the current list + // (empty array means no hostnames allowed) + params.allowed_hostnames = formData.allowed_hostnames; + + let policy: NetworkPolicyView; + if (isEditMode && initialPolicy) { + policy = await client.networkPolicies.update(initialPolicy.id, params); + } else { + // For create, name is required + policy = await client.networkPolicies.create({ + ...params, + name: formData.name.trim(), + }); } - - const policy = await client.networkPolicies.create(createParams); setResult(policy); } catch (err) { setError(err as Error); } finally { - setCreating(false); + setSubmitting(false); } }; + const breadcrumbLabel = isEditMode + ? initialPolicy?.name || "Edit" + : "Create"; + const actionLabel = isEditMode ? "Edit" : "Create"; + // Result screen if (result) { return ( @@ -243,10 +288,12 @@ export const NetworkPolicyCreatePage = ({ - + @@ -265,8 +312,21 @@ export const NetworkPolicyCreatePage = ({ + {isEditMode && ( + + + {figures.info} Changes are eventually consistent and may take a + few moments to propagate. + + + )} ); @@ -279,10 +339,13 @@ export const NetworkPolicyCreatePage = ({ - + - + ); } @@ -314,10 +379,26 @@ export const NetworkPolicyCreatePage = ({ + {isEditMode && ( + + + {figures.warning} Note: Network policy updates are{" "} + eventually consistent. Changes may take a few + moments to propagate to all devboxes using this policy. + + + )} + {fields.map((field) => { const isActive = currentField === field.key; @@ -328,7 +409,7 @@ export const NetworkPolicyCreatePage = ({ key={field.key} label={field.label} isActive={isActive} - hint="[Enter to create]" + hint={`[Enter to ${isEditMode ? "update" : "create"}]`} /> ); } @@ -404,7 +485,7 @@ export const NetworkPolicyCreatePage = ({ diff --git a/src/screens/NetworkPolicyDetailScreen.tsx b/src/screens/NetworkPolicyDetailScreen.tsx index 4c76802c..66cb1fea 100644 --- a/src/screens/NetworkPolicyDetailScreen.tsx +++ b/src/screens/NetworkPolicyDetailScreen.tsx @@ -19,11 +19,13 @@ import { import { getNetworkPolicy, deleteNetworkPolicy, + updateNetworkPolicy, } from "../services/networkPolicyService.js"; import { SpinnerComponent } from "../components/Spinner.js"; import { ErrorMessage } from "../components/ErrorMessage.js"; import { Breadcrumb } from "../components/Breadcrumb.js"; import { ConfirmationPrompt } from "../components/ConfirmationPrompt.js"; +import { NetworkPolicyCreatePage } from "../components/NetworkPolicyCreatePage.js"; import { colors } from "../utils/theme.js"; interface NetworkPolicyDetailScreenProps { @@ -57,6 +59,7 @@ export function NetworkPolicyDetailScreen({ React.useState(null); const [deleting, setDeleting] = React.useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = React.useState(false); + const [showEditForm, setShowEditForm] = React.useState(false); // Find policy in store first const policyFromStore = networkPolicies.find((p) => p.id === networkPolicyId); @@ -210,6 +213,13 @@ export function NetworkPolicyDetailScreen({ // Operations available for network policies const operations: ResourceOperation[] = [ + { + key: "edit", + label: "Edit Network Policy", + color: colors.warning, + icon: figures.pointer, + shortcut: "e", + }, { key: "delete", label: "Delete Network Policy", @@ -225,6 +235,9 @@ export function NetworkPolicyDetailScreen({ _resource: NetworkPolicy, ) => { switch (operation) { + case "edit": + setShowEditForm(true); + break; case "delete": // Show confirmation dialog setShowDeleteConfirm(true); @@ -358,6 +371,21 @@ export function NetworkPolicyDetailScreen({ return lines; }; + // Show edit form + if (showEditForm && policy) { + return ( + setShowEditForm(false)} + onCreate={(updatedPolicy) => { + // Update the fetched policy with the new data + setFetchedPolicy(updatedPolicy as NetworkPolicy); + setShowEditForm(false); + }} + initialPolicy={policy} + /> + ); + } + // Show delete confirmation if (showDeleteConfirm && policy) { return ( diff --git a/src/services/networkPolicyService.ts b/src/services/networkPolicyService.ts index 2957ce68..6727581a 100644 --- a/src/services/networkPolicyService.ts +++ b/src/services/networkPolicyService.ts @@ -138,3 +138,35 @@ export async function createNetworkPolicy( }, }; } + +/** + * Update a network policy + */ +export interface UpdateNetworkPolicyParams { + name?: string; + description?: string; + allow_all?: boolean; + allow_devbox_to_devbox?: boolean; + allowed_hostnames?: string[]; +} + +export async function updateNetworkPolicy( + id: string, + params: UpdateNetworkPolicyParams, +): Promise { + const client = getClient(); + const policy = await client.networkPolicies.update(id, params); + + return { + id: policy.id, + name: policy.name, + description: policy.description ?? undefined, + create_time_ms: policy.create_time_ms, + update_time_ms: policy.update_time_ms, + egress: { + allow_all: policy.egress.allow_all, + allow_devbox_to_devbox: policy.egress.allow_devbox_to_devbox, + allowed_hostnames: policy.egress.allowed_hostnames || [], + }, + }; +} From d9ecc61715902380bc620577db8892b403b25263 Mon Sep 17 00:00:00 2001 From: Alexander Dines Date: Thu, 22 Jan 2026 16:36:14 -0800 Subject: [PATCH 24/24] cp dines --- src/components/NetworkPolicyCreatePage.tsx | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/components/NetworkPolicyCreatePage.tsx b/src/components/NetworkPolicyCreatePage.tsx index a71073f5..080bcf38 100644 --- a/src/components/NetworkPolicyCreatePage.tsx +++ b/src/components/NetworkPolicyCreatePage.tsx @@ -251,8 +251,7 @@ export const NetworkPolicyCreatePage = ({ } params.allow_all = formData.allow_all === "Yes"; - params.allow_devbox_to_devbox = - formData.allow_devbox_to_devbox === "Yes"; + params.allow_devbox_to_devbox = formData.allow_devbox_to_devbox === "Yes"; // For allowed_hostnames, always send the current list // (empty array means no hostnames allowed) @@ -276,9 +275,7 @@ export const NetworkPolicyCreatePage = ({ } }; - const breadcrumbLabel = isEditMode - ? initialPolicy?.name || "Edit" - : "Create"; + const breadcrumbLabel = isEditMode ? initialPolicy?.name || "Edit" : "Create"; const actionLabel = isEditMode ? "Edit" : "Create"; // Result screen