mirror of
https://github.com/coder/coder.git
synced 2025-07-06 15:41:45 +00:00
feat(site): display build logs on workspace transitioning statuses (#8397)
This commit is contained in:
6
coderd/apidoc/docs.go
generated
6
coderd/apidoc/docs.go
generated
@ -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": {
|
||||
|
6
coderd/apidoc/swagger.json
generated
6
coderd/apidoc/swagger.json
generated
@ -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": {
|
||||
|
@ -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.
|
||||
|
@ -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
|
||||
|
||||
|
@ -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 = {
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
)
|
||||
|
@ -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",
|
||||
|
@ -379,7 +379,7 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
|
||||
|
||||
{buildLogs && buildLogs.length > 0 && (
|
||||
<WorkspaceBuildLogs
|
||||
templateEditorPane
|
||||
sx={{ borderRadius: 0 }}
|
||||
hideTimestamps
|
||||
logs={buildLogs}
|
||||
/>
|
||||
|
@ -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()} />,
|
||||
},
|
||||
}
|
||||
|
||||
|
@ -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}
|
||||
|
@ -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>
|
||||
|
@ -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,
|
||||
},
|
||||
}))
|
||||
|
111
site/src/contexts/LocalPreferencesContext.tsx
Normal file
111
site/src/contexts/LocalPreferencesContext.tsx
Normal 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
|
||||
}
|
@ -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,
|
||||
|
90
site/src/pages/WorkspacePage/WorkspaceBuildLogsSection.tsx
Normal file
90
site/src/pages/WorkspacePage/WorkspaceBuildLogsSection.tsx
Normal 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>
|
||||
)
|
||||
}
|
@ -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}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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) => {
|
||||
|
Reference in New Issue
Block a user