Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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()
Expand All @@ -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<io.fabric8.kubernetes.api.model.autoscaling.v2.MetricSpec> 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())
Expand Down
145 changes: 130 additions & 15 deletions web/app/apps/components/application-performance-info.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -197,24 +198,27 @@ function ReplicasInput({ value, onChange }: { value: number | undefined, onChang
}

function SingleEnvironmentConfig({ index }: SingleEnvironmentConfigProps) {
const { control } = useFormContext<ApplicationPerformanceEnvFormValues>()
const { control, watch } = useFormContext<ApplicationPerformanceEnvFormValues>()
const { t } = useLanguage()
const autoscalingEnabled = watch(`environmentConfigs.${index}.autoscaling.enabled`) === true

return (
<div className="flex flex-col gap-4">
<FormField
control={control}
name={`environmentConfigs.${index}.replicas`}
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center gap-1"><Copy className="h-3.5 w-3.5" />{t("apps.perf.replicas")}</FormLabel>
<FormControl>
<ReplicasInput value={field.value} onChange={field.onChange} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{!autoscalingEnabled && (
<FormField
control={control}
name={`environmentConfigs.${index}.replicas`}
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center gap-1"><Copy className="h-3.5 w-3.5" />{t("apps.perf.replicas")}</FormLabel>
<FormControl>
<ReplicasInput value={field.value} onChange={field.onChange} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<div className="grid grid-cols-2 gap-x-4 gap-y-4 w-fit">
<FormField
control={control}
Expand Down Expand Up @@ -281,6 +285,117 @@ function SingleEnvironmentConfig({ index }: SingleEnvironmentConfigProps) {
)}
/>
</div>
<div className="border-t pt-4 flex flex-col gap-3">
<FormField
control={control}
name={`environmentConfigs.${index}.autoscaling.enabled`}
render={({ field }) => (
<FormItem className="flex items-center gap-2 space-y-0">
<FormControl>
<Checkbox checked={field.value === true} onCheckedChange={(v) => field.onChange(v === true)} />
</FormControl>
<FormLabel className="flex items-center gap-1 !mt-0 cursor-pointer">
<Gauge className="h-3.5 w-3.5" />{t("apps.perf.autoscaling")}
</FormLabel>
</FormItem>
)}
/>
{autoscalingEnabled && (
<>
<p className="text-xs text-muted-foreground">{t("apps.perf.autoscalingHint")}</p>
<div className="grid grid-cols-2 gap-x-4 gap-y-4 w-fit">
<FormField
control={control}
name={`environmentConfigs.${index}.autoscaling.minReplicas`}
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center gap-1"><Copy className="h-3.5 w-3.5" />{t("apps.perf.minReplicas")}</FormLabel>
<FormControl>
<Input
type="number"
min={1}
placeholder="1"
className="w-24"
value={field.value ?? ""}
onChange={e => field.onChange(e.target.value === "" ? undefined : parseInt(e.target.value, 10))}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name={`environmentConfigs.${index}.autoscaling.maxReplicas`}
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center gap-1"><Copy className="h-3.5 w-3.5" />{t("apps.perf.maxReplicas")}</FormLabel>
<FormControl>
<Input
type="number"
min={1}
placeholder="10"
className="w-24"
value={field.value ?? ""}
onChange={e => field.onChange(e.target.value === "" ? undefined : parseInt(e.target.value, 10))}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name={`environmentConfigs.${index}.autoscaling.targetCpuUtilization`}
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center gap-1"><Cpu className="h-3.5 w-3.5" />{t("apps.perf.targetCpu")}</FormLabel>
<FormControl>
<div className="relative w-24">
<Input
type="number"
min={1}
max={100}
placeholder="70"
className="pr-8"
value={field.value ?? ""}
onChange={e => field.onChange(e.target.value === "" ? undefined : parseInt(e.target.value, 10))}
/>
<span className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-xs text-muted-foreground">%</span>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name={`environmentConfigs.${index}.autoscaling.targetMemoryUtilization`}
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center gap-1"><MemoryStick className="h-3.5 w-3.5" />{t("apps.perf.targetMemory")}</FormLabel>
<FormControl>
<div className="relative w-24">
<Input
type="number"
min={1}
max={100}
placeholder="—"
className="pr-8"
value={field.value ?? ""}
onChange={e => field.onChange(e.target.value === "" ? undefined : parseInt(e.target.value, 10))}
/>
<span className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-xs text-muted-foreground">%</span>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</>
)}
</div>
</div>
)
}
7 changes: 7 additions & 0 deletions web/app/apps/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
})),
})

Expand Down
9 changes: 9 additions & 0 deletions web/lib/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
6 changes: 6 additions & 0 deletions web/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 6 additions & 0 deletions web/locales/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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": "获取配置失败",
Expand Down