feat: K8s集群导出改为导出所有命名空间资源,去掉命名空间选择
- 后端: 新增 ExportResourcesAllNS handler (GET /:clusterCode/export),遍历所有NS收集资源 - 前端: KubernetesClusters 导出弹窗去掉命名空间输入框,一键导出全部NS - API: exportResources → exportAllResources,无需传 namespace 参数
This commit is contained in:
1 parent
a54b2d86b0
commit
dd7cb6895e
4 files changed
+156
-20
No files matched your search
@@ -16,11 +16,124 @@ import (
|
|||||||
|
|
||||||
// yamlEntry 表示一个待打包的 YAML 资源
|
// yamlEntry 表示一个待打包的 YAML 资源
|
||||||
type yamlEntry struct {
|
type yamlEntry struct {
|
||||||
|
namespace string // 所属命名空间(用于 tar 内路径)
|
||||||
folder string // tar 内子目录
|
folder string // tar 内子目录
|
||||||
name string // 文件名(不含 .yaml)
|
name string // 文件名(不含 .yaml)
|
||||||
yaml []byte // 清理后的 YAML
|
yaml []byte // 清理后的 YAML
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ExportResourcesAllNS 导出集群所有命名空间的 Deployment+Service+Ingress+ConfigMap+Secret 为 tar.gz
|
||||||
|
func (h *KubernetesHandler) ExportResourcesAllNS(c *gin.Context) {
|
||||||
|
clusterID := h.resolveClusterCode(c.Param("clusterCode"))
|
||||||
|
if clusterID == 0 {
|
||||||
|
c.JSON(500, gin.H{"code": 500, "message": "cluster not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
client, _, err := h.getK8sClient(clusterID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(500, gin.H{"code": 500, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取所有命名空间
|
||||||
|
namespaces, err := client.ListNamespaces()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(500, gin.H{"code": 500, "message": "list namespaces failed: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 资源类型 → API 路径模板 + 子目录名
|
||||||
|
type resourceDef struct {
|
||||||
|
apiPathTmpl string // %s = namespace
|
||||||
|
folder string
|
||||||
|
kind string
|
||||||
|
apiVersion string
|
||||||
|
}
|
||||||
|
resourceDefs := []resourceDef{
|
||||||
|
{"/apis/apps/v1/namespaces/%s/deployments", "deployments", "Deployment", "apps/v1"},
|
||||||
|
{"/api/v1/namespaces/%s/services", "services", "Service", "v1"},
|
||||||
|
{"/apis/networking.k8s.io/v1/namespaces/%s/ingresses", "ingresses", "Ingress", "networking.k8s.io/v1"},
|
||||||
|
{"/api/v1/namespaces/%s/configmaps", "configmaps", "ConfigMap", "v1"},
|
||||||
|
{"/api/v1/namespaces/%s/secrets", "secrets", "Secret", "v1"},
|
||||||
|
}
|
||||||
|
|
||||||
|
var entries []yamlEntry
|
||||||
|
totalCount := 0
|
||||||
|
|
||||||
|
for _, ns := range namespaces {
|
||||||
|
for _, rd := range resourceDefs {
|
||||||
|
listPath := fmt.Sprintf(rd.apiPathTmpl, ns)
|
||||||
|
yamlBytes, err := client.GetResourceYAML(listPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[ExportAllNS] %s/%s list failed: %v", ns, rd.folder, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var listObj map[string]interface{}
|
||||||
|
if err := yaml.Unmarshal(yamlBytes, &listObj); err != nil {
|
||||||
|
log.Printf("[ExportAllNS] %s/%s parse failed: %v", ns, rd.folder, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
items, ok := listObj["items"].([]interface{})
|
||||||
|
if !ok || len(items) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, item := range items {
|
||||||
|
itemMap, ok := item.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
meta, _ := itemMap["metadata"].(map[string]interface{})
|
||||||
|
name, _ := meta["name"].(string)
|
||||||
|
if name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
itemMap["apiVersion"] = rd.apiVersion
|
||||||
|
itemMap["kind"] = rd.kind
|
||||||
|
cleanResourceYAML(itemMap)
|
||||||
|
|
||||||
|
cleanedBytes, err := yaml.Marshal(itemMap)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[ExportAllNS] marshal %s/%s/%s failed: %v", ns, rd.folder, name, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
entries = append(entries, yamlEntry{
|
||||||
|
namespace: ns,
|
||||||
|
folder: rd.folder,
|
||||||
|
name: name,
|
||||||
|
yaml: cleanedBytes,
|
||||||
|
})
|
||||||
|
totalCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if totalCount == 0 {
|
||||||
|
c.JSON(404, gin.H{"code": 404, "message": "集群没有可导出的资源"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tarGz, err := buildTarGzAllNS(entries)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(500, gin.H{"code": 500, "message": "打包失败: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
clusterName := c.Param("clusterCode")
|
||||||
|
filename := fmt.Sprintf("cluster-%s-%s.tar.gz", clusterName, time.Now().Format("20060102-150405"))
|
||||||
|
c.Header("Content-Type", "application/gzip")
|
||||||
|
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
|
||||||
|
c.Header("Content-Length", fmt.Sprintf("%d", len(tarGz)))
|
||||||
|
c.Data(http.StatusOK, "application/gzip", tarGz)
|
||||||
|
|
||||||
|
log.Printf("[ExportAllNS] exported %d resources across %d namespaces (cluster %d)", totalCount, len(namespaces), clusterID)
|
||||||
|
}
|
||||||
|
|
||||||
// ExportResourcesNS 导出指定命名空间的 Deployment + Service + Ingress + ConfigMap + Secret 为 tar.gz
|
// ExportResourcesNS 导出指定命名空间的 Deployment + Service + Ingress + ConfigMap + Secret 为 tar.gz
|
||||||
func (h *KubernetesHandler) ExportResourcesNS(c *gin.Context) {
|
func (h *KubernetesHandler) ExportResourcesNS(c *gin.Context) {
|
||||||
clusterID := h.resolveClusterCode(c.Param("clusterCode"))
|
clusterID := h.resolveClusterCode(c.Param("clusterCode"))
|
||||||
@@ -212,15 +325,48 @@ func cleanRuntimeAnnotations(annotations map[string]interface{}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildTarGz 构建 tar.gz 包
|
// buildTarGz 构建 tar.gz 包(单命名空间,路径: ns/folder/name.yaml)
|
||||||
func buildTarGz(entries []yamlEntry, namespace string) ([]byte, error) {
|
func buildTarGz(entries []yamlEntry, namespace string) ([]byte, error) {
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
gw := gzip.NewWriter(&buf)
|
gw := gzip.NewWriter(&buf)
|
||||||
tw := tar.NewWriter(gw)
|
tw := tar.NewWriter(gw)
|
||||||
|
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
// 路径: {namespace}/{folder}/{name}.yaml
|
ns := e.namespace
|
||||||
path := fmt.Sprintf("%s/%s/%s.yaml", namespace, e.folder, e.name)
|
if ns == "" {
|
||||||
|
ns = namespace
|
||||||
|
}
|
||||||
|
path := fmt.Sprintf("%s/%s/%s.yaml", ns, e.folder, e.name)
|
||||||
|
header := &tar.Header{
|
||||||
|
Name: path,
|
||||||
|
Mode: 0644,
|
||||||
|
Size: int64(len(e.yaml)),
|
||||||
|
}
|
||||||
|
if err := tw.WriteHeader(header); err != nil {
|
||||||
|
return nil, fmt.Errorf("tar write header %s: %w", path, err)
|
||||||
|
}
|
||||||
|
if _, err := io.Copy(tw, bytes.NewReader(e.yaml)); err != nil {
|
||||||
|
return nil, fmt.Errorf("tar write body %s: %w", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tw.Close(); err != nil {
|
||||||
|
return nil, fmt.Errorf("tar close: %w", err)
|
||||||
|
}
|
||||||
|
if err := gw.Close(); err != nil {
|
||||||
|
return nil, fmt.Errorf("gzip close: %w", err)
|
||||||
|
}
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildTarGzAllNS 构建 tar.gz 包(多命名空间,路径: ns/folder/name.yaml)
|
||||||
|
func buildTarGzAllNS(entries []yamlEntry) ([]byte, error) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
gw := gzip.NewWriter(&buf)
|
||||||
|
tw := tar.NewWriter(gw)
|
||||||
|
|
||||||
|
for _, e := range entries {
|
||||||
|
path := fmt.Sprintf("%s/%s/%s.yaml", e.namespace, e.folder, e.name)
|
||||||
header := &tar.Header{
|
header := &tar.Header{
|
||||||
Name: path,
|
Name: path,
|
||||||
Mode: 0644,
|
Mode: 0644,
|
||||||
|
|||||||
@@ -398,6 +398,7 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
|
|||||||
// 🆕 YAML Apply
|
// 🆕 YAML Apply
|
||||||
kubernetesRouter.POST("/:clusterCode/yaml/apply", k8sHandler.ApplyYAML)
|
kubernetesRouter.POST("/:clusterCode/yaml/apply", k8sHandler.ApplyYAML)
|
||||||
// 🆕 批量导出资源 (Deployment+Service+Ingress+ConfigMap+Secret) // Logan.Li
|
// 🆕 批量导出资源 (Deployment+Service+Ingress+ConfigMap+Secret) // Logan.Li
|
||||||
|
kubernetesRouter.GET("/:clusterCode/export", k8sHandler.ExportResourcesAllNS)
|
||||||
kubernetesRouter.GET("/:clusterCode/namespace/:namespace/export", k8sHandler.ExportResourcesNS)
|
kubernetesRouter.GET("/:clusterCode/namespace/:namespace/export", k8sHandler.ExportResourcesNS)
|
||||||
|
|
||||||
// PVC (存储)
|
// PVC (存储)
|
||||||
|
|||||||
@@ -52,7 +52,6 @@ export function KubernetesClusters() {
|
|||||||
|
|
||||||
// 导出资源弹窗
|
// 导出资源弹窗
|
||||||
const [exportCluster, setExportCluster] = useState<any>(null);
|
const [exportCluster, setExportCluster] = useState<any>(null);
|
||||||
const [exportNs, setExportNs] = useState('default');
|
|
||||||
const [exporting, setExporting] = useState(false);
|
const [exporting, setExporting] = useState(false);
|
||||||
|
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
@@ -113,11 +112,10 @@ export function KubernetesClusters() {
|
|||||||
const handleExport = async () => {
|
const handleExport = async () => {
|
||||||
if (!exportCluster) return;
|
if (!exportCluster) return;
|
||||||
const code = exportCluster.code || String(exportCluster.id);
|
const code = exportCluster.code || String(exportCluster.id);
|
||||||
if (!exportNs.trim()) { toast.error('请输入命名空间'); return; }
|
|
||||||
try {
|
try {
|
||||||
setExporting(true);
|
setExporting(true);
|
||||||
toast.info(`正在导出 ${exportNs} 命名空间的资源...`);
|
toast.info(`正在导出 ${exportCluster.name} 所有命名空间的资源...`);
|
||||||
await kubernetesApi.exportResources(code, exportNs.trim());
|
await kubernetesApi.exportAllResources(code);
|
||||||
toast.success('导出完成,已开始下载');
|
toast.success('导出完成,已开始下载');
|
||||||
setExportCluster(null);
|
setExportCluster(null);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -359,7 +357,6 @@ export function KubernetesClusters() {
|
|||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setExportCluster(cluster);
|
setExportCluster(cluster);
|
||||||
setExportNs('default');
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Download className="h-4 w-4 mr-2" />
|
<Download className="h-4 w-4 mr-2" />
|
||||||
@@ -473,16 +470,8 @@ export function KubernetesClusters() {
|
|||||||
<div className="bg-slate-900 border border-slate-700 rounded-lg p-5 w-96 shadow-xl" onClick={(e) => e.stopPropagation()}>
|
<div className="bg-slate-900 border border-slate-700 rounded-lg p-5 w-96 shadow-xl" onClick={(e) => e.stopPropagation()}>
|
||||||
<h3 className="text-base font-semibold text-slate-100 mb-1">导出集群资源</h3>
|
<h3 className="text-base font-semibold text-slate-100 mb-1">导出集群资源</h3>
|
||||||
<p className="text-xs text-slate-400 mb-4">
|
<p className="text-xs text-slate-400 mb-4">
|
||||||
导出 {exportCluster.name} 的 Deployment + Service + Ingress + ConfigMap + Secret 为 tar.gz
|
导出 {exportCluster.name} 所有命名空间的 Deployment、Service、Ingress、ConfigMap、Secret 为 tar.gz
|
||||||
</p>
|
</p>
|
||||||
<label className="text-xs text-slate-300 mb-1.5 block">命名空间</label>
|
|
||||||
<Input
|
|
||||||
value={exportNs}
|
|
||||||
onChange={(e) => setExportNs(e.target.value)}
|
|
||||||
placeholder="default"
|
|
||||||
className="bg-slate-800 border-slate-700 text-slate-100 mb-4"
|
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleExport()}
|
|
||||||
/>
|
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<Button variant="outline" className="border-slate-700 text-slate-300" onClick={() => setExportCluster(null)}>取消</Button>
|
<Button variant="outline" className="border-slate-700 text-slate-300" onClick={() => setExportCluster(null)}>取消</Button>
|
||||||
<Button className="bg-blue-600 hover:bg-blue-700 text-white" onClick={handleExport} disabled={exporting}>
|
<Button className="bg-blue-600 hover:bg-blue-700 text-white" onClick={handleExport} disabled={exporting}>
|
||||||
|
|||||||
@@ -190,9 +190,9 @@ export const kubernetesApi = {
|
|||||||
syncCluster: async (clusterId: number | string) => {
|
syncCluster: async (clusterId: number | string) => {
|
||||||
return http.post(`/kubernetes-clusters/${clusterId}/sync`);
|
return http.post(`/kubernetes-clusters/${clusterId}/sync`);
|
||||||
},
|
},
|
||||||
// 🆕 批量导出资源 (Deployment+Service+Ingress+ConfigMap+Secret) // Logan.Li
|
// 🆕 批量导出集群所有命名空间资源 // Logan.Li
|
||||||
exportResources: async (clusterCode: string, namespace: string) => {
|
exportAllResources: async (clusterCode: string) => {
|
||||||
return http.download(`/kubernetes/${clusterCode}/namespace/${namespace}/export`, `${namespace}-resources.tar.gz`);
|
return http.download(`/kubernetes/${clusterCode}/export`, `cluster-${clusterCode}-resources.tar.gz`);
|
||||||
},
|
},
|
||||||
getTopology: async (clusterId: number | string) => { // Logan.Li
|
getTopology: async (clusterId: number | string) => { // Logan.Li
|
||||||
return http.get(`/kubernetes-clusters/${clusterId}/topology`);
|
return http.get(`/kubernetes-clusters/${clusterId}/topology`);
|
||||||
|
|||||||
Reference in new issue
Block a user