Skip to content

Commit f2f46a5

Browse files
committed
[dashboard] support connect via SSH
1 parent 686a286 commit f2f46a5

File tree

3 files changed

+133
-0
lines changed

3 files changed

+133
-0
lines changed

components/dashboard/src/start/StartWorkspace.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import PendingChangesDropdown from "../components/PendingChangesDropdown";
2727
import { watchHeadlessLogs } from "../components/PrebuildLogs";
2828
import { getGitpodService, gitpodHostUrl } from "../service/service";
2929
import { StartPage, StartPhase, StartWorkspaceError } from "./StartPage";
30+
import ConnectToSSHModal from "../workspaces/ConnectToSSHModal";
3031
const sessionId = v4();
3132

3233
const WorkspaceLogs = React.lazy(() => import("../components/WorkspaceLogs"));
@@ -91,6 +92,8 @@ export interface StartWorkspaceState {
9192
clientID?: string;
9293
};
9394
ideOptions?: IDEOptions;
95+
isSSHModalVisible?: boolean;
96+
ownerToken?: string;
9497
}
9598

9699
export default class StartWorkspace extends React.Component<StartWorkspaceProps, StartWorkspaceState> {
@@ -519,6 +522,15 @@ export default class StartWorkspace extends React.Component<StartWorkspaceProps,
519522
onClick: () =>
520523
getGitpodService().server.stopWorkspace(this.props.workspaceId),
521524
},
525+
{
526+
title: "Connect via SSH",
527+
onClick: async () => {
528+
const ownerToken = await getGitpodService().server.getOwnerToken(
529+
this.props.workspaceId,
530+
);
531+
this.setState({ isSSHModalVisible: true, ownerToken });
532+
},
533+
},
522534
{
523535
title: "Go to Dashboard",
524536
href: gitpodHostUrl.asDashboard().toString(),
@@ -556,6 +568,14 @@ export default class StartWorkspace extends React.Component<StartWorkspaceProps,
556568
</a>
557569
.
558570
</div>
571+
{this.state.isSSHModalVisible === true && this.state.ownerToken && (
572+
<ConnectToSSHModal
573+
workspaceId={this.props.workspaceId}
574+
ownerToken={this.state.ownerToken}
575+
ideUrl={this.state.workspaceInstance?.ideUrl.replaceAll("https://", "")}
576+
onClose={() => this.setState({ isSSHModalVisible: false, ownerToken: "" })}
577+
/>
578+
)}
559579
</div>
560580
);
561581
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
/**
2+
* Copyright (c) 2021 Gitpod GmbH. All rights reserved.
3+
* Licensed under the GNU Affero General Public License (AGPL).
4+
* See License-AGPL.txt in the project root for license information.
5+
*/
6+
7+
import { useState } from "react";
8+
import Modal from "../components/Modal";
9+
import Tooltip from "../components/Tooltip";
10+
import copy from "../images/copy.svg";
11+
import CodeText from "../components/CodeText";
12+
13+
function InputWithCopy(props: { value: string; tip?: string; className?: string }) {
14+
const [copied, setCopied] = useState<boolean>(false);
15+
const copyToClipboard = (text: string) => {
16+
const el = document.createElement("textarea");
17+
el.value = text;
18+
document.body.appendChild(el);
19+
el.select();
20+
try {
21+
document.execCommand("copy");
22+
} finally {
23+
document.body.removeChild(el);
24+
}
25+
setCopied(true);
26+
setTimeout(() => setCopied(false), 2000);
27+
};
28+
const tip = props.tip ?? "Click to copy";
29+
return (
30+
<div className={`w-full relative ${props.className ?? ""}`}>
31+
<input
32+
disabled={true}
33+
readOnly={true}
34+
autoFocus
35+
className="w-full pr-8 overscroll-none"
36+
type="text"
37+
defaultValue={props.value}
38+
/>
39+
<div className="cursor-pointer" onClick={() => copyToClipboard(props.value)}>
40+
<div className="absolute top-1/3 right-3">
41+
<Tooltip content={copied ? "Copied!" : tip}>
42+
<img src={copy} alt="copy icon" title={tip} />
43+
</Tooltip>
44+
</div>
45+
</div>
46+
</div>
47+
);
48+
}
49+
50+
interface SSHProps {
51+
workspaceId: string;
52+
ownerToken: string;
53+
ideUrl: string;
54+
}
55+
56+
function SSHView(props: SSHProps) {
57+
const sshCommand = `ssh ${props.workspaceId}#${props.ownerToken}@${props.ideUrl}`;
58+
return (
59+
<>
60+
<div className="mt-1 mb-2">
61+
<p className="text-gray-500 whitespace-normal text-base">
62+
The following shell command can be used to SSH into this workspace.
63+
<br />
64+
If you encounter a prompt to enter a password, pleaase make sure you have a private key in&nbsp;
65+
<CodeText>~/.ssh/id_rsa</CodeText>. You can use command&nbsp;<CodeText>ssh-keygen</CodeText>&nbsp;to
66+
generate one.
67+
</p>
68+
</div>
69+
<InputWithCopy value={sshCommand} tip="Copy SSH Command" />
70+
</>
71+
);
72+
}
73+
74+
export default function ConnectToSSHModal(props: {
75+
workspaceId: string;
76+
ownerToken: string;
77+
ideUrl: string;
78+
onClose: () => void;
79+
}) {
80+
return (
81+
<Modal visible={true} onClose={props.onClose}>
82+
<h3 className="mb-4">Connect via SSH</h3>
83+
<SSHView workspaceId={props.workspaceId} ownerToken={props.ownerToken} ideUrl={props.ideUrl} />
84+
<div className="flex justify-end mt-6">
85+
<button className={"ml-2 secondary"} onClick={() => props.onClose()}>
86+
Close
87+
</button>
88+
</div>
89+
</Modal>
90+
);
91+
}

components/dashboard/src/workspaces/WorkspaceEntry.tsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import PendingChangesDropdown from "../components/PendingChangesDropdown";
2424
import Tooltip from "../components/Tooltip";
2525
import { WorkspaceModel } from "./workspace-model";
2626
import { getGitpodService } from "../service/service";
27+
import ConnectToSSHModal from "./ConnectToSSHModal";
2728

2829
function getLabel(state: WorkspaceInstancePhase, conditions?: WorkspaceInstanceConditions) {
2930
if (conditions?.failed) {
@@ -42,13 +43,15 @@ interface Props {
4243
export function WorkspaceEntry({ desc, model, isAdmin, stopWorkspace }: Props) {
4344
const [isDeleteModalVisible, setDeleteModalVisible] = useState(false);
4445
const [isRenameModalVisible, setRenameModalVisible] = useState(false);
46+
const [isSSHModalVisible, setSSHModalVisible] = useState(false);
4547
const renameInputRef = useRef<HTMLInputElement>(null);
4648
const [errorMessage, setErrorMessage] = useState("");
4749
const state: WorkspaceInstancePhase = desc.latestInstance?.status?.phase || "stopped";
4850
const currentBranch =
4951
desc.latestInstance?.status.repo?.branch || Workspace.getBranchName(desc.workspace) || "<unknown>";
5052
const ws = desc.workspace;
5153
const [workspaceDescription, setWsDescription] = useState(ws.description);
54+
const [ownerToken, setOwnerToken] = useState("");
5255

5356
const startUrl = new GitpodHostUrl(window.location.href).with({
5457
pathname: "/start/",
@@ -77,6 +80,14 @@ export function WorkspaceEntry({ desc, model, isAdmin, stopWorkspace }: Props) {
7780
title: "Stop",
7881
onClick: () => stopWorkspace(ws.id),
7982
});
83+
menuEntries.splice(1, 0, {
84+
title: "Connect via SSH",
85+
onClick: async () => {
86+
const ot = await getGitpodService().server.getOwnerToken(ws.id);
87+
setOwnerToken(ot);
88+
setSSHModalVisible(true);
89+
},
90+
});
8091
}
8192
menuEntries.push({
8293
title: "Download",
@@ -234,6 +245,17 @@ export function WorkspaceEntry({ desc, model, isAdmin, stopWorkspace }: Props) {
234245
</button>
235246
</div>
236247
</Modal>
248+
{isSSHModalVisible && desc.latestInstance && ownerToken !== "" && (
249+
<ConnectToSSHModal
250+
workspaceId={ws.id}
251+
ownerToken={ownerToken}
252+
ideUrl={desc.latestInstance.ideUrl.replaceAll("https://", "")}
253+
onClose={() => {
254+
setSSHModalVisible(false);
255+
setOwnerToken("");
256+
}}
257+
/>
258+
)}
237259
</Item>
238260
);
239261
}

0 commit comments

Comments
 (0)