-
事件列表 ({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 (
-
-
-
+
@@ -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"