fix: K8s 集群添加缺少 code 必填字段
- KubernetesClusterDialog: 新增 code 表单字段 - 名称变更自动生成 code(小写+连字符) - handleSubmit 增加 code 校验 + toast 提示 - payload 增加 code 字段(后端 binding:required)
This commit is contained in:
1 parent
f24fe43ac0
commit
ce438c9f05
18 files changed
+3941
-144
No files matched your search
@@ -9,14 +9,24 @@ import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
ArrowLeft, Minus, Plus, RotateCw, Upload, FileText, Terminal,
|
||||
Gauge, Clock, Play, Pause, Trash2, RefreshCw, Loader2,
|
||||
CheckCircle, AlertTriangle, XCircle, ExternalLink,
|
||||
CheckCircle, AlertTriangle, XCircle, ExternalLink, FolderOpen,
|
||||
} from 'lucide-react';
|
||||
import { kubernetesApi } from '@/services/api';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { useUserStore } from '@/store';
|
||||
import { PodExecTerminal } from './PodExecTerminal';
|
||||
import { PodLogsViewer } from './PodLogsViewer';
|
||||
import { PodFileBrowser } from './PodFileBrowser';
|
||||
|
||||
interface DeploymentDetailProps {
|
||||
clusterCode: string;
|
||||
@@ -44,6 +54,25 @@ export function DeploymentDetail({ clusterCode, namespace, deploymentName, onBac
|
||||
const [showLogs, setShowLogs] = useState(false);
|
||||
const [logTail, setLogTail] = useState(200);
|
||||
|
||||
// 🆕 Pod exec / logs / file browser (Logan <admin@attacker.club>)
|
||||
const [execPod, setExecPod] = useState<{ name: string; containers: string[] } | null>(null);
|
||||
const [execOpen, setExecOpen] = useState(false);
|
||||
const [logsViewerPod, setLogsViewerPod] = useState<{ name: string; containers: string[] } | null>(null);
|
||||
const [logsViewerOpen, setLogsViewerOpen] = useState(false);
|
||||
const [fileBrowserPod, setFileBrowserPod] = useState<{ name: string; containers: string[] } | null>(null);
|
||||
const [fileBrowserOpen, setFileBrowserOpen] = useState(false);
|
||||
// Logs tab 内的 Pod 选择器(增强版查看器入口)
|
||||
const [logsTabPod, setLogsTabPod] = useState<string>('');
|
||||
|
||||
// 从 Pod 对象提取容器名列表(兼容多种字段)
|
||||
const extractContainers = (pod: any): string[] => {
|
||||
const c = pod?.containers;
|
||||
if (Array.isArray(c)) return c.map((x: any) => typeof x === 'string' ? x : (x?.name || x?.Name)).filter(Boolean);
|
||||
if (Array.isArray(pod?.container_statuses)) return pod.container_statuses.map((x: any) => x?.name).filter(Boolean);
|
||||
if (Array.isArray(pod?.containerNames)) return pod.containerNames.filter(Boolean);
|
||||
return [];
|
||||
};
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -359,13 +388,29 @@ export function DeploymentDetail({ clusterCode, namespace, deploymentName, onBac
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-blue-400"
|
||||
title="查看日志" onClick={() => handleLoadLogs(pod.name)}>
|
||||
title="查看日志"
|
||||
onClick={() => {
|
||||
setLogsViewerPod({ name: pod.name, containers: extractContainers(pod) });
|
||||
setLogsViewerOpen(true);
|
||||
}}>
|
||||
<FileText className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-emerald-400"
|
||||
title="终端" onClick={() => toast({ title: '终端功能开发中', description: '需要 SPDY/WebSocket 支持' })}>
|
||||
title="exec 终端"
|
||||
onClick={() => {
|
||||
setExecPod({ name: pod.name, containers: extractContainers(pod) });
|
||||
setExecOpen(true);
|
||||
}}>
|
||||
<Terminal className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-amber-400"
|
||||
title="文件浏览器"
|
||||
onClick={() => {
|
||||
setFileBrowserPod({ name: pod.name, containers: extractContainers(pod) });
|
||||
setFileBrowserOpen(true);
|
||||
}}>
|
||||
<FolderOpen className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -384,6 +429,19 @@ export function DeploymentDetail({ clusterCode, namespace, deploymentName, onBac
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm text-slate-400">Pod 日志</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
{showLogs && logPod && (
|
||||
<Button
|
||||
variant="outline" size="sm"
|
||||
className="border-blue-700 text-blue-400 text-xs h-7"
|
||||
onClick={() => {
|
||||
const pod = pods.find((p: any) => p.name === logPod);
|
||||
setLogsViewerPod({ name: logPod, containers: pod ? extractContainers(pod) : [] });
|
||||
setLogsViewerOpen(true);
|
||||
}}
|
||||
>
|
||||
<ExternalLink className="h-3 w-3 mr-1" />增强版日志
|
||||
</Button>
|
||||
)}
|
||||
<span className="text-xs text-slate-500">最近</span>
|
||||
<Input
|
||||
type="number" value={logTail} onChange={(e) => setLogTail(Number(e.target.value))}
|
||||
@@ -394,41 +452,45 @@ export function DeploymentDetail({ clusterCode, namespace, deploymentName, onBac
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!showLogs ? (
|
||||
{pods.length === 0 ? (
|
||||
<div className="text-center py-12 text-slate-500">
|
||||
<FileText className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<p>点击 Pod 列表中的「日志」按钮查看</p>
|
||||
<p>暂无 Pod</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Button variant="outline" size="sm" className="border-slate-700 text-slate-300 text-xs h-7"
|
||||
onClick={async () => {
|
||||
setLogs('');
|
||||
// Stream mode
|
||||
const token = useUserStore.getState().token || '';
|
||||
const url = `${window.location.protocol}//${window.location.host}/api/v1/kubernetes/${clusterCode}/namespace/${namespace}/pods/${logPod}/logs?tail=${logTail}&stream=true`;
|
||||
try {
|
||||
const resp = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
|
||||
const reader = resp.body?.getReader();
|
||||
if (!reader) return;
|
||||
const decoder = new TextDecoder();
|
||||
let streamLogs = '';
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
streamLogs += decoder.decode(value, { stream: true });
|
||||
setLogs(streamLogs);
|
||||
}
|
||||
} catch (e) {
|
||||
setLogs('流式连接中断');
|
||||
}
|
||||
}}>
|
||||
<RefreshCw className="h-3 w-3 mr-1" />实时流
|
||||
</Button>
|
||||
<span className="text-xs text-slate-500">{logPod}</span>
|
||||
</div>
|
||||
<pre className="bg-slate-950 rounded-lg p-4 text-xs text-slate-300 font-mono overflow-auto max-h-[500px] whitespace-pre-wrap">
|
||||
<div className="flex items-center gap-3 py-10 justify-center flex-wrap">
|
||||
<FileText className="h-5 w-5 text-blue-400" />
|
||||
<span className="text-sm text-slate-400">选择 Pod 打开增强版日志查看器</span>
|
||||
<Select value={logsTabPod} onValueChange={setLogsTabPod}>
|
||||
<SelectTrigger className="w-[280px] h-8 bg-slate-800 border-slate-700 text-slate-200 text-xs">
|
||||
<SelectValue placeholder="选择 Pod…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{pods.map((p: any) => (
|
||||
<SelectItem key={p.name} value={p.name}>{p.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-blue-600 hover:bg-blue-700 h-8"
|
||||
disabled={!logsTabPod}
|
||||
onClick={() => {
|
||||
const pod = pods.find((p: any) => p.name === logsTabPod);
|
||||
setLogsViewerPod({ name: logsTabPod, containers: pod ? extractContainers(pod) : [] });
|
||||
setLogsViewerOpen(true);
|
||||
}}
|
||||
>
|
||||
<ExternalLink className="h-3 w-3 mr-1" />打开日志
|
||||
</Button>
|
||||
<span className="text-xs text-slate-600 block w-full text-center mt-2">
|
||||
支持实时跟踪、多容器切换、previous 日志、关键字过滤、导出
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{showLogs && logPod && (
|
||||
<div className="mt-2">
|
||||
<pre className="bg-slate-950 rounded-lg p-4 text-xs text-slate-300 font-mono overflow-auto max-h-[300px] whitespace-pre-wrap">
|
||||
{logs || '无日志内容'}
|
||||
</pre>
|
||||
</div>
|
||||
@@ -499,6 +561,52 @@ export function DeploymentDetail({ clusterCode, namespace, deploymentName, onBac
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* 🆕 Pod exec 终端 */}
|
||||
{execPod && (
|
||||
<PodExecTerminal
|
||||
open={execOpen}
|
||||
onOpenChange={(o) => {
|
||||
setExecOpen(o);
|
||||
if (!o) setExecPod(null);
|
||||
}}
|
||||
clusterId={clusterCode}
|
||||
clusterCode={clusterCode}
|
||||
namespace={namespace}
|
||||
podName={execPod.name}
|
||||
containers={execPod.containers}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 🆕 增强版日志查看器 */}
|
||||
{logsViewerPod && (
|
||||
<PodLogsViewer
|
||||
open={logsViewerOpen}
|
||||
onOpenChange={(o) => {
|
||||
setLogsViewerOpen(o);
|
||||
if (!o) setLogsViewerPod(null);
|
||||
}}
|
||||
clusterCode={clusterCode}
|
||||
namespace={namespace}
|
||||
podName={logsViewerPod.name}
|
||||
containers={logsViewerPod.containers}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 🆕 容器文件浏览器 */}
|
||||
{fileBrowserPod && (
|
||||
<PodFileBrowser
|
||||
open={fileBrowserOpen}
|
||||
onOpenChange={(o) => {
|
||||
setFileBrowserOpen(o);
|
||||
if (!o) setFileBrowserPod(null);
|
||||
}}
|
||||
clusterCode={clusterCode}
|
||||
namespace={namespace}
|
||||
podName={fileBrowserPod.name}
|
||||
containers={fileBrowserPod.containers}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,45 @@
|
||||
// DeploymentDetailPage.tsx — 路由包装:解析 cluster/namespace/mode/name
|
||||
// 支持 Kuboard 风格双模式路由:
|
||||
// view: /kubernetes/:c/namespace/:n/workload/view/Deployment/:name
|
||||
// edit: /kubernetes/:c/namespace/:n/workload/edit/Deployment/:name
|
||||
// 兼容旧路由: /kubernetes/:c/namespace/:n/deployments/:name(默认 view)
|
||||
// 作者: Logan <admin@attacker.club>
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { DeploymentDetail } from './DeploymentDetail';
|
||||
import { DeploymentWorkload } from './DeploymentWorkload';
|
||||
|
||||
export default function DeploymentDetailPage() {
|
||||
const { clusterCode, namespace, name } = useParams<{
|
||||
clusterCode: string; namespace: string; name: string;
|
||||
// 兼容两种路由参数命名:旧 :name 与新 :name(路径段不同)
|
||||
const params = useParams<{
|
||||
clusterCode: string;
|
||||
namespace: string;
|
||||
name: string;
|
||||
mode?: string; // 'view' | 'edit'(新路由)
|
||||
workloadKind?: string; // 'Deployment'(新路由,预留扩展)
|
||||
}>();
|
||||
|
||||
const navigate = useNavigate();
|
||||
const clusterCode = params.clusterCode || '';
|
||||
const namespace = params.namespace || '';
|
||||
const deploymentName = params.name || '';
|
||||
const currentMode: 'view' | 'edit' = params.mode === 'edit' ? 'edit' : 'view';
|
||||
|
||||
// 模式切换:在 view/edit 路由间跳转
|
||||
const handleModeChange = (m: 'view' | 'edit') => {
|
||||
if (m === currentMode) return;
|
||||
navigate(
|
||||
`/kubernetes/${clusterCode}/namespace/${namespace}/workload/${m}/Deployment/${deploymentName}`,
|
||||
{ replace: true }
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<DeploymentDetail
|
||||
clusterCode={clusterCode || ''}
|
||||
namespace={namespace || ''}
|
||||
deploymentName={name || ''}
|
||||
<DeploymentWorkload
|
||||
clusterCode={clusterCode}
|
||||
namespace={namespace}
|
||||
deploymentName={deploymentName}
|
||||
mode={currentMode}
|
||||
onBack={() => navigate(-1)}
|
||||
onModeChange={handleModeChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
// DeploymentEdit.tsx — 表单式编辑器(环境变量/资源/探针/挂载)
|
||||
// 作者: Logan <admin@attacker.club>
|
||||
// 说明: 前端 UI 先行;保存通过 PUT application/yaml(清理后的 manifest)落地。
|
||||
import { useState, useMemo } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Plus, Trash2, Save, Loader2, Container, Cpu, MemoryStick, Heart, FolderOpen } from 'lucide-react';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { kubernetesApi } from '@/services/api';
|
||||
import { cleanYAML } from './k8s-yaml-utils';
|
||||
|
||||
interface ContainerSpec {
|
||||
name: string;
|
||||
image: string;
|
||||
resources?: {
|
||||
requests?: { cpu?: string; memory?: string };
|
||||
limits?: { cpu?: string; memory?: string };
|
||||
};
|
||||
env?: Array<{ name: string; value?: string; valueFrom?: any }>;
|
||||
livenessProbe?: any;
|
||||
readinessProbe?: any;
|
||||
startupProbe?: any;
|
||||
volumeMounts?: Array<{ name: string; mountPath: string; readOnly?: boolean }>;
|
||||
ports?: Array<{ containerPort: number; name?: string; protocol?: string }>;
|
||||
}
|
||||
|
||||
interface DeploymentEditProps {
|
||||
clusterCode: string;
|
||||
namespace: string;
|
||||
deploymentName: string;
|
||||
/** 从后端拿到的 manifest 对象(spec.template.spec.containers 为编辑目标) */
|
||||
manifest: any | null;
|
||||
onSaved: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
/** 把扁平的 deployment 字段或后端 manifest 归一化为可编辑的容器 spec 数组 */
|
||||
function toContainerSpecs(deployment: any): ContainerSpec[] {
|
||||
// 优先用 manifest 里的结构化容器
|
||||
const fromManifest = deployment?.spec?.template?.spec?.containers;
|
||||
if (Array.isArray(fromManifest) && fromManifest.length > 0) {
|
||||
return fromManifest.map((c: any) => ({
|
||||
name: c.name,
|
||||
image: c.image,
|
||||
resources: c.resources || {},
|
||||
env: Array.isArray(c.env) ? c.env : [],
|
||||
livenessProbe: c.livenessProbe || null,
|
||||
readinessProbe: c.readinessProbe || null,
|
||||
startupProbe: c.startupProbe || null,
|
||||
volumeMounts: Array.isArray(c.volumeMounts) ? c.volumeMounts : [],
|
||||
ports: Array.isArray(c.ports) ? c.ports : [],
|
||||
}));
|
||||
}
|
||||
// 降级:从扁平字段构造单个容器
|
||||
return [{
|
||||
name: deployment?.container_name || deployment?.name || 'main',
|
||||
image: deployment?.image || '',
|
||||
resources: {
|
||||
requests: { cpu: deployment?.cpu_request, memory: deployment?.memory_request },
|
||||
limits: { cpu: deployment?.cpu_limit, memory: deployment?.memory_limit },
|
||||
},
|
||||
env: [],
|
||||
volumeMounts: [],
|
||||
ports: [],
|
||||
}];
|
||||
}
|
||||
|
||||
export function DeploymentEdit({ clusterCode, namespace, deploymentName, manifest, onSaved, onCancel }: DeploymentEditProps) {
|
||||
const { toast } = useToast();
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [rawManifest, setRawManifest] = useState<any>(manifest || {});
|
||||
const [containers, setContainers] = useState<ContainerSpec[]>(() => toContainerSpecs(manifest));
|
||||
|
||||
// 副本数 & 镜像策略顶层字段
|
||||
const replicas = rawManifest?.spec?.replicas ?? manifest?.replicas ?? 1;
|
||||
const setReplicas = (n: number) =>
|
||||
setRawManifest((m: any) => ({ ...m, spec: { ...(m.spec || {}), replicas: n } }));
|
||||
|
||||
const updateContainer = (idx: number, patch: Partial<ContainerSpec>) => {
|
||||
setContainers(prev => prev.map((c, i) => (i === idx ? { ...c, ...patch } : c)));
|
||||
};
|
||||
|
||||
// ============ Env ============
|
||||
const addEnv = (ci: number) => {
|
||||
const env = [...(containers[ci].env || []), { name: '', value: '' }];
|
||||
updateContainer(ci, { env });
|
||||
};
|
||||
const updateEnv = (ci: number, ei: number, patch: Partial<{ name: string; value: string }>) => {
|
||||
const env = (containers[ci].env || []).map((e, i) => (i === ei ? { ...e, ...patch } : e));
|
||||
updateContainer(ci, { env });
|
||||
};
|
||||
const removeEnv = (ci: number, ei: number) => {
|
||||
updateContainer(ci, { env: (containers[ci].env || []).filter((_, i) => i !== ei) });
|
||||
};
|
||||
|
||||
// ============ Resources ============
|
||||
const updateResource = (ci: number, kind: 'requests' | 'limits', key: 'cpu' | 'memory', val: string) => {
|
||||
const c = containers[ci];
|
||||
const resources = { ...(c.resources || {}) };
|
||||
resources[kind] = { ...(resources[kind] || {}), [key]: val };
|
||||
updateContainer(ci, { resources });
|
||||
};
|
||||
|
||||
// ============ Probe(简化:仅编辑 httpGet.path/port + initialDelaySeconds + periodSeconds) ============
|
||||
const updateProbe = (ci: number, probeType: 'livenessProbe' | 'readinessProbe' | 'startupProbe', patch: any) => {
|
||||
const c = containers[ci];
|
||||
const existing = c[probeType] || {};
|
||||
updateContainer(ci, { [probeType]: { ...existing, ...patch } } as any);
|
||||
};
|
||||
const toggleProbe = (ci: number, probeType: 'livenessProbe' | 'readinessProbe' | 'startupProbe') => {
|
||||
const c = containers[ci];
|
||||
if (c[probeType]) {
|
||||
updateContainer(ci, { [probeType]: null } as any);
|
||||
} else {
|
||||
updateContainer(ci, { [probeType]: { httpGet: { path: '/', port: 8080 }, initialDelaySeconds: 10, periodSeconds: 10 } } as any);
|
||||
}
|
||||
};
|
||||
|
||||
// ============ VolumeMount ============
|
||||
const addMount = (ci: number) => {
|
||||
const mounts = [...(containers[ci].volumeMounts || []), { name: '', mountPath: '' }];
|
||||
updateContainer(ci, { volumeMounts: mounts });
|
||||
};
|
||||
const updateMount = (ci: number, mi: number, patch: Partial<{ name: string; mountPath: string }>) => {
|
||||
const mounts = (containers[ci].volumeMounts || []).map((m, i) => (i === mi ? { ...m, ...patch } : m));
|
||||
updateContainer(ci, { volumeMounts: mounts });
|
||||
};
|
||||
const removeMount = (ci: number, mi: number) => {
|
||||
updateContainer(ci, { volumeMounts: (containers[ci].volumeMounts || []).filter((_, i) => i !== mi) });
|
||||
};
|
||||
|
||||
// ============ 序列化为 YAML 并保存 ============
|
||||
const buildManifest = useMemo(() => {
|
||||
const base = JSON.parse(JSON.stringify(rawManifest && Object.keys(rawManifest).length ? rawManifest : manifest || {}));
|
||||
base.spec = base.spec || {};
|
||||
base.spec.replicas = replicas;
|
||||
base.spec.template = base.spec.template || {};
|
||||
base.spec.template.spec = base.spec.template.spec || {};
|
||||
base.spec.template.spec.containers = containers.map(c => {
|
||||
const out: any = { name: c.name, image: c.image };
|
||||
if (c.resources && Object.keys(c.resources).length) out.resources = c.resources;
|
||||
if (c.env && c.env.length) out.env = c.env.filter(e => e.name);
|
||||
if (c.volumeMounts && c.volumeMounts.length) out.volumeMounts = c.volumeMounts;
|
||||
if (c.ports && c.ports.length) out.ports = c.ports;
|
||||
if (c.livenessProbe) out.livenessProbe = c.livenessProbe;
|
||||
if (c.readinessProbe) out.readinessProbe = c.readinessProbe;
|
||||
if (c.startupProbe) out.startupProbe = c.startupProbe;
|
||||
return out;
|
||||
});
|
||||
return base;
|
||||
}, [rawManifest, manifest, replicas, containers]);
|
||||
|
||||
// 简易 YAML 序列化(不引入 js-yaml,仅处理扁平/二层结构用于编辑落地)
|
||||
// 复杂嵌套交给后端 application/yaml 端点容忍 JSON-ish YAML;大多数 K8s API server 接受 JSON 文档作为 YAML 提交。
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const json = JSON.stringify(buildManifest, null, 2);
|
||||
// 后端 yaml 端点一般同时接受 JSON 文档(YAML 是 JSON 超集)
|
||||
await kubernetesApi.updateResourceYaml(clusterCode, namespace, 'deployments', deploymentName, json);
|
||||
toast({ title: '编辑已保存', description: `${deploymentName} 更新已提交` });
|
||||
onSaved();
|
||||
} catch (e: any) {
|
||||
toast({ title: '保存失败', description: e?.message || String(e), variant: 'destructive' });
|
||||
} finally { setSaving(false); }
|
||||
};
|
||||
|
||||
if (!manifest) {
|
||||
return (
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="p-8 text-center text-slate-500">
|
||||
未获取到 manifest 结构化数据,请使用「编辑 YAML」模式直接修改原文。
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 顶层:副本数 */}
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardHeader className="pb-3"><CardTitle className="text-sm text-slate-200">副本与镜像策略</CardTitle></CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<div className="flex items-center gap-4">
|
||||
<div>
|
||||
<Label className="text-slate-400 text-xs">副本数 (replicas)</Label>
|
||||
<Input type="number" min={0} value={replicas}
|
||||
onChange={e => setReplicas(Number(e.target.value))}
|
||||
className="w-32 mt-1 bg-slate-800 border-slate-700 text-slate-200" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 每个容器一组编辑卡片 */}
|
||||
{containers.map((c, ci) => (
|
||||
<Card key={ci} className="bg-slate-900 border-slate-800">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm text-slate-200 flex items-center gap-2">
|
||||
<Container className="h-4 w-4 text-blue-400" />
|
||||
容器: <span className="font-mono text-blue-300">{c.name}</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* 镜像 */}
|
||||
<div>
|
||||
<Label className="text-slate-400 text-xs">镜像 (image)</Label>
|
||||
<Input value={c.image} onChange={e => updateContainer(ci, { image: e.target.value })}
|
||||
placeholder="harbor.example.com/app:v1.0"
|
||||
className="mt-1 bg-slate-800 border-slate-700 text-slate-200 font-mono text-sm" />
|
||||
</div>
|
||||
|
||||
{/* 资源 requests/limits */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Cpu className="h-4 w-4 text-amber-400" />
|
||||
<span className="text-xs text-slate-300 font-semibold">资源 requests / limits</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||
<div>
|
||||
<Label className="text-[11px] text-slate-500">CPU 请求</Label>
|
||||
<Input value={c.resources?.requests?.cpu || ''} placeholder="100m"
|
||||
onChange={e => updateResource(ci, 'requests', 'cpu', e.target.value)}
|
||||
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-8" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[11px] text-slate-500">CPU 上限</Label>
|
||||
<Input value={c.resources?.limits?.cpu || ''} placeholder="500m"
|
||||
onChange={e => updateResource(ci, 'limits', 'cpu', e.target.value)}
|
||||
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-8" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[11px] text-slate-500">内存请求</Label>
|
||||
<Input value={c.resources?.requests?.memory || ''} placeholder="128Mi"
|
||||
onChange={e => updateResource(ci, 'requests', 'memory', e.target.value)}
|
||||
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-8" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[11px] text-slate-500">内存上限</Label>
|
||||
<Input value={c.resources?.limits?.memory || ''} placeholder="512Mi"
|
||||
onChange={e => updateResource(ci, 'limits', 'memory', e.target.value)}
|
||||
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-8" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 探针 */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Heart className="h-4 w-4 text-rose-400" />
|
||||
<span className="text-xs text-slate-300 font-semibold">健康探针</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{(['livenessProbe', 'readinessProbe', 'startupProbe'] as const).map(pt => {
|
||||
const labels = { livenessProbe: '存活探针', readinessProbe: '就绪探针', startupProbe: '启动探针' };
|
||||
const probe = (c as any)[pt];
|
||||
const enabled = !!probe;
|
||||
return (
|
||||
<div key={pt} className="border border-slate-800 rounded-md p-2 bg-slate-950/30">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-slate-300">{labels[pt]}</span>
|
||||
<Button size="sm" variant={enabled ? 'destructive' : 'outline'}
|
||||
className="h-6 text-[11px]"
|
||||
onClick={() => toggleProbe(ci, pt)}>
|
||||
{enabled ? '移除' : '启用'}
|
||||
</Button>
|
||||
</div>
|
||||
{enabled && probe?.httpGet && (
|
||||
<div className="grid grid-cols-3 gap-2 mt-2">
|
||||
<div>
|
||||
<Label className="text-[10px] text-slate-500">路径</Label>
|
||||
<Input value={probe.httpGet.path || ''} placeholder="/healthz"
|
||||
onChange={e => updateProbe(ci, pt, { httpGet: { ...probe.httpGet, path: e.target.value } })}
|
||||
className="bg-slate-800 border-slate-700 text-slate-200 text-xs h-7 font-mono" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[10px] text-slate-500">端口</Label>
|
||||
<Input value={probe.httpGet.port ?? ''} placeholder="8080"
|
||||
onChange={e => updateProbe(ci, pt, { httpGet: { ...probe.httpGet, port: Number(e.target.value) } })}
|
||||
className="bg-slate-800 border-slate-700 text-slate-200 text-xs h-7 font-mono" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[10px] text-slate-500">延迟(s)</Label>
|
||||
<Input type="number" value={probe.initialDelaySeconds ?? ''} placeholder="10"
|
||||
onChange={e => updateProbe(ci, pt, { initialDelaySeconds: Number(e.target.value) })}
|
||||
className="bg-slate-800 border-slate-700 text-slate-200 text-xs h-7 font-mono" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 环境变量 */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-slate-300 font-semibold">环境变量</span>
|
||||
<Badge variant="outline" className="border-slate-700 text-slate-500 text-[10px]">{c.env?.length || 0}</Badge>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" className="h-7 border-slate-700 text-slate-300 text-xs" onClick={() => addEnv(ci)}>
|
||||
<Plus className="h-3 w-3 mr-1" />新增
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{(c.env || []).map((env, ei) => (
|
||||
<div key={ei} className="grid grid-cols-[1fr_1.4fr_auto] gap-2">
|
||||
<Input value={env.name} placeholder="KEY"
|
||||
onChange={e => updateEnv(ci, ei, { name: e.target.value })}
|
||||
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-8" />
|
||||
<Input value={env.value || ''} placeholder="value"
|
||||
onChange={e => updateEnv(ci, ei, { value: e.target.value })}
|
||||
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-8" />
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-red-400 hover:text-red-300"
|
||||
onClick={() => removeEnv(ci, ei)}><Trash2 className="h-3.5 w-3.5" /></Button>
|
||||
</div>
|
||||
))}
|
||||
{(!c.env || c.env.length === 0) && <p className="text-[11px] text-slate-600">无环境变量</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 卷挂载 */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<FolderOpen className="h-3.5 w-3.5 text-emerald-400" />
|
||||
<span className="text-xs text-slate-300 font-semibold">Volume 挂载</span>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" className="h-7 border-slate-700 text-slate-300 text-xs" onClick={() => addMount(ci)}>
|
||||
<Plus className="h-3 w-3 mr-1" />新增
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{(c.volumeMounts || []).map((m, mi) => (
|
||||
<div key={mi} className="grid grid-cols-[1fr_1.4fr_auto] gap-2">
|
||||
<Input value={m.name} placeholder="volume 名称"
|
||||
onChange={e => updateMount(ci, mi, { name: e.target.value })}
|
||||
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-8" />
|
||||
<Input value={m.mountPath} placeholder="/path/to/mount"
|
||||
onChange={e => updateMount(ci, mi, { mountPath: e.target.value })}
|
||||
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-8" />
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-red-400 hover:text-red-300"
|
||||
onClick={() => removeMount(ci, mi)}><Trash2 className="h-3.5 w-3.5" /></Button>
|
||||
</div>
|
||||
))}
|
||||
{(!c.volumeMounts || c.volumeMounts.length === 0) && <p className="text-[11px] text-slate-600">无卷挂载</p>}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{/* 保存栏 */}
|
||||
<div className="sticky bottom-0 bg-slate-950/80 backdrop-blur border-t border-slate-800 p-3 flex items-center justify-between">
|
||||
<p className="text-[11px] text-slate-500">
|
||||
保存将通过 <code className="text-slate-400">PUT application/yaml</code> 提交(自动剥离 status/managedFields 等只读字段)
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" className="border-slate-700 text-slate-400" onClick={onCancel}>取消</Button>
|
||||
<Button className="bg-blue-600 hover:bg-blue-700" disabled={saving} onClick={save}>
|
||||
{saving ? <Loader2 className="h-4 w-4 animate-spin mr-1" /> : <Save className="h-4 w-4 mr-1" />}
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
// DeploymentView.tsx — 增强只读详情看板(对标 Kuboard)
|
||||
// 作者: Logan <admin@attacker.club>
|
||||
import { useState, Fragment } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
Clock, Cpu, MemoryStick, HardDrive, Network, Activity,
|
||||
RotateCcw, FileCode, Server, Container, History, ChevronDown, ChevronRight,
|
||||
} from 'lucide-react';
|
||||
import { kubernetesApi } from '@/services/api';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import {
|
||||
formatK8sTime, formatDuration, elapsedSeconds, statusColor, STATUS_COLORS,
|
||||
} from './k8s-yaml-utils';
|
||||
|
||||
interface PodItem { name: string; namespace?: string; status?: string; node_name?: string; node?: string; ip?: string; pod_ip?: string; restart_count?: number; age?: string; created_at?: string; start_time?: string; containers?: any[]; image?: string; images?: string[]; }
|
||||
|
||||
interface ReplicaSetItem { name: string; namespace?: string; replicas?: number; ready_replicas?: number; available_replicas?: number; image?: string; revision?: number; status?: string; created_at?: string; controlled?: boolean; owner_deployment?: string; }
|
||||
|
||||
interface DeploymentViewProps {
|
||||
clusterCode: string;
|
||||
namespace: string;
|
||||
deployment: any;
|
||||
pods: PodItem[];
|
||||
replicaSets: ReplicaSetItem[];
|
||||
revisions: any[];
|
||||
onRefresh: () => void;
|
||||
onShowPodLogs: (pod: PodItem) => void;
|
||||
onShowPodTerminal: (pod: PodItem) => void;
|
||||
onShowPodYaml: (pod: PodItem) => void;
|
||||
}
|
||||
|
||||
/** 计算 Pod 从调度到就绪的各阶段耗时(基于条件时间线,兼容扁平字段) */
|
||||
function getPodTimeline(pod: PodItem) {
|
||||
const cond: any[] = (pod as any).conditions || (pod as any).pod_conditions || [];
|
||||
const find = (t: string) => cond.find((c: any) => (c.type || c.Type) === t);
|
||||
const scheduled = find('PodScheduled') || {};
|
||||
const initialized = find('Initialized') || {};
|
||||
const ready = find('Ready') || {};
|
||||
const containersReady = find('ContainersReady') || {};
|
||||
const ts = (c: any) => c?.lastTransitionTime || c?.LastTransitionTime || c?.last_transition_time;
|
||||
return {
|
||||
scheduled: ts(scheduled),
|
||||
initialized: ts(initialized),
|
||||
containersReady: ts(containersReady),
|
||||
ready: ts(ready),
|
||||
startTime: pod.start_time || (pod as any).startTime,
|
||||
};
|
||||
}
|
||||
|
||||
function StatCard({ label, value, sub, color }: { label: string; value: React.ReactNode; sub?: string; color?: string }) {
|
||||
return (
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="p-3">
|
||||
<p className="text-xs text-slate-500">{label}</p>
|
||||
<p className="text-base font-bold mt-0.5" style={{ color: color || '#e2e8f0' }}>{value}</p>
|
||||
{sub && <p className="text-[10px] text-slate-600 mt-0.5">{sub}</p>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function DeploymentView({
|
||||
clusterCode, namespace, deployment, pods, replicaSets, revisions,
|
||||
onRefresh, onShowPodLogs, onShowPodTerminal, onShowPodYaml,
|
||||
}: DeploymentViewProps) {
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const [expandedPod, setExpandedPod] = useState<string | null>(null);
|
||||
const [rollbackLoading, setRollbackLoading] = useState<number | null>(null);
|
||||
|
||||
const ready = deployment.ready_replicas || deployment.readyReplicas || 0;
|
||||
const total = deployment.replicas || 0;
|
||||
const available = deployment.available_replicas || deployment.availableReplicas || 0;
|
||||
const updated = deployment.updated_replicas || deployment.updatedReplicas || 0;
|
||||
const isHealthy = ready === total && total > 0;
|
||||
|
||||
const createdAt = deployment.created_at || deployment.creationTimestamp || deployment.created_at;
|
||||
const updatedAt = deployment.updated_at || deployment.last_updated;
|
||||
|
||||
const handleRollback = async (rev: any) => {
|
||||
const revision = rev.revision ?? rev.revision_number ?? rev.number;
|
||||
if (!confirm(`确认回滚到修订版本 #${revision}?\n镜像: ${rev.image || '-'}`)) return;
|
||||
setRollbackLoading(revision);
|
||||
try {
|
||||
await kubernetesApi.rollbackDeployment(clusterCode, namespace, deployment.name, Number(revision));
|
||||
toast({ title: '回滚指令已下发', description: `正在回滚到 revision #${revision}` });
|
||||
onRefresh();
|
||||
} catch (e: any) {
|
||||
toast({ title: '回滚失败', description: e?.message || String(e), variant: 'destructive' });
|
||||
} finally { setRollbackLoading(null); }
|
||||
};
|
||||
|
||||
const nodeClick = (nodeName: string | undefined) => {
|
||||
if (!nodeName) return;
|
||||
navigate(`/kubernetes/${clusterCode}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* ============ 1. 状态总览 ============ */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
|
||||
<StatCard label="副本就绪" value={`${ready}/${total}`} color={isHealthy ? STATUS_COLORS.green : STATUS_COLORS.orange} sub={isHealthy ? '全部就绪' : '部分未就绪'} />
|
||||
<StatCard label="可用副本" value={available} color={STATUS_COLORS.blue} />
|
||||
<StatCard label="已更新副本" value={updated} color={STATUS_COLORS.green} />
|
||||
<StatCard label="策略" value={deployment.strategy || 'RollingUpdate'} color="#94a3b8" />
|
||||
<StatCard label="创建时间" value={<span className="text-xs">{formatK8sTime(createdAt)}</span>} color="#94a3b8" />
|
||||
<StatCard label="更新时间" value={<span className="text-xs">{formatK8sTime(updatedAt)}</span>} color="#94a3b8" />
|
||||
</div>
|
||||
|
||||
{/* ============ 2. 容器详情(CPU/内存/镜像/端口) ============ */}
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm text-slate-200 flex items-center gap-2"><Container className="h-4 w-4 text-blue-400" />容器模板(Pod Template)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<ContainersTable deployment={deployment} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ============ 3. Pod 状态时间线 + 详情列表 ============ */}
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm text-slate-200 flex items-center gap-2">
|
||||
<Activity className="h-4 w-4 text-emerald-400" />Pod 实例 ({pods.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-slate-800 hover:bg-transparent">
|
||||
<TableHead className="text-slate-400 w-8"></TableHead>
|
||||
<TableHead className="text-slate-400">Pod 名称</TableHead>
|
||||
<TableHead className="text-slate-400">状态</TableHead>
|
||||
<TableHead className="text-slate-400">所在节点</TableHead>
|
||||
<TableHead className="text-slate-400">Pod IP</TableHead>
|
||||
<TableHead className="text-slate-400">重启</TableHead>
|
||||
<TableHead className="text-slate-400">调度→就绪</TableHead>
|
||||
<TableHead className="text-slate-400">启动时间</TableHead>
|
||||
<TableHead className="text-slate-400">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pods.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={9} className="text-center text-slate-500 py-8">暂无 Pod</TableCell></TableRow>
|
||||
) : pods.map((pod) => {
|
||||
const tl = getPodTimeline(pod);
|
||||
const schedToReady = elapsedSeconds(tl.scheduled, tl.ready);
|
||||
const expanded = expandedPod === pod.name;
|
||||
const nodeName = pod.node_name || pod.node;
|
||||
return (
|
||||
<Fragment key={pod.name}>
|
||||
<TableRow className="border-slate-800 hover:bg-slate-800/30">
|
||||
<TableCell>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6 text-slate-500" onClick={() => setExpandedPod(expanded ? null : pod.name)}>
|
||||
{expanded ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
|
||||
</Button>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium text-slate-200">{pod.name}</TableCell>
|
||||
<TableCell>
|
||||
<Badge style={{ background: `${statusColor(pod.status)}22`, color: statusColor(pod.status), border: `1px solid ${statusColor(pod.status)}44` }}>
|
||||
{pod.status || 'Unknown'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<button className="text-sm text-blue-400 hover:underline inline-flex items-center gap-1" onClick={() => nodeClick(nodeName)} title="跳转节点详情">
|
||||
<Server className="h-3 w-3" />{nodeName || '-'}
|
||||
</button>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-slate-400 font-mono">{pod.ip || pod.pod_ip || '-'}</TableCell>
|
||||
<TableCell className={(pod.restart_count || 0) > 0 ? 'text-yellow-400' : 'text-slate-500'}>{pod.restart_count || 0}</TableCell>
|
||||
<TableCell className="text-xs text-slate-400">{formatDuration(schedToReady)}</TableCell>
|
||||
<TableCell className="text-xs text-slate-500">{formatK8sTime(tl.startTime || pod.created_at)}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-blue-400" title="日志" onClick={() => onShowPodLogs(pod)}><FileCode className="h-3.5 w-3.5" /></Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-emerald-400" title="终端" onClick={() => onShowPodTerminal(pod)}><Container className="h-3.5 w-3.5" /></Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-amber-400" title="YAML" onClick={() => onShowPodYaml(pod)}><FileCode className="h-3.5 w-3.5" /></Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{expanded && (
|
||||
<TableRow className="border-slate-800 bg-slate-950/40">
|
||||
<TableCell colSpan={9} className="p-4">
|
||||
<PodTimeline pod={pod} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ============ 4. 历史版本(可回滚) ============ */}
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm text-slate-200 flex items-center gap-2"><History className="h-4 w-4 text-purple-400" />历史版本(Revision / Rollout)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-slate-800 hover:bg-transparent">
|
||||
<TableHead className="text-slate-400">Revision</TableHead>
|
||||
<TableHead className="text-slate-400">镜像</TableHead>
|
||||
<TableHead className="text-slate-400">状态</TableHead>
|
||||
<TableHead className="text-slate-400">更新时间</TableHead>
|
||||
<TableHead className="text-slate-400">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{revisions.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={5} className="text-center text-slate-500 py-6 text-xs">后端 /revisions 接口尚未就绪,等待后端联调</TableCell></TableRow>
|
||||
) : revisions.map((rev, idx) => {
|
||||
const revision = rev.revision ?? rev.revision_number ?? rev.number ?? idx + 1;
|
||||
const isLatest = idx === 0;
|
||||
return (
|
||||
<TableRow key={revision} className="border-slate-800 hover:bg-slate-800/30">
|
||||
<TableCell className="font-mono text-slate-200">#{revision}</TableCell>
|
||||
<TableCell className="text-xs font-mono text-slate-400 max-w-[280px] truncate">{rev.image || rev.images?.[0] || '-'}</TableCell>
|
||||
<TableCell>
|
||||
{isLatest
|
||||
? <Badge className="bg-emerald-500/20 text-emerald-400 border-emerald-500/30">当前</Badge>
|
||||
: <Badge className="bg-slate-700/40 text-slate-400 border-slate-600">历史</Badge>}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-slate-500">{formatK8sTime(rev.created_at || rev.time)}</TableCell>
|
||||
<TableCell>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-amber-400 hover:text-amber-300 disabled:opacity-40"
|
||||
disabled={isLatest || rollbackLoading !== null}
|
||||
onClick={() => handleRollback(rev)}>
|
||||
{rollbackLoading === revision ? '回滚中...' : <><RotateCcw className="h-3 w-3 mr-1" />回滚</>}
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ============ 5. ReplicaSet 列表 ============ */}
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm text-slate-200 flex items-center gap-2"><Container className="h-4 w-4 text-cyan-400" />ReplicaSet 列表 ({replicaSets.length})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-slate-800 hover:bg-transparent">
|
||||
<TableHead className="text-slate-400">名称</TableHead>
|
||||
<TableHead className="text-slate-400">副本</TableHead>
|
||||
<TableHead className="text-slate-400">镜像</TableHead>
|
||||
<TableHead className="text-slate-400">Revision</TableHead>
|
||||
<TableHead className="text-slate-400">状态</TableHead>
|
||||
<TableHead className="text-slate-400">创建时间</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{replicaSets.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={6} className="text-center text-slate-500 py-6 text-xs">后端 /replicasets 接口尚未就绪,等待后端联调</TableCell></TableRow>
|
||||
) : replicaSets.map((rs) => {
|
||||
const r: any = rs;
|
||||
const rsReady = r.ready_replicas ?? r.readyReplicas ?? 0;
|
||||
const rsTotal = r.replicas ?? 0;
|
||||
const ok = rsReady === rsTotal && rsTotal > 0;
|
||||
return (
|
||||
<TableRow key={r.name} className="border-slate-800 hover:bg-slate-800/30">
|
||||
<TableCell className="font-medium text-slate-200">
|
||||
{r.name}
|
||||
{r.controlled && <Badge variant="outline" className="ml-2 border-emerald-700 text-emerald-400 text-[10px]">当前</Badge>}
|
||||
</TableCell>
|
||||
<TableCell className="text-slate-300">{rsReady}/{rsTotal}</TableCell>
|
||||
<TableCell className="text-xs font-mono text-slate-400 max-w-[240px] truncate">{r.image || '-'}</TableCell>
|
||||
<TableCell className="text-xs text-slate-400 font-mono">#{r.revision || '-'}</TableCell>
|
||||
<TableCell>
|
||||
<Badge style={{ background: `${ok ? STATUS_COLORS.green : STATUS_COLORS.orange}22`, color: ok ? STATUS_COLORS.green : STATUS_COLORS.orange, border: `1px solid ${ok ? STATUS_COLORS.green : STATUS_COLORS.orange}44` }}>
|
||||
{r.status || (ok ? 'Ready' : 'Updating')}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-slate-500">{formatK8sTime(r.created_at)}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============ 容器模板表(镜像/端口/资源/探针) ============
|
||||
function ContainersTable({ deployment }: { deployment: any }) {
|
||||
const containers: any[] = deployment.containers || deployment.container_specs || deployment.spec_containers || [];
|
||||
if (containers.length === 0) {
|
||||
return <p className="text-xs text-slate-500 py-3">无容器详情数据(扁平字段缺少 containers,可查看 YAML)</p>;
|
||||
}
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{containers.map((c, idx) => {
|
||||
const cpuReq = c.resources?.requests?.cpu || c.cpu_request;
|
||||
const cpuLim = c.resources?.limits?.cpu || c.cpu_limit;
|
||||
const memReq = c.resources?.requests?.memory || c.memory_request;
|
||||
const memLim = c.resources?.limits?.memory || c.memory_limit;
|
||||
const ports: any[] = c.ports || c.container_ports || [];
|
||||
return (
|
||||
<div key={idx} className="border border-slate-800 rounded-lg p-3 bg-slate-950/30">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Container className="h-4 w-4 text-blue-400" />
|
||||
<span className="text-sm font-semibold text-slate-200">{c.name || `container-${idx}`}</span>
|
||||
</div>
|
||||
<code className="text-[11px] text-slate-400 font-mono truncate max-w-[400px]">{c.image}</code>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-xs">
|
||||
<div className="flex items-center gap-1.5 text-slate-400">
|
||||
<Cpu className="h-3.5 w-3.5 text-amber-400" />
|
||||
<span>CPU: <span className="text-slate-200 font-mono">{cpuReq || '-'}/{cpuLim || '-'}</span></span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-slate-400">
|
||||
<MemoryStick className="h-3.5 w-3.5 text-purple-400" />
|
||||
<span>内存: <span className="text-slate-200 font-mono">{memReq || '-'}/{memLim || '-'}</span></span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-slate-400">
|
||||
<Network className="h-3.5 w-3.5 text-cyan-400" />
|
||||
<span>端口: <span className="text-slate-200 font-mono">{ports.map((p: any) => typeof p === 'object' ? (p.containerPort || p.port) : p).join(', ') || '-'}</span></span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-slate-400">
|
||||
<HardDrive className="h-3.5 w-3.5 text-emerald-400" />
|
||||
<span>挂载: <span className="text-slate-200 font-mono">{(c.volume_mounts || c.volumeMounts || []).length || 0}</span></span>
|
||||
</div>
|
||||
</div>
|
||||
{/* 探针 */}
|
||||
<div className="mt-2 flex flex-wrap gap-3 text-[11px] text-slate-500">
|
||||
{c.liveness_probe || c.livenessProbe ? (
|
||||
<span className="text-emerald-500">● 存活探针</span>
|
||||
) : <span className="text-slate-700">○ 无存活探针</span>}
|
||||
{c.readiness_probe || c.readinessProbe ? (
|
||||
<span className="text-blue-500">● 就绪探针</span>
|
||||
) : <span className="text-slate-700">○ 无就绪探针</span>}
|
||||
{c.startup_probe || c.startupProbe ? (
|
||||
<span className="text-purple-500">● 启动探针</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============ Pod 状态时间线(调度→初始化→容器就绪→就绪) ============
|
||||
function PodTimeline({ pod }: { pod: PodItem }) {
|
||||
const tl = getPodTimeline(pod);
|
||||
const stages = [
|
||||
{ label: '调度', ts: tl.scheduled, color: STATUS_COLORS.blue },
|
||||
{ label: '初始化', ts: tl.initialized, color: STATUS_COLORS.orange },
|
||||
{ label: '容器就绪', ts: tl.containersReady, color: STATUS_COLORS.green },
|
||||
{ label: 'Pod 就绪', ts: tl.ready, color: STATUS_COLORS.green },
|
||||
];
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs text-slate-400 mb-2 flex items-center gap-1"><Clock className="h-3 w-3" />状态时间线</p>
|
||||
<div className="flex items-center gap-1 overflow-x-auto pb-1">
|
||||
{stages.map((s, i) => {
|
||||
const prev = stages[i - 1];
|
||||
const dur = elapsedSeconds(prev?.ts, s.ts);
|
||||
return (
|
||||
<div key={s.label} className="flex items-center">
|
||||
<div className="flex flex-col items-center min-w-[80px]">
|
||||
<div className="h-2.5 w-2.5 rounded-full" style={{ background: s.ts ? s.color : '#334155' }} />
|
||||
<span className="text-[10px] mt-1 font-medium" style={{ color: s.ts ? s.color : '#475569' }}>{s.label}</span>
|
||||
<span className="text-[9px] text-slate-600">{s.ts ? formatK8sTime(s.ts) : '-'}</span>
|
||||
</div>
|
||||
{i < stages.length - 1 && (
|
||||
<div className="flex flex-col items-center mx-1 min-w-[40px]">
|
||||
<div className="h-0.5 w-10" style={{ background: '#334155' }} />
|
||||
<span className="text-[9px] text-slate-600 mt-0.5">{dur != null ? formatDuration(dur) : ''}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,569 @@
|
||||
// DeploymentWorkload.tsx — Kuboard 风格 Deployment 详情主组件
|
||||
// - 顶部操作菜单: 编辑 | 预览/编辑YAML | 调整镜像版本 | 伸缩 | 日志/终端 | 重启 | 删除 | 元数据 | 运行时 | 服务 | 应用路由
|
||||
// - 双模式: view(只读增强看板)/ edit(表单编辑器)
|
||||
// 作者: Logan <admin@attacker.club>
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
ArrowLeft, Pencil, FileCode, Image as ImageIcon, Minus, Plus, RotateCw,
|
||||
Trash2, FileText, Terminal, RefreshCw, Loader2, X,
|
||||
ChevronDown, Cpu, Network, GitBranch, Info,
|
||||
CheckCircle, AlertTriangle, XCircle,
|
||||
} from 'lucide-react';
|
||||
import { kubernetesApi } from '@/services/api';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { useUserStore } from '@/store';
|
||||
import { YamlViewer } from './YamlViewer';
|
||||
import { DeploymentView } from './DeploymentView';
|
||||
import { DeploymentEdit } from './DeploymentEdit';
|
||||
|
||||
export interface DeploymentWorkloadProps {
|
||||
clusterCode: string;
|
||||
namespace: string;
|
||||
deploymentName: string;
|
||||
/** 'view' | 'edit' — 来自路由参数 */
|
||||
mode: 'view' | 'edit';
|
||||
onBack: () => void;
|
||||
/** 跳转到另一模式(用于"编辑"按钮、保存成功后切换) */
|
||||
onModeChange: (mode: 'view' | 'edit') => void;
|
||||
}
|
||||
|
||||
type ActionModal =
|
||||
| { kind: 'yaml' }
|
||||
| { kind: 'image' }
|
||||
| { kind: 'scale' }
|
||||
| { kind: 'podLogs'; pod: string }
|
||||
| { kind: 'podYaml'; pod: string }
|
||||
| { kind: 'delete' }
|
||||
| null;
|
||||
|
||||
export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mode, onBack, onModeChange }: DeploymentWorkloadProps) {
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const [deployment, setDeployment] = useState<any>(null);
|
||||
const [manifest, setManifest] = useState<any>(null);
|
||||
const [pods, setPods] = useState<any[]>([]);
|
||||
const [replicaSets, setReplicaSets] = useState<any[]>([]);
|
||||
const [revisions, setRevisions] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [actionLoading, setActionLoading] = useState('');
|
||||
const [modal, setModal] = useState<ActionModal>(null);
|
||||
// 调整镜像版本
|
||||
const [newImage, setNewImage] = useState('');
|
||||
// 伸缩
|
||||
const [scaleValue, setScaleValue] = useState(0);
|
||||
|
||||
const toArray = (v: any) => (Array.isArray(v) ? v : (v?.list || v?.items || v?.data || []));
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [dep, podRes, rsRes, revRes] = await Promise.all([
|
||||
kubernetesApi.getDeployment(clusterCode, namespace, deploymentName).catch(() => null),
|
||||
kubernetesApi.getNamespacePods(clusterCode, namespace).catch(() => []),
|
||||
kubernetesApi.getReplicaSets(clusterCode, namespace).catch(() => []),
|
||||
kubernetesApi.getDeploymentRevisions(clusterCode, namespace, deploymentName).catch(() => []),
|
||||
]);
|
||||
const depData = dep || (Array.isArray(dep) ? dep[0] : null);
|
||||
setDeployment(depData);
|
||||
setScaleValue(depData?.replicas || 0);
|
||||
setNewImage(depData?.image || '');
|
||||
|
||||
const allPods = toArray(podRes);
|
||||
// 过滤当前 Deployment 的 Pod(按名称前缀,兼容 ownerReference 不可用场景)
|
||||
const depPods = allPods.filter((p: any) =>
|
||||
p.namespace === namespace && (p.name || '').startsWith(deploymentName)
|
||||
);
|
||||
setPods(depPods);
|
||||
|
||||
const allRs = toArray(rsRes);
|
||||
const depRs = allRs.filter((rs: any) =>
|
||||
(rs.namespace === namespace) &&
|
||||
((rs.owner_deployment === deploymentName) || (rs.name || '').startsWith(deploymentName))
|
||||
);
|
||||
setReplicaSets(depRs);
|
||||
|
||||
setRevisions(toArray(revRes));
|
||||
} catch (e) {
|
||||
console.error('加载详情失败:', e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [clusterCode, namespace, deploymentName]);
|
||||
|
||||
// 加载 manifest(用于 edit 模式 + YAML 编辑)
|
||||
const loadManifest = useCallback(async () => {
|
||||
try {
|
||||
const yamlText = await kubernetesApi.getResourceYaml(clusterCode, namespace, 'deployments', deploymentName);
|
||||
// 简易 YAML→JSON:交给浏览器原生 JSON.parse 兜底(K8s API 多以 JSON 返回);
|
||||
// 若是纯 YAML,则尝试用 js-yaml(如项目已安装),否则保留为 null 让编辑模式降级。
|
||||
let parsed: any = null;
|
||||
try { parsed = JSON.parse(yamlText); } catch {
|
||||
// 非纯 JSON,尝试动态加载 js-yaml
|
||||
try {
|
||||
const jsyaml: any = await import('js-yaml');
|
||||
parsed = jsyaml.load(yamlText);
|
||||
} catch {
|
||||
parsed = null;
|
||||
}
|
||||
}
|
||||
setManifest(parsed || deployment);
|
||||
} catch (e) {
|
||||
console.warn('加载 manifest 失败(编辑功能降级):', e);
|
||||
setManifest(deployment);
|
||||
}
|
||||
}, [clusterCode, namespace, deploymentName, deployment]);
|
||||
|
||||
useEffect(() => { loadData(); }, [loadData]);
|
||||
useEffect(() => { if (mode === 'edit' && !manifest) loadManifest(); }, [mode, manifest, loadManifest]);
|
||||
|
||||
// ============ 操作 ============
|
||||
const handleScale = async (delta: number) => {
|
||||
const newReplicas = (deployment?.replicas || 0) + delta;
|
||||
if (newReplicas < 0) return;
|
||||
setActionLoading('scale');
|
||||
try {
|
||||
await kubernetesApi.scaleDeployment(clusterCode, namespace, deploymentName, newReplicas);
|
||||
toast({ title: '扩缩容成功', description: `副本数已设置为 ${newReplicas}` });
|
||||
loadData();
|
||||
} catch (e: any) {
|
||||
toast({ title: '操作失败', description: e?.message || String(e), variant: 'destructive' });
|
||||
} finally { setActionLoading(''); }
|
||||
};
|
||||
|
||||
const handleScaleTo = async () => {
|
||||
setActionLoading('scale');
|
||||
try {
|
||||
await kubernetesApi.scaleDeployment(clusterCode, namespace, deploymentName, scaleValue);
|
||||
toast({ title: '伸缩成功', description: `副本数已设置为 ${scaleValue}` });
|
||||
setModal(null);
|
||||
loadData();
|
||||
} catch (e: any) {
|
||||
toast({ title: '操作失败', description: e?.message || String(e), variant: 'destructive' });
|
||||
} finally { setActionLoading(''); }
|
||||
};
|
||||
|
||||
const handleRestart = async () => {
|
||||
if (!confirm(`确认滚动重启 Deployment "${deploymentName}"?`)) return;
|
||||
setActionLoading('restart');
|
||||
try {
|
||||
await kubernetesApi.restartDeployment(clusterCode, namespace, deploymentName);
|
||||
toast({ title: '重启指令已下发', description: 'Deployment 正在滚动重启' });
|
||||
setTimeout(loadData, 1500);
|
||||
} catch (e: any) {
|
||||
toast({ title: '重启失败', description: e?.message || String(e), variant: 'destructive' });
|
||||
} finally { setActionLoading(''); }
|
||||
};
|
||||
|
||||
const handleUpdateImage = async () => {
|
||||
if (!newImage.trim()) return;
|
||||
setActionLoading('image');
|
||||
try {
|
||||
await kubernetesApi.updateDeploymentImage(clusterCode, namespace, deploymentName, newImage);
|
||||
toast({ title: '镜像更新成功', description: `正在滚动更新为 ${newImage}` });
|
||||
setModal(null);
|
||||
loadData();
|
||||
} catch (e: any) {
|
||||
toast({ title: '更新失败', description: e?.message || String(e), variant: 'destructive' });
|
||||
} finally { setActionLoading(''); }
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
setActionLoading('delete');
|
||||
try {
|
||||
// 复用通用删除:通过 PUT 空文档或调用隐式端点。若无专用端点,则提示。
|
||||
const tk = useUserStore.getState().token || '';
|
||||
const baseUrl = import.meta.env.VITE_API_BASE_URL || '';
|
||||
const url = `${baseUrl}/api/v1/kubernetes/${clusterCode}/namespace/${namespace}/deployments/${deploymentName}`;
|
||||
const resp = await fetch(url, { method: 'DELETE', headers: { Authorization: `Bearer ${tk}` } });
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
toast({ title: '删除成功', description: `${deploymentName} 已删除` });
|
||||
setModal(null);
|
||||
setTimeout(() => navigate(`/kubernetes/${clusterCode}/namespace/${namespace}/deployments`), 800);
|
||||
} catch (e: any) {
|
||||
toast({ title: '删除失败', description: e?.message || String(e), variant: 'destructive' });
|
||||
} finally { setActionLoading(''); }
|
||||
};
|
||||
|
||||
// 日志流(Pod 实时日志)
|
||||
const handleShowPodLogs = (pod: any) => setModal({ kind: 'podLogs', pod: pod.name });
|
||||
const handleShowPodYaml = (pod: any) => setModal({ kind: 'podYaml', pod: pod.name });
|
||||
const handleShowPodTerminal = (_pod: any) => {
|
||||
toast({ title: '容器终端', description: 'Pod exec 终端复用 KubectlTerminal 通道,开发中' });
|
||||
};
|
||||
|
||||
// ============ 渲染 ============
|
||||
if (loading) {
|
||||
return (
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="p-12 text-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin mx-auto text-slate-500" />
|
||||
<p className="text-slate-500 mt-3">加载中...</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!deployment) {
|
||||
return (
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="p-12 text-center">
|
||||
<XCircle className="h-8 w-8 mx-auto text-red-400" />
|
||||
<p className="text-slate-400 mt-3">未找到 Deployment: {deploymentName}</p>
|
||||
<Button variant="outline" className="mt-4" onClick={onBack}>返回</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const ready = deployment.ready_replicas || deployment.readyReplicas || 0;
|
||||
const total = deployment.replicas || 0;
|
||||
const isHealthy = ready === total && total > 0;
|
||||
const viewBase = `/kubernetes/${clusterCode}/namespace/${namespace}/workload`;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* ============ 顶部:标题 + 模式标识 + 操作菜单 ============ */}
|
||||
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<Button variant="ghost" size="sm" className="text-slate-400" onClick={onBack}>
|
||||
<ArrowLeft className="h-4 w-4 mr-1" />返回
|
||||
</Button>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h1 className="text-xl font-bold text-slate-100 truncate">{deploymentName}</h1>
|
||||
<Badge variant="outline" className="border-slate-700 text-slate-400">{namespace}</Badge>
|
||||
<Badge variant="outline" className="border-slate-700 text-slate-400">Deployment</Badge>
|
||||
<Badge
|
||||
className={isHealthy
|
||||
? 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30'
|
||||
: 'bg-amber-500/20 text-amber-400 border-amber-500/30'}
|
||||
>
|
||||
{isHealthy ? <CheckCircle className="h-3 w-3 mr-1" /> : <AlertTriangle className="h-3 w-3 mr-1" />}
|
||||
{ready}/{total}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 mt-0.5 truncate">
|
||||
镜像: <code className="text-slate-400">{deployment.image || '-'}</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 模式切换 Tab */}
|
||||
<div className="inline-flex rounded-md border border-slate-700 overflow-hidden">
|
||||
<button
|
||||
className={`px-3 py-1.5 text-xs font-medium transition-colors ${mode === 'view' ? 'bg-slate-700 text-slate-100' : 'bg-transparent text-slate-400 hover:text-slate-200'}`}
|
||||
onClick={() => onModeChange('view')}
|
||||
>
|
||||
查看模式
|
||||
</button>
|
||||
<button
|
||||
className={`px-3 py-1.5 text-xs font-medium transition-colors ${mode === 'edit' ? 'bg-blue-600 text-white' : 'bg-transparent text-slate-400 hover:text-slate-200'}`}
|
||||
onClick={() => onModeChange('edit')}
|
||||
>
|
||||
<Pencil className="h-3 w-3 inline mr-1" />编辑模式
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Button variant="outline" size="sm" className="border-slate-700 text-slate-400" onClick={loadData} title="刷新">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{/* 顶部操作菜单(Kuboard 风格下拉) */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="border-slate-700 text-slate-200">
|
||||
操作 <ChevronDown className="h-3.5 w-3.5 ml-1" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-52 bg-slate-900 border-slate-700">
|
||||
<DropdownMenuItem className="text-slate-200 hover:bg-slate-800 cursor-pointer" onClick={() => onModeChange('edit')}>
|
||||
<Pencil className="h-4 w-4 mr-2 text-blue-400" /> 编辑
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="text-slate-200 hover:bg-slate-800 cursor-pointer" onClick={() => setModal({ kind: 'yaml' })}>
|
||||
<FileCode className="h-4 w-4 mr-2 text-emerald-400" /> 预览 / 编辑 YAML
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="text-slate-200 hover:bg-slate-800 cursor-pointer" onClick={() => setModal({ kind: 'image' })}>
|
||||
<ImageIcon className="h-4 w-4 mr-2 text-purple-400" /> 调整镜像版本
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="text-slate-200 hover:bg-slate-800 cursor-pointer" onClick={() => { setScaleValue(deployment.replicas || 0); setModal({ kind: 'scale' }); }}>
|
||||
<Minus className="h-4 w-4 mr-2 text-amber-400" /> 伸缩
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="text-slate-200 hover:bg-slate-800 cursor-pointer" onClick={() => toast({ title: '日志 / 终端', description: '请在 Pod 列表中点击对应按钮' })}>
|
||||
<Terminal className="h-4 w-4 mr-2 text-cyan-400" /> 日志 / 终端
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="text-slate-200 hover:bg-slate-800 cursor-pointer" onClick={handleRestart}>
|
||||
<RotateCw className="h-4 w-4 mr-2 text-blue-400" /> 重启
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="text-red-400 hover:bg-slate-800 cursor-pointer" onClick={() => setModal({ kind: 'delete' })}>
|
||||
<Trash2 className="h-4 w-4 mr-2" /> 删除
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator className="bg-slate-800" />
|
||||
<DropdownMenuItem className="text-slate-300 hover:bg-slate-800 cursor-pointer" onClick={() => toast({ title: '元数据', description: 'Labels / Annotations / Selectors,集成在 YAML 视图中' })}>
|
||||
<Info className="h-4 w-4 mr-2 text-slate-400" /> 元数据
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="text-slate-300 hover:bg-slate-800 cursor-pointer" onClick={() => toast({ title: '运行时', description: 'Pod 运行状态请见 Pod 实例区' })}>
|
||||
<Cpu className="h-4 w-4 mr-2 text-slate-400" /> 运行时
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="text-slate-300 hover:bg-slate-800 cursor-pointer"
|
||||
onClick={() => navigate(`${viewBase}/view/Service?selector=${encodeURIComponent(JSON.stringify(deployment.labels || {}))}`)}>
|
||||
<Network className="h-4 w-4 mr-2 text-slate-400" /> 服务
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="text-slate-300 hover:bg-slate-800 cursor-pointer"
|
||||
onClick={() => navigate(`${viewBase}/view/Ingress?namespace=${namespace}`)}>
|
||||
<GitBranch className="h-4 w-4 mr-2 text-slate-400" /> 应用路由
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ============ 主体:view / edit ============ */}
|
||||
{mode === 'view' ? (
|
||||
<DeploymentView
|
||||
clusterCode={clusterCode}
|
||||
namespace={namespace}
|
||||
deployment={deployment}
|
||||
pods={pods}
|
||||
replicaSets={replicaSets}
|
||||
revisions={revisions}
|
||||
onRefresh={loadData}
|
||||
onShowPodLogs={handleShowPodLogs}
|
||||
onShowPodTerminal={handleShowPodTerminal}
|
||||
onShowPodYaml={handleShowPodYaml}
|
||||
/>
|
||||
) : (
|
||||
<DeploymentEdit
|
||||
clusterCode={clusterCode}
|
||||
namespace={namespace}
|
||||
deploymentName={deploymentName}
|
||||
manifest={manifest}
|
||||
onSaved={() => { loadManifest(); loadData(); onModeChange('view'); }}
|
||||
onCancel={() => onModeChange('view')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ============ 弹窗:YAML ============ */}
|
||||
{modal?.kind === 'yaml' && (
|
||||
<YamlViewer
|
||||
clusterCode={clusterCode}
|
||||
namespace={namespace}
|
||||
resource="deployments"
|
||||
name={deploymentName}
|
||||
cleanBeforeEdit
|
||||
onClose={() => setModal(null)}
|
||||
onSaved={loadData}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 弹窗:Pod YAML */}
|
||||
{modal?.kind === 'podYaml' && (
|
||||
<YamlViewer
|
||||
clusterCode={clusterCode}
|
||||
namespace={namespace}
|
||||
resource="pods"
|
||||
name={modal.pod}
|
||||
cleanBeforeEdit
|
||||
onClose={() => setModal(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ============ 弹窗:调整镜像版本 ============ */}
|
||||
{modal?.kind === 'image' && (
|
||||
<ModalShell title="调整镜像版本" onClose={() => setModal(null)}>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label className="text-slate-400 text-sm">当前镜像</Label>
|
||||
<p className="text-xs text-slate-500 font-mono mt-1 bg-slate-800 rounded px-3 py-2">{deployment.image || '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-slate-400 text-sm">新镜像地址</Label>
|
||||
<Input value={newImage} onChange={e => setNewImage(e.target.value)}
|
||||
placeholder="harbor.example.com/app:v2.0"
|
||||
className="mt-1 bg-slate-800 border-slate-700 text-slate-200 text-sm font-mono" />
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="outline" className="border-slate-700 text-slate-400" onClick={() => setModal(null)}>取消</Button>
|
||||
<Button className="bg-blue-600 hover:bg-blue-700" disabled={actionLoading === 'image' || !newImage.trim()} onClick={handleUpdateImage}>
|
||||
{actionLoading === 'image' ? <Loader2 className="h-4 w-4 animate-spin" /> : '更新镜像'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalShell>
|
||||
)}
|
||||
|
||||
{/* ============ 弹窗:伸缩 ============ */}
|
||||
{modal?.kind === 'scale' && (
|
||||
<ModalShell title="伸缩 (Scale)" onClose={() => setModal(null)}>
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs text-slate-500">当前副本: {deployment.replicas || 0}</p>
|
||||
<div className="flex items-center justify-center gap-3 py-3">
|
||||
<Button variant="outline" size="icon" className="border-slate-700 text-slate-300"
|
||||
disabled={actionLoading === 'scale' || scaleValue <= 0}
|
||||
onClick={() => setScaleValue(v => Math.max(0, v - 1))}>
|
||||
<Minus className="h-4 w-4" />
|
||||
</Button>
|
||||
<Input type="number" min={0} value={scaleValue}
|
||||
onChange={e => setScaleValue(Math.max(0, Number(e.target.value)))}
|
||||
className="w-24 text-center text-lg font-bold bg-slate-800 border-slate-700 text-slate-200" />
|
||||
<Button variant="outline" size="icon" className="border-slate-700 text-slate-300"
|
||||
disabled={actionLoading === 'scale'}
|
||||
onClick={() => setScaleValue(v => v + 1)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="outline" className="border-slate-700 text-slate-400" onClick={() => setModal(null)}>取消</Button>
|
||||
<Button className="bg-blue-600 hover:bg-blue-700" disabled={actionLoading === 'scale'} onClick={handleScaleTo}>
|
||||
{actionLoading === 'scale' ? <Loader2 className="h-4 w-4 animate-spin" /> : '确认'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalShell>
|
||||
)}
|
||||
|
||||
{/* ============ 弹窗:删除 ============ */}
|
||||
{modal?.kind === 'delete' && (
|
||||
<ModalShell title="删除 Deployment" onClose={() => setModal(null)}>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-red-400">
|
||||
<AlertTriangle className="h-5 w-5" />
|
||||
<span className="font-semibold">此操作不可撤销</span>
|
||||
</div>
|
||||
<p className="text-sm text-slate-400">
|
||||
确认删除 Deployment <code className="text-slate-200 font-mono">{namespace}/{deploymentName}</code>?
|
||||
其管理的所有 Pod 将被一并回收。
|
||||
</p>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="outline" className="border-slate-700 text-slate-400" onClick={() => setModal(null)}>取消</Button>
|
||||
<Button variant="destructive" disabled={actionLoading === 'delete'} onClick={handleDelete}>
|
||||
{actionLoading === 'delete' ? <Loader2 className="h-4 w-4 animate-spin mr-1" /> : <Trash2 className="h-4 w-4 mr-1" />}
|
||||
确认删除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalShell>
|
||||
)}
|
||||
|
||||
{/* ============ 弹窗:Pod 日志 ============ */}
|
||||
{modal?.kind === 'podLogs' && (
|
||||
<PodLogsModal
|
||||
clusterCode={clusterCode}
|
||||
namespace={namespace}
|
||||
podName={modal.pod}
|
||||
containers={(pods.find(p => p.name === modal.pod)?.containers || []).map((c: any) => c.name).filter(Boolean)}
|
||||
onClose={() => setModal(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============ 通用 Modal 外壳 ============
|
||||
function ModalShell({ title, children, onClose }: { title: string; children: React.ReactNode; onClose: () => void }) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={onClose}>
|
||||
<div className="bg-slate-900 border border-slate-700 rounded-xl p-6 w-[460px] max-w-[90vw] shadow-2xl" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-base font-bold text-slate-100">{title}</h2>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400" onClick={onClose}><X className="h-4 w-4" /></Button>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============ Pod 日志弹窗(带容器切换 + 行数 + 实时流) ============
|
||||
function PodLogsModal({ clusterCode, namespace, podName, containers, onClose }: {
|
||||
clusterCode: string; namespace: string; podName: string; containers: string[]; onClose: () => void;
|
||||
}) {
|
||||
const { toast } = useToast();
|
||||
const [logs, setLogs] = useState('');
|
||||
const [tail, setTail] = useState(200);
|
||||
const [container, setContainer] = useState(containers[0] || '');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [streaming, setStreaming] = useState(false);
|
||||
|
||||
const loadLogs = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res: any = await kubernetesApi.getPodLogs(clusterCode, namespace, podName, tail, container || undefined);
|
||||
setLogs(res?.data?.logs || res?.logs || res || '无日志');
|
||||
} catch {
|
||||
setLogs('获取日志失败');
|
||||
} finally { setLoading(false); }
|
||||
};
|
||||
|
||||
useEffect(() => { loadLogs(); }, [container, tail]);
|
||||
|
||||
const startStream = async () => {
|
||||
setStreaming(true);
|
||||
const token = useUserStore.getState().token || '';
|
||||
const baseUrl = import.meta.env.VITE_API_BASE_URL || '';
|
||||
const cParam = container ? `&container=${encodeURIComponent(container)}` : '';
|
||||
const url = `${baseUrl}/api/v1/kubernetes/${clusterCode}/namespace/${namespace}/pods/${podName}/logs?tail=${tail}&stream=true${cParam}`;
|
||||
try {
|
||||
const resp = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
|
||||
const reader = resp.body?.getReader();
|
||||
if (!reader) return;
|
||||
const decoder = new TextDecoder();
|
||||
let buf = '';
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
setLogs(buf);
|
||||
}
|
||||
} catch {
|
||||
toast({ title: '流式日志连接中断', variant: 'destructive' });
|
||||
} finally { setStreaming(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={onClose}>
|
||||
<div className="bg-slate-900 border border-slate-700 rounded-xl w-[90vw] max-w-4xl max-h-[80vh] flex flex-col" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between p-3 border-b border-slate-700">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-4 w-4 text-blue-400" />
|
||||
<span className="text-sm font-semibold text-slate-200">{podName}</span>
|
||||
{container && <Badge variant="outline" className="border-slate-700 text-slate-400 text-[10px]">{container}</Badge>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{containers.length > 0 && (
|
||||
<select value={container} onChange={e => setContainer(e.target.value)}
|
||||
className="bg-slate-800 border border-slate-700 rounded text-xs text-slate-300 px-2 py-1">
|
||||
{containers.map(c => <option key={c} value={c}>{c}</option>)}
|
||||
</select>
|
||||
)}
|
||||
<span className="text-[11px] text-slate-500">最近</span>
|
||||
<Input type="number" value={tail} onChange={e => setTail(Number(e.target.value) || 200)}
|
||||
className="w-16 h-7 bg-slate-800 border-slate-700 text-slate-200 text-xs" />
|
||||
<span className="text-[11px] text-slate-500">行</span>
|
||||
<Button size="sm" variant="outline" className="h-7 border-slate-700 text-slate-300 text-xs" onClick={loadLogs} disabled={loading}>
|
||||
{loading ? <Loader2 className="h-3 w-3 animate-spin" /> : <RefreshCw className="h-3 w-3" />}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="h-7 border-slate-700 text-emerald-400 text-xs" onClick={startStream} disabled={streaming}>
|
||||
{streaming ? '流中...' : '实时流'}
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400" onClick={onClose}><X className="h-4 w-4" /></Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto p-3">
|
||||
<pre className="bg-slate-950 rounded p-3 text-xs text-slate-300 font-mono whitespace-pre-wrap max-h-[60vh] overflow-auto">
|
||||
{logs || '无日志内容'}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,9 +13,11 @@ import {
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { kubernetesApi } from '@/services/api';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const emptyForm = {
|
||||
name: '',
|
||||
code: '',
|
||||
version: '',
|
||||
description: '',
|
||||
config_type: 1, // 0=Token, 1=Kubeconfig
|
||||
@@ -51,6 +53,7 @@ export function KubernetesClusterDialog({
|
||||
setHasKubeConfig(!!d.has_kube_config);
|
||||
setFormData({
|
||||
name: d.name || '',
|
||||
code: d.code || d.name?.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/^-+|-+$/g, '') || '',
|
||||
version: d.version || '',
|
||||
description: d.description || '',
|
||||
config_type: Number(d.config_type ?? 1),
|
||||
@@ -62,6 +65,7 @@ export function KubernetesClusterDialog({
|
||||
// 降级:用列表数据
|
||||
setFormData({
|
||||
name: editingCluster.name || '',
|
||||
code: editingCluster.code || editingCluster.name?.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/^-+|-+$/g, '') || '',
|
||||
version: editingCluster.version || '',
|
||||
description: editingCluster.description || '',
|
||||
config_type: Number(editingCluster.config_type ?? 1),
|
||||
@@ -93,11 +97,12 @@ export function KubernetesClusterDialog({
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!formData.name.trim()) return;
|
||||
if (!formData.name.trim() || !formData.code.trim()) { toast.error('请填写集群名称和代码'); return; }
|
||||
try {
|
||||
setSubmitting(true);
|
||||
const payload = {
|
||||
name: formData.name,
|
||||
code: formData.code,
|
||||
version: formData.version,
|
||||
description: formData.description,
|
||||
config_type: formData.config_type,
|
||||
@@ -136,11 +141,24 @@ export function KubernetesClusterDialog({
|
||||
<Label className="text-[var(--dg-text-secondary)]">集群名称</Label>
|
||||
<Input
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
onChange={(e) => {
|
||||
const name = e.target.value;
|
||||
const code = name.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/^-+|-+$/g, '');
|
||||
setFormData({ ...formData, name, code: formData.code || code });
|
||||
}}
|
||||
placeholder="例如:prod-cluster"
|
||||
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)]"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[var(--dg-text-secondary)]">集群代码 (Code)</Label>
|
||||
<Input
|
||||
value={formData.code}
|
||||
onChange={(e) => setFormData({ ...formData, code: e.target.value })}
|
||||
placeholder="如: prod-cluster"
|
||||
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)] font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[var(--dg-text-secondary)]">版本(可选)</Label>
|
||||
<Input
|
||||
@@ -233,7 +251,7 @@ export function KubernetesClusterDialog({
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={submitting || !formData.name.trim()}
|
||||
disabled={submitting || !formData.name.trim() || !formData.code.trim()}
|
||||
className="bg-[var(--dg-blue)] hover:opacity-90 text-white"
|
||||
>
|
||||
{submitting ? '保存中...' : editingCluster ? '保存' : '添加'}
|
||||
|
||||
@@ -277,7 +277,8 @@ export default function NamespaceResourcePage() {
|
||||
|
||||
const handleDetail = (item: any) => {
|
||||
if (type === 'deployments') {
|
||||
navigate(`/kubernetes/${clusterCode}/namespace/${namespace}/deployments/${item.name}`);
|
||||
// Kuboard 风格双模式路由(默认 view 模式)
|
||||
navigate(`/kubernetes/${clusterCode}/namespace/${namespace}/workload/view/Deployment/${item.name}`);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Terminal as XTerm } from 'xterm';
|
||||
import { FitAddon } from 'xterm-addon-fit';
|
||||
import 'xterm/css/xterm.css';
|
||||
import {
|
||||
Terminal, Maximize2, Minimize2, X, Loader2, RotateCw, Box,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { kubernetesApi } from '@/services/api';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
|
||||
/**
|
||||
* Pod 级 exec 终端 — 进入容器执行命令 (kubectl exec -it)
|
||||
*
|
||||
* 复用 KubectlTerminal.tsx 的 xterm.js 模式,连接 Pod exec WebSocket 通道:
|
||||
* ws://host/ws/kubernetes-exec/:clusterId/namespaces/:ns/pods/:pod?container=&token=
|
||||
*
|
||||
* 支持:
|
||||
* - 容器选择下拉(默认第一个容器,可切换)
|
||||
* - 自定义 shell(默认 bash,可切 sh)
|
||||
* - Dialog/Modal 形式,全屏切换
|
||||
* - 终端 resize 透传(ResizeObserver + fit)
|
||||
*
|
||||
* @author Logan <admin@attacker.club>
|
||||
*/
|
||||
interface PodExecTerminalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** 集群 ID(用于 ws 路由 :clusterId) */
|
||||
clusterId: number | string;
|
||||
/** 集群 code(备用,部分后端用 code) */
|
||||
clusterCode?: string;
|
||||
namespace: string;
|
||||
podName: string;
|
||||
/** 可选:Pod 容器名列表,缺省时仅显示 shell 选择 */
|
||||
containers?: string[];
|
||||
/** 初始容器 */
|
||||
initialContainer?: string;
|
||||
}
|
||||
|
||||
type ConnState = 'idle' | 'connecting' | 'connected' | 'error' | 'closed';
|
||||
|
||||
const SHELLS = [
|
||||
{ value: 'bash', label: '/bin/bash' },
|
||||
{ value: 'sh', label: '/bin/sh' },
|
||||
{ value: 'sh', label: '/bin/ash' },
|
||||
];
|
||||
|
||||
export function PodExecTerminal({
|
||||
open,
|
||||
onOpenChange,
|
||||
clusterId,
|
||||
namespace,
|
||||
podName,
|
||||
containers = [],
|
||||
initialContainer,
|
||||
}: PodExecTerminalProps) {
|
||||
const { toast } = useToast();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const termRef = useRef<XTerm | null>(null);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const fitRef = useRef<FitAddon | null>(null);
|
||||
|
||||
const [connState, setConnState] = useState<ConnState>('idle');
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
const [container, setContainer] = useState<string>(initialContainer || containers[0] || '');
|
||||
const [shell, setShell] = useState<string>('bash');
|
||||
|
||||
// 重置全屏状态
|
||||
useEffect(() => {
|
||||
if (!open) setFullscreen(false);
|
||||
}, [open]);
|
||||
|
||||
// 容器列表变化时同步默认值
|
||||
useEffect(() => {
|
||||
if (open && !container && containers.length > 0) {
|
||||
setContainer(initialContainer || containers[0]);
|
||||
}
|
||||
}, [open, containers, initialContainer, container]);
|
||||
|
||||
// 创建 xterm 实例(仅在 open 时创建一次,容器/shell 变化时重连 ws)
|
||||
const connect = useCallback(() => {
|
||||
if (!termRef.current) return;
|
||||
const term = termRef.current;
|
||||
|
||||
// 关闭旧连接
|
||||
if (wsRef.current) {
|
||||
try { wsRef.current.close(); } catch { /* noop */ }
|
||||
wsRef.current = null;
|
||||
}
|
||||
|
||||
setConnState('connecting');
|
||||
term.clear();
|
||||
term.writeln(`\x1b[36m[正在连接 ${podName}${container ? '/' + container : ''} ...]\x1b[0m`);
|
||||
|
||||
const wsUrl = kubernetesApi.buildPodExecWsUrl(clusterId, namespace, podName, container || undefined);
|
||||
const ws = new WebSocket(wsUrl);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
setConnState('connected');
|
||||
term.reset();
|
||||
// 发送初始 resize + 启动 shell
|
||||
try {
|
||||
const { cols, rows } = term;
|
||||
ws.send(JSON.stringify({ status: 1, cols, rows }));
|
||||
// 启动 shell 进程:标准 remote exec 协议 (status:0 data)
|
||||
ws.send(JSON.stringify({ status: 0, data: `${shell}\r` }));
|
||||
} catch { /* noop */ }
|
||||
setTimeout(() => { try { term.focus(); } catch { /* noop */ } }, 100);
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.status === 0 && typeof msg.data === 'string') {
|
||||
term.write(msg.data);
|
||||
} else if (msg.status === 1) {
|
||||
// 服务端 resize 确认,忽略
|
||||
} else if (msg.status === 2 || msg.error) {
|
||||
term.write(`\r\n\x1b[31m${msg.message || msg.error || '执行错误'}\x1b[0m\r\n`);
|
||||
}
|
||||
} catch {
|
||||
if (typeof event.data === 'string') term.write(event.data);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
setConnState((s) => (s === 'error' ? s : 'closed'));
|
||||
term.write('\r\n\x1b[33m[连接已断开]\x1b[0m\r\n');
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
setConnState('error');
|
||||
term.write('\r\n\x1b[31m[连接发生错误]\x1b[0m\r\n');
|
||||
};
|
||||
}, [clusterId, namespace, podName, container, shell]);
|
||||
|
||||
// 创建/销毁 xterm + 绑定 onData(生命周期跟随 open)
|
||||
useEffect(() => {
|
||||
if (!open || !clusterId || !podName) return;
|
||||
|
||||
const term = new XTerm({
|
||||
cursorBlink: true,
|
||||
fontSize: 14,
|
||||
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
||||
allowProposedApi: true,
|
||||
scrollback: 5000,
|
||||
theme: {
|
||||
background: '#0f172a',
|
||||
foreground: '#e2e8f0',
|
||||
cursor: '#38bdf8',
|
||||
selectionBackground: '#1e40af',
|
||||
black: '#1e293b', red: '#ef4444', green: '#10b981',
|
||||
yellow: '#f59e0b', blue: '#3b82f6', magenta: '#a855f7',
|
||||
cyan: '#06b6d4', white: '#e2e8f0',
|
||||
brightBlack: '#475569', brightRed: '#f87171', brightGreen: '#34d399',
|
||||
brightYellow: '#fbbf24', brightBlue: '#60a5fa', brightMagenta: '#c084fc',
|
||||
brightCyan: '#22d3ee', brightWhite: '#f8fafc',
|
||||
},
|
||||
});
|
||||
const fit = new FitAddon();
|
||||
term.loadAddon(fit);
|
||||
termRef.current = term;
|
||||
fitRef.current = fit;
|
||||
|
||||
const onData = (data: string) => {
|
||||
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({ status: 0, data }));
|
||||
}
|
||||
};
|
||||
term.onData(onData);
|
||||
|
||||
if (containerRef.current) {
|
||||
term.open(containerRef.current);
|
||||
requestAnimationFrame(() => {
|
||||
try { fit.fit(); } catch { /* noop */ }
|
||||
});
|
||||
}
|
||||
|
||||
// 首次连接
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
try {
|
||||
if (wsRef.current && (wsRef.current.readyState === WebSocket.OPEN || wsRef.current.readyState === WebSocket.CONNECTING)) {
|
||||
wsRef.current.close();
|
||||
}
|
||||
} catch { /* noop */ }
|
||||
try { term.dispose(); } catch { /* noop */ }
|
||||
termRef.current = null;
|
||||
wsRef.current = null;
|
||||
fitRef.current = null;
|
||||
setConnState('idle');
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, clusterId, podName]);
|
||||
|
||||
// 容器 / shell 变化时重连(需 term 已就绪)
|
||||
useEffect(() => {
|
||||
if (open && termRef.current) {
|
||||
connect();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [container, shell]);
|
||||
|
||||
// ResizeObserver 监听容器变化自动 fit + 透传 resize
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const containerEl = containerRef.current;
|
||||
if (!containerEl) return;
|
||||
|
||||
let resizeTimer: ReturnType<typeof setTimeout>;
|
||||
const ro = new ResizeObserver(() => {
|
||||
clearTimeout(resizeTimer);
|
||||
resizeTimer = setTimeout(() => {
|
||||
if (fitRef.current && termRef.current && wsRef.current) {
|
||||
try {
|
||||
fitRef.current.fit();
|
||||
termRef.current.focus();
|
||||
const { cols, rows } = termRef.current;
|
||||
if (wsRef.current.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({ status: 1, cols, rows }));
|
||||
}
|
||||
} catch { /* noop */ }
|
||||
}
|
||||
}, 60);
|
||||
});
|
||||
|
||||
ro.observe(containerEl);
|
||||
return () => {
|
||||
clearTimeout(resizeTimer);
|
||||
ro.disconnect();
|
||||
};
|
||||
}, [open, fullscreen]);
|
||||
|
||||
const statusColor = connState === 'connected' ? 'bg-emerald-400'
|
||||
: connState === 'error' ? 'bg-red-400'
|
||||
: connState === 'closed' ? 'bg-slate-500'
|
||||
: 'bg-amber-400';
|
||||
const statusText = connState === 'connected' ? '已连接'
|
||||
: connState === 'connecting' ? '连接中'
|
||||
: connState === 'error' ? '连接错误'
|
||||
: connState === 'closed' ? '已断开'
|
||||
: '空闲';
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<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: '960px', width: '92vw' }}
|
||||
>
|
||||
<DialogTitle className="sr-only">exec — {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', statusColor)} title={statusText} />
|
||||
<Terminal className="h-4 w-4 text-blue-400 shrink-0" />
|
||||
<span className="text-[var(--dg-text-primary)] font-medium truncate">
|
||||
exec — {podName}
|
||||
</span>
|
||||
{connState === 'connecting' && <Loader2 className="h-3 w-3 animate-spin text-[var(--dg-text-muted)]" />}
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">{statusText}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 容器选择 */}
|
||||
{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>
|
||||
)}
|
||||
|
||||
{/* Shell 选择 */}
|
||||
<Select value={shell} onValueChange={setShell}>
|
||||
<SelectTrigger className="h-8 w-[100px] text-xs bg-[var(--dg-bg-hover)] border-[var(--dg-border)]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="bash">/bin/bash</SelectItem>
|
||||
<SelectItem value="sh">/bin/sh</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 重连 */}
|
||||
<Button
|
||||
variant="ghost" size="sm"
|
||||
className="h-8 px-2 text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)]"
|
||||
onClick={() => { connect(); toast({ title: '正在重连…' }); }}
|
||||
title="重连"
|
||||
>
|
||||
<RotateCw className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
|
||||
<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={() => onOpenChange(false)}
|
||||
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
|
||||
ref={containerRef}
|
||||
className="bg-[#0f172a]"
|
||||
style={{ height: fullscreen ? 'calc(100vh - 49px)' : '480px' }}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,552 @@
|
||||
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Folder, FolderOpen, FileText, FileCode, FileArchive, FileImage,
|
||||
File, Download, Eye, ChevronRight, ArrowLeft, ArrowRight, ArrowUp,
|
||||
RotateCw, Loader2, Maximize2, Minimize2, X, Box, Home, HardDrive,
|
||||
AlertCircle, FileType, Binary,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { kubernetesApi } from '@/services/api';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
|
||||
/**
|
||||
* 容器文件浏览器 — 浏览 Pod 内文件系统
|
||||
*
|
||||
* - 调 POST .../pods/:pod/files/list {path:"/"} 获取目录
|
||||
* - 调 GET .../pods/:pod/files/download?path=/xxx 下载文件
|
||||
* - 面包屑导航 + 返回上级
|
||||
* - 文件类型图标
|
||||
* - 下载 / 查看文本(小文件预览)
|
||||
* - 浏览器风格历史栈(前进 / 后退)
|
||||
*
|
||||
* 注:后端文件浏览器 API 并行开发中,UI 与 API 层先行;
|
||||
* 返回结构兼容 {entries:[{name,isDir,size,modTime}]} 或数组。
|
||||
*
|
||||
* @author Logan <admin@attacker.club>
|
||||
*/
|
||||
interface PodFileBrowserProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
clusterCode: string;
|
||||
namespace: string;
|
||||
podName: string;
|
||||
containers?: string[];
|
||||
initialContainer?: string;
|
||||
}
|
||||
|
||||
interface FileEntry {
|
||||
name: string;
|
||||
isDir: boolean;
|
||||
size?: number;
|
||||
modTime?: string;
|
||||
mode?: string;
|
||||
}
|
||||
|
||||
// 文本预览大小阈值(超过则提示下载)
|
||||
const TEXT_PREVIEW_MAX = 256 * 1024; // 256KB
|
||||
// 可预览的扩展名
|
||||
const TEXT_EXTENSIONS = new Set([
|
||||
'.txt', '.log', '.conf', '.cfg', '.ini', '.yaml', '.yml', '.json',
|
||||
'.xml', '.md', '.sh', '.bash', '.env', '.properties', '.toml',
|
||||
'.js', '.ts', '.jsx', '.tsx', '.py', '.go', '.java', '.rs', '.c',
|
||||
'.cpp', '.h', '.hpp', '.css', '.html', '.sql', '.csv', '.gitignore',
|
||||
'.dockerfile', '.makefile',
|
||||
]);
|
||||
|
||||
function normalizePath(p: string): string {
|
||||
if (!p) return '/';
|
||||
// 去除重复斜杠
|
||||
let s = p.replace(/\/+/g, '/');
|
||||
if (!s.startsWith('/')) s = '/' + s;
|
||||
if (s.length > 1 && s.endsWith('/')) s = s.slice(0, -1);
|
||||
return s;
|
||||
}
|
||||
|
||||
function joinPath(base: string, name: string): string {
|
||||
return normalizePath(base === '/' ? `/${name}` : `${base}/${name}`);
|
||||
}
|
||||
|
||||
function parentPath(p: string): string {
|
||||
const n = normalizePath(p);
|
||||
if (n === '/') return '/';
|
||||
const idx = n.lastIndexOf('/');
|
||||
return idx <= 0 ? '/' : n.slice(0, idx);
|
||||
}
|
||||
|
||||
function pathSegments(p: string): { name: string; path: string }[] {
|
||||
const n = normalizePath(p);
|
||||
const segs = [{ name: '/', path: '/' }];
|
||||
if (n === '/') return segs;
|
||||
const parts = n.split('/').filter(Boolean);
|
||||
let cur = '';
|
||||
for (const part of parts) {
|
||||
cur = cur === '' ? `/${part}` : `${cur}/${part}`;
|
||||
segs.push({ name: part, path: cur });
|
||||
}
|
||||
return segs;
|
||||
}
|
||||
|
||||
function getExt(name: string): string {
|
||||
const idx = name.lastIndexOf('.');
|
||||
if (idx === -1) return '';
|
||||
// 处理 .gitignore 这类
|
||||
if (idx === 0) return name.toLowerCase();
|
||||
return name.slice(idx).toLowerCase();
|
||||
}
|
||||
|
||||
function getFileIcon(name: string, isDir: boolean) {
|
||||
if (isDir) return Folder;
|
||||
const ext = getExt(name);
|
||||
if (['.yaml', '.yml', '.json', '.xml', '.toml', '.ini', '.conf', '.cfg', '.env', '.properties'].includes(ext))
|
||||
return FileCode;
|
||||
if (['.js', '.ts', '.jsx', '.tsx', '.py', '.go', '.java', '.rs', '.c', '.cpp', '.h', '.hpp', '.sh', '.bash', '.sql'].includes(ext))
|
||||
return FileCode;
|
||||
if (['.log', '.txt', '.md', '.csv'].includes(ext)) return FileText;
|
||||
if (['.tar', '.gz', '.zip', '.tgz', '.bz2', '.xz', '.7z', '.rar'].includes(ext)) return FileArchive;
|
||||
if (['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.bmp', '.ico'].includes(ext)) return FileImage;
|
||||
if (name.toLowerCase() === 'dockerfile' || ext === '.dockerfile') return FileType;
|
||||
return File;
|
||||
}
|
||||
|
||||
function formatSize(bytes?: number): string {
|
||||
if (bytes === undefined || bytes === null) return '-';
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
export function PodFileBrowser({
|
||||
open,
|
||||
onOpenChange,
|
||||
clusterCode,
|
||||
namespace,
|
||||
podName,
|
||||
containers = [],
|
||||
initialContainer,
|
||||
}: PodFileBrowserProps) {
|
||||
const { toast } = useToast();
|
||||
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
const [container, setContainer] = useState<string>(initialContainer || containers[0] || '');
|
||||
|
||||
const [entries, setEntries] = useState<FileEntry[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// 浏览器风格历史栈
|
||||
const [historyStack, setHistoryStack] = useState<string[]>(['/']);
|
||||
const [historyIndex, setHistoryIndex] = useState(0);
|
||||
const currentPath = historyStack[historyIndex] || '/';
|
||||
|
||||
// 预览
|
||||
const [preview, setPreview] = useState<{ path: string; content: string; loading: boolean } | null>(null);
|
||||
|
||||
// 选中的条目(用于高亮)
|
||||
const [selected, setSelected] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) setFullscreen(false);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && !container && containers.length > 0) {
|
||||
setContainer(initialContainer || containers[0]);
|
||||
}
|
||||
}, [open, containers, initialContainer, container]);
|
||||
|
||||
// 加载目录
|
||||
const loadPath = useCallback(async (path: string) => {
|
||||
if (!podName) return;
|
||||
const n = normalizePath(path);
|
||||
setLoading(true);
|
||||
setError('');
|
||||
setSelected('');
|
||||
try {
|
||||
const res: any = await kubernetesApi.listPodFiles(clusterCode, namespace, podName, n, container || undefined);
|
||||
// 兼容多种返回结构
|
||||
let arr: any[] = [];
|
||||
if (Array.isArray(res)) arr = res;
|
||||
else if (Array.isArray(res?.entries)) arr = res.entries;
|
||||
else if (Array.isArray(res?.items)) arr = res.items;
|
||||
else if (Array.isArray(res?.files)) arr = res.files;
|
||||
else if (Array.isArray(res?.data)) arr = res.data;
|
||||
else if (res?.data && Array.isArray(res.data.entries)) arr = res.data.entries;
|
||||
|
||||
const list: FileEntry[] = arr.map((it: any) => ({
|
||||
name: it.name ?? it.Name ?? it.path?.split('/').pop() ?? '',
|
||||
isDir: Boolean(it.isDir ?? it.IsDir ?? it.dir ?? it.directory ?? (it.mode?.startsWith('d'))),
|
||||
size: it.size ?? it.Size,
|
||||
modTime: it.modTime ?? it.ModTime ?? it.mtime ?? it.modified,
|
||||
mode: it.mode ?? it.Mode,
|
||||
})).filter((e) => e.name && e.name !== '.');
|
||||
|
||||
// 排序:目录优先,再按名称
|
||||
list.sort((a, b) => {
|
||||
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
setEntries(list);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || String(e));
|
||||
setEntries([]);
|
||||
// 后端 API 并行开发中,错误时给出友好提示
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [clusterCode, namespace, podName, container]);
|
||||
|
||||
// 打开 / 路径变化时加载
|
||||
useEffect(() => {
|
||||
if (open && podName) {
|
||||
loadPath(currentPath);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, podName, currentPath, container]);
|
||||
|
||||
// 浏览器风格导航
|
||||
const navigateTo = useCallback((path: string) => {
|
||||
const n = normalizePath(path);
|
||||
setHistoryStack((stack) => {
|
||||
const next = stack.slice(0, historyIndex + 1);
|
||||
// 避免重复
|
||||
if (next[next.length - 1] !== n) next.push(n);
|
||||
return next;
|
||||
});
|
||||
setHistoryIndex((i) => i + 1);
|
||||
}, [historyIndex]);
|
||||
|
||||
const goBack = useCallback(() => {
|
||||
if (historyIndex > 0) {
|
||||
setHistoryIndex(historyIndex - 1);
|
||||
}
|
||||
}, [historyIndex]);
|
||||
|
||||
const goForward = useCallback(() => {
|
||||
if (historyIndex < historyStack.length - 1) {
|
||||
setHistoryIndex(historyIndex + 1);
|
||||
}
|
||||
}, [historyIndex, historyStack.length]);
|
||||
|
||||
const goUp = useCallback(() => {
|
||||
navigateTo(parentPath(currentPath));
|
||||
}, [currentPath, navigateTo]);
|
||||
|
||||
const canGoBack = historyIndex > 0;
|
||||
const canGoForward = historyIndex < historyStack.length - 1;
|
||||
|
||||
// 进入目录 / 预览文件
|
||||
const handleEntryClick = useCallback((entry: FileEntry) => {
|
||||
setSelected(entry.name);
|
||||
const fullPath = joinPath(currentPath, entry.name);
|
||||
if (entry.isDir) {
|
||||
navigateTo(fullPath);
|
||||
} else {
|
||||
// 双击或点击预览文本文件
|
||||
const ext = getExt(entry.name);
|
||||
const isText = TEXT_EXTENSIONS.has(ext) || (entry.size ?? 0) <= TEXT_PREVIEW_MAX && !Binary;
|
||||
if (isText && (entry.size ?? 0) <= TEXT_PREVIEW_MAX) {
|
||||
previewFile(fullPath);
|
||||
} else if ((entry.size ?? 0) > TEXT_PREVIEW_MAX && isText) {
|
||||
toast({ title: '文件较大', description: `超过 ${formatSize(TEXT_PREVIEW_MAX)},建议下载查看`, variant: 'default' });
|
||||
}
|
||||
}
|
||||
}, [currentPath, navigateTo, toast]);
|
||||
|
||||
// 预览文本文件
|
||||
const previewFile = useCallback(async (path: string) => {
|
||||
setPreview({ path, content: '', loading: true });
|
||||
try {
|
||||
const text = await kubernetesApi.readPodFileText(clusterCode, namespace, podName, path, container || undefined);
|
||||
setPreview({ path, content: text, loading: false });
|
||||
} catch (e: any) {
|
||||
setPreview({ path, content: `[预览失败: ${e?.message || String(e)}]`, loading: false });
|
||||
toast({ title: '预览失败', description: e?.message || String(e), variant: 'destructive' });
|
||||
}
|
||||
}, [clusterCode, namespace, podName, container, toast]);
|
||||
|
||||
// 下载文件
|
||||
const handleDownload = useCallback((path: string) => {
|
||||
const url = kubernetesApi.buildPodFileDownloadUrl(clusterCode, namespace, podName, path, container || undefined);
|
||||
window.open(url, '_blank');
|
||||
toast({ title: '已开始下载', description: path });
|
||||
}, [clusterCode, namespace, podName, container, toast]);
|
||||
|
||||
const breadcrumbs = useMemo(() => pathSegments(currentPath), [currentPath]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<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: '1000px', 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">
|
||||
<HardDrive 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)]">{entries.length} 项</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{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>
|
||||
)}
|
||||
<Button variant="ghost" size="sm" className="h-8 px-2" onClick={() => loadPath(currentPath)} title="刷新">
|
||||
<RotateCw className={cn('h-3.5 w-3.5', loading && 'animate-spin')} />
|
||||
</Button>
|
||||
<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={() => onOpenChange(false)}
|
||||
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-1 px-4 py-2 border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)] flex-wrap">
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" disabled={!canGoBack} onClick={goBack} title="后退">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" disabled={!canGoForward} onClick={goForward} title="前进">
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" disabled={currentPath === '/'} onClick={goUp} title="返回上级">
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => navigateTo('/')} title="根目录">
|
||||
<Home className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{/* 面包屑 */}
|
||||
<div className="flex items-center gap-0.5 ml-2 flex-1 min-w-0 overflow-x-auto">
|
||||
{breadcrumbs.map((seg, i) => (
|
||||
<div key={seg.path} className="flex items-center shrink-0">
|
||||
{i > 0 && <ChevronRight className="h-3 w-3 text-[var(--dg-text-muted)] mx-0.5" />}
|
||||
<button
|
||||
className={cn(
|
||||
'px-1.5 py-0.5 rounded text-xs hover:bg-[var(--dg-bg-hover)]',
|
||||
i === breadcrumbs.length - 1
|
||||
? 'text-[var(--dg-text-primary)] font-medium'
|
||||
: 'text-[var(--dg-text-secondary)]'
|
||||
)}
|
||||
onClick={() => navigateTo(seg.path)}
|
||||
>
|
||||
{seg.name === '/' ? <Home className="h-3 w-3" /> : seg.name}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-xs text-[var(--dg-text-muted)] font-mono shrink-0">{currentPath}</span>
|
||||
</div>
|
||||
|
||||
{/* 主体:左列表 / 右预览 */}
|
||||
<div className="flex" style={{ height: fullscreen ? 'calc(100vh - 98px)' : '480px' }}>
|
||||
{/* 文件列表 */}
|
||||
<div className={cn('flex-1 overflow-auto', preview && 'border-r border-[var(--dg-border)]')}>
|
||||
{error && (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center p-6">
|
||||
<AlertCircle className="h-8 w-8 text-amber-400 mb-2" />
|
||||
<p className="text-sm text-[var(--dg-text-primary)] mb-1">加载失败</p>
|
||||
<p className="text-xs text-[var(--dg-text-muted)] mb-3 max-w-md">{error}</p>
|
||||
<p className="text-xs text-[var(--dg-text-muted)] mb-3 max-w-md">
|
||||
文件浏览器 API 仍在并行开发中,请确认后端 <code className="px-1 bg-[var(--dg-bg-hover)] rounded">POST .../files/list</code> 已就绪。
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={() => loadPath(currentPath)}>
|
||||
<RotateCw className="h-3.5 w-3.5 mr-1" />重试
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && loading && entries.length === 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>
|
||||
)}
|
||||
|
||||
{!error && !loading && entries.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center h-full text-[var(--dg-text-muted)]">
|
||||
<FolderOpen className="h-8 w-8 mb-2 opacity-50" />
|
||||
<span className="text-sm">空目录</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && entries.length > 0 && (
|
||||
<table className="w-full text-sm">
|
||||
<thead className="sticky top-0 bg-[var(--dg-bg-secondary)] border-b border-[var(--dg-border)]">
|
||||
<tr className="text-xs text-[var(--dg-text-muted)]">
|
||||
<th className="text-left font-normal px-3 py-1.5">名称</th>
|
||||
<th className="text-right font-normal px-3 py-1.5 w-28">大小</th>
|
||||
<th className="text-left font-normal px-3 py-1.5 w-40">修改时间</th>
|
||||
<th className="text-right font-normal px-3 py-1.5 w-28">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{currentPath !== '/' && (
|
||||
<tr
|
||||
className="border-b border-[var(--dg-border)]/50 hover:bg-[var(--dg-bg-hover)] cursor-pointer"
|
||||
onClick={goUp}
|
||||
>
|
||||
<td className="px-3 py-1.5 text-[var(--dg-text-secondary)] flex items-center gap-2">
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
<span className="italic">..</span>
|
||||
</td>
|
||||
<td className="px-3 py-1.5"></td>
|
||||
<td className="px-3 py-1.5"></td>
|
||||
<td className="px-3 py-1.5"></td>
|
||||
</tr>
|
||||
)}
|
||||
{entries.map((entry) => {
|
||||
const Icon = getFileIcon(entry.name, entry.isDir);
|
||||
const fullPath = joinPath(currentPath, entry.name);
|
||||
const isSelected = selected === entry.name;
|
||||
return (
|
||||
<tr
|
||||
key={entry.name}
|
||||
className={cn(
|
||||
'border-b border-[var(--dg-border)]/50 hover:bg-[var(--dg-bg-hover)] cursor-pointer',
|
||||
isSelected && 'bg-[var(--dg-bg-hover)]'
|
||||
)}
|
||||
onClick={() => handleEntryClick(entry)}
|
||||
>
|
||||
<td className="px-3 py-1.5">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Icon className={cn(
|
||||
'h-4 w-4 shrink-0',
|
||||
entry.isDir ? 'text-blue-400' : 'text-[var(--dg-text-muted)]'
|
||||
)} />
|
||||
<span className={cn(
|
||||
'truncate',
|
||||
entry.isDir ? 'text-[var(--dg-text-primary)] font-medium' : 'text-[var(--dg-text-secondary)]'
|
||||
)} title={entry.name}>
|
||||
{entry.name}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-right text-xs text-[var(--dg-text-muted)] font-mono">
|
||||
{entry.isDir ? '-' : formatSize(entry.size)}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-xs text-[var(--dg-text-muted)] font-mono">
|
||||
{entry.modTime || '-'}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-right" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-end gap-0.5">
|
||||
{!entry.isDir && (
|
||||
<>
|
||||
<button
|
||||
className="p-1 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-muted)] hover:text-blue-400"
|
||||
title="预览文本"
|
||||
onClick={() => previewFile(fullPath)}
|
||||
>
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
className="p-1 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-muted)] hover:text-emerald-400"
|
||||
title={entry.isDir ? '目录不支持下载' : '下载'}
|
||||
disabled={entry.isDir}
|
||||
onClick={() => !entry.isDir && handleDownload(fullPath)}
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 预览面板 */}
|
||||
{preview && (
|
||||
<div className="flex flex-col w-[45%] min-w-[300px] bg-[#0f172a]">
|
||||
<div className="flex items-center justify-between px-3 py-1.5 border-b border-[var(--dg-border)]">
|
||||
<div className="flex items-center gap-2 text-xs min-w-0">
|
||||
<Eye className="h-3.5 w-3.5 text-blue-400 shrink-0" />
|
||||
<span className="text-[var(--dg-text-primary)] truncate font-mono" title={preview.path}>
|
||||
{preview.path}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5 shrink-0">
|
||||
<button
|
||||
className="p-1 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-muted)] hover:text-emerald-400"
|
||||
title="下载"
|
||||
onClick={() => handleDownload(preview.path)}
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
className="p-1 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)]"
|
||||
title="关闭预览"
|
||||
onClick={() => setPreview(null)}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto p-3">
|
||||
{preview.loading ? (
|
||||
<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>
|
||||
) : (
|
||||
<pre className="text-xs text-slate-300 font-mono whitespace-pre-wrap break-all">
|
||||
{preview.content || '(空文件)'}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { X, Copy, Check, Save, RefreshCw, Loader2 } from 'lucide-react';
|
||||
import { X, Copy, Check, Save, RefreshCw, Loader2, Sparkles } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { kubernetesApi } from '@/services/api';
|
||||
import { useUserStore } from '@/store';
|
||||
import { cleanYAML } from './k8s-yaml-utils';
|
||||
|
||||
interface YamlViewerProps {
|
||||
clusterCode: string;
|
||||
@@ -12,11 +12,14 @@ interface YamlViewerProps {
|
||||
name: string;
|
||||
onClose: () => void;
|
||||
onSaved?: () => void;
|
||||
/** 进入编辑模式前是否清理只读/噪音字段(managedFields/uid/status 等) */
|
||||
cleanBeforeEdit?: boolean;
|
||||
}
|
||||
|
||||
export function YamlViewer({ clusterCode, namespace, resource, name, onClose, onSaved }: YamlViewerProps) {
|
||||
export function YamlViewer({ clusterCode, namespace, resource, name, onClose, onSaved, cleanBeforeEdit = false }: YamlViewerProps) {
|
||||
const { toast } = useToast();
|
||||
const [yaml, setYaml] = useState('');
|
||||
const [rawYaml, setRawYaml] = useState(''); // 原始(只读展示用)
|
||||
const [editYaml, setEditYaml] = useState(''); // 编辑态(可能被清理过)
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [editing, setEditing] = useState(false);
|
||||
@@ -25,95 +28,108 @@ export function YamlViewer({ clusterCode, namespace, resource, name, onClose, on
|
||||
const loadYaml = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const tk = useUserStore.getState().token || '';
|
||||
const baseUrl = import.meta.env.VITE_API_BASE_URL || '';
|
||||
const url = `${baseUrl}/api/v1/kubernetes/${clusterCode}/namespace/${namespace}/${resource}/${name}/yaml`;
|
||||
const resp = await fetch(url, { headers: { Authorization: `Bearer ${tk}` } });
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const text = await resp.text();
|
||||
setYaml(text);
|
||||
const text = await kubernetesApi.getResourceYaml(clusterCode, namespace, resource, name);
|
||||
setRawYaml(text);
|
||||
setEditYaml(text);
|
||||
} catch (e: any) {
|
||||
toast({ title: '获取YAML失败', description: e.message, variant: 'destructive' });
|
||||
setYaml(`# Error: ${e.message}`);
|
||||
const err = `# Error: ${e.message}`;
|
||||
setRawYaml(err);
|
||||
setEditYaml(err);
|
||||
} finally { setLoading(false); }
|
||||
};
|
||||
|
||||
const enterEdit = () => {
|
||||
// 进入编辑前可选清理
|
||||
const cleaned = cleanBeforeEdit ? cleanYAML(rawYaml) : rawYaml;
|
||||
setEditYaml(cleaned);
|
||||
setEditing(true);
|
||||
if (cleanBeforeEdit) {
|
||||
toast({ title: '已清理只读字段', description: '已剥离 status / managedFields / uid 等' });
|
||||
}
|
||||
};
|
||||
|
||||
const saveYaml = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const tk = useUserStore.getState().token || '';
|
||||
const baseUrl = import.meta.env.VITE_API_BASE_URL || '';
|
||||
const url = `${baseUrl}/api/v1/kubernetes/${clusterCode}/namespace/${namespace}/${resource}/${name}/yaml`;
|
||||
const resp = await fetch(url, {
|
||||
method: 'PUT',
|
||||
headers: { Authorization: `Bearer ${tk}`, 'Content-Type': 'application/yaml' },
|
||||
body: yaml,
|
||||
});
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
toast({ title: 'YAML已应用', description: `${resource}/${name} 更新成功` });
|
||||
setEditing(false);
|
||||
onSaved?.();
|
||||
} catch (e: any) {
|
||||
toast({ title: '保存失败', description: e.message, variant: 'destructive' });
|
||||
} finally { setSaving(false); }
|
||||
};
|
||||
await kubernetesApi.updateResourceYaml(clusterCode, namespace, resource, name, editYaml);
|
||||
toast({ title: 'YAML已应用', description: `${resource}/${name} 更新成功` });
|
||||
setEditing(false);
|
||||
setRawYaml(editYaml);
|
||||
onSaved?.();
|
||||
} catch (e: any) {
|
||||
toast({ title: '保存失败', description: e.message, variant: 'destructive' });
|
||||
} finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const copyYaml = async () => {
|
||||
await navigator.clipboard.writeText(yaml);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
const copyYaml = async () => {
|
||||
const content = editing ? editYaml : rawYaml;
|
||||
await navigator.clipboard.writeText(content);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
useEffect(() => { loadYaml(); }, []);
|
||||
useEffect(() => { loadYaml(); }, []);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={onClose}>
|
||||
<div className="bg-slate-900 border border-slate-700 rounded-xl w-[90vw] max-w-4xl max-h-[80vh] flex flex-col shadow-2xl" onClick={e => e.stopPropagation()}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-slate-700">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-slate-100">{resource}/{name}</h2>
|
||||
<p className="text-xs text-slate-500">{namespace} · {clusterCode}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" className="h-8 border-slate-700 text-slate-300" onClick={copyYaml}>
|
||||
{copied ? <Check className="h-3 w-3 mr-1 text-emerald-400" /> : <Copy className="h-3 w-3 mr-1" />}
|
||||
{copied ? '已复制' : '复制'}
|
||||
</Button>
|
||||
{!editing ? (
|
||||
<Button variant="outline" size="sm" className="h-8 border-slate-700 text-slate-300" onClick={() => setEditing(true)}>
|
||||
编辑
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="outline" size="sm" className="h-8 border-slate-700 text-slate-300" onClick={() => { setEditing(false); loadYaml(); }}>
|
||||
取消
|
||||
</Button>
|
||||
<Button size="sm" className="h-8 bg-blue-600 hover:bg-blue-700" onClick={saveYaml} disabled={saving}>
|
||||
{saving ? <Loader2 className="h-3 w-3 animate-spin mr-1" /> : <Save className="h-3 w-3 mr-1" />}
|
||||
保存
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400" onClick={onClose}><X className="h-4 w-4" /></Button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-full"><Loader2 className="h-6 w-6 animate-spin text-slate-500" /></div>
|
||||
) : editing ? (
|
||||
<textarea
|
||||
value={yaml}
|
||||
onChange={e => setYaml(e.target.value)}
|
||||
className="w-full h-full min-h-[500px] bg-slate-950 text-emerald-400 font-mono text-xs p-4 rounded border border-slate-700 focus:outline-none focus:border-blue-500 resize-none"
|
||||
spellCheck={false}
|
||||
/>
|
||||
) : (
|
||||
<pre className="text-emerald-400 font-mono text-xs whitespace-pre overflow-x-auto">{yaml}</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
const display = editing ? editYaml : rawYaml;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={onClose}>
|
||||
<div className="bg-slate-900 border border-slate-700 rounded-xl w-[90vw] max-w-4xl max-h-[80vh] flex flex-col shadow-2xl" onClick={e => e.stopPropagation()}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-slate-700">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-slate-100">{resource}/{name}</h2>
|
||||
<p className="text-xs text-slate-500">{namespace} · {clusterCode}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" className="h-8 border-slate-700 text-slate-300" onClick={copyYaml}>
|
||||
{copied ? <Check className="h-3 w-3 mr-1 text-emerald-400" /> : <Copy className="h-3 w-3 mr-1" />}
|
||||
{copied ? '已复制' : '复制'}
|
||||
</Button>
|
||||
{!editing ? (
|
||||
<>
|
||||
{cleanBeforeEdit && (
|
||||
<Button variant="outline" size="sm" className="h-8 border-slate-700 text-slate-300" onClick={enterEdit} title="清理 status / managedFields 等只读字段后编辑">
|
||||
<Sparkles className="h-3 w-3 mr-1 text-amber-400" />清理并编辑
|
||||
</Button>
|
||||
)}
|
||||
{!cleanBeforeEdit && (
|
||||
<Button variant="outline" size="sm" className="h-8 border-slate-700 text-slate-300" onClick={() => setEditing(true)}>
|
||||
编辑
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="outline" size="sm" className="h-8 border-slate-700 text-slate-300" onClick={() => { setEditing(false); loadYaml(); }}>
|
||||
取消
|
||||
</Button>
|
||||
<Button size="sm" className="h-8 bg-blue-600 hover:bg-blue-700" onClick={saveYaml} disabled={saving}>
|
||||
{saving ? <Loader2 className="h-3 w-3 animate-spin mr-1" /> : <Save className="h-3 w-3 mr-1" />}
|
||||
保存
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400" onClick={onClose}><X className="h-4 w-4" /></Button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-full"><Loader2 className="h-6 w-6 animate-spin text-slate-500" /></div>
|
||||
) : editing ? (
|
||||
<textarea
|
||||
value={editYaml}
|
||||
onChange={e => setEditYaml(e.target.value)}
|
||||
className="w-full h-full min-h-[500px] bg-slate-950 text-emerald-400 font-mono text-xs p-4 rounded border border-slate-700 focus:outline-none focus:border-blue-500 resize-none"
|
||||
spellCheck={false}
|
||||
/>
|
||||
) : (
|
||||
<pre className="text-emerald-400 font-mono text-xs whitespace-pre overflow-x-auto">{display}</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
// k8s-yaml-utils.ts — YAML 编辑前清理工具(借鉴 opsx-agent cleanYAML 设计)
|
||||
// 不依赖 js-yaml;基于文本行级解析,针对 K8s 标准 manifest 结构清理。
|
||||
// 作者: Logan <admin@attacker.club>
|
||||
|
||||
/**
|
||||
* 编辑前清理 YAML 文本:
|
||||
* - 移除 status 整块(K8s 服务端字段)
|
||||
* - 移除 metadata 中的只读/噪音字段:uid / resourceVersion / managedFields /
|
||||
* creationTimestamp / generation / selfLink / ownerReferences / resourceVersion
|
||||
* - 移除 annotations 中的 kubectl.kubernetes.io/last-applied-configuration
|
||||
* - 规范化行尾,保留可读缩进
|
||||
*/
|
||||
export function cleanYAML(yaml: string): string {
|
||||
if (!yaml) return '';
|
||||
const lines = yaml.replace(/\r\n/g, '\n').split('\n');
|
||||
const out: string[] = [];
|
||||
let i = 0;
|
||||
|
||||
// metadata 下需要整块移除的字段(值为多行块或对象)
|
||||
const METADATA_BLOCK_KEYS = new Set([
|
||||
'managedFields',
|
||||
'ownerReferences',
|
||||
]);
|
||||
// metadata 下需要单行移除的字段
|
||||
const METADATA_LINE_KEYS = new Set([
|
||||
'uid',
|
||||
'resourceVersion',
|
||||
'creationTimestamp',
|
||||
'generation',
|
||||
'selfLink',
|
||||
'clusterName',
|
||||
'managedFields', // 兜底
|
||||
]);
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
const trimmed = line.trimStart();
|
||||
const indent = line.length - trimmed.length;
|
||||
|
||||
// 1) 顶层 status: 整块移除(indent === 0)
|
||||
if (indent === 0 && /^status\s*:/.test(trimmed)) {
|
||||
i++;
|
||||
// 跳过所有后续属于 status 块的行(缩进 > 0 或空行),直到下一个顶层 key
|
||||
while (i < lines.length) {
|
||||
const l = lines[i];
|
||||
const t = l.trimStart();
|
||||
if (t === '' || t.startsWith('#')) { i++; continue; }
|
||||
const ind = l.length - t.length;
|
||||
if (ind === 0) break; // 回到顶层
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// 2) metadata 子字段清理
|
||||
// 匹配形如 " uid: xxx" 或 " managedFields:" 等
|
||||
const metaKeyMatch = trimmed.match(/^([a-zA-Z_][a-zA-Z0-9_-]*)\s*:/);
|
||||
if (metaKeyMatch && indent >= 2 && indent <= 6) {
|
||||
const key = metaKeyMatch[1];
|
||||
|
||||
// 单行移除
|
||||
if (METADATA_LINE_KEYS.has(key)) {
|
||||
// 若该行值非空(如 "uid: xxx"),直接跳过该行
|
||||
// 若该行值为空(如 "managedFields:"),需要跳过其下属缩进块
|
||||
const afterColon = trimmed.slice(metaKeyMatch[0].length).trim();
|
||||
if (afterColon === '') {
|
||||
i++;
|
||||
while (i < lines.length) {
|
||||
const l = lines[i];
|
||||
const t = l.trimStart();
|
||||
if (t === '' || t.startsWith('#')) { i++; continue; }
|
||||
const ind = l.length - t.length;
|
||||
if (ind <= indent) break;
|
||||
i++;
|
||||
}
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// managedFields 块移除(即使有内联内容也整块跳过)
|
||||
if (METADATA_BLOCK_KEYS.has(key)) {
|
||||
i++;
|
||||
while (i < lines.length) {
|
||||
const l = lines[i];
|
||||
const t = l.trimStart();
|
||||
if (t === '' || t.startsWith('#')) { i++; continue; }
|
||||
const ind = l.length - t.length;
|
||||
if (ind <= indent) break;
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 3) annotations 内 last-applied-configuration 移除
|
||||
// 形如 " kubectl.kubernetes.io/last-applied-configuration: >-"
|
||||
// 可能跨多行(block scalar)
|
||||
if (/kubectl\.kubernetes\.io\/last-applied-configuration\s*:/.test(trimmed)) {
|
||||
const afterColon = trimmed.split(':').slice(1).join(':').trim();
|
||||
// 多行 block scalar 标记:>- >- | |- 等
|
||||
const isBlockScalar = /^[>|]/.test(afterColon);
|
||||
i++;
|
||||
if (isBlockScalar) {
|
||||
while (i < lines.length) {
|
||||
const l = lines[i];
|
||||
const t = l.trimStart();
|
||||
if (t === '') { i++; continue; }
|
||||
const ind = l.length - t.length;
|
||||
if (ind <= indent) break;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
out.push(line);
|
||||
i++;
|
||||
}
|
||||
|
||||
// 末尾保持一个换行
|
||||
let result = out.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd();
|
||||
return result + '\n';
|
||||
}
|
||||
|
||||
/** 解析简单 "key: value" 行(顶层或指定缩进),用于表单回填兜底。 */
|
||||
export function parseScalar(line: string): { key: string; value: string } | null {
|
||||
const m = line.match(/^([A-Za-z_][\w-]*)\s*:\s*(.*)$/);
|
||||
if (!m) return null;
|
||||
return { key: m[1], value: m[2].trim().replace(/^["']|["']$/g, '') };
|
||||
}
|
||||
|
||||
/** 状态色(与 opsx-agent 一致) */
|
||||
export const STATUS_COLORS = {
|
||||
green: '#10b981',
|
||||
orange: '#f59e0b',
|
||||
red: '#ef4444',
|
||||
blue: '#326CE5', // Kubernetes 官方蓝
|
||||
} as const;
|
||||
|
||||
/** 根据 Pod/Deployment 状态返回对应色值 */
|
||||
export function statusColor(status: string | undefined): string {
|
||||
if (!status) return '#64748b';
|
||||
const s = status.toLowerCase();
|
||||
if (['running', 'available', 'ready', 'active', 'completed', 'succeeded'].includes(s)) return STATUS_COLORS.green;
|
||||
if (['pending', 'updating', 'progressing', 'containercreating'].includes(s)) return STATUS_COLORS.orange;
|
||||
if (['failed', 'error', 'crashloopbackoff', 'imagepullbackoff', 'errimageneverpull'].includes(s)) return STATUS_COLORS.red;
|
||||
return STATUS_COLORS.blue;
|
||||
}
|
||||
|
||||
/** 按镜像聚合 ReplicaSet / Revision 列表(同镜像合并展示) */
|
||||
export function aggregateByImage<T extends { image?: string; images?: string[] }>(items: T[]): Array<T & { _count: number }> {
|
||||
const map = new Map<string, T & { _count: number }>();
|
||||
for (const it of items) {
|
||||
const img = it.image || (Array.isArray(it.images) ? it.images[0] : '') || 'unknown';
|
||||
const key = img;
|
||||
const existing = map.get(key);
|
||||
if (existing) {
|
||||
existing._count += 1;
|
||||
} else {
|
||||
map.set(key, { ...it, _count: 1 });
|
||||
}
|
||||
}
|
||||
return Array.from(map.values());
|
||||
}
|
||||
|
||||
/** 格式化 K8s 时间戳(ISO / RFC3339)为本地可读格式 */
|
||||
export function formatK8sTime(ts: string | undefined | null): string {
|
||||
if (!ts) return '-';
|
||||
const d = new Date(ts);
|
||||
if (isNaN(d.getTime())) return ts;
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||
}
|
||||
|
||||
/** 计算两个时间戳之间的相对耗时(秒/分/时),用于"调度耗时""就绪耗时"展示 */
|
||||
export function elapsedSeconds(start?: string | null, end?: string | null): number | null {
|
||||
if (!start || !end) return null;
|
||||
const s = new Date(start).getTime();
|
||||
const e = new Date(end).getTime();
|
||||
if (isNaN(s) || isNaN(e) || e < s) return null;
|
||||
return Math.max(0, Math.round((e - s) / 1000));
|
||||
}
|
||||
|
||||
/** 把秒数格式化为 "1m23s" / "2h5m" / "45s" */
|
||||
export function formatDuration(sec: number | null | undefined): string {
|
||||
if (sec == null) return '-';
|
||||
if (sec < 60) return `${sec}s`;
|
||||
if (sec < 3600) return `${Math.floor(sec / 60)}m${sec % 60}s`;
|
||||
const h = Math.floor(sec / 3600);
|
||||
const m = Math.floor((sec % 3600) / 60);
|
||||
return `${h}h${m}m`;
|
||||
}
|
||||
Reference in new issue
Block a user