chore(site): refactor logs and add stories (#12553)

This commit is contained in:
Bruno Quaresma 2024-03-14 14:49:37 -03:00 committed by GitHub
parent 0723dd3abf
commit f78b5c1cfe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 278 additions and 201 deletions

View File

@ -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",
},
};

View File

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

View File

@ -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 };

View File

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

View File

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

View File

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

View File

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

View File

@ -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],
);

View File

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

View File

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

View File

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