diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 5938666d1c997..8517dab9e8d26 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -1,4 +1,5 @@ import axios, { AxiosRequestHeaders } from "axios" +import dayjs from "dayjs" import * as Types from "./types" import { WorkspaceBuildTransition } from "./types" import * as TypesGen from "./typesGenerated" @@ -339,7 +340,7 @@ export const getWorkspaceBuildLogs = async ( export const putWorkspaceExtension = async ( workspaceId: string, - extendWorkspaceRequest: TypesGen.PutExtendWorkspaceRequest, + newDeadline: dayjs.Dayjs, ): Promise => { - await axios.put(`/api/v2/workspaces/${workspaceId}/extend`, extendWorkspaceRequest) + await axios.put(`/api/v2/workspaces/${workspaceId}/extend`, { deadline: newDeadline }) } diff --git a/site/src/components/Workspace/Workspace.stories.tsx b/site/src/components/Workspace/Workspace.stories.tsx index 0ed600e104f84..c94da97054640 100644 --- a/site/src/components/Workspace/Workspace.stories.tsx +++ b/site/src/components/Workspace/Workspace.stories.tsx @@ -17,6 +17,14 @@ Started.args = { isLoading: false, onExtend: action("extend"), }, + scheduleProps: { + onDeadlineMinus: () => { + // do nothing, this is just for storybook + }, + onDeadlinePlus: () => { + // do nothing, this is just for storybook + }, + }, workspace: Mocks.MockWorkspace, handleStart: action("start"), handleStop: action("stop"), diff --git a/site/src/components/Workspace/Workspace.tsx b/site/src/components/Workspace/Workspace.tsx index ace4c3ba928c6..608a8b096c3b0 100644 --- a/site/src/components/Workspace/Workspace.tsx +++ b/site/src/components/Workspace/Workspace.tsx @@ -19,6 +19,10 @@ export interface WorkspaceProps { isLoading?: boolean onExtend: () => void } + scheduleProps: { + onDeadlinePlus: () => void + onDeadlineMinus: () => void + } handleStart: () => void handleStop: () => void handleDelete: () => void @@ -36,6 +40,7 @@ export interface WorkspaceProps { */ export const Workspace: FC = ({ bannerProps, + scheduleProps, handleStart, handleStop, handleDelete, @@ -99,7 +104,11 @@ export const Workspace: FC = ({ - + diff --git a/site/src/components/WorkspaceSchedule/WorkspaceSchedule.stories.tsx b/site/src/components/WorkspaceSchedule/WorkspaceSchedule.stories.tsx index fa941cf88f516..835c5c34d2c82 100644 --- a/site/src/components/WorkspaceSchedule/WorkspaceSchedule.stories.tsx +++ b/site/src/components/WorkspaceSchedule/WorkspaceSchedule.stories.tsx @@ -11,6 +11,7 @@ dayjs.extend(utc) // SEE: https:github.com/storybookjs/storybook/issues/12208#issuecomment-697044557 const ONE = 1 const SEVEN = 7 +const THIRTY = 30 export default { title: "components/WorkspaceSchedule", @@ -47,6 +48,19 @@ NoTTL.args = { }, } +export const ShutdownRealSoon = Template.bind({}) +ShutdownRealSoon.args = { + workspace: { + ...Mocks.MockWorkspace, + latest_build: { + ...Mocks.MockWorkspaceBuild, + deadline: dayjs().add(THIRTY, "minute").utc().format(), + transition: "start", + }, + ttl_ms: 2 * 60 * 60 * 1000, // 2 hours + }, +} + export const ShutdownSoon = Template.bind({}) ShutdownSoon.args = { workspace: { diff --git a/site/src/components/WorkspaceSchedule/WorkspaceSchedule.test.tsx b/site/src/components/WorkspaceSchedule/WorkspaceSchedule.test.tsx new file mode 100644 index 0000000000000..b08dfe5e37aeb --- /dev/null +++ b/site/src/components/WorkspaceSchedule/WorkspaceSchedule.test.tsx @@ -0,0 +1,120 @@ +import dayjs from "dayjs" +import utc from "dayjs/plugin/utc" +import * as TypesGen from "../../api/typesGenerated" +import * as Mocks from "../../testHelpers/entities" +import { + deadlineMinusDisabled, + deadlinePlusDisabled, + shouldDisplayPlusMinus, +} from "./WorkspaceSchedule" + +dayjs.extend(utc) +const now = dayjs() + +describe("WorkspaceSchedule", () => { + describe("shouldDisplayPlusMinus", () => { + it("should not display if the workspace is not running", () => { + // Given: a stopped workspace + const workspace: TypesGen.Workspace = Mocks.MockStoppedWorkspace + + // Then: shouldDisplayPlusMinus should be false + expect(shouldDisplayPlusMinus(workspace)).toBeFalsy() + }) + + it("should display if the workspace is running", () => { + // Given: a stopped workspace + const workspace: TypesGen.Workspace = Mocks.MockWorkspace + + // Then: shouldDisplayPlusMinus should be false + expect(shouldDisplayPlusMinus(workspace)).toBeTruthy() + }) + }) + + describe("deadlineMinusDisabled", () => { + it("should be false if the deadline is more than 30 minutes in the future", () => { + // Given: a workspace with a deadline set to 31 minutes in the future + const workspace: TypesGen.Workspace = { + ...Mocks.MockWorkspace, + latest_build: { + ...Mocks.MockWorkspaceBuild, + deadline: now.add(31, "minutes").utc().format(), + }, + } + + // Then: deadlineMinusDisabled should be falsy + expect(deadlineMinusDisabled(workspace, now)).toBeFalsy() + }) + + it("should be true if the deadline is 30 minutes or less in the future", () => { + // Given: a workspace with a deadline set to 30 minutes in the future + const workspace: TypesGen.Workspace = { + ...Mocks.MockWorkspace, + latest_build: { + ...Mocks.MockWorkspaceBuild, + deadline: now.add(30, "minutes").utc().format(), + }, + } + + // Then: deadlineMinusDisabled should be truthy + expect(deadlineMinusDisabled(workspace, now)).toBeTruthy() + }) + + it("should be true if the deadline is in the past", () => { + // Given: a workspace with a deadline set to 1 minute in the past + const workspace: TypesGen.Workspace = { + ...Mocks.MockWorkspace, + latest_build: { + ...Mocks.MockWorkspaceBuild, + deadline: now.add(-1, "minutes").utc().format(), + }, + } + + // Then: deadlineMinusDisabled should be truthy + expect(deadlineMinusDisabled(workspace, now)).toBeTruthy() + }) + }) + + describe("deadlinePlusDisabled", () => { + it("should be false if the deadline is less than 24 hours in the future", () => { + // Given: a workspace with a deadline set to 23 hours in the future + const workspace: TypesGen.Workspace = { + ...Mocks.MockWorkspace, + latest_build: { + ...Mocks.MockWorkspaceBuild, + deadline: now.add(23, "hours").utc().format(), + }, + } + + // Then: deadlinePlusDisabled should be falsy + expect(deadlinePlusDisabled(workspace, now)).toBeFalsy() + }) + + it("should be true if the deadline is 24 hours or more in the future", () => { + // Given: a workspace with a deadline set to 25 hours in the future + const workspace: TypesGen.Workspace = { + ...Mocks.MockWorkspace, + latest_build: { + ...Mocks.MockWorkspaceBuild, + deadline: now.add(25, "hours").utc().format(), + }, + } + + // Then: deadlinePlusDisabled should be truthy + expect(deadlinePlusDisabled(workspace, now)).toBeTruthy() + }) + + it("should be false if the deadline is in the past", () => { + // Given: a workspace with a deadline set to 1 minute in the past + const workspace: TypesGen.Workspace = { + ...Mocks.MockWorkspace, + latest_build: { + ...Mocks.MockWorkspaceBuild, + deadline: now.add(-1, "minute").utc().format(), + }, + } + + // Then: deadlinePlusDisabled should be falsy + expect(deadlinePlusDisabled(workspace, now)).toBeFalsy() + }) + }) +}) diff --git a/site/src/components/WorkspaceSchedule/WorkspaceSchedule.tsx b/site/src/components/WorkspaceSchedule/WorkspaceSchedule.tsx index e0873db921878..66b8768d261a8 100644 --- a/site/src/components/WorkspaceSchedule/WorkspaceSchedule.tsx +++ b/site/src/components/WorkspaceSchedule/WorkspaceSchedule.tsx @@ -1,6 +1,10 @@ +import IconButton from "@material-ui/core/IconButton" import Link from "@material-ui/core/Link" import { makeStyles } from "@material-ui/core/styles" +import Tooltip from "@material-ui/core/Tooltip" import Typography from "@material-ui/core/Typography" +import AddBoxIcon from "@material-ui/icons/AddBox" +import IndeterminateCheckBoxIcon from "@material-ui/icons/IndeterminateCheckBox" import ScheduleIcon from "@material-ui/icons/Schedule" import cronstrue from "cronstrue" import dayjs from "dayjs" @@ -66,6 +70,8 @@ export const Language = { } }, editScheduleLink: "Edit schedule", + editDeadlineMinus: "Subtract one hour", + editDeadlinePlus: "Add one hour", scheduleHeader: (workspace: Workspace): string => { const tz = workspace.autostart_schedule ? extractTimezone(workspace.autostart_schedule) @@ -75,11 +81,61 @@ export const Language = { } export interface WorkspaceScheduleProps { + now?: dayjs.Dayjs workspace: Workspace + onDeadlinePlus: () => void + onDeadlineMinus: () => void } -export const WorkspaceSchedule: FC = ({ workspace }) => { +export const shouldDisplayPlusMinus = (workspace: Workspace): boolean => { + if (!isWorkspaceOn(workspace)) { + return false + } + const deadline = dayjs(workspace.latest_build.deadline).utc() + return deadline.year() > 1 +} + +export const deadlineMinusDisabled = (workspace: Workspace, now: dayjs.Dayjs): boolean => { + const delta = dayjs(workspace.latest_build.deadline).diff(now) + return delta <= 30 * 60 * 1000 // 30 minutes +} + +export const deadlinePlusDisabled = (workspace: Workspace, now: dayjs.Dayjs): boolean => { + const delta = dayjs(workspace.latest_build.deadline).diff(now) + return delta >= 24 * 60 * 60 * 1000 // 24 hours +} + +export const WorkspaceSchedule: FC = ({ + now, + workspace, + onDeadlineMinus, + onDeadlinePlus, +}) => { const styles = useStyles() + const editDeadlineButtons = shouldDisplayPlusMinus(workspace) ? ( + + + + + + + + + + + + + ) : null return (
@@ -96,9 +152,12 @@ export const WorkspaceSchedule: FC = ({ workspace }) =>
{Language.autoStopLabel} - - {Language.autoStopDisplay(workspace)} - + + + {Language.autoStopDisplay(workspace)} + + {editDeadlineButtons} +
({ color: theme.palette.text.secondary, }, scheduleValue: { - fontSize: 16, - marginTop: theme.spacing(0.25), + fontSize: 14, + marginTop: theme.spacing(0.75), display: "inline-block", color: theme.palette.text.secondary, }, scheduleAction: { cursor: "pointer", }, + editDeadline: { + color: theme.palette.text.secondary, + }, })) diff --git a/site/src/pages/WorkspacePage/WorkspacePage.tsx b/site/src/pages/WorkspacePage/WorkspacePage.tsx index 6dfd881bf50e9..7dfbac41e803c 100644 --- a/site/src/pages/WorkspacePage/WorkspacePage.tsx +++ b/site/src/pages/WorkspacePage/WorkspacePage.tsx @@ -1,4 +1,6 @@ import { useMachine, useSelector } from "@xstate/react" +import dayjs from "dayjs" +import minMax from "dayjs/plugin/minMax" import React, { useContext, useEffect } from "react" import { Helmet } from "react-helmet" import { useParams } from "react-router-dom" @@ -13,6 +15,8 @@ import { XServiceContext } from "../../xServices/StateContext" import { workspaceMachine } from "../../xServices/workspace/workspaceXService" import { workspaceScheduleBannerMachine } from "../../xServices/workspaceSchedule/workspaceScheduleBannerXService" +dayjs.extend(minMax) + export const WorkspacePage: React.FC = () => { const { username: usernameQueryParam, workspace: workspaceQueryParam } = useParams() const username = firstOrItem(usernameQueryParam, null) @@ -56,7 +60,33 @@ export const WorkspacePage: React.FC = () => { bannerProps={{ isLoading: bannerState.hasTag("loading"), onExtend: () => { - bannerSend({ type: "EXTEND_DEADLINE_DEFAULT", workspaceId: workspace.id }) + bannerSend({ + type: "UPDATE_DEADLINE", + workspaceId: workspace.id, + newDeadline: dayjs(workspace.latest_build.deadline).utc().add(4, "hours"), + }) + }, + }} + scheduleProps={{ + onDeadlineMinus: () => { + bannerSend({ + type: "UPDATE_DEADLINE", + workspaceId: workspace.id, + newDeadline: boundedDeadline( + dayjs(workspace.latest_build.deadline).utc().add(-1, "hours"), + dayjs(), + ), + }) + }, + onDeadlinePlus: () => { + bannerSend({ + type: "UPDATE_DEADLINE", + workspaceId: workspace.id, + newDeadline: boundedDeadline( + dayjs(workspace.latest_build.deadline).utc().add(1, "hours"), + dayjs(), + ), + }) }, }} workspace={workspace} @@ -81,3 +111,9 @@ export const WorkspacePage: React.FC = () => { ) } } + +export const boundedDeadline = (newDeadline: dayjs.Dayjs, now: dayjs.Dayjs): dayjs.Dayjs => { + const minDeadline = now.add(30, "minutes") + const maxDeadline = now.add(24, "hours") + return dayjs.min(dayjs.max(minDeadline, newDeadline), maxDeadline) +} diff --git a/site/src/xServices/workspaceSchedule/workspaceScheduleBannerXService.ts b/site/src/xServices/workspaceSchedule/workspaceScheduleBannerXService.ts index 418177519bf0b..cee9230fcd953 100644 --- a/site/src/xServices/workspaceSchedule/workspaceScheduleBannerXService.ts +++ b/site/src/xServices/workspaceSchedule/workspaceScheduleBannerXService.ts @@ -1,18 +1,22 @@ /** * @fileoverview workspaceScheduleBanner is an xstate machine backing a form, - * presented as an Alert/banner, for reactively extending a workspace schedule. + * presented as an Alert/banner, for reactively updating a workspace schedule. */ +import dayjs from "dayjs" 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.", + errorExtension: "Failed to update workspace shutdown time.", + successExtension: "Updated workspace shutdown time.", } -export type WorkspaceScheduleBannerEvent = { type: "EXTEND_DEADLINE_DEFAULT"; workspaceId: string } +export type WorkspaceScheduleBannerEvent = { + type: "UPDATE_DEADLINE" + workspaceId: string + newDeadline: dayjs.Dayjs +} export const workspaceScheduleBannerMachine = createMachine( { @@ -25,13 +29,13 @@ export const workspaceScheduleBannerMachine = createMachine( states: { idle: { on: { - EXTEND_DEADLINE_DEFAULT: "extendingDeadline", + UPDATE_DEADLINE: "updatingDeadline", }, }, - extendingDeadline: { + updatingDeadline: { invoke: { - src: "extendDeadlineDefault", - id: "extendDeadlineDefault", + src: "updateDeadline", + id: "updateDeadline", onDone: { target: "idle", actions: "displaySuccessMessage", @@ -56,8 +60,8 @@ export const workspaceScheduleBannerMachine = createMachine( }, services: { - extendDeadlineDefault: async (_, event) => { - await API.putWorkspaceExtension(event.workspaceId, defaultWorkspaceExtension()) + updateDeadline: async (_, event) => { + await API.putWorkspaceExtension(event.workspaceId, event.newDeadline) }, }, },