diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 298255dddcecf..deee210e4c7b0 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -7644,13 +7644,15 @@ const docTemplate = `{ "moons", "workspace_actions", "tailnet_pg_coordinator", - "convert-to-oidc" + "convert-to-oidc", + "workspace_build_logs_ui" ], "x-enum-varnames": [ "ExperimentMoons", "ExperimentWorkspaceActions", "ExperimentTailnetPGCoordinator", - "ExperimentConvertToOIDC" + "ExperimentConvertToOIDC", + "ExperimentWorkspaceBuildLogsUI" ] }, "codersdk.Feature": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index d05a01871db27..4782d9a7e44a9 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -6840,13 +6840,15 @@ "moons", "workspace_actions", "tailnet_pg_coordinator", - "convert-to-oidc" + "convert-to-oidc", + "workspace_build_logs_ui" ], "x-enum-varnames": [ "ExperimentMoons", "ExperimentWorkspaceActions", "ExperimentTailnetPGCoordinator", - "ExperimentConvertToOIDC" + "ExperimentConvertToOIDC", + "ExperimentWorkspaceBuildLogsUI" ] }, "codersdk.Feature": { diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 88bc65ea6a5f5..add8cd0fa2ad4 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -1763,6 +1763,7 @@ const ( // oidc. ExperimentConvertToOIDC Experiment = "convert-to-oidc" + ExperimentWorkspaceBuildLogsUI Experiment = "workspace_build_logs_ui" // Add new experiments here! // ExperimentExample Experiment = "example" ) @@ -1771,7 +1772,9 @@ const ( // users to opt-in to via --experimental='*'. // Experiments that are not ready for consumption by all users should // not be included here and will be essentially hidden. -var ExperimentsAll = Experiments{} +var ExperimentsAll = Experiments{ + ExperimentWorkspaceBuildLogsUI, +} // Experiments is a list of experiments that are enabled for the deployment. // Multiple experiments may be enabled at the same time. diff --git a/docs/api/schemas.md b/docs/api/schemas.md index b51286f9a6c04..71a23ce681c89 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -2531,12 +2531,13 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in #### Enumerated Values -| Value | -| ------------------------ | -| `moons` | -| `workspace_actions` | -| `tailnet_pg_coordinator` | -| `convert-to-oidc` | +| Value | +| ------------------------- | +| `moons` | +| `workspace_actions` | +| `tailnet_pg_coordinator` | +| `convert-to-oidc` | +| `workspace_build_logs_ui` | ## codersdk.Feature diff --git a/site/.storybook/preview.jsx b/site/.storybook/preview.jsx index b84e9d25caa48..dce12e9ca603c 100644 --- a/site/.storybook/preview.jsx +++ b/site/.storybook/preview.jsx @@ -5,6 +5,7 @@ import { HelmetProvider } from "react-helmet-async" import { dark } from "../src/theme" import "../src/theme/globalFonts" import "../src/i18n" +import { LocalPreferencesProvider } from "../src/contexts/LocalPreferencesContext" export const decorators = [ (Story) => ( @@ -23,6 +24,13 @@ export const decorators = [ ) }, + (Story) => { + return ( + + + + ) + }, ] export const parameters = { diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 6e0a012a2af4e..2a475ea35b8ea 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1416,11 +1416,13 @@ export type Experiment = | "moons" | "tailnet_pg_coordinator" | "workspace_actions" + | "workspace_build_logs_ui" export const Experiments: Experiment[] = [ "convert-to-oidc", "moons", "tailnet_pg_coordinator", "workspace_actions", + "workspace_build_logs_ui", ] // From codersdk/deployment.go diff --git a/site/src/app.tsx b/site/src/app.tsx index af58e5641b2b0..1b14863e6b3f9 100644 --- a/site/src/app.tsx +++ b/site/src/app.tsx @@ -9,6 +9,7 @@ import { GlobalSnackbar } from "./components/GlobalSnackbar/GlobalSnackbar" import { dark } from "./theme" import "./theme/globalFonts" import { StyledEngineProvider, ThemeProvider } from "@mui/material/styles" +import { LocalPreferencesProvider } from "contexts/LocalPreferencesContext" const queryClient = new QueryClient({ defaultOptions: { @@ -25,17 +26,19 @@ export const AppProviders: FC = ({ children }) => { return ( - - - - - - {children} - - - - - + + + + + + + {children} + + + + + + ) diff --git a/site/src/components/Logs/Logs.tsx b/site/src/components/Logs/Logs.tsx index 217b254533464..2cf345db16671 100644 --- a/site/src/components/Logs/Logs.tsx +++ b/site/src/components/Logs/Logs.tsx @@ -97,10 +97,11 @@ const useStyles = makeStyles< >((theme) => ({ root: { minHeight: 156, - padding: theme.spacing(2, 0), + padding: theme.spacing(1, 0), borderRadius: theme.shape.borderRadius, overflowX: "auto", background: theme.palette.background.default, + borderBottom: `1px solid ${theme.palette.divider}`, }, scrollWrapper: { minWidth: "fit-content", diff --git a/site/src/components/TemplateVersionEditor/TemplateVersionEditor.tsx b/site/src/components/TemplateVersionEditor/TemplateVersionEditor.tsx index 4f248160982cf..0855c7dd57250 100644 --- a/site/src/components/TemplateVersionEditor/TemplateVersionEditor.tsx +++ b/site/src/components/TemplateVersionEditor/TemplateVersionEditor.tsx @@ -379,7 +379,7 @@ export const TemplateVersionEditor: FC = ({ {buildLogs && buildLogs.length > 0 && ( diff --git a/site/src/components/Workspace/Workspace.stories.tsx b/site/src/components/Workspace/Workspace.stories.tsx index 70b022f1ecfb2..ef18cd1aef19f 100644 --- a/site/src/components/Workspace/Workspace.stories.tsx +++ b/site/src/components/Workspace/Workspace.stories.tsx @@ -8,6 +8,7 @@ import { withReactContext } from "storybook-react-context" import EventSource from "eventsourcemock" import { ProxyContext, getPreferredProxy } from "contexts/ProxyContext" import { DashboardProviderContext } from "components/Dashboard/DashboardProvider" +import { WorkspaceBuildLogsSection } from "pages/WorkspacePage/WorkspaceBuildLogsSection" const MockedAppearance = { config: Mocks.MockAppearance, @@ -152,7 +153,7 @@ export const FailedWithLogs: Story = { }, }, }, - failedBuildLogs: makeFailedBuildLogs(), + buildLogs: , }, } @@ -170,8 +171,8 @@ export const FailedWithRetry: Story = { }, }, }, - failedBuildLogs: makeFailedBuildLogs(), canRetryDebugMode: true, + buildLogs: , }, } @@ -229,6 +230,7 @@ export const CancellationError: Story = { message: "Job could not be canceled.", }), }, + buildLogs: , }, } diff --git a/site/src/components/Workspace/Workspace.tsx b/site/src/components/Workspace/Workspace.tsx index 2178ca34f2ca3..7fa5e68e66d03 100644 --- a/site/src/components/Workspace/Workspace.tsx +++ b/site/src/components/Workspace/Workspace.tsx @@ -2,7 +2,6 @@ import Button from "@mui/material/Button" import { makeStyles } from "@mui/styles" import { Avatar } from "components/Avatar/Avatar" import { AgentRow } from "components/Resources/AgentRow" -import { WorkspaceBuildLogs } from "components/WorkspaceBuildLogs/WorkspaceBuildLogs" import { ActiveTransition, WorkspaceBuildProgress, @@ -70,8 +69,10 @@ export interface WorkspaceProps { sshPrefix?: string template?: TypesGen.Template quota_budget?: number - failedBuildLogs: TypesGen.ProvisionerJobLog[] | undefined handleBuildRetry: () => void + buildLogs?: React.ReactNode + canChangeBuildLogsVisibility: boolean + isWorkspaceBuildLogsUIActive: boolean } /** @@ -102,9 +103,11 @@ export const Workspace: FC> = ({ sshPrefix, template, quota_budget, - failedBuildLogs, handleBuildRetry, templateWarnings, + buildLogs, + canChangeBuildLogsVisibility, + isWorkspaceBuildLogsUIActive, }) => { const styles = useStyles() const navigate = useNavigate() @@ -208,6 +211,8 @@ export const Workspace: FC> = ({ canChangeVersions={canChangeVersions} isUpdating={isUpdating} isRestarting={isRestarting} + canChangeBuildLogsVisibility={canChangeBuildLogsVisibility} + isWorkspaceBuildLogsUIActive={isWorkspaceBuildLogsUIActive} /> @@ -259,28 +264,25 @@ export const Workspace: FC> = ({ - {failedBuildLogs && ( - - - {t("actionButton.retryDebugMode")} - - ) - } - > - Workspace build failed - {workspace.latest_build.job.error} - - - + {workspace.latest_build.job.error && ( + + {t("actionButton.retryDebugMode")} + + ) + } + > + Workspace build failed + {workspace.latest_build.job.error} + )} {transitionStats !== undefined && ( @@ -290,6 +292,8 @@ export const Workspace: FC> = ({ /> )} + {buildLogs} + {typeof resources !== "undefined" && resources.length > 0 && ( = ({ @@ -54,6 +60,8 @@ export const WorkspaceActions: FC = ({ isUpdating, isRestarting, canChangeVersions, + canChangeBuildLogsVisibility, + isWorkspaceBuildLogsUIActive, }) => { const styles = useStyles() const { @@ -64,6 +72,9 @@ export const WorkspaceActions: FC = ({ const canBeUpdated = isOutdated && canAcceptJobs const menuTriggerRef = useRef(null) const [isMenuOpen, setIsMenuOpen] = useState(false) + const localPreferences = useLocalPreferences() + const isBuildLogsVisible = + localPreferences.getPreference("buildLogsVisibility") === "visible" // A mapping of button type to the corresponding React component const buttonMapping: ButtonMapping = { @@ -140,6 +151,39 @@ export const WorkspaceActions: FC = ({ Delete + + {isWorkspaceBuildLogsUIActive && ( + <> + theme.palette.divider }} /> + {isBuildLogsVisible ? ( + { + localPreferences.setPreference( + "buildLogsVisibility", + "hide", + ) + })} + > + + Hide build logs + + ) : ( + { + localPreferences.setPreference( + "buildLogsVisibility", + "visible", + ) + })} + > + + Show build logs + + )} + + )} diff --git a/site/src/components/WorkspaceBuildLogs/WorkspaceBuildLogs.tsx b/site/src/components/WorkspaceBuildLogs/WorkspaceBuildLogs.tsx index 8b9ba6e6f9746..02e608e57b27e 100644 --- a/site/src/components/WorkspaceBuildLogs/WorkspaceBuildLogs.tsx +++ b/site/src/components/WorkspaceBuildLogs/WorkspaceBuildLogs.tsx @@ -1,10 +1,10 @@ import { makeStyles } from "@mui/styles" import dayjs from "dayjs" -import { FC, Fragment } from "react" +import { ComponentProps, FC, Fragment } from "react" import { ProvisionerJobLog } from "../../api/typesGenerated" import { MONOSPACE_FONT_FAMILY } from "../../theme/constants" import { Logs } from "../Logs/Logs" -import { Theme } from "@mui/material/styles" +import Box from "@mui/material/Box" const Language = { seconds: "seconds", @@ -38,26 +38,30 @@ const getStageDurationInSeconds = (logs: ProvisionerJobLog[]) => { return completedAt.diff(startedAt, "seconds") } -export interface WorkspaceBuildLogsProps { +export type WorkspaceBuildLogsProps = { logs: ProvisionerJobLog[] hideTimestamps?: boolean - - // If true, render different styles that fit the template editor pane - // a bit better. - templateEditorPane?: boolean -} +} & ComponentProps export const WorkspaceBuildLogs: FC = ({ hideTimestamps, logs, - templateEditorPane, + ...boxProps }) => { const groupedLogsByStage = groupLogsByStage(logs) const stages = Object.keys(groupedLogsByStage) - const styles = useStyles({ templateEditorPane: Boolean(templateEditorPane) }) + const styles = useStyles() return ( -
+ `1px solid ${theme.palette.divider}`, + borderRadius: 1, + fontFamily: MONOSPACE_FONT_FAMILY, + ...boxProps.sx, + }} + > {stages.map((stage) => { const logs = groupedLogsByStage[stage] const isEmpty = logs.every((log) => log.output === "") @@ -83,56 +87,36 @@ export const WorkspaceBuildLogs: FC = ({ ) })} -
+ ) } -const useStyles = makeStyles< - Theme, - { - templateEditorPane: boolean - } ->((theme) => ({ - logs: { - border: `1px solid ${theme.palette.divider}`, - borderRadius: (props) => - props.templateEditorPane ? "0px" : theme.shape.borderRadius, - fontFamily: MONOSPACE_FONT_FAMILY, - }, - +const useStyles = makeStyles((theme) => ({ header: { - fontSize: 14, - padding: theme.spacing(2), - paddingLeft: theme.spacing(3), - paddingRight: theme.spacing(3), - borderTop: `1px solid ${theme.palette.divider}`, - borderBottom: `1px solid ${theme.palette.divider}`, - backgroundColor: theme.palette.background.paper, + fontSize: 13, + fontWeight: 600, + padding: theme.spacing(0.5, 3), display: "flex", alignItems: "center", fontFamily: "Inter", - - "&:first-of-type": { - borderTopLeftRadius: theme.shape.borderRadius, - borderTopRightRadius: theme.shape.borderRadius, - borderTop: 0, - }, + borderBottom: `1px solid ${theme.palette.divider}`, + position: "sticky", + top: 0, + background: theme.palette.background.default, "&:last-child": { borderBottom: 0, - borderTop: `1px solid ${theme.palette.divider}`, - borderBottomLeftRadius: theme.shape.borderRadius, - borderBottomRightRadius: theme.shape.borderRadius, + borderRadius: "0 0 8px 8px", }, - "& + $header": { - borderTop: 0, + "&:first-child": { + borderRadius: "8px 8px 0 0", }, }, duration: { marginLeft: "auto", color: theme.palette.text.secondary, - fontSize: theme.typography.body2.fontSize, + fontSize: 12, }, })) diff --git a/site/src/contexts/LocalPreferencesContext.tsx b/site/src/contexts/LocalPreferencesContext.tsx new file mode 100644 index 0000000000000..164fff495fdfd --- /dev/null +++ b/site/src/contexts/LocalPreferencesContext.tsx @@ -0,0 +1,111 @@ +import { + ReactNode, + createContext, + useCallback, + useContext, + useEffect, + useState, +} from "react" + +const LOCAL_PREFERENCES_KEY = "local-preferences" + +const defaultValues = { + buildLogsVisibility: "visible" as "visible" | "hide", +} + +type LocalPreferencesValues = typeof defaultValues +type LocalPreference = keyof LocalPreferencesValues +type LocalPreferenceContextValues = { + values: LocalPreferencesValues + getPreference: ( + name: LocalPreference, + ) => LocalPreferencesValues[LocalPreference] + setPreference: ( + name: LocalPreference, + value: LocalPreferencesValues[LocalPreference], + ) => void +} + +const LocalPreferencesContext = createContext< + LocalPreferenceContextValues | undefined +>(undefined) + +export const LocalPreferencesProvider = ({ + children, +}: { + children: ReactNode +}) => { + const [state, setState] = useState<{ + ready: boolean + values: LocalPreferencesValues + }>({ ready: false, values: defaultValues }) + + useEffect(() => { + const preferencesStr = window.localStorage.getItem(LOCAL_PREFERENCES_KEY) + if (preferencesStr) { + try { + const values = JSON.parse(preferencesStr) + setState({ ...values, ready: true }) + return + } catch (error) { + console.warn( + "Error on parsing local preferences. Default values are used.", + ) + } + } + + setState((state) => ({ ...state, ready: true })) + }, []) + + const getPreference: LocalPreferenceContextValues["getPreference"] = + useCallback( + (name) => { + return state.values[name] + }, + [state.values], + ) + + const setPreference: LocalPreferenceContextValues["setPreference"] = + useCallback((name, value) => { + setState((state) => { + const newState = { + ...state, + values: { + ...state.values, + [name]: value, + }, + } + window.localStorage.setItem( + LOCAL_PREFERENCES_KEY, + JSON.stringify(newState), + ) + return newState + }) + }, []) + + return ( + + {children} + + ) +} + +export const useLocalPreferences = () => { + const context = useContext(LocalPreferencesContext) + if (context === undefined) { + throw new Error( + "useLocalPreference must be used within a LocalPreferenceProvider", + ) + } + return context +} diff --git a/site/src/hooks/useLocalStorage.ts b/site/src/hooks/useLocalStorage.ts index 193e4b8f64edd..bae3bf5fa7653 100644 --- a/site/src/hooks/useLocalStorage.ts +++ b/site/src/hooks/useLocalStorage.ts @@ -1,10 +1,4 @@ -interface UseLocalStorage { - saveLocal: (arg0: string, arg1: string) => void - getLocal: (arg0: string) => string | undefined - clearLocal: (arg0: string) => void -} - -export const useLocalStorage = (): UseLocalStorage => { +export const useLocalStorage = () => { return { saveLocal, getLocal, diff --git a/site/src/pages/WorkspacePage/WorkspaceBuildLogsSection.tsx b/site/src/pages/WorkspacePage/WorkspaceBuildLogsSection.tsx new file mode 100644 index 0000000000000..acc5208093213 --- /dev/null +++ b/site/src/pages/WorkspacePage/WorkspaceBuildLogsSection.tsx @@ -0,0 +1,90 @@ +import CloseOutlined from "@mui/icons-material/CloseOutlined" +import Box from "@mui/material/Box" +import IconButton from "@mui/material/IconButton" +import Tooltip from "@mui/material/Tooltip" +import { ProvisionerJobLog } from "api/typesGenerated" +import { Loader } from "components/Loader/Loader" +import { WorkspaceBuildLogs } from "components/WorkspaceBuildLogs/WorkspaceBuildLogs" +import { useRef, useEffect } from "react" + +export const WorkspaceBuildLogsSection = ({ + logs, + onHide, +}: { + logs: ProvisionerJobLog[] | undefined + onHide?: () => void +}) => { + const scrollRef = useRef(null) + + useEffect(() => { + const scrollEl = scrollRef.current + if (scrollEl) { + scrollEl.scrollTop = scrollEl.scrollHeight + } + }, [logs]) + + return ( + ({ + borderRadius: 1, + border: `1px solid ${theme.palette.divider}`, + })} + > + ({ + background: theme.palette.background.paper, + borderBottom: `1px solid ${theme.palette.divider}`, + padding: theme.spacing(1, 1, 1, 3), + fontSize: 13, + fontWeight: 600, + display: "flex", + alignItems: "center", + borderRadius: "8px 8px 0 0", + })} + > + Build logs + {onHide && ( + + + ({ + color: theme.palette.text.secondary, + "&:hover": { + color: theme.palette.text.primary, + }, + })} + > + + + + + )} + + ({ + height: "400px", + overflowY: "auto", + })} + > + {logs ? ( + + ) : ( + + + + )} + + + ) +} diff --git a/site/src/pages/WorkspacePage/WorkspacePage.tsx b/site/src/pages/WorkspacePage/WorkspacePage.tsx index f0c0c2844afae..43a1944207a78 100644 --- a/site/src/pages/WorkspacePage/WorkspacePage.tsx +++ b/site/src/pages/WorkspacePage/WorkspacePage.tsx @@ -1,10 +1,7 @@ -import { useQuery } from "@tanstack/react-query" import { useMachine } from "@xstate/react" -import { getWorkspaceBuildLogs } from "api/api" -import { Workspace } from "api/typesGenerated" import { ChooseOne, Cond } from "components/Conditionals/ChooseOne" import { Loader } from "components/Loader/Loader" -import { FC, useRef } from "react" +import { FC } from "react" import { useParams } from "react-router-dom" import { quotaMachine } from "xServices/quotas/quotasXService" import { workspaceMachine } from "xServices/workspace/workspaceXService" @@ -15,23 +12,6 @@ import { useOrganizationId } from "hooks" import { isAxiosError } from "axios" import { Margins } from "components/Margins/Margins" -const useFailedBuildLogs = (workspace: Workspace | undefined) => { - const now = useRef(new Date()) - return useQuery({ - queryKey: ["logs", workspace?.latest_build.id], - queryFn: () => { - if (!workspace) { - throw new Error( - `Build log query being called before workspace is defined`, - ) - } - - return getWorkspaceBuildLogs(workspace.latest_build.id, now.current) - }, - enabled: workspace?.latest_build.job.error !== undefined, - }) -} - export const WorkspacePage: FC = () => { const params = useParams() as { username: string @@ -50,7 +30,6 @@ export const WorkspacePage: FC = () => { const { workspace, error } = workspaceState.context const [quotaState] = useMachine(quotaMachine, { context: { username } }) const { getQuotaError } = quotaState.context - const failedBuildLogs = useFailedBuildLogs(workspace) const pageError = error ?? getQuotaError return ( @@ -73,7 +52,6 @@ export const WorkspacePage: FC = () => { } > quotaState: StateFrom workspaceSend: (event: WorkspaceEvent) => void - failedBuildLogs: ProvisionerJobLog[] | undefined } export const WorkspaceReadyPage = ({ workspaceState, quotaState, - failedBuildLogs, workspaceSend, }: WorkspaceReadyPageProps): JSX.Element => { const [_, bannerSend] = useActor( @@ -92,6 +93,17 @@ export const WorkspaceReadyPage = ({ const [isConfirmingRestart, setIsConfirmingRestart] = useState(false) const user = useMe() const { isWarningIgnored, ignoreWarning } = useIgnoreWarnings(user.id) + const buildLogs = useBuildLogs(workspace) + const localPreferences = useLocalPreferences() + const dashboard = useDashboard() + const canChangeBuildLogsVisibility = !hasJobError(workspace) + const isWorkspaceBuildLogsUIActive = dashboard.experiments.includes( + "workspace_build_logs_ui", + ) + const shouldDisplayBuildLogs = + hasJobError(workspace) || + (localPreferences.getPreference("buildLogsVisibility") === "visible" && + isWorkspaceBuildLogsUIActive) const { mutate: restartWorkspace, @@ -121,7 +133,6 @@ export const WorkspaceReadyPage = ({ { bannerSend({ @@ -184,6 +195,20 @@ export const WorkspaceReadyPage = ({ template={template} quota_budget={quotaState.context.quota?.budget} templateWarnings={templateVersion?.warnings} + canChangeBuildLogsVisibility={canChangeBuildLogsVisibility} + isWorkspaceBuildLogsUIActive={isWorkspaceBuildLogsUIActive} + buildLogs={ + shouldDisplayBuildLogs && ( + { + if (canChangeBuildLogsVisibility) { + localPreferences.setPreference("buildLogsVisibility", "hide") + } + }} + /> + ) + } /> ) } + +const useBuildLogs = (workspace: TypesGen.Workspace) => { + const buildNumber = workspace.latest_build.build_number.toString() + const [buildState, buildSend] = useMachine(workspaceBuildMachine, { + context: { + buildNumber, + username: workspace.owner_name, + workspaceName: workspace.name, + timeCursor: new Date(), + }, + }) + const { logs } = buildState.context + + useEffect(() => { + buildSend({ type: "RESET", buildNumber, timeCursor: new Date() }) + }, [buildNumber, buildSend]) + + return logs +} diff --git a/site/src/utils/workspace.tsx b/site/src/utils/workspace.tsx index f366eaa21a2cd..30e3409ccd7ed 100644 --- a/site/src/utils/workspace.tsx +++ b/site/src/utils/workspace.tsx @@ -281,3 +281,7 @@ const getPendingWorkspaceStatusText = ( const LoadingIcon = () => { return } + +export const hasJobError = (workspace: TypesGen.Workspace) => { + return workspace.latest_build.job.error !== undefined +} diff --git a/site/src/xServices/workspaceBuild/workspaceBuildXService.ts b/site/src/xServices/workspaceBuild/workspaceBuildXService.ts index 1ec87eb213154..58f6b6fcc5399 100644 --- a/site/src/xServices/workspaceBuild/workspaceBuildXService.ts +++ b/site/src/xServices/workspaceBuild/workspaceBuildXService.ts @@ -24,6 +24,11 @@ type LogsEvent = | { type: "BUILD_DONE" } + | { + type: "RESET" + buildNumber: string + timeCursor: Date + } export const workspaceBuildMachine = createMachine( { @@ -43,6 +48,12 @@ export const workspaceBuildMachine = createMachine( }, }, initial: "gettingBuild", + on: { + RESET: { + target: "gettingBuild", + actions: ["resetContext"], + }, + }, states: { gettingBuild: { entry: "clearGetBuildError", @@ -96,6 +107,11 @@ export const workspaceBuildMachine = createMachine( }, { actions: { + resetContext: assign({ + buildNumber: (_, event) => event.buildNumber, + timeCursor: (_, event) => event.timeCursor, + logs: undefined, + }), // Build ID assignBuildId: assign({ buildId: (_, event) => event.data.id, @@ -134,8 +150,8 @@ export const workspaceBuildMachine = createMachine( if (!ctx.logs) { throw new Error("logs must be set") } - - const after = ctx.logs[ctx.logs.length - 1].id + const after = + ctx.logs.length > 0 ? ctx.logs[ctx.logs.length - 1].id : undefined const socket = API.watchBuildLogsByBuildId(ctx.buildId, { after, onMessage: (log) => {