Path: blob/master/src/packages/frontend/compute/google-cloud-config.tsx
1503 views
import type {1Images,2State,3GoogleCloudConfiguration as GoogleCloudConfigurationType,4ComputeServerTemplate,5GoogleCloudAcceleratorType,6} from "@cocalc/util/db-schema/compute-servers";7import { reloadImages, useImages, useGoogleImages } from "./images-hook";8import { GOOGLE_CLOUD_DEFAULTS } from "@cocalc/util/db-schema/compute-servers";9import {10getMinDiskSizeGb,11getArchitecture,12} from "@cocalc/util/db-schema/compute-servers";13import {14Alert,15Button,16Checkbox,17Divider,18Popconfirm,19Radio,20Select,21Spin,22Switch,23Table,24Tooltip,25Typography,26} from "antd";27import { currency, cmp, plural } from "@cocalc/util/misc";28import computeCost, {29GoogleCloudData,30EXTERNAL_IP_COST,31DATA_TRANSFER_OUT_COST_PER_GiB,32markup,33computeAcceleratorCost,34computeInstanceCost,35computeDiskCost,36} from "@cocalc/util/compute/cloud/google-cloud/compute-cost";37import {38getGoogleCloudPriceData,39setImageTested,40setServerConfiguration,41} from "./api";42import { useEffect, useState } from "react";43import { A } from "@cocalc/frontend/components/A";44import { Icon } from "@cocalc/frontend/components/icon";45import { isEqual } from "lodash";46import { useTypedRedux } from "@cocalc/frontend/app-framework";47import SelectImage, { ImageLinks, ImageDescription } from "./select-image";48import Ephemeral from "./ephemeral";49import AutoRestart from "./auto-restart";50import AllowCollaboratorControl from "./allow-collaborator-control";51import NestedVirtualization from "./nested-virtualization";52import ShowError from "@cocalc/frontend/components/error";53import Proxy from "./proxy";54import CostOverview from "./cost-overview";55import Disk from "@cocalc/frontend/compute/cloud/common/disk";56import DNS from "@cocalc/frontend/compute/cloud/common/dns";57import ExcludeFromSync from "@cocalc/frontend/compute/exclude-from-sync";58import { availableClouds } from "./config";59import Template from "@cocalc/frontend/compute/cloud/common/template";60import Specs, {61RamAndCpu,62} from "@cocalc/frontend/compute/cloud/google-cloud/specs";63import { displayAcceleratorType } from "@cocalc/frontend/compute/cloud/google-cloud/accelerator";64import { filterOption } from "@cocalc/frontend/compute/util";6566export const SELECTOR_WIDTH = "350px";6768export const DEFAULT_GPU_CONFIG = GOOGLE_CLOUD_DEFAULTS.gpu2;6970// {71// acceleratorType: "nvidia-l4",72// acceleratorCount: 1,73// machineType: "g2-standard-4",74// region: "us-central1",75// zone: "us-central1-b",76// image: "pytorch",77// };7879const FALLBACK_INSTANCE = "n2-standard-4";80// an n1-standard-1 is SO dinky it causes huge trouble81// with downloading/processing models.82const DEFAULT_GPU_INSTANCE = "n1-highmem-2";8384interface ConfigurationType extends GoogleCloudConfigurationType {85valid?: boolean;86}8788interface Props {89configuration: ConfigurationType;90editable?: boolean;91// if id not set, then doesn't try to save anything to the backend92id?: number;93project_id?: string;94// called whenever changes are made.95onChange?: (configuration: ConfigurationType) => void;96disabled?: boolean;97state?: State;98data?;99setCloud?;100template?: ComputeServerTemplate;101}102103export default function GoogleCloudConfiguration({104configuration: configuration0,105editable,106id,107project_id,108onChange,109disabled,110state,111data,112setCloud,113template,114}: Props) {115const [IMAGES, ImagesError] = useImages();116const [googleImages, ImagesErrorGoogle] = useGoogleImages();117const [loading, setLoading] = useState<boolean>(false);118const [cost, setCost] = useState<number | null>(null);119const [priceData, setPriceData] = useState<GoogleCloudData | null>(null);120const [error, setError0] = useState<string>("");121const [configuration, setLocalConfiguration] =122useState<ConfigurationType>(configuration0);123const setError = (error) => {124setError0(error);125const valid = !error;126if (onChange != null && configuration.valid != valid) {127onChange({ ...configuration, valid });128}129};130131useEffect(() => {132if (!editable) {133setLocalConfiguration(configuration0);134}135}, [configuration0]);136137useEffect(() => {138(async () => {139try {140setLoading(true);141const data = await getGoogleCloudPriceData();142setPriceData(data);143} catch (err) {144setError(`${err}`);145} finally {146setLoading(false);147}148})();149}, []);150151useEffect(() => {152if (!editable || configuration == null || priceData == null) {153return;154}155try {156const cost = computeCost({ configuration, priceData });157setCost(cost);158} catch (err) {159setError(`${err}`);160setCost(null);161}162}, [configuration, priceData]);163164if (ImagesError != null) {165return ImagesError;166}167if (ImagesErrorGoogle != null) {168return ImagesErrorGoogle;169}170171if (IMAGES == null || googleImages == null) {172return <Spin />;173}174175const summary = (176<Specs177configuration={configuration}178priceData={priceData}179IMAGES={IMAGES}180/>181);182183if (!editable || !project_id) {184// short summary only185return summary;186}187188if (priceData == null) {189return <Spin />;190}191192const setConfig = async (changes) => {193let changed = false;194for (const key in changes) {195if (!isEqual(changes[key], configuration[key])) {196changed = true;197break;198}199}200if (!changed) {201// nothing at all changed202return;203}204205changes = ensureConsistentConfiguration(206priceData,207configuration,208changes,209IMAGES,210);211const newConfiguration = { ...configuration, ...changes };212213if (214(state ?? "deprovisioned") != "deprovisioned" &&215(configuration.region != newConfiguration.region ||216configuration.zone != newConfiguration.zone)217) {218setError(219"Can't change the region or zone without first deprovisioning the VM",220);221// make copy so config gets reset -- i.e., whatever change you just tried to make is reverted.222setLocalConfiguration({ ...configuration });223return;224}225226if (Object.keys(changes).length == 0) {227// nothing going to change228return;229}230231try {232setLoading(true);233if (onChange != null) {234onChange(newConfiguration);235}236setLocalConfiguration(newConfiguration);237if (id != null) {238await setServerConfiguration({ id, configuration: changes });239}240} catch (err) {241setError(`${err}`);242} finally {243setLoading(false);244}245};246247const columns = [248{249dataIndex: "value",250key: "value",251},252{ dataIndex: "label", key: "label", width: 130 },253];254255const dataSource = [256{257key: "provisioning",258label: (259<A href="https://cloud.google.com/compute/docs/instances/spot">260<Icon name="external-link" /> Provisioning261</A>262),263value: (264<Provisioning265disabled={loading || disabled}266priceData={priceData}267setConfig={setConfig}268configuration={configuration}269/>270),271},272{273key: "gpu",274label: (275<A href="https://cloud.google.com/compute/docs/gpus">276<Icon name="external-link" /> GPUs277</A>278),279value: (280<GPU281state={state}282disabled={loading || disabled}283priceData={priceData}284setConfig={setConfig}285configuration={configuration}286IMAGES={IMAGES}287setCloud={setCloud}288/>289),290},291{292key: "image",293label: (294<ImageLinks image={configuration.image} style={{ height: "90px" }} />295),296value: (297<Image298state={state}299disabled={loading || disabled}300setConfig={setConfig}301configuration={configuration}302gpu={303!!(configuration.acceleratorType && configuration.acceleratorCount)304}305googleImages={googleImages}306arch={getArchitecture(configuration)}307/>308),309},310311{312key: "machineType",313label: (314<A href="https://cloud.google.com/compute/docs/machine-resource#recommendations_for_machine_types">315<Icon name="external-link" /> Machine Types316</A>317),318value: (319<MachineType320state={state}321disabled={loading || disabled}322priceData={priceData}323setConfig={setConfig}324configuration={configuration}325/>326),327},328329{330key: "region",331label: (332<A href="https://cloud.google.com/about/locations">333<Icon name="external-link" /> Regions334</A>335),336value: (337<Region338disabled={339loading || disabled || (state ?? "deprovisioned") != "deprovisioned"340}341priceData={priceData}342setConfig={setConfig}343configuration={configuration}344/>345),346},347{348key: "zone",349label: (350<A href="https://cloud.google.com/about/locations">351<Icon name="external-link" /> Zones352</A>353),354value: (355<Zone356disabled={357loading || disabled || (state ?? "deprovisioned") != "deprovisioned"358}359priceData={priceData}360setConfig={setConfig}361configuration={configuration}362/>363),364},365366{367key: "disk",368label: (369<A href="https://cloud.google.com/compute/docs/disks/performance">370<Icon name="external-link" /> Disks371</A>372),373value: (374<BootDisk375id={id}376disabled={loading}377setConfig={setConfig}378configuration={configuration}379priceData={priceData}380state={state}381IMAGES={IMAGES}382/>383),384},385{386key: "exclude",387value: (388<ExcludeFromSync389id={id}390disabled={loading}391setConfig={setConfig}392configuration={configuration}393state={state}394style={{ marginTop: "10px", color: "#666" }}395/>396),397},398{399key: "network",400label: (401<A href="https://cloud.google.com/compute/docs/network-bandwidth">402<Icon name="external-link" /> Network403</A>404),405value: (406<Network407setConfig={setConfig}408configuration={configuration}409loading={loading}410priceData={priceData}411/>412),413},414{415key: "proxy",416label: <></>,417value: (418<Proxy419setConfig={setConfig}420configuration={configuration}421data={data}422state={state}423IMAGES={IMAGES}424project_id={project_id}425id={id}426/>427),428},429430{431key: "ephemeral",432label: <></>,433value: (434<Ephemeral435setConfig={setConfig}436configuration={configuration}437loading={loading}438/>439),440},441{442key: "auto-restart",443label: <></>,444value: (445<AutoRestart446setConfig={setConfig}447configuration={configuration}448loading={loading}449/>450),451},452{453key: "allow-collaborator-control",454label: <></>,455value: (456<AllowCollaboratorControl457setConfig={setConfig}458configuration={configuration}459loading={loading}460/>461),462},463{464key: "nested-virtualization",465label: <></>,466value: (467<NestedVirtualization468setConfig={setConfig}469configuration={configuration}470loading={loading}471/>472),473},474{475key: "admin",476label: <></>,477value: (478<Admin479id={id}480configuration={configuration}481loading={loading}482template={template}483/>484),485},486];487488const errDisplay = error ? (489<div490style={{491/*minHeight: "35px", */492padding: "5px 10px",493background: error ? "red" : undefined,494color: "white",495borderRadius: "5px",496}}497>498{error}499<Button500size="small"501onClick={() => {502setError("");503setLocalConfiguration(configuration0);504}}505style={{ float: "right" }}506>507Close508</Button>509</div>510) : undefined;511512return (513<div>514{loading && (515<div style={{ float: "right" }}>516<Spin delay={1000} />517</div>518)}519{errDisplay}520{cost != null && (521<CostOverview522cost={cost}523description={524<>525You pay <b>{currency(cost)}/hour</b> while the computer server is526running. The rate is{" "}527<b>528{currency(529computeCost({ configuration, priceData, state: "off" }),530)}531/hour532</b>{" "}533when the server is off, and there is no cost when it is534deprovisioned. Network data transfer out charges are not included535in the above cost, and depend on how much data leaves the server536(see the Network section below). Incoming networking is free.537</>538}539/>540)}541<Divider />542<div style={{ textAlign: "center", margin: "10px 80px" }}>{summary}</div>543<Divider />544<Table545showHeader={false}546style={{ marginTop: "5px" }}547columns={columns}548dataSource={dataSource}549pagination={false}550/>551{errDisplay}552</div>553);554}555556function Region({ priceData, setConfig, configuration, disabled }) {557const [sortByPrice, setSortByPrice] = useState<boolean>(true);558const [newRegion, setNewRegion] = useState<string>(configuration.region);559useEffect(() => {560setNewRegion(configuration.region);561}, [configuration.region]);562563const regions = getRegions(priceData, configuration);564if (sortByPrice) {565regions.sort((a, b) => cmp(a.cost, b.cost));566}567const options = regions.map(({ region, location, lowCO2, cost }) => {568const price = <CostPerHour cost={cost} extra={" (total)"} />;569return {570value: region,571search: `${region} ${location} ${lowCO2 ? " co2 " : ""}`,572label: (573<div key={region} style={{ display: "flex" }}>574<div style={{ flex: 1 }}> {region}</div>575<div style={{ flex: 1 }}>{price}</div>576<div style={{ flex: 0.7 }}> {lowCO2 ? "🍃 Low CO2" : ""}</div>577<div style={{ flex: 0.8 }}> {location?.split(",")[1].trim()}</div>578</div>579),580};581});582583return (584<div>585{configuration.machineType ? (586<div style={{ color: "#666", marginBottom: "5px" }}>587<b>588<Icon name="global" /> Region589</b>590</div>591) : undefined}592<div>593<Select594disabled={disabled}595style={{ width: "100%" }}596options={options as any}597value={newRegion}598onChange={(region) => {599setNewRegion(region);600setConfig({ region });601}}602showSearch603optionFilterProp="children"604filterOption={filterOption}605/>606</div>607<div>608<Checkbox609disabled={disabled}610style={{ marginTop: "5px" }}611checked={sortByPrice}612onChange={() => setSortByPrice(!sortByPrice)}613>614Sort by price615</Checkbox>616<div style={{ color: "#666", marginTop: "5px" }}>617Price above is total price in this region for the machine, disk and618GPU.619</div>620</div>621</div>622);623}624625// Gets the regions where the given VM type is available.626// Ignores the currently selected zone.627function getRegions(priceData, configuration) {628const lowCO2 = new Set<string>();629const regions = new Set<string>();630const location: { [region: string]: string } = {};631const cost: { [region: string]: number } = {};632const { machineType, spot } = configuration ?? {};633for (const zone in priceData.zones) {634const i = zone.lastIndexOf("-");635const region = zone.slice(0, i);636const zoneData = priceData.zones[zone];637if (machineType) {638if (!zoneData.machineTypes.includes(machineType.split("-")[0])) {639continue;640}641if (spot) {642if (priceData.machineTypes[machineType]?.spot?.[region] == null) {643continue;644}645}646}647if (cost[region] == null) {648try {649cost[region] = computeCost({650priceData,651configuration: { ...configuration, region, zone },652});653} catch (_) {654continue;655// console.warn({ ...configuration, region, zone }, err);656}657}658if (zoneData.lowCO2 || zoneData.lowC02) {659// C02 above because of typo in data.660lowCO2.add(region);661}662regions.add(region);663location[region] = zoneData.location;664}665const v = Array.from(regions);666v.sort((a, b) => {667for (const g of [668"us",669"northamerica",670"europe",671"asia",672"southamerica",673"australia",674]) {675if (a.startsWith(g) && !b.startsWith(g)) {676return -1;677}678if (!a.startsWith(g) && b.startsWith(g)) {679return 1;680}681}682return cmp(a, b);683});684const data: {685region: string;686location: string;687lowCO2: boolean;688cost?: number;689}[] = [];690for (const region of v) {691data.push({692region,693location: location[region],694lowCO2: lowCO2.has(region),695cost: cost[region],696});697}698return data;699}700701// Gets the zones compatible with the other configuration702function getZones(priceData, configuration) {703const lowCO2 = new Set<string>();704const zones = new Set<string>();705const { region, machineType, acceleratorType, spot } = configuration;706const prefix = machineType.split("-")[0];707for (const zone in priceData.zones) {708if (region != zoneToRegion(zone)) {709// this zone isn't in the chosen region.710continue;711}712const zoneData = priceData.zones[zone];713if (machineType) {714if (!zoneData.machineTypes.includes(prefix)) {715continue;716}717if (spot != null) {718if (priceData.machineTypes[machineType]?.spot?.[region] == null) {719continue;720}721}722}723if (acceleratorType) {724if (priceData.accelerators[acceleratorType]?.prices?.[zone] == null) {725// not in this zone.726continue;727}728}729if (zoneData.lowCO2 || zoneData.lowC02) {730// C02 above because of typo in data.731lowCO2.add(zone);732}733zones.add(zone);734}735const v = Array.from(zones);736v.sort();737const data: {738zone: string;739lowCO2: boolean;740}[] = [];741for (const zone of v) {742data.push({743zone,744lowCO2: lowCO2.has(zone),745});746}747return data;748}749750function Provisioning({ priceData, setConfig, configuration, disabled }) {751const [newSpot, setNewSpot] = useState<boolean>(!!configuration.spot);752const [prices, setPrices] = useState<{753spot: number | null;754standard: number;755discount: number;756} | null>(getSpotAndStandardPrices(priceData, configuration));757758useEffect(() => {759setNewSpot(!!configuration.spot);760setPrices(getSpotAndStandardPrices(priceData, configuration));761}, [configuration]);762763useEffect(() => {764if (configuration.spot && prices != null && !prices.spot) {765setNewSpot(false);766setConfig({ spot: false });767}768}, [prices, configuration.spot]);769770return (771<div>772<div style={{ color: "#666", marginBottom: "5px" }}>773<b>774<Icon name="sliders" /> Provisioning775</b>776</div>777<Radio.Group778size="large"779buttonStyle="solid"780disabled={disabled}781value={newSpot ? "spot" : "standard"}782onChange={(e) => {783const spot = e.target.value == "standard" ? false : true;784setNewSpot(spot);785setConfig({ spot });786}}787>788<Radio.Button value="spot" disabled={!prices?.spot}>789Spot{" "}790{prices?.spot791? `${currency(prices.spot)}/hour (${prices.discount}% discount)`792: "(not available)"}{" "}793</Radio.Button>794<Radio.Button value="standard">795Standard{" "}796{prices != null797? `${currency(prices.standard)}/hour`798: undefined}{" "}799</Radio.Button>800</Radio.Group>801<div style={{ color: "#666", marginTop: "5px" }}>802Standard VM's run until you stop them, whereas spot VM's are up to 91%803off, but will automatically stop when there is a surge in demand. Spot804instances might also not be available in a given region, so you may have805to try different regions.{" "}806{configuration.acceleratorType && (807<> GPU's are always in high demand.</>808)}809{newSpot && (810<Alert811style={{ margin: "5px 0" }}812type="warning"813showIcon814description={815<div style={{ maxWidth: "100%", lineHeight: 1 }}>816This is a heavily discounted spot instance. It will817automatically{" "}818{configuration.autoRestart ? " reboot if possible " : " stop "}{" "}819when there is a surge in demand.820{!disabled && (821<Popconfirm822title="Switch to Standard?"823description={824<div style={{ maxWidth: "450px" }}>825This will switch to a non-discounted standard instance,826which stays running even if there is high demand. You827can switch back to a spot instance using the blue toggle828above.829</div>830}831onConfirm={() => {832setNewSpot(false);833setConfig({ spot: false });834}}835okText="Switch to Standard"836cancelText="Cancel"837>838<Button type="link">Switch to Standard</Button>839</Popconfirm>840)}841{!configuration.autoRestart && (842<Popconfirm843title="Enable Automatic Restart?"844description={845<div style={{ maxWidth: "450px" }}>846CoCalc will automatically restart your compute server if847it is killed due to high demand. Note that there might848not be any compute resources available, in which case849you will have to wait for your server to start. You can850disable this in the "Automatically Restart" section851below.852</div>853}854onConfirm={() => {855setConfig({ autoRestart: true });856}}857okText="Enable Automatic Restart"858cancelText="Cancel"859>860<Button type="link">Enable Automatic Restart</Button>861</Popconfirm>862)}863</div>864}865/>866)}867</div>868</div>869);870}871872function getSpotAndStandardPrices(priceData, configuration) {873try {874const standard = computeCost({875priceData,876configuration: { ...configuration, spot: false },877});878let spot: number | null = null;879try {880spot = computeCost({881priceData,882configuration: { ...configuration, spot: true },883});884} catch (_) {885// some machines have no spot instance support, eg h3's.886}887return {888standard,889spot,890discount: spot != null ? Math.round((1 - spot / standard) * 100) : 0,891};892} catch (_) {893return null;894}895}896897function Zone({ priceData, setConfig, configuration, disabled }) {898const [newZone, setNewZone] = useState<string>(configuration.zone ?? "");899useEffect(() => {900setNewZone(configuration.zone);901}, [configuration.zone]);902903const zones = getZones(priceData, configuration);904const options = zones.map(({ zone, lowCO2 }) => {905return {906value: zone,907search: `${zone} ${lowCO2 ? " co 2" : ""}`,908label: `${zone} ${lowCO2 ? " - 🍃 Low CO2" : ""}`,909};910});911912return (913<div>914{configuration.machineType ? (915<div style={{ color: "#666", marginBottom: "5px" }}>916<b>917<Icon name="aim" /> Zone918</b>{" "}919in {configuration.region} with {configuration.machineType}{" "}920{configuration.spot ? "spot" : ""} VM's921</div>922) : undefined}923<Select924disabled={disabled}925style={{ width: SELECTOR_WIDTH }}926options={options}927value={newZone}928onChange={(zone) => {929setNewZone(zone);930setConfig({ zone });931}}932showSearch933optionFilterProp="children"934filterOption={filterOption}935/>936</div>937);938}939940function MachineType({ priceData, setConfig, configuration, disabled, state }) {941const [archType, setArchType] = useState<"x86_64" | "arm64">(942getArchitecture(configuration),943);944const [sortByPrice, setSortByPrice] = useState<boolean>(true);945const [newMachineType, setNewMachineType] = useState<string>(946configuration.machineType ?? "",947);948useEffect(() => {949setNewMachineType(configuration.machineType);950setArchType(getArchitecture(configuration));951}, [configuration.machineType]);952useEffect(() => {953if (archType == "arm64" && getArchitecture(configuration) != "arm64") {954setNewMachineType("t2a-standard-4");955setConfig({ machineType: "t2a-standard-4" });956return;957}958if (archType == "x86_64" && getArchitecture(configuration) == "arm64") {959setNewMachineType("t2d-standard-4");960setConfig({ machineType: "t2d-standard-4" });961return;962}963}, [archType, configuration.machineType]);964965const machineTypes = Object.keys(priceData.machineTypes);966let allOptions = machineTypes967.filter((machineType) => {968const { acceleratorType } = configuration;969if (!acceleratorType) {970if (machineType.startsWith("g") || machineType.startsWith("a")) {971return false;972}973if (archType == "arm64" && getArchitecture(configuration) != "arm64") {974return false;975}976if (archType == "x86_64" && getArchitecture(configuration) == "arm64") {977return false;978}979} else {980if (acceleratorType == "nvidia-tesla-t4") {981return machineType.startsWith("n1-");982} else {983const machines =984priceData.accelerators[acceleratorType].machineType[985configuration.acceleratorCount ?? 1986] ?? [];987return machines.includes(machineType);988}989}990991return true;992})993.map((machineType) => {994let cost;995try {996cost = computeInstanceCost({997priceData,998configuration: { ...configuration, machineType },999});1000} catch (_) {1001cost = null;1002}1003const data = priceData.machineTypes[machineType];1004const { memory, vcpu } = data;1005return {1006value: machineType,1007search: machineType + ` memory:${memory} ram:${memory} cpu:${vcpu} `,1008cost,1009label: (1010<div key={machineType} style={{ display: "flex" }}>1011<div style={{ flex: 1 }}>{machineType}</div>1012<div style={{ flex: 1 }}>1013{cost ? (1014<CostPerHour cost={cost} />1015) : (1016<span style={{ color: "#666" }}>(region/zone changes)</span>1017)}1018</div>1019<div style={{ flex: 2 }}>1020<RamAndCpu machineType={machineType} priceData={priceData} />1021</div>1022</div>1023),1024};1025});1026const options = [1027{1028label: "Machine Types",1029options: allOptions.filter((x) => x.cost),1030},1031{1032label: "Location Will Change",1033options: allOptions.filter((x) => !x.cost),1034},1035];10361037if (sortByPrice) {1038options[0].options.sort((a, b) => {1039return cmp(a.cost, b.cost);1040});1041}10421043return (1044<div>1045<div style={{ color: "#666", marginBottom: "5px" }}>1046<Tooltip1047title={1048(state ?? "deprovisioned") != "deprovisioned"1049? "Can only be changed when machine is deprovisioned"1050: archType == "x86_64"1051? "Intel or AMD X86_64 architecture machines"1052: "ARM64 architecture machines"1053}1054>1055<Radio.Group1056style={{ float: "right" }}1057disabled={1058disabled ||1059configuration.acceleratorType ||1060(state ?? "deprovisioned") != "deprovisioned"1061}1062options={[1063{ value: "x86_64", label: "X86_64" },1064{ value: "arm64", label: "ARM64" },1065]}1066value={archType}1067onChange={({ target: { value } }) => {1068setArchType(value);1069}}1070/>1071</Tooltip>1072<b>1073<Icon name="microchip" /> Machine Type1074</b>1075</div>1076<div>1077<Select1078disabled={disabled}1079style={{ width: "100%" }}1080options={options as any}1081value={newMachineType}1082onChange={(machineType) => {1083setNewMachineType(machineType);1084setConfig({ machineType });1085}}1086showSearch1087optionFilterProp="children"1088filterOption={filterOption}1089/>1090</div>1091<div>1092<Checkbox1093disabled={disabled}1094style={{ marginTop: "5px" }}1095checked={sortByPrice}1096onChange={() => setSortByPrice(!sortByPrice)}1097>1098Sort by price1099</Checkbox>1100</div>1101<div style={{ color: "#666", marginTop: "5px" }}>1102Prices and availability depend on the region and provisioning type, so1103adjust those below to find the best overall value. Price above is just1104for the machine, and not the disk or GPU. Search for <code>cpu:4⌴</code>{" "}1105and <code>ram:8⌴</code> to only show options with 4 vCPUs and 8GB RAM.1106</div>1107</div>1108);1109}11101111function BootDisk(props) {1112return (1113<Disk1114{...props}1115minSizeGb={getMinDiskSizeGb(props)}1116maxSizeGb={65536}1117computeDiskCost={computeDiskCost}1118/>1119);1120}11211122function Image(props) {1123const { state = "deprovisioned" } = props;1124return (1125<div>1126<div style={{ color: "#666", marginBottom: "5px" }}>1127<b>1128<Icon name="disk-round" /> Image1129</b>1130</div>1131{state == "deprovisioned" && (1132<div style={{ color: "#666", marginBottom: "5px" }}>1133Select compute server image. You will be able to use sudo as root with1134no password, and can install anything into the Ubuntu Linux image,1135including commercial software.1136</div>1137)}1138<SelectImage {...props} />1139{state != "deprovisioned" && (1140<div style={{ color: "#666", marginTop: "5px" }}>1141You can only edit the image when server is deprovisioned.1142</div>1143)}1144<div style={{ color: "#666", marginTop: "5px" }}>1145<ImageDescription configuration={props.configuration} />1146</div>1147</div>1148);1149}11501151// We do NOT include the P4, P100, V100 or K80, which are older1152// and for which our base image and drivers don't work.1153// If for some reason we need them, we will have to switch to1154// different base drivers or have even more images.11551156// NOTE: H200 disabled because it requires a reservation.11571158const ACCELERATOR_TYPES = [1159"nvidia-tesla-t4",1160"nvidia-l4",1161"nvidia-tesla-a100",1162"nvidia-a100-80gb",1163"nvidia-h100-80gb",1164// "nvidia-h200-141gb",1165// these are too hard to properly keep software image for:1166// "nvidia-tesla-v100",1167//"nvidia-tesla-p100",1168//"nvidia-tesla-p4",1169];11701171/*1172<A href="https://www.nvidia.com/en-us/data-center/tesla-p100/">P100</A>,{" "}1173<A href="https://www.nvidia.com/en-us/data-center/v100/">V100</A>,{" "}1174<A href="https://www.nvidia.com/content/dam/en-zz/Solutions/design-visualization/solutions/resources/documents1/nvidia-p4-datasheet.pdf">1175P41176</A>1177*/11781179function GPU({1180priceData,1181setConfig,1182configuration,1183disabled,1184state,1185IMAGES,1186setCloud,1187}) {1188const { acceleratorType, acceleratorCount } = configuration;1189const head = (1190<div style={{ color: "#666", marginBottom: "5px" }}>1191<b>1192<Icon style={{ float: "right", fontSize: "50px" }} name="gpu" />1193<Icon name="cube" /> NVIDIA GPU{" "}1194<div style={{ float: "right" }}>1195<A href="https://www.nvidia.com/content/dam/en-zz/Solutions/design-visualization/solutions/resources/documents1/Datasheet_NVIDIA_T4_Virtualization.pdf">1196T41197</A>1198, <A href="https://www.nvidia.com/en-us/data-center/l4/">L4</A>,{" "}1199<A href="https://www.nvidia.com/en-us/data-center/a100/">A100</A>,{" "}1200<A href="https://www.nvidia.com/en-us/data-center/h100/">H100</A>1201</div>1202</b>1203</div>1204);12051206const theSwitch = (1207<Switch1208disabled={disabled || (state ?? "deprovisioned") != "deprovisioned"}1209checkedChildren={"NVIDIA GPU"}1210unCheckedChildren={"NO GPU"}1211checked={!!acceleratorType}1212onChange={() => {1213if (!!acceleratorType) {1214setConfig({ acceleratorType: "", acceleratorCount: 0 });1215} else {1216setConfig({1217...DEFAULT_GPU_CONFIG,1218spot: configuration?.spot ?? false,1219});1220}1221}}1222/>1223);1224if (!acceleratorType) {1225return (1226<div>1227{head}1228{theSwitch}1229</div>1230);1231}12321233const options = ACCELERATOR_TYPES.filter(1234(acceleratorType) => priceData.accelerators[acceleratorType] != null,1235).map((acceleratorType: GoogleCloudAcceleratorType) => {1236let cost;1237const config1 = { ...configuration, acceleratorType, acceleratorCount };1238const changes = { acceleratorType, acceleratorCount };1239try {1240cost = computeAcceleratorCost({ priceData, configuration: config1 });1241} catch (_) {1242const newChanges = ensureConsistentConfiguration(1243priceData,1244config1,1245changes,1246IMAGES,1247);1248cost = computeAcceleratorCost({1249priceData,1250configuration: { ...config1, ...newChanges },1251});1252}1253const memory = priceData.accelerators[acceleratorType].memory;1254return {1255value: acceleratorType,1256search: acceleratorType,1257cost,1258memory,1259label: (1260<div key={acceleratorType} style={{ display: "flex" }}>1261<div style={{ flex: 1 }}>1262{displayAcceleratorType(acceleratorType, memory)}1263</div>1264<div style={{ flex: 1 }}>1265<CostPerHour cost={cost} />1266</div>1267</div>1268),1269};1270});12711272const countOptions: any[] = [];1273const min = priceData.accelerators[acceleratorType]?.count ?? 1;1274const max = priceData.accelerators[acceleratorType]?.max ?? 1;1275for (let i = min; i <= max; i *= 2) {1276countOptions.push({ label: `${i}`, value: i });1277}12781279return (1280<div>1281{head}1282{theSwitch}1283<div style={{ marginTop: "15px" }}>1284<Select1285disabled={disabled || (state ?? "deprovisioned") != "deprovisioned"}1286style={{ width: SELECTOR_WIDTH }}1287options={options as any}1288value={acceleratorType}1289onChange={(type) => {1290setConfig({ acceleratorType: type });1291// todo -- change count if necessary1292}}1293showSearch1294optionFilterProp="children"1295filterOption={filterOption}1296/>1297<Select1298style={{ marginLeft: "15px", width: "75px" }}1299disabled={disabled || (state ?? "deprovisioned") != "deprovisioned"}1300options={countOptions}1301value={acceleratorCount}1302onChange={(count) => {1303setConfig({ acceleratorCount: count });1304}}1305/>1306{acceleratorCount && acceleratorType && (1307<div style={{ color: "#666", marginTop: "10px" }}>1308You have selected {acceleratorCount} dedicated{" "}1309<b>{displayAcceleratorType(acceleratorType)}</b>{" "}1310{plural(acceleratorCount, "GPU")}, with a total of{" "}1311<b>1312{priceData.accelerators[acceleratorType].memory *1313acceleratorCount}1314GB GPU RAM1315</b>1316.{" "}1317{acceleratorCount > 1 && (1318<>1319The {acceleratorCount} GPUs will be available on the same1320server.1321</>1322)}1323{1324(state ?? "deprovisioned") != "deprovisioned" && (1325<div>1326You can only change the GPU configuration when the server is1327deprovisioned.1328</div>1329) /* this is mostly a google limitation, not cocalc, though we will eventually do somthing involving recreating the machine. BUT note that e.g., changing the count for L4's actually breaks booting up! */1330}1331{setCloud != null &&1332availableClouds().includes("hyperstack") &&1333(state ?? "deprovisioned") == "deprovisioned" && (1334<Alert1335showIcon1336style={{ margin: "10px 0 5px 0" }}1337type="warning"1338description={1339<div>1340We have partnered with Hyperstack cloud to provide NVIDIA1341H100, A100, L40, and RTX-A4/5/6000 GPUs at a{" "}1342<b>much cheaper price</b> than Google cloud.{" "}1343<Popconfirm1344title="Switch to Hyperstack"1345description={1346<div style={{ maxWidth: "450px" }}>1347This will change the cloud for this compute server1348to Hyperstack, and reset its configuration. Your1349compute server is not storing any data so this is1350safe.1351</div>1352}1353onConfirm={() => {1354setCloud("hyperstack");1355}}1356okText="Switch to Hyperstack"1357cancelText="Cancel"1358>1359<Button type="link">Switch...</Button>1360</Popconfirm>1361</div>1362}1363/>1364)}1365</div>1366)}1367</div>1368</div>1369);1370}1371/*1372{acceleratorType?.includes("a100") && configuration.spot ? (1373<div style={{ marginTop: "5px", color: "#666" }}>1374<b>WARNING:</b> A100 spot instances are rarely available. Consider1375standard provisioning instead.1376</div>1377) : undefined}1378*/13791380function ensureConsistentConfiguration(1381priceData,1382configuration: GoogleCloudConfigurationType,1383changes: Partial<GoogleCloudConfigurationType>,1384IMAGES: Images,1385) {1386const newConfiguration = { ...configuration, ...changes };1387const newChanges = { ...changes };1388ensureConsistentImage(newConfiguration, newChanges, IMAGES);1389ensureConsistentAccelerator(priceData, newConfiguration, newChanges);1390ensureConsistentNvidiaL4andA100(priceData, newConfiguration, newChanges);1391ensureConsistentZoneWithRegion(priceData, newConfiguration, newChanges);1392ensureConsistentRegionAndZoneWithMachineType(1393priceData,1394newConfiguration,1395newChanges,1396);1397ensureSufficientDiskSize(newConfiguration, newChanges, IMAGES);1398ensureConsistentDiskType(priceData, newConfiguration, newChanges);13991400return newChanges;1401}14021403// We make the image consistent with the gpu selection.1404function ensureConsistentImage(configuration, changes, IMAGES) {1405const { gpu } = IMAGES[configuration.image] ?? {};1406const gpuSelected =1407configuration.acceleratorType && configuration.acceleratorCount > 0;1408if (gpu == gpuSelected) {1409// they are consistent1410return;1411}1412if (gpu && !gpuSelected) {1413// GPU image but non-GPU machine -- change image to non-GPU1414configuration["image"] = changes["image"] = "python";1415configuration["tag"] = changes["tag"] = null;1416} else if (!gpu && gpuSelected) {1417// GPU machine but not image -- change image to pytorch1418configuration["image"] = changes["image"] = "pytorch";1419configuration["tag"] = changes["tag"] = null;1420}1421}14221423function ensureSufficientDiskSize(configuration, changes, IMAGES) {1424const min = getMinDiskSizeGb({ configuration, IMAGES });1425if ((configuration.diskSizeGb ?? 0) < min) {1426changes.diskSizeGb = min;1427}1428}14291430function ensureConsistentDiskType(priceData, configuration, changes) {1431const { machineType } = configuration;1432const m = machineType.split("-")[0];1433if (configuration.diskType == "hyperdisk-balanced") {1434// make sure machine is supported1435const { supportedMachineTypes } = priceData.extra["hyperdisk-balanced"];1436if (!supportedMachineTypes.includes(m)) {1437// can't use hyperdisk on this machine, so fix.1438configuration.diskType = changes.diskType = "pd-balanced";1439}1440} else {1441const { requiredMachineTypes } = priceData.extra["hyperdisk-balanced"];1442if (requiredMachineTypes.includes(m)) {1443// must use hyperdisk on this machine, so fix.1444configuration.diskType = changes.diskType = "hyperdisk-balanced";1445}1446}1447}14481449function ensureConsistentZoneWithRegion(priceData, configuration, changes) {1450if (configuration.zone.startsWith(configuration.region)) {1451return;1452}1453if (changes["region"]) {1454// currently changing region, so set a zone that matches the region1455for (const zone in priceData.zones) {1456if (zone.startsWith(configuration.region)) {1457configuration["zone"] = changes["zone"] = zone;1458break;1459}1460}1461} else {1462// probably changing the zone, so set the region from the zone1463configuration["region"] = changes["region"] = zoneToRegion(1464configuration.zone,1465);1466}1467}14681469function ensureConsistentAccelerator(priceData, configuration, changes) {1470let { acceleratorType } = configuration;1471if (!acceleratorType) {1472return;1473}1474if (1475acceleratorType == "nvidia-tesla-a100" ||1476acceleratorType == "nvidia-a100-80gb" ||1477acceleratorType == "nvidia-l4"1478) {1479// L4 and A100 are handled elsewhere.1480return;1481}14821483// have a GPU1484let data = priceData.accelerators[acceleratorType];1485if (!data) {1486// accelerator type no longer exists; replace it by one that does.1487for (const type in priceData.accelerators) {1488acceleratorType =1489configuration["acceleratorType"] =1490changes["acceleratorType"] =1491type;1492data = priceData.accelerators[acceleratorType];1493break;1494}1495}1496if (data == null) {1497throw Error("bug");1498}1499// Ensure the machine type is consistent1500if (!configuration.machineType.startsWith(data.machineType)) {1501if (changes["machineType"]) {1502// if you are explicitly changing the machine type, then we respect1503// that and disabled the gpu1504configuration["acceleratorType"] = changes["acceleratorType"] = "";1505configuration["acceleratorCount"] = changes["acceleratorCount"] = 0;1506return;1507} else {1508// changing something else, so we fix the machine type1509for (const type in priceData.machineTypes) {1510if (type.startsWith(data.machineType)) {1511configuration["machineType"] = changes["machineType"] =1512type.startsWith("n1-") ? DEFAULT_GPU_INSTANCE : type;1513break;1514}1515}1516}1517}1518ensureZoneIsConsistentWithGPU(priceData, configuration, changes);15191520// Ensure the count is consistent1521const count = configuration.acceleratorCount ?? 0;1522if (count < data.count) {1523changes["acceleratorCount"] = data.count;1524} else if (count > data.max) {1525changes["acceleratorCount"] = data.max;1526}1527}15281529function ensureZoneIsConsistentWithGPU(priceData, configuration, changes) {1530if (!configuration.acceleratorType) return;15311532const data = priceData.accelerators[configuration.acceleratorType];1533if (!data) {1534// invalid acceleratorType.1535return;1536}15371538// Ensure the region/zone is consistent with accelerator type1539const prices = data[configuration.spot ? "spot" : "prices"];1540if (prices[configuration.zone] == null) {1541// there are no GPUs in the selected zone of the selected type.1542// If you just explicitly changed the GPU type, then we fix this by changing the zone.1543if (changes["acceleratorType"] != null) {1544// fix the region and zone1545// find cheapest zone in the world.1546let price = 999999999;1547let zoneChoice = "";1548for (const zone in prices) {1549if (prices[zone] < price) {1550price = prices[zone];1551zoneChoice = zone;1552}1553}1554if (zoneChoice) {1555changes["zone"] = configuration["zone"] = zoneChoice;1556changes["region"] = configuration["region"] = zoneToRegion(zoneChoice);1557return;1558}1559} else {1560// You did not change the GPU type, so we disable the GPU1561configuration["acceleratorType"] = changes["acceleratorType"] = "";1562configuration["acceleratorCount"] = changes["acceleratorCount"] = 0;1563return;1564}1565}1566}15671568// The Nvidia L4 and A100 are a little different, etc.1569function ensureConsistentNvidiaL4andA100(priceData, configuration, changes) {1570const { machineType, acceleratorType } = configuration;15711572// L4 or A100 GPU machine type, but switching to no GPU, so we have1573// to change the machine type1574if (1575machineType.startsWith("g2-") ||1576machineType.startsWith("a2-") ||1577machineType.startsWith("a3-")1578) {1579if (!acceleratorType) {1580// Easy case -- the user is explicitly changing the GPU from being set1581// to NOT be set, and the GPU is L4 or A100. In this case,1582// we just set the machine type to some non-gpu type1583// and we're done.1584configuration.machineType = changes.machineType = FALLBACK_INSTANCE;1585return;1586}1587}1588if (1589acceleratorType != "nvidia-h200-141gb" &&1590acceleratorType != "nvidia-h100-80gb" &&1591acceleratorType != "nvidia-tesla-a100" &&1592acceleratorType != "nvidia-a100-80gb" &&1593acceleratorType != "nvidia-l4"1594) {1595// We're not switching to an A100 or L4, so not handled further here.1596return;1597}15981599if (!configuration.acceleratorCount) {1600configuration.acceleratorCount = changes.acceleratorCount = 1;1601}16021603// Ensure machine type is consistent with the GPU and count we're switching to.1604let machineTypes =1605priceData.accelerators[acceleratorType]?.machineType[1606configuration.acceleratorCount1607];1608if (machineTypes == null) {1609configuration.acceleratorCount = changes.acceleratorCount = 1;1610machineTypes =1611priceData.accelerators[acceleratorType]?.machineType[1612configuration.acceleratorCount1613];16141615if (machineTypes == null) {1616// maybe 1 gpu isn't allowed, e.g., with H2001617const machineType = priceData.accelerators[acceleratorType]?.machineType;1618if (machineType != null) {1619for (const count in machineType) {1620configuration.acceleratorCount = changes.acceleratorCount =1621parseInt(count);1622machineTypes =1623priceData.accelerators[acceleratorType]?.machineType[1624configuration.acceleratorCount1625];1626}1627}1628}1629}1630if (machineTypes == null) {1631throw Error("bug -- this can't happen");1632}16331634if (!machineTypes.includes(configuration.machineType)) {1635configuration.machineType = changes.machineType =1636machineTypes[0].startsWith("n1-")1637? DEFAULT_GPU_INSTANCE1638: machineTypes[0];1639}1640}16411642function ensureConsistentRegionAndZoneWithMachineType(1643priceData,1644configuration,1645changes,1646) {1647// Specifically selecting a machine type. We make this the1648// highest priority, so if you are changing this, we make everything1649// else fit it.1650const machineType = configuration["machineType"];1651if (priceData.machineTypes[machineType] == null) {1652console.warn(1653`BUG -- This should never happen: unknown machineType = '${machineType}'`,1654);1655// invalid machineType1656if (configuration.acceleratorType) {1657configuration["machineType"] = changes["machineType"] =1658DEFAULT_GPU_INSTANCE;1659} else {1660configuration["machineType"] = changes["machineType"] = FALLBACK_INSTANCE;1661}1662return;1663}16641665const i = machineType.indexOf("-");1666const prefix = machineType.slice(0, i);16671668let zoneHasMachineType = (1669priceData.zones[configuration.zone]?.machineTypes ?? []1670).includes(prefix);1671const regionToCost =1672priceData.machineTypes[machineType][1673configuration.spot ? "spot" : "prices"1674] ?? {};1675const regionHasMachineType = regionToCost[configuration.region] != null;16761677if (!regionHasMachineType) {1678// Our machine type is not in the currently selected region,1679// so find cheapest region with our requested machine type.1680let price = 1e8;1681for (const region in regionToCost) {1682if (regionToCost[region] < price) {1683price = regionToCost[region];1684configuration["region"] = changes["region"] = region;1685// since we changed the region:1686zoneHasMachineType = false;1687}1688}1689}1690if (!zoneHasMachineType) {1691// now the region has the machine type, but the zone doesn't (or1692// region changed so zone has to change).1693// So we find some zone with the machine in that region1694for (const zone in priceData.zones) {1695if (zone.startsWith(configuration["region"])) {1696if ((priceData.zones[zone]?.machineTypes ?? []).includes(prefix)) {1697configuration["zone"] = changes["zone"] = zone;1698break;1699}1700}1701}1702}17031704if (configuration.acceleratorType && configuration.acceleratorCount) {1705if (priceData.accelerators[configuration.acceleratorType] == null) {1706// The accelerator type no longer exists in the pricing data (e.g., maybe it was deprecated),1707// so replace it by one that exists.1708for (const type in priceData.accelerators) {1709configuration.acceleratorType = changes.acceleratorType = type;1710break;1711}1712}1713// have a GPU -- make sure zone works1714if (1715!priceData.accelerators[configuration.acceleratorType].prices[1716configuration.zone1717]1718) {1719// try to find a different zone in the region that works1720let fixed = false;1721const region = zoneToRegion(configuration["zone"]);1722for (const zone in priceData.accelerators[configuration.acceleratorType]1723?.prices) {1724if (zone.startsWith(region)) {1725fixed = true;1726configuration.zone = changes.zone = zone;1727break;1728}1729}1730if (!fixed) {1731// just choose cheapest zone in some region1732const zone = cheapestZone(1733priceData.accelerators[configuration.acceleratorType][1734configuration.spot ? "spot" : "prices"1735],1736);1737configuration.zone = changes.zone = zone;1738configuration.region = changes.region = zoneToRegion(zone);1739}1740}1741}1742}17431744function zoneToRegion(zone: string): string {1745const i = zone.lastIndexOf("-");1746return zone.slice(0, i);1747}17481749function Network({ setConfig, configuration, loading, priceData }) {1750const [externalIp, setExternalIp] = useState<boolean>(1751configuration.externalIp ?? true,1752);1753useEffect(() => {1754setExternalIp(configuration.externalIp ?? true);1755}, [configuration.externalIp]);17561757return (1758<div>1759<div style={{ color: "#666", marginBottom: "5px" }}>1760<b>1761<Icon name="network" /> Network1762</b>1763<br />1764All compute servers on Google cloud have full network access with1765unlimited data transfer in for free. Data transfer out{" "}1766<b>costs {currency(DATA_TRANSFER_OUT_COST_PER_GiB)}/GiB</b>.1767</div>1768<Checkbox1769checked={externalIp}1770disabled={1771true /* compute servers can't work without external ip or Cloud NAT (which costs a lot), so changing this always disabled. Before: disabled || (state ?? "deprovisioned") != "deprovisioned"*/1772}1773onChange={() => {1774setExternalIp(!externalIp);1775setConfig({ externalIp: !externalIp });1776}}1777>1778External IP Address1779</Checkbox>1780<div style={{ marginTop: "5px" }}>1781<Typography.Paragraph1782style={{ color: "#666" }}1783ellipsis={{1784expandable: true,1785rows: 2,1786symbol: "more",1787}}1788>1789{/* TODO: we can and will in theory support all this without external1790ip using a gateway. E.g., google cloud shell has ssh to host, etc. */}1791An external IP address is required and costs{" "}1792{configuration.spot1793? `${currency(1794markup({ cost: EXTERNAL_IP_COST.spot, priceData }),1795)}/hour`1796: `${currency(1797markup({1798cost: EXTERNAL_IP_COST.standard,1799priceData,1800}),1801)}/hour`}{" "}1802while the VM is running (there is no charge when not running).1803</Typography.Paragraph>1804</div>1805{externalIp && (1806<DNS1807setConfig={setConfig}1808configuration={configuration}1809loading={loading}1810/>1811)}1812</div>1813);1814}18151816function cheapestZone(costs: { [zone: string]: number }): string {1817let price = 99999999999999999;1818let choice = "";1819for (const zone in costs) {1820if (costs[zone] < price) {1821choice = zone;1822price = costs[zone];1823}1824}1825return choice;1826}18271828function CostPerHour({1829cost,1830extra,1831style,1832}: {1833cost?: number;1834extra?;1835style?;1836}) {1837if (cost == null) {1838return null;1839}1840return (1841<div style={{ fontFamily: "monospace", ...style }}>1842{currency(cost)}/hour1843{extra}1844</div>1845);1846}18471848function Admin({ id, configuration, loading, template }) {1849const isAdmin = useTypedRedux("account", "is_admin");1850const [error, setError] = useState<string>("");1851const [calling, setCalling] = useState<boolean>(false);1852if (!isAdmin) {1853return null;1854}1855return (1856<div>1857<div style={{ color: "#666", marginBottom: "5px" }}>1858<b>1859<Icon name="users" /> Admin1860</b>1861<br />1862Settings and functionality only available to admins.1863<br />1864<ShowError error={error} setError={setError} />1865<Tooltip title="Once you have tested the currently selected image, click this button to mark it as tested.">1866<Button1867disabled={loading || !id || calling}1868onClick={async () => {1869try {1870setCalling(true);1871await setImageTested({ id, tested: true });1872// force reload to database via GCP api call1873await reloadImages("compute_servers_images_google", true);1874} catch (err) {1875setError(`${err}`);1876} finally {1877setCalling(false);1878}1879}}1880>1881Mark Google Cloud Image Tested{" "}1882{calling && <Spin style={{ marginLeft: "15px" }} />}1883</Button>1884</Tooltip>1885<pre>1886id={id}, configuration={JSON.stringify(configuration, undefined, 2)}1887</pre>1888<Template id={id} template={template} />1889</div>1890</div>1891);1892}189318941895