Skip to content

Commit 75d7f12

Browse files
authored
fix: add decorations for parse ansi color in log-viewer (#286)
Signed-off-by: Zzde <zhangxh1997@gmail.com>
1 parent 33ec74b commit 75d7f12

File tree

2 files changed

+125
-6
lines changed

2 files changed

+125
-6
lines changed

ui/src/components/log-viewer.tsx

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,12 @@ import type { editor } from 'monaco-editor'
1515
import { useTranslation } from 'react-i18next'
1616

1717
import { TERMINAL_THEMES, TerminalTheme } from '@/types/themes'
18+
import {
19+
AnsiState,
20+
generateAnsiCss,
21+
getAnsiClassNames,
22+
parseAnsi,
23+
} from '@/lib/ansi-parser'
1824
import { useLogsWebSocket } from '@/lib/api'
1925
import { toSimpleContainer } from '@/lib/k8s'
2026
import { Button } from '@/components/ui/button'
@@ -109,6 +115,8 @@ export function LogViewer({
109115
})
110116
const editorRef = useRef<editor.IStandaloneCodeEditor | null>(null)
111117
const [logCount, setLogCount] = useState(0) // Track log count for re-rendering
118+
const ansiStateRef = useRef<AnsiState>({})
119+
const decorationIdsRef = useRef<string[]>([])
112120

113121
const [selectPodName, setSelectPodName] = useState<string | undefined>(
114122
podName || pods?.[0]?.metadata?.name || undefined
@@ -183,17 +191,26 @@ export function LogViewer({
183191
const appendLog = useCallback(
184192
(log: string) => {
185193
setLogCount((count) => count + 1)
194+
195+
const { segments, finalState } = parseAnsi(log, ansiStateRef.current)
196+
ansiStateRef.current = finalState
197+
198+
const plainText = segments.map((s) => s.text).join('')
199+
186200
if (filterTerm) {
187-
if (!log.toLocaleLowerCase().includes(filterTerm)) {
201+
if (!plainText.toLocaleLowerCase().includes(filterTerm.toLowerCase())) {
188202
return
189203
}
190204
}
205+
191206
if (editorRef.current) {
192207
const model = editorRef.current.getModel()
193208
if (model) {
194209
const lineCount = model.getLineCount()
195210
const lineMaxColumn = model.getLineMaxColumn(lineCount)
196211
const prefix = model.getValueLength() === 0 ? '' : '\n'
212+
213+
const textToInsert = `${prefix}${plainText}`
197214
model.applyEdits([
198215
{
199216
range: {
@@ -202,10 +219,54 @@ export function LogViewer({
202219
startLineNumber: lineCount,
203220
endLineNumber: lineCount,
204221
},
205-
text: `${prefix}${log}`,
222+
text: textToInsert,
206223
forceMoveMarkers: true,
207224
},
208225
])
226+
227+
const newDecorations: editor.IModelDeltaDecoration[] = []
228+
229+
// Starting position for decorations
230+
let currentLine = lineCount
231+
let currentColumn = lineMaxColumn
232+
233+
if (prefix === '\n') {
234+
currentLine++
235+
currentColumn = 1
236+
}
237+
238+
segments.forEach((segment) => {
239+
const lines = segment.text.split('\n')
240+
const endLine = currentLine + lines.length - 1
241+
const endColumn =
242+
lines.length === 1
243+
? currentColumn + lines[0].length
244+
: lines[lines.length - 1].length + 1
245+
246+
const className = getAnsiClassNames(segment.styles)
247+
if (className) {
248+
newDecorations.push({
249+
range: {
250+
startLineNumber: currentLine,
251+
startColumn: currentColumn,
252+
endLineNumber: endLine,
253+
endColumn: endColumn,
254+
},
255+
options: {
256+
inlineClassName: className,
257+
},
258+
})
259+
}
260+
261+
currentLine = endLine
262+
currentColumn = endColumn
263+
})
264+
265+
if (newDecorations.length > 0) {
266+
const newIds = model.deltaDecorations([], newDecorations)
267+
decorationIdsRef.current.push(...newIds)
268+
}
269+
209270
const visibleRange = editorRef.current.getVisibleRanges()[0]
210271
if (visibleRange?.endLineNumber + 2 >= lineCount) {
211272
editorRef.current.revealLine(model.getLineCount())
@@ -221,6 +282,8 @@ export function LogViewer({
221282

222283
const cleanLog = useCallback(() => {
223284
setLogCount(0)
285+
ansiStateRef.current = {}
286+
decorationIdsRef.current = []
224287
if (editorRef.current) {
225288
const model = editorRef.current.getModel()
226289
if (model) {
@@ -396,6 +459,7 @@ export function LogViewer({
396459
<Card
397460
className={`h-full flex flex-col py-4 gap-0 ${isFullscreen ? 'fixed inset-0 z-50 m-0 rounded-none' : ''} ${wordWrap ? 'whitespace-pre-wrap' : 'whitespace-pre'} `}
398461
>
462+
<style>{generateAnsiCss()}</style>
399463
<CardHeader>
400464
<div className="flex items-center justify-between">
401465
<div className="flex items-center gap-2">

ui/src/lib/ansi-parser.ts

Lines changed: 59 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ const ANSI_BG_COLORS = {
4848
107: '#ffffff', // bright white
4949
} as const
5050

51-
interface AnsiState {
51+
export interface AnsiState {
5252
color?: string
5353
backgroundColor?: string
5454
bold?: boolean
@@ -64,9 +64,12 @@ interface ParsedSegment {
6464
/**
6565
* Parse ANSI escape sequences in a string and return styled segments
6666
*/
67-
export function parseAnsi(text: string): ParsedSegment[] {
67+
export function parseAnsi(
68+
text: string,
69+
initialState: AnsiState = {}
70+
): { segments: ParsedSegment[]; finalState: AnsiState } {
6871
const segments: ParsedSegment[] = []
69-
let currentState: AnsiState = {}
72+
let currentState: AnsiState = { ...initialState }
7073
let currentText = ''
7174

7275
// Regex to match ANSI escape sequences - using Unicode escape instead of hex
@@ -112,7 +115,7 @@ export function parseAnsi(text: string): ParsedSegment[] {
112115
})
113116
}
114117

115-
return segments
118+
return { segments, finalState: currentState }
116119
}
117120

118121
/**
@@ -210,3 +213,55 @@ export function ansiStateToCss(state: AnsiState): React.CSSProperties {
210213
export function stripAnsi(text: string): string {
211214
return text.replace(new RegExp('\u001b\\[[0-9;]*m', 'g'), '')
212215
}
216+
217+
/**
218+
* Generate CSS class names for an AnsiState
219+
*/
220+
export function getAnsiClassNames(state: AnsiState): string {
221+
const classes: string[] = []
222+
223+
if (state.color) {
224+
// Find the code for this color
225+
const code = Object.entries(ANSI_COLORS).find(
226+
([, value]) => value === state.color
227+
)?.[0]
228+
if (code) classes.push(`ansi-fg-${code}`)
229+
}
230+
231+
if (state.backgroundColor) {
232+
const code = Object.entries(ANSI_BG_COLORS).find(
233+
([, value]) => value === state.backgroundColor
234+
)?.[0]
235+
if (code) classes.push(`ansi-bg-${code}`)
236+
}
237+
238+
if (state.bold) classes.push('ansi-bold')
239+
if (state.italic) classes.push('ansi-italic')
240+
if (state.underline) classes.push('ansi-underline')
241+
242+
return classes.join(' ')
243+
}
244+
245+
/**
246+
* Generate CSS styles for all ANSI classes
247+
*/
248+
export function generateAnsiCss(): string {
249+
let css = ''
250+
251+
// Foreground colors
252+
Object.entries(ANSI_COLORS).forEach(([code, color]) => {
253+
css += `.ansi-fg-${code} { color: ${color} !important; }\n`
254+
})
255+
256+
// Background colors
257+
Object.entries(ANSI_BG_COLORS).forEach(([code, color]) => {
258+
css += `.ansi-bg-${code} { background-color: ${color} !important; }\n`
259+
})
260+
261+
// Styles
262+
css += '.ansi-bold { font-weight: bold !important; }\n'
263+
css += '.ansi-italic { font-style: italic !important; }\n'
264+
css += '.ansi-underline { text-decoration: underline !important; }\n'
265+
266+
return css
267+
}

0 commit comments

Comments
 (0)