feat(site): show previous agent scripts logs (#12233)

This commit is contained in:
Bruno Quaresma 2024-02-21 11:42:34 -03:00 committed by GitHub
parent 0398e3c531
commit b4fb754b2d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 552 additions and 351 deletions

View File

@ -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<typeof Tabs> = {
@ -11,12 +11,19 @@ type Story = StoryObj<typeof Tabs>;
export const Default: Story = {
args: {
active: "tab-1",
children: (
<>
<TabLink to="">Tab 1</TabLink>
<TabLink to="tab-3">Tab 2</TabLink>
<TabLink to="tab-4">Tab 3</TabLink>
</>
<TabsList>
<TabLink value="tab-1" to="">
Tab 1
</TabLink>
<TabLink value="tab-2" to="tab-3">
Tab 2
</TabLink>
<TabLink value="tab-3" to="tab-4">
Tab 3
</TabLink>
</TabsList>
),
},
};

View File

@ -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 Tabs: FC<PropsWithChildren> = ({ children }) => {
export const TAB_PADDING_Y = 12;
export const TAB_PADDING_X = 16;
type TabsContextValue = {
active: string;
};
const TabsContext = createContext<TabsContextValue | undefined>(undefined);
type TabsProps = HTMLAttributes<HTMLDivElement> & TabsContextValue;
export const Tabs: FC<TabsProps> = ({ active, ...htmlProps }) => {
const theme = useTheme();
return (
<TabsContext.Provider value={{ active }}>
<div
css={{
borderBottom: `1px solid ${theme.palette.divider}`,
}}
{...htmlProps}
/>
</TabsContext.Provider>
);
};
type TabsListProps = HTMLAttributes<HTMLDivElement>;
export const TabsList: FC<TabsListProps> = (props) => {
return (
<div
css={(theme) => ({
borderBottom: `1px solid ${theme.palette.divider}`,
marginBottom: 40,
})}
>
<Margins
css={{
display: "flex",
alignItems: "center",
gap: 2,
}}
>
{children}
</Margins>
</div>
role="tablist"
css={{
display: "flex",
alignItems: "baseline",
}}
{...props}
/>
);
};
interface TabLinkProps extends NavLinkProps {
className?: string;
}
type TabLinkProps = LinkProps & {
value: string;
};
export const TabLink: FC<TabLinkProps> = ({
className,
children,
...linkProps
}) => {
const tabLink = useClassName(classNames.tabLink, []);
const activeTabLink = useClassName(classNames.activeTabLink, []);
export const TabLink: FC<TabLinkProps> = ({ 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 (
<NavLink
className={({ isActive }) =>
cx([tabLink, isActive && activeTabLink, className])
}
<Link
{...linkProps}
>
{children}
</NavLink>
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;
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: (css, theme) => css`
color: ${theme.palette.text.primary};
position: relative;
"&:hover": {
color: theme.palette.text.primary,
},
}),
activeTabLink: (theme) => ({
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<string, ClassName>;
"&:before": {
content: '""',
left: 0,
bottom: -1,
height: 1,
width: "100%",
background: theme.palette.primary.main,
position: "absolute",
},
}),
} satisfies Record<string, Interpolation<Theme>>;

View File

@ -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<typeof List>,
"children" | "itemSize" | "itemCount"
> & {
logs: LineWithID[];
sources: WorkspaceAgentLogSource[];
};
export const AgentLogs = forwardRef<List, AgentLogsProps>(
({ 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 (
<List
ref={ref}
css={styles.logs}
itemCount={logs.length}
itemSize={logLineHeight}
{...listProps}
>
{({ 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 = (
<img
src={logSource.icon}
alt=""
width={14}
height={14}
css={{
marginRight: 8,
flexShrink: 0,
}}
/>
);
} else {
icon = (
<div
css={{
width: 14,
height: 14,
marginRight: 8,
flexShrink: 0,
background: determineScriptDisplayColor(
logSource.display_name,
),
borderRadius: "100%",
}}
/>
);
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 = (
<div
css={{
width: 14,
height: 14,
marginRight: 8,
display: "flex",
justifyContent: "center",
position: "relative",
flexShrink: 0,
}}
>
<div
className="dashed-line"
css={(theme) => ({
height: nextChangesSource ? "50%" : "100%",
width: 2,
background: theme.experimental.l1.outline,
borderRadius: 2,
})}
/>
{nextChangesSource && (
<div
className="dashed-line"
css={(theme) => ({
height: 2,
width: "50%",
top: "calc(50% - 2px)",
left: "calc(50% - 1px)",
background: theme.experimental.l1.outline,
borderRadius: 2,
position: "absolute",
})}
/>
)}
</div>
);
}
return (
<LogLine
line={logs[index]}
number={index + 1}
maxNumber={logs.length}
style={style}
sourceIcon={
<Tooltip
title={
<>
{logSource.display_name}
{assignedIcon && (
<i>
<br />
No icon specified!
</i>
)}
</>
}
>
{icon}
</Tooltip>
}
/>
);
}}
</List>
);
},
);
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<LineWithID[] | undefined>(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<string, Interpolation<Theme>>;

View File

@ -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<AgentRowProps> = ({
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<AgentRowProps> = ({
<Collapse in={showLogs}>
<AutoSizer disableHeight>
{({ width }) => (
<List
<AgentLogs
ref={logListRef}
innerRef={logListDivRef}
height={256}
itemCount={startupLogs.length}
itemSize={logLineHeight}
width={width}
css={styles.startupLogs}
onScroll={handleLogScroll}
>
{({ 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 = (
<img
src={logSource.icon}
alt=""
width={14}
height={14}
css={{
marginRight: 8,
flexShrink: 0,
}}
/>
);
} else {
icon = (
<div
css={{
width: 14,
height: 14,
marginRight: 8,
flexShrink: 0,
background: determineScriptDisplayColor(
logSource.display_name,
),
borderRadius: "100%",
}}
/>
);
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 = (
<div
css={{
width: 14,
height: 14,
marginRight: 8,
display: "flex",
justifyContent: "center",
position: "relative",
flexShrink: 0,
}}
>
<div
className="dashed-line"
css={(theme) => ({
height: nextChangesSource ? "50%" : "100%",
width: 2,
background: theme.experimental.l1.outline,
borderRadius: 2,
})}
/>
{nextChangesSource && (
<div
className="dashed-line"
css={(theme) => ({
height: 2,
width: "50%",
top: "calc(50% - 2px)",
left: "calc(50% - 1px)",
background: theme.experimental.l1.outline,
borderRadius: 2,
position: "absolute",
})}
/>
)}
</div>
);
}
return (
<LogLine
line={startupLogs[index]}
number={index + 1}
maxNumber={startupLogs.length}
style={style}
sourceIcon={
<Tooltip
title={
<>
{logSource.display_name}
{assignedIcon && (
<i>
<br />
No icon specified!
</i>
)}
</>
}
>
{icon}
</Tooltip>
}
/>
);
}}
</List>
logs={startupLogs}
sources={agent.log_sources}
/>
)}
</AutoSizer>
</Collapse>
@ -464,64 +316,6 @@ export const AgentRow: FC<AgentRowProps> = ({
);
};
const useAgentLogs = (
agentId: string,
{ enabled, initialData }: { enabled: boolean; initialData?: LineWithID[] },
) => {
const [logs, setLogs] = useState<LineWithID[] | undefined>(initialData);
const socket = useRef<WebSocket | null>(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",
},
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<string, Interpolation<Theme>>;
// 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];
};

View File

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

View File

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

View File

@ -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<PropsWithChildren> = ({
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<PropsWithChildren> = ({
}}
/>
<Tabs>
<TabLink end to={`/templates/${templateName}`}>
Summary
</TabLink>
<TabLink to={`/templates/${templateName}/docs`}>Docs</TabLink>
{data.permissions.canUpdateTemplate && (
<TabLink to={`/templates/${templateName}/files`}>Source Code</TabLink>
)}
<TabLink to={`/templates/${templateName}/versions`}>Versions</TabLink>
<TabLink to={`/templates/${templateName}/embed`}>Embed</TabLink>
{shouldShowInsights && (
<TabLink to={`/templates/${templateName}/insights`}>Insights</TabLink>
)}
<Tabs
active={activeTab}
css={{ marginBottom: 40, marginTop: -TAB_PADDING_Y }}
>
<Margins>
<TabsList>
<TabLink to={`/templates/${templateName}`} value="summary">
Summary
</TabLink>
<TabLink to={`/templates/${templateName}/docs`} value="docs">
Docs
</TabLink>
{data.permissions.canUpdateTemplate && (
<TabLink to={`/templates/${templateName}/files`} value="files">
Source Code
</TabLink>
)}
<TabLink
to={`/templates/${templateName}/versions`}
value="versions"
>
Versions
</TabLink>
<TabLink to={`/templates/${templateName}/embed`} value="embed">
Embed
</TabLink>
{shouldShowInsights && (
<TabLink
to={`/templates/${templateName}/insights`}
value="insights"
>
Insights
</TabLink>
)}
</TabsList>
</Margins>
</Tabs>
<Margins>

View File

@ -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 = () => {
</PageHeader>
</Margins>
<Tabs>
<TabLink to={USERS_LINK}>Users</TabLink>
<TabLink to="/groups">Groups</TabLink>
<Tabs
css={{ marginBottom: 40, marginTop: -TAB_PADDING_Y }}
active={activeTab}
>
<Margins>
<TabsList>
<TabLink to={USERS_LINK} value="users">
Users
</TabLink>
<TabLink to="/groups" value="groups">
Groups
</TabLink>
</TabsList>
</Margins>
</Tabs>
<Margins>

View File

@ -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(<WorkspaceBuildPage />, {
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);
});
});

View File

@ -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<WorkspaceBuildPageViewProps> = ({
activeBuildNumber,
}) => {
const theme = useTheme();
const tab = useTab(LOGS_TAB_KEY, "build");
if (!build) {
return <Loader />;
}
const agents = build.resources.flatMap((r) => r.agents ?? []);
const selectedAgent = agents.find((a) => a.id === tab.value);
return (
<DashboardFullPage>
<FullWidthPageHeader sticky={false}>
@ -128,6 +141,23 @@ export const WorkspaceBuildPageView: FC<WorkspaceBuildPageViewProps> = ({
</Sidebar>
<div css={{ height: "100%", overflowY: "auto", width: "100%" }}>
<Tabs active={tab.value}>
<TabsList>
<TabLink to={`?${LOGS_TAB_KEY}=build`} value="build">
Build
</TabLink>
{agents.map((a) => (
<TabLink
to={`?${LOGS_TAB_KEY}=${a.id}`}
value={a.id}
key={a.id}
>
coder_agent.{a.name}
</TabLink>
))}
</TabsList>
</Tabs>
{build.transition === "delete" && build.job.status === "failed" && (
<Alert
severity="error"
@ -156,13 +186,11 @@ export const WorkspaceBuildPageView: FC<WorkspaceBuildPageViewProps> = ({
</div>
</Alert>
)}
{logs ? (
<WorkspaceBuildLogs
css={{ border: 0 }}
logs={sortLogsByCreatedAt(logs)}
/>
{tab.value === "build" ? (
<BuildLogsContent logs={logs} />
) : (
<Loader />
<AgentLogsContent agent={selectedAgent!} />
)}
</div>
</div>
@ -170,6 +198,44 @@ export const WorkspaceBuildPageView: FC<WorkspaceBuildPageViewProps> = ({
);
};
const BuildLogsContent: FC<{ logs?: ProvisionerJobLog[] }> = ({ logs }) => {
if (!logs) {
return <Loader />;
}
return (
<WorkspaceBuildLogs
css={{
border: 0,
"--log-line-side-padding": `${TAB_PADDING_X}px`,
// Add extra spacing to the first log header to prevent it from being
// too close to the tabs
"& .logs-header:first-of-type": {
paddingTop: 16,
},
}}
logs={sortLogsByCreatedAt(logs)}
/>
);
};
const AgentLogsContent: FC<{ agent: WorkspaceAgent }> = ({ agent }) => {
const logs = useAgentLogs(agent.id);
if (!logs) {
return <Loader />;
}
return (
<AgentLogs
sources={agent.log_sources}
logs={logs}
height={560}
width="100%"
/>
);
};
const styles = {
stats: (theme) => ({
padding: 0,

View File

@ -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 = {