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:
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')}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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 } };
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user