diff --git a/src/app/logs/page.tsx b/src/app/logs/page.tsx index d240717..dd6c53d 100644 --- a/src/app/logs/page.tsx +++ b/src/app/logs/page.tsx @@ -10,6 +10,7 @@ import { cn } from "@/lib/utils"; import { Log } from "@/lib/types"; import { getAllContainerLogs } from "@/lib/dockship/docker"; import { useAsyncInterval } from "@/hooks/use-async"; +import { AnsiText } from "@/components/ansi/Ansi"; export default function LogsPage() { const [isPaused, setIsPaused] = useState(false); @@ -182,9 +183,10 @@ export default function LogsPage() { {/* Message */} - + {/* {log.message} - + */} + ))} diff --git a/src/components/ansi/Ansi.tsx b/src/components/ansi/Ansi.tsx new file mode 100644 index 0000000..d62789f --- /dev/null +++ b/src/components/ansi/Ansi.tsx @@ -0,0 +1,50 @@ +import { tokenizeAnsi } from "./tokenize"; + +type AnsiTextProps = { + text: string; + className?: string; +}; + +const ANSI_TW_CLASSES: Record = { + black: "text-black", + red: "text-red-500", + green: "text-emerald-500", + yellow: "text-yellow-400", + blue: "text-blue-400", + magenta: "text-purple-400", + cyan: "text-cyan-400", + white: "text-gray-100", + + brightBlack: "text-gray-500", + brightRed: "text-red-400", + brightGreen: "text-emerald-400", + brightYellow: "text-yellow-300", + brightBlue: "text-blue-300", + brightMagenta: "text-purple-300", + brightCyan: "text-cyan-300", + brightWhite: "text-white", +}; + +export function AnsiText({ text, className }: AnsiTextProps) { + const tokens = tokenizeAnsi(text); + + return ( + + {tokens.map((token, i) => { + const classes = [ + token.style.color && ANSI_TW_CLASSES[token.style.color], + token.style.bold && "font-semibold", + token.style.underline && "underline", + ] + .filter(Boolean) + .join(" "); + + return ( + + {token.text} + + ); + })} + + ); +} diff --git a/src/components/ansi/parser.ts b/src/components/ansi/parser.ts new file mode 100644 index 0000000..3daa603 --- /dev/null +++ b/src/components/ansi/parser.ts @@ -0,0 +1,45 @@ +export type StyleState = { + color?: string; + bold?: boolean; + underline?: boolean; +}; + +export const ANSI_REGEX = /\u001b\[([0-9;]*)m/g; + +const COLOR_CODES: Record = { + 30: "black", + 31: "red", + 32: "green", + 33: "yellow", + 34: "blue", + 35: "magenta", + 36: "cyan", + 37: "white", + + 90: "brightBlack", + 91: "brightRed", + 92: "brightGreen", + 93: "brightYellow", + 94: "brightBlue", + 95: "brightMagenta", + 96: "brightCyan", + 97: "brightWhite", +}; + +export function applyAnsiCodes(codes: number[], state: StyleState): StyleState { + let next = { ...state }; + + for (const code of codes) { + if (code === 0) { + next = {}; + } else if (code === 1) { + next.bold = true; + } else if (code === 4) { + next.underline = true; + } else if (COLOR_CODES[code]) { + next.color = COLOR_CODES[code]; + } + } + + return next; +} diff --git a/src/components/ansi/tokenize.ts b/src/components/ansi/tokenize.ts new file mode 100644 index 0000000..654b4a6 --- /dev/null +++ b/src/components/ansi/tokenize.ts @@ -0,0 +1,39 @@ +import { ANSI_REGEX, applyAnsiCodes, StyleState } from "./parser"; + +export type Token = { + text: string; + style: StyleState; +}; + +export function tokenizeAnsi(input: string): Token[] { + const tokens: Token[] = []; + + let lastIndex = 0; + let match: RegExpExecArray | null; + let style: StyleState = {}; + + while ((match = ANSI_REGEX.exec(input))) { + const index = match.index; + + if (index > lastIndex) { + tokens.push({ + text: input.slice(lastIndex, index), + style, + }); + } + + const codes = match[1].split(";").map(Number); + style = applyAnsiCodes(codes, style); + + lastIndex = ANSI_REGEX.lastIndex; + } + + if (lastIndex < input.length) { + tokens.push({ + text: input.slice(lastIndex), + style, + }); + } + + return tokens; +}