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/compute/proxy.tsx
Views: 687
/*1The HTTPS proxy server.2*/34import { Alert, Button, Input, Spin, Switch } from "antd";5import { delay } from "awaiting";6import jsonic from "jsonic";7import { useEffect, useMemo, useRef, useState } from "react";89import { A, Icon } from "@cocalc/frontend/components";10import ShowError from "@cocalc/frontend/components/error";11import { PROXY_CONFIG } from "@cocalc/util/compute/constants";12import AuthToken from "./auth-token";13import { writeTextFileToComputeServer } from "./project";1415import { useTypedRedux } from "@cocalc/frontend/app-framework";16import { TimeAgo } from "@cocalc/frontend/components";17import { CancelText } from "@cocalc/frontend/i18n/components";18import { open_new_tab } from "@cocalc/frontend/misc/open-browser-tab";19import { webapp_client } from "@cocalc/frontend/webapp-client";20import { defaultProxyConfig } from "@cocalc/util/compute/images";21import { EditModal } from "./compute-server";22import { getQuery } from "./description";2324export default function Proxy({25id,26project_id,27setConfig,28configuration,29data,30state,31IMAGES,32}) {33const [help, setHelp] = useState<boolean>(false);3435return (36<div>37<div style={{ color: "#666", marginBottom: "5px" }}>38<div>39<b>40<Switch41size="small"42checkedChildren={"Help"}43unCheckedChildren={"Help"}44style={{ float: "right" }}45checked={help}46onChange={(val) => setHelp(val)}47/>48<Icon name="global" /> Web Applications: VS Code, JupyterLab, etc.49</b>50</div>51{help && (52<Alert53showIcon54style={{ margin: "15px 0" }}55type="info"56message={"Proxy"}57description={58<div>59You can directly run servers such as JupyterLab, VS Code, and60Pluto on your compute server. The authorization token is used so61you and your project collaborators can access these servers.62<br />63<br />64<b>NOTE:</b> It can take a few minutes for an app to start65running the first time you launch it.66<br />67<br />68<b>WARNING:</b> You will see a security warning if you don't69configure a domain name. In some cases, e.g., JupyterLab via70Chrome, you <i>must</i> configure a domain name (due to a bug in71Chrome).72</div>73}74/>75)}76<ProxyConfig77id={id}78project_id={project_id}79setConfig={setConfig}80configuration={configuration}81state={state}82IMAGES={IMAGES}83/>84<AuthToken85id={id}86project_id={project_id}87setConfig={setConfig}88configuration={configuration}89state={state}90IMAGES={IMAGES}91/>92<Apps93state={state}94configuration={configuration}95data={data}96IMAGES={IMAGES}97style={{ marginTop: "10px" }}98compute_server_id={id}99project_id={project_id}100/>101</div>102</div>103);104}105106function getProxy({ IMAGES, configuration }) {107return (108configuration?.proxy ??109defaultProxyConfig({ image: configuration?.image, IMAGES })110);111}112113function ProxyConfig({114id,115project_id,116setConfig,117configuration,118state,119IMAGES,120}) {121const [edit, setEdit] = useState<boolean>(false);122const [error, setError] = useState<string>("");123const [saving, setSaving] = useState<boolean>(false);124const proxy = getProxy({ configuration, IMAGES });125const [proxyJson, setProxyJson] = useState<string>(stringify(proxy));126useEffect(() => {127setProxyJson(stringify(proxy));128}, [configuration]);129130if (!edit) {131return (132<Button133style={{ marginTop: "15px", display: "inline-block", float: "right" }}134onClick={() => setEdit(true)}135>136Advanced...137</Button>138);139}140141const save = async () => {142try {143setSaving(true);144setError("");145const proxy = jsonic(proxyJson);146setProxyJson(stringify(proxy));147await setConfig({ proxy });148if (state == "running") {149await writeProxy({150compute_server_id: id,151project_id,152proxy,153});154}155setEdit(false);156} catch (err) {157setError(`${err}`);158} finally {159setSaving(false);160}161};162return (163<div style={{ marginTop: "15px" }}>164<ShowError165error={error}166setError={setError}167style={{ margin: "15px 0" }}168/>169<Button170disabled={saving}171onClick={() => {172setProxyJson(stringify(proxy));173setEdit(false);174}}175style={{ marginRight: "5px" }}176>177<CancelText />178</Button>179<Button180type="primary"181disabled={saving || proxyJson == stringify(proxy)}182onClick={save}183>184Save {saving && <Spin />}185</Button>186<div187style={{188display: "inline-block",189color: "#666",190marginLeft: "30px",191}}192>193Configure <code>/cocalc/conf/proxy.json</code> using{" "}194<A href="https://github.com/sagemathinc/cocalc-compute-docker/tree/main/src/proxy">195this JSON format196</A>197.198</div>199<Input.TextArea200style={{ marginTop: "15px" }}201disabled={saving}202value={proxyJson}203onChange={(e) => setProxyJson(e.target.value)}204autoSize={{ minRows: 2, maxRows: 6 }}205/>206</div>207);208}209210function stringify(proxy) {211return "[\n" + proxy.map((x) => " " + JSON.stringify(x)).join(",\n") + "\n]";212}213214async function writeProxy({ proxy, project_id, compute_server_id }) {215const value = stringify(proxy);216await writeTextFileToComputeServer({217value,218project_id,219compute_server_id,220sudo: true,221path: PROXY_CONFIG,222});223}224225function Apps({226compute_server_id,227configuration,228IMAGES,229style,230data,231project_id,232state,233}) {234const [error, setError] = useState<string>("");235const compute_servers_dns = useTypedRedux("customize", "compute_servers_dns");236const apps = useMemo(237() =>238getApps({239setError,240compute_server_id,241project_id,242configuration,243data,244IMAGES,245compute_servers_dns,246state,247}),248[249configuration?.image,250IMAGES != null,251configuration?.proxy,252data?.externalIp,253],254);255if (apps.length == 0) {256return null;257}258return (259<div style={style}>260<b>Launch App</b> (opens in new browser tab)261<div>262<div style={{ marginTop: "5px" }}>{apps}</div>263<ShowError264style={{ marginTop: "10px" }}265error={error}266setError={setError}267/>268</div>269</div>270);271}272273function getApps({274compute_server_id,275configuration,276data,277IMAGES,278project_id,279compute_servers_dns,280setError,281state,282}) {283const image = configuration?.image;284if (IMAGES == null || image == null) {285return [];286}287const proxy = getProxy({ configuration, IMAGES });288const apps = IMAGES[image]?.apps ?? IMAGES["defaults"]?.apps ?? {};289290const buttons: JSX.Element[] = [];291for (const name in apps) {292const app = apps[name];293if (app.disabled) {294continue;295}296for (const route of proxy) {297if (route.path == app.path) {298buttons.push(299<LauncherButton300key={name}301disabled={state != "running"}302name={name}303app={app}304compute_server_id={compute_server_id}305project_id={project_id}306configuration={configuration}307data={data}308compute_servers_dns={compute_servers_dns}309setError={setError}310route={route}311/>,312);313break;314}315}316}317return buttons;318}319320export function getRoute({ app, configuration, IMAGES }) {321const proxy = getProxy({ configuration, IMAGES });322if (app.name) {323// It's best and most explicit to use the name.324for (const route of proxy) {325if (route.name == app.name) {326return route;327}328}329}330// Name is not specified or not matching, so we try to match the331// route path:332for (const route of proxy) {333if (route.path == app.path) {334return route;335}336}337// nothing matches.338throw Error(`No route found for app '${app.label}'`);339}340341const START_DELAY_MS = 1500;342const MAX_DELAY_MS = 7500;343344export function LauncherButton({345name,346app,347compute_server_id,348project_id,349configuration,350data,351compute_servers_dns,352setError,353disabled,354route,355noHide,356autoLaunch,357}: {358name: string;359app;360compute_server_id: number;361project_id: string;362configuration;363data;364compute_servers_dns?: string;365setError;366disabled?;367route;368noHide?: boolean;369autoLaunch?: boolean;370}) {371const [url, setUrl] = useState<string>("");372const [launching, setLaunching] = useState<boolean>(false);373const [log, setLog] = useState<string>("");374const cancelRef = useRef<boolean>(false);375const [start, setStart] = useState<Date | null>(null);376const [showSettings, setShowSettings] = useState<boolean>(false);377const dnsIssue =378!(configuration?.dns && compute_servers_dns) && app.requiresDns;379useEffect(() => {380if (autoLaunch) {381launch();382}383}, []);384const launch = async () => {385try {386setLaunching(true);387cancelRef.current = false;388const url = getUrl({389app,390configuration,391data,392compute_servers_dns,393});394setUrl(url);395let attempt = 0;396setStart(new Date());397const isRunning = async () => {398attempt += 1;399setLog(`Checking if ${route.target} is alive (attempt: ${attempt})...`);400return await isHttpServerResponding({401project_id,402compute_server_id,403target: route.target,404});405};406if (!(await isRunning())) {407setLog("Launching...");408await webapp_client.exec({409filesystem: false,410compute_server_id,411project_id,412command: app.launch,413err_on_exit: true,414});415}416let d = START_DELAY_MS;417while (!cancelRef.current && d < 60 * 1000 * 5) {418if (await isRunning()) {419setLog("Running!");420break;421}422d = Math.min(MAX_DELAY_MS, d * 1.2);423await delay(d);424}425if (!cancelRef.current) {426setLog("Opening tab");427open_new_tab(url);428}429} catch (err) {430setError(`${app.label}: ${err}`);431} finally {432setLaunching(false);433setLog("");434}435};436return (437<div key={name} style={{ display: "inline-block", marginRight: "5px" }}>438<Button disabled={disabled || dnsIssue || launching} onClick={launch}>439{app.icon ? <Icon name={app.icon} /> : undefined}440{app.label}{" "}441{dnsIssue && <span style={{ marginLeft: "5px" }}>(requires DNS)</span>}442{launching && <Spin />}443</Button>444{launching && (445<Button446style={{ marginLeft: "5px" }}447onClick={() => {448cancelRef.current = true;449setLaunching(false);450setUrl("");451}}452>453<CancelText />454</Button>455)}456{log && (457<div>458{log}459<TimeAgo date={start} />460</div>461)}462{url && (463<div464style={{465color: "#666",466maxWidth: "500px",467border: "1px solid #ccc",468padding: "15px",469borderRadius: "5px",470margin: "10px 0",471}}472>473It could take a minute for {app.label} to start, so revisit this URL474if necessary.475{dnsIssue && (476<Alert477style={{ margin: "10px" }}478type="warning"479showIcon480message={481<>482<b>WARNING:</b> {app.label} probably won't work without a DNS483subdomain configured.484<Button485style={{ marginLeft: "15px" }}486onClick={() => {487setShowSettings(true);488}}489>490<Icon name="settings" /> Settings491</Button>492{showSettings && (493<EditModal494id={compute_server_id}495project_id={project_id}496close={() => setShowSettings(false)}497/>498)}499</>500}501/>502)}503<div style={{ textAlign: "center" }}>504<A href={url}>{url}</A>505</div>506You can also share this URL with other people, who will be able to507access the server, even if they do not have a CoCalc account.508{!noHide && (509<Button size="small" type="link" onClick={() => setUrl("")}>510(hide)511</Button>512)}513</div>514)}515</div>516);517}518519function getUrl({ app, configuration, data, compute_servers_dns }) {520const auth = getQuery(configuration.authToken);521if (configuration.dns && compute_servers_dns) {522return `https://${configuration.dns}.${compute_servers_dns}${app.url}${auth}`;523} else {524if (!data?.externalIp) {525throw Error("no external ip addressed assigned");526}527return `https://${data.externalIp}${app.url}${auth}`;528}529}530531// Returns true if there is an http server responding at http://localhost:port on the532// given compute server.533async function isHttpServerResponding({534project_id,535compute_server_id,536target,537maxTimeS = 5,538}) {539const command = `curl --silent --fail --max-time ${maxTimeS} ${target} >/dev/null; echo $?`;540const { stdout } = await webapp_client.exec({541filesystem: false,542compute_server_id,543project_id,544command,545err_on_exit: false,546});547return stdout.trim() == "0";548}549550551