fix: K8s集群编辑保存失败+凭据覆盖+去掉Kubectl死按钮
问题:
1. UpdateCluster 用 CreateK8sClusterRequest (code binding:"required"),
编辑时不传 code → 校验失败 400 → 保存不成功
2. Token/KubeConfig json:"-" 不序列化, 编辑时拿不到原值,
提交空字符串覆盖真实凭据
3. 操作菜单 Kubectl 是死按钮
修复:
- UpdateCluster 改用 map[string]interface{} 接收, 逐字段更新,
空值不覆盖 (防止凭据清空)
- 新增 GET /:id/detail 返回 has_token/has_kube_config 脱敏标记
- 编辑弹框拉取详情 API 回填, Token/KubeConfig 留空显示 '已配置' 提示
- 去掉 Kubectl 死按钮, 保留 详情/配置/删除 三项
This commit is contained in:
1 parent
33e58ce25a
commit
abf2917fad
5 files changed
+119
-33
No files matched your search
@@ -145,7 +145,8 @@ func (h *K8sHandler) CreateCluster(c *gin.Context) {
|
|||||||
func (h *K8sHandler) UpdateCluster(c *gin.Context) {
|
func (h *K8sHandler) UpdateCluster(c *gin.Context) {
|
||||||
id, _ := strconv.Atoi(c.Param("id"))
|
id, _ := strconv.Atoi(c.Param("id"))
|
||||||
|
|
||||||
var req models.CreateK8sClusterRequest
|
// 用 map 接收,避免 binding:"required" 校验 // Logan.Li
|
||||||
|
var req map[string]interface{}
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, models.Response{
|
c.JSON(http.StatusBadRequest, models.Response{
|
||||||
Code: 400,
|
Code: 400,
|
||||||
@@ -163,15 +164,39 @@ func (h *K8sHandler) UpdateCluster(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
updates := map[string]interface{}{
|
// 逐字段更新:空值不覆盖原值(防止凭据被清空) // Logan.Li
|
||||||
"name": req.Name,
|
updates := map[string]interface{}{}
|
||||||
"version": req.Version,
|
if v, ok := req["name"]; ok && v != nil && v != "" {
|
||||||
"config_type": req.ConfigType,
|
updates["name"] = v
|
||||||
"api_server": req.APIServer,
|
}
|
||||||
"api_endpoint": req.APIServer,
|
if v, ok := req["version"]; ok {
|
||||||
"token": req.Token,
|
updates["version"] = v
|
||||||
"kube_config": req.KubeConfig,
|
}
|
||||||
"description": req.Description,
|
if v, ok := req["description"]; ok {
|
||||||
|
updates["description"] = v
|
||||||
|
}
|
||||||
|
if v, ok := req["config_type"]; ok {
|
||||||
|
updates["config_type"] = v
|
||||||
|
}
|
||||||
|
if v, ok := req["api_server"]; ok && v != nil && v != "" {
|
||||||
|
updates["api_server"] = v
|
||||||
|
updates["api_endpoint"] = v
|
||||||
|
}
|
||||||
|
// Token / KubeConfig:空值不覆盖(前端编辑时可能拿不到原值)
|
||||||
|
if v, ok := req["token"]; ok && v != nil && v != "" {
|
||||||
|
updates["token"] = v
|
||||||
|
}
|
||||||
|
if v, ok := req["kube_config"]; ok && v != nil && v != "" {
|
||||||
|
updates["kube_config"] = v
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(updates) == 0 {
|
||||||
|
c.JSON(http.StatusOK, models.Response{
|
||||||
|
Code: 200,
|
||||||
|
Message: "无更新内容",
|
||||||
|
Data: cluster,
|
||||||
|
})
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.db.Model(&cluster).Updates(updates).Error; err != nil {
|
if err := h.db.Model(&cluster).Updates(updates).Error; err != nil {
|
||||||
@@ -191,6 +216,44 @@ func (h *K8sHandler) UpdateCluster(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetClusterDetail 返回集群完整信息(含 token/kube_config,供编辑回填) // Logan.Li
|
||||||
|
func (h *K8sHandler) GetClusterDetail(c *gin.Context) {
|
||||||
|
id, _ := strconv.Atoi(c.Param("id"))
|
||||||
|
|
||||||
|
var cluster models.K8sCluster
|
||||||
|
if err := h.db.First(&cluster, id).Error; err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, models.Response{
|
||||||
|
Code: 404,
|
||||||
|
Message: "集群不存在",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回时手动脱敏:token/kube_config 只返回是否存在标记,不返回原文
|
||||||
|
c.JSON(http.StatusOK, models.Response{
|
||||||
|
Code: 200,
|
||||||
|
Message: "success",
|
||||||
|
Data: map[string]interface{}{
|
||||||
|
"id": cluster.ID,
|
||||||
|
"name": cluster.Name,
|
||||||
|
"code": cluster.Code,
|
||||||
|
"version": cluster.Version,
|
||||||
|
"status": cluster.Status,
|
||||||
|
"config_type": cluster.ConfigType,
|
||||||
|
"api_server": cluster.APIServer,
|
||||||
|
"api_endpoint": cluster.APIEndpoint,
|
||||||
|
"description": cluster.Description,
|
||||||
|
"has_token": cluster.Token != "",
|
||||||
|
"has_kube_config": cluster.KubeConfig != "",
|
||||||
|
"node_count": cluster.NodeCount,
|
||||||
|
"pod_count": cluster.PodCount,
|
||||||
|
"labels": cluster.Labels,
|
||||||
|
"created_at": cluster.CreatedAt,
|
||||||
|
"updated_at": cluster.UpdatedAt,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// DeleteCluster 删除集群
|
// DeleteCluster 删除集群
|
||||||
// @Summary 删除集群
|
// @Summary 删除集群
|
||||||
// @Description 删除指定K8s集群
|
// @Description 删除指定K8s集群
|
||||||
|
|||||||
@@ -236,6 +236,7 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
|
|||||||
{
|
{
|
||||||
clusters.GET("", k8sHandler.ListClusters)
|
clusters.GET("", k8sHandler.ListClusters)
|
||||||
clusters.GET("/:id", k8sHandler.GetCluster)
|
clusters.GET("/:id", k8sHandler.GetCluster)
|
||||||
|
clusters.GET("/:id/detail", k8sHandler.GetClusterDetail) // 编辑用详情(含 has_token 标记) // Logan.Li
|
||||||
clusters.POST("", k8sHandler.CreateCluster)
|
clusters.POST("", k8sHandler.CreateCluster)
|
||||||
clusters.PUT("/:id", k8sHandler.UpdateCluster)
|
clusters.PUT("/:id", k8sHandler.UpdateCluster)
|
||||||
clusters.DELETE("/:id", k8sHandler.DeleteCluster)
|
clusters.DELETE("/:id", k8sHandler.DeleteCluster)
|
||||||
|
|||||||
@@ -37,21 +37,42 @@ export function K8sClusterDialog({
|
|||||||
}) {
|
}) {
|
||||||
const [formData, setFormData] = useState({ ...emptyForm });
|
const [formData, setFormData] = useState({ ...emptyForm });
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [hasToken, setHasToken] = useState(false); // 已配置 Token 标记 // Logan.Li
|
||||||
|
const [hasKubeConfig, setHasKubeConfig] = useState(false); // 已配置 Kubeconfig 标记
|
||||||
|
|
||||||
// 打开时同步表单(编辑时填充,新建时重置)
|
// 打开时同步表单(编辑时拉取详情,新建时重置)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
if (editingCluster) {
|
if (editingCluster && editingCluster.id) {
|
||||||
setFormData({
|
// 编辑模式:拉取详情获取 has_token/has_kube_config 标记 // Logan.Li
|
||||||
name: editingCluster.name || '',
|
k8sApi.getClusterDetail(editingCluster.id).then((res: any) => {
|
||||||
version: editingCluster.version || '',
|
const d = res || {};
|
||||||
description: editingCluster.description || '',
|
setHasToken(!!d.has_token);
|
||||||
config_type: Number(editingCluster.config_type ?? 1),
|
setHasKubeConfig(!!d.has_kube_config);
|
||||||
api_server: editingCluster.api_server || '',
|
setFormData({
|
||||||
token: editingCluster.token || '',
|
name: d.name || '',
|
||||||
kube_config: editingCluster.kube_config || '',
|
version: d.version || '',
|
||||||
|
description: d.description || '',
|
||||||
|
config_type: Number(d.config_type ?? 1),
|
||||||
|
api_server: d.api_server || '',
|
||||||
|
token: '', // 不回填原文,留空不修改
|
||||||
|
kube_config: '', // 不回填原文,留空不修改
|
||||||
|
});
|
||||||
|
}).catch(() => {
|
||||||
|
// 降级:用列表数据
|
||||||
|
setFormData({
|
||||||
|
name: editingCluster.name || '',
|
||||||
|
version: editingCluster.version || '',
|
||||||
|
description: editingCluster.description || '',
|
||||||
|
config_type: Number(editingCluster.config_type ?? 1),
|
||||||
|
api_server: editingCluster.api_server || '',
|
||||||
|
token: '',
|
||||||
|
kube_config: '',
|
||||||
|
});
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
setHasToken(false);
|
||||||
|
setHasKubeConfig(false);
|
||||||
setFormData({ ...emptyForm });
|
setFormData({ ...emptyForm });
|
||||||
}
|
}
|
||||||
}, [open, editingCluster]);
|
}, [open, editingCluster]);
|
||||||
@@ -171,28 +192,34 @@ export function K8sClusterDialog({
|
|||||||
|
|
||||||
{formData.config_type === 0 ? (
|
{formData.config_type === 0 ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-[var(--dg-text-secondary)]">Token</Label>
|
<Label className="text-[var(--dg-text-secondary)]">
|
||||||
|
Token {hasToken && <span className="text-emerald-400 text-xs ml-1">✓ 已配置(留空不修改)</span>}
|
||||||
|
</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
value={formData.token}
|
value={formData.token}
|
||||||
onChange={(e) => setFormData({ ...formData, token: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, token: e.target.value })}
|
||||||
placeholder="粘贴 Service Account Token..."
|
placeholder={hasToken ? '已配置,输入新值覆盖...' : '粘贴 Service Account Token...'}
|
||||||
rows={4}
|
rows={4}
|
||||||
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)] font-mono text-xs"
|
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)] font-mono text-xs"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-[var(--dg-text-secondary)]">Kubeconfig</Label>
|
<Label className="text-[var(--dg-text-secondary)]">
|
||||||
|
Kubeconfig {hasKubeConfig && <span className="text-emerald-400 text-xs ml-1">✓ 已配置(留空不修改)</span>}
|
||||||
|
</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
value={formData.kube_config}
|
value={formData.kube_config}
|
||||||
onChange={(e) => handleKubeConfigChange(e.target.value)}
|
onChange={(e) => handleKubeConfigChange(e.target.value)}
|
||||||
placeholder={'apiVersion: v1\nkind: Config\nclusters:\n- cluster:\n server: https://10.0.0.1:6443\n ...'}
|
placeholder={hasKubeConfig ? '已配置,输入新值覆盖...' : 'apiVersion: v1\nkind: Config\nclusters:\n- cluster:\n server: https://10.0.0.1:6443\n ...'}
|
||||||
rows={10}
|
rows={10}
|
||||||
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)] font-mono text-xs"
|
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)] font-mono text-xs"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-[var(--dg-text-muted)]">
|
{!hasKubeConfig && (
|
||||||
粘贴 kubeconfig 内容后将自动解析 API Server 地址
|
<p className="text-xs text-[var(--dg-text-muted)]">
|
||||||
</p>
|
粘贴 kubeconfig 内容后将自动解析 API Server 地址
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ import {
|
|||||||
Settings,
|
Settings,
|
||||||
Eye,
|
Eye,
|
||||||
Trash2,
|
Trash2,
|
||||||
Terminal,
|
|
||||||
Search,
|
Search,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { k8sApi } from '@/services/api';
|
import { k8sApi } from '@/services/api';
|
||||||
@@ -263,10 +262,6 @@ export function K8sClusters() {
|
|||||||
<Eye className="h-4 w-4 mr-2" />
|
<Eye className="h-4 w-4 mr-2" />
|
||||||
查看详情
|
查看详情
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem className="text-slate-300 focus:bg-slate-700 focus:text-slate-100">
|
|
||||||
<Terminal className="h-4 w-4 mr-2" />
|
|
||||||
Kubectl
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="text-slate-300 focus:bg-slate-700 focus:text-slate-100"
|
className="text-slate-300 focus:bg-slate-700 focus:text-slate-100"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
|
|||||||
@@ -161,7 +161,7 @@ export const k8sApi = {
|
|||||||
return http.get<ListResponse>('/k8s-clusters');
|
return http.get<ListResponse>('/k8s-clusters');
|
||||||
},
|
},
|
||||||
getClusterDetail: async (id: number | string) => {
|
getClusterDetail: async (id: number | string) => {
|
||||||
return http.get(`/k8s-clusters/${id}`);
|
return http.get(`/k8s-clusters/${id}/detail`);
|
||||||
},
|
},
|
||||||
createCluster: async (data: any) => {
|
createCluster: async (data: any) => {
|
||||||
return http.post('/k8s-clusters', data);
|
return http.post('/k8s-clusters', data);
|
||||||
|
|||||||
Reference in new issue
Block a user