diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 1d3cf06f7aa..e4197539522 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -17,25 +17,26 @@ Feel free to open an issue to report a bug or request a feature. 5. Commit message format standard: `: [JIRA-ticket-number] - ` **Commit Types:** - `feat`: New feature for the user (not a part of the code, or ci, ...). - `fix`: Bugfix for the user (not a fix to build something, ...). - `change`: Modifying an existing visual UI instance. Such as a component or a feature. - `refactor`: Restructuring existing code without changing its external behavior or visual UI. Typically to improve readability, maintainability, and performance. - `test`: New tests or changes to existing tests. Does not change the production code. - `upcoming`: A new feature that is in progress, not visible to users yet, and usually behind a feature flag. + - `feat`: New feature for the user (not a part of the code, or ci, ...). + - `fix`: Bugfix for the user (not a fix to build something, ...). + - `change`: Modifying an existing visual UI instance. Such as a component or a feature. + - `refactor`: Restructuring existing code without changing its external behavior or visual UI. Typically to improve readability, maintainability, and performance. + - `test`: New tests or changes to existing tests. Does not change the production code. + - `upcoming`: A new feature that is in progress, not visible to users yet, and usually behind a feature flag. **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), + - 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`from the root, choose the package to create a changeset for, and provide a description for the change. You can either have it committed automatically or do it manually if you need to edit it. - - A changeset is optional, it merely depends if it falls in one of the following categories: + - A changeset is optional, but should be included if the PR falls in one of the following categories:
`Added`, `Fixed`, `Changed`, `Removed`, `Tech Stories`, `Tests`, `Upcoming Features` + - Select the changeset category that matches the commit type in your PR title. (Where this isn't a 1:1 match: generally, a `feat` commit type falls under an `Added` change and `refactor` falls under `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/packages/api-v4/.changeset/pr-10505-added-1716400052332.md b/packages/api-v4/.changeset/pr-10505-added-1716400052332.md new file mode 100644 index 00000000000..c9a609f8b06 --- /dev/null +++ b/packages/api-v4/.changeset/pr-10505-added-1716400052332.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Added +--- + +New endpoint for LKE HA types used in pricing ([#10505](https://github.com/linode/manager/pull/10505)) diff --git a/packages/api-v4/src/images/types.ts b/packages/api-v4/src/images/types.ts index 48be8aff12a..e25fb28f9a2 100644 --- a/packages/api-v4/src/images/types.ts +++ b/packages/api-v4/src/images/types.ts @@ -4,7 +4,7 @@ export type ImageStatus = | 'deleted' | 'pending_upload'; -type ImageCapabilities = 'cloud-init' | 'distributed-images'; +export type ImageCapabilities = 'cloud-init' | 'distributed-images'; type ImageType = 'manual' | 'automatic'; diff --git a/packages/api-v4/src/kubernetes/kubernetes.ts b/packages/api-v4/src/kubernetes/kubernetes.ts index 8d62051c137..b80c011d4cf 100644 --- a/packages/api-v4/src/kubernetes/kubernetes.ts +++ b/packages/api-v4/src/kubernetes/kubernetes.ts @@ -7,8 +7,8 @@ import Request, { setURL, setXFilter, } from '../request'; -import { Filter, Params, ResourcePage as Page } from '../types'; -import { +import type { Filter, Params, ResourcePage as Page, PriceType } from '../types'; +import type { CreateKubeClusterPayload, KubeConfigResponse, KubernetesCluster, @@ -180,3 +180,15 @@ export const recycleClusterNodes = (clusterID: number) => setMethod('POST'), setURL(`${API_ROOT}/lke/clusters/${encodeURIComponent(clusterID)}/recycle`) ); + +/** + * getKubernetesTypes + * + * Returns a paginated list of available Kubernetes types; used for dynamic pricing. + */ +export const getKubernetesTypes = (params?: Params) => + Request>( + setURL(`${API_ROOT}/lke/types`), + setMethod('GET'), + setParams(params) + ); diff --git a/packages/manager/.changeset/pr-10022-added-1703703204947.md b/packages/manager/.changeset/pr-10022-added-1703703204947.md new file mode 100644 index 00000000000..1a6769e2894 --- /dev/null +++ b/packages/manager/.changeset/pr-10022-added-1703703204947.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Added +--- + +Added Design Tokens (CDS 2.0) ([#10022](https://github.com/linode/manager/pull/10022)) diff --git a/packages/manager/.changeset/pr-10505-changed-1716400104404.md b/packages/manager/.changeset/pr-10505-changed-1716400104404.md new file mode 100644 index 00000000000..e5c959f550c --- /dev/null +++ b/packages/manager/.changeset/pr-10505-changed-1716400104404.md @@ -0,0 +1,5 @@ +--- +'@linode/manager': Changed +--- + +Use dynamic HA pricing with `lke/types` endpoint ([#10505](https://github.com/linode/manager/pull/10505)) diff --git a/packages/manager/.changeset/pr-10545-upcoming-features-1718402597165.md b/packages/manager/.changeset/pr-10545-upcoming-features-1718402597165.md new file mode 100644 index 00000000000..3651a31e1e8 --- /dev/null +++ b/packages/manager/.changeset/pr-10545-upcoming-features-1718402597165.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Update Images Landing table ([#10545](https://github.com/linode/manager/pull/10545)) diff --git a/packages/manager/.changeset/pr-10579-tests-1718297804893.md b/packages/manager/.changeset/pr-10579-tests-1718297804893.md new file mode 100644 index 00000000000..ecd89ebaf74 --- /dev/null +++ b/packages/manager/.changeset/pr-10579-tests-1718297804893.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Refactor Cypress Longview test to use mock API data/events ([#10579](https://github.com/linode/manager/pull/10579)) diff --git a/packages/manager/.changeset/pr-10583-fixed-1718375225734.md b/packages/manager/.changeset/pr-10583-fixed-1718375225734.md new file mode 100644 index 00000000000..9692eb933c3 --- /dev/null +++ b/packages/manager/.changeset/pr-10583-fixed-1718375225734.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Users must be an unrestricted User in order to add or modify tags on Linodes ([#10583](https://github.com/linode/manager/pull/10583)) diff --git a/packages/manager/.changeset/pr-10584-fixed-1718376872460.md b/packages/manager/.changeset/pr-10584-fixed-1718376872460.md new file mode 100644 index 00000000000..586c1a96686 --- /dev/null +++ b/packages/manager/.changeset/pr-10584-fixed-1718376872460.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Potential runtime issue with conditional hook ([#10584](https://github.com/linode/manager/pull/10584)) diff --git a/packages/manager/.changeset/pr-10587-fixed-1718643059797.md b/packages/manager/.changeset/pr-10587-fixed-1718643059797.md new file mode 100644 index 00000000000..1d10211f718 --- /dev/null +++ b/packages/manager/.changeset/pr-10587-fixed-1718643059797.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +CONTRIBUTING doc page commit type list markup ([#10587](https://github.com/linode/manager/pull/10587)) diff --git a/packages/manager/.changeset/pr-10588-fixed-1718731923572.md b/packages/manager/.changeset/pr-10588-fixed-1718731923572.md new file mode 100644 index 00000000000..e12343bfa68 --- /dev/null +++ b/packages/manager/.changeset/pr-10588-fixed-1718731923572.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +React Query Events `seen` behavior and other optimizations ([#10588](https://github.com/linode/manager/pull/10588)) diff --git a/packages/manager/.changeset/pr-10590-fixed-1718722141069.md b/packages/manager/.changeset/pr-10590-fixed-1718722141069.md new file mode 100644 index 00000000000..2fbb1319e47 --- /dev/null +++ b/packages/manager/.changeset/pr-10590-fixed-1718722141069.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Accessibility: Add tabindex to TextTooltip ([#10590](https://github.com/linode/manager/pull/10590)) diff --git a/packages/manager/.changeset/pr-10591-tests-1718746383365.md b/packages/manager/.changeset/pr-10591-tests-1718746383365.md new file mode 100644 index 00000000000..fe1bc7bcd75 --- /dev/null +++ b/packages/manager/.changeset/pr-10591-tests-1718746383365.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Fix hanging unit tests ([#10591](https://github.com/linode/manager/pull/10591)) diff --git a/packages/manager/.changeset/pr-10592-upcoming-features-1718725835055.md b/packages/manager/.changeset/pr-10592-upcoming-features-1718725835055.md new file mode 100644 index 00000000000..87e05abd317 --- /dev/null +++ b/packages/manager/.changeset/pr-10592-upcoming-features-1718725835055.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Add Distributed Icon to ImageSelects for distributed compatible images ([#10592](https://github.com/linode/manager/pull/10592)) diff --git a/packages/manager/.changeset/pr-10596-tests-1718806691101.md b/packages/manager/.changeset/pr-10596-tests-1718806691101.md new file mode 100644 index 00000000000..678714c966d --- /dev/null +++ b/packages/manager/.changeset/pr-10596-tests-1718806691101.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Unit test coverage - HostNameTableCell ([#10596](https://github.com/linode/manager/pull/10596)) diff --git a/packages/manager/.changeset/pr-10597-fixed-1718893685492.md b/packages/manager/.changeset/pr-10597-fixed-1718893685492.md new file mode 100644 index 00000000000..bce06d11bc7 --- /dev/null +++ b/packages/manager/.changeset/pr-10597-fixed-1718893685492.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +fix: [M3-8274] - Fix parsing issue causing in Kubernetes Version field ([#10597](https://github.com/linode/manager/pull/10597)) diff --git a/packages/manager/.changeset/pr-10602-tests-1718985699307.md b/packages/manager/.changeset/pr-10602-tests-1718985699307.md new file mode 100644 index 00000000000..82ccb0de864 --- /dev/null +++ b/packages/manager/.changeset/pr-10602-tests-1718985699307.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Update Object Storage tests to mock account capabilities as needed for multi cluster ([#10602](https://github.com/linode/manager/pull/10602)) diff --git a/packages/manager/CHANGELOG.md b/packages/manager/CHANGELOG.md index cf21e9f2632..ac4dd7d6d8a 100644 --- a/packages/manager/CHANGELOG.md +++ b/packages/manager/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [2024-06-21] - v1.121.2 + +### Fixed: + +- Object Storage showing incorrect object URLs ([#10603](https://github.com/linode/manager/pull/10603)) + ## [2024-06-11] - v1.121.1 ### Fixed: diff --git a/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts b/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts index 7ca34e9d429..fd495ecc0c0 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts @@ -266,7 +266,7 @@ describe('LKE Cluster Creation with DC-specific pricing', () => { ui.regionSelect.find().type(`${dcSpecificPricingRegion.label}{enter}`); // Confirm that HA price updates dynamically once region selection is made. - cy.contains(/\(\$.*\/month\)/).should('be.visible'); + cy.contains(/\$.*\/month/).should('be.visible'); cy.get('[data-testid="ha-radio-button-yes"]').should('be.visible').click(); diff --git a/packages/manager/cypress/e2e/core/longview/longview.spec.ts b/packages/manager/cypress/e2e/core/longview/longview.spec.ts index 4ecfed6ca39..320f57da41e 100644 --- a/packages/manager/cypress/e2e/core/longview/longview.spec.ts +++ b/packages/manager/cypress/e2e/core/longview/longview.spec.ts @@ -1,28 +1,26 @@ -import type { Linode, LongviewClient } from '@linode/api-v4'; -import { createLongviewClient } from '@linode/api-v4'; -import { longviewResponseFactory, longviewClientFactory } from 'src/factories'; -import { LongviewResponse } from 'src/features/Longview/request.types'; +import type { LongviewClient } from '@linode/api-v4'; +import { DateTime } from 'luxon'; +import { + longviewResponseFactory, + longviewClientFactory, + longviewAppsFactory, + longviewLatestStatsFactory, + longviewPackageFactory, +} from 'src/factories'; import { authenticate } from 'support/api/authentication'; import { - longviewInstallTimeout, longviewStatusTimeout, longviewEmptyStateMessage, longviewAddClientButtonText, } from 'support/constants/longview'; import { interceptFetchLongviewStatus, - interceptGetLongviewClients, mockGetLongviewClients, mockFetchLongviewStatus, mockCreateLongviewClient, } from 'support/intercepts/longview'; import { ui } from 'support/ui'; import { cleanUp } from 'support/util/cleanup'; -import { createTestLinode } from 'support/util/linodes'; -import { randomLabel, randomString } from 'support/util/random'; - -// Timeout if Linode creation and boot takes longer than 1 and a half minutes. -const linodeCreateTimeout = 90000; /** * Returns the command used to install Longview which is shown in Cloud's UI. @@ -35,31 +33,6 @@ const getInstallCommand = (installCode: string): string => { return `curl -s https://lv.linode.com/${installCode} | sudo bash`; }; -/** - * Installs Longview on a Linode. - * - * @param linodeIp - IP of Linode on which to install Longview. - * @param linodePass - Root password of Linode on which to install Longview. - * @param installCommand - Longview installation command. - * - * @returns Cypress chainable. - */ -const installLongview = ( - linodeIp: string, - linodePass: string, - installCommand: string -) => { - return cy.exec('./cypress/support/scripts/longview/install-longview.sh', { - failOnNonZeroExit: true, - timeout: longviewInstallTimeout, - env: { - LINODEIP: linodeIp, - LINODEPASSWORD: linodePass, - CURLCOMMAND: installCommand, - }, - }); -}; - /** * Waits for Cloud Manager to fetch Longview data and receive updates. * @@ -100,6 +73,58 @@ const waitForLongviewData = ( ); }; +/* + * Mocks that represent the state of Longview while waiting for client to be installed. + */ +const longviewLastUpdatedWaiting = longviewResponseFactory.build({ + ACTION: 'lastUpdated', + DATA: { updated: 0 }, + NOTIFICATIONS: [], + VERSION: 0.4, +}); + +const longviewGetValuesWaiting = longviewResponseFactory.build({ + ACTION: 'getValues', + DATA: {}, + NOTIFICATIONS: [], + VERSION: 0.4, +}); + +const longviewGetLatestValueWaiting = longviewResponseFactory.build({ + ACTION: 'getLatestValue', + DATA: {}, + NOTIFICATIONS: [], + VERSION: 0.4, +}); + +/* + * Mocks that represent the state of Longview once client is installed and data is received. + */ +const longviewLastUpdatedInstalled = longviewResponseFactory.build({ + ACTION: 'lastUpdated', + DATA: { + updated: DateTime.now().plus({ minutes: 1 }).toSeconds(), + }, + NOTIFICATIONS: [], + VERSION: 0.4, +}); + +const longviewGetValuesInstalled = longviewResponseFactory.build({ + ACTION: 'getValues', + DATA: { + Packages: longviewPackageFactory.buildList(5), + }, + NOTIFICATIONS: [], + VERSION: 0.4, +}); + +const longviewGetLatestValueInstalled = longviewResponseFactory.build({ + ACTION: 'getLatestValue', + DATA: longviewLatestStatsFactory.build(), + NOTIFICATIONS: [], + VERSION: 0.4, +}); + authenticate(); describe('longview', () => { before(() => { @@ -107,78 +132,76 @@ describe('longview', () => { }); /* - * - Tests Longview installation end-to-end using real API data. - * - Creates a Linode, connects to it via SSH, and installs Longview using the given cURL command. + * - Tests Longview installation end-to-end using mock API data. * - Confirms that Cloud Manager UI updates to reflect Longview installation and data. */ - // TODO Unskip for M3-8107. - it.skip('can install Longview client on a Linode', () => { - const linodePassword = randomString(32, { - symbols: false, - lowercase: true, - uppercase: true, - numbers: true, - spaces: false, + + it('can install Longview client on a Linode', () => { + const client: LongviewClient = longviewClientFactory.build({ + api_key: '01AE82DD-6F99-44F6-95781512B64FFBC3', + apps: longviewAppsFactory.build(), + created: new Date().toISOString(), + id: 338283, + install_code: '748632FC-E92B-491F-A29D44019039017C', + label: 'longview-client-longview338283', + updated: new Date().toISOString(), }); - const createLinodeAndClient = async () => { - return Promise.all([ - createTestLinode({ - root_pass: linodePassword, - type: 'g6-standard-1', - booted: true, - }), - createLongviewClient(randomLabel()), - ]); - }; - - // Create Linode and Longview Client before loading Longview landing page. - cy.defer(createLinodeAndClient, { - label: 'Creating Linode and Longview Client...', - timeout: linodeCreateTimeout, - }).then(([linode, client]: [Linode, LongviewClient]) => { - const linodeIp = linode.ipv4[0]; - const installCommand = getInstallCommand(client.install_code); - - interceptGetLongviewClients().as('getLongviewClients'); - interceptFetchLongviewStatus().as('fetchLongviewStatus'); - cy.visitWithLogin('/longview'); - cy.wait('@getLongviewClients'); - - // Find the table row for the new Longview client, assert expected information - // is displayed inside of it. - cy.get(`[data-qa-longview-client="${client.id}"]`) - .should('be.visible') - .within(() => { - cy.findByText(client.label).should('be.visible'); - cy.findByText(client.api_key).should('be.visible'); - cy.contains(installCommand).should('be.visible'); - cy.findByText('Waiting for data...'); - }); - - // Install Longview on Linode by SSHing into machine and executing cURL command. - installLongview(linodeIp, linodePassword, installCommand); - - // Wait for Longview to begin serving data and confirm that Cloud Manager - // UI updates accordingly. - waitForLongviewData('fetchLongviewStatus', client.api_key); - - // Sometimes Cloud Manager UI does not updated automatically upon receiving - // Longivew status data. Performing a page reload mitigates this issue. - // TODO Remove call to `cy.reload()`. - cy.reload(); - cy.get(`[data-qa-longview-client="${client.id}"]`) - .should('be.visible') - .within(() => { - cy.findByText('Waiting for data...').should('not.exist'); - cy.findByText('CPU').should('be.visible'); - cy.findByText('RAM').should('be.visible'); - cy.findByText('Swap').should('be.visible'); - cy.findByText('Load').should('be.visible'); - cy.findByText('Network').should('be.visible'); - cy.findByText('Storage').should('be.visible'); - }); + mockGetLongviewClients([client]).as('getLongviewClients'); + mockFetchLongviewStatus(client, 'lastUpdated', longviewLastUpdatedWaiting); + mockFetchLongviewStatus(client, 'getValues', longviewGetValuesWaiting); + mockFetchLongviewStatus( + client, + 'getLatestValue', + longviewGetLatestValueWaiting + ).as('fetchLongview'); + + const installCommand = getInstallCommand(client.install_code); + + cy.visitWithLogin('/longview'); + cy.wait('@getLongviewClients'); + + // Confirm that Longview landing page lists a client that is still waiting for data... + cy.get(`[data-qa-longview-client="${client.id}"]`) + .should('be.visible') + .within(() => { + cy.findByText(client.label).should('be.visible'); + cy.findByText(client.api_key).should('be.visible'); + cy.contains(installCommand).should('be.visible'); + cy.findByText('Waiting for data...'); + }); + + // Update mocks after initial Longview fetch to simulate client installation and data retrieval. + // The next time Cloud makes a request to the fetch endpoint, data will start being returned. + // 3 fetches is necessary because the Longview landing page fires 3 requests to the Longview fetch endpoint for each client. + // See https://github.com/linode/manager/pull/10579#discussion_r1647945160 + cy.wait(['@fetchLongview', '@fetchLongview', '@fetchLongview']).then(() => { + mockFetchLongviewStatus( + client, + 'lastUpdated', + longviewLastUpdatedInstalled + ); + mockFetchLongviewStatus(client, 'getValues', longviewGetValuesInstalled); + mockFetchLongviewStatus( + client, + 'getLatestValue', + longviewGetLatestValueInstalled + ); }); + + // Confirms that UI updates to show that data has been retrieved. + cy.findByText(`${client.label}`).should('be.visible'); + cy.get(`[data-qa-longview-client="${client.id}"]`) + .should('be.visible') + .within(() => { + cy.findByText('Waiting for data...').should('not.exist'); + cy.findByText('CPU').should('be.visible'); + cy.findByText('RAM').should('be.visible'); + cy.findByText('Swap').should('be.visible'); + cy.findByText('Load').should('be.visible'); + cy.findByText('Network').should('be.visible'); + cy.findByText('Storage').should('be.visible'); + }); }); /* @@ -187,10 +210,15 @@ describe('longview', () => { */ it('displays empty state message when no clients are present and shows the new client when creating one', () => { const client: LongviewClient = longviewClientFactory.build(); - const status: LongviewResponse = longviewResponseFactory.build(); mockGetLongviewClients([]).as('getLongviewClients'); mockCreateLongviewClient(client).as('createLongviewClient'); - mockFetchLongviewStatus(status).as('fetchLongviewStatus'); + mockFetchLongviewStatus(client, 'lastUpdated', longviewLastUpdatedWaiting); + mockFetchLongviewStatus(client, 'getValues', longviewGetValuesWaiting); + mockFetchLongviewStatus( + client, + 'getLatestValue', + longviewGetLatestValueWaiting + ).as('fetchLongview'); cy.visitWithLogin('/longview'); cy.wait('@getLongviewClients'); @@ -206,6 +234,24 @@ describe('longview', () => { .click(); cy.wait('@createLongviewClient'); + // Update mocks after initial Longview fetch to simulate client installation and data retrieval. + // The next time Cloud makes a request to the fetch endpoint, data will start being returned. + // 3 fetches is necessary because the Longview landing page fires 3 requests to the Longview fetch endpoint for each client. + // See https://github.com/linode/manager/pull/10579#discussion_r1647945160 + cy.wait(['@fetchLongview', '@fetchLongview', '@fetchLongview']).then(() => { + mockFetchLongviewStatus( + client, + 'lastUpdated', + longviewLastUpdatedInstalled + ); + mockFetchLongviewStatus(client, 'getValues', longviewGetValuesInstalled); + mockFetchLongviewStatus( + client, + 'getLatestValue', + longviewGetLatestValueInstalled + ); + }); + // Confirms that UI updates to show the new client when creating one. cy.findByText(`${client.label}`).should('be.visible'); cy.get(`[data-qa-longview-client="${client.id}"]`) diff --git a/packages/manager/cypress/e2e/core/objectStorage/access-key.e2e.spec.ts b/packages/manager/cypress/e2e/core/objectStorage/access-key.e2e.spec.ts index 9d8ef3a7757..3806fb0ebb0 100644 --- a/packages/manager/cypress/e2e/core/objectStorage/access-key.e2e.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorage/access-key.e2e.spec.ts @@ -17,6 +17,8 @@ import { makeFeatureFlagData } from 'support/util/feature-flags'; import { randomLabel } from 'support/util/random'; import { ui } from 'support/ui'; import { cleanUp } from 'support/util/cleanup'; +import { mockGetAccount } from 'support/intercepts/account'; +import { accountFactory } from 'src/factories'; authenticate(); describe('object storage access key end-to-end tests', () => { @@ -37,6 +39,7 @@ describe('object storage access key end-to-end tests', () => { interceptGetAccessKeys().as('getKeys'); interceptCreateAccessKey().as('createKey'); + mockGetAccount(accountFactory.build({ capabilities: [] })); mockAppendFeatureFlags({ objMultiCluster: makeFeatureFlagData(false), }); @@ -131,6 +134,7 @@ describe('object storage access key end-to-end tests', () => { ).then(() => { const keyLabel = randomLabel(); + mockGetAccount(accountFactory.build({ capabilities: [] })); mockAppendFeatureFlags({ objMultiCluster: makeFeatureFlagData(false), }); diff --git a/packages/manager/cypress/e2e/core/objectStorage/access-keys.smoke.spec.ts b/packages/manager/cypress/e2e/core/objectStorage/access-keys.smoke.spec.ts index 84d67db2cb4..f3972f56cbc 100644 --- a/packages/manager/cypress/e2e/core/objectStorage/access-keys.smoke.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorage/access-keys.smoke.spec.ts @@ -25,10 +25,11 @@ import { randomString, } from 'support/util/random'; import { ui } from 'support/ui'; -import { regionFactory } from 'src/factories'; +import { accountFactory, regionFactory } from 'src/factories'; import { mockGetRegions } from 'support/intercepts/regions'; import { buildArray } from 'support/util/arrays'; import { Scope } from '@linode/api-v4'; +import { mockGetAccount } from 'support/intercepts/account'; describe('object storage access keys smoke tests', () => { /* @@ -44,6 +45,7 @@ describe('object storage access keys smoke tests', () => { secret_key: randomString(39), }); + mockGetAccount(accountFactory.build({ capabilities: [] })); mockAppendFeatureFlags({ objMultiCluster: makeFeatureFlagData(false), }); @@ -115,6 +117,7 @@ describe('object storage access keys smoke tests', () => { secret_key: randomString(39), }); + mockGetAccount(accountFactory.build({ capabilities: [] })); mockAppendFeatureFlags({ objMultiCluster: makeFeatureFlagData(false), }); @@ -164,6 +167,11 @@ describe('object storage access keys smoke tests', () => { const mockRegions = [...mockRegionsObj, ...mockRegionsNoObj]; beforeEach(() => { + mockGetAccount( + accountFactory.build({ + capabilities: ['Object Storage Access Key Regions'], + }) + ); mockAppendFeatureFlags({ objMultiCluster: makeFeatureFlagData(true), }); diff --git a/packages/manager/cypress/e2e/core/objectStorage/enable-object-storage.spec.ts b/packages/manager/cypress/e2e/core/objectStorage/enable-object-storage.spec.ts index 76459bd7b5b..08e52e042a4 100644 --- a/packages/manager/cypress/e2e/core/objectStorage/enable-object-storage.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorage/enable-object-storage.spec.ts @@ -14,8 +14,12 @@ import { profileFactory, regionFactory, objectStorageKeyFactory, + accountFactory, } from '@src/factories'; -import { mockGetAccountSettings } from 'support/intercepts/account'; +import { + mockGetAccount, + mockGetAccountSettings, +} from 'support/intercepts/account'; import { mockCancelObjectStorage, mockCreateAccessKey, @@ -56,6 +60,7 @@ describe('Object Storage enrollment', () => { * - Confirms that consistent pricing information is shown for all regions in the enable modal. */ it('can enroll in Object Storage', () => { + mockGetAccount(accountFactory.build({ capabilities: [] })); mockAppendFeatureFlags({ objMultiCluster: makeFeatureFlagData(false), }); diff --git a/packages/manager/cypress/e2e/core/objectStorage/object-storage.e2e.spec.ts b/packages/manager/cypress/e2e/core/objectStorage/object-storage.e2e.spec.ts index 87cdaf3371d..be2705f89e3 100644 --- a/packages/manager/cypress/e2e/core/objectStorage/object-storage.e2e.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorage/object-storage.e2e.spec.ts @@ -4,9 +4,12 @@ import 'cypress-file-upload'; import { createBucket } from '@linode/api-v4/lib/object-storage'; -import { objectStorageBucketFactory } from 'src/factories'; +import { accountFactory, objectStorageBucketFactory } from 'src/factories'; import { authenticate } from 'support/api/authentication'; -import { interceptGetNetworkUtilization } from 'support/intercepts/account'; +import { + interceptGetNetworkUtilization, + mockGetAccount, +} from 'support/intercepts/account'; import { interceptCreateBucket, interceptDeleteBucket, @@ -132,6 +135,7 @@ describe('object storage end-to-end tests', () => { interceptDeleteBucket(bucketLabel, bucketCluster).as('deleteBucket'); interceptGetNetworkUtilization().as('getNetworkUtilization'); + mockGetAccount(accountFactory.build({ capabilities: [] })); mockAppendFeatureFlags({ objMultiCluster: makeFeatureFlagData(false), }).as('getFeatureFlags'); diff --git a/packages/manager/cypress/e2e/core/objectStorage/object-storage.smoke.spec.ts b/packages/manager/cypress/e2e/core/objectStorage/object-storage.smoke.spec.ts index 261c4a10491..505ba19b880 100644 --- a/packages/manager/cypress/e2e/core/objectStorage/object-storage.smoke.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorage/object-storage.smoke.spec.ts @@ -23,7 +23,8 @@ import { import { makeFeatureFlagData } from 'support/util/feature-flags'; import { randomLabel, randomString } from 'support/util/random'; import { ui } from 'support/ui'; -import { regionFactory } from 'src/factories'; +import { accountFactory, regionFactory } from 'src/factories'; +import { mockGetAccount } from 'support/intercepts/account'; describe('object storage smoke tests', () => { /* @@ -56,6 +57,11 @@ describe('object storage smoke tests', () => { objects: 0, }); + mockGetAccount( + accountFactory.build({ + capabilities: ['Object Storage Access Key Regions'], + }) + ); mockAppendFeatureFlags({ objMultiCluster: makeFeatureFlagData(true), }).as('getFeatureFlags'); @@ -160,6 +166,7 @@ describe('object storage smoke tests', () => { hostname: bucketHostname, }); + mockGetAccount(accountFactory.build({ capabilities: [] })); mockAppendFeatureFlags({ objMultiCluster: makeFeatureFlagData(false), }).as('getFeatureFlags'); @@ -286,7 +293,7 @@ describe('object storage smoke tests', () => { * - Mocks existing buckets. * - Deletes mocked bucket, confirms that landing page reflects deletion. */ - it('can delete object storage bucket - smoke', () => { + it('can delete object storage bucket - smoke - Multi Cluster Disabled', () => { const bucketLabel = randomLabel(); const bucketCluster = 'us-southeast-1'; const bucketMock = objectStorageBucketFactory.build({ @@ -296,6 +303,12 @@ describe('object storage smoke tests', () => { objects: 0, }); + mockGetAccount(accountFactory.build({ capabilities: [] })); + mockAppendFeatureFlags({ + objMultiCluster: makeFeatureFlagData(false), + }); + mockGetFeatureFlagClientstream(); + mockGetBuckets([bucketMock]).as('getBuckets'); mockDeleteBucket(bucketLabel, bucketCluster).as('deleteBucket'); @@ -324,4 +337,58 @@ describe('object storage smoke tests', () => { cy.wait('@deleteBucket'); cy.findByText('S3-compatible storage solution').should('be.visible'); }); + + /* + * - Tests core object storage bucket deletion flow using mocked API responses. + * - Mocks existing buckets. + * - Deletes mocked bucket, confirms that landing page reflects deletion. + */ + it('can delete object storage bucket - smoke - Multi Cluster Enabled', () => { + const bucketLabel = randomLabel(); + const bucketCluster = 'us-southeast-1'; + const bucketMock = objectStorageBucketFactory.build({ + label: bucketLabel, + cluster: bucketCluster, + hostname: `${bucketLabel}.${bucketCluster}.linodeobjects.com`, + objects: 0, + }); + + mockGetAccount( + accountFactory.build({ + capabilities: ['Object Storage Access Key Regions'], + }) + ); + mockAppendFeatureFlags({ + objMultiCluster: makeFeatureFlagData(true), + }); + mockGetFeatureFlagClientstream(); + + mockGetBuckets([bucketMock]).as('getBuckets'); + mockDeleteBucket(bucketLabel, bucketMock.region!).as('deleteBucket'); + + cy.visitWithLogin('/object-storage'); + cy.wait('@getBuckets'); + + cy.findByText(bucketLabel) + .should('be.visible') + .closest('tr') + .within(() => { + cy.findByText('Delete').should('be.visible').click(); + }); + + ui.dialog + .findByTitle(`Delete Bucket ${bucketLabel}`) + .should('be.visible') + .within(() => { + cy.findByLabelText('Bucket Name').click().type(bucketLabel); + ui.buttonGroup + .findButtonByTitle('Delete') + .should('be.enabled') + .should('be.visible') + .click(); + }); + + cy.wait('@deleteBucket'); + cy.findByText('S3-compatible storage solution').should('be.visible'); + }); }); diff --git a/packages/manager/cypress/support/intercepts/longview.ts b/packages/manager/cypress/support/intercepts/longview.ts index d0cb2c742fc..2bc3eaf27a9 100644 --- a/packages/manager/cypress/support/intercepts/longview.ts +++ b/packages/manager/cypress/support/intercepts/longview.ts @@ -2,7 +2,10 @@ import { apiMatcher } from 'support/util/intercepts'; import { paginateResponse } from 'support/util/paginate'; import { makeResponse } from 'support/util/response'; import { LongviewClient } from '@linode/api-v4'; -import { LongviewResponse } from 'src/features/Longview/request.types'; +import type { + LongviewAction, + LongviewResponse, +} from 'src/features/Longview/request.types'; /** * Intercepts request to retrieve Longview status for a Longview client. @@ -16,15 +19,38 @@ export const interceptFetchLongviewStatus = (): Cypress.Chainable => { /** * Mocks request to retrieve Longview status for a Longview client. * + * @param client - Longview Client for which to intercept Longview fetch request. + * @param apiAction - Longview API action to intercept. + * @param mockStatus - + * * @returns Cypress chainable. */ export const mockFetchLongviewStatus = ( - status: LongviewResponse + client: LongviewClient, + apiAction: LongviewAction, + mockStatus: LongviewResponse ): Cypress.Chainable => { return cy.intercept( - 'POST', - 'https://longview.linode.com/fetch', - makeResponse(status) + { + url: 'https://longview.linode.com/fetch', + method: 'POST', + }, + async (req) => { + const payload = req.body; + const response = new Response(payload, { + headers: { + 'content-type': req.headers['content-type'] as string, + }, + }); + const formData = await response.formData(); + + if ( + formData.get('api_key') === client.api_key && + formData.get('api_action') === apiAction + ) { + req.reply(makeResponse([mockStatus])); + } + } ); }; diff --git a/packages/manager/package.json b/packages/manager/package.json index c6e0afd6552..5c1fdfb36b0 100644 --- a/packages/manager/package.json +++ b/packages/manager/package.json @@ -2,7 +2,7 @@ "name": "linode-manager", "author": "Linode", "description": "The Linode Manager website", - "version": "1.121.1", + "version": "1.121.2", "private": true, "type": "module", "bugs": { @@ -18,6 +18,7 @@ "@emotion/styled": "^11.11.0", "@hookform/resolvers": "2.9.11", "@linode/api-v4": "*", + "@linode/design-language-system": "^2.3.0", "@linode/validation": "*", "@lukemorales/query-key-factory": "^1.3.4", "@mui/icons-material": "^5.14.7", diff --git a/packages/manager/src/MainContent.tsx b/packages/manager/src/MainContent.tsx index c74fd368483..c73a627e77d 100644 --- a/packages/manager/src/MainContent.tsx +++ b/packages/manager/src/MainContent.tsx @@ -130,7 +130,11 @@ const Domains = React.lazy(() => })) ); const Images = React.lazy(() => import('src/features/Images')); -const Kubernetes = React.lazy(() => import('src/features/Kubernetes')); +const Kubernetes = React.lazy(() => + import('src/features/Kubernetes').then((module) => ({ + default: module.Kubernetes, + })) +); const ObjectStorage = React.lazy(() => import('src/features/ObjectStorage')); const Profile = React.lazy(() => import('src/features/Profile/Profile')); const LoadBalancers = React.lazy(() => diff --git a/packages/manager/src/components/ActionMenu/ActionMenu.tsx b/packages/manager/src/components/ActionMenu/ActionMenu.tsx index 679c0b34df9..6f5faf359b1 100644 --- a/packages/manager/src/components/ActionMenu/ActionMenu.tsx +++ b/packages/manager/src/components/ActionMenu/ActionMenu.tsx @@ -1,4 +1,4 @@ -import { IconButton, ListItemText, useTheme } from '@mui/material'; +import { IconButton, ListItemText } from '@mui/material'; import Menu from '@mui/material/Menu'; import MenuItem from '@mui/material/MenuItem'; import * as React from 'react'; @@ -37,7 +37,6 @@ export interface ActionMenuProps { */ export const ActionMenu = React.memo((props: ActionMenuProps) => { const { actionsList, ariaLabel, onOpen } = props; - const theme = useTheme(); const menuId = convertToKebabCase(ariaLabel); const buttonId = `${convertToKebabCase(ariaLabel)}-button`; @@ -70,16 +69,6 @@ export const ActionMenu = React.memo((props: ActionMenuProps) => { } const sxTooltipIcon = { - '& :hover': { - color: '#4d99f1', - }, - '&& .MuiSvgIcon-root': { - fill: theme.color.disabledText, - height: '20px', - width: '20px', - }, - - color: '#fff', padding: '0 0 0 8px', pointerEvents: 'all', // Allows the tooltip to be hovered on a disabled MenuItem }; @@ -89,12 +78,12 @@ export const ActionMenu = React.memo((props: ActionMenuProps) => { ({ ':hover': { - backgroundColor: theme.palette.primary.main, - color: '#fff', + backgroundColor: theme.color.buttonPrimaryHover, + color: theme.color.white, }, - backgroundColor: open ? theme.palette.primary.main : undefined, + backgroundColor: open ? theme.color.buttonPrimaryHover : undefined, borderRadius: 'unset', - color: open ? '#fff' : theme.textColors.linkActiveLight, + color: open ? theme.color.white : theme.textColors.linkActiveLight, height: '100%', minWidth: '40px', padding: '10px', @@ -122,7 +111,6 @@ export const ActionMenu = React.memo((props: ActionMenuProps) => { paper: { sx: (theme) => ({ backgroundColor: theme.palette.primary.main, - boxShadow: 'none', }), }, }} @@ -147,15 +135,6 @@ export const ActionMenu = React.memo((props: ActionMenuProps) => { a.onClick(); } }} - sx={{ - '&:hover': { - background: '#226dc3', - }, - background: '#3683dc', - borderBottom: '1px solid #5294e0', - color: '#fff', - padding: '10px 10px 10px 16px', - }} data-qa-action-menu-item={a.title} data-testid={a.title} disabled={a.disabled} diff --git a/packages/manager/src/components/BetaChip/BetaChip.test.tsx b/packages/manager/src/components/BetaChip/BetaChip.test.tsx index 47a86d03207..39d28178640 100644 --- a/packages/manager/src/components/BetaChip/BetaChip.test.tsx +++ b/packages/manager/src/components/BetaChip/BetaChip.test.tsx @@ -17,7 +17,7 @@ describe('BetaChip', () => { const { getByTestId } = renderWithTheme(); const betaChip = getByTestId('betaChip'); expect(betaChip).toBeInTheDocument(); - expect(betaChip).toHaveStyle('background-color: #3683dc'); + expect(betaChip).toHaveStyle('background-color: rgb(16, 138, 214)'); }); it('triggers an onClick callback', () => { diff --git a/packages/manager/src/components/Breadcrumb/Crumbs.styles.tsx b/packages/manager/src/components/Breadcrumb/Crumbs.styles.tsx index 8fe3480a3a9..848a0a164bc 100644 --- a/packages/manager/src/components/Breadcrumb/Crumbs.styles.tsx +++ b/packages/manager/src/components/Breadcrumb/Crumbs.styles.tsx @@ -4,11 +4,10 @@ import { Typography } from 'src/components/Typography'; export const StyledTypography = styled(Typography, { label: 'StyledTypography', -})(({ theme }) => ({ +})(({}) => ({ '&:hover': { textDecoration: 'underline', }, - color: theme.textColors.tableHeader, fontSize: '1.125rem', lineHeight: 'normal', textTransform: 'capitalize', diff --git a/packages/manager/src/components/Button/StyledActionButton.ts b/packages/manager/src/components/Button/StyledActionButton.ts index aa711d36626..008257f5a50 100644 --- a/packages/manager/src/components/Button/StyledActionButton.ts +++ b/packages/manager/src/components/Button/StyledActionButton.ts @@ -15,10 +15,12 @@ export const StyledActionButton = styled(Button, { })(({ theme, ...props }) => ({ ...(!props.disabled && { '&:hover': { - backgroundColor: theme.palette.primary.main, - color: theme.name === 'dark' ? theme.color.black : theme.color.white, + backgroundColor: theme.color.buttonPrimaryHover, + color: theme.color.white, }, }), + background: 'transparent', + color: theme.textColors.linkActiveLight, fontFamily: latoWeb.normal, fontSize: '14px', lineHeight: '16px', diff --git a/packages/manager/src/components/Button/StyledLinkButton.ts b/packages/manager/src/components/Button/StyledLinkButton.ts index 8c4cec0b4a8..1688a156f79 100644 --- a/packages/manager/src/components/Button/StyledLinkButton.ts +++ b/packages/manager/src/components/Button/StyledLinkButton.ts @@ -10,20 +10,5 @@ import { styled } from '@mui/material/styles'; export const StyledLinkButton = styled('button', { label: 'StyledLinkButton', })(({ theme }) => ({ - '&:disabled': { - color: theme.palette.text.disabled, - cursor: 'not-allowed', - }, - '&:hover:not(:disabled)': { - backgroundColor: 'transparent', - color: theme.palette.primary.main, - textDecoration: 'underline', - }, - background: 'none', - border: 'none', - color: theme.textColors.linkActiveLight, - cursor: 'pointer', - font: 'inherit', - minWidth: 0, - padding: 0, + ...theme.applyLinkStyles, })); diff --git a/packages/manager/src/components/Button/StyledTagButton.ts b/packages/manager/src/components/Button/StyledTagButton.ts index df83fcc4c88..d0dae58b7cd 100644 --- a/packages/manager/src/components/Button/StyledTagButton.ts +++ b/packages/manager/src/components/Button/StyledTagButton.ts @@ -24,11 +24,12 @@ export const StyledTagButton = styled(Button, { }), ...(!props.disabled && { '&:hover, &:focus': { - backgroundColor: theme.color.tagButton, + backgroundColor: theme.color.tagButtonBg, border: 'none', + color: theme.color.tagButtonText, }, - backgroundColor: theme.color.tagButton, - color: theme.textColors.linkActiveLight, + backgroundColor: theme.color.tagButtonBg, + color: theme.color.tagButtonText, }), })); diff --git a/packages/manager/src/components/CollapsibleTable/CollapsibleTable.tsx b/packages/manager/src/components/CollapsibleTable/CollapsibleTable.tsx index 0fe1a57cbae..bd844227670 100644 --- a/packages/manager/src/components/CollapsibleTable/CollapsibleTable.tsx +++ b/packages/manager/src/components/CollapsibleTable/CollapsibleTable.tsx @@ -1,5 +1,3 @@ -import Paper from '@mui/material/Paper'; -import TableContainer from '@mui/material/TableContainer'; import * as React from 'react'; import { Table } from 'src/components/Table'; @@ -25,25 +23,23 @@ export const CollapsibleTable = (props: Props) => { const { TableItems, TableRowEmpty, TableRowHead } = props; return ( - - - - {TableRowHead} - - - {TableItems.length === 0 && TableRowEmpty} - {TableItems.map((item) => { - return ( - - ); - })} - -
-
+ + + {TableRowHead} + + + {TableItems.length === 0 && TableRowEmpty} + {TableItems.map((item) => { + return ( + + ); + })} + +
); }; diff --git a/packages/manager/src/components/ColorPalette/ColorPalette.test.tsx b/packages/manager/src/components/ColorPalette/ColorPalette.test.tsx deleted file mode 100644 index a9f6024520e..00000000000 --- a/packages/manager/src/components/ColorPalette/ColorPalette.test.tsx +++ /dev/null @@ -1,132 +0,0 @@ -import React from 'react'; - -import { renderWithTheme } from 'src/utilities/testHelpers'; - -import { ColorPalette } from './ColorPalette'; - -describe('Color Palette', () => { - it('renders the Color Palette', () => { - const { getAllByText, getByText } = renderWithTheme(); - - // primary colors - getByText('Primary Colors'); - getByText('theme.palette.primary.main'); - const mainHash = getAllByText('#3683dc'); - expect(mainHash).toHaveLength(2); - getByText('theme.palette.primary.light'); - getByText('#4d99f1'); - getByText('theme.palette.primary.dark'); - getByText('#2466b3'); - getByText('theme.palette.text.primary'); - const primaryHash = getAllByText('#606469'); - expect(primaryHash).toHaveLength(3); - getByText('theme.color.headline'); - const headlineHash = getAllByText('#32363c'); - expect(headlineHash).toHaveLength(2); - getByText('theme.palette.divider'); - const dividerHash = getAllByText('#f4f4f4'); - expect(dividerHash).toHaveLength(2); - const whiteColor = getAllByText('theme.color.white'); - expect(whiteColor).toHaveLength(2); - const whiteHash = getAllByText('#fff'); - expect(whiteHash).toHaveLength(3); - - // etc - getByText('Etc.'); - getByText('theme.color.red'); - getByText('#ca0813'); - getByText('theme.color.orange'); - getByText('#ffb31a'); - getByText('theme.color.yellow'); - getByText('#fecf2f'); - getByText('theme.color.green'); - getByText('#00b159'); - getByText('theme.color.teal'); - getByText('#17cf73'); - getByText('theme.color.border2'); - getByText('#c5c6c8'); - getByText('theme.color.border3'); - getByText('#eee'); - getByText('theme.color.grey1'); - getByText('#abadaf'); - getByText('theme.color.grey2'); - getByText('#e7e7e7'); - getByText('theme.color.grey3'); - getByText('#ccc'); - getByText('theme.color.grey4'); - getByText('#8C929D'); - getByText('theme.color.grey5'); - getByText('#f5f5f5'); - getByText('theme.color.grey6'); - const borderGreyHash = getAllByText('#e3e5e8'); - expect(borderGreyHash).toHaveLength(3); - getByText('theme.color.grey7'); - getByText('#e9eaef'); - getByText('theme.color.grey8'); - getByText('#dbdde1'); - getByText('theme.color.grey9'); - const borderGrey9Hash = getAllByText('#f4f5f6'); - expect(borderGrey9Hash).toHaveLength(3); - getByText('theme.color.black'); - getByText('#222'); - getByText('theme.color.offBlack'); - getByText('#444'); - getByText('theme.color.boxShadow'); - getByText('#ddd'); - getByText('theme.color.boxShadowDark'); - getByText('#aaa'); - getByText('theme.color.blueDTwhite'); - getByText('theme.color.tableHeaderText'); - getByText('rgba(0, 0, 0, 0.54)'); - getByText('theme.color.drawerBackdrop'); - getByText('rgba(255, 255, 255, 0.5)'); - getByText('theme.color.label'); - getByText('#555'); - getByText('theme.color.disabledText'); - getByText('#c9cacb'); - getByText('theme.color.tagButton'); - getByText('#f1f7fd'); - getByText('theme.color.tagIcon'); - getByText('#7daee8'); - - // background colors - getByText('Background Colors'); - getByText('theme.bg.app'); - getByText('theme.bg.main'); - getByText('theme.bg.offWhite'); - getByText('#fbfbfb'); - getByText('theme.bg.lightBlue1'); - getByText('#f0f7ff'); - getByText('theme.bg.lightBlue2'); - getByText('#e5f1ff'); - getByText('theme.bg.white'); - getByText('theme.bg.tableHeader'); - getByText('#f9fafa'); - getByText('theme.bg.primaryNavPaper'); - getByText('#3a3f46'); - getByText('theme.bg.mainContentBanner'); - getByText('#33373d'); - getByText('theme.bg.bgPaper'); - getByText('#ffffff'); - getByText('theme.bg.bgAccessRow'); - getByText('#fafafa'); - getByText('theme.bg.bgAccessRowTransparentGradient'); - getByText('rgb(255, 255, 255, .001)'); - - // typography colors - getByText('Typography Colors'); - getByText('theme.textColors.linkActiveLight'); - getByText('#2575d0'); - getByText('theme.textColors.headlineStatic'); - getByText('theme.textColors.tableHeader'); - getByText('#888f91'); - getByText('theme.textColors.tableStatic'); - getByText('theme.textColors.textAccessTable'); - - // border colors - getByText('Border Colors'); - getByText('theme.borderColors.borderTypography'); - getByText('theme.borderColors.borderTable'); - getByText('theme.borderColors.divider'); - }); -}); diff --git a/packages/manager/src/components/ColorPalette/ColorPalette.tsx b/packages/manager/src/components/ColorPalette/ColorPalette.tsx index a3bfaadb121..9404371eea9 100644 --- a/packages/manager/src/components/ColorPalette/ColorPalette.tsx +++ b/packages/manager/src/components/ColorPalette/ColorPalette.tsx @@ -1,12 +1,12 @@ -// eslint-disable-next-line no-restricted-imports import { useTheme } from '@mui/material'; import Grid from '@mui/material/Unstable_Grid2'; -import { Theme } from '@mui/material/styles'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; import { Typography } from 'src/components/Typography'; +import type { Theme } from '@mui/material/styles'; + interface Color { alias: string; color: string; @@ -45,7 +45,7 @@ const useStyles = makeStyles()((theme: Theme) => ({ /** * Add a new color to the palette, especially another tint of gray or blue, only after exhausting the option of using an existing color. * - * - Colors used in light mode are located in `foundations/light.ts + * - Colors used in light mode are located in `foundations/light.ts` * - Colors used in dark mode are located in `foundations/dark.ts` * * If a color does not exist in the current palette and is only used once, consider applying the color conditionally: @@ -102,7 +102,7 @@ export const ColorPalette = () => { { alias: 'theme.color.drawerBackdrop', color: theme.color.drawerBackdrop }, { alias: 'theme.color.label', color: theme.color.label }, { alias: 'theme.color.disabledText', color: theme.color.disabledText }, - { alias: 'theme.color.tagButton', color: theme.color.tagButton }, + { alias: 'theme.color.tagButton', color: theme.color.tagButtonBg }, { alias: 'theme.color.tagIcon', color: theme.color.tagIcon }, ]; diff --git a/packages/manager/src/components/Divider.tsx b/packages/manager/src/components/Divider.tsx index 6daa2d34fdb..cfd18a7fe5a 100644 --- a/packages/manager/src/components/Divider.tsx +++ b/packages/manager/src/components/Divider.tsx @@ -24,13 +24,6 @@ const StyledDivider = styled(_Divider, { 'dark', ]), })(({ theme, ...props }) => ({ - borderColor: props.dark - ? theme.color.border2 - : props.light - ? theme.name === 'light' - ? '#e3e5e8' - : '#2e3238' - : '', marginBottom: props.spacingBottom, marginTop: props.spacingTop, })); diff --git a/packages/manager/src/components/DocsLink/DocsLink.tsx b/packages/manager/src/components/DocsLink/DocsLink.tsx index fc13e6b3baf..aba71077a05 100644 --- a/packages/manager/src/components/DocsLink/DocsLink.tsx +++ b/packages/manager/src/components/DocsLink/DocsLink.tsx @@ -50,13 +50,10 @@ export const DocsLink = (props: DocsLinkProps) => { const StyledDocsLink = styled(Link, { label: 'StyledDocsLink', })(({ theme }) => ({ + ...theme.applyLinkStyles, '& svg': { marginRight: theme.spacing(), }, - '&:hover': { - color: theme.textColors.linkActiveLight, - textDecoration: 'underline', - }, alignItems: 'center', display: 'flex', fontFamily: theme.font.normal, diff --git a/packages/manager/src/components/EnhancedSelect/Select.styles.ts b/packages/manager/src/components/EnhancedSelect/Select.styles.ts index 9ffe05b76e6..597687971d1 100644 --- a/packages/manager/src/components/EnhancedSelect/Select.styles.ts +++ b/packages/manager/src/components/EnhancedSelect/Select.styles.ts @@ -1,6 +1,7 @@ -import { Theme } from '@mui/material/styles'; import { makeStyles } from 'tss-react/mui'; +import type { Theme } from '@mui/material/styles'; + // TODO jss-to-tss-react codemod: usages of this hook outside of this file will not be converted. export const useStyles = makeStyles()((theme: Theme) => ({ algoliaRoot: { @@ -225,6 +226,9 @@ export const useStyles = makeStyles()((theme: Theme) => ({ }, width: '100%', }, + '& .select-placeholder': { + color: theme.color.grey1, + }, '& [class*="MuiFormHelperText-error"]': { paddingBottom: theme.spacing(1), }, diff --git a/packages/manager/src/components/EnhancedSelect/Select.tsx b/packages/manager/src/components/EnhancedSelect/Select.tsx index 085210785f1..77f4ec721e5 100644 --- a/packages/manager/src/components/EnhancedSelect/Select.tsx +++ b/packages/manager/src/components/EnhancedSelect/Select.tsx @@ -1,18 +1,10 @@ -import { Theme, useTheme } from '@mui/material'; +import { useTheme } from '@mui/material'; import * as React from 'react'; -import ReactSelect, { - ActionMeta, - NamedProps as SelectProps, - ValueType, -} from 'react-select'; -import CreatableSelect, { - CreatableProps as CreatableSelectProps, -} from 'react-select/creatable'; +import ReactSelect from 'react-select'; +import CreatableSelect from 'react-select/creatable'; -import { TextFieldProps } from 'src/components/TextField'; import { convertToKebabCase } from 'src/utilities/convertToKebobCase'; -import { reactSelectStyles, useStyles } from './Select.styles'; import { DropdownIndicator } from './components/DropdownIndicator'; import Input from './components/Input'; import { LoadingIndicator } from './components/LoadingIndicator'; @@ -23,6 +15,16 @@ import NoOptionsMessage from './components/NoOptionsMessage'; import { Option } from './components/Option'; import Control from './components/SelectControl'; import { SelectPlaceholder as Placeholder } from './components/SelectPlaceholder'; +import { reactSelectStyles, useStyles } from './Select.styles'; + +import type { Theme } from '@mui/material'; +import type { + ActionMeta, + NamedProps as SelectProps, + ValueType, +} from 'react-select'; +import type { CreatableProps as CreatableSelectProps } from 'react-select/creatable'; +import type { TextFieldProps } from 'src/components/TextField'; export interface Item { data?: any; diff --git a/packages/manager/src/components/EntityHeader/EntityHeader.stories.tsx b/packages/manager/src/components/EntityHeader/EntityHeader.stories.tsx index 795daa1010f..e1b05f120ad 100644 --- a/packages/manager/src/components/EntityHeader/EntityHeader.stories.tsx +++ b/packages/manager/src/components/EntityHeader/EntityHeader.stories.tsx @@ -40,32 +40,14 @@ export const Default: Story = { variant: 'h2', }, render: (args) => { - const sxActionItem = { - '&:hover': { - backgroundColor: '#3683dc', - color: '#fff', - }, - color: '#2575d0', - fontFamily: '"LatoWeb", sans-serif', - fontSize: '0.875rem', - height: '34px', - minWidth: 'auto', - }; - return ( Chip / Progress Go Here - - - + + + should highlight text consistently 1`] = `

Some markdown diff --git a/packages/manager/src/components/ImageSelect/ImageOption.tsx b/packages/manager/src/components/ImageSelect/ImageOption.tsx index 2180c9a9e81..b1da36086ea 100644 --- a/packages/manager/src/components/ImageSelect/ImageOption.tsx +++ b/packages/manager/src/components/ImageSelect/ImageOption.tsx @@ -1,15 +1,19 @@ import DescriptionOutlinedIcon from '@mui/icons-material/DescriptionOutlined'; -import { Theme } from '@mui/material/styles'; import * as React from 'react'; -import { OptionProps } from 'react-select'; import { makeStyles } from 'tss-react/mui'; +import DistributedRegionIcon from 'src/assets/icons/entityIcons/distributed-region.svg'; import { Box } from 'src/components/Box'; -import { Item } from 'src/components/EnhancedSelect'; import { Option } from 'src/components/EnhancedSelect/components/Option'; -import { TooltipIcon } from 'src/components/TooltipIcon'; import { useFlags } from 'src/hooks/useFlags'; +import { Stack } from '../Stack'; +import { Tooltip } from '../Tooltip'; + +import type { ImageItem } from './ImageSelect'; +import type { Theme } from '@mui/material/styles'; +import type { OptionProps } from 'react-select'; + const useStyles = makeStyles()((theme: Theme) => ({ distroIcon: { fontSize: '1.8em', @@ -33,8 +37,10 @@ const useStyles = makeStyles()((theme: Theme) => ({ '& g': { fill: theme.name === 'dark' ? 'white' : '#888f91', }, - display: 'flex', - padding: `2px !important`, // Revisit use of important when we refactor the Select component + display: 'flex !important', + flexDirection: 'row', + justifyContent: 'space-between', + padding: '2px 8px !important', // Revisit use of important when we refactor the Select component }, selected: { '& g': { @@ -43,11 +49,6 @@ const useStyles = makeStyles()((theme: Theme) => ({ }, })); -interface ImageItem extends Item { - className?: string; - isCloudInitCompatible: boolean; -} - interface ImageOptionProps extends OptionProps { data: ImageItem; } @@ -59,48 +60,32 @@ export const ImageOption = (props: ImageOptionProps) => { return ( ); }; - -const sxCloudInitTooltipIcon = { - '& svg': { - height: 20, - width: 20, - }, - '&:hover': { - color: 'inherit', - }, - color: 'inherit', - marginLeft: 'auto', - padding: 0, - paddingRight: 1, -}; diff --git a/packages/manager/src/components/ImageSelect/ImageSelect.test.tsx b/packages/manager/src/components/ImageSelect/ImageSelect.test.tsx index 25e88889795..664525d5383 100644 --- a/packages/manager/src/components/ImageSelect/ImageSelect.test.tsx +++ b/packages/manager/src/components/ImageSelect/ImageSelect.test.tsx @@ -34,6 +34,7 @@ describe('imagesToGroupedItems', () => { className: 'fl-tux', created: '2022-10-20T14:05:30', isCloudInitCompatible: false, + isDistributedCompatible: false, label: 'Slackware 14.1', value: 'private/4', }, @@ -41,6 +42,7 @@ describe('imagesToGroupedItems', () => { className: 'fl-tux', created: '2022-10-20T14:05:30', isCloudInitCompatible: false, + isDistributedCompatible: false, label: 'Slackware 14.1', value: 'private/5', }, @@ -72,6 +74,7 @@ describe('imagesToGroupedItems', () => { className: 'fl-tux', created: '2017-06-16T20:02:29', isCloudInitCompatible: false, + isDistributedCompatible: false, label: 'Debian 9 (deprecated)', value: 'private/6', }, @@ -79,6 +82,7 @@ describe('imagesToGroupedItems', () => { className: 'fl-tux', created: '2017-06-16T20:02:29', isCloudInitCompatible: false, + isDistributedCompatible: false, label: 'Debian 9 (deprecated)', value: 'private/7', }, diff --git a/packages/manager/src/components/ImageSelect/ImageSelect.tsx b/packages/manager/src/components/ImageSelect/ImageSelect.tsx index e4fbc8e9bac..5b320a4d7a7 100644 --- a/packages/manager/src/components/ImageSelect/ImageSelect.tsx +++ b/packages/manager/src/components/ImageSelect/ImageSelect.tsx @@ -21,10 +21,11 @@ import { distroIcons } from '../DistributionIcon'; export type Variant = 'all' | 'private' | 'public'; -interface ImageItem extends Item { +export interface ImageItem extends Item { className: string; created: string; isCloudInitCompatible: boolean; + isDistributedCompatible: boolean; } interface ImageSelectProps { @@ -111,6 +112,9 @@ export const imagesToGroupedItems = (images: Image[]) => { : `fl-tux`, created, isCloudInitCompatible: capabilities?.includes('cloud-init'), + isDistributedCompatible: capabilities?.includes( + 'distributed-images' + ), // Add suffix 'deprecated' to the image at end of life. label: differenceInMonths > 0 ? `${label} (deprecated)` : label, diff --git a/packages/manager/src/components/ImageSelectv2/ImageOptionv2.test.tsx b/packages/manager/src/components/ImageSelectv2/ImageOptionv2.test.tsx index f74e3570601..33923a9f889 100644 --- a/packages/manager/src/components/ImageSelectv2/ImageOptionv2.test.tsx +++ b/packages/manager/src/components/ImageSelectv2/ImageOptionv2.test.tsx @@ -36,4 +36,15 @@ describe('ImageOptionv2', () => { getByLabelText('This image is compatible with cloud-init.') ).toBeVisible(); }); + it('renders a distributed icon if image has the "distributed-images" capability', () => { + const image = imageFactory.build({ capabilities: ['distributed-images'] }); + + const { getByLabelText } = renderWithTheme( + + ); + + expect( + getByLabelText('This image is compatible with distributed regions.') + ).toBeVisible(); + }); }); diff --git a/packages/manager/src/components/ImageSelectv2/ImageOptionv2.tsx b/packages/manager/src/components/ImageSelectv2/ImageOptionv2.tsx index 4f38225e331..d8ceb098d02 100644 --- a/packages/manager/src/components/ImageSelectv2/ImageOptionv2.tsx +++ b/packages/manager/src/components/ImageSelectv2/ImageOptionv2.tsx @@ -1,6 +1,7 @@ import DescriptionOutlinedIcon from '@mui/icons-material/DescriptionOutlined'; import React from 'react'; +import DistributedRegionIcon from 'src/assets/icons/entityIcons/distributed-region.svg'; import { useFlags } from 'src/hooks/useFlags'; import { SelectedIcon } from '../Autocomplete/Autocomplete.styles'; @@ -21,15 +22,30 @@ export const ImageOptionv2 = ({ image, isSelected, listItemProps }: Props) => { const flags = useFlags(); return ( -
  • - +
  • + {image.label} - + + + {image.capabilities.includes('distributed-images') && ( + +
    + +
    +
    + )} {flags.metadata && image.capabilities.includes('cloud-init') && ( diff --git a/packages/manager/src/components/InlineMenuAction/InlineMenuAction.tsx b/packages/manager/src/components/InlineMenuAction/InlineMenuAction.tsx index 94e0f7d417f..dba7a9ae442 100644 --- a/packages/manager/src/components/InlineMenuAction/InlineMenuAction.tsx +++ b/packages/manager/src/components/InlineMenuAction/InlineMenuAction.tsx @@ -52,7 +52,7 @@ export const InlineMenuAction = (props: InlineMenuActionProps) => { = { export default meta; const StyledWrapper = styled('div')(({ theme }) => ({ - backgroundColor: theme.color.grey2, - padding: theme.spacing(2), })); diff --git a/packages/manager/src/components/Notice/Notice.test.tsx b/packages/manager/src/components/Notice/Notice.test.tsx index cf12ad28ca2..e7d536cd907 100644 --- a/packages/manager/src/components/Notice/Notice.test.tsx +++ b/packages/manager/src/components/Notice/Notice.test.tsx @@ -58,7 +58,7 @@ describe('Notice Component', () => { it('applies variant prop', () => { const { container } = renderWithTheme(); - expect(container.firstChild).toHaveStyle('border-left: 5px solid #ca0813;'); + expect(container.firstChild).toHaveStyle('border-left: 5px solid #d63c42;'); }); it('displays icon for important notices', () => { diff --git a/packages/manager/src/components/PrimaryNav/PrimaryNav.styles.ts b/packages/manager/src/components/PrimaryNav/PrimaryNav.styles.ts index c62e5c2d996..03912e910e5 100644 --- a/packages/manager/src/components/PrimaryNav/PrimaryNav.styles.ts +++ b/packages/manager/src/components/PrimaryNav/PrimaryNav.styles.ts @@ -1,8 +1,9 @@ -import { Theme } from '@mui/material/styles'; import { makeStyles } from 'tss-react/mui'; import { SIDEBAR_WIDTH } from 'src/components/PrimaryNav/SideMenu'; +import type { Theme } from '@mui/material/styles'; + const useStyles = makeStyles()( (theme: Theme, _params, classes) => ({ active: { @@ -10,7 +11,7 @@ const useStyles = makeStyles()( opacity: 1, }, '& svg': { - color: theme.color.teal, + color: theme.palette.success.dark, }, backgroundImage: 'linear-gradient(98deg, #38584B 1%, #3A5049 166%)', textDecoration: 'none', @@ -22,12 +23,6 @@ const useStyles = makeStyles()( backgroundColor: 'rgba(0, 0, 0, 0.12)', color: '#222', }, - fadeContainer: { - display: 'flex', - flexDirection: 'column', - height: 'calc(100% - 90px)', - width: '100%', - }, linkItem: { '&.hiddenWhenCollapsed': { maxHeight: 36, @@ -70,8 +65,8 @@ const useStyles = makeStyles()( opacity: 1, }, '& svg': { - color: theme.color.teal, - fill: theme.color.teal, + color: theme.palette.success.dark, + fill: theme.palette.success.dark, }, [`& .${classes.linkItem}`]: { color: 'white', @@ -86,7 +81,6 @@ const useStyles = makeStyles()( minWidth: SIDEBAR_WIDTH, padding: '8px 13px', position: 'relative', - transition: theme.transitions.create(['background-color']), }, logo: { '& .akamai-logo-name': { @@ -96,7 +90,7 @@ const useStyles = makeStyles()( transition: 'width .1s linear', }, logoAkamaiCollapsed: { - background: theme.bg.primaryNavPaper, + background: theme.bg.appBar, width: 83, }, logoContainer: { @@ -111,6 +105,7 @@ const useStyles = makeStyles()( }, logoItemAkamai: { alignItems: 'center', + backgroundColor: theme.name === 'dark' ? theme.bg.appBar : undefined, display: 'flex', height: 50, paddingLeft: 13, diff --git a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx index 4c7ec032ab0..180c6c5a32b 100644 --- a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx +++ b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx @@ -1,6 +1,6 @@ import Grid from '@mui/material/Unstable_Grid2'; import * as React from 'react'; -import { Link, LinkProps, useLocation } from 'react-router-dom'; +import { Link, useLocation } from 'react-router-dom'; import Account from 'src/assets/icons/account.svg'; import CloudPulse from 'src/assets/icons/cloudpulse.svg'; @@ -43,6 +43,8 @@ import { isFeatureEnabled } from 'src/utilities/accountCapabilities'; import useStyles from './PrimaryNav.styles'; import { linkIsActive } from './utils'; +import type { LinkProps } from 'react-router-dom'; + type NavEntity = | 'Account' | 'Account' @@ -343,7 +345,7 @@ export const PrimaryNav = (props: PrimaryNavProps) => { spacing={0} wrap="nowrap" > - + { -
    - {primaryLinkGroups.map((thisGroup, idx) => { - const filteredLinks = thisGroup.filter((thisLink) => !thisLink.hide); - if (filteredLinks.length === 0) { - return null; - } - return ( -
    - - {filteredLinks.map((thisLink) => { - const props = { - closeMenu, - isCollapsed, - key: thisLink.display, - locationPathname: location.pathname, - locationSearch: location.search, - ...thisLink, - }; - - // PrefetchPrimaryLink and PrimaryLink are two separate components because invocation of - // hooks cannot be conditional. is a wrapper around - // that includes the usePrefetch hook. - return thisLink.prefetchRequestFn && - thisLink.prefetchRequestCondition !== undefined ? ( - - ) : ( - - ); + + {primaryLinkGroups.map((thisGroup, idx) => { + const filteredLinks = thisGroup.filter((thisLink) => !thisLink.hide); + if (filteredLinks.length === 0) { + return null; + } + return ( +
    + ({ + borderColor: + theme.name === 'light' + ? theme.borderColors.dividerDark + : 'rgba(0, 0, 0, 0.19)', })} -
    - ); - })} -
    + className={classes.divider} + spacingBottom={11} + /> + {filteredLinks.map((thisLink) => { + const props = { + closeMenu, + isCollapsed, + key: thisLink.display, + locationPathname: location.pathname, + locationSearch: location.search, + ...thisLink, + }; + + // PrefetchPrimaryLink and PrimaryLink are two separate components because invocation of + // hooks cannot be conditional. is a wrapper around + // that includes the usePrefetch hook. + return thisLink.prefetchRequestFn && + thisLink.prefetchRequestCondition !== undefined ? ( + + ) : ( + + ); + })} +
    + ); + })}
    ); }; diff --git a/packages/manager/src/components/PrimaryNav/SideMenu.tsx b/packages/manager/src/components/PrimaryNav/SideMenu.tsx index e901df9883c..5be1241600f 100644 --- a/packages/manager/src/components/PrimaryNav/SideMenu.tsx +++ b/packages/manager/src/components/PrimaryNav/SideMenu.tsx @@ -66,7 +66,8 @@ const StyledDrawer = styled(Drawer, { shouldForwardProp: (prop) => prop !== 'collapse', })<{ collapse?: boolean }>(({ theme, ...props }) => ({ '& .MuiDrawer-paper': { - backgroundColor: theme.bg.primaryNavPaper, + backgroundColor: + theme.name === 'dark' ? theme.bg.appBar : theme.bg.primaryNavPaper, borderRight: 'none', boxShadow: 'none', height: '100%', diff --git a/packages/manager/src/components/RegionSelect/RegionSelect.tsx b/packages/manager/src/components/RegionSelect/RegionSelect.tsx index 6c1fbef0d0c..533201a3fa3 100644 --- a/packages/manager/src/components/RegionSelect/RegionSelect.tsx +++ b/packages/manager/src/components/RegionSelect/RegionSelect.tsx @@ -70,7 +70,9 @@ export const RegionSelect = < regions, }); - const selectedRegion = regionOptions.find((r) => r.id === value) ?? null; + const selectedRegion = value + ? regionOptions.find((r) => r.id === value) + : null; const disabledRegions = regionOptions.reduce< Record diff --git a/packages/manager/src/components/RegionSelect/RegionSelect.types.ts b/packages/manager/src/components/RegionSelect/RegionSelect.types.ts index d2dfbe23f96..83413b4bae0 100644 --- a/packages/manager/src/components/RegionSelect/RegionSelect.types.ts +++ b/packages/manager/src/components/RegionSelect/RegionSelect.types.ts @@ -1,11 +1,10 @@ -import React from 'react'; - import type { AccountAvailability, Capabilities, Region, RegionSite, } from '@linode/api-v4'; +import type React from 'react'; import type { EnhancedAutocompleteProps } from 'src/components/Autocomplete/Autocomplete'; export interface DisableRegionOption { @@ -49,7 +48,7 @@ export interface RegionSelectProps< /** * The ID of the selected region. */ - value: null | string; + value: string | undefined; width?: number; } diff --git a/packages/manager/src/components/SelectRegionPanel/SelectRegionPanel.tsx b/packages/manager/src/components/SelectRegionPanel/SelectRegionPanel.tsx index 3d230d03b77..4c53d1cf9e2 100644 --- a/packages/manager/src/components/SelectRegionPanel/SelectRegionPanel.tsx +++ b/packages/manager/src/components/SelectRegionPanel/SelectRegionPanel.tsx @@ -1,4 +1,3 @@ -import { Capabilities } from '@linode/api-v4/lib/regions'; import { useTheme } from '@mui/material'; import * as React from 'react'; import { useLocation } from 'react-router-dom'; @@ -25,8 +24,9 @@ import { getQueryParamsFromQueryString } from 'src/utilities/queryParams'; import { Box } from '../Box'; import { DocsLink } from '../DocsLink/DocsLink'; import { Link } from '../Link'; -import { RegionSelectProps } from '../RegionSelect/RegionSelect.types'; +import type { RegionSelectProps } from '../RegionSelect/RegionSelect.types'; +import type { Capabilities } from '@linode/api-v4/lib/regions'; import type { LinodeCreateType } from 'src/features/Linodes/LinodesCreate/types'; interface SelectRegionPanelProps { @@ -158,7 +158,7 @@ export const SelectRegionPanel = (props: SelectRegionPanelProps) => { onChange={(e, region) => handleSelection(region.id)} regionFilter={hideDistributedRegions ? 'core' : undefined} regions={regions ?? []} - value={selectedId || null} + value={selectedId} {...RegionSelectProps} /> {showClonePriceWarning && ( diff --git a/packages/manager/src/components/SelectionCard/CardBase.styles.ts b/packages/manager/src/components/SelectionCard/CardBase.styles.ts index b07fe04d202..8c06a76beee 100644 --- a/packages/manager/src/components/SelectionCard/CardBase.styles.ts +++ b/packages/manager/src/components/SelectionCard/CardBase.styles.ts @@ -1,5 +1,5 @@ -import Grid from '@mui/material/Unstable_Grid2'; import { styled } from '@mui/material/styles'; +import Grid from '@mui/material/Unstable_Grid2'; import type { CardBaseProps } from './CardBase'; @@ -18,15 +18,25 @@ export const CardBaseGrid = styled(Grid, { width: 5, }, '&:hover': { - backgroundColor: props.checked ? theme.bg.lightBlue2 : theme.bg.main, + backgroundColor: props.checked + ? theme.name === 'dark' + ? `rgba(0, 49, 77, .2)` + : `rgba(1, 116, 188, .2)` + : theme.bg.interactionBgPrimary, borderColor: props.checked ? theme.palette.primary.main - : theme.color.border2, + : theme.borderColors.borderHover, }, alignItems: 'center', - backgroundColor: props.checked ? theme.bg.lightBlue2 : theme.bg.offWhite, + backgroundColor: props.checked + ? theme.name === 'dark' + ? `rgba(0, 49, 77, .2)` + : `rgba(1, 116, 188, .2)` + : theme.bg.interactionBgPrimary, border: `1px solid ${theme.bg.main}`, - borderColor: props.checked ? theme.palette.primary.main : undefined, + borderColor: props.checked + ? theme.palette.primary.main + : theme.borderColors.divider, height: '100%', margin: 0, minHeight: 60, diff --git a/packages/manager/src/components/Snackbar/Snackbar.tsx b/packages/manager/src/components/Snackbar/Snackbar.tsx index 6bebab056c2..3a02f8ad24d 100644 --- a/packages/manager/src/components/Snackbar/Snackbar.tsx +++ b/packages/manager/src/components/Snackbar/Snackbar.tsx @@ -1,25 +1,30 @@ import { styled } from '@mui/material/styles'; -import { SnackbarProvider, SnackbarProviderProps } from 'notistack'; import { MaterialDesignContent } from 'notistack'; +import { SnackbarProvider } from 'notistack'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; import { CloseSnackbar } from './CloseSnackbar'; import type { Theme } from '@mui/material/styles'; +import type { SnackbarProviderProps } from 'notistack'; const StyledMaterialDesignContent = styled(MaterialDesignContent)( ({ theme }: { theme: Theme }) => ({ '&.notistack-MuiContent-error': { + backgroundColor: theme.palette.error.light, borderLeft: `6px solid ${theme.palette.error.dark}`, }, '&.notistack-MuiContent-info': { + backgroundColor: theme.palette.info.light, borderLeft: `6px solid ${theme.palette.primary.main}`, }, '&.notistack-MuiContent-success': { - borderLeft: `6px solid ${theme.palette.success.main}`, // corrected to palette.success + backgroundColor: theme.palette.success.light, + borderLeft: `6px solid ${theme.palette.success.dark}`, }, '&.notistack-MuiContent-warning': { + backgroundColor: theme.palette.warning.light, borderLeft: `6px solid ${theme.palette.warning.dark}`, }, }) @@ -28,7 +33,7 @@ const StyledMaterialDesignContent = styled(MaterialDesignContent)( const useStyles = makeStyles()((theme: Theme) => ({ root: { '& div': { - backgroundColor: `${theme.bg.white} !important`, + backgroundColor: `transparent`, color: theme.palette.text.primary, fontSize: '0.875rem', }, diff --git a/packages/manager/src/components/StatusIcon/StatusIcon.tsx b/packages/manager/src/components/StatusIcon/StatusIcon.tsx index fab623fe350..54d84bfee4f 100644 --- a/packages/manager/src/components/StatusIcon/StatusIcon.tsx +++ b/packages/manager/src/components/StatusIcon/StatusIcon.tsx @@ -53,16 +53,16 @@ const StyledDiv = styled(Box, { transition: theme.transitions.create(['color']), width: '16px', ...(props.status === 'active' && { - backgroundColor: theme.color.teal, + backgroundColor: theme.palette.success.dark, }), ...(props.status === 'inactive' && { backgroundColor: theme.color.grey8, }), ...(props.status === 'error' && { - backgroundColor: theme.color.red, + backgroundColor: theme.palette.error.dark, }), ...(!['active', 'error', 'inactive'].includes(props.status) && { - backgroundColor: theme.color.orange, + backgroundColor: theme.palette.warning.dark, }), ...(props.pulse && { animation: 'pulse 1.5s ease-in-out infinite', diff --git a/packages/manager/src/components/Table/Table.styles.ts b/packages/manager/src/components/Table/Table.styles.ts index f6b70032f91..87318bf2aaf 100644 --- a/packages/manager/src/components/Table/Table.styles.ts +++ b/packages/manager/src/components/Table/Table.styles.ts @@ -26,11 +26,9 @@ export const StyledTableWrapper = styled('div', { borderRight: 'none', }, backgroundColor: theme.bg.tableHeader, - borderBottom: `2px solid ${theme.borderColors.borderTable}`, - borderLeft: `1px solid ${theme.borderColors.borderTable}`, + borderBottom: `1px solid ${theme.borderColors.borderTable}`, borderRight: `1px solid ${theme.borderColors.borderTable}`, - borderTop: `2px solid ${theme.borderColors.borderTable}`, - color: theme.textColors.tableHeader, + borderTop: `1px solid ${theme.borderColors.borderTable}`, fontFamily: theme.font.bold, padding: '10px 15px', }, @@ -43,11 +41,4 @@ export const StyledTableWrapper = styled('div', { border: 0, }, }), - ...(props.rowHoverState && { - '& tbody tr': { - '&:hover': { - backgroundColor: theme.bg.lightBlue1, - }, - }, - }), })); diff --git a/packages/manager/src/components/TableRow/TableRow.styles.ts b/packages/manager/src/components/TableRow/TableRow.styles.ts index 78fc55f09a4..745ff361b3c 100644 --- a/packages/manager/src/components/TableRow/TableRow.styles.ts +++ b/packages/manager/src/components/TableRow/TableRow.styles.ts @@ -9,9 +9,6 @@ export const StyledTableRow = styled(_TableRow, { label: 'StyledTableRow', shouldForwardProp: omittedProps(['forceIndex']), })(({ theme, ...props }) => ({ - backgroundColor: theme.bg.bgPaper, - borderLeft: `1px solid ${theme.borderColors.borderTable}`, - borderRight: `1px solid ${theme.borderColors.borderTable}`, [theme.breakpoints.up('md')]: { boxShadow: `inset 3px 0 0 transparent`, }, @@ -38,14 +35,14 @@ export const StyledTableRow = styled(_TableRow, { ...(props.selected && { '& td': { '&:first-of-type': { - borderLeft: `1px solid ${theme.palette.primary.light}`, + borderLeft: `1px solid ${theme.borderColors.borderTable}`, }, - borderBottomColor: theme.palette.primary.light, - borderTop: `1px solid ${theme.palette.primary.light}`, + borderBottomColor: theme.borderColors.borderTable, + borderTop: `1px solid ${theme.borderColors.borderTable}`, position: 'relative', [theme.breakpoints.down('lg')]: { '&:last-child': { - borderRight: `1px solid ${theme.palette.primary.light}`, + borderRight: `1px solid ${theme.borderColors.borderTable}`, }, }, }, diff --git a/packages/manager/src/components/TableSortCell/TableSortCell.tsx b/packages/manager/src/components/TableSortCell/TableSortCell.tsx index 8cd56dd0452..0928cc1787b 100644 --- a/packages/manager/src/components/TableSortCell/TableSortCell.tsx +++ b/packages/manager/src/components/TableSortCell/TableSortCell.tsx @@ -18,7 +18,6 @@ const useStyles = makeStyles()((theme: Theme) => ({ marginRight: 4, }, label: { - color: theme.textColors.tableHeader, fontSize: '.875rem', minHeight: 20, transition: 'none', diff --git a/packages/manager/src/components/Tabs/Tab.test.tsx b/packages/manager/src/components/Tabs/Tab.test.tsx index 38736410cdb..6463053b864 100644 --- a/packages/manager/src/components/Tabs/Tab.test.tsx +++ b/packages/manager/src/components/Tabs/Tab.test.tsx @@ -20,7 +20,7 @@ describe('Tab Component', () => { expect(tabElement).toHaveStyle(` display: inline-flex; - color: rgb(54, 131, 220); + color: rgb(0, 156, 222); `); }); diff --git a/packages/manager/src/components/Tabs/Tab.tsx b/packages/manager/src/components/Tabs/Tab.tsx index c940218ba07..ea65565187c 100644 --- a/packages/manager/src/components/Tabs/Tab.tsx +++ b/packages/manager/src/components/Tabs/Tab.tsx @@ -11,7 +11,7 @@ const useStyles = makeStyles()((theme: Theme) => ({ }, '&:hover': { backgroundColor: theme.color.grey7, - color: theme.palette.primary.main, + color: theme.textColors.linkHover, }, alignItems: 'center', borderBottom: '2px solid transparent', @@ -29,7 +29,7 @@ const useStyles = makeStyles()((theme: Theme) => ({ }, '&[data-reach-tab][data-selected]': { '&:hover': { - color: theme.palette.primary.main, + color: theme.textColors.linkHover, }, borderBottom: `3px solid ${theme.textColors.linkActiveLight}`, color: theme.textColors.headlineStatic, diff --git a/packages/manager/src/components/Tabs/TabList.tsx b/packages/manager/src/components/Tabs/TabList.tsx index 16a12a41296..0ae8e8a8714 100644 --- a/packages/manager/src/components/Tabs/TabList.tsx +++ b/packages/manager/src/components/Tabs/TabList.tsx @@ -23,9 +23,7 @@ export { TabList }; const StyledReachTabList = styled(ReachTabList)(({ theme }) => ({ '&[data-reach-tab-list]': { background: 'none !important', - boxShadow: `inset 0 -1px 0 ${ - theme.name === 'light' ? '#e3e5e8' : '#2e3238' - }`, + boxShadow: `inset 0 -1px 0 ${theme.borderColors.divider}`, marginBottom: theme.spacing(), [theme.breakpoints.down('lg')]: { overflowX: 'auto', diff --git a/packages/manager/src/components/Tabs/__snapshots__/TabList.test.tsx.snap b/packages/manager/src/components/Tabs/__snapshots__/TabList.test.tsx.snap index 947542f4e9b..7c5d66fe7d1 100644 --- a/packages/manager/src/components/Tabs/__snapshots__/TabList.test.tsx.snap +++ b/packages/manager/src/components/Tabs/__snapshots__/TabList.test.tsx.snap @@ -8,7 +8,7 @@ exports[`TabList component > renders TabList correctly 1`] = ` >
    diff --git a/packages/manager/src/components/Tag/Tag.styles.ts b/packages/manager/src/components/Tag/Tag.styles.ts index a54f9b67755..74ab54e1dd9 100644 --- a/packages/manager/src/components/Tag/Tag.styles.ts +++ b/packages/manager/src/components/Tag/Tag.styles.ts @@ -16,7 +16,6 @@ export const StyledChip = styled(Chip, { borderTopRightRadius: props.onDelete && 0, }, borderRadius: 4, - color: theme.name === 'light' ? '#3a3f46' : '#fff', fontFamily: theme.font.normal, maxWidth: 350, padding: '7px 10px', @@ -32,18 +31,19 @@ export const StyledChip = styled(Chip, { ['& .StyledDeleteButton']: { color: theme.color.tagIcon, }, - backgroundColor: theme.color.tagButton, + backgroundColor: theme.color.tagButtonBg, }, // Overrides MUI chip default styles so these appear as separate elements. '&:hover': { ['& .StyledDeleteButton']: { color: theme.color.tagIcon, }, - backgroundColor: theme.color.tagButton, + backgroundColor: theme.color.tagButtonBg, }, fontSize: '0.875rem', height: 30, padding: 0, + transition: 'none', ...(props.colorVariant === 'blue' && { '& > span': { '&:hover, &:focus': { @@ -58,15 +58,16 @@ export const StyledChip = styled(Chip, { ...(props.colorVariant === 'lightBlue' && { '& > span': { '&:focus': { - backgroundColor: theme.color.tagButton, - color: theme.color.black, + backgroundColor: theme.color.tagButtonBg, + color: theme.color.white, }, '&:hover': { - backgroundColor: theme.palette.primary.main, - color: 'white', + backgroundColor: theme.color.tagButtonBgHover, + color: theme.color.tagButtonTextHover, }, }, - backgroundColor: theme.color.tagButton, + backgroundColor: theme.color.tagButtonBg, + color: theme.color.tagButtonText, }), })); @@ -85,10 +86,9 @@ export const StyledDeleteButton = styled(StyledLinkButton, { }, '&:hover': { '& svg': { - color: 'white', + color: theme.color.tagIconHover, }, - backgroundColor: theme.palette.primary.main, - color: 'white', + backgroundColor: theme.color.buttonPrimaryHover, }, borderBottomRightRadius: 3, borderLeft: `1px solid ${theme.name === 'light' ? '#fff' : '#2e3238'}`, diff --git a/packages/manager/src/components/TagCell/TagCell.test.tsx b/packages/manager/src/components/TagCell/TagCell.test.tsx new file mode 100644 index 00000000000..63bfc371a12 --- /dev/null +++ b/packages/manager/src/components/TagCell/TagCell.test.tsx @@ -0,0 +1,41 @@ +import { fireEvent, screen, waitFor } from '@testing-library/react'; +import React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { TagCell } from './TagCell'; + +describe('TagCell Component', () => { + const tags = ['tag1', 'tag2']; + const updateTags = vi.fn(() => Promise.resolve()); + + describe('Disabled States', () => { + it('does not allow adding a new tag when disabled', async () => { + const { getByTestId } = renderWithTheme( + + ); + const disabledButton = getByTestId('Button'); + expect(disabledButton).toHaveAttribute('aria-disabled', 'true'); + }); + + it('should display the tooltip if disabled and tooltipText is true', async () => { + const { getByTestId } = renderWithTheme( + + ); + const disabledButton = getByTestId('Button'); + expect(disabledButton).toBeInTheDocument(); + + fireEvent.mouseOver(disabledButton); + + await waitFor(() => { + expect(screen.getByRole('tooltip')).toBeInTheDocument(); + }); + + expect( + screen.getByText( + 'You must be an unrestricted User in order to add or modify tags on Linodes.' + ) + ).toBeVisible(); + }); + }); +}); diff --git a/packages/manager/src/components/TagCell/TagCell.tsx b/packages/manager/src/components/TagCell/TagCell.tsx index 7340707d0a9..c1433e9bd9e 100644 --- a/packages/manager/src/components/TagCell/TagCell.tsx +++ b/packages/manager/src/components/TagCell/TagCell.tsx @@ -1,7 +1,6 @@ import MoreHoriz from '@mui/icons-material/MoreHoriz'; import { styled } from '@mui/material/styles'; import Grid from '@mui/material/Unstable_Grid2'; -import { SxProps } from '@mui/system'; import * as React from 'react'; import { IconButton } from 'src/components/IconButton'; @@ -13,6 +12,8 @@ import { StyledPlusIcon, StyledTagButton } from '../Button/StyledTagButton'; import { CircleProgress } from '../CircleProgress'; import { AddTag } from './AddTag'; +import type { SxProps } from '@mui/system'; + export interface TagCellProps { /** * Disable adding or deleting tags. @@ -83,6 +84,11 @@ export const TagCell = (props: TagCellProps) => { const AddButton = (props: { panel?: boolean }) => ( } @@ -219,7 +225,7 @@ const StyledIconButton = styled(IconButton)(({ theme }) => ({ backgroundColor: theme.palette.primary.main, color: '#ffff', }, - backgroundColor: theme.color.tagButton, + backgroundColor: theme.color.tagButtonBg, borderRadius: 0, color: theme.color.tagIcon, height: 30, diff --git a/packages/manager/src/components/TextTooltip/TextTooltip.test.tsx b/packages/manager/src/components/TextTooltip/TextTooltip.test.tsx index 849a034e535..301c5787ca5 100644 --- a/packages/manager/src/components/TextTooltip/TextTooltip.test.tsx +++ b/packages/manager/src/components/TextTooltip/TextTooltip.test.tsx @@ -56,7 +56,7 @@ describe('TextTooltip', () => { const displayText = getByText(props.displayText); - expect(displayText).toHaveStyle('color: rgb(54, 131, 220)'); + expect(displayText).toHaveStyle('color: rgb(0, 156, 222)'); expect(displayText).toHaveStyle('font-size: 18px'); }); diff --git a/packages/manager/src/components/TextTooltip/TextTooltip.tsx b/packages/manager/src/components/TextTooltip/TextTooltip.tsx index e480ea3f0c6..25e43179479 100644 --- a/packages/manager/src/components/TextTooltip/TextTooltip.tsx +++ b/packages/manager/src/components/TextTooltip/TextTooltip.tsx @@ -69,6 +69,7 @@ export const TextTooltip = (props: TextTooltipProps) => { data-qa-tooltip={dataQaTooltip} enterTouchDelay={0} placement={placement ? placement : 'bottom'} + tabIndex={0} title={tooltipText} > @@ -81,10 +82,13 @@ export const TextTooltip = (props: TextTooltipProps) => { const StyledRootTooltip = styled(Tooltip, { label: 'StyledRootTooltip', })(({ theme }) => ({ + '&:hover': { + color: theme.textColors.linkHover, + }, borderRadius: 4, - color: theme.palette.primary.main, + color: theme.textColors.linkActiveLight, cursor: 'pointer', position: 'relative', - textDecoration: `underline dotted ${theme.palette.primary.main}`, + textDecoration: `underline dotted ${theme.textColors.linkActiveLight}`, textUnderlineOffset: 4, })); diff --git a/packages/manager/src/components/Tile/Tile.styles.ts b/packages/manager/src/components/Tile/Tile.styles.ts index ddeb5994f3f..a1a26d525ea 100644 --- a/packages/manager/src/components/Tile/Tile.styles.ts +++ b/packages/manager/src/components/Tile/Tile.styles.ts @@ -15,8 +15,8 @@ export const useStyles = makeStyles()( }, card: { alignItems: 'center', - backgroundColor: theme.color.white, - border: `1px solid ${theme.color.grey2}`, + backgroundColor: theme.bg.bgPaper, + border: `1px solid ${theme.borderColors.divider}`, display: 'flex', flexDirection: 'column', height: '100%', @@ -51,7 +51,7 @@ export const useStyles = makeStyles()( icon: { '& .insidePath': { fill: 'none', - stroke: '#3683DC', + stroke: theme.palette.primary.main, strokeLinejoin: 'round', strokeWidth: 1.25, }, diff --git a/packages/manager/src/components/TooltipIcon.tsx b/packages/manager/src/components/TooltipIcon.tsx index 0977bf75fb9..fc982c1a1b6 100644 --- a/packages/manager/src/components/TooltipIcon.tsx +++ b/packages/manager/src/components/TooltipIcon.tsx @@ -110,16 +110,16 @@ export const TooltipIcon = (props: TooltipIconProps) => { const sxRootStyle = { '&&': { - fill: '#888f91', - stroke: '#888f91', + fill: theme.color.grey4, + stroke: theme.color.grey4, strokeWidth: 0, }, '&:hover': { - color: '#3683dc', - fill: '#3683dc', - stroke: '#3683dc', + color: theme.palette.primary.main, + fill: theme.palette.primary.main, + stroke: theme.palette.primary.main, }, - color: '#888f91', + color: theme.color.grey4, height: 20, width: 20, }; diff --git a/packages/manager/src/dev-tools/FeatureFlagTool.tsx b/packages/manager/src/dev-tools/FeatureFlagTool.tsx index 4651e8d2fe6..03aa62060a2 100644 --- a/packages/manager/src/dev-tools/FeatureFlagTool.tsx +++ b/packages/manager/src/dev-tools/FeatureFlagTool.tsx @@ -3,11 +3,12 @@ import * as React from 'react'; import { useDispatch } from 'react-redux'; import withFeatureFlagProvider from 'src/containers/withFeatureFlagProvider.container'; -import { FlagSet, Flags } from 'src/featureFlags'; -import { Dispatch } from 'src/hooks/types'; import { useFlags } from 'src/hooks/useFlags'; import { setMockFeatureFlags } from 'src/store/mockFeatureFlags'; import { getStorage, setStorage } from 'src/utilities/storage'; + +import type { FlagSet, Flags } from 'src/featureFlags'; +import type { Dispatch } from 'src/hooks/types'; const MOCK_FEATURE_FLAGS_STORAGE_KEY = 'devTools/mock-feature-flags'; /** @@ -27,6 +28,7 @@ const options: { flag: keyof Flags; label: string; desc?: string }[] = [ { flag: 'disableLargestGbPlans', label: 'Disable Largest GB Plans' }, { flag: 'eventMessagesV2', label: 'Event Messages V2' }, { flag: 'gecko2', label: 'Gecko' }, + { flag: 'imageServiceGen2', label: 'Image Service Gen2' }, { flag: 'linodeCreateRefactor', label: 'Linode Create v2' }, { flag: 'linodeDiskEncryption', label: 'Linode Disk Encryption (LDE)' }, { flag: 'objMultiCluster', label: 'OBJ Multi-Cluster' }, diff --git a/packages/manager/src/factories/longviewDisks.ts b/packages/manager/src/factories/longviewDisks.ts index 35d77269cb6..5a5dc8ca1fc 100644 --- a/packages/manager/src/factories/longviewDisks.ts +++ b/packages/manager/src/factories/longviewDisks.ts @@ -1,11 +1,37 @@ import * as Factory from 'factory.ts'; -import { Disk, LongviewDisk } from 'src/features/Longview/request.types'; +import { + Disk, + LongviewDisk, + LongviewCPU, + CPU, + LongviewSystemInfo, + LongviewNetworkInterface, + InboundOutboundNetwork, + LongviewNetwork, + LongviewMemory, + LongviewLoad, + Uptime, +} from 'src/features/Longview/request.types'; const mockStats = [ - { x: 0, y: 1 }, - { x: 0, y: 2 }, - { x: 0, y: 3 }, + { x: 1717770900, y: 0 }, + { x: 1717770900, y: 20877.4637037037 }, + { x: 1717770900, y: 4.09420479302832 }, + { x: 1717770900, y: 83937959936 }, + { x: 1717770900, y: 5173267 }, + { x: 1717770900, y: 5210112 }, + { x: 1717770900, y: 82699642934.6133 }, + { x: 1717770900, y: 0.0372984749455338 }, + { x: 1717770900, y: 0.00723311546840959 }, + { x: 1717770900, y: 0.0918300653594771 }, + { x: 1717770900, y: 466.120718954248 }, + { x: 1717770900, y: 451.9651416122 }, + { x: 1717770900, y: 524284 }, + { x: 1717770900, y: 547242.706666667 }, + { x: 1717770900, y: 3466265.29333333 }, + { x: 1717770900, y: 57237.6133333333 }, + { x: 1717770900, y: 365385.893333333 }, ]; export const diskFactory = Factory.Sync.makeFactory({ @@ -14,8 +40,23 @@ export const diskFactory = Factory.Sync.makeFactory({ dm: 0, isswap: 0, mounted: 1, - reads: mockStats, - writes: mockStats, + reads: [mockStats[0]], + write_bytes: [mockStats[1]], + writes: [mockStats[2]], + fs: { + total: [mockStats[3]], + ifree: [mockStats[4]], + itotal: [mockStats[5]], + path: '/', + free: [mockStats[6]], + }, + read_bytes: [mockStats[0]], +}); + +export const cpuFactory = Factory.Sync.makeFactory({ + system: [mockStats[7]], + wait: [mockStats[8]], + user: [mockStats[9]], }); export const longviewDiskFactory = Factory.Sync.makeFactory({ @@ -24,3 +65,75 @@ export const longviewDiskFactory = Factory.Sync.makeFactory({ '/dev/sdb': diskFactory.build({ isswap: 1 }), }, }); + +export const longviewCPUFactory = Factory.Sync.makeFactory({ + CPU: { + cpu0: cpuFactory.build(), + cpu1: cpuFactory.build(), + }, +}); + +export const longviewSysInfoFactory = Factory.Sync.makeFactory( + { + SysInfo: { + arch: 'x86_64', + client: '1.1.5', + cpu: { + cores: 2, + type: 'AMD EPYC 7713 64-Core Processor', + }, + hostname: 'localhost', + kernel: 'Linux 5.10.0-28-amd64', + os: { + dist: 'Debian', + distversion: '11.9', + }, + type: 'kvm', + }, + } +); + +export const InboundOutboundNetworkFactory = Factory.Sync.makeFactory( + { + rx_bytes: [mockStats[10]], + tx_bytes: [mockStats[11]], + } +); + +export const LongviewNetworkInterfaceFactory = Factory.Sync.makeFactory( + { + eth0: InboundOutboundNetworkFactory.build(), + } +); + +export const longviewNetworkFactory = Factory.Sync.makeFactory( + { + Network: { + Interface: LongviewNetworkInterfaceFactory.build(), + mac_addr: 'f2:3c:94:e6:81:e2', + }, + } +); + +export const LongviewMemoryFactory = Factory.Sync.makeFactory({ + Memory: { + swap: { + free: [mockStats[12]], + used: [mockStats[0]], + }, + real: { + used: [mockStats[13]], + free: [mockStats[14]], + buffers: [mockStats[15]], + cache: [mockStats[16]], + }, + }, +}); + +export const LongviewLoadFactory = Factory.Sync.makeFactory({ + Load: [mockStats[0]], +}); + +export const UptimeFactory = Factory.Sync.makeFactory({ + uptime: 84516.53, +}); diff --git a/packages/manager/src/factories/longviewResponse.ts b/packages/manager/src/factories/longviewResponse.ts index 315fad71bff..fa992343ae9 100644 --- a/packages/manager/src/factories/longviewResponse.ts +++ b/packages/manager/src/factories/longviewResponse.ts @@ -1,14 +1,58 @@ import * as Factory from 'factory.ts'; import { LongviewResponse } from 'src/features/Longview/request.types'; +import { AllData, LongviewPackage } from 'src/features/Longview/request.types'; -import { longviewDiskFactory } from './longviewDisks'; +import { + longviewDiskFactory, + longviewCPUFactory, + longviewSysInfoFactory, + longviewNetworkFactory, + LongviewMemoryFactory, + LongviewLoadFactory, + UptimeFactory, +} from './longviewDisks'; + +const longviewResponseData = () => { + const diskData = longviewDiskFactory.build(); + const cpuData = longviewCPUFactory.build(); + const sysinfoData = longviewSysInfoFactory.build(); + const networkData = longviewNetworkFactory.build(); + const memoryData = LongviewMemoryFactory.build(); + const loadData = LongviewLoadFactory.build(); + const uptimeData = UptimeFactory.build(); + + return { + ...diskData, + ...cpuData, + ...sysinfoData, + ...networkData, + ...memoryData, + ...loadData, + ...uptimeData, + }; +}; export const longviewResponseFactory = Factory.Sync.makeFactory( { - ACTION: 'getValues', - DATA: longviewDiskFactory.build(), + ACTION: 'getLatestValue', + DATA: {}, NOTIFICATIONS: [], VERSION: 0.4, } ); + +export const longviewLatestStatsFactory = Factory.Sync.makeFactory< + Partial +>({ + ...longviewResponseData(), +}); + +export const longviewPackageFactory = Factory.Sync.makeFactory( + { + current: Factory.each((i) => `${i + 1}.${i + 2}.${i + 3}`), + held: 0, + name: Factory.each((i) => `mock-package-${i}`), + new: Factory.each((i) => `${i + 1}.${i + 2}.${i + 3}`), + } +); diff --git a/packages/manager/src/factories/types.ts b/packages/manager/src/factories/types.ts index b184dcbbbf5..192c8219ec2 100644 --- a/packages/manager/src/factories/types.ts +++ b/packages/manager/src/factories/types.ts @@ -173,6 +173,43 @@ export const volumeTypeFactory = Factory.Sync.makeFactory({ transfer: 0, }); +export const lkeStandardAvailabilityTypeFactory = Factory.Sync.makeFactory( + { + id: 'lke-sa', + label: 'LKE Standard Availability', + price: { + hourly: 0.0, + monthly: 0.0, + }, + region_prices: [], + transfer: 0, + } +); + +export const lkeHighAvailabilityTypeFactory = Factory.Sync.makeFactory( + { + id: 'lke-ha', + label: 'LKE High Availability', + price: { + hourly: 0.09, + monthly: 60.0, + }, + region_prices: [ + { + hourly: 0.108, + id: 'id-cgk', + monthly: 72.0, + }, + { + hourly: 0.126, + id: 'br-gru', + monthly: 84.0, + }, + ], + transfer: 0, + } +); + export const objectStorageTypeFactory = Factory.Sync.makeFactory({ id: 'objectstorage', label: 'Object Storage', diff --git a/packages/manager/src/featureFlags.ts b/packages/manager/src/featureFlags.ts index 811f3b66097..07a945affe9 100644 --- a/packages/manager/src/featureFlags.ts +++ b/packages/manager/src/featureFlags.ts @@ -73,6 +73,7 @@ export interface Flags { gecko: boolean; // @TODO gecko: delete this after next release gecko2: GaFeatureFlag; gpuv2: gpuV2; + imageServiceGen2: boolean; ipv6Sharing: boolean; linodeCreateRefactor: boolean; linodeCreateWithFirewall: boolean; diff --git a/packages/manager/src/features/Billing/BillingPanels/BillingActivityPanel/BillingActivityPanel.tsx b/packages/manager/src/features/Billing/BillingPanels/BillingActivityPanel/BillingActivityPanel.tsx index c0891c7d83d..9a7235d8729 100644 --- a/packages/manager/src/features/Billing/BillingPanels/BillingActivityPanel/BillingActivityPanel.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/BillingActivityPanel/BillingActivityPanel.tsx @@ -4,7 +4,7 @@ import { Payment, getInvoiceItems, } from '@linode/api-v4/lib/account'; -import { Theme } from '@mui/material/styles'; +import { Theme, styled } from '@mui/material/styles'; import Grid from '@mui/material/Unstable_Grid2'; import { DateTime } from 'luxon'; import * as React from 'react'; @@ -335,9 +335,11 @@ export const BillingActivityPanel = (props: Props) => { }, [selectedTransactionType, combinedData]); return ( - +
    -
    + {`${isAkamaiCustomer ? 'Usage' : 'Billing & Payment'} History`} @@ -397,7 +399,7 @@ export const BillingActivityPanel = (props: Props) => { />
    -
    + { ); }; +const StyledBillingAndPaymentHistoryHeader = styled('div', { + name: 'BillingAndPaymentHistoryHeader', +})(({ theme }) => ({ + border: theme.name === 'dark' ? `1px solid ${theme.borderColors.divider}` : 0, + borderBottom: 0, +})); + // ============================================================================= // // ============================================================================= diff --git a/packages/manager/src/features/Billing/InvoiceDetail/InvoiceTable.tsx b/packages/manager/src/features/Billing/InvoiceDetail/InvoiceTable.tsx index cfe7c4a3cad..e633213429d 100644 --- a/packages/manager/src/features/Billing/InvoiceDetail/InvoiceTable.tsx +++ b/packages/manager/src/features/Billing/InvoiceDetail/InvoiceTable.tsx @@ -1,8 +1,6 @@ import { InvoiceItem } from '@linode/api-v4/lib/account'; import { APIError } from '@linode/api-v4/lib/types'; -import { Theme } from '@mui/material/styles'; import * as React from 'react'; -import { makeStyles } from 'tss-react/mui'; import { Currency } from 'src/components/Currency'; import { DateTimeDisplay } from 'src/components/DateTimeDisplay'; @@ -21,18 +19,6 @@ import { useRegionsQuery } from 'src/queries/regions/regions'; import { getInvoiceRegion } from '../PdfGenerator/utils'; -const useStyles = makeStyles()((theme: Theme) => ({ - table: { - '& thead th': { - '&:last-of-type': { - paddingRight: 15, - }, - borderBottom: `1px solid ${theme.borderColors.borderTable}`, - }, - border: `1px solid ${theme.borderColors.borderTable}`, - }, -})); - interface Props { errors?: APIError[]; items?: InvoiceItem[]; @@ -41,7 +27,6 @@ interface Props { } export const InvoiceTable = (props: Props) => { - const { classes } = useStyles(); const MIN_PAGE_SIZE = 25; const { @@ -157,7 +142,7 @@ export const InvoiceTable = (props: Props) => { }; return ( - +
    Description diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx index 87c9b675bc0..898f947a94a 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx @@ -1,4 +1,3 @@ -/* eslint-disable no-console */ import * as React from 'react'; import { RegionSelect } from 'src/components/RegionSelect/RegionSelect'; @@ -29,7 +28,7 @@ export const CloudPulseRegionSelect = React.memo( noMarginTop onChange={(e, region) => setRegion(region.id)} regions={regions ? regions : []} - value={null} + value={undefined} /> ); } diff --git a/packages/manager/src/features/Events/EventsLanding.tsx b/packages/manager/src/features/Events/EventsLanding.tsx index 33bfba77ee5..0caf895222f 100644 --- a/packages/manager/src/features/Events/EventsLanding.tsx +++ b/packages/manager/src/features/Events/EventsLanding.tsx @@ -32,7 +32,7 @@ export const EventsLanding = (props: Props) => { const { emptyMessage, entityId } = props; const flags = useFlags(); - const filter = EVENTS_LIST_FILTER; + const filter = { ...EVENTS_LIST_FILTER }; if (entityId) { filter['entity.id'] = entityId; diff --git a/packages/manager/src/features/Events/constants.ts b/packages/manager/src/features/Events/constants.ts index b1ed2afe60d..ad07694713c 100644 --- a/packages/manager/src/features/Events/constants.ts +++ b/packages/manager/src/features/Events/constants.ts @@ -1,5 +1,5 @@ // TODO eventMessagesV2: delete when flag is removed -import type { Event, Filter } from '@linode/api-v4'; +import type { Event } from '@linode/api-v4'; export const EVENT_ACTIONS: Event['action'][] = [ 'account_settings_update', @@ -154,6 +154,15 @@ export const ACTIONS_TO_INCLUDE_AS_PROGRESS_EVENTS: Event['action'][] = [ 'database_resize', ]; -export const EVENTS_LIST_FILTER: Filter = { +/** + * This is our base filter for GETing /v4/account/events. + * + * We exclude `profile_update` events because they are generated + * often (by updating user preferences for example) and we don't + * need them. + * + * @readonly Do not modify this object + */ +export const EVENTS_LIST_FILTER = Object.freeze({ action: { '+neq': 'profile_update' }, -}; +}); diff --git a/packages/manager/src/features/Events/utils.test.tsx b/packages/manager/src/features/Events/utils.test.tsx index 5367809d580..89ce3f0c328 100644 --- a/packages/manager/src/features/Events/utils.test.tsx +++ b/packages/manager/src/features/Events/utils.test.tsx @@ -8,6 +8,7 @@ import { } from './utils'; import type { Event } from '@linode/api-v4'; +import { DateTime } from 'luxon'; describe('getEventMessage', () => { const mockEvent1: Event = eventFactory.build({ @@ -126,6 +127,10 @@ describe('formatProgressEvent', () => { }); it('returns the correct format for a finished Event', () => { + const currentDateMock = DateTime.fromISO(mockEvent1.created).plus({ + seconds: 1, + }); + vi.setSystemTime(currentDateMock.toJSDate()); const { progressEventDisplay, showProgress } = formatProgressEvent( mockEvent1 ); @@ -135,6 +140,10 @@ describe('formatProgressEvent', () => { }); it('returns the correct format for a "started" event without time remaining info', () => { + const currentDateMock = DateTime.fromISO(mockEvent2.created).plus({ + seconds: 1, + }); + vi.setSystemTime(currentDateMock.toJSDate()); const { progressEventDisplay, showProgress } = formatProgressEvent( mockEvent2 ); diff --git a/packages/manager/src/features/Help/Panels/PopularPosts.tsx b/packages/manager/src/features/Help/Panels/PopularPosts.tsx index 96d2d47feac..ec5f14daa7e 100644 --- a/packages/manager/src/features/Help/Panels/PopularPosts.tsx +++ b/packages/manager/src/features/Help/Panels/PopularPosts.tsx @@ -1,5 +1,5 @@ -import Grid from '@mui/material/Unstable_Grid2'; import { Theme } from '@mui/material/styles'; +import Grid from '@mui/material/Unstable_Grid2'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; @@ -18,7 +18,7 @@ const useStyles = makeStyles()((theme: Theme) => ({ margin: `${theme.spacing(6)} 0`, }, withSeparator: { - borderLeft: `1px solid ${theme.palette.divider}`, + borderLeft: `1px solid ${theme.borderColors.divider}`, paddingLeft: theme.spacing(4), [theme.breakpoints.down('sm')]: { borderLeft: 'none', diff --git a/packages/manager/src/features/Help/Panels/SearchPanel.tsx b/packages/manager/src/features/Help/Panels/SearchPanel.tsx index 3a2150403b3..49cb8a97d1c 100644 --- a/packages/manager/src/features/Help/Panels/SearchPanel.tsx +++ b/packages/manager/src/features/Help/Panels/SearchPanel.tsx @@ -22,7 +22,10 @@ const StyledRootContainer = styled(Paper, { label: 'StyledRootContainer', })(({ theme }) => ({ alignItems: 'center', - backgroundColor: theme.color.green, + backgroundColor: + theme.name === 'dark' + ? theme.palette.primary.light + : theme.palette.primary.dark, display: 'flex', flexDirection: 'column', justifyContent: 'center', @@ -36,7 +39,7 @@ const StyledRootContainer = styled(Paper, { const StyledH1Header = styled(H1Header, { label: 'StyledH1Header', })(({ theme }) => ({ - color: theme.name === 'dark' ? theme.color.black : theme.color.white, + color: theme.color.white, marginBottom: theme.spacing(), position: 'relative', textAlign: 'center', diff --git a/packages/manager/src/features/Images/ImagesCreate/ImageCreate.tsx b/packages/manager/src/features/Images/ImagesCreate/ImageCreate.tsx index fafc8614c04..d1bf1d2b064 100644 --- a/packages/manager/src/features/Images/ImagesCreate/ImageCreate.tsx +++ b/packages/manager/src/features/Images/ImagesCreate/ImageCreate.tsx @@ -6,7 +6,7 @@ import { NavTab, NavTabs } from 'src/components/NavTabs/NavTabs'; import { SuspenseLoader } from 'src/components/SuspenseLoader'; const ImageUpload = React.lazy(() => - import('../ImageUpload').then((module) => ({ default: module.ImageUpload })) + import('./ImageUpload').then((module) => ({ default: module.ImageUpload })) ); const CreateImageTab = React.lazy(() => diff --git a/packages/manager/src/features/Images/ImageUpload.tsx b/packages/manager/src/features/Images/ImagesCreate/ImageUpload.tsx similarity index 98% rename from packages/manager/src/features/Images/ImageUpload.tsx rename to packages/manager/src/features/Images/ImagesCreate/ImageUpload.tsx index 0d64ef0b88c..bfef25f2a31 100644 --- a/packages/manager/src/features/Images/ImageUpload.tsx +++ b/packages/manager/src/features/Images/ImagesCreate/ImageUpload.tsx @@ -38,15 +38,15 @@ import { setPendingUpload } from 'src/store/pendingUpload'; import { getGDPRDetails } from 'src/utilities/formatRegion'; import { readableBytes } from 'src/utilities/unitConversions'; -import { EUAgreementCheckbox } from '../Account/Agreements/EUAgreementCheckbox'; -import { getRestrictedResourceText } from '../Account/utils'; +import { EUAgreementCheckbox } from '../../Account/Agreements/EUAgreementCheckbox'; +import { getRestrictedResourceText } from '../../Account/utils'; import { ImageUploadSchema, recordImageAnalytics } from './ImageUpload.utils'; import { ImageUploadFormData, ImageUploadNavigationState, } from './ImageUpload.utils'; import { ImageUploadCLIDialog } from './ImageUploadCLIDialog'; -import { uploadImageFile } from './requests'; +import { uploadImageFile } from '../requests'; import type { AxiosError, AxiosProgressEvent } from 'axios'; diff --git a/packages/manager/src/features/Images/ImageUpload.utils.ts b/packages/manager/src/features/Images/ImagesCreate/ImageUpload.utils.ts similarity index 100% rename from packages/manager/src/features/Images/ImageUpload.utils.ts rename to packages/manager/src/features/Images/ImagesCreate/ImageUpload.utils.ts diff --git a/packages/manager/src/features/Images/ImageUploadCLIDialog.test.tsx b/packages/manager/src/features/Images/ImagesCreate/ImageUploadCLIDialog.test.tsx similarity index 100% rename from packages/manager/src/features/Images/ImageUploadCLIDialog.test.tsx rename to packages/manager/src/features/Images/ImagesCreate/ImageUploadCLIDialog.test.tsx diff --git a/packages/manager/src/features/Images/ImageUploadCLIDialog.tsx b/packages/manager/src/features/Images/ImagesCreate/ImageUploadCLIDialog.tsx similarity index 100% rename from packages/manager/src/features/Images/ImageUploadCLIDialog.tsx rename to packages/manager/src/features/Images/ImagesCreate/ImageUploadCLIDialog.tsx diff --git a/packages/manager/src/features/Images/EditImageDrawer.test.tsx b/packages/manager/src/features/Images/ImagesLanding/EditImageDrawer.test.tsx similarity index 100% rename from packages/manager/src/features/Images/EditImageDrawer.test.tsx rename to packages/manager/src/features/Images/ImagesLanding/EditImageDrawer.test.tsx diff --git a/packages/manager/src/features/Images/EditImageDrawer.tsx b/packages/manager/src/features/Images/ImagesLanding/EditImageDrawer.tsx similarity index 87% rename from packages/manager/src/features/Images/EditImageDrawer.tsx rename to packages/manager/src/features/Images/ImagesLanding/EditImageDrawer.tsx index eaa00d8f4f1..582a7738462 100644 --- a/packages/manager/src/features/Images/EditImageDrawer.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/EditImageDrawer.tsx @@ -1,5 +1,4 @@ import { yupResolver } from '@hookform/resolvers/yup'; -import { APIError, Image, UpdateImagePayload } from '@linode/api-v4'; import { updateImageSchema } from '@linode/validation'; import * as React from 'react'; import { Controller, useForm } from 'react-hook-form'; @@ -9,24 +8,28 @@ import { Drawer } from 'src/components/Drawer'; import { Notice } from 'src/components/Notice/Notice'; import { TagsInput } from 'src/components/TagsInput/TagsInput'; import { TextField } from 'src/components/TextField'; +import { usePrevious } from 'src/hooks/usePrevious'; import { useUpdateImageMutation } from 'src/queries/images'; -import { useImageAndLinodeGrantCheck } from './utils'; +import { useImageAndLinodeGrantCheck } from '../utils'; + +import type { APIError, Image, UpdateImagePayload } from '@linode/api-v4'; interface Props { image: Image | undefined; onClose: () => void; - open: boolean; } export const EditImageDrawer = (props: Props) => { - const { image, onClose, open } = props; + const { image, onClose } = props; const { canCreateImage } = useImageAndLinodeGrantCheck(); + // Prevent content from disappearing when closing drawer + const prevImage = usePrevious(image); const defaultValues = { - description: image?.description ?? undefined, - label: image?.label, - tags: image?.tags, + description: image?.description ?? prevImage?.description ?? undefined, + label: image?.label ?? prevImage?.label, + tags: image?.tags ?? prevImage?.tags, }; const { @@ -75,7 +78,12 @@ export const EditImageDrawer = (props: Props) => { }); return ( - + {!canCreateImage && ( mockMatchMedia()); + +describe('Image Table Row', () => { + const image = imageFactory.build({ + capabilities: ['cloud-init', 'distributed-images'], + regions: [ + { region: 'us-east', status: 'available' }, + { region: 'us-southeast', status: 'pending' }, + ], + }); + + const handlers: Handlers = { + onCancelFailed: vi.fn(), + onDelete: vi.fn(), + onDeploy: vi.fn(), + onEdit: vi.fn(), + onManageRegions: vi.fn(), + onRestore: vi.fn(), + onRetry: vi.fn(), + }; + + it('should render an image row', async () => { + const { getAllByText, getByLabelText, getByText } = renderWithTheme( + wrapWithTableBody( + + ) + ); + + // Check to see if the row rendered some data + getByText(image.label); + getAllByText('Ready'); + getAllByText((text) => text.includes(image.regions[0].region)); + getAllByText('+1'); + getAllByText('Cloud-init, Distributed'); + expect(getAllByText('1500 MB').length).toBe(2); + getAllByText(image.id); + + // Open action menu + const actionMenu = getByLabelText(`Action menu for Image ${image.label}`); + await userEvent.click(actionMenu); + + getByText('Edit'); + getByText('Manage Regions'); + getByText('Deploy to New Linode'); + getByText('Rebuild an Existing Linode'); + getByText('Delete'); + }); + + it('calls handlers when performing actions', async () => { + const { getByLabelText, getByText } = renderWithTheme( + wrapWithTableBody( + + ) + ); + + // Open action menu + const actionMenu = getByLabelText(`Action menu for Image ${image.label}`); + await userEvent.click(actionMenu); + + await userEvent.click(getByText('Edit')); + expect(handlers.onEdit).toBeCalledWith(image); + + await userEvent.click(getByText('Manage Regions')); + expect(handlers.onManageRegions).toBeCalledWith(image); + + await userEvent.click(getByText('Deploy to New Linode')); + expect(handlers.onDeploy).toBeCalledWith(image.id); + + await userEvent.click(getByText('Rebuild an Existing Linode')); + expect(handlers.onRestore).toBeCalledWith(image); + + await userEvent.click(getByText('Delete')); + expect(handlers.onDelete).toBeCalledWith( + image.label, + image.id, + image.status + ); + }); +}); diff --git a/packages/manager/src/features/Images/ImageRow.tsx b/packages/manager/src/features/Images/ImagesLanding/ImageRow.tsx similarity index 65% rename from packages/manager/src/features/Images/ImageRow.tsx rename to packages/manager/src/features/Images/ImagesLanding/ImageRow.tsx index 605475be3c0..bd3e50581e2 100644 --- a/packages/manager/src/features/Images/ImageRow.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/ImageRow.tsx @@ -1,4 +1,3 @@ -import { Event, Image } from '@linode/api-v4'; import * as React from 'react'; import { Hidden } from 'src/components/Hidden'; @@ -9,23 +8,47 @@ import { useProfile } from 'src/queries/profile/profile'; import { capitalizeAllWords } from 'src/utilities/capitalize'; import { formatDate } from 'src/utilities/formatDate'; -import { Handlers, ImagesActionMenu } from './ImagesActionMenu'; +import { ImagesActionMenu } from './ImagesActionMenu'; +import { RegionsList } from './RegionsList'; + +import type { Handlers } from './ImagesActionMenu'; +import type { Event, Image, ImageCapabilities } from '@linode/api-v4'; + +const capabilityMap: Record = { + 'cloud-init': 'Cloud-init', + 'distributed-images': 'Distributed', +}; interface Props { event?: Event; handlers: Handlers; image: Image; + multiRegionsEnabled?: boolean; // TODO Image Service v2: delete after GA } const ImageRow = (props: Props) => { - const { event, image } = props; + const { event, handlers, image, multiRegionsEnabled } = props; - const { created, expiry, id, label, size, status } = image; + const { + capabilities, + created, + expiry, + id, + label, + regions, + size, + status, + total_size, + } = image; const { data: profile } = useProfile(); const isFailed = status === 'pending_upload' && event?.status === 'failed'; + const compatibilitiesList = multiRegionsEnabled + ? capabilities.map((capability) => capabilityMap[capability]).join(', ') + : ''; + const getStatusForImage = (status: string) => { switch (status) { case 'creating': @@ -63,15 +86,41 @@ const ImageRow = (props: Props) => { {label} {status ? {getStatusForImage(status)} : null} + + {multiRegionsEnabled && ( + <> + + + {regions && regions.length > 0 && ( + handlers.onManageRegions?.(image)} + regions={regions} + /> + )} + + + + {compatibilitiesList} + + + )} + + {getSizeForImage(size, status, event?.status)} + + {multiRegionsEnabled && ( + + + {getSizeForImage(total_size, status, event?.status)} + + + )} + {formatDate(created, { timezone: profile?.timezone, })} - - {getSizeForImage(size, status, event?.status)} - {expiry ? ( @@ -81,6 +130,11 @@ const ImageRow = (props: Props) => { ) : null} + {multiRegionsEnabled && ( + + {id} + + )} diff --git a/packages/manager/src/features/Images/ImagesActionMenu.tsx b/packages/manager/src/features/Images/ImagesLanding/ImagesActionMenu.tsx similarity index 81% rename from packages/manager/src/features/Images/ImagesActionMenu.tsx rename to packages/manager/src/features/Images/ImagesLanding/ImagesActionMenu.tsx index d50b994a04f..41ea6d1b519 100644 --- a/packages/manager/src/features/Images/ImagesActionMenu.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/ImagesActionMenu.tsx @@ -1,13 +1,16 @@ -import { Event, Image, ImageStatus } from '@linode/api-v4'; import * as React from 'react'; -import { Action, ActionMenu } from 'src/components/ActionMenu/ActionMenu'; +import { ActionMenu } from 'src/components/ActionMenu/ActionMenu'; + +import type { Event, Image, ImageStatus } from '@linode/api-v4'; +import type { Action } from 'src/components/ActionMenu/ActionMenu'; export interface Handlers { onCancelFailed?: (imageID: string) => void; onDelete?: (label: string, imageID: string, status?: ImageStatus) => void; onDeploy?: (imageID: string) => void; onEdit?: (image: Image) => void; + onManageRegions?: (image: Image) => void; onRestore?: (image: Image) => void; onRetry?: ( imageID: string, @@ -32,6 +35,7 @@ export const ImagesActionMenu = (props: Props) => { onDelete, onDeploy, onEdit, + onManageRegions, onRestore, onRetry, } = handlers; @@ -60,6 +64,15 @@ export const ImagesActionMenu = (props: Props) => { ? 'Image is not yet available for use.' : undefined, }, + ...(onManageRegions + ? [ + { + disabled: isDisabled, + onClick: () => onManageRegions(image), + title: 'Manage Regions', + }, + ] + : []), { disabled: isDisabled, onClick: () => onDeploy?.(id), @@ -91,6 +104,7 @@ export const ImagesActionMenu = (props: Props) => { onCancelFailed, onEdit, image, + onManageRegions, onDeploy, onRestore, onDelete, diff --git a/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.test.tsx b/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.test.tsx new file mode 100644 index 00000000000..1a9601dcfc6 --- /dev/null +++ b/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.test.tsx @@ -0,0 +1,255 @@ +import { waitForElementToBeRemoved } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import * as React from 'react'; + +import { imageFactory } from 'src/factories'; +import { makeResourcePage } from 'src/mocks/serverHandlers'; +import { HttpResponse, http, server } from 'src/mocks/testServer'; +import { mockMatchMedia, renderWithTheme } from 'src/utilities/testHelpers'; + +import ImagesLanding from './ImagesLanding'; + +const mockHistory = { + push: vi.fn(), + replace: vi.fn(), +}; + +// Mock useHistory +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { + ...actual, + useHistory: vi.fn(() => mockHistory), + }; +}); + +beforeAll(() => mockMatchMedia()); + +const loadingTestId = 'circle-progress'; + +describe('Images Landing Table', () => { + it('should render images landing table with items', async () => { + server.use( + http.get('*/images', () => { + const images = imageFactory.buildList(3, { + regions: [ + { region: 'us-east', status: 'available' }, + { region: 'us-southeast', status: 'pending' }, + ], + }); + return HttpResponse.json(makeResourcePage(images)); + }) + ); + + const { getAllByText, getByTestId } = renderWithTheme(, { + flags: { imageServiceGen2: true }, + }); + + // Loading state should render + expect(getByTestId(loadingTestId)).toBeInTheDocument(); + + await waitForElementToBeRemoved(getByTestId(loadingTestId)); + + // Two tables should render + getAllByText('Custom Images'); + getAllByText('Recovery Images'); + + // Static text and table column headers + expect(getAllByText('Image').length).toBe(2); + expect(getAllByText('Status').length).toBe(2); + expect(getAllByText('Region(s)').length).toBe(1); + expect(getAllByText('Compatibility').length).toBe(1); + expect(getAllByText('Size').length).toBe(2); + expect(getAllByText('Total Size').length).toBe(1); + expect(getAllByText('Created').length).toBe(2); + expect(getAllByText('Image ID').length).toBe(1); + }); + + it('should render custom images empty state', async () => { + server.use( + http.get('*/images', ({ request }) => { + return HttpResponse.json( + makeResourcePage( + request.headers.get('x-filter')?.includes('automatic') + ? [imageFactory.build({ type: 'automatic' })] + : [] + ) + ); + }) + ); + + const { getByTestId, getByText } = renderWithTheme(); + + await waitForElementToBeRemoved(getByTestId(loadingTestId)); + expect(getByText('No Custom Images to display.')).toBeInTheDocument(); + }); + + it('should render automatic images empty state', async () => { + server.use( + http.get('*/images', ({ request }) => { + return HttpResponse.json( + makeResourcePage( + request.headers.get('x-filter')?.includes('manual') + ? [imageFactory.build({ type: 'manual' })] + : [] + ) + ); + }) + ); + + const { getByTestId, getByText } = renderWithTheme(); + + await waitForElementToBeRemoved(getByTestId(loadingTestId)); + expect(getByText('No Recovery Images to display.')).toBeInTheDocument(); + }); + + it('should render images landing empty state', async () => { + server.use( + http.get('*/images', () => { + return HttpResponse.json(makeResourcePage([])); + }) + ); + + const { getByTestId, getByText } = renderWithTheme(); + + await waitForElementToBeRemoved(getByTestId(loadingTestId)); + expect( + getByText((text) => text.includes('Store your own custom Linux images')) + ).toBeInTheDocument(); + }); + + it('should allow opening the Edit Image drawer', async () => { + const images = imageFactory.buildList(3, { + regions: [ + { region: 'us-east', status: 'available' }, + { region: 'us-southeast', status: 'pending' }, + ], + }); + server.use( + http.get('*/images', () => { + return HttpResponse.json(makeResourcePage(images)); + }) + ); + + const { getAllByLabelText, getByTestId, getByText } = renderWithTheme( + + ); + + // Loading state should render + expect(getByTestId(loadingTestId)).toBeInTheDocument(); + + await waitForElementToBeRemoved(getByTestId(loadingTestId)); + + // Open action menu + const actionMenu = getAllByLabelText( + `Action menu for Image ${images[0].label}` + )[0]; + await userEvent.click(actionMenu); + + await userEvent.click(getByText('Edit')); + + getByText('Edit Image'); + }); + + it('should allow opening the Restore Image drawer', async () => { + const images = imageFactory.buildList(3, { + regions: [ + { region: 'us-east', status: 'available' }, + { region: 'us-southeast', status: 'pending' }, + ], + }); + server.use( + http.get('*/images', () => { + return HttpResponse.json(makeResourcePage(images)); + }) + ); + + const { getAllByLabelText, getByTestId, getByText } = renderWithTheme( + + ); + + // Loading state should render + expect(getByTestId(loadingTestId)).toBeInTheDocument(); + + await waitForElementToBeRemoved(getByTestId(loadingTestId)); + + // Open action menu + const actionMenu = getAllByLabelText( + `Action menu for Image ${images[0].label}` + )[0]; + await userEvent.click(actionMenu); + + await userEvent.click(getByText('Rebuild an Existing Linode')); + + getByText('Restore from Image'); + }); + + it('should allow deploying to a new Linode', async () => { + const images = imageFactory.buildList(3, { + regions: [ + { region: 'us-east', status: 'available' }, + { region: 'us-southeast', status: 'pending' }, + ], + }); + server.use( + http.get('*/images', () => { + return HttpResponse.json(makeResourcePage(images)); + }) + ); + + const { getAllByLabelText, getByTestId, getByText } = renderWithTheme( + + ); + + // Loading state should render + expect(getByTestId(loadingTestId)).toBeInTheDocument(); + + await waitForElementToBeRemoved(getByTestId(loadingTestId)); + + // Open action menu + const actionMenu = getAllByLabelText( + `Action menu for Image ${images[0].label}` + )[0]; + await userEvent.click(actionMenu); + + await userEvent.click(getByText('Deploy to New Linode')); + expect(mockHistory.push).toBeCalledWith({ + pathname: '/linodes/create/', + search: `?type=Images&imageID=${images[0].id}`, + state: { selectedImageId: images[0].id }, + }); + }); + + it('should allow deleting an image', async () => { + const images = imageFactory.buildList(3, { + regions: [ + { region: 'us-east', status: 'available' }, + { region: 'us-southeast', status: 'pending' }, + ], + }); + server.use( + http.get('*/images', () => { + return HttpResponse.json(makeResourcePage(images)); + }) + ); + + const { getAllByLabelText, getByTestId, getByText } = renderWithTheme( + + ); + + // Loading state should render + expect(getByTestId(loadingTestId)).toBeInTheDocument(); + + await waitForElementToBeRemoved(getByTestId(loadingTestId)); + + // Open action menu + const actionMenu = getAllByLabelText( + `Action menu for Image ${images[0].label}` + )[0]; + await userEvent.click(actionMenu); + + await userEvent.click(getByText('Delete')); + + getByText(`Delete Image ${images[0].label}`); + }); +}); diff --git a/packages/manager/src/features/Images/ImagesLanding.tsx b/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx similarity index 90% rename from packages/manager/src/features/Images/ImagesLanding.tsx rename to packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx index 37a58e4152a..c3cc58de087 100644 --- a/packages/manager/src/features/Images/ImagesLanding.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx @@ -28,6 +28,7 @@ import { TableRowLoading } from 'src/components/TableRowLoading/TableRowLoading' import { TableSortCell } from 'src/components/TableSortCell'; import { TextField } from 'src/components/TextField'; import { Typography } from 'src/components/Typography'; +import { useFlags } from 'src/hooks/useFlags'; import { useOrder } from 'src/hooks/useOrder'; import { usePagination } from 'src/hooks/usePagination'; import { @@ -42,11 +43,11 @@ import { } from 'src/queries/images'; import { getErrorStringOrDefault } from 'src/utilities/errorUtils'; +import { getEventsForImages } from '../utils'; import { EditImageDrawer } from './EditImageDrawer'; import ImageRow from './ImageRow'; import { ImagesLandingEmptyState } from './ImagesLandingEmptyState'; import { RebuildImageDrawer } from './RebuildImageDrawer'; -import { getEventsForImages } from './utils'; import type { Handlers as ImageHandlers } from './ImagesActionMenu'; import type { Image, ImageStatus } from '@linode/api-v4'; @@ -90,6 +91,7 @@ export const ImagesLanding = () => { const { classes } = useStyles(); const history = useHistory(); const { enqueueSnackbar } = useSnackbar(); + const flags = useFlags(); const location = useLocation(); const queryParams = new URLSearchParams(location.search); const imageLabelFromParam = queryParams.get(searchQueryKey) ?? ''; @@ -198,19 +200,25 @@ export const ImagesLanding = () => { imageEvents ); + // TODO Image Service V2: delete after GA + const multiRegionsEnabled = + (flags.imageServiceGen2 && + manualImages?.data.some((image) => image.regions?.length)) ?? + false; + // Automatic images with the associated events tied in. const automaticImagesEvents = getEventsForImages( automaticImages?.data ?? [], imageEvents ); - const [selectedImage, setSelectedImage] = React.useState(); - - const [editDrawerOpen, setEditDrawerOpen] = React.useState(false); - - const [rebuildDrawerOpen, setRebuildDrawerOpen] = React.useState( - false - ); + const [ + // @ts-expect-error This will be unused until the regions drawer is implemented + manageRegionsDrawerImage, + setManageRegionsDrawerImage, + ] = React.useState(); + const [editDrawerImage, setEditDrawerImage] = React.useState(); + const [rebuildDrawerImage, setRebuildDrawerImage] = React.useState(); const [dialog, setDialogState] = React.useState( defaultDialogState @@ -296,16 +304,6 @@ export const ImagesLanding = () => { queryClient.invalidateQueries(imageQueries.paginated._def); }; - const openForEdit = (image: Image) => { - setSelectedImage(image); - setEditDrawerOpen(true); - }; - - const openForRestore = (image: Image) => { - setSelectedImage(image); - setRebuildDrawerOpen(true); - }; - const deployNewLinode = (imageID: string) => { history.push({ pathname: `/linodes/create/`, @@ -347,8 +345,11 @@ export const ImagesLanding = () => { onCancelFailed: onCancelFailedClick, onDelete: openDialog, onDeploy: deployNewLinode, - onEdit: openForEdit, - onRestore: openForRestore, + onEdit: setEditDrawerImage, + onManageRegions: multiRegionsEnabled + ? setManageRegionsDrawerImage + : undefined, + onRestore: setRebuildDrawerImage, onRetry: onRetryClick, }; @@ -392,7 +393,7 @@ export const ImagesLanding = () => { } const noManualImages = ( - + ); const noAutomaticImages = ( @@ -458,7 +459,30 @@ export const ImagesLanding = () => { Status - + {multiRegionsEnabled && ( + <> + + Region(s) + + + Compatibility + + + )} + + Size + + {multiRegionsEnabled && ( + + Total Size + + )} + { Created - - Size - + {multiRegionsEnabled && ( + + Image ID + + )} @@ -487,6 +508,7 @@ export const ImagesLanding = () => { handlers={handlers} image={manualImage} key={manualImage.id} + multiRegionsEnabled={multiRegionsEnabled} /> )) : noManualImages} @@ -574,14 +596,12 @@ export const ImagesLanding = () => { /> setEditDrawerOpen(false)} - open={editDrawerOpen} + image={editDrawerImage} + onClose={() => setEditDrawerImage(undefined)} /> setRebuildDrawerOpen(false)} - open={rebuildDrawerOpen} + image={rebuildDrawerImage} + onClose={() => setRebuildDrawerImage(undefined)} /> void; - open?: boolean; } export const RebuildImageDrawer = (props: Props) => { - const { image, onClose, open } = props; + const { image, onClose } = props; const history = useHistory(); const { @@ -51,7 +51,7 @@ export const RebuildImageDrawer = (props: Props) => { {formState.errors.root?.message && ( diff --git a/packages/manager/src/features/Images/ImagesLanding/RegionsList.test.tsx b/packages/manager/src/features/Images/ImagesLanding/RegionsList.test.tsx new file mode 100644 index 00000000000..ea58d15f6dc --- /dev/null +++ b/packages/manager/src/features/Images/ImagesLanding/RegionsList.test.tsx @@ -0,0 +1,43 @@ +import userEvent from '@testing-library/user-event'; +import * as React from 'react'; + +import 'src/mocks/testServer'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { RegionsList } from './RegionsList'; + +describe('RegionsList', () => { + it('should render a single region', async () => { + const { findByText } = renderWithTheme( + + ); + + // Should initially fallback to region id + await findByText('us-east'); + await findByText('Newark, NJ'); + }); + + it('should allow expanding to view multiple regions', async () => { + const manageRegions = vi.fn(); + + const { findByRole, findByText } = renderWithTheme( + + ); + + await findByText((text) => text.includes('Newark, NJ')); + const expand = await findByRole('button'); + expect(expand).toHaveTextContent('+1'); + + await userEvent.click(expand); + expect(manageRegions).toBeCalled(); + }); +}); diff --git a/packages/manager/src/features/Images/ImagesLanding/RegionsList.tsx b/packages/manager/src/features/Images/ImagesLanding/RegionsList.tsx new file mode 100644 index 00000000000..e17785ea634 --- /dev/null +++ b/packages/manager/src/features/Images/ImagesLanding/RegionsList.tsx @@ -0,0 +1,31 @@ +import React from 'react'; + +import { StyledLinkButton } from 'src/components/Button/StyledLinkButton'; +import { Typography } from 'src/components/Typography'; +import { useRegionsQuery } from 'src/queries/regions/regions'; + +import type { ImageRegion } from '@linode/api-v4'; + +interface Props { + onManageRegions: () => void; + regions: ImageRegion[]; +} + +export const RegionsList = ({ onManageRegions, regions }: Props) => { + const { data: regionsData } = useRegionsQuery(); + + return ( + + {regionsData?.find((region) => region.id == regions[0].region)?.label ?? + regions[0].region} + {regions.length > 1 && ( + <> + ,{' '} + + +{regions.length - 1} + + + )} + + ); +}; diff --git a/packages/manager/src/features/Images/index.tsx b/packages/manager/src/features/Images/index.tsx index 4f294a76b29..91767da9302 100644 --- a/packages/manager/src/features/Images/index.tsx +++ b/packages/manager/src/features/Images/index.tsx @@ -4,7 +4,7 @@ import { Redirect, Route, Switch, useRouteMatch } from 'react-router-dom'; import { ProductInformationBanner } from 'src/components/ProductInformationBanner/ProductInformationBanner'; import { SuspenseLoader } from 'src/components/SuspenseLoader'; -const ImagesLanding = React.lazy(() => import('./ImagesLanding')); +const ImagesLanding = React.lazy(() => import('./ImagesLanding/ImagesLanding')); const ImageCreate = React.lazy( () => import('./ImagesCreate/ImageCreateContainer') ); diff --git a/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx b/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx index 7c38f687f8e..734db88e0cb 100644 --- a/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx +++ b/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx @@ -1,10 +1,3 @@ -import { Region } from '@linode/api-v4'; -import { - CreateKubeClusterPayload, - CreateNodePoolData, - KubeNodePoolResponse, -} from '@linode/api-v4/lib/kubernetes'; -import { APIError } from '@linode/api-v4/lib/types'; import { Divider } from '@mui/material'; import Grid from '@mui/material/Unstable_Grid2'; import { pick, remove, update } from 'ramda'; @@ -14,7 +7,7 @@ import { useHistory } from 'react-router-dom'; import { Box } from 'src/components/Box'; import { DocsLink } from 'src/components/DocsLink/DocsLink'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; -import Select, { Item } from 'src/components/EnhancedSelect/Select'; +import Select from 'src/components/EnhancedSelect/Select'; import { ErrorState } from 'src/components/ErrorState/ErrorState'; import { LandingHeader } from 'src/components/LandingHeader'; import { Notice } from 'src/components/Notice/Notice'; @@ -34,6 +27,7 @@ import { } from 'src/queries/account/agreements'; import { useCreateKubernetesClusterMutation, + useKubernetesTypesQuery, useKubernetesVersionQuery, } from 'src/queries/kubernetes'; import { useRegionsQuery } from 'src/queries/regions/regions'; @@ -42,11 +36,9 @@ import { getAPIErrorOrDefault, getErrorMap } from 'src/utilities/errorUtils'; import { extendType } from 'src/utilities/extendType'; import { filterCurrentTypes } from 'src/utilities/filterCurrentLinodeTypes'; import { plansNoticesUtils } from 'src/utilities/planNotices'; -import { - DOCS_LINK_LABEL_DC_PRICING, - LKE_HA_PRICE, -} from 'src/utilities/pricing/constants'; -import { getDCSpecificPrice } from 'src/utilities/pricing/dynamicPricing'; +import { DOCS_LINK_LABEL_DC_PRICING } from 'src/utilities/pricing/constants'; +import { UNKNOWN_PRICE } from 'src/utilities/pricing/constants'; +import { getDCSpecificPriceByType } from 'src/utilities/pricing/dynamicPricing'; import { scrollErrorIntoView } from 'src/utilities/scrollErrorIntoView'; import KubeCheckoutBar from '../KubeCheckoutBar'; @@ -58,9 +50,19 @@ import { import { HAControlPlane } from './HAControlPlane'; import { NodePoolPanel } from './NodePoolPanel'; +import type { + CreateKubeClusterPayload, + CreateNodePoolData, + KubeNodePoolResponse, +} from '@linode/api-v4/lib/kubernetes'; +import type { APIError } from '@linode/api-v4/lib/types'; +import type { Item } from 'src/components/EnhancedSelect/Select'; + export const CreateCluster = () => { const { classes } = useStyles(); - const [selectedRegionID, setSelectedRegionID] = React.useState(''); + const [selectedRegionId, setSelectedRegionId] = React.useState< + string | undefined + >(); const [nodePools, setNodePools] = React.useState([]); const [label, setLabel] = React.useState(); const [version, setVersion] = React.useState | undefined>(); @@ -76,6 +78,16 @@ export const CreateCluster = () => { const { data: account } = useAccount(); const { showHighAvailability } = getKubeHighAvailability(account); + const { + data: kubernetesHighAvailabilityTypesData, + isError: isErrorKubernetesTypes, + isLoading: isLoadingKubernetesTypes, + } = useKubernetesTypesQuery(); + + const lkeHAType = kubernetesHighAvailabilityTypesData?.find( + (type) => type.id === 'lke-ha' + ); + const { data: allTypes, error: typesError, @@ -121,7 +133,7 @@ export const CreateCluster = () => { k8s_version, label, node_pools, - region: selectedRegionID, + region: selectedRegionId, }; createKubernetesCluster(payload) @@ -164,31 +176,23 @@ export const CreateCluster = () => { setLabel(newLabel ? newLabel : undefined); }; - /** - * @param regionId - region selection or null if no selection made - * @returns dynamically calculated high availability price by region - */ - const getHighAvailabilityPrice = (regionId: Region['id'] | null) => { - const dcSpecificPrice = regionId - ? getDCSpecificPrice({ basePrice: LKE_HA_PRICE, regionId }) - : undefined; - return dcSpecificPrice ? parseFloat(dcSpecificPrice) : undefined; - }; + const highAvailabilityPrice = getDCSpecificPriceByType({ + regionId: selectedRegionId, + type: lkeHAType, + }); const errorMap = getErrorMap( ['region', 'node_pools', 'label', 'k8s_version', 'versionLoad'], errors ); - const selectedId = selectedRegionID || null; - const { hasSelectedRegion, isPlanPanelDisabled, isSelectedRegionEligibleForPlan, } = plansNoticesUtils({ regionsData, - selectedRegionID, + selectedRegionID: selectedRegionId, }); if (typesError || regionsError || versionLoadError) { @@ -227,9 +231,9 @@ export const CreateCluster = () => { currentCapability="Kubernetes" disableClearable errorText={errorMap.region} - onChange={(e, region) => setSelectedRegionID(region.id)} + onChange={(e, region) => setSelectedRegionId(region.id)} regions={regionsData} - value={selectedId} + value={selectedRegionId} /> @@ -255,7 +259,14 @@ export const CreateCluster = () => { {showHighAvailability ? ( @@ -276,7 +287,7 @@ export const CreateCluster = () => { isPlanPanelDisabled={isPlanPanelDisabled} isSelectedRegionEligibleForPlan={isSelectedRegionEligibleForPlan} regionsData={regionsData} - selectedRegionId={selectedRegionID} + selectedRegionId={selectedRegionId} types={typesData || []} typesLoading={typesLoading} /> @@ -287,10 +298,15 @@ export const CreateCluster = () => { data-testid="kube-checkout-bar" > { createCluster={createCluster} hasAgreed={hasAgreed} highAvailability={highAvailability} - highAvailabilityPrice={getHighAvailabilityPrice(selectedId)} pools={nodePools} - region={selectedRegionID} + region={selectedRegionId} regionsData={regionsData} removePool={removePool} showHighAvailability={showHighAvailability} diff --git a/packages/manager/src/features/Kubernetes/CreateCluster/HAControlPlane.test.tsx b/packages/manager/src/features/Kubernetes/CreateCluster/HAControlPlane.test.tsx index f6e654363fd..b8f995c02f1 100644 --- a/packages/manager/src/features/Kubernetes/CreateCluster/HAControlPlane.test.tsx +++ b/packages/manager/src/features/Kubernetes/CreateCluster/HAControlPlane.test.tsx @@ -1,13 +1,18 @@ import { fireEvent } from '@testing-library/react'; import * as React from 'react'; -import { LKE_HA_PRICE } from 'src/utilities/pricing/constants'; +import { UNKNOWN_PRICE } from 'src/utilities/pricing/constants'; import { renderWithTheme } from 'src/utilities/testHelpers'; -import { HAControlPlane, HAControlPlaneProps } from './HAControlPlane'; +import { HAControlPlane } from './HAControlPlane'; + +import type { HAControlPlaneProps } from './HAControlPlane'; const props: HAControlPlaneProps = { - highAvailabilityPrice: LKE_HA_PRICE, + highAvailabilityPrice: '60.00', + isErrorKubernetesTypes: false, + isLoadingKubernetesTypes: false, + selectedRegionId: 'us-southeast', setHighAvailability: vi.fn(), }; @@ -18,12 +23,17 @@ describe('HAControlPlane', () => { expect(getByTestId('ha-control-plane-form')).toBeVisible(); }); - it('should not render an HA price when the price is undefined', () => { - const { queryAllByText } = renderWithTheme( - + it('should not render an HA price when there is a price error', () => { + const { getByText } = renderWithTheme( + ); - expect(queryAllByText(/\$60\.00/)).toHaveLength(0); + getByText(/The cost for HA control plane is not available at this time./); + getByText(/For this region, HA control plane costs \$--.--\/month./); }); it('should render an HA price when the price is a number', async () => { diff --git a/packages/manager/src/features/Kubernetes/CreateCluster/HAControlPlane.tsx b/packages/manager/src/features/Kubernetes/CreateCluster/HAControlPlane.tsx index 6acbfc82bd0..be39c12bb0b 100644 --- a/packages/manager/src/features/Kubernetes/CreateCluster/HAControlPlane.tsx +++ b/packages/manager/src/features/Kubernetes/CreateCluster/HAControlPlane.tsx @@ -1,16 +1,20 @@ import { FormLabel } from '@mui/material'; import * as React from 'react'; -import { displayPrice } from 'src/components/DisplayPrice'; +import { CircleProgress } from 'src/components/CircleProgress'; import { FormControl } from 'src/components/FormControl'; import { FormControlLabel } from 'src/components/FormControlLabel'; import { Link } from 'src/components/Link'; +import { Notice } from 'src/components/Notice/Notice'; import { Radio } from 'src/components/Radio/Radio'; import { RadioGroup } from 'src/components/RadioGroup'; import { Typography } from 'src/components/Typography'; export interface HAControlPlaneProps { - highAvailabilityPrice: number | undefined; + highAvailabilityPrice: string; + isErrorKubernetesTypes: boolean; + isLoadingKubernetesTypes: boolean; + selectedRegionId: string | undefined; setHighAvailability: (ha: boolean | undefined) => void; } @@ -26,8 +30,23 @@ export const HACopy = () => ( ); +export const getRegionPriceLink = (selectedRegionId: string) => { + if (selectedRegionId === 'id-cgk') { + return 'https://www.linode.com/pricing/jakarta/#kubernetes'; + } else if (selectedRegionId === 'br-gru') { + return 'https://www.linode.com/pricing/sao-paulo/#kubernetes'; + } + return 'https://www.linode.com/pricing/#kubernetes'; +}; + export const HAControlPlane = (props: HAControlPlaneProps) => { - const { highAvailabilityPrice, setHighAvailability } = props; + const { + highAvailabilityPrice, + isErrorKubernetesTypes, + isLoadingKubernetesTypes, + selectedRegionId, + setHighAvailability, + } = props; const handleChange = (e: React.ChangeEvent) => { setHighAvailability(e.target.value === 'yes'); @@ -46,17 +65,31 @@ export const HAControlPlane = (props: HAControlPlaneProps) => { HA Control Plane + {isLoadingKubernetesTypes && selectedRegionId ? ( + + ) : selectedRegionId && isErrorKubernetesTypes ? ( + + + The cost for HA control plane is not available at this time. Refer + to pricing{' '} + for information. + + + ) : null} handleChange(e)} > + Yes, enable HA control plane.{' '} + {selectedRegionId + ? `For this region, HA control plane costs $${highAvailabilityPrice}/month.` + : '(Select a region to view price information.)'} + + } control={} name="yes" value="yes" diff --git a/packages/manager/src/features/Kubernetes/CreateCluster/NodePoolPanel.tsx b/packages/manager/src/features/Kubernetes/CreateCluster/NodePoolPanel.tsx index 867b89ceea4..bbc316947a2 100644 --- a/packages/manager/src/features/Kubernetes/CreateCluster/NodePoolPanel.tsx +++ b/packages/manager/src/features/Kubernetes/CreateCluster/NodePoolPanel.tsx @@ -27,7 +27,7 @@ export interface NodePoolPanelProps { isPlanPanelDisabled: (planType?: LinodeTypeClass) => boolean; isSelectedRegionEligibleForPlan: (planType?: LinodeTypeClass) => boolean; regionsData: Region[]; - selectedRegionId: Region['id']; + selectedRegionId: Region['id'] | undefined; types: ExtendedType[]; typesError?: string; typesLoading: boolean; diff --git a/packages/manager/src/features/Kubernetes/KubeCheckoutBar/KubeCheckoutBar.test.tsx b/packages/manager/src/features/Kubernetes/KubeCheckoutBar/KubeCheckoutBar.test.tsx index 64564568879..60e58baa7cc 100644 --- a/packages/manager/src/features/Kubernetes/KubeCheckoutBar/KubeCheckoutBar.test.tsx +++ b/packages/manager/src/features/Kubernetes/KubeCheckoutBar/KubeCheckoutBar.test.tsx @@ -3,13 +3,13 @@ import * as React from 'react'; import { regionFactory } from 'src/factories'; import { nodePoolFactory } from 'src/factories/kubernetesCluster'; -import { - LKE_CREATE_CLUSTER_CHECKOUT_MESSAGE, - LKE_HA_PRICE, -} from 'src/utilities/pricing/constants'; +import { UNKNOWN_PRICE } from 'src/utilities/pricing/constants'; +import { LKE_CREATE_CLUSTER_CHECKOUT_MESSAGE } from 'src/utilities/pricing/constants'; import { renderWithTheme } from 'src/utilities/testHelpers'; -import KubeCheckoutBar, { Props } from './KubeCheckoutBar'; +import KubeCheckoutBar from './KubeCheckoutBar'; + +import type { Props } from './KubeCheckoutBar'; const pools = nodePoolFactory.buildList(5, { count: 3, type: 'g6-standard-1' }); @@ -17,7 +17,7 @@ const props: Props = { createCluster: vi.fn(), hasAgreed: false, highAvailability: false, - highAvailabilityPrice: LKE_HA_PRICE, + highAvailabilityPrice: '60', pools, region: 'us-east', regionsData: regionFactory.buildList(1), @@ -34,7 +34,7 @@ const renderComponent = (_props: Props) => describe('KubeCheckoutBar', () => { it('should render helper text and disable create button until a region has been selected', async () => { const { findByText, getByTestId, getByText } = renderWithTheme( - + ); await waitForElementToBeRemoved(getByTestId('circle-progress')); @@ -84,12 +84,41 @@ describe('KubeCheckoutBar', () => { await findByText(/\$210\.00/); }); - it('should display the DC-Specific total price of the cluster for a region with a price increase', async () => { + it('should display the DC-Specific total price of the cluster for a region with a price increase without HA selection', async () => { const { findByText } = renderWithTheme( ); - // 5 node pools * 3 linodes per pool * 10 per linode * 20% increase for Jakarta + // 5 node pools * 3 linodes per pool * 12 per linode * 20% increase for Jakarta + 72 per month per cluster for HA + await findByText(/\$180\.00/); + }); + + it('should display the DC-Specific total price of the cluster for a region with a price increase with HA selection', async () => { + const { findByText } = renderWithTheme( + + ); + + // 5 node pools * 3 linodes per pool * 12 per linode * 20% increase for Jakarta + 72 per month per cluster for HA + await findByText(/\$252\.00/); + }); + + it('should display UNKNOWN_PRICE for HA when not available and show total price of cluster as the sum of the node pools', async () => { + const { findByText, getByText } = renderWithTheme( + + ); + + // 5 node pools * 3 linodes per pool * 12 per linode * 20% increase for Jakarta + UNKNOWN_PRICE await findByText(/\$180\.00/); + getByText(/\$--.--\/month/); }); }); diff --git a/packages/manager/src/features/Kubernetes/KubeCheckoutBar/KubeCheckoutBar.tsx b/packages/manager/src/features/Kubernetes/KubeCheckoutBar/KubeCheckoutBar.tsx index 16d3f07758e..c2cfb96908a 100644 --- a/packages/manager/src/features/Kubernetes/KubeCheckoutBar/KubeCheckoutBar.tsx +++ b/packages/manager/src/features/Kubernetes/KubeCheckoutBar/KubeCheckoutBar.tsx @@ -1,11 +1,9 @@ -import { KubeNodePoolResponse, Region } from '@linode/api-v4'; import { Typography, styled } from '@mui/material'; import * as React from 'react'; import { Box } from 'src/components/Box'; import { CheckoutBar } from 'src/components/CheckoutBar/CheckoutBar'; import { CircleProgress } from 'src/components/CircleProgress'; -import { displayPrice } from 'src/components/DisplayPrice'; import { Divider } from 'src/components/Divider'; import { Notice } from 'src/components/Notice/Notice'; import { RenderGuard } from 'src/components/RenderGuard'; @@ -22,15 +20,17 @@ import { } from 'src/utilities/pricing/kubernetes'; import { nodeWarning } from '../kubeUtils'; -import NodePoolSummary from './NodePoolSummary'; +import { NodePoolSummary } from './NodePoolSummary'; + +import type { KubeNodePoolResponse, Region } from '@linode/api-v4'; export interface Props { createCluster: () => void; hasAgreed: boolean; highAvailability?: boolean; - highAvailabilityPrice: number | undefined; + highAvailabilityPrice: string; pools: KubeNodePoolResponse[]; - region: string; + region: string | undefined; regionsData: Region[]; removePool: (poolIdx: number) => void; showHighAvailability: boolean | undefined; @@ -39,7 +39,7 @@ export interface Props { updatePool: (poolIdx: number, updatedPool: KubeNodePoolResponse) => void; } -export const KubeCheckoutBar: React.FC = (props) => { +export const KubeCheckoutBar = (props: Props) => { const { createCluster, hasAgreed, @@ -81,7 +81,7 @@ export const KubeCheckoutBar: React.FC = (props) => { highAvailabilityPrice !== undefined; const disableCheckout = Boolean( - needsAPool || gdprConditions || haConditions || region === '' + needsAPool || gdprConditions || haConditions || !region ); if (isLoading) { @@ -96,10 +96,10 @@ export const KubeCheckoutBar: React.FC = (props) => { ) : undefined } calculatedPrice={ - region !== '' + region ? getTotalClusterPrice({ highAvailabilityPrice: highAvailability - ? highAvailabilityPrice + ? Number(highAvailabilityPrice) : undefined, pools, region, @@ -122,7 +122,7 @@ export const KubeCheckoutBar: React.FC = (props) => { types?.find((thisType) => thisType.id === thisPool.type) || null } price={ - region !== '' + region ? getKubernetesMonthlyPrice({ count: thisPool.count, region, @@ -148,14 +148,12 @@ export const KubeCheckoutBar: React.FC = (props) => { variant="warning" /> )} - {region != '' && highAvailability ? ( + {region && highAvailability ? ( High Availability (HA) Control Plane - - {displayPrice(Number(highAvailabilityPrice))}/month - + {`$${highAvailabilityPrice}/month`} ) : undefined} diff --git a/packages/manager/src/features/Kubernetes/KubeCheckoutBar/NodePoolSummary.test.tsx b/packages/manager/src/features/Kubernetes/KubeCheckoutBar/NodePoolSummary.test.tsx index c264ea67f85..26927ba9879 100644 --- a/packages/manager/src/features/Kubernetes/KubeCheckoutBar/NodePoolSummary.test.tsx +++ b/packages/manager/src/features/Kubernetes/KubeCheckoutBar/NodePoolSummary.test.tsx @@ -4,7 +4,7 @@ import * as React from 'react'; import { extendedTypes } from 'src/__data__/ExtendedType'; import { renderWithTheme } from 'src/utilities/testHelpers'; -import NodePoolSummary, { Props } from './NodePoolSummary'; +import { NodePoolSummary, Props } from './NodePoolSummary'; const props: Props = { nodeCount: 3, diff --git a/packages/manager/src/features/Kubernetes/KubeCheckoutBar/NodePoolSummary.tsx b/packages/manager/src/features/Kubernetes/KubeCheckoutBar/NodePoolSummary.tsx index 6d9f5a7923f..a36523911dc 100644 --- a/packages/manager/src/features/Kubernetes/KubeCheckoutBar/NodePoolSummary.tsx +++ b/packages/manager/src/features/Kubernetes/KubeCheckoutBar/NodePoolSummary.tsx @@ -1,7 +1,7 @@ import Close from '@mui/icons-material/Close'; import { Theme } from '@mui/material/styles'; -import { makeStyles } from 'tss-react/mui'; import * as React from 'react'; +import { makeStyles } from 'tss-react/mui'; import { Box } from 'src/components/Box'; import { DisplayPrice } from 'src/components/DisplayPrice'; @@ -55,7 +55,7 @@ export interface Props { updateNodeCount: (count: number) => void; } -export const NodePoolSummary: React.FC = (props) => { +export const NodePoolSummary = React.memo((props: Props) => { const { classes } = useStyles(); const { nodeCount, onRemove, poolType, price, updateNodeCount } = props; @@ -109,6 +109,4 @@ export const NodePoolSummary: React.FC = (props) => { ); -}; - -export default React.memo(NodePoolSummary); +}); diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeClusterSpecs.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeClusterSpecs.tsx index e84e1621b04..d7f2407acb3 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeClusterSpecs.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeClusterSpecs.tsx @@ -1,21 +1,31 @@ -import { KubernetesCluster } from '@linode/api-v4'; +import { useTheme } from '@mui/material/styles'; import Grid from '@mui/material/Unstable_Grid2'; -import { Theme } from '@mui/material/styles'; -import { makeStyles } from 'tss-react/mui'; import * as React from 'react'; +import { makeStyles } from 'tss-react/mui'; +import { CircleProgress } from 'src/components/CircleProgress'; +import { TooltipIcon } from 'src/components/TooltipIcon'; import { Typography } from 'src/components/Typography'; -import { useAllKubernetesNodePoolQuery } from 'src/queries/kubernetes'; +import { + useAllKubernetesNodePoolQuery, + useKubernetesTypesQuery, +} from 'src/queries/kubernetes'; import { useRegionsQuery } from 'src/queries/regions/regions'; import { useSpecificTypes } from 'src/queries/types'; import { extendTypesQueryResult } from 'src/utilities/extendType'; import { pluralize } from 'src/utilities/pluralize'; -import { LKE_HA_PRICE } from 'src/utilities/pricing/constants'; -import { getDCSpecificPrice } from 'src/utilities/pricing/dynamicPricing'; +import { + HA_PRICE_ERROR_MESSAGE, + UNKNOWN_PRICE, +} from 'src/utilities/pricing/constants'; +import { getDCSpecificPriceByType } from 'src/utilities/pricing/dynamicPricing'; import { getTotalClusterPrice } from 'src/utilities/pricing/kubernetes'; import { getTotalClusterMemoryCPUAndStorage } from '../kubeUtils'; +import type { KubernetesCluster } from '@linode/api-v4'; +import type { Theme } from '@mui/material/styles'; + interface Props { cluster: KubernetesCluster; } @@ -45,15 +55,19 @@ const useStyles = makeStyles()((theme: Theme) => ({ marginBottom: theme.spacing(3), padding: `${theme.spacing(2.5)} ${theme.spacing(2.5)} ${theme.spacing(3)}`, }, + tooltip: { + '& .MuiTooltip-tooltip': { + minWidth: 320, + }, + }, })); -export const KubeClusterSpecs = (props: Props) => { +export const KubeClusterSpecs = React.memo((props: Props) => { const { cluster } = props; const { classes } = useStyles(); const { data: regions } = useRegionsQuery(); - + const theme = useTheme(); const { data: pools } = useAllKubernetesNodePoolQuery(cluster.id); - const typesQuery = useSpecificTypes(pools?.map((pool) => pool.type) ?? []); const types = extendTypesQueryResult(typesQuery); @@ -62,30 +76,53 @@ export const KubeClusterSpecs = (props: Props) => { types ?? [] ); - const region = regions?.find((r) => r.id === cluster.region); + const { + data: kubernetesHighAvailabilityTypesData, + isError: isErrorKubernetesTypes, + isLoading: isLoadingKubernetesTypes, + } = useKubernetesTypesQuery(); - const displayRegion = region?.label ?? cluster.region; + const lkeHAType = kubernetesHighAvailabilityTypesData?.find( + (type) => type.id === 'lke-ha' + ); - const dcSpecificPrice = cluster.control_plane.high_availability - ? getDCSpecificPrice({ - basePrice: LKE_HA_PRICE, - regionId: region?.id, - }) - : undefined; + const region = regions?.find((r) => r.id === cluster.region); + const displayRegion = region?.label ?? cluster.region; - const highAvailabilityPrice = dcSpecificPrice - ? parseFloat(dcSpecificPrice) + const highAvailabilityPrice = cluster.control_plane.high_availability + ? getDCSpecificPriceByType({ regionId: region?.id, type: lkeHAType }) : undefined; const kubeSpecsLeft = [ `Version ${cluster.k8s_version}`, displayRegion, - `$${getTotalClusterPrice({ - highAvailabilityPrice, - pools: pools ?? [], - region: region?.id, - types: types ?? [], - }).toFixed(2)}/month`, + isLoadingKubernetesTypes ? ( + + ) : cluster.control_plane.high_availability && isErrorKubernetesTypes ? ( + <> + ${UNKNOWN_PRICE}/month + + + ) : ( + `$${getTotalClusterPrice({ + highAvailabilityPrice: highAvailabilityPrice + ? Number(highAvailabilityPrice) + : undefined, + pools: pools ?? [], + region: region?.id, + types: types ?? [], + }).toFixed(2)}/month` + ), ]; const kubeSpecsRight = [ @@ -115,6 +152,4 @@ export const KubeClusterSpecs = (props: Props) => { {kubeSpecsRight.map(kubeSpecItem)} ); -}; - -export default KubeClusterSpecs; +}); diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeSummaryPanel.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeSummaryPanel.tsx index 56436e6aab2..e3f2a409034 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeSummaryPanel.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeSummaryPanel.tsx @@ -1,6 +1,4 @@ -import { KubernetesCluster } from '@linode/api-v4/lib/kubernetes'; import OpenInNewIcon from '@mui/icons-material/OpenInNew'; -import { Theme } from '@mui/material/styles'; import Grid from '@mui/material/Unstable_Grid2'; import { useSnackbar } from 'notistack'; import * as React from 'react'; @@ -12,7 +10,7 @@ import { Chip } from 'src/components/Chip'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; import { Paper } from 'src/components/Paper'; import { TagCell } from 'src/components/TagCell/TagCell'; -import KubeClusterSpecs from 'src/features/Kubernetes/KubernetesClusterDetail/KubeClusterSpecs'; +import { KubeClusterSpecs } from 'src/features/Kubernetes/KubernetesClusterDetail/KubeClusterSpecs'; import { useIsResourceRestricted } from 'src/hooks/useIsResourceRestricted'; import { useKubernetesClusterMutation, @@ -25,6 +23,9 @@ import { DeleteKubernetesClusterDialog } from './DeleteKubernetesClusterDialog'; import { KubeConfigDisplay } from './KubeConfigDisplay'; import { KubeConfigDrawer } from './KubeConfigDrawer'; +import type { KubernetesCluster } from '@linode/api-v4/lib/kubernetes'; +import type { Theme } from '@mui/material/styles'; + const useStyles = makeStyles()((theme: Theme) => ({ actionRow: { '& button': { @@ -100,7 +101,7 @@ interface Props { cluster: KubernetesCluster; } -export const KubeSummaryPanel = (props: Props) => { +export const KubeSummaryPanel = React.memo((props: Props) => { const { cluster } = props; const { classes } = useStyles(); const { enqueueSnackbar } = useSnackbar(); @@ -258,6 +259,4 @@ export const KubeSummaryPanel = (props: Props) => { ); -}; - -export default React.memo(KubeSummaryPanel); +}); diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubernetesClusterDetail.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubernetesClusterDetail.tsx index 9e8383e09b2..93cc3987a39 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubernetesClusterDetail.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubernetesClusterDetail.tsx @@ -15,7 +15,7 @@ import { import { useRegionsQuery } from 'src/queries/regions/regions'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; -import KubeSummaryPanel from './KubeSummaryPanel'; +import { KubeSummaryPanel } from './KubeSummaryPanel'; import { NodePoolsDisplay } from './NodePoolsDisplay/NodePoolsDisplay'; import { UpgradeKubernetesClusterToHADialog } from './UpgradeClusterDialog'; import UpgradeKubernetesVersionBanner from './UpgradeKubernetesVersionBanner'; @@ -25,24 +25,21 @@ export const KubernetesClusterDetail = () => { const { clusterID } = useParams<{ clusterID: string }>(); const id = Number(clusterID); const location = useLocation(); - const { data: cluster, error, isLoading } = useKubernetesClusterQuery(id); - const { data: regionsData } = useRegionsQuery(); const { mutateAsync: updateKubernetesCluster } = useKubernetesClusterMutation( id ); - const [updateError, setUpdateError] = React.useState(); - - const [isUpgradeToHAOpen, setIsUpgradeToHAOpen] = React.useState(false); - const { isClusterHighlyAvailable, showHighAvailability, } = getKubeHighAvailability(account, cluster); + const [updateError, setUpdateError] = React.useState(); + const [isUpgradeToHAOpen, setIsUpgradeToHAOpen] = React.useState(false); + if (error) { return ( { ); }; - -export default KubernetesClusterDetail; diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/UpgradeClusterDialog.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/UpgradeClusterDialog.tsx index 6b697426e6a..b61c0226f54 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/UpgradeClusterDialog.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/UpgradeClusterDialog.tsx @@ -1,10 +1,10 @@ -import { Theme } from '@mui/material/styles'; -import { makeStyles } from 'tss-react/mui'; import { useSnackbar } from 'notistack'; import * as React from 'react'; +import { makeStyles } from 'tss-react/mui'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { Checkbox } from 'src/components/Checkbox'; +import { CircleProgress } from 'src/components/CircleProgress'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; import { Notice } from 'src/components/Notice/Notice'; import { Typography } from 'src/components/Typography'; @@ -12,12 +12,17 @@ import { localStorageWarning, nodesDeletionWarning, } from 'src/features/Kubernetes/kubeUtils'; -import { useKubernetesClusterMutation } from 'src/queries/kubernetes'; -import { LKE_HA_PRICE } from 'src/utilities/pricing/constants'; -import { getDCSpecificPrice } from 'src/utilities/pricing/dynamicPricing'; +import { + useKubernetesClusterMutation, + useKubernetesTypesQuery, +} from 'src/queries/kubernetes'; +import { HA_UPGRADE_PRICE_ERROR_MESSAGE } from 'src/utilities/pricing/constants'; +import { getDCSpecificPriceByType } from 'src/utilities/pricing/dynamicPricing'; import { HACopy } from '../CreateCluster/HAControlPlane'; +import type { Theme } from '@mui/material/styles'; + const useStyles = makeStyles()((theme: Theme) => ({ noticeHeader: { fontSize: '0.875rem', @@ -39,11 +44,10 @@ interface Props { regionID: string; } -export const UpgradeKubernetesClusterToHADialog = (props: Props) => { +export const UpgradeKubernetesClusterToHADialog = React.memo((props: Props) => { const { clusterID, onClose, open, regionID } = props; const { enqueueSnackbar } = useSnackbar(); const [checked, setChecked] = React.useState(false); - const toggleChecked = () => setChecked((isChecked) => !isChecked); const { mutateAsync: updateKubernetesCluster } = useKubernetesClusterMutation( @@ -53,6 +57,16 @@ export const UpgradeKubernetesClusterToHADialog = (props: Props) => { const [submitting, setSubmitting] = React.useState(false); const { classes } = useStyles(); + const { + data: kubernetesHighAvailabilityTypesData, + isError: isErrorKubernetesTypes, + isLoading: isLoadingKubernetesTypes, + } = useKubernetesTypesQuery(); + + const lkeHAType = kubernetesHighAvailabilityTypesData?.find( + (type) => type.id === 'lke-ha' + ); + const onUpgrade = () => { setSubmitting(true); setError(undefined); @@ -70,6 +84,11 @@ export const UpgradeKubernetesClusterToHADialog = (props: Props) => { }); }; + const highAvailabilityPrice = getDCSpecificPriceByType({ + regionId: regionID, + type: lkeHAType, + }); + const actions = ( { open={open} title="Upgrade to High Availability" > - - - For this region, pricing for the HA control plane is $ - {getDCSpecificPrice({ - basePrice: LKE_HA_PRICE, - regionId: regionID, - })}{' '} - per month per cluster. - - - - Caution: - -
      -
    • {nodesDeletionWarning}
    • -
    • {localStorageWarning}
    • -
    • - This may take several minutes, as nodes will be replaced on a - rolling basis. -
    • -
    -
    - + {isLoadingKubernetesTypes ? ( + + ) : ( + <> + + {isErrorKubernetesTypes ? ( + + {HA_UPGRADE_PRICE_ERROR_MESSAGE} + + ) : ( + <> + + For this region, pricing for the HA control plane is $ + {highAvailabilityPrice} per month per cluster. + + + + Caution: + +
      +
    • {nodesDeletionWarning}
    • +
    • {localStorageWarning}
    • +
    • + This may take several minutes, as nodes will be replaced on + a rolling basis. +
    • +
    +
    + + + )} + + )} ); -}; +}); diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/index.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/index.tsx deleted file mode 100644 index 2dd9213e1e0..00000000000 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { default } from './KubernetesClusterDetail'; diff --git a/packages/manager/src/features/Kubernetes/index.tsx b/packages/manager/src/features/Kubernetes/index.tsx index a25b9a50694..82aebd910fd 100644 --- a/packages/manager/src/features/Kubernetes/index.tsx +++ b/packages/manager/src/features/Kubernetes/index.tsx @@ -7,21 +7,29 @@ import { SuspenseLoader } from 'src/components/SuspenseLoader'; const KubernetesLanding = React.lazy( () => import('./KubernetesLanding/KubernetesLanding') ); + const ClusterCreate = React.lazy(() => import('./CreateCluster/CreateCluster').then((module) => ({ default: module.CreateCluster, })) ); -const ClusterDetail = React.lazy(() => import('./KubernetesClusterDetail')); -const Kubernetes: React.FC = () => { +const KubernetesClusterDetail = React.lazy(() => + import('./KubernetesClusterDetail/KubernetesClusterDetail').then( + (module) => ({ + default: module.KubernetesClusterDetail, + }) + ) +); + +export const Kubernetes = () => { return ( }> @@ -43,5 +51,3 @@ const Kubernetes: React.FC = () => { ); }; - -export default Kubernetes; diff --git a/packages/manager/src/features/Kubernetes/kubeUtils.test.ts b/packages/manager/src/features/Kubernetes/kubeUtils.test.ts index 30f67b917be..35619fcce5f 100644 --- a/packages/manager/src/features/Kubernetes/kubeUtils.test.ts +++ b/packages/manager/src/features/Kubernetes/kubeUtils.test.ts @@ -5,7 +5,10 @@ import { } from 'src/factories'; import { extendType } from 'src/utilities/extendType'; -import { getTotalClusterMemoryCPUAndStorage } from './kubeUtils'; +import { + getLatestVersion, + getTotalClusterMemoryCPUAndStorage, +} from './kubeUtils'; describe('helper functions', () => { const badPool = nodePoolFactory.build({ @@ -64,4 +67,39 @@ describe('helper functions', () => { }); }); }); + describe('getLatestVersion', () => { + it('should return the correct latest version from a list of versions', () => { + const versions = [ + { label: '1.00', value: '1.00' }, + { label: '1.10', value: '1.10' }, + { label: '2.00', value: '2.00' }, + ]; + const result = getLatestVersion(versions); + expect(result).toEqual({ label: '2.00', value: '2.00' }); + }); + + it('should handle latest version minor version correctly', () => { + const versions = [ + { label: '1.22', value: '1.22' }, + { label: '1.23', value: '1.23' }, + { label: '1.30', value: '1.30' }, + ]; + const result = getLatestVersion(versions); + expect(result).toEqual({ label: '1.30', value: '1.30' }); + }); + it('should handle latest patch version correctly', () => { + const versions = [ + { label: '1.22', value: '1.30' }, + { label: '1.23', value: '1.15' }, + { label: '1.30', value: '1.50.1' }, + { label: '1.30', value: '1.50' }, + ]; + const result = getLatestVersion(versions); + expect(result).toEqual({ label: '1.50.1', value: '1.50.1' }); + }); + it('should return default fallback value when called with empty versions', () => { + const result = getLatestVersion([]); + expect(result).toEqual({ label: '', value: '' }); + }); + }); }); diff --git a/packages/manager/src/features/Kubernetes/kubeUtils.ts b/packages/manager/src/features/Kubernetes/kubeUtils.ts index e19fe18873a..0189a5b2dfd 100644 --- a/packages/manager/src/features/Kubernetes/kubeUtils.ts +++ b/packages/manager/src/features/Kubernetes/kubeUtils.ts @@ -1,13 +1,13 @@ -import { Account } from '@linode/api-v4/lib/account'; -import { +import { sortByVersion } from 'src/utilities/sort-by'; + +import type { Account } from '@linode/api-v4/lib/account'; +import type { KubeNodePoolResponse, KubernetesCluster, KubernetesVersion, } from '@linode/api-v4/lib/kubernetes'; -import { Region } from '@linode/api-v4/lib/regions'; - +import type { Region } from '@linode/api-v4/lib/regions'; import type { ExtendedType } from 'src/utilities/extendType'; - export const nodeWarning = `We recommend a minimum of 3 nodes in each Node Pool to avoid downtime during upgrades and maintenance.`; export const nodesDeletionWarning = `All nodes will be deleted and new nodes will be created to replace them.`; export const localStorageWarning = `Any local storage (such as \u{2019}hostPath\u{2019} volumes) will be erased.`; @@ -112,15 +112,40 @@ export const getKubeHighAvailability = ( }; }; +/** + * Retrieves the latest version from an array of version objects. + * + * This function sorts an array of objects containing version information and returns the object + * with the highest version number. The sorting is performed in ascending order based on the + * `value` property of each object, and the last element of the sorted array, which represents + * the latest version, is returned. + * + * @param {{label: string, value: string}[]} versions - An array of objects with `label` and `value` + * properties where `value` is a version string. + * @returns {{label: string, value: string}} Returns the object with the highest version number. + * If the array is empty, returns an default fallback object. + * + * @example + * // Returns the latest version object + * getLatestVersion([ + * { label: 'Version 1.1', value: '1.1' }, + * { label: 'Version 2.0', value: '2.0' } + * ]); + * // Output: { label: '2.0', value: '2.0' } + */ export const getLatestVersion = ( versions: { label: string; value: string }[] -) => { - const versionsNumbersArray: number[] = []; +): { label: string; value: string } => { + const sortedVersions = versions.sort((a, b) => { + return sortByVersion(a.value, b.value, 'asc'); + }); + + const latestVersion = sortedVersions.pop(); - for (const element of versions) { - versionsNumbersArray.push(parseFloat(element.value)); + if (!latestVersion) { + // Return a default fallback object + return { label: '', value: '' }; } - const latestVersionValue = Math.max.apply(null, versionsNumbersArray); - return { label: `${latestVersionValue}`, value: `${latestVersionValue}` }; + return { label: `${latestVersion.value}`, value: `${latestVersion.value}` }; }; diff --git a/packages/manager/src/features/Linodes/LinodeEntityDetail.styles.ts b/packages/manager/src/features/Linodes/LinodeEntityDetail.styles.ts index 47af5c1e3da..f59d4fb895c 100644 --- a/packages/manager/src/features/Linodes/LinodeEntityDetail.styles.ts +++ b/packages/manager/src/features/Linodes/LinodeEntityDetail.styles.ts @@ -122,7 +122,8 @@ export const StyledTable = styled(Table, { label: 'StyledTable' })( whiteSpace: 'nowrap', }, '& th': { - backgroundColor: theme.bg.app, + backgroundColor: + theme.name === 'light' ? theme.color.grey10 : theme.bg.app, borderBottom: `1px solid ${theme.bg.bgPaper}`, color: theme.textColors.textAccessTable, fontFamily: theme.font.bold, @@ -136,6 +137,7 @@ export const StyledTable = styled(Table, { label: 'StyledTable' })( '& tr': { height: 32, }, + border: 'none', tableLayout: 'fixed', }) ); diff --git a/packages/manager/src/features/Linodes/LinodeEntityDetailFooter.tsx b/packages/manager/src/features/Linodes/LinodeEntityDetailFooter.tsx index 30d8a7313f2..db1b6ce6be0 100644 --- a/packages/manager/src/features/Linodes/LinodeEntityDetailFooter.tsx +++ b/packages/manager/src/features/Linodes/LinodeEntityDetailFooter.tsx @@ -4,6 +4,7 @@ import { useSnackbar } from 'notistack'; import * as React from 'react'; import { TagCell } from 'src/components/TagCell/TagCell'; +import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; import { useLinodeUpdateMutation } from 'src/queries/linodes/linodes'; import { useProfile } from 'src/queries/profile/profile'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; @@ -16,8 +17,8 @@ import { sxLastListItem, sxListItemFirstChild, } from './LinodeEntityDetail.styles'; -import { LinodeHandlers } from './LinodesLanding/LinodesLanding'; +import type { LinodeHandlers } from './LinodesLanding/LinodesLanding'; import type { Linode } from '@linode/api-v4/lib/linodes/types'; import type { TypographyProps } from 'src/components/Typography'; @@ -59,6 +60,11 @@ export const LinodeEntityDetailFooter = React.memo((props: FooterProps) => { openTagDrawer, } = props; + const isReadOnlyAccountAccess = useRestrictedGlobalGrantCheck({ + globalGrantType: 'account_access', + permittedGrantLevel: 'read_write', + }); + const { mutateAsync: updateLinode } = useLinodeUpdateMutation(linodeId); const { enqueueSnackbar } = useSnackbar(); @@ -157,7 +163,7 @@ export const LinodeEntityDetailFooter = React.memo((props: FooterProps) => { sx={{ width: '100%', }} - disabled={isLinodesGrantReadOnly} + disabled={isLinodesGrantReadOnly || isReadOnlyAccountAccess} listAllTags={openTagDrawer} tags={linodeTags} updateTags={updateTags} diff --git a/packages/manager/src/features/Linodes/LinodeEntityDetailHeader.tsx b/packages/manager/src/features/Linodes/LinodeEntityDetailHeader.tsx index 398a5284d51..09ceb219071 100644 --- a/packages/manager/src/features/Linodes/LinodeEntityDetailHeader.tsx +++ b/packages/manager/src/features/Linodes/LinodeEntityDetailHeader.tsx @@ -134,10 +134,21 @@ export const LinodeEntityDetailHeader = ( formattedTransitionText !== formattedStatus; const sxActionItem = { + '&:focus': { + color: theme.color.white, + }, '&:hover': { - backgroundColor: theme.color.blue, - color: '#fff', + '&[aria-disabled="true"]': { + color: theme.color.disabledText, + }, + + color: theme.color.white, + }, + '&[aria-disabled="true"]': { + background: 'transparent', + color: theme.color.disabledText, }, + background: 'transparent', color: theme.textColors.linkActiveLight, fontFamily: theme.font.normal, fontSize: '0.875rem', @@ -197,14 +208,14 @@ export const LinodeEntityDetailHeader = ( onClick={() => handlers.onOpenPowerDialog(isRunning ? 'Power Off' : 'Power On') } - buttonType="secondary" + buttonType="primary" disabled={!(isRunning || isOffline) || isLinodesGrantReadOnly} sx={sxActionItem} > {isRunning ? 'Power Off' : 'Power On'}