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