feat(site): display build logs on workspace transitioning statuses (#8397)

This commit is contained in:
Bruno Quaresma
2023-07-10 17:47:39 -03:00
committed by GitHub
parent b7641b219e
commit d896b74fa2
20 changed files with 426 additions and 133 deletions

6
coderd/apidoc/docs.go generated
View File

@ -7609,13 +7609,15 @@ const docTemplate = `{
"moons", "moons",
"workspace_actions", "workspace_actions",
"tailnet_pg_coordinator", "tailnet_pg_coordinator",
"convert-to-oidc" "convert-to-oidc",
"workspace_build_logs_ui"
], ],
"x-enum-varnames": [ "x-enum-varnames": [
"ExperimentMoons", "ExperimentMoons",
"ExperimentWorkspaceActions", "ExperimentWorkspaceActions",
"ExperimentTailnetPGCoordinator", "ExperimentTailnetPGCoordinator",
"ExperimentConvertToOIDC" "ExperimentConvertToOIDC",
"ExperimentWorkspaceBuildLogsUI"
] ]
}, },
"codersdk.Feature": { "codersdk.Feature": {

View File

@ -6810,13 +6810,15 @@
"moons", "moons",
"workspace_actions", "workspace_actions",
"tailnet_pg_coordinator", "tailnet_pg_coordinator",
"convert-to-oidc" "convert-to-oidc",
"workspace_build_logs_ui"
], ],
"x-enum-varnames": [ "x-enum-varnames": [
"ExperimentMoons", "ExperimentMoons",
"ExperimentWorkspaceActions", "ExperimentWorkspaceActions",
"ExperimentTailnetPGCoordinator", "ExperimentTailnetPGCoordinator",
"ExperimentConvertToOIDC" "ExperimentConvertToOIDC",
"ExperimentWorkspaceBuildLogsUI"
] ]
}, },
"codersdk.Feature": { "codersdk.Feature": {

View File

@ -1763,6 +1763,7 @@ const (
// oidc. // oidc.
ExperimentConvertToOIDC Experiment = "convert-to-oidc" ExperimentConvertToOIDC Experiment = "convert-to-oidc"
ExperimentWorkspaceBuildLogsUI Experiment = "workspace_build_logs_ui"
// Add new experiments here! // Add new experiments here!
// ExperimentExample Experiment = "example" // ExperimentExample Experiment = "example"
) )
@ -1771,7 +1772,9 @@ const (
// users to opt-in to via --experimental='*'. // users to opt-in to via --experimental='*'.
// Experiments that are not ready for consumption by all users should // Experiments that are not ready for consumption by all users should
// not be included here and will be essentially hidden. // 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. // Experiments is a list of experiments that are enabled for the deployment.
// Multiple experiments may be enabled at the same time. // Multiple experiments may be enabled at the same time.

View File

@ -2531,12 +2531,13 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
#### Enumerated Values #### Enumerated Values
| Value | | Value |
| ------------------------ | | ------------------------- |
| `moons` | | `moons` |
| `workspace_actions` | | `workspace_actions` |
| `tailnet_pg_coordinator` | | `tailnet_pg_coordinator` |
| `convert-to-oidc` | | `convert-to-oidc` |
| `workspace_build_logs_ui` |
## codersdk.Feature ## codersdk.Feature

View File

@ -5,6 +5,7 @@ import { HelmetProvider } from "react-helmet-async"
import { dark } from "../src/theme" import { dark } from "../src/theme"
import "../src/theme/globalFonts" import "../src/theme/globalFonts"
import "../src/i18n" import "../src/i18n"
import { LocalPreferencesProvider } from "../src/contexts/LocalPreferencesContext"
export const decorators = [ export const decorators = [
(Story) => ( (Story) => (
@ -23,6 +24,13 @@ export const decorators = [
</HelmetProvider> </HelmetProvider>
) )
}, },
(Story) => {
return (
<LocalPreferencesProvider>
<Story />
</LocalPreferencesProvider>
)
},
] ]
export const parameters = { export const parameters = {

View File

@ -1431,11 +1431,13 @@ export type Experiment =
| "moons" | "moons"
| "tailnet_pg_coordinator" | "tailnet_pg_coordinator"
| "workspace_actions" | "workspace_actions"
| "workspace_build_logs_ui"
export const Experiments: Experiment[] = [ export const Experiments: Experiment[] = [
"convert-to-oidc", "convert-to-oidc",
"moons", "moons",
"tailnet_pg_coordinator", "tailnet_pg_coordinator",
"workspace_actions", "workspace_actions",
"workspace_build_logs_ui",
] ]
// From codersdk/deployment.go // From codersdk/deployment.go

View File

@ -9,6 +9,7 @@ import { GlobalSnackbar } from "./components/GlobalSnackbar/GlobalSnackbar"
import { dark } from "./theme" import { dark } from "./theme"
import "./theme/globalFonts" import "./theme/globalFonts"
import { StyledEngineProvider, ThemeProvider } from "@mui/material/styles" import { StyledEngineProvider, ThemeProvider } from "@mui/material/styles"
import { LocalPreferencesProvider } from "contexts/LocalPreferencesContext"
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
@ -25,17 +26,19 @@ export const AppProviders: FC<PropsWithChildren> = ({ children }) => {
return ( return (
<HelmetProvider> <HelmetProvider>
<StyledEngineProvider injectFirst> <StyledEngineProvider injectFirst>
<ThemeProvider theme={dark}> <LocalPreferencesProvider>
<CssBaseline enableColorScheme /> <ThemeProvider theme={dark}>
<ErrorBoundary> <CssBaseline enableColorScheme />
<QueryClientProvider client={queryClient}> <ErrorBoundary>
<AuthProvider> <QueryClientProvider client={queryClient}>
{children} <AuthProvider>
<GlobalSnackbar /> {children}
</AuthProvider> <GlobalSnackbar />
</QueryClientProvider> </AuthProvider>
</ErrorBoundary> </QueryClientProvider>
</ThemeProvider> </ErrorBoundary>
</ThemeProvider>
</LocalPreferencesProvider>
</StyledEngineProvider> </StyledEngineProvider>
</HelmetProvider> </HelmetProvider>
) )

View File

@ -97,10 +97,11 @@ const useStyles = makeStyles<
>((theme) => ({ >((theme) => ({
root: { root: {
minHeight: 156, minHeight: 156,
padding: theme.spacing(2, 0), padding: theme.spacing(1, 0),
borderRadius: theme.shape.borderRadius, borderRadius: theme.shape.borderRadius,
overflowX: "auto", overflowX: "auto",
background: theme.palette.background.default, background: theme.palette.background.default,
borderBottom: `1px solid ${theme.palette.divider}`,
}, },
scrollWrapper: { scrollWrapper: {
minWidth: "fit-content", minWidth: "fit-content",

View File

@ -379,7 +379,7 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
{buildLogs && buildLogs.length > 0 && ( {buildLogs && buildLogs.length > 0 && (
<WorkspaceBuildLogs <WorkspaceBuildLogs
templateEditorPane sx={{ borderRadius: 0 }}
hideTimestamps hideTimestamps
logs={buildLogs} logs={buildLogs}
/> />

View File

@ -8,6 +8,7 @@ import { withReactContext } from "storybook-react-context"
import EventSource from "eventsourcemock" import EventSource from "eventsourcemock"
import { ProxyContext, getPreferredProxy } from "contexts/ProxyContext" import { ProxyContext, getPreferredProxy } from "contexts/ProxyContext"
import { DashboardProviderContext } from "components/Dashboard/DashboardProvider" import { DashboardProviderContext } from "components/Dashboard/DashboardProvider"
import { WorkspaceBuildLogsSection } from "pages/WorkspacePage/WorkspaceBuildLogsSection"
const MockedAppearance = { const MockedAppearance = {
config: Mocks.MockAppearance, config: Mocks.MockAppearance,
@ -152,7 +153,7 @@ export const FailedWithLogs: Story = {
}, },
}, },
}, },
failedBuildLogs: makeFailedBuildLogs(), buildLogs: <WorkspaceBuildLogsSection logs={makeFailedBuildLogs()} />,
}, },
} }
@ -170,8 +171,8 @@ export const FailedWithRetry: Story = {
}, },
}, },
}, },
failedBuildLogs: makeFailedBuildLogs(),
canRetryDebugMode: true, canRetryDebugMode: true,
buildLogs: <WorkspaceBuildLogsSection logs={makeFailedBuildLogs()} />,
}, },
} }
@ -229,6 +230,7 @@ export const CancellationError: Story = {
message: "Job could not be canceled.", message: "Job could not be canceled.",
}), }),
}, },
buildLogs: <WorkspaceBuildLogsSection logs={makeFailedBuildLogs()} />,
}, },
} }

View File

@ -2,7 +2,6 @@ import Button from "@mui/material/Button"
import { makeStyles } from "@mui/styles" import { makeStyles } from "@mui/styles"
import { Avatar } from "components/Avatar/Avatar" import { Avatar } from "components/Avatar/Avatar"
import { AgentRow } from "components/Resources/AgentRow" import { AgentRow } from "components/Resources/AgentRow"
import { WorkspaceBuildLogs } from "components/WorkspaceBuildLogs/WorkspaceBuildLogs"
import { import {
ActiveTransition, ActiveTransition,
WorkspaceBuildProgress, WorkspaceBuildProgress,
@ -70,8 +69,10 @@ export interface WorkspaceProps {
sshPrefix?: string sshPrefix?: string
template?: TypesGen.Template template?: TypesGen.Template
quota_budget?: number quota_budget?: number
failedBuildLogs: TypesGen.ProvisionerJobLog[] | undefined
handleBuildRetry: () => void handleBuildRetry: () => void
buildLogs?: React.ReactNode
canChangeBuildLogsVisibility: boolean
isWorkspaceBuildLogsUIActive: boolean
} }
/** /**
@ -102,9 +103,11 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
sshPrefix, sshPrefix,
template, template,
quota_budget, quota_budget,
failedBuildLogs,
handleBuildRetry, handleBuildRetry,
templateWarnings, templateWarnings,
buildLogs,
canChangeBuildLogsVisibility,
isWorkspaceBuildLogsUIActive,
}) => { }) => {
const styles = useStyles() const styles = useStyles()
const navigate = useNavigate() const navigate = useNavigate()
@ -208,6 +211,8 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
canChangeVersions={canChangeVersions} canChangeVersions={canChangeVersions}
isUpdating={isUpdating} isUpdating={isUpdating}
isRestarting={isRestarting} isRestarting={isRestarting}
canChangeBuildLogsVisibility={canChangeBuildLogsVisibility}
isWorkspaceBuildLogsUIActive={isWorkspaceBuildLogsUIActive}
/> />
</PageHeaderActions> </PageHeaderActions>
</FullWidthPageHeader> </FullWidthPageHeader>
@ -259,28 +264,25 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
</Alert> </Alert>
</Maybe> </Maybe>
{failedBuildLogs && ( {workspace.latest_build.job.error && (
<Stack> <Alert
<Alert severity="error"
severity="error" actions={
actions={ canRetryDebugMode && (
canRetryDebugMode && ( <Button
<Button key={0}
key={0} onClick={handleBuildRetry}
onClick={handleBuildRetry} variant="text"
variant="text" size="small"
size="small" >
> {t("actionButton.retryDebugMode")}
{t("actionButton.retryDebugMode")} </Button>
</Button> )
) }
} >
> <AlertTitle>Workspace build failed</AlertTitle>
<AlertTitle>Workspace build failed</AlertTitle> <AlertDetail>{workspace.latest_build.job.error}</AlertDetail>
<AlertDetail>{workspace.latest_build.job.error}</AlertDetail> </Alert>
</Alert>
<WorkspaceBuildLogs logs={failedBuildLogs} />
</Stack>
)} )}
{transitionStats !== undefined && ( {transitionStats !== undefined && (
@ -290,6 +292,8 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
/> />
)} )}
{buildLogs}
{typeof resources !== "undefined" && resources.length > 0 && ( {typeof resources !== "undefined" && resources.length > 0 && (
<Resources <Resources
resources={resources} resources={resources}

View File

@ -22,6 +22,10 @@ import SettingsOutlined from "@mui/icons-material/SettingsOutlined"
import HistoryOutlined from "@mui/icons-material/HistoryOutlined" import HistoryOutlined from "@mui/icons-material/HistoryOutlined"
import DeleteOutlined from "@mui/icons-material/DeleteOutlined" import DeleteOutlined from "@mui/icons-material/DeleteOutlined"
import IconButton from "@mui/material/IconButton" import IconButton from "@mui/material/IconButton"
import Divider from "@mui/material/Divider"
import VisibilityOffOutlined from "@mui/icons-material/VisibilityOffOutlined"
import VisibilityOutlined from "@mui/icons-material/VisibilityOutlined"
import { useLocalPreferences } from "contexts/LocalPreferencesContext"
export interface WorkspaceActionsProps { export interface WorkspaceActionsProps {
workspaceStatus: WorkspaceStatus workspaceStatus: WorkspaceStatus
@ -38,6 +42,8 @@ export interface WorkspaceActionsProps {
isRestarting: boolean isRestarting: boolean
children?: ReactNode children?: ReactNode
canChangeVersions: boolean canChangeVersions: boolean
canChangeBuildLogsVisibility: boolean
isWorkspaceBuildLogsUIActive: boolean
} }
export const WorkspaceActions: FC<WorkspaceActionsProps> = ({ export const WorkspaceActions: FC<WorkspaceActionsProps> = ({
@ -54,6 +60,8 @@ export const WorkspaceActions: FC<WorkspaceActionsProps> = ({
isUpdating, isUpdating,
isRestarting, isRestarting,
canChangeVersions, canChangeVersions,
canChangeBuildLogsVisibility,
isWorkspaceBuildLogsUIActive,
}) => { }) => {
const styles = useStyles() const styles = useStyles()
const { const {
@ -64,6 +72,9 @@ export const WorkspaceActions: FC<WorkspaceActionsProps> = ({
const canBeUpdated = isOutdated && canAcceptJobs const canBeUpdated = isOutdated && canAcceptJobs
const menuTriggerRef = useRef<HTMLButtonElement>(null) const menuTriggerRef = useRef<HTMLButtonElement>(null)
const [isMenuOpen, setIsMenuOpen] = useState(false) const [isMenuOpen, setIsMenuOpen] = useState(false)
const localPreferences = useLocalPreferences()
const isBuildLogsVisible =
localPreferences.getPreference("buildLogsVisibility") === "visible"
// A mapping of button type to the corresponding React component // A mapping of button type to the corresponding React component
const buttonMapping: ButtonMapping = { const buttonMapping: ButtonMapping = {
@ -140,6 +151,39 @@ export const WorkspaceActions: FC<WorkspaceActionsProps> = ({
<DeleteOutlined /> <DeleteOutlined />
Delete Delete
</MenuItem> </MenuItem>
{isWorkspaceBuildLogsUIActive && (
<>
<Divider sx={{ borderColor: (theme) => theme.palette.divider }} />
{isBuildLogsVisible ? (
<MenuItem
disabled={!canChangeBuildLogsVisibility}
onClick={onMenuItemClick(() => {
localPreferences.setPreference(
"buildLogsVisibility",
"hide",
)
})}
>
<VisibilityOffOutlined />
Hide build logs
</MenuItem>
) : (
<MenuItem
disabled={!canChangeBuildLogsVisibility}
onClick={onMenuItemClick(() => {
localPreferences.setPreference(
"buildLogsVisibility",
"visible",
)
})}
>
<VisibilityOutlined />
Show build logs
</MenuItem>
)}
</>
)}
</Menu> </Menu>
</div> </div>
</div> </div>

View File

@ -1,10 +1,10 @@
import { makeStyles } from "@mui/styles" import { makeStyles } from "@mui/styles"
import dayjs from "dayjs" import dayjs from "dayjs"
import { FC, Fragment } from "react" import { ComponentProps, FC, Fragment } from "react"
import { ProvisionerJobLog } from "../../api/typesGenerated" import { ProvisionerJobLog } from "../../api/typesGenerated"
import { MONOSPACE_FONT_FAMILY } from "../../theme/constants" import { MONOSPACE_FONT_FAMILY } from "../../theme/constants"
import { Logs } from "../Logs/Logs" import { Logs } from "../Logs/Logs"
import { Theme } from "@mui/material/styles" import Box from "@mui/material/Box"
const Language = { const Language = {
seconds: "seconds", seconds: "seconds",
@ -38,26 +38,30 @@ const getStageDurationInSeconds = (logs: ProvisionerJobLog[]) => {
return completedAt.diff(startedAt, "seconds") return completedAt.diff(startedAt, "seconds")
} }
export interface WorkspaceBuildLogsProps { export type WorkspaceBuildLogsProps = {
logs: ProvisionerJobLog[] logs: ProvisionerJobLog[]
hideTimestamps?: boolean hideTimestamps?: boolean
} & ComponentProps<typeof Box>
// If true, render different styles that fit the template editor pane
// a bit better.
templateEditorPane?: boolean
}
export const WorkspaceBuildLogs: FC<WorkspaceBuildLogsProps> = ({ export const WorkspaceBuildLogs: FC<WorkspaceBuildLogsProps> = ({
hideTimestamps, hideTimestamps,
logs, logs,
templateEditorPane, ...boxProps
}) => { }) => {
const groupedLogsByStage = groupLogsByStage(logs) const groupedLogsByStage = groupLogsByStage(logs)
const stages = Object.keys(groupedLogsByStage) const stages = Object.keys(groupedLogsByStage)
const styles = useStyles({ templateEditorPane: Boolean(templateEditorPane) }) const styles = useStyles()
return ( return (
<div className={styles.logs}> <Box
{...boxProps}
sx={{
border: (theme) => `1px solid ${theme.palette.divider}`,
borderRadius: 1,
fontFamily: MONOSPACE_FONT_FAMILY,
...boxProps.sx,
}}
>
{stages.map((stage) => { {stages.map((stage) => {
const logs = groupedLogsByStage[stage] const logs = groupedLogsByStage[stage]
const isEmpty = logs.every((log) => log.output === "") const isEmpty = logs.every((log) => log.output === "")
@ -83,56 +87,36 @@ export const WorkspaceBuildLogs: FC<WorkspaceBuildLogsProps> = ({
</Fragment> </Fragment>
) )
})} })}
</div> </Box>
) )
} }
const useStyles = makeStyles< const useStyles = makeStyles((theme) => ({
Theme,
{
templateEditorPane: boolean
}
>((theme) => ({
logs: {
border: `1px solid ${theme.palette.divider}`,
borderRadius: (props) =>
props.templateEditorPane ? "0px" : theme.shape.borderRadius,
fontFamily: MONOSPACE_FONT_FAMILY,
},
header: { header: {
fontSize: 14, fontSize: 13,
padding: theme.spacing(2), fontWeight: 600,
paddingLeft: theme.spacing(3), padding: theme.spacing(0.5, 3),
paddingRight: theme.spacing(3),
borderTop: `1px solid ${theme.palette.divider}`,
borderBottom: `1px solid ${theme.palette.divider}`,
backgroundColor: theme.palette.background.paper,
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
fontFamily: "Inter", fontFamily: "Inter",
borderBottom: `1px solid ${theme.palette.divider}`,
"&:first-of-type": { position: "sticky",
borderTopLeftRadius: theme.shape.borderRadius, top: 0,
borderTopRightRadius: theme.shape.borderRadius, background: theme.palette.background.default,
borderTop: 0,
},
"&:last-child": { "&:last-child": {
borderBottom: 0, borderBottom: 0,
borderTop: `1px solid ${theme.palette.divider}`, borderRadius: "0 0 8px 8px",
borderBottomLeftRadius: theme.shape.borderRadius,
borderBottomRightRadius: theme.shape.borderRadius,
}, },
"& + $header": { "&:first-child": {
borderTop: 0, borderRadius: "8px 8px 0 0",
}, },
}, },
duration: { duration: {
marginLeft: "auto", marginLeft: "auto",
color: theme.palette.text.secondary, color: theme.palette.text.secondary,
fontSize: theme.typography.body2.fontSize, fontSize: 12,
}, },
})) }))

View File

@ -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 (
<LocalPreferencesContext.Provider
value={
state.ready
? {
values: state.values,
getPreference,
setPreference,
}
: undefined
}
>
{children}
</LocalPreferencesContext.Provider>
)
}
export const useLocalPreferences = () => {
const context = useContext(LocalPreferencesContext)
if (context === undefined) {
throw new Error(
"useLocalPreference must be used within a LocalPreferenceProvider",
)
}
return context
}

View File

@ -1,10 +1,4 @@
interface UseLocalStorage { export const useLocalStorage = () => {
saveLocal: (arg0: string, arg1: string) => void
getLocal: (arg0: string) => string | undefined
clearLocal: (arg0: string) => void
}
export const useLocalStorage = (): UseLocalStorage => {
return { return {
saveLocal, saveLocal,
getLocal, getLocal,

View File

@ -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<HTMLDivElement>(null)
useEffect(() => {
const scrollEl = scrollRef.current
if (scrollEl) {
scrollEl.scrollTop = scrollEl.scrollHeight
}
}, [logs])
return (
<Box
sx={(theme) => ({
borderRadius: 1,
border: `1px solid ${theme.palette.divider}`,
})}
>
<Box
sx={(theme) => ({
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 && (
<Box sx={{ marginLeft: "auto" }}>
<Tooltip title="Hide build logs" placement="top">
<IconButton
onClick={onHide}
size="small"
sx={(theme) => ({
color: theme.palette.text.secondary,
"&:hover": {
color: theme.palette.text.primary,
},
})}
>
<CloseOutlined sx={{ height: 16, width: 16 }} />
</IconButton>
</Tooltip>
</Box>
)}
</Box>
<Box
ref={scrollRef}
sx={() => ({
height: "400px",
overflowY: "auto",
})}
>
{logs ? (
<WorkspaceBuildLogs logs={logs} sx={{ border: 0, borderRadius: 0 }} />
) : (
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "100%",
height: "100%",
}}
>
<Loader />
</Box>
)}
</Box>
</Box>
)
}

View File

@ -1,10 +1,7 @@
import { useQuery } from "@tanstack/react-query"
import { useMachine } from "@xstate/react" import { useMachine } from "@xstate/react"
import { getWorkspaceBuildLogs } from "api/api"
import { Workspace } from "api/typesGenerated"
import { ChooseOne, Cond } from "components/Conditionals/ChooseOne" import { ChooseOne, Cond } from "components/Conditionals/ChooseOne"
import { Loader } from "components/Loader/Loader" import { Loader } from "components/Loader/Loader"
import { FC, useRef } from "react" import { FC } from "react"
import { useParams } from "react-router-dom" import { useParams } from "react-router-dom"
import { quotaMachine } from "xServices/quotas/quotasXService" import { quotaMachine } from "xServices/quotas/quotasXService"
import { workspaceMachine } from "xServices/workspace/workspaceXService" import { workspaceMachine } from "xServices/workspace/workspaceXService"
@ -15,23 +12,6 @@ import { useOrganizationId } from "hooks"
import { isAxiosError } from "axios" import { isAxiosError } from "axios"
import { Margins } from "components/Margins/Margins" 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 = () => { export const WorkspacePage: FC = () => {
const params = useParams() as { const params = useParams() as {
username: string username: string
@ -50,7 +30,6 @@ export const WorkspacePage: FC = () => {
const { workspace, error } = workspaceState.context const { workspace, error } = workspaceState.context
const [quotaState] = useMachine(quotaMachine, { context: { username } }) const [quotaState] = useMachine(quotaMachine, { context: { username } })
const { getQuotaError } = quotaState.context const { getQuotaError } = quotaState.context
const failedBuildLogs = useFailedBuildLogs(workspace)
const pageError = error ?? getQuotaError const pageError = error ?? getQuotaError
return ( return (
@ -73,7 +52,6 @@ export const WorkspacePage: FC = () => {
} }
> >
<WorkspaceReadyPage <WorkspaceReadyPage
failedBuildLogs={failedBuildLogs.data}
workspaceState={workspaceState} workspaceState={workspaceState}
quotaState={quotaState} quotaState={quotaState}
workspaceSend={workspaceSend} workspaceSend={workspaceSend}

View File

@ -1,5 +1,4 @@
import { useActor } from "@xstate/react" import { useActor, useMachine } from "@xstate/react"
import { ProvisionerJobLog } from "api/typesGenerated"
import { useDashboard } from "components/Dashboard/DashboardProvider" import { useDashboard } from "components/Dashboard/DashboardProvider"
import dayjs from "dayjs" import dayjs from "dayjs"
import { useFeatureVisibility } from "hooks/useFeatureVisibility" import { useFeatureVisibility } from "hooks/useFeatureVisibility"
@ -21,7 +20,7 @@ import {
WorkspaceErrors, WorkspaceErrors,
} from "../../components/Workspace/Workspace" } from "../../components/Workspace/Workspace"
import { pageTitle } from "../../utils/page" import { pageTitle } from "../../utils/page"
import { getFaviconByStatus } from "../../utils/workspace" import { getFaviconByStatus, hasJobError } from "../../utils/workspace"
import { import {
WorkspaceEvent, WorkspaceEvent,
workspaceMachine, workspaceMachine,
@ -38,18 +37,20 @@ import {
import { useMe } from "hooks/useMe" import { useMe } from "hooks/useMe"
import Checkbox from "@mui/material/Checkbox" import Checkbox from "@mui/material/Checkbox"
import FormControlLabel from "@mui/material/FormControlLabel" import FormControlLabel from "@mui/material/FormControlLabel"
import { workspaceBuildMachine } from "xServices/workspaceBuild/workspaceBuildXService"
import * as TypesGen from "api/typesGenerated"
import { useLocalPreferences } from "contexts/LocalPreferencesContext"
import { WorkspaceBuildLogsSection } from "./WorkspaceBuildLogsSection"
interface WorkspaceReadyPageProps { interface WorkspaceReadyPageProps {
workspaceState: StateFrom<typeof workspaceMachine> workspaceState: StateFrom<typeof workspaceMachine>
quotaState: StateFrom<typeof quotaMachine> quotaState: StateFrom<typeof quotaMachine>
workspaceSend: (event: WorkspaceEvent) => void workspaceSend: (event: WorkspaceEvent) => void
failedBuildLogs: ProvisionerJobLog[] | undefined
} }
export const WorkspaceReadyPage = ({ export const WorkspaceReadyPage = ({
workspaceState, workspaceState,
quotaState, quotaState,
failedBuildLogs,
workspaceSend, workspaceSend,
}: WorkspaceReadyPageProps): JSX.Element => { }: WorkspaceReadyPageProps): JSX.Element => {
const [_, bannerSend] = useActor( const [_, bannerSend] = useActor(
@ -92,6 +93,17 @@ export const WorkspaceReadyPage = ({
const [isConfirmingRestart, setIsConfirmingRestart] = useState(false) const [isConfirmingRestart, setIsConfirmingRestart] = useState(false)
const user = useMe() const user = useMe()
const { isWarningIgnored, ignoreWarning } = useIgnoreWarnings(user.id) 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 { const {
mutate: restartWorkspace, mutate: restartWorkspace,
@ -121,7 +133,6 @@ export const WorkspaceReadyPage = ({
</Helmet> </Helmet>
<Workspace <Workspace
failedBuildLogs={failedBuildLogs}
scheduleProps={{ scheduleProps={{
onDeadlineMinus: (hours: number) => { onDeadlineMinus: (hours: number) => {
bannerSend({ bannerSend({
@ -184,6 +195,20 @@ export const WorkspaceReadyPage = ({
template={template} template={template}
quota_budget={quotaState.context.quota?.budget} quota_budget={quotaState.context.quota?.budget}
templateWarnings={templateVersion?.warnings} templateWarnings={templateVersion?.warnings}
canChangeBuildLogsVisibility={canChangeBuildLogsVisibility}
isWorkspaceBuildLogsUIActive={isWorkspaceBuildLogsUIActive}
buildLogs={
shouldDisplayBuildLogs && (
<WorkspaceBuildLogsSection
logs={buildLogs}
onHide={() => {
if (canChangeBuildLogsVisibility) {
localPreferences.setPreference("buildLogsVisibility", "hide")
}
}}
/>
)
}
/> />
<DeleteDialog <DeleteDialog
entity="workspace" entity="workspace"
@ -331,3 +356,22 @@ const WarningDialog: FC<
/> />
) )
} }
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
}

View File

@ -281,3 +281,7 @@ const getPendingWorkspaceStatusText = (
const LoadingIcon = () => { const LoadingIcon = () => {
return <CircularProgress size={10} style={{ color: "#FFF" }} /> return <CircularProgress size={10} style={{ color: "#FFF" }} />
} }
export const hasJobError = (workspace: TypesGen.Workspace) => {
return workspace.latest_build.job.error !== undefined
}

View File

@ -24,6 +24,11 @@ type LogsEvent =
| { | {
type: "BUILD_DONE" type: "BUILD_DONE"
} }
| {
type: "RESET"
buildNumber: string
timeCursor: Date
}
export const workspaceBuildMachine = createMachine( export const workspaceBuildMachine = createMachine(
{ {
@ -43,6 +48,12 @@ export const workspaceBuildMachine = createMachine(
}, },
}, },
initial: "gettingBuild", initial: "gettingBuild",
on: {
RESET: {
target: "gettingBuild",
actions: ["resetContext"],
},
},
states: { states: {
gettingBuild: { gettingBuild: {
entry: "clearGetBuildError", entry: "clearGetBuildError",
@ -96,6 +107,11 @@ export const workspaceBuildMachine = createMachine(
}, },
{ {
actions: { actions: {
resetContext: assign({
buildNumber: (_, event) => event.buildNumber,
timeCursor: (_, event) => event.timeCursor,
logs: undefined,
}),
// Build ID // Build ID
assignBuildId: assign({ assignBuildId: assign({
buildId: (_, event) => event.data.id, buildId: (_, event) => event.data.id,
@ -134,8 +150,8 @@ export const workspaceBuildMachine = createMachine(
if (!ctx.logs) { if (!ctx.logs) {
throw new Error("logs must be set") throw new Error("logs must be set")
} }
const after =
const after = ctx.logs[ctx.logs.length - 1].id ctx.logs.length > 0 ? ctx.logs[ctx.logs.length - 1].id : undefined
const socket = API.watchBuildLogsByBuildId(ctx.buildId, { const socket = API.watchBuildLogsByBuildId(ctx.buildId, {
after, after,
onMessage: (log) => { onMessage: (log) => {