diff --git a/site/src/components/Logs/LogLine.stories.tsx b/site/src/components/Logs/LogLine.stories.tsx new file mode 100644 index 0000000000000..1dbfc835d2441 --- /dev/null +++ b/site/src/components/Logs/LogLine.stories.tsx @@ -0,0 +1,48 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { chromatic } from "testHelpers/chromatic"; +import { LogLine, LogLinePrefix } from "./LogLine"; + +const meta: Meta = { + title: "components/Logs/LogLine", + parameters: { chromatic }, + component: LogLine, + args: { + level: "info", + children: ( + <> + 13:45:31.072 + info: Starting build + + ), + }, +}; + +export default meta; + +type Story = StoryObj; + +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", + }, +}; diff --git a/site/src/components/Logs/LogLine.tsx b/site/src/components/Logs/LogLine.tsx new file mode 100644 index 0000000000000..7d33d23998c41 --- /dev/null +++ b/site/src/components/Logs/LogLine.tsx @@ -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; + +export const LogLine: FC = ({ level, ...divProps }) => { + return ( +
+  );
+};
+
+export const LogLinePrefix: FC> = (props) => {
+  return 
;
+};
+
+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>;
diff --git a/site/src/components/Logs/Logs.stories.tsx b/site/src/components/Logs/Logs.stories.tsx
new file mode 100644
index 0000000000000..3bb09401c5332
--- /dev/null
+++ b/site/src/components/Logs/Logs.stories.tsx
@@ -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 = {
+  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;
+
+const Default: Story = {};
+
+export { Default as Logs };
diff --git a/site/src/components/Logs/Logs.tsx b/site/src/components/Logs/Logs.tsx
new file mode 100644
index 0000000000000..76a16e7f4e3c2
--- /dev/null
+++ b/site/src/components/Logs/Logs.tsx
@@ -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 = ({
+  hideTimestamps,
+  lines,
+  className = "",
+}) => {
+  return (
+    
+
+ {lines.map((line, idx) => ( + + {!hideTimestamps && ( + + {dayjs(line.time).format(`HH:mm:ss.SSS`)} + + )} + {line.output} + + ))} +
+
+ ); +}; + +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>; diff --git a/site/src/modules/resources/AgentLogLine.tsx b/site/src/modules/resources/AgentLogLine.tsx new file mode 100644 index 0000000000000..7866669b4e5d0 --- /dev/null +++ b/site/src/modules/resources/AgentLogLine.tsx @@ -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 = ({ + line, + number, + maxLineNumber, + sourceIcon, + style, +}) => { + const output = useMemo(() => { + return convert.toHtml(line.output.split(/\r/g).pop() as string); + }, [line.output]); + + return ( + + {sourceIcon} + + {number} + + + + ); +}; + +const styles = { + number: (theme) => ({ + width: 32, + textAlign: "right", + flexShrink: 0, + color: theme.palette.text.disabled, + }), +} satisfies Record>; diff --git a/site/src/modules/resources/AgentLogs.tsx b/site/src/modules/resources/AgentLogs.tsx index 16515efb7c04e..b86fe5851b491 100644 --- a/site/src/modules/resources/AgentLogs.tsx +++ b/site/src/modules/resources/AgentLogs.tsx @@ -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, @@ -39,7 +36,7 @@ export const AgentLogs = forwardRef( 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( } ); }; - 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( 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( // same source. if ( index > 0 && - getLogSource(logs[index - 1].source_id).id === log.source_id + getLogSource(logs[index - 1].sourceId).id === log.sourceId ) { icon = (
( } return ( - = { diff --git a/site/src/modules/resources/AgentRow.tsx b/site/src/modules/resources/AgentRow.tsx index fb299abfb9f10..45f97288753d7 100644 --- a/site/src/modules/resources/AgentRow.tsx +++ b/site/src/modules/resources/AgentRow.tsx @@ -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 = ({ 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 = ({ const distanceFromBottom = logListDivRef.current.scrollHeight - (props.scrollOffset + parent.clientHeight); - setBottomOfLogs(distanceFromBottom < logLineHeight); + setBottomOfLogs(distanceFromBottom < AGENT_LOG_LINE_HEIGHT); }, [logListDivRef], ); diff --git a/site/src/modules/workspaces/WorkspaceBuildLogs/Logs.tsx b/site/src/modules/workspaces/WorkspaceBuildLogs/Logs.tsx deleted file mode 100644 index 6d253e2055261..0000000000000 --- a/site/src/modules/workspaces/WorkspaceBuildLogs/Logs.tsx +++ /dev/null @@ -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 = ({ - hideTimestamps, - lines, - className = "", -}) => { - return ( -
-
- {lines.map((line, idx) => ( -
- {!hideTimestamps && ( - <> - - {dayjs(line.time).format(`HH:mm:ss.SSS`)} - - - - )} - {line.output} -
- ))} -
-
- ); -}; - -export const logLineHeight = 20; - -interface LogLineProps { - line: Line; - hideTimestamp?: boolean; - number?: number; - style?: React.CSSProperties; - sourceIcon?: ReactNode; - maxNumber?: number; -} - -export const LogLine: FC = ({ - 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 ( -
- {sourceIcon} - {!hideTimestamp && ( - <> - - {number ? number : dayjs(line.time).format(`HH:mm:ss.SSS`)} - - - - )} - -
- ); -}; - -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>; diff --git a/site/src/modules/workspaces/WorkspaceBuildLogs/WorkspaceBuildLogs.tsx b/site/src/modules/workspaces/WorkspaceBuildLogs/WorkspaceBuildLogs.tsx index 647500e92059c..88cfdd5f14cad 100644 --- a/site/src/modules/workspaces/WorkspaceBuildLogs/WorkspaceBuildLogs.tsx +++ b/site/src/modules/workspaces/WorkspaceBuildLogs/WorkspaceBuildLogs.tsx @@ -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 = ({ 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; diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx index 2cb2e4cabcb14..7830a161c879e 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx @@ -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",