Files
aiops/frontend/src/components/kubernetes/PodLogsViewer.tsx
T
attacker ad9acea1e5 refactor(i18n): U33 K8s 资源编辑器 + Pod 日志/Metrics 模板字面量中文清零
resource-visual-editors/ 5 文件 (ConfigMap/Secret/Service/Ingress/PvcEditor):
- 保存按钮文案 (保存配置/密钥/Service/Ingress/PVC)
- IngressEditor: (可选)/手工输入/帮助文本/常见示例/手动输入/继续手动输入
- PvcEditor: 访问模式描述 (单节点读写/多节点只读/多节点读写)

pod-logs-viewer/ 3 文件:
- ControlBar: 导出/刷新/实时/停止按钮
- LogContentArea: 加载中… 占位
- LogsHeader: 日志标题模板

PodLogsViewer: 6 处错误消息 + DialogTitle/Description
PodMetricsPage: subtitle 模板字面量 + 所有命名空间
PodExecTerminal: 连接错误消息

17 处 toast description 模板字面量中文→i18n.t() 插值:
- DeploymentEditAdvanced/Containers/Storage: 更新已提交
- DeploymentView: 回滚/伸缩
- DeploymentDetail: 伸缩/镜像更新
- DeploymentEditService: 资源删除
- ResourceVisualEditor/NamespaceResourcePage: 资源更新/YAML下载
- ClusterOverviewPage: 集群未找到
- DeploymentEditContainers: 容器名称/镜像为空

5 处 confirm description 中文→i18n.t() 插值:
- DeploymentView/EditService/Detail/WorkloadViewPage/MiddlewareMonitor/SiteMonitor/HealthProbeSection

5 处 subtitle 模板字面量中文→i18n.t():
- PodMetricsPage/CapacityPage/EventsPage/KubernetesNodes/ClusterOverviewPage

新增 k8s.rve 14键 + k8s.toast 18键 + k8s.podMetrics 4键 + k8s.podLogs 13键 = 49 翻译键
zh/en 7481→7524 完全对齐
tsc -b=0, npm build✓ (17.76s), ESLint errors=0/warnings=43/any=0, go build=0, 17/17 测试通过
2026-07-12 16:51:57 +08:00

332 lines
11 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 { 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([i18n.t('k8s.podLogs.fetchFailed', { msg })]);
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([`\x1b[33m${i18n.t('k8s.podLogs.streamEnded')}\x1b[0m`]);
} catch (e) {
const errName = (e as { name?: string })?.name;
if (errName === 'AbortError') {
appendLines([`\x1b[33m${i18n.t('k8s.podLogs.streamStopped')}\x1b[0m`]);
} else {
const msg = e instanceof Error ? e.message : String(e);
appendLines([`\x1b[31m${i18n.t('k8s.podLogs.streamInterrupted', { 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">{i18n.t('k8s.podLogs.logTitle', { name: podName })}</DialogTitle>
<DialogDescription className="sr-only">{i18n.t('k8s.podLogs.logDesc')}</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>
);
}