diff --git a/site/src/components/GlobalSnackbar/GlobalSnackbar.tsx b/site/src/components/GlobalSnackbar/GlobalSnackbar.tsx index e2dd795f806ce..b2dc8eae74619 100644 --- a/site/src/components/GlobalSnackbar/GlobalSnackbar.tsx +++ b/site/src/components/GlobalSnackbar/GlobalSnackbar.tsx @@ -72,6 +72,7 @@ export const GlobalSnackbar: React.FC = () => { return ( void } | undefined>(undefined) + +const useHelpTooltip = () => { + const helpTooltipContext = useContext(HelpTooltipContext) + + if (!helpTooltipContext) { + throw new Error("This hook should be used in side of the HelpTooltipContext.") + } + + return helpTooltipContext +} + export const HelpTooltip: React.FC = ({ children, open, size = "medium" }) => { const styles = useStyles({ size }) const [anchorEl, setAnchorEl] = useState(null) open = open ?? Boolean(anchorEl) const id = open ? "help-popover" : undefined + const onClose = () => { + setAnchorEl(null) + } + return ( <> - = ({ children, open, size = id={id} open={open} anchorEl={anchorEl} - onClose={() => { - setAnchorEl(null) - }} + onClose={onClose} anchorOrigin={{ vertical: "bottom", horizontal: "left", @@ -41,7 +64,7 @@ export const HelpTooltip: React.FC = ({ children, open, size = horizontal: "left", }} > - {children} + {children} ) @@ -70,6 +93,25 @@ export const HelpTooltipLink: React.FC<{ href: string }> = ({ children, href }) ) } +export const HelpTooltipAction: React.FC<{ icon: Icon; onClick: () => void }> = ({ children, icon: Icon, onClick }) => { + const styles = useStyles() + const tooltip = useHelpTooltip() + + return ( + + ) +} + export const HelpTooltipLinksGroup: React.FC = ({ children }) => { const styles = useStyles() @@ -110,11 +152,12 @@ const useStyles = makeStyles((theme) => ({ padding: 0, border: 0, background: "transparent", - color: theme.palette.text.secondary, + color: theme.palette.text.primary, + opacity: 0.5, cursor: "pointer", "&:hover": { - color: theme.palette.text.primary, + opacity: 0.75, }, }, @@ -156,4 +199,22 @@ const useStyles = makeStyles((theme) => ({ linksGroup: { marginTop: theme.spacing(2), }, + + action: { + display: "flex", + alignItems: "center", + background: "none", + border: 0, + color: theme.palette.primary.light, + padding: 0, + cursor: "pointer", + fontSize: 14, + }, + + actionIcon: { + color: "inherit", + width: 14, + height: 14, + marginRight: theme.spacing(1), + }, })) diff --git a/site/src/pages/WorkspacesPage/WorkspacesPage.tsx b/site/src/pages/WorkspacesPage/WorkspacesPage.tsx index c5f47cc50a537..1f742967e5310 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPage.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPage.tsx @@ -10,13 +10,14 @@ import { WorkspacesPageView } from "./WorkspacesPageView" const WorkspacesPage: FC = () => { const [workspacesState, send] = useMachine(workspacesMachine) const [searchParams, setSearchParams] = useSearchParams() + const { workspaceRefs } = workspacesState.context useEffect(() => { const filter = searchParams.get("filter") const query = filter !== null ? filter : workspaceFilterQuery.me send({ - type: "SET_FILTER", + type: "GET_WORKSPACES", query, }) }, [searchParams, send]) @@ -30,7 +31,7 @@ const WorkspacesPage: FC = () => { { searchParams.set("filter", query) setSearchParams(searchParams) diff --git a/site/src/pages/WorkspacesPage/WorkspacesPageView.stories.tsx b/site/src/pages/WorkspacesPage/WorkspacesPageView.stories.tsx index 1b62b533ae30b..f25a33518eb59 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPageView.stories.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPageView.stories.tsx @@ -1,7 +1,9 @@ import { ComponentMeta, Story } from "@storybook/react" +import { spawn } from "xstate" import { ProvisionerJobStatus, Workspace, WorkspaceTransition } from "../../api/typesGenerated" import { MockWorkspace } from "../../testHelpers/entities" import { workspaceFilterQuery } from "../../util/workspace" +import { workspaceItemMachine } from "../../xServices/workspaces/workspacesXService" import { WorkspacesPageView, WorkspacesPageViewProps } from "./WorkspacesPageView" export default { @@ -14,9 +16,11 @@ const Template: Story = (args) => { return { ...MockWorkspace, + outdated, latest_build: { ...MockWorkspace.latest_build, transition, @@ -41,22 +45,29 @@ const workspaces: { [key in ProvisionerJobStatus]: Workspace } = { export const AllStates = Template.bind({}) AllStates.args = { - workspaces: [ + workspaceRefs: [ ...Object.values(workspaces), createWorkspaceWithStatus("running", "stop"), createWorkspaceWithStatus("succeeded", "stop"), createWorkspaceWithStatus("running", "delete"), - ], + ].map((data) => spawn(workspaceItemMachine.withContext({ data }))), +} + +export const Outdated = Template.bind({}) +Outdated.args = { + workspaceRefs: [createWorkspaceWithStatus("running", "stop", true)].map((data) => + spawn(workspaceItemMachine.withContext({ data })), + ), } export const OwnerHasNoWorkspaces = Template.bind({}) OwnerHasNoWorkspaces.args = { - workspaces: [], + workspaceRefs: [], filter: workspaceFilterQuery.me, } export const NoResults = Template.bind({}) NoResults.args = { - workspaces: [], + workspaceRefs: [], filter: "searchtearmwithnoresults", } diff --git a/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx b/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx index c3c6b7c0a942a..d09b7b47ac73c 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx @@ -13,19 +13,21 @@ import TableRow from "@material-ui/core/TableRow" import TextField from "@material-ui/core/TextField" import AddCircleOutline from "@material-ui/icons/AddCircleOutline" import KeyboardArrowRight from "@material-ui/icons/KeyboardArrowRight" +import RefreshIcon from "@material-ui/icons/Refresh" import SearchIcon from "@material-ui/icons/Search" import useTheme from "@material-ui/styles/useTheme" +import { useActor } from "@xstate/react" import dayjs from "dayjs" import relativeTime from "dayjs/plugin/relativeTime" import { FormikErrors, useFormik } from "formik" import { FC, useState } from "react" import { Link as RouterLink, useNavigate } from "react-router-dom" -import * as TypesGen from "../../api/typesGenerated" import { AvatarData } from "../../components/AvatarData/AvatarData" import { CloseDropdown, OpenDropdown } from "../../components/DropdownArrows/DropdownArrows" import { EmptyState } from "../../components/EmptyState/EmptyState" import { HelpTooltip, + HelpTooltipAction, HelpTooltipLink, HelpTooltipLinksGroup, HelpTooltipText, @@ -37,6 +39,7 @@ import { Stack } from "../../components/Stack/Stack" import { TableLoader } from "../../components/TableLoader/TableLoader" import { getFormHelpers, onChangeTrimmed } from "../../util/formUtils" import { getDisplayStatus, workspaceFilterQuery } from "../../util/workspace" +import { WorkspaceItemMachineRef } from "../../xServices/workspaces/workspacesXService" dayjs.extend(relativeTime) @@ -54,6 +57,10 @@ export const Language = { workspaceTooltipLink1: "Create workspaces", workspaceTooltipLink2: "Connect with SSH", workspaceTooltipLink3: "Editors and IDEs", + outdatedLabel: "Outdated", + upToDateLabel: "Up to date", + versionTooltipText: "This workspace version is outdated and a newer version is available.", + updateVersionLabel: "Update version", } const WorkspaceHelpTooltip: React.FC = () => { @@ -76,6 +83,78 @@ const WorkspaceHelpTooltip: React.FC = () => { ) } +const OutdatedHelpTooltip: React.FC<{ onUpdateVersion: () => void }> = ({ onUpdateVersion }) => { + return ( + + {Language.outdatedLabel} + {Language.versionTooltipText} + + + {Language.updateVersionLabel} + + + + ) +} + +const WorkspaceRow: React.FC<{ workspaceRef: WorkspaceItemMachineRef }> = ({ workspaceRef }) => { + const styles = useStyles() + const navigate = useNavigate() + const theme: Theme = useTheme() + const [workspaceState, send] = useActor(workspaceRef) + const { data: workspace } = workspaceState.context + const status = getDisplayStatus(theme, workspace.latest_build) + const navigateToWorkspacePage = () => { + navigate(`/@${workspace.owner_name}/${workspace.name}`) + } + return ( + { + if (event.key === "Enter") { + navigateToWorkspacePage() + } + }} + className={styles.clickableTableRow} + > + + + + {workspace.template_name} + + {workspace.outdated ? ( + + {Language.outdatedLabel} + { + send("UPDATE_VERSION") + }} + /> + + ) : ( + {Language.upToDateLabel} + )} + + + + {dayjs().to(dayjs(workspace.latest_build.created_at))} + + + + {status.status} + + +
+ +
+
+
+ ) +} + interface FilterFormValues { query: string } @@ -84,15 +163,13 @@ export type FilterFormErrors = FormikErrors export interface WorkspacesPageViewProps { loading?: boolean - workspaces?: TypesGen.Workspace[] + workspaceRefs?: WorkspaceItemMachineRef[] filter?: string onFilter: (query: string) => void } -export const WorkspacesPageView: FC = ({ loading, workspaces, filter, onFilter }) => { +export const WorkspacesPageView: FC = ({ loading, workspaceRefs, filter, onFilter }) => { const styles = useStyles() - const navigate = useNavigate() - const theme: Theme = useTheme() const form = useFormik({ enableReinitialize: true, @@ -195,17 +272,17 @@ export const WorkspacesPageView: FC = ({ loading, works - Name - Template - Version - Last Built - Status + Name + Template + Version + Last Built + Status - {!workspaces && loading && } - {workspaces && workspaces.length === 0 && ( + {!workspaceRefs && loading && } + {workspaceRefs && workspaceRefs.length === 0 && ( <> {filter === workspaceFilterQuery.me || filter === workspaceFilterQuery.all ? ( @@ -230,53 +307,8 @@ export const WorkspacesPageView: FC = ({ loading, works )} )} - {workspaces && - workspaces.map((workspace) => { - const status = getDisplayStatus(theme, workspace.latest_build) - const navigateToWorkspacePage = () => { - navigate(`/@${workspace.owner_name}/${workspace.name}`) - } - return ( - { - if (event.key === "Enter") { - navigateToWorkspacePage() - } - }} - className={styles.clickableTableRow} - > - - - - {workspace.template_name} - - {workspace.outdated ? ( - outdated - ) : ( - up to date - )} - - - - {dayjs().to(dayjs(workspace.latest_build.created_at))} - - - - {status.status} - - -
- -
-
-
- ) - })} + {workspaceRefs && + workspaceRefs.map((workspaceRef) => )}
@@ -341,4 +373,10 @@ const useStyles = makeStyles((theme) => ({ arrowCell: { display: "flex", }, + outdatedLabel: { + color: theme.palette.error.main, + display: "flex", + alignItems: "center", + gap: theme.spacing(0.5), + }, })) diff --git a/site/src/xServices/workspaces/workspacesXService.ts b/site/src/xServices/workspaces/workspacesXService.ts index 842d1108d2b67..1fff46c0341e2 100644 --- a/site/src/xServices/workspaces/workspacesXService.ts +++ b/site/src/xServices/workspaces/workspacesXService.ts @@ -1,64 +1,263 @@ -import { assign, createMachine } from "xstate" +import { ActorRefFrom, assign, createMachine, spawn } from "xstate" import * as API from "../../api/api" +import { getErrorMessage } from "../../api/errors" import * as TypesGen from "../../api/typesGenerated" +import { displayError, displayMsg, displaySuccess } from "../../components/GlobalSnackbar/utils" import { workspaceQueryToFilter } from "../../util/workspace" -interface WorkspaceContext { - workspaces?: TypesGen.Workspace[] +/** + * Workspace item machine + * + * It is used to control the state and actions of each workspace in the + * workspaces page view + **/ +interface WorkspaceItemContext { + data: TypesGen.Workspace + updatedTemplate?: TypesGen.Template +} + +type WorkspaceItemEvent = + | { + type: "UPDATE_VERSION" + } + | { + type: "UPDATE_DATA" + data: TypesGen.Workspace + } + +export const workspaceItemMachine = createMachine( + { + id: "workspaceItemMachine", + schema: { + context: {} as WorkspaceItemContext, + events: {} as WorkspaceItemEvent, + services: {} as { + getTemplate: { + data: TypesGen.Template + } + startWorkspace: { + data: TypesGen.WorkspaceBuild + } + getWorkspace: { + data: TypesGen.Workspace + } + }, + }, + tsTypes: {} as import("./workspacesXService.typegen").Typegen0, + type: "parallel", + + states: { + updateVersion: { + initial: "idle", + states: { + idle: { + on: { + UPDATE_VERSION: { + target: "gettingUpdatedTemplate", + // We can improve the UI by optimistically updating the workspace status + // to "Queued" so the UI can display the updated state right away and we + // don't need to display an extra spinner. + actions: ["assignQueuedStatus", "displayUpdatingVersionMessage"], + }, + UPDATE_DATA: { + actions: "assignUpdatedData", + }, + }, + }, + gettingUpdatedTemplate: { + invoke: { + id: "getTemplate", + src: "getTemplate", + onDone: { + actions: "assignUpdatedTemplate", + target: "restartingWorkspace", + }, + onError: { + target: "idle", + actions: "displayUpdateVersionError", + }, + }, + }, + restartingWorkspace: { + invoke: { + id: "startWorkspace", + src: "startWorkspace", + onDone: { + actions: "assignLatestBuild", + target: "waitingToBeUpdated", + }, + onError: { + target: "idle", + actions: "displayUpdateVersionError", + }, + }, + }, + waitingToBeUpdated: { + after: { + 5000: "gettingUpdatedWorkspaceData", + }, + }, + gettingUpdatedWorkspaceData: { + invoke: { + id: "getWorkspace", + src: "getWorkspace", + onDone: [ + { + target: "waitingToBeUpdated", + cond: "isOutdated", + actions: ["assignUpdatedData"], + }, + { + target: "idle", + actions: ["assignUpdatedData", "displayUpdatedSuccessMessage"], + }, + ], + }, + }, + }, + }, + }, + }, + { + guards: { + isOutdated: (_, event) => event.data.outdated, + }, + services: { + getTemplate: (context) => API.getTemplate(context.data.template_id), + startWorkspace: (context) => { + if (!context.updatedTemplate) { + throw new Error("Updated template is not loaded.") + } + + return API.startWorkspace(context.data.id, context.updatedTemplate.active_version_id) + }, + getWorkspace: (context) => API.getWorkspace(context.data.id), + }, + actions: { + assignUpdatedTemplate: assign({ + updatedTemplate: (_, event) => event.data, + }), + assignLatestBuild: assign({ + data: (context, event) => { + return { + ...context.data, + latest_build: event.data, + } + }, + }), + displayUpdateVersionError: (_, event) => { + const message = getErrorMessage(event.data, "Error on update workspace version.") + displayError(message) + }, + displayUpdatingVersionMessage: () => { + displayMsg("Updating workspace...") + }, + assignQueuedStatus: assign({ + data: (ctx) => { + return { + ...ctx.data, + latest_build: { + ...ctx.data.latest_build, + job: { + ...ctx.data.latest_build.job, + status: "pending" as TypesGen.ProvisionerJobStatus, + }, + }, + } + }, + }), + displayUpdatedSuccessMessage: () => { + displaySuccess("Workspace updated successfully.") + }, + assignUpdatedData: assign({ + data: (_, event) => event.data, + }), + }, + }, +) + +/** + * Workspaces machine + * + * It is used to control the state of the workspace list + **/ + +export type WorkspaceItemMachineRef = ActorRefFrom + +interface WorkspacesContext { + workspaceRefs?: WorkspaceItemMachineRef[] filter?: string getWorkspacesError?: Error | unknown } -type WorkspaceEvent = { type: "GET_WORKSPACE"; workspaceId: string } | { type: "SET_FILTER"; query: string } +type WorkspacesEvent = { type: "GET_WORKSPACES"; query: string } | { type: "UPDATE_VERSION"; workspaceId: string } export const workspacesMachine = createMachine( { - tsTypes: {} as import("./workspacesXService.typegen").Typegen0, + tsTypes: {} as import("./workspacesXService.typegen").Typegen1, schema: { - context: {} as WorkspaceContext, - events: {} as WorkspaceEvent, + context: {} as WorkspacesContext, + events: {} as WorkspacesEvent, services: {} as { getWorkspaces: { data: TypesGen.Workspace[] } }, }, - id: "workspaceState", - initial: "ready", - states: { - ready: { - on: { - SET_FILTER: "extractingFilter", - }, + id: "workspacesState", + on: { + GET_WORKSPACES: { + actions: "assignFilter", + target: "gettingWorkspaces", }, - extractingFilter: { - entry: "assignFilter", - always: { - target: "gettingWorkspaces", - }, + UPDATE_VERSION: { + actions: "triggerUpdateVersion", + }, + }, + initial: "idle", + states: { + idle: { + tags: ["loading"], }, gettingWorkspaces: { entry: "clearGetWorkspacesError", invoke: { src: "getWorkspaces", id: "getWorkspaces", - onDone: { - target: "ready", - actions: ["assignWorkspaces", "clearGetWorkspacesError"], - }, + onDone: [ + { + target: "waitToRefreshWorkspaces", + actions: ["assignWorkspaceRefs"], + cond: "isEmpty", + }, + { + target: "waitToRefreshWorkspaces", + actions: ["updateWorkspaceRefs"], + }, + ], onError: { - target: "ready", - actions: ["assignGetWorkspacesError", "clearWorkspaces"], + target: "waitToRefreshWorkspaces", + actions: ["assignGetWorkspacesError"], }, }, - tags: "loading", + }, + waitToRefreshWorkspaces: { + after: { + 5000: "gettingWorkspaces", + }, }, }, }, { + guards: { + isEmpty: (context) => !context.workspaceRefs, + }, actions: { - assignWorkspaces: assign({ - workspaces: (_, event) => event.data, + assignWorkspaceRefs: assign({ + workspaceRefs: (_, event) => + event.data.map((data) => { + return spawn(workspaceItemMachine.withContext({ data }), data.id) + }), }), assignFilter: assign({ filter: (_, event) => event.query, @@ -67,7 +266,54 @@ export const workspacesMachine = createMachine( getWorkspacesError: (_, event) => event.data, }), clearGetWorkspacesError: (context) => assign({ ...context, getWorkspacesError: undefined }), - clearWorkspaces: (context) => assign({ ...context, workspaces: undefined }), + triggerUpdateVersion: (context, event) => { + const workspaceRef = context.workspaceRefs?.find((ref) => ref.id === event.workspaceId) + + if (!workspaceRef) { + throw new Error(`No workspace ref found for ${event.workspaceId}.`) + } + + workspaceRef.send("UPDATE_VERSION") + }, + // Opened discussion on XState https://github.com/statelyai/xstate/discussions/3406 + updateWorkspaceRefs: assign({ + workspaceRefs: (context, event) => { + let workspaceRefs = context.workspaceRefs + + if (!workspaceRefs) { + throw new Error("No workspaces loaded.") + } + + // Update the existent workspaces or create the new ones + for (const data of event.data) { + const ref = workspaceRefs.find((ref) => ref.id === data.id) + + if (!ref) { + workspaceRefs.push(spawn(workspaceItemMachine.withContext({ data }), data.id)) + } else { + ref.send({ type: "UPDATE_DATA", data }) + } + } + + // Remove workspaces that were deleted + for (const ref of workspaceRefs) { + const refData = event.data.find((workspaceData) => workspaceData.id === ref.id) + + // If there is no refData, it is because the workspace was deleted + if (!refData) { + // Stop the actor before remove it from the array + if (ref.stop) { + ref.stop() + } + + // Remove ref from the array + workspaceRefs = workspaceRefs.filter((oldRef) => oldRef.id === ref.id) + } + } + + return workspaceRefs + }, + }), }, services: { getWorkspaces: (context) => API.getWorkspaces(workspaceQueryToFilter(context.filter)),