Skip to content

Commit a64e6d6

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

File tree

3 files changed

+189
-0
lines changed

3 files changed

+189
-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: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
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 React, { useState } from "react";
8+
import Modal from "../components/Modal";
9+
import Tooltip from "../components/Tooltip";
10+
import copy from "../images/copy.svg";
11+
import TabMenuItem from "../components/TabMenuItem";
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 SSHManualView(props: SSHProps) {
57+
return (
58+
<>
59+
<div className="mt-1 mb-2">
60+
<p className="text-gray-500 whitespace-normal text-base">
61+
The following args can be used with SSH to connect to this workspace.
62+
</p>
63+
</div>
64+
<div className="flex flex-col">
65+
<div className="mt-2">
66+
<h4>Host</h4>
67+
<InputWithCopy value={props.ideUrl} />
68+
</div>
69+
<div className="mt-2">
70+
<h4>Username</h4>
71+
<InputWithCopy value={props.workspaceId} />
72+
</div>
73+
<div className="mt-2">
74+
<h4>Password</h4>
75+
<InputWithCopy value={props.ownerToken} />
76+
</div>
77+
</div>
78+
</>
79+
);
80+
}
81+
82+
function SSHPassView(props: SSHProps) {
83+
const sshCommand = `sshpass -p ${props.ownerToken} ssh -o StrictHostKeyChecking=no ${props.workspaceId}@${props.ideUrl}`;
84+
return (
85+
<>
86+
<div className="mt-1 mb-2">
87+
<p className="text-gray-500 whitespace-normal text-base">
88+
The following shell command can be used to SSH into this workspace.
89+
</p>
90+
</div>
91+
<InputWithCopy value={sshCommand} tip="Copy SSH Command" />
92+
<div className="mt-2">
93+
DOCUMENT ABOUT <strong>sshpass</strong> NEEDED
94+
</div>
95+
</>
96+
);
97+
}
98+
99+
export default function ConnectToSSHModal(props: {
100+
workspaceId: string;
101+
ownerToken: string;
102+
ideUrl: string;
103+
onClose: () => void;
104+
}) {
105+
interface MenuItem {
106+
key: string;
107+
component: React.ReactChild;
108+
}
109+
const menuList: MenuItem[] = [
110+
{
111+
key: "manual",
112+
component: (
113+
<SSHManualView workspaceId={props.workspaceId} ownerToken={props.ownerToken} ideUrl={props.ideUrl} />
114+
),
115+
},
116+
{
117+
key: "sshpass",
118+
component: (
119+
<SSHPassView workspaceId={props.workspaceId} ownerToken={props.ownerToken} ideUrl={props.ideUrl} />
120+
),
121+
},
122+
];
123+
124+
const [selectMenu, setSelectMenu] = useState("manual");
125+
return (
126+
<Modal visible={true} onClose={props.onClose}>
127+
<h3 className="mb-4">Connect via SSH</h3>
128+
<>
129+
<nav className="flex border-b border-gray-200 dark:border-gray-800 mb-4">
130+
{menuList.map(({ key }) => (
131+
<TabMenuItem
132+
name={key}
133+
selected={selectMenu === key}
134+
onClick={() => setSelectMenu(key)}
135+
></TabMenuItem>
136+
))}
137+
</nav>
138+
{menuList.find((menu) => menu.key === selectMenu)?.component}
139+
</>
140+
<div className="flex justify-end mt-6">
141+
<button className={"ml-2 secondary"} onClick={() => props.onClose()}>
142+
Close
143+
</button>
144+
</div>
145+
</Modal>
146+
);
147+
}

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)