mirror of https://github.com/coder/coder.git
feat(site): show previous agent scripts logs (#12233)
This commit is contained in:
parent
0398e3c531
commit
b4fb754b2d
|
@ -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>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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>>;
|
||||
|
|
|
@ -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>>;
|
|
@ -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];
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 = {
|
||||
|
|
Loading…
Reference in New Issue