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