diff --git a/src/lib/buy/index.tsx b/src/lib/buy/index.tsx index 0512ace3..ffc65d47 100644 --- a/src/lib/buy/index.tsx +++ b/src/lib/buy/index.tsx @@ -746,7 +746,9 @@ export async function placeBuyOrder(options: { return data; } -export function getPricePerGpuHourFromQuote(quote: NonNullable) { +export function getPricePerGpuHourFromQuote( + quote: Pick, "start_at" | "end_at" | "price" | "quantity">, +) { const startTimeOrNow = parseStartDateOrNow(quote.start_at); // from the market's perspective, "NOW" means at the beginning of the next minute. diff --git a/src/lib/nodes/extend.ts b/src/lib/nodes/extend.ts index 342434c7..3697d749 100644 --- a/src/lib/nodes/extend.ts +++ b/src/lib/nodes/extend.ts @@ -16,7 +16,8 @@ import { import SFCNodes from "@sfcompute/nodes-sdk-alpha"; import { getPricePerGpuHourFromQuote, getQuote } from "../buy/index.tsx"; import { GPUS_PER_NODE } from "../constants.ts"; -import { logAndQuit } from "../../helpers/errors.ts"; +import { formatDuration } from "date-fns/formatDuration"; +import { intervalToDuration } from "date-fns/intervalToDuration"; const extend = new Command("extend") .description("Extend the duration of reserved nodes and update their pricing") @@ -58,17 +59,19 @@ async function extendNodeAction( try { const client = await nodesClient(); - // Fetch all nodes and filter by provided names/IDs - const fetchSpinner = ora().start(); - const { data: allNodes } = await client.nodes.list(); + // Use the API's names parameter to filter nodes directly + const fetchSpinner = ora().start( + `Checking ${nodeNames.length} ${pluralizeNodes(nodeNames.length)}...`, + ); + const { data: fetchedNodes } = await client.nodes.list({ name: nodeNames }); fetchSpinner.stop(); - // Filter nodes that match the provided names/IDs + // Check which names were not found const nodes: { name: string; node: SFCNodes.Node }[] = []; const notFound: string[] = []; for (const nameOrId of nodeNames) { - const node = allNodes.find((n) => + const node = fetchedNodes.find((n) => n.name === nameOrId || n.id === nameOrId ); if (node) { @@ -124,6 +127,16 @@ async function extendNodeAction( process.exit(1); } + const formattedDuration = formatDuration( + intervalToDuration({ + start: 0, + end: options.duration! * 1000, + }), + { + delimiter: ", ", + }, + ); + // Only show pricing and get confirmation if not using --yes if (!options.yes) { // Get quote for accurate pricing preview @@ -144,30 +157,46 @@ async function extendNodeAction( durationSeconds + Math.ceil(durationSeconds * 0.1), ); - const quote = await getQuote({ - instanceType: "h100v", - quantity: extendableNodes.length, - minStartTime: "NOW", - maxStartTime: "NOW", - minDurationSeconds: minDurationSeconds, - maxDurationSeconds: maxDurationSeconds, - }); + const quotes = await Promise.allSettled( + extendableNodes.map(async ({ node }) => { + return await getQuote({ + instanceType: `${node.gpu_type.toLowerCase()}v` as const, + quantity: 8, + minStartTime: node.end_at ? new Date(node.end_at * 1000) : "NOW", + maxStartTime: node.end_at ? new Date(node.end_at * 1000) : "NOW", + minDurationSeconds: minDurationSeconds, + maxDurationSeconds: maxDurationSeconds, + cluster: node.zone ?? undefined, + }); + }), + ); + + const filteredQuotes = quotes.filter((quote) => + quote.status === "fulfilled" + ); spinner.stop(); let confirmationMessage = `Extend ${extendableNodes.length} ${ pluralizeNodes(extendableNodes.length) - } for ${Math.round(durationSeconds / 3600 * 100) / 100} hours`; + } for ${formattedDuration}`; - if (quote) { - const pricePerGpuHour = getPricePerGpuHourFromQuote(quote); + // If there's only one node, show the price per node per hour + if (filteredQuotes.length === 1 && filteredQuotes[0].value) { + const pricePerGpuHour = getPricePerGpuHourFromQuote( + filteredQuotes[0].value, + ); const pricePerNodeHour = (pricePerGpuHour * GPUS_PER_NODE) / 100; confirmationMessage += ` for ~$${pricePerNodeHour.toFixed(2)}/node/hr`; + } else if (filteredQuotes.length > 1) { + const totalPrice = filteredQuotes.reduce((acc, quote) => { + return acc + (quote.value?.price ?? 0); + }, 0); + // If there's multiple nodes, show the total price, as nodes could be on different zones or have different hardware + confirmationMessage += ` for ~$${totalPrice / 100}`; } else { - logAndQuit( - red( - "No nodes available matching your requirements. This is likely due to insufficient capacity.", - ), + confirmationMessage = red( + "No nodes available matching your requirements. This is likely due to insufficient capacity. Attempt to extend anyway", ); } @@ -201,6 +230,11 @@ async function extendNodeAction( } } + if (options.json) { + console.log(JSON.stringify(results.map((r) => r.node), null, 2)); + process.exit(0); + } + if (results.length > 0) { spinner.succeed( `Successfully extended ${results.length} ${ @@ -221,19 +255,12 @@ async function extendNodeAction( } } - if (options.json) { - console.log(JSON.stringify(results.map((r) => r.node), null, 2)); - process.exit(0); - } - if (results.length > 0) { console.log(gray("\nExtended nodes:")); console.log(createNodesTable(results.map((r) => r.node))); console.log( gray( - `\nDuration extended by: ${options.duration} seconds (${ - Math.round(options.duration! / 3600 * 100) / 100 - } hours)`, + `\nDuration extended by ${formattedDuration}`, ), ); console.log(gray(`Max price: $${options.maxPrice.toFixed(2)}/hour`)); diff --git a/src/lib/nodes/get.tsx b/src/lib/nodes/get.tsx new file mode 100644 index 00000000..81fa93de --- /dev/null +++ b/src/lib/nodes/get.tsx @@ -0,0 +1,107 @@ +import React from "react"; +import { Command } from "@commander-js/extra-typings"; +import { gray, red } from "jsr:@std/fmt/colors"; +import console from "node:console"; +import process from "node:process"; +import ora from "ora"; +import { render } from "ink"; + +import { handleNodesError, nodesClient } from "../../nodesClient.ts"; +import { createNodesTable, jsonOption, pluralizeNodes } from "./utils.ts"; +import { getAuthToken } from "../../helpers/config.ts"; +import { logAndQuit } from "../../helpers/errors.ts"; +import { NodesVerboseDisplay } from "./list.tsx"; + +const get = new Command("get") + .alias("show") + .description("Get detailed information about specific nodes") + .showHelpAfterError() + .argument("", "Node names to get information about") + .option("--short", "Show nodes in table format instead of verbose output") + .addOption(jsonOption) + .addHelpText( + "after", + ` +Examples:\n + \x1b[2m# Get detailed information about specific nodes (verbose by default)\x1b[0m + $ sf nodes get node-1 node-2 + + \x1b[2m# Get nodes in table format\x1b[0m + $ sf nodes get node-1 node-2 --short + + \x1b[2m# Get nodes in JSON format\x1b[0m + $ sf nodes get node-1 node-2 --json +`, + ) + .action(getNodesAction); + +async function getNodesAction( + nodeNames: string[], + options: ReturnType, +) { + try { + const token = await getAuthToken(); + if (!token) { + logAndQuit("Not logged in. Please run 'sf login' first."); + } + const client = await nodesClient(token); + + // Use the API's names parameter to filter nodes directly + const spinner = ora("Fetching nodes...").start(); + const { data: fetchedNodes } = await client.nodes.list({ name: nodeNames }); + spinner.stop(); + + // Check which names were not found + const foundNames = new Set(fetchedNodes.map((node) => node.name)); + const notFound: string[] = []; + + for (const name of nodeNames) { + if (!foundNames.has(name)) { + notFound.push(name); + } + } + + if (options.json) { + console.log(JSON.stringify(fetchedNodes, null, 2)); + return; + } + + if (notFound.length > 0) { + console.error( + red( + `Could not find ${notFound.length === 1 ? "this" : "these"} ${ + pluralizeNodes(notFound.length) + }:`, + ), + ); + for (const name of notFound) { + console.error(` • ${name}`); + } + console.error(); + } + + if (fetchedNodes.length === 0) { + console.error("No nodes found."); + process.exit(1); + } + + if (options.short) { + // Show table format + console.log(createNodesTable(fetchedNodes)); + console.log( + gray( + `\nFound ${fetchedNodes.length} ${ + pluralizeNodes(fetchedNodes.length) + }.`, + ), + ); + } else { + // Show verbose output by default + render(); + } + } catch (err) { + handleNodesError(err); + } +} + +export default get; diff --git a/src/lib/nodes/index.ts b/src/lib/nodes/index.ts index 834d0f1e..27f68b95 100644 --- a/src/lib/nodes/index.ts +++ b/src/lib/nodes/index.ts @@ -5,6 +5,7 @@ import list from "./list.tsx"; import release from "./release.ts"; import set from "./set.ts"; import extend from "./extend.ts"; +import get from "./get.tsx"; import { isFeatureEnabled } from "../posthog.ts"; export async function registerNodes(program: Command) { @@ -32,6 +33,9 @@ $ sf nodes create -n 2 -z hayesvalley --start +1h --duration 2d -p 15.00 \x1b[2m# List all nodes\x1b[0m $ sf nodes list +\x1b[2m# Get detailed information about specific nodes\x1b[0m +$ sf nodes get my-node-name + \x1b[2m# Release a node\x1b[0m $ sf nodes release my-node-name @@ -46,6 +50,7 @@ $ sf nodes extend my-node-name --duration 3600 --max-price 12.50 // Attach sub-commands nodes .addCommand(list) + .addCommand(get) .addCommand(extend) .addCommand(release) .addCommand(set) diff --git a/src/lib/nodes/list.tsx b/src/lib/nodes/list.tsx index 5db792de..c2256665 100644 --- a/src/lib/nodes/list.tsx +++ b/src/lib/nodes/list.tsx @@ -345,7 +345,7 @@ function NodeVerboseDisplay({ node }: { node: SFCNodes.Node }) { } // Component for displaying multiple nodes in verbose format -function NodesVerboseDisplay({ nodes }: { nodes: SFCNodes.Node[] }) { +export function NodesVerboseDisplay({ nodes }: { nodes: SFCNodes.Node[] }) { return ( {nodes.map((node, index) => ( diff --git a/src/lib/nodes/release.ts b/src/lib/nodes/release.ts index 34133eac..612dc977 100644 --- a/src/lib/nodes/release.ts +++ b/src/lib/nodes/release.ts @@ -61,17 +61,17 @@ async function releaseNodesAction( try { const client = await nodesClient(); - // Fetch and filter nodes for both dry run and confirmation + // Use the API's names parameter to filter nodes directly const spinner = ora("Fetching nodes to release...").start(); - const { data: allNodes } = await client.nodes.list(); + const { data: fetchedNodes } = await client.nodes.list({ name: nodeNames }); spinner.stop(); - // Filter nodes that match the provided names/IDs + // Check which names were not found const foundNodes: { name: string; node: SFCNodes.Node }[] = []; const notFound: string[] = []; for (const nameOrId of nodeNames) { - const node = allNodes.find((n) => + const node = fetchedNodes.find((n) => n.name === nameOrId || n.id === nameOrId ); if (node) { diff --git a/src/lib/nodes/set.ts b/src/lib/nodes/set.ts index ad9c9c5c..dc79566f 100644 --- a/src/lib/nodes/set.ts +++ b/src/lib/nodes/set.ts @@ -2,7 +2,6 @@ import { Command, CommanderError } from "@commander-js/extra-typings"; import ora from "ora"; import { gray } from "jsr:@std/fmt/colors"; import console from "node:console"; -import type { SFCNodes } from "@sfcompute/nodes-sdk-alpha"; import { handleNodesError, nodesClient } from "../../nodesClient.ts"; import { maxPriceOption, pluralizeNodes } from "./utils.ts"; @@ -25,24 +24,24 @@ async function setNodesAction( const client = await nodesClient(); const spinner = ora("Updating nodes...").start(); - const { data: allNodes } = await client.nodes.list(); + // Use the API's names parameter to filter nodes directly + const { data: fetchedNodes } = await client.nodes.list({ name: names }); - const nodesToUpdate: SFCNodes.Node[] = []; + // Check which names were not found + const foundNames = new Set(fetchedNodes.map((node) => node.name)); const notFound: string[] = []; - for (const nameOrId of names) { - const node = allNodes.find((n) => - n.name === nameOrId || n.id === nameOrId - ); - if (node) nodesToUpdate.push(node); - else notFound.push(nameOrId); + for (const name of names) { + if (!foundNames.has(name)) { + notFound.push(name); + } } // Filter nodes that have procurement_id (auto reserved nodes) - const nodesWithProcurement = nodesToUpdate.filter((node) => + const nodesWithProcurement = fetchedNodes.filter((node) => node.procurement_id ); - const nodesWithoutProcurement = nodesToUpdate.filter((node) => + const nodesWithoutProcurement = fetchedNodes.filter((node) => !node.procurement_id ); diff --git a/src/lib/nodes/utils.ts b/src/lib/nodes/utils.ts index 08cf7ead..80845662 100644 --- a/src/lib/nodes/utils.ts +++ b/src/lib/nodes/utils.ts @@ -118,7 +118,7 @@ export function createNodesTable(nodes: SFCNodes.Node[]): string { const startEnd = formatNullableDateRange(startDate, endDate); const maxPrice = node.max_price_per_node_hour - ? (node.max_price_per_node_hour / 100).toFixed(2) + ? `$${(node.max_price_per_node_hour / 100).toFixed(2)}/hr` : "N/A"; const lastVm = node.vms?.data.sort((a, b) => b.updated_at - a.updated_at) @@ -132,7 +132,7 @@ export function createNodesTable(nodes: SFCNodes.Node[]): string { node.gpu_type, node.zone || "N/A", startEnd, - `$${maxPrice}/hr`, + maxPrice, ]); } diff --git a/src/lib/vm/image/list.tsx b/src/lib/vm/image/list.tsx index 675285cd..e0477d47 100644 --- a/src/lib/vm/image/list.tsx +++ b/src/lib/vm/image/list.tsx @@ -61,12 +61,12 @@ Next Steps:\n } // Sort images by created_at (newest first) - const imagesToShow = images.slice(0, 5); - const sortedImages = [...imagesToShow].sort((a, b) => { + const sortedImages = [...images].sort((a, b) => { const aTime = a.created_at || 0; const bTime = b.created_at || 0; return bTime - aTime; }); + const imagesToShow = sortedImages.slice(0, 5); // Create and display images table const table = new Table({ @@ -82,7 +82,7 @@ Next Steps:\n }, }); - for (const image of sortedImages) { + for (const image of imagesToShow) { const createdAt = image.created_at ? formatDate(new Date(image.created_at * 1000)) : "Unknown";