Files
aiops/frontend/src/components/kubernetes/DeploymentEditStorage.tsx
T
attacker c68aef4444 feat(alert): A2 告警引擎分布式安全 — Redis 分布式锁(SetNX)
- Cache 接口新增 SetNX 方法(go-redis 原子 / redka check-then-set 单副本安全)
- AlertEngine 改造为支持分布式锁: tryAcquireLock/releaseLock/GetEngineStatus
- 多副本部署时仅 Leader 执行告警检查, 60s TTL 防死锁, fail-open 容错
- 新增 GET /alerts/engine/status 监控端点(is_leader/instance_id/lock_holder)
- 修复 GrafanaDashboard.tsx Panel.options 类型不匹配导致构建中断
- 同步未提交的前端类型安全迁移(140+ 文件)
- ROADMAP/TDD_PLAN/PROJECT_EVALUATION 三文档同步更新

验证: go build 零错误, Docker 部署 healthy, 引擎状态端点正常,
锁竞争测试通过(非 Leader 跳过检查), 5/5 核心 API 回归通过
2026-06-26 21:13:15 +08:00

353 lines
17 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 — 存储挂载 Tab(Kuboard 一卷一卡:定义卷 + 多容器挂载)// Logan.Li
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 { Badge } from '@/components/ui/badge';
import {
Plus, Trash2, Save, Loader2, HardDrive, Database, Folder, Lock, File, Disc, Server, Container,
} from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import { kubernetesApi } from '@/api';
import { saveDeploymentManifest } from './deployment-manifest-save';
import { ResourceVisualEditor } from './ResourceVisualEditor';
import {
VolumeTypeFields,
extractResourceNames,
type NamespaceResourceLists,
} from './deployment-storage-fields';
import {
type VolumeForm,
type VolumeEntry,
type VolumeMountRow,
parseVolumeEntries,
applyStorageToManifest,
formToVolume,
volumeEntriesToStorage,
emptyVolumeEntry,
emptyMountRow,
} from './deployment-volume-utils';
import type { JsonData } from '@/types/common';
interface Props {
clusterCode: string;
namespace: string;
deploymentName: string;
manifest: JsonData | null;
onSaved: (updated?: JsonData) => void;
onCancel: () => void;
}
const VOLUME_TYPES: { key: VolumeForm['type']; label: string; icon: typeof HardDrive; color: string }[] = [
{ key: 'persistentVolumeClaim', label: '存储卷声明', icon: Database, color: 'text-emerald-400' },
{ key: 'emptyDir', label: '临时目录(EmptyDir)', icon: Disc, color: 'text-slate-400' },
{ key: 'hostPath', label: 'HostPath', icon: HardDrive, color: 'text-amber-400' },
{ key: 'nfs', label: 'NFS', icon: Server, color: 'text-purple-400' },
{ key: 'configMap', label: '配置字典', icon: File, color: 'text-blue-400' },
{ key: 'secret', label: '密文', icon: Lock, color: 'text-rose-400' },
];
const SELECTABLE_TYPES = new Set(VOLUME_TYPES.map((t) => t.key));
type CreateResourceKind = 'configmaps' | 'secrets' | 'pvcs';
export function DeploymentEditStorage({ clusterCode, namespace, deploymentName, manifest, onSaved, onCancel }: Props) {
const { toast } = useToast();
const [saving, setSaving] = useState(false);
const [loading, setLoading] = useState(!manifest);
const [rawManifest, setRawManifest] = useState<JsonData>(manifest || {});
const [entries, setEntries] = useState<VolumeEntry[]>([]);
const [containers, setContainers] = useState<string[]>([]);
const [passthroughVolumes, setPassthroughVolumes] = useState<JsonData[]>([]);
const [resources, setResources] = useState<NamespaceResourceLists>({ configMaps: [], secrets: [], pvcs: [] });
const [resourcesLoading, setResourcesLoading] = useState(false);
const [createResource, setCreateResource] = useState<CreateResourceKind | null>(null);
const loadResources = useCallback(async () => {
if (!clusterCode || !namespace) return;
setResourcesLoading(true);
try {
const [cm, sec, pvc] = await Promise.all([
kubernetesApi.getNamespaceConfigMaps(clusterCode, namespace).catch(() => []),
kubernetesApi.getNamespaceSecrets(clusterCode, namespace).catch(() => []),
kubernetesApi.getPVCs(clusterCode, namespace).catch(() => []),
]);
setResources({
configMaps: extractResourceNames(cm),
secrets: extractResourceNames(sec),
pvcs: extractResourceNames(pvc),
});
} finally {
setResourcesLoading(false);
}
}, [clusterCode, namespace]);
useEffect(() => { loadResources(); }, [loadResources]);
useEffect(() => {
if (!manifest) { setLoading(false); return; }
setRawManifest(manifest);
const parsed = parseVolumeEntries(manifest);
setEntries(parsed.entries);
setContainers(parsed.containers);
setPassthroughVolumes(parsed.passthroughVolumes);
setLoading(false);
}, [manifest]);
const defaultContainer = containers.find((c) => c !== '(unnamed)') || containers[0] || '';
const updateEntry = (i: number, patch: Partial<VolumeEntry>) =>
setEntries((prev) => prev.map((e, idx) => (idx === i ? { ...e, ...patch } : e)));
const updateVolume = (i: number, patch: Partial<VolumeForm>) =>
setEntries((prev) => prev.map((e, idx) => (idx === i ? { ...e, volume: { ...e.volume, ...patch } } : e)));
const updateMount = (entryIdx: number, mountIdx: number, patch: Partial<VolumeMountRow>) =>
setEntries((prev) => prev.map((e, i) => {
if (i !== entryIdx) return e;
return {
...e,
mounts: e.mounts.map((m, j) => (j === mountIdx ? { ...m, ...patch } : m)),
};
}));
const addMount = (entryIdx: number) =>
setEntries((prev) => prev.map((e, i) => {
if (i !== entryIdx) return e;
return { ...e, mounts: [...e.mounts, emptyMountRow(defaultContainer)] };
}));
const removeMount = (entryIdx: number, mountIdx: number) =>
setEntries((prev) => prev.map((e, i) => {
if (i !== entryIdx) return e;
const next = e.mounts.filter((_, j) => j !== mountIdx);
return { ...e, mounts: next.length > 0 ? next : [emptyMountRow(defaultContainer)] };
}));
const addEntry = () => setEntries((prev) => [...prev, emptyVolumeEntry(defaultContainer)]);
const removeEntry = (i: number) => setEntries((prev) => prev.filter((_, idx) => idx !== i));
const save = async () => {
for (const entry of entries) {
if (!entry.volume.name.trim()) {
toast({ title: '请填写数据卷名称', variant: 'destructive' });
return;
}
if (!formToVolume(entry.volume)) {
const hint = entry.volume.type === 'configMap' ? '请选择 ConfigMap'
: entry.volume.type === 'secret' ? '请选择 Secret'
: entry.volume.type === 'persistentVolumeClaim' ? '请选择 PVC'
: '的类型必填项';
toast({ title: '数据卷配置不完整', description: `请为「${entry.volume.name}」${hint}`, variant: 'destructive' });
return;
}
const validMounts = entry.mounts.filter((m) => m.mountPath.trim());
if (validMounts.length === 0) {
toast({ title: '请填写挂载路径', description: `数据卷「${entry.volume.name}」需至少挂载到一个容器`, variant: 'destructive' });
return;
}
}
setSaving(true);
try {
const { volumes, mounts } = volumeEntriesToStorage(entries);
const base = applyStorageToManifest(rawManifest, volumes, mounts, passthroughVolumes);
await saveDeploymentManifest(clusterCode, namespace, deploymentName, base);
toast({ title: '存储配置已保存', description: `${deploymentName} 更新已提交` });
onSaved(base);
} catch (e) {
toast({ title: '保存失败', description: (e as Error)?.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">
{createResource && (
<ResourceVisualEditor
clusterCode={clusterCode}
namespace={namespace}
resource={createResource}
name=""
isCreate
onClose={() => setCreateResource(null)}
onSaved={() => { loadResources(); setCreateResource(null); }}
/>
)}
<Card className="bg-slate-900 border-slate-800">
<CardHeader className="pb-3">
<div className="flex items-center justify-between gap-3">
<div>
<CardTitle className="text-sm text-slate-200 flex items-center gap-2">
<Database className="h-4 w-4 text-emerald-400" />
数据卷
<Badge variant="outline" className="border-slate-700 text-slate-400">{entries.length}</Badge>
</CardTitle>
<p className="text-[11px] text-slate-500 mt-1">定义数据卷,并将其挂载到工作负载的容器中</p>
</div>
<Button size="sm" variant="outline" className="h-7 border-slate-700 text-slate-300 text-xs shrink-0" onClick={addEntry}>
<Plus className="h-3 w-3 mr-1" />添加数据卷
</Button>
</div>
</CardHeader>
<CardContent className="pt-0 space-y-4">
{entries.length === 0 ? (
<div className="text-center py-10 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>
) : entries.map((entry, i) => {
const vol = entry.volume;
const vtype = VOLUME_TYPES.find((t) => t.key === vol.type);
const VIcon = vtype?.icon || Folder;
const selectValue = SELECTABLE_TYPES.has(vol.type) ? vol.type : 'emptyDir';
return (
<div key={i} className="bg-slate-950/60 border border-slate-800 rounded-lg">
{/* 卷定义 */}
<div className="p-3 space-y-2.5 border-b border-slate-800/80">
<div className="flex items-center gap-2 flex-wrap">
<VIcon className={`h-4 w-4 ${vtype?.color || 'text-slate-400'}`} />
<span className="text-xs text-slate-400 shrink-0">数据卷名称</span>
<Input
value={vol.name}
onChange={(e) => {
const name = e.target.value;
const patch: Partial<VolumeForm> = { name };
if (vol.type === 'configMap' && !vol.configMapName) patch.configMapName = name;
if (vol.type === 'secret' && !vol.secretName) patch.secretName = name;
if (vol.type === 'persistentVolumeClaim' && !vol.pvcName) patch.pvcName = name;
updateVolume(i, patch);
}}
placeholder="如 app-logs / fluent-bit-config"
className="h-8 w-44 bg-slate-800 border-slate-700 text-slate-200 text-sm font-mono"
/>
{/* 原生 select,避免 Radix Select 在 overflow 容器内无法下拉 // Logan.Li */}
<select
value={selectValue}
onChange={(e) => {
const type = e.target.value as VolumeForm['type'];
const patch: Partial<VolumeForm> = { type };
if (type === 'configMap' && resources.configMaps.length === 1) patch.configMapName = resources.configMaps[0];
if (type === 'secret' && resources.secrets.length === 1) patch.secretName = resources.secrets[0];
if (type === 'persistentVolumeClaim' && resources.pvcs.length === 1) patch.pvcName = resources.pvcs[0];
updateVolume(i, patch);
}}
className="h-8 min-w-[168px] bg-slate-800 border border-slate-700 rounded-md text-xs text-slate-300 px-2"
>
{VOLUME_TYPES.map((t) => (
<option key={t.key} value={t.key}>{t.label}</option>
))}
</select>
<Button size="icon" variant="ghost" className="h-8 w-8 text-red-400 ml-auto" onClick={() => removeEntry(i)}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<VolumeTypeFields
vol={vol}
resources={resources}
resourcesLoading={resourcesLoading}
onChange={(p) => {
const vol = entries[i]?.volume;
const extra: Partial<VolumeForm> = {};
if (vol && !vol.name.trim()) {
if (p.configMapName) extra.name = p.configMapName;
if (p.secretName) extra.name = p.secretName;
if (p.pvcName) extra.name = p.pvcName;
}
updateVolume(i, { ...p, ...extra });
}}
onRefreshResources={loadResources}
onCreateResource={setCreateResource}
/>
</div>
{/* 挂载到容器(支持一卷多容器,Kuboard 同卡) */}
<div className="px-3 py-2.5 bg-slate-900/40 space-y-2">
<div className="flex items-center justify-between gap-2">
<p className="text-[11px] text-slate-500 flex items-center gap-1.5">
<Container className="h-3.5 w-3.5 text-blue-400" />
将上述数据卷挂载到容器
</p>
<Button size="sm" variant="ghost" className="h-6 px-2 text-[11px] text-slate-400 hover:text-slate-200" onClick={() => addMount(i)}>
<Plus className="h-3 w-3 mr-0.5" />添加挂载
</Button>
</div>
{entry.mounts.map((mount, mi) => (
<div key={mi} className="space-y-2 rounded-md border border-slate-800/60 bg-slate-950/40 p-2.5">
<div className="flex items-center gap-2 flex-wrap">
<select
value={mount.containerName}
onChange={(e) => updateMount(i, mi, { containerName: e.target.value })}
className="h-8 min-w-[140px] max-w-[280px] bg-slate-800 border border-slate-700 rounded-md text-sm text-slate-200 px-2 font-medium truncate"
>
{containers.length === 0 && <option value="">(无容器)</option>}
{containers.map((cn) => <option key={cn} value={cn}>{cn}</option>)}
</select>
<div className="flex items-center gap-1 bg-slate-800 rounded-lg p-0.5 border border-slate-700">
<button type="button" onClick={() => updateMount(i, mi, { readOnly: true })}
className={`px-2.5 py-1 rounded text-xs font-medium transition-all ${mount.readOnly ? 'bg-amber-500/20 text-amber-300' : 'text-slate-500 hover:text-slate-300'}`}>
只读
</button>
<button type="button" onClick={() => updateMount(i, mi, { readOnly: false })}
className={`px-2.5 py-1 rounded text-xs font-medium transition-all ${!mount.readOnly ? 'bg-blue-500/20 text-blue-300' : 'text-slate-500 hover:text-slate-300'}`}>
读写
</button>
</div>
<Input
value={mount.mountPath}
onChange={(e) => updateMount(i, mi, { mountPath: e.target.value })}
placeholder="挂载路径 /bitcloud/app/.../logs"
className="h-8 flex-1 min-w-[200px] bg-slate-800 border-slate-700 text-slate-200 text-sm font-mono"
/>
{entry.mounts.length > 1 && (
<Button size="icon" variant="ghost" className="h-8 w-8 text-red-400/70 shrink-0" onClick={() => removeMount(i, mi)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
<div className="flex items-center gap-2">
<span className="text-[11px] text-slate-500 shrink-0">子路径 subPath</span>
<Input
value={mount.subPath}
onChange={(e) => updateMount(i, mi, { subPath: e.target.value })}
placeholder="如 config.yaml(挂载单文件时填写)"
className="h-7 flex-1 max-w-md bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono"
/>
</div>
</div>
))}
</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>
);
}