diff --git a/README.md b/README.md index b778a2e..56de1cb 100644 --- a/README.md +++ b/README.md @@ -45,9 +45,9 @@ Releases are managed through GitHub Actions. To create a new release: 3. Click on the "Release" workflow 4. Click "Run workflow" 5. Select the version bump type: - - `patch`: for backwards-compatible bug fixes (0.0.x) - - `minor`: for backwards-compatible new features (0.x.0) - - `major`: for breaking changes (x.0.0) + - `patch`: for backwards-compatible changes (0.0.x) + - `minor`: for breaking changes (0.x.0) + - `major`: for major breaking changes (x.0.0) - `prerelease`: for pre-release versions (0.0.0-pre.timestamp) 6. Click "Run workflow" to start the release process diff --git a/src/lib/contracts/ContractDisplay.tsx b/src/lib/contracts/ContractDisplay.tsx index 05e78b7..61ddfc4 100644 --- a/src/lib/contracts/ContractDisplay.tsx +++ b/src/lib/contracts/ContractDisplay.tsx @@ -170,8 +170,8 @@ export function ContractList(props: { contracts: Contract[] }) { - # Place a buy order to get started - sf buy + # Buy a node to get started + sf nodes create ); diff --git a/src/lib/orders/OrderDisplay.tsx b/src/lib/orders/OrderDisplay.tsx index 71011cc..33e59a2 100644 --- a/src/lib/orders/OrderDisplay.tsx +++ b/src/lib/orders/OrderDisplay.tsx @@ -189,7 +189,7 @@ export function OrderDisplay(props: { # Place an order to buy compute - sf buy + sf nodes create ); diff --git a/src/lib/zones.tsx b/src/lib/zones.tsx index bbaf3b4..5b1785e 100644 --- a/src/lib/zones.tsx +++ b/src/lib/zones.tsx @@ -1,7 +1,11 @@ import * as console from "node:console"; import type { Command } from "@commander-js/extra-typings"; -import chalk from "chalk"; -import Table from "cli-table3"; +import { + differenceInDays, + differenceInHours, + differenceInMinutes, +} from "date-fns"; +import dayjs from "dayjs"; import { Box, render, Text } from "ink"; import { apiClient } from "../apiClient.ts"; import { isLoggedIn } from "../helpers/config.ts"; @@ -15,41 +19,131 @@ import { isFeatureEnabled } from "./posthog.ts"; type ZoneInfo = components["schemas"]["node-api_ZoneInfo"]; -// Delivery type conversion similar to InstanceTypeMetadata pattern -const DeliveryTypeMetadata: Record = { - K8s: { displayName: "Kubernetes" }, - K8sNamespace: { displayName: "Kubernetes" }, - VM: { displayName: "Virtual Machine" }, -} as const; +type CapacityMetrics = { + availableNow: number; + availableWithin1Day: { count: number; startTimestamp: number | null }; + availableWithin1Week: { count: number; startTimestamp: number | null }; +}; -function formatDeliveryType(deliveryType: string): string { - return DeliveryTypeMetadata[deliveryType]?.displayName || deliveryType; -} +function getZoneCapacityMetrics(zone: ZoneInfo): CapacityMetrics { + if (zone.available_capacity.length === 0) { + return { + availableNow: 0, + availableWithin1Day: { count: 0, startTimestamp: null }, + availableWithin1Week: { count: 0, startTimestamp: null }, + }; + } + + const now = dayjs(); + const nowTimestamp = now.unix(); + const oneDayTimestamp = now.add(1, "day").unix(); + const oneWeekTimestamp = now.add(1, "week").unix(); + + // Sort capacity windows by start_timestamp + const sortedCapacity = [...zone.available_capacity].sort( + (a, b) => a.start_timestamp - b.start_timestamp, + ); + + let availableNow = 0; + let maxWithin1Day = 0; + let maxWithin1DayStart: number | null = null; + let maxWithin1Week = 0; + let maxWithin1WeekStart: number | null = null; + + // Iterate through each capacity window + for (const win of sortedCapacity) { + // Skip windows that have already ended + if (win.end_timestamp <= nowTimestamp) { + continue; + } + + // Early exit: windows are sorted, so if this one starts after 1 week, all subsequent ones will too + if (win.start_timestamp > oneWeekTimestamp) { + break; + } + + const quantity = win.quantity; -function getCurrentAvailableCapacity(zone: ZoneInfo): number { - const oldestShape = zone.available_capacity - ?.sort((a, b) => a.start_timestamp - b.start_timestamp) - .at(0); - if ( - oldestShape?.start_timestamp && - oldestShape.start_timestamp >= Math.floor(Date.now() / 1000) - ) { - return 0; - } - return oldestShape?.quantity ?? 0; + // Check if window contains "now" + if ( + nowTimestamp >= win.start_timestamp && + nowTimestamp < win.end_timestamp + ) { + availableNow = quantity; + } + + // Check if window overlaps with "within 1 day" period + if ( + win.start_timestamp < oneDayTimestamp && + win.end_timestamp > nowTimestamp + ) { + if (quantity > maxWithin1Day) { + maxWithin1Day = quantity; + maxWithin1DayStart = Math.max(win.start_timestamp, nowTimestamp); + } + } + + // Check if window overlaps with "within 1 week" period + if ( + win.start_timestamp < oneWeekTimestamp && + win.end_timestamp > nowTimestamp + ) { + if (quantity > maxWithin1Week) { + maxWithin1Week = quantity; + maxWithin1WeekStart = Math.max(win.start_timestamp, nowTimestamp); + } + } + } + + return { + availableNow, + availableWithin1Day: { + count: maxWithin1Day, + startTimestamp: maxWithin1DayStart, + }, + availableWithin1Week: { + count: maxWithin1Week, + startTimestamp: maxWithin1WeekStart, + }, + }; } // Region conversion to short slugs const RegionMetadata: Record = { - NorthAmerica: { slug: "North America" }, - AsiaPacific: { slug: "Asia" }, - EuropeMiddleEastAfrica: { slug: "EMEA" }, + NorthAmerica: { slug: "north america" }, + AsiaPacific: { slug: "asia" }, + EuropeMiddleEastAfrica: { slug: "emea" }, } as const; function formatRegion(region: string): string { return RegionMetadata[region]?.slug || region; } +// Bright colors not used elsewhere in this component +const REGION_COLORS = [ + "green", + "yellow", + "blueBright", + "magentaBright", + "cyanBright", +] as const; + +// Simple deterministic hash for consistent color assignment +function hashString(str: string): number { + let hash = 0; + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i); + hash = (hash << 5) - hash + char; + hash = hash & hash; // Convert to 32bit integer + } + return Math.abs(hash); +} + +function getRegionColor(region: string): (typeof REGION_COLORS)[number] { + const hash = hashString(region); + return REGION_COLORS[hash % REGION_COLORS.length]; +} + export async function registerZones(program: Command) { const isEnabled = await isFeatureEnabled("zones"); if (!isEnabled) return; @@ -61,30 +155,28 @@ export async function registerZones(program: Command) { "after", ` Examples: - \x1b[2m# List all zones\x1b[0m + \x1b[2m# List zones with availability\x1b[0m $ sf zones ls + \x1b[2m# List all zones, including those with no availability\x1b[0m + $ sf zones ls --all + \x1b[2m# List zones with JSON output\x1b[0m $ sf zones ls --json - -Note: This is an early access feature (v0) that may change at any time. `, ); zones .command("list") .alias("ls") .description("List all zones") + .option("--all", "Show all zones, including those with no availability") .option("--json", "Output in JSON format") .action(async (options) => { await listZonesAction(options); }); } -async function listZonesAction(options: { json?: boolean }) { - console.error( - "\x1b[33mNote: This is an early access feature (v0) and may change at any time.\x1b[0m\n", - ); - +async function listZonesAction(options: { all?: boolean; json?: boolean }) { const loggedIn = await isLoggedIn(); if (!loggedIn) { logLoginMessageAndQuit(); @@ -125,81 +217,411 @@ async function listZonesAction(options: { json?: boolean }) { return; } - displayZonesTable(filteredZones); + await displayZonesTable(filteredZones, { showAll: options.all }); +} + +function hasAvailability(zone: ZoneInfo): boolean { + const metrics = getZoneCapacityMetrics(zone); + return ( + metrics.availableNow > 0 || + metrics.availableWithin1Day.count > 0 || + metrics.availableWithin1Week.count > 0 + ); +} + +function formatShortDistance(date: Date): string { + const now = new Date(); + const minutes = differenceInMinutes(date, now); + const hours = differenceInHours(date, now); + const days = differenceInDays(date, now); + + if (days >= 7) { + const weeks = Math.floor(days / 7); + return `${weeks}w`; + } + if (days >= 1) { + return `${days}d`; + } + if (hours >= 1) { + return `${hours}h`; + } + return `${minutes}m`; } -function displayZonesTable(zones: ZoneInfo[]) { +function AvailabilityDisplay({ + count, + startTimestamp, + isNow = false, + padWidth = 1, +}: { + count: number; + startTimestamp: number | null; + isNow?: boolean; + padWidth?: number; +}) { + const countStr = String(count).padStart(padWidth); + + if (isNow && count === 0) { + return ( + + sold out + + ); + } + + if (isNow) { + return ( + <> + {countStr} + {" now "} + + ); + } + + if (count === 0 || startTimestamp === null) { + return ( + <> + + {countStr} + + {" now "} + + ); + } + + const startDate = new Date(startTimestamp * 1000); + const now = new Date(); + const minutesUntil = differenceInMinutes(startDate, now); + + // If available now or within 1 minute, show "now" + if (minutesUntil <= 1) { + return ( + <> + {countStr} + {" now "} + + ); + } + + const distance = formatShortDistance(startDate).padEnd(3); + return ( + <> + {countStr} + {` in ${distance}`} + + ); +} + +// Column widths +const COL = { + gpu: 6, + region: 15, + now: 11, + soonest: 11, + max: 11, +}; + +function ZonesTableDisplay({ + zones, + hiddenCount, + firstAvailableZone, + firstAvailableZoneStartTime, +}: { + zones: ZoneInfo[]; + hiddenCount: number; + firstAvailableZone: string | null; + firstAvailableZoneStartTime: string | null; +}) { + // Calculate dynamic zone column width based on longest zone name + const zoneWidth = Math.max( + 6, // minimum width for "zone" header + padding + ...zones.map((z) => z.name.length + 2), // +2 for padding + ); + + // Pre-calculate metrics for all zones to determine padding widths + const allMetrics = zones.map((z) => getZoneCapacityMetrics(z)); + + // Calculate max values for each column to determine padding + const maxNow = Math.max(1, ...allMetrics.map((m) => m.availableNow)); + const maxToday = Math.max( + 1, + ...allMetrics.map((m) => m.availableWithin1Day.count), + ); + const maxWeek = Math.max( + 1, + ...allMetrics.map((m) => m.availableWithin1Week.count), + ); + + // Calculate pad widths (number of digits needed) + const nowPadWidth = String(maxNow).length; + const todayPadWidth = String(maxToday).length; + const weekPadWidth = String(maxWeek).length; + + return ( + + + {/* Header row */} + + + zone + (slug) + + + gpu + + + region + + + nodes + now + + + + soonest + + + + max + + + + {/* Body rows */} + {zones.map((zone, idx) => { + const metrics = allMetrics[idx]; + const allSoldOut = + metrics.availableNow === 0 && + metrics.availableWithin1Day.count === 0 && + metrics.availableWithin1Week.count === 0; + + // Total width of node columns: now(11) + separator(1) + soonest(11) + separator(1) + max(11) = 35 + const nodeColsWidth = COL.now + 1 + COL.soonest + 1 + COL.max; + const soldOutText = "sold out"; + const dashCount = Math.floor( + (nodeColsWidth - soldOutText.length - 2) / 2, + ); + const soldOutDisplay = `${"-".repeat(dashCount)} ${soldOutText} ${"-".repeat(dashCount)}`; + + return ( + + + {zone.name} + + + {zone.hardware_type} + + + + {formatRegion(zone.region)} + + + {allSoldOut ? ( + + {soldOutDisplay} + + ) : ( + <> + + + + + + + + + + + + + )} + + ); + })} + + {/* Footer */} + {hiddenCount > 0 && ( + + {hiddenCount} sold-out zones hidden. Use + sf zones ls + --all + to show. + + )} + + + {/* Examples */} + {firstAvailableZone && ( + + Use zone names when launching nodes. + + Examples: + + {" "}sf nodes create --zone{" "} + {firstAvailableZone} + {firstAvailableZoneStartTime && ( + <> + {" "} + -s {firstAvailableZoneStartTime} + + )} + + + + )} + + ); +} + +async function displayZonesTable( + zones: ZoneInfo[], + options: { showAll?: boolean } = {}, +) { if (zones.length === 0) { - render(); + const { waitUntilExit } = render(); + await waitUntilExit(); return; } - // Sort zones so available ones come first, then alphabetically by name - const sortedZones = [...zones].sort((a, b) => { - // Available zones first (true comes before false) - const aAvailable = getCurrentAvailableCapacity(a) > 0; - const bAvailable = getCurrentAvailableCapacity(b) > 0; - if (aAvailable !== bAvailable) { - return bAvailable ? 1 : -1; + // Separate zones with and without availability + const zonesWithAvailability = zones.filter(hasAvailability); + const zonesWithoutAvailability = zones.filter((z) => !hasAvailability(z)); + + if (zonesWithAvailability.length === 0) { + const { waitUntilExit } = render(); + await waitUntilExit(); + return; + } + + // Determine which zones to display + const zonesToDisplay = options.showAll ? zones : zonesWithAvailability; + + // Sort zones by availability: now > today > 1 week, then alphabetically by name + const sortedZones = [...zonesToDisplay].sort((a, b) => { + const aMetrics = getZoneCapacityMetrics(a); + const bMetrics = getZoneCapacityMetrics(b); + + // Sort by availableNow (higher first) + if (aMetrics.availableNow !== bMetrics.availableNow) { + return bMetrics.availableNow - aMetrics.availableNow; } - // Then sort by name alphabetically + + // Break ties with availableWithin1Day (higher first) + if ( + aMetrics.availableWithin1Day.count !== bMetrics.availableWithin1Day.count + ) { + return ( + bMetrics.availableWithin1Day.count - aMetrics.availableWithin1Day.count + ); + } + + // Break ties with availableWithin1Week (higher first) + if ( + aMetrics.availableWithin1Week.count !== + bMetrics.availableWithin1Week.count + ) { + return ( + bMetrics.availableWithin1Week.count - + aMetrics.availableWithin1Week.count + ); + } + + // Finally sort by name alphabetically return a.name.localeCompare(b.name); }); - const table = new Table({ - head: [ - chalk.cyan("Zone"), - chalk.cyan("Delivery Type"), - chalk.cyan("Available Nodes"), - chalk.cyan("GPU Type"), - chalk.cyan("Interconnect"), - chalk.cyan("Region"), - ], - style: { - head: [], - border: ["gray"], - }, - }); + const hiddenCount = options.showAll ? 0 : zonesWithoutAvailability.length; + const firstAvailableZone = + zonesWithAvailability.length > 0 ? zonesWithAvailability[0].name : null; - sortedZones.forEach((zone) => { - const available = getCurrentAvailableCapacity(zone); - const availableNodesText = - available > 0 - ? chalk.green(available.toString()) - : chalk.red(available.toString()); - - table.push([ - zone.name, - formatDeliveryType(zone.delivery_type), - availableNodesText, - zone.hardware_type, - zone.interconnect_type || "None", - formatRegion(zone.region), - ]); - }); + // Calculate earliest start time for first available zone + let firstAvailableZoneStartTime: string | null = null; + if (zonesWithAvailability.length > 0) { + const metrics = getZoneCapacityMetrics(zonesWithAvailability[0]); + if (metrics.availableNow > 0) { + firstAvailableZoneStartTime = "now"; + } else if (metrics.availableWithin1Day.startTimestamp) { + firstAvailableZoneStartTime = dayjs + .unix(metrics.availableWithin1Day.startTimestamp) + .toISOString(); + } else if (metrics.availableWithin1Week.startTimestamp) { + firstAvailableZoneStartTime = dayjs + .unix(metrics.availableWithin1Week.startTimestamp) + .toISOString(); + } + } - const availableZones = sortedZones.filter( - (zone) => getCurrentAvailableCapacity(zone) > 0, - ); - const availableZoneName = availableZones?.[0]?.name ?? "alamo"; - console.log(table.toString()); - console.log( - `\n${chalk.gray("Use zone names when placing orders or configuring nodes.")}\n`, - ); - console.log(chalk.gray("Examples:")); - console.log(` sf buy --zone ${chalk.green(availableZoneName)}`); - console.log( - ` sf scale create -n 16 --zone ${chalk.green(availableZoneName)}`, + const { waitUntilExit } = render( + , ); + await waitUntilExit(); } function EmptyZonesDisplay() { return ( - No zones found. + No zones with availability found. # Check back later for available zones sf zones ls + + # To show all zones, including those with no availability + + sf zones ls --all );