diff --git a/libs/domains/services/feature/src/index.ts b/libs/domains/services/feature/src/index.ts index df4e0fc61db..b9fccde3be9 100644 --- a/libs/domains/services/feature/src/index.ts +++ b/libs/domains/services/feature/src/index.ts @@ -37,6 +37,8 @@ export * from './lib/hooks/use-recent-services/use-recent-services' export * from './lib/hooks/use-favorite-services/use-favorite-services' export * from './lib/pod-statuses-callout/pod-statuses-callout' export * from './lib/pods-metrics/pods-metrics' +export * from './lib/keda/scaled-object-status/scaled-object-status' +export * from './lib/keda/components' export * from './lib/service-action-toolbar/service-action-toolbar' export * from './lib/service-deployment-status-label/service-deployment-status-label' export * from './lib/service-details/service-details' diff --git a/libs/domains/services/feature/src/lib/keda/components/hpa-metric-fields.tsx b/libs/domains/services/feature/src/lib/keda/components/hpa-metric-fields.tsx new file mode 100644 index 00000000000..75f230f5d12 --- /dev/null +++ b/libs/domains/services/feature/src/lib/keda/components/hpa-metric-fields.tsx @@ -0,0 +1,109 @@ +import { type Control, Controller, type FieldValues, type UseFormSetValue } from 'react-hook-form' +import { InputText, RadioGroup } from '@qovery/shared/ui' + +export interface HpaMetricFieldsProps { + control: Control + setValue: UseFormSetValue + hpaMetricType?: string +} + +export function HpaMetricFields({ control, setValue, hpaMetricType }: HpaMetricFieldsProps) { + return ( + <> + {/* Metric choice */} + ( +
+ + + + + +
+ )} + /> + + {/* CPU threshold */} + ( + { + const output = parseInt(e.target.value, 10) + const value = isNaN(output) ? 0 : output + field.onChange(value) + }} + error={ + error?.type === 'required' + ? 'Please enter a value.' + : error?.type === 'min' + ? 'Minimum is 1%.' + : error?.type === 'max' + ? 'Maximum is 100%.' + : undefined + } + /> + )} + /> + + {/* Memory threshold (only if CPU_AND_MEMORY) */} + {hpaMetricType === 'CPU_AND_MEMORY' && ( + ( + { + const output = parseInt(e.target.value, 10) + const value = isNaN(output) ? 0 : output + field.onChange(value) + }} + error={ + error?.type === 'required' + ? 'Please enter a value.' + : error?.type === 'min' + ? 'Minimum is 1%.' + : error?.type === 'max' + ? 'Maximum is 100%.' + : undefined + } + /> + )} + /> + )} + + ) +} diff --git a/libs/domains/services/feature/src/lib/keda/components/index.ts b/libs/domains/services/feature/src/lib/keda/components/index.ts new file mode 100644 index 00000000000..43ea77eef4f --- /dev/null +++ b/libs/domains/services/feature/src/lib/keda/components/index.ts @@ -0,0 +1,4 @@ +export * from './instances-range-inputs' +export * from './hpa-metric-fields' +export * from './keda-scalers-fields' +export * from './keda-settings' diff --git a/libs/domains/services/feature/src/lib/keda/components/instances-range-inputs.spec.tsx b/libs/domains/services/feature/src/lib/keda/components/instances-range-inputs.spec.tsx new file mode 100644 index 00000000000..8d1058dff77 --- /dev/null +++ b/libs/domains/services/feature/src/lib/keda/components/instances-range-inputs.spec.tsx @@ -0,0 +1,63 @@ +import { FormProvider, useForm } from 'react-hook-form' +import { renderWithProviders, screen } from '@qovery/shared/util-tests' +import { InstancesRangeInputs } from './instances-range-inputs' + +function TestWrapper({ + requireMinLessThanMax, + showMaxField = true, +}: { + requireMinLessThanMax?: boolean + showMaxField?: boolean +}) { + const methods = useForm({ + defaultValues: { + min_running_instances: 1, + max_running_instances: 5, + }, + }) + + return ( + +
+ + +
+ ) +} + +describe('InstancesRangeInputs', () => { + it('should render min and max fields', () => { + renderWithProviders() + + expect(screen.getByLabelText('Instances min')).toBeInTheDocument() + expect(screen.getByLabelText('Instances max')).toBeInTheDocument() + }) + + it('should render only min field when showMaxField is false', () => { + renderWithProviders() + + expect(screen.getByLabelText('Number of instances')).toBeInTheDocument() + expect(screen.queryByLabelText('Instances max')).not.toBeInTheDocument() + }) + + it('should render with requireMinLessThanMax enabled', () => { + renderWithProviders() + + expect(screen.getByLabelText('Instances min')).toBeInTheDocument() + expect(screen.getByLabelText('Instances max')).toBeInTheDocument() + }) + + it('should render with requireMinLessThanMax disabled', () => { + renderWithProviders() + + expect(screen.getByLabelText('Instances min')).toBeInTheDocument() + expect(screen.getByLabelText('Instances max')).toBeInTheDocument() + }) +}) diff --git a/libs/domains/services/feature/src/lib/keda/components/instances-range-inputs.tsx b/libs/domains/services/feature/src/lib/keda/components/instances-range-inputs.tsx new file mode 100644 index 00000000000..4c26ae118ec --- /dev/null +++ b/libs/domains/services/feature/src/lib/keda/components/instances-range-inputs.tsx @@ -0,0 +1,112 @@ +import { type Control, Controller, type FieldErrors, useWatch } from 'react-hook-form' +import { InputText } from '@qovery/shared/ui' + +export interface InstancesRangeInputsProps { + control: Control + minInstances: number + maxInstances: number + minRunningInstances?: number + disabled?: boolean + showMaxField?: boolean + runningPods?: number + requireMinLessThanMax?: boolean +} + +export function InstancesRangeInputs({ + control, + minInstances, + maxInstances, + minRunningInstances, + disabled = false, + showMaxField = true, + runningPods, + requireMinLessThanMax = false, +}: InstancesRangeInputsProps) { + const minRunningInstancesValue = useWatch({ control, name: 'min_running_instances' }) + + const getErrorMessage = (error: FieldErrors[string], fieldName: string) => { + if (!error) return undefined + if (error.type === 'required') return 'Please enter a size.' + if (error.type === 'max') return `Maximum allowed is: ${maxInstances}.` + if (error.type === 'min') { + const minValue = fieldName === 'max_running_instances' ? minRunningInstancesValue : minInstances + return `Minimum allowed is: ${minValue}.` + } + if (error.type === 'validate') return error.message as string + return undefined + } + + return ( + <> +
+ ( + { + const output = parseInt(e.target.value, 10) + const value = isNaN(output) ? 0 : output + field.onChange(value) + }} + disabled={disabled} + error={getErrorMessage(error, field.name)} + /> + )} + /> + + {showMaxField && ( + { + if (value <= minRunningInstancesValue) { + return 'Maximum instances must be greater than minimum instances.' + } + return true + } + : undefined, + }} + render={({ field, fieldState: { error } }) => ( + { + const output = parseInt(e.target.value, 10) + const value = isNaN(output) ? 0 : output + field.onChange(value) + }} + disabled={disabled} + error={getErrorMessage(error, field.name)} + /> + )} + /> + )} +
+ + {runningPods !== undefined && ( +

+ + Current consumption: {runningPods} instance{runningPods > 1 ? 's' : ''} + +

+ )} + + ) +} diff --git a/libs/domains/services/feature/src/lib/keda/components/keda-scalers-fields.tsx b/libs/domains/services/feature/src/lib/keda/components/keda-scalers-fields.tsx new file mode 100644 index 00000000000..5e269037341 --- /dev/null +++ b/libs/domains/services/feature/src/lib/keda/components/keda-scalers-fields.tsx @@ -0,0 +1,174 @@ +import { type Control, Controller, type UseFieldArrayReturn } from 'react-hook-form' +import { Button, CodeEditor, InputText } from '@qovery/shared/ui' + +export interface KedaScalersFieldsProps { + control: Control + scalersFieldArray: UseFieldArrayReturn + disabled?: boolean + pollingInterval?: number + cooldownPeriod?: number +} + +export function KedaScalersFields({ + control, + scalersFieldArray, + disabled = false, + pollingInterval, + cooldownPeriod, +}: KedaScalersFieldsProps) { + const { fields: scalers, append, remove } = scalersFieldArray + + const handleAddScaler = () => { + append({ type: '', config: '', triggerAuthentication: '' }) + } + + const handleRemoveScaler = (index: number) => { + remove(index) + } + + return ( + <> + {/* Polling interval and cooldown period */} +
+ ( + + )} + /> + ( + + )} + /> +
+ + {/* Scalers list */} +
+
+ + +
+ + {scalers.length === 0 && ( +

No scalers configured. Click "Add scaler" to get started.

+ )} + + {scalers.map((_: unknown, index: number) => ( +
+
+ Scaler #{index + 1} + +
+ + ( + + )} + /> + + { + const lineCount = (field.value ?? '').split('\n').length + const displayLines = Math.min(Math.max(lineCount, 3), 5) + const height = `${displayLines * 19 + 10}px` + + return ( +
+ + field.onChange(value ?? '')} + options={{ + readOnly: disabled, + }} + /> +

Paste raw YAML for this scaler

+
+ ) + }} + /> + + { + const lineCount = (field.value ?? '').split('\n').length + const displayLines = Math.min(Math.max(lineCount, 3), 5) + const height = `${displayLines * 19 + 10}px` + + return ( +
+ + field.onChange(value ?? '')} + options={{ + readOnly: disabled, + }} + /> +

Paste raw YAML for the trigger authentication (optional)

+
+ ) + }} + /> +
+ ))} +
+ + ) +} diff --git a/libs/domains/services/feature/src/lib/keda/components/keda-settings.tsx b/libs/domains/services/feature/src/lib/keda/components/keda-settings.tsx new file mode 100644 index 00000000000..e26289d60d7 --- /dev/null +++ b/libs/domains/services/feature/src/lib/keda/components/keda-settings.tsx @@ -0,0 +1,53 @@ +import { type Control, type UseFieldArrayReturn } from 'react-hook-form' +import { Callout, Icon } from '@qovery/shared/ui' +import { InstancesRangeInputs } from './instances-range-inputs' +import { KedaScalersFields } from './keda-scalers-fields' + +export interface KedaSettingsProps { + control: Control + scalersFieldArray: UseFieldArrayReturn + minInstances: number + maxInstances: number + minRunningInstances?: number + disabled?: boolean + runningPods?: number +} + +export function KedaSettings({ + control, + scalersFieldArray, + minInstances, + maxInstances, + minRunningInstances, + disabled = false, + runningPods, +}: KedaSettingsProps) { + return ( + <> + + + + + + + +

+ For applications requiring high availability, set Minimum Instances to at least 2 to maintain service + availability during pod failures or cluster maintenance. +

+
+
+ + + + ) +} diff --git a/libs/domains/services/feature/src/lib/keda/scaled-object-status/scaled-object-status.tsx b/libs/domains/services/feature/src/lib/keda/scaled-object-status/scaled-object-status.tsx new file mode 100644 index 00000000000..397186af9ff --- /dev/null +++ b/libs/domains/services/feature/src/lib/keda/scaled-object-status/scaled-object-status.tsx @@ -0,0 +1,87 @@ +import type { ScaledObjectStatusDto } from 'qovery-ws-typescript-axios/dist/api' +import { match } from 'ts-pattern' +import { Badge, Heading, Icon, Section, Skeleton, StatusChip } from '@qovery/shared/ui' +import { useRunningStatus } from '../../hooks/use-running-status/use-running-status' + +export interface ScaledObjectStatusProps { + environmentId: string + serviceId: string +} + +export function ScaledObjectStatus({ environmentId, serviceId }: ScaledObjectStatusProps) { + const { data: runningStatus, isLoading } = useRunningStatus({ environmentId, serviceId }) + + // Only show for APPLICATION and CONTAINER with scaled_object + if (!runningStatus || !('scaled_object' in runningStatus) || !runningStatus.scaled_object) { + return null + } + + const scaledObject = runningStatus.scaled_object as ScaledObjectStatusDto + + if (isLoading) { + return ( +
+ Scaled Object + +
+ ) + } + + return ( +
+ Scaled Object (KEDA) +
+
+
+ +
+ {scaledObject.name} +
+
+
+ + {scaledObject.conditions && scaledObject.conditions.length > 0 && ( +
+ {scaledObject.conditions + .filter((condition) => { + // Don't show Fallback at all + if (condition.type === 'Fallback') return false + // Only show Paused if it's True (paused) + if (condition.type === 'Paused' && condition.status !== 'True') return false + // Only show Active if it's True (if False, all scalers have correct values, not an issue) + return !(condition.type === 'Active' && condition.status !== 'True') + }) + .map((condition, index) => { + const chipStatus = match({ type: condition.type, status: condition.status }) + // Positive conditions: True = good, False = bad/warning + .with({ type: 'Ready', status: 'True' }, () => 'RUNNING' as const) + .with({ type: 'Ready', status: 'False' }, () => 'ERROR' as const) + .with({ type: 'Active', status: 'True' }, () => 'RUNNING' as const) + .with({ type: 'Active', status: 'False' }, () => 'WARNING' as const) + // Paused: True = bad (paused) + .with({ type: 'Paused', status: 'True' }, () => 'ERROR' as const) + // Default + .otherwise(() => (condition.status === 'True' ? ('RUNNING' as const) : ('WARNING' as const))) + + return ( +
+
+
+ {condition.type} + + + {condition.status} + +
+ {condition.reason && Reason: {condition.reason}} + {condition.message && {condition.message}} +
+
+ ) + })} +
+ )} +
+
+ ) +} diff --git a/libs/domains/services/feature/src/lib/service-details/service-details.tsx b/libs/domains/services/feature/src/lib/service-details/service-details.tsx index e6e0a6b7f7a..805eb91fb53 100644 --- a/libs/domains/services/feature/src/lib/service-details/service-details.tsx +++ b/libs/domains/services/feature/src/lib/service-details/service-details.tsx @@ -168,25 +168,38 @@ export function ServiceDetails({ className, environmentId, serviceId, ...props } } const resources = match(service) - .with( - { serviceType: ServiceTypeEnum.CONTAINER }, - { serviceType: ServiceTypeEnum.APPLICATION }, - ({ cpu, memory, min_running_instances, max_running_instances, gpu }) => ( + .with({ serviceType: ServiceTypeEnum.CONTAINER }, { serviceType: ServiceTypeEnum.APPLICATION }, (s) => { + const { cpu, memory, min_running_instances, max_running_instances, gpu } = s + + // Determine autoscaling mode using the same logic as other parts of the app + let autoscalingMode = 'Fixed' + if (s.autoscaling?.mode === 'KEDA') { + autoscalingMode = 'KEDA' + } else if (min_running_instances !== max_running_instances) { + autoscalingMode = 'HPA' + } + + const isFixed = autoscalingMode === 'Fixed' + + return ( <> + ) - ) + }) .with({ serviceType: ServiceTypeEnum.DATABASE }, ({ cpu, memory, storage, instance_type, mode }) => ( <> {mode !== 'MANAGED' && ( diff --git a/libs/pages/application/src/lib/feature/page-settings-general-feature/page-settings-general-feature.tsx b/libs/pages/application/src/lib/feature/page-settings-general-feature/page-settings-general-feature.tsx index da25e0745db..134994e0fc2 100644 --- a/libs/pages/application/src/lib/feature/page-settings-general-feature/page-settings-general-feature.tsx +++ b/libs/pages/application/src/lib/feature/page-settings-general-feature/page-settings-general-feature.tsx @@ -28,6 +28,7 @@ import { type HelmGeneralData } from '@qovery/pages/services' import { isHelmGitSource, isHelmRepositorySource, isJobContainerSource, isJobGitSource } from '@qovery/shared/enums' import { type ApplicationGeneralData, type JobGeneralData } from '@qovery/shared/interfaces' import { joinArgsWithQuotes, parseCmd } from '@qovery/shared/util-js' +import { convertAutoscalingResponseToRequest } from '@qovery/shared/util-services' import PageSettingsGeneral from '../../ui/page-settings-general/page-settings-general' export const handleGitApplicationSubmit = ( @@ -45,7 +46,7 @@ export const handleGitApplicationSubmit = ( description: data.description || '', icon_uri: data.icon_uri, auto_deploy: data.auto_deploy, - autoscaling: undefined, + autoscaling: convertAutoscalingResponseToRequest(application.autoscaling), } cloneApplication.auto_deploy = data.auto_deploy @@ -101,7 +102,7 @@ export const handleContainerSubmit = ( registry_id: data.registry || '', annotations_groups: annotationsGroups.filter((group) => data.annotations_groups?.includes(group.id)), labels_groups: labelsGroups.filter((group) => data.labels_groups?.includes(group.id)), - autoscaling: undefined, + autoscaling: convertAutoscalingResponseToRequest(container.autoscaling), } } diff --git a/libs/pages/application/src/lib/feature/page-settings-resources-feature/page-settings-resources-feature.tsx b/libs/pages/application/src/lib/feature/page-settings-resources-feature/page-settings-resources-feature.tsx index 4a2a63d2f32..f18bd561fd0 100644 --- a/libs/pages/application/src/lib/feature/page-settings-resources-feature/page-settings-resources-feature.tsx +++ b/libs/pages/application/src/lib/feature/page-settings-resources-feature/page-settings-resources-feature.tsx @@ -1,11 +1,22 @@ import { type Environment } from 'qovery-typescript-axios' +import { useEffect } from 'react' import { type FieldValues, FormProvider, useForm } from 'react-hook-form' import { useParams } from 'react-router-dom' import { match } from 'ts-pattern' import { useEnvironment } from '@qovery/domains/environments/feature' import { type AnyService, type Database, type Helm } from '@qovery/domains/services/data-access' -import { useEditService, useService } from '@qovery/domains/services/feature' -import { buildEditServicePayload } from '@qovery/shared/util-services' +import { + useAdvancedSettings, + useEditAdvancedSettings, + useEditService, + useService, +} from '@qovery/domains/services/feature' +import { + buildAutoscalingRequestFromForm, + buildEditServicePayload, + buildHpaAdvancedSettingsPayload, + loadHpaSettingsFromAdvancedSettings, +} from '@qovery/shared/util-services' import PageSettingsResources from '../../ui/page-settings-resources/page-settings-resources' export interface SettingsResourcesFeatureProps { @@ -20,6 +31,13 @@ export function SettingsResourcesFeature({ service, environment }: SettingsResou environmentId: environment.id, }) + const { data: advancedSettings } = useAdvancedSettings({ serviceId: service.id, serviceType: service.serviceType }) + const { mutateAsync: editAdvancedSettings } = useEditAdvancedSettings({ + organizationId: environment.organization.id, + projectId: environment.project.id, + environmentId: environment.id, + }) + const defaultInstances = match(service) .with({ serviceType: 'JOB' }, () => ({})) .with({ serviceType: 'TERRAFORM' }, (s) => ({ @@ -42,30 +60,80 @@ export function SettingsResourcesFeature({ service, environment }: SettingsResou gpu: match(service) .with({ serviceType: 'TERRAFORM' }, (s) => s.job_resources.gpu) .otherwise((s) => s.gpu || 0), + + autoscaling_mode: match(service) + .with({ serviceType: 'APPLICATION' }, { serviceType: 'CONTAINER' }, (s) => { + if (s.autoscaling?.mode === 'KEDA') return 'KEDA' + if (s.min_running_instances === s.max_running_instances) return 'NONE' + if (s.min_running_instances !== s.max_running_instances) return 'HPA' + return 'NONE' + }) + .otherwise(() => 'NONE'), + + scalers: match(service) + .with({ serviceType: 'APPLICATION' }, { serviceType: 'CONTAINER' }, (s) => { + if (s.autoscaling?.mode !== 'KEDA') return [] + const autoscalingWithFields = s.autoscaling as { + scalers?: Array<{ + scaler_type?: string + config_yaml?: string + trigger_authentication?: { config_yaml?: string } + }> + } + return ( + autoscalingWithFields.scalers?.map((scaler) => ({ + type: scaler?.scaler_type || '', + config: scaler?.config_yaml || '', + triggerAuthentication: scaler?.trigger_authentication?.config_yaml || '', + })) || [] + ) + }) + .otherwise(() => []), + + autoscaling_polling_interval: match(service) + .with({ serviceType: 'APPLICATION' }, { serviceType: 'CONTAINER' }, (s) => { + if (s.autoscaling?.mode !== 'KEDA') return undefined + const autoscalingWithFields = s.autoscaling as { polling_interval_seconds?: number } | undefined + return autoscalingWithFields?.polling_interval_seconds + }) + .otherwise(() => undefined), + + autoscaling_cooldown_period: match(service) + .with({ serviceType: 'APPLICATION' }, { serviceType: 'CONTAINER' }, (s) => { + if (s.autoscaling?.mode !== 'KEDA') return undefined + const autoscalingWithFields = s.autoscaling as { cooldown_period_seconds?: number } | undefined + return autoscalingWithFields?.cooldown_period_seconds + }) + .otherwise(() => undefined), + + ...loadHpaSettingsFromAdvancedSettings(advancedSettings), + ...defaultInstances, }, }) - const onSubmit = methods.handleSubmit((data: FieldValues) => { - const request = { + // Update form values when advanced settings change (e.g., after save) + useEffect(() => { + if (advancedSettings) { + const hpaSettings = loadHpaSettingsFromAdvancedSettings(advancedSettings) + methods.setValue('hpa_metric_type', hpaSettings.hpa_metric_type) + methods.setValue('hpa_cpu_average_utilization_percent', hpaSettings.hpa_cpu_average_utilization_percent) + methods.setValue('hpa_memory_average_utilization_percent', hpaSettings.hpa_memory_average_utilization_percent) + } + }, [advancedSettings, methods]) + + const onSubmit = methods.handleSubmit(async (data: FieldValues) => { + const baseRequest = { memory: Number(data['memory']), cpu: Number(data['cpu']), gpu: Number(data['gpu']), } - let requestWithInstances = {} - if (service.serviceType !== 'JOB') { - requestWithInstances = { - ...request, - ...{ - min_running_instances: data['min_running_instances'], - max_running_instances: data['max_running_instances'], - }, - } - } + const requestWithInstances = + service.serviceType !== 'JOB' ? buildAutoscalingRequestFromForm(data, baseRequest) : baseRequest const payload = match(service) - .with({ serviceType: 'JOB' }, (service) => buildEditServicePayload({ service, request })) + .with({ serviceType: 'JOB' }, (service) => buildEditServicePayload({ service, request: baseRequest })) .with({ serviceType: 'APPLICATION' }, (service) => buildEditServicePayload({ service, @@ -93,6 +161,17 @@ export function SettingsResourcesFeature({ service, environment }: SettingsResou ) .exhaustive() + // Save HPA advanced settings if in HPA mode + if (data['autoscaling_mode'] === 'HPA' && advancedSettings) { + await editAdvancedSettings({ + serviceId: service.id, + payload: { + serviceType: service.serviceType, + ...buildHpaAdvancedSettingsPayload(data, advancedSettings), + }, + }) + } + editService({ serviceId: service.id, payload, @@ -109,6 +188,7 @@ export function SettingsResourcesFeature({ service, environment }: SettingsResou loading={isLoadingService} service={service} displayWarningCpu={displayWarningCpu} + advancedSettings={advancedSettings} /> ) diff --git a/libs/pages/application/src/lib/ui/page-general/page-general.tsx b/libs/pages/application/src/lib/ui/page-general/page-general.tsx index caf6b2218b4..f8b8e3e5860 100644 --- a/libs/pages/application/src/lib/ui/page-general/page-general.tsx +++ b/libs/pages/application/src/lib/ui/page-general/page-general.tsx @@ -1,8 +1,9 @@ import { motion } from 'framer-motion' +import { useFeatureFlagVariantKey } from 'posthog-js/react' import { useMemo } from 'react' import { EnableObservabilityModal } from '@qovery/domains/observability/feature' import { type AnyService } from '@qovery/domains/services/data-access' -import { PodStatusesCallout, PodsMetrics, ServiceDetails } from '@qovery/domains/services/feature' +import { PodStatusesCallout, PodsMetrics, ScaledObjectStatus, ServiceDetails } from '@qovery/domains/services/feature' import { OutputVariables } from '@qovery/domains/variables/feature' import { Button, ExternalLink, Icon, useModal } from '@qovery/shared/ui' import { useLocalStorage } from '@qovery/shared/util-hooks' @@ -169,11 +170,19 @@ function ObservabilityCallout() { } export function PageGeneral({ serviceId, environmentId, service, hasNoMetrics }: PageGeneralProps) { + const isKedaFeatureEnabled = useFeatureFlagVariantKey('keda') const isLifecycleJobOrTerraform = useMemo( () => (service?.serviceType === 'JOB' && service.job_type === 'LIFECYCLE') || service?.serviceType === 'TERRAFORM', [service] ) const isCronJob = useMemo(() => service?.serviceType === 'JOB' && service.job_type === 'CRON', [service]) + const isKedaAutoscaling = useMemo( + () => + isKedaFeatureEnabled && + (service?.serviceType === 'APPLICATION' || service?.serviceType === 'CONTAINER') && + service.autoscaling?.mode === 'KEDA', + [service, isKedaFeatureEnabled] + ) return (
@@ -197,6 +206,7 @@ export function PageGeneral({ serviceId, environmentId, service, hasNoMetrics }:
)} + {isKedaAutoscaling && } {isLifecycleJobOrTerraform && } - Instances + Instances & Autoscaling +
+
+ +
+ + +
+
+ + No autoscaling (fixed instances) + + +
+
+ + +
+
+ +
+ +
+ +
+
+

+ Choose how instances should scale +

+
@@ -233,56 +334,152 @@ exports[`PageSettingsResources should render warning box and icon for cpu 1`] =
-

- +
+

+ Always assume one instance may fail due to node maintenance or issues. +

+

+ To ensure high availability, set Minimum Instances to 2 if your app can run on 1 instance. +

+

+ If your application requires more than one instance to handle necessary traffic, set the minimum to 3 or higher to guarantee redundancy during a single failure. +

+
+
+
+ +
+
-
+ + CPU only + + + Scale based on CPU usage + +
+ +
+