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/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..1a1647bc 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"; @@ -22,10 +23,16 @@ 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; -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 { @@ -61,6 +68,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(); @@ -75,14 +83,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 }) => { @@ -142,7 +157,11 @@ const ListBlueprintsUI = ({ pageSize: PAGE_SIZE, getItemId: (blueprint: BlueprintListItem) => blueprint.id, pollInterval: 2000, - pollingEnabled: !showPopup && !showCreateDevbox && !executingOperation, + pollingEnabled: + !showPopup && + !showCreateDevbox && + !executingOperation && + !showDeleteConfirm, deps: [PAGE_SIZE], }); @@ -159,13 +178,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 ( { 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 +366,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,9 +466,17 @@ 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 if (operationKey === "delete") { + // Show delete confirmation + setSelectedBlueprint(selectedBlueprintItem); + setShowDeleteConfirm(true); } else { setSelectedBlueprint(selectedBlueprintItem); setExecutingOperation(operationKey as OperationType); @@ -449,6 +485,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); @@ -467,10 +509,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( @@ -509,6 +551,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); @@ -543,6 +590,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 = @@ -565,11 +637,7 @@ const ListBlueprintsUI = ({ {operationError && ( )} - - - Press [Enter], [q], or [esc] to continue - - + ); } @@ -638,11 +706,14 @@ const ListBlueprintsUI = ({ placeholder={currentOp?.inputPlaceholder || ""} /> - - - Press [Enter] to execute • [q or esc] Cancel - - + ); @@ -688,22 +759,6 @@ const ListBlueprintsUI = ({ ); } - // Empty state - if (blueprints.length === 0) { - return ( - <> - - - {figures.info} - No blueprints found. Try: - - rli blueprint create - - - - ); - } - // List view return ( <> @@ -717,6 +772,11 @@ const ListBlueprintsUI = ({ selectedIndex={selectedIndex} title={`blueprints[${totalCount}]`} columns={blueprintColumns} + emptyState={ + + {figures.info} No blueprints found. Try: rli blueprint create + + } /> )} @@ -768,13 +828,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)} @@ -783,31 +845,20 @@ const ListBlueprintsUI = ({ )} {/* Help Bar */} - - - {figures.arrowUp} - {figures.arrowDown} Navigate - - {(hasMore || hasPrev) && ( - - {" "} - • {figures.arrowLeft} - {figures.arrowRight} Page - - )} - - {" "} - • [a] Actions - - - {" "} - • [o] Browser - - - {" "} - • [Esc] Back - - + ); }; diff --git a/src/commands/blueprint/prune.ts b/src/commands/blueprint/prune.ts index c070f6c4..46e90909 100644 --- a/src/commands/blueprint/prune.ts +++ b/src/commands/blueprint/prune.ts @@ -66,14 +66,10 @@ async function fetchAllBlueprintsWithName(name: string): Promise { */ 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 88b4c772..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"; @@ -154,49 +155,31 @@ 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; // 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 + + (showCreated ? timeWidth : 0) + + 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(() => { @@ -211,13 +194,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 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", @@ -260,6 +224,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", @@ -267,16 +246,12 @@ const ListDevboxesUI = ({ width: statusTextWidth, render: (devbox: Devbox, _index: number, isSelected: boolean) => { 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 ( { - 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, @@ -352,6 +320,7 @@ const ListDevboxesUI = ({ idWidth, statusTextWidth, timeWidth, + showCreated, showSource, sourceWidth, showCapabilities, @@ -707,6 +676,11 @@ const ListDevboxesUI = ({ selectedIndex={selectedIndex} title="devboxes" columns={tableColumns} + emptyState={ + + {figures.info} No devboxes found. Press [c] to create one. + + } /> )} @@ -771,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/create.ts b/src/commands/network-policy/create.ts new file mode 100644 index 00000000..461a9524 --- /dev/null +++ b/src/commands/network-policy/create.ts @@ -0,0 +1,38 @@ +/** + * Create network policy command + */ + +import { getClient } from "../../utils/client.js"; +import { output, outputError } from "../../utils/output.js"; + +interface CreateOptions { + name: string; + description?: string; + allowAll?: boolean; + allowDevboxToDevbox?: boolean; + allowedHostnames?: string[]; + output?: string; +} + +export async function createNetworkPolicy(options: CreateOptions) { + try { + const client = getClient(); + + const policy = await client.networkPolicies.create({ + name: options.name, + description: options.description, + allow_all: options.allowAll ?? false, + allow_devbox_to_devbox: options.allowDevboxToDevbox ?? false, + allowed_hostnames: options.allowedHostnames ?? [], + }); + + // Default: just output the ID for easy scripting + if (!options.output || options.output === "text") { + console.log(policy.id); + } else { + output(policy, { format: options.output, defaultFormat: "json" }); + } + } catch (error) { + outputError("Failed to create network policy", error); + } +} diff --git a/src/commands/network-policy/delete.ts b/src/commands/network-policy/delete.ts new file mode 100644 index 00000000..e966cb1b --- /dev/null +++ b/src/commands/network-policy/delete.ts @@ -0,0 +1,32 @@ +/** + * Delete network policy command + */ + +import { getClient } from "../../utils/client.js"; +import { output, outputError } from "../../utils/output.js"; + +interface DeleteOptions { + output?: string; +} + +export async function deleteNetworkPolicy( + id: string, + options: DeleteOptions = {}, +) { + try { + const client = getClient(); + await client.networkPolicies.delete(id); + + // Default: just output the ID for easy scripting + if (!options.output || options.output === "text") { + console.log(id); + } else { + output( + { id, status: "deleted" }, + { format: options.output, defaultFormat: "json" }, + ); + } + } catch (error) { + outputError("Failed to delete network policy", error); + } +} diff --git a/src/commands/network-policy/get.ts b/src/commands/network-policy/get.ts new file mode 100644 index 00000000..ad702911 --- /dev/null +++ b/src/commands/network-policy/get.ts @@ -0,0 +1,21 @@ +/** + * Get network policy details command + */ + +import { getClient } from "../../utils/client.js"; +import { output, outputError } from "../../utils/output.js"; + +interface GetNetworkPolicyOptions { + id: string; + output?: string; +} + +export async function getNetworkPolicy(options: GetNetworkPolicyOptions) { + try { + const client = getClient(); + const policy = await client.networkPolicies.retrieve(options.id); + output(policy, { format: options.output, defaultFormat: "json" }); + } catch (error) { + outputError("Failed to get network policy", error); + } +} diff --git a/src/commands/network-policy/list.tsx b/src/commands/network-policy/list.tsx new file mode 100644 index 00000000..a6008f8a --- /dev/null +++ b/src/commands/network-policy/list.tsx @@ -0,0 +1,748 @@ +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 { NavigationTips } from "../../components/NavigationTips.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"; +import { ConfirmationPrompt } from "../../components/ConfirmationPrompt.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); + const [showEditPolicy, setShowEditPolicy] = React.useState(false); + const [editingPolicy, setEditingPolicy] = + React.useState(null); + const [showDeleteConfirm, setShowDeleteConfirm] = 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 fixedWidth = 6; // border + padding + const idWidth = 25; + const egressWidth = 15; + const timeWidth = 20; + 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( + 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, + refresh, + } = useCursorPagination({ + fetchPage, + pageSize: PAGE_SIZE, + getItemId: (policy: NetworkPolicyListItem) => policy.id, + pollInterval: 5000, + pollingEnabled: + !showPopup && + !executingOperation && + !showCreatePolicy && + !showEditPolicy && + !showDeleteConfirm, + 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: "edit", + label: "Edit Network Policy", + color: colors.warning, + 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 ( + policy: NetworkPolicyListItem, + operationKey: string, + ) => { + const client = getClient(); + + if (!policy) return; + + try { + setOperationLoading(true); + switch (operationKey) { + 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) { + 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; + } + + // Handle create policy screen + if (showCreatePolicy) { + return; + } + + // Handle edit policy screen + if (showEditPolicy) { + 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 if (operationKey === "edit") { + // Show edit form + setEditingPolicy(selectedPolicyItem); + setShowEditPolicy(true); + } else if (operationKey === "delete") { + // Show delete confirmation + setSelectedPolicy(selectedPolicyItem); + setShowDeleteConfirm(true); + } else { + setSelectedPolicy(selectedPolicyItem); + setExecutingOperation(operationKey); + // Execute immediately with values passed directly + executeOperation(selectedPolicyItem, operationKey); + } + } 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 (input === "e" && selectedPolicyItem) { + // Edit hotkey + setShowPopup(false); + setEditingPolicy(selectedPolicyItem); + setShowEditPolicy(true); + } else if (key.escape || input === "q") { + setShowPopup(false); + setSelectedOperation(0); + } else if (input === "d") { + // Delete hotkey - show confirmation + setShowPopup(false); + setSelectedPolicy(selectedPolicyItem); + setShowDeleteConfirm(true); + } + 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 (input === "e" && selectedPolicyItem) { + // Edit shortcut + setEditingPolicy(selectedPolicyItem); + setShowEditPolicy(true); + } else if (key.escape) { + if (onBack) { + onBack(); + } else if (onExit) { + onExit(); + } else { + inkExit(); + } + } + }); + + // 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 = + operations.find((o) => o.key === executingOperation)?.label || + "Operation"; + return ( + <> + +
+ {operationResult && } + {operationError && ( + + )} + + + ); + } + + // 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 }); + }} + /> + ); + } + + // 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 ( + <> + + + + ); + } + + // 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 === "edit" + ? "e" + : op.key === "delete" + ? "d" + : "", + }))} + selectedOperation={selectedOperation} + onClose={() => setShowPopup(false)} + /> + + )} + + {/* Help Bar */} + + + ); +}; + +// 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/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..1f12b3f2 --- /dev/null +++ b/src/commands/object/list.tsx @@ -0,0 +1,798 @@ +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"; +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"; +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"; +import { ConfirmationPrompt } from "../../components/ConfirmationPrompt.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; + delete_after_time_ms?: number | null; + [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); + 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; + const { viewportHeight, terminalWidth } = useViewportHeight({ + overhead, + minHeight: 5, + }); + + const PAGE_SIZE = viewportHeight; + + // All width constants + const fixedWidth = 6; // border + padding + const idWidth = 25; + const stateWidth = 12; + const typeWidth = 15; + const sizeWidth = 12; + const timeWidth = 20; + const ttlWidth = 14; + const showTypeColumn = terminalWidth >= 100; + const showSizeColumn = terminalWidth >= 90; + const showTtlColumn = terminalWidth >= 80; + + // 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 ""; + 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( + 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, + delete_after_time_ms: obj.delete_after_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, + refresh, + } = useCursorPagination({ + fetchPage, + pageSize: PAGE_SIZE, + getItemId: (obj: ObjectListItem) => obj.id, + pollInterval: 2000, + pollingEnabled: + !showPopup && + !executingOperation && + !showDownloadPrompt && + !showDeleteConfirm, + 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", + color: colors.success, + icon: figures.arrowDown, + }, + { + key: "delete", + label: "Delete", + 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( + "state", + "State", + (obj: ObjectListItem) => obj.state || "", + { + width: stateWidth, + color: colors.warning, + dimColor: false, + bold: false, + }, + ), + 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, + }, + ), + createTextColumn( + "ttl", + "Expires", + (obj: ObjectListItem) => formatTtl(obj.delete_after_time_ms), + { + width: ttlWidth, + color: colors.warning, + dimColor: false, + bold: false, + visible: showTtlColumn, + }, + ), + ], + [ + idWidth, + nameWidth, + stateWidth, + typeWidth, + sizeWidth, + timeWidth, + ttlWidth, + showTypeColumn, + showSizeColumn, + showTtlColumn, + formatTtl, + ], + ); + + // 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 ( + obj: ObjectListItem, + operationKey: string, + targetPath?: string, + ) => { + const client = getClient(); + + if (!obj) return; + + try { + setOperationLoading(true); + switch (operationKey) { + case "delete": + await client.objects.delete(obj.id); + setOperationResult(`Storage object ${obj.id} deleted successfully`); + break; + case "download": { + 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; + } + } + } catch (err) { + setOperationError(err as Error); + } finally { + setOperationLoading(false); + } + }; + + // 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; + } + + // 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 if (operationKey === "download") { + // Show download prompt + setSelectedObject(selectedObjectItem); + 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); + // Execute immediately with the object and operation passed directly + executeOperation(selectedObjectItem, operationKey); + } + } 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 - show prompt + setShowPopup(false); + setSelectedObject(selectedObjectItem); + const defaultName = selectedObjectItem.name || selectedObjectItem.id; + setDownloadPath(`./${defaultName}`); + setShowDownloadPrompt(true); + } else if (input === "d") { + // Delete hotkey - show confirmation + setShowPopup(false); + setSelectedObject(selectedObjectItem); + setShowDeleteConfirm(true); + } + 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 && ( + + )} + + + ); + } + + // Download prompt + if (showDownloadPrompt && selectedObject) { + return ( + <> + +
+ + + {figures.arrowRight} Downloading:{" "} + + {selectedObject.name || selectedObject.id} + + + {selectedObject.size_bytes && ( + + {figures.info} Size: {formatFileSize(selectedObject.size_bytes)} + + )} + + + Save to path: + + {figures.pointer} + + + + + + ); + } + + // 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 = + operations.find((o) => o.key === executingOperation)?.label || + "Operation"; + const messages: Record = { + delete: "Deleting storage object...", + download: "Downloading...", + }; + 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={`storage_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 */} + + + ); +}; + +// 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 storage objects", error); + } +} diff --git a/src/commands/snapshot/list.tsx b/src/commands/snapshot/list.tsx index 32fdeb59..218ddaa8 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"; @@ -19,6 +20,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 +68,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; @@ -77,12 +80,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 }) => { @@ -141,18 +150,29 @@ const ListSnapshotsUI = ({ totalCount, nextPage, prevPage, + refresh, } = useCursorPagination({ fetchPage, pageSize: PAGE_SIZE, getItemId: (snapshot: SnapshotListItem) => snapshot.id, pollInterval: 2000, - pollingEnabled: !showPopup && !executingOperation && !showCreateDevbox, + pollingEnabled: + !showPopup && + !executingOperation && + !showCreateDevbox && + !showDeleteConfirm, deps: [devboxId, PAGE_SIZE], }); // 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", @@ -236,15 +256,17 @@ 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`); @@ -261,10 +283,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; } @@ -284,15 +312,29 @@ 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 if (operationKey === "delete") { + // Show delete confirmation + setSelectedSnapshot(selectedSnapshotItem); + setShowDeleteConfirm(true); } 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 + setShowPopup(false); + navigate("snapshot-detail", { + snapshotId: selectedSnapshotItem.id, + }); } else if (key.escape || input === "q") { setShowPopup(false); setSelectedOperation(0); @@ -302,11 +344,10 @@ const ListSnapshotsUI = ({ setSelectedSnapshot(selectedSnapshotItem); setShowCreateDevbox(true); } else if (input === "d") { - // Delete hotkey + // Delete hotkey - show confirmation setShowPopup(false); setSelectedSnapshot(selectedSnapshotItem); - setExecutingOperation("delete"); - setTimeout(() => executeOperation(), 0); + setShowDeleteConfirm(true); } return; } @@ -334,6 +375,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); @@ -370,15 +416,36 @@ const ListSnapshotsUI = ({ {operationError && ( )} - - - Press [Enter], [q], or [esc] to continue - - + ); } + // 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 = @@ -456,29 +523,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 +541,12 @@ const ListSnapshotsUI = ({ selectedIndex={selectedIndex} title={`snapshots[${totalCount}]`} columns={columns} + emptyState={ + + {figures.info} No snapshots found. Try: rli snapshot create{" "} + {""} + + } /> )} @@ -548,11 +598,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)} @@ -561,27 +613,19 @@ const ListSnapshotsUI = ({ )} {/* Help Bar */} - - - {figures.arrowUp} - {figures.arrowDown} Navigate - - {(hasMore || hasPrev) && ( - - {" "} - • {figures.arrowLeft} - {figures.arrowRight} Page - - )} - - {" "} - • [a] Actions - - - {" "} - • [Esc] Back - - + ); }; diff --git a/src/components/Banner.tsx b/src/components/Banner.tsx index c949d869..5ed25c3e 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,67 @@ 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)]); +}; + +// 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 = 400; + 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/components/ConfirmationPrompt.tsx b/src/components/ConfirmationPrompt.tsx new file mode 100644 index 00000000..28a9ef03 --- /dev/null +++ b/src/components/ConfirmationPrompt.tsx @@ -0,0 +1,137 @@ +/** + * 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 { NavigationTips } from "./NavigationTips.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] + + + + + + + + ); +}; diff --git a/src/components/DevboxActionsMenu.tsx b/src/components/DevboxActionsMenu.tsx index a0fd0940..2cfa5871 100644 --- a/src/components/DevboxActionsMenu.tsx +++ b/src/components/DevboxActionsMenu.tsx @@ -7,6 +7,8 @@ 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"; import { useNavigation } from "../store/navigationStore.js"; @@ -73,6 +75,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 +205,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 +216,15 @@ export const DevboxActionsMenu = ({ ) { executeOperation(); } + // Show confirmation for delete + if ( + executingOperation === "delete" && + !loading && + devbox && + !showDeleteConfirm + ) { + setShowDeleteConfirm(true); + } }, [executingOperation]); // Handle Ctrl+C to exit @@ -516,6 +528,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 @@ -674,13 +712,15 @@ export const DevboxActionsMenu = ({ {/* Help bar */} - - - {figures.arrowUp} - {figures.arrowDown} Navigate • [g] Top • [G] Bottom • [c] Copy • - [Enter], [q], or [esc] Back - - + ); } @@ -728,11 +768,7 @@ export const DevboxActionsMenu = ({ {operationError && ( )} - - - Press [Enter], [q], or [esc] to continue - - + ); } @@ -826,11 +862,12 @@ export const DevboxActionsMenu = ({ } /> - - - Press [Enter] to execute • [q or esc] Cancel - - + ); @@ -869,12 +906,14 @@ export const DevboxActionsMenu = ({ - - - {figures.arrowUp} - {figures.arrowDown} Navigate • [Enter] Select • [q] Back - - + ); } 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/DevboxCreatePage.tsx b/src/components/DevboxCreatePage.tsx index a1a98bf1..b830de2e 100644 --- a/src/components/DevboxCreatePage.tsx +++ b/src/components/DevboxCreatePage.tsx @@ -12,7 +12,14 @@ 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, + FormSelect, + FormActionButton, + useFormSelectNavigation, +} from "./form/index.js"; import { colors } from "../utils/theme.js"; import { useExitOnCtrlC } from "../hooks/useExitOnCtrlC.js"; @@ -34,7 +41,8 @@ type FormField = | "keep_alive" | "metadata" | "blueprint_id" - | "snapshot_id"; + | "snapshot_id" + | "network_policy_id"; interface FormData { name: string; @@ -55,8 +63,20 @@ interface FormData { metadata: Record; blueprint_id: string; snapshot_id: string; + network_policy_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, @@ -75,6 +95,7 @@ export const DevboxCreatePage = ({ metadata: {}, blueprint_id: initialBlueprintId || "", snapshot_id: initialSnapshotId || "", + network_policy_id: "", }); const [metadataKey, setMetadataKey] = React.useState(""); const [metadataValue, setMetadataValue] = React.useState(""); @@ -82,7 +103,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 +112,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 +125,28 @@ 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_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_disk", label: "Disk GB (2-64, even)", type: "text" }, ] : []; @@ -120,31 +154,57 @@ 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: "network_policy_id", + label: "Network Policy ID (optional)", + type: "text", + placeholder: "np_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 +234,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,16 +334,38 @@ 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) { + // 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; } @@ -312,35 +376,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 @@ -442,6 +477,10 @@ export const DevboxCreatePage = ({ createParams.snapshot_id = formData.snapshot_id; } + if (formData.network_policy_id) { + launchParameters.network_policy_id = formData.network_policy_id; + } + if (Object.keys(launchParameters).length > 0) { createParams.launch_parameters = launchParameters; } @@ -481,11 +520,9 @@ export const DevboxCreatePage = ({ - - - Press [Enter], [q], or [esc] to return to list - - + ); } @@ -498,11 +535,12 @@ export const DevboxCreatePage = ({ items={[{ label: "Devboxes" }, { label: "Create", active: true }]} /> - - - Press [Enter] or [r] to retry • [q] or [esc] to cancel - - + ); } @@ -533,78 +571,45 @@ 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} + /> ); } @@ -840,12 +845,13 @@ export const DevboxCreatePage = ({ )} {!inMetadataSection && ( - - - {figures.arrowUp} - {figures.arrowDown} Navigate • [Enter] Create • [q] Cancel - - + )} ); diff --git a/src/components/DevboxDetailPage.tsx b/src/components/DevboxDetailPage.tsx index 829f64e5..45b3c472 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,214 @@ 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); - } + // Network Policy + if (lp?.network_policy_id) { + detailFields.push({ + label: "Network Policy", + value: {lp.network_policy_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}"`; - } + // Initiator + if (devbox.initiator_id) { + detailFields.push({ + label: "Initiator", + value: {devbox.initiator_id}, + }); + } - exec(openCommand); - }; - openBrowser(); + // 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(", "), + }); } - }); - const uptime = selectedDevbox.create_time_ms - ? Math.floor((Date.now() - selectedDevbox.create_time_ms) / 1000 / 60) - : null; + if (detailFields.length > 0) { + sections.push({ + title: "Details", + icon: figures.squareSmallFilled, + color: colors.warning, + fields: detailFields, + }); + } - // Build detailed info lines for scrolling - const buildDetailLines = (): React.ReactElement[] => { - const lines: React.ReactElement[] = []; + // 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, + })), + }); + } + + // 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 +342,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 +394,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 +448,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 +476,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 +503,7 @@ export const DevboxDetailPage = ({ if (lp.user_parameters.username) { lines.push( - {" "} + {" "} Username: {lp.user_parameters.username} , ); @@ -475,7 +511,7 @@ export const DevboxDetailPage = ({ if (lp.user_parameters.uid) { lines.push( - {" "} + {" "} UID: {lp.user_parameters.uid} , ); @@ -485,25 +521,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 +547,7 @@ export const DevboxDetailPage = ({ } // Initiator - if (selectedDevbox.initiator_type) { + if (devbox.initiator_type) { lines.push( Initiator @@ -520,14 +556,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 +571,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 +597,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 +615,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 +644,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 +657,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/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 115c2603..e4a43484 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"; @@ -40,6 +41,20 @@ const menuItems: MenuItem[] = [ icon: "◈", color: colors.accent3, }, + { + key: "objects", + label: "Storage Objects", + description: "Manage files and data in cloud storage", + icon: "▤", + color: colors.secondary, + }, + { + key: "network-policies", + label: "Network Policies", + description: "Manage egress network access rules", + icon: "◇", + color: colors.info, + }, ]; interface MainMenuProps { @@ -74,6 +89,10 @@ export const MainMenu = ({ onSelect }: MainMenuProps) => { onSelect("blueprints"); } else if (input === "s" || input === "3") { onSelect("snapshots"); + } 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", [ @@ -89,6 +108,11 @@ export const MainMenu = ({ onSelect }: MainMenuProps) => { if (useCompactLayout) { return ( + + RUNLOOP.ai @@ -131,14 +155,16 @@ export const MainMenu = ({ onSelect }: MainMenuProps) => { })} - - - {figures.arrowUp} - {figures.arrowDown} Navigate • [1-3] Quick select • [Enter] Select • - [Esc] Quit - {updateAvailable && " • [u] Update"} - - + ); } @@ -160,7 +186,7 @@ export const MainMenu = ({ onSelect }: MainMenuProps) => { - + Select a resource: @@ -210,16 +236,15 @@ export const MainMenu = ({ onSelect }: MainMenuProps) => { })} - - - - {figures.arrowUp} - {figures.arrowDown} Navigate • [1-3] 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 new file mode 100644 index 00000000..080bcf38 --- /dev/null +++ b/src/components/NetworkPolicyCreatePage.tsx @@ -0,0 +1,492 @@ +/** + * 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"; +import { ErrorMessage } from "./ErrorMessage.js"; +import { SuccessMessage } from "./SuccessMessage.js"; +import { Breadcrumb } from "./Breadcrumb.js"; +import { NavigationTips } from "./NavigationTips.js"; +import { + FormTextInput, + FormSelect, + FormActionButton, + FormListManager, + useFormSelectNavigation, +} 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 = + | "submit" + | "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, + initialPolicy, +}: NetworkPolicyCreatePageProps) => { + 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 [submitting, setSubmitting] = 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: "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" }, + { + 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 submitting state + if (submitting) { + 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) { + handleSubmit(); + return; + } + + // Handle Enter on submit field + if (currentField === "submit" && key.return) { + handleSubmit(); + 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 (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 || (key.tab && !key.shift)) && + currentFieldIndex < fields.length - 1 + ) { + setCurrentField(fields[currentFieldIndex + 1].key); + return; + } + }); + + const handleSubmit = async () => { + // Validate required fields + if (!formData.name.trim()) { + setValidationError("Name is required"); + setCurrentField("name"); + return; + } + + setSubmitting(true); + setError(null); + setValidationError(null); + + try { + const client = getClient(); + + const params: { + name?: string; + description?: string; + allow_all?: boolean; + allow_devbox_to_devbox?: boolean; + allowed_hostnames?: string[]; + } = {}; + + // 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()) { + params.description = formData.description.trim(); + } else if (isEditMode && initialPolicy?.description) { + // Clear description if it was set before but now empty + params.description = ""; + } + + params.allow_all = formData.allow_all === "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) + 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(), + }); + } + setResult(policy); + } catch (err) { + setError(err as Error); + } finally { + setSubmitting(false); + } + }; + + const breadcrumbLabel = isEditMode ? initialPolicy?.name || "Edit" : "Create"; + const actionLabel = isEditMode ? "Edit" : "Create"; + + // Result screen + if (result) { + return ( + <> + + + + + + ID:{" "} + + {result.id} + + + + Name: {result.name} + + + + + Allow All: {result.egress.allow_all ? "Yes" : "No"} + + + + {isEditMode && ( + + + {figures.info} Changes are eventually consistent and may take a + few moments to propagate. + + + )} + + + ); + } + + // Error screen + if (error) { + return ( + <> + + + + + ); + } + + // Submitting screen + if (submitting) { + return ( + <> + + + + ); + } + + // Form screen + return ( + <> + + + {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; + + 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 && ( + + )} + + ); +}; 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 new file mode 100644 index 00000000..2c7e3d2e --- /dev/null +++ b/src/components/ResourceDetailPage.tsx @@ -0,0 +1,418 @@ +/** + * 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 { NavigationTips } from "./NavigationTips.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}] + + + ); + })} + + + )} + + + + ); +} + +// 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/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" }, + ]} + /> ); } 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 9dc0d433..dc7a2467 100644 --- a/src/components/Table.tsx +++ b/src/components/Table.tsx @@ -48,18 +48,16 @@ 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); return ( - + {/* Title bar (if provided) */} {title && ( @@ -72,12 +70,13 @@ export function Table({ {/* Header row */} - + {/* Space for selection pointer */} {showSelection && ( <> @@ -96,15 +95,36 @@ export function Table({ ); })} + {/* Spacer to fill remaining width */} + + {/* Empty state row */} + {isEmpty && ( + + {showSelection && ( + <> + + + + )} + {emptyState || ( + + {figures.info} No items found + + )} + {/* Spacer to fill remaining width */} + + + )} + {/* Data rows */} {data.map((row, index) => { const isSelected = index === selectedIndex; const rowKey = keyExtractor(row); return ( - + {/* Selection pointer */} {showSelection && ( <> @@ -121,6 +141,8 @@ export function Table({ {column.render(row, index, isSelected)} ))} + {/* Spacer to fill remaining width */} + ); })} 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..f953e3d9 --- /dev/null +++ b/src/components/form/FormField.tsx @@ -0,0 +1,48 @@ +/** + * 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..a79a6325 --- /dev/null +++ b/src/components/form/FormListManager.tsx @@ -0,0 +1,274 @@ +/** + * 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..be2d933f --- /dev/null +++ b/src/components/form/FormTextInput.tsx @@ -0,0 +1,42 @@ +/** + * 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/hooks/useViewportHeight.ts b/src/hooks/useViewportHeight.ts index 9aa7f8fe..e322b01f 100644 --- a/src/hooks/useViewportHeight.ts +++ b/src/hooks/useViewportHeight.ts @@ -19,9 +19,29 @@ interface ViewportDimensions { terminalWidth: number; } +/** + * Get safe terminal dimensions with bounds checking + */ +function getSafeDimensions( + stdout: { columns?: number; rows?: number } | 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 +58,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/router/Router.tsx b/src/router/Router.tsx index c9de0025..cd63f0cb 100644 --- a/src/router/Router.tsx +++ b/src/router/Router.tsx @@ -7,6 +7,8 @@ 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 { useObjectStore } from "../store/objectStore.js"; import { ErrorBoundary } from "../components/ErrorBoundary.js"; import type { ScreenName } from "../router/types.js"; @@ -17,8 +19,15 @@ 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 { ObjectListScreen } from "../screens/ObjectListScreen.js"; +import { ObjectDetailScreen } from "../screens/ObjectDetailScreen.js"; import { SSHSessionScreen } from "../screens/SSHSessionScreen.js"; /** @@ -64,6 +73,21 @@ 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; + + case "object-list": + case "object-detail": + if (!currentScreen.startsWith("object")) { + useObjectStore.getState().clearAll(); + } + break; } } @@ -95,7 +119,7 @@ export function Router() { )} {currentScreen === "blueprint-detail" && ( - + )} {currentScreen === "blueprint-logs" && ( @@ -104,7 +128,22 @@ export function Router() { )} {currentScreen === "snapshot-detail" && ( - + + )} + {currentScreen === "network-policy-list" && ( + + )} + {currentScreen === "network-policy-detail" && ( + + )} + {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 new file mode 100644 index 00000000..5928c7d2 --- /dev/null +++ b/src/screens/BlueprintDetailScreen.tsx @@ -0,0 +1,546 @@ +/** + * 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 { ConfirmationPrompt } from "../components/ConfirmationPrompt.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); + const [showDeleteConfirm, setShowDeleteConfirm] = 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": + // 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 ( + <> + + + + ); + } + + // 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..6ea22e4c 100644 --- a/src/screens/MenuScreen.tsx +++ b/src/screens/MenuScreen.tsx @@ -19,6 +19,12 @@ export function MenuScreen() { case "snapshots": navigate("snapshot-list"); break; + 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/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..66cb1fea --- /dev/null +++ b/src/screens/NetworkPolicyDetailScreen.tsx @@ -0,0 +1,437 @@ +/** + * 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, + 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 { + 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); + 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); + + // 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: "edit", + label: "Edit Network Policy", + color: colors.warning, + icon: figures.pointer, + shortcut: "e", + }, + { + 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 "edit": + setShowEditForm(true); + break; + case "delete": + // 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[] = []; + + // 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 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 ( + setShowDeleteConfirm(false)} + /> + ); + } + + // 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/ObjectDetailScreen.tsx b/src/screens/ObjectDetailScreen.tsx new file mode 100644 index 00000000..0b278b34 --- /dev/null +++ b/src/screens/ObjectDetailScreen.tsx @@ -0,0 +1,630 @@ +/** + * ObjectDetailScreen - Detail page for storage objects + * Uses the generic ResourceDetailPage component + */ +import React from "react"; +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 StorageObjectView, +} from "../store/objectStore.js"; +import { getClient } from "../utils/client.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 { 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 { + 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); + 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); + const [showDeleteConfirm, setShowDeleteConfirm] = 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 StorageObjectView; + 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; + + // 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 ( + <> + + + + ); + } + + // 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), + }); + } + + // 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", + 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", + color: colors.success, + icon: figures.arrowDown, + shortcut: "w", + }, + { + key: "delete", + label: "Delete", + color: colors.error, + icon: figures.cross, + shortcut: "d", + }, + ]; + + // Handle operation selection + const handleOperation = async ( + operation: string, + resource: StorageObjectView, + ) => { + switch (operation) { + case "download": + // Show download prompt + const defaultName = resource.name || resource.id; + setDownloadPath(`./${defaultName}`); + setShowDownloadPrompt(true); + break; + case "delete": + // 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: StorageObjectView): React.ReactElement[] => { + const lines: React.ReactElement[] = []; + + // Core Information + lines.push( + + Storage 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()} + , + ); + } + 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 + 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 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 delete confirmation + if (showDeleteConfirm && storageObject) { + return ( + setShowDeleteConfirm(false)} + /> + ); + } + + // 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 new file mode 100644 index 00000000..d9fe9d0d --- /dev/null +++ b/src/screens/SnapshotDetailScreen.tsx @@ -0,0 +1,351 @@ +/** + * 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 { ConfirmationPrompt } from "../components/ConfirmationPrompt.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); + const [showDeleteConfirm, setShowDeleteConfirm] = 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": + // 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[] = []; + + // 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 delete confirmation + if (showDeleteConfirm && snapshot) { + return ( + setShowDeleteConfirm(false)} + /> + ); + } + + // Show deleting state + if (deleting) { + return ( + <> + + + + ); + } + + return ( + snap.name || snap.id} + getId={(snap) => snap.id} + getStatus={(snap) => snap.status || "unknown"} + 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..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,17 +93,45 @@ export async function getBlueprint(id: string): Promise { const client = getClient(); const blueprint = await client.blueprints.retrieve(id); + // 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, - architecture: (blueprint as any).architecture, - resources: (blueprint as any).resources, + // Spread all API fields + ...blueprint, + // UI-specific convenience fields + architecture: launchParams?.architecture ?? undefined, + resources: launchParams?.resource_size_request ?? 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..6727581a --- /dev/null +++ b/src/services/networkPolicyService.ts @@ -0,0 +1,172 @@ +/** + * 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 || [], + }, + }; +} + +/** + * 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 || [], + }, + }; +} diff --git a/src/services/objectService.ts b/src/services/objectService.ts new file mode 100644 index 00000000..2195cb22 --- /dev/null +++ b/src/services/objectService.ts @@ -0,0 +1,138 @@ +/** + * Object Service - Handles all storage object API calls + */ +import { getClient } from "../utils/client.js"; +import type { StorageObjectView } from "../store/objectStore.js"; + +export interface ListObjectsOptions { + limit: number; + startingAfter?: string; + name?: string; + contentType?: string; + state?: string; + isPublic?: boolean; +} + +export interface ListObjectsResult { + objects: StorageObjectView[]; + 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: StorageObjectView[] = []; + + 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: 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, + delete_after_time_ms: obj.delete_after_time_ms, + // UI-specific extended fields + is_public: obj.is_public, + }); + }); + } + + // 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 || "", + content_type: obj.content_type || "unspecified", + create_time_ms: obj.create_time_ms || 0, + state: obj.state || "UPLOADING", + size_bytes: obj.size_bytes, + delete_after_time_ms: obj.delete_after_time_ms, + // UI-specific extended fields + is_public: obj.is_public, + 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 | null | 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/services/snapshotService.ts b/src/services/snapshotService.ts index 6fe6b1e5..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, }); }); } @@ -92,6 +98,40 @@ 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, + create_time_ms: Date.now(), + metadata: {}, + source_devbox_id: "", + status: operationStatus === "in_progress" ? "pending" : operationStatus, + }; + } + + 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 a snapshot */ @@ -107,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 dea58e1d..4319299b 100644 --- a/src/store/blueprintStore.ts +++ b/src/store/blueprintStore.ts @@ -2,17 +2,22 @@ * Blueprint Store - Manages blueprint list state, pagination, and caching */ import { create } from "zustand"; - -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; } +// Re-export for compatibility with existing code +export type BlueprintParameters = BlueprintBuildParameters; + interface BlueprintState { // List data blueprints: Blueprint[]; @@ -104,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/index.ts b/src/store/index.ts index f049d721..dd2eb0df 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 { StorageObjectView } from "./objectStore.js"; diff --git a/src/store/navigationStore.tsx b/src/store/navigationStore.tsx index 5e31870a..959b66f0 100644 --- a/src/store/navigationStore.tsx +++ b/src/store/navigationStore.tsx @@ -11,6 +11,11 @@ export type ScreenName = | "blueprint-logs" | "snapshot-list" | "snapshot-detail" + | "network-policy-list" + | "network-policy-detail" + | "network-policy-create" + | "object-list" + | "object-detail" | "ssh-session"; export interface RouteParams { @@ -18,6 +23,8 @@ export interface RouteParams { blueprintId?: string; blueprintName?: string; snapshotId?: string; + networkPolicyId?: string; + objectId?: 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..513244d9 --- /dev/null +++ b/src/store/networkPolicyStore.ts @@ -0,0 +1,152 @@ +/** + * 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"; + +// Re-export for compatibility with existing code +export type NetworkPolicy = NetworkPolicyView; +export type NetworkPolicyEgress = NetworkPolicyView.Egress; + +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); + } + } + + // 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); + + 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/objectStore.ts b/src/store/objectStore.ts new file mode 100644 index 00000000..6e28881a --- /dev/null +++ b/src/store/objectStore.ts @@ -0,0 +1,179 @@ +/** + * Object Store - Manages storage object list state, pagination, and caching + */ +import { create } from "zustand"; +import type { ObjectView } from "@runloop/api-client/resources/objects"; + +// 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 + metadata?: Record; + // Public visibility flag + is_public?: boolean; +} + +interface ObjectState { + // List data + objects: StorageObjectView[]; + 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: StorageObjectView[]) => 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: StorageObjectView[], + lastId: string, + ) => void; + getCachedPage: (page: number) => StorageObjectView[] | undefined; + clearCache: () => void; + clearAll: () => void; + + getSelectedObject: () => StorageObjectView | 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); + } + } + + // Deep copy all fields to avoid SDK references + const plainData = data.map((d) => { + return JSON.parse(JSON.stringify(d)) as StorageObjectView; + }); + + 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]; + }, +})); diff --git a/src/store/snapshotStore.ts b/src/store/snapshotStore.ts index 99a38760..bd5fc88d 100644 --- a/src/store/snapshotStore.ts +++ b/src/store/snapshotStore.ts @@ -2,13 +2,17 @@ * 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; + // Computed status from async status query (not a real API field) + status?: string; + // Optional disk size when available + disk_size_bytes?: number; } interface SnapshotState { @@ -102,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") 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', () => {