From d0cf71b05a2811896ba61d8759504e1053d43a3c Mon Sep 17 00:00:00 2001 From: "Logan.Li" Date: Fri, 26 Jun 2026 13:52:32 +0800 Subject: [PATCH] =?UTF-8?q?feat(k8s):=20=E4=BA=8B=E4=BB=B6=E9=A1=B5?= =?UTF-8?q?=E7=AE=80=E5=8C=96=E3=80=81=E5=8F=AF=E8=A7=86=E5=8C=96=E7=BC=96?= =?UTF-8?q?=E8=BE=91=20P0=E3=80=81Deployment=20=E4=BA=94=20Tab=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 事件中心:去彩虹图表,改专业排名列表 + 主题变量 - 网络/配置/存储:ResourceVisualEditor 双向 YAML 同步、导入 YAML、新建 - vite manualChunks 减小 embed 主包体积 - run.sh:多副本强制 MODE=release + MySQL/Redis - Deployment 编辑:共享 manifest、统一 saveDeploymentManifest - 修复存储挂载 initContainers 混入 bug、补 Tolerations UI - 服务 Tab:applyYAML 创建 + ResourceVisualEditor 编辑 - 基本信息:Pod 模板标签 + Selector 同步 --- .../kubernetes/DeploymentEditAdvanced.tsx | 53 ++- .../kubernetes/DeploymentEditBasic.tsx | 83 +++- .../kubernetes/DeploymentEditContainers.tsx | 7 +- .../kubernetes/DeploymentEditService.tsx | 44 +- .../kubernetes/DeploymentEditStorage.tsx | 22 +- .../kubernetes/DeploymentWorkload.tsx | 2 +- .../src/components/kubernetes/EventsPage.tsx | 446 ++++++++---------- .../kubernetes/NamespaceResourcePage.tsx | 82 +++- .../kubernetes/ResourceVisualEditor.tsx | 195 +++++--- .../kubernetes/YamlImportDialog.tsx | 76 +++ .../kubernetes/deployment-edit/types.ts | 2 +- .../kubernetes/deployment-manifest-save.ts | 13 + .../deployment-workload/EditModeContainer.tsx | 113 ++--- .../kubernetes/k8s-manifest-utils.ts | 32 ++ .../kubernetes/resource-create-templates.ts | 63 +++ frontend/vite.config.ts | 21 + run.sh | 57 ++- 17 files changed, 814 insertions(+), 497 deletions(-) create mode 100644 frontend/src/components/kubernetes/YamlImportDialog.tsx create mode 100644 frontend/src/components/kubernetes/deployment-manifest-save.ts create mode 100644 frontend/src/components/kubernetes/k8s-manifest-utils.ts create mode 100644 frontend/src/components/kubernetes/resource-create-templates.ts diff --git a/frontend/src/components/kubernetes/DeploymentEditAdvanced.tsx b/frontend/src/components/kubernetes/DeploymentEditAdvanced.tsx index 19e8db5..2c84aab 100644 --- a/frontend/src/components/kubernetes/DeploymentEditAdvanced.tsx +++ b/frontend/src/components/kubernetes/DeploymentEditAdvanced.tsx @@ -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, + {/* 容忍 (Tolerations) — 此前仅有状态无 UI // Logan.Li */} + + +
+ 容忍 (Tolerations) + +
+
+ + {tolerations.length === 0 &&

未配置容忍

} + {tolerations.map((t, i) => ( +
+ 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" /> + + 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" /> + + 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" /> + +
+ ))} +
+
+ void; + onSaved: (updated?: any) => void; onCancel: () => void; } @@ -46,8 +45,8 @@ export function DeploymentEditBasic({ clusterCode, namespace, deploymentName, ma const [labels, setLabels] = useState>([]); const [annotations, setAnnotations] = useState>([]); const [selectorLabels, setSelectorLabels] = useState>([]); + const [templateLabels, setTemplateLabels] = useState>([]); - // 初始化数据 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; - base.spec.template = base.spec.template || {}; - base.spec.template.metadata = base.spec.template.metadata || {}; - base.spec.template.metadata.labels = { ...base.spec.template.metadata.labels, ...selectorObj }; } - - const json = JSON.stringify(base, null, 2); - await kubernetesApi.updateResourceYaml(clusterCode, namespace, 'deployments', deploymentName, json); + const tplLabelObj = kvToObj(templateLabels); + base.spec.template = base.spec.template || {}; + base.spec.template.metadata = base.spec.template.metadata || {}; + if (Object.keys(tplLabelObj).length > 0) { + base.spec.template.metadata.labels = tplLabelObj; + } else { + delete base.spec.template.metadata.labels; + } + await saveDeploymentManifest(clusterCode, namespace, deploymentName, base); toast({ title: '基本信息已保存', description: `${deploymentName} 更新已提交` }); - 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 + {/* Pod 模板标签 */} + + +
+ + + Pod 模板标签 (template.metadata.labels) + +
+ + +
+
+
+ +

Pod 模板标签必须包含 Selector 全部键值,否则 Deployment 无法管理 Pod

+ {templateLabels.length === 0 &&

无模板标签

} + {templateLabels.map((l, i) => ( +
+ updateTemplateLabel(i, { key: e.target.value })} + className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-8" /> + updateTemplateLabel(i, { value: e.target.value })} + className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-8" /> + +
+ ))} +
+
+ {/* Annotations */} diff --git a/frontend/src/components/kubernetes/DeploymentEditContainers.tsx b/frontend/src/components/kubernetes/DeploymentEditContainers.tsx index 416f408..43a290c 100644 --- a/frontend/src/components/kubernetes/DeploymentEditContainers.tsx +++ b/frontend/src/components/kubernetes/DeploymentEditContainers.tsx @@ -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); } diff --git a/frontend/src/components/kubernetes/DeploymentEditService.tsx b/frontend/src/components/kubernetes/DeploymentEditService.tsx index 9cef2cf..f242fea 100644 --- a/frontend/src/components/kubernetes/DeploymentEditService.tsx +++ b/frontend/src/components/kubernetes/DeploymentEditService.tsx @@ -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([]); const [ingresses, setIngresses] = useState([]); const [selectorLabels, setSelectorLabels] = useState>({}); - 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, {svc.type} {matched && 关联} {svc.clusterIP} - - + ); diff --git a/frontend/src/components/kubernetes/DeploymentEditStorage.tsx b/frontend/src/components/kubernetes/DeploymentEditStorage.tsx index 2b7cddf..c5aa427 100644 --- a/frontend/src/components/kubernetes/DeploymentEditStorage.tsx +++ b/frontend/src/components/kubernetes/DeploymentEditStorage.tsx @@ -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); } diff --git a/frontend/src/components/kubernetes/DeploymentWorkload.tsx b/frontend/src/components/kubernetes/DeploymentWorkload.tsx index c657a93..c51159d 100644 --- a/frontend/src/components/kubernetes/DeploymentWorkload.tsx +++ b/frontend/src/components/kubernetes/DeploymentWorkload.tsx @@ -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')} /> )} diff --git a/frontend/src/components/kubernetes/EventsPage.tsx b/frontend/src/components/kubernetes/EventsPage.tsx index d11129d..1bfd9d8 100644 --- a/frontend/src/components/kubernetes/EventsPage.tsx +++ b/frontend/src/components/kubernetes/EventsPage.tsx @@ -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 ( + + +

{title}

+ {items.length === 0 ? ( +

{emptyText || '暂无数据'}

+ ) : ( +
+ {items.map((item) => { + const active = selected === item.name; + return ( + + ); + })} +
+ )} +
+
+ ); +} + +function StatCell({ label, value, hint }: { label: string; value: number | string; hint?: string }) { + return ( +
+

{label}

+

{value}

+ {hint &&

{hint}

} +
+ ); +} export default function EventsPage() { const { clusterCode } = useParams<{ clusterCode: string }>(); @@ -28,19 +82,15 @@ export default function EventsPage() { const [namespaces, setNamespaces] = useState([]); 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(null); - const [selectedReason, setSelectedReason] = useState(''); - const [selectedKind, setSelectedKind] = useState(''); - - // Expanded row + const [selectedReason, setSelectedReason] = useState(''); + const [selectedKind, setSelectedKind] = useState(''); const [expandedRow, setExpandedRow] = useState(null); useEffect(() => { - kubernetesApi.getClusters().then(( res: any) => { + kubernetesApi.getClusters().then((res: any) => { const list = res?.list || []; setClusters(list); if (!activeCluster && list.length > 0) { @@ -53,7 +103,7 @@ export default function EventsPage() { useEffect(() => { if (!activeCluster) return; - kubernetesApi.getNamespaces(activeCluster).then(( res: any) => { + kubernetesApi.getNamespaces(activeCluster).then((res: any) => { setNamespaces(Array.isArray(res) ? res : (res?.list || res?.data || [])); }).catch(() => {}); }, [activeCluster]); @@ -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(() => + (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 || '', count: n.count })); - }, [summary]); + const nsData = useMemo(() => + (summary?.by_namespace || []).map((n: any) => ({ name: n.namespace || '', 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(() => + (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 (
- {/* Breadcrumb */} -
- 集群 - {activeCluster && <>{activeCluster}} - 事件 +
+ 集群 + {activeCluster && ( + <> + + {activeCluster} + + )} + + 事件
- {/* Header + Filters */}
-

- 事件中心 +

+ + 事件中心

- { setActiveCluster(v); navigate(`/kubernetes/${v}/events`); }}> + + + {clusters.map((c: any) => ( + {c.name || c.code} + ))} - { setSelectedNs(v); setSelectedReason(''); setSelectedKind(''); }}> + + 所有命名空间 - {namespaces.map(n => {n})} + {namespaces.map((n) => {n})} - + + 全部时间 近 1 小时 近 6 小时 @@ -176,182 +221,69 @@ export default function EventsPage() { 近 7 天 -

- {/* Summary Stats Cards */} -
- - -
-
-

事件总数

-

{summary?.total ?? events.length}

-
- -
-
-
- - -
-
-

告警事件

-

{summary?.warning_count ?? warningCount}

-

Warning 累计触发次数

-
- -
-
-
- - -
-
-

正常事件

-

{summary?.normal_count ?? normalCount}

-
- -
-
-
- - -
-
-

告警类型数

-

{reasonData.length}

-

不同 Reason 数量

-
- -
-
-
-
+ {/* 统计条:统一边框,无彩色卡片 */} + + +
+ + + + +
+
+
- {/* Active Filter Badge */} {(selectedReason || selectedKind) && ( -
- 已选过滤: +
+ 筛选: {selectedReason && ( - setSelectedReason('')}> - 原因: {selectedReason} ✕ - + )} {selectedKind && ( - setSelectedKind('')}> - 类型: {selectedKind} ✕ - + )}
)} - {/* Charts Row */} {reasonData.length > 0 && (
- {/* By Reason Chart */} - - -

- 告警原因 Top {reasonData.length} -

- - - - - - - setSelectedReason(prev => prev === d?.name ? '' : d?.name)}> - {reasonData.map(( _: any, i: number) => ( - - ))} - - - -

点击柱状图过滤事件列表

-
-
- - {/* By Namespace Chart */} - {nsData.length > 0 && ( - - -

- 命名空间分布 Top {nsData.length} -

- - - - - - - - - -
-
- )} - - {/* By Kind Chart */} + + {nsData.length > 0 && } {kindData.length > 0 && ( - - -

- 资源类型分布 -

- - - - - - - setSelectedKind(prev => prev === d?.name ? '' : d?.name)}> - {kindData.map(( _: any, i: number) => ( - - ))} - - - -

点击柱状图过滤事件列表

-
-
+ )}
)} - {/* Recent Warnings Alert Panel */} {summary?.recent_warnings?.length > 0 && ( - + -

- 最新告警事件 ({summary.recent_warnings.length}) +

+ + 最新告警 ({summary.recent_warnings.length})

-
- {summary.recent_warnings.map(( w: any, i: number) => ( -
- -
-
- {w.reason} - {w.object} - {w.namespace} - ×{w.count} - {formatRelative(w.last_time)} -
-

{w.message}

+
+ {summary.recent_warnings.map((w: any, i: number) => ( +
+
+ {w.reason} + {w.object} + {w.namespace} + ×{w.count} + {formatRelative(w.last_time)}
+

{w.message}

))}
@@ -359,74 +291,72 @@ export default function EventsPage() { )} - {/* Events Table */} - + -
- 事件列表 ({displayEvents.length}) +
+ 事件列表 ({displayEvents.length})
- - 类型 - 原因 - 对象 - 消息 - 次数 - 命名空间 - 最后时间 - + + + 类型 + 原因 + 对象 + 消息 + 次数 + 命名空间 + 最后时间 + + {displayEvents.length === 0 ? ( - - {loading ? ⏳ : '暂无事件'} - + + + {loading ? : '暂无事件'} + + ) : displayEvents.slice(0, 200).map((e, i) => { const rowKey = `${e.name}-${i}`; const isExpanded = expandedRow === rowKey; + const isWarning = e.type === 'Warning'; return ( setExpandedRow(isExpanded ? null : rowKey)} > - {e.type === 'Warning' ? ( - W - ) : ( - N - )} + + {isWarning ? 'Warning' : 'Normal'} + - - {e.reason || '-'} - - {e.object || '-'} - + {e.reason || '-'} + {e.object || '-'} +
{e.message || '-'}
{isExpanded && ( -
-
首次出现: {formatTime(e.first_time)} ({formatRelative(e.first_time)})
-
最后出现: {formatTime(e.last_time)} ({formatRelative(e.last_time)})
-
事件名称: {e.name}
+
+
首次: {formatTime(e.first_time)} ({formatRelative(e.first_time)})
+
最后: {formatTime(e.last_time)} ({formatRelative(e.last_time)})
+
名称: {e.name}
)} - - {e.count > 1 ? {e.count} : 1} - - {e.namespace || '-'} - + {e.count > 1 ? e.count : 1} + {e.namespace || '-'} +
{formatTime(e.last_time)}
-
{formatRelative(e.last_time)}
+
{formatRelative(e.last_time)}
); @@ -434,12 +364,12 @@ export default function EventsPage() {
{displayEvents.length > 200 && ( -
- 仅显示前 200 条,共 {displayEvents.length} 条事件 +
+ 仅显示前 200 条,共 {displayEvents.length} 条
)}
); -} +} \ No newline at end of file diff --git a/frontend/src/components/kubernetes/NamespaceResourcePage.tsx b/frontend/src/components/kubernetes/NamespaceResourcePage.tsx index 017b16d..4d63708 100644 --- a/frontend/src/components/kubernetes/NamespaceResourcePage.tsx +++ b/frontend/src/components/kubernetes/NamespaceResourcePage.tsx @@ -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 } }) => ( +
+ + +
+); + const RESOURCE_CONFIG: Record{s.type || '-'} {s.cluster_ip || '-'} {s.ports || '-'} - - - + e.stopPropagation()}> ), }, @@ -122,10 +133,7 @@ const RESOURCE_CONFIG: Record{i.namespace} {hosts.length > 0 ? hosts.join(', ') : '-'} {i.tls ? 'HTTPS' : 'HTTP'} - - - + e.stopPropagation()}> ); }, @@ -138,10 +146,7 @@ const RESOURCE_CONFIG: Record{c.name} {c.namespace} {c.key_count || '-'} - - - + e.stopPropagation()}> ), }, @@ -153,10 +158,7 @@ const RESOURCE_CONFIG: Record{s.name} {s.namespace} {s.type || '-'} - - - + e.stopPropagation()}> ), }, @@ -197,10 +199,7 @@ const RESOURCE_CONFIG: Record{p.phase || '-'} {p.size || '-'} {p.storage_class || '-'} - - - + e.stopPropagation()}> ), }, @@ -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(null); const [fileBrowserPod, setFileBrowserPod] = useState(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 && ( + setShowYamlImport(false)} + onApplied={loadData} + /> + )} + {visualItem && ( + setVisualItem(null)} + onSaved={loadData} + /> + )}
集群 @@ -456,6 +482,16 @@ export default function NamespaceResourcePage() { + {VISUAL_RESOURCE_TYPES.includes(type) && ( + <> + + + + )}
diff --git a/frontend/src/components/kubernetes/ResourceVisualEditor.tsx b/frontend/src/components/kubernetes/ResourceVisualEditor.tsx index c3accd1..3becef8 100644 --- a/frontend/src/components/kubernetes/ResourceVisualEditor.tsx +++ b/frontend/src/components/kubernetes/ResourceVisualEditor.tsx @@ -1,7 +1,4 @@ -// ResourceVisualEditor.tsx — Kubernetes 资源可视化编辑器 -// 支持:ConfigMap / Secret / Service / Ingress / PVC -// 替代纯 YAML 编辑,提供表单式交互体验 -// 作者: Logan +// 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(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).message, variant: 'destructive' }); } finally { setLoading(false); } - }, [clusterCode, namespace, resource, name]); + }, [clusterCode, namespace, resource, name, isCreate]); useEffect(() => { loadData(); }, [loadData]); - const handleSave = async ( updatedManifest: any) => { + const handleSave = async (updatedManifest: any) => { setSaving(true); try { - // Serialize manifest back to YAML - const yamlStr = manifestToYaml(updatedManifest); - await kubernetesApi.updateResourceYaml(clusterCode, namespace, resource, name, yamlStr); - toast({ title: '保存成功', description: `${resource}/${name} 已更新` }); + 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).message, variant: 'destructive' }); + toast({ title: isCreate ? '创建失败' : '保存失败', description: (e as Record).message, variant: 'destructive' }); } finally { setSaving(false); } }; const handleSaveYaml = async () => { setSaving(true); try { - await kubernetesApi.updateResourceYaml(clusterCode, namespace, resource, name, editYaml); - toast({ title: 'YAML 已应用', description: `${resource}/${name} 更新成功` }); + 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 = { 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
-

{meta.title} 编辑器

-

{name} · {namespace} · {clusterCode}

+

{isCreate ? `新建 ${meta.title}` : `${meta.title} 编辑`}

+

{isCreate ? namespace : name} · {namespace} · {clusterCode}

@@ -127,12 +148,12 @@ export function ResourceVisualEditor({ clusterCode, namespace, resource, name, o
@@ -165,11 +186,11 @@ export function ResourceVisualEditor({ clusterCode, namespace, resource, name, o
) : manifest ? ( <> - {resource === 'configmaps' && } - {resource === 'secrets' && } - {resource === 'services' && } - {resource === 'ingresses' && } - {resource === 'pvcs' && } + {resource === 'configmaps' && } + {resource === 'secrets' && } + {resource === 'services' && } + {resource === 'ingresses' && } + {resource === 'pvcs' && } {!['configmaps', 'secrets', 'services', 'ingresses', 'pvcs'].includes(resource) && (

该资源类型暂不支持可视化编辑

@@ -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 (
- + setResName(e.target.value)} readOnly={!isCreate} className="mt-1 font-mono text-xs bg-[var(--dg-bg-tertiary)]" />
@@ -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 (
- + setResName(e.target.value)} readOnly={!isCreate} className="mt-1 font-mono text-xs bg-[var(--dg-bg-tertiary)]" />
@@ -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( (spec.ports || []).map(( p: any) => ({ @@ -401,28 +425,34 @@ function ServiceEditor({ manifest, onSave, saving }: { manifest: any; onSave: ( const selObj: Record = {}; 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 (
-
-
- - -
-
- - +
+ + setResName(e.target.value)} readOnly={!isCreate} className="mt-1 font-mono text-xs bg-[var(--dg-bg-tertiary)]" /> +
+
+ +
+ {svcTypes.map((t) => ( + + ))}
@@ -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 = { ...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 (
-
- - +
+
+ + setResName(e.target.value)} readOnly={!isCreate} className="mt-1 font-mono text-xs bg-[var(--dg-bg-tertiary)]" /> +
+
+ + setIngressClass(e.target.value)} placeholder="nginx / alb" className="mt-1 font-mono text-xs h-8 bg-[var(--dg-bg-tertiary)]" /> +
@@ -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
- + setResName(e.target.value)} readOnly={!isCreate} className="mt-1 font-mono text-xs bg-[var(--dg-bg-tertiary)]" />
diff --git a/frontend/src/components/kubernetes/YamlImportDialog.tsx b/frontend/src/components/kubernetes/YamlImportDialog.tsx new file mode 100644 index 0000000..c21a930 --- /dev/null +++ b/frontend/src/components/kubernetes/YamlImportDialog.tsx @@ -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).message, variant: 'destructive' }); + } finally { setApplying(false); } + }; + + return ( +
+
e.stopPropagation()} + > +
+
+ +
+

导入 YAML

+

支持多文档(--- 分隔),适用于新建或更新资源

+
+
+ +
+
+ +
+
+ + +
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/kubernetes/deployment-edit/types.ts b/frontend/src/components/kubernetes/deployment-edit/types.ts index 0f4437e..05a5643 100644 --- a/frontend/src/components/kubernetes/deployment-edit/types.ts +++ b/frontend/src/components/kubernetes/deployment-edit/types.ts @@ -65,7 +65,7 @@ export interface DeploymentEditContainersProps { namespace: string; deploymentName: string; manifest: any | null; - onSaved: () => void; + onSaved: (updated?: any) => void; onCancel: () => void; } diff --git a/frontend/src/components/kubernetes/deployment-manifest-save.ts b/frontend/src/components/kubernetes/deployment-manifest-save.ts new file mode 100644 index 0000000..1817175 --- /dev/null +++ b/frontend/src/components/kubernetes/deployment-manifest-save.ts @@ -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, +): Promise { + const yaml = await prepareManifestForSave(manifest); + await kubernetesApi.updateResourceYaml(clusterCode, namespace, 'deployments', deploymentName, yaml); +} \ No newline at end of file diff --git a/frontend/src/components/kubernetes/deployment-workload/EditModeContainer.tsx b/frontend/src/components/kubernetes/deployment-workload/EditModeContainer.tsx index f78ba75..4edcd27 100644 --- a/frontend/src/components/kubernetes/deployment-workload/EditModeContainer.tsx +++ b/frontend/src/components/kubernetes/deployment-workload/EditModeContainer.tsx @@ -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('basic'); + const [editManifest, setEditManifest] = useState(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 (
- {/* Tab 导航 */}
- {tabs.map(tab => { + {tabs.map((tab) => { const active = activeTab === tab.key; const Icon = tab.icon; return ( ); })}
- {/* Tab 内容 */} - {activeTab === 'basic' ? ( - - ) : activeTab === 'containers' ? ( - /* M7: 完整容器编辑器 — 工作容器 + 初始化容器 */ - - ) : activeTab === 'storage' ? ( - /* M8: 存储挂载 — volumes + volumeMounts 汇总 */ - - ) : activeTab === 'advanced' ? ( - /* M8: 高级设置 — 更新策略/调度/DNS/安全上下文/终止宽限期 */ - - ) : activeTab === 'service' ? ( - /* M9: 服务/应用路由 — 关联 Service + Ingress,可创建 */ - - ) : null} + {activeTab === 'basic' && } + {activeTab === 'containers' && } + {activeTab === 'storage' && } + {activeTab === 'advanced' && } + {activeTab === 'service' && }
); } \ No newline at end of file diff --git a/frontend/src/components/kubernetes/k8s-manifest-utils.ts b/frontend/src/components/kubernetes/k8s-manifest-utils.ts new file mode 100644 index 0000000..7871b56 --- /dev/null +++ b/frontend/src/components/kubernetes/k8s-manifest-utils.ts @@ -0,0 +1,32 @@ +// K8s manifest 解析/序列化共用工具 // Logan.Li +import { cleanYAML } from './k8s-yaml-utils'; + +export async function parseManifest(text: string): Promise | null> { + if (!text?.trim()) return null; + try { return JSON.parse(text); } catch {} + try { + const jsyaml: Record = 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): Promise { + const jsyaml: Record = await import('js-yaml'); + return jsyaml.dump(manifest, { lineWidth: 120, noRefs: true }); +} + +/** 保存前剥离 status 等噪音字段 // Logan.Li */ +export async function prepareManifestForSave(manifest: Record): Promise { + 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); +} \ No newline at end of file diff --git a/frontend/src/components/kubernetes/resource-create-templates.ts b/frontend/src/components/kubernetes/resource-create-templates.ts new file mode 100644 index 0000000..0bda95a --- /dev/null +++ b/frontend/src/components/kubernetes/resource-create-templates.ts @@ -0,0 +1,63 @@ +// 网络/配置/存储资源新建时的空 manifest 模板 // Logan.Li + +export function createEmptyManifest(resource: string, namespace: string, name: string): Record { + 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 } }; + } +} \ No newline at end of file diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index e59627b..5c9db11 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -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, diff --git a/run.sh b/run.sh index 1872214..01e1ab9 100755 --- a/run.sh +++ b/run.sh @@ -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"