Add ANSI color code to HTML conversion for log display
Terminal color codes were rendering as raw escape sequences in the web UI, making logs hard to read. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
62f936cdef
commit
33d21777d3
62
src/client/ansi.ts
Normal file
62
src/client/ansi.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
const COLORS: Record<number, string> = {
|
||||||
|
30: '#666', // black (brightened for dark bg)
|
||||||
|
31: '#f87171', // red
|
||||||
|
32: '#4ade80', // green
|
||||||
|
33: '#facc15', // yellow
|
||||||
|
34: '#60a5fa', // blue
|
||||||
|
35: '#c084fc', // magenta
|
||||||
|
36: '#22d3ee', // cyan
|
||||||
|
37: '#e5e5e5', // white
|
||||||
|
90: '#999', // bright black
|
||||||
|
91: '#fca5a5', // bright red
|
||||||
|
92: '#86efac', // bright green
|
||||||
|
93: '#fde047', // bright yellow
|
||||||
|
94: '#93c5fd', // bright blue
|
||||||
|
95: '#d8b4fe', // bright magenta
|
||||||
|
96: '#67e8f9', // bright cyan
|
||||||
|
97: '#fff', // bright white
|
||||||
|
}
|
||||||
|
|
||||||
|
const ESC = /\x1b\[([0-9;]*)m/g
|
||||||
|
|
||||||
|
const escape = (s: string) =>
|
||||||
|
s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
|
||||||
|
export function ansiToHtml(text: string): string {
|
||||||
|
if (!text.includes('\x1b')) return escape(text)
|
||||||
|
|
||||||
|
let result = ''
|
||||||
|
let last = 0
|
||||||
|
let open = false
|
||||||
|
let match: RegExpExecArray | null
|
||||||
|
|
||||||
|
while ((match = ESC.exec(text)) !== null) {
|
||||||
|
result += escape(text.slice(last, match.index))
|
||||||
|
last = match.index + match[0].length
|
||||||
|
|
||||||
|
const codes = match[1] ? match[1].split(';').map(Number) : [0]
|
||||||
|
|
||||||
|
for (const code of codes) {
|
||||||
|
if (code === 0 || code === 39) {
|
||||||
|
if (open) { result += '</span>'; open = false }
|
||||||
|
} else if (COLORS[code]) {
|
||||||
|
if (open) result += '</span>'
|
||||||
|
result += `<span style="color:${COLORS[code]}">`
|
||||||
|
open = true
|
||||||
|
} else if (code === 1) {
|
||||||
|
if (open) result += '</span>'
|
||||||
|
result += '<span style="font-weight:bold">'
|
||||||
|
open = true
|
||||||
|
} else if (code === 2) {
|
||||||
|
if (open) result += '</span>'
|
||||||
|
result += '<span style="opacity:0.7">'
|
||||||
|
open = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result += escape(text.slice(last))
|
||||||
|
if (open) result += '</span>'
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { define } from '@because/forge'
|
import { define } from '@because/forge'
|
||||||
import type { App, LogLine as LogLineType } from '../../shared/types'
|
import type { App, LogLine as LogLineType } from '../../shared/types'
|
||||||
|
import { ansiToHtml } from '../ansi'
|
||||||
import { getLogDates, getLogsForDate } from '../api'
|
import { getLogDates, getLogsForDate } from '../api'
|
||||||
import { isNarrow } from '../state'
|
import { isNarrow } from '../state'
|
||||||
import { LogLine, LogsContainer, LogsHeader, LogTime, Section, SectionTitle } from '../styles'
|
import { LogLine, LogsContainer, LogsHeader, LogTime, Section, SectionTitle } from '../styles'
|
||||||
|
|
@ -107,7 +108,7 @@ function LogsContent() {
|
||||||
filteredLiveLogs.map((line, i) => (
|
filteredLiveLogs.map((line, i) => (
|
||||||
<LogLine key={i}>
|
<LogLine key={i}>
|
||||||
<LogTime>{new Date(line.time).toLocaleTimeString()}</LogTime>
|
<LogTime>{new Date(line.time).toLocaleTimeString()}</LogTime>
|
||||||
<span>{line.text}</span>
|
<span dangerouslySetInnerHTML={{ __html: ansiToHtml(line.text) }} />
|
||||||
</LogLine>
|
</LogLine>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -126,7 +127,7 @@ function LogsContent() {
|
||||||
<span style={{ color: theme('colors-textFaintest'), marginRight: 12 }}>
|
<span style={{ color: theme('colors-textFaintest'), marginRight: 12 }}>
|
||||||
{line.match(/^\[([^\]]+)\]/)?.[1]?.split('T')[1]?.slice(0, 8) ?? '--:--:--'}
|
{line.match(/^\[([^\]]+)\]/)?.[1]?.split('T')[1]?.slice(0, 8) ?? '--:--:--'}
|
||||||
</span>
|
</span>
|
||||||
<span>{line.replace(/^\[[^\]]+\] \[[^\]]+\] /, '')}</span>
|
<span dangerouslySetInnerHTML={{ __html: ansiToHtml(line.replace(/^\[[^\]]+\] \[[^\]]+\] /, '')) }} />
|
||||||
</LogLine>
|
</LogLine>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { ansiToHtml } from '../ansi'
|
||||||
import { isNarrow } from '../state'
|
import { isNarrow } from '../state'
|
||||||
import {
|
import {
|
||||||
LogApp,
|
LogApp,
|
||||||
|
|
@ -65,9 +66,10 @@ function LogLineEntry({ log }: { log: UnifiedLogLine }) {
|
||||||
<LogEntry narrow={narrow}>
|
<LogEntry narrow={narrow}>
|
||||||
<LogTimestamp narrow={narrow}>{formatTime(log.time)}</LogTimestamp>
|
<LogTimestamp narrow={narrow}>{formatTime(log.time)}</LogTimestamp>
|
||||||
<LogApp narrow={narrow}>{log.app}</LogApp>
|
<LogApp narrow={narrow}>{log.app}</LogApp>
|
||||||
<LogText style={statusColor ? { color: statusColor } : undefined}>
|
<LogText
|
||||||
{log.text}
|
style={statusColor ? { color: statusColor } : undefined}
|
||||||
</LogText>
|
dangerouslySetInnerHTML={{ __html: ansiToHtml(log.text) }}
|
||||||
|
/>
|
||||||
</LogEntry>
|
</LogEntry>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user