feat: add resource edit functionality for k8s resources

- Add edit button for Deployment, StatefulSet, Service, and ConfigMap resources
- Implement resource fetch API to get YAML representation
- Implement resource update API to apply edited YAML
- Add edit dialog with YAML editor and apply/cancel buttons
- Add tooltip icons for better UX
- Restore K8sResourceApply function with HTTP/1.1 enforcement
- Support for fetching and updating the following resource kinds:
  - Deployment
  - StatefulSet
  - Service
  - ConfigMap

🤖 Generated with [Qoder][https://qoder.com]
This commit is contained in:
loveuer
2025-11-13 09:25:41 +08:00
parent 7d2e2ab842
commit 529a90b80d
3 changed files with 356 additions and 28 deletions

View File

@@ -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<HTMLDivElement>(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() {
</Box>
</Drawer>
<Dialog
open={editDialogOpen}
onClose={() => setEditDialogOpen(false)}
maxWidth="md"
fullWidth
>
<DialogTitle>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="h6">
{editResource?.kind}: {editResource?.name} ({editResource?.namespace})
</Typography>
<IconButton onClick={() => setEditDialogOpen(false)}>
<CloseIcon />
</IconButton>
</Box>
</DialogTitle>
<DialogContent>
<TextField
multiline
rows={20}
value={editYaml}
onChange={(e) => setEditYaml(e.target.value)}
placeholder="YAML 内容..."
fullWidth
variant="outlined"
sx={{ fontFamily: 'monospace', fontSize: '0.875rem' }}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setEditDialogOpen(false)}></Button>
<Button
variant="contained"
onClick={handleApplyEdit}
disabled={editing}
>
{editing ? <CircularProgress size={24} /> : '应用'}
</Button>
</DialogActions>
</Dialog>
<Dialog
open={createDialogOpen}
onClose={() => setCreateDialogOpen(false)}

View File

@@ -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))

View File

@@ -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 {