mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
fix: display explicit 'retry' button(s) when a workspace fails (#10720)
* refactor: remove workspace error enums * fix: add in retry button for failed workspaces * fix: make handleBuildRetry auto-detect debug permissions * chore: consolidate retry messaging * chore: update renderWorkspacePage to accept parameters * chore: make workspace test helpers take explicit workspace parameter * refactor: update how parameters for tests are defined * fix: update old tests to be correctly parameterized
This commit is contained in:
@@ -3,7 +3,7 @@ import { Meta, StoryObj } from "@storybook/react";
|
||||
import { WatchAgentMetadataContext } from "components/Resources/AgentMetadata";
|
||||
import { ProvisionerJobLog } from "api/typesGenerated";
|
||||
import * as Mocks from "testHelpers/entities";
|
||||
import { Workspace, WorkspaceErrors } from "./Workspace";
|
||||
import { Workspace } from "./Workspace";
|
||||
import { withReactContext } from "storybook-react-context";
|
||||
import EventSource from "eventsourcemock";
|
||||
import { ProxyContext, getPreferredProxy } from "contexts/ProxyContext";
|
||||
@@ -133,7 +133,7 @@ export const Failed: Story = {
|
||||
...Running.args,
|
||||
workspace: Mocks.MockFailedWorkspace,
|
||||
workspaceErrors: {
|
||||
[WorkspaceErrors.BUILD_ERROR]: Mocks.mockApiError({
|
||||
buildError: Mocks.mockApiError({
|
||||
message: "A workspace build is already active.",
|
||||
}),
|
||||
},
|
||||
@@ -172,7 +172,6 @@ export const FailedWithRetry: Story = {
|
||||
},
|
||||
},
|
||||
},
|
||||
canRetryDebugMode: true,
|
||||
buildLogs: <WorkspaceBuildLogsSection logs={makeFailedBuildLogs()} />,
|
||||
},
|
||||
};
|
||||
@@ -224,7 +223,7 @@ export const GetBuildsError: Story = {
|
||||
args: {
|
||||
...Running.args,
|
||||
workspaceErrors: {
|
||||
[WorkspaceErrors.GET_BUILDS_ERROR]: Mocks.mockApiError({
|
||||
getBuildsError: Mocks.mockApiError({
|
||||
message: "There is a problem fetching builds.",
|
||||
}),
|
||||
},
|
||||
@@ -235,7 +234,7 @@ export const CancellationError: Story = {
|
||||
args: {
|
||||
...Failed.args,
|
||||
workspaceErrors: {
|
||||
[WorkspaceErrors.CANCELLATION_ERROR]: Mocks.mockApiError({
|
||||
cancellationError: Mocks.mockApiError({
|
||||
message: "Job could not be canceled.",
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -29,11 +29,13 @@ import { BuildsTable } from "./BuildsTable";
|
||||
import { WorkspaceDeletedBanner } from "./WorkspaceDeletedBanner";
|
||||
import { WorkspaceStats } from "./WorkspaceStats";
|
||||
|
||||
export enum WorkspaceErrors {
|
||||
GET_BUILDS_ERROR = "getBuildsError",
|
||||
BUILD_ERROR = "buildError",
|
||||
CANCELLATION_ERROR = "cancellationError",
|
||||
}
|
||||
export type WorkspaceError =
|
||||
| "getBuildsError"
|
||||
| "buildError"
|
||||
| "cancellationError";
|
||||
|
||||
export type WorkspaceErrors = Partial<Record<WorkspaceError, unknown>>;
|
||||
|
||||
export interface WorkspaceProps {
|
||||
scheduleProps: {
|
||||
onDeadlinePlus: (hours: number) => void;
|
||||
@@ -56,16 +58,17 @@ export interface WorkspaceProps {
|
||||
resources?: TypesGen.WorkspaceResource[];
|
||||
canUpdateWorkspace: boolean;
|
||||
updateMessage?: string;
|
||||
canRetryDebugMode: boolean;
|
||||
canChangeVersions: boolean;
|
||||
hideSSHButton?: boolean;
|
||||
hideVSCodeDesktopButton?: boolean;
|
||||
workspaceErrors: Partial<Record<WorkspaceErrors, unknown>>;
|
||||
workspaceErrors: WorkspaceErrors;
|
||||
buildInfo?: TypesGen.BuildInfoResponse;
|
||||
sshPrefix?: string;
|
||||
template?: TypesGen.Template;
|
||||
quotaBudget?: number;
|
||||
canRetryDebugMode: boolean;
|
||||
handleBuildRetry: () => void;
|
||||
handleBuildRetryDebug: () => void;
|
||||
buildLogs?: React.ReactNode;
|
||||
builds: TypesGen.WorkspaceBuild[] | undefined;
|
||||
onLoadMoreBuilds: () => void;
|
||||
@@ -87,7 +90,7 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
|
||||
handleCancel,
|
||||
handleSettings,
|
||||
handleChangeVersion,
|
||||
handleDormantActivate: handleDormantActivate,
|
||||
handleDormantActivate,
|
||||
workspace,
|
||||
isUpdating,
|
||||
isRestarting,
|
||||
@@ -95,7 +98,6 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
|
||||
builds,
|
||||
canUpdateWorkspace,
|
||||
updateMessage,
|
||||
canRetryDebugMode,
|
||||
canChangeVersions,
|
||||
workspaceErrors,
|
||||
hideSSHButton,
|
||||
@@ -103,7 +105,9 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
|
||||
buildInfo,
|
||||
sshPrefix,
|
||||
template,
|
||||
canRetryDebugMode,
|
||||
handleBuildRetry,
|
||||
handleBuildRetryDebug,
|
||||
buildLogs,
|
||||
onLoadMoreBuilds,
|
||||
isLoadingMoreBuilds,
|
||||
@@ -111,31 +115,14 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
|
||||
canAutostart,
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
const serverVersion = buildInfo?.version || "";
|
||||
const { saveLocal, getLocal } = useLocalStorage();
|
||||
|
||||
const buildError = Boolean(workspaceErrors[WorkspaceErrors.BUILD_ERROR]) && (
|
||||
<ErrorAlert
|
||||
error={workspaceErrors[WorkspaceErrors.BUILD_ERROR]}
|
||||
dismissible
|
||||
/>
|
||||
);
|
||||
|
||||
const cancellationError = Boolean(
|
||||
workspaceErrors[WorkspaceErrors.CANCELLATION_ERROR],
|
||||
) && (
|
||||
<ErrorAlert
|
||||
error={workspaceErrors[WorkspaceErrors.CANCELLATION_ERROR]}
|
||||
dismissible
|
||||
/>
|
||||
);
|
||||
|
||||
let transitionStats: TypesGen.TransitionStats | undefined = undefined;
|
||||
if (template !== undefined) {
|
||||
transitionStats = ActiveTransition(template, workspace);
|
||||
}
|
||||
|
||||
const [showAlertPendingInQueue, setShowAlertPendingInQueue] = useState(false);
|
||||
|
||||
// 2023-11-15 - MES - This effect will be called every single render because
|
||||
// "now" will always change and invalidate the dependency array. Need to
|
||||
// figure out if this effect really should run every render (possibly meaning
|
||||
// no dependency array at all), or how to get the array stabilized (ideal)
|
||||
const now = dayjs();
|
||||
useEffect(() => {
|
||||
if (
|
||||
@@ -174,6 +161,9 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
|
||||
const autoStartFailing = workspace.autostart_schedule && !canAutostart;
|
||||
const requiresManualUpdate = updateRequired && autoStartFailing;
|
||||
|
||||
const transitionStats =
|
||||
template !== undefined ? ActiveTransition(template, workspace) : undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<FullWidthPageHeader>
|
||||
@@ -213,8 +203,11 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
|
||||
handleUpdate={handleUpdate}
|
||||
handleCancel={handleCancel}
|
||||
handleSettings={handleSettings}
|
||||
handleRetry={handleBuildRetry}
|
||||
handleRetryDebug={handleBuildRetryDebug}
|
||||
handleChangeVersion={handleChangeVersion}
|
||||
handleDormantActivate={handleDormantActivate}
|
||||
canRetryDebug={canRetryDebugMode}
|
||||
canChangeVersions={canChangeVersions}
|
||||
isUpdating={isUpdating}
|
||||
isRestarting={isRestarting}
|
||||
@@ -244,8 +237,15 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
|
||||
{updateMessage && <AlertDetail>{updateMessage}</AlertDetail>}
|
||||
</Alert>
|
||||
))}
|
||||
{buildError}
|
||||
{cancellationError}
|
||||
|
||||
{Boolean(workspaceErrors.buildError) && (
|
||||
<ErrorAlert error={workspaceErrors.buildError} dismissible />
|
||||
)}
|
||||
|
||||
{Boolean(workspaceErrors.cancellationError) && (
|
||||
<ErrorAlert error={workspaceErrors.cancellationError} dismissible />
|
||||
)}
|
||||
|
||||
{workspace.latest_build.status === "running" &&
|
||||
!workspace.health.healthy && (
|
||||
<Alert
|
||||
@@ -311,16 +311,15 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
|
||||
<Alert
|
||||
severity="error"
|
||||
actions={
|
||||
canRetryDebugMode && (
|
||||
<Button
|
||||
key={0}
|
||||
onClick={handleBuildRetry}
|
||||
variant="text"
|
||||
size="small"
|
||||
>
|
||||
Try in debug mode
|
||||
</Button>
|
||||
)
|
||||
<Button
|
||||
onClick={
|
||||
canRetryDebugMode ? handleBuildRetryDebug : handleBuildRetry
|
||||
}
|
||||
variant="text"
|
||||
size="small"
|
||||
>
|
||||
Retry{canRetryDebugMode && " in debug mode"}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<AlertTitle>Workspace build failed</AlertTitle>
|
||||
@@ -357,17 +356,15 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
|
||||
showBuiltinApps={canUpdateWorkspace}
|
||||
hideSSHButton={hideSSHButton}
|
||||
hideVSCodeDesktopButton={hideVSCodeDesktopButton}
|
||||
serverVersion={serverVersion}
|
||||
serverVersion={buildInfo?.version || ""}
|
||||
onUpdateAgent={handleUpdate} // On updating the workspace the agent version is also updated
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{workspaceErrors[WorkspaceErrors.GET_BUILDS_ERROR] ? (
|
||||
<ErrorAlert
|
||||
error={workspaceErrors[WorkspaceErrors.GET_BUILDS_ERROR]}
|
||||
/>
|
||||
{workspaceErrors.getBuildsError ? (
|
||||
<ErrorAlert error={workspaceErrors.getBuildsError} />
|
||||
) : (
|
||||
<BuildsTable
|
||||
builds={builds}
|
||||
|
||||
@@ -1,26 +1,29 @@
|
||||
import { type FC } from "react";
|
||||
import type { Workspace, WorkspaceBuildParameter } from "api/typesGenerated";
|
||||
import { BuildParametersPopover } from "./BuildParametersPopover";
|
||||
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import Button from "@mui/material/Button";
|
||||
import BlockIcon from "@mui/icons-material/Block";
|
||||
import LoadingButton from "@mui/lab/LoadingButton";
|
||||
import ButtonGroup from "@mui/material/ButtonGroup";
|
||||
import CloudQueueIcon from "@mui/icons-material/CloudQueue";
|
||||
import CropSquareIcon from "@mui/icons-material/CropSquare";
|
||||
import PlayCircleOutlineIcon from "@mui/icons-material/PlayCircleOutline";
|
||||
import ReplayIcon from "@mui/icons-material/Replay";
|
||||
import { FC } from "react";
|
||||
import BlockOutlined from "@mui/icons-material/BlockOutlined";
|
||||
import ButtonGroup from "@mui/material/ButtonGroup";
|
||||
import { Workspace, WorkspaceBuildParameter } from "api/typesGenerated";
|
||||
import { BuildParametersPopover } from "./BuildParametersPopover";
|
||||
import BlockIcon from "@mui/icons-material/Block";
|
||||
import OutlinedBlockIcon from "@mui/icons-material/BlockOutlined";
|
||||
import PowerSettingsNewIcon from "@mui/icons-material/PowerSettingsNew";
|
||||
import LoadingButton from "@mui/lab/LoadingButton";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import RetryIcon from "@mui/icons-material/BuildOutlined";
|
||||
import RetryDebugIcon from "@mui/icons-material/BugReportOutlined";
|
||||
|
||||
interface WorkspaceAction {
|
||||
interface WorkspaceActionProps {
|
||||
loading?: boolean;
|
||||
handleAction: () => void;
|
||||
disabled?: boolean;
|
||||
tooltipText?: string;
|
||||
}
|
||||
|
||||
export const UpdateButton: FC<WorkspaceAction> = ({
|
||||
export const UpdateButton: FC<WorkspaceActionProps> = ({
|
||||
handleAction,
|
||||
loading,
|
||||
}) => {
|
||||
@@ -37,7 +40,7 @@ export const UpdateButton: FC<WorkspaceAction> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export const ActivateButton: FC<WorkspaceAction> = ({
|
||||
export const ActivateButton: FC<WorkspaceActionProps> = ({
|
||||
handleAction,
|
||||
loading,
|
||||
}) => {
|
||||
@@ -54,7 +57,7 @@ export const ActivateButton: FC<WorkspaceAction> = ({
|
||||
};
|
||||
|
||||
export const StartButton: FC<
|
||||
Omit<WorkspaceAction, "handleAction"> & {
|
||||
Omit<WorkspaceActionProps, "handleAction"> & {
|
||||
workspace: Workspace;
|
||||
handleAction: (buildParameters?: WorkspaceBuildParameter[]) => void;
|
||||
}
|
||||
@@ -63,7 +66,7 @@ export const StartButton: FC<
|
||||
<ButtonGroup
|
||||
variant="outlined"
|
||||
sx={{
|
||||
// Workaround to make the border transitions smmothly on button groups
|
||||
// Workaround to make the border transitions smoothly on button groups
|
||||
"& > button:hover + button": {
|
||||
borderLeft: "1px solid #FFF",
|
||||
},
|
||||
@@ -94,7 +97,10 @@ export const StartButton: FC<
|
||||
);
|
||||
};
|
||||
|
||||
export const StopButton: FC<WorkspaceAction> = ({ handleAction, loading }) => {
|
||||
export const StopButton: FC<WorkspaceActionProps> = ({
|
||||
handleAction,
|
||||
loading,
|
||||
}) => {
|
||||
return (
|
||||
<LoadingButton
|
||||
loading={loading}
|
||||
@@ -109,7 +115,7 @@ export const StopButton: FC<WorkspaceAction> = ({ handleAction, loading }) => {
|
||||
};
|
||||
|
||||
export const RestartButton: FC<
|
||||
Omit<WorkspaceAction, "handleAction"> & {
|
||||
Omit<WorkspaceActionProps, "handleAction"> & {
|
||||
workspace: Workspace;
|
||||
handleAction: (buildParameters?: WorkspaceBuildParameter[]) => void;
|
||||
}
|
||||
@@ -118,7 +124,7 @@ export const RestartButton: FC<
|
||||
<ButtonGroup
|
||||
variant="outlined"
|
||||
sx={{
|
||||
// Workaround to make the border transitions smmothly on button groups
|
||||
// Workaround to make the border transitions smoothly on button groups
|
||||
"& > button:hover + button": {
|
||||
borderLeft: "1px solid #FFF",
|
||||
},
|
||||
@@ -150,7 +156,7 @@ export const RestartButton: FC<
|
||||
);
|
||||
};
|
||||
|
||||
export const CancelButton: FC<WorkspaceAction> = ({ handleAction }) => {
|
||||
export const CancelButton: FC<WorkspaceActionProps> = ({ handleAction }) => {
|
||||
return (
|
||||
<Button startIcon={<BlockIcon />} onClick={handleAction}>
|
||||
Cancel
|
||||
@@ -164,7 +170,7 @@ interface DisabledProps {
|
||||
|
||||
export const DisabledButton: FC<DisabledProps> = ({ label }) => {
|
||||
return (
|
||||
<Button startIcon={<BlockOutlined />} disabled>
|
||||
<Button startIcon={<OutlinedBlockIcon />} disabled>
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
@@ -181,3 +187,21 @@ export const ActionLoadingButton: FC<LoadingProps> = ({ label }) => {
|
||||
</LoadingButton>
|
||||
);
|
||||
};
|
||||
|
||||
type DebugButtonProps = Omit<WorkspaceActionProps, "loading"> & {
|
||||
debug?: boolean;
|
||||
};
|
||||
|
||||
export const RetryButton = ({
|
||||
handleAction,
|
||||
debug = false,
|
||||
}: DebugButtonProps) => {
|
||||
return (
|
||||
<Button
|
||||
startIcon={debug ? <RetryDebugIcon /> : <RetryIcon />}
|
||||
onClick={handleAction}
|
||||
>
|
||||
Retry{debug && " (Debug)"}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { FC, Fragment, ReactNode } from "react";
|
||||
import { type FC, type ReactNode, Fragment } from "react";
|
||||
import { Workspace, WorkspaceBuildParameter } from "api/typesGenerated";
|
||||
import { useWorkspaceDuplication } from "pages/CreateWorkspacePage/useWorkspaceDuplication";
|
||||
|
||||
import { workspaceUpdatePolicy } from "utils/workspace";
|
||||
import { type ButtonType, actionsByWorkspaceStatus } from "./constants";
|
||||
|
||||
import {
|
||||
ActionLoadingButton,
|
||||
CancelButton,
|
||||
@@ -10,12 +14,8 @@ import {
|
||||
RestartButton,
|
||||
UpdateButton,
|
||||
ActivateButton,
|
||||
RetryButton,
|
||||
} from "./Buttons";
|
||||
import {
|
||||
ButtonMapping,
|
||||
ButtonTypesEnum,
|
||||
actionsByWorkspaceStatus,
|
||||
} from "./constants";
|
||||
|
||||
import Divider from "@mui/material/Divider";
|
||||
import DuplicateIcon from "@mui/icons-material/FileCopyOutlined";
|
||||
@@ -30,7 +30,6 @@ import {
|
||||
MoreMenuTrigger,
|
||||
ThreeDotsButton,
|
||||
} from "components/MoreMenu/MoreMenu";
|
||||
import { workspaceUpdatePolicy } from "utils/workspace";
|
||||
|
||||
export interface WorkspaceActionsProps {
|
||||
workspace: Workspace;
|
||||
@@ -42,11 +41,14 @@ export interface WorkspaceActionsProps {
|
||||
handleCancel: () => void;
|
||||
handleSettings: () => void;
|
||||
handleChangeVersion: () => void;
|
||||
handleRetry: () => void;
|
||||
handleRetryDebug: () => void;
|
||||
handleDormantActivate: () => void;
|
||||
isUpdating: boolean;
|
||||
isRestarting: boolean;
|
||||
children?: ReactNode;
|
||||
canChangeVersions: boolean;
|
||||
canRetryDebug: boolean;
|
||||
}
|
||||
|
||||
export const WorkspaceActions: FC<WorkspaceActionsProps> = ({
|
||||
@@ -58,92 +60,78 @@ export const WorkspaceActions: FC<WorkspaceActionsProps> = ({
|
||||
handleUpdate,
|
||||
handleCancel,
|
||||
handleSettings,
|
||||
handleRetry,
|
||||
handleRetryDebug,
|
||||
handleChangeVersion,
|
||||
handleDormantActivate: handleDormantActivate,
|
||||
handleDormantActivate,
|
||||
isUpdating,
|
||||
isRestarting,
|
||||
canChangeVersions,
|
||||
canRetryDebug,
|
||||
}) => {
|
||||
const {
|
||||
canCancel,
|
||||
canAcceptJobs,
|
||||
actions: actionsByStatus,
|
||||
} = actionsByWorkspaceStatus(workspace, workspace.latest_build.status);
|
||||
const canBeUpdated = workspace.outdated && canAcceptJobs;
|
||||
const { duplicateWorkspace, isDuplicationReady } =
|
||||
useWorkspaceDuplication(workspace);
|
||||
|
||||
const disabled =
|
||||
const { actions, canCancel, canAcceptJobs } = actionsByWorkspaceStatus(
|
||||
workspace,
|
||||
canRetryDebug,
|
||||
);
|
||||
|
||||
const mustUpdate =
|
||||
workspaceUpdatePolicy(workspace, canChangeVersions) === "always" &&
|
||||
workspace.outdated;
|
||||
|
||||
const tooltipText = ((): string => {
|
||||
if (!disabled) {
|
||||
return "";
|
||||
}
|
||||
if (workspace.template_require_active_version) {
|
||||
return "This template requires automatic updates";
|
||||
}
|
||||
if (workspace.automatic_updates === "always") {
|
||||
return "You have enabled automatic updates for this workspace";
|
||||
}
|
||||
return "";
|
||||
})();
|
||||
const tooltipText = getTooltipText(workspace, mustUpdate);
|
||||
const canBeUpdated = workspace.outdated && canAcceptJobs;
|
||||
|
||||
// A mapping of button type to the corresponding React component
|
||||
const buttonMapping: ButtonMapping = {
|
||||
[ButtonTypesEnum.update]: <UpdateButton handleAction={handleUpdate} />,
|
||||
[ButtonTypesEnum.updating]: (
|
||||
<UpdateButton loading handleAction={handleUpdate} />
|
||||
),
|
||||
[ButtonTypesEnum.start]: (
|
||||
const buttonMapping: Record<ButtonType, ReactNode> = {
|
||||
update: <UpdateButton handleAction={handleUpdate} />,
|
||||
updating: <UpdateButton loading handleAction={handleUpdate} />,
|
||||
start: (
|
||||
<StartButton
|
||||
workspace={workspace}
|
||||
handleAction={handleStart}
|
||||
disabled={disabled}
|
||||
disabled={mustUpdate}
|
||||
tooltipText={tooltipText}
|
||||
/>
|
||||
),
|
||||
[ButtonTypesEnum.starting]: (
|
||||
starting: (
|
||||
<StartButton
|
||||
loading
|
||||
workspace={workspace}
|
||||
handleAction={handleStart}
|
||||
disabled={disabled}
|
||||
disabled={mustUpdate}
|
||||
tooltipText={tooltipText}
|
||||
/>
|
||||
),
|
||||
[ButtonTypesEnum.stop]: <StopButton handleAction={handleStop} />,
|
||||
[ButtonTypesEnum.stopping]: (
|
||||
<StopButton loading handleAction={handleStop} />
|
||||
),
|
||||
[ButtonTypesEnum.restart]: (
|
||||
stop: <StopButton handleAction={handleStop} />,
|
||||
stopping: <StopButton loading handleAction={handleStop} />,
|
||||
restart: (
|
||||
<RestartButton
|
||||
workspace={workspace}
|
||||
handleAction={handleRestart}
|
||||
disabled={disabled}
|
||||
disabled={mustUpdate}
|
||||
tooltipText={tooltipText}
|
||||
/>
|
||||
),
|
||||
[ButtonTypesEnum.restarting]: (
|
||||
restarting: (
|
||||
<RestartButton
|
||||
loading
|
||||
workspace={workspace}
|
||||
handleAction={handleRestart}
|
||||
disabled={disabled}
|
||||
disabled={mustUpdate}
|
||||
tooltipText={tooltipText}
|
||||
/>
|
||||
),
|
||||
[ButtonTypesEnum.deleting]: <ActionLoadingButton label="Deleting" />,
|
||||
[ButtonTypesEnum.canceling]: <DisabledButton label="Canceling..." />,
|
||||
[ButtonTypesEnum.deleted]: <DisabledButton label="Deleted" />,
|
||||
[ButtonTypesEnum.pending]: <ActionLoadingButton label="Pending..." />,
|
||||
[ButtonTypesEnum.activate]: (
|
||||
<ActivateButton handleAction={handleDormantActivate} />
|
||||
),
|
||||
[ButtonTypesEnum.activating]: (
|
||||
<ActivateButton loading handleAction={handleDormantActivate} />
|
||||
),
|
||||
deleting: <ActionLoadingButton label="Deleting" />,
|
||||
canceling: <DisabledButton label="Canceling..." />,
|
||||
deleted: <DisabledButton label="Deleted" />,
|
||||
pending: <ActionLoadingButton label="Pending..." />,
|
||||
activate: <ActivateButton handleAction={handleDormantActivate} />,
|
||||
activating: <ActivateButton loading handleAction={handleDormantActivate} />,
|
||||
retry: <RetryButton handleAction={handleRetry} />,
|
||||
retryDebug: <RetryButton debug handleAction={handleRetryDebug} />,
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -151,19 +139,18 @@ export const WorkspaceActions: FC<WorkspaceActionsProps> = ({
|
||||
css={{ display: "flex", alignItems: "center", gap: 12 }}
|
||||
data-testid="workspace-actions"
|
||||
>
|
||||
{canBeUpdated &&
|
||||
(isUpdating
|
||||
? buttonMapping[ButtonTypesEnum.updating]
|
||||
: buttonMapping[ButtonTypesEnum.update])}
|
||||
{canBeUpdated && (
|
||||
<>{isUpdating ? buttonMapping.updating : buttonMapping.update}</>
|
||||
)}
|
||||
|
||||
{isRestarting && buttonMapping[ButtonTypesEnum.restarting]}
|
||||
|
||||
{!isRestarting &&
|
||||
actionsByStatus.map((action) => (
|
||||
<Fragment key={action}>{buttonMapping[action]}</Fragment>
|
||||
))}
|
||||
{isRestarting
|
||||
? buttonMapping.restarting
|
||||
: actions.map((action) => (
|
||||
<Fragment key={action}>{buttonMapping[action]}</Fragment>
|
||||
))}
|
||||
|
||||
{canCancel && <CancelButton handleAction={handleCancel} />}
|
||||
|
||||
<MoreMenu>
|
||||
<MoreMenuTrigger>
|
||||
<ThreeDotsButton
|
||||
@@ -211,3 +198,19 @@ export const WorkspaceActions: FC<WorkspaceActionsProps> = ({
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function getTooltipText(workspace: Workspace, disabled: boolean): string {
|
||||
if (!disabled) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (workspace.template_require_active_version) {
|
||||
return "This template requires automatic updates";
|
||||
}
|
||||
|
||||
if (workspace.automatic_updates === "always") {
|
||||
return "You have enabled automatic updates for this workspace";
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -1,101 +1,119 @@
|
||||
import { Workspace, WorkspaceStatus } from "api/typesGenerated";
|
||||
import { ReactNode } from "react";
|
||||
import { type Workspace, type WorkspaceStatus } from "api/typesGenerated";
|
||||
|
||||
// the button types we have
|
||||
export enum ButtonTypesEnum {
|
||||
start = "start",
|
||||
starting = "starting",
|
||||
stop = "stop",
|
||||
stopping = "stopping",
|
||||
restart = "restart",
|
||||
restarting = "restarting",
|
||||
deleting = "deleting",
|
||||
update = "update",
|
||||
updating = "updating",
|
||||
activate = "activate",
|
||||
activating = "activating",
|
||||
// disabled buttons
|
||||
canceling = "canceling",
|
||||
deleted = "deleted",
|
||||
pending = "pending",
|
||||
}
|
||||
/**
|
||||
* An iterable of all button types supported by the workspace actions UI
|
||||
*/
|
||||
export const buttonTypes = [
|
||||
"start",
|
||||
"starting",
|
||||
"stop",
|
||||
"stopping",
|
||||
"restart",
|
||||
"restarting",
|
||||
"deleting",
|
||||
"update",
|
||||
"updating",
|
||||
"activate",
|
||||
"activating",
|
||||
|
||||
export type ButtonMapping = {
|
||||
[key in ButtonTypesEnum]: ReactNode;
|
||||
};
|
||||
// There's no need for a retrying state because retrying starts a transition
|
||||
// into one of the starting, stopping, or deleting states (based on the
|
||||
// WorkspaceTransition type)
|
||||
"retry",
|
||||
"retryDebug",
|
||||
|
||||
interface WorkspaceAbilities {
|
||||
actions: ButtonTypesEnum[];
|
||||
// These are buttons that should be used with disabled UI elements
|
||||
"canceling",
|
||||
"deleted",
|
||||
"pending",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* A button type supported by the workspace actions UI
|
||||
*/
|
||||
export type ButtonType = (typeof buttonTypes)[number];
|
||||
|
||||
type WorkspaceAbilities = {
|
||||
actions: readonly ButtonType[];
|
||||
canCancel: boolean;
|
||||
canAcceptJobs: boolean;
|
||||
}
|
||||
};
|
||||
|
||||
export const actionsByWorkspaceStatus = (
|
||||
workspace: Workspace,
|
||||
status: WorkspaceStatus,
|
||||
canRetryDebug: boolean,
|
||||
): WorkspaceAbilities => {
|
||||
if (workspace.dormant_at) {
|
||||
return {
|
||||
actions: [ButtonTypesEnum.activate],
|
||||
actions: ["activate"],
|
||||
canCancel: false,
|
||||
canAcceptJobs: false,
|
||||
};
|
||||
}
|
||||
|
||||
const status = workspace.latest_build.status;
|
||||
if (status === "failed" && canRetryDebug) {
|
||||
return {
|
||||
...statusToActions.failed,
|
||||
actions: ["retry", "retryDebug"],
|
||||
};
|
||||
}
|
||||
|
||||
return statusToActions[status];
|
||||
};
|
||||
|
||||
const statusToActions: Record<WorkspaceStatus, WorkspaceAbilities> = {
|
||||
starting: {
|
||||
actions: [ButtonTypesEnum.starting],
|
||||
actions: ["starting"],
|
||||
canCancel: true,
|
||||
canAcceptJobs: false,
|
||||
},
|
||||
running: {
|
||||
actions: [ButtonTypesEnum.stop, ButtonTypesEnum.restart],
|
||||
actions: ["stop", "restart"],
|
||||
canCancel: false,
|
||||
canAcceptJobs: true,
|
||||
},
|
||||
stopping: {
|
||||
actions: [ButtonTypesEnum.stopping],
|
||||
actions: ["stopping"],
|
||||
canCancel: true,
|
||||
canAcceptJobs: false,
|
||||
},
|
||||
stopped: {
|
||||
actions: [ButtonTypesEnum.start],
|
||||
actions: ["start"],
|
||||
canCancel: false,
|
||||
canAcceptJobs: true,
|
||||
},
|
||||
canceled: {
|
||||
actions: [ButtonTypesEnum.start, ButtonTypesEnum.stop],
|
||||
actions: ["start", "stop"],
|
||||
canCancel: false,
|
||||
canAcceptJobs: true,
|
||||
},
|
||||
|
||||
// in the case of an error
|
||||
failed: {
|
||||
actions: [ButtonTypesEnum.start, ButtonTypesEnum.stop],
|
||||
actions: ["retry"],
|
||||
canCancel: false,
|
||||
canAcceptJobs: true,
|
||||
},
|
||||
/**
|
||||
* disabled states
|
||||
*/
|
||||
|
||||
// Disabled states
|
||||
canceling: {
|
||||
actions: [ButtonTypesEnum.canceling],
|
||||
actions: ["canceling"],
|
||||
canCancel: false,
|
||||
canAcceptJobs: false,
|
||||
},
|
||||
deleting: {
|
||||
actions: [ButtonTypesEnum.deleting],
|
||||
actions: ["deleting"],
|
||||
canCancel: true,
|
||||
canAcceptJobs: false,
|
||||
},
|
||||
deleted: {
|
||||
actions: [ButtonTypesEnum.deleted],
|
||||
actions: ["deleted"],
|
||||
canCancel: false,
|
||||
canAcceptJobs: false,
|
||||
},
|
||||
pending: {
|
||||
actions: [ButtonTypesEnum.pending],
|
||||
actions: ["pending"],
|
||||
canCancel: false,
|
||||
canAcceptJobs: false,
|
||||
},
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { type Workspace } from "api/typesGenerated";
|
||||
import { screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import EventSourceMock from "eventsourcemock";
|
||||
@@ -5,6 +6,7 @@ import { rest } from "msw";
|
||||
import {
|
||||
MockTemplate,
|
||||
MockWorkspace,
|
||||
MockFailedWorkspace,
|
||||
MockWorkspaceBuild,
|
||||
MockStoppedWorkspace,
|
||||
MockStartingWorkspace,
|
||||
@@ -21,8 +23,9 @@ import { renderWithAuth } from "testHelpers/renderHelpers";
|
||||
import { server } from "testHelpers/server";
|
||||
import { WorkspacePage } from "./WorkspacePage";
|
||||
|
||||
// It renders the workspace page and waits for it be loaded
|
||||
const renderWorkspacePage = async () => {
|
||||
// Renders the workspace page and waits for it be loaded
|
||||
const renderWorkspacePage = async (workspace: Workspace) => {
|
||||
jest.spyOn(api, "getWorkspaceByOwnerAndName").mockResolvedValue(workspace);
|
||||
jest.spyOn(api, "getTemplate").mockResolvedValueOnce(MockTemplate);
|
||||
jest.spyOn(api, "getTemplateVersionRichParameters").mockResolvedValueOnce([]);
|
||||
jest
|
||||
@@ -34,24 +37,33 @@ const renderWorkspacePage = async () => {
|
||||
options.onDone && options.onDone();
|
||||
return new WebSocket("");
|
||||
});
|
||||
|
||||
renderWithAuth(<WorkspacePage />, {
|
||||
route: `/@${MockWorkspace.owner_name}/${MockWorkspace.name}`,
|
||||
route: `/@${workspace.owner_name}/${workspace.name}`,
|
||||
path: "/:username/:workspace",
|
||||
});
|
||||
await screen.findByText(MockWorkspace.name);
|
||||
|
||||
await screen.findByText(workspace.name);
|
||||
};
|
||||
|
||||
/**
|
||||
* Requests and responses related to workspace status are unrelated, so we can't test in the usual way.
|
||||
* Instead, test that button clicks produce the correct requests and that responses produce the correct UI.
|
||||
* We don't need to test the UI exhaustively because Storybook does that; just enough to prove that the
|
||||
* workspaceStatus was calculated correctly.
|
||||
* Requests and responses related to workspace status are unrelated, so we can't
|
||||
* test in the usual way. Instead, test that button clicks produce the correct
|
||||
* requests and that responses produce the correct UI.
|
||||
*
|
||||
* We don't need to test the UI exhaustively because Storybook does that; just
|
||||
* enough to prove that the workspaceStatus was calculated correctly.
|
||||
*/
|
||||
const testButton = async (label: string, actionMock: jest.SpyInstance) => {
|
||||
const user = userEvent.setup();
|
||||
await renderWorkspacePage();
|
||||
const testButton = async (
|
||||
workspace: Workspace,
|
||||
name: string | RegExp,
|
||||
actionMock: jest.SpyInstance,
|
||||
) => {
|
||||
await renderWorkspacePage(workspace);
|
||||
const workspaceActions = screen.getByTestId("workspace-actions");
|
||||
const button = within(workspaceActions).getByRole("button", { name: label });
|
||||
const button = within(workspaceActions).getByRole("button", { name });
|
||||
|
||||
const user = userEvent.setup();
|
||||
await user.click(button);
|
||||
expect(actionMock).toBeCalled();
|
||||
};
|
||||
@@ -78,7 +90,7 @@ describe("WorkspacePage", () => {
|
||||
const deleteWorkspaceMock = jest
|
||||
.spyOn(api, "deleteWorkspace")
|
||||
.mockResolvedValueOnce(MockWorkspaceBuild);
|
||||
await renderWorkspacePage();
|
||||
await renderWorkspacePage(MockWorkspace);
|
||||
|
||||
// open the workspace action popover so we have access to all available ctas
|
||||
const trigger = screen.getByTestId("workspace-options-button");
|
||||
@@ -121,7 +133,7 @@ describe("WorkspacePage", () => {
|
||||
const deleteWorkspaceMock = jest
|
||||
.spyOn(api, "deleteWorkspace")
|
||||
.mockResolvedValueOnce(MockWorkspaceBuildDelete);
|
||||
await renderWorkspacePage();
|
||||
await renderWorkspacePage(MockWorkspace);
|
||||
|
||||
// open the workspace action popover so we have access to all available ctas
|
||||
const trigger = screen.getByTestId("workspace-options-button");
|
||||
@@ -166,10 +178,12 @@ describe("WorkspacePage", () => {
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const startWorkspaceMock = jest
|
||||
.spyOn(api, "startWorkspace")
|
||||
.mockImplementation(() => Promise.resolve(MockWorkspaceBuild));
|
||||
await testButton("Start", startWorkspaceMock);
|
||||
|
||||
await testButton(MockStoppedWorkspace, "Start", startWorkspaceMock);
|
||||
});
|
||||
|
||||
it("requests a stop job when the user presses Stop", async () => {
|
||||
@@ -177,7 +191,7 @@ describe("WorkspacePage", () => {
|
||||
.spyOn(api, "stopWorkspace")
|
||||
.mockResolvedValueOnce(MockWorkspaceBuild);
|
||||
|
||||
await testButton("Stop", stopWorkspaceMock);
|
||||
await testButton(MockWorkspace, "Stop", stopWorkspaceMock);
|
||||
});
|
||||
|
||||
it("requests a stop when the user presses Restart", async () => {
|
||||
@@ -186,7 +200,7 @@ describe("WorkspacePage", () => {
|
||||
.mockResolvedValueOnce(MockWorkspaceBuild);
|
||||
|
||||
// Render
|
||||
await renderWorkspacePage();
|
||||
await renderWorkspacePage(MockWorkspace);
|
||||
|
||||
// Actions
|
||||
const user = userEvent.setup();
|
||||
@@ -209,20 +223,12 @@ describe("WorkspacePage", () => {
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const cancelWorkspaceMock = jest
|
||||
.spyOn(api, "cancelWorkspaceBuild")
|
||||
.mockImplementation(() => Promise.resolve({ message: "job canceled" }));
|
||||
|
||||
await renderWorkspacePage();
|
||||
|
||||
const workspaceActions = screen.getByTestId("workspace-actions");
|
||||
const cancelButton = within(workspaceActions).getByRole("button", {
|
||||
name: "Cancel",
|
||||
});
|
||||
|
||||
await userEvent.click(cancelButton);
|
||||
|
||||
expect(cancelWorkspaceMock).toBeCalled();
|
||||
await testButton(MockStartingWorkspace, "Cancel", cancelWorkspaceMock);
|
||||
});
|
||||
|
||||
it("requests an update when the user presses Update", async () => {
|
||||
@@ -236,7 +242,7 @@ describe("WorkspacePage", () => {
|
||||
.mockResolvedValueOnce(MockWorkspaceBuild);
|
||||
|
||||
// Render
|
||||
await renderWorkspacePage();
|
||||
await renderWorkspacePage(MockWorkspace);
|
||||
|
||||
// Actions
|
||||
const user = userEvent.setup();
|
||||
@@ -265,7 +271,7 @@ describe("WorkspacePage", () => {
|
||||
);
|
||||
|
||||
// Render
|
||||
await renderWorkspacePage();
|
||||
await renderWorkspacePage(MockWorkspace);
|
||||
|
||||
// Actions
|
||||
const user = userEvent.setup();
|
||||
@@ -312,7 +318,7 @@ describe("WorkspacePage", () => {
|
||||
});
|
||||
|
||||
it("shows the timeline build", async () => {
|
||||
await renderWorkspacePage();
|
||||
await renderWorkspacePage(MockWorkspace);
|
||||
const table = await screen.findByTestId("builds-table");
|
||||
|
||||
// Wait for the results to be loaded
|
||||
@@ -339,7 +345,7 @@ describe("WorkspacePage", () => {
|
||||
});
|
||||
const restartWorkspaceSpy = jest.spyOn(api, "restartWorkspace");
|
||||
const user = userEvent.setup();
|
||||
await renderWorkspacePage();
|
||||
await renderWorkspacePage(MockWorkspace);
|
||||
await user.click(screen.getByTestId("build-parameters-button"));
|
||||
const buildParametersForm = await screen.findByTestId(
|
||||
"build-parameters-form",
|
||||
@@ -358,4 +364,91 @@ describe("WorkspacePage", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Tried to get these wired up via describe.each to reduce repetition, but the
|
||||
// syntax just got too convoluted because of the variance in what arguments
|
||||
// each function gets called with
|
||||
describe("Retrying failed workspaces", () => {
|
||||
const retryButtonRe = /^Retry$/i;
|
||||
const retryDebugButtonRe = /^Retry \(Debug\)$/i;
|
||||
|
||||
describe("Retries a failed 'Start' transition", () => {
|
||||
const mockStart = jest.spyOn(api, "startWorkspace");
|
||||
const failedStart: Workspace = {
|
||||
...MockFailedWorkspace,
|
||||
latest_build: {
|
||||
...MockFailedWorkspace.latest_build,
|
||||
transition: "start",
|
||||
},
|
||||
};
|
||||
|
||||
test("Retry with no debug", async () => {
|
||||
await testButton(failedStart, retryButtonRe, mockStart);
|
||||
|
||||
expect(mockStart).toBeCalledWith(
|
||||
failedStart.id,
|
||||
failedStart.latest_build.template_version_id,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
test("Retry with debug logs", async () => {
|
||||
await testButton(failedStart, retryDebugButtonRe, mockStart);
|
||||
|
||||
expect(mockStart).toBeCalledWith(
|
||||
failedStart.id,
|
||||
failedStart.latest_build.template_version_id,
|
||||
"debug",
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Retries a failed 'Stop' transition", () => {
|
||||
const mockStop = jest.spyOn(api, "stopWorkspace");
|
||||
const failedStop: Workspace = {
|
||||
...MockFailedWorkspace,
|
||||
latest_build: {
|
||||
...MockFailedWorkspace.latest_build,
|
||||
transition: "stop",
|
||||
},
|
||||
};
|
||||
|
||||
test("Retry with no debug", async () => {
|
||||
await testButton(failedStop, retryButtonRe, mockStop);
|
||||
expect(mockStop).toBeCalledWith(failedStop.id, undefined);
|
||||
});
|
||||
|
||||
test("Retry with debug logs", async () => {
|
||||
await testButton(failedStop, retryDebugButtonRe, mockStop);
|
||||
expect(mockStop).toBeCalledWith(failedStop.id, "debug");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Retries a failed 'Delete' transition", () => {
|
||||
const mockDelete = jest.spyOn(api, "deleteWorkspace");
|
||||
const failedDelete: Workspace = {
|
||||
...MockFailedWorkspace,
|
||||
latest_build: {
|
||||
...MockFailedWorkspace.latest_build,
|
||||
transition: "delete",
|
||||
},
|
||||
};
|
||||
|
||||
test("Retry with no debug", async () => {
|
||||
await testButton(failedDelete, retryButtonRe, mockDelete);
|
||||
expect(mockDelete).toBeCalledWith(failedDelete.id, {
|
||||
logLevel: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test("Retry with debug logs", async () => {
|
||||
await testButton(failedDelete, retryDebugButtonRe, mockDelete);
|
||||
expect(mockDelete).toBeCalledWith(failedDelete.id, {
|
||||
logLevel: "debug",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
getMaxDeadlineChange,
|
||||
getMinDeadline,
|
||||
} from "utils/schedule";
|
||||
import { Workspace, WorkspaceErrors } from "./Workspace";
|
||||
import { Workspace } from "./Workspace";
|
||||
import { pageTitle } from "utils/page";
|
||||
import { hasJobError } from "utils/workspace";
|
||||
import { UpdateBuildParametersDialog } from "./UpdateBuildParametersDialog";
|
||||
@@ -79,9 +79,6 @@ export const WorkspaceReadyPage = ({
|
||||
...deploymentConfig(),
|
||||
enabled: permissions?.viewDeploymentValues,
|
||||
});
|
||||
const canRetryDebugMode = Boolean(
|
||||
deploymentValues?.config.enable_terraform_debug_mode,
|
||||
);
|
||||
|
||||
// Build logs
|
||||
const buildLogs = useWorkspaceBuildLogs(workspace.latest_build.id);
|
||||
@@ -196,6 +193,22 @@ export const WorkspaceReadyPage = ({
|
||||
// Cancel build
|
||||
const cancelBuildMutation = useMutation(cancelBuild(workspace, queryClient));
|
||||
|
||||
const handleBuildRetry = (debug = false) => {
|
||||
const logLevel = debug ? "debug" : undefined;
|
||||
|
||||
switch (workspace.latest_build.transition) {
|
||||
case "start":
|
||||
startWorkspaceMutation.mutate({ logLevel });
|
||||
break;
|
||||
case "stop":
|
||||
stopWorkspaceMutation.mutate({ logLevel });
|
||||
break;
|
||||
case "delete":
|
||||
deleteWorkspaceMutation.mutate({ logLevel });
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
@@ -242,19 +255,11 @@ export const WorkspaceReadyPage = ({
|
||||
}}
|
||||
handleCancel={cancelBuildMutation.mutate}
|
||||
handleSettings={() => navigate("settings")}
|
||||
handleBuildRetry={() => {
|
||||
switch (workspace.latest_build.transition) {
|
||||
case "start":
|
||||
startWorkspaceMutation.mutate({ logLevel: "debug" });
|
||||
break;
|
||||
case "stop":
|
||||
stopWorkspaceMutation.mutate({ logLevel: "debug" });
|
||||
break;
|
||||
case "delete":
|
||||
deleteWorkspaceMutation.mutate({ logLevel: "debug" });
|
||||
break;
|
||||
}
|
||||
}}
|
||||
handleBuildRetry={() => handleBuildRetry(false)}
|
||||
handleBuildRetryDebug={() => handleBuildRetry(true)}
|
||||
canRetryDebugMode={
|
||||
deploymentValues?.config.enable_terraform_debug_mode ?? false
|
||||
}
|
||||
handleChangeVersion={() => {
|
||||
setChangeVersionDialogOpen(true);
|
||||
}}
|
||||
@@ -273,19 +278,18 @@ export const WorkspaceReadyPage = ({
|
||||
hasMoreBuilds={hasMoreBuilds}
|
||||
canUpdateWorkspace={canUpdateWorkspace}
|
||||
updateMessage={latestVersion?.message}
|
||||
canRetryDebugMode={canRetryDebugMode}
|
||||
canChangeVersions={canChangeVersions}
|
||||
hideSSHButton={featureVisibility["browser_only"]}
|
||||
hideVSCodeDesktopButton={featureVisibility["browser_only"]}
|
||||
workspaceErrors={{
|
||||
[WorkspaceErrors.GET_BUILDS_ERROR]: buildsError,
|
||||
[WorkspaceErrors.BUILD_ERROR]:
|
||||
getBuildsError: buildsError,
|
||||
buildError:
|
||||
restartBuildError ??
|
||||
startWorkspaceMutation.error ??
|
||||
stopWorkspaceMutation.error ??
|
||||
deleteWorkspaceMutation.error ??
|
||||
updateWorkspaceMutation.error,
|
||||
[WorkspaceErrors.CANCELLATION_ERROR]: cancelBuildMutation.error,
|
||||
cancellationError: cancelBuildMutation.error,
|
||||
}}
|
||||
buildInfo={buildInfo}
|
||||
sshPrefix={sshPrefixQuery.data?.hostname_prefix}
|
||||
@@ -297,6 +301,7 @@ export const WorkspaceReadyPage = ({
|
||||
}
|
||||
canAutostart={canAutostart}
|
||||
/>
|
||||
|
||||
<WorkspaceDeleteDialog
|
||||
workspace={workspace}
|
||||
canUpdateTemplate={canDeleteWorkspace}
|
||||
@@ -310,6 +315,7 @@ export const WorkspaceReadyPage = ({
|
||||
}}
|
||||
workspaceBuildDateStr={dayjs(workspace.created_at).fromNow()}
|
||||
/>
|
||||
|
||||
<UpdateBuildParametersDialog
|
||||
missedParameters={
|
||||
changeVersionMutation.error instanceof MissingBuildParameters
|
||||
@@ -329,6 +335,7 @@ export const WorkspaceReadyPage = ({
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<UpdateBuildParametersDialog
|
||||
missedParameters={
|
||||
updateWorkspaceMutation.error instanceof MissingBuildParameters
|
||||
@@ -345,6 +352,7 @@ export const WorkspaceReadyPage = ({
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<ChangeVersionDialog
|
||||
templateVersions={allVersions?.reverse()}
|
||||
template={template}
|
||||
@@ -360,6 +368,7 @@ export const WorkspaceReadyPage = ({
|
||||
changeVersionMutation.mutate({ versionId: templateVersion.id });
|
||||
}}
|
||||
/>
|
||||
|
||||
<WarningDialog
|
||||
open={isConfirmingUpdate}
|
||||
onConfirm={() => {
|
||||
|
||||
Reference in New Issue
Block a user