fix: K8s 集群添加缺少 code 必填字段

- KubernetesClusterDialog: 新增 code 表单字段
- 名称变更自动生成 code(小写+连字符)
- handleSubmit 增加 code 校验 + toast 提示
- payload 增加 code 字段(后端 binding:required)
This commit is contained in:
attacker committed 2026-06-21 09:50:07 +00:00
1 parent f24fe43ac0
commit ce438c9f05
18 files changed
+3941 -144

No files matched your search

@@ -0,0 +1,550 @@
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 '@/services/api';
import { useToast } from '@/hooks/use-toast';
/**
* 增强版 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: '最近 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<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: any = 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: any) {
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: any) {
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 <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-slate-500'
)} />
<ScrollText className="h-4 w-4 text-blue-400 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 ? '已暂停' : '实时跟踪') : '已停止'} · {matchedCount}{keyword ? `/${lineCount}` : ''} 行
{lineCount >= RING_BUFFER_MAX && ' (缓冲已满)'}
</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 ? '退出全屏' : '全屏'}
>
{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="关闭"
>
<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="容器" />
</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="查看上一次(崩溃)容器的日志"
>
<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="起始时间(sinceTime)"
/>
</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="关键字过滤…"
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="导出当前日志"
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="重新拉取"
>
<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="实时跟踪(tail -f)"
>
<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 text-emerald-400' : 'border-amber-500 text-amber-400')}
onClick={() => setPaused(!paused)}
title={paused ? '继续' : '暂停'}
>
{paused ? <Play className="h-3.5 w-3.5 mr-1" /> : <Pause className="h-3.5 w-3.5 mr-1" />}
{paused ? '继续' : '暂停'}
</Button>
<Button
variant="outline" size="sm"
className="h-8 text-xs border-red-500 text-red-400"
onClick={stopStream}
title="停止"
>
<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-slate-300'
)}
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>暂无日志</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="text-amber-400 flex items-center gap-1"><History className="h-3 w-3" />previous</span>}
</div>
<button
className={cn('flex items-center gap-1', autoScroll ? 'text-emerald-400' : '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 ? '跟随底部' : '已暂停跟随'}
</button>
</div>
</DialogContent>
</Dialog>
);
}