diff --git a/site/package.json b/site/package.json
index 9db41828d38..161b90b95f6 100644
--- a/site/package.json
+++ b/site/package.json
@@ -67,7 +67,6 @@
"@xterm/addon-web-links": "0.12.0",
"@xterm/addon-webgl": "0.19.0",
"@xterm/xterm": "5.5.0",
- "ansi-to-html": "0.7.2",
"axios": "1.16.1",
"chroma-js": "2.6.0",
"class-variance-authority": "0.7.1",
@@ -79,6 +78,7 @@
"dayjs": "1.11.20",
"diff": "8.0.4",
"emoji-mart": "5.6.0",
+ "fancy-ansi": "0.1.3",
"file-saver": "2.0.5",
"formik": "2.4.9",
"front-matter": "4.0.2",
diff --git a/site/pnpm-lock.yaml b/site/pnpm-lock.yaml
index 241100290e1..1a57ac45353 100644
--- a/site/pnpm-lock.yaml
+++ b/site/pnpm-lock.yaml
@@ -122,9 +122,6 @@ importers:
'@xterm/xterm':
specifier: 5.5.0
version: 5.5.0
- ansi-to-html:
- specifier: 0.7.2
- version: 0.7.2
axios:
specifier: 1.16.1
version: 1.16.1
@@ -158,6 +155,9 @@ importers:
emoji-mart:
specifier: 5.6.0
version: 5.6.0
+ fancy-ansi:
+ specifier: 0.1.3
+ version: 0.1.3
file-saver:
specifier: 2.0.5
version: 2.0.5
@@ -3097,11 +3097,6 @@ packages:
resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==, tarball: https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz}
engines: {node: '>=12'}
- ansi-to-html@0.7.2:
- resolution: {integrity: sha512-v6MqmEpNlxF+POuyhKkidusCHWWkaLcGRURzivcU3I9tv7k4JVhFcnukrM5Rlk2rUywdZuzYAZ+kbZqWCnfN3g==, tarball: https://registry.npmjs.org/ansi-to-html/-/ansi-to-html-0.7.2.tgz}
- engines: {node: '>=8.0.0'}
- hasBin: true
-
any-promise@1.3.0:
resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==, tarball: https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz}
@@ -3864,9 +3859,6 @@ packages:
resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==, tarball: https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz}
engines: {node: '>= 0.8'}
- entities@2.2.0:
- resolution: {integrity: sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==, tarball: https://registry.npmjs.org/entities/-/entities-2.2.0.tgz}
-
entities@6.0.1:
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==, tarball: https://registry.npmjs.org/entities/-/entities-6.0.1.tgz}
engines: {node: '>=0.12'}
@@ -3955,6 +3947,9 @@ packages:
extend@3.0.2:
resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==, tarball: https://registry.npmjs.org/extend/-/extend-3.0.2.tgz}
+ fancy-ansi@0.1.3:
+ resolution: {integrity: sha512-tRQVTo5jjdSIiydqgzIIEZpKddzSsfGLsSVt6vWdjVm7fbvDTiQkyoPu6Z3dIPlAM4OZk0jP5jmTCX4G8WGgBw==, tarball: https://registry.npmjs.org/fancy-ansi/-/fancy-ansi-0.1.3.tgz}
+
fast-equals@5.3.2:
resolution: {integrity: sha512-6rxyATwPCkaFIL3JLqw8qXqMpIZ942pTX/tbQFkRsDGblS8tNGtlUauA/+mt6RUfqn/4MoEr+WDkYoIQbibWuQ==, tarball: https://registry.npmjs.org/fast-equals/-/fast-equals-5.3.2.tgz}
engines: {node: '>=6.0.0'}
@@ -9313,10 +9308,6 @@ snapshots:
ansi-styles@6.2.3: {}
- ansi-to-html@0.7.2:
- dependencies:
- entities: 2.2.0
-
any-promise@1.3.0: {}
anymatch@3.1.3:
@@ -10078,8 +10069,6 @@ snapshots:
encodeurl@2.0.0: {}
- entities@2.2.0: {}
-
entities@6.0.1: {}
error-ex@1.3.2:
@@ -10210,6 +10199,10 @@ snapshots:
extend@3.0.2: {}
+ fancy-ansi@0.1.3:
+ dependencies:
+ escape-html: 1.0.3
+
fast-equals@5.3.2: {}
fast-glob@3.3.3:
diff --git a/site/src/modules/resources/AgentLogs/AgentLogLine.test.tsx b/site/src/modules/resources/AgentLogs/AgentLogLine.test.tsx
index 80b1d900b04..7dcdc1a5de4 100644
--- a/site/src/modules/resources/AgentLogs/AgentLogLine.test.tsx
+++ b/site/src/modules/resources/AgentLogs/AgentLogLine.test.tsx
@@ -3,21 +3,50 @@ import type { Line } from "#/components/Logs/LogLine";
import { renderComponent } from "#/testHelpers/renderHelpers";
import { AgentLogLine } from "./AgentLogLine";
-const line: Line = {
+const makeLine = (output: string): Line => ({
id: 1,
level: "info",
- output: 'safe xss',
+ output,
sourceId: "source-id",
time: "2024-03-14T11:31:04.090715Z",
-};
+});
+
+const renderLine = (output: string) =>
+ renderComponent(
+ ,
+ );
describe("AgentLogLine", () => {
it("renders log HTML as escaped text", () => {
- renderComponent();
+ renderLine('safe xss');
expect(screen.queryByTestId("agent-log-xss")).not.toBeInTheDocument();
expect(
screen.getByText(/safe xss<\/span>/),
).toBeInTheDocument();
});
+
+ it("renders ANSI color codes as styled markup", () => {
+ renderLine("\u001b[31mred\u001b[0m plain");
+
+ const colored = screen.getByText("red");
+ expect(colored.tagName).toBe("SPAN");
+ expect(colored.getAttribute("style")).toContain("--ansi-red");
+ expect(screen.getByText(/plain/)).toBeInTheDocument();
+ });
+
+ it("shows only the text after the last carriage return", () => {
+ renderLine("downloading... 50%\rdownloading... 100%");
+
+ expect(screen.getByText("downloading... 100%")).toBeInTheDocument();
+ expect(screen.queryByText(/50%/)).not.toBeInTheDocument();
+ });
+
+ it("renders a ReDoS payload without hanging", () => {
+ // Cure53 CDM-02-004: this pattern caused catastrophic backtracking in
+ // ansi-to-html. fancy-ansi parses it in linear time.
+ const start = performance.now();
+ renderLine(`\u001b[${"1".repeat(50000)}`);
+ expect(performance.now() - start).toBeLessThan(1000);
+ });
});
diff --git a/site/src/modules/resources/AgentLogs/AgentLogLine.tsx b/site/src/modules/resources/AgentLogs/AgentLogLine.tsx
index d7b3c50dbfe..46f6e24784e 100644
--- a/site/src/modules/resources/AgentLogs/AgentLogLine.tsx
+++ b/site/src/modules/resources/AgentLogs/AgentLogLine.tsx
@@ -1,12 +1,10 @@
-import AnsiToHTML from "ansi-to-html";
import dayjs from "dayjs";
+import { AnsiHtml } from "fancy-ansi/react";
import { type FC, type ReactNode, useMemo } from "react";
import { type Line, LogLine, LogLinePrefix } from "#/components/Logs/LogLine";
// Approximate height of a log line. Used to control virtualized list height.
export const AGENT_LOG_LINE_HEIGHT = 20;
-const convert = new AnsiToHTML({ escapeXML: true });
-
interface AgentLogLineProps {
line: Line;
style: React.CSSProperties;
@@ -18,9 +16,13 @@ export const AgentLogLine: FC = ({
sourceIcon,
style,
}) => {
- const output = useMemo(() => {
- return convert.toHtml(line.output.split(/\r/g).pop() as string);
- }, [line.output]);
+ // Only render the text after the last carriage return so progress-bar style
+ // output that redraws a single line shows its final state.
+ const lastCarriageReturn = line.output.lastIndexOf("\r");
+ const output =
+ lastCarriageReturn === -1
+ ? line.output
+ : line.output.slice(lastCarriageReturn + 1);
const timestamp = useMemo(() => {
return dayjs(line.time).format("HH:mm:ss.SSS");
}, [line.time]);
@@ -29,12 +31,7 @@ export const AgentLogLine: FC = ({
{sourceIcon}
{timestamp}
-
+
);
};
diff --git a/site/src/modules/resources/AgentLogs/AgentLogs.stories.tsx b/site/src/modules/resources/AgentLogs/AgentLogs.stories.tsx
index bd4f3364b7b..1cd1035a079 100644
--- a/site/src/modules/resources/AgentLogs/AgentLogs.stories.tsx
+++ b/site/src/modules/resources/AgentLogs/AgentLogs.stories.tsx
@@ -1,4 +1,5 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
+import type { Line } from "#/components/Logs/LogLine";
import { AGENT_LOG_LINE_HEIGHT } from "./AgentLogLine";
import { AgentLogs } from "./AgentLogs";
import { MockLogs, MockSources } from "./mocks";
@@ -28,3 +29,39 @@ export const Overflowed: Story = {
overflowed: true,
},
};
+
+const sourceId = MockSources[0].id;
+
+// Demonstrates how ANSI escape sequences and carriage-return progress-bar
+// redraws are rendered in the log viewer.
+const AnsiLogs: readonly Line[] = [
+ {
+ id: 1,
+ level: "info",
+ output:
+ "\u001b[31mred\u001b[0m \u001b[32mgreen\u001b[0m \u001b[34mblue\u001b[0m \u001b[1mbold\u001b[0m \u001b[3mitalic\u001b[0m",
+ time: "2024-03-14T11:31:04.090715Z",
+ sourceId,
+ },
+ {
+ id: 2,
+ level: "info",
+ output: "\u001b[43m\u001b[30m warning \u001b[0m background colors",
+ time: "2024-03-14T11:31:04.090715Z",
+ sourceId,
+ },
+ {
+ id: 3,
+ level: "info",
+ output: "downloading... 50%\rdownloading... 100%",
+ time: "2024-03-14T11:31:04.090715Z",
+ sourceId,
+ },
+];
+
+export const AnsiFormatting: Story = {
+ args: {
+ logs: AnsiLogs,
+ height: AnsiLogs.length * AGENT_LOG_LINE_HEIGHT,
+ },
+};