+ );
+ expect(() =>
+ assertOrder(container, '[data-testid]', ['First', 'Second', 'Third'])
+ ).not.toThrow();
+ expect(() =>
+ assertOrder(container, '[data-testid]', ['Third', 'Second', 'First'])
+ ).toThrow();
+ });
+ });
+});
diff --git a/packages/manager/src/utilities/testHelpers.tsx b/packages/manager/src/utilities/testHelpers.tsx
index 07818e547a6..0ae3424c2bf 100644
--- a/packages/manager/src/utilities/testHelpers.tsx
+++ b/packages/manager/src/utilities/testHelpers.tsx
@@ -1,5 +1,12 @@
import { QueryClientProvider } from '@tanstack/react-query';
-import { render } from '@testing-library/react';
+import {
+ RouterProvider,
+ createMemoryHistory,
+ createRootRoute,
+ createRoute,
+ createRouter,
+} from '@tanstack/react-router';
+import { act, render, waitFor } from '@testing-library/react';
import mediaQuery from 'css-mediaquery';
import { Formik } from 'formik';
import { LDProvider } from 'launchdarkly-react-client-sdk';
@@ -15,9 +22,12 @@ import thunk from 'redux-thunk';
import { LinodeThemeWrapper } from 'src/LinodeThemeWrapper';
import { queryClientFactory } from 'src/queries/base';
import { setupInterceptors } from 'src/request';
+import { migrationRouteTree } from 'src/routes';
import { defaultState, storeFactory } from 'src/store';
import type { QueryClient } from '@tanstack/react-query';
+// TODO: Tanstack Router - replace AnyRouter once migration is complete.
+import type { AnyRootRoute, AnyRouter } from '@tanstack/react-router';
import type { MatcherFunction, RenderResult } from '@testing-library/react';
import type { FormikConfig, FormikValues } from 'formik';
import type { FieldValues, UseFormProps } from 'react-hook-form';
@@ -100,6 +110,9 @@ export const wrapWithTheme = (ui: any, options: Options = {}) => {
options={{ bootstrap: options.flags }}
>
+ {/**
+ * TODO Tanstack Router - remove amy routing routing wrapWithTheme
+ */}
{routePath ? (
{uiToRender}
@@ -115,6 +128,121 @@ export const wrapWithTheme = (ui: any, options: Options = {}) => {
);
};
+interface OptionsWithRouter
+ extends Omit {
+ initialRoute?: string;
+ routeTree?: AnyRootRoute;
+ router?: AnyRouter;
+}
+
+/**
+ * We don't always need to use the router in our tests. When we do, due to the async nature of TanStack Router, we need to use this helper function.
+ * The reason we use this instead of extending renderWithTheme is because of having to make all tests async.
+ * It seems unnecessary to refactor all tests to async when we don't need to access the router at all.
+ *
+ * In order to use this, you must await the result of the function.
+ *
+ * @example
+ * const { getByText, router } = await renderWithThemeAndRouter(
+ * , {
+ * initialRoute: '/route',
+ * }
+ * );
+ *
+ * // Assert the initial route
+ * expect(router.state.location.pathname).toBe('/route');
+ *
+ * // from here, you can use the router to navigate
+ * await waitFor(() =>
+ * router.navigate({
+ * params: { betaId: beta.id },
+ * to: '/path/to/something',
+ * })
+ * );
+ *
+ * // And assert
+ * expect(router.state.location.pathname).toBe('/path/to/something');
+ *
+ * // and test the UI
+ * getByText('Some text');
+ */
+export const wrapWithThemeAndRouter = (
+ ui: React.ReactNode,
+ options: OptionsWithRouter = {}
+) => {
+ const {
+ customStore,
+ initialRoute = '/',
+ queryClient: passedQueryClient,
+ } = options;
+ const queryClient = passedQueryClient ?? queryClientFactory();
+ const storeToPass = customStore ? baseStore(customStore) : storeFactory();
+
+ setupInterceptors(
+ configureStore([thunk])(defaultState)
+ );
+
+ const rootRoute = createRootRoute({});
+ const indexRoute = createRoute({
+ component: () => ui,
+ getParentRoute: () => rootRoute,
+ path: initialRoute,
+ });
+ const router: AnyRouter = createRouter({
+ history: createMemoryHistory({
+ initialEntries: [initialRoute],
+ }),
+ routeTree: rootRoute.addChildren([indexRoute]),
+ });
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export const renderWithThemeAndRouter = async (
+ ui: React.ReactNode,
+ options: OptionsWithRouter = {}
+): Promise => {
+ const router = createRouter({
+ history: createMemoryHistory({
+ initialEntries: [options.initialRoute || '/'],
+ }),
+ routeTree: options.routeTree || migrationRouteTree,
+ });
+
+ let renderResult: RenderResult;
+
+ await act(async () => {
+ renderResult = render(wrapWithThemeAndRouter(ui, { ...options, router }));
+
+ // Wait for the router to be ready
+ await waitFor(() => expect(router.state.status).toBe('idle'));
+ });
+
+ return {
+ ...renderResult!,
+ rerender: (ui) =>
+ renderResult.rerender(wrapWithThemeAndRouter(ui, { ...options, router })),
+ router,
+ };
+};
+
/**
* Wraps children with just the Redux Store. This is
* useful for testing React hooks that need to access
From 85212bd56652253b61045f2aefb79b594350e228 Mon Sep 17 00:00:00 2001
From: jdamore-linode <97627410+jdamore-linode@users.noreply.github.com>
Date: Wed, 16 Oct 2024 14:59:05 -0400
Subject: [PATCH 20/64] test: Fix LKE update test failure following feature
flag update (#11113)
* Fix LKE update test failure by mocking APL to be disabled
* Added changeset: Mock APL feature flag to be disabled in LKE update tests
---------
Co-authored-by: Joe D'Amore
---
.../pr-11113-tests-1729099850186.md | 5 +
.../e2e/core/kubernetes/lke-update.spec.ts | 2280 +++++++++--------
2 files changed, 1159 insertions(+), 1126 deletions(-)
create mode 100644 packages/manager/.changeset/pr-11113-tests-1729099850186.md
diff --git a/packages/manager/.changeset/pr-11113-tests-1729099850186.md b/packages/manager/.changeset/pr-11113-tests-1729099850186.md
new file mode 100644
index 00000000000..93e47dfd08d
--- /dev/null
+++ b/packages/manager/.changeset/pr-11113-tests-1729099850186.md
@@ -0,0 +1,5 @@
+---
+"@linode/manager": Tests
+---
+
+Mock APL feature flag to be disabled in LKE update tests ([#11113](https://github.com/linode/manager/pull/11113))
diff --git a/packages/manager/cypress/e2e/core/kubernetes/lke-update.spec.ts b/packages/manager/cypress/e2e/core/kubernetes/lke-update.spec.ts
index 8b78f9907d8..f56f0e93cb8 100644
--- a/packages/manager/cypress/e2e/core/kubernetes/lke-update.spec.ts
+++ b/packages/manager/cypress/e2e/core/kubernetes/lke-update.spec.ts
@@ -32,1284 +32,1312 @@ import { ui } from 'support/ui';
import { randomIp, randomLabel } from 'support/util/random';
import { getRegionById } from 'support/util/regions';
import { dcPricingMockLinodeTypes } from 'support/constants/dc-specific-pricing';
+import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags';
const mockNodePools = nodePoolFactory.buildList(2);
describe('LKE cluster updates', () => {
- /*
- * - Confirms UI flow of upgrading a cluster to high availability control plane using mocked data.
- * - Confirms that user is shown a warning and agrees to billing changes before upgrading.
- * - Confirms that details page updates accordingly after upgrading to high availability.
- */
- it('can upgrade to high availability', () => {
- const mockCluster = kubernetesClusterFactory.build({
- k8s_version: latestKubernetesVersion,
- control_plane: {
- high_availability: false,
- },
+ // TODO Add LKE update tests to cover flows when APL is enabled.
+ describe('APL disabled', () => {
+ beforeEach(() => {
+ // Mock the APL feature flag to be disabled.
+ mockAppendFeatureFlags({
+ apl: false,
+ });
});
- const mockClusterWithHA = {
- ...mockCluster,
- control_plane: {
- high_availability: true,
- },
- };
-
- const haUpgradeWarnings = [
- 'All nodes will be deleted and new nodes will be created to replace them.',
- 'Any local storage (such as ’hostPath’ volumes) will be erased.',
- 'This may take several minutes, as nodes will be replaced on a rolling basis.',
- ];
-
- const haUpgradeAgreement =
- 'I agree to the additional fee on my monthly bill and understand HA upgrade can only be reversed by deleting my cluster';
-
- mockGetCluster(mockCluster).as('getCluster');
- mockGetClusterPools(mockCluster.id, mockNodePools).as('getNodePools');
- mockGetKubernetesVersions().as('getVersions');
- mockUpdateCluster(mockCluster.id, mockClusterWithHA).as('updateCluster');
- mockGetDashboardUrl(mockCluster.id);
- mockGetApiEndpoints(mockCluster.id);
-
- cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`);
- cy.wait(['@getCluster', '@getNodePools', '@getVersions']);
-
- // Initiate high availability upgrade and agree to changes.
- ui.button
- .findByTitle('Upgrade to HA')
- .should('be.visible')
- .should('be.enabled')
- .click();
-
- ui.dialog
- .findByTitle('Upgrade to High Availability')
- .should('be.visible')
- .within(() => {
- haUpgradeWarnings.forEach((warning: string) => {
- cy.findByText(warning).should('be.visible');
- });
-
- cy.findByText(haUpgradeAgreement, { exact: false })
- .should('be.visible')
- .closest('label')
- .click();
-
- ui.button
- .findByTitle('Upgrade to HA')
- .should('be.visible')
- .should('be.enabled')
- .click();
+ /*
+ * - Confirms UI flow of upgrading a cluster to high availability control plane using mocked data.
+ * - Confirms that user is shown a warning and agrees to billing changes before upgrading.
+ * - Confirms that details page updates accordingly after upgrading to high availability.
+ */
+ it('can upgrade to high availability', () => {
+ const mockCluster = kubernetesClusterFactory.build({
+ k8s_version: latestKubernetesVersion,
+ control_plane: {
+ high_availability: false,
+ },
});
- // Confirm toast message appears and HA Cluster chip is shown.
- cy.wait('@updateCluster');
- ui.toast.assertMessage('Enabled HA Control Plane');
- cy.findByText('HA CLUSTER').should('be.visible');
- cy.findByText('Upgrade to HA').should('not.exist');
- });
+ const mockClusterWithHA = {
+ ...mockCluster,
+ control_plane: {
+ high_availability: true,
+ },
+ };
+
+ const haUpgradeWarnings = [
+ 'All nodes will be deleted and new nodes will be created to replace them.',
+ 'Any local storage (such as ’hostPath’ volumes) will be erased.',
+ 'This may take several minutes, as nodes will be replaced on a rolling basis.',
+ ];
+
+ const haUpgradeAgreement =
+ 'I agree to the additional fee on my monthly bill and understand HA upgrade can only be reversed by deleting my cluster';
+
+ mockGetCluster(mockCluster).as('getCluster');
+ mockGetClusterPools(mockCluster.id, mockNodePools).as('getNodePools');
+ mockGetKubernetesVersions().as('getVersions');
+ mockUpdateCluster(mockCluster.id, mockClusterWithHA).as('updateCluster');
+ mockGetDashboardUrl(mockCluster.id);
+ mockGetApiEndpoints(mockCluster.id);
+
+ cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`);
+ cy.wait(['@getCluster', '@getNodePools', '@getVersions']);
+
+ // Initiate high availability upgrade and agree to changes.
+ ui.button
+ .findByTitle('Upgrade to HA')
+ .should('be.visible')
+ .should('be.enabled')
+ .click();
- /*
- * - Confirms UI flow of upgrading Kubernetes version using mocked API requests.
- * - Confirms that Kubernetes upgrade prompt is shown when not up-to-date.
- * - Confirms that Kubernetes upgrade prompt is hidden when up-to-date.
- */
- it('can upgrade kubernetes version from the details page', () => {
- const oldVersion = '1.25';
- const newVersion = '1.26';
-
- const mockCluster = kubernetesClusterFactory.build({
- k8s_version: oldVersion,
- });
+ ui.dialog
+ .findByTitle('Upgrade to High Availability')
+ .should('be.visible')
+ .within(() => {
+ haUpgradeWarnings.forEach((warning: string) => {
+ cy.findByText(warning).should('be.visible');
+ });
+
+ cy.findByText(haUpgradeAgreement, { exact: false })
+ .should('be.visible')
+ .closest('label')
+ .click();
- const mockClusterUpdated = {
- ...mockCluster,
- k8s_version: newVersion,
- };
-
- const upgradePrompt = 'A new version of Kubernetes is available (1.26).';
-
- const upgradeNotes = [
- 'Once the upgrade is complete you will need to recycle all nodes in your cluster',
- // Confirm that the old version and new version are both shown.
- oldVersion,
- newVersion,
- ];
-
- mockGetCluster(mockCluster).as('getCluster');
- mockGetKubernetesVersions([newVersion, oldVersion]).as('getVersions');
- mockGetClusterPools(mockCluster.id, mockNodePools).as('getNodePools');
- mockUpdateCluster(mockCluster.id, mockClusterUpdated).as('updateCluster');
- mockGetDashboardUrl(mockCluster.id);
- mockGetApiEndpoints(mockCluster.id);
-
- cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`);
- cy.wait(['@getCluster', '@getNodePools', '@getVersions']);
-
- // Confirm that upgrade prompt is shown.
- cy.findByText(upgradePrompt).should('be.visible');
- ui.button
- .findByTitle('Upgrade Version')
- .should('be.visible')
- .should('be.enabled')
- .click();
-
- ui.dialog
- .findByTitle(
- `Step 1: Upgrade ${mockCluster.label} to Kubernetes ${newVersion}`
- )
- .should('be.visible')
- .within(() => {
- upgradeNotes.forEach((note: string) => {
- cy.findAllByText(note, { exact: false }).should('be.visible');
+ ui.button
+ .findByTitle('Upgrade to HA')
+ .should('be.visible')
+ .should('be.enabled')
+ .click();
});
- ui.button
- .findByTitle('Upgrade Version')
- .should('be.visible')
- .should('be.enabled')
- .click();
+ // Confirm toast message appears and HA Cluster chip is shown.
+ cy.wait('@updateCluster');
+ ui.toast.assertMessage('Enabled HA Control Plane');
+ cy.findByText('HA CLUSTER').should('be.visible');
+ cy.findByText('Upgrade to HA').should('not.exist');
+ });
+
+ /*
+ * - Confirms UI flow of upgrading Kubernetes version using mocked API requests.
+ * - Confirms that Kubernetes upgrade prompt is shown when not up-to-date.
+ * - Confirms that Kubernetes upgrade prompt is hidden when up-to-date.
+ */
+ it('can upgrade kubernetes version from the details page', () => {
+ const oldVersion = '1.25';
+ const newVersion = '1.26';
+
+ const mockCluster = kubernetesClusterFactory.build({
+ k8s_version: oldVersion,
});
- // Wait for API response and assert toast message is shown.
- cy.wait('@updateCluster');
+ const mockClusterUpdated = {
+ ...mockCluster,
+ k8s_version: newVersion,
+ };
+
+ const upgradePrompt = 'A new version of Kubernetes is available (1.26).';
+
+ const upgradeNotes = [
+ 'Once the upgrade is complete you will need to recycle all nodes in your cluster',
+ // Confirm that the old version and new version are both shown.
+ oldVersion,
+ newVersion,
+ ];
+
+ mockGetCluster(mockCluster).as('getCluster');
+ mockGetKubernetesVersions([newVersion, oldVersion]).as('getVersions');
+ mockGetClusterPools(mockCluster.id, mockNodePools).as('getNodePools');
+ mockUpdateCluster(mockCluster.id, mockClusterUpdated).as('updateCluster');
+ mockGetDashboardUrl(mockCluster.id);
+ mockGetApiEndpoints(mockCluster.id);
+
+ cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`);
+ cy.wait(['@getCluster', '@getNodePools', '@getVersions']);
+
+ // Confirm that upgrade prompt is shown.
+ cy.findByText(upgradePrompt).should('be.visible');
+ ui.button
+ .findByTitle('Upgrade Version')
+ .should('be.visible')
+ .should('be.enabled')
+ .click();
- // Verify the banner goes away because the version update has happened
- cy.findByText(upgradePrompt).should('not.exist');
+ ui.dialog
+ .findByTitle(
+ `Step 1: Upgrade ${mockCluster.label} to Kubernetes ${newVersion}`
+ )
+ .should('be.visible')
+ .within(() => {
+ upgradeNotes.forEach((note: string) => {
+ cy.findAllByText(note, { exact: false }).should('be.visible');
+ });
- mockRecycleAllNodes(mockCluster.id).as('recycleAllNodes');
+ ui.button
+ .findByTitle('Upgrade Version')
+ .should('be.visible')
+ .should('be.enabled')
+ .click();
+ });
- const stepTwoDialogTitle = 'Step 2: Recycle All Cluster Nodes';
+ // Wait for API response and assert toast message is shown.
+ cy.wait('@updateCluster');
- ui.dialog
- .findByTitle(stepTwoDialogTitle)
- .should('be.visible')
- .within(() => {
- cy.findByText('Kubernetes version has been updated successfully.', {
- exact: false,
- }).should('be.visible');
+ // Verify the banner goes away because the version update has happened
+ cy.findByText(upgradePrompt).should('not.exist');
- cy.findByText(
- 'For the changes to take full effect you must recycle the nodes in your cluster.',
- { exact: false }
- ).should('be.visible');
+ mockRecycleAllNodes(mockCluster.id).as('recycleAllNodes');
- ui.button
- .findByTitle('Recycle All Nodes')
- .should('be.visible')
- .should('be.enabled')
- .click();
- });
+ const stepTwoDialogTitle = 'Step 2: Recycle All Cluster Nodes';
- // Verify clicking the "Recycle All Nodes" makes an API call
- cy.wait('@recycleAllNodes');
+ ui.dialog
+ .findByTitle(stepTwoDialogTitle)
+ .should('be.visible')
+ .within(() => {
+ cy.findByText('Kubernetes version has been updated successfully.', {
+ exact: false,
+ }).should('be.visible');
+
+ cy.findByText(
+ 'For the changes to take full effect you must recycle the nodes in your cluster.',
+ { exact: false }
+ ).should('be.visible');
+
+ ui.button
+ .findByTitle('Recycle All Nodes')
+ .should('be.visible')
+ .should('be.enabled')
+ .click();
+ });
- // Verify the upgrade dialog closed
- cy.findByText(stepTwoDialogTitle).should('not.exist');
+ // Verify clicking the "Recycle All Nodes" makes an API call
+ cy.wait('@recycleAllNodes');
- // Verify the banner is still gone after the flow
- cy.findByText(upgradePrompt).should('not.exist');
+ // Verify the upgrade dialog closed
+ cy.findByText(stepTwoDialogTitle).should('not.exist');
- // Verify the version is correct after the update
- cy.findByText(`Version ${newVersion}`);
+ // Verify the banner is still gone after the flow
+ cy.findByText(upgradePrompt).should('not.exist');
- ui.toast.findByMessage('Recycle started successfully.');
- });
+ // Verify the version is correct after the update
+ cy.findByText(`Version ${newVersion}`);
- it('can upgrade the kubernetes version from the landing page', () => {
- const oldVersion = '1.25';
- const newVersion = '1.26';
-
- const cluster = kubernetesClusterFactory.build({
- k8s_version: oldVersion,
+ ui.toast.findByMessage('Recycle started successfully.');
});
- const updatedCluster = { ...cluster, k8s_version: newVersion };
+ it('can upgrade the kubernetes version from the landing page', () => {
+ const oldVersion = '1.25';
+ const newVersion = '1.26';
- mockGetClusters([cluster]).as('getClusters');
- mockGetKubernetesVersions([newVersion, oldVersion]).as('getVersions');
- mockUpdateCluster(cluster.id, updatedCluster).as('updateCluster');
- mockRecycleAllNodes(cluster.id).as('recycleAllNodes');
+ const cluster = kubernetesClusterFactory.build({
+ k8s_version: oldVersion,
+ });
- cy.visitWithLogin(`/kubernetes/clusters`);
+ const updatedCluster = { ...cluster, k8s_version: newVersion };
- cy.wait(['@getClusters', '@getVersions']);
+ mockGetClusters([cluster]).as('getClusters');
+ mockGetKubernetesVersions([newVersion, oldVersion]).as('getVersions');
+ mockUpdateCluster(cluster.id, updatedCluster).as('updateCluster');
+ mockRecycleAllNodes(cluster.id).as('recycleAllNodes');
- cy.findByText(oldVersion).should('be.visible');
+ cy.visitWithLogin(`/kubernetes/clusters`);
- cy.findByText('UPGRADE').should('be.visible').should('be.enabled').click();
+ cy.wait(['@getClusters', '@getVersions']);
- ui.dialog
- .findByTitle(
- `Step 1: Upgrade ${cluster.label} to Kubernetes ${newVersion}`
- )
- .should('be.visible');
+ cy.findByText(oldVersion).should('be.visible');
- mockGetClusters([updatedCluster]).as('getClusters');
+ cy.findByText('UPGRADE')
+ .should('be.visible')
+ .should('be.enabled')
+ .click();
- ui.button
- .findByTitle('Upgrade Version')
- .should('be.visible')
- .should('be.enabled')
- .click();
+ ui.dialog
+ .findByTitle(
+ `Step 1: Upgrade ${cluster.label} to Kubernetes ${newVersion}`
+ )
+ .should('be.visible');
- cy.wait(['@updateCluster', '@getClusters']);
+ mockGetClusters([updatedCluster]).as('getClusters');
- ui.dialog
- .findByTitle('Step 2: Recycle All Cluster Nodes')
- .should('be.visible');
+ ui.button
+ .findByTitle('Upgrade Version')
+ .should('be.visible')
+ .should('be.enabled')
+ .click();
- ui.button
- .findByTitle('Recycle All Nodes')
- .should('be.visible')
- .should('be.enabled')
- .click();
+ cy.wait(['@updateCluster', '@getClusters']);
- cy.wait('@recycleAllNodes');
+ ui.dialog
+ .findByTitle('Step 2: Recycle All Cluster Nodes')
+ .should('be.visible');
- ui.toast.assertMessage('Recycle started successfully.');
+ ui.button
+ .findByTitle('Recycle All Nodes')
+ .should('be.visible')
+ .should('be.enabled')
+ .click();
- cy.findByText(newVersion).should('be.visible');
- });
+ cy.wait('@recycleAllNodes');
- /*
- * - Confirms node, node pool, and cluster recycling UI flow using mocked API data.
- * - Confirms that user is warned that recycling recreates nodes and may take a while.
- */
- it('can recycle nodes', () => {
- const mockCluster = kubernetesClusterFactory.build({
- k8s_version: latestKubernetesVersion,
- });
+ ui.toast.assertMessage('Recycle started successfully.');
- const mockKubeLinode = kubeLinodeFactory.build();
-
- const mockNodePool = nodePoolFactory.build({
- count: 1,
- type: 'g6-standard-1',
- nodes: [mockKubeLinode],
+ cy.findByText(newVersion).should('be.visible');
});
- const mockLinode = linodeFactory.build({
- label: randomLabel(),
- id: mockKubeLinode.instance_id ?? undefined,
- });
+ /*
+ * - Confirms node, node pool, and cluster recycling UI flow using mocked API data.
+ * - Confirms that user is warned that recycling recreates nodes and may take a while.
+ */
+ it('can recycle nodes', () => {
+ const mockCluster = kubernetesClusterFactory.build({
+ k8s_version: latestKubernetesVersion,
+ });
- const recycleWarningSubstrings = [
- 'will be deleted',
- 'will be created',
- 'local storage (such as ’hostPath’ volumes) will be erased',
- 'may take several minutes',
- ];
-
- mockGetCluster(mockCluster).as('getCluster');
- mockGetClusterPools(mockCluster.id, [mockNodePool]).as('getNodePools');
- mockGetLinodes([mockLinode]).as('getLinodes');
- mockGetKubernetesVersions().as('getVersions');
- mockGetDashboardUrl(mockCluster.id);
- mockGetApiEndpoints(mockCluster.id);
-
- cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`);
- cy.wait(['@getCluster', '@getNodePools', '@getLinodes', '@getVersions']);
-
- // Recycle individual node.
- ui.button
- .findByTitle('Recycle')
- .should('be.visible')
- .should('be.enabled')
- .click();
-
- mockRecycleNode(mockCluster.id, mockKubeLinode.id).as('recycleNode');
- ui.dialog
- .findByTitle(`Recycle ${mockKubeLinode.id}?`)
- .should('be.visible')
- .within(() => {
- recycleWarningSubstrings.forEach((warning: string) => {
- cy.findByText(warning, { exact: false }).should('be.visible');
- });
+ const mockKubeLinode = kubeLinodeFactory.build();
- ui.button
- .findByTitle('Recycle')
- .should('be.visible')
- .should('be.enabled')
- .click();
+ const mockNodePool = nodePoolFactory.build({
+ count: 1,
+ type: 'g6-standard-1',
+ nodes: [mockKubeLinode],
});
- cy.wait('@recycleNode');
- ui.toast.assertMessage('Node queued for recycling.');
-
- ui.button
- .findByTitle('Recycle Pool Nodes')
- .should('be.visible')
- .should('be.enabled')
- .click();
-
- mockRecycleNodePool(mockCluster.id, mockNodePool.id).as('recycleNodePool');
- ui.dialog
- .findByTitle('Recycle node pool?')
- .should('be.visible')
- .within(() => {
- ui.button
- .findByTitle('Recycle Pool Nodes')
- .should('be.visible')
- .should('be.enabled')
- .click();
+ const mockLinode = linodeFactory.build({
+ label: randomLabel(),
+ id: mockKubeLinode.instance_id ?? undefined,
});
- cy.wait('@recycleNodePool');
- ui.toast.assertMessage(
- `Recycled all nodes in node pool ${mockNodePool.id}`
- );
-
- ui.button
- .findByTitle('Recycle All Nodes')
- .should('be.visible')
- .should('be.enabled')
- .click();
-
- mockRecycleAllNodes(mockCluster.id).as('recycleAllNodes');
- ui.dialog
- .findByTitle('Recycle all nodes in cluster?')
- .should('be.visible')
- .within(() => {
- recycleWarningSubstrings.forEach((warning: string) => {
- cy.findByText(warning, { exact: false }).should('be.visible');
- });
-
- ui.button
- .findByTitle('Recycle All Cluster Nodes')
- .should('be.visible')
- .should('be.enabled')
- .click();
- });
+ const recycleWarningSubstrings = [
+ 'will be deleted',
+ 'will be created',
+ 'local storage (such as ’hostPath’ volumes) will be erased',
+ 'may take several minutes',
+ ];
+
+ mockGetCluster(mockCluster).as('getCluster');
+ mockGetClusterPools(mockCluster.id, [mockNodePool]).as('getNodePools');
+ mockGetLinodes([mockLinode]).as('getLinodes');
+ mockGetKubernetesVersions().as('getVersions');
+ mockGetDashboardUrl(mockCluster.id);
+ mockGetApiEndpoints(mockCluster.id);
+
+ cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`);
+ cy.wait(['@getCluster', '@getNodePools', '@getLinodes', '@getVersions']);
+
+ // Recycle individual node.
+ ui.button
+ .findByTitle('Recycle')
+ .should('be.visible')
+ .should('be.enabled')
+ .click();
- cy.wait('@recycleAllNodes');
- ui.toast.assertMessage('All cluster nodes queued for recycling');
- });
+ mockRecycleNode(mockCluster.id, mockKubeLinode.id).as('recycleNode');
+ ui.dialog
+ .findByTitle(`Recycle ${mockKubeLinode.id}?`)
+ .should('be.visible')
+ .within(() => {
+ recycleWarningSubstrings.forEach((warning: string) => {
+ cy.findByText(warning, { exact: false }).should('be.visible');
+ });
- /*
- * - Confirms UI flow when enabling and disabling node pool autoscaling using mocked API responses.
- * - Confirms that errors are shown when attempting to autoscale using invalid values.
- * - Confirms that UI updates to reflect node pool autoscale state.
- */
- it('can toggle autoscaling', () => {
- const autoscaleMin = 3;
- const autoscaleMax = 10;
-
- const minWarning =
- 'Minimum must be between 1 and 99 nodes and cannot be greater than Maximum.';
- const maxWarning = 'Maximum must be between 1 and 100 nodes.';
-
- const mockCluster = kubernetesClusterFactory.build({
- k8s_version: latestKubernetesVersion,
- });
+ ui.button
+ .findByTitle('Recycle')
+ .should('be.visible')
+ .should('be.enabled')
+ .click();
+ });
- const mockNodePool = nodePoolFactory.build({
- count: 1,
- type: 'g6-standard-1',
- nodes: kubeLinodeFactory.buildList(1),
- });
+ cy.wait('@recycleNode');
+ ui.toast.assertMessage('Node queued for recycling.');
- const mockNodePoolAutoscale = {
- ...mockNodePool,
- autoscaler: {
- enabled: true,
- min: autoscaleMin,
- max: autoscaleMax,
- },
- };
-
- mockGetCluster(mockCluster).as('getCluster');
- mockGetClusterPools(mockCluster.id, [mockNodePool]).as('getNodePools');
- mockGetKubernetesVersions().as('getVersions');
- mockGetDashboardUrl(mockCluster.id);
- mockGetApiEndpoints(mockCluster.id);
-
- cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`);
- cy.wait(['@getCluster', '@getNodePools', '@getVersions']);
-
- // Click "Autoscale Pool", enable autoscaling, and set min and max values.
- mockUpdateNodePool(mockCluster.id, mockNodePoolAutoscale).as(
- 'toggleAutoscale'
- );
- mockGetClusterPools(mockCluster.id, [mockNodePoolAutoscale]).as(
- 'getNodePools'
- );
- ui.button
- .findByTitle('Autoscale Pool')
- .should('be.visible')
- .should('be.enabled')
- .click();
-
- ui.dialog
- .findByTitle('Autoscale Pool')
- .should('be.visible')
- .within(() => {
- cy.findByText('Autoscaler').should('be.visible').click();
-
- cy.findByLabelText('Min')
- .should('be.visible')
- .click()
- .clear()
- .type(`${autoscaleMin}`);
+ ui.button
+ .findByTitle('Recycle Pool Nodes')
+ .should('be.visible')
+ .should('be.enabled')
+ .click();
+
+ mockRecycleNodePool(mockCluster.id, mockNodePool.id).as(
+ 'recycleNodePool'
+ );
+ ui.dialog
+ .findByTitle('Recycle node pool?')
+ .should('be.visible')
+ .within(() => {
+ ui.button
+ .findByTitle('Recycle Pool Nodes')
+ .should('be.visible')
+ .should('be.enabled')
+ .click();
+ });
- cy.findByText(minWarning).should('be.visible');
+ cy.wait('@recycleNodePool');
+ ui.toast.assertMessage(
+ `Recycled all nodes in node pool ${mockNodePool.id}`
+ );
- cy.findByLabelText('Max')
- .should('be.visible')
- .click()
- .clear()
- .type('101');
+ ui.button
+ .findByTitle('Recycle All Nodes')
+ .should('be.visible')
+ .should('be.enabled')
+ .click();
- cy.findByText(minWarning).should('not.exist');
- cy.findByText(maxWarning).should('be.visible');
+ mockRecycleAllNodes(mockCluster.id).as('recycleAllNodes');
+ ui.dialog
+ .findByTitle('Recycle all nodes in cluster?')
+ .should('be.visible')
+ .within(() => {
+ recycleWarningSubstrings.forEach((warning: string) => {
+ cy.findByText(warning, { exact: false }).should('be.visible');
+ });
- cy.findByLabelText('Max')
- .should('be.visible')
- .click()
- .clear()
- .type(`${autoscaleMax}`);
+ ui.button
+ .findByTitle('Recycle All Cluster Nodes')
+ .should('be.visible')
+ .should('be.enabled')
+ .click();
+ });
- cy.findByText(minWarning).should('not.exist');
- cy.findByText(maxWarning).should('not.exist');
+ cy.wait('@recycleAllNodes');
+ ui.toast.assertMessage('All cluster nodes queued for recycling');
+ });
- ui.button.findByTitle('Save Changes').should('be.visible').click();
+ /*
+ * - Confirms UI flow when enabling and disabling node pool autoscaling using mocked API responses.
+ * - Confirms that errors are shown when attempting to autoscale using invalid values.
+ * - Confirms that UI updates to reflect node pool autoscale state.
+ */
+ it('can toggle autoscaling', () => {
+ const autoscaleMin = 3;
+ const autoscaleMax = 10;
+
+ const minWarning =
+ 'Minimum must be between 1 and 99 nodes and cannot be greater than Maximum.';
+ const maxWarning = 'Maximum must be between 1 and 100 nodes.';
+
+ const mockCluster = kubernetesClusterFactory.build({
+ k8s_version: latestKubernetesVersion,
});
- // Wait for API response and confirm that UI updates to reflect autoscale.
- cy.wait(['@toggleAutoscale', '@getNodePools']);
- ui.toast.assertMessage(
- `Autoscaling updated for Node Pool ${mockNodePool.id}.`
- );
- cy.findByText(`(Min ${autoscaleMin} / Max ${autoscaleMax})`).should(
- 'be.visible'
- );
-
- // Click "Autoscale Pool" again and disable autoscaling.
- mockUpdateNodePool(mockCluster.id, mockNodePool).as('toggleAutoscale');
- mockGetClusterPools(mockCluster.id, [mockNodePool]).as('getNodePools');
- ui.button
- .findByTitle('Autoscale Pool')
- .should('be.visible')
- .should('be.enabled')
- .click();
-
- ui.dialog
- .findByTitle('Autoscale Pool')
- .should('be.visible')
- .within(() => {
- cy.findByText('Autoscaler').should('be.visible').click();
-
- ui.button
- .findByTitle('Save Changes')
- .should('be.visible')
- .should('be.enabled')
- .click();
+ const mockNodePool = nodePoolFactory.build({
+ count: 1,
+ type: 'g6-standard-1',
+ nodes: kubeLinodeFactory.buildList(1),
});
- // Wait for API response and confirm that UI updates to reflect no autoscale.
- cy.wait(['@toggleAutoscale', '@getNodePools']);
- ui.toast.assertMessage(
- `Autoscaling updated for Node Pool ${mockNodePool.id}.`
- );
- cy.findByText(`(Min ${autoscaleMin} / Max ${autoscaleMax})`).should(
- 'not.exist'
- );
- });
-
- /*
- * - Confirms node pool resize UI flow using mocked API responses.
- * - Confirms that pool size can be increased and decreased.
- * - Confirms that user is warned when decreasing node pool size.
- * - Confirms that UI updates to reflect new node pool size.
- */
- it('can resize pools', () => {
- const mockCluster = kubernetesClusterFactory.build({
- k8s_version: latestKubernetesVersion,
- });
-
- const mockNodePoolResized = nodePoolFactory.build({
- count: 3,
- type: 'g6-standard-1',
- nodes: kubeLinodeFactory.buildList(3),
- });
+ const mockNodePoolAutoscale = {
+ ...mockNodePool,
+ autoscaler: {
+ enabled: true,
+ min: autoscaleMin,
+ max: autoscaleMax,
+ },
+ };
+
+ mockGetCluster(mockCluster).as('getCluster');
+ mockGetClusterPools(mockCluster.id, [mockNodePool]).as('getNodePools');
+ mockGetKubernetesVersions().as('getVersions');
+ mockGetDashboardUrl(mockCluster.id);
+ mockGetApiEndpoints(mockCluster.id);
+
+ cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`);
+ cy.wait(['@getCluster', '@getNodePools', '@getVersions']);
+
+ // Click "Autoscale Pool", enable autoscaling, and set min and max values.
+ mockUpdateNodePool(mockCluster.id, mockNodePoolAutoscale).as(
+ 'toggleAutoscale'
+ );
+ mockGetClusterPools(mockCluster.id, [mockNodePoolAutoscale]).as(
+ 'getNodePools'
+ );
+ ui.button
+ .findByTitle('Autoscale Pool')
+ .should('be.visible')
+ .should('be.enabled')
+ .click();
- const mockNodePoolInitial = {
- ...mockNodePoolResized,
- count: 1,
- nodes: [mockNodePoolResized.nodes[0]],
- };
-
- const mockLinodes: Linode[] = mockNodePoolResized.nodes.map(
- (node: PoolNodeResponse): Linode => {
- return linodeFactory.build({
- id: node.instance_id ?? undefined,
- ipv4: [randomIp()],
- });
- }
- );
-
- const mockNodePoolDrawerTitle = 'Resize Pool: Linode 2 GB Plan';
-
- const decreaseSizeWarning =
- 'Resizing to fewer nodes will delete random nodes from the pool.';
- const nodeSizeRecommendation =
- 'We recommend a minimum of 3 nodes in each Node Pool to avoid downtime during upgrades and maintenance.';
-
- mockGetCluster(mockCluster).as('getCluster');
- mockGetClusterPools(mockCluster.id, [mockNodePoolInitial]).as(
- 'getNodePools'
- );
- mockGetLinodes(mockLinodes).as('getLinodes');
- mockGetKubernetesVersions().as('getVersions');
- mockGetDashboardUrl(mockCluster.id);
- mockGetApiEndpoints(mockCluster.id);
-
- cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`);
- cy.wait(['@getCluster', '@getNodePools', '@getLinodes', '@getVersions']);
-
- // Confirm that nodes are listed with correct details.
- mockNodePoolInitial.nodes.forEach((node: PoolNodeResponse) => {
- cy.get(`tr[data-qa-node-row="${node.id}"]`)
+ ui.dialog
+ .findByTitle('Autoscale Pool')
.should('be.visible')
.within(() => {
- const nodeLinode = mockLinodes.find(
- (linode: Linode) => linode.id === node.instance_id
- );
- if (nodeLinode) {
- cy.findByText(nodeLinode.label).should('be.visible');
- cy.findByText(nodeLinode.ipv4[0]).should('be.visible');
- ui.button
- .findByTitle('Recycle')
- .should('be.visible')
- .should('be.enabled');
- }
- });
- });
+ cy.findByText('Autoscaler').should('be.visible').click();
- // Click "Resize Pool" and increase size to 3 nodes.
- ui.button
- .findByTitle('Resize Pool')
- .should('be.visible')
- .should('be.enabled')
- .click();
-
- mockUpdateNodePool(mockCluster.id, mockNodePoolResized).as(
- 'resizeNodePool'
- );
- mockGetClusterPools(mockCluster.id, [mockNodePoolResized]).as(
- 'getNodePools'
- );
- ui.drawer
- .findByTitle(mockNodePoolDrawerTitle)
- .should('be.visible')
- .within(() => {
- ui.button
- .findByTitle('Save Changes')
- .should('be.visible')
- .should('be.disabled');
+ cy.findByLabelText('Min')
+ .should('be.visible')
+ .click()
+ .clear()
+ .type(`${autoscaleMin}`);
- cy.findByText('Resized pool: $12/month (1 node at $12/month)').should(
- 'be.visible'
- );
+ cy.findByText(minWarning).should('be.visible');
- cy.findByLabelText('Add 1')
- .should('be.visible')
- .should('be.enabled')
- .click()
- .click();
+ cy.findByLabelText('Max')
+ .should('be.visible')
+ .click()
+ .clear()
+ .type('101');
- cy.findByLabelText('Edit Quantity').should('have.value', '3');
- cy.findByText('Resized pool: $36/month (3 nodes at $12/month)').should(
- 'be.visible'
- );
+ cy.findByText(minWarning).should('not.exist');
+ cy.findByText(maxWarning).should('be.visible');
- ui.button
- .findByTitle('Save Changes')
- .should('be.visible')
- .should('be.enabled')
- .click();
- });
+ cy.findByLabelText('Max')
+ .should('be.visible')
+ .click()
+ .clear()
+ .type(`${autoscaleMax}`);
- cy.wait(['@resizeNodePool', '@getNodePools']);
+ cy.findByText(minWarning).should('not.exist');
+ cy.findByText(maxWarning).should('not.exist');
- // Confirm that new nodes are listed with correct info.
- mockLinodes.forEach((mockLinode: Linode) => {
- cy.findByText(mockLinode.label)
+ ui.button.findByTitle('Save Changes').should('be.visible').click();
+ });
+
+ // Wait for API response and confirm that UI updates to reflect autoscale.
+ cy.wait(['@toggleAutoscale', '@getNodePools']);
+ ui.toast.assertMessage(
+ `Autoscaling updated for Node Pool ${mockNodePool.id}.`
+ );
+ cy.findByText(`(Min ${autoscaleMin} / Max ${autoscaleMax})`).should(
+ 'be.visible'
+ );
+
+ // Click "Autoscale Pool" again and disable autoscaling.
+ mockUpdateNodePool(mockCluster.id, mockNodePool).as('toggleAutoscale');
+ mockGetClusterPools(mockCluster.id, [mockNodePool]).as('getNodePools');
+ ui.button
+ .findByTitle('Autoscale Pool')
+ .should('be.visible')
+ .should('be.enabled')
+ .click();
+
+ ui.dialog
+ .findByTitle('Autoscale Pool')
.should('be.visible')
- .closest('tr')
.within(() => {
- cy.findByText(mockLinode.ipv4[0]).should('be.visible');
+ cy.findByText('Autoscaler').should('be.visible').click();
+
+ ui.button
+ .findByTitle('Save Changes')
+ .should('be.visible')
+ .should('be.enabled')
+ .click();
});
+
+ // Wait for API response and confirm that UI updates to reflect no autoscale.
+ cy.wait(['@toggleAutoscale', '@getNodePools']);
+ ui.toast.assertMessage(
+ `Autoscaling updated for Node Pool ${mockNodePool.id}.`
+ );
+ cy.findByText(`(Min ${autoscaleMin} / Max ${autoscaleMax})`).should(
+ 'not.exist'
+ );
});
- // Click "Resize Pool" and decrease size back to 1 node.
- ui.button
- .findByTitle('Resize Pool')
- .should('be.visible')
- .should('be.enabled')
- .click();
-
- mockUpdateNodePool(mockCluster.id, mockNodePoolInitial).as(
- 'resizeNodePool'
- );
- mockGetClusterPools(mockCluster.id, [mockNodePoolInitial]).as(
- 'getNodePools'
- );
- ui.drawer
- .findByTitle(mockNodePoolDrawerTitle)
- .should('be.visible')
- .within(() => {
- cy.findByLabelText('Subtract 1')
- .should('be.visible')
- .should('be.enabled')
- .click()
- .click();
+ /*
+ * - Confirms node pool resize UI flow using mocked API responses.
+ * - Confirms that pool size can be increased and decreased.
+ * - Confirms that user is warned when decreasing node pool size.
+ * - Confirms that UI updates to reflect new node pool size.
+ */
+ it('can resize pools', () => {
+ const mockCluster = kubernetesClusterFactory.build({
+ k8s_version: latestKubernetesVersion,
+ });
- cy.findByText(decreaseSizeWarning).should('be.visible');
- cy.findByText(nodeSizeRecommendation).should('be.visible');
+ const mockNodePoolResized = nodePoolFactory.build({
+ count: 3,
+ type: 'g6-standard-1',
+ nodes: kubeLinodeFactory.buildList(3),
+ });
- ui.button
- .findByTitle('Save Changes')
+ const mockNodePoolInitial = {
+ ...mockNodePoolResized,
+ count: 1,
+ nodes: [mockNodePoolResized.nodes[0]],
+ };
+
+ const mockLinodes: Linode[] = mockNodePoolResized.nodes.map(
+ (node: PoolNodeResponse): Linode => {
+ return linodeFactory.build({
+ id: node.instance_id ?? undefined,
+ ipv4: [randomIp()],
+ });
+ }
+ );
+
+ const mockNodePoolDrawerTitle = 'Resize Pool: Linode 2 GB Plan';
+
+ const decreaseSizeWarning =
+ 'Resizing to fewer nodes will delete random nodes from the pool.';
+ const nodeSizeRecommendation =
+ 'We recommend a minimum of 3 nodes in each Node Pool to avoid downtime during upgrades and maintenance.';
+
+ mockGetCluster(mockCluster).as('getCluster');
+ mockGetClusterPools(mockCluster.id, [mockNodePoolInitial]).as(
+ 'getNodePools'
+ );
+ mockGetLinodes(mockLinodes).as('getLinodes');
+ mockGetKubernetesVersions().as('getVersions');
+ mockGetDashboardUrl(mockCluster.id);
+ mockGetApiEndpoints(mockCluster.id);
+
+ cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`);
+ cy.wait(['@getCluster', '@getNodePools', '@getLinodes', '@getVersions']);
+
+ // Confirm that nodes are listed with correct details.
+ mockNodePoolInitial.nodes.forEach((node: PoolNodeResponse) => {
+ cy.get(`tr[data-qa-node-row="${node.id}"]`)
.should('be.visible')
- .should('be.enabled')
- .click();
+ .within(() => {
+ const nodeLinode = mockLinodes.find(
+ (linode: Linode) => linode.id === node.instance_id
+ );
+ if (nodeLinode) {
+ cy.findByText(nodeLinode.label).should('be.visible');
+ cy.findByText(nodeLinode.ipv4[0]).should('be.visible');
+ ui.button
+ .findByTitle('Recycle')
+ .should('be.visible')
+ .should('be.enabled');
+ }
+ });
});
- cy.wait(['@resizeNodePool', '@getNodePools']);
- cy.get('[data-qa-node-row]').should('have.length', 1);
- });
+ // Click "Resize Pool" and increase size to 3 nodes.
+ ui.button
+ .findByTitle('Resize Pool')
+ .should('be.visible')
+ .should('be.enabled')
+ .click();
+
+ mockUpdateNodePool(mockCluster.id, mockNodePoolResized).as(
+ 'resizeNodePool'
+ );
+ mockGetClusterPools(mockCluster.id, [mockNodePoolResized]).as(
+ 'getNodePools'
+ );
+ ui.drawer
+ .findByTitle(mockNodePoolDrawerTitle)
+ .should('be.visible')
+ .within(() => {
+ ui.button
+ .findByTitle('Save Changes')
+ .should('be.visible')
+ .should('be.disabled');
- /*
- * - Confirms kubeconfig reset UI flow using mocked API responses.
- * - Confirms that user is warned of repercussions before resetting config.
- * - Confirms that toast appears confirming kubeconfig has reset.
- */
- it('can reset kubeconfig', () => {
- const mockCluster = kubernetesClusterFactory.build({
- k8s_version: latestKubernetesVersion,
- });
+ cy.findByText('Resized pool: $12/month (1 node at $12/month)').should(
+ 'be.visible'
+ );
- mockGetCluster(mockCluster).as('getCluster');
- mockGetClusterPools(mockCluster.id, mockNodePools).as('getNodePools');
- mockGetKubernetesVersions().as('getVersions');
- mockResetKubeconfig(mockCluster.id).as('resetKubeconfig');
- mockGetDashboardUrl(mockCluster.id);
- mockGetApiEndpoints(mockCluster.id);
-
- const resetWarnings = [
- 'This will delete and regenerate the cluster’s Kubeconfig file',
- 'You will no longer be able to access this cluster via your previous Kubeconfig file',
- 'This action cannot be undone',
- ];
-
- cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`);
- cy.wait(['@getCluster', '@getNodePools', '@getVersions']);
-
- // Click "Reset" button, proceed through confirmation dialog.
- cy.findByText('Reset').should('be.visible').click();
- ui.dialog
- .findByTitle('Reset Cluster Kubeconfig?')
- .should('be.visible')
- .within(() => {
- resetWarnings.forEach((warning: string) => {
- cy.findByText(warning, { exact: false }).should('be.visible');
+ cy.findByLabelText('Add 1')
+ .should('be.visible')
+ .should('be.enabled')
+ .click()
+ .click();
+
+ cy.findByLabelText('Edit Quantity').should('have.value', '3');
+ cy.findByText(
+ 'Resized pool: $36/month (3 nodes at $12/month)'
+ ).should('be.visible');
+
+ ui.button
+ .findByTitle('Save Changes')
+ .should('be.visible')
+ .should('be.enabled')
+ .click();
});
- ui.button
- .findByTitle('Reset Kubeconfig')
+ cy.wait(['@resizeNodePool', '@getNodePools']);
+
+ // Confirm that new nodes are listed with correct info.
+ mockLinodes.forEach((mockLinode: Linode) => {
+ cy.findByText(mockLinode.label)
.should('be.visible')
- .should('be.enabled')
- .click();
+ .closest('tr')
+ .within(() => {
+ cy.findByText(mockLinode.ipv4[0]).should('be.visible');
+ });
});
- // Wait for API response and assert toast message appears.
- cy.wait('@resetKubeconfig');
- ui.toast.assertMessage('Successfully reset Kubeconfig');
- });
-
- /*
- * - Confirms UI flow when adding and deleting node pools.
- * - Confirms that user cannot delete a node pool when there is only 1 pool.
- * - Confirms that details page updates to reflect change when pools are added or deleted.
- */
- it('can add and delete node pools', () => {
- const mockCluster = kubernetesClusterFactory.build({
- k8s_version: latestKubernetesVersion,
- });
+ // Click "Resize Pool" and decrease size back to 1 node.
+ ui.button
+ .findByTitle('Resize Pool')
+ .should('be.visible')
+ .should('be.enabled')
+ .click();
+
+ mockUpdateNodePool(mockCluster.id, mockNodePoolInitial).as(
+ 'resizeNodePool'
+ );
+ mockGetClusterPools(mockCluster.id, [mockNodePoolInitial]).as(
+ 'getNodePools'
+ );
+ ui.drawer
+ .findByTitle(mockNodePoolDrawerTitle)
+ .should('be.visible')
+ .within(() => {
+ cy.findByLabelText('Subtract 1')
+ .should('be.visible')
+ .should('be.enabled')
+ .click()
+ .click();
+
+ cy.findByText(decreaseSizeWarning).should('be.visible');
+ cy.findByText(nodeSizeRecommendation).should('be.visible');
+
+ ui.button
+ .findByTitle('Save Changes')
+ .should('be.visible')
+ .should('be.enabled')
+ .click();
+ });
- const mockNodePool = nodePoolFactory.build({
- type: 'g6-dedicated-4',
+ cy.wait(['@resizeNodePool', '@getNodePools']);
+ cy.get('[data-qa-node-row]').should('have.length', 1);
});
- const mockNewNodePool = nodePoolFactory.build({
- type: 'g6-dedicated-2',
- });
+ /*
+ * - Confirms kubeconfig reset UI flow using mocked API responses.
+ * - Confirms that user is warned of repercussions before resetting config.
+ * - Confirms that toast appears confirming kubeconfig has reset.
+ */
+ it('can reset kubeconfig', () => {
+ const mockCluster = kubernetesClusterFactory.build({
+ k8s_version: latestKubernetesVersion,
+ });
- mockGetCluster(mockCluster).as('getCluster');
- mockGetClusterPools(mockCluster.id, [mockNodePool]).as('getNodePools');
- mockGetKubernetesVersions().as('getVersions');
- mockAddNodePool(mockCluster.id, mockNewNodePool).as('addNodePool');
- mockDeleteNodePool(mockCluster.id, mockNewNodePool.id).as('deleteNodePool');
- mockGetDashboardUrl(mockCluster.id);
- mockGetApiEndpoints(mockCluster.id);
-
- cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`);
- cy.wait(['@getCluster', '@getNodePools', '@getVersions']);
-
- // Assert that initial node pool is shown on the page.
- cy.findByText('Dedicated 8 GB', { selector: 'h2' }).should('be.visible');
-
- // "Delete Pool" button should be disabled when only 1 node pool exists.
- ui.button
- .findByTitle('Delete Pool')
- .should('be.visible')
- .should('be.disabled');
-
- // Add a new node pool, select plan, submit form in drawer.
- ui.button
- .findByTitle('Add a Node Pool')
- .should('be.visible')
- .should('be.enabled')
- .click();
-
- mockGetClusterPools(mockCluster.id, [mockNodePool, mockNewNodePool]).as(
- 'getNodePools'
- );
- ui.drawer
- .findByTitle(`Add a Node Pool: ${mockCluster.label}`)
- .should('be.visible')
- .within(() => {
- cy.findByText('Dedicated 4 GB')
- .should('be.visible')
- .closest('tr')
- .within(() => {
- cy.findByLabelText('Add 1').should('be.visible').click();
+ mockGetCluster(mockCluster).as('getCluster');
+ mockGetClusterPools(mockCluster.id, mockNodePools).as('getNodePools');
+ mockGetKubernetesVersions().as('getVersions');
+ mockResetKubeconfig(mockCluster.id).as('resetKubeconfig');
+ mockGetDashboardUrl(mockCluster.id);
+ mockGetApiEndpoints(mockCluster.id);
+
+ const resetWarnings = [
+ 'This will delete and regenerate the cluster’s Kubeconfig file',
+ 'You will no longer be able to access this cluster via your previous Kubeconfig file',
+ 'This action cannot be undone',
+ ];
+
+ cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`);
+ cy.wait(['@getCluster', '@getNodePools', '@getVersions']);
+
+ // Click "Reset" button, proceed through confirmation dialog.
+ cy.findByText('Reset').should('be.visible').click();
+ ui.dialog
+ .findByTitle('Reset Cluster Kubeconfig?')
+ .should('be.visible')
+ .within(() => {
+ resetWarnings.forEach((warning: string) => {
+ cy.findByText(warning, { exact: false }).should('be.visible');
});
- ui.button
- .findByTitle('Add pool')
- .should('be.visible')
- .should('be.enabled')
- .click();
- });
+ ui.button
+ .findByTitle('Reset Kubeconfig')
+ .should('be.visible')
+ .should('be.enabled')
+ .click();
+ });
- // Wait for API responses and confirm that both node pools are shown.
- cy.wait(['@addNodePool', '@getNodePools']);
- cy.findByText('Dedicated 8 GB', { selector: 'h2' }).should('be.visible');
- cy.findByText('Dedicated 4 GB', { selector: 'h2' }).should('be.visible');
-
- // Delete the newly added node pool.
- cy.get(`[data-qa-node-pool-id="${mockNewNodePool.id}"]`)
- .should('be.visible')
- .within(() => {
- ui.button
- .findByTitle('Delete Pool')
- .should('be.visible')
- .should('be.enabled')
- .click();
+ // Wait for API response and assert toast message appears.
+ cy.wait('@resetKubeconfig');
+ ui.toast.assertMessage('Successfully reset Kubeconfig');
+ });
+
+ /*
+ * - Confirms UI flow when adding and deleting node pools.
+ * - Confirms that user cannot delete a node pool when there is only 1 pool.
+ * - Confirms that details page updates to reflect change when pools are added or deleted.
+ */
+ it('can add and delete node pools', () => {
+ const mockCluster = kubernetesClusterFactory.build({
+ k8s_version: latestKubernetesVersion,
});
- mockGetClusterPools(mockCluster.id, [mockNodePool]).as('getNodePools');
- ui.dialog
- .findByTitle('Delete Node Pool?')
- .should('be.visible')
- .within(() => {
- ui.button
- .findByTitle('Delete')
- .should('be.visible')
- .should('be.enabled')
- .click();
+ const mockNodePool = nodePoolFactory.build({
+ type: 'g6-dedicated-4',
});
- // Confirm node pool is deleted, original node pool still exists, and
- // delete pool button is once again disabled.
- cy.wait(['@deleteNodePool', '@getNodePools']);
- cy.findByText('Dedicated 8 GB', { selector: 'h2' }).should('be.visible');
- cy.findByText('Dedicated 4 GB', { selector: 'h2' }).should('not.exist');
+ const mockNewNodePool = nodePoolFactory.build({
+ type: 'g6-dedicated-2',
+ });
- ui.button
- .findByTitle('Delete Pool')
- .should('be.visible')
- .should('be.disabled');
- });
-});
+ mockGetCluster(mockCluster).as('getCluster');
+ mockGetClusterPools(mockCluster.id, [mockNodePool]).as('getNodePools');
+ mockGetKubernetesVersions().as('getVersions');
+ mockAddNodePool(mockCluster.id, mockNewNodePool).as('addNodePool');
+ mockDeleteNodePool(mockCluster.id, mockNewNodePool.id).as(
+ 'deleteNodePool'
+ );
+ mockGetDashboardUrl(mockCluster.id);
+ mockGetApiEndpoints(mockCluster.id);
+
+ cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`);
+ cy.wait(['@getCluster', '@getNodePools', '@getVersions']);
+
+ // Assert that initial node pool is shown on the page.
+ cy.findByText('Dedicated 8 GB', { selector: 'h2' }).should('be.visible');
+
+ // "Delete Pool" button should be disabled when only 1 node pool exists.
+ ui.button
+ .findByTitle('Delete Pool')
+ .should('be.visible')
+ .should('be.disabled');
-describe('LKE cluster updates for DC-specific prices', () => {
- /*
- * - Confirms node pool resize UI flow using mocked API responses.
- * - Confirms that pool size can be increased and decreased.
- * - Confirms that drawer reflects prices in regions with DC-specific pricing.
- * - Confirms that details page updates total cluster price with DC-specific pricing.
- */
- it('can resize pools with DC-specific prices', () => {
- const dcSpecificPricingRegion = getRegionById('us-east');
- const mockPlanType = extendType(dcPricingMockLinodeTypes[0]);
-
- const mockCluster = kubernetesClusterFactory.build({
- k8s_version: latestKubernetesVersion,
- region: dcSpecificPricingRegion.id,
- control_plane: {
- high_availability: false,
- },
- });
+ // Add a new node pool, select plan, submit form in drawer.
+ ui.button
+ .findByTitle('Add a Node Pool')
+ .should('be.visible')
+ .should('be.enabled')
+ .click();
+
+ mockGetClusterPools(mockCluster.id, [mockNodePool, mockNewNodePool]).as(
+ 'getNodePools'
+ );
+ ui.drawer
+ .findByTitle(`Add a Node Pool: ${mockCluster.label}`)
+ .should('be.visible')
+ .within(() => {
+ cy.findByText('Dedicated 4 GB')
+ .should('be.visible')
+ .closest('tr')
+ .within(() => {
+ cy.findByLabelText('Add 1').should('be.visible').click();
+ });
+
+ ui.button
+ .findByTitle('Add pool')
+ .should('be.visible')
+ .should('be.enabled')
+ .click();
+ });
- const mockNodePoolResized = nodePoolFactory.build({
- count: 3,
- type: mockPlanType.id,
- nodes: kubeLinodeFactory.buildList(3),
- });
+ // Wait for API responses and confirm that both node pools are shown.
+ cy.wait(['@addNodePool', '@getNodePools']);
+ cy.findByText('Dedicated 8 GB', { selector: 'h2' }).should('be.visible');
+ cy.findByText('Dedicated 4 GB', { selector: 'h2' }).should('be.visible');
- const mockNodePoolInitial = {
- ...mockNodePoolResized,
- count: 1,
- nodes: [mockNodePoolResized.nodes[0]],
- };
-
- const mockLinodes: Linode[] = mockNodePoolResized.nodes.map(
- (node: PoolNodeResponse): Linode => {
- return linodeFactory.build({
- id: node.instance_id ?? undefined,
- ipv4: [randomIp()],
- region: dcSpecificPricingRegion.id,
- type: mockPlanType.id,
+ // Delete the newly added node pool.
+ cy.get(`[data-qa-node-pool-id="${mockNewNodePool.id}"]`)
+ .should('be.visible')
+ .within(() => {
+ ui.button
+ .findByTitle('Delete Pool')
+ .should('be.visible')
+ .should('be.enabled')
+ .click();
});
- }
- );
-
- const mockNodePoolDrawerTitle = `Resize Pool: ${mockPlanType.formattedLabel} Plan`;
-
- mockGetCluster(mockCluster).as('getCluster');
- mockGetClusterPools(mockCluster.id, [mockNodePoolInitial]).as(
- 'getNodePools'
- );
- mockGetLinodes(mockLinodes).as('getLinodes');
- mockGetLinodeType(mockPlanType).as('getLinodeType');
- mockGetKubernetesVersions().as('getVersions');
- mockGetDashboardUrl(mockCluster.id);
- mockGetApiEndpoints(mockCluster.id);
-
- cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`);
- cy.wait([
- '@getCluster',
- '@getNodePools',
- '@getLinodes',
- '@getVersions',
- '@getLinodeType',
- ]);
-
- // Confirm that nodes are visible.
- mockNodePoolInitial.nodes.forEach((node: PoolNodeResponse) => {
- cy.get(`tr[data-qa-node-row="${node.id}"]`)
+
+ mockGetClusterPools(mockCluster.id, [mockNodePool]).as('getNodePools');
+ ui.dialog
+ .findByTitle('Delete Node Pool?')
.should('be.visible')
.within(() => {
- const nodeLinode = mockLinodes.find(
- (linode: Linode) => linode.id === node.instance_id
- );
- if (nodeLinode) {
- cy.findByText(nodeLinode.label).should('be.visible');
- }
+ ui.button
+ .findByTitle('Delete')
+ .should('be.visible')
+ .should('be.enabled')
+ .click();
});
- });
- // Confirm total price is listed in Kube Specs.
- cy.findByText('$14.40/month').should('be.visible');
-
- // Click "Resize Pool" and increase size to 3 nodes.
- ui.button
- .findByTitle('Resize Pool')
- .should('be.visible')
- .should('be.enabled')
- .click();
-
- mockUpdateNodePool(mockCluster.id, mockNodePoolResized).as(
- 'resizeNodePool'
- );
- mockGetClusterPools(mockCluster.id, [mockNodePoolResized]).as(
- 'getNodePools'
- );
- ui.drawer
- .findByTitle(mockNodePoolDrawerTitle)
- .should('be.visible')
- .within(() => {
- ui.button
- .findByTitle('Save Changes')
- .should('be.visible')
- .should('be.disabled');
+ // Confirm node pool is deleted, original node pool still exists, and
+ // delete pool button is once again disabled.
+ cy.wait(['@deleteNodePool', '@getNodePools']);
+ cy.findByText('Dedicated 8 GB', { selector: 'h2' }).should('be.visible');
+ cy.findByText('Dedicated 4 GB', { selector: 'h2' }).should('not.exist');
- cy.findByText(
- 'Current pool: $14.40/month (1 node at $14.40/month)'
- ).should('be.visible');
- cy.findByText(
- 'Resized pool: $14.40/month (1 node at $14.40/month)'
- ).should('be.visible');
+ ui.button
+ .findByTitle('Delete Pool')
+ .should('be.visible')
+ .should('be.disabled');
+ });
+ });
- cy.findByLabelText('Add 1')
- .should('be.visible')
- .should('be.enabled')
- .click()
- .click()
- .click();
-
- cy.findByLabelText('Edit Quantity').should('have.value', '4');
- cy.findByText(
- 'Current pool: $14.40/month (1 node at $14.40/month)'
- ).should('be.visible');
- cy.findByText(
- 'Resized pool: $57.60/month (4 nodes at $14.40/month)'
- ).should('be.visible');
-
- cy.findByLabelText('Subtract 1')
- .should('be.visible')
- .should('be.enabled')
- .click();
+ describe('LKE cluster updates for DC-specific prices', () => {
+ /*
+ * - Confirms node pool resize UI flow using mocked API responses.
+ * - Confirms that pool size can be increased and decreased.
+ * - Confirms that drawer reflects prices in regions with DC-specific pricing.
+ * - Confirms that details page updates total cluster price with DC-specific pricing.
+ */
+ it('can resize pools with DC-specific prices', () => {
+ const dcSpecificPricingRegion = getRegionById('us-east');
+ const mockPlanType = extendType(dcPricingMockLinodeTypes[0]);
+
+ const mockCluster = kubernetesClusterFactory.build({
+ k8s_version: latestKubernetesVersion,
+ region: dcSpecificPricingRegion.id,
+ control_plane: {
+ high_availability: false,
+ },
+ });
- cy.findByLabelText('Edit Quantity').should('have.value', '3');
- cy.findByText(
- 'Resized pool: $43.20/month (3 nodes at $14.40/month)'
- ).should('be.visible');
+ const mockNodePoolResized = nodePoolFactory.build({
+ count: 3,
+ type: mockPlanType.id,
+ nodes: kubeLinodeFactory.buildList(3),
+ });
- ui.button
- .findByTitle('Save Changes')
+ const mockNodePoolInitial = {
+ ...mockNodePoolResized,
+ count: 1,
+ nodes: [mockNodePoolResized.nodes[0]],
+ };
+
+ const mockLinodes: Linode[] = mockNodePoolResized.nodes.map(
+ (node: PoolNodeResponse): Linode => {
+ return linodeFactory.build({
+ id: node.instance_id ?? undefined,
+ ipv4: [randomIp()],
+ region: dcSpecificPricingRegion.id,
+ type: mockPlanType.id,
+ });
+ }
+ );
+
+ const mockNodePoolDrawerTitle = `Resize Pool: ${mockPlanType.formattedLabel} Plan`;
+
+ mockGetCluster(mockCluster).as('getCluster');
+ mockGetClusterPools(mockCluster.id, [mockNodePoolInitial]).as(
+ 'getNodePools'
+ );
+ mockGetLinodes(mockLinodes).as('getLinodes');
+ mockGetLinodeType(mockPlanType).as('getLinodeType');
+ mockGetKubernetesVersions().as('getVersions');
+ mockGetDashboardUrl(mockCluster.id);
+ mockGetApiEndpoints(mockCluster.id);
+
+ cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`);
+ cy.wait([
+ '@getCluster',
+ '@getNodePools',
+ '@getLinodes',
+ '@getVersions',
+ '@getLinodeType',
+ ]);
+
+ // Confirm that nodes are visible.
+ mockNodePoolInitial.nodes.forEach((node: PoolNodeResponse) => {
+ cy.get(`tr[data-qa-node-row="${node.id}"]`)
.should('be.visible')
- .should('be.enabled')
- .click();
+ .within(() => {
+ const nodeLinode = mockLinodes.find(
+ (linode: Linode) => linode.id === node.instance_id
+ );
+ if (nodeLinode) {
+ cy.findByText(nodeLinode.label).should('be.visible');
+ }
+ });
});
- cy.wait(['@resizeNodePool', '@getNodePools']);
-
- // Confirm total price updates in Kube Specs.
- cy.findByText('$43.20/month').should('be.visible');
- });
-
- /*
- * - Confirms UI flow when adding node pools using mocked API responses.
- * - Confirms that drawer reflects prices in regions with DC-specific pricing.
- * - Confirms that details page updates total cluster price with DC-specific pricing.
- */
- it('can add node pools with DC-specific prices', () => {
- const dcSpecificPricingRegion = getRegionById('us-east');
-
- const mockCluster = kubernetesClusterFactory.build({
- k8s_version: latestKubernetesVersion,
- region: dcSpecificPricingRegion.id,
- control_plane: {
- high_availability: false,
- },
- });
+ // Confirm total price is listed in Kube Specs.
+ cy.findByText('$14.40/month').should('be.visible');
- const mockPlanType = extendType(dcPricingMockLinodeTypes[0]);
+ // Click "Resize Pool" and increase size to 3 nodes.
+ ui.button
+ .findByTitle('Resize Pool')
+ .should('be.visible')
+ .should('be.enabled')
+ .click();
+
+ mockUpdateNodePool(mockCluster.id, mockNodePoolResized).as(
+ 'resizeNodePool'
+ );
+ mockGetClusterPools(mockCluster.id, [mockNodePoolResized]).as(
+ 'getNodePools'
+ );
+ ui.drawer
+ .findByTitle(mockNodePoolDrawerTitle)
+ .should('be.visible')
+ .within(() => {
+ ui.button
+ .findByTitle('Save Changes')
+ .should('be.visible')
+ .should('be.disabled');
+
+ cy.findByText(
+ 'Current pool: $14.40/month (1 node at $14.40/month)'
+ ).should('be.visible');
+ cy.findByText(
+ 'Resized pool: $14.40/month (1 node at $14.40/month)'
+ ).should('be.visible');
+
+ cy.findByLabelText('Add 1')
+ .should('be.visible')
+ .should('be.enabled')
+ .click()
+ .click()
+ .click();
+
+ cy.findByLabelText('Edit Quantity').should('have.value', '4');
+ cy.findByText(
+ 'Current pool: $14.40/month (1 node at $14.40/month)'
+ ).should('be.visible');
+ cy.findByText(
+ 'Resized pool: $57.60/month (4 nodes at $14.40/month)'
+ ).should('be.visible');
+
+ cy.findByLabelText('Subtract 1')
+ .should('be.visible')
+ .should('be.enabled')
+ .click();
+
+ cy.findByLabelText('Edit Quantity').should('have.value', '3');
+ cy.findByText(
+ 'Resized pool: $43.20/month (3 nodes at $14.40/month)'
+ ).should('be.visible');
+
+ ui.button
+ .findByTitle('Save Changes')
+ .should('be.visible')
+ .should('be.enabled')
+ .click();
+ });
- const mockNewNodePool = nodePoolFactory.build({
- count: 2,
- type: mockPlanType.id,
- nodes: kubeLinodeFactory.buildList(2),
- });
+ cy.wait(['@resizeNodePool', '@getNodePools']);
- const mockNodePool = nodePoolFactory.build({
- count: 1,
- type: mockPlanType.id,
- nodes: kubeLinodeFactory.buildList(1),
+ // Confirm total price updates in Kube Specs.
+ cy.findByText('$43.20/month').should('be.visible');
});
- mockGetCluster(mockCluster).as('getCluster');
- mockGetClusterPools(mockCluster.id, [mockNodePool]).as('getNodePools');
- mockGetKubernetesVersions().as('getVersions');
- mockAddNodePool(mockCluster.id, mockNewNodePool).as('addNodePool');
- mockGetLinodeType(mockPlanType).as('getLinodeType');
- mockGetLinodeTypes(dcPricingMockLinodeTypes);
- mockGetDashboardUrl(mockCluster.id);
- mockGetApiEndpoints(mockCluster.id);
-
- cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`);
- cy.wait(['@getCluster', '@getNodePools', '@getVersions', '@getLinodeType']);
-
- // Assert that initial node pool is shown on the page.
- cy.findByText(mockPlanType.formattedLabel, { selector: 'h2' }).should(
- 'be.visible'
- );
-
- // Confirm total price is listed in Kube Specs.
- cy.findByText('$14.40/month').should('be.visible');
-
- // Add a new node pool, select plan, submit form in drawer.
- ui.button
- .findByTitle('Add a Node Pool')
- .should('be.visible')
- .should('be.enabled')
- .click();
-
- mockGetClusterPools(mockCluster.id, [mockNodePool, mockNewNodePool]).as(
- 'getNodePools'
- );
-
- ui.drawer
- .findByTitle(`Add a Node Pool: ${mockCluster.label}`)
- .should('be.visible')
- .within(() => {
- cy.findByText('Shared CPU')
- .should('be.visible')
- .should('be.enabled')
- .click();
- cy.findByText(mockPlanType.formattedLabel)
- .should('be.visible')
- .closest('tr')
- .within(() => {
- // Assert that DC-specific prices are displayed the plan table, then add a node pool with 2 linodes.
- cy.findByText('$14.40').should('be.visible');
- cy.findByText('$0.021').should('be.visible');
- cy.findByLabelText('Add 1').should('be.visible').click().click();
- });
+ /*
+ * - Confirms UI flow when adding node pools using mocked API responses.
+ * - Confirms that drawer reflects prices in regions with DC-specific pricing.
+ * - Confirms that details page updates total cluster price with DC-specific pricing.
+ */
+ it('can add node pools with DC-specific prices', () => {
+ const dcSpecificPricingRegion = getRegionById('us-east');
+
+ const mockCluster = kubernetesClusterFactory.build({
+ k8s_version: latestKubernetesVersion,
+ region: dcSpecificPricingRegion.id,
+ control_plane: {
+ high_availability: false,
+ },
+ });
- // Assert that DC-specific prices are displayed as helper text.
- cy.contains(
- 'This pool will add $28.80/month (2 nodes at $14.40/month) to this cluster.'
- ).should('be.visible');
+ const mockPlanType = extendType(dcPricingMockLinodeTypes[0]);
- ui.button
- .findByTitle('Add pool')
- .should('be.visible')
- .should('be.enabled')
- .click();
+ const mockNewNodePool = nodePoolFactory.build({
+ count: 2,
+ type: mockPlanType.id,
+ nodes: kubeLinodeFactory.buildList(2),
});
- // Wait for API responses.
- cy.wait(['@addNodePool', '@getNodePools']);
-
- // Confirm total price updates in Kube Specs: $14.40/mo existing pool + $28.80/mo new pool.
- cy.findByText('$43.20/month').should('be.visible');
- });
+ const mockNodePool = nodePoolFactory.build({
+ count: 1,
+ type: mockPlanType.id,
+ nodes: kubeLinodeFactory.buildList(1),
+ });
- /*
- * - Confirms node pool resize UI flow using mocked API responses.
- * - Confirms that pool size can be changed.
- * - Confirms that drawer reflects $0 pricing.
- * - Confirms that details page still shows $0 pricing after resizing.
- */
- it('can resize pools with region prices of $0', () => {
- const dcSpecificPricingRegion = getRegionById('us-southeast');
- const mockPlanType = extendType(dcPricingMockLinodeTypes[2]);
-
- const mockCluster = kubernetesClusterFactory.build({
- k8s_version: latestKubernetesVersion,
- region: dcSpecificPricingRegion.id,
- control_plane: {
- high_availability: false,
- },
- });
+ mockGetCluster(mockCluster).as('getCluster');
+ mockGetClusterPools(mockCluster.id, [mockNodePool]).as('getNodePools');
+ mockGetKubernetesVersions().as('getVersions');
+ mockAddNodePool(mockCluster.id, mockNewNodePool).as('addNodePool');
+ mockGetLinodeType(mockPlanType).as('getLinodeType');
+ mockGetLinodeTypes(dcPricingMockLinodeTypes);
+ mockGetDashboardUrl(mockCluster.id);
+ mockGetApiEndpoints(mockCluster.id);
+
+ cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`);
+ cy.wait([
+ '@getCluster',
+ '@getNodePools',
+ '@getVersions',
+ '@getLinodeType',
+ ]);
+
+ // Assert that initial node pool is shown on the page.
+ cy.findByText(mockPlanType.formattedLabel, { selector: 'h2' }).should(
+ 'be.visible'
+ );
+
+ // Confirm total price is listed in Kube Specs.
+ cy.findByText('$14.40/month').should('be.visible');
+
+ // Add a new node pool, select plan, submit form in drawer.
+ ui.button
+ .findByTitle('Add a Node Pool')
+ .should('be.visible')
+ .should('be.enabled')
+ .click();
- const mockNodePoolResized = nodePoolFactory.build({
- count: 3,
- type: mockPlanType.id,
- nodes: kubeLinodeFactory.buildList(3),
- });
+ mockGetClusterPools(mockCluster.id, [mockNodePool, mockNewNodePool]).as(
+ 'getNodePools'
+ );
- const mockNodePoolInitial = {
- ...mockNodePoolResized,
- count: 1,
- nodes: [mockNodePoolResized.nodes[0]],
- };
-
- const mockLinodes: Linode[] = mockNodePoolResized.nodes.map(
- (node: PoolNodeResponse): Linode => {
- return linodeFactory.build({
- id: node.instance_id ?? undefined,
- ipv4: [randomIp()],
- region: dcSpecificPricingRegion.id,
- type: mockPlanType.id,
- });
- }
- );
-
- const mockNodePoolDrawerTitle = `Resize Pool: ${mockPlanType.formattedLabel} Plan`;
-
- mockGetCluster(mockCluster).as('getCluster');
- mockGetClusterPools(mockCluster.id, [mockNodePoolInitial]).as(
- 'getNodePools'
- );
- mockGetLinodes(mockLinodes).as('getLinodes');
- mockGetLinodeType(mockPlanType).as('getLinodeType');
- mockGetKubernetesVersions().as('getVersions');
- mockGetDashboardUrl(mockCluster.id);
- mockGetApiEndpoints(mockCluster.id);
-
- cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`);
- cy.wait([
- '@getCluster',
- '@getNodePools',
- '@getLinodes',
- '@getVersions',
- '@getLinodeType',
- ]);
-
- // Confirm that nodes are visible.
- mockNodePoolInitial.nodes.forEach((node: PoolNodeResponse) => {
- cy.get(`tr[data-qa-node-row="${node.id}"]`)
+ ui.drawer
+ .findByTitle(`Add a Node Pool: ${mockCluster.label}`)
.should('be.visible')
.within(() => {
- const nodeLinode = mockLinodes.find(
- (linode: Linode) => linode.id === node.instance_id
- );
- if (nodeLinode) {
- cy.findByText(nodeLinode.label).should('be.visible');
- }
+ cy.findByText('Shared CPU')
+ .should('be.visible')
+ .should('be.enabled')
+ .click();
+ cy.findByText(mockPlanType.formattedLabel)
+ .should('be.visible')
+ .closest('tr')
+ .within(() => {
+ // Assert that DC-specific prices are displayed the plan table, then add a node pool with 2 linodes.
+ cy.findByText('$14.40').should('be.visible');
+ cy.findByText('$0.021').should('be.visible');
+ cy.findByLabelText('Add 1').should('be.visible').click().click();
+ });
+
+ // Assert that DC-specific prices are displayed as helper text.
+ cy.contains(
+ 'This pool will add $28.80/month (2 nodes at $14.40/month) to this cluster.'
+ ).should('be.visible');
+
+ ui.button
+ .findByTitle('Add pool')
+ .should('be.visible')
+ .should('be.enabled')
+ .click();
});
+
+ // Wait for API responses.
+ cy.wait(['@addNodePool', '@getNodePools']);
+
+ // Confirm total price updates in Kube Specs: $14.40/mo existing pool + $28.80/mo new pool.
+ cy.findByText('$43.20/month').should('be.visible');
});
- // Confirm total price is listed in Kube Specs.
- cy.findByText('$0.00/month').should('be.visible');
-
- // Click "Resize Pool" and increase size to 4 nodes.
- ui.button
- .findByTitle('Resize Pool')
- .should('be.visible')
- .should('be.enabled')
- .click();
-
- mockUpdateNodePool(mockCluster.id, mockNodePoolResized).as(
- 'resizeNodePool'
- );
- mockGetClusterPools(mockCluster.id, [mockNodePoolResized]).as(
- 'getNodePools'
- );
-
- ui.drawer
- .findByTitle(mockNodePoolDrawerTitle)
- .should('be.visible')
- .within(() => {
- ui.button
- .findByTitle('Save Changes')
- .should('be.visible')
- .should('be.disabled');
+ /*
+ * - Confirms node pool resize UI flow using mocked API responses.
+ * - Confirms that pool size can be changed.
+ * - Confirms that drawer reflects $0 pricing.
+ * - Confirms that details page still shows $0 pricing after resizing.
+ */
+ it('can resize pools with region prices of $0', () => {
+ const dcSpecificPricingRegion = getRegionById('us-southeast');
+ const mockPlanType = extendType(dcPricingMockLinodeTypes[2]);
+
+ const mockCluster = kubernetesClusterFactory.build({
+ k8s_version: latestKubernetesVersion,
+ region: dcSpecificPricingRegion.id,
+ control_plane: {
+ high_availability: false,
+ },
+ });
- cy.findByText('Current pool: $0/month (1 node at $0/month)').should(
- 'be.visible'
- );
- cy.findByText('Resized pool: $0/month (1 node at $0/month)').should(
- 'be.visible'
- );
+ const mockNodePoolResized = nodePoolFactory.build({
+ count: 3,
+ type: mockPlanType.id,
+ nodes: kubeLinodeFactory.buildList(3),
+ });
- cy.findByLabelText('Add 1')
- .should('be.visible')
- .should('be.enabled')
- .click()
- .click()
- .click();
-
- cy.findByLabelText('Edit Quantity').should('have.value', '4');
- cy.findByText('Current pool: $0/month (1 node at $0/month)').should(
- 'be.visible'
- );
- cy.findByText('Resized pool: $0/month (4 nodes at $0/month)').should(
- 'be.visible'
- );
-
- ui.button
- .findByTitle('Save Changes')
+ const mockNodePoolInitial = {
+ ...mockNodePoolResized,
+ count: 1,
+ nodes: [mockNodePoolResized.nodes[0]],
+ };
+
+ const mockLinodes: Linode[] = mockNodePoolResized.nodes.map(
+ (node: PoolNodeResponse): Linode => {
+ return linodeFactory.build({
+ id: node.instance_id ?? undefined,
+ ipv4: [randomIp()],
+ region: dcSpecificPricingRegion.id,
+ type: mockPlanType.id,
+ });
+ }
+ );
+
+ const mockNodePoolDrawerTitle = `Resize Pool: ${mockPlanType.formattedLabel} Plan`;
+
+ mockGetCluster(mockCluster).as('getCluster');
+ mockGetClusterPools(mockCluster.id, [mockNodePoolInitial]).as(
+ 'getNodePools'
+ );
+ mockGetLinodes(mockLinodes).as('getLinodes');
+ mockGetLinodeType(mockPlanType).as('getLinodeType');
+ mockGetKubernetesVersions().as('getVersions');
+ mockGetDashboardUrl(mockCluster.id);
+ mockGetApiEndpoints(mockCluster.id);
+
+ cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`);
+ cy.wait([
+ '@getCluster',
+ '@getNodePools',
+ '@getLinodes',
+ '@getVersions',
+ '@getLinodeType',
+ ]);
+
+ // Confirm that nodes are visible.
+ mockNodePoolInitial.nodes.forEach((node: PoolNodeResponse) => {
+ cy.get(`tr[data-qa-node-row="${node.id}"]`)
.should('be.visible')
- .should('be.enabled')
- .click();
+ .within(() => {
+ const nodeLinode = mockLinodes.find(
+ (linode: Linode) => linode.id === node.instance_id
+ );
+ if (nodeLinode) {
+ cy.findByText(nodeLinode.label).should('be.visible');
+ }
+ });
});
- cy.wait(['@resizeNodePool', '@getNodePools']);
+ // Confirm total price is listed in Kube Specs.
+ cy.findByText('$0.00/month').should('be.visible');
- // Confirm total price is still $0 in Kube Specs.
- cy.findByText('$0.00/month').should('be.visible');
- });
+ // Click "Resize Pool" and increase size to 4 nodes.
+ ui.button
+ .findByTitle('Resize Pool')
+ .should('be.visible')
+ .should('be.enabled')
+ .click();
+
+ mockUpdateNodePool(mockCluster.id, mockNodePoolResized).as(
+ 'resizeNodePool'
+ );
+ mockGetClusterPools(mockCluster.id, [mockNodePoolResized]).as(
+ 'getNodePools'
+ );
+
+ ui.drawer
+ .findByTitle(mockNodePoolDrawerTitle)
+ .should('be.visible')
+ .within(() => {
+ ui.button
+ .findByTitle('Save Changes')
+ .should('be.visible')
+ .should('be.disabled');
- /*
- * - Confirms UI flow when adding node pools using mocked API responses.
- * - Confirms that drawer reflects $0 prices.
- * - Confirms that details page still shows $0 pricing after adding node pool.
- */
- it('can add node pools with region prices of $0', () => {
- const dcSpecificPricingRegion = getRegionById('us-southeast');
-
- const mockPlanType = extendType(dcPricingMockLinodeTypes[2]);
-
- const mockCluster = kubernetesClusterFactory.build({
- k8s_version: latestKubernetesVersion,
- region: dcSpecificPricingRegion.id,
- control_plane: {
- high_availability: false,
- },
- });
+ cy.findByText('Current pool: $0/month (1 node at $0/month)').should(
+ 'be.visible'
+ );
+ cy.findByText('Resized pool: $0/month (1 node at $0/month)').should(
+ 'be.visible'
+ );
- const mockNewNodePool = nodePoolFactory.build({
- count: 2,
- type: mockPlanType.id,
- nodes: kubeLinodeFactory.buildList(2),
- });
+ cy.findByLabelText('Add 1')
+ .should('be.visible')
+ .should('be.enabled')
+ .click()
+ .click()
+ .click();
+
+ cy.findByLabelText('Edit Quantity').should('have.value', '4');
+ cy.findByText('Current pool: $0/month (1 node at $0/month)').should(
+ 'be.visible'
+ );
+ cy.findByText('Resized pool: $0/month (4 nodes at $0/month)').should(
+ 'be.visible'
+ );
- const mockNodePool = nodePoolFactory.build({
- count: 1,
- type: mockPlanType.id,
- nodes: kubeLinodeFactory.buildList(1),
+ ui.button
+ .findByTitle('Save Changes')
+ .should('be.visible')
+ .should('be.enabled')
+ .click();
+ });
+
+ cy.wait(['@resizeNodePool', '@getNodePools']);
+
+ // Confirm total price is still $0 in Kube Specs.
+ cy.findByText('$0.00/month').should('be.visible');
});
- mockGetCluster(mockCluster).as('getCluster');
- mockGetClusterPools(mockCluster.id, [mockNodePool]).as('getNodePools');
- mockGetKubernetesVersions().as('getVersions');
- mockAddNodePool(mockCluster.id, mockNewNodePool).as('addNodePool');
- mockGetLinodeType(mockPlanType).as('getLinodeType');
- mockGetLinodeTypes(dcPricingMockLinodeTypes);
- mockGetDashboardUrl(mockCluster.id);
- mockGetApiEndpoints(mockCluster.id);
-
- cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`);
- cy.wait(['@getCluster', '@getNodePools', '@getVersions', '@getLinodeType']);
-
- // Assert that initial node pool is shown on the page.
- cy.findByText(mockPlanType.formattedLabel, { selector: 'h2' }).should(
- 'be.visible'
- );
-
- // Confirm total price of $0 is listed in Kube Specs.
- cy.findByText('$0.00/month').should('be.visible');
-
- // Add a new node pool, select plan, submit form in drawer.
- ui.button
- .findByTitle('Add a Node Pool')
- .should('be.visible')
- .should('be.enabled')
- .click();
-
- mockGetClusterPools(mockCluster.id, [mockNodePool, mockNewNodePool]).as(
- 'getNodePools'
- );
-
- ui.drawer
- .findByTitle(`Add a Node Pool: ${mockCluster.label}`)
- .should('be.visible')
- .within(() => {
- cy.findByText('Shared CPU')
- .should('be.visible')
- .should('be.enabled')
- .click();
- cy.findByText('Linode 2 GB')
- .should('be.visible')
- .closest('tr')
- .within(() => {
- // Assert that $0 prices are displayed the plan table, then add a node pool with 2 linodes.
- cy.findAllByText('$0').should('have.length', 2);
- cy.findByLabelText('Add 1').should('be.visible').click().click();
- });
+ /*
+ * - Confirms UI flow when adding node pools using mocked API responses.
+ * - Confirms that drawer reflects $0 prices.
+ * - Confirms that details page still shows $0 pricing after adding node pool.
+ */
+ it('can add node pools with region prices of $0', () => {
+ const dcSpecificPricingRegion = getRegionById('us-southeast');
+
+ const mockPlanType = extendType(dcPricingMockLinodeTypes[2]);
+
+ const mockCluster = kubernetesClusterFactory.build({
+ k8s_version: latestKubernetesVersion,
+ region: dcSpecificPricingRegion.id,
+ control_plane: {
+ high_availability: false,
+ },
+ });
- // Assert that $0 prices are displayed as helper text.
- cy.contains(
- 'This pool will add $0/month (2 nodes at $0/month) to this cluster.'
- ).should('be.visible');
+ const mockNewNodePool = nodePoolFactory.build({
+ count: 2,
+ type: mockPlanType.id,
+ nodes: kubeLinodeFactory.buildList(2),
+ });
- ui.button
- .findByTitle('Add pool')
- .should('be.visible')
- .should('be.enabled')
- .click();
+ const mockNodePool = nodePoolFactory.build({
+ count: 1,
+ type: mockPlanType.id,
+ nodes: kubeLinodeFactory.buildList(1),
});
- // Wait for API responses.
- cy.wait(['@addNodePool', '@getNodePools']);
+ mockGetCluster(mockCluster).as('getCluster');
+ mockGetClusterPools(mockCluster.id, [mockNodePool]).as('getNodePools');
+ mockGetKubernetesVersions().as('getVersions');
+ mockAddNodePool(mockCluster.id, mockNewNodePool).as('addNodePool');
+ mockGetLinodeType(mockPlanType).as('getLinodeType');
+ mockGetLinodeTypes(dcPricingMockLinodeTypes);
+ mockGetDashboardUrl(mockCluster.id);
+ mockGetApiEndpoints(mockCluster.id);
+
+ cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`);
+ cy.wait([
+ '@getCluster',
+ '@getNodePools',
+ '@getVersions',
+ '@getLinodeType',
+ ]);
+
+ // Assert that initial node pool is shown on the page.
+ cy.findByText(mockPlanType.formattedLabel, { selector: 'h2' }).should(
+ 'be.visible'
+ );
+
+ // Confirm total price of $0 is listed in Kube Specs.
+ cy.findByText('$0.00/month').should('be.visible');
+
+ // Add a new node pool, select plan, submit form in drawer.
+ ui.button
+ .findByTitle('Add a Node Pool')
+ .should('be.visible')
+ .should('be.enabled')
+ .click();
+
+ mockGetClusterPools(mockCluster.id, [mockNodePool, mockNewNodePool]).as(
+ 'getNodePools'
+ );
- // Confirm total price is still $0 in Kube Specs.
- cy.findByText('$0.00/month').should('be.visible');
+ ui.drawer
+ .findByTitle(`Add a Node Pool: ${mockCluster.label}`)
+ .should('be.visible')
+ .within(() => {
+ cy.findByText('Shared CPU')
+ .should('be.visible')
+ .should('be.enabled')
+ .click();
+ cy.findByText('Linode 2 GB')
+ .should('be.visible')
+ .closest('tr')
+ .within(() => {
+ // Assert that $0 prices are displayed the plan table, then add a node pool with 2 linodes.
+ cy.findAllByText('$0').should('have.length', 2);
+ cy.findByLabelText('Add 1').should('be.visible').click().click();
+ });
+
+ // Assert that $0 prices are displayed as helper text.
+ cy.contains(
+ 'This pool will add $0/month (2 nodes at $0/month) to this cluster.'
+ ).should('be.visible');
+
+ ui.button
+ .findByTitle('Add pool')
+ .should('be.visible')
+ .should('be.enabled')
+ .click();
+ });
+
+ // Wait for API responses.
+ cy.wait(['@addNodePool', '@getNodePools']);
+
+ // Confirm total price is still $0 in Kube Specs.
+ cy.findByText('$0.00/month').should('be.visible');
+ });
});
});
From a431f9c7267856f64de61e0a690b56d6ff673b17 Mon Sep 17 00:00:00 2001
From: ankitaakamai
Date: Thu, 17 Oct 2024 02:21:52 +0530
Subject: [PATCH 21/64] DI-20837 - Handle new label property for services while
selecting dashboard and Node-type filter update in DbasS (#11082)
* DI-20837 - Handle new label property for services while selecting dashboard
* small eslint fix
* upcoming: [DI-20837] - adjusted service factory
* upcoming: [DI-21138] - Dbass node type filter change
---------
Co-authored-by: venkatmano-akamai
---
packages/api-v4/src/cloudpulse/types.ts | 1 +
.../src/factories/cloudpulse/services.ts | 8 +++++
packages/manager/src/factories/index.ts | 1 +
.../features/CloudPulse/Utils/FilterConfig.ts | 2 +-
.../ReusableDashboardFilterUtils.test.ts | 10 +++---
.../shared/CloudPulseDashboardSelect.test.tsx | 5 +--
.../shared/CloudPulseDashboardSelect.tsx | 13 ++++---
packages/manager/src/mocks/serverHandlers.ts | 34 ++++++++++++++-----
8 files changed, 53 insertions(+), 21 deletions(-)
create mode 100644 packages/manager/src/factories/cloudpulse/services.ts
diff --git a/packages/api-v4/src/cloudpulse/types.ts b/packages/api-v4/src/cloudpulse/types.ts
index 9d24aca5857..19a5149c76a 100644
--- a/packages/api-v4/src/cloudpulse/types.ts
+++ b/packages/api-v4/src/cloudpulse/types.ts
@@ -126,6 +126,7 @@ export interface CloudPulseMetricsList {
export interface ServiceTypes {
service_type: string;
+ label: string;
}
export interface ServiceTypesList {
diff --git a/packages/manager/src/factories/cloudpulse/services.ts b/packages/manager/src/factories/cloudpulse/services.ts
new file mode 100644
index 00000000000..d86e225a2d7
--- /dev/null
+++ b/packages/manager/src/factories/cloudpulse/services.ts
@@ -0,0 +1,8 @@
+import Factory from 'src/factories/factoryProxy';
+
+import type { ServiceTypes } from '@linode/api-v4';
+
+export const serviceTypesFactory = Factory.Sync.makeFactory({
+ label: Factory.each((i) => `Factory ServiceType-${i}`),
+ service_type: Factory.each((i) => `Factory ServiceType-${i}`),
+});
diff --git a/packages/manager/src/factories/index.ts b/packages/manager/src/factories/index.ts
index fc50d4ca726..4a442b6d171 100644
--- a/packages/manager/src/factories/index.ts
+++ b/packages/manager/src/factories/index.ts
@@ -52,6 +52,7 @@ export * from './vlans';
export * from './volume';
export * from './vpcs';
export * from './dashboards';
+export * from './cloudpulse/services';
// Convert factory output to our itemsById pattern
export const normalizeEntities = (entities: any[]) => {
diff --git a/packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts b/packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts
index cb2049952c1..9034ab46ca8 100644
--- a/packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts
+++ b/packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts
@@ -121,7 +121,7 @@ export const DBAAS_CONFIG: Readonly = {
},
{
configuration: {
- filterKey: 'role',
+ filterKey: 'node_type',
filterType: 'string',
isFilterable: true, // isFilterable -- this determines whether you need to pass it metrics api
isMetricsFilter: false, // if it is false, it will go as a part of filter params, else global filter
diff --git a/packages/manager/src/features/CloudPulse/Utils/ReusableDashboardFilterUtils.test.ts b/packages/manager/src/features/CloudPulse/Utils/ReusableDashboardFilterUtils.test.ts
index 3bdfea69058..56b4e1e060f 100644
--- a/packages/manager/src/features/CloudPulse/Utils/ReusableDashboardFilterUtils.test.ts
+++ b/packages/manager/src/features/CloudPulse/Utils/ReusableDashboardFilterUtils.test.ts
@@ -71,7 +71,7 @@ it('test checkMandatoryFiltersSelected method for role', () => {
result = checkMandatoryFiltersSelected({
dashboardObj: { ...mockDashboard, service_type: 'dbaas' },
- filterValue: { region: 'us-east', role: 'primary' },
+ filterValue: { node_type: 'primary', region: 'us-east' },
resource: 1,
timeDuration: { unit: 'min', value: 30 },
});
@@ -83,12 +83,12 @@ it('test constructDimensionFilters method', () => {
mockDashboard.service_type = 'dbaas';
const result = constructDimensionFilters({
dashboardObj: mockDashboard,
- filterValue: { role: 'primary' },
+ filterValue: { node_type: 'primary' },
resource: 1,
});
expect(result.length).toEqual(1);
- expect(result[0].filterKey).toEqual('role');
+ expect(result[0].filterKey).toEqual('node_type');
expect(result[0].filterValue).toEqual('primary');
});
@@ -99,13 +99,13 @@ it('test checkIfFilterNeededInMetricsCall method', () => {
result = checkIfFilterNeededInMetricsCall('resource_id', 'linode');
expect(result).toEqual(false); // not needed as dimension filter
- result = checkIfFilterNeededInMetricsCall('role', 'dbaas');
+ result = checkIfFilterNeededInMetricsCall('node_type', 'dbaas');
expect(result).toEqual(true);
result = checkIfFilterNeededInMetricsCall('engine', 'dbaas');
expect(result).toEqual(false);
- result = checkIfFilterNeededInMetricsCall('role', 'xyz'); // xyz service type
+ result = checkIfFilterNeededInMetricsCall('node_type', 'xyz'); // xyz service type
expect(result).toEqual(false);
});
diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardSelect.test.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardSelect.test.tsx
index 602e96d97aa..664394ebafb 100644
--- a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardSelect.test.tsx
+++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardSelect.test.tsx
@@ -1,7 +1,7 @@
import { fireEvent, screen } from '@testing-library/react';
import React from 'react';
-import { dashboardFactory } from 'src/factories';
+import { dashboardFactory, serviceTypesFactory } from 'src/factories';
import * as utils from 'src/features/CloudPulse/Utils/utils';
import { renderWithTheme } from 'src/utilities/testHelpers';
@@ -19,6 +19,7 @@ const queryMocks = vi.hoisted(() => ({
useCloudPulseServiceTypes: vi.fn().mockReturnValue({}),
}));
const mockDashboard = dashboardFactory.build();
+const mockServiceTypesList = serviceTypesFactory.build();
vi.mock('src/queries/cloudpulse/dashboards', async () => {
const actual = await vi.importActual('src/queries/cloudpulse/dashboards');
@@ -46,7 +47,7 @@ queryMocks.useCloudPulseDashboardsQuery.mockReturnValue({
queryMocks.useCloudPulseServiceTypes.mockReturnValue({
data: {
- data: [{ service_type: 'linode' }],
+ data: [mockServiceTypesList],
},
});
diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardSelect.tsx
index decead7c591..81526099c4d 100644
--- a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardSelect.tsx
+++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardSelect.tsx
@@ -30,6 +30,9 @@ export const CloudPulseDashboardSelect = React.memo(
} = useCloudPulseServiceTypes(true);
const serviceTypes: string[] = formattedServiceTypes(serviceTypesList);
+ const serviceTypeMap: Map = new Map(
+ serviceTypesList?.data.map((item) => [item.service_type, item.label])
+ );
const {
data: dashboardsList,
@@ -66,6 +69,7 @@ export const CloudPulseDashboardSelect = React.memo(
(a, b) => -b.service_type.localeCompare(a.service_type)
);
};
+
// Once the data is loaded, set the state variable with value stored in preferences
React.useEffect(() => {
// only call this code when the component is rendered initially
@@ -90,11 +94,10 @@ export const CloudPulseDashboardSelect = React.memo(
}}
renderGroup={(params) => (
-
- {params.group}
+
+ {serviceTypeMap.has(params.group)
+ ? serviceTypeMap.get(params.group)
+ : params.group}
{params.children}
diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts
index 4d07f405e18..2d092465043 100644
--- a/packages/manager/src/mocks/serverHandlers.ts
+++ b/packages/manager/src/mocks/serverHandlers.ts
@@ -85,6 +85,7 @@ import {
promoFactory,
regionAvailabilityFactory,
securityQuestionsFactory,
+ serviceTypesFactory,
stackScriptFactory,
staticObjects,
subnetFactory,
@@ -110,10 +111,12 @@ import { pickRandom } from 'src/utilities/random';
import type {
AccountMaintenance,
CreateObjectStorageKeyPayload,
+ Dashboard,
FirewallStatus,
NotificationType,
ObjectStorageEndpointTypes,
SecurityQuestionsPayload,
+ ServiceTypesList,
TokenRequest,
UpdateImageRegionsPayload,
User,
@@ -2307,25 +2310,40 @@ export const handlers = [
return HttpResponse.json(response);
}),
http.get('*/monitor/services', () => {
- const response = {
- data: [{ service_type: 'linode' }],
+ const response: ServiceTypesList = {
+ data: [
+ serviceTypesFactory.build({
+ label: 'Linode',
+ service_type: 'linode',
+ }),
+ serviceTypesFactory.build({
+ label: 'Databases',
+ service_type: 'dbaas',
+ }),
+ ],
};
return HttpResponse.json(response);
}),
- http.get('*/monitor/services/:serviceType/dashboards', () => {
+ http.get('*/monitor/services/:serviceType/dashboards', ({ params }) => {
const response = {
- data: [
+ data: [] as Dashboard[],
+ };
+ if (params.serviceType === 'linode') {
+ response.data.push(
dashboardFactory.build({
label: 'Linode Dashboard',
service_type: 'linode',
- }),
+ })
+ );
+ } else if (params.serviceType === 'dbaas') {
+ response.data.push(
dashboardFactory.build({
label: 'DBaaS Dashboard',
service_type: 'dbaas',
- }),
- ],
- };
+ })
+ );
+ }
return HttpResponse.json(response);
}),
From 98a260c6e8aa1aa228ab34fced31077783c18b43 Mon Sep 17 00:00:00 2001
From: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com>
Date: Wed, 16 Oct 2024 13:56:19 -0700
Subject: [PATCH 22/64] fix: [M3-8748] - Markdown cheatsheet link in Support
ticket interface has an expired domain (#11101)
* Update docs link to valid domain
* Added changeset: Link to expired Markdown cheatsheet domain
* Switch to a better docs link
---
.../manager/.changeset/pr-11101-fixed-1729011064940.md | 5 +++++
.../SupportTicketDetail/TabbedReply/MarkdownReference.tsx | 7 ++++---
2 files changed, 9 insertions(+), 3 deletions(-)
create mode 100644 packages/manager/.changeset/pr-11101-fixed-1729011064940.md
diff --git a/packages/manager/.changeset/pr-11101-fixed-1729011064940.md b/packages/manager/.changeset/pr-11101-fixed-1729011064940.md
new file mode 100644
index 00000000000..2536d253581
--- /dev/null
+++ b/packages/manager/.changeset/pr-11101-fixed-1729011064940.md
@@ -0,0 +1,5 @@
+---
+"@linode/manager": Fixed
+---
+
+Link to expired Markdown cheatsheet domain ([#11101](https://github.com/linode/manager/pull/11101))
diff --git a/packages/manager/src/features/Support/SupportTicketDetail/TabbedReply/MarkdownReference.tsx b/packages/manager/src/features/Support/SupportTicketDetail/TabbedReply/MarkdownReference.tsx
index 22f28aa5677..e0c27ecc042 100644
--- a/packages/manager/src/features/Support/SupportTicketDetail/TabbedReply/MarkdownReference.tsx
+++ b/packages/manager/src/features/Support/SupportTicketDetail/TabbedReply/MarkdownReference.tsx
@@ -1,10 +1,11 @@
-import { Theme } from '@mui/material/styles';
-import { makeStyles } from 'tss-react/mui';
import * as React from 'react';
+import { makeStyles } from 'tss-react/mui';
import { Link } from 'src/components/Link';
import { Typography } from 'src/components/Typography';
+import type { Theme } from '@mui/material/styles';
+
const useStyles = makeStyles()((theme: Theme) => ({
example: {
backgroundColor: theme.name === 'dark' ? theme.bg.white : theme.bg.offWhite,
@@ -30,7 +31,7 @@ export const MarkdownReference = (props: Props) => {
You can use Markdown to format your{' '}
{props.isReply ? 'reply' : 'question'}. For more examples see this{' '}
-
+
Markdown cheatsheet
From f534be78554e2aa65cf314fee0e090c553606f12 Mon Sep 17 00:00:00 2001
From: jdamore-linode <97627410+jdamore-linode@users.noreply.github.com>
Date: Wed, 16 Oct 2024 16:56:37 -0400
Subject: [PATCH 23/64] test: [M3-8744] - Reduce Cypress flakiness in Placement
Group deletion tests (#11107)
* Reduce Cypress flakiness related to Placement Group delete dialog React re-rendering
* Added changeset: Reduce flakiness of Placement Group deletion Cypress tests
---------
Co-authored-by: Joe D'Amore
---
.../pr-11107-tests-1729033939339.md | 5 +
.../delete-placement-groups.spec.ts | 131 ++++++++++++------
2 files changed, 96 insertions(+), 40 deletions(-)
create mode 100644 packages/manager/.changeset/pr-11107-tests-1729033939339.md
diff --git a/packages/manager/.changeset/pr-11107-tests-1729033939339.md b/packages/manager/.changeset/pr-11107-tests-1729033939339.md
new file mode 100644
index 00000000000..e3f36bbdef5
--- /dev/null
+++ b/packages/manager/.changeset/pr-11107-tests-1729033939339.md
@@ -0,0 +1,5 @@
+---
+"@linode/manager": Tests
+---
+
+Reduce flakiness of Placement Group deletion Cypress tests ([#11107](https://github.com/linode/manager/pull/11107))
diff --git a/packages/manager/cypress/e2e/core/placementGroups/delete-placement-groups.spec.ts b/packages/manager/cypress/e2e/core/placementGroups/delete-placement-groups.spec.ts
index b08c39cb67b..d065bf1140f 100644
--- a/packages/manager/cypress/e2e/core/placementGroups/delete-placement-groups.spec.ts
+++ b/packages/manager/cypress/e2e/core/placementGroups/delete-placement-groups.spec.ts
@@ -174,9 +174,11 @@ describe('Placement Group deletion', () => {
);
cy.visitWithLogin('/placement-groups');
- cy.wait('@getPlacementGroups');
+ cy.wait(['@getPlacementGroups', '@getLinodes']);
- // Click "Delete" button next to the mock Placement Group.
+ // Click "Delete" button next to the mock Placement Group, and initially mock
+ // an API error response and confirm that the error message is displayed in the
+ // deletion modal.
cy.findByText(mockPlacementGroup.label)
.should('be.visible')
.closest('tr')
@@ -188,31 +190,72 @@ describe('Placement Group deletion', () => {
.click();
});
- // Click "Delete" button next to the mock Placement Group, mock an HTTP 500 error and confirm UI displays the message.
+ // The Placement Groups landing page fires off a Linode GET request upon
+ // clicking the "Delete" button so that Cloud knows which Linodes are assigned
+ // to the selected Placement Group.
+ cy.wait('@getLinodes');
+
mockUnassignPlacementGroupLinodesError(
mockPlacementGroup.id,
PlacementGroupErrorMessage
).as('UnassignPlacementGroupError');
+ // Close dialog and re-open it. This is a workaround to prevent Cypress
+ // failures triggered by React re-rendering after fetching Linodes.
+ //
+ // Tanstack Query is configured to respond with cached data for the `useAllLinodes`
+ // query hook while awaiting the HTTP request response. Because the Placement
+ // Groups landing page fetches Linodes upon opening the deletion modal, there
+ // is a brief period of time where Linode labels are rendered using cached data,
+ // then re-rendered after the real API request resolves. This re-render occasionally
+ // triggers Cypress failures.
+ //
+ // Opening the deletion modal for the same Placement Group a second time
+ // does not trigger another HTTP GET request, this helps circumvent the
+ // issue because the cached/problematic HTTP request is already long resolved
+ // and there is less risk of a re-render occurring while Cypress interacts
+ // with the dialog.
+ //
+ // TODO Consider removing this workaround after M3-8717 is implemented.
ui.dialog
.findByTitle(`Delete Placement Group ${mockPlacementGroup.label}`)
.should('be.visible')
.within(() => {
- cy.get('[data-qa-selection-list]').within(() => {
- // Select the first Linode to unassign
- const mockLinodeToUnassign = mockPlacementGroupLinodes[0];
+ ui.drawerCloseButton.find().click();
+ });
- cy.findByText(mockLinodeToUnassign.label)
- .should('be.visible')
- .closest('li')
- .within(() => {
- ui.button
- .findByTitle('Unassign')
- .should('be.visible')
- .should('be.enabled')
- .click();
- });
- });
+ cy.findByText(mockPlacementGroup.label)
+ .should('be.visible')
+ .closest('tr')
+ .within(() => {
+ ui.button
+ .findByTitle('Delete')
+ .should('be.visible')
+ .should('be.enabled')
+ .click();
+ });
+
+ ui.dialog
+ .findByTitle(`Delete Placement Group ${mockPlacementGroup.label}`)
+ .should('be.visible')
+ .within(() => {
+ cy.get('[data-qa-selection-list]')
+ .should('be.visible')
+ .within(() => {
+ // Select the first Linode to unassign
+ const mockLinodeToUnassign = mockPlacementGroupLinodes[0];
+
+ cy.findByText(mockLinodeToUnassign.label)
+ .closest('li')
+ .should('be.visible')
+ .within(() => {
+ ui.button
+ .findByTitle('Unassign')
+ .should('be.visible')
+ .should('be.enabled')
+ .click();
+ });
+ });
cy.wait('@UnassignPlacementGroupError');
cy.findByText(PlacementGroupErrorMessage).should('be.visible');
@@ -265,7 +308,10 @@ describe('Placement Group deletion', () => {
.click();
});
- cy.wait('@unassignLinode');
+ // Cloud fires off 2 requests to fetch Linodes: once before the unassignment,
+ // and again after. Wait for both of these requests to resolve to reduce the
+ // risk of a re-render occurring when unassigning the next Linode.
+ cy.wait(['@unassignLinode', '@getLinodes', '@getLinodes']);
cy.findByText(mockLinode.label).should('not.exist');
});
});
@@ -444,7 +490,7 @@ describe('Placement Group deletion', () => {
);
cy.visitWithLogin('/placement-groups');
- cy.wait('@getPlacementGroups');
+ cy.wait(['@getPlacementGroups', '@getLinodes']);
// Click "Delete" button next to the mock Placement Group.
cy.findByText(mockPlacementGroup.label)
@@ -458,12 +504,36 @@ describe('Placement Group deletion', () => {
.click();
});
+ // The Placement Groups landing page fires off a Linode GET request upon
+ // clicking the "Delete" button so that Cloud knows which Linodes are assigned
+ // to the selected Placement Group.
+ cy.wait('@getLinodes');
+
// Click "Delete" button next to the mock Placement Group, mock an HTTP 500 error and confirm UI displays the message.
mockUnassignPlacementGroupLinodesError(
mockPlacementGroup.id,
PlacementGroupErrorMessage
).as('UnassignPlacementGroupError');
+ ui.dialog
+ .findByTitle(`Delete Placement Group ${mockPlacementGroup.label}`)
+ .should('be.visible')
+ .within(() => {
+ ui.drawerCloseButton.find().should('be.visible').click();
+ });
+
+ // Click "Delete" button next to the mock Placement Group again.
+ cy.findByText(mockPlacementGroup.label)
+ .should('be.visible')
+ .closest('tr')
+ .within(() => {
+ ui.button
+ .findByTitle('Delete')
+ .should('be.visible')
+ .should('be.enabled')
+ .click();
+ });
+
ui.dialog
.findByTitle(`Delete Placement Group ${mockPlacementGroup.label}`)
.should('be.visible')
@@ -498,7 +568,7 @@ describe('Placement Group deletion', () => {
'not.exist'
);
- // Click "Delete" button next to the mock Placement Group to reopen the dialog
+ // Click "Delete" button next to the mock Placement Group to reopen the dialog.
cy.findByText(mockPlacementGroup.label)
.should('be.visible')
.closest('tr')
@@ -510,31 +580,12 @@ describe('Placement Group deletion', () => {
.click();
});
- // Confirm deletion warning appears and that form cannot be submitted
- // while Linodes are assigned.
+ // Confirm that the error message from the previous attempt is no longer present.
ui.dialog
.findByTitle(`Delete Placement Group ${mockPlacementGroup.label}`)
.should('be.visible')
.within(() => {
- // ensure error message not exist when reopening the dialog
cy.findByText(PlacementGroupErrorMessage).should('not.exist');
-
- // Unassign each Linode.
- cy.get('[data-qa-selection-list]').within(() => {
- // Select the first Linode to unassign
- const mockLinodeToUnassign = mockPlacementGroupLinodes[0];
-
- cy.findByText(mockLinodeToUnassign.label)
- .should('be.visible')
- .closest('li')
- .within(() => {
- ui.button
- .findByTitle('Unassign')
- .should('be.visible')
- .should('be.enabled')
- .click();
- });
- });
});
});
});
From 621e1ec3d0d9fdfc45db7561878027cdcdc18934 Mon Sep 17 00:00:00 2001
From: carrillo-erik <119514965+carrillo-erik@users.noreply.github.com>
Date: Wed, 16 Oct 2024 14:00:45 -0700
Subject: [PATCH 24/64] feat: [M3-7445] - Public IP Addresses Tooltip and LISH
Display (#11070)
* feat: [M3-7445] - Public IP Addresses Tooltip and LISH Display
* Enable LISH console text
* Updates AccessTable unit tests
* Add changeset
* Update UI based on conversation with UX and updates unit tests
* Remove unnecessary file
---
.../pr-11070-changed-1728495168369.md | 5 +++
.../support/intercepts/object-storage.ts | 2 +-
.../src/features/Linodes/AccessTable.test.tsx | 45 ++++++++++++++-----
.../src/features/Linodes/AccessTable.tsx | 16 +++----
.../Linodes/LinodeEntityDetailBody.tsx | 1 +
.../LinodeSelect/LinodeSelect.test.tsx | 1 -
.../LinodeIPAddressRow.test.tsx | 7 ++-
.../LinodeNetworkingActionMenu.tsx | 14 +++---
.../Linodes/LinodesLanding/IPAddress.tsx | 4 +-
...oltip.tsx => PublicIPAddressesTooltip.tsx} | 8 ++--
.../NodeBalancers/ConfigNodeIPSelect.tsx | 1 -
11 files changed, 64 insertions(+), 40 deletions(-)
create mode 100644 packages/manager/.changeset/pr-11070-changed-1728495168369.md
rename packages/manager/src/features/Linodes/{PublicIpsUnassignedTooltip.tsx => PublicIPAddressesTooltip.tsx} (67%)
diff --git a/packages/manager/.changeset/pr-11070-changed-1728495168369.md b/packages/manager/.changeset/pr-11070-changed-1728495168369.md
new file mode 100644
index 00000000000..eac7baa8f1b
--- /dev/null
+++ b/packages/manager/.changeset/pr-11070-changed-1728495168369.md
@@ -0,0 +1,5 @@
+---
+"@linode/manager": Changed
+---
+
+Update Public IP Addresses tooltip and enable LISH console text ([#11070](https://github.com/linode/manager/pull/11070))
diff --git a/packages/manager/cypress/support/intercepts/object-storage.ts b/packages/manager/cypress/support/intercepts/object-storage.ts
index 8dfcbfa8ed7..7c304178e47 100644
--- a/packages/manager/cypress/support/intercepts/object-storage.ts
+++ b/packages/manager/cypress/support/intercepts/object-storage.ts
@@ -544,7 +544,7 @@ export const mockGetBucket = (
);
};
- /* Intercepts GET request to fetch access information (ACL, CORS) for a given Bucket, and mocks response.
+/* Intercepts GET request to fetch access information (ACL, CORS) for a given Bucket, and mocks response.
*
* @param label - Object storage bucket label.
* @param cluster - Object storage bucket cluster.
diff --git a/packages/manager/src/features/Linodes/AccessTable.test.tsx b/packages/manager/src/features/Linodes/AccessTable.test.tsx
index dbe22dc2faf..92f564fb722 100644
--- a/packages/manager/src/features/Linodes/AccessTable.test.tsx
+++ b/packages/manager/src/features/Linodes/AccessTable.test.tsx
@@ -2,7 +2,7 @@ import { fireEvent } from '@testing-library/react';
import * as React from 'react';
import { linodeFactory } from 'src/factories';
-import { PUBLIC_IPS_UNASSIGNED_TOOLTIP_TEXT } from 'src/features/Linodes/PublicIpsUnassignedTooltip';
+import { PUBLIC_IP_ADDRESSES_TOOLTIP_TEXT } from 'src/features/Linodes/PublicIPAddressesTooltip';
import { renderWithTheme } from 'src/utilities/testHelpers';
import { AccessTable } from './AccessTable';
@@ -10,7 +10,7 @@ import { AccessTable } from './AccessTable';
const linode = linodeFactory.build();
describe('AccessTable', () => {
- it('should disable copy button and display help icon tooltip if isVPCOnlyLinode is true', async () => {
+ it('should display help icon tooltip if isVPCOnlyLinode is true', async () => {
const { findByRole, getAllByRole } = renderWithTheme(
{
const buttons = getAllByRole('button');
const helpIconButton = buttons[0];
- const copyButtons = buttons.slice(1);
fireEvent.mouseEnter(helpIconButton);
- const publicIpsUnassignedTooltip = await findByRole('tooltip');
- expect(publicIpsUnassignedTooltip).toContainHTML(
- PUBLIC_IPS_UNASSIGNED_TOOLTIP_TEXT
+ const publicIPAddressesTooltip = await findByRole('tooltip');
+ expect(publicIPAddressesTooltip).toContainHTML(
+ PUBLIC_IP_ADDRESSES_TOOLTIP_TEXT
);
+ });
+
+ it('should not disable copy button if isVPCOnlyLinode is false', () => {
+ const { getAllByRole } = renderWithTheme(
+ <>
+
+
+
+ >
+ );
+
+ const copyButtons = getAllByRole('button');
copyButtons.forEach((copyButton) => {
- expect(copyButton).toBeDisabled();
+ expect(copyButton).not.toBeDisabled();
});
});
- it('should not disable copy button if isVPCOnlyLinode is false', () => {
- const { getAllByRole } = renderWithTheme(
+ it('should disable copy buttons for Public IP Addresses if isVPCOnlyLinode is true', () => {
+ const { container } = renderWithTheme(
);
- const copyButtons = getAllByRole('button');
+ const copyButtons = container.querySelectorAll('[data-qa-copy-btn]');
copyButtons.forEach((copyButton) => {
- expect(copyButton).not.toBeDisabled();
+ expect(copyButton).toBeDisabled();
});
});
});
diff --git a/packages/manager/src/features/Linodes/AccessTable.tsx b/packages/manager/src/features/Linodes/AccessTable.tsx
index 0254e9c29eb..4c886256df6 100644
--- a/packages/manager/src/features/Linodes/AccessTable.tsx
+++ b/packages/manager/src/features/Linodes/AccessTable.tsx
@@ -4,7 +4,7 @@ import * as React from 'react';
import { CopyTooltip } from 'src/components/CopyTooltip/CopyTooltip';
import { TableBody } from 'src/components/TableBody';
import { TableCell } from 'src/components/TableCell';
-import { PublicIpsUnassignedTooltip } from 'src/features/Linodes/PublicIpsUnassignedTooltip';
+import { PublicIPAddressesTooltip } from 'src/features/Linodes/PublicIPAddressesTooltip';
import {
StyledColumnLabelGrid,
@@ -37,20 +37,20 @@ interface AccessTableProps {
export const AccessTable = React.memo((props: AccessTableProps) => {
const { footer, gridSize, isVPCOnlyLinode, rows, sx, title } = props;
+
+ const isDisabled = isVPCOnlyLinode && title.includes('Public IP Address');
+
return (
- {title}{' '}
- {isVPCOnlyLinode &&
- title.includes('Public IP Address') &&
- PublicIpsUnassignedTooltip}
+ {title} {isDisabled && PublicIPAddressesTooltip}
{rows.map((thisRow) => {
return thisRow.text ? (
-
+
{thisRow.heading ? (
{thisRow.heading}
@@ -60,12 +60,12 @@ export const AccessTable = React.memo((props: AccessTableProps) => {
diff --git a/packages/manager/src/features/Linodes/LinodeEntityDetailBody.tsx b/packages/manager/src/features/Linodes/LinodeEntityDetailBody.tsx
index 487fc7e1dab..9eb76ca4d39 100644
--- a/packages/manager/src/features/Linodes/LinodeEntityDetailBody.tsx
+++ b/packages/manager/src/features/Linodes/LinodeEntityDetailBody.tsx
@@ -170,6 +170,7 @@ export const LinodeEntityDetailBody = React.memo((props: BodyProps) => {
)}
+
{
diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeIPAddressRow.test.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeIPAddressRow.test.tsx
index 12c95cd82e9..6e82eb2be09 100644
--- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeIPAddressRow.test.tsx
+++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeIPAddressRow.test.tsx
@@ -7,12 +7,12 @@ import {
ipResponseToDisplayRows,
vpcConfigInterfaceToDisplayRows,
} from 'src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeIPAddresses';
-import { PUBLIC_IPS_UNASSIGNED_TOOLTIP_TEXT } from 'src/features/Linodes/PublicIpsUnassignedTooltip';
+import { PUBLIC_IP_ADDRESSES_TOOLTIP_TEXT } from 'src/features/Linodes/PublicIPAddressesTooltip';
import { renderWithTheme, wrapWithTableBody } from 'src/utilities/testHelpers';
import { LinodeIPAddressRow } from './LinodeIPAddressRow';
-import type { IPAddressRowHandlers} from './LinodeIPAddressRow';
+import type { IPAddressRowHandlers } from './LinodeIPAddressRow';
const ips = linodeIPFactory.build();
const ipDisplay = ipResponseToDisplayRows(ips)[0];
@@ -100,7 +100,7 @@ describe('LinodeIPAddressRow', () => {
const editRDNSBtn = getByTestId('Edit RDNS');
expect(editRDNSBtn).toHaveAttribute('aria-disabled', 'true');
- expect(getAllByLabelText(PUBLIC_IPS_UNASSIGNED_TOOLTIP_TEXT)).toHaveLength(2);
+ expect(getAllByLabelText(PUBLIC_IP_ADDRESSES_TOOLTIP_TEXT)).toHaveLength(2);
});
it('should not disable the row if disabled is false', async () => {
@@ -116,7 +116,6 @@ describe('LinodeIPAddressRow', () => {
)
);
-
// open the action menu
await userEvent.click(
getByLabelText('Action menu for IP Address [object Object]')
diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeNetworkingActionMenu.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeNetworkingActionMenu.tsx
index b9be41758e6..0b425405105 100644
--- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeNetworkingActionMenu.tsx
+++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeNetworkingActionMenu.tsx
@@ -1,5 +1,4 @@
-import { IPAddress, IPRange } from '@linode/api-v4/lib/networking';
-import { Theme, useTheme } from '@mui/material/styles';
+import { useTheme } from '@mui/material/styles';
import useMediaQuery from '@mui/material/useMediaQuery';
import { isEmpty } from 'ramda';
import * as React from 'react';
@@ -7,10 +6,11 @@ import * as React from 'react';
import { ActionMenu } from 'src/components/ActionMenu/ActionMenu';
import { Box } from 'src/components/Box';
import { InlineMenuAction } from 'src/components/InlineMenuAction/InlineMenuAction';
-import { PUBLIC_IPS_UNASSIGNED_TOOLTIP_TEXT } from 'src/features/Linodes/PublicIpsUnassignedTooltip';
-
-import { IPTypes } from './types';
+import { PUBLIC_IP_ADDRESSES_TOOLTIP_TEXT } from 'src/features/Linodes/PublicIPAddressesTooltip';
+import type { IPTypes } from './types';
+import type { IPAddress, IPRange } from '@linode/api-v4/lib/networking';
+import type { Theme } from '@mui/material/styles';
import type { Action } from 'src/components/ActionMenu/ActionMenu';
interface Props {
@@ -71,7 +71,7 @@ export const LinodeNetworkingActionMenu = (props: Props) => {
tooltip: readOnly
? readOnlyTooltip
: isVPCOnlyLinode
- ? PUBLIC_IPS_UNASSIGNED_TOOLTIP_TEXT
+ ? PUBLIC_IP_ADDRESSES_TOOLTIP_TEXT
: isOnlyPublicIP
? isOnlyPublicIPTooltip
: undefined,
@@ -88,7 +88,7 @@ export const LinodeNetworkingActionMenu = (props: Props) => {
tooltip: readOnly
? readOnlyTooltip
: isVPCOnlyLinode
- ? PUBLIC_IPS_UNASSIGNED_TOOLTIP_TEXT
+ ? PUBLIC_IP_ADDRESSES_TOOLTIP_TEXT
: undefined,
}
: null,
diff --git a/packages/manager/src/features/Linodes/LinodesLanding/IPAddress.tsx b/packages/manager/src/features/Linodes/LinodesLanding/IPAddress.tsx
index 424e4f7c87f..2bfd5a19ea1 100644
--- a/packages/manager/src/features/Linodes/LinodesLanding/IPAddress.tsx
+++ b/packages/manager/src/features/Linodes/LinodesLanding/IPAddress.tsx
@@ -2,7 +2,7 @@ import * as React from 'react';
import { CopyTooltip } from 'src/components/CopyTooltip/CopyTooltip';
import { ShowMore } from 'src/components/ShowMore/ShowMore';
-import { PublicIpsUnassignedTooltip } from 'src/features/Linodes/PublicIpsUnassignedTooltip';
+import { PublicIPAddressesTooltip } from 'src/features/Linodes/PublicIPAddressesTooltip';
import { isPrivateIP } from 'src/utilities/ipUtils';
import { tail } from 'src/utilities/tail';
@@ -90,7 +90,7 @@ export const IPAddress = (props: IPAddressProps) => {
const renderCopyIcon = (ip: string) => {
if (disabled) {
- return PublicIpsUnassignedTooltip;
+ return PublicIPAddressesTooltip;
}
return (
diff --git a/packages/manager/src/features/Linodes/PublicIpsUnassignedTooltip.tsx b/packages/manager/src/features/Linodes/PublicIPAddressesTooltip.tsx
similarity index 67%
rename from packages/manager/src/features/Linodes/PublicIpsUnassignedTooltip.tsx
rename to packages/manager/src/features/Linodes/PublicIPAddressesTooltip.tsx
index 39b958dd984..2024c4611dd 100644
--- a/packages/manager/src/features/Linodes/PublicIpsUnassignedTooltip.tsx
+++ b/packages/manager/src/features/Linodes/PublicIPAddressesTooltip.tsx
@@ -9,14 +9,14 @@ const sxTooltipIcon = {
paddingLeft: '4px',
};
-export const PUBLIC_IPS_UNASSIGNED_TOOLTIP_TEXT =
- 'The Public IP Addresses have been unassigned from the configuration profile.';
+export const PUBLIC_IP_ADDRESSES_TOOLTIP_TEXT =
+ 'The noted Public IP Addresses are provisionally reserved but not assigned to the network interfaces in this configuration profile.';
-export const PublicIpsUnassignedTooltip = (
+export const PublicIPAddressesTooltip = (
- {PUBLIC_IPS_UNASSIGNED_TOOLTIP_TEXT}{' '}
+ {PUBLIC_IP_ADDRESSES_TOOLTIP_TEXT}{' '}
Learn more
diff --git a/packages/manager/src/features/NodeBalancers/ConfigNodeIPSelect.tsx b/packages/manager/src/features/NodeBalancers/ConfigNodeIPSelect.tsx
index 2372102a9d3..70061b5e2b8 100644
--- a/packages/manager/src/features/NodeBalancers/ConfigNodeIPSelect.tsx
+++ b/packages/manager/src/features/NodeBalancers/ConfigNodeIPSelect.tsx
@@ -41,7 +41,6 @@ interface Props {
region: string | undefined;
}
-
export const ConfigNodeIPSelect = React.memo((props: Props) => {
const {
disabled,
From dda6bf987b408b290b41be1624717affc82e7507 Mon Sep 17 00:00:00 2001
From: carrillo-erik <119514965+carrillo-erik@users.noreply.github.com>
Date: Wed, 16 Oct 2024 15:08:22 -0700
Subject: [PATCH 25/64] feat: [M3-7158] - Update NodeJS naming to Node.js for
Marketplace (#11086)
* feat: [M3-7158] - Update NodeJS naming to Node.js for Marketplace
* Revert changes to hardcoded `name` in oneClickAppsv2.tsx
* Add changeset
* Update the logic to display `label` vs `name`
* Update e2e test to use `stackscript.label` instead of `app.name`
* Revert changes to getMarketplaceAppLabel() and fix failing unit test in AppDetailDrawer
* Remove `name` field from oneClickApps in favor of stackscript label
* PR feedback and merge latest from develop branch
---
.../pr-11086-tech-stories-1729012343535.md | 5 +
.../core/oneClickApps/one-click-apps.spec.ts | 17 +-
.../manager/src/factories/stackscripts.ts | 1 -
.../Tabs/Marketplace/AppDetailDrawer.test.tsx | 2 +-
.../Tabs/Marketplace/AppDetailDrawer.tsx | 45 ++---
.../Tabs/Marketplace/AppSection.tsx | 2 +-
.../Tabs/Marketplace/utilities.ts | 1 -
.../features/OneClickApps/oneClickAppsv2.ts | 167 +++---------------
.../src/features/OneClickApps/types.ts | 1 -
9 files changed, 67 insertions(+), 174 deletions(-)
create mode 100644 packages/manager/.changeset/pr-11086-tech-stories-1729012343535.md
diff --git a/packages/manager/.changeset/pr-11086-tech-stories-1729012343535.md b/packages/manager/.changeset/pr-11086-tech-stories-1729012343535.md
new file mode 100644
index 00000000000..8628e0411de
--- /dev/null
+++ b/packages/manager/.changeset/pr-11086-tech-stories-1729012343535.md
@@ -0,0 +1,5 @@
+---
+"@linode/manager": Tech Stories
+---
+
+Update NodeJS naming to Node.js for Marketplace ([#11086](https://github.com/linode/manager/pull/11086))
diff --git a/packages/manager/cypress/e2e/core/oneClickApps/one-click-apps.spec.ts b/packages/manager/cypress/e2e/core/oneClickApps/one-click-apps.spec.ts
index e490f0ffa4f..ad8f3a6e28a 100644
--- a/packages/manager/cypress/e2e/core/oneClickApps/one-click-apps.spec.ts
+++ b/packages/manager/cypress/e2e/core/oneClickApps/one-click-apps.spec.ts
@@ -34,17 +34,20 @@ describe('OneClick Apps (OCA)', () => {
// For every Marketplace app defined in Cloud Manager, make sure the API returns
// the nessesary StackScript and that the app renders on the page.
for (const stackscriptId in oneClickApps) {
- const stackscript = stackScripts.find((s) => s.id === +stackscriptId);
- const app = oneClickApps[stackscriptId];
+ const stackscript = stackScripts.find(
+ (stackScript) => stackScript.id === +stackscriptId
+ );
if (!stackscript) {
throw new Error(
- `Cloud Manager's fetch to GET /v4/linode/stackscripts did not recieve a StackScript with ID ${stackscriptId}. We expected that StackScript to be in the response for the Marketplace app named "${app.name}".`
+ `Cloud Manager's fetch to GET /v4/linode/stackscripts did not receive a StackScript with ID ${stackscriptId}. We expected a StackScript to be in the response.`
);
}
+ const displayLabel = getMarketplaceAppLabel(stackscript.label);
+
// Using `findAllByText` because some apps may be duplicatd under different sections
- cy.findAllByText(getMarketplaceAppLabel(app.name)).should('exist');
+ cy.findAllByText(displayLabel).should('exist');
}
});
});
@@ -81,7 +84,9 @@ describe('OneClick Apps (OCA)', () => {
}
cy.findByTestId('one-click-apps-container').within(() => {
- cy.findAllByLabelText(`Info for "${candidateApp.name}"`)
+ cy.findAllByLabelText(
+ `Info for "${getMarketplaceAppLabel(candidateStackScript.label)}"`
+ )
.first()
.scrollIntoView()
.should('be.visible')
@@ -90,7 +95,7 @@ describe('OneClick Apps (OCA)', () => {
});
ui.drawer
- .findByTitle(candidateApp.name)
+ .findByTitle(getMarketplaceAppLabel(candidateStackScript.label))
.should('be.visible')
.within(() => {
cy.findByText(candidateApp.description).should('be.visible');
diff --git a/packages/manager/src/factories/stackscripts.ts b/packages/manager/src/factories/stackscripts.ts
index 090db396c83..87e8f4bb4e6 100644
--- a/packages/manager/src/factories/stackscripts.ts
+++ b/packages/manager/src/factories/stackscripts.ts
@@ -36,7 +36,6 @@ export const oneClickAppFactory = Factory.Sync.makeFactory({
},
description: 'A test app',
logo_url: 'nodejs.svg',
- name: 'Test App',
summary: 'A test app',
website: 'https://www.linode.com',
});
diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Marketplace/AppDetailDrawer.test.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Marketplace/AppDetailDrawer.test.tsx
index 362721bd586..3f9b0826df6 100644
--- a/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Marketplace/AppDetailDrawer.test.tsx
+++ b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Marketplace/AppDetailDrawer.test.tsx
@@ -23,7 +23,7 @@ describe('AppDetailDrawer', () => {
);
// Verify title renders
- expect(await findByText('WordPress')).toBeVisible();
+ expect(await findByText(stackscript.label)).toBeVisible();
// Verify description renders
expect(
diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Marketplace/AppDetailDrawer.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Marketplace/AppDetailDrawer.tsx
index 3bebdd27c8a..ddbfc43bae3 100644
--- a/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Marketplace/AppDetailDrawer.tsx
+++ b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Marketplace/AppDetailDrawer.tsx
@@ -10,7 +10,7 @@ import { Link } from 'src/components/Link';
import { Typography } from 'src/components/Typography';
import { sanitizeHTML } from 'src/utilities/sanitizeHTML';
-import { useMarketplaceApps } from './utilities';
+import { getMarketplaceAppLabel, useMarketplaceApps } from './utilities';
import type { Theme } from '@mui/material/styles';
@@ -69,11 +69,13 @@ export const AppDetailDrawer = (props: Props) => {
const { classes } = useStyles();
const { apps } = useMarketplaceApps();
- const selectedApp = apps.find((app) => app.stackscript.id === stackScriptId)
- ?.details;
+ const selectedApp = apps.find((app) => app.stackscript.id === stackScriptId);
+ const displayLabel = selectedApp
+ ? getMarketplaceAppLabel(selectedApp?.stackscript.label)
+ : '';
const gradient = {
- backgroundImage: `url(/assets/marketplace-background.png),linear-gradient(to right, #${selectedApp?.colors.start}, #${selectedApp?.colors.end})`,
+ backgroundImage: `url(/assets/marketplace-background.png),linear-gradient(to right, #${selectedApp?.details?.colors.start}, #${selectedApp?.details?.colors.end})`,
};
return (
@@ -109,59 +111,62 @@ export const AppDetailDrawer = (props: Props) => {
- {selectedApp.summary}
+
+ {selectedApp?.details.summary}
+
- {selectedApp.website && (
+ {selectedApp?.details.website && (
Website
- {selectedApp.website}
+ {selectedApp?.details.website}
)}
- {selectedApp.related_guides && (
+ {selectedApp?.details.related_guides && (
Guides
- {selectedApp.related_guides.map((link, idx) => (
+ {selectedApp?.details.related_guides.map((link, idx) => (
{sanitizeHTML({
@@ -173,13 +178,13 @@ export const AppDetailDrawer = (props: Props) => {
)}
- {selectedApp.tips && (
+ {selectedApp?.details.tips && (
Tips
- {selectedApp.tips.map((tip, idx) => (
+ {selectedApp?.details.tips.map((tip, idx) => (
{tip}
diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Marketplace/AppSection.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Marketplace/AppSection.tsx
index 834a4fbecd2..26d1a8c098e 100644
--- a/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Marketplace/AppSection.tsx
+++ b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Marketplace/AppSection.tsx
@@ -20,10 +20,10 @@ interface Props {
export const AppSection = (props: Props) => {
const {
+ apps,
onOpenDetailsDrawer,
onSelect,
selectedStackscriptId,
- apps,
title,
} = props;
diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Marketplace/utilities.ts b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Marketplace/utilities.ts
index f83c696c1ef..34319797d94 100644
--- a/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Marketplace/utilities.ts
+++ b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Marketplace/utilities.ts
@@ -116,7 +116,6 @@ const getDoesMarketplaceAppMatchQuery = (
const searchableAppFields = [
String(app.stackscript.id),
app.stackscript.label,
- app.details.name,
app.details.alt_name,
app.details.alt_description,
...app.details.categories,
diff --git a/packages/manager/src/features/OneClickApps/oneClickAppsv2.ts b/packages/manager/src/features/OneClickApps/oneClickAppsv2.ts
index 61094ec2475..19c2a99220a 100644
--- a/packages/manager/src/features/OneClickApps/oneClickAppsv2.ts
+++ b/packages/manager/src/features/OneClickApps/oneClickAppsv2.ts
@@ -17,7 +17,6 @@ export const oneClickApps: Record = {
},
description: `With 60 million users around the globe, WordPress is the industry standard for custom websites such as blogs, news sites, personal websites, and anything in-between. With a focus on best in class usability and flexibility, you can have a customized website up and running in minutes.`,
logo_url: 'wordpress.svg',
- name: 'WordPress',
related_guides: [
{
href:
@@ -39,7 +38,6 @@ export const oneClickApps: Record = {
},
description: `Drupal is a content management system (CMS) designed for building custom websites for personal and business use. Built for high performance and scalability, Drupal provides the necessary tools to create rich, interactive community websites with forums, user blogs, and private messaging. Drupal also has support for personal publishing projects and can power podcasts, blogs, and knowledge-based systems, all within a single, unified platform.`,
logo_url: 'drupal.svg',
- name: 'Drupal',
related_guides: [
{
href:
@@ -61,7 +59,6 @@ export const oneClickApps: Record = {
description: `The LAMP stack consists of the Linux operating system, the Apache HTTP Server, the MySQL relational database management system, and the PHP programming language. This software environment is a foundation for popular PHP application
frameworks like WordPress, Drupal, and Laravel. Upload your existing PHP application code to your new app or use a PHP framework to write a new application on the Linode.`,
logo_url: 'lamp.svg',
- name: 'LAMP',
related_guides: [
{
href:
@@ -83,7 +80,6 @@ export const oneClickApps: Record = {
and Node.js, which serves as the run-time environment for your application. All of these technologies are well-established, offer robust feature sets, and are well-supported by their maintaining organizations. These characteristics make them a great choice for your applications. Upload your
existing MERN website code to your new Linode, or use MERN's scaffolding tool to start writing new web applications on the Linode.`,
logo_url: 'mern.svg',
- name: 'MERN',
related_guides: [
{
href:
@@ -104,7 +100,6 @@ export const oneClickApps: Record = {
description: `Configuring WireGuard® is as simple as configuring SSH. A connection is established by an exchange of public keys between server and client, and only a client whose public key is present in the server's configuration file is considered authorized. WireGuard sets up
standard network interfaces which behave similarly to other common network interfaces, like eth0. This makes it possible to configure and manage WireGuard interfaces using standard networking tools such as ifconfig and ip. "WireGuard" is a registered trademark of Jason A. Donenfeld.`,
logo_url: 'wireguard.svg',
- name: 'WireGuard®',
related_guides: [
{
href:
@@ -126,7 +121,6 @@ export const oneClickApps: Record = {
description: `GitLab is a complete solution for all aspects of your software development. At its core, GitLab serves as your centralized Git repository. GitLab also features built-in tools that represent every task in your development workflow, from planning to testing to releasing.
Self-hosting your software development with GitLab offers total control of your codebase. At the same time, its familiar interface will ease collaboration for you and your team. GitLab is the most popular self-hosted Git repository, so you'll benefit from a robust set of integrated tools and an active community.`,
logo_url: 'gitlab.svg',
- name: 'GitLab',
related_guides: [
{
href:
@@ -148,7 +142,6 @@ export const oneClickApps: Record = {
},
description: `With WooCommerce, you can securely sell both digital and physical goods, and take payments via major credit cards, bank transfers, PayPal, and other providers like Stripe. With more than 300 extensions to choose from, WooCommerce is extremely flexible.`,
logo_url: 'woocommerce.svg',
- name: 'WooCommerce',
related_guides: [
{
href:
@@ -171,7 +164,6 @@ export const oneClickApps: Record = {
taming forests, and venturing out to sea. Choose a home from the varied list of biomes like ice worlds, flower plains, and jungles. Build ancient castles or modern mega cities, and fill them with redstone circuit contraptions and villagers. Fight off nightly invasions of Skeletons, Zombies, and explosive
Creepers, or adventure to the End and the Nether to summon the fabled End Dragon and the chaotic Wither. If that is not enough, Minecraft is also highly moddable and customizable. You decide the rules when hosting your own Minecraft server for you and your friends to play together in this highly addictive game.`,
logo_url: 'minecraft.svg',
- name: 'Minecraft: Java Edition',
related_guides: [
{
href:
@@ -192,7 +184,6 @@ export const oneClickApps: Record = {
},
description: `OpenVPN is a widely trusted, free, and open-source virtual private network application. OpenVPN creates network tunnels between groups of computers that are not on the same local network, and it uses OpenSSL to encrypt your traffic.`,
logo_url: 'openvpn.svg',
- name: 'OpenVPN',
related_guides: [
{
href:
@@ -213,7 +204,6 @@ export const oneClickApps: Record = {
},
description: `Plesk is a leading WordPress and website management platform and control panel. Plesk lets you build and manage multiple websites from a single dashboard to configure web services, email, and other applications. Plesk features hundreds of extensions, plus a complete WordPress toolkit. Use the Plesk One-Click App to manage websites hosted on your Linode.`,
logo_url: 'plesk.svg',
- name: 'Plesk',
related_guides: [
{
href:
@@ -236,7 +226,6 @@ export const oneClickApps: Record = {
},
description: `The cPanel & WHM® Marketplace App streamlines publishing and managing a website on your Linode. cPanel & WHM is a Linux® based web hosting control panel and platform that helps you create and manage websites, servers, databases and more with a suite of hosting automation and optimization tools.`,
logo_url: 'cpanel.svg',
- name: 'cPanel',
related_guides: [
{
href:
@@ -259,7 +248,6 @@ export const oneClickApps: Record = {
description:
'Shadowsocks is a lightweight SOCKS5 web proxy tool. A full setup requires a Linode server to host the Shadowsocks daemon, and a client installed on PC, Mac, Linux, or a mobile device. Unlike other proxy software, Shadowsocks traffic is designed to be both indiscernible from other traffic to third-party monitoring tools, and also able to disguise itself as a normal direct connection. Data passing through Shadowsocks is encrypted for additional security and privacy.',
logo_url: 'shadowsocks.svg',
- name: 'Shadowsocks',
related_guides: [
{
href:
@@ -281,7 +269,6 @@ export const oneClickApps: Record = {
},
description: `LEMP provides a platform for applications that is compatible with the LAMP stack for nearly all applications; however, because NGINX is able to serve more pages at once with a more predictable memory usage profile, it may be more suited to high demand situations.`,
logo_url: 'lemp.svg',
- name: 'LEMP',
related_guides: [
{
href:
@@ -301,7 +288,6 @@ export const oneClickApps: Record = {
},
description: `MySQL, or MariaDB for Linux operating systems, is primarily used for web and server applications, including as a component of the industry-standard LAMP and LEMP stacks.`,
logo_url: 'mysql.svg',
- name: 'MySQL/MariaDB',
related_guides: [
{
href:
@@ -322,7 +308,6 @@ export const oneClickApps: Record = {
},
description: `Jenkins is an open source automation tool which can build, test, and deploy your infrastructure.`,
logo_url: 'jenkins.svg',
- name: 'Jenkins',
related_guides: [
{
href:
@@ -344,7 +329,6 @@ export const oneClickApps: Record = {
},
description: `Docker is a tool that enables you to create, deploy, and manage lightweight, stand-alone packages that contain everything needed to run an application (code, libraries, runtime, system settings, and dependencies).`,
logo_url: 'docker.svg',
- name: 'Docker',
related_guides: [
{
href:
@@ -365,7 +349,6 @@ export const oneClickApps: Record = {
},
description: `Redis® is an open-source, in-memory, data-structure store, with the optional ability to write and persist data to a disk, which can be used as a key-value database, cache, and message broker. Redis® features built-in transactions, replication, and support for a variety of data structures such as strings, hashes, lists, sets, and others.
*Redis is a registered trademark of Redis Ltd. Any rights therein are reserved to Redis Ltd. Any use by Akamai Technologies is for referential purposes only and does not indicate any sponsorship, endorsement or affiliation between Redis and Akamai Technologies.`,
logo_url: 'redis.svg',
- name: 'Marketplace App for Redis®',
related_guides: [
{
href:
@@ -388,7 +371,6 @@ export const oneClickApps: Record = {
},
description: `Intuitive web interface for MySQL and MariaDB operations, including importing/exporting data, administering multiple servers, and global database search.`,
logo_url: 'phpmyadmin.svg',
- name: 'phpMyAdmin',
related_guides: [
{
href:
@@ -409,7 +391,6 @@ export const oneClickApps: Record = {
},
description: `Rails is a web application development framework written in the Ruby programming language. It is designed to make programming web applications easier by giving every developer a number of common tools they need to get started. Ruby on Rails empowers you to accomplish more with less code.`,
logo_url: 'rubyonrails.svg',
- name: 'Ruby on Rails',
related_guides: [
{
href:
@@ -430,7 +411,6 @@ export const oneClickApps: Record = {
},
description: `Django is a web development framework for the Python programming language. It enables rapid development, while favoring pragmatic and clean design.`,
logo_url: 'django.svg',
- name: 'Django',
related_guides: [
{
href:
@@ -451,7 +431,6 @@ export const oneClickApps: Record = {
},
description: `Flask is a lightweight WSGI web application framework written in Python. It is designed to make getting started quick and easy, with the ability to scale up to complex applications.`,
logo_url: 'flask.svg',
- name: 'Flask',
related_guides: [
{
href:
@@ -472,7 +451,6 @@ export const oneClickApps: Record = {
},
description: `PostgreSQL is a popular open source relational database system that provides many advanced configuration options that can help optimize your database’s performance in a production environment.`,
logo_url: 'postgresql.svg',
- name: 'PostgreSQL',
related_guides: [
{
href:
@@ -493,7 +471,6 @@ export const oneClickApps: Record = {
},
description: `MEAN is a full-stack JavaScript-based framework which accelerates web application development much faster than other frameworks. All involved technologies are well-established, offer robust feature sets, and are well-supported by their maintaining organizations. These characteristics make them a great choice for your applications.`,
logo_url: 'mean.svg',
- name: 'MEAN',
related_guides: [
{
href:
@@ -515,7 +492,6 @@ export const oneClickApps: Record = {
},
description: `Nextcloud AIO stands for Nextcloud All In One, and provides easy deployment and maintenance for popular Nextcloud tools. AIO includes Nextcloud, Nextcloud Office, OnlyOffice, and high-performance backend features.`,
logo_url: 'nextcloud.svg',
- name: 'Nextcloud',
related_guides: [
{
href:
@@ -535,7 +511,6 @@ export const oneClickApps: Record = {
},
description: `All aspects of running a radio station in one web interface so you can start your own station. Manage media, create playlists, and interact with listeners on one free platform.`,
logo_url: 'azuracast.svg',
- name: 'Azuracast',
related_guides: [
{
href:
@@ -557,7 +532,6 @@ export const oneClickApps: Record = {
},
description: `Organize, stream, and share your media library with friends, in addition to free live TV in 220+ countries.`,
logo_url: 'plex.svg',
- name: 'Plex',
related_guides: [
{
href:
@@ -579,7 +553,6 @@ export const oneClickApps: Record = {
},
description: `Secure, stable, and free alternative to popular video conferencing services. Use built-in features to limit meeting access with passwords or stream on YouTube so anyone can attend.`,
logo_url: 'jitsi.svg',
- name: 'Jitsi',
related_guides: [
{
href:
@@ -600,7 +573,6 @@ export const oneClickApps: Record = {
},
description: `Connect and scale applications with asynchronous messaging and highly available work queues, all controlled through an intuitive management UI.`,
logo_url: 'rabbitmq.svg',
- name: 'RabbitMQ',
related_guides: [
{
href:
@@ -621,7 +593,6 @@ export const oneClickApps: Record = {
},
description: `Launch a sleek forum with robust integrations to popular tools like Slack and WordPress to start more conversations.`,
logo_url: 'discourse.svg',
- name: 'Discourse',
related_guides: [
{
href:
@@ -644,7 +615,6 @@ export const oneClickApps: Record = {
},
description: `Lightweight control panel with a suite of features to streamline app management.`,
logo_url: 'webuzo.svg',
- name: 'Webuzo',
related_guides: [
{
href:
@@ -666,7 +636,6 @@ export const oneClickApps: Record = {
},
description: `Launch a portable development environment to speed up tests, downloads, and more.`,
logo_url: 'vscodeserver.svg',
- name: 'VS Code Server',
related_guides: [
{
href:
@@ -687,7 +656,6 @@ export const oneClickApps: Record = {
},
description: `Self-hosted Git service built and maintained by a large developer community.`,
logo_url: 'gitea.svg',
- name: 'Gitea',
related_guides: [
{
href:
@@ -708,7 +676,6 @@ export const oneClickApps: Record = {
},
description: `Use Kepler Builder to easily design and build sites in WordPress - no coding or design knowledge necessary.`,
logo_url: 'keplerbuilder.svg',
- name: 'Kepler Builder',
related_guides: [
{
href:
@@ -729,7 +696,6 @@ export const oneClickApps: Record = {
},
description: `Access your desktop from any device with a browser to keep your desktop hosted in the cloud.`,
logo_url: 'guacamole.svg',
- name: 'Guacamole',
related_guides: [
{
href:
@@ -750,7 +716,6 @@ export const oneClickApps: Record = {
},
description: `File synchronization across multiple users’ computers and other devices to keep everyone working without interruption.`,
logo_url: 'filecloud.svg',
- name: 'FileCloud',
related_guides: [
{
href:
@@ -772,7 +737,6 @@ export const oneClickApps: Record = {
},
description: `Turnkey solution for running apps like WordPress, Rocket.Chat, NextCloud, GitLab, and OpenVPN.`,
logo_url: 'cloudron.svg',
- name: 'Cloudron',
related_guides: [
{
href:
@@ -794,7 +758,6 @@ export const oneClickApps: Record = {
},
description: `Accelerated and scalable hosting for WordPress. Includes OpenLiteSpeed, PHP, MySQL Server, WordPress, and LiteSpeed Cache.`,
logo_url: 'openlitespeedwordpress.svg',
- name: 'OpenLiteSpeed WordPress',
related_guides: [
{
href:
@@ -815,7 +778,6 @@ export const oneClickApps: Record = {
},
description: `Save time on securing your Linode by deploying an instance pre-configured with some basic security best practices: limited user account access, hardened SSH, and Fail2Ban for SSH Login Protection.`,
logo_url: 'secureyourserver.svg',
- name: 'Secure Your Server',
related_guides: [
{
href:
@@ -836,7 +798,6 @@ export const oneClickApps: Record = {
},
description: `Reduce setup time required to host websites and applications, including popular tools like OpenLiteSpeed WordPress.`,
logo_url: 'cyberpanel.svg',
- name: 'CyberPanel',
related_guides: [
{
href:
@@ -857,7 +818,6 @@ export const oneClickApps: Record = {
},
description: `Simplify Docker deployments and make containerization easy for anyone to use. Please note: Yacht is still in alpha and is not recommended for production use.`,
logo_url: 'yacht.svg',
- name: 'Yacht',
related_guides: [
{
href:
@@ -878,7 +838,6 @@ export const oneClickApps: Record = {
},
description: `Monitor, track performance and maintain availability for network servers, devices, services and other IT resources– all in one tool.`,
logo_url: 'zabbix.svg',
- name: 'Zabbix',
related_guides: [
{
href:
@@ -899,7 +858,6 @@ export const oneClickApps: Record = {
},
description: `Host multiple sites on a single server while managing apps, firewall, databases, backups, system users, cron jobs, SSL and email– all in an intuitive interface.`,
logo_url: 'serverwand.svg',
- name: 'ServerWand',
related_guides: [
{
href:
@@ -921,7 +879,6 @@ export const oneClickApps: Record = {
},
description: `Open source alternative to paid ticket management solutions with essential features including a streamlined task list, project and client management, and ticket prioritization.`,
logo_url: 'peppermint.svg',
- name: 'Peppermint',
related_guides: [
{
href:
@@ -943,7 +900,6 @@ export const oneClickApps: Record = {
},
description: `Self-hosted free version to optimize and record video streaming for webinars, gaming, and more.`,
logo_url: 'antmediaserver.svg',
- name: 'Ant Media Server: Community Edition',
related_guides: [
{
href:
@@ -964,7 +920,6 @@ export const oneClickApps: Record = {
},
description: `A live streaming and chat server for use with existing popular broadcasting software.`,
logo_url: 'owncast.svg',
- name: 'Owncast',
related_guides: [
{
href:
@@ -986,7 +941,6 @@ export const oneClickApps: Record = {
},
description: `Robust open-source learning platform enabling online education for more than 200 million users around the world. Create personalized learning environments within a secure and integrated system built for all education levels with an intuitive interface, drag-and-drop features, and accessible documentation.`,
logo_url: 'moodle.svg',
- name: 'Moodle',
related_guides: [
{
href:
@@ -1008,7 +962,6 @@ export const oneClickApps: Record = {
},
description: `Feature-rich alternative control panel for users who need critical control panel functionality but don’t need to pay for more niche premium features. aaPanel is open source and consistently maintained with weekly updates.`,
logo_url: 'aapanel.svg',
- name: 'aaPanel',
related_guides: [
{
href:
@@ -1030,7 +983,6 @@ export const oneClickApps: Record = {
},
description: `Popular data-to-everything platform with advanced security, observability, and automation features for machine learning and AI.`,
logo_url: 'splunk.svg',
- name: 'Splunk',
related_guides: [
{
href:
@@ -1053,7 +1005,6 @@ export const oneClickApps: Record = {
},
description: `Chevereto is a full-featured image sharing solution that acts as an alternative to services like Google Photos or Flickr. Optimize image hosting by using external cloud storage (like Linode’s S3-compatible Object Storage) and connect to Chevereto using API keys.`,
logo_url: 'chevereto.svg',
- name: 'Chevereto',
related_guides: [
{
href:
@@ -1076,7 +1027,6 @@ export const oneClickApps: Record = {
},
description: `Securely share and collaborate Linode S3 object storage files/folders with your internal or external users such as customers, partners, vendors, etc with fine access control and a simple interface. Nirvashare easily integrates with many external identity providers such as Active Directory, GSuite, AWS SSO, KeyClock, etc.`,
logo_url: 'nirvashare.svg',
- name: 'NirvaShare',
related_guides: [
{
href:
@@ -1099,7 +1049,6 @@ export const oneClickApps: Record = {
},
description: `All-in-one interface for scripting and monitoring databases, including MySQL, MariaDB, Percona, PostgreSQL, Galera Cluster and more. Easily deploy database instances, manage with an included CLI, and automate performance monitoring.`,
logo_url: 'clustercontrol.svg',
- name: 'ClusterControl',
related_guides: [
{
href:
@@ -1121,7 +1070,6 @@ export const oneClickApps: Record = {
},
description: `Powerful and customizable backups for several websites and data all in the same interface. JetBackup integrates with any control panel via API, and has native support for cPanel and DirectAdmin. Easily backup your data to storage you already use, including Linode’s S3-compatible Object Storage.`,
logo_url: 'jetbackup.svg',
- name: 'JetBackup',
related_guides: [
{
href:
@@ -1143,7 +1091,6 @@ export const oneClickApps: Record = {
},
description: `Open source registry for images and containers. Linode recommends using Harbor with Linode Kubernetes Engine (LKE).`,
logo_url: 'harbor.svg',
- name: 'Harbor',
related_guides: [
{
href:
@@ -1164,7 +1111,6 @@ export const oneClickApps: Record = {
},
description: `Put data privacy first with an alternative to programs like Slack and Microsoft Teams.`,
logo_url: 'rocketchat.svg',
- name: 'Rocket.Chat',
related_guides: [
{
href:
@@ -1186,7 +1132,6 @@ export const oneClickApps: Record = {
},
description: `Infrastructure monitoring solution to detect threats, intrusion attempts, unauthorized user actions, and provide security analytics.`,
logo_url: 'wazuh.svg',
- name: 'Wazuh',
related_guides: [
{
href:
@@ -1207,7 +1152,6 @@ export const oneClickApps: Record = {
},
description: `Test the security posture of a client or application using client-side vectors, all powered by a simple API. This project is developed solely for lawful research and penetration testing.`,
logo_url: 'beef.svg',
- name: 'BeEF',
related_guides: [
{
href:
@@ -1229,7 +1173,6 @@ export const oneClickApps: Record = {
},
description: `Simple deployment for OLS web server, Python LSAPI, and CertBot.`,
logo_url: 'openlitespeeddjango.svg',
- name: 'OpenLiteSpeed Django',
related_guides: [
{
href:
@@ -1250,7 +1193,6 @@ export const oneClickApps: Record = {
},
description: `Easy setup to run Ruby apps in the cloud and take advantage of OpenLiteSpeed server features like SSL, HTTP/3 support, and RewriteRules.`,
logo_url: 'openlitespeedrails.svg',
- name: 'OpenLiteSpeed Rails',
related_guides: [
{
href:
@@ -1272,7 +1214,6 @@ export const oneClickApps: Record = {
},
description: `High-performance open source web server with Node and CertBot, in addition to features like HTTP/3 support and easy SSL setup.`,
logo_url: 'openlitespeednodejs.svg',
- name: 'OpenLiteSpeed NodeJS',
related_guides: [
{
href:
@@ -1280,7 +1221,7 @@ export const oneClickApps: Record = {
title: 'Deploy OpenLiteSpeed Node.js through the Linode Marketplace',
},
],
- summary: 'OLS web server with NodeJS JavaScript runtime environment.',
+ summary: 'OLS web server with Node.js JavaScript runtime environment.',
website: 'https://docs.litespeedtech.com/cloud/images/nodejs/',
},
923032: {
@@ -1293,7 +1234,6 @@ export const oneClickApps: Record = {
},
description: `High-performance LiteSpeed web server equipped with WHM/cPanel and WHM LiteSpeed Plugin.`,
logo_url: 'litespeedcpanel.svg',
- name: 'LiteSpeed cPanel',
related_guides: [
{
href:
@@ -1315,7 +1255,6 @@ export const oneClickApps: Record = {
},
description: `Akaunting is a universal accounting software that helps small businesses run more efficiently. Track expenses, generate reports, manage your books, and get the other essential features to run your business from a single dashboard.`,
logo_url: 'akaunting.svg',
- name: 'Akaunting',
related_guides: [
{
href:
@@ -1337,7 +1276,6 @@ export const oneClickApps: Record = {
},
description: `Restyaboard is an open-source alternative to Trello, but with additional smart features like offline sync, diff /revisions, nested comments, multiple view layouts, chat, and more.`,
logo_url: 'restyaboard.svg',
- name: 'Restyaboard',
related_guides: [
{
href:
@@ -1358,7 +1296,6 @@ export const oneClickApps: Record = {
},
description: `Feature-rich, self-hosted VPN based on WireGuard® protocol, plus convenient features like single sign-on, real-time bandwidth monitoring, and unlimited users/devices.`,
logo_url: 'warpspeed.svg',
- name: 'WarpSpeed',
related_guides: [
{
href:
@@ -1379,7 +1316,6 @@ export const oneClickApps: Record = {
},
description: `UTunnel VPN is a robust cloud-based VPN server software solution. With UTunnel VPN, businesses could easily set up secure remote access to their business network. UTunnel comes with a host of business-centric features including site-to-site connectivity, single sign-on integration, 2-factor authentication, etc.`,
logo_url: 'utunnel.svg',
- name: 'UTunnel VPN',
related_guides: [
{
href:
@@ -1401,7 +1337,6 @@ export const oneClickApps: Record = {
},
description: `User-friendly VPN for both individual and commercial use. Choose from three pricing plans.`,
logo_url: 'pritunl.svg',
- name: 'Pritunl',
related_guides: [
{
href:
@@ -1422,7 +1357,6 @@ export const oneClickApps: Record = {
},
description: `VictoriaMetrics is designed to collect, store, and process real-time metrics.`,
logo_url: 'victoriametricssingle.svg',
- name: 'VictoriaMetrics Single',
related_guides: [
{
href:
@@ -1444,7 +1378,6 @@ export const oneClickApps: Record = {
},
description: `Protect your network and devices from unwanted content. Avoid ads in non-browser locations with a free, lightweight, and comprehensive privacy solution you can self-host.`,
logo_url: 'pihole.svg',
- name: 'Pi-hole',
related_guides: [
{
href:
@@ -1466,7 +1399,6 @@ export const oneClickApps: Record = {
},
description: `Uptime Kuma is self-hosted alternative to Uptime Robot. Get real-time performance insights for HTTP(s), TCP/ HTTP(s) Keyword, Ping, DNS Record, and more. Monitor everything you need in one UI dashboard, or customize how you receive alerts with a wide range of supported integrations.`,
logo_url: 'uptimekuma.svg',
- name: 'Uptime Kuma',
related_guides: [
{
href:
@@ -1487,7 +1419,6 @@ export const oneClickApps: Record = {
},
description: `Build websites on a CMS that prioritizes speed and simplicity over customization and integration support. Create your content in Markdown and take advantage of powerful taxonomy to customize relationships between pages and other content.`,
logo_url: 'grav.svg',
- name: 'Grav',
related_guides: [
{
href:
@@ -1507,14 +1438,13 @@ export const oneClickApps: Record = {
end: '333333',
start: '3d853c',
},
- description: `NodeJS is a free, open-source, and cross-platform JavaScript run-time environment that lets developers write command line tools and server-side scripts outside of a browser.`,
+ description: `Node.js is a free, open-source, and cross-platform JavaScript run-time environment that lets developers write command line tools and server-side scripts outside of a browser.`,
logo_url: 'nodejs.svg',
- name: 'NodeJS',
related_guides: [
{
href:
'https://www.linode.com/docs/products/tools/marketplace/guides/nodejs/',
- title: 'Deploy NodeJS through the Linode Marketplace',
+ title: 'Deploy Node.js through the Linode Marketplace',
},
],
summary:
@@ -1531,7 +1461,6 @@ export const oneClickApps: Record = {
},
description: `Build applications without writing a single line of code. Saltcorn is a free platform that allows you to build an app with an intuitive point-and-click, drag-and-drop UI.`,
logo_url: 'saltcorn.svg',
- name: 'Saltcorn',
related_guides: [
{
href:
@@ -1553,7 +1482,6 @@ export const oneClickApps: Record = {
},
description: `Odoo is a free and comprehensive business app suite of tools that seamlessly integrate. Choose what you need to manage your business on a single platform, including a CRM, email marketing tools, essential project management functions, and more.`,
logo_url: 'odoo.svg',
- name: 'Odoo',
related_guides: [
{
href:
@@ -1575,7 +1503,6 @@ export const oneClickApps: Record = {
},
description: `Create boards, assign tasks, and keep projects moving with a free and robust alternative to tools like Trello and Asana.`,
logo_url: 'focalboard.svg',
- name: 'Focalboard',
related_guides: [
{
href:
@@ -1596,7 +1523,6 @@ export const oneClickApps: Record = {
},
description: `Free industry-standard monitoring tools that work better together. Prometheus is a powerful monitoring software tool that collects metrics from configurable data points at given intervals, evaluates rule expressions, and can trigger alerts if some condition is observed. Use Grafana to create visuals, monitor, store, and share metrics with your team to keep tabs on your infrastructure.`,
logo_url: 'prometheusgrafana.svg',
- name: 'Prometheus & Grafana',
related_guides: [
{
href:
@@ -1617,7 +1543,6 @@ export const oneClickApps: Record = {
},
description: `Free open source CMS optimized for building custom functionality and design.`,
logo_url: 'joomla.svg',
- name: 'Joomla',
related_guides: [
{
href:
@@ -1639,7 +1564,6 @@ export const oneClickApps: Record = {
},
description: `Ant Media Server makes it easy to set up a video streaming platform with ultra low latency. The Enterprise edition supports WebRTC Live Streaming in addition to CMAF and HLS streaming. Set up live restreaming to social media platforms to reach more viewers.`,
logo_url: 'antmediaserver.svg',
- name: 'Ant Media Server: Enterprise Edition',
related_guides: [
{
href:
@@ -1662,7 +1586,6 @@ export const oneClickApps: Record = {
},
description: `Capture your thoughts and securely access them from any device with a highly customizable note-taking software.`,
logo_url: 'joplin.svg',
- name: 'Joplin',
related_guides: [
{
href:
@@ -1683,7 +1606,6 @@ export const oneClickApps: Record = {
},
description: `Stream live audio or video while maximizing customer engagement with advanced built-in features. Liveswitch provides real-time monitoring, audience polling, and end-to-end (E2E) data encryption.`,
logo_url: 'liveswitch.svg',
- name: 'LiveSwitch',
related_guides: [
{
href:
@@ -1705,7 +1627,6 @@ export const oneClickApps: Record = {
},
description: `Deploy Node.js, Ruby, Python, PHP, Go, and Java applications via an intuitive control panel. Easily set up free SSL certificates, run commands with an in-browser terminal, and push your code from Github to accelerate development.`,
logo_url: 'easypanel.svg',
- name: 'Easypanel',
related_guides: [
{
href:
@@ -1727,7 +1648,6 @@ export const oneClickApps: Record = {
},
description: `Kali Linux is an open source, Debian-based Linux OS that has become an industry-standard tool for penetration testing and security audits. Kali includes hundreds of free tools for reverse engineering, penetration testing and more. Kali prioritizes simplicity, making security best practices more accessible to everyone from cybersecurity professionals to hobbyists.`,
logo_url: 'kalilinux.svg',
- name: 'Kali Linux',
related_guides: [
{
href:
@@ -1751,7 +1671,6 @@ export const oneClickApps: Record = {
description:
'Budibase is a modern, open source low-code platform for building modern business applications in minutes. Build, design and automate business apps, such as: admin panels, forms, internal tools, client portals and more. Before Budibase, it could take developers weeks to build simple CRUD apps; with Budibase, building CRUD apps takes minutes. When self-hosting please follow best practices for securing, updating and backing up your server.',
logo_url: 'budibase.svg',
- name: 'Budibase',
related_guides: [
{
href:
@@ -1774,7 +1693,6 @@ export const oneClickApps: Record = {
description:
'A simple and flexible scheduler and orchestrator to deploy and manage containers and non-containerized applications across on-prem and clouds at scale.',
logo_url: 'nomad.svg',
- name: 'HashiCorp Nomad',
related_guides: [
{
href:
@@ -1796,7 +1714,6 @@ export const oneClickApps: Record = {
description:
'HashiCorp Vault is an open source, centralized secrets management system. It provides a secure and reliable way of storing and distributing secrets like API keys, access tokens, and passwords.',
logo_url: 'vault.svg',
- name: 'HashiCorp Vault',
related_guides: [
{
href:
@@ -1817,7 +1734,6 @@ export const oneClickApps: Record = {
},
description: `Microweber is an easy Drag and Drop website builder and a powerful CMS of a new generation, based on the PHP Laravel Framework.`,
logo_url: 'microweber.svg',
- name: 'Microweber',
related_guides: [
{
href:
@@ -1838,7 +1754,6 @@ export const oneClickApps: Record = {
},
description: `PostgreSQL is a popular open source relational database system that provides many advanced configuration options that can help optimize your database’s performance in a production environment.`,
logo_url: 'postgresqlmarketplaceocc.svg',
- name: 'PostgreSQL Cluster',
related_guides: [
{
href:
@@ -1859,7 +1774,6 @@ export const oneClickApps: Record = {
},
description: `Galera provides a performant multi-master/active-active database solution with synchronous replication, to achieve high availability.`,
logo_url: 'galeramarketplaceocc.svg',
- name: 'Galera Cluster',
related_guides: [
{
href:
@@ -1880,7 +1794,6 @@ export const oneClickApps: Record = {
},
description: `Mastodon is an open-source and decentralized micro-blogging platform, supporting federation and public access to the server.`,
logo_url: 'mastodon.svg',
- name: 'Mastodon',
related_guides: [
{
href:
@@ -1903,7 +1816,6 @@ export const oneClickApps: Record = {
},
description: `Programmatically author, schedule, and monitor workflows with a Python-based tool. Airflow provides full insight into the status and logs of your tasks, all in a modern web application.`,
logo_url: 'apacheairflow.svg',
- name: 'Apache Airflow',
related_guides: [
{
href:
@@ -1925,7 +1837,6 @@ export const oneClickApps: Record = {
},
description: `Harden your web applications and APIs against OWASP Top 10 attacks. Haltdos makes it easy to manage WAF settings and review logs in an intuitive web-based GUI.`,
logo_url: 'haltdos.svg',
- name: 'HaltDOS Community WAF',
related_guides: [
{
href:
@@ -1947,7 +1858,6 @@ export const oneClickApps: Record = {
},
description: `Superinsight provides a simple SQL interface to store and search unstructured data. Superinsight is built on top of PostgreSQL to take advantage of powerful extensions and features, plus the ability to run machine learning operations using SQL statements.`,
logo_url: 'superinsight.svg',
- name: 'Superinsight',
related_guides: [
{
href:
@@ -1969,7 +1879,6 @@ export const oneClickApps: Record = {
},
description: `Provision multicloud clusters, containerize applications, and build DevOps pipelines. Gopaddle’s suite of templates and integrations helps eliminate manual errors and automate Kubernetes application releases.`,
logo_url: 'gopaddle.svg',
- name: 'Gopaddle',
related_guides: [
{
href:
@@ -1991,7 +1900,6 @@ export const oneClickApps: Record = {
},
description: `Self-host a password manager designed to simplify and secure your digital life. Passky is a streamlined version of paid password managers designed for everyone to use.`,
logo_url: 'passky.svg',
- name: 'Passky',
related_guides: [
{
href:
@@ -2012,7 +1920,6 @@ export const oneClickApps: Record = {
},
description: `Create and collaborate on text documents, spreadsheets, and presentations compatible with popular file types including .docx, .xlsx, and more. Additional features include real-time editing, paragraph locking while co-editing, and version history.`,
logo_url: 'onlyoffice.svg',
- name: 'ONLYOFFICE Docs',
related_guides: [
{
href:
@@ -2033,7 +1940,6 @@ export const oneClickApps: Record = {
},
description: `Redis® is an open-source, in-memory, data-structure store, with the optional ability to write and persist data to a disk, which can be used as a key-value database, cache, and message broker. Redis® features built-in transactions, replication, and support for a variety of data structures such as strings, hashes, lists, sets, and others.
*Redis is a registered trademark of Redis Ltd. Any rights therein are reserved to Redis Ltd. Any use by Akamai Technologies is for referential purposes only and does not indicate any sponsorship, endorsement or affiliation between Redis and Akamai Technologies.`,
logo_url: 'redissentinelmarketplaceocc.svg',
- name: 'Marketplace App for Redis® Sentinel Cluster',
related_guides: [
{
href:
@@ -2056,7 +1962,6 @@ export const oneClickApps: Record = {
},
description: `LAMP-stack-based server application that allows you to access your files from anywhere in a secure way.`,
logo_url: 'owncloud.svg',
- name: 'ownCloud',
related_guides: [
{
href:
@@ -2079,7 +1984,6 @@ export const oneClickApps: Record = {
},
description: `A self-hosted Firebase alternative for web, mobile & Flutter developers.`,
logo_url: 'appwrite.svg',
- name: 'Appwrite',
related_guides: [
{
href:
@@ -2102,7 +2006,6 @@ export const oneClickApps: Record = {
},
description: `Self-hosted database for a variety of management projects.`,
logo_url: 'seatable.svg',
- name: 'Seatable',
related_guides: [
{
href:
@@ -2126,7 +2029,6 @@ export const oneClickApps: Record = {
description:
'Illa Builder is a Retool open-source alternative, with low-code UI components for self-hosting the development of internal tools.',
logo_url: 'illabuilder.svg',
- name: 'Illa Builder',
related_guides: [
{
href:
@@ -2149,7 +2051,6 @@ export const oneClickApps: Record = {
description:
'A simple and flexible scheduler and orchestrator to deploy and manage containers and non-containerized applications across on-prem and clouds at scale.',
logo_url: 'nomad.svg',
- name: 'HashiCorp Nomad Cluster',
related_guides: [
{
href:
@@ -2172,7 +2073,6 @@ export const oneClickApps: Record = {
description:
'A simple deployment of multiple clients to horizontally scale an existing Nomad One-Click Cluster.',
logo_url: 'nomad.svg',
- name: 'HashiCorp Nomad Clients Cluster',
related_guides: [
{
href:
@@ -2194,7 +2094,6 @@ export const oneClickApps: Record = {
},
description: `MainConcept FFmpeg Plugins Demo is suited for both VOD and live production workflows, with advanced features such as Hybrid GPU acceleration and xHE-AAC audio format.`,
logo_url: 'mainconcept.svg',
- name: 'MainConcept FFmpeg Plugins Demo',
related_guides: [
{
href:
@@ -2217,7 +2116,6 @@ export const oneClickApps: Record = {
},
description: `MainConcept Live Encoder Demo is a powerful all-in-one encoding engine designed to simplify common broadcast and OTT video workflows.`,
logo_url: 'mainconcept.svg',
- name: 'MainConcept Live Encoder Demo',
related_guides: [
{
href:
@@ -2239,7 +2137,6 @@ export const oneClickApps: Record = {
},
description: `MainConcept P2 AVC ULTRA Transcoder Demo is an optimized Docker container for file-based transcoding of media files into professional Panasonic camera formats like P2 AVC-Intra, P2 AVC LongG and AVC-intra RP2027.v1 and AAC High Efficiency v2 formats into an MP4 container.`,
logo_url: 'mainconcept.svg',
- name: 'MainConcept P2 AVC ULTRA Transcoder Demo',
related_guides: [
{
href:
@@ -2262,7 +2159,6 @@ export const oneClickApps: Record = {
},
description: `MainConcept XAVC Transcoder Demo is an optimized Docker container for file-based transcoding of media files into professional Sony camera formats like XAVC-Intra, XAVC Long GOP and XAVC-S.`,
logo_url: 'mainconcept.svg',
- name: 'MainConcept XAVC Transcoder Demo',
related_guides: [
{
href:
@@ -2285,7 +2181,6 @@ export const oneClickApps: Record = {
},
description: `MainConcept XDCAM Transcoder Demo is an optimized Docker container for file-based transcoding of media files into professional Sony camera formats like XDCAM HD, XDCAM EX, XDCAM IMX and DVCAM (XDCAM DV).`,
logo_url: 'mainconcept.svg',
- name: 'MainConcept XDCAM Transcoder Demo',
related_guides: [
{
href:
@@ -2308,7 +2203,6 @@ export const oneClickApps: Record = {
},
description: `SimpleX Chat - The first messaging platform that has no user identifiers of any kind - 100% private by design. SMP server is the relay server used to pass messages in SimpleX network. XFTP is a new file transfer protocol focussed on meta-data protection. This One-Click APP will deploy both SMP and XFTP servers.`,
logo_url: 'simplexchat.svg',
- name: 'SimpleX Chat',
related_guides: [
{
href:
@@ -2330,7 +2224,6 @@ export const oneClickApps: Record = {
description:
'JupyterLab is a cutting-edge web-based, interactive development environment, geared towards data science, machine learning and other scientific computing workflows.',
logo_url: 'jupyter.svg',
- name: 'JupyterLab',
related_guides: [
{
href:
@@ -2352,7 +2245,6 @@ export const oneClickApps: Record = {
description:
'NATS is a distributed PubSub technology that enables applications to securely communicate across any combination of cloud vendors, on-premise, edge, web and mobile, and devices.',
logo_url: 'nats.svg',
- name: 'NATS Single Node',
related_guides: [
{
href:
@@ -2363,28 +2255,6 @@ export const oneClickApps: Record = {
summary: 'Cloud native application messaging service.',
website: 'https://nats.io',
},
- 1439640: {
- alt_description: 'Open Source Secrets Manager',
- alt_name: 'Passbolt CE',
- categories: ['Security'],
- colors: {
- end: 'D40101',
- start: '171717',
- },
- description: `Passbolt Community Edition is an open-source password manager designed for teams and businesses. It allows users to securely store, share and manage passwords.`,
- logo_url: 'passbolt.svg',
- name: 'Passbolt Community Edition',
- related_guides: [
- {
- href:
- 'https://www.linode.com/docs/products/tools/marketplace/guides/passbolt/',
- title:
- 'Deploy Passbolt Community Edition through the Linode Marketplace',
- },
- ],
- summary: 'Open-source password manager for teams and businesses.',
- website: 'https://www.passbolt.com/',
- },
1329462: {
alt_description:
'LinuxGSM is a command line utility that simplifies self-hosting multiplayer game servers.',
@@ -2396,7 +2266,6 @@ export const oneClickApps: Record = {
},
description: `Self hosted multiplayer game servers.`,
logo_url: 'linuxgsm.svg',
- name: 'LinuxGSM',
related_guides: [
{
href:
@@ -2418,7 +2287,6 @@ export const oneClickApps: Record = {
},
description: `Secure, stable, and free alternative to popular video conferencing services. This app deploys four networked Jitsi nodes.`,
logo_url: 'jitsi.svg',
- name: 'Jitsi Cluster',
related_guides: [
{
href:
@@ -2440,7 +2308,6 @@ export const oneClickApps: Record = {
description:
'GlusterFS is an open source, software scalable network filesystem. This app deploys three GlusterFS servers and three GlusterFS clients.',
logo_url: 'glusterfs.svg',
- name: 'GlusterFS Cluster',
related_guides: [
{
href:
@@ -2463,7 +2330,6 @@ export const oneClickApps: Record = {
description: `Distributed, masterless, replicating NoSQL database cluster.`,
isNew: true,
logo_url: 'apachecassandra.svg',
- name: 'Apache Cassandra Cluster',
related_guides: [
{
href:
@@ -2485,7 +2351,6 @@ export const oneClickApps: Record = {
},
description: `Couchbase Enterprise Server is a high-performance NoSQL database, built for scale. Couchbase Server is designed with memory-first architecture, built-in cache and workload isolation.`,
logo_url: 'couchbase.svg',
- name: 'Couchbase Cluster',
related_guides: [
{
href:
@@ -2507,7 +2372,6 @@ export const oneClickApps: Record = {
},
description: `Apache Kafka supports a wide range of applications from log aggregation to real-time analytics. Kafka provides a foundation for building data pipelines, event-driven architectures, or stream processing applications.`,
logo_url: 'apachekafka.svg',
- name: 'Apache Kafka Cluster',
related_guides: [
{
href:
@@ -2529,7 +2393,6 @@ export const oneClickApps: Record = {
description: `High performance, BSD license key/value database.`,
isNew: true,
logo_url: 'valkey.svg',
- name: 'Valkey',
related_guides: [
{
href:
@@ -2551,7 +2414,6 @@ export const oneClickApps: Record = {
description: `OSI approved open source secrets platform.`,
isNew: true,
logo_url: 'openbao.svg',
- name: 'OpenBao',
related_guides: [
{
href:
@@ -2573,7 +2435,6 @@ export const oneClickApps: Record = {
description: `Time series database supporting native query and visualization.`,
isNew: true,
logo_url: 'influxdb.svg',
- name: 'InfluxDB',
related_guides: [
{
href:
@@ -2596,7 +2457,6 @@ export const oneClickApps: Record = {
description: `Fast, open-source unified analytics engine for large-scale data processing.`,
isNew: true,
logo_url: 'apachespark.svg',
- name: 'Apache Spark Cluster',
related_guides: [
{
href:
@@ -2607,4 +2467,25 @@ export const oneClickApps: Record = {
summary: 'Unified analytics engine for big data processing.',
website: 'https://spark.apache.org/',
},
+ 1439640: {
+ alt_description: 'Open Source Secrets Manager',
+ alt_name: 'Passbolt CE',
+ categories: ['Security'],
+ colors: {
+ end: 'D40101',
+ start: '171717',
+ },
+ description: `Passbolt Community Edition is an open-source password manager designed for teams and businesses. It allows users to securely store, share and manage passwords.`,
+ logo_url: 'passbolt.svg',
+ related_guides: [
+ {
+ href:
+ 'https://www.linode.com/docs/products/tools/marketplace/guides/passbolt/',
+ title:
+ 'Deploy Passbolt Community Edition through the Linode Marketplace',
+ },
+ ],
+ summary: 'Open-source password manager for teams and businesses.',
+ website: 'https://www.passbolt.com/',
+ },
};
diff --git a/packages/manager/src/features/OneClickApps/types.ts b/packages/manager/src/features/OneClickApps/types.ts
index 6b57f977b8e..0cfdaa0bf8f 100644
--- a/packages/manager/src/features/OneClickApps/types.ts
+++ b/packages/manager/src/features/OneClickApps/types.ts
@@ -11,7 +11,6 @@ export interface OCA {
*/
isNew?: boolean;
logo_url: string;
- name: string;
related_guides?: Doc[];
summary: string;
tips?: string[];
From 072c371fd24e01508c0927b6039852e8bccfd91f Mon Sep 17 00:00:00 2001
From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com>
Date: Wed, 16 Oct 2024 18:18:38 -0400
Subject: [PATCH 26/64] fix: [M3-8752] - Region Multi Select spacing issues
(#11103)
* fix region multi select spacing
* add changeset
---------
Co-authored-by: Banks Nussman
---
.../pr-11103-fixed-1729099711508.md | 5 +++
packages/manager/src/components/Flag.tsx | 16 ++++++---
.../RegionSelect/RegionMultiSelect.tsx | 34 +++++--------------
.../components/RegionSelect/RegionOption.tsx | 33 ++++++++----------
.../RegionSelect/RegionSelect.styles.ts | 6 ----
.../components/RegionSelect/RegionSelect.tsx | 5 +--
.../AccessKeyRegions/SelectedRegionsList.tsx | 15 +++-----
7 files changed, 47 insertions(+), 67 deletions(-)
create mode 100644 packages/manager/.changeset/pr-11103-fixed-1729099711508.md
diff --git a/packages/manager/.changeset/pr-11103-fixed-1729099711508.md b/packages/manager/.changeset/pr-11103-fixed-1729099711508.md
new file mode 100644
index 00000000000..fc06956fdba
--- /dev/null
+++ b/packages/manager/.changeset/pr-11103-fixed-1729099711508.md
@@ -0,0 +1,5 @@
+---
+"@linode/manager": Fixed
+---
+
+Region Multi Select spacing issues ([#11103](https://github.com/linode/manager/pull/11103))
diff --git a/packages/manager/src/components/Flag.tsx b/packages/manager/src/components/Flag.tsx
index 84decd01815..bd01d381754 100644
--- a/packages/manager/src/components/Flag.tsx
+++ b/packages/manager/src/components/Flag.tsx
@@ -2,13 +2,16 @@ import { styled } from '@mui/material/styles';
import 'flag-icons/css/flag-icons.min.css';
import React from 'react';
+import { Box } from './Box';
+
+import type { BoxProps } from './Box';
import type { Country } from '@linode/api-v4';
const COUNTRY_FLAG_OVERRIDES = {
uk: 'gb',
};
-interface Props {
+interface Props extends BoxProps {
country: Country;
}
@@ -16,9 +19,14 @@ interface Props {
* Flag icons are provided by the [flag-icons](https://www.npmjs.com/package/flag-icon) package
*/
export const Flag = (props: Props) => {
- const country = props.country.toLowerCase();
+ const { country, ...rest } = props;
- return ;
+ return (
+
+ );
};
const getFlagClass = (country: Country | string) => {
@@ -30,7 +38,7 @@ const getFlagClass = (country: Country | string) => {
return country;
};
-const StyledFlag = styled('div', { label: 'StyledFlag' })(({ theme }) => ({
+const StyledFlag = styled(Box, { label: 'StyledFlag' })(({ theme }) => ({
boxShadow:
theme.palette.mode === 'light' ? `0px 0px 0px 1px #00000010` : undefined,
fontSize: '1.5rem',
diff --git a/packages/manager/src/components/RegionSelect/RegionMultiSelect.tsx b/packages/manager/src/components/RegionSelect/RegionMultiSelect.tsx
index ea4fd248fd0..3239fe19231 100644
--- a/packages/manager/src/components/RegionSelect/RegionMultiSelect.tsx
+++ b/packages/manager/src/components/RegionSelect/RegionMultiSelect.tsx
@@ -2,18 +2,15 @@ import CloseIcon from '@mui/icons-material/Close';
import React from 'react';
import { Autocomplete } from 'src/components/Autocomplete/Autocomplete';
-import { Box } from 'src/components/Box';
import { Chip } from 'src/components/Chip';
import { Flag } from 'src/components/Flag';
import { useAllAccountAvailabilitiesQuery } from 'src/queries/account/availability';
import { getRegionCountryGroup } from 'src/utilities/formatRegion';
import { StyledListItem } from '../Autocomplete/Autocomplete.styles';
+import { Stack } from '../Stack';
import { RegionOption } from './RegionOption';
-import {
- StyledAutocompleteContainer,
- StyledFlagContainer,
-} from './RegionSelect.styles';
+import { StyledAutocompleteContainer } from './RegionSelect.styles';
import {
getRegionOptions,
isRegionOptionUnavailable,
@@ -25,29 +22,16 @@ import type {
} from './RegionSelect.types';
import type { Region } from '@linode/api-v4';
-interface LabelComponentProps {
+interface RegionChipLabelProps {
region: Region;
}
-const SelectedRegion = ({ region }: LabelComponentProps) => {
+const RegionChipLabel = ({ region }: RegionChipLabelProps) => {
return (
-
- ({
- marginRight: theme.spacing(1 / 2),
- transform: 'scale(0.8)',
- })}
- >
-
-
+
+
{region.label} ({region.id})
-
+
);
};
@@ -56,6 +40,7 @@ export const RegionMultiSelect = React.memo((props: RegionMultiSelectProps) => {
SelectedRegionsList,
currentCapability,
disabled,
+ disabledRegions: disabledRegionsFromProps,
errorText,
helperText,
isClearable,
@@ -67,7 +52,6 @@ export const RegionMultiSelect = React.memo((props: RegionMultiSelectProps) => {
selectedIds,
sortRegionOptions,
width,
- disabledRegions: disabledRegionsFromProps,
...rest
} = props;
@@ -145,7 +129,7 @@ export const RegionMultiSelect = React.memo((props: RegionMultiSelectProps) => {
data-testid={option.id}
deleteIcon={}
key={index}
- label={}
+ label={}
onDelete={() => handleRemoveOption(option.id)}
/>
));
diff --git a/packages/manager/src/components/RegionSelect/RegionOption.tsx b/packages/manager/src/components/RegionSelect/RegionOption.tsx
index 407ce7f9306..14f4fdc66a8 100644
--- a/packages/manager/src/components/RegionSelect/RegionOption.tsx
+++ b/packages/manager/src/components/RegionSelect/RegionOption.tsx
@@ -5,12 +5,12 @@ import DistributedRegion from 'src/assets/icons/entityIcons/distributed-region.s
import { Box } from 'src/components/Box';
import { Flag } from 'src/components/Flag';
import { useIsGeckoEnabled } from 'src/components/RegionSelect/RegionSelect.utils';
+import { Stack } from 'src/components/Stack';
import { Tooltip } from 'src/components/Tooltip';
import { TooltipIcon } from 'src/components/TooltipIcon';
import {
SelectedIcon,
- StyledFlagContainer,
StyledListItem,
sxDistributedRegionIcon,
} from './RegionSelect.styles';
@@ -70,24 +70,21 @@ export const RegionOption = ({
isRegionDisabled ? e.preventDefault() : onClick ? onClick(e) : null
}
aria-disabled={undefined}
- data-qa-disabled-item={isRegionDisabled}
className={isRegionDisabled ? `${className} Mui-disabled` : className}
+ data-qa-disabled-item={isRegionDisabled}
>
- <>
-
-
-
-
- {isGeckoLAEnabled ? region.label : `${region.label} (${region.id})`}
- {displayDistributedRegionIcon && (
-
- (This region is a distributed region.)
-
- )}
- {isRegionDisabled && isRegionDisabledReason && (
- {isRegionDisabledReason}
- )}
-
+
+
+ {isGeckoLAEnabled ? region.label : `${region.label} (${region.id})`}
+ {displayDistributedRegionIcon && (
+
+ (This region is a distributed region.)
+
+ )}
+ {isRegionDisabled && isRegionDisabledReason && (
+ {isRegionDisabledReason}
+ )}
+
{isGeckoLAEnabled && `(${region.id})`}
{selected && }
{displayDistributedRegionIcon && (
@@ -98,7 +95,7 @@ export const RegionOption = ({
text="This region is a distributed region."
/>
)}
- >
+
);
diff --git a/packages/manager/src/components/RegionSelect/RegionSelect.styles.ts b/packages/manager/src/components/RegionSelect/RegionSelect.styles.ts
index 088908408c6..8ed59451213 100644
--- a/packages/manager/src/components/RegionSelect/RegionSelect.styles.ts
+++ b/packages/manager/src/components/RegionSelect/RegionSelect.styles.ts
@@ -68,12 +68,6 @@ export const StyledDistributedRegionBox = styled(Box, {
},
}));
-export const StyledFlagContainer = styled('div', {
- label: 'RegionSelectFlagContainer',
-})(({ theme }) => ({
- marginRight: theme.spacing(1),
-}));
-
export const StyledLParentListItem = styled(ListItem, {
label: 'RegionSelectParentListItem',
})(() => ({
diff --git a/packages/manager/src/components/RegionSelect/RegionSelect.tsx b/packages/manager/src/components/RegionSelect/RegionSelect.tsx
index cb126df244a..5d67de51d21 100644
--- a/packages/manager/src/components/RegionSelect/RegionSelect.tsx
+++ b/packages/manager/src/components/RegionSelect/RegionSelect.tsx
@@ -15,7 +15,6 @@ import { RegionOption } from './RegionOption';
import {
StyledAutocompleteContainer,
StyledDistributedRegionBox,
- StyledFlagContainer,
sxDistributedRegionIcon,
} from './RegionSelect.styles';
import {
@@ -155,9 +154,7 @@ export const RegionSelect = <
endAdornment: EndAdornment,
required,
startAdornment: selectedRegion && (
-
-
-
+
),
},
tooltipText,
diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyRegions/SelectedRegionsList.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyRegions/SelectedRegionsList.tsx
index 50ff7ccd614..571139055ea 100644
--- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyRegions/SelectedRegionsList.tsx
+++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyRegions/SelectedRegionsList.tsx
@@ -2,13 +2,10 @@ import * as React from 'react';
import { Box } from 'src/components/Box';
import { Flag } from 'src/components/Flag';
-import { StyledFlagContainer } from 'src/components/RegionSelect/RegionSelect.styles';
-import {
- RemovableItem,
- RemovableSelectionsList,
-} from 'src/components/RemovableSelectionsList/RemovableSelectionsList';
+import { RemovableSelectionsList } from 'src/components/RemovableSelectionsList/RemovableSelectionsList';
import type { Region } from '@linode/api-v4';
+import type { RemovableItem } from 'src/components/RemovableSelectionsList/RemovableSelectionsList';
interface SelectedRegionsProps {
onRemove: (region: string) => void;
@@ -25,12 +22,10 @@ const SelectedRegion = ({ selection }: LabelComponentProps) => {
sx={{
alignItems: 'center',
display: 'flex',
- flexGrow: 1,
+ gap: 1,
}}
>
-
-
-
+
{selection.label} ({selection.id})
);
@@ -46,11 +41,11 @@ export const SelectedRegionsList = ({
return (
);
};
From 8ce11fe653bfa69e1711815e8c19b1b53d2a7e5f Mon Sep 17 00:00:00 2001
From: santoshp210-akamai
<159890961+santoshp210-akamai@users.noreply.github.com>
Date: Thu, 17 Oct 2024 17:54:00 +0530
Subject: [PATCH 27/64] upcoming: [DI-21270] - Added the Alerts tab (#11064)
* upcoming: [DI-21270] - Added the Alerts tab
* Upcoming: [DI-21270] - Addressed the review comments
* Upcoming : [DI:21270] - Added the custom type for the Tab with isEnabled property and memoized the filtering of enabled flags
* Upcoming: [DI:21270] - Improved the names for clarity
---
packages/manager/src/featureFlags.ts | 6 +
.../AlertsLanding/AlertsDefinitionLanding.tsx | 25 ++++
.../Alerts/AlertsLanding/AlertsLanding.tsx | 75 ++++++++++++
.../features/CloudPulse/CloudPulseTabs.tsx | 115 +++++++++++-------
4 files changed, 178 insertions(+), 43 deletions(-)
create mode 100644 packages/manager/src/features/CloudPulse/Alerts/AlertsLanding/AlertsDefinitionLanding.tsx
create mode 100644 packages/manager/src/features/CloudPulse/Alerts/AlertsLanding/AlertsLanding.tsx
diff --git a/packages/manager/src/featureFlags.ts b/packages/manager/src/featureFlags.ts
index 444dadd7e16..41ba5d6516f 100644
--- a/packages/manager/src/featureFlags.ts
+++ b/packages/manager/src/featureFlags.ts
@@ -81,8 +81,14 @@ interface DesignUpdatesBannerFlag extends BaseFeatureFlag {
link: string;
}
+interface AclpAlerting {
+ alertDefinitions: boolean;
+ notificationChannels: boolean;
+ recentActivity: boolean;
+}
export interface Flags {
aclp: AclpFlag;
+ aclpAlerting: AclpAlerting;
aclpReadEndpoint: string;
aclpResourceTypeMap: CloudPulseResourceTypeMapFlag[];
apiMaintenance: APIMaintenance;
diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsLanding/AlertsDefinitionLanding.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsLanding/AlertsDefinitionLanding.tsx
new file mode 100644
index 00000000000..381eb9cf31f
--- /dev/null
+++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsLanding/AlertsDefinitionLanding.tsx
@@ -0,0 +1,25 @@
+import * as React from 'react';
+import { Route, Switch } from 'react-router-dom';
+
+import { Paper } from 'src/components/Paper';
+import { Typography } from 'src/components/Typography';
+
+export const AlertDefinitionLanding = () => {
+ return (
+
+
+
+ );
+};
+
+const AlertDefinition = () => {
+ return (
+
+ Alert Definition
+
+ );
+};
diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsLanding/AlertsLanding.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsLanding/AlertsLanding.tsx
new file mode 100644
index 00000000000..72a1f01c157
--- /dev/null
+++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsLanding/AlertsLanding.tsx
@@ -0,0 +1,75 @@
+import * as React from 'react';
+import {
+ Redirect,
+ Route,
+ Switch,
+ useLocation,
+ useRouteMatch,
+} from 'react-router-dom';
+
+import { Box } from 'src/components/Box';
+import { Paper } from 'src/components/Paper';
+import { TabLinkList } from 'src/components/Tabs/TabLinkList';
+import { Tabs } from 'src/components/Tabs/Tabs';
+import { useFlags } from 'src/hooks/useFlags';
+
+import { AlertDefinitionLanding } from './AlertsDefinitionLanding';
+
+import type { EnabledAlertTab } from '../../CloudPulseTabs';
+
+export const AlertsLanding = React.memo(() => {
+ const flags = useFlags();
+ const { url } = useRouteMatch();
+ const { pathname } = useLocation();
+ const alertTabs = React.useMemo(
+ () => [
+ {
+ isEnabled: Boolean(flags.aclpAlerting?.alertDefinitions),
+ tab: { routeName: `${url}/definitions`, title: 'Definitions' },
+ },
+ ],
+ [url, flags.aclpAlerting]
+ );
+ const accessibleTabs = React.useMemo(
+ () =>
+ alertTabs
+ .filter((alertTab) => alertTab.isEnabled)
+ .map((alertTab) => alertTab.tab),
+ [alertTabs]
+ );
+ const activeTabIndex = React.useMemo(
+ () =>
+ Math.max(
+ accessibleTabs.findIndex((tab) => pathname.startsWith(tab.routeName)),
+ 0
+ ),
+ [accessibleTabs, pathname]
+ );
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ );
+});
diff --git a/packages/manager/src/features/CloudPulse/CloudPulseTabs.tsx b/packages/manager/src/features/CloudPulse/CloudPulseTabs.tsx
index ddcb223c17f..418bee1322c 100644
--- a/packages/manager/src/features/CloudPulse/CloudPulseTabs.tsx
+++ b/packages/manager/src/features/CloudPulse/CloudPulseTabs.tsx
@@ -1,57 +1,86 @@
-import { styled } from '@mui/material/styles';
import * as React from 'react';
-import { matchPath } from 'react-router-dom';
+import {
+ Redirect,
+ Route,
+ Switch,
+ useLocation,
+ useRouteMatch,
+} from 'react-router-dom';
import { SuspenseLoader } from 'src/components/SuspenseLoader';
-import { SafeTabPanel } from 'src/components/Tabs/SafeTabPanel';
import { TabLinkList } from 'src/components/Tabs/TabLinkList';
-import { TabPanels } from 'src/components/Tabs/TabPanels';
import { Tabs } from 'src/components/Tabs/Tabs';
+import { useFlags } from 'src/hooks/useFlags';
+import { AlertsLanding } from './Alerts/AlertsLanding/AlertsLanding';
import { CloudPulseDashboardLanding } from './Dashboard/CloudPulseDashboardLanding';
-import type { RouteComponentProps } from 'react-router-dom';
-type Props = RouteComponentProps<{}>;
+import type { Tab } from 'src/components/Tabs/TabLinkList';
-export const CloudPulseTabs = React.memo((props: Props) => {
- const tabs = [
- {
- routeName: `${props.match.url}/dashboards`,
- title: 'Dashboards',
- },
- ];
-
- const matches = (p: string) => {
- return Boolean(matchPath(p, { path: props.location.pathname }));
- };
-
- const navToURL = (index: number) => {
- props.history.push(tabs[index].routeName);
- };
-
- return (
- matches(tab.routeName)),
+export type EnabledAlertTab = {
+ isEnabled: boolean;
+ tab: Tab;
+};
+export const CloudPulseTabs = () => {
+ const flags = useFlags();
+ const { url } = useRouteMatch();
+ const { pathname } = useLocation();
+ const alertTabs = React.useMemo(
+ () => [
+ {
+ isEnabled: true,
+ tab: {
+ routeName: `${url}/dashboards`,
+ title: 'Dashboards',
+ },
+ },
+ {
+ isEnabled: Boolean(
+ flags.aclpAlerting?.alertDefinitions ||
+ flags.aclpAlerting?.recentActivity ||
+ flags.aclpAlerting?.notificationChannels
+ ),
+ tab: {
+ routeName: `${url}/alerts`,
+ title: 'Alerts',
+ },
+ },
+ ],
+ [url, flags.aclpAlerting]
+ );
+ const accessibleTabs = React.useMemo(
+ () =>
+ alertTabs
+ .filter((alertTab) => alertTab.isEnabled)
+ .map((alertTab) => alertTab.tab),
+ [alertTabs]
+ );
+ const activeTabIndex = React.useMemo(
+ () =>
+ Math.max(
+ accessibleTabs.findIndex((tab) => pathname.startsWith(tab.routeName)),
0
- )}
- onChange={navToURL}
- >
-
+ ),
+ [accessibleTabs, pathname]
+ );
+ return (
+
+ }>
-
-
-
-
-
+
+
+
+
+
-
+
);
-});
-
-const StyledTabs = styled(Tabs, {
- label: 'StyledTabs',
-})(() => ({
- marginTop: 0,
-}));
+};
From e91cafe560ff73f4fac751468b133e120882d44b Mon Sep 17 00:00:00 2001
From: hasyed-akamai
Date: Thu, 17 Oct 2024 18:08:51 +0530
Subject: [PATCH 28/64] fix: [M3-8408] - Change of heading from "Invoice" to
"Tax Invoice" for UAE customers (#11097)
* fix: [M3-8408] - Change of heading from "Invoice" to "Tax Invoice" for UAE customers
* Added changeset: Change of Heading from Invoice to Tax Invoice for UAE Customers
* Change Changeset Description
Co-authored-by: Purvesh Makode
* Change Changeset Type
Co-authored-by: Purvesh Makode
---------
Co-authored-by: Purvesh Makode
---
packages/manager/.changeset/pr-11097-fixed-1728926799806.md | 5 +++++
.../src/features/Billing/PdfGenerator/PdfGenerator.ts | 5 ++++-
2 files changed, 9 insertions(+), 1 deletion(-)
create mode 100644 packages/manager/.changeset/pr-11097-fixed-1728926799806.md
diff --git a/packages/manager/.changeset/pr-11097-fixed-1728926799806.md b/packages/manager/.changeset/pr-11097-fixed-1728926799806.md
new file mode 100644
index 00000000000..93400dceb54
--- /dev/null
+++ b/packages/manager/.changeset/pr-11097-fixed-1728926799806.md
@@ -0,0 +1,5 @@
+---
+"@linode/manager": Changed
+---
+
+Invoice heading from 'Invoice' to 'Tax Invoice' for UAE Customers ([#11097](https://github.com/linode/manager/pull/11097))
diff --git a/packages/manager/src/features/Billing/PdfGenerator/PdfGenerator.ts b/packages/manager/src/features/Billing/PdfGenerator/PdfGenerator.ts
index 1acc134b281..823b68a9835 100644
--- a/packages/manager/src/features/Billing/PdfGenerator/PdfGenerator.ts
+++ b/packages/manager/src/features/Billing/PdfGenerator/PdfGenerator.ts
@@ -301,7 +301,10 @@ export const printInvoice = async (
doc,
Math.max(leftHeaderYPosition, rightHeaderYPosition) + 12,
{
- text: `Invoice: #${invoiceId}`,
+ text:
+ account.country === 'AE'
+ ? `Tax Invoice: #${invoiceId}`
+ : `Invoice: #${invoiceId}`,
}
);
From bc9d4245a8fe1382a1a2fa7f629c6540fd171d91 Mon Sep 17 00:00:00 2001
From: hasyed-akamai
Date: Thu, 17 Oct 2024 18:10:37 +0530
Subject: [PATCH 29/64] feat: [M3-8703] - Disable VPC Action Buttons for
Restricted Users when they do not have access or have read-only access.
(#11083)
* feat: [M3-8703] - Disable VPC Action Buttons for Restricted Users with None And ReadOnly Permission.
* Added changeset: Disable VPC Action buttons when do not have access or have read-only access.
* Add new Check in the useIsResourceRestricted logic
---
packages/manager/.changeset/pr-11083-fixed-1728564903243.md | 5 +++++
packages/manager/src/hooks/useIsResourceRestricted.ts | 6 ++++--
2 files changed, 9 insertions(+), 2 deletions(-)
create mode 100644 packages/manager/.changeset/pr-11083-fixed-1728564903243.md
diff --git a/packages/manager/.changeset/pr-11083-fixed-1728564903243.md b/packages/manager/.changeset/pr-11083-fixed-1728564903243.md
new file mode 100644
index 00000000000..f99dd4694f6
--- /dev/null
+++ b/packages/manager/.changeset/pr-11083-fixed-1728564903243.md
@@ -0,0 +1,5 @@
+---
+"@linode/manager": Fixed
+---
+
+Disable VPC Action buttons when do not have access or have read-only access. ([#11083](https://github.com/linode/manager/pull/11083))
diff --git a/packages/manager/src/hooks/useIsResourceRestricted.ts b/packages/manager/src/hooks/useIsResourceRestricted.ts
index 997fcf6afb2..6caa3f97d75 100644
--- a/packages/manager/src/hooks/useIsResourceRestricted.ts
+++ b/packages/manager/src/hooks/useIsResourceRestricted.ts
@@ -19,7 +19,9 @@ export const useIsResourceRestricted = ({
if (!grants) {
return false;
}
- return grants[grantType].some(
- (grant) => grant.id === id && grant.permissions === grantLevel
+ return (
+ grants[grantType].some(
+ (grant) => grant.id === id && grant.permissions === grantLevel
+ ) || !grants[grantType].some((grant) => grant.id === id)
);
};
From d442759507480ca212a0dc1aba3ab2b3c46ad4be Mon Sep 17 00:00:00 2001
From: cpathipa <119517080+cpathipa@users.noreply.github.com>
Date: Thu, 17 Oct 2024 09:06:13 -0500
Subject: [PATCH 30/64] fix: [M3-7197] - "Support Ticket" button in network tab
not working properly (#11074)
* unit test coverage for HostNameTableCell
* Revert "unit test coverage for HostNameTableCell"
This reverts commit b274baf67e27d79fd4e764607ded7c5aa755ee8b.
* chore: [M3-8662] - Update Github Actions actions (#11009)
* update actions
* add changeset
---------
Co-authored-by: Banks Nussman
* fix: [M3-7197] - "Support Ticket" button in network tab not working properly
* Create pr-11074-fixed-1728476792585.md
---------
Co-authored-by: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com>
Co-authored-by: Banks Nussman
---
.../pr-11074-fixed-1728476792585.md | 5 ++
.../LinodeNetworking/AddIPDrawer.tsx | 37 ++++--------
.../LinodeNetworking/ExplainerCopy.test.tsx | 60 +++++++++++++++++++
.../LinodeNetworking/ExplainerCopy.tsx | 43 +++++++++++++
4 files changed, 119 insertions(+), 26 deletions(-)
create mode 100644 packages/manager/.changeset/pr-11074-fixed-1728476792585.md
create mode 100644 packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/ExplainerCopy.test.tsx
create mode 100644 packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/ExplainerCopy.tsx
diff --git a/packages/manager/.changeset/pr-11074-fixed-1728476792585.md b/packages/manager/.changeset/pr-11074-fixed-1728476792585.md
new file mode 100644
index 00000000000..5fd48a4c63f
--- /dev/null
+++ b/packages/manager/.changeset/pr-11074-fixed-1728476792585.md
@@ -0,0 +1,5 @@
+---
+"@linode/manager": Fixed
+---
+
+"Support Ticket" button in network tab not working properly ([#11074](https://github.com/linode/manager/pull/11074))
diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/AddIPDrawer.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/AddIPDrawer.tsx
index c03017df1d7..57642f09997 100644
--- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/AddIPDrawer.tsx
+++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/AddIPDrawer.tsx
@@ -19,10 +19,12 @@ import {
} from 'src/queries/linodes/networking';
import { useCreateIPv6RangeMutation } from 'src/queries/networking/networking';
+import { ExplainerCopy } from './ExplainerCopy';
+
import type { IPv6Prefix } from '@linode/api-v4/lib/networking';
import type { Item } from 'src/components/EnhancedSelect/Select';
-type IPType = 'v4Private' | 'v4Public';
+export type IPType = 'v4Private' | 'v4Public';
const ipOptions: Item[] = [
{ label: 'Public', value: 'v4Public' },
@@ -34,27 +36,6 @@ const prefixOptions = [
{ label: '/56', value: '56' },
];
-// @todo: Pre-fill support tickets.
-const explainerCopy: Record = {
- v4Private: (
- <>
- Add a private IP address to your Linode. Data sent explicitly to and from
- private IP addresses in the same data center does not incur transfer quota
- usage. To ensure that the private IP is properly configured once added,
- it’s best to reboot your Linode.
- >
- ),
- v4Public: (
- <>
- Public IP addresses, over and above the one included with each Linode,
- incur an additional monthly charge. If you need an additional Public IP
- Address you must request one. Please open a{' '}
- Support Ticket if you have not done so
- already.
- >
- ),
-};
-
const IPv6ExplanatoryCopy = {
56: (
<>
@@ -70,7 +51,7 @@ const IPv6ExplanatoryCopy = {
),
};
-const tooltipCopy: Record = {
+const tooltipCopy: Record = {
v4Private: 'This Linode already has a private IP address.',
v4Public: null,
};
@@ -197,11 +178,11 @@ export const AddIPDrawer = (props: Props) => {
{ipOptions.map((option, idx) => (
}
- data-qa-radio={option.label}
disabled={
option.value === 'v4Private' && linodeIsInDistributedRegion
}
+ control={}
+ data-qa-radio={option.label}
key={idx}
label={option.label}
value={option.value}
@@ -209,7 +190,11 @@ export const AddIPDrawer = (props: Props) => {
))}
- {selectedIPv4 && {explainerCopy[selectedIPv4]}}
+ {selectedIPv4 && (
+
+
+
+ )}
{_tooltipCopy ? (
diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/ExplainerCopy.test.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/ExplainerCopy.test.tsx
new file mode 100644
index 00000000000..5281267a1f9
--- /dev/null
+++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/ExplainerCopy.test.tsx
@@ -0,0 +1,60 @@
+import { screen } from '@testing-library/react';
+import * as React from 'react';
+import { vi } from 'vitest';
+
+import { renderWithTheme } from 'src/utilities/testHelpers';
+
+import { ExplainerCopy } from './ExplainerCopy';
+
+const queryMocks = vi.hoisted(() => ({
+ useLinodeQuery: vi.fn().mockReturnValue({ data: undefined }),
+}));
+
+vi.mock('src/queries/linodes/linodes', async () => {
+ const actual = await vi.importActual('src/queries/linodes/linodes');
+ return {
+ ...actual,
+ useLinodeQuery: queryMocks.useLinodeQuery,
+ };
+});
+
+describe('ExplainerCopy Component', () => {
+ const linodeId = 1234;
+
+ beforeEach(() => {
+ queryMocks.useLinodeQuery.mockReturnValue({
+ data: { label: 'Test Linode' },
+ });
+ });
+
+ afterEach(() => {
+ vi.resetAllMocks();
+ });
+
+ it('renders the correct content for v4Private IPType', () => {
+ renderWithTheme();
+
+ expect(
+ screen.getByText(/Add a private IP address to your Linode/i)
+ ).toBeVisible();
+ expect(
+ screen.getByText(/Data sent explicitly to and from private IP addresses/i)
+ ).toBeVisible();
+ });
+
+ it('renders the correct content for v4Public IPType with SupportLink', () => {
+ renderWithTheme();
+
+ expect(
+ screen.getByText(/Public IP addresses, over and above the one included/i)
+ ).toBeVisible();
+ expect(screen.getByRole('link', { name: 'Support Ticket' })).toBeVisible();
+ });
+
+ it('displays no content when an unknown IPType is provided', () => {
+ renderWithTheme();
+
+ expect(screen.queryByText(/Add a private IP address/i)).toBeNull();
+ expect(screen.queryByText(/Support Ticket/)).toBeNull();
+ });
+});
diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/ExplainerCopy.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/ExplainerCopy.tsx
new file mode 100644
index 00000000000..7328db2ddbe
--- /dev/null
+++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/ExplainerCopy.tsx
@@ -0,0 +1,43 @@
+import * as React from 'react';
+
+import { SupportLink } from 'src/components/SupportLink';
+import { useLinodeQuery } from 'src/queries/linodes/linodes';
+
+import type { IPType } from './AddIPDrawer';
+
+interface ExplainerCopyProps {
+ ipType: IPType;
+ linodeId: number;
+}
+
+export const ExplainerCopy = ({ ipType, linodeId }: ExplainerCopyProps) => {
+ const { data: linode } = useLinodeQuery(linodeId);
+
+ switch (ipType) {
+ case 'v4Private':
+ return (
+ <>
+ Add a private IP address to your Linode. Data sent explicitly to and
+ from private IP addresses in the same data center does not incur
+ transfer quota usage. To ensure that the private IP is properly
+ configured once added, it’s best to reboot your Linode.
+ >
+ );
+ case 'v4Public':
+ return (
+ <>
+ Public IP addresses, over and above the one included with each Linode,
+ incur an additional monthly charge. If you need an additional Public
+ IP Address you must request one. Please open a{' '}
+ {' '}
+ if you have not done so already.
+ >
+ );
+ default:
+ return null;
+ }
+};
From b574f5ef8e4e2a79220d75fe5f04779ba54d4aef Mon Sep 17 00:00:00 2001
From: corya-akamai <136115382+corya-akamai@users.noreply.github.com>
Date: Thu, 17 Oct 2024 10:41:45 -0400
Subject: [PATCH 31/64] fix: [UIE-8181] - DBaaS enable restricted beta users
(#11114)
---
packages/manager/src/featureFlags.ts | 1 +
.../DatabaseBackups/DatabaseBackups.tsx | 8 +-
.../DatabaseResize/DatabaseResize.test.tsx | 4 +-
.../DatabaseResize/DatabaseResize.tsx | 45 ++--
.../DatabaseLanding/DatabaseEmptyState.tsx | 9 +-
.../DatabaseLanding/DatabaseLanding.tsx | 14 +-
.../DatabaseLandingEmptyStateData.tsx | 4 -
.../DatabaseLanding/DatabaseLandingTable.tsx | 4 +-
.../DatabaseLanding/DatabaseLogo.tsx | 6 +-
.../Databases/DatabaseLanding/DatabaseRow.tsx | 4 +-
.../src/features/Databases/utilities.test.ts | 235 +++++++++++++++---
.../src/features/Databases/utilities.ts | 97 +++++---
.../src/queries/databases/databases.ts | 6 +-
13 files changed, 307 insertions(+), 130 deletions(-)
diff --git a/packages/manager/src/featureFlags.ts b/packages/manager/src/featureFlags.ts
index 41ba5d6516f..a157f0c3dc2 100644
--- a/packages/manager/src/featureFlags.ts
+++ b/packages/manager/src/featureFlags.ts
@@ -101,6 +101,7 @@ export interface Flags {
databaseResize: boolean;
databases: boolean;
dbaasV2: BetaFeatureFlag;
+ dbaasV2MonitorMetrics: BetaFeatureFlag;
disableLargestGbPlans: boolean;
disallowImageUploadToNonObjRegions: boolean;
gecko2: GeckoFeatureFlag;
diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.tsx
index 513d881ddf7..c150abd0bfa 100644
--- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.tsx
+++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.tsx
@@ -78,7 +78,7 @@ export const DatabaseBackups = (props: Props) => {
databaseId: string;
engine: Engine;
}>();
- const { isV2GAUser } = useIsDatabasesEnabled();
+ const { isDatabasesV2GA } = useIsDatabasesEnabled();
const [isRestoreDialogOpen, setIsRestoreDialogOpen] = React.useState(false);
const [selectedDate, setSelectedDate] = React.useState(null);
@@ -86,7 +86,7 @@ export const DatabaseBackups = (props: Props) => {
null
);
const [versionOption, setVersionOption] = React.useState(
- isV2GAUser ? 'newest' : 'dateTime'
+ isDatabasesV2GA ? 'newest' : 'dateTime'
);
const {
@@ -143,7 +143,7 @@ export const DatabaseBackups = (props: Props) => {
Restore a Backup
- {isV2GAUser ? (
+ {isDatabasesV2GA ? (
The newest full backup plus incremental is selected by default. Or,
select any date and time within the last 10 days you want to create
@@ -159,7 +159,7 @@ export const DatabaseBackups = (props: Props) => {
{unableToRestoreCopy && (
)}
- {isV2GAUser && (
+ {isDatabasesV2GA && (
{
});
});
- describe('on rendering of page and isDatabasesGAEnabled is true and the Shared CPU tab is preselected ', () => {
+ describe('on rendering of page and isDatabasesV2GA is true and the Shared CPU tab is preselected ', () => {
beforeEach(() => {
// Mock database types
const standardTypes = [
@@ -369,7 +369,7 @@ describe('database resize', () => {
});
});
- describe('on rendering of page and isDatabasesGAEnabled is true and the Dedicated CPU tab is preselected', () => {
+ describe('on rendering of page and isDatabasesV2GA is true and the Dedicated CPU tab is preselected', () => {
beforeEach(() => {
// Mock database types
const mockDedicatedTypes = [
diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResize.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResize.tsx
index ffe15698e5a..fbdc01d70cf 100644
--- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResize.tsx
+++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResize.tsx
@@ -97,10 +97,7 @@ export const DatabaseResize = ({ database, disabled = false }: Props) => {
] = React.useState(false);
const [selectedTab, setSelectedTab] = React.useState(0);
- const {
- isDatabasesV2Enabled,
- isDatabasesGAEnabled,
- } = useIsDatabasesEnabled();
+ const { isDatabasesV2Enabled, isDatabasesV2GA } = useIsDatabasesEnabled();
const [clusterSize, setClusterSize] = React.useState(
database.cluster_size
);
@@ -122,11 +119,7 @@ export const DatabaseResize = ({ database, disabled = false }: Props) => {
const onResize = () => {
const payload: UpdateDatabasePayload = {};
- if (
- clusterSize &&
- clusterSize > database.cluster_size &&
- isDatabasesGAEnabled
- ) {
+ if (clusterSize && clusterSize > database.cluster_size && isDatabasesV2GA) {
payload.cluster_size = clusterSize;
}
@@ -163,30 +156,26 @@ export const DatabaseResize = ({ database, disabled = false }: Props) => {
{summaryText ? (
<>
- {isDatabasesGAEnabled
+ {isDatabasesV2GA
? 'Resized Cluster: ' + summaryText.plan
: summaryText.plan}
{' '}
- {isDatabasesGAEnabled ? (
+ {isDatabasesV2GA ? (
{summaryText.basePrice}
) : null}
-
+
{' '}
{summaryText.numberOfNodes} Node
{summaryText.numberOfNodes > 1 ? 's' : ''}
- {!isDatabasesGAEnabled ? ': ' : ' - HA '}
+ {!isDatabasesV2GA ? ': ' : ' - HA '}
{summaryText.price}
>
- ) : isDatabasesGAEnabled ? (
+ ) : isDatabasesV2GA ? (
<>
Resized Cluster:{' '}
Please select a plan or set the number of nodes.
@@ -263,7 +252,7 @@ export const DatabaseResize = ({ database, disabled = false }: Props) => {
setSummaryText({
numberOfNodes: clusterSize,
plan: formatStorageUnits(selectedPlanType.label),
- price: isDatabasesGAEnabled
+ price: isDatabasesV2GA
? `$${price?.monthly}/month`
: `$${price?.monthly}/month or $${price?.hourly}/hour`,
basePrice: currentPlanPrice,
@@ -287,7 +276,7 @@ export const DatabaseResize = ({ database, disabled = false }: Props) => {
}
const engineType = database.engine.split('/')[0] as Engine;
// When only a higher node selection is made and plan has not been changed
- if (isDatabasesGAEnabled && nodeSelected && isSamePlanSelected) {
+ if (isDatabasesV2GA && nodeSelected && isSamePlanSelected) {
setSummaryAndPrices(database.type, engineType, dbTypes);
}
// No plan selection or plan selection is unchanged
@@ -357,7 +346,7 @@ export const DatabaseResize = ({ database, disabled = false }: Props) => {
Current Cluster: {currentPlan?.heading}
{' '}
-
+
{currentPlanPrice}
@@ -379,7 +368,7 @@ export const DatabaseResize = ({ database, disabled = false }: Props) => {
);
setSelectedTab(initialTab);
- if (isDatabasesGAEnabled) {
+ if (isDatabasesV2GA) {
const engineType = database.engine.split('/')[0] as Engine;
const nodePricingDetails = {
double: currentPlan?.engines[engineType]?.find(
@@ -418,7 +407,7 @@ export const DatabaseResize = ({ database, disabled = false }: Props) => {
return;
}
// Clear plan and related info when when 2 nodes option is selected for incompatible plan.
- if (isDatabasesGAEnabled && selectedTab === 0 && clusterSize === 2) {
+ if (isDatabasesV2GA && selectedTab === 0 && clusterSize === 2) {
setClusterSize(undefined);
setPlanSelected(undefined);
setNodePricing(undefined);
@@ -536,7 +525,7 @@ export const DatabaseResize = ({ database, disabled = false }: Props) => {
tabDisabledMessage="Resizing a 2-nodes cluster is only allowed with Dedicated plans."
types={displayTypes}
/>
- {isDatabasesGAEnabled && (
+ {isDatabasesV2GA && (
<>
@@ -573,13 +562,13 @@ export const DatabaseResize = ({ database, disabled = false }: Props) => {
({
- marginBottom: isDatabasesGAEnabled ? theme.spacing(2) : 0,
+ marginBottom: isDatabasesV2GA ? theme.spacing(2) : 0,
})}
variant="h2"
>
- Summary {isDatabasesGAEnabled ? database.label : ''}
+ Summary {isDatabasesV2GA ? database.label : ''}
- {isDatabasesGAEnabled && currentPlan ? currentSummary : null}
+ {isDatabasesV2GA && currentPlan ? currentSummary : null}
{resizeSummary}
diff --git a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseEmptyState.tsx b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseEmptyState.tsx
index f7bf2997dad..2825dcd8996 100644
--- a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseEmptyState.tsx
+++ b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseEmptyState.tsx
@@ -10,20 +10,23 @@ import {
linkAnalyticsEvent,
youtubeLinkData,
} from 'src/features/Databases/DatabaseLanding/DatabaseLandingEmptyStateData';
+import DatabaseLogo from 'src/features/Databases/DatabaseLanding/DatabaseLogo';
import { useIsDatabasesEnabled } from 'src/features/Databases/utilities';
import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck';
import { sendEvent } from 'src/utilities/analytics/utils';
export const DatabaseEmptyState = () => {
const { push } = useHistory();
- const { isDatabasesV2Enabled, isV2GAUser } = useIsDatabasesEnabled();
+ const { isDatabasesV2Enabled, isDatabasesV2GA } = useIsDatabasesEnabled();
const isRestricted = useRestrictedGlobalGrantCheck({
globalGrantType: 'add_databases',
});
- if (!isDatabasesV2Enabled || !isV2GAUser) {
- headers.logo = '';
+ if (isDatabasesV2Enabled || isDatabasesV2GA) {
+ headers.logo = (
+
+ );
}
return (
diff --git a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLanding.tsx b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLanding.tsx
index 2f57fc258e5..80c6bb91b6d 100644
--- a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLanding.tsx
+++ b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLanding.tsx
@@ -37,9 +37,9 @@ const DatabaseLanding = () => {
const {
isDatabasesV2Enabled,
- isV2ExistingBetaUser,
- isV2GAUser,
- isV2NewBetaUser,
+ isUserExistingBeta,
+ isDatabasesV2GA,
+ isUserNewBeta,
} = useIsDatabasesEnabled();
const { isLoading: isTypeLoading } = useDatabaseTypesQuery({
@@ -47,7 +47,7 @@ const DatabaseLanding = () => {
});
const isDefaultEnabled =
- isV2ExistingBetaUser || isV2NewBetaUser || isV2GAUser;
+ isUserExistingBeta || isUserNewBeta || isDatabasesV2GA;
const {
handleOrderChange: newDatabaseHandleOrderChange,
@@ -97,7 +97,7 @@ const DatabaseLanding = () => {
['+order_by']: legacyDatabaseOrderBy,
};
- if (isV2ExistingBetaUser || isV2GAUser) {
+ if (isUserExistingBeta || isDatabasesV2GA) {
legacyDatabasesFilter['platform'] = 'rdbms-legacy';
}
@@ -111,7 +111,7 @@ const DatabaseLanding = () => {
page_size: legacyDatabasesPagination.pageSize,
},
legacyDatabasesFilter,
- !isV2NewBetaUser
+ !isUserNewBeta
);
const error = newDatabasesError || legacyDatabasesError;
@@ -134,7 +134,7 @@ const DatabaseLanding = () => {
return ;
}
- const isV2Enabled = isDatabasesV2Enabled || isV2GAUser;
+ const isV2Enabled = isDatabasesV2Enabled || isDatabasesV2GA;
const showTabs = isV2Enabled && !!legacyDatabases?.data.length;
const isNewDatabase = isV2Enabled && !!newDatabases?.data.length;
diff --git a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLandingEmptyStateData.tsx b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLandingEmptyStateData.tsx
index 4dac8b5e649..a27baa54d92 100644
--- a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLandingEmptyStateData.tsx
+++ b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLandingEmptyStateData.tsx
@@ -1,6 +1,3 @@
-import React from 'react';
-
-import DatabaseLogo from 'src/features/Databases/DatabaseLanding/DatabaseLogo';
import {
docsLink,
guidesMoreLinkText,
@@ -17,7 +14,6 @@ import type {
export const headers: ResourcesHeaders = {
description:
"Deploy popular database engines such as MySQL and PostgreSQL using Linode's performant, reliable, and fully managed database solution.",
- logo: ,
subtitle: 'Fully managed cloud database clusters',
title: 'Databases',
};
diff --git a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLandingTable.tsx b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLandingTable.tsx
index f198043094f..3dbc125ad59 100644
--- a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLandingTable.tsx
+++ b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLandingTable.tsx
@@ -38,7 +38,7 @@ const DatabaseLandingTable = ({
orderBy,
}: Props) => {
const { data: events } = useInProgressEvents();
- const { isV2GAUser } = useIsDatabasesEnabled();
+ const { isDatabasesV2GA } = useIsDatabasesEnabled();
const dbPlatformType = isNewDatabase ? 'new' : 'legacy';
const pagination = usePagination(1, preferenceKey, dbPlatformType);
@@ -146,7 +146,7 @@ const DatabaseLandingTable = ({
Created
- {isV2GAUser && isNewDatabase && }
+ {isDatabasesV2GA && isNewDatabase && }
diff --git a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLogo.tsx b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLogo.tsx
index eb1186d4918..eecbb5df3e8 100644
--- a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLogo.tsx
+++ b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLogo.tsx
@@ -17,7 +17,7 @@ interface Props {
export const DatabaseLogo = ({ sx }: Props) => {
const theme = useTheme();
- const { isV2GAUser } = useIsDatabasesEnabled();
+ const { isDatabasesV2GA } = useIsDatabasesEnabled();
return (
{
sx={sx ? sx : { margin: '20px' }}
>
- {!isV2GAUser && (
+ {!isDatabasesV2GA && (
{
sx={{
color: theme.palette.mode === 'light' ? theme.color.headline : '',
display: 'flex',
- marginTop: !isV2GAUser ? theme.spacing(1) : '',
+ marginTop: !isDatabasesV2GA ? theme.spacing(1) : '',
}}
component="span"
>
diff --git a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseRow.tsx b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseRow.tsx
index d9e66e5f7c7..031bf088fb4 100644
--- a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseRow.tsx
+++ b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseRow.tsx
@@ -66,7 +66,7 @@ export const DatabaseRow = ({
const plan = types?.find((t: DatabaseType) => t.id === type);
const formattedPlan = plan && formatStorageUnits(plan.label);
const actualRegion = regions?.find((r) => r.id === region);
- const { isV2GAUser } = useIsDatabasesEnabled();
+ const { isDatabasesV2GA } = useIsDatabasesEnabled();
const configuration =
cluster_size === 1 ? (
@@ -107,7 +107,7 @@ export const DatabaseRow = ({
})}
- {isV2GAUser && isNewDatabase && (
+ {isDatabasesV2GA && isNewDatabase && (
{
});
};
+const queryMocks = vi.hoisted(() => ({
+ useDatabaseTypesQuery: vi.fn().mockReturnValue({}),
+}));
+
+vi.mock('src/queries/databases/databases', () => ({
+ useDatabaseTypesQuery: queryMocks.useDatabaseTypesQuery,
+}));
+
describe('useIsDatabasesEnabled', () => {
- it('should return false for an unrestricted user without the account capability', async () => {
+ it('should return correctly for non V1/V2 user', async () => {
const { result } = setup([], { dbaasV2: { beta: true, enabled: true } });
await waitFor(() => {
expect(result.current.isDatabasesEnabled).toBe(false);
@@ -37,14 +44,14 @@ describe('useIsDatabasesEnabled', () => {
expect(result.current.isDatabasesV2Enabled).toBe(false);
expect(result.current.isDatabasesV2Beta).toBe(false);
- expect(result.current.isV2ExistingBetaUser).toBe(false);
- expect(result.current.isV2NewBetaUser).toBe(false);
+ expect(result.current.isUserExistingBeta).toBe(false);
+ expect(result.current.isUserNewBeta).toBe(false);
- expect(result.current.isV2GAUser).toBe(false);
+ expect(result.current.isDatabasesV2GA).toBe(false);
});
});
- it('should return true for an unrestricted user with the account capability V1', async () => {
+ it('should return correctly for V1 user', async () => {
const { result } = setup(['Managed Databases'], {
dbaasV2: { beta: false, enabled: false },
});
@@ -55,14 +62,14 @@ describe('useIsDatabasesEnabled', () => {
expect(result.current.isDatabasesV2Enabled).toBe(false);
expect(result.current.isDatabasesV2Beta).toBe(false);
- expect(result.current.isV2ExistingBetaUser).toBe(false);
- expect(result.current.isV2NewBetaUser).toBe(false);
+ expect(result.current.isUserExistingBeta).toBe(false);
+ expect(result.current.isUserNewBeta).toBe(false);
- expect(result.current.isV2GAUser).toBe(false);
+ expect(result.current.isDatabasesV2GA).toBe(false);
});
});
- it('should return true for a new unrestricted user with the account capability V2 and beta feature flag', async () => {
+ it('should return correctly for V2 new user beta', async () => {
const { result } = setup(['Managed Databases Beta'], {
dbaasV2: { beta: true, enabled: true },
});
@@ -73,16 +80,16 @@ describe('useIsDatabasesEnabled', () => {
expect(result.current.isDatabasesV2Enabled).toBe(true);
expect(result.current.isDatabasesV2Beta).toBe(true);
- expect(result.current.isV2ExistingBetaUser).toBe(false);
- expect(result.current.isV2NewBetaUser).toBe(true);
+ expect(result.current.isUserExistingBeta).toBe(false);
+ expect(result.current.isUserNewBeta).toBe(true);
- expect(result.current.isV2GAUser).toBe(false);
+ expect(result.current.isDatabasesV2GA).toBe(false);
});
});
- it('should return false for a new unrestricted user with the account capability V2 and no beta feature flag', async () => {
+ it('should return correctly for V2 new user no beta', async () => {
const { result } = setup(['Managed Databases Beta'], {
- dbaasV2: { beta: true, enabled: false },
+ dbaasV2: { beta: false, enabled: false },
});
await waitFor(() => {
@@ -91,14 +98,14 @@ describe('useIsDatabasesEnabled', () => {
expect(result.current.isDatabasesV2Enabled).toBe(false);
expect(result.current.isDatabasesV2Beta).toBe(false);
- expect(result.current.isV2ExistingBetaUser).toBe(false);
- expect(result.current.isV2NewBetaUser).toBe(false);
+ expect(result.current.isUserExistingBeta).toBe(false);
+ expect(result.current.isUserNewBeta).toBe(false);
- expect(result.current.isV2GAUser).toBe(false);
+ expect(result.current.isDatabasesV2GA).toBe(false);
});
});
- it('should return true for an existing unrestricted user with the account capability V1 & V2 and beta feature flag', async () => {
+ it('should return correctly for V1 & V2 existing user beta', async () => {
const { result } = setup(['Managed Databases', 'Managed Databases Beta'], {
dbaasV2: { beta: true, enabled: true },
});
@@ -109,14 +116,14 @@ describe('useIsDatabasesEnabled', () => {
expect(result.current.isDatabasesV2Enabled).toBe(true);
expect(result.current.isDatabasesV2Beta).toBe(true);
- expect(result.current.isV2ExistingBetaUser).toBe(true);
- expect(result.current.isV2NewBetaUser).toBe(false);
+ expect(result.current.isUserExistingBeta).toBe(true);
+ expect(result.current.isUserNewBeta).toBe(false);
- expect(result.current.isV2GAUser).toBe(false);
+ expect(result.current.isDatabasesV2GA).toBe(false);
});
});
- it('should return true for an existing unrestricted user with the account capability V1 and no beta feature flag', async () => {
+ it('should return correctly for V1 existing user GA', async () => {
const { result } = setup(['Managed Databases'], {
dbaasV2: { beta: false, enabled: true },
});
@@ -127,45 +134,195 @@ describe('useIsDatabasesEnabled', () => {
expect(result.current.isDatabasesV2Enabled).toBe(false);
expect(result.current.isDatabasesV2Beta).toBe(false);
- expect(result.current.isV2ExistingBetaUser).toBe(false);
- expect(result.current.isV2NewBetaUser).toBe(false);
+ expect(result.current.isUserExistingBeta).toBe(false);
+ expect(result.current.isUserNewBeta).toBe(false);
- expect(result.current.isV2GAUser).toBe(true);
+ expect(result.current.isDatabasesV2GA).toBe(true);
});
});
- it('should return true for a restricted user who can not load account but can load database engines', async () => {
+ it('should return correctly for V1 restricted user non-beta', async () => {
server.use(
http.get('*/v4/account', () => {
return HttpResponse.json({}, { status: 403 });
- }),
- http.get('*/v4beta/databases/engines', () => {
- return HttpResponse.json(makeResourcePage([]));
})
);
+ // default
+ queryMocks.useDatabaseTypesQuery.mockReturnValueOnce({
+ data: null,
+ });
+
+ // legacy
+ queryMocks.useDatabaseTypesQuery.mockReturnValueOnce({
+ data: databaseTypeFactory.buildList(1),
+ });
+
+ const flags = { dbaasV2: { beta: true, enabled: true } };
+
const { result } = renderHook(() => useIsDatabasesEnabled(), {
- wrapper: wrapWithTheme,
+ wrapper: (ui) => wrapWithTheme(ui, { flags }),
});
- await waitFor(() => expect(result.current.isDatabasesEnabled).toBe(true));
+ expect(queryMocks.useDatabaseTypesQuery).toHaveBeenNthCalledWith(
+ 1,
+ ...[{ platform: 'rdbms-default' }, true]
+ );
+
+ expect(queryMocks.useDatabaseTypesQuery).toHaveBeenNthCalledWith(
+ 2,
+ ...[{ platform: 'rdbms-legacy' }, true]
+ );
+
+ await waitFor(() => {
+ expect(result.current.isDatabasesEnabled).toBe(true);
+ expect(result.current.isDatabasesV1Enabled).toBe(true);
+ expect(result.current.isDatabasesV2Enabled).toBe(false);
+
+ expect(result.current.isDatabasesV2Beta).toBe(false);
+ expect(result.current.isUserExistingBeta).toBe(false);
+ expect(result.current.isUserNewBeta).toBe(false);
+
+ expect(result.current.isDatabasesV2GA).toBe(false);
+ });
});
- it('should return false for a restricted user who can not load account and database engines', async () => {
+ it('should return correctly for V1 & V2 restricted user existing beta', async () => {
server.use(
http.get('*/v4/account', () => {
return HttpResponse.json({}, { status: 403 });
- }),
- http.get('*/v4beta/databases/engines', () => {
- return HttpResponse.json({}, { status: 404 });
})
);
+ // default
+ queryMocks.useDatabaseTypesQuery.mockReturnValueOnce({
+ data: databaseTypeFactory.buildList(1),
+ });
+
+ // legacy
+ queryMocks.useDatabaseTypesQuery.mockReturnValueOnce({
+ data: databaseTypeFactory.buildList(1),
+ });
+
+ const flags = { dbaasV2: { beta: true, enabled: true } };
+
const { result } = renderHook(() => useIsDatabasesEnabled(), {
- wrapper: wrapWithTheme,
+ wrapper: (ui) => wrapWithTheme(ui, { flags }),
+ });
+
+ expect(queryMocks.useDatabaseTypesQuery).toHaveBeenNthCalledWith(
+ 1,
+ ...[{ platform: 'rdbms-default' }, true]
+ );
+
+ expect(queryMocks.useDatabaseTypesQuery).toHaveBeenNthCalledWith(
+ 2,
+ ...[{ platform: 'rdbms-legacy' }, true]
+ );
+
+ await waitFor(() => {
+ expect(result.current.isDatabasesEnabled).toBe(true);
+ expect(result.current.isDatabasesV1Enabled).toBe(true);
+ expect(result.current.isDatabasesV2Enabled).toBe(true);
+
+ expect(result.current.isDatabasesV2Beta).toBe(true);
+ expect(result.current.isUserExistingBeta).toBe(true);
+ expect(result.current.isUserNewBeta).toBe(false);
+
+ expect(result.current.isDatabasesV2GA).toBe(false);
+ });
+ });
+
+ it('should return correctly for V2 restricted user new beta', async () => {
+ server.use(
+ http.get('*/v4/account', () => {
+ return HttpResponse.json({}, { status: 403 });
+ })
+ );
+
+ // default
+ queryMocks.useDatabaseTypesQuery.mockReturnValueOnce({
+ data: databaseTypeFactory.buildList(1),
});
- await waitFor(() => expect(result.current.isDatabasesEnabled).toBe(false));
+ // legacy
+ queryMocks.useDatabaseTypesQuery.mockReturnValueOnce({
+ data: null,
+ });
+
+ const flags = { dbaasV2: { beta: true, enabled: true } };
+
+ const { result } = renderHook(() => useIsDatabasesEnabled(), {
+ wrapper: (ui) => wrapWithTheme(ui, { flags }),
+ });
+
+ expect(queryMocks.useDatabaseTypesQuery).toHaveBeenNthCalledWith(
+ 1,
+ ...[{ platform: 'rdbms-default' }, true]
+ );
+
+ expect(queryMocks.useDatabaseTypesQuery).toHaveBeenNthCalledWith(
+ 2,
+ ...[{ platform: 'rdbms-legacy' }, true]
+ );
+
+ await waitFor(() => {
+ expect(result.current.isDatabasesEnabled).toBe(true);
+ expect(result.current.isDatabasesV1Enabled).toBe(false);
+ expect(result.current.isDatabasesV2Enabled).toBe(true);
+
+ expect(result.current.isDatabasesV2Beta).toBe(true);
+ expect(result.current.isUserExistingBeta).toBe(false);
+ expect(result.current.isUserNewBeta).toBe(true);
+
+ expect(result.current.isDatabasesV2GA).toBe(false);
+ });
+ });
+
+ it('should return correctly for V2 restricted user GA', async () => {
+ server.use(
+ http.get('*/v4/account', () => {
+ return HttpResponse.json({}, { status: 403 });
+ })
+ );
+
+ // default
+ queryMocks.useDatabaseTypesQuery.mockReturnValueOnce({
+ data: databaseTypeFactory.buildList(1),
+ });
+
+ // legacy
+ queryMocks.useDatabaseTypesQuery.mockReturnValueOnce({
+ data: null,
+ });
+
+ const flags = { dbaasV2: { beta: false, enabled: true } };
+
+ const { result } = renderHook(() => useIsDatabasesEnabled(), {
+ wrapper: (ui) => wrapWithTheme(ui, { flags }),
+ });
+
+ expect(queryMocks.useDatabaseTypesQuery).toHaveBeenNthCalledWith(
+ 1,
+ ...[{ platform: 'rdbms-default' }, true]
+ );
+
+ expect(queryMocks.useDatabaseTypesQuery).toHaveBeenNthCalledWith(
+ 2,
+ ...[{ platform: 'rdbms-legacy' }, true]
+ );
+
+ await waitFor(() => {
+ expect(result.current.isDatabasesEnabled).toBe(true);
+ expect(result.current.isDatabasesV1Enabled).toBe(false);
+ expect(result.current.isDatabasesV2Enabled).toBe(true);
+
+ expect(result.current.isDatabasesV2Beta).toBe(false);
+ expect(result.current.isUserExistingBeta).toBe(false);
+ expect(result.current.isUserNewBeta).toBe(false);
+
+ expect(result.current.isDatabasesV2GA).toBe(true);
+ });
});
});
diff --git a/packages/manager/src/features/Databases/utilities.ts b/packages/manager/src/features/Databases/utilities.ts
index 2842a0d96a5..ce4fe9bc149 100644
--- a/packages/manager/src/features/Databases/utilities.ts
+++ b/packages/manager/src/features/Databases/utilities.ts
@@ -1,14 +1,29 @@
+import type { DatabaseInstance } from '@linode/api-v4';
import { DatabaseFork } from '@linode/api-v4';
import { DateTime } from 'luxon';
-
import { useFlags } from 'src/hooks/useFlags';
import { useAccount } from 'src/queries/account/account';
-import { useDatabaseEnginesQuery } from 'src/queries/databases/databases';
+import { useDatabaseTypesQuery } from 'src/queries/databases/databases';
import { isFeatureEnabledV2 } from 'src/utilities/accountCapabilities';
-
import { databaseEngineMap } from './DatabaseLanding/DatabaseRow';
-import type { DatabaseInstance } from '@linode/api-v4';
+export interface IsDatabasesEnabled {
+ isDatabasesEnabled: boolean;
+ isDatabasesV1Enabled: boolean;
+ isDatabasesV2Enabled: boolean;
+ isDatabasesV2Beta: boolean;
+ isDatabasesV2GA: boolean;
+ /**
+ * Temporary variable to be removed post GA release
+ */
+ isUserExistingBeta: boolean;
+ /**
+ * Temporary variable to be removed post GA release
+ */
+ isUserNewBeta: boolean;
+ isDatabasesMonitorEnabled: boolean;
+ isDatabasesMonitorBeta: boolean;
+}
/**
* A hook to determine if Databases should be visible to the user.
@@ -18,20 +33,30 @@ import type { DatabaseInstance } from '@linode/api-v4';
*
* For users who don't have permission to load /v4/account
* (who are restricted users without account read access),
- * we must check if they can load Database Engines as a workaround.
- * If these users can successfully fetch database engines, we will
+ * we must check if they can load Database Types as a workaround.
+ * If these users can successfully fetch database types, we will
* show databases.
*/
-export const useIsDatabasesEnabled = () => {
- const { data: account } = useAccount();
+export const useIsDatabasesEnabled = (): IsDatabasesEnabled => {
+ const flags = useFlags();
+ const hasV2Flag: boolean = !!flags.dbaasV2?.enabled;
+ const hasV2BetaFlag: boolean = hasV2Flag && flags.dbaasV2?.beta === true;
+ const hasV2GAFlag: boolean = hasV2Flag && flags.dbaasV2?.beta === false;
+ const { data: account } = useAccount();
// If we don't have permission to GET /v4/account,
// we need to try fetching Database engines to know if the user has databases enabled.
const checkRestrictedUser = !account;
- const { data: engines } = useDatabaseEnginesQuery(checkRestrictedUser);
- const flags = useFlags();
- const isBeta = !!flags.dbaasV2?.beta;
+ const { data: types } = useDatabaseTypesQuery(
+ { platform: 'rdbms-default' },
+ checkRestrictedUser
+ );
+
+ const { data: legacyTypes } = useDatabaseTypesQuery(
+ { platform: 'rdbms-legacy' },
+ checkRestrictedUser
+ );
if (account) {
const isDatabasesV1Enabled = isFeatureEnabledV2(
@@ -42,43 +67,45 @@ export const useIsDatabasesEnabled = () => {
const isDatabasesV2Enabled = isFeatureEnabledV2(
'Managed Databases Beta',
- !!flags.dbaasV2?.enabled,
+ hasV2Flag,
account?.capabilities ?? []
);
- const isV2ExistingBetaUser =
- isBeta && isDatabasesV1Enabled && isDatabasesV2Enabled;
-
- const isV2NewBetaUser =
- isBeta && !isDatabasesV1Enabled && isDatabasesV2Enabled;
-
- const isV2GAUser =
- !isBeta &&
- isFeatureEnabledV2(
- 'Managed Databases',
- !!flags.dbaasV2?.enabled,
- account?.capabilities ?? []
- );
-
- const isDatabasesGA =
- flags.dbaasV2?.enabled && flags.dbaasV2.beta === false;
+ const isDatabasesV2Beta: boolean = isDatabasesV2Enabled && hasV2BetaFlag;
return {
isDatabasesEnabled: isDatabasesV1Enabled || isDatabasesV2Enabled,
- isDatabasesGAEnabled: isDatabasesV1Enabled && isDatabasesGA,
isDatabasesV1Enabled,
- isDatabasesV2Beta: isDatabasesV2Enabled && flags.dbaasV2?.beta,
isDatabasesV2Enabled,
- isV2ExistingBetaUser,
- isV2GAUser,
- isV2NewBetaUser,
+
+ isDatabasesV2Beta,
+ isUserExistingBeta: isDatabasesV2Beta && isDatabasesV1Enabled,
+ isUserNewBeta: isDatabasesV2Beta && !isDatabasesV1Enabled,
+
+ isDatabasesV2GA:
+ (isDatabasesV1Enabled || isDatabasesV2Enabled) && hasV2GAFlag,
+
+ isDatabasesMonitorEnabled: !!flags.dbaasV2MonitorMetrics?.enabled,
+ isDatabasesMonitorBeta: !!flags.dbaasV2MonitorMetrics?.beta,
};
}
- const userCouldLoadDatabaseEngines = engines !== undefined;
+ const hasLegacyTypes: boolean = !!legacyTypes;
+ const hasDefaultTypes: boolean = !!types && hasV2Flag;
return {
- isDatabasesEnabled: userCouldLoadDatabaseEngines,
+ isDatabasesEnabled: hasLegacyTypes || hasDefaultTypes,
+ isDatabasesV1Enabled: hasLegacyTypes,
+ isDatabasesV2Enabled: hasDefaultTypes,
+
+ isDatabasesV2Beta: hasDefaultTypes && hasV2BetaFlag,
+ isUserExistingBeta: hasLegacyTypes && hasDefaultTypes && hasV2BetaFlag,
+ isUserNewBeta: !hasLegacyTypes && hasDefaultTypes && hasV2BetaFlag,
+
+ isDatabasesV2GA: (hasLegacyTypes || hasDefaultTypes) && hasV2GAFlag,
+
+ isDatabasesMonitorEnabled: !!flags.dbaasV2MonitorMetrics?.enabled,
+ isDatabasesMonitorBeta: !!flags.dbaasV2MonitorMetrics?.beta,
};
};
diff --git a/packages/manager/src/queries/databases/databases.ts b/packages/manager/src/queries/databases/databases.ts
index 4ca5d85c42a..1913ba799f3 100644
--- a/packages/manager/src/queries/databases/databases.ts
+++ b/packages/manager/src/queries/databases/databases.ts
@@ -187,9 +187,13 @@ export const useDatabaseEnginesQuery = (enabled: boolean = false) =>
enabled,
});
-export const useDatabaseTypesQuery = (filter: Filter = {}) =>
+export const useDatabaseTypesQuery = (
+ filter: Filter = {},
+ enabled: boolean = true
+) =>
useQuery({
...databaseQueries.types._ctx.all(filter),
+ enabled,
});
export const useDatabaseCredentialsQuery = (
From 5e54d46a760d70f5c5739f17fe320a19548bc52e Mon Sep 17 00:00:00 2001
From: smans-akamai
Date: Thu, 17 Oct 2024 12:25:49 -0400
Subject: [PATCH 32/64] feat: [UIE-7995] DBaaS Monitor GA (#11105)
---
.../pr-11105-added-1729023730319.md | 5 ++
.../src/components/Tabs/TabLinkList.tsx | 2 +
.../manager/src/dev-tools/FeatureFlagTool.tsx | 1 +
.../DatabaseMonitor/DatabaseMonitor.test.tsx | 29 ++++++++++++
.../DatabaseMonitor/DatabaseMonitor.tsx | 18 ++++++++
.../Databases/DatabaseDetail/index.tsx | 46 +++++++++++++++----
6 files changed, 93 insertions(+), 8 deletions(-)
create mode 100644 packages/manager/.changeset/pr-11105-added-1729023730319.md
create mode 100644 packages/manager/src/features/Databases/DatabaseDetail/DatabaseMonitor/DatabaseMonitor.test.tsx
create mode 100644 packages/manager/src/features/Databases/DatabaseDetail/DatabaseMonitor/DatabaseMonitor.tsx
diff --git a/packages/manager/.changeset/pr-11105-added-1729023730319.md b/packages/manager/.changeset/pr-11105-added-1729023730319.md
new file mode 100644
index 00000000000..38c14c9a4a8
--- /dev/null
+++ b/packages/manager/.changeset/pr-11105-added-1729023730319.md
@@ -0,0 +1,5 @@
+---
+"@linode/manager": Added
+---
+
+DBaaS GA Monitor tab ([#11105](https://github.com/linode/manager/pull/11105))
diff --git a/packages/manager/src/components/Tabs/TabLinkList.tsx b/packages/manager/src/components/Tabs/TabLinkList.tsx
index 5d621068ae1..486f8a088b6 100644
--- a/packages/manager/src/components/Tabs/TabLinkList.tsx
+++ b/packages/manager/src/components/Tabs/TabLinkList.tsx
@@ -7,6 +7,7 @@ import { TabList } from 'src/components/Tabs/TabList';
export interface Tab {
routeName: string;
title: string;
+ chip?: React.JSX.Element | null;
}
interface TabLinkListProps {
@@ -30,6 +31,7 @@ export const TabLinkList = ({ noLink, tabs }: TabLinkListProps) => {
{...extraTemporaryProps}
>
{tab.title}
+ {tab.chip}
);
})}
diff --git a/packages/manager/src/dev-tools/FeatureFlagTool.tsx b/packages/manager/src/dev-tools/FeatureFlagTool.tsx
index 3c58f58ae6d..581371a7740 100644
--- a/packages/manager/src/dev-tools/FeatureFlagTool.tsx
+++ b/packages/manager/src/dev-tools/FeatureFlagTool.tsx
@@ -31,6 +31,7 @@ const options: { flag: keyof Flags; label: string }[] = [
{ flag: 'selfServeBetas', label: 'Self Serve Betas' },
{ flag: 'supportTicketSeverity', label: 'Support Ticket Severity' },
{ flag: 'dbaasV2', label: 'Databases V2 Beta' },
+ { flag: 'dbaasV2MonitorMetrics', label: 'Databases V2 Monitor' },
{ flag: 'databaseResize', label: 'Database Resize' },
{ flag: 'apicliDxToolsAdditions', label: 'APICLI DX Tools Additions' },
{ flag: 'apicliButtonCopy', label: 'APICLI Button Copy' },
diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseMonitor/DatabaseMonitor.test.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseMonitor/DatabaseMonitor.test.tsx
new file mode 100644
index 00000000000..ec6c413ebb5
--- /dev/null
+++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseMonitor/DatabaseMonitor.test.tsx
@@ -0,0 +1,29 @@
+import { waitForElementToBeRemoved } from '@testing-library/react';
+import * as React from 'react';
+import { databaseFactory } from 'src/factories';
+import { mockMatchMedia, renderWithTheme } from 'src/utilities/testHelpers';
+import { DatabaseMonitor } from './DatabaseMonitor';
+
+const loadingTestId = 'circle-progress';
+
+beforeAll(() => mockMatchMedia());
+
+describe('database monitor', () => {
+ const database = databaseFactory.build({ id: 12 });
+ it('should render a loading state', async () => {
+ const { getByTestId } = renderWithTheme(
+
+ );
+ // Should render a loading state
+ expect(getByTestId(loadingTestId)).toBeInTheDocument();
+ });
+
+ it('should render CloudPulseDashboardWithFilters', async () => {
+ const { getByTestId } = renderWithTheme(
+
+ );
+ expect(getByTestId(loadingTestId)).toBeInTheDocument();
+ await waitForElementToBeRemoved(getByTestId(loadingTestId));
+ expect(getByTestId('cloudpulse-time-duration')).toBeInTheDocument();
+ });
+});
diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseMonitor/DatabaseMonitor.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseMonitor/DatabaseMonitor.tsx
new file mode 100644
index 00000000000..5a255785977
--- /dev/null
+++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseMonitor/DatabaseMonitor.tsx
@@ -0,0 +1,18 @@
+import * as React from 'react';
+import { CloudPulseDashboardWithFilters } from 'src/features/CloudPulse/Dashboard/CloudPulseDashboardWithFilters';
+import { Database } from '@linode/api-v4';
+
+interface Props {
+ database: Database;
+}
+
+export const DatabaseMonitor = ({ database }: Props) => {
+ const databaseId = database?.id;
+ const dbaasDashboardId = 1;
+ return (
+
+ );
+};
diff --git a/packages/manager/src/features/Databases/DatabaseDetail/index.tsx b/packages/manager/src/features/Databases/DatabaseDetail/index.tsx
index 177cfd28b05..c1594063187 100644
--- a/packages/manager/src/features/Databases/DatabaseDetail/index.tsx
+++ b/packages/manager/src/features/Databases/DatabaseDetail/index.tsx
@@ -8,7 +8,7 @@ import { ErrorState } from 'src/components/ErrorState/ErrorState';
import { LandingHeader } from 'src/components/LandingHeader';
import { Notice } from 'src/components/Notice/Notice';
import { SafeTabPanel } from 'src/components/Tabs/SafeTabPanel';
-import { TabLinkList } from 'src/components/Tabs/TabLinkList';
+import { Tab, TabLinkList } from 'src/components/Tabs/TabLinkList';
import { TabPanels } from 'src/components/Tabs/TabPanels';
import { Tabs } from 'src/components/Tabs/Tabs';
import DatabaseLogo from 'src/features/Databases/DatabaseLanding/DatabaseLogo';
@@ -24,6 +24,8 @@ import { getAPIErrorOrDefault } from 'src/utilities/errorUtils';
import type { Engine } from '@linode/api-v4/lib/databases/types';
import type { APIError } from '@linode/api-v4/lib/types';
+import { BetaChip } from 'src/components/BetaChip/BetaChip';
+import { useIsDatabasesEnabled } from '../utilities';
const DatabaseSummary = React.lazy(() => import('./DatabaseSummary'));
const DatabaseBackups = React.lazy(
@@ -35,7 +37,11 @@ const DatabaseResize = React.lazy(() =>
default: DatabaseResize,
}))
);
-
+const DatabaseMonitor = React.lazy(() =>
+ import('./DatabaseMonitor/DatabaseMonitor').then(({ DatabaseMonitor }) => ({
+ default: DatabaseMonitor,
+ }))
+);
export const DatabaseDetail = () => {
const history = useHistory();
const flags = useFlags();
@@ -66,6 +72,11 @@ export const DatabaseDetail = () => {
setEditableLabelError,
} = useEditableLabelState();
+ const {
+ isDatabasesMonitorEnabled,
+ isDatabasesMonitorBeta,
+ } = useIsDatabasesEnabled();
+
if (error) {
return (
{
return null;
}
- const tabs = [
+ const isDefault = database.platform === 'rdbms-default';
+ const isMonitorEnabled = isDefault && isDatabasesMonitorEnabled;
+
+ const tabs: Tab[] = [
{
routeName: `/databases/${engine}/${id}/summary`,
title: 'Summary',
@@ -99,8 +113,19 @@ export const DatabaseDetail = () => {
},
];
+ const resizeIndex = isMonitorEnabled ? 3 : 2;
+ const backupsIndex = isMonitorEnabled ? 2 : 1;
+
+ if (isMonitorEnabled) {
+ tabs.splice(1, 0, {
+ routeName: `/databases/${engine}/${id}/monitor`,
+ title: 'Monitor',
+ chip: isDatabasesMonitorBeta ? : null,
+ });
+ }
+
if (flags.databaseResize) {
- tabs.splice(2, 0, {
+ tabs.splice(resizeIndex, 0, {
routeName: `/databases/${engine}/${id}/resize`,
title: 'Resize',
});
@@ -187,18 +212,23 @@ export const DatabaseDetail = () => {
disabled={isDatabasesGrantReadOnly}
/>
-
+ {isMonitorEnabled ? (
+
+
+
+ ) : null}
+
{flags.databaseResize ? (
-
+
) : null}
-
+ {
- {database.platform === 'rdbms-default' && }
+ {isDefault && }
>
);
};
From cf8823adbd37cd6f592fda86d2f3ad16859e1ec1 Mon Sep 17 00:00:00 2001
From: cpathipa <119517080+cpathipa@users.noreply.github.com>
Date: Thu, 17 Oct 2024 11:40:05 -0500
Subject: [PATCH 33/64] feat: [M3-7841] - Add the capability to search for a
Linode by ID using the main search tool. (#11112)
* unit test coverage for HostNameTableCell
* Revert "unit test coverage for HostNameTableCell"
This reverts commit b274baf67e27d79fd4e764607ded7c5aa755ee8b.
* chore: [M3-8662] - Update Github Actions actions (#11009)
* update actions
* add changeset
---------
Co-authored-by: Banks Nussman
* Add capability to search linode by id from main search tool
* Added changeset: Add the capability to search for a Linode by ID using the main search tool.
* Update packages/manager/.changeset/pr-11112-added-1729086990373.md
Co-authored-by: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com>
* Update packages/manager/cypress/e2e/core/linodes/search-linodes.spec.ts
Co-authored-by: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com>
---------
Co-authored-by: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com>
Co-authored-by: Banks Nussman
Co-authored-by: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com>
---
.../pr-11112-added-1729086990373.md | 5 +++
.../e2e/core/linodes/search-linodes.spec.ts | 44 +++++++++++++++++++
.../src/features/Search/refinedSearch.ts | 12 ++++-
.../src/features/Search/search.interfaces.ts | 2 +-
.../src/store/selectors/getSearchEntities.ts | 2 +-
5 files changed, 61 insertions(+), 4 deletions(-)
create mode 100644 packages/manager/.changeset/pr-11112-added-1729086990373.md
create mode 100644 packages/manager/cypress/e2e/core/linodes/search-linodes.spec.ts
diff --git a/packages/manager/.changeset/pr-11112-added-1729086990373.md b/packages/manager/.changeset/pr-11112-added-1729086990373.md
new file mode 100644
index 00000000000..12a00fcc645
--- /dev/null
+++ b/packages/manager/.changeset/pr-11112-added-1729086990373.md
@@ -0,0 +1,5 @@
+---
+"@linode/manager": Added
+---
+
+Add the capability to search for a Linode by ID using the main search tool ([#11112](https://github.com/linode/manager/pull/11112))
diff --git a/packages/manager/cypress/e2e/core/linodes/search-linodes.spec.ts b/packages/manager/cypress/e2e/core/linodes/search-linodes.spec.ts
new file mode 100644
index 00000000000..dcc0b7c133a
--- /dev/null
+++ b/packages/manager/cypress/e2e/core/linodes/search-linodes.spec.ts
@@ -0,0 +1,44 @@
+import { ui } from 'support/ui';
+import { cleanUp } from 'support/util/cleanup';
+import { authenticate } from 'support/api/authentication';
+import { createTestLinode } from 'support/util/linodes';
+import type { Linode } from '@linode/api-v4';
+
+authenticate();
+describe('Search Linodes', () => {
+ beforeEach(() => {
+ cleanUp(['linodes']);
+ cy.tag('method:e2e');
+ });
+
+ /*
+ * - Confirm that linodes are searchable and filtered in the UI.
+ */
+ it('create a linode and make sure it shows up in the table and is searchable in main search tool', () => {
+ cy.defer(() => createTestLinode({ booted: true })).then(
+ (linode: Linode) => {
+ cy.visitWithLogin('/linodes');
+ cy.get(`[data-qa-linode="${linode.label}"]`)
+ .should('be.visible')
+ .within(() => {
+ cy.contains('Running').should('be.visible');
+ });
+
+ // Confirm that linode is listed on the landing page.
+ cy.findByText(linode.label).should('be.visible');
+
+ // Use the main search bar to search and filter linode by label
+ cy.get('[id="main-search"').type(linode.label);
+ ui.autocompletePopper.findByTitle(linode.label).should('be.visible');
+
+ // Use the main search bar to search and filter linode by id value
+ cy.get('[id="main-search"').clear().type(`${linode.id}`);
+ ui.autocompletePopper.findByTitle(linode.label).should('be.visible');
+
+ // Use the main search bar to search and filter linode by id: pattern
+ cy.get('[id="main-search"').clear().type(`id:${linode.id}`);
+ ui.autocompletePopper.findByTitle(linode.label).should('be.visible');
+ }
+ );
+ });
+});
diff --git a/packages/manager/src/features/Search/refinedSearch.ts b/packages/manager/src/features/Search/refinedSearch.ts
index 2624d2fce4c..d844566d379 100644
--- a/packages/manager/src/features/Search/refinedSearch.ts
+++ b/packages/manager/src/features/Search/refinedSearch.ts
@@ -5,7 +5,7 @@ import searchString from 'search-string';
import type { SearchField, SearchableItem } from './search.interfaces';
export const COMPRESSED_IPV6_REGEX = /^([0-9A-Fa-f]{1,4}(:[0-9A-Fa-f]{1,4}){0,7})?::([0-9A-Fa-f]{1,4}(:[0-9A-Fa-f]{1,4}){0,7})?$/;
-const DEFAULT_SEARCH_FIELDS = ['label', 'tags', 'ips'];
+const DEFAULT_SEARCH_FIELDS = ['label', 'tags', 'ips', 'value'];
// =============================================================================
// REFINED SEARCH
@@ -166,6 +166,11 @@ export const doesSearchTermMatchItemField = (
const fieldValue = ensureValueIsString(flattenedItem[field]);
+ // Handle numeric comparison (e.g., for the "value" field to search linode by id)
+ if (typeof fieldValue === 'number') {
+ return fieldValue === Number(query); // Ensure exact match for numeric fields
+ }
+
if (caseSensitive) {
return fieldValue.includes(query);
} else {
@@ -177,6 +182,7 @@ export const doesSearchTermMatchItemField = (
export const flattenSearchableItem = (item: SearchableItem) => ({
label: item.label,
type: item.entityType,
+ value: item.value,
...item.data,
});
@@ -203,7 +209,7 @@ export const getQueryInfo = (parsedQuery: any) => {
};
};
-// Our entities have several fields we'd like to search: "tags", "label", "ips".
+// Our entities have several fields we'd like to search: "tags", "label", "ips", "value".
// A user might submit the query "tag:my-app". In this case, we want to trade
// "tag" for "tags", since "tags" is the actual name of the intended property.
export const getRealEntityKey = (key: string): SearchField | string => {
@@ -211,9 +217,11 @@ export const getRealEntityKey = (key: string): SearchField | string => {
const LABEL: SearchField = 'label';
const IPS: SearchField = 'ips';
const TYPE: SearchField = 'type';
+ const VALUE: SearchField = 'value';
const substitutions = {
group: TAGS,
+ id: VALUE,
ip: IPS,
is: TYPE,
name: LABEL,
diff --git a/packages/manager/src/features/Search/search.interfaces.ts b/packages/manager/src/features/Search/search.interfaces.ts
index e8c45d5c334..a5d035f5f55 100644
--- a/packages/manager/src/features/Search/search.interfaces.ts
+++ b/packages/manager/src/features/Search/search.interfaces.ts
@@ -22,7 +22,7 @@ export type SearchableEntityType =
| 'volume';
// These are the properties on our entities we'd like to search
-export type SearchField = 'ips' | 'label' | 'tags' | 'type';
+export type SearchField = 'ips' | 'label' | 'tags' | 'type' | 'value';
export interface SearchResultsByEntity {
buckets: SearchableItem[];
diff --git a/packages/manager/src/store/selectors/getSearchEntities.ts b/packages/manager/src/store/selectors/getSearchEntities.ts
index f7cbef079f3..d7e9bf8d653 100644
--- a/packages/manager/src/store/selectors/getSearchEntities.ts
+++ b/packages/manager/src/store/selectors/getSearchEntities.ts
@@ -158,7 +158,7 @@ export const bucketToSearchableItem = (
cluster: bucket.cluster,
created: bucket.created,
description: readableBytes(bucket.size).formatted,
- icon: 'bucket',
+ icon: 'storage',
label: bucket.label,
path: `/object-storage/buckets/${bucket.cluster}/${bucket.label}`,
},
From 1d622f4c3756a96a5995cd3a757da35f4d7db6a6 Mon Sep 17 00:00:00 2001
From: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com>
Date: Thu, 17 Oct 2024 10:07:27 -0700
Subject: [PATCH 34/64] fix: [M3-8739] - Fix MSW 2.0 initial mock store and
support ticket seeder bugs (#11090)
* Fix the bug in initial mock store creation
* Fix bug where support ticket seeds aren't removed on uncheck
* Added changeset: Fix MSW 2.0 initial mock store and support ticket seeder bugs
* Fix conditional logic
* Update removeSeeds comment to clarify use
---
.../.changeset/pr-11090-tech-stories-1728605016946.md | 5 +++++
packages/manager/src/mocks/mockState.ts | 9 ++++++++-
packages/manager/src/mocks/presets/crud/seeds/utils.ts | 4 ++++
3 files changed, 17 insertions(+), 1 deletion(-)
create mode 100644 packages/manager/.changeset/pr-11090-tech-stories-1728605016946.md
diff --git a/packages/manager/.changeset/pr-11090-tech-stories-1728605016946.md b/packages/manager/.changeset/pr-11090-tech-stories-1728605016946.md
new file mode 100644
index 00000000000..313b634e115
--- /dev/null
+++ b/packages/manager/.changeset/pr-11090-tech-stories-1728605016946.md
@@ -0,0 +1,5 @@
+---
+"@linode/manager": Tech Stories
+---
+
+Fix MSW 2.0 initial mock store and support ticket seeder bugs ([#11090](https://github.com/linode/manager/pull/11090))
diff --git a/packages/manager/src/mocks/mockState.ts b/packages/manager/src/mocks/mockState.ts
index 3cbf770964f..d4aa0d3a905 100644
--- a/packages/manager/src/mocks/mockState.ts
+++ b/packages/manager/src/mocks/mockState.ts
@@ -44,7 +44,14 @@ export const createInitialMockStore = async (): Promise => {
const mockState = await mswDB.getStore('mockState');
if (mockState) {
- return mockState;
+ const mockStateKeys = Object.keys(mockState);
+ const emptyStoreKeys = Object.keys(emptyStore);
+
+ // Return the existing mockState if it includes all keys from the empty store;
+ // else, discard the existing mockState because we've introduced new values.
+ if (emptyStoreKeys.every((key) => mockStateKeys.includes(key))) {
+ return mockState;
+ }
}
return emptyStore;
diff --git a/packages/manager/src/mocks/presets/crud/seeds/utils.ts b/packages/manager/src/mocks/presets/crud/seeds/utils.ts
index 348c6dd6c23..5b77c2d2781 100644
--- a/packages/manager/src/mocks/presets/crud/seeds/utils.ts
+++ b/packages/manager/src/mocks/presets/crud/seeds/utils.ts
@@ -5,6 +5,7 @@ import type { MockSeeder, MockState } from 'src/mocks/types';
/**
* Removes the seeds from the database.
+ * This function is called upon unchecking an individual seeder in the MSW.
*
* @param seederId - The ID of the seeder to remove.
*
@@ -22,6 +23,9 @@ export const removeSeeds = async (seederId: MockSeeder['id']) => {
case 'volumes:crud':
await mswDB.deleteAll('volumes', mockState, 'seedState');
break;
+ case 'support-tickets:crud':
+ await mswDB.deleteAll('supportTickets', mockState, 'seedState');
+ break;
default:
break;
}
From 875a0b0adc4422525cd27530ff6347f45071ddea Mon Sep 17 00:00:00 2001
From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com>
Date: Thu, 17 Oct 2024 15:33:21 -0400
Subject: [PATCH 35/64] chore: Clean up `REACT_APP_LKE_HIGH_AVAILABILITY_PRICE`
from `.env.example` (#11117)
* remove `REACT_APP_LKE_HIGH_AVAILABILITY_PRICE` from env example
* add changeset
---------
Co-authored-by: Banks Nussman
---
.../.changeset/pr-11117-tech-stories-1729171591044.md | 5 +++++
packages/manager/.env.example | 4 ----
2 files changed, 5 insertions(+), 4 deletions(-)
create mode 100644 packages/manager/.changeset/pr-11117-tech-stories-1729171591044.md
diff --git a/packages/manager/.changeset/pr-11117-tech-stories-1729171591044.md b/packages/manager/.changeset/pr-11117-tech-stories-1729171591044.md
new file mode 100644
index 00000000000..0d65405ab62
--- /dev/null
+++ b/packages/manager/.changeset/pr-11117-tech-stories-1729171591044.md
@@ -0,0 +1,5 @@
+---
+"@linode/manager": Tech Stories
+---
+
+Clean up `REACT_APP_LKE_HIGH_AVAILABILITY_PRICE` from `.env.example` ([#11117](https://github.com/linode/manager/pull/11117))
diff --git a/packages/manager/.env.example b/packages/manager/.env.example
index 1eede53ac4f..e5fbc51ed82 100644
--- a/packages/manager/.env.example
+++ b/packages/manager/.env.example
@@ -9,8 +9,6 @@ REACT_APP_API_ROOT='https://api.linode.com/v4'
# REACT_APP_CLIENT_ID='UPDATE_WITH_YOUR_ID'
REACT_APP_APP_ROOT='http://localhost:3000'
-REACT_APP_LKE_HIGH_AVAILABILITY_PRICE='60'
-
##################################
# Optional:
##################################
@@ -64,5 +62,3 @@ REACT_APP_LKE_HIGH_AVAILABILITY_PRICE='60'
# E2E TESTS REMOVE ALL OF YOUR DATA AND RESOURCES
# INCLUDING LINODES,VOLUMES,DOMAINS,NODEBALANCERS
#MANAGER_OAUTH='YOUR_OATH_TOKEN'
-
-
From 6ccccdbbd22003e6ec52062665b575d3f6e3f14a Mon Sep 17 00:00:00 2001
From: jdamore-linode <97627410+jdamore-linode@users.noreply.github.com>
Date: Thu, 17 Oct 2024 15:43:36 -0400
Subject: [PATCH 36/64] test: [M3-8734] - Reduce Linode rebuild test flakiness
(#11119)
* Address test flake by waiting for Image data before interacting with autocomplete
* Add changeset
---------
Co-authored-by: Joe D'Amore
---
packages/manager/.changeset/pr-11119-tests-1729169604255.md | 5 +++++
.../manager/cypress/e2e/core/linodes/rebuild-linode.spec.ts | 4 +++-
2 files changed, 8 insertions(+), 1 deletion(-)
create mode 100644 packages/manager/.changeset/pr-11119-tests-1729169604255.md
diff --git a/packages/manager/.changeset/pr-11119-tests-1729169604255.md b/packages/manager/.changeset/pr-11119-tests-1729169604255.md
new file mode 100644
index 00000000000..475ddd0ae43
--- /dev/null
+++ b/packages/manager/.changeset/pr-11119-tests-1729169604255.md
@@ -0,0 +1,5 @@
+---
+"@linode/manager": Tests
+---
+
+Reduce flakiness of Linode rebuild test ([#11119](https://github.com/linode/manager/pull/11119))
diff --git a/packages/manager/cypress/e2e/core/linodes/rebuild-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/rebuild-linode.spec.ts
index dd0314af1b0..c4b77519d83 100644
--- a/packages/manager/cypress/e2e/core/linodes/rebuild-linode.spec.ts
+++ b/packages/manager/cypress/e2e/core/linodes/rebuild-linode.spec.ts
@@ -324,7 +324,9 @@ describe('rebuild linode', () => {
.findByText('Choose an image')
.should('be.visible')
.click()
- .type(`${image}{enter}`);
+ .type(`${image}`);
+
+ ui.select.findItemByText(image).should('be.visible').click();
assertPasswordComplexity(rootPassword, 'Good');
From 17d088329199101b517ccd3bf27c0f1d32414d19 Mon Sep 17 00:00:00 2001
From: hasyed-akamai
Date: Mon, 21 Oct 2024 11:15:08 +0530
Subject: [PATCH 37/64] feat: [M3-8705] - Disable Create Longview Client button
with tooltip text on Landing Page for restricted Users. (#11108)
* feat: [M3-8705] - Disable Create Longview button with tooltip text Landing Page for restricted users.
* Added changeset: Disable Create Lonview Client button with tooltip text on Landing Page for restricted users.
* change changeset description
Co-authored-by: Purvesh Makode
---------
Co-authored-by: Purvesh Makode
---
.../.changeset/pr-11108-changed-1729059614823.md | 5 +++++
.../Longview/LongviewLanding/LongviewLanding.tsx | 14 ++++++++++++++
2 files changed, 19 insertions(+)
create mode 100644 packages/manager/.changeset/pr-11108-changed-1729059614823.md
diff --git a/packages/manager/.changeset/pr-11108-changed-1729059614823.md b/packages/manager/.changeset/pr-11108-changed-1729059614823.md
new file mode 100644
index 00000000000..9339b47b880
--- /dev/null
+++ b/packages/manager/.changeset/pr-11108-changed-1729059614823.md
@@ -0,0 +1,5 @@
+---
+"@linode/manager": Changed
+---
+
+Disable Longview 'Add Client' button with tooltip text on landing page for restricted users. ([#11108](https://github.com/linode/manager/pull/11108))
diff --git a/packages/manager/src/features/Longview/LongviewLanding/LongviewLanding.tsx b/packages/manager/src/features/Longview/LongviewLanding/LongviewLanding.tsx
index b14810020cb..138ed3d0cf4 100644
--- a/packages/manager/src/features/Longview/LongviewLanding/LongviewLanding.tsx
+++ b/packages/manager/src/features/Longview/LongviewLanding/LongviewLanding.tsx
@@ -19,6 +19,8 @@ import withLongviewClients from 'src/containers/longview.container';
import { useAPIRequest } from 'src/hooks/useAPIRequest';
import { useAccountSettings } from 'src/queries/account/settings';
import { getAPIErrorOrDefault } from 'src/utilities/errorUtils';
+import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck';
+import { getRestrictedResourceText } from 'src/features/Account/utils';
import { SubscriptionDialog } from './SubscriptionDialog';
@@ -71,6 +73,10 @@ export const LongviewLanding = (props: LongviewLandingProps) => {
},
];
+ const isLongviewCreationRestricted = useRestrictedGlobalGrantCheck({
+ globalGrantType: 'add_longview',
+ });
+
const matches = (p: string) => {
return Boolean(matchPath(p, { path: props.location.pathname }));
};
@@ -134,6 +140,14 @@ export const LongviewLanding = (props: LongviewLandingProps) => {
onButtonClick={handleAddClient}
removeCrumbX={1}
title="Longview"
+ disabledCreateButton={isLongviewCreationRestricted}
+ buttonDataAttrs={{
+ tooltipText: getRestrictedResourceText({
+ action: 'create',
+ isSingular: false,
+ resourceType: 'Longview Clients',
+ }),
+ }}
/>
Date: Mon, 21 Oct 2024 11:16:59 +0530
Subject: [PATCH 38/64] feat: [M3-8704] - Disable Create Firewalls button with
tooltip text on empty state Landing Page for restricted users. (#11093)
* feat: [M3-8704] - Disable Create Firewalls button with tooltip text on empty state Landing Page for restricted users
* Added changeset: Disable Create Firewall button with tooltip text on empty state Landing Page for restricted users
---
.../.changeset/pr-11093-fixed-1728898767762.md | 5 +++++
.../FirewallLanding/FirewallLandingEmptyState.tsx | 12 ++++++++++++
2 files changed, 17 insertions(+)
create mode 100644 packages/manager/.changeset/pr-11093-fixed-1728898767762.md
diff --git a/packages/manager/.changeset/pr-11093-fixed-1728898767762.md b/packages/manager/.changeset/pr-11093-fixed-1728898767762.md
new file mode 100644
index 00000000000..eb9f6d41006
--- /dev/null
+++ b/packages/manager/.changeset/pr-11093-fixed-1728898767762.md
@@ -0,0 +1,5 @@
+---
+"@linode/manager": Fixed
+---
+
+Disable Create Firewall button with tooltip text on empty state Landing Page for restricted users ([#11093](https://github.com/linode/manager/pull/11093))
diff --git a/packages/manager/src/features/Firewalls/FirewallLanding/FirewallLandingEmptyState.tsx b/packages/manager/src/features/Firewalls/FirewallLanding/FirewallLandingEmptyState.tsx
index 1a5ccbab7d0..7822e9e81eb 100644
--- a/packages/manager/src/features/Firewalls/FirewallLanding/FirewallLandingEmptyState.tsx
+++ b/packages/manager/src/features/Firewalls/FirewallLanding/FirewallLandingEmptyState.tsx
@@ -2,6 +2,8 @@ import * as React from 'react';
import FirewallIcon from 'src/assets/icons/entityIcons/firewall.svg';
import { ResourcesSection } from 'src/components/EmptyLandingPageResources/ResourcesSection';
+import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck';
+import { getRestrictedResourceText } from 'src/features/Account/utils';
import { sendEvent } from 'src/utilities/analytics/utils';
import {
@@ -18,11 +20,16 @@ interface Props {
export const FirewallLandingEmptyState = (props: Props) => {
const { openAddFirewallDrawer } = props;
+ const isFirewallsCreationRestricted = useRestrictedGlobalGrantCheck({
+ globalGrantType: 'add_firewalls',
+ });
+
return (
{
sendEvent({
action: 'Click:button',
@@ -31,6 +38,11 @@ export const FirewallLandingEmptyState = (props: Props) => {
});
openAddFirewallDrawer();
},
+ tooltipText: getRestrictedResourceText({
+ action: 'create',
+ isSingular: false,
+ resourceType: 'Firewalls',
+ }),
},
]}
gettingStartedGuidesData={gettingStartedGuides}
From 3eaf8c034efd0613dc6c619171ba712276dfb388 Mon Sep 17 00:00:00 2001
From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com>
Date: Mon, 21 Oct 2024 10:32:45 -0400
Subject: [PATCH 39/64] Revert "test: [M3-7863] - Use `happy-dom` instead of
`jsdom` in unit tests (#11085)" (#11128)
This reverts commit efa85802c21e63e41dbce3517826090c8486e6dd.
Co-authored-by: Banks Nussman
---
.../pr-11085-tests-1728657019139.md | 5 -
packages/manager/package.json | 2 +-
.../src/components/Avatar/Avatar.test.tsx | 6 +-
.../src/components/BetaChip/BetaChip.test.tsx | 2 +-
.../DescriptionList/DescriptionList.test.tsx | 2 +-
.../HighlightedMarkdown.test.tsx.snap | 78 +++----
.../src/components/Notice/Notice.test.tsx | 4 +-
.../manager/src/components/Tabs/Tab.test.tsx | 2 +-
.../TextTooltip/TextTooltip.test.tsx | 2 +-
.../DatabaseCreate/DatabaseCreate.test.tsx | 4 +-
.../DatabaseLanding/DatabaseLanding.test.tsx | 36 ++--
.../CreateCluster/HAControlPlane.test.tsx | 6 +-
.../Linodes/CloneLanding/Disks.test.tsx | 8 +-
.../Linodes/LinodeCreate/VPC/VPC.test.tsx | 27 ++-
.../Linodes/LinodeCreate/index.test.tsx | 4 +-
.../LinodeIPAddressRow.test.tsx | 48 ++---
.../LinodeSettings/VPCPanel.test.tsx | 10 +-
.../NodeBalancerConfigPanel.test.tsx | 9 +-
.../NodeBalancerConfigurations.test.tsx | 22 +-
.../NodeBalancerActionMenu.test.tsx | 20 +-
.../NodeBalancerTableRow.test.tsx | 12 +-
.../CreateOAuthClientDrawer.test.tsx | 8 +-
.../features/Volumes/VolumeCreate.test.tsx | 2 +-
.../src/utilities/omittedProps.test.tsx | 2 +-
packages/manager/vite.config.ts | 3 +-
.../pr-11085-tests-1728657169966.md | 5 -
.../src/components/BetaChip/BetaChip.test.tsx | 2 +-
packages/ui/vitest.config.ts | 2 +-
yarn.lock | 196 +++++++++++++++---
29 files changed, 319 insertions(+), 210 deletions(-)
delete mode 100644 packages/manager/.changeset/pr-11085-tests-1728657019139.md
delete mode 100644 packages/ui/.changeset/pr-11085-tests-1728657169966.md
diff --git a/packages/manager/.changeset/pr-11085-tests-1728657019139.md b/packages/manager/.changeset/pr-11085-tests-1728657019139.md
deleted file mode 100644
index 882e40644d2..00000000000
--- a/packages/manager/.changeset/pr-11085-tests-1728657019139.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@linode/manager": Tests
----
-
-Use `happy-dom` instead of `jsdom` in unit tests ([#11085](https://github.com/linode/manager/pull/11085))
diff --git a/packages/manager/package.json b/packages/manager/package.json
index 10248b48034..41241a56de8 100644
--- a/packages/manager/package.json
+++ b/packages/manager/package.json
@@ -195,7 +195,7 @@
"eslint-plugin-xss": "^0.1.10",
"factory.ts": "^0.5.1",
"glob": "^10.3.1",
- "happy-dom": "^15.7.4",
+ "jsdom": "^24.1.1",
"junit2json": "^3.1.4",
"lint-staged": "^15.2.9",
"mocha-junit-reporter": "^2.2.1",
diff --git a/packages/manager/src/components/Avatar/Avatar.test.tsx b/packages/manager/src/components/Avatar/Avatar.test.tsx
index e8f7ae51d3a..65e4ab1baa0 100644
--- a/packages/manager/src/components/Avatar/Avatar.test.tsx
+++ b/packages/manager/src/components/Avatar/Avatar.test.tsx
@@ -31,7 +31,7 @@ describe('Avatar', () => {
const avatarStyles = getComputedStyle(avatar);
expect(getByTestId('avatar-letter')).toHaveTextContent('M');
- expect(avatarStyles.backgroundColor).toBe('#0174bc'); // theme.color.primary.dark (#0174bc)
+ expect(avatarStyles.backgroundColor).toBe('rgb(1, 116, 188)'); // theme.color.primary.dark (#0174bc)
});
it('should render a background color from props', () => {
@@ -48,8 +48,8 @@ describe('Avatar', () => {
const avatarTextStyles = getComputedStyle(avatarText);
// Confirm background color contrasts with text color.
- expect(avatarStyles.backgroundColor).toBe('#000000'); // black
- expect(avatarTextStyles.color).toBe('#fff'); // white
+ expect(avatarStyles.backgroundColor).toBe('rgb(0, 0, 0)'); // black
+ expect(avatarTextStyles.color).toBe('rgb(255, 255, 255)'); // white
});
it('should render the first letter of username from props', async () => {
diff --git a/packages/manager/src/components/BetaChip/BetaChip.test.tsx b/packages/manager/src/components/BetaChip/BetaChip.test.tsx
index 69d7d499fe2..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: #108ad6');
+ expect(betaChip).toHaveStyle('background-color: rgb(16, 138, 214)');
});
it('triggers an onClick callback', () => {
diff --git a/packages/manager/src/components/DescriptionList/DescriptionList.test.tsx b/packages/manager/src/components/DescriptionList/DescriptionList.test.tsx
index 477d27088f0..6fc2fd2fe20 100644
--- a/packages/manager/src/components/DescriptionList/DescriptionList.test.tsx
+++ b/packages/manager/src/components/DescriptionList/DescriptionList.test.tsx
@@ -32,7 +32,7 @@ describe('Description List', () => {
it('has it title bolded', () => {
const { getByText } = renderWithTheme();
const title = getByText('Random title');
- expect(title).toHaveStyle('font-family: LatoWebBold, sans-serif');
+ expect(title).toHaveStyle('font-family: "LatoWebBold",sans-serif');
});
it('renders a column by default', () => {
diff --git a/packages/manager/src/components/HighlightedMarkdown/__snapshots__/HighlightedMarkdown.test.tsx.snap b/packages/manager/src/components/HighlightedMarkdown/__snapshots__/HighlightedMarkdown.test.tsx.snap
index 09a0177c34b..238b90d44c9 100644
--- a/packages/manager/src/components/HighlightedMarkdown/__snapshots__/HighlightedMarkdown.test.tsx.snap
+++ b/packages/manager/src/components/HighlightedMarkdown/__snapshots__/HighlightedMarkdown.test.tsx.snap
@@ -4,52 +4,52 @@ exports[`HighlightedMarkdown component > should highlight text consistently 1`]