From 53b36c71ce28c7e5783b46d990ed17a63a2f06b2 Mon Sep 17 00:00:00 2001 From: SaelixCode Date: Wed, 25 Feb 2026 22:51:07 -0500 Subject: [PATCH] feat: Implement an interactive terminal component with real-time log streaming, and formatting --- backend/src/services/ComposeService.ts | 25 +++++++- backend/src/services/LogFormatter.ts | 87 ++++++++++++++++++++++++++ frontend/src/components/Terminal.tsx | 25 ++++++-- 3 files changed, 130 insertions(+), 7 deletions(-) create mode 100644 backend/src/services/LogFormatter.ts diff --git a/backend/src/services/ComposeService.ts b/backend/src/services/ComposeService.ts index 04c81c62..3a22bf4e 100644 --- a/backend/src/services/ComposeService.ts +++ b/backend/src/services/ComposeService.ts @@ -2,6 +2,7 @@ import { spawn } from 'child_process'; import path from 'path'; import WebSocket from 'ws'; import DockerController from './DockerController'; +import { LogFormatter } from './LogFormatter'; export class ComposeService { private baseDir: string; @@ -137,17 +138,35 @@ export class ComposeService { activeProcesses++; childProcesses.push(child); + let lineBuffer = ''; + const sendOutput = (data: Buffer) => { if (ws.readyState === WebSocket.OPEN) { - const text = data.toString().replace(/\r?\n/g, '\r\n'); - ws.send(text); + lineBuffer += data.toString(); + const lines = lineBuffer.split(/\r?\n/); + + // The last element is either an incomplete line or empty string + lineBuffer = lines.pop() || ''; + + for (const line of lines) { + const formattedLine = LogFormatter.process(line); + ws.send(formattedLine + '\r\n'); + } } }; child.stdout.on('data', sendOutput); child.stderr.on('data', sendOutput); child.on('error', handleProcessEnd); - child.on('close', handleProcessEnd); + child.on('close', () => { + // Flush any remaining partial line before ending + if (lineBuffer && ws.readyState === WebSocket.OPEN) { + const formattedLine = LogFormatter.process(lineBuffer); + ws.send(formattedLine + '\r\n'); + lineBuffer = ''; + } + handleProcessEnd(); + }); } } catch (err) { diff --git a/backend/src/services/LogFormatter.ts b/backend/src/services/LogFormatter.ts new file mode 100644 index 00000000..c582b304 --- /dev/null +++ b/backend/src/services/LogFormatter.ts @@ -0,0 +1,87 @@ +export class LogFormatter { + // ANSI Color Codes + private static readonly GRAY = '\x1b[90m'; + private static readonly CYAN = '\x1b[36m'; + private static readonly RED = '\x1b[31m'; + private static readonly YELLOW = '\x1b[33m'; + private static readonly BLUE = '\x1b[34m'; + private static readonly WHITE = '\x1b[37m'; + private static readonly RESET = '\x1b[0m'; + + // Regex patterns + // Matches standard ISO timestamps like "2024-02-26T12:34:56.789Z " or "2024-02-26 12:34:56 " at the start + private static readonly TIMESTAMP_REGEX = /^(\d{4}-\d{2}-\d{2}[T\s]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})?\s*)/; + + // Matches docker-compose style prefix like "container-name | " or "db-1 | " + private static readonly PREFIX_REGEX = /^([a-zA-Z0-9_-]+)(?:\s+\|\s+)/; + + // Level regexes (case insensitive for matching, but we check raw string for precise targeting if needed) + private static readonly ERROR_REGEX = /\b(ERROR|ERR|Exception|Fatal)\b/i; + private static readonly WARN_REGEX = /\b(WARN|WRN)\b/i; + private static readonly INFO_REGEX = /\b(INFO|INF)\b/i; + + public static process(line: string): string { + if (!line || line.trim() === '') return line; + + let processedLine = line; + let formatAccumulator = ''; + + // 1. Process Timestamp + const tsMatch = processedLine.match(LogFormatter.TIMESTAMP_REGEX); + if (tsMatch) { + const ts = tsMatch[1]; + formatAccumulator += `${LogFormatter.GRAY}${ts}${LogFormatter.RESET}`; + processedLine = processedLine.slice(ts.length); + } + + // 2. Process Prefix + const prefixMatch = processedLine.match(LogFormatter.PREFIX_REGEX); + if (prefixMatch) { + const pfxMatchStr = prefixMatch[0]; // e.g. "container-name | " + const name = prefixMatch[1]; + const restOfPrefix = pfxMatchStr.slice(name.length); // e.g. " | " + + formatAccumulator += `${LogFormatter.CYAN}${name}${LogFormatter.WHITE}${LogFormatter.RESET}${restOfPrefix}`; + processedLine = processedLine.slice(pfxMatchStr.length); + } + + // 3. Process Levels & JSON + const trimmedLine = processedLine.trim(); + + // Fast JSON Check (Starts with { and ends with }) + if (trimmedLine.startsWith('{') && trimmedLine.endsWith('}')) { + try { + const parsed = JSON.parse(trimmedLine); + // If valid, lightly highlight it (e.g., colorize string representation slightly) + // We re-stringify it to ensure it's on one line, but maybe just highlight properties + processedLine = LogFormatter.highlightJson(trimmedLine); + } catch (e) { + // Not valid JSON, fall through to level checking + processedLine = LogFormatter.highlightLevels(processedLine); + } + } else { + // 4. Highlight Levels (Error, Warn, etc.) + processedLine = LogFormatter.highlightLevels(processedLine); + } + + return formatAccumulator + processedLine; + } + + private static highlightLevels(text: string): string { + if (LogFormatter.ERROR_REGEX.test(text)) { + return `${LogFormatter.RED}${text}${LogFormatter.RESET}`; + } + if (LogFormatter.WARN_REGEX.test(text)) { + return `${LogFormatter.YELLOW}${text}${LogFormatter.RESET}`; + } + // For INFO, we can leave as default, or we can make the whole line a bit brighter, but let's leave default to avoid washing out the terminal + return text; + } + + private static highlightJson(jsonStr: string): string { + // A simple regex to highlight JSON keys in blue + // Matches "key": + const keyRegex = /"([^"]+)":/g; + return jsonStr.replace(keyRegex, `${LogFormatter.BLUE}"$1"${LogFormatter.RESET}:`); + } +} diff --git a/frontend/src/components/Terminal.tsx b/frontend/src/components/Terminal.tsx index cce69a7d..4f20c491 100644 --- a/frontend/src/components/Terminal.tsx +++ b/frontend/src/components/Terminal.tsx @@ -44,12 +44,29 @@ export default function TerminalComponent({ stackName }: TerminalComponentProps) const term = new Terminal({ cursorBlink: true, convertEol: true, + allowProposedApi: true, theme: { - background: '#000000', - foreground: '#ffffff', - cursor: '#ffffff', + background: '#0d1117', + foreground: '#e6edf3', + cursor: '#58a6ff', + black: '#484f58', + red: '#ff7b72', + green: '#3fb950', + yellow: '#d29922', + blue: '#58a6ff', + magenta: '#bc8cff', + cyan: '#39c5cf', + white: '#b1bac4', + brightBlack: '#6e7681', + brightRed: '#ffa198', + brightGreen: '#56d364', + brightYellow: '#e3b341', + brightBlue: '#79c0ff', + brightMagenta: '#d2a8ff', + brightCyan: '#56d4dd', + brightWhite: '#ffffff', }, - fontFamily: 'Consolas, Monaco, monospace', + fontFamily: "'JetBrains Mono', Consolas, Monaco, monospace", fontSize: 13, scrollback: 10000, });