diff --git a/packages/api-v4/.changeset/pr-11321-added-1733240671518.md b/packages/api-v4/.changeset/pr-11321-added-1733240671518.md new file mode 100644 index 00000000000..6650a12aebd --- /dev/null +++ b/packages/api-v4/.changeset/pr-11321-added-1733240671518.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Added +--- + +Types for UDP NodeBalancer support ([#11321](https://github.com/linode/manager/pull/11321)) diff --git a/packages/api-v4/.changeset/pr-11346-changed-1733145182722.md b/packages/api-v4/.changeset/pr-11346-changed-1733145182722.md new file mode 100644 index 00000000000..7f6129d47ae --- /dev/null +++ b/packages/api-v4/.changeset/pr-11346-changed-1733145182722.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Changed +--- + +Type of `AlertDefinitionType` to `'system'|'user'` ([#11346](https://github.com/linode/manager/pull/11346)) diff --git a/packages/api-v4/.changeset/pr-11347-fixed-1733327446988.md b/packages/api-v4/.changeset/pr-11347-fixed-1733327446988.md new file mode 100644 index 00000000000..d21cf788e93 --- /dev/null +++ b/packages/api-v4/.changeset/pr-11347-fixed-1733327446988.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Fixed +--- + +Nullable AccountBeta ended & description properties ([#11347](https://github.com/linode/manager/pull/11347)) diff --git a/packages/api-v4/src/account/types.ts b/packages/api-v4/src/account/types.ts index d23ded3eb0d..08b575c7606 100644 --- a/packages/api-v4/src/account/types.ts +++ b/packages/api-v4/src/account/types.ts @@ -607,8 +607,8 @@ export interface AccountBeta { label: string; started: string; id: string; - ended?: string; - description?: string; + ended: string | null; + description: string | null; /** * The datetime the account enrolled into the beta * @example 2024-10-23T14:22:29 diff --git a/packages/api-v4/src/cloudpulse/alerts.ts b/packages/api-v4/src/cloudpulse/alerts.ts index 3c6f909b9db..586be5fbd20 100644 --- a/packages/api-v4/src/cloudpulse/alerts.ts +++ b/packages/api-v4/src/cloudpulse/alerts.ts @@ -1,7 +1,14 @@ import { createAlertDefinitionSchema } from '@linode/validation'; -import Request, { setURL, setMethod, setData } from '../request'; +import Request, { + setURL, + setMethod, + setData, + setParams, + setXFilter, +} from '../request'; import { Alert, AlertServiceType, CreateAlertDefinitionPayload } from './types'; -import { BETA_API_ROOT as API_ROOT } from 'src/constants'; +import { BETA_API_ROOT as API_ROOT } from '../constants'; +import { Params, Filter, ResourcePage } from '../types'; export const createAlertDefinition = ( data: CreateAlertDefinitionPayload, @@ -16,3 +23,11 @@ export const createAlertDefinition = ( setMethod('POST'), setData(data, createAlertDefinitionSchema) ); + +export const getAlertDefinitions = (params?: Params, filters?: Filter) => + Request>( + setURL(`${API_ROOT}/monitor/alert-definitions`), + setMethod('GET'), + setParams(params), + setXFilter(filters) + ); diff --git a/packages/api-v4/src/cloudpulse/types.ts b/packages/api-v4/src/cloudpulse/types.ts index 765a1d71ba1..05224db62bd 100644 --- a/packages/api-v4/src/cloudpulse/types.ts +++ b/packages/api-v4/src/cloudpulse/types.ts @@ -3,8 +3,19 @@ export type MetricAggregationType = 'avg' | 'sum' | 'min' | 'max' | 'count'; export type MetricOperatorType = 'eq' | 'gt' | 'lt' | 'gte' | 'lte'; export type AlertServiceType = 'linode' | 'dbaas'; type DimensionFilterOperatorType = 'eq' | 'neq' | 'startswith' | 'endswith'; -export type AlertDefinitionType = 'default' | 'custom'; +export type AlertDefinitionType = 'system' | 'user'; export type AlertStatusType = 'enabled' | 'disabled'; +export type CriteriaConditionType = 'ALL'; +export type MetricUnitType = + | 'number' + | 'byte' + | 'second' + | 'percent' + | 'bit_per_second' + | 'millisecond' + | 'KB' + | 'MB' + | 'GB'; export interface Dashboard { id: number; label: string; @@ -142,23 +153,30 @@ export interface ServiceTypesList { export interface CreateAlertDefinitionPayload { label: string; + tags?: string[]; description?: string; entity_ids?: string[]; severity: AlertSeverityType; rule_criteria: { rules: MetricCriteria[]; }; - trigger_condition: TriggerCondition; + trigger_conditions: TriggerCondition; channel_ids: number[]; } export interface MetricCriteria { metric: string; aggregation_type: MetricAggregationType; operator: MetricOperatorType; + threshold: number; dimension_filters: DimensionFilter[]; } +export interface AlertDefinitionMetricCriteria extends MetricCriteria { + unit: string; + label: string; +} export interface DimensionFilter { + label: string; dimension_label: string; operator: DimensionFilterOperatorType; value: string; @@ -168,10 +186,12 @@ export interface TriggerCondition { polling_interval_seconds: number; evaluation_period_seconds: number; trigger_occurrences: number; + criteria_condition: CriteriaConditionType; } export interface Alert { id: number; label: string; + tags: string[]; description: string; has_more_resources: boolean; status: AlertStatusType; @@ -180,9 +200,9 @@ export interface Alert { service_type: AlertServiceType; entity_ids: string[]; rule_criteria: { - rules: MetricCriteria[]; + rules: AlertDefinitionMetricCriteria[]; }; - triggerCondition: TriggerCondition; + trigger_conditions: TriggerCondition; channels: { id: string; label: string; diff --git a/packages/api-v4/src/nodebalancers/types.ts b/packages/api-v4/src/nodebalancers/types.ts index f8e93154b0b..68f89c7ac32 100644 --- a/packages/api-v4/src/nodebalancers/types.ts +++ b/packages/api-v4/src/nodebalancers/types.ts @@ -1,8 +1,31 @@ +type TCPAlgorithm = 'roundrobin' | 'leastconn' | 'source'; +type UDPAlgorithm = 'roundrobin' | 'leastconn' | 'ring_hash'; + +export type Algorithm = TCPAlgorithm | UDPAlgorithm; + +export type Protocol = 'http' | 'https' | 'tcp' | 'udp'; + +type TCPStickiness = 'none' | 'table' | 'http_cookie'; +type UDPStickiness = 'none' | 'session' | 'source_ip'; + +export type Stickiness = TCPStickiness | UDPStickiness; + export interface NodeBalancer { id: number; label: string; hostname: string; + /** + * Maximum number of new TCP connections that a client (identified by a specific source IP) + * is allowed to initiate every second. + */ client_conn_throttle: number; + /** + * Maximum number of new UDP sessions that a client (identified by a specific source IP) + * is allowed to initiate every second. + * + * @todo Remove optionality once UDP support is live + */ + client_udp_sess_throttle?: number; region: string; ipv4: string; ipv6: null | string; @@ -31,11 +54,15 @@ export interface BalancerTransfer { total: number; } +/** + * 'none' is reserved for nodes used in UDP configurations. They don't support different modes. + */ export type NodeBalancerConfigNodeMode = | 'accept' | 'reject' | 'backup' - | 'drain'; + | 'drain' + | 'none'; export interface NodeBalancerConfig { id: number; @@ -44,22 +71,31 @@ export interface NodeBalancerConfig { check_passive: boolean; ssl_cert: string; nodes_status: NodesStatus; - protocol: 'http' | 'https' | 'tcp'; + protocol: Protocol; ssl_commonname: string; check_interval: number; check_attempts: number; check_timeout: number; check_body: string; check_path: string; + /** + * @todo Remove optionality once UDP support is live + */ + udp_check_port?: number; + /** + * @readonly This is returned by the API but *not* editable + * @todo Remove optionality once UDP support is live + * @default 16 + */ + udp_session_timeout?: number; proxy_protocol: NodeBalancerProxyProtocol; check: 'none' | 'connection' | 'http' | 'http_body'; ssl_key: string; - stickiness: 'none' | 'table' | 'http_cookie'; - algorithm: 'roundrobin' | 'leastconn' | 'source'; + stickiness: Stickiness; + algorithm: Algorithm; ssl_fingerprint: string; cipher_suite: 'recommended' | 'legacy'; nodes: NodeBalancerConfigNode[]; - modifyStatus?: 'new'; } export type NodeBalancerProxyProtocol = 'none' | 'v1' | 'v2'; @@ -82,9 +118,36 @@ export interface NodeBalancerStats { export interface CreateNodeBalancerConfig { port?: number; - protocol?: 'http' | 'https' | 'tcp'; - algorithm?: 'roundrobin' | 'leastconn' | 'source'; - stickiness?: 'none' | 'table' | 'http_cookie'; + /** + * If `udp` is chosen: + * - `check_passive` must be `false` or unset + * - `proxy_protocol` must be `none` or unset + * - The various SSL related fields like `ssl_cert`, `ssl_key`, `cipher_suite_recommended` should not be set + */ + protocol?: Protocol; + /** + * @default "none" + */ + proxy_protocol?: NodeBalancerProxyProtocol; + /** + * The algorithm for this configuration. + * + * TCP and HTTP support `roundrobin`, `leastconn`, and `source` + * UDP supports `roundrobin`, `leastconn`, and `ring_hash` + * + * @default roundrobin + */ + algorithm?: Algorithm; + /** + * Session stickiness for this configuration. + * + * TCP and HTTP support `none`, `table`, and `http_cookie` + * UDP supports `none`, `session`, and `source_ip` + * + * @default `session` for UDP + * @default `none` for TCP and HTTP + */ + stickiness?: Stickiness; check?: 'none' | 'connection' | 'http' | 'http_body'; check_interval?: number; check_timeout?: number; @@ -92,6 +155,11 @@ export interface CreateNodeBalancerConfig { check_path?: string; check_body?: string; check_passive?: boolean; + /** + * Must be between 1 and 65535 + * @default 80 + */ + udp_check_port?: number; cipher_suite?: 'recommended' | 'legacy'; ssl_cert?: string; ssl_key?: string; @@ -102,6 +170,9 @@ export type UpdateNodeBalancerConfig = CreateNodeBalancerConfig; export interface CreateNodeBalancerConfigNode { address: string; label: string; + /** + * Should not be specified when creating a node used on a UDP configuration + */ mode?: NodeBalancerConfigNodeMode; weight?: number; } @@ -126,8 +197,21 @@ export interface NodeBalancerConfigNodeWithPort extends NodeBalancerConfigNode { export interface CreateNodeBalancerPayload { region?: string; label?: string; + /** + * The connections per second throttle for TCP and HTTP connections + * + * Must be between 0 and 20. Set to 0 to disable throttling. + * @default 0 + */ client_conn_throttle?: number; - configs: any; + /** + * The connections per second throttle for UDP sessions + * + * Must be between 0 and 20. Set to 0 to disable throttling. + * @default 0 + */ + client_udp_sess_throttle?: number; + configs: CreateNodeBalancerConfig[]; firewall_id?: number; tags?: string[]; } diff --git a/packages/manager/.changeset/pr-11154-tech-stories-1730404184309.md b/packages/manager/.changeset/pr-11154-tech-stories-1730404184309.md new file mode 100644 index 00000000000..f1920ce7895 --- /dev/null +++ b/packages/manager/.changeset/pr-11154-tech-stories-1730404184309.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Migrate `/volumes` to Tanstack router ([#11154](https://github.com/linode/manager/pull/11154)) diff --git a/packages/manager/.changeset/pr-11321-tech-stories-1733243149867.md b/packages/manager/.changeset/pr-11321-tech-stories-1733243149867.md new file mode 100644 index 00000000000..9350b678a28 --- /dev/null +++ b/packages/manager/.changeset/pr-11321-tech-stories-1733243149867.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Clean up NodeBalancer related types ([#11321](https://github.com/linode/manager/pull/11321)) diff --git a/packages/manager/.changeset/pr-11346-added-1733145106911.md b/packages/manager/.changeset/pr-11346-added-1733145106911.md new file mode 100644 index 00000000000..6b4d5327cef --- /dev/null +++ b/packages/manager/.changeset/pr-11346-added-1733145106911.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Added +--- + +AlertListing component and AlertTableRow component with Unit Tests ([#11346](https://github.com/linode/manager/pull/11346)) diff --git a/packages/manager/.changeset/pr-11347-tests-1733170030162.md b/packages/manager/.changeset/pr-11347-tests-1733170030162.md new file mode 100644 index 00000000000..261f1ae08b3 --- /dev/null +++ b/packages/manager/.changeset/pr-11347-tests-1733170030162.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Mock LKE creation flow + APL coverage ([#11347](https://github.com/linode/manager/pull/11347)) diff --git a/packages/manager/.changeset/pr-11354-upcoming-features-1733237771685.md b/packages/manager/.changeset/pr-11354-upcoming-features-1733237771685.md new file mode 100644 index 00000000000..2d5d7973305 --- /dev/null +++ b/packages/manager/.changeset/pr-11354-upcoming-features-1733237771685.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Add `CloudPulseAppliedFilter` and `CloudPulseAppliedFilterRenderer` components, update filter change handler function to add another parameter `label` ([#11354](https://github.com/linode/manager/pull/11354)) diff --git a/packages/manager/.changeset/pr-11379-fixed-1733476117431.md b/packages/manager/.changeset/pr-11379-fixed-1733476117431.md new file mode 100644 index 00000000000..315aec9540b --- /dev/null +++ b/packages/manager/.changeset/pr-11379-fixed-1733476117431.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Disallow word-break in billing contact info ([#11379](https://github.com/linode/manager/pull/11379)) diff --git a/packages/manager/.eslintrc.cjs b/packages/manager/.eslintrc.cjs index 99870119ff7..3e3b3de5a66 100644 --- a/packages/manager/.eslintrc.cjs +++ b/packages/manager/.eslintrc.cjs @@ -89,10 +89,14 @@ module.exports = { { files: [ // for each new features added to the migration router, add its directory here - 'src/features/Betas/*', + 'src/features/Betas/**/*', + 'src/features/Volumes/**/*', ], rules: { 'no-restricted-imports': [ + // This needs to remain an error however trying to link to a feature that is not yet migrated will break the router + // For those cases react-router-dom history.push is still needed + // using `eslint-disable-next-line no-restricted-imports` can help bypass those imports 'error', { paths: [ diff --git a/packages/manager/.storybook/preview.tsx b/packages/manager/.storybook/preview.tsx index 55197641f90..6de6f42f5ce 100644 --- a/packages/manager/.storybook/preview.tsx +++ b/packages/manager/.storybook/preview.tsx @@ -9,7 +9,10 @@ import { Controls, Stories, } from '@storybook/blocks'; -import { wrapWithTheme } from '../src/utilities/testHelpers'; +import { + wrapWithTheme, + wrapWithThemeAndRouter, +} from '../src/utilities/testHelpers'; import { useDarkMode } from 'storybook-dark-mode'; import { DocsContainer as BaseContainer } from '@storybook/addon-docs'; import { themes } from '@storybook/theming'; @@ -42,9 +45,13 @@ export const DocsContainer = ({ children, context }) => { const preview: Preview = { decorators: [ - (Story) => { + (Story, context) => { const isDark = useDarkMode(); - return wrapWithTheme(, { theme: isDark ? 'dark' : 'light' }); + return context.parameters.tanStackRouter + ? wrapWithThemeAndRouter(, { + theme: isDark ? 'dark' : 'light', + }) + : wrapWithTheme(, { theme: isDark ? 'dark' : 'light' }); }, ], loaders: [ diff --git a/packages/manager/cypress/e2e/core/billing/smoke-billing-activity.spec.ts b/packages/manager/cypress/e2e/core/billing/smoke-billing-activity.spec.ts index 4780133b78f..c23a44f3d55 100644 --- a/packages/manager/cypress/e2e/core/billing/smoke-billing-activity.spec.ts +++ b/packages/manager/cypress/e2e/core/billing/smoke-billing-activity.spec.ts @@ -353,7 +353,6 @@ describe('Billing Activity Feed', () => { // reflect each timezone. timeZonesList.forEach((timezone) => { const timezoneId = timezone.key; - const humanReadable = timezone.human; mockUpdateProfile({ ...mockProfile, @@ -367,7 +366,7 @@ describe('Billing Activity Feed', () => { cy.findByText('Timezone') .should('be.visible') .click() - .type(`${humanReadable}{enter}`); + .type('Pacific Time - Los Angeles{enter}'); ui.button .findByTitle('Update Timezone') diff --git a/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts b/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts index 6c93301d30b..cd9c4c9b00d 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts @@ -3,32 +3,35 @@ */ import { accountFactory, + dedicatedTypeFactory, kubernetesClusterFactory, kubernetesControlPlaneACLFactory, kubernetesControlPlaneACLOptionsFactory, linodeTypeFactory, regionFactory, + nodePoolFactory, + kubeLinodeFactory, } from 'src/factories'; import { mockCreateCluster, mockGetCluster, mockCreateClusterError, mockGetControlPlaneACL, + mockGetClusterPools, + mockGetDashboardUrl, + mockGetApiEndpoints, + mockGetClusters, + mockGetLKEClusterTypes, } from 'support/intercepts/lke'; +import { mockGetAccountBeta } from 'support/intercepts/betas'; import { mockGetAccount } from 'support/intercepts/account'; import { mockGetRegions, mockGetRegionAvailability, } from 'support/intercepts/regions'; -import { KubernetesCluster } from '@linode/api-v4'; -import { LkePlanDescription } from 'support/api/lke'; -import { lkeClusterPlans } from 'support/constants/lke'; -import { chooseRegion, getRegionById } from 'support/util/regions'; -import { interceptCreateCluster } from 'support/intercepts/lke'; +import { getRegionById } from 'support/util/regions'; import { ui } from 'support/ui'; import { randomLabel, randomNumber, randomItem } from 'support/util/random'; -import { cleanUp } from 'support/util/cleanup'; -import { authenticate } from 'support/api/authentication'; import { dcPricingLkeCheckoutSummaryPlaceholder, dcPricingLkeHAPlaceholder, @@ -40,77 +43,141 @@ import { } from 'support/constants/dc-specific-pricing'; import { mockGetLinodeTypes } from 'support/intercepts/linodes'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { chooseRegion } from 'support/util/regions'; +import { getTotalClusterMemoryCPUAndStorage } from 'src/features/Kubernetes/kubeUtils'; +import { getTotalClusterPrice } from 'src/utilities/pricing/kubernetes'; -/** - * Gets the label for an LKE plan as shown in creation plan table. - * - * @param clusterPlan - Cluster plan from which to determine Cloud Manager LKE plan name. - * - * @returns LKE plan name for plan. - */ -const getLkePlanName = (clusterPlan: LkePlanDescription) => { - return `${clusterPlan.type} ${clusterPlan.size} GB`; -}; +import type { ExtendedType } from 'src/utilities/extendType'; +import type { LkePlanDescription } from 'support/api/lke'; +import { PriceType } from '@linode/api-v4/lib/types'; -/** - * Gets the label for an LKE plan as shown in the creation checkout bar. - * - * @param clusterPlan - Cluster plan from which to determine Cloud Manager LKE checkout name. - * - * @returns LKE checkout plan name for plan. - */ -const getLkePlanCheckoutName = (clusterPlan: LkePlanDescription) => { - return `${clusterPlan.type} ${clusterPlan.size} GB Plan`; -}; +const dedicatedNodeCount = 4; +const nanodeNodeCount = 3; -/** - * Returns each plan in an array which is similar to the given plan. - * - * Plans are considered similar if they have identical type and size. - * - * @param clusterPlan - Cluster plan with which to compare similarity. - * @param clusterPlans - Array from which to find similar cluster plans. - * - * @returns Array of similar cluster plans. - */ -const getSimilarPlans = ( - clusterPlan: LkePlanDescription, - clusterPlans: LkePlanDescription[] -) => { - return clusterPlans.filter((otherClusterPlan: any) => { - return ( - clusterPlan.type === otherClusterPlan.type && - clusterPlan.size === otherClusterPlan.size - ); - }); -}; +const clusterRegion = chooseRegion({ + capabilities: ['Kubernetes'], +}); +const dedicatedCpuPool = nodePoolFactory.build({ + count: dedicatedNodeCount, + nodes: kubeLinodeFactory.buildList(dedicatedNodeCount), + type: 'g6-dedicated-2', +}); +const nanodeMemoryPool = nodePoolFactory.build({ + count: nanodeNodeCount, + nodes: kubeLinodeFactory.buildList(nanodeNodeCount), + type: 'g6-nanode-1', +}); +const dedicatedType = dedicatedTypeFactory.build({ + disk: 81920, + id: 'g6-dedicated-2', + label: 'Dedicated 4 GB', + memory: 4096, + price: { + hourly: 0.054, + monthly: 36.0, + }, + region_prices: dcPricingMockLinodeTypes.find( + (type) => type.id === 'g6-dedicated-2' + )?.region_prices, + vcpus: 2, +}) as ExtendedType; +const nanodeType = linodeTypeFactory.build({ + disk: 25600, + id: 'g6-nanode-1', + label: 'Linode 2 GB', + memory: 2048, + price: { + hourly: 0.0075, + monthly: 5.0, + }, + region_prices: dcPricingMockLinodeTypes.find( + (type) => type.id === 'g6-nanode-1' + )?.region_prices, + vcpus: 1, +}) as ExtendedType; +const mockedLKEClusterPrices: PriceType[] = [ + { + id: 'lke-sa', + label: 'LKE Standard Availability', + price: { + hourly: 0.0, + monthly: 0.0, + }, + region_prices: [], + transfer: 0, + }, +]; +const mockedLKEHAClusterPrices: PriceType[] = [ + { + id: 'lke-ha', + label: 'LKE High Availability', + price: { + hourly: 0.09, + monthly: 60.0, + }, + region_prices: [], + transfer: 0, + }, +]; -authenticate(); describe('LKE Cluster Creation', () => { - before(() => { - cleanUp(['linodes', 'lke-clusters']); - }); - /* * - Confirms that users can create a cluster by completing the LKE create form. * - Confirms that LKE cluster is created. * - Confirms that user is redirected to new LKE cluster summary page. + * - Confirms that correct information is shown on the LKE cluster summary page * - Confirms that new LKE cluster summary page shows expected node pools. * - Confirms that new LKE cluster is shown on LKE clusters landing page. - * - Confirms that correct information is shown on the LKE cluster summary page */ - it('can create an LKE cluster', () => { - cy.tag('method:e2e', 'purpose:dcTesting'); - const clusterLabel = randomLabel(); - const clusterRegion = chooseRegion({ - capabilities: ['Kubernetes'], - }); - const clusterVersion = '1.27'; - const clusterPlans = new Array(2) - .fill(null) - .map(() => randomItem(lkeClusterPlans)); + const clusterLabel = randomLabel(); + const clusterVersion = '1.27'; + const clusterPlans: LkePlanDescription[] = [ + { + nodeCount: dedicatedNodeCount, + planName: 'Dedicated 4 GB', + size: 4, + tab: 'Dedicated CPU', + type: 'dedicated', + }, + { + nodeCount: nanodeNodeCount, + planName: 'Linode 2 GB', + size: 24, + tab: 'Shared CPU', + type: 'nanode', + }, + ]; + const mockedLKECluster = kubernetesClusterFactory.build({ + label: clusterLabel, + region: clusterRegion.id, + }); + const mockedLKEClusterPools = [nanodeMemoryPool, dedicatedCpuPool]; + const mockedLKEClusterControlPlane = kubernetesControlPlaneACLFactory.build(); + const mockedLKEClusterTypes = [dedicatedType, nanodeType]; + const { + CPU: totalCpu, + RAM: totalMemory, + Storage: totalStorage, + } = getTotalClusterMemoryCPUAndStorage( + mockedLKEClusterPools, + mockedLKEClusterTypes + ); - interceptCreateCluster().as('createCluster'); + it('can create an LKE cluster', () => { + mockCreateCluster(mockedLKECluster).as('createCluster'); + mockGetCluster(mockedLKECluster).as('getCluster'); + mockGetClusterPools(mockedLKECluster.id, mockedLKEClusterPools).as( + 'getClusterPools' + ); + mockGetDashboardUrl(mockedLKECluster.id).as('getDashboardUrl'); + mockGetControlPlaneACL( + mockedLKECluster.id, + mockedLKEClusterControlPlane + ).as('getControlPlaneACL'); + mockGetApiEndpoints(mockedLKECluster.id).as('getApiEndpoints'); + mockGetLinodeTypes(mockedLKEClusterTypes).as('getLinodeTypes'); + mockGetLKEClusterTypes(mockedLKEClusterPrices).as('getLKEClusterTypes'); + mockGetClusters([mockedLKECluster]).as('getClusters'); cy.visitWithLogin('/kubernetes/clusters'); @@ -123,44 +190,40 @@ describe('LKE Cluster Creation', () => { cy.url().should('endWith', '/kubernetes/create'); // Fill out LKE creation form label, region, and Kubernetes version fields. - cy.findByLabelText('Cluster Label') + cy.get('[data-qa-textfield-label="Cluster Label"]') .should('be.visible') - .click() - .type(`${clusterLabel}{enter}`); + .click(); + cy.focused().type(`${clusterLabel}{enter}`); ui.regionSelect.find().click().type(`${clusterRegion.label}{enter}`); - cy.findByText('Kubernetes Version') - .should('be.visible') + ui.autocomplete + .findByLabel('Kubernetes Version') .click() .type(`${clusterVersion}{enter}`); - cy.get('[data-testid="ha-radio-button-yes"]').should('be.visible').click(); + cy.get('[data-testid="ha-radio-button-no"]').should('be.visible').click(); - let totalCpu = 0; - let totalMemory = 0; - let totalStorage = 0; let monthPrice = 0; - // Add a node pool for each randomly selected plan, and confirm that the + // Add a node pool for each selected plan, and confirm that the // selected node pool plan is added to the checkout bar. clusterPlans.forEach((clusterPlan) => { - const nodeCount = randomNumber(1, 3); - const planName = getLkePlanName(clusterPlan); - const checkoutName = getLkePlanCheckoutName(clusterPlan); + const nodeCount = clusterPlan.nodeCount; + const planName = clusterPlan.planName; - cy.log(`Adding ${nodeCount}x ${getLkePlanName(clusterPlan)} node(s)`); + cy.log(`Adding ${nodeCount}x ${planName} node(s)`); // Click the right tab for the plan, and add a node pool with the desired // number of nodes. cy.findByText(clusterPlan.tab).should('be.visible').click(); + const quantityInput = '[name="Quantity"]'; cy.findByText(planName) .should('be.visible') .closest('tr') .within(() => { - cy.get('[name="Quantity"]') - .should('be.visible') - .click() - .type(`{selectall}${nodeCount}`); + cy.get(quantityInput).should('be.visible'); + cy.get(quantityInput).click(); + cy.get(quantityInput).type(`{selectall}${nodeCount}`); ui.button .findByTitle('Add') @@ -176,31 +239,16 @@ describe('LKE Cluster Creation', () => { // It's possible that multiple pools of the same type get added. // We're taking a naive approach here by confirming that at least one // instance of the pool appears in the checkout bar. - cy.findAllByText(checkoutName).first().should('be.visible'); + cy.findAllByText(`${planName} Plan`).first().should('be.visible'); }); - // Expected information on the LKE cluster summary page. - if (clusterPlan.size == 2 && clusterPlan.type == 'Linode') { - totalCpu = totalCpu + nodeCount * 1; - totalMemory = totalMemory + nodeCount * 2; - totalStorage = totalStorage + nodeCount * 50; - monthPrice = monthPrice + nodeCount * 12; - } - if (clusterPlan.size == 4 && clusterPlan.type == 'Linode') { - totalCpu = totalCpu + nodeCount * 2; - totalMemory = totalMemory + nodeCount * 4; - totalStorage = totalStorage + nodeCount * 80; - monthPrice = monthPrice + nodeCount * 24; - } - if (clusterPlan.size == 4 && clusterPlan.type == 'Dedicated') { - totalCpu = totalCpu + nodeCount * 2; - totalMemory = totalMemory + nodeCount * 4; - totalStorage = totalStorage + nodeCount * 80; - monthPrice = monthPrice + nodeCount * 36; - } + monthPrice = getTotalClusterPrice({ + highAvailabilityPrice: 0, + pools: [nanodeMemoryPool, dedicatedCpuPool], + region: clusterRegion.id, + types: mockedLKEClusterTypes, + }); }); - // $60.00/month for enabling HA control plane - const totalPrice = monthPrice + 60; // Create LKE cluster. cy.get('[data-testid="kube-checkout-bar"]') @@ -215,31 +263,40 @@ describe('LKE Cluster Creation', () => { // Wait for LKE cluster to be created and confirm that we are redirected // to the cluster summary page. - cy.wait('@createCluster').then(({ response }) => { - if (!response) { - throw new Error( - `Error creating LKE cluster ${clusterLabel}; API request failed` - ); - } - const cluster: KubernetesCluster = response.body; - cy.url().should('endWith', `/kubernetes/clusters/${cluster.id}/summary`); - }); + cy.wait([ + '@getCluster', + '@getClusterPools', + '@createCluster', + '@getLKEClusterTypes', + '@getLinodeTypes', + '@getDashboardUrl', + '@getControlPlaneACL', + '@getApiEndpoints', + ]); + cy.url().should( + 'endWith', + `/kubernetes/clusters/${mockedLKECluster.id}/summary` + ); // Confirm that each node pool is shown. clusterPlans.forEach((clusterPlan) => { // Because multiple node pools may have identical labels, we figure out // how many identical labels for each plan will exist and confirm that // the expected number is present. - const nodePoolLabel = getLkePlanName(clusterPlan); + const nodePoolLabel = clusterPlan.planName; const similarNodePoolCount = getSimilarPlans(clusterPlan, clusterPlans) .length; - //Confirm that the cluster created with the expected parameters. + // Confirm that the cluster created with the expected parameters. cy.findAllByText(`${clusterRegion.label}`).should('be.visible'); cy.findAllByText(`${totalCpu} CPU Cores`).should('be.visible'); - cy.findAllByText(`${totalMemory} GB RAM`).should('be.visible'); - cy.findAllByText(`${totalStorage} GB Storage`).should('be.visible'); - cy.findAllByText(`$${totalPrice}.00/month`).should('be.visible'); + cy.findAllByText(`${Math.round(totalStorage / 1024)} GB Storage`).should( + 'be.visible' + ); + cy.findAllByText(`${Math.round(totalMemory / 1024)} GB RAM`).should( + 'be.visible' + ); + cy.findAllByText(`$${monthPrice.toFixed(2)}/month`).should('be.visible'); cy.contains('Kubernetes API Endpoint').should('be.visible'); cy.contains('linodelke.net:443').should('be.visible'); @@ -249,26 +306,169 @@ describe('LKE Cluster Creation', () => { .should('be.visible'); }); - // Navigate to the LKE landing page and confirm that new cluster is shown. ui.breadcrumb .find() .should('be.visible') .within(() => { cy.findByText(clusterLabel).should('be.visible'); + }); + }); +}); + +describe('LKE Cluster Creation with APL enabled', () => { + it('can create an LKE cluster with APL flag enabled', () => { + const clusterLabel = randomLabel(); + const mockedLKECluster = kubernetesClusterFactory.build({ + label: clusterLabel, + region: clusterRegion.id, + }); + const mockedLKEClusterPools = [nanodeMemoryPool, dedicatedCpuPool]; + const mockedLKEClusterControlPlane = kubernetesControlPlaneACLFactory.build(); + const dedicated4Type = dedicatedTypeFactory.build({ + disk: 163840, + id: 'g6-dedicated-4', + label: 'Dedicated 8GB', + memory: 8192, + price: { + hourly: 0.108, + monthly: 72.0, + }, + region_prices: dcPricingMockLinodeTypes.find( + (type) => type.id === 'g6-dedicated-8' + )?.region_prices, + vcpus: 4, + }); + const dedicated8Type = dedicatedTypeFactory.build({ + disk: 327680, + id: 'g6-dedicated-8', + label: 'Dedicated 16GB', + memory: 16384, + price: { + hourly: 0.216, + monthly: 144.0, + }, + region_prices: dcPricingMockLinodeTypes.find( + (type) => type.id === 'g6-dedicated-8' + )?.region_prices, + vcpus: 8, + }); + const mockedLKEClusterTypes = [ + dedicatedType, + dedicated4Type, + dedicated8Type, + nanodeType, + ]; + mockAppendFeatureFlags({ + apl: { + enabled: true, + }, + }).as('getFeatureFlags'); + mockGetAccountBeta({ + id: 'apl', + label: 'Akamai App Platform Beta', + enrolled: '2024-11-04T21:39:41', + description: + 'Akamai App Platform is a platform that combines developer and operations-centric tools, automation and self-service to streamline the application lifecycle when using Kubernetes. This process will pre-register you for an upcoming beta.', + started: '2024-10-31T18:00:00', + ended: null, + }).as('getAccountBeta'); + mockCreateCluster(mockedLKECluster).as('createCluster'); + mockGetCluster(mockedLKECluster).as('getCluster'); + mockGetClusterPools(mockedLKECluster.id, mockedLKEClusterPools).as( + 'getClusterPools' + ); + mockGetDashboardUrl(mockedLKECluster.id).as('getDashboardUrl'); + mockGetControlPlaneACL( + mockedLKECluster.id, + mockedLKEClusterControlPlane + ).as('getControlPlaneACL'); + mockGetLinodeTypes(mockedLKEClusterTypes).as('getLinodeTypes'); + mockGetLKEClusterTypes(mockedLKEHAClusterPrices).as('getLKEClusterTypes'); + mockGetApiEndpoints(mockedLKECluster.id).as('getApiEndpoints'); + + cy.visitWithLogin('/kubernetes/create'); + + cy.wait([ + '@getFeatureFlags', + '@getAccountBeta', + '@getLinodeTypes', + '@getLKEClusterTypes', + ]); + + // Enter cluster details + cy.get('[data-qa-textfield-label="Cluster Label"]') + .should('be.visible') + .click(); + cy.focused().type(`${clusterLabel}{enter}`); + + ui.regionSelect.find().click().type(`${clusterRegion.label}{enter}`); + + cy.findByTestId('apl-label').should('have.text', 'Akamai App Platform'); + cy.findByTestId('apl-radio-button-yes').should('be.visible').click(); + cy.findByTestId('ha-radio-button-yes').should('be.disabled'); + cy.get( + '[aria-label="Enabled by default when Akamai App Platform is enabled."]' + ).should('be.visible'); + + // Check that Shared CPU plans are disabled + ui.tabList.findTabByTitle('Shared CPU').click(); + cy.findByText( + 'Shared CPU instances are currently not available for Akamai App Platform.' + ).should('be.visible'); + cy.get('[data-qa-plan-row="Linode 2 GB"]').should('have.attr', 'disabled'); + + // Check that Dedicated CPU plans are available if greater than 8GB + ui.tabList.findTabByTitle('Dedicated CPU').click(); + cy.get('[data-qa-plan-row="Dedicated 4 GB"]').should( + 'have.attr', + 'disabled' + ); + cy.get('[data-qa-plan-row="Dedicated 8 GB"]').should( + 'not.have.attr', + 'disabled' + ); + cy.get('[data-qa-plan-row="Dedicated 16 GB"]').within(() => { + cy.get('[name="Quantity"]').click(); + cy.get('[name="Quantity"]').type('{selectall}3'); + + ui.button + .findByTitle('Add') + .should('be.visible') + .should('be.enabled') + .click(); + }); - cy.findByText('kubernetes').should('be.visible').click(); + // Check that the checkout bar displays the correct information + cy.get('[data-testid="kube-checkout-bar"]') + .should('be.visible') + .within(() => { + cy.findByText(`Dedicated 16 GB Plan`).should('be.visible'); + cy.findByText('$432.00').should('be.visible'); + cy.findByText('High Availability (HA) Control Plane').should( + 'be.visible' + ); + cy.findByText('$60.00/month').should('be.visible'); + cy.findByText('$492.00').should('be.visible'); + + ui.button + .findByTitle('Create Cluster') + .should('be.visible') + .should('be.enabled') + .click(); }); - cy.url().should('endWith', '/kubernetes/clusters'); - cy.findByText(clusterLabel).should('be.visible'); + cy.wait([ + '@createCluster', + '@getCluster', + '@getClusterPools', + '@getDashboardUrl', + '@getControlPlaneACL', + '@getApiEndpoints', + ]); }); }); describe('LKE Cluster Creation with DC-specific pricing', () => { - before(() => { - cleanUp('lke-clusters'); - }); - /* * - Confirms that DC-specific prices are present in the LKE create form. * - Confirms that pricing docs link is shown in "Region" section. @@ -279,7 +479,6 @@ describe('LKE Cluster Creation with DC-specific pricing', () => { it('can dynamically update prices when creating an LKE cluster based on region', () => { const dcSpecificPricingRegion = getRegionById('us-east'); const clusterLabel = randomLabel(); - const clusterVersion = '1.27'; const clusterPlans = new Array(2) .fill(null) .map(() => randomItem(dcPricingLkeClusterPlans)); @@ -322,18 +521,16 @@ describe('LKE Cluster Creation with DC-specific pricing', () => { .click() .type(`${clusterLabel}{enter}`); - ui.regionSelect.find().type(`${dcSpecificPricingRegion.label}{enter}`); + ui.regionSelect + .find() + .click() + .type(`${dcSpecificPricingRegion.label}{enter}`); // Confirm that HA price updates dynamically once region selection is made. cy.contains(/\$.*\/month/).should('be.visible'); cy.get('[data-testid="ha-radio-button-yes"]').should('be.visible').click(); - cy.findByText('Kubernetes Version') - .should('be.visible') - .click() - .type(`${clusterVersion}{enter}`); - // Confirm that with region and HA selections, create button is still disabled until plan selection is made. cy.get('[data-qa-deploy-linode]') .should('contain.text', 'Create Cluster') @@ -343,10 +540,9 @@ describe('LKE Cluster Creation with DC-specific pricing', () => { // selected node pool plan is added to the checkout bar. clusterPlans.forEach((clusterPlan) => { const nodeCount = randomNumber(1, 3); - const planName = getLkePlanName(clusterPlan); - const checkoutName = getLkePlanCheckoutName(clusterPlan); + const planName = clusterPlan.planName; - cy.log(`Adding ${nodeCount}x ${getLkePlanName(clusterPlan)} node(s)`); + cy.log(`Adding ${nodeCount}x ${clusterPlan.planName} node(s)`); // Click the right tab for the plan, and add a node pool with the desired // number of nodes. cy.findByText(clusterPlan.tab).should('be.visible').click(); @@ -373,7 +569,7 @@ describe('LKE Cluster Creation with DC-specific pricing', () => { // It's possible that multiple pools of the same type get added. // We're taking a naive approach here by confirming that at least one // instance of the pool appears in the checkout bar. - cy.findAllByText(checkoutName).first().should('be.visible'); + cy.findAllByText(`${planName} Plan`).first().should('be.visible'); }); }); @@ -512,7 +708,6 @@ describe('LKE Cluster Creation with ACL', () => { .should('be.visible'); // Add a node pool - cy.log(`Adding ${nodeCount}x ${getLkePlanName(clusterPlan)} node(s)`); cy.findByText(clusterPlan.tab).should('be.visible').click(); cy.findByText(planName) .should('be.visible') @@ -647,7 +842,6 @@ describe('LKE Cluster Creation with ACL', () => { .click(); // Add a node pool - cy.log(`Adding ${nodeCount}x ${getLkePlanName(clusterPlan)} node(s)`); cy.findByText(clusterPlan.tab).should('be.visible').click(); cy.findByText(planName) .should('be.visible') @@ -785,7 +979,6 @@ describe('LKE Cluster Creation with ACL', () => { cy.contains('Must be a valid IPv6 address.').should('not.exist'); // Add a node pool - cy.log(`Adding ${nodeCount}x ${getLkePlanName(clusterPlan)} node(s)`); cy.findByText(clusterPlan.tab).should('be.visible').click(); cy.findByText(planName) .should('be.visible') @@ -964,3 +1157,25 @@ describe('LKE Cluster Creation with LKE-E', () => { }); }); }); + +/** + * Returns each plan in an array which is similar to the given plan. + * + * Plans are considered similar if they have identical type and size. + * + * @param clusterPlan - Cluster plan with which to compare similarity. + * @param clusterPlans - Array from which to find similar cluster plans. + * + * @returns Array of similar cluster plans. + */ +const getSimilarPlans = ( + clusterPlan: LkePlanDescription, + clusterPlans: LkePlanDescription[] +) => { + return clusterPlans.filter((otherClusterPlan) => { + return ( + clusterPlan.type === otherClusterPlan.type && + clusterPlan.size === otherClusterPlan.size + ); + }); +}; diff --git a/packages/manager/cypress/e2e/core/volumes/create-volume.smoke.spec.ts b/packages/manager/cypress/e2e/core/volumes/create-volume.smoke.spec.ts index 3c645ace145..0ca5c8ce4e2 100644 --- a/packages/manager/cypress/e2e/core/volumes/create-volume.smoke.spec.ts +++ b/packages/manager/cypress/e2e/core/volumes/create-volume.smoke.spec.ts @@ -11,6 +11,7 @@ import { } from 'support/intercepts/linodes'; import { mockCreateVolume, + mockGetVolume, mockGetVolumes, mockDetachVolume, mockGetVolumeTypesError, @@ -85,6 +86,7 @@ describe('volumes', () => { mockGetVolumes([]).as('getVolumes'); mockCreateVolume(mockVolume).as('createVolume'); + mockGetVolume(mockVolume).as('getVolume'); mockGetVolumeTypes(mockVolumeTypes).as('getVolumeTypes'); cy.visitWithLogin('/volumes', { @@ -114,7 +116,7 @@ describe('volumes', () => { mockGetVolumes([mockVolume]).as('getVolumes'); ui.button.findByTitle('Create Volume').should('be.visible').click(); - cy.wait(['@createVolume', '@getVolumes']); + cy.wait(['@createVolume', '@getVolume', '@getVolumes']); validateBasicVolume(mockVolume.label); ui.actionMenu @@ -193,6 +195,7 @@ describe('volumes', () => { mockDetachVolume(mockAttachedVolume.id).as('detachVolume'); mockGetVolumes([mockAttachedVolume]).as('getAttachedVolumes'); + mockGetVolume(mockAttachedVolume).as('getVolume'); cy.visitWithLogin('/volumes', { preferenceOverrides, localStorageOverrides, @@ -209,6 +212,8 @@ describe('volumes', () => { ui.actionMenuItem.findByTitle('Detach').click(); + cy.wait('@getVolume'); + ui.dialog .findByTitle(`Detach Volume ${mockAttachedVolume.label}?`) .should('be.visible') diff --git a/packages/manager/cypress/e2e/core/volumes/upgrade-volume.spec.ts b/packages/manager/cypress/e2e/core/volumes/upgrade-volume.spec.ts index f6d807e4c15..27f5e379b84 100644 --- a/packages/manager/cypress/e2e/core/volumes/upgrade-volume.spec.ts +++ b/packages/manager/cypress/e2e/core/volumes/upgrade-volume.spec.ts @@ -10,7 +10,11 @@ import { mockGetLinodeDisks, mockGetLinodeVolumes, } from 'support/intercepts/linodes'; -import { mockMigrateVolumes, mockGetVolumes } from 'support/intercepts/volumes'; +import { + mockMigrateVolumes, + mockGetVolumes, + mockGetVolume, +} from 'support/intercepts/volumes'; import { ui } from 'support/ui'; describe('volume upgrade/migration', () => { @@ -23,6 +27,7 @@ describe('volume upgrade/migration', () => { }); mockGetVolumes([volume]).as('getVolumes'); + mockGetVolume(volume).as('getVolume'); mockMigrateVolumes().as('migrateVolumes'); mockGetNotifications([migrationScheduledNotification]).as( 'getNotifications' @@ -53,7 +58,7 @@ describe('volume upgrade/migration', () => { .click(); }); - cy.wait(['@migrateVolumes', '@getNotifications']); + cy.wait(['@migrateVolumes', '@getVolume', '@getNotifications']); cy.findByText('UPGRADE PENDING').should('be.visible'); diff --git a/packages/manager/cypress/support/api/lke.ts b/packages/manager/cypress/support/api/lke.ts index 14ed8339749..fa99f1eed71 100644 --- a/packages/manager/cypress/support/api/lke.ts +++ b/packages/manager/cypress/support/api/lke.ts @@ -1,29 +1,39 @@ import { - KubeNodePoolResponse, - KubernetesCluster, - PoolNodeResponse, deleteKubernetesCluster, getKubernetesClusters, getNodePools, } from '@linode/api-v4'; +import { DateTime } from 'luxon'; import { pageSize } from 'support/constants/api'; import { depaginate } from 'support/util/paginate'; -import { DateTime } from 'luxon'; import { isTestLabel } from './common'; +import type { + KubeNodePoolResponse, + KubernetesCluster, + PoolNodeResponse, +} from '@linode/api-v4'; +import type { LinodeTypeClass } from '@linode/api-v4/lib/linodes/types'; + /** * Describes an LKE plan as shown in Cloud Manager. */ export interface LkePlanDescription { - // / Plan size, GB. + /** Number of nodes in the plan. */ + nodeCount: number; + /** Name of the plan. */ + planName: string; + /** Plan size, GB. */ size: number; - - // / Label for tab containing the plan in creation screen. + /** Label for tab containing the plan in creation screen. */ tab: string; + /** Type of plan. */ + type: LinodeTypeClass; +} - // / Type of plan. - type: string; +export interface LkePlanDescriptionAPL extends LkePlanDescription { + disabled: boolean; } /* diff --git a/packages/manager/cypress/support/constants/dc-specific-pricing.ts b/packages/manager/cypress/support/constants/dc-specific-pricing.ts index 3843a35aceb..584fee1378f 100644 --- a/packages/manager/cypress/support/constants/dc-specific-pricing.ts +++ b/packages/manager/cypress/support/constants/dc-specific-pricing.ts @@ -3,7 +3,8 @@ */ import { linodeTypeFactory } from '@src/factories'; -import { LkePlanDescription } from 'support/api/lke'; + +import type { LkePlanDescription } from 'support/api/lke'; /** Notice shown to users when selecting a region with a different price structure. */ export const dcPricingRegionDifferenceNotice = @@ -127,9 +128,11 @@ export const dcPricingMockLinodeTypesForBackups = linodeTypeFactory.buildList( export const dcPricingLkeClusterPlans: LkePlanDescription[] = dcPricingMockLinodeTypes.map( (type) => { return { + nodeCount: 1, + planName: 'Linode 2 GB', size: parseInt(type.id.split('-')[2], 10), tab: 'Shared CPU', - type: 'Linode', + type: 'nanode', }; } ); diff --git a/packages/manager/cypress/support/constants/lke.ts b/packages/manager/cypress/support/constants/lke.ts index 1a4ca20aded..c6db3407abb 100644 --- a/packages/manager/cypress/support/constants/lke.ts +++ b/packages/manager/cypress/support/constants/lke.ts @@ -1,14 +1,3 @@ -import { LkePlanDescription } from 'support/api/lke'; - -/** - * Subset of LKE cluster plans as shown on Cloud Manager. - */ -export const lkeClusterPlans: LkePlanDescription[] = [ - { size: 4, tab: 'Dedicated CPU', type: 'Dedicated' }, - { size: 2, tab: 'Shared CPU', type: 'Linode' }, - { size: 4, tab: 'Shared CPU', type: 'Linode' }, -]; - /** * Kubernetes versions available for cluster creation via Cloud Manager. */ diff --git a/packages/manager/cypress/support/intercepts/betas.ts b/packages/manager/cypress/support/intercepts/betas.ts index 384961da39e..4874620ec77 100644 --- a/packages/manager/cypress/support/intercepts/betas.ts +++ b/packages/manager/cypress/support/intercepts/betas.ts @@ -6,7 +6,7 @@ import { apiMatcher } from 'support/util/intercepts'; import { paginateResponse } from 'support/util/paginate'; import { makeResponse } from 'support/util/response'; -import type { Beta } from '@linode/api-v4'; +import type { AccountBeta, Beta } from '@linode/api-v4'; /** * Intercepts GET request to fetch account betas (the ones the user has opted into) and mocks response. @@ -15,7 +15,9 @@ import type { Beta } from '@linode/api-v4'; * * @returns Cypress chainable. */ -export const mockGetAccountBetas = (betas: Beta[]): Cypress.Chainable => { +export const mockGetAccountBetas = ( + betas: AccountBeta[] +): Cypress.Chainable => { return cy.intercept( 'GET', apiMatcher('account/betas'), @@ -23,6 +25,21 @@ export const mockGetAccountBetas = (betas: Beta[]): Cypress.Chainable => { ); }; +/** + * Intercepts GET request to fetch a beta and mocks response. + * + * @param beta - Beta with which to mock response. + * + * @returns Cypress chainable. + */ +export const mockGetAccountBeta = (beta: AccountBeta): Cypress.Chainable => { + return cy.intercept( + 'GET', + apiMatcher(`account/betas/${beta.id}`), + makeResponse(beta) + ); +}; + /** * Intercepts GET request to fetch available betas (all betas available to the user). * diff --git a/packages/manager/cypress/support/intercepts/lke.ts b/packages/manager/cypress/support/intercepts/lke.ts index 5f646730a96..3f91e6a74a2 100644 --- a/packages/manager/cypress/support/intercepts/lke.ts +++ b/packages/manager/cypress/support/intercepts/lke.ts @@ -19,6 +19,7 @@ import type { KubernetesCluster, KubernetesControlPlaneACLPayload, KubernetesVersion, + PriceType, } from '@linode/api-v4'; /** @@ -455,3 +456,16 @@ export const mockUpdateControlPlaneACLError = ( makeErrorResponse(errorMessage, statusCode) ); }; + +/** + * Intercepts GET request for LKE cluster types and mocks the response + * + * @param types - LKE cluster types with which to mock response + * + * @returns Cypress chainable + */ +export const mockGetLKEClusterTypes = ( + types: PriceType[] +): Cypress.Chainable => { + return cy.intercept('GET', apiMatcher('lke/types*'), paginateResponse(types)); +}; diff --git a/packages/manager/src/MainContent.tsx b/packages/manager/src/MainContent.tsx index e0bfc2f2af3..82a2b3eb6d5 100644 --- a/packages/manager/src/MainContent.tsx +++ b/packages/manager/src/MainContent.tsx @@ -1,5 +1,6 @@ import { Box } from '@linode/ui'; import Grid from '@mui/material/Unstable_Grid2'; +import { useQueryClient } from '@tanstack/react-query'; import { RouterProvider } from '@tanstack/react-router'; import * as React from 'react'; import { Redirect, Route, Switch } from 'react-router-dom'; @@ -130,7 +131,6 @@ const LinodesRoutes = React.lazy(() => default: module.LinodesRoutes, })) ); -const Volumes = React.lazy(() => import('src/features/Volumes')); const Domains = React.lazy(() => import('src/features/Domains').then((module) => ({ default: module.DomainsRoutes, @@ -207,6 +207,7 @@ export const MainContent = () => { const { classes, cx } = useStyles(); const { data: preferences } = usePreferences(); const { mutateAsync: updatePreferences } = useMutatePreferences(); + const queryClient = useQueryClient(); const globalErrors = useGlobalErrors(); @@ -336,8 +337,6 @@ export const MainContent = () => { path="/placement-groups" /> )} - - { */} diff --git a/packages/manager/src/Router.tsx b/packages/manager/src/Router.tsx index 7d89f4fa297..a8e12fd54f3 100644 --- a/packages/manager/src/Router.tsx +++ b/packages/manager/src/Router.tsx @@ -1,3 +1,4 @@ +import { QueryClient } from '@tanstack/react-query'; import { RouterProvider } from '@tanstack/react-router'; import * as React from 'react'; @@ -24,6 +25,7 @@ export const Router = () => { isACLPEnabled, isDatabasesEnabled, isPlacementGroupsEnabled, + queryClient: new QueryClient(), }, }); diff --git a/packages/manager/src/components/TanstackLink.stories.tsx b/packages/manager/src/components/TanstackLink.stories.tsx new file mode 100644 index 00000000000..badc132c03f --- /dev/null +++ b/packages/manager/src/components/TanstackLink.stories.tsx @@ -0,0 +1,38 @@ +import React from 'react'; + +import { TanstackLink } from './TanstackLinks'; + +import type { TanstackLinkComponentProps } from './TanstackLinks'; +import type { Meta, StoryObj } from '@storybook/react'; + +export const AsButtonPrimary: StoryObj = { + render: () => ( + + Home + + ), +}; + +export const AsButtonSecondary: StoryObj = { + render: () => ( + + Home + + ), +}; + +export const AsLink: StoryObj = { + render: () => ( + + Home + + ), +}; + +const meta: Meta = { + parameters: { + tanStackRouter: true, + }, + title: 'Foundations/Link/Tanstack Link', +}; +export default meta; diff --git a/packages/manager/src/components/TanstackLinks.tsx b/packages/manager/src/components/TanstackLinks.tsx new file mode 100644 index 00000000000..bea4ddd332d --- /dev/null +++ b/packages/manager/src/components/TanstackLinks.tsx @@ -0,0 +1,83 @@ +import { Button } from '@linode/ui'; +import { omitProps } from '@linode/ui'; +import { LinkComponent } from '@tanstack/react-router'; +import { createLink } from '@tanstack/react-router'; +import * as React from 'react'; + +import { MenuItem } from 'src/components/MenuItem'; + +import type { ButtonProps, ButtonType } from '@linode/ui'; +import type { LinkProps as TanStackLinkProps } from '@tanstack/react-router'; + +export interface TanstackLinkComponentProps + extends Omit { + linkType: 'link' | ButtonType; + tooltipAnalyticsEvent?: (() => void) | undefined; + tooltipText?: string; +} + +export interface TanStackLinkRoutingProps { + linkType: TanstackLinkComponentProps['linkType']; + params?: TanStackLinkProps['params']; + preload?: TanStackLinkProps['preload']; + search?: TanStackLinkProps['search']; + to: TanStackLinkProps['to']; +} + +const LinkComponent = React.forwardRef< + HTMLButtonElement, + TanstackLinkComponentProps +>((props, ref) => { + const _props = omitProps(props, ['linkType']); + + return + + ); +}; diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/ResourceMultiSelect.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/ResourceMultiSelect.tsx index aabe52f3b8b..1ca76ad165f 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/ResourceMultiSelect.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/ResourceMultiSelect.tsx @@ -38,14 +38,14 @@ export const CloudPulseMultiResourceSelect = ( Boolean(region && serviceType), serviceType?.toString(), {}, - engine !== null ? { engine, region } : { region } + serviceType === 'dbaas' ? { engine, region } : { region } ); const getResourcesList = React.useMemo((): Item[] => { return resources && resources.length > 0 ? resources.map((resource) => ({ label: resource.label, - value: resource.id, + value: resource.id.toString(), })) : []; }, [resources]); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/types.ts b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/types.ts index 844b47639a0..63959e9c0bb 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/types.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/types.ts @@ -2,22 +2,28 @@ import type { AlertServiceType, AlertSeverityType, CreateAlertDefinitionPayload, + DimensionFilter, MetricAggregationType, MetricCriteria, MetricOperatorType, } from '@linode/api-v4'; export interface CreateAlertDefinitionForm - extends Omit { + extends Omit { engineType: null | string; entity_ids: string[]; region: string; + rule_criteria: { + rules: MetricCriteriaForm[]; + }; serviceType: AlertServiceType | null; severity: AlertSeverityType | null; } export interface MetricCriteriaForm - extends Omit { + extends Omit { aggregation_type: MetricAggregationType | null; + dimension_filters: DimensionFilter[]; + metric: null | string; operator: MetricOperatorType | null; } diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/utilities.ts b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/utilities.ts index 7459ca1c5da..81ec2b00ca0 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/utilities.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/utilities.ts @@ -15,18 +15,48 @@ export const filterFormValues = ( 'region', 'engineType', 'severity', + 'rule_criteria', ]); // severity has a need for null in the form for edge-cases, so null-checking and returning it as an appropriate type const severity = formValues.severity!; const entityIds = formValues.entity_ids; - return { ...values, entity_ids: entityIds, severity }; + const rules = formValues.rule_criteria.rules; + return { + ...values, + entity_ids: entityIds, + rule_criteria: { rules: filterMetricCriteriaFormValues(rules) }, + severity, + }; }; export const filterMetricCriteriaFormValues = ( - formValues: MetricCriteriaForm + formValues: MetricCriteriaForm[] ): MetricCriteria[] => { - const aggregationType = formValues.aggregation_type!; - const operator = formValues.operator!; - const values = omitProps(formValues, ['aggregation_type', 'operator']); - return [{ ...values, aggregation_type: aggregationType, operator }]; + return formValues.map((rule) => { + const values = omitProps(rule, ['aggregation_type', 'operator', 'metric']); + return { + ...values, + aggregation_type: rule.aggregation_type!, + dimension_filters: rule.dimension_filters, + metric: rule.metric!, + operator: rule.operator!, + }; + }); +}; + +export const convertToSeconds = (secondsList: string[]) => { + return secondsList.map((second) => { + const unit = second.slice(-1)[0]; + const number = parseInt(second.slice(0, -1), 10); + switch (unit) { + case 's': + return number; + case 'm': + return number * 60; + case 'h': + return number * 3600; + default: + return number * 0; + } + }); }; diff --git a/packages/manager/src/features/CloudPulse/Alerts/constants.ts b/packages/manager/src/features/CloudPulse/Alerts/constants.ts index 08f6223e263..8b80fc39d1c 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/constants.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/constants.ts @@ -1,4 +1,8 @@ -import type { AlertSeverityType } from '@linode/api-v4'; +import type { + AlertSeverityType, + MetricAggregationType, + MetricOperatorType, +} from '@linode/api-v4'; export interface Item { label: L; @@ -21,3 +25,49 @@ export const engineTypeOptions: Item[] = [ value: 'postgresql', }, ]; + +export const MetricOperatorOptions: Item[] = [ + { + label: '>', + value: 'gt', + }, + { + label: '<', + value: 'lt', + }, + { + label: '>=', + value: 'gte', + }, + { + label: '<=', + value: 'lte', + }, + { + label: '==', + value: 'eq', + }, +]; + +export const MetricAggregationOptions: Item[] = [ + { + label: 'Average', + value: 'avg', + }, + { + label: 'Minimum', + value: 'min', + }, + { + label: 'Maximum', + value: 'max', + }, + { + label: 'Count', + value: 'count', + }, + { + label: 'Sum', + value: 'sum', + }, +]; diff --git a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardLanding.tsx b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardLanding.tsx index 828a05301c7..238f81c6189 100644 --- a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardLanding.tsx +++ b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardLanding.tsx @@ -1,13 +1,19 @@ -import { Grid, Paper } from '@mui/material'; +import { Box, Paper } from '@linode/ui'; +import { Grid } from '@mui/material'; import * as React from 'react'; import { GlobalFilters } from '../Overview/GlobalFilters'; +import { CloudPulseAppliedFilterRenderer } from '../shared/CloudPulseAppliedFilterRenderer'; import { CloudPulseDashboardRenderer } from './CloudPulseDashboardRenderer'; import type { Dashboard, TimeDuration } from '@linode/api-v4'; export type FilterValueType = number | number[] | string | string[] | undefined; +export interface FilterData { + id: { [filterKey: string]: FilterValueType }; + label: { [filterKey: string]: string[] }; +} export interface DashboardProp { dashboard?: Dashboard; filterValue: { @@ -17,26 +23,47 @@ export interface DashboardProp { } export const CloudPulseDashboardLanding = () => { - const [filterValue, setFilterValue] = React.useState<{ - [key: string]: FilterValueType; - }>({}); + const [filterData, setFilterData] = React.useState({ + id: {}, + label: {}, + }); + const [timeDuration, setTimeDuration] = React.useState(); const [dashboard, setDashboard] = React.useState(); + const [showAppliedFilters, setShowAppliedFilters] = React.useState( + false + ); + + const toggleAppliedFilter = (isVisible: boolean) => { + setShowAppliedFilters(isVisible); + }; + const onFilterChange = React.useCallback( - (filterKey: string, filterValue: FilterValueType) => { - setFilterValue((prev: { [key: string]: FilterValueType }) => ({ - ...prev, - [filterKey]: filterValue, - })); + (filterKey: string, filterValue: FilterValueType, labels: string[]) => { + setFilterData((prev: FilterData) => { + return { + id: { + ...prev.id, + [filterKey]: filterValue, + }, + label: { + ...prev.label, + [filterKey]: labels, + }, + }; + }); }, [] ); const onDashboardChange = React.useCallback((dashboardObj: Dashboard) => { setDashboard(dashboardObj); - setFilterValue({}); // clear the filter values on dashboard change + setFilterData({ + id: {}, + label: {}, + }); // clear the filter values on dashboard change }, []); const onTimeDurationChange = React.useCallback( (timeDurationObj: TimeDuration) => { @@ -47,17 +74,26 @@ export const CloudPulseDashboardLanding = () => { return ( - - + + + + {dashboard?.service_type && showAppliedFilters && ( + + )} + diff --git a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardWithFilters.tsx b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardWithFilters.tsx index 00f73834381..7c42b26fe25 100644 --- a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardWithFilters.tsx +++ b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardWithFilters.tsx @@ -5,6 +5,7 @@ import React from 'react'; import { ErrorState } from 'src/components/ErrorState/ErrorState'; import { useCloudPulseDashboardByIdQuery } from 'src/queries/cloudpulse/dashboards'; +import { CloudPulseAppliedFilterRenderer } from '../shared/CloudPulseAppliedFilterRenderer'; import { CloudPulseDashboardFilterBuilder } from '../shared/CloudPulseDashboardFilterBuilder'; import { CloudPulseErrorPlaceholder } from '../shared/CloudPulseErrorPlaceholder'; import { CloudPulseTimeRangeSelect } from '../shared/CloudPulseTimeRangeSelect'; @@ -16,7 +17,7 @@ import { } from '../Utils/ReusableDashboardFilterUtils'; import { CloudPulseDashboard } from './CloudPulseDashboard'; -import type { FilterValueType } from './CloudPulseDashboardLanding'; +import type { FilterData, FilterValueType } from './CloudPulseDashboardLanding'; import type { TimeDuration } from '@linode/api-v4'; export interface CloudPulseDashboardWithFiltersProp { @@ -37,18 +38,38 @@ export const CloudPulseDashboardWithFilters = React.memo( dashboardId ); - const [filterValue, setFilterValue] = React.useState<{ - [key: string]: FilterValueType; - }>({}); + const [filterData, setFilterData] = React.useState({ + id: {}, + label: {}, + }); const [timeDuration, setTimeDuration] = React.useState({ unit: 'min', value: 30, }); + const [showAppliedFilters, setShowAppliedFilters] = React.useState( + false + ); + + const toggleAppliedFilter = (isVisible: boolean) => { + setShowAppliedFilters(isVisible); + }; + const onFilterChange = React.useCallback( - (filterKey: string, value: FilterValueType) => { - setFilterValue((prev) => ({ ...prev, [filterKey]: value })); + (filterKey: string, value: FilterValueType, labels: string[]) => { + setFilterData((prev) => { + return { + id: { + ...prev.id, + [filterKey]: value, + }, + label: { + ...prev.label, + [filterKey]: labels, + }, + }; + }); }, [] ); @@ -91,7 +112,7 @@ export const CloudPulseDashboardWithFilters = React.memo( const isFilterBuilderNeeded = checkIfFilterBuilderNeeded(dashboard); const isMandatoryFiltersSelected = checkMandatoryFiltersSelected({ dashboardObj: dashboard, - filterValue, + filterValue: filterData.id, resource, timeDuration, }); @@ -132,15 +153,24 @@ export const CloudPulseDashboardWithFilters = React.memo( )} + + {showAppliedFilters && ( + + )} + {isMandatoryFiltersSelected ? ( { return renderWithTheme( @@ -14,6 +15,7 @@ const setup = () => { handleAnyFilterChange={mockHandleAnyFilterChange} handleDashboardChange={mockHandleDashboardChange} handleTimeDurationChange={mockHandleTimeDurationChange} + handleToggleAppliedFilter={mockHandleToggleAppliedFilter} /> ); }; diff --git a/packages/manager/src/features/CloudPulse/Overview/GlobalFilters.tsx b/packages/manager/src/features/CloudPulse/Overview/GlobalFilters.tsx index b3ab3e678da..e371c50351b 100644 --- a/packages/manager/src/features/CloudPulse/Overview/GlobalFilters.tsx +++ b/packages/manager/src/features/CloudPulse/Overview/GlobalFilters.tsx @@ -17,9 +17,14 @@ import type { FilterValueType } from '../Dashboard/CloudPulseDashboardLanding'; import type { AclpConfig, Dashboard, TimeDuration } from '@linode/api-v4'; export interface GlobalFilterProperties { - handleAnyFilterChange(filterKey: string, filterValue: FilterValueType): void; + handleAnyFilterChange( + filterKey: string, + filterValue: FilterValueType, + labels: string[] + ): void; handleDashboardChange(dashboard: Dashboard | undefined): void; handleTimeDurationChange(timeDuration: TimeDuration): void; + handleToggleAppliedFilter(isVisible: boolean): void; } export const GlobalFilters = React.memo((props: GlobalFilterProperties) => { @@ -27,6 +32,7 @@ export const GlobalFilters = React.memo((props: GlobalFilterProperties) => { handleAnyFilterChange, handleDashboardChange, handleTimeDurationChange, + handleToggleAppliedFilter, } = props; const { @@ -68,19 +74,20 @@ export const GlobalFilters = React.memo((props: GlobalFilterProperties) => { ( filterKey: string, value: FilterValueType, + labels: string[], savePref: boolean = false, updatedPreferenceData: AclpConfig = {} ) => { if (savePref) { updatePreferences(updatedPreferenceData); } - handleAnyFilterChange(filterKey, value); + handleAnyFilterChange(filterKey, value, labels); }, [] ); const handleGlobalRefresh = React.useCallback(() => { - handleAnyFilterChange(REFRESH, Date.now()); + handleAnyFilterChange(REFRESH, Date.now(), []); }, []); const theme = useTheme(); @@ -143,6 +150,7 @@ export const GlobalFilters = React.memo((props: GlobalFilterProperties) => { diff --git a/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.ts b/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.ts index e058b2e3d03..93a72d435f8 100644 --- a/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.ts +++ b/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.ts @@ -54,7 +54,11 @@ interface CloudPulseMandatoryFilterCheckProps { */ export const getRegionProperties = ( props: CloudPulseFilterProperties, - handleRegionChange: (region: string | undefined, savePref?: boolean) => void + handleRegionChange: ( + region: string | undefined, + labels: [], + savePref?: boolean + ) => void ): CloudPulseRegionSelectProps => { const { name: label, placeholder } = props.config.configuration; const { dashboard, isServiceAnalyticsIntegration, preferences } = props; @@ -119,6 +123,7 @@ export const getCustomSelectProperties = ( handleCustomSelectChange: ( filterKey: string, value: FilterValueType, + labels: string[], savePref?: boolean, updatedPreferenceData?: {} ) => void diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseAppliedFilter.test.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseAppliedFilter.test.tsx new file mode 100644 index 00000000000..6a8dbb2a274 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseAppliedFilter.test.tsx @@ -0,0 +1,39 @@ +import React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { CloudPulseAppliedFilter } from './CloudPulseAppliedFilter'; +import { CloudPulseAppliedFilterRenderer } from './CloudPulseAppliedFilterRenderer'; + +const data = { + region: ['us-east'], + resource: ['res1', 'res2'], +}; + +const testId = 'applied-filter'; + +describe('CloudPulse Applied Filter', () => { + it('should render applied filter component', () => { + const { getByTestId } = renderWithTheme( + + ); + expect(getByTestId(testId)).toBeInTheDocument(); + }); + + it('should render the applied filter key & values', () => { + const { getByTestId } = renderWithTheme( + + ); + expect(getByTestId(testId)).toHaveTextContent('region'); + expect(getByTestId(testId)).toHaveTextContent('res1'); + expect(getByTestId(testId)).not.toHaveTextContent('resources'); + }); + + it('should not render the applied filter component', () => { + const { queryByTestId } = renderWithTheme( + + ); + + expect(queryByTestId(testId)).toBe(null); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseAppliedFilter.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseAppliedFilter.tsx new file mode 100644 index 00000000000..ec012793023 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseAppliedFilter.tsx @@ -0,0 +1,63 @@ +import { Box, Chip, Typography } from '@linode/ui'; +import React from 'react'; + +export interface CloudPulseAppliedFilterProps { + filters: { + [label: string]: string[]; + }; +} +export const CloudPulseAppliedFilter = ( + props: CloudPulseAppliedFilterProps +) => { + const { filters } = props; + + return ( + + {Object.entries(filters).map((data, index) => { + const label = data[0]; + const filterValue = data[1]; + return ( + + {`${label}:`} + {filterValue.map((value, index) => { + return ( + ({ + backgroundColor: theme.tokens.color.Ultramarine[10], + color: theme.tokens.color.Neutrals.Black, + fontSize: '14px', + mr: index === filterValue.length - 1 ? 4 : 1, + px: 1, + py: 0.5, + width: { sm: 'fit-content', xs: '98%' }, + })} + key={`${label} ${value}`} + label={value} + /> + ); + })} + + ); + })} + + ); +}; diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseAppliedFilterRenderer.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseAppliedFilterRenderer.tsx new file mode 100644 index 00000000000..085b1569f90 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseAppliedFilterRenderer.tsx @@ -0,0 +1,38 @@ +import React from 'react'; + +import { FILTER_CONFIG } from '../Utils/FilterConfig'; +import { CloudPulseAppliedFilter } from './CloudPulseAppliedFilter'; + +import type { CloudPulseAppliedFilterProps } from './CloudPulseAppliedFilter'; + +interface AppliedFilterRendererProps extends CloudPulseAppliedFilterProps { + serviceType: string; +} + +export const CloudPulseAppliedFilterRenderer = ( + props: AppliedFilterRendererProps +) => { + const { filters, serviceType } = props; + + const filterConfig = FILTER_CONFIG.get(serviceType); + + if (!filterConfig) { + return <>; + } + const configuredFilters = filterConfig.filters; + + const appliedFilter = configuredFilters + .filter((filter) => { + const filterKey = filter.configuration.filterKey; + return Boolean(filters[filterKey]?.length); + }) + .reduce( + (prevValue, filter) => ({ + ...prevValue, + [filter.configuration.name]: filters[filter.configuration.filterKey], + }), + {} + ); + + return ; +}; diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseCustomSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseCustomSelect.tsx index 98338efb533..a68af932c17 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseCustomSelect.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseCustomSelect.tsx @@ -68,10 +68,12 @@ export interface CloudPulseCustomSelectProps { * The callback function , that will be called on a filter change * @param filterKey - The filterKey of the component * @param value - The selected filter value + * @param labels - Labels of the selected filter value */ handleSelectionChange: ( filterKey: string, value: FilterValueType, + labels: string[], savePref?: boolean, updatedPreferenceData?: AclpConfig ) => void; diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseCustomSelectUtils.ts b/packages/manager/src/features/CloudPulse/shared/CloudPulseCustomSelectUtils.ts index ce6dabc4a02..164e22d5471 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseCustomSelectUtils.ts +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseCustomSelectUtils.ts @@ -14,6 +14,7 @@ interface CloudPulseCustomSelectProps { handleSelectionChange: ( filterKey: string, value: FilterValueType, + labels: string[], savePref?: boolean, updatedPreferenceData?: AclpConfig ) => void; @@ -139,7 +140,8 @@ export const getInitialDefaultSelections = ( const initialSelection = isMultiSelect ? [options[0]] : options[0]; handleSelectionChange( filterKey, - isMultiSelect ? [options[0].id] : options[0].id + isMultiSelect ? [options[0].id] : options[0].id, + [options[0].label] ); return initialSelection; } @@ -155,7 +157,12 @@ export const getInitialDefaultSelections = ( ? isMultiSelect ? selectedValues.map(({ id }) => id) : selectedValues[0].id - : undefined // if this is multiselect, return list of ids, otherwise return single id + : undefined, // if this is multiselect, return list of ids, otherwise return single id + selectedValues && selectedValues.length > 0 + ? isMultiSelect + ? selectedValues.map(({ label }) => label) + : [selectedValues[0].label] + : [] ); return selectedValues?.length ? isMultiSelect @@ -195,6 +202,12 @@ export const handleCustomSelectionChange = ( : String(value.id) : undefined; + const labels = value + ? Array.isArray(value) + ? value.map(({ label }) => label) + : [value.label] + : []; + let updatedPreferenceData: AclpConfig = {}; // update the preferences @@ -216,6 +229,7 @@ export const handleCustomSelectionChange = ( handleSelectionChange( filterKey, result, + labels, savePreferences, updatedPreferenceData ); diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.test.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.test.tsx index ec2c10bacc5..71a684c9a1d 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.test.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.test.tsx @@ -13,6 +13,7 @@ describe('CloudPulseDashboardFilterBuilder component tests', () => { service_type: 'linode', })} emitFilterChange={vi.fn()} + handleToggleAppliedFilter={vi.fn()} isServiceAnalyticsIntegration={false} /> ); @@ -28,6 +29,7 @@ describe('CloudPulseDashboardFilterBuilder component tests', () => { service_type: 'dbaas', })} emitFilterChange={vi.fn()} + handleToggleAppliedFilter={vi.fn()} isServiceAnalyticsIntegration={false} /> ); diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.tsx index d836b73ec63..ae55e6b2afa 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.tsx @@ -41,10 +41,13 @@ export interface CloudPulseDashboardFilterBuilderProps { emitFilterChange: ( filterKey: string, value: FilterValueType, + labels: string[], savePref?: boolean, updatePreferenceData?: {} ) => void; + handleToggleAppliedFilter: (isVisible: boolean) => void; + /** * this will handle the restrictions, if the parent of the component is going to be integrated in service analytics page */ @@ -61,6 +64,7 @@ export const CloudPulseDashboardFilterBuilder = React.memo( const { dashboard, emitFilterChange, + handleToggleAppliedFilter, isServiceAnalyticsIntegration, preferences, } = props; @@ -105,12 +109,14 @@ export const CloudPulseDashboardFilterBuilder = React.memo( ( filterKey: string, filterValue: FilterValueType, + labels: string[], savePref: boolean = false, updatedPreferenceData: AclpConfig = {} ) => { emitFilterChange( filterKey, filterValue, + labels, savePref, updatedPreferenceData ); @@ -124,6 +130,7 @@ export const CloudPulseDashboardFilterBuilder = React.memo( emitFilterChangeByFilterKey( RESOURCE_ID, resourceId.map((resource) => resource.id), + resourceId.map((resource) => resource.label), savePref, { [RESOURCES]: resourceId.map((resource: { id: string }) => @@ -136,7 +143,11 @@ export const CloudPulseDashboardFilterBuilder = React.memo( ); const handleRegionChange = React.useCallback( - (region: string | undefined, savePref: boolean = false) => { + ( + region: string | undefined, + labels: string[], + savePref: boolean = false + ) => { const updatedPreferenceData = { [REGION]: region, [RESOURCES]: undefined, @@ -144,6 +155,7 @@ export const CloudPulseDashboardFilterBuilder = React.memo( emitFilterChangeByFilterKey( REGION, region, + labels, savePref, updatedPreferenceData ); @@ -155,12 +167,14 @@ export const CloudPulseDashboardFilterBuilder = React.memo( ( filterKey: string, value: FilterValueType, + labels: string[], savePref: boolean = false, updatedPreferenceData: {} = {} ) => { emitFilterChangeByFilterKey( filterKey, value, + labels, savePref, updatedPreferenceData ); @@ -215,6 +229,7 @@ export const CloudPulseDashboardFilterBuilder = React.memo( ); const toggleShowFilter = () => { + handleToggleAppliedFilter(showFilter); setShowFilter((showFilterPrev) => !showFilterPrev); }; @@ -228,7 +243,7 @@ export const CloudPulseDashboardFilterBuilder = React.memo( CustomIcon={InfoIcon} CustomIconStyles={{ height: '40px', width: '40px' }} errorText={'Please configure filters to continue'} - > + /> ); } @@ -313,6 +328,7 @@ export const CloudPulseDashboardFilterBuilder = React.memo( item maxHeight={theme.spacing(23)} overflow={'auto'} + pr={{ sm: 0, xs: 2 }} rowGap={theme.spacing(2)} xs={12} > diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx index bb8a1c25f03..3d38f51c287 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx @@ -7,7 +7,11 @@ import type { Dashboard, FilterValue } from '@linode/api-v4'; export interface CloudPulseRegionSelectProps { defaultValue?: FilterValue; - handleRegionChange: (region: string | undefined, savePref?: boolean) => void; + handleRegionChange: ( + region: string | undefined, + labels: string[], + savePref?: boolean + ) => void; label: string; placeholder?: string; savePreferences?: boolean; @@ -32,10 +36,10 @@ export const CloudPulseRegionSelect = React.memo( React.useEffect(() => { if (regions && savePreferences) { const region = defaultValue - ? regions.find((regionObj) => regionObj.id === defaultValue)?.id + ? regions.find((regionObj) => regionObj.id === defaultValue) : undefined; - handleRegionChange(region); - setSelectedRegion(region); + handleRegionChange(region?.id, region ? [region.label] : []); + setSelectedRegion(region?.id); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [regions]); @@ -44,7 +48,11 @@ export const CloudPulseRegionSelect = React.memo( { setSelectedRegion(region?.id); - handleRegionChange(region?.id, savePreferences); + handleRegionChange( + region?.id, + region ? [region.label] : [], + savePreferences + ); }} currentCapability={undefined} data-testid="region-select" diff --git a/packages/manager/src/features/Kubernetes/CreateCluster/ApplicationPlatform.tsx b/packages/manager/src/features/Kubernetes/CreateCluster/ApplicationPlatform.tsx index d0f4e0c135d..20d0f5557fc 100644 --- a/packages/manager/src/features/Kubernetes/CreateCluster/ApplicationPlatform.tsx +++ b/packages/manager/src/features/Kubernetes/CreateCluster/ApplicationPlatform.tsx @@ -36,7 +36,7 @@ export const ApplicationPlatform = (props: APLProps) => { }; return ( - + ({ '&&.MuiFormLabel-root.Mui-focused': { @@ -48,19 +48,24 @@ export const ApplicationPlatform = (props: APLProps) => { })} > - Akamai App Platform + Akamai App Platform handleChange(e)}> } label={Yes, enable Akamai App Platform.} - control={} name="yes" value="yes" /> - } label="No" name="no" value="no" /> + } + label="No" + name="no" + value="no" + /> ); diff --git a/packages/manager/src/features/Kubernetes/CreateCluster/HAControlPlane.tsx b/packages/manager/src/features/Kubernetes/CreateCluster/HAControlPlane.tsx index b886bf2861b..794921ef8ec 100644 --- a/packages/manager/src/features/Kubernetes/CreateCluster/HAControlPlane.tsx +++ b/packages/manager/src/features/Kubernetes/CreateCluster/HAControlPlane.tsx @@ -117,7 +117,7 @@ export const HAControlPlane = (props: HAControlPlaneProps) => { } + control={} label="No" name="no" value="no" diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerConfigPanel.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerConfigPanel.tsx index 8a123ad6cd4..7cf7e7b43a9 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerConfigPanel.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerConfigPanel.tsx @@ -166,6 +166,7 @@ export const NodeBalancerConfigPanel = ( const algorithmHelperText = { leastconn: LEAST_CONNECTIONS_ALGORITHM_HELPER_TEXT, + ring_hash: '', // @todo Add copy as part of UDP NodeBalancer project roundrobin: ROUND_ROBIN_ALGORITHM_HELPER_TEXT, source: SOURCE_ALGORITHM_HELPER_TEXT, }; diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.tsx index a0ef7926607..57c4064f241 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.tsx @@ -57,8 +57,8 @@ import { sendCreateNodeBalancerEvent } from 'src/utilities/analytics/customEvent import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { getGDPRDetails } from 'src/utilities/formatRegion'; import { getAPIErrorFor } from 'src/utilities/getAPIErrorFor'; -import { PRICE_ERROR_TOOLTIP_TEXT } from 'src/utilities/pricing/constants'; import { DOCS_LINK_LABEL_DC_PRICING } from 'src/utilities/pricing/constants'; +import { PRICE_ERROR_TOOLTIP_TEXT } from 'src/utilities/pricing/constants'; import { getDCSpecificPriceByType, renderMonthlyPriceToCorrectDecimalPlace, diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerConfigurations.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerConfigurations.tsx index cdd1462d932..0ba3cf6b091 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerConfigurations.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerConfigurations.tsx @@ -7,7 +7,7 @@ import { getNodeBalancerConfigs, updateNodeBalancerConfig, updateNodeBalancerConfigNode, -} from '@linode/api-v4/lib/nodebalancers'; +} from '@linode/api-v4'; import { Accordion, Box, Button, Typography } from '@linode/ui'; import { styled } from '@mui/material/styles'; import { @@ -49,12 +49,13 @@ import type { NodeBalancerConfigFieldsWithStatus, NodeBalancerConfigNodeFields, } from '../types'; -import type { Grants } from '@linode/api-v4'; import type { + APIError, + Grants, NodeBalancerConfig, NodeBalancerConfigNode, -} from '@linode/api-v4/lib/nodebalancers'; -import type { APIError, ResourcePage } from '@linode/api-v4/lib/types'; + ResourcePage, +} from '@linode/api-v4'; import type { Lens } from 'ramda'; import type { RouteComponentProps } from 'react-router-dom'; import type { PromiseLoaderResponse } from 'src/components/PromiseLoader/PromiseLoader'; diff --git a/packages/manager/src/features/NodeBalancers/types.ts b/packages/manager/src/features/NodeBalancers/types.ts index 1a4d465615e..df194d87cbf 100644 --- a/packages/manager/src/features/NodeBalancers/types.ts +++ b/packages/manager/src/features/NodeBalancers/types.ts @@ -1,65 +1,44 @@ import type { - NodeBalancerConfigNodeMode, + APIError, + Algorithm, + NodeBalancerConfigNode, NodeBalancerProxyProtocol, -} from '@linode/api-v4/lib/nodebalancers/types'; -import type { APIError } from '@linode/api-v4/lib/types'; + Protocol, + Stickiness, + UpdateNodeBalancerConfig, +} from '@linode/api-v4'; export interface NodeBalancerConfigFieldsWithStatus extends NodeBalancerConfigFields { + /** + * Exists for the sake of local operations + */ modifyStatus?: 'new'; } -export interface ExtendedNodeBalancerConfigNode { - address: string; - config_id?: number; - errors?: APIError[]; - id: number; - label: string; - mode?: NodeBalancerConfigNodeMode; - modifyStatus?: 'delete' | 'new' | 'update'; - nodebalancer_id: number; - port?: number; - status: 'DOWN' | 'UP' | 'unknown'; - weight?: number; -} -export interface NodeBalancerConfigFields { - algorithm?: 'leastconn' | 'roundrobin' | 'source'; - check?: 'connection' | 'http' | 'http_body' | 'none'; - check_attempts?: number /** 1..30 */; - check_body?: string; - check_interval?: number; - check_passive?: boolean; - check_path?: string; - check_timeout?: number /** 1..30 */; - cipher_suite?: 'legacy' | 'recommended'; +export interface NodeBalancerConfigFields extends UpdateNodeBalancerConfig { id?: number; nodes: NodeBalancerConfigNodeFields[]; - port?: number /** 1..65535 */; - protocol?: 'http' | 'https' | 'tcp'; - proxy_protocol?: NodeBalancerProxyProtocol; - ssl_cert?: string; - ssl_key?: string; - stickiness?: 'http_cookie' | 'none' | 'table'; } -export interface NodeBalancerConfigNodeFields { +export interface NodeBalancerConfigNodeFields + extends Partial { address: string; - config_id?: number; errors?: APIError[]; - id?: number; label: string; - mode?: NodeBalancerConfigNodeMode; - /* for the sake of local operations */ + /** + * Exists for the sake of local operations + */ modifyStatus?: 'delete' | 'new' | 'update'; - nodebalancer_id?: number; + /** + * @note `port` is an "extended" field. The API includes it in the `address` + */ port?: number; - status?: 'DOWN' | 'UP' | 'unknown'; - weight?: number; } export interface NodeBalancerConfigPanelProps { addNode: (nodeIdx?: number) => void; - algorithm: 'leastconn' | 'roundrobin' | 'source'; + algorithm: Algorithm; checkBody: string; checkPassive: boolean; @@ -113,10 +92,10 @@ export interface NodeBalancerConfigPanelProps { onSslCertificateChange: (v: string) => void; port: number; privateKey: string; - protocol: 'http' | 'https' | 'tcp'; + protocol: Protocol; proxyProtocol: NodeBalancerProxyProtocol; removeNode: (nodeIdx: number) => void; - sessionStickiness: 'http_cookie' | 'none' | 'table'; + sessionStickiness: Stickiness; sslCertificate: string; submitting?: boolean; } diff --git a/packages/manager/src/features/NodeBalancers/utils.ts b/packages/manager/src/features/NodeBalancers/utils.ts index b7cec227bcc..528dd6e7831 100644 --- a/packages/manager/src/features/NodeBalancers/utils.ts +++ b/packages/manager/src/features/NodeBalancers/utils.ts @@ -3,7 +3,6 @@ import { filter, isNil } from 'ramda'; import { getErrorMap } from 'src/utilities/errorUtils'; import type { - ExtendedNodeBalancerConfigNode, NodeBalancerConfigFields, NodeBalancerConfigFieldsWithStatus, NodeBalancerConfigNodeFields, @@ -51,7 +50,7 @@ export const nodeForRequest = (node: NodeBalancerConfigNodeFields) => ({ weight: +node.weight!, }); -export const formatAddress = (node: ExtendedNodeBalancerConfigNode) => ({ +export const formatAddress = (node: NodeBalancerConfigNodeFields) => ({ ...node, address: `${node.address}:${node.port}`, }); diff --git a/packages/manager/src/features/Volumes/AttachVolumeDrawer.test.tsx b/packages/manager/src/features/Volumes/AttachVolumeDrawer.test.tsx index 0e52af786d5..798e19f7b66 100644 --- a/packages/manager/src/features/Volumes/AttachVolumeDrawer.test.tsx +++ b/packages/manager/src/features/Volumes/AttachVolumeDrawer.test.tsx @@ -3,7 +3,7 @@ import * as React from 'react'; import { accountFactory, volumeFactory } from 'src/factories'; import { HttpResponse, http, server } from 'src/mocks/testServer'; -import { renderWithTheme } from 'src/utilities/testHelpers'; +import { renderWithThemeAndRouter } from 'src/utilities/testHelpers'; import { AttachVolumeDrawer } from './AttachVolumeDrawer'; @@ -24,7 +24,7 @@ describe('AttachVolumeDrawer', () => { }) ); - const { getByLabelText } = renderWithTheme( + const { getByLabelText } = await renderWithThemeAndRouter( , { flags: { blockStorageEncryption: true }, @@ -48,7 +48,7 @@ describe('AttachVolumeDrawer', () => { }) ); - const { queryByRole } = renderWithTheme( + const { queryByRole } = await renderWithThemeAndRouter( , { flags: { blockStorageEncryption: false }, @@ -69,7 +69,7 @@ describe('AttachVolumeDrawer', () => { }) ); - const { queryByRole } = renderWithTheme( + const { queryByRole } = await renderWithThemeAndRouter( , { flags: { blockStorageEncryption: true }, diff --git a/packages/manager/src/features/Volumes/AttachVolumeDrawer.tsx b/packages/manager/src/features/Volumes/AttachVolumeDrawer.tsx index 37e29f2e1aa..35e9863a2cd 100644 --- a/packages/manager/src/features/Volumes/AttachVolumeDrawer.tsx +++ b/packages/manager/src/features/Volumes/AttachVolumeDrawer.tsx @@ -20,6 +20,7 @@ import { ConfigSelect } from './VolumeDrawer/ConfigSelect'; import type { Volume } from '@linode/api-v4'; interface Props { + isFetching?: boolean; onClose: () => void; open: boolean; volume: Volume | undefined; @@ -35,7 +36,7 @@ const AttachVolumeValidationSchema = object({ }); export const AttachVolumeDrawer = React.memo((props: Props) => { - const { open, volume } = props; + const { isFetching, open, volume } = props; const { enqueueSnackbar } = useSnackbar(); @@ -97,6 +98,7 @@ export const AttachVolumeDrawer = React.memo((props: Props) => { return ( { }) ); - const { getByLabelText } = renderWithTheme( + const { getByLabelText } = await renderWithThemeAndRouter( , { flags: { blockStorageEncryption: true }, @@ -48,7 +48,7 @@ describe('CloneVolumeDrawer', () => { }) ); - const { queryByRole } = renderWithTheme( + const { queryByRole } = await renderWithThemeAndRouter( , { flags: { blockStorageEncryption: false }, @@ -69,7 +69,7 @@ describe('CloneVolumeDrawer', () => { }) ); - const { queryByRole } = renderWithTheme( + const { queryByRole } = await renderWithThemeAndRouter( , { flags: { blockStorageEncryption: true }, diff --git a/packages/manager/src/features/Volumes/CloneVolumeDrawer.tsx b/packages/manager/src/features/Volumes/CloneVolumeDrawer.tsx index 3a6e7cd031a..229cb5feafe 100644 --- a/packages/manager/src/features/Volumes/CloneVolumeDrawer.tsx +++ b/packages/manager/src/features/Volumes/CloneVolumeDrawer.tsx @@ -23,6 +23,7 @@ import { PricePanel } from './VolumeDrawer/PricePanel'; import type { Volume } from '@linode/api-v4'; interface Props { + isFetching?: boolean; onClose: () => void; open: boolean; volume: Volume | undefined; @@ -31,7 +32,7 @@ interface Props { const initialValues = { label: '' }; export const CloneVolumeDrawer = (props: Props) => { - const { onClose: _onClose, open, volume } = props; + const { isFetching, onClose: _onClose, open, volume } = props; const { mutateAsync: cloneVolume } = useCloneVolumeMutation(); @@ -88,7 +89,12 @@ export const CloneVolumeDrawer = (props: Props) => { }; return ( - +
{isReadOnly && ( void; open: boolean; volume: Volume | undefined; } export const DeleteVolumeDialog = (props: Props) => { - const { onClose, open, volume } = props; + const { isFetching, onClose, open, volume } = props; const { error, @@ -39,6 +40,7 @@ export const DeleteVolumeDialog = (props: Props) => { primaryBtnText: 'Delete', type: 'Volume', }} + isFetching={isFetching} label="Volume Label" loading={isPending} onClick={onDelete} diff --git a/packages/manager/src/features/Volumes/DetachVolumeDialog.tsx b/packages/manager/src/features/Volumes/DetachVolumeDialog.tsx index 96bdf19fbbb..859de32f1fe 100644 --- a/packages/manager/src/features/Volumes/DetachVolumeDialog.tsx +++ b/packages/manager/src/features/Volumes/DetachVolumeDialog.tsx @@ -10,13 +10,14 @@ import { useDetachVolumeMutation } from 'src/queries/volumes/volumes'; import type { Volume } from '@linode/api-v4'; interface Props { + isFetching?: boolean; onClose: () => void; open: boolean; volume: Volume | undefined; } export const DetachVolumeDialog = (props: Props) => { - const { onClose, open, volume } = props; + const { isFetching, onClose, open, volume } = props; const { enqueueSnackbar } = useSnackbar(); @@ -53,6 +54,7 @@ export const DetachVolumeDialog = (props: Props) => { primaryBtnText: 'Detach', type: 'Volume', }} + isFetching={isFetching} label="Volume Label" loading={isPending} onClick={onDetach} diff --git a/packages/manager/src/features/Volumes/EditVolumeDrawer.test.tsx b/packages/manager/src/features/Volumes/EditVolumeDrawer.test.tsx index a9c1597995d..df76bb2b4bb 100644 --- a/packages/manager/src/features/Volumes/EditVolumeDrawer.test.tsx +++ b/packages/manager/src/features/Volumes/EditVolumeDrawer.test.tsx @@ -3,7 +3,7 @@ import * as React from 'react'; import { accountFactory, volumeFactory } from 'src/factories'; import { HttpResponse, http, server } from 'src/mocks/testServer'; -import { renderWithTheme } from 'src/utilities/testHelpers'; +import { renderWithThemeAndRouter } from 'src/utilities/testHelpers'; import { EditVolumeDrawer } from './EditVolumeDrawer'; @@ -24,7 +24,7 @@ describe('EditVolumeDrawer', () => { }) ); - const { getByLabelText } = renderWithTheme( + const { getByLabelText } = await renderWithThemeAndRouter( , { flags: { blockStorageEncryption: true }, @@ -48,7 +48,7 @@ describe('EditVolumeDrawer', () => { }) ); - const { queryByRole } = renderWithTheme( + const { queryByRole } = await renderWithThemeAndRouter( , { flags: { blockStorageEncryption: false }, @@ -69,7 +69,7 @@ describe('EditVolumeDrawer', () => { }) ); - const { queryByRole } = renderWithTheme( + const { queryByRole } = await renderWithThemeAndRouter( , { flags: { blockStorageEncryption: true }, diff --git a/packages/manager/src/features/Volumes/EditVolumeDrawer.tsx b/packages/manager/src/features/Volumes/EditVolumeDrawer.tsx index e617af813dc..02ced6ec1ab 100644 --- a/packages/manager/src/features/Volumes/EditVolumeDrawer.tsx +++ b/packages/manager/src/features/Volumes/EditVolumeDrawer.tsx @@ -18,13 +18,14 @@ import { import type { Volume } from '@linode/api-v4'; interface Props { + isFetching?: boolean; onClose: () => void; open: boolean; volume: Volume | undefined; } export const EditVolumeDrawer = (props: Props) => { - const { onClose: _onClose, open, volume } = props; + const { isFetching, onClose: _onClose, open, volume } = props; const { data: grants } = useGrants(); @@ -53,7 +54,7 @@ export const EditVolumeDrawer = (props: Props) => { values, } = useFormik({ enableReinitialize: true, - initialValues: { label: volume?.label, tags: volume?.tags }, + initialValues: { label: volume?.label ?? '', tags: volume?.tags ?? [] }, async onSubmit(values, { setErrors, setStatus }) { try { await updateVolume({ @@ -81,7 +82,12 @@ export const EditVolumeDrawer = (props: Props) => { }; return ( - + {isReadOnly && ( void; open: boolean; volume: Volume | undefined; } export const ResizeVolumeDrawer = (props: Props) => { - const { onClose: _onClose, open, volume } = props; + const { isFetching, onClose: _onClose, open, volume } = props; const { mutateAsync: resizeVolume } = useResizeVolumeMutation(); @@ -94,7 +95,12 @@ export const ResizeVolumeDrawer = (props: Props) => { }; return ( - + {isReadOnly && ( { }; interface Props { + isFetching?: boolean; onClose: () => void; open: boolean; volume: Volume | undefined; } export const UpgradeVolumeDialog = (props: Props) => { - const { onClose, open, volume } = props; + const { isFetching, onClose, open, volume } = props; const { enqueueSnackbar } = useSnackbar(); const { @@ -86,6 +87,7 @@ export const UpgradeVolumeDialog = (props: Props) => { : undefined } actions={actions} + isFetching={isFetching} onClose={onClose} open={open} title={`Upgrade Volume ${volume?.label}`} diff --git a/packages/manager/src/features/Volumes/VolumeCreate.test.tsx b/packages/manager/src/features/Volumes/VolumeCreate.test.tsx index 0e611ed9fb7..8ef03c93226 100644 --- a/packages/manager/src/features/Volumes/VolumeCreate.test.tsx +++ b/packages/manager/src/features/Volumes/VolumeCreate.test.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { accountFactory } from 'src/factories'; import { HttpResponse, http, server } from 'src/mocks/testServer'; -import { renderWithTheme } from 'src/utilities/testHelpers'; +import { renderWithThemeAndRouter } from 'src/utilities/testHelpers'; import { VolumeCreate } from './VolumeCreate'; @@ -12,7 +12,7 @@ const encryptVolumeSectionHeader = 'Encrypt Volume'; describe('VolumeCreate', () => { /* @TODO BSE: Remove feature flagging/conditionality once BSE is fully rolled out */ - it('should not have a "Volume Encryption" section visible if the user has the account capability but the feature flag is off', () => { + it('should not have a "Volume Encryption" section visible if the user has the account capability but the feature flag is off', async () => { server.use( http.get(accountEndpoint, () => { return HttpResponse.json( @@ -21,21 +21,21 @@ describe('VolumeCreate', () => { }) ); - const { queryByText } = renderWithTheme(, { + const { queryByText } = await renderWithThemeAndRouter(, { flags: { blockStorageEncryption: false }, }); expect(queryByText(encryptVolumeSectionHeader)).not.toBeInTheDocument(); }); - it('should not have a "Volume Encryption" section visible if the user does not have the account capability but the feature flag is on', () => { + it('should not have a "Volume Encryption" section visible if the user does not have the account capability but the feature flag is on', async () => { server.use( http.get(accountEndpoint, () => { return HttpResponse.json(accountFactory.build({ capabilities: [] })); }) ); - const { queryByText } = renderWithTheme(, { + const { queryByText } = await renderWithThemeAndRouter(, { flags: { blockStorageEncryption: true }, }); @@ -51,7 +51,7 @@ describe('VolumeCreate', () => { }) ); - const { findByText } = renderWithTheme(, { + const { findByText } = await renderWithThemeAndRouter(, { flags: { blockStorageEncryption: true }, }); diff --git a/packages/manager/src/features/Volumes/VolumeCreate.tsx b/packages/manager/src/features/Volumes/VolumeCreate.tsx index 404c5ae3851..aba8857e316 100644 --- a/packages/manager/src/features/Volumes/VolumeCreate.tsx +++ b/packages/manager/src/features/Volumes/VolumeCreate.tsx @@ -10,11 +10,10 @@ import { } from '@linode/ui'; import { CreateVolumeSchema } from '@linode/validation/lib/volumes.schema'; import { useTheme } from '@mui/material/styles'; -import { createLazyRoute } from '@tanstack/react-router'; +import { useNavigate } from '@tanstack/react-router'; import { useFormik } from 'formik'; import { useSnackbar } from 'notistack'; import * as React from 'react'; -import { useHistory } from 'react-router-dom'; import { makeStyles } from 'tss-react/mui'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; @@ -58,6 +57,7 @@ import { isNilOrEmpty } from 'src/utilities/isNilOrEmpty'; import { maybeCastToNumber } from 'src/utilities/maybeCastToNumber'; import { PRICES_RELOAD_ERROR_NOTICE_TEXT } from 'src/utilities/pricing/constants'; +import { SIZE_FIELD_WIDTH } from './constants'; import { ConfigSelect } from './VolumeDrawer/ConfigSelect'; import { SizeField } from './VolumeDrawer/SizeField'; @@ -65,8 +65,6 @@ import type { VolumeEncryption } from '@linode/api-v4'; import type { Linode } from '@linode/api-v4/lib/linodes/types'; import type { Theme } from '@mui/material/styles'; -export const SIZE_FIELD_WIDTH = 160; - const useStyles = makeStyles()((theme: Theme) => ({ agreement: { maxWidth: '70%', @@ -128,8 +126,8 @@ const useStyles = makeStyles()((theme: Theme) => ({ export const VolumeCreate = () => { const theme = useTheme(); + const navigate = useNavigate(); const { classes } = useStyles(); - const history = useHistory(); const { data: types, isError, isLoading } = useVolumeTypesQuery(); @@ -227,7 +225,13 @@ export const VolumeCreate = () => { enqueueSnackbar(`Volume scheduled for creation.`, { variant: 'success', }); - history.push('/volumes', { volume }); + navigate({ + params: { + action: 'details', + volumeId: volume.id, + }, + to: '/volumes/$volumeId/$action', + }); // Analytics Event sendCreateVolumeEvent(`Size: ${size}GB`, origin); }) @@ -548,7 +552,3 @@ const initialValues: FormState = { region: '', size: 20, }; - -export const volumeCreateLazyRoute = createLazyRoute('/volumes/create')({ - component: VolumeCreate, -}); diff --git a/packages/manager/src/features/Volumes/VolumeDetailsDrawer.tsx b/packages/manager/src/features/Volumes/VolumeDetailsDrawer.tsx index b853f802200..de610efee95 100644 --- a/packages/manager/src/features/Volumes/VolumeDetailsDrawer.tsx +++ b/packages/manager/src/features/Volumes/VolumeDetailsDrawer.tsx @@ -7,16 +7,22 @@ import { Drawer } from 'src/components/Drawer'; import type { Volume } from '@linode/api-v4'; interface Props { + isFetching?: boolean; onClose: () => void; open: boolean; volume: Volume | undefined; } export const VolumeDetailsDrawer = (props: Props) => { - const { onClose, open, volume } = props; + const { isFetching, onClose, open, volume } = props; return ( - + diff --git a/packages/manager/src/features/Volumes/VolumeDrawer/LinodeVolumeAddDrawer.test.tsx b/packages/manager/src/features/Volumes/VolumeDrawer/LinodeVolumeAddDrawer.test.tsx index 29cbd8395db..b1d99ae1105 100644 --- a/packages/manager/src/features/Volumes/VolumeDrawer/LinodeVolumeAddDrawer.test.tsx +++ b/packages/manager/src/features/Volumes/VolumeDrawer/LinodeVolumeAddDrawer.test.tsx @@ -3,7 +3,7 @@ import * as React from 'react'; import { accountFactory, linodeFactory } from 'src/factories'; import { HttpResponse, http, server } from 'src/mocks/testServer'; -import { renderWithTheme } from 'src/utilities/testHelpers'; +import { renderWithThemeAndRouter } from 'src/utilities/testHelpers'; import { LinodeVolumeAddDrawer } from './LinodeVolumeAddDrawer'; @@ -24,7 +24,7 @@ describe('LinodeVolumeAddDrawer', () => { }) ); - const { getByLabelText } = renderWithTheme( + const { getByLabelText } = await renderWithThemeAndRouter( { it("should show the attached Linode's label if present", async () => { - const { getByLabelText, getByTestId, getByText } = renderWithTheme( + const { + getByLabelText, + getByTestId, + getByText, + } = await renderWithThemeAndRouter( wrapWithTableBody( ) @@ -53,7 +60,7 @@ describe('Volume table row', () => { }); it('should show Unattached if the Volume is not attached to a Linode', async () => { - const { getByLabelText, getByText } = renderWithTheme( + const { getByLabelText, getByText } = await renderWithThemeAndRouter( wrapWithTableBody( ) @@ -79,7 +86,7 @@ describe('Volume table row', () => { }) ); - const { findByText } = renderWithTheme( + const { findByText } = await renderWithThemeAndRouter( wrapWithTableBody() ); @@ -99,7 +106,7 @@ describe('Volume table row', () => { }) ); - const { findByText } = renderWithTheme( + const { findByText } = await renderWithThemeAndRouter( wrapWithTableBody() ); @@ -110,7 +117,7 @@ describe('Volume table row', () => { it('should render the encryption status if isBlockStorageEncryptionFeatureEnabled is true', async () => { const volume = volumeFactory.build(); - const { findByText } = renderWithTheme( + const { findByText } = await renderWithThemeAndRouter( wrapWithTableBody( { it('should not render the encryption status if isBlockStorageEncryptionFeatureEnabled is false', async () => { const volume = volumeFactory.build(); - const { queryByText } = renderWithTheme( + const { queryByText } = await renderWithThemeAndRouter( wrapWithTableBody() ); @@ -141,7 +148,7 @@ describe('Volume table row - for linodes detail page', () => { getByText, queryByTestId, queryByText, - } = renderWithTheme( + } = await renderWithThemeAndRouter( wrapWithTableBody( import('./VolumesLanding')); - -const VolumeCreate = React.lazy(() => - import('./VolumeCreate').then((module) => ({ - default: module.VolumeCreate, - })) -); - -const Volumes = () => { - const { path } = useRouteMatch(); - - return ( - <> - - - - - - - - ); -}; - -export default Volumes; diff --git a/packages/manager/src/features/Volumes/VolumesActionMenu.test.tsx b/packages/manager/src/features/Volumes/VolumesActionMenu.test.tsx index ed035f5609c..20a1ad44bc6 100644 --- a/packages/manager/src/features/Volumes/VolumesActionMenu.test.tsx +++ b/packages/manager/src/features/Volumes/VolumesActionMenu.test.tsx @@ -2,9 +2,11 @@ import { userEvent } from '@testing-library/user-event'; import * as React from 'react'; import { volumeFactory } from 'src/factories'; -import { renderWithTheme } from 'src/utilities/testHelpers'; +import { renderWithThemeAndRouter } from 'src/utilities/testHelpers'; -import { Props, VolumesActionMenu } from './VolumesActionMenu'; +import { VolumesActionMenu } from './VolumesActionMenu'; + +import type { Props } from './VolumesActionMenu'; const volume = volumeFactory.build({ linode_id: null, linode_label: null }); @@ -25,7 +27,7 @@ const props: Props = { describe('Volume action menu', () => { it('should include basic Volume actions', async () => { - const { getByLabelText, getByText } = renderWithTheme( + const { getByLabelText, getByText } = await renderWithThemeAndRouter( ); @@ -41,7 +43,11 @@ describe('Volume action menu', () => { }); it('should include Attach if the Volume is not attached', async () => { - const { getByLabelText, getByText, queryByText } = renderWithTheme( + const { + getByLabelText, + getByText, + queryByText, + } = await renderWithThemeAndRouter( ); @@ -61,7 +67,11 @@ describe('Volume action menu', () => { linode_label: 'linode-2', }); - const { getByLabelText, getByText, queryByText } = renderWithTheme( + const { + getByLabelText, + getByText, + queryByText, + } = await renderWithThemeAndRouter( ); @@ -76,7 +86,7 @@ describe('Volume action menu', () => { }); it('should include Delete', async () => { - const { getByLabelText, getByText } = renderWithTheme( + const { getByLabelText, getByText } = await renderWithThemeAndRouter( ); diff --git a/packages/manager/src/features/Volumes/VolumesLanding.tsx b/packages/manager/src/features/Volumes/VolumesLanding.tsx index 36f4ca029e4..aa2a4305d67 100644 --- a/packages/manager/src/features/Volumes/VolumesLanding.tsx +++ b/packages/manager/src/features/Volumes/VolumesLanding.tsx @@ -5,9 +5,8 @@ import { TextField, } from '@linode/ui'; import CloseIcon from '@mui/icons-material/Close'; -import { createLazyRoute } from '@tanstack/react-router'; +import { useNavigate, useParams, useSearch } from '@tanstack/react-router'; import * as React from 'react'; -import { useHistory, useLocation } from 'react-router-dom'; import { debounce } from 'throttle-debounce'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; @@ -23,10 +22,16 @@ import { TableRow } from 'src/components/TableRow'; import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; import { TableSortCell } from 'src/components/TableSortCell'; import { getRestrictedResourceText } from 'src/features/Account/utils'; -import { useOrder } from 'src/hooks/useOrder'; -import { usePagination } from 'src/hooks/usePagination'; +import { useDialogData } from 'src/hooks/useDialogData'; +import { useOrderV2 } from 'src/hooks/useOrderV2'; +import { usePaginationV2 } from 'src/hooks/usePaginationV2'; import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; -import { useVolumesQuery } from 'src/queries/volumes/volumes'; +import { useVolumeQuery, useVolumesQuery } from 'src/queries/volumes/volumes'; +import { + VOLUME_TABLE_DEFAULT_ORDER, + VOLUME_TABLE_DEFAULT_ORDER_BY, +} from 'src/routes/volumes/constants'; +import { VOLUME_TABLE_PREFERENCE_KEY } from 'src/routes/volumes/constants'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { AttachVolumeDrawer } from './AttachVolumeDrawer'; @@ -41,33 +46,43 @@ import { VolumesLandingEmptyState } from './VolumesLandingEmptyState'; import { VolumeTableRow } from './VolumeTableRow'; import type { Filter, Volume } from '@linode/api-v4'; - -const preferenceKey = 'volumes'; -const searchQueryKey = 'query'; +import type { VolumesSearchParams } from 'src/routes/volumes/index'; export const VolumesLanding = () => { - const history = useHistory(); - const location = useLocation<{ volume: Volume | undefined }>(); - const pagination = usePagination(1, preferenceKey); + const navigate = useNavigate(); + const params = useParams({ strict: false }); + const search: VolumesSearchParams = useSearch({ + from: '/volumes', + }); + const pagination = usePaginationV2({ + currentRoute: '/volumes', + preferenceKey: VOLUME_TABLE_PREFERENCE_KEY, + searchParams: (prev) => ({ + ...prev, + query: search.query, + }), + }); const isRestricted = useRestrictedGlobalGrantCheck({ globalGrantType: 'add_volumes', }); - const queryParams = new URLSearchParams(location.search); - const volumeLabelFromParam = queryParams.get(searchQueryKey) ?? ''; + const { query } = search; - const { handleOrderChange, order, orderBy } = useOrder( - { - order: 'desc', - orderBy: 'label', + const { handleOrderChange, order, orderBy } = useOrderV2({ + initialRoute: { + defaultOrder: { + order: VOLUME_TABLE_DEFAULT_ORDER, + orderBy: VOLUME_TABLE_DEFAULT_ORDER_BY, + }, + from: '/volumes', }, - `${preferenceKey}-order` - ); + preferenceKey: VOLUME_TABLE_PREFERENCE_KEY, + }); const filter: Filter = { ['+order']: order, ['+order_by']: orderBy, - ...(volumeLabelFromParam && { - label: { '+contains': volumeLabelFromParam }, + ...(query && { + label: { '+contains': query }, }), }; @@ -83,69 +98,103 @@ export const VolumesLanding = () => { isBlockStorageEncryptionFeatureEnabled, } = useIsBlockStorageEncryptionFeatureEnabled(); - const [selectedVolumeId, setSelectedVolumeId] = React.useState(); - const [isDetailsDrawerOpen, setIsDetailsDrawerOpen] = React.useState( - Boolean(location.state?.volume) - ); - const [isEditDrawerOpen, setIsEditDrawerOpen] = React.useState(false); - const [isResizeDrawerOpen, setIsResizeDrawerOpen] = React.useState(false); - const [isCloneDrawerOpen, setIsCloneDrawerOpen] = React.useState(false); - const [isAttachDrawerOpen, setIsAttachDrawerOpen] = React.useState(false); - const [isDetachDialogOpen, setIsDetachDialogOpen] = React.useState(false); - const [isDeleteDialogOpen, setIsDeleteDialogOpen] = React.useState(false); - const [isUpgradeDialogOpen, setIsUpgradeDialogOpen] = React.useState(false); - - const selectedVolume = volumes?.data.find((v) => v.id === selectedVolumeId); + const { data: selectedVolume, isFetching: isFetchingVolume } = useDialogData({ + enabled: !!params.volumeId, + paramKey: 'volumeId', + queryHook: useVolumeQuery, + redirectToOnNotFound: '/volumes', + }); const handleDetach = (volume: Volume) => { - setSelectedVolumeId(volume.id); - setIsDetachDialogOpen(true); + navigate({ + params: { action: 'detach', volumeId: volume.id }, + search: (prev) => prev, + to: `/volumes/$volumeId/$action`, + }); }; const handleDelete = (volume: Volume) => { - setSelectedVolumeId(volume.id); - setIsDeleteDialogOpen(true); + navigate({ + params: { action: 'delete', volumeId: volume.id }, + search: (prev) => prev, + to: `/volumes/$volumeId/$action`, + }); }; const handleDetails = (volume: Volume) => { - setSelectedVolumeId(volume.id); - setIsDetailsDrawerOpen(true); + navigate({ + params: { action: 'details', volumeId: volume.id }, + search: (prev) => prev, + to: `/volumes/$volumeId/$action`, + }); }; const handleEdit = (volume: Volume) => { - setSelectedVolumeId(volume.id); - setIsEditDrawerOpen(true); + navigate({ + params: { action: 'edit', volumeId: volume.id }, + search: (prev) => prev, + to: `/volumes/$volumeId/$action`, + }); }; const handleResize = (volume: Volume) => { - setSelectedVolumeId(volume.id); - setIsResizeDrawerOpen(true); + navigate({ + params: { action: 'resize', volumeId: volume.id }, + search: (prev) => prev, + to: `/volumes/$volumeId/$action`, + }); }; const handleClone = (volume: Volume) => { - setSelectedVolumeId(volume.id); - setIsCloneDrawerOpen(true); + navigate({ + params: { action: 'clone', volumeId: volume.id }, + search: (prev) => prev, + to: `/volumes/$volumeId/$action`, + }); }; const handleAttach = (volume: Volume) => { - setSelectedVolumeId(volume.id); - setIsAttachDrawerOpen(true); + navigate({ + params: { action: 'attach', volumeId: volume.id }, + search: (prev) => prev, + to: `/volumes/$volumeId/$action`, + }); }; const handleUpgrade = (volume: Volume) => { - setSelectedVolumeId(volume.id); - setIsUpgradeDialogOpen(true); + navigate({ + params: { action: 'upgrade', volumeId: volume.id }, + search: (prev) => prev, + to: `/volumes/$volumeId/$action`, + }); }; const resetSearch = () => { - queryParams.delete(searchQueryKey); - history.push({ search: queryParams.toString() }); + navigate({ + search: (prev) => ({ + ...prev, + query: undefined, + }), + to: '/volumes', + }); }; const onSearch = (e: React.ChangeEvent) => { - queryParams.delete('page'); - queryParams.set(searchQueryKey, e.target.value); - history.push({ search: queryParams.toString() }); + navigate({ + search: (prev) => ({ + ...prev, + page: undefined, + query: e.target.value || undefined, + }), + to: '/volumes', + }); + }; + + const navigateToVolumes = () => { + navigate({ + search: (prev) => prev, + to: '/volumes', + }); }; if (isLoading) { @@ -162,7 +211,7 @@ export const VolumesLanding = () => { ); } - if (volumes?.results === 0 && !volumeLabelFromParam) { + if (volumes?.results === 0 && !query) { return ; } @@ -171,7 +220,7 @@ export const VolumesLanding = () => { { disabledCreateButton={isRestricted} docsLink="https://techdocs.akamai.com/cloud-computing/docs/block-storage" entity="Volume" - onButtonClick={() => history.push('/volumes/create')} + onButtonClick={() => navigate({ to: '/volumes/create' })} title="Volumes" /> {isFetching && } @@ -203,6 +252,7 @@ export const VolumesLanding = () => { ), + sx: { mb: 2 }, }} onChange={debounce(400, (e) => { onSearch(e); @@ -210,8 +260,7 @@ export const VolumesLanding = () => { hideLabel label="Search" placeholder="Search Volumes" - sx={{ mb: 2 }} - value={volumeLabelFromParam} + value={query ?? ''} /> @@ -282,56 +331,53 @@ export const VolumesLanding = () => { pageSize={pagination.pageSize} /> setIsAttachDrawerOpen(false)} - open={isAttachDrawerOpen} + isFetching={isFetchingVolume} + onClose={navigateToVolumes} + open={params.action === 'attach'} volume={selectedVolume} /> { - setIsDetailsDrawerOpen(false); - if (location.state?.volume) { - window.history.replaceState(null, ''); - } - }} - open={isDetailsDrawerOpen} - volume={selectedVolume ?? location.state?.volume} + isFetching={isFetchingVolume} + onClose={navigateToVolumes} + open={params.action === 'details'} + volume={selectedVolume} /> setIsEditDrawerOpen(false)} - open={isEditDrawerOpen} + isFetching={isFetchingVolume} + onClose={navigateToVolumes} + open={params.action === 'edit'} volume={selectedVolume} /> setIsResizeDrawerOpen(false)} - open={isResizeDrawerOpen} + isFetching={isFetchingVolume} + onClose={navigateToVolumes} + open={params.action === 'resize'} volume={selectedVolume} /> setIsCloneDrawerOpen(false)} - open={isCloneDrawerOpen} + isFetching={isFetchingVolume} + onClose={navigateToVolumes} + open={params.action === 'clone'} volume={selectedVolume} /> setIsDetachDialogOpen(false)} - open={isDetachDialogOpen} + isFetching={isFetchingVolume} + onClose={navigateToVolumes} + open={params.action === 'detach'} volume={selectedVolume} /> setIsUpgradeDialogOpen(false)} - open={isUpgradeDialogOpen} + isFetching={isFetchingVolume} + onClose={navigateToVolumes} + open={params.action === 'upgrade'} volume={selectedVolume} /> setIsDeleteDialogOpen(false)} - open={isDeleteDialogOpen} + isFetching={isFetchingVolume} + onClose={navigateToVolumes} + open={params.action === 'delete'} volume={selectedVolume} /> ); }; - -export default VolumesLanding; - -export const volumesLandingLazyRoute = createLazyRoute('/')({ - component: VolumesLanding, -}); diff --git a/packages/manager/src/features/Volumes/VolumesLandingEmptyState.test.tsx b/packages/manager/src/features/Volumes/VolumesLandingEmptyState.test.tsx index 67573292ac4..5db19c1234f 100644 --- a/packages/manager/src/features/Volumes/VolumesLandingEmptyState.test.tsx +++ b/packages/manager/src/features/Volumes/VolumesLandingEmptyState.test.tsx @@ -4,7 +4,7 @@ import React from 'react'; import { grantsFactory, profileFactory } from 'src/factories'; import { makeResourcePage } from 'src/mocks/serverHandlers'; import { HttpResponse, http, server } from 'src/mocks/testServer'; -import { renderWithTheme } from 'src/utilities/testHelpers'; +import { renderWithThemeAndRouter } from 'src/utilities/testHelpers'; import { VolumesLandingEmptyState } from './VolumesLandingEmptyState'; @@ -24,7 +24,9 @@ describe('VolumesLandingEmptyState', () => { }) ); - const { getByText } = renderWithTheme(); + const { getByText } = await renderWithThemeAndRouter( + + ); await waitFor(() => { const createVolumeButton = getByText('Create Volume').closest('button'); diff --git a/packages/manager/src/features/Volumes/VolumesLandingEmptyState.tsx b/packages/manager/src/features/Volumes/VolumesLandingEmptyState.tsx index a7cad37c634..85d3ac47d11 100644 --- a/packages/manager/src/features/Volumes/VolumesLandingEmptyState.tsx +++ b/packages/manager/src/features/Volumes/VolumesLandingEmptyState.tsx @@ -1,5 +1,5 @@ +import { useNavigate } from '@tanstack/react-router'; import * as React from 'react'; -import { useHistory } from 'react-router-dom'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { ResourcesSection } from 'src/components/EmptyLandingPageResources/ResourcesSection'; @@ -16,7 +16,7 @@ import { } from './VolumesLandingEmptyStateData'; export const VolumesLandingEmptyState = () => { - const { push } = useHistory(); + const navigate = useNavigate(); const isRestricted = useRestrictedGlobalGrantCheck({ globalGrantType: 'add_volumes', @@ -36,7 +36,7 @@ export const VolumesLandingEmptyState = () => { category: linkAnalyticsEvent.category, label: 'Create Volume', }); - push('/volumes/create'); + navigate({ to: '/volumes/create' }); }, tooltipText: getRestrictedResourceText({ action: 'create', diff --git a/packages/manager/src/features/Volumes/VolumesLandingEmptyStateData.ts b/packages/manager/src/features/Volumes/VolumesLandingEmptyStateData.ts index cae976e2c71..47cf2552fa1 100644 --- a/packages/manager/src/features/Volumes/VolumesLandingEmptyStateData.ts +++ b/packages/manager/src/features/Volumes/VolumesLandingEmptyStateData.ts @@ -26,7 +26,8 @@ export const gettingStartedGuides: ResourcesLinkSection = { }, { text: 'Create and Manage Block Storage Volumes', - to: 'https://techdocs.akamai.com/cloud-computing/docs/block-storage', + to: + 'https://techdocs.akamai.com/cloud-computing/docs/manage-block-storage-volumes', }, { text: 'Configure a Volume on a Compute Instance', diff --git a/packages/manager/src/features/Volumes/constants.ts b/packages/manager/src/features/Volumes/constants.ts new file mode 100644 index 00000000000..d165b54e603 --- /dev/null +++ b/packages/manager/src/features/Volumes/constants.ts @@ -0,0 +1 @@ +export const SIZE_FIELD_WIDTH = 160; diff --git a/packages/manager/src/features/Volumes/index.tsx b/packages/manager/src/features/Volumes/index.tsx deleted file mode 100644 index 2eeec19097e..00000000000 --- a/packages/manager/src/features/Volumes/index.tsx +++ /dev/null @@ -1,2 +0,0 @@ -import Volumes from './Volumes'; -export default Volumes; diff --git a/packages/manager/src/hooks/useDialogData.ts b/packages/manager/src/hooks/useDialogData.ts new file mode 100644 index 00000000000..47f8890f4df --- /dev/null +++ b/packages/manager/src/hooks/useDialogData.ts @@ -0,0 +1,100 @@ +import { useNavigate, useParams } from '@tanstack/react-router'; +import { useSnackbar } from 'notistack'; +import * as React from 'react'; + +import type { APIError } from '@linode/api-v4'; +import type { UseQueryResult } from '@tanstack/react-query'; +import type { AllParams, LinkProps } from '@tanstack/react-router'; +import type { MigrationRouteTree } from 'src/routes'; + +type ParamsType = AllParams; + +interface Props { + /** + * If false, the query will not be run. + * + * @default true + */ + enabled?: boolean; + /** + * The key of the parameter in the URL that will be used to fetch the entity. + * ex: 'volumeId' for `/volumes/$volumeId` + */ + paramKey: keyof ParamsType; + /** + * The query hook to fetch the entity. + */ + queryHook: ( + id: number | string | undefined, + enabled?: boolean + ) => UseQueryResult; + /** + * The route to redirect to if the entity is not found. + */ + redirectToOnNotFound: LinkProps['to']; +} + +/** + * This hook is used to fetch data for a dialog routed via Tanstack Router (Drawer, Modal, etc.) + * + * It can't be used outside of a feature that hasn't been migrated to Tanstack Router. + * It will return the data for the entity that the dialog is going to target, including its loading state. + * It is usually used on a feature landing page, where the dialog is triggered by a route change. + * + * It should be instantiated as follow: + * + * const { + * data: {entity}, + * isFetching: isFetchingEntity, + * } = useDialogRouteGuard({ + * enabled: !!params.entityId, + * paramKey: 'entityId', + * queryHook: useEntityQuery, // ex: useVolumeQuery + * redirectToOnNotFound: '/entities', // ex: '/volumes' + * }); + */ +export const useDialogData = ({ + enabled = true, + paramKey, + queryHook, + redirectToOnNotFound, +}: Props) => { + const params = useParams({ strict: false }); + const { enqueueSnackbar } = useSnackbar(); + const navigate = useNavigate(); + const query = queryHook(params[paramKey], enabled); + + React.useEffect(() => { + if (enabled && !query.isLoading && !query.data) { + enqueueSnackbar('Not found!', { variant: 'error' }); + navigate({ + params: {}, + to: redirectToOnNotFound, + }); + } + }, [ + enabled, + query.isLoading, + query.data, + enqueueSnackbar, + navigate, + redirectToOnNotFound, + ]); + + return { + data: query.data, + isFetching: query.isFetching, + isLoading: query.isLoading, + }; +}; + +// instance: +// const { +// data: selectedVolume, +// isFetching: isFetchingSelectedVolume, +// } = useDialogRouteGuard({ +// enabled: !!params.volumeId, +// paramKey: 'volumeId', +// queryHook: useVolumeQuery, +// redirectToOnNotFound: '/volumes', +// }); diff --git a/packages/manager/src/hooks/useOrder.ts b/packages/manager/src/hooks/useOrder.ts index ba19fc39c1b..20d3aa76535 100644 --- a/packages/manager/src/hooks/useOrder.ts +++ b/packages/manager/src/hooks/useOrder.ts @@ -16,7 +16,7 @@ export type Order = 'asc' | 'desc'; /** * useOrder is a hook that allows you to handle ordering tables. It takes into account - * the following items when determining inital order + * the following items when determining initial order * 1. Query Params (Ex. ?order=asc&orderBy=status) * 2. User Preference * 3. Initial Order passed as params diff --git a/packages/manager/src/hooks/useOrderV2.test.tsx b/packages/manager/src/hooks/useOrderV2.test.tsx new file mode 100644 index 00000000000..6e06c77020e --- /dev/null +++ b/packages/manager/src/hooks/useOrderV2.test.tsx @@ -0,0 +1,155 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; + +import { HttpResponse, http, server } from 'src/mocks/testServer'; +import { queryClientFactory } from 'src/queries/base'; +import { wrapWithThemeAndRouter } from 'src/utilities/testHelpers'; + +import { useOrderV2 } from './useOrderV2'; + +import type { UseOrderV2Props } from './useOrderV2'; + +const mockNavigate = vi.fn(); +const mockUseSearch = vi.fn(() => ({})); + +vi.mock('@tanstack/react-router', async () => { + const actual = await vi.importActual('@tanstack/react-router'); + return { + ...actual, + useNavigate: vi.fn(() => mockNavigate), + useSearch: vi.fn(() => mockUseSearch()), + }; +}); + +const queryClient = queryClientFactory(); +const defaultProps: UseOrderV2Props = { + initialRoute: { + defaultOrder: { + order: 'asc', + orderBy: 'label', + }, + from: '/', + }, + preferenceKey: 'volumes', +}; + +describe('useOrderV2', () => { + beforeEach(() => { + vi.clearAllMocks(); + queryClient.clear(); + }); + + it('should use URL params with prefix', async () => { + mockUseSearch.mockReturnValue({ + 'test-order': 'desc', + 'test-orderBy': 'status', + }); + + const { result } = renderHook( + () => useOrderV2({ ...defaultProps, prefix: 'test' }), + { + wrapper: (ui) => wrapWithThemeAndRouter(ui.children, { queryClient }), + } + ); + + await waitFor(() => { + expect(result.current.order).toBe('desc'); + expect(result.current.orderBy).toBe('status'); + }); + }); + + it('should use preferences when present and no URL params are provided', async () => { + mockUseSearch.mockReturnValue({}); + + server.use( + http.get('*/profile/preferences', () => { + return HttpResponse.json({ + sortKeys: { + volumes: { + order: 'desc', + orderBy: 'size', + }, + }, + }); + }) + ); + + const { result } = renderHook(() => useOrderV2(defaultProps), { + wrapper: (ui) => wrapWithThemeAndRouter(ui.children, { queryClient }), + }); + + await waitFor(() => { + expect(result.current.order).toBe('desc'); + expect(result.current.orderBy).toBe('size'); + }); + }); + + it('should use default values as last priority', async () => { + mockUseSearch.mockReturnValue({}); + + server.use( + http.get('*/profile/preferences', () => { + return HttpResponse.json({ sortKeys: {} }); + }) + ); + + const { result } = renderHook(() => useOrderV2(defaultProps), { + wrapper: (ui) => wrapWithThemeAndRouter(ui.children, { queryClient }), + }); + + await waitFor(() => { + expect(result.current.order).toBe( + defaultProps.initialRoute.defaultOrder.order + ); + expect(result.current.orderBy).toBe( + defaultProps.initialRoute.defaultOrder.orderBy + ); + }); + }); + + it('should update URL and preferences when handleOrderChange is called', async () => { + const mutatePreferencesMock = vi.fn(); + server.use( + http.put('*/profile/preferences', async ({ request }) => { + const body = await request.json(); + mutatePreferencesMock(body); + return HttpResponse.json(body); + }) + ); + + mockUseSearch.mockReturnValue({}); + + const { result } = renderHook(() => useOrderV2(defaultProps), { + wrapper: (ui) => wrapWithThemeAndRouter(ui.children, { queryClient }), + }); + + act(() => { + result.current.handleOrderChange('size', 'desc'); + }); + + mockUseSearch.mockReturnValue({ + order: 'desc', + orderBy: 'size', + }); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith( + expect.objectContaining({ + search: expect.any(Function), + to: '/', + }) + ); + expect(mutatePreferencesMock).toHaveBeenCalledWith( + expect.objectContaining({ + sortKeys: expect.objectContaining({ + volumes: { + order: 'desc', + orderBy: 'size', + }, + }), + }) + ); + expect(result.current.order).toBe('desc'); + expect(result.current.orderBy).toBe('size'); + }); + }); +}); diff --git a/packages/manager/src/hooks/useOrderV2.ts b/packages/manager/src/hooks/useOrderV2.ts new file mode 100644 index 00000000000..cdc4425709e --- /dev/null +++ b/packages/manager/src/hooks/useOrderV2.ts @@ -0,0 +1,120 @@ +import { useNavigate, useSearch } from '@tanstack/react-router'; + +import { + useMutatePreferences, + usePreferences, +} from 'src/queries/profile/preferences'; + +import type { RoutePaths } from '@tanstack/react-router'; +import type { MigrationRouteTree } from 'src/routes'; +import type { OrderSetWithPrefix } from 'src/types/ManagerPreferences'; + +export type Order = 'asc' | 'desc'; + +export interface UseOrderV2Props { + /** + * initial order to use when no query params are present + * Includes the from and search params + */ + initialRoute: { + defaultOrder: { + order: Order; + orderBy: string; + }; + from: RoutePaths; + }; + /** + * preference key to save to user preferences + */ + preferenceKey: string; + /** + * prefix for the query params in the url + */ + prefix?: string; +} + +/** + * useOrder is a hook that allows you to handle ordering tables. + * It takes into account the following items when determining initial order: + * 1. Query Params (Ex. ?order=asc&orderBy=status) + * 2. User Preference + * 3. Initial Order + * + * When a user changes order using the handleOrderChange function, the query params are + * updated and the user preferences are also updated. + */ +export const useOrderV2 = ({ + initialRoute, + preferenceKey, + prefix, +}: UseOrderV2Props) => { + const { data: preferences } = usePreferences(); + const { mutateAsync: updatePreferences } = useMutatePreferences(); + const searchParams = useSearch({ from: initialRoute.from }); + const navigate = useNavigate(); + + const getOrderValues = () => { + // 1. URL params with prefix + if ( + prefix && + `${prefix}-order` in searchParams && + `${prefix}-orderBy` in searchParams + ) { + const prefixedParams = searchParams as OrderSetWithPrefix; + return { + order: prefixedParams[`${prefix}-order`], + orderBy: prefixedParams[`${prefix}-orderBy`], + }; + } + + // 2. Regular URL params + if ('order' in searchParams && 'orderBy' in searchParams) { + return { + order: searchParams.order as Order, + orderBy: searchParams.orderBy as string, + }; + } + + // 3. Stored preferences + const prefKey = prefix ? `${prefix}-${preferenceKey}` : preferenceKey; + if (preferenceKey && preferences?.sortKeys?.[prefKey]) { + return preferences.sortKeys[prefKey]; + } + + // 4. Default values + return initialRoute.defaultOrder; + }; + + const { order, orderBy } = getOrderValues(); + + const handleOrderChange = (newOrderBy: string, newOrder: Order) => { + const urlData = prefix + ? { + [`${prefix}-order`]: newOrder, + [`${prefix}-orderBy`]: newOrderBy, + } + : { + order: newOrder, + orderBy: newOrderBy, + }; + + navigate({ + search: (prev) => ({ + ...prev, + ...searchParams, + ...urlData, + }), + to: initialRoute.from, + }); + + const prefKey = prefix ? `${prefix}-${preferenceKey}` : preferenceKey; + updatePreferences({ + sortKeys: { + ...(preferences?.sortKeys ?? {}), + [prefKey]: { order: newOrder, orderBy: newOrderBy }, + }, + }); + }; + + return { handleOrderChange, order, orderBy }; +}; diff --git a/packages/manager/src/hooks/usePaginationV2.test.ts b/packages/manager/src/hooks/usePaginationV2.test.ts new file mode 100644 index 00000000000..17b5c3a25a7 --- /dev/null +++ b/packages/manager/src/hooks/usePaginationV2.test.ts @@ -0,0 +1,258 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { vi } from 'vitest'; + +import { MIN_PAGE_SIZE } from 'src/components/PaginationFooter/PaginationFooter'; +import { HttpResponse, http, server } from 'src/mocks/testServer'; +import { wrapWithThemeAndRouter } from 'src/utilities/testHelpers'; + +import { usePaginationV2 } from './usePaginationV2'; + +import type { UsePaginationV2Props } from './usePaginationV2'; +import type { TableSearchParams } from 'src/routes/types'; + +const mockNavigate = vi.fn(); +vi.mock('@tanstack/react-router', async () => { + const actual = await vi.importActual('@tanstack/react-router'); + return { + ...actual, + useNavigate: () => mockNavigate, + useSearch: () => ({ + order: undefined, + orderBy: undefined, + page: undefined, + pageSize: undefined, + }), + }; +}); + +describe('usePaginationV2', () => { + const mockUpdatePreferences = vi.fn(); + const mockPreferences = { + pageSizes: { + 'test-key': 50, + }, + }; + + beforeEach(() => { + server.use( + http.get('*/profile/preferences', () => { + return HttpResponse.json(mockPreferences); + }), + http.put('*/profile/preferences', async ({ request }) => { + const body = await request.json(); + mockUpdatePreferences(body); + + return HttpResponse.json(body); + }) + ); + + mockNavigate.mockClear(); + mockUpdatePreferences.mockClear(); + }); + + const defaultProps: UsePaginationV2Props = { + currentRoute: '/linodes', + initialPage: 1, + preferenceKey: 'test-key', + }; + + it('should initialize with default values', async () => { + const { result } = renderHook(() => usePaginationV2(defaultProps), { + wrapper: (ui) => wrapWithThemeAndRouter(ui.children), + }); + + await waitFor(() => { + expect(result.current).toEqual({ + handlePageChange: expect.any(Function), + handlePageSizeChange: expect.any(Function), + page: 1, + pageSize: 25, + }); + }); + }); + + it('should use MIN_PAGE_SIZE when no preference exists', async () => { + const { result } = renderHook( + () => + usePaginationV2({ + ...defaultProps, + preferenceKey: 'non-existent-key', + }), + { + wrapper: (ui) => wrapWithThemeAndRouter(ui.children), + } + ); + + await waitFor(() => { + expect(result.current.pageSize).toBe(MIN_PAGE_SIZE); + }); + }); + + it('should handle page changes', async () => { + const { result } = renderHook(() => usePaginationV2(defaultProps), { + wrapper: (ui) => wrapWithThemeAndRouter(ui.children), + }); + + act(() => { + result.current.handlePageChange(2); + }); + + expect(mockNavigate).toHaveBeenCalledWith({ + search: expect.any(Function), + to: '/linodes', + }); + + const searchFn = mockNavigate.mock.calls[0][0].search; + const prevParams = { + order: 'asc', + orderBy: 'name', + page: 1, + pageSize: 25, + }; + + await waitFor(() => { + expect(searchFn(prevParams)).toEqual({ + ...prevParams, + page: 2, + }); + }); + }); + + it('should handle page size changes', async () => { + const { result } = renderHook(() => usePaginationV2(defaultProps), { + wrapper: (ui) => wrapWithThemeAndRouter(ui.children), + }); + + act(() => { + result.current.handlePageSizeChange(50); + }); + + await waitFor(() => { + expect(mockUpdatePreferences).toHaveBeenCalledWith({ + pageSizes: { + 'test-key': 50, + }, + 'test-key': undefined, + }); + }); + + // Should navigate twice - once for page size and once for resetting page to 1 + expect(mockNavigate).toHaveBeenCalledTimes(2); + + const pageSizeSearchFn = mockNavigate.mock.calls[0][0].search; + const prevParams = { + order: 'asc', + orderBy: 'name', + page: 2, + pageSize: 50, + }; + + await waitFor(() => { + expect(pageSizeSearchFn(prevParams)).toEqual({ + ...prevParams, + pageSize: 50, + }); + }); + }); + + it('should handle custom search params', async () => { + const customSearchParams = (prev: any) => ({ + ...prev, + customParam: 'test', + }); + + const { result } = renderHook( + () => + usePaginationV2({ + ...defaultProps, + searchParams: customSearchParams, + }), + { + wrapper: (ui) => wrapWithThemeAndRouter(ui.children), + } + ); + + act(() => { + result.current.handlePageChange(2); + }); + + const searchFn = mockNavigate.mock.calls[0][0].search; + const prevParams = { + order: 'asc', + orderBy: 'name', + page: 1, + pageSize: 25, + }; + + await waitFor(() => { + expect(searchFn(prevParams)).toEqual({ + ...prevParams, + customParam: 'test', + page: 2, + }); + }); + }); + + it('should not update preferences if no preferenceKey is provided', async () => { + const { result } = renderHook( + () => + usePaginationV2({ + ...defaultProps, + preferenceKey: '', + }), + { + wrapper: (ui) => wrapWithThemeAndRouter(ui.children), + } + ); + + act(() => { + result.current.handlePageSizeChange(25); + }); + + await waitFor(() => { + expect(mockUpdatePreferences).not.toHaveBeenCalled(); + }); + }); + + it('should handle the queryParamsPrefix for both the page and pageSize params', async () => { + const { result } = renderHook( + () => + usePaginationV2({ + ...defaultProps, + queryParamsPrefix: 'test-prefix', + }), + { + wrapper: (ui) => wrapWithThemeAndRouter(ui.children), + } + ); + + act(() => { + result.current.handlePageChange(2); + result.current.handlePageSizeChange(50); + }); + + const navigateCalls = mockNavigate.mock.calls; + const searchFnForPage = navigateCalls[0][0].search; + const searchFnForPageSize = navigateCalls[1][0].search; + const prevParams = { + order: 'asc', + orderBy: 'name', + }; + + await waitFor(() => { + expect(searchFnForPage(prevParams)).toEqual({ + ...prevParams, + page: undefined, + pageSize: undefined, + 'test-prefix-page': 2, + }); + + expect(searchFnForPageSize(prevParams)).toEqual({ + ...prevParams, + page: undefined, + pageSize: undefined, + 'test-prefix-pageSize': 50, + }); + }); + }); +}); diff --git a/packages/manager/src/hooks/usePaginationV2.ts b/packages/manager/src/hooks/usePaginationV2.ts new file mode 100644 index 00000000000..991b125156b --- /dev/null +++ b/packages/manager/src/hooks/usePaginationV2.ts @@ -0,0 +1,125 @@ +import { useNavigate, useSearch } from '@tanstack/react-router'; + +import { MIN_PAGE_SIZE } from 'src/components/PaginationFooter/PaginationFooter'; +import { + useMutatePreferences, + usePreferences, +} from 'src/queries/profile/preferences'; + +import type { ToSubOptions } from '@tanstack/react-router'; +import type { TableSearchParams } from 'src/routes/types'; + +export interface PaginationPropsV2 { + handlePageChange: (page: number) => void; + handlePageSizeChange: (pageSize: number) => void; + page: number; + pageSize: number; +} + +const setTableSearchParams = (prev: TableSearchParams) => ({ + order: prev.order, + orderBy: prev.orderBy, + page: prev.page, + pageSize: prev.pageSize, +}); + +export interface UsePaginationV2Props { + /** + * The route to which the pagination params are applied. + */ + currentRoute: ToSubOptions['to']; + /** + * The initial page pagination is set to - defaults to 1, it's unusual to set this. + * @default 1 + */ + initialPage?: number; + /** + * A key used to store a user's preferred page size for a specific table. + */ + preferenceKey: string; + /** + * A prefix that is applied to the query params in the url. Useful when this hook is used more than once on the same page. + * ex: two different sortable tables on the same route. + */ + queryParamsPrefix?: string; + /** + * A function that is called to build the search params for the route. + */ + searchParams?: (prev: T) => T; +} + +export const usePaginationV2 = ({ + currentRoute, + initialPage = 1, + preferenceKey, + queryParamsPrefix, + searchParams, +}: UsePaginationV2Props): PaginationPropsV2 => { + const { data: preferences } = usePreferences(); + const { mutateAsync: updatePreferences } = useMutatePreferences(); + + const search: TableSearchParams = useSearch({ strict: false }); + const navigate = useNavigate(); + + const searchParamPage = search.page; + const searchParamPageSize = search.pageSize; + + const pageKey = queryParamsPrefix ? `${queryParamsPrefix}-page` : 'page'; + const pageSizeKey = queryParamsPrefix + ? `${queryParamsPrefix}-pageSize` + : 'pageSize'; + + const preferredPageSize = preferenceKey + ? preferences?.pageSizes?.[preferenceKey] ?? MIN_PAGE_SIZE + : MIN_PAGE_SIZE; + + const page = searchParamPage ? Number(searchParamPage) : initialPage; + const pageSize = searchParamPageSize + ? Number(searchParamPageSize) + : preferredPageSize; + + const setPage = (page: number) => { + navigate({ + search: (prev: TableSearchParams & T) => ({ + ...setTableSearchParams(prev), + ...(searchParams?.(prev) ?? {}), + [pageKey]: page, + ...(queryParamsPrefix ? {} : { page }), + }), + to: currentRoute, + }); + }; + + const setPageSize = (pageSize: number) => { + navigate({ + search: (prev: TableSearchParams & T) => ({ + ...setTableSearchParams(prev), + ...(searchParams?.(prev) ?? {}), + [pageSizeKey]: pageSize, + ...(queryParamsPrefix ? {} : { pageSize }), + }), + to: currentRoute, + }); + }; + + const handlePageSizeChange = (newPageSize: number) => { + setPageSize(newPageSize); + setPage(1); + if (preferenceKey) { + updatePreferences({ + pageSizes: { + ...(preferences?.pageSizes ?? {}), + [preferenceKey]: newPageSize, + }, + [preferenceKey]: undefined, // This may seem weird, but this cleans up the old format so user's preferences don't get too big + }); + } + }; + + return { + handlePageChange: setPage, + handlePageSizeChange, + page, + pageSize, + }; +}; diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index f0df0bde8e2..88c33b8d5d8 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -2346,7 +2346,7 @@ export const handlers = [ http.post( '*/monitor/services/:service_type/alert-definitions', async ({ request }) => { - const types: AlertDefinitionType[] = ['custom', 'default']; + const types: AlertDefinitionType[] = ['system', 'user']; const status: AlertStatusType[] = ['enabled', 'disabled']; const severity: AlertSeverityType[] = [0, 1, 2, 3]; const users = ['user1', 'user2', 'user3']; @@ -2365,6 +2365,32 @@ export const handlers = [ return HttpResponse.json(response); } ), + http.get('*/monitor/alert-definitions', async ({ request }) => { + const customAlerts = alertFactory.buildList(2, { + severity: 0, + type: 'user', + }); + const customAlertsWithServiceType = alertFactory.buildList(2, { + service_type: 'dbaas', + severity: 1, + type: 'user', + }); + const defaultAlerts = alertFactory.buildList(1, { type: 'system' }); + const defaultAlertsWithServiceType = alertFactory.buildList(1, { + service_type: 'dbaas', + severity: 3, + type: 'system', + }); + const alerts = [ + ...defaultAlerts, + ...alertFactory.buildList(3, { status: 'disabled' }), + ...customAlerts, + ...defaultAlertsWithServiceType, + ...alertFactory.buildList(3), + ...customAlertsWithServiceType, + ]; + return HttpResponse.json(makeResourcePage(alerts)); + }), http.get('*/monitor/services', () => { const response: ServiceTypesList = { data: [ diff --git a/packages/manager/src/mocks/utilities/response.ts b/packages/manager/src/mocks/utilities/response.ts index d904fa5d76e..e4b0ae503dd 100644 --- a/packages/manager/src/mocks/utilities/response.ts +++ b/packages/manager/src/mocks/utilities/response.ts @@ -108,7 +108,7 @@ export const makePaginatedResponse = ({ typeof item === 'object' && item !== null && key in item && - String(item).toLowerCase().includes(searchValue) + String(item[key]).toLowerCase().includes(searchValue) ); }) ) diff --git a/packages/manager/src/queries/cloudpulse/alerts.ts b/packages/manager/src/queries/cloudpulse/alerts.ts index 0da27a07093..a744f80d719 100644 --- a/packages/manager/src/queries/cloudpulse/alerts.ts +++ b/packages/manager/src/queries/cloudpulse/alerts.ts @@ -1,6 +1,12 @@ import { createAlertDefinition } from '@linode/api-v4/lib/cloudpulse'; -import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { + keepPreviousData, + useMutation, + useQuery, + useQueryClient, +} from '@tanstack/react-query'; +import { queryPresets } from '../base'; import { queryFactory } from './queries'; import type { @@ -8,7 +14,7 @@ import type { AlertServiceType, CreateAlertDefinitionPayload, } from '@linode/api-v4/lib/cloudpulse'; -import type { APIError } from '@linode/api-v4/lib/types'; +import type { APIError, Filter, Params } from '@linode/api-v4/lib/types'; export const useCreateAlertDefinition = (serviceType: AlertServiceType) => { const queryClient = useQueryClient(); @@ -19,3 +25,16 @@ export const useCreateAlertDefinition = (serviceType: AlertServiceType) => { }, }); }; + +export const useAllAlertDefinitionsQuery = ( + params?: Params, + filter?: Filter, + enabled: boolean = true +) => { + return useQuery({ + ...queryFactory.alerts._ctx.all(params, filter), + ...queryPresets.longLived, + enabled, + placeholderData: keepPreviousData, + }); +}; diff --git a/packages/manager/src/queries/cloudpulse/queries.ts b/packages/manager/src/queries/cloudpulse/queries.ts index dc9205f0ad4..41f280bf37d 100644 --- a/packages/manager/src/queries/cloudpulse/queries.ts +++ b/packages/manager/src/queries/cloudpulse/queries.ts @@ -11,6 +11,7 @@ import { databaseQueries } from '../databases/databases'; import { getAllLinodesRequest } from '../linodes/requests'; import { volumeQueries } from '../volumes/volumes'; import { fetchCloudPulseMetrics } from './metrics'; +import { getAllAlertsRequest } from './requests'; import type { CloudPulseMetricsRequest, @@ -23,7 +24,16 @@ const key = 'Clousepulse'; export const queryFactory = createQueryKeys(key, { alerts: { - // This query key is a placeholder , it will be updated once the relevant queries are added + contextQueries: { + alert: { + // This query key is a placeholder , it will be updated once the relevant queries are added + queryKey: null, + }, + all: (params: Params = {}, filter: Filter = {}) => ({ + queryFn: () => getAllAlertsRequest(params, filter), + queryKey: [params, filter], + }), + }, queryKey: null, }, dashboardById: (dashboardId: number) => ({ diff --git a/packages/manager/src/queries/cloudpulse/requests.ts b/packages/manager/src/queries/cloudpulse/requests.ts new file mode 100644 index 00000000000..148b9f0bdd4 --- /dev/null +++ b/packages/manager/src/queries/cloudpulse/requests.ts @@ -0,0 +1,16 @@ +import { getAlertDefinitions } from '@linode/api-v4'; + +import { getAll } from 'src/utilities/getAll'; + +import type { Alert, Filter, Params } from '@linode/api-v4'; + +export const getAllAlertsRequest = ( + passedParams: Params = {}, + passedFilter: Filter = {} +) => + getAll((params, filter) => + getAlertDefinitions( + { ...params, ...passedParams }, + { ...filter, ...passedFilter } + ) + )().then((data) => data.data); diff --git a/packages/manager/src/routes/index.tsx b/packages/manager/src/routes/index.tsx index b31bfac802c..b608e3949b9 100644 --- a/packages/manager/src/routes/index.tsx +++ b/packages/manager/src/routes/index.tsx @@ -1,3 +1,4 @@ +import { QueryClient } from '@tanstack/react-query'; import { createRoute, createRouter, redirect } from '@tanstack/react-router'; import React from 'react'; @@ -62,8 +63,11 @@ export const routeTree = rootRoute.addChildren([ ]); export const router = createRouter({ - context: {}, + context: { + queryClient: new QueryClient(), + }, defaultNotFoundComponent: () => , + defaultPreload: 'intent', routeTree, }); @@ -82,12 +86,17 @@ declare module '@tanstack/react-router' { */ export const migrationRouteTree = migrationRootRoute.addChildren([ betaRouteTree, + volumesRouteTree, ]); +export type MigrationRouteTree = typeof migrationRouteTree; export const migrationRouter = createRouter({ Wrap: ({ children }) => { return
{children}
; }, - context: {}, + context: { + queryClient: new QueryClient(), + }, defaultNotFoundComponent: () => , + defaultPreload: 'intent', routeTree: migrationRouteTree, }); diff --git a/packages/manager/src/routes/types.ts b/packages/manager/src/routes/types.ts index f41099a58e7..80848e64bea 100644 --- a/packages/manager/src/routes/types.ts +++ b/packages/manager/src/routes/types.ts @@ -1,4 +1,5 @@ import type { AccountSettings } from '@linode/api-v4'; +import type { QueryClient } from '@tanstack/react-query'; export type RouterContext = { accountSettings?: AccountSettings; @@ -8,4 +9,12 @@ export type RouterContext = { isACLPEnabled?: boolean; isDatabasesEnabled?: boolean; isPlacementGroupsEnabled?: boolean; + queryClient: QueryClient; }; + +export interface TableSearchParams { + order?: 'asc' | 'desc'; + orderBy?: string; + page?: number; + pageSize?: number; +} diff --git a/packages/manager/src/routes/volumes/VolumesRoute.tsx b/packages/manager/src/routes/volumes/VolumesRoot.tsx similarity index 92% rename from packages/manager/src/routes/volumes/VolumesRoute.tsx rename to packages/manager/src/routes/volumes/VolumesRoot.tsx index 8aa2bf30d58..0745f7346c9 100644 --- a/packages/manager/src/routes/volumes/VolumesRoute.tsx +++ b/packages/manager/src/routes/volumes/VolumesRoot.tsx @@ -4,7 +4,7 @@ import React from 'react'; import { ProductInformationBanner } from 'src/components/ProductInformationBanner/ProductInformationBanner'; import { SuspenseLoader } from 'src/components/SuspenseLoader'; -export const VolumesRoute = () => { +export const VolumesRoot = () => { return ( }> diff --git a/packages/manager/src/routes/volumes/constants.ts b/packages/manager/src/routes/volumes/constants.ts new file mode 100644 index 00000000000..738d9d16f76 --- /dev/null +++ b/packages/manager/src/routes/volumes/constants.ts @@ -0,0 +1,3 @@ +export const VOLUME_TABLE_DEFAULT_ORDER = 'asc'; +export const VOLUME_TABLE_DEFAULT_ORDER_BY = 'label'; +export const VOLUME_TABLE_PREFERENCE_KEY = 'volumes'; diff --git a/packages/manager/src/routes/volumes/index.ts b/packages/manager/src/routes/volumes/index.ts index 9379b004f5b..87db6e3ce86 100644 --- a/packages/manager/src/routes/volumes/index.ts +++ b/packages/manager/src/routes/volumes/index.ts @@ -1,10 +1,29 @@ -import { createRoute } from '@tanstack/react-router'; +import { createRoute, redirect } from '@tanstack/react-router'; import { rootRoute } from '../root'; -import { VolumesRoute } from './VolumesRoute'; +import { VolumesRoot } from './VolumesRoot'; + +import type { TableSearchParams } from '../types'; + +const volumeAction = { + attach: 'attach', + clone: 'clone', + delete: 'delete', + detach: 'detach', + details: 'details', + edit: 'edit', + resize: 'resize', + upgrade: 'upgrade', +} as const; + +export type VolumeAction = typeof volumeAction[keyof typeof volumeAction]; + +export interface VolumesSearchParams extends TableSearchParams { + query?: string; +} const volumesRoute = createRoute({ - component: VolumesRoute, + component: VolumesRoot, getParentRoute: () => rootRoute, path: 'volumes', }); @@ -12,8 +31,9 @@ const volumesRoute = createRoute({ const volumesIndexRoute = createRoute({ getParentRoute: () => volumesRoute, path: '/', + validateSearch: (search: VolumesSearchParams) => search, }).lazy(() => - import('src/features/Volumes/VolumesLanding').then( + import('src/routes/volumes/volumesLazyRoutes').then( (m) => m.volumesLandingLazyRoute ) ); @@ -22,12 +42,55 @@ const volumesCreateRoute = createRoute({ getParentRoute: () => volumesRoute, path: 'create', }).lazy(() => - import('src/features/Volumes/VolumeCreate').then( - (m) => m.volumeCreateLazyRoute + import('./volumesLazyRoutes').then((m) => m.volumeCreateLazyRoute) +); + +type VolumeActionRouteParams

= { + action: VolumeAction; + volumeId: P; +}; + +const volumeActionRoute = createRoute({ + beforeLoad: async ({ params }) => { + if (!(params.action in volumeAction)) { + throw redirect({ + search: () => ({}), + to: '/volumes', + }); + } + }, + getParentRoute: () => volumesRoute, + params: { + parse: ({ action, volumeId }: VolumeActionRouteParams) => ({ + action, + volumeId: Number(volumeId), + }), + stringify: ({ action, volumeId }: VolumeActionRouteParams) => ({ + action, + volumeId: String(volumeId), + }), + }, + path: '$volumeId/$action', + validateSearch: (search: VolumesSearchParams) => search, +}).lazy(() => + import('src/routes/volumes/volumesLazyRoutes').then( + (m) => m.volumesLandingLazyRoute ) ); +const volumesCatchAllRoute = createRoute({ + beforeLoad: () => { + throw redirect({ + search: () => ({}), + to: '/volumes', + }); + }, + getParentRoute: () => volumesRoute, + path: '*', +}); + export const volumesRouteTree = volumesRoute.addChildren([ - volumesIndexRoute, + volumesIndexRoute.addChildren([volumeActionRoute]), volumesCreateRoute, + volumesCatchAllRoute, ]); diff --git a/packages/manager/src/routes/volumes/volumesLazyRoutes.ts b/packages/manager/src/routes/volumes/volumesLazyRoutes.ts new file mode 100644 index 00000000000..d78731c1d1b --- /dev/null +++ b/packages/manager/src/routes/volumes/volumesLazyRoutes.ts @@ -0,0 +1,12 @@ +import { createLazyRoute } from '@tanstack/react-router'; + +import { VolumeCreate } from 'src/features/Volumes/VolumeCreate'; +import { VolumesLanding } from 'src/features/Volumes/VolumesLanding'; + +export const volumesLandingLazyRoute = createLazyRoute('/')({ + component: VolumesLanding, +}); + +export const volumeCreateLazyRoute = createLazyRoute('/volumes/create')({ + component: VolumeCreate, +}); diff --git a/packages/manager/src/types/ManagerPreferences.ts b/packages/manager/src/types/ManagerPreferences.ts index 5c89f454313..42f76b0fc23 100644 --- a/packages/manager/src/types/ManagerPreferences.ts +++ b/packages/manager/src/types/ManagerPreferences.ts @@ -7,6 +7,10 @@ export interface OrderSet { orderBy: string; } +export type OrderSetWithPrefix

= { + [K in `${P}-order` | `${P}-orderBy`]: K extends `${P}-order` ? Order : string; +}; + export interface DismissedNotification { created: string; expiry?: string; diff --git a/packages/validation/.changeset/pr-11321-added-1733240871159.md b/packages/validation/.changeset/pr-11321-added-1733240871159.md new file mode 100644 index 00000000000..9e03fd4fe1b --- /dev/null +++ b/packages/validation/.changeset/pr-11321-added-1733240871159.md @@ -0,0 +1,5 @@ +--- +"@linode/validation": Added +--- + +Validation for UDP NodeBalancer support ([#11321](https://github.com/linode/manager/pull/11321)) diff --git a/packages/validation/src/cloudpulse.schema.ts b/packages/validation/src/cloudpulse.schema.ts index c7a0317e268..0128c96b73b 100644 --- a/packages/validation/src/cloudpulse.schema.ts +++ b/packages/validation/src/cloudpulse.schema.ts @@ -12,11 +12,12 @@ const metricCriteria = object({ operator: string().required('Criteria Operator is required.'), threshold: number() .required('Threshold value is required.') - .min(0, 'Threshold value cannot be negative.'), + .min(0, 'Threshold value cannot be negative.') + .typeError('Threshold value should be a number.'), dimension_filters: array().of(dimensionFilters).notRequired(), }); -const trigger_condition = object({ +const triggerConditionValidation = object({ polling_interval_seconds: number().required('Polling Interval is required.'), evaluation_period_seconds: number().required( 'Evaluation Period is required.' @@ -38,6 +39,7 @@ export const createAlertDefinitionSchema = object({ .of(metricCriteria) .min(1, 'At least one metric criteria is needed.'), }), - trigger_condition, + trigger_conditions: triggerConditionValidation, channel_ids: array(number()), + tags: array().of(string()).notRequired(), }); diff --git a/packages/validation/src/nodebalancers.schema.ts b/packages/validation/src/nodebalancers.schema.ts index 6cefa2592c4..6c93bfee5f9 100644 --- a/packages/validation/src/nodebalancers.schema.ts +++ b/packages/validation/src/nodebalancers.schema.ts @@ -20,6 +20,11 @@ export const CHECK_INTERVAL = { MAX: 3600, }; +const CONNECTION_THROTTLE = { + MIN: 0, + MAX: 20, +}; + export const nodeBalancerConfigNodeSchema = object({ label: string() .matches( @@ -46,11 +51,15 @@ export const nodeBalancerConfigNodeSchema = object({ .min(1, `Weight must be between 1 and 255.`) .max(255, `Weight must be between 1 and 255.`), - mode: mixed().oneOf(['accept', 'reject', 'backup', 'drain']), + mode: string().oneOf(['accept', 'reject', 'backup', 'drain']), }); export const createNodeBalancerConfigSchema = object({ - algorithm: mixed().oneOf(['roundrobin', 'leastconn', 'source']), + algorithm: string().when('protocol', { + is: 'udp', + then: (schema) => schema.oneOf(['roundrobin', 'leastconn', 'ring_hash']), + otherwise: (schema) => schema.oneOf(['roundrobin', 'leastconn', 'source']), + }), check_attempts: number() .min( CHECK_ATTEMPTS.MIN, @@ -76,7 +85,10 @@ export const createNodeBalancerConfigSchema = object({ ) .typeError('Interval must be a number.') .integer(), - check_passive: boolean(), + check_passive: boolean().when('protocol', { + is: 'udp', + then: (schema) => schema.isFalse(), // You can't enable check_passtive with UDP + }), check_path: string() .matches(/\/.*/) .when('check', { @@ -87,7 +99,11 @@ export const createNodeBalancerConfigSchema = object({ is: 'http_body', then: (schema) => schema.required('An HTTP path is required.'), }), - proxy_protocol: string().oneOf(['none', 'v1', 'v2']), + proxy_protocol: string().when('protocol', { + is: 'udp', + then: (schema) => schema.oneOf(['none']), // UDP does not support proxy_protocol + otherwise: (schema) => schema.oneOf(['none', 'v1', 'v2']), + }), check_timeout: number() .min( CHECK_TIMEOUT.MIN, @@ -106,7 +122,7 @@ export const createNodeBalancerConfigSchema = object({ .required('Port is required') .min(1, PORT_WARNING) .max(65535, PORT_WARNING), - protocol: mixed().oneOf(['http', 'https', 'tcp']), + protocol: string().oneOf(['http', 'https', 'tcp', 'udp']), ssl_key: string().when('protocol', { is: 'https', then: (schema) => schema.required('SSL key is required when using HTTPS.'), @@ -116,7 +132,12 @@ export const createNodeBalancerConfigSchema = object({ then: (schema) => schema.required('SSL certificate is required when using HTTPS.'), }), - stickiness: mixed().oneOf(['none', 'table', 'http_cookie']), + stickiness: string().when('protocol', { + is: 'udp', + then: (schema) => schema.oneOf(['none', 'source_ip', 'session']), + otherwise: (schema) => schema.oneOf(['none', 'table', 'http_cookie']), + }), + udp_check_port: number().min(1).max(65535), nodes: array() .of(nodeBalancerConfigNodeSchema) .required() @@ -124,7 +145,11 @@ export const createNodeBalancerConfigSchema = object({ }); export const UpdateNodeBalancerConfigSchema = object({ - algorithm: mixed().oneOf(['roundrobin', 'leastconn', 'source']), + algorithm: string().when('protocol', { + is: 'udp', + then: (schema) => schema.oneOf(['roundrobin', 'leastconn', 'ring_hash']), + otherwise: (schema) => schema.oneOf(['roundrobin', 'leastconn', 'source']), + }), check_attempts: number() .min( CHECK_ATTEMPTS.MIN, @@ -150,7 +175,10 @@ export const UpdateNodeBalancerConfigSchema = object({ ) .typeError('Interval must be a number.') .integer(), - check_passive: boolean(), + check_passive: boolean().when('protocol', { + is: 'udp', + then: (schema) => schema.isFalse(), // You can't enable check_passtive with UDP + }), check_path: string() .matches(/\/.*/) .when('check', { @@ -161,7 +189,11 @@ export const UpdateNodeBalancerConfigSchema = object({ is: 'http_body', then: (schema) => schema.required('An HTTP path is required.'), }), - proxy_protocol: string().oneOf(['none', 'v1', 'v2']), + proxy_protocol: string().when('protocol', { + is: 'udp', + then: (schema) => schema.oneOf(['none']), // UDP does not support proxy_protocol + otherwise: (schema) => schema.oneOf(['none', 'v1', 'v2']), + }), check_timeout: number() .min( CHECK_TIMEOUT.MIN, @@ -180,7 +212,7 @@ export const UpdateNodeBalancerConfigSchema = object({ .integer() .min(1, PORT_WARNING) .max(65535, PORT_WARNING), - protocol: mixed().oneOf(['http', 'https', 'tcp']), + protocol: string().oneOf(['http', 'https', 'tcp', 'udp']), ssl_key: string().when('protocol', { is: 'https', then: (schema) => schema.required(), @@ -189,10 +221,36 @@ export const UpdateNodeBalancerConfigSchema = object({ is: 'https', then: (schema) => schema.required(), }), - stickiness: mixed().oneOf(['none', 'table', 'http_cookie']), - nodes: array().of(nodeBalancerConfigNodeSchema), + udp_check_port: number().min(1).max(65535), + stickiness: string().when('protocol', { + is: 'udp', + then: (schema) => schema.oneOf(['none', 'source_ip', 'session']), + otherwise: (schema) => schema.oneOf(['none', 'table', 'http_cookie']), + }), }); +const client_conn_throttle = number() + .min( + CONNECTION_THROTTLE.MIN, + `Client Connection Throttle must be between ${CONNECTION_THROTTLE.MIN} and ${CONNECTION_THROTTLE.MAX}.` + ) + .max( + CONNECTION_THROTTLE.MAX, + `Client Connection Throttle must be between ${CONNECTION_THROTTLE.MIN} and ${CONNECTION_THROTTLE.MAX}.` + ) + .typeError('Client Connection Throttle must be a number.'); + +const client_udp_sess_throttle = number() + .min( + CONNECTION_THROTTLE.MIN, + `UDP Session Throttle must be between ${CONNECTION_THROTTLE.MIN} and ${CONNECTION_THROTTLE.MAX}.` + ) + .max( + CONNECTION_THROTTLE.MAX, + `UDP Session Throttle must be between ${CONNECTION_THROTTLE.MIN} and ${CONNECTION_THROTTLE.MAX}.` + ) + .typeError('UDP Session Throttle must be a number.'); + export const NodeBalancerSchema = object({ label: string() .required('Label is required.') @@ -203,7 +261,11 @@ export const NodeBalancerSchema = object({ "Label can't contain special characters or spaces." ), - client_conn_throttle: number().typeError('Must be a number.'), + client_conn_throttle, + + client_udp_sess_throttle, + + tags: array(string()), region: string().required('Region is required.'), @@ -249,8 +311,7 @@ export const UpdateNodeBalancerSchema = object({ /^[a-zA-Z0-9-_]+$/, "Label can't contain special characters or spaces." ), - - client_conn_throttle: number().typeError('Must be a number.'), - - region: string(), + client_conn_throttle, + client_udp_sess_throttle, + tags: array(string()), });