Thanks to visit codestin.com
Credit goes to github.com

Skip to content

feat: ui autostop extension #1987

New issue

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

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

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Jun 2, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions site/src/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,3 +270,10 @@ export const getWorkspaceBuildLogs = async (buildname: string): Promise<TypesGen
const response = await axios.get<TypesGen.ProvisionerJobLog[]>(`/api/v2/workspacebuilds/${buildname}/logs`)
return response.data
}

export const putWorkspaceExtension = async (
workspaceId: string,
extendWorkspaceRequest: TypesGen.PutExtendWorkspaceRequest,
): Promise<void> => {
await axios.put(`/api/v2/workspaces/${workspaceId}/extend`, extendWorkspaceRequest)
}
4 changes: 4 additions & 0 deletions site/src/components/Workspace/Workspace.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ const Template: Story<WorkspaceProps> = (args) => <Workspace {...args} />

export const Started = Template.bind({})
Started.args = {
bannerProps: {
isLoading: false,
onExtend: action("extend"),
},
workspace: Mocks.MockWorkspace,
handleStart: action("start"),
handleStop: action("stop"),
Expand Down
15 changes: 14 additions & 1 deletion site/src/components/Workspace/Workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ import { WorkspaceSection } from "../WorkspaceSection/WorkspaceSection"
import { WorkspaceStats } from "../WorkspaceStats/WorkspaceStats"

export interface WorkspaceProps {
bannerProps: {
isLoading?: boolean
onExtend: () => void
}
handleStart: () => void
handleStop: () => void
handleDelete: () => void
Expand All @@ -28,6 +32,7 @@ export interface WorkspaceProps {
* Workspace is the top-level component for viewing an individual workspace
*/
export const Workspace: FC<WorkspaceProps> = ({
bannerProps,
handleStart,
handleStop,
handleDelete,
Expand All @@ -54,6 +59,7 @@ export const Workspace: FC<WorkspaceProps> = ({
{workspace.owner_name}
</Typography>
</div>

<WorkspaceActions
workspace={workspace}
handleStart={handleStart}
Expand All @@ -70,9 +76,16 @@ export const Workspace: FC<WorkspaceProps> = ({

<Stack direction="row" spacing={3}>
<Stack direction="column" className={styles.firstColumnSpacer} spacing={3}>
<WorkspaceScheduleBanner workspace={workspace} />
<WorkspaceScheduleBanner
isLoading={bannerProps.isLoading}
onExtend={bannerProps.onExtend}
workspace={workspace}
/>

<WorkspaceStats workspace={workspace} />

<Resources resources={resources} getResourcesError={getResourcesError} workspace={workspace} />

<WorkspaceSection title="Timeline" contentsProps={{ className: styles.timelineContents }}>
<BuildsTable builds={builds} className={styles.timelineTable} />
</WorkspaceSection>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { action } from "@storybook/addon-actions"
import { Story } from "@storybook/react"
import dayjs from "dayjs"
import utc from "dayjs/plugin/utc"
Expand All @@ -15,8 +16,11 @@ const Template: Story<WorkspaceScheduleBannerProps> = (args) => <WorkspaceSchedu

export const Example = Template.bind({})
Example.args = {
isLoading: false,
onExtend: action("extend"),
workspace: {
...Mocks.MockWorkspace,

latest_build: {
...Mocks.MockWorkspaceBuild,
deadline: dayjs().utc().format(),
Expand All @@ -26,6 +30,13 @@ Example.args = {
},
transition: "start",
},
ttl: 2 * 60 * 60 * 1000 * 1_000_000, // 2 hours

ttl_ms: 2 * 60 * 60 * 1000, // 2 hours
},
}

export const Loading = Template.bind({})
Loading.args = {
...Example.args,
isLoading: true,
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import Button from "@material-ui/core/Button"
import Alert from "@material-ui/lab/Alert"
import AlertTitle from "@material-ui/lab/AlertTitle"
import dayjs from "dayjs"
Expand All @@ -11,10 +12,13 @@ dayjs.extend(utc)
dayjs.extend(isSameOrBefore)

export const Language = {
bannerAction: "Extend",
bannerTitle: "Your workspace is scheduled to automatically shut down soon.",
}

export interface WorkspaceScheduleBannerProps {
isLoading?: boolean
onExtend: () => void
workspace: TypesGen.Workspace
}

Expand All @@ -31,12 +35,19 @@ export const shouldDisplay = (workspace: TypesGen.Workspace): boolean => {
}
}

export const WorkspaceScheduleBanner: FC<WorkspaceScheduleBannerProps> = ({ workspace }) => {
export const WorkspaceScheduleBanner: FC<WorkspaceScheduleBannerProps> = ({ isLoading, onExtend, workspace }) => {
if (!shouldDisplay(workspace)) {
return null
} else {
return (
<Alert severity="warning">
<Alert
action={
<Button color="inherit" disabled={isLoading} onClick={onExtend} size="small">
{Language.bannerAction}
</Button>
}
severity="warning"
>
<AlertTitle>{Language.bannerTitle}</AlertTitle>
</Alert>
)
Expand Down
9 changes: 9 additions & 0 deletions site/src/pages/WorkspacePage/WorkspacePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { Stack } from "../../components/Stack/Stack"
import { Workspace } from "../../components/Workspace/Workspace"
import { firstOrItem } from "../../util/array"
import { workspaceMachine } from "../../xServices/workspace/workspaceXService"
import { workspaceScheduleBannerMachine } from "../../xServices/workspaceSchedule/workspaceScheduleBannerXService"

export const WorkspacePage: React.FC = () => {
const { workspace: workspaceQueryParam } = useParams()
Expand All @@ -18,6 +19,8 @@ export const WorkspacePage: React.FC = () => {
const [workspaceState, workspaceSend] = useMachine(workspaceMachine)
const { workspace, resources, getWorkspaceError, getResourcesError, builds } = workspaceState.context

const [bannerState, bannerSend] = useMachine(workspaceScheduleBannerMachine)

/**
* Get workspace, template, and organization on mount and whenever workspaceId changes.
* workspaceSend should not change.
Expand All @@ -36,6 +39,12 @@ export const WorkspacePage: React.FC = () => {
<Stack spacing={4}>
<>
<Workspace
bannerProps={{
isLoading: bannerState.hasTag("loading"),
onExtend: () => {
bannerSend({ type: "EXTEND_DEADLINE_DEFAULT", workspaceId: workspace.id })
},
}}
workspace={workspace}
handleStart={() => workspaceSend("START")}
handleStop={() => workspaceSend("STOP")}
Expand Down
7 changes: 5 additions & 2 deletions site/src/testHelpers/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,11 @@ export const handlers = [
rest.put("/api/v2/workspaces/:workspaceId/ttl", async (req, res, ctx) => {
return res(ctx.status(200))
}),
rest.put("/api/v2/workspaces/:workspaceId/extend", async (req, res, ctx) => {
return res(ctx.status(200))
}),

// workspace builds
rest.post("/api/v2/workspaces/:workspaceId/builds", async (req, res, ctx) => {
const { transition } = req.body as CreateWorkspaceBuildRequest
const transitionToBuild = {
Expand All @@ -122,8 +127,6 @@ export const handlers = [
rest.get("/api/v2/workspaces/:workspaceId/builds", async (req, res, ctx) => {
return res(ctx.status(200), ctx.json(M.MockBuilds))
}),

// workspace builds
rest.get("/api/v2/workspacebuilds/:workspaceBuildId", (req, res, ctx) => {
return res(ctx.status(200), ctx.json(M.MockWorkspaceBuild))
}),
Expand Down
25 changes: 24 additions & 1 deletion site/src/util/workspace.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import dayjs from "dayjs"
import * as TypesGen from "../api/typesGenerated"
import * as Mocks from "../testHelpers/entities"
import { isWorkspaceOn } from "./workspace"
import { defaultWorkspaceExtension, isWorkspaceOn } from "./workspace"

describe("util > workspace", () => {
describe("isWorkspaceOn", () => {
Expand Down Expand Up @@ -40,4 +41,26 @@ describe("util > workspace", () => {
expect(isWorkspaceOn(workspace)).toBe(isOn)
})
})

describe("defaultWorkspaceExtension", () => {
it.each<[string, TypesGen.PutExtendWorkspaceRequest]>([
[
"2022-06-02T14:56:34Z",
{
deadline: "2022-06-02T16:26:34Z",
},
],

// This case is the same as above, but in a different timezone to prove
// that UTC conversion for deadline works as expected
[
"2022-06-02T10:56:20-04:00",
{
deadline: "2022-06-02T16:26:20Z",
},
],
])(`defaultWorkspaceExtension(%p) returns %p`, (startTime, request) => {
expect(defaultWorkspaceExtension(dayjs(startTime))).toEqual(request)
})
})
})
29 changes: 22 additions & 7 deletions site/src/util/workspace.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { Theme } from "@material-ui/core/styles"
import dayjs from "dayjs"
import utc from "dayjs/plugin/utc"
import { WorkspaceBuildTransition } from "../api/types"
import { Workspace, WorkspaceAgent, WorkspaceBuild } from "../api/typesGenerated"
import * as TypesGen from "../api/typesGenerated"

dayjs.extend(utc)

export type WorkspaceStatus =
| "queued"
Expand Down Expand Up @@ -29,7 +32,7 @@ const succeededToStatus: Record<WorkspaceBuildTransition, WorkspaceStatus> = {
}

// Converts a workspaces status to a human-readable form.
export const getWorkspaceStatus = (workspaceBuild?: WorkspaceBuild): WorkspaceStatus => {
export const getWorkspaceStatus = (workspaceBuild?: TypesGen.WorkspaceBuild): WorkspaceStatus => {
const transition = workspaceBuild?.transition as WorkspaceBuildTransition
const jobStatus = workspaceBuild?.job.status
switch (jobStatus) {
Expand Down Expand Up @@ -66,7 +69,7 @@ export const DisplayStatusLanguage = {

export const getDisplayStatus = (
theme: Theme,
build: WorkspaceBuild,
build: TypesGen.WorkspaceBuild,
): {
color: string
status: string
Expand Down Expand Up @@ -132,7 +135,7 @@ export const getDisplayStatus = (
throw new Error("unknown status " + status)
}

export const getWorkspaceBuildDurationInSeconds = (build: WorkspaceBuild): number | undefined => {
export const getWorkspaceBuildDurationInSeconds = (build: TypesGen.WorkspaceBuild): number | undefined => {
const isCompleted = build.job.started_at && build.job.completed_at

if (!isCompleted) {
Expand All @@ -144,7 +147,10 @@ export const getWorkspaceBuildDurationInSeconds = (build: WorkspaceBuild): numbe
return completedAt.diff(startedAt, "seconds")
}

export const displayWorkspaceBuildDuration = (build: WorkspaceBuild, inProgressLabel = "In progress"): string => {
export const displayWorkspaceBuildDuration = (
build: TypesGen.WorkspaceBuild,
inProgressLabel = "In progress",
): string => {
const duration = getWorkspaceBuildDurationInSeconds(build)
return duration ? `${duration} seconds` : inProgressLabel
}
Expand All @@ -157,7 +163,7 @@ export const DisplayAgentStatusLanguage = {

export const getDisplayAgentStatus = (
theme: Theme,
agent: WorkspaceAgent,
agent: TypesGen.WorkspaceAgent,
): {
color: string
status: string
Expand Down Expand Up @@ -186,8 +192,17 @@ export const getDisplayAgentStatus = (
}
}

export const isWorkspaceOn = (workspace: Workspace): boolean => {
export const isWorkspaceOn = (workspace: TypesGen.Workspace): boolean => {
const transition = workspace.latest_build.transition
const status = workspace.latest_build.job.status
return transition === "start" && status === "succeeded"
}

export const defaultWorkspaceExtension = (__startDate?: dayjs.Dayjs): TypesGen.PutExtendWorkspaceRequest => {
const now = __startDate ? dayjs(__startDate) : dayjs()
const NinetyMinutesFromNow = now.add(90, "minutes").utc()

return {
deadline: NinetyMinutesFromNow.format(),
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/**
* @fileoverview workspaceScheduleBanner is an xstate machine backing a form,
* presented as an Alert/banner, for reactively extending a workspace schedule.
*/
import { createMachine } from "xstate"
import * as API from "../../api/api"
import { displayError, displaySuccess } from "../../components/GlobalSnackbar/utils"
import { defaultWorkspaceExtension } from "../../util/workspace"

export const Language = {
errorExtension: "Failed to extend workspace deadline.",
successExtension: "Successfully extended workspace deadline.",
}

export type WorkspaceScheduleBannerEvent = { type: "EXTEND_DEADLINE_DEFAULT"; workspaceId: string }

export const workspaceScheduleBannerMachine = createMachine(
{
tsTypes: {} as import("./workspaceScheduleBannerXService.typegen").Typegen0,
schema: {
events: {} as WorkspaceScheduleBannerEvent,
},
id: "workspaceScheduleBannerState",
initial: "idle",
states: {
idle: {
on: {
EXTEND_DEADLINE_DEFAULT: "extendingDeadline",
},
},
extendingDeadline: {
invoke: {
src: "extendDeadlineDefault",
id: "extendDeadlineDefault",
onDone: {
target: "idle",
actions: "displaySuccessMessage",
},
onError: {
target: "idle",
actions: "displayFailureMessage",
},
},
tags: "loading",
},
},
},
{
actions: {
displayFailureMessage: () => {
displayError(Language.errorExtension)
},
displaySuccessMessage: () => {
displaySuccess(Language.successExtension)
},
},

services: {
extendDeadlineDefault: async (_, event) => {
await API.putWorkspaceExtension(event.workspaceId, defaultWorkspaceExtension())
},
},
},
)