问题: i18n 批量转换 (commit55f216c) 生成了 813 个 hash 键引用但翻译从未写入 JSON, 导致用户在受影响模块看到 hash 键路径(如 monitor.quickquerybarts.s64559524)而非实际文本. 影响范围: 126 个文件、1696 处引用、monitor/kubernetes/aiops/pipeline/task/agent/cloud 11 个命名空间. 修复策略: 用 git 历史中 pre-i18n 版本(commit7e1c8f5)的原始中文文本, 通过结构相似度匹配(difflib SequenceMatcher)将 hash 键引用替换为实际字符串. 对于组件拆分后产生的新文件,映射到其原始源文件查找中文文本. 修复明细: - monitor 命名空间: 335 键修复(站点拨测/日志中心/中间件监控/告警规则/SNMP/主机监控) - kubernetes 命名空间: 195 键修复(资源编辑器/Deployment编辑器/事件/命名空间/Pod日志) - aiops 命名空间: 70 键修复(容量预测/成本预测/告警分析) - pipeline/task/agent/cloud/common 命名空间: 其余键修复 验证: tsc -b=0, npm build✓ (16.87s), ESLint=0/0/0, go build=0, 17/17 测试通过, Docker healthy
332 lines
11 KiB
TypeScript
332 lines
11 KiB
TypeScript
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogTitle,
|
||
DialogDescription,
|
||
} from '@/components/ui/dialog';
|
||
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';
|
||
import {
|
||
type PodLogsViewerProps,
|
||
type LogLine,
|
||
RING_BUFFER_MAX,
|
||
} from './pod-logs-viewer/types'; // types.tsx — 含 highlightKeyword JSX 纯函数
|
||
import { LogsHeader } from './pod-logs-viewer/LogsHeader';
|
||
import { ControlBar } from './pod-logs-viewer/ControlBar';
|
||
import { LogContentArea } from './pod-logs-viewer/LogContentArea';
|
||
import { BottomStatusBar } from './pod-logs-viewer/BottomStatusBar';
|
||
|
||
/**
|
||
* 增强版 Pod 日志查看器 — 增强 DeploymentDetail 的日志体验
|
||
*
|
||
* 功能:容器选择 / 实时跟踪(fetch stream) / 行数限制 / previous 日志 /
|
||
* 时间范围 / 关键字过滤(高亮)/ 下载 / 暂停-继续 / 10000 行环形缓冲
|
||
*/
|
||
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) {
|
||
const msg = e instanceof Error ? e.message : String(e);
|
||
appendLines([`\x1b[31m[获取日志失败: ${msg}]\x1b[0m`]);
|
||
toast({ title: i18n.t('k8s.podLogs.loadFailed'), description: msg, 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;
|
||
buffer += decoder.decode(value, { stream: true });
|
||
if (!pausedRef.current) {
|
||
flush();
|
||
} else {
|
||
buffer = '';
|
||
}
|
||
}
|
||
flush();
|
||
appendLines([i18n.t('\\x1b[33m— 流式结束 —\\x1b[0m')]);
|
||
} catch (e) {
|
||
const errName = (e as { name?: string })?.name;
|
||
if (errName === 'AbortError') {
|
||
appendLines([i18n.t('\\x1b[33m— 已停止 —\\x1b[0m')]);
|
||
} else {
|
||
const msg = e instanceof Error ? e.message : String(e);
|
||
appendLines([`\x1b[31m[流式连接中断: ${msg}]\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 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>
|
||
|
||
<LogsHeader
|
||
podName={podName}
|
||
streaming={streaming}
|
||
paused={paused}
|
||
loading={loading}
|
||
fullscreen={fullscreen}
|
||
lineCount={lineCount}
|
||
matchedCount={matchedCount}
|
||
keyword={keyword}
|
||
onToggleFullscreen={() => setFullscreen(!fullscreen)}
|
||
onClose={handleClose}
|
||
/>
|
||
|
||
<ControlBar
|
||
containers={containers}
|
||
container={container}
|
||
onContainerChange={setContainer}
|
||
tailLines={tailLines}
|
||
onTailLinesChange={setTailLines}
|
||
previous={previous}
|
||
onPreviousToggle={() => setPrevious(!previous)}
|
||
sinceTime={sinceTime}
|
||
onSinceTimeChange={setSinceTime}
|
||
keyword={keyword}
|
||
onKeywordChange={setKeyword}
|
||
streaming={streaming}
|
||
paused={paused}
|
||
loading={loading}
|
||
filteredLineCount={filteredLines.length}
|
||
onDownload={handleDownload}
|
||
onRefresh={fetchLogs}
|
||
onStartStream={startStream}
|
||
onStopStream={stopStream}
|
||
onPauseToggle={() => setPaused(!paused)}
|
||
/>
|
||
|
||
<LogContentArea
|
||
filteredLines={filteredLines}
|
||
lineCount={lineCount}
|
||
loading={loading}
|
||
keyword={keyword}
|
||
fullscreen={fullscreen}
|
||
scrollRef={scrollRef}
|
||
autoScrollRef={autoScrollRef}
|
||
onScrollAt={(atBottom) => setAutoScroll(atBottom)}
|
||
/>
|
||
|
||
<BottomStatusBar
|
||
lineCount={lineCount}
|
||
matchedCount={matchedCount}
|
||
keyword={keyword}
|
||
container={container}
|
||
previous={previous}
|
||
autoScroll={autoScroll}
|
||
onToggleAutoScroll={() => {
|
||
setAutoScroll(!autoScroll);
|
||
if (!autoScroll && scrollRef.current) {
|
||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||
}
|
||
}}
|
||
/>
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
}
|