Path: blob/master/src/packages/frontend/compute/google-cloud-config.tsx
5837 views
import {1Alert,2Button,3Checkbox,4Divider,5Popconfirm,6Radio,7Select,8Spin,9Switch,10Table,11Tooltip,12Typography,13} from "antd";14import { isEqual } from "lodash";15import { useEffect, useState } from "react";1617import { useTypedRedux } from "@cocalc/frontend/app-framework";18import { A } from "@cocalc/frontend/components/A";19import ShowError from "@cocalc/frontend/components/error";20import { Icon } from "@cocalc/frontend/components/icon";21import Disk from "@cocalc/frontend/compute/cloud/common/disk";22import DNS from "@cocalc/frontend/compute/cloud/common/dns";23import Template from "@cocalc/frontend/compute/cloud/common/template";24import { displayAcceleratorType } from "@cocalc/frontend/compute/cloud/google-cloud/accelerator";25import Specs, {26RamAndCpu,27} from "@cocalc/frontend/compute/cloud/google-cloud/specs";28import ExcludeFromSync from "@cocalc/frontend/compute/exclude-from-sync";29import { filterOption } from "@cocalc/frontend/compute/util";30import computeCost, {31computeAcceleratorCost,32computeDiskCost,33computeInstanceCost,34DATA_TRANSFER_OUT_COST_PER_GiB,35EXTERNAL_IP_COST,36GoogleCloudData,37markup,38} from "@cocalc/util/compute/cloud/google-cloud/compute-cost";39import type {40Architecture,41ComputeServerTemplate,42GoogleCloudAcceleratorType,43GoogleCloudConfiguration as GoogleCloudConfigurationType,44Images,45State,46} from "@cocalc/util/db-schema/compute-servers";47import {48getArchitecture,49getMachineTypeArchitecture,50getMinDiskSizeGb,51GOOGLE_CLOUD_DEFAULTS,52} from "@cocalc/util/db-schema/compute-servers";53import { cmp, currency, plural } from "@cocalc/util/misc";54import AllowCollaboratorControl from "./allow-collaborator-control";55import {56getGoogleCloudPriceData,57setImageTested,58setServerConfiguration,59} from "./api";60import AutoRestart from "./auto-restart";61import { availableClouds } from "./config";62import CostOverview from "./cost-overview";63import Ephemeral from "./ephemeral";64import { reloadImages, useGoogleImages, useImages } from "./images-hook";65import NestedVirtualization from "./nested-virtualization";66import Proxy from "./proxy";67import SelectImage, { ImageDescription, ImageLinks } from "./select-image";6869export const SELECTOR_WIDTH = "350px";7071export const DEFAULT_GPU_CONFIG = GOOGLE_CLOUD_DEFAULTS.gpu2;7273// {74// acceleratorType: "nvidia-l4",75// acceleratorCount: 1,76// machineType: "g2-standard-4",77// region: "us-central1",78// zone: "us-central1-b",79// image: "pytorch",80// };8182const FALLBACK_INSTANCE = "n2-standard-4";83// an n1-standard-1 is SO dinky it causes huge trouble84// with downloading/processing models.85const DEFAULT_GPU_INSTANCE = "n1-highmem-2";8687interface ConfigurationType extends GoogleCloudConfigurationType {88valid?: boolean;89}9091interface Props {92configuration: ConfigurationType;93editable?: boolean;94// if id not set, then doesn't try to save anything to the backend95id?: number;96project_id?: string;97// called whenever changes are made.98onChange?: (configuration: ConfigurationType) => void;99disabled?: boolean;100state?: State;101data?;102setCloud?;103template?: ComputeServerTemplate;104}105106export default function GoogleCloudConfiguration({107configuration: configuration0,108editable,109id,110project_id,111onChange,112disabled,113state,114data,115setCloud,116template,117}: Props) {118const [IMAGES, ImagesError] = useImages();119const [googleImages, ImagesErrorGoogle] = useGoogleImages();120const [loading, setLoading] = useState<boolean>(false);121const [cost, setCost] = useState<number | null>(null);122const [priceData, setPriceData] = useState<GoogleCloudData | null>(null);123const [error, setError0] = useState<string>("");124const [configuration, setLocalConfiguration] =125useState<ConfigurationType>(configuration0);126const setError = (error) => {127setError0(error);128const valid = !error;129if (onChange != null && configuration.valid != valid) {130onChange({ ...configuration, valid });131}132};133134useEffect(() => {135if (!editable) {136setLocalConfiguration(configuration0);137}138}, [configuration0]);139140useEffect(() => {141(async () => {142try {143setLoading(true);144const data = await getGoogleCloudPriceData();145setPriceData(data);146} catch (err) {147setError(`${err}`);148} finally {149setLoading(false);150}151})();152}, []);153154useEffect(() => {155if (!editable || configuration == null || priceData == null) {156return;157}158try {159const cost = computeCost({ configuration, priceData });160setCost(cost);161} catch (err) {162setError(`${err}`);163setCost(null);164}165}, [configuration, priceData]);166167if (ImagesError != null) {168return ImagesError;169}170if (ImagesErrorGoogle != null) {171return ImagesErrorGoogle;172}173174if (IMAGES == null || googleImages == null) {175return <Spin />;176}177178const summary = (179<Specs180configuration={configuration}181priceData={priceData}182IMAGES={IMAGES}183/>184);185186if (!editable || !project_id) {187// short summary only188return summary;189}190191if (priceData == null) {192return <Spin />;193}194195const setConfig = async (changes) => {196let changed = false;197for (const key in changes) {198if (!isEqual(changes[key], configuration[key])) {199changed = true;200break;201}202}203if (!changed) {204// nothing at all changed205return;206}207208changes = ensureConsistentConfiguration(209priceData,210configuration,211changes,212IMAGES,213);214const newConfiguration = { ...configuration, ...changes };215216if (217(state ?? "deprovisioned") != "deprovisioned" &&218(configuration.region != newConfiguration.region ||219configuration.zone != newConfiguration.zone)220) {221setError(222"Can't change the region or zone without first deprovisioning the VM",223);224// make copy so config gets reset -- i.e., whatever change you just tried to make is reverted.225setLocalConfiguration({ ...configuration });226return;227}228229if (Object.keys(changes).length == 0) {230// nothing going to change231return;232}233234try {235setLoading(true);236if (onChange != null) {237onChange(newConfiguration);238}239setLocalConfiguration(newConfiguration);240if (id != null) {241await setServerConfiguration({ id, configuration: changes });242}243} catch (err) {244setError(`${err}`);245} finally {246setLoading(false);247}248};249250const columns = [251{252dataIndex: "value",253key: "value",254},255{ dataIndex: "label", key: "label", width: 130 },256];257258const dataSource = [259{260key: "provisioning",261label: (262<A href="https://cloud.google.com/compute/docs/instances/spot">263<Icon name="external-link" /> Provisioning264</A>265),266value: (267<Provisioning268disabled={loading || disabled}269priceData={priceData}270setConfig={setConfig}271configuration={configuration}272/>273),274},275{276key: "gpu",277label: (278<A href="https://cloud.google.com/compute/docs/gpus">279<Icon name="external-link" /> GPUs280</A>281),282value: (283<GPU284state={state}285disabled={loading || disabled}286priceData={priceData}287setConfig={setConfig}288configuration={configuration}289IMAGES={IMAGES}290setCloud={setCloud}291/>292),293},294{295key: "image",296label: (297<ImageLinks image={configuration.image} style={{ height: "90px" }} />298),299value: (300<Image301state={state}302disabled={loading || disabled}303setConfig={setConfig}304configuration={configuration}305gpu={306!!(configuration.acceleratorType && configuration.acceleratorCount)307}308googleImages={googleImages}309arch={getArchitecture(configuration)}310/>311),312},313314{315key: "machineType",316label: (317<A href="https://cloud.google.com/compute/docs/machine-resource#recommendations_for_machine_types">318<Icon name="external-link" /> Machine Types319</A>320),321value: (322<MachineType323state={state}324disabled={loading || disabled}325priceData={priceData}326setConfig={setConfig}327configuration={configuration}328/>329),330},331332{333key: "region",334label: (335<A href="https://cloud.google.com/about/locations">336<Icon name="external-link" /> Regions337</A>338),339value: (340<Region341disabled={342loading || disabled || (state ?? "deprovisioned") != "deprovisioned"343}344priceData={priceData}345setConfig={setConfig}346configuration={configuration}347/>348),349},350{351key: "zone",352label: (353<A href="https://cloud.google.com/about/locations">354<Icon name="external-link" /> Zones355</A>356),357value: (358<Zone359disabled={360loading || disabled || (state ?? "deprovisioned") != "deprovisioned"361}362priceData={priceData}363setConfig={setConfig}364configuration={configuration}365/>366),367},368369{370key: "disk",371label: (372<A href="https://cloud.google.com/compute/docs/disks/performance">373<Icon name="external-link" /> Disks374</A>375),376value: (377<BootDisk378id={id}379disabled={loading}380setConfig={setConfig}381configuration={configuration}382priceData={priceData}383state={state}384IMAGES={IMAGES}385/>386),387},388{389key: "exclude",390value: (391<ExcludeFromSync392id={id}393disabled={loading}394setConfig={setConfig}395configuration={configuration}396state={state}397style={{ marginTop: "10px", color: "#666" }}398/>399),400},401{402key: "network",403label: (404<A href="https://cloud.google.com/compute/docs/network-bandwidth">405<Icon name="external-link" /> Network406</A>407),408value: (409<Network410setConfig={setConfig}411configuration={configuration}412loading={loading}413priceData={priceData}414/>415),416},417{418key: "proxy",419label: <></>,420value: (421<Proxy422setConfig={setConfig}423configuration={configuration}424data={data}425state={state}426IMAGES={IMAGES}427project_id={project_id}428id={id}429/>430),431},432433{434key: "ephemeral",435label: <></>,436value: (437<Ephemeral438setConfig={setConfig}439configuration={configuration}440loading={loading}441/>442),443},444{445key: "auto-restart",446label: <></>,447value: (448<AutoRestart449setConfig={setConfig}450configuration={configuration}451loading={loading}452/>453),454},455{456key: "allow-collaborator-control",457label: <></>,458value: (459<AllowCollaboratorControl460setConfig={setConfig}461configuration={configuration}462loading={loading}463/>464),465},466{467key: "nested-virtualization",468label: <></>,469value: (470<NestedVirtualization471setConfig={setConfig}472configuration={configuration}473loading={loading}474/>475),476},477{478key: "admin",479label: <></>,480value: (481<Admin482id={id}483configuration={configuration}484loading={loading}485template={template}486/>487),488},489];490491const errDisplay = error ? (492<div493style={{494/*minHeight: "35px", */495padding: "5px 10px",496background: error ? "red" : undefined,497color: "white",498borderRadius: "5px",499}}500>501{error}502<Button503size="small"504onClick={() => {505setError("");506setLocalConfiguration(configuration0);507}}508style={{ float: "right" }}509>510Close511</Button>512</div>513) : undefined;514515return (516<div>517{loading && (518<div style={{ float: "right" }}>519<Spin delay={1000} />520</div>521)}522{errDisplay}523{cost != null && (524<CostOverview525cost={cost}526description={527<>528You pay <b>{currency(cost)}/hour</b> while the computer server is529running. The rate is{" "}530<b>531{currency(532computeCost({ configuration, priceData, state: "off" }),533)}534/hour535</b>{" "}536when the server is off, and there is no cost when it is537deprovisioned. Network data transfer out charges are not included538in the above cost, and depend on how much data leaves the server539(see the Network section below). Incoming networking is free.540</>541}542/>543)}544<Divider />545<div style={{ textAlign: "center", margin: "10px 80px" }}>{summary}</div>546<Divider />547<Table548showHeader={false}549style={{ marginTop: "5px" }}550columns={columns}551dataSource={dataSource}552pagination={false}553/>554{errDisplay}555</div>556);557}558559function Region({ priceData, setConfig, configuration, disabled }) {560const [sortByPrice, setSortByPrice] = useState<boolean>(true);561const [newRegion, setNewRegion] = useState<string>(configuration.region);562useEffect(() => {563setNewRegion(configuration.region);564}, [configuration.region]);565566const regions = getRegions(priceData, configuration);567if (sortByPrice) {568regions.sort((a, b) => cmp(a.cost, b.cost));569}570const options = regions.map(({ region, location, lowCO2, cost }) => {571const price = <CostPerHour cost={cost} extra={" (total)"} />;572return {573value: region,574search: `${region} ${location} ${lowCO2 ? " co2 " : ""}`,575label: (576<div key={region} style={{ display: "flex" }}>577<div style={{ flex: 1 }}> {region}</div>578<div style={{ flex: 1 }}>{price}</div>579<div style={{ flex: 0.7 }}> {lowCO2 ? "🍃 Low CO2" : ""}</div>580<div style={{ flex: 0.8 }}> {location?.split(",")[1].trim()}</div>581</div>582),583};584});585586return (587<div>588{configuration.machineType ? (589<div style={{ color: "#666", marginBottom: "5px" }}>590<b>591<Icon name="global" /> Region592</b>593</div>594) : undefined}595<div>596<Select597disabled={disabled}598style={{ width: "100%" }}599options={options as any}600value={newRegion}601onChange={(region) => {602setNewRegion(region);603setConfig({ region });604}}605showSearch606optionFilterProp="children"607filterOption={filterOption}608/>609</div>610<div>611<Checkbox612disabled={disabled}613style={{ marginTop: "5px" }}614checked={sortByPrice}615onChange={() => setSortByPrice(!sortByPrice)}616>617Sort by price618</Checkbox>619<div style={{ color: "#666", marginTop: "5px" }}>620Price above is total price in this region for the machine, disk and621GPU.622</div>623</div>624</div>625);626}627628// Gets the regions where the given VM type is available.629// Ignores the currently selected zone.630function getRegions(priceData, configuration) {631const lowCO2 = new Set<string>();632const regions = new Set<string>();633const location: { [region: string]: string } = {};634const cost: { [region: string]: number } = {};635const { machineType, spot } = configuration ?? {};636for (const zone in priceData.zones) {637const i = zone.lastIndexOf("-");638const region = zone.slice(0, i);639const zoneData = priceData.zones[zone];640if (machineType) {641if (!zoneData.machineTypes.includes(machineType.split("-")[0])) {642continue;643}644if (spot) {645if (priceData.machineTypes[machineType]?.spot?.[region] == null) {646continue;647}648}649}650if (cost[region] == null) {651try {652cost[region] = computeCost({653priceData,654configuration: { ...configuration, region, zone },655});656} catch (_) {657continue;658// console.warn({ ...configuration, region, zone }, err);659}660}661if (zoneData.lowCO2 || zoneData.lowC02) {662// C02 above because of typo in data.663lowCO2.add(region);664}665regions.add(region);666location[region] = zoneData.location;667}668const v = Array.from(regions);669v.sort((a, b) => {670for (const g of [671"us",672"northamerica",673"europe",674"asia",675"southamerica",676"australia",677]) {678if (a.startsWith(g) && !b.startsWith(g)) {679return -1;680}681if (!a.startsWith(g) && b.startsWith(g)) {682return 1;683}684}685return cmp(a, b);686});687const data: {688region: string;689location: string;690lowCO2: boolean;691cost?: number;692}[] = [];693for (const region of v) {694data.push({695region,696location: location[region],697lowCO2: lowCO2.has(region),698cost: cost[region],699});700}701return data;702}703704// Gets the zones compatible with the other configuration705function getZones(priceData, configuration) {706const lowCO2 = new Set<string>();707const zones = new Set<string>();708const { region, machineType, acceleratorType, spot } = configuration;709const prefix = machineType.split("-")[0];710for (const zone in priceData.zones) {711if (region != zoneToRegion(zone)) {712// this zone isn't in the chosen region.713continue;714}715const zoneData = priceData.zones[zone];716if (machineType) {717if (!zoneData.machineTypes.includes(prefix)) {718continue;719}720if (spot != null) {721if (priceData.machineTypes[machineType]?.spot?.[region] == null) {722continue;723}724}725}726if (acceleratorType) {727if (priceData.accelerators[acceleratorType]?.prices?.[zone] == null) {728// not in this zone.729continue;730}731}732if (zoneData.lowCO2 || zoneData.lowC02) {733// C02 above because of typo in data.734lowCO2.add(zone);735}736zones.add(zone);737}738const v = Array.from(zones);739v.sort();740const data: {741zone: string;742lowCO2: boolean;743}[] = [];744for (const zone of v) {745data.push({746zone,747lowCO2: lowCO2.has(zone),748});749}750return data;751}752753function Provisioning({ priceData, setConfig, configuration, disabled }) {754const [newSpot, setNewSpot] = useState<boolean>(!!configuration.spot);755const [prices, setPrices] = useState<{756spot: number | null;757standard: number;758discount: number;759} | null>(getSpotAndStandardPrices(priceData, configuration));760761useEffect(() => {762setNewSpot(!!configuration.spot);763setPrices(getSpotAndStandardPrices(priceData, configuration));764}, [configuration]);765766useEffect(() => {767if (configuration.spot && prices != null && !prices.spot) {768setNewSpot(false);769setConfig({ spot: false });770}771}, [prices, configuration.spot]);772773return (774<div>775<div style={{ color: "#666", marginBottom: "5px" }}>776<b>777<Icon name="sliders" /> Provisioning778</b>779</div>780<Radio.Group781size="large"782buttonStyle="solid"783disabled={disabled}784value={newSpot ? "spot" : "standard"}785onChange={(e) => {786const spot = e.target.value == "standard" ? false : true;787setNewSpot(spot);788setConfig({ spot });789}}790>791<Radio.Button value="spot" disabled={!prices?.spot}>792Spot{" "}793{prices?.spot794? `${currency(prices.spot)}/hour (${prices.discount}% discount)`795: "(not available)"}{" "}796</Radio.Button>797<Radio.Button value="standard">798Standard{" "}799{prices != null ? `${currency(prices.standard)}/hour` : undefined}{" "}800</Radio.Button>801</Radio.Group>802<div style={{ color: "#666", marginTop: "5px" }}>803Standard VM's run until you stop them, whereas spot VM's are up to 91%804off, but will automatically stop when there is a surge in demand. Spot805instances might also not be available in a given region, so you may have806to try different regions.{" "}807{configuration.acceleratorType && (808<> GPU's are always in high demand.</>809)}810{newSpot && (811<Alert812style={{ margin: "5px 0" }}813type="warning"814showIcon815description={816<div style={{ maxWidth: "100%", lineHeight: 1 }}>817This is a heavily discounted spot instance. It will818automatically{" "}819{configuration.autoRestart ? " reboot if possible " : " stop "}{" "}820when there is a surge in demand.821{!disabled && (822<Popconfirm823title="Switch to Standard?"824description={825<div style={{ maxWidth: "450px" }}>826This will switch to a non-discounted standard instance,827which stays running even if there is high demand. You828can switch back to a spot instance using the blue toggle829above.830</div>831}832onConfirm={() => {833setNewSpot(false);834setConfig({ spot: false });835}}836okText="Switch to Standard"837cancelText="Cancel"838>839<Button type="link">Switch to Standard</Button>840</Popconfirm>841)}842{!configuration.autoRestart && (843<Popconfirm844title="Enable Automatic Restart?"845description={846<div style={{ maxWidth: "450px" }}>847CoCalc will automatically restart your compute server if848it is killed due to high demand. Note that there might849not be any compute resources available, in which case850you will have to wait for your server to start. You can851disable this in the "Automatically Restart" section852below.853</div>854}855onConfirm={() => {856setConfig({ autoRestart: true });857}}858okText="Enable Automatic Restart"859cancelText="Cancel"860>861<Button type="link">Enable Automatic Restart</Button>862</Popconfirm>863)}864</div>865}866/>867)}868</div>869</div>870);871}872873function getSpotAndStandardPrices(priceData, configuration) {874try {875const standard = computeCost({876priceData,877configuration: { ...configuration, spot: false },878});879let spot: number | null = null;880try {881spot = computeCost({882priceData,883configuration: { ...configuration, spot: true },884});885} catch (_) {886// some machines have no spot instance support, eg h3's.887}888return {889standard,890spot,891discount: spot != null ? Math.round((1 - spot / standard) * 100) : 0,892};893} catch (_) {894return null;895}896}897898function Zone({ priceData, setConfig, configuration, disabled }) {899const [newZone, setNewZone] = useState<string>(configuration.zone ?? "");900useEffect(() => {901setNewZone(configuration.zone);902}, [configuration.zone]);903904const zones = getZones(priceData, configuration);905const options = zones.map(({ zone, lowCO2 }) => {906return {907value: zone,908search: `${zone} ${lowCO2 ? " co 2" : ""}`,909label: `${zone} ${lowCO2 ? " - 🍃 Low CO2" : ""}`,910};911});912913return (914<div>915{configuration.machineType ? (916<div style={{ color: "#666", marginBottom: "5px" }}>917<b>918<Icon name="aim" /> Zone919</b>{" "}920in {configuration.region} with {configuration.machineType}{" "}921{configuration.spot ? "spot" : ""} VM's922</div>923) : undefined}924<Select925disabled={disabled}926style={{ width: SELECTOR_WIDTH }}927options={options}928value={newZone}929onChange={(zone) => {930setNewZone(zone);931setConfig({ zone });932}}933showSearch934optionFilterProp="children"935filterOption={filterOption}936/>937</div>938);939}940941function MachineType({ priceData, setConfig, configuration, disabled, state }) {942const [archType, setArchType] = useState<Architecture>(943getArchitecture(configuration),944);945const [sortByPrice, setSortByPrice] = useState<boolean>(true);946const [newMachineType, setNewMachineType] = useState<string>(947configuration.machineType ?? "",948);949950useEffect(() => {951setNewMachineType(configuration.machineType);952setArchType(getArchitecture(configuration));953}, [configuration.machineType]);954955useEffect(() => {956if (archType == "arm64" && getArchitecture(configuration) != "arm64") {957setNewMachineType("t2a-standard-4");958setConfig({ machineType: "t2a-standard-4" });959return;960}961if (archType == "x86_64" && getArchitecture(configuration) == "arm64") {962setNewMachineType("t2d-standard-4");963setConfig({ machineType: "t2d-standard-4" });964return;965}966}, [archType, configuration.machineType]);967968const machineTypes = Object.keys(priceData.machineTypes);969const allOptions = machineTypes970.filter((machineType) => {971const { acceleratorType } = configuration;972if (!acceleratorType) {973if (machineType.startsWith("g") || machineType.startsWith("a")) {974return false;975}976// Check if the machine type's architecture matches the selected architecture977const machineArch = getMachineTypeArchitecture(machineType);978if (archType == "arm64" && machineArch != "arm64") {979return false;980}981if (archType == "x86_64" && machineArch != "x86_64") {982return false;983}984} else {985if (acceleratorType == "nvidia-tesla-t4") {986return machineType.startsWith("n1-");987} else {988const machines =989priceData.accelerators[acceleratorType].machineType[990configuration.acceleratorCount ?? 1991] ?? [];992return machines.includes(machineType);993}994}995996return true;997})998.map((machineType) => {999let cost;1000try {1001cost = computeInstanceCost({1002priceData,1003configuration: { ...configuration, machineType },1004});1005} catch (_) {1006cost = null;1007}1008const data = priceData.machineTypes[machineType];1009const { memory, vcpu } = data;1010return {1011value: machineType,1012search: machineType + ` memory:${memory} ram:${memory} cpu:${vcpu} `,1013cost,1014label: (1015<div key={machineType} style={{ display: "flex" }}>1016<div style={{ flex: 1 }}>{machineType}</div>1017<div style={{ flex: 1 }}>1018{cost ? (1019<CostPerHour cost={cost} />1020) : (1021<span style={{ color: "#666" }}>(region/zone changes)</span>1022)}1023</div>1024<div style={{ flex: 2 }}>1025<RamAndCpu machineType={machineType} priceData={priceData} />1026</div>1027</div>1028),1029};1030});1031const options = [1032{1033label: "Machine Types",1034options: allOptions.filter((x) => x.cost),1035},1036{1037label: "Location Will Change",1038options: allOptions.filter((x) => !x.cost),1039},1040];10411042if (sortByPrice) {1043options[0].options.sort((a, b) => {1044return cmp(a.cost, b.cost);1045});1046}10471048return (1049<div>1050<div style={{ color: "#666", marginBottom: "5px" }}>1051<Tooltip1052title={1053(state ?? "deprovisioned") != "deprovisioned"1054? "Can only be changed when machine is deprovisioned"1055: archType == "x86_64"1056? "Intel or AMD X86_64 architecture machines"1057: "ARM64 architecture machines"1058}1059>1060<Radio.Group1061style={{ float: "right" }}1062disabled={1063disabled ||1064configuration.acceleratorType ||1065(state ?? "deprovisioned") != "deprovisioned"1066}1067options={[1068{ value: "x86_64", label: "X86_64" },1069{ value: "arm64", label: "ARM64" },1070]}1071value={archType}1072onChange={({ target: { value } }) => {1073setArchType(value);1074}}1075/>1076</Tooltip>1077<b>1078<Icon name="microchip" /> Machine Type1079</b>1080</div>1081<div>1082<Select1083disabled={disabled}1084style={{ width: "100%" }}1085options={options as any}1086value={newMachineType}1087onChange={(machineType) => {1088setNewMachineType(machineType);1089setConfig({ machineType });1090}}1091showSearch1092optionFilterProp="children"1093filterOption={filterOption}1094/>1095</div>1096<div>1097<Checkbox1098disabled={disabled}1099style={{ marginTop: "5px" }}1100checked={sortByPrice}1101onChange={() => setSortByPrice(!sortByPrice)}1102>1103Sort by price1104</Checkbox>1105</div>1106<div style={{ color: "#666", marginTop: "5px" }}>1107Prices and availability depend on the region and provisioning type, so1108adjust those below to find the best overall value. Price above is just1109for the machine, and not the disk or GPU. Search for <code>cpu:4⌴</code>{" "}1110and <code>ram:8⌴</code> to only show options with 4 vCPUs and 8GB RAM.1111</div>1112</div>1113);1114}11151116function BootDisk(props) {1117return (1118<Disk1119{...props}1120minSizeGb={getMinDiskSizeGb(props)}1121maxSizeGb={65536}1122computeDiskCost={computeDiskCost}1123/>1124);1125}11261127function Image(props) {1128const { state = "deprovisioned" } = props;1129return (1130<div>1131<div style={{ color: "#666", marginBottom: "5px" }}>1132<b>1133<Icon name="disk-round" /> Image1134</b>1135</div>1136{state == "deprovisioned" && (1137<div style={{ color: "#666", marginBottom: "5px" }}>1138Select compute server image. You will be able to use sudo as root with1139no password, and can install anything into the Ubuntu Linux image,1140including commercial software.1141</div>1142)}1143<SelectImage {...props} />1144{state != "deprovisioned" && (1145<div style={{ color: "#666", marginTop: "5px" }}>1146You can only edit the image when server is deprovisioned.1147</div>1148)}1149<div style={{ color: "#666", marginTop: "5px" }}>1150<ImageDescription configuration={props.configuration} />1151</div>1152</div>1153);1154}11551156// We do NOT include the P4, P100, V100 or K80, which are older1157// and for which our base image and drivers don't work.1158// If for some reason we need them, we will have to switch to1159// different base drivers or have even more images.11601161// NOTE: H200 disabled because it requires a reservation.11621163const ACCELERATOR_TYPES = [1164"nvidia-tesla-t4",1165"nvidia-l4",1166"nvidia-tesla-a100",1167"nvidia-a100-80gb",1168"nvidia-h100-80gb",1169// "nvidia-h200-141gb",1170// these are too hard to properly keep software image for:1171// "nvidia-tesla-v100",1172//"nvidia-tesla-p100",1173//"nvidia-tesla-p4",1174];11751176/*1177<A href="https://www.nvidia.com/en-us/data-center/tesla-p100/">P100</A>,{" "}1178<A href="https://www.nvidia.com/en-us/data-center/v100/">V100</A>,{" "}1179<A href="https://www.nvidia.com/content/dam/en-zz/Solutions/design-visualization/solutions/resources/documents1/nvidia-p4-datasheet.pdf">1180P41181</A>1182*/11831184function GPU({1185priceData,1186setConfig,1187configuration,1188disabled,1189state,1190IMAGES,1191setCloud,1192}) {1193const { acceleratorType, acceleratorCount } = configuration;1194const head = (1195<div style={{ color: "#666", marginBottom: "5px" }}>1196<b>1197<Icon style={{ float: "right", fontSize: "50px" }} name="gpu" />1198<Icon name="cube" /> NVIDIA GPU{" "}1199<div style={{ float: "right" }}>1200<A href="https://www.nvidia.com/content/dam/en-zz/Solutions/design-visualization/solutions/resources/documents1/Datasheet_NVIDIA_T4_Virtualization.pdf">1201T41202</A>1203, <A href="https://www.nvidia.com/en-us/data-center/l4/">L4</A>,{" "}1204<A href="https://www.nvidia.com/en-us/data-center/a100/">A100</A>,{" "}1205<A href="https://www.nvidia.com/en-us/data-center/h100/">H100</A>1206</div>1207</b>1208</div>1209);12101211const theSwitch = (1212<Switch1213disabled={disabled || (state ?? "deprovisioned") != "deprovisioned"}1214checkedChildren={"NVIDIA GPU"}1215unCheckedChildren={"NO GPU"}1216checked={!!acceleratorType}1217onChange={() => {1218if (!!acceleratorType) {1219setConfig({ acceleratorType: "", acceleratorCount: 0 });1220} else {1221setConfig({1222...DEFAULT_GPU_CONFIG,1223spot: configuration?.spot ?? false,1224});1225}1226}}1227/>1228);1229if (!acceleratorType) {1230return (1231<div>1232{head}1233{theSwitch}1234</div>1235);1236}12371238const options = ACCELERATOR_TYPES.filter(1239(acceleratorType) => priceData.accelerators[acceleratorType] != null,1240).map((acceleratorType: GoogleCloudAcceleratorType) => {1241let cost;1242const config1 = { ...configuration, acceleratorType, acceleratorCount };1243const changes = { acceleratorType, acceleratorCount };1244try {1245cost = computeAcceleratorCost({ priceData, configuration: config1 });1246} catch (_) {1247const newChanges = ensureConsistentConfiguration(1248priceData,1249config1,1250changes,1251IMAGES,1252);1253cost = computeAcceleratorCost({1254priceData,1255configuration: { ...config1, ...newChanges },1256});1257}1258const memory = priceData.accelerators[acceleratorType].memory;1259return {1260value: acceleratorType,1261search: acceleratorType,1262cost,1263memory,1264label: (1265<div key={acceleratorType} style={{ display: "flex" }}>1266<div style={{ flex: 1 }}>1267{displayAcceleratorType(acceleratorType, memory)}1268</div>1269<div style={{ flex: 1 }}>1270<CostPerHour cost={cost} />1271</div>1272</div>1273),1274};1275});12761277const countOptions: any[] = [];1278const min = priceData.accelerators[acceleratorType]?.count ?? 1;1279const max = priceData.accelerators[acceleratorType]?.max ?? 1;1280for (let i = min; i <= max; i *= 2) {1281countOptions.push({ label: `${i}`, value: i });1282}12831284return (1285<div>1286{head}1287{theSwitch}1288<div style={{ marginTop: "15px" }}>1289<Select1290disabled={disabled || (state ?? "deprovisioned") != "deprovisioned"}1291style={{ width: SELECTOR_WIDTH }}1292options={options as any}1293value={acceleratorType}1294onChange={(type) => {1295setConfig({ acceleratorType: type });1296// todo -- change count if necessary1297}}1298showSearch1299optionFilterProp="children"1300filterOption={filterOption}1301/>1302<Select1303style={{ marginLeft: "15px", width: "75px" }}1304disabled={disabled || (state ?? "deprovisioned") != "deprovisioned"}1305options={countOptions}1306value={acceleratorCount}1307onChange={(count) => {1308setConfig({ acceleratorCount: count });1309}}1310/>1311{acceleratorCount && acceleratorType && (1312<div style={{ color: "#666", marginTop: "10px" }}>1313You have selected {acceleratorCount} dedicated{" "}1314<b>{displayAcceleratorType(acceleratorType)}</b>{" "}1315{plural(acceleratorCount, "GPU")}, with a total of{" "}1316<b>1317{priceData.accelerators[acceleratorType].memory *1318acceleratorCount}1319GB GPU RAM1320</b>1321.{" "}1322{acceleratorCount > 1 && (1323<>1324The {acceleratorCount} GPUs will be available on the same1325server.1326</>1327)}1328{1329(state ?? "deprovisioned") != "deprovisioned" && (1330<div>1331You can only change the GPU configuration when the server is1332deprovisioned.1333</div>1334) /* 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! */1335}1336{setCloud != null &&1337availableClouds().includes("hyperstack") &&1338(state ?? "deprovisioned") == "deprovisioned" && (1339<Alert1340showIcon1341style={{ margin: "10px 0 5px 0" }}1342type="warning"1343description={1344<div>1345We have partnered with Hyperstack cloud to provide NVIDIA1346H100, A100, L40, and RTX-A4/5/6000 GPUs at a{" "}1347<b>much cheaper price</b> than Google cloud.{" "}1348<Popconfirm1349title="Switch to Hyperstack"1350description={1351<div style={{ maxWidth: "450px" }}>1352This will change the cloud for this compute server1353to Hyperstack, and reset its configuration. Your1354compute server is not storing any data so this is1355safe.1356</div>1357}1358onConfirm={() => {1359setCloud("hyperstack");1360}}1361okText="Switch to Hyperstack"1362cancelText="Cancel"1363>1364<Button type="link">Switch...</Button>1365</Popconfirm>1366</div>1367}1368/>1369)}1370</div>1371)}1372</div>1373</div>1374);1375}1376/*1377{acceleratorType?.includes("a100") && configuration.spot ? (1378<div style={{ marginTop: "5px", color: "#666" }}>1379<b>WARNING:</b> A100 spot instances are rarely available. Consider1380standard provisioning instead.1381</div>1382) : undefined}1383*/13841385function ensureConsistentConfiguration(1386priceData,1387configuration: GoogleCloudConfigurationType,1388changes: Partial<GoogleCloudConfigurationType>,1389IMAGES: Images,1390) {1391const newConfiguration = { ...configuration, ...changes };1392const newChanges = { ...changes };1393ensureConsistentImage(newConfiguration, newChanges, IMAGES);1394ensureConsistentAccelerator(priceData, newConfiguration, newChanges);1395ensureConsistentNvidiaL4andA100(priceData, newConfiguration, newChanges);1396ensureConsistentZoneWithRegion(priceData, newConfiguration, newChanges);1397ensureConsistentRegionAndZoneWithMachineType(1398priceData,1399newConfiguration,1400newChanges,1401);1402ensureSufficientDiskSize(newConfiguration, newChanges, IMAGES);1403ensureConsistentDiskType(priceData, newConfiguration, newChanges);14041405return newChanges;1406}14071408// We make the image consistent with the gpu selection.1409function ensureConsistentImage(configuration, changes, IMAGES) {1410const { gpu } = IMAGES[configuration.image] ?? {};1411const gpuSelected =1412configuration.acceleratorType && configuration.acceleratorCount > 0;1413if (gpu == gpuSelected) {1414// they are consistent1415return;1416}1417if (gpu && !gpuSelected) {1418// GPU image but non-GPU machine -- change image to non-GPU1419configuration["image"] = changes["image"] = "python";1420configuration["tag"] = changes["tag"] = null;1421} else if (!gpu && gpuSelected) {1422// GPU machine but not image -- change image to pytorch1423configuration["image"] = changes["image"] = "pytorch";1424configuration["tag"] = changes["tag"] = null;1425}1426}14271428function ensureSufficientDiskSize(configuration, changes, IMAGES) {1429const min = getMinDiskSizeGb({ configuration, IMAGES });1430if ((configuration.diskSizeGb ?? 0) < min) {1431changes.diskSizeGb = min;1432}1433}14341435function ensureConsistentDiskType(priceData, configuration, changes) {1436const { machineType } = configuration;1437const m = machineType.split("-")[0];1438if (configuration.diskType == "hyperdisk-balanced") {1439// make sure machine is supported1440const { supportedMachineTypes } = priceData.extra["hyperdisk-balanced"];1441if (!supportedMachineTypes.includes(m)) {1442// can't use hyperdisk on this machine, so fix.1443configuration.diskType = changes.diskType = "pd-balanced";1444}1445} else {1446const { requiredMachineTypes } = priceData.extra["hyperdisk-balanced"];1447if (requiredMachineTypes.includes(m)) {1448// must use hyperdisk on this machine, so fix.1449configuration.diskType = changes.diskType = "hyperdisk-balanced";1450}1451}1452}14531454function ensureConsistentZoneWithRegion(priceData, configuration, changes) {1455if (configuration.zone.startsWith(configuration.region)) {1456return;1457}1458if (changes["region"]) {1459// currently changing region, so set a zone that matches the region1460for (const zone in priceData.zones) {1461if (zone.startsWith(configuration.region)) {1462configuration["zone"] = changes["zone"] = zone;1463break;1464}1465}1466} else {1467// probably changing the zone, so set the region from the zone1468configuration["region"] = changes["region"] = zoneToRegion(1469configuration.zone,1470);1471}1472}14731474function ensureConsistentAccelerator(priceData, configuration, changes) {1475let { acceleratorType } = configuration;1476if (!acceleratorType) {1477return;1478}1479if (1480acceleratorType == "nvidia-tesla-a100" ||1481acceleratorType == "nvidia-a100-80gb" ||1482acceleratorType == "nvidia-l4"1483) {1484// L4 and A100 are handled elsewhere.1485return;1486}14871488// have a GPU1489let data = priceData.accelerators[acceleratorType];1490if (!data) {1491// accelerator type no longer exists; replace it by one that does.1492for (const type in priceData.accelerators) {1493acceleratorType =1494configuration["acceleratorType"] =1495changes["acceleratorType"] =1496type;1497data = priceData.accelerators[acceleratorType];1498break;1499}1500}1501if (data == null) {1502throw Error("bug");1503}1504// Ensure the machine type is consistent1505if (!configuration.machineType.startsWith(data.machineType)) {1506if (changes["machineType"]) {1507// if you are explicitly changing the machine type, then we respect1508// that and disabled the gpu1509configuration["acceleratorType"] = changes["acceleratorType"] = "";1510configuration["acceleratorCount"] = changes["acceleratorCount"] = 0;1511return;1512} else {1513// changing something else, so we fix the machine type1514for (const type in priceData.machineTypes) {1515if (type.startsWith(data.machineType)) {1516configuration["machineType"] = changes["machineType"] =1517type.startsWith("n1-") ? DEFAULT_GPU_INSTANCE : type;1518break;1519}1520}1521}1522}1523ensureZoneIsConsistentWithGPU(priceData, configuration, changes);15241525// Ensure the count is consistent1526const count = configuration.acceleratorCount ?? 0;1527if (count < data.count) {1528changes["acceleratorCount"] = data.count;1529} else if (count > data.max) {1530changes["acceleratorCount"] = data.max;1531}1532}15331534function ensureZoneIsConsistentWithGPU(priceData, configuration, changes) {1535if (!configuration.acceleratorType) return;15361537const data = priceData.accelerators[configuration.acceleratorType];1538if (!data) {1539// invalid acceleratorType.1540return;1541}15421543// Ensure the region/zone is consistent with accelerator type1544const prices = data[configuration.spot ? "spot" : "prices"];1545if (prices[configuration.zone] == null) {1546// there are no GPUs in the selected zone of the selected type.1547// If you just explicitly changed the GPU type, then we fix this by changing the zone.1548if (changes["acceleratorType"] != null) {1549// fix the region and zone1550// find cheapest zone in the world.1551let price = 999999999;1552let zoneChoice = "";1553for (const zone in prices) {1554if (prices[zone] < price) {1555price = prices[zone];1556zoneChoice = zone;1557}1558}1559if (zoneChoice) {1560changes["zone"] = configuration["zone"] = zoneChoice;1561changes["region"] = configuration["region"] = zoneToRegion(zoneChoice);1562return;1563}1564} else {1565// You did not change the GPU type, so we disable the GPU1566configuration["acceleratorType"] = changes["acceleratorType"] = "";1567configuration["acceleratorCount"] = changes["acceleratorCount"] = 0;1568return;1569}1570}1571}15721573// The Nvidia L4 and A100 are a little different, etc.1574function ensureConsistentNvidiaL4andA100(priceData, configuration, changes) {1575const { machineType, acceleratorType } = configuration;15761577// L4 or A100 GPU machine type, but switching to no GPU, so we have1578// to change the machine type1579if (1580machineType.startsWith("g2-") ||1581machineType.startsWith("a2-") ||1582machineType.startsWith("a3-")1583) {1584if (!acceleratorType) {1585// Easy case -- the user is explicitly changing the GPU from being set1586// to NOT be set, and the GPU is L4 or A100. In this case,1587// we just set the machine type to some non-gpu type1588// and we're done.1589configuration.machineType = changes.machineType = FALLBACK_INSTANCE;1590return;1591}1592}1593if (1594acceleratorType != "nvidia-h200-141gb" &&1595acceleratorType != "nvidia-h100-80gb" &&1596acceleratorType != "nvidia-tesla-a100" &&1597acceleratorType != "nvidia-a100-80gb" &&1598acceleratorType != "nvidia-l4"1599) {1600// We're not switching to an A100 or L4, so not handled further here.1601return;1602}16031604if (!configuration.acceleratorCount) {1605configuration.acceleratorCount = changes.acceleratorCount = 1;1606}16071608// Ensure machine type is consistent with the GPU and count we're switching to.1609let machineTypes =1610priceData.accelerators[acceleratorType]?.machineType[1611configuration.acceleratorCount1612];1613if (machineTypes == null) {1614configuration.acceleratorCount = changes.acceleratorCount = 1;1615machineTypes =1616priceData.accelerators[acceleratorType]?.machineType[1617configuration.acceleratorCount1618];16191620if (machineTypes == null) {1621// maybe 1 gpu isn't allowed, e.g., with H2001622const machineType = priceData.accelerators[acceleratorType]?.machineType;1623if (machineType != null) {1624for (const count in machineType) {1625configuration.acceleratorCount = changes.acceleratorCount =1626parseInt(count);1627machineTypes =1628priceData.accelerators[acceleratorType]?.machineType[1629configuration.acceleratorCount1630];1631}1632}1633}1634}1635if (machineTypes == null) {1636throw Error("bug -- this can't happen");1637}16381639if (!machineTypes.includes(configuration.machineType)) {1640configuration.machineType = changes.machineType =1641machineTypes[0].startsWith("n1-")1642? DEFAULT_GPU_INSTANCE1643: machineTypes[0];1644}1645}16461647function ensureConsistentRegionAndZoneWithMachineType(1648priceData,1649configuration,1650changes,1651) {1652// Specifically selecting a machine type. We make this the1653// highest priority, so if you are changing this, we make everything1654// else fit it.1655const machineType = configuration["machineType"];1656if (priceData.machineTypes[machineType] == null) {1657console.warn(1658`BUG -- This should never happen: unknown machineType = '${machineType}'`,1659);1660// invalid machineType1661if (configuration.acceleratorType) {1662configuration["machineType"] = changes["machineType"] =1663DEFAULT_GPU_INSTANCE;1664} else {1665configuration["machineType"] = changes["machineType"] = FALLBACK_INSTANCE;1666}1667return;1668}16691670const i = machineType.indexOf("-");1671const prefix = machineType.slice(0, i);16721673let zoneHasMachineType = (1674priceData.zones[configuration.zone]?.machineTypes ?? []1675).includes(prefix);1676const regionToCost =1677priceData.machineTypes[machineType][1678configuration.spot ? "spot" : "prices"1679] ?? {};1680const regionHasMachineType = regionToCost[configuration.region] != null;16811682if (!regionHasMachineType) {1683// Our machine type is not in the currently selected region,1684// so find cheapest region with our requested machine type.1685let price = 1e8;1686for (const region in regionToCost) {1687if (regionToCost[region] < price) {1688price = regionToCost[region];1689configuration["region"] = changes["region"] = region;1690// since we changed the region:1691zoneHasMachineType = false;1692}1693}1694}1695if (!zoneHasMachineType) {1696// now the region has the machine type, but the zone doesn't (or1697// region changed so zone has to change).1698// So we find some zone with the machine in that region1699for (const zone in priceData.zones) {1700if (zone.startsWith(configuration["region"])) {1701if ((priceData.zones[zone]?.machineTypes ?? []).includes(prefix)) {1702configuration["zone"] = changes["zone"] = zone;1703break;1704}1705}1706}1707}17081709if (configuration.acceleratorType && configuration.acceleratorCount) {1710if (priceData.accelerators[configuration.acceleratorType] == null) {1711// The accelerator type no longer exists in the pricing data (e.g., maybe it was deprecated),1712// so replace it by one that exists.1713for (const type in priceData.accelerators) {1714configuration.acceleratorType = changes.acceleratorType = type;1715break;1716}1717}1718// have a GPU -- make sure zone works1719if (1720!priceData.accelerators[configuration.acceleratorType].prices[1721configuration.zone1722]1723) {1724// try to find a different zone in the region that works1725let fixed = false;1726const region = zoneToRegion(configuration["zone"]);1727for (const zone in priceData.accelerators[configuration.acceleratorType]1728?.prices) {1729if (zone.startsWith(region)) {1730fixed = true;1731configuration.zone = changes.zone = zone;1732break;1733}1734}1735if (!fixed) {1736// just choose cheapest zone in some region1737const zone = cheapestZone(1738priceData.accelerators[configuration.acceleratorType][1739configuration.spot ? "spot" : "prices"1740],1741);1742configuration.zone = changes.zone = zone;1743configuration.region = changes.region = zoneToRegion(zone);1744}1745}1746}1747}17481749function zoneToRegion(zone: string): string {1750const i = zone.lastIndexOf("-");1751return zone.slice(0, i);1752}17531754function Network({ setConfig, configuration, loading, priceData }) {1755const [externalIp, setExternalIp] = useState<boolean>(1756configuration.externalIp ?? true,1757);1758useEffect(() => {1759setExternalIp(configuration.externalIp ?? true);1760}, [configuration.externalIp]);17611762return (1763<div>1764<div style={{ color: "#666", marginBottom: "5px" }}>1765<b>1766<Icon name="network" /> Network1767</b>1768<br />1769All compute servers on Google cloud have full network access with1770unlimited data transfer in for free. Data transfer out{" "}1771<b>costs {currency(DATA_TRANSFER_OUT_COST_PER_GiB)}/GiB</b>.1772</div>1773<Checkbox1774checked={externalIp}1775disabled={1776true /* 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"*/1777}1778onChange={() => {1779setExternalIp(!externalIp);1780setConfig({ externalIp: !externalIp });1781}}1782>1783External IP Address1784</Checkbox>1785<div style={{ marginTop: "5px" }}>1786<Typography.Paragraph1787style={{ color: "#666" }}1788ellipsis={{1789expandable: true,1790rows: 2,1791symbol: "more",1792}}1793>1794{/* TODO: we can and will in theory support all this without external1795ip using a gateway. E.g., google cloud shell has ssh to host, etc. */}1796An external IP address is required and costs{" "}1797{configuration.spot1798? `${currency(1799markup({ cost: EXTERNAL_IP_COST.spot, priceData }),1800)}/hour`1801: `${currency(1802markup({1803cost: EXTERNAL_IP_COST.standard,1804priceData,1805}),1806)}/hour`}{" "}1807while the VM is running (there is no charge when not running).1808</Typography.Paragraph>1809</div>1810{externalIp && (1811<DNS1812setConfig={setConfig}1813configuration={configuration}1814loading={loading}1815/>1816)}1817</div>1818);1819}18201821function cheapestZone(costs: { [zone: string]: number }): string {1822let price = 99999999999999999;1823let choice = "";1824for (const zone in costs) {1825if (costs[zone] < price) {1826choice = zone;1827price = costs[zone];1828}1829}1830return choice;1831}18321833function CostPerHour({1834cost,1835extra,1836style,1837}: {1838cost?: number;1839extra?;1840style?;1841}) {1842if (cost == null) {1843return null;1844}1845return (1846<div style={{ fontFamily: "monospace", ...style }}>1847{currency(cost)}/hour1848{extra}1849</div>1850);1851}18521853function Admin({ id, configuration, loading, template }) {1854const isAdmin = useTypedRedux("account", "is_admin");1855const [error, setError] = useState<string>("");1856const [calling, setCalling] = useState<boolean>(false);1857if (!isAdmin) {1858return null;1859}1860return (1861<div>1862<div style={{ color: "#666", marginBottom: "5px" }}>1863<b>1864<Icon name="users" /> Admin1865</b>1866<br />1867Settings and functionality only available to admins.1868<br />1869<ShowError error={error} setError={setError} />1870<Tooltip title="Once you have tested the currently selected image, click this button to mark it as tested.">1871<Button1872disabled={loading || !id || calling}1873onClick={async () => {1874try {1875setCalling(true);1876await setImageTested({ id, tested: true });1877// force reload to database via GCP api call1878await reloadImages("compute_servers_images_google", true);1879} catch (err) {1880setError(`${err}`);1881} finally {1882setCalling(false);1883}1884}}1885>1886Mark Google Cloud Image Tested{" "}1887{calling && <Spin style={{ marginLeft: "15px" }} />}1888</Button>1889</Tooltip>1890<pre>1891id={id}, configuration={JSON.stringify(configuration, undefined, 2)}1892</pre>1893<Template id={id} template={template} />1894</div>1895</div>1896);1897}189818991900