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
+793 -476

No files matched your search

@@ -7,10 +7,11 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Save, Loader2, Settings, RefreshCw, Shield, Clock, Network,
Save, Loader2, Settings, RefreshCw, Shield, Clock, Network, Plus, Trash2,
} from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import { kubernetesApi } from '@/api';
import { saveDeploymentManifest } from './deployment-manifest-save';
import { SchedulingSection, type ScheduleMode } from './deployment-edit-advanced/SchedulingSection';
import { DnsHostSection } from './deployment-edit-advanced/DnsHostSection';
@@ -19,7 +20,7 @@ interface Props {
namespace: string;
deploymentName: string;
manifest: any | null;
onSaved: () => void;
onSaved: (updated?: any) => void;
onCancel: () => void;
}
@@ -308,10 +309,9 @@ export function DeploymentEditAdvanced({ clusterCode, namespace, deploymentName,
if (Object.keys(dcObj).length > 0) podSpec.dnsConfig = dcObj;
else delete podSpec.dnsConfig;
const json = JSON.stringify(base, null, 2);
await kubernetesApi.updateResourceYaml(clusterCode, namespace, 'deployments', deploymentName, json);
await saveDeploymentManifest(clusterCode, namespace, deploymentName, base);
toast({ title: '高级设置已保存', description: `${deploymentName} 更新已提交` });
onSaved();
onSaved(base);
} catch (e) {
toast({ title: '保存失败', description: e?.message || String(e), variant: 'destructive' });
} finally { setSaving(false); }
@@ -398,6 +398,49 @@ export function DeploymentEditAdvanced({ clusterCode, namespace, deploymentName,
</CardContent>
</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
scheduleMode={scheduleMode}
onScheduleModeChange={setScheduleMode}
@@ -9,15 +9,14 @@ import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Plus, Trash2, Save, Loader2, Tags, FileText, Hash, Lock } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import { kubernetesApi } from '@/api';
import { saveDeploymentManifest } from './deployment-manifest-save';
interface DeploymentEditBasicProps {
clusterCode: string;
namespace: string;
deploymentName: string;
/** 从后端拿到的 manifest 对象 */
manifest: any | null;
onSaved: () => void;
onSaved: (updated?: any) => void;
onCancel: () => void;
}
@@ -46,8 +45,8 @@ export function DeploymentEditBasic({ clusterCode, namespace, deploymentName, ma
const [labels, setLabels] = useState<Array<{ key: string; value: string }>>([]);
const [annotations, setAnnotations] = useState<Array<{ key: string; value: string }>>([]);
const [selectorLabels, setSelectorLabels] = useState<Array<{ key: string; value: string }>>([]);
const [templateLabels, setTemplateLabels] = useState<Array<{ key: string; value: string }>>([]);
// 初始化数据
useEffect(() => {
const m = manifest;
if (!m) { setLoading(false); return; }
@@ -56,6 +55,7 @@ export function DeploymentEditBasic({ clusterCode, namespace, deploymentName, ma
setLabels(objToKv(m?.metadata?.labels));
setAnnotations(objToKv(m?.metadata?.annotations));
setSelectorLabels(objToKv(m?.spec?.selector?.matchLabels));
setTemplateLabels(objToKv(m?.spec?.template?.metadata?.labels));
setLoading(false);
}, [manifest]);
@@ -79,40 +79,47 @@ export function DeploymentEditBasic({ clusterCode, namespace, deploymentName, ma
};
const addSelector = () => setSelectorLabels(prev => [...prev, { key: '', value: '' }]);
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 () => {
setSaving(true);
try {
const base = JSON.parse(JSON.stringify(rawManifest));
// 副本数
base.spec = base.spec || {};
base.spec.replicas = replicas;
// labels
const labelObj = kvToObj(labels);
base.metadata = base.metadata || {};
base.metadata.labels = Object.keys(labelObj).length > 0 ? labelObj : undefined;
// annotations(空则删除 key)
const annObj = kvToObj(annotations);
base.metadata.annotations = Object.keys(annObj).length > 0 ? annObj : undefined;
// selector — 同步更新 spec.selector.matchLabels 和 spec.template.metadata.labels
// ⚠️ selector 必须和 template labels 一致,否则 Deployment 会拒绝
const selectorObj = kvToObj(selectorLabels);
if (Object.keys(selectorObj).length > 0) {
base.spec.selector = base.spec.selector || {};
base.spec.selector.matchLabels = selectorObj;
}
const tplLabelObj = kvToObj(templateLabels);
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 };
if (Object.keys(tplLabelObj).length > 0) {
base.spec.template.metadata.labels = tplLabelObj;
} else {
delete base.spec.template.metadata.labels;
}
const json = JSON.stringify(base, null, 2);
await kubernetesApi.updateResourceYaml(clusterCode, namespace, 'deployments', deploymentName, json);
await saveDeploymentManifest(clusterCode, namespace, deploymentName, base);
toast({ title: '基本信息已保存', description: `${deploymentName} 更新已提交` });
onSaved();
onSaved(base);
} catch (e) {
toast({ title: '保存失败', description: e?.message || String(e), variant: 'destructive' });
} finally { setSaving(false); }
@@ -248,6 +255,42 @@ export function DeploymentEditBasic({ clusterCode, namespace, deploymentName, ma
</CardContent>
</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 */}
<Card className="bg-slate-900 border-slate-800">
<CardHeader className="pb-3">
@@ -18,7 +18,7 @@ import {
} from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import { kubernetesApi } from '@/api';
import { saveDeploymentManifest } from './deployment-manifest-save';
import { ContainerGroupSection } from './deployment-edit/ContainerGroupSection';
import {
@@ -99,10 +99,9 @@ export function DeploymentEditContainers({ clusterCode, namespace, deploymentNam
base.spec.template.spec.imagePullSecrets = imagePullSecrets.filter(s => s.trim()).length > 0
? imagePullSecrets.filter(s => s.trim()).map((s: string) => ({ name: s }))
: undefined;
const json = JSON.stringify(base, null, 2);
await kubernetesApi.updateResourceYaml(clusterCode, namespace, 'deployments', deploymentName, json);
await saveDeploymentManifest(clusterCode, namespace, deploymentName, base);
toast({ title: '容器配置已保存', description: `${deploymentName} 更新已提交` });
onSaved();
onSaved(base);
} catch (e) {
toast({ title: '保存失败', description: e?.message || String(e), variant: 'destructive' });
} finally { setSaving(false); }
@@ -13,7 +13,7 @@ import {
GitBranch,
Loader2,
RefreshCw,
ExternalLink,
Pencil,
Plus,
CheckCircle,
Server,
@@ -25,13 +25,15 @@ import {
import { useToast } from '@/hooks/use-toast';
import { useConfirm } from '@/components/ui/confirm-dialog';
import { kubernetesApi } from '@/api';
import { dumpManifest } from './k8s-manifest-utils';
import { ResourceVisualEditor } from './ResourceVisualEditor';
interface Props {
clusterCode: string;
namespace: string;
deploymentName: string;
manifest: any | null;
onSaved: () => void;
onSaved: (updated?: any) => void;
onCancel: () => void;
}
@@ -78,7 +80,7 @@ export function DeploymentEditService({ clusterCode, namespace, deploymentName,
const [services, setServices] = useState<SvcSummary[]>([]);
const [ingresses, setIngresses] = useState<IngSummary[]>([]);
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);
// 创建 Service 表单 — 多端口
@@ -219,9 +221,9 @@ export function DeploymentEditService({ clusterCode, namespace, deploymentName,
ports: k8sPorts,
},
};
const json = JSON.stringify(svcManifest, null, 2);
try {
await kubernetesApi.updateResourceYaml(clusterCode, namespace, 'services', name, json);
const yaml = await dumpManifest(svcManifest);
await kubernetesApi.applyYAML(clusterCode, yaml);
toast({ title: 'Service 已创建', description: name });
setShowCreate(null);
load();
@@ -254,9 +256,9 @@ export function DeploymentEditService({ clusterCode, namespace, deploymentName,
},
};
if (!ingForm.className) delete manifest.spec.ingressClassName;
const json = JSON.stringify(manifest, null, 2);
try {
await kubernetesApi.updateResourceYaml(clusterCode, namespace, 'ingresses', name, json);
const yaml = await dumpManifest(manifest);
await kubernetesApi.applyYAML(clusterCode, yaml);
toast({ title: 'Ingress 已创建', description: name });
setShowCreate(null);
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>
{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>
<Button size="sm" variant="ghost" className="h-6 text-xs text-slate-400"
onClick={() => setYamlModal({ kind: 'service', name: svc.name })}>
<ExternalLink className="h-3 w-3 mr-1" />YAML
<Button size="sm" variant="ghost" className="h-6 text-xs text-emerald-400"
onClick={() => setVisualEditor({ resource: 'services', name: svc.name })}>
<Pencil className="h-3 w-3 mr-1" />编辑
</Button>
<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)}>
@@ -529,9 +531,9 @@ export function DeploymentEditService({ clusterCode, namespace, deploymentName,
<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>
{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"
onClick={() => setYamlModal({ kind: 'ingress', name: ing.name })}>
<ExternalLink className="h-3 w-3 mr-1" />YAML
<Button size="sm" variant="ghost" className="h-6 text-xs text-emerald-400 ml-auto"
onClick={() => setVisualEditor({ resource: 'ingresses', name: ing.name })}>
<Pencil className="h-3 w-3 mr-1" />编辑
</Button>
<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)}>
@@ -604,15 +606,19 @@ export function DeploymentEditService({ clusterCode, namespace, deploymentName,
</CardContent>
</Card>
{/* YAML 弹窗 */}
{yamlModal && (
<div className="hidden">{/* YamlViewer can be re-added here if needed */}</div>
{visualEditor && (
<ResourceVisualEditor
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">
<Button size="sm" variant="ghost" className="h-7 text-xs text-slate-400" onClick={onCancel}>完成</Button>
<Button size="sm" className="h-7 bg-slate-600 text-xs" onClick={onSaved}>保存并关闭</Button>
<Button size="sm" variant="outline" className="h-7 text-xs border-slate-700 text-slate-400" onClick={onCancel}>返回查看</Button>
</div>
</div>
);
@@ -11,14 +11,14 @@ import {
Plus, Trash2, Save, Loader2, HardDrive, Database, Folder, Lock, File, Disc, Server,
} from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import { kubernetesApi } from '@/api';
import { saveDeploymentManifest } from './deployment-manifest-save';
interface Props {
clusterCode: string;
namespace: string;
deploymentName: string;
manifest: any | null;
onSaved: () => void;
onSaved: (updated?: any) => void;
onCancel: () => void;
}
@@ -160,9 +160,8 @@ export function DeploymentEditStorage({ clusterCode, namespace, deploymentName,
if (k8sVols.length > 0) podSpec.volumes = k8sVols;
else delete podSpec.volumes;
// Save volumeMounts to each container
const allContainers = [...(podSpec.containers || []), ...(podSpec.initContainers || [])];
for (const c of allContainers) {
// 分别更新工作容器和初始化容器的 volumeMounts // Logan.Li
const applyMounts = (c: any) => {
const cName = c.name || '(unnamed)';
const cMounts = mounts
.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;
else delete c.volumeMounts;
}
};
(podSpec.containers || []).forEach(applyMounts);
(podSpec.initContainers || []).forEach(applyMounts);
// Rebuild containers arrays
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);
await saveDeploymentManifest(clusterCode, namespace, deploymentName, base);
toast({ title: '存储配置已保存', description: `${deploymentName} 更新已提交` });
onSaved();
onSaved(base);
} catch (e) {
toast({ title: '保存失败', description: e?.message || String(e), variant: 'destructive' });
} finally { setSaving(false); }
@@ -385,7 +385,7 @@ export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mod
namespace={namespace}
deploymentName={deploymentName}
manifest={manifest}
onSaved={() => { loadManifest(); loadData(); onModeChange('view'); }}
onSaved={() => { loadManifest(); loadData(); }}
onCancel={() => onModeChange('view')}
/>
)}
+180 -250
View File
@@ -2,22 +2,76 @@ import { useState, useEffect, useCallback, useMemo } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select';
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from '@/components/ui/table';
import {
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
Cell,
} from 'recharts';
import { RefreshCw, ChevronRight, Bell, AlertTriangle, Info, Activity, Server, Layers } from 'lucide-react';
import { RefreshCw, ChevronRight, Bell, AlertTriangle, X } from 'lucide-react';
import { kubernetesApi } from '@/api';
// Color palette for charts
const CHART_COLORS = ['#EF4444', '#F59E0B', '#3B82F6', '#8B5CF6', '#EC4899', '#14B8A6', '#F97316', '#6366F1', '#84CC16', '#06B6D4'];
interface RankItem { name: string; count: number }
// 简洁排名列表,替代花哨柱状图 // 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() {
const { clusterCode } = useParams<{ clusterCode: string }>();
@@ -28,15 +82,11 @@ export default function EventsPage() {
const [namespaces, setNamespaces] = useState<string[]>([]);
const [selectedNs, setSelectedNs] = useState('all');
const [filterType, setFilterType] = useState('all');
const [timeWindow, setTimeWindow] = useState('0'); // 0 = all
const [timeWindow, setTimeWindow] = useState('0');
const [activeCluster, setActiveCluster] = useState(clusterCode || '');
// Aggregation data
const [summary, setSummary] = useState<any>(null);
const [selectedReason, setSelectedReason] = useState<string>('');
const [selectedKind, setSelectedKind] = useState<string>('');
// Expanded row
const [selectedReason, setSelectedReason] = useState('');
const [selectedKind, setSelectedKind] = useState('');
const [expandedRow, setExpandedRow] = useState<string | null>(null);
useEffect(() => {
@@ -82,7 +132,6 @@ export default function EventsPage() {
useEffect(() => { loadEvents(); }, [loadEvents]);
useEffect(() => { loadSummary(); }, [loadSummary]);
// Filter events by selected reason/kind from aggregation click
const displayEvents = useMemo(() => {
let filtered = events;
const hours = parseInt(timeWindow, 10);
@@ -93,38 +142,27 @@ export default function EventsPage() {
return !ts || new Date(ts).getTime() >= since;
});
}
if (selectedReason) {
filtered = filtered.filter(e => e.reason === selectedReason);
}
if (selectedKind) {
filtered = filtered.filter(e => (e.object || '').startsWith(selectedKind + '/'));
}
if (selectedReason) filtered = filtered.filter((e) => e.reason === selectedReason);
if (selectedKind) filtered = filtered.filter((e) => (e.object || '').startsWith(`${selectedKind}/`));
return filtered;
}, [events, selectedReason, selectedKind, timeWindow]);
const warningCount = useMemo(() => events.filter(e => e.type === 'Warning').length, [events]);
const normalCount = useMemo(() => events.filter(e => e.type === 'Normal').length, [events]);
const warningCount = useMemo(() => events.filter((e) => e.type === 'Warning').length, [events]);
const normalCount = useMemo(() => events.filter((e) => e.type === 'Normal').length, [events]);
// Chart data from summary
const reasonData = useMemo(() => {
const list = summary?.by_reason || [];
return list.map(( r: any) => ({ name: r.reason || 'Unknown', count: r.count }));
}, [summary]);
const reasonData = useMemo<RankItem[]>(() =>
(summary?.by_reason || []).map((r: any) => ({ name: r.reason || 'Unknown', count: r.count })),
[summary]);
const nsData = useMemo(() => {
const list = summary?.by_namespace || [];
return list.map(( n: any) => ({ name: n.namespace || '<cluster>', count: n.count }));
}, [summary]);
const nsData = useMemo<RankItem[]>(() =>
(summary?.by_namespace || []).map((n: any) => ({ name: n.namespace || '<cluster>', count: n.count })),
[summary]);
const kindData = useMemo(() => {
const list = summary?.by_kind || [];
return list.map(( k: any) => ({ name: k.kind || 'Unknown', count: k.count }));
}, [summary]);
const kindData = useMemo<RankItem[]>(() =>
(summary?.by_kind || []).map((k: any) => ({ name: k.kind || 'Unknown', count: k.count })),
[summary]);
const formatTime = (ts: string) => {
if (!ts) return '-';
return ts.replace('T', ' ').substring(0, 19);
};
const formatTime = (ts: string) => (ts ? ts.replace('T', ' ').substring(0, 19) : '-');
const formatRelative = (ts: string) => {
if (!ts) return '';
@@ -140,35 +178,42 @@ export default function EventsPage() {
return (
<div className="space-y-4">
{/* Breadcrumb */}
<div className="flex items-center gap-2 text-sm text-slate-500">
<Link to="/kubernetes" className="hover:text-slate-300">集群</Link>
{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>
<div className="flex items-center gap-2 text-sm text-[var(--dg-text-muted)]">
<Link to="/kubernetes" className="hover:text-[var(--dg-text-secondary)]">集群</Link>
{activeCluster && (
<>
<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>
{/* Header + Filters */}
<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">
<Bell className="h-5 w-5 text-yellow-400" />事件中心
<h2 className="text-lg font-semibold text-[var(--dg-text-primary)] flex items-center gap-2">
<Bell className="h-5 w-5 text-[var(--dg-text-muted)]" />
事件中心
</h2>
<div className="flex items-center gap-2 flex-wrap">
<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>
<SelectContent className="bg-slate-800 border-slate-700">
{clusters.map(( c: any) => <SelectItem key={c.id} value={c.code || String(c.id)} className="text-xs">{c.name || c.code}</SelectItem>)}
<Select value={activeCluster} onValueChange={(v) => { setActiveCluster(v); navigate(`/kubernetes/${v}/events`); }}>
<SelectTrigger className="w-40 h-8 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
{clusters.map((c: any) => (
<SelectItem key={c.id} value={c.code || String(c.id)} className="text-xs">{c.name || c.code}</SelectItem>
))}
</SelectContent>
</Select>
<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>
<SelectContent className="bg-slate-800 border-slate-700">
<Select value={selectedNs} onValueChange={(v) => { setSelectedNs(v); setSelectedReason(''); setSelectedKind(''); }}>
<SelectTrigger className="w-36 h-8 text-xs"><SelectValue placeholder="命名空间" /></SelectTrigger>
<SelectContent>
<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>
</Select>
<Select value={timeWindow} onValueChange={v => setTimeWindow(v)}>
<SelectTrigger className="w-32 h-8 text-xs bg-slate-800 border-slate-700"><SelectValue /></SelectTrigger>
<SelectContent className="bg-slate-800 border-slate-700">
<Select value={timeWindow} onValueChange={setTimeWindow}>
<SelectTrigger className="w-32 h-8 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="0" className="text-xs">全部时间</SelectItem>
<SelectItem value="1" className="text-xs">近 1 小时</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>
</SelectContent>
</Select>
<Button variant="outline" size="sm" className="h-8 border-slate-700 text-slate-300 text-xs" onClick={() => { loadEvents(); loadSummary(); }} disabled={loading}>
<RefreshCw className={`h-3 w-3 mr-1 ${loading ? 'animate-spin' : ''}`} />刷新
<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' : ''}`} />
刷新
</Button>
</div>
</div>
{/* Summary Stats Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<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-slate-100">{summary?.total ?? events.length}</p>
</div>
<Activity className="h-8 w-8 text-slate-600" />
{/* 统计条:统一边框,无彩色卡片 */}
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-0">
<div className="grid grid-cols-2 md:grid-cols-4">
<StatCell label="事件总数" value={summary?.total ?? events.length} />
<StatCell label="告警事件" value={summary?.warning_count ?? warningCount} hint="Warning" />
<StatCell label="正常事件" value={summary?.normal_count ?? normalCount} hint="Normal" />
<StatCell label="告警类型" value={reasonData.length} hint="不同 Reason" />
</div>
</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) && (
<div className="flex items-center gap-2">
<span className="text-xs text-slate-500">已选过滤:</span>
<div className="flex items-center gap-2 text-xs">
<span className="text-[var(--dg-text-muted)]">筛选:</span>
{selectedReason && (
<Badge className="bg-amber-500/20 text-amber-300 border-amber-500/30 cursor-pointer text-xs" onClick={() => setSelectedReason('')}>
原因: {selectedReason} ✕
</Badge>
<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} <X className="h-3 w-3" />
</button>
)}
{selectedKind && (
<Badge className="bg-blue-500/20 text-blue-300 border-blue-500/30 cursor-pointer text-xs" onClick={() => setSelectedKind('')}>
类型: {selectedKind} ✕
</Badge>
<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} <X className="h-3 w-3" />
</button>
)}
</div>
)}
{/* Charts Row */}
{reasonData.length > 0 && (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
{/* By Reason Chart */}
<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">
<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 */}
<RankPanel title="告警原因" items={reasonData} selected={selectedReason} onSelect={setSelectedReason} />
{nsData.length > 0 && <RankPanel title="命名空间" items={nsData} />}
{kindData.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">
<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>
<RankPanel title="资源类型" items={kindData} selected={selectedKind} onSelect={setSelectedKind} />
)}
</div>
)}
{/* Recent Warnings Alert Panel */}
{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">
<h3 className="text-sm font-medium text-red-300 mb-3 flex items-center gap-1.5">
<AlertTriangle className="h-4 w-4" />最新告警事件 ({summary.recent_warnings.length})
<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 text-[var(--dg-warning)]" />
最新告警 ({summary.recent_warnings.length})
</h3>
<div className="space-y-1.5">
<div className="space-y-1">
{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">
<AlertTriangle className="h-3.5 w-3.5 text-red-400 mt-0.5 shrink-0" />
<div className="flex-1 min-w-0">
<div key={i} className="text-xs py-1.5 px-2 rounded hover:bg-[var(--dg-bg-tertiary)]/50">
<div className="flex items-center gap-2 flex-wrap">
<Badge variant="outline" className="text-[10px] h-4 px-1 border-red-500/30 text-red-400">{w.reason}</Badge>
<span className="text-slate-500 font-mono text-[11px]">{w.object}</span>
<span className="text-slate-600 text-[10px]">{w.namespace}</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>
<span className="shrink-0 font-mono text-[var(--dg-warning)]">{w.reason}</span>
<span className="text-[var(--dg-text-muted)] font-mono truncate">{w.object}</span>
<span className="text-[var(--dg-text-muted)]">{w.namespace}</span>
<span className="text-[var(--dg-text-muted)]">×{w.count}</span>
<span className="ml-auto text-[var(--dg-text-muted)]">{formatRelative(w.last_time)}</span>
</div>
<p className="text-[var(--dg-text-secondary)] truncate mt-0.5" title={w.message}>{w.message}</p>
</div>
))}
</div>
@@ -359,74 +291,72 @@ export default function EventsPage() {
</Card>
)}
{/* Events Table */}
<Card className="bg-slate-900 border-slate-800">
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-0">
<div className="flex items-center justify-between px-4 py-2 border-b border-slate-800">
<span className="text-sm text-slate-400">事件列表 ({displayEvents.length})</span>
<div className="flex items-center justify-between px-4 py-2 border-b border-[var(--dg-border)]">
<span className="text-sm text-[var(--dg-text-secondary)]">事件列表 ({displayEvents.length})</span>
<Select value={filterType} onValueChange={setFilterType}>
<SelectTrigger className="w-28 h-7 text-xs bg-slate-800 border-slate-700"><SelectValue /></SelectTrigger>
<SelectContent className="bg-slate-800 border-slate-700">
<SelectTrigger className="w-28 h-7 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="all" className="text-xs">所有类型</SelectItem>
<SelectItem value="Warning" className="text-xs">⚠ Warning</SelectItem>
<SelectItem value="Normal" className="text-xs">ℹ Normal</SelectItem>
<SelectItem value="Warning" className="text-xs">Warning</SelectItem>
<SelectItem value="Normal" className="text-xs">Normal</SelectItem>
</SelectContent>
</Select>
</div>
<Table>
<TableHeader><TableRow className="border-slate-800 hover:bg-transparent">
<TableHead className="text-slate-400 w-16">类型</TableHead>
<TableHead className="text-slate-400 w-24">原因</TableHead>
<TableHead className="text-slate-400">对象</TableHead>
<TableHead className="text-slate-400">消息</TableHead>
<TableHead className="text-slate-400 w-12">次数</TableHead>
<TableHead className="text-slate-400 w-20">命名空间</TableHead>
<TableHead className="text-slate-400 w-40">最后时间</TableHead>
</TableRow></TableHeader>
<TableHeader>
<TableRow className="border-[var(--dg-border)] hover:bg-transparent">
<TableHead className="text-[var(--dg-text-muted)] w-16">类型</TableHead>
<TableHead className="text-[var(--dg-text-muted)] w-24">原因</TableHead>
<TableHead className="text-[var(--dg-text-muted)]">对象</TableHead>
<TableHead className="text-[var(--dg-text-muted)]">消息</TableHead>
<TableHead className="text-[var(--dg-text-muted)] w-12">次数</TableHead>
<TableHead className="text-[var(--dg-text-muted)] w-20">命名空间</TableHead>
<TableHead className="text-[var(--dg-text-muted)] w-40">最后时间</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{displayEvents.length === 0 ? (
<TableRow><TableCell colSpan={7} className="text-center text-slate-500 py-8">
{loading ? <span className="animate-spin inline-block">⏳</span> : '暂无事件'}
</TableCell></TableRow>
<TableRow>
<TableCell colSpan={7} className="text-center text-[var(--dg-text-muted)] py-8">
{loading ? <RefreshCw className="h-4 w-4 animate-spin mx-auto" /> : '暂无事件'}
</TableCell>
</TableRow>
) : displayEvents.slice(0, 200).map((e, i) => {
const rowKey = `${e.name}-${i}`;
const isExpanded = expandedRow === rowKey;
const isWarning = e.type === 'Warning';
return (
<TableRow
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)}
>
<TableCell>
{e.type === 'Warning' ? (
<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>
) : (
<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>
)}
<span className={`text-xs font-medium ${isWarning ? 'text-[var(--dg-warning)]' : 'text-[var(--dg-text-muted)]'}`}>
{isWarning ? 'Warning' : 'Normal'}
</span>
</TableCell>
<TableCell className="text-xs text-slate-300 font-medium">
<span className={e.type === 'Warning' ? 'text-amber-400' : 'text-slate-400'}>{e.reason || '-'}</span>
</TableCell>
<TableCell className="text-xs text-slate-500 font-mono">{e.object || '-'}</TableCell>
<TableCell className="text-xs text-slate-400 max-w-[400px]">
<TableCell className="text-xs text-[var(--dg-text-secondary)] font-medium">{e.reason || '-'}</TableCell>
<TableCell className="text-xs text-[var(--dg-text-muted)] font-mono">{e.object || '-'}</TableCell>
<TableCell className="text-xs text-[var(--dg-text-secondary)] max-w-[400px]">
<div className={`truncate ${isExpanded ? 'whitespace-normal' : ''}`} title={!isExpanded ? e.message : undefined}>
{e.message || '-'}
</div>
{isExpanded && (
<div className="mt-1 pt-1 border-t border-slate-800 text-[10px] text-slate-600 space-y-0.5">
<div>首次出现: {formatTime(e.first_time)} ({formatRelative(e.first_time)})</div>
<div>最后出现: {formatTime(e.last_time)} ({formatRelative(e.last_time)})</div>
<div>事件名称: {e.name}</div>
<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.last_time)} ({formatRelative(e.last_time)})</div>
<div>名称: {e.name}</div>
</div>
)}
</TableCell>
<TableCell className="text-sm text-slate-400 text-center">
{e.count > 1 ? <Badge variant="outline" className="text-[10px] h-4 px-1">{e.count}</Badge> : <span className="text-slate-600">1</span>}
</TableCell>
<TableCell className="text-xs text-slate-500">{e.namespace || '-'}</TableCell>
<TableCell className="text-xs text-slate-500">
<TableCell className="text-sm text-[var(--dg-text-muted)] text-center tabular-nums">{e.count > 1 ? e.count : 1}</TableCell>
<TableCell className="text-xs text-[var(--dg-text-muted)]">{e.namespace || '-'}</TableCell>
<TableCell className="text-xs text-[var(--dg-text-muted)]">
<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>
</TableRow>
);
@@ -434,8 +364,8 @@ export default function EventsPage() {
</TableBody>
</Table>
{displayEvents.length > 200 && (
<div className="text-center py-2 text-xs text-slate-600 border-t border-slate-800">
仅显示前 200 条,共 {displayEvents.length} 条事件
<div className="text-center py-2 text-xs text-[var(--dg-text-muted)] border-t border-[var(--dg-border)]">
仅显示前 200 条,共 {displayEvents.length} 条
</div>
)}
</CardContent>
@@ -13,9 +13,11 @@ import {
import {
RefreshCw, Search, Loader2, Boxes, Container,
Network, FileText, Shield, Activity, Clock, ChevronRight,
Download, FileCode, Pencil, Terminal, HardDrive,
Download, FileCode, Pencil, Terminal, HardDrive, Plus, Upload,
} from 'lucide-react';
import { YamlViewer } from './YamlViewer';
import { ResourceVisualEditor } from './ResourceVisualEditor';
import { YamlImportDialog } from './YamlImportDialog';
import { PodExecTerminal } from './PodExecTerminal';
import { PodFileBrowser } from './PodFileBrowser';
import { kubernetesApi } from '@/api';
@@ -27,6 +29,18 @@ type ResourceType =
| 'pvcs' | 'statefulsets' | 'daemonsets'
| '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, {
title: 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 className="text-sm text-slate-400">{s.cluster_ip || '-'}</TableCell>
<TableCell className="text-sm text-slate-400">{s.ports || '-'}</TableCell>
<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>
<TableCell onClick={(e) => e.stopPropagation()}><VisualActions name={s.name} opts={opts} /></TableCell>
</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 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>
<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>
<TableCell onClick={(e) => e.stopPropagation()}><VisualActions name={i.name} opts={opts} /></TableCell>
</TableRow>
);
},
@@ -138,10 +146,7 @@ const RESOURCE_CONFIG: Record<ResourceType, {
<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 className="text-sm text-slate-400">{c.key_count || '-'}</TableCell>
<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>
<TableCell onClick={(e) => e.stopPropagation()}><VisualActions name={c.name} opts={opts} /></TableCell>
</TableRow>
),
},
@@ -153,10 +158,7 @@ const RESOURCE_CONFIG: Record<ResourceType, {
<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 className="text-sm text-slate-400">{s.type || '-'}</TableCell>
<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>
<TableCell onClick={(e) => e.stopPropagation()}><VisualActions name={s.name} opts={opts} /></TableCell>
</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 className="text-sm text-slate-400">{p.size || '-'}</TableCell>
<TableCell className="text-sm text-slate-400">{p.storage_class || '-'}</TableCell>
<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>
<TableCell onClick={(e) => e.stopPropagation()}><VisualActions name={p.name} opts={opts} /></TableCell>
</TableRow>
),
},
@@ -338,6 +337,8 @@ export default function NamespaceResourcePage() {
const [loading, setLoading] = useState(false);
const [search, setSearch] = useState('');
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 [fileBrowserPod, setFileBrowserPod] = useState<any>(null);
@@ -405,8 +406,15 @@ export default function NamespaceResourcePage() {
};
const handleYaml = (name: string) => setYamlItem({ resource: type, name });
// 网络/配置/存储统一走 YamlViewer 编辑(清理只读字段 + CodeMirror)// Logan.Li
const handleEdit = (name: string) => setYamlItem({ resource: type, name, editMode: true });
// 网络/配置/存储走可视化编辑器,YAML 作为高级入口 // Logan.Li
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 handleExportJSON = () => kubernetesApi.exportAsJSON(`${type}-${clusterCode}-${namespace}.json`, filtered);
@@ -426,6 +434,24 @@ export default function NamespaceResourcePage() {
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">
<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">
<Download className="h-3 w-3 mr-1" />JSON
</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 className="flex items-center gap-3 flex-wrap">
@@ -1,7 +1,4 @@
// ResourceVisualEditor.tsx — Kubernetes 资源可视化编辑器
// 支持:ConfigMap / Secret / Service / Ingress / PVC
// 替代纯 YAML 编辑,提供表单式交互体验
// 作者: Logan <admin@attacker.club>
// ResourceVisualEditor — ConfigMap/Secret/Service/Ingress/PVC 可视化编辑 // Logan.Li
import { useState, useEffect, useCallback } from 'react';
import { X, Save, Loader2, Plus, Trash2, FileText, Network, Shield, Database, ChevronRight} from 'lucide-react';
import { Button } from '@/components/ui/button';
@@ -15,6 +12,8 @@ import CodeMirror from '@uiw/react-codemirror';
import { yaml } from '@codemirror/lang-yaml';
import { oneDark } from '@codemirror/theme-one-dark';
import { cleanYAML } from './k8s-yaml-utils';
import { createEmptyManifest } from './resource-create-templates';
import { parseManifest, dumpManifest, prepareManifestForSave } from './k8s-manifest-utils';
interface ResourceVisualEditorProps {
clusterCode: string;
@@ -23,69 +22,80 @@ interface ResourceVisualEditorProps {
name: string;
onClose: () => void;
onSaved?: () => void;
/** 新建模式:不拉取远端 YAML,使用空模板 // Logan.Li */
isCreate?: boolean;
}
// Simple YAML→JS parser (avoids js-yaml dependency — K8s API returns JSON-compatible YAML)
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) {
export function ResourceVisualEditor({ clusterCode, namespace, resource, name, onClose, onSaved, isCreate = false }: ResourceVisualEditorProps) {
const { toast } = useToast();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [rawYaml, setRawYaml] = useState('');
const [manifest, setManifest] = useState<any>(null);
// Track which tab: 'visual' | 'yaml'
const [mode, setMode] = useState<'visual' | 'yaml'>('visual');
// YAML editor state (for fallback yaml mode)
const [editYaml, setEditYaml] = useState('');
const loadData = useCallback(async () => {
setLoading(true);
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 cleaned = cleanYAML(text);
setRawYaml(text);
setEditYaml(cleaned);
const parsed = tryParseYAML(cleaned);
const parsed = await parseManifest(cleaned);
setManifest(parsed);
if (!parsed) {
// Can't parse — fall back to YAML mode
setMode('yaml');
toast({ title: '可视化编辑不可用', description: '无法解析资源结构,已切换到 YAML 编辑模式' });
}
} catch (e) {
toast({ title: '加载失败', description: (e as Record<string, any>).message, variant: 'destructive' });
} finally { setLoading(false); }
}, [clusterCode, namespace, resource, name]);
}, [clusterCode, namespace, resource, name, isCreate]);
useEffect(() => { loadData(); }, [loadData]);
const handleSave = async (updatedManifest: any) => {
setSaving(true);
try {
// Serialize manifest back to YAML
const yamlStr = manifestToYaml(updatedManifest);
const yamlStr = await prepareManifestForSave(updatedManifest);
const resName = updatedManifest?.metadata?.name || name;
if (isCreate) {
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?.();
onClose();
} 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); }
};
const handleSaveYaml = async () => {
setSaving(true);
try {
await kubernetesApi.updateResourceYaml(clusterCode, namespace, resource, name, editYaml);
const yamlStr = cleanYAML(editYaml);
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?.();
onClose();
} catch (e) {
@@ -93,13 +103,24 @@ export function ResourceVisualEditor({ clusterCode, namespace, resource, name, o
} finally { setSaving(false); }
};
// Manifest → YAML string (JSON serialization works as YAML superset for K8s API)
function manifestToYaml( m: any): string {
// Use 2-space indented JSON — K8s API accepts JSON as YAML
return JSON.stringify(m, null, 2);
// 可视化 ↔ YAML 双向同步 // Logan.Li
const switchToVisual = async () => {
const parsed = await parseManifest(editYaml);
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 }> = {
configmaps: { title: 'ConfigMap', icon: FileText, color: 'text-blue-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">
<MetaIcon className={`h-5 w-5 ${meta.color}`} />
<div>
<h2 className="text-lg font-bold text-[var(--dg-text-primary)]">{meta.title} 编辑器</h2>
<p className="text-xs text-[var(--dg-text-muted)]">{name} · {namespace} · {clusterCode}</p>
<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)]">{isCreate ? namespace : name} · {namespace} · {clusterCode}</p>
</div>
</div>
<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">
<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)]'}`}
onClick={() => manifest && setMode('visual')}
disabled={!manifest}
onClick={() => { if (mode === 'yaml') switchToVisual(); else setMode('visual'); }}
disabled={!manifest && mode !== 'yaml'}
>可视化编辑</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)]'}`}
onClick={() => setMode('yaml')}
onClick={() => { if (mode === 'visual') switchToYaml(); else setMode('yaml'); }}
>YAML 编辑</button>
</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>
@@ -165,11 +186,11 @@ export function ResourceVisualEditor({ clusterCode, namespace, resource, name, o
</div>
) : manifest ? (
<>
{resource === 'configmaps' && <ConfigMapEditor manifest={manifest} onSave={handleSave} saving={saving} />}
{resource === 'secrets' && <SecretEditor manifest={manifest} onSave={handleSave} saving={saving} />}
{resource === 'services' && <ServiceEditor manifest={manifest} onSave={handleSave} saving={saving} />}
{resource === 'ingresses' && <IngressEditor manifest={manifest} onSave={handleSave} saving={saving} />}
{resource === 'pvcs' && <PvcEditor 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} isCreate={isCreate} />}
{resource === 'services' && <ServiceEditor manifest={manifest} onSave={handleSave} saving={saving} isCreate={isCreate} />}
{resource === 'ingresses' && <IngressEditor manifest={manifest} onSave={handleSave} saving={saving} isCreate={isCreate} />}
{resource === 'pvcs' && <PvcEditor manifest={manifest} onSave={handleSave} saving={saving} isCreate={isCreate} />}
{!['configmaps', 'secrets', 'services', 'ingresses', 'pvcs'].includes(resource) && (
<div className="text-center py-12 text-[var(--dg-text-muted)]">
<p>该资源类型暂不支持可视化编辑</p>
@@ -192,8 +213,9 @@ export function ResourceVisualEditor({ clusterCode, namespace, resource, name, o
// ══════════════════════════════════════════════
// 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 [resName, setResName] = useState(manifest?.metadata?.name || '');
const [entries, setEntries] = useState<{ key: string; value: string }[]>(
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 => {
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 (
<div className="space-y-4">
<div>
<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="flex items-center justify-between mb-2">
@@ -264,8 +286,9 @@ function ConfigMapEditor({ manifest, onSave, saving }: { manifest: any; onSave:
// ══════════════════════════════════════════════
// 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 [resName, setResName] = useState(manifest?.metadata?.name || '');
// Decode base64 values for editing
const [entries, setEntries] = useState<{ key: string; value: string; encoded: boolean }[]>(
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;
}
});
onSave({ ...manifest, data: newData });
onSave({ ...manifest, metadata: { ...manifest.metadata, name: resName.trim() }, data: newData });
};
return (
<div className="space-y-4">
<div>
<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>
<Label className="text-[var(--dg-text-secondary)] text-xs">类型</Label>
@@ -353,8 +376,9 @@ function SecretEditor({ manifest, onSave, saving }: { manifest: any; onSave: ( m
// ══════════════════════════════════════════════
// 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 [resName, setResName] = useState(manifest?.metadata?.name || '');
const [svcType, setSvcType] = useState(spec.type || 'ClusterIP');
const [ports, setPorts] = useState<any[]>(
(spec.ports || []).map(( p: any) => ({
@@ -401,28 +425,34 @@ function ServiceEditor({ manifest, onSave, saving }: { manifest: any; onSave: (
const selObj: Record<string, string> = {};
selector.forEach(s => { if (s.key.trim()) selObj[s.key.trim()] = s.value; });
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 (
<div className="space-y-5">
<div className="grid grid-cols-2 gap-4">
<div>
<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)]" />
<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">Service 类型</Label>
<Select value={svcType} onValueChange={setSvcType}>
<SelectTrigger className="mt-1 h-8 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ClusterIP">ClusterIP (集群内部)</SelectItem>
<SelectItem value="NodePort">NodePort (节点端口)</SelectItem>
<SelectItem value="LoadBalancer">LoadBalancer (负载均衡)</SelectItem>
</SelectContent>
</Select>
<Label className="text-[var(--dg-text-secondary)] text-xs mb-2 block">服务类型</Label>
<div className="flex rounded-lg border border-[var(--dg-border)] overflow-hidden w-fit">
{svcTypes.map((t) => (
<button
key={t.value}
type="button"
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)]'}`}
onClick={() => setSvcType(t.value)}
>
{t.label}
</button>
))}
</div>
</div>
@@ -484,8 +514,10 @@ function ServiceEditor({ manifest, onSave, saving }: { manifest: any; onSave: (
// ══════════════════════════════════════════════
// 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 [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 }[] }[]>(
(spec.rules || []).map(( r: any) => ({
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 (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<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>
<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 className="flex items-center justify-between mb-2">
@@ -594,9 +635,10 @@ function IngressEditor({ manifest, onSave, saving }: { manifest: any; onSave: (
// ══════════════════════════════════════════════
// 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 resources = spec.resources?.requests || {};
const [resName, setResName] = useState(manifest?.metadata?.name || '');
const [size, setSize] = useState(resources.storage || '1Gi');
const [accessMode, setAccessMode] = useState(spec.accessModes?.[0] || 'ReadWriteOnce');
const [storageClass, setStorageClass] = useState(spec.storageClassName || '');
@@ -604,6 +646,7 @@ function PvcEditor({ manifest, onSave, saving }: { manifest: any; onSave: ( m: a
const handleSave = () => {
onSave({
...manifest,
metadata: { ...manifest.metadata, name: resName.trim() },
spec: {
...spec,
accessModes: [accessMode],
@@ -617,7 +660,7 @@ function PvcEditor({ manifest, onSave, saving }: { manifest: any; onSave: ( m: a
<div className="space-y-4">
<div>
<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 className="grid grid-cols-2 gap-4">
<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;
deploymentName: string;
manifest: any | null;
onSaved: () => void;
onSaved: (updated?: any) => 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 + 容器编辑预留)
// 作者: Logan.Li
import { useState } from 'react';
// EditModeContainer.tsx — 编辑模式 Tab 容器,共享 manifest 状态 // Logan.Li
import { useState, useEffect } from 'react';
import { Container, Settings, Database, Network } from 'lucide-react';
import { DeploymentEditBasic } from '../DeploymentEditBasic';
import { DeploymentEditContainers } from '../DeploymentEditContainers';
@@ -20,98 +19,60 @@ export interface EditModeContainerProps {
export function EditModeContainer({ clusterCode, namespace, deploymentName, manifest, onSaved, onCancel }: EditModeContainerProps) {
type EditTab = 'basic' | 'containers' | 'storage' | 'advanced' | 'service';
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 }> = [
{ key: 'basic', label: '基本信息', icon: Settings, available: true },
{ key: 'containers', label: '容器信息', icon: Container, available: true },
{ key: 'storage', label: '存储挂载', icon: Database, available: true },
{ key: 'advanced', label: '高级设置', icon: Settings, available: true },
{ key: 'service', label: '服务/应用路由', icon: Network, available: true },
useEffect(() => {
if (manifest) setEditManifest(manifest);
}, [manifest]);
const handleSaved = (updated?: any) => {
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 (
<div className="space-y-4">
{/* Tab 导航 */}
<div className="flex items-center gap-1 border-b border-slate-800 pb-px overflow-x-auto">
{tabs.map(tab => {
{tabs.map((tab) => {
const active = activeTab === tab.key;
const Icon = tab.icon;
return (
<button
key={tab.key}
disabled={!tab.available}
onClick={() => tab.available && setActiveTab(tab.key)}
className={`flex items-center gap-1.5 px-3 py-2 text-xs font-medium border-b-2 transition-colors whitespace-nowrap
${active
? 'border-blue-500 text-blue-400'
: tab.available
? 'border-transparent text-slate-400 hover:text-slate-200'
: 'border-transparent text-slate-600 cursor-not-allowed'
onClick={() => setActiveTab(tab.key)}
className={`flex items-center gap-1.5 px-3 py-2 text-xs font-medium border-b-2 transition-colors whitespace-nowrap ${
active ? 'border-blue-500 text-blue-400' : 'border-transparent text-slate-400 hover:text-slate-200'
}`}
>
<Icon className="h-3.5 w-3.5" />
{tab.label}
{tab.badge && (
<span className={`text-[9px] px-1 py-0.5 rounded ${active ? 'bg-blue-500/20 text-blue-300' : 'bg-slate-800 text-slate-600'}`}>
{tab.badge}
</span>
)}
</button>
);
})}
</div>
{/* Tab 内容 */}
{activeTab === 'basic' ? (
<DeploymentEditBasic
clusterCode={clusterCode}
namespace={namespace}
deploymentName={deploymentName}
manifest={manifest}
onSaved={onSaved}
onCancel={onCancel}
/>
) : activeTab === 'containers' ? (
/* M7: 完整容器编辑器 — 工作容器 + 初始化容器 */
<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}
{activeTab === 'basic' && <DeploymentEditBasic {...commonProps} />}
{activeTab === 'containers' && <DeploymentEditContainers {...commonProps} />}
{activeTab === 'storage' && <DeploymentEditStorage {...commonProps} />}
{activeTab === 'advanced' && <DeploymentEditAdvanced {...commonProps} />}
{activeTab === 'service' && <DeploymentEditService {...commonProps} />}
</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"),
},
},
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: {
port: 5173,
host: true,
+56 -1
View File
@@ -924,6 +924,51 @@ cmd_kubernetes_build() {
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 部署
cmd_kubernetes() {
local action=${1:-help}
@@ -959,7 +1004,13 @@ cmd_kubernetes() {
# 4. 部署应用
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"
ensure_cluster_mode_for_replicas "$target_replicas"
kubectl -n "$KUBERNETES_NAMESPACE" rollout status deployment/aiops --timeout=180s 2>/dev/null || {
warning "应用正在启动,查看日志: ./run.sh kubernetes logs"
}
@@ -983,6 +1034,10 @@ cmd_kubernetes() {
upgrade|update)
check_kubernetes_deps
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
info "更新 Deployment 镜像: $KUBERNETES_BUILT_IMAGE"
kubectl -n "$KUBERNETES_NAMESPACE" set image deployment/aiops aiops="$KUBERNETES_BUILT_IMAGE" --record
@@ -1061,7 +1116,7 @@ cmd_kubernetes() {
check_kubernetes_deps
local replicas=${2:-1}
if [ "$replicas" -gt 1 ]; then
warning "MODE=local 时 SQLite 数据不共享,多副本会导致数据分裂,建议 replicas=1 或切换 MODE=release"
ensure_cluster_mode_for_replicas "$replicas"
fi
info "扩缩容到 $replicas 副本..."
kubectl -n "$KUBERNETES_NAMESPACE" scale deployment/aiops --replicas="$replicas"