mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-09-01 13:18:08 +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 path from 'path';
|
||||||
import WebSocket from 'ws';
|
import WebSocket from 'ws';
|
||||||
import DockerController from './DockerController';
|
import DockerController from './DockerController';
|
||||||
|
import { LogFormatter } from './LogFormatter';
|
||||||
|
|
||||||
export class ComposeService {
|
export class ComposeService {
|
||||||
private baseDir: string;
|
private baseDir: string;
|
||||||
@@ -137,17 +138,35 @@ export class ComposeService {
|
|||||||
activeProcesses++;
|
activeProcesses++;
|
||||||
childProcesses.push(child);
|
childProcesses.push(child);
|
||||||
|
|
||||||
|
let lineBuffer = '';
|
||||||
|
|
||||||
const sendOutput = (data: Buffer) => {
|
const sendOutput = (data: Buffer) => {
|
||||||
if (ws.readyState === WebSocket.OPEN) {
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
const text = data.toString().replace(/\r?\n/g, '\r\n');
|
lineBuffer += data.toString();
|
||||||
ws.send(text);
|
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.stdout.on('data', sendOutput);
|
||||||
child.stderr.on('data', sendOutput);
|
child.stderr.on('data', sendOutput);
|
||||||
child.on('error', handleProcessEnd);
|
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) {
|
} 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({
|
const term = new Terminal({
|
||||||
cursorBlink: true,
|
cursorBlink: true,
|
||||||
convertEol: true,
|
convertEol: true,
|
||||||
|
allowProposedApi: true,
|
||||||
theme: {
|
theme: {
|
||||||
background: '#000000',
|
background: '#0d1117',
|
||||||
foreground: '#ffffff',
|
foreground: '#e6edf3',
|
||||||
cursor: '#ffffff',
|
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,
|
fontSize: 13,
|
||||||
scrollback: 10000,
|
scrollback: 10000,
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user