From 4c64a2968def99ab06d5e2e62a7affaea0a6653e Mon Sep 17 00:00:00 2001 From: Savannah Ostrowski Date: Wed, 17 Jun 2026 18:13:40 -0700 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=94=92=EF=B8=8F=20Render=20cloud=20lo?= =?UTF-8?q?gs=20via=20DOM=20instead=20of=20HTML=20strings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/cloud/commands/logs.ts | 31 ++++++------- src/cloud/ui/panel/styles.css | 24 ++++++++++ src/cloud/ui/panel/webview.ts | 31 +++++++++++-- src/test/cloud/commands/logs.test.ts | 69 +++++++++++----------------- 4 files changed, 90 insertions(+), 65 deletions(-) diff --git a/src/cloud/commands/logs.ts b/src/cloud/commands/logs.ts index cdbd6b9..dfc7e85 100644 --- a/src/cloud/commands/logs.ts +++ b/src/cloud/commands/logs.ts @@ -36,17 +36,6 @@ const FILTER_CHIPS = [ { level: "critical", label: "CRITICAL" }, ] -const HTML_ESCAPE: Record = { - "&": "&", - "<": "<", - ">": ">", - '"': """, -} - -function escapeHtml(text: string): string { - return text.replace(/[&<>"]/g, (ch) => HTML_ESCAPE[ch]) -} - function formatTimestamp(ts: string): string { const d = new Date(ts) return Number.isNaN(d.getTime()) ? ts : `${d.toISOString().slice(0, 23)}Z` @@ -71,14 +60,20 @@ function normalizeLevel(level: string, message?: string): string { return resolved } -export function formatLogEntry(entry: AppLogEntry): string { +export interface FormattedLogEntry { + level: string + timestamp: string + message: string +} + +export function formatLogEntry(entry: AppLogEntry): FormattedLogEntry { const rawLevel = (entry.level ?? "info").toLowerCase() const level = normalizeLevel(rawLevel, entry.message) - const pipeColor = LEVEL_COLORS[level] ?? LEVEL_COLORS.default - const ts = escapeHtml(formatTimestamp(entry.timestamp)) - const msg = escapeHtml(entry.message) - const escapedLevel = escapeHtml(level) - return `
${ts} ${msg}
` + return { + level, + timestamp: formatTimestamp(entry.timestamp), + message: entry.message, + } } // --- Webview HTML --- @@ -290,7 +285,7 @@ export class LogsViewProvider implements vscode.WebviewViewProvider { count++ this.view.webview.postMessage({ type: "log", - html: formatLogEntry(entry), + entry: formatLogEntry(entry), }) } clearTimeout(connectedTimer) diff --git a/src/cloud/ui/panel/styles.css b/src/cloud/ui/panel/styles.css index 645fa01..4e936d4 100644 --- a/src/cloud/ui/panel/styles.css +++ b/src/cloud/ui/panel/styles.css @@ -248,6 +248,30 @@ body { display: none; } +.pipe { + color: #888; +} + +.log-line[data-level="debug"] .pipe { + color: #4488ff; +} + +.log-line[data-level="info"] .pipe { + color: #00cccc; +} + +.log-line[data-level="warning"] .pipe { + color: #ccaa00; +} + +.log-line[data-level="error"] .pipe { + color: #f14c4c; +} + +.log-line[data-level="critical"] .pipe { + color: #cc66cc; +} + .ts { opacity: 0.5; } diff --git a/src/cloud/ui/panel/webview.ts b/src/cloud/ui/panel/webview.ts index fbaad21..c71b9f6 100644 --- a/src/cloud/ui/panel/webview.ts +++ b/src/cloud/ui/panel/webview.ts @@ -129,6 +129,28 @@ function setStreamingState(streaming: boolean, appLabel?: string): void { streaming && appLabel ? `Streaming logs for ${appLabel}...` : "" } +interface LogEntry { + level: string + timestamp: string + message: string +} + +// Build the log line as a DOM node. The untrusted message is set as a text +// node, so it is never parsed as HTML — no sanitization needed. +function buildLogLine(entry: LogEntry): HTMLElement { + const line = document.createElement("div") + line.className = "log-line" + line.dataset.level = entry.level + const pipe = document.createElement("span") + pipe.className = "pipe" + pipe.textContent = "┃" + const ts = document.createElement("span") + ts.className = "ts" + ts.textContent = entry.timestamp + line.append(pipe, " ", ts, " ", entry.message) + return line +} + window.addEventListener("message", (event) => { const msg = event.data if (msg.type === "log") { @@ -137,13 +159,12 @@ window.addEventListener("message", (event) => { firstEntry = false } const wasAtBottom = isNearBottom() - logs.insertAdjacentHTML("beforeend", msg.html) - const last = logs.lastElementChild as HTMLElement | null + const line = buildLogLine(msg.entry) + logs.append(line) if ( - last && - !shouldShow(last, getSelectedLevels(), searchInput.value.toLowerCase()) + !shouldShow(line, getSelectedLevels(), searchInput.value.toLowerCase()) ) { - last.classList.add("filtered") + line.classList.add("filtered") } if (wasAtBottom) window.scrollTo(0, document.body.scrollHeight) } else if (msg.type === "status") { diff --git a/src/test/cloud/commands/logs.test.ts b/src/test/cloud/commands/logs.test.ts index a1ef254..4be9b88 100644 --- a/src/test/cloud/commands/logs.test.ts +++ b/src/test/cloud/commands/logs.test.ts @@ -136,8 +136,10 @@ suite("cloud/commands/logs", () => { const logMessages = messages.filter((m) => m.type === "log") assert.strictEqual(logMessages.length, 2) - assert.ok(logMessages[0].html.includes("line 1")) - assert.ok(logMessages[1].html.includes("line 2")) + assert.strictEqual(logMessages[0].entry.message, "line 1") + assert.strictEqual(logMessages[0].entry.level, "info") + assert.strictEqual(logMessages[1].entry.message, "line 2") + assert.strictEqual(logMessages[1].entry.level, "error") const statusMessages = messages.filter((m) => m.type === "status") assert.ok(statusMessages.some((m) => m.text === "Stream ended.")) @@ -446,104 +448,87 @@ suite("cloud/commands/logs", () => { }) suite("formatLogEntry", () => { - test("formats a log entry with level, timestamp, and message", () => { - const html = formatLogEntry({ + test("returns level, timestamp, and message fields", () => { + const entry = formatLogEntry({ timestamp: "2025-01-15T10:30:00Z", message: "Server started", level: "info", }) - assert.ok(html.startsWith('
')) - assert.ok(html.includes("┃")) + assert.equal(entry.level, "info") + assert.equal(entry.message, "Server started") + assert.equal(entry.timestamp, "2025-01-15T10:30:00.000Z") }) test("normalizes warn to warning", () => { - const html = formatLogEntry({ + const entry = formatLogEntry({ timestamp: "2025-01-15T10:30:00Z", message: "msg", level: "warn", }) - assert.ok(html.includes('data-level="warning"')) + assert.equal(entry.level, "warning") }) test("normalizes fatal to critical", () => { - const html = formatLogEntry({ + const entry = formatLogEntry({ timestamp: "2025-01-15T10:30:00Z", message: "msg", level: "fatal", }) - assert.ok(html.includes('data-level="critical"')) + assert.equal(entry.level, "critical") }) test("infers level from message prefix when level is unknown", () => { - const html = formatLogEntry({ + const entry = formatLogEntry({ timestamp: "2025-01-15T10:30:00Z", message: ' INFO 50.35.91.231:0 - "GET / HTTP/1.1" 200', level: "unknown", }) - assert.ok(html.includes('data-level="info"')) - assert.ok(html.includes("color:#00cccc")) + assert.equal(entry.level, "info") }) test("defaults to info when level is missing", () => { - const html = formatLogEntry({ + const entry = formatLogEntry({ timestamp: "2025-01-15T10:30:00Z", message: "no level", level: undefined as any, }) - assert.ok(html.includes('data-level="info"')) + assert.equal(entry.level, "info") }) - test("uses default color for unknown level", () => { - const html = formatLogEntry({ + test("preserves unrecognized level verbatim", () => { + const entry = formatLogEntry({ timestamp: "2025-01-15T10:30:00Z", message: "msg", level: "trace", }) - assert.ok(html.includes('data-level="trace"')) - assert.ok(html.includes("color:#888")) + assert.equal(entry.level, "trace") }) test("lowercases level", () => { - const html = formatLogEntry({ + const entry = formatLogEntry({ timestamp: "2025-01-15T10:30:00Z", message: "msg", level: "ERROR", }) - assert.ok(html.includes('data-level="error"')) + assert.equal(entry.level, "error") }) - test("escapes HTML in message", () => { - const html = formatLogEntry({ + test("passes message through unescaped (webview sets it as text)", () => { + const entry = formatLogEntry({ timestamp: "2025-01-15T10:30:00Z", message: "", level: "info", }) - assert.ok(html.includes("<script>")) - assert.ok(!html.includes("") }) test("handles invalid timestamp gracefully", () => { - const html = formatLogEntry({ + const entry = formatLogEntry({ timestamp: "not-a-date", message: "msg", level: "info", }) - assert.ok(html.includes("not-a-date")) + assert.equal(entry.timestamp, "not-a-date") }) }) From 96882648fddf4cac9c6a7b1cf7dc9740ef55283f Mon Sep 17 00:00:00 2001 From: Savannah Ostrowski Date: Wed, 17 Jun 2026 18:20:47 -0700 Subject: [PATCH 2/2] =?UTF-8?q?=E2=9A=B0=EF=B8=8F=20More=20cleanup=20to=20?= =?UTF-8?q?reuse=20existing=20interface=20and=20remove=20stale=20const?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/cloud/commands/logs.ts | 40 ++++++++++++++--------------------- src/cloud/ui/panel/webview.ts | 10 +++------ 2 files changed, 19 insertions(+), 31 deletions(-) diff --git a/src/cloud/commands/logs.ts b/src/cloud/commands/logs.ts index dfc7e85..baa721f 100644 --- a/src/cloud/commands/logs.ts +++ b/src/cloud/commands/logs.ts @@ -16,17 +16,17 @@ const SINCE_OPTIONS = [ { label: "1 day", value: "1d" }, ] -// Roughly matches fastapi-cloud-cli LOG_LEVEL_COLORS -const LEVEL_COLORS: Record = { - debug: "#4488ff", - info: "#00cccc", - warning: "#ccaa00", - warn: "#ccaa00", - error: "#f14c4c", - critical: "#cc66cc", - fatal: "#cc66cc", - default: "#888", -} +// Levels recognized when inferring a log's level from its message prefix. +// Pipe colors for these live in the webview stylesheet, keyed on [data-level]. +const KNOWN_LEVELS = [ + "debug", + "info", + "warning", + "warn", + "error", + "critical", + "fatal", +] const FILTER_CHIPS = [ { level: "debug", label: "DEBUG" }, @@ -41,12 +41,7 @@ function formatTimestamp(ts: string): string { return Number.isNaN(d.getTime()) ? ts : `${d.toISOString().slice(0, 23)}Z` } -const MESSAGE_LEVEL_RE = new RegExp( - `^\\s*(${Object.keys(LEVEL_COLORS) - .filter((k) => k !== "default") - .join("|")})\\b`, - "i", -) +const MESSAGE_LEVEL_RE = new RegExp(`^\\s*(${KNOWN_LEVELS.join("|")})\\b`, "i") function normalizeLevel(level: string, message?: string): string { // The streaming API returns "unknown" for new logs (Loki limitation) so try to infer from message prefix @@ -60,13 +55,10 @@ function normalizeLevel(level: string, message?: string): string { return resolved } -export interface FormattedLogEntry { - level: string - timestamp: string - message: string -} - -export function formatLogEntry(entry: AppLogEntry): FormattedLogEntry { +// Returns the entry with its level normalized and timestamp formatted. Same +// shape as the raw entry, but the webview builds the DOM node itself +// (className/dataset/textContent), so no HTML escaping is required here. +export function formatLogEntry(entry: AppLogEntry): AppLogEntry { const rawLevel = (entry.level ?? "info").toLowerCase() const level = normalizeLevel(rawLevel, entry.message) return { diff --git a/src/cloud/ui/panel/webview.ts b/src/cloud/ui/panel/webview.ts index c71b9f6..09671a8 100644 --- a/src/cloud/ui/panel/webview.ts +++ b/src/cloud/ui/panel/webview.ts @@ -1,6 +1,8 @@ /// /// +import type { AppLogEntry } from "../../api" + declare function acquireVsCodeApi(): { postMessage(msg: unknown): void } const vscode = acquireVsCodeApi() @@ -129,15 +131,9 @@ function setStreamingState(streaming: boolean, appLabel?: string): void { streaming && appLabel ? `Streaming logs for ${appLabel}...` : "" } -interface LogEntry { - level: string - timestamp: string - message: string -} - // Build the log line as a DOM node. The untrusted message is set as a text // node, so it is never parsed as HTML — no sanitization needed. -function buildLogLine(entry: LogEntry): HTMLElement { +function buildLogLine(entry: AppLogEntry): HTMLElement { const line = document.createElement("div") line.className = "log-line" line.dataset.level = entry.level