Files
aiops/frontend/src/components/kubernetes/DeploymentEditStorage.tsx
T

438 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// DeploymentEditStorage.tsx — 编辑器「存储挂载」Tab (M8)
// 编辑内容:spec.template.spec.volumes[] — 数据卷定义
// 支持 6 种常用 volume 类型:emptyDir / hostPath / configMap / secret / persistentVolumeClaim / nfs
// 作者: Logan <admin@attacker.club>
import { useState, useEffect } 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, HardDrive, Database, Folder, Lock,
} from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import { kubernetesApi } from '@/services/api';
interface Props {
clusterCode: string;
namespace: string;
deploymentName: string;
manifest: any | null;
onSaved: () => void;
onCancel: () => void;
}
/** 单个 volume 的编辑态 */
interface VolumeForm {
name: string;
type: 'emptyDir' | 'hostPath' | 'configMap' | 'secret' | 'persistentVolumeClaim' | 'nfs';
// 各类型字段(仅对应类型有效)
medium?: string; // emptyDir.medium: "" | "Memory"
sizeLimit?: string; // emptyDir.sizeLimit
path?: string; // hostPath.path
pathType?: string; // hostPath.pathType: Directory | File | Socket | ...
configMapName?: string; // configMap.name
secretName?: string; // secret.secretName
pvcName?: string; // persistentVolumeClaim.claimName
nfsServer?: string; // nfs.server
nfsPath?: string; // nfs.path
readOnly?: boolean; // pvc/nfs readOnly
items?: string; // configMap/secret items (JSON 文本,高级用户)
}
/** 从 K8s volume 对象提取为 form */
function volumeToForm(v: any): VolumeForm {
if (!v || typeof v !== 'object') return { name: '', type: 'emptyDir' };
const name = v.name || '';
if (v.emptyDir) {
return {
name, type: 'emptyDir',
medium: v.emptyDir.medium || '',
sizeLimit: v.emptyDir.sizeLimit || '',
};
}
if (v.hostPath) {
return {
name, type: 'hostPath',
path: v.hostPath.path || '',
pathType: v.hostPath.type || 'Directory',
};
}
if (v.configMap) {
return {
name, type: 'configMap',
configMapName: v.configMap.name || '',
items: v.configMap.items ? JSON.stringify(v.configMap.items) : '',
};
}
if (v.secret) {
return {
name, type: 'secret',
secretName: v.secret.secretName || '',
items: v.secret.items ? JSON.stringify(v.secret.items) : '',
};
}
if (v.persistentVolumeClaim) {
return {
name, type: 'persistentVolumeClaim',
pvcName: v.persistentVolumeClaim.claimName || '',
readOnly: !!v.persistentVolumeClaim.readOnly,
};
}
if (v.nfs) {
return {
name, type: 'nfs',
nfsServer: v.nfs.server || '',
nfsPath: v.nfs.path || '',
readOnly: !!v.nfs.readOnly,
};
}
// 未识别类型 — fallback 为 emptyDir 占位
return { name, type: 'emptyDir' };
}
/** 将 form 转回 K8s volume 对象 */
function formToVolume(f: VolumeForm): any {
const v: any = { name: f.name.trim() };
if (!v.name) return null; // 无名 volume 跳过
switch (f.type) {
case 'emptyDir': {
const ed: any = {};
if (f.medium) ed.medium = f.medium;
if (f.sizeLimit) ed.sizeLimit = f.sizeLimit;
v.emptyDir = ed;
break;
}
case 'hostPath': {
v.hostPath = {
path: f.path || '',
type: f.pathType || 'Directory',
};
break;
}
case 'configMap': {
v.configMap = { name: f.configMapName || '' };
if (f.items) {
try { v.configMap.items = JSON.parse(f.items); } catch {}
}
break;
}
case 'secret': {
v.secret = { secretName: f.secretName || '' };
if (f.items) {
try { v.secret.items = JSON.parse(f.items); } catch {}
}
break;
}
case 'persistentVolumeClaim': {
v.persistentVolumeClaim = {
claimName: f.pvcName || '',
...(f.readOnly ? { readOnly: true } : {}),
};
break;
}
case 'nfs': {
v.nfs = {
server: f.nfsServer || '',
path: f.nfsPath || '',
...(f.readOnly ? { readOnly: true } : {}),
};
break;
}
}
return v;
}
const TYPE_META: Record<VolumeForm['type'], { label: string; icon: typeof HardDrive; color: string }> = {
emptyDir: { label: 'emptyDir', icon: Folder, color: 'text-slate-300' },
hostPath: { label: 'hostPath', icon: HardDrive, color: 'text-amber-400' },
configMap: { label: 'configMap', icon: Folder, color: 'text-blue-400' },
secret: { label: 'secret', icon: Lock, color: 'text-rose-400' },
persistentVolumeClaim: { label: 'PVC', icon: Database, color: 'text-emerald-400' },
nfs: { label: 'NFS', icon: HardDrive, color: 'text-purple-400' },
};
export function DeploymentEditStorage({ clusterCode, namespace, deploymentName, manifest, onSaved, onCancel }: Props) {
const { toast } = useToast();
const [saving, setSaving] = useState(false);
const [loading, setLoading] = useState(!manifest);
const [volumes, setVolumes] = useState<VolumeForm[]>([]);
const [rawManifest, setRawManifest] = useState<any>(manifest || {});
// 容器已声明的 volumeMounts(只读展示,便于用户对齐 volume name)
const [mountSummary, setMountSummary] = useState<Array<{ container: string; mounts: string[] }>>([]);
useEffect(() => {
const m = manifest;
if (!m) { setLoading(false); return; }
setRawManifest(m);
const podSpec = m?.spec?.template?.spec || {};
const vols: any[] = Array.isArray(podSpec.volumes) ? podSpec.volumes : [];
setVolumes(vols.map(volumeToForm));
// 汇总容器 volumeMounts
const allContainers = [
...(podSpec.containers || []),
...(podSpec.initContainers || []),
];
const summary = allContainers.map((c: any) => ({
container: c.name || '(unnamed)',
mounts: (Array.isArray(c.volumeMounts) ? c.volumeMounts : [])
.map((vm: any) => `${vm.name}:${vm.mountPath}${vm.readOnly ? '(ro)' : ''}`),
})).filter((s: any) => s.mounts.length > 0);
setMountSummary(summary);
setLoading(false);
}, [manifest]);
const addVolume = () => setVolumes(prev => [...prev, { name: '', type: 'emptyDir' }]);
const removeVolume = (i: number) => setVolumes(prev => prev.filter((_, idx) => idx !== i));
const updateVolume = (i: number, patch: Partial<VolumeForm>) =>
setVolumes(prev => prev.map((v, idx) => (idx === i ? { ...v, ...patch } : v)));
const save = async () => {
setSaving(true);
try {
const base = JSON.parse(JSON.stringify(rawManifest));
base.spec = base.spec || {};
base.spec.template = base.spec.template || {};
base.spec.template.spec = base.spec.template.spec || {};
const k8sVols = volumes.map(formToVolume).filter(Boolean);
if (k8sVols.length > 0) {
base.spec.template.spec.volumes = k8sVols;
} else {
delete base.spec.template.spec.volumes;
}
const json = JSON.stringify(base, null, 2);
await kubernetesApi.updateResourceYaml(clusterCode, namespace, 'deployments', deploymentName, json);
toast({ title: '存储配置已保存', description: `${deploymentName} volumes 更新已提交` });
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">
<div className="flex items-center justify-between">
<CardTitle className="text-sm text-slate-200 flex items-center gap-2">
<Database className="h-4 w-4 text-emerald-400" />
数据卷 (Volumes)
<Badge variant="outline" className="border-slate-700 text-slate-400">{volumes.length}</Badge>
</CardTitle>
<Button size="sm" variant="outline" className="h-7 border-slate-700 text-slate-300 text-xs" onClick={addVolume}>
<Plus className="h-3 w-3 mr-1" />添加数据卷
</Button>
</div>
<p className="text-xs text-slate-500 mt-1">
Pod 级别的卷定义,容器通过 <code className="text-slate-400">volumeMounts</code> 引用这些卷名。
</p>
</CardHeader>
<CardContent className="pt-0 space-y-3">
{volumes.length === 0 ? (
<div className="text-center py-8 text-slate-500 text-sm border border-dashed border-slate-800 rounded-lg">
<Database className="h-8 w-8 mx-auto mb-2 text-slate-700" />
未配置数据卷,点击右上角「添加数据卷」
</div>
) : volumes.map((vol, i) => {
const meta = TYPE_META[vol.type];
const Icon = meta.icon;
return (
<div key={i} className="bg-slate-950/50 border border-slate-800 rounded-lg p-3 space-y-3">
<div className="flex items-center gap-3">
<Icon className={`h-4 w-4 ${meta.color}`} />
<Input
value={vol.name}
onChange={e => updateVolume(i, { name: e.target.value })}
placeholder="volume-name"
className="flex-1 h-8 bg-slate-800 border-slate-700 text-slate-200 text-sm font-mono"
/>
<select
value={vol.type}
onChange={e => updateVolume(i, { type: e.target.value as VolumeForm['type'] })}
className="h-8 bg-slate-800 border border-slate-700 rounded text-xs text-slate-300 px-2"
>
{Object.entries(TYPE_META).map(([t, m]) => (
<option key={t} value={t}>{m.label}</option>
))}
</select>
<Button size="icon" variant="ghost" className="h-7 w-7 text-red-400 hover:text-red-300"
onClick={() => removeVolume(i)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
{/* 类型相关字段 */}
{vol.type === 'emptyDir' && (
<div className="grid grid-cols-2 gap-3 pl-7">
<div>
<Label className="text-slate-500 text-xs">medium (留空=磁盘, Memory=tmpfs)</Label>
<Input value={vol.medium || ''} onChange={e => updateVolume(i, { medium: e.target.value })}
placeholder="" className="h-7 mt-1 bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono" />
</div>
<div>
<Label className="text-slate-500 text-xs">sizeLimit (例: 512Mi)</Label>
<Input value={vol.sizeLimit || ''} onChange={e => updateVolume(i, { sizeLimit: e.target.value })}
placeholder="512Mi" className="h-7 mt-1 bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono" />
</div>
</div>
)}
{vol.type === 'hostPath' && (
<div className="grid grid-cols-2 gap-3 pl-7">
<div>
<Label className="text-slate-500 text-xs">主机路径 path</Label>
<Input value={vol.path || ''} onChange={e => updateVolume(i, { path: e.target.value })}
placeholder="/data/app" className="h-7 mt-1 bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono" />
</div>
<div>
<Label className="text-slate-500 text-xs">pathType</Label>
<select value={vol.pathType || 'Directory'} onChange={e => updateVolume(i, { pathType: e.target.value })}
className="h-7 mt-1 w-full bg-slate-800 border border-slate-700 rounded text-xs text-slate-300 px-2">
<option value="Directory">Directory</option>
<option value="File">File</option>
<option value="Socket">Socket</option>
<option value="CharDevice">CharDevice</option>
<option value="BlockDevice">BlockDevice</option>
<option value="">(空)</option>
</select>
</div>
</div>
)}
{vol.type === 'configMap' && (
<div className="pl-7">
<Label className="text-slate-500 text-xs">ConfigMap 名称</Label>
<Input value={vol.configMapName || ''} onChange={e => updateVolume(i, { configMapName: e.target.value })}
placeholder="app-config" className="h-7 mt-1 bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono" />
<Label className="text-slate-500 text-xs mt-2 block">items (JSON, 可选)</Label>
<Input value={vol.items || ''} onChange={e => updateVolume(i, { items: e.target.value })}
placeholder='[{"key":"app.conf","path":"app.conf"}]'
className="h-7 mt-1 bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono" />
</div>
)}
{vol.type === 'secret' && (
<div className="pl-7">
<Label className="text-slate-500 text-xs">Secret 名称</Label>
<Input value={vol.secretName || ''} onChange={e => updateVolume(i, { secretName: e.target.value })}
placeholder="app-tls" className="h-7 mt-1 bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono" />
<Label className="text-slate-500 text-xs mt-2 block">items (JSON, 可选)</Label>
<Input value={vol.items || ''} onChange={e => updateVolume(i, { items: e.target.value })}
placeholder='[{"key":"tls.crt","path":"tls.crt"}]'
className="h-7 mt-1 bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono" />
</div>
)}
{vol.type === 'persistentVolumeClaim' && (
<div className="grid grid-cols-2 gap-3 pl-7">
<div>
<Label className="text-slate-500 text-xs">PVC claimName</Label>
<Input value={vol.pvcName || ''} onChange={e => updateVolume(i, { pvcName: e.target.value })}
placeholder="data-pvc" className="h-7 mt-1 bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono" />
</div>
<div className="flex items-end pb-1">
<label className="flex items-center gap-2 text-xs text-slate-400 cursor-pointer">
<input type="checkbox" checked={!!vol.readOnly}
onChange={e => updateVolume(i, { readOnly: e.target.checked })}
className="accent-blue-500" />
readOnly
</label>
</div>
</div>
)}
{vol.type === 'nfs' && (
<div className="grid grid-cols-3 gap-3 pl-7">
<div>
<Label className="text-slate-500 text-xs">server</Label>
<Input value={vol.nfsServer || ''} onChange={e => updateVolume(i, { nfsServer: e.target.value })}
placeholder="10.10.10.100" className="h-7 mt-1 bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono" />
</div>
<div>
<Label className="text-slate-500 text-xs">path</Label>
<Input value={vol.nfsPath || ''} onChange={e => updateVolume(i, { nfsPath: e.target.value })}
placeholder="/export/data" className="h-7 mt-1 bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono" />
</div>
<div className="flex items-end pb-1">
<label className="flex items-center gap-2 text-xs text-slate-400 cursor-pointer">
<input type="checkbox" checked={!!vol.readOnly}
onChange={e => updateVolume(i, { readOnly: e.target.checked })}
className="accent-blue-500" />
readOnly
</label>
</div>
</div>
)}
</div>
);
})}
</CardContent>
</Card>
{/* 容器 volumeMounts 汇总(只读) */}
{mountSummary.length > 0 && (
<Card className="bg-slate-900 border-slate-800">
<CardHeader className="pb-3">
<CardTitle className="text-sm text-slate-200 flex items-center gap-2">
<Folder className="h-4 w-4 text-blue-400" />
容器挂载点 (volumeMounts)
</CardTitle>
<p className="text-xs text-slate-500 mt-1">
容器侧已声明的挂载,请在「容器信息」Tab 修改。
</p>
</CardHeader>
<CardContent className="pt-0 space-y-2">
{mountSummary.map((s, i) => (
<div key={i} className="flex items-start gap-2 text-xs">
<Badge variant="outline" className="border-slate-700 text-slate-400 font-mono shrink-0">
{s.container}
</Badge>
<div className="flex flex-wrap gap-1.5">
{s.mounts.map((m, j) => (
<code key={j} className="bg-slate-800 px-1.5 py-0.5 rounded text-slate-300 text-[11px]">{m}</code>
))}
</div>
</div>
))}
</CardContent>
</Card>
)}
{/* 操作栏 */}
<div className="flex justify-end gap-2 pt-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>
);
}