feat(k8s): 事件页简化、可视化编辑 P0、Deployment 五 Tab 修复

- 事件中心:去彩虹图表,改专业排名列表 + 主题变量
- 网络/配置/存储:ResourceVisualEditor 双向 YAML 同步、导入 YAML、新建
- vite manualChunks 减小 embed 主包体积
- run.sh:多副本强制 MODE=release + MySQL/Redis
- Deployment 编辑:共享 manifest、统一 saveDeploymentManifest
- 修复存储挂载 initContainers 混入 bug、补 Tolerations UI
- 服务 Tab:applyYAML 创建 + ResourceVisualEditor 编辑
- 基本信息:Pod 模板标签 + Selector 同步
This commit is contained in:
Logan.Li committed 2026-06-26 13:52:32 +08:00
1 parent 339b24a579
commit d0cf71b05a
17 files changed
+814 -497

No files matched your search

@@ -7,10 +7,11 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { import {
Save, Loader2, Settings, RefreshCw, Shield, Clock, Network, Save, Loader2, Settings, RefreshCw, Shield, Clock, Network, Plus, Trash2,
} from 'lucide-react'; } from 'lucide-react';
import { useToast } from '@/hooks/use-toast'; import { useToast } from '@/hooks/use-toast';
import { kubernetesApi } from '@/api'; import { kubernetesApi } from '@/api';
import { saveDeploymentManifest } from './deployment-manifest-save';
import { SchedulingSection, type ScheduleMode } from './deployment-edit-advanced/SchedulingSection'; import { SchedulingSection, type ScheduleMode } from './deployment-edit-advanced/SchedulingSection';
import { DnsHostSection } from './deployment-edit-advanced/DnsHostSection'; import { DnsHostSection } from './deployment-edit-advanced/DnsHostSection';
@@ -19,7 +20,7 @@ interface Props {
namespace: string; namespace: string;
deploymentName: string; deploymentName: string;
manifest: any | null; manifest: any | null;
onSaved: () => void; onSaved: (updated?: any) => void;
onCancel: () => void; onCancel: () => void;
} }
@@ -308,10 +309,9 @@ export function DeploymentEditAdvanced({ clusterCode, namespace, deploymentName,
if (Object.keys(dcObj).length > 0) podSpec.dnsConfig = dcObj; if (Object.keys(dcObj).length > 0) podSpec.dnsConfig = dcObj;
else delete podSpec.dnsConfig; else delete podSpec.dnsConfig;
const json = JSON.stringify(base, null, 2); await saveDeploymentManifest(clusterCode, namespace, deploymentName, base);
await kubernetesApi.updateResourceYaml(clusterCode, namespace, 'deployments', deploymentName, json);
toast({ title: '高级设置已保存', description: `${deploymentName} 更新已提交` }); toast({ title: '高级设置已保存', description: `${deploymentName} 更新已提交` });
onSaved(); onSaved(base);
} catch (e) { } catch (e) {
toast({ title: '保存失败', description: e?.message || String(e), variant: 'destructive' }); toast({ title: '保存失败', description: e?.message || String(e), variant: 'destructive' });
} finally { setSaving(false); } } finally { setSaving(false); }
@@ -398,6 +398,49 @@ export function DeploymentEditAdvanced({ clusterCode, namespace, deploymentName,
</CardContent> </CardContent>
</Card> </Card>
{/* 容忍 (Tolerations) — 此前仅有状态无 UI // Logan.Li */}
<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">容忍 (Tolerations)</CardTitle>
<Button size="sm" variant="outline" className="h-7 border-slate-700 text-slate-300 text-xs"
onClick={() => setTolerations(prev => [...prev, { key: '', operator: 'Equal', value: '', effect: 'NoSchedule', tolerationSeconds: '' }])}>
<Plus className="h-3 w-3 mr-1" />添加
</Button>
</div>
</CardHeader>
<CardContent className="pt-0 space-y-2">
{tolerations.length === 0 && <p className="text-xs text-slate-600">未配置容忍</p>}
{tolerations.map((t, i) => (
<div key={i} className="grid grid-cols-[1fr_80px_1fr_100px_80px_auto] gap-2 items-center">
<Input value={t.key} placeholder="key" onChange={e => setTolerations(prev => prev.map((x, idx) => idx === i ? { ...x, key: e.target.value } : x))}
className="h-7 bg-slate-800 border-slate-700 text-xs font-mono" />
<select value={t.operator} onChange={e => setTolerations(prev => prev.map((x, idx) => idx === i ? { ...x, operator: e.target.value } : x))}
className="h-7 bg-slate-800 border border-slate-700 rounded text-xs text-slate-300 px-1">
<option value="Equal">Equal</option>
<option value="Exists">Exists</option>
</select>
<Input value={t.value} placeholder="value" disabled={t.operator === 'Exists'}
onChange={e => setTolerations(prev => prev.map((x, idx) => idx === i ? { ...x, value: e.target.value } : x))}
className="h-7 bg-slate-800 border-slate-700 text-xs font-mono" />
<select value={t.effect} onChange={e => setTolerations(prev => prev.map((x, idx) => idx === i ? { ...x, effect: e.target.value } : x))}
className="h-7 bg-slate-800 border border-slate-700 rounded text-xs text-slate-300 px-1">
<option value="">任意</option>
<option value="NoSchedule">NoSchedule</option>
<option value="PreferNoSchedule">PreferNoSchedule</option>
<option value="NoExecute">NoExecute</option>
</select>
<Input value={t.tolerationSeconds} placeholder="秒" disabled={t.effect !== 'NoExecute'}
onChange={e => setTolerations(prev => prev.map((x, idx) => idx === i ? { ...x, tolerationSeconds: e.target.value } : x))}
className="h-7 bg-slate-800 border-slate-700 text-xs font-mono" />
<Button variant="ghost" size="icon" className="h-7 w-7 text-red-400" onClick={() => setTolerations(prev => prev.filter((_, idx) => idx !== i))}>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
</CardContent>
</Card>
<SchedulingSection <SchedulingSection
scheduleMode={scheduleMode} scheduleMode={scheduleMode}
onScheduleModeChange={setScheduleMode} onScheduleModeChange={setScheduleMode}
@@ -9,15 +9,14 @@ import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Plus, Trash2, Save, Loader2, Tags, FileText, Hash, Lock } from 'lucide-react'; import { Plus, Trash2, Save, Loader2, Tags, FileText, Hash, Lock } from 'lucide-react';
import { useToast } from '@/hooks/use-toast'; import { useToast } from '@/hooks/use-toast';
import { kubernetesApi } from '@/api'; import { saveDeploymentManifest } from './deployment-manifest-save';
interface DeploymentEditBasicProps { interface DeploymentEditBasicProps {
clusterCode: string; clusterCode: string;
namespace: string; namespace: string;
deploymentName: string; deploymentName: string;
/** 从后端拿到的 manifest 对象 */
manifest: any | null; manifest: any | null;
onSaved: () => void; onSaved: (updated?: any) => void;
onCancel: () => void; onCancel: () => void;
} }
@@ -46,8 +45,8 @@ export function DeploymentEditBasic({ clusterCode, namespace, deploymentName, ma
const [labels, setLabels] = useState<Array<{ key: string; value: string }>>([]); const [labels, setLabels] = useState<Array<{ key: string; value: string }>>([]);
const [annotations, setAnnotations] = useState<Array<{ key: string; value: string }>>([]); const [annotations, setAnnotations] = useState<Array<{ key: string; value: string }>>([]);
const [selectorLabels, setSelectorLabels] = useState<Array<{ key: string; value: string }>>([]); const [selectorLabels, setSelectorLabels] = useState<Array<{ key: string; value: string }>>([]);
const [templateLabels, setTemplateLabels] = useState<Array<{ key: string; value: string }>>([]);
// 初始化数据
useEffect(() => { useEffect(() => {
const m = manifest; const m = manifest;
if (!m) { setLoading(false); return; } if (!m) { setLoading(false); return; }
@@ -56,6 +55,7 @@ export function DeploymentEditBasic({ clusterCode, namespace, deploymentName, ma
setLabels(objToKv(m?.metadata?.labels)); setLabels(objToKv(m?.metadata?.labels));
setAnnotations(objToKv(m?.metadata?.annotations)); setAnnotations(objToKv(m?.metadata?.annotations));
setSelectorLabels(objToKv(m?.spec?.selector?.matchLabels)); setSelectorLabels(objToKv(m?.spec?.selector?.matchLabels));
setTemplateLabels(objToKv(m?.spec?.template?.metadata?.labels));
setLoading(false); setLoading(false);
}, [manifest]); }, [manifest]);
@@ -79,40 +79,47 @@ export function DeploymentEditBasic({ clusterCode, namespace, deploymentName, ma
}; };
const addSelector = () => setSelectorLabels(prev => [...prev, { key: '', value: '' }]); const addSelector = () => setSelectorLabels(prev => [...prev, { key: '', value: '' }]);
const removeSelector = (i: number) => setSelectorLabels(prev => prev.filter((_, idx) => idx !== i)); const removeSelector = (i: number) => setSelectorLabels(prev => prev.filter((_, idx) => idx !== i));
const updateTemplateLabel = (i: number, patch: Partial<{ key: string; value: string }>) => {
setTemplateLabels(prev => prev.map((l, idx) => (idx === i ? { ...l, ...patch } : l)));
};
const addTemplateLabel = () => setTemplateLabels(prev => [...prev, { key: '', value: '' }]);
const removeTemplateLabel = (i: number) => setTemplateLabels(prev => prev.filter((_, idx) => idx !== i));
const syncSelectorToTemplate = () => {
const sel = kvToObj(selectorLabels);
setTemplateLabels(prev => {
const merged = { ...kvToObj(prev), ...sel };
return Object.entries(merged).map(([key, value]) => ({ key, value }));
});
toast({ title: '已同步', description: 'Selector 标签已合并到 Pod 模板标签' });
};
// ============ 序列化并保存 ============
const save = async () => { const save = async () => {
setSaving(true); setSaving(true);
try { try {
const base = JSON.parse(JSON.stringify(rawManifest)); const base = JSON.parse(JSON.stringify(rawManifest));
// 副本数
base.spec = base.spec || {}; base.spec = base.spec || {};
base.spec.replicas = replicas; base.spec.replicas = replicas;
// labels
const labelObj = kvToObj(labels); const labelObj = kvToObj(labels);
base.metadata = base.metadata || {}; base.metadata = base.metadata || {};
base.metadata.labels = Object.keys(labelObj).length > 0 ? labelObj : undefined; base.metadata.labels = Object.keys(labelObj).length > 0 ? labelObj : undefined;
// annotations(空则删除 key)
const annObj = kvToObj(annotations); const annObj = kvToObj(annotations);
base.metadata.annotations = Object.keys(annObj).length > 0 ? annObj : undefined; 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); const selectorObj = kvToObj(selectorLabels);
if (Object.keys(selectorObj).length > 0) { if (Object.keys(selectorObj).length > 0) {
base.spec.selector = base.spec.selector || {}; base.spec.selector = base.spec.selector || {};
base.spec.selector.matchLabels = selectorObj; 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 tplLabelObj = kvToObj(templateLabels);
const json = JSON.stringify(base, null, 2); base.spec.template = base.spec.template || {};
await kubernetesApi.updateResourceYaml(clusterCode, namespace, 'deployments', deploymentName, json); base.spec.template.metadata = base.spec.template.metadata || {};
if (Object.keys(tplLabelObj).length > 0) {
base.spec.template.metadata.labels = tplLabelObj;
} else {
delete base.spec.template.metadata.labels;
}
await saveDeploymentManifest(clusterCode, namespace, deploymentName, base);
toast({ title: '基本信息已保存', description: `${deploymentName} 更新已提交` }); toast({ title: '基本信息已保存', description: `${deploymentName} 更新已提交` });
onSaved(); onSaved(base);
} catch (e) { } catch (e) {
toast({ title: '保存失败', description: e?.message || String(e), variant: 'destructive' }); toast({ title: '保存失败', description: e?.message || String(e), variant: 'destructive' });
} finally { setSaving(false); } } finally { setSaving(false); }
@@ -248,6 +255,42 @@ export function DeploymentEditBasic({ clusterCode, namespace, deploymentName, ma
</CardContent> </CardContent>
</Card> </Card>
{/* Pod 模板标签 */}
<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-cyan-400" />
Pod 模板标签 (template.metadata.labels)
</CardTitle>
<div className="flex gap-1">
<Button size="sm" variant="outline" className="h-7 border-slate-700 text-slate-300 text-xs" onClick={syncSelectorToTemplate}>
从 Selector 同步
</Button>
<Button size="sm" variant="outline" className="h-7 border-slate-700 text-slate-300 text-xs" onClick={addTemplateLabel}>
<Plus className="h-3 w-3 mr-1" />新增
</Button>
</div>
</div>
</CardHeader>
<CardContent className="pt-0 space-y-1.5">
<p className="text-[11px] text-slate-600 mb-1">Pod 模板标签必须包含 Selector 全部键值,否则 Deployment 无法管理 Pod</p>
{templateLabels.length === 0 && <p className="text-[11px] text-slate-600">无模板标签</p>}
{templateLabels.map((l, i) => (
<div key={i} className="grid grid-cols-[1fr_1.4fr_auto] gap-2">
<Input value={l.key} placeholder="app"
onChange={e => updateTemplateLabel(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 => updateTemplateLabel(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={() => removeTemplateLabel(i)}><Trash2 className="h-3.5 w-3.5" /></Button>
</div>
))}
</CardContent>
</Card>
{/* Annotations */} {/* Annotations */}
<Card className="bg-slate-900 border-slate-800"> <Card className="bg-slate-900 border-slate-800">
<CardHeader className="pb-3"> <CardHeader className="pb-3">
@@ -18,7 +18,7 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { useToast } from '@/hooks/use-toast'; import { useToast } from '@/hooks/use-toast';
import { kubernetesApi } from '@/api'; import { saveDeploymentManifest } from './deployment-manifest-save';
import { ContainerGroupSection } from './deployment-edit/ContainerGroupSection'; import { ContainerGroupSection } from './deployment-edit/ContainerGroupSection';
import { import {
@@ -99,10 +99,9 @@ export function DeploymentEditContainers({ clusterCode, namespace, deploymentNam
base.spec.template.spec.imagePullSecrets = imagePullSecrets.filter(s => s.trim()).length > 0 base.spec.template.spec.imagePullSecrets = imagePullSecrets.filter(s => s.trim()).length > 0
? imagePullSecrets.filter(s => s.trim()).map((s: string) => ({ name: s })) ? imagePullSecrets.filter(s => s.trim()).map((s: string) => ({ name: s }))
: undefined; : undefined;
const json = JSON.stringify(base, null, 2); await saveDeploymentManifest(clusterCode, namespace, deploymentName, base);
await kubernetesApi.updateResourceYaml(clusterCode, namespace, 'deployments', deploymentName, json);
toast({ title: '容器配置已保存', description: `${deploymentName} 更新已提交` }); toast({ title: '容器配置已保存', description: `${deploymentName} 更新已提交` });
onSaved(); onSaved(base);
} catch (e) { } catch (e) {
toast({ title: '保存失败', description: e?.message || String(e), variant: 'destructive' }); toast({ title: '保存失败', description: e?.message || String(e), variant: 'destructive' });
} finally { setSaving(false); } } finally { setSaving(false); }
@@ -13,7 +13,7 @@ import {
GitBranch, GitBranch,
Loader2, Loader2,
RefreshCw, RefreshCw,
ExternalLink, Pencil,
Plus, Plus,
CheckCircle, CheckCircle,
Server, Server,
@@ -25,13 +25,15 @@ import {
import { useToast } from '@/hooks/use-toast'; import { useToast } from '@/hooks/use-toast';
import { useConfirm } from '@/components/ui/confirm-dialog'; import { useConfirm } from '@/components/ui/confirm-dialog';
import { kubernetesApi } from '@/api'; import { kubernetesApi } from '@/api';
import { dumpManifest } from './k8s-manifest-utils';
import { ResourceVisualEditor } from './ResourceVisualEditor';
interface Props { interface Props {
clusterCode: string; clusterCode: string;
namespace: string; namespace: string;
deploymentName: string; deploymentName: string;
manifest: any | null; manifest: any | null;
onSaved: () => void; onSaved: (updated?: any) => void;
onCancel: () => void; onCancel: () => void;
} }
@@ -78,7 +80,7 @@ export function DeploymentEditService({ clusterCode, namespace, deploymentName,
const [services, setServices] = useState<SvcSummary[]>([]); const [services, setServices] = useState<SvcSummary[]>([]);
const [ingresses, setIngresses] = useState<IngSummary[]>([]); const [ingresses, setIngresses] = useState<IngSummary[]>([]);
const [selectorLabels, setSelectorLabels] = useState<Record<string, string>>({}); const [selectorLabels, setSelectorLabels] = useState<Record<string, string>>({});
const [yamlModal, setYamlModal] = useState<{ kind: 'service' | 'ingress'; name: string } | null>(null); const [visualEditor, setVisualEditor] = useState<{ resource: 'services' | 'ingresses'; name: string } | null>(null);
const [showCreate, setShowCreate] = useState<'service' | 'ingress' | null>(null); const [showCreate, setShowCreate] = useState<'service' | 'ingress' | null>(null);
// 创建 Service 表单 — 多端口 // 创建 Service 表单 — 多端口
@@ -219,9 +221,9 @@ export function DeploymentEditService({ clusterCode, namespace, deploymentName,
ports: k8sPorts, ports: k8sPorts,
}, },
}; };
const json = JSON.stringify(svcManifest, null, 2);
try { try {
await kubernetesApi.updateResourceYaml(clusterCode, namespace, 'services', name, json); const yaml = await dumpManifest(svcManifest);
await kubernetesApi.applyYAML(clusterCode, yaml);
toast({ title: 'Service 已创建', description: name }); toast({ title: 'Service 已创建', description: name });
setShowCreate(null); setShowCreate(null);
load(); load();
@@ -254,9 +256,9 @@ export function DeploymentEditService({ clusterCode, namespace, deploymentName,
}, },
}; };
if (!ingForm.className) delete manifest.spec.ingressClassName; if (!ingForm.className) delete manifest.spec.ingressClassName;
const json = JSON.stringify(manifest, null, 2);
try { try {
await kubernetesApi.updateResourceYaml(clusterCode, namespace, 'ingresses', name, json); const yaml = await dumpManifest(manifest);
await kubernetesApi.applyYAML(clusterCode, yaml);
toast({ title: 'Ingress 已创建', description: name }); toast({ title: 'Ingress 已创建', description: name });
setShowCreate(null); setShowCreate(null);
load(); load();
@@ -355,9 +357,9 @@ export function DeploymentEditService({ clusterCode, namespace, deploymentName,
<Badge variant="outline" className="border-slate-700 text-slate-400 text-[10px]">{svc.type}</Badge> <Badge variant="outline" className="border-slate-700 text-slate-400 text-[10px]">{svc.type}</Badge>
{matched && <Badge className="bg-blue-500/20 text-blue-300 border-blue-500/30 text-[10px]">关联</Badge>} {matched && <Badge className="bg-blue-500/20 text-blue-300 border-blue-500/30 text-[10px]">关联</Badge>}
<code className="text-[11px] text-slate-500 ml-auto">{svc.clusterIP}</code> <code className="text-[11px] text-slate-500 ml-auto">{svc.clusterIP}</code>
<Button size="sm" variant="ghost" className="h-6 text-xs text-slate-400" <Button size="sm" variant="ghost" className="h-6 text-xs text-emerald-400"
onClick={() => setYamlModal({ kind: 'service', name: svc.name })}> onClick={() => setVisualEditor({ resource: 'services', name: svc.name })}>
<ExternalLink className="h-3 w-3 mr-1" />YAML <Pencil className="h-3 w-3 mr-1" />编辑
</Button> </Button>
<Button size="sm" variant="ghost" className="h-6 w-6 p-0 text-red-400 hover:text-red-300" <Button size="sm" variant="ghost" className="h-6 w-6 p-0 text-red-400 hover:text-red-300"
title="删除 Service" onClick={() => deleteResource('service', svc.name)}> title="删除 Service" onClick={() => deleteResource('service', svc.name)}>
@@ -529,9 +531,9 @@ export function DeploymentEditService({ clusterCode, namespace, deploymentName,
<span className="text-sm font-semibold text-slate-200">{ing.name}</span> <span className="text-sm font-semibold text-slate-200">{ing.name}</span>
<Badge variant="outline" className="border-slate-700 text-slate-400 text-[10px]">{ing.className}</Badge> <Badge variant="outline" className="border-slate-700 text-slate-400 text-[10px]">{ing.className}</Badge>
{matched && <Badge className="bg-purple-500/20 text-purple-300 border-purple-500/30 text-[10px]">关联</Badge>} {matched && <Badge className="bg-purple-500/20 text-purple-300 border-purple-500/30 text-[10px]">关联</Badge>}
<Button size="sm" variant="ghost" className="h-6 text-xs text-slate-400 ml-auto" <Button size="sm" variant="ghost" className="h-6 text-xs text-emerald-400 ml-auto"
onClick={() => setYamlModal({ kind: 'ingress', name: ing.name })}> onClick={() => setVisualEditor({ resource: 'ingresses', name: ing.name })}>
<ExternalLink className="h-3 w-3 mr-1" />YAML <Pencil className="h-3 w-3 mr-1" />编辑
</Button> </Button>
<Button size="sm" variant="ghost" className="h-6 w-6 p-0 text-red-400 hover:text-red-300" <Button size="sm" variant="ghost" className="h-6 w-6 p-0 text-red-400 hover:text-red-300"
title="删除 Ingress" onClick={() => deleteResource('ingress', ing.name)}> title="删除 Ingress" onClick={() => deleteResource('ingress', ing.name)}>
@@ -604,15 +606,19 @@ export function DeploymentEditService({ clusterCode, namespace, deploymentName,
</CardContent> </CardContent>
</Card> </Card>
{/* YAML 弹窗 */} {visualEditor && (
{yamlModal && ( <ResourceVisualEditor
<div className="hidden">{/* YamlViewer can be re-added here if needed */}</div> clusterCode={clusterCode}
namespace={namespace}
resource={visualEditor.resource}
name={visualEditor.name}
onClose={() => setVisualEditor(null)}
onSaved={() => { setVisualEditor(null); load(); }}
/>
)} )}
{/* 底部操作 */}
<div className="flex justify-end gap-2 pt-2"> <div className="flex justify-end gap-2 pt-2">
<Button size="sm" variant="ghost" className="h-7 text-xs text-slate-400" onClick={onCancel}>完成</Button> <Button size="sm" variant="outline" className="h-7 text-xs border-slate-700 text-slate-400" onClick={onCancel}>返回查看</Button>
<Button size="sm" className="h-7 bg-slate-600 text-xs" onClick={onSaved}>保存并关闭</Button>
</div> </div>
</div> </div>
); );
@@ -11,14 +11,14 @@ import {
Plus, Trash2, Save, Loader2, HardDrive, Database, Folder, Lock, File, Disc, Server, Plus, Trash2, Save, Loader2, HardDrive, Database, Folder, Lock, File, Disc, Server,
} from 'lucide-react'; } from 'lucide-react';
import { useToast } from '@/hooks/use-toast'; import { useToast } from '@/hooks/use-toast';
import { kubernetesApi } from '@/api'; import { saveDeploymentManifest } from './deployment-manifest-save';
interface Props { interface Props {
clusterCode: string; clusterCode: string;
namespace: string; namespace: string;
deploymentName: string; deploymentName: string;
manifest: any | null; manifest: any | null;
onSaved: () => void; onSaved: (updated?: any) => void;
onCancel: () => void; onCancel: () => void;
} }
@@ -160,9 +160,8 @@ export function DeploymentEditStorage({ clusterCode, namespace, deploymentName,
if (k8sVols.length > 0) podSpec.volumes = k8sVols; if (k8sVols.length > 0) podSpec.volumes = k8sVols;
else delete podSpec.volumes; else delete podSpec.volumes;
// Save volumeMounts to each container // 分别更新工作容器和初始化容器的 volumeMounts // Logan.Li
const allContainers = [...(podSpec.containers || []), ...(podSpec.initContainers || [])]; const applyMounts = (c: any) => {
for (const c of allContainers) {
const cName = c.name || '(unnamed)'; const cName = c.name || '(unnamed)';
const cMounts = mounts const cMounts = mounts
.filter(m => m.containerName === cName && m.volumeName && m.mountPath) .filter(m => m.containerName === cName && m.volumeName && m.mountPath)
@@ -173,16 +172,13 @@ export function DeploymentEditStorage({ clusterCode, namespace, deploymentName,
}); });
if (cMounts.length > 0) c.volumeMounts = cMounts; if (cMounts.length > 0) c.volumeMounts = cMounts;
else delete c.volumeMounts; else delete c.volumeMounts;
} };
(podSpec.containers || []).forEach(applyMounts);
(podSpec.initContainers || []).forEach(applyMounts);
// Rebuild containers arrays await saveDeploymentManifest(clusterCode, namespace, deploymentName, base);
podSpec.containers = allContainers.filter(( c: any) => !(podSpec.initContainers || []).includes(c));
podSpec.initContainers = (podSpec.initContainers || []);
const json = JSON.stringify(base, null, 2);
await kubernetesApi.updateResourceYaml(clusterCode, namespace, 'deployments', deploymentName, json);
toast({ title: '存储配置已保存', description: `${deploymentName} 更新已提交` }); toast({ title: '存储配置已保存', description: `${deploymentName} 更新已提交` });
onSaved(); onSaved(base);
} catch (e) { } catch (e) {
toast({ title: '保存失败', description: e?.message || String(e), variant: 'destructive' }); toast({ title: '保存失败', description: e?.message || String(e), variant: 'destructive' });
} finally { setSaving(false); } } finally { setSaving(false); }
@@ -385,7 +385,7 @@ export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mod
namespace={namespace} namespace={namespace}
deploymentName={deploymentName} deploymentName={deploymentName}
manifest={manifest} manifest={manifest}
onSaved={() => { loadManifest(); loadData(); onModeChange('view'); }} onSaved={() => { loadManifest(); loadData(); }}
onCancel={() => onModeChange('view')} onCancel={() => onModeChange('view')}
/> />
)} )}
+188 -258
View File
@@ -2,22 +2,76 @@ import { useState, useEffect, useCallback, useMemo } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom'; import { useParams, useNavigate, Link } from 'react-router-dom';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow, Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from '@/components/ui/table'; } from '@/components/ui/table';
import { import { RefreshCw, ChevronRight, Bell, AlertTriangle, X } from 'lucide-react';
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
Cell,
} from 'recharts';
import { RefreshCw, ChevronRight, Bell, AlertTriangle, Info, Activity, Server, Layers } from 'lucide-react';
import { kubernetesApi } from '@/api'; import { kubernetesApi } from '@/api';
// Color palette for charts interface RankItem { name: string; count: number }
const CHART_COLORS = ['#EF4444', '#F59E0B', '#3B82F6', '#8B5CF6', '#EC4899', '#14B8A6', '#F97316', '#6366F1', '#84CC16', '#06B6D4'];
// 简洁排名列表,替代花哨柱状图 // Logan.Li
function RankPanel({
title, items, selected, onSelect, emptyText,
}: {
title: string;
items: RankItem[];
selected?: string;
onSelect?: (name: string) => void;
emptyText?: string;
}) {
const max = Math.max(...items.map((i) => i.count), 1);
return (
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-4">
<h3 className="text-sm font-medium text-[var(--dg-text-primary)] mb-3">{title}</h3>
{items.length === 0 ? (
<p className="text-xs text-[var(--dg-text-muted)] py-6 text-center">{emptyText || '暂无数据'}</p>
) : (
<div className="space-y-1.5 max-h-[220px] overflow-y-auto pr-1">
{items.map((item) => {
const active = selected === item.name;
return (
<button
key={item.name}
type="button"
className={`w-full flex items-center gap-2 rounded px-2 py-1.5 text-left transition-colors ${
active ? 'bg-[var(--dg-bg-tertiary)]' : 'hover:bg-[var(--dg-bg-tertiary)]/60'
}`}
onClick={() => onSelect?.(active ? '' : item.name)}
>
<span className="w-[88px] shrink-0 truncate text-xs text-[var(--dg-text-secondary)]" title={item.name}>
{item.name}
</span>
<div className="flex-1 h-1.5 rounded-full bg-[var(--dg-bg-tertiary)] overflow-hidden">
<div
className={`h-full rounded-full ${active ? 'bg-[var(--dg-accent)]' : 'bg-[var(--dg-text-muted)]/50'}`}
style={{ width: `${(item.count / max) * 100}%` }}
/>
</div>
<span className="w-8 text-right text-xs tabular-nums text-[var(--dg-text-muted)]">{item.count}</span>
</button>
);
})}
</div>
)}
</CardContent>
</Card>
);
}
function StatCell({ label, value, hint }: { label: string; value: number | string; hint?: string }) {
return (
<div className="px-4 py-3 border-r border-[var(--dg-border)] last:border-r-0">
<p className="text-xs text-[var(--dg-text-muted)]">{label}</p>
<p className="text-xl font-semibold tabular-nums text-[var(--dg-text-primary)] mt-0.5">{value}</p>
{hint && <p className="text-[10px] text-[var(--dg-text-muted)] mt-0.5">{hint}</p>}
</div>
);
}
export default function EventsPage() { export default function EventsPage() {
const { clusterCode } = useParams<{ clusterCode: string }>(); const { clusterCode } = useParams<{ clusterCode: string }>();
@@ -28,19 +82,15 @@ export default function EventsPage() {
const [namespaces, setNamespaces] = useState<string[]>([]); const [namespaces, setNamespaces] = useState<string[]>([]);
const [selectedNs, setSelectedNs] = useState('all'); const [selectedNs, setSelectedNs] = useState('all');
const [filterType, setFilterType] = useState('all'); const [filterType, setFilterType] = useState('all');
const [timeWindow, setTimeWindow] = useState('0'); // 0 = all const [timeWindow, setTimeWindow] = useState('0');
const [activeCluster, setActiveCluster] = useState(clusterCode || ''); const [activeCluster, setActiveCluster] = useState(clusterCode || '');
// Aggregation data
const [summary, setSummary] = useState<any>(null); const [summary, setSummary] = useState<any>(null);
const [selectedReason, setSelectedReason] = useState<string>(''); const [selectedReason, setSelectedReason] = useState('');
const [selectedKind, setSelectedKind] = useState<string>(''); const [selectedKind, setSelectedKind] = useState('');
// Expanded row
const [expandedRow, setExpandedRow] = useState<string | null>(null); const [expandedRow, setExpandedRow] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
kubernetesApi.getClusters().then(( res: any) => { kubernetesApi.getClusters().then((res: any) => {
const list = res?.list || []; const list = res?.list || [];
setClusters(list); setClusters(list);
if (!activeCluster && list.length > 0) { if (!activeCluster && list.length > 0) {
@@ -53,7 +103,7 @@ export default function EventsPage() {
useEffect(() => { useEffect(() => {
if (!activeCluster) return; if (!activeCluster) return;
kubernetesApi.getNamespaces(activeCluster).then(( res: any) => { kubernetesApi.getNamespaces(activeCluster).then((res: any) => {
setNamespaces(Array.isArray(res) ? res : (res?.list || res?.data || [])); setNamespaces(Array.isArray(res) ? res : (res?.list || res?.data || []));
}).catch(() => {}); }).catch(() => {});
}, [activeCluster]); }, [activeCluster]);
@@ -82,7 +132,6 @@ export default function EventsPage() {
useEffect(() => { loadEvents(); }, [loadEvents]); useEffect(() => { loadEvents(); }, [loadEvents]);
useEffect(() => { loadSummary(); }, [loadSummary]); useEffect(() => { loadSummary(); }, [loadSummary]);
// Filter events by selected reason/kind from aggregation click
const displayEvents = useMemo(() => { const displayEvents = useMemo(() => {
let filtered = events; let filtered = events;
const hours = parseInt(timeWindow, 10); const hours = parseInt(timeWindow, 10);
@@ -93,38 +142,27 @@ export default function EventsPage() {
return !ts || new Date(ts).getTime() >= since; return !ts || new Date(ts).getTime() >= since;
}); });
} }
if (selectedReason) { if (selectedReason) filtered = filtered.filter((e) => e.reason === selectedReason);
filtered = filtered.filter(e => e.reason === selectedReason); if (selectedKind) filtered = filtered.filter((e) => (e.object || '').startsWith(`${selectedKind}/`));
}
if (selectedKind) {
filtered = filtered.filter(e => (e.object || '').startsWith(selectedKind + '/'));
}
return filtered; return filtered;
}, [events, selectedReason, selectedKind, timeWindow]); }, [events, selectedReason, selectedKind, timeWindow]);
const warningCount = useMemo(() => events.filter(e => e.type === 'Warning').length, [events]); const warningCount = useMemo(() => events.filter((e) => e.type === 'Warning').length, [events]);
const normalCount = useMemo(() => events.filter(e => e.type === 'Normal').length, [events]); const normalCount = useMemo(() => events.filter((e) => e.type === 'Normal').length, [events]);
// Chart data from summary const reasonData = useMemo<RankItem[]>(() =>
const reasonData = useMemo(() => { (summary?.by_reason || []).map((r: any) => ({ name: r.reason || 'Unknown', count: r.count })),
const list = summary?.by_reason || []; [summary]);
return list.map(( r: any) => ({ name: r.reason || 'Unknown', count: r.count }));
}, [summary]);
const nsData = useMemo(() => { const nsData = useMemo<RankItem[]>(() =>
const list = summary?.by_namespace || []; (summary?.by_namespace || []).map((n: any) => ({ name: n.namespace || '<cluster>', count: n.count })),
return list.map(( n: any) => ({ name: n.namespace || '<cluster>', count: n.count })); [summary]);
}, [summary]);
const kindData = useMemo(() => { const kindData = useMemo<RankItem[]>(() =>
const list = summary?.by_kind || []; (summary?.by_kind || []).map((k: any) => ({ name: k.kind || 'Unknown', count: k.count })),
return list.map(( k: any) => ({ name: k.kind || 'Unknown', count: k.count })); [summary]);
}, [summary]);
const formatTime = (ts: string) => { const formatTime = (ts: string) => (ts ? ts.replace('T', ' ').substring(0, 19) : '-');
if (!ts) return '-';
return ts.replace('T', ' ').substring(0, 19);
};
const formatRelative = (ts: string) => { const formatRelative = (ts: string) => {
if (!ts) return ''; if (!ts) return '';
@@ -140,35 +178,42 @@ export default function EventsPage() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* Breadcrumb */} <div className="flex items-center gap-2 text-sm text-[var(--dg-text-muted)]">
<div className="flex items-center gap-2 text-sm text-slate-500"> <Link to="/kubernetes" className="hover:text-[var(--dg-text-secondary)]">集群</Link>
<Link to="/kubernetes" className="hover:text-slate-300">集群</Link> {activeCluster && (
{activeCluster && <><ChevronRight className="h-3 w-3" /><Link to={`/kubernetes/${activeCluster}`} className="hover:text-slate-300">{activeCluster}</Link></>} <>
<ChevronRight className="h-3 w-3" /><span className="text-slate-300">事件</span> <ChevronRight className="h-3 w-3" />
<Link to={`/kubernetes/${activeCluster}`} className="hover:text-[var(--dg-text-secondary)]">{activeCluster}</Link>
</>
)}
<ChevronRight className="h-3 w-3" />
<span className="text-[var(--dg-text-secondary)]">事件</span>
</div> </div>
{/* Header + Filters */}
<div className="flex items-center justify-between flex-wrap gap-3"> <div className="flex items-center justify-between flex-wrap gap-3">
<h2 className="text-lg font-semibold text-slate-100 flex items-center gap-2"> <h2 className="text-lg font-semibold text-[var(--dg-text-primary)] flex items-center gap-2">
<Bell className="h-5 w-5 text-yellow-400" />事件中心 <Bell className="h-5 w-5 text-[var(--dg-text-muted)]" />
事件中心
</h2> </h2>
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<Select value={activeCluster} onValueChange={v => { setActiveCluster(v); navigate(`/kubernetes/${v}/events`); }}> <Select value={activeCluster} onValueChange={(v) => { setActiveCluster(v); navigate(`/kubernetes/${v}/events`); }}>
<SelectTrigger className="w-40 h-8 text-xs bg-slate-800 border-slate-700"><SelectValue /></SelectTrigger> <SelectTrigger className="w-40 h-8 text-xs"><SelectValue /></SelectTrigger>
<SelectContent className="bg-slate-800 border-slate-700"> <SelectContent>
{clusters.map(( c: any) => <SelectItem key={c.id} value={c.code || String(c.id)} className="text-xs">{c.name || c.code}</SelectItem>)} {clusters.map((c: any) => (
<SelectItem key={c.id} value={c.code || String(c.id)} className="text-xs">{c.name || c.code}</SelectItem>
))}
</SelectContent> </SelectContent>
</Select> </Select>
<Select value={selectedNs} onValueChange={v => { setSelectedNs(v); setSelectedReason(''); setSelectedKind(''); }}> <Select value={selectedNs} onValueChange={(v) => { setSelectedNs(v); setSelectedReason(''); setSelectedKind(''); }}>
<SelectTrigger className="w-36 h-8 text-xs bg-slate-800 border-slate-700"><SelectValue placeholder="命名空间" /></SelectTrigger> <SelectTrigger className="w-36 h-8 text-xs"><SelectValue placeholder="命名空间" /></SelectTrigger>
<SelectContent className="bg-slate-800 border-slate-700"> <SelectContent>
<SelectItem value="all" className="text-xs">所有命名空间</SelectItem> <SelectItem value="all" className="text-xs">所有命名空间</SelectItem>
{namespaces.map(n => <SelectItem key={n} value={n} className="text-xs">{n}</SelectItem>)} {namespaces.map((n) => <SelectItem key={n} value={n} className="text-xs">{n}</SelectItem>)}
</SelectContent> </SelectContent>
</Select> </Select>
<Select value={timeWindow} onValueChange={v => setTimeWindow(v)}> <Select value={timeWindow} onValueChange={setTimeWindow}>
<SelectTrigger className="w-32 h-8 text-xs bg-slate-800 border-slate-700"><SelectValue /></SelectTrigger> <SelectTrigger className="w-32 h-8 text-xs"><SelectValue /></SelectTrigger>
<SelectContent className="bg-slate-800 border-slate-700"> <SelectContent>
<SelectItem value="0" className="text-xs">全部时间</SelectItem> <SelectItem value="0" className="text-xs">全部时间</SelectItem>
<SelectItem value="1" className="text-xs">近 1 小时</SelectItem> <SelectItem value="1" className="text-xs">近 1 小时</SelectItem>
<SelectItem value="6" className="text-xs">近 6 小时</SelectItem> <SelectItem value="6" className="text-xs">近 6 小时</SelectItem>
@@ -176,182 +221,69 @@ export default function EventsPage() {
<SelectItem value="168" className="text-xs">近 7 天</SelectItem> <SelectItem value="168" className="text-xs">近 7 天</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<Button variant="outline" size="sm" className="h-8 border-slate-700 text-slate-300 text-xs" onClick={() => { loadEvents(); loadSummary(); }} disabled={loading}> <Button variant="outline" size="sm" className="h-8 text-xs" onClick={() => { loadEvents(); loadSummary(); }} disabled={loading}>
<RefreshCw className={`h-3 w-3 mr-1 ${loading ? 'animate-spin' : ''}`} />刷新 <RefreshCw className={`h-3 w-3 mr-1 ${loading ? 'animate-spin' : ''}`} />
刷新
</Button> </Button>
</div> </div>
</div> </div>
{/* Summary Stats Cards */} {/* 统计条:统一边框,无彩色卡片 */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3"> <Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<Card className="bg-slate-900 border-slate-800"> <CardContent className="p-0">
<CardContent className="p-4"> <div className="grid grid-cols-2 md:grid-cols-4">
<div className="flex items-center justify-between"> <StatCell label="事件总数" value={summary?.total ?? events.length} />
<div> <StatCell label="告警事件" value={summary?.warning_count ?? warningCount} hint="Warning" />
<p className="text-xs text-slate-500 mb-1">事件总数</p> <StatCell label="正常事件" value={summary?.normal_count ?? normalCount} hint="Normal" />
<p className="text-2xl font-bold text-slate-100">{summary?.total ?? events.length}</p> <StatCell label="告警类型" value={reasonData.length} hint="不同 Reason" />
</div> </div>
<Activity className="h-8 w-8 text-slate-600" /> </CardContent>
</div> </Card>
</CardContent>
</Card>
<Card className="bg-slate-900 border-red-900/40">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-slate-500 mb-1">告警事件</p>
<p className="text-2xl font-bold text-red-400">{summary?.warning_count ?? warningCount}</p>
<p className="text-[10px] text-slate-600 mt-0.5">Warning 累计触发次数</p>
</div>
<AlertTriangle className="h-8 w-8 text-red-500/30" />
</div>
</CardContent>
</Card>
<Card className="bg-slate-900 border-emerald-900/40">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-slate-500 mb-1">正常事件</p>
<p className="text-2xl font-bold text-emerald-400">{summary?.normal_count ?? normalCount}</p>
</div>
<Info className="h-8 w-8 text-emerald-500/30" />
</div>
</CardContent>
</Card>
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-slate-500 mb-1">告警类型数</p>
<p className="text-2xl font-bold text-amber-400">{reasonData.length}</p>
<p className="text-[10px] text-slate-600 mt-0.5">不同 Reason 数量</p>
</div>
<Layers className="h-8 w-8 text-amber-500/30" />
</div>
</CardContent>
</Card>
</div>
{/* Active Filter Badge */}
{(selectedReason || selectedKind) && ( {(selectedReason || selectedKind) && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 text-xs">
<span className="text-xs text-slate-500">已选过滤:</span> <span className="text-[var(--dg-text-muted)]">筛选:</span>
{selectedReason && ( {selectedReason && (
<Badge className="bg-amber-500/20 text-amber-300 border-amber-500/30 cursor-pointer text-xs" onClick={() => setSelectedReason('')}> <button type="button" className="inline-flex items-center gap-1 rounded border border-[var(--dg-border)] px-2 py-0.5 text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-tertiary)]" onClick={() => setSelectedReason('')}>
原因: {selectedReason} ✕ 原因 {selectedReason} <X className="h-3 w-3" />
</Badge> </button>
)} )}
{selectedKind && ( {selectedKind && (
<Badge className="bg-blue-500/20 text-blue-300 border-blue-500/30 cursor-pointer text-xs" onClick={() => setSelectedKind('')}> <button type="button" className="inline-flex items-center gap-1 rounded border border-[var(--dg-border)] px-2 py-0.5 text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-tertiary)]" onClick={() => setSelectedKind('')}>
类型: {selectedKind} ✕ 类型 {selectedKind} <X className="h-3 w-3" />
</Badge> </button>
)} )}
</div> </div>
)} )}
{/* Charts Row */}
{reasonData.length > 0 && ( {reasonData.length > 0 && (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
{/* By Reason Chart */} <RankPanel title="告警原因" items={reasonData} selected={selectedReason} onSelect={setSelectedReason} />
<Card className="bg-slate-900 border-slate-800"> {nsData.length > 0 && <RankPanel title="命名空间" items={nsData} />}
<CardContent className="p-4">
<h3 className="text-sm font-medium text-slate-300 mb-3 flex items-center gap-1.5">
<AlertTriangle className="h-4 w-4 text-amber-400" />告警原因 Top {reasonData.length}
</h3>
<ResponsiveContainer width="100%" height={220}>
<BarChart data={reasonData} layout="vertical" margin={{ left: 10, right: 10, top: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" horizontal={false} />
<XAxis type="number" tick={{ fill: '#64748b', fontSize: 10 }} axisLine={{ stroke: '#334155' }} />
<YAxis type="category" dataKey="name" tick={{ fill: '#94a3b8', fontSize: 10 }} width={100} axisLine={{ stroke: '#334155' }} />
<Tooltip
contentStyle={{ background: '#1e293b', border: '1px solid #334155', borderRadius: '6px', fontSize: '12px' }}
labelStyle={{ color: '#e2e8f0' }}
/>
<Bar dataKey="count" radius={[0, 4, 4, 0]} cursor="pointer" onClick={( d: any) => setSelectedReason(prev => prev === d?.name ? '' : d?.name)}>
{reasonData.map(( _: any, i: number) => (
<Cell key={i} fill={CHART_COLORS[i % CHART_COLORS.length]} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
<p className="text-[10px] text-slate-600 text-center mt-1">点击柱状图过滤事件列表</p>
</CardContent>
</Card>
{/* By Namespace Chart */}
{nsData.length > 0 && (
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-4">
<h3 className="text-sm font-medium text-slate-300 mb-3 flex items-center gap-1.5">
<Server className="h-4 w-4 text-blue-400" />命名空间分布 Top {nsData.length}
</h3>
<ResponsiveContainer width="100%" height={220}>
<BarChart data={nsData} layout="vertical" margin={{ left: 10, right: 10, top: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" horizontal={false} />
<XAxis type="number" tick={{ fill: '#64748b', fontSize: 10 }} axisLine={{ stroke: '#334155' }} />
<YAxis type="category" dataKey="name" tick={{ fill: '#94a3b8', fontSize: 10 }} width={80} axisLine={{ stroke: '#334155' }} />
<Tooltip
contentStyle={{ background: '#1e293b', border: '1px solid #334155', borderRadius: '6px', fontSize: '12px' }}
labelStyle={{ color: '#e2e8f0' }}
/>
<Bar dataKey="count" radius={[0, 4, 4, 0]} fill="#3B82F6" fillOpacity={0.7} />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
)}
{/* By Kind Chart */}
{kindData.length > 0 && ( {kindData.length > 0 && (
<Card className="bg-slate-900 border-slate-800"> <RankPanel title="资源类型" items={kindData} selected={selectedKind} onSelect={setSelectedKind} />
<CardContent className="p-4">
<h3 className="text-sm font-medium text-slate-300 mb-3 flex items-center gap-1.5">
<Layers className="h-4 w-4 text-purple-400" />资源类型分布
</h3>
<ResponsiveContainer width="100%" height={220}>
<BarChart data={kindData} layout="vertical" margin={{ left: 10, right: 10, top: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" horizontal={false} />
<XAxis type="number" tick={{ fill: '#64748b', fontSize: 10 }} axisLine={{ stroke: '#334155' }} />
<YAxis type="category" dataKey="name" tick={{ fill: '#94a3b8', fontSize: 10 }} width={80} axisLine={{ stroke: '#334155' }} />
<Tooltip
contentStyle={{ background: '#1e293b', border: '1px solid #334155', borderRadius: '6px', fontSize: '12px' }}
labelStyle={{ color: '#e2e8f0' }}
/>
<Bar dataKey="count" radius={[0, 4, 4, 0]} cursor="pointer" onClick={( d: any) => setSelectedKind(prev => prev === d?.name ? '' : d?.name)}>
{kindData.map(( _: any, i: number) => (
<Cell key={i} fill={['#8B5CF6', '#6366F1', '#EC4899', '#14B8A6'][i % 4]} fillOpacity={0.7} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
<p className="text-[10px] text-slate-600 text-center mt-1">点击柱状图过滤事件列表</p>
</CardContent>
</Card>
)} )}
</div> </div>
)} )}
{/* Recent Warnings Alert Panel */}
{summary?.recent_warnings?.length > 0 && ( {summary?.recent_warnings?.length > 0 && (
<Card className="bg-red-950/20 border-red-900/30"> <Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] border-l-2 border-l-[var(--dg-warning)]">
<CardContent className="p-4"> <CardContent className="p-4">
<h3 className="text-sm font-medium text-red-300 mb-3 flex items-center gap-1.5"> <h3 className="text-sm font-medium text-[var(--dg-text-primary)] mb-3 flex items-center gap-1.5">
<AlertTriangle className="h-4 w-4" />最新告警事件 ({summary.recent_warnings.length}) <AlertTriangle className="h-4 w-4 text-[var(--dg-warning)]" />
最新告警 ({summary.recent_warnings.length})
</h3> </h3>
<div className="space-y-1.5"> <div className="space-y-1">
{summary.recent_warnings.map(( w: any, i: number) => ( {summary.recent_warnings.map((w: any, i: number) => (
<div key={i} className="flex items-start gap-2 text-xs py-1.5 px-2 rounded bg-slate-900/40 hover:bg-slate-800/40 transition-colors"> <div key={i} className="text-xs py-1.5 px-2 rounded hover:bg-[var(--dg-bg-tertiary)]/50">
<AlertTriangle className="h-3.5 w-3.5 text-red-400 mt-0.5 shrink-0" /> <div className="flex items-center gap-2 flex-wrap">
<div className="flex-1 min-w-0"> <span className="shrink-0 font-mono text-[var(--dg-warning)]">{w.reason}</span>
<div className="flex items-center gap-2 flex-wrap"> <span className="text-[var(--dg-text-muted)] font-mono truncate">{w.object}</span>
<Badge variant="outline" className="text-[10px] h-4 px-1 border-red-500/30 text-red-400">{w.reason}</Badge> <span className="text-[var(--dg-text-muted)]">{w.namespace}</span>
<span className="text-slate-500 font-mono text-[11px]">{w.object}</span> <span className="text-[var(--dg-text-muted)]">×{w.count}</span>
<span className="text-slate-600 text-[10px]">{w.namespace}</span> <span className="ml-auto text-[var(--dg-text-muted)]">{formatRelative(w.last_time)}</span>
<span className="text-slate-600 text-[10px]">×{w.count}</span>
<span className="text-slate-600 text-[10px] ml-auto">{formatRelative(w.last_time)}</span>
</div>
<p className="text-slate-400 mt-0.5 truncate" title={w.message}>{w.message}</p>
</div> </div>
<p className="text-[var(--dg-text-secondary)] truncate mt-0.5" title={w.message}>{w.message}</p>
</div> </div>
))} ))}
</div> </div>
@@ -359,74 +291,72 @@ export default function EventsPage() {
</Card> </Card>
)} )}
{/* Events Table */} <Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-0"> <CardContent className="p-0">
<div className="flex items-center justify-between px-4 py-2 border-b border-slate-800"> <div className="flex items-center justify-between px-4 py-2 border-b border-[var(--dg-border)]">
<span className="text-sm text-slate-400">事件列表 ({displayEvents.length})</span> <span className="text-sm text-[var(--dg-text-secondary)]">事件列表 ({displayEvents.length})</span>
<Select value={filterType} onValueChange={setFilterType}> <Select value={filterType} onValueChange={setFilterType}>
<SelectTrigger className="w-28 h-7 text-xs bg-slate-800 border-slate-700"><SelectValue /></SelectTrigger> <SelectTrigger className="w-28 h-7 text-xs"><SelectValue /></SelectTrigger>
<SelectContent className="bg-slate-800 border-slate-700"> <SelectContent>
<SelectItem value="all" className="text-xs">所有类型</SelectItem> <SelectItem value="all" className="text-xs">所有类型</SelectItem>
<SelectItem value="Warning" className="text-xs">⚠ Warning</SelectItem> <SelectItem value="Warning" className="text-xs">Warning</SelectItem>
<SelectItem value="Normal" className="text-xs">ℹ Normal</SelectItem> <SelectItem value="Normal" className="text-xs">Normal</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<Table> <Table>
<TableHeader><TableRow className="border-slate-800 hover:bg-transparent"> <TableHeader>
<TableHead className="text-slate-400 w-16">类型</TableHead> <TableRow className="border-[var(--dg-border)] hover:bg-transparent">
<TableHead className="text-slate-400 w-24">原因</TableHead> <TableHead className="text-[var(--dg-text-muted)] w-16">类型</TableHead>
<TableHead className="text-slate-400">对象</TableHead> <TableHead className="text-[var(--dg-text-muted)] w-24">原因</TableHead>
<TableHead className="text-slate-400">消息</TableHead> <TableHead className="text-[var(--dg-text-muted)]">对象</TableHead>
<TableHead className="text-slate-400 w-12">次数</TableHead> <TableHead className="text-[var(--dg-text-muted)]">消息</TableHead>
<TableHead className="text-slate-400 w-20">命名空间</TableHead> <TableHead className="text-[var(--dg-text-muted)] w-12">次数</TableHead>
<TableHead className="text-slate-400 w-40">最后时间</TableHead> <TableHead className="text-[var(--dg-text-muted)] w-20">命名空间</TableHead>
</TableRow></TableHeader> <TableHead className="text-[var(--dg-text-muted)] w-40">最后时间</TableHead>
</TableRow>
</TableHeader>
<TableBody> <TableBody>
{displayEvents.length === 0 ? ( {displayEvents.length === 0 ? (
<TableRow><TableCell colSpan={7} className="text-center text-slate-500 py-8"> <TableRow>
{loading ? <span className="animate-spin inline-block">⏳</span> : '暂无事件'} <TableCell colSpan={7} className="text-center text-[var(--dg-text-muted)] py-8">
</TableCell></TableRow> {loading ? <RefreshCw className="h-4 w-4 animate-spin mx-auto" /> : '暂无事件'}
</TableCell>
</TableRow>
) : displayEvents.slice(0, 200).map((e, i) => { ) : displayEvents.slice(0, 200).map((e, i) => {
const rowKey = `${e.name}-${i}`; const rowKey = `${e.name}-${i}`;
const isExpanded = expandedRow === rowKey; const isExpanded = expandedRow === rowKey;
const isWarning = e.type === 'Warning';
return ( return (
<TableRow <TableRow
key={rowKey} key={rowKey}
className={`border-slate-800 cursor-pointer ${e.type === 'Warning' ? 'hover:bg-red-950/10' : 'hover:bg-slate-800/30'} ${isExpanded ? 'bg-slate-800/20' : ''}`} className={`border-[var(--dg-border)] cursor-pointer hover:bg-[var(--dg-bg-tertiary)]/40 ${isExpanded ? 'bg-[var(--dg-bg-tertiary)]/30' : ''}`}
onClick={() => setExpandedRow(isExpanded ? null : rowKey)} onClick={() => setExpandedRow(isExpanded ? null : rowKey)}
> >
<TableCell> <TableCell>
{e.type === 'Warning' ? ( <span className={`text-xs font-medium ${isWarning ? 'text-[var(--dg-warning)]' : 'text-[var(--dg-text-muted)]'}`}>
<Badge className="bg-red-500/20 text-red-400 border-red-500/30 text-xs"><AlertTriangle className="h-3 w-3 mr-0.5 inline" />W</Badge> {isWarning ? 'Warning' : 'Normal'}
) : ( </span>
<Badge className="bg-blue-500/20 text-blue-400 border-blue-500/30 text-xs"><Info className="h-3 w-3 mr-0.5 inline" />N</Badge>
)}
</TableCell> </TableCell>
<TableCell className="text-xs text-slate-300 font-medium"> <TableCell className="text-xs text-[var(--dg-text-secondary)] font-medium">{e.reason || '-'}</TableCell>
<span className={e.type === 'Warning' ? 'text-amber-400' : 'text-slate-400'}>{e.reason || '-'}</span> <TableCell className="text-xs text-[var(--dg-text-muted)] font-mono">{e.object || '-'}</TableCell>
</TableCell> <TableCell className="text-xs text-[var(--dg-text-secondary)] max-w-[400px]">
<TableCell className="text-xs text-slate-500 font-mono">{e.object || '-'}</TableCell>
<TableCell className="text-xs text-slate-400 max-w-[400px]">
<div className={`truncate ${isExpanded ? 'whitespace-normal' : ''}`} title={!isExpanded ? e.message : undefined}> <div className={`truncate ${isExpanded ? 'whitespace-normal' : ''}`} title={!isExpanded ? e.message : undefined}>
{e.message || '-'} {e.message || '-'}
</div> </div>
{isExpanded && ( {isExpanded && (
<div className="mt-1 pt-1 border-t border-slate-800 text-[10px] text-slate-600 space-y-0.5"> <div className="mt-1 pt-1 border-t border-[var(--dg-border)] text-[10px] text-[var(--dg-text-muted)] space-y-0.5">
<div>首次出现: {formatTime(e.first_time)} ({formatRelative(e.first_time)})</div> <div>首次: {formatTime(e.first_time)} ({formatRelative(e.first_time)})</div>
<div>最后出现: {formatTime(e.last_time)} ({formatRelative(e.last_time)})</div> <div>最后: {formatTime(e.last_time)} ({formatRelative(e.last_time)})</div>
<div>事件名称: {e.name}</div> <div>名称: {e.name}</div>
</div> </div>
)} )}
</TableCell> </TableCell>
<TableCell className="text-sm text-slate-400 text-center"> <TableCell className="text-sm text-[var(--dg-text-muted)] text-center tabular-nums">{e.count > 1 ? e.count : 1}</TableCell>
{e.count > 1 ? <Badge variant="outline" className="text-[10px] h-4 px-1">{e.count}</Badge> : <span className="text-slate-600">1</span>} <TableCell className="text-xs text-[var(--dg-text-muted)]">{e.namespace || '-'}</TableCell>
</TableCell> <TableCell className="text-xs text-[var(--dg-text-muted)]">
<TableCell className="text-xs text-slate-500">{e.namespace || '-'}</TableCell>
<TableCell className="text-xs text-slate-500">
<div>{formatTime(e.last_time)}</div> <div>{formatTime(e.last_time)}</div>
<div className="text-[10px] text-slate-600">{formatRelative(e.last_time)}</div> <div className="text-[10px]">{formatRelative(e.last_time)}</div>
</TableCell> </TableCell>
</TableRow> </TableRow>
); );
@@ -434,12 +364,12 @@ export default function EventsPage() {
</TableBody> </TableBody>
</Table> </Table>
{displayEvents.length > 200 && ( {displayEvents.length > 200 && (
<div className="text-center py-2 text-xs text-slate-600 border-t border-slate-800"> <div className="text-center py-2 text-xs text-[var(--dg-text-muted)] border-t border-[var(--dg-border)]">
仅显示前 200 条,共 {displayEvents.length} 条事件 仅显示前 200 条,共 {displayEvents.length} 条
</div> </div>
)} )}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
); );
} }
@@ -13,9 +13,11 @@ import {
import { import {
RefreshCw, Search, Loader2, Boxes, Container, RefreshCw, Search, Loader2, Boxes, Container,
Network, FileText, Shield, Activity, Clock, ChevronRight, Network, FileText, Shield, Activity, Clock, ChevronRight,
Download, FileCode, Pencil, Terminal, HardDrive, Download, FileCode, Pencil, Terminal, HardDrive, Plus, Upload,
} from 'lucide-react'; } from 'lucide-react';
import { YamlViewer } from './YamlViewer'; import { YamlViewer } from './YamlViewer';
import { ResourceVisualEditor } from './ResourceVisualEditor';
import { YamlImportDialog } from './YamlImportDialog';
import { PodExecTerminal } from './PodExecTerminal'; import { PodExecTerminal } from './PodExecTerminal';
import { PodFileBrowser } from './PodFileBrowser'; import { PodFileBrowser } from './PodFileBrowser';
import { kubernetesApi } from '@/api'; import { kubernetesApi } from '@/api';
@@ -27,6 +29,18 @@ type ResourceType =
| 'pvcs' | 'statefulsets' | 'daemonsets' | 'pvcs' | 'statefulsets' | 'daemonsets'
| 'serviceaccounts' | 'roles' | 'rolebindings'; | 'serviceaccounts' | 'roles' | 'rolebindings';
// 支持可视化编辑/新建的资源类型 // Logan.Li
const VISUAL_RESOURCE_TYPES: ResourceType[] = ['services', 'ingresses', 'configmaps', 'secrets', 'pvcs'];
const VisualActions = ({ name, opts }: { name: string; opts: { onYaml?: (n: string) => void; onEdit?: (n: string) => void } }) => (
<div className="flex items-center gap-0.5">
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs text-emerald-400 hover:text-emerald-300" title="可视化编辑"
onClick={() => opts.onEdit?.(name)}><Pencil className="h-3.5 w-3.5 mr-1" />编辑</Button>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs text-slate-400 hover:text-slate-300" title="YAML"
onClick={() => opts.onYaml?.(name)}><FileCode className="h-3.5 w-3.5 mr-1" />YAML</Button>
</div>
);
const RESOURCE_CONFIG: Record<ResourceType, { const RESOURCE_CONFIG: Record<ResourceType, {
title: string; title: string;
subtitle: string; subtitle: string;
@@ -104,10 +118,7 @@ const RESOURCE_CONFIG: Record<ResourceType, {
<TableCell><Badge className="bg-blue-500/20 text-blue-400 border-blue-500/30">{s.type || '-'}</Badge></TableCell> <TableCell><Badge className="bg-blue-500/20 text-blue-400 border-blue-500/30">{s.type || '-'}</Badge></TableCell>
<TableCell className="text-sm text-slate-400">{s.cluster_ip || '-'}</TableCell> <TableCell className="text-sm text-slate-400">{s.cluster_ip || '-'}</TableCell>
<TableCell className="text-sm text-slate-400">{s.ports || '-'}</TableCell> <TableCell className="text-sm text-slate-400">{s.ports || '-'}</TableCell>
<TableCell> <TableCell onClick={(e) => e.stopPropagation()}><VisualActions name={s.name} opts={opts} /></TableCell>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs text-blue-400 hover:text-blue-300" title="YAML 编辑"
onClick={() => opts.onEdit?.(s.name)}><FileCode className="h-3.5 w-3.5 mr-1" />YAML</Button>
</TableCell>
</TableRow> </TableRow>
), ),
}, },
@@ -122,10 +133,7 @@ const RESOURCE_CONFIG: Record<ResourceType, {
<TableCell><Badge variant="outline" className="border-slate-700 text-slate-400">{i.namespace}</Badge></TableCell> <TableCell><Badge variant="outline" className="border-slate-700 text-slate-400">{i.namespace}</Badge></TableCell>
<TableCell className="text-sm text-slate-400 max-w-[250px] truncate">{hosts.length > 0 ? hosts.join(', ') : '-'}</TableCell> <TableCell className="text-sm text-slate-400 max-w-[250px] truncate">{hosts.length > 0 ? hosts.join(', ') : '-'}</TableCell>
<TableCell><Badge className={i.tls ? 'bg-emerald-500/20 text-emerald-400' : 'bg-slate-500/20 text-slate-400'}>{i.tls ? 'HTTPS' : 'HTTP'}</Badge></TableCell> <TableCell><Badge className={i.tls ? 'bg-emerald-500/20 text-emerald-400' : 'bg-slate-500/20 text-slate-400'}>{i.tls ? 'HTTPS' : 'HTTP'}</Badge></TableCell>
<TableCell> <TableCell onClick={(e) => e.stopPropagation()}><VisualActions name={i.name} opts={opts} /></TableCell>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs text-blue-400 hover:text-blue-300" title="YAML 编辑"
onClick={() => opts.onEdit?.(i.name)}><FileCode className="h-3.5 w-3.5 mr-1" />YAML</Button>
</TableCell>
</TableRow> </TableRow>
); );
}, },
@@ -138,10 +146,7 @@ const RESOURCE_CONFIG: Record<ResourceType, {
<TableCell className="font-medium text-slate-200">{c.name}</TableCell> <TableCell className="font-medium text-slate-200">{c.name}</TableCell>
<TableCell><Badge variant="outline" className="border-slate-700 text-slate-400">{c.namespace}</Badge></TableCell> <TableCell><Badge variant="outline" className="border-slate-700 text-slate-400">{c.namespace}</Badge></TableCell>
<TableCell className="text-sm text-slate-400">{c.key_count || '-'}</TableCell> <TableCell className="text-sm text-slate-400">{c.key_count || '-'}</TableCell>
<TableCell> <TableCell onClick={(e) => e.stopPropagation()}><VisualActions name={c.name} opts={opts} /></TableCell>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs text-blue-400 hover:text-blue-300" title="YAML 编辑"
onClick={() => opts.onEdit?.(c.name)}><FileCode className="h-3.5 w-3.5 mr-1" />YAML</Button>
</TableCell>
</TableRow> </TableRow>
), ),
}, },
@@ -153,10 +158,7 @@ const RESOURCE_CONFIG: Record<ResourceType, {
<TableCell className="font-medium text-slate-200">{s.name}</TableCell> <TableCell className="font-medium text-slate-200">{s.name}</TableCell>
<TableCell><Badge variant="outline" className="border-slate-700 text-slate-400">{s.namespace}</Badge></TableCell> <TableCell><Badge variant="outline" className="border-slate-700 text-slate-400">{s.namespace}</Badge></TableCell>
<TableCell className="text-sm text-slate-400">{s.type || '-'}</TableCell> <TableCell className="text-sm text-slate-400">{s.type || '-'}</TableCell>
<TableCell> <TableCell onClick={(e) => e.stopPropagation()}><VisualActions name={s.name} opts={opts} /></TableCell>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs text-blue-400 hover:text-blue-300" title="YAML 编辑"
onClick={() => opts.onEdit?.(s.name)}><FileCode className="h-3.5 w-3.5 mr-1" />YAML</Button>
</TableCell>
</TableRow> </TableRow>
), ),
}, },
@@ -197,10 +199,7 @@ const RESOURCE_CONFIG: Record<ResourceType, {
<TableCell><Badge className={p.phase === 'Bound' ? 'bg-emerald-500/20 text-emerald-400' : 'bg-yellow-500/20 text-yellow-400'}>{p.phase || '-'}</Badge></TableCell> <TableCell><Badge className={p.phase === 'Bound' ? 'bg-emerald-500/20 text-emerald-400' : 'bg-yellow-500/20 text-yellow-400'}>{p.phase || '-'}</Badge></TableCell>
<TableCell className="text-sm text-slate-400">{p.size || '-'}</TableCell> <TableCell className="text-sm text-slate-400">{p.size || '-'}</TableCell>
<TableCell className="text-sm text-slate-400">{p.storage_class || '-'}</TableCell> <TableCell className="text-sm text-slate-400">{p.storage_class || '-'}</TableCell>
<TableCell> <TableCell onClick={(e) => e.stopPropagation()}><VisualActions name={p.name} opts={opts} /></TableCell>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs text-blue-400 hover:text-blue-300" title="YAML 编辑"
onClick={() => opts.onEdit?.(p.name)}><FileCode className="h-3.5 w-3.5 mr-1" />YAML</Button>
</TableCell>
</TableRow> </TableRow>
), ),
}, },
@@ -338,6 +337,8 @@ export default function NamespaceResourcePage() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [yamlItem, setYamlItem] = useState<{resource: string; name: string; editMode?: boolean} | null>(null); const [yamlItem, setYamlItem] = useState<{resource: string; name: string; editMode?: boolean} | null>(null);
const [visualItem, setVisualItem] = useState<{ resource: string; name: string; isCreate?: boolean } | null>(null);
const [showYamlImport, setShowYamlImport] = useState(false);
const [terminalPod, setTerminalPod] = useState<any>(null); const [terminalPod, setTerminalPod] = useState<any>(null);
const [fileBrowserPod, setFileBrowserPod] = useState<any>(null); const [fileBrowserPod, setFileBrowserPod] = useState<any>(null);
@@ -405,8 +406,15 @@ export default function NamespaceResourcePage() {
}; };
const handleYaml = (name: string) => setYamlItem({ resource: type, name }); const handleYaml = (name: string) => setYamlItem({ resource: type, name });
// 网络/配置/存储统一走 YamlViewer 编辑(清理只读字段 + CodeMirror)// Logan.Li // 网络/配置/存储走可视化编辑器,YAML 作为高级入口 // Logan.Li
const handleEdit = (name: string) => setYamlItem({ resource: type, name, editMode: true }); const handleEdit = (name: string) => {
if (VISUAL_RESOURCE_TYPES.includes(type)) {
setVisualItem({ resource: type, name });
} else {
setYamlItem({ resource: type, name, editMode: true });
}
};
const handleCreate = () => setVisualItem({ resource: type, name: '', isCreate: true });
const handleExportCSV = () => kubernetesApi.exportAsCSV(`${type}-${clusterCode}-${namespace}.csv`, filtered); const handleExportCSV = () => kubernetesApi.exportAsCSV(`${type}-${clusterCode}-${namespace}.csv`, filtered);
const handleExportJSON = () => kubernetesApi.exportAsJSON(`${type}-${clusterCode}-${namespace}.json`, filtered); const handleExportJSON = () => kubernetesApi.exportAsJSON(`${type}-${clusterCode}-${namespace}.json`, filtered);
@@ -426,6 +434,24 @@ export default function NamespaceResourcePage() {
autoEdit={yamlItem.editMode === true} autoEdit={yamlItem.editMode === true}
/> />
)} )}
{showYamlImport && (
<YamlImportDialog
clusterCode={clusterCode || ''}
onClose={() => setShowYamlImport(false)}
onApplied={loadData}
/>
)}
{visualItem && (
<ResourceVisualEditor
clusterCode={clusterCode || ''}
namespace={namespace || ''}
resource={visualItem.resource}
name={visualItem.name}
isCreate={visualItem.isCreate}
onClose={() => setVisualItem(null)}
onSaved={loadData}
/>
)}
<div className="flex items-center gap-2 text-sm text-slate-500"> <div className="flex items-center gap-2 text-sm text-slate-500">
<Link to="/kubernetes" className="hover:text-slate-300 transition-colors">集群</Link> <Link to="/kubernetes" className="hover:text-slate-300 transition-colors">集群</Link>
@@ -456,6 +482,16 @@ export default function NamespaceResourcePage() {
<Button variant="outline" size="sm" className="h-9 border-slate-700 text-slate-400 text-xs" onClick={handleExportJSON} title="导出JSON"> <Button variant="outline" size="sm" className="h-9 border-slate-700 text-slate-400 text-xs" onClick={handleExportJSON} title="导出JSON">
<Download className="h-3 w-3 mr-1" />JSON <Download className="h-3 w-3 mr-1" />JSON
</Button> </Button>
{VISUAL_RESOURCE_TYPES.includes(type) && (
<>
<Button variant="outline" size="sm" className="h-9 border-slate-700 text-slate-400 text-xs" onClick={() => setShowYamlImport(true)}>
<Upload className="h-3 w-3 mr-1" />导入
</Button>
<Button size="sm" className="h-9 text-xs bg-blue-600 hover:bg-blue-700" onClick={handleCreate}>
<Plus className="h-3 w-3 mr-1" />新建
</Button>
</>
)}
</div> </div>
</div> </div>
<div className="flex items-center gap-3 flex-wrap"> <div className="flex items-center gap-3 flex-wrap">
@@ -1,7 +1,4 @@
// ResourceVisualEditor.tsx — Kubernetes 资源可视化编辑器 // ResourceVisualEditor — ConfigMap/Secret/Service/Ingress/PVC 可视化编辑 // Logan.Li
// 支持:ConfigMap / Secret / Service / Ingress / PVC
// 替代纯 YAML 编辑,提供表单式交互体验
// 作者: Logan <admin@attacker.club>
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { X, Save, Loader2, Plus, Trash2, FileText, Network, Shield, Database, ChevronRight} from 'lucide-react'; import { X, Save, Loader2, Plus, Trash2, FileText, Network, Shield, Database, ChevronRight} from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -15,6 +12,8 @@ import CodeMirror from '@uiw/react-codemirror';
import { yaml } from '@codemirror/lang-yaml'; import { yaml } from '@codemirror/lang-yaml';
import { oneDark } from '@codemirror/theme-one-dark'; import { oneDark } from '@codemirror/theme-one-dark';
import { cleanYAML } from './k8s-yaml-utils'; import { cleanYAML } from './k8s-yaml-utils';
import { createEmptyManifest } from './resource-create-templates';
import { parseManifest, dumpManifest, prepareManifestForSave } from './k8s-manifest-utils';
interface ResourceVisualEditorProps { interface ResourceVisualEditorProps {
clusterCode: string; clusterCode: string;
@@ -23,69 +22,80 @@ interface ResourceVisualEditorProps {
name: string; name: string;
onClose: () => void; onClose: () => void;
onSaved?: () => void; onSaved?: () => void;
/** 新建模式:不拉取远端 YAML,使用空模板 // Logan.Li */
isCreate?: boolean;
} }
// Simple YAML→JS parser (avoids js-yaml dependency — K8s API returns JSON-compatible YAML) export function ResourceVisualEditor({ clusterCode, namespace, resource, name, onClose, onSaved, isCreate = false }: ResourceVisualEditorProps) {
function tryParseYAML(text: string): any {
try { return JSON.parse(text); } catch {}
// Fallback: try trimming and re-parsing as JSON (K8s API mostly returns JSON-serialized YAML)
try {
const lines = text.split('\n').filter(l => !l.trim().startsWith('#'));
return JSON.parse(lines.join('\n'));
} catch { return null; }
}
export function ResourceVisualEditor({ clusterCode, namespace, resource, name, onClose, onSaved }: ResourceVisualEditorProps) {
const { toast } = useToast(); const { toast } = useToast();
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [rawYaml, setRawYaml] = useState(''); const [rawYaml, setRawYaml] = useState('');
const [manifest, setManifest] = useState<any>(null); const [manifest, setManifest] = useState<any>(null);
// Track which tab: 'visual' | 'yaml'
const [mode, setMode] = useState<'visual' | 'yaml'>('visual'); const [mode, setMode] = useState<'visual' | 'yaml'>('visual');
// YAML editor state (for fallback yaml mode)
const [editYaml, setEditYaml] = useState(''); const [editYaml, setEditYaml] = useState('');
const loadData = useCallback(async () => { const loadData = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
if (isCreate) {
const empty = createEmptyManifest(resource, namespace, name || '');
setManifest(empty);
setEditYaml(JSON.stringify(empty, null, 2));
setRawYaml('');
return;
}
const text = await kubernetesApi.getResourceYaml(clusterCode, namespace, resource, name); const text = await kubernetesApi.getResourceYaml(clusterCode, namespace, resource, name);
const cleaned = cleanYAML(text); const cleaned = cleanYAML(text);
setRawYaml(text); setRawYaml(text);
setEditYaml(cleaned); setEditYaml(cleaned);
const parsed = tryParseYAML(cleaned); const parsed = await parseManifest(cleaned);
setManifest(parsed); setManifest(parsed);
if (!parsed) { if (!parsed) {
// Can't parse — fall back to YAML mode
setMode('yaml'); setMode('yaml');
toast({ title: '可视化编辑不可用', description: '无法解析资源结构,已切换到 YAML 编辑模式' }); toast({ title: '可视化编辑不可用', description: '无法解析资源结构,已切换到 YAML 编辑模式' });
} }
} catch (e) { } catch (e) {
toast({ title: '加载失败', description: (e as Record<string, any>).message, variant: 'destructive' }); toast({ title: '加载失败', description: (e as Record<string, any>).message, variant: 'destructive' });
} finally { setLoading(false); } } finally { setLoading(false); }
}, [clusterCode, namespace, resource, name]); }, [clusterCode, namespace, resource, name, isCreate]);
useEffect(() => { loadData(); }, [loadData]); useEffect(() => { loadData(); }, [loadData]);
const handleSave = async ( updatedManifest: any) => { const handleSave = async (updatedManifest: any) => {
setSaving(true); setSaving(true);
try { try {
// Serialize manifest back to YAML const yamlStr = await prepareManifestForSave(updatedManifest);
const yamlStr = manifestToYaml(updatedManifest); const resName = updatedManifest?.metadata?.name || name;
await kubernetesApi.updateResourceYaml(clusterCode, namespace, resource, name, yamlStr); if (isCreate) {
toast({ title: '保存成功', description: `${resource}/${name} 已更新` }); if (!resName?.trim()) {
toast({ title: '请填写名称', variant: 'destructive' });
return;
}
await kubernetesApi.applyYAML(clusterCode, yamlStr);
toast({ title: '创建成功', description: `${resource}/${resName}` });
} else {
await kubernetesApi.updateResourceYaml(clusterCode, namespace, resource, name, yamlStr);
toast({ title: '保存成功', description: `${resource}/${name} 已更新` });
}
onSaved?.(); onSaved?.();
onClose(); onClose();
} catch (e) { } catch (e) {
toast({ title: '保存失败', description: (e as Record<string, any>).message, variant: 'destructive' }); toast({ title: isCreate ? '创建失败' : '保存失败', description: (e as Record<string, any>).message, variant: 'destructive' });
} finally { setSaving(false); } } finally { setSaving(false); }
}; };
const handleSaveYaml = async () => { const handleSaveYaml = async () => {
setSaving(true); setSaving(true);
try { try {
await kubernetesApi.updateResourceYaml(clusterCode, namespace, resource, name, editYaml); const yamlStr = cleanYAML(editYaml);
toast({ title: 'YAML 已应用', description: `${resource}/${name} 更新成功` }); if (isCreate) {
await kubernetesApi.applyYAML(clusterCode, yamlStr);
toast({ title: '创建成功', description: 'YAML 已应用' });
} else {
await kubernetesApi.updateResourceYaml(clusterCode, namespace, resource, name, yamlStr);
toast({ title: 'YAML 已应用', description: `${resource}/${name} 更新成功` });
}
onSaved?.(); onSaved?.();
onClose(); onClose();
} catch (e) { } catch (e) {
@@ -93,13 +103,24 @@ export function ResourceVisualEditor({ clusterCode, namespace, resource, name, o
} finally { setSaving(false); } } finally { setSaving(false); }
}; };
// Manifest → YAML string (JSON serialization works as YAML superset for K8s API) // 可视化 ↔ YAML 双向同步 // Logan.Li
function manifestToYaml( m: any): string { const switchToVisual = async () => {
// Use 2-space indented JSON — K8s API accepts JSON as YAML const parsed = await parseManifest(editYaml);
return JSON.stringify(m, null, 2); if (!parsed) {
} toast({ title: '无法解析 YAML', description: '请检查语法后继续用 YAML 模式', variant: 'destructive' });
return;
}
setManifest(parsed);
setMode('visual');
};
// ─── Resource type config ─── const switchToYaml = async () => {
if (manifest) {
const dumped = await dumpManifest(manifest);
setEditYaml(cleanYAML(dumped));
}
setMode('yaml');
};
const resourceMeta: Record<string, { title: string; icon: any; color: string }> = { const resourceMeta: Record<string, { title: string; icon: any; color: string }> = {
configmaps: { title: 'ConfigMap', icon: FileText, color: 'text-blue-400' }, configmaps: { title: 'ConfigMap', icon: FileText, color: 'text-blue-400' },
secrets: { title: 'Secret', icon: Shield, color: 'text-amber-400' }, secrets: { title: 'Secret', icon: Shield, color: 'text-amber-400' },
@@ -118,8 +139,8 @@ export function ResourceVisualEditor({ clusterCode, namespace, resource, name, o
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<MetaIcon className={`h-5 w-5 ${meta.color}`} /> <MetaIcon className={`h-5 w-5 ${meta.color}`} />
<div> <div>
<h2 className="text-lg font-bold text-[var(--dg-text-primary)]">{meta.title} 编辑器</h2> <h2 className="text-lg font-bold text-[var(--dg-text-primary)]">{isCreate ? `新建 ${meta.title}` : `${meta.title} 编辑`}</h2>
<p className="text-xs text-[var(--dg-text-muted)]">{name} · {namespace} · {clusterCode}</p> <p className="text-xs text-[var(--dg-text-muted)]">{isCreate ? namespace : name} · {namespace} · {clusterCode}</p>
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -127,12 +148,12 @@ export function ResourceVisualEditor({ clusterCode, namespace, resource, name, o
<div className="flex rounded-lg border border-[var(--dg-border)] overflow-hidden"> <div className="flex rounded-lg border border-[var(--dg-border)] overflow-hidden">
<button <button
className={`px-3 py-1.5 text-xs font-medium transition-colors ${mode === 'visual' ? 'bg-blue-600 text-white' : 'bg-transparent text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-tertiary)]'}`} className={`px-3 py-1.5 text-xs font-medium transition-colors ${mode === 'visual' ? 'bg-blue-600 text-white' : 'bg-transparent text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-tertiary)]'}`}
onClick={() => manifest && setMode('visual')} onClick={() => { if (mode === 'yaml') switchToVisual(); else setMode('visual'); }}
disabled={!manifest} disabled={!manifest && mode !== 'yaml'}
>可视化编辑</button> >可视化编辑</button>
<button <button
className={`px-3 py-1.5 text-xs font-medium transition-colors ${mode === 'yaml' ? 'bg-blue-600 text-white' : 'bg-transparent text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-tertiary)]'}`} className={`px-3 py-1.5 text-xs font-medium transition-colors ${mode === 'yaml' ? 'bg-blue-600 text-white' : 'bg-transparent text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-tertiary)]'}`}
onClick={() => setMode('yaml')} onClick={() => { if (mode === 'visual') switchToYaml(); else setMode('yaml'); }}
>YAML 编辑</button> >YAML 编辑</button>
</div> </div>
<Button variant="ghost" size="icon" className="h-8 w-8 text-[var(--dg-text-secondary)]" onClick={onClose}><X className="h-4 w-4" /></Button> <Button variant="ghost" size="icon" className="h-8 w-8 text-[var(--dg-text-secondary)]" onClick={onClose}><X className="h-4 w-4" /></Button>
@@ -165,11 +186,11 @@ export function ResourceVisualEditor({ clusterCode, namespace, resource, name, o
</div> </div>
) : manifest ? ( ) : manifest ? (
<> <>
{resource === 'configmaps' && <ConfigMapEditor manifest={manifest} onSave={handleSave} saving={saving} />} {resource === 'configmaps' && <ConfigMapEditor manifest={manifest} onSave={handleSave} saving={saving} isCreate={isCreate} />}
{resource === 'secrets' && <SecretEditor manifest={manifest} onSave={handleSave} saving={saving} />} {resource === 'secrets' && <SecretEditor manifest={manifest} onSave={handleSave} saving={saving} isCreate={isCreate} />}
{resource === 'services' && <ServiceEditor manifest={manifest} onSave={handleSave} saving={saving} />} {resource === 'services' && <ServiceEditor manifest={manifest} onSave={handleSave} saving={saving} isCreate={isCreate} />}
{resource === 'ingresses' && <IngressEditor manifest={manifest} onSave={handleSave} saving={saving} />} {resource === 'ingresses' && <IngressEditor manifest={manifest} onSave={handleSave} saving={saving} isCreate={isCreate} />}
{resource === 'pvcs' && <PvcEditor manifest={manifest} onSave={handleSave} saving={saving} />} {resource === 'pvcs' && <PvcEditor manifest={manifest} onSave={handleSave} saving={saving} isCreate={isCreate} />}
{!['configmaps', 'secrets', 'services', 'ingresses', 'pvcs'].includes(resource) && ( {!['configmaps', 'secrets', 'services', 'ingresses', 'pvcs'].includes(resource) && (
<div className="text-center py-12 text-[var(--dg-text-muted)]"> <div className="text-center py-12 text-[var(--dg-text-muted)]">
<p>该资源类型暂不支持可视化编辑</p> <p>该资源类型暂不支持可视化编辑</p>
@@ -192,8 +213,9 @@ export function ResourceVisualEditor({ clusterCode, namespace, resource, name, o
// ══════════════════════════════════════════════ // ══════════════════════════════════════════════
// ConfigMap 编辑器 — 键值对表格 // ConfigMap 编辑器 — 键值对表格
// ══════════════════════════════════════════════ // ══════════════════════════════════════════════
function ConfigMapEditor({ manifest, onSave, saving }: { manifest: any; onSave: ( m: any) => void; saving: boolean }) { function ConfigMapEditor({ manifest, onSave, saving, isCreate }: { manifest: any; onSave: (m: any) => void; saving: boolean; isCreate?: boolean }) {
const data = manifest?.data || {}; const data = manifest?.data || {};
const [resName, setResName] = useState(manifest?.metadata?.name || '');
const [entries, setEntries] = useState<{ key: string; value: string }[]>( const [entries, setEntries] = useState<{ key: string; value: string }[]>(
Object.entries(data).map(([key, value]) => ({ key, value: String(value) })) Object.entries(data).map(([key, value]) => ({ key, value: String(value) }))
); );
@@ -212,14 +234,14 @@ function ConfigMapEditor({ manifest, onSave, saving }: { manifest: any; onSave:
entries.forEach(e => { entries.forEach(e => {
if (e.key.trim()) newData[e.key.trim()] = e.value; if (e.key.trim()) newData[e.key.trim()] = e.value;
}); });
onSave({ ...manifest, data: newData }); onSave({ ...manifest, metadata: { ...manifest.metadata, name: resName.trim() }, data: newData });
}; };
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<Label className="text-[var(--dg-text-secondary)] text-xs">名称</Label> <Label className="text-[var(--dg-text-secondary)] text-xs">名称</Label>
<Input value={manifest?.metadata?.name || ''} readOnly className="mt-1 font-mono text-xs bg-[var(--dg-bg-tertiary)]" /> <Input value={resName} onChange={(e) => setResName(e.target.value)} readOnly={!isCreate} className="mt-1 font-mono text-xs bg-[var(--dg-bg-tertiary)]" />
</div> </div>
<div> <div>
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
@@ -264,8 +286,9 @@ function ConfigMapEditor({ manifest, onSave, saving }: { manifest: any; onSave:
// ══════════════════════════════════════════════ // ══════════════════════════════════════════════
// Secret 编辑器 — 键值对表格 (Base64 编码/解码) // Secret 编辑器 — 键值对表格 (Base64 编码/解码)
// ══════════════════════════════════════════════ // ══════════════════════════════════════════════
function SecretEditor({ manifest, onSave, saving }: { manifest: any; onSave: ( m: any) => void; saving: boolean }) { function SecretEditor({ manifest, onSave, saving, isCreate }: { manifest: any; onSave: (m: any) => void; saving: boolean; isCreate?: boolean }) {
const rawData = manifest?.data || {}; const rawData = manifest?.data || {};
const [resName, setResName] = useState(manifest?.metadata?.name || '');
// Decode base64 values for editing // Decode base64 values for editing
const [entries, setEntries] = useState<{ key: string; value: string; encoded: boolean }[]>( const [entries, setEntries] = useState<{ key: string; value: string; encoded: boolean }[]>(
Object.entries(rawData).map(([key, value]) => { Object.entries(rawData).map(([key, value]) => {
@@ -297,14 +320,14 @@ function SecretEditor({ manifest, onSave, saving }: { manifest: any; onSave: ( m
newData[e.key.trim()] = e.encoded ? btoa(e.value) : e.value; newData[e.key.trim()] = e.encoded ? btoa(e.value) : e.value;
} }
}); });
onSave({ ...manifest, data: newData }); onSave({ ...manifest, metadata: { ...manifest.metadata, name: resName.trim() }, data: newData });
}; };
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<Label className="text-[var(--dg-text-secondary)] text-xs">名称</Label> <Label className="text-[var(--dg-text-secondary)] text-xs">名称</Label>
<Input value={manifest?.metadata?.name || ''} readOnly className="mt-1 font-mono text-xs bg-[var(--dg-bg-tertiary)]" /> <Input value={resName} onChange={(e) => setResName(e.target.value)} readOnly={!isCreate} className="mt-1 font-mono text-xs bg-[var(--dg-bg-tertiary)]" />
</div> </div>
<div> <div>
<Label className="text-[var(--dg-text-secondary)] text-xs">类型</Label> <Label className="text-[var(--dg-text-secondary)] text-xs">类型</Label>
@@ -353,8 +376,9 @@ function SecretEditor({ manifest, onSave, saving }: { manifest: any; onSave: ( m
// ══════════════════════════════════════════════ // ══════════════════════════════════════════════
// Service 编辑器 — 端口配置 + 类型 + 选择器 // Service 编辑器 — 端口配置 + 类型 + 选择器
// ══════════════════════════════════════════════ // ══════════════════════════════════════════════
function ServiceEditor({ manifest, onSave, saving }: { manifest: any; onSave: ( m: any) => void; saving: boolean }) { function ServiceEditor({ manifest, onSave, saving, isCreate }: { manifest: any; onSave: (m: any) => void; saving: boolean; isCreate?: boolean }) {
const spec = manifest?.spec || {}; const spec = manifest?.spec || {};
const [resName, setResName] = useState(manifest?.metadata?.name || '');
const [svcType, setSvcType] = useState(spec.type || 'ClusterIP'); const [svcType, setSvcType] = useState(spec.type || 'ClusterIP');
const [ports, setPorts] = useState<any[]>( const [ports, setPorts] = useState<any[]>(
(spec.ports || []).map(( p: any) => ({ (spec.ports || []).map(( p: any) => ({
@@ -401,28 +425,34 @@ function ServiceEditor({ manifest, onSave, saving }: { manifest: any; onSave: (
const selObj: Record<string, string> = {}; const selObj: Record<string, string> = {};
selector.forEach(s => { if (s.key.trim()) selObj[s.key.trim()] = s.value; }); selector.forEach(s => { if (s.key.trim()) selObj[s.key.trim()] = s.value; });
newSpec.selector = selObj; newSpec.selector = selObj;
onSave({ ...manifest, spec: newSpec }); onSave({ ...manifest, metadata: { ...manifest.metadata, name: resName.trim() }, spec: newSpec });
}; };
const svcTypes = [
{ value: 'ClusterIP', label: 'ClusterIP' },
{ value: 'NodePort', label: 'NodePort' },
{ value: 'LoadBalancer', label: 'LoadBalancer' },
];
return ( return (
<div className="space-y-5"> <div className="space-y-5">
<div className="grid grid-cols-2 gap-4"> <div>
<div> <Label className="text-[var(--dg-text-secondary)] text-xs">服务名称</Label>
<Label className="text-[var(--dg-text-secondary)] text-xs">名称</Label> <Input value={resName} onChange={(e) => setResName(e.target.value)} readOnly={!isCreate} className="mt-1 font-mono text-xs bg-[var(--dg-bg-tertiary)]" />
<Input value={manifest?.metadata?.name || ''} readOnly className="mt-1 font-mono text-xs bg-[var(--dg-bg-tertiary)]" /> </div>
</div> <div>
<div> <Label className="text-[var(--dg-text-secondary)] text-xs mb-2 block">服务类型</Label>
<Label className="text-[var(--dg-text-secondary)] text-xs">Service 类型</Label> <div className="flex rounded-lg border border-[var(--dg-border)] overflow-hidden w-fit">
<Select value={svcType} onValueChange={setSvcType}> {svcTypes.map((t) => (
<SelectTrigger className="mt-1 h-8 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] text-xs"> <button
<SelectValue /> key={t.value}
</SelectTrigger> type="button"
<SelectContent> className={`px-3 py-1.5 text-xs transition-colors ${svcType === t.value ? 'bg-blue-600 text-white' : 'text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-tertiary)]'}`}
<SelectItem value="ClusterIP">ClusterIP (集群内部)</SelectItem> onClick={() => setSvcType(t.value)}
<SelectItem value="NodePort">NodePort (节点端口)</SelectItem> >
<SelectItem value="LoadBalancer">LoadBalancer (负载均衡)</SelectItem> {t.label}
</SelectContent> </button>
</Select> ))}
</div> </div>
</div> </div>
@@ -484,8 +514,10 @@ function ServiceEditor({ manifest, onSave, saving }: { manifest: any; onSave: (
// ══════════════════════════════════════════════ // ══════════════════════════════════════════════
// Ingress 编辑器 — Host/Path 路由规则 // Ingress 编辑器 — Host/Path 路由规则
// ══════════════════════════════════════════════ // ══════════════════════════════════════════════
function IngressEditor({ manifest, onSave, saving }: { manifest: any; onSave: ( m: any) => void; saving: boolean }) { function IngressEditor({ manifest, onSave, saving, isCreate }: { manifest: any; onSave: (m: any) => void; saving: boolean; isCreate?: boolean }) {
const spec = manifest?.spec || {}; const spec = manifest?.spec || {};
const [resName, setResName] = useState(manifest?.metadata?.name || '');
const [ingressClass, setIngressClass] = useState(spec.ingressClassName || 'nginx');
const [rules, setRules] = useState<{ host: string; paths: { path: string; pathType: string; serviceName: string; servicePort: string }[] }[]>( const [rules, setRules] = useState<{ host: string; paths: { path: string; pathType: string; serviceName: string; servicePort: string }[] }[]>(
(spec.rules || []).map(( r: any) => ({ (spec.rules || []).map(( r: any) => ({
host: r.host || '', host: r.host || '',
@@ -537,14 +569,23 @@ function IngressEditor({ manifest, onSave, saving }: { manifest: any; onSave: (
})), })),
}, },
})); }));
onSave({ ...manifest, spec: { ...spec, rules: newRules } }); const newSpec: Record<string, any> = { ...spec, rules: newRules };
if (ingressClass.trim()) newSpec.ingressClassName = ingressClass.trim();
else delete newSpec.ingressClassName;
onSave({ ...manifest, metadata: { ...manifest.metadata, name: resName.trim() }, spec: newSpec });
}; };
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div> <div className="grid grid-cols-2 gap-4">
<Label className="text-[var(--dg-text-secondary)] text-xs">名称</Label> <div>
<Input value={manifest?.metadata?.name || ''} readOnly className="mt-1 font-mono text-xs bg-[var(--dg-bg-tertiary)]" /> <Label className="text-[var(--dg-text-secondary)] text-xs">名称</Label>
<Input value={resName} onChange={(e) => setResName(e.target.value)} readOnly={!isCreate} className="mt-1 font-mono text-xs bg-[var(--dg-bg-tertiary)]" />
</div>
<div>
<Label className="text-[var(--dg-text-secondary)] text-xs">Ingress Class</Label>
<Input value={ingressClass} onChange={(e) => setIngressClass(e.target.value)} placeholder="nginx / alb" className="mt-1 font-mono text-xs h-8 bg-[var(--dg-bg-tertiary)]" />
</div>
</div> </div>
<div> <div>
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
@@ -594,9 +635,10 @@ function IngressEditor({ manifest, onSave, saving }: { manifest: any; onSave: (
// ══════════════════════════════════════════════ // ══════════════════════════════════════════════
// PVC 编辑器 — 存储大小/访问模式/StorageClass // PVC 编辑器 — 存储大小/访问模式/StorageClass
// ══════════════════════════════════════════════ // ══════════════════════════════════════════════
function PvcEditor({ manifest, onSave, saving }: { manifest: any; onSave: ( m: any) => void; saving: boolean }) { function PvcEditor({ manifest, onSave, saving, isCreate }: { manifest: any; onSave: (m: any) => void; saving: boolean; isCreate?: boolean }) {
const spec = manifest?.spec || {}; const spec = manifest?.spec || {};
const resources = spec.resources?.requests || {}; const resources = spec.resources?.requests || {};
const [resName, setResName] = useState(manifest?.metadata?.name || '');
const [size, setSize] = useState(resources.storage || '1Gi'); const [size, setSize] = useState(resources.storage || '1Gi');
const [accessMode, setAccessMode] = useState(spec.accessModes?.[0] || 'ReadWriteOnce'); const [accessMode, setAccessMode] = useState(spec.accessModes?.[0] || 'ReadWriteOnce');
const [storageClass, setStorageClass] = useState(spec.storageClassName || ''); const [storageClass, setStorageClass] = useState(spec.storageClassName || '');
@@ -604,6 +646,7 @@ function PvcEditor({ manifest, onSave, saving }: { manifest: any; onSave: ( m: a
const handleSave = () => { const handleSave = () => {
onSave({ onSave({
...manifest, ...manifest,
metadata: { ...manifest.metadata, name: resName.trim() },
spec: { spec: {
...spec, ...spec,
accessModes: [accessMode], accessModes: [accessMode],
@@ -617,7 +660,7 @@ function PvcEditor({ manifest, onSave, saving }: { manifest: any; onSave: ( m: a
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<Label className="text-[var(--dg-text-secondary)] text-xs">名称</Label> <Label className="text-[var(--dg-text-secondary)] text-xs">名称</Label>
<Input value={manifest?.metadata?.name || ''} readOnly className="mt-1 font-mono text-xs bg-[var(--dg-bg-tertiary)]" /> <Input value={resName} onChange={(e) => setResName(e.target.value)} readOnly={!isCreate} className="mt-1 font-mono text-xs bg-[var(--dg-bg-tertiary)]" />
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
@@ -0,0 +1,76 @@
// YAML 导入对话框 — 粘贴/应用多文档 manifest // Logan.Li
import { useState } from 'react';
import { X, Upload, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useToast } from '@/hooks/use-toast';
import { kubernetesApi } from '@/api';
import CodeMirror from '@uiw/react-codemirror';
import { yaml } from '@codemirror/lang-yaml';
import { oneDark } from '@codemirror/theme-one-dark';
interface YamlImportDialogProps {
clusterCode: string;
onClose: () => void;
onApplied?: () => void;
}
export function YamlImportDialog({ clusterCode, onClose, onApplied }: YamlImportDialogProps) {
const { toast } = useToast();
const [content, setContent] = useState('');
const [applying, setApplying] = useState(false);
const apply = async () => {
if (!content.trim()) {
toast({ title: '请输入 YAML', variant: 'destructive' });
return;
}
setApplying(true);
try {
await kubernetesApi.applyYAML(clusterCode, content);
toast({ title: '导入成功', description: 'YAML 已应用到集群' });
onApplied?.();
onClose();
} catch (e) {
toast({ title: '导入失败', description: (e as Record<string, any>).message, variant: 'destructive' });
} finally { setApplying(false); }
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={onClose}>
<div
className="bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-xl w-[92vw] max-w-4xl max-h-[85vh] flex flex-col shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between p-4 border-b border-[var(--dg-border)]">
<div className="flex items-center gap-2">
<Upload className="h-5 w-5 text-[var(--dg-text-muted)]" />
<div>
<h2 className="text-lg font-bold text-[var(--dg-text-primary)]">导入 YAML</h2>
<p className="text-xs text-[var(--dg-text-muted)]">支持多文档(--- 分隔),适用于新建或更新资源</p>
</div>
</div>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onClose}><X className="h-4 w-4" /></Button>
</div>
<div className="flex-1 overflow-auto p-4">
<CodeMirror
value={content}
height="420px"
theme={oneDark}
extensions={[yaml()]}
onChange={setContent}
placeholder="粘贴 YAML 内容..."
basicSetup={{ lineNumbers: true, foldGutter: true, highlightActiveLine: true }}
style={{ fontSize: '12px' }}
/>
</div>
<div className="flex justify-end gap-2 p-4 border-t border-[var(--dg-border)]">
<Button variant="outline" onClick={onClose}>取消</Button>
<Button className="bg-blue-600 hover:bg-blue-700" onClick={apply} disabled={applying}>
{applying ? <Loader2 className="h-4 w-4 animate-spin mr-1" /> : <Upload className="h-4 w-4 mr-1" />}
应用
</Button>
</div>
</div>
</div>
);
}
@@ -65,7 +65,7 @@ export interface DeploymentEditContainersProps {
namespace: string; namespace: string;
deploymentName: string; deploymentName: string;
manifest: any | null; manifest: any | null;
onSaved: () => void; onSaved: (updated?: any) => void;
onCancel: () => void; onCancel: () => void;
} }
@@ -0,0 +1,13 @@
// Deployment manifest 统一保存 // Logan.Li
import { kubernetesApi } from '@/api';
import { prepareManifestForSave } from './k8s-manifest-utils';
export async function saveDeploymentManifest(
clusterCode: string,
namespace: string,
deploymentName: string,
manifest: Record<string, any>,
): Promise<void> {
const yaml = await prepareManifestForSave(manifest);
await kubernetesApi.updateResourceYaml(clusterCode, namespace, 'deployments', deploymentName, yaml);
}
@@ -1,6 +1,5 @@
// EditModeContainer.tsx — 编辑模式 Tab 容器(M6: 基本信息Tab + 容器编辑预留) // EditModeContainer.tsx — 编辑模式 Tab 容器,共享 manifest 状态 // Logan.Li
// 作者: Logan.Li import { useState, useEffect } from 'react';
import { useState } from 'react';
import { Container, Settings, Database, Network } from 'lucide-react'; import { Container, Settings, Database, Network } from 'lucide-react';
import { DeploymentEditBasic } from '../DeploymentEditBasic'; import { DeploymentEditBasic } from '../DeploymentEditBasic';
import { DeploymentEditContainers } from '../DeploymentEditContainers'; import { DeploymentEditContainers } from '../DeploymentEditContainers';
@@ -20,98 +19,60 @@ export interface EditModeContainerProps {
export function EditModeContainer({ clusterCode, namespace, deploymentName, manifest, onSaved, onCancel }: EditModeContainerProps) { export function EditModeContainer({ clusterCode, namespace, deploymentName, manifest, onSaved, onCancel }: EditModeContainerProps) {
type EditTab = 'basic' | 'containers' | 'storage' | 'advanced' | 'service'; type EditTab = 'basic' | 'containers' | 'storage' | 'advanced' | 'service';
const [activeTab, setActiveTab] = useState<EditTab>('basic'); const [activeTab, setActiveTab] = useState<EditTab>('basic');
const [editManifest, setEditManifest] = useState<any>(manifest);
const tabs: Array<{ key: EditTab; label: string; icon: typeof Settings; available: boolean; badge?: string }> = [ useEffect(() => {
{ key: 'basic', label: '基本信息', icon: Settings, available: true }, if (manifest) setEditManifest(manifest);
{ key: 'containers', label: '容器信息', icon: Container, available: true }, }, [manifest]);
{ key: 'storage', label: '存储挂载', icon: Database, available: true },
{ key: 'advanced', label: '高级设置', icon: Settings, available: true }, const handleSaved = (updated?: any) => {
{ key: 'service', label: '服务/应用路由', icon: Network, available: true }, if (updated) setEditManifest(updated);
onSaved();
};
const tabs: Array<{ key: EditTab; label: string; icon: typeof Settings }> = [
{ key: 'basic', label: '基本信息', icon: Settings },
{ key: 'containers', label: '容器信息', icon: Container },
{ key: 'storage', label: '存储挂载', icon: Database },
{ key: 'advanced', label: '高级设置', icon: Settings },
{ key: 'service', label: '服务/应用路由', icon: Network },
]; ];
const commonProps = {
clusterCode,
namespace,
deploymentName,
manifest: editManifest,
onSaved: handleSaved,
onCancel,
};
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* Tab 导航 */}
<div className="flex items-center gap-1 border-b border-slate-800 pb-px overflow-x-auto"> <div className="flex items-center gap-1 border-b border-slate-800 pb-px overflow-x-auto">
{tabs.map(tab => { {tabs.map((tab) => {
const active = activeTab === tab.key; const active = activeTab === tab.key;
const Icon = tab.icon; const Icon = tab.icon;
return ( return (
<button <button
key={tab.key} key={tab.key}
disabled={!tab.available} onClick={() => setActiveTab(tab.key)}
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 ${
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' : 'border-transparent text-slate-400 hover:text-slate-200'
${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" /> <Icon className="h-3.5 w-3.5" />
{tab.label} {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> </button>
); );
})} })}
</div> </div>
{/* Tab 内容 */} {activeTab === 'basic' && <DeploymentEditBasic {...commonProps} />}
{activeTab === 'basic' ? ( {activeTab === 'containers' && <DeploymentEditContainers {...commonProps} />}
<DeploymentEditBasic {activeTab === 'storage' && <DeploymentEditStorage {...commonProps} />}
clusterCode={clusterCode} {activeTab === 'advanced' && <DeploymentEditAdvanced {...commonProps} />}
namespace={namespace} {activeTab === 'service' && <DeploymentEditService {...commonProps} />}
deploymentName={deploymentName}
manifest={manifest}
onSaved={onSaved}
onCancel={onCancel}
/>
) : activeTab === 'containers' ? (
/* M7: 完整容器编辑器 — 工作容器 + 初始化容器 */
<DeploymentEditContainers
clusterCode={clusterCode}
namespace={namespace}
deploymentName={deploymentName}
manifest={manifest}
onSaved={onSaved}
onCancel={onCancel}
/>
) : activeTab === 'storage' ? (
/* M8: 存储挂载 — volumes + volumeMounts 汇总 */
<DeploymentEditStorage
clusterCode={clusterCode}
namespace={namespace}
deploymentName={deploymentName}
manifest={manifest}
onSaved={onSaved}
onCancel={onCancel}
/>
) : activeTab === 'advanced' ? (
/* M8: 高级设置 — 更新策略/调度/DNS/安全上下文/终止宽限期 */
<DeploymentEditAdvanced
clusterCode={clusterCode}
namespace={namespace}
deploymentName={deploymentName}
manifest={manifest}
onSaved={onSaved}
onCancel={onCancel}
/>
) : activeTab === 'service' ? (
/* M9: 服务/应用路由 — 关联 Service + Ingress,可创建 */
<DeploymentEditService
clusterCode={clusterCode}
namespace={namespace}
deploymentName={deploymentName}
manifest={manifest}
onSaved={onSaved}
onCancel={onCancel}
/>
) : null}
</div> </div>
); );
} }
@@ -0,0 +1,32 @@
// K8s manifest 解析/序列化共用工具 // Logan.Li
import { cleanYAML } from './k8s-yaml-utils';
export async function parseManifest(text: string): Promise<Record<string, any> | null> {
if (!text?.trim()) return null;
try { return JSON.parse(text); } catch {}
try {
const jsyaml: Record<string, any> = await import('js-yaml');
const parsed = jsyaml.load(text);
return parsed && typeof parsed === 'object' ? parsed : null;
} catch { return null; }
}
export async function dumpManifest(manifest: Record<string, any>): Promise<string> {
const jsyaml: Record<string, any> = await import('js-yaml');
return jsyaml.dump(manifest, { lineWidth: 120, noRefs: true });
}
/** 保存前剥离 status 等噪音字段 // Logan.Li */
export async function prepareManifestForSave(manifest: Record<string, any>): Promise<string> {
const cloned = JSON.parse(JSON.stringify(manifest));
delete cloned.status;
if (cloned.metadata) {
delete cloned.metadata.managedFields;
delete cloned.metadata.resourceVersion;
delete cloned.metadata.uid;
delete cloned.metadata.creationTimestamp;
delete cloned.metadata.generation;
}
const yaml = await dumpManifest(cloned);
return cleanYAML(yaml);
}
@@ -0,0 +1,63 @@
// 网络/配置/存储资源新建时的空 manifest 模板 // Logan.Li
export function createEmptyManifest(resource: string, namespace: string, name: string): Record<string, any> {
switch (resource) {
case 'services':
return {
apiVersion: 'v1',
kind: 'Service',
metadata: { name, namespace },
spec: {
type: 'ClusterIP',
selector: {},
ports: [{ name: 'http', port: 80, targetPort: 80, protocol: 'TCP' }],
},
};
case 'ingresses':
return {
apiVersion: 'networking.k8s.io/v1',
kind: 'Ingress',
metadata: { name, namespace },
spec: {
ingressClassName: 'nginx',
rules: [{
host: '',
http: {
paths: [{
path: '/',
pathType: 'Prefix',
backend: { service: { name: '', port: { number: 80 } } },
}],
},
}],
},
};
case 'configmaps':
return {
apiVersion: 'v1',
kind: 'ConfigMap',
metadata: { name, namespace },
data: {},
};
case 'secrets':
return {
apiVersion: 'v1',
kind: 'Secret',
metadata: { name, namespace },
type: 'Opaque',
data: {},
};
case 'pvcs':
return {
apiVersion: 'v1',
kind: 'PersistentVolumeClaim',
metadata: { name, namespace },
spec: {
accessModes: ['ReadWriteOnce'],
resources: { requests: { storage: '1Gi' } },
},
};
default:
return { apiVersion: 'v1', kind: 'Unknown', metadata: { name, namespace } };
}
}
+21
View File
@@ -13,6 +13,27 @@ export default defineConfig({
"@": path.resolve(__dirname, "./src"), "@": path.resolve(__dirname, "./src"),
}, },
}, },
build: {
rollupOptions: {
output: {
// 按 vendor 拆包,减小 embed 主包体积 // Logan.Li
manualChunks(id) {
if (!id.includes("node_modules")) return
// recharts 须在 react 之前匹配,避免 vendor 循环依赖 // Logan.Li
if (id.includes("recharts") || id.includes("/d3-")) return "vendor-charts"
if (id.includes("react-dom") || id.includes("react-router") || /[/\\]react[/\\]/.test(id)) {
return "vendor-react"
}
if (id.includes("@codemirror") || id.includes("@uiw/react-codemirror")) return "vendor-codemirror"
if (id.includes("xterm")) return "vendor-xterm"
if (id.includes("@radix-ui")) return "vendor-radix"
if (id.includes("@tanstack")) return "vendor-query"
if (id.includes("lucide-react")) return "vendor-icons"
},
},
},
chunkSizeWarningLimit: 600,
},
server: { server: {
port: 5173, port: 5173,
host: true, host: true,
+56 -1
View File
@@ -924,6 +924,51 @@ cmd_kubernetes_build() {
KUBERNETES_BUILT_IMAGE="$image_tag" KUBERNETES_BUILT_IMAGE="$image_tag"
} }
# 读取 deployment.yaml 中的 replicas(默认 1)// Logan.Li
kubernetes_manifest_replicas() {
local replicas
replicas=$(awk '/^ replicas:/{print $2; exit}' "$KUBERNETES_MANIFEST_DIR/deployment.yaml" 2>/dev/null)
echo "${replicas:-1}"
}
# 读取集群中 aiops Deployment 当前副本数 // Logan.Li
kubernetes_live_replicas() {
kubectl -n "$KUBERNETES_NAMESPACE" get deployment aiops -o jsonpath='{.spec.replicas}' 2>/dev/null
}
# 多副本时 SQLite 不共享,强制切换 MODE=release + 集群内 MySQL/Redis // Logan.Li
ensure_cluster_mode_for_replicas() {
local replicas=${1:-1}
if [ "$replicas" -le 1 ]; then
return 0
fi
if ! kubectl -n "$KUBERNETES_NAMESPACE" get deployment aiops &>/dev/null; then
warning "Deployment 尚未创建,跳过多副本模式切换(apply 后会自动处理)"
return 0
fi
local current_mode
current_mode=$(kubectl -n "$KUBERNETES_NAMESPACE" get deployment aiops \
-o jsonpath='{.spec.template.spec.containers[0].env[?(@.name=="MODE")].value}' 2>/dev/null)
if [ "$current_mode" = "release" ]; then
info "副本 $replicas,已为 release 模式"
return 0
fi
warning "副本 $replicas > 1,SQLite 数据不共享,自动切换 MODE=release (mysql + redis)"
kubectl -n "$KUBERNETES_NAMESPACE" set env deployment/aiops \
MODE=release \
DATABASE_DRIVER=mysql \
DATABASE_HOST=mysql \
DATABASE_PORT=3306 \
DATABASE_USER=aiops \
DATABASE_PASSWORD=aiops_pass_2026 \
DATABASE_DBNAME=aiops \
REDIS_DRIVER=redis \
REDIS_HOST=redis \
REDIS_PORT=6379 \
--overwrite
success "已切换 release 模式,使用集群 MySQL/Redis"
}
# Kubernetes 部署 # Kubernetes 部署
cmd_kubernetes() { cmd_kubernetes() {
local action=${1:-help} local action=${1:-help}
@@ -959,7 +1004,13 @@ cmd_kubernetes() {
# 4. 部署应用 # 4. 部署应用
info "部署 AIOps 应用..." info "部署 AIOps 应用..."
local target_replicas
target_replicas=$(kubernetes_manifest_replicas)
if [ "$target_replicas" -gt 1 ]; then
warning "manifest replicas=$target_replicas,将自动切换 MODE=release"
fi
kubectl apply -f "$KUBERNETES_MANIFEST_DIR/deployment.yaml" kubectl apply -f "$KUBERNETES_MANIFEST_DIR/deployment.yaml"
ensure_cluster_mode_for_replicas "$target_replicas"
kubectl -n "$KUBERNETES_NAMESPACE" rollout status deployment/aiops --timeout=180s 2>/dev/null || { kubectl -n "$KUBERNETES_NAMESPACE" rollout status deployment/aiops --timeout=180s 2>/dev/null || {
warning "应用正在启动,查看日志: ./run.sh kubernetes logs" warning "应用正在启动,查看日志: ./run.sh kubernetes logs"
} }
@@ -983,6 +1034,10 @@ cmd_kubernetes() {
upgrade|update) upgrade|update)
check_kubernetes_deps check_kubernetes_deps
banner "========== Kubernetes 滚动更新 ==========" banner "========== Kubernetes 滚动更新 =========="
local live_replicas
live_replicas=$(kubernetes_live_replicas)
live_replicas=${live_replicas:-1}
ensure_cluster_mode_for_replicas "$live_replicas"
cmd_kubernetes_build 1 cmd_kubernetes_build 1
info "更新 Deployment 镜像: $KUBERNETES_BUILT_IMAGE" info "更新 Deployment 镜像: $KUBERNETES_BUILT_IMAGE"
kubectl -n "$KUBERNETES_NAMESPACE" set image deployment/aiops aiops="$KUBERNETES_BUILT_IMAGE" --record kubectl -n "$KUBERNETES_NAMESPACE" set image deployment/aiops aiops="$KUBERNETES_BUILT_IMAGE" --record
@@ -1061,7 +1116,7 @@ cmd_kubernetes() {
check_kubernetes_deps check_kubernetes_deps
local replicas=${2:-1} local replicas=${2:-1}
if [ "$replicas" -gt 1 ]; then if [ "$replicas" -gt 1 ]; then
warning "MODE=local 时 SQLite 数据不共享,多副本会导致数据分裂,建议 replicas=1 或切换 MODE=release" ensure_cluster_mode_for_replicas "$replicas"
fi fi
info "扩缩容到 $replicas 副本..." info "扩缩容到 $replicas 副本..."
kubectl -n "$KUBERNETES_NAMESPACE" scale deployment/aiops --replicas="$replicas" kubectl -n "$KUBERNETES_NAMESPACE" scale deployment/aiops --replicas="$replicas"