diff --git a/site/src/components/Tabs/Tabs.stories.tsx b/site/src/components/Tabs/Tabs.stories.tsx index b52a0d6cad965..94466403ddb93 100644 --- a/site/src/components/Tabs/Tabs.stories.tsx +++ b/site/src/components/Tabs/Tabs.stories.tsx @@ -1,4 +1,4 @@ -import { Tabs, TabLink } from "./Tabs"; +import { Tabs, TabLink, TabsList } from "./Tabs"; import type { Meta, StoryObj } from "@storybook/react"; const meta: Meta = { @@ -11,12 +11,19 @@ type Story = StoryObj; export const Default: Story = { args: { + active: "tab-1", children: ( - <> - Tab 1 - Tab 2 - Tab 3 - + + + Tab 1 + + + Tab 2 + + + Tab 3 + + ), }, }; diff --git a/site/src/components/Tabs/Tabs.tsx b/site/src/components/Tabs/Tabs.tsx index bc1f7d74066ab..31e2f722b8b9c 100644 --- a/site/src/components/Tabs/Tabs.tsx +++ b/site/src/components/Tabs/Tabs.tsx @@ -1,78 +1,95 @@ -import { cx } from "@emotion/css"; -import { type FC, type PropsWithChildren } from "react"; -import { NavLink, NavLinkProps } from "react-router-dom"; -import { Margins } from "components/Margins/Margins"; -import { type ClassName, useClassName } from "hooks/useClassName"; +import { HTMLAttributes, type FC, createContext, useContext } from "react"; +import { Link, LinkProps } from "react-router-dom"; +import { Interpolation, Theme, useTheme } from "@emotion/react"; + +export const TAB_PADDING_Y = 12; +export const TAB_PADDING_X = 16; + +type TabsContextValue = { + active: string; +}; + +const TabsContext = createContext(undefined); + +type TabsProps = HTMLAttributes & TabsContextValue; + +export const Tabs: FC = ({ active, ...htmlProps }) => { + const theme = useTheme(); -export const Tabs: FC = ({ children }) => { return ( -
({ - borderBottom: `1px solid ${theme.palette.divider}`, - marginBottom: 40, - })} - > - +
- {children} - -
+ {...htmlProps} + /> + + ); +}; + +type TabsListProps = HTMLAttributes; + +export const TabsList: FC = (props) => { + return ( +
); }; -interface TabLinkProps extends NavLinkProps { - className?: string; -} +type TabLinkProps = LinkProps & { + value: string; +}; -export const TabLink: FC = ({ - className, - children, - ...linkProps -}) => { - const tabLink = useClassName(classNames.tabLink, []); - const activeTabLink = useClassName(classNames.activeTabLink, []); +export const TabLink: FC = ({ value, ...linkProps }) => { + const tabsContext = useContext(TabsContext); + + if (!tabsContext) { + throw new Error("Tab only can be used inside of Tabs"); + } + + const isActive = tabsContext.active === value; return ( - - cx([tabLink, isActive && activeTabLink, className]) - } + - {children} - + css={[styles.tabLink, isActive ? styles.activeTabLink : ""]} + /> ); }; -const classNames = { - tabLink: (css, theme) => css` - text-decoration: none; - color: ${theme.palette.text.secondary}; - font-size: 14px; - display: block; - padding: 0 16px 16px; - - &:hover { - color: ${theme.palette.text.primary}; - } - `, - activeTabLink: (css, theme) => css` - color: ${theme.palette.text.primary}; - position: relative; - - &:before { - content: ""; - left: 0; - bottom: 0; - height: 2px; - width: 100%; - background: ${theme.palette.primary.main}; - position: absolute; - } - `, -} satisfies Record; +const styles = { + tabLink: (theme) => ({ + textDecoration: "none", + color: theme.palette.text.secondary, + fontSize: 14, + display: "block", + padding: `${TAB_PADDING_Y}px ${TAB_PADDING_X}px`, + fontWeight: 500, + lineHeight: "1", + + "&:hover": { + color: theme.palette.text.primary, + }, + }), + activeTabLink: (theme) => ({ + color: theme.palette.text.primary, + position: "relative", + + "&:before": { + content: '""', + left: 0, + bottom: -1, + height: 1, + width: "100%", + background: theme.palette.primary.main, + position: "absolute", + }, + }), +} satisfies Record>; diff --git a/site/src/modules/resources/AgentLogs.tsx b/site/src/modules/resources/AgentLogs.tsx new file mode 100644 index 0000000000000..c1e2ee5353888 --- /dev/null +++ b/site/src/modules/resources/AgentLogs.tsx @@ -0,0 +1,273 @@ +import Tooltip from "@mui/material/Tooltip"; +import { FixedSizeList as List } from "react-window"; +import type { WorkspaceAgentLogSource } from "api/typesGenerated"; +import { + LogLine, + logLineHeight, +} from "modules/workspaces/WorkspaceBuildLogs/Logs"; +import { + ComponentProps, + forwardRef, + useEffect, + useMemo, + useState, +} from "react"; +import { LineWithID } from "./AgentRow"; +import { Interpolation, Theme } from "@emotion/react"; +import * as API from "api/api"; + +type AgentLogsProps = Omit< + ComponentProps, + "children" | "itemSize" | "itemCount" +> & { + logs: LineWithID[]; + sources: WorkspaceAgentLogSource[]; +}; + +export const AgentLogs = forwardRef( + ({ logs, sources, ...listProps }, ref) => { + const logSourceByID = useMemo(() => { + const sourcesById: { [id: string]: WorkspaceAgentLogSource } = {}; + for (const source of sources) { + sourcesById[source.id] = source; + } + return sourcesById; + }, [sources]); + + return ( + + {({ index, style }) => { + const log = logs[index]; + // getLogSource always returns a valid log source. + // This is necessary to support deployments before `coder_script`. + // Existed that haven't restarted their agents. + const getLogSource = (id: string): WorkspaceAgentLogSource => { + return ( + logSourceByID[id] || { + created_at: "", + display_name: "Logs", + icon: "", + id: "00000000-0000-0000-0000-000000000000", + workspace_agent_id: "", + } + ); + }; + const logSource = getLogSource(log.source_id); + + let assignedIcon = false; + let icon: JSX.Element; + // If no icon is specified, we show a deterministic + // colored circle to identify unique scripts. + if (logSource.icon) { + icon = ( + + ); + } else { + icon = ( +
+ ); + assignedIcon = true; + } + + let nextChangesSource = false; + if (index < logs.length - 1) { + nextChangesSource = + getLogSource(logs[index + 1].source_id).id !== log.source_id; + } + // We don't want every line to repeat the icon, because + // that is ugly and repetitive. This removes the icon + // for subsequent lines of the same source and shows a + // line instead, visually indicating they are from the + // same source. + if ( + index > 0 && + getLogSource(logs[index - 1].source_id).id === log.source_id + ) { + icon = ( +
+
({ + height: nextChangesSource ? "50%" : "100%", + width: 2, + background: theme.experimental.l1.outline, + borderRadius: 2, + })} + /> + {nextChangesSource && ( +
({ + height: 2, + width: "50%", + top: "calc(50% - 2px)", + left: "calc(50% - 1px)", + background: theme.experimental.l1.outline, + borderRadius: 2, + position: "absolute", + })} + /> + )} +
+ ); + } + + return ( + + {logSource.display_name} + {assignedIcon && ( + +
+ No icon specified! +
+ )} + + } + > + {icon} + + } + /> + ); + }} + + ); + }, +); + +export const useAgentLogs = ( + agentId: string, + options?: { enabled?: boolean; initialData?: LineWithID[] }, +) => { + const initialData = options?.initialData; + const enabled = options?.enabled === undefined ? true : options.enabled; + const [logs, setLogs] = useState(initialData); + + useEffect(() => { + if (!enabled) { + setLogs([]); + return; + } + + const socket = API.watchWorkspaceAgentLogs(agentId, { + // Get all logs + after: 0, + onMessage: (logs) => { + // Prevent new logs getting added when a connection is not open + if (socket.readyState !== WebSocket.OPEN) { + return; + } + + setLogs((previousLogs) => { + const newLogs: LineWithID[] = logs.map((log) => ({ + id: log.id, + level: log.level || "info", + output: log.output, + time: log.created_at, + source_id: log.source_id, + })); + + if (!previousLogs) { + return newLogs; + } + + return [...previousLogs, ...newLogs]; + }); + }, + onError: (error) => { + // For some reason Firefox and Safari throw an error when a websocket + // connection is close in the middle of a message and because of that we + // can't safely show to the users an error message since most of the + // time they are just internal stuff. This does not happen to Chrome at + // all and I tried to find better way to "soft close" a WS connection on + // those browsers without success. + console.error(error); + }, + }); + + return () => { + socket.close(); + }; + }, [agentId, enabled]); + + return logs; +}; + +// These colors were picked at random. Feel free +// to add more, adjust, or change! Users will not +// depend on these colors. +const scriptDisplayColors = [ + "#85A3B2", + "#A37EB2", + "#C29FDE", + "#90B3D7", + "#829AC7", + "#728B8E", + "#506080", + "#5654B0", + "#6B56D6", + "#7847CC", +]; + +const determineScriptDisplayColor = (displayName: string): string => { + const hash = displayName.split("").reduce((hash, char) => { + return (hash << 5) + hash + char.charCodeAt(0); // bit-shift and add for our simple hash + }, 0); + return scriptDisplayColors[Math.abs(hash) % scriptDisplayColors.length]; +}; + +const styles = { + logs: (theme) => ({ + backgroundColor: theme.palette.background.paper, + paddingTop: 16, + + // We need this to be able to apply the padding top from startupLogs + "& > div": { + position: "relative", + }, + }), +} satisfies Record>; diff --git a/site/src/modules/resources/AgentRow.tsx b/site/src/modules/resources/AgentRow.tsx index 9ddecff973f5c..cf5aaca77b58f 100644 --- a/site/src/modules/resources/AgentRow.tsx +++ b/site/src/modules/resources/AgentRow.tsx @@ -1,6 +1,5 @@ import Collapse from "@mui/material/Collapse"; import Skeleton from "@mui/material/Skeleton"; -import Tooltip from "@mui/material/Tooltip"; import { type Interpolation, type Theme } from "@emotion/react"; import { type FC, @@ -13,18 +12,15 @@ import { } from "react"; import AutoSizer from "react-virtualized-auto-sizer"; import { FixedSizeList as List, ListOnScrollProps } from "react-window"; -import * as API from "api/api"; import type { Template, Workspace, WorkspaceAgent, - WorkspaceAgentLogSource, WorkspaceAgentMetadata, } from "api/typesGenerated"; import { DropdownArrow } from "components/DropdownArrow/DropdownArrow"; import { Line, - LogLine, logLineHeight, } from "modules/workspaces/WorkspaceBuildLogs/Logs"; import { useProxy } from "contexts/ProxyContext"; @@ -41,6 +37,7 @@ import { VSCodeDesktopButton } from "./VSCodeDesktopButton/VSCodeDesktopButton"; import { useQuery } from "react-query"; import { xrayScan } from "api/queries/integrations"; import { XRayScanAlert } from "./XRayScanAlert"; +import { AgentLogs, useAgentLogs } from "./AgentLogs"; // Logs are stored as the Line interface to make rendering // much more efficient. Instead of mapping objects each time, we're @@ -96,14 +93,6 @@ export const AgentRow: FC = ({ agent.display_apps.includes("vscode_insiders"); const showVSCode = hasVSCodeApp && !hideVSCodeDesktopButton; - // Agent runtime logs - const logSourceByID = useMemo(() => { - const sources: { [id: string]: WorkspaceAgentLogSource } = {}; - for (const source of agent.log_sources) { - sources[source.id] = source; - } - return sources; - }, [agent.log_sources]); const hasStartupFeatures = Boolean(agent.logs_length); const { proxy } = useProxy(); const [showLogs, setShowLogs] = useState( @@ -300,153 +289,16 @@ export const AgentRow: FC = ({ {({ width }) => ( - - {({ index, style }) => { - const log = startupLogs[index]; - // getLogSource always returns a valid log source. - // This is necessary to support deployments before `coder_script`. - // Existed that haven't restarted their agents. - const getLogSource = ( - id: string, - ): WorkspaceAgentLogSource => { - return ( - logSourceByID[id] || { - created_at: "", - display_name: "Logs", - icon: "", - id: "00000000-0000-0000-0000-000000000000", - workspace_agent_id: "", - } - ); - }; - const logSource = getLogSource(log.source_id); - - let assignedIcon = false; - let icon: JSX.Element; - // If no icon is specified, we show a deterministic - // colored circle to identify unique scripts. - if (logSource.icon) { - icon = ( - - ); - } else { - icon = ( -
- ); - assignedIcon = true; - } - - let nextChangesSource = false; - if (index < startupLogs.length - 1) { - nextChangesSource = - getLogSource(startupLogs[index + 1].source_id).id !== - log.source_id; - } - // We don't want every line to repeat the icon, because - // that is ugly and repetitive. This removes the icon - // for subsequent lines of the same source and shows a - // line instead, visually indicating they are from the - // same source. - if ( - index > 0 && - getLogSource(startupLogs[index - 1].source_id).id === - log.source_id - ) { - icon = ( -
-
({ - height: nextChangesSource ? "50%" : "100%", - width: 2, - background: theme.experimental.l1.outline, - borderRadius: 2, - })} - /> - {nextChangesSource && ( -
({ - height: 2, - width: "50%", - top: "calc(50% - 2px)", - left: "calc(50% - 1px)", - background: theme.experimental.l1.outline, - borderRadius: 2, - position: "absolute", - })} - /> - )} -
- ); - } - - return ( - - {logSource.display_name} - {assignedIcon && ( - -
- No icon specified! -
- )} - - } - > - {icon} - - } - /> - ); - }} - + logs={startupLogs} + sources={agent.log_sources} + /> )} @@ -464,64 +316,6 @@ export const AgentRow: FC = ({ ); }; -const useAgentLogs = ( - agentId: string, - { enabled, initialData }: { enabled: boolean; initialData?: LineWithID[] }, -) => { - const [logs, setLogs] = useState(initialData); - const socket = useRef(null); - - useEffect(() => { - if (!enabled) { - socket.current?.close(); - setLogs([]); - return; - } - - socket.current = API.watchWorkspaceAgentLogs(agentId, { - // Get all logs - after: 0, - onMessage: (logs) => { - // Prevent new logs getting added when a connection is not open - if (socket.current?.readyState !== WebSocket.OPEN) { - return; - } - - setLogs((previousLogs) => { - const newLogs: LineWithID[] = logs.map((log) => ({ - id: log.id, - level: log.level || "info", - output: log.output, - time: log.created_at, - source_id: log.source_id, - })); - - if (!previousLogs) { - return newLogs; - } - - return [...previousLogs, ...newLogs]; - }); - }, - onError: (error) => { - // For some reason Firefox and Safari throw an error when a websocket - // connection is close in the middle of a message and because of that we - // can't safely show to the users an error message since most of the - // time they are just internal stuff. This does not happen to Chrome at - // all and I tried to find better way to "soft close" a WS connection on - // those browsers without success. - console.error(error); - }, - }); - - return () => { - socket.current?.close(); - }; - }, [agentId, enabled]); - - return logs; -}; - const styles = { agentRow: (theme) => ({ fontSize: 14, @@ -645,18 +439,6 @@ const styles = { color: theme.palette.text.secondary, }), - startupLogs: (theme) => ({ - maxHeight: 256, - borderBottom: `1px solid ${theme.palette.divider}`, - backgroundColor: theme.palette.background.paper, - paddingTop: 16, - - // We need this to be able to apply the padding top from startupLogs - "& > div": { - position: "relative", - }, - }), - agentNameAndStatus: (theme) => ({ display: "flex", alignItems: "center", @@ -740,27 +522,16 @@ const styles = { agentOS: { textTransform: "capitalize", }, -} satisfies Record>; -// These colors were picked at random. Feel free -// to add more, adjust, or change! Users will not -// depend on these colors. -const scriptDisplayColors = [ - "#85A3B2", - "#A37EB2", - "#C29FDE", - "#90B3D7", - "#829AC7", - "#728B8E", - "#506080", - "#5654B0", - "#6B56D6", - "#7847CC", -]; - -const determineScriptDisplayColor = (displayName: string): string => { - const hash = displayName.split("").reduce((hash, char) => { - return (hash << 5) + hash + char.charCodeAt(0); // bit-shift and add for our simple hash - }, 0); - return scriptDisplayColors[Math.abs(hash) % scriptDisplayColors.length]; -}; + startupLogs: (theme) => ({ + maxHeight: 256, + borderBottom: `1px solid ${theme.palette.divider}`, + backgroundColor: theme.palette.background.paper, + paddingTop: 16, + + // We need this to be able to apply the padding top from startupLogs + "& > div": { + position: "relative", + }, + }), +} satisfies Record>; diff --git a/site/src/modules/workspaces/WorkspaceBuildLogs/Logs.tsx b/site/src/modules/workspaces/WorkspaceBuildLogs/Logs.tsx index 4d8d8e23ded6e..cf189dd8d965b 100644 --- a/site/src/modules/workspaces/WorkspaceBuildLogs/Logs.tsx +++ b/site/src/modules/workspaces/WorkspaceBuildLogs/Logs.tsx @@ -5,6 +5,8 @@ import { type FC, type ReactNode, useMemo } from "react"; import AnsiToHTML from "ansi-to-html"; import { MONOSPACE_FONT_FAMILY } from "theme/constants"; +export const DEFAULT_LOG_LINE_SIDE_PADDING = 24; + const convert = new AnsiToHTML(); export interface Line { @@ -127,7 +129,7 @@ const styles = { height: "auto", // Whitespace is significant in terminal output for alignment whiteSpace: "pre", - padding: "0 32px", + padding: `0 var(--log-line-side-padding, ${DEFAULT_LOG_LINE_SIDE_PADDING}px)`, "&.error": { backgroundColor: theme.roles.error.background, diff --git a/site/src/modules/workspaces/WorkspaceBuildLogs/WorkspaceBuildLogs.tsx b/site/src/modules/workspaces/WorkspaceBuildLogs/WorkspaceBuildLogs.tsx index 30392cf242b62..43a85dccfcd3f 100644 --- a/site/src/modules/workspaces/WorkspaceBuildLogs/WorkspaceBuildLogs.tsx +++ b/site/src/modules/workspaces/WorkspaceBuildLogs/WorkspaceBuildLogs.tsx @@ -2,7 +2,7 @@ import dayjs from "dayjs"; import { type FC, Fragment, type HTMLAttributes } from "react"; import type { ProvisionerJobLog } from "api/typesGenerated"; import { BODY_FONT_FAMILY, MONOSPACE_FONT_FAMILY } from "theme/constants"; -import { Logs } from "./Logs"; +import { DEFAULT_LOG_LINE_SIDE_PADDING, Logs } from "./Logs"; import { type Interpolation, type Theme, useTheme } from "@emotion/react"; const Language = { @@ -99,12 +99,13 @@ const styles = { header: (theme) => ({ fontSize: 13, fontWeight: 600, - padding: "4px 24px", + padding: `12px var(--log-line-side-padding, ${DEFAULT_LOG_LINE_SIDE_PADDING}px)`, display: "flex", alignItems: "center", fontFamily: BODY_FONT_FAMILY, borderBottom: `1px solid ${theme.palette.divider}`, background: theme.palette.background.default, + lineHeight: "1", "&:last-child": { borderBottom: 0, diff --git a/site/src/pages/TemplatePage/TemplateLayout.tsx b/site/src/pages/TemplatePage/TemplateLayout.tsx index 010184d1ed2da..cfb0f0e1d0d94 100644 --- a/site/src/pages/TemplatePage/TemplateLayout.tsx +++ b/site/src/pages/TemplatePage/TemplateLayout.tsx @@ -6,7 +6,7 @@ import { useContext, } from "react"; import { useQuery } from "react-query"; -import { Outlet, useNavigate, useParams } from "react-router-dom"; +import { Outlet, useLocation, useNavigate, useParams } from "react-router-dom"; import type { AuthorizationRequest } from "api/typesGenerated"; import { checkAuthorization, @@ -17,7 +17,7 @@ import { useOrganizationId } from "contexts/auth/useOrganizationId"; import { ErrorAlert } from "components/Alert/ErrorAlert"; import { Margins } from "components/Margins/Margins"; import { Loader } from "components/Loader/Loader"; -import { TabLink, Tabs } from "components/Tabs/Tabs"; +import { TAB_PADDING_Y, TabLink, Tabs, TabsList } from "components/Tabs/Tabs"; import { TemplatePageHeader } from "./TemplatePageHeader"; const templatePermissions = ( @@ -80,6 +80,9 @@ export const TemplateLayout: FC = ({ queryKey: ["template", templateName], queryFn: () => fetchTemplate(orgId, templateName), }); + const location = useLocation(); + const paths = location.pathname.split("/"); + const activeTab = paths[3] ?? "summary"; // Auditors should also be able to view insights, but do not automatically // have permission to update templates. Need both checks. const shouldShowInsights = @@ -108,19 +111,42 @@ export const TemplateLayout: FC = ({ }} /> - - - Summary - - Docs - {data.permissions.canUpdateTemplate && ( - Source Code - )} - Versions - Embed - {shouldShowInsights && ( - Insights - )} + + + + + Summary + + + Docs + + {data.permissions.canUpdateTemplate && ( + + Source Code + + )} + + Versions + + + Embed + + {shouldShowInsights && ( + + Insights + + )} + + diff --git a/site/src/pages/UsersPage/UsersLayout.tsx b/site/src/pages/UsersPage/UsersLayout.tsx index 2f8a72d5840fe..b313aefbd7a39 100644 --- a/site/src/pages/UsersPage/UsersLayout.tsx +++ b/site/src/pages/UsersPage/UsersLayout.tsx @@ -3,13 +3,18 @@ import Link from "@mui/material/Link"; import GroupAdd from "@mui/icons-material/GroupAddOutlined"; import PersonAdd from "@mui/icons-material/PersonAddOutlined"; import { type FC, Suspense } from "react"; -import { Link as RouterLink, Outlet, useNavigate } from "react-router-dom"; +import { + Link as RouterLink, + Outlet, + useNavigate, + useLocation, +} from "react-router-dom"; import { usePermissions } from "contexts/auth/usePermissions"; import { USERS_LINK } from "modules/dashboard/Navbar/NavbarView"; import { useFeatureVisibility } from "modules/dashboard/useFeatureVisibility"; import { PageHeader, PageHeaderTitle } from "components/PageHeader/PageHeader"; import { Margins } from "components/Margins/Margins"; -import { TabLink, Tabs } from "components/Tabs/Tabs"; +import { TAB_PADDING_Y, TabLink, Tabs, TabsList } from "components/Tabs/Tabs"; import { Loader } from "components/Loader/Loader"; export const UsersLayout: FC = () => { @@ -17,6 +22,8 @@ export const UsersLayout: FC = () => { usePermissions(); const navigate = useNavigate(); const { template_rbac: isTemplateRBACEnabled } = useFeatureVisibility(); + const location = useLocation(); + const activeTab = location.pathname.endsWith("groups") ? "groups" : "users"; return ( <> @@ -46,9 +53,20 @@ export const UsersLayout: FC = () => { - - Users - Groups + + + + + Users + + + Groups + + + diff --git a/site/src/pages/WorkspaceBuildPage/WorkspaceBuildPage.test.tsx b/site/src/pages/WorkspaceBuildPage/WorkspaceBuildPage.test.tsx index 7ec9d8f25595d..f99cb1db2f32b 100644 --- a/site/src/pages/WorkspaceBuildPage/WorkspaceBuildPage.test.tsx +++ b/site/src/pages/WorkspaceBuildPage/WorkspaceBuildPage.test.tsx @@ -2,8 +2,14 @@ import { screen, waitFor } from "@testing-library/react"; import WS from "jest-websocket-mock"; import { renderWithAuth } from "testHelpers/renderHelpers"; import { WorkspaceBuildPage } from "./WorkspaceBuildPage"; -import { MockWorkspace, MockWorkspaceBuild } from "testHelpers/entities"; +import { + MockWorkspace, + MockWorkspaceAgent, + MockWorkspaceAgentLogs, + MockWorkspaceBuild, +} from "testHelpers/entities"; import * as API from "api/api"; +import { LOGS_TAB_KEY } from "./WorkspaceBuildPageView"; afterEach(() => { WS.clean(); @@ -56,4 +62,18 @@ describe("WorkspaceBuildPage", () => { server.close(); }); + + test("shows selected agent logs", async () => { + const server = new WS( + `ws://localhost/api/v2/workspaceagents/${MockWorkspaceAgent.id}/logs?follow&after=0`, + ); + renderWithAuth(, { + route: `/@${MockWorkspace.owner_name}/${MockWorkspace.name}/builds/${MockWorkspace.latest_build.build_number}?${LOGS_TAB_KEY}=${MockWorkspaceAgent.id}`, + path: "/:username/:workspace/builds/:buildNumber", + }); + await screen.findByText(`Build #${MockWorkspaceBuild.build_number}`); + await server.connected; + server.send(JSON.stringify(MockWorkspaceAgentLogs)); + await screen.findByText(MockWorkspaceAgentLogs[0].output); + }); }); diff --git a/site/src/pages/WorkspaceBuildPage/WorkspaceBuildPageView.tsx b/site/src/pages/WorkspaceBuildPage/WorkspaceBuildPageView.tsx index c162f1ed155f7..e2349dda9c4aa 100644 --- a/site/src/pages/WorkspaceBuildPage/WorkspaceBuildPageView.tsx +++ b/site/src/pages/WorkspaceBuildPage/WorkspaceBuildPageView.tsx @@ -1,6 +1,10 @@ import { type Interpolation, type Theme, useTheme } from "@emotion/react"; import { type FC } from "react"; -import type { ProvisionerJobLog, WorkspaceBuild } from "api/typesGenerated"; +import type { + ProvisionerJobLog, + WorkspaceAgent, + WorkspaceBuild, +} from "api/typesGenerated"; import { Link } from "react-router-dom"; import { displayWorkspaceBuildDuration } from "utils/workspace"; import { DashboardFullPage } from "modules/dashboard/DashboardLayout"; @@ -20,6 +24,11 @@ import { WorkspaceBuildDataSkeleton, } from "modules/workspaces/WorkspaceBuild/WorkspaceBuildData"; import { Sidebar, SidebarCaption, SidebarItem } from "./Sidebar"; +import { TAB_PADDING_X, TabLink, Tabs, TabsList } from "components/Tabs/Tabs"; +import { useTab } from "hooks"; +import { AgentLogs, useAgentLogs } from "modules/resources/AgentLogs"; + +export const LOGS_TAB_KEY = "logs"; const sortLogsByCreatedAt = (logs: ProvisionerJobLog[]) => { return [...logs].sort( @@ -42,11 +51,15 @@ export const WorkspaceBuildPageView: FC = ({ activeBuildNumber, }) => { const theme = useTheme(); + const tab = useTab(LOGS_TAB_KEY, "build"); if (!build) { return ; } + const agents = build.resources.flatMap((r) => r.agents ?? []); + const selectedAgent = agents.find((a) => a.id === tab.value); + return ( @@ -128,6 +141,23 @@ export const WorkspaceBuildPageView: FC = ({
+ + + + Build + + + {agents.map((a) => ( + + coder_agent.{a.name} + + ))} + + {build.transition === "delete" && build.job.status === "failed" && ( = ({
)} - {logs ? ( - + + {tab.value === "build" ? ( + ) : ( - + )}
@@ -170,6 +198,44 @@ export const WorkspaceBuildPageView: FC = ({ ); }; +const BuildLogsContent: FC<{ logs?: ProvisionerJobLog[] }> = ({ logs }) => { + if (!logs) { + return ; + } + + return ( + + ); +}; + +const AgentLogsContent: FC<{ agent: WorkspaceAgent }> = ({ agent }) => { + const logs = useAgentLogs(agent.id); + + if (!logs) { + return ; + } + + return ( + + ); +}; + const styles = { stats: (theme) => ({ padding: 0, diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index c3bde07743048..42a29a9011876 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -994,11 +994,11 @@ export const MockWorkspaceBuildDelete: TypesGen.WorkspaceBuild = { }; export const MockBuilds = [ - MockWorkspaceBuild, - MockWorkspaceBuildAutostart, - MockWorkspaceBuildAutostop, - MockWorkspaceBuildStop, - MockWorkspaceBuildDelete, + { ...MockWorkspaceBuild, id: "1" }, + { ...MockWorkspaceBuildAutostart, id: "2" }, + { ...MockWorkspaceBuildAutostop, id: "3" }, + { ...MockWorkspaceBuildStop, id: "4" }, + { ...MockWorkspaceBuildDelete, id: "5" }, ]; export const MockWorkspace: TypesGen.Workspace = {