From 339e8fead4b3ee676e448588cfa06353e5340c3c Mon Sep 17 00:00:00 2001 From: Bruno Date: Mon, 16 May 2022 20:07:22 +0000 Subject: [PATCH 01/10] Add base structure --- site/src/api/api.ts | 5 + .../components/BuildsTable/BuildsTable.tsx | 64 +++++++++++ site/src/components/Workspace/Workspace.tsx | 10 +- .../src/pages/WorkspacePage/WorkspacePage.tsx | 3 +- .../xServices/workspace/workspaceXService.ts | 101 ++++++++++++++++++ 5 files changed, 176 insertions(+), 7 deletions(-) create mode 100644 site/src/components/BuildsTable/BuildsTable.tsx diff --git a/site/src/api/api.ts b/site/src/api/api.ts index a2f18e9330f06..cbf8d4346cb18 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -216,3 +216,8 @@ export const regenerateUserSSHKey = async (userId = "me"): Promise(`/api/v2/users/${userId}/gitsshkey`) return response.data } + +export const getWorkspaceBuilds = async (workspaceId: string): Promise => { + const response = await axios.get(`/api/v2/workspaces/${workspaceId}/builds`) + return response.data +} diff --git a/site/src/components/BuildsTable/BuildsTable.tsx b/site/src/components/BuildsTable/BuildsTable.tsx new file mode 100644 index 0000000000000..2441cedbcf4c8 --- /dev/null +++ b/site/src/components/BuildsTable/BuildsTable.tsx @@ -0,0 +1,64 @@ +import Box from "@material-ui/core/Box" +import Table from "@material-ui/core/Table" +import TableBody from "@material-ui/core/TableBody" +import TableCell from "@material-ui/core/TableCell" +import TableHead from "@material-ui/core/TableHead" +import TableRow from "@material-ui/core/TableRow" +import React from "react" +import * as TypesGen from "../../api/typesGenerated" +import { EmptyState } from "../EmptyState/EmptyState" +import { TableHeaderRow } from "../TableHeaders/TableHeaders" +import { TableLoader } from "../TableLoader/TableLoader" + +export const Language = { + pageTitle: "Builds", + usersTitle: "All users", + emptyMessage: "No users found", + usernameLabel: "User", + suspendMenuItem: "Suspend", + resetPasswordMenuItem: "Reset password", + rolesLabel: "Roles", +} + +export interface BuildsTableProps { + builds?: TypesGen.WorkspaceBuild[] +} + +export const BuildsTable: React.FC = ({ builds }) => { + const isLoading = !builds + + return ( + + + + Action + Duration + Started at + Status + + + + {isLoading && } + {builds && + builds.map((b) => ( + + {b.transition} + {b.created_at} + {b.created_at} + {b.job.status} + + ))} + + {builds && builds.length === 0 && ( + + + + + + + + )} + +
+ ) +} diff --git a/site/src/components/Workspace/Workspace.tsx b/site/src/components/Workspace/Workspace.tsx index 7e2a9f2ab0c2b..7e5f448d55092 100644 --- a/site/src/components/Workspace/Workspace.tsx +++ b/site/src/components/Workspace/Workspace.tsx @@ -3,6 +3,7 @@ import Typography from "@material-ui/core/Typography" import React from "react" import * as TypesGen from "../../api/typesGenerated" import { WorkspaceStatus } from "../../pages/WorkspacePage/WorkspacePage" +import { BuildsTable } from "../BuildsTable/BuildsTable" import { WorkspaceSchedule } from "../WorkspaceSchedule/WorkspaceSchedule" import { WorkspaceSection } from "../WorkspaceSection/WorkspaceSection" import { WorkspaceStatusBar } from "../WorkspaceStatusBar/WorkspaceStatusBar" @@ -16,6 +17,7 @@ export interface WorkspaceProps { handleRetry: () => void handleUpdate: () => void workspaceStatus: WorkspaceStatus + builds?: TypesGen.WorkspaceBuild[] } /** @@ -30,6 +32,7 @@ export const Workspace: React.FC = ({ handleRetry, handleUpdate, workspaceStatus, + builds, }) => { const styles = useStyles() @@ -61,12 +64,7 @@ export const Workspace: React.FC = ({
-
- -
+
diff --git a/site/src/pages/WorkspacePage/WorkspacePage.tsx b/site/src/pages/WorkspacePage/WorkspacePage.tsx index 4dc6ba6ea3122..96b04ed704c97 100644 --- a/site/src/pages/WorkspacePage/WorkspacePage.tsx +++ b/site/src/pages/WorkspacePage/WorkspacePage.tsx @@ -27,7 +27,7 @@ export const WorkspacePage: React.FC = () => { const xServices = useContext(XServiceContext) const [workspaceState, workspaceSend] = useActor(xServices.workspaceXService) - const { workspace, template, organization, getWorkspaceError, getTemplateError, getOrganizationError } = + const { workspace, template, organization, getWorkspaceError, getTemplateError, getOrganizationError, builds } = workspaceState.context const workspaceStatus = useSelector(xServices.workspaceXService, selectWorkspaceStatus) @@ -56,6 +56,7 @@ export const WorkspacePage: React.FC = () => { handleRetry={() => workspaceSend("RETRY")} handleUpdate={() => workspaceSend("UPDATE")} workspaceStatus={workspaceStatus} + builds={builds} /> diff --git a/site/src/xServices/workspace/workspaceXService.ts b/site/src/xServices/workspace/workspaceXService.ts index a64633595466c..c00c0cb9adfbb 100644 --- a/site/src/xServices/workspace/workspaceXService.ts +++ b/site/src/xServices/workspace/workspaceXService.ts @@ -21,6 +21,10 @@ export interface WorkspaceContext { // these are separate from getX errors because they don't make the page unusable refreshWorkspaceError: Error | unknown refreshTemplateError: Error | unknown + // Builds + builds?: TypesGen.WorkspaceBuild[] + getBuildsError?: Error | unknown + loadMoreBuildsError?: Error | unknown } export type WorkspaceEvent = @@ -29,6 +33,7 @@ export type WorkspaceEvent = | { type: "STOP" } | { type: "RETRY" } | { type: "UPDATE" } + | { type: "LOAD_MORE_BUILDS" } export const workspaceMachine = createMachine( { @@ -55,6 +60,12 @@ export const workspaceMachine = createMachine( refreshWorkspace: { data: TypesGen.Workspace | undefined } + getBuilds: { + data: TypesGen.WorkspaceBuild[] + } + loadMoreBuilds: { + data: TypesGen.WorkspaceBuild[] + } }, }, id: "workspaceState", @@ -200,6 +211,54 @@ export const workspaceMachine = createMachine( }, }, }, + + builds: { + initial: "gettingBuilds", + states: { + idle: {}, + gettingBuilds: { + entry: "clearGetBuildsError", + invoke: { + src: "getBuilds", + onDone: { + actions: ["assignBuilds"], + target: "loadedBuilds", + }, + onError: { + actions: ["assignGetBuildsError"], + target: "idle", + }, + }, + }, + loadedBuilds: { + initial: "idle", + states: { + idle: { + on: { + LOAD_MORE_BUILDS: { + target: "loadingMoreBuilds", + cond: "hasMoreBuilds", + }, + }, + }, + loadingMoreBuilds: { + entry: "clearLoadMoreBuildsError", + invoke: { + src: "loadMoreBuilds", + onDone: { + actions: ["assignNewBuilds"], + target: "idle", + }, + onError: { + actions: ["assignLoadMoreBuildsError"], + target: "idle", + }, + }, + }, + }, + }, + }, + }, }, }, error: { @@ -274,9 +333,37 @@ export const workspaceMachine = createMachine( assign({ refreshTemplateError: undefined, }), + // Builds + assignBuilds: assign({ + builds: (_, event) => event.data, + }), + assignGetBuildsError: assign({ + getBuildsError: (_, event) => event.data, + }), + clearGetBuildsError: assign({ + getBuildsError: (_) => undefined, + }), + assignNewBuilds: assign({ + builds: (context, event) => { + const oldBuilds = context.builds + + if (!oldBuilds) { + throw new Error("Builds not loaded") + } + + return [...oldBuilds, ...event.data] + }, + }), + assignLoadMoreBuildsError: assign({ + loadMoreBuildsError: (_, event) => event.data, + }), + clearLoadMoreBuildsError: assign({ + loadMoreBuildsError: (_) => undefined, + }), }, guards: { triedToStart: (context) => context.workspace?.latest_build.transition === "start", + hasMoreBuilds: (_) => false, }, services: { getWorkspace: async (_, event) => { @@ -317,6 +404,20 @@ export const workspaceMachine = createMachine( throw Error("Cannot refresh workspace without id") } }, + getBuilds: async (context) => { + if (context.workspace) { + return await API.getWorkspaceBuilds(context.workspace.id) + } else { + throw Error("Cannot refresh workspace without id") + } + }, + loadMoreBuilds: async (context) => { + if (context.workspace) { + return await API.getWorkspaceBuilds(context.workspace.id) + } else { + throw Error("Cannot refresh workspace without id") + } + }, }, }, ) From 89c042e89fa77b91f4454851ae52542a8a11bc05 Mon Sep 17 00:00:00 2001 From: Bruno Date: Tue, 17 May 2022 17:42:15 +0000 Subject: [PATCH 02/10] Refactor table to properly show the status and times --- .../components/BuildsTable/BuildsTable.tsx | 67 ++++++++++++----- site/src/components/Workspace/Workspace.tsx | 10 ++- .../WorkspaceSection/WorkspaceSection.tsx | 10 ++- .../WorkspacesPage/WorkspacesPageView.tsx | 73 +------------------ site/src/util/workspace.ts | 69 ++++++++++++++++++ 5 files changed, 136 insertions(+), 93 deletions(-) diff --git a/site/src/components/BuildsTable/BuildsTable.tsx b/site/src/components/BuildsTable/BuildsTable.tsx index 2441cedbcf4c8..5dfc04f057404 100644 --- a/site/src/components/BuildsTable/BuildsTable.tsx +++ b/site/src/components/BuildsTable/BuildsTable.tsx @@ -1,15 +1,23 @@ import Box from "@material-ui/core/Box" +import { Theme } from "@material-ui/core/styles" import Table from "@material-ui/core/Table" import TableBody from "@material-ui/core/TableBody" import TableCell from "@material-ui/core/TableCell" import TableHead from "@material-ui/core/TableHead" import TableRow from "@material-ui/core/TableRow" +import useTheme from "@material-ui/styles/useTheme" +import dayjs from "dayjs" +import duration from "dayjs/plugin/duration" +import relativeTime from "dayjs/plugin/relativeTime" import React from "react" import * as TypesGen from "../../api/typesGenerated" +import { getDisplayStatus } from "../../util/workspace" import { EmptyState } from "../EmptyState/EmptyState" -import { TableHeaderRow } from "../TableHeaders/TableHeaders" import { TableLoader } from "../TableLoader/TableLoader" +dayjs.extend(relativeTime) +dayjs.extend(duration) + export const Language = { pageTitle: "Builds", usersTitle: "All users", @@ -18,36 +26,61 @@ export const Language = { suspendMenuItem: "Suspend", resetPasswordMenuItem: "Reset password", rolesLabel: "Roles", + inProgressLabel: "In progress", + actionLabel: "Action", + durationLabel: "Duration", + startedAtLabel: "Started at", + statusLabel: "Status", } export interface BuildsTableProps { builds?: TypesGen.WorkspaceBuild[] + className?: string } -export const BuildsTable: React.FC = ({ builds }) => { +export const BuildsTable: React.FC = ({ builds, className }) => { const isLoading = !builds + const theme: Theme = useTheme() return ( - +
- - Action - Duration - Started at - Status - + + {Language.actionLabel} + {Language.durationLabel} + {Language.startedAtLabel} + {Language.statusLabel} + {isLoading && } {builds && - builds.map((b) => ( - - {b.transition} - {b.created_at} - {b.created_at} - {b.job.status} - - ))} + builds.map((b) => { + const status = getDisplayStatus(theme, b) + + let displayDuration = Language.inProgressLabel + if (b.job.started_at && b.job.completed_at) { + const startedAt = dayjs(b.job.started_at) + const completedAt = dayjs(b.job.completed_at) + const diff = completedAt.diff(startedAt, "seconds") + displayDuration = `${diff} seconds` + } + + return ( + + {b.transition} + + {displayDuration} + + + {dayjs().to(dayjs(b.created_at))} + + + {status.status} + + + ) + })} {builds && builds.length === 0 && ( diff --git a/site/src/components/Workspace/Workspace.tsx b/site/src/components/Workspace/Workspace.tsx index 10b41a902c643..5f94b749463ce 100644 --- a/site/src/components/Workspace/Workspace.tsx +++ b/site/src/components/Workspace/Workspace.tsx @@ -59,8 +59,8 @@ export const Workspace: React.FC = ({
- - + +
@@ -103,5 +103,11 @@ export const useStyles = makeStyles(() => { timelineContainer: { flex: 1, }, + timelineContents: { + margin: 0, + }, + timelineTable: { + border: 0, + }, } }) diff --git a/site/src/components/WorkspaceSection/WorkspaceSection.tsx b/site/src/components/WorkspaceSection/WorkspaceSection.tsx index bcdb90c03463c..73dac822eb8d6 100644 --- a/site/src/components/WorkspaceSection/WorkspaceSection.tsx +++ b/site/src/components/WorkspaceSection/WorkspaceSection.tsx @@ -1,14 +1,16 @@ 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 React, { HTMLProps } from "react" import { CardPadding, CardRadius } from "../../theme/constants" +import { combineClasses } from "../../util/combineClasses" export interface WorkspaceSectionProps { title?: string + contentsProps?: HTMLProps } -export const WorkspaceSection: React.FC = ({ title, children }) => { +export const WorkspaceSection: React.FC = ({ title, children, contentsProps }) => { const styles = useStyles() return ( @@ -21,7 +23,9 @@ export const WorkspaceSection: React.FC = ({ title, child )} -
{children}
+
+ {children} +
) } diff --git a/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx b/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx index 355d709c7a7b0..e71ff2b69def7 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx @@ -14,11 +14,10 @@ import relativeTime from "dayjs/plugin/relativeTime" import React from "react" import { Link as RouterLink } from "react-router-dom" import * as TypesGen from "../../api/typesGenerated" -import { WorkspaceBuild } from "../../api/typesGenerated" import { Margins } from "../../components/Margins/Margins" import { Stack } from "../../components/Stack/Stack" import { firstLetter } from "../../util/firstLetter" -import { getWorkspaceStatus } from "../../util/workspace" +import { getDisplayStatus } from "../../util/workspace" dayjs.extend(relativeTime) @@ -68,7 +67,7 @@ export const WorkspacesPageView: React.FC = (props) =>
)} {props.workspaces?.map((workspace) => { - const status = getStatus(theme, workspace.latest_build) + const status = getDisplayStatus(theme, workspace.latest_build) return ( @@ -108,74 +107,6 @@ export const WorkspacesPageView: React.FC = (props) => ) } -const getStatus = ( - theme: Theme, - build: WorkspaceBuild, -): { - color: string - status: string -} => { - const status = getWorkspaceStatus(build) - switch (status) { - case undefined: - return { - color: theme.palette.text.secondary, - status: "Loading...", - } - case "started": - return { - color: theme.palette.success.main, - status: "⦿ Running", - } - case "starting": - return { - color: theme.palette.success.main, - status: "⦿ Starting", - } - case "stopping": - return { - color: theme.palette.text.secondary, - status: "◍ Stopping", - } - case "stopped": - return { - color: theme.palette.text.secondary, - status: "◍ Stopped", - } - case "deleting": - return { - color: theme.palette.text.secondary, - status: "⦸ Deleting", - } - case "deleted": - return { - color: theme.palette.text.secondary, - status: "⦸ Deleted", - } - case "canceling": - return { - color: theme.palette.warning.light, - status: "◍ Canceling", - } - case "canceled": - return { - color: theme.palette.text.secondary, - status: "◍ Canceled", - } - case "error": - return { - color: theme.palette.error.main, - status: "ⓧ Failed", - } - case "queued": - return { - color: theme.palette.text.secondary, - status: "◍ Queued", - } - } - throw new Error("unknown status " + status) -} - const useStyles = makeStyles((theme) => ({ actions: { marginTop: theme.spacing(3), diff --git a/site/src/util/workspace.ts b/site/src/util/workspace.ts index f4b844cdd3665..1c36ce958309a 100644 --- a/site/src/util/workspace.ts +++ b/site/src/util/workspace.ts @@ -1,3 +1,4 @@ +import { Theme } from "@material-ui/core/styles" import { WorkspaceBuildTransition } from "../api/types" import { WorkspaceBuild } from "../api/typesGenerated" @@ -47,3 +48,71 @@ export const getWorkspaceStatus = (workspaceBuild?: WorkspaceBuild): WorkspaceSt return "error" } } + +export const getDisplayStatus = ( + theme: Theme, + build: WorkspaceBuild, +): { + color: string + status: string +} => { + const status = getWorkspaceStatus(build) + switch (status) { + case undefined: + return { + color: theme.palette.text.secondary, + status: "Loading...", + } + case "started": + return { + color: theme.palette.success.main, + status: "⦿ Running", + } + case "starting": + return { + color: theme.palette.success.main, + status: "⦿ Starting", + } + case "stopping": + return { + color: theme.palette.text.secondary, + status: "◍ Stopping", + } + case "stopped": + return { + color: theme.palette.text.secondary, + status: "◍ Stopped", + } + case "deleting": + return { + color: theme.palette.text.secondary, + status: "⦸ Deleting", + } + case "deleted": + return { + color: theme.palette.text.secondary, + status: "⦸ Deleted", + } + case "canceling": + return { + color: theme.palette.warning.light, + status: "◍ Canceling", + } + case "canceled": + return { + color: theme.palette.text.secondary, + status: "◍ Canceled", + } + case "error": + return { + color: theme.palette.error.main, + status: "ⓧ Failed", + } + case "queued": + return { + color: theme.palette.text.secondary, + status: "◍ Queued", + } + } + throw new Error("unknown status " + status) +} From f9c27e0983d317adb562fe09a17deb5b15d6dd22 Mon Sep 17 00:00:00 2001 From: Bruno Date: Tue, 17 May 2022 19:45:49 +0000 Subject: [PATCH 03/10] Refresh timeline when the build is updated --- .../xServices/workspace/workspaceXService.ts | 39 ++++++++++++++++--- 1 file changed, 33 insertions(+), 6 deletions(-) diff --git a/site/src/xServices/workspace/workspaceXService.ts b/site/src/xServices/workspace/workspaceXService.ts index c00c0cb9adfbb..782d4f847a459 100644 --- a/site/src/xServices/workspace/workspaceXService.ts +++ b/site/src/xServices/workspace/workspaceXService.ts @@ -1,8 +1,16 @@ -import { assign, createMachine } from "xstate" +import { assign, createMachine, send } from "xstate" +import { pure } from "xstate/lib/actions" import * as API from "../../api/api" import * as TypesGen from "../../api/typesGenerated" import { displayError } from "../../components/GlobalSnackbar/utils" +const latestBuild = (builds: TypesGen.WorkspaceBuild[]) => { + // Cloning builds to not change the origin object with the sort() + return [...builds].sort((a, b) => { + return new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime() + })[0] +} + const Language = { refreshTemplateError: "Error updating workspace: latest template could not be fetched.", buildError: "Workspace action failed.", @@ -34,6 +42,7 @@ export type WorkspaceEvent = | { type: "RETRY" } | { type: "UPDATE" } | { type: "LOAD_MORE_BUILDS" } + | { type: "REFRESH_TIMELINE" } export const workspaceMachine = createMachine( { @@ -105,7 +114,7 @@ export const workspaceMachine = createMachine( invoke: { id: "refreshWorkspace", src: "refreshWorkspace", - onDone: { target: "waiting", actions: "assignWorkspace" }, + onDone: { target: "waiting", actions: ["refreshTimeline", "assignWorkspace"] }, onError: { target: "waiting", actions: "assignRefreshWorkspaceError" }, }, }, @@ -171,7 +180,7 @@ export const workspaceMachine = createMachine( src: "startWorkspace", onDone: { target: "idle", - actions: "assignBuild", + actions: ["assignBuild", "refreshTimeline"], }, onError: { target: "idle", @@ -186,7 +195,7 @@ export const workspaceMachine = createMachine( src: "stopWorkspace", onDone: { target: "idle", - actions: "assignBuild", + actions: ["assignBuild", "refreshTimeline"], }, onError: { target: "idle", @@ -212,7 +221,7 @@ export const workspaceMachine = createMachine( }, }, - builds: { + timeline: { initial: "gettingBuilds", states: { idle: {}, @@ -239,6 +248,7 @@ export const workspaceMachine = createMachine( target: "loadingMoreBuilds", cond: "hasMoreBuilds", }, + REFRESH_TIMELINE: "#workspaceState.ready.timeline.gettingBuilds", }, }, loadingMoreBuilds: { @@ -333,7 +343,7 @@ export const workspaceMachine = createMachine( assign({ refreshTemplateError: undefined, }), - // Builds + // Timeline assignBuilds: assign({ builds: (_, event) => event.data, }), @@ -360,6 +370,23 @@ export const workspaceMachine = createMachine( clearLoadMoreBuildsError: assign({ loadMoreBuildsError: (_) => undefined, }), + refreshTimeline: pure((context, event) => { + // No need to refresh the timeline if it is not loaded + if (!context.builds) { + return + } + // When it is a refresh workspace event, we want to check if the latest + // build was updated to not over fetch the builds + if (event.type === "done.invoke.refreshWorkspace") { + const latestBuildInTimeline = latestBuild(context.builds) + const isUpdated = event.data?.latest_build.updated_at !== latestBuildInTimeline.updated_at + if (isUpdated) { + return send({ type: "REFRESH_TIMELINE" }) + } + } else { + return send({ type: "REFRESH_TIMELINE" }) + } + }), }, guards: { triedToStart: (context) => context.workspace?.latest_build.transition === "start", From db88444681f0a17fe38ab7d2906c058c03d2720e Mon Sep 17 00:00:00 2001 From: Bruno Date: Tue, 17 May 2022 19:48:39 +0000 Subject: [PATCH 04/10] Update started at format --- site/src/components/BuildsTable/BuildsTable.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/components/BuildsTable/BuildsTable.tsx b/site/src/components/BuildsTable/BuildsTable.tsx index 5dfc04f057404..75ff0d7f77b60 100644 --- a/site/src/components/BuildsTable/BuildsTable.tsx +++ b/site/src/components/BuildsTable/BuildsTable.tsx @@ -73,7 +73,7 @@ export const BuildsTable: React.FC = ({ builds, className }) = {displayDuration} - {dayjs().to(dayjs(b.created_at))} + {new Date(b.created_at).toLocaleString()} {status.status} From c7662288c80e9622871b2ddc4e52b9552f9ba60f Mon Sep 17 00:00:00 2001 From: Bruno Date: Tue, 17 May 2022 19:55:52 +0000 Subject: [PATCH 05/10] Make columns fixed --- site/src/components/BuildsTable/BuildsTable.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/site/src/components/BuildsTable/BuildsTable.tsx b/site/src/components/BuildsTable/BuildsTable.tsx index 75ff0d7f77b60..88041821bd78f 100644 --- a/site/src/components/BuildsTable/BuildsTable.tsx +++ b/site/src/components/BuildsTable/BuildsTable.tsx @@ -46,10 +46,10 @@ export const BuildsTable: React.FC = ({ builds, className }) =
- {Language.actionLabel} - {Language.durationLabel} - {Language.startedAtLabel} - {Language.statusLabel} + {Language.actionLabel} + {Language.durationLabel} + {Language.startedAtLabel} + {Language.statusLabel} From 92a7b018736ab2ac643ae2c3f4805bb9d3e047fc Mon Sep 17 00:00:00 2001 From: Bruno Date: Tue, 17 May 2022 20:12:48 +0000 Subject: [PATCH 06/10] Refactor duration logic --- .../components/BuildsTable/BuildsTable.tsx | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/site/src/components/BuildsTable/BuildsTable.tsx b/site/src/components/BuildsTable/BuildsTable.tsx index 88041821bd78f..bf1dc7419336f 100644 --- a/site/src/components/BuildsTable/BuildsTable.tsx +++ b/site/src/components/BuildsTable/BuildsTable.tsx @@ -33,6 +33,19 @@ export const Language = { statusLabel: "Status", } +const getDurationInSeconds = (build: TypesGen.WorkspaceBuild) => { + let display = Language.inProgressLabel + + if (build.job.started_at && build.job.completed_at) { + const startedAt = dayjs(build.job.started_at) + const completedAt = dayjs(build.job.completed_at) + const diff = completedAt.diff(startedAt, "seconds") + display = `${diff} seconds` + } + + return display +} + export interface BuildsTableProps { builds?: TypesGen.WorkspaceBuild[] className?: string @@ -57,20 +70,13 @@ export const BuildsTable: React.FC = ({ builds, className }) = {builds && builds.map((b) => { const status = getDisplayStatus(theme, b) - - let displayDuration = Language.inProgressLabel - if (b.job.started_at && b.job.completed_at) { - const startedAt = dayjs(b.job.started_at) - const completedAt = dayjs(b.job.completed_at) - const diff = completedAt.diff(startedAt, "seconds") - displayDuration = `${diff} seconds` - } + const duration = getDurationInSeconds(b) return ( {b.transition} - {displayDuration} + {duration} {new Date(b.created_at).toLocaleString()} From 5876cf96ec3741d654f267d1340a287eabb379bd Mon Sep 17 00:00:00 2001 From: Bruno Date: Tue, 17 May 2022 20:31:09 +0000 Subject: [PATCH 07/10] Fix tests --- site/src/testHelpers/handlers.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/site/src/testHelpers/handlers.ts b/site/src/testHelpers/handlers.ts index 1f65874616dc1..27e8875d1fe52 100644 --- a/site/src/testHelpers/handlers.ts +++ b/site/src/testHelpers/handlers.ts @@ -98,6 +98,9 @@ export const handlers = [ const result = transitionToBuild[transition as WorkspaceBuildTransition] return res(ctx.status(200), ctx.json(result)) }), + rest.get("/api/v2/workspaces/:workspaceId/builds", async (req, res, ctx) => { + return res(ctx.status(200), ctx.json([M.MockWorkspaceBuild, M.MockWorkspaceBuildStop, M.MockWorkspaceBuildDelete])) + }), // workspace builds rest.get("/api/v2/workspacebuilds/:workspaceBuildId/resources", (req, res, ctx) => { From 929173054213ef2bccb38278faef90fcc1f38601 Mon Sep 17 00:00:00 2001 From: Bruno Date: Tue, 17 May 2022 22:33:27 +0000 Subject: [PATCH 08/10] Fix tests --- .../components/BuildsTable/BuildsTable.tsx | 2 +- .../WorkspacePage/WorkspacePage.test.tsx | 155 +++++++++++------- site/src/testHelpers/entities.ts | 8 +- site/src/testHelpers/handlers.ts | 2 +- 4 files changed, 102 insertions(+), 65 deletions(-) diff --git a/site/src/components/BuildsTable/BuildsTable.tsx b/site/src/components/BuildsTable/BuildsTable.tsx index bf1dc7419336f..2ddc0b8258194 100644 --- a/site/src/components/BuildsTable/BuildsTable.tsx +++ b/site/src/components/BuildsTable/BuildsTable.tsx @@ -73,7 +73,7 @@ export const BuildsTable: React.FC = ({ builds, className }) = const duration = getDurationInSeconds(b) return ( - + {b.transition} {duration} diff --git a/site/src/pages/WorkspacePage/WorkspacePage.test.tsx b/site/src/pages/WorkspacePage/WorkspacePage.test.tsx index 2686316362320..7b827f42f4e61 100644 --- a/site/src/pages/WorkspacePage/WorkspacePage.test.tsx +++ b/site/src/pages/WorkspacePage/WorkspacePage.test.tsx @@ -1,9 +1,8 @@ -/* eslint-disable @typescript-eslint/no-floating-promises */ -import { screen } from "@testing-library/react" +import { fireEvent, screen, waitFor } 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 { Workspace } from "../../api/typesGenerated" import { Language } from "../../components/WorkspaceStatusBar/WorkspaceStatusBar" import { MockCancelingWorkspace, @@ -22,6 +21,12 @@ import { import { server } from "../../testHelpers/server" import { WorkspacePage } from "./WorkspacePage" +// It renders the workspace page and waits for it be loaded +const renderWorkspacePage = async () => { + renderWithAuth(, { route: `/workspaces/${MockWorkspace.id}`, path: "/workspaces/:workspace" }) + await screen.findByText(MockWorkspace.name) +} + /** * Requests and responses related to workspace status are unrelated, so we can't test in the usual way. * Instead, test that button clicks produce the correct requests and that responses produce the correct UI. @@ -29,16 +34,11 @@ import { WorkspacePage } from "./WorkspacePage" * 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 testButton = async (label: string, actionMock: jest.SpyInstance) => { + await renderWorkspacePage() const button = await screen.findByText(label) - button.click() - expect(mock).toHaveBeenCalled() + await waitFor(() => fireEvent.click(button)) + expect(actionMock).toBeCalled() } const testStatus = async (mock: Workspace, label: string) => { @@ -47,82 +47,115 @@ const testStatus = async (mock: Workspace, label: string) => { return res(ctx.status(200), ctx.json(mock)) }), ) - renderWithAuth(, { route: `/workspaces/${MockWorkspace.id}`, path: "/workspaces/:workspace" }) + await renderWorkspacePage() const status = await screen.findByRole("status") expect(status).toHaveTextContent(label) } +beforeEach(() => { + jest.resetAllMocks() +}) + describe("Workspace Page", () => { it("shows a workspace", async () => { - renderWithAuth(, { route: `/workspaces/${MockWorkspace.id}`, path: "/workspaces/:workspace" }) - const workspaceName = await screen.findByText(MockWorkspace.name) + await renderWorkspacePage() + const workspaceName = screen.getByText(MockWorkspace.name) expect(workspaceName).toBeDefined() }) it("shows the status of the workspace", async () => { - renderWithAuth(, { route: `/workspaces/${MockWorkspace.id}`, path: "/workspaces/:workspace" }) - const status = await screen.findByRole("status") + await renderWorkspacePage() + const status = screen.getByRole("status") expect(status).toHaveTextContent("Running") }) it("requests a stop job when the user presses Stop", async () => { + const stopWorkspaceMock = jest.spyOn(api, "stopWorkspace").mockResolvedValueOnce(MockWorkspaceBuild) + await testButton(Language.stop, stopWorkspaceMock) + }) + it("requests a start job 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)) + }), + ) + const startWorkspaceMock = jest + .spyOn(api, "startWorkspace") + .mockImplementation(() => Promise.resolve(MockWorkspaceBuild)) + await testButton(Language.start, startWorkspaceMock) + }) + it("requests a start job when the user presses Retry after trying to start", async () => { + // Use a workspace that failed during start + server.use( + rest.get(`/api/v2/workspaces/${MockWorkspace.id}`, (req, res, ctx) => { + return res( + ctx.status(200), + ctx.json({ + ...MockFailedWorkspace, + latest_build: { + ...MockFailedWorkspace.latest_build, + transition: "start", + }, + }), + ) + }), + ) + const startWorkSpaceMock = jest.spyOn(api, "startWorkspace").mockResolvedValueOnce(MockWorkspaceBuild) + await testButton(Language.retry, startWorkSpaceMock) + }) + it("requests a stop job when the user presses Retry after trying to stop", async () => { + // Use a workspace that failed during stop + server.use( + rest.get(`/api/v2/workspaces/${MockWorkspace.id}`, (req, res, ctx) => { + return res( + ctx.status(200), + ctx.json({ + ...MockFailedWorkspace, + latest_build: { + ...MockFailedWorkspace.latest_build, + transition: "stop", + }, + }), + ) + }), + ) 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) - }) + await testButton(Language.retry, stopWorkspaceMock) + }) + it("requests a template when the user presses Update", async () => { + const getTemplateMock = jest.spyOn(api, "getTemplate").mockResolvedValueOnce(MockTemplate) + server.use( + rest.get(`/api/v2/workspaces/${MockWorkspace.id}`, (req, res, ctx) => { + return res(ctx.status(200), ctx.json(MockOutdatedWorkspace)) + }), + ) + await testButton(Language.update, getTemplateMock) + }) + it("shows the Stopping status when the workspace is stopping", async () => { + await testStatus(MockStoppingWorkspace, Language.stopping) + }) it("shows the Stopped status when the workspace is stopped", async () => { - testStatus(MockStoppedWorkspace, Language.stopped) + await testStatus(MockStoppedWorkspace, Language.stopped) }) it("shows the Building status when the workspace is starting", async () => { - testStatus(MockStartingWorkspace, Language.starting) + await testStatus(MockStartingWorkspace, Language.starting) }) it("shows the Running status when the workspace is started", async () => { - testStatus(MockWorkspace, Language.started) + await testStatus(MockWorkspace, Language.started) }) it("shows the Error status when the workspace is failed or canceled", async () => { - testStatus(MockFailedWorkspace, Language.error) + await testStatus(MockFailedWorkspace, Language.error) }) it("shows the Loading status when the workspace is canceling", async () => { - testStatus(MockCancelingWorkspace, Language.canceling) + await testStatus(MockCancelingWorkspace, Language.canceling) }) it("shows the Deleting status when the workspace is deleting", async () => { - testStatus(MockDeletingWorkspace, Language.canceling) + await testStatus(MockDeletingWorkspace, Language.deleting) }) it("shows the Deleted status when the workspace is deleted", async () => { - testStatus(MockDeletedWorkspace, Language.canceling) + await testStatus(MockDeletedWorkspace, Language.deleted) + }) + it("shows the timeline build", async () => { + renderWithAuth(, { route: `/workspaces/${MockWorkspace.id}`, path: "/workspaces/:workspace" }) }) }) diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 5954c69a8de4f..d4ed7a0e91136 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -113,26 +113,30 @@ export const MockWorkspaceBuild: TypesGen.WorkspaceBuild = { after_id: "", before_id: "", created_at: new Date().toString(), - id: "test-workspace-build", + id: "1", initiator_id: "", job: MockProvisionerJob, name: "a-workspace-build", template_version_id: "", transition: "start", - updated_at: "", + updated_at: "2022-05-17T17:39:01.382927298Z", workspace_id: "test-workspace", } export const MockWorkspaceBuildStop = { ...MockWorkspaceBuild, + id: "2", transition: "stop", } export const MockWorkspaceBuildDelete = { ...MockWorkspaceBuild, + id: "3", transition: "delete", } +export const MockBuilds = [MockWorkspaceBuild, MockWorkspaceBuildStop, MockWorkspaceBuildDelete] + export const MockWorkspace: TypesGen.Workspace = { id: "test-workspace", name: "Test-Workspace", diff --git a/site/src/testHelpers/handlers.ts b/site/src/testHelpers/handlers.ts index 27e8875d1fe52..ee5f2eb5c9de1 100644 --- a/site/src/testHelpers/handlers.ts +++ b/site/src/testHelpers/handlers.ts @@ -99,7 +99,7 @@ export const handlers = [ return res(ctx.status(200), ctx.json(result)) }), rest.get("/api/v2/workspaces/:workspaceId/builds", async (req, res, ctx) => { - return res(ctx.status(200), ctx.json([M.MockWorkspaceBuild, M.MockWorkspaceBuildStop, M.MockWorkspaceBuildDelete])) + return res(ctx.status(200), ctx.json(M.MockBuilds)) }), // workspace builds From 281228a77a6bfd830bf2b40aecc86d4b2bfd0fbd Mon Sep 17 00:00:00 2001 From: Bruno Date: Tue, 17 May 2022 22:35:26 +0000 Subject: [PATCH 09/10] Add basic test to see if the timeline is rendered --- site/src/pages/WorkspacePage/WorkspacePage.test.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/site/src/pages/WorkspacePage/WorkspacePage.test.tsx b/site/src/pages/WorkspacePage/WorkspacePage.test.tsx index 7b827f42f4e61..ad23e0cd4ed9c 100644 --- a/site/src/pages/WorkspacePage/WorkspacePage.test.tsx +++ b/site/src/pages/WorkspacePage/WorkspacePage.test.tsx @@ -5,6 +5,7 @@ import * as api from "../../api/api" import { Workspace } from "../../api/typesGenerated" import { Language } from "../../components/WorkspaceStatusBar/WorkspaceStatusBar" import { + MockBuilds, MockCancelingWorkspace, MockDeletedWorkspace, MockDeletingWorkspace, @@ -156,6 +157,9 @@ describe("Workspace Page", () => { await testStatus(MockDeletedWorkspace, Language.deleted) }) it("shows the timeline build", async () => { - renderWithAuth(, { route: `/workspaces/${MockWorkspace.id}`, path: "/workspaces/:workspace" }) + await renderWorkspacePage() + const table = await screen.findByRole("table") + const rows = table.querySelectorAll("tbody > tr") + expect(rows).toHaveLength(MockBuilds.length) }) }) From 2cf0e97456d13cad3481f910bae2284883a15614 Mon Sep 17 00:00:00 2001 From: Bruno Date: Tue, 17 May 2022 22:39:14 +0000 Subject: [PATCH 10/10] Add storybook --- .../BuildsTable/BuildsTable.stories.tsx | 21 +++++++++++++++++++ .../components/BuildsTable/BuildsTable.tsx | 10 ++------- 2 files changed, 23 insertions(+), 8 deletions(-) create mode 100644 site/src/components/BuildsTable/BuildsTable.stories.tsx diff --git a/site/src/components/BuildsTable/BuildsTable.stories.tsx b/site/src/components/BuildsTable/BuildsTable.stories.tsx new file mode 100644 index 0000000000000..4626b8723cd87 --- /dev/null +++ b/site/src/components/BuildsTable/BuildsTable.stories.tsx @@ -0,0 +1,21 @@ +import { ComponentMeta, Story } from "@storybook/react" +import React from "react" +import { MockBuilds } from "../../testHelpers/entities" +import { BuildsTable, BuildsTableProps } from "./BuildsTable" + +export default { + title: "components/BuildsTable", + component: BuildsTable, +} as ComponentMeta + +const Template: Story = (args) => + +export const Example = Template.bind({}) +Example.args = { + builds: MockBuilds, +} + +export const Empty = Template.bind({}) +Empty.args = { + builds: [], +} diff --git a/site/src/components/BuildsTable/BuildsTable.tsx b/site/src/components/BuildsTable/BuildsTable.tsx index 2ddc0b8258194..3e26910894e75 100644 --- a/site/src/components/BuildsTable/BuildsTable.tsx +++ b/site/src/components/BuildsTable/BuildsTable.tsx @@ -19,13 +19,7 @@ dayjs.extend(relativeTime) dayjs.extend(duration) export const Language = { - pageTitle: "Builds", - usersTitle: "All users", - emptyMessage: "No users found", - usernameLabel: "User", - suspendMenuItem: "Suspend", - resetPasswordMenuItem: "Reset password", - rolesLabel: "Roles", + emptyMessage: "No builds found", inProgressLabel: "In progress", actionLabel: "Action", durationLabel: "Duration", @@ -92,7 +86,7 @@ export const BuildsTable: React.FC = ({ builds, className }) = - +