diff --git a/web/frontend/.gitignore b/web/frontend/.gitignore
index 4811cdd9b..72e68ffba 100644
--- a/web/frontend/.gitignore
+++ b/web/frontend/.gitignore
@@ -1,5 +1,4 @@
# Logs
-logs
*.log
npm-debug.log*
yarn-debug.log*
@@ -23,4 +22,4 @@ dist-ssr
*.sln
*.sw?
-.tanstack
\ No newline at end of file
+.tanstack
diff --git a/web/frontend/package.json b/web/frontend/package.json
index 687fd5771..373b4d468 100644
--- a/web/frontend/package.json
+++ b/web/frontend/package.json
@@ -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",
diff --git a/web/frontend/pnpm-lock.yaml b/web/frontend/pnpm-lock.yaml
index 9de3354a1..75acacfa5 100644
--- a/web/frontend/pnpm-lock.yaml
+++ b/web/frontend/pnpm-lock.yaml
@@ -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
diff --git a/web/frontend/src/components/logs/ansi-log-line.tsx b/web/frontend/src/components/logs/ansi-log-line.tsx
new file mode 100644
index 000000000..db078efd2
--- /dev/null
+++ b/web/frontend/src/components/logs/ansi-log-line.tsx
@@ -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 (
+
+ {segments.map((segment, index) => (
+
+ {segment.text}
+
+ ))}
+
+ )
+}
diff --git a/web/frontend/src/components/logs/logs-page.tsx b/web/frontend/src/components/logs/logs-page.tsx
new file mode 100644
index 000000000..a4c458fa2
--- /dev/null
+++ b/web/frontend/src/components/logs/logs-page.tsx
@@ -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 (
+
+
+
+ {t("pages.logs.clear")}
+
+ }
+ />
+
+
+
+
+
+ )
+}
diff --git a/web/frontend/src/components/logs/logs-panel.tsx b/web/frontend/src/components/logs/logs-panel.tsx
new file mode 100644
index 000000000..083fb74d8
--- /dev/null
+++ b/web/frontend/src/components/logs/logs-panel.tsx
@@ -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
+ measureRef: RefObject
+}
+
+export function LogsPanel({
+ logs,
+ wrapColumns,
+ contentRef,
+ measureRef,
+}: LogsPanelProps) {
+ const { t } = useTranslation()
+ const scrollRef = useRef(null)
+
+ useEffect(() => {
+ if (scrollRef.current) {
+ scrollRef.current.scrollIntoView({ behavior: "smooth" })
+ }
+ }, [logs])
+
+ return (
+
+
+
+
+ 0
+
+ {logs.length === 0 ? (
+
{t("pages.logs.empty")}
+ ) : (
+ logs.map((log, index) => (
+
+ ))
+ )}
+
+
+
+
+ )
+}
diff --git a/web/frontend/src/components/shared-form.tsx b/web/frontend/src/components/shared-form.tsx
index 05e429e26..14da8e1f1 100644
--- a/web/frontend/src/components/shared-form.tsx
+++ b/web/frontend/src/components/shared-form.tsx
@@ -35,13 +35,13 @@ export function Field({
if (layout === "setting-row") {
return (
-
+
{label}
{required && *}
{hint && (
-
+
{hint}
)}
@@ -141,10 +141,10 @@ export function SwitchCardField({
if (layout === "setting-row") {
return (
-
+
{label}
{hint && (
-
+
{hint}
)}
diff --git a/web/frontend/src/hooks/use-gateway-logs.ts b/web/frontend/src/hooks/use-gateway-logs.ts
new file mode 100644
index 000000000..a39e6e930
--- /dev/null
+++ b/web/frontend/src/hooks/use-gateway-logs.ts
@@ -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
([])
+ 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
+
+ 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,
+ }
+}
diff --git a/web/frontend/src/hooks/use-log-wrap-columns.ts b/web/frontend/src/hooks/use-log-wrap-columns.ts
new file mode 100644
index 000000000..9a07e019c
--- /dev/null
+++ b/web/frontend/src/hooks/use-log-wrap-columns.ts
@@ -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(null)
+ const measureRef = useRef(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,
+ }
+}
diff --git a/web/frontend/src/i18n/locales/en.json b/web/frontend/src/i18n/locales/en.json
index 5a151c73c..453c5905f 100644
--- a/web/frontend/src/i18n/locales/en.json
+++ b/web/frontend/src/i18n/locales/en.json
@@ -450,7 +450,6 @@
"unsaved_changes": "You have unsaved changes."
},
"logs": {
- "description": "System logs and monitoring.",
"clear": "Clear logs",
"empty": "Waiting for logs..."
}
diff --git a/web/frontend/src/i18n/locales/zh.json b/web/frontend/src/i18n/locales/zh.json
index e01cb225f..b6bdedbfa 100644
--- a/web/frontend/src/i18n/locales/zh.json
+++ b/web/frontend/src/i18n/locales/zh.json
@@ -450,7 +450,6 @@
"unsaved_changes": "您有未保存的更改。"
},
"logs": {
- "description": "系统日志和监控。",
"clear": "清空日志",
"empty": "等待日志中..."
}
diff --git a/web/frontend/src/lib/ansi-log.ts b/web/frontend/src/lib/ansi-log.ts
new file mode 100644
index 000000000..39561fb98
--- /dev/null
+++ b/web/frontend/src/lib/ansi-log.ts
@@ -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,
+ })
+}
diff --git a/web/frontend/src/routes/logs.tsx b/web/frontend/src/routes/logs.tsx
index ef39e0bdf..86cbf1210 100644
--- a/web/frontend/src/routes/logs.tsx
+++ b/web/frontend/src/routes/logs.tsx
@@ -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([])
- const [clearing, setClearing] = useState(false)
- const logOffsetRef = useRef(0)
- const logRunIdRef = useRef(-1)
- const syncTokenRef = useRef(0)
- const scrollRef = useRef(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
-
- 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 (
-
-
-
-
-
-
-
- {t("navigation.logs")}
-
-
- {t("pages.logs.description")}
-
-
-
-
-
-
-
-
-
- {logs.length === 0 ? (
-
- {t("pages.logs.empty")}
-
- ) : (
- logs.map((log, i) => (
-
- {log}
-
- ))
- )}
-
-
-
-
-
-
- )
-}