Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[server][dashboard] Allow cancelling Prebuilds #5865

Merged
merged 1 commit into from
Oct 18, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions components/dashboard/src/icons/StatusCanceled.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
26 changes: 24 additions & 2 deletions components/dashboard/src/projects/ConfigureProject.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ export default function () {
const [isEditorDisabled, setIsEditorDisabled] = useState<boolean>(true);
const [isDetecting, setIsDetecting] = useState<boolean>(true);
const [prebuildWasTriggered, setPrebuildWasTriggered] = useState<boolean>(false);
const [prebuildWasCancelled, setPrebuildWasCancelled] = useState<boolean>(false);
const [startPrebuildResult, setStartPrebuildResult] = useState<StartPrebuildResult | undefined>();
const [prebuildInstance, setPrebuildInstance] = useState<WorkspaceInstance | undefined>();
const { isDark } = useContext(ThemeContext);
Expand Down Expand Up @@ -172,6 +173,9 @@ export default function () {
if (!!startPrebuildResult) {
setStartPrebuildResult(undefined);
}
if (!!prebuildInstance) {
setPrebuildInstance(undefined);
}
try {
setPrebuildWasTriggered(true);
if (!isEditorDisabled) {
Expand All @@ -185,6 +189,20 @@ export default function () {
}
}

const cancelPrebuild = async () => {
if (!project || !startPrebuildResult) {
return;
}
setPrebuildWasCancelled(true);
try {
await getGitpodService().server.cancelPrebuild(project.id, startPrebuildResult.prebuildId);
} catch (error) {
setEditorMessage(<EditorMessage type="warning" heading="Could not cancel prebuild." message={String(error).replace(/Error: Request \w+ failed with message: /, '')}/>);
} finally {
setPrebuildWasCancelled(false);
}
}

const onInstanceUpdate = (instance: WorkspaceInstance) => {
setPrebuildInstance(instance);
}
Expand Down Expand Up @@ -239,10 +257,14 @@ export default function () {
<div className="h-20 px-6 bg-gray-50 dark:bg-gray-800 border-t border-gray-200 dark:border-gray-600 flex space-x-2">
{prebuildWasTriggered && <PrebuildInstanceStatus prebuildInstance={prebuildInstance} />}
<div className="flex-grow" />
{((!isDetecting && isEditorDisabled) || (prebuildInstance?.status.phase === "stopped" && !prebuildInstance?.status.conditions.failed))
{((!isDetecting && isEditorDisabled) || (prebuildInstance?.status.phase === "stopped" && !prebuildInstance?.status.conditions.failed && !prebuildInstance?.status.conditions.headlessTaskFailed))
? <a className="my-auto" href={`/#${project?.cloneUrl}`}><button className="secondary">New Workspace</button></a>
: <button disabled={true} className="secondary">New Workspace</button>}
<button disabled={isDetecting || (prebuildWasTriggered && prebuildInstance?.status.phase !== "stopped")} onClick={buildProject}>Run Prebuild</button>
{(prebuildWasTriggered && prebuildInstance?.status.phase !== "stopped")
? <button className="danger flex items-center space-x-2" disabled={prebuildWasCancelled || (prebuildInstance?.status.phase !== "initializing" && prebuildInstance?.status.phase !== "running")} onClick={cancelPrebuild}>
<span>Cancel Prebuild</span>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: Probably due to technical limitation, but the Cancel Prebuild button becomes clickable only a few second in RUNNING phase, right? Also, the Canceling Prebuild action becomes available instantly in RUNNING phase from the more actions dropdown in prebuilds list.

Could we make the action button (Cancel Prebuild) on both pages (Configure, Prebuilds) available instantly in PENDING phase?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sadly, we cannot always cancel a Prebuild -- the Prebuild instance needs to be at least in initializing (or running) state.

Before that (e.g. when the prebuild doesn't even exist yet, or when its Docker image is being built), it can't technically be cancelled. The Cancel Prebuild button is disabled when we know that's not possible.

(Also, you're right, in the Prebuilds list, we don't know which state the Prebuild instance is in, so maybe we enable the Cancel Prebuild action at times where that's not actually possible -- this could be improved later, but it seems a bit less critical to me than in the Configurator, where you're more often actively watching and clicking the buttons as soon as possible.)

jankeromnes marked this conversation as resolved.
Show resolved Hide resolved
</button>
: <button disabled={isDetecting} onClick={buildProject}>Run Prebuild</button>}
</div>
</div>
</div>
Expand Down
28 changes: 24 additions & 4 deletions components/dashboard/src/projects/Prebuild.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export default function () {
const [ prebuild, setPrebuild ] = useState<PrebuildWithStatus | undefined>();
const [ prebuildInstance, setPrebuildInstance ] = useState<WorkspaceInstance | undefined>();
const [ isRerunningPrebuild, setIsRerunningPrebuild ] = useState<boolean>(false);
const [ isCancellingPrebuild, setIsCancellingPrebuild ] = useState<boolean>(false);

useEffect(() => {
if (!teams || !projectName || !prebuildId) {
Expand Down Expand Up @@ -83,8 +84,8 @@ export default function () {
if (!prebuild) {
return;
}
setIsRerunningPrebuild(true);
try {
setIsRerunningPrebuild(true);
await getGitpodService().server.triggerPrebuild(prebuild.info.projectId, prebuild.info.branch);
// TODO: Open a Prebuilds page that's specific to `prebuild.info.branch`?
history.push(`/${!!team ? 't/'+team.slug : 'projects'}/${projectName}/prebuilds`);
Expand All @@ -95,6 +96,20 @@ export default function () {
}
}

const cancelPrebuild = async () => {
if (!prebuild) {
return;
}
try {
setIsCancellingPrebuild(true);
await getGitpodService().server.cancelPrebuild(prebuild.info.projectId, prebuild.info.id);
} catch (error) {
console.error('Could not cancel prebuild', error);
} finally {
setIsCancellingPrebuild(false);
}
}

useEffect(() => { document.title = 'Prebuild — Gitpod' }, []);

return <>
Expand All @@ -112,9 +127,14 @@ export default function () {
{isRerunningPrebuild && <img className="h-4 w-4 animate-spin filter brightness-150" src={Spinner} />}
<span>Rerun Prebuild ({prebuild.info.branch})</span>
</button>
: (prebuild?.status === 'available'
? <a className="my-auto" href={gitpodHostUrl.withContext(`${prebuild?.info.changeUrl}`).toString()}><button>New Workspace ({prebuild?.info.branch})</button></a>
: <button disabled={true}>New Workspace ({prebuild?.info.branch})</button>)}
: (prebuild?.status === 'building'
? <button className="danger flex items-center space-x-2" disabled={isCancellingPrebuild} onClick={cancelPrebuild}>
{isCancellingPrebuild && <img className="h-4 w-4 animate-spin filter brightness-150" src={Spinner} />}
<span>Cancel Prebuild</span>
</button>
: (prebuild?.status === 'available'
? <a className="my-auto" href={gitpodHostUrl.withContext(`${prebuild?.info.changeUrl}`).toString()}><button>New Workspace ({prebuild?.info.branch})</button></a>
: <button disabled={true}>New Workspace ({prebuild?.info.branch})</button>))}
</div>
</div>
</div>
Expand Down
47 changes: 33 additions & 14 deletions components/dashboard/src/projects/Prebuilds.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { ItemsList, Item, ItemField, ItemFieldContextMenu } from "../components/
import Spinner from "../icons/Spinner.svg";
import StatusDone from "../icons/StatusDone.svg";
import StatusFailed from "../icons/StatusFailed.svg";
import StatusCanceled from "../icons/StatusCanceled.svg";
import StatusPaused from "../icons/StatusPaused.svg";
import StatusRunning from "../icons/StatusRunning.svg";
import { getGitpodService } from "../service/service";
Expand Down Expand Up @@ -91,7 +92,7 @@ export default function () {
entries.push({
title: "Cancel Prebuild",
customFontStyle: 'text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300',
onClick: () => window.alert('cancellation not yet supported')
onClick: () => cancelPrebuild(p.info.id),
})
}
return entries;
Expand Down Expand Up @@ -131,9 +132,17 @@ export default function () {
}

const triggerPrebuild = (branchName: string | null) => {
if (project) {
getGitpodService().server.triggerPrebuild(project.id, branchName);
if (!project) {
return;
}
getGitpodService().server.triggerPrebuild(project.id, branchName);
}

const cancelPrebuild = (prebuildId: string) => {
if (!project) {
return;
}
getGitpodService().server.cancelPrebuild(project.id, prebuildId);
}

const formatDate = (date: string | undefined) => {
Expand Down Expand Up @@ -200,37 +209,39 @@ export default function () {
}

export function prebuildStatusLabel(prebuild?: PrebuildWithStatus) {
if (prebuild?.error) {
return (<span className="font-medium text-red-500 uppercase">failed</span>);
}
switch (prebuild?.status) {
case undefined: // Fall through
case "queued":
return (<span className="font-medium text-orange-500 uppercase">pending</span>);
case "building":
return (<span className="font-medium text-blue-500 uppercase">running</span>);
case "aborted": // Fall through
case "aborted":
return (<span className="font-medium text-gray-500 uppercase">canceled</span>);
case "timeout":
return (<span className="font-medium text-red-500 uppercase">failed</span>);
case "available":
if (prebuild?.error) {
return (<span className="font-medium text-red-500 uppercase">failed</span>);
}
return (<span className="font-medium text-green-500 uppercase">ready</span>);
}
}

export function prebuildStatusIcon(prebuild?: PrebuildWithStatus) {
if (prebuild?.error) {
return <img className="h-4 w-4" src={StatusFailed} />;
}
switch (prebuild?.status) {
case undefined: // Fall through
case "queued":
return <img className="h-4 w-4" src={StatusPaused} />;
case "building":
return <img className="h-4 w-4" src={StatusRunning} />;
case "aborted": // Fall through
case "aborted":
return <img className="h-4 w-4" src={StatusCanceled} />;
case "timeout":
return <img className="h-4 w-4" src={StatusFailed} />;
case "available":
if (prebuild?.error) {
return <img className="h-4 w-4" src={StatusFailed} />;
}
return <img className="h-4 w-4" src={StatusDone} />;
}
}
Expand Down Expand Up @@ -279,14 +290,22 @@ export function PrebuildInstanceStatus(props: { prebuildInstance?: WorkspaceInst
</div>;
break;
}
if (props.prebuildInstance?.status.conditions.failed || props.prebuildInstance?.status.conditions.headlessTaskFailed) {
if (props.prebuildInstance?.status.conditions.stoppedByRequest) {
status = <div className="flex space-x-1 items-center text-gray-500">
<img className="h-4 w-4" src={StatusCanceled} />
<span>CANCELED</span>
</div>;
details = <div className="flex space-x-1 items-center text-gray-400">
<span>Prebuild canceled</span>
</div>;
} else if (props.prebuildInstance?.status.conditions.failed || props.prebuildInstance?.status.conditions.headlessTaskFailed) {
status = <div className="flex space-x-1 items-center text-gitpod-red">
<img className="h-4 w-4" src={StatusFailed} />
<span>FAILED</span>
</div>;
</div>;
details = <div className="flex space-x-1 items-center text-gray-400">
<span>Prebuild failed</span>
</div>;
</div>;
}
return <div className="flex flex-col space-y-1 justify-center text-sm font-semibold">
<div>{status}</div>
Expand Down
29 changes: 19 additions & 10 deletions components/dashboard/src/projects/Project.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@
*/

import moment from "moment";
import { PrebuildInfo, PrebuildWithStatus, Project } from "@gitpod/gitpod-protocol";
import { PrebuildWithStatus, Project } from "@gitpod/gitpod-protocol";
import { useContext, useEffect, useState } from "react";
import { useHistory, useLocation, useRouteMatch } from "react-router";
import { useLocation, useRouteMatch } from "react-router";
import Header from "../components/Header";
import { ItemsList, Item, ItemField, ItemFieldContextMenu } from "../components/ItemsList";
import { getGitpodService, gitpodHostUrl } from "../service/service";
Expand All @@ -20,7 +20,6 @@ import { ErrorCodes } from "@gitpod/gitpod-protocol/lib/messaging/error";
import { openAuthorizeWindow } from "../provider-utils";

export default function () {
const history = useHistory();
const location = useLocation();

const { teams } = useContext(TeamsContext);
Expand Down Expand Up @@ -155,13 +154,17 @@ export default function () {
}

const triggerPrebuild = (branch: Project.BranchDetails) => {
if (project) {
getGitpodService().server.triggerPrebuild(project.id, branch.name)
if (!project) {
return;
}
getGitpodService().server.triggerPrebuild(project.id, branch.name);
}

const openPrebuild = (pb: PrebuildInfo) => {
history.push(`/${!!team ? 't/' + team.slug : 'projects'}/${projectName}/${pb.id}`);
const cancelPrebuild = (prebuildId: string) => {
if (!project) {
return;
}
getGitpodService().server.cancelPrebuild(project.id, prebuildId);
}

const formatDate = (date: string | undefined) => {
Expand Down Expand Up @@ -236,9 +239,9 @@ export default function () {
</div>
</ItemField>
<ItemField className="flex items-center">
<div className="text-base text-gray-900 dark:text-gray-50 font-medium uppercase mb-1 cursor-pointer" onClick={() => prebuild && openPrebuild(prebuild.info)}>
<a className="text-base text-gray-900 dark:text-gray-50 font-medium uppercase mb-1 cursor-pointer" href={prebuild ? `/${!!team ? 't/' + team.slug : 'projects'}/${projectName}/${prebuild.info.id}` : 'javascript:void(0)'}>
{prebuild ? (<><div className="inline-block align-text-bottom mr-2 w-4 h-4">{statusIcon}</div>{status}</>) : (<span> </span>)}
</div>
</a>
<span className="flex-grow" />
<a href={gitpodHostUrl.withContext(`${branch.url}`).toString()}>
<button className={`primary mr-2 py-2 opacity-0 group-hover:opacity-100`}>New Workspace</button>
Expand All @@ -248,7 +251,13 @@ export default function () {
title: `${prebuild ? 'Rerun' : 'Run'} Prebuild (${branch.name})`,
onClick: () => triggerPrebuild(branch),
}]
: []} />
: (prebuild.status === 'building'
? [{
title: 'Cancel Prebuild',
customFontStyle: 'text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300',
onClick: () => cancelPrebuild(prebuild.info.id),
}]
: [])} />
</ItemField>
</Item>
}
Expand Down
1 change: 1 addition & 0 deletions components/gitpod-protocol/src/gitpod-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ export interface GitpodServer extends JsonRpcServer<GitpodClient>, AdminServer,
getProjectOverview(projectId: string): Promise<Project.Overview | undefined>;
findPrebuilds(params: FindPrebuildsParams): Promise<PrebuildWithStatus[]>;
triggerPrebuild(projectId: string, branchName: string | null): Promise<StartPrebuildResult>;
cancelPrebuild(projectId: string, prebuildId: string): Promise<void>;
setProjectConfiguration(projectId: string, configString: string): Promise<void>;
fetchProjectRepositoryConfiguration(projectId: string): Promise<string | undefined>;
guessProjectConfiguration(projectId: string): Promise<string | undefined>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ export namespace PrebuildInfo {
}

export interface StartPrebuildResult {
prebuildId: string;
wsid: string;
done: boolean;
}
Expand Down
3 changes: 3 additions & 0 deletions components/gitpod-protocol/src/workspace-instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,9 @@ export interface WorkspaceInstanceConditions {

// headless_task_failed indicates that a headless workspace task failed
headlessTaskFailed?: string;

// stopped_by_request is true if the workspace was stopped using a StopWorkspace call
stoppedByRequest?: boolean;
}

// AdmissionLevel describes who can access a workspace instance and its ports.
Expand Down
19 changes: 15 additions & 4 deletions components/server/ee/src/prebuilds/prebuild-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ export class PrebuildManager {
const isSameConfig = JSON.stringify(filterPrebuildTasks(existingConfig?.tasks)) === JSON.stringify(filterPrebuildTasks(newConfig?.tasks));
// If there is an existing prebuild that isn't failed and it's based on the current config, we return it here instead of triggering a new prebuild.
if (isSameConfig) {
return { wsid: existingPB.buildWorkspaceId, done: true };
return { prebuildId: existingPB.id, wsid: existingPB.buildWorkspaceId, done: true };
}
}

Expand Down Expand Up @@ -110,6 +110,7 @@ export class PrebuildManager {
log.debug("Created prebuild context", prebuildContext);

const workspace = await this.workspaceFactory.createForContext({span}, user, prebuildContext, contextURL);
const prebuildPromise = this.workspaceDB.trace({span}).findPrebuildByWorkspaceID(workspace.id)!;

// const canBuildNow = await this.prebuildRateLimiter.canBuildNow({ span }, user, cloneURL);
// if (!canBuildNow) {
Expand All @@ -120,7 +121,11 @@ export class PrebuildManager {

span.setTag("starting", true);
await this.workspaceStarter.startWorkspace({ span }, workspace, user, [], {excludeFeatureFlags: ['full_workspace_backup']});
return { wsid: workspace.id, done: false };
const prebuild = await prebuildPromise;
if (!prebuild) {
throw new Error(`Failed to create a prebuild for: ${contextURL}`);
}
return { prebuildId: prebuild.id, wsid: workspace.id, done: false };
} catch (err) {
TraceContext.logError({ span }, err);
throw err;
Expand All @@ -133,18 +138,24 @@ export class PrebuildManager {
const span = TraceContext.startSpan("retriggerPrebuild", ctx);
span.setTag("workspaceId", workspaceId);
try {
const workspacePromise = this.workspaceDB.trace({ span }).findById(workspaceId);
const prebuildPromise = this.workspaceDB.trace({ span }).findPrebuildByWorkspaceID(workspaceId);
const runningInstance = await this.workspaceDB.trace({ span }).findRunningInstance(workspaceId);
if (runningInstance !== undefined) {
throw new WorkspaceRunningError('Workspace is still runnning', runningInstance);
}
span.setTag("starting", true);
const workspace = await this.workspaceDB.trace({ span }).findById(workspaceId);
const workspace = await workspacePromise;
if (!workspace) {
console.error('Unknown workspace id.', { workspaceId });
throw new Error('Unknown workspace ' + workspaceId);
}
const prebuild = await prebuildPromise;
if (!prebuild) {
throw new Error('No prebuild found for workspace ' + workspaceId);
}
await this.workspaceStarter.startWorkspace({ span }, workspace, user);
return { wsid: workspace.id, done: false };
return { prebuildId: prebuild.id, wsid: workspace.id, done: false };
} catch (err) {
TraceContext.logError({ span }, err);
throw err;
Expand Down
Loading