Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
sagemathinc
GitHub Repository: sagemathinc/cocalc
Path: blob/master/src/packages/frontend/course/compute/students.tsx
1503 views
1
import {
2
ACTION_INFO,
3
STATE_INFO,
4
} from "@cocalc/util/db-schema/compute-servers";
5
import {
6
Alert,
7
Button,
8
Checkbox,
9
Popconfirm,
10
Space,
11
Spin,
12
Tooltip,
13
} from "antd";
14
import { useEffect, useMemo, useRef, useState } from "react";
15
import type { CourseActions } from "../actions";
16
import { redux, useRedux, useTypedRedux } from "@cocalc/frontend/app-framework";
17
import { Icon } from "@cocalc/frontend/components/icon";
18
import { capitalize, get_array_range, plural } from "@cocalc/util/misc";
19
import ShowError from "@cocalc/frontend/components/error";
20
import type { Unit } from "../store";
21
import { getServersById } from "@cocalc/frontend/compute/api";
22
import { BigSpin } from "@cocalc/frontend/purchases/stripe-payment";
23
import { MAX_PARALLEL_TASKS } from "./util";
24
import { TerminalButton, TerminalCommand } from "./terminal-command";
25
import ComputeServer from "@cocalc/frontend/compute/inline";
26
import CurrentCost from "@cocalc/frontend/compute/current-cost";
27
import type { StudentsMap } from "../store";
28
import { map as awaitMap } from "awaiting";
29
import type { SyncTable } from "@cocalc/sync/table";
30
import { getSyncTable } from "./synctable";
31
import { parse_students, pick_student_sorter } from "../util";
32
import { RunningProgress } from "@cocalc/frontend/compute/doc-status";
33
import {
34
SpendLimitButton,
35
SpendLimitStatus,
36
} from "@cocalc/frontend/compute/spend-limit";
37
import { webapp_client } from "@cocalc/frontend/webapp-client";
38
39
declare var DEBUG: boolean;
40
41
interface Props {
42
actions: CourseActions;
43
unit: Unit;
44
onClose?: () => void;
45
}
46
47
export type ServersMap = {
48
[id: number]: {
49
id?: number;
50
state?;
51
deleted?: boolean;
52
};
53
};
54
55
export type SelectedStudents = Set<string>;
56
57
export default function Students({ actions, unit, onClose }: Props) {
58
const [servers, setServers] = useState<ServersMap | null>(null);
59
const students: StudentsMap = useRedux(actions.name, "students");
60
const [error, setError] = useState<string>("");
61
const [selected, setSelected] = useState<SelectedStudents>(new Set());
62
const [terminal, setTerminal] = useState<boolean>(false);
63
const [mostRecentSelected, setMostRecentSelected] = useState<string | null>(
64
null,
65
);
66
const course_server_id = unit.getIn(["compute_server", "server_id"]);
67
const [courseServer, setCourseServer] = useState<any>(null);
68
useEffect(() => {
69
if (!course_server_id) {
70
setCourseServer(null);
71
}
72
(async () => {
73
const v = await getServersById({
74
ids: [course_server_id!],
75
fields: ["configuration"],
76
});
77
setCourseServer(v[0] ?? null);
78
})();
79
}, [course_server_id]);
80
81
const studentServersRef = useRef<null | SyncTable>(null);
82
useEffect(() => {
83
const course_project_id = actions.get_store().get("course_project_id");
84
if (!course_server_id || !course_project_id) {
85
studentServersRef.current = null;
86
return;
87
}
88
(async () => {
89
studentServersRef.current = await getSyncTable({
90
course_server_id,
91
course_project_id,
92
fields: [
93
"id",
94
"state",
95
"deleted",
96
"cost_per_hour",
97
"detailed_state",
98
"account_id",
99
"project_id",
100
"project_specific_id",
101
"configuration",
102
"spend",
103
],
104
});
105
studentServersRef.current.on("change", () => {
106
setServers(studentServersRef.current?.get()?.toJS() ?? null);
107
});
108
})();
109
110
return () => {
111
const table = studentServersRef.current;
112
if (table != null) {
113
studentServersRef.current = null;
114
table.close();
115
}
116
};
117
}, [course_server_id]);
118
119
const active_student_sort = useRedux(actions.name, "active_student_sort");
120
const user_map = useTypedRedux("users", "user_map");
121
const studentIds = useMemo(() => {
122
const v0 = parse_students(students, user_map, redux);
123
// Remove deleted students
124
const v1: any[] = [];
125
for (const x of v0) {
126
if (!x.deleted) {
127
v1.push(x);
128
}
129
}
130
v1.sort(pick_student_sorter(active_student_sort.toJS()));
131
return v1.map((x) => x.student_id) as string[];
132
}, [students, user_map, active_student_sort]);
133
134
if (servers == null) {
135
if (error) {
136
return <ShowError error={error} setError={setError} />;
137
}
138
return <BigSpin />;
139
}
140
141
let extra: React.JSX.Element | null = null;
142
143
if (!!course_server_id && courseServer?.configuration?.cloud == "onprem") {
144
extra = (
145
<Alert
146
style={{ margin: "15px 0" }}
147
type="warning"
148
showIcon
149
message={"Self Hosted Compute Server"}
150
description={
151
<>
152
Self hosted compute servers are currently not supported for courses.
153
The compute server <ComputeServer id={course_server_id} /> is self
154
hosted. Please select a non-self-hosted compute server instead.
155
{DEBUG ? (
156
<b> You are in DEBUG mode, so we still allow this.</b>
157
) : (
158
""
159
)}
160
</>
161
}
162
/>
163
);
164
if (!DEBUG) {
165
return extra;
166
}
167
}
168
169
const v: React.JSX.Element[] = [];
170
v.push(
171
<div
172
key="all"
173
style={{
174
minHeight: "32px" /* this avoids a flicker */,
175
borderBottom: "1px solid #ccc",
176
paddingBottom: "15px",
177
}}
178
>
179
<Space>
180
<div
181
key="check-all"
182
style={{
183
fontSize: "14pt",
184
cursor: "pointer",
185
}}
186
onClick={() => {
187
if (selected.size == 0) {
188
setSelected(new Set(studentIds));
189
} else {
190
setSelected(new Set());
191
}
192
}}
193
>
194
<Button>
195
<Icon
196
name={
197
selected.size == 0
198
? "square"
199
: selected.size == studentIds.length
200
? "check-square"
201
: "minus-square"
202
}
203
/>
204
{selected.size == 0 ? "Check All" : "Uncheck All"}
205
</Button>
206
</div>
207
{selected.size > 0 && servers != null && (
208
<CommandsOnSelected
209
key="commands-on-selected"
210
{...{
211
selected,
212
servers,
213
actions,
214
unit,
215
setError,
216
terminal,
217
setTerminal,
218
}}
219
/>
220
)}
221
</Space>
222
{terminal && (
223
<TerminalCommand
224
onClose={() => setTerminal(false)}
225
style={{ marginTop: "15px" }}
226
{...{ servers, selected, students, unit, actions }}
227
/>
228
)}
229
</div>,
230
);
231
let i = 0;
232
for (const student_id of studentIds) {
233
v.push(
234
<StudentControl
235
key={student_id}
236
onClose={onClose}
237
student={students.get(student_id)}
238
actions={actions}
239
unit={unit}
240
servers={servers}
241
style={i % 2 ? { background: "#f2f6fc" } : undefined}
242
selected={selected.has(student_id)}
243
setSelected={(checked, shift) => {
244
if (!shift || !mostRecentSelected) {
245
if (checked) {
246
selected.add(student_id);
247
} else {
248
selected.delete(student_id);
249
}
250
} else {
251
// set the range of id's between this message and the most recent one
252
// to be checked. See also similar code in messages and our explorer,
253
// e.g., frontend/messages/main.tsx
254
const v = get_array_range(
255
studentIds,
256
mostRecentSelected,
257
student_id,
258
);
259
if (checked) {
260
for (const student_id of v) {
261
selected.add(student_id);
262
}
263
} else {
264
for (const student_id of v) {
265
selected.delete(student_id);
266
}
267
}
268
}
269
setSelected(new Set(selected));
270
setMostRecentSelected(student_id);
271
}}
272
/>,
273
);
274
i += 1;
275
}
276
277
return (
278
<Space direction="vertical" style={{ width: "100%" }}>
279
<ShowError style={{ margin: "15px" }} error={error} setError={setError} />
280
{extra}
281
{v}
282
</Space>
283
);
284
}
285
286
const COMMANDS = [
287
"create",
288
"start",
289
"stop",
290
"reboot",
291
"deprovision",
292
"delete",
293
"transfer",
294
] as const;
295
296
export type Command = (typeof COMMANDS)[number];
297
298
const REQUIRES_CONFIRM = new Set([
299
"stop",
300
"deprovision",
301
"reboot",
302
"delete",
303
"transfer",
304
]);
305
306
const VALID_COMMANDS: { [state: string]: Command[] } = {
307
off: ["start", "deprovision", "delete"],
308
starting: [],
309
running: ["stop", "reboot", "deprovision"],
310
stopping: [],
311
deprovisioned: ["start", "transfer", "delete"],
312
suspending: [],
313
suspended: ["start", "deprovision"],
314
};
315
316
const NONOWNER_COMMANDS = new Set(["start", "stop", "reboot"]);
317
318
function StudentControl({
319
onClose,
320
student,
321
actions,
322
unit,
323
servers,
324
style,
325
selected,
326
setSelected,
327
}) {
328
const [loading, setLoading] = useState<null | Command>(null);
329
const [error, setError] = useState<string>("");
330
const student_id = student.get("student_id");
331
const server_id = getServerId({ unit, student_id });
332
const server = servers?.[server_id];
333
const name = actions.get_store().get_student_name(student.get("student_id"));
334
335
const v: React.JSX.Element[] = [];
336
337
v.push(
338
<Checkbox
339
key="checkbox"
340
style={{ width: "30px" }}
341
checked={selected}
342
onChange={(e) => {
343
const shiftKey = e.nativeEvent.shiftKey;
344
setSelected(e.target.checked, shiftKey);
345
}}
346
/>,
347
);
348
349
v.push(
350
<a
351
key="name"
352
onClick={() => {
353
const project_id = student.get("project_id");
354
if (project_id) {
355
redux.getActions("projects").open_project({
356
project_id,
357
});
358
redux.getProjectActions(project_id).showComputeServers();
359
onClose?.();
360
}
361
}}
362
>
363
<div
364
style={{
365
width: "150px",
366
whiteSpace: "nowrap",
367
overflow: "hidden",
368
textOverflow: "ellipsis",
369
}}
370
>
371
{name}
372
</div>
373
{student.get("account_id") == server?.account_id ? (
374
<div style={{ marginRight: "15px" }}>
375
<b>Student Owned Server</b>
376
</div>
377
) : undefined}
378
</a>,
379
);
380
if (server?.project_specific_id) {
381
v.push(
382
<div key="id" style={{ width: "50px" }}>
383
<Tooltip
384
title={`Compute server has id ${server.project_specific_id} in the student's project, and global id ${server.id}.`}
385
>
386
Id: {server.project_specific_id}
387
</Tooltip>
388
</div>,
389
);
390
}
391
if (server?.state) {
392
v.push(
393
<div key="state" style={{ width: "125px" }}>
394
<Icon name={STATE_INFO[server.state].icon as any} />{" "}
395
{capitalize(server.state)}
396
</div>,
397
);
398
if (server.state == "running") {
399
v.push(
400
<div
401
key="running-progress"
402
style={{ width: "100px", paddingTop: "5px" }}
403
>
404
<RunningProgress server={server} />
405
</div>,
406
);
407
}
408
} else {
409
v.push(
410
<div key="state" style={{ width: "125px" }}>
411
-
412
</div>,
413
);
414
}
415
if (server?.cost_per_hour) {
416
v.push(
417
<div key="cost" style={{ width: "75px" }}>
418
<CurrentCost
419
state={server.state}
420
cost_per_hour={server.cost_per_hour}
421
/>
422
</div>,
423
);
424
}
425
if (server?.id) {
426
v.push(
427
<div key="cost" style={{ width: "75px" }}>
428
<SpendLimitStatus server={server} />
429
</div>,
430
);
431
}
432
433
const getButton = ({ command, disabled }) => {
434
return (
435
<CommandButton
436
key={command}
437
{...{
438
command,
439
disabled,
440
loading,
441
setLoading,
442
actions,
443
unit,
444
student_id,
445
setError,
446
servers,
447
}}
448
/>
449
);
450
};
451
452
for (const command of getCommands(server)) {
453
let disabled = loading == command;
454
if (!disabled) {
455
// disable some buttons depending on state info...
456
if (server_id) {
457
if (command == "create") {
458
disabled = true;
459
} else {
460
}
461
} else {
462
if (command != "create") {
463
disabled = true;
464
}
465
}
466
}
467
v.push(getButton({ command, disabled }));
468
}
469
470
return (
471
<div
472
style={{
473
borderRadius: "5px",
474
padding: "5px 15px",
475
...style,
476
}}
477
>
478
<Space wrap style={{ width: "100%" }}>
479
{v}
480
</Space>
481
<ShowError style={{ margin: "15px" }} error={error} setError={setError} />
482
</div>
483
);
484
}
485
486
function CommandButton({
487
command,
488
disabled,
489
loading,
490
setLoading,
491
actions,
492
unit,
493
student_id,
494
setError,
495
servers,
496
}) {
497
const confirm = REQUIRES_CONFIRM.has(command);
498
const studentIds =
499
typeof student_id == "string" ? [student_id] : Array.from(student_id);
500
const doIt = async () => {
501
try {
502
setLoading(command);
503
const task = async (student_id) => {
504
const server_id = getServerId({ unit, student_id });
505
if (!getCommands(servers[server_id]).includes(command)) {
506
return;
507
}
508
await actions.compute.computeServerCommand({
509
command,
510
unit,
511
student_id,
512
});
513
};
514
await awaitMap(studentIds, MAX_PARALLEL_TASKS, task);
515
} catch (err) {
516
setError(`${err}`);
517
} finally {
518
setLoading(null);
519
}
520
};
521
const icon = getIcon(command);
522
const btn = (
523
<Button
524
disabled={disabled}
525
onClick={confirm ? undefined : doIt}
526
key={command}
527
>
528
{icon != null ? <Icon name={icon as any} /> : undefined}{" "}
529
{capitalize(command)}
530
{loading == command && <Spin style={{ marginLeft: "15px" }} />}
531
</Button>
532
);
533
if (confirm) {
534
return (
535
<Popconfirm
536
key={command}
537
onConfirm={doIt}
538
title={`${capitalize(command)} ${studentIds.length == 1 ? "this compute server" : "these compute servers"}?`}
539
>
540
{btn}
541
</Popconfirm>
542
);
543
} else {
544
return btn;
545
}
546
}
547
548
function getCommands(server): Command[] {
549
const v: Command[] = [];
550
for (const command of COMMANDS) {
551
if (command == "create") {
552
if (server != null) {
553
// already created
554
continue;
555
}
556
} else {
557
if (server == null) {
558
// doesn't exist, so no need for other buttons
559
continue;
560
}
561
}
562
if (server?.state != null) {
563
if (!VALID_COMMANDS[server.state]?.includes(command)) {
564
continue;
565
}
566
}
567
if (server != null && server?.account_id != webapp_client.account_id) {
568
// not the owner
569
if (!NONOWNER_COMMANDS.has(command)) {
570
continue;
571
}
572
}
573
574
v.push(command);
575
}
576
return v;
577
}
578
579
function getIcon(command: Command) {
580
if (command == "delete") {
581
return "trash";
582
} else if (command == "transfer") {
583
return "user-check";
584
} else if (command == "create") {
585
return "plus-circle";
586
} else {
587
return ACTION_INFO[command]?.icon;
588
}
589
}
590
591
export function getServerId({ unit, student_id }) {
592
return unit.getIn(["compute_server", "students", student_id, "server_id"]);
593
}
594
595
function CommandsOnSelected({
596
selected,
597
servers,
598
actions,
599
unit,
600
setError,
601
terminal,
602
setTerminal,
603
}) {
604
const [loading, setLoading] = useState<null | Command>(null);
605
606
if (selected.size == 0) {
607
return null;
608
}
609
610
const X = new Set<string>();
611
for (const student_id of selected) {
612
const server_id = getServerId({ unit, student_id });
613
for (const command of getCommands(servers[server_id])) {
614
X.add(command);
615
}
616
}
617
618
const v: React.JSX.Element[] = [];
619
for (const command of X) {
620
v.push(
621
<CommandButton
622
key={command}
623
{...{
624
command,
625
disabled: loading,
626
loading,
627
setLoading,
628
actions,
629
unit,
630
student_id: selected,
631
setError,
632
servers,
633
}}
634
/>,
635
);
636
}
637
if (X.has("stop")) {
638
v.push(
639
<TerminalButton
640
key="terminal"
641
terminal={terminal}
642
setTerminal={setTerminal}
643
/>,
644
);
645
} else if (terminal) {
646
setTimeout(() => {
647
setTerminal(false);
648
}, 0);
649
}
650
v.push(
651
<MultiSpendLimitButton selected={selected} servers={servers} unit={unit} />,
652
);
653
654
v.push(
655
<div key="what">
656
{selected.size} selected {plural(selected.size, "server")}
657
</div>,
658
);
659
660
return (
661
<>
662
<Space wrap>{v}</Space>
663
</>
664
);
665
}
666
667
function MultiSpendLimitButton({ selected, servers, unit }) {
668
const extra = useMemo(() => {
669
const extra: { project_id: string; id: number }[] = [];
670
for (const student_id of selected) {
671
const id = getServerId({ unit, student_id });
672
if (servers?.[id] != null) {
673
const { project_id } = servers[id];
674
extra.push({ id, project_id });
675
}
676
}
677
return extra;
678
}, [selected]);
679
if (extra.length == 0) {
680
return null;
681
}
682
return (
683
<SpendLimitButton
684
id={extra[0].id}
685
project_id={extra[0].project_id}
686
extra={extra.slice(1)}
687
/>
688
);
689
}
690
691