diff --git a/packages/api-v4/src/aglb/configurations.ts b/packages/api-v4/src/aglb/configurations.ts index ec299962e7f..dd647471946 100644 --- a/packages/api-v4/src/aglb/configurations.ts +++ b/packages/api-v4/src/aglb/configurations.ts @@ -75,7 +75,7 @@ export const createLoadbalancerConfiguration = ( export const updateLoadbalancerConfiguration = ( loadbalancerId: number, configurationId: number, - data: Partial + data: Partial ) => Request( setURL( diff --git a/packages/api-v4/src/aglb/types.ts b/packages/api-v4/src/aglb/types.ts index f3108a60239..083cbe2a04d 100644 --- a/packages/api-v4/src/aglb/types.ts +++ b/packages/api-v4/src/aglb/types.ts @@ -24,7 +24,7 @@ export interface UpdateLoadbalancerPayload { configuration_ids?: number[]; } -type Protocol = 'TCP' | 'HTTP' | 'HTTPS'; +type Protocol = 'tcp' | 'http' | 'https'; type Policy = | 'round_robin' @@ -69,7 +69,7 @@ export interface ConfigurationPayload { label: string; port: number; protocol: Protocol; - certificate_table: CertificateTable[]; + certificates: CertificateConfig[]; routes?: RoutePayload[]; route_ids?: number[]; } @@ -79,13 +79,13 @@ export interface Configuration { label: string; port: number; protocol: Protocol; - certificate_table: CertificateTable[]; - routes: string[]; + certificates: CertificateConfig[]; + routes: { id: number; label: string }[]; } -export interface CertificateTable { - sni_hostname: string; - certificate_id: string; +export interface CertificateConfig { + hostname: string; + id: number; } export interface Rule { diff --git a/packages/manager/.changeset/pr-9591-upcoming-features-1692984602898.md b/packages/manager/.changeset/pr-9591-upcoming-features-1692984602898.md new file mode 100644 index 00000000000..2a41728fc70 --- /dev/null +++ b/packages/manager/.changeset/pr-9591-upcoming-features-1692984602898.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +AGLB Details - Configuration Tab ([#9591](https://github.com/linode/manager/pull/9591)) diff --git a/packages/manager/src/factories/aglb.ts b/packages/manager/src/factories/aglb.ts index dad4a1d6e0b..cf6c2c7f81c 100644 --- a/packages/manager/src/factories/aglb.ts +++ b/packages/manager/src/factories/aglb.ts @@ -72,17 +72,21 @@ LhIhYpJ8UsCVt5snWo2N+M+6ANh5tpWdQnEK6zILh4tRbuzaiHgb // Configuration endpoints // ******************** export const configurationFactory = Factory.Sync.makeFactory({ - certificate_table: [ + certificates: [ { - certificate_id: 'cert-12345', - sni_hostname: 'example.com', + hostname: 'example.com', + id: 0, }, ], id: Factory.each((i) => i), - label: Factory.each((i) => `entrypoint${i}`), + label: Factory.each((i) => `configuration-${i}`), port: 80, - protocol: 'HTTP', - routes: ['images-route'], + protocol: 'http', + routes: [ + { id: 0, label: 'route-0' }, + { id: 1, label: 'route-1' }, + { id: 2, label: 'route-2' }, + ], }); // *********************** @@ -101,15 +105,15 @@ export const createLoadbalancerWithAllChildrenFactory = Factory.Sync.makeFactory { configurations: [ { - certificate_table: [ + certificates: [ { - certificate_id: 'cert-12345', - sni_hostname: 'example.com', + id: 1, + hostname: 'example.com', }, ], label: 'myentrypoint1', port: 80, - protocol: 'HTTP', + protocol: 'http', routes: [ { label: 'my-route', diff --git a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Certificates/CertificateSelect.tsx b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Certificates/CertificateSelect.tsx new file mode 100644 index 00000000000..0cd5c2d7275 --- /dev/null +++ b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Certificates/CertificateSelect.tsx @@ -0,0 +1,92 @@ +import Autocomplete from '@mui/material/Autocomplete'; +import React from 'react'; + +import { TextField } from 'src/components/TextField'; +import { useLoadBalancerCertificatesInfiniteQuery } from 'src/queries/aglb/certificates'; + +import type { Certificate, Filter } from '@linode/api-v4'; + +interface Props { + /** + * Error text to display as helper text under the TextField. Useful for validation errors. + */ + errorText?: string; + /** + * The TextField label + * @default Certificate + */ + label?: string; + /** + * The id of the Load Balancer you want to show certificates for + */ + loadbalancerId: number; + /** + * Called when the value of the Select changes + */ + onChange: (certificate: Certificate | null) => void; + /** + * The id of the selected certificate + */ + value: number; +} + +export const CertificateSelect = (props: Props) => { + const { errorText, label, loadbalancerId, onChange, value } = props; + + const [inputValue, setInputValue] = React.useState(''); + + const filter: Filter = {}; + + if (inputValue) { + filter['label'] = { '+contains': inputValue }; + } + + const { + data, + error, + fetchNextPage, + hasNextPage, + isLoading, + } = useLoadBalancerCertificatesInfiniteQuery(loadbalancerId, filter); + + const certificates = data?.pages.flatMap((page) => page.data); + + const selectedCertificate = + certificates?.find((cert) => cert.id === value) ?? null; + + const onScroll = (event: React.SyntheticEvent) => { + const listboxNode = event.currentTarget; + if ( + listboxNode.scrollTop + listboxNode.clientHeight >= + listboxNode.scrollHeight && + hasNextPage + ) { + fetchNextPage(); + } + }; + + return ( + { + if (reason === 'input') { + setInputValue(value); + } + }} + renderInput={(params) => ( + + )} + inputValue={selectedCertificate ? selectedCertificate.label : inputValue} + loading={isLoading} + onChange={(e, value) => onChange(value)} + options={certificates ?? []} + value={selectedCertificate} + /> + ); +}; diff --git a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Configurations/ApplyCertificatesDrawer.tsx b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Configurations/ApplyCertificatesDrawer.tsx new file mode 100644 index 00000000000..a40c6ac4609 --- /dev/null +++ b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Configurations/ApplyCertificatesDrawer.tsx @@ -0,0 +1,110 @@ +import { Configuration } from '@linode/api-v4'; +import { certificateConfigSchema } from '@linode/validation'; +import { useFormik } from 'formik'; +import React, { useEffect } from 'react'; + +import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; +import { Box } from 'src/components/Box'; +import { Button } from 'src/components/Button/Button'; +import { Code } from 'src/components/Code/Code'; +import { Divider } from 'src/components/Divider'; +import { Drawer } from 'src/components/Drawer'; +import { Link } from 'src/components/Link'; +import { TextField } from 'src/components/TextField'; +import { Typography } from 'src/components/Typography'; + +import { CertificateSelect } from '../Certificates/CertificateSelect'; + +interface Props { + loadbalancerId: number; + onAdd: (certificates: Configuration['certificates']) => void; + onClose: () => void; + open: boolean; +} + +const defaultCertItem = { + hostname: '', + id: -1, +}; + +export const ApplyCertificatesDrawer = (props: Props) => { + const { loadbalancerId, onAdd, onClose, open } = props; + + const formik = useFormik<{ certificates: Configuration['certificates'] }>({ + initialValues: { + certificates: [defaultCertItem], + }, + onSubmit(values) { + onAdd(values.certificates); + onClose(); + }, + validateOnChange: false, + validationSchema: certificateConfigSchema, + }); + + useEffect(() => { + if (open) { + formik.resetForm(); + } + }, [open]); + + const onAddAnother = () => { + formik.setFieldValue('certificates', [ + ...formik.values.certificates, + defaultCertItem, + ]); + }; + + return ( + + {/* @TODO Add AGLB docs link - M3-7041 */} + + Input the host header that the Load Balancer will repsond to and the + respective certificate to deliver. Use * as a wildcard + apply to any host. Learn more. + +
+ {formik.values.certificates.map(({ hostname, id }, index) => ( + + + formik.setFieldValue( + `certificates.${index}.hostname`, + e.target.value + ) + } + errorText={formik.errors.certificates?.[index]?.['hostname']} + label="Host Header" + value={hostname} + /> + + formik.setFieldValue( + `certificates.${index}.id`, + certificate?.id ?? null + ) + } + errorText={formik.errors.certificates?.[index]?.['id']} + loadbalancerId={loadbalancerId} + value={id} + /> + + + ))} + + + +
+ ); +}; diff --git a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Configurations/CertificateTable.tsx b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Configurations/CertificateTable.tsx new file mode 100644 index 00000000000..00154f2b99b --- /dev/null +++ b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Configurations/CertificateTable.tsx @@ -0,0 +1,63 @@ +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 { useLoadBalancerCertificatesQuery } from 'src/queries/aglb/certificates'; + +import type { Configuration } from '@linode/api-v4'; + +interface Props { + certificates: Configuration['certificates']; + loadbalancerId: number; + onRemove: (index: number) => void; +} + +export const CertificateTable = (props: Props) => { + const { certificates, loadbalancerId, onRemove } = props; + + const { data } = useLoadBalancerCertificatesQuery( + loadbalancerId, + {}, + { '+or': certificates.map((cert) => ({ id: cert.id })) } + ); + + return ( + + + + Certificate + Host Header + + + + + {certificates.length === 0 && } + {certificates.map((cert, idx) => { + const certificate = data?.data.find((c) => c.id === cert.id); + return ( + + {certificate?.label ?? cert.id} + {cert.hostname} + + onRemove(idx)} + > + + + + + ); + })} + +
+ ); +}; diff --git a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Configurations/ConfigurationAccordion.tsx b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Configurations/ConfigurationAccordion.tsx new file mode 100644 index 00000000000..dfb4b3df9ba --- /dev/null +++ b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Configurations/ConfigurationAccordion.tsx @@ -0,0 +1,189 @@ +import Stack from '@mui/material/Stack'; +import { useFormik } from 'formik'; +import React, { useState } from 'react'; + +import { Accordion } from 'src/components/Accordion'; +import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; +import { Box } from 'src/components/Box'; +import { Button } from 'src/components/Button/Button'; +import { Divider } from 'src/components/Divider'; +import Select from 'src/components/EnhancedSelect/Select'; +import { StatusIcon } from 'src/components/StatusIcon/StatusIcon'; +import { TextField } from 'src/components/TextField'; +import { TooltipIcon } from 'src/components/TooltipIcon'; +import { Typography } from 'src/components/Typography'; +import { useLoadBalancerConfigurationMutation } from 'src/queries/aglb/configurations'; +import { getErrorMap } from 'src/utilities/errorUtils'; +import { pluralize } from 'src/utilities/pluralize'; + +import { ApplyCertificatesDrawer } from './ApplyCertificatesDrawer'; +import { CertificateTable } from './CertificateTable'; + +import type { Configuration } from '@linode/api-v4'; +import { InputLabel } from 'src/components/InputLabel'; + +interface Props { + configuration: Configuration; + loadbalancerId: number; +} + +export const ConfigurationAccordion = (props: Props) => { + const { configuration, loadbalancerId } = props; + const [isApplyCertDialogOpen, setIsApplyCertDialogOpen] = useState(false); + + const { + error, + isLoading, + mutateAsync, + } = useLoadBalancerConfigurationMutation(loadbalancerId, configuration.id); + + const formik = useFormik({ + enableReinitialize: true, + initialValues: configuration, + onSubmit(values) { + mutateAsync(values); + }, + }); + + const protocolOptions = [ + { label: 'HTTPS', value: 'https' }, + { label: 'HTTP', value: 'http' }, + { label: 'TCP', value: 'tcp' }, + ]; + + const handleRemoveCert = (index: number) => { + formik.values.certificates.splice(index, 1); + formik.setFieldValue('certificates', formik.values.certificates); + }; + + const handleAddCerts = (certificates: Configuration['certificates']) => { + formik.setFieldValue('certificates', [ + ...formik.values.certificates, + ...certificates, + ]); + }; + + const errorMap = getErrorMap(['protocol', 'port', 'label'], error); + + return ( + + + {configuration.label} + + + Port {configuration.port} -{' '} + {pluralize('Route', 'Routes', configuration.routes.length)} + + + {/* @TODO Hook up endpoint status */} + + + Endpoints: + + 4 up + + + 6 down + + + ID: {configuration.id} + + + + } + headingProps={{ sx: { width: '100%' } }} + > +
+ Details + + + +