Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place. Commercial Alternative to JupyterHub.
Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place. Commercial Alternative to JupyterHub.
Path: blob/master/src/packages/frontend/client/project.ts
Views: 923
/*1* This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.2* License: MS-RSL – see LICENSE.md for details3*/45/*6Functionality that mainly involves working with a specific project.7*/89import { join } from "path";1011import { redux } from "@cocalc/frontend/app-framework";12import computeServers from "@cocalc/frontend/compute/manager";13import { appBasePath } from "@cocalc/frontend/customize/app-base-path";14import { dialogs } from "@cocalc/frontend/i18n";15import { getIntl } from "@cocalc/frontend/i18n/get-intl";16import { ipywidgetsGetBufferUrl } from "@cocalc/frontend/jupyter/server-urls";17import { allow_project_to_run } from "@cocalc/frontend/project/client-side-throttle";18import { ensure_project_running } from "@cocalc/frontend/project/project-start-warning";19import { API } from "@cocalc/frontend/project/websocket/api";20import { connection_to_project } from "@cocalc/frontend/project/websocket/connect";21import {22ProjectInfo,23project_info,24} from "@cocalc/frontend/project/websocket/project-info";25import {26ProjectStatus,27project_status,28} from "@cocalc/frontend/project/websocket/project-status";29import {30UsageInfoWS,31get_usage_info,32} from "@cocalc/frontend/project/websocket/usage-info";33import {34Configuration,35ConfigurationAspect,36} from "@cocalc/frontend/project_configuration";37import { HOME_ROOT } from "@cocalc/util/consts/files";38import type { ApiKey } from "@cocalc/util/db-schema/api-keys";39import {40isExecOptsBlocking,41type ExecOpts,42type ExecOutput,43} from "@cocalc/util/db-schema/projects";44import * as message from "@cocalc/util/message";45import {46coerce_codomain_to_numbers,47copy_without,48defaults,49encode_path,50is_valid_uuid_string,51required,52} from "@cocalc/util/misc";53import { reuseInFlight } from "@cocalc/util/reuse-in-flight";54import { DirectoryListingEntry } from "@cocalc/util/types";55import httpApi from "./api";56import { WebappClient } from "./client";57import { throttle } from "lodash";5859export class ProjectClient {60private client: WebappClient;61private touch_throttle: { [project_id: string]: number } = {};6263constructor(client: WebappClient) {64this.client = client;65}6667private async call(message: object): Promise<any> {68return await this.client.async_call({ message });69}7071public async write_text_file(opts: {72project_id: string;73path: string;74content: string;75}): Promise<void> {76return await this.call(message.write_text_file_to_project(opts));77}7879public async read_text_file(opts: {80project_id: string; // string or array of strings81path: string; // string or array of strings82}): Promise<string> {83return (await this.call(message.read_text_file_from_project(opts))).content;84}8586// Like "read_text_file" above, except the callback87// message gives a url from which the file can be88// downloaded using standard AJAX.89public read_file(opts: {90project_id: string; // string or array of strings91path: string; // string or array of strings92}): string {93const base_path = appBasePath;94if (opts.path[0] === "/") {95// absolute path to the root96opts.path = HOME_ROOT + opts.path; // use root symlink, which is created by start_smc97}98return encode_path(join(base_path, `${opts.project_id}/raw/${opts.path}`));99}100101public async copy_path_between_projects(opts: {102public?: boolean; // used e.g., by share server landing page action.103src_project_id: string; // id of source project104src_path: string; // relative path of director or file in the source project105target_project_id: string; // if of target project106target_path?: string; // defaults to src_path107overwrite_newer?: boolean; // overwrite newer versions of file at destination (destructive)108delete_missing?: boolean; // delete files in dest that are missing from source (destructive)109backup?: boolean; // make ~ backup files instead of overwriting changed files110timeout?: number; // **timeout in seconds** -- how long to wait for the copy to complete before reporting "error" (though it could still succeed)111exclude?: string[]; // list of patterns to exclude; this uses exactly the (confusing) rsync patterns112}): Promise<void> {113const is_public = opts.public;114delete opts.public;115116if (opts.target_path == null) {117opts.target_path = opts.src_path;118}119120const mesg = is_public121? message.copy_public_path_between_projects(opts)122: message.copy_path_between_projects(opts);123mesg.wait_until_done = true; // TODO: our UI only supports this for now.124125// THIS CAN BE USEFUL FOR DEBUGGING!126// mesg.debug_delay_s = 10;127128await this.client.async_call({129timeout: opts.timeout,130message: mesg,131allow_post: false, // since it may take too long132});133}134135// Set a quota parameter for a given project.136// As of now, only user in the admin group can make these changes.137public async set_quotas(opts: {138project_id: string;139memory?: number; // see message.js for the units, etc., for all these settings140memory_request?: number;141cpu_shares?: number;142cores?: number;143disk_quota?: number;144mintime?: number;145network?: number;146member_host?: number;147always_running?: number;148}): Promise<void> {149// we do some extra work to ensure all the quotas are numbers (typescript isn't150// enough; sometimes client code provides strings, which can cause lots of trouble).151const x = coerce_codomain_to_numbers(copy_without(opts, ["project_id"]));152await this.call(153message.project_set_quotas({ ...x, ...{ project_id: opts.project_id } }),154);155}156157public async websocket(project_id: string): Promise<any> {158const store = redux.getStore("projects");159// Wait until project is running (or admin and not on project)160await store.async_wait({161until: () => {162const state = store.get_state(project_id);163if (state == null && redux.getStore("account")?.get("is_admin")) {164// is admin so doesn't know project state -- just immediately165// try, which will cause project to run166return true;167}168return state == "running";169},170});171172// get_my_group returns undefined when the various info to173// determine this isn't yet loaded. For some connections174// this websocket function gets called before that info is175// loaded, which can cause trouble.176let group: string | undefined;177await store.async_wait({178until: () => (group = store.get_my_group(project_id)) != null,179});180if (group == "public") {181throw Error("no access to project websocket");182}183return await connection_to_project(project_id);184}185186public async api(project_id: string): Promise<API> {187return (await this.websocket(project_id)).api;188}189190/*191Execute code in a given project or associated compute server.192193Aggregate option -- use like this:194195webapp.exec196aggregate: timestamp (or something else sequential)197198means: if there are multiple attempts to run the given command with the same199time, they are all aggregated and run only one time by the project. If requests200comes in with a newer time, they all run in another group after the first201one finishes. The timestamp will usually come from something like the "last save202time" (which is stored in the db), which they client will know. This is used, e.g.,203for operations like "run rst2html on this file whenever it is saved."204*/205public async exec(opts: ExecOpts & { post?: boolean }): Promise<ExecOutput> {206if ("async_get" in opts) {207opts = defaults(opts, {208project_id: required,209compute_server_id: undefined,210async_get: required,211async_stats: undefined,212async_await: undefined,213post: false, // if true, uses the POST api through nextjs instead of the websocket api.214timeout: 30,215cb: undefined,216});217} else {218opts = defaults(opts, {219project_id: required,220compute_server_id: undefined,221filesystem: undefined,222path: "",223command: required,224args: [],225max_output: undefined,226bash: false,227aggregate: undefined,228err_on_exit: true,229env: undefined,230post: false, // if true, uses the POST api through nextjs instead of the websocket api.231async_call: undefined, // if given use a callback interface instead of async232timeout: 30,233cb: undefined,234});235}236237const intl = await getIntl();238const msg = intl.formatMessage(dialogs.client_project_exec_msg, {239blocking: isExecOptsBlocking(opts),240arg: isExecOptsBlocking(opts) ? opts.command : opts.async_get,241});242243if (!(await ensure_project_running(opts.project_id, msg))) {244return {245type: "blocking",246stdout: "",247stderr: intl.formatMessage(dialogs.client_project_exec_start_first),248exit_code: 1,249time: 0,250};251}252253const { post } = opts;254delete opts.post;255256try {257let msg;258if (post) {259// use post API260msg = await httpApi("exec", opts);261} else {262const ws = await this.websocket(opts.project_id);263const exec_opts = copy_without(opts, ["project_id"]);264msg = await ws.api.exec(exec_opts);265}266if (msg.status && msg.status == "error") {267throw new Error(msg.error);268}269if (msg.type === "blocking") {270delete msg.status;271}272delete msg.error;273if (opts.cb == null) {274return msg;275} else {276opts.cb(undefined, msg);277return msg;278}279} catch (err) {280if (opts.cb == null) {281throw err;282} else {283if (!err.message) {284// Important since err.message can be falsey, e.g., for Error(''), but toString will never be falsey.285opts.cb(err.toString());286} else {287opts.cb(err.message);288}289return {290type: "blocking",291stdout: "",292stderr: err.message,293exit_code: 1,294time: 0, // should be ignored; this is just to make typescript happy.295};296}297}298}299300// Directly compute the directory listing. No caching or other information301// is used -- this just sends a message over the websocket requesting302// the backend node.js project process to compute the listing.303public async directory_listing(opts: {304project_id: string;305path: string;306compute_server_id: number;307timeout?: number;308hidden?: boolean;309}): Promise<{ files: DirectoryListingEntry[] }> {310if (opts.timeout == null) opts.timeout = 15;311const api = await this.api(opts.project_id);312const listing = await api.listing(313opts.path,314opts.hidden,315opts.timeout * 1000,316opts.compute_server_id,317);318return { files: listing };319}320321public async public_get_text_file(opts: {322project_id: string;323path: string;324}): Promise<string> {325return (await this.call(message.public_get_text_file(opts))).data;326}327328public async find_directories(opts: {329project_id: string;330query?: string; // see the -iwholename option to the UNIX find command.331path?: string; // Root path to find directories from332exclusions?: string[]; // paths relative to `opts.path`. Skips whole sub-trees333include_hidden?: boolean;334}): Promise<{335query: string;336path: string;337project_id: string;338directories: string[];339}> {340opts = defaults(opts, {341project_id: required,342query: "*", // see the -iwholename option to the UNIX find command.343path: ".", // Root path to find directories from344exclusions: undefined, // Array<String> Paths relative to `opts.path`. Skips whole sub-trees345include_hidden: false,346});347if (opts.path == null || opts.query == null)348throw Error("bug -- cannot happen");349350const args: string[] = [351opts.path,352"-xdev",353"!",354"-readable",355"-prune",356"-o",357"-type",358"d",359"-iwholename", // See https://github.com/sagemathinc/cocalc/issues/5502360`'${opts.query}'`,361"-readable",362];363if (opts.exclusions != null) {364for (const excluded_path of opts.exclusions) {365args.push(366`-a -not \\( -path '${opts.path}/${excluded_path}' -prune \\)`,367);368}369}370371args.push("-print");372const command = `find ${args.join(" ")}`;373374const result = await this.exec({375// err_on_exit = false: because want this to still work even if there's a nonzero exit code,376// which might happen if find hits a directory it can't read, e.g., a broken ~/.snapshots.377err_on_exit: false,378project_id: opts.project_id,379command,380timeout: 60,381aggregate: Math.round(Date.now() / 5000), // aggregate calls into 5s windows, in case multiple clients ask for same find at once...382});383const n = opts.path.length + 1;384let v = result.stdout.split("\n");385if (!opts.include_hidden) {386v = v.filter((x) => x.indexOf("/.") === -1);387}388v = v.filter((x) => x.length > n).map((x) => x.slice(n));389return {390query: opts.query,391path: opts.path,392project_id: opts.project_id,393directories: v,394};395}396397// This is async, so do "await smc_webapp.configuration(...project_id...)".398// for reuseInFlight, see https://github.com/sagemathinc/cocalc/issues/7806399public configuration = reuseInFlight(400async (401project_id: string,402aspect: ConfigurationAspect,403no_cache: boolean,404): Promise<Configuration> => {405if (!is_valid_uuid_string(project_id)) {406throw Error("project_id must be a valid uuid");407}408return (await this.api(project_id)).configuration(aspect, no_cache);409},410);411412// Remove all upgrades from all projects that this user collaborates on.413public async remove_all_upgrades(projects?: string[]): Promise<void> {414await this.call(message.remove_all_upgrades({ projects }));415}416417touch_project = async (418// project_id where activity occured419project_id: string,420// optional global id of a compute server (in the given project), in which case we also mark421// that compute server as active, which keeps it running in case it has idle timeout configured.422compute_server_id?: number,423): Promise<void> => {424if (compute_server_id) {425// this is throttled, etc. and is independent of everything below.426touchComputeServer({427project_id,428compute_server_id,429client: this.client,430});431// that said, we do still touch the project, since if a user is actively432// using a compute server, the project should also be considered active.433}434435const state = redux.getStore("projects")?.get_state(project_id);436if (!(state == null && redux.getStore("account")?.get("is_admin"))) {437// not trying to view project as admin so do some checks438if (!(await allow_project_to_run(project_id))) return;439if (!this.client.is_signed_in()) {440// silently ignore if not signed in441return;442}443if (state != "running") {444// not running so don't touch (user must explicitly start first)445return;446}447}448449// Throttle -- so if this function is called with the same project_id450// twice in 3s, it's ignored (to avoid unnecessary network traffic).451// Do not make the timeout long, since that can mess up452// getting the hub-websocket to connect to the project.453const last = this.touch_throttle[project_id];454if (last != null && Date.now() - last <= 3000) {455return;456}457this.touch_throttle[project_id] = Date.now();458try {459await this.call(message.touch_project({ project_id }));460} catch (err) {461// silently ignore; this happens, e.g., if you touch too frequently,462// and shouldn't be fatal and break other things.463// NOTE: this is a bit ugly for now -- basically the464// hub returns an error regarding actually touching465// the project (updating the db), but it still *does*466// ensure there is a TCP connection to the project.467}468};469470// Print file to pdf471// The printed version of the file will be created in the same directory472// as path, but with extension replaced by ".pdf".473// Only used for sagews, and would be better done with websocket api anyways...474public async print_to_pdf(opts: {475project_id: string;476path: string;477options?: any; // optional options that get passed to the specific backend for this file type478timeout?: number; // client timeout -- some things can take a long time to print!479}): Promise<string> {480// returns path to pdf file481if (opts.options == null) opts.options = {};482opts.options.timeout = opts.timeout; // timeout on backend483484return (485await this.client.async_call({486message: message.local_hub({487project_id: opts.project_id,488message: message.print_to_pdf({489path: opts.path,490options: opts.options,491}),492}),493timeout: opts.timeout,494allow_post: false,495})496).path;497}498499public async create(opts: {500title: string;501description: string;502image?: string;503start?: boolean;504license?: string; // "license_id1,license_id2,..." -- if given, create project with these licenses applied505noPool?: boolean; // never use pool506}): Promise<string> {507const { project_id } = await this.client.async_call({508allow_post: false, // since gets called for anonymous and cookie not yet set.509message: message.create_project(opts),510});511512this.client.tracking_client.user_tracking("create_project", {513project_id,514title: opts.title,515});516517return project_id;518}519520// Disconnect whatever hub we are connected to from the project521// Adding this right now only for debugging/dev purposes!522public async disconnect_hub_from_project(project_id: string): Promise<void> {523await this.call(message.disconnect_from_project({ project_id }));524}525526public async realpath(opts: {527project_id: string;528path: string;529}): Promise<string> {530const real = (await this.api(opts.project_id)).realpath(opts.path);531return real;532}533534async isdir({535project_id,536path,537}: {538project_id: string;539path: string;540}): Promise<boolean> {541const { stdout, exit_code } = await this.exec({542project_id,543command: "file",544args: ["-Eb", path],545err_on_exit: false,546});547return !exit_code && stdout.trim() == "directory";548}549550// Add and remove a license from a project. Note that these551// might not be used to implement anything in the client frontend, but552// are used via the API, and this is a convenient way to test them.553public async add_license_to_project(554project_id: string,555license_id: string,556): Promise<void> {557await this.call(message.add_license_to_project({ project_id, license_id }));558}559560public async remove_license_from_project(561project_id: string,562license_id: string,563): Promise<void> {564await this.call(565message.remove_license_from_project({ project_id, license_id }),566);567}568569public project_info(project_id: string): ProjectInfo {570return project_info(this.client, project_id);571}572573public project_status(project_id: string): ProjectStatus {574return project_status(this.client, project_id);575}576577public usage_info(project_id: string): UsageInfoWS {578return get_usage_info(project_id);579}580581public ipywidgetsGetBuffer = reuseInFlight(582async (583project_id: string,584path: string,585model_id: string,586buffer_path: string,587useHttp?: boolean, // ONLY works for home base, NOT compute servers!588): Promise<ArrayBuffer> => {589if (useHttp) {590const url = ipywidgetsGetBufferUrl(591project_id,592path,593model_id,594buffer_path,595);596return await (await fetch(url)).arrayBuffer();597}598const actions = redux.getEditorActions(project_id, path);599return await actions.jupyter_actions.ipywidgetsGetBuffer(600model_id,601buffer_path,602);603},604);605606// getting, setting, editing, deleting, etc., the api keys for a project607public async api_keys(opts: {608project_id: string;609action: "get" | "delete" | "create" | "edit";610password?: string;611name?: string;612id?: number;613expire?: Date;614}): Promise<ApiKey[] | undefined> {615if (this.client.account_id == null) {616throw Error("must be logged in");617}618if (!is_valid_uuid_string(opts.project_id)) {619throw Error("project_id must be a valid uuid");620}621if (opts.project_id == null && !opts.password) {622throw Error("must provide password for non-project api key");623}624// because message always uses id, so we have to use something else!625const opts2: any = { ...opts };626delete opts2.id;627opts2.key_id = opts.id;628return (await this.call(message.api_keys(opts2))).response;629}630631computeServers = (project_id) => {632return computeServers(project_id);633};634635getServerIdForPath = async ({636project_id,637path,638}): Promise<number | undefined> => {639return await computeServers(project_id)?.getServerIdForPath(path);640};641}642643// (NOTE: this won't throw an exception)644const touchComputeServer = throttle(645async ({ project_id, compute_server_id, client }) => {646if (!compute_server_id) {647// nothing to do648return;649}650try {651await client.async_query({652query: {653compute_servers: {654project_id,655id: compute_server_id,656last_edited_user: client.server_time(),657},658},659});660} catch (err) {661// just a warning -- if we can't connect then touching isn't something we should be doing anyways.662console.log(663"WARNING: failed to touch compute server -- ",664{ compute_server_id },665err,666);667}668},66930000,670);671672673