Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
sagemathinc
GitHub Repository: sagemathinc/cocalc
Path: blob/master/src/packages/frontend/compute/action.tsx
1503 views
1
import { Alert, Button, Modal, Popconfirm, Popover, Spin } from "antd";
2
import { useEffect, useState } from "react";
3
import { redux, useStore } from "@cocalc/frontend/app-framework";
4
import { A, CopyToClipBoard, Icon } from "@cocalc/frontend/components";
5
import ShowError from "@cocalc/frontend/components/error";
6
import { appBasePath } from "@cocalc/frontend/customize/app-base-path";
7
import { CancelText } from "@cocalc/frontend/i18n/components";
8
import MoneyStatistic from "@cocalc/frontend/purchases/money-statistic";
9
import confirmStartComputeServer from "@cocalc/frontend/purchases/pay-as-you-go/confirm-start-compute-server";
10
import { webapp_client } from "@cocalc/frontend/webapp-client";
11
import {
12
ACTION_INFO,
13
STATE_INFO,
14
getTargetState,
15
getArchitecture,
16
} from "@cocalc/util/db-schema/compute-servers";
17
import { computeServerAction, getApiKey } from "./api";
18
import costPerHour from "./cost";
19
20
export default function getActions({
21
id,
22
state,
23
editable,
24
setError,
25
configuration,
26
editModal,
27
type,
28
project_id,
29
}): React.JSX.Element[] {
30
if (!editable && !configuration?.allowCollaboratorControl) {
31
return [];
32
}
33
const s = STATE_INFO[state ?? "off"];
34
if (s == null) {
35
return [];
36
}
37
if ((s.actions ?? []).length == 0) {
38
return [];
39
}
40
const v: React.JSX.Element[] = [];
41
for (const action of s.actions) {
42
if (
43
!editable &&
44
!["stop", "start", "suspend", "resume", "reboot", "deprovision"].includes(
45
action,
46
)
47
) {
48
// non-owner can only do start/stop/suspend/resume/deprovision -- NOT delete.
49
continue;
50
}
51
const a = ACTION_INFO[action];
52
if (!a) continue;
53
if (action == "suspend") {
54
if (configuration.cloud != "google-cloud") {
55
continue;
56
}
57
if (getArchitecture(configuration) == "arm64") {
58
// TODO: suspend/resume breaks the clock badly on ARM64, and I haven't
59
// figured out a workaround, so don't support it for now. I guess this
60
// is a GCP bug.
61
continue;
62
}
63
// must have no gpu and <= 208GB of RAM -- https://cloud.google.com/compute/docs/instances/suspend-resume-instance
64
if (configuration.acceleratorType) {
65
continue;
66
}
67
// [ ] TODO: we don't have an easy way to check the RAM requirement right now.
68
}
69
if (!editModal && configuration.ephemeral && action == "stop") {
70
continue;
71
}
72
const {
73
label,
74
icon,
75
tip,
76
description,
77
confirm,
78
danger,
79
confirmMessage,
80
clouds,
81
} = a;
82
if (danger && !configuration.ephemeral && !editModal) {
83
continue;
84
}
85
if (clouds && !clouds.includes(configuration.cloud)) {
86
continue;
87
}
88
v.push(
89
<ActionButton
90
style={v.length > 0 ? { marginLeft: "5px" } : undefined}
91
key={action}
92
id={id}
93
action={action}
94
label={label}
95
icon={icon}
96
tip={tip}
97
editable={editable}
98
description={description}
99
setError={setError}
100
confirm={confirm}
101
configuration={configuration}
102
danger={danger}
103
confirmMessage={confirmMessage}
104
type={type}
105
state={state ?? "off"}
106
project_id={project_id}
107
/>,
108
);
109
}
110
return v;
111
}
112
113
function ActionButton({
114
id,
115
action,
116
icon,
117
label,
118
editable,
119
description,
120
tip,
121
setError,
122
confirm,
123
confirmMessage,
124
configuration,
125
danger,
126
type,
127
style,
128
state,
129
project_id,
130
}) {
131
const [showOnPremStart, setShowOnPremStart] = useState<boolean>(false);
132
const [showOnPremStop, setShowOnPremStop] = useState<boolean>(false);
133
const [showOnPremDeprovision, setShowOnPremDeprovision] =
134
useState<boolean>(false);
135
const [cost_per_hour, setCostPerHour] = useState<number | null>(null);
136
const [popConfirm, setPopConfirm] = useState<boolean>(false);
137
const updateCost = async () => {
138
try {
139
const c = await costPerHour({
140
configuration,
141
state: getTargetState(action),
142
});
143
setCostPerHour(c);
144
return c;
145
} catch (err) {
146
setError(`Unable to compute cost: ${err}`);
147
setCostPerHour(null);
148
return null;
149
}
150
};
151
useEffect(() => {
152
if (configuration == null) return;
153
updateCost();
154
}, [configuration, action]);
155
const customize = useStore("customize");
156
const [understand, setUnderstand] = useState<boolean>(false);
157
const [doing, setDoing] = useState<boolean>(!STATE_INFO[state]?.stable);
158
159
const doAction = async () => {
160
if (action == "start") {
161
// check version
162
const required =
163
customize?.get("version_compute_server_min_project") ?? 0;
164
if (required > 0) {
165
if (redux.getStore("projects").get_state(project_id) == "running") {
166
// only check if running -- if not running, the project will obviously
167
// not need a restart, since it isn't even running
168
const api = await webapp_client.project_client.api(project_id);
169
const version = await api.version(
170
0 /* want version of the home base! */,
171
);
172
if (version < required) {
173
setError(
174
"You must restart your project to upgrade it to the latest version.",
175
);
176
return;
177
}
178
}
179
}
180
}
181
182
if (configuration.cloud == "onprem") {
183
if (action == "start") {
184
setShowOnPremStart(true);
185
} else if (action == "stop") {
186
setShowOnPremStop(true);
187
} else if (action == "deprovision") {
188
setShowOnPremDeprovision(true);
189
}
190
191
// right now user has to copy paste
192
return;
193
}
194
195
try {
196
setError("");
197
setDoing(true);
198
if (editable && (action == "start" || action == "resume")) {
199
let c = cost_per_hour;
200
if (c == null) {
201
c = await updateCost();
202
if (c == null) {
203
// error would be displayed above.
204
return;
205
}
206
}
207
await confirmStartComputeServer({ id, cost_per_hour: c });
208
}
209
await computeServerAction({ id, action });
210
} catch (err) {
211
setError(`${err}`);
212
} finally {
213
setDoing(false);
214
}
215
};
216
useEffect(() => {
217
setDoing(!STATE_INFO[state]?.stable);
218
}, [action, state]);
219
220
if (configuration == null) {
221
return null;
222
}
223
224
let button = (
225
<Button
226
style={style}
227
disabled={doing}
228
type={type}
229
onClick={!confirm ? doAction : undefined}
230
danger={danger}
231
>
232
<Icon name={icon} /> {label}{" "}
233
{doing && (
234
<>
235
<div style={{ display: "inline-block", width: "10px" }} />
236
<Spin />
237
</>
238
)}
239
</Button>
240
);
241
if (confirm) {
242
button = (
243
<Popconfirm
244
onOpenChange={setPopConfirm}
245
placement="right"
246
okButtonProps={{
247
disabled: !configuration.ephemeral && danger && !understand,
248
}}
249
title={
250
<div>
251
{label} - Are you sure?
252
{action == "deprovision" && (
253
<Alert
254
showIcon
255
style={{ margin: "15px 0", maxWidth: "400px" }}
256
type="warning"
257
message={
258
"This will delete the boot disk! This does not touch the files in your HOME BASE (what you see when not using a compute server). This permanently deletes EVERYTHING stored on the compute server, especially in any fast local directories."
259
}
260
/>
261
)}
262
{action == "stop" && (
263
<Alert
264
showIcon
265
style={{ margin: "15px 0" }}
266
type="info"
267
message={`This will safely turn off the VM${
268
editable ? ", and allow you to edit its configuration." : "."
269
}`}
270
/>
271
)}
272
{!configuration.ephemeral && danger && (
273
<div>
274
{/* ATTN: Not using a checkbox here to WORKAROUND A BUG IN CHROME that I see after a day or so! */}
275
<Button onClick={() => setUnderstand(!understand)} type="text">
276
<Icon
277
name={understand ? "check-square" : "square"}
278
style={{ marginRight: "5px" }}
279
/>
280
{confirmMessage ??
281
"I understand that this may result in data loss."}
282
</Button>
283
</div>
284
)}
285
</div>
286
}
287
onConfirm={doAction}
288
okText={`Yes, ${label} VM`}
289
cancelText={<CancelText />}
290
>
291
{button}
292
</Popconfirm>
293
);
294
}
295
296
const content = (
297
<>
298
{button}
299
{showOnPremStart && action == "start" && (
300
<OnPremGuide
301
action={action}
302
setShow={setShowOnPremStart}
303
configuration={configuration}
304
id={id}
305
title={
306
<>
307
<Icon name="server" /> Connect Your Virtual Machine to CoCalc
308
</>
309
}
310
/>
311
)}
312
{showOnPremStop && action == "stop" && (
313
<OnPremGuide
314
action={action}
315
setShow={setShowOnPremStop}
316
configuration={configuration}
317
id={id}
318
title={
319
<>
320
<Icon name="stop" /> Disconnect Your Virtual Machine from CoCalc
321
</>
322
}
323
/>
324
)}
325
{showOnPremDeprovision && action == "deprovision" && (
326
<OnPremGuide
327
action={action}
328
setShow={setShowOnPremDeprovision}
329
configuration={configuration}
330
id={id}
331
title={
332
<div style={{ color: "darkred" }}>
333
<Icon name="trash" /> Disconnect Your Virtual Machine and Remove
334
Files
335
</div>
336
}
337
/>
338
)}
339
</>
340
);
341
342
// Do NOT use popover in case we're doing a popconfirm.
343
// Two popovers at once is just unprofessional and hard to use.
344
// That's why the "open={popConfirm ? false : undefined}" below
345
346
return (
347
<Popover
348
open={popConfirm ? false : undefined}
349
placement="left"
350
key={action}
351
mouseEnterDelay={1}
352
title={
353
<div>
354
<Icon name={icon} /> {tip}
355
</div>
356
}
357
content={
358
<div style={{ width: "400px" }}>
359
{description} {editable && <>You will be charged:</>}
360
{!editable && <>The owner of this compute server will be charged:</>}
361
{cost_per_hour != null && (
362
<div style={{ textAlign: "center" }}>
363
<MoneyStatistic
364
value={cost_per_hour}
365
title="Cost per hour"
366
costPerMonth={730 * cost_per_hour}
367
/>
368
</div>
369
)}
370
</div>
371
}
372
>
373
{content}
374
</Popover>
375
);
376
}
377
378
function OnPremGuide({ setShow, configuration, id, title, action }) {
379
const [apiKey, setApiKey] = useState<string | null>(null);
380
const [error, setError] = useState<string>("");
381
useEffect(() => {
382
(async () => {
383
try {
384
setError("");
385
setApiKey(await getApiKey({ id }));
386
} catch (err) {
387
setError(`${err}`);
388
}
389
})();
390
}, []);
391
return (
392
<Modal
393
width={800}
394
title={title}
395
open={true}
396
onCancel={() => {
397
setShow(false);
398
}}
399
onOk={() => {
400
setShow(false);
401
}}
402
>
403
{action == "start" && (
404
<div>
405
You can connect any{" "}
406
<b>Ubuntu 22.04 or 24.04 Linux Virtual Machine (VM)</b> with root
407
access to this project. This VM can be anywhere (your laptop or a
408
cloud hosting providing). Your VM needs to be able to create outgoing
409
network connections, but does NOT need to have a public ip address.
410
<Alert
411
style={{ margin: "15px 0" }}
412
type="warning"
413
showIcon
414
message={<b>USE AN UBUNTU 22.04 or 24.04 VIRTUAL MACHINE</b>}
415
description={
416
<div>
417
You can use any{" "}
418
<u>
419
<b>
420
<A href="https://multipass.run/">UBUNTU VIRTUAL MACHINE</A>
421
</b>
422
</u>{" "}
423
that you have a root acount on.{" "}
424
<A href="https://multipass.run/">
425
Multipass is a very easy and free way to install one or more
426
minimal Ubuntu VM's on Windows, Mac, and Linux.
427
</A>{" "}
428
After you install Multipass, create a VM by pasting this in a
429
terminal on your computer (you can increase the cpu, memory and
430
disk):
431
<CopyToClipBoard
432
inputWidth="600px"
433
style={{ marginTop: "10px" }}
434
value={`multipass launch --name compute-server-${id} --cpus 1 --memory 4G --disk 25G`}
435
/>
436
<br />
437
Then launch a terminal shell running in the VM:
438
<CopyToClipBoard
439
inputWidth="600px"
440
style={{ marginTop: "10px" }}
441
value={`multipass shell compute-server-${id}`}
442
/>
443
</div>
444
}
445
/>
446
{configuration.gpu && (
447
<span>
448
Since you clicked GPU, you must also have an NVIDIA GPU and the
449
Cuda drivers installed and working.{" "}
450
</span>
451
)}
452
</div>
453
)}
454
<div style={{ marginTop: "15px" }}>
455
{apiKey && (
456
<div>
457
<div style={{ marginBottom: "10px" }}>
458
Copy and paste the following into a terminal shell on your{" "}
459
<b>Ubuntu Virtual Machine</b>:
460
</div>
461
<CopyToClipBoard
462
inputWidth={"700px"}
463
value={`curl -fsS https://${window.location.host}${
464
appBasePath.length > 1 ? appBasePath : ""
465
}/compute/${id}/onprem/${action}/${apiKey} | sudo bash`}
466
/>
467
</div>
468
)}
469
{!apiKey && !error && <Spin />}
470
<ShowError error={error} setError={setError} />
471
</div>
472
{action == "stop" && (
473
<div>
474
This will disconnect your VM from CoCalc and stop it from syncing
475
files, running terminals and Jupyter notebooks. Files and software you
476
installed will not be deleted and you can start the compute server
477
later.
478
<Alert
479
style={{ margin: "15px 0" }}
480
type="warning"
481
showIcon
482
message={
483
<b>
484
If you're using{" "}
485
<A href="https://multipass.run/">Multipass...</A>
486
</b>
487
}
488
description={
489
<div>
490
<CopyToClipBoard
491
value={`multipass stop compute-server-${id}`}
492
/>
493
<br />
494
HINT: If you ever need to enlarge the disk, do this:
495
<CopyToClipBoard
496
inputWidth="600px"
497
value={`multipass stop compute-server-${id} && multipass set local.compute-server-${id}.disk=30G`}
498
/>
499
</div>
500
}
501
/>
502
</div>
503
)}
504
{action == "deprovision" && (
505
<div>
506
This will disconnect your VM from CoCalc, and permanently delete any
507
local files and software you installed into your compute server.
508
<Alert
509
style={{ margin: "15px 0" }}
510
type="warning"
511
showIcon
512
message={
513
<b>
514
If you're using{" "}
515
<A href="https://multipass.run/">Multipass...</A>
516
</b>
517
}
518
description={
519
<CopyToClipBoard
520
value={`multipass delete compute-server-${id}`}
521
/>
522
}
523
/>
524
</div>
525
)}
526
{action == "deprovision" && (
527
<div style={{ marginTop: "15px" }}>
528
NOTE: This does not delete Docker or any Docker images. Run this to
529
delete all unused Docker images:
530
<br />
531
<CopyToClipBoard value="docker image prune -a" />
532
</div>
533
)}
534
</Modal>
535
);
536
}
537
538