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",
"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": {

View File

@ -6810,13 +6810,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": {

View File

@ -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.

View File

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

View File

@ -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 = [
</HelmetProvider>
)
},
(Story) => {
return (
<LocalPreferencesProvider>
<Story />
</LocalPreferencesProvider>
)
},
]
export const parameters = {

View File

@ -1431,11 +1431,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

View File

@ -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,6 +26,7 @@ export const AppProviders: FC<PropsWithChildren> = ({ children }) => {
return (
<HelmetProvider>
<StyledEngineProvider injectFirst>
<LocalPreferencesProvider>
<ThemeProvider theme={dark}>
<CssBaseline enableColorScheme />
<ErrorBoundary>
@ -36,6 +38,7 @@ export const AppProviders: FC<PropsWithChildren> = ({ children }) => {
</QueryClientProvider>
</ErrorBoundary>
</ThemeProvider>
</LocalPreferencesProvider>
</StyledEngineProvider>
</HelmetProvider>
)

View File

@ -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",

View File

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

View File

@ -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: <WorkspaceBuildLogsSection logs={makeFailedBuildLogs()} />,
},
}
@ -170,8 +171,8 @@ export const FailedWithRetry: Story = {
},
},
},
failedBuildLogs: makeFailedBuildLogs(),
canRetryDebugMode: true,
buildLogs: <WorkspaceBuildLogsSection logs={makeFailedBuildLogs()} />,
},
}
@ -229,6 +230,7 @@ export const CancellationError: Story = {
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 { 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<React.PropsWithChildren<WorkspaceProps>> = ({
sshPrefix,
template,
quota_budget,
failedBuildLogs,
handleBuildRetry,
templateWarnings,
buildLogs,
canChangeBuildLogsVisibility,
isWorkspaceBuildLogsUIActive,
}) => {
const styles = useStyles()
const navigate = useNavigate()
@ -208,6 +211,8 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
canChangeVersions={canChangeVersions}
isUpdating={isUpdating}
isRestarting={isRestarting}
canChangeBuildLogsVisibility={canChangeBuildLogsVisibility}
isWorkspaceBuildLogsUIActive={isWorkspaceBuildLogsUIActive}
/>
</PageHeaderActions>
</FullWidthPageHeader>
@ -259,8 +264,7 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
</Alert>
</Maybe>
{failedBuildLogs && (
<Stack>
{workspace.latest_build.job.error && (
<Alert
severity="error"
actions={
@ -279,8 +283,6 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
<AlertTitle>Workspace build failed</AlertTitle>
<AlertDetail>{workspace.latest_build.job.error}</AlertDetail>
</Alert>
<WorkspaceBuildLogs logs={failedBuildLogs} />
</Stack>
)}
{transitionStats !== undefined && (
@ -290,6 +292,8 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
/>
)}
{buildLogs}
{typeof resources !== "undefined" && resources.length > 0 && (
<Resources
resources={resources}

View File

@ -22,6 +22,10 @@ import SettingsOutlined from "@mui/icons-material/SettingsOutlined"
import HistoryOutlined from "@mui/icons-material/HistoryOutlined"
import DeleteOutlined from "@mui/icons-material/DeleteOutlined"
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 {
workspaceStatus: WorkspaceStatus
@ -38,6 +42,8 @@ export interface WorkspaceActionsProps {
isRestarting: boolean
children?: ReactNode
canChangeVersions: boolean
canChangeBuildLogsVisibility: boolean
isWorkspaceBuildLogsUIActive: boolean
}
export const WorkspaceActions: FC<WorkspaceActionsProps> = ({
@ -54,6 +60,8 @@ export const WorkspaceActions: FC<WorkspaceActionsProps> = ({
isUpdating,
isRestarting,
canChangeVersions,
canChangeBuildLogsVisibility,
isWorkspaceBuildLogsUIActive,
}) => {
const styles = useStyles()
const {
@ -64,6 +72,9 @@ export const WorkspaceActions: FC<WorkspaceActionsProps> = ({
const canBeUpdated = isOutdated && canAcceptJobs
const menuTriggerRef = useRef<HTMLButtonElement>(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<WorkspaceActionsProps> = ({
<DeleteOutlined />
Delete
</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>
</div>
</div>

View File

@ -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<typeof Box>
export const WorkspaceBuildLogs: FC<WorkspaceBuildLogsProps> = ({
hideTimestamps,
logs,
templateEditorPane,
...boxProps
}) => {
const groupedLogsByStage = groupLogsByStage(logs)
const stages = Object.keys(groupedLogsByStage)
const styles = useStyles({ templateEditorPane: Boolean(templateEditorPane) })
const styles = useStyles()
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) => {
const logs = groupedLogsByStage[stage]
const isEmpty = logs.every((log) => log.output === "")
@ -83,56 +87,36 @@ export const WorkspaceBuildLogs: FC<WorkspaceBuildLogsProps> = ({
</Fragment>
)
})}
</div>
</Box>
)
}
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,
},
}))

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 {
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,

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 { 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 = () => {
}
>
<WorkspaceReadyPage
failedBuildLogs={failedBuildLogs.data}
workspaceState={workspaceState}
quotaState={quotaState}
workspaceSend={workspaceSend}

View File

@ -1,5 +1,4 @@
import { useActor } from "@xstate/react"
import { ProvisionerJobLog } from "api/typesGenerated"
import { useActor, useMachine } from "@xstate/react"
import { useDashboard } from "components/Dashboard/DashboardProvider"
import dayjs from "dayjs"
import { useFeatureVisibility } from "hooks/useFeatureVisibility"
@ -21,7 +20,7 @@ import {
WorkspaceErrors,
} from "../../components/Workspace/Workspace"
import { pageTitle } from "../../utils/page"
import { getFaviconByStatus } from "../../utils/workspace"
import { getFaviconByStatus, hasJobError } from "../../utils/workspace"
import {
WorkspaceEvent,
workspaceMachine,
@ -38,18 +37,20 @@ import {
import { useMe } from "hooks/useMe"
import Checkbox from "@mui/material/Checkbox"
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 {
workspaceState: StateFrom<typeof workspaceMachine>
quotaState: StateFrom<typeof quotaMachine>
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 = ({
</Helmet>
<Workspace
failedBuildLogs={failedBuildLogs}
scheduleProps={{
onDeadlineMinus: (hours: number) => {
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 && (
<WorkspaceBuildLogsSection
logs={buildLogs}
onHide={() => {
if (canChangeBuildLogsVisibility) {
localPreferences.setPreference("buildLogsVisibility", "hide")
}
}}
/>
)
}
/>
<DeleteDialog
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 = () => {
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: "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) => {