Path: blob/main/components/dashboard/src/start/StartWorkspace.tsx
2500 views
/**1* Copyright (c) 2021 Gitpod GmbH. All rights reserved.2* Licensed under the GNU Affero General Public License (AGPL).3* See License.AGPL.txt in the project root for license information.4*/56import { DisposableCollection, RateLimiterError, WorkspaceImageBuild } from "@gitpod/gitpod-protocol";7import { IDEOptions } from "@gitpod/gitpod-protocol/lib/ide-protocol";8import { ErrorCodes } from "@gitpod/gitpod-protocol/lib/messaging/error";9import EventEmitter from "events";10import * as queryString from "query-string";11import React, { Suspense, useEffect, useMemo } from "react";12import { v4 } from "uuid";13import Arrow from "../components/Arrow";14import ContextMenu from "../components/ContextMenu";15import PendingChangesDropdown from "../components/PendingChangesDropdown";16import PrebuildLogs from "../components/PrebuildLogs";17import { getGitpodService, gitpodHostUrl, getIDEFrontendService, IDEFrontendService } from "../service/service";18import { StartPage, StartPhase, StartWorkspaceError } from "./StartPage";19import ConnectToSSHModal from "../workspaces/ConnectToSSHModal";20import Alert from "../components/Alert";21import { workspaceClient } from "../service/public-api";22import {23WatchWorkspaceStatusPriority,24watchWorkspaceStatusInOrder,25} from "../data/workspaces/listen-to-workspace-ws-messages2";26import { Button } from "@podkit/buttons/Button";27import {28GetWorkspaceRequest,29StartWorkspaceRequest,30StartWorkspaceResponse,31Workspace,32WorkspacePhase_Phase,33WorkspaceSpec_WorkspaceType,34} from "@gitpod/public-api/lib/gitpod/v1/workspace_pb";35import { PartialMessage } from "@bufbuild/protobuf";36import { trackEvent } from "../Analytics";37import { fromWorkspaceName } from "../workspaces/RenameWorkspaceModal";38import { LinkButton } from "@podkit/buttons/LinkButton";3940const sessionId = v4();4142const WorkspaceLogs = React.lazy(() => import("../components/WorkspaceLogs"));4344export interface StartWorkspaceProps {45workspaceId: string;46runsInIFrame: boolean;47/**48* This flag is used to break the autostart-cycle explained in https://github.com/gitpod-io/gitpod/issues/804349*/50dontAutostart: boolean;51}5253export function parseProps(workspaceId: string, search?: string): StartWorkspaceProps {54const params = parseParameters(search);55const runsInIFrame = window.top !== window.self;56return {57workspaceId,58runsInIFrame,59// Either:60// - 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:61// - this is a (very) old tab and the workspace already timed out62// - due to a start error our workspace terminated very quickly between:63// a) us being redirected to that IDEUrl (based on the first ws-manager update) and64// b) our requests being validated by ws-proxy65// - runsInIFrame (IDE case):66// - we assume the workspace has already been started for us67// - we don't know it's instanceId68dontAutostart: params.notFound || runsInIFrame,69};70}7172function parseParameters(search?: string): { notFound?: boolean } {73try {74if (search === undefined) {75return {};76}77const params = queryString.parse(search, { parseBooleans: true });78const notFound = !!(params && params["not_found"]);79return {80notFound,81};82} catch (err) {83console.error("/start: error parsing search params", err);84return {};85}86}8788export interface StartWorkspaceState {89/**90* This is set to the instanceId we started (think we started on).91* We only receive updates for this particular instance, or none if not set.92*/93startedInstanceId?: string;94workspace?: Workspace;95hasImageBuildLogs?: boolean;96error?: StartWorkspaceError;97desktopIde?: {98link: string;99label: string;100clientID?: string;101};102ideOptions?: IDEOptions;103isSSHModalVisible?: boolean;104ownerToken?: string;105/**106* 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).107*/108redirected?: boolean;109/**110* Determines whether `redirected` has been `true` for long enough to display our "new tab" info banner without racing with same-tab redirection in regular setups111*/112showRedirectMessage?: boolean;113}114115// TODO: use Function Components116export default class StartWorkspace extends React.Component<StartWorkspaceProps, StartWorkspaceState> {117private ideFrontendService: IDEFrontendService | undefined;118119constructor(props: StartWorkspaceProps) {120super(props);121this.state = {};122}123124private readonly toDispose = new DisposableCollection();125componentWillMount() {126if (this.props.runsInIFrame) {127this.ideFrontendService = getIDEFrontendService(this.props.workspaceId, sessionId, getGitpodService());128this.toDispose.push(129this.ideFrontendService.onSetState((data) => {130if (data.ideFrontendFailureCause) {131const error = { message: data.ideFrontendFailureCause };132this.setState({ error });133}134if (data.desktopIDE?.link) {135const label = data.desktopIDE.label || "Open Desktop IDE";136const clientID = data.desktopIDE.clientID;137const link = data.desktopIDE?.link;138this.setState({ desktopIde: { link, label, clientID } });139}140}),141);142}143144try {145const watchDispose = watchWorkspaceStatusInOrder(146this.props.workspaceId,147WatchWorkspaceStatusPriority.StartWorkspacePage,148async (resp) => {149if (resp.workspaceId !== this.props.workspaceId || !resp.status) {150return;151}152await this.onWorkspaceUpdate(153new Workspace({154...this.state.workspace,155// NOTE: this.state.workspace might be undefined here, leaving Workspace.id, Workspace.metadata and Workspace.spec undefined empty.156// Thus we:157// - fill in ID158// - wait for fetchWorkspaceInfo to fill in metadata and spec in later render cycles159id: resp.workspaceId,160status: resp.status,161}),162);163// wait for next frame164await new Promise((resolve) => setTimeout(resolve, 0));165},166);167this.toDispose.push(watchDispose);168this.toDispose.push(169getGitpodService().registerClient({170notifyDidOpenConnection: () => this.fetchWorkspaceInfo(undefined),171}),172);173} catch (error) {174console.error(error);175this.setState({ error });176}177178if (this.props.dontAutostart) {179// we saw errors previously, or run in-frame180this.fetchWorkspaceInfo(undefined);181} else {182// dashboard case (w/o previous errors): start workspace as quickly as possible183this.startWorkspace();184}185186// query IDE options so we can show them if necessary once the workspace is running187this.fetchIDEOptions();188}189190componentWillUnmount() {191this.toDispose.dispose();192}193194componentDidUpdate(_prevProps: StartWorkspaceProps, prevState: StartWorkspaceState) {195const newPhase = this.state?.workspace?.status?.phase?.name;196const oldPhase = prevState.workspace?.status?.phase?.name;197const type = this.state.workspace?.spec?.type === WorkspaceSpec_WorkspaceType.PREBUILD ? "prebuild" : "regular";198if (newPhase !== oldPhase) {199trackEvent("status_rendered", {200sessionId,201instanceId: this.state.workspace?.status?.instanceId,202workspaceId: this.props.workspaceId,203type,204phase: newPhase ? WorkspacePhase_Phase[newPhase] : undefined,205});206}207208if (!!this.state.error && this.state.error !== prevState.error) {209trackEvent("error_rendered", {210sessionId,211instanceId: this.state.workspace?.status?.instanceId,212workspaceId: this.props.workspaceId,213type,214error: this.state.error,215});216}217}218219async startWorkspace(restart = false, forceDefaultConfig = false) {220const state = this.state;221if (state) {222if (!restart && state.startedInstanceId /* || state.errorMessage */) {223// We stick with a started instance until we're explicitly told not to224return;225}226}227228const { workspaceId } = this.props;229try {230const result = await this.startWorkspaceRateLimited(workspaceId, { forceDefaultConfig });231if (!result) {232throw new Error("No result!");233}234console.log("/start: started workspace instance: " + result.workspace?.status?.instanceId);235236// redirect to workspaceURL if we are not yet running in an iframe237if (!this.props.runsInIFrame && result.workspace?.status?.workspaceUrl) {238// before redirect, make sure we actually have the auth cookie set!239await this.ensureWorkspaceAuth(result.workspace.status.instanceId, true);240this.redirectTo(result.workspace.status.workspaceUrl);241return;242}243// TODO: Remove this once we use `useStartWorkspaceMutation`244// Start listening to instance updates - and explicitly query state once to guarantee we get at least one update245// (needed for already started workspaces, and not hanging in 'Starting ...' for too long)246this.fetchWorkspaceInfo(result.workspace?.status?.instanceId);247} catch (error) {248const normalizedError = typeof error === "string" ? { message: error } : error;249console.error(normalizedError);250251if (normalizedError?.code === ErrorCodes.USER_BLOCKED) {252this.redirectTo(gitpodHostUrl.with({ pathname: "/blocked" }).toString());253return;254}255this.setState({ error: normalizedError });256}257}258259/**260* TODO(gpl) Ideally this can be pushed into the GitpodService implementation. But to get started we hand-roll it here.261* @param workspaceId262* @param options263* @returns264*/265protected async startWorkspaceRateLimited(266workspaceId: string,267options: PartialMessage<StartWorkspaceRequest>,268): Promise<StartWorkspaceResponse> {269let retries = 0;270while (true) {271try {272// TODO: use `useStartWorkspaceMutation`273return await workspaceClient.startWorkspace({274...options,275workspaceId,276});277} catch (err) {278if (err?.code !== ErrorCodes.TOO_MANY_REQUESTS) {279throw err;280}281282if (retries >= 10) {283throw err;284}285retries++;286287const data = err?.data as RateLimiterError | undefined;288const timeoutSeconds = data?.retryAfter || 5;289console.log(290`startWorkspace was rate-limited: waiting for ${timeoutSeconds}s before doing ${retries}nd retry...`,291);292await new Promise((resolve) => setTimeout(resolve, timeoutSeconds * 1000));293}294}295}296297/**298* Fetches initial WorkspaceInfo from the server. If there is a WorkspaceInstance for workspaceId, we feed it299* into "onInstanceUpdate" and start accepting further updates.300*301* @param startedInstanceId The instanceId we want to listen on302*/303async fetchWorkspaceInfo(startedInstanceId: string | undefined) {304// this ensures we're receiving updates for this instance305if (startedInstanceId) {306this.setState({ startedInstanceId });307}308309const { workspaceId } = this.props;310try {311const request = new GetWorkspaceRequest();312request.workspaceId = workspaceId;313const response = await workspaceClient.getWorkspace(request);314if (response.workspace?.status?.instanceId) {315this.setState((s) => ({316workspace: response.workspace,317startedInstanceId: s.startedInstanceId || response.workspace?.status?.instanceId, // note: here's a potential mismatch between startedInstanceId and instance.id. TODO(gpl) How to handle this?318}));319this.onWorkspaceUpdate(response.workspace);320}321} catch (error) {322console.error(error);323this.setState({ error });324}325}326327/**328* Fetches the current IDEOptions config for this user329*330* TODO(gpl) Ideally this would be part of the WorkspaceInstance shape, really. And we'd display options based on331* what support it was started with.332*/333protected async fetchIDEOptions() {334const ideOptions = await getGitpodService().server.getIDEOptions();335this.setState({ ideOptions });336}337338private async onWorkspaceUpdate(workspace?: Workspace) {339if (!workspace?.status?.instanceId || !workspace.id) {340return;341}342// Here we filter out updates to instances we haven't started to avoid issues with updates coming in out-of-order343// (e.g., multiple "stopped" events from the older instance, where we already started a fresh one after the first)344// Only exception is when we do the switch from the "old" to the "new" one.345const startedInstanceId = this.state?.startedInstanceId;346if (startedInstanceId !== workspace.status.instanceId) {347const latestInfo = await workspaceClient.getWorkspace({ workspaceId: workspace.id });348const latestInstanceId = latestInfo.workspace?.status?.instanceId;349if (workspace.status.instanceId !== latestInstanceId) {350return;351}352// do we want to switch to "new" instance we just received an update for? Yes353this.setState({354startedInstanceId: workspace.status.instanceId,355workspace,356});357if (startedInstanceId) {358// now we're listening to a new instance, which might have been started with other IDEoptions359this.fetchIDEOptions();360}361}362363await this.ensureWorkspaceAuth(workspace.status.instanceId, false); // Don't block the workspace auth retrieval, as it's guaranteed to get a seconds chance later on!364365// Redirect to workspaceURL if we are not yet running in an iframe.366// It happens this late if we were waiting for a docker build.367if (368!this.props.runsInIFrame &&369workspace.status.workspaceUrl &&370(!this.props.dontAutostart || workspace.status.phase?.name === WorkspacePhase_Phase.RUNNING)371) {372(async () => {373// At this point we cannot be certain that we already have the relevant cookie in multi-cluster374// scenarios with distributed workspace bridges (control loops): We might receive the update, but the backend might not have the token, yet.375// So we have to ask again, and wait until we're actually successful (it returns immediately on the happy path)376await this.ensureWorkspaceAuth(workspace.status!.instanceId, true);377if (this.state.error && this.state.error?.code !== ErrorCodes.NOT_FOUND) {378return;379}380this.redirectTo(workspace.status!.workspaceUrl);381})().catch(console.error);382return;383}384385if (workspace.status.phase?.name === WorkspacePhase_Phase.IMAGEBUILD) {386this.setState({ hasImageBuildLogs: true });387}388389let error: StartWorkspaceError | undefined;390if (workspace.status.conditions?.failed) {391error = { message: workspace.status.conditions.failed };392}393394// Successfully stopped and headless: the prebuild is done, let's try to use it!395if (396!error &&397workspace.status.phase?.name === WorkspacePhase_Phase.STOPPED &&398this.state.workspace?.spec?.type === WorkspaceSpec_WorkspaceType.PREBUILD399) {400// 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!)401const contextURL = this.state.workspace.metadata?.originalContextUrl;402if (contextURL) {403this.redirectTo(gitpodHostUrl.withContext(contextURL.toString()).toString());404} else {405console.error(`unable to parse contextURL: ${contextURL}`);406}407}408409this.setState({ workspace, error });410}411412async ensureWorkspaceAuth(instanceID: string, retry: boolean) {413const MAX_ATTEMPTS = 10;414const ATTEMPT_INTERVAL_MS = 2000;415let attempt = 0;416let fetchError: Error | undefined = undefined;417while (attempt <= MAX_ATTEMPTS) {418attempt++;419420let code: number | undefined = undefined;421fetchError = undefined;422try {423const authURL = gitpodHostUrl.asWorkspaceAuth(instanceID);424const response = await fetch(authURL.toString());425code = response.status;426} catch (err) {427fetchError = err;428}429430if (retry) {431if (code === 404 && !fetchError) {432fetchError = new Error("Unable to retrieve workspace-auth cookie (code: 404)");433}434if (fetchError) {435console.warn("Unable to retrieve workspace-auth cookie! Retrying shortly...", fetchError, {436instanceID,437code,438attempt,439});440// If the token is not there, we assume it will appear, soon: Retry a couple of times.441await new Promise((resolve) => setTimeout(resolve, ATTEMPT_INTERVAL_MS));442continue;443}444}445if (code !== 200) {446// getting workspace auth didn't work as planned447console.warn("Unable to retrieve workspace-auth cookie.", {448instanceID,449code,450attempt,451});452return;453}454455// Response code is 200 at this point: done!456console.info("Retrieved workspace-auth cookie.", { instanceID, code, attempt });457return;458}459460console.error("Unable to retrieve workspace-auth cookie! Giving up.", { instanceID, attempt });461462if (fetchError) {463// To maintain prior behavior we bubble up this error to callers464throw fetchError;465}466}467468redirectTo(url: string) {469if (this.state.redirected) {470console.info("Prevented another redirect", { url });471return;472}473if (this.props.runsInIFrame) {474this.ideFrontendService?.relocate(url);475} else {476window.location.href = url;477}478479this.setState({ redirected: true });480setTimeout(() => {481this.setState({ showRedirectMessage: true });482}, 2000);483}484485private openDesktopLink(link: string) {486this.ideFrontendService?.openDesktopIDE(link);487}488489render() {490const { error } = this.state;491const isPrebuild = this.state.workspace?.spec?.type === WorkspaceSpec_WorkspaceType.PREBUILD;492let withPrebuild = false;493for (const initializer of this.state.workspace?.spec?.initializer?.specs ?? []) {494if (initializer.spec.case === "prebuild") {495withPrebuild = !!initializer.spec.value.prebuildId;496}497}498let phase: StartPhase | undefined = StartPhase.Preparing;499let title = undefined;500let isStoppingOrStoppedPhase = false;501let isError = error ? true : false;502let statusMessage = !!error ? undefined : <p className="text-base text-gray-400">Preparing workspace …</p>;503const contextURL = this.state.workspace?.metadata?.originalContextUrl;504const useLatest = this.state.workspace?.spec?.editor?.version === "latest";505506switch (this.state?.workspace?.status?.phase?.name) {507// unknown indicates an issue within the system in that it cannot determine the actual phase of508// a workspace. This phase is usually accompanied by an error.509case WorkspacePhase_Phase.UNSPECIFIED:510break;511// Preparing means that we haven't actually started the workspace instance just yet, but rather512// are still preparing for launch.513case WorkspacePhase_Phase.PREPARING:514phase = StartPhase.Preparing;515statusMessage = <p className="text-base text-gray-400">Starting workspace …</p>;516break;517518case WorkspacePhase_Phase.IMAGEBUILD:519// Building means we're building the Docker image for the workspace.520return <ImageBuildView workspaceId={this.state.workspace.id} />;521522// Pending means the workspace does not yet consume resources in the cluster, but rather is looking for523// some space within the cluster. If for example the cluster needs to scale up to accommodate the524// workspace, the workspace will be in Pending state until that happened.525case WorkspacePhase_Phase.PENDING:526phase = StartPhase.Preparing;527statusMessage = <p className="text-base text-gray-400">Allocating resources …</p>;528break;529530// Creating means the workspace is currently being created. That includes downloading the images required531// to run the workspace over the network. The time spent in this phase varies widely and depends on the current532// network speed, image size and cache states.533case WorkspacePhase_Phase.CREATING:534phase = StartPhase.Creating;535statusMessage = <p className="text-base text-gray-400">Pulling container image …</p>;536break;537538// Initializing is the phase in which the workspace is executing the appropriate workspace initializer (e.g. Git539// clone or backup download). After this phase one can expect the workspace to either be Running or Failed.540case WorkspacePhase_Phase.INITIALIZING:541phase = StartPhase.Starting;542statusMessage = (543<p className="text-base text-gray-400">544{withPrebuild ? "Loading prebuild …" : "Initializing content …"}545</p>546);547break;548549// Running means the workspace is able to actively perform work, either by serving a user through Theia,550// or as a headless workspace.551case WorkspacePhase_Phase.RUNNING:552if (isPrebuild) {553return (554<StartPage title="Prebuild in Progress" workspaceId={this.props.workspaceId}>555<div className="mt-6 w-11/12 lg:w-3/5">556{/* TODO(gpl) These classes are copied around in Start-/CreateWorkspace. This should properly go somewhere central. */}557<PrebuildLogs workspaceId={this.props.workspaceId} />558</div>559</StartPage>560);561}562if (!this.state.desktopIde) {563phase = StartPhase.Running;564statusMessage = <p className="text-base text-gray-400">Opening Workspace …</p>;565} else {566phase = StartPhase.IdeReady;567const openLink = this.state.desktopIde.link;568const openLinkLabel = this.state.desktopIde.label;569const clientID = this.state.desktopIde.clientID;570const client = clientID ? this.state.ideOptions?.clients?.[clientID] : undefined;571const installationSteps = client?.installationSteps?.length && (572<div className="flex flex-col text-center m-auto text-sm w-72 text-gray-400">573{client.installationSteps.map((step) => (574<div575key={step}576dangerouslySetInnerHTML={{577// eslint-disable-next-line no-template-curly-in-string578__html: step.replaceAll("${OPEN_LINK_LABEL}", openLinkLabel),579}}580/>581))}582</div>583);584statusMessage = (585<div>586<p className="text-base text-gray-400">Opening Workspace …</p>587<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">588<div className="rounded-full w-3 h-3 text-sm bg-green-500"> </div>589<div>590<p className="text-gray-700 dark:text-gray-200 font-semibold w-56 truncate">591{fromWorkspaceName(this.state.workspace) || this.state.workspace.id}592</p>593<a target="_parent" href={contextURL}>594<p className="w-56 truncate hover:text-blue-600 dark:hover:text-blue-400">595{contextURL}596</p>597</a>598</div>599</div>600{installationSteps}601<div className="mt-10 justify-center flex space-x-2">602<ContextMenu603menuEntries={[604{605title: "Open in Browser",606onClick: () => {607this.ideFrontendService?.openBrowserIDE();608},609},610{611title: "Stop Workspace",612onClick: () =>613workspaceClient.stopWorkspace({ workspaceId: this.props.workspaceId }),614},615{616title: "Connect via SSH",617onClick: async () => {618const response = await workspaceClient.getWorkspaceOwnerToken({619workspaceId: this.props.workspaceId,620});621this.setState({622isSSHModalVisible: true,623ownerToken: response.ownerToken,624});625},626},627{628title: "Go to Dashboard",629href: gitpodHostUrl.asWorkspacePage().toString(),630target: "_parent",631},632]}633>634<Button variant="secondary">635More Actions...636<Arrow direction={"down"} />637</Button>638</ContextMenu>639<Button onClick={() => this.openDesktopLink(openLink)}>{openLinkLabel}</Button>640</div>641{!useLatest && (642<Alert type="info" className="mt-4 w-96">643You can change the default editor for opening workspaces in{" "}644<a645className="gp-link"646target="_blank"647rel="noreferrer"648href={gitpodHostUrl.asPreferences().toString()}649>650user preferences651</a>652.653</Alert>654)}655{this.state.isSSHModalVisible === true && this.state.ownerToken && (656<ConnectToSSHModal657workspaceId={this.props.workspaceId}658ownerToken={this.state.ownerToken}659ideUrl={this.state.workspace.status.workspaceUrl.replaceAll("https://", "")}660onClose={() => this.setState({ isSSHModalVisible: false, ownerToken: "" })}661/>662)}663</div>664);665}666667break;668669// Interrupted is an exceptional state where the container should be running but is temporarily unavailable.670// When in this state, we expect it to become running or stopping anytime soon.671case WorkspacePhase_Phase.INTERRUPTED:672phase = StartPhase.Running;673statusMessage = <p className="text-base text-gray-400">Checking workspace …</p>;674break;675676// Stopping means that the workspace is currently shutting down. It could go to stopped every moment.677case WorkspacePhase_Phase.STOPPING:678isStoppingOrStoppedPhase = true;679if (isPrebuild) {680return (681<StartPage title="Prebuild in Progress" workspaceId={this.props.workspaceId}>682<div className="mt-6 w-11/12 lg:w-3/5">683{/* TODO(gpl) These classes are copied around in Start-/CreateWorkspace. This should properly go somewhere central. */}684<PrebuildLogs workspaceId={this.props.workspaceId} />685</div>686</StartPage>687);688}689phase = StartPhase.Stopping;690statusMessage = (691<div>692<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">693<div className="rounded-full w-3 h-3 text-sm bg-kumquat-ripe"> </div>694<div>695<p className="text-gray-700 dark:text-gray-200 font-semibold w-56 truncate">696{fromWorkspaceName(this.state.workspace) || this.state.workspace.id}697</p>698<a target="_parent" href={contextURL}>699<p className="w-56 truncate hover:text-blue-600 dark:hover:text-blue-400">700{contextURL}701</p>702</a>703</div>704</div>705<div className="mt-10 flex justify-center">706<a target="_parent" href={gitpodHostUrl.asWorkspacePage().toString()}>707<Button variant="secondary">Go to Dashboard</Button>708</a>709</div>710</div>711);712break;713714// Stopped means the workspace ended regularly because it was shut down.715case WorkspacePhase_Phase.STOPPED:716isStoppingOrStoppedPhase = true;717phase = StartPhase.Stopped;718if (this.state.hasImageBuildLogs) {719const restartWithDefaultImage = (event: React.MouseEvent) => {720(event.target as HTMLButtonElement).disabled = true;721this.startWorkspace(true, true);722};723return (724<ImageBuildView725workspaceId={this.state.workspace.id}726onStartWithDefaultImage={restartWithDefaultImage}727phase={phase}728error={error}729/>730);731}732if (!isPrebuild && this.state.workspace.status.conditions?.timeout) {733title = "Timed Out";734}735statusMessage = (736<div>737<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">738<div className="rounded-full w-3 h-3 text-sm bg-gray-300"> </div>739<div>740<p className="text-gray-700 dark:text-gray-200 font-semibold w-56 truncate">741{fromWorkspaceName(this.state.workspace) || this.state.workspace.id}742</p>743<a target="_parent" href={contextURL}>744<p className="w-56 truncate hover:text-blue-600 dark:hover:text-blue-400">745{contextURL}746</p>747</a>748</div>749</div>750<PendingChangesDropdown751gitStatus={this.state.workspace.status.gitStatus}752className="justify-center"753/>754<div className="mt-10 justify-center flex space-x-2">755<a target="_parent" href={gitpodHostUrl.asWorkspacePage().toString()}>756<Button variant="secondary">Go to Dashboard</Button>757</a>758<a target="_parent" href={gitpodHostUrl.asStart(this.state.workspace.id).toString()}>759<Button>Open Workspace</Button>760</a>761</div>762</div>763);764break;765}766767return (768<StartPage769phase={phase}770error={error}771title={title}772showLatestIdeWarning={useLatest && (isError || !isStoppingOrStoppedPhase)}773workspaceId={this.props.workspaceId}774>775{statusMessage}776{this.state.showRedirectMessage && (777<>778<Alert type="info" className="mt-4 w-112">779We redirected you to your workspace, but your browser probably opened it in another tab.780</Alert>781782<div className="mt-4 justify-center flex space-x-2">783<LinkButton href={gitpodHostUrl.asWorkspacePage().toString()} target="_self" isExternalUrl>784Go to Dashboard785</LinkButton>786{this.state.workspace?.status?.workspaceUrl &&787this.state.workspace.status.phase?.name === WorkspacePhase_Phase.RUNNING && (788<LinkButton789variant={"secondary"}790href={this.state.workspace.status.workspaceUrl}791target="_self"792isExternalUrl793>794Re-open Workspace795</LinkButton>796)}797</div>798</>799)}800</StartPage>801);802}803}804805interface ImageBuildViewProps {806workspaceId: string;807onStartWithDefaultImage?: (event: React.MouseEvent) => void;808phase?: StartPhase;809error?: StartWorkspaceError;810}811812function ImageBuildView(props: ImageBuildViewProps) {813const logsEmitter = useMemo(() => new EventEmitter(), []);814815useEffect(() => {816let registered = false;817const watchBuild = () => {818if (registered) {819return;820}821registered = true;822823getGitpodService()824.server.watchWorkspaceImageBuildLogs(props.workspaceId)825.catch((err) => {826registered = false;827if (err?.code === ErrorCodes.HEADLESS_LOG_NOT_YET_AVAILABLE) {828// wait, and then retry829setTimeout(watchBuild, 5000);830}831});832};833watchBuild();834835const toDispose = getGitpodService().registerClient({836notifyDidOpenConnection: () => {837registered = false; // new connection, we're not registered anymore838watchBuild();839},840onWorkspaceImageBuildLogs: (841info: WorkspaceImageBuild.StateInfo,842content?: WorkspaceImageBuild.LogContent,843) => {844if (!content?.data) {845return;846}847const chunk = new Uint8Array(content.data);848logsEmitter.emit("logs", chunk);849},850});851852return function cleanup() {853toDispose.dispose();854};855// eslint-disable-next-line react-hooks/exhaustive-deps856}, []);857858return (859<StartPage title="Building Image" phase={props.phase} workspaceId={props.workspaceId}>860<Suspense fallback={<div />}>861<WorkspaceLogs taskId="image-build" logsEmitter={logsEmitter} errorMessage={props.error?.message} />862</Suspense>863{!!props.onStartWithDefaultImage && (864<>865<div className="mt-6 w-11/12 lg:w-3/5">866<p className="text-center text-gray-400 dark:text-gray-500">867💡 You can use the <code>gp validate</code> command to validate the workspace configuration868from the editor terminal. 869<a870href="https://www.gitpod.io/docs/configure/workspaces#validate-your-gitpod-configuration"871target="_blank"872rel="noopener noreferrer"873className="gp-link"874>875Learn More876</a>877</p>878</div>879<Button variant="secondary" className="mt-6" onClick={props.onStartWithDefaultImage}>880Continue with Default Image881</Button>882</>883)}884</StartPage>885);886}887888889