Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 22 additions & 35 deletions src/cloud/commands/logs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,17 @@ const SINCE_OPTIONS = [
{ label: "1 day", value: "1d" },
]

// Roughly matches fastapi-cloud-cli LOG_LEVEL_COLORS
const LEVEL_COLORS: Record<string, string> = {
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" },
Expand All @@ -36,28 +36,12 @@ const FILTER_CHIPS = [
{ level: "critical", label: "CRITICAL" },
]

const HTML_ESCAPE: Record<string, string> = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
}

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`
}

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
Expand All @@ -71,14 +55,17 @@ function normalizeLevel(level: string, message?: string): string {
return resolved
}

export function formatLogEntry(entry: AppLogEntry): string {
// 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)
const pipeColor = LEVEL_COLORS[level] ?? LEVEL_COLORS.default
const ts = escapeHtml(formatTimestamp(entry.timestamp))
const msg = escapeHtml(entry.message)
const escapedLevel = escapeHtml(level)
return `<div class="log-line" data-level="${escapedLevel}"><span class="pipe" style="color:${pipeColor}">┃</span> <span class="ts">${ts}</span> ${msg}</div>`
return {
level,
timestamp: formatTimestamp(entry.timestamp),
message: entry.message,
}
}

// --- Webview HTML ---
Expand Down Expand Up @@ -290,7 +277,7 @@ export class LogsViewProvider implements vscode.WebviewViewProvider {
count++
this.view.webview.postMessage({
type: "log",
html: formatLogEntry(entry),
entry: formatLogEntry(entry),
})
}
clearTimeout(connectedTimer)
Expand Down
24 changes: 24 additions & 0 deletions src/cloud/ui/panel/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
27 changes: 22 additions & 5 deletions src/cloud/ui/panel/webview.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
/// <reference lib="dom" />
/// <reference lib="dom.iterable" />

import type { AppLogEntry } from "../../api"

declare function acquireVsCodeApi(): { postMessage(msg: unknown): void }

const vscode = acquireVsCodeApi()
Expand Down Expand Up @@ -129,6 +131,22 @@ function setStreamingState(streaming: boolean, appLabel?: string): void {
streaming && appLabel ? `Streaming logs for ${appLabel}...` : ""
}

// 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: AppLogEntry): 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") {
Expand All @@ -137,13 +155,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") {
Expand Down
69 changes: 27 additions & 42 deletions src/test/cloud/commands/logs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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."))
Expand Down Expand Up @@ -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('<div class="log-line"'))
assert.ok(html.includes('data-level="info"'))
assert.ok(html.includes("color:#00cccc"))
assert.ok(html.includes("Server started"))
assert.ok(html.includes("2025-01-15T10:30:00.000Z"))
assert.ok(html.includes('<span class="ts">'))
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: "<script>alert('xss')</script>",
level: "info",
})
assert.ok(html.includes("&lt;script&gt;"))
assert.ok(!html.includes("<script>alert"))
})

test("escapes quotes in level for attribute safety", () => {
const html = formatLogEntry({
timestamp: "2025-01-15T10:30:00Z",
message: "msg",
level: '"onclick="alert(1)',
})
assert.ok(!html.includes('data-level=""onclick'))
assert.ok(html.includes("&quot;"))
assert.equal(entry.message, "<script>alert('xss')</script>")
})

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")
})
})

Expand Down
Loading