Skip to content

Commit 53375fc

Browse files
authored
feat(cluster): add resources settings (#2271)
1 parent 58a3d2f commit 53375fc

11 files changed

Lines changed: 274 additions & 74 deletions

File tree

Lines changed: 200 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,209 @@
1-
import { createFileRoute } from '@tanstack/react-router'
1+
import { createFileRoute, useParams } from '@tanstack/react-router'
2+
import {
3+
type Cluster,
4+
type ClusterFeatureKarpenterParametersResponse,
5+
type ClusterFeatureStringResponse,
6+
type ClusterRequestFeaturesInner,
7+
} from 'qovery-typescript-axios'
8+
import { type FieldValues, FormProvider, useForm } from 'react-hook-form'
9+
import { SCW_CONTROL_PLANE_FEATURE_ID } from '@qovery/domains/cloud-providers/feature'
10+
import {
11+
ClusterMigrationModal,
12+
useCluster,
13+
useEditCluster,
14+
useUpdateKarpenterPrivateFargate,
15+
} from '@qovery/domains/clusters/feature'
16+
import { ClusterResourcesSettingsFeature, SettingsHeading } from '@qovery/shared/console-shared'
17+
import { type ClusterResourcesEdit, type SCWControlPlaneFeatureType } from '@qovery/shared/interfaces'
18+
import { Button, Section, useModal } from '@qovery/shared/ui'
219

320
export const Route = createFileRoute(
421
'/_authenticated/organization/$organizationId/cluster/$clusterId/settings/resources'
522
)({
623
component: RouteComponent,
724
})
825

26+
function getValueByKey(key: string, data: { [key: string]: string }[] = []): string[] {
27+
return data.filter((obj) => key in obj).map((obj) => obj[key])
28+
}
29+
30+
const handleSubmit = (data: FieldValues, cluster: Cluster): Cluster => {
31+
const payload = {
32+
...cluster,
33+
max_running_nodes: data['nodes'][1],
34+
min_running_nodes: data['nodes'][0],
35+
disk_size: data['disk_size'],
36+
instance_type: data['instance_type'],
37+
}
38+
39+
const hasKarpenterFeature = cluster.features?.some((f) => f.id === 'KARPENTER')
40+
41+
if (data['karpenter']?.enabled && !hasKarpenterFeature) {
42+
payload.features = [
43+
...(cluster.features || []),
44+
{
45+
id: 'KARPENTER',
46+
value: {
47+
spot_enabled: data['karpenter'].spot_enabled ?? false,
48+
disk_size_in_gib: data['karpenter'].disk_size_in_gib,
49+
default_service_architecture: data['karpenter'].default_service_architecture,
50+
qovery_node_pools: data['karpenter'].qovery_node_pools,
51+
},
52+
} as ClusterRequestFeaturesInner,
53+
]
54+
} else {
55+
payload.features = cluster.features?.map((feature) => {
56+
if (feature.id === 'KARPENTER') {
57+
return {
58+
...feature,
59+
value: {
60+
spot_enabled: data['karpenter'].spot_enabled ?? false,
61+
disk_size_in_gib: data['karpenter'].disk_size_in_gib,
62+
default_service_architecture: data['karpenter'].default_service_architecture,
63+
qovery_node_pools: data['karpenter'].qovery_node_pools,
64+
},
65+
}
66+
}
67+
68+
return feature
69+
})
70+
}
71+
72+
if (cluster.cloud_provider === 'SCW') {
73+
payload.features = cluster.features?.map((feature) => {
74+
if (feature.id === SCW_CONTROL_PLANE_FEATURE_ID) {
75+
return {
76+
...feature,
77+
value: data['scw_control_plane'],
78+
}
79+
}
80+
81+
return feature
82+
})
83+
}
84+
85+
return payload
86+
}
87+
88+
function ClusterResourcesSettingsForm({ cluster }: { cluster: Cluster }) {
89+
const { organizationId, clusterId } = useParams({ strict: false })
90+
const karpenterFeature = cluster.features?.find(
91+
(feature) => feature.id === 'KARPENTER'
92+
) as ClusterFeatureKarpenterParametersResponse
93+
const scwFeature = cluster.features?.find(
94+
(feature) => feature.id === SCW_CONTROL_PLANE_FEATURE_ID
95+
) as ClusterFeatureStringResponse
96+
97+
const { openModal, closeModal } = useModal()
98+
99+
const methods = useForm<ClusterResourcesEdit>({
100+
mode: 'onChange',
101+
defaultValues: {
102+
cluster_type: cluster.kubernetes,
103+
instance_type: cluster.instance_type,
104+
nodes: [cluster.min_running_nodes || 1, cluster.max_running_nodes || 1],
105+
disk_size: cluster.disk_size || 0,
106+
karpenter: karpenterFeature
107+
? {
108+
enabled: true,
109+
spot_enabled: karpenterFeature.value.spot_enabled,
110+
disk_size_in_gib: karpenterFeature.value.disk_size_in_gib,
111+
default_service_architecture: karpenterFeature.value.default_service_architecture,
112+
qovery_node_pools: karpenterFeature.value.qovery_node_pools,
113+
}
114+
: {
115+
enabled: false,
116+
},
117+
scw_control_plane: scwFeature ? (scwFeature.value as SCWControlPlaneFeatureType) : undefined,
118+
},
119+
})
120+
const { mutate: editCluster, isLoading: isEditClusterLoading } = useEditCluster()
121+
const { mutateAsync: updateKarpenterPrivateFargate } = useUpdateKarpenterPrivateFargate()
122+
123+
const onSubmit = methods.handleSubmit(async (data) => {
124+
const updateCluster = async () => {
125+
const cloneCluster = handleSubmit(data, cluster)
126+
editCluster({
127+
clusterId: cluster.id,
128+
organizationId: organizationId || '',
129+
clusterRequest: cloneCluster,
130+
})
131+
}
132+
133+
const updateClusterKarpenterSubnets = async () => {
134+
if (data?.aws_existing_vpc?.eks_subnets) {
135+
try {
136+
await updateKarpenterPrivateFargate({
137+
organizationId: organizationId || '',
138+
clusterId: clusterId || '',
139+
clusterKarpenterPrivateSubnetIdsPutRequest: {
140+
eks_karpenter_fargate_subnets_zone_a_ids: getValueByKey('A', data.aws_existing_vpc.eks_subnets),
141+
eks_karpenter_fargate_subnets_zone_b_ids: getValueByKey('B', data.aws_existing_vpc.eks_subnets),
142+
eks_karpenter_fargate_subnets_zone_c_ids: getValueByKey('C', data.aws_existing_vpc.eks_subnets),
143+
},
144+
})
145+
await updateCluster()
146+
} catch (error) {
147+
console.error(error)
148+
}
149+
} else {
150+
await updateCluster()
151+
}
152+
}
153+
154+
if (data && cluster) {
155+
const hasKarpenterFeature = cluster.features?.some((f) => f.id === 'KARPENTER')
156+
if (data.karpenter?.enabled === !hasKarpenterFeature) {
157+
openModal({
158+
content: <ClusterMigrationModal onClose={closeModal} onSubmit={updateClusterKarpenterSubnets} />,
159+
})
160+
} else {
161+
await updateClusterKarpenterSubnets()
162+
}
163+
}
164+
})
165+
166+
const hasAlreadyKarpenter = cluster.features?.some((f) => f.id === 'KARPENTER')
167+
168+
return (
169+
<FormProvider {...methods}>
170+
<div className="flex w-full flex-col justify-between">
171+
<Section className="p-8">
172+
<SettingsHeading title="Resources settings" />
173+
<form onSubmit={onSubmit} className="max-w-content-with-navigation-left">
174+
<ClusterResourcesSettingsFeature
175+
cluster={cluster}
176+
cloudProvider={cluster.cloud_provider}
177+
clusterRegion={cluster.region}
178+
isProduction={cluster.production}
179+
hasAlreadyKarpenter={hasAlreadyKarpenter}
180+
fromDetail
181+
/>
182+
<div className="mt-10 flex justify-end">
183+
<Button
184+
data-testid="submit-button"
185+
type="submit"
186+
size="lg"
187+
loading={isEditClusterLoading}
188+
disabled={hasAlreadyKarpenter && !methods.formState.isValid}
189+
>
190+
Save
191+
</Button>
192+
</div>
193+
</form>
194+
</Section>
195+
</div>
196+
</FormProvider>
197+
)
198+
}
199+
9200
function RouteComponent() {
10-
return <div>Hello "/_authenticated/organization/$organizationId/cluster/$clusterId/settings/resources"!</div>
201+
const { organizationId, clusterId } = useParams({ strict: false })
202+
const { data: cluster } = useCluster({ organizationId, clusterId })
203+
204+
if (!cluster) {
205+
return null
206+
}
207+
208+
return <ClusterResourcesSettingsForm cluster={cluster} />
11209
}

libs/domains/cloud-providers/feature/src/lib/karpenter-instance-filter-modal/instance-category/instance-category.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -135,10 +135,10 @@ export function InstanceCategory({ title, attributes }: InstanceCategoryProps) {
135135
}}
136136
/>
137137
<Collapsible.Trigger className="flex w-full items-center justify-between gap-3">
138-
<span className="text-neutral-400">
138+
<span className="text-neutral">
139139
{title.toUpperCase()} - {getInstanceTypeCategory(title)}
140140
</span>
141-
<Icon className="text-sm text-neutral-350" iconName={open ? 'chevron-up' : 'chevron-down'} />
141+
<Icon className="text-sm text-neutral-subtle" iconName={open ? 'chevron-up' : 'chevron-down'} />
142142
</Collapsible.Trigger>
143143
</div>
144144

@@ -161,7 +161,7 @@ export function InstanceCategory({ title, attributes }: InstanceCategoryProps) {
161161
checked={false}
162162
disabled
163163
/>
164-
<label htmlFor={`${title}-${attribute.instance_family}`} className="text-neutral-400">
164+
<label htmlFor={`${title}-${attribute.instance_family}`} className="text-neutral">
165165
{attribute.instance_family}
166166
</label>
167167
</div>
@@ -184,7 +184,7 @@ export function InstanceCategory({ title, attributes }: InstanceCategoryProps) {
184184
field.onChange(newValue)
185185
}}
186186
/>
187-
<label htmlFor={`${title}-${attribute.instance_family}`} className="text-neutral-400">
187+
<label htmlFor={`${title}-${attribute.instance_family}`} className="text-neutral">
188188
{attribute.instance_family}
189189
</label>
190190
</div>

libs/domains/cloud-providers/feature/src/lib/karpenter-instance-filter-modal/karpenter-instance-filter-modal.spec.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ describe('KarpenterInstanceFilterModal', () => {
6666
const mockOnChange = jest.fn()
6767
const mockOnClose = jest.fn()
6868

69-
const { userEvent, debug, baseElement } = renderWithProviders(
69+
const { userEvent } = renderWithProviders(
7070
<KarpenterInstanceFilterModal clusterRegion="us-east-1" onChange={mockOnChange} onClose={mockOnClose} />
7171
)
7272

libs/domains/cloud-providers/feature/src/lib/karpenter-instance-filter-modal/karpenter-instance-filter-modal.tsx

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -241,10 +241,10 @@ function KarpenterInstanceForm({
241241
return (
242242
<FormProvider {...methods}>
243243
<ModalCrud title="Karpenter Instance Visual filter" onClose={onClose} onSubmit={onSubmit} submitLabel="Confirm">
244-
<div className="flex rounded-md border border-neutral-200">
244+
<div className="flex rounded-md border border-neutral">
245245
<div className="flex max-h-[60vh] w-1/2 flex-col gap-2 overflow-y-scroll p-2">
246-
<div className="flex flex-col gap-4 rounded border border-neutral-200 bg-neutral-100 p-4">
247-
<span className="flex w-full justify-between font-semibold text-neutral-400">Architecture</span>
246+
<div className="flex flex-col gap-4 rounded border border-neutral bg-surface-neutral p-4">
247+
<span className="flex w-full justify-between font-semibold text-neutral">Architecture</span>
248248
<div className="grid grid-cols-2 gap-1">
249249
<div className="flex items-center gap-3">
250250
<Controller
@@ -336,8 +336,8 @@ function KarpenterInstanceForm({
336336
</Callout.Root>
337337
)}
338338
</div>
339-
<div className="flex flex-col gap-4 rounded border border-neutral-200 bg-neutral-100 p-4">
340-
<div className="flex w-full justify-between font-semibold text-neutral-400">
339+
<div className="flex flex-col gap-4 rounded border border-neutral bg-surface-neutral p-4">
340+
<div className="flex w-full justify-between font-semibold text-neutral">
341341
Size
342342
<div className="flex gap-0.5">
343343
{watchSizes.length === 0 ? (
@@ -391,8 +391,8 @@ function KarpenterInstanceForm({
391391
))}
392392
</div>
393393
</div>
394-
<div className="flex flex-col gap-4 rounded border border-neutral-200 bg-neutral-100 p-4">
395-
<div className="flex w-full justify-between font-semibold text-neutral-400">
394+
<div className="flex flex-col gap-4 rounded border border-neutral bg-surface-neutral p-4">
395+
<div className="flex w-full justify-between font-semibold text-neutral">
396396
Categories/Families
397397
<div className="flex gap-0.5">
398398
{Object.keys(watchCategories).length === 0 ? (
@@ -474,14 +474,14 @@ function KarpenterInstanceForm({
474474
</div>
475475
</div>
476476
</div>
477-
<div className="flex max-h-[60vh] w-1/2 flex-col gap-4 overflow-y-scroll border-l border-neutral-200 p-6">
477+
<div className="flex max-h-[60vh] w-1/2 flex-col gap-4 overflow-y-scroll border-l border-neutral p-6">
478478
<div className="flex w-full items-center justify-between">
479-
<span className="font-semibold text-neutral-400">Selected instance types: {dataFiltered.length}</span>
479+
<span className="font-semibold text-neutral">Selected instance types: {dataFiltered.length}</span>
480480
<Tooltip
481481
classNameContent="max-w-80"
482482
content="Karpenter will create nodes based on the specified list of instance types. By selecting specific instance types, you can control the performance, cost, and architecture of the nodes in your cluster."
483483
>
484-
<span className="text-neutral-400">
484+
<span className="text-neutral">
485485
<Icon iconName="info-circle" iconStyle="regular" />
486486
</span>
487487
</Tooltip>
@@ -502,7 +502,7 @@ function KarpenterInstanceForm({
502502
</Callout.Root>
503503
)}
504504
{dataFiltered.length > 0 && (
505-
<div className="flex flex-wrap text-neutral-400">
505+
<div className="flex flex-wrap text-neutral">
506506
{(!extendSelection ? dataFiltered.slice(0, DISPLAY_LIMIT) : dataFiltered).map((instanceType, index) => (
507507
<span key={instanceType.name} className="mr-1 inline-block last:mr-0">
508508
{instanceType.name}

libs/domains/cloud-providers/feature/src/lib/karpenter-instance-type-preview/karpenter-instance-type-preview.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ export function KarpenterInstanceTypePreview({
3434
const families = requirements?.find((f) => f.key === 'InstanceFamily')
3535

3636
return (
37-
<div className={twMerge('flex flex-col gap-1 text-sm text-neutral-400', className)}>
37+
<div className={twMerge('flex flex-col gap-1 text-sm text-neutral', className)}>
3838
{defaultServiceArchitecture && architectures && architectures?.values.length > 0 && (
3939
<p className="font-normal">
4040
<span className="font-medium">

libs/domains/clusters/feature/src/lib/gpu-resources-settings/gpu-resources-settings.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,15 @@ export const GpuResourcesSettings = ({ cluster, clusterRegion = '' }: GpuResourc
1717
const watchKarpenter = watch('karpenter')
1818

1919
return (
20-
<div className="flex border-t border-neutral-250 p-4 text-sm font-medium text-neutral-400">
20+
<div className="flex border-t border-neutral p-4 text-sm font-medium text-neutral">
2121
<div className="w-full">
2222
<p className="mb-2">
2323
Instance types scope{' '}
2424
<Tooltip
2525
classNameContent="max-w-80"
2626
content="Karpenter will create nodes based on the specified list of instance types. By selecting specific instance types, you can control the performance, cost, and architecture of the nodes in your cluster."
2727
>
28-
<span className="text-neutral-400">
28+
<span className="text-neutral-subtle">
2929
<Icon iconName="info-circle" iconStyle="regular" />
3030
</span>
3131
</Tooltip>

libs/domains/clusters/feature/src/lib/nodepools-resources-settings/nodepool-modal/nodepool-modal.tsx

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ function LimitsFields({ prefix }: { prefix: OverridePrefix }) {
3939
classNameContent="w-80"
4040
content={`This section is dedicated to configuring the ${prefix === 'gpu_override' ? 'CPU, GPU and memory' : 'CPU and memory'} limits for the Nodepool. Nodes can be deployed within these limits, ensuring that their total resources do not exceed the defined maximum. This configuration helps prevent unlimited resource allocation, avoiding excessive costs.`}
4141
>
42-
<span className="text-neutral-400">
42+
<span className="text-neutral-subtle">
4343
<Icon iconName="circle-info" iconStyle="regular" />
4444
</span>
4545
</Tooltip>
@@ -246,29 +246,29 @@ export function NodepoolModal({ type, cluster, onChange, defaultValues }: Nodepo
246246
onClose={closeModal}
247247
submitLabel="Confirm"
248248
>
249-
<div className="mb-6 flex flex-col gap-4 rounded border border-neutral-250 bg-neutral-100 p-4">
249+
<div className="mb-6 flex flex-col gap-4 rounded border border-neutral bg-surface-neutral p-4">
250250
<LimitsFields prefix={prefix} />
251251
</div>
252252
{match(prefix)
253253
.with('default_override', () => (
254-
<div className="flex flex-col gap-4 rounded border border-neutral-250 bg-neutral-100 p-4">
254+
<div className="flex flex-col gap-4 rounded border border-neutral bg-surface-neutral p-4">
255255
<div className="flex gap-3">
256256
<Tooltip content="Consolidation cannot be disabled on this NodePool">
257257
<span>
258258
<InputToggle value={true} forceAlignTop disabled small />
259259
</span>
260260
</Tooltip>
261261
<div className="relative -top-0.5 text-sm">
262-
<p className="font-medium text-neutral-400">Operates every day, 24 hours a day</p>
263-
<span className="text-neutral-350">
262+
<p className="font-medium text-neutral">Operates every day, 24 hours a day</p>
263+
<span className="text-neutral-subtle">
264264
Define when consolidation occurs to optimize resource usage by reducing the number of active nodes.
265265
</span>
266266
</div>
267267
</div>
268268
</div>
269269
))
270270
.with('stable_override', 'gpu_override', (prefix) => (
271-
<div className="flex flex-col gap-4 rounded border border-neutral-250 bg-neutral-100 p-4">
271+
<div className="flex flex-col gap-4 rounded border border-neutral bg-surface-neutral p-4">
272272
<Controller
273273
name={`${prefix}.consolidation.enabled`}
274274
control={methods.control}
@@ -286,7 +286,7 @@ export function NodepoolModal({ type, cluster, onChange, defaultValues }: Nodepo
286286
classNameContent="w-80"
287287
content="Consolidation optimizes resource usage by consolidating workloads onto fewer nodes. This schedule applies to Nodepools used by Qovery’s internal applications and single-instance applications (like container databases)"
288288
>
289-
<span className="text-neutral-400">
289+
<span className="text-neutral-subtle">
290290
<Icon iconName="circle-info" iconStyle="regular" />
291291
</span>
292292
</Tooltip>

0 commit comments

Comments
 (0)