Files
aiops/frontend/src/components/kubernetes/PodLogsViewer.tsx
T
attacker da9fd1e987 feat(quality): U12 工程质量回归修复 — ESLint errors(8→0) + React Hooks 关键规则(82→0)
远程 i18n 全量改造 + 堡垒机合并后引入两类质量回归,本次修复:

1. ESLint errors 8→0:
   - GrafanaDashboard/PrometheusDashboard: 未使用的 const { t } 改为 useTranslation()
   - ServerTableRow: 移除未使用 cn import
   - replay-utils: no-control-regex 标注 disable (终端回放有意检测控制字符)

2. React Hooks 6 大关键规则 82→0 (恢复 U7 治理水平):
   - exhaustive-deps 52→0 (mount-only effects 标注 disable)
   - set-state-in-effect 18→0 (effect 初始化模式)
   - purity 5→0 (AlertCenter 提取 nowMs / EnhancedLogResults 骨架屏确定性公式)
   - incompatible-library 3→0 (@tanstack/react-virtual 兼容性)
   - immutability 2→0 (AlertCenter TDZ)
   - preserve-manual-memoization 2→0 (StatChartPanel useMemo)

验证: tsc -b=0, npm build✓(18.04s), go build=0, ESLint errors=0, 17/17 测试通过
2026-07-06 00:55:01 +08:00

554 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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';
import i18n from '@/i18n';
/**
* 增强版 Pod 日志查看器 — 增强 DeploymentDetail 的日志体验
*
* 功能:
* - 容器选择下拉(多容器 Pod)
* - 实时跟踪开关(tail -f 效果,用 fetch stream)
* - 行数限制选择(100 / 500 / 1000 / 全部)
* - previous 容器日志(上一次崩溃的日志)
* - 时间范围筛选(sinceTime)
* - 关键字过滤(前端过滤,高亮)
* - 下载按钮(导出当前日志为文件)
* - 暂停 / 继续(流式时)
* - 10000 行环形缓冲(借鉴 opsx-agent)
*
* @author Logan <admin@attacker.club>
*/
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: i18n.t('kubernetes.podlogsviewerts.s58b02a7a') },
{ value: '500', label: i18n.t('kubernetes.podlogsviewerts.s9a036801') },
{ value: '1000', label: i18n.t('kubernetes.podlogsviewerts.sf89a330c') },
{ value: '0', label: i18n.t('system.auditAll') },
];
// 环形缓冲大小(借鉴 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<string>(initialContainer || containers[0] || '');
const [tailLines, setTailLines] = useState<number>(500);
const [previous, setPrevious] = useState(false);
const [sinceTime, setSinceTime] = useState<string>('');
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<LogLine[]>([]);
const linesRef = useRef<LogLine[]>([]);
const lineIdRef = useRef(0);
const abortRef = useRef<AbortController | null>(null);
const pausedRef = useRef(false);
const autoScrollRef = useRef(true);
const scrollRef = useRef<HTMLDivElement>(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: i18n.t('k8s.podLogs.loadFailed'), 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([i18n.t('kubernetes.podlogsviewerts.s4f0e17cd')]);
} catch (e) {
if (e?.name === 'AbortError') {
appendLines([i18n.t('kubernetes.podlogsviewerts.s1ca9010d')]);
} 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: i18n.t('monitor.logPanel.exported'), description: filename });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [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 <span>{text}</span>;
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(<span key={key++}>{text.slice(idx)}</span>);
break;
}
if (found > idx) parts.push(<span key={key++}>{text.slice(idx, found)}</span>);
parts.push(
<mark key={key++} className="bg-amber-400/40 text-amber-200 rounded px-0.5">
{text.slice(found, found + kw.length)}
</mark>
);
idx = found + kw.length;
}
return <>{parts}</>;
};
const lineCount = lines.length;
const matchedCount = filteredLines.length;
return (
<Dialog open={open} onOpenChange={(o) => { if (!o) handleClose(); }}>
<DialogContent
showCloseButton={false}
onOpenAutoFocus={(e) => 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' }}
>
<DialogTitle className="sr-only">日志 — {podName}</DialogTitle>
<DialogDescription className="sr-only">Pod 容器日志查看</DialogDescription>
{/* 头部工具栏 */}
<div className="flex items-center justify-between gap-2 px-4 py-2 border-b border-[var(--dg-border)] flex-wrap">
<div className="flex items-center gap-2 text-sm min-w-0">
<div className={cn(
'w-2 h-2 rounded-full shrink-0',
streaming ? (paused ? 'bg-amber-400' : 'bg-emerald-400 animate-pulse') : 'bg-[var(--dg-bg-active)]'
)} />
<ScrollText className="h-4 w-4 dg-text-blue shrink-0" />
<span className="text-[var(--dg-text-primary)] font-medium truncate">日志 — {podName}</span>
{loading && <Loader2 className="h-3 w-3 animate-spin text-[var(--dg-text-muted)]" />}
<span className="text-xs text-[var(--dg-text-muted)]">
{streaming ? (paused ? i18n.t('kubernetes.deployment.paused') : i18n.t('kubernetes.podlogsviewerts.sd9c71f71')) : i18n.t('agent.agentServiceStopped')} · {matchedCount}{keyword ? `/${lineCount}` : ''} 行
{lineCount >= RING_BUFFER_MAX && i18n.t('kubernetes.podlogsviewerts.sf3280b73')}
</span>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => setFullscreen(!fullscreen)}
className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)]"
title={fullscreen ? i18n.t('common.exitFullscreen2') : i18n.t('monitor.fullscreen')}
>
{fullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
</button>
<button
onClick={handleClose}
className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)]"
title={i18n.t('agent.portClosed')}
>
<X className="h-4 w-4" />
</button>
</div>
</div>
{/* 控制栏 */}
<div className="flex items-center gap-2 px-4 py-2 border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)] flex-wrap">
{/* 容器 */}
{containers.length > 0 && (
<Select value={container} onValueChange={setContainer}>
<SelectTrigger className="h-8 w-[150px] text-xs bg-[var(--dg-bg-hover)] border-[var(--dg-border)]">
<Box className="h-3 w-3 mr-1 text-[var(--dg-text-muted)]" />
<SelectValue placeholder={i18n.t('kubernetes.container.title')} />
</SelectTrigger>
<SelectContent>
{containers.map((c) => (
<SelectItem key={c} value={c}>{c}</SelectItem>
))}
</SelectContent>
</Select>
)}
{/* 行数 */}
<Select value={String(tailLines)} onValueChange={(v) => setTailLines(Number(v))}>
<SelectTrigger className="h-8 w-[130px] text-xs bg-[var(--dg-bg-hover)] border-[var(--dg-border)]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TAIL_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
{/* previous */}
<Button
variant={previous ? 'default' : 'outline'}
size="sm"
className={cn('h-8 text-xs', previous ? 'bg-amber-600 hover:bg-amber-700' : 'border-[var(--dg-border)] text-[var(--dg-text-secondary)]')}
onClick={() => setPrevious(!previous)}
title={i18n.t('kubernetes.podlogsviewerts.s76a2e637')}
>
<History className="h-3.5 w-3.5 mr-1" />
previous
</Button>
{/* sinceTime */}
<div className="flex items-center gap-1">
<Clock className="h-3.5 w-3.5 text-[var(--dg-text-muted)]" />
<Input
type="datetime-local"
value={sinceTime ? sinceTime.slice(0, 16) : ''}
onChange={(e) => {
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={i18n.t('kubernetes.podlogsviewerts.s127892fd')}
/>
</div>
<div className="flex-1" />
{/* 关键字过滤 */}
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-[var(--dg-text-muted)]" />
<Input
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder={i18n.t('kubernetes.podlogsviewerts.s7558f175')}
className="h-8 w-[160px] pl-7 text-xs bg-[var(--dg-bg-hover)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"
/>
</div>
{/* 下载 */}
<Button
variant="outline" size="sm"
className="h-8 text-xs border-[var(--dg-border)] text-[var(--dg-text-secondary)]"
onClick={handleDownload}
title={i18n.t('kubernetes.podlogsviewerts.s1a023dfb')}
disabled={filteredLines.length === 0}
>
<Download className="h-3.5 w-3.5 mr-1" />导出
</Button>
{/* 刷新(一次性) */}
<Button
variant="outline" size="sm"
className="h-8 text-xs border-[var(--dg-border)] text-[var(--dg-text-secondary)]"
onClick={fetchLogs}
disabled={loading}
title={i18n.t('kubernetes.podlogsviewerts.s930b4e01')}
>
<RefreshCw className={cn('h-3.5 w-3.5 mr-1', loading && 'animate-spin')} />刷新
</Button>
{/* 实时跟踪 / 暂停 / 继续 */}
{!streaming ? (
<Button
size="sm"
className="h-8 text-xs bg-emerald-600 hover:bg-emerald-700"
onClick={startStream}
disabled={loading}
title={i18n.t('kubernetes.podlogsviewerts.s0d519cc0')}
>
<Play className="h-3.5 w-3.5 mr-1" />实时
</Button>
) : (
<>
<Button
variant="outline" size="sm"
className={cn('h-8 text-xs', paused ? 'border-emerald-500 dg-text-green' : 'border-amber-500 dg-text-yellow')}
onClick={() => setPaused(!paused)}
title={paused ? i18n.t('common.continue') : i18n.t('kubernetes.job.suspend')}
>
{paused ? <Play className="h-3.5 w-3.5 mr-1" /> : <Pause className="h-3.5 w-3.5 mr-1" />}
{paused ? i18n.t('common.continue') : i18n.t('kubernetes.job.suspend')}
</Button>
<Button
variant="outline" size="sm"
className="h-8 text-xs border-red-500 dg-text-red"
onClick={stopStream}
title={i18n.t('agent.agentServiceStop')}
>
<Pause className="h-3.5 w-3.5 mr-1" />停止
</Button>
</>
)}
</div>
{/* 日志内容 */}
<div
ref={scrollRef}
onScroll={(e) => {
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-[var(--dg-text-secondary)]'
)}
style={{ height: fullscreen ? 'calc(100vh - 98px)' : '520px' }}
>
{lineCount === 0 && !loading && (
<div className="flex flex-col items-center justify-center h-full text-[var(--dg-text-muted)]">
<AlertCircle className="h-8 w-8 mb-2 opacity-50" />
<span> {i18n.t('pipeline.noLog')} </span>
</div>
)}
{loading && lineCount === 0 && (
<div className="flex items-center justify-center h-full text-[var(--dg-text-muted)]">
<Loader2 className="h-4 w-4 animate-spin mr-2" />加载中…
</div>
)}
{filteredLines.map((line) => (
<div key={line.id} className="whitespace-pre-wrap break-all hover:bg-white/5 px-1">
{renderLine(line)}
</div>
))}
</div>
{/* 底部状态条 */}
<div className="flex items-center justify-between px-4 py-1.5 border-t border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)] text-xs text-[var(--dg-text-muted)]">
<div className="flex items-center gap-3">
<span className="flex items-center gap-1"><Filter className="h-3 w-3" />缓冲 {lineCount}/{RING_BUFFER_MAX}</span>
{keyword && <span>匹配 {matchedCount}</span>}
{container && <span className="flex items-center gap-1"><Box className="h-3 w-3" />{container}</span>}
{previous && <span className="dg-text-yellow flex items-center gap-1"><History className="h-3 w-3" />previous</span>}
</div>
<button
className={cn('flex items-center gap-1', autoScroll ? 'dg-text-green' : 'text-[var(--dg-text-muted)]')}
onClick={() => {
setAutoScroll(!autoScroll);
if (!autoScroll && scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}}
>
<Maximize2 className="h-3 w-3" />{autoScroll ? i18n.t('kubernetes.podlogsviewerts.sd7eb1e98') : i18n.t('kubernetes.podlogsviewerts.s130db316')}
</button>
</div>
</DialogContent>
</Dialog>
);
}