diff --git a/ui/apps/everest/.e2e/pr/db-cluster/db-wizard/create-db-cluster/affinity.e2e.ts b/ui/apps/everest/.e2e/pr/db-cluster/db-wizard/create-db-cluster/affinity.e2e.ts new file mode 100644 index 000000000..a279a3c7a --- /dev/null +++ b/ui/apps/everest/.e2e/pr/db-cluster/db-wizard/create-db-cluster/affinity.e2e.ts @@ -0,0 +1,241 @@ +import { expect, Page, test } from '@playwright/test'; +import { selectDbEngine } from '../db-wizard-utils'; +import { moveForward, submitWizard } from '@e2e/utils/db-wizard'; +import { findDbAndClickRow } from '@e2e/utils/db-clusters-list'; +import { deleteDbClusterFn } from '@e2e/utils/db-cluster'; + +type AffinityRuleFormArgs = { + component?: 'DB Node' | 'Proxy' | 'Router' | 'PG Bouncer' | 'Config Server'; + type?: 'Node affinity' | 'Pod affinity' | 'Pod anti-affinity'; + preference?: 'preferred' | 'required'; + weight?: string; + topologyKey?: string; + key?: string; + operator?: 'in' | 'not in' | 'exists' | 'does not exist'; + values?: string; +}; + +const fillAffinityRuleForm = async ( + page: Page, + { + weight, + topologyKey, + key, + operator, + values, + component, + type, + preference, + }: AffinityRuleFormArgs +) => { + if (preference) { + await page.getByTestId(`toggle-button-${preference}`).click(); + } + + if (type) { + await page.getByTestId('select-type-button').click(); + await page.getByRole('option', { name: type, exact: true }).click(); + } + + if (component) { + await page.getByTestId('select-component-button').click(); + await page.getByRole('option', { name: component, exact: true }).click(); + } + + if (weight) { + await page.getByTestId('text-input-weight').fill(weight); + } + + if (topologyKey) { + await page.getByTestId('text-input-topology-key').fill(topologyKey); + } + + if (key) { + await page.getByTestId('text-input-key').fill(key); + } + + if (operator) { + await page.getByTestId('select-operator-button').click(); + await page.getByRole('option', { name: operator, exact: true }).click(); + } + + if (values) { + await page.getByTestId('text-input-values').fill(values); + } +}; + +const addAffinityRule = async ( + page: Page, + affinityRuleFormArgs: AffinityRuleFormArgs +) => { + await page.getByTestId('create-affinity').click(); + await fillAffinityRuleForm(page, affinityRuleFormArgs); + await page.getByTestId('form-dialog-add-rule').click(); +}; + +const editAffinityRule = async ( + page: Page, + ruleIdx: number, + affinityRuleFormArgs: AffinityRuleFormArgs +) => { + const rule = page.getByTestId('editable-item').nth(ruleIdx); + await rule.getByTestId('edit-editable-item-button-affinity-rule').click(); + await fillAffinityRuleForm(page, affinityRuleFormArgs); + await page.getByTestId('form-dialog-edit-rule').click(); +}; + +test.describe('Affinity via wizard', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/databases'); + }); + + test('Affinity rule creation', async ({ page }) => { + await selectDbEngine(page, 'psmdb'); + await page.getByTestId('switch-input-sharding').check(); + await moveForward(page); + await moveForward(page); + await moveForward(page); + await page.getByText('Affinity', { exact: true }).waitFor(); + + const configServerRules = page.getByTestId('config-server-rules'); + const dbNodeRules = page.getByTestId('db-node-rules'); + const proxyRules = page.getByTestId('proxy-rules'); + await expect(configServerRules).toBeVisible(); + await expect(dbNodeRules).toBeVisible(); + await expect(proxyRules).toBeVisible(); + expect( + await configServerRules + .getByTestId('config-server-rules-list') + .getByTestId('editable-item') + .count() + ).toBe(1); + expect( + await dbNodeRules + .getByTestId('db-node-rules-list') + .getByTestId('editable-item') + .count() + ).toBe(1); + expect( + await proxyRules + .getByTestId('proxy-rules-list') + .getByTestId('editable-item') + .count() + ).toBe(1); + + expect( + await page.getByTestId('config-server-rules-list').textContent() + ).toBe('podAntiAffinity | kubernetes.io/hostnamePreferred - 1'); + expect(await page.getByTestId('db-node-rules-list').textContent()).toBe( + 'podAntiAffinity | kubernetes.io/hostnamePreferred - 1' + ); + expect(await page.getByTestId('proxy-rules-list').textContent()).toBe( + 'podAntiAffinity | kubernetes.io/hostnamePreferred - 1' + ); + + do { + const deleteIcons = await page + .getByTestId('delete-editable-item-button-affinity-rule') + .all(); + if (deleteIcons.length === 0) { + break; + } + await deleteIcons[0].click(); + await page.getByText('Delete affinity rule').waitFor(); + await page.getByTestId('confirm-dialog-delete').click(); + } while (1); + + await addAffinityRule(page, { + component: 'DB Node', + type: 'Node affinity', + preference: 'preferred', + weight: '2', + key: 'my-key', + operator: 'in', + values: 'val1, val2', + }); + await addAffinityRule(page, { + component: 'Router', + type: 'Pod affinity', + preference: 'required', + topologyKey: 'my-topology-key', + key: 'my-key', + operator: 'does not exist', + }); + await addAffinityRule(page, { + component: 'Config Server', + type: 'Pod anti-affinity', + preference: 'preferred', + topologyKey: 'my-topology-key', + weight: '3', + }); + + const addedRules = page.getByTestId('editable-item'); + const nrAddedRules = await addedRules.count(); + expect(nrAddedRules).toBe(3); + expect(await addedRules.nth(0).textContent()).toBe( + 'podAntiAffinity | my-topology-keyPreferred - 3' + ); + expect(await addedRules.nth(1).textContent()).toBe( + 'nodeAffinity | kubernetes.io/hostname | my-key | In | val1,val2Preferred - 2' + ); + expect(await addedRules.nth(2).textContent()).toBe( + 'podAffinity | my-topology-key | my-key | DoesNotExistRequired' + ); + await editAffinityRule(page, 1, { + preference: 'required', + }); + expect(await addedRules.nth(1).textContent()).toBe( + 'nodeAffinity | kubernetes.io/hostname | my-key | In | val1,val2Required' + ); + }); +}); + +test.describe('Affinity via components page', () => { + const dbName = 'affinity-db-test'; + test.beforeAll(async ({ browser }) => { + const page = await browser.newPage(); + await page.goto('/databases'); + await selectDbEngine(page, 'pxc'); + await page.getByTestId('text-input-db-name').fill(dbName); + await moveForward(page); + await page.getByTestId('toggle-button-nodes-1').click(); + await page.getByTestId('text-input-memory').fill('1'); + await page.getByTestId('text-input-disk').fill('1'); + await moveForward(page); + await moveForward(page); + await moveForward(page); + await submitWizard(page); + }); + test.afterAll(async ({ request }) => { + await deleteDbClusterFn(request, dbName); + }); + test('Interaction with rules', async ({ page }) => { + await page.goto('/databases'); + await findDbAndClickRow(page, dbName); + await page.getByTestId('components').click(); + await page.getByTestId('db-node-rules').waitFor(); + await page.getByTestId('proxy-rules').waitFor(); + await expect(page.getByTestId('editable-item')).toHaveCount(2); + await page + .getByTestId('delete-editable-item-button-affinity-rule') + .nth(0) + .click(); + await page.getByTestId('confirm-dialog-delete').click(); + // Only proxy rule should be left now + await expect(page.getByTestId('editable-item')).toHaveCount(1); + await expect(page.getByTestId('proxy-rules')).toBeVisible(); + await editAffinityRule(page, 0, { + type: 'Pod affinity', + preference: 'required', + topologyKey: 'my-topology-key', + key: 'my-key', + operator: 'not in', + values: 'val1, val2', + }); + await page + .getByText( + 'podAffinity | my-topology-key | my-key | NotIn | val1,val2Required' + ) + .waitFor(); + }); +}); diff --git a/ui/apps/everest/src/api/api.ts b/ui/apps/everest/src/api/api.ts index bef0c91e8..ee0620b7c 100644 --- a/ui/apps/everest/src/api/api.ts +++ b/ui/apps/everest/src/api/api.ts @@ -12,7 +12,7 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. -import axios from 'axios'; +import axios, { AxiosError } from 'axios'; import { enqueueSnackbar } from 'notistack'; const BASE_URL = '/v1/'; @@ -29,13 +29,18 @@ export const addApiErrorInterceptor = () => { if (errorInterceptor === null) { errorInterceptor = api.interceptors.response.use( (response) => response, - (error) => { + (error: AxiosError<{ message: string }>) => { if ( error.response && error.response.status >= 400 && error.response.status <= 500 ) { let message = DEFAULT_ERROR_MESSAGE; + let notificationsDisabled = error.config?.disableNotifications; + + if (typeof notificationsDisabled === 'function') { + notificationsDisabled = notificationsDisabled(error); + } if (error.response.status === 401) { localStorage.removeItem('everestToken'); @@ -43,7 +48,7 @@ export const addApiErrorInterceptor = () => { location.replace('/login'); } - if (error.config?.disableNotifications !== true) { + if (!notificationsDisabled) { if (error.response.data && error.response.data.message) { if ( error.response.data.message.length > MAX_ERROR_MESSAGE_LENGTH diff --git a/ui/apps/everest/src/api/axios.d.ts b/ui/apps/everest/src/api/axios.d.ts index ad75bedd9..a2e7a3a35 100644 --- a/ui/apps/everest/src/api/axios.d.ts +++ b/ui/apps/everest/src/api/axios.d.ts @@ -2,6 +2,6 @@ import 'axios'; declare module 'axios' { export interface AxiosRequestConfig { - disableNotifications?: boolean; + disableNotifications?: boolean | ((error: AxiosError) => boolean); } } diff --git a/ui/apps/everest/src/api/dbClusterApi.ts b/ui/apps/everest/src/api/dbClusterApi.ts index d6b763aa1..287f78de2 100644 --- a/ui/apps/everest/src/api/dbClusterApi.ts +++ b/ui/apps/everest/src/api/dbClusterApi.ts @@ -36,7 +36,10 @@ export const updateDbClusterFn = async ( ) => { const response = await api.put( `namespaces/${namespace}/database-clusters/${dbClusterName}`, - data + data, + { + disableNotifications: (e) => e.status === 409, + } ); return response.data; diff --git a/ui/apps/everest/src/components/cluster-form/advanced-configuration/advanced-configuration-schema.ts b/ui/apps/everest/src/components/cluster-form/advanced-configuration/advanced-configuration-schema.ts index e5ad8b92c..fb7ee74b6 100644 --- a/ui/apps/everest/src/components/cluster-form/advanced-configuration/advanced-configuration-schema.ts +++ b/ui/apps/everest/src/components/cluster-form/advanced-configuration/advanced-configuration-schema.ts @@ -17,6 +17,7 @@ import { z } from 'zod'; import { AdvancedConfigurationFields } from './advanced-configuration.types'; import { IP_REGEX } from 'consts'; import { Messages } from './messages'; +import { AffinityRule } from 'shared-types/affinity.types'; export const advancedConfigurationsSchema = () => z @@ -27,6 +28,7 @@ export const advancedConfigurationsSchema = () => ), [AdvancedConfigurationFields.engineParametersEnabled]: z.boolean(), [AdvancedConfigurationFields.engineParameters]: z.string().optional(), + [AdvancedConfigurationFields.affinityRules]: z.custom<AffinityRule[]>(), }) .passthrough() .superRefine(({ sourceRanges }, ctx) => { diff --git a/ui/apps/everest/src/components/cluster-form/advanced-configuration/advanced-configuration.tsx b/ui/apps/everest/src/components/cluster-form/advanced-configuration/advanced-configuration.tsx index 5bc4a815c..73a869277 100644 --- a/ui/apps/everest/src/components/cluster-form/advanced-configuration/advanced-configuration.tsx +++ b/ui/apps/everest/src/components/cluster-form/advanced-configuration/advanced-configuration.tsx @@ -13,67 +13,104 @@ // See the License for the specific language governing permissions and // limitations under the License. +import { Stack } from '@mui/material'; +import { DbType } from '@percona/types'; import { SwitchInput, TextArray, TextInput } from '@percona/ui-lib'; -import { Messages } from './messages'; -import { AdvancedConfigurationFields } from './advanced-configuration.types'; +import { AffinityListView } from 'components/cluster-form/affinity/affinity-list-view/affinity-list.view'; import { useFormContext } from 'react-hook-form'; -import { DbType } from '@percona/types'; +import { AdvancedConfigurationFields } from './advanced-configuration.types'; import { getParamsPlaceholderFromDbType } from './advanced-configuration.utils'; -import { Stack } from '@mui/material'; +import { Messages } from './messages'; +import { DbWizardForm } from 'consts'; +import { AffinityRule } from 'shared-types/affinity.types'; +import { useCallback } from 'react'; +import RoundedBox from 'components/rounded-box'; interface AdvancedConfigurationFormProps { dbType: DbType; + showAffinity?: boolean; } export const AdvancedConfigurationForm = ({ dbType, + showAffinity = false, }: AdvancedConfigurationFormProps) => { - const { watch } = useFormContext(); - const [externalAccess, engineParametersEnabled] = watch([ + const { watch, setValue } = useFormContext(); + + const [ + externalAccess, + engineParametersEnabled, + formAffinityRules, + isShardingEnabled, + ] = watch([ AdvancedConfigurationFields.externalAccess, AdvancedConfigurationFields.engineParametersEnabled, + AdvancedConfigurationFields.affinityRules, + DbWizardForm.sharding, ]); + const onRulesChange = useCallback( + (newRules: AffinityRule[]) => { + setValue(AdvancedConfigurationFields.affinityRules, newRules, { + shouldTouch: true, + shouldDirty: true, + }); + }, + [setValue] + ); + return ( <> - <SwitchInput - label={Messages.enableExternalAccess.title} - labelCaption={Messages.enableExternalAccess.caption} - name={AdvancedConfigurationFields.externalAccess} - /> - {externalAccess && ( - <Stack sx={{ ml: 6 }}> - <TextArray - placeholder={Messages.sourceRangePlaceholder} - fieldName={AdvancedConfigurationFields.sourceRanges} - fieldKey="sourceRange" - label={Messages.sourceRange} - /> - </Stack> + {showAffinity && ( + <AffinityListView + initialRules={formAffinityRules} + onRulesChange={onRulesChange} + dbType={dbType} + isShardingEnabled={isShardingEnabled} + /> )} - <SwitchInput - label={Messages.engineParameters.title} - labelCaption={Messages.engineParameters.caption} - name={AdvancedConfigurationFields.engineParametersEnabled} - formControlLabelProps={{ - sx: { - mt: 1, - }, - }} - /> - {engineParametersEnabled && ( - <TextInput - name={AdvancedConfigurationFields.engineParameters} - textFieldProps={{ - placeholder: getParamsPlaceholderFromDbType(dbType), - multiline: true, - minRows: 3, + <RoundedBox> + <SwitchInput + label={Messages.enableExternalAccess.title} + labelCaption={Messages.enableExternalAccess.caption} + name={AdvancedConfigurationFields.externalAccess} + /> + {externalAccess && ( + <Stack sx={{ ml: 6 }}> + <TextArray + placeholder={Messages.sourceRangePlaceholder} + fieldName={AdvancedConfigurationFields.sourceRanges} + fieldKey="sourceRange" + label={Messages.sourceRange} + /> + </Stack> + )} + </RoundedBox> + <RoundedBox> + <SwitchInput + label={Messages.engineParameters.title} + labelCaption={Messages.engineParameters.caption} + name={AdvancedConfigurationFields.engineParametersEnabled} + formControlLabelProps={{ sx: { - ml: 6, + mt: 1, }, }} /> - )} + {engineParametersEnabled && ( + <TextInput + name={AdvancedConfigurationFields.engineParameters} + textFieldProps={{ + placeholder: getParamsPlaceholderFromDbType(dbType), + multiline: true, + minRows: 3, + sx: { + ml: 6, + }, + }} + /> + )} + </RoundedBox> </> ); }; diff --git a/ui/apps/everest/src/components/cluster-form/advanced-configuration/advanced-configuration.types.ts b/ui/apps/everest/src/components/cluster-form/advanced-configuration/advanced-configuration.types.ts index b6a491c9f..adc5d7943 100644 --- a/ui/apps/everest/src/components/cluster-form/advanced-configuration/advanced-configuration.types.ts +++ b/ui/apps/everest/src/components/cluster-form/advanced-configuration/advanced-configuration.types.ts @@ -18,4 +18,5 @@ export enum AdvancedConfigurationFields { sourceRanges = 'sourceRanges', engineParametersEnabled = 'engineParametersEnabled', engineParameters = 'engineParameters', + affinityRules = 'affinityRules', } diff --git a/ui/apps/everest/src/components/cluster-form/advanced-configuration/advanced-configuration.utils.ts b/ui/apps/everest/src/components/cluster-form/advanced-configuration/advanced-configuration.utils.ts index 3c9d4cbb2..a8419f3c4 100644 --- a/ui/apps/everest/src/components/cluster-form/advanced-configuration/advanced-configuration.utils.ts +++ b/ui/apps/everest/src/components/cluster-form/advanced-configuration/advanced-configuration.utils.ts @@ -17,7 +17,7 @@ import { DbType } from '@percona/types'; import { DbCluster, ProxyExposeType } from 'shared-types/dbCluster.types'; import { AdvancedConfigurationFields } from './advanced-configuration.types'; import { AdvancedConfigurationFormType } from './advanced-configuration-schema'; -import { isProxy } from 'utils/db'; +import { dbPayloadToAffinityRules, isProxy } from 'utils/db'; export const getParamsPlaceholderFromDbType = (dbType: DbType) => { let dynamicText = ''; @@ -60,5 +60,7 @@ export const advancedConfigurationModalDefaultValues = ( [AdvancedConfigurationFields.sourceRanges]: sourceRangesSource ? sourceRangesSource.map((sourceRange) => ({ sourceRange })) : [{ sourceRange: '' }], + [AdvancedConfigurationFields.affinityRules]: + dbPayloadToAffinityRules(dbCluster), }; }; diff --git a/ui/apps/everest/src/components/cluster-form/advanced-configuration/affinity/affinity-form.messages.ts b/ui/apps/everest/src/components/cluster-form/advanced-configuration/affinity/affinity-form.messages.ts new file mode 100644 index 000000000..068a8444f --- /dev/null +++ b/ui/apps/everest/src/components/cluster-form/advanced-configuration/affinity/affinity-form.messages.ts @@ -0,0 +1,5 @@ +export const Messages = { + description: + 'Create a new affinity rule to control how your database workloads are allocated across your system. Use this rule to enhance performance, improve resource management, or ensure high availability based on your deployment needs.', + learnMore: 'Learn more', +}; diff --git a/ui/apps/everest/src/components/cluster-form/affinity/affinity-form-dialog/affinity-form-dialog-context/affinity-form-context.tsx b/ui/apps/everest/src/components/cluster-form/affinity/affinity-form-dialog/affinity-form-dialog-context/affinity-form-context.tsx new file mode 100644 index 000000000..f7534f13d --- /dev/null +++ b/ui/apps/everest/src/components/cluster-form/affinity/affinity-form-dialog/affinity-form-dialog-context/affinity-form-context.tsx @@ -0,0 +1,15 @@ +import { createContext } from 'react'; +import { AffinityFormDialogContextType } from './affinity-form-dialog-context.types'; +import { DbType } from '@percona/types'; + +export const AffinityFormDialogContext = + createContext<AffinityFormDialogContextType>({ + handleSubmit: () => {}, + handleClose: () => {}, + selectedAffinityUid: null, + setOpenAffinityModal: () => {}, + openAffinityModal: false, + affinityRules: [], + dbType: DbType.Mongo, + isShardingEnabled: false, + }); diff --git a/ui/apps/everest/src/components/cluster-form/affinity/affinity-form-dialog/affinity-form-dialog-context/affinity-form-dialog-context.types.ts b/ui/apps/everest/src/components/cluster-form/affinity/affinity-form-dialog/affinity-form-dialog-context/affinity-form-dialog-context.types.ts new file mode 100644 index 000000000..69b35d8c5 --- /dev/null +++ b/ui/apps/everest/src/components/cluster-form/affinity/affinity-form-dialog/affinity-form-dialog-context/affinity-form-dialog-context.types.ts @@ -0,0 +1,14 @@ +import { AffinityRule } from 'shared-types/affinity.types'; +import { AffinityFormData } from '../affinity-form/affinity-form.types'; +import { DbType } from '@percona/types'; + +export type AffinityFormDialogContextType = { + handleSubmit: (data: AffinityFormData) => void; + handleClose: () => void; + selectedAffinityUid: string | null; + setOpenAffinityModal: React.Dispatch<React.SetStateAction<boolean>>; + openAffinityModal: boolean; + affinityRules: AffinityRule[]; + dbType: DbType; + isShardingEnabled: boolean; +}; diff --git a/ui/apps/everest/src/components/cluster-form/affinity/affinity-form-dialog/affinity-form-dialog.messages.ts b/ui/apps/everest/src/components/cluster-form/affinity/affinity-form-dialog/affinity-form-dialog.messages.ts new file mode 100644 index 000000000..9312c2d0c --- /dev/null +++ b/ui/apps/everest/src/components/cluster-form/affinity/affinity-form-dialog/affinity-form-dialog.messages.ts @@ -0,0 +1 @@ +export const Messages = { addRule: 'Add rule', editRule: 'Edit rule' }; diff --git a/ui/apps/everest/src/components/cluster-form/affinity/affinity-form-dialog/affinity-form-dialog.test.tsx b/ui/apps/everest/src/components/cluster-form/affinity/affinity-form-dialog/affinity-form-dialog.test.tsx new file mode 100644 index 000000000..74def8168 --- /dev/null +++ b/ui/apps/everest/src/components/cluster-form/affinity/affinity-form-dialog/affinity-form-dialog.test.tsx @@ -0,0 +1,296 @@ +import { DbType } from '@percona/types'; +import { + fireEvent, + render, + screen, + waitFor, + within, +} from '@testing-library/react'; +import { AffinityFormDialogContext } from './affinity-form-dialog-context/affinity-form-context'; +import { AffinityFormDialogContextType } from './affinity-form-dialog-context/affinity-form-dialog-context.types'; +import { AffinityFormDialog } from './affinity-form-dialog'; + +const contextDefaultProps: AffinityFormDialogContextType = { + openAffinityModal: true, + handleClose: () => {}, + handleSubmit: () => {}, + selectedAffinityUid: null, + setOpenAffinityModal: () => {}, + affinityRules: [], + dbType: DbType.Mongo, + isShardingEnabled: false, +}; + +const Wrapper = ({ + contextProps, +}: { + contextProps?: Partial<AffinityFormDialogContextType>; +}) => { + return ( + <AffinityFormDialogContext.Provider + value={{ + ...contextDefaultProps, + ...contextProps, + }} + > + <AffinityFormDialog /> + </AffinityFormDialogContext.Provider> + ); +}; + +const selectTypeOption = (optionText: string) => { + fireEvent.mouseDown( + within(screen.getByTestId('select-type-button')).getByRole('combobox') + ); + const option = screen + .getAllByRole('option') + .find((o) => o.textContent === optionText); + + expect(option).toBeDefined(); + fireEvent.click(option!); +}; + +const selectOperatorOption = (optionText: string) => { + fireEvent.mouseDown( + within(screen.getByTestId('select-operator-button')).getByRole('combobox') + ); + const option = screen + .getAllByRole('option') + .find((o) => o.textContent === optionText); + + expect(option).toBeDefined(); + fireEvent.click(option!); +}; + +describe('AffinityFormDialog', () => { + describe('MongoDB', () => { + test('show defaults', () => { + render(<Wrapper />); + expect(screen.getByTestId('select-input-component')).toHaveValue( + 'dbNode' + ); + expect(screen.getByTestId('select-input-type')).toHaveValue( + 'podAntiAffinity' + ); + expect(screen.getByTestId('text-input-weight')).toHaveValue(1); + expect(screen.getByTestId('text-input-topology-key')).toHaveValue( + 'kubernetes.io/hostname' + ); + }); + + test('sharding disabled only allows Db Node component', async () => { + render(<Wrapper />); + expect(screen.getByTestId('select-input-component')).toHaveValue( + 'dbNode' + ); + fireEvent.mouseDown( + within(screen.getByTestId('select-component-button')).getByRole( + 'combobox' + ) + ); + await waitFor(() => + expect(screen.getByTestId('dbNode')).toBeInTheDocument() + ); + const options = screen.getAllByRole('option'); + expect(options).toHaveLength(1); + expect(options[0]).toHaveTextContent('DB Node'); + }); + + test('sharding enabled allows all components', async () => { + render( + <Wrapper + contextProps={{ + isShardingEnabled: true, + }} + /> + ); + expect(screen.getByTestId('select-input-component')).toHaveValue( + 'dbNode' + ); + fireEvent.mouseDown( + within(screen.getByTestId('select-component-button')).getByRole( + 'combobox' + ) + ); + await waitFor(() => + expect(screen.getByTestId('dbNode')).toBeInTheDocument() + ); + const options = screen.getAllByRole('option'); + expect(options).toHaveLength(3); + ['DB Node', 'Config Server', 'Router'].forEach((component) => + expect( + options.some((option) => option.textContent === component) + ).toBeTruthy() + ); + }); + }); + + describe('PostgreSQL', () => { + test('sharding enabled allows all components', async () => { + render( + <Wrapper + contextProps={{ + dbType: DbType.Postresql, + }} + /> + ); + expect(screen.getByTestId('select-input-component')).toHaveValue( + 'dbNode' + ); + fireEvent.mouseDown( + within(screen.getByTestId('select-component-button')).getByRole( + 'combobox' + ) + ); + await waitFor(() => + expect(screen.getByTestId('dbNode')).toBeInTheDocument() + ); + const options = screen.getAllByRole('option'); + expect(options).toHaveLength(2); + ['DB Node', 'PG Bouncer'].forEach((component) => + expect( + options.some((option) => option.textContent === component) + ).toBeTruthy() + ); + }); + }); + + describe('Node Affinity', () => { + test('Mandatory key and operator', async () => { + render(<Wrapper />); + + await waitFor(() => + expect(screen.getByTestId('form-dialog-add-rule')).not.toBeDisabled() + ); + selectTypeOption('Node affinity'); + + await waitFor(() => + expect(screen.getByTestId('text-input-key')).toBeInvalid() + ); + expect( + screen.queryByTestId('text-input-topology-key') + ).not.toBeInTheDocument(); + expect(screen.getByTestId('form-dialog-add-rule')).toBeDisabled(); + expect(screen.getByTestId('text-input-key')).toHaveValue(''); + + fireEvent.change(screen.getByTestId('text-input-key'), { + target: { value: 'my-key' }, + }); + + await waitFor(() => + expect(screen.getByTestId('text-input-key')).not.toBeInvalid() + ); + + expect(screen.getByTestId('select-input-operator')).toHaveValue(''); + expect(screen.getByTestId('select-input-operator')).toBeInvalid(); + expect(screen.getByTestId('form-dialog-add-rule')).toBeDisabled(); + + selectOperatorOption('exists'); + + await waitFor(() => + expect(screen.getByTestId('select-input-operator')).not.toBeInvalid() + ); + expect(screen.getByTestId('select-input-operator')).toHaveValue('Exists'); + expect(screen.getByTestId('form-dialog-add-rule')).not.toBeDisabled(); + }); + + test('Mandatory value if operator is "In" or "NotIn"', async () => { + render(<Wrapper />); + selectTypeOption('Node affinity'); + fireEvent.change(screen.getByTestId('text-input-key'), { + target: { value: 'my-key' }, + }); + selectOperatorOption('in'); + + await waitFor(() => + expect(screen.getByTestId('form-dialog-add-rule')).toBeDisabled() + ); + + fireEvent.change(screen.getByTestId('text-input-values'), { + target: { value: 'val1,val2' }, + }); + + await waitFor(() => + expect(screen.getByTestId('form-dialog-add-rule')).not.toBeDisabled() + ); + + fireEvent.change(screen.getByTestId('text-input-values'), { + target: { value: '' }, + }); + + await waitFor(() => + expect(screen.getByTestId('form-dialog-add-rule')).toBeDisabled() + ); + + selectOperatorOption('not in'); + + await waitFor(() => + expect(screen.getByTestId('form-dialog-add-rule')).toBeDisabled() + ); + + fireEvent.change(screen.getByTestId('text-input-values'), { + target: { value: 'val1,val2' }, + }); + + await waitFor(() => + expect(screen.getByTestId('form-dialog-add-rule')).not.toBeDisabled() + ); + }); + }); + + describe('Pod (Anti-)Affinity', () => { + ['Pod affinity', 'Pod anti-affinity'].forEach((type) => { + test(`Mandatory topology key for ${type}`, async () => { + render(<Wrapper />); + selectTypeOption(type); + + await waitFor(() => + expect(screen.getByTestId('form-dialog-add-rule')).not.toBeDisabled() + ); + fireEvent.change(screen.getByTestId('text-input-topology-key'), { + target: { value: '' }, + }); + await waitFor(() => + expect(screen.getByTestId('form-dialog-add-rule')).toBeDisabled() + ); + fireEvent.change(screen.getByTestId('text-input-topology-key'), { + target: { value: 'toplogy-key' }, + }); + await waitFor(() => + expect(screen.getByTestId('form-dialog-add-rule')).not.toBeDisabled() + ); + }); + + test(`Operator and values mandatory if key not empty for ${type}`, async () => { + render(<Wrapper />); + selectTypeOption(type); + await waitFor(() => + expect(screen.getByTestId('form-dialog-add-rule')).not.toBeDisabled() + ); + expect(screen.getByTestId('select-input-operator')).toBeDisabled(); + fireEvent.change(screen.getByTestId('text-input-key'), { + target: { value: 'my-key' }, + }); + await waitFor(() => + expect(screen.getByTestId('form-dialog-add-rule')).toBeDisabled() + ); + expect(screen.getByTestId('select-input-operator')).not.toBeDisabled(); + expect(screen.getByTestId('select-input-operator')).toBeInvalid(); + selectOperatorOption('exists'); + await waitFor(() => + expect(screen.getByTestId('form-dialog-add-rule')).not.toBeDisabled() + ); + selectOperatorOption('in'); + await waitFor(() => + expect(screen.getByTestId('form-dialog-add-rule')).toBeDisabled() + ); + fireEvent.change(screen.getByTestId('text-input-values'), { + target: { value: 'val1' }, + }); + await waitFor(() => + expect(screen.getByTestId('form-dialog-add-rule')).not.toBeDisabled() + ); + }); + }); + }); +}); diff --git a/ui/apps/everest/src/components/cluster-form/affinity/affinity-form-dialog/affinity-form-dialog.tsx b/ui/apps/everest/src/components/cluster-form/affinity/affinity-form-dialog/affinity-form-dialog.tsx new file mode 100644 index 000000000..a7fb5883a --- /dev/null +++ b/ui/apps/everest/src/components/cluster-form/affinity/affinity-form-dialog/affinity-form-dialog.tsx @@ -0,0 +1,46 @@ +import { FormDialog } from 'components/form-dialog'; +import { Messages } from './affinity-form-dialog.messages'; +import { AffinityFormDialogContext } from './affinity-form-dialog-context/affinity-form-context'; +import { affinityFormSchema } from './affinity-form/affinity-form.types'; +import { affinityModalDefaultValues } from './affinity-form-dialog.utils'; +import { AffinityForm } from './affinity-form/affinity-form'; +import { useContext, useMemo } from 'react'; + +export const AffinityFormDialog = () => { + const { + openAffinityModal, + handleClose, + handleSubmit, + affinityRules, + selectedAffinityUid, + } = useContext(AffinityFormDialogContext); + + const isEditing = selectedAffinityUid !== null; + + const selectedAffinityRule = useMemo(() => { + if (isEditing) { + return affinityRules.find(({ uid }) => uid === selectedAffinityUid); + } + }, [affinityRules, isEditing, selectedAffinityUid]); + + const values = useMemo(() => { + return affinityModalDefaultValues(selectedAffinityRule); + }, [selectedAffinityRule]); + + return ( + <FormDialog + schema={affinityFormSchema} + isOpen={!!openAffinityModal} + closeModal={handleClose} + headerMessage={isEditing ? Messages.editRule : Messages.addRule} + onSubmit={handleSubmit} + submitMessage={isEditing ? Messages.editRule : Messages.addRule} + {...(isEditing && { values })} + defaultValues={values} + size="XXL" + dataTestId={`affinity-form`} + > + <AffinityForm /> + </FormDialog> + ); +}; diff --git a/ui/apps/everest/src/components/cluster-form/affinity/affinity-form-dialog/affinity-form-dialog.utils.ts b/ui/apps/everest/src/components/cluster-form/affinity/affinity-form-dialog/affinity-form-dialog.utils.ts new file mode 100644 index 000000000..0599e1161 --- /dev/null +++ b/ui/apps/everest/src/components/cluster-form/affinity/affinity-form-dialog/affinity-form-dialog.utils.ts @@ -0,0 +1,47 @@ +import { + AffinityComponent, + AffinityPriority, + AffinityRule, + AffinityType, +} from 'shared-types/affinity.types'; +import { + AffinityFormData, + AffinityFormFields, +} from 'components/cluster-form/affinity/affinity-form-dialog/affinity-form/affinity-form.types'; + +export const affinityModalDefaultValues = ( + selectedRule?: AffinityRule +): AffinityFormData => { + if (selectedRule) { + const { + component, + type, + priority, + weight, + topologyKey, + key, + operator, + values, + } = selectedRule; + return { + [AffinityFormFields.component]: component, + [AffinityFormFields.type]: type, + [AffinityFormFields.priority]: priority, + [AffinityFormFields.weight]: parseInt(weight?.toString() || '1', 10), + [AffinityFormFields.topologyKey]: topologyKey || '', + [AffinityFormFields.key]: key || '', + [AffinityFormFields.operator]: operator || '', + [AffinityFormFields.values]: values, + }; + } + return { + [AffinityFormFields.component]: AffinityComponent.DbNode, + [AffinityFormFields.type]: AffinityType.PodAntiAffinity, + [AffinityFormFields.priority]: AffinityPriority.Preferred, + [AffinityFormFields.weight]: 1, + [AffinityFormFields.topologyKey]: 'kubernetes.io/hostname', + [AffinityFormFields.key]: '', + [AffinityFormFields.operator]: '', + [AffinityFormFields.values]: '', + }; +}; diff --git a/ui/apps/everest/src/components/cluster-form/affinity/affinity-form-dialog/affinity-form/affinity-form.tsx b/ui/apps/everest/src/components/cluster-form/affinity/affinity-form-dialog/affinity-form/affinity-form.tsx new file mode 100644 index 000000000..7a97abd97 --- /dev/null +++ b/ui/apps/everest/src/components/cluster-form/affinity/affinity-form-dialog/affinity-form/affinity-form.tsx @@ -0,0 +1,85 @@ +// everest +// Copyright (C) 2023 Percona LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { ArrowOutward } from '@mui/icons-material'; +import { Box, Button, Typography } from '@mui/material'; +import { useContext, useEffect } from 'react'; +import { AffinityPriority, AffinityType } from 'shared-types/affinity.types'; +import { Messages } from '../../../advanced-configuration/affinity/affinity-form.messages'; +import { AffinityFormDialogContext } from '../affinity-form-dialog-context/affinity-form-context'; +import { AffinityFormFields } from './affinity-form.types'; +import { useFormContext } from 'react-hook-form'; +import { RuleDetailsSection, RuleTypeSection } from './sections'; + +export const AffinityForm = () => { + const { dbType, isShardingEnabled, selectedAffinityUid } = useContext( + AffinityFormDialogContext + ); + const { watch, resetField, trigger } = useFormContext(); + const [operator, key, priority, type] = watch([ + AffinityFormFields.operator, + AffinityFormFields.key, + AffinityFormFields.priority, + AffinityFormFields.type, + ]); + + useEffect(() => { + resetField(AffinityFormFields.weight, { + keepError: false, + }); + }, [priority, resetField]); + + useEffect(() => { + trigger(); + }, [type, trigger]); + + return ( + <> + <Box sx={{ display: 'flex' }}> + <Typography variant="body2"> + {Messages.description} + <Button + data-testid="learn-more-button" + size="small" + sx={{ fontWeight: '600', paddingTop: 0 }} + onClick={() => { + window.open( + 'https://kubernetes.io/docs/concepts/scheduling-eviction/assign-pod-node/#affinity-and-anti-affinity', + '_blank', + 'noopener' + ); + }} + endIcon={<ArrowOutward />} + > + {Messages.learnMore} + </Button> + </Typography> + </Box> + <RuleTypeSection + dbType={dbType} + isShardingEnabled={isShardingEnabled} + disableComponent={selectedAffinityUid !== null} + showWeight={priority === AffinityPriority.Preferred} + /> + <RuleDetailsSection + showTopologyKey={type !== AffinityType.NodeAffinity} + operator={operator} + disableOperator={!key} + disableValue={!key} + affinityType={type} + /> + </> + ); +}; diff --git a/ui/apps/everest/src/components/cluster-form/affinity/affinity-form-dialog/affinity-form/affinity-form.types.ts b/ui/apps/everest/src/components/cluster-form/affinity/affinity-form-dialog/affinity-form/affinity-form.types.ts new file mode 100644 index 000000000..fcaa0de3c --- /dev/null +++ b/ui/apps/everest/src/components/cluster-form/affinity/affinity-form-dialog/affinity-form/affinity-form.types.ts @@ -0,0 +1,119 @@ +import { + AffinityComponent, + AffinityOperator, + AffinityPriority, + AffinityType, +} from 'shared-types/affinity.types'; +import { PerconaZodCustomIssue } from 'utils/common-validation'; +import { z } from 'zod'; + +enum AffinityForm { + component = 'component', + type = 'type', + priority = 'priority', + weight = 'weight', + topologyKey = 'topologyKey', + key = 'key', + operator = 'operator', + values = 'values', +} + +export const AffinityFormFields = AffinityForm; +const keys = ['', ...Object.keys(AffinityOperator)] as [string, ...string[]]; + +const baseSchema = z.object({ + [AffinityFormFields.component]: z.nativeEnum(AffinityComponent), + [AffinityFormFields.type]: z.nativeEnum(AffinityType), + [AffinityFormFields.priority]: z.nativeEnum(AffinityPriority), + [AffinityFormFields.weight]: z + .union([z.number(), z.string().transform((s) => parseInt(s, 10))]) + .optional(), + [AffinityFormFields.topologyKey]: z.string().optional(), + [AffinityFormFields.key]: z.string().optional(), + [AffinityFormFields.operator]: z.enum(keys).optional(), + [AffinityFormFields.values]: z + .string() + .trim() + .transform((v) => v.replace(/\s/g, '')) + .optional(), +}); + +const checkValuesPresenceForOperator = ( + ctx: z.RefinementCtx, + operator: AffinityOperator, + values: string | undefined +) => { + if ( + [AffinityOperator.In, AffinityOperator.NotIn].includes( + operator as AffinityOperator + ) && + !values + ) { + ctx.addIssue(PerconaZodCustomIssue.required(AffinityFormFields.values)); + } +}; + +// Zod won't validate until all form is filled, so we call preprocess in advance +export const affinityFormSchema = z.preprocess((input, ctx) => { + const { data } = baseSchema.safeParse(input); + if (data) { + const { type, priority, weight, topologyKey, key, operator, values } = data; + + if ( + priority === AffinityPriority.Preferred && + (weight === undefined || !(weight >= 1 && weight <= 100)) + ) { + ctx.addIssue( + PerconaZodCustomIssue.between(AffinityFormFields.weight, 1, 100) + ); + } + + if (type === AffinityType.NodeAffinity) { + // Key and Operator are required + if (!key || !operator) { + [AffinityFormFields.key, AffinityFormFields.operator].forEach( + (field) => { + if (!data[field]) { + ctx.addIssue(PerconaZodCustomIssue.required(field)); + } + } + ); + } else { + checkValuesPresenceForOperator( + ctx, + operator as AffinityOperator, + values + ); + } + } else { + if (!topologyKey) { + ctx.addIssue( + PerconaZodCustomIssue.required( + AffinityFormFields.topologyKey, + 'Topology Key' + ) + ); + } + + // Key and Operator are optional + // If key is set, operator is required + if (key) { + if (!operator) { + ctx.addIssue( + PerconaZodCustomIssue.required(AffinityFormFields.operator) + ); + } else { + checkValuesPresenceForOperator( + ctx, + operator as AffinityOperator, + values + ); + } + } + } + } + + return input; +}, baseSchema); + +export type AffinityFormData = z.infer<typeof affinityFormSchema>; diff --git a/ui/apps/everest/src/components/cluster-form/affinity/affinity-form-dialog/affinity-form/affinity-form.utils.tsx b/ui/apps/everest/src/components/cluster-form/affinity/affinity-form-dialog/affinity-form/affinity-form.utils.tsx new file mode 100644 index 000000000..f5d82cfad --- /dev/null +++ b/ui/apps/everest/src/components/cluster-form/affinity/affinity-form-dialog/affinity-form/affinity-form.utils.tsx @@ -0,0 +1,19 @@ +import { generateShortUID } from 'utils/generateShortUID'; +import { AffinityFormData, AffinityFormFields } from './affinity-form.types'; +import { AffinityOperator, AffinityRule } from 'shared-types/affinity.types'; + +export const convertFormDataToAffinityRule = ( + data: AffinityFormData +): AffinityRule => { + return { + component: data[AffinityFormFields.component], + type: data[AffinityFormFields.type], + priority: data[AffinityFormFields.priority], + key: data[AffinityFormFields.key], + topologyKey: data[AffinityFormFields.topologyKey], + weight: data[AffinityFormFields.weight], + operator: data[AffinityFormFields.operator] as AffinityOperator, + values: data[AffinityFormFields.values], + uid: generateShortUID(), + }; +}; diff --git a/ui/apps/everest/src/components/cluster-form/affinity/affinity-form-dialog/affinity-form/fields/component-input.tsx b/ui/apps/everest/src/components/cluster-form/affinity/affinity-form-dialog/affinity-form/fields/component-input.tsx new file mode 100644 index 000000000..52842f053 --- /dev/null +++ b/ui/apps/everest/src/components/cluster-form/affinity/affinity-form-dialog/affinity-form/fields/component-input.tsx @@ -0,0 +1,33 @@ +import { SelectInput } from '@percona/ui-lib'; +import { DbType } from '@percona/types'; +import { MenuItem } from '@mui/material'; +import { AffinityFormFields } from '../affinity-form.types'; +import { AffinityComponent } from 'shared-types/affinity.types'; +import { getAffinityComponentLabel } from 'utils/db'; + +type Props = { + disabled: boolean; + components: AffinityComponent[]; + dbType: DbType; +}; + +const ComponentInput = ({ disabled, components, dbType }: Props) => ( + <SelectInput + name={AffinityFormFields.component} + label="Component" + selectFieldProps={{ + label: 'Component', + sx: { width: '213px' }, + disabled, + }} + data-testid="component-select" + > + {components.map((value) => ( + <MenuItem key={value} value={value} data-testid={value}> + {getAffinityComponentLabel(dbType, value)} + </MenuItem> + ))} + </SelectInput> +); + +export default ComponentInput; diff --git a/ui/apps/everest/src/components/cluster-form/affinity/affinity-form-dialog/affinity-form/fields/index.ts b/ui/apps/everest/src/components/cluster-form/affinity/affinity-form-dialog/affinity-form/fields/index.ts new file mode 100644 index 000000000..e044db73f --- /dev/null +++ b/ui/apps/everest/src/components/cluster-form/affinity/affinity-form-dialog/affinity-form/fields/index.ts @@ -0,0 +1,8 @@ +export { default as ComponentInput } from './component-input'; +export { default as KeyInput } from './key-input'; +export { default as OperatorInput } from './operator-input'; +export { default as PriorityToggle } from './priority-toggle'; +export { default as TopologyKeyInput } from './topology-key-input'; +export { default as TypeInput } from './type-input'; +export { default as ValueInput } from './value-input'; +export { default as WeightInput } from './weight-input'; diff --git a/ui/apps/everest/src/components/cluster-form/affinity/affinity-form-dialog/affinity-form/fields/key-input.tsx b/ui/apps/everest/src/components/cluster-form/affinity/affinity-form-dialog/affinity-form/fields/key-input.tsx new file mode 100644 index 000000000..60734de24 --- /dev/null +++ b/ui/apps/everest/src/components/cluster-form/affinity/affinity-form-dialog/affinity-form/fields/key-input.tsx @@ -0,0 +1,32 @@ +import { TextInput } from '@percona/ui-lib'; +import { AffinityFormFields } from '../affinity-form.types'; +import { AffinityType } from 'shared-types/affinity.types'; + +const getHelperTextForAffinityType = (affinityType: AffinityType) => { + switch (affinityType) { + case AffinityType.NodeAffinity: + return 'A label key assigned to nodes that defines scheduling rules'; + default: + return 'A label key assigned to pods that defines scheduling rules'; + } +}; + +const KeyInput = ({ affinityType }: { affinityType: AffinityType }) => ( + <TextInput + name={AffinityFormFields.key} + label="Key" + // Deps allows RHF to trigger cross-validation on dependent fields + controllerProps={{ + rules: { + deps: [AffinityFormFields.operator, AffinityFormFields.values], + }, + }} + textFieldProps={{ + sx: { + flex: '0 0 35%', + }, + helperText: getHelperTextForAffinityType(affinityType), + }} + /> +); +export default KeyInput; diff --git a/ui/apps/everest/src/components/cluster-form/affinity/affinity-form-dialog/affinity-form/fields/operator-input.tsx b/ui/apps/everest/src/components/cluster-form/affinity/affinity-form-dialog/affinity-form/fields/operator-input.tsx new file mode 100644 index 000000000..74dfca2bc --- /dev/null +++ b/ui/apps/everest/src/components/cluster-form/affinity/affinity-form-dialog/affinity-form/fields/operator-input.tsx @@ -0,0 +1,32 @@ +import { + AffinityOperator, + AffinityOperatorValue, +} from 'shared-types/affinity.types'; +import { AffinityFormFields } from '../affinity-form.types'; +import { SelectInput } from '@percona/ui-lib'; +import { MenuItem } from '@mui/material'; + +type Props = { + disabled: boolean; +}; + +const OperatorInput = ({ disabled }: Props) => ( + <SelectInput + name={AffinityFormFields.operator} + label="Operator" + selectFieldProps={{ + sx: { width: '213px' }, + label: 'Operator', + disabled, + }} + data-testid="operator-select" + > + {Object.values(AffinityOperator).map((value) => ( + <MenuItem key={value} value={value} data-testid={value}> + {AffinityOperatorValue[value]} + </MenuItem> + ))} + </SelectInput> +); + +export default OperatorInput; diff --git a/ui/apps/everest/src/components/cluster-form/affinity/affinity-form-dialog/affinity-form/fields/priority-toggle.tsx b/ui/apps/everest/src/components/cluster-form/affinity/affinity-form-dialog/affinity-form/fields/priority-toggle.tsx new file mode 100644 index 000000000..dcd24f524 --- /dev/null +++ b/ui/apps/everest/src/components/cluster-form/affinity/affinity-form-dialog/affinity-form/fields/priority-toggle.tsx @@ -0,0 +1,33 @@ +import { ToggleButtonGroupInput, ToggleCard } from '@percona/ui-lib'; +import { + AffinityPriority, + AffinityPriorityValue, +} from 'shared-types/affinity.types'; +import { AffinityFormFields } from '../affinity-form.types'; + +const PriorityToggle = () => ( + <ToggleButtonGroupInput // TODO needs extra styling to look like FIGMA + name={AffinityFormFields.priority} + toggleButtonGroupProps={{ + size: 'small', + sx: { + height: '30px', + width: '160px', + marginTop: '20px', + alignSelf: 'center', + }, + }} + > + {Object.values(AffinityPriority).map((value) => ( + <ToggleCard + sx={{ borderRadius: '15px' }} + value={value} + data-testid={`toggle-button-${value}`} + key={value} + > + {AffinityPriorityValue[value]} + </ToggleCard> + ))} + </ToggleButtonGroupInput> +); +export default PriorityToggle; diff --git a/ui/apps/everest/src/components/cluster-form/affinity/affinity-form-dialog/affinity-form/fields/topology-key-input.tsx b/ui/apps/everest/src/components/cluster-form/affinity/affinity-form-dialog/affinity-form/fields/topology-key-input.tsx new file mode 100644 index 000000000..dccf617da --- /dev/null +++ b/ui/apps/everest/src/components/cluster-form/affinity/affinity-form-dialog/affinity-form/fields/topology-key-input.tsx @@ -0,0 +1,17 @@ +import { TextInput } from '@percona/ui-lib'; +import { AffinityFormFields } from '../affinity-form.types'; + +const TopologyKeyInput = () => ( + <TextInput + name={AffinityFormFields.topologyKey} + label="Topology Key" + textFieldProps={{ + sx: { + flex: '0 0 35%', + }, + helperText: 'A domain key that determines relative pod placement', + }} + /> +); + +export default TopologyKeyInput; diff --git a/ui/apps/everest/src/components/cluster-form/affinity/affinity-form-dialog/affinity-form/fields/type-input.tsx b/ui/apps/everest/src/components/cluster-form/affinity/affinity-form-dialog/affinity-form/fields/type-input.tsx new file mode 100644 index 000000000..479dd15c6 --- /dev/null +++ b/ui/apps/everest/src/components/cluster-form/affinity/affinity-form-dialog/affinity-form/fields/type-input.tsx @@ -0,0 +1,21 @@ +import { SelectInput } from '@percona/ui-lib'; +import { MenuItem } from '@mui/material'; +import { AffinityType, AffinityTypeValue } from 'shared-types/affinity.types'; +import { AffinityFormFields } from '../affinity-form.types'; + +const TypeInput = () => ( + <SelectInput + name={AffinityFormFields.type} + label={'Type'} + selectFieldProps={{ sx: { width: '213px' }, label: 'Type' }} + data-testid="type-select" + > + {Object.values(AffinityType).map((value) => ( + <MenuItem key={value} value={value} data-testid={value}> + {AffinityTypeValue[value]} + </MenuItem> + ))} + </SelectInput> +); + +export default TypeInput; diff --git a/ui/apps/everest/src/components/cluster-form/affinity/affinity-form-dialog/affinity-form/fields/value-input.tsx b/ui/apps/everest/src/components/cluster-form/affinity/affinity-form-dialog/affinity-form/fields/value-input.tsx new file mode 100644 index 000000000..6828f3237 --- /dev/null +++ b/ui/apps/everest/src/components/cluster-form/affinity/affinity-form-dialog/affinity-form/fields/value-input.tsx @@ -0,0 +1,25 @@ +import { TextInput } from '@percona/ui-lib'; +import { AffinityFormFields } from '../affinity-form.types'; + +type Props = { + disabled: boolean; +}; + +const ValueInput = ({ disabled }: Props) => ( + <TextInput + name={AffinityFormFields.values} + label={'Values'} + textFieldProps={{ + sx: { + marginTop: '25px', + width: '645px', + }, + inputProps: { + disabled, + }, + helperText: 'Insert comma seperated values', + }} + /> +); + +export default ValueInput; diff --git a/ui/apps/everest/src/components/cluster-form/affinity/affinity-form-dialog/affinity-form/fields/weight-input.tsx b/ui/apps/everest/src/components/cluster-form/affinity/affinity-form-dialog/affinity-form/fields/weight-input.tsx new file mode 100644 index 000000000..a7b97e92b --- /dev/null +++ b/ui/apps/everest/src/components/cluster-form/affinity/affinity-form-dialog/affinity-form/fields/weight-input.tsx @@ -0,0 +1,19 @@ +import { TextInput } from '@percona/ui-lib'; +import { AffinityFormFields } from '../affinity-form.types'; + +const WeightInput = () => ( + <TextInput + name={AffinityFormFields.weight} + textFieldProps={{ + helperText: '1 - 100', + type: 'number', + sx: { + width: '213px', + marginTop: '25px', + }, + }} + label="Weight" + /> +); + +export default WeightInput; diff --git a/ui/apps/everest/src/components/cluster-form/affinity/affinity-form-dialog/affinity-form/sections/details-section.tsx b/ui/apps/everest/src/components/cluster-form/affinity/affinity-form-dialog/affinity-form/sections/details-section.tsx new file mode 100644 index 000000000..91add2a7b --- /dev/null +++ b/ui/apps/everest/src/components/cluster-form/affinity/affinity-form-dialog/affinity-form/sections/details-section.tsx @@ -0,0 +1,40 @@ +import { Box, Typography } from '@mui/material'; +import { + KeyInput, + OperatorInput, + TopologyKeyInput, + ValueInput, +} from '../fields'; +import { AffinityOperator, AffinityType } from 'shared-types/affinity.types'; + +type Props = { + disableOperator: boolean; + disableValue: boolean; + operator: AffinityOperator; + showTopologyKey: boolean; + affinityType: AffinityType; +}; + +const RuleDetailsSection = ({ + operator, + disableOperator, + disableValue, + showTopologyKey, + affinityType, +}: Props) => ( + <> + <Typography variant="sectionHeading" sx={{ marginTop: '20px' }}> + Rule details + </Typography> + <Box sx={{ display: 'flex', gap: '20px' }}> + {showTopologyKey && <TopologyKeyInput />} + <KeyInput affinityType={affinityType} /> + <OperatorInput disabled={disableOperator} /> + </Box> + {[AffinityOperator.In, AffinityOperator.NotIn].includes(operator) && ( + <ValueInput disabled={disableValue} /> + )} + </> +); + +export default RuleDetailsSection; diff --git a/ui/apps/everest/src/components/cluster-form/affinity/affinity-form-dialog/affinity-form/sections/index.tsx b/ui/apps/everest/src/components/cluster-form/affinity/affinity-form-dialog/affinity-form/sections/index.tsx new file mode 100644 index 000000000..d9a5ac670 --- /dev/null +++ b/ui/apps/everest/src/components/cluster-form/affinity/affinity-form-dialog/affinity-form/sections/index.tsx @@ -0,0 +1,2 @@ +export { default as RuleTypeSection } from './type-section'; +export { default as RuleDetailsSection } from './details-section'; diff --git a/ui/apps/everest/src/components/cluster-form/affinity/affinity-form-dialog/affinity-form/sections/type-section.tsx b/ui/apps/everest/src/components/cluster-form/affinity/affinity-form-dialog/affinity-form/sections/type-section.tsx new file mode 100644 index 000000000..f41bb8bbe --- /dev/null +++ b/ui/apps/everest/src/components/cluster-form/affinity/affinity-form-dialog/affinity-form/sections/type-section.tsx @@ -0,0 +1,41 @@ +import { Box, Typography } from '@mui/material'; +import { + ComponentInput, + PriorityToggle, + TypeInput, + WeightInput, +} from '../fields'; +import { availableComponentsType } from 'components/cluster-form/affinity/affinity-utils'; +import { DbType } from '@percona/types'; + +type Props = { + dbType: DbType; + isShardingEnabled: boolean; + disableComponent: boolean; + showWeight?: boolean; +}; + +const RuleTypeSection = ({ + dbType, + isShardingEnabled, + disableComponent, + showWeight, +}: Props) => ( + <> + <Typography variant="sectionHeading" sx={{ marginTop: '20px' }}> + Rule type + </Typography> + <Box sx={{ display: 'flex', gap: '20px' }}> + <ComponentInput + disabled={disableComponent} + dbType={dbType} + components={availableComponentsType(dbType, isShardingEnabled)} + /> + <TypeInput /> + <PriorityToggle /> + </Box> + {showWeight && <WeightInput />} + </> +); + +export default RuleTypeSection; diff --git a/ui/apps/everest/src/components/cluster-form/affinity/affinity-list-view/affinity-item.tsx b/ui/apps/everest/src/components/cluster-form/affinity/affinity-list-view/affinity-item.tsx new file mode 100644 index 000000000..25faaf685 --- /dev/null +++ b/ui/apps/everest/src/components/cluster-form/affinity/affinity-list-view/affinity-item.tsx @@ -0,0 +1,31 @@ +import { Stack, Typography } from '@mui/material'; +import { AffinityRule } from 'shared-types/affinity.types'; + +const showRuleProperty = (prop: string | undefined) => { + return prop ? ` | ${prop}` : ''; +}; + +export const AffinityItem = ({ rule }: { rule: AffinityRule }) => { + return ( + <Stack + direction="row" + alignItems="center" + sx={{ + width: '100%', + }} + > + <Stack + sx={{ + width: '50%', + }} + > + <Typography variant="body1"> + {rule.type} + {[rule.topologyKey, rule.key, rule.operator, rule.values].map( + (prop) => showRuleProperty(prop) + )} + </Typography> + </Stack> + </Stack> + ); +}; diff --git a/ui/apps/everest/src/components/cluster-form/affinity/affinity-list-view/affinity-list.view.tsx b/ui/apps/everest/src/components/cluster-form/affinity/affinity-list-view/affinity-list.view.tsx new file mode 100644 index 000000000..18d53ce79 --- /dev/null +++ b/ui/apps/everest/src/components/cluster-form/affinity/affinity-list-view/affinity-list.view.tsx @@ -0,0 +1,209 @@ +import { BoxProps, Stack, Typography } from '@mui/material'; +import { DbType } from '@percona/types'; +import { ActionableLabeledContent } from '@percona/ui-lib'; +import { + AffinityComponent, + AffinityPriority, + AffinityPriorityValue, + AffinityRule, +} from 'shared-types/affinity.types'; +import EditableItem from 'components/editable-item'; +import { Fragment, useEffect, useState } from 'react'; +import { AffinityFormDialog } from '../affinity-form-dialog/affinity-form-dialog'; +import { AffinityFormDialogContext } from '../affinity-form-dialog/affinity-form-dialog-context/affinity-form-context'; +import { AffinityFormData } from '../affinity-form-dialog/affinity-form/affinity-form.types'; +import { availableComponentsType } from '../affinity-utils'; +import { AffinityItem } from './affinity-item'; +import { convertFormDataToAffinityRule } from '../affinity-form-dialog/affinity-form/affinity-form.utils'; +import { ConfirmDialog } from 'components/confirm-dialog/confirm-dialog'; +import RoundedBox from 'components/rounded-box'; +import { kebabize } from '@percona/utils'; +import { getAffinityComponentLabel } from 'utils/db'; + +export const AffinityListView = ({ + onRulesChange, + dbType, + initialRules = [], + rules, + isShardingEnabled = false, + disableActions = false, + boxProps = { sx: {} }, +}: { + initialRules?: AffinityRule[]; + // 'rules' make this component controlled + rules?: AffinityRule[]; + onRulesChange: (newRules: AffinityRule[]) => void; + dbType: DbType; + isShardingEnabled?: boolean; + disableActions?: boolean; + boxProps?: BoxProps; +}) => { + const [selectedAffinityUid, setSelectedAffinityUid] = useState<string | null>( + null + ); + const [openAffinityModal, setOpenAffinityModal] = useState(false); + const [openDeleteModal, setOpenDeleteModal] = useState(false); + const [internalRules, setInternalRules] = + useState<AffinityRule[]>(initialRules); + const { sx: boxSx, ...rest } = boxProps; + const controlled = rules !== undefined; + + const handleCreate = () => { + setOpenAffinityModal(true); + }; + + const handleEdit = (uid: string) => { + setSelectedAffinityUid(uid); + setOpenAffinityModal(true); + }; + + const handleClose = () => { + setSelectedAffinityUid(null); + setOpenAffinityModal(false); + }; + + const onDeleteClick = (ruleUid: string) => { + setSelectedAffinityUid(ruleUid); + setOpenDeleteModal(true); + }; + + const handleDelete = () => { + const newRules = internalRules.filter( + ({ uid }) => uid !== selectedAffinityUid + ); + updateRules(newRules); + setOpenDeleteModal(false); + }; + + const onSubmit = (data: AffinityFormData) => { + let newRules: AffinityRule[] = []; + const addedRule = convertFormDataToAffinityRule(data); + + if (selectedAffinityUid === null) { + newRules = [...internalRules, addedRule]; + } else { + newRules = [...internalRules]; + const ruleIdx = newRules.findIndex( + ({ uid }) => uid === selectedAffinityUid + ); + + if (ruleIdx !== -1) { + newRules[ruleIdx] = addedRule; + } + } + updateRules(newRules); + setOpenAffinityModal(false); + }; + + const updateRules = (newRules: AffinityRule[]) => { + setSelectedAffinityUid(null); + onRulesChange(newRules); + + if (!controlled) { + setInternalRules(newRules); + } + }; + + const closeModal = () => { + setSelectedAffinityUid(null); + setOpenDeleteModal(false); + }; + + useEffect(() => { + if (rules && Array.isArray(rules)) { + setInternalRules(rules); + } + }, [rules]); + + return ( + <RoundedBox + sx={{ + ...boxSx, + }} + {...rest} + > + <ActionableLabeledContent + label="Affinity" + actionButtonProps={{ + dataTestId: 'create-affinity', + buttonText: 'Create affinity rule', + onClick: () => handleCreate(), + disabled: disableActions, + }} + verticalStackSx={{ + mt: 0, + }} + horizontalStackSx={{ + mb: 0, + }} + > + {availableComponentsType(dbType, isShardingEnabled).map( + (component: AffinityComponent) => { + const hasRules = (internalRules || []).find( + (rule) => rule.component === component + ); + return ( + <Fragment key={component}> + {hasRules && ( + <Stack data-testid={`${kebabize(component)}-rules`}> + <Typography + variant="sectionHeading" + sx={{ marginTop: '20px' }} + > + {getAffinityComponentLabel(dbType, component)} + </Typography> + <Stack data-testid={`${kebabize(component)}-rules-list`}> + {internalRules.map((rule) => ( + <Fragment key={rule.uid}> + {rule.component === component && ( + <EditableItem + children={<AffinityItem rule={rule} />} + editButtonProps={{ + disabled: disableActions, + onClick: () => handleEdit(rule.uid), + }} + deleteButtonProps={{ + disabled: disableActions, + onClick: () => onDeleteClick(rule.uid), + }} + dataTestId={'affinity-rule'} + endText={`${AffinityPriorityValue[rule.priority]}${rule.priority === AffinityPriority.Preferred && !!rule.weight ? ` - ${rule.weight}` : ''}`} + /> + )} + </Fragment> + ))} + </Stack> + </Stack> + )} + </Fragment> + ); + } + )} + </ActionableLabeledContent> + + <AffinityFormDialogContext.Provider + value={{ + selectedAffinityUid, + handleSubmit: onSubmit, + handleClose, + setOpenAffinityModal, + openAffinityModal, + affinityRules: internalRules, + dbType, + isShardingEnabled, + }} + > + {openAffinityModal && <AffinityFormDialog />} + </AffinityFormDialogContext.Provider> + <ConfirmDialog + isOpen={openDeleteModal} + selectedId={selectedAffinityUid!} + handleConfirm={handleDelete} + closeModal={closeModal} + headerMessage="Delete affinity rule" + > + Are you sure you want to delete this affinity rule? + </ConfirmDialog> + </RoundedBox> + ); +}; diff --git a/ui/apps/everest/src/components/cluster-form/affinity/affinity-utils.ts b/ui/apps/everest/src/components/cluster-form/affinity/affinity-utils.ts new file mode 100644 index 000000000..09ea45947 --- /dev/null +++ b/ui/apps/everest/src/components/cluster-form/affinity/affinity-utils.ts @@ -0,0 +1,20 @@ +import { DbType } from '@percona/types'; +import { AffinityComponent } from 'shared-types/affinity.types'; + +export const availableComponentsType = ( + dbType: DbType, + isShardingEnabled: boolean +): AffinityComponent[] => { + const availableTypes = + dbType === DbType.Mongo + ? isShardingEnabled + ? [ + AffinityComponent.ConfigServer, + AffinityComponent.DbNode, + AffinityComponent.Proxy, + ] + : [AffinityComponent.DbNode] + : [AffinityComponent.DbNode, AffinityComponent.Proxy]; + + return availableTypes.sort(); +}; diff --git a/ui/apps/everest/src/components/cluster-form/resources/constants.ts b/ui/apps/everest/src/components/cluster-form/resources/constants.ts index 8ab6f962c..3ecb1db4d 100644 --- a/ui/apps/everest/src/components/cluster-form/resources/constants.ts +++ b/ui/apps/everest/src/components/cluster-form/resources/constants.ts @@ -4,6 +4,13 @@ import { Resources } from 'shared-types/dbCluster.types'; import { DbWizardFormFields } from 'consts'; import { cpuParser, memoryParser } from 'utils/k8ResourceParser'; import { Messages } from './messages'; +import { + AffinityComponent, + AffinityPriority, + AffinityRule, + AffinityType, +} from 'shared-types/affinity.types'; +import { generateShortUID } from 'utils/generateShortUID'; const resourceToNumber = (minimum = 0) => z.union([z.string().min(1), z.number()]).pipe( @@ -347,3 +354,31 @@ export const resourcesFormSchema = ( }; export const CUSTOM_NR_UNITS_INPUT_VALUE = 'custom-units-nr'; +const DEFAULT_TOPOLOGY_KEY = 'kubernetes.io/hostname'; + +export const generateDefaultAffinityRule = ( + component: AffinityComponent +): AffinityRule => ({ + component, + type: AffinityType.PodAntiAffinity, + priority: AffinityPriority.Preferred, + weight: 1, + topologyKey: DEFAULT_TOPOLOGY_KEY, + uid: generateShortUID(), +}); + +export const getDefaultAffinityRules = (dbType: DbType, sharding: boolean) => { + const rules: AffinityRule[] = [ + generateDefaultAffinityRule(AffinityComponent.DbNode), + ]; + + if (dbType === DbType.Mongo) { + if (sharding) { + rules.push(generateDefaultAffinityRule(AffinityComponent.Proxy)); + rules.push(generateDefaultAffinityRule(AffinityComponent.ConfigServer)); + } + } else { + rules.push(generateDefaultAffinityRule(AffinityComponent.Proxy)); + } + return rules; +}; diff --git a/ui/apps/everest/src/components/cluster-form/resources/resources.tsx b/ui/apps/everest/src/components/cluster-form/resources/resources.tsx index 60c2caf07..be1181cf3 100644 --- a/ui/apps/everest/src/components/cluster-form/resources/resources.tsx +++ b/ui/apps/everest/src/components/cluster-form/resources/resources.tsx @@ -39,7 +39,7 @@ import { } from './constants'; import { DbWizardFormFields } from 'consts'; import { DbType } from '@percona/types'; -import { getProxyUnitNamesFromDbType } from './utils'; +import { getProxyUnitNamesFromDbType } from 'utils/db'; import { ResourcesTogglesProps, ResourceInputProps } from './resources.types'; import { Messages } from './messages'; diff --git a/ui/apps/everest/src/components/cluster-form/resources/utils.ts b/ui/apps/everest/src/components/cluster-form/resources/utils.ts deleted file mode 100644 index 36c5bbace..000000000 --- a/ui/apps/everest/src/components/cluster-form/resources/utils.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { DbType } from '@percona/types'; - -export const getProxyUnitNamesFromDbType = ( - dbType: DbType -): { singular: string; plural: string } => { - switch (dbType) { - case DbType.Postresql: - return { singular: 'PG Bouncer', plural: 'PG Bouncers' }; - case DbType.Mongo: - return { singular: 'router', plural: 'routers' }; - case DbType.Mysql: - default: - return { singular: 'proxy', plural: 'proxies' }; - } -}; diff --git a/ui/apps/everest/src/components/editable-item/editable-item.tsx b/ui/apps/everest/src/components/editable-item/editable-item.tsx index 891d37c23..417b2be0e 100644 --- a/ui/apps/everest/src/components/editable-item/editable-item.tsx +++ b/ui/apps/everest/src/components/editable-item/editable-item.tsx @@ -13,7 +13,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Box, IconButton, Paper, Tooltip } from '@mui/material'; +import { Box, IconButton, Paper, Tooltip, Typography } from '@mui/material'; import DeleteOutlineOutlinedIcon from '@mui/icons-material/DeleteOutlineOutlined'; import EditOutlinedIcon from '@mui/icons-material/EditOutlined'; import { EditableItemProps } from './editable-item.types'; @@ -24,6 +24,7 @@ const EditableItem = ({ paperProps, deleteButtonProps, editButtonProps, + endText, }: EditableItemProps) => { return ( <Paper @@ -41,11 +42,15 @@ const EditableItem = ({ {...paperProps} > {children} + + <Typography variant="body2" sx={{ color: 'grey', width: '100px' }}> + {endText} + </Typography> <Box flexWrap="nowrap" display="flex"> {editButtonProps && ( <IconButton size="small" - data-testid={`delete-editable-item-button-${dataTestId}`} + data-testid={`edit-editable-item-button-${dataTestId}`} color="primary" {...editButtonProps} > diff --git a/ui/apps/everest/src/components/editable-item/editable-item.types.ts b/ui/apps/everest/src/components/editable-item/editable-item.types.ts index 849ddad14..95edf7253 100644 --- a/ui/apps/everest/src/components/editable-item/editable-item.types.ts +++ b/ui/apps/everest/src/components/editable-item/editable-item.types.ts @@ -23,4 +23,5 @@ export interface EditableItemProps { }; dataTestId: string; paperProps?: PaperProps; + endText?: string; } diff --git a/ui/apps/everest/src/components/form-dialog/form-dialog.types.ts b/ui/apps/everest/src/components/form-dialog/form-dialog.types.ts index 6107a4971..b2767cd0d 100644 --- a/ui/apps/everest/src/components/form-dialog/form-dialog.types.ts +++ b/ui/apps/everest/src/components/form-dialog/form-dialog.types.ts @@ -7,13 +7,13 @@ import { UseFormReturn, ValidationMode, } from 'react-hook-form'; -import { ZodEffects, ZodObject, ZodRawShape } from 'zod'; +import { z, ZodTypeDef } from 'zod'; export interface FormDialogProps<T extends FieldValues> { isOpen: boolean; closeModal: () => void; headerMessage: string; - schema: ZodEffects<ZodObject<ZodRawShape>> | ZodObject<ZodRawShape>; + schema: z.Schema<unknown, ZodTypeDef>; defaultValues?: DefaultValues<T>; values?: T; onSubmit: (data: T) => void; diff --git a/ui/apps/everest/src/components/rounded-box/RoundedBox.tsx b/ui/apps/everest/src/components/rounded-box/RoundedBox.tsx new file mode 100644 index 000000000..45cd8159e --- /dev/null +++ b/ui/apps/everest/src/components/rounded-box/RoundedBox.tsx @@ -0,0 +1,20 @@ +import { Box, BoxProps } from '@mui/material'; + +const RoundedBox = ({ sx, children, ...rest }: BoxProps) => ( + <Box + className="percona-rounded-box" + sx={{ + p: 2, + borderStyle: 'solid', + borderWidth: '1px', + borderColor: (theme) => theme.palette.divider, + borderRadius: '8px', + ...sx, + }} + {...rest} + > + {children} + </Box> +); + +export default RoundedBox; diff --git a/ui/apps/everest/src/components/rounded-box/index.ts b/ui/apps/everest/src/components/rounded-box/index.ts new file mode 100644 index 000000000..adad0b714 --- /dev/null +++ b/ui/apps/everest/src/components/rounded-box/index.ts @@ -0,0 +1 @@ +export { default } from './RoundedBox'; diff --git a/ui/apps/everest/src/hooks/api/db-cluster/useCreateDbCluster.ts b/ui/apps/everest/src/hooks/api/db-cluster/useCreateDbCluster.ts index b43f4ab11..f87ca4c12 100644 --- a/ui/apps/everest/src/hooks/api/db-cluster/useCreateDbCluster.ts +++ b/ui/apps/everest/src/hooks/api/db-cluster/useCreateDbCluster.ts @@ -20,10 +20,12 @@ import { useQuery, } from '@tanstack/react-query'; import { createDbClusterFn, getDbClusterCredentialsFn } from 'api/dbClusterApi'; +import { affinityRulesToDbPayload } from 'utils/db'; import { CUSTOM_NR_UNITS_INPUT_VALUE, MIN_NUMBER_OF_SHARDS, } from 'components/cluster-form'; +import { AffinityComponent, AffinityRule } from 'shared-types/affinity.types'; import { DbWizardType } from 'pages/database-form/database-form-schema.ts'; import { ClusterCredentials, @@ -46,12 +48,23 @@ const formValuesToPayloadMapping = ( dbPayload: DbWizardType, backupDataSource?: DataSource ): DbCluster => { + const affinityRules = dbPayload.affinityRules || []; + const affinityRulesMap: Record<AffinityComponent, AffinityRule[]> = { + [AffinityComponent.Proxy]: [], + [AffinityComponent.DbNode]: [], + [AffinityComponent.ConfigServer]: [], + }; const numberOfNodes = parseInt( dbPayload.numberOfNodes === CUSTOM_NR_UNITS_INPUT_VALUE ? dbPayload.customNrOfNodes || '' : dbPayload.numberOfNodes, 10 ); + + affinityRules.forEach((rule) => { + affinityRulesMap[rule.component].push(rule); + }); + const dbClusterPayload: DbCluster = { apiVersion: 'everest.percona.com/v1alpha1', kind: 'DatabaseCluster', @@ -98,6 +111,9 @@ const formValuesToPayloadMapping = ( config: dbPayload.engineParametersEnabled ? dbPayload.engineParameters : '', + affinity: affinityRulesToDbPayload( + affinityRulesMap[AffinityComponent.DbNode] + ), }, monitoring: { ...(!!dbPayload.monitoring && { @@ -112,7 +128,8 @@ const formValuesToPayloadMapping = ( dbPayload.proxyCpu, dbPayload.proxyMemory, dbPayload.sharding, - dbPayload.sourceRanges || [] + dbPayload.sourceRanges || [], + affinityRulesToDbPayload(affinityRulesMap[AffinityComponent.Proxy]) ), ...(dbPayload.dbType === DbType.Mongo && { sharding: { @@ -120,6 +137,9 @@ const formValuesToPayloadMapping = ( shards: +(dbPayload.shardNr ?? MIN_NUMBER_OF_SHARDS), configServer: { replicas: +(dbPayload.shardConfigServers ?? 3), + affinity: affinityRulesToDbPayload( + affinityRulesMap[AffinityComponent.ConfigServer] + ), }, }, }), diff --git a/ui/apps/everest/src/hooks/api/db-cluster/useUpdateDbCluster.ts b/ui/apps/everest/src/hooks/api/db-cluster/useUpdateDbCluster.ts index b14476e9a..e881a7e4a 100644 --- a/ui/apps/everest/src/hooks/api/db-cluster/useUpdateDbCluster.ts +++ b/ui/apps/everest/src/hooks/api/db-cluster/useUpdateDbCluster.ts @@ -13,7 +13,11 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { useMutation } from '@tanstack/react-query'; +import { + MutateOptions, + useMutation, + useQueryClient, +} from '@tanstack/react-query'; import { updateDbClusterFn } from 'api/dbClusterApi'; import { DbCluster, @@ -25,13 +29,20 @@ import { getProxySpec } from './utils'; import { DbEngineType } from 'shared-types/dbEngines.types'; import { dbEngineToDbType } from '@percona/utils'; import cronConverter from 'utils/cron-converter'; +import { enqueueSnackbar } from 'notistack'; +import { AxiosError } from 'axios'; +import { useRef } from 'react'; +import { DB_CLUSTER_QUERY, useDbCluster } from './useDbCluster'; + +const UPDATE_RETRY_TIMEOUT_MS = 5000; +const UPDATE_RETRY_DELAY_MS = 200; export const updateDbCluster = ( clusterName: string, namespace: string, dbCluster: DbCluster -) => - updateDbClusterFn(clusterName, namespace, { +) => { + return updateDbClusterFn(clusterName, namespace, { ...dbCluster, spec: { ...dbCluster?.spec, @@ -50,6 +61,7 @@ export const updateDbCluster = ( }), }, }); +}; export const useUpdateDbClusterCrd = () => useMutation({ @@ -221,7 +233,8 @@ export const useUpdateDbClusterResources = () => !!sharding, ((dbCluster.spec.proxy as Proxy).expose.ipSourceRanges || []).map( (sourceRange) => ({ sourceRange }) - ) + ), + (dbCluster.spec.proxy as Proxy).affinity ), ...(dbCluster.spec.engine.type === DbEngineType.PSMDB && sharding && { @@ -296,3 +309,136 @@ export const useUpdateDbClusterPITR = () => }, }), }); + +// TODO apply this to all update mutations. Right now it's only used in one place, components -> affinity rules +export const useUpdateDbClusterWithConflictRetry = ( + oldDbClusterData: DbCluster, + mutationOptions?: MutateOptions< + DbCluster, + AxiosError<unknown, unknown>, + { + clusterName: string; + namespace: string; + dbCluster: DbCluster; + }, + unknown + > +) => { + const { + onSuccess: ownOnSuccess = () => {}, + onError: ownOnError = () => {}, + ...restMutationOptions + } = mutationOptions || {}; + const { + name: dbClusterName, + namespace, + generation: dbClusterGeneration, + } = oldDbClusterData.metadata; + + const queryClient = useQueryClient(); + const watchStartTime = useRef<number | null>(null); + const clusterDataToBeSent = useRef<DbCluster | null>(null); + const { refetch } = useDbCluster(dbClusterName, namespace, { + enabled: false, + }); + + const mutationMethods = useMutation< + DbCluster, + AxiosError, + { + clusterName: string; + namespace: string; + dbCluster: DbCluster; + }, + unknown + >({ + mutationFn: ({ + clusterName, + namespace, + dbCluster, + }: { + clusterName: string; + namespace: string; + dbCluster: DbCluster; + }) => { + clusterDataToBeSent.current = dbCluster; + return updateDbCluster(clusterName, namespace, dbCluster); + }, + onError: async (error, vars, ctx) => { + const { status } = error; + + if (status === 409) { + if (watchStartTime.current === null) { + watchStartTime.current = Date.now(); + } + + const timeDiff = Date.now() - watchStartTime.current; + + if (timeDiff > UPDATE_RETRY_TIMEOUT_MS) { + enqueueSnackbar( + 'There is a conflict with the current object definition.', + { + variant: 'error', + } + ); + ownOnError?.(error, vars, ctx); + watchStartTime.current = null; + return; + } + + return new Promise<void>((resolve) => + setTimeout(async () => { + const { data: freshDbCluster } = await refetch(); + + if (freshDbCluster) { + const { generation, resourceVersion } = freshDbCluster.metadata; + + if (generation === dbClusterGeneration) { + resolve(); + mutationMethods.mutate({ + clusterName: dbClusterName, + namespace, + dbCluster: { + ...clusterDataToBeSent.current!, + metadata: { ...freshDbCluster.metadata, resourceVersion }, + }, + }); + } else { + enqueueSnackbar( + 'The object definition has been changed somewhere else. Please re-apply your changes.', + { + variant: 'error', + } + ); + ownOnError?.(error, vars, ctx); + watchStartTime.current = null; + resolve(); + } + } else { + watchStartTime.current = null; + ownOnError?.(error, vars, ctx); + resolve(); + } + }, UPDATE_RETRY_DELAY_MS) + ); + } + + mutationOptions?.onError?.(error, vars, ctx); + return; + }, + onSuccess: (data, vars, ctx) => { + watchStartTime.current = null; + queryClient.setQueryData<DbCluster>( + [DB_CLUSTER_QUERY, dbClusterName], + (oldData) => ({ + ...oldData, + ...data, + }) + ); + ownOnSuccess?.(data, vars, ctx); + }, + ...restMutationOptions, + }); + + return mutationMethods; +}; diff --git a/ui/apps/everest/src/hooks/api/db-cluster/utils.ts b/ui/apps/everest/src/hooks/api/db-cluster/utils.ts index bb2e6c8cd..d4bb0ec18 100644 --- a/ui/apps/everest/src/hooks/api/db-cluster/utils.ts +++ b/ui/apps/everest/src/hooks/api/db-cluster/utils.ts @@ -1,6 +1,7 @@ import { DbType } from '@percona/types'; import { dbTypeToProxyType } from '@percona/utils'; import { CUSTOM_NR_UNITS_INPUT_VALUE } from 'components/cluster-form'; +import { Affinity } from 'shared-types/affinity.types'; import { Proxy, ProxyExposeConfig, @@ -28,7 +29,8 @@ export const getProxySpec = ( cpu: number, memory: number, sharding: boolean, - sourceRanges?: Array<{ sourceRange?: string }> + sourceRanges?: Array<{ sourceRange?: string }>, + proxyAffinityRules?: Affinity ): Proxy | ProxyExposeConfig => { if (dbType === DbType.Mongo && !sharding) { return { @@ -52,5 +54,6 @@ export const getProxySpec = ( memory: `${memory}G`, }, expose: getExposteConfig(externalAccess, sourceRanges), + affinity: proxyAffinityRules, }; }; diff --git a/ui/apps/everest/src/pages/database-form/database-form-body/steps/advanced-configurations/advanced-configurations.tsx b/ui/apps/everest/src/pages/database-form/database-form-body/steps/advanced-configurations/advanced-configurations.tsx index 6cc2a15be..b740fe46f 100644 --- a/ui/apps/everest/src/pages/database-form/database-form-body/steps/advanced-configurations/advanced-configurations.tsx +++ b/ui/apps/everest/src/pages/database-form/database-form-body/steps/advanced-configurations/advanced-configurations.tsx @@ -28,8 +28,15 @@ export const AdvancedConfigurations = () => { return ( <> <StepHeader pageTitle={Messages.advanced} /> - <FormGroup sx={{ mt: 3 }}> - <AdvancedConfigurationForm dbType={dbType} /> + <FormGroup + sx={{ + mt: 3, + '& > .percona-rounded-box:not(:last-child)': { + mb: 2, + }, + }} + > + <AdvancedConfigurationForm showAffinity dbType={dbType} /> </FormGroup> </> ); diff --git a/ui/apps/everest/src/pages/database-form/database-form-body/steps/first/first-step.tsx b/ui/apps/everest/src/pages/database-form/database-form-body/steps/first/first-step.tsx index 6a15c10f2..509aefebe 100644 --- a/ui/apps/everest/src/pages/database-form/database-form-body/steps/first/first-step.tsx +++ b/ui/apps/everest/src/pages/database-form/database-form-body/steps/first/first-step.tsx @@ -40,6 +40,7 @@ import { Messages } from './first-step.messages.ts'; import { filterAvailableDbVersionsForDbEngineEdition } from 'components/cluster-form/db-version/utils.ts'; import { useNamespacePermissionsForResource } from 'hooks/rbac'; import { + generateDefaultAffinityRule, NODES_DEFAULT_SIZES, PROXIES_DEFAULT_SIZES, ResourceSize, @@ -47,6 +48,11 @@ import { import { DbVersion } from 'components/cluster-form/db-version'; import { useDBEnginesForDbEngineTypes } from 'hooks/index.ts'; import { useDatabasePageDefaultValues } from 'pages/database-form/useDatabaseFormDefaultValues.ts'; +import { + AffinityComponent, + AffinityRule, +} from 'shared-types/affinity.types.ts'; +import { filterOutUnavailableAffinityRulesForMongo } from 'pages/database-form/database-form.utils.ts'; export const FirstStep = ({ loadingDefaultsForEdition }: StepProps) => { const mode = useDatabasePageMode(); @@ -54,7 +60,7 @@ export const FirstStep = ({ loadingDefaultsForEdition }: StepProps) => { const { defaultValues: { [DbWizardFormFields.dbVersion]: defaultDbVersion }, } = useDatabasePageDefaultValues(mode); - const { watch, setValue, getFieldState, resetField, trigger } = + const { watch, setValue, getFieldState, resetField, trigger, getValues } = useFormContext(); const { data: clusterInfo, isFetching: clusterInfoFetching } = @@ -224,6 +230,33 @@ export const FirstStep = ({ loadingDefaultsForEdition }: StepProps) => { }); }, []); + const onShardingToggleChange = useCallback( + (e: React.ChangeEvent<HTMLInputElement>) => { + const enabled = e.target.checked; + const rules: AffinityRule[] = getValues(DbWizardFormFields.affinityRules); + const { isDirty } = getFieldState(DbWizardFormFields.affinityRules); + + if (!enabled) { + resetField(DbWizardFormFields.shardNr, { + keepError: false, + }); + resetField(DbWizardFormFields.shardConfigServers, { + keepError: false, + }); + } else if (!isDirty) { + rules.push(generateDefaultAffinityRule(AffinityComponent.Proxy)); + rules.push(generateDefaultAffinityRule(AffinityComponent.ConfigServer)); + } + + const filteredRules = filterOutUnavailableAffinityRulesForMongo( + rules, + enabled + ); + setValue(DbWizardFormFields.affinityRules, filteredRules); + }, + [getFieldState, getValues, resetField, setValue] + ); + useEffect(() => { setDefaultsForDbType(dbType); }, [dbType, setDefaultsForDbType]); @@ -292,16 +325,7 @@ export const FirstStep = ({ loadingDefaultsForEdition }: StepProps) => { name={DbWizardFormFields.sharding} switchFieldProps={{ disabled: disableSharding, - onChange: (e) => { - if (!e.target.checked) { - resetField(DbWizardFormFields.shardNr, { - keepError: false, - }); - resetField(DbWizardFormFields.shardConfigServers, { - keepError: false, - }); - } - }, + onChange: onShardingToggleChange, }} /> {notSupportedMongoOperatorVersionForSharding && diff --git a/ui/apps/everest/src/pages/database-form/database-form.constants.ts b/ui/apps/everest/src/pages/database-form/database-form.constants.ts index f66db4295..d8be1ba84 100644 --- a/ui/apps/everest/src/pages/database-form/database-form.constants.ts +++ b/ui/apps/everest/src/pages/database-form/database-form.constants.ts @@ -17,6 +17,7 @@ import { DbType } from '@percona/types'; import { DbWizardFormFields } from 'consts.ts'; import { DbWizardType } from './database-form-schema.ts'; import { + getDefaultAffinityRules, getDefaultNumberOfconfigServersByNumberOfNodes, NODES_DEFAULT_SIZES, PROXIES_DEFAULT_SIZES, @@ -65,4 +66,8 @@ export const DB_WIZARD_DEFAULTS: DbWizardType = { getDefaultNumberOfconfigServersByNumberOfNodes( parseInt(DEFAULT_NODES[DbType.Mongo], 10) ), + [DbWizardFormFields.affinityRules]: getDefaultAffinityRules( + DbType.Mongo, + false + ), }; diff --git a/ui/apps/everest/src/pages/database-form/database-form.utils.ts b/ui/apps/everest/src/pages/database-form/database-form.utils.ts index aebb4b6a4..b6ce6f824 100644 --- a/ui/apps/everest/src/pages/database-form/database-form.utils.ts +++ b/ui/apps/everest/src/pages/database-form/database-form.utils.ts @@ -35,6 +35,10 @@ import { } from 'components/cluster-form'; import { isProxy } from 'utils/db.tsx'; import { advancedConfigurationModalDefaultValues } from 'components/cluster-form/advanced-configuration/advanced-configuration.utils.ts'; +import { + AffinityComponent, + AffinityRule, +} from 'shared-types/affinity.types.ts'; const replicasToNodes = (replicas: string, dbType: DbType): string => { const nodeOptions = NODES_DB_TYPE_MAP[dbType]; @@ -155,3 +159,11 @@ export const DbClusterPayloadToFormValues = ( dbCluster?.spec?.monitoring?.monitoringConfigName || '', }; }; + +export const filterOutUnavailableAffinityRulesForMongo = ( + rules: AffinityRule[], + sharding: boolean +) => + rules.filter((rule) => + sharding ? true : rule.component === AffinityComponent.DbNode + ); diff --git a/ui/apps/everest/src/pages/database-form/database-preview/sections/resources-section.tsx b/ui/apps/everest/src/pages/database-form/database-preview/sections/resources-section.tsx index c6fbeec7b..0442a5fe3 100644 --- a/ui/apps/everest/src/pages/database-form/database-preview/sections/resources-section.tsx +++ b/ui/apps/everest/src/pages/database-form/database-preview/sections/resources-section.tsx @@ -1,7 +1,7 @@ import { CUSTOM_NR_UNITS_INPUT_VALUE } from 'components/cluster-form'; import { PreviewContentText } from '../preview-section'; import { SectionProps } from './section.types'; -import { getProxyUnitNamesFromDbType } from 'components/cluster-form/resources/utils'; +import { getProxyUnitNamesFromDbType } from 'utils/db'; export const ResourcesPreviewSection = ({ dbType, diff --git a/ui/apps/everest/src/pages/database-form/useDatabaseFormDefaultValues.ts b/ui/apps/everest/src/pages/database-form/useDatabaseFormDefaultValues.ts index 1409b9f93..1ea61d921 100644 --- a/ui/apps/everest/src/pages/database-form/useDatabaseFormDefaultValues.ts +++ b/ui/apps/everest/src/pages/database-form/useDatabaseFormDefaultValues.ts @@ -25,12 +25,14 @@ import { DbWizardType } from './database-form-schema.ts'; import { dbEngineToDbType } from '@percona/utils'; import { DbType } from '@percona/types'; import { generateShortUID } from 'utils/generateShortUID.ts'; +import { getDefaultAffinityRules } from 'components/cluster-form'; const getNewDbDefaults = (dbType: DbType) => { return { ...DB_WIZARD_DEFAULTS, dbType: dbType, [DbWizardFormFields.dbName]: `${dbType}-${generateShortUID()}`, + [DbWizardFormFields.affinityRules]: getDefaultAffinityRules(dbType, false), }; }; diff --git a/ui/apps/everest/src/pages/databases/expandedRow/ExpandedRow.tsx b/ui/apps/everest/src/pages/databases/expandedRow/ExpandedRow.tsx index 96ae0a5a2..7732e4630 100644 --- a/ui/apps/everest/src/pages/databases/expandedRow/ExpandedRow.tsx +++ b/ui/apps/everest/src/pages/databases/expandedRow/ExpandedRow.tsx @@ -28,7 +28,7 @@ import { getTotalResourcesDetailedString, memoryParser, } from 'utils/k8ResourceParser'; -import { getProxyUnitNamesFromDbType } from 'components/cluster-form/resources/utils'; +import { getProxyUnitNamesFromDbType } from 'utils/db'; import { dbEngineToDbType } from '@percona/utils'; export const ExpandedRow = ({ diff --git a/ui/apps/everest/src/pages/db-cluster-details/cluster-overview/cards/db-details/advanced-configuration/edit-advanced-configuration/edit-advanced-configuration.tsx b/ui/apps/everest/src/pages/db-cluster-details/cluster-overview/cards/db-details/advanced-configuration/edit-advanced-configuration/edit-advanced-configuration.tsx index a346580a0..fa437814a 100644 --- a/ui/apps/everest/src/pages/db-cluster-details/cluster-overview/cards/db-details/advanced-configuration/edit-advanced-configuration/edit-advanced-configuration.tsx +++ b/ui/apps/everest/src/pages/db-cluster-details/cluster-overview/cards/db-details/advanced-configuration/edit-advanced-configuration/edit-advanced-configuration.tsx @@ -37,12 +37,14 @@ export const AdvancedConfigurationEditModal = ({ engineParametersEnabled, engineParameters, sourceRanges, + affinityRules, }) => { handleSubmitModal({ externalAccess, engineParametersEnabled, engineParameters, sourceRanges, + affinityRules, }); }; diff --git a/ui/apps/everest/src/pages/db-cluster-details/cluster-overview/cards/resources-details.tsx b/ui/apps/everest/src/pages/db-cluster-details/cluster-overview/cards/resources-details.tsx index b638e8a83..59478d975 100644 --- a/ui/apps/everest/src/pages/db-cluster-details/cluster-overview/cards/resources-details.tsx +++ b/ui/apps/everest/src/pages/db-cluster-details/cluster-overview/cards/resources-details.tsx @@ -41,8 +41,7 @@ import { import { dbEngineToDbType } from '@percona/utils'; import { DB_CLUSTER_QUERY, useUpdateDbClusterResources } from 'hooks'; import { DbType } from '@percona/types'; -import { isProxy } from 'utils/db'; -import { getProxyUnitNamesFromDbType } from 'components/cluster-form/resources/utils'; +import { isProxy, getProxyUnitNamesFromDbType } from 'utils/db'; import { DbClusterStatus } from 'shared-types/dbCluster.types'; export const ResourcesDetails = ({ diff --git a/ui/apps/everest/src/pages/db-cluster-details/components/components.tsx b/ui/apps/everest/src/pages/db-cluster-details/components/components.tsx index 24f3d8613..5f66b3432 100644 --- a/ui/apps/everest/src/pages/db-cluster-details/components/components.tsx +++ b/ui/apps/everest/src/pages/db-cluster-details/components/components.tsx @@ -13,31 +13,47 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { useParams } from 'react-router-dom'; -import { useDbClusterComponents } from 'hooks/api/db-cluster/useDbClusterComponents'; -import { useMemo } from 'react'; import { capitalize, Tooltip } from '@mui/material'; import { Table } from '@percona/ui-lib'; -import { MRT_ColumnDef } from 'material-react-table'; -import { DBClusterComponent } from 'shared-types/components.types'; import StatusField from 'components/status-field'; +import { DATE_FORMAT } from 'consts'; import { format, formatDistanceToNowStrict, isValid } from 'date-fns'; +import { useDbClusterComponents } from 'hooks/api/db-cluster/useDbClusterComponents'; +import { MRT_ColumnDef } from 'material-react-table'; +import { useCallback, useContext, useMemo, useState } from 'react'; +import { useParams } from 'react-router-dom'; +import { DBClusterComponent } from 'shared-types/components.types'; import { COMPONENT_STATUS, COMPONENT_STATUS_WEIGHT, componentStatusToBaseStatus, } from './components.constants'; import ExpandedRow from './expanded-row'; -import { DATE_FORMAT } from 'consts'; +import { AffinityListView } from 'components/cluster-form/affinity/affinity-list-view/affinity-list.view'; +import { AffinityComponent, AffinityRule } from 'shared-types/affinity.types'; +import { DbClusterContext } from '../dbCluster.context'; +import { + changeDbClusterAffinityRules, + dbPayloadToAffinityRules, +} from 'utils/db'; +import { dbEngineToDbType } from '@percona/utils'; +import { useUpdateDbClusterWithConflictRetry } from 'hooks'; const Components = () => { const { dbClusterName, namespace = '' } = useParams(); + const [updatingAffinityRules, setUpdatingAffinityRules] = useState(false); const { data: components = [], isLoading } = useDbClusterComponents( namespace, dbClusterName! ); - + const { dbCluster } = useContext(DbClusterContext); + const { mutate: update } = useUpdateDbClusterWithConflictRetry(dbCluster!, { + onSuccess: () => { + setUpdatingAffinityRules(false); + }, + onError: () => setUpdatingAffinityRules(false), + }); const columns = useMemo<MRT_ColumnDef<DBClusterComponent>[]>(() => { return [ { @@ -95,33 +111,68 @@ const Components = () => { }, ]; }, []); + const affinityRules = useMemo( + () => dbPayloadToAffinityRules(dbCluster!), + [dbCluster] + ); + + const onRulesChange = useCallback( + (newRules: AffinityRule[]) => { + const filteredRules: Record<AffinityComponent, AffinityRule[]> = { + [AffinityComponent.DbNode]: [], + [AffinityComponent.Proxy]: [], + [AffinityComponent.ConfigServer]: [], + }; + setUpdatingAffinityRules(true); + + newRules.forEach((rule) => { + filteredRules[rule.component].push(rule); + }); + update({ + dbCluster: changeDbClusterAffinityRules(dbCluster!, newRules), + clusterName: dbCluster!.metadata.name, + namespace: dbCluster!.metadata.namespace, + }); + }, + [dbCluster, update] + ); return ( - <Table - initialState={{ - sorting: [ - { - id: 'status', - desc: true, - }, - ], - }} - state={{ isLoading }} - tableName={`${dbClusterName}-components`} - columns={columns} - data={components} - noDataMessage={'No components'} - renderDetailPanel={({ row }) => <ExpandedRow row={row} />} - muiTableDetailPanelProps={{ - sx: { - padding: 0, - width: '100%', - '.MuiCollapse-root': { + <> + <Table + initialState={{ + sorting: [ + { + id: 'status', + desc: true, + }, + ], + }} + state={{ isLoading }} + tableName={`${dbClusterName}-components`} + columns={columns} + data={components} + noDataMessage={'No components'} + renderDetailPanel={({ row }) => <ExpandedRow row={row} />} + muiTableDetailPanelProps={{ + sx: { + padding: 0, width: '100%', + '.MuiCollapse-root': { + width: '100%', + }, }, - }, - }} - /> + }} + /> + <AffinityListView + rules={affinityRules} + onRulesChange={onRulesChange} + dbType={dbEngineToDbType(dbCluster!.spec.engine.type)} + isShardingEnabled={!!dbCluster!.spec.sharding?.enabled} + disableActions={updatingAffinityRules} + boxProps={{ sx: { mt: 3 } }} + /> + </> ); }; diff --git a/ui/apps/everest/src/shared-types/affinity.types.ts b/ui/apps/everest/src/shared-types/affinity.types.ts new file mode 100644 index 000000000..2c48e5109 --- /dev/null +++ b/ui/apps/everest/src/shared-types/affinity.types.ts @@ -0,0 +1,114 @@ +export type AffinityRule = { + component: AffinityComponent; + type: AffinityType; + priority: AffinityPriority; + weight?: number; + topologyKey?: string; + key?: string; + operator?: AffinityOperator; + values?: string; + // uid is used to uniquely identify the rule on the client side + uid: string; +}; + +type AffinityComponentType = keyof typeof AffinityComponent; + +export type AffinityRules = [ + { component: AffinityComponentType; rules: AffinityRule[] }, +]; + +export enum AffinityComponent { + DbNode = 'dbNode', + Proxy = 'proxy', + ConfigServer = 'configServer', +} + +export enum AffinityType { + PodAntiAffinity = 'podAntiAffinity', + PodAffinity = 'podAffinity', + NodeAffinity = 'nodeAffinity', +} + +export enum AffinityPriority { + Preferred = 'preferred', + Required = 'required', +} + +export enum AffinityOperator { + In = 'In', + NotIn = 'NotIn', + Exists = 'Exists', + DoesNotExist = 'DoesNotExist', +} + +export const AffinityTypeValue: Record<AffinityType, string> = { + [AffinityType.NodeAffinity]: 'Node affinity', + [AffinityType.PodAffinity]: 'Pod affinity', + [AffinityType.PodAntiAffinity]: 'Pod anti-affinity', +}; + +export const AffinityOperatorValue: Record<AffinityOperator, string> = { + [AffinityOperator.Exists]: 'exists', + [AffinityOperator.DoesNotExist]: 'does not exist', + [AffinityOperator.In]: 'in', + [AffinityOperator.NotIn]: 'not in', +}; + +export const AffinityPriorityValue: Record<AffinityPriority, string> = { + [AffinityPriority.Preferred]: 'Preferred', + [AffinityPriority.Required]: 'Required', +}; + +export type AffinityMatchExpression = { + key: string; + operator: AffinityOperator; + values?: string[]; +}; + +type NodeAffinityPreference = { + matchExpressions: AffinityMatchExpression[]; +}; + +type NodeSelectorTerm = NodeAffinityPreference; + +export type PodAffinityTerm = { + labelSelector?: { + matchExpressions: AffinityMatchExpression[]; + }; + topologyKey: string; +}; + +export type PreferredNodeSchedulingTerm = { + preference: NodeAffinityPreference; + weight: number; +}; + +export type PreferredPodSchedulingTerm = { + weight: number; + podAffinityTerm: PodAffinityTerm; +}; + +export type RequiredNodeSchedulingTerm = { + nodeSelectorTerms: NodeSelectorTerm[]; +}; + +export type RequiredPodSchedulingTerm = PodAffinityTerm[]; + +export type NodeAffinity = { + preferredDuringSchedulingIgnoredDuringExecution?: PreferredNodeSchedulingTerm[]; + requiredDuringSchedulingIgnoredDuringExecution?: RequiredNodeSchedulingTerm; +}; + +export type PodAffinity = { + preferredDuringSchedulingIgnoredDuringExecution?: PreferredPodSchedulingTerm[]; + requiredDuringSchedulingIgnoredDuringExecution?: RequiredPodSchedulingTerm; +}; + +export type PodAntiAffinity = PodAffinity; +export type Affinity = { + nodeAffinity?: NodeAffinity; +} & { + podAffinity?: PodAffinity; +} & { + podAntiAffinity?: PodAntiAffinity; +}; diff --git a/ui/apps/everest/src/shared-types/dbCluster.types.ts b/ui/apps/everest/src/shared-types/dbCluster.types.ts index e8d7ec91c..c72b14ce1 100644 --- a/ui/apps/everest/src/shared-types/dbCluster.types.ts +++ b/ui/apps/everest/src/shared-types/dbCluster.types.ts @@ -14,6 +14,7 @@ // limitations under the License. import { ProxyType } from '@percona/types'; import { DbEngineType } from './dbEngines.types'; +import { Affinity } from './affinity.types'; export enum ProxyExposeType { internal = 'internal', @@ -73,6 +74,7 @@ interface Engine { type: DbEngineType; version?: string; config?: string; + affinity?: Affinity; } export interface ProxyExposeConfig { @@ -85,6 +87,7 @@ export interface Proxy { expose: ProxyExposeConfig; resources?: Resources; type: ProxyType; + affinity?: Affinity; } export interface DataSource { @@ -104,6 +107,7 @@ export interface Monitoring { export interface Sharding { configServer: { replicas: number; + affinity?: Affinity; }; shards: number; enabled: boolean; @@ -131,6 +135,8 @@ export interface StatusSpec { } export interface DbClusterMetadata { + generation?: number; + resourceVersion?: string; name: string; namespace: string; annotations?: { diff --git a/ui/apps/everest/src/utils/common-validation.ts b/ui/apps/everest/src/utils/common-validation.ts index 003217576..0f340562c 100644 --- a/ui/apps/everest/src/utils/common-validation.ts +++ b/ui/apps/everest/src/utils/common-validation.ts @@ -1,4 +1,5 @@ -import z from 'zod'; +import { capitalize } from '@mui/material'; +import z, { IssueData } from 'zod'; const tooLongErrorMessage = (fieldName: string) => `The ${fieldName} name is too long`; @@ -28,3 +29,34 @@ export const rfc_123_schema = (fieldName: string) => .regex(doesNotEndWithDash, errorMessages.doesNotEndWithDash) .regex(doesNotStartWithDash, errorMessages.doesNotStartWithDash) .trim(); + +type PerconaCustomZodIssueOptions = Partial<Pick<IssueData, 'code'>> & + Omit<IssueData, 'code'>; + +const getPerconaZodCustomIssuePath = (field: string | string[]) => + Array.isArray(field) ? field : [field]; + +export const PerconaZodCustomIssue = { + required: ( + field: string, + fieldLabel: string = capitalize(field), + options?: PerconaCustomZodIssueOptions + ): IssueData => ({ + path: getPerconaZodCustomIssuePath(field), + message: `${fieldLabel} is required`, + ...options, + code: z.ZodIssueCode.custom, + }), + between: ( + field: string, + min: number, + max: number, + fieldLabel: string = field, + options?: PerconaCustomZodIssueOptions + ): IssueData => ({ + path: getPerconaZodCustomIssuePath(field), + message: `${fieldLabel} must be between ${min} and ${max}`, + ...options, + code: z.ZodIssueCode.custom, + }), +}; diff --git a/ui/apps/everest/src/utils/db.test.ts b/ui/apps/everest/src/utils/db.test.ts new file mode 100644 index 000000000..99c239beb --- /dev/null +++ b/ui/apps/everest/src/utils/db.test.ts @@ -0,0 +1,401 @@ +import { + AffinityRule, + Affinity, + AffinityComponent, + AffinityType, + AffinityPriority, + AffinityOperator, +} from 'shared-types/affinity.types'; +import { DbCluster } from 'shared-types/dbCluster.types'; +import { affinityRulesToDbPayload, dbPayloadToAffinityRules } from './db'; + +describe('affinityRulesToDbPayload', () => { + const tests: [string, AffinityRule[], Affinity][] = [ + ['empty', [], {}], + [ + 'single required node affinity', + [ + { + component: AffinityComponent.DbNode, + type: AffinityType.NodeAffinity, + priority: AffinityPriority.Required, + uid: '', + key: 'my-key', + operator: AffinityOperator.Exists, + }, + ], + { + nodeAffinity: { + requiredDuringSchedulingIgnoredDuringExecution: { + nodeSelectorTerms: [ + { + matchExpressions: [ + { + key: 'my-key', + operator: AffinityOperator.Exists, + }, + ], + }, + ], + }, + }, + }, + ], + [ + 'multiple required node affinity', + [ + { + component: AffinityComponent.DbNode, + type: AffinityType.NodeAffinity, + priority: AffinityPriority.Required, + uid: '', + key: 'my-key', + operator: AffinityOperator.Exists, + }, + { + component: AffinityComponent.DbNode, + type: AffinityType.NodeAffinity, + priority: AffinityPriority.Required, + uid: '', + key: 'my-other-key', + operator: AffinityOperator.In, + values: 'value1,value2', + }, + ], + { + nodeAffinity: { + requiredDuringSchedulingIgnoredDuringExecution: { + nodeSelectorTerms: [ + { + matchExpressions: [ + { + key: 'my-key', + operator: AffinityOperator.Exists, + }, + ], + }, + { + matchExpressions: [ + { + key: 'my-other-key', + operator: AffinityOperator.In, + values: ['value1', 'value2'], + }, + ], + }, + ], + }, + }, + }, + ], + [ + 'mixed affinities', + [ + { + component: AffinityComponent.DbNode, + type: AffinityType.NodeAffinity, + priority: AffinityPriority.Preferred, + weight: 10, + uid: '', + key: 'my-key', + operator: AffinityOperator.Exists, + }, + { + component: AffinityComponent.DbNode, + type: AffinityType.PodAffinity, + priority: AffinityPriority.Required, + topologyKey: 'my-topology-key', + uid: '', + }, + { + component: AffinityComponent.DbNode, + type: AffinityType.PodAntiAffinity, + priority: AffinityPriority.Preferred, + weight: 20, + topologyKey: 'my-topology-key', + operator: AffinityOperator.NotIn, + key: 'my-key', + values: 'value1', + uid: '', + }, + ], + { + nodeAffinity: { + preferredDuringSchedulingIgnoredDuringExecution: [ + { + weight: 10, + preference: { + matchExpressions: [ + { + key: 'my-key', + operator: AffinityOperator.Exists, + }, + ], + }, + }, + ], + }, + podAffinity: { + requiredDuringSchedulingIgnoredDuringExecution: [ + { + topologyKey: 'my-topology-key', + }, + ], + }, + podAntiAffinity: { + preferredDuringSchedulingIgnoredDuringExecution: [ + { + weight: 20, + podAffinityTerm: { + topologyKey: 'my-topology-key', + labelSelector: { + matchExpressions: [ + { + key: 'my-key', + operator: AffinityOperator.NotIn, + values: ['value1'], + }, + ], + }, + }, + }, + ], + }, + }, + ], + ]; + + tests.map(([name, input, expected]) => { + it(name, () => { + expect(affinityRulesToDbPayload(input)).toEqual(expected); + }); + }); +}); + +describe('dbPayloadToAffinityRules', () => { + const tests: [string, DbCluster, Omit<AffinityRule, 'uid'>[]][] = [ + [ + 'single required node affinity', + { + spec: { + engine: { + affinity: { + nodeAffinity: { + requiredDuringSchedulingIgnoredDuringExecution: { + nodeSelectorTerms: [ + { + matchExpressions: [ + { + key: 'my-key', + operator: AffinityOperator.Exists, + }, + ], + }, + ], + }, + }, + }, + }, + }, + } as DbCluster, + [ + { + component: AffinityComponent.DbNode, + type: AffinityType.NodeAffinity, + priority: AffinityPriority.Required, + key: 'my-key', + operator: AffinityOperator.Exists, + }, + ], + ], + [ + 'multiple required node affinity', + { + spec: { + engine: { + affinity: { + nodeAffinity: { + requiredDuringSchedulingIgnoredDuringExecution: { + nodeSelectorTerms: [ + { + matchExpressions: [ + { + key: 'my-key', + operator: AffinityOperator.Exists, + }, + ], + }, + { + matchExpressions: [ + { + key: 'my-other-key', + operator: AffinityOperator.In, + values: ['value1', 'value2'], + }, + ], + }, + ], + }, + }, + }, + }, + }, + } as DbCluster, + [ + { + component: AffinityComponent.DbNode, + type: AffinityType.NodeAffinity, + priority: AffinityPriority.Required, + key: 'my-key', + operator: AffinityOperator.Exists, + }, + { + component: AffinityComponent.DbNode, + type: AffinityType.NodeAffinity, + priority: AffinityPriority.Required, + key: 'my-other-key', + operator: AffinityOperator.In, + values: 'value1,value2', + }, + ], + ], + [ + 'mixed affinities', + { + spec: { + engine: { + affinity: { + nodeAffinity: { + preferredDuringSchedulingIgnoredDuringExecution: [ + { + weight: 10, + preference: { + matchExpressions: [ + { + key: 'my-key', + operator: AffinityOperator.Exists, + }, + ], + }, + }, + ], + }, + podAffinity: { + requiredDuringSchedulingIgnoredDuringExecution: [ + { + topologyKey: 'my-topology-key', + }, + ], + }, + podAntiAffinity: { + preferredDuringSchedulingIgnoredDuringExecution: [ + { + weight: 20, + podAffinityTerm: { + topologyKey: 'my-topology-key', + labelSelector: { + matchExpressions: [ + { + key: 'my-key', + operator: AffinityOperator.NotIn, + values: ['value1'], + }, + ], + }, + }, + }, + ], + }, + }, + }, + proxy: { + affinity: { + podAffinity: { + requiredDuringSchedulingIgnoredDuringExecution: [ + { + topologyKey: 'my-topology-key', + }, + ], + }, + }, + }, + sharding: { + configServer: { + affinity: { + podAntiAffinity: { + preferredDuringSchedulingIgnoredDuringExecution: [ + { + weight: 20, + podAffinityTerm: { + topologyKey: 'my-topology-key', + labelSelector: { + matchExpressions: [ + { + key: 'my-key', + operator: AffinityOperator.NotIn, + values: ['value1'], + }, + ], + }, + }, + }, + ], + }, + }, + }, + }, + }, + } as DbCluster, + [ + { + component: AffinityComponent.DbNode, + type: AffinityType.NodeAffinity, + priority: AffinityPriority.Preferred, + weight: 10, + key: 'my-key', + operator: AffinityOperator.Exists, + }, + { + component: AffinityComponent.DbNode, + type: AffinityType.PodAffinity, + priority: AffinityPriority.Required, + topologyKey: 'my-topology-key', + }, + { + component: AffinityComponent.DbNode, + type: AffinityType.PodAntiAffinity, + priority: AffinityPriority.Preferred, + weight: 20, + topologyKey: 'my-topology-key', + operator: AffinityOperator.NotIn, + key: 'my-key', + values: 'value1', + }, + { + component: AffinityComponent.Proxy, + type: AffinityType.PodAffinity, + priority: AffinityPriority.Required, + topologyKey: 'my-topology-key', + }, + { + component: AffinityComponent.ConfigServer, + type: AffinityType.PodAntiAffinity, + priority: AffinityPriority.Preferred, + weight: 20, + topologyKey: 'my-topology-key', + operator: AffinityOperator.NotIn, + key: 'my-key', + values: 'value1', + }, + ], + ], + ]; + + tests.map(([name, input, expected]) => { + it(name, () => { + // Since we generate a UID for each rule, we can't compare the whole object + // Therefore, we just expect the function to return an object with at least the same keys as the expected result + expect(dbPayloadToAffinityRules(input)).toMatchObject(expected); + }); + }); +}); diff --git a/ui/apps/everest/src/utils/db.tsx b/ui/apps/everest/src/utils/db.tsx index 8f6d5d040..e5a216318 100644 --- a/ui/apps/everest/src/utils/db.tsx +++ b/ui/apps/everest/src/utils/db.tsx @@ -1,12 +1,32 @@ import { MongoIcon, MySqlIcon, PostgreSqlIcon } from '@percona/ui-lib'; import { DbType, ProxyType } from '@percona/types'; import { + DbCluster, ManageableSchedules, Proxy, ProxyExposeConfig, Schedule, } from 'shared-types/dbCluster.types'; import { can } from './rbac'; +import { + Affinity, + AffinityComponent, + AffinityMatchExpression, + AffinityOperator, + AffinityPriority, + AffinityRule, + AffinityType, + NodeAffinity, + PodAffinity, + PodAffinityTerm, + PodAntiAffinity, + PreferredNodeSchedulingTerm, + PreferredPodSchedulingTerm, + RequiredNodeSchedulingTerm, + RequiredPodSchedulingTerm, +} from 'shared-types/affinity.types'; +import { generateShortUID } from './generateShortUID'; +import { capitalize } from '@mui/material'; export const dbTypeToIcon = (dbType: DbType) => { switch (dbType) { @@ -46,6 +66,20 @@ export const dbTypeToProxyType = (dbType: DbType): ProxyType => { } }; +export const getProxyUnitNamesFromDbType = ( + dbType: DbType +): { singular: string; plural: string } => { + switch (dbType) { + case DbType.Postresql: + return { singular: 'PG Bouncer', plural: 'PG Bouncers' }; + case DbType.Mongo: + return { singular: 'router', plural: 'routers' }; + case DbType.Mysql: + default: + return { singular: 'proxy', plural: 'proxies' }; + } +}; + export const isProxy = (proxy: Proxy | ProxyExposeConfig): proxy is Proxy => { return proxy && typeof (proxy as Proxy).expose === 'object'; }; @@ -72,3 +106,342 @@ export const transformSchedulesIntoManageableSchedules = async ( return transformedSchedules; }; + +export const affinityRulesToDbPayload = ( + affinityRules: AffinityRule[] +): Affinity => { + const generatePodAffinityTerm = ( + topologyKey: string, + operator: AffinityOperator, + values: string[], + key?: string + ): PodAffinityTerm => ({ + topologyKey: topologyKey!, + ...(key && { + labelSelector: { + matchExpressions: [ + { + key, + operator: operator!, + ...(values.length > 0 && { + values, + }), + }, + ], + }, + }), + }); + + type AffinityRulesPayloadMap = { + [AffinityType.NodeAffinity]: { + preferred: PreferredNodeSchedulingTerm[]; + required: RequiredNodeSchedulingTerm; + }; + } & { + [key in AffinityType.PodAffinity | AffinityType.PodAntiAffinity]: { + preferred: PreferredPodSchedulingTerm[]; + required: RequiredPodSchedulingTerm; + }; + }; + + const map: AffinityRulesPayloadMap = { + [AffinityType.NodeAffinity]: { + preferred: [], + required: { nodeSelectorTerms: [] }, + }, + [AffinityType.PodAffinity]: { + preferred: [], + required: [], + }, + [AffinityType.PodAntiAffinity]: { + preferred: [], + required: [], + }, + }; + + affinityRules.forEach((rule) => { + const { values = '', type, weight, key, operator, topologyKey } = rule; + const valuesList = values.split(','); + + const required = rule.priority === AffinityPriority.Required; + + switch (type) { + case AffinityType.NodeAffinity: { + const matchExpressions: AffinityMatchExpression[] = [ + { + key: key!, + operator: operator!, + ...([AffinityOperator.In, AffinityOperator.NotIn].includes( + operator! + ) && { + values: valuesList, + }), + }, + ]; + + if (required) { + map[AffinityType.NodeAffinity].required.nodeSelectorTerms.push({ + matchExpressions, + }); + } else { + map[AffinityType.NodeAffinity].preferred.push({ + weight: weight!, + preference: { + matchExpressions, + }, + }); + } + break; + } + + case AffinityType.PodAntiAffinity: + case AffinityType.PodAffinity: { + const podAffinityTerm = generatePodAffinityTerm( + topologyKey!, + operator!, + valuesList, + key + ); + if (required) { + map[type].required.push(podAffinityTerm); + } else { + map[type].preferred.push({ + weight: weight!, + podAffinityTerm, + }); + } + break; + } + } + }); + const nodeAffinity: NodeAffinity = { + ...(map[AffinityType.NodeAffinity].preferred.length > 0 && { + preferredDuringSchedulingIgnoredDuringExecution: + map[AffinityType.NodeAffinity].preferred, + }), + ...(map[AffinityType.NodeAffinity].required.nodeSelectorTerms.length > + 0 && { + requiredDuringSchedulingIgnoredDuringExecution: + map[AffinityType.NodeAffinity].required, + }), + }; + const podAffinity: PodAffinity = { + ...(map[AffinityType.PodAffinity].preferred.length > 0 && { + preferredDuringSchedulingIgnoredDuringExecution: + map[AffinityType.PodAffinity].preferred, + }), + ...(map[AffinityType.PodAffinity].required.length > 0 && { + requiredDuringSchedulingIgnoredDuringExecution: + map[AffinityType.PodAffinity].required, + }), + }; + const podAntiAffinity: PodAntiAffinity = { + ...(map[AffinityType.PodAntiAffinity].preferred.length > 0 && { + preferredDuringSchedulingIgnoredDuringExecution: + map[AffinityType.PodAntiAffinity].preferred, + }), + ...(map[AffinityType.PodAntiAffinity].required.length > 0 && { + requiredDuringSchedulingIgnoredDuringExecution: + map[AffinityType.PodAntiAffinity].required, + }), + }; + + const finalObject: Affinity = { + ...(Object.keys(nodeAffinity).length > 0 && { + nodeAffinity, + }), + ...(Object.keys(podAffinity).length > 0 && { + podAffinity, + }), + ...(Object.keys(podAntiAffinity).length > 0 && { + podAntiAffinity, + }), + }; + + return finalObject; +}; + +export const dbPayloadToAffinityRules = ( + dbCluster: DbCluster +): AffinityRule[] => { + const rules: AffinityRule[] = []; + const { + spec: { + engine = { affinity: {} as Affinity }, + proxy = { affinity: {} as Affinity }, + sharding = { configServer: { affinity: {} as Affinity } }, + }, + } = dbCluster; + const { affinity: engineAffinity } = engine; + const { affinity: proxyAffinity } = proxy as Proxy; + const { affinity: configServerAffinity } = sharding.configServer; + const allAffinities = [ + { + affinityObject: engineAffinity, + component: AffinityComponent.DbNode, + }, + { + affinityObject: proxyAffinity, + component: AffinityComponent.Proxy, + }, + { + affinityObject: configServerAffinity, + component: AffinityComponent.ConfigServer, + }, + ]; + + allAffinities.forEach(({ affinityObject, component }) => { + if (affinityObject && Object.keys(affinityObject).length) { + const { nodeAffinity, podAffinity, podAntiAffinity } = affinityObject; + + if (nodeAffinity) { + const { + preferredDuringSchedulingIgnoredDuringExecution = [], + requiredDuringSchedulingIgnoredDuringExecution = { + nodeSelectorTerms: [], + }, + } = nodeAffinity; + + preferredDuringSchedulingIgnoredDuringExecution.forEach( + ({ preference = { matchExpressions: [] }, weight }) => { + rules.push({ + component, + type: AffinityType.NodeAffinity, + priority: AffinityPriority.Preferred, + uid: generateShortUID(), + weight, + ...(preference.matchExpressions.length > 0 && { + key: preference.matchExpressions[0].key, + operator: preference.matchExpressions[0].operator, + values: preference.matchExpressions[0].values?.join(','), + }), + }); + } + ); + + requiredDuringSchedulingIgnoredDuringExecution.nodeSelectorTerms.forEach( + ({ matchExpressions = [] }) => { + rules.push({ + component, + type: AffinityType.NodeAffinity, + priority: AffinityPriority.Required, + uid: generateShortUID(), + ...(matchExpressions.length > 0 && { + key: matchExpressions[0].key, + operator: matchExpressions[0].operator, + values: matchExpressions[0].values?.join(','), + }), + }); + } + ); + } + + [podAffinity, podAntiAffinity].forEach((affinity) => { + if (affinity) { + const affinityType = + affinity === podAffinity + ? AffinityType.PodAffinity + : AffinityType.PodAntiAffinity; + const { + preferredDuringSchedulingIgnoredDuringExecution = [], + requiredDuringSchedulingIgnoredDuringExecution = [], + } = affinity; + preferredDuringSchedulingIgnoredDuringExecution.forEach( + ({ weight, podAffinityTerm }) => { + const { topologyKey, labelSelector = { matchExpressions: [] } } = + podAffinityTerm; + + rules.push({ + component, + type: affinityType, + priority: AffinityPriority.Preferred, + uid: generateShortUID(), + weight, + topologyKey, + ...(labelSelector.matchExpressions.length > 0 && { + key: labelSelector.matchExpressions[0].key, + operator: labelSelector.matchExpressions[0].operator, + values: labelSelector.matchExpressions[0].values?.join(','), + }), + }); + } + ); + + requiredDuringSchedulingIgnoredDuringExecution.forEach( + ({ topologyKey, labelSelector = { matchExpressions: [] } }) => { + rules.push({ + component, + type: affinityType, + priority: AffinityPriority.Required, + uid: generateShortUID(), + topologyKey, + ...(labelSelector.matchExpressions.length > 0 && { + key: labelSelector.matchExpressions[0].key, + operator: labelSelector.matchExpressions[0].operator, + values: labelSelector.matchExpressions[0].values?.join(','), + }), + }); + } + ); + } + }); + } + }); + + return rules; +}; + +export const changeDbClusterAffinityRules = ( + dbCluster: DbCluster, + newRules: AffinityRule[] +): DbCluster => { + const filteredRules: Record<AffinityComponent, AffinityRule[]> = { + [AffinityComponent.DbNode]: [], + [AffinityComponent.Proxy]: [], + [AffinityComponent.ConfigServer]: [], + }; + + newRules.forEach((rule) => { + filteredRules[rule.component].push(rule); + }); + + return { + ...dbCluster, + spec: { + ...dbCluster!.spec, + engine: { + ...dbCluster!.spec.engine, + affinity: affinityRulesToDbPayload(filteredRules.dbNode), + }, + proxy: { + ...dbCluster!.spec.proxy, + affinity: affinityRulesToDbPayload(filteredRules.proxy), + }, + ...(dbCluster!.spec.sharding && { + sharding: { + ...dbCluster!.spec.sharding, + configServer: { + ...dbCluster!.spec.sharding?.configServer, + affinity: affinityRulesToDbPayload(filteredRules.configServer), + }, + }, + }), + }, + } as DbCluster; +}; + +export const getAffinityComponentLabel = ( + dbType: DbType, + component: AffinityComponent +): string => { + switch (component) { + case AffinityComponent.Proxy: + return capitalize(getProxyUnitNamesFromDbType(dbType).singular); + case AffinityComponent.ConfigServer: + return 'Config Server'; + case AffinityComponent.DbNode: + return 'DB Node'; + default: + return ''; + } +}; diff --git a/ui/packages/ui-lib/src/actionable-labeled-content/actionable-labeled-content.tsx b/ui/packages/ui-lib/src/actionable-labeled-content/actionable-labeled-content.tsx index 08de47281..ea737d0b6 100644 --- a/ui/packages/ui-lib/src/actionable-labeled-content/actionable-labeled-content.tsx +++ b/ui/packages/ui-lib/src/actionable-labeled-content/actionable-labeled-content.tsx @@ -16,6 +16,8 @@ export const ActionableLabeledContent = ({ techPreview, content, actionButtonProps, + verticalStackSx, + horizontalStackSx, ...rest }: ActionableLabeledContentProps) => { const { dataTestId, buttonText, ...buttonProps } = actionButtonProps || {}; @@ -28,9 +30,11 @@ export const ActionableLabeledContent = ({ '.MuiTextField-root': { mt: actionButtonProps ? 0 : 1.5, }, + ...verticalStackSx, }} horizontalStackSx={{ marginBottom: actionButtonProps ? 1 : 0.5, + ...horizontalStackSx, }} horizontalStackChildrenSlot={ <> diff --git a/ui/packages/ui-lib/src/form/inputs/text/text.tsx b/ui/packages/ui-lib/src/form/inputs/text/text.tsx index 13cf6ff26..a79fc8258 100644 --- a/ui/packages/ui-lib/src/form/inputs/text/text.tsx +++ b/ui/packages/ui-lib/src/form/inputs/text/text.tsx @@ -8,10 +8,11 @@ const TextInput = ({ name, label, controllerProps, - textFieldProps, + textFieldProps = {}, isRequired, }: TextInputProps) => { const { control: contextControl } = useFormContext(); + const { sx: textFieldPropsSx, ...restFieldProps } = textFieldProps; return ( <Controller name={name} @@ -20,9 +21,9 @@ const TextInput = ({ <TextField label={label} {...field} - size={textFieldProps?.size || 'small'} - sx={{ mt: 3 }} - {...textFieldProps} + size={restFieldProps?.size || 'small'} + sx={{ mt: 3, ...textFieldPropsSx }} + {...restFieldProps} variant="outlined" required={isRequired} error={!!error} @@ -34,9 +35,9 @@ const TextInput = ({ onWheel: (e) => { (e.target as HTMLElement).blur(); }, - ...textFieldProps?.inputProps, + ...restFieldProps?.inputProps, }} - helperText={error ? error.message : textFieldProps?.helperText} + helperText={error ? error.message : restFieldProps?.helperText} /> )} {...controllerProps} diff --git a/ui/packages/utils/src/string/string.ts b/ui/packages/utils/src/string/string.ts index 1f13cc9be..067e4ee16 100644 --- a/ui/packages/utils/src/string/string.ts +++ b/ui/packages/utils/src/string/string.ts @@ -1,7 +1,10 @@ const kebabize = (str: string) => - str.replace( - /[A-Z]+(?![a-z])|[A-Z]/g, - ($, ofs) => (ofs ? '-' : '') + $.toLowerCase() - ); + str + .replace( + /[A-Z]+(?![a-z])|[A-Z]/g, + ($, ofs) => (ofs ? '-' : '') + $.toLowerCase() + ) + .replace(/\s+(?=[-])/, '') + .replace(/\s+(?![-])/, '-'); export default kebabize;