fix(web): render ansi logs with wrapped lines (#1425)
This commit is contained in:
parent
7872bb3f0a
commit
d18a319b0c
13 changed files with 595 additions and 159 deletions
3
web/frontend/.gitignore
vendored
3
web/frontend/.gitignore
vendored
|
|
@ -1,5 +1,4 @@
|
||||||
# Logs
|
# Logs
|
||||||
logs
|
|
||||||
*.log
|
*.log
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
|
|
@ -23,4 +22,4 @@ dist-ssr
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
.tanstack
|
.tanstack
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,8 @@
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tailwindcss": "^4.2.1",
|
"tailwindcss": "^4.2.1",
|
||||||
"tw-animate-css": "^1.4.0"
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"wrap-ansi": "^10.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
|
|
|
||||||
28
web/frontend/pnpm-lock.yaml
generated
28
web/frontend/pnpm-lock.yaml
generated
|
|
@ -80,6 +80,9 @@ importers:
|
||||||
tw-animate-css:
|
tw-animate-css:
|
||||||
specifier: ^1.4.0
|
specifier: ^1.4.0
|
||||||
version: 1.4.0
|
version: 1.4.0
|
||||||
|
wrap-ansi:
|
||||||
|
specifier: ^10.0.0
|
||||||
|
version: 10.0.0
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@eslint/js':
|
'@eslint/js':
|
||||||
specifier: ^9.39.1
|
specifier: ^9.39.1
|
||||||
|
|
@ -1837,6 +1840,10 @@ packages:
|
||||||
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
|
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
ansi-styles@6.2.3:
|
||||||
|
resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
ansis@4.2.0:
|
ansis@4.2.0:
|
||||||
resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==}
|
resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
|
|
@ -3558,6 +3565,10 @@ packages:
|
||||||
resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==}
|
resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
string-width@8.2.0:
|
||||||
|
resolution: {integrity: sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
stringify-entities@4.0.4:
|
stringify-entities@4.0.4:
|
||||||
resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==}
|
resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==}
|
||||||
|
|
||||||
|
|
@ -3883,6 +3894,10 @@ packages:
|
||||||
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
|
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
|
||||||
engines: {node: '>=0.10.0'}
|
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:
|
wrap-ansi@6.2.0:
|
||||||
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
|
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
@ -5677,6 +5692,8 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
color-convert: 2.0.1
|
color-convert: 2.0.1
|
||||||
|
|
||||||
|
ansi-styles@6.2.3: {}
|
||||||
|
|
||||||
ansis@4.2.0: {}
|
ansis@4.2.0: {}
|
||||||
|
|
||||||
anymatch@3.1.3:
|
anymatch@3.1.3:
|
||||||
|
|
@ -7628,6 +7645,11 @@ snapshots:
|
||||||
get-east-asian-width: 1.5.0
|
get-east-asian-width: 1.5.0
|
||||||
strip-ansi: 7.2.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:
|
stringify-entities@4.0.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
character-entities-html4: 2.1.0
|
character-entities-html4: 2.1.0
|
||||||
|
|
@ -7904,6 +7926,12 @@ snapshots:
|
||||||
|
|
||||||
word-wrap@1.2.5: {}
|
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:
|
wrap-ansi@6.2.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
ansi-styles: 4.3.0
|
ansi-styles: 4.3.0
|
||||||
|
|
|
||||||
24
web/frontend/src/components/logs/ansi-log-line.tsx
Normal file
24
web/frontend/src/components/logs/ansi-log-line.tsx
Normal file
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
42
web/frontend/src/components/logs/logs-page.tsx
Normal file
42
web/frontend/src/components/logs/logs-page.tsx
Normal file
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
55
web/frontend/src/components/logs/logs-panel.tsx
Normal file
55
web/frontend/src/components/logs/logs-panel.tsx
Normal file
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -35,13 +35,13 @@ export function Field({
|
||||||
if (layout === "setting-row") {
|
if (layout === "setting-row") {
|
||||||
return (
|
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="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>
|
<FieldLabel>
|
||||||
{label}
|
{label}
|
||||||
{required && <span className="text-destructive ml-1">*</span>}
|
{required && <span className="text-destructive ml-1">*</span>}
|
||||||
</FieldLabel>
|
</FieldLabel>
|
||||||
{hint && (
|
{hint && (
|
||||||
<FieldDescription className="text-xs leading-normal">
|
<FieldDescription className="text-xs leading-normal break-words">
|
||||||
{hint}
|
{hint}
|
||||||
</FieldDescription>
|
</FieldDescription>
|
||||||
)}
|
)}
|
||||||
|
|
@ -141,10 +141,10 @@ export function SwitchCardField({
|
||||||
if (layout === "setting-row") {
|
if (layout === "setting-row") {
|
||||||
return (
|
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="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>
|
<p className="text-sm font-medium">{label}</p>
|
||||||
{hint && (
|
{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}
|
{hint}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
96
web/frontend/src/hooks/use-gateway-logs.ts
Normal file
96
web/frontend/src/hooks/use-gateway-logs.ts
Normal file
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
52
web/frontend/src/hooks/use-log-wrap-columns.ts
Normal file
52
web/frontend/src/hooks/use-log-wrap-columns.ts
Normal file
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -450,7 +450,6 @@
|
||||||
"unsaved_changes": "You have unsaved changes."
|
"unsaved_changes": "You have unsaved changes."
|
||||||
},
|
},
|
||||||
"logs": {
|
"logs": {
|
||||||
"description": "System logs and monitoring.",
|
|
||||||
"clear": "Clear logs",
|
"clear": "Clear logs",
|
||||||
"empty": "Waiting for logs..."
|
"empty": "Waiting for logs..."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -450,7 +450,6 @@
|
||||||
"unsaved_changes": "您有未保存的更改。"
|
"unsaved_changes": "您有未保存的更改。"
|
||||||
},
|
},
|
||||||
"logs": {
|
"logs": {
|
||||||
"description": "系统日志和监控。",
|
|
||||||
"clear": "清空日志",
|
"clear": "清空日志",
|
||||||
"empty": "等待日志中..."
|
"empty": "等待日志中..."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
290
web/frontend/src/lib/ansi-log.ts
Normal file
290
web/frontend/src/lib/ansi-log.ts
Normal 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,156 +1,7 @@
|
||||||
import { IconTrash } from "@tabler/icons-react"
|
|
||||||
import { createFileRoute } from "@tanstack/react-router"
|
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 { LogsPage } from "@/components/logs/logs-page"
|
||||||
import { PageHeader } from "@/components/page-header"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
|
||||||
import { gatewayAtom } from "@/store/gateway"
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/logs")({
|
export const Route = createFileRoute("/logs")({
|
||||||
component: LogsPage,
|
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue