diff --git a/CHANGELOG.md b/CHANGELOG.md index e2d02943c36..261079d212c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,49 @@ 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/). +## [2023-05-30] - v1.94.0 + +### Added: + +- Resource links to Object Storage empty state landing page #9098 +- Resource links to Images empty state landing page #9095 + +### Fixed: + +- Required fields for Firewall rules drawer form #9127 +- Cloud Manager maintenance mode #9130 +- Error handling for loadScript and Adobe Analytics #9161 + +### Removed: + +- Unifi Marketplace app #9145 + +### Tech Stories: + +- Automate the changelog with changeset generation #9104 +- Upgrade Cypress to v12.11 #9038 +- React Query for Linode Details - General Details #9099 +- React Query Linode Details - Network Tab #9097 +- React Query for Linode Details Settings Tab #9121 +- React Query for Support Tickets - Ticket Details #9105 +- MUI v5 Migration - `Components > EditableEntityLabel` #9129 +- MUI v5 Migration - `Components > EnhancedNumberInput` #9152 +- MUI v5 Migration - `Components > EntityDetail` #9123 +- MUI v5 Migration - `Components > EntityHeader` #9109 +- MUI v5 Migration - `Components > EntityIcon` #9125 +- MUI v5 Migration - `Components > ErrorState` #9128 +- MUI v5 Migration - `Components > IconButton` #9102 +- MUI v5 Migration - `Components > Placeholder & Components > H1 Header` #9131 +- MUI v5 Migration - `Components > TransferDisplay` #9107 +- MUI v5 Migration - `Components > TypeToConfirm` & `Components > TypeToConfirmDialog` #9124 +- MUI v5 Migration - `Features > CancelLanding` #9113 +- MUI v5 Migration - `Features > NodeBalancers` #9139 +- MUI v5 Migration - `Features > NotificationCenter` #9162 + ## [2023-05-22] - v1.93.3 ### Fixed: + - LISH Console via SSH containing `none` as the username #9148 - Ability to add a Linode to a Firewall when the Firewall contains a large number of Linodes #9151 - Inability of restricted users with NodeBalancer creation permissions to add NodeBalancers #9150 @@ -15,17 +55,20 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p ## [2023-05-22] - v1.93.2 ### Fixed: + - Issue where linode "Reboot" button was disabled #9143 ## [2023-05-18] - v1.93.1 ### Fixed: + - Initialize linode before referencing #9133 - Revert linode landing changes #9136 ## [2023-05-15] - v1.93.0 ### Added: + - Resource links to empty state Volumes landing page #9065 - Resource links to empty state Firewalls landing page #9078 - Resource links to empty state StackScripts landing page #9091 @@ -35,11 +78,13 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - Accessible graph data for LineGraphs #9045 ### Changed: + - Banner text size and spacing to improve readability #9064 - Updated ClusterControl description #9081 - Highlighted Marketplace apps and button card height on empty state Linodes landing page #9083 ### Fixed: + - Ability to search Linodes by IPv6 #9073 - Surface general errors in the Object Storage Bucket Create Drawer #9067 - Large file size for invoices due to uncompressed JPG logo #9069 @@ -48,6 +93,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - Send Adobe Analytics page views #9108 ### Tech Stories: + - MUI v5 Migration - `Components > CheckoutSummary` #9100 - MUI v5 Migration - `Components > CopyableTextField` #9018 - MUI v5 Migration - `Components > DialogTitle` #9050 @@ -66,28 +112,32 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - React Query - Linodes - Detail - Backups #9079 - Add Adobe Analytics custom event tracking #9004 - ## [2023-05-01] - v1.92.0 ### Added: + - No Results section for Marketplace Search #8999 - Private IP checkbox when cloning a Linode #9039 - Metadata migrate warning #9033 ### Changed: + - Region Select will dynamically get country flags and group all countries based on API data #8996 - Removed MongoDB Marketplace Apps #9071 ### Fixed: + - Kubernetes Delete Dialog clears when it is re-opened #9000 - HTML showing up in event messages #9003 - Inability to edit and save Linode Configurations #9053 +- Excessively large file size for invoices due to uncompressed JPG logo #9069 - Marketplace One Click Cluster UDF caching issue #8997 - Prevent IP transfer & sharing modals form submission if no action selected #9026 - Increase radio button padding to fix hover effect shape #9031 - Blank Kubernetes Node Pool plan selection #9009 ### Tech Stories: + - MUI v5 Migration - `Components > CircleProgress` #9028 - MUI v5 Migration - `Components > StatusIcon` #9014 - MUI v5 Migration - `Components > TagsInput, TagsPanel` #8995 @@ -122,27 +172,32 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p ## [2023-04-18] - v1.91.1 ### Fixed: + - Add Premium plans to LKE #9021 ## [2023-04-17] - v1.91.0 ### Added: + - Cross Data Center Clone warning #8937 - `Plan` column header to plan select table #8943 ### Changed: + - Use Akamai logo for TPA provider screen #8982 - Use Akamai logo for the favicon #8988 - Only fetch grants when the user is restricted #8941 - Improve the StackScript user defined fields (UDF) forms #8973 ### Fixed: + - Styling of Linode Details Add Configurations modal header #8981 - Alignment issues with Kubernetes Node Pool table and buttons #8967 - Domain Records not updating when navigating #8957 - Notification menu displaying empty menu on secondary status click #8902 ### Tech Story: + - React Query for NodeBalancers #8964 - React Query for Profile - Trusted Devices #8942 - React Query for OAuth Apps #8938 diff --git a/EntityIcon b/EntityIcon new file mode 100644 index 00000000000..e69de29bb2d diff --git a/docker-compose.yml b/docker-compose.yml index 426d49263e1..def749a7c6a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,30 +1,50 @@ --- version: '3.4' +# Environment variables that will be exposed to every Cypress runner. x-e2e-env: &default-env + + # CI build information for Cypress Dashboard. BUILD_ID: "${GIT_BRANCH}-${BUILD_NUMBER}" - BUILD_URL: ${BUILD_URL} BUILD_NUMBER: ${BUILD_NUMBER} + BUILD_URL: ${BUILD_URL} + + # Exposing `COMMIT_INFO_*` environment variables to Cypress allows us to + # manually specify git commit information. This is required because Cypress + # cannot retrieve the information automatically from within the container due + # to file ownership differences. + # + # See also: + # - https://github.com/cypress-io/commit-info + # - https://github.blog/2022-04-18-highlights-from-git-2-36/#stricter-repository-ownership-checks + COMMIT_INFO_AUTHOR: ${COMMIT_INFO_AUTHOR} COMMIT_INFO_BRANCH: ${CHANGE_BRANCH} - COMMIT_INFO_SHA: ${GIT_COMMIT} + COMMIT_INFO_EMAIL: ${COMMIT_INFO_EMAIL} + COMMIT_INFO_MESSAGE: ${COMMIT_INFO_MESSAGE} COMMIT_INFO_REMOTE: ${GIT_URL} - JENKINS_HOME: ${JENKINS_HOME} - JENKINS_VERSION: ${JENKINS_VERSION} - HUDSON_URL: ${HUDSON_URL} - HUDSON_HOME: ${HUDSON_HOME} + COMMIT_INFO_SHA: ${GIT_COMMIT} + + # Cypress environment variables for run environment and CI configuration. CYPRESS_BASE_URL: http://web:3000 - CYPRESS_RECORD: ${CYPRESS_RECORD} - CYPRESS_RECORD_KEY: ${CYPRESS_RECORD_KEY} CYPRESS_PULL_REQUEST_ID: ${CHANGE_ID} CYPRESS_PULL_REQUEST_URL: ${CHANGE_URL} + CYPRESS_RECORD: ${CYPRESS_RECORD} + CYPRESS_RECORD_KEY: ${CYPRESS_RECORD_KEY} + + # Cloud Manager build environment. HOME: /home/node REACT_APP_API_ROOT: ${REACT_APP_API_ROOT} REACT_APP_CLIENT_ID: ${REACT_APP_CLIENT_ID} + REACT_APP_DISABLE_NEW_RELIC: ${REACT_APP_DISABLE_NEW_RELIC} REACT_APP_LAUNCH_DARKLY_ID: ${REACT_APP_LAUNCH_DARKLY_ID} REACT_APP_LOGIN_ROOT: ${REACT_APP_LOGIN_ROOT} + # Miscellaneous Jenkins vars. + HUDSON_HOME: ${HUDSON_HOME} + HUDSON_URL: ${HUDSON_URL} +# Volumes that will be exposed to every Cypress runner. x-e2e-volumes: &default-volumes - ./.git:/home/node/app/.git @@ -34,8 +54,23 @@ x-e2e-volumes: - ./package.json:/home/node/app/package.json - ./node_modules:/home/node/app/node_modules -services: +# Base Docker Compose service config for each Cypress runner. +# This can be extended/overridden on a per-runner basis for e.g.container name +# and OAuth token. +x-e2e-runners: + &default-runner + build: + context: . + dockerfile: ./packages/manager/Dockerfile + target: e2e + depends_on: + web: + condition: service_healthy + env_file: ./packages/manager/.env + volumes: *default-volumes +services: + # Serves a local instance of Cloud Manager for Cypress to use for its tests. web: build: context: . @@ -54,80 +89,31 @@ services: timeout: 10s retries: 10 - # Use this service to run E2E tests via Docker locally. - # Later, this should be renamed to better clarify this distinction. - # (Perhaps it should be removed entirely?) - e2e: - build: - context: . - dockerfile: ./packages/manager/Dockerfile - target: e2e - depends_on: - web: - condition: service_healthy - environment: - <<: *default-env - MANAGER_OAUTH: ${MANAGER_OAUTH_1} - env_file: ./packages/manager/.env - entrypoint: yarn cy:ci - volumes: *default-volumes - + # Cypress runners. e2e-1: - build: - context: . - dockerfile: ./packages/manager/Dockerfile - target: e2e + <<: *default-runner container_name: cloud-e2e-1 - depends_on: - web: - condition: service_healthy environment: <<: *default-env MANAGER_OAUTH: ${MANAGER_OAUTH_1} - env_file: ./packages/manager/.env - volumes: *default-volumes e2e-2: - build: - context: . - dockerfile: ./packages/manager/Dockerfile - target: e2e + <<: *default-runner container_name: cloud-e2e-2 - depends_on: - web: - condition: service_healthy environment: <<: *default-env MANAGER_OAUTH: ${MANAGER_OAUTH_2} - env_file: ./packages/manager/.env - volumes: *default-volumes e2e-3: - build: - context: . - dockerfile: ./packages/manager/Dockerfile - target: e2e + <<: *default-runner container_name: cloud-e2e-3 - depends_on: - web: - condition: service_healthy environment: <<: *default-env MANAGER_OAUTH: ${MANAGER_OAUTH_3} - env_file: ./packages/manager/.env - volumes: *default-volumes e2e-4: - build: - context: . - dockerfile: ./packages/manager/Dockerfile - target: e2e + <<: *default-runner container_name: cloud-e2e-4 - depends_on: - web: - condition: service_healthy environment: <<: *default-env MANAGER_OAUTH: ${MANAGER_OAUTH_4} - env_file: ./packages/manager/.env - volumes: *default-volumes diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index dd906893b53..1e874870487 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -32,5 +32,13 @@ Feel free to open an issue to report a bug or request a feature. **Example:** `feat: [M3-1234] - Allow user to view their login history` 6. Open a pull request against `develop` and make sure the title follows the same format as the commit message. +7. If needed, create a changeset to populate our changelog + - If you don't have the Github CLI installed or need to update it (you need GH CLI 2.21.0 or greater), + - install it via `brew`: https://cli.github.com/manual/installation or upgrade with `brew upgrade gh` + - Once installed, run `gh repo set-default` and pick `linode/manager` (only > 2.21.0) + - You can also just create the changeset manually, in this case make sure to use the proper formatting for it. + - Run `yarn changeset` and provide a description for the change. You can either have it committed automatically or do it manually + - A changeset is optional, it merely depends if it falls in one of the following categories: + `Added`, `Fixed`, `Changed`, `Removed`, `Tech Stories` Two reviews from members of the Cloud Manager team are required before merge. After approval, all pull requests are squash merged. diff --git a/docs/development-guide/01-repository-structure.md b/docs/development-guide/01-repository-structure.md index bc901fe0d4e..fee00a5cc03 100644 --- a/docs/development-guide/01-repository-structure.md +++ b/docs/development-guide/01-repository-structure.md @@ -86,7 +86,7 @@ In `/src` there are several important files on the directory root: - base-level request methods, responsible for injecting a user's access token to all API requests and intercepting errors - **session.ts** - methods for handling session management -- **themeFactory.ts and themes.ts** +- **foundations/themes.ts and utilities/themes.ts** - app-wide styles The /src directory has several subdirectories: @@ -158,5 +158,5 @@ The /src directory has several subdirectories: 1. Find where the styles are defined for the component you want to modify a. They are likely defined in the feature component's file, e.g. `src/features//.tsx`. -2. Avoid making changes in `src/index.css`, `src/themeFactory.ts`, and `src/themes.ts` unless you are intentionally making a global styling change. +2. Avoid making changes in `src/index.css`, `src/foundations/themes/index.ts`, and unless you are intentionally making a global styling change. 3. Avoid making changes in `src/components/` unless you are intentionally making a global styling change, or if the change cannot be made in the feature component file and the change can be controlled through props or composition. diff --git a/docs/development-guide/13-coding-standards.md b/docs/development-guide/13-coding-standards.md index e0038e182ea..c6c408ce4e9 100644 --- a/docs/development-guide/13-coding-standards.md +++ b/docs/development-guide/13-coding-standards.md @@ -46,7 +46,7 @@ Function: handleLabelChange The styles for Cloud Manager are located in three places: - `packages/manager/src/index.css` contains global styles, utility styles, and accessibility related styles. -- `packages/manager/src/themeFactory.ts` and `packages/manager/src/themes.ts` contain code for modifying the default [Material UI](https://mui.com) styles and theme specific styles. - - Light mode styles are located in `themeFactory.ts` and dark mode styles are located in `themes.ts`. - - The breakpoints can be modified at the end of `themeFactory.ts`. -- Component-specific styles may be defined either at the end of the component file or in a dedicated file, named `ComponentName.styles.tsx`. Refer to the guidelines outlined in the "Styles" section of [Component Structure](02-component-structure.md#styles). \ No newline at end of file +- `packages/manager/src/foundations/themes/index.ts` contain code for modifying the default [Material UI](https://mui.com) styles and theme specific styles. + - Light mode styles are located in `/foundations/themes/light.ts` and dark mode styles are located in `/foundations/themes/dark.ts`. + - The breakpoints can be modified at `/foundations/breakpoints/index.ts`. +- Component-specific styles may be defined either at the end of the component file or in a dedicated file, named `ComponentName.styles.tsx`. Refer to the guidelines outlined in the "Styles" section of [Component Structure](02-component-structure.md#styles). \ No newline at end of file diff --git a/packages/api-v4/CHANGELOG.md b/packages/api-v4/CHANGELOG.md index cb7471c761f..2f88aaf5245 100644 --- a/packages/api-v4/CHANGELOG.md +++ b/packages/api-v4/CHANGELOG.md @@ -1,3 +1,7 @@ +## [2023-05-30] - v0.93.0 +### Added: +- `ticket_update` to account types ([#9105](https://github.com/linode/manager/pull/9105)) +- filtering on IPv6 ranges ([#9097](https://github.com/linode/manager/pull/9097)) ## [2023-05-15] - v0.92.0 diff --git a/packages/api-v4/package.json b/packages/api-v4/package.json index f6e8ac62d7a..ae9c653c452 100644 --- a/packages/api-v4/package.json +++ b/packages/api-v4/package.json @@ -1,6 +1,6 @@ { "name": "@linode/api-v4", - "version": "0.92.0", + "version": "0.93.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 11f5df99cbe..7ff7ef5542e 100644 --- a/packages/api-v4/src/account/types.ts +++ b/packages/api-v4/src/account/types.ts @@ -308,6 +308,7 @@ export type EventAction = | 'tfa_enabled' | 'tfa_disabled' | 'ticket_attachment_upload' + | 'ticket_update' | 'user_ssh_key_add' | 'user_ssh_key_update' | 'user_ssh_key_delete' diff --git a/packages/api-v4/src/linodes/ips.ts b/packages/api-v4/src/linodes/ips.ts index b714e2f66f4..e4af21331c7 100644 --- a/packages/api-v4/src/linodes/ips.ts +++ b/packages/api-v4/src/linodes/ips.ts @@ -43,16 +43,16 @@ export const allocateIPAddress = ( * Deletes a Linode's public IP Address. This request will fail if this is the last IP * address allocated to the Linode. This request cannot be invoked on a private IP Address * - * @param {linodeID: number, IPAddress: string} payload - the linode ID and IP Address for - * which you'd like the delete request to be invoked + * @param payload.linodeID {number} - the linode ID for which you'd like the delete request to be invoked + * @param payload.address {string} - the IP Address for which you'd like the delete request to be invoked */ export const removeIPAddress = (payload: { linodeID: number; - IPAddress: string; + address: string; }) => { return Request<{}>( setURL( - `${API_ROOT}/linode/instances/${payload.linodeID}/ips/${payload.IPAddress}` + `${API_ROOT}/linode/instances/${payload.linodeID}/ips/${payload.address}` ), setMethod('DELETE') ); @@ -63,11 +63,11 @@ export const removeIPAddress = (payload: { * * Deletes a Linode's IPv6 range. * - * @param {IPv6Range: string} payload - the IPv6 Range for which you'd like the delete request to be invoked + * @param payload.range { string } - the IPv6 Range for which you'd like the delete request to be invoked */ -export const removeIPv6Range = (payload: { IPv6Range: string }) => { +export const removeIPv6Range = (payload: { range: string }) => { return Request<{}>( - setURL(`${API_ROOT}/networking/ipv6/ranges/${payload.IPv6Range}`), + setURL(`${API_ROOT}/networking/ipv6/ranges/${payload.range}`), setMethod('DELETE') ); }; diff --git a/packages/api-v4/src/networking/networking.ts b/packages/api-v4/src/networking/networking.ts index 402262e1827..881a7fec0a1 100644 --- a/packages/api-v4/src/networking/networking.ts +++ b/packages/api-v4/src/networking/networking.ts @@ -160,11 +160,12 @@ export const getIPv6Pools = (params?: Params) => * View IPv6 range information. * */ -export const getIPv6Ranges = (params?: Params) => +export const getIPv6Ranges = (params?: Params, filter?: Filter) => Request>( setURL(`${API_ROOT}/networking/ipv6/ranges`), setMethod('GET'), - setParams(params) + setParams(params), + setXFilter(filter) ); /** diff --git a/packages/api-v4/src/types.ts b/packages/api-v4/src/types.ts index 309e54a1ed4..8044ff25b85 100644 --- a/packages/api-v4/src/types.ts +++ b/packages/api-v4/src/types.ts @@ -50,7 +50,7 @@ type LinodeFilter = | { [key in keyof FilterConditionTypes]: FilterConditionTypes[key]; } - | { [key: string]: string | number | boolean | Filter | undefined }; + | { [key: string]: string | number | boolean | Filter | null | undefined }; // const filter: Filter = { // '+or': [{ vcpus: 1 }, { class: 'standard' }], diff --git a/packages/manager/.changeset/README.md b/packages/manager/.changeset/README.md new file mode 100644 index 00000000000..66704a59628 --- /dev/null +++ b/packages/manager/.changeset/README.md @@ -0,0 +1,18 @@ +# Changesets + +This directory gets auto-populated when running `yarn changeset`. +You can however add your changesets manually as well, knowing that the [TYPE] is limited to the following options `Added`, `Fixed`, `Changed`, `Removed`, `Tech Stories`, and follow this format: + +```md +--- +"@linode/manager": [TYPE] +--- + +My PR Description ([#`PR number`](`PR link`)) +``` + +You must commit them to the repo so they can be picked up for the changelog generation. + +This directory get wiped out when running `yarn generate-changelog`. + +See `changeset.mjs` for implementation details. \ No newline at end of file diff --git a/packages/manager/CHANGELOG.md b/packages/manager/CHANGELOG.md new file mode 100644 index 00000000000..261079d212c --- /dev/null +++ b/packages/manager/CHANGELOG.md @@ -0,0 +1,5532 @@ +# Change Log + +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/). + +## [2023-05-30] - v1.94.0 + +### Added: + +- Resource links to Object Storage empty state landing page #9098 +- Resource links to Images empty state landing page #9095 + +### Fixed: + +- Required fields for Firewall rules drawer form #9127 +- Cloud Manager maintenance mode #9130 +- Error handling for loadScript and Adobe Analytics #9161 + +### Removed: + +- Unifi Marketplace app #9145 + +### Tech Stories: + +- Automate the changelog with changeset generation #9104 +- Upgrade Cypress to v12.11 #9038 +- React Query for Linode Details - General Details #9099 +- React Query Linode Details - Network Tab #9097 +- React Query for Linode Details Settings Tab #9121 +- React Query for Support Tickets - Ticket Details #9105 +- MUI v5 Migration - `Components > EditableEntityLabel` #9129 +- MUI v5 Migration - `Components > EnhancedNumberInput` #9152 +- MUI v5 Migration - `Components > EntityDetail` #9123 +- MUI v5 Migration - `Components > EntityHeader` #9109 +- MUI v5 Migration - `Components > EntityIcon` #9125 +- MUI v5 Migration - `Components > ErrorState` #9128 +- MUI v5 Migration - `Components > IconButton` #9102 +- MUI v5 Migration - `Components > Placeholder & Components > H1 Header` #9131 +- MUI v5 Migration - `Components > TransferDisplay` #9107 +- MUI v5 Migration - `Components > TypeToConfirm` & `Components > TypeToConfirmDialog` #9124 +- MUI v5 Migration - `Features > CancelLanding` #9113 +- MUI v5 Migration - `Features > NodeBalancers` #9139 +- MUI v5 Migration - `Features > NotificationCenter` #9162 + +## [2023-05-22] - v1.93.3 + +### Fixed: + +- LISH Console via SSH containing `none` as the username #9148 +- Ability to add a Linode to a Firewall when the Firewall contains a large number of Linodes #9151 +- Inability of restricted users with NodeBalancer creation permissions to add NodeBalancers #9150 +- Bucket Access unnecessarily refreshing #9140 + +## [2023-05-22] - v1.93.2 + +### Fixed: + +- Issue where linode "Reboot" button was disabled #9143 + +## [2023-05-18] - v1.93.1 + +### Fixed: + +- Initialize linode before referencing #9133 +- Revert linode landing changes #9136 + +## [2023-05-15] - v1.93.0 + +### Added: + +- Resource links to empty state Volumes landing page #9065 +- Resource links to empty state Firewalls landing page #9078 +- Resource links to empty state StackScripts landing page #9091 +- Resource links to empty state Domains landing page #9092 +- Ability download DNS zone file #9075 +- New flag to deliver DC availability notice for premium plans #9066 +- Accessible graph data for LineGraphs #9045 + +### Changed: + +- Banner text size and spacing to improve readability #9064 +- Updated ClusterControl description #9081 +- Highlighted Marketplace apps and button card height on empty state Linodes landing page #9083 + +### Fixed: + +- Ability to search Linodes by IPv6 #9073 +- Surface general errors in the Object Storage Bucket Create Drawer #9067 +- Large file size for invoices due to uncompressed JPG logo #9069 +- Phone Verification error does not reset #9059 +- Show error for PayPal payments #9058 +- Send Adobe Analytics page views #9108 + +### Tech Stories: + +- MUI v5 Migration - `Components > CheckoutSummary` #9100 +- MUI v5 Migration - `Components > CopyableTextField` #9018 +- MUI v5 Migration - `Components > DialogTitle` #9050 +- MUI v5 Migration - `Components > DownloadCSV` #9084 +- MUI v5 Migration - `Components > Notice` #9094 +- MUI v5 Migration - `Components > PrimaryNav` #9090 +- MUI v5 Migration - `Components > ShowMoreExpansion` #9096 +- MUI v5 Migration - `Components > Table` #9082 +- MUI v5 Migration - `Components > TableBody` #9082 +- MUI v5 Migration - `Components > TableCell` #9082 +- MUI v5 Migration - `Components > TableHead` #9082 +- MUI v5 Migration - `Components > TableRow` #9082 +- MUI v5 Migration - `Components > TableSortCell` #9082 +- React Query - Linodes - Prepare for React Query for Linodes #9049 +- React Query - Linodes - Landing #9062 +- React Query - Linodes - Detail - Backups #9079 +- Add Adobe Analytics custom event tracking #9004 + +## [2023-05-01] - v1.92.0 + +### Added: + +- No Results section for Marketplace Search #8999 +- Private IP checkbox when cloning a Linode #9039 +- Metadata migrate warning #9033 + +### Changed: + +- Region Select will dynamically get country flags and group all countries based on API data #8996 +- Removed MongoDB Marketplace Apps #9071 + +### Fixed: + +- Kubernetes Delete Dialog clears when it is re-opened #9000 +- HTML showing up in event messages #9003 +- Inability to edit and save Linode Configurations #9053 +- Excessively large file size for invoices due to uncompressed JPG logo #9069 +- Marketplace One Click Cluster UDF caching issue #8997 +- Prevent IP transfer & sharing modals form submission if no action selected #9026 +- Increase radio button padding to fix hover effect shape #9031 +- Blank Kubernetes Node Pool plan selection #9009 + +### Tech Stories: + +- MUI v5 Migration - `Components > CircleProgress` #9028 +- MUI v5 Migration - `Components > StatusIcon` #9014 +- MUI v5 Migration - `Components > TagsInput, TagsPanel` #8995 +- MUI v5 Migration - Grid v2 for Features #8985 +- MUI v5 Migration - `Components > Dialog` #9020 +- MUI v5 Migration - `Components > DeletionDialog` #9047 +- MUI v5 Migration - `Components > Currency` #9030 +- MUI v5 Migration - `Components > DisplayPrice` #9022 +- MUI v5 Migration - `Components > CreateLinodeDisabled` #9015 +- MUI v5 Migration - `Components > DateTimeDisplay, DebouncedSearchTextField` #9007 +- MUI v5 Migration - `Components > ConfirmationDialog` #9016 +- MUI v5 Migration - `Components > CopyTooltip` #9040 +- MUI v5 Migration - `Components > CheckoutBar` #9051 +- MUI v5 Migration - `Components > CreateLinodeDisabled` #9015 +- MUI v5 Migration - `Components > ColorPalette` #9013 +- MUI v5 Migration - `Components > Tile` #9001 +- MUI v5 Migration - `Components > TagsInput, TagsPanel` #8995 +- MUI v5 Migration - `Components > DismissibleBanner` #8998 +- MUI v5 Migration - `Components > SupportLink, TextTooltip` #8993 +- MUI v5 Migration - `Components > Toggle` #8990 +- MUI v5 Migration - `Components > SplashScreen` #8994 +- Remove `ConditionalWrapper` #9002 +- Upgrade New Relic to v1230 #9005 +- Add basic Adobe Analytics tracking #8989 +- Add more eslint rules #9043 +- @linode/validation version badge Label in `README.md` #9011 +- Improve Firewall ports regex to prevent exponential backtracking #9010 +- Fix code scanning alert that DOM text is reinterpreted as HTML #9032 +- Fix the typesafety of the ` - - - + + {(formikProps) => { + return ( + + ); }} - /> - {hasCustomInput ? ( - - ) : null} - + + + + {hasCustomInput ? ( + + ) : null} + + + Active health checks proactively check the health of back-end nodes.{' '} + {conditionalText} + + + {healthCheckType !== 'none' && ( + + + seconds + ), + }} + value={healthCheckInterval} + onChange={onHealthCheckIntervalChange} + errorText={errorMap.check_interval} + errorGroup={forEdit ? `${configIdx}` : undefined} + data-qa-active-check-interval + disabled={disabled} + /> + + Seconds between health check probes + + + + seconds + ), + }} + value={healthCheckTimeout} + onChange={onHealthCheckTimeoutChange} + errorText={errorMap.check_timeout} + errorGroup={forEdit ? `${configIdx}` : undefined} + data-qa-active-check-timeout + disabled={disabled} + /> + + Seconds to wait before considering the probe a failure. 1-30. + Must be less than check_interval. + + + + + + Number of failed probes before taking a node out of rotation. + 1-30 + + + {['http', 'http_body'].includes(healthCheckType) && ( + + + + )} + {healthCheckType === 'http_body' && ( + + + + )} + + )} + + + ); +}; diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerConfigNode.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerConfigNode.tsx index 8a6b5a7b4db..87ecc1b056a 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerConfigNode.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerConfigNode.tsx @@ -1,68 +1,19 @@ import * as React from 'react'; import ActionsPanel from 'src/components/ActionsPanel'; +import Button from 'src/components/Button'; import Chip from 'src/components/core/Chip'; import Divider from 'src/components/core/Divider'; -import MenuItem from 'src/components/core/MenuItem'; -import { makeStyles } from '@mui/styles'; -import { Theme } from '@mui/material/styles'; -import Typography from 'src/components/core/Typography'; import Grid from '@mui/material/Unstable_Grid2'; -import { Notice } from 'src/components/Notice/Notice'; +import MenuItem from 'src/components/core/MenuItem'; import TextField from 'src/components/TextField'; +import Typography from 'src/components/core/Typography'; +import { ConfigNodeIPSelect } from './ConfigNodeIPSelect'; import { getErrorMap } from 'src/utilities/errorUtils'; -import SelectIP from './ConfigNodeIPSelect'; import { NodeBalancerConfigNodeFields } from './types'; +import { Notice } from 'src/components/Notice/Notice'; +import { styled } from '@mui/material/styles'; -const useStyles = makeStyles((theme: Theme) => ({ - backendIPAction: { - display: 'flex', - alignItems: 'flex-end', - paddingLeft: theme.spacing(2), - marginLeft: `-${theme.spacing()}`, - [theme.breakpoints.down('lg')]: { - marginTop: `-${theme.spacing()}`, - }, - [theme.breakpoints.down('sm')]: { - marginTop: 0, - }, - '& .remove': { - margin: 0, - padding: theme.spacing(2.5), - }, - }, - statusHeader: { - fontSize: '.9rem', - color: theme.color.label, - marginTop: `calc(${theme.spacing(2)} - 4)`, - }, - statusChip: { - marginTop: theme.spacing(1), - color: 'white', - '&.undefined': { - backgroundColor: theme.color.grey2, - color: theme.palette.text.primary, - }, - }, - 'chip-UP': { - backgroundColor: theme.color.green, - }, - 'chip-DOWN': { - backgroundColor: theme.color.red, - }, - button: { - ...theme.applyLinkStyles, - }, - mode: { - '& .MuiSelect-root': { - height: 33, - minHeight: 33, - padding: theme.spacing(), - paddingTop: 9, - }, - }, -})); - -export interface Props { +export interface NodeBalancerConfigNodeProps { node: NodeBalancerConfigNodeFields; idx: number; forEdit: boolean; @@ -80,192 +31,219 @@ export interface Props { removeNode: (e: React.MouseEvent) => void; } -export const NodeBalancerConfigNode: React.FC = (props) => { - const classes = useStyles(); - const { - disabled, - node, - idx, - forEdit, - configIdx, - nodeBalancerRegion, - onNodeAddressChange, - onNodeLabelChange, - onNodeModeChange, - onNodeWeightChange, - onNodePortChange, - removeNode, - } = props; +export const NodeBalancerConfigNode = React.memo( + (props: NodeBalancerConfigNodeProps) => { + const { + disabled, + node, + idx, + forEdit, + configIdx, + nodeBalancerRegion, + onNodeAddressChange, + onNodeLabelChange, + onNodeModeChange, + onNodeWeightChange, + onNodePortChange, + removeNode, + } = props; - if (node.modifyStatus === 'delete') { - /* This node has been marked for deletion, don't display it */ - return null; - } + if (node.modifyStatus === 'delete') { + /* This node has been marked for deletion, don't display it */ + return null; + } - const nodesErrorMap = getErrorMap( - ['label', 'address', 'weight', 'port', 'mode'], - node.errors - ); + const nodesErrorMap = getErrorMap( + ['label', 'address', 'weight', 'port', 'mode'], + node.errors + ); - return ( - - - {idx !== 0 && ( - - - - )} - {nodesErrorMap.none && ( - - - - )} - - - - - {node.status && ( - - - Status -
- -
-
+ return ( + + + {idx !== 0 && ( + + )} - -
- - - - + + + )} + + - - - - - - + > + + + {node.status && ( + + + Status +
+ +
+
+
+ )}
- {forEdit && ( +
+ + + + + ) => - onNodeModeChange(e, idx) - } - errorText={nodesErrorMap.mode} - data-qa-backend-ip-mode + onChange={onNodePortChange} + errorText={nodesErrorMap.port} + errorGroup={forEdit ? `${configIdx}` : undefined} + data-qa-backend-ip-port noMarginTop disabled={disabled} - > - - Accept - - - Reject - - - Backup - - - Drain - - + /> - )} - - {(forEdit || idx !== 0) && ( - + /> + + {forEdit && ( + + ) => + onNodeModeChange(e, idx) + } + errorText={nodesErrorMap.mode} + data-qa-backend-ip-mode + noMarginTop + disabled={disabled} + > + + Accept + + + Reject + + + Backup + + + Drain + + + )} - + + {(forEdit || idx !== 0) && ( + + )} + +
-
-
- ); -}; + + ); + } +); + +const StyledActionsPanel = styled(ActionsPanel)(({ theme }) => ({ + display: 'flex', + alignItems: 'flex-end', + paddingLeft: theme.spacing(2), + marginLeft: `-${theme.spacing()}`, + [theme.breakpoints.down('lg')]: { + marginTop: `-${theme.spacing()}`, + }, + [theme.breakpoints.down('sm')]: { + marginTop: 0, + }, + '& .remove': { + margin: 0, + padding: theme.spacing(2.5), + }, +})); -export default React.memo(NodeBalancerConfigNode); +const StyledStatusHeader = styled(Typography, { + label: 'StyledStatusHeader', +})(({ theme }) => ({ + color: theme.color.label, + fontSize: '.9rem', + marginTop: `calc(${theme.spacing(2)} - 4)`, +})); + +const StyledStatusChip = styled(Chip, { + label: 'StyledStatusChip', +})>(({ theme, ...props }) => ({ + backgroundColor: + props.label === 'UP' + ? theme.color.green + : props.label === 'DOWN' + ? theme.color.red + : theme.color.grey2, + color: props?.node?.status ? theme.palette.text.primary : 'white', + marginTop: theme.spacing(1), +})); diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerConfigPanel.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerConfigPanel.tsx index a32fd5dbfe9..7432c2a05c4 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerConfigPanel.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerConfigPanel.tsx @@ -1,575 +1,273 @@ -import { NodeBalancerProxyProtocol } from '@linode/api-v4/lib/nodebalancers/types'; -import { APIError } from '@linode/api-v4/lib/types'; import * as React from 'react'; import ActionsPanel from 'src/components/ActionsPanel'; import Button from 'src/components/Button'; import Divider from 'src/components/core/Divider'; -import FormControlLabel from 'src/components/core/FormControlLabel'; import FormHelperText from 'src/components/core/FormHelperText'; -import InputAdornment from 'src/components/core/InputAdornment'; -import { createStyles, withStyles, WithStyles } from '@mui/styles'; -import { Theme } from '@mui/material/styles'; -import Typography from 'src/components/core/Typography'; -import Select, { Item } from 'src/components/EnhancedSelect/Select'; import Grid from '@mui/material/Unstable_Grid2'; import Link from 'src/components/Link'; -import { Notice } from 'src/components/Notice/Notice'; +import Select from 'src/components/EnhancedSelect/Select'; import TextField from 'src/components/TextField'; -import { Toggle } from 'src/components/Toggle'; -import { getErrorMap } from 'src/utilities/errorUtils'; -import NodeBalancerConfigNode from './NodeBalancerConfigNode'; -import { NodeBalancerConfigNodeFields } from './types'; - -type ClassNames = 'passiveChecks' | 'actionsPanel'; - -const styles = (theme: Theme) => - createStyles({ - passiveChecks: { - marginTop: 4, - }, - actionsPanel: { - paddingBottom: 0, - paddingRight: `${theme.spacing()} !important`, - }, - }); - -const styled = withStyles(styles); - -interface Props { - nodeBalancerRegion?: string; - errors?: APIError[]; - nodeMessage?: string; - configIdx?: number; - - forEdit?: boolean; - submitting?: boolean; - onSave?: () => void; - onDelete?: any; - - algorithm: 'roundrobin' | 'leastconn' | 'source'; - onAlgorithmChange: (v: string) => void; - - checkPassive: boolean; - onCheckPassiveChange: (v: boolean) => void; - - checkBody: string; - onCheckBodyChange: (v: string) => void; - - checkPath: string; - onCheckPathChange: (v: string) => void; - - port: number; - onPortChange: (v: string | number) => void; - - protocol: 'http' | 'https' | 'tcp'; - onProtocolChange: (v: string) => void; - - proxyProtocol: NodeBalancerProxyProtocol; - onProxyProtocolChange: (v: string) => void; - - healthCheckType: 'none' | 'connection' | 'http' | 'http_body'; - onHealthCheckTypeChange: (v: string) => void; - - healthCheckAttempts: number; - onHealthCheckAttemptsChange: (v: string | number) => void; - - healthCheckInterval: number; - onHealthCheckIntervalChange: (v: string | number) => void; - - healthCheckTimeout: number; - onHealthCheckTimeoutChange: (v: string | number) => void; - - sessionStickiness: 'none' | 'table' | 'http_cookie'; - onSessionStickinessChange: (v: string) => void; - - sslCertificate: string; - onSslCertificateChange: (v: string) => void; - - privateKey: string; - onPrivateKeyChange: (v: string) => void; - - nodes: NodeBalancerConfigNodeFields[]; - disabled?: boolean; - addNode: (nodeIdx?: number) => void; - removeNode: (nodeIdx: number) => void; - onNodeLabelChange: (nodeIdx: number, value: string) => void; - onNodeAddressChange: (nodeIdx: number, value: string) => void; - onNodePortChange: (nodeIdx: number, value: string) => void; - onNodeWeightChange: (nodeIdx: number, value: string) => void; - onNodeModeChange?: (nodeIdx: number, value: string) => void; -} - -type CombinedProps = Props & WithStyles; - -interface State { - currentNodeAddressIndex: number | null; -} +import Typography from 'src/components/core/Typography'; +import { ActiveCheck } from './NodeBalancerActiveCheck'; +import { NodeBalancerConfigNode } from './NodeBalancerConfigNode'; +import { Notice } from 'src/components/Notice/Notice'; +import { PassiveCheck } from './NodeBalancerPassiveCheck'; +import { setErrorMap } from './utils'; +import { styled } from '@mui/material/styles'; +import type { Item } from 'src/components/EnhancedSelect/Select'; +import type { NodeBalancerConfigPanelProps } from './types'; const DATA_NODE = 'data-node-idx'; -class NodeBalancerConfigPanel extends React.Component { - state: State = { - currentNodeAddressIndex: null, - }; - - static defaultProps: Partial = {}; - - onAlgorithmChange = (e: Item) => - this.props.onAlgorithmChange(e.value); - - onCheckPassiveChange = ( - e: React.ChangeEvent, - value: boolean - ) => this.props.onCheckPassiveChange(value); - - onCheckBodyChange = (e: React.ChangeEvent) => - this.props.onCheckBodyChange(e.target.value); - - onCheckPathChange = (e: React.ChangeEvent) => - this.props.onCheckPathChange(e.target.value); - - onHealthCheckAttemptsChange = (e: React.ChangeEvent) => - this.props.onHealthCheckAttemptsChange(e.target.value); - - onHealthCheckIntervalChange = (e: React.ChangeEvent) => - this.props.onHealthCheckIntervalChange(e.target.value); - - onHealthCheckTimeoutChange = (e: React.ChangeEvent) => - this.props.onHealthCheckTimeoutChange(e.target.value); - - onHealthCheckTypeChange = (e: Item) => - this.props.onHealthCheckTypeChange(e.value); - - onPortChange = (e: React.ChangeEvent) => - this.props.onPortChange(e.target.value); - - onPrivateKeyChange = (e: React.ChangeEvent) => - this.props.onPrivateKeyChange(e.target.value); - - onProtocolChange = (e: Item) => { - const { healthCheckType } = this.props; +export const NodeBalancerConfigPanel = ( + props: NodeBalancerConfigPanelProps +) => { + const { + algorithm, + configIdx, + errors, + forEdit, + nodes, + nodeMessage, + port, + privateKey, + protocol, + proxyProtocol, + sessionStickiness, + sslCertificate, + submitting, + disabled, + onSave, + } = props; + + const onAlgorithmChange = (e: Item) => + props.onAlgorithmChange(e.value); + + const onPortChange = (e: React.ChangeEvent) => + props.onPortChange(e.target.value); + + const onPrivateKeyChange = (e: React.ChangeEvent) => + props.onPrivateKeyChange(e.target.value); + + const onProtocolChange = (e: Item) => { + const { healthCheckType } = props; const { value: protocol } = e; - this.props.onProtocolChange(e.value); + props.onProtocolChange(e.value); if ( protocol === 'tcp' && (healthCheckType === 'http' || healthCheckType === 'http_body') ) { - this.props.onHealthCheckTypeChange('connection'); + props.onHealthCheckTypeChange('connection'); } if (protocol === 'http' && healthCheckType === 'connection') { - this.props.onHealthCheckTypeChange('http'); + props.onHealthCheckTypeChange('http'); } if (protocol === 'https' && healthCheckType === 'connection') { - this.props.onHealthCheckTypeChange('http'); + props.onHealthCheckTypeChange('http'); } }; - onProxyProtocolChange = (e: Item) => { - this.props.onProxyProtocolChange(e.value); + const onProxyProtocolChange = (e: Item) => { + props.onProxyProtocolChange(e.value); }; - onSessionStickinessChange = (e: Item) => - this.props.onSessionStickinessChange(e.value); + const onSessionStickinessChange = (e: Item) => + props.onSessionStickinessChange(e.value); - onSslCertificateChange = (e: React.ChangeEvent) => - this.props.onSslCertificateChange(e.target.value); + const onSslCertificateChange = (e: React.ChangeEvent) => + props.onSslCertificateChange(e.target.value); - onNodeLabelChange = (e: React.ChangeEvent) => { + const onNodeLabelChange = (e: React.ChangeEvent) => { const nodeIdx = e.currentTarget.getAttribute(DATA_NODE); if (nodeIdx) { - this.props.onNodeLabelChange(+nodeIdx, e.target.value); + props.onNodeLabelChange(+nodeIdx, e.target.value); } }; - onNodePortChange = (e: React.ChangeEvent) => { + const onNodePortChange = (e: React.ChangeEvent) => { const nodeIdx = e.currentTarget.getAttribute(DATA_NODE); if (nodeIdx) { - this.props.onNodePortChange(+nodeIdx, e.target.value); + props.onNodePortChange(+nodeIdx, e.target.value); } }; - onNodeWeightChange = (e: React.ChangeEvent) => { + const onNodeWeightChange = (e: React.ChangeEvent) => { const nodeIdx = e.currentTarget.getAttribute(DATA_NODE); if (nodeIdx) { - this.props.onNodeWeightChange(+nodeIdx, e.target.value); + props.onNodeWeightChange(+nodeIdx, e.target.value); } }; - onNodeModeChange = ( + const onNodeModeChange = ( e: React.ChangeEvent, nodeIdx: number ) => { - this.props.onNodeModeChange!(nodeIdx, e.target.value); + props.onNodeModeChange!(nodeIdx, e.target.value); }; - addNode = () => { - if (this.props.disabled) { + const addNode = () => { + if (props.disabled) { return; } - this.props.addNode(); + props.addNode(); }; - removeNode = (e: React.MouseEvent) => { - if (this.props.disabled) { + const removeNode = (e: React.MouseEvent) => { + if (props.disabled) { return; } const nodeIdx: string | null = e.currentTarget.getAttribute(DATA_NODE); - const { removeNode } = this.props; + const { removeNode } = props; if (removeNode && nodeIdx) { return removeNode(+nodeIdx); } }; - displayProtocolText = (p: string) => { - if (p === 'tcp') { - return `'TCP Connection' requires a successful TCP handshake.`; - } - if (p === 'http' || p === 'https') { - return `'HTTP Valid Status' requires a 2xx or 3xx response from the backend node. 'HTTP Body Regex' uses a regex to match against an expected result body.`; + const errorMap = setErrorMap(errors || []); + + const globalFormError = errorMap.none; + + const protocolOptions = [ + { label: 'TCP', value: 'tcp' }, + { label: 'HTTP', value: 'http' }, + { label: 'HTTPS', value: 'https' }, + ]; + + const proxyProtocolOptions = [ + { label: 'None', value: 'none' }, + { label: 'v1', value: 'v1' }, + { label: 'v2', value: 'v2' }, + ]; + + const defaultProtocol = protocolOptions.find((eachProtocol) => { + return eachProtocol.value === protocol; + }); + + const selectedProxyProtocol = proxyProtocolOptions.find( + (eachProxyProtocol) => { + return eachProxyProtocol.value === proxyProtocol; } - return undefined; - }; + ); - onSave = this.props.onSave; - - renderActiveCheck(errorMap: Record) { - const { - checkBody, - checkPath, - configIdx, - forEdit, - healthCheckAttempts, - healthCheckInterval, - healthCheckTimeout, - healthCheckType, - protocol, - disabled, - } = this.props; - - const conditionalText = this.displayProtocolText(protocol); - - const typeOptions = [ - { - label: 'None', - value: 'none', - }, - { - label: 'TCP Connection', - value: 'connection', - }, - { - label: 'HTTP Status', - value: 'http', - disabled: protocol === 'tcp', - }, - { - label: 'HTTP Body', - value: 'http_body', - disabled: protocol === 'tcp', - }, - ]; - - const defaultType = typeOptions.find((eachType) => { - return eachType.value === healthCheckType; - }); - - return ( - - - - - Active Health Checks - - + const algOptions = [ + { label: 'Round Robin', value: 'roundrobin' }, + { label: 'Least Connections', value: 'leastconn' }, + { label: 'Source', value: 'source' }, + ]; + + const defaultAlg = algOptions.find((eachAlg) => { + return eachAlg.value === algorithm; + }); + + const sessionOptions = [ + { label: 'None', value: 'none' }, + { label: 'Table', value: 'table' }, + { label: 'HTTP Cookie', value: 'http_cookie' }, + ]; + + const defaultSession = sessionOptions.find((eachSession) => { + return eachSession.value === sessionStickiness; + }); + + const tcpSelected = protocol === 'tcp'; + + return ( + + {globalFormError && ( + + )} + + + + Listen on this port + + + - - Active health checks proactively check the health of back-end - nodes. {conditionalText} - - - {healthCheckType !== 'none' && ( - + seconds - ), - }} - value={healthCheckInterval} - onChange={this.onHealthCheckIntervalChange} - errorText={errorMap.check_interval} + multiline + rows={3} + label="SSL Certificate" + value={sslCertificate || ''} + onChange={onSslCertificateChange} + required={protocol === 'https'} + errorText={errorMap.ssl_cert} errorGroup={forEdit ? `${configIdx}` : undefined} - data-qa-active-check-interval + data-qa-cert-field disabled={disabled} /> - - Seconds between health check probes - seconds - ), - }} - value={healthCheckTimeout} - onChange={this.onHealthCheckTimeoutChange} - errorText={errorMap.check_timeout} - errorGroup={forEdit ? `${configIdx}` : undefined} - data-qa-active-check-timeout - disabled={disabled} - /> - - Seconds to wait before considering the probe a failure. 1-30. - Must be less than check_interval. - - - - - - Number of failed probes before taking a node out of rotation. - 1-30 - - {['http', 'http_body'].includes(healthCheckType) && ( - - - - )} - {healthCheckType === 'http_body' && ( - - - - )} - - )} - - - ); - } - - renderPassiveCheck() { - const { checkPassive, classes, disabled } = this.props; - - return ( - - - - - Passive Checks - - - - - } - label="Passive Checks" - /> - - Enable passive checks based on observing communication with - back-end nodes. - + - - - ); - } - - render() { - const { - algorithm, - classes, - configIdx, - errors, - forEdit, - nodes, - nodeMessage, - port, - privateKey, - protocol, - proxyProtocol, - sessionStickiness, - sslCertificate, - submitting, - disabled, - } = this.props; - - // We don't want to end up with nodes[3].ip_address as errorMap.none - const filteredErrors = errors - ? errors.filter( - (thisError) => - !thisError.field || !thisError.field.match(/nodes\[[0-9+]\]/) - ) - : []; - - const errorMap = getErrorMap( - [ - 'algorithm', - 'check_attempts', - 'check_body', - 'check_interval', - 'check_path', - 'check_timeout', - 'check', - 'configs', - 'port', - 'protocol', - 'proxy_protocol', - 'ssl_cert', - 'ssl_key', - 'stickiness', - 'nodes', - ], - filteredErrors - ); - - const globalFormError = errorMap.none; - - const protocolOptions = [ - { label: 'TCP', value: 'tcp' }, - { label: 'HTTP', value: 'http' }, - { label: 'HTTPS', value: 'https' }, - ]; - - const proxyProtocolOptions = [ - { label: 'None', value: 'none' }, - { label: 'v1', value: 'v1' }, - { label: 'v2', value: 'v2' }, - ]; - - const defaultProtocol = protocolOptions.find((eachProtocol) => { - return eachProtocol.value === protocol; - }); - - const selectedProxyProtocol = proxyProtocolOptions.find( - (eachProxyProtocol) => { - return eachProxyProtocol.value === proxyProtocol; - } - ); - - const algOptions = [ - { label: 'Round Robin', value: 'roundrobin' }, - { label: 'Least Connections', value: 'leastconn' }, - { label: 'Source', value: 'source' }, - ]; - - const defaultAlg = algOptions.find((eachAlg) => { - return eachAlg.value === algorithm; - }); - - const sessionOptions = [ - { label: 'None', value: 'none' }, - { label: 'Table', value: 'table' }, - { label: 'HTTP Cookie', value: 'http_cookie' }, - ]; - - const defaultSession = sessionOptions.find((eachSession) => { - return eachSession.value === sessionStickiness; - }); - - const tcpSelected = protocol === 'tcp'; - - return ( - - {globalFormError && ( - )} - - - - Listen on this port - + + {tcpSelected && ( + + Roundrobin. Least connections assigns connections to the backend + with the least connections. Source uses the client’s IPv4 + address + + - {protocol === 'https' && ( + + + Backend Nodes + + {errorMap.nodes && ( + {errorMap.nodes} + )} + + + + {nodes?.map((node, nodeIdx) => ( + - - Proxy Protocol preserves initial TCP connection information. - Please consult{' '} - - our Proxy Protocol guide - - {` `} - for information on the differences between each option. - + ))} + + - )} - - - - - Route subsequent requests from the client to the same backend - - - - - - {this.renderActiveCheck(errorMap)} - {this.renderPassiveCheck()} + + + {(forEdit || configIdx !== 0) && ( + - - - - {nodeMessage && ( - - - - )} - - Backend Nodes - - {errorMap.nodes && ( - {errorMap.nodes} - )} - - - {nodes && - nodes.map((node, nodeIdx) => ( - - ))} - + + {(forEdit || configIdx !== 0) && ( - - + )} + {forEdit && ( + + )} + - - - {(forEdit || configIdx !== 0) && ( - - - - - - - {(forEdit || configIdx !== 0) && ( - - )} - {forEdit && ( - - )} - - - - )} - - ); - } -} - -export default styled(NodeBalancerConfigPanel) as React.ComponentType; + + )} + + ); +}; + +const StyledActionsPanel = styled(ActionsPanel, { + label: 'StyledActionsPanel', +})(({ theme }) => ({ + paddingBottom: 0, + paddingRight: `${theme.spacing()} !important`, +})); diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.tsx index 815175098ab..070cfb71032 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.tsx @@ -1,4 +1,29 @@ -import { APIError } from '@linode/api-v4/lib/types'; +import * as React from 'react'; +import ActionsPanel from 'src/components/ActionsPanel'; +import Button from 'src/components/Button'; +import Box from 'src/components/core/Box'; +import Accordion from 'src/components/Accordion'; +import Paper from 'src/components/core/Paper'; +import LandingHeader from 'src/components/LandingHeader'; +import TextField from 'src/components/TextField'; +import Typography from 'src/components/core/Typography'; +import SelectRegionPanel from 'src/components/SelectRegionPanel'; +import getAPIErrorFor from 'src/utilities/getAPIErrorFor'; +import scrollErrorIntoView from 'src/utilities/scrollErrorIntoView'; +import EUAgreementCheckbox from '../Account/Agreements/EUAgreementCheckbox'; +import { CheckoutSummary } from 'src/components/CheckoutSummary/CheckoutSummary'; +import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; +import { DocumentTitleSegment } from 'src/components/DocumentTitle'; +import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; +import { isEURegion } from 'src/utilities/formatRegion'; +import { NodeBalancerConfigPanel } from './NodeBalancerConfigPanel'; +import { Notice } from 'src/components/Notice/Notice'; +import { sendCreateNodeBalancerEvent } from 'src/utilities/ga'; +import { TagsInput, Tag } from 'src/components/TagsInput/TagsInput'; +import { useGrants, useProfile } from 'src/queries/profile'; +import { useHistory } from 'react-router-dom'; +import { useNodebalancerCreateMutation } from 'src/queries/nodebalancers'; +import { useRegionsQuery } from 'src/queries/regions'; import { append, clone, @@ -8,42 +33,17 @@ import { over, pathOr, } from 'ramda'; -import * as React from 'react'; -import { useHistory } from 'react-router-dom'; -import ActionsPanel from 'src/components/ActionsPanel'; -import Button from 'src/components/Button'; -import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; -import Typography from 'src/components/core/Typography'; -import { DocumentTitleSegment } from 'src/components/DocumentTitle'; -import { Notice } from 'src/components/Notice/Notice'; -import SelectRegionPanel from 'src/components/SelectRegionPanel'; -import { TagsInput, Tag } from 'src/components/TagsInput/TagsInput'; -import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; -import { isEURegion } from 'src/utilities/formatRegion'; -import { sendCreateNodeBalancerEvent } from 'src/utilities/ga'; -import getAPIErrorFor from 'src/utilities/getAPIErrorFor'; -import scrollErrorIntoView from 'src/utilities/scrollErrorIntoView'; -import EUAgreementCheckbox from '../Account/Agreements/EUAgreementCheckbox'; -import NodeBalancerConfigPanel from './NodeBalancerConfigPanel'; import { createNewNodeBalancerConfig, createNewNodeBalancerConfigNode, - NodeBalancerConfigFieldsWithStatus, transformConfigsForRequest, } from './utils'; import { useAccountAgreements, useMutateAccountAgreements, } from 'src/queries/accountAgreements'; -import LandingHeader from 'src/components/LandingHeader'; -import { useNodebalancerCreateMutation } from 'src/queries/nodebalancers'; -import Box from 'src/components/core/Box'; -import { CheckoutSummary } from 'src/components/CheckoutSummary/CheckoutSummary'; -import Accordion from 'src/components/Accordion'; -import Paper from 'src/components/core/Paper'; -import TextField from 'src/components/TextField'; -import { useGrants, useProfile } from 'src/queries/profile'; -import { useRegionsQuery } from 'src/queries/regions'; +import type { APIError } from '@linode/api-v4/lib/types'; +import type { NodeBalancerConfigFieldsWithStatus } from './types'; interface NodeBalancerFieldsState { label?: string; diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDeleteDialog.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDeleteDialog.tsx index 6661248fe75..58e90eb2862 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerDeleteDialog.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDeleteDialog.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { useHistory } from 'react-router-dom'; import Typography from 'src/components/core/Typography'; import { Notice } from 'src/components/Notice/Notice'; -import TypeToConfirmDialog from 'src/components/TypeToConfirmDialog'; +import { TypeToConfirmDialog } from 'src/components/TypeToConfirmDialog/TypeToConfirmDialog'; import { useNodebalancerDeleteMutation } from 'src/queries/nodebalancers'; interface Props { diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerConfigurations.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerConfigurations.tsx index 72718f10bd5..fce230e34ad 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerConfigurations.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerConfigurations.tsx @@ -30,8 +30,7 @@ import Accordion from 'src/components/Accordion'; import ActionsPanel from 'src/components/ActionsPanel'; import Button from 'src/components/Button'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; -import { createStyles, withStyles, WithStyles } from '@mui/styles'; -import { Theme } from '@mui/material/styles'; +import { styled } from '@mui/material/styles'; import Typography from 'src/components/core/Typography'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import Box from '@mui/material/Box'; @@ -40,14 +39,16 @@ import PromiseLoader, { } from 'src/components/PromiseLoader/PromiseLoader'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import scrollErrorIntoView from 'src/utilities/scrollErrorIntoView'; -import NodeBalancerConfigPanel from '../NodeBalancerConfigPanel'; +import { NodeBalancerConfigPanel } from '../NodeBalancerConfigPanel'; import { lensFrom } from '../NodeBalancerCreate'; -import { NodeBalancerConfigNodeFields } from '../types'; +import type { + NodeBalancerConfigNodeFields, + NodeBalancerConfigFieldsWithStatus, +} from '../types'; import { clampNumericString, createNewNodeBalancerConfig, createNewNodeBalancerConfigNode, - NodeBalancerConfigFieldsWithStatus, nodeForRequest, parseAddress, parseAddresses, @@ -59,29 +60,28 @@ import { WithQueryClientProps, } from 'src/containers/withQueryClient.container'; -type ClassNames = 'title' | 'port' | 'nbStatuses' | 'button'; +const StyledPortsSpan = styled('span', { + label: 'StyledPortsSpan', +})(({ theme }) => ({ + marginRight: theme.spacing(2), +})); + +const StyledNBStatusesTypography = styled(Typography, { + label: 'StyledNBStatusesTypography', +})(({ theme }) => ({ + display: 'block', + [theme.breakpoints.up('sm')]: { + display: 'inline', + }, +})); -const styles = (theme: Theme) => - createStyles({ - title: { - marginTop: theme.spacing(1), - marginBottom: theme.spacing(2), - }, - port: { - marginRight: theme.spacing(2), - }, - nbStatuses: { - display: 'block', - [theme.breakpoints.up('sm')]: { - display: 'inline', - }, - }, - button: { - [theme.breakpoints.down('lg')]: { - marginLeft: theme.spacing(), - }, - }, - }); +const StyledConfigsButton = styled(Button, { + label: 'StyledConfigsButton', +})(({ theme }) => ({ + [theme.breakpoints.down('lg')]: { + marginLeft: theme.spacing(), + }, +})); interface Props { nodeBalancerLabel: string; @@ -120,11 +120,7 @@ interface State { }; } -type CombinedProps = Props & - RouteProps & - WithStyles & - PreloadedProps & - WithQueryClientProps; +type CombinedProps = Props & RouteProps & PreloadedProps & WithQueryClientProps; const getConfigsWithNodes = (nodeBalancerId: number) => { return getNodeBalancerConfigs(nodeBalancerId).then((configs) => { @@ -969,7 +965,6 @@ class NodeBalancerConfigurations extends React.Component { const isNewConfig = this.state.hasUnsavedConfig && idx === this.state.configs.length - 1; const { panelNodeMessages } = this.state; - const { classes } = this.props; const lensTo = lensFrom(['configs', idx]); @@ -1006,18 +1001,17 @@ class NodeBalancerConfigurations extends React.Component { configErrors[idx], panelMessages[idx], panelNodeMessages[idx], - classes, ]} defaultExpanded={isNewConfig || isExpanded} success={panelMessages[idx]} heading={ - + Port {config.port !== undefined ? config.port : ''} - - + + {formatNodesStatus(config.nodes)} - + } > @@ -1107,7 +1101,7 @@ class NodeBalancerConfigurations extends React.Component { ); render() { - const { classes, nodeBalancerLabel } = this.props; + const { nodeBalancerLabel } = this.props; const { configs, configErrors, @@ -1128,16 +1122,15 @@ class NodeBalancerConfigurations extends React.Component { {!hasUnsavedConfig && ( - + )} @@ -1162,8 +1155,6 @@ class NodeBalancerConfigurations extends React.Component { } } -const styled = withStyles(styles); - const preloaded = PromiseLoader({ configs: (props) => { const { @@ -1176,7 +1167,6 @@ const preloaded = PromiseLoader({ }); const enhanced = composeC( - styled, withRouter, preloaded, withQueryClient diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerDetail.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerDetail.tsx index b611a044006..ce2f5ed1ed0 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerDetail.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerDetail.tsx @@ -2,13 +2,13 @@ import * as React from 'react'; import { CircleProgress } from 'src/components/CircleProgress'; import TabPanels from 'src/components/core/ReachTabPanels'; import Tabs from 'src/components/core/ReachTabs'; -import ErrorState from 'src/components/ErrorState'; +import { ErrorState } from 'src/components/ErrorState/ErrorState'; import { Notice } from 'src/components/Notice/Notice'; import SafeTabPanel from 'src/components/SafeTabPanel'; import TabLinkList from 'src/components/TabLinkList'; import NodeBalancerConfigurations from './NodeBalancerConfigurations'; import NodeBalancerSettings from './NodeBalancerSettings'; -import NodeBalancerSummary from './NodeBalancerSummary/NodeBalancerSummary'; +import { NodeBalancerSummary } from './NodeBalancerSummary/NodeBalancerSummary'; import { getErrorMap } from 'src/utilities/errorUtils'; import LandingHeader from 'src/components/LandingHeader'; import { @@ -22,7 +22,7 @@ import { useParams, } from 'react-router-dom'; -const NodeBalancerDetail = () => { +export const NodeBalancerDetail = () => { const history = useHistory(); const location = useLocation(); const { nodeBalancerId } = useParams<{ nodeBalancerId: string }>(); @@ -132,5 +132,3 @@ const NodeBalancerDetail = () => { ); }; - -export default NodeBalancerDetail; diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/NodeBalancerSummary.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/NodeBalancerSummary.tsx index 74855727a4c..303d04b4802 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/NodeBalancerSummary.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/NodeBalancerSummary.tsx @@ -1,28 +1,13 @@ import * as React from 'react'; import Grid from '@mui/material/Unstable_Grid2'; -import SummaryPanel from './SummaryPanel'; -import TablesPanel from './TablesPanel'; -import { useParams } from 'react-router-dom'; -import { makeStyles } from '@mui/styles'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; +import { styled } from '@mui/material/styles'; +import { SummaryPanel } from './SummaryPanel'; +import { TablesPanel } from './TablesPanel'; import { useNodeBalancerQuery } from 'src/queries/nodebalancers'; -import { Theme } from '@mui/material/styles'; - -const useStyles = makeStyles((theme: Theme) => ({ - main: { - [theme.breakpoints.up('md')]: { - order: 1, - }, - }, - sidebar: { - [theme.breakpoints.up('md')]: { - order: 2, - }, - }, -})); +import { useParams } from 'react-router-dom'; -const NodeBalancerSummary = () => { - const classes = useStyles(); +export const NodeBalancerSummary = () => { const { nodeBalancerId } = useParams<{ nodeBalancerId: string }>(); const id = Number(nodeBalancerId); const { data: nodebalancer } = useNodeBalancerQuery(id); @@ -31,15 +16,29 @@ const NodeBalancerSummary = () => {
- + - - + + - +
); }; -export default NodeBalancerSummary; +const StyledMainGridItem = styled(Grid, { + label: 'StyledMainGridItem', +})(({ theme }) => ({ + [theme.breakpoints.up('md')]: { + order: 1, + }, +})); + +const StyledSidebarGridItem = styled(Grid, { + label: 'StyledSidebarGridItem', +})(({ theme }) => ({ + [theme.breakpoints.up('md')]: { + order: 2, + }, +})); diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/SummaryPanel.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/SummaryPanel.tsx index c5039f1f22e..822d82f0ccb 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/SummaryPanel.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/SummaryPanel.tsx @@ -1,13 +1,11 @@ import * as React from 'react'; +import IPAddress from 'src/features/linodes/LinodesLanding/IPAddress'; import Paper from 'src/components/core/Paper'; import Typography from 'src/components/core/Typography'; -import { TagsPanel } from 'src/components/TagsPanel/TagsPanel'; -import summaryPanelStyles from 'src/containers/SummaryPanels.styles'; -import IPAddress from 'src/features/linodes/LinodesLanding/IPAddress'; -import { Link, useParams } from 'react-router-dom'; import { convertMegabytesTo } from 'src/utilities/unitConversions'; -import { Theme } from '@mui/material/styles'; -import { makeStyles } from '@mui/styles'; +import { Link, useParams } from 'react-router-dom'; +import { styled } from '@mui/material/styles'; +import { TagsPanel } from 'src/components/TagsPanel/TagsPanel'; import { useRegionsQuery } from 'src/queries/regions'; import { useAllNodeBalancerConfigsQuery, @@ -15,31 +13,7 @@ import { useNodebalancerUpdateMutation, } from 'src/queries/nodebalancers'; -const useStyles = makeStyles((theme: Theme) => ({ - ...summaryPanelStyles(theme), - root: { - paddingTop: theme.spacing(), - }, - NBsummarySection: { - [theme.breakpoints.up('md')]: { - marginTop: theme.spacing(6), - }, - }, - IPgrouping: { - margin: '-2px 0 0 2px', - display: 'flex', - flexDirection: 'column', - }, - nodeTransfer: { - marginTop: 12, - }, - hostName: { - wordBreak: 'break-word', - }, -})); - -const SummaryPanel = () => { - const classes = useStyles(); +export const SummaryPanel = () => { const { nodeBalancerId } = useParams<{ nodeBalancerId: string }>(); const id = Number(nodeBalancerId); const { data: nodebalancer } = useNodeBalancerQuery(id); @@ -67,79 +41,129 @@ const SummaryPanel = () => { } return ( -
- - - NodeBalancer Details - -
- - Ports: - {configPorts?.length === 0 && 'None'} - {configPorts?.map(({ port, configId }, i) => ( - - - {port} - - {i < configPorts?.length - 1 ? ', ' : ''} - - ))} - -
-
- - Backend Status: - {`${up} up, ${down} down`} - -
-
- - Transferred: - {convertMegabytesTo(nodebalancer.transfer.total)} - -
-
- - Host Name: - {nodebalancer.hostname} - -
-
- - Region: {region?.label} - -
-
- - + + + + + NodeBalancer Details + + + + Ports: + {configPorts?.length === 0 && 'None'} + {configPorts?.map(({ port, configId }, i) => ( + + + {port} + + {i < configPorts?.length - 1 ? ', ' : ''} + + ))} + + + + + Backend Status: + {`${up} up, ${down} down`} + + + + + Transferred: + {convertMegabytesTo(nodebalancer.transfer.total)} + + + + + Host Name: + {nodebalancer.hostname} + + + + + Region: {region?.label} + + + + + + IP Addresses - -
-
+ + + {nodebalancer?.ipv4 && ( )} {nodebalancer?.ipv6 && } -
-
-
- - - + + + + + Tags - + updateNodeBalancer({ tags })} /> - -
+ + ); }; -export default SummaryPanel; +const StyledRootDiv = styled('div', { + label: 'StyledRootDiv', +})(({ theme }) => ({ + paddingTop: theme.spacing(), +})); + +const StyledSummarySectionWrapper = styled('div', { + label: 'StyledSummarySectionWrapper', +})(({ theme }) => ({ + [theme.breakpoints.up('md')]: { + marginTop: theme.spacing(6), + }, +})); + +const StyledSummarySection = styled(Paper, { + label: 'StyledSummarySection', +})(({ theme }) => ({ + padding: theme.spacing(2.5), + marginBottom: theme.spacing(2), + minHeight: '160px', + height: '93%', +})); + +const StyledTitle = styled(Typography, { + label: 'StyledTitle', +})(({ theme }) => ({ + marginBottom: theme.spacing(2), +})); + +const StyledSection = styled('div', { + label: 'StyledSection', +})(({ theme }) => ({ + marginBottom: theme.spacing(1), + ...theme.typography.body1, + '& .dif': { + position: 'relative', + width: 'auto', + '& .chip': { + position: 'absolute', + top: '-4px', + right: -10, + }, + }, +})); + +const StyledIPGrouping = styled('div', { + label: 'StyledIPGrouping', +})(() => ({ + margin: '-2px 0 0 2px', + display: 'flex', + flexDirection: 'column', +})); diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/TablesPanel.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/TablesPanel.tsx index cf5a13a46e3..326b58d7cce 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/TablesPanel.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/TablesPanel.tsx @@ -1,9 +1,8 @@ import * as React from 'react'; import { CircleProgress } from 'src/components/CircleProgress'; import Paper from 'src/components/core/Paper'; -import { makeStyles } from '@mui/styles'; import Typography from 'src/components/core/Typography'; -import ErrorState from 'src/components/ErrorState'; +import { ErrorState } from 'src/components/ErrorState/ErrorState'; import LineGraph from 'src/components/LineGraph'; import MetricsDisplay from 'src/components/LineGraph/MetricsDisplay'; import getUserTimezone from 'src/utilities/getUserTimezone'; @@ -19,64 +18,12 @@ import { } from 'src/queries/nodebalancers'; import { useParams } from 'react-router-dom'; import { Theme, useTheme } from '@mui/material/styles'; - -const useStyles = makeStyles((theme: Theme) => ({ - header: { - padding: theme.spacing(2), - }, - chart: { - position: 'relative', - width: '100%', - paddingLeft: theme.spacing(1), - }, - bottomLegend: { - margin: `${theme.spacing(2)} ${theme.spacing(1)} ${theme.spacing(1)}`, - padding: 10, - color: '#777', - backgroundColor: theme.bg.offWhite, - border: `1px solid ${theme.color.border3}`, - fontSize: 14, - }, - graphControls: { - display: 'flex', - alignItems: 'center', - [theme.breakpoints.up('md')]: { - margin: `${theme.spacing(2)} 0`, - }, - }, - title: { - [theme.breakpoints.down('lg')]: { - marginLeft: theme.spacing(), - }, - }, - panel: { - padding: theme.spacing(2), - marginTop: theme.spacing(2), - }, - emptyText: { - textAlign: 'center', - marginTop: theme.spacing(), - }, -})); - -const Loading = () => ( -
- -
-); +import { styled } from '@mui/material/styles'; const STATS_NOT_READY_TITLE = 'Stats for this NodeBalancer are not available yet'; -const TablesPanel = () => { - const classes = useStyles(); +export const TablesPanel = () => { const theme = useTheme(); const { data: profile } = useProfile(); const timezone = getUserTimezone(profile?.timezone); @@ -107,14 +54,14 @@ const TablesPanel = () => { errorText={ <>
- + {STATS_NOT_READY_TITLE} - +
- + Connection stats will be available shortly - +
} @@ -134,7 +81,7 @@ const TablesPanel = () => { return ( -
+ { }, ]} /> -
-
+ + { }, ]} /> -
+
); }; @@ -178,14 +125,14 @@ const TablesPanel = () => { errorText={ <>
- + {STATS_NOT_READY_TITLE} - +
- + Traffic stats will be available shortly - +
} @@ -203,7 +150,7 @@ const TablesPanel = () => { return ( -
+ { }, ]} /> -
-
+ + { }, ]} /> -
+
); }; return ( -
- - Graphs - -
- - + + Graphs + + + Connections (CXN/s, 5 min avg.) - + {renderConnectionsChart()} - - - - Traffic (bits/s, 5 min avg.) - + + + Traffic (bits/s, 5 min avg.) {renderTrafficChart()} - +
); }; -export default TablesPanel; +const StyledHeader = styled(Typography, { + label: 'StyledHeader', +})(({ theme }) => ({ + padding: theme.spacing(2), +})); + +const StyledTitle = styled(Typography, { + label: 'StyledTitle', +})(({ theme }) => ({ + [theme.breakpoints.down('lg')]: { + marginLeft: theme.spacing(), + }, +})); + +const StyledChart = styled('div', { + label: 'StyledChart', +})(({ theme }) => ({ + position: 'relative', + width: '100%', + paddingLeft: theme.spacing(1), +})); + +const StyledBottomLegend = styled('div', { + label: 'StyledBottomLegend', +})(({ theme }) => ({ + margin: `${theme.spacing(2)} ${theme.spacing(1)} ${theme.spacing(1)}`, + padding: 10, + color: '#777', + backgroundColor: theme.bg.offWhite, + border: `1px solid ${theme.color.border3}`, + fontSize: 14, +})); + +const StyledgGraphControls = styled(Typography, { + label: 'StyledgGraphControls', +})(({ theme }) => ({ + display: 'flex', + alignItems: 'center', + [theme.breakpoints.up('md')]: { + margin: `${theme.spacing(2)} 0`, + }, +})); + +const StyledPanel = styled(Paper, { + label: 'StyledPanel', +})(({ theme }) => ({ + padding: theme.spacing(2), + marginTop: theme.spacing(2), +})); + +const StyledEmptyText = styled(Typography, { + label: 'StyledEmptyText', +})(({ theme }) => ({ + textAlign: 'center', + marginTop: theme.spacing(), +})); + +const Loading = () => ( +
+ +
+); diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerPassiveCheck.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerPassiveCheck.tsx new file mode 100644 index 00000000000..57effce6341 --- /dev/null +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerPassiveCheck.tsx @@ -0,0 +1,45 @@ +import * as React from 'react'; +import FormControlLabel from 'src/components/core/FormControlLabel'; +import FormHelperText from 'src/components/core/FormHelperText'; +import Grid from '@mui/material/Unstable_Grid2'; +import Typography from 'src/components/core/Typography'; +import { Toggle } from 'src/components/Toggle'; +import type { NodeBalancerConfigPanelProps } from './types'; + +export const PassiveCheck = (props: NodeBalancerConfigPanelProps) => { + const { checkPassive, disabled } = props; + + const onCheckPassiveChange = ( + e: React.ChangeEvent, + value: boolean + ) => props.onCheckPassiveChange(value); + + return ( + + + + + Passive Checks + + + + + } + label="Passive Checks" + /> + + Enable passive checks based on observing communication with back-end + nodes. + + + + + ); +}; diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancers.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancers.tsx index 371dc1d8a85..0ca68fe3501 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancers.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancers.tsx @@ -2,8 +2,10 @@ import * as React from 'react'; import { Route, Switch } from 'react-router-dom'; import { CircleProgress } from 'src/components/CircleProgress'; -const NodeBalancerDetail = React.lazy( - () => import('./NodeBalancerDetail/NodeBalancerDetail') +const NodeBalancerDetail = React.lazy(() => + import('./NodeBalancerDetail/NodeBalancerDetail').then((module) => ({ + default: module.NodeBalancerDetail, + })) ); const NodeBalancersLanding = React.lazy( () => import('./NodeBalancersLanding/NodeBalancersLanding') diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancersLanding.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancersLanding.tsx index 460a03507d4..11a4c3091af 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancersLanding.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancersLanding.tsx @@ -6,18 +6,18 @@ import { TableBody } from 'src/components/TableBody'; import { TableHead } from 'src/components/TableHead'; import { TableRow } from 'src/components/TableRow'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; -import ErrorState from 'src/components/ErrorState'; +import { ErrorState } from 'src/components/ErrorState/ErrorState'; import LandingHeader from 'src/components/LandingHeader'; import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter'; import { Table } from 'src/components/Table'; import { TableCell } from 'src/components/TableCell'; import { TableSortCell } from 'src/components/TableSortCell/TableSortCell'; -import TransferDisplay from 'src/components/TransferDisplay'; +import { TransferDisplay } from 'src/components/TransferDisplay/TransferDisplay'; import { useOrder } from 'src/hooks/useOrder'; import { usePagination } from 'src/hooks/usePagination'; import { useNodeBalancersQuery } from 'src/queries/nodebalancers'; import { NodeBalancerDeleteDialog } from '../NodeBalancerDeleteDialog'; -import NodeBalancersLandingEmptyState from './NodeBalancersLandingEmptyState'; +import { NodeBalancerLandingEmptyState } from './NodeBalancersLandingEmptyState'; import { NodeBalancerTableRow } from './NodeBalancerTableRow'; const preferenceKey = 'nodebalancers'; @@ -77,7 +77,7 @@ export const NodeBalancersLanding = () => { } if (data?.results === 0) { - return ; + return ; } return ( diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancersLandingEmptyState.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancersLandingEmptyState.tsx index 4702107c4cd..5a019adc965 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancersLandingEmptyState.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancersLandingEmptyState.tsx @@ -1,34 +1,22 @@ import * as React from 'react'; import { useHistory } from 'react-router-dom'; import NodeBalancer from 'src/assets/icons/entityIcons/nodebalancer.svg'; -import { makeStyles } from '@mui/styles'; -import { Theme } from '@mui/material/styles'; +import { styled } from '@mui/material/styles'; import Typography from 'src/components/core/Typography'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import Link from 'src/components/Link'; -import Placeholder from 'src/components/Placeholder'; +import { Placeholder } from 'src/components/Placeholder/Placeholder'; -const useStyles = makeStyles((theme: Theme) => ({ - placeholderAdjustment: { - padding: `${theme.spacing(2)} 0 0 0`, - [theme.breakpoints.up('md')]: { - padding: `${theme.spacing(10)} 0 0 0`, - }, - }, -})); - -const NodeBalancerLandingEmptyState = () => { +export const NodeBalancerLandingEmptyState = () => { const history = useHistory(); - const classes = useStyles(); return ( - history.push('/nodebalancers/create'), @@ -46,9 +34,15 @@ const NodeBalancerLandingEmptyState = () => { visit our guides and tutorials. - + ); }; -export default React.memo(NodeBalancerLandingEmptyState); +const StyledPlaceholder = styled(Placeholder, { + label: 'StyledPlaceholder', +})(({ theme }) => ({ + // this important rules can be removed when Placeholder is refactored + // and we can just use sx={{ paddingBottom: 0 }} on placeholder + padding: `${theme.spacing(2)} 0 0 0 !important`, +})); diff --git a/packages/manager/src/features/NodeBalancers/types.ts b/packages/manager/src/features/NodeBalancers/types.ts index 102303e5cf0..c50e7705582 100644 --- a/packages/manager/src/features/NodeBalancers/types.ts +++ b/packages/manager/src/features/NodeBalancers/types.ts @@ -4,6 +4,10 @@ import { } from '@linode/api-v4/lib/nodebalancers/types'; import { APIError } from '@linode/api-v4/lib/types'; +export interface NodeBalancerConfigFieldsWithStatus + extends NodeBalancerConfigFields { + modifyStatus?: 'new'; +} export interface ExtendedNodeBalancerConfigNode { id: number; label: string; @@ -52,3 +56,67 @@ export interface NodeBalancerConfigNodeFields { errors?: APIError[]; status?: 'UP' | 'DOWN' | 'unknown'; } + +export interface NodeBalancerConfigPanelProps { + nodeBalancerRegion?: string; + errors?: APIError[]; + nodeMessage?: string; + configIdx?: number; + + forEdit?: boolean; + submitting?: boolean; + onSave?: () => void; + onDelete?: any; + + algorithm: 'roundrobin' | 'leastconn' | 'source'; + onAlgorithmChange: (v: string) => void; + + checkPassive: boolean; + onCheckPassiveChange: (v: boolean) => void; + + checkBody: string; + onCheckBodyChange: (v: string) => void; + + checkPath: string; + onCheckPathChange: (v: string) => void; + + port: number; + onPortChange: (v: string | number) => void; + + protocol: 'http' | 'https' | 'tcp'; + onProtocolChange: (v: string) => void; + + proxyProtocol: NodeBalancerProxyProtocol; + onProxyProtocolChange: (v: string) => void; + + healthCheckType: 'none' | 'connection' | 'http' | 'http_body'; + onHealthCheckTypeChange: (v: string) => void; + + healthCheckAttempts: number; + onHealthCheckAttemptsChange: (v: string | number) => void; + + healthCheckInterval: number; + onHealthCheckIntervalChange: (v: string | number) => void; + + healthCheckTimeout: number; + onHealthCheckTimeoutChange: (v: string | number) => void; + + sessionStickiness: 'none' | 'table' | 'http_cookie'; + onSessionStickinessChange: (v: string) => void; + + sslCertificate: string; + onSslCertificateChange: (v: string) => void; + + privateKey: string; + onPrivateKeyChange: (v: string) => void; + + nodes: NodeBalancerConfigNodeFields[]; + disabled?: boolean; + addNode: (nodeIdx?: number) => void; + removeNode: (nodeIdx: number) => void; + onNodeLabelChange: (nodeIdx: number, value: string) => void; + onNodeAddressChange: (nodeIdx: number, value: string) => void; + onNodePortChange: (nodeIdx: number, value: string) => void; + onNodeWeightChange: (nodeIdx: number, value: string) => void; + onNodeModeChange?: (nodeIdx: number, value: string) => void; +} diff --git a/packages/manager/src/features/NodeBalancers/utils.ts b/packages/manager/src/features/NodeBalancers/utils.ts index 76ba3696b50..b211efbb8c1 100644 --- a/packages/manager/src/features/NodeBalancers/utils.ts +++ b/packages/manager/src/features/NodeBalancers/utils.ts @@ -1,16 +1,14 @@ -import { NodeBalancerConfigNode } from '@linode/api-v4/lib/nodebalancers'; import { clamp, compose, filter, isNil, toString } from 'ramda'; -import { +import { defaultNumeric } from 'src/utilities/defaultNumeric'; +import { getErrorMap } from 'src/utilities/errorUtils'; +import type { APIError } from '@linode/api-v4'; +import type { NodeBalancerConfigNode } from '@linode/api-v4/lib/nodebalancers'; +import type { ExtendedNodeBalancerConfigNode, NodeBalancerConfigNodeFields, NodeBalancerConfigFields, + NodeBalancerConfigFieldsWithStatus, } from './types'; -import { defaultNumeric } from 'src/utilities/defaultNumeric'; - -export interface NodeBalancerConfigFieldsWithStatus - extends NodeBalancerConfigFields { - modifyStatus?: 'new'; -} export const clampNumericString = (low: number, hi: number) => compose(toString, clamp(low, hi), (value: number) => @@ -161,3 +159,34 @@ export const shouldIncludeCheckPath = (config: NodeBalancerConfigFields) => { export const shouldIncludeCheckBody = (config: NodeBalancerConfigFields) => { return config.check === 'http_body' && config.check_body; }; + +// We don't want to end up with nodes[3].ip_address as errorMap.none +const filteredErrors = (errors: APIError[]) => + errors + ? errors.filter( + (thisError) => + !thisError.field || !thisError.field.match(/nodes\[[0-9+]\]/) + ) + : []; + +export const setErrorMap = (errors: APIError[]) => + getErrorMap( + [ + 'algorithm', + 'check_attempts', + 'check_body', + 'check_interval', + 'check_path', + 'check_timeout', + 'check', + 'configs', + 'port', + 'protocol', + 'proxy_protocol', + 'ssl_cert', + 'ssl_key', + 'stickiness', + 'nodes', + ], + filteredErrors(errors) + ); diff --git a/packages/manager/src/features/NotificationCenter/Events.tsx b/packages/manager/src/features/NotificationCenter/Events.tsx index 0266af787b0..89a16b94782 100644 --- a/packages/manager/src/features/NotificationCenter/Events.tsx +++ b/packages/manager/src/features/NotificationCenter/Events.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import { useEventNotifications } from './NotificationData/useEventNotifications'; -import NotificationSection from './NotificationSection'; +import { NotificationSection } from './NotificationSection'; const NUM_EVENTS_DISPLAY = 20; diff --git a/packages/manager/src/features/NotificationCenter/NotificationData/RenderEvent.styles.ts b/packages/manager/src/features/NotificationCenter/NotificationData/RenderEvent.styles.ts new file mode 100644 index 00000000000..7267b42de7b --- /dev/null +++ b/packages/manager/src/features/NotificationCenter/NotificationData/RenderEvent.styles.ts @@ -0,0 +1,37 @@ +import Box from 'src/components/core/Box'; +import { GravatarByUsername } from 'src/components/GravatarByUsername'; +import { makeStyles } from 'tss-react/mui'; +import { styled } from '@mui/material/styles'; +import { Theme } from '@mui/material/styles'; + +export const RenderEventStyledBox = styled(Box, { + label: 'StyledBox', +})(({ theme }) => ({ + color: theme.textColors.tableHeader, + paddingLeft: '20px', + paddingRight: '20px', + '&:hover': { + backgroundColor: theme.bg.app, + }, + gap: 16, + paddingBottom: 12, + paddingTop: 12, + width: '100%', +})); + +export const RenderEventGravatar = styled(GravatarByUsername, { + label: 'StyledGravatarByUsername', +})(() => ({ + height: 40, + minWidth: 40, +})); + +export const useRenderEventStyles = makeStyles()((theme: Theme) => ({ + bar: { + marginTop: theme.spacing(), + }, + unseenEvent: { + color: theme.textColors.headlineStatic, + textDecoration: 'none', + }, +})); diff --git a/packages/manager/src/features/NotificationCenter/NotificationData/RenderEvent.tsx b/packages/manager/src/features/NotificationCenter/NotificationData/RenderEvent.tsx index c83bc01111c..725f78b7cae 100644 --- a/packages/manager/src/features/NotificationCenter/NotificationData/RenderEvent.tsx +++ b/packages/manager/src/features/NotificationCenter/NotificationData/RenderEvent.tsx @@ -1,101 +1,54 @@ -import { Event } from '@linode/api-v4/lib/account/types'; -import classNames from 'classnames'; import * as React from 'react'; import Box from 'src/components/core/Box'; +import classNames from 'classnames'; import Divider from 'src/components/core/Divider'; -import { makeStyles } from '@mui/styles'; -import { Theme } from '@mui/material/styles'; -import Typography from 'src/components/core/Typography'; import HighlightedMarkdown from 'src/components/HighlightedMarkdown'; -import { GravatarByUsername } from 'src/components/GravatarByUsername'; -import { parseAPIDate } from 'src/utilities/date'; +import Typography from 'src/components/core/Typography'; import useEventInfo from './useEventInfo'; +import { Event } from '@linode/api-v4/lib/account/types'; +import { parseAPIDate } from 'src/utilities/date'; +import { + RenderEventGravatar, + RenderEventStyledBox, + useRenderEventStyles, +} from './RenderEvent.styles'; import { useApplicationStore } from 'src/store'; -export const useStyles = makeStyles((theme: Theme) => ({ - root: { - paddingTop: 12, - paddingBottom: 12, - width: '100%', - gap: 16, - }, - icon: { - height: 40, - minWidth: 40, - }, - event: { - color: theme.textColors.tableHeader, - '&:hover': { - backgroundColor: theme.bg.app, - // Extends the hover state to the edges of the drawer - marginLeft: -20, - marginRight: -20, - paddingLeft: 20, - paddingRight: 20, - width: 'calc(100% + 40px)', - }, - }, - eventMessage: { - marginTop: 2, - }, - unseenEvent: { - color: theme.textColors.headlineStatic, - textDecoration: 'none', - }, -})); - -interface Props { +interface RenderEventProps { event: Event; onClose: () => void; } -export const RenderEvent: React.FC = (props) => { +export const RenderEvent = React.memo((props: RenderEventProps) => { const store = useApplicationStore(); - const classes = useStyles(); - + const { classes } = useRenderEventStyles(); const { event } = props; const { message } = useEventInfo(event, store); + const unseenEventClass = classNames({ [classes.unseenEvent]: !event.seen }); + if (message === null) { return null; } const eventMessage = ( -
+
); return ( <> - - -
+ + + {eventMessage} - + {parseAPIDate(event.created).toRelative()} -
-
+ + ); -}; - -export default React.memo(RenderEvent); +}); diff --git a/packages/manager/src/features/NotificationCenter/NotificationData/RenderProgressEvent.tsx b/packages/manager/src/features/NotificationCenter/NotificationData/RenderProgressEvent.tsx index 342a5546bc4..00ea13f3bd6 100644 --- a/packages/manager/src/features/NotificationCenter/NotificationData/RenderProgressEvent.tsx +++ b/packages/manager/src/features/NotificationCenter/NotificationData/RenderProgressEvent.tsx @@ -1,29 +1,23 @@ -import { Event } from '@linode/api-v4/lib/account/types'; -import classNames from 'classnames'; -import { Duration } from 'luxon'; import * as React from 'react'; import BarPercent from 'src/components/BarPercent'; import Box from 'src/components/core/Box'; import Divider from 'src/components/core/Divider'; -import { makeStyles } from '@mui/styles'; -import { Theme } from '@mui/material/styles'; import Typography from 'src/components/core/Typography'; +import useLinodes from 'src/hooks/useLinodes'; +import { Duration } from 'luxon'; +import { Event } from '@linode/api-v4/lib/account/types'; +import { extendTypesQueryResult } from 'src/utilities/extendType'; +import { isNotNullOrUndefined } from 'src/utilities/nullOrUndefined'; +import { useSpecificTypes } from 'src/queries/types'; import { eventLabelGenerator, eventMessageGenerator, } from 'src/eventMessageGenerator_CMR'; -import { GravatarByUsername } from 'src/components/GravatarByUsername'; -import useLinodes from 'src/hooks/useLinodes'; -import { useSpecificTypes } from 'src/queries/types'; -import { useStyles as useEventStyles } from './RenderEvent'; -import { extendTypesQueryResult } from 'src/utilities/extendType'; -import { isNotNullOrUndefined } from 'src/utilities/nullOrUndefined'; - -const useStyles = makeStyles((theme: Theme) => ({ - bar: { - marginTop: theme.spacing(), - }, -})); +import { + RenderEventGravatar, + RenderEventStyledBox, + useRenderEventStyles, +} from './RenderEvent.styles'; interface Props { event: Event; @@ -33,10 +27,8 @@ interface Props { export type CombinedProps = Props; export const RenderProgressEvent: React.FC = (props) => { + const { classes } = useRenderEventStyles(); const { event } = props; - const eventClasses = useEventStyles(); - const classes = useStyles(); - const { linodes } = useLinodes(); const _linodes = Object.values(linodes.itemsById); const typesQuery = useSpecificTypes( @@ -66,19 +58,9 @@ export const RenderProgressEvent: React.FC = (props) => { return ( <> - - -
+ + + {eventMessage} = (props) => { rounded narrow /> -
-
+ + ); diff --git a/packages/manager/src/features/NotificationCenter/NotificationData/useEventInfo.ts b/packages/manager/src/features/NotificationCenter/NotificationData/useEventInfo.ts index 626efb7a426..571f687ce9e 100644 --- a/packages/manager/src/features/NotificationCenter/NotificationData/useEventInfo.ts +++ b/packages/manager/src/features/NotificationCenter/NotificationData/useEventInfo.ts @@ -6,7 +6,7 @@ import { getEntityByIDFromStore, } from 'src/utilities/getEntityByIDFromStore'; import { formatEventSeconds } from 'src/utilities/minute-conversion/minute-conversion'; -import { Variant } from 'src/components/EntityIcon'; +import type { EntityVariants } from 'src/components/EntityIcon/EntityIcon'; import { ApplicationStore } from 'src/store'; /** @@ -17,7 +17,7 @@ import { ApplicationStore } from 'src/store'; export interface EventInfo { duration: string; message: string | null; - type: Variant; + type: EntityVariants; status?: string; } @@ -26,7 +26,7 @@ export const useEventInfo = ( store: ApplicationStore ): EventInfo => { const message = eventMessageGenerator(event); - const type = (event.entity?.type ?? 'linode') as Variant; + const type = (event.entity?.type ?? 'linode') as EntityVariants; const entity = getEntityByIDFromStore( type as EntityType, diff --git a/packages/manager/src/features/NotificationCenter/NotificationData/useEventNotifications.tsx b/packages/manager/src/features/NotificationCenter/NotificationData/useEventNotifications.tsx index 823b03a7ac1..10d1f91c955 100644 --- a/packages/manager/src/features/NotificationCenter/NotificationData/useEventNotifications.tsx +++ b/packages/manager/src/features/NotificationCenter/NotificationData/useEventNotifications.tsx @@ -1,14 +1,14 @@ -import { Event, EventAction } from '@linode/api-v4/lib/account/types'; -import { partition } from 'ramda'; import * as React from 'react'; import useEvents from 'src/hooks/useEvents'; -import { isInProgressEvent } from 'src/store/events/event.helpers'; +import RenderProgressEvent from './RenderProgressEvent'; +import { Event, EventAction } from '@linode/api-v4/lib/account/types'; import { ExtendedEvent } from 'src/store/events/event.types'; -import { removeBlocklistedEvents } from 'src/utilities/eventUtils'; +import { isInProgressEvent } from 'src/store/events/event.helpers'; import { notificationContext as _notificationContext } from '../NotificationContext'; import { NotificationItem } from '../NotificationSection'; -import RenderEvent from './RenderEvent'; -import RenderProgressEvent from './RenderProgressEvent'; +import { partition } from 'ramda'; +import { removeBlocklistedEvents } from 'src/utilities/eventUtils'; +import { RenderEvent } from './RenderEvent'; const unwantedEvents: EventAction[] = [ 'account_update', diff --git a/packages/manager/src/features/NotificationCenter/NotificationData/useFormattedNotifications.tsx b/packages/manager/src/features/NotificationCenter/NotificationData/useFormattedNotifications.tsx index 5edcaad4546..f55b3867b74 100644 --- a/packages/manager/src/features/NotificationCenter/NotificationData/useFormattedNotifications.tsx +++ b/packages/manager/src/features/NotificationCenter/NotificationData/useFormattedNotifications.tsx @@ -1,29 +1,28 @@ -import { - Notification, - NotificationSeverity, - NotificationType, -} from '@linode/api-v4/lib/account'; -import { Region } from '@linode/api-v4/lib/regions'; -import { DateTime } from 'luxon'; -import { path } from 'ramda'; import * as React from 'react'; -import Button from 'src/components/Button'; -import { makeStyles } from '@mui/styles'; -import { Theme, styled } from '@mui/material/styles'; +import RenderNotification from './RenderNotification'; import Typography from 'src/components/core/Typography'; -import { Link } from 'src/components/Link'; -import { complianceUpdateContext } from 'src/context/complianceUpdateContext'; -import { reportException } from 'src/exceptionReporting'; import useDismissibleNotifications from 'src/hooks/useDismissibleNotifications'; -import { useRegionsQuery } from 'src/queries/regions'; +import { checkIfMaintenanceNotification } from './notificationUtils'; +import { complianceUpdateContext } from 'src/context/complianceUpdateContext'; +import { DateTime } from 'luxon'; import { formatDate } from 'src/utilities/formatDate'; +import { Link } from 'src/components/Link'; +import { LinkStyledButton } from 'src/components/Button/LinkStyledButton'; import { notificationContext as _notificationContext } from '../NotificationContext'; import { NotificationItem } from '../NotificationSection'; -import { checkIfMaintenanceNotification } from './notificationUtils'; -import RenderNotification from './RenderNotification'; -import { useNotificationsQuery } from 'src/queries/accountNotifications'; +import { path } from 'ramda'; import { Profile } from '@linode/api-v4'; +import { Region } from '@linode/api-v4/lib/regions'; +import { reportException } from 'src/exceptionReporting'; +import { styled } from '@mui/material/styles'; +import { useNotificationsQuery } from 'src/queries/accountNotifications'; import { useProfile } from 'src/queries/profile'; +import { useRegionsQuery } from 'src/queries/regions'; +import { + Notification, + NotificationSeverity, + NotificationType, +} from '@linode/api-v4/lib/account'; export interface ExtendedNotification extends Notification { jsx?: JSX.Element; @@ -380,15 +379,7 @@ export const adjustSeverity = ({ return severity; }; -const useComplianceNotificationStyles = makeStyles((theme: Theme) => ({ - reviewUpdateButton: { - ...theme.applyLinkStyles, - minHeight: 0, - }, -})); - const ComplianceNotification: React.FC<{}> = () => { - const classes = useComplianceNotificationStyles(); const complianceModelContext = React.useContext(complianceUpdateContext); return ( @@ -396,12 +387,12 @@ const ComplianceNotification: React.FC<{}> = () => { Please review the compliance update for guidance regarding the EU Standard Contractual Clauses and its application to users located in Europe as well as deployments in Linode’s London and Frankfurt data centers - + ); }; diff --git a/packages/manager/src/features/NotificationCenter/NotificationSection.tsx b/packages/manager/src/features/NotificationCenter/NotificationSection.tsx index 1b0e83303b8..fc6a3d40386 100644 --- a/packages/manager/src/features/NotificationCenter/NotificationSection.tsx +++ b/packages/manager/src/features/NotificationCenter/NotificationSection.tsx @@ -1,53 +1,33 @@ -import KeyboardArrowDown from '@mui/icons-material/KeyboardArrowDown'; -import classNames from 'classnames'; import * as React from 'react'; -import { Link } from 'react-router-dom'; -import { CircleProgress } from 'src/components/CircleProgress'; import Box from 'src/components/core/Box'; +import classNames from 'classnames'; +import ExtendedAccordion from 'src/components/ExtendedAccordion'; import Hidden from 'src/components/core/Hidden'; -import { makeStyles } from '@mui/styles'; -import { Theme, useTheme } from '@mui/material/styles'; +import KeyboardArrowDown from '@mui/icons-material/KeyboardArrowDown'; import Typography from 'src/components/core/Typography'; -import ExtendedAccordion from 'src/components/ExtendedAccordion'; +import { CircleProgress } from 'src/components/CircleProgress'; +import { Link } from 'react-router-dom'; +import { LinkStyledButton } from 'src/components/Button/LinkStyledButton'; +import { makeStyles } from 'tss-react/mui'; import { menuLinkStyle } from 'src/features/TopMenu/UserMenu/UserMenu'; +import { styled } from '@mui/material/styles'; +import type { Theme } from '@mui/material/styles'; -const useStyles = makeStyles((theme: Theme) => ({ - root: { - display: 'flex', - flexWrap: 'nowrap', - alignItems: 'flex-start', - justifyContent: 'flex-start', +const useStyles = makeStyles()((theme: Theme) => ({ + inverted: { + transform: 'rotate(180deg)', }, notificationSpacing: { marginBottom: theme.spacing(2), - }, - header: { - display: 'flex', - alignItems: 'center', - justifyContent: 'space-between', - borderBottom: `solid 1px ${theme.borderColors.borderTypography}`, - marginBottom: 6, - paddingBottom: theme.spacing(1), - }, - content: { - width: '100%', - }, - loading: { - display: 'flex', - justifyContent: 'center', - }, - notificationItem: { - display: 'flex', - justifyContent: 'space-between', - fontSize: '0.875rem', - width: '100%', - '& p': { - color: theme.textColors.headlineStatic, - lineHeight: '1.25rem', + '& > div:not(:first-child)': { + padding: '0 20px', + margin: `${theme.spacing()} 0`, }, }, + menuItemLink: { + ...menuLinkStyle(theme.textColors.linkActiveLight), + }, showMore: { - ...theme.applyLinkStyles, fontSize: 14, fontWeight: 'bold', paddingTop: theme.spacing(), @@ -57,20 +37,6 @@ const useStyles = makeStyles((theme: Theme) => ({ textDecoration: 'none', }, }, - caret: { - color: theme.palette.primary.main, - marginRight: -4, - }, - inverted: { - transform: 'rotate(180deg)', - }, - emptyMessage: { - marginTop: theme.spacing(), - marginBottom: theme.spacing(2.5), - }, - menuItemLink: { - ...menuLinkStyle(theme.textColors.linkActiveLight), - }, })); export interface NotificationItem { @@ -79,7 +45,7 @@ export interface NotificationItem { countInTotal: boolean; } -interface Props { +interface NotificationSectionProps { header: string; count?: number; showMoreText?: string; @@ -89,10 +55,8 @@ interface Props { emptyMessage?: string; } -export type CombinedProps = Props; - -export const NotificationSection: React.FC = (props) => { - const classes = useStyles(); +export const NotificationSection = (props: NotificationSectionProps) => { + const { classes } = useStyles(); const { content, @@ -127,14 +91,13 @@ export const NotificationSection: React.FC = (props) => { {isActualNotificationContainer && content.length === 0 ? null : ( <> -
-
-
+ + {header} {showMoreTarget && ( @@ -147,7 +110,7 @@ export const NotificationSection: React.FC = (props) => { )} -
+ = (props) => { header={header} emptyMessage={emptyMessage} /> -
-
+ +
@@ -186,9 +149,8 @@ interface BodyProps { loading: boolean; } -const ContentBody: React.FC = React.memo((props) => { - const theme = useTheme(); - const classes = useStyles(); +const ContentBody = React.memo((props: BodyProps) => { + const { classes } = useStyles(); const { header, content, count, emptyMessage, loading } = props; @@ -196,9 +158,9 @@ const ContentBody: React.FC = React.memo((props) => { if (loading) { return ( -
+ -
+ ); } @@ -208,43 +170,98 @@ const ContentBody: React.FC = React.memo((props) => { // eslint-disable-next-line <> {_content.map((thisItem) => ( - {thisItem.body} - + ))} {content.length > count ? ( - - - + + ) : null} ) : header === 'Events' ? ( - + {emptyMessage ? emptyMessage : `You have no ${header.toLocaleLowerCase()}.`} - + ) : null; }); -export default React.memo(NotificationSection); +const StyledRootContainer = styled('div', { + label: 'StyledRootContainer', +})(() => ({ + alignItems: 'flex-start', + display: 'flex', + flexWrap: 'nowrap', + justifyContent: 'flex-start', +})); + +const StyledHeader = styled('div', { + label: 'StyledHeader', +})(({ theme }) => ({ + alignItems: 'center', + borderBottom: `solid 1px ${theme.borderColors.borderTable}`, + display: 'flex', + justifyContent: 'space-between', + padding: `0 20px ${theme.spacing()}`, +})); + +const StyledLoadingContainer = styled('div', { + label: 'StyledLoadingContainer', +})(() => ({ + display: 'flex', + justifyContent: 'center', +})); + +const StyledLToggleContainer = styled(Box, { + label: 'StyledLToggleButton', +})(({ theme }) => ({ + padding: `0 16px ${theme.spacing()}`, +})); + +const StyledNotificationItem = styled(Box, { + shouldForwardProp: (prop) => prop !== 'content', + label: 'StyledNotificationItem', +})(({ theme, ...props }) => ({ + display: 'flex', + fontSize: '0.875rem', + justifyContent: 'space-between', + width: '100%', + padding: props.header === 'Notifications' ? `${theme.spacing(1.5)} 20px` : 0, + '& p': { + color: theme.textColors.headlineStatic, + lineHeight: '1.25rem', + }, +})); + +const StyledCaret = styled(KeyboardArrowDown)(({ theme }) => ({ + color: theme.palette.primary.main, + marginLeft: theme.spacing(), +})); + +const StyledEmptyMessage = styled(Typography)(({ theme }) => ({ + marginTop: theme.spacing(), + marginBottom: theme.spacing(2.5), + padding: `0 20px`, +})); diff --git a/packages/manager/src/features/NotificationCenter/Notifications.tsx b/packages/manager/src/features/NotificationCenter/Notifications.tsx index d384086caf3..15916dd352b 100644 --- a/packages/manager/src/features/NotificationCenter/Notifications.tsx +++ b/packages/manager/src/features/NotificationCenter/Notifications.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; +import { NotificationSection } from './NotificationSection'; import { useFormattedNotifications } from './NotificationData/useFormattedNotifications'; -import NotificationSection from './NotificationSection'; export const Notifications = () => { const notifications = useFormattedNotifications(); diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/BucketSSL.tsx b/packages/manager/src/features/ObjectStorage/BucketDetail/BucketSSL.tsx index f0094469376..ab2b466c37a 100644 --- a/packages/manager/src/features/ObjectStorage/BucketDetail/BucketSSL.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketDetail/BucketSSL.tsx @@ -13,7 +13,7 @@ import Paper from 'src/components/core/Paper'; import { makeStyles } from '@mui/styles'; import { Theme } from '@mui/material/styles'; import Typography from 'src/components/core/Typography'; -import ErrorState from 'src/components/ErrorState'; +import { ErrorState } from 'src/components/ErrorState/ErrorState'; import ExternalLink from 'src/components/ExternalLink'; import Grid from '@mui/material/Unstable_Grid2'; import { Notice } from 'src/components/Notice/Notice'; diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/FolderTableRow.tsx b/packages/manager/src/features/ObjectStorage/BucketDetail/FolderTableRow.tsx index 7e5d963d1a9..934c95d133b 100644 --- a/packages/manager/src/features/ObjectStorage/BucketDetail/FolderTableRow.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketDetail/FolderTableRow.tsx @@ -3,7 +3,7 @@ import { Link } from 'react-router-dom'; import Hidden from 'src/components/core/Hidden'; import { makeStyles } from '@mui/styles'; import { Theme } from '@mui/material/styles'; -import EntityIcon from 'src/components/EntityIcon'; +import { EntityIcon } from 'src/components/EntityIcon/EntityIcon'; import Grid from '@mui/material/Unstable_Grid2'; import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectTableRow.tsx b/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectTableRow.tsx index 9a99e2383e9..c61e91f9732 100644 --- a/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectTableRow.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectTableRow.tsx @@ -5,7 +5,7 @@ import { makeStyles } from '@mui/styles'; import { Theme } from '@mui/material/styles'; import Typography from 'src/components/core/Typography'; import { DateTimeDisplay } from 'src/components/DateTimeDisplay'; -import EntityIcon from 'src/components/EntityIcon'; +import { EntityIcon } from 'src/components/EntityIcon/EntityIcon'; import Grid from '@mui/material/Unstable_Grid2'; import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketLanding.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketLanding.tsx index 4b5c86d6f72..7e049969555 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketLanding.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketLanding.tsx @@ -2,41 +2,38 @@ import { ObjectStorageBucket, ObjectStorageCluster, } from '@linode/api-v4/lib/object-storage'; -import { APIError } from '@linode/api-v4/lib/types'; -import classNames from 'classnames'; import * as React from 'react'; -import BucketIcon from 'src/assets/icons/entityIcons/bucket.svg'; -import { CircleProgress } from 'src/components/CircleProgress'; -import { makeStyles } from '@mui/styles'; -import { Theme } from '@mui/material/styles'; -import Typography from 'src/components/core/Typography'; -import { DocumentTitleSegment } from 'src/components/DocumentTitle'; -import ErrorState from 'src/components/ErrorState'; +import BucketDetailsDrawer from './BucketDetailsDrawer'; +import BucketTable from './BucketTable'; +import CancelNotice from '../CancelNotice'; +import { ErrorState } from 'src/components/ErrorState/ErrorState'; import Grid from '@mui/material/Unstable_Grid2'; import { Notice } from 'src/components/Notice/Notice'; import OrderBy from 'src/components/OrderBy'; -import Placeholder from 'src/components/Placeholder'; -import TransferDisplay from 'src/components/TransferDisplay'; -import TypeToConfirmDialog from 'src/components/TypeToConfirmDialog'; +import { TransferDisplay } from 'src/components/TransferDisplay/TransferDisplay'; +import { TypeToConfirmDialog } from 'src/components/TypeToConfirmDialog/TypeToConfirmDialog'; +import Typography from 'src/components/core/Typography'; import useOpenClose from 'src/hooks/useOpenClose'; +import { APIError } from '@linode/api-v4/lib/types'; +import { BucketLandingEmptyState } from './BucketLandingEmptyState'; +import { CircleProgress } from 'src/components/CircleProgress'; +import { DocumentTitleSegment } from 'src/components/DocumentTitle'; +import { makeStyles } from '@mui/styles'; +import { readableBytes } from 'src/utilities/unitConversions'; +import { Theme } from '@mui/material/styles'; +import { useProfile } from 'src/queries/profile'; +import { useRegionsQuery } from 'src/queries/regions'; + +import { + sendDeleteBucketEvent, + sendDeleteBucketFailedEvent, +} from 'src/utilities/ga'; import { BucketError, useDeleteBucketMutation, useObjectStorageBuckets, useObjectStorageClusters, } from 'src/queries/objectStorage'; -import { useRegionsQuery } from 'src/queries/regions'; -import { - sendDeleteBucketEvent, - sendDeleteBucketFailedEvent, - sendObjectStorageDocsEvent, -} from 'src/utilities/ga'; -import { readableBytes } from 'src/utilities/unitConversions'; -import CancelNotice from '../CancelNotice'; -import BucketDetailsDrawer from './BucketDetailsDrawer'; -import BucketTable from './BucketTable'; -import { useProfile } from 'src/queries/profile'; -import { useHistory } from 'react-router-dom'; const useStyles = makeStyles((theme: Theme) => ({ copy: { @@ -279,46 +276,7 @@ export const BucketLanding = () => { }; const RenderEmpty = () => { - const classes = useStyles(); - const history = useHistory(); - - return ( - - - history.replace('/object-storage/buckets/create'), - children: 'Create Bucket', - }, - ]} - showTransferDisplay - > - Need help getting started? - - sendObjectStorageDocsEvent('Empty state')} - href="https://linode.com/docs/platform/object-storage" - target="_blank" - aria-describedby="external-site" - rel="noopener noreferrer" - className="h-u" - > - Learn more about storage options for your multimedia, archives, and - data backups here. - - - - - ); + return ; }; export default BucketLanding; diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketLandingEmptyResourcesData.ts b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketLandingEmptyResourcesData.ts new file mode 100644 index 00000000000..cce0387df36 --- /dev/null +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketLandingEmptyResourcesData.ts @@ -0,0 +1,79 @@ +import { + youtubeChannelLink, + youtubeMoreLinkText, +} from 'src/utilities/emptyStateLandingUtils'; +import type { + ResourcesHeaders, + ResourcesLinkSection, + ResourcesLinks, +} from 'src/components/EmptyLandingPageResources/ResourcesLinksTypes'; + +export const headers: ResourcesHeaders = { + description: '', + subtitle: 'S3-compatible storage solution', + title: 'Object Storage', +}; + +export const gettingStartedGuides: ResourcesLinkSection = { + links: [ + { + to: 'https://www.linode.com/docs/products/storage/object-storage/', + text: 'Overview of Object Storage', + }, + { + to: + 'https://www.linode.com/docs/products/storage/object-storage/guides/linode-cli', + text: 'Using the Linode CLI with Object Storage', + }, + { + to: + 'https://www.linode.com/docs/products/storage/object-storage/guides/s3cmd', + text: 'Use Object Storage with s3cmd', + }, + { + to: + 'https://www.linode.com/docs/products/storage/object-storage/guides/s4cmd', + text: 'Use Object Storage with s4cmd', + }, + { + to: + 'https://www.linode.com/docs/products/storage/object-storage/guides/cyberduck', + text: 'Use Object Storage with Cyberduck', + }, + ], + moreInfo: { + to: 'https://www.linode.com/docs/products/storage/object-storage/', + text: 'View additional Object Storage documentation', + }, + title: 'Getting Started Guides', +}; + +export const youtubeLinkData: ResourcesLinkSection = { + links: [ + { + to: 'https://www.youtube.com/watch?v=q88OKsr5l6c', + text: 'Getting Started with S3 Object Storage on Linode', + external: true, + }, + { + to: 'https://www.youtube.com/watch?v=7J3_NAq7fz0', + text: 'S3 Object Storage Simply Explained', + external: true, + }, + { + to: 'https://www.youtube.com/watch?v=ZfGyeJ8jYxI', + text: 'Deploy a Static Website Using the Linode CLI and Object Storage', + external: true, + }, + ], + moreInfo: { + to: youtubeChannelLink, + text: youtubeMoreLinkText, + }, + title: 'Video Playlist', +}; + +export const linkGAEvent: ResourcesLinks['linkGAEvent'] = { + action: 'Click:link', + category: 'Object Storage landing page empty', +}; diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketLandingEmptyState.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketLandingEmptyState.tsx new file mode 100644 index 00000000000..fba8a8e6469 --- /dev/null +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketLandingEmptyState.tsx @@ -0,0 +1,40 @@ +import * as React from 'react'; +import { ResourcesSection } from 'src/components/EmptyLandingPageResources/ResourcesSection'; +import { sendEvent } from 'src/utilities/ga'; +import { StyledBucketIcon } from './StylesBucketIcon'; +import { useHistory } from 'react-router-dom'; +import { + gettingStartedGuides, + headers, + linkGAEvent, + youtubeLinkData, +} from './BucketLandingEmptyResourcesData'; + +export const BucketLandingEmptyState = () => { + const history = useHistory(); + + return ( + { + sendEvent({ + category: linkGAEvent.category, + action: 'Click:button', + label: 'Create Bucket', + }); + history.replace('/object-storage/buckets/create'); + }, + + children: 'Create Bucket', + }, + ]} + gettingStartedGuidesData={gettingStartedGuides} + headers={headers} + icon={StyledBucketIcon} + linkGAEvent={linkGAEvent} + showTransferDisplay + youtubeLinkData={youtubeLinkData} + /> + ); +}; diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/StylesBucketIcon.ts b/packages/manager/src/features/ObjectStorage/BucketLanding/StylesBucketIcon.ts new file mode 100644 index 00000000000..01f1638b117 --- /dev/null +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/StylesBucketIcon.ts @@ -0,0 +1,8 @@ +import BucketIcon from 'src/assets/icons/entityIcons/bucket.svg'; +import { styled } from '@mui/material/styles'; + +const StyledBucketIcon = styled(BucketIcon)(() => ({ + transform: 'scale(0.80)', +})); + +export { StyledBucketIcon }; diff --git a/packages/manager/src/features/ObjectStorage/ObjectStorageLanding.tsx b/packages/manager/src/features/ObjectStorage/ObjectStorageLanding.tsx index 29fa8017bd9..d11513a05fb 100644 --- a/packages/manager/src/features/ObjectStorage/ObjectStorageLanding.tsx +++ b/packages/manager/src/features/ObjectStorage/ObjectStorageLanding.tsx @@ -1,25 +1,25 @@ import * as React from 'react'; -import { DateTime } from 'luxon'; import { useHistory, useParams } from 'react-router-dom'; -import TabPanels from 'src/components/core/ReachTabPanels'; -import Tabs from 'src/components/core/ReachTabs'; -import { makeStyles } from '@mui/styles'; -import { Theme } from '@mui/material/styles'; -import Typography from 'src/components/core/Typography'; import DismissibleBanner from 'src/components/DismissibleBanner'; -import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import LandingHeader from 'src/components/LandingHeader'; -import { Link } from 'src/components/Link'; import ProductInformationBanner from 'src/components/ProductInformationBanner'; import PromotionalOfferCard from 'src/components/PromotionalOfferCard/PromotionalOfferCard'; import SafeTabPanel from 'src/components/SafeTabPanel'; import SuspenseLoader from 'src/components/SuspenseLoader'; import TabLinkList from 'src/components/TabLinkList'; +import TabPanels from 'src/components/core/ReachTabPanels'; +import Tabs from 'src/components/core/ReachTabs'; +import Typography from 'src/components/core/Typography'; import useAccountManagement from 'src/hooks/useAccountManagement'; import useFlags from 'src/hooks/useFlags'; import useOpenClose from 'src/hooks/useOpenClose'; -import { MODE } from './AccessKeyLanding/types'; import { CreateBucketDrawer } from './BucketLanding/CreateBucketDrawer'; +import { DateTime } from 'luxon'; +import { DocumentTitleSegment } from 'src/components/DocumentTitle'; +import { Link } from 'src/components/Link'; +import { makeStyles } from '@mui/styles'; +import { MODE } from './AccessKeyLanding/types'; +import { Theme } from '@mui/material/styles'; import { useObjectStorageBuckets, useObjectStorageClusters, @@ -44,19 +44,16 @@ export const ObjectStorageLanding = () => { action?: 'create'; tab?: 'buckets' | 'access-keys'; }>(); - const isCreateBucketOpen = tab === 'buckets' && action === 'create'; - const { _isRestrictedUser, accountSettings } = useAccountManagement(); - const { data: objectStorageClusters } = useObjectStorageClusters(); - const { data: objectStorageBucketsResponse, isLoading: areBucketsLoading, error: bucketsErrors, } = useObjectStorageBuckets(objectStorageClusters); - + const userHasNoBucketCreated = + objectStorageBucketsResponse?.buckets.length === 0; const createOrEditDrawer = useOpenClose(); const tabs = [ @@ -94,9 +91,14 @@ export const ObjectStorageLanding = () => { const shouldDisplayBillingNotice = !areBucketsLoading && !bucketsErrors && - objectStorageBucketsResponse?.buckets.length === 0 && + userHasNoBucketCreated && accountSettings?.object_storage === 'active'; + // No need to display header since the it is redundant with the docs and CTA of the empty state + // Meanwhile it will still display the header for the access keys tab at all times + const shouldHideDocsAndCreateButtons = + !areBucketsLoading && tab === 'buckets' && userHasNoBucketCreated; + const createButtonText = tab === 'access-keys' ? 'Create Access Key' : 'Create Bucket'; @@ -114,13 +116,14 @@ export const ObjectStorageLanding = () => { { expiry: DateTime.utc().plus({ days: 30 }).toISO(), }} > - + You are being billed for Object Storage but do not have any Buckets. You can cancel Object Storage in your{' '} Account Settings, or{' '} diff --git a/packages/manager/src/features/OneClickApps/FakeSpec.ts b/packages/manager/src/features/OneClickApps/FakeSpec.ts index 4ed7561958f..cbb69347e53 100644 --- a/packages/manager/src/features/OneClickApps/FakeSpec.ts +++ b/packages/manager/src/features/OneClickApps/FakeSpec.ts @@ -2099,28 +2099,6 @@ export const oneClickApps: OCA[] = [ end: '873d0c', }, }, - { - name: 'UniFi Network Application', - alt_name: 'Networking control panel', - alt_description: 'Interface for UniFi networking devices and software.', - categories: ['Control Panels'], - description: `UniFi Network Application is a versatile control panel that simplifies network management across regions, customizes access to wifi networks, and more. Manage and apply updates to UniFi networking devices to ensure your networks are performant and secure.`, - summary: `Multi-use networking control panel`, - related_guides: [ - { - title: - 'Deploy the UniFi Network Application through the Linode Marketplace', - href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/unifi-network-application/', - }, - ], - website: 'https://www.ui.com/', - logo_url: 'unifi.svg', - colors: { - start: '1681FC', - end: '63666A', - }, - }, { name: 'Uptime Kuma', alt_name: 'Infrastructure monitoring', diff --git a/packages/manager/src/features/Profile/AuthenticationSettings/AuthenticationSettings.tsx b/packages/manager/src/features/Profile/AuthenticationSettings/AuthenticationSettings.tsx index 7058030e482..ca484353dbe 100644 --- a/packages/manager/src/features/Profile/AuthenticationSettings/AuthenticationSettings.tsx +++ b/packages/manager/src/features/Profile/AuthenticationSettings/AuthenticationSettings.tsx @@ -6,7 +6,7 @@ import { makeStyles } from '@mui/styles'; import { Theme } from '@mui/material/styles'; import Typography from 'src/components/core/Typography'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; -import ErrorState from 'src/components/ErrorState'; +import { ErrorState } from 'src/components/ErrorState/ErrorState'; import { useProfile } from 'src/queries/profile'; import { PhoneVerification } from './PhoneVerification/PhoneVerification'; import ResetPassword from './ResetPassword'; diff --git a/packages/manager/src/features/Search/SearchLanding.tsx b/packages/manager/src/features/Search/SearchLanding.tsx index 3488e2f60fa..7664a92f039 100644 --- a/packages/manager/src/features/Search/SearchLanding.tsx +++ b/packages/manager/src/features/Search/SearchLanding.tsx @@ -9,7 +9,7 @@ import { makeStyles } from '@mui/styles'; import { Theme } from '@mui/material/styles'; import Typography from 'src/components/core/Typography'; import Grid from '@mui/material/Unstable_Grid2'; -import H1Header from 'src/components/H1Header'; +import { H1Header } from 'src/components/H1Header/H1Header'; import { Notice } from 'src/components/Notice/Notice'; import { REFRESH_INTERVAL } from 'src/constants'; import useAPISearch from 'src/features/Search/useAPISearch'; diff --git a/packages/manager/src/features/StackScripts/StackScriptBase/StackScriptBase.tsx b/packages/manager/src/features/StackScripts/StackScriptBase/StackScriptBase.tsx index d775a9b4557..5bfb7942e4d 100644 --- a/packages/manager/src/features/StackScripts/StackScriptBase/StackScriptBase.tsx +++ b/packages/manager/src/features/StackScripts/StackScriptBase/StackScriptBase.tsx @@ -10,11 +10,11 @@ import classNames from 'classnames'; import { CircleProgress } from 'src/components/CircleProgress'; import { compose } from 'recompose'; import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField'; -import ErrorState from 'src/components/ErrorState'; +import { ErrorState } from 'src/components/ErrorState/ErrorState'; import { Image } from '@linode/api-v4/lib/images'; import { pathOr } from 'ramda'; import { Notice } from 'src/components/Notice/Notice'; -import Placeholder from 'src/components/Placeholder'; +import { Placeholder } from 'src/components/Placeholder/Placeholder'; import { RouteComponentProps, withRouter } from 'react-router-dom'; import StackScriptsIcon from 'src/assets/icons/entityIcons/stackscript.svg'; import StackScriptTableHead from '../Partials/StackScriptTableHead'; diff --git a/packages/manager/src/features/StackScripts/StackScriptCreate/StackScriptCreate.tsx b/packages/manager/src/features/StackScripts/StackScriptCreate/StackScriptCreate.tsx index e832ad3e22f..4b254538291 100644 --- a/packages/manager/src/features/StackScripts/StackScriptCreate/StackScriptCreate.tsx +++ b/packages/manager/src/features/StackScripts/StackScriptCreate/StackScriptCreate.tsx @@ -19,7 +19,7 @@ import { createStyles, withStyles, WithStyles } from '@mui/styles'; import Typography from 'src/components/core/Typography'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { Item } from 'src/components/EnhancedSelect/Select'; -import ErrorState from 'src/components/ErrorState'; +import { ErrorState } from 'src/components/ErrorState/ErrorState'; import { Notice } from 'src/components/Notice/Notice'; import withImages, { DefaultProps as ImagesProps, diff --git a/packages/manager/src/features/StackScripts/stackScriptUtils.ts b/packages/manager/src/features/StackScripts/stackScriptUtils.ts index 7335f41d0de..0c529f5a160 100644 --- a/packages/manager/src/features/StackScripts/stackScriptUtils.ts +++ b/packages/manager/src/features/StackScripts/stackScriptUtils.ts @@ -116,7 +116,6 @@ export const baseApps = { '1037036': 'Budibase', '1037037': 'HashiCorp Nomad', '1037038': 'HashiCorp Vault', - '1051711': 'UniFi Network Application', '1051714': 'Microweber', '1096122': 'Mastodon', '1102900': 'Apache Airflow', diff --git a/packages/manager/src/features/Support/ExpandableTicketPanel.test.tsx b/packages/manager/src/features/Support/ExpandableTicketPanel.test.tsx index a1bc56ab54f..822695102eb 100644 --- a/packages/manager/src/features/Support/ExpandableTicketPanel.test.tsx +++ b/packages/manager/src/features/Support/ExpandableTicketPanel.test.tsx @@ -4,7 +4,7 @@ import * as React from 'react'; import { ISO_DATETIME_NO_TZ_FORMAT } from 'src/constants'; import { supportReplyFactory } from 'src/factories/support'; import { renderWithTheme } from 'src/utilities/testHelpers'; -import ExpandableTicketPanel, { Props } from './ExpandableTicketPanel'; +import { ExpandableTicketPanel } from './ExpandableTicketPanel'; import { shouldRenderHively } from './Hively'; const recent = DateTime.utc() @@ -17,7 +17,7 @@ const user = 'Linode'; const reply = supportReplyFactory.build(); -const props: Props = { +const props = { reply, isCurrentUser: false, }; diff --git a/packages/manager/src/features/Support/ExpandableTicketPanel.tsx b/packages/manager/src/features/Support/ExpandableTicketPanel.tsx index 3e52e8ed7d8..262ef21085a 100644 --- a/packages/manager/src/features/Support/ExpandableTicketPanel.tsx +++ b/packages/manager/src/features/Support/ExpandableTicketPanel.tsx @@ -1,6 +1,5 @@ import { SupportReply, SupportTicket } from '@linode/api-v4'; import * as React from 'react'; -import { compose } from 'recompose'; import { makeStyles } from '@mui/styles'; import { Theme } from '@mui/material/styles'; import Typography from 'src/components/core/Typography'; @@ -79,7 +78,7 @@ const useStyles = makeStyles((theme: Theme) => ({ }, })); -export interface Props { +interface Props { reply?: SupportReply; ticket?: SupportTicket; open?: boolean; @@ -88,8 +87,6 @@ export interface Props { ticketUpdated?: string; } -type CombinedProps = Props; - interface Data { gravatar_id: string; date: string; @@ -102,7 +99,7 @@ interface Data { updated: string; } -export const ExpandableTicketPanel: React.FC = (props) => { +export const ExpandableTicketPanel = React.memo((props: Props) => { const classes = useStyles(); const { parentTicket, ticket, open, reply, ticketUpdated } = props; @@ -196,6 +193,4 @@ export const ExpandableTicketPanel: React.FC = (props) => { ); -}; - -export default compose(React.memo)(ExpandableTicketPanel); +}); diff --git a/packages/manager/src/features/Support/SupportTicketDetail/CloseTicketLink.tsx b/packages/manager/src/features/Support/SupportTicketDetail/CloseTicketLink.tsx index 04ff4e38464..ab43c70872c 100644 --- a/packages/manager/src/features/Support/SupportTicketDetail/CloseTicketLink.tsx +++ b/packages/manager/src/features/Support/SupportTicketDetail/CloseTicketLink.tsx @@ -1,161 +1,82 @@ -import { closeSupportTicket } from '@linode/api-v4/lib/support'; import * as React from 'react'; import ActionsPanel from 'src/components/ActionsPanel'; import Button from 'src/components/Button'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; -import { createStyles, withStyles, WithStyles } from '@mui/styles'; +import { makeStyles } from 'tss-react/mui'; import { Theme } from '@mui/material/styles'; import Typography from 'src/components/core/Typography'; -import { Notice } from 'src/components/Notice/Notice'; -import { getErrorStringOrDefault } from 'src/utilities/errorUtils'; -import scrollTo from 'src/utilities/scrollTo'; +import { useSupportTicketCloseMutation } from 'src/queries/support'; -type ClassNames = 'closeLink'; - -const styles = (theme: Theme) => - createStyles({ - closeLink: { - ...theme.applyLinkStyles, - }, - }); +const useStyles = makeStyles()((theme: Theme) => ({ + closeLink: { + ...theme.applyLinkStyles, + }, +})); interface Props { ticketId: number; - closeTicketSuccess: () => void; -} - -interface State { - dialogOpen: boolean; - isClosingTicket: boolean; - ticketCloseError?: string; } -type CombinedProps = Props & WithStyles; - -class CloseTicketLink extends React.Component { - mounted: boolean = false; - state: State = { - dialogOpen: false, - isClosingTicket: false, - }; - - componentDidMount() { - this.mounted = true; - } - - componentWillUnmount() { - this.mounted = false; - } - - openConfirmationDialog = () => { - if (!this.mounted) { - return; - } - this.setState({ - dialogOpen: true, - isClosingTicket: false, - ticketCloseError: undefined, - }); - }; - - closeConfirmationDialog = () => { - if (!this.mounted) { - return; - } - this.setState({ dialogOpen: false }); - }; +export const CloseTicketLink = ({ ticketId }: Props) => { + const { classes } = useStyles(); - onClose = (e: React.MouseEvent) => { - e.preventDefault(); - this.closeTicket(); - }; + const [isDialogOpen, setIsDialogOpen] = React.useState(false); - closeTicket = () => { - const { closeTicketSuccess, ticketId } = this.props; - if (this.mounted) { - this.setState({ isClosingTicket: true }); - } - closeSupportTicket(ticketId) - .then(() => { - if (this.mounted) { - this.setState({ isClosingTicket: false, dialogOpen: false }); - scrollTo(); - } - closeTicketSuccess(); - }) - .catch((errorResponse) => { - const apiError = getErrorStringOrDefault( - errorResponse, - 'Ticket could not be closed.' - ); - if (!this.mounted) { - return; - } - this.setState({ - isClosingTicket: false, - ticketCloseError: apiError, - }); - }); - }; + const { + mutateAsync: closeSupportTicket, + isLoading, + error, + } = useSupportTicketCloseMutation(ticketId); - dialogActions = () => { - return ( - - - - - ); + const closeTicket = async () => { + await closeSupportTicket(); + setIsDialogOpen(false); }; - render() { - const { ticketCloseError } = this.state; - const { classes } = this.props; - return ( - - - {`If everything is resolved, you can `} - - . - - + + + + ); + + return ( + + + {`If everything is resolved, you can `} + + . + + setIsDialogOpen(false)} + actions={actions} + error={error?.[0].reason} + > + {`Are you sure you want to close this ticket?`} + + + ); +}; diff --git a/packages/manager/src/features/Support/SupportTicketDetail/SupportTicketDetail.test.tsx b/packages/manager/src/features/Support/SupportTicketDetail/SupportTicketDetail.test.tsx index ec0fe2963d1..9ecdc76881c 100644 --- a/packages/manager/src/features/Support/SupportTicketDetail/SupportTicketDetail.test.tsx +++ b/packages/manager/src/features/Support/SupportTicketDetail/SupportTicketDetail.test.tsx @@ -1,94 +1,98 @@ import { render, screen } from '@testing-library/react'; import * as React from 'react'; +import SupportTicketDetail from './SupportTicketDetail'; import { - ClassNames, - SupportTicketDetail, - CombinedProps, -} from './SupportTicketDetail'; - -import { reactRouterProps } from 'src/__data__/reactRouterProps'; -import { supportTicketFactory } from 'src/factories/support'; + supportReplyFactory, + supportTicketFactory, +} from 'src/factories/support'; import { rest, server } from 'src/mocks/testServer'; import { wrapWithTheme } from 'src/utilities/testHelpers'; -import { profileFactory } from 'src/factories'; -import { UseQueryResult } from 'react-query'; -import { APIError } from '@linode/api-v4/lib/types'; -import { Grants, Profile } from '@linode/api-v4/lib'; -import { storeFactory } from 'src/store'; -import { queryClientFactory } from 'src/queries/base'; - -const store = storeFactory(queryClientFactory()); - -const classes: Record = { - title: '', - label: '', - labelIcon: '', - status: '', - open: '', - ticketLabel: '', - closed: '', - breadcrumbs: '', -}; - -const props: CombinedProps = { - classes, - store, - profile: { data: profileFactory.build() } as UseQueryResult< - Profile, - APIError[] - >, - grants: { data: {} } as UseQueryResult, - ...reactRouterProps, -}; - -const mockClosedTicket = () => { - server.use( - rest.get('*/support/tickets/:ticketId', (req, res, ctx) => { - const ticket = supportTicketFactory.build({ - id: req.params.ticketId, - status: 'closed', - }); - return res(ctx.json(ticket)); - }) - ); -}; +import { makeResourcePage } from 'src/mocks/serverHandlers'; describe('Support Ticket Detail', () => { - describe('Component', () => { - it('should display a loading spinner', () => { - render(wrapWithTheme()); - expect(screen.getByTestId('circle-progress')).toBeInTheDocument(); - }); + it('should display a loading spinner', () => { + render(wrapWithTheme()); + expect(screen.getByTestId('circle-progress')).toBeInTheDocument(); + }); - it('should display the ticket summary', async () => { - render(wrapWithTheme()); - expect( - await screen.findByText(/#0: TEST Support Ticket/i) - ).toBeInTheDocument(); - }); + it('should display the ticket body', async () => { + server.use( + rest.get('*/support/tickets/:ticketId', (req, res, ctx) => { + const ticket = supportTicketFactory.build({ + id: req.params.ticketId, + status: 'open', + description: 'TEST Support Ticket body', + summary: '#0: TEST Support Ticket', + }); + return res(ctx.json(ticket)); + }) + ); + const { findByText } = render(wrapWithTheme()); + expect( + await screen.findByText(/#0: TEST Support Ticket/i) + ).toBeInTheDocument(); + expect(await findByText(/TEST Support Ticket body/i)).toBeInTheDocument(); + }); - it('should display the ticket body', async () => { - const { findByText } = render( - wrapWithTheme() - ); - expect(await findByText(/TEST Support Ticket body/i)).toBeInTheDocument(); - }); + it("should display a 'new' icon and 'updated by' messaging", async () => { + server.use( + rest.get('*/support/tickets/:ticketId', (req, res, ctx) => { + const ticket = supportTicketFactory.build({ + id: req.params.ticketId, + status: 'new', + updated_by: 'test-account', + }); + return res(ctx.json(ticket)); + }) + ); + render(wrapWithTheme()); + expect(await screen.findByText(/new/)).toBeInTheDocument(); + expect( + await screen.findByText(/updated by test-account/i) + ).toBeInTheDocument(); + }); - it("should display a 'new' icon and 'updated by' messaging", async () => { - render(wrapWithTheme()); - expect(await screen.findByText(/new/)).toBeInTheDocument(); - expect( - await screen.findByText(/updated by test-account/i) - ).toBeInTheDocument(); - }); + it("should display a 'closed' status and 'closed by' messaging", async () => { + server.use( + rest.get('*/support/tickets/:ticketId', (req, res, ctx) => { + const ticket = supportTicketFactory.build({ + id: req.params.ticketId, + status: 'closed', + }); + return res(ctx.json(ticket)); + }) + ); + render(wrapWithTheme()); + expect(await screen.findByText(/closed/)).toBeInTheDocument(); + expect( + await screen.findByText(/closed by test-account/i) + ).toBeInTheDocument(); + }); - it("should display a 'closed' status and 'closed by' messaging", async () => { - mockClosedTicket(); - render(wrapWithTheme()); - expect(await screen.findByText(/closed/)).toBeInTheDocument(); - expect( - await screen.findByText(/closed by test-account/i) - ).toBeInTheDocument(); - }); + it('should display replies', async () => { + server.use( + rest.get('*/support/tickets/:ticketId/replies', (req, res, ctx) => { + const ticket = supportReplyFactory.buildList(1, { + description: + 'Hi, this is lindoe support! OMG, sorry your Linode is broken!', + }); + return res(ctx.json(makeResourcePage(ticket))); + }), + rest.get('*/support/tickets/:ticketId', (req, res, ctx) => { + const ticket = supportTicketFactory.build({ + id: req.params.ticketId, + status: 'open', + description: 'this ticket should have a reply on it', + summary: 'My Linode is broken :(', + }); + return res(ctx.json(ticket)); + }) + ); + render(wrapWithTheme()); + expect( + await screen.findByText( + 'Hi, this is lindoe support! OMG, sorry your Linode is broken!' + ) + ).toBeInTheDocument(); }); }); diff --git a/packages/manager/src/features/Support/SupportTicketDetail/SupportTicketDetail.tsx b/packages/manager/src/features/Support/SupportTicketDetail/SupportTicketDetail.tsx index b7758cd0faf..3f69604c33b 100644 --- a/packages/manager/src/features/Support/SupportTicketDetail/SupportTicketDetail.tsx +++ b/packages/manager/src/features/Support/SupportTicketDetail/SupportTicketDetail.tsx @@ -1,422 +1,218 @@ -import { - getTicket, - getTicketReplies, - SupportReply, - SupportTicket, -} from '@linode/api-v4/lib/support'; -import { APIError } from '@linode/api-v4/lib/types'; -import * as Bluebird from 'bluebird'; -import classNames from 'classnames'; -import { compose, isEmpty, pathOr } from 'ramda'; +import { SupportReply } from '@linode/api-v4/lib/support'; +import { isEmpty } from 'ramda'; import * as React from 'react'; -import { Link, RouteComponentProps } from 'react-router-dom'; -import DomainIcon from 'src/assets/addnewmenu/domain.svg'; -import LinodeIcon from 'src/assets/addnewmenu/linode.svg'; -import NodebalIcon from 'src/assets/addnewmenu/nodebalancer.svg'; -import VolumeIcon from 'src/assets/addnewmenu/volume.svg'; +import { Link, useHistory, useLocation, useParams } from 'react-router-dom'; import { CircleProgress } from 'src/components/CircleProgress'; import Chip from 'src/components/core/Chip'; -import { createStyles, withStyles, WithStyles } from '@mui/styles'; +import { makeStyles } from 'tss-react/mui'; import { Theme } from '@mui/material/styles'; import Typography from 'src/components/core/Typography'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; -import ErrorState from 'src/components/ErrorState'; +import { ErrorState } from 'src/components/ErrorState/ErrorState'; import Grid from '@mui/material/Unstable_Grid2'; -import { Notice } from 'src/components/Notice/Notice'; -import { - withProfile, - WithProfileProps, -} from 'src/containers/profile.container'; -import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import formatDate from 'src/utilities/formatDate'; -import { getLinkTargets } from 'src/utilities/getEventsActionLink'; -import ExpandableTicketPanel from '../ExpandableTicketPanel'; +import { ExpandableTicketPanel } from '../ExpandableTicketPanel'; import TicketAttachmentList from '../TicketAttachmentList'; import AttachmentError from './AttachmentError'; -import Reply from './TabbedReply'; +import { ReplyContainer } from './TabbedReply/ReplyContainer'; import LandingHeader from 'src/components/LandingHeader'; import { - withApplicationStore, - WithApplicationStoreProps, -} from 'src/containers/withApplicationStore.container'; - -export type ClassNames = - | 'title' - | 'breadcrumbs' - | 'label' - | 'labelIcon' - | 'status' - | 'open' - | 'ticketLabel' - | 'closed'; - -const styles = (theme: Theme) => - createStyles({ - title: { - display: 'flex', - alignItems: 'center', - }, - label: { - marginLeft: 32, - width: `calc(100% - (32px + ${theme.spacing(7)}))`, - [theme.breakpoints.up('sm')]: { - marginLeft: `calc(40px + ${theme.spacing(1)})`, - width: `calc(100% - (40px + ${theme.spacing(7)}))`, - }, - }, - ticketLabel: { - position: 'relative', - top: -3, - }, - labelIcon: { - paddingRight: 0, - '& svg': { - width: 40, - height: 40, - }, - '& .outerCircle': { - fill: theme.bg.offWhite, - stroke: theme.bg.main, - }, - '& .circle': { - stroke: theme.bg.main, - }, - }, - status: { - marginTop: 5, - marginLeft: theme.spacing(1), - color: theme.color.white, - }, - open: { - backgroundColor: theme.color.green, - }, - closed: { - backgroundColor: theme.color.red, - }, - }); - -type RouteProps = RouteComponentProps<{ ticketId?: string }>; + useInfiniteSupportTicketRepliesQuery, + useSupportTicketQuery, +} from 'src/queries/support'; +import { useProfile } from 'src/queries/profile'; +import { EntityIcon } from 'src/components/EntityIcon/EntityIcon'; +import type { EntityVariants } from 'src/components/EntityIcon/EntityIcon'; +import { Waypoint } from 'react-waypoint'; +import Stack from '@mui/material/Stack'; +import { Notice } from 'src/components/Notice/Notice'; +import { getLinkTargets } from 'src/utilities/getEventsActionLink'; +import { capitalize } from 'src/utilities/capitalize'; + +const useStyles = makeStyles()((theme: Theme) => ({ + title: { + display: 'flex', + alignItems: 'center', + }, + ticketLabel: { + position: 'relative', + top: -3, + }, + status: { + marginTop: 5, + marginLeft: theme.spacing(1), + color: theme.color.white, + }, + open: { + backgroundColor: theme.color.green, + }, + closed: { + backgroundColor: theme.color.red, + }, +})); export interface AttachmentError { file: string; error: string; } -interface State { - loading: boolean; - errors?: APIError[]; - attachmentErrors: AttachmentError[]; - replies?: SupportReply[]; - ticket?: SupportTicket; - ticketCloseSuccess: boolean; -} +const SupportTicketDetail = () => { + const history = useHistory<{ attachmentErrors?: AttachmentError[] }>(); + const location = useLocation(); + const { ticketId } = useParams<{ ticketId: string }>(); + const id = Number(ticketId); -export type CombinedProps = RouteProps & - WithProfileProps & - WithApplicationStoreProps & - WithStyles; + const { classes, cx } = useStyles(); -export class SupportTicketDetail extends React.Component { - mounted: boolean = false; - state: State = { - loading: true, - ticketCloseSuccess: false, - attachmentErrors: pathOr( - [], - ['history', 'location', 'state', 'attachmentErrors'], - this.props - ), - }; + const attachmentErrors = history.location.state?.attachmentErrors; - componentDidMount() { - this.mounted = true; - const { history, location } = this.props; - this.loadTicketAndReplies(); - // Clear any state that was passed from React Router so errors don't persist after reload. - history.replace(location.pathname, {}); - } + const { data: profile } = useProfile(); - componentWillUnmount() { - this.mounted = false; - } + const { data: ticket, isLoading, error, refetch } = useSupportTicketQuery(id); + const { + data: repliesData, + isLoading: repliesLoading, + error: repliesError, + hasNextPage, + fetchNextPage, + } = useInfiniteSupportTicketRepliesQuery(id); - componentDidUpdate(prevProps: CombinedProps) { - if (prevProps.match.params.ticketId !== this.props.match.params.ticketId) { - this.setState({ loading: true, ticketCloseSuccess: false }); - this.loadTicketAndReplies(); - } - } - - loadTicket = (): any => { - const ticketId = Number(this.props.match.params.ticketId ?? 0); - return getTicket(+ticketId); - }; + const replies = repliesData?.pages.flatMap((page) => page.data); - loadReplies = (): any => { - const ticketId = Number(this.props.match.params.ticketId ?? 0); - return ( - getTicketReplies(ticketId) - // This is a paginated method but here we only need the list of replies - .then((response) => response.data) - ); - }; - - reloadAttachments = () => { - this.loadTicket().then((ticket: SupportTicket) => { - this.setState({ - ticket: { - ...this.state.ticket!, - attachments: ticket.attachments, - }, - ticketCloseSuccess: false, - }); - }); - }; + if (isLoading) { + return ; + } - closeTicketSuccess = () => { - this.setState({ ticketCloseSuccess: true }); - this.loadTicketAndReplies(); - }; + if (error) { + return ; + } - handleJoinedPromise = ( - ticketResponse: SupportTicket, - replyResponse: SupportReply[] - ) => { - if (this.mounted) { - this.setState({ - replies: replyResponse, - ticket: ticketResponse, - loading: false, - }); - } - }; + if (!ticket) { + return null; + } - loadTicketAndReplies = () => { - Bluebird.join( - this.loadTicket(), - this.loadReplies(), - this.handleJoinedPromise - ).catch((err) => { - this.setState({ - loading: false, - errors: getAPIErrorOrDefault(err, 'Ticket not found.'), - }); - }); - }; + const formattedDate = formatDate(ticket.updated, { + timezone: profile?.timezone, + }); - onCreateReplySuccess = (newReply: SupportReply) => { - const replies = pathOr([], ['replies'], this.state); - const updatedReplies = [...replies, ...[newReply]]; - this.setState({ - replies: updatedReplies, - ticketCloseSuccess: false, - attachmentErrors: [], - }); - }; + const status = ticket.status === 'closed' ? 'Closed' : 'Last updated'; - getEntityIcon = (type: string) => { - switch (type) { - case 'domain': - return ; - case 'linode': - return ; - case 'nodebalancer': - return ; - case 'volume': - return ; - default: - return ; - } - }; + const renderEntityLabelWithIcon = () => { + const entity = ticket?.entity; - renderEntityLabelWithIcon = () => { - const { classes, store } = this.props; - const { entity } = this.state.ticket!; if (!entity) { return null; } - const icon: JSX.Element = this.getEntityIcon(entity.type); - const target = getLinkTargets(entity, store); - return ( - - {icon} - - {target !== null ? ( - - {entity.label} - - ) : ( - - {entity.label} - - )} - - - ); - }; - - renderReplies = (replies: SupportReply[]) => { - const { ticket } = this.state; - return replies - .filter((reply) => reply.description.trim() !== '') - .map((reply: SupportReply, idx: number) => { - return ( - - ); - }); - }; - - render() { - const { classes, profile, location } = this.props; - const { - attachmentErrors, - errors, - loading, - replies, - ticket, - ticketCloseSuccess, - } = this.state; - const ticketId = this.props.match.params.ticketId; - /* - * Including loading/error states here (rather than in a - * renderContent function) because the header - * depends on having a ticket object for its content. - */ - - // Loading - if (loading) { - return ; - } - - // Error state - if (errors) { - return ; - } - // Empty state - if (!ticket) { - return null; - } + const target = getLinkTargets(entity); - // Format date for header - const formattedDate = formatDate(ticket.updated, { - timezone: profile.data?.timezone, - }); - const status = ticket.status === 'closed' ? 'Closed' : 'Last updated'; - - const _Chip = () => ( - + return ( + + + + + This ticket is associated with your {capitalize(entity.type)}{' '} + {target ? {entity.label} : entity.label} + + + ); + }; - // Might be an opportunity to refactor the nested grid containing the ticket summary, status, and last updated - // details. For more info see the below link. - // https://github.com/linode/manager/pull/4056/files/b0977c6e397e42720479478db96df56022618151#r232298065 - return ( - - - , - }, - crumbOverrides: [ - { - position: 2, - linkTo: { - pathname: `/support/tickets`, - // If we're viewing a `Closed` ticket, the Breadcrumb link should take us to `Closed` tickets. - search: `type=${ - ticket.status === 'closed' ? 'closed' : 'open' - }`, - }, + const _Chip = () => ( + + ); + + return ( + + + , + }, + crumbOverrides: [ + { + position: 2, + linkTo: { + pathname: `/support/tickets`, + // If we're viewing a `Closed` ticket, the Breadcrumb link should take us to `Closed` tickets. + search: `type=${ + ticket.status === 'closed' ? 'closed' : 'open' + }`, }, - ], - }} - /> + }, + ], + }} + /> - {/* If a user attached files when creating the ticket and was redirected here, display those errors. */} - {!isEmpty(attachmentErrors) && - attachmentErrors.map((error, idx: number) => ( - ( + + ))} + + {ticket.entity && renderEntityLabelWithIcon()} + + + + {/* If the ticket isn't blank, display it, followed by replies (if any). */} + {ticket.description && ( + + )} + {replies?.map((reply: SupportReply, idx: number) => ( + ))} - - {ticket.entity && this.renderEntityLabelWithIcon()} - - {/* Show message if the ticket has been closed through the link on this page. */} - {ticketCloseSuccess && ( - - )} - - - - {/* If the ticket isn't blank, display it, followed by replies (if any). */} - {ticket.description && ( - - )} - {replies && this.renderReplies(replies)} - - {/* If the ticket is open, allow users to reply to it. */} - {['open', 'new'].includes(ticket.status) && ( - - )} - - + {repliesLoading && } + {repliesError ? ( + + ) : null} + {hasNextPage && fetchNextPage()} />} + + {/* If the ticket is open, allow users to reply to it. */} + {['open', 'new'].includes(ticket.status) && ( + + )} - - ); - } -} -const styled = withStyles(styles); + + + ); +}; -export default compose( - withProfile, - withApplicationStore, - styled -)(SupportTicketDetail); +export default SupportTicketDetail; diff --git a/packages/manager/src/features/Support/SupportTicketDetail/TabbedReply/ReplyActions.tsx b/packages/manager/src/features/Support/SupportTicketDetail/TabbedReply/ReplyActions.tsx index 5f5900b1bcb..9bcf1307a34 100644 --- a/packages/manager/src/features/Support/SupportTicketDetail/TabbedReply/ReplyActions.tsx +++ b/packages/manager/src/features/Support/SupportTicketDetail/TabbedReply/ReplyActions.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import ActionsPanel from 'src/components/ActionsPanel'; import Button from 'src/components/Button'; -import CloseTicketLink from '../CloseTicketLink'; +import { CloseTicketLink } from '../CloseTicketLink'; import { makeStyles } from '@mui/styles'; const useStyles = makeStyles(() => ({ @@ -13,26 +13,16 @@ const useStyles = makeStyles(() => ({ interface Props { isSubmitting: boolean; - closeTicketSuccess: () => void; value: string; submitForm: (value: string) => void; closable: boolean; ticketId: number; } -type CombinedProps = Props; - -const ReplyActions: React.FC = (props) => { +export const ReplyActions = (props: Props) => { const classes = useStyles(); - const { - isSubmitting, - submitForm, - closeTicketSuccess, - closable, - value, - ticketId, - } = props; + const { isSubmitting, submitForm, closable, value, ticketId } = props; const handleSubmitForm = () => { submitForm(value); @@ -40,12 +30,7 @@ const ReplyActions: React.FC = (props) => { return ( <> - {closable && ( - - )} + {closable && }
); }; -} -const styled = withStyles(styles); + return ( +
+ {readOnly && } + + {renderIPTable()} + setIsIPDrawerOpen(false)} + ip={selectedIP} + /> + setIsRangeDrawerOpen(false)} + range={selectedRange} + /> + setIsIpRdnsDrawerOpen(false)} + ip={selectedIP} + /> + setIsRangeRdnsDrawerOpen(false)} + range={selectedRange} + linodeId={id} + /> + setIsViewRDNSDialogOpen(false)} + linodeId={id} + selectedRange={selectedRange} + /> + setIsAddDrawerOpen(false)} + linodeId={id} + readOnly={readOnly} + /> + setIsTransferDialogOpen(false)} + linodeId={id} + readOnly={readOnly} + /> + setIsShareDialogOpen(false)} + linodeId={id} + readOnly={readOnly} + /> + {selectedIP && ( + setIsDeleteIPDialogOpen(false)} + address={selectedIP.address} + open={isDeleteIPDialogOpen} + linodeId={id} + /> + )} + {selectedRange && ( + setIsDeleteRangeDialogOpen(false)} + range={selectedRange} + open={isDeleteRangeDialogOpen} + /> + )} +
+ ); +}; + +export default LinodeNetworking; -interface ContextProps { - linode: Linode; - readOnly: boolean; -} +const RangeRDNSCell = (props: { + range: IPRange; + linodeId: number; + onViewDetails: () => void; +}) => { + const { range, linodeId, onViewDetails } = props; + const theme = useTheme(); -const linodeContext = withLinodeDetailContext(({ linode }) => ({ - /** actually needs the whole linode for the purposes */ - linode, - readOnly: linode._permissions === 'read_only', -})); + const { data: linode } = useLinodeQuery(linodeId); -interface DispatchProps { - upsertLinode: (data: Linode) => void; -} + const { data: ipsInRegion, isLoading: ipv6Loading } = useAllIPsQuery( + {}, + { + region: linode?.region, + }, + linode !== undefined + ); -const mapDispatchToProps: MapDispatchToProps = ( - dispatch: any -) => ({ - upsertLinode: (linode) => dispatch(_upsertLinode(linode)), -}); + const ipsWithRDNS = listIPv6InRange(range.range, range.prefix, ipsInRegion); -const connected = connect(undefined, mapDispatchToProps); + if (ipv6Loading) { + return ; + } -const enhanced = recompose( - connected, - withFeatureFlags, - linodeContext, - styled -); + // We don't show anything if there are no addresses. + if (ipsWithRDNS.length === 0) { + return null; + } + + if (ipsWithRDNS.length === 1) { + return ( + + {ipsWithRDNS[0].address} + {ipsWithRDNS[0].rdns} + + ); + } -export default enhanced(LinodeNetworking); + return ( + + ); +}; // ============================================================================= // Utilities diff --git a/packages/manager/src/features/linodes/LinodesDetail/LinodeNetworking/LinodeNetworkingActionMenu.tsx b/packages/manager/src/features/linodes/LinodesDetail/LinodeNetworking/LinodeNetworkingActionMenu.tsx index 273541828ce..84a44f884ff 100644 --- a/packages/manager/src/features/linodes/LinodesDetail/LinodeNetworking/LinodeNetworkingActionMenu.tsx +++ b/packages/manager/src/features/linodes/LinodesDetail/LinodeNetworking/LinodeNetworkingActionMenu.tsx @@ -3,13 +3,14 @@ import { isEmpty } from 'ramda'; import * as React from 'react'; import { RouteComponentProps, withRouter } from 'react-router-dom'; import ActionMenu, { Action } from 'src/components/ActionMenu'; -import { makeStyles, useTheme } from '@mui/styles'; +import { makeStyles } from 'tss-react/mui'; +import { useTheme } from '@mui/styles'; import { Theme } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; import InlineMenuAction from 'src/components/InlineMenuAction'; import { IPTypes } from './types'; -const useStyles = makeStyles(() => ({ +const useStyles = makeStyles()(() => ({ emptyCell: { height: 40, }, @@ -26,7 +27,7 @@ interface Props { type CombinedProps = Props & RouteComponentProps<{}>; export const LinodeNetworkingActionMenu: React.FC = (props) => { - const classes = useStyles(); + const { classes } = useStyles(); const theme = useTheme(); const matchesMdDown = useMediaQuery(theme.breakpoints.down('lg')); diff --git a/packages/manager/src/features/linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/NetworkTransfer.tsx b/packages/manager/src/features/linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/NetworkTransfer.tsx index 509ab6ada92..1bbfed79670 100644 --- a/packages/manager/src/features/linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/NetworkTransfer.tsx +++ b/packages/manager/src/features/linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/NetworkTransfer.tsx @@ -2,7 +2,7 @@ import { getLinodeTransfer } from '@linode/api-v4/lib/linodes'; import * as React from 'react'; import BarPercent from 'src/components/BarPercent'; import { CircleProgress } from 'src/components/CircleProgress'; -import { makeStyles } from '@mui/styles'; +import { makeStyles } from 'tss-react/mui'; import { Theme } from '@mui/material/styles'; import Typography from 'src/components/core/Typography'; import Grid from '@mui/material/Unstable_Grid2'; @@ -11,7 +11,7 @@ import { useAPIRequest } from 'src/hooks/useAPIRequest'; import { useAccountTransfer } from 'src/queries/accountTransfer'; import { readableBytes } from 'src/utilities/unitConversions'; -const useStyles = makeStyles((theme: Theme) => ({ +const useStyles = makeStyles()((theme: Theme) => ({ header: { paddingBottom: 10, }, @@ -50,7 +50,7 @@ interface Props { export const NetworkTransfer: React.FC = (props) => { const { linodeID, linodeLabel } = props; - const classes = useStyles(); + const { classes } = useStyles(); const linodeTransfer = useAPIRequest( () => getLinodeTransfer(linodeID), @@ -114,7 +114,7 @@ const TransferContent: React.FC = (props) => { accountQuotaInGB, // accountBillableInGB } = props; - const classes = useStyles(); + const { classes } = useStyles(); /** * In this component we display three pieces of information: diff --git a/packages/manager/src/features/linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/NetworkingSummaryPanel.tsx b/packages/manager/src/features/linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/NetworkingSummaryPanel.tsx index b5912b059f7..f91a7ecb80f 100644 --- a/packages/manager/src/features/linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/NetworkingSummaryPanel.tsx +++ b/packages/manager/src/features/linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/NetworkingSummaryPanel.tsx @@ -1,13 +1,14 @@ import * as React from 'react'; import Paper from 'src/components/core/Paper'; -import { makeStyles } from '@mui/styles'; +import { makeStyles } from 'tss-react/mui'; import { Theme } from '@mui/material/styles'; import Grid from '@mui/material/Unstable_Grid2'; import DNSResolvers from './DNSResolvers'; import NetworkTransfer from './NetworkTransfer'; import TransferHistory from './TransferHistory'; +import { useLinodeQuery } from 'src/queries/linodes/linodes'; -const useStyles = makeStyles((theme: Theme) => ({ +const useStyles = makeStyles()((theme: Theme) => ({ root: { display: 'flex', flexFlow: 'row nowrap', @@ -36,26 +37,29 @@ const useStyles = makeStyles((theme: Theme) => ({ })); interface Props { - linodeRegion: string; linodeID: number; - linodeCreated: string; - linodeLabel: string; } -type CombinedProps = Props; +const LinodeNetworkingSummaryPanel = (props: Props) => { + // @todo maybe move this query closer to the consuming component + const { data: linode } = useLinodeQuery(props.linodeID); + const { classes } = useStyles(); -const LinodeNetworkingSummaryPanel: React.FC = (props) => { - const { linodeID, linodeRegion, linodeCreated, linodeLabel } = props; - const classes = useStyles(); + if (!linode) { + return null; + } return ( - + - + = (props) => { paddingBottom: 0, }} > - + diff --git a/packages/manager/src/features/linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/TransferHistory.tsx b/packages/manager/src/features/linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/TransferHistory.tsx index 64262745db2..6acf2f0e67f 100644 --- a/packages/manager/src/features/linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/TransferHistory.tsx +++ b/packages/manager/src/features/linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/TransferHistory.tsx @@ -1,14 +1,13 @@ import { Stats } from '@linode/api-v4/lib/linodes'; import ArrowBackIosIcon from '@mui/icons-material/ArrowBackIos'; -import classNames from 'classnames'; import { DateTime, Interval } from 'luxon'; import * as React from 'react'; import { CircleProgress } from 'src/components/CircleProgress'; import Box from 'src/components/core/Box'; -import { makeStyles } from '@mui/styles'; +import { makeStyles } from 'tss-react/mui'; import { Theme } from '@mui/material/styles'; import Typography from 'src/components/core/Typography'; -import ErrorState from 'src/components/ErrorState'; +import { ErrorState } from 'src/components/ErrorState/ErrorState'; import LineGraph from 'src/components/LineGraph'; import { convertNetworkToUnit, @@ -26,7 +25,7 @@ import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { readableBytes } from 'src/utilities/unitConversions'; import PendingIcon from 'src/assets/icons/pending.svg'; -const useStyles = makeStyles((theme: Theme) => ({ +const useStyles = makeStyles()((theme: Theme) => ({ arrowIconOuter: { ...theme.applyLinkStyles, display: 'flex', @@ -60,7 +59,7 @@ interface Props { export const TransferHistory: React.FC = (props) => { const { linodeID, linodeCreated } = props; - const classes = useStyles(); + const { classes, cx } = useStyles(); // Needed to see the user's timezone. const { data: profile } = useProfile(); @@ -233,7 +232,7 @@ export const TransferHistory: React.FC = (props) => { disabled={monthOffset === maxMonthOffset} > = (props) => { disabled={monthOffset === minMonthOffset} > - createStyles({ - root: {}, - section: { - marginBottom: theme.spacing(2), - paddingBottom: theme.spacing(2), - borderBottom: `1px solid ${theme.palette.divider}`, - }, - }); +const useStyles = makeStyles()((theme: Theme) => ({ + section: { + marginBottom: theme.spacing(2), + paddingBottom: theme.spacing(2), + borderBottom: `1px solid ${theme.palette.divider}`, + }, +})); interface Props { open: boolean; @@ -26,10 +22,9 @@ interface Props { onClose: () => void; } -type CombinedProps = Props & WithStyles; - -const ViewIPDrawer: React.FC = (props) => { - const { classes, ip } = props; +export const ViewIPDrawer = (props: Props) => { + const { classes } = useStyles(); + const { ip } = props; const { data: regions } = useRegionsQuery(); @@ -108,7 +103,3 @@ const ViewIPDrawer: React.FC = (props) => { ); }; - -const styled = withStyles(styles); - -export default styled(ViewIPDrawer); diff --git a/packages/manager/src/features/linodes/LinodesDetail/LinodeNetworking/ViewRDNSDrawer.tsx b/packages/manager/src/features/linodes/LinodesDetail/LinodeNetworking/ViewRDNSDrawer.tsx index 6a6fef135f2..902f2f2e5c0 100644 --- a/packages/manager/src/features/linodes/LinodesDetail/LinodeNetworking/ViewRDNSDrawer.tsx +++ b/packages/manager/src/features/linodes/LinodesDetail/LinodeNetworking/ViewRDNSDrawer.tsx @@ -1,11 +1,14 @@ -import { IPAddress } from '@linode/api-v4/lib/networking'; +import { IPRange } from '@linode/api-v4/lib/networking'; import * as React from 'react'; -import { makeStyles } from '@mui/styles'; +import { makeStyles } from 'tss-react/mui'; import { Theme } from '@mui/material/styles'; import Typography from 'src/components/core/Typography'; import Drawer from 'src/components/Drawer'; +import { useLinodeQuery } from 'src/queries/linodes/linodes'; +import { useAllIPsQuery } from 'src/queries/linodes/networking'; +import { listIPv6InRange } from './LinodeNetworking'; -const useStyles = makeStyles((theme: Theme) => ({ +const useStyles = makeStyles()((theme: Theme) => ({ rdnsListItem: { marginBottom: theme.spacing(2), }, @@ -14,14 +17,28 @@ const useStyles = makeStyles((theme: Theme) => ({ interface Props { open: boolean; onClose: () => void; - ips: IPAddress[]; + linodeId: number; + selectedRange: IPRange | undefined; } -type CombinedProps = Props; +const ViewRDNSDrawer = (props: Props) => { + const { open, onClose, linodeId, selectedRange } = props; + const { classes } = useStyles(); -const ViewRDNSDrawer: React.FC = (props) => { - const { open, onClose, ips } = props; - const classes = useStyles(); + const { data: linode } = useLinodeQuery(linodeId, open); + + const { data: ipsInRegion } = useAllIPsQuery( + {}, + { + region: linode?.region, + }, + linode !== undefined && open + ); + + // @todo in the future use an API filter insted of `listIPv6InRange` ARB-3785 + const ips = selectedRange + ? listIPv6InRange(selectedRange.range, selectedRange.prefix, ipsInRegion) + : []; return ( diff --git a/packages/manager/src/features/linodes/LinodesDetail/LinodeNetworking/ViewRangeDrawer.tsx b/packages/manager/src/features/linodes/LinodesDetail/LinodeNetworking/ViewRangeDrawer.tsx index 43b4897f2d5..db6b70d6828 100644 --- a/packages/manager/src/features/linodes/LinodesDetail/LinodeNetworking/ViewRangeDrawer.tsx +++ b/packages/manager/src/features/linodes/LinodesDetail/LinodeNetworking/ViewRangeDrawer.tsx @@ -2,23 +2,19 @@ import { IPRange } from '@linode/api-v4/lib/networking'; import * as React from 'react'; import ActionsPanel from 'src/components/ActionsPanel'; import Button from 'src/components/Button'; -import { createStyles, withStyles, WithStyles } from '@mui/styles'; +import { makeStyles } from 'tss-react/mui'; import { Theme } from '@mui/material/styles'; import Typography from 'src/components/core/Typography'; import Drawer from 'src/components/Drawer'; import { useRegionsQuery } from 'src/queries/regions'; -type ClassNames = 'root' | 'section'; - -const styles = (theme: Theme) => - createStyles({ - root: {}, - section: { - marginBottom: theme.spacing(2), - paddingBottom: theme.spacing(2), - borderBottom: `1px solid ${theme.palette.divider}`, - }, - }); +const useStyles = makeStyles()((theme: Theme) => ({ + section: { + marginBottom: theme.spacing(2), + paddingBottom: theme.spacing(2), + borderBottom: `1px solid ${theme.palette.divider}`, + }, +})); interface Props { open: boolean; @@ -26,10 +22,9 @@ interface Props { onClose: () => void; } -type CombinedProps = Props & WithStyles; - -const ViewRangeDrawer: React.FC = (props) => { - const { classes, range } = props; +export const ViewRangeDrawer = (props: Props) => { + const { classes } = useStyles(); + const { range } = props; const region = (range && range.region) || ''; const { data: regions } = useRegionsQuery(); @@ -76,7 +71,3 @@ const ViewRangeDrawer: React.FC = (props) => { ); }; - -const styled = withStyles(styles); - -export default styled(ViewRangeDrawer); diff --git a/packages/manager/src/features/linodes/LinodesDetail/LinodePowerControl/LinodePowerControl.test.tsx b/packages/manager/src/features/linodes/LinodesDetail/LinodePowerControl/LinodePowerControl.test.tsx deleted file mode 100644 index 9c659b643bc..00000000000 --- a/packages/manager/src/features/linodes/LinodesDetail/LinodePowerControl/LinodePowerControl.test.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import { shallow } from 'enzyme'; -import * as React from 'react'; - -import { LinodePowerButton } from './LinodePowerControl'; - -describe('Linode Power Control Dialogs', () => { - const component = ( - - ); - - it('powerAlertOpen state should be true if reboot menu item is clicked', () => { - const renderedComponent = shallow(component); - const button = renderedComponent.find( - 'WithStyles(WrapperMenuItem)[data-qa-set-power="reboot"]' - ); - button.simulate('click'); - expect(renderedComponent.state('powerDialogOpen')).toBeTruthy(); - }); - - it('powerAlertOpen state should be true if power off menu item is clicked', () => { - const renderedComponent = shallow(component); - const button = renderedComponent.find( - 'WithStyles(WrapperMenuItem)[data-qa-set-power="powerOff"]' - ); - button.simulate('click'); - expect(renderedComponent.state('powerDialogOpen')).toBeTruthy(); - }); - - xit('Confirmation Dialog cancel button should set powerAlertOpen state is false', () => { - const renderedComponent = shallow(component); - const cancelButton = renderedComponent - .find('WithStyles(ConfirmationDialog)') - .dive() - .dive() - .find('[data-qa-confirm-cancel]'); - cancelButton.simulate('click'); - expect(renderedComponent.state('powerDialogOpen')).toBeFalsy(); - }); - - it('should only have the option to reboot if the status is "running"', () => { - const renderedComponent = shallow(component); - - // "Running" - renderedComponent.setProps({ status: 'running' }); - expect( - renderedComponent.find('[data-qa-set-power="reboot"]').exists() - ).toBeTruthy(); - - // "Offline" - renderedComponent.setProps({ status: 'offline' }); - expect( - renderedComponent.find('[data-qa-set-power="reboot"]').exists() - ).toBeFalsy(); - }); - - it('button should be disabled if status is not Running or Offline', () => { - const renderedComponent = shallow(component); - - // Set status to a transition status such as "cloning." - renderedComponent.setProps({ status: 'cloning' }); - - expect(renderedComponent.find('Button').prop('disabled')).toEqual(true); - }); -}); diff --git a/packages/manager/src/features/linodes/LinodesDetail/LinodePowerControl/LinodePowerControl.tsx b/packages/manager/src/features/linodes/LinodesDetail/LinodePowerControl/LinodePowerControl.tsx deleted file mode 100644 index 9330ac886b0..00000000000 --- a/packages/manager/src/features/linodes/LinodesDetail/LinodePowerControl/LinodePowerControl.tsx +++ /dev/null @@ -1,323 +0,0 @@ -import KeyboardArrowDown from '@mui/icons-material/KeyboardArrowDown'; -import KeyboardArrowUp from '@mui/icons-material/KeyboardArrowUp'; -import classNames from 'classnames'; -import { Event } from '@linode/api-v4/lib/account'; -import { Config, LinodeStatus } from '@linode/api-v4/lib/linodes'; -import * as React from 'react'; -import Button from 'src/components/Button'; -import Menu from 'src/components/core/Menu'; -import { createStyles, withStyles, WithStyles } from '@mui/styles'; -import { Theme } from '@mui/material/styles'; -import EntityIcon from 'src/components/EntityIcon'; -import MenuItem from 'src/components/MenuItem'; -import { linodeInTransition } from 'src/features/linodes/transitions'; -import { PowerActionsDialog, Action } from '../../PowerActionsDialogOrDrawer'; - -type ClassNames = - | 'root' - | 'button' - | 'buttonText' - | 'caret' - | 'caretDisabled' - | 'menuItem' - | 'menuItemInner' - | 'buttonInner' - | 'hidden'; - -const styles = (theme: Theme) => - createStyles({ - '@keyframes fadeIn': { - from: { - opacity: 0, - }, - to: { - opacity: 1, - }, - }, - root: { - '& svg': { - transition: theme.transitions.create(['color']), - }, - }, - button: { - position: 'relative', - transition: theme.transitions.create(['color', 'border-color']), - minWidth: 145, - padding: `calc(${theme.spacing(1)} - 2px) ${theme.spacing(1)}`, - '&:hover': { - textDecoration: 'underline', - }, - '&:hover, &.active': { - borderColor: theme.palette.primary.light, - backgroundColor: 'transparent', - }, - }, - buttonText: { - marginLeft: theme.spacing(1), - }, - caret: { - color: 'inherit', - transition: theme.transitions.create(['color']), - position: 'relative', - top: 2, - left: 2, - marginLeft: theme.spacing(0.5), - }, - caretDisabled: { - color: theme.color.disabledText, - }, - menuItem: { - color: theme.palette.primary.main, - padding: theme.spacing(2), - outline: 0, - borderBottom: `1px solid ${theme.palette.divider}`, - '&:not(.hasTooltip)': { - '&:hover': { - '& $buttonText': { - color: 'white', - }, - '& svg': { - fill: '#FFF', - }, - '& .insidePath *, ': { - stroke: '#fff', - }, - '& svg:not(.loading) .outerCircle': { - stroke: '#fff', - }, - }, - }, - }, - menuItemInner: { - display: 'flex', - alignItems: 'center', - }, - buttonInner: { - display: 'flex', - animation: '$fadeIn .2s ease-in-out', - alignItems: 'center', - }, - hidden: { - ...theme.visually.hidden, - }, - }); - -interface Props { - id: number; - label: string; - status: LinodeStatus; - disabled?: boolean; - linodeEvents?: Event[]; - linodeConfigs: Config[]; -} - -interface State { - menu: { - anchorEl?: HTMLElement; - }; - selectedBootAction?: Action; - powerDialogOpen: boolean; -} - -type CombinedProps = Props & WithStyles; - -export class LinodePowerButton extends React.Component { - state: State = { - menu: { - anchorEl: undefined, - }, - powerDialogOpen: false, - }; - - _toggleMenu = (value?: HTMLElement) => - this.setState({ menu: { anchorEl: value } }); - - openMenu = (e: React.MouseEvent) => { - this._toggleMenu(e.currentTarget); - }; - - closeMenu = () => { - this._toggleMenu(); - }; - - powerOn = () => { - // const { id, label } = this.props; - this.closeMenu(); - }; - - openDialog = (bootOption: Action) => { - this.setState({ - powerDialogOpen: true, - selectedBootAction: bootOption, - }); - this.closeMenu(); - }; - - closeDialog = () => { - this.setState({ powerDialogOpen: false }); - }; - - render() { - const { - status, - classes, - disabled, - linodeEvents, - linodeConfigs, - } = this.props; - const { - menu: { anchorEl }, - } = this.state; - - const hasNoConfigs = linodeConfigs.length === 0; - - const firstEventWithPercent = (linodeEvents || []).find( - (eachEvent) => typeof eachEvent.percent_complete === 'number' - ); - - const isBusy = linodeInTransition(status, firstEventWithPercent); - const isRunning = !isBusy && status === 'running'; - const isOffline = !isBusy && status === 'offline'; - const isStopped = status === 'stopped'; - const isUnknown = !isRunning && !isOffline && !isStopped; - - const buttonText = () => { - if (isBusy) { - return 'Busy'; - } else if (isRunning) { - return 'Running'; - } else if (isOffline) { - return 'Offline'; - } else if (isStopped) { - return 'Stopped'; - } else { - return 'Offline'; - } - }; - - const text = buttonText(); - - return ( - - - - {isRunning && ( - this.openDialog('Reboot')} - className={classes.menuItem} - data-qa-set-power="reboot" - disabled={disabled} - > -
- - Reboot -
-
- )} - {isRunning && ( - this.openDialog('Power Off')} - className={classes.menuItem} - data-qa-set-power="powerOff" - disabled={disabled} - > -
- - Power Off -
-
- )} - {isOffline && ( - this.openDialog('Power On')} - className={classes.menuItem} - data-qa-set-power="powerOn" - disabled={hasNoConfigs || disabled} - tooltip={ - hasNoConfigs - ? 'A config needs to be added before powering on a Linode' - : undefined - } - > -
- - Power On -
-
- )} -
- -
- ); - } -} - -const styled = withStyles(styles); - -export default styled(LinodePowerButton); diff --git a/packages/manager/src/features/linodes/LinodesDetail/LinodePowerControl/index.ts b/packages/manager/src/features/linodes/LinodesDetail/LinodePowerControl/index.ts deleted file mode 100644 index 09ddba07c3a..00000000000 --- a/packages/manager/src/features/linodes/LinodesDetail/LinodePowerControl/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -import LinodePowerControl from './LinodePowerControl'; -export default LinodePowerControl; diff --git a/packages/manager/src/features/linodes/LinodesDetail/LinodeRebuild/RebuildFromImage.tsx b/packages/manager/src/features/linodes/LinodesDetail/LinodeRebuild/RebuildFromImage.tsx index 91ea2d8b410..6fed6ff9a70 100644 --- a/packages/manager/src/features/linodes/LinodesDetail/LinodeRebuild/RebuildFromImage.tsx +++ b/packages/manager/src/features/linodes/LinodesDetail/LinodeRebuild/RebuildFromImage.tsx @@ -18,7 +18,8 @@ import Box from 'src/components/core/Box'; import Divider from 'src/components/core/Divider'; import Grid from '@mui/material/Unstable_Grid2'; import ImageSelect from 'src/components/ImageSelect'; -import TypeToConfirm from 'src/components/TypeToConfirm'; +import { TypeToConfirm } from 'src/components/TypeToConfirm/TypeToConfirm'; + import { resetEventsPolling } from 'src/eventsPolling'; import { UserDataAccordion } from 'src/features/linodes/LinodesCreate/UserDataAccordion/UserDataAccordion'; import useFlags from 'src/hooks/useFlags'; diff --git a/packages/manager/src/features/linodes/LinodesDetail/LinodeRebuild/RebuildFromStackScript.tsx b/packages/manager/src/features/linodes/LinodesDetail/LinodeRebuild/RebuildFromStackScript.tsx index c9c2538558a..1c1fd43f43b 100644 --- a/packages/manager/src/features/linodes/LinodesDetail/LinodeRebuild/RebuildFromStackScript.tsx +++ b/packages/manager/src/features/linodes/LinodesDetail/LinodeRebuild/RebuildFromStackScript.tsx @@ -13,7 +13,7 @@ import { makeStyles } from '@mui/styles'; import { Theme } from '@mui/material/styles'; import Grid from '@mui/material/Unstable_Grid2'; import ImageSelect from 'src/components/ImageSelect'; -import TypeToConfirm from 'src/components/TypeToConfirm'; +import { TypeToConfirm } from 'src/components/TypeToConfirm/TypeToConfirm'; import { resetEventsPolling } from 'src/eventsPolling'; import ImageEmptyState from 'src/features/linodes/LinodesCreate/TabbedContent/ImageEmptyState'; import SelectStackScriptPanel from 'src/features/StackScripts/SelectStackScriptPanel'; diff --git a/packages/manager/src/features/linodes/LinodesDetail/LinodeRescue/StandardRescueDialog.tsx b/packages/manager/src/features/linodes/LinodesDetail/LinodeRescue/StandardRescueDialog.tsx index 87b13f84175..539472fa157 100644 --- a/packages/manager/src/features/linodes/LinodesDetail/LinodeRescue/StandardRescueDialog.tsx +++ b/packages/manager/src/features/linodes/LinodesDetail/LinodeRescue/StandardRescueDialog.tsx @@ -9,7 +9,7 @@ import Paper from 'src/components/core/Paper'; import { makeStyles } from '@mui/styles'; import { Theme } from '@mui/material/styles'; import { Dialog } from 'src/components/Dialog/Dialog'; -import ErrorState from 'src/components/ErrorState'; +import { ErrorState } from 'src/components/ErrorState/ErrorState'; import { Notice } from 'src/components/Notice/Notice'; import { resetEventsPolling } from 'src/eventsPolling'; import usePrevious from 'src/hooks/usePrevious'; diff --git a/packages/manager/src/features/linodes/LinodesDetail/LinodeResize/LinodeResize.tsx b/packages/manager/src/features/linodes/LinodesDetail/LinodeResize/LinodeResize.tsx index 0bfa77385e7..8d7a2bd8d0c 100644 --- a/packages/manager/src/features/linodes/LinodesDetail/LinodeResize/LinodeResize.tsx +++ b/packages/manager/src/features/linodes/LinodesDetail/LinodeResize/LinodeResize.tsx @@ -17,7 +17,7 @@ import { Dialog } from 'src/components/Dialog/Dialog'; import ExternalLink from 'src/components/ExternalLink'; import { TooltipIcon } from 'src/components/TooltipIcon/TooltipIcon'; import { Notice } from 'src/components/Notice/Notice'; -import TypeToConfirm from 'src/components/TypeToConfirm'; +import { TypeToConfirm } from 'src/components/TypeToConfirm/TypeToConfirm'; import { withProfile, WithProfileProps, diff --git a/packages/manager/src/features/linodes/LinodesDetail/LinodeSettings/AlertSection.tsx b/packages/manager/src/features/linodes/LinodesDetail/LinodeSettings/AlertSection.tsx index ec581d5f043..53a59a89731 100644 --- a/packages/manager/src/features/linodes/LinodesDetail/LinodeSettings/AlertSection.tsx +++ b/packages/manager/src/features/linodes/LinodesDetail/LinodeSettings/AlertSection.tsx @@ -140,5 +140,3 @@ export const AlertSection = (props: Props) => { ); }; - -export default AlertSection; diff --git a/packages/manager/src/features/linodes/LinodesDetail/LinodeSettings/LinodeConfigDialog.tsx b/packages/manager/src/features/linodes/LinodesDetail/LinodeSettings/LinodeConfigDialog.tsx index 667bb5fdee4..216b38f70ae 100644 --- a/packages/manager/src/features/linodes/LinodesDetail/LinodeSettings/LinodeConfigDialog.tsx +++ b/packages/manager/src/features/linodes/LinodesDetail/LinodeSettings/LinodeConfigDialog.tsx @@ -27,7 +27,7 @@ import { Theme } from '@mui/material/styles'; import Typography from 'src/components/core/Typography'; import { Dialog } from 'src/components/Dialog/Dialog'; import Select, { Item } from 'src/components/EnhancedSelect/Select'; -import ErrorState from 'src/components/ErrorState'; +import { ErrorState } from 'src/components/ErrorState/ErrorState'; import ExternalLink from 'src/components/ExternalLink'; import Grid from '@mui/material/Unstable_Grid2'; import { TooltipIcon } from 'src/components/TooltipIcon/TooltipIcon'; diff --git a/packages/manager/src/features/linodes/LinodesDetail/LinodeSettings/LinodeSettings.tsx b/packages/manager/src/features/linodes/LinodesDetail/LinodeSettings/LinodeSettings.tsx index baf2833b01b..7c0cd79cd05 100644 --- a/packages/manager/src/features/linodes/LinodesDetail/LinodeSettings/LinodeSettings.tsx +++ b/packages/manager/src/features/linodes/LinodesDetail/LinodeSettings/LinodeSettings.tsx @@ -1,60 +1,33 @@ import * as React from 'react'; -import { LinodeDetailContextConsumer } from '../linodeDetailContext'; import LinodePermissionsError from '../LinodePermissionsError'; -import LinodeSettingsAlertsPanel from './LinodeSettingsAlertsPanel'; -import LinodeSettingsDeletePanel from './LinodeSettingsDeletePanel'; -import LinodeSettingsLabelPanel from './LinodeSettingsLabelPanel'; -import LinodeSettingsPasswordPanel from './LinodeSettingsPasswordPanel'; -import LinodeWatchdogPanel from './LinodeWatchdogPanel'; +import { LinodeSettingsAlertsPanel } from './LinodeSettingsAlertsPanel'; +import { LinodeSettingsLabelPanel } from './LinodeSettingsLabelPanel'; +import { LinodeSettingsPasswordPanel } from './LinodeSettingsPasswordPanel'; +import { useParams } from 'react-router-dom'; +import { useGrants } from 'src/queries/profile'; +import { LinodeWatchdogPanel } from './LinodeWatchdogPanel'; +import { LinodeSettingsDeletePanel } from './LinodeSettingsDeletePanel'; -interface Props { - isBareMetalInstance: boolean; -} +const LinodeSettings = () => { + const { linodeId } = useParams<{ linodeId: string }>(); + const id = Number(linodeId); -type CombinedProps = Props; + const { data: grants } = useGrants(); -const LinodeSettings: React.FC = (props) => { - const { isBareMetalInstance } = props; + const isReadOnly = + grants !== undefined && + grants?.linode.find((grant) => grant.id === id)?.permissions === + 'read_only'; return ( - - {({ linode }) => { - if (!linode) { - return null; - } - - const permissionsError = - linode._permissions === 'read_only' ? ( - - ) : null; - - return ( - <> - {permissionsError} - - - - - - - ); - }} - + <> + {isReadOnly && } + + + + + + ); }; diff --git a/packages/manager/src/features/linodes/LinodesDetail/LinodeSettings/LinodeSettingsAlertsPanel.tsx b/packages/manager/src/features/linodes/LinodesDetail/LinodeSettings/LinodeSettingsAlertsPanel.tsx index ad5b0a83d49..f50810739db 100644 --- a/packages/manager/src/features/linodes/LinodesDetail/LinodeSettings/LinodeSettingsAlertsPanel.tsx +++ b/packages/manager/src/features/linodes/LinodesDetail/LinodeSettings/LinodeSettingsAlertsPanel.tsx @@ -1,228 +1,185 @@ -import { GrantLevel } from '@linode/api-v4/lib/account'; -import { LinodeAlerts } from '@linode/api-v4/lib/linodes'; -import { APIError } from '@linode/api-v4/lib/types'; -import { compose, lensPath, set } from 'ramda'; +import { Linode } from '@linode/api-v4'; +import { useFormik } from 'formik'; +import { useSnackbar } from 'notistack'; import * as React from 'react'; -import { compose as rCompose } from 'recompose'; +import Accordion from 'src/components/Accordion/Accordion'; import ActionsPanel from 'src/components/ActionsPanel'; import Button from 'src/components/Button'; -import Accordion from 'src/components/Accordion'; import { Notice } from 'src/components/Notice/Notice'; -import PanelErrorBoundary from 'src/components/PanelErrorBoundary'; -import { withLinodeDetailContext } from 'src/features/linodes/LinodesDetail/linodeDetailContext'; import { - LinodeActionsProps, - withLinodeActions, -} from 'src/store/linodes/linode.containers'; -import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; + useLinodeQuery, + useLinodeUpdateMutation, +} from 'src/queries/linodes/linodes'; +import { useTypeQuery } from 'src/queries/types'; import getAPIErrorFor from 'src/utilities/getAPIErrorFor'; -import scrollErrorIntoView from 'src/utilities/scrollErrorIntoView'; -import AlertSection from './AlertSection'; +import { AlertSection } from './AlertSection'; interface Props { - isBareMetalInstance?: boolean; linodeId: number; - linodeLabel: string; - linodeAlerts: LinodeAlerts; + isReadOnly?: boolean; } -interface State { - submitting: boolean; - success?: string; - cpuusage: AlertState; - diskio: AlertState; - incoming: AlertState; - outbound: AlertState; - transfer: AlertState; - errors?: APIError[]; -} +export const LinodeSettingsAlertsPanel = (props: Props) => { + const { linodeId, isReadOnly } = props; + const { enqueueSnackbar } = useSnackbar(); -interface AlertState { - state: boolean; - value: number; -} + const { data: linode } = useLinodeQuery(linodeId); -interface Section { - title: string; - textTitle: string; - radioInputLabel: string; - textInputLabel: string; - copy: string; - state: boolean; - value: number; - onStateChange: (e: React.ChangeEvent<{}>, checked: boolean) => void; - onValueChange: (e: React.ChangeEvent) => void; - error?: string; - endAdornment: string; -} + const { + mutateAsync: updateLinode, + isLoading, + error, + } = useLinodeUpdateMutation(linodeId); -type CombinedProps = Props & ContextProps & LinodeActionsProps; + const { data: type } = useTypeQuery(linode?.type ?? '', linode !== undefined); -const maybeNumber = (v: string) => (v === '' ? '' : Number(v)); + const isBareMetalInstance = type?.class === 'metal'; -class LinodeSettingsAlertsPanel extends React.Component { - public state: State = { - submitting: false, - cpuusage: { - state: this.props.linodeAlerts.cpu > 0, - value: this.props.linodeAlerts.cpu, + const formik = useFormik({ + enableReinitialize: true, + initialValues: { + cpu: linode?.alerts.cpu ?? 0, + io: linode?.alerts.io ?? 0, + network_in: linode?.alerts.network_in ?? 0, + network_out: linode?.alerts.network_out ?? 0, + transfer_quota: linode?.alerts.transfer_quota ?? 0, }, - diskio: { - state: this.props.linodeAlerts.io > 0, - value: this.props.linodeAlerts.io, + async onSubmit({ cpu, network_in, network_out, transfer_quota, io }) { + await updateLinode({ + alerts: { + cpu: isBareMetalInstance ? undefined : cpu, + network_in: isBareMetalInstance ? undefined : network_in, + network_out, + transfer_quota, + io, + }, + }); + + enqueueSnackbar( + `Successfully updated alert settings for ${linode?.label}`, + { variant: 'success' } + ); }, - incoming: { - state: this.props.linodeAlerts.network_in > 0, - value: this.props.linodeAlerts.network_in, + }); + + const hasErrorFor = getAPIErrorFor( + { + 'alerts.cpu': 'CPU', + 'alerts.network_in': 'Incoming traffic', + 'alerts.network_out': 'Outbound traffic', + 'alerts.transfer_quota': 'Transfer quota', + 'alerts.io': 'Disk I/O rate', }, - outbound: { - state: this.props.linodeAlerts.network_out > 0, - value: this.props.linodeAlerts.network_out, + error ?? undefined + ); + + const alertSections = [ + { + title: 'CPU Usage', + textTitle: 'Usage Threshold', + radioInputLabel: 'cpu_usage_state', + textInputLabel: 'cpu_usage_threshold', + copy: + 'Average CPU usage over 2 hours exceeding this value triggers this alert.', + state: formik.values.cpu > 0, + value: formik.values.cpu, + onStateChange: ( + e: React.ChangeEvent, + checked: boolean + ) => + formik.setFieldValue( + 'cpu', + checked ? 90 * (linode?.specs.vcpus ?? 1) : 0 + ), + onValueChange: (e: React.ChangeEvent) => + formik.setFieldValue('cpu', e.target.valueAsNumber), + error: hasErrorFor('alerts.cpu'), + endAdornment: '%', + hidden: isBareMetalInstance, }, - transfer: { - state: this.props.linodeAlerts.transfer_quota > 0, - value: this.props.linodeAlerts.transfer_quota, + { + radioInputLabel: 'disk_io_state', + textInputLabel: 'disk_io_threshold', + textTitle: 'I/O Threshold', + title: 'Disk I/O Rate', + copy: + 'Average Disk I/O ops/sec over 2 hours exceeding this value triggers this alert.', + state: formik.values.io > 0, + value: formik.values.io, + onStateChange: ( + e: React.ChangeEvent, + checked: boolean + ) => formik.setFieldValue('io', checked ? 10000 : 0), + onValueChange: (e: React.ChangeEvent) => + formik.setFieldValue('io', e.target.valueAsDate), + error: hasErrorFor('alerts.io'), + endAdornment: 'IOPS', + hidden: isBareMetalInstance, }, - }; - - renderAlertSections = () => { - const hasErrorFor = getAPIErrorFor( - { - 'alerts.cpu': 'CPU', - 'alerts.network_in': 'Incoming traffic', - 'alerts.network_out': 'Outbound traffic', - 'alerts.transfer_quota': 'Transfer quota', - 'alerts.io': 'Disk I/O rate', - }, - this.state.errors - ); - - const { isBareMetalInstance } = this.props; - - return [ - { - title: 'CPU Usage', - textTitle: 'Usage Threshold', - radioInputLabel: 'cpu_usage_state', - textInputLabel: 'cpu_usage_threshold', - copy: - 'Average CPU usage over 2 hours exceeding this value triggers this alert.', - state: this.state.cpuusage.state, - value: this.state.cpuusage.value, - onStateChange: ( - e: React.ChangeEvent, - checked: boolean - ) => this.setState(set(lensPath(['cpuusage', 'state']), checked)), - onValueChange: (e: React.ChangeEvent) => { - this.setState( - set(lensPath(['cpuusage', 'value']), maybeNumber(e.target.value)) - ); - }, - error: hasErrorFor('alerts.cpu'), - endAdornment: '%', - hidden: isBareMetalInstance, - }, - { - radioInputLabel: 'disk_io_state', - textInputLabel: 'disk_io_threshold', - textTitle: 'I/O Threshold', - title: 'Disk I/O Rate', - copy: - 'Average Disk I/O ops/sec over 2 hours exceeding this value triggers this alert.', - state: this.state.diskio.state, - value: this.state.diskio.value, - onStateChange: ( - e: React.ChangeEvent, - checked: boolean - ) => this.setState(set(lensPath(['diskio', 'state']), checked)), - onValueChange: (e: React.ChangeEvent) => - this.setState( - set(lensPath(['diskio', 'value']), maybeNumber(e.target.value)) - ), - error: hasErrorFor('alerts.io'), - endAdornment: 'IOPS', - hidden: isBareMetalInstance, - }, - { - radioInputLabel: 'incoming_traffic_state', - textInputLabel: 'incoming_traffic_threshold', - textTitle: 'Traffic Threshold', - title: 'Incoming Traffic', - copy: `Average incoming traffic over a 2 hour period exceeding this value triggers this + { + radioInputLabel: 'incoming_traffic_state', + textInputLabel: 'incoming_traffic_threshold', + textTitle: 'Traffic Threshold', + title: 'Incoming Traffic', + copy: `Average incoming traffic over a 2 hour period exceeding this value triggers this alert.`, - state: this.state.incoming.state, - value: this.state.incoming.value, - onStateChange: ( - e: React.ChangeEvent, - checked: boolean - ) => this.setState(set(lensPath(['incoming', 'state']), checked)), - onValueChange: (e: React.ChangeEvent) => - this.setState( - set(lensPath(['incoming', 'value']), maybeNumber(e.target.value)) - ), - error: hasErrorFor('alerts.network_in'), - endAdornment: 'Mb/s', - }, - { - radioInputLabel: 'outbound_traffic_state', - textInputLabel: 'outbound_traffic_threshold', - textTitle: 'Traffic Threshold', - title: 'Outbound Traffic', - copy: `Average outbound traffic over a 2 hour period exceeding this value triggers this + state: formik.values.network_in > 0, + value: formik.values.network_in, + onStateChange: ( + e: React.ChangeEvent, + checked: boolean + ) => formik.setFieldValue('network_in', checked ? 10 : 0), + onValueChange: (e: React.ChangeEvent) => + formik.setFieldValue('network_in', e.target.valueAsNumber), + error: hasErrorFor('alerts.network_in'), + endAdornment: 'Mb/s', + }, + { + radioInputLabel: 'outbound_traffic_state', + textInputLabel: 'outbound_traffic_threshold', + textTitle: 'Traffic Threshold', + title: 'Outbound Traffic', + copy: `Average outbound traffic over a 2 hour period exceeding this value triggers this alert.`, - state: this.state.outbound.state, - value: this.state.outbound.value, - onStateChange: ( - e: React.ChangeEvent, - checked: boolean - ) => this.setState(set(lensPath(['outbound', 'state']), checked)), - onValueChange: (e: React.ChangeEvent) => - this.setState( - set(lensPath(['outbound', 'value']), maybeNumber(e.target.value)) - ), - error: hasErrorFor('alerts.network_out'), - endAdornment: 'Mb/s', - }, - { - radioInputLabel: 'transfer_quota_state', - textInputLabel: 'transfer_quota_threshold', - textTitle: 'Quota Threshold', - title: 'Transfer Quota', - copy: `Percentage of network transfer quota used being greater than this value will trigger + state: formik.values.network_out > 0, + value: formik.values.network_out, + onStateChange: ( + e: React.ChangeEvent, + checked: boolean + ) => formik.setFieldValue('network_out', checked ? 10 : 0), + onValueChange: (e: React.ChangeEvent) => + formik.setFieldValue('network_out', e.target.valueAsNumber), + error: hasErrorFor('alerts.network_out'), + endAdornment: 'Mb/s', + }, + { + radioInputLabel: 'transfer_quota_state', + textInputLabel: 'transfer_quota_threshold', + textTitle: 'Quota Threshold', + title: 'Transfer Quota', + copy: `Percentage of network transfer quota used being greater than this value will trigger this alert.`, - state: this.state.transfer.state, - value: this.state.transfer.value, - onStateChange: ( - e: React.ChangeEvent, - checked: boolean - ) => this.setState(set(lensPath(['transfer', 'state']), checked)), - onValueChange: (e: React.ChangeEvent) => - this.setState( - set(lensPath(['transfer', 'value']), maybeNumber(e.target.value)) - ), - error: hasErrorFor('alerts.transfer_quota'), - endAdornment: '%', - }, - ].filter((thisAlert) => !thisAlert.hidden); - }; - - renderExpansionActions = () => { - const noError = - this.state.submitting && - !this.renderAlertSections().reduce( - (result, s) => result || Boolean(s.error), - false - ); - - const { permissions } = this.props; + state: formik.values.transfer_quota > 0, + value: formik.values.transfer_quota, + onStateChange: ( + e: React.ChangeEvent, + checked: boolean + ) => formik.setFieldValue('transfer_quota', checked ? 80 : 0), + onValueChange: (e: React.ChangeEvent) => + formik.setFieldValue('transfer_quota', e.target.valueAsNumber), + error: hasErrorFor('alerts.transfer_quota'), + endAdornment: '%', + }, + ].filter((thisAlert) => !thisAlert.hidden); + const renderExpansionActions = () => { return ( - - ); - }; - - searchDisks = (value: string = '') => { - if (this.state.disksLoading === false) { - this.setState({ disksLoading: true }); + await changeLinodeDiskPassword({ password }); } - - return getLinodeDisks( - this.props.linodeId, - {}, - { label: { '+contains': value } } - ) - .then((response) => - response.data - .filter((disk: Disk) => disk.filesystem !== 'swap') - .map((disk) => ({ - value: disk.id, - label: disk.label, - data: disk, - })) - ) - .then((disks) => { - this.setState({ disks, disksLoading: false }); - - /** TLDR; If we only have one disk we set that to state after the disks have been set */ - if (disks.length === 1) { - this.handleDiskSelection(disks[0]); - } - }) - .catch((_) => - this.setState({ - disksError: 'An error occurred while searching for disks.', - disksLoading: false, - }) - ); + setPassword(''); + enqueueSnackbar('Sucessfully changed password', { variant: 'success' }); }; - debouncedSearch = debounce(400, false, this.searchDisks); + const errorMap = getErrorMap(['root_pass', 'password'], error); - onInputChange = (inputValue: string, actionMeta: { action: string }) => { - if (actionMeta.action !== 'input-change') { - return; - } - this.setState({ disksLoading: true }); - this.debouncedSearch(inputValue); - }; + const passwordError = isBareMetalInstance + ? errorMap.root_pass + : errorMap.password; - handleDiskSelection = (selected: Item) => { - if (selected) { - return this.setState({ diskId: Number(selected.value) }); - } + const generalError = errorMap.none; - return this.setState({ diskId: undefined }); - }; + const diskOptions = disks + ?.filter((d) => d.filesystem !== 'swap') + .map((d) => ({ label: d.label, value: d.id })); - handlePanelChange = (e: React.ChangeEvent<{}>, open: boolean) => { - if (open && !this.props.isBareMetalInstance) { - this.searchDisks(); + // If there is only one selectable disk, select it automaticly + React.useEffect(() => { + if (diskOptions !== undefined && diskOptions.length === 1) { + setSelectedDiskId(diskOptions[0].value); } - }; - - getSelectedDisk = (diskId: number) => { - const { disks } = this.state; - const idx = disks.findIndex((disk) => disk.value === diskId); - if (idx > -1) { - return disks[idx]; - } else { - return null; - } - }; - - render() { - const { diskId, disks, disksError, disksLoading } = this.state; - const { permissions, isBareMetalInstance } = this.props; - const selectedDisk = diskId ? this.getSelectedDisk(diskId) : null; - const disabled = permissions === 'read_only'; - - const hasErrorFor = getAPIErrorFor({}, this.state.errors); - const passwordError = hasErrorFor('password'); - const diskIdError = hasErrorFor('diskId'); - const generalError = hasErrorFor('none'); - - return ( - + + + ); + + return ( + actions} + defaultExpanded + > +
+ {generalError && } + {!isBareMetalInstance ? ( + setSelectedDiskId(item.value)} + value={diskOptions?.find((item) => item.value === selectedDiskId)} + data-qa-select-linode + disabled={isReadOnly} + isClearable={false} + /> + ) : null} + }> + setPassword(e.target.value)} + errorText={passwordError} + errorGroup="linode-settings-password" + error={Boolean(passwordError)} + data-qa-password-input + disabled={isReadOnly} + disabledReason={ + isReadOnly + ? "You don't have permissions to modify this Linode" + : undefined + } + /> + + +
+ ); +}; diff --git a/packages/manager/src/features/linodes/LinodesDetail/LinodeSettings/LinodeWatchdogPanel.tsx b/packages/manager/src/features/linodes/LinodesDetail/LinodeSettings/LinodeWatchdogPanel.tsx index ae26fc6843e..a96c88f17f7 100644 --- a/packages/manager/src/features/linodes/LinodesDetail/LinodeSettings/LinodeWatchdogPanel.tsx +++ b/packages/manager/src/features/linodes/LinodesDetail/LinodeSettings/LinodeWatchdogPanel.tsx @@ -1,65 +1,29 @@ -import { GrantLevel } from '@linode/api-v4/lib/account'; import * as React from 'react'; -import { RouteComponentProps, withRouter } from 'react-router-dom'; -import { compose as recompose } from 'recompose'; import Accordion from 'src/components/Accordion'; import FormControlLabel from 'src/components/core/FormControlLabel'; import Typography from 'src/components/core/Typography'; import Grid from '@mui/material/Unstable_Grid2'; import { Notice } from 'src/components/Notice/Notice'; -import PanelErrorBoundary from 'src/components/PanelErrorBoundary'; import { Toggle } from 'src/components/Toggle'; -import { withLinodeDetailContext } from 'src/features/linodes/LinodesDetail/linodeDetailContext'; import { - LinodeActionsProps, - withLinodeActions, -} from 'src/store/linodes/linode.containers'; + useLinodeQuery, + useLinodeUpdateMutation, +} from 'src/queries/linodes/linodes'; +import { Box, CircularProgress, Stack } from '@mui/material'; interface Props { linodeId: number; - currentStatus: boolean; + isReadOnly?: boolean; } -type CombinedProps = Props & - ContextProps & - LinodeActionsProps & - RouteComponentProps<{}>; +export const LinodeWatchdogPanel = ({ linodeId, isReadOnly }: Props) => { + const { data: linode } = useLinodeQuery(linodeId); -export const LinodeWatchdogPanel: React.FC = (props) => { const { - linodeId, - linodeActions: { updateLinode }, - permissions, - } = props; - - const disabled = permissions === 'read_only'; - - const [currentStatus, setCurrentStatus] = React.useState( - props.currentStatus - ); - const [submitting, setSubmitting] = React.useState(false); - const [success, setSuccess] = React.useState(undefined); - const [errors, setErrors] = React.useState(undefined); - - const toggleWatchdog = ( - e: React.ChangeEvent, - value: boolean - ) => { - setSubmitting(true); - setSuccess(undefined); - setErrors(undefined); - - updateLinode({ linodeId, watchdog_enabled: value }) - .then((response) => { - setSubmitting(false); - setSuccess(`Watchdog successfully ${value ? 'enabled' : 'disabled.'}`); - setCurrentStatus(response.watchdog_enabled); - }) - .catch(() => { - setSubmitting(false); - setErrors(`Unable to ${!value ? 'disable' : 'enable'} Watchdog.`); - }); - }; + mutateAsync: updateLinode, + isLoading, + error, + } = useLinodeUpdateMutation(linodeId); return ( = (props) => { data-qa-watchdog-panel > - {(success || errors) && ( + {Boolean(error) && ( - + )} + updateLinode({ watchdog_enabled: checked }) + } + checked={linode?.watchdog_enabled ?? false} + data-qa-watchdog-toggle={linode?.watchdog_enabled ?? false} /> } - label={currentStatus ? 'Enabled' : 'Disabled'} + label={ + + {linode?.watchdog_enabled ? 'Enabled' : 'Disabled'} + {isLoading && } + + } aria-label={ - currentStatus + linode?.watchdog_enabled ? 'Shutdown Watchdog is enabled' : 'Shutdown Watchdog is disabled' } - disabled={submitting || disabled} + disabled={isReadOnly} /> @@ -108,20 +75,3 @@ export const LinodeWatchdogPanel: React.FC = (props) => { ); }; - -const errorBoundary = PanelErrorBoundary({ heading: 'Delete Linode' }); - -interface ContextProps { - permissions: GrantLevel; -} - -const linodeContext = withLinodeDetailContext(({ linode }) => ({ - permissions: linode._permissions, -})); - -export default recompose( - errorBoundary, - withRouter, - withLinodeActions, - linodeContext -)(LinodeWatchdogPanel) as React.ComponentType; diff --git a/packages/manager/src/features/linodes/LinodesDetail/LinodeSummary/ActivitySummary.test.tsx b/packages/manager/src/features/linodes/LinodesDetail/LinodeSummary/ActivitySummary.test.tsx deleted file mode 100644 index be226cca7cd..00000000000 --- a/packages/manager/src/features/linodes/LinodesDetail/LinodeSummary/ActivitySummary.test.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import * as account from '@linode/api-v4/lib/account/events'; -import { shallow } from 'enzyme'; -import * as React from 'react'; -import { ActivitySummary } from './ActivitySummary'; - -const mockGetEvents = jest.spyOn(account, 'getEvents'); - -const props = { - linodeId: 123456, - eventsFromRedux: [], - inProgressEvents: [], - mostRecentEventTime: '', - classes: { - root: '', - header: '', - viewMore: '', - }, -}; -const component = shallow(); - -describe('ActivitySummary component', () => { - it('should render', () => { - expect(component).toHaveLength(1); - }); - - it("should request the Linode's events on load", () => { - expect(mockGetEvents).toHaveBeenCalledWith( - {}, - { 'entity.id': 123456, 'entity.type': 'linode' } - ); - }); -}); diff --git a/packages/manager/src/features/linodes/LinodesDetail/LinodeSummary/ActivitySummary.tsx b/packages/manager/src/features/linodes/LinodesDetail/LinodeSummary/ActivitySummary.tsx deleted file mode 100644 index bb8572e931b..00000000000 --- a/packages/manager/src/features/linodes/LinodesDetail/LinodeSummary/ActivitySummary.tsx +++ /dev/null @@ -1,171 +0,0 @@ -import { Event } from '@linode/api-v4/lib/account'; -import * as React from 'react'; -import Grid from '@mui/material/Unstable_Grid2'; -import Paper from 'src/components/core/Paper'; -import { createStyles, withStyles, WithStyles } from '@mui/styles'; -import { Theme } from '@mui/material/styles'; -import Typography from 'src/components/core/Typography'; -import ViewAllLink from 'src/components/ViewAllLink'; -import { getErrorStringOrDefault } from 'src/utilities/errorUtils'; -import { getEventsForEntity } from 'src/utilities/getEventsForEntity'; -import ActivitySummaryContent from './ActivitySummaryContent'; - -import { - filterUniqueEvents, - shouldUpdateEvents, -} from 'src/features/Events/Event.helpers'; -import { ExtendedEvent } from 'src/store/events/event.types'; -import { removeBlocklistedEvents } from 'src/utilities/eventUtils'; - -type ClassNames = 'root' | 'header' | 'viewMore'; - -const styles = (theme: Theme) => - createStyles({ - root: {}, - header: { - marginTop: theme.spacing(3), - marginBottom: theme.spacing(1), - }, - viewMore: { - position: 'relative', - top: 2, - }, - }); - -interface Props { - linodeId: number; - inProgressEvents: Record; - eventsFromRedux: ExtendedEvent[]; - mostRecentEventTime: string; -} - -interface State { - loading: boolean; - error?: string; - events: Event[]; -} - -type CombinedProps = Props & WithStyles; - -export class ActivitySummary extends React.Component { - state: State = { - loading: true, - error: undefined, - events: [], - }; - - componentDidUpdate(prevProps: CombinedProps) { - /** - * This condition checks either the most recent event time has changed OR - * if the in progress events have changed or that the in-progress events have new percentages - * - * This is necessary because we have 2 types of events: ones that have percent and ones that - * don't. - * - * Events that don't have a percentage won't affect the inProgressEvents state, which is why - * we're checking the mostRecentEvent time becasue that will update when we get a new event - * - * That being said, mostRecentEventTime will NOT be updated when a event's percentage updates - */ - if ( - shouldUpdateEvents( - { - mostRecentEventTime: prevProps.mostRecentEventTime, - inProgressEvents: prevProps.inProgressEvents, - }, - { - mostRecentEventTime: this.props.mostRecentEventTime, - inProgressEvents: this.props.inProgressEvents, - } - ) - ) { - this.setState({ - events: filterUniqueEvents([ - /* - make sure that we're popping new related events to the top - of the activity stream. Make sure they're events after the ones - we got from page load and ones that match the Linode ID - */ - ...this.props.eventsFromRedux.filter((eachEvent) => { - return ( - /** all events from Redux will have this flag as a boolean value */ - !eachEvent._initial && - eachEvent.entity && - eachEvent.entity.id === this.props.linodeId && - eachEvent.entity.type === 'linode' - ); - }), - /* - at this point, the state is populated with events from the cDM - request (which don't include the "_initial flag"), but it might also - contain events from Redux as well. We only want the ones where the "_initial" - flag doesn't exist - */ - ...this.state.events.filter( - (eachEvent) => typeof eachEvent._initial === 'undefined' - ), - ]), - }); - } - } - - componentDidMount() { - getEventsForEntity({}, 'linode', this.props.linodeId) - .then((response) => { - this.setState({ - events: response.data, - loading: false, - }); - }) - .catch((err) => { - this.setState({ - error: getErrorStringOrDefault( - err, - "Couldn't retrieve events for this Linode." - ), - loading: false, - }); - }); - } - render() { - const { classes, linodeId } = this.props; - const { events, error, loading } = this.state; - - const filteredEvents = removeBlocklistedEvents(events); - - return ( - <> - - - Activity Feed - - - - - - - - - - - - ); - } -} - -const styled = withStyles(styles); - -export default styled(ActivitySummary); diff --git a/packages/manager/src/features/linodes/LinodesDetail/LinodeSummary/ActivitySummaryContent.test.tsx b/packages/manager/src/features/linodes/LinodesDetail/LinodeSummary/ActivitySummaryContent.test.tsx deleted file mode 100644 index df884044e61..00000000000 --- a/packages/manager/src/features/linodes/LinodesDetail/LinodeSummary/ActivitySummaryContent.test.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { shallow } from 'enzyme'; -import * as React from 'react'; - -import { events } from 'src/__data__/events'; - -import { ActivitySummaryContent } from './ActivitySummaryContent'; - -const props = { - loading: true, - events: [], - classes: { - root: '', - emptyState: '', - }, -}; - -const component = shallow(); - -describe('ActivitySummaryContent', () => { - it('should render a loading spinner when loading', () => { - expect(component.find('[data-qa-activity-loading]')).toHaveLength(1); - }); - - it('should render an error state when there is an error', () => { - component.setProps({ error: 'An error' }); - expect(component.find('[data-qa-activity-error]')).toHaveLength(1); - }); - - it('should render an empty state when there are no events', () => { - component.setProps({ error: undefined, loading: false }); - expect(component.find('[data-qa-activity-empty]')).toHaveLength(1); - }); - - it('should render an ActivityRow for each event when there are events', () => { - component.setProps({ events }); - expect(component.find('[data-qa-activity-row]')).toHaveLength( - events.length - ); - }); -}); diff --git a/packages/manager/src/features/linodes/LinodesDetail/LinodeSummary/ActivitySummaryContent.tsx b/packages/manager/src/features/linodes/LinodesDetail/LinodeSummary/ActivitySummaryContent.tsx deleted file mode 100644 index 2de8f5e9e17..00000000000 --- a/packages/manager/src/features/linodes/LinodesDetail/LinodeSummary/ActivitySummaryContent.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import { Event } from '@linode/api-v4/lib/account'; -import * as React from 'react'; -import { CircleProgress } from 'src/components/CircleProgress'; -import Grid from '@mui/material/Unstable_Grid2'; -import { createStyles, withStyles, WithStyles } from '@mui/styles'; -import { Theme } from '@mui/material/styles'; -import Typography from 'src/components/core/Typography'; -import ErrorState from 'src/components/ErrorState'; - -import ActivityRow from './ActivityRow'; - -type ClassNames = 'root' | 'emptyState'; - -const styles = (theme: Theme) => - createStyles({ - root: {}, - emptyState: { - padding: theme.spacing(2), - }, - }); - -interface Props { - error?: string; - loading: boolean; - events: Event[]; -} - -type CombinedProps = Props & WithStyles; - -export const ActivitySummaryContent: React.FC = (props) => { - const { classes, error, loading, events } = props; - if (error) { - return ; - } - - if (loading) { - return ( - - - - ); - } - - if (events.length === 0) { - return ( - - - No recent activity for this Linode. - - - ); - } - - return ( - // eslint-disable-next-line react/jsx-no-useless-fragment - <> - {events.map((event, idx) => ( - - ))} - - ); -}; - -const styled = withStyles(styles); - -export default styled(ActivitySummaryContent); diff --git a/packages/manager/src/features/linodes/LinodesDetail/LinodeSummary/LinodeSummary.tsx b/packages/manager/src/features/linodes/LinodesDetail/LinodeSummary/LinodeSummary.tsx index d01f3ab35c4..86ae65e7906 100644 --- a/packages/manager/src/features/linodes/LinodesDetail/LinodeSummary/LinodeSummary.tsx +++ b/packages/manager/src/features/linodes/LinodesDetail/LinodeSummary/LinodeSummary.tsx @@ -7,7 +7,7 @@ import { createStyles, makeStyles, useTheme } from '@mui/styles'; import { Theme } from '@mui/material/styles'; import Typography from 'src/components/core/Typography'; import Select, { Item } from 'src/components/EnhancedSelect/Select'; -import ErrorState from 'src/components/ErrorState'; +import { ErrorState } from 'src/components/ErrorState/ErrorState'; import Grid from '@mui/material/Unstable_Grid2'; import LineGraph from 'src/components/LineGraph'; import { useWindowDimensions } from 'src/hooks/useWindowDimensions'; diff --git a/packages/manager/src/features/linodes/LinodesDetail/LinodesDetail.container.tsx b/packages/manager/src/features/linodes/LinodesDetail/LinodesDetail.container.tsx index 36a1a853ece..0576616598d 100644 --- a/packages/manager/src/features/linodes/LinodesDetail/LinodesDetail.container.tsx +++ b/packages/manager/src/features/linodes/LinodesDetail/LinodesDetail.container.tsx @@ -13,22 +13,21 @@ import { ThunkDispatch } from 'src/store/types'; import { shouldRequestEntity } from 'src/utilities/shouldRequestEntity'; import LinodesDetail from './LinodesDetail'; -interface Props { - linodeId?: string; -} +// @todo delete this file after we react queryify most linode features +// React Query will fetch data when it needs to. A file like this won't be needed /** * We want to hold off loading this screen until Linode data is available. * If we have recently requested all Linode data, we're good. If not, * we show a loading spinner until the requests are complete. */ -export const LinodesDetailContainer: React.FC = (props) => { +export const LinodesDetailContainer = () => { const { linodes } = useLinodes(); const dispatch = useDispatch(); const { account } = useAccountManagement(); const params = useParams<{ linodeId: string }>(); - const linodeId = props.linodeId ? props.linodeId : params.linodeId; + const linodeId = params.linodeId; const { isLoading: imagesLoading } = useAllImagesQuery({}, {}); @@ -72,7 +71,7 @@ export const LinodesDetailContainer: React.FC = (props) => { return ; } - return ; + return ; }; export default React.memo(LinodesDetailContainer); diff --git a/packages/manager/src/features/linodes/LinodesDetail/LinodesDetail.tsx b/packages/manager/src/features/linodes/LinodesDetail/LinodesDetail.tsx index e05f6e247d1..d3346f8a117 100644 --- a/packages/manager/src/features/linodes/LinodesDetail/LinodesDetail.tsx +++ b/packages/manager/src/features/linodes/LinodesDetail/LinodesDetail.tsx @@ -3,12 +3,10 @@ import { useDispatch } from 'react-redux'; import { Redirect, Route, - RouteComponentProps, Switch, - withRouter, + useParams, + useRouteMatch, } from 'react-router-dom'; -import { compose } from 'recompose'; -import NotFound from 'src/components/NotFound'; import SuspenseLoader from 'src/components/SuspenseLoader'; import useExtendedLinode from 'src/hooks/useExtendedLinode'; import { @@ -16,34 +14,43 @@ import { linodeDetailContextFactory as createLinodeDetailContext, LinodeDetailContextProvider, } from './linodeDetailContext'; -import LinodeDetailErrorBoundary from './LinodeDetailErrorBoundary'; +import { useLinodeQuery } from 'src/queries/linodes/linodes'; +import { ErrorState } from 'src/components/ErrorState/ErrorState'; +import { CircleProgress } from 'src/components/CircleProgress'; -const LinodesDetailHeader = React.lazy(() => import('./LinodesDetailHeader')); +const LinodesDetailHeader = React.lazy( + () => import('./LinodesDetailHeader/LinodeDetailHeader') +); const LinodesDetailNavigation = React.lazy( () => import('./LinodesDetailNavigation') ); const CloneLanding = React.lazy(() => import('../CloneLanding')); -interface Props { - linodeId: string; -} - -type CombinedProps = Props & RouteComponentProps<{ linodeId: string }>; +const LinodeDetail = () => { + const { url, path } = useRouteMatch(); + const { linodeId } = useParams<{ linodeId: string }>(); -const LinodeDetail: React.FC = (props) => { - const { - linodeId, - match: { path, url }, - } = props; + const id = Number(linodeId); const dispatch = useDispatch(); - const linode = useExtendedLinode(+linodeId); + const { data: linode, isLoading, error } = useLinodeQuery(id); + + // We can remove this when we remove the context below + const extendedLinode = useExtendedLinode(id); + + if (error) { + return ; + } - if (!linode) { - return ; + if (isLoading || !linode || !extendedLinode) { + return ; } - const ctx: LinodeDetailContext = createLinodeDetailContext(linode, dispatch); + // We will delete this as soon as we react query all consumers of this context + const ctx: LinodeDetailContext = createLinodeDetailContext( + extendedLinode, + dispatch + ); return ( @@ -87,9 +94,4 @@ const LinodeDetail: React.FC = (props) => { ); }; -const enhanced = compose( - withRouter, - LinodeDetailErrorBoundary -); - -export default enhanced(LinodeDetail); +export default LinodeDetail; diff --git a/packages/manager/src/features/linodes/LinodesDetail/LinodesDetailHeader/LinodeControls.tsx b/packages/manager/src/features/linodes/LinodesDetail/LinodesDetailHeader/LinodeControls.tsx deleted file mode 100644 index 77b0f8c72f1..00000000000 --- a/packages/manager/src/features/linodes/LinodesDetail/LinodesDetailHeader/LinodeControls.tsx +++ /dev/null @@ -1,165 +0,0 @@ -import { APIError } from '@linode/api-v4/lib/types'; -import { last } from 'ramda'; -import * as React from 'react'; -import { RouteComponentProps, withRouter } from 'react-router-dom'; -import { compose } from 'recompose'; -import { - Breadcrumb, - BreadcrumbProps, -} from 'src/components/Breadcrumb/Breadcrumb'; -import Button from 'src/components/Button'; -import { createStyles, withStyles, WithStyles } from '@mui/styles'; -import { Theme } from '@mui/material/styles'; -import Grid from '@mui/material/Unstable_Grid2'; -import { lishLaunch } from 'src/features/Lish/lishUtils'; -import useEditableLabelState from 'src/hooks/useEditableLabelState'; -import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; -import scrollErrorIntoView from 'src/utilities/scrollErrorIntoView'; -import { - LinodeDetailContext, - withLinodeDetailContext, -} from '../linodeDetailContext'; -import LinodePowerControl from '../LinodePowerControl'; - -type ClassNames = 'breadCrumbs' | 'controls' | 'launchButton'; - -const styles = (theme: Theme) => - createStyles({ - breadCrumbs: { - position: 'relative', - top: -2, - [theme.breakpoints.down('md')]: { - top: 10, - }, - }, - controls: { - position: 'relative', - marginTop: `calc(9 - (${theme.spacing(1)} / 2))`, // 4 - [theme.breakpoints.down('md')]: { - margin: 0, - left: -8, - display: 'flex', - flexBasis: '100%', - }, - }, - launchButton: { - lineHeight: 1, - '&:hover': { - backgroundColor: 'transparent', - textDecoration: 'underline', - }, - '&:focus > span:first-of-type': { - outline: '1px dotted #999', - }, - }, - }); - -interface Props { - breadcrumbProps?: Partial; -} - -type CombinedProps = Props & - LinodeDetailContext & - RouteComponentProps<{}> & - WithStyles; - -const LinodeControls: React.FC = (props) => { - const { classes, linode, updateLinode, breadcrumbProps } = props; - - const { - editableLabelError, - setEditableLabelError, - resetEditableLabel, - } = useEditableLabelState(); - - const disabled = linode._permissions === 'read_only'; - - const handleSubmitLabelChange = (label: string) => { - return updateLinode({ label }) - .then(() => { - resetEditableLabel(); - }) - .catch((err) => { - const errors: APIError[] = getAPIErrorOrDefault( - err, - 'An error occurred while updating label', - 'label' - ); - const errorStrings: string[] = errors.map((e) => e.reason); - setEditableLabelError(errorStrings[0]); - scrollErrorIntoView(); - return Promise.reject(errorStrings[0]); - }); - }; - - const getLabelLink = (): string | undefined => { - return last(location.pathname.split('/')) !== 'summary' - ? `${linode.id}/summary` - : undefined; - }; - return ( - - - - - - - - - - ); -}; - -const styled = withStyles(styles); - -const enhanced = compose( - withRouter, - withLinodeDetailContext(({ linode, updateLinode }) => ({ - linode, - updateLinode, - configs: linode._configs, - })), - styled -); - -export default enhanced(LinodeControls); diff --git a/packages/manager/src/features/linodes/LinodesDetail/LinodesDetailHeader/LinodeDetailHeader.tsx b/packages/manager/src/features/linodes/LinodesDetail/LinodesDetailHeader/LinodeDetailHeader.tsx index c178df0336d..6d87df38f75 100644 --- a/packages/manager/src/features/linodes/LinodesDetail/LinodesDetailHeader/LinodeDetailHeader.tsx +++ b/packages/manager/src/features/linodes/LinodesDetail/LinodesDetailHeader/LinodeDetailHeader.tsx @@ -1,26 +1,19 @@ -import { Config, Disk, LinodeStatus } from '@linode/api-v4/lib/linodes'; import * as React from 'react'; import { useHistory, useLocation, useRouteMatch } from 'react-router-dom'; -import { compose } from 'recompose'; import TagDrawer from 'src/components/TagCell/TagDrawer'; import LinodeEntityDetail from 'src/features/linodes/LinodeEntityDetail'; import { PowerActionsDialog, Action, } from 'src/features/linodes/PowerActionsDialogOrDrawer'; -import useLinodeActions from 'src/hooks/useLinodeActions'; import { parseQueryParams } from 'src/utilities/queryParams'; import { DeleteLinodeDialog } from '../../LinodesLanding/DeleteLinodeDialog'; import { MigrateLinode } from 'src/features/linodes/MigrateLinode'; -import { - LinodeDetailContext, - withLinodeDetailContext, -} from '../linodeDetailContext'; import { LinodeRebuildDialog } from '../LinodeRebuild/LinodeRebuildDialog'; import { RescueDialog } from '../LinodeRescue/RescueDialog'; import LinodeResize from '../LinodeResize/LinodeResize'; import HostMaintenance from './HostMaintenance'; -import MutationNotification from './MutationNotification'; +import { MutationNotification } from './MutationNotification'; import Notifications from './Notifications'; import LandingHeader from 'src/components/LandingHeader'; import { sendEvent } from 'src/utilities/ga'; @@ -28,23 +21,20 @@ import useEditableLabelState from 'src/hooks/useEditableLabelState'; import { APIError } from '@linode/api-v4/lib/types'; import scrollErrorIntoView from 'src/utilities/scrollErrorIntoView'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; -import { ACCESS_LEVELS } from 'src/constants'; import { EnableBackupsDialog } from '../LinodeBackup/EnableBackupsDialog'; - -interface Props { - numVolumes: number; - username: string; - linodeConfigs: Config[]; -} +import { + useLinodeQuery, + useLinodeUpdateMutation, +} from 'src/queries/linodes/linodes'; +import { CircleProgress } from 'src/components/CircleProgress'; +import { ErrorState } from 'src/components/ErrorState/ErrorState'; interface TagDrawerProps { tags: string[]; open: boolean; } -type CombinedProps = Props & LinodeDetailContext & LinodeContext; - -const LinodeDetailHeader: React.FC = (props) => { +const LinodeDetailHeader = () => { // Several routes that used to have dedicated pages (e.g. /resize, /rescue) // now show their content in modals instead. The logic below facilitates handling // modal-related query params (and the older /:subpath routes before the redirect @@ -59,7 +49,11 @@ const LinodeDetailHeader: React.FC = (props) => { const matchedLinodeId = Number(match?.params?.linodeId ?? 0); - const { linode, linodeStatus, linodeDisks } = props; + const { data: linode, isLoading, error } = useLinodeQuery(matchedLinodeId); + + const { mutateAsync: updateLinode } = useLinodeUpdateMutation( + matchedLinodeId + ); const [powerAction, setPowerAction] = React.useState('Reboot'); const [powerDialogOpen, setPowerDialogOpen] = React.useState(false); @@ -87,7 +81,6 @@ const LinodeDetailHeader: React.FC = (props) => { tags: [], }); - const { updateLinode } = useLinodeActions(); const history = useHistory(); const closeDialogs = () => { @@ -123,8 +116,8 @@ const LinodeDetailHeader: React.FC = (props) => { }); }; - const updateTags = (linodeId: number, tags: string[]) => { - return updateLinode({ linodeId, tags }).then((_) => { + const updateTags = (tags: string[]) => { + return updateLinode({ tags }).then((_) => { setTagDrawer((tagDrawer) => ({ ...tagDrawer, tags })); }); }; @@ -134,11 +127,10 @@ const LinodeDetailHeader: React.FC = (props) => { setEditableLabelError, resetEditableLabel, } = useEditableLabelState(); - const disabled = linode._permissions === ACCESS_LEVELS.readOnly; - const updateLinodeLabel = async (linodeId: number, label: string) => { + const updateLinodeLabel = async (label: string) => { try { - await updateLinode({ linodeId, label }); + await updateLinode({ label }); } catch (updateError) { const errors: APIError[] = getAPIErrorOrDefault( updateError, @@ -151,8 +143,7 @@ const LinodeDetailHeader: React.FC = (props) => { }; const handleLinodeLabelUpdate = (label: string) => { - const linodeId = linode.id; - return updateLinodeLabel(linodeId, label) + return updateLinodeLabel(label) .then(() => { resetEditableLabel(); }) @@ -189,10 +180,6 @@ const LinodeDetailHeader: React.FC = (props) => { setMigrateDialogOpen(true); }; - const onDeleteSuccess = () => { - history.push('/linodes'); - }; - const handlers = { onOpenPowerDialog, onOpenDeleteDialog, @@ -202,25 +189,35 @@ const LinodeDetailHeader: React.FC = (props) => { onOpenMigrateDialog, }; + if (isLoading) { + return ; + } + + if (error) { + return ; + } + + if (!linode) { + return null; + } + return ( <> - - + + { sendEvent({ @@ -231,9 +228,8 @@ const LinodeDetailHeader: React.FC = (props) => { }} /> @@ -247,7 +243,6 @@ const LinodeDetailHeader: React.FC = (props) => { open={deleteDialogOpen} onClose={closeDialogs} linodeId={matchedLinodeId} - onSuccess={onDeleteSuccess} /> = (props) => { linodeId={matchedLinodeId} /> updateTags(linode.id, tags)} + updateTags={updateTags} onClose={closeTagDrawer} /> = (props) => { ); }; -interface LinodeContext { - linodeStatus: LinodeStatus; - linodeDisks: Disk[]; -} - -export default compose( - withLinodeDetailContext(({ linode }) => ({ - linode, - linodeStatus: linode.status, - linodeDisks: linode._disks, - configs: linode._configs, - })) -)(LinodeDetailHeader); +export default LinodeDetailHeader; diff --git a/packages/manager/src/features/linodes/LinodesDetail/LinodesDetailHeader/LinodeDetailsBreadcrumb.tsx b/packages/manager/src/features/linodes/LinodesDetail/LinodesDetailHeader/LinodeDetailsBreadcrumb.tsx deleted file mode 100644 index ac723be9923..00000000000 --- a/packages/manager/src/features/linodes/LinodesDetail/LinodesDetailHeader/LinodeDetailsBreadcrumb.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import { APIError } from '@linode/api-v4/lib/types'; -import { last } from 'ramda'; -import * as React from 'react'; -import { RouteComponentProps, withRouter } from 'react-router-dom'; -import { compose } from 'recompose'; -import { - Breadcrumb, - BreadcrumbProps, -} from 'src/components/Breadcrumb/Breadcrumb'; -import { makeStyles } from '@mui/styles'; -import { Theme } from '@mui/material/styles'; -import DocsLink from 'src/components/DocsLink'; -import Grid from '@mui/material/Unstable_Grid2'; -import useEditableLabelState from 'src/hooks/useEditableLabelState'; -import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; -import scrollErrorIntoView from 'src/utilities/scrollErrorIntoView'; -import { - LinodeDetailContext, - withLinodeDetailContext, -} from '../linodeDetailContext'; - -const useStyles = makeStyles((theme: Theme) => ({ - root: { - textDecoration: 'none', - [theme.breakpoints.down('md')]: { - paddingRight: `${theme.spacing()} !important`, - }, - [theme.breakpoints.down('sm')]: { - paddingLeft: theme.spacing(), - }, - }, -})); - -interface Props { - breadcrumbProps?: Partial; -} - -type CombinedProps = Props & LinodeDetailContext & RouteComponentProps<{}>; - -const LinodeControls: React.FC = (props) => { - const classes = useStyles(); - - const { linode, updateLinode, breadcrumbProps } = props; - - const { - editableLabelError, - setEditableLabelError, - resetEditableLabel, - } = useEditableLabelState(); - - const disabled = linode._permissions === 'read_only'; - - const handleSubmitLabelChange = (label: string) => { - return updateLinode({ label }) - .then(() => { - resetEditableLabel(); - }) - .catch((err) => { - const errors: APIError[] = getAPIErrorOrDefault( - err, - 'An error occurred while updating label', - 'label' - ); - const errorStrings: string[] = errors.map((e) => e.reason); - setEditableLabelError(errorStrings[0]); - scrollErrorIntoView(); - return Promise.reject(errorStrings[0]); - }); - }; - - const getLabelLink = (): string | undefined => { - return last(location.pathname.split('/')) !== 'summary' - ? `${linode.id}/summary` - : undefined; - }; - return ( - - - - - - - - - ); -}; - -const enhanced = compose( - withRouter, - withLinodeDetailContext(({ linode, updateLinode }) => ({ - linode, - updateLinode, - configs: linode._configs, - })) -); - -export default enhanced(LinodeControls); diff --git a/packages/manager/src/features/linodes/LinodesDetail/LinodesDetailHeader/MutationNotification.tsx b/packages/manager/src/features/linodes/LinodesDetail/LinodesDetailHeader/MutationNotification.tsx index 66ee623fdc8..233fe495219 100644 --- a/packages/manager/src/features/linodes/LinodesDetail/LinodesDetailHeader/MutationNotification.tsx +++ b/packages/manager/src/features/linodes/LinodesDetail/LinodesDetailHeader/MutationNotification.tsx @@ -1,105 +1,79 @@ -import { Disk, LinodeSpecs, startMutation } from '@linode/api-v4/lib/linodes'; -import { withSnackbar, WithSnackbarProps } from 'notistack'; +import { Disk } from '@linode/api-v4/lib/linodes'; +import { useSnackbar } from 'notistack'; import * as React from 'react'; -import { connect, MapDispatchToProps } from 'react-redux'; -import { compose } from 'recompose'; -import { Action } from 'redux'; -import { ThunkDispatch } from 'redux-thunk'; -import { createStyles, withStyles, WithStyles } from '@mui/styles'; import { Theme } from '@mui/material/styles'; import Typography from 'src/components/core/Typography'; import { Notice } from 'src/components/Notice/Notice'; import { MBpsIntraDC } from 'src/constants'; import { resetEventsPolling } from 'src/eventsPolling'; -import { useSpecificTypes } from 'src/queries/types'; -import { ApplicationState } from 'src/store'; -import { requestLinodeForStore } from 'src/store/linodes/linode.requests'; -import { getErrorStringOrDefault } from 'src/utilities/errorUtils'; -import { withLinodeDetailContext } from '../linodeDetailContext'; +import { useTypeQuery } from 'src/queries/types'; import MutateDrawer from '../MutateDrawer'; -import withMutationDrawerState, { - MutationDrawerProps, -} from './mutationDrawerState'; -import { ExtendedType } from 'src/utilities/extendType'; +import { makeStyles } from 'tss-react/mui'; +import { useLinodeQuery } from 'src/queries/linodes/linodes'; +import { useAllLinodeDisksQuery } from 'src/queries/linodes/disks'; +import { useStartLinodeMutationMutation } from 'src/queries/linodes/actions'; -type ClassNames = 'pendingMutationLink'; - -const styles = (theme: Theme) => - createStyles({ - pendingMutationLink: { - ...theme.applyLinkStyles, - }, - }); +const useStyles = makeStyles()((theme: Theme) => ({ + pendingMutationLink: { + ...theme.applyLinkStyles, + }, +})); interface Props { - disks: Disk[]; + linodeId: number; } -type CombinedProps = Props & - MutationDrawerProps & - ContextProps & - WithSnackbarProps & - DispatchProps & - WithStyles; +export const MutationNotification = (props: Props) => { + const { classes } = useStyles(); + const { linodeId } = props; + const { enqueueSnackbar } = useSnackbar(); -const MutationNotification: React.FC = (props) => { - const { - classes, + const { data: linode } = useLinodeQuery(linodeId); + + const { data: currentTypeInfo } = useTypeQuery( + linode?.type ?? '', + linode !== undefined + ); + + const { data: successorTypeInfo } = useTypeQuery( + currentTypeInfo?.successor ?? '', + currentTypeInfo !== undefined && currentTypeInfo.successor !== null + ); + + const { data: disks } = useAllLinodeDisksQuery( linodeId, - linodeType, - linodeSpecs, - enqueueSnackbar, - openMutationDrawer, - closeMutationDrawer, - mutationFailed, - mutationDrawerError, - mutationDrawerLoading, - mutationDrawerOpen, - updateLinode, - } = props; - - const typesQuery = useSpecificTypes( - linodeType?.successor ? [linodeType?.successor] : [] + successorTypeInfo !== undefined ); - const successorMetaData = typesQuery[0]?.data ?? null; + + const [isMutationDrawerOpen, setIsMutationDrawerOpen] = React.useState(false); + + const { + mutateAsync: startMutation, + isLoading, + error, + } = useStartLinodeMutationMutation(linodeId); const initMutation = () => { - openMutationDrawer(); - - /* - * It's okay to disregard the possibility of linode - * being undefined. The upgrade message won't appear unless - * it's defined - */ - startMutation(linodeId) - .then(() => { - closeMutationDrawer(); - resetEventsPolling(); - updateLinode(linodeId); - enqueueSnackbar('Linode upgrade has been initiated.', { - variant: 'info', - }); - }) - .catch((errors) => { - const e = getErrorStringOrDefault( - errors, - 'Mutation could not be initiated.' - ); - mutationFailed(e); + startMutation().then(() => { + setIsMutationDrawerOpen(false); + resetEventsPolling(); + enqueueSnackbar('Linode upgrade has been initiated.', { + variant: 'info', }); + }); }; - const usedDiskSpace = addUsedDiskSpace(props.disks); + const usedDiskSpace = addUsedDiskSpace(disks ?? []); const estimatedTimeToUpgradeInMins = Math.ceil( usedDiskSpace / MBpsIntraDC / 60 ); /** Mutate */ - if (!linodeType || !successorMetaData) { + if (!currentTypeInfo || !successorTypeInfo) { return null; } - const { vcpus, network_out, disk, transfer, memory } = linodeType; + const { vcpus, network_out, disk, transfer, memory } = currentTypeInfo; return ( <> @@ -115,10 +89,10 @@ const MutationNotification: React.FC = (props) => { more,  setIsMutationDrawerOpen(true)} onKeyDown={(e) => { if (e.key === 'Enter') { - openMutationDrawer(); + setIsMutationDrawerOpen(true); } }} role="button" @@ -132,36 +106,36 @@ const MutationNotification: React.FC = (props) => { setIsMutationDrawerOpen(false)} isMovingFromSharedToDedicated={isMovingFromSharedToDedicated( - linodeType.id, - successorMetaData.id + currentTypeInfo.id, + successorTypeInfo.id )} mutateInfo={{ vcpus: - successorMetaData.vcpus !== vcpus ? successorMetaData.vcpus : null, + successorTypeInfo.vcpus !== vcpus ? successorTypeInfo.vcpus : null, network_out: - successorMetaData.network_out !== network_out - ? successorMetaData.network_out + successorTypeInfo.network_out !== network_out + ? successorTypeInfo.network_out : null, - disk: successorMetaData.disk !== disk ? successorMetaData.disk : null, + disk: successorTypeInfo.disk !== disk ? successorTypeInfo.disk : null, transfer: - successorMetaData.transfer !== transfer - ? successorMetaData.transfer + successorTypeInfo.transfer !== transfer + ? successorTypeInfo.transfer : null, memory: - successorMetaData.memory !== memory - ? successorMetaData.memory + successorTypeInfo.memory !== memory + ? successorTypeInfo.memory : null, }} currentTypeInfo={{ - vcpus: linodeSpecs.vcpus, - transfer: linodeSpecs.transfer, - disk: linodeSpecs.disk, - memory: linodeSpecs.memory, + vcpus: currentTypeInfo.vcpus, + transfer: currentTypeInfo.transfer, + disk: currentTypeInfo.disk, + memory: currentTypeInfo.memory, network_out, }} initMutation={initMutation} @@ -177,42 +151,6 @@ export const addUsedDiskSpace = (disks: Disk[]) => { return disks.reduce((accum, eachDisk) => eachDisk.size + accum, 0); }; -const styled = withStyles(styles); - -interface ContextProps { - linodeSpecs: LinodeSpecs; - linodeId: number; - linodeType?: ExtendedType | null; -} - -interface DispatchProps { - updateLinode: (id: number) => void; -} - -const mapDispatchToProps: MapDispatchToProps = ( - dispatch: ThunkDispatch> -) => { - return { - updateLinode: (id: number) => dispatch(requestLinodeForStore(id)), - }; -}; - -const connected = connect(undefined, mapDispatchToProps); - -const enhanced = compose( - styled, - connected, - withLinodeDetailContext(({ linode }) => ({ - linodeSpecs: linode.specs, - linodeId: linode.id, - linodeType: linode._type, - })), - withMutationDrawerState, - withSnackbar -); - -export default enhanced(MutationNotification); - // Hack solution to determine if a type is moving from shared CPU cores to dedicated. const isMovingFromSharedToDedicated = (typeA: string, typeB: string) => { return typeA.startsWith('g6-highmem') && typeB.startsWith('g7-highmem'); diff --git a/packages/manager/src/features/linodes/LinodesDetail/LinodesDetailHeader/index.ts b/packages/manager/src/features/linodes/LinodesDetail/LinodesDetailHeader/index.ts deleted file mode 100644 index 859496d7215..00000000000 --- a/packages/manager/src/features/linodes/LinodesDetail/LinodesDetailHeader/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from './LinodeDetailHeader'; diff --git a/packages/manager/src/features/linodes/LinodesDetail/LinodesDetailNavigation.tsx b/packages/manager/src/features/linodes/LinodesDetail/LinodesDetailNavigation.tsx index ef4e1276644..64eadd80826 100644 --- a/packages/manager/src/features/linodes/LinodesDetail/LinodesDetailNavigation.tsx +++ b/packages/manager/src/features/linodes/LinodesDetail/LinodesDetailNavigation.tsx @@ -1,7 +1,10 @@ -import { Config } from '@linode/api-v4/lib/linodes'; import * as React from 'react'; -import { matchPath, RouteComponentProps, withRouter } from 'react-router-dom'; -import { compose } from 'recompose'; +import { + matchPath, + useHistory, + useParams, + useRouteMatch, +} from 'react-router-dom'; import TabPanels from 'src/components/core/ReachTabPanels'; import Tabs from 'src/components/core/ReachTabs'; import DismissibleBanner from 'src/components/DismissibleBanner'; @@ -11,8 +14,10 @@ import SafeTabPanel from 'src/components/SafeTabPanel'; import SuspenseLoader from 'src/components/SuspenseLoader'; import TabLinkList from 'src/components/TabLinkList'; import SMTPRestrictionText from 'src/features/linodes/SMTPRestrictionText'; -import { ExtendedType } from 'src/utilities/extendType'; -import { withLinodeDetailContext } from './linodeDetailContext'; +import { useLinodeQuery } from 'src/queries/linodes/linodes'; +import { useTypeQuery } from 'src/queries/types'; +import { CircleProgress } from 'src/components/CircleProgress'; +import { ErrorState } from 'src/components/ErrorState/ErrorState'; const LinodeSummary = React.lazy(() => import('./LinodeSummary/LinodeSummary')); const LinodeNetworking = React.lazy( @@ -30,22 +35,17 @@ const LinodeSettings = React.lazy( () => import('./LinodeSettings/LinodeSettings') ); -type CombinedProps = ContextProps & - RouteComponentProps<{ - linodeId: string; - }>; +const LinodesDetailNavigation = () => { + const { linodeId } = useParams<{ linodeId: string }>(); + const id = Number(linodeId); + const { data: linode, error } = useLinodeQuery(id); + const { url } = useRouteMatch(); + const history = useHistory(); -const LinodesDetailNavigation: React.FC = (props) => { - const { - linodeId, - linodeLabel, - linodeType, - linodeCreated, - match: { url }, - } = props; + const { data: type } = useTypeQuery(linode?.type ?? '', linode !== undefined); // Bare metal Linodes have a very different detail view - const isBareMetalInstance = linodeType?.class === 'metal'; + const isBareMetalInstance = type?.class === 'metal'; const tabs = [ { @@ -93,22 +93,32 @@ const LinodesDetailNavigation: React.FC = (props) => { }; const navToURL = (index: number) => { - props.history.push(tabs[index].routeName); + history.push(tabs[index].routeName); }; let idx = 0; + if (error) { + return ; + } + + if (!linode) { + return ; + } + return ( <> - + {({ text }) => text !== null ? ( {text} @@ -119,20 +129,17 @@ const LinodesDetailNavigation: React.FC = (props) => {
- }> - - {isBareMetalInstance ? null : ( <> @@ -147,13 +154,11 @@ const LinodesDetailNavigation: React.FC = (props) => { )} - - - + @@ -163,27 +168,4 @@ const LinodesDetailNavigation: React.FC = (props) => { ); }; -interface ContextProps { - linodeId: number; - linodeConfigs: Config[]; - linodeLabel: string; - linodeRegion: string; - linodeCreated: string; - linodeType?: ExtendedType | null | undefined; - readOnly: boolean; -} - -const enhanced = compose( - withRouter, - withLinodeDetailContext(({ linode }) => ({ - linodeId: linode.id, - linodeConfigs: linode._configs, - linodeLabel: linode.label, - linodeRegion: linode.region, - linodeType: linode._type, - linodeCreated: linode.created, - readOnly: linode._permissions === 'read_only', - })) -); - -export default enhanced(LinodesDetailNavigation); +export default LinodesDetailNavigation; diff --git a/packages/manager/src/features/linodes/LinodesDetail/MutateDrawer/MutateDrawer.tsx b/packages/manager/src/features/linodes/LinodesDetail/MutateDrawer/MutateDrawer.tsx index 87f3c63119f..ef207b266e3 100644 --- a/packages/manager/src/features/linodes/LinodesDetail/MutateDrawer/MutateDrawer.tsx +++ b/packages/manager/src/features/linodes/LinodesDetail/MutateDrawer/MutateDrawer.tsx @@ -36,7 +36,7 @@ interface Props { currentTypeInfo: MutateInfo; linodeId: number; loading: boolean; - error: string; + error: string | undefined; estimatedTimeToUpgradeInMins: number; isMovingFromSharedToDedicated: boolean; } diff --git a/packages/manager/src/features/linodes/LinodesDetail/initLinode.ts b/packages/manager/src/features/linodes/LinodesDetail/initLinode.ts deleted file mode 100644 index 4397bb1a8d1..00000000000 --- a/packages/manager/src/features/linodes/LinodesDetail/initLinode.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { connect } from 'react-redux'; -import { compose, lifecycle } from 'recompose'; -import { getLinode as _getLinode } from 'src/store/linodes/linode.requests'; -import { GetLinodeRequest } from 'src/store/linodes/linodes.actions'; - -interface OuterProps { - linodeId: number; -} - -/** - * Get the Linode on mount and on linodeId change. - */ - -export default compose( - connect(undefined, { getLinode: _getLinode }), - lifecycle({ - componentDidMount() { - const { linodeId, getLinode } = this.props; - getLinode({ linodeId }); - }, - componentDidUpdate(prevProps) { - const { linodeId: prevLinodeId } = prevProps; - const { linodeId } = this.props; - if (linodeId !== prevLinodeId) { - _getLinode({ linodeId }); - } - }, - }) -); diff --git a/packages/manager/src/features/linodes/LinodesDetail/initLinodeConfigs.ts b/packages/manager/src/features/linodes/LinodesDetail/initLinodeConfigs.ts deleted file mode 100644 index 21d78eee43d..00000000000 --- a/packages/manager/src/features/linodes/LinodesDetail/initLinodeConfigs.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { connect } from 'react-redux'; -import { compose, lifecycle } from 'recompose'; -import { GetAllLinodeConfigsRequest } from 'src/store/linodes/config/config.actions'; -import { getAllLinodeConfigs } from 'src/store/linodes/config/config.requests'; - -interface OuterProps { - linodeId: number; -} - -/** - * Get the Linode's configs on mount and on linodeId change. - */ - -export default compose( - connect(undefined, { getAllLinodeConfigs }), - lifecycle< - OuterProps & { getAllLinodeConfigs: GetAllLinodeConfigsRequest }, - {} - >({ - componentDidMount() { - // tslint:disable-next-line:no-shadowed-variable - const { linodeId, getAllLinodeConfigs } = this.props; - getAllLinodeConfigs({ linodeId }); - }, - - componentDidUpdate(prevProps) { - // tslint:disable-next-line:no-shadowed-variable - const { linodeId: prevLinodeId, getAllLinodeConfigs } = this.props; - const { linodeId } = prevProps; - - if (linodeId === prevLinodeId) { - return; - } - - getAllLinodeConfigs({ linodeId }); - }, - }) -); diff --git a/packages/manager/src/features/linodes/LinodesDetail/initLinodeDisks.ts b/packages/manager/src/features/linodes/LinodesDetail/initLinodeDisks.ts deleted file mode 100644 index e8159f82d32..00000000000 --- a/packages/manager/src/features/linodes/LinodesDetail/initLinodeDisks.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { connect } from 'react-redux'; -import { compose, lifecycle } from 'recompose'; -import { GetAllLinodeConfigsRequest } from 'src/store/linodes/config/config.actions'; -import { getAllLinodeDisks } from 'src/store/linodes/disk/disk.requests'; - -interface OuterProps { - linodeId: number; -} - -/** - * Get the Linode's disks on mount and on linodeId change. - */ - -export default compose( - connect(undefined, { getAllLinodeDisks }), - lifecycle( - { - componentDidMount() { - // tslint:disable-next-line:no-shadowed-variable - const { linodeId, getAllLinodeDisks } = this.props; - - getAllLinodeDisks({ linodeId }); - }, - - componentDidUpdate(prevProps) { - // tslint:disable-next-line:no-shadowed-variable - const { linodeId, getAllLinodeDisks } = this.props; - const { linodeId: prevLinodeId } = this.props; - - if (linodeId === prevLinodeId) { - return; - } - - getAllLinodeDisks({ linodeId }); - }, - } - ) -); diff --git a/packages/manager/src/features/linodes/LinodesLanding/CardView.tsx b/packages/manager/src/features/linodes/LinodesLanding/CardView.tsx index 8c835c36ab5..cb186649337 100644 --- a/packages/manager/src/features/linodes/LinodesLanding/CardView.tsx +++ b/packages/manager/src/features/linodes/LinodesLanding/CardView.tsx @@ -5,9 +5,9 @@ import Typography from 'src/components/core/Typography'; import Grid from '@mui/material/Unstable_Grid2'; import TagDrawer, { TagDrawerProps } from 'src/components/TagCell/TagDrawer'; import LinodeEntityDetail from 'src/features/linodes/LinodeEntityDetail'; -import useLinodeActions from 'src/hooks/useLinodeActions'; import { useProfile } from 'src/queries/profile'; import { RenderLinodesProps } from './DisplayLinodes'; +import { useLinodeUpdateMutation } from 'src/queries/linodes/linodes'; const useStyles = makeStyles((theme: Theme) => ({ '@keyframes pulse': { @@ -28,10 +28,9 @@ const useStyles = makeStyles((theme: Theme) => ({ }, })); -const CardView: React.FC = (props) => { +const CardView = (props: RenderLinodesProps) => { const classes = useStyles(); - const { updateLinode } = useLinodeActions(); const { data: profile } = useProfile(); const [tagDrawer, setTagDrawer] = React.useState({ @@ -41,6 +40,10 @@ const CardView: React.FC = (props) => { entityID: 0, }); + const { mutateAsync: updateLinode } = useLinodeUpdateMutation( + tagDrawer.entityID + ); + const closeTagDrawer = () => { setTagDrawer({ ...tagDrawer, open: false }); }; @@ -54,8 +57,8 @@ const CardView: React.FC = (props) => { }); }; - const updateTags = (linodeId: number, tags: string[]) => { - return updateLinode({ linodeId, tags }).then((_) => { + const updateTags = (tags: string[]) => { + return updateLinode({ tags }).then((_) => { setTagDrawer({ ...tagDrawer, tags }); }); }; @@ -101,7 +104,6 @@ const CardView: React.FC = (props) => { openTagDrawer(linode.label, linode.id, tags) } linode={linode} - backups={linode.backups} /> @@ -111,7 +113,7 @@ const CardView: React.FC = (props) => { entityLabel={tagDrawer.label} open={tagDrawer.open} tags={tagDrawer.tags} - updateTags={(tags) => updateTags(tagDrawer.entityID, tags)} + updateTags={updateTags} onClose={closeTagDrawer} /> diff --git a/packages/manager/src/features/linodes/LinodesLanding/DeleteLinodeDialog.tsx b/packages/manager/src/features/linodes/LinodesLanding/DeleteLinodeDialog.tsx index 98a2daa3e5c..3c8a3b202ee 100644 --- a/packages/manager/src/features/linodes/LinodesLanding/DeleteLinodeDialog.tsx +++ b/packages/manager/src/features/linodes/LinodesLanding/DeleteLinodeDialog.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import Typography from 'src/components/core/Typography'; import { Notice } from 'src/components/Notice/Notice'; -import TypeToConfirmDialog from 'src/components/TypeToConfirmDialog'; +import { TypeToConfirmDialog } from 'src/components/TypeToConfirmDialog/TypeToConfirmDialog'; import { resetEventsPolling } from 'src/eventsPolling'; import { useDeleteLinodeMutation, diff --git a/packages/manager/src/features/linodes/LinodesLanding/DisplayGroupedLinodes.tsx b/packages/manager/src/features/linodes/LinodesLanding/DisplayGroupedLinodes.tsx index 2e10ffab67f..71bb8c5eddd 100644 --- a/packages/manager/src/features/linodes/LinodesLanding/DisplayGroupedLinodes.tsx +++ b/packages/manager/src/features/linodes/LinodesLanding/DisplayGroupedLinodes.tsx @@ -3,7 +3,7 @@ import { compose } from 'ramda'; import * as React from 'react'; import GroupByTag from 'src/assets/icons/group-by-tag.svg'; import TableView from 'src/assets/icons/table-view.svg'; -import IconButton from 'src/components/IconButton'; +import { IconButton } from 'src/components/IconButton'; import { makeStyles } from '@mui/styles'; import { Theme } from '@mui/material/styles'; import { TableBody } from 'src/components/TableBody'; diff --git a/packages/manager/src/features/linodes/LinodesLanding/DisplayLinodes.tsx b/packages/manager/src/features/linodes/LinodesLanding/DisplayLinodes.tsx index ed170e48911..2999ec19698 100644 --- a/packages/manager/src/features/linodes/LinodesLanding/DisplayLinodes.tsx +++ b/packages/manager/src/features/linodes/LinodesLanding/DisplayLinodes.tsx @@ -13,7 +13,7 @@ import { Action } from 'src/features/linodes/PowerActionsDialogOrDrawer'; import { DialogType } from 'src/features/linodes/types'; import { useInfinitePageSize } from 'src/hooks/useInfinitePageSize'; import TableWrapper from './TableWrapper'; -import IconButton from 'src/components/IconButton'; +import { IconButton } from 'src/components/IconButton'; import Tooltip from 'src/components/core/Tooltip'; import GroupByTag from 'src/assets/icons/group-by-tag.svg'; import TableView from 'src/assets/icons/table-view.svg'; diff --git a/packages/manager/src/features/linodes/LinodesLanding/LinodesLanding.tsx b/packages/manager/src/features/linodes/LinodesLanding/LinodesLanding.tsx index ec1e7653591..b124f5adfd3 100644 --- a/packages/manager/src/features/linodes/LinodesLanding/LinodesLanding.tsx +++ b/packages/manager/src/features/linodes/LinodesLanding/LinodesLanding.tsx @@ -10,13 +10,12 @@ import { AnyAction } from 'redux'; import { ThunkDispatch } from 'redux-thunk'; import { CircleProgress } from 'src/components/CircleProgress'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; -import ErrorState from 'src/components/ErrorState'; +import { ErrorState } from 'src/components/ErrorState/ErrorState'; import Grid from '@mui/material/Unstable_Grid2'; import LandingHeader from 'src/components/LandingHeader'; import MaintenanceBanner from 'src/components/MaintenanceBanner'; import OrderBy from 'src/components/OrderBy'; import PreferenceToggle, { ToggleProps } from 'src/components/PreferenceToggle'; -import TransferDisplay from 'src/components/TransferDisplay'; import { withProfile, WithProfileProps, @@ -48,6 +47,7 @@ import LinodeResize from '../LinodesDetail/LinodeResize/LinodeResize'; import { RescueDialog } from '../LinodesDetail/LinodeRescue/RescueDialog'; import { DeleteLinodeDialog } from './DeleteLinodeDialog'; import { LinodeWithMaintenance } from 'src/store/linodes/linodes.helpers'; +import { TransferDisplay } from 'src/components/TransferDisplay/TransferDisplay'; interface State { powerDialogOpen: boolean; diff --git a/packages/manager/src/features/linodes/LinodesLanding/SortableTableHead.tsx b/packages/manager/src/features/linodes/LinodesLanding/SortableTableHead.tsx index aa7acf5a14c..2f15ebf557a 100644 --- a/packages/manager/src/features/linodes/LinodesLanding/SortableTableHead.tsx +++ b/packages/manager/src/features/linodes/LinodesLanding/SortableTableHead.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import GridView from 'src/assets/icons/grid-view.svg'; import Hidden from 'src/components/core/Hidden'; -import IconButton from 'src/components/IconButton'; +import { IconButton } from 'src/components/IconButton'; import { makeStyles } from '@mui/styles'; import { Theme } from '@mui/material/styles'; import { TableHead } from 'src/components/TableHead'; diff --git a/packages/manager/src/foundations/breakpoints.ts b/packages/manager/src/foundations/breakpoints.ts new file mode 100644 index 00000000000..ad313364edc --- /dev/null +++ b/packages/manager/src/foundations/breakpoints.ts @@ -0,0 +1,11 @@ +import createBreakpoints from '@mui/system/createTheme/createBreakpoints'; + +export const breakpoints = createBreakpoints({ + values: { + xs: 0, + sm: 600, + md: 960, + lg: 1280, + xl: 1920, + }, +}); diff --git a/packages/manager/src/foundations/fonts.ts b/packages/manager/src/foundations/fonts.ts new file mode 100644 index 00000000000..a7fb0eeeae9 --- /dev/null +++ b/packages/manager/src/foundations/fonts.ts @@ -0,0 +1,5 @@ +export const latoWeb = { + normal: '"LatoWeb", sans-serif', + semiBold: '"LatoWebSemibold", sans-serif', + bold: '"LatoWebBold", sans-serif', +} as const; diff --git a/packages/manager/src/themes.ts b/packages/manager/src/foundations/themes/dark.ts similarity index 97% rename from packages/manager/src/themes.ts rename to packages/manager/src/foundations/themes/dark.ts index 3e747e3a776..ba2e788ff17 100644 --- a/packages/manager/src/themes.ts +++ b/packages/manager/src/foundations/themes/dark.ts @@ -1,8 +1,5 @@ -import { createTheme, ThemeOptions } from '@mui/material/styles'; -import { mergeDeepRight } from 'ramda'; -import { base, breakpoints } from './themeFactory'; - -export const light = createTheme(base); +import { ThemeOptions } from '@mui/material/styles'; +import { breakpoints } from 'src/foundations/breakpoints'; const primaryColors = { main: '#3683dc', @@ -127,7 +124,7 @@ const genericTableHeaderStyle = { }, }; -const darkThemeOptions: ThemeOptions = { +export const darkTheme: ThemeOptions = { name: 'dark', breakpoints, bg: customDarkModeOptions.bg, @@ -586,5 +583,3 @@ const darkThemeOptions: ThemeOptions = { }, }, }; - -export const dark = createTheme(mergeDeepRight(base, darkThemeOptions)); diff --git a/packages/manager/src/foundations/themes/index.ts b/packages/manager/src/foundations/themes/index.ts new file mode 100644 index 00000000000..b551469ff35 --- /dev/null +++ b/packages/manager/src/foundations/themes/index.ts @@ -0,0 +1,85 @@ +import { createTheme } from '@mui/material/styles'; +import _merge from 'lodash/merge'; + +// Themes & Brands +import { darkTheme } from 'src/foundations/themes/dark'; +import { lightTheme } from 'src/foundations/themes/light'; + +// Types & Interfaces +import { customDarkModeOptions } from 'src/foundations/themes/dark'; +import { latoWeb } from 'src/foundations/fonts'; +import { + color, + bg, + textColors, + borderColors, +} from 'src/foundations/themes/light'; + +export type ThemeName = 'light' | 'dark'; + +type Fonts = typeof latoWeb; + +type MergeTypes = Omit & + Omit & + { [K in keyof A & keyof B]: A[K] | B[K] }; + +type LightModeColors = typeof color; +type DarkModeColors = typeof customDarkModeOptions.color; + +type Colors = MergeTypes; + +type LightModeBgColors = typeof bg; +type DarkModeBgColors = typeof customDarkModeOptions.bg; + +type BgColors = MergeTypes; + +type LightModeTextColors = typeof textColors; +type DarkModeTextColors = typeof customDarkModeOptions.textColors; +type TextColors = MergeTypes; + +type LightModeBorderColors = typeof borderColors; +type DarkModeBorderColors = typeof customDarkModeOptions.borderColors; + +type BorderColors = MergeTypes; + +/** + * Augmenting the Theme and ThemeOptions. + * This allows us to add custom fields to the theme. + * Avoid doing this unless you have a good reason. + */ +declare module '@mui/material/styles/createTheme' { + interface Theme { + name: ThemeName; + bg: BgColors; + color: Colors; + textColors: TextColors; + borderColors: BorderColors; + font: Fonts; + graphs: any; + visually: any; + animateCircleIcon?: any; + addCircleHoverEffect?: any; + applyLinkStyles?: any; + applyStatusPillStyles?: any; + applyTableHeaderStyles?: any; + } + + interface ThemeOptions { + name: ThemeName; + bg?: LightModeBgColors | DarkModeBgColors; + color?: LightModeColors | DarkModeColors; + textColors?: LightModeTextColors | DarkModeTextColors; + borderColors?: LightModeBorderColors | DarkModeBorderColors; + font?: Fonts; + graphs?: any; + visually?: any; + animateCircleIcon?: any; + addCircleHoverEffect?: any; + applyLinkStyles?: any; + applyStatusPillStyles?: any; + applyTableHeaderStyles?: any; + } +} + +export const light = createTheme(lightTheme); +export const dark = createTheme(_merge(lightTheme, darkTheme)); diff --git a/packages/manager/src/themeFactory.ts b/packages/manager/src/foundations/themes/light.ts similarity index 91% rename from packages/manager/src/themeFactory.ts rename to packages/manager/src/foundations/themes/light.ts index 3fc47570764..160b9b6e317 100644 --- a/packages/manager/src/themeFactory.ts +++ b/packages/manager/src/foundations/themes/light.ts @@ -1,84 +1,8 @@ import { ThemeOptions } from '@mui/material/styles'; -import createBreakpoints from '@mui/system/createTheme/createBreakpoints'; -import { customDarkModeOptions } from './themes'; +import { breakpoints } from 'src/foundations/breakpoints'; +import { latoWeb } from 'src/foundations/fonts'; -export type ThemeName = 'light' | 'dark'; - -type Fonts = typeof primaryFonts; - -type MergeTypes = Omit & - Omit & - { [K in keyof A & keyof B]: A[K] | B[K] }; - -type LightModeColors = typeof color; -type DarkModeColors = typeof customDarkModeOptions.color; - -type Colors = MergeTypes; - -type LightModeBgColors = typeof bg; -type DarkModeBgColors = typeof customDarkModeOptions.bg; - -type BgColors = MergeTypes; - -type LightModeTextColors = typeof textColors; -type DarkModeTextColors = typeof customDarkModeOptions.textColors; -type TextColors = MergeTypes; - -type LightModeBorderColors = typeof borderColors; -type DarkModeBorderColors = typeof customDarkModeOptions.borderColors; - -type BorderColors = MergeTypes; - -/** - * Augmenting the Theme and ThemeOptions. - * This allows us to add cutom fields to the theme. - * Avoid doing this unless you have a good reason. - */ -declare module '@mui/material/styles/createTheme' { - interface Theme { - name: ThemeName; - bg: BgColors; - color: Colors; - textColors: TextColors; - borderColors: BorderColors; - font: Fonts; - graphs: any; - visually: any; - animateCircleIcon?: any; - addCircleHoverEffect?: any; - applyLinkStyles?: any; - applyStatusPillStyles?: any; - applyTableHeaderStyles?: any; - } - - interface ThemeOptions { - name: ThemeName; - bg?: LightModeBgColors | DarkModeBgColors; - color?: LightModeColors | DarkModeColors; - textColors?: LightModeTextColors | DarkModeTextColors; - borderColors?: LightModeBorderColors | DarkModeBorderColors; - font?: Fonts; - graphs?: any; - visually?: any; - animateCircleIcon?: any; - addCircleHoverEffect?: any; - applyLinkStyles?: any; - applyStatusPillStyles?: any; - applyTableHeaderStyles?: any; - } -} - -export const breakpoints = createBreakpoints({ - values: { - xs: 0, - sm: 600, - md: 960, - lg: 1280, - xl: 1920, - }, -}); - -const bg = { +export const bg = { app: '#f4f5f6', main: '#f4f4f4', offWhite: '#fbfbfb', @@ -103,7 +27,7 @@ const primaryColors = { white: '#fff', }; -const color = { +export const color = { headline: primaryColors.headline, red: '#ca0813', orange: '#ffb31a', @@ -136,7 +60,7 @@ const color = { blue: '#3683dc', } as const; -const textColors = { +export const textColors = { linkActiveLight: '#2575d0', headlineStatic: '#32363c', tableHeader: '#888f91', @@ -144,18 +68,12 @@ const textColors = { textAccessTable: '#606469', } as const; -const borderColors = { +export const borderColors = { borderTypography: '#e3e5e8', borderTable: '#f4f5f6', divider: '#e3e5e8', } as const; -const primaryFonts = { - normal: '"LatoWeb", sans-serif', - semiBold: '"LatoWebSemibold", sans-serif', - bold: '"LatoWebBold", sans-serif', -} as const; - const iconCircleAnimation = { '& .circle': { fill: primaryColors.main, @@ -202,7 +120,7 @@ const genericLinkStyle = { const genericStatusPillStyle = { backgroundColor: 'transparent', color: textColors.tableStatic, - fontFamily: primaryFonts.bold, + fontFamily: latoWeb.bold, fontSize: '1rem', padding: 0, '&:before': { @@ -247,7 +165,7 @@ const graphTransparency = '0.7'; const spacing = 8; -export const base: ThemeOptions = { +export const lightTheme: ThemeOptions = { name: 'light', // we really should just leverage pallete.mode breakpoints, shadows: [ @@ -346,9 +264,9 @@ export const base: ThemeOptions = { yellow: `rgba(255, 220, 125, ${graphTransparency})`, }, font: { - normal: primaryFonts.normal, - semiBold: primaryFonts.semiBold, - bold: primaryFonts.bold, + normal: latoWeb.normal, + semiBold: latoWeb.semiBold, + bold: latoWeb.bold, }, animateCircleIcon: { ...iconCircleAnimation, @@ -388,13 +306,13 @@ export const base: ThemeOptions = { }, }, typography: { - fontFamily: primaryFonts.normal, + fontFamily: latoWeb.normal, fontSize: 16, h1: { color: primaryColors.headline, fontSize: '1.25rem', lineHeight: '1.75rem', - fontFamily: primaryFonts.bold, + fontFamily: latoWeb.bold, [breakpoints.up('lg')]: { fontSize: '1.5rem', lineHeight: '1.875rem', @@ -403,13 +321,13 @@ export const base: ThemeOptions = { h2: { color: primaryColors.headline, fontSize: '1.125rem', - fontFamily: primaryFonts.bold, + fontFamily: latoWeb.bold, lineHeight: '1.5rem', }, h3: { color: primaryColors.headline, fontSize: '1rem', - fontFamily: primaryFonts.bold, + fontFamily: latoWeb.bold, lineHeight: '1.4rem', }, body1: { @@ -489,7 +407,7 @@ export const base: ThemeOptions = { border: 'none', borderRadius: 1, cursor: 'pointer', - fontFamily: primaryFonts.bold, + fontFamily: latoWeb.bold, fontSize: '1rem', minHeight: 34, minWidth: 105, @@ -794,7 +712,7 @@ export const base: ThemeOptions = { styleOverrides: { root: { color: '#555', - fontFamily: primaryFonts.bold, + fontFamily: latoWeb.bold, fontSize: '.875rem', marginBottom: 8, '&$focused': { @@ -835,6 +753,9 @@ export const base: ThemeOptions = { marginRight: 0, }, }, + defaultProps: { + size: 'large', + }, }, MuiInput: { styleOverrides: { @@ -989,7 +910,7 @@ export const base: ThemeOptions = { }, '&.selectHeader': { opacity: 1, - fontFamily: primaryFonts.bold, + fontFamily: latoWeb.bold, fontSize: '1rem', color: primaryColors.text, }, @@ -1044,7 +965,7 @@ export const base: ThemeOptions = { styleOverrides: { root: { height: 'auto', - fontFamily: primaryFonts.normal, + fontFamily: latoWeb.normal, fontSize: '.9rem', whiteSpace: 'initial', textOverflow: 'initial', @@ -1273,7 +1194,7 @@ export const base: ThemeOptions = { minWidth: 75, }, '&$selected, &$selected:hover': { - fontFamily: primaryFonts.bold, + fontFamily: latoWeb.bold, color: primaryColors.headline, }, '&:hover': { @@ -1448,7 +1369,7 @@ export const base: ThemeOptions = { borderRadius: '3px', fontSize: '1rem', lineHeight: 1, - fontFamily: primaryFonts.bold, + fontFamily: latoWeb.bold, backgroundColor: primaryColors.main, color: '#fff', padding: `8px 20px`, diff --git a/packages/manager/src/hooks/useScript.ts b/packages/manager/src/hooks/useScript.ts index 3baae5b9173..e07e92f2f72 100644 --- a/packages/manager/src/hooks/useScript.ts +++ b/packages/manager/src/hooks/useScript.ts @@ -13,62 +13,62 @@ interface ScriptOptions { * The logic comes from https://usehooks.com/useScript/ * @param src source url of the script you intend to load * @param options setStatus - a react state set function so that the hook's state can be updated; location - placement of the script in document - * @returns void + * @returns Promise */ -export const loadScript = (src: string, options?: ScriptOptions) => { - // Allow falsy src value if waiting on other data needed for - // constructing the script URL passed to this hook. - if (!src) { - options?.setStatus?.('idle'); - return; - } - // Fetch existing script element by src - // It may have been added by another intance of this hook - let script = document.querySelector( - `script[src='${src}']` - ) as HTMLScriptElement; - if (!script) { - // Create script - script = document.createElement('script'); - script.src = src; - script.async = true; - script.setAttribute('data-status', 'loading'); - // Add script to document; default to body - if (options?.location === 'head') { - document.head.appendChild(script); +export const loadScript = ( + src: string, + options?: ScriptOptions +): Promise => { + return new Promise((resolve, reject) => { + // Allow falsy src value if waiting on other data needed for + // constructing the script URL passed to this hook. + if (!src) { + options?.setStatus?.('idle'); + return resolve({ status: 'idle' }); + } + // Fetch existing script element by src + // It may have been added by another intance of this hook + let script = document.querySelector( + `script[src='${src}']` + ) as HTMLScriptElement; + if (!script) { + // Create script + script = document.createElement('script'); + script.src = src; + script.async = true; + script.setAttribute('data-status', 'loading'); + + script.onload = (event: any) => { + script.setAttribute('data-status', 'ready'); + setStateFromEvent(event); + resolve({ status: 'ready' }); + }; + script.onerror = (event: any) => { + script.setAttribute('data-status', 'error'); + setStateFromEvent(event); + reject({ + status: 'error', + message: `Failed to load script with src ${src}`, + }); + }; + + // Add script to document; default to body + if (options?.location === 'head') { + document.head.appendChild(script); + } else { + document.body.appendChild(script); + } } else { - document.body.appendChild(script); + // Grab existing script status from attribute and set to state. + options?.setStatus?.(script.getAttribute('data-status') as ScriptStatus); } - // Store status in attribute on script - // This can be read by other instances of this hook - const setAttributeFromEvent = (event: any) => { - script.setAttribute( - 'data-status', - event.type === 'load' ? 'ready' : 'error' - ); + // Script event handler to update status in state + // Note: Even if the script already exists we still need to add + // event handlers to update the state for *this* hook instance. + const setStateFromEvent = (event: any) => { + options?.setStatus?.(event.type === 'load' ? 'ready' : 'error'); }; - script.addEventListener('load', setAttributeFromEvent); - script.addEventListener('error', setAttributeFromEvent); - } else { - // Grab existing script status from attribute and set to state. - options?.setStatus?.(script.getAttribute('data-status') as ScriptStatus); - } - // Script event handler to update status in state - // Note: Even if the script already exists we still need to add - // event handlers to update the state for *this* hook instance. - const setStateFromEvent = (event: any) => { - options?.setStatus?.(event.type === 'load' ? 'ready' : 'error'); - }; - // Add event listeners - script.addEventListener('load', setStateFromEvent); - script.addEventListener('error', setStateFromEvent); - // Remove event listeners on cleanup - return () => { - if (script) { - script.removeEventListener('load', setStateFromEvent); - script.removeEventListener('error', setStateFromEvent); - } - }; + }); }; /** @@ -83,7 +83,13 @@ export const useScript = ( ): ScriptStatus => { const [status, setStatus] = useState(src ? 'loading' : 'idle'); - useEffect(() => loadScript(src, { setStatus, location }), [src]); + useEffect(() => { + (async () => { + try { + await loadScript(src, { setStatus, location }); + } catch (e) {} // Handle errors where useScript is called. + })(); + }, [src]); return status; }; diff --git a/packages/manager/src/index.tsx b/packages/manager/src/index.tsx index 910ce721463..e7b97a925cc 100644 --- a/packages/manager/src/index.tsx +++ b/packages/manager/src/index.tsx @@ -32,7 +32,9 @@ const store = storeFactory(queryClient); setupInterceptors(store); const Lish = React.lazy(() => import('src/features/Lish')); -const Cancel = React.lazy(() => import('src/features/CancelLanding')); +const CancelLanding = React.lazy( + () => import('src/features/CancelLanding/CancelLanding') +); const LoginAsCustomerCallback = React.lazy( () => import('src/layouts/LoginAsCustomerCallback') ); @@ -79,7 +81,7 @@ const ContextWrapper = () => ( {/* A place to go that prevents the app from loading while refreshing OAuth tokens */} - + diff --git a/packages/manager/src/queries/linodes/actions.ts b/packages/manager/src/queries/linodes/actions.ts new file mode 100644 index 00000000000..f5a50bcc9d5 --- /dev/null +++ b/packages/manager/src/queries/linodes/actions.ts @@ -0,0 +1,12 @@ +import { APIError, startMutation } from '@linode/api-v4'; +import { useMutation, useQueryClient } from 'react-query'; +import { queryKey } from './linodes'; + +export const useStartLinodeMutationMutation = (id: number) => { + const queryClient = useQueryClient(); + return useMutation<{}, APIError[]>(() => startMutation(id), { + onSuccess() { + queryClient.invalidateQueries([queryKey]); + }, + }); +}; diff --git a/packages/manager/src/queries/linodes/disks.ts b/packages/manager/src/queries/linodes/disks.ts index 58ab186f6ac..9f954b1baf5 100644 --- a/packages/manager/src/queries/linodes/disks.ts +++ b/packages/manager/src/queries/linodes/disks.ts @@ -1,7 +1,12 @@ -import { APIError, Disk, getLinodeDisks } from '@linode/api-v4'; -import { useQuery } from 'react-query'; +import { useMutation, useQuery } from 'react-query'; import { queryKey } from './linodes'; import { getAll } from 'src/utilities/getAll'; +import { + APIError, + Disk, + changeLinodeDiskPassword, + getLinodeDisks, +} from '@linode/api-v4'; export const useAllLinodeDisksQuery = (id: number, enabled = true) => { return useQuery( @@ -15,3 +20,11 @@ const getAllLinodeDisks = (id: number) => getAll((params, filter) => getLinodeDisks(id, params, filter))().then( (data) => data.data ); + +export const useLinodeDiskChangePasswordMutation = ( + linodeId: number, + diskId: number +) => + useMutation(({ password }) => + changeLinodeDiskPassword(linodeId, diskId, password) + ); diff --git a/packages/manager/src/queries/linodes/linodes.ts b/packages/manager/src/queries/linodes/linodes.ts index d9898b402d6..cc7eb809379 100644 --- a/packages/manager/src/queries/linodes/linodes.ts +++ b/packages/manager/src/queries/linodes/linodes.ts @@ -25,6 +25,7 @@ import { linodeBoot, linodeReboot, linodeShutdown, + changeLinodePassword, } from '@linode/api-v4/lib/linodes'; export const queryKey = 'linodes'; @@ -161,3 +162,17 @@ export const useShutdownLinodeMutation = (id: number) => { }, }); }; + +export const useLinodeChangePasswordMutation = (id: number) => + useMutation<{}, APIError[], { root_pass: string }>(({ root_pass }) => + changeLinodePassword(id, root_pass) + ); + +export const useLinodeDeleteMutation = (id: number) => { + const queryClient = useQueryClient(); + return useMutation<{}, APIError[]>(() => deleteLinode(id), { + onSuccess() { + queryClient.invalidateQueries([queryKey]); + }, + }); +}; diff --git a/packages/manager/src/queries/linodes/networking.ts b/packages/manager/src/queries/linodes/networking.ts new file mode 100644 index 00000000000..190f3514648 --- /dev/null +++ b/packages/manager/src/queries/linodes/networking.ts @@ -0,0 +1,171 @@ +import { useMutation, useQuery, useQueryClient } from 'react-query'; +import { queryKey } from './linodes'; +import { getAll } from 'src/utilities/getAll'; +import { + APIError, + CreateIPv6RangePayload, + Filter, + IPAddress, + IPAllocationRequest, + IPAssignmentPayload, + IPRange, + IPRangeInformation, + IPSharingPayload, + LinodeIPsResponse, + Params, + allocateIPAddress, + assignAddresses, + createIPv6Range, + getIPs, + getIPv6RangeInfo, + getIPv6Ranges, + getLinodeIPs, + removeIPAddress, + removeIPv6Range, + shareAddresses, + updateIP, +} from '@linode/api-v4'; + +export const useLinodeIPsQuery = ( + linodeId: number, + enabled: boolean = true +) => { + return useQuery( + [queryKey, 'linode', linodeId, 'ips'], + () => getLinodeIPs(linodeId), + { enabled } + ); +}; + +export const useLinodeIPMutation = () => { + const queryClient = useQueryClient(); + return useMutation< + IPAddress, + APIError[], + { address: string; rdns?: string | null } + >(({ address, rdns }) => updateIP(address, rdns), { + onSuccess() { + queryClient.invalidateQueries([queryKey]); + }, + }); +}; + +export const useLinodeIPDeleteMutation = ( + linodeId: number, + address: string +) => { + const queryClient = useQueryClient(); + return useMutation<{}, APIError[]>( + () => removeIPAddress({ linodeID: linodeId, address }), + { + onSuccess() { + queryClient.invalidateQueries([queryKey]); + }, + } + ); +}; + +export const useLinodeRemoveRangeMutation = (range: string) => { + const queryClient = useQueryClient(); + return useMutation<{}, APIError[]>(() => removeIPv6Range({ range }), { + onSuccess() { + queryClient.invalidateQueries([queryKey]); + }, + }); +}; + +export const useLinodeShareIPMutation = () => { + const queryClient = useQueryClient(); + return useMutation<{}, APIError[], IPSharingPayload>(shareAddresses, { + onSuccess() { + queryClient.invalidateQueries([queryKey]); + }, + }); +}; + +export const useAssignAdressesMutation = () => { + const queryClient = useQueryClient(); + return useMutation<{}, APIError[], IPAssignmentPayload>(assignAddresses, { + onSuccess() { + queryClient.invalidateQueries([queryKey]); + }, + }); +}; + +export const useAllocateIPMutation = (linodeId: number) => { + const queryClient = useQueryClient(); + return useMutation<{}, APIError[], IPAllocationRequest>( + (data) => allocateIPAddress(linodeId, data), + { + onSuccess() { + queryClient.invalidateQueries([queryKey]); + }, + } + ); +}; + +export const useCreateIPv6RangeMutation = () => { + const queryClient = useQueryClient(); + return useMutation<{}, APIError[], CreateIPv6RangePayload>(createIPv6Range, { + onSuccess() { + queryClient.invalidateQueries([queryKey]); + }, + }); +}; + +export const useAllIPsQuery = ( + params?: Params, + filter?: Filter, + enabled: boolean = true +) => { + return useQuery( + [queryKey, 'ips', params, filter], + () => getAllIps(params, filter), + { enabled } + ); +}; + +export const useAllIPv6RangesQuery = ( + params?: Params, + filter?: Filter, + enabled: boolean = true +) => { + return useQuery( + [queryKey, 'ipv6', 'ranges', params, filter], + () => getAllIPv6Ranges(params, filter), + { enabled } + ); +}; + +export const useAllDetailedIPv6RangesQuery = ( + params?: Params, + filter?: Filter, + enabled: boolean = true +) => { + const { data: ranges } = useAllIPv6RangesQuery(params, filter, enabled); + return useQuery( + [queryKey, 'ipv6', 'ranges', 'details', params, filter], + async () => { + return await Promise.all( + (ranges ?? []).map((range) => getIPv6RangeInfo(range.range)) + ); + }, + { enabled: ranges !== undefined && enabled } + ); +}; + +const getAllIps = (passedParams: Params = {}, passedFilter: Filter = {}) => + getAll((params, filter) => + getIPs({ ...params, ...passedParams }, { ...filter, ...passedFilter }) + )().then((data) => data.data); + +const getAllIPv6Ranges = ( + passedParams: Params = {}, + passedFilter: Filter = {} +) => + getAll((params, filter) => + getIPv6Ranges( + { ...params, ...passedParams }, + { ...filter, ...passedFilter } + ) + )().then((data) => data.data); diff --git a/packages/manager/src/queries/networking.ts b/packages/manager/src/queries/networking.ts index 039d7966056..e69de29bb2d 100644 --- a/packages/manager/src/queries/networking.ts +++ b/packages/manager/src/queries/networking.ts @@ -1,16 +0,0 @@ -import { getIPv6Ranges, IPRange } from '@linode/api-v4/lib/networking'; -import { APIError, ResourcePage } from '@linode/api-v4/lib/types'; -import { useQuery } from 'react-query'; -import { queryPresets } from './base'; - -export const ipv6RangeQueryKey = 'networking-ipv6-ranges'; - -export const useIpv6RangesQuery = () => { - return useQuery, APIError[]>( - [ipv6RangeQueryKey], - () => getIPv6Ranges(), - { - ...queryPresets.oneTimeFetch, - } - ); -}; diff --git a/packages/manager/src/queries/support.ts b/packages/manager/src/queries/support.ts index e49cfa36f94..5b3cddb531e 100644 --- a/packages/manager/src/queries/support.ts +++ b/packages/manager/src/queries/support.ts @@ -1,17 +1,73 @@ -import { getTickets, SupportTicket } from '@linode/api-v4/lib/support'; import { + useInfiniteQuery, + useMutation, + useQuery, + useQueryClient, +} from 'react-query'; +import { EventWithStore } from 'src/events'; +import { + closeSupportTicket, + createReply, + getTicket, + getTicketReplies, + getTickets, + ReplyRequest, + SupportReply, + SupportTicket, +} from '@linode/api-v4/lib/support'; +import type { APIError, Filter, Params, ResourcePage, } from '@linode/api-v4/lib/types'; -import { useQuery } from 'react-query'; -const queryKey = `support`; +const queryKey = `tickets`; export const useSupportTicketsQuery = (params: Params, filter: Filter) => useQuery, APIError[]>( - [`${queryKey}-tickets`, params, filter], + [queryKey, 'paginated', params, filter], () => getTickets(params, filter), { keepPreviousData: true } ); + +export const useSupportTicketQuery = (id: number) => + useQuery([queryKey, 'ticket', id], () => + getTicket(id) + ); + +export const useInfiniteSupportTicketRepliesQuery = (id: number) => + useInfiniteQuery, APIError[]>( + [queryKey, 'ticket', id, 'replies'], + ({ pageParam }) => getTicketReplies(id, { page: pageParam, page_size: 25 }), + { + getNextPageParam: ({ page, pages }) => { + if (page === pages) { + return undefined; + } + return page + 1; + }, + } + ); + +export const useSupportTicketReplyMutation = () => { + const queryClient = useQueryClient(); + return useMutation(createReply, { + onSuccess() { + queryClient.invalidateQueries([queryKey]); + }, + }); +}; + +export const useSupportTicketCloseMutation = (id: number) => { + const queryClient = useQueryClient(); + return useMutation<{}, APIError[]>(() => closeSupportTicket(id), { + onSuccess() { + queryClient.invalidateQueries([queryKey]); + }, + }); +}; + +export const supportTicketEventHandler = ({ queryClient }: EventWithStore) => { + queryClient.invalidateQueries([queryKey]); +}; diff --git a/packages/manager/src/styles/keyframes.ts b/packages/manager/src/styles/keyframes.ts index 6540f5b4171..5551f922d28 100644 --- a/packages/manager/src/styles/keyframes.ts +++ b/packages/manager/src/styles/keyframes.ts @@ -11,9 +11,9 @@ export const rotate360 = keyframes` export const fadeIn = keyframes` from: { - opacity: 0, + opacity: 0; }, to: { - opacity: 1, + opacity: 1; } `; diff --git a/packages/manager/src/utilities/ga.ts b/packages/manager/src/utilities/ga.ts index 4b3a53d99eb..ade77bf3f2a 100644 --- a/packages/manager/src/utilities/ga.ts +++ b/packages/manager/src/utilities/ga.ts @@ -1,6 +1,5 @@ import { event } from 'react-ga'; import { GA_ID, ADOBE_ANALYTICS_URL } from 'src/constants'; -import { reportException } from 'src/exceptionReporting'; interface AnalyticsEvent { category: string; @@ -15,18 +14,13 @@ export const sendEvent = (eventPayload: AnalyticsEvent): void => { } // Send a Direct Call Rule if our environment is configured with an Adobe Launch script - try { + if ((window as any)._satellite) { (window as any)._satellite.track('custom event', { category: eventPayload.category, action: eventPayload.action, label: eventPayload.label, value: eventPayload.value, }); - } catch (error) { - reportException(error, { - message: - 'An error occurred when tracking a custom event. Adobe Launch script not loaded correctly; no analytics will be sent.', - }); } /** only send events if we have a GA ID */ @@ -396,10 +390,15 @@ export const sendObjectStorageDocsEvent = (action: string) => { }); }; -export const sendMarketplaceSearchEvent = (appCategory?: string) => { +type TypeOfSearch = 'Search Field' | 'Category Dropdown'; + +export const sendMarketplaceSearchEvent = ( + typeOfSearch: TypeOfSearch, + appCategory?: string +) => { sendEvent({ category: 'Marketplace Create Flow', - action: 'Category Dropdown', - label: appCategory, + action: `Click: ${typeOfSearch}`, + label: appCategory ?? 'Apps Search', }); }; diff --git a/packages/manager/src/utilities/getEventsActionLink.ts b/packages/manager/src/utilities/getEventsActionLink.ts index 57c2374d2b9..27e3cb28479 100644 --- a/packages/manager/src/utilities/getEventsActionLink.ts +++ b/packages/manager/src/utilities/getEventsActionLink.ts @@ -1,10 +1,5 @@ import { Entity, EventAction } from '@linode/api-v4/lib/account'; import { nonClickEvents } from 'src/constants'; -import { ApplicationStore } from 'src/store'; -import { - EntityType, - getEntityByIDFromStore, -} from 'src/utilities/getEntityByIDFromStore'; export const getEngineFromDatabaseEntityURL = (url: string) => { return url.match(/databases\/(\w*)\/instances/i)?.[1]; @@ -116,40 +111,35 @@ export const getLinkForEvent = ( } }; -export const getLinkTargets = ( - entity: Entity | null, - store: ApplicationStore -) => { +export const getLinkTargets = (entity: Entity | null) => { if (entity === null) { return null; } - const entityInStore = getEntityByIDFromStore( - entity.type as EntityType, - entity.id, - store - ); - /** - * If the entity doesn't exist in the store, don't link to it - * as it is probably an old ticket re: an entity that - * has since been deleted. - */ - if (!entityInStore) { - return null; - } - switch (entity.type) { case 'linode': return `/linodes/${entity.id}`; case 'domain': return `/domains/${entity.id}`; + case 'firewall': + return `/firewalls/${entity.id}`; + case 'stackscript': + return `/stackscripts/${entity.id}`; case 'nodebalancer': return `/nodebalancers/${entity.id}`; + case 'lkecluster': + return `/kubernetes/clusters/${entity.id}`; + case 'database': + return `/databases/${getEngineFromDatabaseEntityURL(entity.url)}/${ + entity.id + }/summary`; + case 'image': + return '/images'; case 'longview': return '/longview'; case 'volume': return '/volumes'; default: - return ''; + return null; } }; diff --git a/packages/manager/src/utilities/safeGetTabRender.tsx b/packages/manager/src/utilities/safeGetTabRender.tsx index 2456019e2e5..3338554f8ba 100644 --- a/packages/manager/src/utilities/safeGetTabRender.tsx +++ b/packages/manager/src/utilities/safeGetTabRender.tsx @@ -1,7 +1,7 @@ import { pathOr } from 'ramda'; import * as React from 'react'; -import ErrorState from 'src/components/ErrorState'; +import { ErrorState } from 'src/components/ErrorState/ErrorState'; import { reportException } from 'src/exceptionReporting'; import { CreateTypes } from 'src/store/linodeCreate/linodeCreate.actions'; diff --git a/packages/manager/src/utilities/theme.ts b/packages/manager/src/utilities/theme.ts index 407601751e0..b5bba81cd95 100644 --- a/packages/manager/src/utilities/theme.ts +++ b/packages/manager/src/utilities/theme.ts @@ -1,6 +1,6 @@ import { Theme } from '@mui/material/styles'; -import { ThemeName } from 'src/themeFactory'; -import { dark, light } from 'src/themes'; +import { dark, light } from 'src/foundations/themes'; +import type { ThemeName } from 'src/foundations/themes'; export type ThemeChoice = 'light' | 'dark' | 'system'; diff --git a/yarn.lock b/yarn.lock index e6b50071f2a..b26e392521d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2,6 +2,31 @@ # yarn lockfile v1 +"@actions/core@^1.10.0": + version "1.10.0" + resolved "https://registry.yarnpkg.com/@actions/core/-/core-1.10.0.tgz#44551c3c71163949a2f06e94d9ca2157a0cfac4f" + integrity sha512-2aZDDa3zrrZbP5ZYg159sNoLRb61nQ7awl5pSvIq5Qpj81vwDzdMRKzkWJGJuwVvWpvZKx7vspJALyvaaIQyug== + dependencies: + "@actions/http-client" "^2.0.1" + uuid "^8.3.2" + +"@actions/github@^5.1.1": + version "5.1.1" + resolved "https://registry.yarnpkg.com/@actions/github/-/github-5.1.1.tgz#40b9b9e1323a5efcf4ff7dadd33d8ea51651bbcb" + integrity sha512-Nk59rMDoJaV+mHCOJPXuvB1zIbomlKS0dmSIqPGxd0enAXBnOfn4VWF+CGtRCwXZG9Epa54tZA7VIRlJDS8A6g== + dependencies: + "@actions/http-client" "^2.0.1" + "@octokit/core" "^3.6.0" + "@octokit/plugin-paginate-rest" "^2.17.0" + "@octokit/plugin-rest-endpoint-methods" "^5.13.0" + +"@actions/http-client@^2.0.1": + version "2.1.0" + resolved "https://registry.yarnpkg.com/@actions/http-client/-/http-client-2.1.0.tgz#b6d8c3934727d6a50d10d19f00a711a964599a9f" + integrity sha512-BonhODnXr3amchh4qkmjPMUO8mFi/zLaaCeCAJZqch8iQqyDnVIkySjB38VHAC8IJ+bnlgfOqlhpyCUZHlQsqw== + dependencies: + tunnel "^0.0.6" + "@algolia/cache-browser-local-storage@4.14.3": version "4.14.3" resolved "https://registry.yarnpkg.com/@algolia/cache-browser-local-storage/-/cache-browser-local-storage-4.14.3.tgz#b9e0da012b2f124f785134a4d468ee0841b2399d" @@ -2301,6 +2326,193 @@ "@nodelib/fs.scandir" "2.1.5" fastq "^1.6.0" +"@octokit/auth-token@^2.4.4": + version "2.5.0" + resolved "https://registry.yarnpkg.com/@octokit/auth-token/-/auth-token-2.5.0.tgz#27c37ea26c205f28443402477ffd261311f21e36" + integrity sha512-r5FVUJCOLl19AxiuZD2VRZ/ORjp/4IN98Of6YJoJOkY75CIBuYfmiNHGrDwXr+aLGG55igl9QrxX3hbiXlLb+g== + dependencies: + "@octokit/types" "^6.0.3" + +"@octokit/auth-token@^3.0.0": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@octokit/auth-token/-/auth-token-3.0.3.tgz#ce7e48a3166731f26068d7a7a7996b5da58cbe0c" + integrity sha512-/aFM2M4HVDBT/jjDBa84sJniv1t9Gm/rLkalaz9htOm+L+8JMj1k9w0CkUdcxNyNxZPlTxKPVko+m1VlM58ZVA== + dependencies: + "@octokit/types" "^9.0.0" + +"@octokit/core@^3.6.0": + version "3.6.0" + resolved "https://registry.yarnpkg.com/@octokit/core/-/core-3.6.0.tgz#3376cb9f3008d9b3d110370d90e0a1fcd5fe6085" + integrity sha512-7RKRKuA4xTjMhY+eG3jthb3hlZCsOwg3rztWh75Xc+ShDWOfDDATWbeZpAHBNRpm4Tv9WgBMOy1zEJYXG6NJ7Q== + dependencies: + "@octokit/auth-token" "^2.4.4" + "@octokit/graphql" "^4.5.8" + "@octokit/request" "^5.6.3" + "@octokit/request-error" "^2.0.5" + "@octokit/types" "^6.0.3" + before-after-hook "^2.2.0" + universal-user-agent "^6.0.0" + +"@octokit/core@^4.1.0": + version "4.2.0" + resolved "https://registry.yarnpkg.com/@octokit/core/-/core-4.2.0.tgz#8c253ba9605aca605bc46187c34fcccae6a96648" + integrity sha512-AgvDRUg3COpR82P7PBdGZF/NNqGmtMq2NiPqeSsDIeCfYFOZ9gddqWNQHnFdEUf+YwOj4aZYmJnlPp7OXmDIDg== + dependencies: + "@octokit/auth-token" "^3.0.0" + "@octokit/graphql" "^5.0.0" + "@octokit/request" "^6.0.0" + "@octokit/request-error" "^3.0.0" + "@octokit/types" "^9.0.0" + before-after-hook "^2.2.0" + universal-user-agent "^6.0.0" + +"@octokit/endpoint@^6.0.1": + version "6.0.12" + resolved "https://registry.yarnpkg.com/@octokit/endpoint/-/endpoint-6.0.12.tgz#3b4d47a4b0e79b1027fb8d75d4221928b2d05658" + integrity sha512-lF3puPwkQWGfkMClXb4k/eUT/nZKQfxinRWJrdZaJO85Dqwo/G0yOC434Jr2ojwafWJMYqFGFa5ms4jJUgujdA== + dependencies: + "@octokit/types" "^6.0.3" + is-plain-object "^5.0.0" + universal-user-agent "^6.0.0" + +"@octokit/endpoint@^7.0.0": + version "7.0.5" + resolved "https://registry.yarnpkg.com/@octokit/endpoint/-/endpoint-7.0.5.tgz#2bb2a911c12c50f10014183f5d596ce30ac67dd1" + integrity sha512-LG4o4HMY1Xoaec87IqQ41TQ+glvIeTKqfjkCEmt5AIwDZJwQeVZFIEYXrYY6yLwK+pAScb9Gj4q+Nz2qSw1roA== + dependencies: + "@octokit/types" "^9.0.0" + is-plain-object "^5.0.0" + universal-user-agent "^6.0.0" + +"@octokit/graphql@^4.5.8": + version "4.8.0" + resolved "https://registry.yarnpkg.com/@octokit/graphql/-/graphql-4.8.0.tgz#664d9b11c0e12112cbf78e10f49a05959aa22cc3" + integrity sha512-0gv+qLSBLKF0z8TKaSKTsS39scVKF9dbMxJpj3U0vC7wjNWFuIpL/z76Qe2fiuCbDRcJSavkXsVtMS6/dtQQsg== + dependencies: + "@octokit/request" "^5.6.0" + "@octokit/types" "^6.0.3" + universal-user-agent "^6.0.0" + +"@octokit/graphql@^5.0.0": + version "5.0.5" + resolved "https://registry.yarnpkg.com/@octokit/graphql/-/graphql-5.0.5.tgz#a4cb3ea73f83b861893a6370ee82abb36e81afd2" + integrity sha512-Qwfvh3xdqKtIznjX9lz2D458r7dJPP8l6r4GQkIdWQouZwHQK0mVT88uwiU2bdTU2OtT1uOlKpRciUWldpG0yQ== + dependencies: + "@octokit/request" "^6.0.0" + "@octokit/types" "^9.0.0" + universal-user-agent "^6.0.0" + +"@octokit/openapi-types@^12.11.0": + version "12.11.0" + resolved "https://registry.yarnpkg.com/@octokit/openapi-types/-/openapi-types-12.11.0.tgz#da5638d64f2b919bca89ce6602d059f1b52d3ef0" + integrity sha512-VsXyi8peyRq9PqIz/tpqiL2w3w80OgVMwBHltTml3LmVvXiphgeqmY9mvBw9Wu7e0QWk/fqD37ux8yP5uVekyQ== + +"@octokit/openapi-types@^17.1.2": + version "17.1.2" + resolved "https://registry.yarnpkg.com/@octokit/openapi-types/-/openapi-types-17.1.2.tgz#b7bc1cc5d3581adac9dce197a21f0e5f2ceaabf1" + integrity sha512-OaS7Ol4Y+U50PbejfzQflGWRMxO04nYWO5ZBv6JerqMKE2WS/tI9VoVDDPXHBlRMGG2fOdKwtVGlFfc7AVIstw== + +"@octokit/plugin-paginate-rest@^2.17.0": + version "2.21.3" + resolved "https://registry.yarnpkg.com/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-2.21.3.tgz#7f12532797775640dbb8224da577da7dc210c87e" + integrity sha512-aCZTEf0y2h3OLbrgKkrfFdjRL6eSOo8komneVQJnYecAxIej7Bafor2xhuDJOIFau4pk0i/P28/XgtbyPF0ZHw== + dependencies: + "@octokit/types" "^6.40.0" + +"@octokit/plugin-paginate-rest@^6.1.0": + version "6.1.0" + resolved "https://registry.yarnpkg.com/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-6.1.0.tgz#3522ef5c2712436332655085b197eafe4ac7afc4" + integrity sha512-5T4iXjJdYCVA1rdWS1C+uZV9AvtZY9QgTG74kFiSFVj94dZXowyi/YK8f4SGjZaL69jZthGlBaDKRdCMCF9log== + dependencies: + "@octokit/types" "^9.2.2" + +"@octokit/plugin-request-log@^1.0.4": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@octokit/plugin-request-log/-/plugin-request-log-1.0.4.tgz#5e50ed7083a613816b1e4a28aeec5fb7f1462e85" + integrity sha512-mLUsMkgP7K/cnFEw07kWqXGF5LKrOkD+lhCrKvPHXWDywAwuDUeDwWBpc69XK3pNX0uKiVt8g5z96PJ6z9xCFA== + +"@octokit/plugin-rest-endpoint-methods@^5.13.0": + version "5.16.2" + resolved "https://registry.yarnpkg.com/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-5.16.2.tgz#7ee8bf586df97dd6868cf68f641354e908c25342" + integrity sha512-8QFz29Fg5jDuTPXVtey05BLm7OB+M8fnvE64RNegzX7U+5NUXcOcnpTIK0YfSHBg8gYd0oxIq3IZTe9SfPZiRw== + dependencies: + "@octokit/types" "^6.39.0" + deprecation "^2.3.1" + +"@octokit/plugin-rest-endpoint-methods@^7.1.0": + version "7.1.0" + resolved "https://registry.yarnpkg.com/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-7.1.0.tgz#7f3f4fac10bf72f8c5cd0c343252cd5f73dbf2d8" + integrity sha512-SWwz/hc47GaKJR6BlJI4WIVRodbAFRvrR0QRPSoPMs7krb7anYPML3psg+ThEz/kcwOdSNh/oA8qThi/Wvs4Fw== + dependencies: + "@octokit/types" "^9.2.2" + deprecation "^2.3.1" + +"@octokit/request-error@^2.0.5", "@octokit/request-error@^2.1.0": + version "2.1.0" + resolved "https://registry.yarnpkg.com/@octokit/request-error/-/request-error-2.1.0.tgz#9e150357831bfc788d13a4fd4b1913d60c74d677" + integrity sha512-1VIvgXxs9WHSjicsRwq8PlR2LR2x6DwsJAaFgzdi0JfJoGSO8mYI/cHJQ+9FbN21aa+DrgNLnwObmyeSC8Rmpg== + dependencies: + "@octokit/types" "^6.0.3" + deprecation "^2.0.0" + once "^1.4.0" + +"@octokit/request-error@^3.0.0": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@octokit/request-error/-/request-error-3.0.3.tgz#ef3dd08b8e964e53e55d471acfe00baa892b9c69" + integrity sha512-crqw3V5Iy2uOU5Np+8M/YexTlT8zxCfI+qu+LxUB7SZpje4Qmx3mub5DfEKSO8Ylyk0aogi6TYdf6kxzh2BguQ== + dependencies: + "@octokit/types" "^9.0.0" + deprecation "^2.0.0" + once "^1.4.0" + +"@octokit/request@^5.6.0", "@octokit/request@^5.6.3": + version "5.6.3" + resolved "https://registry.yarnpkg.com/@octokit/request/-/request-5.6.3.tgz#19a022515a5bba965ac06c9d1334514eb50c48b0" + integrity sha512-bFJl0I1KVc9jYTe9tdGGpAMPy32dLBXXo1dS/YwSCTL/2nd9XeHsY616RE3HPXDVk+a+dBuzyz5YdlXwcDTr2A== + dependencies: + "@octokit/endpoint" "^6.0.1" + "@octokit/request-error" "^2.1.0" + "@octokit/types" "^6.16.1" + is-plain-object "^5.0.0" + node-fetch "^2.6.7" + universal-user-agent "^6.0.0" + +"@octokit/request@^6.0.0": + version "6.2.3" + resolved "https://registry.yarnpkg.com/@octokit/request/-/request-6.2.3.tgz#76d5d6d44da5c8d406620a4c285d280ae310bdb4" + integrity sha512-TNAodj5yNzrrZ/VxP+H5HiYaZep0H3GU0O7PaF+fhDrt8FPrnkei9Aal/txsN/1P7V3CPiThG0tIvpPDYUsyAA== + dependencies: + "@octokit/endpoint" "^7.0.0" + "@octokit/request-error" "^3.0.0" + "@octokit/types" "^9.0.0" + is-plain-object "^5.0.0" + node-fetch "^2.6.7" + universal-user-agent "^6.0.0" + +"@octokit/rest@^19.0.8": + version "19.0.8" + resolved "https://registry.yarnpkg.com/@octokit/rest/-/rest-19.0.8.tgz#db1e67cb66018859fde2c6c3a49eb5c55dc04d92" + integrity sha512-/PKrzqn+zDzXKwBMwLI2IKrvk8yv8cedJOdcmxrjR3gmu6UIzURhP5oQj+4qkn7+uQi1gg7QqV4SqlaQ1HYW1Q== + dependencies: + "@octokit/core" "^4.1.0" + "@octokit/plugin-paginate-rest" "^6.1.0" + "@octokit/plugin-request-log" "^1.0.4" + "@octokit/plugin-rest-endpoint-methods" "^7.1.0" + +"@octokit/types@^6.0.3", "@octokit/types@^6.16.1", "@octokit/types@^6.39.0", "@octokit/types@^6.40.0": + version "6.41.0" + resolved "https://registry.yarnpkg.com/@octokit/types/-/types-6.41.0.tgz#e58ef78d78596d2fb7df9c6259802464b5f84a04" + integrity sha512-eJ2jbzjdijiL3B4PrSQaSjuF2sPEQPVCPzBvTHJD9Nz+9dw2SGH4K4xeQJ77YfTq5bRQ+bD8wT11JbeDPmxmGg== + dependencies: + "@octokit/openapi-types" "^12.11.0" + +"@octokit/types@^9.0.0", "@octokit/types@^9.2.2": + version "9.2.2" + resolved "https://registry.yarnpkg.com/@octokit/types/-/types-9.2.2.tgz#d111d33928f288f48083bfe49d8a9a5945e67db1" + integrity sha512-9BjDxjgQIvCjNWZsbqyH5QC2Yni16oaE6xL+8SUBMzcYPF4TGQBXGA97Cl3KceK9mwiNMb1mOYCz6FbCCLEL+g== + dependencies: + "@octokit/openapi-types" "^17.1.2" + "@open-draft/until@^1.0.3": version "1.0.3" resolved "https://registry.yarnpkg.com/@open-draft/until/-/until-1.0.3.tgz#db9cc719191a62e7d9200f6e7bab21c5b848adca" @@ -5199,6 +5411,11 @@ bcrypt-pbkdf@^1.0.0: dependencies: tweetnacl "^0.14.3" +before-after-hook@^2.2.0: + version "2.2.3" + resolved "https://registry.yarnpkg.com/before-after-hook/-/before-after-hook-2.2.3.tgz#c51e809c81a4e354084422b9b26bad88249c517c" + integrity sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ== + better-opn@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/better-opn/-/better-opn-2.1.1.tgz#94a55b4695dc79288f31d7d0e5f658320759f7c6" @@ -5211,11 +5428,6 @@ big-integer@^1.6.16, big-integer@^1.6.44: resolved "https://registry.yarnpkg.com/big-integer/-/big-integer-1.6.51.tgz#0df92a5d9880560d3ff2d5fd20245c889d130686" integrity sha512-GPEid2Y9QU1Exl1rpO9B2IPJGHPSupF5GnVIP0blYvNOMer2bTvSWs1jGOUg04hTmu67nmLsQ9TBo1puaotBHg== -big.js@^5.2.2: - version "5.2.2" - resolved "https://registry.yarnpkg.com/big.js/-/big.js-5.2.2.tgz#65f0af382f578bcdc742bd9c281e9cb2d7768328" - integrity sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ== - binary-extensions@^2.0.0: version "2.2.0" resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d" @@ -5306,6 +5518,13 @@ brace-expansion@^1.1.7: balanced-match "^1.0.0" concat-map "0.0.1" +brace-expansion@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.1.tgz#1edc459e0f0c548486ecf9fc99f2221364b9a0ae" + integrity sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA== + dependencies: + balanced-match "^1.0.0" + braces@^2.3.1: version "2.3.2" resolved "https://registry.yarnpkg.com/braces/-/braces-2.3.2.tgz#5979fd3f14cd531565e5fa2df1abfff1dfaee729" @@ -5587,7 +5806,7 @@ chalk@5.0.1: resolved "https://registry.yarnpkg.com/chalk/-/chalk-5.0.1.tgz#ca57d71e82bb534a296df63bbacc4a1c22b2a4b6" integrity sha512-Fo07WOYGqMfCWHOzSXOt2CxDbC6skS/jO9ynEcmpANMoPrD+W1r1K6Vx7iNm+AQmETU1Xr2t+n8nzkV9t6xh3w== -chalk@5.2.0, chalk@^5.0.1: +chalk@5.2.0, chalk@^5.0.1, chalk@^5.2.0: version "5.2.0" resolved "https://registry.yarnpkg.com/chalk/-/chalk-5.2.0.tgz#249623b7d66869c673699fb66d65723e54dfcfb3" integrity sha512-ree3Gqw/nazQAPuJJEy+avdl7QfZMcUvmHIKgEZkGL+xOBzRvup5Hxo6LHuMceSxOabuJLJm5Yp/92R9eMmMvA== @@ -6557,6 +6776,11 @@ depd@2.0.0: resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== +deprecation@^2.0.0, deprecation@^2.3.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/deprecation/-/deprecation-2.3.1.tgz#6368cbdb40abf3373b525ac87e4a260c3a700919" + integrity sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ== + dequal@^2.0.0, dequal@^2.0.2: version "2.0.3" resolved "https://registry.yarnpkg.com/dequal/-/dequal-2.0.3.tgz#2644214f1997d39ed0ee0ece72335490a7ac67be" @@ -6800,16 +7024,18 @@ emoji-regex@^9.2.2: resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-9.2.2.tgz#840c8803b0d8047f4ff0cf963176b32d4ef3ed72" integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg== -emojis-list@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-3.0.0.tgz#5570662046ad29e2e916e71aae260abdff4f6a78" - integrity sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q== - encodeurl@~1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" integrity sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w== +encoding@^0.1.11: + version "0.1.13" + resolved "https://registry.yarnpkg.com/encoding/-/encoding-0.1.13.tgz#56574afdd791f54a8e9b2785c0582a2d26210fa9" + integrity sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A== + dependencies: + iconv-lite "^0.6.2" + end-of-stream@^1.0.0, end-of-stream@^1.1.0, end-of-stream@^1.4.1: version "1.4.4" resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0" @@ -8265,13 +8491,20 @@ github-slugger@^1.0.0: resolved "https://registry.yarnpkg.com/github-slugger/-/github-slugger-1.5.0.tgz#17891bbc73232051474d68bd867a34625c955f7d" integrity sha512-wIh+gKBI9Nshz2o46B0B3f5k/W+WI9ZAv6y5Dn5WJ5SK1t0TnDimB4WE5rmTD05ZAIn8HALCZVmCsvj0w0v0lw== -glob-parent@^5.0.0, glob-parent@^5.1.2, glob-parent@^6.0.2, glob-parent@~5.1.2: +glob-parent@^5.0.0, glob-parent@^5.1.2, glob-parent@~5.1.2: version "5.1.2" resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== dependencies: is-glob "^4.0.1" +glob-parent@^6.0.2: + version "6.0.2" + resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-6.0.2.tgz#6d237d99083950c79290f24c7642a3de9a28f9e3" + integrity sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A== + dependencies: + is-glob "^4.0.3" + glob-promise@^4.2.0: version "4.2.2" resolved "https://registry.yarnpkg.com/glob-promise/-/glob-promise-4.2.2.tgz#15f44bcba0e14219cd93af36da6bb905ff007877" @@ -8415,7 +8648,7 @@ gunzip-maybe@^1.4.2: pumpify "^1.3.3" through2 "^2.0.3" -handlebars@^4.4.3, handlebars@^4.7.7: +handlebars@^4.7.7: version "4.7.7" resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.7.7.tgz#9ce33416aad02dbd6c8fafa8240d5d98004945a1" integrity sha512-aAcXm5OAfE/8IXkcZvCepKU3VzW1/39Fb5ZuqMtgI/hT8X2YgoMvBY5dLhq/cpOvw7Lk1nK/UF71aLG/ZnVYRA== @@ -8601,12 +8834,10 @@ hoist-non-react-statics@^3.1.0, hoist-non-react-statics@^3.3.0, hoist-non-react- dependencies: react-is "^16.7.0" -hosted-git-info@^2.1.4, hosted-git-info@^5.0.0: - version "5.2.1" - resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-5.2.1.tgz#0ba1c97178ef91f3ab30842ae63d6a272341156f" - integrity sha512-xIcQYMnhcx2Nr4JTjsFmwwnr9vldugPy9uVm0o87bjqqWMv9GaqsTeT+i99wTl0mk1uLxJtHxLb8kymqTENQsw== - dependencies: - lru-cache "^7.5.1" +hosted-git-info@^2.1.4: + version "2.8.9" + resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.9.tgz#dffc0bf9a21c02209090f2aa69429e1414daf3f9" + integrity sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw== html-element-map@^1.2.0: version "1.3.1" @@ -8750,6 +8981,13 @@ iconv-lite@0.4.24, iconv-lite@^0.4.24: dependencies: safer-buffer ">= 2.1.2 < 3" +iconv-lite@^0.6.2: + version "0.6.3" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501" + integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw== + dependencies: + safer-buffer ">= 2.1.2 < 3.0.0" + ieee754@^1.1.13: version "1.2.1" resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" @@ -8973,16 +9211,16 @@ is-boolean-object@^1.0.1, is-boolean-object@^1.1.0: call-bind "^1.0.2" has-tostringtag "^1.0.0" +is-buffer@^1.1.5, is-buffer@~1.1.6: + version "1.1.6" + resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be" + integrity sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w== + is-buffer@^2.0.0, is-buffer@^2.0.5: version "2.0.5" resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-2.0.5.tgz#ebc252e400d22ff8d77fa09888821a24a658c191" integrity sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ== -is-buffer@~1.1.6: - version "1.1.6" - resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be" - integrity sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w== - is-callable@^1.1.3, is-callable@^1.1.4, is-callable@^1.1.5, is-callable@^1.2.7: version "1.2.7" resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.7.tgz#3bc2a85ea742d9e36205dcacdd72ca1fdc51b055" @@ -9119,7 +9357,7 @@ is-generator-function@^1.0.7: dependencies: has-tostringtag "^1.0.0" -is-glob@^4.0.0, is-glob@^4.0.1, is-glob@~4.0.1: +is-glob@^4.0.0, is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1: version "4.0.3" resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== @@ -9250,7 +9488,7 @@ is-shared-array-buffer@^1.0.2: dependencies: call-bind "^1.0.2" -is-stream@^1.1.0: +is-stream@^1.0.1, is-stream@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44" integrity sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ== @@ -10104,7 +10342,7 @@ json-stringify-safe@~5.0.1: resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb" integrity sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA== -json5@^2.1.2, json5@^2.2.2: +json5@^2.2.2: version "2.2.3" resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283" integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== @@ -10231,7 +10469,26 @@ jss@10.10.0, jss@^10.10.0: array-includes "^3.1.5" object.assign "^4.1.3" -kind-of@^3.0.2, kind-of@^3.0.3, kind-of@^3.2.0, kind-of@^4.0.0, kind-of@^5.0.0, kind-of@^6.0.0, kind-of@^6.0.2, kind-of@^6.0.3: +kind-of@^3.0.2, kind-of@^3.0.3, kind-of@^3.2.0: + version "3.2.2" + resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.2.2.tgz#31ea21a734bab9bbb0f32466d893aea51e4a3c64" + integrity sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ== + dependencies: + is-buffer "^1.1.5" + +kind-of@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-4.0.0.tgz#20813df3d712928b207378691a45066fae72dd57" + integrity sha512-24XsCxmEbRwEDbz/qz3stgin8TTzZ1ESR56OMCN0ujYg+vRutNSiOj9bHH9u85DKgXguraugV5sFuvbD4FW/hw== + dependencies: + is-buffer "^1.1.5" + +kind-of@^5.0.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-5.1.0.tgz#729c91e2d857b7a419a1f9aa65685c4c33f5845d" + integrity sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw== + +kind-of@^6.0.0, kind-of@^6.0.2: version "6.0.3" resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd" integrity sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw== @@ -10427,15 +10684,6 @@ load-tsconfig@^0.2.3: resolved "https://registry.yarnpkg.com/load-tsconfig/-/load-tsconfig-0.2.3.tgz#08af3e7744943caab0c75f8af7f1703639c3ef1f" integrity sha512-iyT2MXws+dc2Wi6o3grCFtGXpeMvHmJqS27sMPGtV2eUu4PeFnG+33I8BlFK1t1NWMjOpcx9bridn5yxLDX2gQ== -loader-utils@^2.0.4: - version "2.0.4" - resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-2.0.4.tgz#8b5cb38b5c34a9a018ee1fc0e6a066d1dfcc528c" - integrity sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw== - dependencies: - big.js "^5.2.2" - emojis-list "^3.0.0" - json5 "^2.1.2" - locate-path@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-3.0.0.tgz#dbec3b3ab759758071b58fe59fc41871af21400e" @@ -10567,11 +10815,6 @@ lru-cache@^6.0.0: dependencies: yallist "^4.0.0" -lru-cache@^7.5.1: - version "7.18.3" - resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-7.18.3.tgz#f793896e0fd0e954a59dfdd82f0773808df6aa89" - integrity sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA== - luxon@^3.2.1: version "3.2.1" resolved "https://registry.yarnpkg.com/luxon/-/luxon-3.2.1.tgz#14f1af209188ad61212578ea7e3d518d18cee45f" @@ -11236,14 +11479,21 @@ mini-create-react-context@^0.3.0: "@babel/runtime" "^7.12.1" tiny-warning "^1.0.3" -minimatch@3.1.2, minimatch@^3.0.2, minimatch@^3.0.4, minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2, minimatch@^5.0.1: +minimatch@3.1.2, minimatch@^3.0.2, minimatch@^3.0.4, minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2: version "3.1.2" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== dependencies: brace-expansion "^1.1.7" -minimist@^1.1.1, minimist@^1.2.0, minimist@^1.2.3, minimist@^1.2.5, minimist@^1.2.6, minimist@^1.2.8: +minimatch@^5.0.1: + version "5.1.6" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-5.1.6.tgz#1cfcb8cf5522ea69952cd2af95ae09477f122a96" + integrity sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g== + dependencies: + brace-expansion "^2.0.1" + +minimist@^1.1.1, minimist@^1.2.0, minimist@^1.2.5, minimist@^1.2.6, minimist@^1.2.8: version "1.2.8" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== @@ -11435,7 +11685,15 @@ node-fetch-native@^1.0.2: resolved "https://registry.yarnpkg.com/node-fetch-native/-/node-fetch-native-1.0.2.tgz#de3651399fda89a1a7c0bf6e7c4e9c239e8d0697" integrity sha512-KIkvH1jl6b3O7es/0ShyCgWLcfXxlBrLBbP3rOr23WArC66IMcU4DeZEeYEOwnopYhawLTn7/y+YtmASe8DFVQ== -node-fetch@^1.0.1, node-fetch@^2.6.0, node-fetch@^2.6.1, node-fetch@^2.6.7: +node-fetch@^1.0.1: + version "1.7.3" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-1.7.3.tgz#980f6f72d85211a5347c6b2bc18c5b84c3eb47ef" + integrity sha512-NhZ4CsKx7cYm2vSrBAr2PvFOe6sWDf0UYLRqA6svUYg7+/TSfVAu49jYC4BvQ4Sms9SZgdqGBgroqfDhJdTyKQ== + dependencies: + encoding "^0.1.11" + is-stream "^1.0.1" + +node-fetch@^2.6.0, node-fetch@^2.6.1, node-fetch@^2.6.7: version "2.6.9" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.9.tgz#7c7f744b5cc6eb5fd404e0c7a9fec630a55657e6" integrity sha512-DJm/CJkZkRjKKj4Zi4BsKVZh3ValV5IR5s7LVZnW+6YMh0W1BfNA8XSs6DLMGYlId5F3KnA70uu2qepcR08Qqg== @@ -12274,7 +12532,7 @@ prop-types@^15.0.0, prop-types@^15.5.8, prop-types@^15.6.0, prop-types@^15.6.1, object-assign "^4.1.1" react-is "^16.13.1" -property-expr@^2.0.3, property-expr@^2.0.4: +property-expr@^2.0.4: version "2.0.5" resolved "https://registry.yarnpkg.com/property-expr/-/property-expr-2.0.5.tgz#278bdb15308ae16af3e3b9640024524f4dc02cb4" integrity sha512-IJUkICM5dP5znhCckHSv30Q4b5/JA5enCtkRHYaOVOAocnH/1BQEYTC5NMfT3AVl/iXKdr3aqQbQn9DxyWknwA== @@ -13287,7 +13545,7 @@ safe-regex@^1.1.0: dependencies: ret "~0.1.10" -"safer-buffer@>= 2.1.2 < 3", safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0: +"safer-buffer@>= 2.1.2 < 3", "safer-buffer@>= 2.1.2 < 3.0.0", safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0: version "2.1.2" resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== @@ -14466,6 +14724,11 @@ tunnel-agent@^0.6.0: dependencies: safe-buffer "^5.0.1" +tunnel@^0.0.6: + version "0.0.6" + resolved "https://registry.yarnpkg.com/tunnel/-/tunnel-0.0.6.tgz#72f1314b34a5b192db012324df2cc587ca47f92c" + integrity sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg== + tweetnacl@^0.14.3, tweetnacl@~0.14.0: version "0.14.5" resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64" @@ -14564,7 +14827,7 @@ typescript@^4.9.5: resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.5.tgz#095979f9bcc0d09da324d58d03ce8f8374cbe65a" integrity sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g== -ua-parser-js@^0.7.30, ua-parser-js@^0.7.33: +ua-parser-js@^0.7.30: version "0.7.35" resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.35.tgz#8bda4827be4f0b1dda91699a29499575a1f1d307" integrity sha512-veRf7dawaj9xaWEu9HoTVn5Pggtc/qj+kqTOFvNiN1l0YdxwC1kvel57UCjThjGa3BHBihE8/UJAHI+uQHmd/g== @@ -14725,6 +14988,11 @@ unist-util-visit@^4.0.0: unist-util-is "^5.0.0" unist-util-visit-parents "^5.1.1" +universal-user-agent@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/universal-user-agent/-/universal-user-agent-6.0.0.tgz#3381f8503b251c0d9cd21bc1de939ec9df5480ee" + integrity sha512-isyNax3wXoKaulPDZWHQqbmIx1k2tb9fb3GGDBRxCscfYV2Ch7WxPArBsFEG8s/safwXTT7H4QGhaIkTp9447w== + universalify@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.2.0.tgz#6451760566fa857534745ab1dde952d1b1761be0" @@ -15280,11 +15548,16 @@ xterm@^4.2.0: resolved "https://registry.yarnpkg.com/xterm/-/xterm-4.19.0.tgz#c0f9d09cd61de1d658f43ca75f992197add9ef6d" integrity sha512-c3Cp4eOVsYY5Q839dR5IejghRPpxciGmLWWaP9g+ppfMeBChMeLa1DCA+pmX/jyDZ+zxFOmlJL/82qVdayVoGQ== -"y18n@^3.2.1 || ^4.0.0", y18n@^4.0.0, y18n@^5.0.5: +"y18n@^3.2.1 || ^4.0.0", y18n@^4.0.0: version "4.0.3" resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.3.tgz#b5f259c82cd6e336921efd7bfd8bf560de9eeedf" integrity sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ== +y18n@^5.0.5: + version "5.0.8" + resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55" + integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA== + yallist@^3.0.2: version "3.1.1" resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd" @@ -15305,7 +15578,15 @@ yaml@^2.2.1: resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.2.1.tgz#3014bf0482dcd15147aa8e56109ce8632cd60ce4" integrity sha512-e0WHiYql7+9wr4cWMx3TVQrNwejKaEe7/rHNmQmqRjazfOP5W8PB6Jpebb5o6fIapbz9o9+2ipcaTM2ZwDI6lw== -yargs-parser@^11.1.1, yargs-parser@^18.1.2, yargs-parser@^18.1.3, yargs-parser@^20.2.2, yargs-parser@^20.2.9: +yargs-parser@^11.1.1: + version "11.1.1" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-11.1.1.tgz#879a0865973bca9f6bab5cbdf3b1c67ec7d3bcf4" + integrity sha512-C6kB/WJDiaxONLJQnF8ccx9SEeoTTLek8RVbaOIsrAUS8VrBEXfmeSnCZxygc+XC2sNMBIwOOnfcxiynjHsVSQ== + dependencies: + camelcase "^5.0.0" + decamelize "^1.2.0" + +yargs-parser@^18.1.2: version "18.1.3" resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-18.1.3.tgz#be68c4975c6b2abf469236b0c870362fab09a7b0" integrity sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ== @@ -15313,6 +15594,11 @@ yargs-parser@^11.1.1, yargs-parser@^18.1.2, yargs-parser@^18.1.3, yargs-parser@^ camelcase "^5.0.0" decamelize "^1.2.0" +yargs-parser@^20.2.2, yargs-parser@^20.2.9: + version "20.2.9" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-20.2.9.tgz#2eb7dc3b0289718fc295f362753845c41a0c94ee" + integrity sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w== + yargs@^12.0.5: version "12.0.5" resolved "https://registry.yarnpkg.com/yargs/-/yargs-12.0.5.tgz#05f5997b609647b64f66b81e3b4b10a368e7ad13"