From d18a319b0ccaeea00618c22d25e6bb6baa57df0f Mon Sep 17 00:00:00 2001 From: wenjie Date: Thu, 12 Mar 2026 19:12:19 +0800 Subject: [PATCH] fix(web): render ansi logs with wrapped lines (#1425) --- web/frontend/.gitignore | 3 +- web/frontend/package.json | 3 +- web/frontend/pnpm-lock.yaml | 28 ++ .../src/components/logs/ansi-log-line.tsx | 24 ++ .../src/components/logs/logs-page.tsx | 42 +++ .../src/components/logs/logs-panel.tsx | 55 ++++ web/frontend/src/components/shared-form.tsx | 8 +- web/frontend/src/hooks/use-gateway-logs.ts | 96 ++++++ .../src/hooks/use-log-wrap-columns.ts | 52 ++++ web/frontend/src/i18n/locales/en.json | 1 - web/frontend/src/i18n/locales/zh.json | 1 - web/frontend/src/lib/ansi-log.ts | 290 ++++++++++++++++++ web/frontend/src/routes/logs.tsx | 151 +-------- 13 files changed, 595 insertions(+), 159 deletions(-) create mode 100644 web/frontend/src/components/logs/ansi-log-line.tsx create mode 100644 web/frontend/src/components/logs/logs-page.tsx create mode 100644 web/frontend/src/components/logs/logs-panel.tsx create mode 100644 web/frontend/src/hooks/use-gateway-logs.ts create mode 100644 web/frontend/src/hooks/use-log-wrap-columns.ts create mode 100644 web/frontend/src/lib/ansi-log.ts 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} -
- )) - )} -
-
- -
-
-
- ) -}