Skip to content

Commit 2cacd23

Browse files
feat: [PRODUCT-607] nudge users from sf buy legacy VMs to sf nodes Node VMs (#229)
1 parent 90afd9e commit 2cacd23

9 files changed

Lines changed: 383 additions & 94 deletions

File tree

deno.lock

Lines changed: 92 additions & 60 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/lib/buy/index.tsx

Lines changed: 139 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,10 @@ import ms from "ms";
77
import console from "node:console";
88
import process from "node:process";
99
import { setTimeout } from "node:timers";
10-
import dayjs from "npm:dayjs@1.11.13";
11-
import duration from "npm:dayjs@1.11.13/plugin/duration.js";
12-
import relativeTime from "npm:dayjs@1.11.13/plugin/relativeTime.js";
10+
import boxen from "npm:boxen@8.0.1";
11+
import dayjs from "dayjs";
12+
import duration from "dayjs/plugin/duration";
13+
import relativeTime from "dayjs/plugin/relativeTime";
1314
import parseDurationFromLibrary from "parse-duration";
1415
import React, { useCallback, useEffect, useState } from "react";
1516
import invariant from "tiny-invariant";
@@ -34,10 +35,12 @@ import { Row } from "../Row.tsx";
3435
import { GPUS_PER_NODE } from "../constants.ts";
3536
import { parseAccelerators } from "../index.ts";
3637
import { analytics } from "../posthog.ts";
38+
import { components } from "../../schema.ts";
3739

3840
dayjs.extend(relativeTime);
3941
dayjs.extend(duration);
4042

43+
type ZoneInfo = components["schemas"]["node-api_ZoneInfo"];
4144
export type SfBuyOptions = ReturnType<ReturnType<typeof _registerBuy>["opts"]>;
4245

4346
export function _registerBuy(program: Command) {
@@ -234,6 +237,7 @@ export function QuoteComponent(props: { options: SfBuyOptions }) {
234237

235238
export function QuoteAndBuy(props: { options: SfBuyOptions }) {
236239
const [orderProps, setOrderProps] = useState<BuyOrderProps | null>(null);
240+
const [zone, setZone] = useState<ZoneInfo>();
237241

238242
// submit a quote request, handle loading state
239243
useEffect(() => {
@@ -243,6 +247,7 @@ export function QuoteAndBuy(props: { options: SfBuyOptions }) {
243247
let pricePerGpuHour = parsePricePerGpuHour(props.options.price);
244248
let startAt = start;
245249
let endsAt: Date;
250+
let quoteZone: string | undefined;
246251
const coercedStart = parseStartDate(start);
247252
if (duration) {
248253
// If duration is set, calculate end from start + duration
@@ -268,15 +273,34 @@ export function QuoteAndBuy(props: { options: SfBuyOptions }) {
268273
}
269274

270275
pricePerGpuHour = getPricePerGpuHourFromQuote(quote);
271-
272276
startAt = parseStartDateOrNow(quote.start_at);
273-
274277
endsAt = dayjs(quote.end_at).toDate();
278+
quoteZone = "zone" in quote ? quote.zone : undefined;
275279
}
276280

277281
const { type, accelerators, colocate, yes, standing, cluster } =
278282
props.options;
279283

284+
if (cluster) {
285+
const api = await apiClient();
286+
const { data, error, response } = await api.GET(`/v0/zones/{id}`, {
287+
params: {
288+
path: {
289+
id: cluster,
290+
},
291+
},
292+
});
293+
if (error) {
294+
return logAndQuit(
295+
`Failed to get zone: ${JSON.stringify(error, null, 2)}`,
296+
);
297+
}
298+
if (!response.ok) {
299+
return logAndQuit(`No zone found with slug: ${cluster}`);
300+
}
301+
setZone(data);
302+
}
303+
280304
setOrderProps({
281305
type,
282306
price: pricePerGpuHour,
@@ -286,7 +310,9 @@ export function QuoteAndBuy(props: { options: SfBuyOptions }) {
286310
yes,
287311
standing,
288312
colocate,
289-
cluster,
313+
// If the user didn't specify a zone, use the zone from the quote
314+
// This helps prevent price surprises/location mismatches
315+
cluster: cluster ?? quoteZone,
290316
});
291317
})();
292318
}, [props.options]);
@@ -300,7 +326,7 @@ export function QuoteAndBuy(props: { options: SfBuyOptions }) {
300326
</Box>
301327
</Box>
302328
)
303-
: <BuyOrder {...orderProps} />;
329+
: <BuyOrder {...orderProps} zone={zone} />;
304330
}
305331

306332
export function getTotalPrice(
@@ -429,10 +455,60 @@ type BuyOrderProps = {
429455
yes?: boolean;
430456
standing?: boolean;
431457
cluster?: string;
458+
zone?: ZoneInfo;
432459
};
433460

461+
function VMWarning(props: BuyOrderProps) {
462+
const startDate = props.startAt === "NOW" ? dayjs() : dayjs(props.startAt);
463+
const endDate = dayjs(roundEndDate(props.endsAt));
464+
const realDuration = endDate.diff(startDate);
465+
const realDurationString = ms(realDuration);
466+
467+
// Build the equivalent sf nodes command
468+
let equivalentCommand = `sf nodes create -n ${props.size}`;
469+
470+
if (props.price) {
471+
equivalentCommand += ` -p ${
472+
(props.price * GPUS_PER_NODE / 100).toFixed(2)
473+
}`;
474+
}
475+
if (props.startAt !== "NOW") {
476+
const startFormatted = startDate.toISOString();
477+
equivalentCommand += ` -s "${startFormatted}"`;
478+
}
479+
equivalentCommand += ` -d ${realDurationString}`;
480+
if (props.yes) {
481+
equivalentCommand += ` -y`;
482+
}
483+
if (props.cluster) {
484+
equivalentCommand += ` -z ${props.cluster}`;
485+
} else {
486+
// TODO: add support for any-zone
487+
// equivalentCommand += `--any-zone`;
488+
}
489+
490+
const warningMessage = boxen(
491+
`\x1b[31mWe're deprecating \x1b[97msf buy\x1b[31m for Virtual Machines.\x1b[0m
492+
\x1b[31mWe recommend you create a VM Node instead: \x1b[97m${equivalentCommand}\x1b[0m
493+
\x1b[31m\x1b[97msf nodes\x1b[31m allows you to create, extend, and release specific machines directly.\x1b[0m`,
494+
{
495+
padding: 0.75,
496+
borderColor: "red",
497+
},
498+
);
499+
500+
return <Text>{warningMessage}</Text>;
501+
}
502+
434503
function BuyOrder(props: BuyOrderProps) {
435504
const [isLoading, setIsLoading] = useState(false);
505+
const { type, zone } = props;
506+
const isVM = type?.endsWith("v") || zone?.delivery_type === "VM";
507+
const [vmWarningState, setVmWarningState] = useState<
508+
"prompt" | "accepted" | "dismissed" | "not_applicable"
509+
>(
510+
isVM ? (props.yes ? "accepted" : "prompt") : "not_applicable",
511+
);
436512
const { exit } = useApp();
437513
const [order, setOrder] = useState<Order | null>(null);
438514

@@ -522,6 +598,18 @@ function BuyOrder(props: BuyOrderProps) {
522598
[props, exit, submitOrder],
523599
);
524600

601+
const handleDismissVMWarning = useCallback((submitValue: boolean) => {
602+
if (!submitValue) {
603+
setIsLoading(false);
604+
setResultMessage(
605+
"VM order not placed. We recommend you use 'sf nodes create' instead.",
606+
);
607+
setTimeout(() => {
608+
exit();
609+
}, 0);
610+
} else setVmWarningState("accepted");
611+
}, [exit]);
612+
525613
useEffect(() => {
526614
if (!isLoading || !order?.id) {
527615
return;
@@ -554,9 +642,47 @@ function BuyOrder(props: BuyOrderProps) {
554642

555643
return (
556644
<Box gap={1} flexDirection="column">
557-
<MemoizedBuyOrderPreview {...props} />
645+
{(vmWarningState === "prompt" || vmWarningState === "accepted") && (
646+
<Box gap={0.5} flexDirection="column">
647+
<VMWarning {...props} />
648+
{vmWarningState === "prompt" && (
649+
<>
650+
<Text color="red">
651+
Place an order for a legacy VM anyway?{" "}
652+
<Text color="white">
653+
(y/n)
654+
</Text>
655+
</Text>
656+
657+
<ConfirmInput
658+
isChecked={false}
659+
onSubmit={handleDismissVMWarning}
660+
/>
661+
</>
662+
)}
663+
</Box>
664+
)}
665+
666+
{(vmWarningState === "dismissed" || vmWarningState === "not_applicable" ||
667+
vmWarningState === "accepted") && (
668+
<MemoizedBuyOrderPreview
669+
{...props}
670+
/>
671+
)}
558672

559-
{!isLoading && !props.yes && (
673+
{vmWarningState === "accepted" && !isLoading && !props.yes && (
674+
<Box gap={1}>
675+
<Text>Place order? (y/n)</Text>
676+
677+
<ConfirmInput
678+
isChecked={false}
679+
onSubmit={handleSubmit}
680+
/>
681+
</Box>
682+
)}
683+
684+
{(vmWarningState === "dismissed" ||
685+
vmWarningState === "not_applicable") && !isLoading && !props.yes && (
560686
<Box gap={1}>
561687
<Text>Place order? (y/n)</Text>
562688

@@ -836,11 +962,13 @@ export async function getQuote(options: QuoteOptions) {
836962
if (!response.ok) {
837963
switch (response.status) {
838964
case 400:
839-
return logAndQuit(`Bad Request: ${error}`);
965+
return logAndQuit(`Bad Request: ${JSON.stringify(error, null, 2)}`);
840966
case 401:
841967
return await logSessionTokenExpiredAndQuit();
842968
case 500:
843-
return logAndQuit(`Failed to get quote: ${error}`);
969+
return logAndQuit(
970+
`Failed to get quote: ${JSON.stringify(error, null, 2)}`,
971+
);
844972
default:
845973
return logAndQuit(`Failed to get quote: ${response.statusText}`);
846974
}

src/lib/dev.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ import * as console from "node:console";
33
import { confirm } from "@inquirer/prompts";
44
import { gray, green, white, yellow } from "jsr:@std/fmt/colors";
55
import type { Command } from "@commander-js/extra-typings";
6-
import dayjs from "npm:dayjs@1.11.13";
7-
import utc from "npm:dayjs@1.11.13/plugin/utc.js";
6+
import dayjs from "dayjs";
7+
import utc from "dayjs/plugin/utc";
88
import {
99
deleteConfig,
1010
getConfigPath,

src/lib/extend/index.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { type Command } from "@commander-js/extra-typings";
2-
import dayjs from "npm:dayjs@1.11.13";
3-
import duration from "npm:dayjs@1.11.13/plugin/duration.js";
4-
import relativeTime from "npm:dayjs@1.11.13/plugin/relativeTime.js";
2+
import dayjs from "dayjs";
3+
import duration from "dayjs/plugin/duration";
4+
import relativeTime from "dayjs/plugin/relativeTime";
55
import boxen from "npm:boxen@8.0.1";
66
import console from "node:console";
77
import process from "node:process";

src/lib/orders/OrderDisplay.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Box, measureElement, Text, useInput } from "ink";
22
import process from "node:process";
3-
import dayjs from "npm:dayjs@1.11.13";
3+
import dayjs from "dayjs";
44
import React, { useEffect } from "react";
55
import { Row } from "../Row.tsx";
66
import { GPUS_PER_NODE } from "../constants.ts";

src/lib/orders/index.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { type Command, Option } from "@commander-js/extra-typings";
22
import { render } from "ink";
33
import * as console from "node:console";
4-
import dayjs from "npm:dayjs@1.11.13";
5-
import duration from "npm:dayjs@1.11.13/plugin/duration.js";
6-
import relativeTime from "npm:dayjs@1.11.13/plugin/relativeTime.js";
4+
import dayjs from "dayjs";
5+
import duration from "dayjs/plugin/duration";
6+
import relativeTime from "dayjs/plugin/relativeTime";
77
import React from "react";
88
import { getAuthToken, isLoggedIn } from "../../helpers/config.ts";
99
import { parseDurationArgument } from "../../helpers/duration.ts";

0 commit comments

Comments
 (0)