Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
gitpod-io
GitHub Repository: gitpod-io/gitpod
Path: blob/main/components/dashboard/src/start/StartWorkspace.tsx
2500 views
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 { DisposableCollection, RateLimiterError, WorkspaceImageBuild } from "@gitpod/gitpod-protocol";
8
import { IDEOptions } from "@gitpod/gitpod-protocol/lib/ide-protocol";
9
import { ErrorCodes } from "@gitpod/gitpod-protocol/lib/messaging/error";
10
import EventEmitter from "events";
11
import * as queryString from "query-string";
12
import React, { Suspense, useEffect, useMemo } from "react";
13
import { v4 } from "uuid";
14
import Arrow from "../components/Arrow";
15
import ContextMenu from "../components/ContextMenu";
16
import PendingChangesDropdown from "../components/PendingChangesDropdown";
17
import PrebuildLogs from "../components/PrebuildLogs";
18
import { getGitpodService, gitpodHostUrl, getIDEFrontendService, IDEFrontendService } from "../service/service";
19
import { StartPage, StartPhase, StartWorkspaceError } from "./StartPage";
20
import ConnectToSSHModal from "../workspaces/ConnectToSSHModal";
21
import Alert from "../components/Alert";
22
import { workspaceClient } from "../service/public-api";
23
import {
24
WatchWorkspaceStatusPriority,
25
watchWorkspaceStatusInOrder,
26
} from "../data/workspaces/listen-to-workspace-ws-messages2";
27
import { Button } from "@podkit/buttons/Button";
28
import {
29
GetWorkspaceRequest,
30
StartWorkspaceRequest,
31
StartWorkspaceResponse,
32
Workspace,
33
WorkspacePhase_Phase,
34
WorkspaceSpec_WorkspaceType,
35
} from "@gitpod/public-api/lib/gitpod/v1/workspace_pb";
36
import { PartialMessage } from "@bufbuild/protobuf";
37
import { trackEvent } from "../Analytics";
38
import { fromWorkspaceName } from "../workspaces/RenameWorkspaceModal";
39
import { LinkButton } from "@podkit/buttons/LinkButton";
40
41
const sessionId = v4();
42
43
const WorkspaceLogs = React.lazy(() => import("../components/WorkspaceLogs"));
44
45
export interface StartWorkspaceProps {
46
workspaceId: string;
47
runsInIFrame: boolean;
48
/**
49
* This flag is used to break the autostart-cycle explained in https://github.com/gitpod-io/gitpod/issues/8043
50
*/
51
dontAutostart: boolean;
52
}
53
54
export function parseProps(workspaceId: string, search?: string): StartWorkspaceProps {
55
const params = parseParameters(search);
56
const runsInIFrame = window.top !== window.self;
57
return {
58
workspaceId,
59
runsInIFrame,
60
// Either:
61
// - not_found: we were sent back from a workspace cluster/IDE URL where we expected a workspace to be running but it wasn't because either:
62
// - this is a (very) old tab and the workspace already timed out
63
// - due to a start error our workspace terminated very quickly between:
64
// a) us being redirected to that IDEUrl (based on the first ws-manager update) and
65
// b) our requests being validated by ws-proxy
66
// - runsInIFrame (IDE case):
67
// - we assume the workspace has already been started for us
68
// - we don't know it's instanceId
69
dontAutostart: params.notFound || runsInIFrame,
70
};
71
}
72
73
function parseParameters(search?: string): { notFound?: boolean } {
74
try {
75
if (search === undefined) {
76
return {};
77
}
78
const params = queryString.parse(search, { parseBooleans: true });
79
const notFound = !!(params && params["not_found"]);
80
return {
81
notFound,
82
};
83
} catch (err) {
84
console.error("/start: error parsing search params", err);
85
return {};
86
}
87
}
88
89
export interface StartWorkspaceState {
90
/**
91
* This is set to the instanceId we started (think we started on).
92
* We only receive updates for this particular instance, or none if not set.
93
*/
94
startedInstanceId?: string;
95
workspace?: Workspace;
96
hasImageBuildLogs?: boolean;
97
error?: StartWorkspaceError;
98
desktopIde?: {
99
link: string;
100
label: string;
101
clientID?: string;
102
};
103
ideOptions?: IDEOptions;
104
isSSHModalVisible?: boolean;
105
ownerToken?: string;
106
/**
107
* Set to prevent multiple redirects to the same URL when the User Agent ignores our wish to open links in the same tab (by setting window.location.href).
108
*/
109
redirected?: boolean;
110
/**
111
* Determines whether `redirected` has been `true` for long enough to display our "new tab" info banner without racing with same-tab redirection in regular setups
112
*/
113
showRedirectMessage?: boolean;
114
}
115
116
// TODO: use Function Components
117
export default class StartWorkspace extends React.Component<StartWorkspaceProps, StartWorkspaceState> {
118
private ideFrontendService: IDEFrontendService | undefined;
119
120
constructor(props: StartWorkspaceProps) {
121
super(props);
122
this.state = {};
123
}
124
125
private readonly toDispose = new DisposableCollection();
126
componentWillMount() {
127
if (this.props.runsInIFrame) {
128
this.ideFrontendService = getIDEFrontendService(this.props.workspaceId, sessionId, getGitpodService());
129
this.toDispose.push(
130
this.ideFrontendService.onSetState((data) => {
131
if (data.ideFrontendFailureCause) {
132
const error = { message: data.ideFrontendFailureCause };
133
this.setState({ error });
134
}
135
if (data.desktopIDE?.link) {
136
const label = data.desktopIDE.label || "Open Desktop IDE";
137
const clientID = data.desktopIDE.clientID;
138
const link = data.desktopIDE?.link;
139
this.setState({ desktopIde: { link, label, clientID } });
140
}
141
}),
142
);
143
}
144
145
try {
146
const watchDispose = watchWorkspaceStatusInOrder(
147
this.props.workspaceId,
148
WatchWorkspaceStatusPriority.StartWorkspacePage,
149
async (resp) => {
150
if (resp.workspaceId !== this.props.workspaceId || !resp.status) {
151
return;
152
}
153
await this.onWorkspaceUpdate(
154
new Workspace({
155
...this.state.workspace,
156
// NOTE: this.state.workspace might be undefined here, leaving Workspace.id, Workspace.metadata and Workspace.spec undefined empty.
157
// Thus we:
158
// - fill in ID
159
// - wait for fetchWorkspaceInfo to fill in metadata and spec in later render cycles
160
id: resp.workspaceId,
161
status: resp.status,
162
}),
163
);
164
// wait for next frame
165
await new Promise((resolve) => setTimeout(resolve, 0));
166
},
167
);
168
this.toDispose.push(watchDispose);
169
this.toDispose.push(
170
getGitpodService().registerClient({
171
notifyDidOpenConnection: () => this.fetchWorkspaceInfo(undefined),
172
}),
173
);
174
} catch (error) {
175
console.error(error);
176
this.setState({ error });
177
}
178
179
if (this.props.dontAutostart) {
180
// we saw errors previously, or run in-frame
181
this.fetchWorkspaceInfo(undefined);
182
} else {
183
// dashboard case (w/o previous errors): start workspace as quickly as possible
184
this.startWorkspace();
185
}
186
187
// query IDE options so we can show them if necessary once the workspace is running
188
this.fetchIDEOptions();
189
}
190
191
componentWillUnmount() {
192
this.toDispose.dispose();
193
}
194
195
componentDidUpdate(_prevProps: StartWorkspaceProps, prevState: StartWorkspaceState) {
196
const newPhase = this.state?.workspace?.status?.phase?.name;
197
const oldPhase = prevState.workspace?.status?.phase?.name;
198
const type = this.state.workspace?.spec?.type === WorkspaceSpec_WorkspaceType.PREBUILD ? "prebuild" : "regular";
199
if (newPhase !== oldPhase) {
200
trackEvent("status_rendered", {
201
sessionId,
202
instanceId: this.state.workspace?.status?.instanceId,
203
workspaceId: this.props.workspaceId,
204
type,
205
phase: newPhase ? WorkspacePhase_Phase[newPhase] : undefined,
206
});
207
}
208
209
if (!!this.state.error && this.state.error !== prevState.error) {
210
trackEvent("error_rendered", {
211
sessionId,
212
instanceId: this.state.workspace?.status?.instanceId,
213
workspaceId: this.props.workspaceId,
214
type,
215
error: this.state.error,
216
});
217
}
218
}
219
220
async startWorkspace(restart = false, forceDefaultConfig = false) {
221
const state = this.state;
222
if (state) {
223
if (!restart && state.startedInstanceId /* || state.errorMessage */) {
224
// We stick with a started instance until we're explicitly told not to
225
return;
226
}
227
}
228
229
const { workspaceId } = this.props;
230
try {
231
const result = await this.startWorkspaceRateLimited(workspaceId, { forceDefaultConfig });
232
if (!result) {
233
throw new Error("No result!");
234
}
235
console.log("/start: started workspace instance: " + result.workspace?.status?.instanceId);
236
237
// redirect to workspaceURL if we are not yet running in an iframe
238
if (!this.props.runsInIFrame && result.workspace?.status?.workspaceUrl) {
239
// before redirect, make sure we actually have the auth cookie set!
240
await this.ensureWorkspaceAuth(result.workspace.status.instanceId, true);
241
this.redirectTo(result.workspace.status.workspaceUrl);
242
return;
243
}
244
// TODO: Remove this once we use `useStartWorkspaceMutation`
245
// Start listening to instance updates - and explicitly query state once to guarantee we get at least one update
246
// (needed for already started workspaces, and not hanging in 'Starting ...' for too long)
247
this.fetchWorkspaceInfo(result.workspace?.status?.instanceId);
248
} catch (error) {
249
const normalizedError = typeof error === "string" ? { message: error } : error;
250
console.error(normalizedError);
251
252
if (normalizedError?.code === ErrorCodes.USER_BLOCKED) {
253
this.redirectTo(gitpodHostUrl.with({ pathname: "/blocked" }).toString());
254
return;
255
}
256
this.setState({ error: normalizedError });
257
}
258
}
259
260
/**
261
* TODO(gpl) Ideally this can be pushed into the GitpodService implementation. But to get started we hand-roll it here.
262
* @param workspaceId
263
* @param options
264
* @returns
265
*/
266
protected async startWorkspaceRateLimited(
267
workspaceId: string,
268
options: PartialMessage<StartWorkspaceRequest>,
269
): Promise<StartWorkspaceResponse> {
270
let retries = 0;
271
while (true) {
272
try {
273
// TODO: use `useStartWorkspaceMutation`
274
return await workspaceClient.startWorkspace({
275
...options,
276
workspaceId,
277
});
278
} catch (err) {
279
if (err?.code !== ErrorCodes.TOO_MANY_REQUESTS) {
280
throw err;
281
}
282
283
if (retries >= 10) {
284
throw err;
285
}
286
retries++;
287
288
const data = err?.data as RateLimiterError | undefined;
289
const timeoutSeconds = data?.retryAfter || 5;
290
console.log(
291
`startWorkspace was rate-limited: waiting for ${timeoutSeconds}s before doing ${retries}nd retry...`,
292
);
293
await new Promise((resolve) => setTimeout(resolve, timeoutSeconds * 1000));
294
}
295
}
296
}
297
298
/**
299
* Fetches initial WorkspaceInfo from the server. If there is a WorkspaceInstance for workspaceId, we feed it
300
* into "onInstanceUpdate" and start accepting further updates.
301
*
302
* @param startedInstanceId The instanceId we want to listen on
303
*/
304
async fetchWorkspaceInfo(startedInstanceId: string | undefined) {
305
// this ensures we're receiving updates for this instance
306
if (startedInstanceId) {
307
this.setState({ startedInstanceId });
308
}
309
310
const { workspaceId } = this.props;
311
try {
312
const request = new GetWorkspaceRequest();
313
request.workspaceId = workspaceId;
314
const response = await workspaceClient.getWorkspace(request);
315
if (response.workspace?.status?.instanceId) {
316
this.setState((s) => ({
317
workspace: response.workspace,
318
startedInstanceId: s.startedInstanceId || response.workspace?.status?.instanceId, // note: here's a potential mismatch between startedInstanceId and instance.id. TODO(gpl) How to handle this?
319
}));
320
this.onWorkspaceUpdate(response.workspace);
321
}
322
} catch (error) {
323
console.error(error);
324
this.setState({ error });
325
}
326
}
327
328
/**
329
* Fetches the current IDEOptions config for this user
330
*
331
* TODO(gpl) Ideally this would be part of the WorkspaceInstance shape, really. And we'd display options based on
332
* what support it was started with.
333
*/
334
protected async fetchIDEOptions() {
335
const ideOptions = await getGitpodService().server.getIDEOptions();
336
this.setState({ ideOptions });
337
}
338
339
private async onWorkspaceUpdate(workspace?: Workspace) {
340
if (!workspace?.status?.instanceId || !workspace.id) {
341
return;
342
}
343
// Here we filter out updates to instances we haven't started to avoid issues with updates coming in out-of-order
344
// (e.g., multiple "stopped" events from the older instance, where we already started a fresh one after the first)
345
// Only exception is when we do the switch from the "old" to the "new" one.
346
const startedInstanceId = this.state?.startedInstanceId;
347
if (startedInstanceId !== workspace.status.instanceId) {
348
const latestInfo = await workspaceClient.getWorkspace({ workspaceId: workspace.id });
349
const latestInstanceId = latestInfo.workspace?.status?.instanceId;
350
if (workspace.status.instanceId !== latestInstanceId) {
351
return;
352
}
353
// do we want to switch to "new" instance we just received an update for? Yes
354
this.setState({
355
startedInstanceId: workspace.status.instanceId,
356
workspace,
357
});
358
if (startedInstanceId) {
359
// now we're listening to a new instance, which might have been started with other IDEoptions
360
this.fetchIDEOptions();
361
}
362
}
363
364
await this.ensureWorkspaceAuth(workspace.status.instanceId, false); // Don't block the workspace auth retrieval, as it's guaranteed to get a seconds chance later on!
365
366
// Redirect to workspaceURL if we are not yet running in an iframe.
367
// It happens this late if we were waiting for a docker build.
368
if (
369
!this.props.runsInIFrame &&
370
workspace.status.workspaceUrl &&
371
(!this.props.dontAutostart || workspace.status.phase?.name === WorkspacePhase_Phase.RUNNING)
372
) {
373
(async () => {
374
// At this point we cannot be certain that we already have the relevant cookie in multi-cluster
375
// scenarios with distributed workspace bridges (control loops): We might receive the update, but the backend might not have the token, yet.
376
// So we have to ask again, and wait until we're actually successful (it returns immediately on the happy path)
377
await this.ensureWorkspaceAuth(workspace.status!.instanceId, true);
378
if (this.state.error && this.state.error?.code !== ErrorCodes.NOT_FOUND) {
379
return;
380
}
381
this.redirectTo(workspace.status!.workspaceUrl);
382
})().catch(console.error);
383
return;
384
}
385
386
if (workspace.status.phase?.name === WorkspacePhase_Phase.IMAGEBUILD) {
387
this.setState({ hasImageBuildLogs: true });
388
}
389
390
let error: StartWorkspaceError | undefined;
391
if (workspace.status.conditions?.failed) {
392
error = { message: workspace.status.conditions.failed };
393
}
394
395
// Successfully stopped and headless: the prebuild is done, let's try to use it!
396
if (
397
!error &&
398
workspace.status.phase?.name === WorkspacePhase_Phase.STOPPED &&
399
this.state.workspace?.spec?.type === WorkspaceSpec_WorkspaceType.PREBUILD
400
) {
401
// here we want to point to the original context, w/o any modifiers "workspace" was started with (as this might have been a manually triggered prebuild!)
402
const contextURL = this.state.workspace.metadata?.originalContextUrl;
403
if (contextURL) {
404
this.redirectTo(gitpodHostUrl.withContext(contextURL.toString()).toString());
405
} else {
406
console.error(`unable to parse contextURL: ${contextURL}`);
407
}
408
}
409
410
this.setState({ workspace, error });
411
}
412
413
async ensureWorkspaceAuth(instanceID: string, retry: boolean) {
414
const MAX_ATTEMPTS = 10;
415
const ATTEMPT_INTERVAL_MS = 2000;
416
let attempt = 0;
417
let fetchError: Error | undefined = undefined;
418
while (attempt <= MAX_ATTEMPTS) {
419
attempt++;
420
421
let code: number | undefined = undefined;
422
fetchError = undefined;
423
try {
424
const authURL = gitpodHostUrl.asWorkspaceAuth(instanceID);
425
const response = await fetch(authURL.toString());
426
code = response.status;
427
} catch (err) {
428
fetchError = err;
429
}
430
431
if (retry) {
432
if (code === 404 && !fetchError) {
433
fetchError = new Error("Unable to retrieve workspace-auth cookie (code: 404)");
434
}
435
if (fetchError) {
436
console.warn("Unable to retrieve workspace-auth cookie! Retrying shortly...", fetchError, {
437
instanceID,
438
code,
439
attempt,
440
});
441
// If the token is not there, we assume it will appear, soon: Retry a couple of times.
442
await new Promise((resolve) => setTimeout(resolve, ATTEMPT_INTERVAL_MS));
443
continue;
444
}
445
}
446
if (code !== 200) {
447
// getting workspace auth didn't work as planned
448
console.warn("Unable to retrieve workspace-auth cookie.", {
449
instanceID,
450
code,
451
attempt,
452
});
453
return;
454
}
455
456
// Response code is 200 at this point: done!
457
console.info("Retrieved workspace-auth cookie.", { instanceID, code, attempt });
458
return;
459
}
460
461
console.error("Unable to retrieve workspace-auth cookie! Giving up.", { instanceID, attempt });
462
463
if (fetchError) {
464
// To maintain prior behavior we bubble up this error to callers
465
throw fetchError;
466
}
467
}
468
469
redirectTo(url: string) {
470
if (this.state.redirected) {
471
console.info("Prevented another redirect", { url });
472
return;
473
}
474
if (this.props.runsInIFrame) {
475
this.ideFrontendService?.relocate(url);
476
} else {
477
window.location.href = url;
478
}
479
480
this.setState({ redirected: true });
481
setTimeout(() => {
482
this.setState({ showRedirectMessage: true });
483
}, 2000);
484
}
485
486
private openDesktopLink(link: string) {
487
this.ideFrontendService?.openDesktopIDE(link);
488
}
489
490
render() {
491
const { error } = this.state;
492
const isPrebuild = this.state.workspace?.spec?.type === WorkspaceSpec_WorkspaceType.PREBUILD;
493
let withPrebuild = false;
494
for (const initializer of this.state.workspace?.spec?.initializer?.specs ?? []) {
495
if (initializer.spec.case === "prebuild") {
496
withPrebuild = !!initializer.spec.value.prebuildId;
497
}
498
}
499
let phase: StartPhase | undefined = StartPhase.Preparing;
500
let title = undefined;
501
let isStoppingOrStoppedPhase = false;
502
let isError = error ? true : false;
503
let statusMessage = !!error ? undefined : <p className="text-base text-gray-400">Preparing workspace …</p>;
504
const contextURL = this.state.workspace?.metadata?.originalContextUrl;
505
const useLatest = this.state.workspace?.spec?.editor?.version === "latest";
506
507
switch (this.state?.workspace?.status?.phase?.name) {
508
// unknown indicates an issue within the system in that it cannot determine the actual phase of
509
// a workspace. This phase is usually accompanied by an error.
510
case WorkspacePhase_Phase.UNSPECIFIED:
511
break;
512
// Preparing means that we haven't actually started the workspace instance just yet, but rather
513
// are still preparing for launch.
514
case WorkspacePhase_Phase.PREPARING:
515
phase = StartPhase.Preparing;
516
statusMessage = <p className="text-base text-gray-400">Starting workspace …</p>;
517
break;
518
519
case WorkspacePhase_Phase.IMAGEBUILD:
520
// Building means we're building the Docker image for the workspace.
521
return <ImageBuildView workspaceId={this.state.workspace.id} />;
522
523
// Pending means the workspace does not yet consume resources in the cluster, but rather is looking for
524
// some space within the cluster. If for example the cluster needs to scale up to accommodate the
525
// workspace, the workspace will be in Pending state until that happened.
526
case WorkspacePhase_Phase.PENDING:
527
phase = StartPhase.Preparing;
528
statusMessage = <p className="text-base text-gray-400">Allocating resources …</p>;
529
break;
530
531
// Creating means the workspace is currently being created. That includes downloading the images required
532
// to run the workspace over the network. The time spent in this phase varies widely and depends on the current
533
// network speed, image size and cache states.
534
case WorkspacePhase_Phase.CREATING:
535
phase = StartPhase.Creating;
536
statusMessage = <p className="text-base text-gray-400">Pulling container image …</p>;
537
break;
538
539
// Initializing is the phase in which the workspace is executing the appropriate workspace initializer (e.g. Git
540
// clone or backup download). After this phase one can expect the workspace to either be Running or Failed.
541
case WorkspacePhase_Phase.INITIALIZING:
542
phase = StartPhase.Starting;
543
statusMessage = (
544
<p className="text-base text-gray-400">
545
{withPrebuild ? "Loading prebuild …" : "Initializing content …"}
546
</p>
547
);
548
break;
549
550
// Running means the workspace is able to actively perform work, either by serving a user through Theia,
551
// or as a headless workspace.
552
case WorkspacePhase_Phase.RUNNING:
553
if (isPrebuild) {
554
return (
555
<StartPage title="Prebuild in Progress" workspaceId={this.props.workspaceId}>
556
<div className="mt-6 w-11/12 lg:w-3/5">
557
{/* TODO(gpl) These classes are copied around in Start-/CreateWorkspace. This should properly go somewhere central. */}
558
<PrebuildLogs workspaceId={this.props.workspaceId} />
559
</div>
560
</StartPage>
561
);
562
}
563
if (!this.state.desktopIde) {
564
phase = StartPhase.Running;
565
statusMessage = <p className="text-base text-gray-400">Opening Workspace …</p>;
566
} else {
567
phase = StartPhase.IdeReady;
568
const openLink = this.state.desktopIde.link;
569
const openLinkLabel = this.state.desktopIde.label;
570
const clientID = this.state.desktopIde.clientID;
571
const client = clientID ? this.state.ideOptions?.clients?.[clientID] : undefined;
572
const installationSteps = client?.installationSteps?.length && (
573
<div className="flex flex-col text-center m-auto text-sm w-72 text-gray-400">
574
{client.installationSteps.map((step) => (
575
<div
576
key={step}
577
dangerouslySetInnerHTML={{
578
// eslint-disable-next-line no-template-curly-in-string
579
__html: step.replaceAll("${OPEN_LINK_LABEL}", openLinkLabel),
580
}}
581
/>
582
))}
583
</div>
584
);
585
statusMessage = (
586
<div>
587
<p className="text-base text-gray-400">Opening Workspace …</p>
588
<div className="flex space-x-3 items-center text-left rounded-xl m-auto px-4 h-16 w-72 mt-4 mb-2 bg-pk-surface-secondary">
589
<div className="rounded-full w-3 h-3 text-sm bg-green-500">&nbsp;</div>
590
<div>
591
<p className="text-gray-700 dark:text-gray-200 font-semibold w-56 truncate">
592
{fromWorkspaceName(this.state.workspace) || this.state.workspace.id}
593
</p>
594
<a target="_parent" href={contextURL}>
595
<p className="w-56 truncate hover:text-blue-600 dark:hover:text-blue-400">
596
{contextURL}
597
</p>
598
</a>
599
</div>
600
</div>
601
{installationSteps}
602
<div className="mt-10 justify-center flex space-x-2">
603
<ContextMenu
604
menuEntries={[
605
{
606
title: "Open in Browser",
607
onClick: () => {
608
this.ideFrontendService?.openBrowserIDE();
609
},
610
},
611
{
612
title: "Stop Workspace",
613
onClick: () =>
614
workspaceClient.stopWorkspace({ workspaceId: this.props.workspaceId }),
615
},
616
{
617
title: "Connect via SSH",
618
onClick: async () => {
619
const response = await workspaceClient.getWorkspaceOwnerToken({
620
workspaceId: this.props.workspaceId,
621
});
622
this.setState({
623
isSSHModalVisible: true,
624
ownerToken: response.ownerToken,
625
});
626
},
627
},
628
{
629
title: "Go to Dashboard",
630
href: gitpodHostUrl.asWorkspacePage().toString(),
631
target: "_parent",
632
},
633
]}
634
>
635
<Button variant="secondary">
636
More Actions...
637
<Arrow direction={"down"} />
638
</Button>
639
</ContextMenu>
640
<Button onClick={() => this.openDesktopLink(openLink)}>{openLinkLabel}</Button>
641
</div>
642
{!useLatest && (
643
<Alert type="info" className="mt-4 w-96">
644
You can change the default editor for opening workspaces in{" "}
645
<a
646
className="gp-link"
647
target="_blank"
648
rel="noreferrer"
649
href={gitpodHostUrl.asPreferences().toString()}
650
>
651
user preferences
652
</a>
653
.
654
</Alert>
655
)}
656
{this.state.isSSHModalVisible === true && this.state.ownerToken && (
657
<ConnectToSSHModal
658
workspaceId={this.props.workspaceId}
659
ownerToken={this.state.ownerToken}
660
ideUrl={this.state.workspace.status.workspaceUrl.replaceAll("https://", "")}
661
onClose={() => this.setState({ isSSHModalVisible: false, ownerToken: "" })}
662
/>
663
)}
664
</div>
665
);
666
}
667
668
break;
669
670
// Interrupted is an exceptional state where the container should be running but is temporarily unavailable.
671
// When in this state, we expect it to become running or stopping anytime soon.
672
case WorkspacePhase_Phase.INTERRUPTED:
673
phase = StartPhase.Running;
674
statusMessage = <p className="text-base text-gray-400">Checking workspace …</p>;
675
break;
676
677
// Stopping means that the workspace is currently shutting down. It could go to stopped every moment.
678
case WorkspacePhase_Phase.STOPPING:
679
isStoppingOrStoppedPhase = true;
680
if (isPrebuild) {
681
return (
682
<StartPage title="Prebuild in Progress" workspaceId={this.props.workspaceId}>
683
<div className="mt-6 w-11/12 lg:w-3/5">
684
{/* TODO(gpl) These classes are copied around in Start-/CreateWorkspace. This should properly go somewhere central. */}
685
<PrebuildLogs workspaceId={this.props.workspaceId} />
686
</div>
687
</StartPage>
688
);
689
}
690
phase = StartPhase.Stopping;
691
statusMessage = (
692
<div>
693
<div className="flex space-x-3 items-center text-left rounded-xl m-auto px-4 h-16 w-72 mt-4 bg-pk-surface-secondary">
694
<div className="rounded-full w-3 h-3 text-sm bg-kumquat-ripe">&nbsp;</div>
695
<div>
696
<p className="text-gray-700 dark:text-gray-200 font-semibold w-56 truncate">
697
{fromWorkspaceName(this.state.workspace) || this.state.workspace.id}
698
</p>
699
<a target="_parent" href={contextURL}>
700
<p className="w-56 truncate hover:text-blue-600 dark:hover:text-blue-400">
701
{contextURL}
702
</p>
703
</a>
704
</div>
705
</div>
706
<div className="mt-10 flex justify-center">
707
<a target="_parent" href={gitpodHostUrl.asWorkspacePage().toString()}>
708
<Button variant="secondary">Go to Dashboard</Button>
709
</a>
710
</div>
711
</div>
712
);
713
break;
714
715
// Stopped means the workspace ended regularly because it was shut down.
716
case WorkspacePhase_Phase.STOPPED:
717
isStoppingOrStoppedPhase = true;
718
phase = StartPhase.Stopped;
719
if (this.state.hasImageBuildLogs) {
720
const restartWithDefaultImage = (event: React.MouseEvent) => {
721
(event.target as HTMLButtonElement).disabled = true;
722
this.startWorkspace(true, true);
723
};
724
return (
725
<ImageBuildView
726
workspaceId={this.state.workspace.id}
727
onStartWithDefaultImage={restartWithDefaultImage}
728
phase={phase}
729
error={error}
730
/>
731
);
732
}
733
if (!isPrebuild && this.state.workspace.status.conditions?.timeout) {
734
title = "Timed Out";
735
}
736
statusMessage = (
737
<div>
738
<div className="flex space-x-3 items-center text-left rounded-xl m-auto px-4 h-16 w-72 mt-4 mb-2 bg-pk-surface-secondary">
739
<div className="rounded-full w-3 h-3 text-sm bg-gray-300">&nbsp;</div>
740
<div>
741
<p className="text-gray-700 dark:text-gray-200 font-semibold w-56 truncate">
742
{fromWorkspaceName(this.state.workspace) || this.state.workspace.id}
743
</p>
744
<a target="_parent" href={contextURL}>
745
<p className="w-56 truncate hover:text-blue-600 dark:hover:text-blue-400">
746
{contextURL}
747
</p>
748
</a>
749
</div>
750
</div>
751
<PendingChangesDropdown
752
gitStatus={this.state.workspace.status.gitStatus}
753
className="justify-center"
754
/>
755
<div className="mt-10 justify-center flex space-x-2">
756
<a target="_parent" href={gitpodHostUrl.asWorkspacePage().toString()}>
757
<Button variant="secondary">Go to Dashboard</Button>
758
</a>
759
<a target="_parent" href={gitpodHostUrl.asStart(this.state.workspace.id).toString()}>
760
<Button>Open Workspace</Button>
761
</a>
762
</div>
763
</div>
764
);
765
break;
766
}
767
768
return (
769
<StartPage
770
phase={phase}
771
error={error}
772
title={title}
773
showLatestIdeWarning={useLatest && (isError || !isStoppingOrStoppedPhase)}
774
workspaceId={this.props.workspaceId}
775
>
776
{statusMessage}
777
{this.state.showRedirectMessage && (
778
<>
779
<Alert type="info" className="mt-4 w-112">
780
We redirected you to your workspace, but your browser probably opened it in another tab.
781
</Alert>
782
783
<div className="mt-4 justify-center flex space-x-2">
784
<LinkButton href={gitpodHostUrl.asWorkspacePage().toString()} target="_self" isExternalUrl>
785
Go to Dashboard
786
</LinkButton>
787
{this.state.workspace?.status?.workspaceUrl &&
788
this.state.workspace.status.phase?.name === WorkspacePhase_Phase.RUNNING && (
789
<LinkButton
790
variant={"secondary"}
791
href={this.state.workspace.status.workspaceUrl}
792
target="_self"
793
isExternalUrl
794
>
795
Re-open Workspace
796
</LinkButton>
797
)}
798
</div>
799
</>
800
)}
801
</StartPage>
802
);
803
}
804
}
805
806
interface ImageBuildViewProps {
807
workspaceId: string;
808
onStartWithDefaultImage?: (event: React.MouseEvent) => void;
809
phase?: StartPhase;
810
error?: StartWorkspaceError;
811
}
812
813
function ImageBuildView(props: ImageBuildViewProps) {
814
const logsEmitter = useMemo(() => new EventEmitter(), []);
815
816
useEffect(() => {
817
let registered = false;
818
const watchBuild = () => {
819
if (registered) {
820
return;
821
}
822
registered = true;
823
824
getGitpodService()
825
.server.watchWorkspaceImageBuildLogs(props.workspaceId)
826
.catch((err) => {
827
registered = false;
828
if (err?.code === ErrorCodes.HEADLESS_LOG_NOT_YET_AVAILABLE) {
829
// wait, and then retry
830
setTimeout(watchBuild, 5000);
831
}
832
});
833
};
834
watchBuild();
835
836
const toDispose = getGitpodService().registerClient({
837
notifyDidOpenConnection: () => {
838
registered = false; // new connection, we're not registered anymore
839
watchBuild();
840
},
841
onWorkspaceImageBuildLogs: (
842
info: WorkspaceImageBuild.StateInfo,
843
content?: WorkspaceImageBuild.LogContent,
844
) => {
845
if (!content?.data) {
846
return;
847
}
848
const chunk = new Uint8Array(content.data);
849
logsEmitter.emit("logs", chunk);
850
},
851
});
852
853
return function cleanup() {
854
toDispose.dispose();
855
};
856
// eslint-disable-next-line react-hooks/exhaustive-deps
857
}, []);
858
859
return (
860
<StartPage title="Building Image" phase={props.phase} workspaceId={props.workspaceId}>
861
<Suspense fallback={<div />}>
862
<WorkspaceLogs taskId="image-build" logsEmitter={logsEmitter} errorMessage={props.error?.message} />
863
</Suspense>
864
{!!props.onStartWithDefaultImage && (
865
<>
866
<div className="mt-6 w-11/12 lg:w-3/5">
867
<p className="text-center text-gray-400 dark:text-gray-500">
868
💡 You can use the <code>gp validate</code> command to validate the workspace configuration
869
from the editor terminal. &nbsp;
870
<a
871
href="https://www.gitpod.io/docs/configure/workspaces#validate-your-gitpod-configuration"
872
target="_blank"
873
rel="noopener noreferrer"
874
className="gp-link"
875
>
876
Learn More
877
</a>
878
</p>
879
</div>
880
<Button variant="secondary" className="mt-6" onClick={props.onStartWithDefaultImage}>
881
Continue with Default Image
882
</Button>
883
</>
884
)}
885
</StartPage>
886
);
887
}
888
889