mirror of https://github.com/coder/coder.git
chore(site): refactor logs and add stories (#12553)
This commit is contained in:
parent
0723dd3abf
commit
f78b5c1cfe
|
@ -0,0 +1,48 @@
|
|||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { chromatic } from "testHelpers/chromatic";
|
||||
import { LogLine, LogLinePrefix } from "./LogLine";
|
||||
|
||||
const meta: Meta<typeof LogLine> = {
|
||||
title: "components/Logs/LogLine",
|
||||
parameters: { chromatic },
|
||||
component: LogLine,
|
||||
args: {
|
||||
level: "info",
|
||||
children: (
|
||||
<>
|
||||
<LogLinePrefix>13:45:31.072</LogLinePrefix>
|
||||
<span>info: Starting build</span>
|
||||
</>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof LogLine>;
|
||||
|
||||
export const Info: Story = {};
|
||||
|
||||
export const Debug: Story = {
|
||||
args: {
|
||||
level: "debug",
|
||||
},
|
||||
};
|
||||
|
||||
export const Error: Story = {
|
||||
args: {
|
||||
level: "error",
|
||||
},
|
||||
};
|
||||
|
||||
export const Trace: Story = {
|
||||
args: {
|
||||
level: "trace",
|
||||
},
|
||||
};
|
||||
|
||||
export const Warn: Story = {
|
||||
args: {
|
||||
level: "warn",
|
||||
},
|
||||
};
|
|
@ -0,0 +1,80 @@
|
|||
import type { Interpolation, Theme } from "@emotion/react";
|
||||
import type { FC, HTMLAttributes } from "react";
|
||||
import type { LogLevel } from "api/typesGenerated";
|
||||
import { MONOSPACE_FONT_FAMILY } from "theme/constants";
|
||||
|
||||
export const DEFAULT_LOG_LINE_SIDE_PADDING = 24;
|
||||
|
||||
export interface Line {
|
||||
time: string;
|
||||
output: string;
|
||||
level: LogLevel;
|
||||
sourceId: string;
|
||||
}
|
||||
|
||||
type LogLineProps = {
|
||||
level: LogLevel;
|
||||
} & HTMLAttributes<HTMLPreElement>;
|
||||
|
||||
export const LogLine: FC<LogLineProps> = ({ level, ...divProps }) => {
|
||||
return (
|
||||
<pre
|
||||
css={styles.line}
|
||||
className={`${level} ${divProps.className} logs-line`}
|
||||
{...divProps}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const LogLinePrefix: FC<HTMLAttributes<HTMLSpanElement>> = (props) => {
|
||||
return <pre css={styles.prefix} {...props} />;
|
||||
};
|
||||
|
||||
const styles = {
|
||||
line: (theme) => ({
|
||||
margin: 0,
|
||||
wordBreak: "break-all",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
fontSize: 13,
|
||||
color: theme.palette.text.primary,
|
||||
fontFamily: MONOSPACE_FONT_FAMILY,
|
||||
height: "auto",
|
||||
padding: `0 var(--log-line-side-padding, ${DEFAULT_LOG_LINE_SIDE_PADDING}px)`,
|
||||
|
||||
"&.error": {
|
||||
backgroundColor: theme.roles.error.background,
|
||||
color: theme.roles.error.text,
|
||||
|
||||
"& .dashed-line": {
|
||||
backgroundColor: theme.roles.error.outline,
|
||||
},
|
||||
},
|
||||
|
||||
"&.debug": {
|
||||
backgroundColor: theme.roles.info.background,
|
||||
color: theme.roles.info.text,
|
||||
|
||||
"& .dashed-line": {
|
||||
backgroundColor: theme.roles.info.outline,
|
||||
},
|
||||
},
|
||||
|
||||
"&.warn": {
|
||||
backgroundColor: theme.roles.warning.background,
|
||||
color: theme.roles.warning.text,
|
||||
|
||||
"& .dashed-line": {
|
||||
backgroundColor: theme.roles.warning.outline,
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
prefix: (theme) => ({
|
||||
userSelect: "none",
|
||||
margin: 0,
|
||||
display: "inline-block",
|
||||
color: theme.palette.text.secondary,
|
||||
marginRight: 24,
|
||||
}),
|
||||
} satisfies Record<string, Interpolation<Theme>>;
|
|
@ -0,0 +1,26 @@
|
|||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { chromatic } from "testHelpers/chromatic";
|
||||
import { MockWorkspaceBuildLogs } from "testHelpers/entities";
|
||||
import { Logs } from "./Logs";
|
||||
|
||||
const meta: Meta<typeof Logs> = {
|
||||
title: "components/Logs",
|
||||
parameters: { chromatic },
|
||||
component: Logs,
|
||||
args: {
|
||||
lines: MockWorkspaceBuildLogs.map((log) => ({
|
||||
level: log.log_level,
|
||||
time: log.created_at,
|
||||
output: log.output,
|
||||
sourceId: log.log_source,
|
||||
})),
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof Logs>;
|
||||
|
||||
const Default: Story = {};
|
||||
|
||||
export { Default as Logs };
|
|
@ -0,0 +1,50 @@
|
|||
import type { Interpolation, Theme } from "@emotion/react";
|
||||
import dayjs from "dayjs";
|
||||
import type { FC } from "react";
|
||||
import { LogLinePrefix, LogLine, type Line } from "./LogLine";
|
||||
|
||||
export const DEFAULT_LOG_LINE_SIDE_PADDING = 24;
|
||||
|
||||
export interface LogsProps {
|
||||
lines: Line[];
|
||||
hideTimestamps?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const Logs: FC<LogsProps> = ({
|
||||
hideTimestamps,
|
||||
lines,
|
||||
className = "",
|
||||
}) => {
|
||||
return (
|
||||
<div css={styles.root} className={`${className} logs-container`}>
|
||||
<div css={{ minWidth: "fit-content" }}>
|
||||
{lines.map((line, idx) => (
|
||||
<LogLine key={idx} level={line.level}>
|
||||
{!hideTimestamps && (
|
||||
<LogLinePrefix>
|
||||
{dayjs(line.time).format(`HH:mm:ss.SSS`)}
|
||||
</LogLinePrefix>
|
||||
)}
|
||||
<span>{line.output}</span>
|
||||
</LogLine>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = {
|
||||
root: (theme) => ({
|
||||
minHeight: 156,
|
||||
padding: "8px 0",
|
||||
borderRadius: 8,
|
||||
overflowX: "auto",
|
||||
background: theme.palette.background.default,
|
||||
|
||||
"&:not(:last-child)": {
|
||||
borderBottom: `1px solid ${theme.palette.divider}`,
|
||||
borderRadius: 0,
|
||||
},
|
||||
}),
|
||||
} satisfies Record<string, Interpolation<Theme>>;
|
|
@ -0,0 +1,55 @@
|
|||
import type { Interpolation, Theme } from "@emotion/react";
|
||||
import AnsiToHTML from "ansi-to-html";
|
||||
import { type FC, type ReactNode, useMemo } from "react";
|
||||
import { type Line, LogLine, LogLinePrefix } from "components/Logs/LogLine";
|
||||
|
||||
const convert = new AnsiToHTML();
|
||||
|
||||
interface AgentLogLineProps {
|
||||
line: Line;
|
||||
number: number;
|
||||
style: React.CSSProperties;
|
||||
sourceIcon: ReactNode;
|
||||
maxLineNumber: number;
|
||||
}
|
||||
|
||||
export const AgentLogLine: FC<AgentLogLineProps> = ({
|
||||
line,
|
||||
number,
|
||||
maxLineNumber,
|
||||
sourceIcon,
|
||||
style,
|
||||
}) => {
|
||||
const output = useMemo(() => {
|
||||
return convert.toHtml(line.output.split(/\r/g).pop() as string);
|
||||
}, [line.output]);
|
||||
|
||||
return (
|
||||
<LogLine css={{ paddingLeft: 16 }} level={line.level} style={style}>
|
||||
{sourceIcon}
|
||||
<LogLinePrefix
|
||||
css={styles.number}
|
||||
style={{
|
||||
minWidth: `${maxLineNumber.toString().length - 1}em`,
|
||||
}}
|
||||
>
|
||||
{number}
|
||||
</LogLinePrefix>
|
||||
<span
|
||||
// Output contains HTML to represent ANSI-code formatting
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: output,
|
||||
}}
|
||||
/>
|
||||
</LogLine>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = {
|
||||
number: (theme) => ({
|
||||
width: 32,
|
||||
textAlign: "right",
|
||||
flexShrink: 0,
|
||||
color: theme.palette.text.disabled,
|
||||
}),
|
||||
} satisfies Record<string, Interpolation<Theme>>;
|
|
@ -10,11 +10,8 @@ import {
|
|||
import { FixedSizeList as List } from "react-window";
|
||||
import * as API from "api/api";
|
||||
import type { WorkspaceAgentLogSource } from "api/typesGenerated";
|
||||
import {
|
||||
LogLine,
|
||||
logLineHeight,
|
||||
} from "modules/workspaces/WorkspaceBuildLogs/Logs";
|
||||
import type { LineWithID } from "./AgentRow";
|
||||
import { AgentLogLine } from "./AgentLogLine";
|
||||
import { AGENT_LOG_LINE_HEIGHT, type LineWithID } from "./AgentRow";
|
||||
|
||||
type AgentLogsProps = Omit<
|
||||
ComponentProps<typeof List>,
|
||||
|
@ -39,7 +36,7 @@ export const AgentLogs = forwardRef<List, AgentLogsProps>(
|
|||
ref={ref}
|
||||
css={styles.logs}
|
||||
itemCount={logs.length}
|
||||
itemSize={logLineHeight}
|
||||
itemSize={AGENT_LOG_LINE_HEIGHT}
|
||||
{...listProps}
|
||||
>
|
||||
{({ index, style }) => {
|
||||
|
@ -58,7 +55,7 @@ export const AgentLogs = forwardRef<List, AgentLogsProps>(
|
|||
}
|
||||
);
|
||||
};
|
||||
const logSource = getLogSource(log.source_id);
|
||||
const logSource = getLogSource(log.sourceId);
|
||||
|
||||
let assignedIcon = false;
|
||||
let icon: JSX.Element;
|
||||
|
@ -98,7 +95,7 @@ export const AgentLogs = forwardRef<List, AgentLogsProps>(
|
|||
let nextChangesSource = false;
|
||||
if (index < logs.length - 1) {
|
||||
nextChangesSource =
|
||||
getLogSource(logs[index + 1].source_id).id !== log.source_id;
|
||||
getLogSource(logs[index + 1].sourceId).id !== log.sourceId;
|
||||
}
|
||||
// We don't want every line to repeat the icon, because
|
||||
// that is ugly and repetitive. This removes the icon
|
||||
|
@ -107,7 +104,7 @@ export const AgentLogs = forwardRef<List, AgentLogsProps>(
|
|||
// same source.
|
||||
if (
|
||||
index > 0 &&
|
||||
getLogSource(logs[index - 1].source_id).id === log.source_id
|
||||
getLogSource(logs[index - 1].sourceId).id === log.sourceId
|
||||
) {
|
||||
icon = (
|
||||
<div
|
||||
|
@ -149,10 +146,10 @@ export const AgentLogs = forwardRef<List, AgentLogsProps>(
|
|||
}
|
||||
|
||||
return (
|
||||
<LogLine
|
||||
<AgentLogLine
|
||||
line={logs[index]}
|
||||
number={index + 1}
|
||||
maxNumber={logs.length}
|
||||
maxLineNumber={logs.length}
|
||||
style={style}
|
||||
sourceIcon={
|
||||
<Tooltip
|
||||
|
@ -208,7 +205,7 @@ export const useAgentLogs = (
|
|||
level: log.level || "info",
|
||||
output: log.output,
|
||||
time: log.created_at,
|
||||
source_id: log.source_id,
|
||||
sourceId: log.source_id,
|
||||
}));
|
||||
|
||||
if (!previousLogs) {
|
||||
|
|
|
@ -79,7 +79,7 @@ const storybookLogs: LineWithID[] = [
|
|||
level: "info",
|
||||
output: line,
|
||||
time: "",
|
||||
source_id: M.MockWorkspaceAgentLogSource.id,
|
||||
sourceId: M.MockWorkspaceAgentLogSource.id,
|
||||
}));
|
||||
|
||||
const meta: Meta<typeof AgentRow> = {
|
||||
|
|
|
@ -21,12 +21,9 @@ import type {
|
|||
WorkspaceAgentMetadata,
|
||||
} from "api/typesGenerated";
|
||||
import { DropdownArrow } from "components/DropdownArrow/DropdownArrow";
|
||||
import type { Line } from "components/Logs/LogLine";
|
||||
import { Stack } from "components/Stack/Stack";
|
||||
import { useProxy } from "contexts/ProxyContext";
|
||||
import {
|
||||
type Line,
|
||||
logLineHeight,
|
||||
} from "modules/workspaces/WorkspaceBuildLogs/Logs";
|
||||
import { AgentLatency } from "./AgentLatency";
|
||||
import { AgentLogs, useAgentLogs } from "./AgentLogs";
|
||||
import { AgentMetadata } from "./AgentMetadata";
|
||||
|
@ -39,6 +36,9 @@ import { TerminalLink } from "./TerminalLink/TerminalLink";
|
|||
import { VSCodeDesktopButton } from "./VSCodeDesktopButton/VSCodeDesktopButton";
|
||||
import { XRayScanAlert } from "./XRayScanAlert";
|
||||
|
||||
// Approximate height of a log line. Used to control virtualized list height.
|
||||
export const AGENT_LOG_LINE_HEIGHT = 20;
|
||||
|
||||
// Logs are stored as the Line interface to make rendering
|
||||
// much more efficient. Instead of mapping objects each time, we're
|
||||
// able to just pass the array of logs to the component.
|
||||
|
@ -115,7 +115,7 @@ export const AgentRow: FC<AgentRowProps> = ({
|
|||
level: "error",
|
||||
output: "Startup logs exceeded the max size of 1MB!",
|
||||
time: new Date().toISOString(),
|
||||
source_id: "",
|
||||
sourceId: "",
|
||||
});
|
||||
}
|
||||
return logs;
|
||||
|
@ -154,7 +154,7 @@ export const AgentRow: FC<AgentRowProps> = ({
|
|||
const distanceFromBottom =
|
||||
logListDivRef.current.scrollHeight -
|
||||
(props.scrollOffset + parent.clientHeight);
|
||||
setBottomOfLogs(distanceFromBottom < logLineHeight);
|
||||
setBottomOfLogs(distanceFromBottom < AGENT_LOG_LINE_HEIGHT);
|
||||
},
|
||||
[logListDivRef],
|
||||
);
|
||||
|
|
|
@ -1,179 +0,0 @@
|
|||
import type { Interpolation, Theme } from "@emotion/react";
|
||||
import AnsiToHTML from "ansi-to-html";
|
||||
import dayjs from "dayjs";
|
||||
import { type FC, type ReactNode, useMemo } from "react";
|
||||
import type { LogLevel } from "api/typesGenerated";
|
||||
import { MONOSPACE_FONT_FAMILY } from "theme/constants";
|
||||
|
||||
export const DEFAULT_LOG_LINE_SIDE_PADDING = 24;
|
||||
|
||||
const convert = new AnsiToHTML();
|
||||
|
||||
export interface Line {
|
||||
time: string;
|
||||
output: string;
|
||||
level: LogLevel;
|
||||
source_id: string;
|
||||
}
|
||||
|
||||
export interface LogsProps {
|
||||
lines: Line[];
|
||||
hideTimestamps?: boolean;
|
||||
className?: string;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export const Logs: FC<LogsProps> = ({
|
||||
hideTimestamps,
|
||||
lines,
|
||||
className = "",
|
||||
}) => {
|
||||
return (
|
||||
<div css={styles.root} className={`${className} logs-container`}>
|
||||
<div css={{ minWidth: "fit-content" }}>
|
||||
{lines.map((line, idx) => (
|
||||
<div
|
||||
css={[styles.line]}
|
||||
className={`${line.level} logs-line`}
|
||||
key={idx}
|
||||
>
|
||||
{!hideTimestamps && (
|
||||
<>
|
||||
<span css={styles.time}>
|
||||
{dayjs(line.time).format(`HH:mm:ss.SSS`)}
|
||||
</span>
|
||||
<span css={styles.space} />
|
||||
</>
|
||||
)}
|
||||
<span>{line.output}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const logLineHeight = 20;
|
||||
|
||||
interface LogLineProps {
|
||||
line: Line;
|
||||
hideTimestamp?: boolean;
|
||||
number?: number;
|
||||
style?: React.CSSProperties;
|
||||
sourceIcon?: ReactNode;
|
||||
maxNumber?: number;
|
||||
}
|
||||
|
||||
export const LogLine: FC<LogLineProps> = ({
|
||||
line,
|
||||
hideTimestamp,
|
||||
number,
|
||||
maxNumber,
|
||||
sourceIcon,
|
||||
style,
|
||||
}) => {
|
||||
const output = useMemo(() => {
|
||||
return convert.toHtml(line.output.split(/\r/g).pop() as string);
|
||||
}, [line.output]);
|
||||
const isUsingLineNumber = number !== undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
css={[styles.line, isUsingLineNumber && { paddingLeft: 16 }]}
|
||||
className={line.level}
|
||||
style={style}
|
||||
>
|
||||
{sourceIcon}
|
||||
{!hideTimestamp && (
|
||||
<>
|
||||
<span
|
||||
css={[styles.time, isUsingLineNumber && styles.number]}
|
||||
style={{
|
||||
minWidth: `${maxNumber ? maxNumber.toString().length - 1 : 0}em`,
|
||||
}}
|
||||
>
|
||||
{number ? number : dayjs(line.time).format(`HH:mm:ss.SSS`)}
|
||||
</span>
|
||||
<span css={styles.space} />
|
||||
</>
|
||||
)}
|
||||
<span
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: output,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = {
|
||||
root: (theme) => ({
|
||||
minHeight: 156,
|
||||
padding: "8px 0",
|
||||
borderRadius: 8,
|
||||
overflowX: "auto",
|
||||
background: theme.palette.background.default,
|
||||
|
||||
"&:not(:last-child)": {
|
||||
borderBottom: `1px solid ${theme.palette.divider}`,
|
||||
borderRadius: 0,
|
||||
},
|
||||
}),
|
||||
line: (theme) => ({
|
||||
wordBreak: "break-all",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
fontSize: 13,
|
||||
color: theme.palette.text.primary,
|
||||
fontFamily: MONOSPACE_FONT_FAMILY,
|
||||
height: "auto",
|
||||
// Whitespace is significant in terminal output for alignment
|
||||
whiteSpace: "pre",
|
||||
padding: `0 var(--log-line-side-padding, ${DEFAULT_LOG_LINE_SIDE_PADDING}px)`,
|
||||
|
||||
"&.error": {
|
||||
backgroundColor: theme.roles.error.background,
|
||||
color: theme.roles.error.text,
|
||||
|
||||
"& .dashed-line": {
|
||||
backgroundColor: theme.roles.error.outline,
|
||||
},
|
||||
},
|
||||
|
||||
"&.debug": {
|
||||
backgroundColor: theme.roles.info.background,
|
||||
color: theme.roles.info.text,
|
||||
|
||||
"& .dashed-line": {
|
||||
backgroundColor: theme.roles.info.outline,
|
||||
},
|
||||
},
|
||||
|
||||
"&.warn": {
|
||||
backgroundColor: theme.roles.warning.background,
|
||||
color: theme.roles.warning.text,
|
||||
|
||||
"& .dashed-line": {
|
||||
backgroundColor: theme.roles.warning.outline,
|
||||
},
|
||||
},
|
||||
}),
|
||||
space: {
|
||||
userSelect: "none",
|
||||
width: 24,
|
||||
display: "block",
|
||||
flexShrink: 0,
|
||||
},
|
||||
time: (theme) => ({
|
||||
userSelect: "none",
|
||||
whiteSpace: "pre",
|
||||
display: "inline-block",
|
||||
color: theme.palette.text.secondary,
|
||||
}),
|
||||
number: (theme) => ({
|
||||
width: 32,
|
||||
textAlign: "right",
|
||||
flexShrink: 0,
|
||||
color: theme.palette.text.disabled,
|
||||
}),
|
||||
} satisfies Record<string, Interpolation<Theme>>;
|
|
@ -2,8 +2,8 @@ import { type Interpolation, type Theme, useTheme } from "@emotion/react";
|
|||
import dayjs from "dayjs";
|
||||
import { type FC, Fragment, type HTMLAttributes } from "react";
|
||||
import type { ProvisionerJobLog } from "api/typesGenerated";
|
||||
import { DEFAULT_LOG_LINE_SIDE_PADDING, Logs } from "components/Logs/Logs";
|
||||
import { BODY_FONT_FAMILY, MONOSPACE_FONT_FAMILY } from "theme/constants";
|
||||
import { DEFAULT_LOG_LINE_SIDE_PADDING, Logs } from "./Logs";
|
||||
|
||||
const Language = {
|
||||
seconds: "seconds",
|
||||
|
@ -69,7 +69,7 @@ export const WorkspaceBuildLogs: FC<WorkspaceBuildLogsProps> = ({
|
|||
time: log.created_at,
|
||||
output: log.output,
|
||||
level: log.log_level,
|
||||
source_id: log.log_source,
|
||||
sourceId: log.log_source,
|
||||
}));
|
||||
const duration = getStageDurationInSeconds(logs);
|
||||
const shouldDisplayDuration = duration !== undefined;
|
||||
|
|
|
@ -35,7 +35,7 @@ const permissionsToCheck = (workspace: TypesGen.Workspace) =>
|
|||
updateWorkspace: {
|
||||
object: {
|
||||
resource_type: "workspace",
|
||||
resource_id: workspace.id,
|
||||
resourceId: workspace.id,
|
||||
owner_id: workspace.owner_id,
|
||||
},
|
||||
action: "update",
|
||||
|
|
Loading…
Reference in New Issue