mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
fix(web): render ansi logs with wrapped lines (#1425)
This commit is contained in:
@@ -1,5 +1,4 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
@@ -23,4 +22,4 @@ dist-ssr
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
.tanstack
|
||||
.tanstack
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+28
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -450,7 +450,6 @@
|
||||
"unsaved_changes": "You have unsaved changes."
|
||||
},
|
||||
"logs": {
|
||||
"description": "System logs and monitoring.",
|
||||
"clear": "Clear logs",
|
||||
"empty": "Waiting for logs..."
|
||||
}
|
||||
|
||||
@@ -450,7 +450,6 @@
|
||||
"unsaved_changes": "您有未保存的更改。"
|
||||
},
|
||||
"logs": {
|
||||
"description": "系统日志和监控。",
|
||||
"clear": "清空日志",
|
||||
"empty": "等待日志中..."
|
||||
}
|
||||
|
||||
@@ -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 { 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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user