Compare commits

...

4 Commits

Author SHA1 Message Date
1e4d66cbe4 Fix log search filtering to match against plain text instead of ANSI escape codes
Move styles array outside the parse loop in ansiToHtml so styles accumulate across sequences, and use stripAnsi when filtering live logs so search matches visible text.
2026-03-18 11:23:06 -07:00
a824d62058 Refactor ANSI parser to support SGR reset (code 39) and bold/dim styles
Merge color and style maps into a unified STYLES table, hoist the
regex to module scope, export stripAnsi for use in log parsing, and
handle SGR 39 (default foreground) by removing only color styles
instead of clearing all styles.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 11:14:03 -07:00
33d91747af Combine ANSI style codes into a single span element
Multiple SGR parameters in one escape sequence (e.g. bold + color)
were each opening a new span, losing earlier styles. Collect styles
per sequence and emit one span with all of them.
2026-03-18 10:59:07 -07:00
33d21777d3 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>
2026-03-18 10:55:31 -07:00
3 changed files with 81 additions and 7 deletions

71
src/client/ansi.ts Normal file
View File

@ -0,0 +1,71 @@
const ESC = /\x1b\[([0-9;]*)m/g
const STYLES: Record<number, string> = {
1: 'font-weight:bold',
2: 'opacity:0.7',
30: 'color:#666',
31: 'color:#f87171',
32: 'color:#4ade80',
33: 'color:#facc15',
34: 'color:#60a5fa',
35: 'color:#c084fc',
36: 'color:#22d3ee',
37: 'color:#e5e5e5',
90: 'color:#999',
91: 'color:#fca5a5',
92: 'color:#86efac',
93: 'color:#fde047',
94: 'color:#93c5fd',
95: 'color:#d8b4fe',
96: 'color:#67e8f9',
97: 'color:#fff',
}
const escape = (s: string) =>
s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
export const stripAnsi = (s: string) =>
s.replace(/\x1b\[[0-9;]*m/g, '')
export function ansiToHtml(text: string): string {
if (!text.includes('\x1b')) return escape(text)
ESC.lastIndex = 0
let result = ''
let last = 0
let open = false
const styles: string[] = []
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) {
styles.length = 0
if (open) { result += '</span>'; open = false }
} else if (code === 39) {
const filtered = styles.filter(s => !s.startsWith('color:'))
styles.length = 0
styles.push(...filtered)
if (open) { result += '</span>'; open = false }
} else if (STYLES[code]) {
styles.push(STYLES[code])
}
}
if (styles.length) {
if (open) result += '</span>'
result += `<span style="${styles.join(';')}">`
open = true
}
}
result += escape(text.slice(last))
if (open) result += '</span>'
return result
}

View File

@ -1,5 +1,6 @@
import { define } from '@because/forge'
import type { App, LogLine as LogLineType } from '../../shared/types'
import { ansiToHtml, stripAnsi } from '../ansi'
import { getLogDates, getLogsForDate } from '../api'
import { isNarrow } from '../state'
import { LogLine, LogsContainer, LogsHeader, LogTime, Section, SectionTitle } from '../styles'
@ -91,7 +92,7 @@ function LogsContent() {
const state = getState(currentApp.name)
const isLive = state.selectedDate === 'live'
const filteredLiveLogs = filterLogs(currentApp.logs ?? [], state.searchFilter, l => l.text)
const filteredLiveLogs = filterLogs(currentApp.logs ?? [], state.searchFilter, l => stripAnsi(l.text))
const filteredHistoricalLogs = filterLogs(state.historicalLogs, state.searchFilter, l => l)
return (
@ -107,7 +108,7 @@ function LogsContent() {
filteredLiveLogs.map((line, i) => (
<LogLine key={i}>
<LogTime>{new Date(line.time).toLocaleTimeString()}</LogTime>
<span>{line.text}</span>
<span dangerouslySetInnerHTML={{ __html: ansiToHtml(line.text) }} />
</LogLine>
))
) : (
@ -126,7 +127,7 @@ function LogsContent() {
<span style={{ color: theme('colors-textFaintest'), marginRight: 12 }}>
{line.match(/^\[([^\]]+)\]/)?.[1]?.split('T')[1]?.slice(0, 8) ?? '--:--:--'}
</span>
<span>{line.replace(/^\[[^\]]+\] \[[^\]]+\] /, '')}</span>
<span dangerouslySetInnerHTML={{ __html: ansiToHtml(line.replace(/^\[[^\]]+\] \[[^\]]+\] /, '')) }} />
</LogLine>
))
) : (

View File

@ -1,3 +1,4 @@
import { ansiToHtml, stripAnsi } from '../ansi'
import { isNarrow } from '../state'
import {
LogApp,
@ -58,16 +59,17 @@ function parseLogText(text: string): { method?: string, path?: string, status?:
}
function LogLineEntry({ log }: { log: UnifiedLogLine }) {
const parsed = parseLogText(log.text)
const parsed = parseLogText(stripAnsi(log.text))
const statusColor = getStatusColor(parsed.status)
const narrow = isNarrow || undefined
return (
<LogEntry narrow={narrow}>
<LogTimestamp narrow={narrow}>{formatTime(log.time)}</LogTimestamp>
<LogApp narrow={narrow}>{log.app}</LogApp>
<LogText style={statusColor ? { color: statusColor } : undefined}>
{log.text}
</LogText>
<LogText
style={statusColor ? { color: statusColor } : undefined}
dangerouslySetInnerHTML={{ __html: ansiToHtml(log.text) }}
/>
</LogEntry>
)
}