Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.
Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.
Path: blob/master/src/packages/next/components/store/dedicated.tsx
Views: 687
/*1* This file is part of CoCalc: Copyright © 2022 Sagemath, Inc.2* License: MS-RSL – see LICENSE.md for details3*/45/*6Create a new dedicated vm/disk site license.7*/89import { Alert, Divider, Form, Input, Radio, Select, Typography } from "antd";10import { sortBy } from "lodash";11import { useRouter } from "next/router";12import { useEffect, useRef, useState } from "react";13import PaygInfo from "./payg-info";14import { Icon } from "@cocalc/frontend/components/icon";15import { get_local_storage } from "@cocalc/frontend/misc/local-storage";16import { HOME_PREFIX, ROOT } from "@cocalc/util/consts/dedicated";17import { DOC_CLOUD_STORAGE_URL } from "@cocalc/util/consts/project";18import { testDedicatedDiskNameBasic } from "@cocalc/util/licenses/check-disk-name-basics";19import { CostInputPeriod } from "@cocalc/util/licenses/purchase/types";20import { money } from "@cocalc/util/licenses/purchase/utils";21import {22DedicatedDiskSpeedNames,23DISK_NAMES,24VMsType,25} from "@cocalc/util/types/dedicated";26import {27DEDICATED_DISK_SIZE_INCREMENT,28DEFAULT_DEDICATED_DISK_SIZE,29DEFAULT_DEDICATED_DISK_SPEED,30getDedicatedDiskKey,31MAX_DEDICATED_DISK_SIZE,32MIN_DEDICATED_DISK_SIZE,33PRICES,34} from "@cocalc/util/upgrades/dedicated";35import { DateRange } from "@cocalc/util/upgrades/shopping";36import { Paragraph, Text, Title } from "components/misc";37import A from "components/misc/A";38import IntegerSlider from "components/misc/integer-slider";39import Loading from "components/share/loading";40import SiteName from "components/share/site-name";41import apiPost from "lib/api/post";42import { useScrollY } from "lib/use-scroll-y";43import { AddBox } from "./add-box";44import { ApplyLicenseToProject } from "./apply-license-to-project";45import { computeCost } from "@cocalc/util/licenses/store/compute-cost";46import { InfoBar } from "./cost-info-bar";47import { SignInToPurchase } from "./sign-in-to-purchase";48import { TitleDescription } from "./title-description";49import { ToggleExplanations } from "./toggle-explanations";50import { UsageAndDuration } from "./usage-and-duration";51import { getType, loadDateRange } from "./util";5253const GCP_DISK_URL = "https://cloud.google.com/compute/docs/disks#pdspecs";54const GCP_DISK_PERFORMANCE_URL =55"https://cloud.google.com/compute/docs/disks/performance";5657interface Props {58noAccount: boolean;59}6061export default function DedicatedResource(props: Props) {62const { noAccount } = props;63const router = useRouter();64const headerRef = useRef<HTMLHeadingElement>(null);6566// most likely, user will go to the cart next67useEffect(() => {68router.prefetch("/store/cart");69}, []);7071const [offsetHeader, setOffsetHeader] = useState(0);72const scrollY = useScrollY();7374useEffect(() => {75if (headerRef.current) {76setOffsetHeader(headerRef.current.offsetTop);77}78}, []);7980return (81<>82<Title level={3} ref={headerRef}>83<Icon name={"dedicated"} style={{ marginRight: "5px" }} />{" "}84{router.query.id != null85? "Edit Dedicated VM License in Shopping Cart"86: "Buy a Dedicated VM (Deprecated)"}87</Title>88{router.query.id == null && (89<>90<Paragraph>91A{" "}92<A href="https://doc.cocalc.com/licenses.html">93<SiteName /> dedicated resource license94</A>{" "}95can be used to outfit your project either with additional disk96storage or moves your project to a much more powerful virtual97machine. Create a dedicated resources license below then add it to98your <A href="/store/cart">shopping cart</A>.99</Paragraph>100<Paragraph>101It is also possible to run <SiteName /> on your own hardware. Check102out the{" "}103<Text strong>104<A href={"/pricing/onprem"}>on premises offerings</A>105</Text>{" "}106to learn more about this.107</Paragraph>108<Paragraph>109<PaygInfo what={"a dedicated VM or disk"} />110</Paragraph>111</>112)}113<CreateDedicatedResource114showInfoBar={scrollY > offsetHeader}115noAccount={noAccount}116/>117</>118);119}120121function CreateDedicatedResource({ showInfoBar = false, noAccount = false }) {122// somehow this state is necessary to render the form properly123const [formType, setFormType] = useState<"disk" | "vm" | null>(null);124const [cost, setCost] = useState<CostInputPeriod | undefined>(undefined);125const [loading, setLoading] = useState<boolean>(false);126const [cartError, setCartError] = useState<string>("");127const [showExplanations, setShowExplanations] = useState<boolean>(true);128const [durationTypes, setDurationTypes] = useState<"monthly" | "range">(129"monthly",130);131const [vmMachine, setVmMachine] = useState<keyof VMsType | null>(null);132const [diskNameValid, setDiskNameValid] = useState<boolean>(false);133const [showInfo, setShowInfo] = useState<boolean>(false);134const [form] = Form.useForm();135const router = useRouter();136137// most likely, user will go to the cart next138useEffect(() => {139router.prefetch("/store/cart");140}, []);141142function fixupDuration() {143switch (form.getFieldValue("type")) {144case "disk":145setDurationTypes("monthly");146if (form.getFieldValue("period") === "range") {147form.setFieldsValue({ period: "monthly" });148}149break;150case "vm":151setDurationTypes("range");152if (form.getFieldValue("period") !== "range") {153form.setFieldsValue({ period: "range" });154}155break;156}157}158159function calcCost() {160const data = form.getFieldsValue(true);161162try {163switch (data.type) {164case "disk":165const size_gb = data["disk-size_gb"];166const speed = data["disk-speed"];167if (size_gb == null || speed == null) {168return; // no data to compute price169}170setCost(171computeCost({172type: "disk",173period: "monthly",174dedicated_disk: {175speed,176size_gb,177name: data["disk-name"],178},179}),180);181break;182case "vm":183setCost(184computeCost({185type: "vm",186period: "range",187range: data.range,188dedicated_vm: {189machine: data["vm-machine"],190},191}),192);193break;194}195} catch (err) {196setCost(undefined);197}198}199200function onChange() {201fixupDuration();202calcCost();203}204205async function loadItem(item: {206id: number;207product: string;208description: {209dedicated_disk?: any;210dedicated_vm?: any;211range?: DateRange;212title?: string;213description?: string;214};215}) {216if (item.product !== "site-license") {217throw new Error("not a site license");218}219const type = getType(item);220if (type !== "disk" && type !== "vm") {221throw new Error(`cannot deal with type ${type}`);222}223const conf = item.description;224225// restoring name/description226form.setFieldsValue({227title: conf.title,228description: conf.description,229});230231switch (type) {232case "disk":233const d = conf.dedicated_disk;234form.setFieldsValue({235type,236"disk-size_gb": d.size_gb,237"disk-speed": d.type,238"disk-name": d.name,239});240// we have to re-validate the disk name, b/c name could be taken in the meantime241// just calling the form to revalidate does not work.242try {243await testDedicatedDiskName(d.name);244setDiskNameValid(true);245} catch (err) {246setDiskNameValid(false);247}248break;249250case "vm":251const vm = conf.dedicated_vm?.machine;252if (PRICES.vms[vm] == null) {253console.warn(`VM type ${vm} not found`);254} else {255form.setFieldsValue({256"vm-machine": vm,257});258}259form.setFieldsValue({260type,261range: loadDateRange(conf.range),262});263break;264}265// unpacking and configuring the form worked, now we do the type selection to show it266setFormType(type);267}268269useEffect(() => {270const store_site_license_show_explanations = get_local_storage(271"store_site_license_show_explanations",272);273if (store_site_license_show_explanations != null) {274setShowExplanations(!!store_site_license_show_explanations);275}276const { id } = router.query;277if (!noAccount && id != null) {278// editing something in the shopping cart279(async () => {280try {281setLoading(true);282const item = await apiPost("/shopping/cart/get", { id });283await loadItem(item);284} catch (err) {285setCartError(err.message);286} finally {287setLoading(false);288}289onChange();290})();291}292onChange();293}, []);294295useEffect(() => {296const { type } = router.query;297if (typeof type === "string") {298setType(type);299}300}, []);301302useEffect(() => {303form.validateFields();304}, [form.getFieldValue("type")]);305306if (loading) {307return <Loading large center />;308}309310function setType(type: string) {311if (type === "vm" || type === "disk") {312form.resetFields();313form.setFieldsValue({ type });314setFormType(type);315setCost(undefined);316setCartError("");317onChange();318} else {319console.log(`unable to setType to ${type}`);320}321}322323function renderTypeSelection() {324return (325<Form.Item326name="type"327label="Dedicated"328rules={[{ required: true, message: "Please select a type" }]}329extra={330showExplanations && (331<div style={{ marginTop: "5px" }}>332Select if you want to get a Dedicate Disk or a Virtual Machine.333NOTE: Dedicated VM's are deprecated -- create a{" "}334<A href="https://doc.cocalc.com/compute_server.html">335compute server336</A>{" "}337instead.338</div>339)340}341>342<Radio.Group343disabled344onChange={(e) => {345// Clear error whenever changing this selection to something.346// See comment in validateDedicatedDiskName about how this347// isn't great.348setCartError("");349setType(e.target.value);350}}351>352<Radio.Button key={"disk"} value={"disk"} disabled>353Disk354</Radio.Button>355<Radio.Button key={"vm"} value={"vm"}>356Virtual Machine357</Radio.Button>358</Radio.Group>359</Form.Item>360);361}362363function renderAdditionalInfoContent() {364switch (formType) {365case "disk":366return (367<>368<Typography.Paragraph369ellipsis={{370expandable: true,371rows: 2,372symbol: "more",373onExpand: () => setShowInfo(true),374}}375>376This license attaches a disk to your project. When the license is377valid and activated by adding to a project, a disk will be created378on the fly. It will be formatted and mounted into your project.379You'll be able to access it via a symlink in your project's home380directory – i.e. <code>~/{HOME_PREFIX}/<name></code> will be381pointing to <code>{ROOT}/<name></code>.382</Typography.Paragraph>383<Typography.Paragraph style={{ display: showInfo ? "" : "none" }}>384Once you cancel the subscription, the subscription will end at the385end of the billing period. Then, the disk and all the data it386contains <strong>will be deleted</strong>!387</Typography.Paragraph>388<Typography.Paragraph style={{ display: showInfo ? "" : "none" }}>389It's also possible to move a disk from one project to another one.390First, remove the license from the project, restart the project to391unmount the disk. Then, add the license to another project and392restart that project as well.393</Typography.Paragraph>394<Typography.Paragraph style={{ display: showInfo ? "" : "none" }}>395Note: it is also possible to mount external data storage to a396project:{" "}397<A href={DOC_CLOUD_STORAGE_URL}>398cloud storage & remote file systems399</A>400. This could help transferring data in and out of <SiteName />.401</Typography.Paragraph>402</>403);404case "vm":405return (406<>407<Typography.Paragraph408ellipsis={{409expandable: true,410rows: 2,411symbol: "more",412onExpand: () => setShowInfo(true),413}}414>415For the specified period of time, a virtual machine is provisioned416and started inside of <SiteName />417's cluster. You have to add the license to one of your projects in418order to tell it to move to this virtual machine. This happens419when the project is started or restarted.420</Typography.Paragraph>421<Typography.Paragraph style={{ display: showInfo ? "" : "none" }}>422Once your project has moved over, the usual quota upgrades will be423ineffective – instead, your project runs with the quota limits424implied by the performance of the underlying virtual machine. The425files/data in your project will be exactly the same as before.426</Typography.Paragraph>427<Typography.Paragraph style={{ display: showInfo ? "" : "none" }}>428Once the license period is over, the virtual machine will be shut429down. At that point your project will be stopped as well. The next430time it starts, it will run under the usual quota regime on a431shared node in the cluster.432</Typography.Paragraph>433</>434);435}436}437438function renderAdditionalInfo() {439return (440<Form.Item label="How does it work?">441<div style={{ paddingTop: "5px" }}>{renderAdditionalInfoContent()}</div>442</Form.Item>443);444}445446function renderDurationExplanation() {447if (!showExplanations) return;448switch (durationTypes) {449case "monthly":450return (451<>452Currently, disk can be only be rented on a monthly basis only. Note:453you can cancel the subscription any time and at the end of the454billing period the disk – and the data it holds – will be destroyed.455</>456);457case "range":458return (459<>460Dedicated VMs can only be rented for a specific period of time. At461its end, the node will be stopped and removed, and your project462moves back to the usual upgrade schema.463</>464);465}466}467468function renderUsageAndDuration() {469return (470<UsageAndDuration471extraDuration={renderDurationExplanation()}472form={form}473onChange={onChange}474showUsage={false}475duration={durationTypes}476discount={false}477/>478);479}480481async function testDedicatedDiskName(name): Promise<void> {482testDedicatedDiskNameBasic(name);483// if the above passes, then we can check if the name is available.484const serverCheck = await apiPost("licenses/check-disk-name", { name }, 60);485if (serverCheck?.available === true) {486return;487} else {488throw new Error("Please choose a different disk name.");489}490}491492/**493* The disk name will get a prefix like "kucalc-[cluster id]-pd-[namespace]-dedicated-..."494* It's impossible to know the prefix, since the properties of the cluster can change.495* The maximum total length of the disk name is 63, according to the GCE documentation.496* https://cloud.google.com/compute/docs/naming-resources#resource-name-format497* I hope a max length of 20 is sufficiently restrictive.498*/499function validateDedicatedDiskName() {500return {501validator: async (_, name) => {502try {503await testDedicatedDiskName(name);504setDiskNameValid(true);505// WARNING! This is obviously not good code in general, since we're clearing all506// errors if the disk name happens to be valid.507// It's OK for now since this is the only field we do validation with, and508// any other error would be, e.g., in submission of the form to the backend.509setCartError("");510} catch (err) {511setCartError(err.message);512setDiskNameValid(false);513throw err;514}515},516};517}518519function renderDedicatedDiskInfo() {520if (!showExplanations) return;521return (522<p>523More information about Dedicated Disks can be found at{" "}524<A href={GCP_DISK_URL}>GCP: Persistent Disk</A>.525</p>526);527}528529function renderDiskPerformance() {530const size_gb = form.getFieldValue("disk-size_gb");531const speed = form.getFieldValue("disk-speed");532if (size_gb == null || speed == null) return;533const diskID = getDedicatedDiskKey({ size_gb, speed });534const di = PRICES.disks[diskID];535if (di == null) {536return (537<p style={{ marginTop: "5px" }}>538Unknown disk with ID <code>{diskID}</code>.539</p>540);541}542return (543<p style={{ marginTop: "5px" }}>544{di.mbps} MB/s sustained throughput and {di.iops} IOPS read/write. For545more detailed information:{" "}546<A href={GCP_DISK_PERFORMANCE_URL}>GCP disk performance</A> information.547</p>548);549}550551function renderDiskExtra() {552if (!showExplanations) return;553const formName = form.getFieldValue("disk-name");554const name = formName ? formName : <><name></>;555return (556<p>557Give your disk a name. It must be unique and will be used as part of the558directory name. The mount point will be{" "}559<code>560{ROOT}/{name}561</code>{" "}562and if the name isn't already taken. For your convenience, if possible563there will be a symlink named{" "}564<code>565~/{HOME_PREFIX}/{name}566</code>{" "}567pointing from your home directory to your disk for your convenience.568</p>569);570}571572// ATTN: the IntegerSlider must be kept in sync with DEDICATED_DISK_SIZES in573// src/packages/util/upgrades/dedicated.ts574function renderDedicatedDisk() {575return (576<>577<Form.Item578name="disk-name"579label="Name"580hasFeedback581extra={renderDiskExtra()}582rules={[validateDedicatedDiskName]}583>584<Input style={{ width: "15em" }} />585</Form.Item>586587<Form.Item588label="Size"589name="disk-size_gb"590initialValue={DEFAULT_DEDICATED_DISK_SIZE}591extra={592showExplanations && <>Select the size of the dedicated disk.</>593}594>595<IntegerSlider596min={MIN_DEDICATED_DISK_SIZE}597max={MAX_DEDICATED_DISK_SIZE}598step={DEDICATED_DISK_SIZE_INCREMENT}599onChange={(val) => {600form.setFieldsValue({ "disk-size_gb": val });601onChange();602}}603units={"G"}604presets={[32, 64, 128, 256, 512, 1024]}605/>606</Form.Item>607608<Form.Item609name="disk-speed"610label="Speed"611initialValue={DEFAULT_DEDICATED_DISK_SPEED}612extra={renderDedicatedDiskInfo()}613>614<Radio.Group615onChange={(e) => {616form.setFieldsValue({ "disk-speed": e.target.value });617onChange();618}}619>620{DedicatedDiskSpeedNames.map((type) => (621<Radio.Button key={type} value={type}>622{DISK_NAMES[type]}623</Radio.Button>624))}625</Radio.Group>626</Form.Item>627628<Form.Item label="Performance">{renderDiskPerformance()}</Form.Item>629</>630);631}632633function renderDedicatedVmInfo() {634if (!showExplanations) return;635return (636<>637More information about VM types can be found at{" "}638<A href={"https://cloud.google.com/compute/docs/machine-types"}>639GCP: machine families640</A>641.642</>643);644}645646function renderVmPerformance() {647if (vmMachine == null) return;648const { spec } = PRICES.vms?.[vmMachine] ?? {};649if (spec == null) {650return (651<p>652Problem: the specifications of <code>{vmMachine}</code> are not known653</p>654);655}656return (657<p>658Restarting your project while this license is active, will move your659project on a virtual machine in <SiteName />660's cluster. This machine will allow you to use up to {spec.cpu} vCPU661cores and {spec.mem} G memory.662</p>663);664}665666function dedicatedVmOptions() {667return sortBy(668Object.entries(PRICES.vms),669([_, vm]) =>670`${1000 + (vm?.spec.cpu ?? 0)}:${1000 + (vm?.spec.mem ?? 0)}`,671).map(([id, vm]: [string, NonNullable<VMsType[string]>]) => {672return (673<Select.Option key={id} value={id}>674<Text>{vm.title ?? vm.spec}</Text>675<Text style={{ paddingLeft: "1em" }} type="secondary">676({money(vm.price_day)} per day)677</Text>678</Select.Option>679);680});681}682683function renderDedicatedVM() {684return (685<>686<Form.Item687label="Type"688name="vm-machine"689initialValue={null}690extra={renderDedicatedVmInfo()}691rules={[{ required: true, message: "Please select a VM type." }]}692>693<Select694onChange={(val) => {695form.setFieldsValue({ "vm-machine": val });696setVmMachine(val);697onChange();698}}699>700{dedicatedVmOptions()}701</Select>702</Form.Item>703<Form.Item label="Performance">704<div style={{ paddingTop: "5px" }}>{renderVmPerformance()}</div>705</Form.Item>706</>707);708}709710function renderConfiguration() {711switch (formType) {712case "disk":713return renderDedicatedDisk();714case "vm":715return renderDedicatedVM();716}717}718719function renderCost() {720const input = cost?.input;721const disabled =722cost == null ||723input == null ||724(input.type === "vm" && (input.start == null || input.end == null)) ||725(input.type === "disk" && !diskNameValid);726727return (728<Form.Item wrapperCol={{ offset: 0, span: 24 }}>729<AddBox730cost={cost}731form={form}732cartError={cartError}733setCartError={setCartError}734router={router}735dedicatedItem={true}736disabled={disabled}737noAccount={noAccount}738/>739</Form.Item>740);741}742743function renderStartupWarning() {744if (formType !== "vm") return;745return (746<Form.Item label="Warning">747<Alert748type="warning"749showIcon750message="It takes about 15 minutes to start a Dedicated VM. Until then, the project will not be able to start."751/>752</Form.Item>753);754}755756return (757<div>758<InfoBar759show={showInfoBar}760cost={cost}761router={router}762form={form}763cartError={cartError}764setCartError={setCartError}765noAccount={noAccount}766/>767<ApplyLicenseToProject router={router} />768<SignInToPurchase noAccount={noAccount} />769<Form770form={form}771style={{772marginTop: "15px",773margin: "auto",774border: "1px solid #ddd",775padding: "15px",776}}777name="basic"778labelCol={{ span: 6 }}779wrapperCol={{ span: 18 }}780autoComplete="off"781onValuesChange={onChange}782>783<ToggleExplanations784showExplanations={showExplanations}785setShowExplanations={setShowExplanations}786/>787788{renderTypeSelection()}789790{formType != null && (791<>792{renderAdditionalInfo()}793{renderUsageAndDuration()}794795<Divider plain>Configuration</Divider>796{renderConfiguration()}797798<TitleDescription showExplanations={showExplanations} form={form} />799{renderStartupWarning()}800{renderCost()}801</>802)}803</Form>804</div>805);806}807808809