= ({
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) => {