import { useState, useRef, useEffect, useCallback, useMemo } from 'react'; import { Dialog, DialogContent, DialogTitle, DialogDescription, } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { ScrollText, Maximize2, Minimize2, X, Play, Pause, Download, Search, RefreshCw, Loader2, Box, History, Clock, Filter, AlertCircle, } from 'lucide-react'; import { cn } from '@/lib/utils'; import { kubernetesApi } from '@/api'; import { useToast } from '@/hooks/use-toast'; import type { JsonData } from '@/types/common'; /** * 增强版 Pod 日志查看器 — 增强 DeploymentDetail 的日志体验 * * 功能: * - 容器选择下拉(多容器 Pod) * - 实时跟踪开关(tail -f 效果,用 fetch stream) * - 行数限制选择(100 / 500 / 1000 / 全部) * - previous 容器日志(上一次崩溃的日志) * - 时间范围筛选(sinceTime) * - 关键字过滤(前端过滤,高亮) * - 下载按钮(导出当前日志为文件) * - 暂停 / 继续(流式时) * - 10000 行环形缓冲(借鉴 opsx-agent) * * @author Logan */ interface PodLogsViewerProps { open: boolean; onOpenChange: (open: boolean) => void; clusterCode: string; namespace: string; podName: string; /** 可选:Pod 容器名列表 */ containers?: string[]; initialContainer?: string; } const TAIL_OPTIONS = [ { value: '100', label: '最近 100 行' }, { value: '500', label: '最近 500 行' }, { value: '1000', label: '最近 1000 行' }, { value: '0', label: '全部' }, ]; // 环形缓冲大小(借鉴 opsx-agent) const RING_BUFFER_MAX = 10000; interface LogLine { id: number; text: string; } export function PodLogsViewer({ open, onOpenChange, clusterCode, namespace, podName, containers = [], initialContainer, }: PodLogsViewerProps) { const { toast } = useToast(); const [fullscreen, setFullscreen] = useState(false); const [container, setContainer] = useState(initialContainer || containers[0] || ''); const [tailLines, setTailLines] = useState(500); const [previous, setPrevious] = useState(false); const [sinceTime, setSinceTime] = useState(''); const [keyword, setKeyword] = useState(''); const [autoScroll, setAutoScroll] = useState(true); // 流式状态 const [streaming, setStreaming] = useState(false); const [paused, setPaused] = useState(false); const [loading, setLoading] = useState(false); // 环形缓冲(使用 state 触发渲染,ref 持久存储) const [lines, setLines] = useState([]); const linesRef = useRef([]); const lineIdRef = useRef(0); const abortRef = useRef(null); const pausedRef = useRef(false); const autoScrollRef = useRef(true); const scrollRef = useRef(null); useEffect(() => { pausedRef.current = paused; }, [paused]); useEffect(() => { autoScrollRef.current = autoScroll; }, [autoScroll]); // 重置全屏状态 useEffect(() => { if (!open) setFullscreen(false); }, [open]); // 容器列表变化时同步默认值 useEffect(() => { if (open && !container && containers.length > 0) { setContainer(initialContainer || containers[0]); } }, [open, containers, initialContainer, container]); // 追加行到环形缓冲(批量更新) const appendLines = useCallback((newTexts: string[]) => { if (newTexts.length === 0) return; const next = [...linesRef.current]; for (const t of newTexts) { next.push({ id: ++lineIdRef.current, text: t }); } // 环形缓冲裁剪 if (next.length > RING_BUFFER_MAX) { linesRef.current = next.slice(next.length - RING_BUFFER_MAX); } else { linesRef.current = next; } setLines(linesRef.current); }, []); // 自动滚动到底部 useEffect(() => { if (autoScrollRef.current && scrollRef.current) { scrollRef.current.scrollTop = scrollRef.current.scrollHeight; } }, [lines]); // 停止流 const stopStream = useCallback(() => { if (abortRef.current) { abortRef.current.abort(); abortRef.current = null; } setStreaming(false); setPaused(false); }, []); // 一次性拉取日志 const fetchLogs = useCallback(async () => { if (!podName) return; stopStream(); setLoading(true); linesRef.current = []; setLines([]); try { const res: JsonData = await kubernetesApi.getPodLogsEnhanced(clusterCode, namespace, podName, { container: container || undefined, tailLines: tailLines, previous, sinceTime: sinceTime || undefined, }); const raw = res?.logs ?? res?.data?.logs ?? (typeof res === 'string' ? res : '') ?? ''; const arr = typeof raw === 'string' ? raw.split('\n') : (Array.isArray(raw) ? raw : String(raw).split('\n')); appendLines(arr); } catch (e) { appendLines([`\x1b[31m[获取日志失败: ${e?.message || String(e)}]\x1b[0m`]); toast({ title: '获取日志失败', description: e?.message || String(e), variant: 'destructive' }); } finally { setLoading(false); } }, [clusterCode, namespace, podName, container, tailLines, previous, sinceTime, appendLines, stopStream, toast]); // 启动实时流(fetch ReadableStream,tail -f 效果) const startStream = useCallback(async () => { if (!podName) return; stopStream(); linesRef.current = []; setLines([]); setLoading(true); setStreaming(true); setPaused(false); const controller = new AbortController(); abortRef.current = controller; const url = kubernetesApi.buildPodLogsStreamUrl(clusterCode, namespace, podName, { container: container || undefined, tailLines: tailLines, previous, sinceTime: sinceTime || undefined, }); const token = (await import('@/store')).useUserStore.getState().token || ''; try { const resp = await fetch(url, { signal: controller.signal, headers: token ? { Authorization: `Bearer ${token}` } : undefined, }); if (!resp.ok || !resp.body) { throw new Error(`HTTP ${resp.status}`); } setLoading(false); const reader = resp.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; const flush = () => { if (!buffer) return; // 按行切分,保留最后未结束的行 const parts = buffer.split('\n'); buffer = parts.pop() ?? ''; if (parts.length > 0) appendLines(parts); }; while (true) { const { done, value } = await reader.read(); if (done) break; // 暂停时累积但不写入缓冲(丢弃,保持实时性);这里改为暂停时不 append buffer += decoder.decode(value, { stream: true }); if (!pausedRef.current) { flush(); } else { // 暂停期间丢弃缓冲内容,避免堆积 buffer = ''; } } flush(); // 收尾 appendLines(['\x1b[33m— 流式结束 —\x1b[0m']); } catch (e) { if (e?.name === 'AbortError') { appendLines(['\x1b[33m— 已停止 —\x1b[0m']); } else { appendLines([`\x1b[31m[流式连接中断: ${e?.message || String(e)}]\x1b[0m`]); } } finally { setStreaming(false); setPaused(false); setLoading(false); abortRef.current = null; } }, [clusterCode, namespace, podName, container, tailLines, previous, sinceTime, appendLines, stopStream]); // 打开时自动拉取一次 useEffect(() => { if (open && podName) { fetchLogs(); } return () => { stopStream(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, podName]); // 参数变化时若正在流则重启;否则只重新拉取(用户手动控制更友好,这里仅在非流时自动刷新) useEffect(() => { if (!open) return; if (streaming) { // 重启流 startStream(); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [container, tailLines, previous, sinceTime]); // 关闭弹窗清理 const handleClose = useCallback(() => { stopStream(); onOpenChange(false); }, [stopStream, onOpenChange]); // 下载当前(过滤后)日志 const handleDownload = useCallback(() => { const content = filteredLines.map((l) => l.text).join('\n'); const filename = `${podName}${container ? '-' + container : ''}-${previous ? 'previous-' : ''}${new Date().toISOString().slice(0, 19).replace(/[:T]/g, '')}.log`; const blob = new Blob([content], { type: 'text/plain;charset=utf-8' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = filename; a.click(); URL.revokeObjectURL(a.href); toast({ title: '已导出', description: filename }); }, [podName, container, previous, toast]); // 关键字过滤 const filteredLines = useMemo(() => { if (!keyword.trim()) return lines; const kw = keyword.trim().toLowerCase(); return lines.filter((l) => l.text.toLowerCase().includes(kw)); }, [lines, keyword]); // 高亮关键字渲染 const renderLine = (line: LogLine) => { const text = line.text; if (!keyword.trim()) return {text}; const kw = keyword.trim(); const lower = text.toLowerCase(); const kwLower = kw.toLowerCase(); const parts: React.ReactNode[] = []; let idx = 0; let key = 0; while (idx < text.length) { const found = lower.indexOf(kwLower, idx); if (found === -1) { parts.push({text.slice(idx)}); break; } if (found > idx) parts.push({text.slice(idx, found)}); parts.push( {text.slice(found, found + kw.length)} ); idx = found + kw.length; } return <>{parts}; }; const lineCount = lines.length; const matchedCount = filteredLines.length; return ( { if (!o) handleClose(); }}> e.preventDefault()} className={cn( 'bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] p-0 gap-0', '!duration-0 !transition-none', fullscreen ? '!max-w-none !w-screen !h-screen !rounded-none !translate-x-0 !translate-y-0 !left-0 !top-0' : '' )} style={fullscreen ? { maxWidth: '100vw', width: '100vw', height: '100vh' } : { maxWidth: '1100px', width: '94vw' }} > 日志 — {podName} Pod 容器日志查看 {/* 头部工具栏 */}
日志 — {podName} {loading && } {streaming ? (paused ? '已暂停' : '实时跟踪') : '已停止'} · {matchedCount}{keyword ? `/${lineCount}` : ''} 行 {lineCount >= RING_BUFFER_MAX && ' (缓冲已满)'}
{/* 控制栏 */}
{/* 容器 */} {containers.length > 0 && ( )} {/* 行数 */} {/* previous */} {/* sinceTime */}
{ const v = e.target.value; setSinceTime(v ? new Date(v).toISOString() : ''); }} className="h-8 w-[170px] text-xs bg-[var(--dg-bg-hover)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" title="起始时间(sinceTime)" />
{/* 关键字过滤 */}
setKeyword(e.target.value)} placeholder="关键字过滤…" className="h-8 w-[160px] pl-7 text-xs bg-[var(--dg-bg-hover)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
{/* 下载 */} {/* 刷新(一次性) */} {/* 实时跟踪 / 暂停 / 继续 */} {!streaming ? ( ) : ( <> )}
{/* 日志内容 */}
{ const el = e.currentTarget; // 用户主动向上滚时关闭自动跟随 const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40; if (!atBottom && autoScrollRef.current) setAutoScroll(false); if (atBottom && !autoScrollRef.current) setAutoScroll(true); }} className={cn( 'overflow-auto bg-[#0f172a] font-mono text-xs leading-relaxed p-3', 'text-slate-300' )} style={{ height: fullscreen ? 'calc(100vh - 98px)' : '520px' }} > {lineCount === 0 && !loading && (
暂无日志
)} {loading && lineCount === 0 && (
加载中…
)} {filteredLines.map((line) => (
{renderLine(line)}
))}
{/* 底部状态条 */}
缓冲 {lineCount}/{RING_BUFFER_MAX} {keyword && 匹配 {matchedCount}} {container && {container}} {previous && previous}
); }