fix(web): render ansi logs with wrapped lines (#1425)

This commit is contained in:
wenjie
2026-03-12 19:12:19 +08:00
committed by GitHub
parent 7872bb3f0a
commit d18a319b0c
13 changed files with 595 additions and 159 deletions
+1 -2
View File
@@ -1,5 +1,4 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
@@ -23,4 +22,4 @@ dist-ssr
*.sln
*.sw?
.tanstack
.tanstack
+2 -1
View File
@@ -36,7 +36,8 @@
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.1",
"tw-animate-css": "^1.4.0"
"tw-animate-css": "^1.4.0",
"wrap-ansi": "^10.0.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
+28
View File
@@ -80,6 +80,9 @@ importers:
tw-animate-css:
specifier: ^1.4.0
version: 1.4.0
wrap-ansi:
specifier: ^10.0.0
version: 10.0.0
devDependencies:
'@eslint/js':
specifier: ^9.39.1
@@ -1837,6 +1840,10 @@ packages:
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
engines: {node: '>=8'}
ansi-styles@6.2.3:
resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==}
engines: {node: '>=12'}
ansis@4.2.0:
resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==}
engines: {node: '>=14'}
@@ -3558,6 +3565,10 @@ packages:
resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==}
engines: {node: '>=18'}
string-width@8.2.0:
resolution: {integrity: sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==}
engines: {node: '>=20'}
stringify-entities@4.0.4:
resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==}
@@ -3883,6 +3894,10 @@ packages:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'}
wrap-ansi@10.0.0:
resolution: {integrity: sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ==}
engines: {node: '>=20'}
wrap-ansi@6.2.0:
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
engines: {node: '>=8'}
@@ -5677,6 +5692,8 @@ snapshots:
dependencies:
color-convert: 2.0.1
ansi-styles@6.2.3: {}
ansis@4.2.0: {}
anymatch@3.1.3:
@@ -7628,6 +7645,11 @@ snapshots:
get-east-asian-width: 1.5.0
strip-ansi: 7.2.0
string-width@8.2.0:
dependencies:
get-east-asian-width: 1.5.0
strip-ansi: 7.2.0
stringify-entities@4.0.4:
dependencies:
character-entities-html4: 2.1.0
@@ -7904,6 +7926,12 @@ snapshots:
word-wrap@1.2.5: {}
wrap-ansi@10.0.0:
dependencies:
ansi-styles: 6.2.3
string-width: 8.2.0
strip-ansi: 7.2.0
wrap-ansi@6.2.0:
dependencies:
ansi-styles: 4.3.0
@@ -0,0 +1,24 @@
import { Fragment, useMemo } from "react"
import { parseAnsiSegments, wrapLogLine } from "@/lib/ansi-log"
type AnsiLogLineProps = {
line: string
wrapColumns: number
}
export function AnsiLogLine({ line, wrapColumns }: AnsiLogLineProps) {
const segments = useMemo(() => {
return parseAnsiSegments(wrapLogLine(line, wrapColumns))
}, [line, wrapColumns])
return (
<div className="break-normal whitespace-pre-wrap">
{segments.map((segment, index) => (
<Fragment key={`${index}-${segment.text.length}`}>
<span style={segment.style}>{segment.text}</span>
</Fragment>
))}
</div>
)
}
@@ -0,0 +1,42 @@
import { IconTrash } from "@tabler/icons-react"
import { useTranslation } from "react-i18next"
import { LogsPanel } from "@/components/logs/logs-panel"
import { PageHeader } from "@/components/page-header"
import { Button } from "@/components/ui/button"
import { useGatewayLogs } from "@/hooks/use-gateway-logs"
import { useLogWrapColumns } from "@/hooks/use-log-wrap-columns"
export function LogsPage() {
const { t } = useTranslation()
const { clearLogs, clearing, logs } = useGatewayLogs()
const { contentRef, measureRef, wrapColumns } = useLogWrapColumns()
return (
<div className="flex h-full flex-col">
<PageHeader
title={t("navigation.logs")}
children={
<Button
variant="outline"
size="sm"
onClick={clearLogs}
disabled={logs.length === 0 || clearing}
>
<IconTrash className="size-4" />
{t("pages.logs.clear")}
</Button>
}
/>
<div className="flex flex-1 flex-col gap-4 overflow-hidden p-4 sm:p-8">
<LogsPanel
logs={logs}
wrapColumns={wrapColumns}
contentRef={contentRef}
measureRef={measureRef}
/>
</div>
</div>
)
}
@@ -0,0 +1,55 @@
import { type RefObject, useEffect, useRef } from "react"
import { useTranslation } from "react-i18next"
import { AnsiLogLine } from "@/components/logs/ansi-log-line"
import { ScrollArea } from "@/components/ui/scroll-area"
type LogsPanelProps = {
logs: string[]
wrapColumns: number
contentRef: RefObject<HTMLDivElement | null>
measureRef: RefObject<HTMLSpanElement | null>
}
export function LogsPanel({
logs,
wrapColumns,
contentRef,
measureRef,
}: LogsPanelProps) {
const { t } = useTranslation()
const scrollRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollIntoView({ behavior: "smooth" })
}
}, [logs])
return (
<div className="relative flex-1 overflow-hidden rounded-lg border border-zinc-800 bg-zinc-950 text-zinc-100">
<ScrollArea className="h-full">
<div
ref={contentRef}
className="relative p-4 font-mono text-sm leading-relaxed"
>
<span
ref={measureRef}
aria-hidden
className="pointer-events-none invisible absolute font-mono text-sm"
>
0
</span>
{logs.length === 0 ? (
<div className="text-zinc-500 italic">{t("pages.logs.empty")}</div>
) : (
logs.map((log, index) => (
<AnsiLogLine key={index} line={log} wrapColumns={wrapColumns} />
))
)}
<div ref={scrollRef} />
</div>
</ScrollArea>
</div>
)
}
+4 -4
View File
@@ -35,13 +35,13 @@ export function Field({
if (layout === "setting-row") {
return (
<div className="flex flex-col gap-4 py-4 md:grid md:grid-cols-[minmax(0,1fr)_minmax(240px,320px)] md:items-center md:gap-6">
<div className="space-y-1">
<div className="max-w-full space-y-1 md:max-w-[clamp(18rem,42vw,28rem)]">
<FieldLabel>
{label}
{required && <span className="text-destructive ml-1">*</span>}
</FieldLabel>
{hint && (
<FieldDescription className="text-xs leading-normal">
<FieldDescription className="text-xs leading-normal break-words">
{hint}
</FieldDescription>
)}
@@ -141,10 +141,10 @@ export function SwitchCardField({
if (layout === "setting-row") {
return (
<div className="flex flex-col gap-4 py-4 md:grid md:grid-cols-[minmax(0,1fr)_auto] md:items-center md:gap-6">
<div className="min-w-0">
<div className="max-w-full min-w-0 md:max-w-[clamp(18rem,42vw,28rem)]">
<p className="text-sm font-medium">{label}</p>
{hint && (
<p className="text-muted-foreground mt-0.5 text-xs leading-normal">
<p className="text-muted-foreground mt-0.5 text-xs leading-normal break-words">
{hint}
</p>
)}
@@ -0,0 +1,96 @@
import { useAtomValue } from "jotai"
import { useEffect, useRef, useState } from "react"
import { clearGatewayLogs, getGatewayStatus } from "@/api/gateway"
import { gatewayAtom } from "@/store/gateway"
export function useGatewayLogs() {
const [logs, setLogs] = useState<string[]>([])
const [clearing, setClearing] = useState(false)
const logOffsetRef = useRef(0)
const logRunIdRef = useRef(-1)
const syncTokenRef = useRef(0)
const gateway = useAtomValue(gatewayAtom)
const clearLogs = async () => {
setClearing(true)
try {
const data = await clearGatewayLogs()
syncTokenRef.current += 1
setLogs([])
logOffsetRef.current = data.log_total ?? 0
if (data.log_run_id !== undefined) {
logRunIdRef.current = data.log_run_id
}
} catch {
// Ignore clear failures silently to avoid noisy transient errors.
} finally {
setClearing(false)
}
}
useEffect(() => {
let mounted = true
let timeout: ReturnType<typeof setTimeout>
const fetchLogs = async () => {
if (
!mounted ||
(gateway.status !== "running" && gateway.status !== "starting")
) {
if (mounted) {
timeout = setTimeout(fetchLogs, 1000)
}
return
}
try {
const requestToken = syncTokenRef.current
const requestOffset = logOffsetRef.current
const requestRunId = logRunIdRef.current
const data = await getGatewayStatus({
log_offset: requestOffset,
log_run_id: requestRunId,
})
if (!mounted || requestToken !== syncTokenRef.current) {
return
}
if (data.log_run_id !== undefined && data.log_run_id !== requestRunId) {
logRunIdRef.current = data.log_run_id
logOffsetRef.current = 0
if (data.logs) {
setLogs(data.logs)
logOffsetRef.current = data.log_total || data.logs.length
}
} else if (data.logs && data.logs.length > 0) {
const nextLogs = data.logs
setLogs((prev) => [...prev, ...nextLogs])
logOffsetRef.current =
data.log_total || logOffsetRef.current + nextLogs.length
}
} catch {
// Ignore simple fetch errors during polling.
} finally {
if (mounted) {
timeout = setTimeout(fetchLogs, 1000)
}
}
}
fetchLogs()
return () => {
mounted = false
clearTimeout(timeout)
}
}, [gateway.status])
return {
clearLogs,
clearing,
logs,
}
}
@@ -0,0 +1,52 @@
import { useEffect, useRef, useState } from "react"
const DEFAULT_WRAP_COLUMNS = 120
const MIN_WRAP_COLUMNS = 20
export function useLogWrapColumns() {
const [wrapColumns, setWrapColumns] = useState(DEFAULT_WRAP_COLUMNS)
const contentRef = useRef<HTMLDivElement>(null)
const measureRef = useRef<HTMLSpanElement>(null)
useEffect(() => {
const content = contentRef.current
const measure = measureRef.current
if (!content || !measure) {
return
}
const updateWrapColumns = () => {
const contentWidth = content.clientWidth
const charWidth = measure.getBoundingClientRect().width
if (!contentWidth || !charWidth) {
return
}
const nextColumns = Math.max(
Math.floor(contentWidth / charWidth) - 1,
MIN_WRAP_COLUMNS,
)
setWrapColumns((current) =>
current === nextColumns ? current : nextColumns,
)
}
updateWrapColumns()
const observer = new ResizeObserver(updateWrapColumns)
observer.observe(content)
return () => {
observer.disconnect()
}
}, [])
return {
contentRef,
measureRef,
wrapColumns,
}
}
-1
View File
@@ -450,7 +450,6 @@
"unsaved_changes": "You have unsaved changes."
},
"logs": {
"description": "System logs and monitoring.",
"clear": "Clear logs",
"empty": "Waiting for logs..."
}
-1
View File
@@ -450,7 +450,6 @@
"unsaved_changes": "您有未保存的更改。"
},
"logs": {
"description": "系统日志和监控。",
"clear": "清空日志",
"empty": "等待日志中..."
}
+290
View File
@@ -0,0 +1,290 @@
import type { CSSProperties } from "react"
import wrapAnsi from "wrap-ansi"
export type AnsiSegment = {
style: CSSProperties
text: string
}
type AnsiState = {
background?: string
bold?: boolean
dim?: boolean
foreground?: string
italic?: boolean
strikethrough?: boolean
underline?: boolean
underlineColor?: string
}
const ANSI_PATTERN = new RegExp(String.raw`\u001B\[([0-9;]*)m`, "g")
const ANSI_COLORS = [
"#4b5563",
"#f87171",
"#4ade80",
"#facc15",
"#60a5fa",
"#c084fc",
"#22d3ee",
"#f3f4f6",
]
const ANSI_BRIGHT_COLORS = [
"#6b7280",
"#fb7185",
"#86efac",
"#fde047",
"#93c5fd",
"#e879f9",
"#67e8f9",
"#ffffff",
]
function cloneAnsiState(state: AnsiState): AnsiState {
return { ...state }
}
function ansi256ToHex(code: number): string {
if (code < 0 || code > 255) {
return "inherit"
}
if (code < 8) {
return ANSI_COLORS[code]
}
if (code < 16) {
return ANSI_BRIGHT_COLORS[code - 8]
}
if (code < 232) {
const index = code - 16
const red = Math.floor(index / 36)
const green = Math.floor((index % 36) / 6)
const blue = index % 6
const scale = [0, 95, 135, 175, 215, 255]
return `rgb(${scale[red]}, ${scale[green]}, ${scale[blue]})`
}
const gray = 8 + (code - 232) * 10
return `rgb(${gray}, ${gray}, ${gray})`
}
function codeToColor(code: number): string | undefined {
if (code >= 30 && code <= 37) {
return ANSI_COLORS[code - 30]
}
if (code >= 40 && code <= 47) {
return ANSI_COLORS[code - 40]
}
if (code >= 90 && code <= 97) {
return ANSI_BRIGHT_COLORS[code - 90]
}
if (code >= 100 && code <= 107) {
return ANSI_BRIGHT_COLORS[code - 100]
}
if (code === 39 || code === 49) {
return undefined
}
}
function applyExtendedColor(
state: AnsiState,
codes: number[],
index: number,
target: "foreground" | "background" | "underlineColor",
): number {
const mode = codes[index + 1]
if (mode === 5) {
const colorCode = codes[index + 2]
if (colorCode !== undefined) {
state[target] = ansi256ToHex(colorCode)
return index + 2
}
}
if (mode === 2) {
const red = codes[index + 2]
const green = codes[index + 3]
const blue = codes[index + 4]
if (red !== undefined && green !== undefined && blue !== undefined) {
state[target] = `rgb(${red}, ${green}, ${blue})`
return index + 4
}
}
return index
}
function styleToCss(style: AnsiState): CSSProperties {
return {
backgroundColor: style.background,
color: style.foreground,
fontStyle: style.italic ? "italic" : undefined,
fontWeight: style.bold ? 700 : undefined,
opacity: style.dim ? 0.7 : undefined,
textDecorationColor: style.underlineColor,
textDecorationLine:
[
style.underline ? "underline" : "",
style.strikethrough ? "line-through" : "",
]
.filter(Boolean)
.join(" ") || undefined,
}
}
export function parseAnsiSegments(input: string): AnsiSegment[] {
const segments: AnsiSegment[] = []
const state: AnsiState = {}
let lastIndex = 0
let match: RegExpExecArray | null
const pushText = (text: string) => {
if (!text) {
return
}
segments.push({
style: styleToCss(cloneAnsiState(state)),
text,
})
}
ANSI_PATTERN.lastIndex = 0
while ((match = ANSI_PATTERN.exec(input)) !== null) {
pushText(input.slice(lastIndex, match.index))
const codes = (match[1] || "0")
.split(";")
.map((value) => (value === "" ? 0 : Number.parseInt(value, 10)))
.filter((value) => Number.isFinite(value))
for (let index = 0; index < codes.length; index += 1) {
const code = codes[index]
if (code === 0) {
Object.keys(state).forEach((key) => {
delete state[key as keyof AnsiState]
})
continue
}
if (code === 1) {
state.bold = true
continue
}
if (code === 2) {
state.dim = true
continue
}
if (code === 3) {
state.italic = true
continue
}
if (code === 4) {
state.underline = true
continue
}
if (code === 9) {
state.strikethrough = true
continue
}
if (code === 21 || code === 22) {
delete state.bold
delete state.dim
continue
}
if (code === 23) {
delete state.italic
continue
}
if (code === 24) {
delete state.underline
continue
}
if (code === 29) {
delete state.strikethrough
continue
}
if (code === 39) {
delete state.foreground
continue
}
if (code === 49) {
delete state.background
continue
}
if (code === 59) {
delete state.underlineColor
continue
}
if (code === 38) {
index = applyExtendedColor(state, codes, index, "foreground")
continue
}
if (code === 48) {
index = applyExtendedColor(state, codes, index, "background")
continue
}
if (code === 58) {
index = applyExtendedColor(state, codes, index, "underlineColor")
continue
}
if ((code >= 30 && code <= 37) || (code >= 90 && code <= 97)) {
state.foreground = codeToColor(code)
continue
}
if ((code >= 40 && code <= 47) || (code >= 100 && code <= 107)) {
state.background = codeToColor(code)
}
}
lastIndex = ANSI_PATTERN.lastIndex
}
pushText(input.slice(lastIndex))
if (segments.length === 0) {
return [{ style: {}, text: input }]
}
return segments
}
export function wrapLogLine(line: string, columns: number): string {
const normalized = line.replaceAll("\r\n", "\n").replaceAll("\r", "\n")
if (columns < 20) {
return normalized
}
return wrapAnsi(normalized, columns, {
hard: true,
trim: false,
wordWrap: false,
})
}
+1 -150
View File
@@ -1,156 +1,7 @@
import { IconTrash } from "@tabler/icons-react"
import { createFileRoute } from "@tanstack/react-router"
import { useAtomValue } from "jotai"
import { useEffect, useRef, useState } from "react"
import { useTranslation } from "react-i18next"
import { clearGatewayLogs, getGatewayStatus } from "@/api/gateway"
import { PageHeader } from "@/components/page-header"
import { Button } from "@/components/ui/button"
import { ScrollArea } from "@/components/ui/scroll-area"
import { gatewayAtom } from "@/store/gateway"
import { LogsPage } from "@/components/logs/logs-page"
export const Route = createFileRoute("/logs")({
component: LogsPage,
})
function LogsPage() {
const { t } = useTranslation()
const [logs, setLogs] = useState<string[]>([])
const [clearing, setClearing] = useState(false)
const logOffsetRef = useRef<number>(0)
const logRunIdRef = useRef<number>(-1)
const syncTokenRef = useRef<number>(0)
const scrollRef = useRef<HTMLDivElement>(null)
const gateway = useAtomValue(gatewayAtom)
const handleClearLogs = async () => {
setClearing(true)
try {
const data = await clearGatewayLogs()
syncTokenRef.current += 1
setLogs([])
logOffsetRef.current = data.log_total ?? 0
if (data.log_run_id !== undefined) {
logRunIdRef.current = data.log_run_id
}
} catch {
// Ignore clear failures silently to avoid noisy transient errors.
} finally {
setClearing(false)
}
}
useEffect(() => {
let mounted = true
let timeout: ReturnType<typeof setTimeout>
const fetchLogs = async () => {
// Only fetch logs if the gateway is running or starting
if (
!mounted ||
(gateway.status !== "running" && gateway.status !== "starting")
) {
if (mounted) {
// Still poll the state, but maybe at a slower rate, or we just rely on SSE for status
// and restart fast polling when it's running. Let's just re-evaluate every second
timeout = setTimeout(fetchLogs, 1000)
}
return
}
try {
const requestToken = syncTokenRef.current
const requestOffset = logOffsetRef.current
const requestRunId = logRunIdRef.current
const data = await getGatewayStatus({
log_offset: requestOffset,
log_run_id: requestRunId,
})
if (!mounted || requestToken !== syncTokenRef.current) return
if (data.log_run_id !== undefined && data.log_run_id !== requestRunId) {
logRunIdRef.current = data.log_run_id
logOffsetRef.current = 0
if (data.logs) {
setLogs(data.logs)
logOffsetRef.current = data.log_total || data.logs.length
}
} else if (data.logs && data.logs.length > 0) {
setLogs((prev) => [...prev, ...data.logs!])
logOffsetRef.current =
data.log_total || logOffsetRef.current + data.logs.length
}
} catch {
// Ignore simple fetch errors during polling
} finally {
if (mounted) {
timeout = setTimeout(fetchLogs, 1000)
}
}
}
fetchLogs()
return () => {
mounted = false
clearTimeout(timeout)
}
}, [gateway.status])
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollIntoView({ behavior: "smooth" })
}
}, [logs])
return (
<div className="flex h-full flex-col">
<PageHeader title={t("navigation.logs")} />
<div className="flex flex-1 flex-col overflow-hidden p-4 sm:p-8">
<div className="mb-4 flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight">
{t("navigation.logs")}
</h1>
<p className="text-muted-foreground mt-2 text-sm">
{t("pages.logs.description")}
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={handleClearLogs}
disabled={logs.length === 0 || clearing}
>
<IconTrash className="size-4" />
{t("pages.logs.clear")}
</Button>
</div>
<div className="bg-muted/30 relative flex-1 overflow-hidden rounded-lg border">
<ScrollArea className="h-full">
<div className="p-4 font-mono text-sm leading-relaxed">
{logs.length === 0 ? (
<div className="text-muted-foreground italic">
{t("pages.logs.empty")}
</div>
) : (
logs.map((log, i) => (
<div key={i} className="break-all whitespace-pre-wrap">
{log}
</div>
))
)}
<div ref={scrollRef} />
</div>
</ScrollArea>
</div>
</div>
</div>
)
}