diff --git a/packages/api-v4/src/aglb/service-targets.ts b/packages/api-v4/src/aglb/service-targets.ts index 06e610bd311..9137d1c3d72 100644 --- a/packages/api-v4/src/aglb/service-targets.ts +++ b/packages/api-v4/src/aglb/service-targets.ts @@ -8,6 +8,7 @@ import Request, { import { Filter, Params, ResourcePage } from '../types'; import { BETA_API_ROOT } from '../constants'; import type { ServiceTarget, ServiceTargetPayload } from './types'; +import { CreateServiceTargetSchema } from '@linode/validation'; /** * getLoadbalancerServiceTargets @@ -63,7 +64,7 @@ export const createLoadbalancerServiceTarget = ( loadbalancerId )}/service-targets` ), - setData(data), + setData(data, CreateServiceTargetSchema), setMethod('POST') ); diff --git a/packages/api-v4/src/aglb/types.ts b/packages/api-v4/src/aglb/types.ts index 083cbe2a04d..9eb46aa9a3b 100644 --- a/packages/api-v4/src/aglb/types.ts +++ b/packages/api-v4/src/aglb/types.ts @@ -115,6 +115,7 @@ export interface ServiceTargetPayload { } interface HealthCheck { + protocol: 'tcp' | 'http'; interval: number; timeout: number; unhealthy_threshold: number; @@ -128,7 +129,7 @@ export interface ServiceTarget extends ServiceTargetPayload { } export interface Endpoint { - ip?: string; + ip: string; host?: string; port: number; rate_capacity: number; diff --git a/packages/manager/.changeset/pr-9657-upcoming-features-1694451006203.md b/packages/manager/.changeset/pr-9657-upcoming-features-1694451006203.md new file mode 100644 index 00000000000..fa280b9f90b --- /dev/null +++ b/packages/manager/.changeset/pr-9657-upcoming-features-1694451006203.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Add AGLB Create Service Target Drawer ([#9657](https://github.com/linode/manager/pull/9657)) diff --git a/packages/manager/cypress/e2e/core/loadBalancers/load-balancer-service-targets.spec.ts b/packages/manager/cypress/e2e/core/loadBalancers/load-balancer-service-targets.spec.ts new file mode 100644 index 00000000000..7209bc5c37b --- /dev/null +++ b/packages/manager/cypress/e2e/core/loadBalancers/load-balancer-service-targets.spec.ts @@ -0,0 +1,230 @@ +/** + * @file Integration tests related to Cloud Manager AGLB Service Target management. + */ + +import { + linodeFactory, + loadbalancerFactory, + serviceTargetFactory, + certificateFactory, +} from '@src/factories'; +import { + mockAppendFeatureFlags, + mockGetFeatureFlagClientstream, +} from 'support/intercepts/feature-flags'; +import { + mockGetLoadBalancer, + mockGetLoadBalancerCertificates, + mockGetServiceTargets, + mockCreateServiceTarget, +} from 'support/intercepts/load-balancers'; +import { makeFeatureFlagData } from 'support/util/feature-flags'; +import type { Linode, ServiceTarget } from '@linode/api-v4'; +import { randomLabel, randomIp, randomNumber } from 'support/util/random'; +import { ui } from 'support/ui'; +import { chooseRegion } from 'support/util/regions'; +import { mockGetLinodes } from 'support/intercepts/linodes'; + +describe('Akamai Global Load Balancer service targets', () => { + // TODO Remove this `beforeEach()` hook and related `cy.wait()` calls when `aglb` feature flag goes away. + beforeEach(() => { + mockAppendFeatureFlags({ + aglb: makeFeatureFlagData(true), + }).as('getFeatureFlags'); + mockGetFeatureFlagClientstream().as('getClientStream'); + }); + + /* + * - Confirms that Load Balancer service targets are listed in the "Service Targets" tab. + */ + it('lists Load Balancer service targets', () => { + const mockLoadBalancer = loadbalancerFactory.build(); + // const mockServiceTargets = serviceTargetFactory.buildList(5); + const mockServiceTargets = new Array(5).fill(null).map( + (_item: null, i: number): ServiceTarget => { + return serviceTargetFactory.build({ + label: randomLabel(), + }); + } + ); + + mockGetLoadBalancer(mockLoadBalancer).as('getLoadBalancer'); + mockGetServiceTargets(mockLoadBalancer, mockServiceTargets).as( + 'getServiceTargets' + ); + + cy.visitWithLogin(`/loadbalancers/${mockLoadBalancer.id}/service-targets`); + cy.wait([ + '@getFeatureFlags', + '@getClientStream', + '@getLoadBalancer', + '@getServiceTargets', + ]); + + // Confirm that each service target is listed as expected. + mockServiceTargets.forEach((serviceTarget: ServiceTarget) => { + cy.findByText(serviceTarget.label).should('be.visible'); + // TODO Assert that endpoints, health checks, algorithm, and certificates are listed. + }); + }); + + /** + * - Confirms that service target page shows empty state when there are no service targets. + * - Confirms that clicking "Create Service Target" opens "Add a Service Target" drawer. + * - Confirms that "Add a Service Target" drawer can be cancelled. + * - Confirms that endpoints can be selected via Linode label and via IP address. + * - Confirms that health check can be disabled or re-enabled, and UI responds to toggle. + * - [TODO] Confirms that service target list updates to reflect created service target. + */ + it('can create a Load Balancer service target', () => { + const loadBalancerRegion = chooseRegion(); + const mockLinodes = new Array(2).fill(null).map( + (_item: null, _i: number): Linode => { + return linodeFactory.build({ + label: randomLabel(), + region: loadBalancerRegion.id, + ipv4: [randomIp()], + }); + } + ); + + const mockLoadBalancer = loadbalancerFactory.build({ + regions: [loadBalancerRegion.id], + }); + const mockServiceTarget = serviceTargetFactory.build({ + label: randomLabel(), + }); + const mockCertificate = certificateFactory.build({ + label: randomLabel(), + }); + + mockGetLinodes(mockLinodes); + mockGetLoadBalancer(mockLoadBalancer).as('getLoadBalancer'); + mockGetServiceTargets(mockLoadBalancer, []).as('getServiceTargets'); + mockGetLoadBalancerCertificates(mockLoadBalancer.id, [mockCertificate]); + + cy.visitWithLogin(`/loadbalancers/${mockLoadBalancer.id}/service-targets`); + cy.wait([ + '@getFeatureFlags', + '@getClientStream', + '@getLoadBalancer', + '@getServiceTargets', + ]); + + cy.findByText('No items to display.'); + + ui.button + .findByTitle('Create Service Target') + .should('be.visible') + .should('be.enabled') + .click(); + + // Confirm that drawer can be closed. + ui.drawer + .findByTitle('Add a Service Target') + .should('be.visible') + .within(() => { + ui.button + .findByTitle('Cancel') + .scrollIntoView() + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.get('[data-qa-drawer]').should('not.exist'); + + // Re-open "Add a Service Target" drawer. + ui.button + .findByTitle('Create Service Target') + .should('be.visible') + .should('be.enabled') + .click(); + + // Fill out service target drawer, click "Create Service Target". + mockCreateServiceTarget(mockLoadBalancer, mockServiceTarget).as( + 'createServiceTarget' + ); + + ui.drawer + .findByTitle('Add a Service Target') + .should('be.visible') + .within(() => { + cy.findByText('Service Target Label') + .should('be.visible') + .click() + .type(mockServiceTarget.label); + + // Fill out the first endpoint using the mocked Linode's label. + cy.findByText('Linode or Public IP Address') + .should('be.visible') + .click() + .type(mockLinodes[0].label); + + ui.autocompletePopper + .findByTitle(mockLinodes[0].label) + .should('be.visible') + .click(); + + ui.button.findByTitle('Add Endpoint').should('be.visible').click(); + + // Verify the first endpoint was added to the table + cy.findByText(mockLinodes[0].label, { exact: false }) + .scrollIntoView() + .should('be.visible'); + + // Create another endpoint + cy.findByText('Linode or Public IP Address') + .should('be.visible') + .click() + .type(mockLinodes[1].ipv4[0]); + + ui.autocompletePopper + .findByTitle(mockLinodes[1].label) + .should('be.visible') + .click(); + + ui.button.findByTitle('Add Endpoint').should('be.visible').click(); + + // Verify the second endpoint was added to the table + cy.findByText(mockLinodes[1].label, { exact: false }) + .scrollIntoView() + .should('be.visible'); + + // Select the certificate mocked for this load balancer. + cy.findByText('Certificate').click().type(mockCertificate.label); + + ui.autocompletePopper + .findByTitle(mockCertificate.label) + .should('be.visible') + .click(); + + // Confirm that health check options are hidden when health check is disabled. + cy.findByText('Use Health Checks').should('be.visible').click(); + + cy.get('[data-qa-healthcheck-options]').should('not.exist'); + + // Re-enable health check, fill out form. + cy.findByText('Use Health Checks') + .scrollIntoView() + .should('be.visible') + .click(); + + cy.get('[data-qa-healthcheck-options]') + .scrollIntoView() + .should('be.visible'); + + ui.button + .findByTitle('Create Service Target') + .scrollIntoView() + .should('be.visible') + .should('be.enabled') + .click(); + }); + cy.wait('@createServiceTarget'); + + // TODO Assert that new service target is listed. + // cy.findByText('No items to display.').should('not.exist'); + // cy.findByText(mockServiceTarget.label).should('not.exist'); + }); +}); diff --git a/packages/manager/cypress/support/intercepts/load-balancers.ts b/packages/manager/cypress/support/intercepts/load-balancers.ts index 458dffd2366..18331f3ac65 100644 --- a/packages/manager/cypress/support/intercepts/load-balancers.ts +++ b/packages/manager/cypress/support/intercepts/load-balancers.ts @@ -102,10 +102,13 @@ export const mockUploadLoadBalancerCertificate = ( * * @returns Cypress chainable. */ -export const mockGetServiceTargets = (serviceTargets: ServiceTarget[]) => { +export const mockGetServiceTargets = ( + loadBalancer: Loadbalancer, + serviceTargets: ServiceTarget[] +) => { return cy.intercept( 'GET', - apiMatcher('/aglb/service-targets*'), + apiMatcher(`/aglb/${loadBalancer.id}/service-targets*`), paginateResponse(serviceTargets) ); }; @@ -125,3 +128,22 @@ export const mockGetServiceTargetsError = (message?: string) => { makeErrorResponse(message ?? defaultMessage, 500) ); }; + +/** + * Intercepts POST request to create a service target and mocks response. + * + * @param loadBalancer - Load balancer for mocked service target. + * @param serviceTarget - Service target with which to mock response. + * + * @returns Cypress chainable. + */ +export const mockCreateServiceTarget = ( + loadBalancer: Loadbalancer, + serviceTarget: ServiceTarget +) => { + return cy.intercept( + 'POST', + apiMatcher(`/aglb/${loadBalancer.id}/service-targets`), + makeResponse(serviceTarget) + ); +}; diff --git a/packages/manager/src/components/Autocomplete/Autocomplete.styles.tsx b/packages/manager/src/components/Autocomplete/Autocomplete.styles.tsx index 45021702d47..f1df3c7134a 100644 --- a/packages/manager/src/components/Autocomplete/Autocomplete.styles.tsx +++ b/packages/manager/src/components/Autocomplete/Autocomplete.styles.tsx @@ -51,7 +51,6 @@ export const CustomPopper = (props: PopperProps) => { ? `calc(${style.width} + 2px)` : style.width + 2 : undefined, - zIndex: 1, }; return ( diff --git a/packages/manager/src/components/TextField.tsx b/packages/manager/src/components/TextField.tsx index a9567108e11..6122159148e 100644 --- a/packages/manager/src/components/TextField.tsx +++ b/packages/manager/src/components/TextField.tsx @@ -1,5 +1,4 @@ import KeyboardArrowDown from '@mui/icons-material/KeyboardArrowDown'; -import { Box } from 'src/components/Box'; import { default as _TextField, StandardTextFieldProps, @@ -9,6 +8,7 @@ import { clamp } from 'ramda'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; +import { Box } from 'src/components/Box'; import { CircleProgress } from 'src/components/CircleProgress'; import { FormHelperText } from 'src/components/FormHelperText'; import { InputAdornment } from 'src/components/InputAdornment'; @@ -296,7 +296,7 @@ export const TextField = (props: TextFieldProps) => { inputId || (label ? convertToKebabCase(`${label}`) : undefined); return ( -
{ {helperText} )} -
+ ); }; diff --git a/packages/manager/src/factories/aglb.ts b/packages/manager/src/factories/aglb.ts index cf6c2c7f81c..58a158847da 100644 --- a/packages/manager/src/factories/aglb.ts +++ b/packages/manager/src/factories/aglb.ts @@ -4,6 +4,7 @@ import { CreateCertificatePayload, CreateLoadbalancerPayload, CreateRoutePayload, + Endpoint, Loadbalancer, Route, ServiceTarget, @@ -107,8 +108,8 @@ export const createLoadbalancerWithAllChildrenFactory = Factory.Sync.makeFactory { certificates: [ { - id: 1, hostname: 'example.com', + id: 1, }, ], label: 'myentrypoint1', @@ -141,6 +142,7 @@ export const createLoadbalancerWithAllChildrenFactory = Factory.Sync.makeFactory host: 'linode.com', interval: 10000, path: '/images', + protocol: 'http', timeout: 5000, unhealthy_threshold: 5, }, @@ -234,6 +236,7 @@ export const serviceTargetFactory = Factory.Sync.makeFactory({ host: 'linode.com', interval: 10000, path: '/images', + protocol: 'http', timeout: 5000, unhealthy_threshold: 5, }, @@ -257,6 +260,7 @@ export const createServiceTargetFactory = Factory.Sync.makeFactory({ + host: 'example.com', + ip: '192.168.1.1', + port: 80, + rate_capacity: 10_000, +}); diff --git a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Certificates/CertificateSelect.tsx b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Certificates/CertificateSelect.tsx index 0cd5c2d7275..d4ff54c52bc 100644 --- a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Certificates/CertificateSelect.tsx +++ b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Certificates/CertificateSelect.tsx @@ -1,7 +1,6 @@ -import Autocomplete from '@mui/material/Autocomplete'; import React from 'react'; -import { TextField } from 'src/components/TextField'; +import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; import { useLoadBalancerCertificatesInfiniteQuery } from 'src/queries/aglb/certificates'; import type { Certificate, Filter } from '@linode/api-v4'; @@ -25,9 +24,10 @@ interface Props { */ onChange: (certificate: Certificate | null) => void; /** - * The id of the selected certificate + * The id of the selected certificate OR a function that should return `true` + * if the certificate should be selected. */ - value: number; + value: ((certificate: Certificate) => boolean) | number; } export const CertificateSelect = (props: Props) => { @@ -52,7 +52,9 @@ export const CertificateSelect = (props: Props) => { const certificates = data?.pages.flatMap((page) => page.data); const selectedCertificate = - certificates?.find((cert) => cert.id === value) ?? null; + typeof value === 'function' + ? certificates?.find(value) ?? null + : certificates?.find((cert) => cert.id === value) ?? null; const onScroll = (event: React.SyntheticEvent) => { const listboxNode = event.currentTarget; @@ -75,17 +77,13 @@ export const CertificateSelect = (props: Props) => { setInputValue(value); } }} - renderInput={(params) => ( - - )} + errorText={error?.[0].reason ?? errorText} inputValue={selectedCertificate ? selectedCertificate.label : inputValue} + label={label ?? 'Certificate'} loading={isLoading} onChange={(e, value) => onChange(value)} options={certificates ?? []} + placeholder="Select a Certificate" value={selectedCertificate} /> ); diff --git a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/LoadBalancerServiceTargets.tsx b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/LoadBalancerServiceTargets.tsx index 43fd6b60db0..5a83fd2ba41 100644 --- a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/LoadBalancerServiceTargets.tsx +++ b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/LoadBalancerServiceTargets.tsx @@ -27,7 +27,8 @@ import { useOrder } from 'src/hooks/useOrder'; import { usePagination } from 'src/hooks/usePagination'; import { useLoadBalancerServiceTargetsQuery } from 'src/queries/aglb/serviceTargets'; -import { DeleteServiceTargetDialog } from './DeleteServiceTargetDialog'; +import { CreateServiceTargetDrawer } from './ServiceTargets/CreateServiceTargetDrawer'; +import { DeleteServiceTargetDialog } from './ServiceTargets/DeleteServiceTargetDialog'; import type { Filter } from '@linode/api-v4'; @@ -37,6 +38,7 @@ export const LoadBalancerServiceTargets = () => { const { loadbalancerId } = useParams<{ loadbalancerId: string }>(); const [query, setQuery] = useState(); + const [isCreateDrawerOpen, setIsCreateDrawerOpen] = useState(false); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const [ selectedServiceTarget, @@ -117,7 +119,12 @@ export const LoadBalancerServiceTargets = () => { value={query} /> - + @@ -152,7 +159,7 @@ export const LoadBalancerServiceTargets = () => { {serviceTarget.label} - + 4 up @@ -197,7 +204,11 @@ export const LoadBalancerServiceTargets = () => { page={pagination.page} pageSize={pagination.pageSize} /> - + setIsCreateDrawerOpen(false)} + open={isCreateDrawerOpen} + /> setIsDeleteDialogOpen(false)} diff --git a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/ServiceTargets/AddEndpointForm.tsx b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/ServiceTargets/AddEndpointForm.tsx new file mode 100644 index 00000000000..ba98d0be266 --- /dev/null +++ b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/ServiceTargets/AddEndpointForm.tsx @@ -0,0 +1,91 @@ +import { EndpointSchema } from '@linode/validation'; +import { useFormik } from 'formik'; +import React from 'react'; + +import { Box } from 'src/components/Box'; +import { Button } from 'src/components/Button/Button'; +import { InputAdornment } from 'src/components/InputAdornment'; +import { TextField } from 'src/components/TextField'; + +import { LinodeOrIPSelect } from './LinodeOrIPSelect'; + +import type { Endpoint } from '@linode/api-v4'; + +const defaultEndpoint: Endpoint = { + host: '', + ip: '', + port: 80, + rate_capacity: 10_000, +}; + +interface Props { + onAdd: (endpoint: Endpoint) => void; +} + +export const AddEndpointForm = (props: Props) => { + const { onAdd } = props; + + const formik = useFormik({ + initialValues: defaultEndpoint, + onSubmit(values, helpers) { + onAdd(values); + helpers.resetForm(); + }, + validationSchema: EndpointSchema, + }); + + return ( + <> + + formik.setFieldValue(`ip`, ip)} + value={formik.values.ip} + /> + + + + Requests per second + + ), + }} + errorText={formik.errors.rate_capacity} + label="Rate Capacity" + labelTooltipText="TODO" + name="rate_capacity" + onChange={formik.handleChange} + sx={{ maxWidth: '275px' }} + type="number" + value={formik.values.rate_capacity} + /> + + + + ); +}; diff --git a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/ServiceTargets/CreateServiceTargetDrawer.tsx b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/ServiceTargets/CreateServiceTargetDrawer.tsx new file mode 100644 index 00000000000..84e95eb0978 --- /dev/null +++ b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/ServiceTargets/CreateServiceTargetDrawer.tsx @@ -0,0 +1,329 @@ +import { Endpoint, ServiceTargetPayload } from '@linode/api-v4'; +import Stack from '@mui/material/Stack'; +import { useFormik } from 'formik'; +import React from 'react'; + +import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; +import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; +import { SelectedIcon } from 'src/components/Autocomplete/Autocomplete.styles'; +import { Box } from 'src/components/Box'; +import { Divider } from 'src/components/Divider'; +import { Drawer } from 'src/components/Drawer'; +import { FormControlLabel } from 'src/components/FormControlLabel'; +import { FormHelperText } from 'src/components/FormHelperText'; +import { FormLabel } from 'src/components/FormLabel'; +import { InputAdornment } from 'src/components/InputAdornment'; +import { Notice } from 'src/components/Notice/Notice'; +import { Radio } from 'src/components/Radio/Radio'; +import { RadioGroup } from 'src/components/RadioGroup'; +import { TextField } from 'src/components/TextField'; +import { Toggle } from 'src/components/Toggle'; +import { TooltipIcon } from 'src/components/TooltipIcon'; +import { Typography } from 'src/components/Typography'; +import { useServiceTargetCreateMutation } from 'src/queries/aglb/serviceTargets'; +import scrollErrorIntoView from 'src/utilities/scrollErrorIntoView'; + +import { CertificateSelect } from '../Certificates/CertificateSelect'; +import { AddEndpointForm } from './AddEndpointForm'; +import { EndpointTable } from './EndpointTable'; + +interface Props { + loadbalancerId: number; + onClose: () => void; + open: boolean; +} + +const algorithmOptions = [ + { + description: 'Sequential routing to each instance.', + label: 'Round Robin', + value: 'round_robin', + }, + { + description: + 'Sends requests to the target with the least amount of current connections.', + label: 'Least Request', + value: 'least_request', + }, + { + description: 'Reads the request hash value and routes accordingly.', + label: 'Ring Hash', + value: 'ring_hash', + }, + { + description: 'Requests are distributed randomly.', + label: 'Random', + value: 'random', + }, + { + description: + 'Reads the upstream hash to make content aware routing decisions.', + label: 'Maglev', + value: 'maglev', + }, +]; + +const initialValues: ServiceTargetPayload = { + ca_certificate: '', + endpoints: [], + healthcheck: { + healthy_threshold: 3, + host: '', + interval: 10, + path: '', + protocol: 'http', + timeout: 5, + unhealthy_threshold: 3, + }, + label: '', + load_balancing_policy: 'round_robin', +}; + +export const CreateServiceTargetDrawer = (props: Props) => { + const { loadbalancerId, onClose: _onClose, open } = props; + + const { + error, + mutateAsync: createServiceTarget, + reset, + } = useServiceTargetCreateMutation(loadbalancerId); + + const formik = useFormik({ + initialValues, + async onSubmit(values) { + try { + await createServiceTarget(values); + onClose(); + } catch (error) { + scrollErrorIntoView(); + } + }, + }); + + const onClose = () => { + formik.resetForm(); + reset(); + _onClose(); + }; + + const onAddEndpoint = (endpoint: Endpoint) => { + formik.setFieldValue('endpoints', [...formik.values.endpoints, endpoint]); + }; + + const onRemoveEndpoint = (index: number) => { + formik.values.endpoints.splice(index, 1); + formik.setFieldValue('endpoints', formik.values.endpoints); + }; + + const generalError = error?.find((e) => !e.field)?.reason; + + return ( + +
+ {generalError && } + e.field === 'label')?.reason} + label="Service Target Label" + name="label" + onChange={formik.handleChange} + value={formik.values.label} + /> + e.field === 'load_balancing_policy')?.reason + } + onChange={(e, selected) => + formik.setFieldValue('load_balancing_policy', selected.value) + } + renderOption={(props, option, state) => { + return ( +
  • + + + {option.label} + + {option.description} + + +
  • + ); + }} + value={algorithmOptions.find( + (option) => option.value === formik.values.load_balancing_policy + )} + disableClearable + label="Algorithm" + options={algorithmOptions} + /> + + + Endpoints + + + error.field?.startsWith('endpoints') + )} + endpoints={formik.values.endpoints} + onRemove={onRemoveEndpoint} + /> + + + + Service Target CA Certificate + + + + formik.setFieldValue('ca_certificate', cert?.label ?? null) + } + errorText={error?.find((e) => e.field === 'ca_certificate')?.reason} + loadbalancerId={loadbalancerId} + value={(cert) => cert.label === formik.values.ca_certificate} + /> + + + Health Checks + + + + formik.setFieldValue('healthcheck.interval', checked ? 10 : 0) + } + checked={formik.values.healthcheck.interval !== 0} + /> + } + label="Use Health Checks" + /> + {formik.values.healthcheck.interval !== 0 && ( + + + formik.setFieldValue('healthcheck.protocol', value) + } + sx={{ marginBottom: '0px !important' }} + value={formik.values.healthcheck.protocol} + > + Protocol + } label="HTTP" value="http" /> + } label="TCP" value="tcp" /> + + {error?.find((e) => e.field === 'healthcheck.protocol')?.reason} + + + + seconds + ), + }} + errorText={ + error?.find((e) => e.field === 'healthcheck.interval')?.reason + } + label="Interval" + labelTooltipText="TODO: AGLB" + name="healthcheck.interval" + onChange={formik.handleChange} + type="number" + value={formik.values.healthcheck.interval} + /> + checks + ), + }} + errorText={ + error?.find( + (e) => e.field === 'healthcheck.healthy_threshold' + )?.reason + } + label="Healthy Threshold" + labelTooltipText="TODO: AGLB" + name="healthcheck.healthy_threshold" + onChange={formik.handleChange} + type="number" + value={formik.values.healthcheck.healthy_threshold} + /> + + + seconds + ), + }} + errorText={ + error?.find((e) => e.field === 'healthcheck.timeout')?.reason + } + label="Timeout" + labelTooltipText="TODO: AGLB" + name="healthcheck.timeout" + onChange={formik.handleChange} + type="number" + value={formik.values.healthcheck.timeout} + /> + checks + ), + }} + errorText={ + error?.find( + (e) => e.field === 'healthcheck.unhealthy_threshold' + )?.reason + } + label="Unhealthy Threshold" + labelTooltipText="TODO: AGLB" + name="healthcheck.unhealthy_threshold" + onChange={formik.handleChange} + type="number" + value={formik.values.healthcheck.unhealthy_threshold} + /> + + {formik.values.healthcheck.protocol === 'http' && ( + <> + e.field === 'healthcheck.path')?.reason + } + label="Health Check Path" + labelTooltipText="TODO: AGLB" + name="healthcheck.path" + onChange={formik.handleChange} + optional + value={formik.values.healthcheck.path} + /> + e.field === 'healthcheck.host')?.reason + } + label="Health Check Host" + labelTooltipText="TODO: AGLB" + name="healthcheck.host" + onChange={formik.handleChange} + optional + value={formik.values.healthcheck.host} + /> + + )} + + )} + + +
    + ); +}; diff --git a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/DeleteServiceTargetDialog.tsx b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/ServiceTargets/DeleteServiceTargetDialog.tsx similarity index 100% rename from packages/manager/src/features/LoadBalancers/LoadBalancerDetail/DeleteServiceTargetDialog.tsx rename to packages/manager/src/features/LoadBalancers/LoadBalancerDetail/ServiceTargets/DeleteServiceTargetDialog.tsx diff --git a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/ServiceTargets/EndpointTable.test.tsx b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/ServiceTargets/EndpointTable.test.tsx new file mode 100644 index 00000000000..809621bad30 --- /dev/null +++ b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/ServiceTargets/EndpointTable.test.tsx @@ -0,0 +1,117 @@ +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { endpointFactory, linodeFactory } from 'src/factories'; +import { makeResourcePage } from 'src/mocks/serverHandlers'; +import { rest, server } from 'src/mocks/testServer'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { EndpointTable } from './EndpointTable'; + +describe('EndpointTable', () => { + it('should render a table with endpoints', async () => { + const endpoints = [ + endpointFactory.build({ host: 'example.com', ip: '1.1.1.1', port: 22 }), + endpointFactory.build({ host: 'test.com', ip: '8.8.8.8', port: 80 }), + ]; + + const props = { + endpoints, + onRemove: jest.fn(), + }; + + const { getByText } = renderWithTheme(); + + for (const endpoint of endpoints) { + expect(getByText(`${endpoint.ip}:${endpoint.port}`)).toBeVisible(); + + if (endpoint.host) { + expect(getByText(endpoint.host)).toBeInTheDocument(); + } + } + }); + it('should call onRemove with the endpoint index when the remove button is clicked', async () => { + const endpoints = [ + endpointFactory.build({ host: 'example.com', ip: '1.1.1.1', port: 22 }), + endpointFactory.build({ host: 'test.com', ip: '8.8.8.8', port: 80 }), + ]; + + const props = { + endpoints, + onRemove: jest.fn(), + }; + + const { getByLabelText } = renderWithTheme(); + + for (const endpoint of endpoints) { + const removeEndpointButton = getByLabelText( + `Remove Endpoint ${endpoint.ip}:${endpoint.port}` + ); + + expect(removeEndpointButton).toBeVisible(); + + userEvent.click(removeEndpointButton); + + expect(props.onRemove).toBeCalledWith(endpoints.indexOf(endpoint)); + } + }); + it('should display all possible API errors', async () => { + const endpoints = [ + endpointFactory.build({ host: 'example.com', ip: '1.1.1.1', port: 22 }), + endpointFactory.build({ host: 'test.com', ip: '8.8.8.8', port: 80 }), + ]; + + const errors = [ + { field: 'endpoints[0].ip', reason: 'That is not a valid IPv4.' }, + { field: 'endpoints[0].port', reason: 'That is not a valid port.' }, + { field: 'endpoints[1].host', reason: 'That is not a valid host.' }, + { + field: 'endpoints[1].rate_capacity', + reason: 'rate_capacity must be non-negative', + }, + ]; + + const props = { + endpoints, + errors, + onRemove: jest.fn(), + }; + + const { getByText } = renderWithTheme(); + + for (const error of errors) { + expect(getByText(error.reason)).toBeVisible(); + } + }); + it('should render a Linode label if an IP is an IP of a Linode', async () => { + const linodes = linodeFactory.buildList(1, { + ipv4: ['1.1.1.1'], + label: 'my-linode-label-that-should-be-in-the-table', + }); + + const endpoints = [ + endpointFactory.build({ host: 'example.com', ip: '1.1.1.1', port: 22 }), + endpointFactory.build({ host: 'test.com', ip: '8.8.8.8', port: 80 }), + ]; + + server.use( + rest.get('*/linode/instances', (req, res, ctx) => { + return res(ctx.json(makeResourcePage(linodes))); + }) + ); + + const props = { + endpoints, + onRemove: jest.fn(), + }; + + const { findByText, getByText } = renderWithTheme( + + ); + + // Verify Linode label renders after Linodes API request happens + await findByText(`my-linode-label-that-should-be-in-the-table:22`); + + expect(getByText(`8.8.8.8:80`)).toBeVisible(); + }); +}); diff --git a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/ServiceTargets/EndpointTable.tsx b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/ServiceTargets/EndpointTable.tsx new file mode 100644 index 00000000000..b41ef639fa9 --- /dev/null +++ b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/ServiceTargets/EndpointTable.tsx @@ -0,0 +1,105 @@ +import CloseIcon from '@mui/icons-material/Close'; +import React from 'react'; + +import { IconButton } from 'src/components/IconButton'; +import { Table } from 'src/components/Table'; +import { TableBody } from 'src/components/TableBody'; +import { TableCell } from 'src/components/TableCell'; +import { TableHead } from 'src/components/TableHead'; +import { TableRow } from 'src/components/TableRow'; +import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; +import { Typography } from 'src/components/Typography'; +import { useLinodesQuery } from 'src/queries/linodes/linodes'; + +import type { APIError, Endpoint } from '@linode/api-v4'; + +interface Props { + endpoints: Endpoint[]; + errors?: APIError[]; + onRemove: (index: number) => void; +} + +export const EndpointTable = (props: Props) => { + const { endpoints, errors, onRemove } = props; + + const { data: linodes } = useLinodesQuery( + { page_size: 500 }, + { + '+or': endpoints.map((endpoint) => ({ + ipv4: { '+contains': endpoint.ip }, + })), + } + ); + + return ( +
    + + + Endpoint + Host + + + + + {endpoints.length === 0 && ( + + )} + {endpoints.map((endpoint, idx) => { + const fieldErrors = { + host: errors?.find((e) => e.field === `endpoints[${idx}].host`) + ?.reason, + ip: errors?.find((e) => e.field === `endpoints[${idx}].ip`)?.reason, + port: errors?.find((e) => e.field === `endpoints[${idx}].port`) + ?.reason, + rate_capacity: errors?.find( + (e) => e.field === `endpoints[${idx}].rate_capacity` + )?.reason, + }; + + const linode = linodes?.data.find((linode) => + linode.ipv4.includes(endpoint.ip) + ); + + return ( + + + {linode?.label ?? endpoint.ip}:{endpoint.port} + {fieldErrors.ip && ( + theme.palette.error.main}> + {fieldErrors.ip} + + )} + {fieldErrors.port && ( + theme.palette.error.main}> + {fieldErrors.port} + + )} + {fieldErrors.rate_capacity && ( + theme.palette.error.main}> + {fieldErrors.rate_capacity} + + )} + + + {endpoint.host} + {fieldErrors.host && ( + theme.palette.error.main}> + {fieldErrors.host} + + )} + + + onRemove(idx)} + > + + + + + ); + })} + +
    + ); +}; diff --git a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/ServiceTargets/LinodeOrIPSelect.tsx b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/ServiceTargets/LinodeOrIPSelect.tsx new file mode 100644 index 00000000000..c235126a655 --- /dev/null +++ b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/ServiceTargets/LinodeOrIPSelect.tsx @@ -0,0 +1,124 @@ +import Stack from '@mui/material/Stack'; +import React from 'react'; + +import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; +import { SelectedIcon } from 'src/components/Autocomplete/Autocomplete.styles'; +import { Box } from 'src/components/Box'; +import { linodeFactory } from 'src/factories'; +import { useInfiniteLinodesQuery } from 'src/queries/linodes/linodes'; +import { useRegionsQuery } from 'src/queries/regions'; + +import type { Filter } from '@linode/api-v4'; + +interface Props { + /** + * Error text to display as helper text under the TextField. Useful for validation errors. + */ + errorText?: string; + /** + * Called when the value of the Select changes + */ + onChange: (ip: string) => void; + /** + * The id of the selected certificate + */ + value: null | string; +} + +export const LinodeOrIPSelect = (props: Props) => { + const { errorText, onChange, value } = props; + + const [inputValue, setInputValue] = React.useState(''); + + const filter: Filter = {}; + + // If the user types in the Autocomplete, API filter for Linodes. + if (inputValue) { + filter['+or'] = [ + { label: { '+contains': inputValue } }, + { ipv4: { '+contains': inputValue } }, + ]; + } + + const { + data, + error, + fetchNextPage, + hasNextPage, + isLoading, + } = useInfiniteLinodesQuery(filter); + + const { data: regions } = useRegionsQuery(); + + const linodes = data?.pages.flatMap((page) => page.data) ?? []; + + const selectedLinode = value + ? linodes?.find((linode) => linode.ipv4.includes(value)) ?? null + : null; + + const onScroll = (event: React.SyntheticEvent) => { + const listboxNode = event.currentTarget; + if ( + listboxNode.scrollTop + listboxNode.clientHeight >= + listboxNode.scrollHeight && + hasNextPage + ) { + fetchNextPage(); + } + }; + + const customIpPlaceholder = linodeFactory.build({ + ipv4: [inputValue], + label: `Use IP ${inputValue}`, + }); + + const options = [...linodes]; + + if (linodes.length === 0 && !isLoading) { + options.push(customIpPlaceholder); + } + + return ( + { + if (reason === 'input' || reason === 'clear') { + setInputValue(value); + onChange(value); + } + }} + renderOption={(props, option, state) => { + const region = + regions?.find((r) => r.id === option.region)?.label ?? option.region; + + const isCustomIp = option === customIpPlaceholder; + + return ( +
  • + + + {isCustomIp ? 'Custom IP' : option.label} + + + {isCustomIp ? option.ipv4[0] : `${option.ipv4[0]} - ${region}`} + + + +
  • + ); + }} + errorText={error?.[0]?.reason ?? errorText} + filterOptions={(x) => x} + fullWidth + inputValue={selectedLinode ? selectedLinode.label : inputValue} + label="Linode or Public IP Address" + loading={isLoading} + onChange={(e, value) => onChange(value?.ipv4[0] ?? '')} + options={options} + placeholder="Select Linode or Enter IPv4 Address" + value={linodes.length === 0 ? customIpPlaceholder : selectedLinode} + /> + ); +}; diff --git a/packages/manager/src/foundations/themes/dark.ts b/packages/manager/src/foundations/themes/dark.ts index 9be8e511c2c..0639118ccc9 100644 --- a/packages/manager/src/foundations/themes/dark.ts +++ b/packages/manager/src/foundations/themes/dark.ts @@ -556,6 +556,11 @@ export const darkTheme: ThemeOptions = { paper: '#2e3238', }, divider: primaryColors.divider, + error: { + dark: customDarkModeOptions.color.red, + light: customDarkModeOptions.color.red, + main: customDarkModeOptions.color.red, + }, mode: 'dark', primary: primaryColors, text: { diff --git a/packages/manager/src/foundations/themes/light.ts b/packages/manager/src/foundations/themes/light.ts index e466a652a77..c52418234d6 100644 --- a/packages/manager/src/foundations/themes/light.ts +++ b/packages/manager/src/foundations/themes/light.ts @@ -1413,9 +1413,9 @@ export const lightTheme: ThemeOptions = { }, divider: primaryColors.divider, error: { - dark: '#cd2227', - light: '#f8dedf', - main: '#f8dedf', + dark: color.red, + light: color.red, + main: color.red, }, info: { dark: '#3682dd', diff --git a/packages/manager/src/queries/aglb/serviceTargets.ts b/packages/manager/src/queries/aglb/serviceTargets.ts index 870cfd83647..5ba15e53620 100644 --- a/packages/manager/src/queries/aglb/serviceTargets.ts +++ b/packages/manager/src/queries/aglb/serviceTargets.ts @@ -1,17 +1,20 @@ import { - ServiceTarget, + createLoadbalancerServiceTarget, deleteLoadbalancerServiceTarget, getLoadbalancerServiceTargets, } from '@linode/api-v4'; -import { +import { useMutation, useQuery, useQueryClient } from 'react-query'; + +import { QUERY_KEY } from './loadbalancers'; + +import type { APIError, Filter, Params, ResourcePage, -} from '@linode/api-v4/lib/types'; -import { useMutation, useQuery, useQueryClient } from 'react-query'; - -import { QUERY_KEY } from './loadbalancers'; + ServiceTarget, + ServiceTargetPayload, +} from '@linode/api-v4'; export const useLoadBalancerServiceTargetsQuery = ( loadbalancerId: number, @@ -25,6 +28,23 @@ export const useLoadBalancerServiceTargetsQuery = ( ); }; +export const useServiceTargetCreateMutation = (loadbalancerId: number) => { + const queryClient = useQueryClient(); + return useMutation( + (data) => createLoadbalancerServiceTarget(loadbalancerId, data), + { + onSuccess() { + queryClient.invalidateQueries([ + QUERY_KEY, + 'aglb', + loadbalancerId, + 'service-targets', + ]); + }, + } + ); +}; + export const useLoadBalancerServiceTargetDeleteMutation = ( loadbalancerId: number, serviceTargetId: number diff --git a/packages/validation/src/loadbalancers.schema.ts b/packages/validation/src/loadbalancers.schema.ts index ab517b444f2..304ebecdfe8 100644 --- a/packages/validation/src/loadbalancers.schema.ts +++ b/packages/validation/src/loadbalancers.schema.ts @@ -21,3 +21,30 @@ export const certificateConfigSchema = object({ }) ), }); + +export const EndpointSchema = object({ + ip: string().required('IP is required.'), + host: string(), + port: number().required('Port is required.').min(0).max(65_535), + rate_capacity: number().required('Rate Capacity is required.'), +}); + +const HealthCheckSchema = object({ + protocol: string().oneOf(['http', 'tcp']), + interval: number().min(0), + timeout: number().min(0), + unhealthy_threshold: number().min(0), + healthy_threshold: number().min(0), + path: string(), + host: string(), +}); + +export const CreateServiceTargetSchema = object({ + label: string().required('Label is required.'), + endpoints: array(EndpointSchema).required(), + ca_certificate: string().nullable(), + load_balancing_policy: string() + .required() + .oneOf(['round_robin', 'least_request', 'ring_hash', 'random', 'maglev']), + healthcheck: HealthCheckSchema, +});