CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutSign UpSign In
sagemathinc

Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.

GitHub Repository: sagemathinc/cocalc
Path: blob/master/src/packages/next/components/store/dedicated.tsx
Views: 687
1
/*
2
* This file is part of CoCalc: Copyright © 2022 Sagemath, Inc.
3
* License: MS-RSL – see LICENSE.md for details
4
*/
5
6
/*
7
Create a new dedicated vm/disk site license.
8
*/
9
10
import { Alert, Divider, Form, Input, Radio, Select, Typography } from "antd";
11
import { sortBy } from "lodash";
12
import { useRouter } from "next/router";
13
import { useEffect, useRef, useState } from "react";
14
import PaygInfo from "./payg-info";
15
import { Icon } from "@cocalc/frontend/components/icon";
16
import { get_local_storage } from "@cocalc/frontend/misc/local-storage";
17
import { HOME_PREFIX, ROOT } from "@cocalc/util/consts/dedicated";
18
import { DOC_CLOUD_STORAGE_URL } from "@cocalc/util/consts/project";
19
import { testDedicatedDiskNameBasic } from "@cocalc/util/licenses/check-disk-name-basics";
20
import { CostInputPeriod } from "@cocalc/util/licenses/purchase/types";
21
import { money } from "@cocalc/util/licenses/purchase/utils";
22
import {
23
DedicatedDiskSpeedNames,
24
DISK_NAMES,
25
VMsType,
26
} from "@cocalc/util/types/dedicated";
27
import {
28
DEDICATED_DISK_SIZE_INCREMENT,
29
DEFAULT_DEDICATED_DISK_SIZE,
30
DEFAULT_DEDICATED_DISK_SPEED,
31
getDedicatedDiskKey,
32
MAX_DEDICATED_DISK_SIZE,
33
MIN_DEDICATED_DISK_SIZE,
34
PRICES,
35
} from "@cocalc/util/upgrades/dedicated";
36
import { DateRange } from "@cocalc/util/upgrades/shopping";
37
import { Paragraph, Text, Title } from "components/misc";
38
import A from "components/misc/A";
39
import IntegerSlider from "components/misc/integer-slider";
40
import Loading from "components/share/loading";
41
import SiteName from "components/share/site-name";
42
import apiPost from "lib/api/post";
43
import { useScrollY } from "lib/use-scroll-y";
44
import { AddBox } from "./add-box";
45
import { ApplyLicenseToProject } from "./apply-license-to-project";
46
import { computeCost } from "@cocalc/util/licenses/store/compute-cost";
47
import { InfoBar } from "./cost-info-bar";
48
import { SignInToPurchase } from "./sign-in-to-purchase";
49
import { TitleDescription } from "./title-description";
50
import { ToggleExplanations } from "./toggle-explanations";
51
import { UsageAndDuration } from "./usage-and-duration";
52
import { getType, loadDateRange } from "./util";
53
54
const GCP_DISK_URL = "https://cloud.google.com/compute/docs/disks#pdspecs";
55
const GCP_DISK_PERFORMANCE_URL =
56
"https://cloud.google.com/compute/docs/disks/performance";
57
58
interface Props {
59
noAccount: boolean;
60
}
61
62
export default function DedicatedResource(props: Props) {
63
const { noAccount } = props;
64
const router = useRouter();
65
const headerRef = useRef<HTMLHeadingElement>(null);
66
67
// most likely, user will go to the cart next
68
useEffect(() => {
69
router.prefetch("/store/cart");
70
}, []);
71
72
const [offsetHeader, setOffsetHeader] = useState(0);
73
const scrollY = useScrollY();
74
75
useEffect(() => {
76
if (headerRef.current) {
77
setOffsetHeader(headerRef.current.offsetTop);
78
}
79
}, []);
80
81
return (
82
<>
83
<Title level={3} ref={headerRef}>
84
<Icon name={"dedicated"} style={{ marginRight: "5px" }} />{" "}
85
{router.query.id != null
86
? "Edit Dedicated VM License in Shopping Cart"
87
: "Buy a Dedicated VM (Deprecated)"}
88
</Title>
89
{router.query.id == null && (
90
<>
91
<Paragraph>
92
A{" "}
93
<A href="https://doc.cocalc.com/licenses.html">
94
<SiteName /> dedicated resource license
95
</A>{" "}
96
can be used to outfit your project either with additional disk
97
storage or moves your project to a much more powerful virtual
98
machine. Create a dedicated resources license below then add it to
99
your <A href="/store/cart">shopping cart</A>.
100
</Paragraph>
101
<Paragraph>
102
It is also possible to run <SiteName /> on your own hardware. Check
103
out the{" "}
104
<Text strong>
105
<A href={"/pricing/onprem"}>on premises offerings</A>
106
</Text>{" "}
107
to learn more about this.
108
</Paragraph>
109
<Paragraph>
110
<PaygInfo what={"a dedicated VM or disk"} />
111
</Paragraph>
112
</>
113
)}
114
<CreateDedicatedResource
115
showInfoBar={scrollY > offsetHeader}
116
noAccount={noAccount}
117
/>
118
</>
119
);
120
}
121
122
function CreateDedicatedResource({ showInfoBar = false, noAccount = false }) {
123
// somehow this state is necessary to render the form properly
124
const [formType, setFormType] = useState<"disk" | "vm" | null>(null);
125
const [cost, setCost] = useState<CostInputPeriod | undefined>(undefined);
126
const [loading, setLoading] = useState<boolean>(false);
127
const [cartError, setCartError] = useState<string>("");
128
const [showExplanations, setShowExplanations] = useState<boolean>(true);
129
const [durationTypes, setDurationTypes] = useState<"monthly" | "range">(
130
"monthly",
131
);
132
const [vmMachine, setVmMachine] = useState<keyof VMsType | null>(null);
133
const [diskNameValid, setDiskNameValid] = useState<boolean>(false);
134
const [showInfo, setShowInfo] = useState<boolean>(false);
135
const [form] = Form.useForm();
136
const router = useRouter();
137
138
// most likely, user will go to the cart next
139
useEffect(() => {
140
router.prefetch("/store/cart");
141
}, []);
142
143
function fixupDuration() {
144
switch (form.getFieldValue("type")) {
145
case "disk":
146
setDurationTypes("monthly");
147
if (form.getFieldValue("period") === "range") {
148
form.setFieldsValue({ period: "monthly" });
149
}
150
break;
151
case "vm":
152
setDurationTypes("range");
153
if (form.getFieldValue("period") !== "range") {
154
form.setFieldsValue({ period: "range" });
155
}
156
break;
157
}
158
}
159
160
function calcCost() {
161
const data = form.getFieldsValue(true);
162
163
try {
164
switch (data.type) {
165
case "disk":
166
const size_gb = data["disk-size_gb"];
167
const speed = data["disk-speed"];
168
if (size_gb == null || speed == null) {
169
return; // no data to compute price
170
}
171
setCost(
172
computeCost({
173
type: "disk",
174
period: "monthly",
175
dedicated_disk: {
176
speed,
177
size_gb,
178
name: data["disk-name"],
179
},
180
}),
181
);
182
break;
183
case "vm":
184
setCost(
185
computeCost({
186
type: "vm",
187
period: "range",
188
range: data.range,
189
dedicated_vm: {
190
machine: data["vm-machine"],
191
},
192
}),
193
);
194
break;
195
}
196
} catch (err) {
197
setCost(undefined);
198
}
199
}
200
201
function onChange() {
202
fixupDuration();
203
calcCost();
204
}
205
206
async function loadItem(item: {
207
id: number;
208
product: string;
209
description: {
210
dedicated_disk?: any;
211
dedicated_vm?: any;
212
range?: DateRange;
213
title?: string;
214
description?: string;
215
};
216
}) {
217
if (item.product !== "site-license") {
218
throw new Error("not a site license");
219
}
220
const type = getType(item);
221
if (type !== "disk" && type !== "vm") {
222
throw new Error(`cannot deal with type ${type}`);
223
}
224
const conf = item.description;
225
226
// restoring name/description
227
form.setFieldsValue({
228
title: conf.title,
229
description: conf.description,
230
});
231
232
switch (type) {
233
case "disk":
234
const d = conf.dedicated_disk;
235
form.setFieldsValue({
236
type,
237
"disk-size_gb": d.size_gb,
238
"disk-speed": d.type,
239
"disk-name": d.name,
240
});
241
// we have to re-validate the disk name, b/c name could be taken in the meantime
242
// just calling the form to revalidate does not work.
243
try {
244
await testDedicatedDiskName(d.name);
245
setDiskNameValid(true);
246
} catch (err) {
247
setDiskNameValid(false);
248
}
249
break;
250
251
case "vm":
252
const vm = conf.dedicated_vm?.machine;
253
if (PRICES.vms[vm] == null) {
254
console.warn(`VM type ${vm} not found`);
255
} else {
256
form.setFieldsValue({
257
"vm-machine": vm,
258
});
259
}
260
form.setFieldsValue({
261
type,
262
range: loadDateRange(conf.range),
263
});
264
break;
265
}
266
// unpacking and configuring the form worked, now we do the type selection to show it
267
setFormType(type);
268
}
269
270
useEffect(() => {
271
const store_site_license_show_explanations = get_local_storage(
272
"store_site_license_show_explanations",
273
);
274
if (store_site_license_show_explanations != null) {
275
setShowExplanations(!!store_site_license_show_explanations);
276
}
277
const { id } = router.query;
278
if (!noAccount && id != null) {
279
// editing something in the shopping cart
280
(async () => {
281
try {
282
setLoading(true);
283
const item = await apiPost("/shopping/cart/get", { id });
284
await loadItem(item);
285
} catch (err) {
286
setCartError(err.message);
287
} finally {
288
setLoading(false);
289
}
290
onChange();
291
})();
292
}
293
onChange();
294
}, []);
295
296
useEffect(() => {
297
const { type } = router.query;
298
if (typeof type === "string") {
299
setType(type);
300
}
301
}, []);
302
303
useEffect(() => {
304
form.validateFields();
305
}, [form.getFieldValue("type")]);
306
307
if (loading) {
308
return <Loading large center />;
309
}
310
311
function setType(type: string) {
312
if (type === "vm" || type === "disk") {
313
form.resetFields();
314
form.setFieldsValue({ type });
315
setFormType(type);
316
setCost(undefined);
317
setCartError("");
318
onChange();
319
} else {
320
console.log(`unable to setType to ${type}`);
321
}
322
}
323
324
function renderTypeSelection() {
325
return (
326
<Form.Item
327
name="type"
328
label="Dedicated"
329
rules={[{ required: true, message: "Please select a type" }]}
330
extra={
331
showExplanations && (
332
<div style={{ marginTop: "5px" }}>
333
Select if you want to get a Dedicate Disk or a Virtual Machine.
334
NOTE: Dedicated VM's are deprecated -- create a{" "}
335
<A href="https://doc.cocalc.com/compute_server.html">
336
compute server
337
</A>{" "}
338
instead.
339
</div>
340
)
341
}
342
>
343
<Radio.Group
344
disabled
345
onChange={(e) => {
346
// Clear error whenever changing this selection to something.
347
// See comment in validateDedicatedDiskName about how this
348
// isn't great.
349
setCartError("");
350
setType(e.target.value);
351
}}
352
>
353
<Radio.Button key={"disk"} value={"disk"} disabled>
354
Disk
355
</Radio.Button>
356
<Radio.Button key={"vm"} value={"vm"}>
357
Virtual Machine
358
</Radio.Button>
359
</Radio.Group>
360
</Form.Item>
361
);
362
}
363
364
function renderAdditionalInfoContent() {
365
switch (formType) {
366
case "disk":
367
return (
368
<>
369
<Typography.Paragraph
370
ellipsis={{
371
expandable: true,
372
rows: 2,
373
symbol: "more",
374
onExpand: () => setShowInfo(true),
375
}}
376
>
377
This license attaches a disk to your project. When the license is
378
valid and activated by adding to a project, a disk will be created
379
on the fly. It will be formatted and mounted into your project.
380
You'll be able to access it via a symlink in your project's home
381
directory – i.e. <code>~/{HOME_PREFIX}/&lt;name&gt;</code> will be
382
pointing to <code>{ROOT}/&lt;name&gt;</code>.
383
</Typography.Paragraph>
384
<Typography.Paragraph style={{ display: showInfo ? "" : "none" }}>
385
Once you cancel the subscription, the subscription will end at the
386
end of the billing period. Then, the disk and all the data it
387
contains <strong>will be deleted</strong>!
388
</Typography.Paragraph>
389
<Typography.Paragraph style={{ display: showInfo ? "" : "none" }}>
390
It's also possible to move a disk from one project to another one.
391
First, remove the license from the project, restart the project to
392
unmount the disk. Then, add the license to another project and
393
restart that project as well.
394
</Typography.Paragraph>
395
<Typography.Paragraph style={{ display: showInfo ? "" : "none" }}>
396
Note: it is also possible to mount external data storage to a
397
project:{" "}
398
<A href={DOC_CLOUD_STORAGE_URL}>
399
cloud storage & remote file systems
400
</A>
401
. This could help transferring data in and out of <SiteName />.
402
</Typography.Paragraph>
403
</>
404
);
405
case "vm":
406
return (
407
<>
408
<Typography.Paragraph
409
ellipsis={{
410
expandable: true,
411
rows: 2,
412
symbol: "more",
413
onExpand: () => setShowInfo(true),
414
}}
415
>
416
For the specified period of time, a virtual machine is provisioned
417
and started inside of <SiteName />
418
's cluster. You have to add the license to one of your projects in
419
order to tell it to move to this virtual machine. This happens
420
when the project is started or restarted.
421
</Typography.Paragraph>
422
<Typography.Paragraph style={{ display: showInfo ? "" : "none" }}>
423
Once your project has moved over, the usual quota upgrades will be
424
ineffective – instead, your project runs with the quota limits
425
implied by the performance of the underlying virtual machine. The
426
files/data in your project will be exactly the same as before.
427
</Typography.Paragraph>
428
<Typography.Paragraph style={{ display: showInfo ? "" : "none" }}>
429
Once the license period is over, the virtual machine will be shut
430
down. At that point your project will be stopped as well. The next
431
time it starts, it will run under the usual quota regime on a
432
shared node in the cluster.
433
</Typography.Paragraph>
434
</>
435
);
436
}
437
}
438
439
function renderAdditionalInfo() {
440
return (
441
<Form.Item label="How does it work?">
442
<div style={{ paddingTop: "5px" }}>{renderAdditionalInfoContent()}</div>
443
</Form.Item>
444
);
445
}
446
447
function renderDurationExplanation() {
448
if (!showExplanations) return;
449
switch (durationTypes) {
450
case "monthly":
451
return (
452
<>
453
Currently, disk can be only be rented on a monthly basis only. Note:
454
you can cancel the subscription any time and at the end of the
455
billing period the disk – and the data it holds – will be destroyed.
456
</>
457
);
458
case "range":
459
return (
460
<>
461
Dedicated VMs can only be rented for a specific period of time. At
462
its end, the node will be stopped and removed, and your project
463
moves back to the usual upgrade schema.
464
</>
465
);
466
}
467
}
468
469
function renderUsageAndDuration() {
470
return (
471
<UsageAndDuration
472
extraDuration={renderDurationExplanation()}
473
form={form}
474
onChange={onChange}
475
showUsage={false}
476
duration={durationTypes}
477
discount={false}
478
/>
479
);
480
}
481
482
async function testDedicatedDiskName(name): Promise<void> {
483
testDedicatedDiskNameBasic(name);
484
// if the above passes, then we can check if the name is available.
485
const serverCheck = await apiPost("licenses/check-disk-name", { name }, 60);
486
if (serverCheck?.available === true) {
487
return;
488
} else {
489
throw new Error("Please choose a different disk name.");
490
}
491
}
492
493
/**
494
* The disk name will get a prefix like "kucalc-[cluster id]-pd-[namespace]-dedicated-..."
495
* It's impossible to know the prefix, since the properties of the cluster can change.
496
* The maximum total length of the disk name is 63, according to the GCE documentation.
497
* https://cloud.google.com/compute/docs/naming-resources#resource-name-format
498
* I hope a max length of 20 is sufficiently restrictive.
499
*/
500
function validateDedicatedDiskName() {
501
return {
502
validator: async (_, name) => {
503
try {
504
await testDedicatedDiskName(name);
505
setDiskNameValid(true);
506
// WARNING! This is obviously not good code in general, since we're clearing all
507
// errors if the disk name happens to be valid.
508
// It's OK for now since this is the only field we do validation with, and
509
// any other error would be, e.g., in submission of the form to the backend.
510
setCartError("");
511
} catch (err) {
512
setCartError(err.message);
513
setDiskNameValid(false);
514
throw err;
515
}
516
},
517
};
518
}
519
520
function renderDedicatedDiskInfo() {
521
if (!showExplanations) return;
522
return (
523
<p>
524
More information about Dedicated Disks can be found at{" "}
525
<A href={GCP_DISK_URL}>GCP: Persistent Disk</A>.
526
</p>
527
);
528
}
529
530
function renderDiskPerformance() {
531
const size_gb = form.getFieldValue("disk-size_gb");
532
const speed = form.getFieldValue("disk-speed");
533
if (size_gb == null || speed == null) return;
534
const diskID = getDedicatedDiskKey({ size_gb, speed });
535
const di = PRICES.disks[diskID];
536
if (di == null) {
537
return (
538
<p style={{ marginTop: "5px" }}>
539
Unknown disk with ID <code>{diskID}</code>.
540
</p>
541
);
542
}
543
return (
544
<p style={{ marginTop: "5px" }}>
545
{di.mbps} MB/s sustained throughput and {di.iops} IOPS read/write. For
546
more detailed information:{" "}
547
<A href={GCP_DISK_PERFORMANCE_URL}>GCP disk performance</A> information.
548
</p>
549
);
550
}
551
552
function renderDiskExtra() {
553
if (!showExplanations) return;
554
const formName = form.getFieldValue("disk-name");
555
const name = formName ? formName : <>&lt;name&gt;</>;
556
return (
557
<p>
558
Give your disk a name. It must be unique and will be used as part of the
559
directory name. The mount point will be{" "}
560
<code>
561
{ROOT}/{name}
562
</code>{" "}
563
and if the name isn't already taken. For your convenience, if possible
564
there will be a symlink named{" "}
565
<code>
566
~/{HOME_PREFIX}/{name}
567
</code>{" "}
568
pointing from your home directory to your disk for your convenience.
569
</p>
570
);
571
}
572
573
// ATTN: the IntegerSlider must be kept in sync with DEDICATED_DISK_SIZES in
574
// src/packages/util/upgrades/dedicated.ts
575
function renderDedicatedDisk() {
576
return (
577
<>
578
<Form.Item
579
name="disk-name"
580
label="Name"
581
hasFeedback
582
extra={renderDiskExtra()}
583
rules={[validateDedicatedDiskName]}
584
>
585
<Input style={{ width: "15em" }} />
586
</Form.Item>
587
588
<Form.Item
589
label="Size"
590
name="disk-size_gb"
591
initialValue={DEFAULT_DEDICATED_DISK_SIZE}
592
extra={
593
showExplanations && <>Select the size of the dedicated disk.</>
594
}
595
>
596
<IntegerSlider
597
min={MIN_DEDICATED_DISK_SIZE}
598
max={MAX_DEDICATED_DISK_SIZE}
599
step={DEDICATED_DISK_SIZE_INCREMENT}
600
onChange={(val) => {
601
form.setFieldsValue({ "disk-size_gb": val });
602
onChange();
603
}}
604
units={"G"}
605
presets={[32, 64, 128, 256, 512, 1024]}
606
/>
607
</Form.Item>
608
609
<Form.Item
610
name="disk-speed"
611
label="Speed"
612
initialValue={DEFAULT_DEDICATED_DISK_SPEED}
613
extra={renderDedicatedDiskInfo()}
614
>
615
<Radio.Group
616
onChange={(e) => {
617
form.setFieldsValue({ "disk-speed": e.target.value });
618
onChange();
619
}}
620
>
621
{DedicatedDiskSpeedNames.map((type) => (
622
<Radio.Button key={type} value={type}>
623
{DISK_NAMES[type]}
624
</Radio.Button>
625
))}
626
</Radio.Group>
627
</Form.Item>
628
629
<Form.Item label="Performance">{renderDiskPerformance()}</Form.Item>
630
</>
631
);
632
}
633
634
function renderDedicatedVmInfo() {
635
if (!showExplanations) return;
636
return (
637
<>
638
More information about VM types can be found at{" "}
639
<A href={"https://cloud.google.com/compute/docs/machine-types"}>
640
GCP: machine families
641
</A>
642
.
643
</>
644
);
645
}
646
647
function renderVmPerformance() {
648
if (vmMachine == null) return;
649
const { spec } = PRICES.vms?.[vmMachine] ?? {};
650
if (spec == null) {
651
return (
652
<p>
653
Problem: the specifications of <code>{vmMachine}</code> are not known
654
</p>
655
);
656
}
657
return (
658
<p>
659
Restarting your project while this license is active, will move your
660
project on a virtual machine in <SiteName />
661
's cluster. This machine will allow you to use up to {spec.cpu} vCPU
662
cores and {spec.mem} G memory.
663
</p>
664
);
665
}
666
667
function dedicatedVmOptions() {
668
return sortBy(
669
Object.entries(PRICES.vms),
670
([_, vm]) =>
671
`${1000 + (vm?.spec.cpu ?? 0)}:${1000 + (vm?.spec.mem ?? 0)}`,
672
).map(([id, vm]: [string, NonNullable<VMsType[string]>]) => {
673
return (
674
<Select.Option key={id} value={id}>
675
<Text>{vm.title ?? vm.spec}</Text>
676
<Text style={{ paddingLeft: "1em" }} type="secondary">
677
({money(vm.price_day)} per day)
678
</Text>
679
</Select.Option>
680
);
681
});
682
}
683
684
function renderDedicatedVM() {
685
return (
686
<>
687
<Form.Item
688
label="Type"
689
name="vm-machine"
690
initialValue={null}
691
extra={renderDedicatedVmInfo()}
692
rules={[{ required: true, message: "Please select a VM type." }]}
693
>
694
<Select
695
onChange={(val) => {
696
form.setFieldsValue({ "vm-machine": val });
697
setVmMachine(val);
698
onChange();
699
}}
700
>
701
{dedicatedVmOptions()}
702
</Select>
703
</Form.Item>
704
<Form.Item label="Performance">
705
<div style={{ paddingTop: "5px" }}>{renderVmPerformance()}</div>
706
</Form.Item>
707
</>
708
);
709
}
710
711
function renderConfiguration() {
712
switch (formType) {
713
case "disk":
714
return renderDedicatedDisk();
715
case "vm":
716
return renderDedicatedVM();
717
}
718
}
719
720
function renderCost() {
721
const input = cost?.input;
722
const disabled =
723
cost == null ||
724
input == null ||
725
(input.type === "vm" && (input.start == null || input.end == null)) ||
726
(input.type === "disk" && !diskNameValid);
727
728
return (
729
<Form.Item wrapperCol={{ offset: 0, span: 24 }}>
730
<AddBox
731
cost={cost}
732
form={form}
733
cartError={cartError}
734
setCartError={setCartError}
735
router={router}
736
dedicatedItem={true}
737
disabled={disabled}
738
noAccount={noAccount}
739
/>
740
</Form.Item>
741
);
742
}
743
744
function renderStartupWarning() {
745
if (formType !== "vm") return;
746
return (
747
<Form.Item label="Warning">
748
<Alert
749
type="warning"
750
showIcon
751
message="It takes about 15 minutes to start a Dedicated VM. Until then, the project will not be able to start."
752
/>
753
</Form.Item>
754
);
755
}
756
757
return (
758
<div>
759
<InfoBar
760
show={showInfoBar}
761
cost={cost}
762
router={router}
763
form={form}
764
cartError={cartError}
765
setCartError={setCartError}
766
noAccount={noAccount}
767
/>
768
<ApplyLicenseToProject router={router} />
769
<SignInToPurchase noAccount={noAccount} />
770
<Form
771
form={form}
772
style={{
773
marginTop: "15px",
774
margin: "auto",
775
border: "1px solid #ddd",
776
padding: "15px",
777
}}
778
name="basic"
779
labelCol={{ span: 6 }}
780
wrapperCol={{ span: 18 }}
781
autoComplete="off"
782
onValuesChange={onChange}
783
>
784
<ToggleExplanations
785
showExplanations={showExplanations}
786
setShowExplanations={setShowExplanations}
787
/>
788
789
{renderTypeSelection()}
790
791
{formType != null && (
792
<>
793
{renderAdditionalInfo()}
794
{renderUsageAndDuration()}
795
796
<Divider plain>Configuration</Divider>
797
{renderConfiguration()}
798
799
<TitleDescription showExplanations={showExplanations} form={form} />
800
{renderStartupWarning()}
801
{renderCost()}
802
</>
803
)}
804
</Form>
805
</div>
806
);
807
}
808
809