Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.
Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.
Path: blob/master/src/packages/frontend/client/project.ts
Views: 687
/*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, getIntl } from "@cocalc/frontend/i18n";15import { ipywidgetsGetBufferUrl } from "@cocalc/frontend/jupyter/server-urls";16import { allow_project_to_run } from "@cocalc/frontend/project/client-side-throttle";17import { ensure_project_running } from "@cocalc/frontend/project/project-start-warning";18import { API } from "@cocalc/frontend/project/websocket/api";19import { connection_to_project } from "@cocalc/frontend/project/websocket/connect";20import {21ProjectInfo,22project_info,23} from "@cocalc/frontend/project/websocket/project-info";24import {25ProjectStatus,26project_status,27} from "@cocalc/frontend/project/websocket/project-status";28import {29UsageInfoWS,30get_usage_info,31} from "@cocalc/frontend/project/websocket/usage-info";32import {33Configuration,34ConfigurationAspect,35} from "@cocalc/frontend/project_configuration";36import { HOME_ROOT } from "@cocalc/util/consts/files";37import type { ApiKey } from "@cocalc/util/db-schema/api-keys";38import {39isExecOptsBlocking,40type ExecOpts,41type ExecOutput,42} from "@cocalc/util/db-schema/projects";43import * as message from "@cocalc/util/message";44import {45coerce_codomain_to_numbers,46copy_without,47defaults,48encode_path,49is_valid_uuid_string,50required,51} from "@cocalc/util/misc";52import { reuseInFlight } from "@cocalc/util/reuse-in-flight";53import { DirectoryListingEntry } from "@cocalc/util/types";54import httpApi from "./api";55import { WebappClient } from "./client";5657export class ProjectClient {58private client: WebappClient;59private touch_throttle: { [project_id: string]: number } = {};6061constructor(client: WebappClient) {62this.client = client;63}6465private async call(message: object): Promise<any> {66return await this.client.async_call({ message });67}6869public async write_text_file(opts: {70project_id: string;71path: string;72content: string;73}): Promise<void> {74return await this.call(message.write_text_file_to_project(opts));75}7677public async read_text_file(opts: {78project_id: string; // string or array of strings79path: string; // string or array of strings80}): Promise<string> {81return (await this.call(message.read_text_file_from_project(opts))).content;82}8384// Like "read_text_file" above, except the callback85// message gives a url from which the file can be86// downloaded using standard AJAX.87public read_file(opts: {88project_id: string; // string or array of strings89path: string; // string or array of strings90}): string {91const base_path = appBasePath;92if (opts.path[0] === "/") {93// absolute path to the root94opts.path = HOME_ROOT + opts.path; // use root symlink, which is created by start_smc95}96return encode_path(join(base_path, `${opts.project_id}/raw/${opts.path}`));97}9899public async copy_path_between_projects(opts: {100public?: boolean; // used e.g., by share server landing page action.101src_project_id: string; // id of source project102src_path: string; // relative path of director or file in the source project103target_project_id: string; // if of target project104target_path?: string; // defaults to src_path105overwrite_newer?: boolean; // overwrite newer versions of file at destination (destructive)106delete_missing?: boolean; // delete files in dest that are missing from source (destructive)107backup?: boolean; // make ~ backup files instead of overwriting changed files108timeout?: number; // **timeout in seconds** -- how long to wait for the copy to complete before reporting "error" (though it could still succeed)109exclude?: string[]; // list of patterns to exclude; this uses exactly the (confusing) rsync patterns110}): Promise<void> {111const is_public = opts.public;112delete opts.public;113114if (opts.target_path == null) {115opts.target_path = opts.src_path;116}117118const mesg = is_public119? message.copy_public_path_between_projects(opts)120: message.copy_path_between_projects(opts);121mesg.wait_until_done = true; // TODO: our UI only supports this for now.122123// THIS CAN BE USEFUL FOR DEBUGGING!124// mesg.debug_delay_s = 10;125126await this.client.async_call({127timeout: opts.timeout,128message: mesg,129allow_post: false, // since it may take too long130});131}132133// Set a quota parameter for a given project.134// As of now, only user in the admin group can make these changes.135public async set_quotas(opts: {136project_id: string;137memory?: number; // see message.js for the units, etc., for all these settings138memory_request?: number;139cpu_shares?: number;140cores?: number;141disk_quota?: number;142mintime?: number;143network?: number;144member_host?: number;145always_running?: number;146}): Promise<void> {147// we do some extra work to ensure all the quotas are numbers (typescript isn't148// enough; sometimes client code provides strings, which can cause lots of trouble).149const x = coerce_codomain_to_numbers(copy_without(opts, ["project_id"]));150await this.call(151message.project_set_quotas({ ...x, ...{ project_id: opts.project_id } }),152);153}154155public async websocket(project_id: string): Promise<any> {156const store = redux.getStore("projects");157// Wait until project is running (or admin and not on project)158await store.async_wait({159until: () => {160const state = store.get_state(project_id);161if (state == null && redux.getStore("account")?.get("is_admin")) {162// is admin so doesn't know project state -- just immediately163// try, which will cause project to run164return true;165}166return state == "running";167},168});169170// get_my_group returns undefined when the various info to171// determine this isn't yet loaded. For some connections172// this websocket function gets called before that info is173// loaded, which can cause trouble.174let group: string | undefined;175await store.async_wait({176until: () => (group = store.get_my_group(project_id)) != null,177});178if (group == "public") {179throw Error("no access to project websocket");180}181return await connection_to_project(project_id);182}183184public async api(project_id: string): Promise<API> {185return (await this.websocket(project_id)).api;186}187188/*189Execute code in a given project or associated compute server.190191Aggregate option -- use like this:192193webapp.exec194aggregate: timestamp (or something else sequential)195196means: if there are multiple attempts to run the given command with the same197time, they are all aggregated and run only one time by the project. If requests198comes in with a newer time, they all run in another group after the first199one finishes. The timestamp will usually come from something like the "last save200time" (which is stored in the db), which they client will know. This is used, e.g.,201for operations like "run rst2html on this file whenever it is saved."202*/203public async exec(opts: ExecOpts & { post?: boolean }): Promise<ExecOutput> {204if ("async_get" in opts) {205opts = defaults(opts, {206project_id: required,207compute_server_id: undefined,208async_get: required,209async_stats: undefined,210async_await: undefined,211post: false, // if true, uses the POST api through nextjs instead of the websocket api.212timeout: 30,213cb: undefined,214});215} else {216opts = defaults(opts, {217project_id: required,218compute_server_id: undefined,219filesystem: undefined,220path: "",221command: required,222args: [],223max_output: undefined,224bash: false,225aggregate: undefined,226err_on_exit: true,227env: undefined,228post: false, // if true, uses the POST api through nextjs instead of the websocket api.229async_call: undefined, // if given use a callback interface instead of async230timeout: 30,231cb: undefined,232});233}234235const intl = await getIntl();236const msg = intl.formatMessage(dialogs.client_project_exec_msg, {237blocking: isExecOptsBlocking(opts),238arg: isExecOptsBlocking(opts) ? opts.command : opts.async_get,239});240241if (!(await ensure_project_running(opts.project_id, msg))) {242return {243type: "blocking",244stdout: "",245stderr: intl.formatMessage(dialogs.client_project_exec_start_first),246exit_code: 1,247time: 0,248};249}250251const { post } = opts;252delete opts.post;253254try {255let msg;256if (post) {257// use post API258msg = await httpApi("exec", opts);259} else {260const ws = await this.websocket(opts.project_id);261const exec_opts = copy_without(opts, ["project_id"]);262msg = await ws.api.exec(exec_opts);263}264if (msg.status && msg.status == "error") {265throw new Error(msg.error);266}267if (msg.type === "blocking") {268delete msg.status;269}270delete msg.error;271if (opts.cb == null) {272return msg;273} else {274opts.cb(undefined, msg);275return msg;276}277} catch (err) {278if (opts.cb == null) {279throw err;280} else {281if (!err.message) {282// Important since err.message can be falsey, e.g., for Error(''), but toString will never be falsey.283opts.cb(err.toString());284} else {285opts.cb(err.message);286}287return {288type: "blocking",289stdout: "",290stderr: err.message,291exit_code: 1,292time: 0, // should be ignored; this is just to make typescript happy.293};294}295}296}297298// Directly compute the directory listing. No caching or other information299// is used -- this just sends a message over the websocket requesting300// the backend node.js project process to compute the listing.301public async directory_listing(opts: {302project_id: string;303path: string;304compute_server_id: number;305timeout?: number;306hidden?: boolean;307}): Promise<{ files: DirectoryListingEntry[] }> {308if (opts.timeout == null) opts.timeout = 15;309const api = await this.api(opts.project_id);310const listing = await api.listing(311opts.path,312opts.hidden,313opts.timeout * 1000,314opts.compute_server_id,315);316return { files: listing };317}318319public async public_get_text_file(opts: {320project_id: string;321path: string;322}): Promise<string> {323return (await this.call(message.public_get_text_file(opts))).data;324}325326public async find_directories(opts: {327project_id: string;328query?: string; // see the -iwholename option to the UNIX find command.329path?: string; // Root path to find directories from330exclusions?: string[]; // paths relative to `opts.path`. Skips whole sub-trees331include_hidden?: boolean;332}): Promise<{333query: string;334path: string;335project_id: string;336directories: string[];337}> {338opts = defaults(opts, {339project_id: required,340query: "*", // see the -iwholename option to the UNIX find command.341path: ".", // Root path to find directories from342exclusions: undefined, // Array<String> Paths relative to `opts.path`. Skips whole sub-trees343include_hidden: false,344});345if (opts.path == null || opts.query == null)346throw Error("bug -- cannot happen");347348const args: string[] = [349opts.path,350"-xdev",351"!",352"-readable",353"-prune",354"-o",355"-type",356"d",357"-iwholename", // See https://github.com/sagemathinc/cocalc/issues/5502358`'${opts.query}'`,359"-readable",360];361if (opts.exclusions != null) {362for (const excluded_path of opts.exclusions) {363args.push(364`-a -not \\( -path '${opts.path}/${excluded_path}' -prune \\)`,365);366}367}368369args.push("-print");370const command = `find ${args.join(" ")}`;371372const result = await this.exec({373// err_on_exit = false: because want this to still work even if there's a nonzero exit code,374// which might happen if find hits a directory it can't read, e.g., a broken ~/.snapshots.375err_on_exit: false,376project_id: opts.project_id,377command,378timeout: 60,379aggregate: Math.round(Date.now() / 5000), // aggregate calls into 5s windows, in case multiple clients ask for same find at once...380});381const n = opts.path.length + 1;382let v = result.stdout.split("\n");383if (!opts.include_hidden) {384v = v.filter((x) => x.indexOf("/.") === -1);385}386v = v.filter((x) => x.length > n).map((x) => x.slice(n));387return {388query: opts.query,389path: opts.path,390project_id: opts.project_id,391directories: v,392};393}394395// This is async, so do "await smc_webapp.configuration(...project_id...)".396// for reuseInFlight, see https://github.com/sagemathinc/cocalc/issues/7806397public configuration = reuseInFlight(398async (399project_id: string,400aspect: ConfigurationAspect,401no_cache: boolean,402): Promise<Configuration> => {403if (!is_valid_uuid_string(project_id)) {404throw Error("project_id must be a valid uuid");405}406return (await this.api(project_id)).configuration(aspect, no_cache);407},408);409410// Remove all upgrades from all projects that this user collaborates on.411public async remove_all_upgrades(projects?: string[]): Promise<void> {412await this.call(message.remove_all_upgrades({ projects }));413}414415public async touch(project_id: string): Promise<void> {416const state = redux.getStore("projects")?.get_state(project_id);417if (!(state == null && redux.getStore("account")?.get("is_admin"))) {418// not trying to view project as admin so do some checks419if (!(await allow_project_to_run(project_id))) return;420if (!this.client.is_signed_in()) {421// silently ignore if not signed in422return;423}424if (state != "running") {425// not running so don't touch (user must explicitly start first)426return;427}428}429430// Throttle -- so if this function is called with the same project_id431// twice in 3s, it's ignored (to avoid unnecessary network traffic).432// Do not make the timeout long, since that can mess up433// getting the hub-websocket to connect to the project.434const last = this.touch_throttle[project_id];435if (last != null && Date.now() - last <= 3000) {436return;437}438this.touch_throttle[project_id] = Date.now();439try {440await this.call(message.touch_project({ project_id }));441} catch (err) {442// silently ignore; this happens, e.g., if you touch too frequently,443// and shouldn't be fatal and break other things.444// NOTE: this is a bit ugly for now -- basically the445// hub returns an error regarding actually touching446// the project (updating the db), but it still *does*447// ensure there is a TCP connection to the project.448}449}450451// Print file to pdf452// The printed version of the file will be created in the same directory453// as path, but with extension replaced by ".pdf".454// Only used for sagews, and would be better done with websocket api anyways...455public async print_to_pdf(opts: {456project_id: string;457path: string;458options?: any; // optional options that get passed to the specific backend for this file type459timeout?: number; // client timeout -- some things can take a long time to print!460}): Promise<string> {461// returns path to pdf file462if (opts.options == null) opts.options = {};463opts.options.timeout = opts.timeout; // timeout on backend464465return (466await this.client.async_call({467message: message.local_hub({468project_id: opts.project_id,469message: message.print_to_pdf({470path: opts.path,471options: opts.options,472}),473}),474timeout: opts.timeout,475allow_post: false,476})477).path;478}479480public async create(opts: {481title: string;482description: string;483image?: string;484start?: boolean;485license?: string; // "license_id1,license_id2,..." -- if given, create project with these licenses applied486noPool?: boolean; // never use pool487}): Promise<string> {488const { project_id } = await this.client.async_call({489allow_post: false, // since gets called for anonymous and cookie not yet set.490message: message.create_project(opts),491});492493this.client.tracking_client.user_tracking("create_project", {494project_id,495title: opts.title,496});497498return project_id;499}500501// Disconnect whatever hub we are connected to from the project502// Adding this right now only for debugging/dev purposes!503public async disconnect_hub_from_project(project_id: string): Promise<void> {504await this.call(message.disconnect_from_project({ project_id }));505}506507public async realpath(opts: {508project_id: string;509path: string;510}): Promise<string> {511const real = (await this.api(opts.project_id)).realpath(opts.path);512return real;513}514515async isdir({516project_id,517path,518}: {519project_id: string;520path: string;521}): Promise<boolean> {522const { stdout, exit_code } = await this.exec({523project_id,524command: "file",525args: ["-Eb", path],526err_on_exit: false,527});528return !exit_code && stdout.trim() == "directory";529}530531// Add and remove a license from a project. Note that these532// might not be used to implement anything in the client frontend, but533// are used via the API, and this is a convenient way to test them.534public async add_license_to_project(535project_id: string,536license_id: string,537): Promise<void> {538await this.call(message.add_license_to_project({ project_id, license_id }));539}540541public async remove_license_from_project(542project_id: string,543license_id: string,544): Promise<void> {545await this.call(546message.remove_license_from_project({ project_id, license_id }),547);548}549550public project_info(project_id: string): ProjectInfo {551return project_info(this.client, project_id);552}553554public project_status(project_id: string): ProjectStatus {555return project_status(this.client, project_id);556}557558public usage_info(project_id: string): UsageInfoWS {559return get_usage_info(project_id);560}561562public ipywidgetsGetBuffer = reuseInFlight(563async (564project_id: string,565path: string,566model_id: string,567buffer_path: string,568useHttp?: boolean, // ONLY works for home base, NOT compute servers!569): Promise<ArrayBuffer> => {570if (useHttp) {571const url = ipywidgetsGetBufferUrl(572project_id,573path,574model_id,575buffer_path,576);577return await (await fetch(url)).arrayBuffer();578}579const actions = redux.getEditorActions(project_id, path);580return await actions.jupyter_actions.ipywidgetsGetBuffer(581model_id,582buffer_path,583);584},585);586587// getting, setting, editing, deleting, etc., the api keys for a project588public async api_keys(opts: {589project_id: string;590action: "get" | "delete" | "create" | "edit";591password?: string;592name?: string;593id?: number;594expire?: Date;595}): Promise<ApiKey[] | undefined> {596if (this.client.account_id == null) {597throw Error("must be logged in");598}599if (!is_valid_uuid_string(opts.project_id)) {600throw Error("project_id must be a valid uuid");601}602if (opts.project_id == null && !opts.password) {603throw Error("must provide password for non-project api key");604}605// because message always uses id, so we have to use something else!606const opts2: any = { ...opts };607delete opts2.id;608opts2.key_id = opts.id;609return (await this.call(message.api_keys(opts2))).response;610}611612computeServers = (project_id) => {613return computeServers(project_id);614};615616getServerIdForPath = async ({617project_id,618path,619}): Promise<number | undefined> => {620return await computeServers(project_id)?.getServerIdForPath(path);621};622}623624625