Spaces:
Runtime error
Runtime error
| "use client"; | |
| import { useTranslations } from "next-intl"; | |
| /** | |
| * Console Log Viewer — Real-time application log viewer. | |
| * | |
| * Displays structured application logs from the server with a terminal-like UI. | |
| * Polls the backend API every 5 seconds. Shows logs from the last 1 hour. | |
| * Supports level filtering, text search, auto-scroll, and copy-to-clipboard. | |
| */ | |
| import { useState, useEffect, useRef, useCallback } from "react"; | |
| import { copyToClipboard } from "@/shared/utils/clipboard"; | |
| interface LogEntry { | |
| timestamp: string; | |
| level: string; | |
| component?: string; | |
| module?: string; | |
| message?: string; | |
| msg?: string; | |
| correlationId?: string; | |
| [key: string]: unknown; | |
| } | |
| const LEVEL_COLORS: Record<string, string> = { | |
| debug: "text-gray-400", | |
| trace: "text-gray-500", | |
| info: "text-cyan-400", | |
| warn: "text-yellow-400", | |
| error: "text-red-400", | |
| fatal: "text-fuchsia-400", | |
| }; | |
| const LEVEL_BG: Record<string, string> = { | |
| debug: "bg-gray-500/10 border-gray-500/20", | |
| trace: "bg-gray-500/10 border-gray-500/20", | |
| info: "bg-cyan-500/10 border-cyan-500/20", | |
| warn: "bg-yellow-500/10 border-yellow-500/20", | |
| error: "bg-red-500/10 border-red-500/20", | |
| fatal: "bg-fuchsia-500/10 border-fuchsia-500/20", | |
| }; | |
| const POLL_INTERVAL = 5000; // 5 seconds | |
| export default function ConsoleLogViewer() { | |
| const t = useTranslations("loggers"); | |
| const tv = useTranslations("logs.consoleViewer"); | |
| const [logs, setLogs] = useState<LogEntry[]>([]); | |
| const [loading, setLoading] = useState(true); | |
| const [error, setError] = useState<string | null>(null); | |
| const [levelFilter, setLevelFilter] = useState("all"); | |
| const [searchText, setSearchText] = useState(""); | |
| const [autoScroll, setAutoScroll] = useState(true); | |
| const [lastUpdated, setLastUpdated] = useState<Date | null>(null); | |
| const [copiedIdx, setCopiedIdx] = useState<number | null>(null); | |
| const scrollRef = useRef<HTMLDivElement>(null); | |
| const fetchLogs = useCallback(async () => { | |
| try { | |
| const params = new URLSearchParams(); | |
| if (levelFilter !== "all") params.set("level", levelFilter); | |
| params.set("limit", "500"); | |
| const res = await fetch(`/api/logs/console?${params.toString()}`); | |
| if (!res.ok) throw new Error(`HTTP ${res.status}`); | |
| const data: LogEntry[] = await res.json(); | |
| setLogs(data); | |
| setLastUpdated(new Date()); | |
| setError(null); | |
| } catch (err: any) { | |
| setError(err.message || tv("fetchFailed")); | |
| } finally { | |
| setLoading(false); | |
| } | |
| }, [levelFilter, tv]); | |
| // Initial fetch + polling | |
| useEffect(() => { | |
| fetchLogs(); | |
| const interval = setInterval(fetchLogs, POLL_INTERVAL); | |
| return () => clearInterval(interval); | |
| }, [fetchLogs]); | |
| // Auto-scroll to bottom on new logs | |
| useEffect(() => { | |
| if (autoScroll && scrollRef.current) { | |
| scrollRef.current.scrollTop = scrollRef.current.scrollHeight; | |
| } | |
| }, [logs, autoScroll]); | |
| const handleCopy = async (entry: LogEntry, idx: number) => { | |
| const text = JSON.stringify(entry, null, 2); | |
| const success = await copyToClipboard(text); | |
| if (!success) { | |
| setError(tv("copyFailed")); | |
| return; | |
| } | |
| setError(null); | |
| setCopiedIdx(idx); | |
| setTimeout(() => setCopiedIdx(null), 2000); | |
| }; | |
| const formatTime = (ts: string) => { | |
| try { | |
| const d = new Date(ts); | |
| return d.toLocaleTimeString("en-US", { | |
| hour12: false, | |
| hour: "2-digit", | |
| minute: "2-digit", | |
| second: "2-digit", | |
| fractionalSecondDigits: 3, | |
| }); | |
| } catch { | |
| return ts; | |
| } | |
| }; | |
| const stringifyValue = (value: unknown) => { | |
| if (value === undefined || value === null) return ""; | |
| if (typeof value === "string") return value; | |
| if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") { | |
| return String(value); | |
| } | |
| try { | |
| return JSON.stringify(value); | |
| } catch { | |
| return String(value); | |
| } | |
| }; | |
| const getText = (entry: LogEntry) => stringifyValue(entry.msg || entry.message || ""); | |
| const getComponent = (entry: LogEntry) => stringifyValue(entry.component || entry.module || ""); | |
| const getCorrelationId = (entry: LogEntry) => stringifyValue(entry.correlationId); | |
| // Apply text search filter | |
| const filteredLogs = searchText | |
| ? logs.filter((entry) => { | |
| const full = JSON.stringify(entry).toLowerCase(); | |
| return full.includes(searchText.toLowerCase()); | |
| }) | |
| : logs; | |
| return ( | |
| <div className="flex flex-col gap-4"> | |
| {/* Toolbar */} | |
| <div className="flex flex-wrap items-center gap-3 p-4 rounded-xl bg-[var(--color-surface)] border border-[var(--color-border)]"> | |
| {/* Level filter */} | |
| <select | |
| value={levelFilter} | |
| onChange={(e) => setLevelFilter(e.target.value)} | |
| aria-label="Filter by log level" | |
| className="px-3 py-2 rounded-lg text-sm bg-[var(--color-bg)] border border-[var(--color-border)] text-[var(--color-text-main)] focus:outline-2 focus:outline-[var(--color-accent)]" | |
| > | |
| <option value="all">{t("allLevels")}</option> | |
| <option value="debug">Debug+</option> | |
| <option value="info">Info+</option> | |
| <option value="warn">Warn+</option> | |
| <option value="error">Error+</option> | |
| </select> | |
| {/* Search */} | |
| <input | |
| type="text" | |
| placeholder="Search logs..." | |
| value={searchText} | |
| onChange={(e) => setSearchText(e.target.value)} | |
| aria-label="Search log entries" | |
| className="flex-1 min-w-[200px] px-3 py-2 rounded-lg text-sm bg-[var(--color-bg)] border border-[var(--color-border)] text-[var(--color-text-main)] placeholder:text-[var(--color-text-muted)] focus:outline-2 focus:outline-[var(--color-accent)]" | |
| /> | |
| {/* Auto-scroll toggle */} | |
| <button | |
| onClick={() => setAutoScroll(!autoScroll)} | |
| title={autoScroll ? "Disable auto-scroll" : "Enable auto-scroll"} | |
| className={`px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${ | |
| autoScroll | |
| ? "bg-cyan-500/15 text-cyan-400 border-cyan-500/30" | |
| : "bg-[var(--color-bg)] text-[var(--color-text-muted)] border-[var(--color-border)]" | |
| }`} | |
| > | |
| <span className="material-symbols-outlined text-[16px] align-middle mr-1"> | |
| {autoScroll ? "vertical_align_bottom" : "lock"} | |
| </span> | |
| Auto-scroll | |
| </button> | |
| {/* Refresh */} | |
| <button | |
| onClick={fetchLogs} | |
| disabled={loading} | |
| className="px-3 py-2 rounded-lg text-sm font-medium bg-[var(--color-bg)] border border-[var(--color-border)] text-[var(--color-text-main)] hover:bg-[var(--color-bg-alt)] disabled:opacity-50 transition-colors" | |
| > | |
| <span className="material-symbols-outlined text-[16px] align-middle">refresh</span> | |
| </button> | |
| {/* Status */} | |
| <div className="flex items-center gap-2 ml-auto text-xs text-[var(--color-text-muted)]"> | |
| <span className="inline-block w-2 h-2 rounded-full bg-green-500 animate-pulse" /> | |
| <span>{filteredLogs.length} entries</span> | |
| <span className="text-[var(--color-text-muted)]/50">•</span> | |
| <span>Last 1h</span> | |
| {lastUpdated && ( | |
| <> | |
| <span className="text-[var(--color-text-muted)]/50">•</span> | |
| <span>Updated {lastUpdated.toLocaleTimeString()}</span> | |
| </> | |
| )} | |
| </div> | |
| </div> | |
| {/* Error */} | |
| {error && ( | |
| <div | |
| className="p-4 rounded-lg bg-red-500/10 border border-red-500/30 text-red-400 text-sm" | |
| role="alert" | |
| > | |
| <span className="material-symbols-outlined text-[16px] align-middle mr-2">error</span> | |
| {error} | |
| <span className="text-xs ml-2 opacity-70"> | |
| — Make sure the application is writing logs to file (APP_LOG_TO_FILE=true) | |
| </span> | |
| </div> | |
| )} | |
| {/* Console output */} | |
| <div | |
| ref={scrollRef} | |
| className="rounded-xl border border-[var(--color-border)] bg-[#0d1117] overflow-auto font-mono text-xs leading-relaxed" | |
| style={{ maxHeight: "calc(100vh - 340px)", minHeight: "400px" }} | |
| role="log" | |
| aria-label="Application console logs" | |
| aria-live="polite" | |
| > | |
| {/* Header bar */} | |
| <div className="sticky top-0 z-10 px-4 py-2 bg-[#161b22] border-b border-[#30363d] flex items-center gap-2"> | |
| <div className="w-3 h-3 rounded-full bg-[#FF5F56]" /> | |
| <div className="w-3 h-3 rounded-full bg-[#FFBD2E]" /> | |
| <div className="w-3 h-3 rounded-full bg-[#27C93F]" /> | |
| <span className="ml-3 text-[#8b949e] text-[11px]">OmniRoute — Application Console</span> | |
| </div> | |
| {/* Log entries */} | |
| <div className="p-3 space-y-px"> | |
| {filteredLogs.length === 0 && !loading ? ( | |
| <div className="text-[#8b949e] text-center py-12"> | |
| <span className="material-symbols-outlined text-[40px] block mb-2 opacity-30"> | |
| terminal | |
| </span> | |
| <p>{t("noLogEntries")}</p> | |
| <p className="text-[10px] mt-1 opacity-60"> | |
| Ensure APP_LOG_TO_FILE=true is set in your .env file | |
| </p> | |
| </div> | |
| ) : ( | |
| filteredLogs.map((entry, idx) => { | |
| const level = stringifyValue(entry.level || "info").toLowerCase(); | |
| const colorClass = LEVEL_COLORS[level] || LEVEL_COLORS.info; | |
| const bgClass = LEVEL_BG[level] || ""; | |
| const comp = getComponent(entry); | |
| const msg = getText(entry); | |
| const correlationId = getCorrelationId(entry); | |
| return ( | |
| <div | |
| key={idx} | |
| className={`group flex items-start gap-2 px-2 py-1 rounded hover:bg-white/5 transition-colors ${ | |
| level === "error" || level === "fatal" ? "bg-red-500/5" : "" | |
| }`} | |
| > | |
| {/* Timestamp */} | |
| <span className="text-[#484f58] whitespace-nowrap shrink-0 select-none"> | |
| {formatTime(entry.timestamp)} | |
| </span> | |
| {/* Level badge */} | |
| <span | |
| className={`inline-block px-1.5 py-0 rounded text-[10px] font-semibold uppercase border shrink-0 ${colorClass} ${bgClass}`} | |
| > | |
| {level.padEnd(5)} | |
| </span> | |
| {/* Component */} | |
| {comp && <span className="text-purple-400/80 shrink-0">[{comp}]</span>} | |
| {/* Message */} | |
| <span className="text-[#c9d1d9] flex-1 break-all"> | |
| {msg} | |
| {/* Extra meta */} | |
| {correlationId && ( | |
| <span className="text-[#484f58] ml-2">cid:{correlationId.slice(0, 8)}</span> | |
| )} | |
| </span> | |
| {/* Copy button */} | |
| <button | |
| onClick={() => handleCopy(entry, idx)} | |
| title={tv("copyLogEntry")} | |
| className="opacity-0 group-hover:opacity-100 transition-opacity shrink-0 text-[#8b949e] hover:text-white" | |
| > | |
| <span className="material-symbols-outlined text-[14px]"> | |
| {copiedIdx === idx ? "check" : "content_copy"} | |
| </span> | |
| </button> | |
| </div> | |
| ); | |
| }) | |
| )} | |
| {loading && filteredLogs.length === 0 && ( | |
| <div className="text-[#8b949e] text-center py-12"> | |
| <span className="material-symbols-outlined text-[24px] animate-spin block mb-2"> | |
| progress_activity | |
| </span> | |
| Loading logs... | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } | |