diff --git a/frontend/src/pages/K8sResourceList.tsx b/frontend/src/pages/K8sResourceList.tsx index 9218202..f621f42 100644 --- a/frontend/src/pages/K8sResourceList.tsx +++ b/frontend/src/pages/K8sResourceList.tsx @@ -28,6 +28,9 @@ import { Snackbar, MenuItem, Select, + AppBar, + Toolbar, + Tooltip, } from '@mui/material' import SettingsIcon from '@mui/icons-material/Settings' import CloseIcon from '@mui/icons-material/Close' @@ -35,6 +38,7 @@ import AddIcon from '@mui/icons-material/Add' import UploadFileIcon from '@mui/icons-material/UploadFile' import VisibilityIcon from '@mui/icons-material/Visibility' import DeleteIcon from '@mui/icons-material/Delete' +import EditIcon from '@mui/icons-material/Edit' const KINDS = [ { key: 'namespace', label: 'Namespace', endpoint: '/api/v1/k8s/namespace/list' }, @@ -75,6 +79,10 @@ export default function K8sResourceList() { const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) const [deleteTarget, setDeleteTarget] = useState<{ name: string; namespace: string } | null>(null) const [deleting, setDeleting] = useState(false) + const [editDialogOpen, setEditDialogOpen] = useState(false) + const [editResource, setEditResource] = useState<{ name: string; namespace: string; kind: string; yaml: string } | null>(null) + const [editYaml, setEditYaml] = useState('') + const [editing, setEditing] = useState(false) const logsEndRef = useRef(null) useEffect(() => { @@ -211,6 +219,52 @@ export default function K8sResourceList() { } } + const handleEditResource = async (name: string, namespace: string, kind: string) => { + try { + const res = await fetch(`/api/v1/k8s/resource/get?name=${encodeURIComponent(name)}&namespace=${encodeURIComponent(namespace)}&kind=${encodeURIComponent(kind)}`) + const result = await res.json() + + if (!res.ok) { + throw new Error(result.err || 'Failed to get resource') + } + + setEditResource({ name, namespace, kind, yaml: result.data.yaml }) + setEditYaml(result.data.yaml) + setEditDialogOpen(true) + } catch (e: any) { + setSnackbar({ open: true, message: `获取资源失败: ${e.message}`, severity: 'error' }) + } + } + + const handleApplyEdit = async () => { + if (!editResource) return + + setEditing(true) + try { + const res = await fetch('/api/v1/k8s/resource/update', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ yaml: editYaml }), + }) + + const result = await res.json() + + if (!res.ok) { + throw new Error(result.err || 'Failed to update resource') + } + + setSnackbar({ open: true, message: '资源更新成功', severity: 'success' }) + setEditDialogOpen(false) + setEditResource(null) + setEditYaml('') + fetchResources() + } catch (e: any) { + setSnackbar({ open: true, message: `更新失败: ${e.message}`, severity: 'error' }) + } finally { + setEditing(false) + } + } + const openDeleteDialog = (podName: string, podNamespace: string) => { setDeleteTarget({ name: podName, namespace: podNamespace }) setDeleteDialogOpen(true) @@ -264,9 +318,9 @@ export default function K8sResourceList() { case 'statefulset': return ['Name', 'Namespace', 'Replicas', 'Age'] case 'service': - return ['Name', 'Namespace', 'Type', 'Cluster IP', 'External IP', 'Ports', 'Age'] + return ['Name', 'Namespace', 'Type', 'Cluster IP', 'External IP', 'Ports', 'Age', 'Actions'] case 'configmap': - return ['Name', 'Namespace', 'Data Keys', 'Age'] + return ['Name', 'Namespace', 'Data Keys', 'Age', 'Actions'] case 'pod': return ['Name', 'Namespace', 'Status', 'IP', 'Node', 'Age', 'Actions'] case 'pv': @@ -537,6 +591,46 @@ export default function K8sResourceList() { + setEditDialogOpen(false)} + maxWidth="md" + fullWidth + > + + + + 编辑 {editResource?.kind}: {editResource?.name} ({editResource?.namespace}) + + setEditDialogOpen(false)}> + + + + + + setEditYaml(e.target.value)} + placeholder="YAML 内容..." + fullWidth + variant="outlined" + sx={{ fontFamily: 'monospace', fontSize: '0.875rem' }} + /> + + + + + + + setCreateDialogOpen(false)} diff --git a/internal/api/api.go b/internal/api/api.go index 4b89698..82cb0e0 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -67,6 +67,8 @@ func Init(ctx context.Context, address string, db *gorm.DB, store store.Store) ( k8sAPI.Post("/config", k8s.ClusterConfigSet(ctx, db, store)) // resource operations k8sAPI.Post("/resource/apply", k8s.K8sResourceApply(ctx, db, store)) + k8sAPI.Get("/resource/get", k8s.K8sResourceFetch(ctx, db, store)) + k8sAPI.Post("/resource/update", k8s.K8sResourceUpdate(ctx, db, store)) // resource list k8sAPI.Get("/namespace/list", k8s.K8sNamespaceList(ctx, db, store)) k8sAPI.Get("/deployment/list", k8s.K8sDeploymentList(ctx, db, store)) diff --git a/internal/module/k8s/handler.resource.go b/internal/module/k8s/handler.resource.go index 3dfecbf..c66fb28 100644 --- a/internal/module/k8s/handler.resource.go +++ b/internal/module/k8s/handler.resource.go @@ -308,6 +308,112 @@ func K8sServiceList(ctx context.Context, db *gorm.DB, store store.Store) fiber.H } } +func K8sResourceGet(ctx context.Context, db *gorm.DB, store store.Store) fiber.Handler { + return func(c fiber.Ctx) error { + name := c.Query("name", "") + namespace := c.Query("namespace", "") + kind := c.Query("kind", "") + + if name == "" || kind == "" { + return resp.R400(c, "", nil, fmt.Errorf("name and kind are required")) + } + + clientset, err := getK8sClient(db) + if err != nil { + return resp.R500(c, "", nil, err) + } + + var yamlData []byte + + switch kind { + case "Deployment": + var deployment *appsv1.Deployment + if namespace != "" { + deployment, err = clientset.AppsV1().Deployments(namespace).Get(c.Context(), name, metav1.GetOptions{}) + } else { + return resp.R400(c, "", nil, fmt.Errorf("namespace is required for Deployment")) + } + if err != nil { + return resp.R500(c, "", nil, fmt.Errorf("failed to get deployment: %w", err)) + } + yamlData, err = yaml.Marshal(deployment) + case "StatefulSet": + var statefulset *appsv1.StatefulSet + if namespace != "" { + statefulset, err = clientset.AppsV1().StatefulSets(namespace).Get(c.Context(), name, metav1.GetOptions{}) + } else { + return resp.R400(c, "", nil, fmt.Errorf("namespace is required for StatefulSet")) + } + if err != nil { + return resp.R500(c, "", nil, fmt.Errorf("failed to get statefulset: %w", err)) + } + yamlData, err = yaml.Marshal(statefulset) + case "Service": + var service *corev1.Service + if namespace != "" { + service, err = clientset.CoreV1().Services(namespace).Get(c.Context(), name, metav1.GetOptions{}) + } else { + return resp.R400(c, "", nil, fmt.Errorf("namespace is required for Service")) + } + if err != nil { + return resp.R500(c, "", nil, fmt.Errorf("failed to get service: %w", err)) + } + yamlData, err = yaml.Marshal(service) + case "ConfigMap": + var configmap *corev1.ConfigMap + if namespace != "" { + configmap, err = clientset.CoreV1().ConfigMaps(namespace).Get(c.Context(), name, metav1.GetOptions{}) + } else { + return resp.R400(c, "", nil, fmt.Errorf("namespace is required for ConfigMap")) + } + if err != nil { + return resp.R500(c, "", nil, fmt.Errorf("failed to get configmap: %w", err)) + } + yamlData, err = yaml.Marshal(configmap) + default: + return resp.R400(c, "", nil, fmt.Errorf("unsupported resource kind: %s", kind)) + } + + if err != nil { + return resp.R500(c, "", nil, fmt.Errorf("failed to marshal resource to yaml: %w", err)) + } + + return resp.R200(c, map[string]any{ + "yaml": string(yamlData), + }) + } +} + +func getResourceName(kind string) string { + kindToResource := map[string]string{ + "Namespace": "namespaces", + "Deployment": "deployments", + "StatefulSet": "statefulsets", + "Service": "services", + "ConfigMap": "configmaps", + "Pod": "pods", + "PersistentVolume": "persistentvolumes", + "PersistentVolumeClaim": "persistentvolumeclaims", + "Secret": "secrets", + "Ingress": "ingresses", + "DaemonSet": "daemonsets", + "Job": "jobs", + "CronJob": "cronjobs", + "ReplicaSet": "replicasets", + "ServiceAccount": "serviceaccounts", + "Role": "roles", + "RoleBinding": "rolebindings", + "ClusterRole": "clusterroles", + "ClusterRoleBinding": "clusterrolebindings", + } + + if resource, ok := kindToResource[kind]; ok { + return resource + } + + return kind + "s" +} + func K8sResourceApply(ctx context.Context, db *gorm.DB, store store.Store) fiber.Handler { return func(c fiber.Ctx) error { var req struct { @@ -336,6 +442,10 @@ func K8sResourceApply(ctx context.Context, db *gorm.DB, store store.Store) fiber return resp.R500(c, "", nil, fmt.Errorf("failed to parse kubeconfig: %w", err)) } + // Force HTTP/1.1 to avoid stream closing issues + clientConfig.TLSClientConfig.NextProtos = []string{"http/1.1"} + clientConfig.Timeout = 0 + dynamicClient, err := dynamic.NewForConfig(clientConfig) if err != nil { return resp.R500(c, "", nil, fmt.Errorf("failed to create dynamic client: %w", err)) @@ -379,34 +489,156 @@ func K8sResourceApply(ctx context.Context, db *gorm.DB, store store.Store) fiber } } -func getResourceName(kind string) string { - kindToResource := map[string]string{ - "Namespace": "namespaces", - "Deployment": "deployments", - "StatefulSet": "statefulsets", - "Service": "services", - "ConfigMap": "configmaps", - "Pod": "pods", - "PersistentVolume": "persistentvolumes", - "PersistentVolumeClaim": "persistentvolumeclaims", - "Secret": "secrets", - "Ingress": "ingresses", - "DaemonSet": "daemonsets", - "Job": "jobs", - "CronJob": "cronjobs", - "ReplicaSet": "replicasets", - "ServiceAccount": "serviceaccounts", - "Role": "roles", - "RoleBinding": "rolebindings", - "ClusterRole": "clusterroles", - "ClusterRoleBinding": "clusterrolebindings", - } +func K8sResourceFetch(ctx context.Context, db *gorm.DB, store store.Store) fiber.Handler { + return func(c fiber.Ctx) error { + name := c.Query("name", "") + namespace := c.Query("namespace", "") + kind := c.Query("kind", "") - if resource, ok := kindToResource[kind]; ok { - return resource - } + if name == "" || kind == "" { + return resp.R400(c, "", nil, fmt.Errorf("name and kind are required")) + } - return kind + "s" + clientset, err := getK8sClient(db) + if err != nil { + return resp.R500(c, "", nil, err) + } + + var yamlData []byte + + switch kind { + case "Deployment": + var deployment *appsv1.Deployment + if namespace != "" { + deployment, err = clientset.AppsV1().Deployments(namespace).Get(c.Context(), name, metav1.GetOptions{}) + } else { + return resp.R400(c, "", nil, fmt.Errorf("namespace is required for Deployment")) + } + if err != nil { + return resp.R500(c, "", nil, fmt.Errorf("failed to get deployment: %w", err)) + } + yamlData, err = yaml.Marshal(deployment) + case "StatefulSet": + var statefulset *appsv1.StatefulSet + if namespace != "" { + statefulset, err = clientset.AppsV1().StatefulSets(namespace).Get(c.Context(), name, metav1.GetOptions{}) + } else { + return resp.R400(c, "", nil, fmt.Errorf("namespace is required for StatefulSet")) + } + if err != nil { + return resp.R500(c, "", nil, fmt.Errorf("failed to get statefulset: %w", err)) + } + yamlData, err = yaml.Marshal(statefulset) + case "Service": + var service *corev1.Service + if namespace != "" { + service, err = clientset.CoreV1().Services(namespace).Get(c.Context(), name, metav1.GetOptions{}) + } else { + return resp.R400(c, "", nil, fmt.Errorf("namespace is required for Service")) + } + if err != nil { + return resp.R500(c, "", nil, fmt.Errorf("failed to get service: %w", err)) + } + yamlData, err = yaml.Marshal(service) + case "ConfigMap": + var configmap *corev1.ConfigMap + if namespace != "" { + configmap, err = clientset.CoreV1().ConfigMaps(namespace).Get(c.Context(), name, metav1.GetOptions{}) + } else { + return resp.R400(c, "", nil, fmt.Errorf("namespace is required for ConfigMap")) + } + if err != nil { + return resp.R500(c, "", nil, fmt.Errorf("failed to get configmap: %w", err)) + } + yamlData, err = yaml.Marshal(configmap) + default: + return resp.R400(c, "", nil, fmt.Errorf("unsupported resource kind: %s", kind)) + } + + if err != nil { + return resp.R500(c, "", nil, fmt.Errorf("failed to marshal resource to yaml: %w", err)) + } + + return resp.R200(c, map[string]any{ + "yaml": string(yamlData), + }) + } +} + +func K8sResourceUpdate(ctx context.Context, db *gorm.DB, store store.Store) fiber.Handler { + return func(c fiber.Ctx) error { + var req struct { + Yaml string `json:"yaml"` + } + + if err := json.Unmarshal(c.Body(), &req); err != nil { + return resp.R400(c, "", nil, err) + } + + if req.Yaml == "" { + return resp.R400(c, "", nil, fmt.Errorf("yaml content is empty")) + } + + var config model.ClusterConfig + if err := db.Where("key = ?", "kubeconfig").First(&config).Error; err != nil { + return resp.R500(c, "", nil, fmt.Errorf("kubeconfig not found: %w", err)) + } + + if config.Value == "" { + return resp.R500(c, "", nil, fmt.Errorf("kubeconfig is empty")) + } + + clientConfig, err := clientcmd.RESTConfigFromKubeConfig([]byte(config.Value)) + if err != nil { + return resp.R500(c, "", nil, fmt.Errorf("failed to parse kubeconfig: %w", err)) + } + + // Force HTTP/1.1 to avoid stream closing issues + clientConfig.TLSClientConfig.NextProtos = []string{"http/1.1"} + clientConfig.Timeout = 0 + + dynamicClient, err := dynamic.NewForConfig(clientConfig) + if err != nil { + return resp.R500(c, "", nil, fmt.Errorf("failed to create dynamic client: %w", err)) + } + + var obj unstructured.Unstructured + if err := yaml.Unmarshal([]byte(req.Yaml), &obj); err != nil { + return resp.R400(c, "", nil, fmt.Errorf("failed to parse yaml: %w", err)) + } + + gvk := obj.GroupVersionKind() + namespace := obj.GetNamespace() + name := obj.GetName() + + if name == "" { + return resp.R400(c, "", nil, fmt.Errorf("resource name is required")) + } + + gvr := schema.GroupVersionResource{ + Group: gvk.Group, + Version: gvk.Version, + Resource: getResourceName(gvk.Kind), + } + + // Update the resource + var result *unstructured.Unstructured + if namespace != "" { + result, err = dynamicClient.Resource(gvr).Namespace(namespace).Update(c.Context(), &obj, metav1.UpdateOptions{}) + } else { + result, err = dynamicClient.Resource(gvr).Update(c.Context(), &obj, metav1.UpdateOptions{}) + } + + if err != nil { + return resp.R500(c, "", nil, fmt.Errorf("failed to update resource: %w", err)) + } + + return resp.R200(c, map[string]any{ + "name": result.GetName(), + "namespace": result.GetNamespace(), + "kind": result.GetKind(), + }) + } } func K8sPodLogs(ctx context.Context, db *gorm.DB, store store.Store) fiber.Handler {