feat(k8s): M6 编辑器基础设施 + 基本信息 Tab + YAML编辑器
- DeploymentWorkload: 三模式切换(查看/编辑/YAML编辑)+ EditModeContainer Tab 导航系统 - DeploymentEditBasic: 新增基本信息 Tab — replicas/labels/annotations/selector 编辑 - YamlViewer: 升级为 CodeMirror 6(语法高亮+行号+折叠+括号匹配),新增 inline 内联模式 - DeploymentDetailPage: mode 类型扩展为 view|edit|yaml - 路由 :mode 参数自动支持 yaml 值 - M7-M9 Tab 预留占位(容器信息/存储/高级/服务)
This commit is contained in:
1 parent
2e66908e20
commit
68a954c1c5
9 files changed
+855
-42
No files matched your search
@@ -1,19 +1,21 @@
|
||||
// DeploymentDetailPage.tsx — 路由包装:解析 cluster/namespace/mode/name
|
||||
// 支持 Kuboard 风格双模式路由:
|
||||
// 支持 Kuboard 风格三模式路由:
|
||||
// view: /kubernetes/:c/namespace/:n/workload/view/Deployment/:name
|
||||
// edit: /kubernetes/:c/namespace/:n/workload/edit/Deployment/:name
|
||||
// yaml: /kubernetes/:c/namespace/:n/workload/yaml/Deployment/:name
|
||||
// 兼容旧路由: /kubernetes/:c/namespace/:n/deployments/:name(默认 view)
|
||||
// 作者: Logan <admin@attacker.club>
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { DeploymentWorkload } from './DeploymentWorkload';
|
||||
|
||||
export type WorkloadMode = 'view' | 'edit' | 'yaml';
|
||||
|
||||
export default function DeploymentDetailPage() {
|
||||
// 兼容两种路由参数命名:旧 :name 与新 :name(路径段不同)
|
||||
const params = useParams<{
|
||||
clusterCode: string;
|
||||
namespace: string;
|
||||
name: string;
|
||||
mode?: string; // 'view' | 'edit'(新路由)
|
||||
mode?: string; // 'view' | 'edit' | 'yaml'(新路由)
|
||||
workloadKind?: string; // 'Deployment'(新路由,预留扩展)
|
||||
}>();
|
||||
|
||||
@@ -21,10 +23,10 @@ export default function DeploymentDetailPage() {
|
||||
const clusterCode = params.clusterCode || '';
|
||||
const namespace = params.namespace || '';
|
||||
const deploymentName = params.name || '';
|
||||
const currentMode: 'view' | 'edit' = params.mode === 'edit' ? 'edit' : 'view';
|
||||
const currentMode: WorkloadMode = (params.mode === 'edit' || params.mode === 'yaml') ? params.mode : 'view';
|
||||
|
||||
// 模式切换:在 view/edit 路由间跳转
|
||||
const handleModeChange = (m: 'view' | 'edit') => {
|
||||
// 模式切换:在 view/edit/yaml 路由间跳转
|
||||
const handleModeChange = (m: WorkloadMode) => {
|
||||
if (m === currentMode) return;
|
||||
navigate(
|
||||
`/kubernetes/${clusterCode}/namespace/${namespace}/workload/${m}/Deployment/${deploymentName}`,
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
// DeploymentEditBasic.tsx — 编辑器「基本信息」Tab
|
||||
// 编辑内容:replicas / labels / annotations(name/namespace 只读)
|
||||
// 作者: Logan <admin@attacker.club>
|
||||
import { useState, useEffect, useCallback } 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 { Badge } from '@/components/ui/badge';
|
||||
import { Plus, Trash2, Save, Loader2, Tags, FileText, Hash, Lock } from 'lucide-react';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { kubernetesApi } from '@/services/api';
|
||||
import { cleanYAML } from './k8s-yaml-utils';
|
||||
|
||||
interface DeploymentEditBasicProps {
|
||||
clusterCode: string;
|
||||
namespace: string;
|
||||
deploymentName: string;
|
||||
/** 从后端拿到的 manifest 对象 */
|
||||
manifest: any | null;
|
||||
onSaved: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
/** 将 metadata.labels 对象转为 key-value 数组 */
|
||||
function objToKv(obj: Record<string, string> | undefined): Array<{ key: string; value: string }> {
|
||||
if (!obj || typeof obj !== 'object') return [];
|
||||
return Object.entries(obj).map(([key, value]) => ({ key, value: String(value) }));
|
||||
}
|
||||
|
||||
/** 将 key-value 数组转为对象(去重,空 key 跳过) */
|
||||
function kvToObj(arr: Array<{ key: string; value: string }>): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const { key, value } of arr) {
|
||||
const k = key.trim();
|
||||
if (k) out[k] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function DeploymentEditBasic({ clusterCode, namespace, deploymentName, manifest, onSaved, onCancel }: DeploymentEditBasicProps) {
|
||||
const { toast } = useToast();
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [loading, setLoading] = useState(!manifest);
|
||||
const [rawManifest, setRawManifest] = useState<any>(manifest || {});
|
||||
const [replicas, setReplicas] = useState(1);
|
||||
const [labels, setLabels] = useState<Array<{ key: string; value: string }>>([]);
|
||||
const [annotations, setAnnotations] = useState<Array<{ key: string; value: string }>>([]);
|
||||
const [selectorLabels, setSelectorLabels] = useState<Array<{ key: string; value: string }>>([]);
|
||||
|
||||
// 初始化数据
|
||||
useEffect(() => {
|
||||
const m = manifest;
|
||||
if (!m) { setLoading(false); return; }
|
||||
setRawManifest(m);
|
||||
setReplicas(m?.spec?.replicas ?? 1);
|
||||
setLabels(objToKv(m?.metadata?.labels));
|
||||
setAnnotations(objToKv(m?.metadata?.annotations));
|
||||
setSelectorLabels(objToKv(m?.spec?.selector?.matchLabels));
|
||||
setLoading(false);
|
||||
}, [manifest]);
|
||||
|
||||
// ============ Labels ============
|
||||
const updateLabel = (i: number, patch: Partial<{ key: string; value: string }>) => {
|
||||
setLabels(prev => prev.map((l, idx) => (idx === i ? { ...l, ...patch } : l)));
|
||||
};
|
||||
const addLabel = () => setLabels(prev => [...prev, { key: '', value: '' }]);
|
||||
const removeLabel = (i: number) => setLabels(prev => prev.filter((_, idx) => idx !== i));
|
||||
|
||||
// ============ Annotations ============
|
||||
const updateAnnotation = (i: number, patch: Partial<{ key: string; value: string }>) => {
|
||||
setAnnotations(prev => prev.map((a, idx) => (idx === i ? { ...a, ...patch } : a)));
|
||||
};
|
||||
const addAnnotation = () => setAnnotations(prev => [...prev, { key: '', value: '' }]);
|
||||
const removeAnnotation = (i: number) => setAnnotations(prev => prev.filter((_, idx) => idx !== i));
|
||||
|
||||
// ============ Selector (只读显示) ============
|
||||
const updateSelector = (i: number, patch: Partial<{ key: string; value: string }>) => {
|
||||
setSelectorLabels(prev => prev.map((s, idx) => (idx === i ? { ...s, ...patch } : s)));
|
||||
};
|
||||
const addSelector = () => setSelectorLabels(prev => [...prev, { key: '', value: '' }]);
|
||||
const removeSelector = (i: number) => setSelectorLabels(prev => prev.filter((_, idx) => idx !== i));
|
||||
|
||||
// ============ 序列化并保存 ============
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const base = JSON.parse(JSON.stringify(rawManifest));
|
||||
// 副本数
|
||||
base.spec = base.spec || {};
|
||||
base.spec.replicas = replicas;
|
||||
|
||||
// labels
|
||||
const labelObj = kvToObj(labels);
|
||||
base.metadata = base.metadata || {};
|
||||
base.metadata.labels = Object.keys(labelObj).length > 0 ? labelObj : undefined;
|
||||
|
||||
// annotations(空则删除 key)
|
||||
const annObj = kvToObj(annotations);
|
||||
base.metadata.annotations = Object.keys(annObj).length > 0 ? annObj : undefined;
|
||||
|
||||
// selector — 同步更新 spec.selector.matchLabels 和 spec.template.metadata.labels
|
||||
// ⚠️ selector 必须和 template labels 一致,否则 Deployment 会拒绝
|
||||
const selectorObj = kvToObj(selectorLabels);
|
||||
if (Object.keys(selectorObj).length > 0) {
|
||||
base.spec.selector = base.spec.selector || {};
|
||||
base.spec.selector.matchLabels = selectorObj;
|
||||
base.spec.template = base.spec.template || {};
|
||||
base.spec.template.metadata = base.spec.template.metadata || {};
|
||||
base.spec.template.metadata.labels = { ...base.spec.template.metadata.labels, ...selectorObj };
|
||||
}
|
||||
|
||||
const json = JSON.stringify(base, null, 2);
|
||||
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 (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 text-sm">加载中...</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
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 flex items-center gap-2">
|
||||
<Hash className="h-4 w-4 text-blue-400" />
|
||||
副本数 (Replicas)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={replicas}
|
||||
onChange={e => setReplicas(Math.max(0, Number(e.target.value) || 0))}
|
||||
className="w-32 bg-slate-800 border-slate-700 text-slate-200"
|
||||
/>
|
||||
<span className="text-xs text-slate-500">期望运行的 Pod 实例数量</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 基本标识(只读) */}
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm text-slate-200 flex items-center gap-2">
|
||||
<Lock className="h-4 w-4 text-slate-500" />
|
||||
基本标识(只读)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0 grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="text-slate-400 text-xs">名称 (name)</Label>
|
||||
<Input value={deploymentName} readOnly
|
||||
className="mt-1 bg-slate-950 border-slate-800 text-slate-500 font-mono text-sm cursor-not-allowed" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-slate-400 text-xs">命名空间 (namespace)</Label>
|
||||
<Input value={namespace} readOnly
|
||||
className="mt-1 bg-slate-950 border-slate-800 text-slate-500 font-mono text-sm cursor-not-allowed" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Labels */}
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm text-slate-200 flex items-center gap-2">
|
||||
<Tags className="h-4 w-4 text-emerald-400" />
|
||||
标签 (Labels)
|
||||
<Badge variant="outline" className="border-slate-700 text-slate-500 text-[10px]">{labels.length}</Badge>
|
||||
</CardTitle>
|
||||
<Button size="sm" variant="outline" className="h-7 border-slate-700 text-slate-300 text-xs" onClick={addLabel}>
|
||||
<Plus className="h-3 w-3 mr-1" />新增
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0 space-y-1.5">
|
||||
{labels.length === 0 && <p className="text-[11px] text-slate-600">无标签</p>}
|
||||
{labels.map((l, i) => (
|
||||
<div key={i} className="grid grid-cols-[1fr_1.4fr_auto] gap-2">
|
||||
<Input value={l.key} placeholder="app"
|
||||
onChange={e => updateLabel(i, { key: e.target.value })}
|
||||
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-8" />
|
||||
<Input value={l.value} placeholder="nginx"
|
||||
onChange={e => updateLabel(i, { 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={() => removeLabel(i)}><Trash2 className="h-3.5 w-3.5" /></Button>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Selector */}
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm text-slate-200 flex items-center gap-2">
|
||||
<Tags className="h-4 w-4 text-amber-400" />
|
||||
选择器 (Selector)
|
||||
<Badge variant="outline" className="border-amber-700/50 text-amber-500 text-[10px]">⚠️ 需与模板标签一致</Badge>
|
||||
</CardTitle>
|
||||
<Button size="sm" variant="outline" className="h-7 border-slate-700 text-slate-300 text-xs" onClick={addSelector}>
|
||||
<Plus className="h-3 w-3 mr-1" />新增
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0 space-y-1.5">
|
||||
<p className="text-[11px] text-amber-500/70 mb-1">Selector 决定哪些 Pod 归属此 Deployment。修改时必须确保与 Pod Template Labels 匹配,否则 Deployment 将无法管理 Pod。</p>
|
||||
{selectorLabels.length === 0 && <p className="text-[11px] text-slate-600">无选择器</p>}
|
||||
{selectorLabels.map((s, i) => (
|
||||
<div key={i} className="grid grid-cols-[1fr_1.4fr_auto] gap-2">
|
||||
<Input value={s.key} placeholder="app"
|
||||
onChange={e => updateSelector(i, { key: e.target.value })}
|
||||
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-8" />
|
||||
<Input value={s.value} placeholder="nginx"
|
||||
onChange={e => updateSelector(i, { 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={() => removeSelector(i)}><Trash2 className="h-3.5 w-3.5" /></Button>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Annotations */}
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm text-slate-200 flex items-center gap-2">
|
||||
<FileText className="h-4 w-4 text-purple-400" />
|
||||
注解 (Annotations)
|
||||
<Badge variant="outline" className="border-slate-700 text-slate-500 text-[10px]">{annotations.length}</Badge>
|
||||
</CardTitle>
|
||||
<Button size="sm" variant="outline" className="h-7 border-slate-700 text-slate-300 text-xs" onClick={addAnnotation}>
|
||||
<Plus className="h-3 w-3 mr-1" />新增
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0 space-y-1.5">
|
||||
{annotations.length === 0 && <p className="text-[11px] text-slate-600">无注解</p>}
|
||||
{annotations.map((a, i) => (
|
||||
<div key={i} className="grid grid-cols-[1fr_1.4fr_auto] gap-2">
|
||||
<Input value={a.key} placeholder="deployment.kubernetes.io/revision"
|
||||
onChange={e => updateAnnotation(i, { key: e.target.value })}
|
||||
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-8" />
|
||||
<Input value={a.value} placeholder="1"
|
||||
onChange={e => updateAnnotation(i, { 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={() => removeAnnotation(i)}><Trash2 className="h-3.5 w-3.5" /></Button>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// DeploymentWorkload.tsx — Kuboard 风格 Deployment 详情主组件
|
||||
// - 顶部操作菜单: 编辑 | 预览/编辑YAML | 调整镜像版本 | 伸缩 | 日志/终端 | 重启 | 暂停/恢复更新 | CI/CD发布 | 删除 | 元数据 | 运行时 | 服务 | 应用路由
|
||||
// - 双模式: view(只读增强看板)/ edit(表单编辑器)
|
||||
// - 三模式: view(只读增强看板)/ edit(可视化编辑器)/ yaml(YAML编辑器)
|
||||
// 作者: Logan <admin@attacker.club>
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
Trash2, FileText, Terminal, RefreshCw, Loader2, X,
|
||||
ChevronDown, Cpu, Network, GitBranch, Info,
|
||||
CheckCircle, AlertTriangle, XCircle,
|
||||
Pause, Play, Rocket,
|
||||
Pause, Play, Rocket, Container, Settings,
|
||||
} from 'lucide-react';
|
||||
import { kubernetesApi } from '@/services/api';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
@@ -25,16 +25,19 @@ import { useUserStore } from '@/store';
|
||||
import { YamlViewer } from './YamlViewer';
|
||||
import { DeploymentView } from './DeploymentView';
|
||||
import { DeploymentEdit } from './DeploymentEdit';
|
||||
import { DeploymentEditBasic } from './DeploymentEditBasic';
|
||||
|
||||
type WorkloadMode = 'view' | 'edit' | 'yaml';
|
||||
|
||||
export interface DeploymentWorkloadProps {
|
||||
clusterCode: string;
|
||||
namespace: string;
|
||||
deploymentName: string;
|
||||
/** 'view' | 'edit' — 来自路由参数 */
|
||||
mode: 'view' | 'edit';
|
||||
/** 'view' | 'edit' | 'yaml' — 来自路由参数 */
|
||||
mode: WorkloadMode;
|
||||
onBack: () => void;
|
||||
/** 跳转到另一模式(用于"编辑"按钮、保存成功后切换) */
|
||||
onModeChange: (mode: 'view' | 'edit') => void;
|
||||
onModeChange: (mode: WorkloadMode) => void;
|
||||
}
|
||||
|
||||
type ActionModal =
|
||||
@@ -287,7 +290,7 @@ export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mod
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 模式切换 Tab */}
|
||||
{/* 模式切换 Tab — 三模式:查看 / 编辑 / YAML */}
|
||||
<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'}`}
|
||||
@@ -301,6 +304,12 @@ export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mod
|
||||
>
|
||||
<Pencil className="h-3 w-3 inline mr-1" />编辑模式
|
||||
</button>
|
||||
<button
|
||||
className={`px-3 py-1.5 text-xs font-medium transition-colors ${mode === 'yaml' ? 'bg-emerald-600 text-white' : 'bg-transparent text-slate-400 hover:text-slate-200'}`}
|
||||
onClick={() => onModeChange('yaml')}
|
||||
>
|
||||
<FileCode className="h-3 w-3 inline mr-1" />YAML编辑
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Button variant="outline" size="sm" className="border-slate-700 text-slate-400" onClick={loadData} title="刷新">
|
||||
@@ -372,7 +381,7 @@ export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mod
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ============ 主体:view / edit ============ */}
|
||||
{/* ============ 主体:view / edit / yaml ============ */}
|
||||
{mode === 'view' ? (
|
||||
<DeploymentView
|
||||
clusterCode={clusterCode}
|
||||
@@ -386,8 +395,21 @@ export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mod
|
||||
onShowPodTerminal={handleShowPodTerminal}
|
||||
onShowPodYaml={handleShowPodYaml}
|
||||
/>
|
||||
) : mode === 'yaml' ? (
|
||||
/* YAML 编辑模式 — 内联 CodeMirror 编辑器 */
|
||||
<YamlViewer
|
||||
clusterCode={clusterCode}
|
||||
namespace={namespace}
|
||||
resource="deployments"
|
||||
name={deploymentName}
|
||||
cleanBeforeEdit
|
||||
inline
|
||||
onSaved={loadData}
|
||||
onClose={() => onModeChange('view')}
|
||||
/>
|
||||
) : (
|
||||
<DeploymentEdit
|
||||
/* 可视化编辑模式 — Tab 系统 */
|
||||
<EditModeContainer
|
||||
clusterCode={clusterCode}
|
||||
namespace={namespace}
|
||||
deploymentName={deploymentName}
|
||||
@@ -513,6 +535,83 @@ export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mod
|
||||
);
|
||||
}
|
||||
|
||||
// ============ 编辑模式 Tab 容器(M6: 基本信息Tab + 容器编辑预留) ============
|
||||
function EditModeContainer({ clusterCode, namespace, deploymentName, manifest, onSaved, onCancel }: {
|
||||
clusterCode: string;
|
||||
namespace: string;
|
||||
deploymentName: string;
|
||||
manifest: any | null;
|
||||
onSaved: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
type EditTab = 'basic' | 'containers' | 'storage' | 'advanced' | 'service';
|
||||
const [activeTab, setActiveTab] = useState<EditTab>('basic');
|
||||
|
||||
const tabs: Array<{ key: EditTab; label: string; icon: typeof Settings; available: boolean; badge?: string }> = [
|
||||
{ key: 'basic', label: '基本信息', icon: Settings, available: true },
|
||||
{ key: 'containers', label: '容器信息', icon: Container, available: true, badge: 'M7' },
|
||||
{ key: 'storage', label: '存储挂载', icon: Container, available: false, badge: 'M8' },
|
||||
{ key: 'advanced', label: '高级设置', icon: Settings, available: false, badge: 'M8' },
|
||||
{ key: 'service', label: '服务/应用路由', icon: Network, available: false, badge: 'M9' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Tab 导航 */}
|
||||
<div className="flex items-center gap-1 border-b border-slate-800 pb-px overflow-x-auto">
|
||||
{tabs.map(tab => {
|
||||
const active = activeTab === tab.key;
|
||||
const Icon = tab.icon;
|
||||
return (
|
||||
<button
|
||||
key={tab.key}
|
||||
disabled={!tab.available}
|
||||
onClick={() => tab.available && setActiveTab(tab.key)}
|
||||
className={`flex items-center gap-1.5 px-3 py-2 text-xs font-medium border-b-2 transition-colors whitespace-nowrap
|
||||
${active
|
||||
? 'border-blue-500 text-blue-400'
|
||||
: tab.available
|
||||
? 'border-transparent text-slate-400 hover:text-slate-200'
|
||||
: 'border-transparent text-slate-600 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
{tab.label}
|
||||
{tab.badge && (
|
||||
<span className={`text-[9px] px-1 py-0.5 rounded ${active ? 'bg-blue-500/20 text-blue-300' : 'bg-slate-800 text-slate-600'}`}>
|
||||
{tab.badge}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Tab 内容 */}
|
||||
{activeTab === 'basic' ? (
|
||||
<DeploymentEditBasic
|
||||
clusterCode={clusterCode}
|
||||
namespace={namespace}
|
||||
deploymentName={deploymentName}
|
||||
manifest={manifest}
|
||||
onSaved={onSaved}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
) : activeTab === 'containers' ? (
|
||||
/* M7 预留:容器编辑 — 暂用旧 DeploymentEdit */
|
||||
<DeploymentEdit
|
||||
clusterCode={clusterCode}
|
||||
namespace={namespace}
|
||||
deploymentName={deploymentName}
|
||||
manifest={manifest}
|
||||
onSaved={onSaved}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============ 通用 Modal 外壳 ============
|
||||
function ModalShell({ title, children, onClose }: { title: string; children: React.ReactNode; onClose: () => void }) {
|
||||
return (
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
// YamlViewer.tsx — YAML 查看/编辑器(CodeMirror 6 语法高亮)
|
||||
// 支持:语法高亮 + 行号 + 基本校验 + 编辑/保存 + 只读字段清理
|
||||
// 作者: Logan <admin@attacker.club>
|
||||
import { useState, useEffect, useMemo, useCallback } from '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 { cleanYAML } from './k8s-yaml-utils';
|
||||
import CodeMirror from '@uiw/react-codemirror';
|
||||
import { yaml } from '@codemirror/lang-yaml';
|
||||
import { oneDark } from '@codemirror/theme-one-dark';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
|
||||
interface YamlViewerProps {
|
||||
clusterCode: string;
|
||||
@@ -16,13 +23,17 @@ interface YamlViewerProps {
|
||||
cleanBeforeEdit?: boolean;
|
||||
/** 打开即自动进入编辑模式(编辑按钮专用入口) */
|
||||
autoEdit?: boolean;
|
||||
/** 内联模式(非弹窗)— 用于 YAML 编辑模式 Tab */
|
||||
inline?: boolean;
|
||||
/** 内联模式下 YAML 内容直接传入 */
|
||||
initialYaml?: string;
|
||||
}
|
||||
|
||||
export function YamlViewer({ clusterCode, namespace, resource, name, onClose, onSaved, cleanBeforeEdit = false, autoEdit = false }: YamlViewerProps) {
|
||||
export function YamlViewer({ clusterCode, namespace, resource, name, onClose, onSaved, cleanBeforeEdit = false, autoEdit = false, inline = false, initialYaml }: YamlViewerProps) {
|
||||
const { toast } = useToast();
|
||||
const [rawYaml, setRawYaml] = useState(''); // 原始(只读展示用)
|
||||
const [rawYaml, setRawYaml] = useState(initialYaml || ''); // 原始(只读展示用)
|
||||
const [editYaml, setEditYaml] = useState(''); // 编辑态(可能被清理过)
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loading, setLoading] = useState(!initialYaml);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [editing, setEditing] = useState(autoEdit); // autoEdit → 初始即编辑态
|
||||
const [copied, setCopied] = useState(false);
|
||||
@@ -30,6 +41,12 @@ export function YamlViewer({ clusterCode, namespace, resource, name, onClose, on
|
||||
const effectiveClean = cleanBeforeEdit || autoEdit;
|
||||
|
||||
const loadYaml = async () => {
|
||||
if (initialYaml) {
|
||||
setRawYaml(initialYaml);
|
||||
setEditYaml(effectiveClean ? cleanYAML(initialYaml) : initialYaml);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const text = await kubernetesApi.getResourceYaml(clusterCode, namespace, resource, name);
|
||||
@@ -44,7 +61,6 @@ export function YamlViewer({ clusterCode, namespace, resource, name, onClose, on
|
||||
};
|
||||
|
||||
const enterEdit = () => {
|
||||
// 进入编辑前可选清理
|
||||
const cleaned = effectiveClean ? cleanYAML(rawYaml) : rawYaml;
|
||||
setEditYaml(cleaned);
|
||||
setEditing(true);
|
||||
@@ -77,9 +93,84 @@ export function YamlViewer({ clusterCode, namespace, resource, name, onClose, on
|
||||
|
||||
const display = editing ? editYaml : rawYaml;
|
||||
|
||||
// CodeMirror 主题扩展
|
||||
const extensions = useMemo(() => [
|
||||
yaml(),
|
||||
EditorView.lineWrapping,
|
||||
...(editing ? [] : [EditorView.editable.of(false)]),
|
||||
], [editing]);
|
||||
|
||||
// 内联模式 — 不显示外层弹窗,适合嵌入 Tab 页面
|
||||
if (inline) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-slate-500">
|
||||
<code className="text-slate-400">{resource}/{name}</code> · {namespace} · {clusterCode}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" className="h-7 border-slate-700 text-slate-300 text-xs" 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 ? (
|
||||
<>
|
||||
{effectiveClean && (
|
||||
<Button variant="outline" size="sm" className="h-7 border-slate-700 text-slate-300 text-xs" onClick={enterEdit} title="清理 status / managedFields 等只读字段后编辑">
|
||||
<Sparkles className="h-3 w-3 mr-1 text-amber-400" />清理并编辑
|
||||
</Button>
|
||||
)}
|
||||
{!effectiveClean && (
|
||||
<Button variant="outline" size="sm" className="h-7 border-slate-700 text-slate-300 text-xs" onClick={() => setEditing(true)}>
|
||||
编辑
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="outline" size="sm" className="h-7 border-slate-700 text-slate-300 text-xs" onClick={() => { setEditing(false); loadYaml(); }}>
|
||||
取消
|
||||
</Button>
|
||||
<Button size="sm" className="h-7 bg-blue-600 hover:bg-blue-700 text-xs" onClick={saveYaml} disabled={saving}>
|
||||
{saving ? <Loader2 className="h-3 w-3 animate-spin mr-1" /> : <Save className="h-3 w-3 mr-1" />}
|
||||
保存
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="border border-slate-700 rounded-lg overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-96"><Loader2 className="h-6 w-6 animate-spin text-slate-500" /></div>
|
||||
) : (
|
||||
<CodeMirror
|
||||
value={display}
|
||||
height="600px"
|
||||
theme={oneDark}
|
||||
extensions={extensions}
|
||||
onChange={(val) => editing && setEditYaml(val)}
|
||||
basicSetup={{
|
||||
lineNumbers: true,
|
||||
foldGutter: true,
|
||||
highlightActiveLine: editing,
|
||||
highlightActiveLineGutter: editing,
|
||||
autocompletion: true,
|
||||
bracketMatching: true,
|
||||
closeBrackets: true,
|
||||
indentOnInput: true,
|
||||
}}
|
||||
style={{ fontSize: '12px' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 弹窗模式(原有行为)
|
||||
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()}>
|
||||
<div className="bg-slate-900 border border-slate-700 rounded-xl w-[90vw] max-w-4xl max-h-[85vh] 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>
|
||||
@@ -122,15 +213,25 @@ export function YamlViewer({ clusterCode, namespace, resource, name, onClose, on
|
||||
<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>
|
||||
<CodeMirror
|
||||
value={display}
|
||||
height="600px"
|
||||
theme={oneDark}
|
||||
extensions={extensions}
|
||||
onChange={(val) => editing && setEditYaml(val)}
|
||||
basicSetup={{
|
||||
lineNumbers: true,
|
||||
foldGutter: true,
|
||||
highlightActiveLine: editing,
|
||||
highlightActiveLineGutter: editing,
|
||||
autocompletion: true,
|
||||
bracketMatching: true,
|
||||
closeBrackets: true,
|
||||
indentOnInput: true,
|
||||
}}
|
||||
style={{ fontSize: '12px' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user