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 (
+
+
+
+ );
+};
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,
+});