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