From b1cc9d618d36561de9bf1b076cb11d5ba27e466d Mon Sep 17 00:00:00 2001 From: Presley Date: Thu, 5 May 2022 05:05:01 +0000 Subject: [PATCH 01/44] Move component and prep --- site/src/components/Workspace/Workspace.tsx | 52 +---------------- site/src/components/Workspace/constants.ts | 3 - .../WorkspaceSection/WorkspaceSection.tsx | 2 +- .../WorkspaceStatusBar/WorkspaceStatusBar.tsx | 57 +++++++++++++++++++ site/src/theme/constants.ts | 3 + 5 files changed, 63 insertions(+), 54 deletions(-) delete mode 100644 site/src/components/Workspace/constants.ts create mode 100644 site/src/components/WorkspaceStatusBar/WorkspaceStatusBar.tsx diff --git a/site/src/components/Workspace/Workspace.tsx b/site/src/components/Workspace/Workspace.tsx index b349569b51b29..d838853532118 100644 --- a/site/src/components/Workspace/Workspace.tsx +++ b/site/src/components/Workspace/Workspace.tsx @@ -1,14 +1,10 @@ -import Box from "@material-ui/core/Box" -import Paper from "@material-ui/core/Paper" import { makeStyles } from "@material-ui/core/styles" import Typography from "@material-ui/core/Typography" -import CloudCircleIcon from "@material-ui/icons/CloudCircle" import React from "react" -import { Link } from "react-router-dom" import * as Types from "../../api/types" import { WorkspaceSchedule } from "../WorkspaceSchedule/WorkspaceSchedule" import { WorkspaceSection } from "../WorkspaceSection/WorkspaceSection" -import * as Constants from "./constants" +import { WorkspaceStatusBar } from "../WorkspaceStatusBar/WorkspaceStatusBar" export interface WorkspaceProps { organization: Types.Organization @@ -25,7 +21,7 @@ export const Workspace: React.FC = ({ organization, template, wo return (
- +
@@ -55,40 +51,6 @@ export const Workspace: React.FC = ({ organization, template, wo ) } -/** - * Component for the header at the top of the workspace page - */ -export const WorkspaceHeader: React.FC = ({ organization, template, workspace }) => { - const styles = useStyles() - - const templateLink = `/templates/${organization.name}/${template.name}` - - return ( - -
- -
- {workspace.name} - - {template.name} - -
-
-
- ) -} - -/** - * Component to render the 'Hero Icon' in the header of a workspace - */ -export const WorkspaceHeroIcon: React.FC = () => { - return ( - - - - ) -} - /** * Temporary placeholder component until we have the sections implemented * Can be removed once the Workspace page has all the necessary sections @@ -115,12 +77,6 @@ export const useStyles = makeStyles((theme) => { display: "flex", flexDirection: "column", }, - section: { - border: `1px solid ${theme.palette.divider}`, - borderRadius: Constants.CardRadius, - padding: Constants.CardPadding, - margin: theme.spacing(1), - }, sidebarContainer: { display: "flex", flexDirection: "column", @@ -129,9 +85,5 @@ export const useStyles = makeStyles((theme) => { timelineContainer: { flex: 1, }, - icon: { - width: Constants.TitleIconSize, - height: Constants.TitleIconSize, - }, } }) diff --git a/site/src/components/Workspace/constants.ts b/site/src/components/Workspace/constants.ts deleted file mode 100644 index 44919f96f5399..0000000000000 --- a/site/src/components/Workspace/constants.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const TitleIconSize = 48 -export const CardRadius = 8 -export const CardPadding = 20 diff --git a/site/src/components/WorkspaceSection/WorkspaceSection.tsx b/site/src/components/WorkspaceSection/WorkspaceSection.tsx index e5453dd259c10..0a43db1504f67 100644 --- a/site/src/components/WorkspaceSection/WorkspaceSection.tsx +++ b/site/src/components/WorkspaceSection/WorkspaceSection.tsx @@ -2,7 +2,7 @@ import Paper from "@material-ui/core/Paper" import { makeStyles } from "@material-ui/core/styles" import Typography from "@material-ui/core/Typography" import React from "react" -import { CardPadding, CardRadius } from "../Workspace/constants" +import { CardRadius, CardPadding } from "../../theme/constants" export interface WorkspaceSectionProps { title: string diff --git a/site/src/components/WorkspaceStatusBar/WorkspaceStatusBar.tsx b/site/src/components/WorkspaceStatusBar/WorkspaceStatusBar.tsx new file mode 100644 index 0000000000000..3f1628bac85f4 --- /dev/null +++ b/site/src/components/WorkspaceStatusBar/WorkspaceStatusBar.tsx @@ -0,0 +1,57 @@ +import Box from "@material-ui/core/Box" +import Paper from "@material-ui/core/Paper" +import Typography from "@material-ui/core/Typography" +import React from "react" +import { Link } from "react-router-dom" +import { WorkspaceProps } from "../Workspace/Workspace" +import CloudCircleIcon from "@material-ui/icons/CloudCircle" +import { CardPadding, CardRadius, TitleIconSize } from "../../theme/constants" +import { makeStyles } from "@material-ui/core/styles" + +/** + * Component for the header at the top of the workspace page + */ +export const WorkspaceStatusBar: React.FC = ({ organization, template, workspace }) => { + const styles = useStyles() + + const templateLink = `/templates/${organization.name}/${template.name}` + + return ( + +
+ + + +
+ {workspace.name} + + {template.name} + +
+
+
+ ) +} + +const useStyles = makeStyles((theme) => { + return { + icon: { + width: TitleIconSize, + height: TitleIconSize, + }, + horizontal: { + display: "flex", + flexDirection: "row", + }, + vertical: { + display: "flex", + flexDirection: "column", + }, + section: { + border: `1px solid ${theme.palette.divider}`, + borderRadius: CardRadius, + padding: CardPadding, + margin: theme.spacing(1), + }, + } +}) diff --git a/site/src/theme/constants.ts b/site/src/theme/constants.ts index fd07a3abcb26f..79bc3444d5edf 100644 --- a/site/src/theme/constants.ts +++ b/site/src/theme/constants.ts @@ -9,3 +9,6 @@ export const emptyBoxShadow = "none" export const navHeight = 56 export const maxWidth = 1380 export const sidePadding = "50px" +export const TitleIconSize = 48 +export const CardRadius = 8 +export const CardPadding = 20 From 0da5e7e4afe3dd68367d04b98b236256b6e489dc Mon Sep 17 00:00:00 2001 From: Presley Date: Thu, 5 May 2022 18:15:39 +0000 Subject: [PATCH 02/44] Make WorkspaceSection more reusable --- .../components/WorkspaceSection/WorkspaceSection.tsx | 12 +++++++----- .../WorkspaceStatusBar/WorkspaceStatusBar.tsx | 11 +++-------- 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/site/src/components/WorkspaceSection/WorkspaceSection.tsx b/site/src/components/WorkspaceSection/WorkspaceSection.tsx index 0a43db1504f67..c55932f31fb47 100644 --- a/site/src/components/WorkspaceSection/WorkspaceSection.tsx +++ b/site/src/components/WorkspaceSection/WorkspaceSection.tsx @@ -5,7 +5,7 @@ import React from "react" import { CardRadius, CardPadding } from "../../theme/constants" export interface WorkspaceSectionProps { - title: string + title?: string } export const WorkspaceSection: React.FC = ({ title, children }) => { @@ -13,11 +13,13 @@ export const WorkspaceSection: React.FC = ({ title, child return ( -
-
- {title} + {title && +
+
+ {title} +
-
+ }
{children}
diff --git a/site/src/components/WorkspaceStatusBar/WorkspaceStatusBar.tsx b/site/src/components/WorkspaceStatusBar/WorkspaceStatusBar.tsx index 3f1628bac85f4..bcf9d6d5828d2 100644 --- a/site/src/components/WorkspaceStatusBar/WorkspaceStatusBar.tsx +++ b/site/src/components/WorkspaceStatusBar/WorkspaceStatusBar.tsx @@ -7,6 +7,7 @@ import { WorkspaceProps } from "../Workspace/Workspace" import CloudCircleIcon from "@material-ui/icons/CloudCircle" import { CardPadding, CardRadius, TitleIconSize } from "../../theme/constants" import { makeStyles } from "@material-ui/core/styles" +import { WorkspaceSection } from "../WorkspaceSection/WorkspaceSection" /** * Component for the header at the top of the workspace page @@ -17,7 +18,7 @@ export const WorkspaceStatusBar: React.FC = ({ organization, tem const templateLink = `/templates/${organization.name}/${template.name}` return ( - +
@@ -29,7 +30,7 @@ export const WorkspaceStatusBar: React.FC = ({ organization, tem
-
+
) } @@ -47,11 +48,5 @@ const useStyles = makeStyles((theme) => { display: "flex", flexDirection: "column", }, - section: { - border: `1px solid ${theme.palette.divider}`, - borderRadius: CardRadius, - padding: CardPadding, - margin: theme.spacing(1), - }, } }) From 580e80110ee1aacd10f42bf0f7fa55600e9da3eb Mon Sep 17 00:00:00 2001 From: Presley Date: Thu, 5 May 2022 18:48:53 +0000 Subject: [PATCH 03/44] Lay out elements --- .../WorkspaceStatusBar/WorkspaceStatusBar.tsx | 39 ++++++++++++++----- 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/site/src/components/WorkspaceStatusBar/WorkspaceStatusBar.tsx b/site/src/components/WorkspaceStatusBar/WorkspaceStatusBar.tsx index bcf9d6d5828d2..5779e47c3e15e 100644 --- a/site/src/components/WorkspaceStatusBar/WorkspaceStatusBar.tsx +++ b/site/src/components/WorkspaceStatusBar/WorkspaceStatusBar.tsx @@ -1,13 +1,14 @@ import Box from "@material-ui/core/Box" -import Paper from "@material-ui/core/Paper" import Typography from "@material-ui/core/Typography" import React from "react" import { Link } from "react-router-dom" import { WorkspaceProps } from "../Workspace/Workspace" import CloudCircleIcon from "@material-ui/icons/CloudCircle" -import { CardPadding, CardRadius, TitleIconSize } from "../../theme/constants" +import { TitleIconSize } from "../../theme/constants" import { makeStyles } from "@material-ui/core/styles" import { WorkspaceSection } from "../WorkspaceSection/WorkspaceSection" +import { Stack } from "../Stack/Stack" +import Button from "@material-ui/core/Button" /** * Component for the header at the top of the workspace page @@ -16,26 +17,41 @@ export const WorkspaceStatusBar: React.FC = ({ organization, tem const styles = useStyles() const templateLink = `/templates/${organization.name}/${template.name}` + const action = "Start" // TODO don't let me merge this + const outOfDate = false // TODO return ( + + + Back to {template.name} +
- - - -
- {workspace.name} - - {template.name} - +
+ + + +
+ {workspace.name} +
+
+
+ + + Settings
+ ) } const useStyles = makeStyles((theme) => { return { + link: { + textDecoration: 'none', + color: theme.palette.text.primary + }, icon: { width: TitleIconSize, height: TitleIconSize, @@ -43,6 +59,9 @@ const useStyles = makeStyles((theme) => { horizontal: { display: "flex", flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + gap: theme.spacing(1) }, vertical: { display: "flex", From b995f4a00a755b31b1179f37eb9334aa49e814ee Mon Sep 17 00:00:00 2001 From: Presley Date: Fri, 6 May 2022 14:37:20 +0000 Subject: [PATCH 04/44] Layout tweaks --- .../components/WorkspaceStatusBar/WorkspaceStatusBar.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/site/src/components/WorkspaceStatusBar/WorkspaceStatusBar.tsx b/site/src/components/WorkspaceStatusBar/WorkspaceStatusBar.tsx index 5779e47c3e15e..65d18bf2e8343 100644 --- a/site/src/components/WorkspaceStatusBar/WorkspaceStatusBar.tsx +++ b/site/src/components/WorkspaceStatusBar/WorkspaceStatusBar.tsx @@ -9,6 +9,7 @@ import { makeStyles } from "@material-ui/core/styles" import { WorkspaceSection } from "../WorkspaceSection/WorkspaceSection" import { Stack } from "../Stack/Stack" import Button from "@material-ui/core/Button" +import Divider from "@material-ui/core/Divider" /** * Component for the header at the top of the workspace page @@ -37,7 +38,10 @@ export const WorkspaceStatusBar: React.FC = ({ organization, tem
- + {outOfDate && + + } + Settings
@@ -61,7 +65,7 @@ const useStyles = makeStyles((theme) => { flexDirection: "row", justifyContent: "space-between", alignItems: "center", - gap: theme.spacing(1) + gap: theme.spacing(2) }, vertical: { display: "flex", From e7dc082f52b4d69f546b3caca9d6a7c34399d205 Mon Sep 17 00:00:00 2001 From: Presley Date: Fri, 6 May 2022 14:37:42 +0000 Subject: [PATCH 05/44] Add outdated to Workspace type --- site/src/api/types.ts | 1 + site/src/testHelpers/entities.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/site/src/api/types.ts b/site/src/api/types.ts index f2308aeb9b461..dcd25b95793cf 100644 --- a/site/src/api/types.ts +++ b/site/src/api/types.ts @@ -69,6 +69,7 @@ export interface WorkspaceBuild { export interface Workspace { id: string + outdated: boolean; created_at: string updated_at: string owner_id: string diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index e7c3e91274635..d84d8fdbf21aa 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -86,6 +86,7 @@ export const MockWorkspace: Workspace = { created_at: "", updated_at: "", template_id: MockTemplate.id, + outdated: false, owner_id: MockUser.id, autostart_schedule: MockWorkspaceAutostartEnabled.schedule, autostop_schedule: MockWorkspaceAutostopEnabled.schedule, From 7f6bbdaabf80da6d2c8401114b56ad482e043309 Mon Sep 17 00:00:00 2001 From: Presley Date: Fri, 6 May 2022 14:38:01 +0000 Subject: [PATCH 06/44] Fill out status bar component --- site/src/components/Workspace/Workspace.tsx | 11 ++++- .../WorkspaceStatusBar/WorkspaceStatusBar.tsx | 45 +++++++++++++++---- 2 files changed, 46 insertions(+), 10 deletions(-) diff --git a/site/src/components/Workspace/Workspace.tsx b/site/src/components/Workspace/Workspace.tsx index d838853532118..31e6f949fa934 100644 --- a/site/src/components/Workspace/Workspace.tsx +++ b/site/src/components/Workspace/Workspace.tsx @@ -6,22 +6,29 @@ import { WorkspaceSchedule } from "../WorkspaceSchedule/WorkspaceSchedule" import { WorkspaceSection } from "../WorkspaceSection/WorkspaceSection" import { WorkspaceStatusBar } from "../WorkspaceStatusBar/WorkspaceStatusBar" +export type WorkspaceStatus = + "started" + | "stopping" + | "stopped" + | "starting" + export interface WorkspaceProps { organization: Types.Organization workspace: Types.Workspace template: Types.Template + status: WorkspaceStatus } /** * Workspace is the top-level component for viewing an individual workspace */ -export const Workspace: React.FC = ({ organization, template, workspace }) => { +export const Workspace: React.FC = ({ organization, template, workspace, status }) => { const styles = useStyles() return (
- +
diff --git a/site/src/components/WorkspaceStatusBar/WorkspaceStatusBar.tsx b/site/src/components/WorkspaceStatusBar/WorkspaceStatusBar.tsx index 65d18bf2e8343..f1b310240b19a 100644 --- a/site/src/components/WorkspaceStatusBar/WorkspaceStatusBar.tsx +++ b/site/src/components/WorkspaceStatusBar/WorkspaceStatusBar.tsx @@ -2,7 +2,7 @@ import Box from "@material-ui/core/Box" import Typography from "@material-ui/core/Typography" import React from "react" import { Link } from "react-router-dom" -import { WorkspaceProps } from "../Workspace/Workspace" +import { WorkspaceStatus } from "../Workspace/Workspace" import CloudCircleIcon from "@material-ui/icons/CloudCircle" import { TitleIconSize } from "../../theme/constants" import { makeStyles } from "@material-ui/core/styles" @@ -10,16 +10,45 @@ import { WorkspaceSection } from "../WorkspaceSection/WorkspaceSection" import { Stack } from "../Stack/Stack" import Button from "@material-ui/core/Button" import Divider from "@material-ui/core/Divider" +import * as Types from "../../api/types" + +const Language = { + stop: "Stop", + start: "Start", + update: "Update", + settings: "Settings" +} +export interface WorkspaceStatusBarProps { + organization: Types.Organization + workspace: Types.Workspace + template: Types.Template + status: WorkspaceStatus + handleUpdate: () => void + handleToggle: () => void +} /** * Component for the header at the top of the workspace page */ -export const WorkspaceStatusBar: React.FC = ({ organization, template, workspace }) => { +export const WorkspaceStatusBar: React.FC = ({ organization, template, workspace, status, handleUpdate, handleToggle }) => { const styles = useStyles() const templateLink = `/templates/${organization.name}/${template.name}` - const action = "Start" // TODO don't let me merge this - const outOfDate = false // TODO + const statusToAction: Record = { + started: Language.stop, + stopping: Language.stop, + stopped: Language.start, + starting: Language.start, + } + // Cannot start or stop in the middle of starting or stopping + const statusToDisabled: Record = { + started: false, + stopping: true, + stopped: false, + starting: true + } + const action = statusToAction[status] + const actionDisabled = statusToDisabled[status] return ( @@ -37,12 +66,12 @@ export const WorkspaceStatusBar: React.FC = ({ organization, tem
- - {outOfDate && - + + {workspace.outdated && + } - Settings + {Language.settings}
From 1bc3e3546e43c518ae47b9091c8aecf1f1501446 Mon Sep 17 00:00:00 2001 From: Presley Date: Fri, 6 May 2022 14:39:25 +0000 Subject: [PATCH 07/44] Format --- site/src/api/types.ts | 2 +- site/src/components/Workspace/Workspace.tsx | 15 ++-- .../WorkspaceSection/WorkspaceSection.tsx | 6 +- .../WorkspaceStatusBar/WorkspaceStatusBar.tsx | 80 +++++++++++-------- 4 files changed, 61 insertions(+), 42 deletions(-) diff --git a/site/src/api/types.ts b/site/src/api/types.ts index a488435b3587f..3132631d1a3fd 100644 --- a/site/src/api/types.ts +++ b/site/src/api/types.ts @@ -70,7 +70,7 @@ export interface WorkspaceBuild { export interface Workspace { id: string - outdated: boolean; + outdated: boolean created_at: string updated_at: string owner_id: string diff --git a/site/src/components/Workspace/Workspace.tsx b/site/src/components/Workspace/Workspace.tsx index 31e6f949fa934..8a838cc046a59 100644 --- a/site/src/components/Workspace/Workspace.tsx +++ b/site/src/components/Workspace/Workspace.tsx @@ -6,11 +6,7 @@ import { WorkspaceSchedule } from "../WorkspaceSchedule/WorkspaceSchedule" import { WorkspaceSection } from "../WorkspaceSection/WorkspaceSection" import { WorkspaceStatusBar } from "../WorkspaceStatusBar/WorkspaceStatusBar" -export type WorkspaceStatus = - "started" - | "stopping" - | "stopped" - | "starting" +export type WorkspaceStatus = "started" | "stopping" | "stopped" | "starting" export interface WorkspaceProps { organization: Types.Organization @@ -28,7 +24,14 @@ export const Workspace: React.FC = ({ organization, template, wo return (
- +
diff --git a/site/src/components/WorkspaceSection/WorkspaceSection.tsx b/site/src/components/WorkspaceSection/WorkspaceSection.tsx index c55932f31fb47..bcdb90c03463c 100644 --- a/site/src/components/WorkspaceSection/WorkspaceSection.tsx +++ b/site/src/components/WorkspaceSection/WorkspaceSection.tsx @@ -2,7 +2,7 @@ import Paper from "@material-ui/core/Paper" import { makeStyles } from "@material-ui/core/styles" import Typography from "@material-ui/core/Typography" import React from "react" -import { CardRadius, CardPadding } from "../../theme/constants" +import { CardPadding, CardRadius } from "../../theme/constants" export interface WorkspaceSectionProps { title?: string @@ -13,13 +13,13 @@ export const WorkspaceSection: React.FC = ({ title, child return ( - {title && + {title && (
{title}
- } + )}
{children}
diff --git a/site/src/components/WorkspaceStatusBar/WorkspaceStatusBar.tsx b/site/src/components/WorkspaceStatusBar/WorkspaceStatusBar.tsx index f1b310240b19a..329db06bbf555 100644 --- a/site/src/components/WorkspaceStatusBar/WorkspaceStatusBar.tsx +++ b/site/src/components/WorkspaceStatusBar/WorkspaceStatusBar.tsx @@ -1,22 +1,22 @@ import Box from "@material-ui/core/Box" +import Button from "@material-ui/core/Button" +import Divider from "@material-ui/core/Divider" +import { makeStyles } from "@material-ui/core/styles" import Typography from "@material-ui/core/Typography" +import CloudCircleIcon from "@material-ui/icons/CloudCircle" import React from "react" import { Link } from "react-router-dom" -import { WorkspaceStatus } from "../Workspace/Workspace" -import CloudCircleIcon from "@material-ui/icons/CloudCircle" +import * as Types from "../../api/types" import { TitleIconSize } from "../../theme/constants" -import { makeStyles } from "@material-ui/core/styles" -import { WorkspaceSection } from "../WorkspaceSection/WorkspaceSection" import { Stack } from "../Stack/Stack" -import Button from "@material-ui/core/Button" -import Divider from "@material-ui/core/Divider" -import * as Types from "../../api/types" +import { WorkspaceStatus } from "../Workspace/Workspace" +import { WorkspaceSection } from "../WorkspaceSection/WorkspaceSection" const Language = { stop: "Stop", start: "Start", - update: "Update", - settings: "Settings" + update: "Update", + settings: "Settings", } export interface WorkspaceStatusBarProps { organization: Types.Organization @@ -30,7 +30,14 @@ export interface WorkspaceStatusBarProps { /** * Component for the header at the top of the workspace page */ -export const WorkspaceStatusBar: React.FC = ({ organization, template, workspace, status, handleUpdate, handleToggle }) => { +export const WorkspaceStatusBar: React.FC = ({ + organization, + template, + workspace, + status, + handleUpdate, + handleToggle, +}) => { const styles = useStyles() const templateLink = `/templates/${organization.name}/${template.name}` @@ -45,7 +52,7 @@ export const WorkspaceStatusBar: React.FC = ({ organiza started: false, stopping: true, stopped: false, - starting: true + starting: true, } const action = statusToAction[status] const actionDisabled = statusToDisabled[status] @@ -53,27 +60,36 @@ export const WorkspaceStatusBar: React.FC = ({ organiza return ( - - Back to {template.name} - -
+ + Back to{" "} + + {template.name} + +
- - - -
- {workspace.name} +
+ + + +
+ {workspace.name} +
+
+
+ + {workspace.outdated && ( + + )} + + + {Language.settings} +
-
- - {workspace.outdated && - - } - - {Language.settings} -
-
) @@ -82,8 +98,8 @@ export const WorkspaceStatusBar: React.FC = ({ organiza const useStyles = makeStyles((theme) => { return { link: { - textDecoration: 'none', - color: theme.palette.text.primary + textDecoration: "none", + color: theme.palette.text.primary, }, icon: { width: TitleIconSize, @@ -94,7 +110,7 @@ const useStyles = makeStyles((theme) => { flexDirection: "row", justifyContent: "space-between", alignItems: "center", - gap: theme.spacing(2) + gap: theme.spacing(2), }, vertical: { display: "flex", From ccbf527db0fb612342a65de03076b311b9894094 Mon Sep 17 00:00:00 2001 From: Presley Date: Fri, 6 May 2022 18:23:43 +0000 Subject: [PATCH 08/44] Add transition to types --- site/src/api/types.ts | 3 +++ site/src/testHelpers/entities.ts | 10 +++++++--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/site/src/api/types.ts b/site/src/api/types.ts index 3132631d1a3fd..7d631af8dec4d 100644 --- a/site/src/api/types.ts +++ b/site/src/api/types.ts @@ -66,6 +66,7 @@ export interface CreateWorkspaceRequest { export interface WorkspaceBuild { id: string + transition: WorkspaceBuildTransition } export interface Workspace { @@ -111,6 +112,8 @@ export interface WorkspaceAutostopRequest { schedule: string } +export type WorkspaceBuildTransition = "start" | "stop" | "delete" + export interface UpdateProfileRequest { readonly username: string readonly email: string diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index d84d8fdbf21aa..928efed2d20a5 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -8,6 +8,7 @@ import { Workspace, WorkspaceAgent, WorkspaceAutostartRequest, + WorkspaceBuildTransition, WorkspaceResource, } from "../api/types" import { AuthMethods } from "../api/typesGenerated" @@ -80,6 +81,11 @@ export const MockWorkspaceAutostopEnabled: WorkspaceAutostartRequest = { schedule: "CRON_TZ=America/Toronto 30 21 * * 1-5", } +export const MockWorkspaceBuild = { + id: "test-workspace-build", + transition: "start" as WorkspaceBuildTransition +} + export const MockWorkspace: Workspace = { id: "test-workspace", name: "Test-Workspace", @@ -90,9 +96,7 @@ export const MockWorkspace: Workspace = { owner_id: MockUser.id, autostart_schedule: MockWorkspaceAutostartEnabled.schedule, autostop_schedule: MockWorkspaceAutostopEnabled.schedule, - latest_build: { - id: "test-workspace-build", - }, + latest_build: MockWorkspaceBuild } export const MockWorkspaceAgent: WorkspaceAgent = { From f6bcbaa797125e43d59997e4a50678c655b1fe91 Mon Sep 17 00:00:00 2001 From: Presley Date: Mon, 9 May 2022 15:32:57 +0000 Subject: [PATCH 09/44] Add api handlers for build toggle --- site/src/api/index.ts | 13 +++++++++++++ site/src/testHelpers/entities.ts | 20 ++++++++++++++++++-- site/src/testHelpers/handlers.ts | 12 ++++++++++++ 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/site/src/api/index.ts b/site/src/api/index.ts index d0e275009f315..d7d0ac32a6253 100644 --- a/site/src/api/index.ts +++ b/site/src/api/index.ts @@ -116,6 +116,19 @@ export const getWorkspaceResources = async (workspaceBuildID: string): Promise async (workspaceId: string, templateVersionId?: string): Promise => { + const payload = { + transition, + templateVersionId + } + const response = await axios.post(`api/v2/workspaces/${workspaceId}/builds`, payload) + return response.data +} + +export const startWorkspace = postWorkspaceBuild("start") +export const stopWorkspace = postWorkspaceBuild("stop") +export const deleteWorkspace = postWorkspaceBuild("delete") + export const createUser = async (user: Types.CreateUserRequest): Promise => { const response = await axios.post("/api/v2/users", user) return response.data diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 928efed2d20a5..7c8f1140e925f 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -83,7 +83,23 @@ export const MockWorkspaceAutostopEnabled: WorkspaceAutostartRequest = { export const MockWorkspaceBuild = { id: "test-workspace-build", - transition: "start" as WorkspaceBuildTransition + transition: "start" as WorkspaceBuildTransition, +} + +// These are special cases of MockWorkspaceBuild for more precise testing +export const MockWorkspaceStart = { + id: "test-workspace-build-start", + transition: "start" +} + +export const MockWorkspaceStop = { + id: "test-workspace-build-stop", + transition: "stop" +} + +export const MockWorkspaceDelete = { + id: "test-workspace-build-delete", + transition: "delete" } export const MockWorkspace: Workspace = { @@ -96,7 +112,7 @@ export const MockWorkspace: Workspace = { owner_id: MockUser.id, autostart_schedule: MockWorkspaceAutostartEnabled.schedule, autostop_schedule: MockWorkspaceAutostopEnabled.schedule, - latest_build: MockWorkspaceBuild + latest_build: MockWorkspaceBuild, } export const MockWorkspaceAgent: WorkspaceAgent = { diff --git a/site/src/testHelpers/handlers.ts b/site/src/testHelpers/handlers.ts index dbc2334c1385d..e2644dbe1a578 100644 --- a/site/src/testHelpers/handlers.ts +++ b/site/src/testHelpers/handlers.ts @@ -1,4 +1,6 @@ import { rest } from "msw" +import { WorkspaceBuildTransition } from "../api/types" +import { CreateWorkspaceBuildRequest } from "../api/typesGenerated" import * as M from "./entities" export const handlers = [ @@ -65,6 +67,16 @@ export const handlers = [ rest.put("/api/v2/workspaces/:workspaceId/autostop", async (req, res, ctx) => { return res(ctx.status(200)) }), + rest.post("/api/v2/workspaces/:workspaceId/builds", async (req, res, ctx) => { + const { transition } = req.body as CreateWorkspaceBuildRequest + const transitionToBuild = { + start: M.MockWorkspaceStart, + stop: M.MockWorkspaceStop, + delete: M.MockWorkspaceDelete + } + const result = transitionToBuild[transition as WorkspaceBuildTransition] + return res(ctx.status(200), ctx.json(result)) + }), // workspace builds rest.get("/api/v2/workspacebuilds/:workspaceBuildId/resources", (req, res, ctx) => { From db348a65f1918b6acd4745c119f2c478ec75c177 Mon Sep 17 00:00:00 2001 From: Presley Date: Mon, 9 May 2022 18:51:24 +0000 Subject: [PATCH 10/44] Format --- site/src/api/index.ts | 16 +++++++++------- site/src/testHelpers/entities.ts | 6 +++--- site/src/testHelpers/handlers.ts | 2 +- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/site/src/api/index.ts b/site/src/api/index.ts index d7d0ac32a6253..3f7254c27b579 100644 --- a/site/src/api/index.ts +++ b/site/src/api/index.ts @@ -116,14 +116,16 @@ export const getWorkspaceResources = async (workspaceBuildID: string): Promise async (workspaceId: string, templateVersionId?: string): Promise => { - const payload = { - transition, - templateVersionId +const postWorkspaceBuild = + (transition: string) => + async (workspaceId: string, templateVersionId?: string): Promise => { + const payload = { + transition, + templateVersionId, + } + const response = await axios.post(`api/v2/workspaces/${workspaceId}/builds`, payload) + return response.data } - const response = await axios.post(`api/v2/workspaces/${workspaceId}/builds`, payload) - return response.data -} export const startWorkspace = postWorkspaceBuild("start") export const stopWorkspace = postWorkspaceBuild("stop") diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 7c8f1140e925f..41ac52c37e5f2 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -89,17 +89,17 @@ export const MockWorkspaceBuild = { // These are special cases of MockWorkspaceBuild for more precise testing export const MockWorkspaceStart = { id: "test-workspace-build-start", - transition: "start" + transition: "start", } export const MockWorkspaceStop = { id: "test-workspace-build-stop", - transition: "stop" + transition: "stop", } export const MockWorkspaceDelete = { id: "test-workspace-build-delete", - transition: "delete" + transition: "delete", } export const MockWorkspace: Workspace = { diff --git a/site/src/testHelpers/handlers.ts b/site/src/testHelpers/handlers.ts index e2644dbe1a578..1485e78a02b14 100644 --- a/site/src/testHelpers/handlers.ts +++ b/site/src/testHelpers/handlers.ts @@ -72,7 +72,7 @@ export const handlers = [ const transitionToBuild = { start: M.MockWorkspaceStart, stop: M.MockWorkspaceStop, - delete: M.MockWorkspaceDelete + delete: M.MockWorkspaceDelete, } const result = transitionToBuild[transition as WorkspaceBuildTransition] return res(ctx.status(200), ctx.json(result)) From 8d5fcfd25af34e14a917801e45e1bae00139cb0e Mon Sep 17 00:00:00 2001 From: Presley Date: Mon, 9 May 2022 18:54:06 +0000 Subject: [PATCH 11/44] Parallelize machine --- .../xServices/workspace/workspaceXService.ts | 77 ++++++++++++------- 1 file changed, 49 insertions(+), 28 deletions(-) diff --git a/site/src/xServices/workspace/workspaceXService.ts b/site/src/xServices/workspace/workspaceXService.ts index 34137ce5f0796..c9d4919ce6ed2 100644 --- a/site/src/xServices/workspace/workspaceXService.ts +++ b/site/src/xServices/workspace/workspaceXService.ts @@ -44,7 +44,7 @@ export const workspaceMachine = createMachine( src: "getWorkspace", id: "getWorkspace", onDone: { - target: "gettingTemplate", + target: "ready", actions: ["assignWorkspace", "clearGetWorkspaceError"], }, onError: { @@ -54,35 +54,56 @@ export const workspaceMachine = createMachine( }, tags: "loading", }, - gettingTemplate: { - invoke: { - src: "getTemplate", - id: "getTemplate", - onDone: { - target: "gettingOrganization", - actions: ["assignTemplate", "clearGetTemplateError"], - }, - onError: { - target: "error", - actions: "assignGetTemplateError", + ready: { + type: "parallel", + states: { + breadcrumb: { + initial: "gettingTemplate", + states: { + gettingTemplate: { + invoke: { + src: "getTemplate", + id: "getTemplate", + onDone: { + target: "gettingOrganization", + actions: ["assignTemplate", "clearGetTemplateError"], + }, + onError: { + target: "error", + actions: "assignGetTemplateError", + }, + }, + tags: "loading", + }, + gettingOrganization: { + invoke: { + src: "getOrganization", + id: "getOrganization", + onDone: { + target: "ready", + actions: ["assignOrganization", "clearGetOrganizationError"], + }, + onError: { + target: "error", + actions: "assignGetOrganizationError", + }, + }, + tags: "loading", + }, + error: {}, + ready: {} + }, }, + build: { + initial: "idle", + states: { + idle: {}, + requesting: {}, + building: {}, + error: {} + } + } }, - tags: "loading", - }, - gettingOrganization: { - invoke: { - src: "getOrganization", - id: "getOrganization", - onDone: { - target: "idle", - actions: ["assignOrganization", "clearGetOrganizationError"], - }, - onError: { - target: "error", - actions: "assignGetOrganizationError", - }, - }, - tags: "loading", }, error: { on: { From 62c40f4d0bc218cd65cd76d6a9038dee585a5e2e Mon Sep 17 00:00:00 2001 From: Presley Date: Mon, 9 May 2022 19:40:00 +0000 Subject: [PATCH 12/44] Lay out basics of build submachine --- .../xServices/workspace/workspaceXService.ts | 82 +++++++++++++++++-- 1 file changed, 77 insertions(+), 5 deletions(-) diff --git a/site/src/xServices/workspace/workspaceXService.ts b/site/src/xServices/workspace/workspaceXService.ts index c9d4919ce6ed2..98201e91430ac 100644 --- a/site/src/xServices/workspace/workspaceXService.ts +++ b/site/src/xServices/workspace/workspaceXService.ts @@ -1,6 +1,7 @@ import { assign, createMachine } from "xstate" import * as API from "../../api" import * as Types from "../../api/types" +import * as TypesGen from "../../api/typesGenerated" interface WorkspaceContext { workspace?: Types.Workspace @@ -11,7 +12,7 @@ interface WorkspaceContext { getOrganizationError?: Error | unknown } -type WorkspaceEvent = { type: "GET_WORKSPACE"; workspaceId: string } +type WorkspaceEvent = { type: "GET_WORKSPACE"; workspaceId: string } | { type: "START" } | {type: "STOP"} | { type: "RETRY" } export const workspaceMachine = createMachine( { @@ -29,6 +30,15 @@ export const workspaceMachine = createMachine( getOrganization: { data: Types.Organization } + startWorkspace: { + data: TypesGen.WorkspaceBuild + } + stopWorkspace: { + data: TypesGen.WorkspaceBuild + } + pollBuild: { + data: Types.Workspace + } }, }, id: "workspaceState", @@ -97,10 +107,58 @@ export const workspaceMachine = createMachine( build: { initial: "idle", states: { - idle: {}, - requesting: {}, - building: {}, - error: {} + idle: { + on: { + START: "requestingStart", + STOP: "requestingStop" + } + }, + requestingStart: { + invoke: { + id: "startWorkspace", + src: "startWorkspace", + onDone: "building", + onError: { + target: "error", + actions: ["assignFailedTransition", "assignEnqueueError"] + } + } + }, + requestingStop: { + invoke: { + id: "stopWorkspace", + src: "stopWorkspace", + onDone: "building", + onError: { + target: "error", + actions: ["assignFailedTransition", "assignEnqueueError"] + } + } + }, + building: { + invoke: { + id: "building", + src: "pollBuild", + onDone: "idle", + onError: { + target: "error", + actions: ["assignFailedTransition", "assignBuildError"] + } + } + }, + error: { + on: { + RETRY: [ + { + cond: "failedToStart", + target: "requestingStart" + }, + { + target: "requestingStop" + } + ] + } + } } } }, @@ -154,6 +212,20 @@ export const workspaceMachine = createMachine( throw Error("Cannot get organization without template") } }, + startWorkspace: async (context) => { + if (context.workspace) { + return await API.startWorkspace(context.workspace.id) + } else { + throw Error("Cannot start workspace without workspace id") + } + }, + stopWorkspace: async (context) => { + if (context.workspace) { + return await API.stopWorkspace(context.workspace.id) + } else { + throw Error("Cannot stop workspace without workspace id") + } + } }, }, ) From eaa353d34bdc5deb10fb568baa2fdadfc66642ca Mon Sep 17 00:00:00 2001 From: Presley Date: Mon, 9 May 2022 20:00:29 +0000 Subject: [PATCH 13/44] Pipe start and stop events through - needs status --- site/src/api/types.ts | 3 ++ site/src/components/Workspace/Workspace.tsx | 12 +++---- .../WorkspaceStatusBar/WorkspaceStatusBar.tsx | 32 ++++--------------- .../src/pages/WorkspacePage/WorkspacePage.tsx | 5 ++- 4 files changed, 19 insertions(+), 33 deletions(-) diff --git a/site/src/api/types.ts b/site/src/api/types.ts index 7d631af8dec4d..375501d8b3d4d 100644 --- a/site/src/api/types.ts +++ b/site/src/api/types.ts @@ -1,3 +1,5 @@ +import * as TypesGen from "./typesGenerated" + /** * `BuildInfoResponse` must be kept in sync with the go struct in buildinfo.go. */ @@ -67,6 +69,7 @@ export interface CreateWorkspaceRequest { export interface WorkspaceBuild { id: string transition: WorkspaceBuildTransition + job: TypesGen.ProvisionerJob } export interface Workspace { diff --git a/site/src/components/Workspace/Workspace.tsx b/site/src/components/Workspace/Workspace.tsx index 8a838cc046a59..84d05539d1104 100644 --- a/site/src/components/Workspace/Workspace.tsx +++ b/site/src/components/Workspace/Workspace.tsx @@ -6,19 +6,18 @@ import { WorkspaceSchedule } from "../WorkspaceSchedule/WorkspaceSchedule" import { WorkspaceSection } from "../WorkspaceSection/WorkspaceSection" import { WorkspaceStatusBar } from "../WorkspaceStatusBar/WorkspaceStatusBar" -export type WorkspaceStatus = "started" | "stopping" | "stopped" | "starting" - export interface WorkspaceProps { organization: Types.Organization workspace: Types.Workspace template: Types.Template - status: WorkspaceStatus + handleStart: () => void + handleStop: () => void } /** * Workspace is the top-level component for viewing an individual workspace */ -export const Workspace: React.FC = ({ organization, template, workspace, status }) => { +export const Workspace: React.FC = ({ organization, template, workspace, handleStart, handleStop }) => { const styles = useStyles() return ( @@ -28,9 +27,8 @@ export const Workspace: React.FC = ({ organization, template, wo organization={organization} template={template} workspace={workspace} - status={status} - handleUpdate={} - handleToggle={} + handleStart={handleStart} + handleStop={handleStop} />
diff --git a/site/src/components/WorkspaceStatusBar/WorkspaceStatusBar.tsx b/site/src/components/WorkspaceStatusBar/WorkspaceStatusBar.tsx index 329db06bbf555..4bf8b477e1758 100644 --- a/site/src/components/WorkspaceStatusBar/WorkspaceStatusBar.tsx +++ b/site/src/components/WorkspaceStatusBar/WorkspaceStatusBar.tsx @@ -9,7 +9,6 @@ import { Link } from "react-router-dom" import * as Types from "../../api/types" import { TitleIconSize } from "../../theme/constants" import { Stack } from "../Stack/Stack" -import { WorkspaceStatus } from "../Workspace/Workspace" import { WorkspaceSection } from "../WorkspaceSection/WorkspaceSection" const Language = { @@ -22,9 +21,8 @@ export interface WorkspaceStatusBarProps { organization: Types.Organization workspace: Types.Workspace template: Types.Template - status: WorkspaceStatus - handleUpdate: () => void - handleToggle: () => void + handleStart: () => void + handleStop: () => void } /** @@ -34,28 +32,12 @@ export const WorkspaceStatusBar: React.FC = ({ organization, template, workspace, - status, - handleUpdate, - handleToggle, + handleStart, + handleStop }) => { const styles = useStyles() const templateLink = `/templates/${organization.name}/${template.name}` - const statusToAction: Record = { - started: Language.stop, - stopping: Language.stop, - stopped: Language.start, - starting: Language.start, - } - // Cannot start or stop in the middle of starting or stopping - const statusToDisabled: Record = { - started: false, - stopping: true, - stopped: false, - starting: true, - } - const action = statusToAction[status] - const actionDisabled = statusToDisabled[status] return ( @@ -76,11 +58,11 @@ export const WorkspaceStatusBar: React.FC = ({
- {workspace.outdated && ( - )} diff --git a/site/src/pages/WorkspacePage/WorkspacePage.tsx b/site/src/pages/WorkspacePage/WorkspacePage.tsx index 730953651b860..22c596e60fe51 100644 --- a/site/src/pages/WorkspacePage/WorkspacePage.tsx +++ b/site/src/pages/WorkspacePage/WorkspacePage.tsx @@ -34,7 +34,10 @@ export const WorkspacePage: React.FC = () => { return ( - + workspaceSend("START")} + handleStop={() => workspaceSend("STOP")} + /> ) From 399390e81091af02c0a205629898a819d82ff9d6 Mon Sep 17 00:00:00 2001 From: Presley Date: Tue, 10 May 2022 02:31:23 +0000 Subject: [PATCH 14/44] Attempt at a machine It's so big, but collapsing start and stop made it hard to distinguish retry from toggle --- .../xServices/workspace/workspaceXService.ts | 194 ++++++++++++++++-- 1 file changed, 173 insertions(+), 21 deletions(-) diff --git a/site/src/xServices/workspace/workspaceXService.ts b/site/src/xServices/workspace/workspaceXService.ts index 98201e91430ac..890a4d29f0cfc 100644 --- a/site/src/xServices/workspace/workspaceXService.ts +++ b/site/src/xServices/workspace/workspaceXService.ts @@ -10,9 +10,13 @@ interface WorkspaceContext { getWorkspaceError?: Error | unknown getTemplateError?: Error | unknown getOrganizationError?: Error | unknown + // error enqueuing a ProvisionerJob to create a new WorkspaceBuild + jobError?: Error | unknown + // error creating a new WorkspaceBuild + buildError?: Error | unknown } -type WorkspaceEvent = { type: "GET_WORKSPACE"; workspaceId: string } | { type: "START" } | {type: "STOP"} | { type: "RETRY" } +type WorkspaceEvent = { type: "GET_WORKSPACE"; workspaceId: string } | { type: "START" } | {type: "STOP"} | { type: "RETRY" } | { type: 'REFRESH_WORKSPACE' } export const workspaceMachine = createMachine( { @@ -36,9 +40,6 @@ export const workspaceMachine = createMachine( stopWorkspace: { data: TypesGen.WorkspaceBuild } - pollBuild: { - data: Types.Workspace - } }, }, id: "workspaceState", @@ -48,6 +49,7 @@ export const workspaceMachine = createMachine( on: { GET_WORKSPACE: "gettingWorkspace", }, + tags: "loading" }, gettingWorkspace: { invoke: { @@ -105,55 +107,153 @@ export const workspaceMachine = createMachine( }, }, build: { - initial: "idle", + initial: "dispatch", states: { - idle: { + dispatch: { + always: [ + { + cond: "workspaceIsStarted", + target: "started" + }, + { + cond: "workspaceIsStopped", + target: "stopped" + }, + { + cond: "workspaceIsStarting", + target: "buildingStart" + }, + { + cond: "workspaceIsStopping", + target: "buildingStop" + }, + { target: "error" } + ] + }, + started: { on: { - START: "requestingStart", STOP: "requestingStop" - } + }, + tags: "buildReady" + }, + stopped: { + on: { + START: "requestingStart" + }, + tags: "buildReady" }, requestingStart: { invoke: { id: "startWorkspace", src: "startWorkspace", - onDone: "building", + onDone: { + target: "buildingStart", + actions: "clearJobError" + }, onError: { target: "error", - actions: ["assignFailedTransition", "assignEnqueueError"] + actions: "assignJobError" } - } + }, + tags: "buildLoading" }, requestingStop: { invoke: { id: "stopWorkspace", src: "stopWorkspace", - onDone: "building", + onDone: { target: "buildingStop", actions: "clearJobError" }, onError: { target: "error", - actions: ["assignFailedTransition", "assignEnqueueError"] + actions: "assignJobError" } - } + }, + tags: "loading" }, - building: { + buildingStart: { invoke: { id: "building", src: "pollBuild", - onDone: "idle", - onError: { - target: "error", - actions: ["assignFailedTransition", "assignBuildError"] + }, + initial: "refreshingWorkspace", + states: { + refreshingWorkspace: { + invoke: { + id: "refreshWorkspace", + src: "refreshWorkspace", + onDone: [ + { + cond: "jobSucceeded", + target: "#workspaceState.ready.build.started", + actions: "clearBuildError" + }, + { + cond: "jobPendingOrRunning", + target: "waiting" + }, + { + // if job is canceling, cancelled, or failed, the user needs to retry + target: "#workspaceState.ready.build.error", + actions: "assignBuildError" + } + ], + onError: "waiting" + } + }, + waiting: { + on: { + REFRESH_WORKSPACE: "refreshingWorkspace" + } } - } + }, + tags: "loading" + }, + buildingStop: { + invoke: { + id: "building", + src: "pollBuild", + }, + initial: "refreshingWorkspace", + states: { + refreshingWorkspace: { + invoke: { + id: "refreshWorkspace", + src: "refreshWorkspace", + onDone: [ + { + cond: "jobSucceeded", + target: "#workspaceState.ready.build.stopped", + actions: "clearBuildError" + }, + { + cond: "jobPendingOrRunning", + target: "waiting" + }, + { + // if job is canceling, cancelled, or failed, the user needs to retry + target: "#workspaceState.ready.build.error", + actions: "assignBuildError" + } + ], + onError: "waiting" + } + }, + waiting: { + on: { + REFRESH_WORKSPACE: "refreshingWorkspace" + } + } + }, + tags: "loading" }, error: { on: { RETRY: [ { - cond: "failedToStart", + cond: "triedToStart", target: "requestingStart" }, { + // this could also be post-delete target: "requestingStop" } ] @@ -193,6 +293,42 @@ export const workspaceMachine = createMachine( getOrganizationError: (_, event) => event.data, }), clearGetOrganizationError: (context) => assign({ ...context, getOrganizationError: undefined }), + assignJobError: (_, event) => assign({ + jobError: event.data + }), + clearJobError: (_) => assign({ + jobError: undefined + }), + assignBuildError: (_, event) => assign({ + buildError: event.data + }), + clearBuildError: (_) => assign({ + buildError: undefined + }), + }, + guards: { + workspaceIsStarted: (context) => ( + context.workspace?.latest_build.transition === "start" && context.workspace.latest_build.job.status === "succeeded" + ), + workspaceIsStopped: (context) => ( + context.workspace?.latest_build.transition === "stop" && context.workspace.latest_build.job.status === "succeeded" + ), + workspaceIsStarting: (context) => ( + context.workspace?.latest_build.transition === "start" && ["pending", "running"].includes(context.workspace.latest_build.job.status) + ), + workspaceIsStopping: (context) => ( + context.workspace?.latest_build.transition === "stop" && ["pending", "running"].includes(context.workspace.latest_build.job.status) + ), + triedToStart: (context) => ( + context.workspace?.latest_build.transition === "start" + ), + jobSucceeded: (context) => ( + context.workspace?.latest_build.job.status === "succeeded" + ), + jobPendingOrRunning: (context) => { + const status = context.workspace?.latest_build.job.status + return status === "pending" || status === "running" + } }, services: { getWorkspace: async (_, event) => { @@ -225,6 +361,22 @@ export const workspaceMachine = createMachine( } else { throw Error("Cannot stop workspace without workspace id") } + }, + pollBuild: async (context) => (send) => { + if (context.workspace) { + const workspaceId = context.workspace.id + const intervalId = setInterval(() => send({ type: 'GET_WORKSPACE', workspaceId }), 1000); + return () => clearInterval(intervalId); + } else { + throw Error("Cannot fetch workspace without id") + } + }, + refreshWorkspace: async (context) => { + if (context.workspace) { + return await API.getWorkspace(context.workspace.id) + } else { + throw Error("Cannot refresh workspace without id") + } } }, }, From c100ec58364bce486c0f4150131b64a2d05f083a Mon Sep 17 00:00:00 2001 From: Presley Date: Tue, 10 May 2022 02:32:06 +0000 Subject: [PATCH 15/44] Update mock --- site/src/testHelpers/entities.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 41ac52c37e5f2..98207da316f5a 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -11,7 +11,7 @@ import { WorkspaceBuildTransition, WorkspaceResource, } from "../api/types" -import { AuthMethods } from "../api/typesGenerated" +import { AuthMethods, ProvisionerJobStatus } from "../api/typesGenerated" export const MockSessionToken = { session_token: "my-session-token" } @@ -81,9 +81,20 @@ export const MockWorkspaceAutostopEnabled: WorkspaceAutostartRequest = { schedule: "CRON_TZ=America/Toronto 30 21 * * 1-5", } +export const MockProvisionerJob = { + id: "test-provisioner-job", + created_at: "", + started_at: "", + completed_at: "", + error: "", + status: "succeeded" as ProvisionerJobStatus, + worker_id: "test-worker-id" +} + export const MockWorkspaceBuild = { id: "test-workspace-build", transition: "start" as WorkspaceBuildTransition, + job: MockProvisionerJob } // These are special cases of MockWorkspaceBuild for more precise testing From 903e8ee5b94a9938ce010ff0a1e6645c5507b2d0 Mon Sep 17 00:00:00 2001 From: Presley Date: Tue, 10 May 2022 03:38:48 +0000 Subject: [PATCH 16/44] Render status and buttons --- .../Workspace/Workspace.stories.tsx | 21 +++++- site/src/components/Workspace/Workspace.tsx | 11 ++- .../WorkspaceStatusBar/WorkspaceStatusBar.tsx | 74 +++++++++++++++---- .../src/pages/WorkspacePage/WorkspacePage.tsx | 18 ++++- .../xServices/workspace/workspaceXService.ts | 8 +- 5 files changed, 106 insertions(+), 26 deletions(-) diff --git a/site/src/components/Workspace/Workspace.stories.tsx b/site/src/components/Workspace/Workspace.stories.tsx index cc624d1bbe91c..01b84e5db427d 100644 --- a/site/src/components/Workspace/Workspace.stories.tsx +++ b/site/src/components/Workspace/Workspace.stories.tsx @@ -1,3 +1,4 @@ +import { action } from "@storybook/addon-actions" import { Story } from "@storybook/react" import React from "react" import { MockOrganization, MockTemplate, MockWorkspace } from "../../testHelpers" @@ -11,9 +12,25 @@ export default { const Template: Story = (args) => -export const Example = Template.bind({}) -Example.args = { +export const Started = Template.bind({}) +Started.args = { organization: MockOrganization, template: MockTemplate, workspace: MockWorkspace, + handleStart: action("start"), + handleStop: action("stop"), + handleRetry: action("retry"), + workspaceStatus: "started" } + +export const Starting = Template.bind({}) +Starting.args = { ...Started.args, workspaceStatus: "starting" } + +export const Stopped = Template.bind({}) +Stopped.args = { ...Started.args, workspaceStatus: "stopped" } + +export const Stopping = Template.bind({}) +Stopping.args = { ...Started.args, workspaceStatus: "stopping" } + +export const Error = Template.bind({}) +Error.args = { ...Started.args, workspaceStatus: "error" } diff --git a/site/src/components/Workspace/Workspace.tsx b/site/src/components/Workspace/Workspace.tsx index 84d05539d1104..fe36b52089173 100644 --- a/site/src/components/Workspace/Workspace.tsx +++ b/site/src/components/Workspace/Workspace.tsx @@ -2,22 +2,25 @@ import { makeStyles } from "@material-ui/core/styles" import Typography from "@material-ui/core/Typography" import React from "react" import * as Types from "../../api/types" +import { WorkspaceStatus } from "../../pages/WorkspacePage/WorkspacePage" import { WorkspaceSchedule } from "../WorkspaceSchedule/WorkspaceSchedule" import { WorkspaceSection } from "../WorkspaceSection/WorkspaceSection" import { WorkspaceStatusBar } from "../WorkspaceStatusBar/WorkspaceStatusBar" export interface WorkspaceProps { - organization: Types.Organization + organization?: Types.Organization workspace: Types.Workspace - template: Types.Template + template?: Types.Template handleStart: () => void handleStop: () => void + handleRetry: () => void + workspaceStatus: WorkspaceStatus } /** * Workspace is the top-level component for viewing an individual workspace */ -export const Workspace: React.FC = ({ organization, template, workspace, handleStart, handleStop }) => { +export const Workspace: React.FC = ({ organization, template, workspace, handleStart, handleStop, handleRetry, workspaceStatus }) => { const styles = useStyles() return ( @@ -29,6 +32,8 @@ export const Workspace: React.FC = ({ organization, template, wo workspace={workspace} handleStart={handleStart} handleStop={handleStop} + handleRetry={handleRetry} + workspaceStatus={workspaceStatus} />
diff --git a/site/src/components/WorkspaceStatusBar/WorkspaceStatusBar.tsx b/site/src/components/WorkspaceStatusBar/WorkspaceStatusBar.tsx index 4bf8b477e1758..7b4a84bd912d9 100644 --- a/site/src/components/WorkspaceStatusBar/WorkspaceStatusBar.tsx +++ b/site/src/components/WorkspaceStatusBar/WorkspaceStatusBar.tsx @@ -7,6 +7,7 @@ import CloudCircleIcon from "@material-ui/icons/CloudCircle" import React from "react" import { Link } from "react-router-dom" import * as Types from "../../api/types" +import { WorkspaceStatus } from "../../pages/WorkspacePage/WorkspacePage" import { TitleIconSize } from "../../theme/constants" import { Stack } from "../Stack/Stack" import { WorkspaceSection } from "../WorkspaceSection/WorkspaceSection" @@ -14,15 +15,24 @@ import { WorkspaceSection } from "../WorkspaceSection/WorkspaceSection" const Language = { stop: "Stop", start: "Start", + retry: "Retry", update: "Update", settings: "Settings", + started: "Running", + stopped: "Stopped", + starting: "Building", + stopping: "Stopping", + error: "Build Failed" } + export interface WorkspaceStatusBarProps { - organization: Types.Organization + organization?: Types.Organization workspace: Types.Workspace - template: Types.Template + template?: Types.Template handleStart: () => void handleStop: () => void + handleRetry: () => void + workspaceStatus: WorkspaceStatus } /** @@ -33,43 +43,70 @@ export const WorkspaceStatusBar: React.FC = ({ template, workspace, handleStart, - handleStop + handleStop, + handleRetry, + workspaceStatus }) => { const styles = useStyles() - const templateLink = `/templates/${organization.name}/${template.name}` + const templateLink = `/templates/${organization?.name}/${template?.name}` return ( - - Back to{" "} - - {template.name} - - + + {organization && template && + + Back to{" "} + + {template.name} + + + } +
- - -
{workspace.name}
+ + {workspaceStatus === "started" && Language.started} + {workspaceStatus === "starting" && Language.starting} + {workspaceStatus === "stopped" && Language.stopped} + {workspaceStatus === "stopping" && Language.stopping} + {workspaceStatus === "error" && Language.error} +
+
- + {workspaceStatus === "started" && + () + } + {workspaceStatus === "stopped" && + () + } + {workspaceStatus === "error" && + () + } + {workspace.outdated && ( )} + + {Language.settings} +
@@ -94,6 +131,11 @@ const useStyles = makeStyles((theme) => { alignItems: "center", gap: theme.spacing(2), }, + statusChip: { + border: `solid 1px ${theme.palette.text.hint}`, + borderRadius: theme.shape.borderRadius, + padding: theme.spacing(1) + }, vertical: { display: "flex", flexDirection: "column", diff --git a/site/src/pages/WorkspacePage/WorkspacePage.tsx b/site/src/pages/WorkspacePage/WorkspacePage.tsx index 22c596e60fe51..e42dcc3861209 100644 --- a/site/src/pages/WorkspacePage/WorkspacePage.tsx +++ b/site/src/pages/WorkspacePage/WorkspacePage.tsx @@ -9,6 +9,8 @@ import { Workspace } from "../../components/Workspace/Workspace" import { firstOrItem } from "../../util/array" import { XServiceContext } from "../../xServices/StateContext" +export type WorkspaceStatus = "started" | "starting" | "stopped" | "stopping" | "error" + export const WorkspacePage: React.FC = () => { const { workspace: workspaceQueryParam } = useParams() const workspaceId = firstOrItem(workspaceQueryParam, null) @@ -17,6 +19,18 @@ export const WorkspacePage: React.FC = () => { const [workspaceState, workspaceSend] = useActor(xServices.workspaceXService) const { workspace, template, organization, getWorkspaceError, getTemplateError, getOrganizationError } = workspaceState.context + let workspaceStatus: WorkspaceStatus + if (workspaceState.matches("ready.build.started")) { + workspaceStatus = "started" + } else if (workspaceState.matches("ready.build.stopped")) { + workspaceStatus = "stopped" + } else if (workspaceState.hasTag("starting")) { + workspaceStatus = "starting" + } else if (workspaceState.hasTag("stopping")) { + workspaceStatus = "stopping" + } else { + workspaceStatus = "error" + } /** * Get workspace, template, and organization on mount and whenever workspaceId changes. @@ -28,7 +42,7 @@ export const WorkspacePage: React.FC = () => { if (workspaceState.matches("error")) { return - } else if (!workspace || !template || !organization) { + } else if (!workspace){ return } else { return ( @@ -37,6 +51,8 @@ export const WorkspacePage: React.FC = () => { workspaceSend("START")} handleStop={() => workspaceSend("STOP")} + handleRetry={() => workspaceSend("RETRY")} + workspaceStatus={workspaceStatus} /> diff --git a/site/src/xServices/workspace/workspaceXService.ts b/site/src/xServices/workspace/workspaceXService.ts index 890a4d29f0cfc..82678b197b6c5 100644 --- a/site/src/xServices/workspace/workspaceXService.ts +++ b/site/src/xServices/workspace/workspaceXService.ts @@ -155,7 +155,7 @@ export const workspaceMachine = createMachine( actions: "assignJobError" } }, - tags: "buildLoading" + tags: ["buildLoading", "starting"] }, requestingStop: { invoke: { @@ -167,7 +167,7 @@ export const workspaceMachine = createMachine( actions: "assignJobError" } }, - tags: "loading" + tags: ["buildLoading", "stopping"] }, buildingStart: { invoke: { @@ -205,7 +205,7 @@ export const workspaceMachine = createMachine( } } }, - tags: "loading" + tags: ["buildLoading", "starting"] }, buildingStop: { invoke: { @@ -243,7 +243,7 @@ export const workspaceMachine = createMachine( } } }, - tags: "loading" + tags: ["buildLoading", "stopping"] }, error: { on: { From e8e81ce92fa7a7fb038320fb14d7583f789beda7 Mon Sep 17 00:00:00 2001 From: Presley Date: Tue, 10 May 2022 03:38:57 +0000 Subject: [PATCH 17/44] Fix type error on template page --- .../OrganizationPage/TemplatePage/TemplatePage.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/site/src/pages/TemplatesPages/OrganizationPage/TemplatePage/TemplatePage.tsx b/site/src/pages/TemplatesPages/OrganizationPage/TemplatePage/TemplatePage.tsx index a92335da7e15c..c52a2a434fee2 100644 --- a/site/src/pages/TemplatesPages/OrganizationPage/TemplatePage/TemplatePage.tsx +++ b/site/src/pages/TemplatesPages/OrganizationPage/TemplatePage/TemplatePage.tsx @@ -66,8 +66,8 @@ export const TemplatePage: React.FC = () => { { key: "name", name: "Name", - renderer: (nameField: string | WorkspaceBuild, workspace: Workspace) => { - return {nameField} + renderer: (_, workspace: Workspace) => { + return {workspace.name} }, }, ] From 2226fea96c6b4431650a6077b02499a677ad55b1 Mon Sep 17 00:00:00 2001 From: Presley Date: Tue, 10 May 2022 13:48:49 +0000 Subject: [PATCH 18/44] Move Settings --- .../Workspace/Workspace.stories.tsx | 3 ++ .../WorkspaceStatusBar/WorkspaceStatusBar.tsx | 40 ++++++++++--------- 2 files changed, 25 insertions(+), 18 deletions(-) diff --git a/site/src/components/Workspace/Workspace.stories.tsx b/site/src/components/Workspace/Workspace.stories.tsx index 01b84e5db427d..c72db37a10c3a 100644 --- a/site/src/components/Workspace/Workspace.stories.tsx +++ b/site/src/components/Workspace/Workspace.stories.tsx @@ -34,3 +34,6 @@ Stopping.args = { ...Started.args, workspaceStatus: "stopping" } export const Error = Template.bind({}) Error.args = { ...Started.args, workspaceStatus: "error" } + +export const NoBreadcrumb = Template.bind({}) +NoBreadcrumb.args = { ...Started.args, template: undefined } diff --git a/site/src/components/WorkspaceStatusBar/WorkspaceStatusBar.tsx b/site/src/components/WorkspaceStatusBar/WorkspaceStatusBar.tsx index 7b4a84bd912d9..2322f7ea850a9 100644 --- a/site/src/components/WorkspaceStatusBar/WorkspaceStatusBar.tsx +++ b/site/src/components/WorkspaceStatusBar/WorkspaceStatusBar.tsx @@ -9,6 +9,7 @@ import { Link } from "react-router-dom" import * as Types from "../../api/types" import { WorkspaceStatus } from "../../pages/WorkspacePage/WorkspacePage" import { TitleIconSize } from "../../theme/constants" +import { combineClasses } from "../../util/combineClasses" import { Stack } from "../Stack/Stack" import { WorkspaceSection } from "../WorkspaceSection/WorkspaceSection" @@ -55,20 +56,27 @@ export const WorkspaceStatusBar: React.FC = ({ - {organization && template && - - Back to{" "} - - {template.name} - - - } +
+
+ + {Language.settings} + +
+ + {organization && template && + + Back to{" "} + + {template.name} + + + } + +
-
- {workspace.name} -
+ {workspace.name} {workspaceStatus === "started" && Language.started} {workspaceStatus === "starting" && Language.starting} @@ -101,12 +109,6 @@ export const WorkspaceStatusBar: React.FC = ({ )} - - - - {Language.settings} - -
@@ -126,11 +128,13 @@ const useStyles = makeStyles((theme) => { }, horizontal: { display: "flex", - flexDirection: "row", justifyContent: "space-between", alignItems: "center", gap: theme.spacing(2), }, + reverse: { + flexDirection: 'row-reverse' + }, statusChip: { border: `solid 1px ${theme.palette.text.hint}`, borderRadius: theme.shape.borderRadius, From df0bc5b9efc8d5f9a068fc07f525eb60b7afc0bd Mon Sep 17 00:00:00 2001 From: Presley Date: Tue, 10 May 2022 13:51:51 +0000 Subject: [PATCH 19/44] Format --- .../Workspace/Workspace.stories.tsx | 2 +- site/src/components/Workspace/Workspace.tsx | 10 +- .../WorkspaceStatusBar/WorkspaceStatusBar.tsx | 49 ++--- .../TemplatePage/TemplatePage.tsx | 2 +- .../src/pages/WorkspacePage/WorkspacePage.tsx | 9 +- site/src/testHelpers/entities.ts | 16 +- .../xServices/workspace/workspaceXService.ts | 171 +++++++++--------- 7 files changed, 133 insertions(+), 126 deletions(-) diff --git a/site/src/components/Workspace/Workspace.stories.tsx b/site/src/components/Workspace/Workspace.stories.tsx index c72db37a10c3a..c2208f2076cd9 100644 --- a/site/src/components/Workspace/Workspace.stories.tsx +++ b/site/src/components/Workspace/Workspace.stories.tsx @@ -20,7 +20,7 @@ Started.args = { handleStart: action("start"), handleStop: action("stop"), handleRetry: action("retry"), - workspaceStatus: "started" + workspaceStatus: "started", } export const Starting = Template.bind({}) diff --git a/site/src/components/Workspace/Workspace.tsx b/site/src/components/Workspace/Workspace.tsx index fe36b52089173..4c13ea34936f0 100644 --- a/site/src/components/Workspace/Workspace.tsx +++ b/site/src/components/Workspace/Workspace.tsx @@ -20,7 +20,15 @@ export interface WorkspaceProps { /** * Workspace is the top-level component for viewing an individual workspace */ -export const Workspace: React.FC = ({ organization, template, workspace, handleStart, handleStop, handleRetry, workspaceStatus }) => { +export const Workspace: React.FC = ({ + organization, + template, + workspace, + handleStart, + handleStop, + handleRetry, + workspaceStatus, +}) => { const styles = useStyles() return ( diff --git a/site/src/components/WorkspaceStatusBar/WorkspaceStatusBar.tsx b/site/src/components/WorkspaceStatusBar/WorkspaceStatusBar.tsx index 2322f7ea850a9..87dc04ceedd18 100644 --- a/site/src/components/WorkspaceStatusBar/WorkspaceStatusBar.tsx +++ b/site/src/components/WorkspaceStatusBar/WorkspaceStatusBar.tsx @@ -1,9 +1,7 @@ import Box from "@material-ui/core/Box" import Button from "@material-ui/core/Button" -import Divider from "@material-ui/core/Divider" import { makeStyles } from "@material-ui/core/styles" import Typography from "@material-ui/core/Typography" -import CloudCircleIcon from "@material-ui/icons/CloudCircle" import React from "react" import { Link } from "react-router-dom" import * as Types from "../../api/types" @@ -23,7 +21,7 @@ const Language = { stopped: "Stopped", starting: "Building", stopping: "Stopping", - error: "Build Failed" + error: "Build Failed", } export interface WorkspaceStatusBarProps { @@ -46,7 +44,7 @@ export const WorkspaceStatusBar: React.FC = ({ handleStart, handleStop, handleRetry, - workspaceStatus + workspaceStatus, }) => { const styles = useStyles() @@ -55,23 +53,21 @@ export const WorkspaceStatusBar: React.FC = ({ return ( -
- - {Language.settings} - + + {Language.settings} +
- {organization && template && + {organization && template && ( Back to{" "} {template.name} - } - + )}
@@ -87,28 +83,23 @@ export const WorkspaceStatusBar: React.FC = ({
- {workspaceStatus === "started" && - () - } - {workspaceStatus === "stopped" && - ( + )} + {workspaceStatus === "stopped" && ( + ) - } - {workspaceStatus === "error" && - ( + )} + {workspaceStatus === "error" && ( + ) - } - - {workspace.outdated && ( - )} + {workspace.outdated && }
@@ -133,12 +124,12 @@ const useStyles = makeStyles((theme) => { gap: theme.spacing(2), }, reverse: { - flexDirection: 'row-reverse' + flexDirection: "row-reverse", }, statusChip: { border: `solid 1px ${theme.palette.text.hint}`, borderRadius: theme.shape.borderRadius, - padding: theme.spacing(1) + padding: theme.spacing(1), }, vertical: { display: "flex", diff --git a/site/src/pages/TemplatesPages/OrganizationPage/TemplatePage/TemplatePage.tsx b/site/src/pages/TemplatesPages/OrganizationPage/TemplatePage/TemplatePage.tsx index c52a2a434fee2..4c98f221323cd 100644 --- a/site/src/pages/TemplatesPages/OrganizationPage/TemplatePage/TemplatePage.tsx +++ b/site/src/pages/TemplatesPages/OrganizationPage/TemplatePage/TemplatePage.tsx @@ -1,7 +1,7 @@ import React from "react" import { Link, useNavigate, useParams } from "react-router-dom" import useSWR from "swr" -import { Organization, Template, Workspace, WorkspaceBuild } from "../../../../api/types" +import { Organization, Template, Workspace } from "../../../../api/types" import { EmptyState } from "../../../../components/EmptyState/EmptyState" import { ErrorSummary } from "../../../../components/ErrorSummary/ErrorSummary" import { Header } from "../../../../components/Header/Header" diff --git a/site/src/pages/WorkspacePage/WorkspacePage.tsx b/site/src/pages/WorkspacePage/WorkspacePage.tsx index e42dcc3861209..627d41044740c 100644 --- a/site/src/pages/WorkspacePage/WorkspacePage.tsx +++ b/site/src/pages/WorkspacePage/WorkspacePage.tsx @@ -42,18 +42,21 @@ export const WorkspacePage: React.FC = () => { if (workspaceState.matches("error")) { return - } else if (!workspace){ + } else if (!workspace) { return } else { return ( - workspaceSend("START")} handleStop={() => workspaceSend("STOP")} handleRetry={() => workspaceSend("RETRY")} workspaceStatus={workspaceStatus} - /> + /> ) diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 98207da316f5a..4ecfe13983714 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -82,19 +82,19 @@ export const MockWorkspaceAutostopEnabled: WorkspaceAutostartRequest = { } export const MockProvisionerJob = { - id: "test-provisioner-job", - created_at: "", - started_at: "", - completed_at: "", - error: "", - status: "succeeded" as ProvisionerJobStatus, - worker_id: "test-worker-id" + id: "test-provisioner-job", + created_at: "", + started_at: "", + completed_at: "", + error: "", + status: "succeeded" as ProvisionerJobStatus, + worker_id: "test-worker-id", } export const MockWorkspaceBuild = { id: "test-workspace-build", transition: "start" as WorkspaceBuildTransition, - job: MockProvisionerJob + job: MockProvisionerJob, } // These are special cases of MockWorkspaceBuild for more precise testing diff --git a/site/src/xServices/workspace/workspaceXService.ts b/site/src/xServices/workspace/workspaceXService.ts index 82678b197b6c5..eb7054c39462f 100644 --- a/site/src/xServices/workspace/workspaceXService.ts +++ b/site/src/xServices/workspace/workspaceXService.ts @@ -16,7 +16,12 @@ interface WorkspaceContext { buildError?: Error | unknown } -type WorkspaceEvent = { type: "GET_WORKSPACE"; workspaceId: string } | { type: "START" } | {type: "STOP"} | { type: "RETRY" } | { type: 'REFRESH_WORKSPACE' } +type WorkspaceEvent = + | { type: "GET_WORKSPACE"; workspaceId: string } + | { type: "START" } + | { type: "STOP" } + | { type: "RETRY" } + | { type: "REFRESH_WORKSPACE" } export const workspaceMachine = createMachine( { @@ -49,7 +54,7 @@ export const workspaceMachine = createMachine( on: { GET_WORKSPACE: "gettingWorkspace", }, - tags: "loading" + tags: "loading", }, gettingWorkspace: { invoke: { @@ -103,7 +108,7 @@ export const workspaceMachine = createMachine( tags: "loading", }, error: {}, - ready: {} + ready: {}, }, }, build: { @@ -113,34 +118,34 @@ export const workspaceMachine = createMachine( always: [ { cond: "workspaceIsStarted", - target: "started" + target: "started", }, - { + { cond: "workspaceIsStopped", - target: "stopped" + target: "stopped", }, { cond: "workspaceIsStarting", - target: "buildingStart" + target: "buildingStart", }, { cond: "workspaceIsStopping", - target: "buildingStop" + target: "buildingStop", }, - { target: "error" } - ] + { target: "error" }, + ], }, started: { on: { - STOP: "requestingStop" + STOP: "requestingStop", }, - tags: "buildReady" + tags: "buildReady", }, stopped: { on: { - START: "requestingStart" + START: "requestingStart", }, - tags: "buildReady" + tags: "buildReady", }, requestingStart: { invoke: { @@ -148,14 +153,14 @@ export const workspaceMachine = createMachine( src: "startWorkspace", onDone: { target: "buildingStart", - actions: "clearJobError" + actions: "clearJobError", }, onError: { target: "error", - actions: "assignJobError" - } + actions: "assignJobError", + }, }, - tags: ["buildLoading", "starting"] + tags: ["buildLoading", "starting"], }, requestingStop: { invoke: { @@ -164,10 +169,10 @@ export const workspaceMachine = createMachine( onDone: { target: "buildingStop", actions: "clearJobError" }, onError: { target: "error", - actions: "assignJobError" - } + actions: "assignJobError", + }, }, - tags: ["buildLoading", "stopping"] + tags: ["buildLoading", "stopping"], }, buildingStart: { invoke: { @@ -184,28 +189,28 @@ export const workspaceMachine = createMachine( { cond: "jobSucceeded", target: "#workspaceState.ready.build.started", - actions: "clearBuildError" + actions: "clearBuildError", }, { cond: "jobPendingOrRunning", - target: "waiting" + target: "waiting", }, { // if job is canceling, cancelled, or failed, the user needs to retry target: "#workspaceState.ready.build.error", - actions: "assignBuildError" - } + actions: "assignBuildError", + }, ], - onError: "waiting" - } + onError: "waiting", + }, }, waiting: { on: { - REFRESH_WORKSPACE: "refreshingWorkspace" - } - } + REFRESH_WORKSPACE: "refreshingWorkspace", + }, + }, }, - tags: ["buildLoading", "starting"] + tags: ["buildLoading", "starting"], }, buildingStop: { invoke: { @@ -222,45 +227,45 @@ export const workspaceMachine = createMachine( { cond: "jobSucceeded", target: "#workspaceState.ready.build.stopped", - actions: "clearBuildError" + actions: "clearBuildError", }, { cond: "jobPendingOrRunning", - target: "waiting" + target: "waiting", }, { // if job is canceling, cancelled, or failed, the user needs to retry target: "#workspaceState.ready.build.error", - actions: "assignBuildError" - } + actions: "assignBuildError", + }, ], - onError: "waiting" - } + onError: "waiting", + }, }, waiting: { on: { - REFRESH_WORKSPACE: "refreshingWorkspace" - } - } + REFRESH_WORKSPACE: "refreshingWorkspace", + }, + }, }, - tags: ["buildLoading", "stopping"] + tags: ["buildLoading", "stopping"], }, error: { on: { RETRY: [ { cond: "triedToStart", - target: "requestingStart" + target: "requestingStart", }, { // this could also be post-delete - target: "requestingStop" - } - ] - } - } - } - } + target: "requestingStop", + }, + ], + }, + }, + }, + }, }, }, error: { @@ -293,42 +298,42 @@ export const workspaceMachine = createMachine( getOrganizationError: (_, event) => event.data, }), clearGetOrganizationError: (context) => assign({ ...context, getOrganizationError: undefined }), - assignJobError: (_, event) => assign({ - jobError: event.data - }), - clearJobError: (_) => assign({ - jobError: undefined - }), - assignBuildError: (_, event) => assign({ - buildError: event.data - }), - clearBuildError: (_) => assign({ - buildError: undefined - }), + assignJobError: (_, event) => + assign({ + jobError: event.data, + }), + clearJobError: (_) => + assign({ + jobError: undefined, + }), + assignBuildError: (_, event) => + assign({ + buildError: event.data, + }), + clearBuildError: (_) => + assign({ + buildError: undefined, + }), }, guards: { - workspaceIsStarted: (context) => ( - context.workspace?.latest_build.transition === "start" && context.workspace.latest_build.job.status === "succeeded" - ), - workspaceIsStopped: (context) => ( - context.workspace?.latest_build.transition === "stop" && context.workspace.latest_build.job.status === "succeeded" - ), - workspaceIsStarting: (context) => ( - context.workspace?.latest_build.transition === "start" && ["pending", "running"].includes(context.workspace.latest_build.job.status) - ), - workspaceIsStopping: (context) => ( - context.workspace?.latest_build.transition === "stop" && ["pending", "running"].includes(context.workspace.latest_build.job.status) - ), - triedToStart: (context) => ( - context.workspace?.latest_build.transition === "start" - ), - jobSucceeded: (context) => ( - context.workspace?.latest_build.job.status === "succeeded" - ), + workspaceIsStarted: (context) => + context.workspace?.latest_build.transition === "start" && + context.workspace.latest_build.job.status === "succeeded", + workspaceIsStopped: (context) => + context.workspace?.latest_build.transition === "stop" && + context.workspace.latest_build.job.status === "succeeded", + workspaceIsStarting: (context) => + context.workspace?.latest_build.transition === "start" && + ["pending", "running"].includes(context.workspace.latest_build.job.status), + workspaceIsStopping: (context) => + context.workspace?.latest_build.transition === "stop" && + ["pending", "running"].includes(context.workspace.latest_build.job.status), + triedToStart: (context) => context.workspace?.latest_build.transition === "start", + jobSucceeded: (context) => context.workspace?.latest_build.job.status === "succeeded", jobPendingOrRunning: (context) => { const status = context.workspace?.latest_build.job.status return status === "pending" || status === "running" - } + }, }, services: { getWorkspace: async (_, event) => { @@ -365,8 +370,8 @@ export const workspaceMachine = createMachine( pollBuild: async (context) => (send) => { if (context.workspace) { const workspaceId = context.workspace.id - const intervalId = setInterval(() => send({ type: 'GET_WORKSPACE', workspaceId }), 1000); - return () => clearInterval(intervalId); + const intervalId = setInterval(() => send({ type: "GET_WORKSPACE", workspaceId }), 1000) + return () => clearInterval(intervalId) } else { throw Error("Cannot fetch workspace without id") } @@ -377,7 +382,7 @@ export const workspaceMachine = createMachine( } else { throw Error("Cannot refresh workspace without id") } - } + }, }, }, ) From 14bd5981c380e868fd83e409024884a118741f49 Mon Sep 17 00:00:00 2001 From: Presley Date: Tue, 10 May 2022 14:45:02 +0000 Subject: [PATCH 20/44] Keep refreshed workspace --- site/src/xServices/workspace/workspaceXService.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/site/src/xServices/workspace/workspaceXService.ts b/site/src/xServices/workspace/workspaceXService.ts index eb7054c39462f..319dbc41874fe 100644 --- a/site/src/xServices/workspace/workspaceXService.ts +++ b/site/src/xServices/workspace/workspaceXService.ts @@ -45,6 +45,9 @@ export const workspaceMachine = createMachine( stopWorkspace: { data: TypesGen.WorkspaceBuild } + refreshWorkspace: { + data: Types.Workspace | undefined + } }, }, id: "workspaceState", @@ -189,16 +192,17 @@ export const workspaceMachine = createMachine( { cond: "jobSucceeded", target: "#workspaceState.ready.build.started", - actions: "clearBuildError", + actions: ["clearBuildError", "assignWorkspace"], }, { cond: "jobPendingOrRunning", target: "waiting", + actions: "assignWorkspace" }, { // if job is canceling, cancelled, or failed, the user needs to retry target: "#workspaceState.ready.build.error", - actions: "assignBuildError", + actions: ["assignBuildError", "assignWorkspace"], }, ], onError: "waiting", From 1093103f7281a916a70cf7ec45bf8a80152f2e40 Mon Sep 17 00:00:00 2001 From: Presley Date: Tue, 10 May 2022 15:00:15 +0000 Subject: [PATCH 21/44] Make it switch workspaces --- site/src/xServices/workspace/workspaceXService.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/site/src/xServices/workspace/workspaceXService.ts b/site/src/xServices/workspace/workspaceXService.ts index 319dbc41874fe..0e5645eba5db2 100644 --- a/site/src/xServices/workspace/workspaceXService.ts +++ b/site/src/xServices/workspace/workspaceXService.ts @@ -52,11 +52,11 @@ export const workspaceMachine = createMachine( }, id: "workspaceState", initial: "idle", + on: { + GET_WORKSPACE: "gettingWorkspace", + }, states: { idle: { - on: { - GET_WORKSPACE: "gettingWorkspace", - }, tags: "loading", }, gettingWorkspace: { From 478db51c4ef804a7b76e521883bcfd06981ec6cb Mon Sep 17 00:00:00 2001 From: Presley Date: Tue, 10 May 2022 15:18:49 +0000 Subject: [PATCH 22/44] Lint --- site/src/components/Workspace/Workspace.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/components/Workspace/Workspace.tsx b/site/src/components/Workspace/Workspace.tsx index 4c13ea34936f0..7aa7b0b10f36f 100644 --- a/site/src/components/Workspace/Workspace.tsx +++ b/site/src/components/Workspace/Workspace.tsx @@ -84,7 +84,7 @@ const Placeholder: React.FC = () => { ) } -export const useStyles = makeStyles((theme) => { +export const useStyles = makeStyles(() => { return { root: { display: "flex", From 40a62a83f6d672f92e58329f995bd293512817eb Mon Sep 17 00:00:00 2001 From: Presley Date: Tue, 10 May 2022 16:54:51 +0000 Subject: [PATCH 23/44] Fix relative api path --- site/src/api/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/api/index.ts b/site/src/api/index.ts index a8473e9f44fc6..6419eec84911a 100644 --- a/site/src/api/index.ts +++ b/site/src/api/index.ts @@ -123,7 +123,7 @@ const postWorkspaceBuild = transition, templateVersionId, } - const response = await axios.post(`api/v2/workspaces/${workspaceId}/builds`, payload) + const response = await axios.post(`/api/v2/workspaces/${workspaceId}/builds`, payload) return response.data } From bd3a026ee9dfc3a74eaaa4d97c5c96c71cc07487 Mon Sep 17 00:00:00 2001 From: Presley Date: Tue, 10 May 2022 17:29:44 +0000 Subject: [PATCH 24/44] Test --- .../components/Workspace/Workspace.test.tsx | 15 ------ .../WorkspaceStatusBar/WorkspaceStatusBar.tsx | 2 +- .../WorkspacePage/WorkspacePage.test.tsx | 47 ++++++++++++++++- site/src/testHelpers/entities.ts | 50 +++++++++++++++++++ 4 files changed, 97 insertions(+), 17 deletions(-) delete mode 100644 site/src/components/Workspace/Workspace.test.tsx diff --git a/site/src/components/Workspace/Workspace.test.tsx b/site/src/components/Workspace/Workspace.test.tsx deleted file mode 100644 index 534835a41529c..0000000000000 --- a/site/src/components/Workspace/Workspace.test.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { screen } from "@testing-library/react" -import React from "react" -import { MockOrganization, MockTemplate, MockWorkspace, render } from "../../testHelpers" -import { Workspace } from "./Workspace" - -describe("Workspace", () => { - it("renders", async () => { - // When - render() - - // Then - const element = await screen.findByText(MockWorkspace.name) - expect(element).toBeDefined() - }) -}) diff --git a/site/src/components/WorkspaceStatusBar/WorkspaceStatusBar.tsx b/site/src/components/WorkspaceStatusBar/WorkspaceStatusBar.tsx index 87dc04ceedd18..c278a2fa1a658 100644 --- a/site/src/components/WorkspaceStatusBar/WorkspaceStatusBar.tsx +++ b/site/src/components/WorkspaceStatusBar/WorkspaceStatusBar.tsx @@ -73,7 +73,7 @@ export const WorkspaceStatusBar: React.FC = ({
{workspace.name} - + {workspaceStatus === "started" && Language.started} {workspaceStatus === "starting" && Language.starting} {workspaceStatus === "stopped" && Language.stopped} diff --git a/site/src/pages/WorkspacePage/WorkspacePage.test.tsx b/site/src/pages/WorkspacePage/WorkspacePage.test.tsx index 4f2795b32bbc6..90c0e2fa5f3c6 100644 --- a/site/src/pages/WorkspacePage/WorkspacePage.test.tsx +++ b/site/src/pages/WorkspacePage/WorkspacePage.test.tsx @@ -1,6 +1,8 @@ import { screen } from "@testing-library/react" +import { rest } from "msw" import React from "react" -import { MockTemplate, MockWorkspace, renderWithAuth } from "../../testHelpers" +import { MockFailedWorkspace, MockStoppedWorkspace, MockTemplate, MockWorkspace, renderWithAuth } from "../../testHelpers" +import { server } from "../../testHelpers/server" import { WorkspacePage } from "./WorkspacePage" describe("Workspace Page", () => { @@ -11,4 +13,47 @@ describe("Workspace Page", () => { expect(workspaceName).toBeDefined() expect(templateName).toBeDefined() }) + it("shows the status of the workspace", async () => { + renderWithAuth(, { route: `/workspaces/${MockWorkspace.id}`, path: "/workspaces/:workspace" }) + const status = await screen.findByRole("status") + expect(status).toHaveTextContent("Running") + }) + it("stops the workspace when the user presses Stop", async () => { + renderWithAuth(, { route: `/workspaces/${MockWorkspace.id}`, path: "/workspaces/:workspace" }) + const status = await screen.findByText("Running") + expect(status).toBeDefined() + const stopButton = await screen.findByText("Stop") + stopButton.click() + const laterStatus = await screen.findByText("Stopping") + expect(laterStatus).toBeDefined() + }) + it("starts the workspace when the user presses Start", async () => { + server.use( + rest.get(`/api/v2/workspaces/${MockWorkspace.id}`, (req, res, ctx) => { + return res(ctx.status(200), ctx.json(MockStoppedWorkspace)) + }), + ) + renderWithAuth(, { route: `/workspaces/${MockWorkspace.id}`, path: "/workspaces/:workspace" }) + const startButton = await screen.findByText("Start") + const status = await screen.findByText("Stopped") + expect(status).toBeDefined() + startButton.click() + const laterStatus = await screen.findByText("Building") + expect(laterStatus).toBeDefined() + }) + it("retries starting the workspace when the user presses Retry", async () => { + // MockFailedWorkspace.latest_build.transition is start so Retry will attempt to start + renderWithAuth(, { route: `/workspaces/${MockWorkspace.id}`, path: "/workspaces/:workspace" }) + server.use( + rest.get(`/api/v2/workspaces/${MockWorkspace.id}`, (req, res, ctx) => { + return res(ctx.status(200), ctx.json(MockFailedWorkspace)) + }), + ) + const status = await screen.findByText("Build Failed") + expect(status).toBeDefined() + const retryButton = await screen.findByText("Retry") + retryButton.click() + const laterStatus = await screen.findByText("Building") + expect(laterStatus).toBeDefined() + }) }) diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index d24281795a6c0..caf1a5066079c 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -93,12 +93,34 @@ export const MockProvisionerJob = { worker_id: "test-worker-id", } +export const MockFailedProvisionerJob = { + id: "test-provisioner-job", + created_at: "", + started_at: "", + completed_at: "", + error: "", + status: "failed" as ProvisionerJobStatus, + worker_id: "test-worker-id", +} + export const MockWorkspaceBuild = { id: "test-workspace-build", transition: "start" as WorkspaceBuildTransition, job: MockProvisionerJob, } +export const MockWorkspaceBuildStop = { + id: "test-workspace-build", + transition: "stop" as WorkspaceBuildTransition, + job: MockProvisionerJob, +} + +export const MockFailedWorkspaceBuild = { + id: "test-workspace-build", + transition: "start" as WorkspaceBuildTransition, + job: MockFailedProvisionerJob, +} + // These are special cases of MockWorkspaceBuild for more precise testing export const MockWorkspaceStart = { id: "test-workspace-build-start", @@ -128,6 +150,34 @@ export const MockWorkspace: Workspace = { latest_build: MockWorkspaceBuild, } +export const MockStoppedWorkspace: Workspace = { + id: "test-workspace", + name: "Test-Workspace", + created_at: "", + updated_at: "", + template_id: MockTemplate.id, + outdated: false, + owner_id: MockUser.id, + autostart_schedule: MockWorkspaceAutostartEnabled.schedule, + autostop_schedule: MockWorkspaceAutostopEnabled.schedule, + latest_build: MockWorkspaceBuildStop, +} + +export const MockFailedWorkspace: Workspace = { + id: "test-workspace", + name: "Test-Workspace", + created_at: "", + updated_at: "", + template_id: MockTemplate.id, + outdated: false, + owner_id: MockUser.id, + autostart_schedule: MockWorkspaceAutostartEnabled.schedule, + autostop_schedule: MockWorkspaceAutostopEnabled.schedule, + latest_build: MockFailedWorkspaceBuild, +} + + + export const MockWorkspaceAgent: WorkspaceAgent = { id: "test-workspace-agent", name: "a-workspace-agent", From 9786f6c8211d46da833a6bd25ccc33a34ff5408c Mon Sep 17 00:00:00 2001 From: Presley Date: Tue, 10 May 2022 22:58:52 +0000 Subject: [PATCH 25/44] Fix polling --- .../xServices/workspace/workspaceXService.ts | 84 ++++++------------- 1 file changed, 26 insertions(+), 58 deletions(-) diff --git a/site/src/xServices/workspace/workspaceXService.ts b/site/src/xServices/workspace/workspaceXService.ts index 0e5645eba5db2..783d2f821974f 100644 --- a/site/src/xServices/workspace/workspaceXService.ts +++ b/site/src/xServices/workspace/workspaceXService.ts @@ -178,79 +178,50 @@ export const workspaceMachine = createMachine( tags: ["buildLoading", "stopping"], }, buildingStart: { - invoke: { - id: "building", - src: "pollBuild", - }, initial: "refreshingWorkspace", states: { refreshingWorkspace: { + entry: "clearRefreshWorkspaceError", invoke: { id: "refreshWorkspace", src: "refreshWorkspace", onDone: [ - { - cond: "jobSucceeded", - target: "#workspaceState.ready.build.started", - actions: ["clearBuildError", "assignWorkspace"], - }, - { - cond: "jobPendingOrRunning", - target: "waiting", - actions: "assignWorkspace" - }, - { - // if job is canceling, cancelled, or failed, the user needs to retry - target: "#workspaceState.ready.build.error", - actions: ["assignBuildError", "assignWorkspace"], - }, + { cond: "jobSucceeded", target: "#workspaceState.ready.build.started", actions: ["clearBuildError", "assignWorkspace"]}, + { cond: "jobPendingOrRunning", target: "waiting", actions: "assignWorkspace" }, + { target: "#workspaceState.ready.build.error", actions: ["assignWorkspace", "assignBuildError"] } ], - onError: "waiting", - }, + onError: { target: "waiting", actions: "assignRefreshWorkspaceError"} + } }, waiting: { - on: { - REFRESH_WORKSPACE: "refreshingWorkspace", - }, - }, + after: { + 1000: "refreshingWorkspace" + } + } }, tags: ["buildLoading", "starting"], }, buildingStop: { - invoke: { - id: "building", - src: "pollBuild", - }, initial: "refreshingWorkspace", states: { refreshingWorkspace: { + entry: "clearRefreshWorkspaceError", invoke: { id: "refreshWorkspace", src: "refreshWorkspace", onDone: [ - { - cond: "jobSucceeded", - target: "#workspaceState.ready.build.stopped", - actions: "clearBuildError", - }, - { - cond: "jobPendingOrRunning", - target: "waiting", - }, - { - // if job is canceling, cancelled, or failed, the user needs to retry - target: "#workspaceState.ready.build.error", - actions: "assignBuildError", - }, + { cond: "jobSucceeded", target: "#workspaceState.ready.build.stopped", actions: ["clearBuildError", "assignWorkspace"]}, + { cond: "jobPendingOrRunning", target: "waiting", actions: "assignWorkspace" }, + { target: "#workspaceState.ready.build.error", actions: ["assignWorkspace", "assignBuildError"] } ], - onError: "waiting", - }, + onError: { target: "waiting", actions: "assignRefreshWorkspaceError"} + } }, waiting: { - on: { - REFRESH_WORKSPACE: "refreshingWorkspace", - }, - }, + after: { + 1000: "refreshingWorkspace" + } + } }, tags: ["buildLoading", "stopping"], }, @@ -318,6 +289,12 @@ export const workspaceMachine = createMachine( assign({ buildError: undefined, }), + assignRefreshWorkspaceError: (_, event) => assign({ + refreshWorkspaceError: event.data + }), + clearRefreshWorkspaceError: (_) => assign({ + refreshWorkspaceError: undefined + }) }, guards: { workspaceIsStarted: (context) => @@ -371,15 +348,6 @@ export const workspaceMachine = createMachine( throw Error("Cannot stop workspace without workspace id") } }, - pollBuild: async (context) => (send) => { - if (context.workspace) { - const workspaceId = context.workspace.id - const intervalId = setInterval(() => send({ type: "GET_WORKSPACE", workspaceId }), 1000) - return () => clearInterval(intervalId) - } else { - throw Error("Cannot fetch workspace without id") - } - }, refreshWorkspace: async (context) => { if (context.workspace) { return await API.getWorkspace(context.workspace.id) From 7727a1b934bfb8df1d22747c50200d70279388b8 Mon Sep 17 00:00:00 2001 From: Presley Date: Tue, 10 May 2022 22:59:41 +0000 Subject: [PATCH 26/44] Add loading workspace state --- .../components/WorkspaceStatusBar/WorkspaceStatusBar.tsx | 2 ++ site/src/pages/WorkspacePage/WorkspacePage.tsx | 6 ++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/site/src/components/WorkspaceStatusBar/WorkspaceStatusBar.tsx b/site/src/components/WorkspaceStatusBar/WorkspaceStatusBar.tsx index c278a2fa1a658..7afd2c278b71e 100644 --- a/site/src/components/WorkspaceStatusBar/WorkspaceStatusBar.tsx +++ b/site/src/components/WorkspaceStatusBar/WorkspaceStatusBar.tsx @@ -22,6 +22,7 @@ const Language = { starting: "Building", stopping: "Stopping", error: "Build Failed", + loading: "Loading Status" } export interface WorkspaceStatusBarProps { @@ -79,6 +80,7 @@ export const WorkspaceStatusBar: React.FC = ({ {workspaceStatus === "stopped" && Language.stopped} {workspaceStatus === "stopping" && Language.stopping} {workspaceStatus === "error" && Language.error} + {workspaceStatus === "loading" && Language.loading}
diff --git a/site/src/pages/WorkspacePage/WorkspacePage.tsx b/site/src/pages/WorkspacePage/WorkspacePage.tsx index 627d41044740c..e6595fc342b16 100644 --- a/site/src/pages/WorkspacePage/WorkspacePage.tsx +++ b/site/src/pages/WorkspacePage/WorkspacePage.tsx @@ -9,7 +9,7 @@ import { Workspace } from "../../components/Workspace/Workspace" import { firstOrItem } from "../../util/array" import { XServiceContext } from "../../xServices/StateContext" -export type WorkspaceStatus = "started" | "starting" | "stopped" | "stopping" | "error" +export type WorkspaceStatus = "started" | "starting" | "stopped" | "stopping" | "error" | "loading" export const WorkspacePage: React.FC = () => { const { workspace: workspaceQueryParam } = useParams() @@ -28,8 +28,10 @@ export const WorkspacePage: React.FC = () => { workspaceStatus = "starting" } else if (workspaceState.hasTag("stopping")) { workspaceStatus = "stopping" - } else { + } else if (workspaceState.matches("ready.build.error")) { workspaceStatus = "error" + } else { + workspaceStatus = "loading" } /** From 4a57152e1ab872d97ee4a404a0028eb5f82bf384 Mon Sep 17 00:00:00 2001 From: Presley Date: Tue, 10 May 2022 23:00:29 +0000 Subject: [PATCH 27/44] Format --- .../WorkspaceStatusBar/WorkspaceStatusBar.tsx | 2 +- .../WorkspacePage/WorkspacePage.test.tsx | 8 ++- site/src/testHelpers/entities.ts | 2 - .../xServices/workspace/workspaceXService.ts | 56 ++++++++++++------- 4 files changed, 44 insertions(+), 24 deletions(-) diff --git a/site/src/components/WorkspaceStatusBar/WorkspaceStatusBar.tsx b/site/src/components/WorkspaceStatusBar/WorkspaceStatusBar.tsx index 7afd2c278b71e..b09d4c7e2dffa 100644 --- a/site/src/components/WorkspaceStatusBar/WorkspaceStatusBar.tsx +++ b/site/src/components/WorkspaceStatusBar/WorkspaceStatusBar.tsx @@ -22,7 +22,7 @@ const Language = { starting: "Building", stopping: "Stopping", error: "Build Failed", - loading: "Loading Status" + loading: "Loading Status", } export interface WorkspaceStatusBarProps { diff --git a/site/src/pages/WorkspacePage/WorkspacePage.test.tsx b/site/src/pages/WorkspacePage/WorkspacePage.test.tsx index 90c0e2fa5f3c6..6cfad89310661 100644 --- a/site/src/pages/WorkspacePage/WorkspacePage.test.tsx +++ b/site/src/pages/WorkspacePage/WorkspacePage.test.tsx @@ -1,7 +1,13 @@ import { screen } from "@testing-library/react" import { rest } from "msw" import React from "react" -import { MockFailedWorkspace, MockStoppedWorkspace, MockTemplate, MockWorkspace, renderWithAuth } from "../../testHelpers" +import { + MockFailedWorkspace, + MockStoppedWorkspace, + MockTemplate, + MockWorkspace, + renderWithAuth, +} from "../../testHelpers" import { server } from "../../testHelpers/server" import { WorkspacePage } from "./WorkspacePage" diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index caf1a5066079c..634231c16060e 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -176,8 +176,6 @@ export const MockFailedWorkspace: Workspace = { latest_build: MockFailedWorkspaceBuild, } - - export const MockWorkspaceAgent: WorkspaceAgent = { id: "test-workspace-agent", name: "a-workspace-agent", diff --git a/site/src/xServices/workspace/workspaceXService.ts b/site/src/xServices/workspace/workspaceXService.ts index 783d2f821974f..ea7aceffc7718 100644 --- a/site/src/xServices/workspace/workspaceXService.ts +++ b/site/src/xServices/workspace/workspaceXService.ts @@ -186,18 +186,25 @@ export const workspaceMachine = createMachine( id: "refreshWorkspace", src: "refreshWorkspace", onDone: [ - { cond: "jobSucceeded", target: "#workspaceState.ready.build.started", actions: ["clearBuildError", "assignWorkspace"]}, + { + cond: "jobSucceeded", + target: "#workspaceState.ready.build.started", + actions: ["clearBuildError", "assignWorkspace"], + }, { cond: "jobPendingOrRunning", target: "waiting", actions: "assignWorkspace" }, - { target: "#workspaceState.ready.build.error", actions: ["assignWorkspace", "assignBuildError"] } + { + target: "#workspaceState.ready.build.error", + actions: ["assignWorkspace", "assignBuildError"], + }, ], - onError: { target: "waiting", actions: "assignRefreshWorkspaceError"} - } + onError: { target: "waiting", actions: "assignRefreshWorkspaceError" }, + }, }, waiting: { after: { - 1000: "refreshingWorkspace" - } - } + 1000: "refreshingWorkspace", + }, + }, }, tags: ["buildLoading", "starting"], }, @@ -210,18 +217,25 @@ export const workspaceMachine = createMachine( id: "refreshWorkspace", src: "refreshWorkspace", onDone: [ - { cond: "jobSucceeded", target: "#workspaceState.ready.build.stopped", actions: ["clearBuildError", "assignWorkspace"]}, + { + cond: "jobSucceeded", + target: "#workspaceState.ready.build.stopped", + actions: ["clearBuildError", "assignWorkspace"], + }, { cond: "jobPendingOrRunning", target: "waiting", actions: "assignWorkspace" }, - { target: "#workspaceState.ready.build.error", actions: ["assignWorkspace", "assignBuildError"] } + { + target: "#workspaceState.ready.build.error", + actions: ["assignWorkspace", "assignBuildError"], + }, ], - onError: { target: "waiting", actions: "assignRefreshWorkspaceError"} - } + onError: { target: "waiting", actions: "assignRefreshWorkspaceError" }, + }, }, waiting: { after: { - 1000: "refreshingWorkspace" - } - } + 1000: "refreshingWorkspace", + }, + }, }, tags: ["buildLoading", "stopping"], }, @@ -289,12 +303,14 @@ export const workspaceMachine = createMachine( assign({ buildError: undefined, }), - assignRefreshWorkspaceError: (_, event) => assign({ - refreshWorkspaceError: event.data - }), - clearRefreshWorkspaceError: (_) => assign({ - refreshWorkspaceError: undefined - }) + assignRefreshWorkspaceError: (_, event) => + assign({ + refreshWorkspaceError: event.data, + }), + clearRefreshWorkspaceError: (_) => + assign({ + refreshWorkspaceError: undefined, + }), }, guards: { workspaceIsStarted: (context) => From e3ae1b88ea86d414cdbb6d317a3456c44b822b41 Mon Sep 17 00:00:00 2001 From: Presley Date: Wed, 11 May 2022 02:10:37 +0000 Subject: [PATCH 28/44] Add stub settings page --- site/src/AppRouter.tsx | 22 ++++++++++++++----- .../WorkspaceStatusBar/WorkspaceStatusBar.tsx | 3 ++- .../WorkspaceSettingsPage.tsx | 5 +++++ 3 files changed, 23 insertions(+), 7 deletions(-) create mode 100644 site/src/pages/WorkspaceSettingsPage/WorkspaceSettingsPage.tsx diff --git a/site/src/AppRouter.tsx b/site/src/AppRouter.tsx index ed3d299c9706a..629dfee22b7e9 100644 --- a/site/src/AppRouter.tsx +++ b/site/src/AppRouter.tsx @@ -20,6 +20,7 @@ import { TemplatesPage } from "./pages/TemplatesPages/TemplatesPage" import { CreateUserPage } from "./pages/UsersPage/CreateUserPage/CreateUserPage" import { UsersPage } from "./pages/UsersPage/UsersPage" import { WorkspacePage } from "./pages/WorkspacePage/WorkspacePage" +import { WorkspaceSettingsPage } from "./pages/WorkspaceSettingsPage/WorkspaceSettingsPage" const TerminalPage = React.lazy(() => import("./pages/TerminalPage/TerminalPage")) @@ -79,12 +80,21 @@ export const AppRouter: React.FC = () => ( - - - } - /> + > + + + } + /> + + + + }/> + diff --git a/site/src/components/WorkspaceStatusBar/WorkspaceStatusBar.tsx b/site/src/components/WorkspaceStatusBar/WorkspaceStatusBar.tsx index b09d4c7e2dffa..14914ad02738a 100644 --- a/site/src/components/WorkspaceStatusBar/WorkspaceStatusBar.tsx +++ b/site/src/components/WorkspaceStatusBar/WorkspaceStatusBar.tsx @@ -50,13 +50,14 @@ export const WorkspaceStatusBar: React.FC = ({ const styles = useStyles() const templateLink = `/templates/${organization?.name}/${template?.name}` + const settingsLink = "edit" return (
- + {Language.settings}
diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceSettingsPage.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceSettingsPage.tsx new file mode 100644 index 0000000000000..26fc70d2c9eab --- /dev/null +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceSettingsPage.tsx @@ -0,0 +1,5 @@ +import React from 'react' + +export const WorkspaceSettingsPage = () => { + return
Coming soon!
+} From 2695a296f4d392f237c1cc18a7a4365f7e2e3acb Mon Sep 17 00:00:00 2001 From: Presley Date: Wed, 11 May 2022 02:11:21 +0000 Subject: [PATCH 29/44] Format --- site/src/AppRouter.tsx | 13 +++++++------ .../WorkspaceSettingsPage/WorkspaceSettingsPage.tsx | 2 +- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/site/src/AppRouter.tsx b/site/src/AppRouter.tsx index 629dfee22b7e9..b54308f3de27a 100644 --- a/site/src/AppRouter.tsx +++ b/site/src/AppRouter.tsx @@ -78,14 +78,14 @@ export const AppRouter: React.FC = () => ( - - + - } + + } /> ( - }/> + } + /> diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceSettingsPage.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceSettingsPage.tsx index 26fc70d2c9eab..97c3052533fa1 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceSettingsPage.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceSettingsPage.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import React from "react" export const WorkspaceSettingsPage = () => { return
Coming soon!
From d83e5ace251cb9e752d33705093769fb74603f9d Mon Sep 17 00:00:00 2001 From: Presley Date: Wed, 11 May 2022 13:51:51 +0000 Subject: [PATCH 30/44] Lint --- site/src/pages/WorkspaceSettingsPage/WorkspaceSettingsPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceSettingsPage.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceSettingsPage.tsx index 97c3052533fa1..9e50e5b828da3 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceSettingsPage.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceSettingsPage.tsx @@ -1,5 +1,5 @@ import React from "react" -export const WorkspaceSettingsPage = () => { +export const WorkspaceSettingsPage: React.FC = () => { return
Coming soon!
} From f909a869402fdd0f856509f2733c33ab476c3bcb Mon Sep 17 00:00:00 2001 From: Presley Date: Wed, 11 May 2022 14:17:46 +0000 Subject: [PATCH 31/44] Get rid of let --- .../src/pages/WorkspacePage/WorkspacePage.tsx | 25 ++++++++----------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/site/src/pages/WorkspacePage/WorkspacePage.tsx b/site/src/pages/WorkspacePage/WorkspacePage.tsx index e6595fc342b16..f8a43146e4142 100644 --- a/site/src/pages/WorkspacePage/WorkspacePage.tsx +++ b/site/src/pages/WorkspacePage/WorkspacePage.tsx @@ -19,20 +19,17 @@ export const WorkspacePage: React.FC = () => { const [workspaceState, workspaceSend] = useActor(xServices.workspaceXService) const { workspace, template, organization, getWorkspaceError, getTemplateError, getOrganizationError } = workspaceState.context - let workspaceStatus: WorkspaceStatus - if (workspaceState.matches("ready.build.started")) { - workspaceStatus = "started" - } else if (workspaceState.matches("ready.build.stopped")) { - workspaceStatus = "stopped" - } else if (workspaceState.hasTag("starting")) { - workspaceStatus = "starting" - } else if (workspaceState.hasTag("stopping")) { - workspaceStatus = "stopping" - } else if (workspaceState.matches("ready.build.error")) { - workspaceStatus = "error" - } else { - workspaceStatus = "loading" - } + const workspaceStatus: WorkspaceStatus = workspaceState.matches("ready.build.started") + ? "started" + : workspaceState.matches("ready.build.stopped") + ? "stopped" + : workspaceState.hasTag("starting") + ? "starting" + : workspaceState.hasTag("stopping") + ? "stopping" + : workspaceState.matches("ready.build.error") + ? "error" + : "loading" /** * Get workspace, template, and organization on mount and whenever workspaceId changes. From be867500bcc2000fe85b2a51aaf4f488670a1501 Mon Sep 17 00:00:00 2001 From: Presley Date: Thu, 12 May 2022 00:54:51 +0000 Subject: [PATCH 32/44] Add update --- .../Workspace/Workspace.stories.tsx | 5 ++- site/src/components/Workspace/Workspace.tsx | 3 ++ .../WorkspaceStatusBar/WorkspaceStatusBar.tsx | 9 +++- .../WorkspacePage/WorkspacePage.test.tsx | 13 ++++++ .../src/pages/WorkspacePage/WorkspacePage.tsx | 1 + site/src/testHelpers/entities.ts | 28 ++---------- .../xServices/workspace/workspaceXService.ts | 44 +++++++++++++++++-- 7 files changed, 74 insertions(+), 29 deletions(-) diff --git a/site/src/components/Workspace/Workspace.stories.tsx b/site/src/components/Workspace/Workspace.stories.tsx index c2208f2076cd9..5d8695ee9d67c 100644 --- a/site/src/components/Workspace/Workspace.stories.tsx +++ b/site/src/components/Workspace/Workspace.stories.tsx @@ -1,7 +1,7 @@ import { action } from "@storybook/addon-actions" import { Story } from "@storybook/react" import React from "react" -import { MockOrganization, MockTemplate, MockWorkspace } from "../../testHelpers" +import { MockOrganization, MockOutdatedWorkspace, MockTemplate, MockWorkspace } from "../../testHelpers" import { Workspace, WorkspaceProps } from "./Workspace" export default { @@ -37,3 +37,6 @@ Error.args = { ...Started.args, workspaceStatus: "error" } export const NoBreadcrumb = Template.bind({}) NoBreadcrumb.args = { ...Started.args, template: undefined } + +export const Outdated = Template.bind({}) +Outdated.args = { ...Started.args, workspace: MockOutdatedWorkspace } diff --git a/site/src/components/Workspace/Workspace.tsx b/site/src/components/Workspace/Workspace.tsx index 7aa7b0b10f36f..134f28677d2aa 100644 --- a/site/src/components/Workspace/Workspace.tsx +++ b/site/src/components/Workspace/Workspace.tsx @@ -14,6 +14,7 @@ export interface WorkspaceProps { handleStart: () => void handleStop: () => void handleRetry: () => void + handleUpdate: () => void workspaceStatus: WorkspaceStatus } @@ -27,6 +28,7 @@ export const Workspace: React.FC = ({ handleStart, handleStop, handleRetry, + handleUpdate, workspaceStatus, }) => { const styles = useStyles() @@ -41,6 +43,7 @@ export const Workspace: React.FC = ({ handleStart={handleStart} handleStop={handleStop} handleRetry={handleRetry} + handleUpdate={handleUpdate} workspaceStatus={workspaceStatus} />
diff --git a/site/src/components/WorkspaceStatusBar/WorkspaceStatusBar.tsx b/site/src/components/WorkspaceStatusBar/WorkspaceStatusBar.tsx index 14914ad02738a..aea2428506fa3 100644 --- a/site/src/components/WorkspaceStatusBar/WorkspaceStatusBar.tsx +++ b/site/src/components/WorkspaceStatusBar/WorkspaceStatusBar.tsx @@ -32,6 +32,7 @@ export interface WorkspaceStatusBarProps { handleStart: () => void handleStop: () => void handleRetry: () => void + handleUpdate: () => void workspaceStatus: WorkspaceStatus } @@ -45,6 +46,7 @@ export const WorkspaceStatusBar: React.FC = ({ handleStart, handleStop, handleRetry, + handleUpdate, workspaceStatus, }) => { const styles = useStyles() @@ -102,7 +104,12 @@ export const WorkspaceStatusBar: React.FC = ({ )} - {workspace.outdated && } + {/* Workspace will not update while another job is in progress so hide the button until it's usable */} + {workspace.outdated && ["started", "stopped", "error"].includes(workspaceStatus) && ( + + )}
diff --git a/site/src/pages/WorkspacePage/WorkspacePage.test.tsx b/site/src/pages/WorkspacePage/WorkspacePage.test.tsx index 6cfad89310661..2ffe44d3c70ac 100644 --- a/site/src/pages/WorkspacePage/WorkspacePage.test.tsx +++ b/site/src/pages/WorkspacePage/WorkspacePage.test.tsx @@ -3,6 +3,7 @@ import { rest } from "msw" import React from "react" import { MockFailedWorkspace, + MockOutdatedWorkspace, MockStoppedWorkspace, MockTemplate, MockWorkspace, @@ -62,4 +63,16 @@ describe("Workspace Page", () => { const laterStatus = await screen.findByText("Building") expect(laterStatus).toBeDefined() }) + it("restarts the workspace when the user presses Update", async () => { + renderWithAuth(, { route: `/workspaces/${MockWorkspace.id}`, path: "/workspaces/:workspace" }) + server.use( + rest.get(`/api/v2/workspaces/${MockWorkspace.id}`, (req, res, ctx) => { + return res(ctx.status(200), ctx.json(MockOutdatedWorkspace)) + }), + ) + const updateButton = await screen.findByText("Update") + updateButton.click() + const status = await screen.findByText("Building") + expect(status).toBeDefined() + }) }) diff --git a/site/src/pages/WorkspacePage/WorkspacePage.tsx b/site/src/pages/WorkspacePage/WorkspacePage.tsx index f8a43146e4142..56fd5d97b1dca 100644 --- a/site/src/pages/WorkspacePage/WorkspacePage.tsx +++ b/site/src/pages/WorkspacePage/WorkspacePage.tsx @@ -54,6 +54,7 @@ export const WorkspacePage: React.FC = () => { handleStart={() => workspaceSend("START")} handleStop={() => workspaceSend("STOP")} handleRetry={() => workspaceSend("RETRY")} + handleUpdate={() => workspaceSend("UPDATE")} workspaceStatus={workspaceStatus} /> diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index eb5e368bdf43a..8a62809b7599b 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -167,31 +167,11 @@ export const MockWorkspace: Workspace = { latest_build: MockWorkspaceBuild, } -export const MockStoppedWorkspace: Workspace = { - id: "test-workspace", - name: "Test-Workspace", - created_at: "", - updated_at: "", - template_id: MockTemplate.id, - outdated: false, - owner_id: MockUser.id, - autostart_schedule: MockWorkspaceAutostartEnabled.schedule, - autostop_schedule: MockWorkspaceAutostopEnabled.schedule, - latest_build: MockWorkspaceBuildStop, -} +export const MockStoppedWorkspace: Workspace = { ...MockWorkspace, latest_build: MockWorkspaceBuildStop } -export const MockFailedWorkspace: Workspace = { - id: "test-workspace", - name: "Test-Workspace", - created_at: "", - updated_at: "", - template_id: MockTemplate.id, - outdated: false, - owner_id: MockUser.id, - autostart_schedule: MockWorkspaceAutostartEnabled.schedule, - autostop_schedule: MockWorkspaceAutostopEnabled.schedule, - latest_build: MockFailedWorkspaceBuild, -} +export const MockFailedWorkspace: Workspace = { ...MockWorkspace, latest_build: MockFailedWorkspaceBuild } + +export const MockOutdatedWorkspace: Workspace = { ...MockWorkspace, outdated: true } export const MockWorkspaceAgent: WorkspaceAgent = { id: "test-workspace-agent", diff --git a/site/src/xServices/workspace/workspaceXService.ts b/site/src/xServices/workspace/workspaceXService.ts index ea7aceffc7718..a0b2521c9260f 100644 --- a/site/src/xServices/workspace/workspaceXService.ts +++ b/site/src/xServices/workspace/workspaceXService.ts @@ -7,6 +7,7 @@ interface WorkspaceContext { workspace?: Types.Workspace template?: Types.Template organization?: Types.Organization + build?: TypesGen.WorkspaceBuild getWorkspaceError?: Error | unknown getTemplateError?: Error | unknown getOrganizationError?: Error | unknown @@ -14,6 +15,9 @@ interface WorkspaceContext { jobError?: Error | unknown // error creating a new WorkspaceBuild buildError?: Error | unknown + // these are separate from get X errors because they don't make the page unusable + refreshWorkspaceError: Error | unknown + refreshTemplateError: Error | unknown } type WorkspaceEvent = @@ -21,6 +25,7 @@ type WorkspaceEvent = | { type: "START" } | { type: "STOP" } | { type: "RETRY" } + | { type: "UPDATE" } | { type: "REFRESH_WORKSPACE" } export const workspaceMachine = createMachine( @@ -60,12 +65,13 @@ export const workspaceMachine = createMachine( tags: "loading", }, gettingWorkspace: { + entry: ["clearGetWorkspaceError", "clearContext"], invoke: { src: "getWorkspace", id: "getWorkspace", onDone: { target: "ready", - actions: ["assignWorkspace", "clearGetWorkspaceError"], + actions: ["assignWorkspace"], }, onError: { target: "error", @@ -116,6 +122,9 @@ export const workspaceMachine = createMachine( }, build: { initial: "dispatch", + on: { + UPDATE: "#workspaceState.ready.build.refreshingTemplate", + }, states: { dispatch: { always: [ @@ -156,7 +165,7 @@ export const workspaceMachine = createMachine( src: "startWorkspace", onDone: { target: "buildingStart", - actions: "clearJobError", + actions: ["assignBuild", "clearJobError"], }, onError: { target: "error", @@ -169,7 +178,7 @@ export const workspaceMachine = createMachine( invoke: { id: "stopWorkspace", src: "stopWorkspace", - onDone: { target: "buildingStop", actions: "clearJobError" }, + onDone: { target: "buildingStop", actions: ["assignBuild", "clearJobError"] }, onError: { target: "error", actions: "assignJobError", @@ -239,6 +248,15 @@ export const workspaceMachine = createMachine( }, tags: ["buildLoading", "stopping"], }, + refreshingTemplate: { + entry: "clearRefreshTemplateError", + invoke: { + id: "refreshTemplate", + src: "getTemplate", + onDone: { target: "#workspaceState.ready.build.requestingStart", actions: "assignTemplate" }, + onError: { target: "error", actions: "assignRefreshTemplateError" }, + }, + }, error: { on: { RETRY: [ @@ -266,6 +284,14 @@ export const workspaceMachine = createMachine( }, { actions: { + // Clear data about an old workspace when looking at a new one + clearContext: () => + assign({ + workspace: undefined, + template: undefined, + organization: undefined, + build: undefined, + }), assignWorkspace: assign({ workspace: (_, event) => event.data, }), @@ -287,6 +313,10 @@ export const workspaceMachine = createMachine( getOrganizationError: (_, event) => event.data, }), clearGetOrganizationError: (context) => assign({ ...context, getOrganizationError: undefined }), + assignBuild: (_, event) => + assign({ + build: event.data, + }), assignJobError: (_, event) => assign({ jobError: event.data, @@ -311,6 +341,14 @@ export const workspaceMachine = createMachine( assign({ refreshWorkspaceError: undefined, }), + assignRefreshTemplateError: (_, event) => + assign({ + refreshTemplateError: event.data, + }), + clearRefreshTemplateError: (_) => + assign({ + refreshTemplateError: undefined, + }), }, guards: { workspaceIsStarted: (context) => From 992ee0cc1e048c1950ef43e585cf0f0f2be2263a Mon Sep 17 00:00:00 2001 From: Presley Date: Thu, 12 May 2022 13:50:54 +0000 Subject: [PATCH 33/44] Make start use version id Important for update --- site/src/xServices/workspace/workspaceXService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/xServices/workspace/workspaceXService.ts b/site/src/xServices/workspace/workspaceXService.ts index a0b2521c9260f..0e4f96e36723f 100644 --- a/site/src/xServices/workspace/workspaceXService.ts +++ b/site/src/xServices/workspace/workspaceXService.ts @@ -390,7 +390,7 @@ export const workspaceMachine = createMachine( }, startWorkspace: async (context) => { if (context.workspace) { - return await API.startWorkspace(context.workspace.id) + return await API.startWorkspace(context.workspace.id, context.template?.active_version_id) } else { throw Error("Cannot start workspace without workspace id") } From 9cd386e0d91889249e3c4e490accfa03095c3cd8 Mon Sep 17 00:00:00 2001 From: Presley Date: Thu, 12 May 2022 17:24:16 +0000 Subject: [PATCH 34/44] Fix imports --- .../components/WorkspaceStatusBar/WorkspaceStatusBar.tsx | 8 ++++---- site/src/testHelpers/handlers.ts | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/site/src/components/WorkspaceStatusBar/WorkspaceStatusBar.tsx b/site/src/components/WorkspaceStatusBar/WorkspaceStatusBar.tsx index aea2428506fa3..ab187cba6a7ff 100644 --- a/site/src/components/WorkspaceStatusBar/WorkspaceStatusBar.tsx +++ b/site/src/components/WorkspaceStatusBar/WorkspaceStatusBar.tsx @@ -4,7 +4,7 @@ import { makeStyles } from "@material-ui/core/styles" import Typography from "@material-ui/core/Typography" import React from "react" import { Link } from "react-router-dom" -import * as Types from "../../api/types" +import * as TypesGen from "../../api/typesGenerated" import { WorkspaceStatus } from "../../pages/WorkspacePage/WorkspacePage" import { TitleIconSize } from "../../theme/constants" import { combineClasses } from "../../util/combineClasses" @@ -26,9 +26,9 @@ const Language = { } export interface WorkspaceStatusBarProps { - organization?: Types.Organization - workspace: Types.Workspace - template?: Types.Template + organization?: TypesGen.Organization + workspace: TypesGen.Workspace + template?: TypesGen.Template handleStart: () => void handleStop: () => void handleRetry: () => void diff --git a/site/src/testHelpers/handlers.ts b/site/src/testHelpers/handlers.ts index c4a895245ffef..89ce5b8fc3ae5 100644 --- a/site/src/testHelpers/handlers.ts +++ b/site/src/testHelpers/handlers.ts @@ -73,9 +73,9 @@ export const handlers = [ rest.post("/api/v2/workspaces/:workspaceId/builds", async (req, res, ctx) => { const { transition } = req.body as CreateWorkspaceBuildRequest const transitionToBuild = { - start: M.MockWorkspaceStart, - stop: M.MockWorkspaceStop, - delete: M.MockWorkspaceDelete, + start: M.MockWorkspaceBuild, + stop: M.MockWorkspaceBuildStop, + delete: M.MockWorkspaceBuildDelete, } const result = transitionToBuild[transition as WorkspaceBuildTransition] return res(ctx.status(200), ctx.json(result)) From 1a09166619e65675274c24324567e56c477c13ff Mon Sep 17 00:00:00 2001 From: Presley Date: Thu, 12 May 2022 19:37:24 +0000 Subject: [PATCH 35/44] Add polling for outdated --- .../xServices/workspace/workspaceXService.ts | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/site/src/xServices/workspace/workspaceXService.ts b/site/src/xServices/workspace/workspaceXService.ts index 6545ec84a3a75..96f66d50090f7 100644 --- a/site/src/xServices/workspace/workspaceXService.ts +++ b/site/src/xServices/workspace/workspaceXService.ts @@ -14,7 +14,7 @@ interface WorkspaceContext { jobError?: Error | unknown // error creating a new WorkspaceBuild buildError?: Error | unknown - // these are separate from get X errors because they don't make the page unusable + // these are separate from getX errors because they don't make the page unusable refreshWorkspaceError: Error | unknown refreshTemplateError: Error | unknown } @@ -82,6 +82,26 @@ export const workspaceMachine = createMachine( ready: { type: "parallel", states: { + // We poll the workspace consistently to know if it becomes outdated + pollingWorkspace: { + initial: "refreshingWorkspace", + states: { + refreshingWorkspace: { + entry: "clearRefreshWorkspaceError", + invoke: { + id: "refreshWorkspace", + src: "refreshWorkspace", + onDone: { actions: "assignWorkspace"}, + onError: { target: "waiting", actions: "assignRefreshWorkspaceError" }, + }, + }, + waiting: { + after: { + 5000: "refreshingWorkspace" + } + } + } + }, breadcrumb: { initial: "gettingTemplate", states: { From e9758c79aec342d16db82bec2dc37098c8bc542e Mon Sep 17 00:00:00 2001 From: Presley Date: Thu, 12 May 2022 21:29:43 +0000 Subject: [PATCH 36/44] Rely on context instead of finite state for status --- .../WorkspaceStatusBar/WorkspaceStatusBar.tsx | 9 +- .../src/pages/WorkspacePage/WorkspacePage.tsx | 17 +- .../xServices/workspace/workspaceSelectors.ts | 30 +++ .../xServices/workspace/workspaceXService.ts | 196 ++++-------------- 4 files changed, 76 insertions(+), 176 deletions(-) create mode 100644 site/src/xServices/workspace/workspaceSelectors.ts diff --git a/site/src/components/WorkspaceStatusBar/WorkspaceStatusBar.tsx b/site/src/components/WorkspaceStatusBar/WorkspaceStatusBar.tsx index ab187cba6a7ff..7ea4b7c131cfc 100644 --- a/site/src/components/WorkspaceStatusBar/WorkspaceStatusBar.tsx +++ b/site/src/components/WorkspaceStatusBar/WorkspaceStatusBar.tsx @@ -23,6 +23,8 @@ const Language = { stopping: "Stopping", error: "Build Failed", loading: "Loading Status", + deleting: "Deleting", + deleted: "Deleted" } export interface WorkspaceStatusBarProps { @@ -78,12 +80,7 @@ export const WorkspaceStatusBar: React.FC = ({
{workspace.name} - {workspaceStatus === "started" && Language.started} - {workspaceStatus === "starting" && Language.starting} - {workspaceStatus === "stopped" && Language.stopped} - {workspaceStatus === "stopping" && Language.stopping} - {workspaceStatus === "error" && Language.error} - {workspaceStatus === "loading" && Language.loading} + {Language[workspaceStatus]}
diff --git a/site/src/pages/WorkspacePage/WorkspacePage.tsx b/site/src/pages/WorkspacePage/WorkspacePage.tsx index 56fd5d97b1dca..e2cd040700c00 100644 --- a/site/src/pages/WorkspacePage/WorkspacePage.tsx +++ b/site/src/pages/WorkspacePage/WorkspacePage.tsx @@ -1,4 +1,4 @@ -import { useActor } from "@xstate/react" +import { useActor, useSelector } from "@xstate/react" import React, { useContext, useEffect } from "react" import { useParams } from "react-router-dom" import { ErrorSummary } from "../../components/ErrorSummary/ErrorSummary" @@ -8,8 +8,9 @@ import { Stack } from "../../components/Stack/Stack" import { Workspace } from "../../components/Workspace/Workspace" import { firstOrItem } from "../../util/array" import { XServiceContext } from "../../xServices/StateContext" +import { selectWorkspaceStatus } from "../../xServices/workspace/workspaceSelectors" -export type WorkspaceStatus = "started" | "starting" | "stopped" | "stopping" | "error" | "loading" +export type WorkspaceStatus = "started" | "starting" | "stopped" | "stopping" | "error" | "loading" | "deleting" | "deleted" export const WorkspacePage: React.FC = () => { const { workspace: workspaceQueryParam } = useParams() @@ -19,17 +20,7 @@ export const WorkspacePage: React.FC = () => { const [workspaceState, workspaceSend] = useActor(xServices.workspaceXService) const { workspace, template, organization, getWorkspaceError, getTemplateError, getOrganizationError } = workspaceState.context - const workspaceStatus: WorkspaceStatus = workspaceState.matches("ready.build.started") - ? "started" - : workspaceState.matches("ready.build.stopped") - ? "stopped" - : workspaceState.hasTag("starting") - ? "starting" - : workspaceState.hasTag("stopping") - ? "stopping" - : workspaceState.matches("ready.build.error") - ? "error" - : "loading" + const workspaceStatus = useSelector(xServices.workspaceXService, selectWorkspaceStatus) /** * Get workspace, template, and organization on mount and whenever workspaceId changes. diff --git a/site/src/xServices/workspace/workspaceSelectors.ts b/site/src/xServices/workspace/workspaceSelectors.ts new file mode 100644 index 0000000000000..fc585faa0a6a8 --- /dev/null +++ b/site/src/xServices/workspace/workspaceSelectors.ts @@ -0,0 +1,30 @@ +import { State } from "xstate" +import { WorkspaceBuildTransition } from "../../api/types" +import { WorkspaceStatus } from "../../pages/WorkspacePage/WorkspacePage" +import { WorkspaceContext, WorkspaceEvent } from "./workspaceXService" + +const inProgressToStatus: Record> = { + "start": "starting", + "stop": "stopping", + "delete": "deleting" +} + +const succeededToStatus: Record> = { + "start": "started", + "stop": "stopped", + "delete": "deleted" +} + +export const selectWorkspaceStatus = (state: State): WorkspaceStatus => { + const transition = state.context.workspace?.latest_build.transition as WorkspaceBuildTransition + const jobStatus = state.context.workspace?.latest_build.job.status + switch (jobStatus) { + case undefined: return "loading" + case "succeeded": return succeededToStatus[transition] + case "pending": return inProgressToStatus[transition] + case "running": return inProgressToStatus[transition] + case "canceled": return "error" + case "canceling": return "error" + case "failed": return "error" + } +} diff --git a/site/src/xServices/workspace/workspaceXService.ts b/site/src/xServices/workspace/workspaceXService.ts index 96f66d50090f7..9f7896412a87d 100644 --- a/site/src/xServices/workspace/workspaceXService.ts +++ b/site/src/xServices/workspace/workspaceXService.ts @@ -2,7 +2,7 @@ import { assign, createMachine } from "xstate" import * as API from "../../api/api" import * as TypesGen from "../../api/typesGenerated" -interface WorkspaceContext { +export interface WorkspaceContext { workspace?: TypesGen.Workspace template?: TypesGen.Template organization?: TypesGen.Organization @@ -19,13 +19,12 @@ interface WorkspaceContext { refreshTemplateError: Error | unknown } -type WorkspaceEvent = +export type WorkspaceEvent = | { type: "GET_WORKSPACE"; workspaceId: string } | { type: "START" } | { type: "STOP" } | { type: "RETRY" } | { type: "UPDATE" } - | { type: "REFRESH_WORKSPACE" } export const workspaceMachine = createMachine( { @@ -82,7 +81,7 @@ export const workspaceMachine = createMachine( ready: { type: "parallel", states: { - // We poll the workspace consistently to know if it becomes outdated + // We poll the workspace consistently to know if it becomes outdated and to update build status pollingWorkspace: { initial: "refreshingWorkspace", states: { @@ -91,13 +90,13 @@ export const workspaceMachine = createMachine( invoke: { id: "refreshWorkspace", src: "refreshWorkspace", - onDone: { actions: "assignWorkspace"}, + onDone: { target: "waiting", actions: "assignWorkspace"}, onError: { target: "waiting", actions: "assignRefreshWorkspaceError" }, }, }, waiting: { after: { - 5000: "refreshingWorkspace" + 1000: "refreshingWorkspace" } } } @@ -140,157 +139,65 @@ export const workspaceMachine = createMachine( }, }, build: { - initial: "dispatch", - on: { - UPDATE: "#workspaceState.ready.build.refreshingTemplate", - }, + initial: "idle", states: { - dispatch: { - always: [ - { - cond: "workspaceIsStarted", - target: "started", - }, - { - cond: "workspaceIsStopped", - target: "stopped", - }, - { - cond: "workspaceIsStarting", - target: "buildingStart", - }, - { - cond: "workspaceIsStopping", - target: "buildingStop", - }, - { target: "error" }, - ], - }, - started: { - on: { - STOP: "requestingStop", - }, - tags: "buildReady", - }, - stopped: { + idle: { on: { START: "requestingStart", + STOP: "requestingStop", + RETRY: [ + { cond: "triedToStart", target: "requestingStart" }, + { target: "requestingStop" } + ], + UPDATE: "refreshingTemplate", }, - tags: "buildReady", }, requestingStart: { + entry: "clearBuildError", invoke: { id: "startWorkspace", src: "startWorkspace", onDone: { - target: "buildingStart", - actions: ["assignBuild", "clearJobError"], + target: "idle", + actions: "assignBuild" }, onError: { - target: "error", - actions: "assignJobError", - }, - }, - tags: ["buildLoading", "starting"], + target: "idle", + actions: "assignBuildError" + } + } }, requestingStop: { + entry: "clearBuildError", invoke: { id: "stopWorkspace", src: "stopWorkspace", - onDone: { target: "buildingStop", actions: ["assignBuild", "clearJobError"] }, - onError: { - target: "error", - actions: "assignJobError", - }, - }, - tags: ["buildLoading", "stopping"], - }, - buildingStart: { - initial: "refreshingWorkspace", - states: { - refreshingWorkspace: { - entry: "clearRefreshWorkspaceError", - invoke: { - id: "refreshWorkspace", - src: "refreshWorkspace", - onDone: [ - { - cond: "jobSucceeded", - target: "#workspaceState.ready.build.started", - actions: ["clearBuildError", "assignWorkspace"], - }, - { cond: "jobPendingOrRunning", target: "waiting", actions: "assignWorkspace" }, - { - target: "#workspaceState.ready.build.error", - actions: ["assignWorkspace", "assignBuildError"], - }, - ], - onError: { target: "waiting", actions: "assignRefreshWorkspaceError" }, - }, - }, - waiting: { - after: { - 1000: "refreshingWorkspace", - }, - }, - }, - tags: ["buildLoading", "starting"], - }, - buildingStop: { - initial: "refreshingWorkspace", - states: { - refreshingWorkspace: { - entry: "clearRefreshWorkspaceError", - invoke: { - id: "refreshWorkspace", - src: "refreshWorkspace", - onDone: [ - { - cond: "jobSucceeded", - target: "#workspaceState.ready.build.stopped", - actions: ["clearBuildError", "assignWorkspace"], - }, - { cond: "jobPendingOrRunning", target: "waiting", actions: "assignWorkspace" }, - { - target: "#workspaceState.ready.build.error", - actions: ["assignWorkspace", "assignBuildError"], - }, - ], - onError: { target: "waiting", actions: "assignRefreshWorkspaceError" }, - }, - }, - waiting: { - after: { - 1000: "refreshingWorkspace", - }, + onDone: { + target: "idle", + actions: "assignBuild" }, - }, - tags: ["buildLoading", "stopping"], + onError: { + target: "idle", + actions: "assignBuildError" + } + } }, refreshingTemplate: { entry: "clearRefreshTemplateError", invoke: { id: "refreshTemplate", src: "getTemplate", - onDone: { target: "#workspaceState.ready.build.requestingStart", actions: "assignTemplate" }, - onError: { target: "error", actions: "assignRefreshTemplateError" }, - }, - }, - error: { - on: { - RETRY: [ - { - cond: "triedToStart", - target: "requestingStart", - }, - { - // this could also be post-delete - target: "requestingStop", - }, - ], - }, + onDone: { + target: "requestingStart", + actions: "assignTemplate" + }, + onError: { + target: "idle", + actions: "assignRefreshTemplateError" + } + } }, - }, + } }, }, }, @@ -336,14 +243,6 @@ export const workspaceMachine = createMachine( assign({ build: event.data, }), - assignJobError: (_, event) => - assign({ - jobError: event.data, - }), - clearJobError: (_) => - assign({ - jobError: undefined, - }), assignBuildError: (_, event) => assign({ buildError: event.data, @@ -370,24 +269,7 @@ export const workspaceMachine = createMachine( }), }, guards: { - workspaceIsStarted: (context) => - context.workspace?.latest_build.transition === "start" && - context.workspace.latest_build.job.status === "succeeded", - workspaceIsStopped: (context) => - context.workspace?.latest_build.transition === "stop" && - context.workspace.latest_build.job.status === "succeeded", - workspaceIsStarting: (context) => - context.workspace?.latest_build.transition === "start" && - ["pending", "running"].includes(context.workspace.latest_build.job.status), - workspaceIsStopping: (context) => - context.workspace?.latest_build.transition === "stop" && - ["pending", "running"].includes(context.workspace.latest_build.job.status), triedToStart: (context) => context.workspace?.latest_build.transition === "start", - jobSucceeded: (context) => context.workspace?.latest_build.job.status === "succeeded", - jobPendingOrRunning: (context) => { - const status = context.workspace?.latest_build.job.status - return status === "pending" || status === "running" - }, }, services: { getWorkspace: async (_, event) => { From 87fee0668a27fcc307826e707d421d7cfda3256a Mon Sep 17 00:00:00 2001 From: Presley Date: Fri, 13 May 2022 14:12:07 +0000 Subject: [PATCH 37/44] Handle canceling --- site/src/components/Workspace/Workspace.stories.tsx | 12 ++++++++++++ .../WorkspaceStatusBar/WorkspaceStatusBar.tsx | 7 +++++-- site/src/pages/WorkspacePage/WorkspacePage.tsx | 2 +- site/src/xServices/workspace/workspaceSelectors.ts | 2 +- 4 files changed, 19 insertions(+), 4 deletions(-) diff --git a/site/src/components/Workspace/Workspace.stories.tsx b/site/src/components/Workspace/Workspace.stories.tsx index ba6a8f6af63b8..203669f417d2c 100644 --- a/site/src/components/Workspace/Workspace.stories.tsx +++ b/site/src/components/Workspace/Workspace.stories.tsx @@ -35,6 +35,18 @@ Stopping.args = { ...Started.args, workspaceStatus: "stopping" } export const Error = Template.bind({}) Error.args = { ...Started.args, workspaceStatus: "error" } +export const BuildLoading = Template.bind({}) +BuildLoading.args = {...Started.args, workspaceStatus: "loading" } + +export const Deleting = Template.bind({}) +Deleting.args = {...Started.args, workspaceStatus: "deleting"} + +export const Deleted = Template.bind({}) +Deleted.args = {...Started.args, workspaceStatus: "deleted"} + +export const Canceling = Template.bind({}) +Canceling.args = {...Started.args, workspaceStatus: "canceling"} + export const NoBreadcrumb = Template.bind({}) NoBreadcrumb.args = { ...Started.args, template: undefined } diff --git a/site/src/components/WorkspaceStatusBar/WorkspaceStatusBar.tsx b/site/src/components/WorkspaceStatusBar/WorkspaceStatusBar.tsx index 7ea4b7c131cfc..fc1e1946f1d7a 100644 --- a/site/src/components/WorkspaceStatusBar/WorkspaceStatusBar.tsx +++ b/site/src/components/WorkspaceStatusBar/WorkspaceStatusBar.tsx @@ -24,7 +24,10 @@ const Language = { error: "Build Failed", loading: "Loading Status", deleting: "Deleting", - deleted: "Deleted" + deleted: "Deleted", + // "Canceling" would be misleading because it refers to a build, not the workspace. + // So just stall. When it is canceled it will appear as the error workspaceStatus. + canceling: "Loading Status" } export interface WorkspaceStatusBarProps { @@ -102,7 +105,7 @@ export const WorkspaceStatusBar: React.FC = ({ )} {/* Workspace will not update while another job is in progress so hide the button until it's usable */} - {workspace.outdated && ["started", "stopped", "error"].includes(workspaceStatus) && ( + {workspace.outdated && ["started", "stopped", "deleted", "error"].includes(workspaceStatus) && ( diff --git a/site/src/pages/WorkspacePage/WorkspacePage.tsx b/site/src/pages/WorkspacePage/WorkspacePage.tsx index e2cd040700c00..925b51c2c4db6 100644 --- a/site/src/pages/WorkspacePage/WorkspacePage.tsx +++ b/site/src/pages/WorkspacePage/WorkspacePage.tsx @@ -10,7 +10,7 @@ import { firstOrItem } from "../../util/array" import { XServiceContext } from "../../xServices/StateContext" import { selectWorkspaceStatus } from "../../xServices/workspace/workspaceSelectors" -export type WorkspaceStatus = "started" | "starting" | "stopped" | "stopping" | "error" | "loading" | "deleting" | "deleted" +export type WorkspaceStatus = "started" | "starting" | "stopped" | "stopping" | "error" | "loading" | "deleting" | "deleted" | "canceling" export const WorkspacePage: React.FC = () => { const { workspace: workspaceQueryParam } = useParams() diff --git a/site/src/xServices/workspace/workspaceSelectors.ts b/site/src/xServices/workspace/workspaceSelectors.ts index fc585faa0a6a8..d413b73462389 100644 --- a/site/src/xServices/workspace/workspaceSelectors.ts +++ b/site/src/xServices/workspace/workspaceSelectors.ts @@ -23,8 +23,8 @@ export const selectWorkspaceStatus = (state: State Date: Fri, 13 May 2022 15:22:55 +0000 Subject: [PATCH 38/44] Fix tests --- .../WorkspacePage/WorkspacePage.test.tsx | 143 ++++++++++++------ site/src/testHelpers/entities.ts | 33 +++- 2 files changed, 123 insertions(+), 53 deletions(-) diff --git a/site/src/pages/WorkspacePage/WorkspacePage.test.tsx b/site/src/pages/WorkspacePage/WorkspacePage.test.tsx index ae795b3b50305..7357a5416f975 100644 --- a/site/src/pages/WorkspacePage/WorkspacePage.test.tsx +++ b/site/src/pages/WorkspacePage/WorkspacePage.test.tsx @@ -1,17 +1,56 @@ import { screen } from "@testing-library/react" import { rest } from "msw" import React from "react" +import * as api from "../../api/api" +import { Template, Workspace, WorkspaceBuild } from "../../api/typesGenerated" +import { Language } from "../../components/WorkspaceStatusBar/WorkspaceStatusBar" import { + MockCancelingWorkspace, + MockDeletedWorkspace, + MockDeletingWorkspace, MockFailedWorkspace, MockOutdatedWorkspace, + MockStartingWorkspace, MockStoppedWorkspace, + MockStoppingWorkspace, MockTemplate, MockWorkspace, + MockWorkspaceBuild, renderWithAuth, } from "../../testHelpers/renderHelpers" import { server } from "../../testHelpers/server" import { WorkspacePage } from "./WorkspacePage" +/** + * 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, + mock: + | jest.SpyInstance, [workspaceId: string, templateVersionId?: string | undefined]> + | jest.SpyInstance, [templateId: string]>, +) => { + renderWithAuth(, { route: `/workspaces/${MockWorkspace.id}`, path: "/workspaces/:workspace" }) + const button = await screen.findByText(label) + button.click() + expect(mock).toHaveBeenCalled() +} + +const testStatus = async (mock: Workspace, label: string) => { + server.use( + rest.get(`/api/v2/workspaces/${MockWorkspace.id}`, (req, res, ctx) => { + return res(ctx.status(200), ctx.json(mock)) + }), + ) + renderWithAuth(, { route: `/workspaces/${MockWorkspace.id}`, path: "/workspaces/:workspace" }) + const status = await screen.findByRole("status") + expect(status).toHaveTextContent(label) +} + describe("Workspace Page", () => { it("shows a workspace", async () => { renderWithAuth(, { route: `/workspaces/${MockWorkspace.id}`, path: "/workspaces/:workspace" }) @@ -25,54 +64,66 @@ describe("Workspace Page", () => { const status = await screen.findByRole("status") expect(status).toHaveTextContent("Running") }) - it("stops the workspace when the user presses Stop", async () => { - renderWithAuth(, { route: `/workspaces/${MockWorkspace.id}`, path: "/workspaces/:workspace" }) - const status = await screen.findByText("Running") - expect(status).toBeDefined() - const stopButton = await screen.findByText("Stop") - stopButton.click() - const laterStatus = await screen.findByText("Stopping") - expect(laterStatus).toBeDefined() + it("requests a stop job when the user presses Stop", async () => { + const stopWorkspaceMock = jest + .spyOn(api, "stopWorkspace") + .mockImplementation(() => Promise.resolve(MockWorkspaceBuild)) + testButton(Language.start, stopWorkspaceMock) + }), + it("requests a start job when the user presses Start", async () => { + const startWorkspaceMock = jest + .spyOn(api, "startWorkspace") + .mockImplementation(() => Promise.resolve(MockWorkspaceBuild)) + testButton(Language.start, startWorkspaceMock) + }), + it("requests a start job when the user presses Retry after trying to start", async () => { + const startWorkspaceMock = jest + .spyOn(api, "startWorkspace") + .mockImplementation(() => Promise.resolve(MockWorkspaceBuild)) + testButton(Language.retry, startWorkspaceMock) + }), + it("requests a stop job when the user presses Retry after trying to stop", async () => { + const stopWorkspaceMock = jest + .spyOn(api, "stopWorkspace") + .mockImplementation(() => Promise.resolve(MockWorkspaceBuild)) + server.use( + rest.get(`/api/v2/workspaces/${MockWorkspace.id}`, (req, res, ctx) => { + return res(ctx.status(200), ctx.json(MockStoppedWorkspace)) + }), + ) + testButton(Language.start, stopWorkspaceMock) + }), + it("requests a template when the user presses Update", async () => { + const getTemplateMock = jest.spyOn(api, "getTemplate").mockImplementation(() => Promise.resolve(MockTemplate)) + server.use( + rest.get(`/api/v2/workspaces/${MockWorkspace.id}`, (req, res, ctx) => { + return res(ctx.status(200), ctx.json(MockOutdatedWorkspace)) + }), + ) + testButton(Language.update, getTemplateMock) + }), + it("shows the Stopping status when the workspace is stopping", async () => { + testStatus(MockStoppingWorkspace, Language.stopping) + }) + it("shows the Stopped status when the workspace is stopped", async () => { + testStatus(MockStoppedWorkspace, Language.stopped) }) - it("starts the workspace when the user presses Start", async () => { - server.use( - rest.get(`/api/v2/workspaces/${MockWorkspace.id}`, (req, res, ctx) => { - return res(ctx.status(200), ctx.json(MockStoppedWorkspace)) - }), - ) - renderWithAuth(, { route: `/workspaces/${MockWorkspace.id}`, path: "/workspaces/:workspace" }) - const startButton = await screen.findByText("Start") - const status = await screen.findByText("Stopped") - expect(status).toBeDefined() - startButton.click() - const laterStatus = await screen.findByText("Building") - expect(laterStatus).toBeDefined() + it("shows the Building status when the workspace is starting", async () => { + testStatus(MockStartingWorkspace, Language.starting) }) - it("retries starting the workspace when the user presses Retry", async () => { - // MockFailedWorkspace.latest_build.transition is start so Retry will attempt to start - renderWithAuth(, { route: `/workspaces/${MockWorkspace.id}`, path: "/workspaces/:workspace" }) - server.use( - rest.get(`/api/v2/workspaces/${MockWorkspace.id}`, (req, res, ctx) => { - return res(ctx.status(200), ctx.json(MockFailedWorkspace)) - }), - ) - const status = await screen.findByText("Build Failed") - expect(status).toBeDefined() - const retryButton = await screen.findByText("Retry") - retryButton.click() - const laterStatus = await screen.findByText("Building") - expect(laterStatus).toBeDefined() + it("shows the Running status when the workspace is started", async () => { + testStatus(MockWorkspace, Language.started) }) - it("restarts the workspace when the user presses Update", async () => { - renderWithAuth(, { route: `/workspaces/${MockWorkspace.id}`, path: "/workspaces/:workspace" }) - server.use( - rest.get(`/api/v2/workspaces/${MockWorkspace.id}`, (req, res, ctx) => { - return res(ctx.status(200), ctx.json(MockOutdatedWorkspace)) - }), - ) - const updateButton = await screen.findByText("Update") - updateButton.click() - const status = await screen.findByText("Building") - expect(status).toBeDefined() + it("shows the Error status when the workspace is failed or canceled", async () => { + testStatus(MockFailedWorkspace, Language.error) + }) + it("shows the Loading status when the workspace is canceling", async () => { + testStatus(MockCancelingWorkspace, Language.canceling) + }) + it("shows the Deleting status when the workspace is deleting", async () => { + testStatus(MockDeletingWorkspace, Language.canceling) + }) + it("shows the Deleted status when the workspace is deleted", async () => { + testStatus(MockDeletedWorkspace, Language.canceling) }) }) diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 4f669c9d5da11..fb9c080b88af3 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -72,6 +72,11 @@ export const MockProvisionerJob: TypesGen.ProvisionerJob = { } export const MockFailedProvisionerJob = { ...MockProvisionerJob, status: "failed" as TypesGen.ProvisionerJobStatus } +export const MockCancelingProvisionerJob = { + ...MockProvisionerJob, + status: "canceling" as TypesGen.ProvisionerJobStatus, +} +export const MockRunningProvisionerJob = { ...MockProvisionerJob, status: "running" as TypesGen.ProvisionerJobStatus } export const MockTemplate: TypesGen.Template = { id: "test-template", @@ -117,11 +122,6 @@ export const MockWorkspaceBuild: TypesGen.WorkspaceBuild = { workspace_id: "test-workspace", } -export const MockFailedWorkspaceBuild = { - ...MockWorkspaceBuild, - job: MockFailedProvisionerJob, -} - export const MockWorkspaceBuildStop = { ...MockWorkspaceBuild, transition: "stop", @@ -147,8 +147,27 @@ export const MockWorkspace: TypesGen.Workspace = { } export const MockStoppedWorkspace: TypesGen.Workspace = { ...MockWorkspace, latest_build: MockWorkspaceBuildStop } - -export const MockFailedWorkspace: TypesGen.Workspace = { ...MockWorkspace, latest_build: MockFailedWorkspaceBuild } +export const MockStoppingWorkspace: TypesGen.Workspace = { + ...MockWorkspace, + latest_build: { ...MockWorkspaceBuildStop, job: MockRunningProvisionerJob }, +} +export const MockStartingWorkspace: TypesGen.Workspace = { + ...MockWorkspace, + latest_build: { ...MockWorkspaceBuild, job: MockRunningProvisionerJob }, +} +export const MockCancelingWorkspace: TypesGen.Workspace = { + ...MockWorkspace, + latest_build: { ...MockWorkspaceBuild, job: MockCancelingProvisionerJob }, +} +export const MockFailedWorkspace: TypesGen.Workspace = { + ...MockWorkspace, + latest_build: { ...MockWorkspaceBuild, job: MockFailedProvisionerJob }, +} +export const MockDeletingWorkspace: TypesGen.Workspace = { + ...MockWorkspace, + latest_build: { ...MockWorkspaceBuildDelete, job: MockRunningProvisionerJob }, +} +export const MockDeletedWorkspace: TypesGen.Workspace = { ...MockWorkspace, latest_build: MockWorkspaceBuildDelete } export const MockOutdatedWorkspace: TypesGen.Workspace = { ...MockWorkspace, outdated: true } From 39e84d9912d4109bf9e134ede78a64f38a7f2e63 Mon Sep 17 00:00:00 2001 From: Presley Date: Fri, 13 May 2022 15:23:03 +0000 Subject: [PATCH 39/44] Format --- .../Workspace/Workspace.stories.tsx | 8 ++-- .../WorkspaceStatusBar/WorkspaceStatusBar.tsx | 8 ++-- .../src/pages/WorkspacePage/WorkspacePage.tsx | 11 ++++- .../xServices/workspace/workspaceSelectors.ts | 33 +++++++++------ .../xServices/workspace/workspaceXService.ts | 41 +++++++++---------- 5 files changed, 57 insertions(+), 44 deletions(-) diff --git a/site/src/components/Workspace/Workspace.stories.tsx b/site/src/components/Workspace/Workspace.stories.tsx index 203669f417d2c..f6a8ca00cd097 100644 --- a/site/src/components/Workspace/Workspace.stories.tsx +++ b/site/src/components/Workspace/Workspace.stories.tsx @@ -36,16 +36,16 @@ export const Error = Template.bind({}) Error.args = { ...Started.args, workspaceStatus: "error" } export const BuildLoading = Template.bind({}) -BuildLoading.args = {...Started.args, workspaceStatus: "loading" } +BuildLoading.args = { ...Started.args, workspaceStatus: "loading" } export const Deleting = Template.bind({}) -Deleting.args = {...Started.args, workspaceStatus: "deleting"} +Deleting.args = { ...Started.args, workspaceStatus: "deleting" } export const Deleted = Template.bind({}) -Deleted.args = {...Started.args, workspaceStatus: "deleted"} +Deleted.args = { ...Started.args, workspaceStatus: "deleted" } export const Canceling = Template.bind({}) -Canceling.args = {...Started.args, workspaceStatus: "canceling"} +Canceling.args = { ...Started.args, workspaceStatus: "canceling" } export const NoBreadcrumb = Template.bind({}) NoBreadcrumb.args = { ...Started.args, template: undefined } diff --git a/site/src/components/WorkspaceStatusBar/WorkspaceStatusBar.tsx b/site/src/components/WorkspaceStatusBar/WorkspaceStatusBar.tsx index fc1e1946f1d7a..07ed161d29fe3 100644 --- a/site/src/components/WorkspaceStatusBar/WorkspaceStatusBar.tsx +++ b/site/src/components/WorkspaceStatusBar/WorkspaceStatusBar.tsx @@ -11,7 +11,7 @@ import { combineClasses } from "../../util/combineClasses" import { Stack } from "../Stack/Stack" import { WorkspaceSection } from "../WorkspaceSection/WorkspaceSection" -const Language = { +export const Language = { stop: "Stop", start: "Start", retry: "Retry", @@ -23,11 +23,11 @@ const Language = { stopping: "Stopping", error: "Build Failed", loading: "Loading Status", - deleting: "Deleting", + deleting: "Deleting", deleted: "Deleted", - // "Canceling" would be misleading because it refers to a build, not the workspace. + // "Canceling" would be misleading because it refers to a build, not the workspace. // So just stall. When it is canceled it will appear as the error workspaceStatus. - canceling: "Loading Status" + canceling: "Loading Status", } export interface WorkspaceStatusBarProps { diff --git a/site/src/pages/WorkspacePage/WorkspacePage.tsx b/site/src/pages/WorkspacePage/WorkspacePage.tsx index 925b51c2c4db6..4dc6ba6ea3122 100644 --- a/site/src/pages/WorkspacePage/WorkspacePage.tsx +++ b/site/src/pages/WorkspacePage/WorkspacePage.tsx @@ -10,7 +10,16 @@ import { firstOrItem } from "../../util/array" import { XServiceContext } from "../../xServices/StateContext" import { selectWorkspaceStatus } from "../../xServices/workspace/workspaceSelectors" -export type WorkspaceStatus = "started" | "starting" | "stopped" | "stopping" | "error" | "loading" | "deleting" | "deleted" | "canceling" +export type WorkspaceStatus = + | "started" + | "starting" + | "stopped" + | "stopping" + | "error" + | "loading" + | "deleting" + | "deleted" + | "canceling" export const WorkspacePage: React.FC = () => { const { workspace: workspaceQueryParam } = useParams() diff --git a/site/src/xServices/workspace/workspaceSelectors.ts b/site/src/xServices/workspace/workspaceSelectors.ts index d413b73462389..27fa11af63a39 100644 --- a/site/src/xServices/workspace/workspaceSelectors.ts +++ b/site/src/xServices/workspace/workspaceSelectors.ts @@ -4,27 +4,34 @@ import { WorkspaceStatus } from "../../pages/WorkspacePage/WorkspacePage" import { WorkspaceContext, WorkspaceEvent } from "./workspaceXService" const inProgressToStatus: Record> = { - "start": "starting", - "stop": "stopping", - "delete": "deleting" + start: "starting", + stop: "stopping", + delete: "deleting", } const succeededToStatus: Record> = { - "start": "started", - "stop": "stopped", - "delete": "deleted" + start: "started", + stop: "stopped", + delete: "deleted", } export const selectWorkspaceStatus = (state: State): WorkspaceStatus => { const transition = state.context.workspace?.latest_build.transition as WorkspaceBuildTransition const jobStatus = state.context.workspace?.latest_build.job.status switch (jobStatus) { - case undefined: return "loading" - case "succeeded": return succeededToStatus[transition] - case "pending": return inProgressToStatus[transition] - case "running": return inProgressToStatus[transition] - case "canceling": return "canceling" - case "canceled": return "error" - case "failed": return "error" + case undefined: + return "loading" + case "succeeded": + return succeededToStatus[transition] + case "pending": + return inProgressToStatus[transition] + case "running": + return inProgressToStatus[transition] + case "canceling": + return "canceling" + case "canceled": + return "error" + case "failed": + return "error" } } diff --git a/site/src/xServices/workspace/workspaceXService.ts b/site/src/xServices/workspace/workspaceXService.ts index 9f7896412a87d..fcb8d513c1fbc 100644 --- a/site/src/xServices/workspace/workspaceXService.ts +++ b/site/src/xServices/workspace/workspaceXService.ts @@ -90,16 +90,16 @@ export const workspaceMachine = createMachine( invoke: { id: "refreshWorkspace", src: "refreshWorkspace", - onDone: { target: "waiting", actions: "assignWorkspace"}, + onDone: { target: "waiting", actions: "assignWorkspace" }, onError: { target: "waiting", actions: "assignRefreshWorkspaceError" }, }, }, waiting: { after: { - 1000: "refreshingWorkspace" - } - } - } + 1000: "refreshingWorkspace", + }, + }, + }, }, breadcrumb: { initial: "gettingTemplate", @@ -145,10 +145,7 @@ export const workspaceMachine = createMachine( on: { START: "requestingStart", STOP: "requestingStop", - RETRY: [ - { cond: "triedToStart", target: "requestingStart" }, - { target: "requestingStop" } - ], + RETRY: [{ cond: "triedToStart", target: "requestingStart" }, { target: "requestingStop" }], UPDATE: "refreshingTemplate", }, }, @@ -159,13 +156,13 @@ export const workspaceMachine = createMachine( src: "startWorkspace", onDone: { target: "idle", - actions: "assignBuild" + actions: "assignBuild", }, onError: { target: "idle", - actions: "assignBuildError" - } - } + actions: "assignBuildError", + }, + }, }, requestingStop: { entry: "clearBuildError", @@ -174,13 +171,13 @@ export const workspaceMachine = createMachine( src: "stopWorkspace", onDone: { target: "idle", - actions: "assignBuild" + actions: "assignBuild", }, onError: { target: "idle", - actions: "assignBuildError" - } - } + actions: "assignBuildError", + }, + }, }, refreshingTemplate: { entry: "clearRefreshTemplateError", @@ -189,15 +186,15 @@ export const workspaceMachine = createMachine( src: "getTemplate", onDone: { target: "requestingStart", - actions: "assignTemplate" + actions: "assignTemplate", }, onError: { target: "idle", - actions: "assignRefreshTemplateError" - } - } + actions: "assignRefreshTemplateError", + }, + }, }, - } + }, }, }, }, From 06abf62ec1a17fcf57214e138649e913a8da88e8 Mon Sep 17 00:00:00 2001 From: Presley Date: Fri, 13 May 2022 19:27:54 +0000 Subject: [PATCH 40/44] Display errors so users know when button presses didn't work --- .../xServices/workspace/workspaceXService.ts | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/site/src/xServices/workspace/workspaceXService.ts b/site/src/xServices/workspace/workspaceXService.ts index fcb8d513c1fbc..2f8f28ee3e9e7 100644 --- a/site/src/xServices/workspace/workspaceXService.ts +++ b/site/src/xServices/workspace/workspaceXService.ts @@ -1,6 +1,12 @@ import { assign, createMachine } from "xstate" import * as API from "../../api/api" import * as TypesGen from "../../api/typesGenerated" +import { displayError } from "../../components/GlobalSnackbar/utils" + +const Language = { + refreshTemplateError: "Error updating workspace: latest template could not be fetched.", + buildError: "Workspace action failed." +} export interface WorkspaceContext { workspace?: TypesGen.Workspace @@ -10,8 +16,6 @@ export interface WorkspaceContext { getWorkspaceError?: Error | unknown getTemplateError?: Error | unknown getOrganizationError?: Error | unknown - // error enqueuing a ProvisionerJob to create a new WorkspaceBuild - jobError?: Error | unknown // error creating a new WorkspaceBuild buildError?: Error | unknown // these are separate from getX errors because they don't make the page unusable @@ -160,7 +164,7 @@ export const workspaceMachine = createMachine( }, onError: { target: "idle", - actions: "assignBuildError", + actions: ["assignBuildError", "displayBuildError"], }, }, }, @@ -175,7 +179,7 @@ export const workspaceMachine = createMachine( }, onError: { target: "idle", - actions: "assignBuildError", + actions: ["assignBuildError", "displayBuildError"], }, }, }, @@ -190,7 +194,7 @@ export const workspaceMachine = createMachine( }, onError: { target: "idle", - actions: "assignRefreshTemplateError", + actions: ["assignRefreshTemplateError", "displayRefreshTemplateError"], }, }, }, @@ -244,6 +248,9 @@ export const workspaceMachine = createMachine( assign({ buildError: event.data, }), + displayBuildError: (_, event) => { + displayError(Language.buildError) + }, clearBuildError: (_) => assign({ buildError: undefined, @@ -260,6 +267,9 @@ export const workspaceMachine = createMachine( assign({ refreshTemplateError: event.data, }), + displayRefreshTemplateError: (_, event) => { + displayError(Language.refreshTemplateError) + }, clearRefreshTemplateError: (_) => assign({ refreshTemplateError: undefined, @@ -273,6 +283,7 @@ export const workspaceMachine = createMachine( return await API.getWorkspace(event.workspaceId) }, getTemplate: async (context) => { + console.log("get template", context.template?.active_version_id) if (context.workspace) { return await API.getTemplate(context.workspace.template_id) } else { @@ -287,6 +298,7 @@ export const workspaceMachine = createMachine( } }, startWorkspace: async (context) => { + console.log("start workspace", context.template?.active_version_id) if (context.workspace) { return await API.startWorkspace(context.workspace.id, context.template?.active_version_id) } else { From 8552ea2205051c97011f094b737cb5e63f30f914 Mon Sep 17 00:00:00 2001 From: Presley Date: Fri, 13 May 2022 20:54:10 +0000 Subject: [PATCH 41/44] Fix api typo, remove logging --- site/src/api/api.ts | 4 ++-- site/src/xServices/workspace/workspaceXService.ts | 2 -- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/site/src/api/api.ts b/site/src/api/api.ts index f85427a8f1920..a02845f9c9dfb 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -126,10 +126,10 @@ export const getWorkspaceResources = async (workspaceBuildID: string): Promise - async (workspaceId: string, templateVersionId?: string): Promise => { + async (workspaceId: string, template_version_id?: string): Promise => { const payload = { transition, - templateVersionId, + template_version_id, } const response = await axios.post(`/api/v2/workspaces/${workspaceId}/builds`, payload) return response.data diff --git a/site/src/xServices/workspace/workspaceXService.ts b/site/src/xServices/workspace/workspaceXService.ts index 2f8f28ee3e9e7..bd962a5368f30 100644 --- a/site/src/xServices/workspace/workspaceXService.ts +++ b/site/src/xServices/workspace/workspaceXService.ts @@ -283,7 +283,6 @@ export const workspaceMachine = createMachine( return await API.getWorkspace(event.workspaceId) }, getTemplate: async (context) => { - console.log("get template", context.template?.active_version_id) if (context.workspace) { return await API.getTemplate(context.workspace.template_id) } else { @@ -298,7 +297,6 @@ export const workspaceMachine = createMachine( } }, startWorkspace: async (context) => { - console.log("start workspace", context.template?.active_version_id) if (context.workspace) { return await API.startWorkspace(context.workspace.id, context.template?.active_version_id) } else { From 72c856e6fbdc464e39d02d7dfd8e5d6a16369080 Mon Sep 17 00:00:00 2001 From: Presley Date: Fri, 13 May 2022 21:12:35 +0000 Subject: [PATCH 42/44] Lint --- site/src/pages/WorkspacePage/WorkspacePage.test.tsx | 1 + site/src/xServices/workspace/workspaceXService.ts | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/site/src/pages/WorkspacePage/WorkspacePage.test.tsx b/site/src/pages/WorkspacePage/WorkspacePage.test.tsx index 7357a5416f975..f2e02a888d429 100644 --- a/site/src/pages/WorkspacePage/WorkspacePage.test.tsx +++ b/site/src/pages/WorkspacePage/WorkspacePage.test.tsx @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-floating-promises */ import { screen } from "@testing-library/react" import { rest } from "msw" import React from "react" diff --git a/site/src/xServices/workspace/workspaceXService.ts b/site/src/xServices/workspace/workspaceXService.ts index bd962a5368f30..a64633595466c 100644 --- a/site/src/xServices/workspace/workspaceXService.ts +++ b/site/src/xServices/workspace/workspaceXService.ts @@ -5,7 +5,7 @@ import { displayError } from "../../components/GlobalSnackbar/utils" const Language = { refreshTemplateError: "Error updating workspace: latest template could not be fetched.", - buildError: "Workspace action failed." + buildError: "Workspace action failed.", } export interface WorkspaceContext { @@ -248,7 +248,7 @@ export const workspaceMachine = createMachine( assign({ buildError: event.data, }), - displayBuildError: (_, event) => { + displayBuildError: () => { displayError(Language.buildError) }, clearBuildError: (_) => @@ -267,7 +267,7 @@ export const workspaceMachine = createMachine( assign({ refreshTemplateError: event.data, }), - displayRefreshTemplateError: (_, event) => { + displayRefreshTemplateError: () => { displayError(Language.refreshTemplateError) }, clearRefreshTemplateError: (_) => From 24829beb5be5ca34052eabcc332b97a3e8329c37 Mon Sep 17 00:00:00 2001 From: Presley Pizzo <1290996+presleyp@users.noreply.github.com> Date: Mon, 16 May 2022 06:53:25 -0400 Subject: [PATCH 43/44] Simplify type Co-authored-by: G r e y --- site/src/xServices/workspace/workspaceSelectors.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/site/src/xServices/workspace/workspaceSelectors.ts b/site/src/xServices/workspace/workspaceSelectors.ts index 27fa11af63a39..b1dffa9d1cc62 100644 --- a/site/src/xServices/workspace/workspaceSelectors.ts +++ b/site/src/xServices/workspace/workspaceSelectors.ts @@ -3,13 +3,13 @@ import { WorkspaceBuildTransition } from "../../api/types" import { WorkspaceStatus } from "../../pages/WorkspacePage/WorkspacePage" import { WorkspaceContext, WorkspaceEvent } from "./workspaceXService" -const inProgressToStatus: Record> = { +const inProgressToStatus: Record = { start: "starting", stop: "stopping", delete: "deleting", } -const succeededToStatus: Record> = { +const succeededToStatus: Record = { start: "started", stop: "stopped", delete: "deleted", From 542d8652e4801fc3de018d98f157f2d7a3e151ad Mon Sep 17 00:00:00 2001 From: Presley Date: Mon, 16 May 2022 16:21:41 +0000 Subject: [PATCH 44/44] Add type, extract helper --- site/src/api/api.ts | 3 ++- .../WorkspaceStatusBar/WorkspaceStatusBar.tsx | 10 ++++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 14f6cc4344c75..b9dd2b92c978a 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -1,5 +1,6 @@ import axios, { AxiosRequestHeaders } from "axios" import { mutate } from "swr" +import { WorkspaceBuildTransition } from "./types" import * as TypesGen from "./typesGenerated" const CONTENT_TYPE_JSON: AxiosRequestHeaders = { @@ -133,7 +134,7 @@ export const getWorkspaceResources = async (workspaceBuildID: string): Promise + (transition: WorkspaceBuildTransition) => async (workspaceId: string, template_version_id?: string): Promise => { const payload = { transition, diff --git a/site/src/components/WorkspaceStatusBar/WorkspaceStatusBar.tsx b/site/src/components/WorkspaceStatusBar/WorkspaceStatusBar.tsx index 07ed161d29fe3..0f3acc5b8093b 100644 --- a/site/src/components/WorkspaceStatusBar/WorkspaceStatusBar.tsx +++ b/site/src/components/WorkspaceStatusBar/WorkspaceStatusBar.tsx @@ -41,6 +41,13 @@ export interface WorkspaceStatusBarProps { workspaceStatus: WorkspaceStatus } +/** + * Jobs submitted while another job is in progress will be discarded, + * so check whether workspace job status has reached completion (whether successful or not). + */ +const canAcceptJobs = (workspaceStatus: WorkspaceStatus) => + ["started", "stopped", "deleted", "error"].includes(workspaceStatus) + /** * Component for the header at the top of the workspace page */ @@ -104,8 +111,7 @@ export const WorkspaceStatusBar: React.FC = ({ )} - {/* Workspace will not update while another job is in progress so hide the button until it's usable */} - {workspace.outdated && ["started", "stopped", "deleted", "error"].includes(workspaceStatus) && ( + {workspace.outdated && canAcceptJobs(workspaceStatus) && (