feat: Implement an interactive terminal component with real-time log streaming, and formatting

This commit is contained in:
SaelixCode
2026-02-25 22:51:07 -05:00
parent 46e6aedfd0
commit 53b36c71ce
3 changed files with 130 additions and 7 deletions
+22 -3
View File
@@ -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) {
+87
View File
@@ -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}:`);
}
}
+21 -4
View File
@@ -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,
});