Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
sagemathinc
GitHub Repository: sagemathinc/cocalc
Path: blob/master/src/packages/frontend/course/configuration/terminal-command.tsx
1503 views
1
/*
2
* This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.
3
* License: MS-RSL – see LICENSE.md for details
4
*/
5
6
import {
7
List as AntdList,
8
Button,
9
Card,
10
Form,
11
Input,
12
InputNumber,
13
Space,
14
} from "antd";
15
import { List, Map, fromJS } from "immutable";
16
import { useState } from "react";
17
import { FormattedMessage, useIntl } from "react-intl";
18
19
import {
20
CSS,
21
redux,
22
useActions,
23
useRedux,
24
} from "@cocalc/frontend/app-framework";
25
26
import { Gap, Icon, Paragraph } from "@cocalc/frontend/components";
27
import { course, labels } from "@cocalc/frontend/i18n";
28
import { COLORS } from "@cocalc/util/theme";
29
import { CourseActions } from "../actions";
30
import { CourseStore, TerminalCommand, TerminalCommandOutput } from "../store";
31
import { MAX_PARALLEL_TASKS } from "../student-projects/actions";
32
import { Result } from "../student-projects/run-in-all-projects";
33
34
interface Props {
35
name: string;
36
}
37
38
export function TerminalCommandPanel({ name }: Props) {
39
const intl = useIntl();
40
const actions = useActions<CourseActions>({ name });
41
const terminal_command: TerminalCommand | undefined = useRedux(
42
name,
43
"terminal_command",
44
);
45
const [timeout, setTimeout] = useState<number | null>(1);
46
47
function render_button(running: boolean) {
48
return (
49
<Button
50
style={{ width: "6em" }}
51
onClick={() => run_terminal_command()}
52
disabled={running}
53
>
54
<Icon name={running ? "cocalc-ring" : "play"} spin={running} /> <Gap />{" "}
55
Run
56
</Button>
57
);
58
}
59
60
function render_input() {
61
const c = terminal_command;
62
let running = false;
63
if (c != null) {
64
running = c.get("running", false);
65
}
66
return (
67
<Form
68
style={{ marginBottom: "10px" }}
69
onFinish={() => {
70
run_terminal_command();
71
}}
72
>
73
<Space.Compact
74
style={{
75
display: "flex",
76
whiteSpace: "nowrap",
77
marginBottom: "5px",
78
}}
79
>
80
<Input
81
allowClear
82
style={{ fontFamily: "monospace" }}
83
placeholder={`${intl.formatMessage(labels.terminal_command)}...`}
84
onChange={(e) => {
85
set_field("input", e.target.value);
86
}}
87
onPressEnter={() => run_terminal_command()}
88
/>
89
{render_button(running)}
90
</Space.Compact>
91
<InputNumber
92
value={timeout}
93
onChange={(t) => setTimeout(t ?? null)}
94
min={0}
95
max={30}
96
addonAfter={"minute timeout"}
97
/>
98
</Form>
99
);
100
}
101
102
function render_running() {
103
const c = terminal_command;
104
if (c != null && c.get("running")) {
105
return (
106
<div
107
style={{
108
color: "#888",
109
padding: "5px",
110
fontSize: "16px",
111
fontWeight: "bold",
112
}}
113
>
114
<Icon name={"cocalc-ring"} spin /> Running...
115
</div>
116
);
117
}
118
}
119
120
function render_output() {
121
const c = terminal_command;
122
if (c == null) return;
123
const output = c.get("output");
124
if (!output) return;
125
return (
126
<AntdList
127
size="small"
128
style={{ maxHeight: "400px", overflowY: "auto" }}
129
bordered
130
dataSource={output.toArray()}
131
renderItem={(item) => (
132
<AntdList.Item style={{ padding: "5px" }}>
133
<Output result={item} />
134
</AntdList.Item>
135
)}
136
/>
137
);
138
}
139
140
function get_store(): CourseStore {
141
return actions.get_store();
142
}
143
144
function set_field(field: "input" | "running" | "output", value: any): void {
145
const store: CourseStore = get_store();
146
let terminal_command: TerminalCommand = store.get(
147
"terminal_command",
148
Map() as TerminalCommand,
149
);
150
if (value == null) {
151
terminal_command = terminal_command.delete(field);
152
} else {
153
terminal_command = terminal_command.set(field, value);
154
}
155
actions.setState({ terminal_command });
156
}
157
158
function run_log(result: Result): void {
159
// Important to get from store, not from props, since on second
160
// run old output isn't pushed down to props by the time this
161
// gets called.
162
const store = redux.getStore(name);
163
if (!store) {
164
return;
165
}
166
const c = (store as any).get("terminal_command");
167
let output;
168
if (c == null) {
169
output = List();
170
} else {
171
output = c.get("output", List());
172
}
173
set_field("output", output.push(fromJS(result)));
174
}
175
176
async function run_terminal_command(): Promise<void> {
177
const c = terminal_command;
178
if (c == null) return;
179
const input = c.get("input");
180
set_field("output", undefined);
181
if (!input) return;
182
try {
183
set_field("running", true);
184
await actions.student_projects.run_in_all_student_projects({
185
command: input,
186
timeout: (timeout ? timeout : 1) * 60,
187
log: run_log,
188
});
189
} finally {
190
set_field("running", false);
191
}
192
}
193
194
function render_terminal() {
195
return (
196
<div>
197
{render_input()}
198
{render_output()}
199
{render_running()}
200
</div>
201
);
202
}
203
204
function render_header() {
205
return (
206
<>
207
<Icon name="terminal" />{" "}
208
{intl.formatMessage(course.run_terminal_command_title)}
209
</>
210
);
211
}
212
213
return (
214
<Card title={render_header()}>
215
{render_terminal()}
216
<hr />
217
<Paragraph type="secondary">
218
<FormattedMessage
219
id="course.terminal-command.info"
220
defaultMessage={`Run a BASH terminal command in the home directory of all student projects.
221
Up to {MAX_PARALLEL_TASKS} commands run in parallel,
222
with a timeout of {timeout} minutes.`}
223
values={{ MAX_PARALLEL_TASKS, timeout }}
224
/>
225
</Paragraph>
226
</Card>
227
);
228
}
229
230
const PROJECT_LINK_STYLE: CSS = {
231
maxWidth: "80%",
232
overflow: "hidden",
233
textOverflow: "ellipsis",
234
cursor: "pointer",
235
display: "block",
236
whiteSpace: "nowrap",
237
} as const;
238
239
const CODE_STYLE: CSS = {
240
maxHeight: "200px",
241
overflow: "auto",
242
fontSize: "90%",
243
padding: "2px",
244
} as const;
245
246
const ERR_STYLE: CSS = {
247
...CODE_STYLE,
248
color: "white",
249
background: COLORS.ANTD_RED,
250
} as const;
251
252
function Output({ result }: { result: TerminalCommandOutput }) {
253
function open_project(): void {
254
const project_id = result.get("project_id");
255
redux.getActions("projects").open_project({ project_id });
256
}
257
258
const project_id: string = result.get("project_id");
259
const title: string = redux.getStore("projects").get_title(project_id);
260
261
const stdout = result.get("stdout");
262
const stderr = result.get("stderr");
263
const timeout = result.get("timeout");
264
const total_time = result.get("total_time");
265
266
return (
267
<RenderOutput
268
title={
269
<a style={PROJECT_LINK_STYLE} onClick={open_project}>
270
{title}
271
</a>
272
}
273
stdout={stdout}
274
stderr={stderr}
275
timeout={timeout}
276
total_time={total_time}
277
/>
278
);
279
}
280
281
export function RenderOutput({ title, stdout, stderr, total_time, timeout }) {
282
const noresult = !stdout && !stderr;
283
return (
284
<div style={{ padding: 0, width: "100%", marginTop: "15px" }}>
285
<b>{title}</b>
286
{stdout && <pre style={CODE_STYLE}>{stdout.trim()}</pre>}
287
{stderr && <pre style={ERR_STYLE}>{stderr.trim()}</pre>}
288
{noresult && (
289
<div>
290
No output{" "}
291
{total_time != null && timeout != null && total_time >= timeout - 5
292
? "(possible timeout)"
293
: ""}
294
</div>
295
)}
296
{total_time != null && <>(Time: {total_time} seconds)</>}
297
</div>
298
);
299
}
300
301