@@ -254,77 +93,7 @@ export const Resources: FC
> = ({
)
}
-const useStyles = makeStyles((theme) => ({
- wrapper: {
- borderRadius: theme.shape.borderRadius,
- border: `1px solid ${theme.palette.divider}`,
- },
-
- tableContainer: {
- border: 0,
- },
-
- resourceAvatar: {
- color: "#FFF",
- backgroundColor: "#3B73D8",
- },
-
- resourceNameCell: {
- borderRight: `1px solid ${theme.palette.divider}`,
- },
-
- resourceType: {
- fontSize: 14,
- color: theme.palette.text.secondary,
- marginTop: theme.spacing(0.5),
- display: "block",
- },
-
- // Adds some left spacing
- agentColumn: {
- paddingLeft: `${theme.spacing(4)}px !important`,
- },
-
- operatingSystem: {
- display: "block",
- textTransform: "capitalize",
- },
-
- agentVersion: {
- display: "block",
- },
-
- accessLinks: {
- display: "flex",
- gap: theme.spacing(0.5),
- flexWrap: "wrap",
- justifyContent: "flex-end",
- },
-
- status: {
- whiteSpace: "nowrap",
- },
-
- data: {
- color: theme.palette.text.secondary,
- fontSize: 14,
- marginTop: theme.spacing(0.75),
- display: "grid",
- gridAutoFlow: "row",
- whiteSpace: "nowrap",
- gap: theme.spacing(0.75),
- height: "fit-content",
- },
-
- dataRow: {
- display: "flex",
- alignItems: "center",
-
- "& strong": {
- marginRight: theme.spacing(1),
- },
- },
-
+const useStyles = makeStyles(() => ({
buttonWrapper: {
display: "flex",
alignItems: "center",
diff --git a/site/src/components/Resources/SensitiveValue.tsx b/site/src/components/Resources/SensitiveValue.tsx
new file mode 100644
index 0000000000..b8e7f42d76
--- /dev/null
+++ b/site/src/components/Resources/SensitiveValue.tsx
@@ -0,0 +1,69 @@
+import IconButton from "@material-ui/core/IconButton"
+import { makeStyles } from "@material-ui/core/styles"
+import Tooltip from "@material-ui/core/Tooltip"
+import VisibilityOffOutlined from "@material-ui/icons/VisibilityOffOutlined"
+import VisibilityOutlined from "@material-ui/icons/VisibilityOutlined"
+import { CopyableValue } from "components/CopyableValue/CopyableValue"
+import { useState } from "react"
+
+const Language = {
+ showLabel: "Show value",
+ hideLabel: "Hide value",
+}
+
+export const SensitiveValue: React.FC<{ value: string }> = ({ value }) => {
+ const [shouldDisplay, setShouldDisplay] = useState(false)
+ const styles = useStyles()
+ const displayValue = shouldDisplay ? value : "••••••••"
+ const buttonLabel = shouldDisplay ? Language.hideLabel : Language.showLabel
+ const icon = shouldDisplay ? (
+
+ ) : (
+
+ )
+
+ return (
+
+
+ {displayValue}
+
+
+ {
+ setShouldDisplay((value) => !value)
+ }}
+ size="small"
+ aria-label={buttonLabel}
+ >
+ {icon}
+
+
+
+ )
+}
+
+const useStyles = makeStyles((theme) => ({
+ value: {
+ // 22px is the button width
+ width: "calc(100% - 22px)",
+ overflow: "hidden",
+ whiteSpace: "nowrap",
+ textOverflow: "ellipsis",
+ },
+
+ sensitiveValue: {
+ display: "flex",
+ alignItems: "center",
+ gap: theme.spacing(0.5),
+ },
+
+ button: {
+ color: "inherit",
+
+ "& .MuiSvgIcon-root": {
+ width: 16,
+ height: 16,
+ },
+ },
+}))
diff --git a/site/src/components/Stack/Stack.tsx b/site/src/components/Stack/Stack.tsx
index d12f4e5821..1f181816c6 100644
--- a/site/src/components/Stack/Stack.tsx
+++ b/site/src/components/Stack/Stack.tsx
@@ -12,6 +12,7 @@ export type StackProps = {
spacing?: number
alignItems?: CSSProperties["alignItems"]
justifyContent?: CSSProperties["justifyContent"]
+ wrap?: CSSProperties["flexWrap"]
} & React.HTMLProps
type StyleProps = Omit
@@ -23,6 +24,7 @@ const useStyles = makeStyles((theme) => ({
gap: ({ spacing }: StyleProps) => spacing && theme.spacing(spacing),
alignItems: ({ alignItems }: StyleProps) => alignItems,
justifyContent: ({ justifyContent }: StyleProps) => justifyContent,
+ flexWrap: ({ wrap }: StyleProps) => wrap,
[theme.breakpoints.down("sm")]: {
width: "100%",
@@ -37,6 +39,7 @@ export const Stack: FC = ({
spacing = 2,
alignItems,
justifyContent,
+ wrap,
...divProps
}) => {
const styles = useStyles({
@@ -44,6 +47,7 @@ export const Stack: FC = ({
direction,
alignItems,
justifyContent,
+ wrap,
})
return (
diff --git a/site/src/components/TemplateStats/TemplateStats.tsx b/site/src/components/TemplateStats/TemplateStats.tsx
index 6065c5a0ec..3bc9434795 100644
--- a/site/src/components/TemplateStats/TemplateStats.tsx
+++ b/site/src/components/TemplateStats/TemplateStats.tsx
@@ -1,12 +1,16 @@
import { makeStyles } from "@material-ui/core/styles"
import { FC } from "react"
import { createDayString } from "util/createDayString"
-import { formatTemplateActiveDevelopers } from "util/templates"
+import {
+ formatTemplateBuildTime,
+ formatTemplateActiveDevelopers,
+} from "util/templates"
import { Template, TemplateVersion } from "../../api/typesGenerated"
import { MONOSPACE_FONT_FAMILY } from "../../theme/constants"
const Language = {
usedByLabel: "Used by",
+ buildTimeLabel: "Build time",
activeVersionLabel: "Active version",
lastUpdateLabel: "Last updated",
developerPlural: "developers",
@@ -38,6 +42,14 @@ export const TemplateStats: FC = ({
{Language.activeVersionLabel}
{activeVersion.name}
@@ -75,7 +87,7 @@ const useStyles = makeStyles((theme) => ({
},
statItem: {
- minWidth: "20%",
+ minWidth: "15%",
padding: theme.spacing(2),
paddingTop: theme.spacing(1.75),
},
diff --git a/site/src/components/Tooltips/HelpTooltip/HelpTooltip.tsx b/site/src/components/Tooltips/HelpTooltip/HelpTooltip.tsx
index dc9a555357..9b992e6bca 100644
--- a/site/src/components/Tooltips/HelpTooltip/HelpTooltip.tsx
+++ b/site/src/components/Tooltips/HelpTooltip/HelpTooltip.tsx
@@ -1,5 +1,5 @@
import Link from "@material-ui/core/Link"
-import Popover from "@material-ui/core/Popover"
+import Popover, { PopoverProps } from "@material-ui/core/Popover"
import { makeStyles } from "@material-ui/core/styles"
import HelpIcon from "@material-ui/icons/HelpOutline"
import OpenInNewIcon from "@material-ui/icons/OpenInNew"
@@ -35,6 +35,35 @@ const useHelpTooltip = () => {
return helpTooltipContext
}
+export const HelpPopover: React.FC<
+ PopoverProps & { onOpen: () => void; onClose: () => void }
+> = ({ onOpen, onClose, children, ...props }) => {
+ const styles = useStyles({ size: "small" })
+
+ return (
+
+ {children}
+
+ )
+}
+
export const HelpTooltip: React.FC<
React.PropsWithChildren
> = ({ children, open, size = "medium" }) => {
@@ -60,38 +89,24 @@ export const HelpTooltip: React.FC<
onMouseEnter={() => {
setIsOpen(true)
}}
+ onMouseLeave={() => {
+ setIsOpen(false)
+ }}
aria-label={Language.ariaLabel}
>
- {
- setIsOpen(true)
- },
- onMouseLeave: () => {
- setIsOpen(false)
- },
- }}
+ onOpen={() => setIsOpen(true)}
+ onClose={() => setIsOpen(false)}
>
{children}
-
+
>
)
}
diff --git a/site/src/components/Workspace/Workspace.stories.tsx b/site/src/components/Workspace/Workspace.stories.tsx
index f8430ee947..273bb3bdd1 100644
--- a/site/src/components/Workspace/Workspace.stories.tsx
+++ b/site/src/components/Workspace/Workspace.stories.tsx
@@ -49,6 +49,7 @@ Running.args = {
canUpdateWorkspace: true,
workspaceErrors: {},
buildInfo: Mocks.MockBuildInfo,
+ template: Mocks.MockTemplate,
}
export const WithoutUpdateAccess = Template.bind({})
diff --git a/site/src/components/Workspace/Workspace.tsx b/site/src/components/Workspace/Workspace.tsx
index a9849063b6..f2c22d3864 100644
--- a/site/src/components/Workspace/Workspace.tsx
+++ b/site/src/components/Workspace/Workspace.tsx
@@ -20,6 +20,10 @@ import { WorkspaceSection } from "../WorkspaceSection/WorkspaceSection"
import { WorkspaceStats } from "../WorkspaceStats/WorkspaceStats"
import { AlertBanner } from "../AlertBanner/AlertBanner"
import { useTranslation } from "react-i18next"
+import {
+ EstimateTransitionTime,
+ WorkspaceBuildProgress,
+} from "components/WorkspaceBuildProgress/WorkspaceBuildProgress"
export enum WorkspaceErrors {
GET_RESOURCES_ERROR = "getResourcesError",
@@ -34,10 +38,12 @@ export interface WorkspaceProps {
onExtend: () => void
}
scheduleProps: {
- onDeadlinePlus: () => void
- onDeadlineMinus: () => void
+ onDeadlinePlus: (hours: number) => void
+ onDeadlineMinus: (hours: number) => void
deadlinePlusEnabled: () => boolean
deadlineMinusEnabled: () => boolean
+ maxDeadlineIncrease: number
+ maxDeadlineDecrease: number
}
handleStart: () => void
handleStop: () => void
@@ -53,6 +59,7 @@ export interface WorkspaceProps {
workspaceErrors: Partial>
buildInfo?: TypesGen.BuildInfoResponse
applicationsHost?: string
+ template?: TypesGen.Template
}
/**
@@ -75,6 +82,7 @@ export const Workspace: FC> = ({
hideSSHButton,
buildInfo,
applicationsHost,
+ template,
}) => {
const { t } = useTranslation("workspacePage")
const styles = useStyles()
@@ -110,6 +118,15 @@ export const Workspace: FC> = ({
/>
)
+ let buildTimeEstimate: number | undefined = undefined
+ let isTransitioning: boolean | undefined = undefined
+ if (template !== undefined) {
+ ;[buildTimeEstimate, isTransitioning] = EstimateTransitionTime(
+ template,
+ workspace,
+ )
+ }
+
return (
> = ({
onDeadlinePlus={scheduleProps.onDeadlinePlus}
deadlineMinusEnabled={scheduleProps.deadlineMinusEnabled}
deadlinePlusEnabled={scheduleProps.deadlinePlusEnabled}
+ maxDeadlineDecrease={scheduleProps.maxDeadlineDecrease}
+ maxDeadlineIncrease={scheduleProps.maxDeadlineIncrease}
canUpdateWorkspace={canUpdateWorkspace}
/>
> = ({
+ {isTransitioning !== undefined && isTransitioning && (
+
+ )}
+
{typeof resources !== "undefined" && resources.length > 0 && (
+
+const Template: Story = (args) => (
+
+)
+
+export const Starting = Template.bind({})
+Starting.args = {
+ buildEstimate: 10000,
+ workspace: {
+ ...MockStartingWorkspace,
+ latest_build: {
+ ...MockWorkspaceBuild,
+ status: "starting",
+ job: {
+ ...MockProvisionerJob,
+ started_at: dayjs().add(-5, "second").format(),
+ status: "running",
+ },
+ },
+ },
+}
+
+export const StartingUnknown = Template.bind({})
+StartingUnknown.args = {
+ ...Starting.args,
+ buildEstimate: undefined,
+}
+
+export const StartingPassedEstimate = Template.bind({})
+StartingPassedEstimate.args = {
+ ...Starting.args,
+ buildEstimate: 1000,
+}
diff --git a/site/src/components/WorkspaceBuildProgress/WorkspaceBuildProgress.tsx b/site/src/components/WorkspaceBuildProgress/WorkspaceBuildProgress.tsx
new file mode 100644
index 0000000000..f1d61ee26e
--- /dev/null
+++ b/site/src/components/WorkspaceBuildProgress/WorkspaceBuildProgress.tsx
@@ -0,0 +1,136 @@
+import LinearProgress from "@material-ui/core/LinearProgress"
+import makeStyles from "@material-ui/core/styles/makeStyles"
+import { Template, Workspace } from "api/typesGenerated"
+import dayjs, { Dayjs } from "dayjs"
+import { FC, useEffect, useState } from "react"
+import { MONOSPACE_FONT_FAMILY } from "theme/constants"
+
+import duration from "dayjs/plugin/duration"
+
+dayjs.extend(duration)
+
+const estimateFinish = (
+ startedAt: Dayjs,
+ buildEstimate: number,
+): [number, string] => {
+ const realPercentage = dayjs().diff(startedAt) / buildEstimate
+
+ const maxPercentage = 1
+ if (realPercentage > maxPercentage) {
+ return [maxPercentage * 100, "Any moment now..."]
+ }
+
+ return [
+ realPercentage * 100,
+ `~${Math.ceil(
+ dayjs.duration((1 - realPercentage) * buildEstimate).asSeconds(),
+ )} seconds remaining...`,
+ ]
+}
+
+export interface WorkspaceBuildProgressProps {
+ workspace: Workspace
+ buildEstimate?: number
+}
+
+// EstimateTransitionTime gets the build estimate for the workspace,
+// if it is in a transition state.
+export const EstimateTransitionTime = (
+ template: Template,
+ workspace: Workspace,
+): [number | undefined, boolean] => {
+ switch (workspace.latest_build.status) {
+ case "starting":
+ return [template.build_time_stats.start_ms, true]
+ case "stopping":
+ return [template.build_time_stats.stop_ms, true]
+ case "deleting":
+ return [template.build_time_stats.delete_ms, true]
+ default:
+ // Not in a transition state
+ return [undefined, false]
+ }
+}
+
+export const WorkspaceBuildProgress: FC = ({
+ workspace,
+ buildEstimate,
+}) => {
+ const styles = useStyles()
+ const job = workspace.latest_build.job
+ const [progressValue, setProgressValue] = useState(0)
+
+ // By default workspace is updated every second, which can cause visual stutter
+ // when the build estimate is a few seconds. The timer ensures no observable
+ // stutter in all cases.
+ useEffect(() => {
+ const updateProgress = () => {
+ if (job.status !== "running" || buildEstimate === undefined) {
+ setProgressValue(undefined)
+ return
+ }
+ const est = estimateFinish(dayjs(job.started_at), buildEstimate)[0]
+ setProgressValue(est)
+ }
+ setTimeout(updateProgress, 5)
+ }, [progressValue, job, buildEstimate])
+
+ return (
+
+
+
+
{`Build ${job.status}`}
+
+ {(() => {
+ if (job.status !== "running") {
+ return ""
+ } else if (buildEstimate !== undefined) {
+ return estimateFinish(dayjs(job.started_at), buildEstimate)[1]
+ } else {
+ return "Unknown ETA"
+ }
+ })()}
+
+
+
+ )
+}
+
+const useStyles = makeStyles((theme) => ({
+ stack: {
+ paddingLeft: theme.spacing(0.2),
+ paddingRight: theme.spacing(0.2),
+ },
+ noTransition: {
+ transition: "none",
+ },
+ barHelpers: {
+ display: "flex",
+ justifyContent: "space-between",
+ },
+ label: {
+ fontFamily: MONOSPACE_FONT_FAMILY,
+ fontSize: 12,
+ textTransform: "uppercase",
+ display: "block",
+ fontWeight: 600,
+ color: theme.palette.text.secondary,
+ },
+}))
diff --git a/site/src/components/WorkspaceScheduleButton/EditHours.tsx b/site/src/components/WorkspaceScheduleButton/EditHours.tsx
new file mode 100644
index 0000000000..5bcd34a710
--- /dev/null
+++ b/site/src/components/WorkspaceScheduleButton/EditHours.tsx
@@ -0,0 +1,53 @@
+import Button from "@material-ui/core/Button"
+import { makeStyles } from "@material-ui/core/styles"
+import TextField from "@material-ui/core/TextField"
+import { Stack } from "components/Stack/Stack"
+import { useState } from "react"
+import { useTranslation } from "react-i18next"
+
+interface EditHoursProps {
+ handleSubmit: (hours: number) => void
+ max: number
+}
+
+export const EditHours = ({
+ handleSubmit,
+ max,
+}: EditHoursProps): JSX.Element => {
+ const { t } = useTranslation("workspacePage")
+ const [hours, setHours] = useState(1)
+ const styles = useStyles()
+
+ return (
+
+ )
+}
+
+const useStyles = makeStyles(() => ({
+ inputField: {
+ width: "70px",
+ "& .MuiOutlinedInput-root": {
+ height: "30px",
+ },
+ },
+ button: {
+ "&.MuiButton-root": {
+ minHeight: "30px",
+ height: "30px",
+ },
+ },
+}))
diff --git a/site/src/components/WorkspaceScheduleButton/WorkspaceScheduleButton.stories.tsx b/site/src/components/WorkspaceScheduleButton/WorkspaceScheduleButton.stories.tsx
index 043c53da94..033c9698ce 100644
--- a/site/src/components/WorkspaceScheduleButton/WorkspaceScheduleButton.stories.tsx
+++ b/site/src/components/WorkspaceScheduleButton/WorkspaceScheduleButton.stories.tsx
@@ -16,6 +16,12 @@ export default {
canUpdateWorkspace: {
defaultValue: true,
},
+ deadlineMinusEnabled: {
+ defaultValue: (): boolean => false,
+ },
+ deadlinePlusEnabled: {
+ defaultValue: (): boolean => false,
+ },
},
}
@@ -64,6 +70,7 @@ WorkspaceOffShort.args = {
export const WorkspaceOffLong = Template.bind({})
WorkspaceOffLong.args = {
+ deadlinePlusEnabled: () => true,
workspace: {
...Mocks.MockWorkspace,
diff --git a/site/src/components/WorkspaceScheduleButton/WorkspaceScheduleButton.test.tsx b/site/src/components/WorkspaceScheduleButton/WorkspaceScheduleButton.test.tsx
index 8e0d4e5358..5ee401f7be 100644
--- a/site/src/components/WorkspaceScheduleButton/WorkspaceScheduleButton.test.tsx
+++ b/site/src/components/WorkspaceScheduleButton/WorkspaceScheduleButton.test.tsx
@@ -2,7 +2,7 @@ import dayjs from "dayjs"
import utc from "dayjs/plugin/utc"
import * as TypesGen from "../../api/typesGenerated"
import * as Mocks from "../../testHelpers/entities"
-import { shouldDisplayPlusMinus } from "./WorkspaceScheduleButton"
+import { canEditDeadline } from "./WorkspaceScheduleButton"
dayjs.extend(utc)
@@ -13,7 +13,7 @@ describe("WorkspaceScheduleButton", () => {
const workspace: TypesGen.Workspace = Mocks.MockStoppedWorkspace
// Then: shouldDisplayPlusMinus should be false
- expect(shouldDisplayPlusMinus(workspace)).toBeFalsy()
+ expect(canEditDeadline(workspace)).toBeFalsy()
})
it("should display if the workspace is running", () => {
@@ -21,7 +21,7 @@ describe("WorkspaceScheduleButton", () => {
const workspace: TypesGen.Workspace = Mocks.MockWorkspace
// Then: shouldDisplayPlusMinus should be false
- expect(shouldDisplayPlusMinus(workspace)).toBeTruthy()
+ expect(canEditDeadline(workspace)).toBeTruthy()
})
})
})
diff --git a/site/src/components/WorkspaceScheduleButton/WorkspaceScheduleButton.tsx b/site/src/components/WorkspaceScheduleButton/WorkspaceScheduleButton.tsx
index 66e3df5e30..b613786910 100644
--- a/site/src/components/WorkspaceScheduleButton/WorkspaceScheduleButton.tsx
+++ b/site/src/components/WorkspaceScheduleButton/WorkspaceScheduleButton.tsx
@@ -1,11 +1,13 @@
import Button from "@material-ui/core/Button"
import IconButton from "@material-ui/core/IconButton"
import Popover from "@material-ui/core/Popover"
-import { makeStyles } from "@material-ui/core/styles"
+import { makeStyles, Theme } from "@material-ui/core/styles"
import Tooltip from "@material-ui/core/Tooltip"
import AddIcon from "@material-ui/icons/Add"
import RemoveIcon from "@material-ui/icons/Remove"
import ScheduleIcon from "@material-ui/icons/Schedule"
+import { Maybe } from "components/Conditionals/Maybe"
+import { Stack } from "components/Stack/Stack"
import dayjs from "dayjs"
import advancedFormat from "dayjs/plugin/advancedFormat"
import duration from "dayjs/plugin/duration"
@@ -17,6 +19,7 @@ import { useTranslation } from "react-i18next"
import { Workspace } from "../../api/typesGenerated"
import { isWorkspaceOn } from "../../util/workspace"
import { WorkspaceSchedule } from "../WorkspaceSchedule/WorkspaceSchedule"
+import { EditHours } from "./EditHours"
import { WorkspaceScheduleLabel } from "./WorkspaceScheduleLabel"
// REMARK: some plugins depend on utc, so it's listed first. Otherwise they're
@@ -27,12 +30,12 @@ dayjs.extend(duration)
dayjs.extend(relativeTime)
dayjs.extend(timezone)
-export const shouldDisplayPlusMinus = (workspace: Workspace): boolean => {
+export const canEditDeadline = (workspace: Workspace): boolean => {
return isWorkspaceOn(workspace) && Boolean(workspace.latest_build.deadline)
}
export const shouldDisplayScheduleLabel = (workspace: Workspace): boolean => {
- if (shouldDisplayPlusMinus(workspace)) {
+ if (canEditDeadline(workspace)) {
return true
}
if (isWorkspaceOn(workspace)) {
@@ -43,13 +46,17 @@ export const shouldDisplayScheduleLabel = (workspace: Workspace): boolean => {
export interface WorkspaceScheduleButtonProps {
workspace: Workspace
- onDeadlinePlus: () => void
- onDeadlineMinus: () => void
+ onDeadlinePlus: (hours: number) => void
+ onDeadlineMinus: (hours: number) => void
deadlineMinusEnabled: () => boolean
deadlinePlusEnabled: () => boolean
+ maxDeadlineIncrease: number
+ maxDeadlineDecrease: number
canUpdateWorkspace: boolean
}
+export type EditMode = "add" | "subtract" | "off"
+
export const WorkspaceScheduleButton: React.FC<
WorkspaceScheduleButtonProps
> = ({
@@ -58,49 +65,93 @@ export const WorkspaceScheduleButton: React.FC<
onDeadlineMinus,
deadlinePlusEnabled,
deadlineMinusEnabled,
+ maxDeadlineDecrease,
+ maxDeadlineIncrease,
canUpdateWorkspace,
}) => {
const { t } = useTranslation("workspacePage")
const anchorRef = useRef(null)
const [isOpen, setIsOpen] = useState(false)
+ const [editMode, setEditMode] = useState("off")
const id = isOpen ? "schedule-popover" : undefined
- const styles = useStyles()
+ const styles = useStyles({ editMode })
const onClose = () => {
setIsOpen(false)
}
+ const handleSubmitHours = (hours: number) => {
+ if (hours !== 0) {
+ if (editMode === "add") {
+ onDeadlinePlus(hours)
+ }
+ if (editMode === "subtract") {
+ onDeadlineMinus(hours)
+ }
+ }
+ setEditMode("off")
+ }
+
return (
- {shouldDisplayScheduleLabel(workspace) && (
-
-
- {canUpdateWorkspace && shouldDisplayPlusMinus(workspace) && (
-
-
-
-
-
-
-
-
-
-
-
-
- )}
-
- )}
+
+
+
+
+
+
+ {
+ setEditMode("subtract")
+ }}
+ >
+
+
+
+
+ {
+ setEditMode("add")
+ }}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
<>