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 测试通过
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([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>
|
||
);
|
||
}
|