diff --git a/docker-compose.yml b/docker-compose.yml index 229dd704059..51f2f01e6a7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -35,6 +35,8 @@ x-e2e-env: # Cloud Manager-specific test configuration. CY_TEST_SUITE: ${CY_TEST_SUITE} CY_TEST_REGION: ${CY_TEST_REGION} + CY_TEST_TAGS: ${CY_TEST_TAGS} + CY_TEST_DISABLE_RETRIES: ${CY_TEST_DISABLE_RETRIES} # Cypress environment variables for alternative parallelization. CY_TEST_SPLIT_RUN: ${CY_TEST_SPLIT_RUN} @@ -104,12 +106,23 @@ services: timeout: 10s retries: 10 + # Generic end-to-end test runner for Cloud's primary testing pipeline. + # Configured to run against a local Cloud instance. e2e: <<: *default-runner environment: <<: *default-env MANAGER_OAUTH: ${MANAGER_OAUTH} + # End-to-end test runner for Cloud's synthetic monitoring tests. + # Configured to run against a remote Cloud instance hosted at some URL. + e2e_heimdall: + <<: *default-runner + depends_on: [] + environment: + <<: *default-env + MANAGER_OAUTH: ${MANAGER_OAUTH} + region-1: build: context: . diff --git a/docs/development-guide/08-testing.md b/docs/development-guide/08-testing.md index febe5b53519..48fcf586379 100644 --- a/docs/development-guide/08-testing.md +++ b/docs/development-guide/08-testing.md @@ -215,6 +215,7 @@ Environment variables related to Cypress logging and reporting, as well as repor | `CY_TEST_USER_REPORT` | Log test account information when tests begin | `1` | Unset; disabled by default | | `CY_TEST_JUNIT_REPORT` | Enable JUnit reporting | `1` | Unset; disabled by default | | `CY_TEST_DISABLE_FILE_WATCHING` | Disable file watching in Cypress UI | `1` | Unset; disabled by default | +| `CY_TEST_DISABLE_RETRIES` | Disable test retries on failure in CI | `1` | Unset; disabled by default | | `CY_TEST_FAIL_ON_MANAGED` | Fail affected tests when Managed is enabled | `1` | Unset; disabled by default | ### Writing End-to-End Tests diff --git a/packages/api-v4/CHANGELOG.md b/packages/api-v4/CHANGELOG.md index d0d32e3e2d4..6af57ce2702 100644 --- a/packages/api-v4/CHANGELOG.md +++ b/packages/api-v4/CHANGELOG.md @@ -1,3 +1,20 @@ +## [2024-08-05] - v0.123.0 + +### Added: + +- `site_type` to the linode instance type ([#10714](https://github.com/linode/manager/pull/10714)) + +### Changed: + +- Update Object Storage types with more descriptive names ([#10686](https://github.com/linode/manager/pull/10686)) +- Support null values in `Interface` type ([#10690](https://github.com/linode/manager/pull/10690)) +- Linode, Volume, and VolumeRequestPayload interfaces and VolumeStatus, AccountCapability, and Capabilities types to reflect Block Storage Encryption changes ([#10716](https://github.com/linode/manager/pull/10716)) + +### Upcoming Features: + +- Add MetricDefinitions, Dimension, JWETokenPayload, JWEToken and metricDefinitions, dashboard by id and jwe token api calls ([#10676](https://github.com/linode/manager/pull/10676)) +- Add new /v4/object-storage/endpoints endpoint ([#10677](https://github.com/linode/manager/pull/10677)) + ## [2024-07-22] - v0.122.0 ### Changed: diff --git a/packages/api-v4/package.json b/packages/api-v4/package.json index fa5bcb2b2e4..67e970d0b7a 100644 --- a/packages/api-v4/package.json +++ b/packages/api-v4/package.json @@ -1,6 +1,6 @@ { "name": "@linode/api-v4", - "version": "0.122.0", + "version": "0.123.0", "homepage": "https://github.com/linode/manager/tree/develop/packages/api-v4", "bugs": { "url": "https://github.com/linode/manager/issues" diff --git a/packages/api-v4/src/account/types.ts b/packages/api-v4/src/account/types.ts index 7921bdc4543..fafa09e4d3c 100644 --- a/packages/api-v4/src/account/types.ts +++ b/packages/api-v4/src/account/types.ts @@ -62,6 +62,7 @@ export type BillingSource = 'linode' | 'akamai'; export type AccountCapability = | 'Akamai Cloud Load Balancer' | 'Block Storage' + | 'Block Storage Encryption' | 'Cloud Firewall' | 'CloudPulse' | 'Disk Encryption' diff --git a/packages/api-v4/src/aclb/certificates.ts b/packages/api-v4/src/aclb/certificates.ts deleted file mode 100644 index 38af6d3f0a9..00000000000 --- a/packages/api-v4/src/aclb/certificates.ts +++ /dev/null @@ -1,110 +0,0 @@ -import Request, { - setData, - setMethod, - setParams, - setURL, - setXFilter, -} from '../request'; -import { BETA_API_ROOT } from '../constants'; -import { Filter, Params, ResourcePage } from '../types'; -import { - Certificate, - CreateCertificatePayload, - UpdateCertificatePayload, -} from './types'; -import { - CreateCertificateSchema, - UpdateCertificateSchema, -} from '@linode/validation'; - -/** - * getLoadbalancerCertificates - * - * Returns a paginated list of Akamai Cloud Load Balancer certificates - */ -export const getLoadbalancerCertificates = ( - loadbalancerId: number, - params?: Params, - filter?: Filter -) => - Request>( - setURL( - `${BETA_API_ROOT}/aclb/${encodeURIComponent(loadbalancerId)}/certificates` - ), - setMethod('GET'), - setParams(params), - setXFilter(filter) - ); - -/** - * getLoadbalancerCertificate - * - * Returns an Akamai Cloud Load Balancer certificate - */ -export const getLoadbalancerCertificate = ( - loadbalancerId: number, - certificateId: number -) => - Request( - setURL( - `${BETA_API_ROOT}/aclb/${encodeURIComponent( - loadbalancerId - )}/certificates/${encodeURIComponent(certificateId)}` - ), - setMethod('GET') - ); - -/** - * createLoadbalancerCertificate - * - * Creates an Akamai Cloud Load Balancer certificate - */ -export const createLoadbalancerCertificate = ( - loadbalancerId: number, - data: CreateCertificatePayload -) => - Request( - setURL( - `${BETA_API_ROOT}/aclb/${encodeURIComponent(loadbalancerId)}/certificates` - ), - setMethod('POST'), - setData(data, CreateCertificateSchema) - ); - -/** - * updateLoadbalancerCertificate - * - * Updates an Akamai Cloud Load Balancer certificate - */ -export const updateLoadbalancerCertificate = ( - loadbalancerId: number, - certificateId: number, - data: Partial -) => - Request( - setURL( - `${BETA_API_ROOT}/aclb/${encodeURIComponent( - loadbalancerId - )}/certificates/${encodeURIComponent(certificateId)}` - ), - setMethod('PUT'), - setData(data, UpdateCertificateSchema) - ); - -/** - * deleteLoadbalancerCertificate - * - * Deletes an Akamai Cloud Load Balancer certificate - */ -export const deleteLoadbalancerCertificate = ( - loadbalancerId: number, - certificateId: number -) => - Request<{}>( - setURL( - `${BETA_API_ROOT}/aclb/${encodeURIComponent( - loadbalancerId - )}/certificates/${encodeURIComponent(certificateId)}` - ), - setMethod('DELETE') - ); diff --git a/packages/api-v4/src/aclb/configurations.ts b/packages/api-v4/src/aclb/configurations.ts deleted file mode 100644 index 8fe0d9ca30b..00000000000 --- a/packages/api-v4/src/aclb/configurations.ts +++ /dev/null @@ -1,132 +0,0 @@ -import Request, { - setData, - setMethod, - setParams, - setURL, - setXFilter, -} from '../request'; -import { Filter, Params, ResourcePage } from '../types'; -import { BETA_API_ROOT } from '../constants'; -import type { - Configuration, - ConfigurationPayload, - ConfigurationsEndpointHealth, - UpdateConfigurationPayload, -} from './types'; -import { - CreateConfigurationSchema, - UpdateConfigurationSchema, -} from '@linode/validation'; - -/** - * getLoadbalancerConfigurations - * - * Returns a paginated list of Akamai Cloud Load Balancer configurations - */ -export const getLoadbalancerConfigurations = ( - loadbalancerId: number, - params?: Params, - filter?: Filter -) => - Request>( - setURL( - `${BETA_API_ROOT}/aclb/${encodeURIComponent( - loadbalancerId - )}/configurations` - ), - setMethod('GET'), - setParams(params), - setXFilter(filter) - ); - -/** - * getLoadbalancerConfiguration - * - * Returns an Akamai Cloud Load Balancer configuration - */ -export const getLoadbalancerConfiguration = ( - loadbalancerId: number, - configurationId: number -) => - Request( - setURL( - `${BETA_API_ROOT}/aclb/${encodeURIComponent( - loadbalancerId - )}/configurations/${encodeURIComponent(configurationId)}` - ), - setMethod('GET') - ); - -/** - * getLoadbalancerConfigurationsEndpointHealth - * - * Returns endpoint health for an Akamai Cloud Load Balancer configuration - */ -export const getLoadbalancerConfigurationsEndpointHealth = ( - loadbalancerId: number -) => - Request( - setURL( - `${BETA_API_ROOT}/aclb/${encodeURIComponent( - loadbalancerId - )}/configurations/endpoints-health` - ), - setMethod('GET') - ); - -/** - * createLoadbalancerConfiguration - * - * Creates an Akamai Cloud Load Balancer configuration - */ -export const createLoadbalancerConfiguration = ( - loadbalancerId: number, - data: ConfigurationPayload -) => - Request( - setURL( - `${BETA_API_ROOT}/aclb/${encodeURIComponent( - loadbalancerId - )}/configurations` - ), - setData(data, CreateConfigurationSchema), - setMethod('POST') - ); - -/** - * updateLoadbalancerConfiguration - * - * Updates an Akamai Cloud Load Balancer configuration - */ -export const updateLoadbalancerConfiguration = ( - loadbalancerId: number, - configurationId: number, - data: UpdateConfigurationPayload -) => - Request( - setURL( - `${BETA_API_ROOT}/aclb/${encodeURIComponent( - loadbalancerId - )}/configurations/${encodeURIComponent(configurationId)}` - ), - setData(data, UpdateConfigurationSchema), - setMethod('PUT') - ); - -/** - * deleteLoadbalancerConfiguration - * - * Deletes an Akamai Cloud Load Balancer configuration - */ -export const deleteLoadbalancerConfiguration = ( - loadbalancerId: number, - configurationId: number -) => - Request<{}>( - setURL( - `${BETA_API_ROOT}/aclb/${encodeURIComponent( - loadbalancerId - )}/configurations/${encodeURIComponent(configurationId)}` - ), - setMethod('DELETE') - ); diff --git a/packages/api-v4/src/aclb/index.ts b/packages/api-v4/src/aclb/index.ts deleted file mode 100644 index c680ac9a9ff..00000000000 --- a/packages/api-v4/src/aclb/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -export * from './configurations'; - -export * from './loadbalancers'; - -export * from './routes'; - -export * from './service-targets'; - -export * from './types'; - -export * from './certificates'; diff --git a/packages/api-v4/src/aclb/loadbalancers.ts b/packages/api-v4/src/aclb/loadbalancers.ts deleted file mode 100644 index 2176bb3e7e5..00000000000 --- a/packages/api-v4/src/aclb/loadbalancers.ts +++ /dev/null @@ -1,102 +0,0 @@ -import Request, { - setData, - setMethod, - setParams, - setURL, - setXFilter, -} from '../request'; -import { BETA_API_ROOT } from '../constants'; -import { Filter, Params, ResourcePage } from '../types'; -import type { - CreateBasicLoadbalancerPayload, - CreateLoadbalancerPayload, - LoadBalancerEndpointHealth, - Loadbalancer, - UpdateLoadbalancerPayload, -} from './types'; -import { CreateBasicLoadbalancerSchema } from '@linode/validation'; - -/** - * getLoadbalancers - * - * Returns a paginated list of Akamai Cloud Load Balancers - */ -export const getLoadbalancers = (params?: Params, filter?: Filter) => - Request>( - setURL(`${BETA_API_ROOT}/aclb`), - setMethod('GET'), - setParams(params), - setXFilter(filter) - ); - -/** - * getLoadbalancer - * - * Returns an Akamai Cloud Load Balancer - */ -export const getLoadbalancer = (id: number) => - Request( - setURL(`${BETA_API_ROOT}/aclb/${encodeURIComponent(id)}`), - setMethod('GET') - ); - -/** - * getLoadbalancerEndpointHealth - * - * Returns the general endpoint health of an Akamai Cloud Load Balancer - */ -export const getLoadbalancerEndpointHealth = (id: number) => - Request( - setURL(`${BETA_API_ROOT}/aclb/${encodeURIComponent(id)}/endpoints-health`), - setMethod('GET') - ); - -/** - * createLoadbalancer - * - * Creates an Akamai Cloud Load Balancer - */ -export const createLoadbalancer = (data: CreateLoadbalancerPayload) => - Request( - setURL(`${BETA_API_ROOT}/aclb`), - setData(data), - setMethod('POST') - ); - -/** - * createBasicLoadbalancer - * - * Creates an unconfigured Akamai Cloud Load Balancer - */ -export const createBasicLoadbalancer = (data: CreateBasicLoadbalancerPayload) => - Request( - setURL(`${BETA_API_ROOT}/aclb`), - setData(data, CreateBasicLoadbalancerSchema), - setMethod('POST') - ); - -/** - * updateLoadbalancer - * - * Updates an Akamai Cloud Load Balancer - */ -export const updateLoadbalancer = ( - id: number, - data: UpdateLoadbalancerPayload -) => - Request( - setURL(`${BETA_API_ROOT}/aclb/${encodeURIComponent(id)}`), - setData(data), - setMethod('PUT') - ); - -/** - * deleteLoadbalancer - * - * Deletes an Akamai Cloud Load Balancer - */ -export const deleteLoadbalancer = (id: number) => - Request<{}>( - setURL(`${BETA_API_ROOT}/aclb/${encodeURIComponent(id)}`), - setMethod('DELETE') - ); diff --git a/packages/api-v4/src/aclb/routes.ts b/packages/api-v4/src/aclb/routes.ts deleted file mode 100644 index 91a7aa42686..00000000000 --- a/packages/api-v4/src/aclb/routes.ts +++ /dev/null @@ -1,100 +0,0 @@ -import Request, { - setData, - setMethod, - setParams, - setURL, - setXFilter, -} from '../request'; -import { Filter, Params, ResourcePage } from '../types'; -import { BETA_API_ROOT } from '../constants'; -import type { Route, CreateRoutePayload, UpdateRoutePayload } from './types'; -import { UpdateRouteSchema, CreateRouteSchema } from '@linode/validation'; - -/** - * getLoadbalancerRoutes - * - * Returns a paginated list of Akamai Cloud Load Balancer routes - */ -export const getLoadbalancerRoutes = ( - loadbalancerId: number, - params?: Params, - filter?: Filter -) => - Request>( - setURL( - `${BETA_API_ROOT}/aclb/${encodeURIComponent(loadbalancerId)}/routes` - ), - setMethod('GET'), - setParams(params), - setXFilter(filter) - ); - -/** - * getLoadbalancerRoute - * - * Returns an Akamai Cloud Load Balancer route - */ -export const getLoadbalancerRoute = (loadbalancerId: number, routeId: number) => - Request( - setURL( - `${BETA_API_ROOT}/aclb/${encodeURIComponent( - loadbalancerId - )}/routes/${encodeURIComponent(routeId)}` - ), - setMethod('GET') - ); - -/** - * createLoadbalancerRoute - * - * Creates an Akamai Cloud Load Balancer route - */ -export const createLoadbalancerRoute = ( - loadbalancerId: number, - data: CreateRoutePayload -) => - Request( - setURL( - `${BETA_API_ROOT}/aclb/${encodeURIComponent(loadbalancerId)}/routes` - ), - setData(data, CreateRouteSchema), - setMethod('POST') - ); - -/** - * updateLoadbalancerRoute - * - * Updates an Akamai Cloud Load Balancer route - */ -export const updateLoadbalancerRoute = ( - loadbalancerId: number, - routeId: number, - data: UpdateRoutePayload -) => - Request( - setURL( - `${BETA_API_ROOT}/aclb/${encodeURIComponent( - loadbalancerId - )}/routes/${encodeURIComponent(routeId)}` - ), - setData(data, UpdateRouteSchema), - setMethod('PUT') - ); - -/** - * deleteLoadbalancerRoute - * - * Deletes an Akamai Cloud Load Balancer route - */ -export const deleteLoadbalancerRoute = ( - loadbalancerId: number, - routeId: number -) => - Request<{}>( - setURL( - `${BETA_API_ROOT}/aclb/${encodeURIComponent( - loadbalancerId - )}/routes/${encodeURIComponent(routeId)}` - ), - setMethod('DELETE') - ); diff --git a/packages/api-v4/src/aclb/service-targets.ts b/packages/api-v4/src/aclb/service-targets.ts deleted file mode 100644 index 40ef68a8939..00000000000 --- a/packages/api-v4/src/aclb/service-targets.ts +++ /dev/null @@ -1,129 +0,0 @@ -import Request, { - setData, - setMethod, - setParams, - setURL, - setXFilter, -} from '../request'; -import { Filter, Params, ResourcePage } from '../types'; -import { BETA_API_ROOT } from '../constants'; -import type { - ServiceTarget, - ServiceTargetPayload, - ServiceTargetsEndpointHealth, -} from './types'; -import { - CreateServiceTargetSchema, - UpdateServiceTargetSchema, -} from '@linode/validation'; - -/** - * getLoadbalancerServiceTargets - * - * Returns a paginated list of Akamai Cloud Load Balancer service targets - */ -export const getLoadbalancerServiceTargets = ( - loadbalancerId: number, - params?: Params, - filter?: Filter -) => - Request>( - setURL( - `${BETA_API_ROOT}/aclb/${encodeURIComponent( - loadbalancerId - )}/service-targets` - ), - setMethod('GET'), - setParams(params), - setXFilter(filter) - ); - -/** - * getServiceTarget - * - * Returns an Akamai Cloud Load Balancer service target - */ -export const getServiceTarget = ( - loadbalancerId: number, - serviceTargetId: number -) => - Request( - setURL( - `${BETA_API_ROOT}/aclb/${encodeURIComponent( - loadbalancerId - )}/service-targets/${encodeURIComponent(serviceTargetId)}` - ), - setMethod('GET') - ); - -/** - * getServiceTargetsEndpointHealth - * - * Returns endpoint health data for each service targets on an Akamai Cloud Load Balancer - */ -export const getServiceTargetsEndpointHealth = (loadbalancerId: number) => - Request( - setURL( - `${BETA_API_ROOT}/aclb/${encodeURIComponent( - loadbalancerId - )}/service-targets/endpoints-health` - ), - setMethod('GET') - ); - -/** - * createLoadbalancerServiceTarget - * - * Creates an Akamai Cloud Load Balancer service target - */ -export const createLoadbalancerServiceTarget = ( - loadbalancerId: number, - data: ServiceTargetPayload -) => - Request( - setURL( - `${BETA_API_ROOT}/aclb/${encodeURIComponent( - loadbalancerId - )}/service-targets` - ), - setData(data, CreateServiceTargetSchema), - setMethod('POST') - ); - -/** - * updateLoadbalancerServiceTarget - * - * Updates an Akamai Cloud Load Balancer service target - */ -export const updateLoadbalancerServiceTarget = ( - loadbalancerId: number, - serviceTargetId: number, - data: Partial -) => - Request( - setURL( - `${BETA_API_ROOT}/aclb/${encodeURIComponent( - loadbalancerId - )}/service-targets/${encodeURIComponent(serviceTargetId)}` - ), - setData(data, UpdateServiceTargetSchema), - setMethod('PUT') - ); - -/** - * deleteLoadbalancerServiceTarget - * - * Deletes an Akamai Cloud Load Balancer service target - */ -export const deleteLoadbalancerServiceTarget = ( - loadbalancerId: number, - serviceTargetId: number -) => - Request<{}>( - setURL( - `${BETA_API_ROOT}/aclb/${encodeURIComponent( - loadbalancerId - )}/service-targets/${encodeURIComponent(serviceTargetId)}` - ), - setMethod('DELETE') - ); diff --git a/packages/api-v4/src/aclb/types.ts b/packages/api-v4/src/aclb/types.ts deleted file mode 100644 index 4eb00ce4b00..00000000000 --- a/packages/api-v4/src/aclb/types.ts +++ /dev/null @@ -1,243 +0,0 @@ -export interface Loadbalancer { - id: number; - tags: string[]; - label: string; - regions: string[]; - hostname: string; - configurations: { - id: number; - label: string; - }[]; -} - -export interface CreateLoadbalancerPayload { - label: string; - regions: string[]; - tags?: string[]; - configurations?: ConfigurationPayload[]; -} - -/** - * TODO: ACLB - remove when we move to full creation flow - */ -export interface CreateBasicLoadbalancerPayload { - label: string; -} - -export interface UpdateLoadbalancerPayload { - label?: string; - regions?: string[]; - tags?: string[]; - configuration_ids?: number[]; -} - -export type Protocol = 'tcp' | 'http' | 'https'; - -type RouteProtocol = 'tcp' | 'http'; - -type Policy = - | 'round_robin' - | 'least_request' - | 'ring_hash' - | 'random' - | 'maglev'; - -export type MatchField = - | 'always_match' - | 'path_prefix' - | 'path_regex' - | 'query' - | 'header' - | 'method'; - -export interface RoutePayload { - label: string; - protocol: Protocol; - rules: RuleCreatePayload[]; -} - -export interface Route { - id: number; - label: string; - protocol: RouteProtocol; - rules: { - match_condition?: MatchCondition; - service_targets: { - id: number; - label: string; - percentage: number; - }[]; - }[]; -} - -export type UpdateRoutePayload = Partial<{ - label: string; - protocol: RouteProtocol; - rules: RulePayload[]; -}>; - -export interface CreateRoutePayload { - label: string; - protocol: RouteProtocol; - rules: RulePayload[]; -} - -export interface Rule { - match_condition?: MatchCondition; - service_targets: { - id: number; - label: string; - percentage: number; - }[]; -} - -export interface RulePayload { - match_condition?: MatchCondition; - service_targets: { - id: number; - label: string; - percentage: number; - }[]; -} - -export interface ConfigurationPayload { - label: string; - port: number; - protocol: Protocol; - certificates: CertificateConfig[]; - routes?: RoutePayload[]; - route_ids?: number[]; -} - -export interface Configuration { - id: number; - label: string; - port: number; - protocol: Protocol; - certificates: CertificateConfig[]; - routes: { id: number; label: string }[]; -} - -export type UpdateConfigurationPayload = Partial<{ - label: string; - port: number; - protocol: Protocol; - certificates: CertificateConfig[]; - route_ids: number[]; -}>; - -export interface CertificateConfig { - hostname: string; - id: number; -} - -export interface RuleCreatePayload { - match_condition: MatchCondition; - service_targets: ServiceTargetPayload[]; -} - -export interface MatchCondition { - hostname: string | null; - match_field: MatchField; - match_value: string; - session_stickiness_cookie: string | null; - session_stickiness_ttl: number | null; -} - -export interface RouteServiceTargetPayload { - service_target_name: string; - service_target_percentage: number; -} - -export interface ServiceTargetPayload { - label: string; - protocol: Protocol; - percentage: number; - endpoints: Endpoint[]; - certificate_id: number | null; - load_balancing_policy: Policy; - healthcheck: HealthCheck; -} - -interface HealthCheck { - protocol: 'tcp' | 'http'; - interval: number; - timeout: number; - unhealthy_threshold: number; - healthy_threshold: number; - path?: string | null; - host?: string | null; -} - -export interface ServiceTarget extends ServiceTargetPayload { - id: number; -} - -export interface Endpoint { - ip: string; - host?: string | null; - port: number; - rate_capacity: number; -} - -type CertificateType = 'ca' | 'downstream'; - -export interface Certificate { - id: number; - label: string; - certificate?: string; // Not returned for Alpha - type: CertificateType; -} - -export interface CreateCertificatePayload { - key?: string; - certificate: string; - label: string; - type: CertificateType; -} - -export interface UpdateCertificatePayload { - key?: string; - certificate?: string; - label?: string; - type?: CertificateType; -} - -export interface LoadBalancerEndpointHealth { - id: number; - healthy_endpoints: number; - total_endpoints: number; - timestamp: string; -} - -export interface EndpointHealth { - id: number; - label: string; - url: string; - type: string; - healthy_endpoints: number; - total_endpoints: number; - timestamp: string; -} - -export interface ConfigurationsEndpointHealth { - /** - * The id of the ACLB - */ - id: number; - /** - * An array of health data for each configuration on the ACLB - */ - configurations: EndpointHealth[]; -} - -export interface ServiceTargetsEndpointHealth { - /** - * The id of the ACLB - */ - id: number; - /** - * An array of health data for each service target on the ACLB - */ - service_targets: EndpointHealth[]; -} diff --git a/packages/api-v4/src/cloudpulse/dashboards.ts b/packages/api-v4/src/cloudpulse/dashboards.ts index 46755363f44..90b4d4ef010 100644 --- a/packages/api-v4/src/cloudpulse/dashboards.ts +++ b/packages/api-v4/src/cloudpulse/dashboards.ts @@ -3,9 +3,15 @@ import Request, { setMethod, setURL } from '../request'; import { Dashboard } from './types'; import { API_ROOT } from 'src/constants'; -//Returns the list of all the dashboards available +// Returns the list of all the dashboards available export const getDashboards = () => Request>( setURL(`${API_ROOT}/monitor/services/linode/dashboards`), setMethod('GET') ); + +export const getDashboardById = (dashboardId: number) => + Request( + setURL(`${API_ROOT}/monitor/dashboards/${encodeURIComponent(dashboardId)}`), + setMethod('GET') + ); diff --git a/packages/api-v4/src/cloudpulse/index.ts b/packages/api-v4/src/cloudpulse/index.ts index 25a3879e494..6b4ff9e8b55 100644 --- a/packages/api-v4/src/cloudpulse/index.ts +++ b/packages/api-v4/src/cloudpulse/index.ts @@ -1,3 +1,5 @@ export * from './types'; export * from './dashboards'; + +export * from './services'; diff --git a/packages/api-v4/src/cloudpulse/services.ts b/packages/api-v4/src/cloudpulse/services.ts new file mode 100644 index 00000000000..f8d884d572a --- /dev/null +++ b/packages/api-v4/src/cloudpulse/services.ts @@ -0,0 +1,24 @@ +import { API_ROOT } from 'src/constants'; +import Request, { setData, setMethod, setURL } from '../request'; +import { JWEToken, JWETokenPayLoad, MetricDefinitions } from './types'; +import { ResourcePage as Page } from 'src/types'; + +export const getMetricDefinitionsByServiceType = (serviceType: string) => { + return Request>( + setURL( + `${API_ROOT}/monitor/services/${encodeURIComponent( + serviceType + )}/metric-definitions` + ), + setMethod('GET') + ); +}; + +export const getJWEToken = (data: JWETokenPayLoad, serviceType: string) => + Request( + setURL( + `${API_ROOT}/monitor/services/${encodeURIComponent(serviceType)}/token` + ), + setMethod('POST'), + setData(data) + ); diff --git a/packages/api-v4/src/cloudpulse/types.ts b/packages/api-v4/src/cloudpulse/types.ts index b1a8b38f7a2..53d3b507bbc 100644 --- a/packages/api-v4/src/cloudpulse/types.ts +++ b/packages/api-v4/src/cloudpulse/types.ts @@ -59,3 +59,31 @@ export interface AclpWidget { label: string; size: number; } + +export interface MetricDefinitions { + data: AvailableMetrics[]; +} + +export interface AvailableMetrics { + label: string; + metric: string; + metric_type: string; + unit: string; + scrape_interval: string; + available_aggregate_functions: string[]; + dimensions: Dimension[]; +} + +export interface Dimension { + label: string; + dimension_label: string; + values: string[]; +} + +export interface JWETokenPayLoad { + resource_id: string[]; +} + +export interface JWEToken { + token: string; +} diff --git a/packages/api-v4/src/images/types.ts b/packages/api-v4/src/images/types.ts index cd3b34db673..cc1572d449b 100644 --- a/packages/api-v4/src/images/types.ts +++ b/packages/api-v4/src/images/types.ts @@ -4,7 +4,7 @@ export type ImageStatus = | 'deleted' | 'pending_upload'; -export type ImageCapabilities = 'cloud-init' | 'distributed-images'; +export type ImageCapabilities = 'cloud-init' | 'distributed-sites'; type ImageType = 'manual' | 'automatic'; diff --git a/packages/api-v4/src/index.ts b/packages/api-v4/src/index.ts index ae104c76a8b..838db834c2d 100644 --- a/packages/api-v4/src/index.ts +++ b/packages/api-v4/src/index.ts @@ -1,7 +1,5 @@ export * from './account'; -export * from './aclb'; - export * from './cloudpulse'; export * from './databases'; diff --git a/packages/api-v4/src/linodes/types.ts b/packages/api-v4/src/linodes/types.ts index 2e29b76b3cd..9eabb9f5e75 100644 --- a/packages/api-v4/src/linodes/types.ts +++ b/packages/api-v4/src/linodes/types.ts @@ -1,4 +1,4 @@ -import type { Region } from '../regions'; +import type { Region, RegionSite } from '../regions'; import type { IPAddress, IPRange } from '../networking/types'; import type { SSHKey } from '../profile/types'; import type { PlacementGroupPayload } from '../placement-groups/types'; @@ -19,6 +19,7 @@ export interface Linode { id: number; alerts: LinodeAlerts; backups: LinodeBackups; + bs_encryption_supported?: boolean; // @TODO BSE: Remove optionality once BSE is fully rolled out created: string; disk_encryption?: EncryptionStatus; // @TODO LDE: Remove optionality once LDE is fully rolled out region: string; @@ -36,6 +37,7 @@ export interface Linode { specs: LinodeSpecs; watchdog_enabled: boolean; tags: string[]; + site_type: RegionSite; } export interface LinodeAlerts { @@ -157,12 +159,12 @@ export type LinodeStatus = export type InterfacePurpose = 'public' | 'vlan' | 'vpc'; export interface ConfigInterfaceIPv4 { - vpc?: string; - nat_1_1?: string; + vpc?: string | null; + nat_1_1?: string | null; } export interface ConfigInterfaceIPv6 { - vpc?: string; + vpc?: string | null; } export interface Interface { diff --git a/packages/api-v4/src/object-storage/buckets.ts b/packages/api-v4/src/object-storage/buckets.ts index 360c47c6e86..2f974e63392 100644 --- a/packages/api-v4/src/object-storage/buckets.ts +++ b/packages/api-v4/src/object-storage/buckets.ts @@ -14,14 +14,14 @@ import Request, { import { Filter, Params, ResourcePage as Page } from '../types'; import { ObjectStorageBucket, - ObjectStorageBucketAccessRequest, - ObjectStorageBucketAccessResponse, - ObjectStorageBucketRequestPayload, - ObjectStorageBucketSSLRequest, - ObjectStorageBucketSSLResponse, - ObjectStorageDeleteBucketRequestPayload, - ObjectStorageObjectListParams, - ObjectStorageObjectListResponse, + UpdateObjectStorageBucketAccessPayload, + ObjectStorageBucketAccess, + CreateObjectStorageBucketPayload, + CreateObjectStorageBucketSSLPayload, + ObjectStorageBucketSSL, + DeleteObjectStorageBucketPayload, + GetObjectStorageObjectListPayload, + ObjectStorageObjectList, } from './types'; /** @@ -96,7 +96,7 @@ export const getBucketsInRegion = ( * @param data { object } The label and clusterId of the new Bucket. * */ -export const createBucket = (data: ObjectStorageBucketRequestPayload) => +export const createBucket = (data: CreateObjectStorageBucketPayload) => Request( setURL(`${API_ROOT}/object-storage/buckets`), setMethod('POST'), @@ -113,7 +113,7 @@ export const createBucket = (data: ObjectStorageBucketRequestPayload) => export const deleteBucket = ({ cluster, label, -}: ObjectStorageDeleteBucketRequestPayload) => +}: DeleteObjectStorageBucketPayload) => Request( setURL( `${API_ROOT}/object-storage/buckets/${encodeURIComponent( @@ -155,12 +155,12 @@ export const deleteBucketWithRegion = ({ /** * Returns a list of Objects in a given Bucket. */ -export const getObjectList = ( - clusterId: string, - bucketName: string, - params?: ObjectStorageObjectListParams -) => - Request( +export const getObjectList = ({ + clusterId, + bucket: bucketName, + params, +}: GetObjectStorageObjectListPayload) => + Request( setMethod('GET'), setParams(params), setURL( @@ -176,9 +176,9 @@ export const getObjectList = ( export const uploadSSLCert = ( clusterId: string, bucketName: string, - data: ObjectStorageBucketSSLRequest + data: CreateObjectStorageBucketSSLPayload ) => - Request( + Request( setMethod('POST'), setData(data, UploadCertificateSchema), setURL( @@ -195,7 +195,7 @@ export const uploadSSLCert = ( * the specified bucket, { ssl: false } otherwise. */ export const getSSLCert = (clusterId: string, bucketName: string) => - Request( + Request( setMethod('GET'), setURL( `${API_ROOT}/object-storage/buckets/${encodeURIComponent( @@ -227,7 +227,7 @@ export const deleteSSLCert = (clusterId: string, bucketName: string) => * Returns access information (ACL, CORS) for a given Bucket. */ export const getBucketAccess = (clusterId: string, bucketName: string) => - Request( + Request( setMethod('GET'), setURL( `${API_ROOT}/object-storage/buckets/${encodeURIComponent( @@ -244,7 +244,7 @@ export const getBucketAccess = (clusterId: string, bucketName: string) => export const updateBucketAccess = ( clusterId: string, bucketName: string, - data: ObjectStorageBucketAccessRequest + params: UpdateObjectStorageBucketAccessPayload ) => Request<{}>( setMethod('PUT'), @@ -253,5 +253,5 @@ export const updateBucketAccess = ( clusterId )}/${encodeURIComponent(bucketName)}/access` ), - setData(data, UpdateBucketAccessSchema) + setData(params, UpdateBucketAccessSchema) ); diff --git a/packages/api-v4/src/object-storage/objectStorageKeys.ts b/packages/api-v4/src/object-storage/objectStorageKeys.ts index df8f3b0ce48..365697c6e26 100644 --- a/packages/api-v4/src/object-storage/objectStorageKeys.ts +++ b/packages/api-v4/src/object-storage/objectStorageKeys.ts @@ -13,8 +13,8 @@ import Request, { import { Filter, Params, ResourcePage as Page } from '../types'; import { ObjectStorageKey, - ObjectStorageKeyRequest, - UpdateObjectStorageKeyRequest, + CreateObjectStorageKeyPayload, + UpdateObjectStorageKeyPayload, } from './types'; /** @@ -35,7 +35,7 @@ export const getObjectStorageKeys = (params?: Params, filters?: Filter) => * * Creates an Object Storage key */ -export const createObjectStorageKeys = (data: ObjectStorageKeyRequest) => +export const createObjectStorageKeys = (data: CreateObjectStorageKeyPayload) => Request( setMethod('POST'), setURL(`${API_ROOT}/object-storage/keys`), @@ -49,7 +49,7 @@ export const createObjectStorageKeys = (data: ObjectStorageKeyRequest) => */ export const updateObjectStorageKey = ( id: number, - data: UpdateObjectStorageKeyRequest + data: UpdateObjectStorageKeyPayload ) => Request( setMethod('PUT'), diff --git a/packages/api-v4/src/object-storage/objects.ts b/packages/api-v4/src/object-storage/objects.ts index 5d27fbb563f..69417c4486c 100644 --- a/packages/api-v4/src/object-storage/objects.ts +++ b/packages/api-v4/src/object-storage/objects.ts @@ -1,21 +1,33 @@ import { API_ROOT } from '../constants'; -import Request, { setData, setMethod, setURL } from '../request'; +import Request, { + setData, + setMethod, + setParams, + setURL, + setXFilter, +} from '../request'; import { ACLType, + ObjectStorageEndpoint, ObjectStorageObjectACL, ObjectStorageObjectURL, - ObjectStorageObjectURLOptions, + GetObjectStorageACLPayload, + CreateObjectStorageObjectURLPayload, } from './types'; +import type { ResourcePage, RequestOptions } from '../types'; + /** - * Gets a URL to upload/download/delete Objects from a Bucket. + * Creates a pre-signed URL to access a single object in a bucket. + * Use it to share, create, or delete objects by using the appropriate + * HTTP method in your request body's method parameter. */ export const getObjectURL = ( clusterId: string, bucketName: string, name: string, method: 'GET' | 'PUT' | 'POST' | 'DELETE', - options?: ObjectStorageObjectURLOptions + options?: CreateObjectStorageObjectURLPayload ) => Request( setMethod('POST'), @@ -33,18 +45,18 @@ export const getObjectURL = ( * * Gets the ACL for a given Object. */ -export const getObjectACL = ( - clusterId: string, - bucketName: string, - name: string -) => +export const getObjectACL = ({ + clusterId, + bucket, + params, +}: GetObjectStorageACLPayload) => Request( setMethod('GET'), setURL( `${API_ROOT}/object-storage/buckets/${encodeURIComponent( clusterId - )}/${encodeURIComponent(bucketName)}/object-acl?name=${encodeURIComponent( - name + )}/${encodeURIComponent(bucket)}/object-acl?name=${encodeURIComponent( + params.name )}` ) ); @@ -70,3 +82,11 @@ export const updateObjectACL = ( ), setData({ acl, name }) ); + +export const getObjectStorageEndpoints = ({ filter, params }: RequestOptions) => + Request>( + setMethod('GET'), + setURL(`${API_ROOT}/object-storage/endpoints`), + setParams(params), + setXFilter(filter) + ); diff --git a/packages/api-v4/src/object-storage/types.ts b/packages/api-v4/src/object-storage/types.ts index a537230de08..cfa15fb7a3c 100644 --- a/packages/api-v4/src/object-storage/types.ts +++ b/packages/api-v4/src/object-storage/types.ts @@ -1,50 +1,51 @@ -export interface RegionS3EndpointAndID { +type ObjectStorageEndpointTypes = 'E0' | 'E1' | 'E2' | 'E3'; + +export interface ObjectStorageKeyRegions { id: string; s3_endpoint: string; + endpoint_type?: ObjectStorageEndpointTypes; } export interface ObjectStorageKey { access_key: string; - bucket_access: Scope[] | null; + bucket_access: ObjectStorageKeyBucketAccess[] | null; id: number; label: string; limited: boolean; - regions: RegionS3EndpointAndID[]; + regions: ObjectStorageKeyRegions[]; secret_key: string; } -export type AccessType = 'read_only' | 'read_write' | 'none'; +export type ObjectStorageKeyBucketAccessPermissions = + | 'read_only' + | 'read_write' + | 'none'; -export interface Scope { +export interface ObjectStorageKeyBucketAccess { bucket_name: string; - permissions: AccessType; + permissions: ObjectStorageKeyBucketAccessPermissions; cluster: string; region?: string; // @TODO OBJ Multicluster: Remove optional indicator when API changes get released to prod } -export interface ScopeRequest extends Omit { - // @TODO OBJ Multicluster: Omit 'region' as well when API changes get released to prod - cluster?: string; - region?: string; -} - -export interface ObjectStorageKeyRequest { +export interface CreateObjectStorageKeyPayload { label: string; - bucket_access: Scope[] | null; + bucket_access: ObjectStorageKeyBucketAccess[] | null; regions?: string[]; } -export interface UpdateObjectStorageKeyRequest { +export interface UpdateObjectStorageKeyPayload { label?: string; regions?: string[]; } -export interface ObjectStorageBucketRequestPayload { +export interface CreateObjectStorageBucketPayload { acl?: 'private' | 'public-read' | 'authenticated-read' | 'public-read-write'; cluster?: string; cors_enabled?: boolean; label: string; region?: string; + endpoint_type?: ObjectStorageEndpointTypes; /* @TODO OBJ Multicluster: 'region' will become required, and the 'cluster' field will be deprecated once the feature is fully rolled out in production as part of the process of cleaning up the 'objMultiCluster' @@ -55,7 +56,7 @@ export interface ObjectStorageBucketRequestPayload { */ } -export interface ObjectStorageDeleteBucketRequestPayload { +export interface DeleteObjectStorageBucketPayload { cluster: string; label: string; } @@ -73,6 +74,8 @@ export interface ObjectStorageBucket { hostname: string; objects: number; size: number; // Size of bucket in bytes + s3_endpoint?: string; + endpoint_type?: ObjectStorageEndpointTypes; } export interface ObjectStorageObject { @@ -84,10 +87,16 @@ export interface ObjectStorageObject { } export interface ObjectStorageObjectURL { - exists: boolean; + exists: boolean; // TODO: This doesn't appear documented in API docs url: string; } +export interface ObjectStorageEndpoint { + region: string; + endpoint_type: ObjectStorageEndpointTypes; + s3_endpoint: string | null; +} + export type ACLType = | 'private' | 'public-read' @@ -95,12 +104,13 @@ export type ACLType = | 'public-read-write' | 'custom'; +// Gen2 endpoints ('E2', 'E3') are not supported and will return null. export interface ObjectStorageObjectACL { - acl: ACLType; - acl_xml: string; + acl: ACLType | null; + acl_xml: string | null; } -export interface ObjectStorageObjectURLOptions { +export interface CreateObjectStorageObjectURLPayload { expires_in?: number; // "Content-Type" is normally an HTTP header, but here it is used in the body // of a request to /object-url, to inform the API which kind of file it is @@ -124,36 +134,52 @@ export interface ObjectStorageCluster { static_site_domain: string; } -export interface ObjectStorageObjectListParams { +export interface GetObjectStorageObjectListPayload { + clusterId: string; + bucket: string; + params?: ObjectStorageObjectListParams; +} + +interface ObjectStorageObjectListParams { delimiter?: string; marker?: string; prefix?: string; page_size?: number; } -export interface ObjectStorageObjectListResponse { +export interface ObjectStorageObjectList { data: ObjectStorageObject[]; next_marker: string | null; is_truncated: boolean; } -export interface ObjectStorageBucketSSLRequest { +export interface CreateObjectStorageBucketSSLPayload { certificate: string; private_key: string; } -export interface ObjectStorageBucketSSLResponse { - ssl: boolean; +// Gen2 endpoints ('E2', 'E3') are not supported and will return null. +export interface ObjectStorageBucketSSL { + ssl: boolean | null; } -export interface ObjectStorageBucketAccessRequest { - acl?: Omit; +export interface UpdateObjectStorageBucketAccessPayload { + acl?: ACLType; cors_enabled?: boolean; } -export interface ObjectStorageBucketAccessResponse { +export interface GetObjectStorageACLPayload { + clusterId: string; + bucket: string; + params: { + name: string; + }; +} + +// Gen2 endpoints ('E2', 'E3') are not supported and will return null. +export interface ObjectStorageBucketAccess { acl: ACLType; acl_xml: string; - cors_enabled: boolean; - cors_xml: string; + cors_enabled: boolean | null; + cors_xml: string | null; } diff --git a/packages/api-v4/src/regions/types.ts b/packages/api-v4/src/regions/types.ts index ab102674177..cb159254e95 100644 --- a/packages/api-v4/src/regions/types.ts +++ b/packages/api-v4/src/regions/types.ts @@ -3,6 +3,7 @@ import { COUNTRY_CODE_TO_CONTINENT_CODE } from './constants'; export type Capabilities = | 'Bare Metal' | 'Block Storage' + | 'Block Storage Encryption' | 'Block Storage Migrations' | 'Cloud Firewall' | 'Disk Encryption' diff --git a/packages/api-v4/src/volumes/types.ts b/packages/api-v4/src/volumes/types.ts index 0dd27d1834d..cc1d1c6c4ea 100644 --- a/packages/api-v4/src/volumes/types.ts +++ b/packages/api-v4/src/volumes/types.ts @@ -1,3 +1,5 @@ +export type VolumeEncryption = 'enabled' | 'disabled'; + export interface Volume { id: number; label: string; @@ -11,16 +13,18 @@ export interface Volume { filesystem_path: string; tags: string[]; hardware_type: VolumeHardwareType; + encryption?: VolumeEncryption; // @TODO BSE: Remove optionality once BSE is fully rolled out } type VolumeHardwareType = 'hdd' | 'nvme'; export type VolumeStatus = - | 'creating' | 'active' - | 'resizing' + | 'creating' + | 'key_rotating' | 'migrating' - | 'offline'; + | 'offline' + | 'resizing'; export interface VolumeRequestPayload { label: string; @@ -29,6 +33,7 @@ export interface VolumeRequestPayload { linode_id?: number; config_id?: number; tags?: string[]; + encryption?: VolumeEncryption; } export interface AttachVolumePayload { diff --git a/packages/manager/CHANGELOG.md b/packages/manager/CHANGELOG.md index a06bb8a7583..ae3b9815e4c 100644 --- a/packages/manager/CHANGELOG.md +++ b/packages/manager/CHANGELOG.md @@ -4,6 +4,85 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [2024-08-05] - v1.125.0 + +### Added: + +- Marketplace apps for August 2024 ([#10634](https://github.com/linode/manager/pull/10634)) +- Account Limit support ticket to remaining create flows ([#10684](https://github.com/linode/manager/pull/10684)) +- ARIA label to Account Maintenance tables ([#10694](https://github.com/linode/manager/pull/10694)) + +### Changed: + +- Use `getRestrictedResourceText` utility and move restrictions Notice to top of Image Create and Upload pages ([#10675](https://github.com/linode/manager/pull/10675)) +- Improve Types for Object Storage ([#10686](https://github.com/linode/manager/pull/10686)) +- Rename SRV column headers in Linode's DNS Manager ([#10687](https://github.com/linode/manager/pull/10687)) +- Scale LISH to fit viewport ([#10689](https://github.com/linode/manager/pull/10689)) +- Open LISH in new tab rather than new window ([#10689](https://github.com/linode/manager/pull/10689)) +- Save and restore more form fields from local storage in support ticket dialog ([#10703](https://github.com/linode/manager/pull/10703)) +- Update Placement Group policy text copy ([#10727](https://github.com/linode/manager/pull/10727)) +- Update Appwrite Marketplace logo ([#10729](https://github.com/linode/manager/pull/10729)) +- Revise UX & copy in Monthly Network Transfer Pool Modal ([#10737](https://github.com/linode/manager/pull/10737)) +- Disable Image Action Menu Buttons for Restricted Users ([#10682](https://github.com/linode/manager/pull/10682)) + +### Fixed: + +- Incorrect Linode network interface configuration being displayed ([#10690](https://github.com/linode/manager/pull/10690)) +- Sources not displaying correctly in Firewall Rule drawer ([#10724](https://github.com/linode/manager/pull/10724)) +- Liked answer/question notifications from Community Questions Site ([#10732](https://github.com/linode/manager/pull/10732)) +- Filtering for Linode Create v2 Core region selection ([#10743](https://github.com/linode/manager/pull/10743)) + +### Removed: + +- Akamai Cloud Load Balancer ([#10705](https://github.com/linode/manager/pull/10705)) + +### Tech Stories: + +- Query Key Factory for Linodes ([#10659](https://github.com/linode/manager/pull/10659)) +- Query Key Factory for Status Page ([#10672](https://github.com/linode/manager/pull/10672)) +- Replace 'react-select' with Autocomplete: + - Billing ([#10681](https://github.com/linode/manager/pull/10681)) + - NodeBalancers Create (#10688) + - Domains (#10693) + - Firewalls' Add Inbound/Outbound rule drawer ([#10701](https://github.com/linode/manager/pull/10701)) + - `IPSelect`, `PaginationFooter`, and `TagsInput` (#10706) + - Longview ([#10721](https://github.com/linode/manager/pull/10721)) +- Migrate from `xterm` package to latest `@xterm/xterm` package ([#10689](https://github.com/linode/manager/pull/10689)) +- Docker Compose changes to facilitate new testing pipeline ([#10713](https://github.com/linode/manager/pull/10713)) +- Upgrade to latest Design Language System (DLS) 2.6.1 ([#10734](https://github.com/linode/manager/pull/10734)) +- Refactor DiskEncryption component and rename to Encryption ([#10735](https://github.com/linode/manager/pull/10735)) + +### Tests: + +- Add Cypress integration test for Support Ticket landing page ([#10616](https://github.com/linode/manager/pull/10616)) +- Add cypress test coverage to DX tools additions in Linode create flow ([#10626](https://github.com/linode/manager/pull/10626)) +- Improve feature flag mocking ergonomics for Cypress tests ([#10635](https://github.com/linode/manager/pull/10635)) +- Add Cypress test for login redirect upon API unauthorized response ([#10655](https://github.com/linode/manager/pull/10655)) +- Confirm UI flow when a user changes their Longview plan ([#10668](https://github.com/linode/manager/pull/10668)) +- Confirm refactored Linode Create flow with Firewalls attached ([#10683](https://github.com/linode/manager/pull/10683)) +- Add Cypress integration tests for account "Maintenance" tab ([#10694](https://github.com/linode/manager/pull/10694)) +- Make existing Linode Create Cypress test compatible with Linode Create v2 ([#10695](https://github.com/linode/manager/pull/10695)) +- Mock sidebar as open in some tests to minimize flake ([#10698](https://github.com/linode/manager/pull/10698)) +- Tag tests for synthetic monitoring ([#10713](https://github.com/linode/manager/pull/10713)) +- Add E2E coverage for refactored Events and Placement Groups flows ([#10719](https://github.com/linode/manager/pull/10719)) +- Avoid cleaning up Volumes that are not in "active" state ([#10728](https://github.com/linode/manager/pull/10728)) +- Add E2E coverage for Logout flow ([#10733](https://github.com/linode/manager/pull/10733)) + +### Upcoming Features: + +- Add CloudPulse widget component in the UI for metrics data ([#10676](https://github.com/linode/manager/pull/10676)) +- Object Storage Gen2 cors_enabled and type updates ([#10677](https://github.com/linode/manager/pull/10677)) +- Add EU Agreement to Linode Create v2 ([#10692](https://github.com/linode/manager/pull/10692)) +- Fix broken Linode Create v2 clone validation ([#10698](https://github.com/linode/manager/pull/10698)) +- Replace Formik with React Hook Form for Create Bucket Drawer ([#10699](https://github.com/linode/manager/pull/10699)) +- Make minor improvements to Linode Create v2 ([#10704](https://github.com/linode/manager/pull/10704)) +- Add feature flag for Block Storage Encryption (BSE) ([#10707](https://github.com/linode/manager/pull/10707)) +- Allow Marketplace Apps to be overwritten with a feature flag on Linode Create v2 ([#10709](https://github.com/linode/manager/pull/10709)) +- Hide Monthly Network Transfer section for distributed regions ([#10714](https://github.com/linode/manager/pull/10714)) +- Add new MSW, Factory, and E2E intercepts for OBJ Gen2 ([#10720](https://github.com/linode/manager/pull/10720)) +- Add support for Two-step region select in Linode Create v2 ([#10723](https://github.com/linode/manager/pull/10723)) +- Fix Image Capability and other tweaks in Image Service Gen2 ([#10731](https://github.com/linode/manager/pull/10731)) + ## [2024-07-22] - v1.124.0 ### Added: diff --git a/packages/manager/cypress.config.ts b/packages/manager/cypress.config.ts index 1c7efb41af8..c0cc12e20a3 100644 --- a/packages/manager/cypress.config.ts +++ b/packages/manager/cypress.config.ts @@ -41,7 +41,7 @@ export default defineConfig({ video: true, // Only retry test when running via CI. - retries: process.env['CI'] ? 2 : 0, + retries: process.env['CI'] && !process.env['CY_TEST_DISABLE_RETRIES'] ? 2 : 0, experimentalMemoryManagement: true, e2e: { diff --git a/packages/manager/cypress/e2e/core/account/account-logout.spec.ts b/packages/manager/cypress/e2e/core/account/account-logout.spec.ts new file mode 100644 index 00000000000..bb72c9795b4 --- /dev/null +++ b/packages/manager/cypress/e2e/core/account/account-logout.spec.ts @@ -0,0 +1,34 @@ +import { LOGIN_ROOT } from 'src/constants'; +import { interceptGetAccount } from 'support/intercepts/account'; +import { ui } from 'support/ui'; + +describe('Logout Test', () => { + beforeEach(() => { + cy.tag('purpose:syntheticTesting'); + }); + + /* + * - Confirms that Cloud Manager log out functionality works as expected. + * - Confirms that the login application is up after account logout. + */ + it('can logout the account and redirect to login endpoint', () => { + interceptGetAccount().as('getAccount'); + + cy.visitWithLogin('/account'); + cy.wait('@getAccount'); + + // User can click Logout via user menu. + ui.userMenuButton.find().click(); + ui.userMenu + .find() + .should('be.visible') + .within(() => { + cy.findByText('Log Out').should('be.visible').click(); + }); + // Upon clicking "Log Out", the user is redirected to the login endpoint at /login + cy.url().should('equal', `${LOGIN_ROOT}/login`); + // Using cy.visit to navigate back to Cloud results in another redirect to the login page + cy.visit('/'); + cy.url().should('startWith', `${LOGIN_ROOT}/login`); + }); +}); diff --git a/packages/manager/cypress/e2e/core/account/account-maintenance.spec.ts b/packages/manager/cypress/e2e/core/account/account-maintenance.spec.ts new file mode 100644 index 00000000000..56292a96bd5 --- /dev/null +++ b/packages/manager/cypress/e2e/core/account/account-maintenance.spec.ts @@ -0,0 +1,129 @@ +import { mockGetMaintenance } from 'support/intercepts/account'; +import { accountMaintenanceFactory } from 'src/factories'; + +describe('Maintenance', () => { + /* + * - Confirm user can navigate to account maintenance page via user menu. + * - When there is no pending maintenance, "No pending maintenance." is shown in the table. + * - When there is no completed maintenance, "No completed maintenance." is shown in the table. + */ + it('table empty when no maintenance', () => { + mockGetMaintenance([], []).as('getMaintenance'); + + cy.visitWithLogin('/linodes'); + // user can navigate to account maintenance page via user menu. + cy.findByTestId('nav-group-profile').click(); + cy.findByTestId('menu-item-Maintenance') + .should('be.visible') + .should('be.enabled') + .click(); + cy.url().should('endWith', '/account/maintenance'); + + cy.wait('@getMaintenance'); + + // Confirm correct messages shown in the table when no maintenance. + cy.contains('No pending maintenance').should('be.visible'); + cy.contains('No completed maintenance').should('be.visible'); + }); + + /* + * - Uses mock API data to confirm maintenance details. + * - When there is pending maintenance, it is shown in the table with expected details. + * - When there is completed maintenance, it is shown in the table with expected details. + * - Confirm "Download CSV" button for pending maintenance visible and enabled. + * - Confirm "Download CSV" button for completed maintenance visible and enabled. + */ + it('confirm maintenance details in the tables', () => { + const pendingMaintenanceNumber = 2; + const completedMaintenanceNumber = 5; + const accountpendingMaintenance = accountMaintenanceFactory.buildList( + pendingMaintenanceNumber + ); + const accountcompletedMaintenance = accountMaintenanceFactory.buildList( + completedMaintenanceNumber, + { status: 'completed' } + ); + + mockGetMaintenance( + accountpendingMaintenance, + accountcompletedMaintenance + ).as('getMaintenance'); + + cy.visitWithLogin('/account/maintenance'); + + cy.wait('@getMaintenance'); + + cy.contains('No pending maintenance').should('not.exist'); + cy.contains('No completed maintenance').should('not.exist'); + + // Confirm Pending table is not empty and contains exact number of pending maintenances + cy.findByLabelText('List of pending maintenance') + .should('be.visible') + .find('tbody') + .within(() => { + accountpendingMaintenance.forEach(() => { + cy.get('tr') + .should('have.length', accountpendingMaintenance.length) + .each((row, index) => { + const pendingMaintenance = accountpendingMaintenance[index]; + cy.wrap(row).within(() => { + cy.contains(pendingMaintenance.entity.label).should( + 'be.visible' + ); + // Confirm that the first 90 characters of each reason string are rendered on screen + const truncatedReason = pendingMaintenance.reason.substring( + 0, + 90 + ); + cy.findByText(truncatedReason, { exact: false }).should( + 'be.visible' + ); + // Check the content of each element + cy.get('td').each(($cell, idx, $cells) => { + cy.wrap($cell).should('not.be.empty'); + }); + }); + }); + }); + }); + + // Confirm Completed table is not empty and contains exact number of completed maintenances + cy.findByLabelText('List of completed maintenance') + .should('be.visible') + .find('tbody') + .within(() => { + accountcompletedMaintenance.forEach(() => { + cy.get('tr') + .should('have.length', accountcompletedMaintenance.length) + .each((row, index) => { + const completedMaintenance = accountcompletedMaintenance[index]; + cy.wrap(row).within(() => { + cy.contains(completedMaintenance.entity.label).should( + 'be.visible' + ); + // Confirm that the first 90 characters of each reason string are rendered on screen + const truncatedReason = completedMaintenance.reason.substring( + 0, + 90 + ); + cy.findByText(truncatedReason, { exact: false }).should( + 'be.visible' + ); + // Check the content of each element + cy.get('td').each(($cell, idx, $cells) => { + cy.wrap($cell).should('not.be.empty'); + }); + }); + }); + }); + }); + + // Confirm download buttons work + cy.get('button') + .filter(':contains("Download CSV")') + .should('be.visible') + .should('be.enabled') + .click({ multiple: true }); + // TODO Need to add assertions to confirm CSV contains the expected contents on first trial (M3-8393) + }); +}); diff --git a/packages/manager/cypress/e2e/core/account/betas.spec.ts b/packages/manager/cypress/e2e/core/account/betas.spec.ts index cdedcbcae3e..cf3375fb402 100644 --- a/packages/manager/cypress/e2e/core/account/betas.spec.ts +++ b/packages/manager/cypress/e2e/core/account/betas.spec.ts @@ -2,12 +2,9 @@ * @file Integration tests for Betas landing page. */ -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { ui } from 'support/ui'; +import { mockGetUserPreferences } from 'support/intercepts/profile'; // TODO Delete feature flag mocks when feature flag is removed. describe('Betas landing page', () => { @@ -18,12 +15,16 @@ describe('Betas landing page', () => { */ it('can navigate to Betas landing page', () => { mockAppendFeatureFlags({ - selfServeBetas: makeFeatureFlagData(true), + selfServeBetas: true, }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); + + // Ensure that the Primary Nav is open + mockGetUserPreferences({ desktop_sidebar_open: false }).as( + 'getPreferences' + ); cy.visitWithLogin('/linodes'); - cy.wait(['@getFeatureFlags', '@getClientStream']); + cy.wait('@getFeatureFlags'); ui.nav.findItemByTitle('Betas').should('be.visible').click(); @@ -41,12 +42,11 @@ describe('Betas landing page', () => { it('cannot access Betas landing page when feature is disabled', () => { // TODO Delete this test when betas feature flag is removed from codebase. mockAppendFeatureFlags({ - selfServeBetas: makeFeatureFlagData(false), + selfServeBetas: false, }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); cy.visitWithLogin('/betas'); - cy.wait(['@getFeatureFlags', '@getClientStream']); + cy.wait('@getFeatureFlags'); cy.findByText('Not Found').should('be.visible'); diff --git a/packages/manager/cypress/e2e/core/billing/billing-invoices.spec.ts b/packages/manager/cypress/e2e/core/billing/billing-invoices.spec.ts index 721621de73f..183c180fed9 100644 --- a/packages/manager/cypress/e2e/core/billing/billing-invoices.spec.ts +++ b/packages/manager/cypress/e2e/core/billing/billing-invoices.spec.ts @@ -302,7 +302,7 @@ describe('Account invoices', () => { cy.findByLabelText('Invoice Details').within(() => { // Confirm that page size selection is set to "Show 25". ui.pagination.findPageSizeSelect().click(); - ui.select.findItemByText('Show 25').should('be.visible').click(); + ui.autocompletePopper.findByTitle('Show 25').should('be.visible').click(); // Confirm that pagination controls list exactly 4 pages. ui.pagination @@ -337,7 +337,10 @@ describe('Account invoices', () => { // Change pagination size selection from "Show 25" to "Show 100". ui.pagination.findPageSizeSelect().click(); - ui.select.findItemByText('Show 100').should('be.visible').click(); + ui.autocompletePopper + .findByTitle('Show 100') + .should('be.visible') + .click(); // Confirm that all invoice items are listed. cy.get('tr').should('have.length', 102); diff --git a/packages/manager/cypress/e2e/core/billing/smoke-billing-activity.spec.ts b/packages/manager/cypress/e2e/core/billing/smoke-billing-activity.spec.ts index 1cdcbcb1b6c..70079a2d4cb 100644 --- a/packages/manager/cypress/e2e/core/billing/smoke-billing-activity.spec.ts +++ b/packages/manager/cypress/e2e/core/billing/smoke-billing-activity.spec.ts @@ -170,11 +170,6 @@ describe('Billing Activity Feed', () => { .scrollIntoView() .should('be.visible'); - cy.contains('[data-qa-enhanced-select]', 'All Transaction Types').should( - 'be.visible' - ); - cy.contains('[data-qa-enhanced-select]', '6 Months').should('be.visible'); - // Confirm that payments and invoices from the past 6 months are displayed, // and that payments and invoices beyond 6 months are not displayed. invoiceMocks6Months.forEach((invoice) => @@ -201,11 +196,12 @@ describe('Billing Activity Feed', () => { mockGetInvoices(invoiceMocks).as('getInvoices'); mockGetPayments(paymentMocks).as('getPayments'); - cy.contains('[data-qa-enhanced-select]', '6 Months') + cy.findByText('Transaction Dates').click().type(`All Time`); + ui.autocompletePopper + .findByTitle(`All Time`) .should('be.visible') .click(); - ui.select.findItemByText('All Time').should('be.visible').click(); cy.wait(['@getInvoices', '@getPayments']); // Confirm that all invoices and payments are displayed. @@ -218,12 +214,12 @@ describe('Billing Activity Feed', () => { }); // Change transaction type drop-down to "Payments" only. - cy.contains('[data-qa-enhanced-select]', 'All Transaction Types') + cy.findByText('Transaction Types').click().type(`Payments`); + ui.autocompletePopper + .findByTitle(`Payments`) .should('be.visible') .click(); - ui.select.findItemByText('Payments').should('be.visible').click(); - // Confirm that all payments are shown and that all invoices are hidden. paymentMocks.forEach((payment) => cy.findByText(`Payment #${payment.id}`).should('be.visible') @@ -233,12 +229,12 @@ describe('Billing Activity Feed', () => { ); // Change transaction type drop-down to "Invoices" only. - cy.contains('[data-qa-enhanced-select]', 'Payments') + cy.findByText('Transaction Types').should('be.visible').focused().click(); + ui.autocompletePopper + .findByTitle('Invoices') .should('be.visible') .click(); - ui.select.findItemByText('Invoices').should('be.visible').click(); - // Confirm that all invoices are shown and that all payments are hidden. invoiceMocks6Months.forEach((invoice) => { cy.findByText(invoice.label).should('be.visible'); @@ -272,11 +268,8 @@ describe('Billing Activity Feed', () => { cy.wait(['@getInvoices', '@getPayments', '@getPaymentMethods']); // Change invoice date selection from "6 Months" to "All Time". - cy.contains('[data-qa-enhanced-select]', '6 Months') - .should('be.visible') - .click(); - - ui.select.findItemByText('All Time').should('be.visible').click(); + cy.findByText('Transaction Dates').click().type('All Time'); + ui.autocompletePopper.findByTitle('All Time').should('be.visible').click(); cy.get('[data-qa-billing-activity-panel]') .should('be.visible') diff --git a/packages/manager/cypress/e2e/core/firewalls/update-firewall.spec.ts b/packages/manager/cypress/e2e/core/firewalls/update-firewall.spec.ts index 9ca48ad7fe0..85520e1432e 100644 --- a/packages/manager/cypress/e2e/core/firewalls/update-firewall.spec.ts +++ b/packages/manager/cypress/e2e/core/firewalls/update-firewall.spec.ts @@ -77,10 +77,9 @@ const addFirewallRules = (rule: FirewallRuleType, direction: string) => { .should('be.visible') .within(() => { const port = rule.ports ? rule.ports : '22'; - cy.get('[data-qa-enhanced-select="Select a rule preset..."]').type( + cy.findByPlaceholderText('Select a rule preset...').type( portPresetMap[port] + '{enter}' ); - const label = rule.label ? rule.label : 'test-label'; const description = rule.description ? rule.description diff --git a/packages/manager/cypress/e2e/core/general/account-login-redirect.spec.ts b/packages/manager/cypress/e2e/core/general/account-login-redirect.spec.ts new file mode 100644 index 00000000000..2f7048bb057 --- /dev/null +++ b/packages/manager/cypress/e2e/core/general/account-login-redirect.spec.ts @@ -0,0 +1,20 @@ +import { mockApiRequestWithError } from 'support/intercepts/general'; +import { LOGIN_ROOT } from 'src/constants'; + +describe('account login redirect', () => { + /** + * The API will return 401 with the body below for all the endpoints. + * + * { "errors": [ { "reason": "Your account must be authorized to use this endpoint" } ] } + */ + it('should redirect to the login page when the user is not authorized', () => { + const errorReason = 'Your account must be authorized to use this endpoint'; + + mockApiRequestWithError(401, errorReason); + + cy.visitWithLogin('/linodes/create'); + + cy.url().should('contain', `${LOGIN_ROOT}/login?`, { exact: false }); + cy.findByText('Please log in to continue.').should('be.visible'); + }); +}); diff --git a/packages/manager/cypress/e2e/core/general/gdpr-agreement.spec.ts b/packages/manager/cypress/e2e/core/general/gdpr-agreement.spec.ts index da7d1b36e3d..089e766fb93 100644 --- a/packages/manager/cypress/e2e/core/general/gdpr-agreement.spec.ts +++ b/packages/manager/cypress/e2e/core/general/gdpr-agreement.spec.ts @@ -4,6 +4,11 @@ import { regionFactory } from '@src/factories'; import { randomString, randomLabel } from 'support/util/random'; import { mockGetRegions } from 'support/intercepts/regions'; import { mockGetAccountAgreements } from 'support/intercepts/account'; +import { + mockAppendFeatureFlags, + mockGetFeatureFlagClientstream, +} from 'support/intercepts/feature-flags'; +import { makeFeatureFlagData } from 'support/util/feature-flags'; import type { Region } from '@linode/api-v4'; @@ -49,13 +54,15 @@ describe('GDPR agreement', () => { }).as('getAgreements'); cy.visitWithLogin('/linodes/create'); - cy.wait(['@getAgreements', '@getRegions']); + cy.wait('@getRegions'); // Paris should have the agreement ui.regionSelect.find().click(); ui.regionSelect.findItemByRegionId('fr-par').click(); cy.get('[data-testid="eu-agreement-checkbox"]').should('be.visible'); + cy.wait('@getAgreements'); + // London should have the agreement ui.regionSelect.find().click(); ui.regionSelect.findItemByRegionId('eu-west').click(); @@ -75,13 +82,15 @@ describe('GDPR agreement', () => { }).as('getAgreements'); cy.visitWithLogin('/linodes/create'); - cy.wait(['@getAgreements', '@getRegions']); + cy.wait('@getRegions'); // Paris should not have the agreement ui.regionSelect.find().click(); ui.regionSelect.findItemByRegionId('fr-par').click(); cy.get('[data-testid="eu-agreement-checkbox"]').should('not.exist'); + cy.wait('@getAgreements'); + // London should not have the agreement ui.regionSelect.find().click(); ui.regionSelect.findItemByRegionId('eu-west').click(); @@ -94,6 +103,14 @@ describe('GDPR agreement', () => { }); it('needs the agreement checked to validate the form', () => { + // This test does not apply to Linode Create v2 because + // Linode Create v2 allows you to press "Create Linode" + // without checking the GDPR checkbox. (The user will + // get a validation error if they have not agreed). + mockAppendFeatureFlags({ + linodeCreateRefactor: makeFeatureFlagData(false), + }); + mockGetFeatureFlagClientstream(); mockGetRegions(mockRegions).as('getRegions'); mockGetAccountAgreements({ privacy_policy: false, @@ -120,7 +137,7 @@ describe('GDPR agreement', () => { cy.get('[data-qa-deploy-linode="true"]').should('be.disabled'); // check the agreement - getClick('[data-testid="eu-agreement-checkbox"]'); + getClick('#gdpr-checkbox'); // expect the button to be enabled cy.get('[data-qa-deploy-linode="true"]').should('not.be.disabled'); diff --git a/packages/manager/cypress/e2e/core/helpAndSupport/open-support-ticket.spec.ts b/packages/manager/cypress/e2e/core/helpAndSupport/open-support-ticket.spec.ts index 94b0aba76e6..1161505d52b 100644 --- a/packages/manager/cypress/e2e/core/helpAndSupport/open-support-ticket.spec.ts +++ b/packages/manager/cypress/e2e/core/helpAndSupport/open-support-ticket.spec.ts @@ -422,14 +422,11 @@ describe('help & support', () => { .click(); cy.wait('@createLinode'); - cy.get('[data-qa-error="true"]') - .first() - .scrollIntoView() - .within(() => { - cy.contains(ACCOUNT_THING_LIMIT_ERROR); - // Navigate to the account limit ticket form. - cy.findByText('contact Support').should('be.visible').click(); - }); + cy.get('[data-qa-error="true"]').first().scrollIntoView(); + cy.contains(ACCOUNT_THING_LIMIT_ERROR); + + // Navigate to the account limit ticket form. + cy.findByText('contact Support').should('be.visible').click(); // Fill out ticket form. ui.dialog diff --git a/packages/manager/cypress/e2e/core/helpAndSupport/support-tickets-landing-page.spec.ts b/packages/manager/cypress/e2e/core/helpAndSupport/support-tickets-landing-page.spec.ts new file mode 100644 index 00000000000..2c511d9810f --- /dev/null +++ b/packages/manager/cypress/e2e/core/helpAndSupport/support-tickets-landing-page.spec.ts @@ -0,0 +1,287 @@ +import { interceptGetProfile } from 'support/intercepts/profile'; +import { + mockAppendFeatureFlags, + mockGetFeatureFlagClientstream, +} from 'support/intercepts/feature-flags'; +import { makeFeatureFlagData } from 'support/util/feature-flags'; +import { + randomItem, + randomLabel, + randomNumber, + randomPhrase, +} from 'support/util/random'; +import { + entityFactory, + linodeFactory, + supportTicketFactory, + volumeFactory, + linodeConfigFactory, + LinodeConfigInterfaceFactory, +} from 'src/factories'; +import { + mockGetSupportTicket, + mockGetSupportTickets, + mockGetSupportTicketReplies, +} from 'support/intercepts/support'; +import { SEVERITY_LABEL_MAP } from 'src/features/Support/SupportTickets/constants'; +import { mockGetLinodeConfigs } from 'support/intercepts/configs'; +import { + mockGetLinodeDetails, + mockGetLinodeVolumes, + mockGetLinodeDisks, +} from 'support/intercepts/linodes'; +import { Config, Disk } from '@linode/api-v4'; + +describe('support tickets landing page', () => { + /* + * - Confirms that "No items to display" is shown when the user has no open support tickets. + */ + it('shows the empty message when there are no tickets.', () => { + mockAppendFeatureFlags({ + supportTicketSeverity: makeFeatureFlagData(false), + }); + mockGetFeatureFlagClientstream(); + + interceptGetProfile().as('getProfile'); + + // intercept get ticket request, stub response. + mockGetSupportTickets([]).as('getSupportTickets'); + + cy.visitWithLogin('/support/tickets'); + + cy.wait(['@getProfile', '@getSupportTickets']); + + cy.get('[data-qa-open-tickets-tab]').within(() => { + // Confirm that "Severity" table column is not shown. + cy.findByLabelText('Sort by severity').should('not.exist'); + + // Confirm that other table columns are shown. + cy.findByText('Subject').should('be.visible'); + cy.findByText('Ticket ID').should('be.visible'); + cy.findByText('Regarding').should('be.visible'); + cy.findByText('Date Created').should('be.visible'); + cy.findByText('Last Updated').should('be.visible'); + cy.findByText('Updated By').should('be.visible'); + }); + + // Confirm that no ticket is listed. + cy.findByText('No items to display.').should('be.visible'); + }); + + /* + * - Confirms that support tickets are listed in the table when the user has ones. + */ + it('lists support tickets in the table as expected', () => { + // TODO Integrate this test with the above test when feature flag goes away. + const mockTicket = supportTicketFactory.build({ + id: randomNumber(), + summary: randomLabel(), + description: randomPhrase(), + severity: randomItem([1, 2, 3]), + status: 'new', + }); + + const mockAnotherTicket = supportTicketFactory.build({ + id: randomNumber(), + summary: randomLabel(), + description: randomPhrase(), + severity: randomItem([1, 2, 3]), + status: 'open', + }); + + const mockTickets = [mockTicket, mockAnotherTicket]; + + mockAppendFeatureFlags({ + supportTicketSeverity: makeFeatureFlagData(true), + }); + mockGetFeatureFlagClientstream(); + mockGetSupportTickets(mockTickets); + + cy.visitWithLogin('/support/tickets'); + + cy.get('[data-qa-open-tickets-tab]').within(() => { + // Confirm that "Severity" table column is displayed. + cy.findByLabelText('Sort by severity').should('be.visible'); + + // Confirm that other table columns are shown. + cy.findByText('Subject').should('be.visible'); + cy.findByText('Ticket ID').should('be.visible'); + cy.findByText('Regarding').should('be.visible'); + cy.findByText('Date Created').should('be.visible'); + cy.findByText('Last Updated').should('be.visible'); + cy.findByText('Updated By').should('be.visible'); + }); + + mockTickets.forEach((ticket) => { + // Get severity label for numeric severity level. + // Bail out if we're unable to get a valid label -- this indicates a mismatch between the test and source. + const severityLabel = SEVERITY_LABEL_MAP.get(ticket.severity!); + if (!severityLabel) { + throw new Error( + `Unable to retrieve label for severity level '${ticket.severity}'. Is this a valid support severity level?` + ); + } + + // Confirm that tickets are listed as expected. + cy.findByText(ticket.summary) + .should('be.visible') + .closest('tr') + .within(() => { + cy.findByText(ticket.id).should('be.visible'); + cy.findByText(severityLabel).should('be.visible'); + }); + }); + }); + + /* + * - Confirms that clicking on the ticket subject navigates to the ticket's page. + */ + it("can navigate to the ticket's page when clicking on the ticket subject", () => { + // TODO Integrate this test with the above test when feature flag goes away. + const mockTicket = supportTicketFactory.build({ + id: randomNumber(), + summary: randomLabel(), + description: randomPhrase(), + severity: randomItem([1, 2, 3]), + status: 'new', + }); + + // Get severity label for numeric severity level. + // Bail out if we're unable to get a valid label -- this indicates a mismatch between the test and source. + const severityLabel = SEVERITY_LABEL_MAP.get(mockTicket.severity!); + if (!severityLabel) { + throw new Error( + `Unable to retrieve label for severity level '${mockTicket.severity}'. Is this a valid support severity level?` + ); + } + + mockAppendFeatureFlags({ + supportTicketSeverity: makeFeatureFlagData(true), + }); + mockGetFeatureFlagClientstream(); + mockGetSupportTickets([mockTicket]); + mockGetSupportTicket(mockTicket).as('getSupportTicket'); + mockGetSupportTicketReplies(mockTicket.id, []).as('getReplies'); + + cy.visitWithLogin('/support/tickets'); + + // Confirm that tickets are listed as expected. + cy.findByText(mockTicket.summary).should('be.visible').click(); + + cy.wait(['@getSupportTicket', '@getReplies']); + + cy.url().should('endWith', `/tickets/${mockTicket.id}`); + cy.findByText( + mockTicket.status.substring(0, 1).toUpperCase() + + mockTicket.status.substring(1) + ).should('be.visible'); + cy.findByText(`#${mockTicket.id}: ${mockTicket.summary}`).should( + 'be.visible' + ); + cy.findByText(mockTicket.description).should('be.visible'); + cy.findByText(severityLabel).should('be.visible'); + }); + + /* + * - Confirms that the entity is shown in the table when the support ticket is related to it. + * - Confirms that clicking the entity's label redirects to that entity's page + */ + it("can navigate to the entity's page when clicking the entity's label", () => { + // TODO Integrate this test with the above test when feature flag goes away. + const mockLinode = linodeFactory.build({ + id: randomNumber(), + label: `${randomLabel()}-linode`, + }); + const mockVolume = volumeFactory.build(); + const mockPublicConfigInterface = LinodeConfigInterfaceFactory.build({ + ipam_address: null, + purpose: 'public', + }); + const mockConfig: Config = linodeConfigFactory.build({ + id: randomNumber(), + interfaces: [ + // The order of this array is significant. Index 0 (eth0) should be public. + mockPublicConfigInterface, + ], + }); + const mockDisks: Disk[] = [ + { + id: 44311273, + status: 'ready', + label: 'Debian 10 Disk', + created: '2020-08-21T17:26:14', + updated: '2020-08-21T17:26:30', + filesystem: 'ext4', + size: 81408, + }, + { + id: 44311274, + status: 'ready', + label: '512 MB Swap Image', + created: '2020-08-21T17:26:14', + updated: '2020-08-21T17:26:31', + filesystem: 'swap', + size: 512, + }, + ]; + + const mockEntity = entityFactory.build({ + id: mockLinode.id, + label: `${randomLabel()}-entity`, + type: 'linode', + url: 'https://www.example.com', + }); + + const mockTicket = supportTicketFactory.build({ + id: randomNumber(), + entity: mockEntity, + summary: `${randomLabel()}-support-ticket`, + description: randomPhrase(), + severity: randomItem([1, 2, 3]), + status: 'new', + }); + + // Get severity label for numeric severity level. + // Bail out if we're unable to get a valid label -- this indicates a mismatch between the test and source. + const severityLabel = SEVERITY_LABEL_MAP.get(mockTicket.severity!); + if (!severityLabel) { + throw new Error( + `Unable to retrieve label for severity level '${mockTicket.severity}'. Is this a valid support severity level?` + ); + } + + mockAppendFeatureFlags({ + supportTicketSeverity: makeFeatureFlagData(true), + }); + mockGetFeatureFlagClientstream(); + mockGetSupportTickets([mockTicket]); + mockGetSupportTicket(mockTicket).as('getSupportTicket'); + mockGetSupportTicketReplies(mockTicket.id, []).as('getReplies'); + mockGetLinodeDetails(mockLinode.id, mockLinode).as('getLinode'); + mockGetLinodeConfigs(mockLinode.id, [mockConfig]).as('getLinodeConfigs'); + mockGetLinodeDisks(mockLinode.id, mockDisks).as('getDisks'); + mockGetLinodeVolumes(mockLinode.id, [mockVolume]).as('getVolumes'); + + cy.visitWithLogin('/support/tickets'); + + // Confirm that tickets are listed as expected. + cy.findByText(mockTicket.summary).should('be.visible').click(); + + cy.wait(['@getSupportTicket', '@getReplies']); + + cy.url().should('endWith', `/tickets/${mockTicket.id}`); + cy.findByText( + mockTicket.status.substring(0, 1).toUpperCase() + + mockTicket.status.substring(1) + ).should('be.visible'); + cy.findByText(`#${mockTicket.id}: ${mockTicket.summary}`).should( + 'be.visible' + ); + cy.findByText(mockTicket.description).should('be.visible'); + cy.findByText(severityLabel).should('be.visible'); + + // Clicking on the entity will redirect to the entity's page. + cy.findByText(`${mockEntity.label}`).should('be.visible').click(); + cy.url().should('endWith', `/linodes/${mockLinode.id}`); + }); +}); diff --git a/packages/manager/cypress/e2e/core/images/create-image.spec.ts b/packages/manager/cypress/e2e/core/images/create-image.spec.ts index ac1b6e794ba..1415f28d93c 100644 --- a/packages/manager/cypress/e2e/core/images/create-image.spec.ts +++ b/packages/manager/cypress/e2e/core/images/create-image.spec.ts @@ -2,10 +2,7 @@ import type { Linode, Region } from '@linode/api-v4'; import { accountFactory, linodeFactory, regionFactory } from 'src/factories'; import { authenticate } from 'support/api/authentication'; import { mockGetAccount } from 'support/intercepts/account'; -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { ui } from 'support/ui'; import { cleanUp } from 'support/util/cleanup'; import { makeFeatureFlagData } from 'support/util/feature-flags'; @@ -132,7 +129,6 @@ describe('create image (e2e)', () => { mockAppendFeatureFlags({ linodeDiskEncryption: makeFeatureFlagData(true), }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); // Mock responses const mockAccount = accountFactory.build({ @@ -145,13 +141,7 @@ describe('create image (e2e)', () => { // intercept request cy.visitWithLogin('/images/create'); - cy.wait([ - '@getFeatureFlags', - '@getClientStream', - '@getAccount', - '@getLinodes', - '@getRegions', - ]); + cy.wait(['@getFeatureFlags', '@getAccount', '@getLinodes', '@getRegions']); // Find the Linode select and open it cy.findByLabelText('Linode') @@ -176,7 +166,6 @@ describe('create image (e2e)', () => { mockAppendFeatureFlags({ linodeDiskEncryption: makeFeatureFlagData(false), }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); // Mock responses const mockAccount = accountFactory.build({ @@ -189,13 +178,7 @@ describe('create image (e2e)', () => { // intercept request cy.visitWithLogin('/images/create'); - cy.wait([ - '@getFeatureFlags', - '@getClientStream', - '@getAccount', - '@getLinodes', - '@getRegions', - ]); + cy.wait(['@getFeatureFlags', '@getAccount', '@getLinodes', '@getRegions']); // Find the Linode select and open it cy.findByLabelText('Linode') @@ -220,7 +203,6 @@ describe('create image (e2e)', () => { mockAppendFeatureFlags({ linodeDiskEncryption: makeFeatureFlagData(true), }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); // Mock responses const mockAccount = accountFactory.build({ @@ -234,13 +216,7 @@ describe('create image (e2e)', () => { // intercept request cy.visitWithLogin('/images/create'); - cy.wait([ - '@getFeatureFlags', - '@getClientStream', - '@getAccount', - '@getRegions', - '@getLinodes', - ]); + cy.wait(['@getFeatureFlags', '@getAccount', '@getRegions', '@getLinodes']); // Find the Linode select and open it cy.findByLabelText('Linode') diff --git a/packages/manager/cypress/e2e/core/images/create-linode-from-image.spec.ts b/packages/manager/cypress/e2e/core/images/create-linode-from-image.spec.ts index c6ae84c86ab..d2799cb7419 100644 --- a/packages/manager/cypress/e2e/core/images/create-linode-from-image.spec.ts +++ b/packages/manager/cypress/e2e/core/images/create-linode-from-image.spec.ts @@ -1,4 +1,4 @@ -import { containsClick, fbtClick, fbtVisible, getClick } from 'support/helpers'; +import { fbtClick, fbtVisible, getClick } from 'support/helpers'; import { apiMatcher } from 'support/util/intercepts'; import { randomLabel, randomNumber, randomString } from 'support/util/random'; import { mockGetAllImages } from 'support/intercepts/images'; @@ -33,14 +33,17 @@ const createLinodeWithImageMock = (url: string, preselectedImage: boolean) => { cy.visitWithLogin(url); cy.wait('@mockImage'); + if (!preselectedImage) { - cy.get('[data-qa-enhanced-select="Choose an image"]').within(() => { - containsClick('Choose an image'); - }); - cy.get(`[data-qa-image-select-item="${mockImage.id}"]`).within(() => { - cy.get('span').should('have.class', 'fl-tux'); - fbtClick(mockImage.label); - }); + cy.findByPlaceholderText('Choose an image') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.findByText(mockImage.label) + .should('be.visible') + .should('be.enabled') + .click(); } ui.regionSelect.find().click(); @@ -49,7 +52,13 @@ const createLinodeWithImageMock = (url: string, preselectedImage: boolean) => { fbtClick('Shared CPU'); getClick('[id="g6-nanode-1"][type="radio"]'); cy.get('[id="root-password"]').type(randomString(32)); - getClick('[data-qa-deploy-linode="true"]'); + + ui.button + .findByTitle('Create Linode') + .scrollIntoView() + .should('be.visible') + .should('be.enabled') + .click(); cy.wait('@mockLinodeRequest'); diff --git a/packages/manager/cypress/e2e/core/images/manage-image-regions.spec.ts b/packages/manager/cypress/e2e/core/images/manage-image-regions.spec.ts index c09ccfe63fa..ea570d271f5 100644 --- a/packages/manager/cypress/e2e/core/images/manage-image-regions.spec.ts +++ b/packages/manager/cypress/e2e/core/images/manage-image-regions.spec.ts @@ -22,7 +22,7 @@ describe('Manage Image Regions', () => { const image = imageFactory.build({ size: 50, total_size: 100, - capabilities: ['distributed-images'], + capabilities: ['distributed-sites'], regions: [ { region: region1.id, status: 'available' }, { region: region2.id, status: 'available' }, diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-view-code-snippet.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-view-code-snippet.spec.ts new file mode 100644 index 00000000000..fc0ac2643a5 --- /dev/null +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-view-code-snippet.spec.ts @@ -0,0 +1,206 @@ +/** + * @file Linode Create view code snippets tests. + */ + +import { ui } from 'support/ui'; + +import { randomLabel, randomString } from 'support/util/random'; +import { linodeCreatePage } from 'support/ui/pages'; +import { + mockAppendFeatureFlags, + mockGetFeatureFlagClientstream, +} from 'support/intercepts/feature-flags'; + +import { makeFeatureFlagData } from 'support/util/feature-flags'; + +describe('Create Linode', () => { + /* + * tests for create Linode flow to validate code snippet modal. + */ + describe('Create Linode flow with apicliDxToolsAdditions enabled', () => { + // Enable the `apicliDxToolsAdditions` feature flag. + // TODO Delete these mocks once `apicliDxToolsAdditions` feature flag is retired. + beforeEach(() => { + mockAppendFeatureFlags({ + apicliDxToolsAdditions: makeFeatureFlagData(true), + linodeCreateRefactor: makeFeatureFlagData(true), + }); + mockGetFeatureFlagClientstream(); + }); + it(`view code snippets in create linode flow`, () => { + const linodeLabel = randomLabel(); + const rootPass = randomString(32); + + cy.visitWithLogin('/linodes/create'); + + // Set Linode label, distribution, plan type, password, etc. + linodeCreatePage.setLabel(linodeLabel); + linodeCreatePage.selectImage('Debian 11'); + linodeCreatePage.selectRegionById('us-east'); + linodeCreatePage.selectPlan('Shared CPU', 'Nanode 1 GB'); + linodeCreatePage.setRootPassword(rootPass); + + // View Code Snippets and confirm it's provisioned as expected. + ui.button + .findByTitle('View Code Snippets') + .should('be.visible') + .should('be.enabled') + .click(); + + ui.dialog + .findByTitle('Create Linode') + .should('be.visible') + .within(() => { + ui.tabList + .findTabByTitle('cURL') + .should('be.visible') + .should('be.enabled'); + + ui.tabList.findTabByTitle('Linode CLI').should('be.visible').click(); + + // Validate Integrations + ui.tabList + .findTabByTitle('Integrations') + .should('be.visible') + .click(); + + // Validate Ansible and links + ui.autocomplete.find().click(); + + ui.autocompletePopper + .findByTitle('Ansible') + .should('be.visible') + .click(); + cy.contains( + 'a', + 'Getting Started With Ansible: Basic Installation and Setup' + ).should('be.visible'); + cy.contains('a', 'Linode Cloud Instance Module').should('be.visible'); + cy.contains('a', 'Manage Personal Access Tokens').should( + 'be.visible' + ); + cy.contains('a', 'Best Practices For Ansible').should('be.visible'); + cy.contains( + 'a', + 'Use the Linode Ansible Collection to Deploy a Linode' + ).should('be.visible'); + + // Validate Terraform and links + ui.autocomplete.find().click(); + ui.autocompletePopper + .findByTitle('Terraform') + .should('be.visible') + .click(); + cy.contains('a', `A Beginner's Guide to Terraform`).should( + 'be.visible' + ); + cy.contains('a', 'Install Terraform').should('be.visible'); + cy.contains('a', 'Manage Personal Access Tokens').should( + 'be.visible' + ); + cy.contains('a', 'Use Terraform With Linode Object Storage').should( + 'be.visible' + ); + cy.contains( + 'a', + 'Use Terraform to Provision Infrastructure on Linode' + ).should('be.visible'); + cy.contains( + 'a', + 'Import Existing Infrastructure to Terraform' + ).should('be.visible'); + + // Validate SDK's tab + ui.tabList.findTabByTitle(`SDK's`).should('be.visible').click(); + + ui.autocomplete.find().click(); + + // Validate linodego and links + ui.autocompletePopper + .findByTitle('Go (linodego)') + .should('be.visible') + .click(); + cy.contains('a', 'Go client for Linode REST v4 API').should( + 'be.visible' + ); + cy.contains('a', 'Linodego Documentation').should('be.visible'); + + ui.autocomplete.find().click(); + + // Validate Python API4 and links + ui.autocompletePopper + .findByTitle('Python (linode_api4-python)') + .should('be.visible') + .click(); + + cy.contains( + 'a', + 'Official python library for the Linode APIv4 in python' + ).should('be.visible'); + cy.contains('a', 'linode_api4-python Documentation').should( + 'be.visible' + ); + + ui.button + .findByTitle('Close') + .should('be.visible') + .should('be.enabled') + .click(); + }); + }); + }); + describe('Create Linode flow with apicliDxToolsAdditions disabled', () => { + // Enable the `apicliDxToolsAdditions` feature flag. + // TODO Delete these mocks and test once `apicliDxToolsAdditions` feature flag is retired. + beforeEach(() => { + mockAppendFeatureFlags({ + apicliDxToolsAdditions: makeFeatureFlagData(false), + linodeCreateRefactor: makeFeatureFlagData(true), + }); + mockGetFeatureFlagClientstream(); + }); + it(`view code snippets in create linode flow`, () => { + const linodeLabel = randomLabel(); + const rootPass = randomString(32); + + cy.visitWithLogin('/linodes/create'); + + // Set Linode label, distribution, plan type, password, etc. + linodeCreatePage.setLabel(linodeLabel); + linodeCreatePage.selectImage('Debian 11'); + linodeCreatePage.selectRegionById('us-east'); + linodeCreatePage.selectPlan('Shared CPU', 'Nanode 1 GB'); + linodeCreatePage.setRootPassword(rootPass); + + // View Code Snippets and confirm it's provisioned as expected. + ui.button + .findByTitle('Create using command line') + .should('be.visible') + .should('be.enabled') + .click(); + + ui.dialog + .findByTitle('Create Linode') + .should('be.visible') + .within(() => { + ui.tabList + .findTabByTitle('cURL') + .should('be.visible') + .should('be.enabled'); + + ui.tabList.findTabByTitle('Linode CLI').should('be.visible').click(); + + // Validate Integrations + ui.tabList.findTabByTitle('Integrations').should('not.exist'); + // Validate Integrations + ui.tabList.findTabByTitle(`SDK's`).should('not.exist'); + + ui.button + .findByTitle('Close') + .should('be.visible') + .should('be.enabled') + .click(); + }); + }); + }); +}); diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-with-firewall.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-with-firewall.spec.ts new file mode 100644 index 00000000000..099ffbe79ef --- /dev/null +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-with-firewall.spec.ts @@ -0,0 +1,182 @@ +import { linodeFactory, firewallFactory } from 'src/factories'; +import { + mockAppendFeatureFlags, + mockGetFeatureFlagClientstream, +} from 'support/intercepts/feature-flags'; +import { + mockCreateLinode, + mockGetLinodeDetails, +} from 'support/intercepts/linodes'; +import { + mockGetFirewalls, + mockCreateFirewall, +} from 'support/intercepts/firewalls'; +import { ui } from 'support/ui'; +import { linodeCreatePage } from 'support/ui/pages'; +import { makeFeatureFlagData } from 'support/util/feature-flags'; +import { randomLabel, randomNumber, randomString } from 'support/util/random'; +import { chooseRegion } from 'support/util/regions'; + +describe('Create Linode with Firewall', () => { + // TODO Remove feature flag mocks when `linodeCreateRefactor` flag is retired. + beforeEach(() => { + mockAppendFeatureFlags({ + linodeCreateRefactor: makeFeatureFlagData(true), + }); + mockGetFeatureFlagClientstream(); + }); + + /* + * - Confirms UI flow to create a Linode with an existing Firewall using mock API data. + * - Confirms that Firewall is reflected in create summary section. + * - Confirms that outgoing Linode Create API request specifies the selected Firewall to be attached. + */ + it('can assign existing Firewall during Linode Create flow', () => { + const linodeRegion = chooseRegion({ capabilities: ['Cloud Firewall'] }); + + const mockFirewall = firewallFactory.build({ + id: randomNumber(), + label: randomLabel(), + }); + + const mockLinode = linodeFactory.build({ + id: randomNumber(), + label: randomLabel(), + region: linodeRegion.id, + }); + + mockGetFirewalls([mockFirewall]).as('getFirewall'); + mockCreateLinode(mockLinode).as('createLinode'); + mockGetLinodeDetails(mockLinode.id, mockLinode); + + cy.visitWithLogin('/linodes/create'); + + linodeCreatePage.setLabel(mockLinode.label); + linodeCreatePage.selectImage('Debian 11'); + linodeCreatePage.selectRegionById(linodeRegion.id); + linodeCreatePage.selectPlan('Shared CPU', 'Nanode 1 GB'); + linodeCreatePage.setRootPassword(randomString(32)); + + // Confirm that mocked Firewall is shown in the Autocomplete, and then select it. + cy.findByText('Assign Firewall').click().type(`${mockFirewall.label}`); + + ui.autocompletePopper + .findByTitle(mockFirewall.label) + .should('be.visible') + .click(); + + // Confirm Firewall assignment indicator is shown in Linode summary. + cy.get('[data-qa-linode-create-summary]') + .scrollIntoView() + .within(() => { + cy.findByText('Firewall Assigned').should('be.visible'); + }); + + // Create Linode and confirm contents of outgoing API request payload. + ui.button + .findByTitle('Create Linode') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.wait('@createLinode').then((xhr) => { + const requestPayload = xhr.request.body; + const firewallId = requestPayload['firewall_id']; + expect(firewallId).to.equal(mockFirewall.id); + }); + + // Confirm redirect to new Linode. + cy.url().should('endWith', `/linodes/${mockLinode.id}`); + // Confirm toast notification should appear on Linode create. + ui.toast.assertMessage(`Your Linode ${mockLinode.label} is being created.`); + }); + + /* + * - Uses mock API data to confirm Firewall creation and attachment UI flow during Linode create. + * - Confirms that Firewall is reflected in create summary section. + * - Confirms that outgoing Linode Create API request specifies the selected Firewall to be attached. + */ + it('can assign new Firewall during Linode Create flow', () => { + const linodeRegion = chooseRegion({ capabilities: ['Cloud Firewall'] }); + + const mockFirewall = firewallFactory.build({ + id: randomNumber(), + label: randomLabel(), + }); + + const mockLinode = linodeFactory.build({ + id: randomNumber(), + label: randomLabel(), + region: linodeRegion.id, + }); + + mockCreateFirewall(mockFirewall).as('createFirewall'); + mockGetFirewalls([mockFirewall]).as('getFirewall'); + mockCreateLinode(mockLinode).as('createLinode'); + mockGetLinodeDetails(mockLinode.id, mockLinode); + + cy.visitWithLogin('/linodes/create'); + + linodeCreatePage.setLabel(mockLinode.label); + linodeCreatePage.selectImage('Debian 11'); + linodeCreatePage.selectRegionById(linodeRegion.id); + linodeCreatePage.selectPlan('Shared CPU', 'Nanode 1 GB'); + linodeCreatePage.setRootPassword(randomString(32)); + + cy.findByText('Create Firewall').should('be.visible').click(); + + ui.drawer + .findByTitle('Create Firewall') + .should('be.visible') + .within(() => { + // An error message appears when attempting to create a Firewall without a label + cy.get('[data-testid="submit"]').click(); + cy.findByText('Label is required.'); + // Fill out and submit firewall create form. + cy.contains('Label').click().type(mockFirewall.label); + ui.buttonGroup + .findButtonByTitle('Create Firewall') + .should('be.visible') + .should('be.enabled') + .click(); + }); + cy.wait('@getFirewall'); + // Confirm toast notification should appear on Linode create. + ui.toast.assertMessage( + `Firewall ${mockFirewall.label} successfully created` + ); + + // Confirm that mocked Firewall is shown in the Autocomplete, and then select it. + cy.findByText('Assign Firewall').click().type(`${mockFirewall.label}`); + + ui.autocompletePopper + .findByTitle(mockFirewall.label) + .should('be.visible') + .click(); + + // Confirm Firewall assignment indicator is shown in Linode summary. + cy.get('[data-qa-linode-create-summary]') + .scrollIntoView() + .within(() => { + cy.findByText('Firewall Assigned').should('be.visible'); + }); + + // Create Linode and confirm contents of outgoing API request payload. + ui.button + .findByTitle('Create Linode') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.wait('@createLinode').then((xhr) => { + const requestPayload = xhr.request.body; + const firewallId = requestPayload['firewall_id']; + expect(firewallId).to.equal(mockFirewall.id); + }); + + // Confirm redirect to new Linode. + cy.url().should('endWith', `/linodes/${mockLinode.id}`); + // Confirm toast notification should appear on Linode create. + ui.toast.assertMessage(`Your Linode ${mockLinode.label} is being created.`); + }); +}); diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-with-vlan.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-with-vlan.spec.ts index 7b3d495de94..65361c25d01 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode-with-vlan.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-with-vlan.spec.ts @@ -110,7 +110,8 @@ describe('Create Linode with VLANs', () => { }); cy.url().should('endWith', `/linodes/${mockLinode.id}`); - // TODO Confirm whether toast notification should appear on Linode create. + // Confirm toast notification should appear on Linode create. + ui.toast.assertMessage(`Your Linode ${mockLinode.label} is being created.`); }); /* @@ -191,7 +192,8 @@ describe('Create Linode with VLANs', () => { }); cy.url().should('endWith', `/linodes/${mockLinode.id}`); - // TODO Confirm whether toast notification should appear on Linode create. + // Confirm toast notification should appear on Linode create. + ui.toast.assertMessage(`Your Linode ${mockLinode.label} is being created.`); }); /* diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-with-vpc.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-with-vpc.spec.ts index 668c344f2de..238be908e06 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode-with-vpc.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-with-vpc.spec.ts @@ -125,7 +125,8 @@ describe('Create Linode with VPCs', () => { // Confirm redirect to new Linode. cy.url().should('endWith', `/linodes/${mockLinode.id}`); - // TODO Confirm whether toast notification should appear on Linode create. + // Confirm toast notification should appear on Linode create. + ui.toast.assertMessage(`Your Linode ${mockLinode.label} is being created.`); }); /* @@ -236,7 +237,8 @@ describe('Create Linode with VPCs', () => { }); cy.url().should('endWith', `/linodes/${mockLinode.id}`); - // TODO Confirm whether toast notification should appear on Linode create. + // Confirm toast notification should appear on Linode create. + ui.toast.assertMessage(`Your Linode ${mockLinode.label} is being created.`); }); /* diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts index 5ade90c2328..34d33345230 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts @@ -257,11 +257,6 @@ describe('Create Linode', () => { mockGetLinodeType(dcPricingMockLinodeTypes[1]); mockGetLinodeTypes(dcPricingMockLinodeTypes).as('getLinodeTypes'); - mockAppendFeatureFlags({ - vpc: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - mockGetRegions([mockVPCRegion]).as('getRegions'); mockGetVLANs(mockVLANs); @@ -274,12 +269,7 @@ describe('Create Linode', () => { // intercept request cy.visitWithLogin('/linodes/create'); - cy.wait([ - '@getLinodeTypes', - '@getClientStream', - '@getFeatureFlags', - '@getVPCs', - ]); + cy.wait(['@getLinodeTypes', '@getVPCs']); cy.get('[data-qa-header="Create"]').should('have.text', 'Create'); @@ -294,15 +284,14 @@ describe('Create Linode', () => { getVisible('[data-testid="vpc-panel"]').within(() => { containsVisible('Assign this Linode to an existing VPC.'); // select VPC - cy.get('[data-qa-enhanced-select="None"]') + cy.findByLabelText('Assign VPC') .should('be.visible') - .click() - .type(`${mockVPC.label}{enter}`); + .focus() + .type(`${mockVPC.label}{downArrow}{enter}`); // select subnet - cy.findByText('Select Subnet') + cy.findByPlaceholderText('Select Subnet') .should('be.visible') - .click() - .type(`${mockSubnet.label}{enter}`); + .type(`${mockSubnet.label}{downArrow}{enter}`); }); // The drawer opens when clicking "Add an SSH Key" button @@ -341,11 +330,13 @@ describe('Create Linode', () => { ui.toast.assertMessage('Successfully created SSH key.'); // When a user creates an SSH key, the list of SSH keys for each user updates to show the new key for the signed in user - cy.findAllByText(sshPublicKeyLabel).should('be.visible'); + cy.findByText(sshPublicKeyLabel, { exact: false }).should('be.visible'); getClick('#linode-label').clear().type(linodeLabel); cy.get('#root-password').type(rootpass); - getClick('[data-qa-deploy-linode]'); + + ui.button.findByTitle('Create Linode').click(); + cy.wait('@linodeCreated').its('response.statusCode').should('eq', 200); fbtVisible(linodeLabel); cy.contains('RUNNING', { timeout: 300000 }).should('be.visible'); diff --git a/packages/manager/cypress/e2e/core/linodes/legacy-create-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/legacy-create-linode.spec.ts index cf92b39a21b..19d8f35a550 100644 --- a/packages/manager/cypress/e2e/core/linodes/legacy-create-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/legacy-create-linode.spec.ts @@ -55,7 +55,7 @@ import { makeFeatureFlagData } from 'support/util/feature-flags'; import { checkboxTestId, headerTestId, -} from 'src/components/DiskEncryption/DiskEncryption'; +} from 'src/components/Encryption/Encryption'; import type { Config, VLAN, Disk, Region } from '@linode/api-v4'; @@ -93,8 +93,8 @@ describe('create linode', () => { beforeEach(() => { mockAppendFeatureFlags({ linodeCreateRefactor: makeFeatureFlagData(false), + apicliDxToolsAdditions: makeFeatureFlagData(false), }); - mockGetFeatureFlagClientstream(); }); /* @@ -368,17 +368,11 @@ describe('create linode', () => { mockGetLinodeType(dcPricingMockLinodeTypes[0]); mockGetLinodeType(dcPricingMockLinodeTypes[1]); mockGetLinodeTypes(dcPricingMockLinodeTypes).as('getLinodeTypes'); - - mockAppendFeatureFlags({ - vpc: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - mockGetRegions([mockNoVPCRegion]).as('getRegions'); // intercept request cy.visitWithLogin('/linodes/create'); - cy.wait(['@getLinodeTypes', '@getClientStream', '@getFeatureFlags']); + cy.wait('@getLinodeTypes'); cy.get('[data-qa-header="Create"]').should('have.text', 'Create'); @@ -469,7 +463,7 @@ describe('create linode', () => { mockGetLinodeTypes(dcPricingMockLinodeTypes).as('getLinodeTypes'); mockAppendFeatureFlags({ - vpc: makeFeatureFlagData(true), + apicliDxToolsAdditions: makeFeatureFlagData(false), }).as('getFeatureFlags'); mockGetFeatureFlagClientstream().as('getClientStream'); @@ -485,12 +479,7 @@ describe('create linode', () => { // intercept request cy.visitWithLogin('/linodes/create'); - cy.wait([ - '@getLinodeTypes', - '@getClientStream', - '@getFeatureFlags', - '@getVPCs', - ]); + cy.wait(['@getLinodeTypes', '@getVPCs']); cy.get('[data-qa-header="Create"]').should('have.text', 'Create'); @@ -505,20 +494,21 @@ describe('create linode', () => { getVisible('[data-testid="vpc-panel"]').within(() => { containsVisible('Assign this Linode to an existing VPC.'); // select VPC - cy.get('[data-qa-enhanced-select="None"]') + cy.findByLabelText('Assign VPC') .should('be.visible') - .click() - .type(`${mockVPC.label}{enter}`); + .focus() + .type(`${mockVPC.label}{downArrow}{enter}`); // select subnet - cy.findByText('Select Subnet') + cy.findByPlaceholderText('Select Subnet') .should('be.visible') - .click() - .type(`${mockSubnet.label}{enter}`); + .type(`${mockSubnet.label}{downArrow}{enter}`); }); getClick('#linode-label').clear().type(linodeLabel); cy.get('#root-password').type(rootpass); - getClick('[data-qa-deploy-linode]'); + + ui.button.findByTitle('Create Linode').click(); + cy.wait('@linodeCreated').its('response.statusCode').should('eq', 200); fbtVisible(linodeLabel); cy.contains('RUNNING', { timeout: 300000 }).should('be.visible'); @@ -539,8 +529,8 @@ describe('create linode', () => { // Mock feature flag -- @TODO LDE: Remove feature flag once LDE is fully rolled out mockAppendFeatureFlags({ linodeDiskEncryption: makeFeatureFlagData(false), + apicliDxToolsAdditions: makeFeatureFlagData(false), }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); // Mock account response const mockAccount = accountFactory.build({ @@ -551,7 +541,7 @@ describe('create linode', () => { // intercept request cy.visitWithLogin('/linodes/create'); - cy.wait(['@getFeatureFlags', '@getClientStream', '@getAccount']); + cy.wait(['@getFeatureFlags', '@getAccount']); // Check if section is visible cy.get(`[data-testid=${headerTestId}]`).should('not.exist'); @@ -561,8 +551,8 @@ describe('create linode', () => { // Mock feature flag -- @TODO LDE: Remove feature flag once LDE is fully rolled out mockAppendFeatureFlags({ linodeDiskEncryption: makeFeatureFlagData(true), + apicliDxToolsAdditions: makeFeatureFlagData(false), }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); // Mock account response const mockAccount = accountFactory.build({ @@ -584,7 +574,7 @@ describe('create linode', () => { // intercept request cy.visitWithLogin('/linodes/create'); - cy.wait(['@getFeatureFlags', '@getClientStream', '@getAccount']); + cy.wait(['@getFeatureFlags', '@getAccount']); // Check if section is visible cy.get(`[data-testid="${headerTestId}"]`).should('exist'); diff --git a/packages/manager/cypress/e2e/core/loadBalancers/load-balancer-certificates.spec.ts b/packages/manager/cypress/e2e/core/loadBalancers/load-balancer-certificates.spec.ts deleted file mode 100644 index 6d97a0dc412..00000000000 --- a/packages/manager/cypress/e2e/core/loadBalancers/load-balancer-certificates.spec.ts +++ /dev/null @@ -1,571 +0,0 @@ -/** - * @file Integration tests for Akamai Cloud Load Balancer certificates page. - */ - -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; -import { - loadbalancerFactory, - certificateFactory, - mockCertificate, -} from '@src/factories'; -import { ui } from 'support/ui'; -import { randomItem, randomLabel, randomString } from 'support/util/random'; -import { - mockDeleteLoadBalancerCertificate, - mockDeleteLoadBalancerCertificateError, - mockGetLoadBalancer, - mockGetLoadBalancerCertificates, - mockUpdateLoadBalancerCertificate, - mockUploadLoadBalancerCertificate, -} from 'support/intercepts/load-balancers'; -import { Loadbalancer, Certificate } from '@linode/api-v4'; - -/** - * Deletes the TLS / Service Target certificate in the ACLB landing page. - * - * @param loadBalancer - The load balancer that contains the certificate to be deleted. - * @param certificatesDeleteBefore - The array of certificates to be displayed before deleting. - * @param certificatesDeleteAfter - The array of certificates to be displayed after deleting. - * - * Asserts that the landing page has updated to reflect the changes. - */ -const deleteCertificate = ( - loadBalancer: Loadbalancer, - certificatesDeleteBefore: Certificate[], - certificatesDeleteAfter: Certificate[] -) => { - mockAppendFeatureFlags({ - aclb: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - mockGetLoadBalancer(loadBalancer).as('getLoadBalancer'); - mockGetLoadBalancerCertificates(loadBalancer.id, certificatesDeleteBefore).as( - 'getCertificates' - ); - - cy.visitWithLogin(`/loadbalancers/${loadBalancer.id}/certificates`); - cy.wait([ - '@getFeatureFlags', - '@getClientStream', - '@getLoadBalancer', - '@getCertificates', - ]); - - // Delete a TLS/Service Target certificate. - const certificateToDeleteLabel = certificatesDeleteBefore[0].label; - ui.actionMenu - .findByTitle(`Action Menu for certificate ${certificateToDeleteLabel}`) - .should('be.visible') - .click(); - ui.actionMenuItem.findByTitle('Delete').should('be.visible').click(); - - mockDeleteLoadBalancerCertificate( - loadBalancer.id, - certificatesDeleteBefore[0].id - ).as('deleteCertificate'); - - mockGetLoadBalancerCertificates(loadBalancer.id, certificatesDeleteAfter).as( - 'getCertificates' - ); - - ui.dialog - .findByTitle(`Delete Certificate ${certificateToDeleteLabel}?`) - .should('be.visible') - .within(() => { - ui.button - .findByTitle('Delete') - .should('be.visible') - .should('be.enabled') - .click(); - }); - - cy.wait(['@deleteCertificate', '@getCertificates']); - - // Confirm that the deleted certificate is removed from the table with expected info. - cy.findByText(certificateToDeleteLabel).should('not.exist'); - - if (certificatesDeleteAfter.length === 0) { - // Confirm that Cloud Manager allows users to delete the last certificate, and display empty state gracefully. - cy.findByText('No items to display.').should('be.visible'); - } -}; - -describe('Akamai Cloud Load Balancer certificates page', () => { - let mockLoadBalancer: Loadbalancer; - - before(() => { - mockLoadBalancer = loadbalancerFactory.build(); - }); - - /* - * - Confirms Load Balancer certificate upload UI flow using mocked API requests. - * - Confirms that TLS and Service Target certificates can be uploaded. - * - Confirms that certificates table update to reflects uploaded certificates. - */ - it('can upload a TLS certificate', () => { - const mockLoadBalancerCertTls = certificateFactory.build({ - label: randomLabel(), - type: 'downstream', - }); - - mockAppendFeatureFlags({ - aclb: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - mockGetLoadBalancer(mockLoadBalancer).as('getLoadBalancer'); - mockGetLoadBalancerCertificates(mockLoadBalancer.id, []).as( - 'getCertificates' - ); - - cy.visitWithLogin(`/loadbalancers/${mockLoadBalancer.id}/certificates`); - cy.wait([ - '@getFeatureFlags', - '@getClientStream', - '@getLoadBalancer', - '@getCertificates', - ]); - - // Confirm that no certificates are listed. - cy.findByText('No items to display.').should('be.visible'); - - // Upload a TLS certificate. - ui.button - .findByTitle('Upload Certificate') - .should('be.visible') - .should('be.enabled') - .click(); - - mockUploadLoadBalancerCertificate( - mockLoadBalancer.id, - mockLoadBalancerCertTls - ).as('uploadCertificate'); - - mockGetLoadBalancerCertificates(mockLoadBalancer.id, [ - mockLoadBalancerCertTls, - ]).as('getCertificates'); - - ui.drawer - .findByTitle('Upload TLS Certificate') - .should('be.visible') - .within(() => { - cy.findByLabelText('Label') - .should('be.visible') - .type(mockLoadBalancerCertTls.label); - - cy.findByLabelText('TLS Certificate') - .should('be.visible') - .type(randomString(32)); - - cy.findByLabelText('Private Key') - .should('be.visible') - .type(randomString(32)); - - ui.buttonGroup - .findButtonByTitle('Upload Certificate') - .scrollIntoView() - .should('be.visible') - .should('be.enabled') - .click(); - }); - - cy.wait(['@uploadCertificate', '@getCertificates']); - - // Confirm that new certificate is listed in the table with expected info. - cy.findByText(mockLoadBalancerCertTls.label).should('be.visible'); - }); - - it('can upload a service target certificate', () => { - const mockLoadBalancerCertServiceTarget = certificateFactory.build({ - label: randomLabel(), - type: 'ca', - }); - - mockAppendFeatureFlags({ - aclb: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - mockGetLoadBalancer(mockLoadBalancer).as('getLoadBalancer'); - mockGetLoadBalancerCertificates(mockLoadBalancer.id, []).as( - 'getCertificates' - ); - - cy.visitWithLogin(`/loadbalancers/${mockLoadBalancer.id}/certificates/ca`); - cy.wait([ - '@getFeatureFlags', - '@getClientStream', - '@getLoadBalancer', - '@getCertificates', - ]); - - // Confirm that no certificates are listed. - cy.findByText('No items to display.').should('be.visible'); - - // Upload a TLS certificate. - ui.button - .findByTitle('Upload Certificate') - .should('be.visible') - .should('be.enabled') - .click(); - - // Upload a service target certificate. - mockUploadLoadBalancerCertificate( - mockLoadBalancer.id, - mockLoadBalancerCertServiceTarget - ).as('uploadCertificate'); - mockGetLoadBalancerCertificates(mockLoadBalancer.id, [ - mockLoadBalancerCertServiceTarget, - ]).as('getCertificates'); - - ui.drawer - .findByTitle('Upload Service Target Certificate') - .should('be.visible') - .within(() => { - cy.findByLabelText('Label') - .should('be.visible') - .type(mockLoadBalancerCertServiceTarget.label); - - cy.findByLabelText('Server Certificate') - .should('be.visible') - .type(randomString(32)); - - ui.buttonGroup - .findButtonByTitle('Upload Certificate') - .scrollIntoView() - .should('be.visible') - .should('be.enabled') - .click(); - }); - - cy.wait(['@uploadCertificate', '@getCertificates']); - - // Confirm that both new certificates are listed in the table with expected info. - cy.findByText(mockLoadBalancerCertServiceTarget.label).should('be.visible'); - }); - - /* - * - Confirms Load Balancer certificate edit UI flow using mocked API requests. - * - Confirms that TLS and Service Target certificates can be edited. - * - Confirms that certificates table updates to reflect edited certificates. - */ - it('can update a TLS certificate', () => { - const mockLoadBalancer = loadbalancerFactory.build(); - const mockLoadBalancerCertTls = certificateFactory.build({ - label: randomLabel(), - type: 'downstream', - certificate: mockCertificate.trim(), - }); - - const mockNewLoadBalancerKey = 'mock-new-key'; - const mockNewLoadBalancerCertTls = certificateFactory.build({ - label: 'my-updated-tls-cert', - certificate: 'mock-new-cert', - type: 'downstream', - }); - - mockAppendFeatureFlags({ - aclb: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - mockGetLoadBalancer(mockLoadBalancer).as('getLoadBalancer'); - mockGetLoadBalancerCertificates(mockLoadBalancer.id, [ - mockLoadBalancerCertTls, - ]).as('getCertificates'); - - cy.visitWithLogin(`/loadbalancers/${mockLoadBalancer.id}/certificates`); - cy.wait([ - '@getFeatureFlags', - '@getClientStream', - '@getLoadBalancer', - '@getCertificates', - ]); - - // Edit a TLS certificate. - ui.actionMenu - .findByTitle( - `Action Menu for certificate ${mockLoadBalancerCertTls.label}` - ) - .should('be.visible') - .click(); - ui.actionMenuItem.findByTitle('Edit').should('be.visible').click(); - - mockUpdateLoadBalancerCertificate( - mockLoadBalancer.id, - mockLoadBalancerCertTls - ).as('updateCertificate'); - - mockGetLoadBalancerCertificates(mockLoadBalancer.id, [ - mockNewLoadBalancerCertTls, - ]).as('getCertificates'); - - ui.drawer - .findByTitle(`Edit ${mockLoadBalancerCertTls.label}`) - .should('be.visible') - .within(() => { - // Confirm that drawer displays certificate data or indicates where data is redacted for security. - cy.findByLabelText('Certificate Label') - .should('be.visible') - .should('have.value', mockLoadBalancerCertTls.label); - - cy.findByLabelText('TLS Certificate') - .should('be.visible') - .should('have.value', mockLoadBalancerCertTls.certificate); - - cy.findByLabelText('Private Key') - .should('be.visible') - .should('have.value', '') - .invoke('attr', 'placeholder') - .should('contain', 'Private key is redacted for security.'); - - // Attempt to submit an incorrect form without a label or a new cert key. - cy.findByLabelText('Certificate Label').clear(); - cy.findByLabelText('TLS Certificate').clear().type('my-new-cert'); - - ui.buttonGroup - .findButtonByTitle('Update Certificate') - .scrollIntoView() - .should('be.visible') - .should('be.enabled') - .click(); - - // Confirm that validation errors appear when drawer is not filled out correctly. - cy.findAllByText('Label must not be empty.').should('be.visible'); - cy.findAllByText('Private Key is required').should('be.visible'); - - // Fix errors. - cy.findByLabelText('Certificate Label') - .click() - .type(mockNewLoadBalancerCertTls.label); - - cy.findByLabelText('TLS Certificate') - .click() - .type(mockNewLoadBalancerCertTls.certificate!); - - cy.findByLabelText('Private Key').click().type(mockNewLoadBalancerKey); - - ui.buttonGroup - .findButtonByTitle('Update Certificate') - .scrollIntoView() - .click(); - }); - - cy.wait(['@updateCertificate', '@getCertificates']); - - // Confirm that new certificate is listed in the table with expected info. - cy.findByText(mockNewLoadBalancerCertTls.label).should('be.visible'); - }); - - it('can update a service target certificate', () => { - const mockLoadBalancer = loadbalancerFactory.build(); - const mockLoadBalancerCertServiceTarget = certificateFactory.build({ - label: randomLabel(), - type: 'ca', - certificate: mockCertificate.trim(), - }); - const mockNewLoadBalancerCertServiceTarget = certificateFactory.build({ - label: 'my-updated-ca-cert', - certificate: 'mock-new-cert', - type: 'ca', - }); - - mockAppendFeatureFlags({ - aclb: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - mockGetLoadBalancer(mockLoadBalancer).as('getLoadBalancer'); - mockGetLoadBalancerCertificates(mockLoadBalancer.id, [ - mockLoadBalancerCertServiceTarget, - ]).as('getCertificates'); - - cy.visitWithLogin(`/loadbalancers/${mockLoadBalancer.id}/certificates`); - cy.wait([ - '@getFeatureFlags', - '@getClientStream', - '@getLoadBalancer', - '@getCertificates', - ]); - - // Edit a CA certificate. - ui.actionMenu - .findByTitle( - `Action Menu for certificate ${mockLoadBalancerCertServiceTarget.label}` - ) - .should('be.visible') - .click(); - ui.actionMenuItem.findByTitle('Edit').should('be.visible').click(); - - mockUpdateLoadBalancerCertificate( - mockLoadBalancer.id, - mockLoadBalancerCertServiceTarget - ).as('updateCertificate'); - - mockGetLoadBalancerCertificates(mockLoadBalancer.id, [ - mockNewLoadBalancerCertServiceTarget, - ]).as('getCertificates'); - - ui.drawer - .findByTitle(`Edit ${mockLoadBalancerCertServiceTarget.label}`) - .should('be.visible') - .within(() => { - // Confirm that drawer displays certificate data or indicates where data is redacted for security. - cy.findByLabelText('Certificate Label') - .should('be.visible') - .should('have.value', mockLoadBalancerCertServiceTarget.label); - - cy.findByLabelText('Server Certificate') - .should('be.visible') - .should('have.value', mockLoadBalancerCertServiceTarget.certificate); - - cy.findByLabelText('Private Key').should('not.exist'); - - // Attempt to submit an incorrect form without a label. - cy.findByLabelText('Certificate Label').clear(); - - ui.buttonGroup - .findButtonByTitle('Update Certificate') - .scrollIntoView() - .should('be.visible') - .should('be.enabled') - .click(); - - // Confirm that validation error appears when drawer is not filled out correctly. - cy.findAllByText('Label must not be empty.').should('be.visible'); - - // Fix error. - cy.findByLabelText('Certificate Label') - .click() - .type(mockNewLoadBalancerCertServiceTarget.label); - - // Update certificate. - cy.findByLabelText('Server Certificate') - .click() - .type(mockNewLoadBalancerCertServiceTarget.certificate!); - - ui.buttonGroup - .findButtonByTitle('Update Certificate') - .scrollIntoView() - .click(); - }); - - cy.wait(['@updateCertificate', '@getCertificates']); - - // Confirm that new certificate is listed in the table with expected info. - cy.findByText(mockNewLoadBalancerCertServiceTarget.label).should( - 'be.visible' - ); - }); - - /* - * - Confirms Load Balancer certificate delete UI flow using mocked API requests. - * - Confirms that TLS and Service Target certificates can be deleted. - * - Confirms that certificates table update to reflects deleted certificates. - * - Confirms that the last certificate can be deleted. - */ - it('can delete a TLS certificate', () => { - const mockLoadBalancerCertsTls = certificateFactory.buildList(5, { - type: 'downstream', - }); - const mockLoadBalancerAfterDeleteCertsTls = mockLoadBalancerCertsTls.slice( - 1 - ); - - deleteCertificate( - mockLoadBalancer, - mockLoadBalancerCertsTls, - mockLoadBalancerAfterDeleteCertsTls - ); - }); - - it('can delete a service target certificate', () => { - const mockLoadBalancerCertsTls = certificateFactory.buildList(5, { - type: 'ca', - }); - const mockLoadBalancerAfterDeleteCertsTls = mockLoadBalancerCertsTls.slice( - 1 - ); - - deleteCertificate( - mockLoadBalancer, - mockLoadBalancerCertsTls, - mockLoadBalancerAfterDeleteCertsTls - ); - }); - - it('can delete the last certificate', () => { - const mockLoadBalancerCertsTls = certificateFactory.buildList(1, { - type: randomItem(['ca', 'downstream']), - }); - const mockLoadBalancerAfterDeleteCertsTls = mockLoadBalancerCertsTls.slice( - 1 - ); - - deleteCertificate( - mockLoadBalancer, - mockLoadBalancerCertsTls, - mockLoadBalancerAfterDeleteCertsTls - ); - }); - - it('can handle server errors gracefully when failing to delete the certificate', () => { - const mockLoadBalancerCertsTls = certificateFactory.buildList(1, { - type: randomItem(['ca', 'downstream']), - }); - - mockAppendFeatureFlags({ - aclb: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - mockGetLoadBalancer(mockLoadBalancer).as('getLoadBalancer'); - mockGetLoadBalancerCertificates( - mockLoadBalancer.id, - mockLoadBalancerCertsTls - ).as('getCertificates'); - - cy.visitWithLogin(`/loadbalancers/${mockLoadBalancer.id}/certificates`); - cy.wait([ - '@getFeatureFlags', - '@getClientStream', - '@getLoadBalancer', - '@getCertificates', - ]); - - // Delete a TLS/Service Target certificate. - const certificateToDeleteLabel = mockLoadBalancerCertsTls[0].label; - ui.actionMenu - .findByTitle(`Action Menu for certificate ${certificateToDeleteLabel}`) - .should('be.visible') - .click(); - ui.actionMenuItem.findByTitle('Delete').should('be.visible').click(); - - mockDeleteLoadBalancerCertificateError( - mockLoadBalancer.id, - mockLoadBalancerCertsTls[0].id - ).as('deleteCertificateError'); - - ui.dialog - .findByTitle(`Delete Certificate ${certificateToDeleteLabel}?`) - .should('be.visible') - .within(() => { - ui.button - .findByTitle('Delete') - .should('be.visible') - .should('be.enabled') - .click(); - }); - - cy.wait('@deleteCertificateError'); - - ui.dialog - .findByTitle(`Delete Certificate ${certificateToDeleteLabel}?`) - .should('be.visible') - .within(() => { - // Confirm that an error message shows up in the dialog - cy.findByText( - 'An error occurred while deleting Load Balancer certificate.' - ).should('be.visible'); - }); - }); -}); diff --git a/packages/manager/cypress/e2e/core/loadBalancers/load-balancer-configurations.spec.ts b/packages/manager/cypress/e2e/core/loadBalancers/load-balancer-configurations.spec.ts deleted file mode 100644 index e676aee0499..00000000000 --- a/packages/manager/cypress/e2e/core/loadBalancers/load-balancer-configurations.spec.ts +++ /dev/null @@ -1,708 +0,0 @@ -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; -import { - loadbalancerFactory, - configurationFactory, - certificateFactory, - routeFactory, -} from '@src/factories'; -import { - mockCreateLoadBalancerConfiguration, - mockCreateLoadBalancerConfigurationError, - mockDeleteLoadBalancerConfiguration, - mockDeleteLoadBalancerConfigurationError, - mockGetLoadBalancer, - mockGetLoadBalancerCertificates, - mockGetLoadBalancerConfigurations, - mockGetLoadBalancerRoutes, - mockUpdateLoadBalancerConfiguration, - mockUpdateLoadBalancerConfigurationError, -} from 'support/intercepts/load-balancers'; -import { ui } from 'support/ui'; - -describe('Akamai Cloud Load Balancer configurations page', () => { - beforeEach(() => { - mockAppendFeatureFlags({ - aclb: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - }); - - it('renders configurations', () => { - const loadbalancer = loadbalancerFactory.build(); - const configurations = configurationFactory.buildList(5); - - mockGetLoadBalancer(loadbalancer).as('getLoadBalancer'); - mockGetLoadBalancerConfigurations(loadbalancer.id, configurations).as( - 'getConfigurations' - ); - - cy.visitWithLogin(`/loadbalancers/${loadbalancer.id}/configurations`); - - cy.wait([ - '@getFeatureFlags', - '@getClientStream', - '@getLoadBalancer', - '@getConfigurations', - ]); - - for (const configuration of configurations) { - cy.findByText(configuration.label).should('be.visible'); - } - }); - describe('create', () => { - it('creates a HTTPS configuration', () => { - const loadbalancer = loadbalancerFactory.build(); - const certificates = certificateFactory.buildList(1); - const routes = routeFactory.buildList(1); - const configuration = configurationFactory.build(); - - mockGetLoadBalancer(loadbalancer).as('getLoadBalancer'); - mockGetLoadBalancerConfigurations(loadbalancer.id, []).as( - 'getConfigurations' - ); - mockGetLoadBalancerCertificates(loadbalancer.id, certificates); - mockGetLoadBalancerRoutes(loadbalancer.id, routes); - mockCreateLoadBalancerConfiguration(loadbalancer.id, configuration).as( - 'createConfiguration' - ); - - cy.visitWithLogin(`/loadbalancers/${loadbalancer.id}/configurations`); - - cy.wait([ - '@getFeatureFlags', - '@getClientStream', - '@getLoadBalancer', - '@getConfigurations', - ]); - - ui.button.findByTitle('Add Configuration').click(); - - cy.findByLabelText('Configuration Label') - .should('be.visible') - .type(configuration.label); - - ui.button.findByTitle('Add Certificate').should('be.visible').click(); - - ui.drawer.findByTitle('Add Certificate').within(() => { - cy.findByLabelText('Add Existing Certificate').click(); - - cy.findByLabelText('Server Name Indication (SNI) Hostname') - .should('be.visible') - .type('*'); - - cy.findByLabelText('Certificate').should('be.visible').click(); - - ui.autocompletePopper - .findByTitle(certificates[0].label) - .should('be.visible') - .click(); - - ui.button - .findByTitle('Add') - .should('be.visible') - .should('be.enabled') - .click(); - }); - - cy.findByText(certificates[0].label); - cy.findByText('*'); - - ui.button.findByTitle('Add Route').click(); - - ui.drawer.findByTitle('Add Route').within(() => { - cy.findByLabelText('Route').click(); - - ui.autocompletePopper - .findByTitle(routes[0].label, { exact: false }) - .click(); - - ui.buttonGroup - .findButtonByTitle('Add Route') - .should('be.visible') - .should('be.enabled') - .click(); - }); - - ui.button - .findByTitle('Create Configuration') - .should('be.visible') - .should('be.enabled') - .click(); - - cy.wait(['@createConfiguration', '@getConfigurations']); - }); - it('creates a HTTP configuration', () => { - const loadbalancer = loadbalancerFactory.build(); - const routes = routeFactory.buildList(1, { protocol: 'http' }); - const configuration = configurationFactory.build({ - port: 80, - protocol: 'http', - }); - - mockGetLoadBalancer(loadbalancer).as('getLoadBalancer'); - mockGetLoadBalancerConfigurations(loadbalancer.id, []).as( - 'getConfigurations' - ); - mockGetLoadBalancerRoutes(loadbalancer.id, routes); - mockCreateLoadBalancerConfiguration(loadbalancer.id, configuration).as( - 'createConfiguration' - ); - - cy.visitWithLogin(`/loadbalancers/${loadbalancer.id}/configurations`); - - cy.wait([ - '@getFeatureFlags', - '@getClientStream', - '@getLoadBalancer', - '@getConfigurations', - ]); - - ui.button.findByTitle('Add Configuration').click(); - - cy.findByLabelText('Configuration Label') - .should('be.visible') - .type(configuration.label); - - cy.findByLabelText('Protocol').click(); - - ui.autocompletePopper - .findByTitle(configuration.protocol.toUpperCase()) - .click(); - - cy.findByLabelText('Port').clear().type(`${configuration.port}`); - - // Certificates do not apply to HTTP, so make sure there is not mention of them - cy.findByText('Details') - .closest('form') - .findByText('Certificate') - .should('not.exist'); - - ui.button.findByTitle('Add Route').click(); - - ui.drawer.findByTitle('Add Route').within(() => { - cy.findByLabelText('Route').click(); - - ui.autocompletePopper - .findByTitle(routes[0].label, { exact: false }) - .click(); - - ui.buttonGroup - .findButtonByTitle('Add Route') - .should('be.visible') - .should('be.enabled') - .click(); - }); - - ui.button - .findByTitle('Create Configuration') - .should('be.visible') - .should('be.enabled') - .click(); - - cy.wait(['@createConfiguration', '@getConfigurations']); - }); - it('creates a TCP configuration', () => { - const loadbalancer = loadbalancerFactory.build(); - const routes = routeFactory.buildList(1, { protocol: 'tcp' }); - const configuration = configurationFactory.build({ - port: 22, - protocol: 'tcp', - }); - - mockGetLoadBalancer(loadbalancer).as('getLoadBalancer'); - mockGetLoadBalancerConfigurations(loadbalancer.id, []).as( - 'getConfigurations' - ); - mockGetLoadBalancerRoutes(loadbalancer.id, routes); - mockCreateLoadBalancerConfiguration(loadbalancer.id, configuration).as( - 'createConfiguration' - ); - - cy.visitWithLogin(`/loadbalancers/${loadbalancer.id}/configurations`); - - cy.wait([ - '@getFeatureFlags', - '@getClientStream', - '@getLoadBalancer', - '@getConfigurations', - ]); - - ui.button.findByTitle('Add Configuration').click(); - - cy.findByLabelText('Configuration Label') - .should('be.visible') - .type(configuration.label); - - cy.findByLabelText('Protocol').click(); - - ui.autocompletePopper - .findByTitle(configuration.protocol.toUpperCase()) - .click(); - - cy.findByLabelText('Port').clear().type(`${configuration.port}`); - - // Certificates do not apply to HTTP, so make sure there is not mention of them - cy.findByText('Details') - .closest('form') - .findByText('Certificate') - .should('not.exist'); - - ui.button.findByTitle('Add Route').click(); - - ui.drawer.findByTitle('Add Route').within(() => { - cy.findByLabelText('Route').click(); - - ui.autocompletePopper - .findByTitle(routes[0].label, { exact: false }) - .click(); - - ui.buttonGroup - .findButtonByTitle('Add Route') - .should('be.visible') - .should('be.enabled') - .click(); - }); - - ui.button - .findByTitle('Create Configuration') - .should('be.visible') - .should('be.enabled') - .click(); - - cy.wait(['@createConfiguration', '@getConfigurations']); - }); - it('shows API errors when creating an HTTPS configuration', () => { - const loadbalancer = loadbalancerFactory.build(); - const certificates = certificateFactory.buildList(1); - const routes = routeFactory.buildList(1); - - mockGetLoadBalancer(loadbalancer).as('getLoadBalancer'); - mockGetLoadBalancerConfigurations(loadbalancer.id, []).as( - 'getConfigurations' - ); - mockGetLoadBalancerCertificates(loadbalancer.id, certificates); - mockGetLoadBalancerRoutes(loadbalancer.id, routes); - - const errors = [ - { field: 'label', reason: 'Must be greater than 2 characters.' }, - { field: 'port', reason: 'Must be a number.' }, - { field: 'protocol', reason: "Can't use UDP." }, - { field: 'route_ids', reason: 'Your routes are messed up.' }, - { - field: 'certificates', - reason: 'Something about your certs is not correct.', - }, - ]; - - mockCreateLoadBalancerConfigurationError(loadbalancer.id, errors).as( - 'createConfiguration' - ); - - cy.visitWithLogin(`/loadbalancers/${loadbalancer.id}/configurations`); - cy.wait([ - '@getFeatureFlags', - '@getClientStream', - '@getLoadBalancer', - '@getConfigurations', - ]); - - ui.button.findByTitle('Add Configuration').click(); - - cy.findByLabelText('Configuration Label') - .should('be.visible') - .type('test'); - - ui.button.findByTitle('Add Certificate').should('be.visible').click(); - - ui.drawer.findByTitle('Add Certificate').within(() => { - cy.findByLabelText('Add Existing Certificate').click(); - - cy.findByLabelText('Server Name Indication (SNI) Hostname') - .should('be.visible') - .type('*'); - - cy.findByLabelText('Certificate').should('be.visible').click(); - - ui.autocompletePopper - .findByTitle(certificates[0].label) - .should('be.visible') - .click(); - - ui.button - .findByTitle('Add') - .should('be.visible') - .should('be.enabled') - .click(); - }); - - cy.findByText(certificates[0].label); - cy.findByText('*'); - - ui.button.findByTitle('Add Route').click(); - - ui.drawer.findByTitle('Add Route').within(() => { - cy.findByLabelText('Route').click(); - - ui.autocompletePopper - .findByTitle(routes[0].label, { exact: false }) - .click(); - - ui.buttonGroup - .findButtonByTitle('Add Route') - .should('be.visible') - .should('be.enabled') - .click(); - }); - - ui.button - .findByTitle('Create Configuration') - .should('be.visible') - .should('be.enabled') - .click(); - - cy.wait(['@createConfiguration']); - - for (const { reason } of errors) { - cy.findByText(reason).should('be.visible'); - } - }); - }); - - describe('update', () => { - it('edits a HTTPS configuration', () => { - const configuration = configurationFactory.build({ protocol: 'https' }); - const loadbalancer = loadbalancerFactory.build({ - configurations: [{ id: configuration.id, label: configuration.label }], - }); - const certificates = certificateFactory.buildList(3); - const routes = routeFactory.buildList(3); - - mockGetLoadBalancer(loadbalancer).as('getLoadBalancer'); - mockGetLoadBalancerConfigurations(loadbalancer.id, [configuration]).as( - 'getConfigurations' - ); - mockGetLoadBalancerCertificates(loadbalancer.id, certificates).as( - 'getCertificates' - ); - mockUpdateLoadBalancerConfiguration(loadbalancer.id, configuration).as( - 'updateConfiguration' - ); - mockGetLoadBalancerRoutes(loadbalancer.id, routes).as('getRoutes'); - - cy.visitWithLogin( - `/loadbalancers/${loadbalancer.id}/configurations/${configuration.id}` - ); - - cy.wait([ - '@getFeatureFlags', - '@getClientStream', - '@getLoadBalancer', - '@getConfigurations', - '@getRoutes', - '@getCertificates', - ]); - - // In edit mode, we will disable the "save" button if the user hasn't made any changes - ui.button - .findByTitle('Save Configuration') - .should('be.visible') - .should('be.disabled'); - - cy.findByLabelText('Configuration Label') - .should('be.visible') - .clear() - .type('new-label'); - - cy.findByLabelText('Port').should('be.visible').clear().type('444'); - - ui.button - .findByTitle('Add Certificate') - .should('be.visible') - .should('be.enabled') - .click(); - - ui.drawer.findByTitle('Add Certificate').within(() => { - cy.findByLabelText('Add Existing Certificate').click(); - - cy.findByLabelText('Server Name Indication (SNI) Hostname').type( - 'example-1.com' - ); - - cy.findByLabelText('Certificate').click(); - - ui.autocompletePopper.findByTitle(certificates[1].label).click(); - - ui.button - .findByTitle('Add') - .should('be.visible') - .should('be.enabled') - .click(); - }); - - ui.button - .findByTitle('Save Configuration') - .should('be.visible') - .should('be.enabled') - .click(); - - cy.wait('@updateConfiguration'); - }); - - it('can remove a route from an ACLB configuration', () => { - const routes = routeFactory.buildList(3); - const configuration = configurationFactory.build({ - protocol: 'http', - certificates: [], - routes: routes.map((route) => ({ id: route.id, label: route.label })), - }); - const loadbalancer = loadbalancerFactory.build({ - configurations: [{ id: configuration.id, label: configuration.label }], - }); - - mockGetLoadBalancer(loadbalancer).as('getLoadBalancer'); - mockGetLoadBalancerConfigurations(loadbalancer.id, [configuration]).as( - 'getConfigurations' - ); - mockUpdateLoadBalancerConfiguration(loadbalancer.id, configuration).as( - 'updateConfiguration' - ); - mockGetLoadBalancerRoutes(loadbalancer.id, routes).as('getRoutes'); - - cy.visitWithLogin( - `/loadbalancers/${loadbalancer.id}/configurations/${configuration.id}` - ); - - cy.wait([ - '@getFeatureFlags', - '@getClientStream', - '@getLoadBalancer', - '@getConfigurations', - '@getRoutes', - ]); - - // In edit mode, we will disable the "save" button if the user hasn't made any changes - ui.button - .findByTitle('Save Configuration') - .should('be.visible') - .should('be.disabled'); - - const routeToDelete = routes[1]; - - cy.findByText(routeToDelete.label) - .closest('tr') - .within(() => { - ui.actionMenu - .findByTitle(`Action Menu for Route ${routeToDelete.label}`) - .click(); - }); - - // Because the route table uses an API filter at all times to show the correct routes, - // we must simulate the filtering by mocking. - const newRoutes = routes.filter((r) => r.label !== routeToDelete.label); - mockGetLoadBalancerRoutes(loadbalancer.id, newRoutes).as('getRoutes'); - - ui.actionMenuItem.findByTitle('Remove').click(); - - const newConfiguration = { - ...configuration, - routes: newRoutes.map((r) => ({ id: r.id, label: r.label })), - }; - - // Beacsue the configruations data will be invalidated and refetched, - // we must mock that the route was removed. - mockGetLoadBalancerConfigurations(loadbalancer.id, [newConfiguration]).as( - 'getConfigurations' - ); - - ui.button - .findByTitle('Save Configuration') - .should('be.visible') - .should('be.enabled') - .click(); - - cy.wait('@updateConfiguration'); - - // After successfully saving changes, the button should be disabled. - ui.button - .findByTitle('Save Configuration') - .should('be.visible') - .should('be.disabled'); - }); - - it('shows API errors when editing', () => { - const configuration = configurationFactory.build({ protocol: 'https' }); - const loadbalancer = loadbalancerFactory.build({ - configurations: [{ id: configuration.id, label: configuration.label }], - }); - - mockGetLoadBalancer(loadbalancer).as('getLoadBalancer'); - mockGetLoadBalancerConfigurations(loadbalancer.id, [configuration]).as( - 'getConfigurations' - ); - - const errors = [ - { field: 'label', reason: 'Bad Label.' }, - { field: 'port', reason: 'Port number is too high.' }, - { field: 'protocol', reason: 'This protocol is not supported.' }, - { - field: 'certificates[0].id', - reason: 'Certificate 0 does not exist.', - }, - { - field: 'certificates[0].hostname', - reason: 'That hostname is too long.', - }, - { field: 'route_ids', reason: 'Some of these routes do not exist.' }, - ]; - - mockUpdateLoadBalancerConfigurationError( - loadbalancer.id, - configuration.id, - errors - ).as('updateConfigurationError'); - - cy.visitWithLogin( - `/loadbalancers/${loadbalancer.id}/configurations/${configuration.id}` - ); - - cy.wait([ - '@getFeatureFlags', - '@getClientStream', - '@getLoadBalancer', - '@getConfigurations', - ]); - - // In edit mode, we will disable the "save" button if the user hasn't made any changes - ui.button - .findByTitle('Save Configuration') - .should('be.visible') - .should('be.disabled'); - - cy.findByLabelText('Configuration Label') - .should('be.visible') - .clear() - .type('new-label'); - - ui.button - .findByTitle('Save Configuration') - .should('be.visible') - .should('be.enabled') - .click(); - - cy.wait('@updateConfigurationError'); - - for (const error of errors) { - cy.findByText(error.reason).should('be.visible'); - } - }); - }); - - describe('delete', () => { - it('deletes a configuration', () => { - const loadbalancer = loadbalancerFactory.build(); - const configurations = configurationFactory.buildList(1); - - mockGetLoadBalancer(loadbalancer).as('getLoadBalancer'); - mockGetLoadBalancerConfigurations(loadbalancer.id, configurations).as( - 'getConfigurations' - ); - - cy.visitWithLogin(`/loadbalancers/${loadbalancer.id}/configurations`); - - cy.wait([ - '@getFeatureFlags', - '@getClientStream', - '@getLoadBalancer', - '@getConfigurations', - ]); - - const configuration = configurations[0]; - - cy.findByText(configuration.label).as('accordionHeader'); - // Click the accordion header to open the accordion - cy.get('@accordionHeader').click(); - // Get the Configuration's entire accordion area - cy.get('@accordionHeader') - .closest('[data-qa-panel]') - .within(() => { - // Click the Delete button to open the delete dialog - ui.button.findByTitle('Delete').click(); - }); - - mockDeleteLoadBalancerConfiguration(loadbalancer.id, configuration.id).as( - 'deleteConfiguration' - ); - mockGetLoadBalancerConfigurations(loadbalancer.id, []).as( - 'getConfigurations' - ); - - ui.dialog - .findByTitle(`Delete Configuration ${configuration.label}?`) - .within(() => { - cy.findByText( - 'Are you sure you want to delete this configuration?' - ).should('be.visible'); - - ui.button.findByTitle('Delete').click(); - }); - - cy.wait(['@deleteConfiguration', '@getConfigurations']); - - cy.findByText(configuration.label).should('not.exist'); - }); - it('shows API errors when deleting a configuration', () => { - const loadbalancer = loadbalancerFactory.build(); - const configurations = configurationFactory.buildList(1); - - mockGetLoadBalancer(loadbalancer).as('getLoadBalancer'); - mockGetLoadBalancerConfigurations(loadbalancer.id, configurations).as( - 'getConfigurations' - ); - - cy.visitWithLogin(`/loadbalancers/${loadbalancer.id}/configurations`); - cy.wait([ - '@getFeatureFlags', - '@getClientStream', - '@getLoadBalancer', - '@getConfigurations', - ]); - - const configuration = configurations[0]; - - cy.findByText(configuration.label).as('accordionHeader'); - // Click the accordion header to open the accordion - cy.get('@accordionHeader').click(); - // Get the Configuration's entire accordion area - cy.get('@accordionHeader') - .closest('[data-qa-panel]') - .within(() => { - // Click the Delete button to open the delete dialog - ui.button.findByTitle('Delete').click(); - }); - - mockDeleteLoadBalancerConfigurationError( - loadbalancer.id, - configuration.id, - 'Control Plane Error' - ).as('deleteConfiguration'); - - ui.dialog - .findByTitle(`Delete Configuration ${configuration.label}?`) - .within(() => { - cy.findByText( - 'Are you sure you want to delete this configuration?' - ).should('be.visible'); - - ui.button.findByTitle('Delete').click(); - - cy.wait(['@deleteConfiguration']); - - cy.findByText('Control Plane Error').should('be.visible'); - }); - }); - }); -}); diff --git a/packages/manager/cypress/e2e/core/loadBalancers/load-balancer-landing-page.spec.ts b/packages/manager/cypress/e2e/core/loadBalancers/load-balancer-landing-page.spec.ts deleted file mode 100644 index 0d09b58f7d9..00000000000 --- a/packages/manager/cypress/e2e/core/loadBalancers/load-balancer-landing-page.spec.ts +++ /dev/null @@ -1,280 +0,0 @@ -/** - * @file Integration tests for Akamai Cloud Load Balancer navigation. - */ - -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; -import { loadbalancerFactory, configurationFactory } from '@src/factories'; -import { ui } from 'support/ui'; -import { randomLabel } from 'support/util/random'; -import { - mockGetLoadBalancer, - mockGetLoadBalancers, - mockDeleteLoadBalancerError, - mockDeleteLoadBalancer, -} from 'support/intercepts/load-balancers'; -import type { Loadbalancer } from '@linode/api-v4'; -import { chooseRegion } from 'support/util/regions'; - -/** - * Navigates to the ACLB landing page using breadcrumb navigation. - * - * Asserts that the URL has updated to reflect navigation. - */ -const returnToLandingPage = () => { - ui.entityHeader.find().within(() => { - cy.findByText('Cloud Load Balancers').should('be.visible').click(); - }); - - cy.url().should('endWith', '/loadbalancers'); -}; - -describe('Akamai Cloud Load Balancer landing page', () => { - /* - * - Confirms that load balancers are listed on the ACLB landing page. - * - Confirms that clicking a load balancer label directs to its details pages. - * - Confirms that Create Loadbalancer button is present and enabled. - * - Confirms that load balancer action menu items are present. - */ - it('Load Balancers landing page lists each load balancer', () => { - const chosenRegion = chooseRegion(); - const loadBalancerConfiguration = configurationFactory.build(); - const loadbalancerMocks = [ - loadbalancerFactory.build({ - id: 1, - label: randomLabel(), - configurations: [ - { - id: loadBalancerConfiguration.id, - label: loadBalancerConfiguration.label, - }, - ], - regions: ['us-east', chosenRegion.id], - }), - ]; - - // TODO Delete feature flag mocks when ACLB feature flag goes away. - mockAppendFeatureFlags({ - aclb: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - mockGetLoadBalancers(loadbalancerMocks).as('getLoadBalancers'); - mockGetLoadBalancer(loadbalancerMocks[0]); - - cy.visitWithLogin('/loadbalancers'); - cy.wait(['@getFeatureFlags', '@getClientStream', '@getLoadBalancers']); - - loadbalancerMocks.forEach((loadbalancerMock: Loadbalancer) => { - // Confirm label is shown, and clicking navigates to details page. - cy.findByText(loadbalancerMock.label) - .should('be.visible') - .closest('tr') - .within(() => { - // TODO: ACLB - Confirm that regions from the API are listed for load balancer - // loadbalancerMock.regions.forEach((loadbalancerRegion: string) => { - // const regionLabel = getRegionById(loadbalancerRegion).label; - // cy.findByText(regionLabel, { exact: false }).should('be.visible'); - // cy.findByText(loadbalancerRegion, { exact: false }).should( - // 'be.visible' - // ); - // }); - - cy.findByText(loadbalancerMock.hostname).should('be.visible'); - - // Confirm that clicking label navigates to details page. - cy.findByText(loadbalancerMock.label).should('be.visible').click(); - }); - - cy.url().should('endWith', `/loadbalancers/${loadbalancerMock.id}`); - returnToLandingPage(); - - cy.findByText(loadbalancerMock.label) - .should('be.visible') - .closest('tr') - .within(() => { - // TODO: ACLB - Confirm that regions from the API are listed for load balancer - // loadbalancerMock.regions.forEach((loadbalancerRegion: string) => { - // const regionLabel = getRegionById(loadbalancerRegion).label; - // cy.findByText(regionLabel, { exact: false }).should('be.visible'); - // cy.findByText(loadbalancerRegion, { exact: false }).should( - // 'be.visible' - // ); - // }); - - ui.actionMenu - .findByTitle( - `Action menu for Load Balancer ${loadbalancerMock.label}` - ) - .should('be.visible') - .click(); - }); - - // TODO Assert that clicking on the action menu items navigates to the expected page. - ['Configurations', 'Settings', 'Delete'].forEach( - (actionMenuItem: string) => { - ui.actionMenuItem.findByTitle(actionMenuItem).should('be.visible'); - } - ); - - // TODO Assert that clicking button navigates to '/loadbalancers/create'. - // First we need to dismiss the action menu that's opened above. - ui.button - .findByTitle('Create Load Balancer') - .should('be.visible') - .should('be.enabled'); - }); - }); -}); - -describe('Delete', () => { - /* - * - Confirms that Deleting a load balancer from the ACLB landing page. - * - Confirms ACLB landing page reverts to its empty state when all of the load balancers have been deleted. - */ - it('Delete a Load Balancer from landing page.', () => { - const chosenRegion = chooseRegion(); - const loadBalancerConfiguration = configurationFactory.build(); - const loadbalancerMocks = [ - loadbalancerFactory.build({ - id: 1, - label: randomLabel(), - configurations: [ - { - id: loadBalancerConfiguration.id, - label: loadBalancerConfiguration.label, - }, - ], - regions: ['us-east', chosenRegion.id], - }), - ]; - - // TODO Delete feature flag mocks when ACLB feature flag goes away. - mockAppendFeatureFlags({ - aclb: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - mockGetLoadBalancers(loadbalancerMocks).as('getLoadBalancers'); - mockGetLoadBalancer(loadbalancerMocks[0]); - - const loadbalancer = loadbalancerMocks[0]; - - cy.visitWithLogin('/loadbalancers'); - cy.wait(['@getFeatureFlags', '@getClientStream', '@getLoadBalancers']); - - ui.actionMenu - .findByTitle(`Action menu for Load Balancer ${loadbalancer.label}`) - .should('be.visible') - .click(); - - ui.actionMenuItem.findByTitle('Delete').should('be.visible').click(); - - // Mock the API call for deleting the load balancer. - mockDeleteLoadBalancer(loadbalancer.id).as('deleteLoadBalancer'); - - mockGetLoadBalancers([]).as('getLoadBalancers'); - - // Handle the delete confirmation dialog. - ui.dialog - .findByTitle(`Delete ${loadbalancer.label}?`) - .should('be.visible') - .within(() => { - cy.findByLabelText('Load Balancer Label') - .should('be.visible') - .click() - .type(loadbalancer.label); - - ui.buttonGroup - .findButtonByTitle('Delete') - .should('be.visible') - .should('be.enabled') - .click(); - }); - - cy.wait(['@deleteLoadBalancer', '@getLoadBalancers']); - - // Confirm that user is navigated to the empty loadbalancer empty state landing page. - - cy.get('[data-qa-header]') - .should('be.visible') - .should('have.text', 'Cloud Load Balancers'); - - cy.findByText( - 'Scalable Layer 4 and Layer 7 load balancer to route and manage enterprise traffic between clients and your distributed applications and networks globally.' - ).should('be.visible'); - cy.findByText('Getting Started Guides').should('be.visible'); - - // Create button exists and navigates user to create page. - ui.button - .findByTitle('Create Cloud Load Balancer') - .should('be.visible') - .should('be.enabled'); - - cy.findByText(loadbalancer.label).should('not.exist'); - }); - - it('Shows API errors when deleting a load balancer', () => { - const chosenRegion = chooseRegion(); - const loadBalancerConfiguration = configurationFactory.build(); - const loadbalancerMocks = [ - loadbalancerFactory.build({ - id: 1, - label: randomLabel(), - configurations: [ - { - id: loadBalancerConfiguration.id, - label: loadBalancerConfiguration.label, - }, - ], - regions: ['us-east', chosenRegion.id], - }), - ]; - - // TODO Delete feature flag mocks when ACLB feature flag goes away. - mockAppendFeatureFlags({ - aclb: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - mockGetLoadBalancers(loadbalancerMocks).as('getLoadBalancers'); - mockGetLoadBalancer(loadbalancerMocks[0]); - - const loadbalancer = loadbalancerMocks[0]; - - cy.visitWithLogin('/loadbalancers'); - cy.wait(['@getFeatureFlags', '@getClientStream', '@getLoadBalancers']); - - ui.actionMenu - .findByTitle(`Action menu for Load Balancer ${loadbalancer.label}`) - .should('be.visible') - .click(); - - ui.actionMenuItem.findByTitle('Delete').should('be.visible').click(); - // Mock the API call for deleting the load balancer. - mockDeleteLoadBalancerError(loadbalancer.id, 'Control Plane Error').as( - 'deleteLoadBalancer' - ); - - mockGetLoadBalancers([]).as('getLoadBalancers'); - - // Handle the delete confirmation dialog. - ui.dialog - .findByTitle(`Delete ${loadbalancer.label}?`) - .should('be.visible') - .within(() => { - cy.findByLabelText('Load Balancer Label') - .should('be.visible') - .click() - .type(loadbalancer.label); - - ui.buttonGroup.findButtonByTitle('Delete').click(); - - cy.wait(['@deleteLoadBalancer']); - - cy.findByText('Control Plane Error').should('be.visible'); - - ui.buttonGroup.findButtonByTitle('Cancel').click(); - }); - }); -}); diff --git a/packages/manager/cypress/e2e/core/loadBalancers/load-balancer-navigation.spec.ts b/packages/manager/cypress/e2e/core/loadBalancers/load-balancer-navigation.spec.ts deleted file mode 100644 index a54950ab214..00000000000 --- a/packages/manager/cypress/e2e/core/loadBalancers/load-balancer-navigation.spec.ts +++ /dev/null @@ -1,49 +0,0 @@ -/** - * @file Integration tests for Akamai Cloud Load Balancer navigation. - */ - -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; -import { ui } from 'support/ui'; - -describe('Akamai Cloud Load Balancer navigation', () => { - /* - * - Confirms that ACLB sidebar nav item is present when feature is enabled. - * - Confirms that clicking on ACLB nav item directs users to ACLB landing page. - */ - it('can navigate to load balancer landing page', () => { - // TODO Delete feature flag mocks when ACLB feature flag goes away. - mockAppendFeatureFlags({ - aclb: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - - cy.visitWithLogin('/linodes'); - cy.wait(['@getFeatureFlags', '@getClientStream']); - - ui.nav.findItemByTitle('Cloud Load Balancers').should('be.visible').click(); - - cy.url().should('endWith', '/loadbalancers'); - }); - - /* - * - Confirms that ACLB sidebar nav item is not shown when feature is not enabled. - */ - it('does not show load balancer navigation item when feature is disabled', () => { - // TODO Delete this test when ACLB feature flag goes away. - mockAppendFeatureFlags({ - aclb: makeFeatureFlagData(false), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - - cy.visitWithLogin('/linodes'); - cy.wait(['@getFeatureFlags', '@getClientStream']); - - ui.nav.find().within(() => { - cy.findByText('Cloud Load Balancers').should('not.exist'); - }); - }); -}); diff --git a/packages/manager/cypress/e2e/core/loadBalancers/load-balancer-routes.spec.ts b/packages/manager/cypress/e2e/core/loadBalancers/load-balancer-routes.spec.ts deleted file mode 100644 index 35336e7b045..00000000000 --- a/packages/manager/cypress/e2e/core/loadBalancers/load-balancer-routes.spec.ts +++ /dev/null @@ -1,632 +0,0 @@ -/** - * @file Integration tests for Akamai Cloud Load Balancer routes page. - */ - -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; -import { - loadbalancerFactory, - routeFactory, - serviceTargetFactory, -} from '@src/factories'; -import { ui } from 'support/ui'; -import { - mockGetLoadBalancer, - mockGetLoadBalancerRoutes, - mockGetLoadBalancerServiceTargets, - mockUpdateRoute, - mockUpdateRouteError, -} from 'support/intercepts/load-balancers'; - -describe('Akamai Cloud Load Balancer routes page', () => { - describe('create route', () => { - it('can create a route', () => { - const loadbalancer = loadbalancerFactory.build(); - const routes = routeFactory.buildList(1, { protocol: 'http' }); - - mockAppendFeatureFlags({ - aclb: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - mockGetLoadBalancer(loadbalancer).as('getLoadBalancer'); - mockGetLoadBalancerRoutes(loadbalancer.id, routes).as('getRoutes'); - - cy.visitWithLogin(`/loadbalancers/${loadbalancer.id}/routes`); - cy.wait([ - '@getFeatureFlags', - '@getClientStream', - '@getLoadBalancer', - '@getRoutes', - ]); - - ui.button - .findByTitle('Create Route') - .should('be.visible') - .should('be.enabled') - .click(); - - mockUpdateRoute(loadbalancer, routes[0]).as('updateRoute'); - - ui.drawer - .findByTitle('Create Route') - .should('be.visible') - .within(() => { - cy.findByLabelText('Route Label') - .should('be.visible') - .click() - .type(routes[0].label); - - cy.get('[data-qa-radio="HTTP"]').find('input').should('be.checked'); - - ui.buttonGroup - .findButtonByTitle('Create Route') - .should('be.visible') - .should('be.enabled') - .click(); - }); - }); - it('surfaces API errors in the Create Route Drawer', () => { - const loadbalancer = loadbalancerFactory.build(); - const routes = routeFactory.buildList(1, { protocol: 'http' }); - - mockAppendFeatureFlags({ - aclb: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - mockGetLoadBalancer(loadbalancer).as('getLoadBalancer'); - mockGetLoadBalancerRoutes(loadbalancer.id, routes).as('getRoutes'); - - cy.visitWithLogin(`/loadbalancers/${loadbalancer.id}/routes`); - cy.wait([ - '@getFeatureFlags', - '@getClientStream', - '@getLoadBalancer', - '@getRoutes', - ]); - - ui.button - .findByTitle('Create Route') - .should('be.visible') - .should('be.enabled') - .click(); - - mockUpdateRoute(loadbalancer, routes[0]).as('updateRoute'); - - ui.drawer - .findByTitle('Create Route') - .should('be.visible') - .within(() => { - ui.buttonGroup - .findButtonByTitle('Create Route') - .should('be.visible') - .should('be.enabled') - .click(); - }); - - cy.findByText('Label is required.', { exact: false }); - }); - }); - - describe('edit route', () => { - it('can edit a route label and protocol', () => { - const loadbalancer = loadbalancerFactory.build(); - const routes = routeFactory.buildList(1, { - protocol: 'http', - }); - - mockAppendFeatureFlags({ - aclb: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - mockGetLoadBalancer(loadbalancer).as('getLoadBalancer'); - mockGetLoadBalancerRoutes(loadbalancer.id, routes).as('getRoutes'); - - cy.visitWithLogin(`/loadbalancers/${loadbalancer.id}/routes`); - cy.wait([ - '@getFeatureFlags', - '@getClientStream', - '@getLoadBalancer', - '@getRoutes', - ]); - - ui.actionMenu - .findByTitle(`Action Menu for Route ${routes[0].label}`) - .click(); - - ui.actionMenuItem.findByTitle('Edit').click(); - - mockUpdateRoute(loadbalancer, routes[0]).as('updateRoute'); - - ui.drawer - .findByTitle(`Edit Route ${routes[0].label}`) - .should('be.visible') - .within(() => { - cy.findByLabelText('Route Label') - .should('have.value', routes[0].label) - .clear() - .type('new-label'); - - cy.get('[data-qa-radio="HTTP"]').find('input').should('be.checked'); - cy.get('[data-qa-radio="TCP"]').click(); - - ui.button - .findByTitle('Save') - .should('be.visible') - .should('be.enabled') - .click(); - }); - - cy.wait('@updateRoute'); - - cy.findByText('new-label').should('be.visible'); - }); - }); - - describe('create rule', () => { - it('can add a HTTP rule', () => { - const loadbalancer = loadbalancerFactory.build(); - const routes = routeFactory.buildList(1, { protocol: 'http' }); - const serviceTargets = serviceTargetFactory.buildList(3); - - mockAppendFeatureFlags({ - aclb: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - mockGetLoadBalancer(loadbalancer).as('getLoadBalancer'); - mockGetLoadBalancerRoutes(loadbalancer.id, routes).as('getRoutes'); - mockGetLoadBalancerServiceTargets(loadbalancer.id, serviceTargets).as( - 'getServiceTargets' - ); - - cy.visitWithLogin(`/loadbalancers/${loadbalancer.id}/routes`); - cy.wait([ - '@getFeatureFlags', - '@getClientStream', - '@getLoadBalancer', - '@getRoutes', - ]); - - ui.button - .findByTitle('Add Rule') - .should('be.visible') - .should('be.enabled') - .click(); - - mockUpdateRoute(loadbalancer, routes[0]).as('updateRoute'); - - ui.drawer - .findByTitle('Add Rule') - .should('be.visible') - .within(() => { - cy.findByLabelText('Match Type') - .should('be.visible') - .click() - .type('Header'); - - ui.autocompletePopper - .findByTitle('HTTP Header') - .should('be.visible') - .click(); - - cy.findByLabelText('Match Value') - .should('be.visible') - .type('x-header=value'); - - cy.findByLabelText('Percent') - .should('be.visible') - .click() - .clear() - .type('50'); - - cy.findByLabelText('Service Target') - .should('be.visible') - .click() - .type(serviceTargets[0].label); - - cy.wait('@getServiceTargets'); - - ui.autocompletePopper - .findByTitle(serviceTargets[0].label, { exact: false }) - .should('be.visible') - .click(); - - cy.findByText('Add Service Target').click(); - - cy.findByDisplayValue('100') - .should('be.visible') - .click() - .clear() - .type('50'); - - cy.findAllByLabelText('Service Target') - .last() - .should('be.visible') - .click() - .type(serviceTargets[1].label); - - cy.wait('@getServiceTargets'); - - ui.autocompletePopper - .findByTitle(serviceTargets[1].label, { exact: false }) - .should('be.visible') - .click(); - - cy.findByLabelText('Use Session Stickiness').check(); - - cy.findByLabelText('Cookie type').should('be.visible').click(); - - ui.autocompletePopper - .findByTitle('Origin') - .should('be.visible') - .click(); - - cy.findByLabelText('Cookie Key') - .should('be.visible') - .click() - .clear() - .type('my-cookie-for-sticky'); - - ui.buttonGroup - .findButtonByTitle('Add Rule') - .should('be.visible') - .should('be.enabled') - .click(); - }); - - cy.wait('@updateRoute'); - }); - it('can add a TCP rule', () => { - const loadbalancer = loadbalancerFactory.build(); - const routes = routeFactory.buildList(1, { protocol: 'tcp' }); - const serviceTargets = serviceTargetFactory.buildList(3); - - mockAppendFeatureFlags({ - aclb: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - mockGetLoadBalancer(loadbalancer).as('getLoadBalancer'); - mockGetLoadBalancerRoutes(loadbalancer.id, routes).as('getRoutes'); - mockGetLoadBalancerServiceTargets(loadbalancer.id, serviceTargets).as( - 'getServiceTargets' - ); - - cy.visitWithLogin(`/loadbalancers/${loadbalancer.id}/routes`); - cy.wait([ - '@getFeatureFlags', - '@getClientStream', - '@getLoadBalancer', - '@getRoutes', - ]); - - ui.button - .findByTitle('Add Rule') - .should('be.visible') - .should('be.enabled') - .click(); - - mockUpdateRoute(loadbalancer, routes[0]).as('updateRoute'); - - ui.drawer - .findByTitle('Add Rule') - .should('be.visible') - .within(() => { - cy.findByLabelText('Percent') - .should('be.visible') - .click() - .clear() - .type('50'); - - cy.findByLabelText('Service Target') - .should('be.visible') - .click() - .type(serviceTargets[0].label); - - cy.wait('@getServiceTargets'); - - ui.autocompletePopper - .findByTitle(serviceTargets[0].label, { exact: false }) - .should('be.visible') - .click(); - - cy.findByText('Add Service Target').click(); - - cy.findByDisplayValue('100') - .should('be.visible') - .click() - .clear() - .type('50'); - - cy.findAllByLabelText('Service Target') - .last() - .should('be.visible') - .click() - .type(serviceTargets[1].label); - - cy.wait('@getServiceTargets'); - - ui.autocompletePopper - .findByTitle(serviceTargets[1].label, { exact: false }) - .should('be.visible') - .click(); - - ui.buttonGroup - .findButtonByTitle('Add Rule') - .should('be.visible') - .should('be.enabled') - .click(); - }); - - cy.wait('@updateRoute'); - }); - it('surfaces API errors in the Add Rule Drawer for an HTTP route', () => { - const loadbalancer = loadbalancerFactory.build(); - const routes = routeFactory.buildList(1, { protocol: 'http' }); - const serviceTargets = serviceTargetFactory.buildList(3); - - mockAppendFeatureFlags({ - aclb: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - mockGetLoadBalancer(loadbalancer).as('getLoadBalancer'); - mockGetLoadBalancerRoutes(loadbalancer.id, routes).as('getRoutes'); - mockGetLoadBalancerServiceTargets(loadbalancer.id, serviceTargets).as( - 'getServiceTargets' - ); - - cy.visitWithLogin(`/loadbalancers/${loadbalancer.id}/routes`); - cy.wait([ - '@getFeatureFlags', - '@getClientStream', - '@getLoadBalancer', - '@getRoutes', - ]); - - ui.button - .findByTitle('Add Rule') - .should('be.visible') - .should('be.enabled') - .click(); - - mockUpdateRouteError(loadbalancer, routes[0]).as('updateRoute'); - - ui.drawer - .findByTitle('Add Rule') - .should('be.visible') - .within(() => { - cy.findByLabelText('Match Value') - .should('be.visible') - .type('x-header=value'); - - cy.findByLabelText('Service Target') - .should('be.visible') - .click() - .type(serviceTargets[0].label); - - cy.wait('@getServiceTargets'); - - ui.autocompletePopper - .findByTitle(serviceTargets[0].label, { exact: false }) - .should('be.visible') - .click(); - - ui.buttonGroup - .findButtonByTitle('Add Rule') - .should('be.visible') - .should('be.enabled') - .click(); - }); - - cy.wait('@updateRoute'); - - cy.findByText('Bad Match Value'); - cy.findByText('Bad Match Type'); - cy.findByText('Service Target does not exist'); - cy.findByText('Invalid percentage'); - cy.findByText('Invalid TTL', { exact: false }); - cy.findByText('Invalid Cookie', { exact: false }); - cy.findByText('A backend service is down', { exact: false }); - cy.findByText('You reached a rate limit', { exact: false }); - cy.findByText('Hostname is not valid'); - - cy.findByLabelText('Use Session Stickiness').check(); - - cy.findByText('Invalid TTL', { exact: true }); - cy.findByText('Invalid Cookie', { exact: true }); - }); - it('surfaces API errors in the Add Rule Drawer for a TCP route', () => { - const loadbalancer = loadbalancerFactory.build(); - const routes = routeFactory.buildList(1, { protocol: 'tcp' }); - const serviceTargets = serviceTargetFactory.buildList(3); - - mockAppendFeatureFlags({ - aclb: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - mockGetLoadBalancer(loadbalancer).as('getLoadBalancer'); - mockGetLoadBalancerRoutes(loadbalancer.id, routes).as('getRoutes'); - mockGetLoadBalancerServiceTargets(loadbalancer.id, serviceTargets).as( - 'getServiceTargets' - ); - - cy.visitWithLogin(`/loadbalancers/${loadbalancer.id}/routes`); - cy.wait([ - '@getFeatureFlags', - '@getClientStream', - '@getLoadBalancer', - '@getRoutes', - ]); - - ui.button - .findByTitle('Add Rule') - .should('be.visible') - .should('be.enabled') - .click(); - - mockUpdateRouteError(loadbalancer, routes[0]).as('updateRoute'); - - ui.drawer - .findByTitle('Add Rule') - .should('be.visible') - .within(() => { - cy.findByLabelText('Service Target') - .should('be.visible') - .click() - .type(serviceTargets[0].label); - - cy.wait('@getServiceTargets'); - - ui.autocompletePopper - .findByTitle(serviceTargets[0].label, { exact: false }) - .should('be.visible') - .click(); - - ui.buttonGroup - .findButtonByTitle('Add Rule') - .should('be.visible') - .should('be.enabled') - .click(); - }); - - cy.wait('@updateRoute'); - - cy.findByText('Bad Match Value', { exact: false }); - cy.findByText('Bad Match Type', { exact: false }); - cy.findByText('Service Target does not exist'); - cy.findByText('Invalid percentage'); - cy.findByText('Invalid TTL', { exact: false }); - cy.findByText('Invalid Cookie', { exact: false }); - cy.findByText('A backend service is down', { exact: false }); - cy.findByText('You reached a rate limit', { exact: false }); - }); - }); - - describe('edit rule', () => { - it('can edit a HTTP rule', () => { - const loadbalancer = loadbalancerFactory.build(); - const routes = routeFactory.buildList(1, { protocol: 'http' }); - const serviceTargets = serviceTargetFactory.buildList(3); - - mockAppendFeatureFlags({ - aclb: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - mockGetLoadBalancer(loadbalancer).as('getLoadBalancer'); - mockGetLoadBalancerRoutes(loadbalancer.id, routes).as('getRoutes'); - mockGetLoadBalancerServiceTargets(loadbalancer.id, serviceTargets).as( - 'getServiceTargets' - ); - - cy.visitWithLogin(`/loadbalancers/${loadbalancer.id}/routes`); - cy.wait([ - '@getFeatureFlags', - '@getClientStream', - '@getLoadBalancer', - '@getRoutes', - ]); - - cy.findByLabelText(`expand route-${routes[0].id} row`).click(); - - ui.actionMenu.findByTitle('Action Menu for Rule 0').click(); - - ui.actionMenuItem.findByTitle('Edit').click(); - - mockUpdateRoute(loadbalancer, routes[0]).as('updateRoute'); - - ui.drawer - .findByTitle('Edit Rule') - .should('be.visible') - .within(() => { - cy.findByLabelText('Hostname Match (optional)') - .should('have.value', routes[0].rules[0].match_condition!.hostname) - .clear() - .type('example.com'); - - cy.findByLabelText('Match Type') - .should('be.visible') - .click() - .clear() - .type('Header'); - - ui.autocompletePopper - .findByTitle('HTTP Header') - .should('be.visible') - .click(); - - cy.findByLabelText('Match Value') - .should( - 'have.value', - routes[0].rules[0].match_condition!.match_value - ) - .clear() - .type('x-header=my-header-value'); - - ui.buttonGroup - .findButtonByTitle('Save') - .should('be.visible') - .should('be.enabled') - .click(); - }); - - cy.wait('@updateRoute'); - - // Verify the table updates after the drawer saves and closes - cy.findByLabelText('Rule 0').within(() => { - cy.findByText('x-header=my-header-value'); - cy.findByText('HTTP Header'); - }); - }); - }); - describe('delete rule', () => { - it('can delete a rule', () => { - const loadbalancer = loadbalancerFactory.build(); - const routes = routeFactory.buildList(1, { protocol: 'http' }); - - mockAppendFeatureFlags({ - aclb: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - mockGetLoadBalancer(loadbalancer).as('getLoadBalancer'); - mockGetLoadBalancerRoutes(loadbalancer.id, routes).as('getRoutes'); - - cy.visitWithLogin(`/loadbalancers/${loadbalancer.id}/routes`); - cy.wait([ - '@getFeatureFlags', - '@getClientStream', - '@getLoadBalancer', - '@getRoutes', - ]); - - // Expand the route table - cy.findByLabelText(`expand route-${routes[0].id} row`).click(); - - // Verify all rules are shown - for (const rule of routes[0].rules) { - cy.findByText(rule.match_condition!.match_value).should('be.visible'); - } - - const indexOfRuleToDelete = 1; - - ui.actionMenu - .findByTitle(`Action Menu for Rule ${indexOfRuleToDelete}`) - .click(); - - ui.actionMenuItem.findByTitle('Delete').click(); - - mockUpdateRoute(loadbalancer, routes[0]).as('updateRoute'); - - ui.dialog.findByTitle('Delete Rule?').within(() => { - ui.button.findByTitle('Delete').should('be.visible').click(); - }); - - cy.wait('@updateRoute'); - - // Verify the deleted rule no longer shows - cy.findByText( - routes[0].rules[indexOfRuleToDelete].match_condition!.match_value - ).should('not.exist'); - }); - }); -}); 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 deleted file mode 100644 index d8df5927f38..00000000000 --- a/packages/manager/cypress/e2e/core/loadBalancers/load-balancer-service-targets.spec.ts +++ /dev/null @@ -1,400 +0,0 @@ -/** - * @file Integration tests related to Cloud Manager ACLB Service Target management. - */ - -import { - linodeFactory, - loadbalancerFactory, - serviceTargetFactory, - certificateFactory, -} from '@src/factories'; -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; -import { - mockGetLoadBalancer, - mockGetLoadBalancerCertificates, - mockGetServiceTargets, - mockCreateServiceTarget, - mockUpdateServiceTarget, -} from 'support/intercepts/load-balancers'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; -import type { Linode, ServiceTarget } from '@linode/api-v4'; -import { randomLabel, randomIp } from 'support/util/random'; -import { ui } from 'support/ui'; -import { chooseRegion } from 'support/util/regions'; -import { mockGetLinodes } from 'support/intercepts/linodes'; - -describe('Akamai Cloud Load Balancer service targets', () => { - // TODO Remove this `beforeEach()` hook and related `cy.wait()` calls when `aclb` feature flag goes away. - beforeEach(() => { - mockAppendFeatureFlags({ - aclb: 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(); - - cy.findByLabelText('Health Check Host Header') - .scrollIntoView() - .should('be.visible') - .type('example.com'); - - 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'); - }); - - /** - * - Confirms that clicking a service target's Edit action opens "Edit Service Target" drawer. - * - Confirms that "Edit Service Target" drawer can be cancelled. - * - Confirms that form fields are pre-populated with service target data. - * - [TODO] Confirms that service target list updates to reflect updated service target. - */ - it('can edit 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({ - certificate_id: 0, - load_balancing_policy: 'random', - }); - const mockCertificate = certificateFactory.build({ - id: 0, - label: 'my-certificate', - }); - const mockNewCertificate = certificateFactory.build({ - label: 'my-new-certificate', - }); - - mockGetLinodes(mockLinodes); - mockGetLoadBalancer(mockLoadBalancer).as('getLoadBalancer'); - mockGetServiceTargets(mockLoadBalancer, [mockServiceTarget]).as( - 'getServiceTargets' - ); - mockGetLoadBalancerCertificates(mockLoadBalancer.id, [ - mockCertificate, - mockNewCertificate, - ]); - - cy.visitWithLogin(`/loadbalancers/${mockLoadBalancer.id}/service-targets`); - cy.wait([ - '@getFeatureFlags', - '@getClientStream', - '@getLoadBalancer', - '@getServiceTargets', - ]); - - // Open the "Edit Service Target" drawer via the action menu item. - ui.actionMenu - .findByTitle(`Action Menu for service target ${mockServiceTarget.label}`) - .click(); - ui.actionMenuItem.findByTitle('Edit').click(); - - // Confirm that drawer can be closed. - ui.drawer - .findByTitle(`Edit ${mockServiceTarget.label}`) - .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 drawer and modify service target, then click "Save Service Target". - ui.actionMenu - .findByTitle(`Action Menu for service target ${mockServiceTarget.label}`) - .click(); - ui.actionMenuItem.findByTitle('Edit').click(); - - mockUpdateServiceTarget(mockLoadBalancer, mockServiceTarget).as( - 'updateServiceTarget' - ); - - ui.drawer - .findByTitle(`Edit ${mockServiceTarget.label}`) - .should('be.visible') - .within(() => { - cy.findByLabelText('Service Target Label') - .should('have.value', mockServiceTarget.label) - .clear() - .type('my-updated-service-target-label'); - - // Confirm the load balancing policy selection matches that of the service target, then change the policy. - cy.findByLabelText('Algorithm') - .should('have.value', 'Random') - .clear() - .type('Ring Hash'); - - ui.autocompletePopper - .findByTitle('Ring Hash') - .should('be.visible') - .click(); - - // Confirm the endpoint is populated in the table. - cy.findByText( - `${mockServiceTarget.endpoints[0].ip}:${mockServiceTarget.endpoints[0].port}`, - { exact: false } - ) - .scrollIntoView() - .should('be.visible'); - - // Confirm the endpoint can be deleted. - cy.findByLabelText( - `Remove Endpoint ${mockServiceTarget.endpoints[0].ip}:${mockServiceTarget.endpoints[0].port}`, - { exact: false } - ).click(); - - // Confirm endpoint empty state text is visible without any endpoints. - cy.findByText('No endpoints to display.') - .scrollIntoView() - .should('be.visible'); - - // Select the certificate mocked for this load balancer. - cy.findByLabelText('Certificate') - .should('have.value', mockCertificate.label) - .clear() - .type('my-new-certificate'); - - ui.autocompletePopper - .findByTitle('my-new-certificate') - .should('be.visible') - .click(); - - // Confirm that health check options match service target data. - cy.findByLabelText('Interval').should( - 'have.value', - mockServiceTarget.healthcheck.interval - ); - - cy.findByLabelText('Timeout').should( - 'have.value', - mockServiceTarget.healthcheck.timeout - ); - - cy.findByLabelText('Healthy Threshold').should( - 'have.value', - mockServiceTarget.healthcheck.healthy_threshold - ); - - cy.findByLabelText('Unhealthy Threshold').should( - 'have.value', - mockServiceTarget.healthcheck.unhealthy_threshold - ); - - //Confirm that health check path and host match service target data. - cy.findByLabelText('Health Check Path', { exact: false }).should( - 'have.value', - mockServiceTarget.healthcheck.path - ); - - cy.findByLabelText('Health Check Host Header', { exact: false }).should( - 'have.value', - mockServiceTarget.healthcheck.host - ); - - // Confirm the primary action button exists and is enabled. - ui.button - .findByTitle('Save Service Target') - .scrollIntoView() - .should('be.visible') - .should('be.enabled') - .click(); - }); - cy.wait('@updateServiceTarget'); - - // 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/e2e/core/loadBalancers/load-balancer-summary.spec.ts b/packages/manager/cypress/e2e/core/loadBalancers/load-balancer-summary.spec.ts deleted file mode 100644 index 4eb6a35be71..00000000000 --- a/packages/manager/cypress/e2e/core/loadBalancers/load-balancer-summary.spec.ts +++ /dev/null @@ -1,140 +0,0 @@ -/** - * @file Integration tests for Akamai Cloud Load Balancer summary page. - */ - -import { loadbalancerFactory, configurationFactory } from '@src/factories/aclb'; -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; - -import { - mockGetLoadBalancer, - mockGetLoadBalancers, - mockDeleteLoadBalancer, -} from 'support/intercepts/load-balancers'; -import { randomLabel } from 'support/util/random'; -import { ui } from 'support/ui'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; - -describe('Akamai Cloud Load Balancer details page', () => { - it('renders all tabs and basic loadbalancer info', () => { - const mockLoadBalancer = loadbalancerFactory.build(); - - mockAppendFeatureFlags({ - aclb: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - - mockGetFeatureFlagClientstream().as('getClientStream'); - mockGetLoadBalancer(mockLoadBalancer).as('getLoadBalancer'); - - cy.visitWithLogin(`/loadbalancers/${mockLoadBalancer.id}`); - cy.wait(['@getFeatureFlags', '@getClientStream', '@getLoadBalancer']); - - const tabs = [ - 'Summary', - 'Configurations', - 'Routes', - 'Service Targets', - 'Certificates', - 'Settings', - ]; - - for (const tab of tabs) { - cy.findByText(tab).should('be.visible'); - } - - cy.findByText(mockLoadBalancer.label).should('be.visible'); - cy.findByText(mockLoadBalancer.hostname).should('be.visible'); - }); -}); - -describe('Delete', () => { - /* - * Deleting a load balancer from the ACLB load balancer details page "Settings" tab (route: /loadbalancers/:id/settings) - * Confirms User is redirected to ACLB landing page upon deleting from Load Balancer details page "Settings" tab, and load balancer is not listed on the landing page. - */ - - // Test case for deleting a load balancer from the Settings tab. - it('Deletes a loadbalancer from Settings tab', () => { - const mockLoadBalancer = loadbalancerFactory.build(); - // Setup additional mock load balancer data. - const loadBalancerConfiguration = configurationFactory.build(); - const loadbalancerMocks = [ - loadbalancerFactory.build({ - id: 1, - label: randomLabel(), - configurations: [ - { - id: loadBalancerConfiguration.id, - label: loadBalancerConfiguration.label, - }, - ], - regions: ['us-east'], - }), - ]; - const loadbalancerMock = loadbalancerMocks[0]; - - mockGetLoadBalancers(loadbalancerMocks).as('getLoadBalancers'); - - mockAppendFeatureFlags({ - aclb: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - - mockGetFeatureFlagClientstream().as('getClientStream'); - mockGetLoadBalancer(mockLoadBalancer).as('getLoadBalancer'); - - // Visit the specific load balancer's page with login. - cy.visitWithLogin(`/loadbalancers/${mockLoadBalancer.id}`); - - // Wait for all the mock API calls to complete. - cy.wait(['@getFeatureFlags', '@getClientStream', '@getLoadBalancer']); - - // Navigate to the 'Settings' tab. - cy.findByText('Settings').should('be.visible').click(); - - cy.findByText(mockLoadBalancer.label).should('be.visible'); - - cy.findByText('Delete Load Balancer').should('be.visible'); - - // Mock the API call for deleting the load balancer. - mockDeleteLoadBalancer(mockLoadBalancer.id).as('deleteLoadBalancer'); - - ui.button.findByTitle('Delete').should('be.visible').click(); - - // Handle the delete confirmation dialog. - ui.dialog - .findByTitle(`Delete ${mockLoadBalancer.label}?`) - .should('be.visible') - .within(() => { - cy.findByTestId('textfield-input') - .should('be.visible') - .click() - .type(mockLoadBalancer.label); - - ui.buttonGroup - .findButtonByTitle('Delete') - .should('be.visible') - .should('be.enabled') - .click(); - }); - - // Wait for the delete operation and subsequent data retrieval to complete. - cy.wait(['@deleteLoadBalancer', '@getLoadBalancers']); - - // Confirm user is navigated to the load balancers landing page list. - cy.findByText(loadbalancerMock.label) - .should('be.visible') - .closest('tr') - .within(() => { - ui.actionMenu - .findByTitle( - `Action menu for Load Balancer ${loadbalancerMock.label}` - ) - .should('be.visible'); - }); - - // Verify that the deleted load balancer no longer exists in the list. - cy.findByText(mockLoadBalancer.label).should('not.exist'); - }); -}); diff --git a/packages/manager/cypress/e2e/core/longview/longview-plan.spec.ts b/packages/manager/cypress/e2e/core/longview/longview-plan.spec.ts new file mode 100644 index 00000000000..def2a6a8b67 --- /dev/null +++ b/packages/manager/cypress/e2e/core/longview/longview-plan.spec.ts @@ -0,0 +1,60 @@ +import type { ActiveLongviewPlan } from '@linode/api-v4'; +import { longviewActivePlanFactory } from 'src/factories'; +import { authenticate } from 'support/api/authentication'; +import { + mockGetLongviewPlan, + mockUpdateLongviewPlan, +} from 'support/intercepts/longview'; +import { ui } from 'support/ui'; +import { cleanUp } from 'support/util/cleanup'; + +authenticate(); +describe('longview plan', () => { + before(() => { + cleanUp(['linodes', 'longview-clients']); + }); + + /* + * - Tests Longview change plan end-to-end using mock API data. + * - Confirm UI flow when a user changes their Longview plan. + */ + it('can change longview plan', () => { + const newPlan: ActiveLongviewPlan = longviewActivePlanFactory.build(); + + mockGetLongviewPlan({}).as('getLongviewPlan'); + + cy.visitWithLogin('/longview'); + cy.wait('@getLongviewPlan'); + + // Confirms that Longview Plan Details tab is visible on the page. + cy.findByText('Plan Details').should('be.visible').click(); + + // Confirms that Longview current plan is visible and enabled by default. + cy.findByTestId('lv-sub-radio-longview-free').should('be.enabled'); + cy.findByTestId('current-plan-longview-free').should('be.visible'); + ui.button + .findByTitle('Change Plan') + .should('be.visible') + .should('be.disabled'); + + mockUpdateLongviewPlan(newPlan).as('updateLongviewPlan'); + + // Confirms that Longview plan can be changed. + cy.findByTestId('lv-sub-table-row-longview-3').click(); + ui.button + .findByTitle('Change Plan') + .should('be.visible') + .should('be.enabled') + .click(); + + // Confirms the Longview plan details shown correctly after plan changed + cy.wait('@updateLongviewPlan'); + cy.findByText('Plan updated successfully.').should('be.visible'); + cy.findByTestId('lv-sub-table-row-longview-3').should('be.enabled'); + cy.findByTestId('current-plan-longview-3').should('be.visible'); + ui.button + .findByTitle('Change Plan') + .should('be.visible') + .should('be.disabled'); + }); +}); diff --git a/packages/manager/cypress/e2e/core/managed/managed-ssh.spec.ts b/packages/manager/cypress/e2e/core/managed/managed-ssh.spec.ts index ef9df9ea00d..1dd368a3702 100644 --- a/packages/manager/cypress/e2e/core/managed/managed-ssh.spec.ts +++ b/packages/manager/cypress/e2e/core/managed/managed-ssh.spec.ts @@ -142,7 +142,7 @@ describe('Managed SSH Access tab', () => { .type(newUser); // Set IP address to 'Any'. - cy.get('label[for="ip-address"]') + cy.findByLabelText('IP Address') .should('be.visible') .click() .type('Any{enter}'); diff --git a/packages/manager/cypress/e2e/core/nodebalancers/smoke-create-nodebal.spec.ts b/packages/manager/cypress/e2e/core/nodebalancers/smoke-create-nodebal.spec.ts index 57db284984c..3155ecc1d53 100644 --- a/packages/manager/cypress/e2e/core/nodebalancers/smoke-create-nodebal.spec.ts +++ b/packages/manager/cypress/e2e/core/nodebalancers/smoke-create-nodebal.spec.ts @@ -32,7 +32,9 @@ const createNodeBalancerWithUI = ( .click() .clear() .type(nodeBal.label); - cy.contains('create a tag').click().type(entityTag); + cy.findByPlaceholderText(/create a tag/i) + .click() + .type(entityTag); if (isDcPricingTest) { const newRegion = getRegionById('br-gru'); diff --git a/packages/manager/cypress/e2e/core/notificationsAndEvents/events.spec.ts b/packages/manager/cypress/e2e/core/notificationsAndEvents/events.spec.ts index 0adc8a7f8c1..ee659c4cd4d 100644 --- a/packages/manager/cypress/e2e/core/notificationsAndEvents/events.spec.ts +++ b/packages/manager/cypress/e2e/core/notificationsAndEvents/events.spec.ts @@ -135,6 +135,8 @@ describe('verify notification types and icons', () => { }); } containsClick('View all events'); + // Clicking "View all events" navigates to Events page at /events + cy.url().should('endWith', '/events'); events.forEach((event) => { const text = [`${event.message}`, `${event.entity?.label}`]; const regex = new RegExp(`${text.join('|')}`, 'g'); diff --git a/packages/manager/cypress/e2e/core/objectStorage/access-key.e2e.spec.ts b/packages/manager/cypress/e2e/core/objectStorage/access-key.e2e.spec.ts index 3806fb0ebb0..aa7cf9ee38b 100644 --- a/packages/manager/cypress/e2e/core/objectStorage/access-key.e2e.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorage/access-key.e2e.spec.ts @@ -2,7 +2,7 @@ * @file End-to-end tests for Object Storage Access Key operations. */ -import { objectStorageBucketFactory } from 'src/factories/objectStorage'; +import { createObjectStorageBucketFactoryLegacy } from 'src/factories/objectStorage'; import { authenticate } from 'support/api/authentication'; import { createBucket } from '@linode/api-v4/lib/object-storage'; import { @@ -120,7 +120,7 @@ describe('object storage access key end-to-end tests', () => { it('can create an access key with limited access - e2e', () => { const bucketLabel = randomLabel(); const bucketCluster = 'us-east-1'; - const bucketRequest = objectStorageBucketFactory.build({ + const bucketRequest = createObjectStorageBucketFactoryLegacy.build({ label: bucketLabel, cluster: bucketCluster, // Default factory sets `cluster` and `region`, but API does not accept `region` yet. diff --git a/packages/manager/cypress/e2e/core/objectStorage/access-keys.smoke.spec.ts b/packages/manager/cypress/e2e/core/objectStorage/access-keys.smoke.spec.ts index f3972f56cbc..25ed857c5a6 100644 --- a/packages/manager/cypress/e2e/core/objectStorage/access-keys.smoke.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorage/access-keys.smoke.spec.ts @@ -28,7 +28,7 @@ import { ui } from 'support/ui'; import { accountFactory, regionFactory } from 'src/factories'; import { mockGetRegions } from 'support/intercepts/regions'; import { buildArray } from 'support/util/arrays'; -import { Scope } from '@linode/api-v4'; +import { ObjectStorageKeyBucketAccess } from '@linode/api-v4'; import { mockGetAccount } from 'support/intercepts/account'; describe('object storage access keys smoke tests', () => { @@ -304,7 +304,7 @@ describe('object storage access keys smoke tests', () => { ], limited: true, bucket_access: mockBuckets.map( - (bucket): Scope => ({ + (bucket): ObjectStorageKeyBucketAccess => ({ bucket_name: bucket.label, cluster: '', permissions: 'read_only', diff --git a/packages/manager/cypress/e2e/core/objectStorage/enable-object-storage.spec.ts b/packages/manager/cypress/e2e/core/objectStorage/enable-object-storage.spec.ts index 08e52e042a4..869fa8aaaf1 100644 --- a/packages/manager/cypress/e2e/core/objectStorage/enable-object-storage.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorage/enable-object-storage.spec.ts @@ -149,6 +149,10 @@ describe('Object Storage enrollment', () => { .findByTitle('Create Bucket') .should('be.visible') .within(() => { + cy.findByLabelText('Label (required)') + .should('be.visible') + .type(randomLabel()); + // Select a region with special pricing structure. ui.regionSelect.find().click().type('Jakarta, ID{enter}'); diff --git a/packages/manager/cypress/e2e/core/objectStorage/object-storage.e2e.spec.ts b/packages/manager/cypress/e2e/core/objectStorage/object-storage.e2e.spec.ts index 4d415a8fb19..e0a15a45e8e 100644 --- a/packages/manager/cypress/e2e/core/objectStorage/object-storage.e2e.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorage/object-storage.e2e.spec.ts @@ -4,7 +4,11 @@ import 'cypress-file-upload'; import { createBucket } from '@linode/api-v4/lib/object-storage'; -import { accountFactory, objectStorageBucketFactory } from 'src/factories'; +import { + accountFactory, + createObjectStorageBucketFactoryLegacy, + createObjectStorageBucketFactoryGen1, +} from 'src/factories'; import { authenticate } from 'support/api/authentication'; import { interceptGetNetworkUtilization, @@ -55,14 +59,20 @@ const getNonEmptyBucketMessage = (bucketLabel: string) => { * * @param label - Bucket label. * @param cluster - Bucket cluster. + * @param cors_enabled - Enable CORS on the bucket: defaults to true for Gen1 and false for Gen2. * * @returns Promise that resolves to created Bucket. */ -const setUpBucket = (label: string, cluster: string) => { +const setUpBucket = ( + label: string, + cluster: string, + cors_enabled: boolean = true +) => { return createBucket( - objectStorageBucketFactory.build({ + createObjectStorageBucketFactoryLegacy.build({ label, cluster, + cors_enabled, // API accepts either `cluster` or `region`, but not both. Our factory // populates both fields, so we have to manually set `region` to `undefined` @@ -80,14 +90,20 @@ const setUpBucket = (label: string, cluster: string) => { * * @param label - Bucket label. * @param regionId - ID of Bucket region. + * @param cors_enabled - Enable CORS on the bucket: defaults to true for Gen1 and false for Gen2. * * @returns Promise that resolves to created Bucket. */ -const setUpBucketMulticluster = (label: string, regionId: string) => { +const setUpBucketMulticluster = ( + label: string, + regionId: string, + cors_enabled: boolean = true +) => { return createBucket( - objectStorageBucketFactory.build({ + createObjectStorageBucketFactoryGen1.build({ label, region: regionId, + cors_enabled, // API accepts either `cluster` or `region`, but not both. Our factory // populates both fields, so we have to manually set `cluster` to `undefined` @@ -156,6 +172,8 @@ describe('object storage end-to-end tests', () => { * - Confirms that deleted buckets are no longer listed on landing page. */ it('can create and delete object storage buckets', () => { + cy.tag('purpose:syntheticTesting'); + const bucketLabel = randomLabel(); const bucketRegion = 'Atlanta, GA'; const bucketCluster = 'us-southeast-1'; diff --git a/packages/manager/cypress/e2e/core/objectStorage/object-storage.smoke.spec.ts b/packages/manager/cypress/e2e/core/objectStorage/object-storage.smoke.spec.ts index 505ba19b880..cabb5c8e2a5 100644 --- a/packages/manager/cypress/e2e/core/objectStorage/object-storage.smoke.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorage/object-storage.smoke.spec.ts @@ -82,12 +82,6 @@ describe('object storage smoke tests', () => { .findByTitle('Create Bucket') .should('be.visible') .within(() => { - // Submit button is disabled when fields are empty. - ui.buttonGroup - .findButtonByTitle('Create Bucket') - .should('be.visible') - .should('be.disabled'); - // Enter label. cy.contains('Label').click().type(mockBucket.label); @@ -169,6 +163,7 @@ describe('object storage smoke tests', () => { mockGetAccount(accountFactory.build({ capabilities: [] })); mockAppendFeatureFlags({ objMultiCluster: makeFeatureFlagData(false), + gecko2: makeFeatureFlagData(false), }).as('getFeatureFlags'); mockGetFeatureFlagClientstream().as('getClientStream'); diff --git a/packages/manager/cypress/e2e/core/oneClickApps/one-click-apps.spec.ts b/packages/manager/cypress/e2e/core/oneClickApps/one-click-apps.spec.ts index 3a08199557f..f7aa759ec34 100644 --- a/packages/manager/cypress/e2e/core/oneClickApps/one-click-apps.spec.ts +++ b/packages/manager/cypress/e2e/core/oneClickApps/one-click-apps.spec.ts @@ -49,14 +49,16 @@ describe('OneClick Apps (OCA)', () => { // Check the content of the OCA listing cy.findByTestId('one-click-apps-container').within(() => { // Check that all sections are present (note: New apps can be empty so not asserting its presence) - cy.findByTestId('Popular apps').should('exist'); - cy.findByTestId('All apps').should('exist'); + cy.findByText('Popular apps').should('be.visible'); + cy.findByText('All apps').should('be.visible'); trimmedApps.forEach((stackScript) => { const { decodedLabel, label } = handleAppLabel(stackScript); // Check that every OCA is listed with the correct label - cy.get(`[data-qa-select-card-heading="${label}"]`).should('exist'); + cy.get(`[data-qa-select-card-heading="${label.trim()}"]`).should( + 'exist' + ); // Check that every OCA has a drawer match // This validates the regex in `mapStackScriptLabelToOCA` @@ -76,7 +78,7 @@ describe('OneClick Apps (OCA)', () => { const candidateLabel = handleAppLabel(trimmedApps[0]).label; const stackScriptCandidate = cy - .get(`[data-qa-selection-card-info="${candidateLabel}"]`) + .get(`[data-qa-selection-card-info="${candidateLabel.trim()}"]`) .first(); stackScriptCandidate.should('exist').click(); @@ -92,7 +94,7 @@ describe('OneClick Apps (OCA)', () => { } ui.drawer - .findByTitle(trimmedApps[0].label) + .findByTitle(trimmedApps[0].label.trim()) .should('be.visible') .within(() => { containsVisible(app.description); @@ -113,9 +115,9 @@ describe('OneClick Apps (OCA)', () => { 'have.length.below', initialNumberOfApps ); - cy.get(`[data-qa-selection-card-info="${candidateLabel}"]`).should( - 'be.visible' - ); + cy.get( + `[data-qa-selection-card-info="${candidateLabel.trim()}"]` + ).should('be.visible'); }); }); }); @@ -168,6 +170,7 @@ describe('OneClick Apps (OCA)', () => { mockGetStackScripts([stackScripts]).as('getStackScripts'); mockAppendFeatureFlags({ + linodeCreateRefactor: makeFeatureFlagData(false), oneClickApps: makeFeatureFlagData({ 401709: 'E2E Test App', }), @@ -181,7 +184,7 @@ describe('OneClick Apps (OCA)', () => { cy.findByTestId('one-click-apps-container').within(() => { // Since it is mock data we can assert the New App section is present - cy.findByTestId('New apps').should('exist'); + cy.findByText('New apps').should('be.visible'); // Check that the app is listed and select it cy.get('[data-qa-selection-card="true"]').should('have.length', 3); diff --git a/packages/manager/cypress/e2e/core/placementGroups/create-linode-with-placement-groups.spec.ts b/packages/manager/cypress/e2e/core/placementGroups/create-linode-with-placement-groups.spec.ts index 554bf09e06d..78f38efda26 100644 --- a/packages/manager/cypress/e2e/core/placementGroups/create-linode-with-placement-groups.spec.ts +++ b/packages/manager/cypress/e2e/core/placementGroups/create-linode-with-placement-groups.spec.ts @@ -11,17 +11,21 @@ import { } from 'src/factories'; import { regionFactory } from 'src/factories'; import { ui } from 'support/ui/'; -import { mockCreateLinode } from 'support/intercepts/linodes'; +import { + mockCreateLinode, + mockGetLinodeDetails, +} from 'support/intercepts/linodes'; import { mockGetRegions } from 'support/intercepts/regions'; import { mockCreatePlacementGroup, mockGetPlacementGroups, } from 'support/intercepts/placement-groups'; -import { randomString } from 'support/util/random'; +import { randomNumber, randomString } from 'support/util/random'; import { CANNOT_CHANGE_PLACEMENT_GROUP_POLICY_MESSAGE } from 'src/features/PlacementGroups/constants'; import type { Region } from '@linode/api-v4'; import type { Flags } from 'src/featureFlags'; +import { linodeCreatePage } from 'support/ui/pages'; const mockAccount = accountFactory.build(); const mockRegions: Region[] = [ @@ -208,4 +212,76 @@ describe('Linode create flow with Placement Group', () => { ); }); }); + + /* + * - Confirms UI flow to create a Linode with an existing Placement Group using mock API data. + * - Confirms that Placement Group is reflected in create summary section. + * - Confirms that outgoing Linode Create API request specifies the selected Placement Group to be attached. + */ + it('can assign existing Placement Group during Linode Create flow', () => { + const mockPlacementGroup = placementGroupFactory.build({ + label: 'pg-1-us-east', + region: mockRegions[0].id, + placement_group_type: 'anti_affinity:local', + placement_group_policy: 'strict', + is_compliant: true, + }); + + const linodeLabel = 'linode-with-placement-group'; + const mockLinode = linodeFactory.build({ + id: randomNumber(), + label: linodeLabel, + region: mockRegions[0].id, + placement_group: { + id: mockPlacementGroup.id, + }, + }); + + mockGetPlacementGroups([mockPlacementGroup]).as('getPlacementGroups'); + mockCreateLinode(mockLinode).as('createLinode'); + mockGetLinodeDetails(mockLinode.id, mockLinode); + + cy.visitWithLogin('/linodes/create'); + + linodeCreatePage.selectRegionById(mockRegions[0].id); + cy.wait('@getPlacementGroups'); + linodeCreatePage.selectPlan('Shared CPU', 'Nanode 1 GB'); + linodeCreatePage.setRootPassword(randomString(32)); + linodeCreatePage.setLabel(mockLinode.label); + + // Confirm that mocked Placement Group is shown in the Autocomplete, and then select it. + cy.findByText('Placement Groups in Newark, NJ (us-east)') + .click() + .type(`${mockPlacementGroup.label}`); + ui.autocompletePopper + .findByTitle(mockPlacementGroup.label) + .should('be.visible') + .click(); + + // Confirm the Placement group assignment is accounted for in the summary. + cy.get('[data-qa-summary="true"]').within(() => { + cy.findByText('Assigned to Placement Group').should('be.visible'); + }); + + // Create Linode and confirm contents of outgoing API request payload. + ui.button + .findByTitle('Create Linode') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.wait('@createLinode').then((xhr) => { + const requestPayload = xhr.request.body; + expect(requestPayload['region']).to.equal(mockRegions[0].id); + expect(requestPayload['label']).to.equal(linodeLabel); + expect(requestPayload['placement_group'].id).to.equal( + mockPlacementGroup.id + ); + }); + + // Confirm redirect to new Linode. + cy.url().should('endWith', `/linodes/${mockLinode.id}`); + // Confirm toast notification should appear on Linode create. + ui.toast.assertMessage(`Your Linode ${mockLinode.label} is being created.`); + }); }); diff --git a/packages/manager/cypress/e2e/core/placementGroups/placement-groups-navigation.spec.ts b/packages/manager/cypress/e2e/core/placementGroups/placement-groups-navigation.spec.ts index 4e1d4b0a686..e405cf03829 100644 --- a/packages/manager/cypress/e2e/core/placementGroups/placement-groups-navigation.spec.ts +++ b/packages/manager/cypress/e2e/core/placementGroups/placement-groups-navigation.spec.ts @@ -2,17 +2,11 @@ * @file Integration tests for Placement Groups navigation. */ -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { mockGetAccount } from 'support/intercepts/account'; import { accountFactory } from 'src/factories'; import { ui } from 'support/ui'; -import type { Flags } from 'src/featureFlags'; - const mockAccount = accountFactory.build(); describe('Placement Groups navigation', () => { @@ -27,18 +21,16 @@ describe('Placement Groups navigation', () => { */ it('can navigate to Placement Groups landing page', () => { mockAppendFeatureFlags({ - placementGroups: makeFeatureFlagData({ + placementGroups: { beta: true, enabled: true, - }), + }, }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); cy.visitWithLogin('/linodes'); - cy.wait(['@getFeatureFlags', '@getClientStream']); + cy.wait('@getFeatureFlags'); ui.nav.findItemByTitle('Placement Groups').should('be.visible').click(); - cy.url().should('endWith', '/placement-groups'); }); @@ -47,15 +39,14 @@ describe('Placement Groups navigation', () => { */ it('does not show Placement Groups navigation item when feature is disabled', () => { mockAppendFeatureFlags({ - placementGroups: makeFeatureFlagData({ + placementGroups: { beta: true, enabled: false, - }), + }, }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); cy.visitWithLogin('/linodes'); - cy.wait(['@getFeatureFlags', '@getClientStream']); + cy.wait('@getFeatureFlags'); ui.nav.find().within(() => { cy.findByText('Placement Groups').should('not.exist'); @@ -67,15 +58,14 @@ describe('Placement Groups navigation', () => { */ it('displays Not Found when manually navigating to /placement-groups with feature flag disabled', () => { mockAppendFeatureFlags({ - placementGroups: makeFeatureFlagData({ + placementGroups: { beta: true, enabled: false, - }), + }, }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); cy.visitWithLogin('/placement-groups'); - cy.wait(['@getFeatureFlags', '@getClientStream']); + cy.wait('@getFeatureFlags'); cy.findByText('Not Found').should('be.visible'); }); diff --git a/packages/manager/cypress/e2e/core/stackscripts/create-stackscripts.spec.ts b/packages/manager/cypress/e2e/core/stackscripts/create-stackscripts.spec.ts index 2733a68d940..9d73880a322 100644 --- a/packages/manager/cypress/e2e/core/stackscripts/create-stackscripts.spec.ts +++ b/packages/manager/cypress/e2e/core/stackscripts/create-stackscripts.spec.ts @@ -351,22 +351,16 @@ describe('Create stackscripts', () => { .click(); // Confirm that expected images are present in "Choose an image" drop-down. - cy.findByText('Choose an image').should('be.visible').click(); + cy.findByPlaceholderText('Choose an image').should('be.visible').click(); imageSamples.forEach((imageSample) => { const imageLabel = imageSample.label; - const imageSelector = imageSample.sel; - - cy.get(`[data-qa-image-select-item="${imageSelector}"]`) - .scrollIntoView() - .should('be.visible') - .within(() => { - cy.findByText(imageLabel).should('be.visible'); - }); + + cy.findByText(imageLabel).scrollIntoView().should('be.visible'); }); // Select private image. - cy.get(`[data-qa-image-select-item="${privateImage.id}"]`) + cy.findByText(privateImage.label) .scrollIntoView() .should('be.visible') .click(); diff --git a/packages/manager/cypress/e2e/core/stackscripts/smoke-community-stackscrips.spec.ts b/packages/manager/cypress/e2e/core/stackscripts/smoke-community-stackscrips.spec.ts index 9bc796d2c6e..9cfc145aa5d 100644 --- a/packages/manager/cypress/e2e/core/stackscripts/smoke-community-stackscrips.spec.ts +++ b/packages/manager/cypress/e2e/core/stackscripts/smoke-community-stackscrips.spec.ts @@ -5,7 +5,6 @@ import { mockGetStackScripts, mockGetStackScript, } from 'support/intercepts/stackscripts'; -import { containsClick } from 'support/helpers'; import { ui } from 'support/ui'; import { randomLabel, randomString } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; @@ -16,6 +15,7 @@ import { Profile } from '@linode/api-v4'; import { formatDate } from '@src/utilities/formatDate'; import type { StackScript } from '@linode/api-v4'; +import { mockGetUserPreferences } from 'support/intercepts/profile'; const mockStackScripts: StackScript[] = [ stackScriptFactory.build({ @@ -263,9 +263,13 @@ describe('Community Stackscripts integration tests', () => { const region = chooseRegion({ capabilities: ['Vlans'] }); const linodeLabel = randomLabel(); + // Ensure that the Primary Nav is open + mockGetUserPreferences({ desktop_sidebar_open: false }).as( + 'getPreferences' + ); interceptGetStackScripts().as('getStackScripts'); cy.visitWithLogin('/stackscripts/community'); - cy.wait('@getStackScripts'); + cy.wait(['@getStackScripts', '@getPreferences']); cy.get('[id="search-by-label,-username,-or-description"]') .click() @@ -326,9 +330,9 @@ describe('Community Stackscripts integration tests', () => { cy.get('[id="vpn-password"]').should('have.value', vpnPassword); // Choose an image - cy.get('[data-qa-enhanced-select="Choose an image"]').within(() => { - containsClick('Choose an image').type(`${image}{enter}`); - }); + cy.findByPlaceholderText('Choose an image').should('be.visible').click(); + + cy.findByText(image).should('be.visible').click(); // Choose a region ui.button @@ -370,7 +374,10 @@ describe('Community Stackscripts integration tests', () => { .should('be.visible') .should('be.enabled') .click(); - cy.contains('Password does not meet complexity requirements.'); + + cy.findByText('Password does not meet', { exact: false }).should( + 'be.visible' + ); cy.get('[id="root-password"]').clear().type(fairPassword); ui.button @@ -378,7 +385,10 @@ describe('Community Stackscripts integration tests', () => { .should('be.visible') .should('be.enabled') .click(); - cy.contains('Password does not meet complexity requirements.'); + + cy.findByText('Password does not meet', { exact: false }).should( + 'be.visible' + ); // Only strong password is allowed to rebuild the linode cy.get('[id="root-password"]').type(rootPassword); diff --git a/packages/manager/cypress/e2e/core/volumes/create-volume.spec.ts b/packages/manager/cypress/e2e/core/volumes/create-volume.spec.ts index 3041575a061..25115abf26e 100644 --- a/packages/manager/cypress/e2e/core/volumes/create-volume.spec.ts +++ b/packages/manager/cypress/e2e/core/volumes/create-volume.spec.ts @@ -27,6 +27,8 @@ describe('volume create flow', () => { * - Confirms that volume is listed correctly on volumes landing page. */ it('creates an unattached volume', () => { + cy.tag('purpose:syntheticTesting'); + const region = chooseRegion(); const volume = { label: randomLabel(), diff --git a/packages/manager/cypress/e2e/core/volumes/update-volume.spec.ts b/packages/manager/cypress/e2e/core/volumes/update-volume.spec.ts index 2980b3e6a22..e5a3d9f5de0 100644 --- a/packages/manager/cypress/e2e/core/volumes/update-volume.spec.ts +++ b/packages/manager/cypress/e2e/core/volumes/update-volume.spec.ts @@ -51,7 +51,7 @@ describe('volume update flow', () => { .click() .type(`{selectall}{backspace}${newLabel}`); - cy.findByText('Type to choose or create a tag.') + cy.findByPlaceholderText('Type to choose or create a tag.') .should('be.visible') .click() .type(`${newTags.join('{enter}')}{enter}`); @@ -72,8 +72,11 @@ describe('volume update flow', () => { cy.findByText('Edit Volume').should('be.visible'); cy.findByDisplayValue(newLabel).should('be.visible'); + // Click the tags input field to see all the selected tags + cy.findByRole('combobox').should('be.visible').click(); + newTags.forEach((newTag) => { - cy.findByText(newTag).should('be.visible'); + cy.findAllByText(newTag).should('be.visible'); }); }); } diff --git a/packages/manager/cypress/e2e/core/vpc/vpc-create.spec.ts b/packages/manager/cypress/e2e/core/vpc/vpc-create.spec.ts index 148646e4019..3a1d3db5d1e 100644 --- a/packages/manager/cypress/e2e/core/vpc/vpc-create.spec.ts +++ b/packages/manager/cypress/e2e/core/vpc/vpc-create.spec.ts @@ -9,17 +9,12 @@ import { linodeFactory, regionFactory, } from '@src/factories'; -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; import { mockGetRegions } from 'support/intercepts/regions'; import { mockCreateVPCError, mockCreateVPC, mockGetSubnets, } from 'support/intercepts/vpc'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; import { randomLabel, randomPhrase, @@ -77,15 +72,10 @@ describe('VPC create flow', () => { const vpcCreationErrorMessage = 'An unknown error has occurred.'; const totalSubnetUniqueLinodes = getUniqueLinodesFromSubnets(mockSubnets); - mockAppendFeatureFlags({ - vpc: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientstream'); - mockGetRegions([mockVPCRegion]).as('getRegions'); cy.visitWithLogin('/vpcs/create'); - cy.wait(['@getFeatureFlags', '@getClientstream', '@getRegions']); + cy.wait('@getRegions'); ui.regionSelect.find().click().type(`${mockVPCRegion.label}{enter}`); @@ -292,15 +282,10 @@ describe('VPC create flow', () => { const totalSubnetUniqueLinodes = getUniqueLinodesFromSubnets([]); - mockAppendFeatureFlags({ - vpc: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientstream'); - mockGetRegions([mockVPCRegion]).as('getRegions'); cy.visitWithLogin('/vpcs/create'); - cy.wait(['@getFeatureFlags', '@getClientstream', '@getRegions']); + cy.wait('@getRegions'); ui.regionSelect.find().click().type(`${mockVPCRegion.label}{enter}`); diff --git a/packages/manager/cypress/e2e/core/vpc/vpc-details-page.spec.ts b/packages/manager/cypress/e2e/core/vpc/vpc-details-page.spec.ts index e7672f90c3c..dd7bd443c96 100644 --- a/packages/manager/cypress/e2e/core/vpc/vpc-details-page.spec.ts +++ b/packages/manager/cypress/e2e/core/vpc/vpc-details-page.spec.ts @@ -8,13 +8,8 @@ import { mockEditSubnet, mockGetSubnets, } from 'support/intercepts/vpc'; -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; import { subnetFactory, vpcFactory } from '@src/factories'; import { randomLabel, randomNumber, randomPhrase } from 'support/util/random'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; import type { VPC } from '@linode/api-v4'; import { getRegionById } from 'support/util/regions'; import { ui } from 'support/ui'; @@ -40,16 +35,12 @@ describe('VPC details page', () => { const vpcRegion = getRegionById(mockVPC.region); - mockAppendFeatureFlags({ - vpc: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); mockGetVPC(mockVPC).as('getVPC'); mockUpdateVPC(mockVPC.id, mockVPCUpdated).as('updateVPC'); mockDeleteVPC(mockVPC.id).as('deleteVPC'); cy.visitWithLogin(`/vpcs/${mockVPC.id}`); - cy.wait(['@getFeatureFlags', '@getClientStream', '@getVPC']); + cy.wait('@getVPC'); // Confirm that VPC details are displayed. cy.findByText(mockVPC.label).should('be.visible'); @@ -144,17 +135,12 @@ describe('VPC details page', () => { subnets: [mockSubnet], }); - mockAppendFeatureFlags({ - vpc: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetVPC(mockVPC).as('getVPC'); - mockGetFeatureFlagClientstream().as('getClientStream'); mockGetSubnets(mockVPC.id, []).as('getSubnets'); mockCreateSubnet(mockVPC.id).as('createSubnet'); cy.visitWithLogin(`/vpcs/${mockVPC.id}`); - cy.wait(['@getFeatureFlags', '@getClientStream', '@getVPC', '@getSubnets']); + cy.wait(['@getVPC', '@getSubnets']); // confirm that vpc and subnet details get displayed cy.findByText(mockVPC.label).should('be.visible'); diff --git a/packages/manager/cypress/e2e/core/vpc/vpc-landing-page.spec.ts b/packages/manager/cypress/e2e/core/vpc/vpc-landing-page.spec.ts index 00ac5c910de..e2863a1a22d 100644 --- a/packages/manager/cypress/e2e/core/vpc/vpc-landing-page.spec.ts +++ b/packages/manager/cypress/e2e/core/vpc/vpc-landing-page.spec.ts @@ -1,8 +1,3 @@ -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; import { mockGetVPCs, mockDeleteVPC, @@ -23,14 +18,10 @@ describe('VPC landing page', () => { */ it('lists VPC instances', () => { const mockVPCs = vpcFactory.buildList(5); - mockAppendFeatureFlags({ - vpc: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); mockGetVPCs(mockVPCs).as('getVPCs'); cy.visitWithLogin('/vpcs'); - cy.wait(['@getFeatureFlags', '@getClientStream', '@getVPCs']); + cy.wait('@getVPCs'); // Confirm each VPC is listed with expected data. mockVPCs.forEach((mockVPC) => { @@ -58,14 +49,10 @@ describe('VPC landing page', () => { * - Confirms VPC landing page empty state is shown when no VPCs are present. */ it('shows empty state when there are no VPCs', () => { - mockAppendFeatureFlags({ - vpc: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); mockGetVPCs([]).as('getVPCs'); cy.visitWithLogin('/vpcs'); - cy.wait(['@getFeatureFlags', '@getClientStream', '@getVPCs']); + cy.wait('@getVPCs'); // Confirm that empty state is shown and that each section is present. cy.findByText(VPC_LABEL).should('be.visible'); @@ -109,15 +96,11 @@ describe('VPC landing page', () => { description: randomPhrase(), }; - mockAppendFeatureFlags({ - vpc: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); mockGetVPCs([mockVPCs[1]]).as('getVPCs'); mockUpdateVPC(mockVPCs[1].id, mockUpdatedVPC).as('updateVPC'); cy.visitWithLogin('/vpcs'); - cy.wait(['@getFeatureFlags', '@getClientStream', '@getVPCs']); + cy.wait('@getVPCs'); // Find mocked VPC and click its "Edit" button. cy.findByText(mockVPCs[1].label) @@ -182,7 +165,7 @@ describe('VPC landing page', () => { mockDeleteVPC(mockVPCs[0].id).as('deleteVPC'); cy.visitWithLogin('/vpcs'); - cy.wait(['@getFeatureFlags', '@getClientStream', '@getVPCs']); + cy.wait('@getVPCs'); // Delete the first VPC instance cy.findByText(mockVPCs[0].label) @@ -271,15 +254,11 @@ describe('VPC landing page', () => { }), ]; - mockAppendFeatureFlags({ - vpc: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); mockGetVPCs(mockVPCs).as('getVPCs'); mockDeleteVPCError(mockVPCs[0].id).as('deleteVPCError'); cy.visitWithLogin('/vpcs'); - cy.wait(['@getFeatureFlags', '@getClientStream', '@getVPCs']); + cy.wait('@getVPCs'); // Try to delete VPC cy.findByText(mockVPCs[0].label) diff --git a/packages/manager/cypress/e2e/core/vpc/vpc-linodes-update.spec.ts b/packages/manager/cypress/e2e/core/vpc/vpc-linodes-update.spec.ts index 1c030b31225..a4ac2977d21 100644 --- a/packages/manager/cypress/e2e/core/vpc/vpc-linodes-update.spec.ts +++ b/packages/manager/cypress/e2e/core/vpc/vpc-linodes-update.spec.ts @@ -2,11 +2,6 @@ * @file Integration tests for VPC assign/unassign Linodes flows. */ -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; import { mockGetSubnets, mockCreateSubnet, @@ -82,17 +77,13 @@ describe('VPC assign/unassign flows', () => { subnets: [mockSubnetAfterLinodeAssignment], }); - mockAppendFeatureFlags({ - vpc: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); mockGetVPCs(mockVPCs).as('getVPCs'); mockGetVPC(mockVPC).as('getVPC'); mockGetSubnets(mockVPC.id, []).as('getSubnets'); mockCreateSubnet(mockVPC.id).as('createSubnet'); cy.visitWithLogin(`/vpcs/${mockVPC.id}`); - cy.wait(['@getFeatureFlags', '@getClientStream', '@getVPC', '@getSubnets']); + cy.wait(['@getVPC', '@getSubnets']); // confirm that vpc and subnet details get displayed cy.findByText(mockVPC.label).should('be.visible'); @@ -230,16 +221,12 @@ describe('VPC assign/unassign flows', () => { ], }); - mockAppendFeatureFlags({ - vpc: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); mockGetVPCs(mockVPCs).as('getVPCs'); mockGetVPC(mockVPC).as('getVPC'); mockGetSubnets(mockVPC.id, [mockSubnet]).as('getSubnets'); cy.visitWithLogin(`/vpcs/${mockVPC.id}`); - cy.wait(['@getFeatureFlags', '@getClientStream', '@getVPC', '@getSubnets']); + cy.wait(['@getVPC', '@getSubnets']); // confirm that subnet should get displayed on VPC's detail page cy.findByText(mockVPC.label).should('be.visible'); diff --git a/packages/manager/cypress/e2e/core/vpc/vpc-navigation.spec.ts b/packages/manager/cypress/e2e/core/vpc/vpc-navigation.spec.ts index 5128eea733f..38ecad7a0d9 100644 --- a/packages/manager/cypress/e2e/core/vpc/vpc-navigation.spec.ts +++ b/packages/manager/cypress/e2e/core/vpc/vpc-navigation.spec.ts @@ -2,30 +2,23 @@ * @file Integration tests for VPC navigation. */ -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; import { ui } from 'support/ui'; +import { mockGetUserPreferences } from 'support/intercepts/profile'; -// TODO Remove feature flag mocks when feature flag is removed from codebase. describe('VPC navigation', () => { /* * - Confirms that VPC navigation item is shown when feature is enabled. * - Confirms that clicking VPC navigation item directs user to VPC landing page. */ it('can navigate to VPC landing page', () => { - mockAppendFeatureFlags({ - vpc: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); + // Ensure that the Primary Nav is open + mockGetUserPreferences({ desktop_sidebar_open: false }).as( + 'getPreferences' + ); cy.visitWithLogin('/linodes'); - cy.wait(['@getFeatureFlags', '@getClientStream']); ui.nav.findItemByTitle('VPC').should('be.visible').click(); - cy.url().should('endWith', '/vpcs'); }); }); diff --git a/packages/manager/cypress/support/api/objectStorage.ts b/packages/manager/cypress/support/api/objectStorage.ts index 247d5a91e84..55265ec3ea5 100644 --- a/packages/manager/cypress/support/api/objectStorage.ts +++ b/packages/manager/cypress/support/api/objectStorage.ts @@ -6,16 +6,17 @@ import { getObjectURL, revokeObjectStorageKey, } from '@linode/api-v4/lib/object-storage'; -import { - ObjectStorageBucket, - ObjectStorageKey, - ObjectStorageObject, -} from '@linode/api-v4'; import axios from 'axios'; import { authenticate } from 'support/api/authentication'; import { isTestLabel } from 'support/api/common'; import { depaginate } from 'support/util/paginate'; +import type { + ObjectStorageBucket, + ObjectStorageKey, + ObjectStorageObject, +} from '@linode/api-v4'; + /** * Asynchronously deletes all objects within a test bucket. * @@ -39,7 +40,10 @@ export const deleteAllTestBucketObjects = async ( authenticate(); // @TODO Improve object retrieval to account for pagination for buckets with many objects. - const storageObjects = await getObjectList(clusterId, bucketLabel); + const storageObjects = await getObjectList({ + bucket: bucketLabel, + clusterId, + }); const storageObjectDeletePromises = storageObjects.data.map( async (storageObject: ObjectStorageObject) => { const objectUrl = await getObjectURL( diff --git a/packages/manager/cypress/support/api/volumes.ts b/packages/manager/cypress/support/api/volumes.ts index b94b7bcaa95..a52e4784f13 100644 --- a/packages/manager/cypress/support/api/volumes.ts +++ b/packages/manager/cypress/support/api/volumes.ts @@ -18,7 +18,10 @@ export const deleteAllTestVolumes = async (): Promise => { ); const detachDeletePromises = volumes - .filter((volume: Volume) => isTestLabel(volume.label)) + .filter( + (volume: Volume) => + isTestLabel(volume.label) && volume.status === 'active' + ) .map(async (volume: Volume) => { if (volume.linode_id) { await detachVolume(volume.id); diff --git a/packages/manager/cypress/support/constants/tickets.ts b/packages/manager/cypress/support/constants/tickets.ts new file mode 100644 index 00000000000..dc91654fa39 --- /dev/null +++ b/packages/manager/cypress/support/constants/tickets.ts @@ -0,0 +1,7 @@ +import { TicketSeverity } from '@linode/api-v4'; + +export const severityLabelMap: Map = new Map([ + [1, '1-Major Impact'], + [2, '2-Moderate Impact'], + [3, '3-Low Impact'], +]); diff --git a/packages/manager/cypress/support/e2e.ts b/packages/manager/cypress/support/e2e.ts index 36169150553..989f53c5caf 100644 --- a/packages/manager/cypress/support/e2e.ts +++ b/packages/manager/cypress/support/e2e.ts @@ -61,6 +61,8 @@ chai.use(function (chai, utils) { // Test setup. import { mockAccountRequest } from './setup/mock-account-request'; import { trackApiRequests } from './setup/request-tracking'; +import { mockFeatureFlagClientstream } from './setup/feature-flag-clientstream'; trackApiRequests(); mockAccountRequest(); +mockFeatureFlagClientstream(); diff --git a/packages/manager/cypress/support/intercepts/account.ts b/packages/manager/cypress/support/intercepts/account.ts index 4d51f05b620..54472bb1fe1 100644 --- a/packages/manager/cypress/support/intercepts/account.ts +++ b/packages/manager/cypress/support/intercepts/account.ts @@ -11,6 +11,7 @@ import { makeResponse } from 'support/util/response'; import type { Account, AccountLogin, + AccountMaintenance, AccountSettings, Agreements, CancelAccount, @@ -35,6 +36,15 @@ export const mockGetAccount = (account: Account): Cypress.Chainable => { return cy.intercept('GET', apiMatcher('account'), makeResponse(account)); }; +/** + * Intercepts GET request to fetch account. + * + * @returns Cypress chainable. + */ +export const interceptGetAccount = (): Cypress.Chainable => { + return cy.intercept('GET', apiMatcher('account')); +}; + /** * Intercepts PUT request to update account and mocks response. * @@ -646,3 +656,25 @@ export const mockGetAccountLogins = ( export const interceptGetNetworkUtilization = (): Cypress.Chainable => { return cy.intercept('GET', apiMatcher('account/transfer')); }; + +/** + * Intercepts GET request to fetch the account maintenance and mocks the response. + * + * @param accountMaintenance - Account Maintenance objects with which to mock response. + * + * @returns Cypress chainable. + */ +export const mockGetMaintenance = ( + accountPendingMaintenance: AccountMaintenance[], + accountCompletedMaintenance: AccountMaintenance[] +): Cypress.Chainable => { + return cy.intercept('GET', apiMatcher(`account/maintenance*`), (req) => { + const filters = getFilters(req); + + if (filters?.['status'] === 'completed') { + req.reply(paginateResponse(accountCompletedMaintenance)); + } else { + req.reply(paginateResponse(accountPendingMaintenance)); + } + }); +}; diff --git a/packages/manager/cypress/support/intercepts/feature-flags.ts b/packages/manager/cypress/support/intercepts/feature-flags.ts index 9fdf442a267..9bf393efb58 100644 --- a/packages/manager/cypress/support/intercepts/feature-flags.ts +++ b/packages/manager/cypress/support/intercepts/feature-flags.ts @@ -2,9 +2,9 @@ * @file Cypress intercepts and mocks for Cloud Manager feature flags. */ -import { makeResponse } from 'support/util/response'; +import { getResponseDataFromMockData } from 'support/util/feature-flags'; -import type { FeatureFlagResponseData } from 'support/util/feature-flags'; +import type { FeatureFlagMockData } from 'support/util/feature-flags'; // LaunchDarkly URL pattern for feature flag retrieval. const launchDarklyUrlPattern = @@ -27,44 +27,29 @@ export const mockGetFeatureFlagClientstream = () => { /** * Intercepts GET request to fetch feature flags and modifies the response. * - * The given feature flag data is merged with the actual response data so that - * existing but unrelated feature flags are left intact. + * The given feature flag mock data is merged with the actual response data so + * that existing but unrelated feature flags are unmodified. * * The response from LaunchDarkly is not modified if the status code is * anything other than 200. * - * @param featureFlags - Feature flag response data with which to append response. + * @param featureFlags - Feature flag mock data with which to append response. * * @returns Cypress chainable. */ export const mockAppendFeatureFlags = ( - featureFlags: FeatureFlagResponseData + featureFlags: FeatureFlagMockData ): Cypress.Chainable => { + const mockFeatureFlagResponse = getResponseDataFromMockData(featureFlags); + return cy.intercept('GET', launchDarklyUrlPattern, (req) => { req.continue((res) => { if (res.statusCode === 200) { res.body = { ...res.body, - ...featureFlags, + ...mockFeatureFlagResponse, }; } }); }); }; - -/** - * Intercepts GET request to fetch feature flags and mocks the response. - * - * @param featureFlags - Feature flag response data with which to mock response. - * - * @returns Cypress chainable. - */ -export const mockGetFeatureFlags = ( - featureFlags: FeatureFlagResponseData -): Cypress.Chainable => { - return cy.intercept( - 'GET', - launchDarklyUrlPattern, - makeResponse(featureFlags) - ); -}; diff --git a/packages/manager/cypress/support/intercepts/firewalls.ts b/packages/manager/cypress/support/intercepts/firewalls.ts index 6067e386c7b..07e2fc7a097 100644 --- a/packages/manager/cypress/support/intercepts/firewalls.ts +++ b/packages/manager/cypress/support/intercepts/firewalls.ts @@ -31,6 +31,19 @@ export const mockGetFirewalls = ( ); }; +/** + * Intercepts POST request to create a Firewall and mocks response. + * + * @param firewall - A Firewall with which to mock response. + * + * @returns Cypress chainable. + */ +export const mockCreateFirewall = ( + firewall: Firewall +): Cypress.Chainable => { + return cy.intercept('POST', apiMatcher('networking/firewalls*'), firewall); +}; + /** * Intercepts POST request to create a Firewall. * diff --git a/packages/manager/cypress/support/intercepts/general.ts b/packages/manager/cypress/support/intercepts/general.ts index 09cd74e0167..3c53386a098 100644 --- a/packages/manager/cypress/support/intercepts/general.ts +++ b/packages/manager/cypress/support/intercepts/general.ts @@ -57,3 +57,27 @@ export const mockApiMaintenanceMode = (): Cypress.Chainable => { return cy.intercept(apiMatcher('**'), errorResponse); }; + +/** + * Intercepts all requests to Linode API v4 and mocks an error HTTP response. + * + * @param errorCode - HTTP status code to mock. + * @param errorMessage - Response error message to mock. + * + * @returns Cypress chainable. + */ +export const mockApiRequestWithError = ( + errorCode: number, + errorReason: string +): Cypress.Chainable => { + return cy.intercept('GET', apiMatcher('*'), { + statusCode: errorCode, + body: { + errors: [ + { + reason: errorReason, + }, + ], + }, + }); +}; diff --git a/packages/manager/cypress/support/intercepts/load-balancers.ts b/packages/manager/cypress/support/intercepts/load-balancers.ts deleted file mode 100644 index 3a33cd1849b..00000000000 --- a/packages/manager/cypress/support/intercepts/load-balancers.ts +++ /dev/null @@ -1,512 +0,0 @@ -import { makeErrorResponse } from 'support/util/errors'; -import { apiMatcher } from 'support/util/intercepts'; -import { paginateResponse } from 'support/util/paginate'; -import { makeResponse } from 'support/util/response'; - -import type { - APIError, - Certificate, - Configuration, - Loadbalancer, - Route, - ServiceTarget, -} from '@linode/api-v4'; - -/** - * Intercepts GET request to fetch an ACLB load balancer and mocks response. - * - * @param loadBalancer - Load balancer with which to mock response. - * - * @returns Cypress chainable. - */ -export const mockGetLoadBalancer = (loadBalancer: Loadbalancer) => { - return cy.intercept( - 'GET', - apiMatcher(`/aclb/${loadBalancer.id}`), - makeResponse(loadBalancer) - ); -}; - -/** - * Intercepts GET request to retrieve ACLB load balancers and mocks response. - * - * @param loadBalancers - Load balancers with which to mock response. - * - * @returns Cypress chainable. - */ -export const mockGetLoadBalancers = (loadBalancers: Loadbalancer[]) => { - return cy.intercept( - 'GET', - apiMatcher('/aclb*'), - paginateResponse(loadBalancers) - ); -}; - -/** - * Intercepts DELETE requests to delete an ACLB load balancer. - * - * @param loadBalancerId - ID of load balancer for which to delete. - * - * @returns Cypress chainable. - */ -export const mockDeleteLoadBalancer = (loadBalancerId: number) => { - return cy.intercept('DELETE', apiMatcher(`/aclb/${loadBalancerId}`), {}); -}; - -/** - * Intercepts DELETE requests to delete an ACLB load balancer and mocks HTTP 500 error response. - * - * @param loadBalancerId - ID of load balancer for which to delete. - * @param message - Optional error message with which to respond. - * - * @returns Cypress chainable. - */ -export const mockDeleteLoadBalancerError = ( - loadBalancerId: number, - message?: string -) => { - const defaultMessage = 'An error occurred while deleting Load Balancer.'; - return cy.intercept( - 'DELETE', - apiMatcher(`/aclb/${loadBalancerId}`), - makeErrorResponse(message ?? defaultMessage, 500) - ); -}; - -/** - * Intercepts GET requests to retrieve ACLB load balancer configurations and mocks response. - * - * @param loadBalancerId - ID of load balancer for which to mock configurations. - * @param configurations - Load balancer configurations with which to mock response. - * - * @returns Cypress chainable. - */ -export const mockGetLoadBalancerConfigurations = ( - loadBalancerId: number, - configurations: Configuration[] -) => { - return cy.intercept( - 'GET', - apiMatcher(`/aclb/${loadBalancerId}/configurations*`), - paginateResponse(configurations) - ); -}; - -/** - * Intercepts DELETE requests to delete an ACLB load balancer configuration. - * - * @param loadBalancerId - ID of load balancer for which to delete the configuration. - * @param configId - ID of the configuration being deleted. - * - * @returns Cypress chainable. - */ -export const mockDeleteLoadBalancerConfiguration = ( - loadBalancerId: number, - configId: number -) => { - return cy.intercept( - 'DELETE', - apiMatcher(`/aclb/${loadBalancerId}/configurations/${configId}`), - {} - ); -}; - -/** - * Intercepts DELETE requests to delete an ACLB load balancer configuration and returns an error. - * - * @param loadBalancerId - ID of load balancer for which to delete the configuration. - * @param configId - ID of the configuration being deleted. - * - * @returns Cypress chainable. - */ -export const mockDeleteLoadBalancerConfigurationError = ( - loadBalancerId: number, - configId: number, - error: string -) => { - return cy.intercept( - 'DELETE', - apiMatcher(`/aclb/${loadBalancerId}/configurations/${configId}`), - makeResponse({ errors: [{ reason: error }] }, 500) - ); -}; - -/** - * Intercepts POST request to create an ACLB configuration. - * - * @param loadBalancerId - ID of load balancer for which to create the configuration. - * @param configuration - The ACLB configuration being created. - * - * @returns Cypress chainable. - */ -export const mockCreateLoadBalancerConfiguration = ( - loadBalancerId: number, - configuration: Configuration -) => { - return cy.intercept( - 'POST', - apiMatcher(`/aclb/${loadBalancerId}/configurations`), - makeResponse(configuration) - ); -}; - -/** - * Intercepts PUT request to update an ACLB configuration. - * - * @param loadBalancerId - ID of load balancer for which to update the configuration. - * @param configuration - The ACLB configuration being updated. - * - * @returns Cypress chainable. - */ -export const mockUpdateLoadBalancerConfiguration = ( - loadBalancerId: number, - configuration: Configuration -) => { - return cy.intercept( - 'PUT', - apiMatcher(`/aclb/${loadBalancerId}/configurations/${configuration.id}`), - makeResponse(configuration) - ); -}; - -/** - * Intercepts PUT request to update an ACLB configuration. - * - * @param loadBalancerId - ID of load balancer for which to update the configuration. - * @param configuration - The ACLB configuration being updated. - * - * @returns Cypress chainable. - */ -export const mockUpdateLoadBalancerConfigurationError = ( - loadBalancerId: number, - configurationId: number, - errors: APIError[] -) => { - return cy.intercept( - 'PUT', - apiMatcher(`/aclb/${loadBalancerId}/configurations/${configurationId}`), - makeResponse({ errors }, 400) - ); -}; - -/** - * Intercepts POST request to create an ACLB configuration and returns an error. - * - * @param loadBalancerId - ID of load balancer for which to create the configuration. - * @param errors - Array of API errors to mock. - * - * @returns Cypress chainable. - */ -export const mockCreateLoadBalancerConfigurationError = ( - loadBalancerId: number, - errors: APIError[] -) => { - return cy.intercept( - 'POST', - apiMatcher(`/aclb/${loadBalancerId}/configurations`), - makeResponse({ errors }, 500) - ); -}; - -/** - * Intercepts GET requests to retrieve ACLB load balancer certificates and mocks response. - * - * @param loadBalancerId - ID of load balancer for which to mock certificates. - * @param certificates - Load balancer certificates with which to mock response. - * - * @returns Cypress chainable. - */ -export const mockGetLoadBalancerCertificates = ( - loadBalancerId: number, - certificates: Certificate[] -) => { - return cy.intercept( - 'GET', - apiMatcher(`/aclb/${loadBalancerId}/certificates*`), - paginateResponse(certificates) - ); -}; - -/** - * Intercepts POST request to upload an ACLB load balancer certificate and mocks a success response. - * - * @param loadBalancerId - ID of load balancer for which to mock certificates. - * - * @returns Cypress chainable. - */ -export const mockUploadLoadBalancerCertificate = ( - loadBalancerId: number, - certificate: Certificate -) => { - return cy.intercept( - 'POST', - apiMatcher(`/aclb/${loadBalancerId}/certificates`), - makeResponse(certificate) - ); -}; - -/** - * Intercepts DELETE request to delete an ACLB load balancer certificate and mocks a success response. - * - * @param loadBalancerId - ID of load balancer for which to delete certificates. - * @param certificateId - ID of certificate for which to remove. - * - * @returns Cypress chainable. - */ -export const mockDeleteLoadBalancerCertificate = ( - loadBalancerId: number, - certificateId: number -) => { - return cy.intercept( - 'DELETE', - apiMatcher(`/aclb/${loadBalancerId}/certificates/${certificateId}`), - makeResponse() - ); -}; - -/** - * Intercepts GET request to retrieve ACLB service targets and mocks HTTP 500 error response. - * - * @param loadBalancerId - ID of load balancer for which to delete certificates. - * @param certificateId - ID of certificate for which to remove. - * @param message - Optional error message with which to respond. - * - * @returns Cypress chainable. - */ -export const mockDeleteLoadBalancerCertificateError = ( - loadBalancerId: number, - certificateId: number, - message?: string -) => { - const defaultMessage = - 'An error occurred while deleting Load Balancer certificate.'; - return cy.intercept( - 'DELETE', - apiMatcher(`/aclb/${loadBalancerId}/certificates/${certificateId}`), - makeErrorResponse(message ?? defaultMessage, 500) - ); -}; - -/** - * Intercepts PUT request to update an ACLB load balancer certificate and mocks a success response. - * - * @param loadBalancerId - ID of load balancer for which to mock certificates. - * - * @returns Cypress chainable. - */ -export const mockUpdateLoadBalancerCertificate = ( - loadBalancerId: number, - certificate: Certificate -) => { - return cy.intercept( - 'PUT', - apiMatcher(`/aclb/${loadBalancerId}/certificates/${certificate.id}`), - makeResponse(certificate) - ); -}; - -/** - * Intercepts GET request to retrieve ACLB service targets and mocks response. - * - * @param serviceTargets - Service targets with which to mock response. - * - * @returns Cypress chainable. - */ -// TODO: ACLB - We should probably remove this mock and use "mockGetLoadBalancerServiceTargets" below. -export const mockGetServiceTargets = ( - loadBalancer: Loadbalancer, - serviceTargets: ServiceTarget[] -) => { - return cy.intercept( - 'GET', - apiMatcher(`/aclb/${loadBalancer.id}/service-targets*`), - paginateResponse(serviceTargets) - ); -}; - -/** - * Intercepts GET request to retrieve ACLB service targets and mocks HTTP 500 error response. - * - * @param message - Optional error message with which to respond. - * - * @returns Cypress chainable. - */ -export const mockGetServiceTargetsError = (message?: string) => { - const defaultMessage = 'An error occurred while retrieving service targets'; - return cy.intercept( - 'GET', - apiMatcher('/aclb/service-targets*'), - 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(`/aclb/${loadBalancer.id}/service-targets`), - makeResponse(serviceTarget) - ); -}; - -/** - * Intercepts PUT request to update a service target and mocks the response. - * - * @param loadBalancer - Load balancer for mocked route. - * @param serviceTarget - Service target with which to mock response. - * - * @returns Cypress chainable. - */ -export const mockUpdateServiceTarget = ( - loadBalancer: Loadbalancer, - serviceTarget: ServiceTarget -) => { - return cy.intercept( - 'PUT', - apiMatcher(`/aclb/${loadBalancer.id}/service-targets/${serviceTarget.id}`), - makeResponse(serviceTarget) - ); -}; - -/** - * Intercepts POST request to create a route and mocks response. - * - * @param loadBalancer - Load balancer for mocked route. - * @param route - Route with which to mock response. - * - * @returns Cypress chainable. - */ -export const mockCreateRoute = (loadBalancer: Loadbalancer, route: Route) => { - return cy.intercept( - 'POST', - apiMatcher(`/aclb/${loadBalancer.id}/routes`), - makeResponse(route) - ); -}; - -/** - * Intercepts GET requests to retrieve ACLB load balancer routes and mocks response. - * - * @param loadBalancerId - ID of load balancer for which to mock certificates. - * @param routes - Load balancer routes with which to mock response. - * - * @returns Cypress chainable. - */ -export const mockGetLoadBalancerRoutes = ( - loadBalancerId: number, - routes: Route[] -) => { - return cy.intercept( - 'GET', - apiMatcher(`/aclb/${loadBalancerId}/routes*`), - paginateResponse(routes) - ); -}; - -/** - * Intercepts GET requests to retrieve ACLB load balancer service targets and mocks response. - * - * @param loadBalancerId - ID of load balancer for which to mock certificates. - * @param serviceTargets - Load balancer service targets with which to mock response. - * - * @returns Cypress chainable. - */ -export const mockGetLoadBalancerServiceTargets = ( - loadBalancerId: number, - serviceTargets: ServiceTarget[] -) => { - return cy.intercept( - 'GET', - apiMatcher(`/aclb/${loadBalancerId}/service-targets*`), - paginateResponse(serviceTargets) - ); -}; - -/** - * Intercepts PUT request to update a route and mocks the response. - * - * @param loadBalancer - Load balancer for mocked route. - * @param route - Route with which to mock response. - * - * @returns Cypress chainable. - */ -export const mockUpdateRoute = (loadBalancer: Loadbalancer, route: Route) => { - return cy.intercept( - 'PUT', - apiMatcher(`/aclb/${loadBalancer.id}/routes/${route.id}`), - makeResponse(route) - ); -}; - -/** - * Intercepts PUT request to update a route and mocks the response. - * - * @param loadBalancer - Load balancer for mocked route. - * @param route - Route with which to mock response. - * - * @returns Cypress chainable. - */ -export const mockUpdateRouteError = ( - loadBalancer: Loadbalancer, - route: Route -) => { - return cy.intercept( - 'PUT', - apiMatcher(`/aclb/${loadBalancer.id}/routes/${route.id}`), - makeResponse( - { - errors: [ - { - field: 'rules[3].match_condition.match_value', - reason: 'Bad Match Value', - }, - { - field: 'rules[3].match_condition.match_field', - reason: 'Bad Match Type', - }, - { - field: 'rules[3].service_targets[0].id', - reason: 'Service Target does not exist', - }, - { - field: 'rules[3].service_targets[0].percentage', - reason: 'Invalid percentage', - }, - { - field: 'rules[3].service_targets[0].percentage', - reason: 'Invalid percentage', - }, - { - field: 'rules[3].match_condition.session_stickiness_ttl', - reason: 'Invalid TTL', - }, - { - field: 'rules[3].match_condition.session_stickiness_cookie', - reason: 'Invalid Cookie', - }, - { - field: 'rules[3].match_condition.hostname', - reason: 'Hostname is not valid', - }, - { - reason: 'A backend service is down', - }, - { - reason: 'You reached a rate limit', - }, - ], - }, - 400 - ) - ); -}; diff --git a/packages/manager/cypress/support/intercepts/longview.ts b/packages/manager/cypress/support/intercepts/longview.ts index 9c9b04b5ba5..60ee3e6d0b8 100644 --- a/packages/manager/cypress/support/intercepts/longview.ts +++ b/packages/manager/cypress/support/intercepts/longview.ts @@ -1,7 +1,7 @@ import { apiMatcher } from 'support/util/intercepts'; import { paginateResponse } from 'support/util/paginate'; import { makeResponse } from 'support/util/response'; -import { LongviewClient } from '@linode/api-v4'; +import { LongviewClient, ActiveLongviewPlan } from '@linode/api-v4'; import type { LongviewAction, LongviewResponse, @@ -93,6 +93,28 @@ export const mockCreateLongviewClient = ( ); }; +export const mockGetLongviewPlan = ( + plan: ActiveLongviewPlan +): Cypress.Chainable => { + return cy.intercept('GET', apiMatcher('longview/plan'), makeResponse(plan)); +}; + +export const mockUpdateLongviewPlan = ( + newPlan: ActiveLongviewPlan +): Cypress.Chainable => { + return cy.intercept( + 'PUT', + apiMatcher('longview/plan'), + makeResponse(newPlan) + ); +}; + +export const mockCreateLongviewPlan = ( + plan: ActiveLongviewPlan +): Cypress.Chainable => { + return cy.intercept('POST', apiMatcher('longview/plan'), makeResponse(plan)); +}; + /** * Mocks request to delete a Longview Client. * diff --git a/packages/manager/cypress/support/intercepts/object-storage.ts b/packages/manager/cypress/support/intercepts/object-storage.ts index 9d5527f011f..ae266ee9482 100644 --- a/packages/manager/cypress/support/intercepts/object-storage.ts +++ b/packages/manager/cypress/support/intercepts/object-storage.ts @@ -9,6 +9,7 @@ import { paginateResponse } from 'support/util/paginate'; import { makeResponse } from 'support/util/response'; import type { + CreateObjectStorageBucketPayload, ObjectStorageBucket, ObjectStorageCluster, ObjectStorageKey, @@ -86,7 +87,7 @@ export const interceptCreateBucket = (): Cypress.Chainable => { * @returns Cypress chainable. */ export const mockCreateBucket = ( - bucket: ObjectStorageBucket + bucket: CreateObjectStorageBucketPayload ): Cypress.Chainable => { return cy.intercept( 'POST', @@ -478,3 +479,12 @@ export const interceptUpdateBucketAccess = ( apiMatcher(`object-storage/buckets/${cluster}/${label}/access`) ); }; + +/** + * Intercepts GET request to get object storage endpoints. + * + * @returns Cypress chainable. + */ +export const interceptGetObjectStorageEndpoints = (): Cypress.Chainable => { + return cy.intercept('GET', apiMatcher(`object-storage/endpoints`)); +}; diff --git a/packages/manager/cypress/support/setup/feature-flag-clientstream.ts b/packages/manager/cypress/support/setup/feature-flag-clientstream.ts new file mode 100644 index 00000000000..1c50282297b --- /dev/null +++ b/packages/manager/cypress/support/setup/feature-flag-clientstream.ts @@ -0,0 +1,16 @@ +/** + * @file Mocks feature flag clientstream request across all tests. + */ + +import { mockGetFeatureFlagClientstream } from 'support/intercepts/feature-flags'; + +/** + * Mocks LaunchDarkly feature flag clientstream request across all tests. + * + * This prevents our feature flag mocks from being overridden. + */ +export const mockFeatureFlagClientstream = () => { + beforeEach(() => { + mockGetFeatureFlagClientstream(); + }); +}; diff --git a/packages/manager/cypress/support/ui/pagination.ts b/packages/manager/cypress/support/ui/pagination.ts index ba16ee3db16..ffc5d349288 100644 --- a/packages/manager/cypress/support/ui/pagination.ts +++ b/packages/manager/cypress/support/ui/pagination.ts @@ -20,7 +20,7 @@ export const pagination = { return pagination .find() .find('[data-qa-pagination-page-size]') - .find('[data-qa-enhanced-select]'); + .find('[data-qa-autocomplete]'); }, /** diff --git a/packages/manager/cypress/support/util/feature-flags.ts b/packages/manager/cypress/support/util/feature-flags.ts index 5331031140c..8d09514edec 100644 --- a/packages/manager/cypress/support/util/feature-flags.ts +++ b/packages/manager/cypress/support/util/feature-flags.ts @@ -2,6 +2,15 @@ * @file Types and utilities related to Cloud Manager feature flags. */ +import type { Flags } from 'src/featureFlags'; + +const defaultFeatureFlagData = { + flagVersion: 1, + trackEvents: false, + variation: 0, + version: 1, +}; + /** * Data for a Cloud Manager feature flag. */ @@ -13,13 +22,72 @@ export interface FeatureFlagData { version: number; } +/** + * Cloud Manager feature flag mock data. + * + * This allows feature flag data to be expressed more flexibly, but must be + * converted to a `FeatureFlagResponseData` object before it can be used in a + * mocked LaunchDarkly response. + * + * See also `getResponseDataFromMockData()`. + */ +export type FeatureFlagMockData = Partial< + Record< + keyof Flags, + Partial> | Flags[keyof Flags]> + > +>; + /** * Cloud Manager feature flag response. + * + * This data requires that all feature flag properties (`value`, `version`, + * `variation`, etc.) are specified. See also `FeatureFlagMockData` for a more + * flexible way to define and represent feature flag data. */ export interface FeatureFlagResponseData { [key: string]: FeatureFlagData; } +/** + * Determines whether the given data is a partial representation of `FeatureFlagData`. + * + * @returns `true` if `data` is a partial feature flag object, `false` otherwise. + */ +export const isPartialFeatureFlagData = ( + data: any +): data is Partial> => { + if (typeof data === 'object' && data !== null && 'value' in data) { + return true; + } + return false; +}; + +/** + * Returns a new `FeatureFlagResponseData` object for the given + * `FeatureFlagMockData` object. + * + * @param data - Feature flag mock data from which to create response data. + * + * @returns Feature flag response data that can be used for mocking purposes. + */ +export const getResponseDataFromMockData = (data: FeatureFlagMockData) => { + const output = { ...data }; + return Object.keys(output).reduce((acc: FeatureFlagMockData, cur: string) => { + const mockData = output[cur]; + if (isPartialFeatureFlagData(mockData)) { + output[cur] = { + ...defaultFeatureFlagData, + ...mockData, + }; + return output; + } else { + output[cur] = makeFeatureFlagData(mockData); + } + return output; + }, output); +}; + /** * Returns an object containing feature flag data. * @@ -36,13 +104,6 @@ export const makeFeatureFlagData = ( trackEvents?: boolean, flagVersion?: number ): FeatureFlagData => { - const defaultFeatureFlagData = { - flagVersion: 1, - trackEvents: false, - variation: 0, - version: 1, - }; - return { flagVersion: flagVersion ?? defaultFeatureFlagData.flagVersion, trackEvents: trackEvents ?? defaultFeatureFlagData.trackEvents, diff --git a/packages/manager/cypress/support/util/tag.ts b/packages/manager/cypress/support/util/tag.ts index c7fa37f0a08..9be33d52a39 100644 --- a/packages/manager/cypress/support/util/tag.ts +++ b/packages/manager/cypress/support/util/tag.ts @@ -18,6 +18,7 @@ export type TestTag = // DC testing purposes even if that is not the primary purpose of the test. | 'purpose:dcTesting' | 'purpose:smokeTesting' + | 'purpose:syntheticTesting' // Method-related tags. // Describe the way the tests operate -- either end-to-end using real API requests, diff --git a/packages/manager/package.json b/packages/manager/package.json index 0dec3e6594f..5598b70c01d 100644 --- a/packages/manager/package.json +++ b/packages/manager/package.json @@ -2,7 +2,7 @@ "name": "linode-manager", "author": "Linode", "description": "The Linode Manager website", - "version": "1.124.0", + "version": "1.125.0", "private": true, "type": "module", "bugs": { @@ -18,7 +18,7 @@ "@emotion/styled": "^11.11.0", "@hookform/resolvers": "2.9.11", "@linode/api-v4": "*", - "@linode/design-language-system": "^2.3.0", + "@linode/design-language-system": "^2.6.1", "@linode/validation": "*", "@linode/search": "*", "@lukemorales/query-key-factory": "^1.3.4", @@ -81,7 +81,8 @@ "tss-react": "^4.8.2", "typescript-fsa": "^3.0.0", "typescript-fsa-reducers": "^1.2.0", - "xterm": "^4.2.0", + "@xterm/xterm": "^5.5.0", + "@xterm/addon-fit": "^0.10.0", "yup": "^0.32.9", "zxcvbn": "^4.4.2" }, diff --git a/packages/manager/public/assets/apachecassandra.svg b/packages/manager/public/assets/apachecassandra.svg new file mode 100755 index 00000000000..8a2eff9fb19 --- /dev/null +++ b/packages/manager/public/assets/apachecassandra.svg @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/manager/public/assets/appwrite.svg b/packages/manager/public/assets/appwrite.svg index 46b297d934d..7f458121ead 100644 --- a/packages/manager/public/assets/appwrite.svg +++ b/packages/manager/public/assets/appwrite.svg @@ -1,4 +1,9 @@ - - - + + + diff --git a/packages/manager/public/assets/influxdb.svg b/packages/manager/public/assets/influxdb.svg new file mode 100755 index 00000000000..4f85affee44 --- /dev/null +++ b/packages/manager/public/assets/influxdb.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/manager/public/assets/openbao.svg b/packages/manager/public/assets/openbao.svg new file mode 100755 index 00000000000..4f778354f66 --- /dev/null +++ b/packages/manager/public/assets/openbao.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/manager/public/assets/valkey.svg b/packages/manager/public/assets/valkey.svg new file mode 100755 index 00000000000..1607a4d0c18 --- /dev/null +++ b/packages/manager/public/assets/valkey.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/manager/public/assets/white/apachecassandra.svg b/packages/manager/public/assets/white/apachecassandra.svg new file mode 100755 index 00000000000..efa2d283944 --- /dev/null +++ b/packages/manager/public/assets/white/apachecassandra.svg @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/manager/public/assets/white/appwrite.svg b/packages/manager/public/assets/white/appwrite.svg index 1d226bac9ce..9f068317edd 100644 --- a/packages/manager/public/assets/white/appwrite.svg +++ b/packages/manager/public/assets/white/appwrite.svg @@ -1,4 +1,9 @@ - - - + + + diff --git a/packages/manager/public/assets/white/influxdb.svg b/packages/manager/public/assets/white/influxdb.svg new file mode 100755 index 00000000000..1958c2a2197 --- /dev/null +++ b/packages/manager/public/assets/white/influxdb.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/manager/public/assets/white/openbao.svg b/packages/manager/public/assets/white/openbao.svg new file mode 100755 index 00000000000..7dd33bfcfc7 --- /dev/null +++ b/packages/manager/public/assets/white/openbao.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/manager/public/assets/white/valkey.svg b/packages/manager/public/assets/white/valkey.svg new file mode 100755 index 00000000000..dd8ee39d975 --- /dev/null +++ b/packages/manager/public/assets/white/valkey.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/manager/src/GoTo.tsx b/packages/manager/src/GoTo.tsx index c028a452077..05cde0ef923 100644 --- a/packages/manager/src/GoTo.tsx +++ b/packages/manager/src/GoTo.tsx @@ -1,16 +1,17 @@ import Dialog from '@mui/material/Dialog'; -import { Theme } from '@mui/material/styles'; import * as React from 'react'; import { useHistory } from 'react-router-dom'; import { makeStyles } from 'tss-react/mui'; -import EnhancedSelect, { Item } from 'src/components/EnhancedSelect/Select'; +import EnhancedSelect from 'src/components/EnhancedSelect/Select'; -import { useIsACLBEnabled } from './features/LoadBalancers/utils'; +import { useIsDatabasesEnabled } from './features/Databases/utilities'; import { useIsPlacementGroupsEnabled } from './features/PlacementGroups/utils'; import { useAccountManagement } from './hooks/useAccountManagement'; import { useGlobalKeyboardListener } from './hooks/useGlobalKeyboardListener'; -import { useIsDatabasesEnabled } from './features/Databases/utilities'; + +import type { Theme } from '@mui/material/styles'; +import type { Item } from 'src/components/EnhancedSelect/Select'; const useStyles = makeStyles()((theme: Theme) => ({ input: { @@ -61,7 +62,6 @@ export const GoTo = React.memo(() => { const routerHistory = useHistory(); const { _hasAccountAccess, _isManagedAccount } = useAccountManagement(); - const { isACLBEnabled } = useIsACLBEnabled(); const { isPlacementGroupsEnabled } = useIsPlacementGroupsEnabled(); const { isDatabasesEnabled } = useIsDatabasesEnabled(); const { goToOpen, setGoToOpen } = useGlobalKeyboardListener(); @@ -91,11 +91,6 @@ export const GoTo = React.memo(() => { display: 'Volumes', href: '/volumes', }, - { - display: 'Load Balancers', - hide: !isACLBEnabled, - href: '/loadbalancers', - }, { display: 'VPC', href: '/vpcs', @@ -163,12 +158,7 @@ export const GoTo = React.memo(() => { href: '/profile/display', }, ], - [ - _hasAccountAccess, - _isManagedAccount, - isACLBEnabled, - isPlacementGroupsEnabled, - ] + [_hasAccountAccess, _isManagedAccount, isPlacementGroupsEnabled] ); const options: Item[] = React.useMemo( diff --git a/packages/manager/src/MainContent.tsx b/packages/manager/src/MainContent.tsx index 11d1b77dbd6..e84915eac47 100644 --- a/packages/manager/src/MainContent.tsx +++ b/packages/manager/src/MainContent.tsx @@ -30,7 +30,6 @@ import { complianceUpdateContext } from './context/complianceUpdateContext'; import { sessionExpirationContext } from './context/sessionExpirationContext'; import { switchAccountSessionContext } from './context/switchAccountSessionContext'; import { useIsDatabasesEnabled } from './features/Databases/utilities'; -import { useIsACLBEnabled } from './features/LoadBalancers/utils'; import { useIsPlacementGroupsEnabled } from './features/PlacementGroups/utils'; import { useGlobalErrors } from './hooks/useGlobalErrors'; import { useAccountSettings } from './queries/account/settings'; @@ -142,11 +141,6 @@ const Kubernetes = React.lazy(() => ); const ObjectStorage = React.lazy(() => import('src/features/ObjectStorage')); const Profile = React.lazy(() => import('src/features/Profile/Profile')); -const LoadBalancers = React.lazy(() => - import('src/features/LoadBalancers').then((module) => ({ - default: module.LoadBalancers, - })) -); const NodeBalancers = React.lazy( () => import('src/features/NodeBalancers/NodeBalancers') ); @@ -227,7 +221,6 @@ export const MainContent = () => { const username = profile?.username || ''; const { isDatabasesEnabled } = useIsDatabasesEnabled(); - const { isACLBEnabled } = useIsACLBEnabled(); const { isPlacementGroupsEnabled } = useIsPlacementGroupsEnabled(); const { data: accountSettings } = useAccountSettings(); @@ -334,12 +327,6 @@ export const MainContent = () => { )} - {isACLBEnabled && ( - - )} + + + + diff --git a/packages/manager/src/assets/icons/entityIcons/loadbalancer.svg b/packages/manager/src/assets/icons/entityIcons/loadbalancer.svg deleted file mode 100644 index 54f10dcd9fe..00000000000 --- a/packages/manager/src/assets/icons/entityIcons/loadbalancer.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/packages/manager/src/assets/weblish/weblish.css b/packages/manager/src/assets/weblish/weblish.css index 3b4281badf9..4298fd3519c 100644 --- a/packages/manager/src/assets/weblish/weblish.css +++ b/packages/manager/src/assets/weblish/weblish.css @@ -1,263 +1,15 @@ -#root { - background-color: #000; +body { + background-color: black !important; } #terminal { - width: 100%; - margin: 0; - padding: 0; - font-family: Helvetica; - background-color: #000; - /*background-image: linear-gradient(45deg, #CACACC 25%, transparent 25%, transparent 75%, #CACACC 75%, #CACACC), - linear-gradient(45deg, #CACACC 25%, transparent 25%, transparent 75%, #CACACC 75%, #CACACC); - */ - background-size: 10px 10px; - background-position: 0 0, 5px 5px; + margin: 0; + padding: 0; + background-color: #000; + background-size: 10px 10px; + background-position: 0 0, 5px 5px; } -.full-height { - height: auto; -} - -body > div.terminal { - font-family: 'Source Code Pro for Powerline', monospace, fixed; -} - -#disconnected { - color: white; - width: 100%; - height: 100%; - top: 0px; - left: 0px; - position: absolute; -} - -#disconnected.modal-open { - display: block; -} - -#disconnected>* { - z-index: 2; - top: 0px; - left: 0px; -} - -#disconnected>.blur { - height: 100%; - z-index: 1; - -webkit-filter: blur(1px); - -moz-filter: blur(1px); - -o-filter: blur(1px); - -ms-filter: blur(1px); - filter: blur(1px); -} - -#reconnect { - transition: all 0.3s ease 0s; - background: #333; - opacity: .75; - color: #eee; - border: thin solid #ccc; - position: absolute; - left: 33%; - top: 33%; - width: 33%; - height: 33%; - font-size: .8rem; - text-align: center; -} - -#reconnect h2 { - font-size: 2rem; -} - -#reconnect:hover { - background: #383838; - color: #f0f0f0; - border-color: #eee; - opacity: 1; -} - -#reconnect button { - width: 66%; - line-height: 60px; - margin: .2rem; - font-size: 1.5rem; - color: #f0f0f0; - background: #428bca; - border: .1rem outset #428bca; -} - -#reconnect:hover button { - border: .1rem outset #529bca; - background: #3071A9; -} -#reconnect:active button { - border-style: inset; - background: #226b8a; -} - -.osx, .osx:hover { - - color: #000; text-decoration: none; text-shadow: 0 1px rgba(255,255,255,.2); - font: 400 13px/19px "Helvetica Neue", Arial, sans-serif; - - -webkit-tap-highlight-color: transparent; - - padding: 0 12px; - - border: 1px solid; - border-top-color: #9d9d9d; border-bottom-color: #939393; - border-left-color: #949494; border-right-color: #949494; - - -webkit-border-radius: 4px; box-shadow: 0 1px rgba(0,0,0,0.1); - -moz-border-radius: 4px; -moz-box-shadow: 0 1px rgba(0,0,0,0.1); - border-radius: 4px; -webkit-box-shadow: 0 1px rgba(0,0,0,0.1); - - -webkit-appearance: none; - - background: #ffffff; /* Old browsers */ - - background: -webkit-gradient(linear, left top, left bottom, - /* Chrome, */ color-stop( 0%, #ffffff), - /* Safari4+ */ color-stop(25%, #ffffff), - color-stop( 30%, #fcfcfc), - color-stop(35%, #f9f9f9), - color-stop(40%, #f7f7f7), - color-stop(45%, #f5f5f5), - color-stop( 50%, #f2f2f2), - color-stop(50%, #ececec), - color-stop(80%, #ededed), - color-stop(95%, #efefef), - color-stop(100%, #f2f2f2)); - background: -webkit-linear-gradient(top, #ffffff 0%, #ffffff 25%, #fcfcfc 30%, #f9f9f9 35%, - /* Chrome10+, */ #f7f7f7 40%, #f5f5f5 45%, #f2f2f2 50%, #ececec 50%, - /* Safari5.1+ */ #ededed 80%, #efefef 95%, #f2f2f2 100%); - background: -moz-linear-gradient(top, #ffffff 0%, #ffffff 25%, #fcfcfc 30%, #f9f9f9 35%, - /* FF3.6+ */ #f7f7f7 40%, #f5f5f5 45%, #f2f2f2 50%, #ececec 50%, - #ededed 80%, #efefef 95%, #f2f2f2 100%); - background: -o-linear-gradient(top, #ffffff 0%, #ffffff 25%, #fcfcfc 30%, #f9f9f9 35%, - /* Opera 11.10+ */ #f7f7f7 40%, #f5f5f5 45%, #f2f2f2 50%, #ececec 50%, - #ededed 80%, #efefef 95%, #f2f2f2 100%); - background: -ms-linear-gradient(top, #ffffff 0%, #ffffff 25%, #fcfcfc 30%, #f9f9f9 35%, - /* IE10+ */ #f7f7f7 40%, #f5f5f5 45%, #f2f2f2 50%, #ececec 50%, - #ededed 80%, #efefef 95%, #f2f2f2 100%); - background: linear-gradient(to bottom, #ffffff 0%, #ffffff 25%, #fcfcfc 30%, #f9f9f9 35%, - /* W3C */ #f7f7f7 40%, #f5f5f5 45%, #f2f2f2 50%, #ececec 50%, - #ededed 80%, #efefef 95%, #f2f2f2 100%); - filter: progid:DXImageTransform.Microsoft.gradient( - /* IE6-9 */ startColorstr='#ffffff', endColorstr='#f2f2f2',GradientType=0 ); - - cursor: default; -webkit-user-select: none; - -moz-user-select: none; user-select: none; - -} - -.osx:active, .osx.primary, .osx.primary:hover { - background: #bcd6ef; /* Old browsers */ - background: -webkit-gradient(linear, left top, left bottom, - /* Chrome, */ color-stop( 0%, #bcd6ef), - /* Safari4+ */ color-stop( 5%, #a3c1ef), - color-stop( 10%, #98b8e9), - color-stop(15%, #91b3e9), - color-stop(20%, #8ab1e9), - color-stop(25%, #8ab2ea), - color-stop( 30%, #83abe8), - color-stop(35%, #7cabe9), - color-stop(40%, #73a6e8), - color-stop(45%, #6ca4e9), - color-stop( 50%, #67a1e9), - color-stop(50%, #4693ea), - color-stop(70%, #579eec), - color-stop(75%, #64a7ee), - color-stop( 80%, #6eaeee), - color-stop(85%, #7db6ef), - color-stop(90%, #88bfef), - olor-stop(95%, #97caef), - color-stop(100%, #abd4ef)); - background: -webkit-linear-gradient(top, #bcd6ef 0%, #a3c1ef 5%, #98b8e9 10%, #91b3e9 15%, - /* Chrome10+, */ #8ab1e9 20%, #8ab2ea 25%, #83abe8 30%, #7cabe9 35%, - /* Safari5.1+ */ #73a6e8 40%, #6ca4e9 45%, #67a1e9 50%, #4693ea 50%, - #579eec 70%, #64a7ee 75%, #6eaeee 80%, #7db6ef 85%, - #88bfef 90%, #97caef 95%, #abd4ef 100%); - background: -moz-linear-gradient(top, #bcd6ef 0%, #a3c1ef 5%, #98b8e9 10%, #91b3e9 15%, - /* FF3.6+ */ #8ab1e9 20%, #8ab2ea 25%, #83abe8 30%, #7cabe9 35%, - #73a6e8 40%, #6ca4e9 45%, #67a1e9 50%, #4693ea 50%, - #579eec 70%, #64a7ee 75%, #6eaeee 80%, #7db6ef 85%, - #88bfef 90%, #97caef 95%, #abd4ef 100%); - background: -o-linear-gradient(top, #bcd6ef 0%, #a3c1ef 5%, #98b8e9 10%, #91b3e9 15%, - /* Opera 11.10+ */ #8ab1e9 20%, #8ab2ea 25%, #83abe8 30%, #7cabe9 35%, - #73a6e8 40%, #6ca4e9 45%, #67a1e9 50%, #4693ea 50%, - #579eec 70%, #64a7ee 75%, #6eaeee 80%, #7db6ef 85%, - #88bfef 90%, #97caef 95%, #abd4ef 100%); - background: -ms-linear-gradient(top, #bcd6ef 0%, #a3c1ef 5%, #98b8e9 10%, #91b3e9 15%, - /* IE10+ */ #8ab1e9 20%, #8ab2ea 25%, #83abe8 30%, #7cabe9 35%, - #73a6e8 40%, #6ca4e9 45%, #67a1e9 50%, #4693ea 50%, - #579eec 70%, #64a7ee 75%, #6eaeee 80%, #7db6ef 85%, - #88bfef 90%, #97caef 95%, #abd4ef 100%); - background: linear-gradient(to bottom, #bcd6ef 0%, #a3c1ef 5%, #98b8e9 10%, #91b3e9 15%, - /* W3C */ #8ab1e9 20%, #8ab2ea 25%, #83abe8 30%, #7cabe9 35%, - #73a6e8 40%, #6ca4e9 45%, #67a1e9 50%, #4693ea 50%, - #579eec 70%, #64a7ee 75%, #6eaeee 80%, #7db6ef 85%, - #88bfef 90%, #97caef 95%, #abd4ef 100%); - filter: progid:DXImageTransform.Microsoft.gradient( - /* IE6-9 */ startColorstr='#bcd6ef', endColorstr='#abd4ef',GradientType=0 ); - - border-top-color: #5158ab; border-bottom-color: #464872; - border-left-color: #4b4d8c; border-right-color: #4b4d8c; - -} - -.osx.primary, .osx.primary:hover { - -webkit-animation: pulsate 730ms infinite alternate ease-in-out; - -moz-animation: pulsate 730ms infinite alternate ease-in-out; - animation: pulsate 730ms infinite alternate ease-in-out; -} - -.osx.primary:active { - animation: none; - -moz-animation: none; - -webkit-animation: none; -} - -@-webkit-keyframes pulsate { - 0% { -webkit-box-shadow: 0 1px rgba(0,0,0,0.1), inset 0 0 19px 2px #d2f7ff; box-shadow: 0 1px rgba(0,0,0,0.1), inset 0 0 19px 2px #d2f7ff; } - 100% { -webkit-box-shadow: 0 1px rgba(0,0,0,0.1), inset 0 0 19px 0px #a0c1ed; box-shadow: 0 1px rgba(0,0,0,0.1), inset 0 0 19px 0px #a0c1ed; } -} - -@-moz-keyframes pulsate { - 0% { -moz-box-shadow: 0 1px rgba(0,0,0,0.1), inset 0 0 19px 2px #d2f7ff; box-shadow: 0 1px rgba(0,0,0,0.1), inset 0 0 19px 2px #d2f7ff; } - 100% { -moz-box-shadow: 0 1px rgba(0,0,0,0.1), inset 0 0 19px 0px #a0c1ed; box-shadow: 0 1px rgba(0,0,0,0.1), inset 0 0 19px 0px #a0c1ed; } -} - -@keyframes pulsate { - 0% { box-shadow: 0 1px rgba(0,0,0,0.1), inset 0 0 19px 2px #d2f7ff; } - 100% { box-shadow: 0 1px rgba(0,0,0,0.1), inset 0 0 19px 0px #a0c1ed; } -} - -.osx:focus { - -webkit-box-shadow: 0 1px rgba(0,0,0,0.1), 0 0 4px hsl(204, 100%, 88%); - -moz-box-shadow: 0 1px rgba(0,0,0,0.1), 0 0 4px hsl(204, 100%, 88%); - box-shadow: 0 1px rgba(0,0,0,0.1), 0 0 4px hsl(204, 100%, 88%); - border-color: hsl(204, 68%, 58%); -} - -.osx.round { - -webkit-border-radius: 9999px; - -moz-border-radius: 9999px; - border-radius: 9999px; - padding: 0 10px; -} - -.osx.single{ - padding: 0 6px; -} - -.osx b, .osx strong { - font-weight: 500; -} - -[data-qa-tab="Glish"] canvas { - margin: 0 !important; - padding: 24px; +canvas { + margin: unset !important; } \ No newline at end of file diff --git a/packages/manager/src/assets/weblish/xterm.css b/packages/manager/src/assets/weblish/xterm.css index eec41a05cca..5868d8644b6 100644 --- a/packages/manager/src/assets/weblish/xterm.css +++ b/packages/manager/src/assets/weblish/xterm.css @@ -94,7 +94,7 @@ .xterm .xterm-viewport { /* On OS X this is required in order for the scroll bar to appear fully opaque */ background-color: #000; - overflow-y: scroll; + overflow-y: auto; cursor: default; position: absolute; right: 0; diff --git a/packages/manager/src/components/AccessPanel/AccessPanel.tsx b/packages/manager/src/components/AccessPanel/AccessPanel.tsx index f21c9e146da..78b0905f193 100644 --- a/packages/manager/src/components/AccessPanel/AccessPanel.tsx +++ b/packages/manager/src/components/AccessPanel/AccessPanel.tsx @@ -11,9 +11,9 @@ import { ENCRYPT_DISK_REBUILD_DISTRIBUTED_COPY, ENCRYPT_DISK_REBUILD_LKE_COPY, ENCRYPT_DISK_REBUILD_STANDARD_COPY, -} from 'src/components/DiskEncryption/constants'; -import { DiskEncryption } from 'src/components/DiskEncryption/DiskEncryption'; -import { useIsDiskEncryptionFeatureEnabled } from 'src/components/DiskEncryption/utils'; +} from 'src/components/Encryption/constants'; +import { Encryption } from 'src/components/Encryption/Encryption'; +import { useIsDiskEncryptionFeatureEnabled } from 'src/components/Encryption/utils'; import { Paper } from 'src/components/Paper'; import { getIsDistributedRegion } from 'src/components/RegionSelect/RegionSelect.utils'; import { SuspenseLoader } from 'src/components/SuspenseLoader'; @@ -201,7 +201,7 @@ export const AccessPanel = (props: Props) => { toggleDiskEncryptionEnabled !== undefined ? ( <> - { linodeIsInDistributedRegion, regionSupportsDiskEncryption, })} - isEncryptDiskChecked={diskEncryptionEnabled ?? false} + isEncryptEntityChecked={diskEncryptionEnabled ?? false} onChange={() => toggleDiskEncryptionEnabled()} /> diff --git a/packages/manager/src/components/Autocomplete/Autocomplete.tsx b/packages/manager/src/components/Autocomplete/Autocomplete.tsx index 8fe72590532..e8644fbce4d 100644 --- a/packages/manager/src/components/Autocomplete/Autocomplete.tsx +++ b/packages/manager/src/components/Autocomplete/Autocomplete.tsx @@ -38,6 +38,8 @@ export interface EnhancedAutocompleteProps< placeholder?: string; /** Label for the "select all" option. */ selectAllLabel?: string; + /** Removes "select all" option for mutliselect */ + disableSelectAll?: boolean; textFieldProps?: Partial; } @@ -86,6 +88,7 @@ export const Autocomplete = < selectAllLabel = '', textFieldProps, value, + disableSelectAll = false, ...rest } = props; @@ -166,7 +169,11 @@ export const Autocomplete = < multiple={multiple} noOptionsText={noOptionsText || You have no options to choose from} onBlur={onBlur} - options={multiple && options.length > 0 ? optionsWithSelectAll : options} + options={ + multiple && !disableSelectAll && options.length > 0 + ? optionsWithSelectAll + : options + } popupIcon={} value={value} {...rest} diff --git a/packages/manager/src/components/DiskEncryption/DiskEncryption.test.tsx b/packages/manager/src/components/Encryption/Encryption.test.tsx similarity index 83% rename from packages/manager/src/components/DiskEncryption/DiskEncryption.test.tsx rename to packages/manager/src/components/Encryption/Encryption.test.tsx index 3226a4677de..1a4750c0846 100644 --- a/packages/manager/src/components/DiskEncryption/DiskEncryption.test.tsx +++ b/packages/manager/src/components/Encryption/Encryption.test.tsx @@ -3,18 +3,18 @@ import * as React from 'react'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { - DiskEncryption, + Encryption, checkboxTestId, descriptionTestId, headerTestId, -} from './DiskEncryption'; +} from './Encryption'; describe('DiskEncryption', () => { it('should render a header', () => { const { getByTestId } = renderWithTheme( - ); @@ -27,9 +27,9 @@ describe('DiskEncryption', () => { it('should render a description', () => { const { getByTestId } = renderWithTheme( - ); @@ -41,9 +41,9 @@ describe('DiskEncryption', () => { it('should render a checkbox', () => { const { getByTestId } = renderWithTheme( - ); diff --git a/packages/manager/src/components/DiskEncryption/DiskEncryption.tsx b/packages/manager/src/components/Encryption/Encryption.tsx similarity index 65% rename from packages/manager/src/components/DiskEncryption/DiskEncryption.tsx rename to packages/manager/src/components/Encryption/Encryption.tsx index ba4f236e881..f4b7e0da4d4 100644 --- a/packages/manager/src/components/DiskEncryption/DiskEncryption.tsx +++ b/packages/manager/src/components/Encryption/Encryption.tsx @@ -3,35 +3,38 @@ import * as React from 'react'; import { Box } from 'src/components/Box'; import { Checkbox } from 'src/components/Checkbox'; import { Typography } from 'src/components/Typography'; + import { Notice } from '../Notice/Notice'; -export interface DiskEncryptionProps { +export interface EncryptionProps { descriptionCopy: JSX.Element | string; disabled?: boolean; disabledReason?: string; + entityType?: string; error?: string; - isEncryptDiskChecked: boolean; + isEncryptEntityChecked: boolean; onChange: (checked: boolean) => void; } -export const headerTestId = 'disk-encryption-header'; -export const descriptionTestId = 'disk-encryption-description'; -export const checkboxTestId = 'encrypt-disk-checkbox'; +export const headerTestId = 'encryption-header'; +export const descriptionTestId = 'encryption-description'; +export const checkboxTestId = 'encrypt-entity-checkbox'; -export const DiskEncryption = (props: DiskEncryptionProps) => { +export const Encryption = (props: EncryptionProps) => { const { descriptionCopy, disabled, disabledReason, + entityType, error, - isEncryptDiskChecked, + isEncryptEntityChecked, onChange, } = props; return ( <> - Disk Encryption + {`${entityType ?? 'Disk'} Encryption`} {error && ( @@ -51,11 +54,11 @@ export const DiskEncryption = (props: DiskEncryptionProps) => { flexDirection="row" > onChange(checked)} - text="Encrypt Disk" + text={`Encrypt ${entityType ?? 'Disk'}`} toolTipText={disabled ? disabledReason : ''} /> diff --git a/packages/manager/src/components/DiskEncryption/constants.tsx b/packages/manager/src/components/Encryption/constants.tsx similarity index 98% rename from packages/manager/src/components/DiskEncryption/constants.tsx rename to packages/manager/src/components/Encryption/constants.tsx index aff4981ad13..bdc3a7f0749 100644 --- a/packages/manager/src/components/DiskEncryption/constants.tsx +++ b/packages/manager/src/components/Encryption/constants.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { Link } from 'src/components/Link'; +/* Disk Encryption constants */ const DISK_ENCRYPTION_GUIDE_LINK = 'https://www.linode.com/docs/products/compute/compute-instances/guides/local-disk-encryption'; diff --git a/packages/manager/src/components/DiskEncryption/utils.ts b/packages/manager/src/components/Encryption/utils.ts similarity index 100% rename from packages/manager/src/components/DiskEncryption/utils.ts rename to packages/manager/src/components/Encryption/utils.ts diff --git a/packages/manager/src/components/EnhancedSelect/components/SelectControl.tsx b/packages/manager/src/components/EnhancedSelect/components/SelectControl.tsx index b57df52062c..590b194b65d 100644 --- a/packages/manager/src/components/EnhancedSelect/components/SelectControl.tsx +++ b/packages/manager/src/components/EnhancedSelect/components/SelectControl.tsx @@ -8,6 +8,7 @@ type Props = ControlProps; const SelectControl: React.FC = (props) => { return ( { const { entityType, message } = props; + const isSupportTicketError = supportTextRegex.test(message); - if (typeof message === 'string') { - return {message}; - } else { + if (isSupportTicketError) { return ( { /> ); } + + return {message}; }; diff --git a/packages/manager/src/components/ErrorState/ErrorState.tsx b/packages/manager/src/components/ErrorState/ErrorState.tsx index 11f007ddc55..97c9b02ef4b 100644 --- a/packages/manager/src/components/ErrorState/ErrorState.tsx +++ b/packages/manager/src/components/ErrorState/ErrorState.tsx @@ -1,6 +1,6 @@ import ErrorOutline from '@mui/icons-material/ErrorOutline'; import Grid from '@mui/material/Unstable_Grid2'; -import { styled, useTheme } from '@mui/material/styles'; +import { SxProps, Theme, styled, useTheme } from '@mui/material/styles'; import * as React from 'react'; import { Typography } from 'src/components/Typography'; @@ -24,10 +24,14 @@ export interface ErrorStateProps { * The error text to display. */ errorText: JSX.Element | string; + /** + * Styles applied to the error text + */ + typographySx?: SxProps; } export const ErrorState = (props: ErrorStateProps) => { - const { CustomIcon, compact } = props; + const { CustomIcon, compact, typographySx } = props; const theme = useTheme(); const sxIcon = { @@ -60,6 +64,7 @@ export const ErrorState = (props: ErrorStateProps) => { {props.errorText} diff --git a/packages/manager/src/components/IPSelect/IPSelect.tsx b/packages/manager/src/components/IPSelect/IPSelect.tsx index 21dbb13fd72..6831f0d2632 100644 --- a/packages/manager/src/components/IPSelect/IPSelect.tsx +++ b/packages/manager/src/components/IPSelect/IPSelect.tsx @@ -1,14 +1,19 @@ import * as React from 'react'; -import Select, { Item } from 'src/components/EnhancedSelect/Select'; +import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; import { useLinodeQuery } from 'src/queries/linodes/linodes'; +interface Option { + label: string; + value: string; +} + interface Props { - customizeOptions?: (options: Item[]) => Item[]; + customizeOptions?: (options: Option[]) => Option[]; errorText?: string; handleChange: (ip: string) => void; linodeId: number; - value: Item; + value: Option; } export const IPSelect = (props: Props) => { @@ -29,7 +34,7 @@ export const IPSelect = (props: Props) => { } // Create React-Select-friendly options. - let options: Item[] = ips.map((ip) => ({ label: ip, value: ip })); + let options = ips.map((ip) => ({ label: ip, value: ip })); // If a customizeOptions function was provided, apply it here. if (customizeOptions) { @@ -46,12 +51,12 @@ export const IPSelect = (props: Props) => { } return ( - handleSizeChange(value)} + onChange={(_, selected) => handleSizeChange(selected.value)} options={finalOptions} + textFieldProps={{ hideLabel: true, noMarginTop: true }} + value={defaultPagination} /> ) : null} diff --git a/packages/manager/src/components/PasswordInput/PasswordInput.tsx b/packages/manager/src/components/PasswordInput/PasswordInput.tsx index a8e092169f2..1168895d2f0 100644 --- a/packages/manager/src/components/PasswordInput/PasswordInput.tsx +++ b/packages/manager/src/components/PasswordInput/PasswordInput.tsx @@ -1,12 +1,12 @@ -import Grid from '@mui/material/Unstable_Grid2'; import * as React from 'react'; import zxcvbn from 'zxcvbn'; -import { TextFieldProps } from 'src/components/TextField'; - import { StrengthIndicator } from '../PasswordInput/StrengthIndicator'; +import { Stack } from '../Stack'; import { HideShowText } from './HideShowText'; +import type { TextFieldProps } from 'src/components/TextField'; + interface Props extends TextFieldProps { disabledReason?: JSX.Element | string; hideStrengthLabel?: boolean; @@ -26,25 +26,21 @@ const PasswordInput = (props: Props) => { const strength = React.useMemo(() => maybeStrength(value), [value]); return ( - - - - + + {!hideValidation && ( - - - + )} - + ); }; diff --git a/packages/manager/src/components/PrimaryNav/PrimaryNav.test.tsx b/packages/manager/src/components/PrimaryNav/PrimaryNav.test.tsx index 51101c393fe..6dc0e8622be 100644 --- a/packages/manager/src/components/PrimaryNav/PrimaryNav.test.tsx +++ b/packages/manager/src/components/PrimaryNav/PrimaryNav.test.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import { accountFactory } from 'src/factories'; -import { http, HttpResponse, server } from 'src/mocks/testServer'; +import { HttpResponse, http, server } from 'src/mocks/testServer'; import { queryClientFactory } from 'src/queries/base'; import { renderWithTheme, wrapWithTheme } from 'src/utilities/testHelpers'; @@ -71,62 +71,4 @@ describe('PrimaryNav', () => { expect(databaseNavItem).toBeVisible(); }); - - it('should show ACLB if the feature flag is on, but there is not an account capability', async () => { - const account = accountFactory.build({ - capabilities: [], - }); - - server.use( - http.get('*/account', () => { - return HttpResponse.json(account); - }) - ); - - const { findByText } = renderWithTheme(, { - flags: { aclb: true }, - }); - - const loadbalancerNavItem = await findByText('Cloud Load Balancers'); - - expect(loadbalancerNavItem).toBeVisible(); - }); - - it('should show ACLB if the feature flag is off, but the account has the capability', async () => { - const account = accountFactory.build({ - capabilities: ['Akamai Cloud Load Balancer'], - }); - - server.use( - http.get('*/account', () => { - return HttpResponse.json(account); - }) - ); - - const { findByText } = renderWithTheme(, { - flags: { aclb: false }, - }); - - const loadbalancerNavItem = await findByText('Cloud Load Balancers'); - - expect(loadbalancerNavItem).toBeVisible(); - }); - - it('should not show ACLB if the feature flag is off and there is no account capability', async () => { - const account = accountFactory.build({ - capabilities: [], - }); - - server.use( - http.get('*/account', () => { - return HttpResponse.json(account); - }) - ); - - const { queryByText } = renderWithTheme(, { - flags: { aclb: false }, - }); - - expect(queryByText('Cloud Load Balancers')).not.toBeInTheDocument(); - }); }); diff --git a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx index 180c6c5a32b..99530ef1ddb 100644 --- a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx +++ b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx @@ -12,7 +12,6 @@ import Firewall from 'src/assets/icons/entityIcons/firewall.svg'; import Image from 'src/assets/icons/entityIcons/image.svg'; import Kubernetes from 'src/assets/icons/entityIcons/kubernetes.svg'; import Linode from 'src/assets/icons/entityIcons/linode.svg'; -import LoadBalancer from 'src/assets/icons/entityIcons/loadbalancer.svg'; import Managed from 'src/assets/icons/entityIcons/managed.svg'; import NodeBalancer from 'src/assets/icons/entityIcons/nodebalancer.svg'; import OCA from 'src/assets/icons/entityIcons/oneclick.svg'; @@ -27,7 +26,6 @@ import { BetaChip } from 'src/components/BetaChip/BetaChip'; import { Box } from 'src/components/Box'; import { Divider } from 'src/components/Divider'; import { useIsDatabasesEnabled } from 'src/features/Databases/utilities'; -import { useIsACLBEnabled } from 'src/features/LoadBalancers/utils'; import { useIsPlacementGroupsEnabled } from 'src/features/PlacementGroups/utils'; import { useAccountManagement } from 'src/hooks/useAccountManagement'; import { useFlags } from 'src/hooks/useFlags'; @@ -38,7 +36,7 @@ import { } from 'src/queries/objectStorage'; import { useRegionsQuery } from 'src/queries/regions/regions'; import { useMarketplaceAppsQuery } from 'src/queries/stackscripts'; -import { isFeatureEnabled } from 'src/utilities/accountCapabilities'; +import { isFeatureEnabledV2 } from 'src/utilities/accountCapabilities'; import useStyles from './PrimaryNav.styles'; import { linkIsActive } from './utils'; @@ -104,7 +102,7 @@ export const PrimaryNav = (props: PrimaryNavProps) => { const { _isManagedAccount, account } = useAccountManagement(); - const isObjMultiClusterEnabled = isFeatureEnabled( + const isObjMultiClusterEnabled = isFeatureEnabledV2( 'Object Storage Access Key Regions', Boolean(flags.objMultiCluster), account?.capabilities ?? [] @@ -164,7 +162,6 @@ export const PrimaryNav = (props: PrimaryNavProps) => { // the followed comment is for later use, the showCloudPulse will be removed and isACLPEnabled will be used // const { isACLPEnabled } = useIsACLPEnabled(); - const { isACLBEnabled } = useIsACLBEnabled(); const { isPlacementGroupsEnabled } = useIsPlacementGroupsEnabled(); const { isDatabasesEnabled } = useIsDatabasesEnabled(); @@ -202,14 +199,6 @@ export const PrimaryNav = (props: PrimaryNavProps) => { href: '/volumes', icon: , }, - { - betaChipClassName: 'beta-chip-aclb', - display: 'Cloud Load Balancers', - hide: !isACLBEnabled, - href: '/loadbalancers', - icon: , - isBeta: true, - }, { display: 'NodeBalancers', href: '/nodebalancers', @@ -325,7 +314,6 @@ export const PrimaryNav = (props: PrimaryNavProps) => { allowObjPrefetch, allowMarketplacePrefetch, flags.databaseBeta, - isACLBEnabled, isPlacementGroupsEnabled, flags.placementGroups, showCloudPulse, diff --git a/packages/manager/src/components/RegionSelect/RegionMultiSelect.tsx b/packages/manager/src/components/RegionSelect/RegionMultiSelect.tsx index ca9a483438e..ea4fd248fd0 100644 --- a/packages/manager/src/components/RegionSelect/RegionMultiSelect.tsx +++ b/packages/manager/src/components/RegionSelect/RegionMultiSelect.tsx @@ -67,6 +67,7 @@ export const RegionMultiSelect = React.memo((props: RegionMultiSelectProps) => { selectedIds, sortRegionOptions, width, + disabledRegions: disabledRegionsFromProps, ...rest } = props; @@ -88,6 +89,9 @@ export const RegionMultiSelect = React.memo((props: RegionMultiSelectProps) => { const disabledRegions = regionOptions.reduce< Record >((acc, region) => { + if (disabledRegionsFromProps?.[region.id]) { + acc[region.id] = disabledRegionsFromProps[region.id]; + } if ( isRegionOptionUnavailable({ accountAvailabilityData: accountAvailability, diff --git a/packages/manager/src/components/RegionSelect/RegionSelect.types.ts b/packages/manager/src/components/RegionSelect/RegionSelect.types.ts index 0ace08a3194..0a394d1a833 100644 --- a/packages/manager/src/components/RegionSelect/RegionSelect.types.ts +++ b/packages/manager/src/components/RegionSelect/RegionSelect.types.ts @@ -76,6 +76,7 @@ export interface RegionMultiSelectProps selectedRegions: Region[]; }>; currentCapability: Capabilities | undefined; + disabledRegions?: Record; helperText?: string; isClearable?: boolean; label?: string; diff --git a/packages/manager/src/components/SelectRegionPanel/SelectRegionPanel.test.tsx b/packages/manager/src/components/SelectRegionPanel/SelectRegionPanel.test.tsx index 48d8a822850..6ec3a8d17ba 100644 --- a/packages/manager/src/components/SelectRegionPanel/SelectRegionPanel.test.tsx +++ b/packages/manager/src/components/SelectRegionPanel/SelectRegionPanel.test.tsx @@ -133,7 +133,7 @@ describe('SelectRegionPanel on the Clone Flow', () => { expect(getByTestId('different-price-structure-notice')).toBeInTheDocument(); }); - it('should disable distributed regions if the selected image does not have the `distributed-images` capability', async () => { + it('should disable distributed regions if the selected image does not have the `distributed-sites` capability', async () => { const image = imageFactory.build({ capabilities: [] }); const distributedRegion = regionFactory.build({ diff --git a/packages/manager/src/components/SupportError.tsx b/packages/manager/src/components/SupportError.tsx deleted file mode 100644 index 4436952c82d..00000000000 --- a/packages/manager/src/components/SupportError.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { useTheme } from '@mui/material/styles'; -import * as React from 'react'; - -import { SupportLink } from 'src/components/SupportLink'; -import { Typography } from 'src/components/Typography'; -import { capitalize } from 'src/utilities/capitalize'; - -import type { APIError } from '@linode/api-v4/lib/types'; - -interface Props { - errors: APIError[]; -} - -export const SupportError = (props: Props) => { - const theme = useTheme(); - const { errors } = props; - const supportTextRegex = new RegExp( - /(open a support ticket|contact Support)/i - ); - const errorMsg = errors[0].reason.split(supportTextRegex); - - return ( - - {errorMsg.map((substring: string, idx) => { - const openTicket = substring.match(supportTextRegex); - if (openTicket) { - return ( - - ); - } else { - return substring; - } - })} - - ); -}; diff --git a/packages/manager/src/components/SupportTicketGeneralError.tsx b/packages/manager/src/components/SupportTicketGeneralError.tsx index 5d49c71fd33..6e09556dc37 100644 --- a/packages/manager/src/components/SupportTicketGeneralError.tsx +++ b/packages/manager/src/components/SupportTicketGeneralError.tsx @@ -4,28 +4,28 @@ import React from 'react'; import { SupportLink } from 'src/components/SupportLink'; import { capitalize } from 'src/utilities/capitalize'; +import { supportTextRegex } from './ErrorMessage'; import { Typography } from './Typography'; import type { EntityType } from 'src/features/Support/SupportTickets/SupportTicketDialog'; interface SupportTicketGeneralErrorProps { entityType: EntityType; - generalError: JSX.Element; + generalError: string; } +const accountLimitRegex = /(limit|limit for the number of active services) on your account/i; + export const SupportTicketGeneralError = ( props: SupportTicketGeneralErrorProps ) => { const { entityType, generalError } = props; const theme = useTheme(); - const supportTextRegex = /(open a support ticket|contact Support)/i; - const reason: string = generalError.props.errors[0].reason; - const limitError = reason.split(supportTextRegex); + const limitError = generalError.split(supportTextRegex); // Determine whether we'll need to link to a specific support ticket form based on ticketType. - const accountLimitRegex = /(limit|limit for the number of active services) on your account/i; - const isAccountLimitSupportTicket = accountLimitRegex.test(reason); + const isAccountLimitSupportTicket = accountLimitRegex.test(generalError); return ( = { args: { @@ -25,12 +24,12 @@ export const Default: StoryObj = { render: (args) => { const TagsInputWrapper = () => { const [, setTags] = useArgs(); - const handleUpdateTags = (selected: Item[]) => { + const handleUpdateTags = (selected: Tag[]) => { return setTags({ value: selected }); }; return ( - + handleUpdateTags(selected)} diff --git a/packages/manager/src/components/TagsInput/TagsInput.test.tsx b/packages/manager/src/components/TagsInput/TagsInput.test.tsx index aac581ded5c..83c9934aa70 100644 --- a/packages/manager/src/components/TagsInput/TagsInput.test.tsx +++ b/packages/manager/src/components/TagsInput/TagsInput.test.tsx @@ -1,5 +1,5 @@ import * as tags from '@linode/api-v4/lib/tags/tags'; -import { fireEvent, waitFor } from '@testing-library/react'; +import { screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import * as React from 'react'; @@ -7,42 +7,58 @@ import { renderWithTheme } from 'src/utilities/testHelpers'; import { TagsInput } from './TagsInput'; -vi.mock('src/components/EnhancedSelect/Select'); -const mockGetTags = vi.spyOn(tags, 'getTags'); +const mockGetTags = vi.spyOn(tags, 'getTags').mockResolvedValue({ + data: [ + { label: 'tag-1', value: 'tag-1' }, + { label: 'tag-2', value: 'tag-2' }, + { label: 'tag-3', value: 'tag-3' }, + { label: 'tag-4', value: 'tag-4' }, + { label: 'tag-5', value: 'tag-5' }, + ], +}); describe('TagsInput', () => { const onChange = vi.fn(); it('sets account tags based on API request', async () => { - const { getByTestId, queryAllByTestId } = renderWithTheme( + renderWithTheme( ); - fireEvent.click(getByTestId('select')); - await waitFor(() => - expect(queryAllByTestId('mock-option')).toHaveLength(5) - ); + + userEvent.click(screen.getByRole('combobox')); + + await waitFor(() => expect(screen.getAllByText(/tag-/i)).toHaveLength(5)); await waitFor(() => expect(mockGetTags).toHaveBeenCalledTimes(1)); }); it('calls onChange handler when the value is updated', async () => { - const { findByTestId, queryAllByTestId } = renderWithTheme( + renderWithTheme( ); - await waitFor(() => - expect(queryAllByTestId('mock-option')).toHaveLength(5) - ); - userEvent.selectOptions(await findByTestId('select'), 'tag-2'); + const input = screen.getByRole('combobox'); + + // Typing 'new-tag' in the input field + userEvent.type(input, 'new-tag'); + + await waitFor(() => expect(input).toHaveValue('new-tag')); + + const createOption = screen.getByText('Create "new-tag"'); + + // Click 'Create "new-tag"' option to create a new-tag + userEvent.click(createOption); + // Wait for the onChange to be called with the updated value await waitFor(() => expect(onChange).toHaveBeenCalledWith([ - { label: 'tag-2', value: 'tag-2' }, + { label: 'mockValue', value: 'mockValue' }, + { label: 'new-tag', value: 'new-tag' }, ]) ); }); diff --git a/packages/manager/src/components/TagsInput/TagsInput.tsx b/packages/manager/src/components/TagsInput/TagsInput.tsx index 0d82a178a4e..49aa48fe435 100644 --- a/packages/manager/src/components/TagsInput/TagsInput.tsx +++ b/packages/manager/src/components/TagsInput/TagsInput.tsx @@ -1,12 +1,11 @@ +import CloseIcon from '@mui/icons-material/Close'; import { APIError } from '@linode/api-v4/lib/types'; import { useQueryClient } from '@tanstack/react-query'; import { concat } from 'ramda'; import * as React from 'react'; -import Select, { - Item, - NoOptionsMessageProps, -} from 'src/components/EnhancedSelect/Select'; +import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; +import { Chip } from 'src/components/Chip'; import { useProfile } from 'src/queries/profile/profile'; import { updateTagsSuggestionsData, useTagSuggestions } from 'src/queries/tags'; import { getErrorMap } from 'src/utilities/errorUtils'; @@ -46,7 +45,7 @@ export interface TagsInputProps { /** * Callback fired when the value changes. */ - onChange: (selected: Item[]) => void; + onChange: (selected: Tag[]) => void; /** * An error to display beneath the input. */ @@ -54,7 +53,7 @@ export interface TagsInputProps { /** * The value of the input. */ - value: Item[]; + value: Tag[]; } export const TagsInput = (props: TagsInputProps) => { @@ -62,8 +61,6 @@ export const TagsInput = (props: TagsInputProps) => { disabled, hideLabel, label, - menuPlacement, - name, noMarginTop, onChange, tagError, @@ -79,7 +76,7 @@ export const TagsInput = (props: TagsInputProps) => { const queryClient = useQueryClient(); - const accountTagItems: Item[] = + const accountTagItems: Tag[] = accountTags?.map((tag) => ({ label: tag.label, value: tag.label, @@ -105,13 +102,30 @@ export const TagsInput = (props: TagsInputProps) => { } }; - const getEmptyMessage = (value: NoOptionsMessageProps) => { - const { value: tags } = props; - if (tags.map((tag) => tag.value).includes(value.inputValue)) { - return 'This tag is already selected.'; - } else { - return 'No results.'; + const handleRemoveOption = (tagToRemove: Tag) => { + onChange(value.filter((t) => t.value !== tagToRemove.value)); + }; + + const filterOptions = ( + options: Tag[], + { inputValue }: { inputValue: string } + ) => { + const filtered = options.filter((o) => + o.label.toLowerCase().includes(inputValue.toLowerCase()) + ); + + const isExistingTag = options.some( + (o) => o.label.toLowerCase() === inputValue.toLowerCase() + ); + + if (inputValue !== '' && !isExistingTag) { + filtered.push({ + label: `Create "${inputValue}"`, + value: inputValue, + }); } + + return filtered; }; const errorMap = getErrorMap(['label'], errors); @@ -128,21 +142,43 @@ export const TagsInput = (props: TagsInputProps) => { : undefined); return ( - components. - const handleTransactionTypeChange = React.useCallback( - (item: Item) => { - setSelectedTransactionType(item.value); - pdfErrors.clear(); - pdfLoading.clear(); - }, - [pdfErrors, pdfLoading] - ); - - const handleTransactionDateChange = React.useCallback( - (item: Item) => { - setSelectedTransactionDate(item.value); - pdfErrors.clear(); - pdfLoading.clear(); - }, - [pdfErrors, pdfLoading] - ); - // Combine Invoices and Payments const combinedData = React.useMemo( () => [ @@ -330,78 +297,83 @@ export const BillingActivityPanel = (props: Props) => { // Filter on transaction type const filteredData = React.useMemo(() => { return combinedData.filter( - (thisBillingItem) => thisBillingItem.type === selectedTransactionType + (thisBillingItem) => + thisBillingItem.type === selectedTransactionType.value ); }, [selectedTransactionType, combinedData]); return ( -
+ - - {`${isAkamaiCustomer ? 'Usage' : 'Billing & Payment'} History`} - - {isAkamaiCustomer ? ( -
+
+ + {`${isAkamaiCustomer ? 'Usage' : 'Billing & Payment'} History`} + + {accountActiveSince ? ( + + Account active since{' '} + {formatDate(accountActiveSince, { + displayTime: false, + timezone: profile?.timezone, + })} + + ) : null} + {isAkamaiCustomer ? ( -
- ) : null} + ) : null} +
- {accountActiveSince && ( -
- - Account active since{' '} - {formatDate(accountActiveSince, { - displayTime: false, - timezone: profile?.timezone, - })} - -
- )} -
- thisOption.value === selectedTransactionDate - ) || null - } - className={classes.transactionDate} - hideLabel - inline - isClearable={false} - isSearchable={false} - label="Transaction Dates" - onChange={handleTransactionDateChange} - options={transactionDateOptions} - small - /> -
+ { + setSelectedTransactionType(item); + pdfErrors.clear(); + pdfLoading.clear(); + }} + value={transactionTypeOptions.find( + (option) => option.value === selectedTransactionType.value + )} + className={classes.transactionType} + disableClearable + label="Transaction Types" + noMarginTop + options={transactionTypeOptions} + /> + { + setSelectedTransactionDate(item); + pdfErrors.clear(); + pdfLoading.clear(); + }} + value={transactionDateOptions.find( + (option) => option.value === selectedTransactionDate.value + )} + className={classes.transactionDate} + disableClearable + label="Transaction Dates" + noMarginTop + options={transactionDateOptions} + />
@@ -486,16 +458,17 @@ export const BillingActivityPanel = (props: Props) => { )} -
+
); -}; +}); const StyledBillingAndPaymentHistoryHeader = styled('div', { name: 'BillingAndPaymentHistoryHeader', })(({ theme }) => ({ border: theme.name === 'dark' ? `1px solid ${theme.borderColors.divider}` : 0, borderBottom: 0, + padding: `15px 0px 15px 20px`, })); // ============================================================================= @@ -602,10 +575,10 @@ export const paymentToActivityFeedItem = ( * @returns ISO format beginning of the range date */ export const getCutoffFromDateRange = ( - range: DateRange, + range: TransactionDateOptions, currentDatetime?: string ): null | string => { - if (range === 'All Time') { + if (range === transactionDateOptions[5]) { return null; } @@ -613,19 +586,19 @@ export const getCutoffFromDateRange = ( let outputDate: DateTime; switch (range) { - case '30 Days': + case transactionDateOptions[0]: outputDate = date.minus({ days: 30 }); break; - case '60 Days': + case transactionDateOptions[1]: outputDate = date.minus({ days: 60 }); break; - case '90 Days': + case transactionDateOptions[2]: outputDate = date.minus({ days: 90 }); break; - case '6 Months': + case transactionDateOptions[3]: outputDate = date.minus({ months: 6 }); break; - case '12 Months': + case transactionDateOptions[4]: outputDate = date.minus({ months: 12 }); break; default: @@ -652,5 +625,3 @@ export const makeFilter = (endDate: null | string) => { return filter; }; - -export default React.memo(BillingActivityPanel); diff --git a/packages/manager/src/features/Billing/BillingPanels/BillingActivityPanel/index.ts b/packages/manager/src/features/Billing/BillingPanels/BillingActivityPanel/index.ts deleted file mode 100644 index ee79182bad0..00000000000 --- a/packages/manager/src/features/Billing/BillingPanels/BillingActivityPanel/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -import BillingActivityPanel from './BillingActivityPanel'; -export default BillingActivityPanel; diff --git a/packages/manager/src/features/CloudPulse/CloudPulseTabs.tsx b/packages/manager/src/features/CloudPulse/CloudPulseTabs.tsx index a6562db5204..ddcb223c17f 100644 --- a/packages/manager/src/features/CloudPulse/CloudPulseTabs.tsx +++ b/packages/manager/src/features/CloudPulse/CloudPulseTabs.tsx @@ -1,6 +1,6 @@ import { styled } from '@mui/material/styles'; import * as React from 'react'; -import { RouteComponentProps, matchPath } from 'react-router-dom'; +import { matchPath } from 'react-router-dom'; import { SuspenseLoader } from 'src/components/SuspenseLoader'; import { SafeTabPanel } from 'src/components/Tabs/SafeTabPanel'; @@ -8,7 +8,9 @@ import { TabLinkList } from 'src/components/Tabs/TabLinkList'; import { TabPanels } from 'src/components/Tabs/TabPanels'; import { Tabs } from 'src/components/Tabs/Tabs'; -import { DashboardLanding } from './Dashboard/DashboardLanding'; +import { CloudPulseDashboardLanding } from './Dashboard/CloudPulseDashboardLanding'; + +import type { RouteComponentProps } from 'react-router-dom'; type Props = RouteComponentProps<{}>; export const CloudPulseTabs = React.memo((props: Props) => { @@ -40,7 +42,7 @@ export const CloudPulseTabs = React.memo((props: Props) => { }> - + diff --git a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboard.tsx b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboard.tsx new file mode 100644 index 00000000000..5790a15d0f3 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboard.tsx @@ -0,0 +1,241 @@ +import { Grid, Paper } from '@mui/material'; +import React from 'react'; + +import CloudPulseIcon from 'src/assets/icons/entityIcons/cv_overview.svg'; +import { CircleProgress } from 'src/components/CircleProgress'; +import { ErrorState } from 'src/components/ErrorState/ErrorState'; +import { Placeholder } from 'src/components/Placeholder/Placeholder'; +import { useCloudPulseDashboardByIdQuery } from 'src/queries/cloudpulse/dashboards'; +import { useResourcesQuery } from 'src/queries/cloudpulse/resources'; +import { + useCloudPulseJWEtokenQuery, + useGetCloudPulseMetricDefinitionsByServiceType, +} from 'src/queries/cloudpulse/services'; + +import { getUserPreferenceObject } from '../Utils/UserPreference'; +import { createObjectCopy } from '../Utils/utils'; +import { CloudPulseWidget } from '../Widget/CloudPulseWidget'; +import { + all_interval_options, + getInSeconds, + getIntervalIndex, +} from '../Widget/components/CloudPulseIntervalSelect'; + +import type { CloudPulseWidgetProperties } from '../Widget/CloudPulseWidget'; +import type { + AvailableMetrics, + Dashboard, + JWETokenPayLoad, + TimeDuration, + Widgets, +} from '@linode/api-v4'; + +export interface DashboardProperties { + /** + * Id of the selected dashboard + */ + dashboardId: number; + + /** + * time duration to fetch the metrics data in this widget + */ + duration: TimeDuration; + + /** + * optional timestamp to pass as react query param to forcefully re-fetch data + */ + manualRefreshTimeStamp?: number | undefined; + + /** + * Selected region for the dashboard + */ + region?: string; + + /** + * Selected resources for the dashboard + */ + resources: string[]; + + /** + * optional flag to check whether changes should be stored in preferences or not (in case this component is reused) + */ + savePref?: boolean; +} + +export const CloudPulseDashboard = (props: DashboardProperties) => { + const { + dashboardId, + duration, + manualRefreshTimeStamp, + resources, + savePref, + } = props; + + const getJweTokenPayload = (): JWETokenPayLoad => { + return { + resource_id: resourceList?.map((resource) => String(resource.id)) ?? [], + }; + }; + + const getCloudPulseGraphProperties = ( + widget: Widgets + ): CloudPulseWidgetProperties => { + const graphProp: CloudPulseWidgetProperties = { + ariaLabel: widget.label, + authToken: '', + availableMetrics: undefined, + duration, + errorLabel: 'Error While Loading Data', + resourceIds: resources, + resources: [], + serviceType: dashboard?.service_type ?? '', + timeStamp: manualRefreshTimeStamp, + unit: widget.unit ?? '%', + widget: { ...widget }, + }; + if (savePref) { + setPreferredWidgetPlan(graphProp.widget); + } + return graphProp; + }; + + const setPreferredWidgetPlan = (widgetObj: Widgets) => { + const widgetPreferences = getUserPreferenceObject().widgets; + const pref = widgetPreferences?.[widgetObj.label]; + if (pref) { + Object.assign(widgetObj, { + aggregate_function: pref.aggregateFunction, + size: pref.size, + time_granularity: { ...pref.timeGranularity }, + }); + } + }; + + const getTimeGranularity = (scrapeInterval: string) => { + const scrapeIntervalValue = getInSeconds(scrapeInterval); + const index = getIntervalIndex(scrapeIntervalValue); + return index < 0 ? all_interval_options[0] : all_interval_options[index]; + }; + + const { + data: dashboard, + isLoading: isDashboardLoading, + } = useCloudPulseDashboardByIdQuery(dashboardId); + + const { + data: resourceList, + isLoading: isResourcesLoading, + } = useResourcesQuery( + Boolean(dashboard?.service_type), + dashboard?.service_type, + {}, + {} + ); + + const { + data: metricDefinitions, + isError: isMetricDefinitionError, + isLoading: isMetricDefinitionLoading, + } = useGetCloudPulseMetricDefinitionsByServiceType( + dashboard?.service_type, + Boolean(dashboard?.service_type) + ); + + const { + data: jweToken, + isError: isJweTokenError, + } = useCloudPulseJWEtokenQuery( + dashboard?.service_type, + getJweTokenPayload(), + Boolean(resourceList) + ); + + if (isJweTokenError) { + return ( + + + + ); + } + + if (isMetricDefinitionLoading || isDashboardLoading || isResourcesLoading) { + return ; + } + + if (isMetricDefinitionError) { + return ; + } + + const RenderWidgets = () => { + if (!dashboard || Boolean(dashboard.widgets?.length)) { + return renderPlaceHolder( + 'No visualizations are available at this moment. Create Dashboards to list here.' + ); + } + + if ( + !dashboard.service_type || + !Boolean(resources.length > 0) || + !jweToken?.token || + !Boolean(resourceList?.length) + ) { + return renderPlaceHolder( + 'Select Dashboard, Region and Resource to visualize metrics' + ); + } + + // maintain a copy + const newDashboard: Dashboard = createObjectCopy(dashboard)!; + return ( + + {{ ...newDashboard }.widgets.map((widget, index) => { + // check if widget metric definition is available or not + if (widget) { + // find the metric defintion of the widget label + const availMetrics = metricDefinitions?.data.find( + (availMetrics: AvailableMetrics) => + widget.label === availMetrics.label + ); + const cloudPulseWidgetProperties = getCloudPulseGraphProperties({ + ...widget, + }); + + // metric definition is available but time_granularity is not present + if ( + availMetrics && + !cloudPulseWidgetProperties.widget.time_granularity + ) { + cloudPulseWidgetProperties.widget.time_granularity = getTimeGranularity( + availMetrics.scrape_interval + ); + } + return ( + + ); + } else { + return ; + } + })} + + ); + }; + + const renderPlaceHolder = (subtitle: string) => { + return ( + + + + + + ); + }; + + return ; +}; diff --git a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardLanding.tsx b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardLanding.tsx new file mode 100644 index 00000000000..167160d62c1 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardLanding.tsx @@ -0,0 +1,37 @@ +import { Grid, Paper } from '@mui/material'; +import * as React from 'react'; + +import { CircleProgress } from 'src/components/CircleProgress'; + +import { GlobalFilters } from '../Overview/GlobalFilters'; +import { useLoadUserPreferences } from '../Utils/UserPreference'; +import { CloudPulseDashboard } from './CloudPulseDashboard'; + +import type { FiltersObject } from '../Overview/GlobalFilters'; +import type { TimeDuration } from '@linode/api-v4'; + +export const CloudPulseDashboardLanding = () => { + // eslint-disable-next-line @typescript-eslint/no-empty-function + const onFilterChange = React.useCallback((_filters: FiltersObject) => {}, []); + const { isLoading } = useLoadUserPreferences(); + + if (isLoading) { + return ; + } + return ( + + + + + + + + + ); +}; diff --git a/packages/manager/src/features/CloudPulse/Dashboard/DashboardLanding.tsx b/packages/manager/src/features/CloudPulse/Dashboard/DashboardLanding.tsx deleted file mode 100644 index f1cc664825b..00000000000 --- a/packages/manager/src/features/CloudPulse/Dashboard/DashboardLanding.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { Paper } from '@mui/material'; -import * as React from 'react'; - -import { CircleProgress } from 'src/components/CircleProgress'; - -import { GlobalFilters } from '../Overview/GlobalFilters'; -import { useLoadUserPreferences } from '../Utils/UserPreference'; - -import type { FiltersObject } from '../Overview/GlobalFilters'; - -export const DashboardLanding = () => { - // eslint-disable-next-line @typescript-eslint/no-empty-function - const onFilterChange = React.useCallback((_filters: FiltersObject) => {}, []); - const { isLoading } = useLoadUserPreferences(); - - if (isLoading) { - return ; - } - - return ( - -
-
- -
-
-
- ); -}; diff --git a/packages/manager/src/features/CloudPulse/Overview/GlobalFilters.tsx b/packages/manager/src/features/CloudPulse/Overview/GlobalFilters.tsx index 302718c518f..8d89447db6c 100644 --- a/packages/manager/src/features/CloudPulse/Overview/GlobalFilters.tsx +++ b/packages/manager/src/features/CloudPulse/Overview/GlobalFilters.tsx @@ -1,8 +1,10 @@ import { Dashboard } from '@linode/api-v4'; +import { IconButton, Tooltip } from '@mui/material'; import { styled } from '@mui/material/styles'; import Grid from '@mui/material/Unstable_Grid2'; import * as React from 'react'; +import Reload from 'src/assets/icons/reload.svg'; import { WithStartAndEnd } from 'src/features/Longview/request.types'; import { CloudPulseDashboardSelect } from '../shared/CloudPulseDashboardSelect'; @@ -81,6 +83,8 @@ export const GlobalFilters = React.memo((props: GlobalFilterProperties) => { [] ); + const handleGlobalRefresh = React.useCallback(() => {}, []); + return ( @@ -110,6 +114,13 @@ export const GlobalFilters = React.memo((props: GlobalFilterProperties) => { label="Select Time Range" /> + + + + + + + ); @@ -146,3 +157,14 @@ const itemSpacing = { boxSizing: 'border-box', margin: '0', }; + +const StyledReload = styled(Reload, { label: 'StyledReload' })(({ theme }) => ({ + '&:active': { + color: `${theme.palette.success}`, + }, + '&:hover': { + cursor: 'pointer', + }, + height: '27px', + width: '27px', +})); diff --git a/packages/manager/src/features/CloudPulse/Utils/UserPreference.ts b/packages/manager/src/features/CloudPulse/Utils/UserPreference.ts index 956f7a2f1e4..175a0d0f2e8 100644 --- a/packages/manager/src/features/CloudPulse/Utils/UserPreference.ts +++ b/packages/manager/src/features/CloudPulse/Utils/UserPreference.ts @@ -3,7 +3,7 @@ import { usePreferences, } from 'src/queries/profile/preferences'; -import type { AclpConfig } from '@linode/api-v4'; +import type { AclpConfig, AclpWidget } from '@linode/api-v4'; let userPreference: AclpConfig; let timerId: ReturnType; @@ -47,6 +47,27 @@ export const updateGlobalFilterPreference = (data: {}) => { debounce(userPreference); }; +export const updateWidgetPreference = ( + label: string, + data: Partial +) => { + if (!userPreference) { + userPreference = {} as AclpConfig; + } + + if (!userPreference.widgets) { + userPreference.widgets = {}; + } + + userPreference.widgets[label] = { + ...userPreference.widgets[label], + label, + ...data, + }; + + debounce(userPreference); +}; + // to avoid frequent preference update calls within 500 ms interval const debounce = (updatedData: AclpConfig) => { if (timerId) { diff --git a/packages/manager/src/features/CloudPulse/utils.ts b/packages/manager/src/features/CloudPulse/Utils/utils.ts similarity index 57% rename from packages/manager/src/features/CloudPulse/utils.ts rename to packages/manager/src/features/CloudPulse/Utils/utils.ts index 62403c09d09..eb18278833a 100644 --- a/packages/manager/src/features/CloudPulse/utils.ts +++ b/packages/manager/src/features/CloudPulse/Utils/utils.ts @@ -18,3 +18,23 @@ export const useIsACLPEnabled = (): { return { isACLPEnabled }; }; + +export const convertStringToCamelCasesWithSpaces = ( + nonFormattedString: string +): string => { + return nonFormattedString + ?.split(' ') + .map((text) => text.charAt(0).toUpperCase() + text.slice(1)) + .join(' '); +}; + +export const createObjectCopy = (object: T): T | null => { + if (!object) { + return null; + } + try { + return JSON.parse(JSON.stringify(object)); + } catch (e) { + return null; + } +}; diff --git a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx new file mode 100644 index 00000000000..b89f55891d2 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx @@ -0,0 +1,248 @@ +import { Box, Grid, Paper, Stack, Typography } from '@mui/material'; +import React from 'react'; + +import { Divider } from 'src/components/Divider'; +import { LineGraph } from 'src/components/LineGraph/LineGraph'; +import { useProfile } from 'src/queries/profile/profile'; + +import { AGGREGATE_FUNCTION, SIZE, TIME_GRANULARITY } from '../Utils/constants'; +import { + getUserPreferenceObject, + updateWidgetPreference, +} from '../Utils/UserPreference'; +import { convertStringToCamelCasesWithSpaces } from '../Utils/utils'; +import { CloudPulseAggregateFunction } from './components/CloudPulseAggregateFunction'; +import { CloudPulseIntervalSelect } from './components/CloudPulseIntervalSelect'; +import { ZoomIcon } from './components/Zoomer'; + +import type { CloudPulseResources } from '../shared/CloudPulseResourcesSelect'; +import type { + AvailableMetrics, + TimeDuration, + TimeGranularity, +} from '@linode/api-v4'; +import type { Widgets } from '@linode/api-v4'; + +export interface CloudPulseWidgetProperties { + /** + * Aria label for this widget + */ + ariaLabel?: string; + + /** + * token to fetch metrics data + */ + authToken: string; + + /** + * metrics defined of this widget + */ + availableMetrics: AvailableMetrics | undefined; + + /** + * time duration to fetch the metrics data in this widget + */ + duration: TimeDuration; + + /** + * Any error to be shown in this widget + */ + errorLabel?: string; + + /** + * resources ids selected by user to show metrics for + */ + resourceIds: string[]; + + /** + * List of resources available of selected service type + */ + resources: CloudPulseResources[]; + + /** + * optional flag to check whether changes should be stored in preferences or not (in case this component is reused) + */ + savePref?: boolean; + + /** + * Service type selected by user + */ + serviceType: string; + + /** + * optional timestamp to pass as react query param to forcefully re-fetch data + */ + timeStamp?: number; + + /** + * this should come from dashboard, which maintains map for service types in a separate API call + */ + unit: string; + + /** + * color index to be selected from available them if not theme is provided by user + */ + useColorIndex?: number; + + /** + * this comes from dashboard, has inbuilt metrics, agg_func,group_by,filters,gridsize etc , also helpful in publishing any changes + */ + widget: Widgets; +} + +export const CloudPulseWidget = (props: CloudPulseWidgetProperties) => { + const { data: profile } = useProfile(); + + const timezone = profile?.timezone ?? 'US/Eastern'; + + const [widget, setWidget] = React.useState({ ...props.widget }); + + const { availableMetrics, savePref } = props; + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [today, _] = React.useState(false); // Temporarily disabled eslint for this line. Will be removed in future PRs + + /** + * + * @param zoomInValue: True if zoom in clicked & False if zoom out icon clicked + */ + const handleZoomToggle = React.useCallback((zoomInValue: boolean) => { + if (savePref) { + updateWidgetPreference(widget.label, { + [SIZE]: zoomInValue ? 12 : 6, + }); + } + + setWidget((currentWidget) => { + return { + ...currentWidget, + size: zoomInValue ? 12 : 6, + }; + }); + }, []); + + /** + * + * @param aggregateValue: aggregate function select from AggregateFunction component + */ + const handleAggregateFunctionChange = React.useCallback( + (aggregateValue: string) => { + // To avoid updation if user again selected the currently selected value from drop down. + if (aggregateValue !== widget.aggregate_function) { + if (savePref) { + updateWidgetPreference(widget.label, { + [AGGREGATE_FUNCTION]: aggregateValue, + }); + } + + setWidget((currentWidget) => { + return { + ...currentWidget, + aggregate_function: aggregateValue, + }; + }); + } + }, + [] + ); + + /** + * + * @param intervalValue : TimeGranularity object selected from the interval select + */ + const handleIntervalChange = React.useCallback( + (intervalValue: TimeGranularity) => { + if ( + !widget.time_granularity || + intervalValue.unit !== widget.time_granularity.unit || + intervalValue.value !== widget.time_duration.value + ) { + if (savePref) { + updateWidgetPreference(widget.label, { + [TIME_GRANULARITY]: { ...intervalValue }, + }); + } + + setWidget((currentWidget) => { + return { + ...currentWidget, + time_granularity: { ...intervalValue }, + }; + }); + } + }, + [] + ); + // Update the widget preference if already not present in the preferences + React.useEffect(() => { + if (savePref) { + const widgets = getUserPreferenceObject()?.widgets; + if (!widgets || !widgets[widget.label]) { + updateWidgetPreference(widget.label, { + [AGGREGATE_FUNCTION]: widget.aggregate_function, + [SIZE]: widget.size, + [TIME_GRANULARITY]: widget.time_granularity, + }); + } + } + }, []); + + return ( + + + + + + {convertStringToCamelCasesWithSpaces(widget.label)}{' '} + + + {availableMetrics?.scrape_interval && ( + + )} + {Boolean( + availableMetrics?.available_aggregate_functions?.length + ) && ( + + )} + + + + + + + + + + + + + ); +}; diff --git a/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseAggregateFunction.test.tsx b/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseAggregateFunction.test.tsx new file mode 100644 index 00000000000..e40a09c699e --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseAggregateFunction.test.tsx @@ -0,0 +1,39 @@ +import { fireEvent, screen } from '@testing-library/react'; +import React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { CloudPulseAggregateFunction } from './CloudPulseAggregateFunction'; + +import type { AggregateFunctionProperties } from './CloudPulseAggregateFunction'; + +const aggregateFunctionChange = (_selectedAggregateFunction: string) => {}; +const availableAggregateFunctions = ['max', 'min', 'avg']; +const defaultAggregateFunction = 'avg'; + +const props: AggregateFunctionProperties = { + availableAggregateFunctions, + defaultAggregateFunction, + onAggregateFuncChange: aggregateFunctionChange, +}; + +describe('Cloud Pulse Aggregate Function', () => { + it('should check for the selected value in aggregate function dropdown', () => { + const { getByRole } = renderWithTheme( + + ); + + const dropdown = getByRole('combobox'); + + expect(dropdown).toHaveAttribute('value', defaultAggregateFunction); + }); + + it('should select the aggregate function on click', () => { + renderWithTheme(); + + fireEvent.click(screen.getByRole('button', { name: 'Open' })); + fireEvent.click(screen.getByRole('option', { name: 'min' })); + + expect(screen.getByRole('combobox')).toHaveAttribute('value', 'min'); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseAggregateFunction.tsx b/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseAggregateFunction.tsx new file mode 100644 index 00000000000..ca63afef004 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseAggregateFunction.tsx @@ -0,0 +1,57 @@ +import React from 'react'; + +import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; + +export interface AggregateFunctionProperties { + /** + * List of aggregate functions available to display + */ + availableAggregateFunctions: string[]; + + /** + * Default aggregate function to be selected + */ + defaultAggregateFunction?: string | undefined; + + /** + * Function to be triggered on aggregate function changed from dropdown + */ + onAggregateFuncChange: any; +} + +export const CloudPulseAggregateFunction = React.memo( + (props: AggregateFunctionProperties) => { + // Convert list of availableAggregateFunc into a proper response structure accepted by Autocomplete component + const availableAggregateFunc = props.availableAggregateFunctions?.map( + (aggrFunc) => { + return { + label: aggrFunc, + value: aggrFunc, + }; + } + ); + + const defaultAggregateFunc = + availableAggregateFunc.find( + (obj) => obj.label === props.defaultAggregateFunction + ) || props.availableAggregateFunctions[0]; + + return ( + { + return option.label == value.label; + }} + onChange={(_: any, selectedAggregateFunc: any) => { + props.onAggregateFuncChange(selectedAggregateFunc.label); + }} + defaultValue={defaultAggregateFunc} + disableClearable + fullWidth={false} + label="" + noMarginTop={true} + options={availableAggregateFunc} + sx={{ width: '100%' }} + /> + ); + } +); diff --git a/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseIntervalSelect.test.tsx b/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseIntervalSelect.test.tsx new file mode 100644 index 00000000000..96023a33da8 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseIntervalSelect.test.tsx @@ -0,0 +1,28 @@ +import React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { CloudPulseIntervalSelect } from './CloudPulseIntervalSelect'; + +import type { TimeGranularity } from '@linode/api-v4'; + +describe('Interval select component', () => { + const intervalSelectionChange = (_selectedInterval: TimeGranularity) => {}; + + it('should check for the selected value in interval select dropdown', () => { + const scrape_interval = '30s'; + const default_interval = { unit: 'min', value: 5 }; + + const { getByRole } = renderWithTheme( + + ); + + const dropdown = getByRole('combobox'); + + expect(dropdown).toHaveAttribute('value', '5 min'); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseIntervalSelect.tsx b/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseIntervalSelect.tsx new file mode 100644 index 00000000000..01aef34b1ee --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseIntervalSelect.tsx @@ -0,0 +1,132 @@ +import React from 'react'; + +import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; + +import type { TimeGranularity } from '@linode/api-v4'; + +export interface IntervalSelectProperties { + /** + * Default time granularity to be selected + */ + default_interval?: TimeGranularity | undefined; + + /** + * Function to be triggered on aggregate function changed from dropdown + */ + onIntervalChange: any; + + /** + * scrape intervalto filter out minimum time granularity + */ + scrape_interval: string; +} + +export const getInSeconds = (interval: string) => { + if (interval.endsWith('s')) { + return Number(interval.slice(0, -1)); + } + if (interval.endsWith('m')) { + return Number(interval.slice(0, -1)) * 60; + } + if (interval.endsWith('h')) { + return Number(interval.slice(0, -1)) * 3600; + } + if (interval.endsWith('d')) { + return Number(interval.slice(0, -1)) * 86400; + } + return 0; + // month and year cases to be added if required +}; + +// Intervals must be in ascending order here +export const all_interval_options = [ + { + label: '1 min', + unit: 'min', + value: 1, + }, + { + label: '5 min', + unit: 'min', + value: 5, + }, + { + label: '1 hr', + unit: 'hr', + value: 1, + }, + { + label: '1 day', + unit: 'day', + value: 1, + }, +]; + +const autoIntervalOption = { + label: 'Auto', + unit: 'Auto', + value: -1, +}; + +export const getIntervalIndex = (scrapeIntervalValue: number) => { + return all_interval_options.findIndex( + (interval) => + scrapeIntervalValue <= + getInSeconds(String(interval.value) + interval.unit.slice(0, 1)) + ); +}; + +export const CloudPulseIntervalSelect = React.memo( + (props: IntervalSelectProperties) => { + const scrapeIntervalValue = getInSeconds(props.scrape_interval); + + const firstIntervalIndex = getIntervalIndex(scrapeIntervalValue); + + // all intervals displayed if srape interval > highest available interval. Error handling done by api + const available_interval_options = + firstIntervalIndex < 0 + ? all_interval_options.slice() + : all_interval_options.slice( + firstIntervalIndex, + all_interval_options.length + ); + + let default_interval = + props.default_interval?.unit === 'Auto' + ? autoIntervalOption + : available_interval_options.find( + (obj) => + obj.value === props.default_interval?.value && + obj.unit === props.default_interval?.unit + ); + + if (!default_interval) { + default_interval = autoIntervalOption; + props.onIntervalChange({ + unit: default_interval.unit, + value: default_interval.value, + }); + } + + return ( + { + return option?.value === value?.value && option?.unit === value?.unit; + }} + onChange={(_: any, selectedInterval: any) => { + props.onIntervalChange({ + unit: selectedInterval?.unit, + value: selectedInterval?.value, + }); + }} + defaultValue={{ ...default_interval }} + disableClearable + fullWidth={false} + label="" + noMarginTop={true} + options={[autoIntervalOption, ...available_interval_options]} + sx={{ width: { xs: '100%' } }} + /> + ); + } +); diff --git a/packages/manager/src/features/CloudPulse/Widget/components/Zoomer.test.tsx b/packages/manager/src/features/CloudPulse/Widget/components/Zoomer.test.tsx new file mode 100644 index 00000000000..6b6f327c3be --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Widget/components/Zoomer.test.tsx @@ -0,0 +1,28 @@ +import React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { ZoomIcon } from './Zoomer'; + +import type { ZoomIconProperties } from './Zoomer'; + +describe('Cloud Pulse Zoomer', () => { + it('Should render zoomer with zoom-out button', () => { + const props: ZoomIconProperties = { + handleZoomToggle: (_zoomInValue: boolean) => {}, + zoomIn: false, + }; + const { getByTestId } = renderWithTheme(); + + expect(getByTestId('zoom-out')).toBeInTheDocument(); + }), + it('Should render zoomer with zoom-in button', () => { + const props: ZoomIconProperties = { + handleZoomToggle: (_zoomInValue: boolean) => {}, + zoomIn: true, + }; + const { getByTestId } = renderWithTheme(); + + expect(getByTestId('zoom-in')).toBeInTheDocument(); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Widget/components/Zoomer.tsx b/packages/manager/src/features/CloudPulse/Widget/components/Zoomer.tsx new file mode 100644 index 00000000000..6ed093ed513 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Widget/components/Zoomer.tsx @@ -0,0 +1,37 @@ +import ZoomInMap from '@mui/icons-material/ZoomInMap'; +import ZoomOutMap from '@mui/icons-material/ZoomOutMap'; +import * as React from 'react'; + +export interface ZoomIconProperties { + className?: string; + handleZoomToggle: (zoomIn: boolean) => void; + zoomIn: boolean; +} + +export const ZoomIcon = React.memo((props: ZoomIconProperties) => { + const handleClick = (needZoomIn: boolean) => { + props.handleZoomToggle(needZoomIn); + }; + + const ToggleZoomer = () => { + if (props.zoomIn) { + return ( + handleClick(false)} + style={{ color: 'grey', fontSize: 'x-large' }} + /> + ); + } + + return ( + handleClick(true)} + style={{ color: 'grey', fontSize: 'x-large' }} + /> + ); + }; + + return ; +}); diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardSelect.test.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardSelect.test.tsx index 09d826f6351..c3b58a27ec4 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardSelect.test.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardSelect.test.tsx @@ -16,18 +16,18 @@ const props: CloudPulseDashboardSelectProps = { }; const queryMocks = vi.hoisted(() => ({ - useCloudViewDashboardsQuery: vi.fn().mockReturnValue({}), + useCloudPulseDashboardsQuery: vi.fn().mockReturnValue({}), })); vi.mock('src/queries/cloudpulse/dashboards', async () => { const actual = await vi.importActual('src/queries/cloudpulse/dashboards'); return { ...actual, - useCloudViewDashboardsQuery: queryMocks.useCloudViewDashboardsQuery, + useCloudPulseDashboardsQuery: queryMocks.useCloudPulseDashboardsQuery, }; }); -queryMocks.useCloudViewDashboardsQuery.mockReturnValue({ +queryMocks.useCloudPulseDashboardsQuery.mockReturnValue({ data: { data: [ { @@ -51,7 +51,7 @@ describe('CloudPulse Dashboard select', () => { ); - expect(getByTestId('cloudview-dashboard-select')).toBeInTheDocument(); + expect(getByTestId('cloudpulse-dashboard-select')).toBeInTheDocument(); expect(getByPlaceholderText('Select a Dashboard')).toBeInTheDocument(); }), it('Should render dashboard select component with data', () => { diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardSelect.tsx index f8efd3abfa5..f7eaa1c3dc2 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardSelect.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardSelect.tsx @@ -4,7 +4,7 @@ import React from 'react'; import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; import { Box } from 'src/components/Box'; import { Typography } from 'src/components/Typography'; -import { useCloudViewDashboardsQuery } from 'src/queries/cloudpulse/dashboards'; +import { useCloudPulseDashboardsQuery } from 'src/queries/cloudpulse/dashboards'; import { DASHBOARD_ID, REGION, RESOURCES } from '../Utils/constants'; import { @@ -25,7 +25,7 @@ export const CloudPulseDashboardSelect = React.memo( data: dashboardsList, error, isLoading, - } = useCloudViewDashboardsQuery(true); // Fetch the list of dashboards + } = useCloudPulseDashboardsQuery(true); // Fetch the list of dashboards const [ selectedDashboard, @@ -85,7 +85,7 @@ export const CloudPulseDashboardSelect = React.memo( )} autoHighlight clearOnBlur - data-testid="cloudview-dashboard-select" + data-testid="cloudpulse-dashboard-select" disabled={!dashboardsList} errorText={errorText} fullWidth diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.test.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.test.tsx index 7779224595f..07de1dea75d 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.test.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.test.tsx @@ -13,7 +13,7 @@ const props: CloudPulseRegionSelectProps = { selectedDashboard: undefined, }; -describe('CloudViewRegionSelect', () => { +describe('CloudPulseRegionSelect', () => { vi.spyOn(regions, 'useRegionsQuery').mockReturnValue({ data: Array(), } as ReturnType); diff --git a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.tsx b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.tsx index 36a598f3b4e..35127bcd302 100644 --- a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.tsx +++ b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.tsx @@ -1,15 +1,4 @@ -import { - ClusterSize, - ComprehensiveReplicationType, - CreateDatabasePayload, - DatabaseClusterSizeObject, - DatabaseEngine, - DatabasePriceObject, - Engine, -} from '@linode/api-v4/lib/databases/types'; -import { APIError } from '@linode/api-v4/lib/types'; import { createDatabaseSchema } from '@linode/validation/lib/databases.schema'; -import { Theme } from '@mui/material/styles'; import Grid from '@mui/material/Unstable_Grid2'; import { useFormik } from 'formik'; import { groupBy } from 'ramda'; @@ -25,7 +14,8 @@ import { Button } from 'src/components/Button/Button'; import { CircleProgress } from 'src/components/CircleProgress'; import { Divider } from 'src/components/Divider'; import { _SingleValue } from 'src/components/EnhancedSelect/components/SingleValue'; -import Select, { Item } from 'src/components/EnhancedSelect/Select'; +import Select from 'src/components/EnhancedSelect/Select'; +import { ErrorMessage } from 'src/components/ErrorMessage'; import { ErrorState } from 'src/components/ErrorState/ErrorState'; import { FormControl } from 'src/components/FormControl'; import { FormControlLabel } from 'src/components/FormControlLabel'; @@ -55,14 +45,23 @@ import { useRegionsQuery } from 'src/queries/regions/regions'; import { formatStorageUnits } from 'src/utilities/formatStorageUnits'; import { handleAPIErrors } from 'src/utilities/formikErrorUtils'; import { getSelectedOptionFromGroupedOptions } from 'src/utilities/getSelectedOptionFromGroupedOptions'; -import { - ExtendedIP, - ipFieldPlaceholder, - validateIPs, -} from 'src/utilities/ipUtils'; +import { ipFieldPlaceholder, validateIPs } from 'src/utilities/ipUtils'; import { scrollErrorIntoViewV2 } from 'src/utilities/scrollErrorIntoViewV2'; +import type { + ClusterSize, + ComprehensiveReplicationType, + CreateDatabasePayload, + DatabaseClusterSizeObject, + DatabaseEngine, + DatabasePriceObject, + Engine, +} from '@linode/api-v4/lib/databases/types'; +import type { APIError } from '@linode/api-v4/lib/types'; +import type { Theme } from '@mui/material/styles'; +import type { Item } from 'src/components/EnhancedSelect/Select'; import type { PlanSelectionType } from 'src/features/components/PlansPanel/types'; +import type { ExtendedIP } from 'src/utilities/ipUtils'; const useStyles = makeStyles()((theme: Theme) => ({ btnCtn: { @@ -461,7 +460,11 @@ const DatabaseCreate = () => { title="Create" /> - {createError ? : null} + {createError && ( + + + + )} Name Your Cluster { const { data: profile } = useProfile(); @@ -63,12 +66,25 @@ export const CreateDomain = () => { const history = useHistory(); - const [defaultRecordsSetting, setDefaultRecordsSetting] = React.useState< - Item - >({ - label: 'Do not insert default records for me.', - value: 'none', - }); + const defaultRecords: DefaultRecordsSetting[] = [ + { + label: 'Do not insert default records for me.', + value: 'none', + }, + { + label: 'Insert default records from one of my Linodes.', + value: 'linode', + }, + { + label: 'Insert default records from one of my NodeBalancers.', + value: 'nodebalancer', + }, + ]; + + const [ + defaultRecordsSetting, + setDefaultRecordsSetting, + ] = React.useState(defaultRecords[0]); const [selectedDefaultLinode, setSelectedDefaultLinode] = React.useState< Linode | undefined @@ -358,29 +374,15 @@ export const CreateDomain = () => { )} {isCreatingPrimaryDomain && ( - fn(e.value)} + onChange={(_, selected) => fn(selected.value)} options={MSSelectOptions} + value={defaultOption} /> ); }; @@ -364,17 +364,17 @@ export class DomainRecordDrawer extends React.Component< }); return ( - this.setTag(e.value)} + onChange={(_, selected) => this.setTag(selected.value)} options={tagOptions} + value={defaultTag} /> ); }; diff --git a/packages/manager/src/features/Domains/DomainRecords.tsx b/packages/manager/src/features/Domains/DomainRecords.tsx index c0d451b5f8c..4e5737bc556 100644 --- a/packages/manager/src/features/Domains/DomainRecords.tsx +++ b/packages/manager/src/features/Domains/DomainRecords.tsx @@ -584,10 +584,10 @@ class DomainRecords extends React.Component { /** SRV Record */ { columns: [ - { render: (r: DomainRecord) => r.name, title: 'Name' }, + { render: (r: DomainRecord) => r.name, title: 'Service/Protocol' }, { render: () => this.props.domain.domain, - title: 'Domain', + title: 'Name', }, { render: (r: DomainRecord) => String(r.priority), diff --git a/packages/manager/src/features/Events/Event.helpers.ts b/packages/manager/src/features/Events/Event.helpers.ts index 8ca4f216904..f41f41792b5 100644 --- a/packages/manager/src/features/Events/Event.helpers.ts +++ b/packages/manager/src/features/Events/Event.helpers.ts @@ -17,6 +17,7 @@ const ACTIONS_WITHOUT_USERNAMES = [ 'entity_transfer_fail', 'entity_transfer_stale', 'lassie_reboot', + 'community_like', ]; export const formatEventWithUsername = ( diff --git a/packages/manager/src/features/Events/eventMessageGenerator.ts b/packages/manager/src/features/Events/eventMessageGenerator.ts index f96aa48932a..800ab1068e7 100644 --- a/packages/manager/src/features/Events/eventMessageGenerator.ts +++ b/packages/manager/src/features/Events/eventMessageGenerator.ts @@ -75,7 +75,7 @@ export const eventMessageCreators: { [index: string]: CreatorsForStatus } = { community_like: { notification: (e) => e.entity?.label - ? `A post on "${e.entity.label}" has been liked.` + ? `${e.entity.label}` : `There has been a like on your community post.`, }, community_mention: { diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/RemoveDeviceDialog.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/RemoveDeviceDialog.tsx index 98b43f25baf..9d199ce7d9b 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/RemoveDeviceDialog.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/RemoveDeviceDialog.tsx @@ -6,7 +6,7 @@ import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; import { Typography } from 'src/components/Typography'; import { useRemoveFirewallDeviceMutation } from 'src/queries/firewalls'; -import { queryKey as linodesQueryKey } from 'src/queries/linodes/linodes'; +import { linodeQueries } from 'src/queries/linodes/linodes'; import { nodebalancerQueries } from 'src/queries/nodebalancers'; import type { FirewallDevice } from '@linode/api-v4'; @@ -57,7 +57,8 @@ export const RemoveDeviceDialog = React.memo((props: Props) => { // Since the linode was removed as a device, invalidate the linode-specific firewall query if (deviceType === 'linode') { queryClient.invalidateQueries({ - queryKey: [linodesQueryKey, deviceType, device.entity.id, 'firewalls'], + queryKey: linodeQueries.linode(device.entity.id)._ctx.firewalls + .queryKey, }); } diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.test.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.test.tsx index b18c4f1aeb7..5549ef447e7 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.test.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.test.tsx @@ -1,5 +1,4 @@ import { FirewallPolicyType } from '@linode/api-v4/lib/firewalls/types'; -import { screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import * as React from 'react'; @@ -29,8 +28,6 @@ const mockOnSubmit = vi.fn(); const baseItems = [PORT_PRESETS['22'], PORT_PRESETS['443']]; -vi.mock('src/components/EnhancedSelect/Select'); - const props: FirewallRuleDrawerProps = { category: 'inbound', isOpen: true, @@ -48,21 +45,23 @@ describe('AddRuleDrawer', () => { }); it('disables the port input when the ICMP protocol is selected', async () => { - renderWithTheme( + const { getByText, getByPlaceholderText } = renderWithTheme( ); - expect(screen.getByLabelText('Ports')).not.toBeDisabled(); - await userEvent.selectOptions(screen.getByLabelText('Protocol'), 'ICMP'); - expect(screen.getByLabelText('Ports')).toBeDisabled(); + expect(getByPlaceholderText('Select a port...')).not.toBeDisabled(); + await userEvent.click(getByPlaceholderText('Select a protocol...')); + await userEvent.click(getByText('ICMP')); + expect(getByPlaceholderText('Select a port...')).toBeDisabled(); }); it('disables the port input when the IPENCAP protocol is selected', async () => { - renderWithTheme( + const { getByText, getByPlaceholderText } = renderWithTheme( ); - expect(screen.getByLabelText('Ports')).not.toBeDisabled(); - await userEvent.selectOptions(screen.getByLabelText('Protocol'), 'IPENCAP'); - expect(screen.getByLabelText('Ports')).toBeDisabled(); + expect(getByPlaceholderText('Select a port...')).not.toBeDisabled(); + await userEvent.click(getByPlaceholderText('Select a protocol...')); + await userEvent.click(getByText('IPENCAP')); + expect(getByPlaceholderText('Select a port...')).toBeDisabled(); }); }); diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx index 5b06ed6c8a6..fc302b723fd 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx @@ -24,7 +24,7 @@ import type { FirewallRuleProtocol, FirewallRuleType, } from '@linode/api-v4/lib/firewalls'; -import type { Item } from 'src/components/EnhancedSelect/Select'; +import { FirewallOptionItem } from '../../shared'; import type { ExtendedIP } from 'src/utilities/ipUtils'; // ============================================================================= @@ -41,9 +41,11 @@ export const FirewallRuleDrawer = React.memo( const [ips, setIPs] = React.useState([{ address: '' }]); // Firewall Ports, like IPs, are tracked separately. The form.values state value - // tracks the custom user input; the Item[] array of port presets in the multi-select + // tracks the custom user input; the FirewallOptionItem[] array of port presets in the multi-select // is stored here. - const [presetPorts, setPresetPorts] = React.useState[]>([]); + const [presetPorts, setPresetPorts] = React.useState< + FirewallOptionItem[] + >([]); // Reset state. If we're in EDIT mode, set IPs to the addresses of the rule we're modifying // (along with any errors we may have). diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.types.ts b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.types.ts index 4062ad748d1..6478e546f97 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.types.ts +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.types.ts @@ -5,7 +5,7 @@ import type { FirewallRuleType, } from '@linode/api-v4/lib/firewalls'; import type { FormikProps } from 'formik'; -import type { Item } from 'src/components/EnhancedSelect/Select'; +import { FirewallOptionItem } from '../../shared'; import type { ExtendedIP } from 'src/utilities/ipUtils'; export type FirewallRuleDrawerMode = 'create' | 'edit'; @@ -34,8 +34,8 @@ export interface FirewallRuleFormProps extends FormikProps { category: Category; ips: ExtendedIP[]; mode: FirewallRuleDrawerMode; - presetPorts: Item[]; + presetPorts: FirewallOptionItem[]; ruleErrors?: FirewallRuleError[]; setIPs: (ips: ExtendedIP[]) => void; - setPresetPorts: (selected: Item[]) => void; + setPresetPorts: (selected: FirewallOptionItem[]) => void; } diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.utils.ts b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.utils.ts index ce11d51c069..e57bd284795 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.utils.ts +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.utils.ts @@ -11,6 +11,8 @@ import { allIPv6, allowAllIPv4, allowAllIPv6, + allowNoneIPv4, + allowNoneIPv6, allowsAllIPs, predefinedFirewallFromRule, } from 'src/features/Firewalls/shared'; @@ -24,7 +26,7 @@ import type { FirewallRuleProtocol, FirewallRuleType, } from '@linode/api-v4/lib/firewalls'; -import type { Item } from 'src/components/EnhancedSelect'; +import type { FirewallOptionItem } from 'src/features/Firewalls/shared'; import type { ExtendedIP } from 'src/utilities/ipUtils'; export const IP_ERROR_MESSAGE = 'Must be a valid IPv4 or IPv6 range.'; @@ -172,11 +174,11 @@ export const getInitialAddressFormValue = ( return 'all'; } - if (allowAllIPv4(addresses)) { + if (allowAllIPv4(addresses) && allowNoneIPv6(addresses)) { return 'allIPv4'; } - if (allowAllIPv6(addresses)) { + if (allowAllIPv6(addresses) && allowNoneIPv4(addresses)) { return 'allIPv6'; } @@ -239,7 +241,7 @@ export const getInitialIPs = ( * output: '22, 443, 1313-1515, 8080' */ export const itemsToPortString = ( - items: Item[], + items: FirewallOptionItem[], portInput?: string ): string | undefined => { // If a user has selected ALL, just return that; anything else in the string @@ -261,11 +263,11 @@ export const itemsToPortString = ( /** * * Inverse of itemsToPortString. Takes a string from an API response (or row value) - * and converts it to Item[] and a custom input string. + * and converts it to FirewallOptionItem[] and a custom input string. */ export const portStringToItems = ( portString?: string -): [Item[], string] => { +): [FirewallOptionItem[], string] => { // Handle empty input if (!portString) { return [[], '']; @@ -277,7 +279,7 @@ export const portStringToItems = ( } const ports = portString.split(',').map((p) => p.trim()); - const items: Item[] = []; + const items: FirewallOptionItem[] = []; const customInput: string[] = []; ports.forEach((thisPort) => { diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleForm.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleForm.tsx index 4278f323a9e..f55a8b0fcca 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleForm.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleForm.tsx @@ -2,7 +2,7 @@ import { styled } from '@mui/material/styles'; import * as React from 'react'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; -import Select from 'src/components/EnhancedSelect'; +import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; import { FormControlLabel } from 'src/components/FormControlLabel'; import { MultipleIPInput } from 'src/components/MultipleIPInput/MultipleIPInput'; import { Notice } from 'src/components/Notice/Notice'; @@ -11,6 +11,7 @@ import { RadioGroup } from 'src/components/RadioGroup'; import { TextField } from 'src/components/TextField'; import { Typography } from 'src/components/Typography'; import { + FirewallOptionItem, addressOptions, firewallOptionItemsShort, portPresets, @@ -23,7 +24,6 @@ import { enforceIPMasks } from './FirewallRuleDrawer.utils'; import { PORT_PRESETS, PORT_PRESETS_ITEMS } from './shared'; import type { FirewallRuleFormProps } from './FirewallRuleDrawer.types'; -import type { Item } from 'src/components/EnhancedSelect/Select'; import type { ExtendedIP } from 'src/utilities/ipUtils'; const ipNetmaskTooltipText = @@ -83,7 +83,7 @@ export const FirewallRuleForm = React.memo((props: FirewallRuleFormProps) => { // These handlers are all memoized because the form was laggy when I tried them inline. const handleTypeChange = React.useCallback( - (item: Item | null) => { + (item: FirewallOptionItem | null) => { const selectedType = item?.value; // If the user re-selects the same preset or selectedType is undefined, don't do anything @@ -128,9 +128,9 @@ export const FirewallRuleForm = React.memo((props: FirewallRuleFormProps) => { ); const handleProtocolChange = React.useCallback( - (item: Item | null) => { - setFieldValue('protocol', item?.value); - if (item?.value === 'ICMP' || item?.value === 'IPENCAP') { + (item: string) => { + setFieldValue('protocol', item); + if (item === 'ICMP' || item === 'IPENCAP') { // Submitting the form with ICMP or IPENCAP and defined ports causes an error setFieldValue('ports', ''); setPresetPorts([]); @@ -140,8 +140,8 @@ export const FirewallRuleForm = React.memo((props: FirewallRuleFormProps) => { ); const handleAddressesChange = React.useCallback( - (item: Item | null) => { - setFieldValue('addresses', item?.value); + (item: string) => { + setFieldValue('addresses', item); // Reset custom IPs setIPs([{ address: '' }]); }, @@ -169,7 +169,7 @@ export const FirewallRuleForm = React.memo((props: FirewallRuleFormProps) => { }; const handlePortPresetChange = React.useCallback( - (items: Item[]) => { + (items: FirewallOptionItem[]) => { // If the user is selecting "ALL", it doesn't make sense // to show additional selections. if ( @@ -191,7 +191,7 @@ export const FirewallRuleForm = React.memo((props: FirewallRuleFormProps) => { return ( addressOptions.find( (thisOption) => thisOption.value === values.addresses - ) || null + ) || undefined ); }, [values]); @@ -205,13 +205,18 @@ export const FirewallRuleForm = React.memo((props: FirewallRuleFormProps) => { variant="error" /> )} - handleProtocolChange(selected.value)} options={protocolOptions} placeholder="Select a protocol..." - required value={protocolOptions.find((p) => p.value === values.protocol)} /> - { + handleAddressesChange(selected.value); + }} options={addressOptions} placeholder={`Select ${addressesLabel}s...`} - required value={addressesValue} /> {/* Show this field only if "IP / Netmask has been selected." */} diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.tsx index 2c00c226e1c..ce3d395c8aa 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.tsx @@ -12,11 +12,12 @@ import { } from 'react-beautiful-dnd'; import Undo from 'src/assets/icons/undo.svg'; +import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; import { Box } from 'src/components/Box'; -import Select, { Item } from 'src/components/EnhancedSelect/Select'; import { Hidden } from 'src/components/Hidden'; import { Typography } from 'src/components/Typography'; import { + FirewallOptionItem, generateAddressesLabel, generateRuleLabel, predefinedFirewallFromRule as ruleToPredefinedFirewall, @@ -369,7 +370,7 @@ interface PolicyRowProps { policy: FirewallPolicyType; } -const policyOptions: Item[] = [ +const policyOptions: FirewallOptionItem[] = [ { label: 'Accept', value: 'ACCEPT' }, { label: 'Drop', value: 'DROP' }, ]; @@ -439,18 +440,18 @@ export const PolicyRow = React.memo((props: PolicyRowProps) => { {helperText} - { + handleSortKeyChange(value); + }} + size="small" + textFieldProps={{ + hideLabel: true, + }} value={sortOptions.find( (thisOption) => thisOption.value === sortKey )} - hideLabel - isClearable={false} - label="Sort by" - onChange={handleSortKeyChange} - options={sortOptions} - small /> diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerActiveCheck.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerActiveCheck.tsx index c125d4f8a2e..33e8cf66a97 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerActiveCheck.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerActiveCheck.tsx @@ -1,7 +1,7 @@ import Grid from '@mui/material/Unstable_Grid2'; import * as React from 'react'; -import Select from 'src/components/EnhancedSelect/Select'; +import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; import { FormHelperText } from 'src/components/FormHelperText'; import { InputAdornment } from 'src/components/InputAdornment'; import { TextField } from 'src/components/TextField'; @@ -10,7 +10,6 @@ import { Typography } from 'src/components/Typography'; import { setErrorMap } from './utils'; import type { NodeBalancerConfigPanelProps } from './types'; -import type { Item } from 'src/components/EnhancedSelect'; interface ActiveCheckProps extends NodeBalancerConfigPanelProps { errorMap: Record; @@ -60,9 +59,6 @@ export const ActiveCheck = (props: ActiveCheckProps) => { const onHealthCheckTimeoutChange = (e: React.ChangeEvent) => props.onHealthCheckTimeoutChange(e.target.value); - const onHealthCheckTypeChange = (e: Item) => - props.onHealthCheckTypeChange(e.value); - const conditionalText = displayProtocolText(protocol); const typeOptions = [ @@ -99,22 +95,25 @@ export const ActiveCheck = (props: ActiveCheckProps) => {
- @@ -258,22 +251,25 @@ export const NodeBalancerConfigPanel = ( {tcpSelected && ( - { + props.onAlgorithmChange(selected.value); + }} options={algOptions} - small + size="small" value={defaultAlg || algOptions[0]} /> @@ -315,22 +314,25 @@ export const NodeBalancerConfigPanel = ( -