- 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 回归通过
353 lines
17 KiB
TypeScript
353 lines
17 KiB
TypeScript
// 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>
|
||
);
|
||
} |