mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-05 08:27:42 +00:00
feat: Implement an interactive terminal component with real-time log streaming, and formatting
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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}:`);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user