diff --git a/src/main/java/com/github/wellch4n/oops/data/ApplicationPerformanceConfig.java b/src/main/java/com/github/wellch4n/oops/data/ApplicationPerformanceConfig.java index c3e510f0..830bd866 100644 --- a/src/main/java/com/github/wellch4n/oops/data/ApplicationPerformanceConfig.java +++ b/src/main/java/com/github/wellch4n/oops/data/ApplicationPerformanceConfig.java @@ -37,6 +37,17 @@ public static class EnvironmentConfig { private String memoryLimit; private Integer replicas; + + private AutoscalingConfig autoscaling; + } + + @Data + public static class AutoscalingConfig { + private Boolean enabled; + private Integer minReplicas; + private Integer maxReplicas; + private Integer targetCpuUtilization; + private Integer targetMemoryUtilization; } @Converter diff --git a/src/main/java/com/github/wellch4n/oops/task/ArtifactDeployTask.java b/src/main/java/com/github/wellch4n/oops/task/ArtifactDeployTask.java index 00e23508..36abb27c 100644 --- a/src/main/java/com/github/wellch4n/oops/task/ArtifactDeployTask.java +++ b/src/main/java/com/github/wellch4n/oops/task/ArtifactDeployTask.java @@ -8,10 +8,13 @@ import io.fabric8.kubernetes.api.model.*; import io.fabric8.kubernetes.api.model.apps.StatefulSet; import io.fabric8.kubernetes.api.model.apps.StatefulSetBuilder; +import io.fabric8.kubernetes.api.model.autoscaling.v2.HorizontalPodAutoscaler; +import io.fabric8.kubernetes.api.model.autoscaling.v2.HorizontalPodAutoscalerBuilder; import io.fabric8.kubernetes.client.KubernetesClient; import io.fabric8.kubernetes.client.dsl.base.CustomResourceDefinitionContext; import io.fabric8.kubernetes.client.dsl.base.PatchContext; import io.fabric8.kubernetes.client.dsl.base.PatchType; +import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.concurrent.Callable; @@ -70,10 +73,9 @@ public Boolean call() { checkNamespace(); checkImagePullSecret(); - StatefulSet statefulSet = new StatefulSetBuilder() + StatefulSetBuilder ssBuilder = new StatefulSetBuilder() .withNewMetadata().withName(applicationName).withLabels(labels).endMetadata() .withNewSpec() - .withReplicas(perfEnvConfig.getReplicas() == null ? 0 : perfEnvConfig.getReplicas()) .withServiceName(applicationName) .withNewSelector().withMatchLabels(labels).endSelector() .withNewTemplate() @@ -94,17 +96,103 @@ public Boolean call() { .addNewImagePullSecret("dockerhub") .endSpec() .endTemplate() - .endSpec() - .build(); + .endSpec(); + if (!isAutoscalingEnabled()) { + ssBuilder.editSpec() + .withReplicas(perfEnvConfig.getReplicas() == null ? 0 : perfEnvConfig.getReplicas()) + .endSpec(); + } + + StatefulSet statefulSet = ssBuilder.build(); client.apps().statefulSets().inNamespace(namespace).resource(statefulSet).patch(patchContext); + checkHpa(); checkService(); checkIngressRoute(); return true; } + private boolean isAutoscalingEnabled() { + var cfg = perfEnvConfig.getAutoscaling(); + return cfg != null && Boolean.TRUE.equals(cfg.getEnabled()); + } + + private void checkHpa() { + String namespace = application.getNamespace(); + String applicationName = application.getName(); + + var hpaClient = client.autoscaling().v2() + .horizontalPodAutoscalers() + .inNamespace(namespace); + + if (!isAutoscalingEnabled()) { + try { + hpaClient.withName(applicationName).delete(); + } catch (Exception e) { + log.warn("Failed to delete HPA for {}/{}: {}", namespace, applicationName, e.getMessage()); + } + return; + } + + var cfg = perfEnvConfig.getAutoscaling(); + int minReplicas = cfg.getMinReplicas() == null ? 1 : cfg.getMinReplicas(); + int maxReplicas = cfg.getMaxReplicas() == null ? Math.max(minReplicas, 10) : cfg.getMaxReplicas(); + if (maxReplicas < minReplicas) { + maxReplicas = minReplicas; + } + + List metrics = new ArrayList<>(); + if (cfg.getTargetCpuUtilization() != null) { + metrics.add(buildResourceMetric("cpu", cfg.getTargetCpuUtilization())); + } + if (cfg.getTargetMemoryUtilization() != null) { + metrics.add(buildResourceMetric("memory", cfg.getTargetMemoryUtilization())); + } + if (metrics.isEmpty()) { + metrics.add(buildResourceMetric("cpu", 70)); + } + + HorizontalPodAutoscaler hpa = new HorizontalPodAutoscalerBuilder() + .withNewMetadata() + .withName(applicationName) + .withNamespace(namespace) + .withLabels(labels) + .endMetadata() + .withNewSpec() + .withMinReplicas(minReplicas) + .withMaxReplicas(maxReplicas) + .withNewScaleTargetRef() + .withApiVersion("apps/v1") + .withKind("StatefulSet") + .withName(applicationName) + .endScaleTargetRef() + .withMetrics(metrics) + .endSpec() + .build(); + + try { + hpaClient.resource(hpa).patch(patchContext); + } catch (Exception e) { + log.error("Failed to apply HPA for {}/{}:", namespace, applicationName, e); + throw e; + } + } + + private io.fabric8.kubernetes.api.model.autoscaling.v2.MetricSpec buildResourceMetric(String name, int utilization) { + return new io.fabric8.kubernetes.api.model.autoscaling.v2.MetricSpecBuilder() + .withType("Resource") + .withNewResource() + .withName(name) + .withNewTarget() + .withType("Utilization") + .withAverageUtilization(utilization) + .endTarget() + .endResource() + .build(); + } + private void checkNamespace() { client.namespaces() .resource(new NamespaceBuilder().withNewMetadata().withName(application.getNamespace()).endMetadata().build()) diff --git a/web/app/apps/components/application-performance-info.tsx b/web/app/apps/components/application-performance-info.tsx index 055c32b9..571ea66b 100644 --- a/web/app/apps/components/application-performance-info.tsx +++ b/web/app/apps/components/application-performance-info.tsx @@ -11,13 +11,14 @@ import { } from "@/components/ui/form" import { Input } from "@/components/ui/input" import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" import { useForm, useFieldArray, useFormContext } from "react-hook-form" import { zodResolver } from "@hookform/resolvers/zod" import { ApplicationPerformanceEnvFormValues, applicationPerformanceEnvSchema } from "../schema" import { TabsContent } from "@/components/ui/tabs" import { ApplicationPerformanceConfigEnvironmentConfig, ApplicationEnvironment } from "@/lib/api/types" import { updateApplicationPerformanceEnvConfigs } from "@/lib/api/applications" -import { Cpu, MemoryStick, Copy } from "lucide-react" +import { Cpu, MemoryStick, Copy, Gauge } from "lucide-react" import { toast } from "sonner" import { ApplicationEnvironmentSelector } from "./application-environment-selector" import { Skeleton } from "@/components/ui/skeleton" @@ -197,24 +198,27 @@ function ReplicasInput({ value, onChange }: { value: number | undefined, onChang } function SingleEnvironmentConfig({ index }: SingleEnvironmentConfigProps) { - const { control } = useFormContext() + const { control, watch } = useFormContext() const { t } = useLanguage() + const autoscalingEnabled = watch(`environmentConfigs.${index}.autoscaling.enabled`) === true return (
- ( - - {t("apps.perf.replicas")} - - - - - - )} - /> + {!autoscalingEnabled && ( + ( + + {t("apps.perf.replicas")} + + + + + + )} + /> + )}
+
+ ( + + + field.onChange(v === true)} /> + + + {t("apps.perf.autoscaling")} + + + )} + /> + {autoscalingEnabled && ( + <> +

{t("apps.perf.autoscalingHint")}

+
+ ( + + {t("apps.perf.minReplicas")} + + field.onChange(e.target.value === "" ? undefined : parseInt(e.target.value, 10))} + /> + + + + )} + /> + ( + + {t("apps.perf.maxReplicas")} + + field.onChange(e.target.value === "" ? undefined : parseInt(e.target.value, 10))} + /> + + + + )} + /> + ( + + {t("apps.perf.targetCpu")} + +
+ field.onChange(e.target.value === "" ? undefined : parseInt(e.target.value, 10))} + /> + % +
+
+ +
+ )} + /> + ( + + {t("apps.perf.targetMemory")} + +
+ field.onChange(e.target.value === "" ? undefined : parseInt(e.target.value, 10))} + /> + % +
+
+ +
+ )} + /> +
+ + )} +
) } diff --git a/web/app/apps/schema.ts b/web/app/apps/schema.ts index 038669cb..cb1d7ece 100644 --- a/web/app/apps/schema.ts +++ b/web/app/apps/schema.ts @@ -49,6 +49,13 @@ export const applicationPerformanceEnvSchema = z.object({ cpuLimit: z.string().optional(), memoryRequest: z.string().optional(), memoryLimit: z.string().optional(), + autoscaling: z.object({ + enabled: z.boolean().optional(), + minReplicas: z.number().int().min(1).optional(), + maxReplicas: z.number().int().min(1).optional(), + targetCpuUtilization: z.number().int().min(1).max(100).optional(), + targetMemoryUtilization: z.number().int().min(1).max(100).optional(), + }).optional(), })), }) diff --git a/web/lib/api/types.ts b/web/lib/api/types.ts index 65a2add5..1e196ecc 100644 --- a/web/lib/api/types.ts +++ b/web/lib/api/types.ts @@ -107,6 +107,15 @@ export interface ApplicationPerformanceConfigEnvironmentConfig { cpuLimit?: string memoryRequest?: string memoryLimit?: string + autoscaling?: AutoscalingConfig +} + +export interface AutoscalingConfig { + enabled?: boolean + minReplicas?: number + maxReplicas?: number + targetCpuUtilization?: number + targetMemoryUtilization?: number } export interface ApplicationPodStatus { diff --git a/web/locales/en.ts b/web/locales/en.ts index 9b463e27..518e576e 100644 --- a/web/locales/en.ts +++ b/web/locales/en.ts @@ -155,6 +155,12 @@ const en = { "apps.perf.saveSuccess": "Config saved", "apps.perf.saveError": "Save failed", "apps.perf.noAppInfo": "Please save app info first", + "apps.perf.autoscaling": "Autoscaling (HPA)", + "apps.perf.autoscalingHint": "When enabled, replicas are managed by HPA. Cluster must have metrics-server installed.", + "apps.perf.minReplicas": "Min Replicas", + "apps.perf.maxReplicas": "Max Replicas", + "apps.perf.targetCpu": "Target CPU", + "apps.perf.targetMemory": "Target Memory", "apps.config.noConfig": "No config items", "apps.config.addItem": "Add Item", "apps.config.fetchError": "Failed to fetch config", diff --git a/web/locales/zh.ts b/web/locales/zh.ts index 692dcae5..a6b1305a 100644 --- a/web/locales/zh.ts +++ b/web/locales/zh.ts @@ -155,6 +155,12 @@ const zh = { "apps.perf.saveSuccess": "环境配置保存成功", "apps.perf.saveError": "保存环境配置失败", "apps.perf.noAppInfo": "请先保存应用基本信息", + "apps.perf.autoscaling": "自动扩缩 (HPA)", + "apps.perf.autoscalingHint": "开启后 副本数将由 HPA 自动调整,集群需安装 metrics-server", + "apps.perf.minReplicas": "最小副本数", + "apps.perf.maxReplicas": "最大副本数", + "apps.perf.targetCpu": "CPU 目标利用率", + "apps.perf.targetMemory": "内存 目标利用率", "apps.config.noConfig": "暂无配置项", "apps.config.addItem": "添加配置项", "apps.config.fetchError": "获取配置失败",