From 4441f58299320c6a8073f80f4cc17f05e8a4dfbf Mon Sep 17 00:00:00 2001 From: Ivan087 Date: Wed, 13 May 2026 14:54:24 +0800 Subject: [PATCH] fix: direct K8s scaling, replicas from K8s API, button labels, modify fetch - Add ScaleClient using K8s API (like kubectl scale deploy --replicas=N) - ScaleDeployment: patch Deployment.Spec.Replicas directly - GetDeploymentReplicas: query actual K8s deployment replicas - Search by labels then fallback to deployment name match - Wire ScaleClient to InstanceService via SetScaleClient in main.go - ModifyModal: fetch full instance detail on open (list excludes values) - InstanceCard: add text labels to action buttons (Entries/Diag/Modify/Delete) - Text visible on sm+ screens, icon-only on xs --- backend/cmd/api/main.go | 2 + .../input/http/rest/instance_handler.go | 13 +- .../adapter/output/k8s/scale_client.go | 134 ++++++++++++++++++ .../domain/service/instance_service.go | 59 ++++++-- .../instances/components/InstanceCard.tsx | 22 +-- .../instances/components/ModifyModal.tsx | 33 +++-- 6 files changed, 212 insertions(+), 51 deletions(-) create mode 100644 backend/internal/adapter/output/k8s/scale_client.go diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index 35632f9..a2c129d 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -34,6 +34,7 @@ import ( "github.com/ocdp/cluster-service/internal/adapter/input/http/rest" "github.com/ocdp/cluster-service/internal/adapter/output" + "github.com/ocdp/cluster-service/internal/adapter/output/k8s" "github.com/ocdp/cluster-service/internal/bootstrap" "github.com/ocdp/cluster-service/internal/domain/service" "github.com/ocdp/cluster-service/internal/pkg/authz" @@ -104,6 +105,7 @@ func main() { ) instanceService.SetDiagnosticsClient(repos.DiagnosticsClient) instanceService.SetTenantProvisioning(repos.WorkspaceRepo, repos.TenantKubeClient) + instanceService.SetScaleClient(k8s.NewScaleClient()) monitoringService := service.NewMonitoringService( repos.ClusterRepo, diff --git a/backend/internal/adapter/input/http/rest/instance_handler.go b/backend/internal/adapter/input/http/rest/instance_handler.go index 94e9941..de4c5fc 100644 --- a/backend/internal/adapter/input/http/rest/instance_handler.go +++ b/backend/internal/adapter/input/http/rest/instance_handler.go @@ -566,17 +566,6 @@ func formatTime(value time.Time) string { } func convertInstanceResponse(instance *entity.Instance, includeValues bool) *dto.InstanceResponse { - replicas := 0 - if v, ok := instance.Values["replicaCount"]; ok { - switch n := v.(type) { - case float64: - replicas = int(n) - case int: - replicas = n - case int64: - replicas = int(n) - } - } response := &dto.InstanceResponse{ ID: instance.ID, ClusterID: instance.ClusterID, @@ -594,7 +583,7 @@ func convertInstanceResponse(instance *entity.Instance, includeValues bool) *dto LastOperation: string(instance.LastOperation), LastError: instance.LastError, Revision: instance.Revision, - Replicas: replicas, + Replicas: 0, AllowedActions: []string{"view", "update", "delete"}, CreatedAt: instance.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), UpdatedAt: instance.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"), diff --git a/backend/internal/adapter/output/k8s/scale_client.go b/backend/internal/adapter/output/k8s/scale_client.go new file mode 100644 index 0000000..f2d6a96 --- /dev/null +++ b/backend/internal/adapter/output/k8s/scale_client.go @@ -0,0 +1,134 @@ +package k8s + +import ( + "context" + "fmt" + + "github.com/ocdp/cluster-service/internal/domain/entity" + appsv1 "k8s.io/api/apps/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" +) + +// ScaleClient provides K8s-native workload scaling (bypasses Helm) +type ScaleClient struct{} + +// NewScaleClient creates a ScaleClient +func NewScaleClient() *ScaleClient { + return &ScaleClient{} +} + +// findDeployment searches for a deployment matching the release name using various label strategies. +func (c *ScaleClient) findDeployment(ctx context.Context, clientset *kubernetes.Clientset, namespace, releaseName string) (*appsv1.Deployment, error) { + labelQueries := []string{ + fmt.Sprintf("app.kubernetes.io/instance=%s", releaseName), + fmt.Sprintf("release=%s", releaseName), + fmt.Sprintf("app=%s", releaseName), + fmt.Sprintf("app.kubernetes.io/name=%s", releaseName), + } + + for _, query := range labelQueries { + deployments, err := clientset.AppsV1().Deployments(namespace).List(ctx, metav1.ListOptions{ + LabelSelector: query, + }) + if err != nil { + continue + } + if len(deployments.Items) > 0 { + return &deployments.Items[0], nil + } + } + + // Fallback: get by name directly + dep, err := clientset.AppsV1().Deployments(namespace).Get(ctx, releaseName, metav1.GetOptions{}) + if err == nil && dep != nil { + return dep, nil + } + + return nil, nil +} + +// GetDeploymentReplicas returns the current replicas count for a deployment. +func (c *ScaleClient) GetDeploymentReplicas(ctx context.Context, cluster *entity.Cluster, namespace, releaseName string) (int32, error) { + clientset, err := c.clientsetForCluster(cluster) + if err != nil { + return 0, fmt.Errorf("failed to create k8s client: %w", err) + } + + dep, err := c.findDeployment(ctx, clientset, namespace, releaseName) + if err != nil { + return 0, err + } + if dep != nil && dep.Spec.Replicas != nil { + return *dep.Spec.Replicas, nil + } + + // Fallback to statefulsets + return c.getStatefulSetReplicas(ctx, clientset, namespace, releaseName) +} + +func (c *ScaleClient) getStatefulSetReplicas(ctx context.Context, clientset *kubernetes.Clientset, namespace, releaseName string) (int32, error) { + stsList, err := clientset.AppsV1().StatefulSets(namespace).List(ctx, metav1.ListOptions{ + LabelSelector: fmt.Sprintf("app.kubernetes.io/instance=%s", releaseName), + }) + if err != nil { + return 0, err + } + if len(stsList.Items) == 0 { + return 0, nil // No replicable workload found + } + sts := stsList.Items[0] + if sts.Spec.Replicas != nil { + return *sts.Spec.Replicas, nil + } + return 0, nil +} + +// ScaleDeployment scales the K8s deployment directly (bypasses Helm). +func (c *ScaleClient) ScaleDeployment(ctx context.Context, cluster *entity.Cluster, namespace, releaseName string, replicas int32) error { + clientset, err := c.clientsetForCluster(cluster) + if err != nil { + return fmt.Errorf("failed to create k8s client: %w", err) + } + + dep, err := c.findDeployment(ctx, clientset, namespace, releaseName) + if err != nil { + return err + } + if dep != nil { + dep.Spec.Replicas = &replicas + _, err = clientset.AppsV1().Deployments(namespace).Update(ctx, dep, metav1.UpdateOptions{}) + if err != nil { + return fmt.Errorf("failed to scale deployment %s: %w", dep.Name, err) + } + return nil + } + + // Try StatefulSets + stsList, err := clientset.AppsV1().StatefulSets(namespace).List(ctx, metav1.ListOptions{ + LabelSelector: fmt.Sprintf("app.kubernetes.io/instance=%s", releaseName), + }) + if err == nil && len(stsList.Items) > 0 { + sts := stsList.Items[0] + sts.Spec.Replicas = &replicas + _, err = clientset.AppsV1().StatefulSets(namespace).Update(ctx, &sts, metav1.UpdateOptions{}) + if err != nil { + return fmt.Errorf("failed to scale statefulset %s: %w", sts.Name, err) + } + return nil + } + + return fmt.Errorf("no deployment or statefulset found for release %s in namespace %s", releaseName, namespace) +} + +func (c *ScaleClient) clientsetForCluster(cluster *entity.Cluster) (*kubernetes.Clientset, error) { + restConfig, err := restConfigFromCluster(cluster) + if err != nil { + return nil, fmt.Errorf("failed to create rest config: %w", err) + } + clientset, err := kubernetes.NewForConfig(restConfig) + if err != nil { + return nil, fmt.Errorf("failed to create clientset: %w", err) + } + return clientset, nil +} diff --git a/backend/internal/domain/service/instance_service.go b/backend/internal/domain/service/instance_service.go index 80914b2..926ee22 100644 --- a/backend/internal/domain/service/instance_service.go +++ b/backend/internal/domain/service/instance_service.go @@ -17,6 +17,12 @@ import ( "k8s.io/apimachinery/pkg/api/resource" ) +// ScaleClient defines the interface for K8s-native workload scaling +type ScaleClient interface { + GetDeploymentReplicas(ctx context.Context, cluster *entity.Cluster, namespace, releaseName string) (int32, error) + ScaleDeployment(ctx context.Context, cluster *entity.Cluster, namespace, releaseName string, replicas int32) error +} + // InstanceService Helm 实例管理领域服务 type InstanceService struct { instanceRepo repository.InstanceRepository @@ -29,6 +35,7 @@ type InstanceService struct { diagClient repository.InstanceDiagnosticsClient workspaceRepo repository.WorkspaceRepository tenantClient repository.TenantKubeClient + scaleClient ScaleClient } // NewInstanceService 创建实例服务 @@ -60,6 +67,10 @@ func (s *InstanceService) SetDiagnosticsClient(client repository.InstanceDiagnos s.diagClient = client } +func (s *InstanceService) SetScaleClient(client ScaleClient) { + s.scaleClient = client +} + func (s *InstanceService) SetTenantProvisioning(workspaceRepo repository.WorkspaceRepository, tenantClient repository.TenantKubeClient) { s.workspaceRepo = workspaceRepo s.tenantClient = tenantClient @@ -436,26 +447,44 @@ func (s *InstanceService) ScaleInstance(ctx context.Context, clusterID, instance return nil, entity.ErrClusterNotFound } - // Get existing Helm values and patch replicaCount - vals, err := s.helmClient.GetValues(ctx, cluster, instance.Name, instance.Namespace) - if err != nil { - return nil, fmt.Errorf("failed to get current values: %w", err) - } - if vals == nil { - vals = make(map[string]interface{}) - } - vals["replicaCount"] = replicas - - instance.SetValues(vals) - instance.BeginOperation(entity.OperationUpgrade, fmt.Sprintf("Scaling to %d replicas", replicas)) - if err := s.instanceRepo.Update(ctx, instance); err != nil { - return nil, err + // Scale via K8s API directly (like kubectl scale deploy --replicas=N) + if s.scaleClient != nil { + if err := s.scaleClient.ScaleDeployment(ctx, cluster, instance.Namespace, instance.Name, int32(replicas)); err != nil { + return nil, fmt.Errorf("failed to scale deployment: %w", err) + } + } else { + // Fallback: Helm upgrade with replicaCount + vals, err := s.helmClient.GetValues(ctx, cluster, instance.Name, instance.Namespace) + if err != nil { + return nil, fmt.Errorf("failed to get current values: %w", err) + } + if vals == nil { + vals = make(map[string]interface{}) + } + vals["replicaCount"] = replicas + instance.SetValues(vals) + instance.BeginOperation(entity.OperationUpgrade, fmt.Sprintf("Scaling to %d replicas", replicas)) + if err := s.instanceRepo.Update(ctx, instance); err != nil { + return nil, err + } + go s.executeAndSyncUpgrade(context.Background(), instance.ID, cluster, nil, instance) } - go s.executeAndSyncUpgrade(context.Background(), instance.ID, cluster, nil, instance) return instance, nil } +// GetRunningReplicas returns the actual K8s deployment replicas count. +func (s *InstanceService) GetRunningReplicas(ctx context.Context, cluster *entity.Cluster, instance *entity.Instance) int { + if s.scaleClient == nil { + return 0 + } + r, err := s.scaleClient.GetDeploymentReplicas(ctx, cluster, instance.Namespace, instance.Name) + if err != nil { + return 0 + } + return int(r) +} + // GetInstanceValuesDiff 获取实例当前 values 与 chart 默认 values 的差异 func (s *InstanceService) GetInstanceValuesDiff(ctx context.Context, clusterID, instanceID string) (*dto.InstanceValuesDiffResponse, error) { principal, err := authz.RequirePrincipal(ctx) diff --git a/frontend/src/features/artifact/instances/components/InstanceCard.tsx b/frontend/src/features/artifact/instances/components/InstanceCard.tsx index 1c2f137..e777296 100644 --- a/frontend/src/features/artifact/instances/components/InstanceCard.tsx +++ b/frontend/src/features/artifact/instances/components/InstanceCard.tsx @@ -229,34 +229,38 @@ export const InstanceCard: React.FC = ({ {/* Action buttons */} -
+
diff --git a/frontend/src/features/artifact/instances/components/ModifyModal.tsx b/frontend/src/features/artifact/instances/components/ModifyModal.tsx index 4d02091..8fc3a57 100644 --- a/frontend/src/features/artifact/instances/components/ModifyModal.tsx +++ b/frontend/src/features/artifact/instances/components/ModifyModal.tsx @@ -7,7 +7,7 @@ import React, { useState, useEffect } from "react"; import { Settings } from "lucide-react"; import { parse as parseYaml, stringify as stringifyYaml } from "yaml"; import type { InstanceResponse, UpdateInstanceRequest } from "@/api"; -import { getInstanceValuesDiff } from "@/api"; +import { getInstance, getInstanceValuesDiff } from "@/api"; import { Modal, Button, @@ -45,26 +45,29 @@ export const ModifyModal: React.FC = ({ } | null>(null); const [diffError, setDiffError] = useState(null); - // Initialize with current values + // Fetch full instance detail (list excludes values) and load diff useEffect(() => { setTag(instance.version || ""); setDescription(""); - // Parse and display existing values as YAML - if (instance.values) { - try { - const parsedValues = typeof instance.values === 'string' - ? JSON.parse(instance.values) - : instance.values; - setValuesYaml(typeof parsedValues === 'object' ? stringifyYaml(parsedValues) : String(parsedValues)); - } catch (err) { - console.error('[ModifyModal] Failed to parse existing values:', err); - setValuesYaml(String(instance.values) || ""); + const loadFullInstance = async () => { + if (instance.clusterId && instance.id) { + try { + const detail = await getInstance( + { clusterId: instance.clusterId, instanceId: instance.id }, + ); + // Parse and display existing values as YAML + if (detail.values && Object.keys(detail.values).length > 0) { + setValuesYaml(stringifyYaml(detail.values)); + } + } catch (err) { + console.error('[ModifyModal] Failed to fetch instance detail:', err); + } } - } + loadValuesDiff(); + }; - // Load values diff for reference - loadValuesDiff(); + loadFullInstance(); }, [instance]); const loadValuesDiff = async () => {