diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 60761224224..eab44266804 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,7 +28,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 with: run_install: false version: 10 @@ -43,7 +43,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 with: run_install: false version: 10 @@ -63,7 +63,7 @@ jobs: needs: build-validation steps: - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 with: run_install: false version: 10 @@ -83,7 +83,7 @@ jobs: needs: build-validation steps: - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 with: run_install: false version: 10 @@ -107,7 +107,7 @@ jobs: needs: build-sdk steps: - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 with: run_install: false version: 10 @@ -148,7 +148,7 @@ jobs: needs: build-sdk steps: - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 with: run_install: false version: 10 @@ -171,7 +171,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 with: run_install: false version: 10 @@ -186,7 +186,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 with: run_install: false version: 10 @@ -202,7 +202,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 with: run_install: false version: 10 @@ -222,7 +222,7 @@ jobs: needs: build-sdk steps: - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 with: run_install: false version: 10 @@ -242,7 +242,7 @@ jobs: needs: build-sdk steps: - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 with: run_install: false version: 10 @@ -266,7 +266,7 @@ jobs: needs: build-sdk steps: - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 with: run_install: false version: 10 @@ -282,7 +282,7 @@ jobs: needs: build-sdk steps: - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 with: run_install: false version: 10 @@ -302,7 +302,7 @@ jobs: needs: build-sdk steps: - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 with: run_install: false version: 10 @@ -316,13 +316,13 @@ jobs: path: packages/api-v4/lib - run: pnpm install --frozen-lockfile - run: pnpm run --filter @linode/queries typecheck - + typecheck-shared: runs-on: ubuntu-latest needs: build-sdk steps: - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 with: run_install: false version: 10 @@ -340,13 +340,13 @@ jobs: path: packages/validation/lib - run: pnpm install --frozen-lockfile - run: pnpm run --filter @linode/shared typecheck - + typecheck-manager: runs-on: ubuntu-latest needs: build-sdk steps: - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 with: run_install: false version: 10 @@ -375,7 +375,7 @@ jobs: - validate-sdk steps: - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 with: run_install: false version: 10 @@ -397,7 +397,7 @@ jobs: NPM_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }} - run: pnpm publish -r --filter @linode/api-v4 --filter @linode/validation --no-git-checks --access public - name: slack-notify - uses: rtCamp/action-slack-notify@master + uses: rtCamp/action-slack-notify@e31e87e03dd19038e411e38ae27cbad084a90661 # v2.3.3 env: SLACK_CHANNEL: api-js-client SLACK_TITLE: "Packages published" @@ -413,7 +413,7 @@ jobs: NODE_OPTIONS: --max-old-space-size=4096 steps: - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 with: run_install: false version: 10 @@ -446,7 +446,7 @@ jobs: with: name: storybook-build path: storybook/build - - uses: jakejarvis/s3-sync-action@master + - uses: jakejarvis/s3-sync-action@be0c4ab89158cac4278689ebedd8407dd5f35a83 # v0.5.1 with: args: --acl public-read --follow-symlinks --delete env: diff --git a/.github/workflows/coverage_badge.yml b/.github/workflows/coverage_badge.yml index 548ea89dfb1..639f21e3e0d 100644 --- a/.github/workflows/coverage_badge.yml +++ b/.github/workflows/coverage_badge.yml @@ -16,7 +16,7 @@ jobs: - name: Checkout Code uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 with: run_install: false version: 10 diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 4a81cf374f9..a0466e3b1b6 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -18,7 +18,7 @@ jobs: - name: Setup Pages uses: actions/configure-pages@v5 - - uses: oven-sh/setup-bun@v2 + - uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2 with: bun-version: 1.0.21 diff --git a/.github/workflows/e2e_schedule_and_push.yml b/.github/workflows/e2e_schedule_and_push.yml index 69c4e9f2a9c..ba726631bbb 100644 --- a/.github/workflows/e2e_schedule_and_push.yml +++ b/.github/workflows/e2e_schedule_and_push.yml @@ -31,13 +31,13 @@ jobs: fail-fast: false matrix: user: - - { index: 1, name: 'USER_1' } - - { index: 2, name: 'USER_2' } - - { index: 3, name: 'USER_3' } - - { index: 4, name: 'USER_4' } + - { index: 1, name: "USER_1" } + - { index: 2, name: "USER_2" } + - { index: 3, name: "USER_3" } + - { index: 4, name: "USER_4" } steps: - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 with: run_install: false version: 10 @@ -53,13 +53,13 @@ jobs: echo "REACT_APP_API_ROOT=${{ secrets.REACT_APP_API_ROOT }}" >> ./packages/manager/.env echo "REACT_APP_APP_ROOT=${{ secrets.REACT_APP_APP_ROOT }}" >> ./packages/manager/.env echo "REACT_APP_DISABLE_NEW_RELIC=1" >> ./packages/manager/.env - echo "MANAGER_OAUTH=${{ secrets[matrix.user.name] }}" >> ./packages/manager/.env + echo "MANAGER_OAUTH=${{ env[matrix.user.name] }}" >> ./packages/manager/.env echo "CY_TEST_SPLIT_RUN_INDEX=${{ matrix.user.index }}" >> ./packages/manager/.env - run: pnpm install --frozen-lockfile - run: pnpm run --filter @linode/validation build - run: pnpm run --filter @linode/api-v4 build - name: Run tests - uses: cypress-io/github-action@v6 + uses: cypress-io/github-action@b8ba51a856ba5f4c15cf39007636d4ab04f23e3c # v6.10.2 with: working-directory: packages/manager wait-on: "http://localhost:3000" diff --git a/.github/workflows/eslint_review.yml b/.github/workflows/eslint_review.yml index 5fc14caf77e..44430dfec58 100644 --- a/.github/workflows/eslint_review.yml +++ b/.github/workflows/eslint_review.yml @@ -12,7 +12,7 @@ jobs: package: [manager, api-v4, queries, shared, ui, utilities, validation] steps: - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v2 + - uses: pnpm/action-setup@eae0cfeb286e66ffb5155f1a79b90583a127a68b # v2.4.1 with: run_install: false version: 10 @@ -26,5 +26,5 @@ jobs: workdir: packages/${{ matrix.package }} github_token: ${{ secrets.GITHUB_TOKEN }} reporter: github-pr-check - level: warning # This will report both warnings and errors - filter_mode: added # Only comment on new/modified lines \ No newline at end of file + level: warning # This will report both warnings and errors + filter_mode: added # Only comment on new/modified lines diff --git a/.github/workflows/security_scan.yml b/.github/workflows/security_scan.yml index 05706bf466d..2b835cd894e 100644 --- a/.github/workflows/security_scan.yml +++ b/.github/workflows/security_scan.yml @@ -15,22 +15,22 @@ jobs: container: image: returntocorp/semgrep steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v4 - # Perform scanning using Semgrep - # Pass even when it identifies issues or encounters errors. - - name: Run SAST scan - if: always() - run: semgrep ci || true - env: - SEMGREP_RULES: p/default + # Perform scanning using Semgrep + # Pass even when it identifies issues or encounters errors. + - name: Run SAST scan + if: always() + run: semgrep ci || true + env: + SEMGREP_RULES: p/default - # Post results to Slack notification channel. - - name: slack-notify - uses: rtCamp/action-slack-notify@master - env: + # Post results to Slack notification channel. + - name: slack-notify + uses: rtCamp/action-slack-notify@e31e87e03dd19038e411e38ae27cbad084a90661 # v2.3.3 + env: SLACK_WEBHOOK: ${{ secrets.SLACK_SAST_WEBHOOK }} SLACK_MESSAGE: "Message: ${{ github.event.head_commit.message }} \nRepository: ${{ github.event.repository.url }}" SLACK_COLOR: ${{ job.status }} - SLACK_FOOTER: '' + SLACK_FOOTER: "" MSG_MINIMAL: event,actions url,commit diff --git a/packages/api-v4/CHANGELOG.md b/packages/api-v4/CHANGELOG.md index a3a1fef8b11..87635e8b4fa 100644 --- a/packages/api-v4/CHANGELOG.md +++ b/packages/api-v4/CHANGELOG.md @@ -1,3 +1,26 @@ +## [2025-08-26] - v0.147.0 + + +### Added: + +- ACLP: `CloudPulseServiceType` type for type safety across cloudpulse ([#12646](https://github.com/linode/manager/pull/12646)) + +### Changed: + +- Replace deprecated apis from /account/entity-transfers to /account/service-transfers ([#12658](https://github.com/linode/manager/pull/12658)) +- IAM RBAC Update `getAccountEntities` API call with params ([#12762](https://github.com/linode/manager/pull/12762)) + +### Removed: + +- Delete `ConfigInterfaceIPv6` and use `IPv6Interface` instead ([#12612](https://github.com/linode/manager/pull/12612)) + +### Upcoming Features: + +- API endpoint for Datastream - Create Destination ([#12627](https://github.com/linode/manager/pull/12627)) +- Updated AccontMaintenance interface to make time fields nullable to match API ([#12665](https://github.com/linode/manager/pull/12665)) +- Update `KubernetesCluster` `vpc_id` and `subnet_id` types to include `null` ([#12700](https://github.com/linode/manager/pull/12700)) +- CloudPulse: Update cloud pulse metrics request payload type at `types.ts` ([#12704](https://github.com/linode/manager/pull/12704)) + ## [2025-08-12] - v0.146.0 diff --git a/packages/api-v4/package.json b/packages/api-v4/package.json index eebed6ed310..26480538eb5 100644 --- a/packages/api-v4/package.json +++ b/packages/api-v4/package.json @@ -1,6 +1,6 @@ { "name": "@linode/api-v4", - "version": "0.146.0", + "version": "0.147.0", "homepage": "https://github.com/linode/manager/tree/develop/packages/api-v4", "bugs": { "url": "https://github.com/linode/manager/issues" diff --git a/packages/api-v4/src/account/types.ts b/packages/api-v4/src/account/types.ts index 0aad4ee18ac..36def13c55b 100644 --- a/packages/api-v4/src/account/types.ts +++ b/packages/api-v4/src/account/types.ts @@ -343,6 +343,7 @@ export const EventActionKeys = [ 'database_update', 'database_migrate', 'database_upgrade', + 'destination_create', 'disk_create', 'disk_delete', 'disk_duplicate', @@ -468,6 +469,7 @@ export const EventActionKeys = [ 'stackscript_publicize', 'stackscript_revise', 'stackscript_update', + 'stream_create', 'subnet_create', 'subnet_delete', 'subnet_update', @@ -489,7 +491,6 @@ export const EventActionKeys = [ 'user_ssh_key_delete', 'user_ssh_key_update', 'user_update', - 'stream_create', 'volume_attach', 'volume_clone', 'volume_create', @@ -573,7 +574,7 @@ export interface SaveCreditCardData { } export interface AccountMaintenance { - complete_time: string; + complete_time: null | string; description: 'emergency' | 'scheduled'; entity: { id: number; @@ -582,14 +583,14 @@ export interface AccountMaintenance { url: string; }; maintenance_policy_set: MaintenancePolicySlug; - not_before: string; + not_before: null | string; reason: string; source: 'platform' | 'user'; - start_time: string; + start_time: null | string; status: | 'canceled' | 'completed' - | 'in-progress' + | 'in_progress' | 'pending' | 'scheduled' | 'started'; @@ -600,7 +601,7 @@ export interface AccountMaintenance { | 'power_off_on' | 'reboot' | 'volume_migration'; - when: string; + when: string; // Never null, always datetime object } // Note: In the future there will be more slugs, ie: 'private/1234'. diff --git a/packages/api-v4/src/cloudpulse/alerts.ts b/packages/api-v4/src/cloudpulse/alerts.ts index 77f422890e9..ea31bdfdff1 100644 --- a/packages/api-v4/src/cloudpulse/alerts.ts +++ b/packages/api-v4/src/cloudpulse/alerts.ts @@ -15,7 +15,6 @@ import Request, { import type { Filter, Params, ResourcePage } from '../types'; import type { Alert, - AlertServiceType, CloudPulseAlertsPayload, CreateAlertDefinitionPayload, EditAlertDefinitionPayload, @@ -24,7 +23,7 @@ import type { export const createAlertDefinition = ( data: CreateAlertDefinitionPayload, - serviceType: AlertServiceType, + serviceType: string, ) => Request( setURL( diff --git a/packages/api-v4/src/cloudpulse/types.ts b/packages/api-v4/src/cloudpulse/types.ts index d3a2109b87a..837c0503bbe 100644 --- a/packages/api-v4/src/cloudpulse/types.ts +++ b/packages/api-v4/src/cloudpulse/types.ts @@ -3,12 +3,12 @@ import type { AccountCapability } from 'src/account'; export type AlertSeverityType = 0 | 1 | 2 | 3; export type MetricAggregationType = 'avg' | 'count' | 'max' | 'min' | 'sum'; export type MetricOperatorType = 'eq' | 'gt' | 'gte' | 'lt' | 'lte'; -export type AlertServiceType = 'dbaas' | 'firewall' | 'linode' | 'nodebalancer'; -export type MetricsServiceType = +export type CloudPulseServiceType = | 'dbaas' | 'firewall' | 'linode' | 'nodebalancer'; + export type AlertClass = 'dedicated' | 'shared'; export type DimensionFilterOperatorType = | 'endswith' @@ -41,7 +41,7 @@ export interface Dashboard { created: string; id: number; label: string; - service_type: string; + service_type: CloudPulseServiceType; time_duration: TimeDuration; updated: string; widgets: Widgets[]; @@ -76,8 +76,8 @@ export interface Widgets { metric: string; namespace_id: number; region_id: number; - service_type: string; - serviceType: string; + service_type: CloudPulseServiceType; + serviceType: CloudPulseServiceType; size: number; time_duration: TimeDuration; time_granularity: TimeGranularity; @@ -146,6 +146,7 @@ export interface Metric { export interface CloudPulseMetricsRequest { absolute_time_duration: DateTimeWithPreset | undefined; + associated_entity_region?: string; entity_ids: number[]; filters?: Filters[]; group_by: string[]; @@ -183,7 +184,7 @@ export interface Service { alert: ServiceAlert; label: string; regions: string; - service_type: string; + service_type: CloudPulseServiceType; } export interface ServiceTypesList { @@ -252,7 +253,7 @@ export interface Alert { rules: AlertDefinitionMetricCriteria[]; }; scope: AlertDefinitionScope; - service_type: AlertServiceType; + service_type: CloudPulseServiceType; severity: AlertSeverityType; status: AlertStatusType; tags: string[]; @@ -349,7 +350,7 @@ export interface EditAlertDefinitionPayload { export interface EditAlertPayloadWithService extends EditAlertDefinitionPayload { alertId: number; - serviceType: string; + serviceType: CloudPulseServiceType; } export type AlertStatusUpdateType = 'Disable' | 'Enable'; @@ -361,11 +362,11 @@ export interface EntityAlertUpdatePayload { export interface DeleteAlertPayload { alertId: number; - serviceType: string; + serviceType: CloudPulseServiceType; } export const capabilityServiceTypeMapping: Record< - AlertServiceType | MetricsServiceType | string, + CloudPulseServiceType, AccountCapability > = { linode: 'Linodes', diff --git a/packages/api-v4/src/datastream/destinations.ts b/packages/api-v4/src/datastream/destinations.ts index dcd349585ee..66784f50ff3 100644 --- a/packages/api-v4/src/datastream/destinations.ts +++ b/packages/api-v4/src/datastream/destinations.ts @@ -1,8 +1,16 @@ +import { createDestinationSchema } from '@linode/validation'; + import { BETA_API_ROOT } from '../constants'; -import Request, { setMethod, setParams, setURL, setXFilter } from '../request'; +import Request, { + setData, + setMethod, + setParams, + setURL, + setXFilter, +} from '../request'; import type { Filter, ResourcePage as Page, Params } from '../types'; -import type { Destination } from './types'; +import type { CreateDestinationPayload, Destination } from './types'; /** * Returns all the information about a specified Destination. @@ -29,3 +37,15 @@ export const getDestinations = (params?: Params, filter?: Filter) => setParams(params), setXFilter(filter), ); + +/** + * Adds a new Destination. + * + * @param data { object } Data for type, label, etc. + */ +export const createDestination = (data: CreateDestinationPayload) => + Request( + setData(data, createDestinationSchema), + setURL(`${BETA_API_ROOT}/monitor/streams/destinations`), + setMethod('POST'), + ); diff --git a/packages/api-v4/src/datastream/types.ts b/packages/api-v4/src/datastream/types.ts index a8b769acc72..76f50d5e919 100644 --- a/packages/api-v4/src/datastream/types.ts +++ b/packages/api-v4/src/datastream/types.ts @@ -108,3 +108,9 @@ export interface CreateStreamPayload { status?: StreamStatus; type: StreamType; } + +export interface CreateDestinationPayload { + details: CustomHTTPsDetails | LinodeObjectStorageDetails; + label: string; + type: DestinationType; +} diff --git a/packages/api-v4/src/entities/entities.ts b/packages/api-v4/src/entities/entities.ts index 8c2471fe30c..794a13f80f3 100644 --- a/packages/api-v4/src/entities/entities.ts +++ b/packages/api-v4/src/entities/entities.ts @@ -1,8 +1,8 @@ import { BETA_API_ROOT } from '../constants'; -import Request, { setMethod, setURL } from '../request'; +import Request, { setMethod, setParams, setURL } from '../request'; import type { AccountEntity } from './types'; -import type { ResourcePage } from 'src/types'; +import type { Params, ResourcePage } from 'src/types'; /** * getAccountEntities @@ -10,9 +10,10 @@ import type { ResourcePage } from 'src/types'; * Return all entities for account. * */ -export const getAccountEntities = () => { +export const getAccountEntities = (params?: Params) => { return Request>( setURL(`${BETA_API_ROOT}/entities`), setMethod('GET'), + setParams(params), ); }; diff --git a/packages/api-v4/src/entity-transfers/index.ts b/packages/api-v4/src/entity-transfers/index.ts deleted file mode 100644 index 2097859b8d8..00000000000 --- a/packages/api-v4/src/entity-transfers/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './transfers'; - -export * from './types'; diff --git a/packages/api-v4/src/entity-transfers/transfers.ts b/packages/api-v4/src/entity-transfers/transfers.ts deleted file mode 100644 index c7ce4e0be9e..00000000000 --- a/packages/api-v4/src/entity-transfers/transfers.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { CreateTransferSchema } from '@linode/validation/lib/transfers.schema'; - -import { BETA_API_ROOT } from '../constants'; -import Request, { - setData, - setMethod, - setParams, - setURL, - setXFilter, -} from '../request'; - -import type { Filter, ResourcePage as Page, Params } from '../types'; -import type { CreateTransferPayload, EntityTransfer } from './types'; - -/** - * @deprecated - * getEntityTransfers - * - * Returns a paginated list of all Entity Transfers which this customer has created or accepted. - */ -export const getEntityTransfers = (params?: Params, filter?: Filter) => - Request>( - setMethod('GET'), - setParams(params), - setXFilter(filter), - setURL(`${BETA_API_ROOT}/account/entity-transfers`), - ); - -/** - * @deprecated - * getEntityTransfer - * - * Get a single Entity Transfer by its token (uuid). A Pending transfer - * can be retrieved by any unrestricted user. - * - */ -export const getEntityTransfer = (token: string) => - Request( - setMethod('GET'), - setURL( - `${BETA_API_ROOT}/account/entity-transfers/${encodeURIComponent(token)}`, - ), - ); - -/** - * @deprecated - * createEntityTransfer - * - * Creates a pending Entity Transfer for one or more entities on - * the sender's account. Only unrestricted users can create a transfer. - */ -export const createEntityTransfer = (data: CreateTransferPayload) => - Request( - setMethod('POST'), - setData(data, CreateTransferSchema), - setURL(`${BETA_API_ROOT}/account/entity-transfers`), - ); - -/** - * @deprecated - * acceptEntityTransfer - * - * Accepts a transfer that has been created by a user on a different account. - */ -export const acceptEntityTransfer = (token: string) => - Request<{}>( - setMethod('POST'), - setURL( - `${BETA_API_ROOT}/account/entity-transfers/${encodeURIComponent( - token, - )}/accept`, - ), - ); - -/** - * @deprecated - * cancelTransfer - * - * Cancels a pending transfer. Only unrestricted users on the account - * that created the transfer are able to cancel it. - * - */ -export const cancelTransfer = (token: string) => - Request<{}>( - setMethod('DELETE'), - setURL( - `${BETA_API_ROOT}/account/entity-transfers/${encodeURIComponent(token)}`, - ), - ); diff --git a/packages/api-v4/src/iam/types.ts b/packages/api-v4/src/iam/types.ts index 7259c9885af..677cb9cafb5 100644 --- a/packages/api-v4/src/iam/types.ts +++ b/packages/api-v4/src/iam/types.ts @@ -101,7 +101,8 @@ export type AccountAdmin = | 'view_user_preferences' | AccountBillingAdmin | AccountFirewallAdmin - | AccountLinodeAdmin; + | AccountLinodeAdmin + | AccountOauthClientAdmin; /** Permissions associated with the "account_billing_admin" role. */ export type AccountBillingAdmin = @@ -357,3 +358,5 @@ export interface Roles { } export type IamAccessType = keyof IamAccountRoles; + +export type PickPermissions = T; diff --git a/packages/api-v4/src/index.ts b/packages/api-v4/src/index.ts index 531a0052ae9..11bc7b95baf 100644 --- a/packages/api-v4/src/index.ts +++ b/packages/api-v4/src/index.ts @@ -14,8 +14,6 @@ export * from './domains'; export * from './entities'; -export * from './entity-transfers'; - export * from './firewalls'; export * from './iam'; diff --git a/packages/api-v4/src/kubernetes/types.ts b/packages/api-v4/src/kubernetes/types.ts index c04a0eaf2ce..a518d0260d7 100644 --- a/packages/api-v4/src/kubernetes/types.ts +++ b/packages/api-v4/src/kubernetes/types.ts @@ -39,7 +39,7 @@ export interface KubernetesCluster { * Upcoming Feature Notice - LKE-E:** this property may not be available to all customers * and may change in subsequent releases. */ - subnet_id?: number; + subnet_id?: null | number; tags: string[]; /** Marked as 'optional' in this existing interface to prevent duplicated code for beta functionality, in line with the apl_enabled approach. * @todo LKE-E - Make this field required once LKE-E is in GA. tier defaults to 'standard' in the API. @@ -50,7 +50,7 @@ export interface KubernetesCluster { * Upcoming Feature Notice - LKE-E:** this property may not be available to all customers * and may change in subsequent releases. */ - vpc_id?: number; + vpc_id?: null | number; } export interface KubeNodePoolResponse { diff --git a/packages/api-v4/src/linodes/types.ts b/packages/api-v4/src/linodes/types.ts index b3b349628e6..48005cd73d2 100644 --- a/packages/api-v4/src/linodes/types.ts +++ b/packages/api-v4/src/linodes/types.ts @@ -195,14 +195,6 @@ export interface IPv6SLAAC { range: string; } -export interface ConfigInterfaceIPv6 { - is_public: boolean; - ranges: { - range?: string; - }[]; - slaac: IPv6SLAAC[]; -} - // The legacy interface type - for Configuration Profile Interfaces export interface Interface { active: boolean; @@ -210,7 +202,7 @@ export interface Interface { ip_ranges?: string[]; ipam_address: null | string; ipv4?: ConfigInterfaceIPv4; - ipv6?: ConfigInterfaceIPv6; + ipv6?: IPv6Interface; label: null | string; primary?: boolean; purpose: InterfacePurpose; @@ -225,7 +217,7 @@ export interface InterfacePayload { */ ipam_address?: null | string; ipv4?: ConfigInterfaceIPv4; - ipv6?: ConfigInterfaceIPv6; + ipv6?: Partial; /** * Required to specify a VLAN */ @@ -301,14 +293,13 @@ export interface LinodeInterfaces { interfaces: LinodeInterface[]; } -export interface LinodeInterfaceIPv6 { +export interface IPv6Interface { is_public: boolean; ranges: { range: string; }[]; slaac: IPv6SLAAC[]; } - export interface VPCInterfaceData { ipv4?: { addresses: { @@ -318,7 +309,7 @@ export interface VPCInterfaceData { }[]; ranges: { range: string }[]; }; - ipv6?: LinodeInterfaceIPv6; + ipv6?: IPv6Interface; subnet_id: number; vpc_id: number; } diff --git a/packages/api-v4/src/service-transfers/index.ts b/packages/api-v4/src/service-transfers/index.ts index 71eab4754c8..c7919e59ff8 100644 --- a/packages/api-v4/src/service-transfers/index.ts +++ b/packages/api-v4/src/service-transfers/index.ts @@ -1 +1,3 @@ export * from './service-transfers'; + +export * from './types'; diff --git a/packages/api-v4/src/service-transfers/service-transfers.ts b/packages/api-v4/src/service-transfers/service-transfers.ts index 67db00b656b..fc18ebcac2d 100644 --- a/packages/api-v4/src/service-transfers/service-transfers.ts +++ b/packages/api-v4/src/service-transfers/service-transfers.ts @@ -9,11 +9,8 @@ import Request, { setXFilter, } from '../request'; -import type { - CreateTransferPayload, - EntityTransfer, -} from '../entity-transfers/types'; import type { Filter, ResourcePage as Page, Params } from '../types'; +import type { CreateTransferPayload, EntityTransfer } from './types'; /** * getServiceTransfers diff --git a/packages/api-v4/src/entity-transfers/types.ts b/packages/api-v4/src/service-transfers/types.ts similarity index 100% rename from packages/api-v4/src/entity-transfers/types.ts rename to packages/api-v4/src/service-transfers/types.ts diff --git a/packages/manager/CHANGELOG.md b/packages/manager/CHANGELOG.md index 81c95e5269d..a7ca5ee9fa9 100644 --- a/packages/manager/CHANGELOG.md +++ b/packages/manager/CHANGELOG.md @@ -4,6 +4,114 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [2025-08-26] - v1.149.0 + +### Added: + +- Copyable Node Pool ID ([#12619](https://github.com/linode/manager/pull/12619)) +- Toast notification for failed disk deletion events ([#12673](https://github.com/linode/manager/pull/12673)) +- PlansPanel flow property ([#12711](https://github.com/linode/manager/pull/12711)) + +### Changed: + +- Quotas informational banner updated to use DismissibleBanner ([#12595](https://github.com/linode/manager/pull/12595)) +- Move all Kubernetes Node Pool actions into an Action Menu ([#12619](https://github.com/linode/manager/pull/12619)) +- Node Pool headers from `h2` to `h3` ([#12619](https://github.com/linode/manager/pull/12619)) +- ACLP: all the instances of service type property has `CloudPulseServiceType` type ([#12646](https://github.com/linode/manager/pull/12646)) +- IAM permissions for billing payment methods ([#12654](https://github.com/linode/manager/pull/12654)) +- IAM RBAC block non-beta route access ([#12656](https://github.com/linode/manager/pull/12656)) +- IAM RBAC: add a permission check in Account Service Transfers ([#12658](https://github.com/linode/manager/pull/12658)) +- IAM RBAC permissions for Billing Activity ([#12660](https://github.com/linode/manager/pull/12660)) +- IAM RBAC: add the missing permission checks for creating a disk in the drawer ([#12667](https://github.com/linode/manager/pull/12667)) +- Enable action buttons for VPCs autogenerated for LKE-E ([#12675](https://github.com/linode/manager/pull/12675)) +- Update logic in metrics filters to use the resources from `useResources` useQuery cache in CloudPulse metrics ([#12678](https://github.com/linode/manager/pull/12678)) +- IAM RBAC: fix permission check for rebuilding and resizing linode ([#12680](https://github.com/linode/manager/pull/12680)) +- Enable view all payments query based on new permissions ([#12682](https://github.com/linode/manager/pull/12682)) +- Consolidate DimensionFilterValue logic, utils, schemas & tests; added configMap to drive use-cases ([#12697](https://github.com/linode/manager/pull/12697)) +- IAM RBAC: add the missing permission checks for Profile OAuth Apps ([#12698](https://github.com/linode/manager/pull/12698)) +- ACLP-Alerting: Conditionally set the query data on successful edit alert operation ([#12699](https://github.com/linode/manager/pull/12699)) +- Enhance Linode alerts input validation messages behavior ([#12703](https://github.com/linode/manager/pull/12703)) +- Update RBAC IAM Users Permissions ([#12714](https://github.com/linode/manager/pull/12714)) +- Account/Administration section in user menu updated for consistency with primary navigation ([#12717](https://github.com/linode/manager/pull/12717)) +- IAM RBAC: fix perm check for nodebalancer ([#12719](https://github.com/linode/manager/pull/12719)) +- Update Maintenance Policy descriptions for clarity ([#12725](https://github.com/linode/manager/pull/12725)) +- Update `Table.HeaderNested.Icon` component to use new Default, Hover, and Active design tokens ([#12728](https://github.com/linode/manager/pull/12728)) +- IAM RBAC: add the tooltips for linode configuration menu ([#12731](https://github.com/linode/manager/pull/12731)) + +### Fixed: + +- Wrong stackScriptID used when clicking Deploy New Linode during an active search ([#12623](https://github.com/linode/manager/pull/12623)) +- ImageSelect onChange rendering bug as well as other console errors ([#12638](https://github.com/linode/manager/pull/12638)) +- Console error from `hasBorder` prop in `StyledFlag` component ([#12657](https://github.com/linode/manager/pull/12657)) +- IAM RBAC: Accidental row expansion in Roles table when selecting roles via checkbox ([#12659](https://github.com/linode/manager/pull/12659)) +- Correct maintenance status from `in-progress` to `in_progress` for consistency. Update components to handle nullable time fields with proper fallbacks ([#12665](https://github.com/linode/manager/pull/12665)) +- IAM RBAC: Missing 'update_linode' check for label edits, missing 'create_linode' account check when enabling Clone button ([#12668](https://github.com/linode/manager/pull/12668)) +- Unexpected wrapping in Linode disk table row ([#12673](https://github.com/linode/manager/pull/12673)) +- Incorrect tags permission check on Linode details page ([#12674](https://github.com/linode/manager/pull/12674)) +- IAM RBAC: Missing 'update_firewall' check for label edits, missing 'list_account_logins' check for Account Login History ([#12681](https://github.com/linode/manager/pull/12681)) +- Incomplete results shown when filtering by user in Assign Roles drawer ([#12684](https://github.com/linode/manager/pull/12684)) +- Missing `firewall_apply` event messages ([#12685](https://github.com/linode/manager/pull/12685)) +- IAM Add User FE validation and schema improvement ([#12687](https://github.com/linode/manager/pull/12687)) +- Ensure StyledLinkButton inherit brand font ([#12688](https://github.com/linode/manager/pull/12688)) +- IAM RBAC: IP Addresses actions disabled for account_linode_admin role ([#12689](https://github.com/linode/manager/pull/12689)) +- Use empty string instead of unknown for delete dialog titles ([#12701](https://github.com/linode/manager/pull/12701)) +- DBaaS Resize does not prevent resizing to and from premium plans, region availability notice tooltip does not display accurate region list in database resize ([#12711](https://github.com/linode/manager/pull/12711)) +- ACLP: `metrics` and `alerts` visible for restricted account ([#12713](https://github.com/linode/manager/pull/12713)) +- ACLP-Alerting: Add aclpAlerting flag object to flagsFactor in featureFlag.ts ([#12715](https://github.com/linode/manager/pull/12715)) +- IAM RBAC: Assign Role Drawer user selector only showing previously filtered usernames ([#12717](https://github.com/linode/manager/pull/12717)) +- Imported the updated akamai-cds-component library to resolve a Firefox bug, and updated the text in a toast notice ([#12718](https://github.com/linode/manager/pull/12718)) +- ACLP-Alerting: capitalization logic for Dimension Values in Show-details ([#12724](https://github.com/linode/manager/pull/12724)) +- Maintenance Policy selection enabled in Linode Settings for unsupported regions ([#12725](https://github.com/linode/manager/pull/12725)) +- Cannot open payment method "Make a Payment" drawer when IAM nav is enabled ([#12726](https://github.com/linode/manager/pull/12726)) +- Cannot navigate invoice pagination when IAM nav is enabled ([#12726](https://github.com/linode/manager/pull/12726)) +- Service transfers details page breadcrumb contains hyphen ([#12726](https://github.com/linode/manager/pull/12726)) +- Redirect does not occur when IAM navigation is enabled and Quotas is disabled ([#12726](https://github.com/linode/manager/pull/12726)) +- DBaaS Landing page shows filter error state after sorting by "Plan" column in table ([#12729](https://github.com/linode/manager/pull/12729)) +- DBaaS drawers and dialogs in resize, configuration, and settings not resetting errors and validation state on close ([#12733](https://github.com/linode/manager/pull/12733)) +- Navigating to "/account" redirects to "/billing" when IAM navigation is enabled ([#12735](https://github.com/linode/manager/pull/12735)) +- Navigating to "account/users" shows tabs for administration pages when IAM nav is enabled ([#12735](https://github.com/linode/manager/pull/12735)) +- IAM RBAC: User Detail UI fix, add missing tooltips to Linode Storage Action Menu ([#12722](https://github.com/linode/manager/pull/12722)) + +### Tech Stories: + +- Routing: remove `react-router-dom` and fully switch to TanStack router ([#12602](https://github.com/linode/manager/pull/12602)) +- Clean up types for `LinodeCreateFormValues` interface ([#12612](https://github.com/linode/manager/pull/12612)) +- Refactor single disk encryption status component into two separate components (Node Pool and Linodes) ([#12619](https://github.com/linode/manager/pull/12619)) +- Refactor the Add Node Pool drawer to use `react-hook-form` ([#12631](https://github.com/linode/manager/pull/12631)) +- Pin third-party GitHub Actions to commit SHAs for security ([#12649](https://github.com/linode/manager/pull/12649)) +- Fix Excessive Secrets Exposure vulnerability in E2E GitHub Action ([#12664](https://github.com/linode/manager/pull/12664)) +- Use older linodes landing page order preference key ([#12690](https://github.com/linode/manager/pull/12690)) + +### Tests: + +- Allow action menu items to be selected in 'within' blocks in Cypress ([#12625](https://github.com/linode/manager/pull/12625)) +- Remove clean up from longview.spec.ts ([#12651](https://github.com/linode/manager/pull/12651)) +- Show legacy 'Save Alerts' confirmation modal only if user has already opted into Beta Alerts mode ([#12683](https://github.com/linode/manager/pull/12683)) +- Update `smoke-linode-landing-table.spec.ts` to account for removal of `/dashboard` ([#12690](https://github.com/linode/manager/pull/12690)) +- Fix `qemu-reboot-upgrade-notice.spec.ts` test failure due to incorrect assertion ([#12691](https://github.com/linode/manager/pull/12691)) +- Add `lke-enterprise-read` and `lke-standard-read` Cypress specs; test LKE-E VPC coverage ([#12700](https://github.com/linode/manager/pull/12700)) +- Fix failing test in linode-storage.spec.ts ([#12705](https://github.com/linode/manager/pull/12705)) +- Add tests on confirm dialog in linode details page ([#12707](https://github.com/linode/manager/pull/12707)) +- Add `lke-enterprise-create` Cypress spec to test LKE-E Phase 2 (VPC + IP Stack) coverage ([#12709](https://github.com/linode/manager/pull/12709)) +- Update Cypress tests to pass when IAM navigation is enabled ([#12723](https://github.com/linode/manager/pull/12723)) + +### Upcoming Features: + +- Add Destinations list for DataStream page and POST mock handler for Destination Create ([#12627](https://github.com/linode/manager/pull/12627)) +- Allow Node Pool Update Strategy to be configured when adding an enterprise node pool ([#12631](https://github.com/linode/manager/pull/12631)) +- Add a new feature flag and Administration section in the Primary Nav ([#12633](https://github.com/linode/manager/pull/12633)) +- IAM: Rename Account section to Administration and add navigation for IAM in the top right menu ([#12640](https://github.com/linode/manager/pull/12640)) +- Redirect /account/billing → /billing when feature flag is enabled ([#12670](https://github.com/linode/manager/pull/12670)) +- CloudPulse: Add new flag - 'aclpServices', filter services at `CloudPulseDashboardSelect.tsx`, `AlertListing.tsx`, `ServiceTypeSelect.tsx` ([#12671](https://github.com/linode/manager/pull/12671)) +- CloudPulse: Add dimension filter value label transformation config at `DimensionTransform.ts` and update labels in metrics and alerts ([#12676](https://github.com/linode/manager/pull/12676)) +- Add search and select inputs for Streams table. Add search input for Destinations table ([#12679](https://github.com/linode/manager/pull/12679)) +- Temporarily fix Linode Interface `firewall_device_add` event message ([#12685](https://github.com/linode/manager/pull/12685)) +- Restrict access to the Identity & Access link from the Primary Nav for non-beta users ([#12692](https://github.com/linode/manager/pull/12692)) +- Redirect Account tabs to flat routes `/login-history`, `/settings`, `/maintenance`, and `/service-transfers` ([#12702](https://github.com/linode/manager/pull/12702)) +- CloudPulse: Add linode region filter in `filterconfig.ts`, refactor `CloudPulseRegionSelect.tsx`, add `useFetchOptions.ts` hook ([#12704](https://github.com/linode/manager/pull/12704)) +- Add node pool firewall selection to LKE-E create flow ([#12712](https://github.com/linode/manager/pull/12712)) +- CloudPulse metric label support for Linode Interface firewall entities ([#12716](https://github.com/linode/manager/pull/12716)) + ## [2025-08-12] - v1.148.0 ### Added: @@ -40,7 +148,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - Premature validation of Linode Alert numeric input ([#12626](https://github.com/linode/manager/pull/12626)) - DBaaS Create and Resize node selector options for premium plans and region disabling behavior and handling not being applied in Resize ([#12634](https://github.com/linode/manager/pull/12634)) - ACLP: `loading` screen on auto refetch in edit alert page ([#12636](https://github.com/linode/manager/pull/12636)) -- ACLP: not display `/s` with *PS units on initial widget loading ([#12647](https://github.com/linode/manager/pull/12647)) +- ACLP: not display `/s` with \*PS units on initial widget loading ([#12647](https://github.com/linode/manager/pull/12647)) ### Tech Stories: @@ -48,12 +156,12 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - Add MSW Crud support for Linode Config profiles and prevent deletion of vpcs/subnets with resources ([#12574](https://github.com/linode/manager/pull/12574)) - Revise PR template to recommend video previews ([#12608](https://github.com/linode/manager/pull/12608)) - Update PR template to hide a section; add a Scope subsection for confirmation of customer-facing changes ([#12609](https://github.com/linode/manager/pull/12609)) -- ACLP: `filterRegionByServiceType` method to alerts/utils/utils.ts, remove `supportedRegionIds` property from `CloudPulseResourceTypeMapFlag` feature flag ([#12573](https://github.com/linode/manager/pull/12573)) +- ACLP: `filterRegionByServiceType` method to alerts/utils/utils.ts, remove `supportedRegionIds` property from `CloudPulseResourceTypeMapFlag` feature flag ([#12573](https://github.com/linode/manager/pull/12573)) ### Tests: - Add Cypress tests for ACLP alerts in Linode create flow ([#12540](https://github.com/linode/manager/pull/12540)) -- Add Cypress tests for QEMU Upgrade Notice ([#12564](https://github.com/linode/manager/pull/12564)) +- Add Cypress tests for QEMU Upgrade Notice ([#12564](https://github.com/linode/manager/pull/12564)) - Add Cypress verification tests for CloudPulse NodeBalancer widget ([#12568](https://github.com/linode/manager/pull/12568)) - Improve stability of Linode create password field tests ([#12576](https://github.com/linode/manager/pull/12576)) - Mock LKE versions in `lke-create.spec.ts` to fix test failure due to LKE version 1.31 being deprecated ([#12597](https://github.com/linode/manager/pull/12597)) @@ -73,14 +181,14 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - IAM RBAC: add a permission check in Account Settings Tab ([#12630](https://github.com/linode/manager/pull/12630)) - Add subnet IPv6 to VPC create page ([#12563](https://github.com/linode/manager/pull/12563)) - Add/update inline docs for ACLP Alerts logic ([#12578](https://github.com/linode/manager/pull/12578)) -- ACLP: add checkbox functionality in `AlertRegions`. ([#12582](https://github.com/linode/manager/pull/12582)) +- ACLP: add checkbox functionality in `AlertRegions`. ([#12582](https://github.com/linode/manager/pull/12582)) - Add Linode Interface support for Linode CLI codesnippets tool ([#12591](https://github.com/linode/manager/pull/12591)) - Add IP Version (IPv4/IPv6) support to LKE-E cluster create flow ([#12594](https://github.com/linode/manager/pull/12594)) - Add VPC IPv4 and IPv6 columns to node pools table on LKE-E cluster details page ([#12600](https://github.com/linode/manager/pull/12600)) - Add VPC IPv6 address in Linode Detail > Summary panel ([#12610](https://github.com/linode/manager/pull/12610)) - Update the usePermissions hook to return consistent with the other queries ([#12617](https://github.com/linode/manager/pull/12617)) - Integrate RBAC permission checks in edit billing info ([#12618](https://github.com/linode/manager/pull/12618)) -- ACLP-Alerting: change the `aclpAlerting` to include alert and metric limits and other relevant changes ([#12624](https://github.com/linode/manager/pull/12624)) +- ACLP-Alerting: change the `aclpAlerting` to include alert and metric limits and other relevant changes ([#12624](https://github.com/linode/manager/pull/12624)) - ACLP-Alerting: add custom config_id validation, dynamic schema resolver, helperText map, TextField logic, and mock API for nodebalancer metrics added ([#12629](https://github.com/linode/manager/pull/12629)) - Update VM Host Maintenance GPU Notice Text ([#12632](https://github.com/linode/manager/pull/12632)) - Improve maintenance banner datetime display and formatting ([#12663](https://github.com/linode/manager/pull/12663)) @@ -145,7 +253,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - Add save legacy alerts confirmation modal ([#12516](https://github.com/linode/manager/pull/12516)) - Implement the new RBAC permission hook in Linode Create flow ([#12522](https://github.com/linode/manager/pull/12522)) - Add streams list for datastream page and GET, POST mock handlers for streams requests ([#12524](https://github.com/linode/manager/pull/12524)) -- IAM RBAC: Integrate a new hook to fetch permissions for a list of entities ([#12529](https://github.com/linode/manager/pull/12529)) +- IAM RBAC: Integrate a new hook to fetch permissions for a list of entities ([#12529](https://github.com/linode/manager/pull/12529)) - Implement the new RBAC permission hook in Firewalls Rules flow ([#12534](https://github.com/linode/manager/pull/12534)) - IAM RBAC permission hook: update checks for sub-entities in Linodes Storage, Configuration, and Settings tabs ([#12535](https://github.com/linode/manager/pull/12535)) - IAM RBAC: fix error message and styles issues ([#12542](https://github.com/linode/manager/pull/12542)) @@ -157,7 +265,6 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - Show GPU warning notice conditionally based on policy type - display for "migrate" policy but hide for "power-off-on" policy ([#12512](https://github.com/linode/manager/pull/12512)) - IAM RBAC: Implement the new RBAC permission hook in Firewall Linodes tab ([#12500](https://github.com/linode/manager/pull/12500)) - ## [2025-07-21] - v1.146.2 ### Fixed: @@ -166,14 +273,12 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p ## [2025-07-16] - v1.146.1 - ### Fixed: - IAM RBAC: Fix a permission check for notification banner in Linode details component ([#12525](https://github.com/linode/manager/pull/12525)) ## [2025-07-15] - v1.146.0 - ### Added: - Unsaved Changes modal for Legacy Alerts on Linode Details page ([#12385](https://github.com/linode/manager/pull/12385)) @@ -254,7 +359,6 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p ## [2025-07-01] - v1.145.0 - ### Changed: - Kubernetes cluster details to show restricted access warnings and disabled actions ([#12360](https://github.com/linode/manager/pull/12360)) @@ -317,7 +421,6 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p ## [2025-06-17] - v1.144.0 - ### Added: - Subheading Support to MUI Accordion Component ([#12286](https://github.com/linode/manager/pull/12286)) @@ -327,7 +430,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p ### Changed: - Hide `gb-lon`, `au-mel`, `sg-sin-2`, and `jp-tyo-3` for Image upload and replication ([#12257](https://github.com/linode/manager/pull/12257)) -- Replace node pool autoscaler dialog with drawer ([#12325](https://github.com/linode/manager/pull/12325)) +- Replace node pool autoscaler dialog with drawer ([#12325](https://github.com/linode/manager/pull/12325)) - Disable "Reuse user data previously provided" checkbox in Linode rebuild dialog if the Linode does not have existing user data ([#12352](https://github.com/linode/manager/pull/12352)) - Expand "Add User Data" section by default in the Linode rebuild dialog if the Linode has existing user data ([#12352](https://github.com/linode/manager/pull/12352)) @@ -432,7 +535,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - ACL Revision ID being set to empty string on LKE clusters ([#12210](https://github.com/linode/manager/pull/12210)) - NodeBalancer label and connection throttle not updating until page refresh ([#12217](https://github.com/linode/manager/pull/12217)) - Inconsistent restricted user notices on landing pages ([#12223](https://github.com/linode/manager/pull/12223)) -- `linode_resize` started event referencing the wrong linode ([#12252](https://github.com/linode/manager/pull/12252)) +- `linode_resize` started event referencing the wrong linode ([#12252](https://github.com/linode/manager/pull/12252)) - Image Select overflows off screen on mobile viewports ([#12269](https://github.com/linode/manager/pull/12269)) - LinodeCreateError notice not spanning full width ([#12276](https://github.com/linode/manager/pull/12276)) - Manual clearing of default Alerts fields now resets values to zero, preventing empty string/NaN and ensuring consistency with toggle off state ([#12215](https://github.com/linode/manager/pull/12215)) @@ -442,7 +545,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - Reduce api requests made for every keystroke in Volume attach drawer ([#12052](https://github.com/linode/manager/pull/12052)) - Add support for NB-VPC related /v4/vpcs changes in CRUD mocks ([#12201](https://github.com/linode/manager/pull/12201)) - Move images related queries and dependencies to shared `queries` package ([#12205](https://github.com/linode/manager/pull/12205)) -- Move domain related queries and dependencies to shared `queries` package ([#12204](https://github.com/linode/manager/pull/12204)) +- Move domain related queries and dependencies to shared `queries` package ([#12204](https://github.com/linode/manager/pull/12204)) - Move quotas related queries and dependencies to shared `queries` package ([#12221](https://github.com/linode/manager/pull/12221)) - Add MSW presets for Events, Maintenance, and Notifications ([#12212](https://github.com/linode/manager/pull/12212)) - Upgrade @sentry/react to v9 ([#12219](https://github.com/linode/manager/pull/12219)) @@ -455,7 +558,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - Fix erroneous Sentry error in useAdobeAnalytics hook ([#12265](https://github.com/linode/manager/pull/12265)) - Re-add `eslint-plugin-react-refresh` eslint plugin ([#12267](https://github.com/linode/manager/pull/12267)) - Switch to self-hosting the Pendo agent with Adobe Launch ([#12203](https://github.com/linode/manager/pull/12203)) -- Fix bug in loadScript function not resolving promise if script already existed ([#12203](https://github.com/linode/manager/pull/12203)) +- Fix bug in loadScript function not resolving promise if script already existed ([#12203](https://github.com/linode/manager/pull/12203)) - Make quota_id a string ([#12272](https://github.com/linode/manager/pull/12272)) ### Tests: diff --git a/packages/manager/cypress/e2e/core/account/account-maintenance.spec.ts b/packages/manager/cypress/e2e/core/account/account-maintenance.spec.ts index 390967fb522..14e4ada60c6 100644 --- a/packages/manager/cypress/e2e/core/account/account-maintenance.spec.ts +++ b/packages/manager/cypress/e2e/core/account/account-maintenance.spec.ts @@ -1,5 +1,6 @@ import { mockGetMaintenance } from 'support/intercepts/account'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { ui } from 'support/ui'; import { parseCsv } from 'support/util/csv'; import { accountMaintenanceFactory } from 'src/factories'; @@ -26,6 +27,8 @@ describe('Maintenance', () => { // TODO When the Host & VM Maintenance feature rolls out, we want to enable the feature flag and update the test. mockAppendFeatureFlags({ + // TODO M3-10491 - Remove "iamRbacPrimaryNavChanges" feature flag mock once feature flag is deleted. + iamRbacPrimaryNavChanges: true, vmHostMaintenance: { enabled: false, }, @@ -37,14 +40,18 @@ describe('Maintenance', () => { cy.visitWithLogin('/linodes'); cy.wait('@getFeatureFlags'); - // user can navigate to account maintenance page via user menu. - cy.findByTestId('nav-group-profile').click(); - cy.findByTestId('menu-item-Maintenance') + + // User can navigate to maintenance page via user menu. + ui.userMenuButton.find().should('be.visible').click(); + + ui.userMenu + .find() .should('be.visible') - .should('be.enabled') - .click(); - cy.url().should('endWith', '/account/maintenance'); + .within(() => { + cy.findByText('Maintenance').should('be.visible').click(); + }); + cy.url().should('endWith', '/maintenance'); cy.wait('@getMaintenance'); // Confirm correct messages shown in the table when no maintenance. diff --git a/packages/manager/cypress/e2e/core/account/oauth-apps.spec.ts b/packages/manager/cypress/e2e/core/account/oauth-apps.spec.ts index f18d980d3ea..821da0ac719 100644 --- a/packages/manager/cypress/e2e/core/account/oauth-apps.spec.ts +++ b/packages/manager/cypress/e2e/core/account/oauth-apps.spec.ts @@ -280,7 +280,7 @@ describe('OAuth Apps', () => { cy.findByText('Edit').should('be.visible').click(); }); ui.drawer - .findByTitle('Create OAuth App') + .findByTitle('Edit OAuth App') .should('be.visible') .within(() => { ui.buttonGroup @@ -299,7 +299,7 @@ describe('OAuth Apps', () => { .within(() => { cy.findByText('Edit').should('be.visible').click(); }); - ui.drawer.findByTitle('Create OAuth App').should('be.visible'); + ui.drawer.findByTitle('Edit OAuth App').should('be.visible'); ui.drawerCloseButton.find().click(); // Confirm edition. @@ -317,7 +317,7 @@ describe('OAuth Apps', () => { mockGetOAuthApps(updatedApps).as('getUpdatedOAuthApps'); mockUpdateOAuthApps(updatedApps[0].id, updatedApps).as('updateOAuthApp'); ui.drawer - .findByTitle('Create OAuth App') + .findByTitle('Edit OAuth App') .should('be.visible') .within(() => { // If there is no changes, the 'save' button should disabled diff --git a/packages/manager/cypress/e2e/core/account/quotas-nav.spec.ts b/packages/manager/cypress/e2e/core/account/quotas-nav.spec.ts index bbfc38d8bb2..6d972eb36b9 100644 --- a/packages/manager/cypress/e2e/core/account/quotas-nav.spec.ts +++ b/packages/manager/cypress/e2e/core/account/quotas-nav.spec.ts @@ -2,45 +2,83 @@ import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { ui } from 'support/ui'; describe('Quotas accessible when limitsEvolution feature flag enabled', () => { - beforeEach(() => { - // TODO M3-10003 - Remove mock once `limitsEvolution` feature flag is removed. - mockAppendFeatureFlags({ - limitsEvolution: { - enabled: true, - }, - }).as('getFeatureFlags'); - }); - it('can navigate directly to Quotas page', () => { - cy.visitWithLogin('/account/quotas'); - cy.wait('@getFeatureFlags'); - cy.url().should('endWith', '/quotas'); - cy.contains( - 'View your Object Storage quotas by applying the endpoint filter below' - ).should('be.visible'); - }); + // TODO M3-10491 - Remove `describe` block and move tests to parent scope once `iamRbacPrimaryNavChanges` feature flag is removed. + describe('When IAM RBAC account navigation feature flag is enabled', () => { + beforeEach(() => { + // TODO M3-10003 - Remove mock once `limitsEvolution` feature flag is removed. + mockAppendFeatureFlags({ + limitsEvolution: { + enabled: true, + }, + // TODO M3-10491 - Remove `iamRbacPrimaryNavChanges` mock once feature flag is removed. + iamRbacPrimaryNavChanges: true, + }).as('getFeatureFlags'); + }); - it('can navigate to the Quotas page via the User Menu', () => { - cy.visitWithLogin('/'); - cy.wait('@getFeatureFlags'); - // Open user menu - ui.userMenuButton.find().click(); - ui.userMenu.find().within(() => { - cy.get('[data-testid="menu-item-Quotas"]').should('be.visible').click(); + it('can navigate directly to Quotas page', () => { + cy.visitWithLogin('/quotas'); + cy.wait('@getFeatureFlags'); cy.url().should('endWith', '/quotas'); + cy.contains( + 'View your Object Storage quotas by applying the endpoint filter below' + ).should('be.visible'); + }); + + it('can navigate to the Quotas page via the User Menu', () => { + cy.visitWithLogin('/'); + cy.wait('@getFeatureFlags'); + // Open user menu + ui.userMenuButton.find().click(); + ui.userMenu.find().within(() => { + cy.get('[data-testid="menu-item-Quotas"]').should('be.visible').click(); + cy.url().should('endWith', '/quotas'); + }); }); }); - it('Quotas tab is visible from all other tabs in Account tablist', () => { - cy.visitWithLogin('/account/billing'); - cy.wait('@getFeatureFlags'); - ui.tabList.find().within(() => { - cy.get('a').each(($link) => { - cy.wrap($link).click(); - cy.get('[data-testid="Quotas"]').should('be.visible'); + // TODO M3-10491 - Remove `describe` block and tests once "iamRbacPrimaryNavChanges" feature flag is removed. + describe('When IAM RBAC account navigation feature flag is disabled', () => { + beforeEach(() => { + mockAppendFeatureFlags({ + limitsEvolution: { + enabled: true, + }, + iamRbacPrimaryNavChanges: false, + }).as('getFeatureFlags'); + }); + + it('can navigate directly to Quotas page', () => { + cy.visitWithLogin('/account/quotas'); + cy.wait('@getFeatureFlags'); + cy.url().should('endWith', '/account/quotas'); + cy.contains( + 'View your Object Storage quotas by applying the endpoint filter below' + ).should('be.visible'); + }); + + it('can navigate to the Quotas page via the User Menu', () => { + cy.visitWithLogin('/'); + cy.wait('@getFeatureFlags'); + // Open user menu + ui.userMenuButton.find().click(); + ui.userMenu.find().within(() => { + cy.get('[data-testid="menu-item-Quotas"]').should('be.visible').click(); + cy.url().should('endWith', '/quotas'); }); }); - cy.get('[data-testid="Quotas"]').should('be.visible').click(); - cy.url().should('endWith', '/quotas'); + + it('Quotas tab is visible from all other tabs in Account tablist', () => { + cy.visitWithLogin('/account/billing'); + cy.wait('@getFeatureFlags'); + ui.tabList.find().within(() => { + cy.get('a').each(($link) => { + cy.wrap($link).click(); + cy.get('[data-testid="Quotas"]').should('be.visible'); + }); + }); + cy.get('[data-testid="Quotas"]').should('be.visible').click(); + cy.url().should('endWith', '/quotas'); + }); }); }); @@ -50,40 +88,24 @@ describe('Quotas inaccessible when limitsEvolution feature flag disabled', () => limitsEvolution: { enabled: false, }, + iamRbacPrimaryNavChanges: true, }).as('getFeatureFlags'); }); + it('Quotas page is inaccessible', () => { - cy.visitWithLogin('/account/quotas'); + cy.visitWithLogin('/quotas'); cy.wait('@getFeatureFlags'); - cy.url().should('endWith', '/billing'); + cy.findByText('Not Found').should('be.visible'); + cy.findByText('This page does not exist.').should('be.visible'); }); - it('cannot navigate to the Quotas tab via the Users & Grants link in the User Menu', () => { + it('Cannot navigate to the Quotas tab via the user menu', () => { cy.visitWithLogin('/'); cy.wait('@getFeatureFlags'); // Open user menu ui.userMenuButton.find().click(); ui.userMenu.find().within(() => { - cy.get('[data-testid="menu-item-Quotas"]').should('not.exist'); - cy.get('[data-testid="menu-item-Users & Grants"]') - .should('be.visible') - .click(); - }); - cy.url().should('endWith', '/users'); - cy.get('[data-testid="Quotas"]').should('not.exist'); - }); - - it('cannot navigate to the Quotas tab via the Billing link in the User Menu', () => { - cy.visitWithLogin('/'); - cy.wait('@getFeatureFlags'); - ui.userMenuButton.find().click(); - ui.userMenu.find().within(() => { - cy.get('[data-testid="menu-item-Quotas"]').should('not.exist'); - cy.get('[data-testid="menu-item-Billing & Contact Information"]') - .should('be.visible') - .click(); + cy.findByText('Quotas').should('not.exist'); }); - cy.url().should('endWith', '/billing'); - cy.get('[data-testid="Quotas"]').should('not.exist'); }); }); diff --git a/packages/manager/cypress/e2e/core/account/restricted-user-details-pages.spec.ts b/packages/manager/cypress/e2e/core/account/restricted-user-details-pages.spec.ts index ae937d44759..6e7a65ed47a 100644 --- a/packages/manager/cypress/e2e/core/account/restricted-user-details-pages.spec.ts +++ b/packages/manager/cypress/e2e/core/account/restricted-user-details-pages.spec.ts @@ -105,6 +105,8 @@ describe('restricted user details pages', () => { mockAppendFeatureFlags({ apl: false, dbaasV2: { beta: false, enabled: false }, + // TODO M3-10491 - Remove `iamRbacPrimaryNavChanges` feature flag mock once flag is deleted. + iamRbacPrimaryNavChanges: true, }); }); @@ -202,26 +204,6 @@ describe('restricted user details pages', () => { label: randomLabel(), type: 'automatic', }); - // const mockCustomImages: Image[] = new Array(3) - // .fill(null) - // .map((_item: null, index: number): Image => { - // return imageFactory.build({ - // eol: imageEOLDate.toISOString(), - // label: `Image ${index}`, - // tags: [index % 2 === 0 ? 'even' : 'odd', 'nums'], - // type: 'manual', - // }); - // }); - // const mockRecoveryImages: Image[] = new Array(3) - // .fill(null) - // .map((_item: null, index: number): Image => { - // return imageFactory.build({ - // eol: imageEOLDate.toISOString(), - // label: `Image ${index}`, - // tags: [index % 2 === 0 ? 'even' : 'odd', 'nums'], - // type: 'automatic', - // }); - // }); const actions = [ 'Edit', 'Deploy to New Linode', diff --git a/packages/manager/cypress/e2e/core/account/service-transfer.spec.ts b/packages/manager/cypress/e2e/core/account/service-transfer.spec.ts index 620e66bb792..6d0d8e4649e 100644 --- a/packages/manager/cypress/e2e/core/account/service-transfer.spec.ts +++ b/packages/manager/cypress/e2e/core/account/service-transfer.spec.ts @@ -14,6 +14,7 @@ import { mockInitiateEntityTransferError, mockReceiveEntityTransfer, } from 'support/intercepts/account'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { mockGetLinodes } from 'support/intercepts/linodes'; import { ui } from 'support/ui'; import { cleanUp } from 'support/util/cleanup'; @@ -39,10 +40,10 @@ const serviceTransferEmptyState = 'No data to display.'; export const serviceTransferErrorMessage = 'An unknown error has occurred'; // Service transfer landing page URL. -const serviceTransferLandingUrl = '/account/service-transfers'; +const serviceTransferLandingUrl = '/service-transfers'; // Service transfer initiation page URL. -const serviceTransferCreateUrl = '/account/service-transfers/create'; +const serviceTransferCreateUrl = '/service-transfers/create'; // Possible status responses for service transfers. const serviceTransferStatuses: EntityTransferStatus[] = [ @@ -126,6 +127,14 @@ describe('Account service transfers', () => { cleanUp(['service-transfers', 'linodes', 'lke-clusters']); }); + beforeEach(() => { + // Mock the iamRbacPrimaryNavChanges feature flag to be disabled. + mockAppendFeatureFlags({ + // TODO M3-10491 - Remove `iamRbacPrimaryNavChanges` feature flag mock once flag is deleted. + iamRbacPrimaryNavChanges: true, + }).as('getFeatureFlags'); + }); + /* * - Confirms user can navigate to service transfer page via user menu. */ @@ -400,7 +409,9 @@ describe('Account service transfers', () => { cy.findByText(errorMessage).should('be.visible'); // Navigate back to landing page and cancel transfer. - cy.contains('a', 'Service Transfers').should('be.visible').click(); + ui.entityHeader.find().within(() => { + cy.contains('a', 'Service Transfers').should('be.visible').click(); + }); cy.url().should('endWith', serviceTransferLandingUrl); cy.findByText(token) diff --git a/packages/manager/cypress/e2e/core/account/user-permissions.spec.ts b/packages/manager/cypress/e2e/core/account/user-permissions.spec.ts index 314a12465b7..5d66a00e002 100644 --- a/packages/manager/cypress/e2e/core/account/user-permissions.spec.ts +++ b/packages/manager/cypress/e2e/core/account/user-permissions.spec.ts @@ -9,6 +9,7 @@ import { mockUpdateUser, mockUpdateUserGrants, } from 'support/intercepts/account'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { mockGetProfile } from 'support/intercepts/profile'; import { ui } from 'support/ui'; import { shuffleArray } from 'support/util/arrays'; @@ -168,6 +169,13 @@ const assertBillingAccessSelected = ( }; describe('User permission management', () => { + beforeEach(() => { + // TODO M3-10003 - Remove mock once `limitsEvolution` feature flag is removed. + mockAppendFeatureFlags({ + iamRbacPrimaryNavChanges: true, + }).as('getFeatureFlags'); + }); + /* * - Confirms that full account access can be toggled for account users using mock API data. * - Confirms that users can navigate to User Permissions pages via Users & Grants page. @@ -193,7 +201,7 @@ describe('User permission management', () => { mockGetUserGrantsUnrestrictedAccess(mockUser.username).as('getUserGrants'); // Navigate to Users & Grants page, find mock user, click its "User Permissions" button. - cy.visitWithLogin('/account/users'); + cy.visitWithLogin('/users'); cy.wait('@getUsers'); cy.findByText(mockUser.username) .should('be.visible') @@ -208,10 +216,7 @@ describe('User permission management', () => { // Confirm that Cloud navigates to the user's permissions page and that user has // unrestricted account access. - cy.url().should( - 'endWith', - `/account/users/${mockUser.username}/permissions` - ); + cy.url().should('endWith', `/users/${mockUser.username}/permissions`); cy.findByText(unrestrictedAccessMessage).should('be.visible'); // Restrict account access, confirm page updates to reflect change. @@ -305,7 +310,7 @@ describe('User permission management', () => { mockGetUser(mockUser).as('getUser'); mockGetUserGrants(mockUser.username, mockUserGrants).as('getUserGrants'); - cy.visitWithLogin(`/account/users/${mockUser.username}/permissions`); + cy.visitWithLogin(`/users/${mockUser.username}/permissions`); cy.wait(['@getUser', '@getUserGrants']); mockUpdateUserGrants(mockUser.username, mockUserGrantsUpdatedGlobal).as( @@ -397,7 +402,7 @@ describe('User permission management', () => { mockGetUser(mockUser); mockGetUserGrants(mockUser.username, mockUserGrants); - cy.visitWithLogin(`/account/users/${mockUser.username}/permissions`); + cy.visitWithLogin(`/users/${mockUser.username}/permissions`); // Test reset in Global Permissions section. cy.get('[data-qa-global-section]') @@ -508,9 +513,7 @@ describe('User permission management', () => { mockGetUserGrants(mockActiveUser.username, mockUserGrants); mockGetProfile(mockProfile); - cy.visitWithLogin( - `/account/users/${mockRestrictedUser.username}/permissions` - ); + cy.visitWithLogin(`/users/${mockRestrictedUser.username}/permissions`); mockGetUser(mockRestrictedUser); mockGetUserGrants(mockRestrictedUser.username, mockUserGrants); @@ -572,9 +575,7 @@ describe('User permission management', () => { mockGetUser(mockRestrictedProxyUser); mockGetUserGrants(mockRestrictedProxyUser.username, mockUserGrants); - cy.visitWithLogin( - `/account/users/${mockRestrictedProxyUser.username}/permissions` - ); + cy.visitWithLogin(`/users/${mockRestrictedProxyUser.username}/permissions`); cy.findByText('Parent User Permissions', { exact: false }).should( 'be.visible' diff --git a/packages/manager/cypress/e2e/core/account/users-landing-page.spec.ts b/packages/manager/cypress/e2e/core/account/users-landing-page.spec.ts index 015ace12186..f3badb4c852 100644 --- a/packages/manager/cypress/e2e/core/account/users-landing-page.spec.ts +++ b/packages/manager/cypress/e2e/core/account/users-landing-page.spec.ts @@ -8,6 +8,7 @@ import { mockGetUserGrantsUnrestrictedAccess, mockGetUsers, } from 'support/intercepts/account'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { mockGetProfile, mockGetProfileGrants, @@ -79,6 +80,13 @@ const initTestUsers = (profile: Profile, enableChildAccountAccess: boolean) => { }; describe('Users landing page', () => { + beforeEach(() => { + // TODO M3-10003 - Remove mock once `limitsEvolution` feature flag is removed. + mockAppendFeatureFlags({ + iamRbacPrimaryNavChanges: true, + }).as('getFeatureFlags'); + }); + /* * Confirm the visibility and status of the "Child account access" column for the following users: * - Unrestricted parent user (Enabled) @@ -97,7 +105,7 @@ describe('Users landing page', () => { const mockUsers = initTestUsers(mockProfile, true); // Navigate to Users & Grants page. - cy.visitWithLogin('/account/users'); + cy.visitWithLogin('/users'); cy.wait('@getUsers'); // Confirm that "Child account access" column is present @@ -130,7 +138,7 @@ describe('Users landing page', () => { initTestUsers(mockProfile, true); // Navigate to Users & Grants page. - cy.visitWithLogin('/account/users'); + cy.visitWithLogin('/users'); // Confirm that "Child account access" column is present cy.findByText('Child Account Access').should('be.visible'); @@ -146,7 +154,7 @@ describe('Users landing page', () => { initTestUsers(mockProfile, false); // Navigate to Users & Grants page. - cy.visitWithLogin('/account/users'); + cy.visitWithLogin('/users'); // Confirm that "Child account access" column is not present cy.findByText('Child Account Access').should('not.exist'); @@ -161,7 +169,7 @@ describe('Users landing page', () => { initTestUsers(mockProfile, false); // Navigate to Users & Grants page. - cy.visitWithLogin('/account/users'); + cy.visitWithLogin('/users'); cy.wait('@getUsers'); // Confirm that "Child account access" column is not present @@ -178,7 +186,7 @@ describe('Users landing page', () => { initTestUsers(mockProfile, false); // Navigate to Users & Grants page. - cy.visitWithLogin('/account/users'); + cy.visitWithLogin('/users'); cy.wait('@getUsers'); // Confirm that "Child account access" column is not present @@ -195,7 +203,7 @@ describe('Users landing page', () => { initTestUsers(mockProfile, false); // Navigate to Users & Grants page. - cy.visitWithLogin('/account/users'); + cy.visitWithLogin('/users'); cy.wait('@getUsers'); // Confirm that "Child account access" column is not present @@ -224,7 +232,7 @@ describe('Users landing page', () => { mockGetProfile(mockProfile); // Navigate to Users & Grants page. - cy.visitWithLogin('/account/users'); + cy.visitWithLogin('/users'); cy.wait('@getUsers'); // Confirm the "Parent User Settings" and "User Settings" sections are not present. @@ -236,7 +244,7 @@ describe('Users landing page', () => { * Confirm the Users & Grants and User Permissions pages flow for a child account viewing a proxy user. * Confirm that "Parent User Settings" and "User Settings" sections are present on the Users & Grants page. * Confirm that proxy accounts are listed under "Parent User Settings". - * Confirm that clicking the "Manage Access" button navigates to the proxy user's User Permissions page at /account/users/:user/permissions. + * Confirm that clicking the "Manage Access" button navigates to the proxy user's User Permissions page at /users/:user/permissions. */ it('tests the users landing flow for a child account viewing a proxy user', () => { const mockChildProfile = profileFactory.build({ @@ -267,7 +275,7 @@ describe('Users landing page', () => { mockGetUserGrants(mockRestrictedProxyUser.username, mockUserGrants); // Navigate to Users & Grants page and confirm "Parent User Settings" and "User Settings" sections are visible. - cy.visitWithLogin('/account/users'); + cy.visitWithLogin('/users'); cy.wait('@getUsers'); cy.findByText(`${PARENT_USER} Settings`).should('be.visible'); cy.findByText('User Settings').should('be.visible'); @@ -288,10 +296,10 @@ describe('Users landing page', () => { }); }); - // Confirm button navigates to the proxy user's User Permissions page at /account/users/:user/permissions. + // Confirm button navigates to the proxy user's User Permissions page at /users/:user/permissions. cy.url().should( 'endWith', - `/account/users/${mockRestrictedProxyUser.username}/permissions` + `/users/${mockRestrictedProxyUser.username}/permissions` ); }); @@ -314,7 +322,7 @@ describe('Users landing page', () => { mockAddUser(newUser).as('addUser'); // Navigate to Users & Grants page, find mock user, click its "User Permissions" button. - cy.visitWithLogin('/account/users'); + cy.visitWithLogin('/users'); cy.wait('@getUsers'); // Confirm that the "Users & Grants" page initially lists the main user @@ -449,7 +457,7 @@ describe('Users landing page', () => { mockAddUser(newUser).as('addUser'); // Navigate to Users & Grants page, find mock user, click its "User Permissions" button. - cy.visitWithLogin('/account/users'); + cy.visitWithLogin('/users'); cy.wait('@getUsers'); // Confirm that the "Users & Grants" page initially lists the main user @@ -565,7 +573,7 @@ describe('Users landing page', () => { mockDeleteUser(additionalUser.username).as('deleteUser'); // Navigate to Users & Grants page, find mock user, click its "User Permissions" button. - cy.visitWithLogin('/account/users'); + cy.visitWithLogin('/users'); cy.wait('@getUsers'); mockGetUsers([mockUser]).as('getUsers'); diff --git a/packages/manager/cypress/e2e/core/billing/billing-invoices.spec.ts b/packages/manager/cypress/e2e/core/billing/billing-invoices.spec.ts index 1c419543396..90de20fa000 100644 --- a/packages/manager/cypress/e2e/core/billing/billing-invoices.spec.ts +++ b/packages/manager/cypress/e2e/core/billing/billing-invoices.spec.ts @@ -9,6 +9,7 @@ import { mockGetInvoice, mockGetInvoiceItems, } from 'support/intercepts/account'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { ui } from 'support/ui'; import { buildArray } from 'support/util/arrays'; import { formatUsd } from 'support/util/currency'; @@ -30,6 +31,12 @@ const getRegionLabel = (regionId: string) => { }; describe('Account invoices', () => { + beforeEach(() => { + mockAppendFeatureFlags({ + // TODO M3-10491 - Remove `iamRbacPrimaryNavChanges` feature flag mock once flag is deleted. + iamRbacPrimaryNavChanges: true, + }); + }); /* * - Confirms that invoice items are listed on invoice details page using mock API data. * - Confirms that each invoice item is displayed with correct accompanying info. @@ -138,7 +145,7 @@ describe('Account invoices', () => { mockGetInvoice(mockInvoice).as('getInvoice'); mockGetInvoiceItems(mockInvoice, mockInvoiceItems).as('getInvoiceItems'); - cy.visitWithLogin(`/account/billing/invoices/${mockInvoice.id}`); + cy.visitWithLogin(`/billing/invoices/${mockInvoice.id}`); cy.wait(['@getInvoice', '@getInvoiceItems']); // Confirm that "Region" table column is not present; old invoices will not be backfilled and we don't want to display a blank column. @@ -249,7 +256,7 @@ describe('Account invoices', () => { // Confirm that clicking the "Back to Billing" button redirects the user to // the account billing page. cy.get('[data-qa-back-to-billing]').should('be.visible').click(); - cy.url().should('endWith', '/account/billing'); + cy.url().should('endWith', '/billing'); }); it('does not list the region on past invoices', () => { @@ -267,7 +274,7 @@ describe('Account invoices', () => { mockGetInvoiceItems(mockInvoice, mockInvoiceItems).as('getInvoiceItems'); // Visit invoice details page, wait for relevant requests to resolve. - cy.visitWithLogin(`/account/billing/invoices/${mockInvoice.id}`); + cy.visitWithLogin(`/billing/invoices/${mockInvoice.id}`); cy.wait(['@getInvoice', '@getInvoiceItems']); cy.findByLabelText('Invoice Details').within(() => { @@ -300,7 +307,7 @@ describe('Account invoices', () => { mockGetInvoice(mockInvoice).as('getInvoice'); mockGetInvoiceItems(mockInvoice, mockInvoiceItems).as('getInvoiceItems'); - cy.visitWithLogin(`/account/billing/invoices/${mockInvoice.id}`); + cy.visitWithLogin(`/billing/invoices/${mockInvoice.id}`); cy.wait(['@getInvoice', '@getInvoiceItems']); cy.findByLabelText('Invoice Details').within(() => { diff --git a/packages/manager/cypress/e2e/core/billing/credit-card-expired-banner.spec.ts b/packages/manager/cypress/e2e/core/billing/credit-card-expired-banner.spec.ts index 2ed6fa23839..4ebaf70e8be 100644 --- a/packages/manager/cypress/e2e/core/billing/credit-card-expired-banner.spec.ts +++ b/packages/manager/cypress/e2e/core/billing/credit-card-expired-banner.spec.ts @@ -1,4 +1,5 @@ import { mockGetAccount } from 'support/intercepts/account'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { mockGetUserPreferences } from 'support/intercepts/profile'; import { ui } from 'support/ui'; @@ -9,7 +10,13 @@ const creditCardExpiredBannerNotice = describe('Credit Card Expired Banner', () => { beforeEach(() => { - mockGetUserPreferences({ dismissed_notifications: {} }); + mockGetUserPreferences({ + dismissed_notifications: {}, + }); + mockAppendFeatureFlags({ + // TODO M3-10491 - Remove `iamRbacPrimaryNavChanges` feature flag mock once flag is deleted. + iamRbacPrimaryNavChanges: true, + }).as('getFeatureFlags'); }); it('appears when the expiration date is in the past', () => { @@ -21,15 +28,15 @@ describe('Credit Card Expired Banner', () => { cy.findByText(creditCardExpiredBannerNotice).should('be.visible'); ui.button.findByTitle('Update Card').should('be.visible').click(); - // clicking on the link navigates to /account/billing - cy.url().should('endWith', '/account/billing'); + // clicking on the link navigates to /billing + cy.url().should('endWith', '/billing'); }); it('does not appear when the expiration date is in the future', () => { mockGetAccount( accountFactory.build({ credit_card: { expiry: '01/2999' } }) ).as('getAccount'); - cy.visitWithLogin('/account/billing'); + cy.visitWithLogin('/billing'); cy.wait('@getAccount'); cy.findByText('Payment Methods').should('be.visible'); cy.findByText(creditCardExpiredBannerNotice).should('not.exist'); diff --git a/packages/manager/cypress/e2e/core/billing/google-pay.spec.ts b/packages/manager/cypress/e2e/core/billing/google-pay.spec.ts index 3c484ee5154..b13bfd3f8c9 100644 --- a/packages/manager/cypress/e2e/core/billing/google-pay.spec.ts +++ b/packages/manager/cypress/e2e/core/billing/google-pay.spec.ts @@ -1,4 +1,5 @@ import { mockGetPaymentMethods } from 'support/intercepts/account'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { ui } from 'support/ui'; import type { CreditCardData, PaymentMethod } from '@linode/api-v4'; @@ -8,7 +9,7 @@ const mockPaymentMethods: PaymentMethod[] = [ created: '2021-07-27T14:37:43', data: { card_type: 'American Express', - expiry: '07/2025', + expiry: '07/2029', last_four: '2222', }, id: 420330, @@ -17,7 +18,7 @@ const mockPaymentMethods: PaymentMethod[] = [ }, { created: '2021-08-04T18:29:01', - data: { card_type: 'Visa', expiry: '07/2025', last_four: '2045' }, + data: { card_type: 'Visa', expiry: '07/2029', last_four: '2045' }, id: 434357, is_default: false, type: 'google_pay', @@ -56,20 +57,27 @@ const braintreeURL = 'https://+(payments.braintree-api.com|payments.sandbox.braintree-api.com)/*'; describe('Google Pay', () => { + beforeEach(() => { + mockAppendFeatureFlags({ + // TODO M3-10491 - Remove `iamRbacPrimaryNavChanges` feature flag mock once flag is deleted. + iamRbacPrimaryNavChanges: true, + }); + }); + it('adds google pay method', () => { cy.intercept(braintreeURL).as('braintree'); mockGetPaymentMethods(mockPaymentMethods).as('getPaymentMethods'); - cy.visitWithLogin('/account/billing'); + cy.visitWithLogin('/billing'); cy.wait('@getPaymentMethods'); cy.findByText('Add Payment Method').should('be.visible').click(); cy.get('[data-qa-button="gpayChip"]').should('be.visible').click(); cy.wait('@braintree'); }); - it('tests make payment flow - google pay', () => { + it('can make a payment via Google Pay using payment method menu button', () => { cy.intercept(braintreeURL).as('braintree'); mockGetPaymentMethods(mockPaymentMethods).as('getPaymentMethods'); - cy.visitWithLogin('/account/billing'); + cy.visitWithLogin('/billing'); cy.wait('@getPaymentMethods'); ui.actionMenu @@ -100,10 +108,10 @@ describe('Google Pay', () => { cy.wait('@braintree'); }); - it('tests payment flow with expired card - google pay', () => { + it('cannot make a payment with an expired payment method via Google Pay using payment method menu button', () => { cy.intercept(braintreeURL).as('braintree'); mockGetPaymentMethods(mockPaymentMethodsExpired).as('getPaymentMethods'); - cy.visitWithLogin('/account/billing'); + cy.visitWithLogin('/billing'); cy.wait('@getPaymentMethods'); cy.get('[data-qa-payment-row="google_pay"]').within(() => { diff --git a/packages/manager/cypress/e2e/core/billing/restricted-user-billing.spec.ts b/packages/manager/cypress/e2e/core/billing/restricted-user-billing.spec.ts index 3e00dec7a13..61f2c081cc2 100644 --- a/packages/manager/cypress/e2e/core/billing/restricted-user-billing.spec.ts +++ b/packages/manager/cypress/e2e/core/billing/restricted-user-billing.spec.ts @@ -6,6 +6,7 @@ import { grantsFactory, profileFactory } from '@linode/utilities'; import { paymentMethodFactory } from '@src/factories'; import { accountUserFactory } from '@src/factories/accountUsers'; import { mockGetPaymentMethods, mockGetUser } from 'support/intercepts/account'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { mockGetProfile, mockGetProfileGrants, @@ -25,7 +26,7 @@ const mockPaymentMethods = [ paymentMethodFactory.build({ data: { card_type: 'Visa', - expiry: '12/2026', + expiry: '12/2029', last_four: '1234', }, is_default: false, @@ -33,7 +34,7 @@ const mockPaymentMethods = [ paymentMethodFactory.build({ data: { card_type: 'Visa', - expiry: '12/2026', + expiry: '12/2029', last_four: '5678', }, is_default: true, @@ -223,6 +224,10 @@ const assertMakeAPaymentEnabled = () => { describe('restricted user billing flows', () => { beforeEach(() => { mockGetPaymentMethods(mockPaymentMethods); + // TODO M3-10491 - Remove `iamRbacPrimaryNavChanges` feature flag mock once flag is deleted. + mockAppendFeatureFlags({ + iamRbacPrimaryNavChanges: true, + }); }); /* @@ -254,7 +259,7 @@ describe('restricted user billing flows', () => { mockGetProfile(mockProfile); mockGetProfileGrants(mockGrants); mockGetUser(mockUser); - cy.visitWithLogin('/account/billing'); + cy.visitWithLogin('/billing'); assertEditBillingInfoDisabled(restrictedUserTooltip); assertAddPaymentMethodDisabled(restrictedUserTooltip); @@ -284,7 +289,7 @@ describe('restricted user billing flows', () => { mockGetProfile(mockProfile); mockGetUser(mockUser); - cy.visitWithLogin('/account/billing'); + cy.visitWithLogin('/billing'); assertEditBillingInfoDisabled(restrictedUserTooltip); assertAddPaymentMethodDisabled(restrictedUserTooltip); @@ -324,7 +329,7 @@ describe('restricted user billing flows', () => { // Confirm button behavior for regular users. mockGetProfile(mockProfileRegular); mockGetUser(mockUserRegular); - cy.visitWithLogin('/account/billing'); + cy.visitWithLogin('/billing'); cy.findByText(mockProfileRegular.username); assertEditBillingInfoEnabled(); assertAddPaymentMethodEnabled(); @@ -333,7 +338,7 @@ describe('restricted user billing flows', () => { // Confirm button behavior for parent users. mockGetProfile(mockProfileParent); mockGetUser(mockUserParent); - cy.visitWithLogin('/account/billing'); + cy.visitWithLogin('/billing'); cy.findByText(mockProfileParent.username); assertEditBillingInfoEnabled(); assertAddPaymentMethodEnabled(); 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 73fb4893f57..9a9f8946d11 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 @@ -8,6 +8,7 @@ import { mockGetPaymentMethods, mockGetPayments, } from 'support/intercepts/account'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { mockGetProfile, mockUpdateProfile } from 'support/intercepts/profile'; import { ui } from 'support/ui'; import { buildArray } from 'support/util/arrays'; @@ -47,12 +48,10 @@ const navigateToBilling = () => { .find() .should('be.visible') .within(() => { - cy.findByText('Billing & Contact Information') - .should('be.visible') - .click(); + cy.findByText('Billing').should('be.visible').click(); }); - cy.url().should('endWith', '/account/billing'); + cy.url().should('endWith', '/billing'); }; /** @@ -117,6 +116,12 @@ const assertPaymentInfo = (payment: Payment, timezone: string) => { authenticate(); describe('Billing Activity Feed', () => { + beforeEach(() => { + mockAppendFeatureFlags({ + // TODO M3-10491 - Remove `iamRbacPrimaryNavChanges` feature flag mock once flag is deleted. + iamRbacPrimaryNavChanges: true, + }); + }); /* * - Uses mocked API data to confirm that invoices and payments are listed on billing page. * - Confirms that invoice and payment labels, dates, and totals are displayed as expected. @@ -163,7 +168,7 @@ describe('Billing Activity Feed', () => { cy.defer(() => getProfile()).then((profile: Profile) => { const timezone = profile.timezone; - cy.visitWithLogin('/account/billing'); + cy.visitWithLogin('/billing'); cy.wait(['@getInvoices', '@getPayments']); cy.findByText('Billing & Payment History') .as('qaBilling') @@ -266,7 +271,7 @@ describe('Billing Activity Feed', () => { mockGetPayments([]).as('getPayments'); mockGetPaymentMethods([]).as('getPaymentMethods'); - cy.visitWithLogin('/account/billing'); + cy.visitWithLogin('/billing'); cy.wait(['@getInvoices', '@getPayments', '@getPaymentMethods']); // Change invoice date selection from "6 Months" to "All Time". diff --git a/packages/manager/cypress/e2e/core/cloudpulse/aclp-support.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/aclp-support.spec.ts index d8f57e7ddaa..a1effa63ac8 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/aclp-support.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/aclp-support.spec.ts @@ -32,10 +32,10 @@ import type { Stats } from '@linode/api-v4'; describe('ACLP Components UI varies according to ACLP support by region and user preference', function () { beforeEach(function () { mockAppendFeatureFlags({ - aclpBetaServices: { + aclpServices: { linode: { - alerts: false, - metrics: true, + alerts: { beta: false, enabled: false }, + metrics: { beta: true, enabled: true }, }, }, }).as('getFeatureFlags'); diff --git a/packages/manager/cypress/e2e/core/cloudpulse/alert-errors.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/alert-errors.spec.ts index 328620c8cf5..36345028a96 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/alert-errors.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/alert-errors.spec.ts @@ -7,11 +7,7 @@ import { import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { ui } from 'support/ui'; -import { accountFactory, alertFactory } from 'src/factories'; - -import type { Flags } from 'src/featureFlags'; - -const flags: Partial = { aclp: { beta: true, enabled: true } }; +import { accountFactory, alertFactory, flagsFactory } from 'src/factories'; const mockAccount = accountFactory.build(); const mockAlerts = [ alertFactory.build({ @@ -39,7 +35,7 @@ describe('Alerts Listing Page - Error Handling', () => { * - Confirms that the UI does not reflect a successful state change if the request fails. */ beforeEach(() => { - mockAppendFeatureFlags(flags); + mockAppendFeatureFlags(flagsFactory.build()); mockGetAccount(mockAccount); mockGetCloudPulseServices(['linode', 'dbaas']); mockGetAllAlertDefinitions(mockAlerts).as('getAlertDefinitionsList'); @@ -82,9 +78,9 @@ describe('Alerts Listing Page - Error Handling', () => { .findByTitle(`Action menu for Alert ${alertName}`) .should('be.visible') .click(); + ui.actionMenuItem.findByTitle(action).should('be.visible').click(); }); - ui.actionMenuItem.findByTitle(action).should('be.visible').click(); ui.button.findByTitle(action).should('be.visible').click(); cy.wait(alias).then(({ response }) => { ui.toast.assertMessage(response?.body.errors[0].reason); diff --git a/packages/manager/cypress/e2e/core/cloudpulse/alert-show-details.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/alert-show-details.spec.ts index c48eba0e7c8..edae79f9816 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/alert-show-details.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/alert-show-details.spec.ts @@ -132,11 +132,13 @@ describe('Integration Tests for Alert Show Detail Page', () => { .findByTitle(`Action menu for Alert ${label}`) .should('be.visible') .click(); + // Select the "Show Details" option from the action menu + ui.actionMenuItem + .findByTitle('Show Details') + .should('be.visible') + .click(); }); - // Select the "Show Details" option from the action menu - ui.actionMenuItem.findByTitle('Show Details').should('be.visible').click(); - // Verify the URL ends with the expected details page path cy.url().should('endWith', `/detail/${service_type}/${id}`); }); diff --git a/packages/manager/cypress/e2e/core/cloudpulse/alerts-listing-page.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/alerts-listing-page.spec.ts index 590e646587a..485dd160cb7 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/alerts-listing-page.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/alerts-listing-page.spec.ts @@ -15,7 +15,12 @@ import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { mockGetProfile } from 'support/intercepts/profile'; import { ui } from 'support/ui'; -import { accountFactory, alertFactory, alertRulesFactory } from 'src/factories'; +import { + accountFactory, + alertFactory, + alertRulesFactory, + flagsFactory, +} from 'src/factories'; import { alertLimitMessage, alertToolTipText, @@ -28,20 +33,16 @@ import { } from 'src/features/CloudPulse/Alerts/constants'; import { formatDate } from 'src/utilities/formatDate'; -import type { Alert, AlertServiceType, AlertStatusType } from '@linode/api-v4'; -import type { Flags } from 'src/featureFlags'; +import type { + Alert, + AlertStatusType, + CloudPulseServiceType, +} from '@linode/api-v4'; const alertDefinitionsUrl = '/alerts/definitions'; const mockProfile = profileFactory.build({ timezone: 'gmt', }); -const flags: Partial = { - aclp: { beta: true, enabled: true }, - aclpBetaServices: { - dbaas: { metrics: true, alerts: true }, - linode: { metrics: true, alerts: true }, - }, -}; const mockAccount = accountFactory.build(); const now = new Date(); const mockAlerts = [ @@ -111,7 +112,7 @@ const statusList: AlertStatusType[] = [ 'in progress', 'failed', ]; -const serviceTypes: AlertServiceType[] = ['linode', 'dbaas']; +const serviceTypes: CloudPulseServiceType[] = ['linode', 'dbaas']; /** * @description @@ -211,7 +212,7 @@ describe('Integration Tests for CloudPulse Alerts Listing Page', () => { * - Ensures API calls return correct responses and status codes. */ beforeEach(() => { - mockAppendFeatureFlags(flags); + mockAppendFeatureFlags(flagsFactory.build()); mockGetAccount(mockAccount); mockGetProfile(mockProfile); mockGetCloudPulseServices(['linode', 'dbaas']); @@ -381,8 +382,8 @@ describe('Integration Tests for CloudPulse Alerts Listing Page', () => { .findByTitle(`Action menu for Alert ${alertName}`) .should('be.visible') .click(); + ui.actionMenuItem.findByTitle(action).should('be.visible').click(); }); - ui.actionMenuItem.findByTitle(action).should('be.visible').click(); // verify dialog title ui.dialog diff --git a/packages/manager/cypress/e2e/core/cloudpulse/alerts-service-ld-flags.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/alerts-service-ld-flags.spec.ts new file mode 100644 index 00000000000..1c0a8b6cb9f --- /dev/null +++ b/packages/manager/cypress/e2e/core/cloudpulse/alerts-service-ld-flags.spec.ts @@ -0,0 +1,167 @@ +/** + * @file Integration tests for feature flag behavior on the alert page. + */ +import { widgetDetails } from 'support/constants/widgets'; +import { mockGetAccount } from 'support/intercepts/account'; +import { + mockGetCloudPulseMetricDefinitions, + mockGetCloudPulseServices, +} from 'support/intercepts/cloudpulse'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { mockGetUserPreferences } from 'support/intercepts/profile'; +import { ui } from 'support/ui'; + +import { + accountFactory, + dashboardMetricFactory, + flagsFactory, +} from 'src/factories'; +/** + * This test ensures that widget titles are displayed correctly on the dashboard. + * This test suite is dedicated to verifying the functionality and display of widgets on the Cloudpulse dashboard. + * It includes: + * Validating that widgets are correctly loaded and displayed. + * Ensuring that widget titles and data match the expected values. + * Verifying that widget settings, such as granularity and aggregation, are applied correctly. + * Testing widget interactions, including zooming and filtering, to ensure proper behavior. + * Each test ensures that widgets on the dashboard operate correctly and display accurate information. + */ + +const { metrics } = widgetDetails.linode; +const serviceType = 'linode'; + +const metricDefinitions = metrics.map(({ name, title, unit }) => + dashboardMetricFactory.build({ + label: title, + metric: name, + unit, + }) +); + +const mockAccount = accountFactory.build(); +const CREATE_ALERT_PAGE_URL = '/alerts/definitions/create'; +const NO_OPTIONS_TEXT = 'You have no options to choose from'; + +describe('Linode ACLP Metrics and Alerts Flag Behavior', () => { + beforeEach(() => { + mockGetAccount(mockAccount); // Enables the account to have capability for Akamai Cloud Pulse + mockGetCloudPulseMetricDefinitions(serviceType, metricDefinitions); + mockGetCloudPulseServices([serviceType]).as('fetchServices'); + mockGetUserPreferences({}); + }); + + it('should show Linode with beta tag in Service dropdown on Alert page when alerts.beta is true', () => { + mockAppendFeatureFlags(flagsFactory.build()); + cy.visitWithLogin(CREATE_ALERT_PAGE_URL); + ui.autocomplete.findByLabel('Service').as('serviceInput'); + cy.get('@serviceInput').click(); + + cy.get('[data-qa-id="linode"]') + .should('have.text', 'Linode') + .parent() + .as('linodeBetaServiceOption'); + + cy.get('@linodeBetaServiceOption') + .find('[data-testid="betaChip"]') + .should('be.visible') + .and('have.text', 'beta'); + + cy.get('@serviceInput').should('be.visible').type('Linode'); + ui.autocompletePopper.findByTitle('Linode').should('be.visible').click(); + }); + it('should exclude Linode beta in Service dropdown when alerts.beta is false', () => { + // Mock feature flags with alerts beta disabled + const mockflags = flagsFactory.build({ + aclpServices: { + linode: { + alerts: { beta: false, enabled: false }, + }, + }, + }); + + mockAppendFeatureFlags(mockflags); + + // Visit the alert creation page + cy.visitWithLogin(CREATE_ALERT_PAGE_URL); + + // Click the Service dropdown + ui.autocomplete.findByLabel('Service').as('serviceInput'); + cy.get('@serviceInput').click(); + + // Assert dropdown behavior + cy.get('[data-qa-autocomplete-popper]') + .should('be.visible') + .and('have.text', NO_OPTIONS_TEXT) + .and('not.contain.text', 'Linode beta'); + }); + + it('should show no available services in the Service dropdown when Linode alerts are disabled but beta is true', () => { + const mockflags = flagsFactory.build({ + aclpServices: { + linode: { + alerts: { beta: true, enabled: false }, + }, + }, + }); + + mockAppendFeatureFlags(mockflags); + // Visit the alert creation page + cy.visitWithLogin(CREATE_ALERT_PAGE_URL); + // Click the Service dropdown + ui.autocomplete.findByLabel('Service').as('serviceInput'); + cy.get('@serviceInput').click(); + + // ---------- Assert ---------- + cy.get('[data-qa-autocomplete-popper]') + .should('be.visible') + .and('have.text', NO_OPTIONS_TEXT) + .and('not.contain.text', 'Linode beta'); + }); + + it('should show no options and exclude Linode beta in Service dropdown when alerts are disabled but beta is true', () => { + const mockflags = flagsFactory.build({ + aclpServices: { + linode: { + alerts: { beta: true, enabled: false }, + }, + }, + }); + + mockAppendFeatureFlags(mockflags); + // Visit the alert creation page + cy.visitWithLogin(CREATE_ALERT_PAGE_URL); + // Click the Service dropdown + ui.autocomplete.findByLabel('Service').as('serviceInput'); + cy.get('@serviceInput').click(); + + // ---------- Assert ---------- + cy.get('[data-qa-autocomplete-popper]') + .should('be.visible') + .and('contain.text', 'You have no options to choose from') + .and('not.contain.text', 'Linode beta'); + }); + + it('should show Linode without beta tag in Service dropdown when alerts are enabled but not in beta', () => { + const mockflags = flagsFactory.build({ + aclpServices: { + linode: { + alerts: { beta: false, enabled: true }, + }, + }, + }); + mockAppendFeatureFlags(mockflags); + cy.visitWithLogin(CREATE_ALERT_PAGE_URL); + ui.autocomplete.findByLabel('Service').as('serviceInput'); + cy.get('@serviceInput').click(); + + // ---------- Assert ---------- + cy.get('[data-qa-id="linode"]') + .should('have.text', 'Linode') + .parent() + .as('linodeBetaServiceOption'); + + cy.get('@linodeBetaServiceOption') + .find('[data-testid="betaChip"]') + .should('not.exist'); + }); +}); diff --git a/packages/manager/cypress/e2e/core/cloudpulse/cloudpulse-dashboard-errors.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/cloudpulse-dashboard-errors.spec.ts index b616987bcd1..d852296fd3d 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/cloudpulse-dashboard-errors.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/cloudpulse-dashboard-errors.spec.ts @@ -33,11 +33,11 @@ import { dashboardFactory, dashboardMetricFactory, databaseFactory, + flagsFactory, widgetFactory, } from 'src/factories'; import type { Database } from '@linode/api-v4'; -import type { Flags } from 'src/featureFlags'; /** * Verifies the presence and values of specific properties within the aclpPreference object @@ -47,32 +47,9 @@ import type { Flags } from 'src/featureFlags'; * @param requestPayload - The payload received from the request, containing the aclpPreference object. * @param expectedValues - An object containing the expected values for properties to validate against the requestPayload. */ - -const flags: Partial = { - aclp: { beta: true, enabled: true }, - aclpResourceTypeMap: [ - { - dimensionKey: 'LINODE_ID', - maxResourceSelections: 10, - serviceType: 'linode', - }, - { - dimensionKey: 'cluster_id', - maxResourceSelections: 10, - serviceType: 'dbaas', - }, - ], -}; -const { - clusterName, - dashboardName, - engine, - id, - metrics, - nodeType, - serviceType, -} = widgetDetails.dbaas; - +const { clusterName, dashboardName, engine, id, metrics, nodeType } = + widgetDetails.dbaas; +const serviceType = 'dbaas'; const dashboard = dashboardFactory.build({ label: dashboardName, service_type: serviceType, @@ -139,7 +116,7 @@ const mockAccount = accountFactory.build(); describe('Tests for API error handling', () => { beforeEach(() => { - mockAppendFeatureFlags(flags); + mockAppendFeatureFlags(flagsFactory.build()); mockGetAccount(mockAccount); mockGetCloudPulseMetricDefinitions(serviceType, metricDefinitions); mockGetCloudPulseDashboards(serviceType, [dashboard]).as('fetchDashboard'); @@ -412,6 +389,11 @@ describe('Tests for API error handling', () => { // Wait for the API calls . cy.wait(['@fetchServices', '@fetchDashboard']); + // simulate an error on instances call before changing the region again + mockGetDatabasesError('Internal Server Error').as( + 'getDatabaseInstancesError' + ); + // Select a dashboard from the autocomplete input ui.autocomplete .findByLabel('Dashboard') @@ -423,33 +405,6 @@ describe('Tests for API error handling', () => { .should('be.visible') .click(); - // Select a Database Engine from the autocomplete input. - ui.autocomplete - .findByLabel('Database Engine') - .should('be.visible') - .type(engine); - - ui.autocompletePopper.findByTitle(engine).should('be.visible').click(); - - // Select a region from the dropdown. - ui.regionSelect.find().click(); - ui.regionSelect - .findItemByRegionId(mockRegions[0].id, mockRegions) - .should('be.visible') - .click(); - - // simulate an error on instances call before changing the region again - mockGetDatabasesError('Internal Server Error').as( - 'getDatabaseInstancesError' - ); - - // Select a region from the dropdown. - ui.regionSelect.find().click(); - ui.regionSelect - .findItemByRegionId(mockRegions[1].id, mockRegions) - .should('be.visible') - .click(); - // Wait for the intercepted request to complete cy.wait('@getDatabaseInstancesError'); diff --git a/packages/manager/cypress/e2e/core/cloudpulse/create-user-alert.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/create-user-alert.spec.ts index 3e27a710155..82b1f5fd4a4 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/create-user-alert.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/create-user-alert.spec.ts @@ -26,15 +26,13 @@ import { cpuRulesFactory, dashboardMetricFactory, databaseFactory, + flagsFactory, memoryRulesFactory, notificationChannelFactory, triggerConditionFactory, } from 'src/factories'; import { CREATE_ALERT_SUCCESS_MESSAGE } from 'src/features/CloudPulse/Alerts/constants'; import { formatDate } from 'src/utilities/formatDate'; - -import type { Flags } from 'src/featureFlags'; - export interface MetricDetails { aggregationType: string; dataField: string; @@ -43,8 +41,6 @@ export interface MetricDetails { threshold: string; } -const flags: Partial = { aclp: { beta: true, enabled: true } }; - // Create mock data const mockAccount = accountFactory.build(); const mockRegions = [ @@ -67,7 +63,8 @@ const mockRegions = [ }, }), ]; -const { metrics, serviceType } = widgetDetails.dbaas; +const { metrics } = widgetDetails.dbaas; +const serviceType = 'dbaas'; const databaseMock = databaseFactory.buildList(10, { cluster_size: 3, engine: 'mysql', @@ -175,7 +172,7 @@ describe('Create Alert', () => { * - Confirms that the UI displays a success message after creating an alert. */ beforeEach(() => { - mockAppendFeatureFlags(flags); + mockAppendFeatureFlags(flagsFactory.build()); mockGetAccount(mockAccount); mockGetProfile(mockProfile); mockGetCloudPulseServices([serviceType]); diff --git a/packages/manager/cypress/e2e/core/cloudpulse/dbaas-widgets-verification.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/dbaas-widgets-verification.spec.ts index 96be3fc1fa3..8de25cf4d55 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/dbaas-widgets-verification.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/dbaas-widgets-verification.spec.ts @@ -27,6 +27,7 @@ import { dashboardMetricFactory, databaseFactory, dimensionFilterFactory, + flagsFactory, kubeLinodeFactory, widgetFactory, } from 'src/factories'; @@ -40,7 +41,6 @@ import type { DimensionFilter, Widgets, } from '@linode/api-v4'; -import type { Flags } from 'src/featureFlags'; import type { Interception } from 'support/cypress-exports'; /** @@ -55,33 +55,9 @@ import type { Interception } from 'support/cypress-exports'; */ const expectedGranularityArray = ['Auto', '1 day', '1 hr', '5 min']; const timeDurationToSelect = 'Last 24 Hours'; - -const flags: Partial = { - aclp: { beta: true, enabled: true }, - aclpResourceTypeMap: [ - { - dimensionKey: 'LINODE_ID', - maxResourceSelections: 10, - serviceType: 'linode', - }, - { - dimensionKey: 'cluster_id', - maxResourceSelections: 10, - serviceType: 'dbaas', - }, - ], -}; - -const { - clusterName, - dashboardName, - engine, - id, - metrics, - nodeType, - serviceType, -} = widgetDetails.dbaas; - +const { clusterName, dashboardName, engine, id, metrics, nodeType } = + widgetDetails.dbaas; +const serviceType = 'dbaas'; const dashboard = dashboardFactory.build({ label: dashboardName, service_type: serviceType, @@ -168,6 +144,7 @@ const getWidgetLegendRowValuesFromResponse = ( ], status: 'success', unit, + serviceType, }); // Destructure metrics data from the first legend row @@ -206,7 +183,7 @@ const validateWidgetFilters = (widget: Widgets) => { describe('Integration Tests for DBaaS Dashboard ', () => { beforeEach(() => { - mockAppendFeatureFlags(flags); + mockAppendFeatureFlags(flagsFactory.build()); mockGetAccount(mockAccount); // Enables the account to have capability for Akamai Cloud Pulse mockGetLinodes([mockLinode]); mockGetCloudPulseMetricDefinitions(serviceType, metricDefinitions); diff --git a/packages/manager/cypress/e2e/core/cloudpulse/edit-system-alert.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/edit-system-alert.spec.ts index f24a05fb99c..44df530332a 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/edit-system-alert.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/edit-system-alert.spec.ts @@ -16,12 +16,14 @@ import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { mockGetRegions } from 'support/intercepts/regions'; import { ui } from 'support/ui'; -import { accountFactory, alertFactory, databaseFactory } from 'src/factories'; +import { + accountFactory, + alertFactory, + databaseFactory, + flagsFactory, +} from 'src/factories'; import type { Alert, Database } from '@linode/api-v4'; -import type { Flags } from 'src/featureFlags'; - -const flags: Partial = { aclp: { beta: true, enabled: true } }; const expectedResourceIds = Array.from({ length: 50 }, (_, i) => String(i + 1)); const mockAccount = accountFactory.build(); @@ -69,7 +71,7 @@ describe('Integration Tests for Edit Alert', () => { * - Confirms that after submitting, the data matches with the API response. */ beforeEach(() => { - mockAppendFeatureFlags(flags); + mockAppendFeatureFlags(flagsFactory.build()); mockGetAccount(mockAccount); mockGetRegions(regions); mockGetAllAlertDefinitions([alertDetails]).as('getAlertDefinitionsList'); @@ -98,11 +100,10 @@ describe('Integration Tests for Edit Alert', () => { .findByTitle(`Action menu for Alert ${label}`) .should('be.visible') .click(); + // Select the "Edit" option from the action menu + ui.actionMenuItem.findByTitle('Edit').should('be.visible').click(); }); - // Select the "Edit" option from the action menu - ui.actionMenuItem.findByTitle('Edit').should('be.visible').click(); - // Verify the URL ends with the expected details page path cy.url().should('endWith', `/edit/${service_type}/${id}`); }); diff --git a/packages/manager/cypress/e2e/core/cloudpulse/edit-user-alert.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/edit-user-alert.spec.ts index 1b55b520162..3ab96ff6578 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/edit-user-alert.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/edit-user-alert.spec.ts @@ -35,6 +35,7 @@ import { cpuRulesFactory, dashboardMetricFactory, databaseFactory, + flagsFactory, memoryRulesFactory, notificationChannelFactory, triggerConditionFactory, @@ -43,10 +44,7 @@ import { UPDATE_ALERT_SUCCESS_MESSAGE } from 'src/features/CloudPulse/Alerts/con import { formatDate } from 'src/utilities/formatDate'; import type { Database } from '@linode/api-v4'; -import type { Flags } from 'src/featureFlags'; -// Feature flag setup -const flags: Partial = { aclp: { beta: true, enabled: true } }; const mockAccount = accountFactory.build(); // Mock alert details @@ -127,7 +125,7 @@ describe('Integration Tests for Edit Alert', () => { */ beforeEach(() => { // Mocking various API responses - mockAppendFeatureFlags(flags); + mockAppendFeatureFlags(flagsFactory.build()); mockGetAccount(mockAccount); mockGetProfile(mockProfile); mockGetRegions(regions); diff --git a/packages/manager/cypress/e2e/core/cloudpulse/feature-flag-disabled.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/feature-flag-disabled.spec.ts index b61ec199da9..d25dfba0a9a 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/feature-flag-disabled.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/feature-flag-disabled.spec.ts @@ -8,13 +8,13 @@ import { randomLabel, randomNumber } from 'support/util/random'; import type { UserPreferences } from '@linode/api-v4'; -describe('User preferences for alerts and metrics have no effect when aclpBetaServices alerts/metrics feature flag is disabled', () => { +describe('User preferences for alerts and metrics have no effect when aclpServices alerts/metrics feature flag is disabled', () => { beforeEach(() => { mockAppendFeatureFlags({ - aclpBetaServices: { + aclpServices: { linode: { - alerts: false, - metrics: false, + alerts: { beta: false, enabled: false }, + metrics: { beta: false, enabled: false }, }, }, }).as('getFeatureFlags'); diff --git a/packages/manager/cypress/e2e/core/cloudpulse/groupby-tags.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/groupby-tags.spec.ts index 15be14ef546..e6eb5094c01 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/groupby-tags.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/groupby-tags.spec.ts @@ -13,12 +13,13 @@ import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { mockGetUserPreferences } from 'support/intercepts/profile'; import { ui } from 'support/ui'; -import { accountFactory, alertFactory } from 'src/factories'; +import { accountFactory, alertFactory, flagsFactory } from 'src/factories'; -import type { Alert, AlertServiceType, AlertStatusType } from '@linode/api-v4'; -import type { Flags } from 'src/featureFlags'; - -const flags: Partial = { aclp: { beta: true, enabled: true } }; +import type { + Alert, + AlertStatusType, + CloudPulseServiceType, +} from '@linode/api-v4'; const mockAccount = accountFactory.build(); const statusList: AlertStatusType[] = [ @@ -27,7 +28,7 @@ const statusList: AlertStatusType[] = [ 'in progress', 'failed', ]; -const serviceTypes: AlertServiceType[] = ['linode', 'dbaas']; +const serviceTypes: CloudPulseServiceType[] = ['linode', 'dbaas']; const tagSequence = ['LinodeTags', 'DBaaSTags', 'bothTags', 'No Tags']; // Generate mock alerts with a mix of tags, statuses, and service types @@ -85,7 +86,7 @@ describe('Integration Tests for Grouping Alerts by Tags on the CloudPulse Alerts */ it('Displays alerts accurately grouped under their corresponding tags', () => { // Setup necessary mocks and feature flags - mockAppendFeatureFlags(flags); + mockAppendFeatureFlags(flagsFactory.build()); mockGetAccount(mockAccount); mockGetCloudPulseServices(serviceTypes); mockGetAllAlertDefinitions(mockAlerts).as('getAlertDefinitionsList'); diff --git a/packages/manager/cypress/e2e/core/cloudpulse/linode-widget-verification.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/linode-widget-verification.spec.ts index 0f6e2acf118..9c30ccef63e 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/linode-widget-verification.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/linode-widget-verification.spec.ts @@ -24,6 +24,7 @@ import { cloudPulseMetricsResponseFactory, dashboardFactory, dashboardMetricFactory, + flagsFactory, kubeLinodeFactory, widgetFactory, } from 'src/factories'; @@ -31,7 +32,6 @@ import { generateGraphData } from 'src/features/CloudPulse/Utils/CloudPulseWidge import { formatToolTip } from 'src/features/CloudPulse/Utils/unitConversion'; import type { CloudPulseMetricsResponse } from '@linode/api-v4'; -import type { Flags } from 'src/featureFlags'; import type { Interception } from 'support/cypress-exports'; /** @@ -46,24 +46,8 @@ import type { Interception } from 'support/cypress-exports'; */ const expectedGranularityArray = ['Auto', '1 day', '1 hr', '5 min']; const timeDurationToSelect = 'Last 24 Hours'; -const flags: Partial = { - aclp: { beta: true, enabled: true }, - aclpResourceTypeMap: [ - { - dimensionKey: 'LINODE_ID', - maxResourceSelections: 10, - serviceType: 'linode', - }, - { - dimensionKey: 'cluster_id', - maxResourceSelections: 10, - serviceType: 'dbaas', - }, - ], -}; -const { dashboardName, id, metrics, region, resource, serviceType } = - widgetDetails.linode; - +const { dashboardName, id, metrics, region, resource } = widgetDetails.linode; +const serviceType = 'linode'; const dashboard = dashboardFactory.build({ label: dashboardName, service_type: serviceType, @@ -145,6 +129,7 @@ const getWidgetLegendRowValuesFromResponse = ( ], status: 'success', unit, + serviceType, }); // Destructure metrics data from the first legend row @@ -160,7 +145,7 @@ const getWidgetLegendRowValuesFromResponse = ( describe('Integration Tests for Linode Dashboard ', () => { beforeEach(() => { - mockAppendFeatureFlags(flags); + mockAppendFeatureFlags(flagsFactory.build()); mockGetAccount(mockAccount); // Enables the account to have capability for Akamai Cloud Pulse mockGetLinodes([mockLinode]); mockGetCloudPulseMetricDefinitions(serviceType, metricDefinitions); diff --git a/packages/manager/cypress/e2e/core/cloudpulse/metrics-service-ld-flags.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/metrics-service-ld-flags.spec.ts new file mode 100644 index 00000000000..82a3548e5ed --- /dev/null +++ b/packages/manager/cypress/e2e/core/cloudpulse/metrics-service-ld-flags.spec.ts @@ -0,0 +1,317 @@ +/** + * @file Integration tests for feature flag behavior on the Metrics page. + */ +import { widgetDetails } from 'support/constants/widgets'; +import { mockGetAccount } from 'support/intercepts/account'; +import { + mockGetCloudPulseDashboard, + mockGetCloudPulseDashboards, + mockGetCloudPulseMetricDefinitions, + mockGetCloudPulseServices, +} from 'support/intercepts/cloudpulse'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { mockGetUserPreferences } from 'support/intercepts/profile'; +import { ui } from 'support/ui'; + +import { + accountFactory, + dashboardFactory, + dashboardMetricFactory, + flagsFactory, + widgetFactory, +} from 'src/factories'; + +import type { Flags } from 'src/featureFlags'; + +/** + * This test ensures that widget titles are displayed correctly on the dashboard. + * This test suite is dedicated to verifying the functionality and display of widgets on the Cloudpulse dashboard. + * It includes: + * Validating that widgets are correctly loaded and displayed. + * Ensuring that widget titles and data match the expected values. + * Verifying that widget settings, such as granularity and aggregation, are applied correctly. + * Testing widget interactions, including zooming and filtering, to ensure proper behavior. + * Each test ensures that widgets on the dashboard operate correctly and display accurate information. + */ + +const { dashboardName, id, metrics } = widgetDetails.linode; +const serviceType = 'linode'; +const dashboard = dashboardFactory.build({ + label: dashboardName, + service_type: serviceType, + widgets: metrics.map(({ name, title, unit, yLabel }) => { + return widgetFactory.build({ + label: title, + metric: name, + unit, + y_label: yLabel, + }); + }), +}); + +const metricDefinitions = metrics.map(({ name, title, unit }) => + dashboardMetricFactory.build({ + label: title, + metric: name, + unit, + }) +); +const mockAccount = accountFactory.build(); + +describe('Linode ACLP Metrics and Alerts Flag Behavior', () => { + /* + * - Mocks ACLP feature flags dynamically to simulate various flag combinations for Linode services. + * + * - Validates visibility of "Linode" dashboard option in Metrics dropdown based on: + * - Presence of `aclpServices.linode.metrics` flag. + * - Enabled and beta states under `metrics` and `alerts` keys. + * + * - Ensures correct rendering behavior: + * - "Linode" option should appear only when `metrics.enabled` is true. + * - Beta chip should appear only when `metrics.beta` is also true. + * - Linode should not appear if `metrics` flag is missing, disabled, or malformed. + * + * - Asserts "no options" message is shown when Linode dashboard is not available. + * + * - Uses Cypress commands to: + * - Visit Metrics page after login. + * - Interact with autocomplete dropdown and select dashboards. + * - Validate presence/absence of beta chip (`[data-testid="betaChip"]`). + * + * - Improves test coverage for conditional UI behavior tied to feature flag configurations. + * - Supports staged rollout testing and toggling of experimental dashboard features. + */ + + beforeEach(() => { + mockGetAccount(mockAccount); // Enables the account to have capability for Akamai Cloud Pulse + mockGetCloudPulseMetricDefinitions(serviceType, metricDefinitions); + mockGetCloudPulseServices([serviceType]).as('fetchServices'); + mockGetUserPreferences({}); + }); + it('should display "Linode" with a beta tag in the Service dropdown on the Metrics page when metrics.beta is enabled and the service is enabled', () => { + mockAppendFeatureFlags(flagsFactory.build()); + mockGetCloudPulseDashboard(id, dashboard); + mockGetCloudPulseDashboards(serviceType, [dashboard]).as('fetchDashboard'); + + // navigate to the metrics page + cy.visitWithLogin('/metrics'); + + ui.autocomplete.findByLabel('Dashboard').as('dashboardInput'); + + // Click using the alias + cy.get('@dashboardInput').click(); + + cy.get('[data-qa-id="linode"]') // Selects the Linode label + .should('have.text', 'Linode') + .parent() // Moves up to the
  • containing both label and chip + .as('linodeBetaServiceOption'); // Alias for reuse + + cy.get('@linodeBetaServiceOption') + .find('[data-testid="betaChip"]') + .should('be.visible') + .and('have.text', 'beta'); + + ui.autocomplete + .findByLabel('Dashboard') + .should('be.visible') + .type(dashboardName); + + ui.autocompletePopper + .findByTitle(dashboardName) + .should('have.text', dashboardName) + .click(); + }); + + it('should display "Linode" without a beta tag in the Service dropdown on the Metrics page when metrics.beta is false and the service is enabled', () => { + const mockflags = flagsFactory.build({ + aclpServices: { + linode: { + metrics: { beta: false, enabled: true }, + }, + }, + }); + + // Apply mock flags + mockAppendFeatureFlags(mockflags); + mockGetCloudPulseDashboard(id, dashboard); + mockGetCloudPulseDashboards(serviceType, [dashboard]).as('fetchDashboard'); + + // Visit the Metrics page + cy.visitWithLogin('/metrics'); + + // Locate and open the Dashboard dropdown + ui.autocomplete.findByLabel('Dashboard').as('dashboardInput'); + cy.get('@dashboardInput').click(); + + // Verify "Linode" is present without a beta chip + ui.autocompletePopper + .findByTitle('Linode') + .should('be.visible') + .within(() => { + cy.get('[data-testid="betaChip"]').should('not.exist'); + }); + + // Select the dashboard + cy.get('@dashboardInput').should('be.visible').type(dashboardName); + + ui.autocompletePopper + .findByTitle(dashboardName) + .should('have.text', dashboardName) + .click(); + }); + + it('should display "Linode" with a beta tag in the Service dropdown on the Metrics page when metrics.beta is true and enabled is false', () => { + // Mock the feature flags to disable metrics for Linode + + const mockflags = flagsFactory.build({ + aclpServices: { + linode: { + metrics: { beta: true, enabled: false }, + }, + }, + }); + // Apply the mock feature flags + mockAppendFeatureFlags(mockflags); + mockGetCloudPulseDashboard(id, dashboard); + mockGetCloudPulseDashboards(serviceType, [dashboard]).as('fetchDashboard'); + + // Visit the Metrics page after login + + cy.visitWithLogin('/metrics'); + + ui.autocomplete.findByLabel('Dashboard').click(); + + // Verify the autocomplete dropdown is visible and contains the "no options" message + + cy.get('[data-qa-autocomplete-popper]') + .should('be.visible') + .and('contain.text', 'You have no options to choose from') + .within(() => { + // Assert that "Linode" does not appear in the dropdown + + cy.contains('Linode').should('not.exist'); + // Assert that the beta chip is not rendered + + cy.get('[data-testid="betaChip"]').should('not.exist'); + }); + }); + + it('should not display "Linode" when its feature flag is missing', () => { + // Mock the feature flags without linode under aclpServices + const flags = { + aclp: { beta: true, enabled: true }, + aclpServices: { linode: {} }, + } as unknown as Partial; + mockAppendFeatureFlags(flags); + // Visit the Metrics page after login + cy.visitWithLogin('/metrics'); + // Open the dashboard autocomplete dropdown + ui.autocomplete.findByLabel('Dashboard').as('dashboardInput'); + cy.get('@dashboardInput').click(); + + // Verify the dropdown is visible and shows "no options" + cy.get('[data-qa-autocomplete-popper]') + .should('be.visible') + .and('contain.text', 'You have no options to choose from') + .within(() => { + // Assert "Linode" is not shown + cy.contains('Linode').should('not.exist'); + + // Assert no beta chip is visible + cy.get('[data-testid="betaChip"]').should('not.exist'); + }); + }); + + it('should not display "Linode" with a beta tag in the Service dropdown on the Metrics page when metrics.beta is false and the service is not enabled', () => { + // Mock the feature flags to disable metrics for Linode + const mockflags = flagsFactory.build({ + aclpServices: { + linode: { + metrics: { beta: false, enabled: false }, + }, + }, + }); + // Apply the mock feature flags + mockAppendFeatureFlags(mockflags); + // Visit the Metrics page after login + + cy.visitWithLogin('/metrics'); + + ui.autocomplete.findByLabel('Dashboard').click(); + + // Verify the autocomplete dropdown is visible and contains the "no options" message + + cy.get('[data-qa-autocomplete-popper]') + .should('be.visible') + .and('contain.text', 'You have no options to choose from') + .within(() => { + // Assert that "Linode" does not appear in the dropdown + + cy.contains('Linode').should('not.exist'); + // Assert that the beta chip is not rendered + + cy.get('[data-testid="betaChip"]').should('not.exist'); + }); + }); + + it('should show no service options when aclpServices flag is missing', () => { + // Mock the feature flags without linode under aclpServices + const flags = { + aclp: { beta: true, enabled: true }, + } as unknown as Partial; + mockAppendFeatureFlags(flags); + // Visit the Metrics page after login + cy.visitWithLogin('/metrics'); + // Open the dashboard autocomplete dropdown + ui.autocomplete.findByLabel('Dashboard').as('dashboardInput'); + cy.get('@dashboardInput').click(); + + // Verify the dropdown is visible and shows "no options" + cy.get('[data-qa-autocomplete-popper]') + .should('be.visible') + .and('contain.text', 'You have no options to choose from') + .within(() => { + // Assert "Linode" is not shown + cy.contains('Linode').should('not.exist'); + + // Assert no beta chip is visible + cy.get('[data-testid="betaChip"]').should('not.exist'); + }); + }); + + it('should not show Linode in Dashboard dropdown when metrics flags are missing and service is not enabled', () => { + // Mock the feature flags to disable metrics for Linode + + const mockflags = flagsFactory.build({ + aclpServices: { + linode: { + metrics: { enabled: false }, + }, + }, + }); + // Apply the mock feature flags + mockAppendFeatureFlags(mockflags); + mockGetCloudPulseDashboard(id, dashboard); + mockGetCloudPulseDashboards(serviceType, [dashboard]).as('fetchDashboard'); + + // Visit the Metrics page after login + + cy.visitWithLogin('/metrics'); + + ui.autocomplete.findByLabel('Dashboard').click(); + + // Verify the autocomplete dropdown is visible and contains the "no options" message + + cy.get('[data-qa-autocomplete-popper]') + .should('be.visible') + .and('contain.text', 'You have no options to choose from') + .within(() => { + // Assert that "Linode" does not appear in the dropdown + + cy.contains('Linode').should('not.exist'); + // Assert that the beta chip is not rendered + + cy.get('[data-testid="betaChip"]').should('not.exist'); + }); + }); +}); diff --git a/packages/manager/cypress/e2e/core/cloudpulse/nodebalancer-widget-verification.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/nodebalancer-widget-verification.spec.ts index 48db4780f98..77d1c026048 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/nodebalancer-widget-verification.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/nodebalancer-widget-verification.spec.ts @@ -30,6 +30,7 @@ import { cloudPulseMetricsResponseFactory, dashboardFactory, dashboardMetricFactory, + flagsFactory, kubeLinodeFactory, widgetFactory, } from 'src/factories'; @@ -37,7 +38,6 @@ import { generateGraphData } from 'src/features/CloudPulse/Utils/CloudPulseWidge import { formatToolTip } from 'src/features/CloudPulse/Utils/unitConversion'; import type { CloudPulseMetricsResponse } from '@linode/api-v4'; -import type { Flags } from 'src/featureFlags'; import type { Interception } from 'support/cypress-exports'; /** * This test ensures that widget titles are displayed correctly on the dashboard. @@ -51,30 +51,9 @@ import type { Interception } from 'support/cypress-exports'; */ const expectedGranularityArray = ['Auto', '1 day', '1 hr', '5 min']; const timeDurationToSelect = 'Last 24 Hours'; -const flags: Partial = { - aclp: { beta: true, enabled: true }, - aclpBetaServices: { nodebalancer: { alerts: true, metrics: true } }, - aclpResourceTypeMap: [ - { - dimensionKey: 'LINODE_ID', - maxResourceSelections: 10, - serviceType: 'linode', - }, - { - dimensionKey: 'cluster_id', - maxResourceSelections: 10, - serviceType: 'dbaas', - }, - { - dimensionKey: 'cluster_id', - maxResourceSelections: 10, - serviceType: 'nodebalancer', - }, - ], -}; -const { dashboardName, id, metrics, region, resource, serviceType } = +const { dashboardName, id, metrics, region, resource } = widgetDetails.nodebalancer; - +const serviceType = 'nodebalancer'; const dashboard = dashboardFactory.build({ label: dashboardName, service_type: serviceType, @@ -143,6 +122,7 @@ const getWidgetLegendRowValuesFromResponse = ( ], status: 'success', unit, + serviceType, }); // Destructure metrics data from the first legend row @@ -167,9 +147,8 @@ const mockNodeBalancer = nodeBalancerFactory.build({ // Tests will be modified describe('Integration Tests for Nodebalancer Dashboard ', () => { beforeEach(() => { - mockAppendFeatureFlags(flags); + mockAppendFeatureFlags(flagsFactory.build()); mockGetAccount(accountFactory.build({})); - mockGetCloudPulseMetricDefinitions(serviceType, metricDefinitions); mockGetCloudPulseDashboards(serviceType, [dashboard]).as('fetchDashboard'); mockGetCloudPulseServices([serviceType]).as('fetchServices'); diff --git a/packages/manager/cypress/e2e/core/cloudpulse/timerange-verification.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/timerange-verification.spec.ts index c2f9eb570be..0c5dc680376 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/timerange-verification.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/timerange-verification.spec.ts @@ -30,12 +30,12 @@ import { dashboardFactory, dashboardMetricFactory, databaseFactory, + flagsFactory, widgetFactory, } from 'src/factories'; import { formatDate } from 'src/utilities/formatDate'; import type { Database, DateTimeWithPreset } from '@linode/api-v4'; -import type { Flags } from 'src/featureFlags'; import type { Interception } from 'support/cypress-exports'; const formatter = "yyyy-MM-dd'T'HH:mm:ss'Z'"; @@ -59,19 +59,8 @@ const mockRegion = regionFactory.build({ }, }); -const flags: Partial = { - aclp: { beta: true, enabled: true }, - aclpResourceTypeMap: [ - { - dimensionKey: 'cluster_id', - maxResourceSelections: 10, - serviceType: 'dbaas', - }, - ], -}; - -const { dashboardName, engine, id, metrics, serviceType } = widgetDetails.dbaas; - +const { dashboardName, engine, id, metrics } = widgetDetails.dbaas; +const serviceType = 'dbaas'; const dashboard = dashboardFactory.build({ label: dashboardName, service_type: serviceType, @@ -212,8 +201,7 @@ describe('Integration tests for verifying Cloudpulse custom and preset configura */ beforeEach(() => { - cy.viewport(1280, 720); - mockAppendFeatureFlags(flags); + mockAppendFeatureFlags(flagsFactory.build()); mockGetAccount(mockAccount); mockGetCloudPulseMetricDefinitions(serviceType, metricDefinitions.data); mockGetCloudPulseDashboards(serviceType, [dashboard]).as('fetchDashboard'); diff --git a/packages/manager/cypress/e2e/core/domains/smoke-clone-domain.spec.ts b/packages/manager/cypress/e2e/core/domains/smoke-clone-domain.spec.ts index e88631fda0f..c0bb9d15138 100644 --- a/packages/manager/cypress/e2e/core/domains/smoke-clone-domain.spec.ts +++ b/packages/manager/cypress/e2e/core/domains/smoke-clone-domain.spec.ts @@ -64,8 +64,8 @@ describe('Clone a Domain', () => { .findByTitle(`Action menu for Domain ${domain.domain}`) .should('be.visible') .click(); + ui.actionMenuItem.findByTitle('Clone').should('be.visible').click(); }); - ui.actionMenuItem.findByTitle('Clone').should('be.visible').click(); // Cancel cloning when prompted to confirm. ui.drawer @@ -88,8 +88,8 @@ describe('Clone a Domain', () => { .findByTitle(`Action menu for Domain ${domain.domain}`) .should('be.visible') .click(); + ui.actionMenuItem.findByTitle('Clone').should('be.visible').click(); }); - ui.actionMenuItem.findByTitle('Clone').should('be.visible').click(); // Confirm cloning. ui.drawer diff --git a/packages/manager/cypress/e2e/core/domains/smoke-delete-domain.spec.ts b/packages/manager/cypress/e2e/core/domains/smoke-delete-domain.spec.ts index e6962632185..b3435e5e679 100644 --- a/packages/manager/cypress/e2e/core/domains/smoke-delete-domain.spec.ts +++ b/packages/manager/cypress/e2e/core/domains/smoke-delete-domain.spec.ts @@ -36,8 +36,11 @@ describe('Delete a Domain', () => { .findByTitle(`Action menu for Domain ${domain.domain}`) .should('be.visible') .click(); + ui.actionMenuItem + .findByTitle('Delete') + .should('be.visible') + .click(); }); - ui.actionMenuItem.findByTitle('Delete').should('be.visible').click(); // Cancel deletion when prompted to confirm. ui.dialog @@ -60,8 +63,11 @@ describe('Delete a Domain', () => { .findByTitle(`Action menu for Domain ${domain.domain}`) .should('be.visible') .click(); + ui.actionMenuItem + .findByTitle('Delete') + .should('be.visible') + .click(); }); - ui.actionMenuItem.findByTitle('Delete').should('be.visible').click(); // Confirm deletion. ui.dialog diff --git a/packages/manager/cypress/e2e/core/general/smoke-deep-link.spec.ts b/packages/manager/cypress/e2e/core/general/smoke-deep-link.spec.ts index 821ea4a8103..183dc7dd047 100644 --- a/packages/manager/cypress/e2e/core/general/smoke-deep-link.spec.ts +++ b/packages/manager/cypress/e2e/core/general/smoke-deep-link.spec.ts @@ -1,3 +1,4 @@ +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { pages } from 'support/ui/constants'; import type { Page } from 'support/ui/constants'; @@ -8,6 +9,10 @@ beforeEach(() => { describe('smoke - deep links', () => { beforeEach(() => { cy.visitWithLogin('/null'); + // TODO M3-10491 - Remove `iamRbacPrimaryNavChanges` feature flag mock once flag is deleted. + mockAppendFeatureFlags({ + iamRbacPrimaryNavChanges: true, + }).as('getFeatureFlags'); }); it('Go to each route and validate deep links', () => { diff --git a/packages/manager/cypress/e2e/core/images/machine-image-upload.spec.ts b/packages/manager/cypress/e2e/core/images/machine-image-upload.spec.ts index 715d7885f45..f1dac1ca995 100644 --- a/packages/manager/cypress/e2e/core/images/machine-image-upload.spec.ts +++ b/packages/manager/cypress/e2e/core/images/machine-image-upload.spec.ts @@ -193,9 +193,10 @@ describe('machine image', () => { .findByTitle(`Action menu for Image ${initialLabel}`) .should('be.visible') .click(); + + ui.actionMenuItem.findByTitle('Edit').should('be.visible').click(); }); - ui.actionMenuItem.findByTitle('Edit').should('be.visible').click(); cy.wait('@getImage'); mockUpdateImage(mockImage.id, mockImageUpdated).as('updateImage'); @@ -219,6 +220,10 @@ describe('machine image', () => { }); cy.wait(['@getImages', '@updateImage']); + + mockDeleteImage(mockImage.id).as('deleteImage'); + mockGetCustomImages([]).as('getImages'); + cy.get(`[data-qa-image-cell="${mockImage.id}"]`).within(() => { cy.findByText(updatedLabel).should('be.visible'); cy.findByText(initialLabel).should('not.exist'); @@ -226,12 +231,9 @@ describe('machine image', () => { .findByTitle(`Action menu for Image ${updatedLabel}`) .should('be.visible') .click(); + ui.actionMenuItem.findByTitle('Delete').should('be.visible').click(); }); - mockDeleteImage(mockImage.id).as('deleteImage'); - mockGetCustomImages([]).as('getImages'); - ui.actionMenuItem.findByTitle('Delete').should('be.visible').click(); - ui.dialog .findByTitle(`Delete Image ${updatedLabel}`) .should('be.visible') 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 f6717cbbf68..dca95f300a8 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts @@ -17,8 +17,15 @@ import { dcPricingPlanPlaceholder, } from 'support/constants/dc-specific-pricing'; import { + clusterPlans, + dedicatedNodeCount, + dedicatedType, latestEnterpriseTierKubernetesVersion, latestKubernetesVersion, + mockedLKEClusterTypes, + mockedLKEEnterprisePrices, + nanodeNodeCount, + nanodeType, } from 'support/constants/lke'; import { mockGetAccount } from 'support/intercepts/account'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; @@ -50,8 +57,6 @@ import { kubernetesClusterFactory, kubernetesControlPlaneACLFactory, kubernetesControlPlaneACLOptionsFactory, - lkeEnterpriseTypeFactory, - lkeHighAvailabilityTypeFactory, nodePoolFactory, } from 'src/factories'; import { @@ -62,12 +67,8 @@ import { getTotalClusterMemoryCPUAndStorage } from 'src/features/Kubernetes/kube import { getTotalClusterPrice } from 'src/utilities/pricing/kubernetes'; import type { PriceType } from '@linode/api-v4/lib/types'; -import type { ExtendedType } from 'src/utilities/extendType'; import type { LkePlanDescription } from 'support/api/lke'; -const dedicatedNodeCount = 4; -const nanodeNodeCount = 3; - const clusterRegion = chooseRegion({ capabilities: ['Kubernetes'], exclude: ['au-mel', 'eu-west'], // Unavailable regions @@ -82,46 +83,7 @@ const nanodeMemoryPool = nodePoolFactory.build({ nodes: kubeLinodeFactory.buildList(nanodeNodeCount), type: 'g6-standard-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: 51200, - id: 'g6-standard-1', - label: 'Linode 2 GB', - memory: 2048, - price: { - hourly: 0.0095, - monthly: 12.0, - }, - region_prices: dcPricingMockLinodeTypes.find( - (type) => type.id === 'g6-standard-1' - )?.region_prices, - vcpus: 1, -}) as ExtendedType; -const gpuType = linodeTypeFactory.build({ - class: 'gpu', - id: 'g2-gpu-1', -}) as ExtendedType; -const highMemType = linodeTypeFactory.build({ - class: 'highmem', - id: 'g7-highmem-1', -}) as ExtendedType; -const premiumType = linodeTypeFactory.build({ - class: 'premium', - id: 'g7-premium-1', -}) as ExtendedType; + const mockedLKEClusterPrices: PriceType[] = [ { id: 'lke-sa', @@ -146,33 +108,7 @@ const mockedLKEHAClusterPrices: PriceType[] = [ transfer: 0, }, ]; -const mockedLKEEnterprisePrices = [ - lkeHighAvailabilityTypeFactory.build(), - lkeEnterpriseTypeFactory.build(), -]; -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: 'standard', - }, -]; -const mockedLKEClusterTypes = [ - dedicatedType, - nanodeType, - gpuType, - highMemType, - premiumType, -]; + const validEnterprisePlanTabs = [ 'Dedicated CPU', 'Shared CPU', @@ -373,7 +309,7 @@ describe('LKE Cluster Creation', () => { cy.contains('Kubernetes API Endpoint').should('be.visible'); cy.contains('linodelke.net:443').should('be.visible'); - cy.findAllByText(nodePoolLabel, { selector: 'h2' }) + cy.findAllByText(nodePoolLabel, { selector: 'h3' }) .should('have.length', similarNodePoolCount) .first() .should('be.visible'); diff --git a/packages/manager/cypress/e2e/core/kubernetes/lke-enterprise-create.spec.ts b/packages/manager/cypress/e2e/core/kubernetes/lke-enterprise-create.spec.ts new file mode 100644 index 00000000000..bc84016b588 --- /dev/null +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-enterprise-create.spec.ts @@ -0,0 +1,226 @@ +/** + * Confirms create operations on LKE-Enterprise clusters. + */ + +import { regionFactory } from '@linode/utilities'; +import { + clusterPlans, + latestEnterpriseTierKubernetesVersion, + latestKubernetesVersion, + mockedLKEClusterTypes, + mockedLKEEnterprisePrices, +} from 'support/constants/lke'; +import { mockGetAccount } from 'support/intercepts/account'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { mockGetLinodeTypes } from 'support/intercepts/linodes'; +import { + mockCreateCluster, + mockGetKubernetesVersions, + mockGetLKEClusterTypes, + mockGetTieredKubernetesVersions, +} from 'support/intercepts/lke'; +import { mockGetRegions } from 'support/intercepts/regions'; +import { mockGetVPCs } from 'support/intercepts/vpc'; +import { ui } from 'support/ui'; +import { randomLabel } from 'support/util/random'; + +import { + accountFactory, + kubernetesClusterFactory, + subnetFactory, + vpcFactory, +} from 'src/factories'; + +describe('LKE Cluster Creation with LKE-E', () => { + describe('LKE-E Phase 2 Networking Configurations', () => { + const clusterLabel = randomLabel(); + const selectedVpcId = 1; + const selectedSubnetId = 1; + + const mockEnterpriseCluster = kubernetesClusterFactory.build({ + k8s_version: latestEnterpriseTierKubernetesVersion.id, + label: clusterLabel, + region: 'us-iad', + tier: 'enterprise', + }); + + const mockVpcs = [ + { + ...vpcFactory.build(), + id: selectedVpcId, + label: 'test-vpc', + region: 'us-iad', + subnets: [ + subnetFactory.build({ + id: selectedSubnetId, + label: 'subnet-a', + ipv4: '10.0.0.0/13', + }), + ], + }, + ]; + + // Accounts for the different combination of IP Networking and VPC/Subnet radio selections + const possibleNetworkingConfigurations = [ + { + description: + 'Successfully creates cluster with auto-generated dual-stack VPC and IPv4+IPv6 stack', + isUsingOwnVPC: false, + stackType: 'ipv4-ipv6', + }, + { + description: + 'Successfully creates cluster with auto-generated dual-stack VPC and IPv4 stack', + isUsingOwnVPC: false, + stackType: 'ipv4', + }, + { + description: + 'Successfully creates cluster with existing (BYO) dual-stack VPC and IPv4+IPv6 stack', + isUsingOwnVPC: true, + stackType: 'ipv4-ipv6', + }, + { + description: + 'Successfully creates cluster with existing (BYO) dual-stack VPC and IPv4 stack', + isUsingOwnVPC: true, + stackType: 'ipv4', + }, + ]; + + beforeEach(() => { + // TODO LKE-E: Remove feature flag mocks once we're in GA + mockAppendFeatureFlags({ + lkeEnterprise: { + enabled: true, + la: true, + postLa: false, + phase2Mtc: true, + }, + }).as('getFeatureFlags'); + mockGetAccount( + accountFactory.build({ + capabilities: ['Kubernetes Enterprise'], + }) + ).as('getAccount'); + + mockGetTieredKubernetesVersions('enterprise', [ + latestEnterpriseTierKubernetesVersion, + ]).as('getTieredKubernetesVersions'); + mockGetKubernetesVersions([latestKubernetesVersion]).as( + 'getKubernetesVersions' + ); + + mockGetLinodeTypes(mockedLKEClusterTypes).as('getLinodeTypes'); + mockGetLKEClusterTypes(mockedLKEEnterprisePrices).as( + 'getLKEEnterpriseClusterTypes' + ); + mockCreateCluster(mockEnterpriseCluster).as('createCluster'); + + mockGetRegions([ + regionFactory.build({ + capabilities: [ + 'Linodes', + 'Kubernetes', + 'Kubernetes Enterprise', + 'VPCs', + ], + id: 'us-iad', + label: 'Washington, DC', + }), + ]).as('getRegions'); + + mockGetVPCs(mockVpcs).as('getVPCs'); + + cy.visitWithLogin('/kubernetes/clusters'); + cy.wait(['@getAccount']); + + ui.button.findByTitle('Create Cluster').click(); + cy.url().should('endWith', '/kubernetes/create'); + cy.wait([ + '@getKubernetesVersions', + '@getTieredKubernetesVersions', + '@getLinodeTypes', + ]); + }); + + possibleNetworkingConfigurations.forEach( + ({ description, isUsingOwnVPC, stackType }) => { + it(`${description}`, () => { + // Select the enterprise tier and available region + cy.findByLabelText('Cluster Label').type(clusterLabel); + cy.findByText('LKE Enterprise').click(); + + cy.wait(['@getLKEEnterpriseClusterTypes', '@getRegions']); + + ui.regionSelect.find().clear().type('Washington, DC{enter}'); + cy.wait('@getVPCs'); + + // Select either the autogenerated or existing (BYO) VPC radio button + if (isUsingOwnVPC) { + cy.findByTestId('isUsingOwnVpc').within(() => { + cy.findByLabelText('Use an existing VPC').click(); + }); + + // Select the existing VPC and Subnet to use + ui.autocomplete.findByLabel('VPC').click(); + cy.findByText('test-vpc').click(); + ui.autocomplete.findByLabel('Subnet').click(); + cy.findByText(/subnet-a/).click(); + } + + // Select either the IPv4 or IPv4 + IPv6 (dual-stack) IP Networking radio button + cy.findByLabelText( + stackType === 'ipv4' ? 'IPv4' : 'IPv4 + IPv6' + ).click(); + + // Select a plan and add nodes + cy.findByText(clusterPlans[0].tab).should('be.visible').click(); + cy.findByText(clusterPlans[0].planName) + .should('be.visible') + .closest('tr') + .within(() => { + cy.get('[name="Quantity"]').should('be.visible').click(); + cy.focused().type(`{selectall}${clusterPlans[0].nodeCount}`); + + ui.button + .findByTitle('Add') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Bypass ACL validation error + cy.get('input[name="acl-acknowledgement"]').check(); + + // Create LKE-E cluster + cy.get('[data-testid="kube-checkout-bar"]') + .should('be.visible') + .within(() => { + ui.button + .findByTitle('Create Cluster') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Confirm request payload + cy.wait('@createCluster').then((intercept) => { + const payload = intercept.request.body; + + expect(payload.stack_type).to.eq(stackType); + // Confirm existing (BYO) VPC selection passes the vpc_id and subnet_id; + // else, confirm undefined is passed for an autogenerated VPC + if (isUsingOwnVPC) { + expect(payload.vpc_id).to.eq(selectedVpcId); + expect(payload.subnet_id).to.eq(selectedSubnetId); + } else { + expect(payload.vpc_id).to.be.undefined; + expect(payload.subnet_id).to.be.undefined; + } + }); + }); + } + ); + }); +}); diff --git a/packages/manager/cypress/e2e/core/kubernetes/lke-enterprise-read.spec.ts b/packages/manager/cypress/e2e/core/kubernetes/lke-enterprise-read.spec.ts new file mode 100644 index 00000000000..b5724cab3e7 --- /dev/null +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-enterprise-read.spec.ts @@ -0,0 +1,243 @@ +/** + * Confirms read operations on LKE-Enterprise clusters. + */ + +import { + linodeFactory, + linodeIPFactory, + profileFactory, +} from '@linode/utilities'; +import { mockGetAccount } from 'support/intercepts/account'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { + mockGetLinodeIPAddresses, + mockGetLinodes, +} from 'support/intercepts/linodes'; +import { + mockGetCluster, + mockGetClusterPools, + mockGetKubernetesVersions, +} from 'support/intercepts/lke'; +import { mockGetProfile } from 'support/intercepts/profile'; +import { mockGetVPC } from 'support/intercepts/vpc'; + +import { + accountFactory, + kubeLinodeFactory, + kubernetesClusterFactory, + nodePoolFactory, + subnetFactory, + vpcFactory, +} from 'src/factories'; + +const mockProfile = profileFactory.build(); + +const mockVPC = vpcFactory.build({ + id: 123, + label: 'lke-e-vpc', + subnets: [subnetFactory.build()], +}); + +const mockClusterWithVPC = kubernetesClusterFactory.build({ + id: 1, + vpc_id: mockVPC.id, + subnet_id: mockVPC.subnets[0].id, + tier: 'enterprise', +}); +const mockClusterWithoutVPC = kubernetesClusterFactory.build({ + id: 2, + vpc_id: null, + tier: 'enterprise', +}); +const mockNodePools = [ + nodePoolFactory.build({ + id: 1, + nodes: [kubeLinodeFactory.build()], + count: 1, + }), +]; + +const mockLinodes = mockNodePools.map((pool, i) => + linodeFactory.build({ + id: pool.nodes[i].instance_id ?? undefined, + lke_cluster_id: mockClusterWithVPC.id, + type: pool.type, + }) +); +const mockLinodeIPs = linodeIPFactory.build({ + ipv4: { + public: [ + { + address: '192.0.2.1', + linode_id: mockLinodes[0].id, + }, + ], + private: [ + { + linode_id: mockLinodes[0].id, + }, + ], + vpc: [ + { + address: '10.0.0.1', + linode_id: mockLinodes[0].id, + vpc_id: mockVPC.id, + subnet_id: mockVPC.subnets[0].id, + }, + ], + }, + ipv6: { + slaac: { + address: '2600:abcd::efgh:ijkl:mnop:qrst', + linode_id: mockLinodes[0].id, + }, + link_local: { + linode_id: mockLinodes[0].id, + }, + vpc: [ + { + linode_id: mockLinodes[0].id, + vpc_id: mockVPC.id, + subnet_id: mockVPC.subnets[0].id, + ipv6_addresses: [ + { + slaac_address: '2600:1234::abcd:5678:efgh:9012', + }, + ], + }, + ], + }, +}); + +/** + * Confirms the expected information is displayed in the cluster summary section of the cluster details page: + * - Confirms the linked VPC is shown for an LKE-E cluster when it exists. + * - Confirms a linked VPC is not shown for an LKE-E cluster when it doesn't exist. + */ +describe('LKE-E Cluster Summary - VPC Section', () => { + beforeEach(() => { + // TODO LKE-E: Remove once feature is in GA + mockAppendFeatureFlags({ + lkeEnterprise: { enabled: true, la: true, phase2Mtc: true }, + }); + mockGetAccount( + accountFactory.build({ + capabilities: ['Kubernetes Enterprise'], + }) + ).as('getAccount'); + }); + /* + * Confirms LKE-E summary page shows VPC info and links to the correct VPC page when a vpc_id is present. + */ + it('shows linked VPC in summary for cluster with a VPC', () => { + mockGetCluster(mockClusterWithVPC).as('getCluster'); + mockGetKubernetesVersions().as('getVersions'); + mockGetClusterPools(mockClusterWithVPC.id, []).as('getNodePools'); + mockGetVPC(mockVPC).as('getVPC'); + mockGetProfile(mockProfile).as('getProfile'); + + cy.visitWithLogin(`/kubernetes/clusters/${mockClusterWithVPC.id}/summary`); + cy.wait([ + '@getCluster', + '@getNodePools', + '@getVersions', + '@getVPC', + '@getProfile', + ]); + + // Verify VPC details appear in the summary + cy.get('[data-qa-kube-entity-footer]').within(() => { + cy.contains('VPC:').should('exist'); + cy.findByTestId('assigned-lke-cluster-label') + .should('contain.text', mockVPC.label) + .should('have.attr', 'href') + .and('include', `/vpcs/${mockVPC.id}`); + }); + + // Navigate to the VPC by clicking the link + cy.findByTestId('assigned-lke-cluster-label').click(); + + // Verify the VPC details page loads + cy.url().should('include', `/vpcs/${mockVPC.id}`); + cy.contains(mockVPC.label).should('exist'); + }); + + /* + * Confirms VPC info is not shown when cluster's vpc_id is null. + */ + it('does not show linked VPC in summary when cluster does not specify a VPC', () => { + mockGetCluster(mockClusterWithoutVPC).as('getCluster'); + mockGetKubernetesVersions().as('getVersions'); + mockGetClusterPools(mockClusterWithoutVPC.id, []).as('getNodePools'); + mockGetProfile(mockProfile).as('getProfile'); + + cy.visitWithLogin( + `/kubernetes/clusters/${mockClusterWithoutVPC.id}/summary` + ); + cy.wait(['@getCluster', '@getNodePools', '@getVersions', '@getProfile']); + + // Confirm that no VPC label or link is shown in the summary section + cy.get('[data-qa-kube-entity-footer]').within(() => { + cy.contains('VPC:').should('not.exist'); + cy.findByTestId('assigned-lke-cluster-label').should('not.exist'); + }); + }); +}); + +/** + * Confirms the expected information is shown for a cluster's node pools on the cluster details page. + */ +describe('LKE-E Node Pools', () => { + /** + * - Confirms the VPC IP address table headers are shown in the node table. + * - Confirms the IP address data is shown for a node in the node pool. + */ + it('shows VPC IPv4 and IPv6 columns for an LKE-E cluster', () => { + mockAppendFeatureFlags({ + // TODO LKE-E: Remove once feature is in GA + lkeEnterprise: { enabled: true, la: true, phase2Mtc: true }, + }); + mockGetAccount( + accountFactory.build({ + capabilities: ['Kubernetes Enterprise'], + }) + ).as('getAccount'); + + mockGetCluster(mockClusterWithVPC).as('getCluster'); + mockGetKubernetesVersions().as('getVersions'); + mockGetClusterPools(mockClusterWithVPC.id, mockNodePools).as( + 'getNodePools' + ); + mockGetVPC(mockVPC).as('getVPC'); + mockGetProfile(mockProfile).as('getProfile'); + mockGetLinodes(mockLinodes).as('getLinodes'); + mockGetLinodeIPAddresses(mockLinodes[0].id, mockLinodeIPs).as( + 'getLinodeIPs' + ); + + cy.visitWithLogin(`/kubernetes/clusters/${mockClusterWithVPC.id}/summary`); + cy.wait([ + '@getCluster', + '@getNodePools', + '@getVersions', + '@getProfile', + '@getVPC', + ]); + + // Confirm VPC IP columns are present in the table header + cy.get('[aria-label="List of Your Cluster Nodes"] thead').within(() => { + cy.contains('th', 'VPC IPv4').should('be.visible'); + cy.contains('th', 'VPC IPv6').should('be.visible'); + }); + + // Confirm VPC IP addresses are present in the table data + const vpcIPv6 = + mockLinodeIPs.ipv6?.vpc?.[0]?.ipv6_addresses?.[0]?.slaac_address; + const vpcIPv4 = mockLinodeIPs.ipv4?.vpc?.[0]?.address; + + cy.get('[data-qa-node-row]').within(() => { + cy.contains('td', vpcIPv6).should('be.visible'); + cy.contains('td', vpcIPv4).should('be.visible'); + }); + }); +}); diff --git a/packages/manager/cypress/e2e/core/kubernetes/lke-standard-read.spec.ts b/packages/manager/cypress/e2e/core/kubernetes/lke-standard-read.spec.ts new file mode 100644 index 00000000000..07c8162b686 --- /dev/null +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-standard-read.spec.ts @@ -0,0 +1,112 @@ +/** + * Confirms read operations on LKE standard clusters. + */ + +import { linodeFactory, profileFactory } from '@linode/utilities'; +import { mockGetAccount } from 'support/intercepts/account'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { mockGetLinodes } from 'support/intercepts/linodes'; +import { + mockGetCluster, + mockGetClusterPools, + mockGetKubernetesVersions, +} from 'support/intercepts/lke'; +import { mockGetProfile } from 'support/intercepts/profile'; + +import { + accountFactory, + kubeLinodeFactory, + kubernetesClusterFactory, + nodePoolFactory, +} from 'src/factories'; + +const mockProfile = profileFactory.build(); + +const mockCluster = kubernetesClusterFactory.build({ + id: 3, + tier: 'standard', + vpc_id: undefined, + subnet_id: undefined, +}); + +const mockNodePools = [ + nodePoolFactory.build({ + id: 2, + nodes: [kubeLinodeFactory.build()], + count: 1, + }), +]; + +const mockLinodes = mockNodePools.map((pool, i) => + linodeFactory.build({ + id: pool.nodes[i].instance_id ?? undefined, + lke_cluster_id: mockCluster.id, + type: pool.type, + }) +); + +/** + * Confirms the expected information is displayed in the cluster summary section of the cluster details page. + */ +describe('LKE Cluster Summary', () => { + it('does not show linked VPC in summary for a standard cluster', () => { + // TODO LKE-E: Remove once feature is in GA + mockAppendFeatureFlags({ + lkeEnterprise: { enabled: true, la: true, phase2Mtc: true }, + }); + mockGetAccount( + accountFactory.build({ + capabilities: ['Kubernetes Enterprise'], + }) + ).as('getAccount'); + + mockGetCluster(mockCluster).as('getCluster'); + mockGetKubernetesVersions().as('getVersions'); + mockGetClusterPools(mockCluster.id, []).as('getNodePools'); + mockGetProfile(mockProfile).as('getProfile'); + + cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}/summary`); + cy.wait(['@getCluster', '@getNodePools', '@getVersions', '@getProfile']); + + // Confirm that no VPC label or link is shown in the summary section + cy.get('[data-qa-kube-entity-footer]').within(() => { + cy.contains('VPC:').should('not.exist'); + cy.findByTestId('assigned-lke-cluster-label').should('not.exist'); + }); + }); +}); + +/** + * Confirms the expected information is shown for a cluster's node pools on the cluster details page. + */ +describe('LKE Node Pools', () => { + /** + * Confirms standard LKE clusters do not show VPC IP columns when the LKE-Enterprise Phase 2 feature flag is enabled. + */ + it('does not show VPC IP columns for standard LKE cluster', () => { + // TODO LKE-E: Remove once feature is in GA + mockAppendFeatureFlags({ + lkeEnterprise: { enabled: true, la: true, phase2Mtc: true }, + }); + mockGetAccount( + accountFactory.build({ + capabilities: ['Kubernetes Enterprise'], + }) + ).as('getAccount'); + + mockGetCluster(mockCluster).as('getCluster'); + mockGetClusterPools(mockCluster.id, mockNodePools).as('getNodePools'); + mockGetKubernetesVersions().as('getVersions'); + mockGetProfile(profileFactory.build()).as('getProfile'); + mockGetLinodes(mockLinodes).as('getLinodes'); + + cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}/summary`); + cy.wait(['@getCluster', '@getNodePools', '@getVersions', '@getProfile']); + + // Confirm VPC IP columns are not present in the node table + cy.get('[aria-label="List of Your Cluster Nodes"] thead').within(() => { + cy.contains('th', 'VPC IPv4').should('not.exist'); + cy.contains('th', 'VPC IPv6').should('not.exist'); + }); + }); +}); diff --git a/packages/manager/cypress/e2e/core/kubernetes/lke-update.spec.ts b/packages/manager/cypress/e2e/core/kubernetes/lke-update.spec.ts index dbdcb3f019c..f1f59828e54 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/lke-update.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-update.spec.ts @@ -14,6 +14,7 @@ import { mockGetLinodeTypes, } from 'support/intercepts/linodes'; import { + interceptCreateNodePool, mockAddNodePool, mockDeleteNodePool, mockGetApiEndpoints, @@ -422,7 +423,12 @@ describe('LKE cluster updates', () => { cy.wait('@recycleNode'); ui.toast.assertMessage('Node queued for recycling.'); - ui.button + ui.actionMenu + .findByTitle(`Action menu for Node Pool ${mockNodePool.id}`) + .should('be.visible') + .click(); + + ui.actionMenuItem .findByTitle('Recycle Pool Nodes') .should('be.visible') .should('be.enabled') @@ -542,7 +548,13 @@ describe('LKE cluster updates', () => { mockGetClusterPools(mockCluster.id, [mockNodePoolAutoscale]).as( 'getNodePools' ); - ui.button + + ui.actionMenu + .findByTitle(`Action menu for Node Pool ${mockNodePool.id}`) + .should('be.visible') + .click(); + + ui.actionMenuItem .findByTitle('Autoscale Pool') .should('be.visible') .should('be.enabled') @@ -586,14 +598,20 @@ describe('LKE cluster updates', () => { ui.toast.assertMessage( `Autoscaling updated for Node Pool ${mockNodePool.id}.` ); - cy.findByText(`(Min ${autoscaleMin} / Max ${autoscaleMax})`).should( - 'be.visible' - ); + cy.findByText( + `Autoscaling (Min ${autoscaleMin} / Max ${autoscaleMax})` + ).should('be.visible'); // Click "Autoscale Pool" again and disable autoscaling. mockUpdateNodePool(mockCluster.id, mockNodePool).as('toggleAutoscale'); mockGetClusterPools(mockCluster.id, [mockNodePool]).as('getNodePools'); - ui.button + + ui.actionMenu + .findByTitle(`Action menu for Node Pool ${mockNodePool.id}`) + .should('be.visible') + .click(); + + ui.actionMenuItem .findByTitle('Autoscale Pool') .should('be.visible') .should('be.enabled') @@ -617,9 +635,9 @@ describe('LKE cluster updates', () => { ui.toast.assertMessage( `Autoscaling updated for Node Pool ${mockNodePool.id}.` ); - cy.findByText(`(Min ${autoscaleMin} / Max ${autoscaleMax})`).should( - 'not.exist' - ); + cy.findByText( + `Autoscaling (Min ${autoscaleMin} / Max ${autoscaleMax})` + ).should('not.exist'); }); /* @@ -682,7 +700,13 @@ describe('LKE cluster updates', () => { mockGetClusterPools(mockCluster.id, [mockNodePoolAutoscale]).as( 'getNodePools' ); - ui.button + + ui.actionMenu + .findByTitle(`Action menu for Node Pool ${mockNodePool.id}`) + .should('be.visible') + .click(); + + ui.actionMenuItem .findByTitle('Autoscale Pool') .should('be.visible') .should('be.enabled') @@ -728,9 +752,9 @@ describe('LKE cluster updates', () => { ui.toast.assertMessage( `Autoscaling updated for Node Pool ${mockNodePool.id}.` ); - cy.findByText(`(Min ${autoscaleMin} / Max ${autoscaleMax})`).should( - 'be.visible' - ); + cy.findByText( + `Autoscaling (Min ${autoscaleMin} / Max ${autoscaleMax})` + ).should('be.visible'); }); /* @@ -803,8 +827,13 @@ describe('LKE cluster updates', () => { }); }); + ui.actionMenu + .findByTitle(`Action menu for Node Pool ${mockNodePoolInitial.id}`) + .should('be.visible') + .click(); + // Click "Resize Pool" and increase size to 3 nodes. - ui.button + ui.actionMenuItem .findByTitle('Resize Pool') .should('be.visible') .should('be.enabled') @@ -862,8 +891,13 @@ describe('LKE cluster updates', () => { }); }); + ui.actionMenu + .findByTitle(`Action menu for Node Pool ${mockNodePoolInitial.id}`) + .should('be.visible') + .click(); + // Click "Resize Pool" and decrease size back to 1 node. - ui.button + ui.actionMenuItem .findByTitle('Resize Pool') .should('be.visible') .should('be.enabled') @@ -947,6 +981,85 @@ describe('LKE cluster updates', () => { ui.toast.assertMessage('Successfully reset Kubeconfig'); }); + it('can add a node pool with an update strategy on an LKE enterprise cluster', () => { + const cluster = kubernetesClusterFactory.build({ + tier: 'enterprise', + }); + const account = accountFactory.build({ + capabilities: ['Kubernetes Enterprise'], + }); + const type = linodeTypeFactory.build({ + class: 'dedicated', + label: 'Fake Plan', + }); + + mockAppendFeatureFlags({ + lkeEnterprise: { + enabled: true, + postLa: true, + }, + }); + + mockGetAccount(account).as('getAccount'); + mockGetCluster(cluster).as('getCluster'); + mockGetClusterPools(cluster.id, []).as('getNodePools'); + mockGetLinodeTypes([type]).as('getTypes'); + + cy.visitWithLogin(`/kubernetes/clusters/${cluster.id}`); + + cy.wait(['@getCluster', '@getNodePools', '@getAccount']); + + ui.button + .findByTitle('Add a Node Pool') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.wait('@getTypes'); + + // Selet a plan + cy.get(`[data-qa-plan-row="${type.label}"]`).within(() => { + // Increment nodes 3 times + cy.findByLabelText('Add 1').should('be.enabled').click(); + cy.findByLabelText('Add 1').should('be.enabled').click(); + cy.findByLabelText('Add 1').should('be.enabled').click(); + }); + + interceptCreateNodePool(cluster.id).as('createNodePool'); + + ui.drawer.findByTitle(`Add a Node Pool: ${cluster.label}`).within(() => { + cy.findByLabelText('Update Strategy') + .should('be.visible') + .should('be.enabled') + .should('have.value', 'On Recycle Updates') // Should default to "On Recycle" + .click(); // Open the Autocomplete + + ui.autocompletePopper + .findByTitle('Rolling Updates') // Select "Rolling Updates" + .should('be.visible') + .should('be.enabled') + .click(); + + // Verify the field's value actually changed + cy.findByLabelText('Update Strategy').should( + 'have.value', + 'Rolling Updates' + ); + + ui.button + .findByTitle('Add pool') + .should('be.enabled') + .should('be.visible') + .click(); + }); + + cy.wait('@createNodePool').then((intercept) => { + const payload = intercept.request.body; + expect(payload.update_strategy).to.equal('rolling_update'); + expect(payload.type).to.equal(type.id); + }); + }); + /* * - Confirms UI flow when adding and deleting node pools. * - Confirms that user cannot delete a node pool when there is only 1 pool. @@ -987,14 +1100,22 @@ describe('LKE cluster updates', () => { cy.wait(['@getRegions', '@getCluster', '@getNodePools', '@getVersions']); // Assert that initial node pool is shown on the page. - cy.findByText('Dedicated 8 GB', { selector: 'h2' }).should('be.visible'); + cy.findByText('Dedicated 8 GB', { selector: 'h3' }).should('be.visible'); + + ui.actionMenu + .findByTitle(`Action menu for Node Pool ${mockNodePool.id}`) + .should('be.visible') + .click(); // "Delete Pool" button should be disabled when only 1 node pool exists. - ui.button + ui.actionMenuItem .findByTitle('Delete Pool') .should('be.visible') .should('be.disabled'); + // Close the action menu + cy.focused().type('{esc}'); + // Add a new node pool, select plan, submit form in drawer. ui.button .findByTitle('Add a Node Pool') @@ -1028,20 +1149,26 @@ describe('LKE cluster updates', () => { // Wait for API responses and confirm that both node pools are shown. cy.wait(['@addNodePool', '@getNodePools']); - cy.findByText('Dedicated 8 GB', { selector: 'h2' }).should('be.visible'); - cy.findByText('Dedicated 4 GB', { selector: 'h2' }).should('be.visible'); + cy.findByText('Dedicated 8 GB', { selector: 'h3' }).should('be.visible'); + cy.findByText('Dedicated 4 GB', { selector: 'h3' }).should('be.visible'); // Delete the newly added node pool. cy.get(`[data-qa-node-pool-id="${mockNewNodePool.id}"]`) .should('be.visible') .within(() => { - ui.button - .findByTitle('Delete Pool') + ui.actionMenu + .findByTitle(`Action menu for Node Pool ${mockNewNodePool.id}`) .should('be.visible') - .should('be.enabled') .click(); }); + // "Delete Pool" button should be disabled when only 1 node pool exists. + ui.actionMenuItem + .findByTitle('Delete Pool') + .should('be.visible') + .should('be.enabled') + .click(); + mockGetClusterPools(mockCluster.id, [mockNodePool]).as('getNodePools'); ui.dialog .findByTitle('Delete Node Pool?') @@ -1057,10 +1184,15 @@ describe('LKE cluster updates', () => { // Confirm node pool is deleted, original node pool still exists, and // delete pool button is once again disabled. cy.wait(['@deleteNodePool', '@getNodePools']); - cy.findByText('Dedicated 8 GB', { selector: 'h2' }).should('be.visible'); - cy.findByText('Dedicated 4 GB', { selector: 'h2' }).should('not.exist'); + cy.findByText('Dedicated 8 GB', { selector: 'h3' }).should('be.visible'); + cy.findByText('Dedicated 4 GB', { selector: 'h3' }).should('not.exist'); - ui.button + ui.actionMenu + .findByTitle(`Action menu for Node Pool ${mockNodePool.id}`) + .should('be.visible') + .click(); + + ui.actionMenuItem .findByTitle('Delete Pool') .should('be.visible') .should('be.disabled'); @@ -1326,8 +1458,13 @@ describe('LKE cluster updates', () => { 'getNodePoolsUpdated' ); + ui.actionMenu + .findByTitle(`Action menu for Node Pool ${mockNodePoolInitial.id}`) + .should('be.visible') + .click(); + // Click "Labels and Taints" button and confirm drawer contents. - ui.button + ui.actionMenuItem .findByTitle('Labels and Taints') .should('be.visible') .should('be.enabled') @@ -1464,8 +1601,13 @@ describe('LKE cluster updates', () => { 'getNodePoolsUpdated' ); + ui.actionMenu + .findByTitle(`Action menu for Node Pool ${mockNodePoolInitial.id}`) + .should('be.visible') + .click(); + // Click "Labels and Taints" button and confirm drawer contents. - ui.button + ui.actionMenuItem .findByTitle('Labels and Taints') .should('be.visible') .should('be.enabled') @@ -1641,8 +1783,13 @@ describe('LKE cluster updates', () => { mockErrorMessage ).as('updateNodePoolError'); + ui.actionMenu + .findByTitle(`Action menu for Node Pool ${mockNodePoolInitial.id}`) + .should('be.visible') + .click(); + // Click "Labels and Taints" button and confirm drawer contents. - ui.button + ui.actionMenuItem .findByTitle('Labels and Taints') .should('be.visible') .should('be.enabled') @@ -1749,7 +1896,7 @@ describe('LKE cluster updates', () => { }); }); - it('does not collapse the accordion when an action button is clicked in the accordion header', () => { + it('does not collapse the accordion when the user interacts with the node pool action menu', () => { const mockCluster = kubernetesClusterFactory.build({ k8s_version: latestKubernetesVersion, }); @@ -1770,36 +1917,33 @@ describe('LKE cluster updates', () => { 'true' ); - // Click on a disabled button - cy.get('[data-testid="node-pool-actions"]') + ui.actionMenu + .findByTitle(`Action menu for Node Pool ${mockSingleNodePool.id}`) .should('be.visible') - .within(() => { - ui.button - .findByTitle('Delete Pool') - .should('be.visible') - .should('be.disabled') - .click(); - }); + .click(); + }); + ui.actionMenuItem + .findByTitle('Delete Pool') + .should('be.visible') + .should('be.disabled') + .click({ force: true }); // Force because pointer events are disabled on the delete option + + cy.get(`[data-qa-node-pool-id="${mockSingleNodePool.id}"]`).within(() => { // Check that the accordion is still expanded cy.get(`[data-qa-panel-summary]`).should( 'have.attr', 'aria-expanded', 'true' ); - - // Click on an action button - cy.get('[data-testid="node-pool-actions"]') - .should('be.visible') - .within(() => { - ui.button - .findByTitle('Recycle Pool Nodes') - .should('be.visible') - .should('be.enabled') - .click(); - }); }); + ui.actionMenuItem + .findByTitle('Recycle Pool Nodes') + .should('be.visible') + .should('be.enabled') + .click(); + // Exit dialog ui.dialog .findByTitle('Recycle node pool?') @@ -2125,8 +2269,13 @@ describe('LKE cluster updates', () => { // Confirm total price is listed in Kube Specs. cy.findByText('$14.40/month').should('be.visible'); + ui.actionMenu + .findByTitle(`Action menu for Node Pool ${mockNodePoolInitial.id}`) + .should('be.visible') + .click(); + // Click "Resize Pool" and increase size to 3 nodes. - ui.button + ui.actionMenuItem .findByTitle('Resize Pool') .should('be.visible') .should('be.enabled') @@ -2246,7 +2395,7 @@ describe('LKE cluster updates', () => { ]); // Assert that initial node pool is shown on the page. - cy.findByText(mockPlanType.formattedLabel, { selector: 'h2' }).should( + cy.findByText(mockPlanType.formattedLabel, { selector: 'h3' }).should( 'be.visible' ); @@ -2388,8 +2537,13 @@ describe('LKE cluster updates', () => { // Confirm total price is listed in Kube Specs. cy.findByText('$0.00/month').should('be.visible'); + ui.actionMenu + .findByTitle(`Action menu for Node Pool ${mockNodePoolInitial.id}`) + .should('be.visible') + .click(); + // Click "Resize Pool" and increase size to 4 nodes. - ui.button + ui.actionMenuItem .findByTitle('Resize Pool') .should('be.visible') .should('be.enabled') @@ -2500,7 +2654,7 @@ describe('LKE cluster updates', () => { ]); // Assert that initial node pool is shown on the page. - cy.findByText(mockPlanType.formattedLabel, { selector: 'h2' }).should( + cy.findByText(mockPlanType.formattedLabel, { selector: 'h3' }).should( 'be.visible' ); diff --git a/packages/manager/cypress/e2e/core/linodes/alerts-create.spec.ts b/packages/manager/cypress/e2e/core/linodes/alerts-create.spec.ts index 1c2761ba27f..87cbfc09cf0 100644 --- a/packages/manager/cypress/e2e/core/linodes/alerts-create.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/alerts-create.spec.ts @@ -36,10 +36,16 @@ describe('Create flow when beta alerts enabled by region and feature flag', func cy.wrap(mockRegions).as('mockRegions'); mockGetRegions(mockRegions).as('getRegions'); mockAppendFeatureFlags({ - aclpBetaServices: { + aclpServices: { linode: { - alerts: true, - metrics: false, + alerts: { + beta: true, + enabled: true, + }, + metrics: { + beta: false, + enabled: false, + }, }, }, }).as('getFeatureFlags'); @@ -453,7 +459,7 @@ describe('Create flow when beta alerts enabled by region and feature flag', func }); }); -describe('aclpBetaServices feature flag disabled', function () { +describe('aclpServices feature flag disabled', function () { it('Alerts not present when feature flag disabled', function () { const mockEnabledRegion = regionFactory.build({ capabilities: ['Linodes'], @@ -465,10 +471,16 @@ describe('aclpBetaServices feature flag disabled', function () { cy.wrap(mockRegions).as('mockRegions'); mockGetRegions(mockRegions).as('getRegions'); mockAppendFeatureFlags({ - aclpBetaServices: { + aclpServices: { linode: { - alerts: false, - metrics: false, + alerts: { + beta: false, + enabled: false, + }, + metrics: { + beta: false, + enabled: false, + }, }, }, }).as('getFeatureFlags'); diff --git a/packages/manager/cypress/e2e/core/linodes/alerts-edit.spec.ts b/packages/manager/cypress/e2e/core/linodes/alerts-edit.spec.ts index 29418530955..0bbe2c41235 100644 --- a/packages/manager/cypress/e2e/core/linodes/alerts-edit.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/alerts-edit.spec.ts @@ -14,8 +14,10 @@ import { alertFactory } from 'src/factories'; import { ALERTS_BETA_MODE_BANNER_TEXT, ALERTS_BETA_MODE_BUTTON_TEXT, + ALERTS_BETA_PROMPT, ALERTS_LEGACY_MODE_BANNER_TEXT, ALERTS_LEGACY_MODE_BUTTON_TEXT, + ALERTS_LEGACY_PROMPT, } from 'src/features/Linodes/constants'; const MOCK_LINODE_ID = 2; @@ -57,10 +59,16 @@ const mockEnabledBetaAlerts = { describe('region enables alerts', function () { beforeEach(() => { mockAppendFeatureFlags({ - aclpBetaServices: { + aclpServices: { linode: { - alerts: true, - metrics: false, + alerts: { + beta: true, + enabled: true, + }, + metrics: { + beta: false, + enabled: false, + }, }, }, }).as('getFeatureFlags'); @@ -233,26 +241,6 @@ describe('region enables alerts', function () { .should('be.enabled') .click(); }); - - // TODO: this test passes but modal behavior may change when properly implemented in api (M3-10195) - // ui.dialog - // .findByTitle('Save Alerts?') - // .should('be.visible') - // .within(() => { - // ui.button.findByTitle('Save').should('be.visible') - // .click(); - // }); - // TODO: content of request.body not match prod, 'alerts' attribute missing here - // cy.wait('@updateLinode').then((xhr) => { - // // can save changes. new beta alerts added in assertLinodeAlertsEnabled tests - // const edits = xhr.request.body; - // expect(JSON.stringify(edits.system)).to.equal( - // JSON.stringify([]) - // ); - // expect(JSON.stringify(edits.user)).to.equal( - // JSON.stringify([]) - // ); - // }); }); it('Legacy alerts = 0, Beta alerts > 0, => beta enabled', function () { @@ -387,33 +375,153 @@ describe('region enables alerts', function () { .should('be.enabled') .click(); }); - // TODO: this test passes but modal behavior may change when properly implemented in api (M3-10195) - // ui.dialog - // .findByTitle('Are you sure you want to save legacy Alerts?') - // .should('be.visible') - // .within(() => { - // ui.button.findByTitle('Confirm').should('be.visible') - // .click(); - // }); - // TODO: this test passes but modal behavior will change when properly implemented in api (M3-10195) - // TODO: this test passes but modal behavior may change when properly implemented in api (M3-10195) - // ui.dialog - // .findByTitle('Save Alerts?') - // .should('be.visible') - // .within(() => { - // ui.button.findByTitle('Save').should('be.visible') - // .click(); - // }); + ui.dialog + .findByTitle(ALERTS_LEGACY_PROMPT) + .should('be.visible') + .within(() => { + ui.button.findByTitle('Confirm').should('be.visible').click(); + }); + }); + + it('in default beta mode, edits to beta alerts do not trigger confirmation modal', function () { + const mockLinode = linodeFactory.build({ + id: 2, + label: randomLabel(), + region: this.mockEnabledRegion.id, + alerts: { + ...mockEnabledBetaAlerts, + }, + }); + mockGetLinodeDetails(mockLinode.id, mockLinode).as('getLinode'); + cy.visitWithLogin(`/linodes/${mockLinode.id}/alerts`); + cy.wait(['@getFeatureFlags', '@getRegions', '@getLinode']); + ui.tabList.findTabByTitle('Alerts').within(() => { + cy.get('[data-testid="betaChip"]').should('be.visible'); + }); + cy.get('[data-reach-tab-panels]') + .should('be.visible') + .within(() => { + cy.contains('Alerts').should('be.visible'); + cy.get('[data-testid="notice-info"]') + .should('be.visible') + .within(() => { + cy.contains(ALERTS_BETA_MODE_BANNER_TEXT); + }); + cy.wait(['@getAlertDefinitions']); + // toggles in table are on but can be turned off + assertLinodeAlertsEnabled(this.alertDefinitions); + + mockUpdateLinode(mockLinode.id).as('updateLinode'); + ui.button + .findByTitle('Save') + .should('be.visible') + .should('be.enabled') + .click(); + }); + // M3-10369: "Save Alerts?" prompt does not appear bc beta is already default mode + ui.dialog.find().should('not.exist'); + }); + + it('in default legacy mode, edits to beta alerts trigger confirmation modal ', function () { + const mockLinode = linodeFactory.build({ + id: 2, + label: randomLabel(), + region: this.mockEnabledRegion.id, + alerts: { + ...mockEnabledLegacyAlerts, + }, + }); + mockGetLinodeDetails(mockLinode.id, mockLinode).as('getLinode'); + cy.visitWithLogin(`/linodes/${mockLinode.id}/alerts`); + cy.wait(['@getFeatureFlags', '@getRegions', '@getLinode']); + ui.tabList.findTabByTitle('Alerts').within(() => { + cy.get('[data-testid="betaChip"]').should('not.exist'); + }); + cy.get('[data-reach-tab-panels]') + .should('be.visible') + .within(() => { + cy.contains('Alerts').should('be.visible'); + cy.get('[data-testid="notice-info"]') + .should('be.visible') + .within(() => { + cy.contains(ALERTS_LEGACY_MODE_BANNER_TEXT); + }); + }); + + // upgrade from legacy alerts to ACLP alerts + ui.button + .findByTitle(ALERTS_LEGACY_MODE_BUTTON_TEXT) + .should('be.visible') + .should('be.enabled') + .click(); + + ui.tabList.findTabByTitle('Alerts').within(() => { + cy.get('[data-testid="betaChip"]').should('be.visible'); + }); + cy.get('[data-reach-tab-panels]') + .should('be.visible') + .within(() => { + cy.contains('Alerts').should('be.visible'); + cy.get('[data-testid="notice-info"]') + .should('be.visible') + .within(() => { + cy.contains(ALERTS_BETA_MODE_BANNER_TEXT); + }); + cy.wait(['@getAlertDefinitions']); + cy.get('table[data-testid="alert-table"]') + .should('be.visible') + .get('tbody > tr') + .should('have.length', 3) + .each((row, index) => { + // match alert definitions to table cell contents + cy.wrap(row).within(() => { + cy.get('td') + .eq(0) + .within(() => { + // each alert's toggle should be enabled/on/true and editable + ui.toggle + .find() + .should('have.attr', 'data-qa-toggle', 'true') + .should('be.visible') + .should('be.enabled') + .click(); + ui.toggle + .find() + .should('have.attr', 'data-qa-toggle', 'false'); + }); + }); + }); + + mockUpdateLinode(mockLinode.id).as('updateLinode'); + ui.button + .findByTitle('Save') + .should('be.visible') + .should('be.enabled') + .click(); + }); + // M3-10369: "Save Alerts?" prompt appears bc of edits to beta alerts but linode was previously in legacy mode + ui.dialog + .findByTitle(ALERTS_BETA_PROMPT) + .should('be.visible') + .within(() => { + ui.button.findByTitle('Confirm').should('be.visible').click(); + }); }); }); describe('region disables alerts. beta alerts not available regardless of linode settings', function () { beforeEach(() => { mockAppendFeatureFlags({ - aclpBetaServices: { + aclpServices: { linode: { - alerts: true, - metrics: false, + alerts: { + beta: true, + enabled: true, + }, + metrics: { + beta: false, + enabled: false, + }, }, }, }).as('getFeatureFlags'); diff --git a/packages/manager/cypress/e2e/core/linodes/linode-storage.spec.ts b/packages/manager/cypress/e2e/core/linodes/linode-storage.spec.ts index 7249eef4ba5..7cf8942b4af 100644 --- a/packages/manager/cypress/e2e/core/linodes/linode-storage.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/linode-storage.spec.ts @@ -57,7 +57,7 @@ const deleteInUseDisk = (diskName: string) => { }); cy.findByText( - 'Your Linode must be fully powered down in order to perform this action' + 'Your Linode must be fully powered down in order to perform this action.' ).should('be.visible'); }; @@ -150,10 +150,9 @@ describe('linode storage tab', () => { .findByTitle(`Action menu for Disk ${diskName}`) .should('be.visible') .click(); + ui.actionMenuItem.findByTitle('Resize').should('be.disabled'); }); - ui.actionMenuItem.findByTitle('Resize').should('be.disabled'); - deleteInUseDisk(diskName); ui.button.findByTitle('Add a Disk').should('be.disabled'); @@ -164,10 +163,68 @@ describe('linode storage tab', () => { * - Confirms UI flow end-to-end when a user deletes a Linode disk. * - Confirms that user can successfully delete a disk from a Linode. * - Confirms that Cloud Manager UI automatically updates to reflect deleted disk. + * TODO: Disk cannot be deleted if disk_encryption is 'enabled' + * TODO: edit result of this test if/when behavior of backend is updated. uncertain what expected behavior is for this disk config + */ + it('delete disk fails', () => { + const diskName = randomLabel(); + cy.defer(() => + createTestLinode({ + booted: false, + disk_encryption: 'enabled', + image: null, + }) + ).then((linode) => { + interceptDeleteDisks(linode.id).as('deleteDisk'); + interceptAddDisks(linode.id).as('addDisk'); + cy.visitWithLogin(`/linodes/${linode.id}/storage`); + addDisk(diskName); + + cy.wait('@addDisk').its('response.statusCode').should('eq', 200); + + cy.findByText(diskName) + .should('be.visible') + .closest('tr') + .within(() => { + // Disk should show "Creating". We must wait for it to finish "Creating" before we try to delete the disk + cy.findByText('Creating', { exact: false }).should('be.visible'); + // "Creating" should go away when the Disk is able to be deleted + cy.findByText('Creating', { exact: false }).should('not.exist'); + }); + + deleteDisk(diskName); + cy.wait('@deleteDisk').its('response.statusCode').should('eq', 200); + cy.findByText('Deleting', { exact: false }).should('be.visible'); + ui.button.findByTitle('Add a Disk').should('be.enabled'); + // ui.toast.assertMessage( + // `Disk ${diskName} on Linode ${linode.label} has been deleted.` + // ); + ui.toast + .findByMessage( + `Disk ${diskName} on Linode ${linode.label} has been deleted.` + ) + .should('not.exist'); + // cy.findByLabelText('List of Disks').within(() => { + // cy.contains(diskName).should('not.exist'); + // }); + cy.findByLabelText('List of Disks').within(() => { + cy.contains(diskName).should('be.visible'); + }); + }); + }); + + /* + * - Same test as above, but uses different linode config for disk_encryption */ - it('delete disk', () => { + it('delete disk succeeds', () => { const diskName = randomLabel(); - cy.defer(() => createTestLinode({ image: null })).then((linode) => { + cy.defer(() => + createTestLinode({ + booted: false, + disk_encryption: 'disabled', + image: null, + }) + ).then((linode) => { interceptDeleteDisks(linode.id).as('deleteDisk'); interceptAddDisks(linode.id).as('addDisk'); cy.visitWithLogin(`/linodes/${linode.id}/storage`); @@ -189,6 +246,7 @@ describe('linode storage tab', () => { cy.wait('@deleteDisk').its('response.statusCode').should('eq', 200); cy.findByText('Deleting', { exact: false }).should('be.visible'); ui.button.findByTitle('Add a Disk').should('be.enabled'); + ui.toast.assertMessage( `Disk ${diskName} on Linode ${linode.label} has been deleted.` ); @@ -242,10 +300,9 @@ describe('linode storage tab', () => { .findByTitle(`Action menu for Disk ${diskName}`) .should('be.visible') .click(); + ui.actionMenuItem.findByTitle('Resize').should('be.visible').click(); }); - ui.actionMenuItem.findByTitle('Resize').should('be.visible').click(); - ui.drawer .findByTitle(`Resize ${diskName}`) .should('be.visible') diff --git a/packages/manager/cypress/e2e/core/linodes/maintenance-policy-region-support.spec.ts b/packages/manager/cypress/e2e/core/linodes/maintenance-policy-region-support.spec.ts new file mode 100644 index 00000000000..b3402e4b647 --- /dev/null +++ b/packages/manager/cypress/e2e/core/linodes/maintenance-policy-region-support.spec.ts @@ -0,0 +1,212 @@ +import { createLinodeRequestFactory, linodeFactory } from '@linode/utilities'; +import { linodeTypeFactory, regionFactory } from '@linode/utilities'; +import { authenticate } from 'support/api/authentication'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { + mockGetLinodeDetails, + mockGetLinodeStatsError, + mockGetLinodeTypes, +} from 'support/intercepts/linodes'; +import { + mockGetRegionAvailability, + mockGetRegions, +} from 'support/intercepts/regions'; +import { ui } from 'support/ui'; +import { linodeCreatePage } from 'support/ui/pages'; +import { cleanUp } from 'support/util/cleanup'; +import { createTestLinode } from 'support/util/linodes'; +import { randomLabel } from 'support/util/random'; +import { extendRegion } from 'support/util/regions'; + +import { + MAINTENANCE_POLICY_NOT_AVAILABLE_IN_REGION_TEXT, + MAINTENANCE_POLICY_NOT_AVAILABLE_IN_REGION_TEXT_DETAILS, + MAINTENANCE_POLICY_SELECT_REGION_TEXT, +} from 'src/components/MaintenancePolicySelect/constants'; + +import type { Region } from '@linode/api-v4'; + +authenticate(); + +describe('maintenance policy region support - Create Linode', () => { + it('should disable maintenance policy for core regions if no region is selected', () => { + // Create mocks for a distributed region that doesn't support maintenance policies + const mockRegionOptions: Partial = { + capabilities: ['Linodes'], // Note: no 'Maintenance Policy' capability + site_type: 'core', + }; + const mockRegion = extendRegion(regionFactory.build(mockRegionOptions)); + const mockLinodeTypes = [ + linodeTypeFactory.build({ + class: 'nanode', + label: 'Nanode 1GB', + }), + ]; + + mockAppendFeatureFlags({ + gecko2: { + enabled: true, + la: true, + }, + }).as('getFeatureFlags'); + mockGetRegions([mockRegion]).as('getRegions'); + mockGetLinodeTypes(mockLinodeTypes).as('getLinodeTypes'); + mockGetRegionAvailability(mockRegion.id, []).as('getRegionAvailability'); + + cy.visitWithLogin('/linodes/create'); + cy.wait(['@getFeatureFlags', '@getRegions', '@getLinodeTypes']); + + // Look for the Host Maintenance Policy section and open accordion + cy.findByText('Host Maintenance Policy').should('be.visible').click(); + + // Find the maintenance policy selector and verify it's disabled + cy.findByLabelText('Maintenance Policy') + .should('be.visible') + .should('be.disabled'); + + // Verify the helper text appears explaining why it's disabled + cy.findByText(MAINTENANCE_POLICY_SELECT_REGION_TEXT).should('be.visible'); + }); + it('should disable maintenance policy selector in distributed regions', () => { + // Create mocks for a distributed region that doesn't support maintenance policies + const mockRegionOptions: Partial = { + capabilities: ['Linodes', 'Distributed Plans'], // Note: no 'Maintenance Policy' capability + site_type: 'distributed', + }; + const mockRegion = extendRegion(regionFactory.build(mockRegionOptions)); + const mockLinodeTypes = [ + linodeTypeFactory.build({ + class: 'nanode', + id: 'nanode-edge-1', + label: 'Nanode 1GB', + }), + ]; + + mockAppendFeatureFlags({ + gecko2: { + enabled: true, + la: true, + }, + }).as('getFeatureFlags'); + mockGetRegions([mockRegion]).as('getRegions'); + mockGetLinodeTypes(mockLinodeTypes).as('getLinodeTypes'); + mockGetRegionAvailability(mockRegion.id, []).as('getRegionAvailability'); + + cy.visitWithLogin('/linodes/create'); + cy.wait(['@getFeatureFlags', '@getRegions', '@getLinodeTypes']); + + // Pick a region from the distributed region list + cy.findByTestId('region').within(() => { + ui.tabList.findTabByTitle('Distributed').should('be.visible').click(); + linodeCreatePage.selectRegionById(mockRegion.id); + }); + + cy.wait(['@getRegionAvailability']); + + // Look for the Host Maintenance Policy section and open accordion + cy.findByText('Host Maintenance Policy').should('be.visible').click(); + + // Find the maintenance policy selector and verify it's disabled + cy.findByLabelText('Maintenance Policy') + .should('be.visible') + .should('be.disabled'); + + // Verify the helper text appears explaining why it's disabled + cy.findByText(MAINTENANCE_POLICY_NOT_AVAILABLE_IN_REGION_TEXT).should( + 'be.visible' + ); + }); +}); + +describe('maintenance policy region support - Linode Details > Settings', () => { + before(() => { + cleanUp(['linodes', 'lke-clusters']); + }); + + beforeEach(() => { + mockAppendFeatureFlags({ + iamRbacPrimaryNavChanges: false, + }).as('getFeatureFlags'); + }); + + it('disables maintenance policy selector when region does not support it', () => { + // Mock a linode in a region that doesn't support maintenance policies + const mockRegion = regionFactory.build({ + capabilities: [ + // The 'Maintenance Policy' capability should be absent. + 'Linodes', + ], + }); + + const mockLinode = linodeFactory.build({ + label: randomLabel(), + region: mockRegion.id, + }); + + mockGetLinodeDetails(mockLinode.id, mockLinode); + mockGetLinodeStatsError( + mockLinode.id, + 'Stats for this Linode are not available yet', + 400 + ); + mockGetRegions([mockRegion]); + + // cy.defer(() => createTestLinode(linodeCreatePayload)).then((linode) => { + // Visit the linode details page + cy.visitWithLogin(`/linodes/${mockLinode.id}`); + + // Wait for content to load + cy.findByText('Stats for this Linode are not available yet'); + + // Navigate to the Settings tab + cy.findByText('Settings').should('be.visible').click(); + + // Look for the Host Maintenance Policy section + cy.findByText('Host Maintenance Policy').should('be.visible'); + + // Find the maintenance policy selector and verify it's disabled + cy.findByLabelText('Maintenance Policy') + .should('be.visible') + .should('be.disabled'); + + // Verify the helper text appears explaining why it's disabled + cy.findByText( + MAINTENANCE_POLICY_NOT_AVAILABLE_IN_REGION_TEXT_DETAILS + ).should('be.visible'); + // }); + }); + + it('enables maintenance policy selector when region supports it', () => { + cy.tag('method:e2e'); + // Create a linode in a region that supports maintenance policies + // eu-central is known to support maintenance policies + const linodeCreatePayload = createLinodeRequestFactory.build({ + label: randomLabel(), + region: 'eu-central', // Frankfurt, DE - known to support maintenance policies + }); + + cy.defer(() => createTestLinode(linodeCreatePayload)).then((linode) => { + // Visit the linode details page + cy.visitWithLogin(`/linodes/${linode.id}`); + + // Wait for content to load + cy.findByText('Stats for this Linode are not available yet'); + + // Navigate to the Settings tab + cy.findByText('Settings').should('be.visible').click(); + + // Look for the Host Maintenance Policy section + cy.findByText('Host Maintenance Policy').should('be.visible'); + + // Find the maintenance policy selector and verify it's enabled + cy.findByLabelText('Maintenance Policy') + .should('be.visible') + .should('not.be.disabled'); + + // Verify the helper text about region limitation does NOT appear + cy.findByText( + MAINTENANCE_POLICY_NOT_AVAILABLE_IN_REGION_TEXT_DETAILS + ).should('not.exist'); + }); + }); +}); diff --git a/packages/manager/cypress/e2e/core/linodes/resize-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/resize-linode.spec.ts index cf426d672d3..1f6c3bdfa67 100644 --- a/packages/manager/cypress/e2e/core/linodes/resize-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/resize-linode.spec.ts @@ -21,7 +21,7 @@ describe('resize linode', () => { createTestLinode({ booted: true }, { securityMethod: 'vlan_no_internet' }) ).then((linode) => { interceptLinodeResize(linode.id).as('linodeResize'); - cy.visitWithLogin(`/linodes/${linode.id}?resize=true`); + cy.visitWithLogin(`/linodes/${linode.id}/metrics?resize=true`); ui.dialog .findByTitle(`Resize Linode ${linode.label}`) @@ -63,7 +63,7 @@ describe('resize linode', () => { createTestLinode({ booted: true }, { securityMethod: 'vlan_no_internet' }) ).then((linode) => { interceptLinodeResize(linode.id).as('linodeResize'); - cy.visitWithLogin(`/linodes/${linode.id}?resize=true`); + cy.visitWithLogin(`/linodes/${linode.id}/metrics?resize=true`); ui.dialog .findByTitle(`Resize Linode ${linode.label}`) @@ -168,7 +168,7 @@ describe('resize linode', () => { // Error flow when attempting to resize a linode to a smaller size without // resizing the disk to the requested size first. interceptLinodeResize(linode.id).as('linodeResize'); - cy.visitWithLogin(`/linodes/${linode.id}?resize=true`); + cy.visitWithLogin(`/linodes/${linode.id}/metrics?resize=true`); ui.dialog .findByTitle(`Resize Linode ${linode.label}`) diff --git a/packages/manager/cypress/e2e/core/linodes/smoke-delete-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/smoke-delete-linode.spec.ts index 230751729f0..8c5f8f3ad9f 100644 --- a/packages/manager/cypress/e2e/core/linodes/smoke-delete-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/smoke-delete-linode.spec.ts @@ -127,7 +127,7 @@ describe('delete linode', () => { cy.findByText('Stats for this Linode are not available yet'); // Go to setting tab - cy.findByText('Settings').should('be.visible').click(); + ui.tabList.findTabByTitle('Settings').should('be.visible').click(); // Check elements in setting tab cy.findByText('Linode Label').should('be.visible'); diff --git a/packages/manager/cypress/e2e/core/linodes/smoke-linode-landing-table.spec.ts b/packages/manager/cypress/e2e/core/linodes/smoke-linode-landing-table.spec.ts index aec60223ec5..6be3c0eb1b8 100644 --- a/packages/manager/cypress/e2e/core/linodes/smoke-linode-landing-table.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/smoke-linode-landing-table.spec.ts @@ -69,6 +69,10 @@ const preferenceOverrides = { authenticate(); describe('linode landing checks', () => { beforeEach(() => { + // TODO M3-10491 - Remove `iamRbacPrimaryNavChanges` feature flag mock once flag is deleted. + mockAppendFeatureFlags({ + iamRbacPrimaryNavChanges: true, + }); const mockAccountSettings = accountSettingsFactory.build({ managed: false, }); @@ -87,7 +91,7 @@ describe('linode landing checks', () => { }); it('checks the landing page side menu items', () => { - cy.findByTitle('Akamai - Dashboard').should('be.visible'); + cy.findByTitle('Akamai - Cloud Manager').should('be.visible'); cy.findByTestId('menu-item-Linodes').should('be.visible'); cy.findByTestId('menu-item-Volumes').should('be.visible'); cy.findByTestId('menu-item-NodeBalancers').should('be.visible'); @@ -99,8 +103,9 @@ describe('linode landing checks', () => { cy.findByTestId('menu-item-Object Storage').should('be.visible'); cy.findByTestId('menu-item-Longview').should('be.visible'); cy.findByTestId('menu-item-Marketplace').should('be.visible'); - cy.findByTestId('menu-item-Account').scrollIntoView(); - cy.findByTestId('menu-item-Account').should('be.visible'); + cy.findByTestId('menu-item-Billing').scrollIntoView(); + cy.findByTestId('menu-item-Billing').should('be.visible'); + cy.findByTestId('menu-item-Settings').should('be.visible'); cy.findByTestId('menu-item-Help & Support').should('be.visible'); }); @@ -470,6 +475,10 @@ describe('linode landing checks', () => { describe('linode landing checks for empty state', () => { beforeEach(() => { + // TODO M3-10491 - Remove `iamRbacPrimaryNavChanges` feature flag mock once flag is deleted. + mockAppendFeatureFlags({ + iamRbacPrimaryNavChanges: true, + }); // Mock setup to display the Linode landing page in an empty state mockGetLinodes([]).as('getLinodes'); }); @@ -572,6 +581,10 @@ describe('linode landing checks for empty state', () => { describe('linode landing checks for non-empty state with restricted user', () => { beforeEach(() => { + // TODO M3-10491 - Remove `iamRbacPrimaryNavChanges` feature flag mock once flag is deleted. + mockAppendFeatureFlags({ + iamRbacPrimaryNavChanges: true, + }); // Mock setup to display the Linode landing page in an non-empty state const mockLinodes: Linode[] = new Array(1) .fill(null) diff --git a/packages/manager/cypress/e2e/core/longview/longview.spec.ts b/packages/manager/cypress/e2e/core/longview/longview.spec.ts index e14eac42563..7e80ccd008a 100644 --- a/packages/manager/cypress/e2e/core/longview/longview.spec.ts +++ b/packages/manager/cypress/e2e/core/longview/longview.spec.ts @@ -14,7 +14,6 @@ import { mockUpdateLongviewClient, } from 'support/intercepts/longview'; import { ui } from 'support/ui'; -import { cleanUp } from 'support/util/cleanup'; import { randomLabel } from 'support/util/random'; import { @@ -132,10 +131,6 @@ const longviewGetLatestValueInstalled = longviewResponseFactory.build({ authenticate(); describe('longview', () => { - before(() => { - cleanUp(['linodes', 'longview-clients']); - }); - /* * - Tests Longview installation end-to-end using mock API data. * - Confirms that Cloud Manager UI updates to reflect Longview installation and data. @@ -380,8 +375,8 @@ describe('longview', () => { .findByTitle(`Action menu for Longview Client ${client.label}`) .should('be.visible') .click(); + ui.actionMenuItem.findByTitle('Delete').should('be.visible').click(); }); - ui.actionMenuItem.findByTitle('Delete').should('be.visible').click(); // Confirms that Cloud Manager UI has delete warning message and can cancel deletion. ui.dialog @@ -397,8 +392,8 @@ describe('longview', () => { ui.actionMenu .findByTitle(`Action menu for Longview Client ${client.label}`) .click(); + ui.actionMenuItem.findByTitle('Delete').should('be.visible').click(); }); - ui.actionMenuItem.findByTitle('Delete').should('be.visible').click(); ui.dialog.findByTitle(`Delete ${client.label}?`).within(() => { ui.buttonGroup diff --git a/packages/manager/cypress/e2e/core/managed/managed-monitors.spec.ts b/packages/manager/cypress/e2e/core/managed/managed-monitors.spec.ts index 1c6ce11d81b..975e2970d6e 100644 --- a/packages/manager/cypress/e2e/core/managed/managed-monitors.spec.ts +++ b/packages/manager/cypress/e2e/core/managed/managed-monitors.spec.ts @@ -111,6 +111,7 @@ describe('Managed Monitors tab', () => { }); // Confirm that monitor label has been updated, then disable the monitor. + mockDisableServiceMonitor(monitorId, newMonitor).as('disableMonitor'); cy.findByText(newLabel) .should('be.visible') .closest('tr') @@ -119,14 +120,13 @@ describe('Managed Monitors tab', () => { .findByTitle(monitorMenuLabel) .should('be.visible') .click(); + ui.actionMenuItem.findByTitle('Disable').click(); }); - mockDisableServiceMonitor(monitorId, newMonitor).as('disableMonitor'); - ui.actionMenuItem.findByTitle('Disable').click(); - cy.wait('@disableMonitor'); // Confirm that monitor has been disabled, then re-enable the monitor. + mockEnableServiceMonitor(monitorId, newMonitor).as('enableMonitor'); ui.toast.assertMessage('Monitor disabled successfully.'); cy.findByText(newLabel) .should('be.visible') @@ -137,11 +137,9 @@ describe('Managed Monitors tab', () => { .findByTitle(monitorMenuLabel) .should('be.visible') .click(); + ui.actionMenuItem.findByTitle('Enable').click(); }); - mockEnableServiceMonitor(monitorId, newMonitor).as('enableMonitor'); - ui.actionMenuItem.findByTitle('Enable').click(); - cy.wait('@enableMonitor'); // Confirm that monitor has been re-enabled. @@ -242,10 +240,9 @@ describe('Managed Monitors tab', () => { .findByTitle(monitorMenuLabel) .should('be.visible') .click(); + ui.actionMenuItem.findByTitle('Delete').click(); }); - ui.actionMenuItem.findByTitle('Delete').click(); - cy.wait('@getMonitor'); // Fill out and submit type-to-confirm. diff --git a/packages/manager/cypress/e2e/core/notificationsAndEvents/qemu-reboot-upgrade-notice.spec.ts b/packages/manager/cypress/e2e/core/notificationsAndEvents/qemu-reboot-upgrade-notice.spec.ts index 03b2c3d802f..d1bbfc963b8 100644 --- a/packages/manager/cypress/e2e/core/notificationsAndEvents/qemu-reboot-upgrade-notice.spec.ts +++ b/packages/manager/cypress/e2e/core/notificationsAndEvents/qemu-reboot-upgrade-notice.spec.ts @@ -78,9 +78,12 @@ describe('QEMU reboot upgrade notification', () => { start_time: new Date().toISOString(), }), ]; - const formattedTime = formatDate(upcomingMaintenance[0].start_time, { + + // We use ! since in `LinodeMaintenanceText` the `start_time` is never null. + const formattedTime = formatDate(upcomingMaintenance[0].start_time!, { timezone: mockProfile.timezone, }); + const maintenanceTooltipText = `This Linode’s maintenance window opens at ${formattedTime}. For more information, see your open support tickets.`; mockGetAccount(mockAccount).as('getAccount'); diff --git a/packages/manager/cypress/e2e/core/objectStorageMulticluster/access-keys-multicluster.spec.ts b/packages/manager/cypress/e2e/core/objectStorageMulticluster/access-keys-multicluster.spec.ts index 6aae8857c5d..8c4a63110e8 100644 --- a/packages/manager/cypress/e2e/core/objectStorageMulticluster/access-keys-multicluster.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorageMulticluster/access-keys-multicluster.spec.ts @@ -259,9 +259,9 @@ describe('Object Storage Multicluster access keys', () => { ) .should('be.visible') .click(); + ui.actionMenuItem.findByTitle('Permissions').click(); }); - ui.actionMenuItem.findByTitle('Permissions').click(); ui.drawer .findByTitle(`Permissions for ${mockAccessKey.label}`) .should('be.visible') diff --git a/packages/manager/cypress/e2e/core/placementGroups/delete-placement-groups.spec.ts b/packages/manager/cypress/e2e/core/placementGroups/delete-placement-groups.spec.ts index 1d7911d1280..ad69ded675f 100644 --- a/packages/manager/cypress/e2e/core/placementGroups/delete-placement-groups.spec.ts +++ b/packages/manager/cypress/e2e/core/placementGroups/delete-placement-groups.spec.ts @@ -84,7 +84,7 @@ describe('Placement Group deletion', () => { ).as('deletePlacementGroupError'); ui.dialog - .findByTitle(`Delete Placement Group ${mockPlacementGroup.label}`) + .findByTitle(`Delete Placement Group ${mockPlacementGroup.label}?`) .should('be.visible') .within(() => { cy.findByLabelText('Placement Group').type(mockPlacementGroup.label); @@ -106,7 +106,7 @@ describe('Placement Group deletion', () => { // Confirm deletion warning appears, complete Type-to-Confirm, and submit confirmation. ui.dialog - .findByTitle(`Delete Placement Group ${mockPlacementGroup.label}`) + .findByTitle(`Delete Placement Group ${mockPlacementGroup.label}?`) .should('be.visible') .within(() => { cy.findByText(deletionWarning).should('be.visible'); @@ -197,7 +197,7 @@ describe('Placement Group deletion', () => { ).as('UnassignPlacementGroupError'); ui.dialog - .findByTitle(`Delete Placement Group ${mockPlacementGroup.label}`) + .findByTitle(`Delete Placement Group ${mockPlacementGroup.label}?`) .should('be.visible') .within(() => { cy.get('[data-qa-selection-list]').within(() => { @@ -223,7 +223,7 @@ describe('Placement Group deletion', () => { // Confirm deletion warning appears and that form cannot be submitted // while Linodes are assigned. ui.dialog - .findByTitle(`Delete Placement Group ${mockPlacementGroup.label}`) + .findByTitle(`Delete Placement Group ${mockPlacementGroup.label}?`) .should('be.visible') .within(() => { cy.findByText(deletionWarning).should('be.visible'); @@ -346,7 +346,7 @@ describe('Placement Group deletion', () => { // The dialog can be closed after an unexpect error show up ui.dialog - .findByTitle(`Delete Placement Group ${mockPlacementGroup.label}`) + .findByTitle(`Delete Placement Group ${mockPlacementGroup.label}?`) .should('be.visible') .within(() => { cy.findByLabelText('Placement Group').type(mockPlacementGroup.label); @@ -366,9 +366,9 @@ describe('Placement Group deletion', () => { .should('be.enabled') .click(); }); - cy.findByTitle(`Delete Placement Group ${mockPlacementGroup.label}`).should( - 'not.exist' - ); + cy.findByTitle( + `Delete Placement Group ${mockPlacementGroup.label}?` + ).should('not.exist'); // Click "Delete" button next to the mock Placement Group, // mock a successful response and confirm that Cloud @@ -389,7 +389,7 @@ describe('Placement Group deletion', () => { // Confirm deletion warning appears, complete Type-to-Confirm, and submit confirmation. ui.dialog - .findByTitle(`Delete Placement Group ${mockPlacementGroup.label}`) + .findByTitle(`Delete Placement Group ${mockPlacementGroup.label}?`) .should('be.visible') .within(() => { // ensure error message not exist when reopening the dialog @@ -472,7 +472,7 @@ describe('Placement Group deletion', () => { ).as('UnassignPlacementGroupError'); ui.dialog - .findByTitle(`Delete Placement Group ${mockPlacementGroup.label}`) + .findByTitle(`Delete Placement Group ${mockPlacementGroup.label}?`) .should('be.visible') .within(() => { cy.get('[data-qa-selection-list]').within(() => { @@ -501,9 +501,9 @@ describe('Placement Group deletion', () => { .click(); }); - cy.findByTitle(`Delete Placement Group ${mockPlacementGroup.label}`).should( - 'not.exist' - ); + cy.findByTitle( + `Delete Placement Group ${mockPlacementGroup.label}?` + ).should('not.exist'); // Click "Delete" button next to the mock Placement Group to reopen the dialog. cy.findByText(mockPlacementGroup.label) @@ -519,7 +519,7 @@ describe('Placement Group deletion', () => { // Confirm that the error message from the previous attempt is no longer present. ui.dialog - .findByTitle(`Delete Placement Group ${mockPlacementGroup.label}`) + .findByTitle(`Delete Placement Group ${mockPlacementGroup.label}?`) .should('be.visible') .within(() => { cy.findByText(PlacementGroupErrorMessage).should('not.exist'); diff --git a/packages/manager/cypress/e2e/core/stackscripts/delete-stackscripts.spec.ts b/packages/manager/cypress/e2e/core/stackscripts/delete-stackscripts.spec.ts index cd0aef158e3..ad28bddbf2b 100644 --- a/packages/manager/cypress/e2e/core/stackscripts/delete-stackscripts.spec.ts +++ b/packages/manager/cypress/e2e/core/stackscripts/delete-stackscripts.spec.ts @@ -35,8 +35,9 @@ describe('Delete stackscripts', () => { .findByTitle(`Action menu for StackScript ${stackScripts[0].label}`) .should('be.visible') .click(); + ui.actionMenuItem.findByTitle('Delete').should('be.visible').click(); }); - ui.actionMenuItem.findByTitle('Delete').should('be.visible').click(); + cy.wait('@getStackScript'); ui.dialog .findByTitle(`Delete StackScript ${stackScripts[0].label}?`) @@ -53,6 +54,11 @@ describe('Delete stackscripts', () => { }); // The StackScript is deleted successfully. + mockDeleteStackScript(stackScripts[0].id).as('deleteStackScript'); + mockGetStackScripts([stackScripts[1]]).as('getUpdatedStackScripts'); + mockGetStackScript(stackScripts[1].id, stackScripts[1]).as( + 'getUpdatedStackScript' + ); cy.get(`[data-qa-table-row="${stackScripts[0].label}"]`) .closest('tr') .within(() => { @@ -60,13 +66,9 @@ describe('Delete stackscripts', () => { .findByTitle(`Action menu for StackScript ${stackScripts[0].label}`) .should('be.visible') .click(); + ui.actionMenuItem.findByTitle('Delete').should('be.visible').click(); }); - mockDeleteStackScript(stackScripts[0].id).as('deleteStackScript'); - mockGetStackScripts([stackScripts[1]]).as('getUpdatedStackScripts'); - mockGetStackScript(stackScripts[1].id, stackScripts[1]).as( - 'getUpdatedStackScript' - ); - ui.actionMenuItem.findByTitle('Delete').should('be.visible').click(); + ui.dialog .findByTitle(`Delete StackScript ${stackScripts[0].label}?`) .should('be.visible') @@ -76,11 +78,12 @@ describe('Delete stackscripts', () => { .should('be.visible') .click(); }); - cy.wait('@deleteStackScript'); - cy.wait('@getUpdatedStackScripts'); + cy.wait(['@deleteStackScript', '@getUpdatedStackScripts']); cy.findByText(stackScripts[0].label).should('not.exist'); // The "Automate Deployment with StackScripts!" welcome page appears when no StackScript exists. + mockDeleteStackScript(stackScripts[1].id).as('deleteStackScript'); + mockGetStackScripts([]).as('getUpdatedStackScripts'); cy.get(`[data-qa-table-row="${stackScripts[1].label}"]`) .closest('tr') .within(() => { @@ -88,10 +91,9 @@ describe('Delete stackscripts', () => { .findByTitle(`Action menu for StackScript ${stackScripts[1].label}`) .should('be.visible') .click(); + ui.actionMenuItem.findByTitle('Delete').should('be.visible').click(); }); - mockDeleteStackScript(stackScripts[1].id).as('deleteStackScript'); - mockGetStackScripts([]).as('getUpdatedStackScripts'); - ui.actionMenuItem.findByTitle('Delete').should('be.visible').click(); + cy.wait('@getUpdatedStackScript'); ui.dialog .findByTitle(`Delete StackScript ${stackScripts[1].label}?`) diff --git a/packages/manager/cypress/e2e/core/stackscripts/update-stackscripts.spec.ts b/packages/manager/cypress/e2e/core/stackscripts/update-stackscripts.spec.ts index f3bee63a668..e44b741e5ce 100644 --- a/packages/manager/cypress/e2e/core/stackscripts/update-stackscripts.spec.ts +++ b/packages/manager/cypress/e2e/core/stackscripts/update-stackscripts.spec.ts @@ -117,16 +117,17 @@ describe('Update stackscripts', () => { cy.visitWithLogin('/stackscripts/account'); cy.wait('@getStackScripts'); + mockGetStackScript(stackScripts[0].id, stackScripts[0]).as( + 'getStackScript' + ); cy.get(`[data-qa-table-row="${stackScripts[0].label}"]`).within(() => { ui.actionMenu .findByTitle(`Action menu for StackScript ${stackScripts[0].label}`) .should('be.visible') .click(); + ui.actionMenuItem.findByTitle('Edit').should('be.visible').click(); }); - mockGetStackScript(stackScripts[0].id, stackScripts[0]).as( - 'getStackScript' - ); - ui.actionMenuItem.findByTitle('Edit').should('be.visible').click(); + cy.wait('@getStackScript'); cy.url().should('endWith', `/stackscripts/${stackScripts[0].id}/edit`); diff --git a/packages/manager/cypress/support/api/entityTransfer.ts b/packages/manager/cypress/support/api/entityTransfer.ts index cc0085ca7b4..379d22e1fad 100644 --- a/packages/manager/cypress/support/api/entityTransfer.ts +++ b/packages/manager/cypress/support/api/entityTransfer.ts @@ -1,4 +1,8 @@ -import { cancelTransfer, getEntityTransfers, getLinodes } from '@linode/api-v4'; +import { + cancelServiceTransfer, + getLinodes, + getServiceTransfers, +} from '@linode/api-v4'; import { pageSize } from 'support/constants/api'; import { depaginate } from 'support/util/paginate'; @@ -18,7 +22,7 @@ export const cancelAllTestEntityTransfers = async (): Promise => { const entityTransfers = ( await depaginate((page: number) => - getEntityTransfers({ page, page_size: pageSize }) + getServiceTransfers({ page, page_size: pageSize }) ) ).filter( (entityTransfer: EntityTransfer) => entityTransfer.status === 'pending' @@ -37,7 +41,7 @@ export const cancelAllTestEntityTransfers = async (): Promise => { } ); if (allLinodesAreTestLinodes) { - await cancelTransfer(entityTransfer.token); + await cancelServiceTransfer(entityTransfer.token); } } ); diff --git a/packages/manager/cypress/support/component/setup.tsx b/packages/manager/cypress/support/component/setup.tsx index 5b3fafa9d84..54a8c743334 100644 --- a/packages/manager/cypress/support/component/setup.tsx +++ b/packages/manager/cypress/support/component/setup.tsx @@ -29,7 +29,6 @@ import { LDProvider } from 'launchdarkly-react-client-sdk'; import { SnackbarProvider } from 'notistack'; import * as React from 'react'; import { Provider } from 'react-redux'; -import { MemoryRouter } from 'react-router-dom'; import { LinodeThemeWrapper } from 'src/LinodeThemeWrapper'; import { storeFactory } from 'src/store'; @@ -79,9 +78,7 @@ export const mountWithTheme = ( options={{ bootstrap: flags }} > - - - + diff --git a/packages/manager/cypress/support/constants/lke.ts b/packages/manager/cypress/support/constants/lke.ts index 9be3b2780e9..e8ad5e735f6 100644 --- a/packages/manager/cypress/support/constants/lke.ts +++ b/packages/manager/cypress/support/constants/lke.ts @@ -1,6 +1,16 @@ +import { dedicatedTypeFactory, linodeTypeFactory } from '@linode/utilities'; import { getLatestKubernetesVersion } from 'support/util/lke'; +import { + lkeEnterpriseTypeFactory, + lkeHighAvailabilityTypeFactory, +} from 'src/factories'; + +import { dcPricingMockLinodeTypes } from './dc-specific-pricing'; + import type { KubernetesTieredVersion } from '@linode/api-v4'; +import type { ExtendedType } from 'src/utilities/extendType'; +import type { LkePlanDescription } from 'support/api/lke'; /** * Kubernetes versions available for cluster creation via Cloud Manager. @@ -33,3 +43,81 @@ export const latestEnterpriseTierKubernetesVersion: KubernetesTieredVersion = { id: getLatestKubernetesVersion(enterpriseKubernetesVersions), tier: 'enterprise', }; + +/** + * The following constants are shared between lke-create and lke-enterprise-create specs. + */ + +export 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; +export const nanodeType = linodeTypeFactory.build({ + disk: 51200, + id: 'g6-standard-1', + label: 'Linode 2 GB', + memory: 2048, + price: { + hourly: 0.0095, + monthly: 12.0, + }, + region_prices: dcPricingMockLinodeTypes.find( + (type) => type.id === 'g6-standard-1' + )?.region_prices, + vcpus: 1, +}) as ExtendedType; +const gpuType = linodeTypeFactory.build({ + class: 'gpu', + id: 'g2-gpu-1', +}) as ExtendedType; +const highMemType = linodeTypeFactory.build({ + class: 'highmem', + id: 'g7-highmem-1', +}) as ExtendedType; +const premiumType = linodeTypeFactory.build({ + class: 'premium', + id: 'g7-premium-1', +}) as ExtendedType; + +export const mockedLKEClusterTypes = [ + dedicatedType, + nanodeType, + gpuType, + highMemType, + premiumType, +]; + +export const mockedLKEEnterprisePrices = [ + lkeHighAvailabilityTypeFactory.build(), + lkeEnterpriseTypeFactory.build(), +]; + +export const dedicatedNodeCount = 4; +export const nanodeNodeCount = 3; + +export 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: 'standard', + }, +]; diff --git a/packages/manager/cypress/support/constants/widgets.ts b/packages/manager/cypress/support/constants/widgets.ts index a169454b36f..42c9c818f19 100644 --- a/packages/manager/cypress/support/constants/widgets.ts +++ b/packages/manager/cypress/support/constants/widgets.ts @@ -145,4 +145,49 @@ export const widgetDetails = { port: 1, protocols: ['TCP', 'UDP'], }, + firewall: { + dashboardName: 'Firewall Dashboard', + id: 4, + metrics: [ + { + expectedAggregation: 'max', + expectedAggregationArray: ['sum'], + expectedGranularity: '1 hr', + name: 'system_cpu_utilization_percent', + title: 'CPU Utilization', + unit: '%', + yLabel: 'system_cpu_utilization_ratio', + }, + { + expectedAggregation: 'max', + expectedAggregationArray: ['sum'], + expectedGranularity: '1 hr', + name: 'system_memory_usage_by_resource', + title: 'Memory Usage', + unit: 'B', + yLabel: 'system_memory_usage_bytes', + }, + { + expectedAggregation: 'max', + expectedAggregationArray: ['sum'], + expectedGranularity: '1 hr', + name: 'system_network_io_by_resource', + title: 'Network Traffic', + unit: 'B', + yLabel: 'system_network_io_bytes_total', + }, + { + expectedAggregation: 'max', + expectedAggregationArray: ['sum'], + expectedGranularity: '1 hr', + name: 'system_disk_OPS_total', + title: 'Disk I/O', + unit: 'OPS', + yLabel: 'system_disk_operations_total', + }, + ], + firewalls: 'Firewall-resource', + serviceType: 'firewall', + region: 'Newark', + }, }; diff --git a/packages/manager/cypress/support/intercepts/account.ts b/packages/manager/cypress/support/intercepts/account.ts index 19ff97885bd..4b4d97421ef 100644 --- a/packages/manager/cypress/support/intercepts/account.ts +++ b/packages/manager/cypress/support/intercepts/account.ts @@ -230,7 +230,7 @@ export const mockUpdateUserGrants = ( * @returns Cypress chainable. */ export const interceptInitiateEntityTransfer = (): Cypress.Chainable => { - return cy.intercept('POST', apiMatcher('account/entity-transfers')); + return cy.intercept('POST', apiMatcher('account/service-transfers')); }; /** @@ -245,7 +245,7 @@ export const mockInitiateEntityTransferError = ( ): Cypress.Chainable => { return cy.intercept( 'POST', - apiMatcher('account/entity-transfers'), + apiMatcher('account/service-transfers'), makeErrorResponse(errorMessage) ); }; @@ -272,38 +272,42 @@ export const mockGetEntityTransfers = ( received: EntityTransfer[], sent: EntityTransfer[] ) => { - return cy.intercept('GET', apiMatcher('account/entity-transfers*'), (req) => { - const filters = getFilters(req); - - if (filters?.['status'] === 'pending') { - req.reply(paginateResponse(pending)); - return; - } - - if (filters?.['+and'] && Array.isArray(filters['+and'])) { - const compositeFilters: Record[] = filters['+and']; - - // Confirm that `is_sender` is set, and, if so, that it has the expected value. - const hasTrueSenderValue = compositeFilters.some( - (compositeFilter) => compositeFilter['is_sender'] === true - ); - const hasFalseSenderValue = compositeFilters.some( - (compositeFilter) => compositeFilter['is_sender'] === false - ); + return cy.intercept( + 'GET', + apiMatcher('account/service-transfers*'), + (req) => { + const filters = getFilters(req); - if (hasTrueSenderValue) { - req.reply(paginateResponse(sent)); + if (filters?.['status'] === 'pending') { + req.reply(paginateResponse(pending)); return; } - if (hasFalseSenderValue) { - req.reply(paginateResponse(received)); - return; + if (filters?.['+and'] && Array.isArray(filters['+and'])) { + const compositeFilters: Record[] = filters['+and']; + + // Confirm that `is_sender` is set, and, if so, that it has the expected value. + const hasTrueSenderValue = compositeFilters.some( + (compositeFilter) => compositeFilter['is_sender'] === true + ); + const hasFalseSenderValue = compositeFilters.some( + (compositeFilter) => compositeFilter['is_sender'] === false + ); + + if (hasTrueSenderValue) { + req.reply(paginateResponse(sent)); + return; + } + + if (hasFalseSenderValue) { + req.reply(paginateResponse(received)); + return; + } } - } - req.continue(); - }); + req.continue(); + } + ); }; /** @@ -320,7 +324,7 @@ export const mockGetEntityTransfersError = ( ) => { return cy.intercept( 'GET', - apiMatcher('account/entity-transfers*'), + apiMatcher('account/service-transfers*'), makeErrorResponse(errorMessage, statusCode) ); }; @@ -339,7 +343,7 @@ export const mockReceiveEntityTransfer = ( ): Cypress.Chainable => { return cy.intercept( 'GET', - apiMatcher(`account/entity-transfers/${token}`), + apiMatcher(`account/service-transfers/${token}`), transfer ); }; @@ -356,7 +360,7 @@ export const mockAcceptEntityTransfer = ( ): Cypress.Chainable => { return cy.intercept( 'POST', - apiMatcher(`account/entity-transfers/${token}/accept`), + apiMatcher(`account/service-transfers/${token}/accept`), {} ); }; diff --git a/packages/manager/cypress/support/intercepts/linodes.ts b/packages/manager/cypress/support/intercepts/linodes.ts index d57a0f76908..60c387bcccc 100644 --- a/packages/manager/cypress/support/intercepts/linodes.ts +++ b/packages/manager/cypress/support/intercepts/linodes.ts @@ -780,6 +780,25 @@ export const mockGetLinodeStats = ( ); }; +/** + * Intercepts GET request to retrieve network stats for a Linode and mocks an error response. + * + * @param linodeId - ID of Linode for intercepted request. + * + * @returns Cypress chainable. + */ +export const mockGetLinodeStatsError = ( + linodeId: number, + errorMessage: string, + statusCode: number = 400 +): Cypress.Chainable => { + return cy.intercept( + 'GET', + apiMatcher(`linode/instances/${linodeId}/stats`), + makeErrorResponse(errorMessage, statusCode) + ); +}; + /** * Intercepts PUT request to edit details of a linode * diff --git a/packages/manager/cypress/support/intercepts/lke.ts b/packages/manager/cypress/support/intercepts/lke.ts index 9b63075b23d..f0cdc970dc7 100644 --- a/packages/manager/cypress/support/intercepts/lke.ts +++ b/packages/manager/cypress/support/intercepts/lke.ts @@ -254,6 +254,17 @@ export const mockAddNodePool = ( ); }; +/** + * Intercepts POST request to create Node Pool. + * + * @returns Cypress chainable. + */ +export const interceptCreateNodePool = ( + clusterId: number +): Cypress.Chainable => { + return cy.intercept('POST', apiMatcher(`lke/clusters/${clusterId}/pools`)); +}; + /** * Intercepts PUT request to update a node pool and mocks the response. * diff --git a/packages/manager/cypress/support/ui/action-menu.ts b/packages/manager/cypress/support/ui/action-menu.ts index 92d27c4fe3d..d93073671ef 100644 --- a/packages/manager/cypress/support/ui/action-menu.ts +++ b/packages/manager/cypress/support/ui/action-menu.ts @@ -30,12 +30,15 @@ export const actionMenuItem = { * cy.get(...).within(() => {})), the action menu item may not be found. * * @param menuItemTitle - Title of the action menu item to find. + * @param options - Additional options for the selector matcher. * * @returns Cypress chainable. */ findByTitle: (menuItemTitle: string): Cypress.Chainable => { return cy - .get('[data-qa-action-menu]') + .document() + .its('body') + .find('[data-qa-action-menu]') .should('be.visible') .find(`[data-qa-action-menu-item="${menuItemTitle}"]`) .should('be.visible'); diff --git a/packages/manager/cypress/support/ui/constants.ts b/packages/manager/cypress/support/ui/constants.ts index d2ef557df87..a39dd3183e1 100644 --- a/packages/manager/cypress/support/ui/constants.ts +++ b/packages/manager/cypress/support/ui/constants.ts @@ -243,51 +243,13 @@ export const pages: Page[] = [ name: 'Support/Tickets/Closed', url: `${routes.supportTicketsClosed}`, }, - { - assertIsLoaded: () => cy.findByText('Billing Info').should('be.visible'), - name: 'Account', - url: `${routes.account}`, - }, - { - assertIsLoaded: () => - cy.findByText('Update Contact Information').should('be.visible'), - goWithUI: [ - { - go: () => { - loadAppNoLogin(`${routes.account}/users`); - cy.findByText('Username'); - waitDoubleRerender(); - cy.findByText('Billing Info').should('be.visible').click(); - }, - name: 'Tab', - }, - ], - name: 'Account/Billing', - url: `${routes.account}/billing`, - }, - { - assertIsLoaded: () => cy.findByText('Add a User').should('be.visible'), - goWithUI: [ - { - go: () => { - loadAppNoLogin(`${routes.account}/billing`); - cy.findByText('Billing Contact'); - waitDoubleRerender(); - cy.get('[data-reach-tab]').contains('Users').click(); - }, - name: 'Tab', - }, - ], - name: 'Account/Users', - url: `${routes.account}/users`, - }, { assertIsLoaded: () => cy.findByText('Backup Auto Enrollment').should('be.visible'), goWithUI: [ { go: () => { - loadAppNoLogin(`${routes.account}/billing`); + loadAppNoLogin(`/billing`); cy.findByText('Billing Contact'); waitDoubleRerender(); cy.contains('Settings').click(); @@ -295,7 +257,7 @@ export const pages: Page[] = [ name: 'Tab', }, ], - name: 'Account/Settings', - url: `${routes.account}/settings`, + name: 'Settings', + url: `/settings`, }, ]; diff --git a/packages/manager/eslint.config.js b/packages/manager/eslint.config.js index 0de8306be5e..52e6fbdb0a5 100644 --- a/packages/manager/eslint.config.js +++ b/packages/manager/eslint.config.js @@ -29,10 +29,10 @@ const restrictedImportPaths = [ 'Please use Typography component from @linode/ui instead of @mui/material', }, { - name: 'react-router-dom', + name: '@tanstack/react-router', importNames: ['Link'], message: - 'Please use the Link component from src/components/Link instead of react-router-dom', + 'Please use the Link component from src/components/Link instead of direct imports from @tanstack/react-router', }, ]; @@ -401,93 +401,7 @@ export const baseConfig = [ }, }, - // 14. Tanstack Router (temporary) - { - files: [ - // for each new features added to the migration router, add its directory here - 'src/features/Account/**/*', - 'src/features/Billing/**/*', - 'src/features/Betas/**/*', - 'src/features/CloudPulse/**/*', - 'src/features/Databases/**/*', - 'src/features/Domains/**/*', - 'src/features/DataStream/**/*', - 'src/features/Events/**/*', - 'src/features/Firewalls/**/*', - 'src/features/Help/**/*', - 'src/features/IAM/**/*', - 'src/features/Images/**/*', - 'src/features/Kubernetes/**/*', - 'src/features/Linodes/**/*', - 'src/features/Longview/**/*', - 'src/features/Managed/**/*', - 'src/features/NodeBalancers/**/*', - 'src/features/ObjectStorage/**/*', - 'src/features/PlacementGroups/**/*', - 'src/features/Profile/**/*', - 'src/features/Search/**/*', - 'src/features/TopMenu/SearchBar/**/*', - 'src/components/Tag/**/*', - 'src/features/StackScripts/**/*', - 'src/features/Support/**/*', - 'src/features/Users/**/*', - 'src/features/Volumes/**/*', - 'src/features/VPCs/**/*', - ], - 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: [ - { - importNames: [ - // intentionally not including in this list as this will be updated last globally - 'useNavigate', - 'useParams', - 'useLocation', - 'useHistory', - 'useRouteMatch', - 'matchPath', - 'MemoryRouter', - 'Route', - 'RouteProps', - 'Switch', - 'Redirect', - 'RouteComponentProps', - 'withRouter', - ], - message: - 'Please use routing utilities intended for @tanstack/react-router.', - name: 'react-router-dom', - }, - { - importNames: ['TabLinkList'], - message: - 'Please use the TanStackTabLinkList component for components being migrated to TanStack Router.', - name: 'src/components/Tabs/TabLinkList', - }, - { - importNames: ['OrderBy', 'default'], - message: - 'Please use useOrderV2 hook for components being migrated to TanStack Router.', - name: 'src/components/OrderBy', - }, - { - importNames: ['Prompt'], - message: - 'Please use the TanStack useBlocker hook for components/features being migrated to TanStack Router.', - name: 'src/components/Prompt/Prompt', - }, - ], - }, - ], - }, - }, - - // 15. Prettier (coming last as recommended) + // 14. Prettier (coming last as recommended) { files: ['**/*.{js,ts,tsx}'], plugins: { diff --git a/packages/manager/package.json b/packages/manager/package.json index 7004068c743..c538a4b3de5 100644 --- a/packages/manager/package.json +++ b/packages/manager/package.json @@ -2,7 +2,7 @@ "name": "linode-manager", "author": "Linode", "description": "The Linode Manager website", - "version": "1.148.0", + "version": "1.149.0", "private": true, "type": "module", "bugs": { @@ -24,7 +24,7 @@ "@fontsource/nunito-sans": "^5.1.1", "@hookform/resolvers": "3.9.1", "@linode/api-v4": "workspace:*", - "@linode/design-language-system": "^4.0.0", + "@linode/design-language-system": "^5.0.0", "@linode/queries": "workspace:*", "@linode/search": "workspace:*", "@linode/shared": "workspace:*", @@ -45,7 +45,7 @@ "@tanstack/react-query-devtools": "5.51.24", "@tanstack/react-router": "^1.111.11", "@xterm/xterm": "^5.5.0", - "akamai-cds-react-components": "0.0.1-alpha.11", + "akamai-cds-react-components": "0.0.1-alpha.14", "algoliasearch": "^4.14.3", "axios": "~1.8.3", "braintree-web": "^3.92.2", @@ -77,8 +77,6 @@ "react-hook-form": "^7.51.0", "react-number-format": "^3.5.0", "react-redux": "~7.1.3", - "react-router-dom": "~5.3.4", - "react-router-hash-link": "^2.3.1", "react-vnc": "^3.0.7", "react-waypoint": "^10.3.0", "recharts": "^2.14.1", @@ -153,13 +151,12 @@ "@types/react-csv": "^1.1.3", "@types/react-dom": "^19.1.6", "@types/react-redux": "~7.1.7", - "@types/react-router-dom": "~5.3.3", - "@types/react-router-hash-link": "^1.2.1", "@types/redux-mock-store": "^1.0.1", "@types/throttle-debounce": "^1.0.0", "@types/zxcvbn": "^4.4.0", "@vitejs/plugin-react-swc": "^3.7.2", "@vitest/coverage-v8": "^3.1.2", + "@vueless/storybook-dark-mode": "^9.0.5", "axe-core": "^4.10.2", "chai-string": "^1.5.0", "concurrently": "^9.1.0", @@ -183,7 +180,6 @@ "pdfreader": "^3.0.7", "redux-mock-store": "^1.5.3", "storybook": "^9.0.12", - "@vueless/storybook-dark-mode": "^9.0.5", "vite": "^6.3.4", "vite-plugin-svgr": "^3.2.0" }, diff --git a/packages/manager/src/App.tsx b/packages/manager/src/App.tsx index 4b50f49f28f..ace9e20e6e7 100644 --- a/packages/manager/src/App.tsx +++ b/packages/manager/src/App.tsx @@ -9,21 +9,27 @@ import withFeatureFlagProvider from 'src/containers/withFeatureFlagProvider.cont import { ErrorBoundaryFallback } from 'src/features/ErrorBoundary/ErrorBoundaryFallback'; import { SplashScreen } from './components/SplashScreen'; -import { GoTo } from './GoTo'; -import { useAdobeAnalytics } from './hooks/useAdobeAnalytics'; import { useInitialRequests } from './hooks/useInitialRequests'; -import { useNewRelic } from './hooks/useNewRelic'; -import { usePendo } from './hooks/usePendo'; -import { useSessionExpiryToast } from './hooks/useSessionExpiryToast'; -import { MainContent } from './MainContent'; -import { useEventsPoller } from './queries/events/events'; -// import { Router } from './Router'; +import { Router } from './Router'; import { useSetupFeatureFlags } from './useSetupFeatureFlags'; export const App = withDocumentTitleProvider( withFeatureFlagProvider(() => { - const { isLoading } = useInitialRequests(); + // Skip all initialization if we're on any authentication callback - just let the router handle it + const isAuthCallback = + window.location.pathname === '/oauth/callback' || + window.location.pathname === '/admin/callback'; + + if (isAuthCallback) { + return ( + + + + + ); + } + const { isLoading } = useInitialRequests(); const { areFeatureFlagsLoading } = useSetupFeatureFlags(); if (isLoading || areFeatureFlagsLoading) { @@ -43,24 +49,9 @@ export const App = withDocumentTitleProvider( Opens an external site in a new window - - {/** - * Eventually we will have the here in place of - * - */} - - + ); }) ); - -const GlobalListeners = () => { - useEventsPoller(); - useAdobeAnalytics(); - usePendo(); - useNewRelic(); - useSessionExpiryToast(); - return null; -}; diff --git a/packages/manager/src/FramelessRoot.tsx b/packages/manager/src/FramelessRoot.tsx new file mode 100644 index 00000000000..cfbb8044d73 --- /dev/null +++ b/packages/manager/src/FramelessRoot.tsx @@ -0,0 +1,25 @@ +import { Outlet } from '@tanstack/react-router'; +import * as React from 'react'; + +import { SuspenseLoader } from 'src/components/SuspenseLoader'; +import { useDialogContext } from 'src/context/useDialogContext'; +import { ErrorBoundaryFallback } from 'src/features/ErrorBoundary/ErrorBoundaryFallback'; + +import { sessionExpirationContext } from './context/sessionExpirationContext'; + +export const FramelessRoot = () => { + const SessionExpirationProvider = sessionExpirationContext.Provider; + const sessionExpirationContextValue = useDialogContext({ + isOpen: false, + }); + + return ( + + }> + + + + + + ); +}; diff --git a/packages/manager/src/GoTo.tsx b/packages/manager/src/GoTo.tsx index 8314f43cd16..ff2da93ecf1 100644 --- a/packages/manager/src/GoTo.tsx +++ b/packages/manager/src/GoTo.tsx @@ -1,21 +1,24 @@ import { useAccountSettings, useGrants, useProfile } from '@linode/queries'; import { Dialog, Select } from '@linode/ui'; +import { useNavigate } from '@tanstack/react-router'; import * as React from 'react'; -import { useHistory } from 'react-router-dom'; import { useIsDatabasesEnabled } from './features/Databases/utilities'; import { useIsPlacementGroupsEnabled } from './features/PlacementGroups/utils'; +import { useFlags } from './hooks/useFlags'; import { useGlobalKeyboardListener } from './hooks/useGlobalKeyboardListener'; import type { SelectOption } from '@linode/ui'; export const GoTo = React.memo(() => { - const routerHistory = useHistory(); + const navigate = useNavigate(); const { data: accountSettings } = useAccountSettings(); const { data: grants } = useGrants(); const { data: profile } = useProfile(); + const { iamRbacPrimaryNavChanges } = useFlags(); + const isManagedAccount = accountSettings?.managed ?? false; const hasAccountAccess = @@ -30,7 +33,7 @@ export const GoTo = React.memo(() => { }; const onSelect = (item: SelectOption) => { - routerHistory.push(item.value); + navigate({ to: item.value }); onClose(); }; @@ -103,11 +106,22 @@ export const GoTo = React.memo(() => { display: 'Marketplace', href: '/linodes/create/marketplace', }, - { - display: 'Account', - hide: !hasAccountAccess, - href: '/account/billing', - }, + ...(iamRbacPrimaryNavChanges + ? [ + { display: 'Billing', href: '/billing' }, + { display: 'Identity & Access', href: '/iam' }, + { display: 'Login History', href: '/login-history' }, + { display: 'Service Transfers', href: '/service-transfers' }, + { display: 'Maintenance', href: '/maintenance' }, + { display: 'Settings', href: '/settings' }, + ] + : [ + { + display: 'Account', + hide: !hasAccountAccess, + href: '/account/billing', + }, + ]), { display: 'Help & Support', href: '/support', @@ -117,7 +131,12 @@ export const GoTo = React.memo(() => { href: '/profile/display', }, ], - [hasAccountAccess, isManagedAccount, isPlacementGroupsEnabled] + [ + hasAccountAccess, + isManagedAccount, + isPlacementGroupsEnabled, + iamRbacPrimaryNavChanges, + ] ); const options: SelectOption[] = React.useMemo( diff --git a/packages/manager/src/MainContent.tsx b/packages/manager/src/MainContent.tsx deleted file mode 100644 index 635f8815806..00000000000 --- a/packages/manager/src/MainContent.tsx +++ /dev/null @@ -1,301 +0,0 @@ -import { - useAccountSettings, - useMutatePreferences, - usePreferences, - useProfile, -} from '@linode/queries'; -import { Box } from '@linode/ui'; -import { useMediaQuery } from '@mui/material'; -import Grid from '@mui/material/Grid'; -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'; -import { makeStyles } from 'tss-react/mui'; - -import { MainContentBanner } from 'src/components/MainContentBanner'; -import { MaintenanceScreen } from 'src/components/MaintenanceScreen'; -import { - SIDEBAR_COLLAPSED_WIDTH, - SIDEBAR_WIDTH, -} from 'src/components/PrimaryNav/constants'; -import { SideMenu } from 'src/components/PrimaryNav/SideMenu'; -import { SuspenseLoader } from 'src/components/SuspenseLoader'; -import { useDialogContext } from 'src/context/useDialogContext'; -import { ErrorBoundaryFallback } from 'src/features/ErrorBoundary/ErrorBoundaryFallback'; -import { Footer } from 'src/features/Footer'; -import { GlobalNotifications } from 'src/features/GlobalNotifications/GlobalNotifications'; -import { - notificationCenterContext, - useNotificationContext, -} from 'src/features/NotificationCenter/NotificationCenterContext'; -import { TopMenu } from 'src/features/TopMenu/TopMenu'; - -import { useIsPageScrollable } from './components/PrimaryNav/utils'; -import { ENABLE_MAINTENANCE_MODE } from './constants'; -import { complianceUpdateContext } from './context/complianceUpdateContext'; -import { sessionExpirationContext } from './context/sessionExpirationContext'; -import { switchAccountSessionContext } from './context/switchAccountSessionContext'; -import { TOPMENU_HEIGHT } from './features/TopMenu/constants'; -import { useGlobalErrors } from './hooks/useGlobalErrors'; -import { migrationRouter } from './routes'; - -import type { Theme } from '@mui/material/styles'; -import type { AnyRouter } from '@tanstack/react-router'; - -const useStyles = makeStyles()((theme: Theme) => ({ - activationWrapper: { - padding: theme.spacing(4), - [theme.breakpoints.up('xl')]: { - margin: '0 auto', - width: '50%', - }, - }, - appFrame: { - backgroundColor: theme.bg.app, - display: 'flex', - flexDirection: 'column', - minHeight: '100vh', - position: 'relative', - zIndex: 1, - }, - bgStyling: { - backgroundColor: theme.bg.main, - display: 'flex', - flexDirection: 'column', - justifyContent: 'center', - minHeight: '100vh', - }, - content: { - display: 'flex', - flex: 1, - flexDirection: 'column', - transition: 'margin-left .1s linear', - width: '100%', - }, - fullWidthContent: { - marginLeft: 0, - }, - grid: { - marginLeft: 0, - marginRight: 0, - [theme.breakpoints.up('lg')]: { - height: '100%', - }, - width: '100%', - }, - logo: { - '& > g': { - fill: theme.color.black, - }, - }, - switchWrapper: { - '& > .MuiGrid-container': { - maxWidth: theme.breakpoints.values.lg, - width: '100%', - }, - '&.mlMain': { - [theme.breakpoints.up('lg')]: { - maxWidth: '78.8%', - }, - }, - flex: 1, - maxWidth: '100%', - position: 'relative', - }, -})); - -export const MainContent = () => { - const contentRef = React.useRef(null); - const { classes, cx } = useStyles(); - const { data: isDesktopSidebarOpenPreference } = usePreferences( - (preferences) => preferences?.desktop_sidebar_open - ); - const { mutateAsync: updatePreferences } = useMutatePreferences(); - const queryClient = useQueryClient(); - - const globalErrors = useGlobalErrors(); - - const NotificationProvider = notificationCenterContext.Provider; - const contextValue = useNotificationContext(); - - const ComplianceUpdateProvider = complianceUpdateContext.Provider; - const complianceUpdateContextValue = useDialogContext(); - - const SwitchAccountSessionProvider = switchAccountSessionContext.Provider; - const switchAccountSessionContextValue = useDialogContext({ - isOpen: false, - }); - - const SessionExpirationProvider = sessionExpirationContext.Provider; - const sessionExpirationContextValue = useDialogContext({ - isOpen: false, - }); - - const [menuIsOpen, toggleMenu] = React.useState(false); - - const { data: profile } = useProfile(); - const username = profile?.username || ''; - - const { data: accountSettings } = useAccountSettings(); - const defaultRoot = accountSettings?.managed ? '/managed' : '/linodes'; - - const isNarrowViewport = useMediaQuery((theme: Theme) => - theme.breakpoints.down(960) - ); - - const { isPageScrollable } = useIsPageScrollable(contentRef); - - migrationRouter.update({ - context: { - globalErrors, - queryClient, - }, - }); - - /** - * this is the case where the user has successfully completed signup - * but needs a manual review from Customer Support. In this case, - * the user is going to get 403 errors from almost every single endpoint. - * - * So in this case, we'll show something more user-friendly - */ - if (globalErrors.account_unactivated) { - return ( - <> - - - - ); - } - - // If the API is in maintenance mode, return a Maintenance screen - if (globalErrors.api_maintenance_mode || ENABLE_MAINTENANCE_MODE) { - return ; - } - - const desktopMenuIsOpen = isDesktopSidebarOpenPreference ?? false; - - const desktopMenuToggle = () => { - updatePreferences({ - desktop_sidebar_open: !isDesktopSidebarOpenPreference, - }); - }; - - return ( -
    - - - - - - toggleMenu(true)} - username={username} - /> - - - toggleMenu(false)} - collapse={desktopMenuIsOpen || false} - desktopMenuToggle={desktopMenuToggle} - open={menuIsOpen} - /> - -
    - - ({ - flex: 1, - margin: '0 auto', - maxWidth: `${theme.breakpoints.values.lg}px !important`, - pb: theme.spacingFunction(32), - pt: theme.spacingFunction(24), - px: { - md: theme.spacingFunction(16), - xs: 0, - }, - transition: theme.transitions.create('opacity'), - width: isNarrowViewport - ? '100%' - : `calc(100vw - ${ - desktopMenuIsOpen - ? SIDEBAR_COLLAPSED_WIDTH - : SIDEBAR_WIDTH - }px)`, - })} - > - - -
    - - }> - - - - {/** We don't want to break any bookmarks. This can probably be removed eventually. */} - - {/** - * This is the catch all routes that allows TanStack Router to take over. - * When a route is not found here, it will be handled by the migration router, which in turns handles the NotFound component. - * It is currently set to the migration router in order to incrementally migrate the app to the new routing. - * This is a temporary solution until we are ready to fully migrate to TanStack Router. - */} - - - - - - -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    - ); -}; diff --git a/packages/manager/src/OAuth/LoginAsCustomerCallback.tsx b/packages/manager/src/OAuth/LoginAsCustomerCallback.tsx index 4b67b40ddc3..2ac2d33948e 100644 --- a/packages/manager/src/OAuth/LoginAsCustomerCallback.tsx +++ b/packages/manager/src/OAuth/LoginAsCustomerCallback.tsx @@ -1,6 +1,6 @@ import * as Sentry from '@sentry/react'; +import { useNavigate } from '@tanstack/react-router'; import React, { useEffect } from 'react'; -import type { RouteComponentProps } from 'react-router-dom'; import { SplashScreen } from 'src/components/SplashScreen'; import { @@ -18,14 +18,16 @@ import { * Admin will redirect to Cloud Manager with a URL like: * https://cloud.linode.com/admin/callback#access_token=fjhwehkfg&destination=dashboard&expires_in=900&token_type=Admin */ -export const LoginAsCustomerCallback = (props: RouteComponentProps) => { +export const LoginAsCustomerCallback = () => { + const navigate = useNavigate(); + const authenticate = async () => { try { const { returnTo } = await handleLoginAsCustomerCallback({ params: location.hash.substring(1), // substring is called to remove the leading "#" from the hash params }); - props.history.push(returnTo); + navigate({ to: returnTo }); } catch (error) { // eslint-disable-next-line no-console console.error(error); diff --git a/packages/manager/src/OAuth/OAuthCallback.tsx b/packages/manager/src/OAuth/OAuthCallback.tsx index 3775f135f12..454bea3f446 100644 --- a/packages/manager/src/OAuth/OAuthCallback.tsx +++ b/packages/manager/src/OAuth/OAuthCallback.tsx @@ -1,6 +1,6 @@ import * as Sentry from '@sentry/react'; +import { useNavigate } from '@tanstack/react-router'; import React from 'react'; -import type { RouteComponentProps } from 'react-router-dom'; import { SplashScreen } from 'src/components/SplashScreen'; @@ -12,14 +12,15 @@ import { clearStorageAndRedirectToLogout, handleOAuthCallback } from './oauth'; * * We will handle taking the code, turning it into an access token, and start a Cloud Manager session. */ -export const OAuthCallback = (props: RouteComponentProps) => { +export const OAuthCallback = () => { + const navigate = useNavigate(); const authenticate = async () => { try { const { returnTo } = await handleOAuthCallback({ params: location.search, }); - props.history.push(returnTo); + navigate({ to: returnTo }); } catch (error) { // eslint-disable-next-line no-console console.error(error); diff --git a/packages/manager/src/Root.tsx b/packages/manager/src/Root.tsx index 054c4c55c3e..526f7536180 100644 --- a/packages/manager/src/Root.tsx +++ b/packages/manager/src/Root.tsx @@ -11,16 +11,23 @@ import { useProfile, } from '@linode/queries'; import { Box } from '@linode/ui'; +import { useMediaQuery } from '@mui/material'; import Grid from '@mui/material/Grid'; -import { Outlet } from '@tanstack/react-router'; -import React from 'react'; +import { Outlet, useNavigate } from '@tanstack/react-router'; +import * as React from 'react'; +import { makeStyles } from 'tss-react/mui'; -import Logo from 'src/assets/logo/akamai-logo.svg'; import { MainContentBanner } from 'src/components/MainContentBanner'; import { MaintenanceScreen } from 'src/components/MaintenanceScreen'; +import { + SIDEBAR_COLLAPSED_WIDTH, + SIDEBAR_WIDTH, +} from 'src/components/PrimaryNav/constants'; import { SideMenu } from 'src/components/PrimaryNav/SideMenu'; +import { Snackbar } from 'src/components/Snackbar/Snackbar'; import { SuspenseLoader } from 'src/components/SuspenseLoader'; import { useDialogContext } from 'src/context/useDialogContext'; +import { ErrorBoundaryFallback } from 'src/features/ErrorBoundary/ErrorBoundaryFallback'; import { Footer } from 'src/features/Footer'; import { GlobalNotifications } from 'src/features/GlobalNotifications/GlobalNotifications'; import { @@ -29,15 +36,89 @@ import { } from 'src/features/NotificationCenter/NotificationCenterContext'; import { TopMenu } from 'src/features/TopMenu/TopMenu'; +import { useIsPageScrollable } from './components/PrimaryNav/utils'; import { ENABLE_MAINTENANCE_MODE } from './constants'; import { complianceUpdateContext } from './context/complianceUpdateContext'; import { sessionExpirationContext } from './context/sessionExpirationContext'; import { switchAccountSessionContext } from './context/switchAccountSessionContext'; +import { TOPMENU_HEIGHT } from './features/TopMenu/constants'; +import { GoTo } from './GoTo'; +import { useAdobeAnalytics } from './hooks/useAdobeAnalytics'; import { useGlobalErrors } from './hooks/useGlobalErrors'; -import { useStyles } from './Root.styles'; +import { useNewRelic } from './hooks/useNewRelic'; +import { usePendo } from './hooks/usePendo'; +import { useSessionExpiryToast } from './hooks/useSessionExpiryToast'; +import { useEventsPoller } from './queries/events/events'; + +import type { Theme } from '@mui/material/styles'; + +const useStyles = makeStyles()((theme: Theme) => ({ + activationWrapper: { + padding: theme.spacing(4), + [theme.breakpoints.up('xl')]: { + margin: '0 auto', + width: '50%', + }, + }, + appFrame: { + backgroundColor: theme.bg.app, + display: 'flex', + flexDirection: 'column', + minHeight: '100vh', + position: 'relative', + zIndex: 1, + }, + bgStyling: { + backgroundColor: theme.bg.main, + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + minHeight: '100vh', + }, + content: { + display: 'flex', + flex: 1, + flexDirection: 'column', + transition: 'margin-left .1s linear', + width: '100%', + }, + fullWidthContent: { + marginLeft: 0, + }, + grid: { + marginLeft: 0, + marginRight: 0, + [theme.breakpoints.up('lg')]: { + height: '100%', + }, + width: '100%', + }, + logo: { + '& > g': { + fill: theme.color.black, + }, + }, + switchWrapper: { + '& > .MuiGrid-container': { + maxWidth: theme.breakpoints.values.lg, + width: '100%', + }, + '&.mlMain': { + [theme.breakpoints.up('lg')]: { + maxWidth: '78.8%', + }, + }, + flex: 1, + maxWidth: '100%', + position: 'relative', + }, +})); export const Root = () => { + const navigate = useNavigate(); + const contentRef = React.useRef(null); const { classes, cx } = useStyles(); + const { data: isDesktopSidebarOpenPreference } = usePreferences( (preferences) => preferences?.desktop_sidebar_open ); @@ -66,85 +147,151 @@ export const Root = () => { const { data: profile } = useProfile(); const username = profile?.username || ''; - const desktopMenuIsOpen = isDesktopSidebarOpenPreference ?? false; + const isNarrowViewport = useMediaQuery((theme: Theme) => + theme.breakpoints.down(960) + ); - const desktopMenuToggle = () => { - updatePreferences({ - desktop_sidebar_open: !isDesktopSidebarOpenPreference, - }); - }; + const { isPageScrollable } = useIsPageScrollable(contentRef); + /** + * this is the case where the user has successfully completed signup + * but needs a manual review from Customer Support. In this case, + * the user is going to get 403 errors from almost every single endpoint. + * + * So in this case, we'll show something more user-friendly + */ if (globalErrors.account_unactivated) { - return ( -
    -
    - - - - -
    -
    - ); + navigate({ to: '/account-activation' }); } + // If the API is in maintenance mode, return a Maintenance screen if (globalErrors.api_maintenance_mode || ENABLE_MAINTENANCE_MODE) { return ; } + const desktopMenuIsOpen = isDesktopSidebarOpenPreference ?? false; + + const desktopMenuToggle = () => { + updatePreferences({ + desktop_sidebar_open: !isDesktopSidebarOpenPreference, + }); + }; + return ( -
    - - - - - - toggleMenu(true)} - username={username} - /> -
    - toggleMenu(false)} - collapse={desktopMenuIsOpen || false} +
    + + + + + + + + toggleMenu(true)} + username={username} /> - ({ - maxWidth: `${theme.breakpoints.values.lg}px !important`, - padding: `${theme.spacing(3)} 0`, - paddingTop: 12, - [theme.breakpoints.between('md', 'xl')]: { - paddingLeft: theme.spacing(2), - paddingRight: theme.spacing(2), - }, - [theme.breakpoints.down('md')]: { - paddingLeft: 0, - paddingRight: 0, - paddingTop: theme.spacing(2), - }, - transition: theme.transitions.create('opacity'), - })} - > - - - - }> - - - - + + + toggleMenu(false)} + collapse={desktopMenuIsOpen || false} + desktopMenuToggle={desktopMenuToggle} + open={menuIsOpen} + /> + +
    + + ({ + flex: 1, + margin: '0 auto', + maxWidth: `${theme.breakpoints.values.lg}px !important`, + pb: theme.spacingFunction(32), + pt: theme.spacingFunction(24), + px: { + md: theme.spacingFunction(16), + xs: 0, + }, + transition: theme.transitions.create('opacity'), + width: isNarrowViewport + ? '100%' + : `calc(100vw - ${ + desktopMenuIsOpen + ? SIDEBAR_COLLAPSED_WIDTH + : SIDEBAR_WIDTH + }px)`, + })} + > + + +
    + + }> + + + + +
    +
    +
    +
    +
    +
    -
    - -
    - - - + + + + + +
    ); }; + +const GlobalListeners = () => { + useEventsPoller(); + useAdobeAnalytics(); + usePendo(); + useNewRelic(); + useSessionExpiryToast(); + return null; +}; diff --git a/packages/manager/src/RootSwitch.tsx b/packages/manager/src/RootSwitch.tsx new file mode 100644 index 00000000000..7f0e8cd18c7 --- /dev/null +++ b/packages/manager/src/RootSwitch.tsx @@ -0,0 +1,18 @@ +import { useLocation, useParams } from '@tanstack/react-router'; +import * as React from 'react'; + +import { FramelessRoot } from './FramelessRoot'; +import { Root } from './Root'; + +export const RootSwitch = () => { + const location = useLocation(); + const params = useParams({ + strict: false, + }); + + if (location.pathname.includes('/lish/') && params.linodeId && params.type) { + return ; + } + + return ; +}; diff --git a/packages/manager/src/Router.tsx b/packages/manager/src/Router.tsx index 68a764302a0..3ae62846cf1 100644 --- a/packages/manager/src/Router.tsx +++ b/packages/manager/src/Router.tsx @@ -1,8 +1,9 @@ import { useAccountSettings } from '@linode/queries'; -import { QueryClient } from '@tanstack/react-query'; +import { useQueryClient } from '@tanstack/react-query'; import { RouterProvider } from '@tanstack/react-router'; import * as React from 'react'; +import { useFlags } from 'src/hooks/useFlags'; import { useGlobalErrors } from 'src/hooks/useGlobalErrors'; import { useIsACLPEnabled } from './features/CloudPulse/Utils/utils'; @@ -12,26 +13,30 @@ import { useIsPlacementGroupsEnabled } from './features/PlacementGroups/utils'; import { router } from './routes'; export const Router = () => { + const queryClient = useQueryClient(); + const globalErrors = useGlobalErrors(); + const { data: accountSettings } = useAccountSettings(); const { isDatabasesEnabled } = useIsDatabasesEnabled(); const { isPlacementGroupsEnabled } = useIsPlacementGroupsEnabled(); const { isACLPEnabled } = useIsACLPEnabled(); - const globalErrors = useGlobalErrors(); + const flags = useFlags(); // Update the router's context router.update({ context: { accountSettings, + flags, globalErrors, isACLPEnabled, isDatabasesEnabled, isPlacementGroupsEnabled, - queryClient: new QueryClient(), + queryClient, }, }); return ( - + ); diff --git a/packages/manager/src/__data__/reactRouterProps.ts b/packages/manager/src/__data__/reactRouterProps.ts deleted file mode 100644 index d618a8e67e7..00000000000 --- a/packages/manager/src/__data__/reactRouterProps.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { RouteComponentProps } from 'react-router-dom'; - -type History = RouteComponentProps<{}>['history']; -type Location = History['location']; - -export const mockLocation: Location = { - hash: '', - pathname: '/', - search: '?query=search', - state: {}, -}; - -export const match: RouteComponentProps<{}>['match'] = { - isExact: false, - params: 'test', - path: 'localhost', - url: 'localhost', -}; - -export const history: History = { - action: 'POP', - block: vi.fn(), - createHref: vi.fn(), - go: vi.fn(), - goBack: vi.fn(), - goForward: vi.fn(), - length: 1, - listen: vi.fn(), - location: mockLocation, - push: vi.fn(), - replace: vi.fn(), -}; - -export const reactRouterProps: RouteComponentProps = { - history, - location: mockLocation, - match, - staticContext: undefined, -}; diff --git a/packages/manager/src/assets/icons/entityIcons/coreuser.svg b/packages/manager/src/assets/icons/entityIcons/coreuser.svg new file mode 100644 index 00000000000..1463f5dc3bc --- /dev/null +++ b/packages/manager/src/assets/icons/entityIcons/coreuser.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/manager/src/components/AbuseTicketBanner/AbuseTicketBanner.tsx b/packages/manager/src/components/AbuseTicketBanner/AbuseTicketBanner.tsx index 98a8488c7c9..ce8985467d8 100644 --- a/packages/manager/src/components/AbuseTicketBanner/AbuseTicketBanner.tsx +++ b/packages/manager/src/components/AbuseTicketBanner/AbuseTicketBanner.tsx @@ -1,9 +1,9 @@ import { useNotificationsQuery } from '@linode/queries'; import { Typography } from '@linode/ui'; import Grid from '@mui/material/Grid'; +import { useLocation } from '@tanstack/react-router'; import { DateTime } from 'luxon'; import * as React from 'react'; -import { useLocation } from 'react-router-dom'; import { DismissibleBanner } from 'src/components/DismissibleBanner/DismissibleBanner'; import { Link } from 'src/components/Link'; diff --git a/packages/manager/src/components/AccessPanel/UserSSHKeyPanel.tsx b/packages/manager/src/components/AccessPanel/UserSSHKeyPanel.tsx index 679d7bb530f..b961a9511a6 100644 --- a/packages/manager/src/components/AccessPanel/UserSSHKeyPanel.tsx +++ b/packages/manager/src/components/AccessPanel/UserSSHKeyPanel.tsx @@ -2,6 +2,7 @@ import { useAccountUsers, useProfile, useSSHKeysQuery } from '@linode/queries'; import { Box, Button, Checkbox, Typography } from '@linode/ui'; import { truncateAndJoinList } from '@linode/utilities'; import { useTheme } from '@mui/material/styles'; +import { useLocation } from '@tanstack/react-router'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; @@ -13,7 +14,7 @@ import { TableRow } from 'src/components/TableRow'; import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; import { TableRowError } from 'src/components/TableRowError/TableRowError'; import { CreateSSHKeyDrawer } from 'src/features/Profile/SSHKeys/CreateSSHKeyDrawer'; -import { usePagination } from 'src/hooks/usePagination'; +import { usePaginationV2 } from 'src/hooks/usePaginationV2'; import { Avatar } from '../Avatar/Avatar'; import { PaginationFooter } from '../PaginationFooter/PaginationFooter'; @@ -21,6 +22,7 @@ import { TableRowLoading } from '../TableRowLoading/TableRowLoading'; import type { TypographyProps } from '@linode/ui'; import type { Theme } from '@mui/material/styles'; +import type { LinkProps } from '@tanstack/react-router'; const MAX_SSH_KEYS_DISPLAY = 25; @@ -55,6 +57,7 @@ interface Props { export const UserSSHKeyPanel = (props: Props) => { const { classes } = useStyles(); + const location = useLocation(); const theme = useTheme(); const { authorizedUsers, disabled, headingVariant, setAuthorizedUsers } = props; @@ -62,7 +65,11 @@ export const UserSSHKeyPanel = (props: Props) => { const [isCreateDrawerOpen, setIsCreateDrawerOpen] = React.useState(false); - const pagination = usePagination(1); + const pagination = usePaginationV2({ + currentRoute: location.pathname as LinkProps['to'], + initialPage: 1, + preferenceKey: 'ssh-keys-users-table', + }); const { data: profile } = useProfile(); diff --git a/packages/manager/src/components/AccountActivation/AccountActivationLanding.tsx b/packages/manager/src/components/AccountActivation/AccountActivationLanding.tsx index 2e40219d44f..1f8f477c99b 100644 --- a/packages/manager/src/components/AccountActivation/AccountActivationLanding.tsx +++ b/packages/manager/src/components/AccountActivation/AccountActivationLanding.tsx @@ -1,7 +1,7 @@ import { Box, ErrorState, StyledLinkButton, Typography } from '@linode/ui'; import Warning from '@mui/icons-material/CheckCircle'; +import { useNavigate } from '@tanstack/react-router'; import * as React from 'react'; -import { useHistory } from 'react-router-dom'; import Logo from 'src/assets/logo/akamai-logo.svg'; import { SupportTicketDialog } from 'src/features/Support/SupportTickets/SupportTicketDialog'; @@ -9,7 +9,7 @@ import { SupportTicketDialog } from 'src/features/Support/SupportTickets/Support import type { AttachmentError } from 'src/features/Support/SupportTicketDetail/SupportTicketDetail'; export const AccountActivationLanding = () => { - const history = useHistory(); + const navigate = useNavigate(); const [supportDrawerIsOpen, toggleSupportDrawer] = React.useState(false); @@ -18,9 +18,13 @@ export const AccountActivationLanding = () => { ticketID: number, attachmentErrors?: AttachmentError[] ) => { - history.push({ - pathname: `/support/tickets/${ticketID}`, - state: { attachmentErrors }, + navigate({ + to: '/support/tickets/$ticketId', + params: { ticketId: ticketID }, + state: (prev) => ({ + ...prev, + attachmentErrors, + }), }); toggleSupportDrawer(false); diff --git a/packages/manager/src/components/ActionMenu/ActionMenu.tsx b/packages/manager/src/components/ActionMenu/ActionMenu.tsx index c7d334871aa..d18e8408934 100644 --- a/packages/manager/src/components/ActionMenu/ActionMenu.tsx +++ b/packages/manager/src/components/ActionMenu/ActionMenu.tsx @@ -133,6 +133,12 @@ export const ActionMenu = React.memo((props: ActionMenuProps) => { MenuListProps={{ 'aria-labelledby': buttonId, }} + onClick={(e) => { + // Prevents clicks on disabled MenuItems from propagating + if (stopClickPropagation) { + e.stopPropagation(); + } + }} onClose={handleClose} open={open} slotProps={{ @@ -155,13 +161,13 @@ export const ActionMenu = React.memo((props: ActionMenuProps) => { disabled={a.disabled} key={idx} onClick={(e) => { + if (stopClickPropagation) { + e.stopPropagation(); + } if (!a.disabled) { handleClose(e); a.onClick(); } - if (stopClickPropagation) { - e.stopPropagation(); - } }} onMouseEnter={handleMouseEnter} > diff --git a/packages/manager/src/components/Breadcrumb/Crumbs.tsx b/packages/manager/src/components/Breadcrumb/Crumbs.tsx index a1c444b45ed..1b7261b7055 100644 --- a/packages/manager/src/components/Breadcrumb/Crumbs.tsx +++ b/packages/manager/src/components/Breadcrumb/Crumbs.tsx @@ -1,7 +1,6 @@ import * as React from 'react'; -// eslint-disable-next-line no-restricted-imports -import { Link } from 'react-router-dom'; -import type { LinkProps } from 'react-router-dom'; + +import { Link } from 'src/components/Link'; import { StyledDiv, @@ -12,6 +11,7 @@ import { FinalCrumb } from './FinalCrumb'; import { FinalCrumbPrefix } from './FinalCrumbPrefix'; import type { EditableProps, LabelProps } from './types'; +import type { LinkProps } from '@tanstack/react-router'; export interface CrumbOverridesProps { label?: React.ReactNode | string; diff --git a/packages/manager/src/components/DebouncedSearchTextField/DebouncedSearchTextField.tsx b/packages/manager/src/components/DebouncedSearchTextField/DebouncedSearchTextField.tsx index f568d261c30..5e888f935ee 100644 --- a/packages/manager/src/components/DebouncedSearchTextField/DebouncedSearchTextField.tsx +++ b/packages/manager/src/components/DebouncedSearchTextField/DebouncedSearchTextField.tsx @@ -75,14 +75,30 @@ export const DebouncedSearchTextField = React.memo( const [textFieldValue, setTextFieldValue] = React.useState(''); - // Memoize the debounced onChange handler to prevent unnecessary re-creations. - const debouncedOnChange = React.useMemo( - () => - debounce(debounceTime ?? 400, (e) => { + const debouncedRef = React.useRef>(null); + + React.useEffect(() => { + // Cancel any pending call from a previous instance. + debouncedRef.current?.cancel(); + + debouncedRef.current = debounce( + debounceTime ?? 400, + (e: React.ChangeEvent) => { onSearch(e.target.value); setTextFieldValue(e.target.value); - }), - [debounceTime, onSearch] + } + ); + + return () => { + debouncedRef.current?.cancel(); + }; + }, [debounceTime, onSearch]); + + const handleChange = React.useCallback( + (e: React.ChangeEvent) => { + debouncedRef.current?.(e); + }, + [] ); // Synchronize the internal state with the prop value when the value prop changes. @@ -99,7 +115,7 @@ export const DebouncedSearchTextField = React.memo( defaultValue={defaultValue} hideLabel={hideLabel} label={label} - onChange={debouncedOnChange} + onChange={handleChange} placeholder={placeholder || 'Filter by query'} slotProps={{ input: { @@ -110,13 +126,12 @@ export const DebouncedSearchTextField = React.memo( { + debouncedRef.current?.cancel(); setTextFieldValue(''); onSearch(''); }} size="small" - sx={{ - padding: 0, - }} + sx={{ padding: 0 }} > diff --git a/packages/manager/src/components/Flag.tsx b/packages/manager/src/components/Flag.tsx index 2cbf7b8a23c..62217818a30 100644 --- a/packages/manager/src/components/Flag.tsx +++ b/packages/manager/src/components/Flag.tsx @@ -1,4 +1,4 @@ -import { Box } from '@linode/ui'; +import { Box, omittedProps } from '@linode/ui'; import 'flag-icons/css/flag-icons.min.css'; import { styled } from '@mui/material/styles'; import React from 'react'; @@ -45,7 +45,10 @@ const getFlagClass = (country: Country | string) => { return country; }; -const StyledFlag = styled(Box, { label: 'StyledFlag' })<{ +const StyledFlag = styled(Box, { + label: 'StyledFlag', + shouldForwardProp: omittedProps(['hasBorder']), +})<{ hasBorder: boolean; }>(({ theme, hasBorder }) => ({ boxShadow: diff --git a/packages/manager/src/components/GenerateFirewallDialog/useCreateFirewallFromTemplate.ts b/packages/manager/src/components/GenerateFirewallDialog/useCreateFirewallFromTemplate.ts index 625b4887a56..4239ce7ec0f 100644 --- a/packages/manager/src/components/GenerateFirewallDialog/useCreateFirewallFromTemplate.ts +++ b/packages/manager/src/components/GenerateFirewallDialog/useCreateFirewallFromTemplate.ts @@ -65,7 +65,7 @@ export const createFirewallFromTemplate = async (options: { // Get firewalls and firewall template in parallel const [{ rules, slug }, firewalls] = await Promise.all([ queryClient.ensureQueryData(firewallQueries.template(templateSlug)), - queryClient.fetchQuery(firewallQueries.firewalls._ctx.all), // must fetch fresh data if generating more than one firewall + queryClient.fetchQuery(firewallQueries.firewalls._ctx.all()), // must fetch fresh data if generating more than one firewall ]); if (updateProgress) { diff --git a/packages/manager/src/components/ImageSelect/ImageSelect.tsx b/packages/manager/src/components/ImageSelect/ImageSelect.tsx index 9229a075e08..f65b6ecf5d3 100644 --- a/packages/manager/src/components/ImageSelect/ImageSelect.tsx +++ b/packages/manager/src/components/ImageSelect/ImageSelect.tsx @@ -152,9 +152,16 @@ export const ImageSelect = (props: Props) => { return isImageDeprecated(value) && [value]; }, [value]); - if (options.length === 1 && onChange && selectIfOnlyOneOption && !multiple) { - onChange(options[0]); - } + React.useEffect(() => { + if ( + options.length === 1 && + onChange && + selectIfOnlyOneOption && + !multiple + ) { + onChange(options[0]); + } + }, [options.length, onChange, selectIfOnlyOneOption, multiple, options]); return ( diff --git a/packages/manager/src/components/LandingHeader/LandingHeader.stories.tsx b/packages/manager/src/components/LandingHeader/LandingHeader.stories.tsx index 2656371b9cb..133c431127e 100644 --- a/packages/manager/src/components/LandingHeader/LandingHeader.stories.tsx +++ b/packages/manager/src/components/LandingHeader/LandingHeader.stories.tsx @@ -69,7 +69,7 @@ export const withBreadcrumbOverrides: Story = { crumbOverrides: [ { label: 'My First Crumb', - linkTo: '/someRoute', + linkTo: '/linodes', noCap: true, position: 1, }, diff --git a/packages/manager/src/components/LandingHeader/LandingHeader.test.tsx b/packages/manager/src/components/LandingHeader/LandingHeader.test.tsx index be4582f43eb..b557f996837 100644 --- a/packages/manager/src/components/LandingHeader/LandingHeader.test.tsx +++ b/packages/manager/src/components/LandingHeader/LandingHeader.test.tsx @@ -113,7 +113,7 @@ describe('LandingHeader', () => { crumbOverrides: [ { label: 'My First Crumb', - linkTo: '/someRoute', + linkTo: '/linodes', noCap: true, position: 1, }, diff --git a/packages/manager/src/components/Link.tsx b/packages/manager/src/components/Link.tsx index d1017373757..80468671141 100644 --- a/packages/manager/src/components/Link.tsx +++ b/packages/manager/src/components/Link.tsx @@ -5,15 +5,16 @@ import { flattenChildrenIntoAriaLabel, opensInNewTab, } from '@linode/utilities'; // `link.ts` utils from @linode/utilities -import * as React from 'react'; // eslint-disable-next-line no-restricted-imports -import { Link as RouterLink } from 'react-router-dom'; -import type { LinkProps as _LinkProps } from 'react-router-dom'; +import { Link as RouterLink } from '@tanstack/react-router'; +import * as React from 'react'; import ExternalLinkIcon from 'src/assets/icons/external-link.svg'; import { useStyles } from 'src/components/Link.styles'; -import type { LinkProps as TanStackLinkProps } from '@tanstack/react-router'; +import type { LinkProps as _LinkProps } from '@tanstack/react-router'; + +type To = _LinkProps['to'] | (string & {}); export interface LinkProps extends Omit<_LinkProps, 'to'> { /** @@ -26,6 +27,10 @@ export interface LinkProps extends Omit<_LinkProps, 'to'> { * @default false */ bypassSanitization?: boolean; + /** + * Optional prop to pass a className to the link. + */ + className?: string; /** * Optional prop to render the link as an external link, which features an external link icon, opens in a new tab
    * and provides by default "noopener noreferrer" attributes to prevent security vulnerabilities. @@ -45,14 +50,21 @@ export interface LinkProps extends Omit<_LinkProps, 'to'> { */ hideIcon?: boolean; /** - * The Link's destination. - * We are overwriting react-router-dom's `to` type because they allow objects, functions, and strings. - * We want to keep our `to` prop simple so that we can easily read and sanitize it. - * - * @example "/profile/display" - * @example "https://linode.com" + * Optional prop to pass a onClick handler to the link. + */ + onClick?: (e: React.MouseEvent) => void; + /** + * Optional prop to pass a sx style to the link. + */ + style?: React.CSSProperties; + /** + * Optional prop to pass a title to the link. + */ + title?: string; + /** + * The destination URL. Can be a relative path for internal navigation or an absolute URL for external links. */ - to: Exclude<(string & {}) | TanStackLinkProps['to'], null | undefined>; + to?: To; } /** @@ -87,18 +99,27 @@ export const Link = React.forwardRef( forceCopyColor, hideIcon, onClick, + style, + title, to, } = props; const { classes, cx } = useStyles(); - const processedUrl = () => (bypassSanitization ? to : sanitizeUrl(to)); + const processedUrl = () => { + if (!to) return ''; + return bypassSanitization ? to : sanitizeUrl(to); + }; const shouldOpenInNewTab = opensInNewTab(processedUrl()); - const childrenAsAriaLabel = flattenChildrenIntoAriaLabel(children); + const resolvedChildren = + typeof children === 'function' + ? children({ isActive: false, isTransitioning: false }) + : children; + const childrenAsAriaLabel = flattenChildrenIntoAriaLabel(resolvedChildren); const externalNotice = '- link opens in a new tab'; const ariaLabel = accessibleAriaLabel ? `${accessibleAriaLabel} ${shouldOpenInNewTab ? externalNotice : ''}` : `${childrenAsAriaLabel} ${shouldOpenInNewTab ? externalNotice : ''}`; - if (childrenContainsNoText(children) && !accessibleAriaLabel) { + if (childrenContainsNoText(resolvedChildren) && !accessibleAriaLabel) { // eslint-disable-next-line no-console console.error( 'Link component must have text content to be accessible to screen readers. Please provide an accessibleAriaLabel prop or text content.' @@ -127,9 +148,11 @@ export const Link = React.forwardRef( onClick={onClick} ref={ref} rel="noopener noreferrer" + style={style} target="_blank" + title={title} > - {children} + {resolvedChildren} {external && !hideIcon && ( ( className )} ref={ref} - to={to as string} + style={style} + title={title} + {...(to && !shouldOpenInNewTab ? { to: to as _LinkProps['to'] } : {})} /> ); } diff --git a/packages/manager/src/components/MaintenanceBanner/LinodeMaintenanceBanner.test.tsx b/packages/manager/src/components/MaintenanceBanner/LinodeMaintenanceBanner.test.tsx index 2bcd38a04a7..d649a7b51d7 100644 --- a/packages/manager/src/components/MaintenanceBanner/LinodeMaintenanceBanner.test.tsx +++ b/packages/manager/src/components/MaintenanceBanner/LinodeMaintenanceBanner.test.tsx @@ -36,7 +36,7 @@ describe('LinodeMaintenanceBanner', () => { type: 'reboot', entity: { type: 'linode', id: 2 }, reason: 'Another scheduled maintenance', - status: 'in-progress', + status: 'in_progress', description: 'scheduled', }), accountMaintenanceFactory.build({ @@ -71,7 +71,7 @@ describe('LinodeMaintenanceBanner', () => { type: 'reboot', entity: { type: 'linode', id: 2 }, reason: 'Another scheduled maintenance', - status: 'in-progress', + status: 'in_progress', description: 'scheduled', }), accountMaintenanceFactory.build({ diff --git a/packages/manager/src/components/MaintenanceBanner/LinodeMaintenanceBanner.tsx b/packages/manager/src/components/MaintenanceBanner/LinodeMaintenanceBanner.tsx index b9089b5d4d7..49461185ee5 100644 --- a/packages/manager/src/components/MaintenanceBanner/LinodeMaintenanceBanner.tsx +++ b/packages/manager/src/components/MaintenanceBanner/LinodeMaintenanceBanner.tsx @@ -38,27 +38,29 @@ export const LinodeMaintenanceBanner = ({ linodeId }: Props) => { Linode {linodeMaintenance.entity.label} {linodeMaintenance.description}{' '} maintenance {maintenanceTypeLabel} will begin{' '} - {maintenanceStartTime ? ( - - ({ - font: theme.font.bold, - })} - value={maintenanceStartTime} - />{' '} - at{' '} - ({ - font: theme.font.bold, - })} - value={maintenanceStartTime} - /> - - ) : ( - soon - )} + + {maintenanceStartTime ? ( + <> + ({ + font: theme.font.bold, + })} + value={maintenanceStartTime} + />{' '} + at{' '} + ({ + font: theme.font.bold, + })} + value={maintenanceStartTime} + /> + + ) : ( + 'soon' + )} + . For more details, view{' '} Account Maintenance. diff --git a/packages/manager/src/components/MaintenanceBanner/MaintenanceBannerV2.test.tsx b/packages/manager/src/components/MaintenanceBanner/MaintenanceBannerV2.test.tsx index aba6a8511b4..a29cb207b90 100644 --- a/packages/manager/src/components/MaintenanceBanner/MaintenanceBannerV2.test.tsx +++ b/packages/manager/src/components/MaintenanceBanner/MaintenanceBannerV2.test.tsx @@ -31,7 +31,7 @@ describe('MaintenanceBannerV2', () => { type: 'reboot', entity: { type: 'linode' }, reason: 'Another scheduled maintenance', - status: 'in-progress', + status: 'in_progress', description: 'scheduled', }), accountMaintenanceFactory.build({ diff --git a/packages/manager/src/components/MaintenancePolicySelect/MaintenancePolicySelect.tsx b/packages/manager/src/components/MaintenancePolicySelect/MaintenancePolicySelect.tsx index 3a3d2c775cf..fabb0ecc050 100644 --- a/packages/manager/src/components/MaintenancePolicySelect/MaintenancePolicySelect.tsx +++ b/packages/manager/src/components/MaintenancePolicySelect/MaintenancePolicySelect.tsx @@ -21,18 +21,22 @@ import { DefaultPolicyChip } from './DefaultPolicyChip'; import type { MaintenancePolicy } from '@linode/api-v4'; import type { TextFieldProps } from '@linode/ui'; -interface Props { +interface MaintenancePolicySelectProps { disabled?: boolean; + disabledReason?: string; errorText?: string; hideDefaultChip?: boolean; onChange: (policy: MaintenancePolicy) => void; textFieldProps?: Partial; - value?: 'linode/migrate' | 'linode/power_off_on'; + value?: string; } -export const MaintenancePolicySelect = (props: Props) => { +export const MaintenancePolicySelect = ( + props: MaintenancePolicySelectProps +) => { const { disabled, + disabledReason, errorText, onChange, value, @@ -109,18 +113,23 @@ export const MaintenancePolicySelect = (props: Props) => { ), }, - tooltipText: disabled ? ( - "You don't have permission to change this setting." - ) : ( - - - Migrate: {MIGRATE_TOOLTIP_TEXT} - - - Power Off / Power On: {POWER_OFF_TOOLTIP_TEXT} - - - ), + tooltipText: + disabled && textFieldProps?.tooltipText ? ( + textFieldProps?.tooltipText + ) : disabled && disabledReason ? ( + // Show tooltip for permission issues or other specific reasons + disabledReason + ) : disabled ? undefined : ( // Don't show tooltip when disabled + // Show informational tooltip when not disabled + + + Migrate: {MIGRATE_TOOLTIP_TEXT} + + + Power Off / Power On: {POWER_OFF_TOOLTIP_TEXT} + + + ), tooltipWidth: 410, ...textFieldProps, }} diff --git a/packages/manager/src/components/MaintenancePolicySelect/constants.ts b/packages/manager/src/components/MaintenancePolicySelect/constants.ts index 198693ce460..643fcf1ce9c 100644 --- a/packages/manager/src/components/MaintenancePolicySelect/constants.ts +++ b/packages/manager/src/components/MaintenancePolicySelect/constants.ts @@ -9,10 +9,10 @@ export const POWER_OFF_TOOLTIP_TEXT = export const MAINTENANCE_POLICY_TITLE = 'Host Maintenance Policy'; export const MAINTENANCE_POLICY_DESCRIPTION = - 'Select the preferred host maintenance policy for this Linode. During host maintenance events (such as host upgrades), this policy setting helps determine which maintenance method is performed.'; + 'Select the preferred maintenance policy for this Linode. During scheduled maintenance events (such as host upgrades), this policy setting determines the type of maintenance that is performed. The selected policy does not factor in during emergency maintenance.'; export const MAINTENANCE_POLICY_ACCOUNT_DESCRIPTION = - 'Select the preferred default host maintenance policy for newly deployed Linodes. During host maintenance events (such as host upgrades), this policy setting determines the type of migration that is performed. This preference can be changed when creating new Linodes or modifying existing Linodes.'; + 'Select the preferred default maintenance policy for newly deployed Linodes. During scheduled maintenance events (such as host upgrades), this policy setting determines the type of maintenance that is performed. This preference can be changed when creating new Linodes or modifying existing Linodes. The selected policy does not factor in during emergency maintenance.'; export const MAINTENANCE_POLICY_OPTION_DESCRIPTIONS: Record< MaintenancePolicySlug, @@ -30,6 +30,12 @@ export const MAINTENANCE_POLICY_LEARN_MORE_URL = export const MAINTENANCE_POLICY_NOT_AVAILABLE_IN_REGION_TEXT = 'Maintenance policy is not available in the selected region.'; +export const MAINTENANCE_POLICY_SELECT_REGION_TEXT = + 'Select a region to choose a maintenance policy.'; + +export const MAINTENANCE_POLICY_NOT_AVAILABLE_IN_REGION_TEXT_DETAILS = + 'Maintenance policy is not available in this current region.'; + export const GPU_PLAN_NOTICE = 'GPU plans do not support live migrations. Instead, when the migrate policy is selected, a warm migration is attempted first during maintenance events.'; diff --git a/packages/manager/src/components/OrderBy.tsx b/packages/manager/src/components/OrderBy.tsx index f0b786cedcd..1af3c5cd8a0 100644 --- a/packages/manager/src/components/OrderBy.tsx +++ b/packages/manager/src/components/OrderBy.tsx @@ -1,6 +1,5 @@ import { useMutatePreferences, usePreferences } from '@linode/queries'; import { - getQueryParamsFromQueryString, pathOr, sortByArrayLength, sortByNumber, @@ -8,10 +7,10 @@ import { splitAt, usePrevious, } from '@linode/utilities'; +import { useLocation, useNavigate, useSearch } from '@tanstack/react-router'; import { DateTime } from 'luxon'; import { equals, sort } from 'ramda'; import * as React from 'react'; -import { useHistory, useLocation } from 'react-router-dom'; import { debounce } from 'throttle-debounce'; import { sortByUTFDate } from 'src/utilities/sortByUTFDate'; @@ -159,13 +158,18 @@ export const OrderBy = (props: CombinedProps) => { ); const { mutateAsync: updatePreferences } = useMutatePreferences(); const location = useLocation(); - const history = useHistory(); - const params = getQueryParamsFromQueryString(location.search); + const navigate = useNavigate(); + const search = useSearch({ + strict: false, + }); + const onlySearch = Object.fromEntries( + Object.entries(search).filter(([key]) => key.startsWith('order')) + ) as Record; const initialValues = getInitialValuesFromUserPreferences( props.preferenceKey ?? '', sortPreferences ?? {}, - params, + onlySearch, props.orderBy, props.order ); @@ -223,7 +227,14 @@ export const OrderBy = (props: CombinedProps) => { setOrder(newOrder); // Update the URL query params so that the current sort is bookmark-able - history.replace({ search: `?order=${newOrder}&orderBy=${newOrderBy}` }); + navigate({ + to: location.pathname, + search: (prev: Record) => ({ + ...prev, + order: newOrder, + orderBy: newOrderBy, + }), + }); debouncedUpdateUserPreferences(newOrderBy, newOrder); }; diff --git a/packages/manager/src/components/PaymentMethodRow/DeletePaymentMethodDialog.test.tsx b/packages/manager/src/components/PaymentMethodRow/DeletePaymentMethodDialog.test.tsx index 01db716cfdc..758c91e2f5f 100644 --- a/packages/manager/src/components/PaymentMethodRow/DeletePaymentMethodDialog.test.tsx +++ b/packages/manager/src/components/PaymentMethodRow/DeletePaymentMethodDialog.test.tsx @@ -14,8 +14,26 @@ const props = { paymentMethod: undefined, }; +const queryMocks = vi.hoisted(() => ({ + userPermissions: vi.fn(() => ({ + data: { + delete_payment_method: false, + }, + })), +})); + +vi.mock('src/features/IAM/hooks/usePermissions', () => ({ + usePermissions: queryMocks.userPermissions, +})); + describe('Delete Payment Method Dialog', () => { it('renders the delete payment method dialog', () => { + queryMocks.userPermissions.mockReturnValue({ + data: { + delete_payment_method: true, + }, + }); + const screen = renderWithTheme(); const headerText = screen.getByText('Delete Payment Method'); @@ -31,6 +49,12 @@ describe('Delete Payment Method Dialog', () => { }); it('calls the corresponding functions when buttons are clicked', () => { + queryMocks.userPermissions.mockReturnValue({ + data: { + delete_payment_method: true, + }, + }); + const screen = renderWithTheme(); const deleteButton = screen.getByText('Delete'); @@ -43,4 +67,17 @@ describe('Delete Payment Method Dialog', () => { fireEvent.click(cancelButton); expect(props.onClose).toHaveBeenCalled(); }); + + it('disables the delete button if the user does not have the delete_payment_method permission', () => { + queryMocks.userPermissions.mockReturnValue({ + data: { + delete_payment_method: false, + }, + }); + + const screen = renderWithTheme(); + + const deleteButton = screen.getByText('Delete'); + expect(deleteButton).toBeDisabled(); + }); }); diff --git a/packages/manager/src/components/PaymentMethodRow/DeletePaymentMethodDialog.tsx b/packages/manager/src/components/PaymentMethodRow/DeletePaymentMethodDialog.tsx index b513bfc088b..d818eee0f1f 100644 --- a/packages/manager/src/components/PaymentMethodRow/DeletePaymentMethodDialog.tsx +++ b/packages/manager/src/components/PaymentMethodRow/DeletePaymentMethodDialog.tsx @@ -5,6 +5,7 @@ import { makeStyles } from 'tss-react/mui'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; import CreditCard from 'src/features/Billing/BillingPanels/BillingSummary/PaymentDrawer/CreditCard'; +import { usePermissions } from 'src/features/IAM/hooks/usePermissions'; import { ThirdPartyPayment } from './ThirdPartyPayment'; @@ -35,11 +36,16 @@ export const DeletePaymentMethodDialog = React.memo((props: Props) => { const { error, loading, onClose, onDelete, open, paymentMethod } = props; const { classes } = useStyles(); + const { data: permissions } = usePermissions('account', [ + 'delete_payment_method', + ]); + const actions = ( ({ userPermissions: vi.fn(() => ({ data: { make_billing_payment: false, - update_account: false, + set_default_payment_method: false, + delete_payment_method: false, }, })), })); @@ -148,7 +149,8 @@ describe('Payment Method Row', () => { queryMocks.userPermissions.mockReturnValue({ data: { make_billing_payment: false, - update_account: true, + set_default_payment_method: false, + delete_payment_method: true, }, }); const { getByLabelText, getByText } = renderWithTheme( @@ -174,7 +176,8 @@ describe('Payment Method Row', () => { queryMocks.userPermissions.mockReturnValue({ data: { make_billing_payment: true, - update_account: true, + set_default_payment_method: true, + delete_payment_method: false, }, }); const paymentMethod = paymentMethodFactory.build({ @@ -205,7 +208,8 @@ describe('Payment Method Row', () => { queryMocks.userPermissions.mockReturnValue({ data: { make_billing_payment: false, - update_account: false, + set_default_payment_method: false, + delete_payment_method: false, }, }); const { getByLabelText, getByText } = renderWithTheme( @@ -231,7 +235,8 @@ describe('Payment Method Row', () => { queryMocks.userPermissions.mockReturnValue({ data: { make_billing_payment: true, - update_account: false, + set_default_payment_method: false, + delete_payment_method: false, }, }); const { getByLabelText, getByText } = renderWithTheme( diff --git a/packages/manager/src/components/PaymentMethodRow/PaymentMethodRow.tsx b/packages/manager/src/components/PaymentMethodRow/PaymentMethodRow.tsx index 4a2f6fe98aa..f01ad45292e 100644 --- a/packages/manager/src/components/PaymentMethodRow/PaymentMethodRow.tsx +++ b/packages/manager/src/components/PaymentMethodRow/PaymentMethodRow.tsx @@ -8,6 +8,7 @@ import * as React from 'react'; import { ActionMenu } from 'src/components/ActionMenu/ActionMenu'; import CreditCard from 'src/features/Billing/BillingPanels/BillingSummary/PaymentDrawer/CreditCard'; import { usePermissions } from 'src/features/IAM/hooks/usePermissions'; +import { useFlags } from 'src/hooks/useFlags'; import { ThirdPartyPayment } from './ThirdPartyPayment'; @@ -39,13 +40,15 @@ export const PaymentMethodRow = (props: Props) => { const { is_default, type } = paymentMethod; const { enqueueSnackbar } = useSnackbar(); const navigate = useNavigate(); + const flags = useFlags(); const { mutateAsync: makePaymentMethodDefault } = useMakeDefaultPaymentMethodMutation(props.paymentMethod.id); const { data: permissions } = usePermissions('account', [ 'make_billing_payment', - 'update_account', + 'set_default_payment_method', + 'delete_payment_method', ]); const makeDefault = () => { @@ -62,7 +65,7 @@ export const PaymentMethodRow = (props: Props) => { disabled: isChildUser || !permissions.make_billing_payment, onClick: () => { navigate({ - to: '/account/billing', + to: flags?.iamRbacPrimaryNavChanges ? '/billing' : '/account/billing', search: (prev) => ({ ...prev, action: 'make-payment', @@ -74,7 +77,9 @@ export const PaymentMethodRow = (props: Props) => { }, { disabled: - isChildUser || !permissions.update_account || paymentMethod.is_default, + isChildUser || + !permissions.set_default_payment_method || + paymentMethod.is_default, onClick: makeDefault, title: 'Make Default', tooltip: paymentMethod.is_default @@ -83,7 +88,9 @@ export const PaymentMethodRow = (props: Props) => { }, { disabled: - isChildUser || !permissions.update_account || paymentMethod.is_default, + isChildUser || + !permissions.delete_payment_method || + paymentMethod.is_default, onClick: onDelete, title: 'Delete', tooltip: paymentMethod.is_default diff --git a/packages/manager/src/components/PlatformMaintenanceBanner/LinodePlatformMaintenanceBanner.test.tsx b/packages/manager/src/components/PlatformMaintenanceBanner/LinodePlatformMaintenanceBanner.test.tsx index cd0be38fa20..5e2c5c58f8c 100644 --- a/packages/manager/src/components/PlatformMaintenanceBanner/LinodePlatformMaintenanceBanner.test.tsx +++ b/packages/manager/src/components/PlatformMaintenanceBanner/LinodePlatformMaintenanceBanner.test.tsx @@ -10,7 +10,6 @@ const queryMocks = vi.hoisted(() => ({ useNotificationsQuery: vi.fn().mockReturnValue({}), useAllAccountMaintenanceQuery: vi.fn().mockReturnValue({}), useLinodeQuery: vi.fn().mockReturnValue({}), - useLocation: vi.fn(), })); vi.mock('@linode/queries', async () => { @@ -21,17 +20,8 @@ vi.mock('@linode/queries', async () => { }; }); -vi.mock('react-router-dom', async () => { - const actual = await vi.importActual('react-router-dom'); - return { - ...actual, - useLocation: queryMocks.useLocation, - }; -}); - beforeEach(() => { vi.stubEnv('TZ', 'UTC'); - queryMocks.useLocation.mockReturnValue({ pathname: '/linodes' }); }); describe('LinodePlatformMaintenanceBanner', () => { @@ -161,9 +151,6 @@ describe('LinodePlatformMaintenanceBanner', () => { }), }); - // Mock location to be on a different page - queryMocks.useLocation.mockReturnValue({ pathname: '/linodes' }); - const { getByRole } = renderWithTheme( ); @@ -200,11 +187,11 @@ describe('LinodePlatformMaintenanceBanner', () => { }), }); - // Mock location to be on the linode detail page - queryMocks.useLocation.mockReturnValue({ pathname: '/linodes/123' }); - const { container, queryByRole } = renderWithTheme( - + , + { + initialRoute: '/linodes/123', + } ); // Should show the label as plain text within the Typography component diff --git a/packages/manager/src/components/PlatformMaintenanceBanner/LinodePlatformMaintenanceBanner.tsx b/packages/manager/src/components/PlatformMaintenanceBanner/LinodePlatformMaintenanceBanner.tsx index 0d317de4d97..9963f22a970 100644 --- a/packages/manager/src/components/PlatformMaintenanceBanner/LinodePlatformMaintenanceBanner.tsx +++ b/packages/manager/src/components/PlatformMaintenanceBanner/LinodePlatformMaintenanceBanner.tsx @@ -1,8 +1,8 @@ import { useLinodeQuery } from '@linode/queries'; import { Notice } from '@linode/ui'; import { Box, Button, Stack, Typography } from '@linode/ui'; +import { useLocation } from '@tanstack/react-router'; import React from 'react'; -import { useLocation } from 'react-router-dom'; import { PowerActionsDialog } from 'src/features/Linodes/PowerActionsDialogOrDrawer'; import { usePlatformMaintenance } from 'src/hooks/usePlatformMaintenance'; diff --git a/packages/manager/src/components/PrimaryNav/PrimaryLink.tsx b/packages/manager/src/components/PrimaryNav/PrimaryLink.tsx index 2a85aec40ac..3d7afbb9783 100644 --- a/packages/manager/src/components/PrimaryNav/PrimaryLink.tsx +++ b/packages/manager/src/components/PrimaryNav/PrimaryLink.tsx @@ -4,20 +4,20 @@ import * as React from 'react'; import { StyledActiveLink, StyledPrimaryLinkBox } from './PrimaryNav.styles'; import type { NavEntity } from './PrimaryNav'; +import type { LinkProps } from '@tanstack/react-router'; import type { CreateEntity } from 'src/features/TopMenu/CreateMenu/CreateMenu'; export interface BaseNavLink { attr?: { [key: string]: unknown }; display: CreateEntity | NavEntity; hide?: boolean; - href: string; + to: LinkProps['to']; } export interface PrimaryLink extends BaseNavLink { - activeLinks?: Array; betaChipClassName?: string; isBeta?: boolean; - onClick?: (e: React.ChangeEvent) => void; + onClick?: (e: React.MouseEvent) => void; } interface PrimaryLinkProps extends PrimaryLink { @@ -32,7 +32,7 @@ const PrimaryLink = React.memo((props: PrimaryLinkProps) => { betaChipClassName, closeMenu, display, - href, + to, isActiveLink, isBeta, isCollapsed, @@ -41,13 +41,13 @@ const PrimaryLink = React.memo((props: PrimaryLinkProps) => { return ( ) => { + onClick={(e: React.MouseEvent) => { closeMenu(); if (onClick) { onClick(e); } }} - to={href} + to={to} {...attr} aria-current={isActiveLink} data-testid={`menu-item-${display}`} diff --git a/packages/manager/src/components/PrimaryNav/PrimaryNav.test.tsx b/packages/manager/src/components/PrimaryNav/PrimaryNav.test.tsx index cfa12deb62d..64e6aafb3cf 100644 --- a/packages/manager/src/components/PrimaryNav/PrimaryNav.test.tsx +++ b/packages/manager/src/components/PrimaryNav/PrimaryNav.test.tsx @@ -1,4 +1,5 @@ import { queryClientFactory } from '@linode/queries'; +import { screen, waitFor } from '@testing-library/react'; import * as React from 'react'; import { accountFactory } from 'src/factories'; @@ -331,4 +332,120 @@ describe('PrimaryNav', () => { expect(monitorAlertsDisplayItem).toBeNull(); expect(betaChip).toBeVisible(); }); + + it('should show Administration links if iamRbacPrimaryNavChanges flag is enabled', async () => { + const flags: Partial = { + iamRbacPrimaryNavChanges: true, + iam: { + beta: true, + enabled: true, + }, + limitsEvolution: { + enabled: true, + requestForIncreaseDisabledForAll: true, + requestForIncreaseDisabledForInternalAccountsOnly: true, + }, + }; + + queryMocks.useIsIAMEnabled.mockReturnValue({ + isIAMBeta: true, + isIAMEnabled: true, + }); + + renderWithTheme(, { + flags, + }); + + const adminLink = screen.getByRole('button', { name: 'Administration' }); + expect(adminLink).toBeInTheDocument(); + + await waitFor(() => { + expect(screen.getByRole('link', { name: 'Billing' })).toBeInTheDocument(); + expect( + screen.getByRole('link', { name: 'Identity & Access' }) + ).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Quotas' })).toBeInTheDocument(); + expect( + screen.getByRole('link', { name: 'Login History' }) + ).toBeInTheDocument(); + expect( + screen.getByRole('link', { name: 'Service Transfers' }) + ).toBeInTheDocument(); + expect( + screen.getByRole('link', { name: 'Maintenance' }) + ).toBeInTheDocument(); + expect( + screen.getByRole('link', { name: 'Settings' }) + ).toBeInTheDocument(); + expect(screen.queryByRole('link', { name: 'Account' })).toBeNull(); + }); + }); + + it('should hide Identity & Access link for non beta users', async () => { + const flags: Partial = { + iamRbacPrimaryNavChanges: true, + iam: { + beta: true, + enabled: false, + }, + }; + + queryMocks.useIsIAMEnabled.mockReturnValue({ + isIAMBeta: true, + isIAMEnabled: false, + }); + + renderWithTheme(, { + flags, + }); + + const adminLink = screen.getByRole('button', { name: 'Administration' }); + expect(adminLink).toBeInTheDocument(); + + await waitFor(() => { + expect( + screen.queryByRole('link', { name: 'Identity & Access' }) + ).toBeNull(); + }); + }); + + it('should show Account link and hide Administration if iamRbacPrimaryNavChanges flag is disabled', async () => { + const flags: Partial = { + iamRbacPrimaryNavChanges: false, + iam: { + beta: true, + enabled: true, + }, + }; + + queryMocks.useIsIAMEnabled.mockReturnValue({ + isIAMBeta: true, + isIAMEnabled: true, + }); + + renderWithTheme(, { + flags, + }); + + const adminLink = screen.queryByRole('button', { name: 'Administration' }); + expect(adminLink).toBeNull(); + + await waitFor(() => { + expect(screen.queryByRole('link', { name: 'Billing' })).toBeNull(); + expect(screen.queryByRole('link', { name: 'Quotas' })).toBeNull(); + expect(screen.queryByRole('link', { name: 'Login History' })).toBeNull(); + expect( + screen.queryByRole('link', { name: 'Service Transfers' }) + ).toBeNull(); + expect(screen.queryByRole('link', { name: 'Maintenance' })).toBeNull(); + expect(screen.queryByRole('link', { name: 'Settings' })).toBeNull(); + expect( + screen.queryByRole('link', { name: 'Account' }) + ).toBeInTheDocument(); + expect( + screen.queryByRole('link', { name: 'Identity & Access' }) + ).toBeInTheDocument(); + expect(screen.queryByRole('link', { name: 'Settings' })).toBeNull(); + }); + }); }); diff --git a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx index 1ad111ebac0..9fb20619667 100644 --- a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx +++ b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx @@ -4,12 +4,12 @@ import { usePreferences, } from '@linode/queries'; import { Box } from '@linode/ui'; +import { useLocation } from '@tanstack/react-router'; import * as React from 'react'; -import { useLocation } from 'react-router-dom'; import Compute from 'src/assets/icons/entityIcons/compute.svg'; +import CoreUser from 'src/assets/icons/entityIcons/coreuser.svg'; import Database from 'src/assets/icons/entityIcons/database.svg'; -import IAM from 'src/assets/icons/entityIcons/iam.svg'; import Monitor from 'src/assets/icons/entityIcons/monitor.svg'; import Networking from 'src/assets/icons/entityIcons/networking.svg'; import Storage from 'src/assets/icons/entityIcons/storage.svg'; @@ -35,6 +35,7 @@ export type NavEntity = | 'Account' | 'Alerts' | 'Betas' + | 'Billing' | 'Cloud Load Balancers' | 'Dashboard' | 'Databases' @@ -46,7 +47,9 @@ export type NavEntity = | 'Images' | 'Kubernetes' | 'Linodes' + | 'Login History' | 'Longview' + | 'Maintenance' | 'Managed' | 'Marketplace' | 'Metrics' @@ -54,11 +57,16 @@ export type NavEntity = | 'NodeBalancers' | 'Object Storage' | 'Placement Groups' + | 'Quotas' + | 'Service Transfers' + | 'Settings' | 'StackScripts' + | 'Users & Grants' | 'Volumes' | 'VPC'; export type ProductFamily = + | 'Administration' | 'Compute' | 'Databases' | 'Monitor' @@ -98,6 +106,8 @@ export const PrimaryNav = (props: PrimaryNavProps) => { flags.aclpAlerting?.recentActivity || flags.aclpAlerting?.notificationChannels); + const { iamRbacPrimaryNavChanges, limitsEvolution } = flags; + const { isPlacementGroupsEnabled } = useIsPlacementGroupsEnabled(); const { isDatabasesEnabled, isDatabasesV2Beta } = useIsDatabasesEnabled(); @@ -113,169 +123,200 @@ export const PrimaryNav = (props: PrimaryNavProps) => { const productFamilyLinkGroups: ProductFamilyLinkGroup[] = React.useMemo( - () => [ - { - links: [], - }, - { - icon: , - links: [ - { - activeLinks: [ - '/managed', - '/managed/summary', - '/managed/monitors', - '/managed/ssh-access', - '/managed/credentials', - '/managed/contacts', - ], - display: 'Managed', - hide: !isManaged, - href: '/managed', - }, - { - activeLinks: ['/linodes', '/linodes/create'], - display: 'Linodes', - href: '/linodes', - }, - { - activeLinks: [ - '/images/create/create-image', - '/images/create/upload-image', - ], - display: 'Images', - href: '/images', - }, - { - activeLinks: ['/kubernetes/create'], - display: 'Kubernetes', - href: '/kubernetes/clusters', - }, - { - display: 'StackScripts', - href: '/stackscripts', - }, - { - betaChipClassName: 'beta-chip-placement-groups', - display: 'Placement Groups', - hide: !isPlacementGroupsEnabled, - href: '/placement-groups', - }, - { - attr: { 'data-qa-one-click-nav-btn': true }, - display: 'Marketplace', - href: '/linodes/create/marketplace', - }, - ], - name: 'Compute', - }, - { - icon: , - links: [ - { - activeLinks: [ - '/object-storage/buckets', - '/object-storage/access-keys', - ], - display: 'Object Storage', - href: '/object-storage/buckets', - }, - { - display: 'Volumes', - href: '/volumes', - }, - ], - name: 'Storage', - }, - { - icon: , - links: [ - { - display: 'VPC', - href: '/vpcs', - }, - { - display: 'Firewalls', - href: '/firewalls', - }, - { - display: 'NodeBalancers', - href: '/nodebalancers', - }, - { - display: 'Domains', - href: '/domains', - }, - ], - name: 'Networking', - }, - { - icon: , - links: [ - { - display: 'Databases', - hide: !isDatabasesEnabled, - href: '/databases', - isBeta: isDatabasesV2Beta, - }, - ], - name: 'Databases', - }, - { - icon: , - links: [ - { - display: 'Metrics', - hide: !isACLPEnabled, - href: '/metrics', - isBeta: flags.aclp?.beta, - }, - { - display: 'Alerts', - hide: !isAlertsEnabled, - href: '/alerts', - isBeta: flags.aclp?.beta, - }, - { - display: 'Longview', - href: '/longview', - }, - { - display: 'DataStream', - hide: !flags.aclpLogs?.enabled, - href: '/datastream', - isBeta: flags.aclpLogs?.beta, - }, - ], - name: 'Monitor', - }, - { - icon: , - links: [ - { - display: 'Betas', - hide: !flags.selfServeBetas, - href: '/betas', - }, - { - display: 'Identity & Access', - hide: !isIAMEnabled, - href: '/iam', - icon: , - isBeta: isIAMBeta, - }, - { - display: 'Account', - href: '/account', - }, - { - display: 'Help & Support', - href: '/support', - }, - ], - name: 'More', - }, - ], + () => { + const groups: ProductFamilyLinkGroup[] = [ + { + links: [], + }, + { + icon: , + links: [ + { + display: 'Managed', + hide: !isManaged, + to: '/managed', + }, + { + display: 'Linodes', + to: '/linodes', + }, + { + display: 'Images', + to: '/images', + }, + { + display: 'Kubernetes', + to: '/kubernetes/clusters', + }, + { + display: 'StackScripts', + to: '/stackscripts', + }, + { + betaChipClassName: 'beta-chip-placement-groups', + display: 'Placement Groups', + hide: !isPlacementGroupsEnabled, + to: '/placement-groups', + }, + { + attr: { 'data-qa-one-click-nav-btn': true }, + display: 'Marketplace', + to: '/linodes/create/marketplace', + }, + ], + name: 'Compute', + }, + { + icon: , + links: [ + { + display: 'Object Storage', + to: '/object-storage/buckets', + }, + { + display: 'Volumes', + to: '/volumes', + }, + ], + name: 'Storage', + }, + { + icon: , + links: [ + { + display: 'VPC', + to: '/vpcs', + }, + { + display: 'Firewalls', + to: '/firewalls', + }, + { + display: 'NodeBalancers', + to: '/nodebalancers', + }, + { + display: 'Domains', + to: '/domains', + }, + ], + name: 'Networking', + }, + { + icon: , + links: [ + { + display: 'Databases', + hide: !isDatabasesEnabled, + to: '/databases', + isBeta: isDatabasesV2Beta, + }, + ], + name: 'Databases', + }, + { + icon: , + links: [ + { + display: 'Metrics', + hide: !isACLPEnabled, + to: '/metrics', + isBeta: flags.aclp?.beta, + }, + { + display: 'Alerts', + hide: !isAlertsEnabled, + to: '/alerts', + isBeta: flags.aclp?.beta, + }, + { + display: 'Longview', + to: '/longview', + }, + { + display: 'DataStream', + hide: !flags.aclpLogs?.enabled, + to: '/datastream', + isBeta: flags.aclpLogs?.beta, + }, + ], + name: 'Monitor', + }, + { + icon: , + links: [ + { + display: 'Betas', + hide: !flags.selfServeBetas, + to: '/betas', + }, + { + display: 'Identity & Access', + hide: !isIAMEnabled || iamRbacPrimaryNavChanges, + to: '/iam', + isBeta: isIAMBeta, + }, + { + display: 'Account', + hide: iamRbacPrimaryNavChanges, + to: '/account', + }, + { + display: 'Help & Support', + to: '/support', + }, + ], + name: 'More', + }, + ]; + + if (iamRbacPrimaryNavChanges) { + groups.splice(groups.length - 1, 0, { + icon: , + links: [ + { + display: 'Billing', + to: '/billing', + }, + { + display: 'Users & Grants', + hide: isIAMEnabled, + to: '/users', + }, + { + display: 'Identity & Access', + hide: !isIAMEnabled, + to: '/iam', + isBeta: isIAMBeta, + }, + { + display: 'Quotas', + hide: !limitsEvolution?.enabled, + to: '/quotas', + }, + { + display: 'Login History', + to: '/login-history', + }, + { + display: 'Service Transfers', + to: '/service-transfers', + }, + { + display: 'Maintenance', + to: '/maintenance', + }, + { + display: 'Settings', + to: '/settings', + }, + ], + name: 'Administration', + }); + } + + return groups; + }, // eslint-disable-next-line react-hooks/exhaustive-deps [ isDatabasesEnabled, @@ -285,6 +326,8 @@ export const PrimaryNav = (props: PrimaryNavProps) => { isACLPEnabled, isIAMBeta, isIAMEnabled, + iamRbacPrimaryNavChanges, + limitsEvolution, ] ); @@ -364,12 +407,7 @@ export const PrimaryNav = (props: PrimaryNavProps) => { const filteredLinks = group.links.filter((link) => !link.hide); return filteredLinks.some((link) => - linkIsActive( - link.href, - location.search, - location.pathname, - link.activeLinks - ) + linkIsActive(location.pathname, link.to) ); }); @@ -433,12 +471,7 @@ export const PrimaryNav = (props: PrimaryNavProps) => { const PrimaryLinks = filteredLinks.map((link) => { const isActiveLink = Boolean( - linkIsActive( - link.href, - location.search, - location.pathname, - link.activeLinks - ) + linkIsActive(location.pathname, link.to) ); if (isActiveLink) { diff --git a/packages/manager/src/components/PrimaryNav/utils.ts b/packages/manager/src/components/PrimaryNav/utils.ts index 1c5a6b58d6f..2efa0bd157e 100644 --- a/packages/manager/src/components/PrimaryNav/utils.ts +++ b/packages/manager/src/components/PrimaryNav/utils.ts @@ -1,26 +1,28 @@ import React from 'react'; import { TOPMENU_HEIGHT } from 'src/features/TopMenu/constants'; -import { isPathOneOf } from 'src/utilities/routing/isPathOneOf'; - -export const linkIsActive = ( - href: string, - locationSearch: string, - locationPathname: string, - activeLinks: Array = [] -) => { - const currentlyOnOneClickTab = locationSearch.match(/one-click/gi); - const isOneClickTab = href.match(/one-click/gi); - - /** - * mark as active if the tab is "one click" - * Other create tabs default back to Linodes active tabs - */ - if (currentlyOnOneClickTab) { - return isOneClickTab; + +import type { LinkProps } from '@tanstack/react-router'; + +export const linkIsActive = (locationPathname: string, to: LinkProps['to']) => { + const marketPlacePath = '/linodes/create/marketplace'; + const currentlyOnOneClickTab = locationPathname === marketPlacePath; + const isOneClickTab = to?.match(marketPlacePath); + + // Special handling for marketplace tab + if (currentlyOnOneClickTab || isOneClickTab) { + return currentlyOnOneClickTab && isOneClickTab; + } + + if (to === locationPathname) { + return true; + } + + if (locationPathname.startsWith(to + '/')) { + return true; } - return isPathOneOf([href, ...activeLinks], locationPathname); + return false; }; /** diff --git a/packages/manager/src/components/Prompt/Prompt.tsx b/packages/manager/src/components/Prompt/Prompt.tsx deleted file mode 100644 index 7f35c1813cc..00000000000 --- a/packages/manager/src/components/Prompt/Prompt.tsx +++ /dev/null @@ -1,119 +0,0 @@ -/** - * The component is used to prevent transitions when a user has unsaved changes. - * Internal routes can be prevented using custom components (as render props). Prevention of - * external route changes (and closing of tabs/windows) is achieved by using an event listener on - * "beforeunload". The browser controls this prompt, and it is not possible to customize it. Pass - * in the `confirmWhenLeaving` prop if this behavior is desired. - * - * Example usage: - * - * ```typescript - * return ( - * - * (({ isModalOpen, handleCancel, handleConfirm }) => { - * - * }) - * - * ); - * ``` - */ - -import * as React from 'react'; -import { Prompt as ReactRouterPrompt, useHistory } from 'react-router-dom'; -import type { - PromptProps as ReactRouterPromptProps, - useLocation, -} from 'react-router-dom'; - -interface ChildrenProps { - handleCancel: () => void; - handleConfirm: () => void; - isModalOpen: boolean; -} - -interface PromptProps { - children: (props: ChildrenProps) => React.ReactNode; - confirmWhenLeaving?: boolean; - onConfirm?: (path: string) => void; - when: boolean; -} - -// See: https://developer.mozilla.org/en-US/docs/Web/API/WindowEventHandlers/onbeforeunload -const handleBeforeUnload = (e: BeforeUnloadEvent) => { - // Prevent the unload event. - e.preventDefault(); - // Chrome requires returnValue to be set to a string. - e.returnValue = ''; -}; - -export const Prompt = React.memo((props: PromptProps) => { - const history = useHistory(); - - React.useEffect(() => { - if (!props.when || !props.confirmWhenLeaving) { - return; - } - - window.addEventListener('beforeunload', handleBeforeUnload); - return () => window.removeEventListener('beforeunload', handleBeforeUnload); - }, [props.when, props.confirmWhenLeaving]); - - // Whether or not the user has confirmed navigation. - const confirmedNav = React.useRef(false); - - // The location the user is navigating to. - const [nextLocation, setNextLocation] = React.useState>(null); - - const [isModalOpen, setIsModalOpen] = React.useState(false); - - const handleCancel = () => setIsModalOpen(false); - - const handleConfirm = () => { - setIsModalOpen(false); - - if (!nextLocation) { - return; - } - - // Set confirmedNav to `true`, which will allow navigation in `handleNavigation()`. - confirmedNav.current = true; - - window.removeEventListener('beforeunload', handleBeforeUnload); - - if (props.onConfirm) { - return props.onConfirm(nextLocation.pathname); - } - - history.push(nextLocation.pathname); - }; - - const handleNavigation: ReactRouterPromptProps['message'] = (location) => { - if (location.pathname === history.location.pathname) { - // Sorting order changes affect the search portion of the URL. - // The path is the same though, so the user isn't actually navigating away. - return true; - } - // If this user hasn't yet confirmed navigation, present a confirmation modal. - if (!confirmedNav.current) { - setIsModalOpen(true); - // We need to set the requested location as well. - setNextLocation(location); - return false; - } - // The user has confirmed navigation, so we allow it. - return true; - }; - - return ( - <> - - {props.children({ handleCancel, handleConfirm, isModalOpen })} - - ); -}); diff --git a/packages/manager/src/components/SelectableTableRow/SelectableTableRow.tsx b/packages/manager/src/components/SelectableTableRow/SelectableTableRow.tsx index c1b861cd798..82110b828d8 100644 --- a/packages/manager/src/components/SelectableTableRow/SelectableTableRow.tsx +++ b/packages/manager/src/components/SelectableTableRow/SelectableTableRow.tsx @@ -12,6 +12,14 @@ interface SelectableTableRowProps { * This should be an array of JSX elements. */ children: JSX.Element[]; + /** + * An optional className to apply custom styles to the row. + */ + className?: string; + /** + * A boolean indicating whether the row is currently disabled or not. + */ + disabled?: boolean; /** * A function to handle the toggle of the row's checked state. * This function will be called when the row is clicked to select or deselect it. @@ -25,10 +33,11 @@ interface SelectableTableRowProps { export const SelectableTableRow = React.memo( (props: SelectableTableRowProps) => { - const { handleToggleCheck, isChecked } = props; + const { handleToggleCheck, isChecked, disabled, className } = props; return ( ({ '& td': { padding: `0 ${theme.tokens.spacing.S12}`, @@ -38,6 +47,7 @@ export const SelectableTableRow = React.memo( { const { data: profile } = useProfile(); const theme = useTheme(); - const history = useHistory(); + const navigate = useNavigate(); const { data: imagesData } = useAllImagesQuery( {}, @@ -177,7 +177,10 @@ export const StackScript = React.memo((props: StackScriptProps) => { buttonType="secondary" className={classes.editBtn} onClick={() => { - history.push(`/stackscripts/${stackscriptId}/edit`); + navigate({ + to: '/stackscripts/$id/edit', + params: { id: stackscriptId }, + }); }} > Edit diff --git a/packages/manager/src/components/SupportLink/SupportLink.tsx b/packages/manager/src/components/SupportLink/SupportLink.tsx index 0bc52b417f2..fcf8174463c 100644 --- a/packages/manager/src/components/SupportLink/SupportLink.tsx +++ b/packages/manager/src/components/SupportLink/SupportLink.tsx @@ -1,7 +1,6 @@ import * as React from 'react'; -// eslint-disable-next-line no-restricted-imports -import { Link } from 'react-router-dom'; -import type { LinkProps } from 'react-router-dom'; + +import { Link } from 'src/components/Link'; import type { EntityType, @@ -13,7 +12,7 @@ export interface SupportLinkProps { description?: string; entity?: EntityForTicketDetails; formPayloadValues?: FormPayloadValues; - onClick?: LinkProps['onClick']; + onClick?: () => void; text: string; ticketType?: TicketType; title?: string; @@ -38,16 +37,20 @@ const SupportLink = (props: SupportLinkProps) => { return ( { + return { + ...prev, description, entity, formPayloadValues, ticketType, title, - }, + }; }} + to="/support/tickets/open" > {text} diff --git a/packages/manager/src/components/Tabs/TabLinkList.test.tsx b/packages/manager/src/components/Tabs/TabLinkList.test.tsx index a162772cc8d..a9d133f878d 100644 --- a/packages/manager/src/components/Tabs/TabLinkList.test.tsx +++ b/packages/manager/src/components/Tabs/TabLinkList.test.tsx @@ -23,9 +23,7 @@ describe('TabLinkList', () => { , { initialRoute: '/tab-1', - MemoryRouter: { - initialEntries: ['/tab-1'], - }, + initialEntries: ['/tab-1'], } ) ); @@ -42,9 +40,7 @@ describe('TabLinkList', () => { , { initialRoute: '/tab-1', - MemoryRouter: { - initialEntries: ['/tab-1'], - }, + initialEntries: ['/tab-1'], } ) ); @@ -63,9 +59,7 @@ describe('TabLinkList', () => { , { initialRoute: '/tab-1', - MemoryRouter: { - initialEntries: ['/tab-1'], - }, + initialEntries: ['/tab-1'], } ) ); diff --git a/packages/manager/src/components/Tabs/TabList.test.tsx b/packages/manager/src/components/Tabs/TabList.test.tsx index 78589f65116..7d14b3bb834 100644 --- a/packages/manager/src/components/Tabs/TabList.test.tsx +++ b/packages/manager/src/components/Tabs/TabList.test.tsx @@ -16,9 +16,7 @@ describe('TabList component', () => { , { initialRoute: '/tab-1', - MemoryRouter: { - initialEntries: ['/tab-1'], - }, + initialEntries: ['/tab-1'], } ) ); @@ -34,9 +32,7 @@ describe('TabList component', () => { , { initialRoute: '/tab-1', - MemoryRouter: { - initialEntries: ['/tab-1'], - }, + initialEntries: ['/tab-1'], } ) ); @@ -56,9 +52,7 @@ describe('TabList component', () => { , { initialRoute: '/tab-1', - MemoryRouter: { - initialEntries: ['/tab-1'], - }, + initialEntries: ['/tab-1'], } ) ); diff --git a/packages/manager/src/components/Tabs/TanStackTabLinkList.tsx b/packages/manager/src/components/Tabs/TanStackTabLinkList.tsx index f337bd17c12..5b5f8d582c4 100644 --- a/packages/manager/src/components/Tabs/TanStackTabLinkList.tsx +++ b/packages/manager/src/components/Tabs/TanStackTabLinkList.tsx @@ -1,6 +1,6 @@ -import { Link as TanstackLink } from '@tanstack/react-router'; import * as React from 'react'; +import { Link as TanstackLink } from 'src/components/Link'; import { Tab } from 'src/components/Tabs/Tab'; import { TabList } from 'src/components/Tabs/TabList'; diff --git a/packages/manager/src/components/Tag/Tag.tsx b/packages/manager/src/components/Tag/Tag.tsx index 39458d4cc17..0009b194386 100644 --- a/packages/manager/src/components/Tag/Tag.tsx +++ b/packages/manager/src/components/Tag/Tag.tsx @@ -1,8 +1,7 @@ import { CloseIcon } from '@linode/ui'; import { truncateEnd } from '@linode/utilities'; +import { useNavigate } from '@tanstack/react-router'; import * as React from 'react'; -// eslint-disable-next-line no-restricted-imports -import { useHistory } from 'react-router-dom'; import { StyledChip, StyledDeleteButton } from './Tag.styles'; @@ -51,7 +50,7 @@ export const Tag = (props: TagProps) => { ...chipProps } = props; - const history = useHistory(); + const navigate = useNavigate(); const handleClick = (e: React.MouseEvent) => { e.preventDefault(); @@ -59,7 +58,10 @@ export const Tag = (props: TagProps) => { if (closeMenu) { closeMenu(); } - history.push(`/search?query=tag:${label}`); + navigate({ + to: '/search', + search: { query: `tag:${label}` }, + }); }; // If maxLength is set, truncate display to that length. diff --git a/packages/manager/src/constants.ts b/packages/manager/src/constants.ts index 3fdc894ec3b..173fc717805 100644 --- a/packages/manager/src/constants.ts +++ b/packages/manager/src/constants.ts @@ -286,3 +286,7 @@ export const DISALLOWED_IMAGE_REGIONS = [ 'sg-sin-2', 'jp-tyo-3', ]; + +// Default tooltip text for actions without permission +export const NO_PERMISSION_TOOLTIP_TEXT = + 'You do not have permission to perform this action.'; diff --git a/packages/manager/src/dev-tools/FeatureFlagTool.tsx b/packages/manager/src/dev-tools/FeatureFlagTool.tsx index 4c03dbf387b..3331ec02c96 100644 --- a/packages/manager/src/dev-tools/FeatureFlagTool.tsx +++ b/packages/manager/src/dev-tools/FeatureFlagTool.tsx @@ -21,7 +21,7 @@ const MOCK_FEATURE_FLAGS_STORAGE_KEY = 'devTools/mock-feature-flags'; const options: { flag: keyof Flags; label: string }[] = [ { flag: 'aclp', label: 'CloudPulse' }, { flag: 'aclpAlerting', label: 'CloudPulse Alerting' }, - { flag: 'aclpBetaServices', label: 'ACLP Beta Services' }, + { flag: 'aclpServices', label: 'ACLP Services' }, { flag: 'aclpLogs', label: 'ACLP Logs' }, { flag: 'apl', label: 'Akamai App Platform' }, { flag: 'aplGeneralAvailability', label: 'Akamai App Platform GA' }, @@ -46,8 +46,13 @@ const options: { flag: keyof Flags; label: string }[] = [ { flag: 'databaseAdvancedConfig', label: 'Database Advanced Config' }, { flag: 'databaseVpc', label: 'Database VPC' }, { flag: 'databasePremium', label: 'Database Premium' }, + { + flag: 'databaseRestrictPlanResize', + label: 'Database Restrict Premium Plan Resize', + }, { flag: 'apicliButtonCopy', label: 'APICLI Button Copy' }, { flag: 'iam', label: 'Identity and Access Beta' }, + { flag: 'iamRbacPrimaryNavChanges', label: 'IAM Primary Nav Changes' }, { flag: 'linodeCloneFirewall', label: 'Linode Clone Firewall', diff --git a/packages/manager/src/dev-tools/components/ExtraPresetMaintenance.tsx b/packages/manager/src/dev-tools/components/ExtraPresetMaintenance.tsx index e0e2f15fd18..d1266c97001 100644 --- a/packages/manager/src/dev-tools/components/ExtraPresetMaintenance.tsx +++ b/packages/manager/src/dev-tools/components/ExtraPresetMaintenance.tsx @@ -145,7 +145,7 @@ const renderMaintenanceFields = ( > - + @@ -207,7 +207,7 @@ const maintenanceTemplates = { Canceled: () => accountMaintenanceFactory.build({ status: 'canceled' }), Completed: () => accountMaintenanceFactory.build({ status: 'completed' }), 'In Progress': () => - accountMaintenanceFactory.build({ status: 'in-progress' }), + accountMaintenanceFactory.build({ status: 'in_progress' }), Pending: () => accountMaintenanceFactory.build({ status: 'pending' }), Scheduled: () => accountMaintenanceFactory.build({ status: 'scheduled' }), Started: () => accountMaintenanceFactory.build({ status: 'started' }), diff --git a/packages/manager/src/factories/cloudpulse/services.ts b/packages/manager/src/factories/cloudpulse/services.ts index 0f04524547d..6e9c00f9eda 100644 --- a/packages/manager/src/factories/cloudpulse/services.ts +++ b/packages/manager/src/factories/cloudpulse/services.ts @@ -1,7 +1,14 @@ import { type Service } from '@linode/api-v4'; import { Factory } from '@linode/utilities'; -import type { ServiceAlert } from '@linode/api-v4'; +import type { CloudPulseServiceType, ServiceAlert } from '@linode/api-v4'; + +const serviceTypes: CloudPulseServiceType[] = [ + 'linode', + 'nodebalancer', + 'dbaas', + 'firewall', +]; export const serviceAlertFactory = Factory.Sync.makeFactory({ evaluation_period_seconds: [300, 900, 1800, 3600], @@ -11,7 +18,7 @@ export const serviceAlertFactory = Factory.Sync.makeFactory({ export const serviceTypesFactory = Factory.Sync.makeFactory({ label: Factory.each((i) => `Factory ServiceType-${i}`), - service_type: Factory.each((i) => `Factory ServiceType-${i}`), + service_type: Factory.each((i) => serviceTypes[i % 4]), regions: '*', alert: serviceAlertFactory.build(), }); diff --git a/packages/manager/src/factories/dashboards.ts b/packages/manager/src/factories/dashboards.ts index 91af9aa7303..c867d125559 100644 --- a/packages/manager/src/factories/dashboards.ts +++ b/packages/manager/src/factories/dashboards.ts @@ -44,8 +44,8 @@ export const widgetFactory = Factory.Sync.makeFactory({ metric: Factory.each((i) => `widget_metric_${i}`), namespace_id: Factory.each((i) => i % 10), region_id: Factory.each((i) => i % 5), - service_type: 'default', - serviceType: 'default', + service_type: 'linode', + serviceType: 'linode', size: 12, time_duration: { unit: 'min', diff --git a/packages/manager/src/factories/featureFlags.ts b/packages/manager/src/factories/featureFlags.ts index e3348c6c856..d77be1679e6 100644 --- a/packages/manager/src/factories/featureFlags.ts +++ b/packages/manager/src/factories/featureFlags.ts @@ -1,6 +1,6 @@ import { Factory } from '@linode/utilities'; -import type { ProductInformationBannerFlag } from 'src/featureFlags'; +import type { Flags, ProductInformationBannerFlag } from 'src/featureFlags'; export const productInformationBannerFactory = Factory.Sync.makeFactory({ @@ -15,3 +15,54 @@ export const productInformationBannerFactory = message: 'Store critical data and media files with S3-Compatible Object Storage. New Availability: Atlanta', }); + +export const flagsFactory = Factory.Sync.makeFactory>({ + aclp: { beta: true, enabled: true }, + aclpAlerting: { + accountAlertLimit: 10, + accountMetricLimit: 10, + alertDefinitions: true, + recentActivity: false, + notificationChannels: false, + }, + aclpServices: { + linode: { + alerts: { beta: true, enabled: true }, + metrics: { beta: true, enabled: true }, + }, + firewall: { + alerts: { beta: true, enabled: true }, + metrics: { beta: true, enabled: true }, + }, + dbaas: { + alerts: { beta: true, enabled: true }, + metrics: { beta: true, enabled: true }, + }, + nodebalancer: { + alerts: { beta: true, enabled: true }, + metrics: { beta: true, enabled: true }, + }, + }, + aclpResourceTypeMap: [ + { + dimensionKey: 'LINODE_ID', + maxResourceSelections: 10, + serviceType: 'linode', + }, + { + dimensionKey: 'cluster_id', + maxResourceSelections: 10, + serviceType: 'dbaas', + }, + { + dimensionKey: 'cluster_id', + maxResourceSelections: 10, + serviceType: 'nodebalancer', + }, + { + dimensionKey: 'firewall', + maxResourceSelections: 10, + serviceType: 'firewall', + }, + ], +}); diff --git a/packages/manager/src/featureFlags.ts b/packages/manager/src/featureFlags.ts index c68b06b8d1f..3b21b03401e 100644 --- a/packages/manager/src/featureFlags.ts +++ b/packages/manager/src/featureFlags.ts @@ -1,5 +1,8 @@ import type { OCA } from './features/OneClickApps/types'; -import type { AlertServiceType, TPAProvider } from '@linode/api-v4/lib/profile'; +import type { + CloudPulseServiceType, + TPAProvider, +} from '@linode/api-v4/lib/profile'; import type { NoticeVariant } from '@linode/ui'; // These flags should correspond with active features flags in LD @@ -65,8 +68,17 @@ interface GeckoFeatureFlag extends BaseFeatureFlag { } interface AclpFlag { + /** + * This property indicates whether the feature is in beta + */ beta: boolean; + /** + * This property indicates whether to bypass account capabilities check or not + */ bypassAccountCapabilities?: boolean; + /** + * This property indicates whether the feature is enabled + */ enabled: boolean; } @@ -85,7 +97,7 @@ interface CloudNatFlag extends BetaFeatureFlag { export interface CloudPulseResourceTypeMapFlag { dimensionKey: string; maxResourceSelections?: number; - serviceType: string; + serviceType: CloudPulseServiceType; } interface GpuV2 { @@ -123,10 +135,10 @@ export interface Flags { aclp: AclpFlag; aclpAlerting: AclpAlerting; aclpAlertServiceTypeConfig: AclpAlertServiceTypeConfig[]; - aclpBetaServices: AclpBetaServices; aclpLogs: BetaFeatureFlag; aclpReadEndpoint: string; aclpResourceTypeMap: CloudPulseResourceTypeMapFlag[]; + aclpServices: Partial; apicliButtonCopy: string; apiMaintenance: APIMaintenance; apl: boolean; @@ -138,6 +150,7 @@ export interface Flags { databaseBeta: boolean; databasePremium: boolean; databaseResize: boolean; + databaseRestrictPlanResize: boolean; databases: boolean; databaseVpc: boolean; dbaasV2: BetaFeatureFlag; @@ -146,6 +159,7 @@ export interface Flags { gecko2: GeckoFeatureFlag; gpuv2: GpuV2; iam: BetaFeatureFlag; + iamRbacPrimaryNavChanges: boolean; ipv6Sharing: boolean; limitsEvolution: LimitsEvolution; linodeCloneFirewall: boolean; @@ -310,13 +324,13 @@ export interface APIMaintenance { export interface AclpAlertServiceTypeConfig { maxResourceSelectionCount: number; - serviceType: AlertServiceType; + serviceType: CloudPulseServiceType; // This can be extended to have supportedRegions, supportedFilters and other tags } -export interface AclpBetaServices { - [serviceType: string]: { - alerts: boolean; - metrics: boolean; +export type AclpServices = { + [serviceType in CloudPulseServiceType]: { + alerts?: AclpFlag; + metrics?: AclpFlag; }; -} +}; diff --git a/packages/manager/src/features/Account/AccountLanding.tsx b/packages/manager/src/features/Account/AccountLanding.tsx index 3c8f658fa1a..72fc66c37d4 100644 --- a/packages/manager/src/features/Account/AccountLanding.tsx +++ b/packages/manager/src/features/Account/AccountLanding.tsx @@ -1,4 +1,5 @@ import { useAccount, useProfile } from '@linode/queries'; +import { NotFound } from '@linode/ui'; import { Outlet, useLocation, @@ -37,7 +38,7 @@ export const AccountLanding = () => { }); const { data: account } = useAccount(); const { data: profile } = useProfile(); - const { limitsEvolution } = useFlags(); + const { iamRbacPrimaryNavChanges, limitsEvolution } = useFlags(); const { data: permissions } = usePermissions('account', [ 'make_billing_payment', @@ -46,13 +47,6 @@ export const AccountLanding = () => { const [isDrawerOpen, setIsDrawerOpen] = React.useState(false); const sessionContext = React.useContext(switchAccountSessionContext); - // This is the default route for the account route, so we need to redirect to the billing tab but keep /account as legacy - if (location.pathname === '/account') { - navigate({ - to: '/account/billing', - }); - } - const isAkamaiAccount = account?.billing_source === 'akamai'; const isProxyUser = profile?.user_type === 'proxy'; const isChildUser = profile?.user_type === 'child'; @@ -104,10 +98,20 @@ export const AccountLanding = () => { React.useEffect(() => { if (match.routeId === '/account/quotas' && !showQuotasTab) { navigate({ - to: '/account/billing', + to: iamRbacPrimaryNavChanges ? '/quotas' : '/account/billing', }); } - }, [match.routeId, showQuotasTab, navigate]); + }, [match.routeId, showQuotasTab, navigate, iamRbacPrimaryNavChanges]); + + // This is the default route for the account route, so we need to redirect to the billing tab but keep /account as legacy + if (location.pathname === '/account') { + if (iamRbacPrimaryNavChanges) { + return ; + } + navigate({ + to: '/account/billing', + }); + } const handleAccountSwitch = () => { if (isParentTokenExpired) { @@ -145,7 +149,7 @@ export const AccountLanding = () => { if (!isAkamaiAccount) { landingHeaderProps.onButtonClick = () => navigate({ - to: '/account/billing', + to: iamRbacPrimaryNavChanges ? '/billing' : '/account/billing', search: { action: 'make-payment' }, }); } diff --git a/packages/manager/src/features/Account/AccountLogins.tsx b/packages/manager/src/features/Account/AccountLogins.tsx index c09a98958d4..232a99f4f13 100644 --- a/packages/manager/src/features/Account/AccountLogins.tsx +++ b/packages/manager/src/features/Account/AccountLogins.tsx @@ -15,9 +15,11 @@ import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; import { TableRowError } from 'src/components/TableRowError/TableRowError'; import { TableRowLoading } from 'src/components/TableRowLoading/TableRowLoading'; import { TableSortCell } from 'src/components/TableSortCell'; +import { useFlags } from 'src/hooks/useFlags'; import { useOrderV2 } from 'src/hooks/useOrderV2'; import { usePaginationV2 } from 'src/hooks/usePaginationV2'; +import { usePermissions } from '../IAM/hooks/usePermissions'; import AccountLoginsTableRow from './AccountLoginsTableRow'; import { getRestrictedResourceText } from './utils'; @@ -42,8 +44,14 @@ const useStyles = makeStyles()((theme: Theme) => ({ const AccountLogins = () => { const { classes } = useStyles(); + const flags = useFlags(); + const { data: permissions } = usePermissions('account', [ + 'list_account_logins', + ]); const pagination = usePaginationV2({ - currentRoute: '/account/login-history', + currentRoute: flags?.iamRbacPrimaryNavChanges + ? '/login-history' + : '/account/login-history', preferenceKey: 'account-logins-pagination', }); @@ -53,7 +61,9 @@ const AccountLogins = () => { order: 'desc', orderBy: 'datetime', }, - from: '/account/login-history', + from: flags?.iamRbacPrimaryNavChanges + ? '/login-history' + : '/account/login-history', }, preferenceKey: `${preferenceKey}-order`, }); @@ -72,7 +82,7 @@ const AccountLogins = () => { ); const { data: profile } = useProfile(); const isChildUser = profile?.user_type === 'child'; - const isAccountAccessRestricted = profile?.restricted; + const canViewAccountLogins = permissions.list_account_logins; const renderTableContent = () => { if (isLoading) { @@ -102,7 +112,7 @@ const AccountLogins = () => { return null; }; - return !isAccountAccessRestricted ? ( + return canViewAccountLogins ? ( <> diff --git a/packages/manager/src/features/Account/CloseAccountDialog.tsx b/packages/manager/src/features/Account/CloseAccountDialog.tsx index eb83b0350e8..9352616df41 100644 --- a/packages/manager/src/features/Account/CloseAccountDialog.tsx +++ b/packages/manager/src/features/Account/CloseAccountDialog.tsx @@ -2,9 +2,8 @@ import { cancelAccount } from '@linode/api-v4/lib/account'; import { useProfile } from '@linode/queries'; import { Notice, TextField, Typography } from '@linode/ui'; import { styled } from '@mui/material/styles'; +import { useNavigate } from '@tanstack/react-router'; import * as React from 'react'; -// eslint-disable-next-line no-restricted-imports -import { useHistory } from 'react-router-dom'; import { TypeToConfirmDialog } from 'src/components/TypeToConfirmDialog/TypeToConfirmDialog'; import { @@ -24,7 +23,7 @@ const CloseAccountDialog = ({ closeDialog, open }: Props) => { React.useState(false); const [errors, setErrors] = React.useState(undefined); const [comments, setComments] = React.useState(''); - const history = useHistory(); + const navigate = useNavigate(); const { data: profile } = useProfile(); React.useEffect(() => { @@ -61,7 +60,10 @@ const CloseAccountDialog = ({ closeDialog, open }: Props) => { .then((response) => { setIsClosingAccount(false); /** shoot the user off to survey monkey to answer some questions */ - history.push('/cancel', { survey_link: response.survey_link }); + navigate({ + to: '/cancel', + search: { survey_link: response.survey_link }, + }); }) .catch((e: APIError[]) => { setIsClosingAccount(false); diff --git a/packages/manager/src/features/Account/Maintenance/MaintenanceTable.test.tsx b/packages/manager/src/features/Account/Maintenance/MaintenanceTable.test.tsx index 801d6da509f..643e0d43ff4 100644 --- a/packages/manager/src/features/Account/Maintenance/MaintenanceTable.test.tsx +++ b/packages/manager/src/features/Account/Maintenance/MaintenanceTable.test.tsx @@ -32,7 +32,9 @@ describe('Maintenance Table Row', () => { ) ); getByText(maintenance.entity.label); - getByText(formatDate(maintenance.when)); + if (maintenance.when) { + getByText(formatDate(maintenance.when)); + } }); it('should render a relative time', async () => { @@ -43,9 +45,11 @@ describe('Maintenance Table Row', () => { ); const { getByText } = within(screen.getByTestId('relative-date')); - expect( - getByText(parseAPIDate(maintenance.when).toRelative()!) - ).toBeInTheDocument(); + if (maintenance.when) { + expect( + getByText(parseAPIDate(maintenance.when).toRelative()!) + ).toBeInTheDocument(); + } }); }); diff --git a/packages/manager/src/features/Account/Maintenance/MaintenanceTable.tsx b/packages/manager/src/features/Account/Maintenance/MaintenanceTable.tsx index 0ae0cf35ad0..8a283a565d7 100644 --- a/packages/manager/src/features/Account/Maintenance/MaintenanceTable.tsx +++ b/packages/manager/src/features/Account/Maintenance/MaintenanceTable.tsx @@ -73,7 +73,9 @@ export const MaintenanceTable = ({ type }: Props) => { const flags = useFlags(); const pagination = usePaginationV2({ - currentRoute: `/account/maintenance`, + currentRoute: flags?.iamRbacPrimaryNavChanges + ? `/maintenance` + : `/account/maintenance`, preferenceKey: `${preferenceKey}-${type}`, queryParamsPrefix: type, }); @@ -84,7 +86,9 @@ export const MaintenanceTable = ({ type }: Props) => { order: 'desc', orderBy: 'status', }, - from: `/account/maintenance`, + from: flags?.iamRbacPrimaryNavChanges + ? `/maintenance` + : `/account/maintenance`, }, preferenceKey: `${preferenceKey}-order-${type}`, prefix: type, diff --git a/packages/manager/src/features/Account/Maintenance/MaintenanceTableRow.tsx b/packages/manager/src/features/Account/Maintenance/MaintenanceTableRow.tsx index 203f579a538..6a322be998e 100644 --- a/packages/manager/src/features/Account/Maintenance/MaintenanceTableRow.tsx +++ b/packages/manager/src/features/Account/Maintenance/MaintenanceTableRow.tsx @@ -30,7 +30,7 @@ const statusTextMap: Record = { pending: 'Pending', started: 'In Progress', canceled: 'Canceled', - 'in-progress': 'In Progress', + in_progress: 'In Progress', scheduled: 'Scheduled', }; @@ -39,7 +39,7 @@ const statusIconMap: Record = { pending: 'active', started: 'other', canceled: 'inactive', - 'in-progress': 'other', + in_progress: 'other', scheduled: 'active', }; @@ -98,7 +98,6 @@ export const MaintenanceTableRow = (props: MaintenanceTableRowProps) => { ) : ( { )} - {dateValue + {dateValue && typeof dateValue === 'string' ? formatDate(dateValue, { timezone: profile?.timezone }) : '—'} diff --git a/packages/manager/src/features/Account/Quotas/Quotas.tsx b/packages/manager/src/features/Account/Quotas/Quotas.tsx index e81bfe7782c..b8517a138a6 100644 --- a/packages/manager/src/features/Account/Quotas/Quotas.tsx +++ b/packages/manager/src/features/Account/Quotas/Quotas.tsx @@ -12,6 +12,7 @@ import * as React from 'react'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { Link } from 'src/components/Link'; +import { useFlags } from 'src/hooks/useFlags'; import { QuotasTable } from './QuotasTable'; import { useGetLocationsForQuotaService } from './utils'; @@ -22,6 +23,8 @@ import type { Theme } from '@mui/material'; export const Quotas = () => { const navigate = useNavigate(); + const flags = useFlags(); + const [selectedLocation, setSelectedLocation] = React.useState>(null); const locationData = useGetLocationsForQuotaService('object-storage'); @@ -68,7 +71,11 @@ export const Quotas = () => { label: value?.label, value: value?.value, }); - navigate({ to: '/account/quotas' }); + navigate({ + to: flags?.iamRbacPrimaryNavChanges + ? '/quotas' + : '/account/quotas', + }); }} options={ sortedS3Endpoints?.map((location) => ({ diff --git a/packages/manager/src/features/Account/Quotas/QuotasTable.tsx b/packages/manager/src/features/Account/Quotas/QuotasTable.tsx index 027271eac45..c07e4b77882 100644 --- a/packages/manager/src/features/Account/Quotas/QuotasTable.tsx +++ b/packages/manager/src/features/Account/Quotas/QuotasTable.tsx @@ -12,7 +12,8 @@ import { TableHead } from 'src/components/TableHead'; import { TableRow } from 'src/components/TableRow/TableRow'; import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; import { TableRowLoading } from 'src/components/TableRowLoading/TableRowLoading'; -import { usePagination } from 'src/hooks/usePagination'; +import { useFlags } from 'src/hooks/useFlags'; +import { usePaginationV2 } from 'src/hooks/usePaginationV2'; import { QuotasIncreaseForm } from './QuotasIncreaseForm'; import { QuotasTableRow } from './QuotasTableRow'; @@ -31,8 +32,15 @@ interface QuotasTableProps { export const QuotasTable = (props: QuotasTableProps) => { const { selectedLocation, selectedService } = props; + const flags = useFlags(); const navigate = useNavigate(); - const pagination = usePagination(1, 'quotas-table'); + const pagination = usePaginationV2({ + currentRoute: flags?.iamRbacPrimaryNavChanges + ? '/quotas' + : '/account/quotas', + initialPage: 1, + preferenceKey: 'quotas-table', + }); const hasSelectedLocation = Boolean(selectedLocation); const [supportModalOpen, setSupportModalOpen] = React.useState(false); const [selectedQuota, setSelectedQuota] = React.useState(); diff --git a/packages/manager/src/features/Account/SwitchAccounts/SessionExpirationDialog.test.tsx b/packages/manager/src/features/Account/SwitchAccounts/SessionExpirationDialog.test.tsx index 3bdbf7ebcea..a648ea359ac 100644 --- a/packages/manager/src/features/Account/SwitchAccounts/SessionExpirationDialog.test.tsx +++ b/packages/manager/src/features/Account/SwitchAccounts/SessionExpirationDialog.test.tsx @@ -19,26 +19,26 @@ vi.mock( }) ); -const mockHistory = { - push: vi.fn(), - replace: vi.fn(), -}; +const mockNavigate = vi.fn(); -const realLocation = window.location; +const queryMocks = vi.hoisted(() => ({ + useNavigate: vi.fn(() => mockNavigate), +})); -afterAll(() => { - window.location = realLocation; -}); - -// Mock useHistory -vi.mock('react-router-dom', async () => { - const actual = await vi.importActual('react-router-dom'); +vi.mock('@tanstack/react-router', async () => { + const actual = await vi.importActual('@tanstack/react-router'); return { ...actual, - useHistory: vi.fn(() => mockHistory), + useNavigate: queryMocks.useNavigate, }; }); +const realLocation = window.location; + +afterAll(() => { + window.location = realLocation; +}); + describe('SessionExpirationDialog', () => { it('renders correctly when isOpen is true', async () => { const onCloseMock = vi.fn(); @@ -79,7 +79,9 @@ describe('SessionExpirationDialog', () => { await Promise.resolve(); }); - expect(mockHistory.push).toHaveBeenCalledWith('/logout'); + expect(mockNavigate).toHaveBeenCalledWith({ + to: '/logout', + }); expect(mockReload).toHaveBeenCalled(); }); }); diff --git a/packages/manager/src/features/Account/SwitchAccounts/SessionExpirationDialog.tsx b/packages/manager/src/features/Account/SwitchAccounts/SessionExpirationDialog.tsx index fc4b19d1468..f25942cf0e2 100644 --- a/packages/manager/src/features/Account/SwitchAccounts/SessionExpirationDialog.tsx +++ b/packages/manager/src/features/Account/SwitchAccounts/SessionExpirationDialog.tsx @@ -1,9 +1,8 @@ import { useAccount } from '@linode/queries'; import { ActionsPanel, Typography } from '@linode/ui'; import { pluralize, useInterval } from '@linode/utilities'; +import { useNavigate } from '@tanstack/react-router'; import React, { useEffect } from 'react'; -// eslint-disable-next-line no-restricted-imports -import { useHistory } from 'react-router-dom'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; import { sessionExpirationContext as _sessionExpirationContext } from 'src/context/sessionExpirationContext'; @@ -31,7 +30,7 @@ export const SessionExpirationDialog = React.memo( seconds: 0, }); const [logoutLoading, setLogoutLoading] = React.useState(false); - const history = useHistory(); + const navigate = useNavigate(); const { data: account } = useAccount(); const euuid = account?.euuid ?? ''; @@ -82,7 +81,7 @@ export const SessionExpirationDialog = React.memo( setLogoutLoading(true); if (!validateParentToken()) { - history.push('/logout'); + navigate({ to: '/logout' }); } await revokeToken().catch(() => { diff --git a/packages/manager/src/features/Account/SwitchAccounts/SwitchAccountSessionDialog.test.tsx b/packages/manager/src/features/Account/SwitchAccounts/SwitchAccountSessionDialog.test.tsx index 3f95d950b73..cd9d13bf403 100644 --- a/packages/manager/src/features/Account/SwitchAccounts/SwitchAccountSessionDialog.test.tsx +++ b/packages/manager/src/features/Account/SwitchAccounts/SwitchAccountSessionDialog.test.tsx @@ -1,22 +1,20 @@ import { fireEvent } from '@testing-library/react'; import React from 'react'; -// eslint-disable-next-line no-restricted-imports -import { MemoryRouter } from 'react-router-dom'; import { SwitchAccountSessionDialog } from 'src/features/Account/SwitchAccounts/SwitchAccountSessionDialog'; import { renderWithTheme } from 'src/utilities/testHelpers'; -const mockHistory = { - push: vi.fn(), - replace: vi.fn(), -}; +const mockNavigate = vi.fn(); -// Mock useHistory -vi.mock('react-router-dom', async () => { - const actual = await vi.importActual('react-router-dom'); +const queryMocks = vi.hoisted(() => ({ + useNavigate: vi.fn(() => mockNavigate), +})); + +vi.mock('@tanstack/react-router', async () => { + const actual = await vi.importActual('@tanstack/react-router'); return { ...actual, - useHistory: vi.fn(() => mockHistory), + useNavigate: queryMocks.useNavigate, }; }); @@ -24,9 +22,7 @@ describe('SwitchAccountSessionDialog', () => { it('renders correctly when isOpen is true', () => { const onCloseMock = vi.fn(); const { getByText } = renderWithTheme( - - - + ); expect(getByText('Session expired')).toBeInTheDocument(); @@ -42,9 +38,7 @@ describe('SwitchAccountSessionDialog', () => { it('calls onClose when close button is clicked', () => { const onCloseMock = vi.fn(); const { getByText } = renderWithTheme( - - - + ); fireEvent.click(getByText('Close')); @@ -53,12 +47,12 @@ describe('SwitchAccountSessionDialog', () => { it('calls history.push("/logout") when Log in button is clicked', () => { const { getByText } = renderWithTheme( - - - + ); fireEvent.click(getByText('Log in')); - expect(mockHistory.push).toHaveBeenCalledWith('/logout'); + expect(mockNavigate).toHaveBeenCalledWith({ + to: '/logout', + }); }); }); diff --git a/packages/manager/src/features/Account/SwitchAccounts/SwitchAccountSessionDialog.tsx b/packages/manager/src/features/Account/SwitchAccounts/SwitchAccountSessionDialog.tsx index cf2495b3fbd..3b878bd6bfb 100644 --- a/packages/manager/src/features/Account/SwitchAccounts/SwitchAccountSessionDialog.tsx +++ b/packages/manager/src/features/Account/SwitchAccounts/SwitchAccountSessionDialog.tsx @@ -1,14 +1,13 @@ import { ActionsPanel, Typography } from '@linode/ui'; +import { useNavigate } from '@tanstack/react-router'; import React from 'react'; -// eslint-disable-next-line no-restricted-imports -import { useHistory } from 'react-router-dom'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; import { sendSwitchAccountSessionExpiryEvent } from 'src/utilities/analytics/customEventAnalytics'; export const SwitchAccountSessionDialog = React.memo( ({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) => { - const history = useHistory(); + const navigate = useNavigate(); const actions = ( { sendSwitchAccountSessionExpiryEvent('Log In'); - history.push('/logout'); + navigate({ to: '/logout' }); }, }} secondaryButtonProps={{ diff --git a/packages/manager/src/features/Account/constants.ts b/packages/manager/src/features/Account/constants.ts index acce4f3bbf2..e51f2b2107a 100644 --- a/packages/manager/src/features/Account/constants.ts +++ b/packages/manager/src/features/Account/constants.ts @@ -14,6 +14,7 @@ export const grantTypeMap = { longview: 'Longview Clients', nodebalancer: 'NodeBalancers', placementGroups: 'Placement Groups', + quotas: 'Quotas', stackscript: 'StackScripts', volume: 'Volumes', vpc: 'VPCs', diff --git a/packages/manager/src/features/Billing/BillingDetail.tsx b/packages/manager/src/features/Billing/BillingDetail.tsx index 50b0ff6c636..db2bd9a2a32 100644 --- a/packages/manager/src/features/Billing/BillingDetail.tsx +++ b/packages/manager/src/features/Billing/BillingDetail.tsx @@ -12,6 +12,7 @@ import * as React from 'react'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { PAYPAL_CLIENT_ID } from 'src/constants'; +import { usePermissions } from 'src/features/IAM/hooks/usePermissions'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { BillingActivityPanel } from './BillingPanels/BillingActivityPanel/BillingActivityPanel'; @@ -20,11 +21,14 @@ import { ContactInformation } from './BillingPanels/ContactInfoPanel/ContactInfo import PaymentInformation from './BillingPanels/PaymentInfoPanel'; export const BillingDetail = () => { + const { data: permissions } = usePermissions('account', [ + 'list_billing_payments', + ]); const { data: paymentMethods, error: paymentMethodsError, isLoading: paymentMethodsLoading, - } = useAllPaymentMethodsQuery(); + } = useAllPaymentMethodsQuery(permissions?.list_billing_payments ?? false); const { data: account, diff --git a/packages/manager/src/features/Billing/BillingLanding/BillingLanding.tsx b/packages/manager/src/features/Billing/BillingLanding/BillingLanding.tsx new file mode 100644 index 00000000000..e6104199068 --- /dev/null +++ b/packages/manager/src/features/Billing/BillingLanding/BillingLanding.tsx @@ -0,0 +1,120 @@ +import { useAccount, useProfile } from '@linode/queries'; +import { Navigate, useLocation, useNavigate } from '@tanstack/react-router'; +import * as React from 'react'; + +import { DocumentTitleSegment } from 'src/components/DocumentTitle'; +import { LandingHeader } from 'src/components/LandingHeader'; +import { MaintenanceBannerV2 } from 'src/components/MaintenanceBanner/MaintenanceBannerV2'; +import { switchAccountSessionContext } from 'src/context/switchAccountSessionContext'; +import { useIsParentTokenExpired } from 'src/features/Account/SwitchAccounts/useIsParentTokenExpired'; +import { getRestrictedResourceText } from 'src/features/Account/utils'; +import { useFlags } from 'src/hooks/useFlags'; +import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; +import { sendSwitchAccountEvent } from 'src/utilities/analytics/customEventAnalytics'; + +import { PlatformMaintenanceBanner } from '../../../components/PlatformMaintenanceBanner/PlatformMaintenanceBanner'; +import { SwitchAccountButton } from '../../Account/SwitchAccountButton'; +import { SwitchAccountDrawer } from '../../Account/SwitchAccountDrawer'; +import { usePermissions } from '../../IAM/hooks/usePermissions'; +import { BillingDetail } from '../BillingDetail'; + +import type { LandingHeaderProps } from 'src/components/LandingHeader'; + +export const BillingLanding = () => { + const flags = useFlags(); + const navigate = useNavigate(); + const location = useLocation(); + + const { data: account } = useAccount(); + const { data: profile } = useProfile(); + + const { data: permissions } = usePermissions('account', [ + 'make_billing_payment', + ]); + + const [isDrawerOpen, setIsDrawerOpen] = React.useState(false); + const sessionContext = React.useContext(switchAccountSessionContext); + + const isIAMRbacPrimaryNavChangesEnabled = flags?.iamRbacPrimaryNavChanges; + const isAkamaiAccount = account?.billing_source === 'akamai'; + const isProxyUser = profile?.user_type === 'proxy'; + const isChildUser = profile?.user_type === 'child'; + const isParentUser = profile?.user_type === 'parent'; + + const isChildAccountAccessRestricted = useRestrictedGlobalGrantCheck({ + globalGrantType: 'child_account_access', + }); + + const { isParentTokenExpired } = useIsParentTokenExpired({ isProxyUser }); + + const isReadOnly = !permissions.make_billing_payment || isChildUser; + + if ( + !isIAMRbacPrimaryNavChangesEnabled && + location.pathname !== '/account/billing' + ) { + return ; + } + + const canSwitchBetweenParentOrProxyAccount = + (!isChildAccountAccessRestricted && isParentUser) || isProxyUser; + + const handleAccountSwitch = () => { + if (isParentTokenExpired) { + return sessionContext.updateState({ + isOpen: true, + }); + } + + setIsDrawerOpen(true); + }; + + const landingHeaderProps: LandingHeaderProps = { + breadcrumbProps: { + pathname: '/billing', + }, + buttonDataAttrs: { + disabled: isReadOnly, + tooltipText: getRestrictedResourceText({ + isChildUser, + resourceType: 'Account', + }), + }, + createButtonText: 'Make a Payment', + docsLabel: 'How Linode Billing Works', + docsLink: + 'https://techdocs.akamai.com/cloud-computing/docs/understanding-how-billing-works', + extraActions: canSwitchBetweenParentOrProxyAccount ? ( + { + sendSwitchAccountEvent('Account Landing'); + handleAccountSwitch(); + }} + /> + ) : undefined, + onButtonClick: () => + !isAkamaiAccount + ? navigate({ + to: '/billing', + search: { action: 'make-payment' }, + }) + : {}, + title: 'Billing', + }; + + return ( + <> + + + + + + setIsDrawerOpen(false)} + open={isDrawerOpen} + userType={profile?.user_type} + /> + + ); +}; diff --git a/packages/manager/src/features/Billing/BillingLanding/billingLandingLazyRoute.ts b/packages/manager/src/features/Billing/BillingLanding/billingLandingLazyRoute.ts new file mode 100644 index 00000000000..2e69ec2cfdb --- /dev/null +++ b/packages/manager/src/features/Billing/BillingLanding/billingLandingLazyRoute.ts @@ -0,0 +1,7 @@ +import { createLazyRoute } from '@tanstack/react-router'; + +import { BillingLanding } from './BillingLanding'; + +export const billingLandingLazyRoute = createLazyRoute('/billing')({ + component: BillingLanding, +}); diff --git a/packages/manager/src/features/Billing/BillingLanding/invoiceDetailsLazyRoute.ts b/packages/manager/src/features/Billing/BillingLanding/invoiceDetailsLazyRoute.ts new file mode 100644 index 00000000000..dc0a891448f --- /dev/null +++ b/packages/manager/src/features/Billing/BillingLanding/invoiceDetailsLazyRoute.ts @@ -0,0 +1,9 @@ +import { createLazyRoute } from '@tanstack/react-router'; + +import { InvoiceDetail } from '../InvoiceDetail/InvoiceDetail'; + +export const invoiceDetailsLazyRoute = createLazyRoute( + '/billing/invoices/$invoiceId' +)({ + component: InvoiceDetail, +}); diff --git a/packages/manager/src/features/Billing/BillingPanels/BillingActivityPanel/BillingActivityPanel.tsx b/packages/manager/src/features/Billing/BillingPanels/BillingActivityPanel/BillingActivityPanel.tsx index 233472db103..15a423c635c 100644 --- a/packages/manager/src/features/Billing/BillingPanels/BillingActivityPanel/BillingActivityPanel.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/BillingActivityPanel/BillingActivityPanel.tsx @@ -6,7 +6,7 @@ import { useProfile, useRegionsQuery, } from '@linode/queries'; -import { Autocomplete, Typography } from '@linode/ui'; +import { Autocomplete, Notice, Typography, WarningIcon } from '@linode/ui'; import { getAll, useSet } from '@linode/utilities'; import Grid from '@mui/material/Grid'; import Paper from '@mui/material/Paper'; @@ -37,6 +37,7 @@ import { printInvoice, printPayment, } from 'src/features/Billing/PdfGenerator/PdfGenerator'; +import { usePermissions } from 'src/features/IAM/hooks/usePermissions'; import { useFlags } from 'src/hooks/useFlags'; import { useOrderV2 } from 'src/hooks/useOrderV2'; import { usePaginationV2 } from 'src/hooks/usePaginationV2'; @@ -183,9 +184,12 @@ export const BillingActivityPanel = React.memo((props: Props) => { const { data: profile } = useProfile(); const { data: account } = useAccount(); const { data: regions } = useRegionsQuery(); + const flags = useFlags(); const pagination = usePaginationV2({ - currentRoute: '/account/billing', + currentRoute: flags?.iamRbacPrimaryNavChanges + ? '/billing' + : '/account/billing', preferenceKey: 'billing-activity-pagination', }); const { handleOrderChange, order, orderBy } = useOrderV2({ @@ -194,14 +198,23 @@ export const BillingActivityPanel = React.memo((props: Props) => { order: 'desc', orderBy: 'amount', }, - from: '/account/billing', + from: flags?.iamRbacPrimaryNavChanges ? '/billing' : '/account/billing', }, preferenceKey: 'billing-activity-order', }); + const { data: permissions } = usePermissions('account', [ + 'list_billing_payments', + 'list_billing_invoices', + 'list_invoice_items', + ]); + + const canViewInvoices = permissions.list_billing_invoices; + const canViewPayments = permissions.list_billing_payments; + const canViewInvoiceDetails = permissions.list_invoice_items; + const isAkamaiCustomer = account?.billing_source === 'akamai'; const { classes } = useStyles(); - const flags = useFlags(); const pdfErrors = useSet(); const pdfLoading = useSet(); @@ -218,13 +231,13 @@ export const BillingActivityPanel = React.memo((props: Props) => { data: payments, error: accountPaymentsError, isLoading: accountPaymentsLoading, - } = useAllAccountPayments({}, filter); + } = useAllAccountPayments({}, filter, canViewPayments); const { data: invoices, error: accountInvoicesError, isLoading: accountInvoicesLoading, - } = useAllAccountInvoices({}, filter); + } = useAllAccountInvoices({}, filter, canViewInvoices); const downloadInvoicePDF = React.useCallback( (invoiceId: number) => { @@ -356,7 +369,19 @@ export const BillingActivityPanel = React.memo((props: Props) => { return ( + {' '} + You do not have permission to view billing or payment history. + + ) + } /> ); } @@ -365,6 +390,7 @@ export const BillingActivityPanel = React.memo((props: Props) => { const lastItem = idx === orderedPaginatedData.length - 1; return ( { { + if (!canViewInvoices) { + return options.filter((option) => option.value !== 'invoice'); + } + if (!canViewPayments) { + return options.filter((option) => option.value !== 'payment'); + } + return options; + }} label="Transaction Types" noMarginTop onChange={(_, item) => { @@ -457,6 +493,16 @@ export const BillingActivityPanel = React.memo((props: Props) => { />
    + {(canViewInvoices && !canViewPayments) || + (!canViewInvoices && canViewPayments) ? ( + + ) : null} @@ -473,8 +519,9 @@ export const BillingActivityPanel = React.memo((props: Props) => { > Amount - - + {canViewInvoiceDetails && ( + + )} {renderTableContent()} @@ -503,6 +550,7 @@ const StyledBillingAndPaymentHistoryHeader = styled('div', { // // ============================================================================= interface ActivityFeedItemProps extends ActivityFeedItem { + canViewInvoiceDetails: boolean; downloadPDF: (id: number) => void; hasError: boolean; isLoading: boolean; @@ -512,7 +560,10 @@ interface ActivityFeedItemProps extends ActivityFeedItem { export const ActivityFeedItem = React.memo((props: ActivityFeedItemProps) => { const { classes } = useStyles(); + const { iamRbacPrimaryNavChanges } = useFlags(); + const { + canViewInvoiceDetails, date, downloadPDF, hasError, @@ -542,8 +593,16 @@ export const ActivityFeedItem = React.memo((props: ActivityFeedItemProps) => { return ( - {type === 'invoice' ? ( - {label} + {type === 'invoice' && canViewInvoiceDetails ? ( + + {label} + ) : ( label )} @@ -554,14 +613,16 @@ export const ActivityFeedItem = React.memo((props: ActivityFeedItemProps) => { - - - + {canViewInvoiceDetails && ( + + + + )} ); }); diff --git a/packages/manager/src/features/Billing/BillingPanels/BillingSummary/BillingSummary.tsx b/packages/manager/src/features/Billing/BillingPanels/BillingSummary/BillingSummary.tsx index fcd5dcfa140..54d55cf4372 100644 --- a/packages/manager/src/features/Billing/BillingPanels/BillingSummary/BillingSummary.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/BillingSummary/BillingSummary.tsx @@ -7,6 +7,7 @@ import * as React from 'react'; import { Currency } from 'src/components/Currency'; import { usePermissions } from 'src/features/IAM/hooks/usePermissions'; +import { useFlags } from 'src/hooks/useFlags'; import { isWithinDays } from 'src/utilities/date'; import { BillingPaper } from '../../BillingDetail'; @@ -34,12 +35,15 @@ export const BillingSummary = (props: BillingSummaryProps) => { 'create_promo_code', ]); + const { iamRbacPrimaryNavChanges } = useFlags(); + const [isPromoDialogOpen, setIsPromoDialogOpen] = React.useState(false); const { data: grants } = useGrants(); const accountAccessGrant = grants?.global?.account_access; const readOnlyAccountAccess = accountAccessGrant === 'read_only'; + const url = iamRbacPrimaryNavChanges ? '/billing' : '/account/billing'; // If a user has a payment_due notification with a severity of critical, it indicates that they are outside of any grace period they may have and payment is due immediately. const isBalanceOutsideGracePeriod = notifications?.some( @@ -51,7 +55,9 @@ export const BillingSummary = (props: BillingSummaryProps) => { const { balance, balanceUninvoiced, paymentMethods, promotions } = props; const navigate = useNavigate(); - const search = useSearch({ from: '/account/billing' }); + const search = useSearch({ + from: url, + }); const { paymentMethodId } = search; const makePaymentRouteMatch = search.action === 'make-payment'; @@ -74,7 +80,9 @@ export const BillingSummary = (props: BillingSummaryProps) => { const closePaymentDrawer = React.useCallback(() => { setPaymentDrawerOpen(false); setSelectedPaymentMethod(undefined); - navigate({ to: '/account/billing' }); + navigate({ + to: url, + }); }, [navigate]); const openPromoDialog = () => setIsPromoDialogOpen(true); @@ -131,7 +139,7 @@ export const BillingSummary = (props: BillingSummaryProps) => { diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.test.tsx index f8d31b96dee..48ced3bc96b 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.test.tsx @@ -6,6 +6,8 @@ import { renderWithTheme } from 'src/utilities/testHelpers'; import { CreateAlertDefinition } from './CreateAlertDefinition'; +import type { AclpServices } from 'src/featureFlags'; + vi.mock('src/queries/cloudpulse/resources', () => ({ ...vi.importActual('src/queries/cloudpulse/resources'), useResourcesQuery: queryMocks.useResourcesQuery, @@ -21,6 +23,11 @@ const queryMocks = vi.hoisted(() => ({ useGetCloudPulseMetricDefinitionsByServiceType: vi.fn().mockReturnValue({}), useRegionsQuery: vi.fn(), useResourcesQuery: vi.fn(), + useFlags: vi.fn(), +})); + +vi.mock('src/hooks/useFlags', () => ({ + useFlags: queryMocks.useFlags, })); vi.mock('src/queries/cloudpulse/services', async () => { @@ -33,6 +40,20 @@ vi.mock('src/queries/cloudpulse/services', async () => { }; }); +const aclpServicesFlag: Partial = { + linode: { + alerts: { enabled: true, beta: true }, + metrics: { enabled: true, beta: true }, + }, + dbaas: { + alerts: { enabled: true, beta: true }, + metrics: { enabled: true, beta: true }, + }, +}; + +const linodeLabel = 'Linode beta'; +const databasesLabel = 'Databases beta'; + beforeEach(() => { Element.prototype.scrollIntoView = vi.fn(); queryMocks.useGetCloudPulseMetricDefinitionsByServiceType.mockReturnValue({ @@ -51,11 +72,19 @@ beforeEach(() => { isFetching: false, }); queryMocks.useCloudPulseServiceTypes.mockReturnValue({ - data: { data: [{ label: 'Linode', service_type: 'linode' }] }, + data: { + data: [ + { label: 'Linode', service_type: 'linode' }, + { label: 'Databases', service_type: 'dbaas' }, + ], + }, isError: false, isLoading: false, status: 'success', }); + queryMocks.useFlags.mockReturnValue({ + aclpServices: aclpServicesFlag, + }); }); describe('AlertDefinition Create', () => { @@ -197,4 +226,46 @@ describe('AlertDefinition Create', () => { await container.findByText('Description must be 100 characters or less.') ).toBeVisible(); }); + + it('should render the service types based on the aclp services flag', async () => { + queryMocks.useFlags.mockReturnValue({ + aclpServices: { + linode: { + alerts: { enabled: true, beta: true }, + metrics: { enabled: true, beta: true }, + }, + dbaas: { + alerts: { enabled: false, beta: true }, + metrics: { enabled: false, beta: true }, + }, + }, + }); + + renderWithTheme(); + const serviceFilterDropdown = screen.getByTestId('servicetype-select'); + await userEvent.click( + within(serviceFilterDropdown).getByRole('button', { name: 'Open' }) + ); + expect(screen.getByRole('option', { name: linodeLabel })).toBeVisible(); + expect(screen.queryByRole('option', { name: databasesLabel })).toBeNull(); // Verify that Databases is NOT present (filtered out by the flag) + }); + + it('should not return service types that are missing in the flag', async () => { + queryMocks.useFlags.mockReturnValue({ + aclpServices: { + linode: { + alerts: { enabled: true, beta: true }, + metrics: { enabled: true, beta: true }, + }, + }, + }); + + renderWithTheme(); + const serviceFilterDropdown = screen.getByTestId('servicetype-select'); + await userEvent.click( + within(serviceFilterDropdown).getByRole('button', { name: 'Open' }) + ); + expect(screen.getByRole('option', { name: linodeLabel })).toBeVisible(); + expect(screen.queryByRole('option', { name: 'Databases' })).toBeNull(); + }); }); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.tsx index 67f1836db31..cc6b538ce05 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.tsx @@ -38,6 +38,7 @@ import type { TriggerConditionForm, } from './types'; import type { APIError } from '@linode/api-v4'; +import type { CrumbOverridesProps } from 'src/components/Breadcrumb/Crumbs'; const triggerConditionInitialValues: TriggerConditionForm = { criteria_condition: 'ALL', @@ -66,17 +67,12 @@ const initialValues: CreateAlertDefinitionForm = { scope: 'entity', }; -const overrides = [ +const overrides: CrumbOverridesProps[] = [ { label: 'Definitions', linkTo: '/alerts/definitions', position: 1, }, - { - label: 'Details', - linkTo: `/alerts/definitions/create`, - position: 2, - }, ]; export const CreateAlertDefinition = () => { const navigate = useNavigate(); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterField.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterField.test.tsx index be308c26241..94a5b6d05ec 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterField.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterField.test.tsx @@ -176,56 +176,58 @@ describe('Dimension filter field component', () => { }); it('should render the Value component with options happy path and select an option', async () => { - const container = - renderWithThemeAndHookFormContext({ - component: ( - - ), - useFormOptions: { - defaultValues: { - rule_criteria: { - rules: [mockData[0]], - }, - serviceType: 'linode', + renderWithThemeAndHookFormContext({ + component: ( + + ), + useFormOptions: { + defaultValues: { + rule_criteria: { + rules: [mockData[0]], }, + serviceType: 'linode', }, - }); - const dataFieldContainer = container.getByTestId(dataFieldId); + }, + }); + // selecting data field + const dataFieldContainer = screen.getByTestId(dataFieldId); const dataFieldInput = within(dataFieldContainer).getByRole('button', { name: 'Open', }); - const valueLabel = capitalize(dimensionFieldMockData[1].values[0]); await user.click(dataFieldInput); await user.click( - await container.findByRole('option', { + await screen.findByRole('option', { name: dimensionFieldMockData[1].label, }) ); - const valueContainer = container.getByTestId('value'); - const valueInput = within(valueContainer).getByRole('button', { - name: 'Open', - }); - - user.click(valueInput); - expect( - await container.findByRole('option', { - name: valueLabel, + // selecting operator + const operatorContainer = screen.getByTestId('operator'); + await user.click( + within(operatorContainer).getByRole('button', { + name: 'Open', }) ); - - expect( - await container.findByRole('option', { - name: valueLabel, + await user.click( + await screen.findByRole('option', { + name: 'Equal', }) ); + // selecting value + const valueLabel = capitalize(dimensionFieldMockData[1].values[0]); + const valueContainer = screen.getByTestId('value'); await user.click( - container.getByRole('option', { + within(valueContainer).getByRole('button', { + name: 'Open', + }) + ); + await user.click( + await screen.findByRole('option', { name: valueLabel, }) ); @@ -309,56 +311,4 @@ describe('Dimension filter field component', () => { expect(within(valueContainer).getByText(userLabel)).toBeInTheDocument(); expect(within(valueContainer).getByText(idleLabel)).toBeInTheDocument(); }); - it('should render a TextField for the Value input when the selected dimension has no values (for all operators)', async () => { - renderWithThemeAndHookFormContext({ - component: ( - - ), - useFormOptions: { - defaultValues: { - rule_criteria: { - rules: [mockData[0]], - }, - serviceType: 'linode', - }, - }, - }); - - const dataFieldContainer = screen.getByTestId('data-field'); - const dataFieldInput = within(dataFieldContainer).getByRole('button', { - name: 'Open', - }); - - await user.click(dataFieldInput); - await user.click( - await screen.findByRole('option', { - name: dimensionFieldMockData[0].label, - }) - ); - - const operatorContainer = screen.getByTestId('operator'); - const operatorInput = within(operatorContainer).getByRole('button', { - name: 'Open', - }); - - await user.click(operatorInput); - await user.click( - screen.getByRole('option', { - name: 'Equal', - }) - ); - - const valueContainer = screen.getByTestId('value'); - - expect(within(valueContainer).getByRole('textbox')).toBeInTheDocument(); - - await user.click(operatorInput); - await user.click(screen.getByRole('option', { name: 'In' })); - expect(within(valueContainer).getByRole('textbox')).toBeInTheDocument(); - }); }); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterField.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterField.tsx index 8788f6786bc..42babd42afa 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterField.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterField.tsx @@ -1,19 +1,13 @@ -import { Autocomplete, Box, TextField } from '@linode/ui'; -import { capitalize } from '@linode/utilities'; +import { Autocomplete, Box } from '@linode/ui'; import { GridLegacy } from '@mui/material'; import React from 'react'; import { Controller, useFormContext, useWatch } from 'react-hook-form'; import type { FieldPathByValue } from 'react-hook-form'; -import { - dimensionOperatorOptions, - HELPER_TEXT_MAP, - PLACEHOLDER_TEXT_MAP, - textFieldOperators, -} from '../../constants'; +import { dimensionOperatorOptions } from '../../constants'; import { ClearIconButton } from './ClearIconButton'; +import { ValueFieldRenderer } from './DimensionFilterValue/ValueFieldRenderer'; -import type { Item } from '../../constants'; import type { CreateAlertDefinitionForm, DimensionFilterForm } from '../types'; import type { Dimension, DimensionFilterOperatorType } from '@linode/api-v4'; @@ -78,7 +72,14 @@ export const DimensionFilterField = (props: DimensionFilterFieldProps) => { name: `${name}.operator`, }); - const dimensionValueWatcher = useWatch({ control, name: `${name}.value` }); + const entities = useWatch({ + control, + name: 'entity_ids', + }); + const serviceType = useWatch({ + control, + name: 'serviceType', + }); const selectedDimension = dimensionOptions && dimensionFieldWatcher ? (dimensionOptions.find( @@ -86,74 +87,6 @@ export const DimensionFilterField = (props: DimensionFilterFieldProps) => { ) ?? null) : null; - const valueOptions = () => { - if (selectedDimension !== null && selectedDimension.values) { - return selectedDimension.values.map((val) => ({ - label: capitalize(val), - value: val, - })); - } - return []; - }; - const isValueMultiple = - valueOptions().length > 0 && dimensionOperatorWatcher === 'in'; - - const isTextField = - !valueOptions().length || - (dimensionOperatorWatcher - ? textFieldOperators.includes(dimensionOperatorWatcher) - : false); - - const valuePlaceholder = `${isTextField ? 'Enter' : 'Select'} a Value`; - - const customPlaceholderDimension = - PLACEHOLDER_TEXT_MAP[dimensionFieldWatcher ?? '']; - const customPlaceholderText = - customPlaceholderDimension?.[ - dimensionOperatorWatcher === 'in' ? 'in' : 'default' - ] ?? valuePlaceholder; - - const customHelperDimension = HELPER_TEXT_MAP[dimensionFieldWatcher ?? '']; - const customHelperText = - customHelperDimension?.[ - dimensionOperatorWatcher === 'in' ? 'in' : 'default' - ] ?? undefined; - - const resolveSelectedValues = ( - options: Item[], - value: null | string - ): Item | Item[] | null => { - if (!value) return isValueMultiple ? [] : null; - - if (isValueMultiple) { - const splitValues = value.split(','); - return options.filter((option) => splitValues.includes(option.value)); - } - - return options.find((option) => option.value === value) ?? null; - }; - - const handleValueChange = ( - selected: Item | Item[] | null, - operation: string - ): string => { - if (!['removeOption', 'selectOption'].includes(operation)) { - return ''; - } - - if (isValueMultiple && Array.isArray(selected)) { - return selected.map((item) => item.value).join(','); - } - - if (!isValueMultiple && selected && !Array.isArray(selected)) { - return selected.value; - } - - return ''; - }; - - const isCustomValueDimension = - dimensionFieldWatcher === 'port' || dimensionFieldWatcher === 'config_id'; return ( { )} /> - + { /> - - - isTextField ? ( - field.onChange(event.target.value)} - placeholder={customPlaceholderText} - sx={{ flex: 1, width: '256px' }} - type={ - isCustomValueDimension && dimensionOperatorWatcher !== 'in' - ? 'number' - : 'text' - } - value={field.value ?? ''} - /> - ) : ( - - value.value === option.value - } - label="Value" - limitTags={1} - multiple={isValueMultiple} - onBlur={field.onBlur} - onChange={(_, selected, operation) => { - field.onChange(handleValueChange(selected, operation)); - }} - options={valueOptions()} - placeholder={ - dimensionValueWatcher && - (!Array.isArray(dimensionValueWatcher) || - dimensionValueWatcher.length) - ? '' - : valuePlaceholder - } - sx={{ flex: 1 }} - value={resolveSelectedValues(valueOptions(), field.value)} - /> - ) - } - /> - - - + ( + + )} + /> + + + + diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/DimensionFilterAutocomplete.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/DimensionFilterAutocomplete.test.tsx new file mode 100644 index 00000000000..c1de69eebc2 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/DimensionFilterAutocomplete.test.tsx @@ -0,0 +1,126 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { vi } from 'vitest'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { DimensionFilterAutocomplete } from './DimensionFilterAutocomplete'; + +import type { Item } from '../../../constants'; + +const mockOptions: Item[] = [ + { label: 'TCP', value: 'tcp' }, + { label: 'UDP', value: 'udp' }, +]; + +describe('', () => { + const defaultProps = { + name: `rule_criteria.rules.${0}.dimension_filters.%{0}.value`, + disabled: false, + errorText: '', + fieldOnBlur: vi.fn(), + fieldOnChange: vi.fn(), + fieldValue: 'tcp', + multiple: false, + placeholderText: 'Select a value', + values: mockOptions, + }; + + it('renders with label and placeholder', () => { + renderWithTheme(); + expect(screen.getByLabelText(/Value/i)).toBeVisible(); + expect(screen.getByPlaceholderText('Select a value')).toBeVisible(); + }); + + it('calls fieldOnBlur when input is blurred', async () => { + const user = userEvent.setup(); + renderWithTheme(); + const input = screen.getByRole('combobox'); + await user.click(input); + await user.tab(); // move focus away + expect(defaultProps.fieldOnBlur).toHaveBeenCalled(); + }); + + it('disables the Autocomplete when disabled is true', () => { + renderWithTheme(); + const input = screen.getByRole('combobox'); + expect(input).toBeDisabled(); + }); + + it('renders error text when provided', () => { + renderWithTheme( + + ); + expect(screen.getByText('Invalid protocol')).toBeVisible(); + }); + + it('calls fieldOnChange with correct value when selecting TCP (single)', async () => { + const user = userEvent.setup(); + const fieldOnChange = vi.fn(); + renderWithTheme( + + ); + + await user.click(screen.getByRole('button', { name: 'Open' })); + expect( + screen.getByRole('option', { name: mockOptions[0].label }) + ).toBeVisible(); + await user.click( + screen.getByRole('option', { name: mockOptions[0].label }) + ); + expect(fieldOnChange).toHaveBeenCalledWith(mockOptions[0].value); + }); + + it('should select multiple options when multiple prop is true', async () => { + const user = userEvent.setup(); + const fieldOnChange = vi.fn(); + const { rerender } = renderWithTheme( + + ); + + await user.click(screen.getByRole('button', { name: 'Open' })); + expect( + screen.getByRole('option', { name: mockOptions[1].label }) + ).toBeVisible(); + await user.click( + screen.getByRole('option', { name: mockOptions[1].label }) + ); + expect(fieldOnChange).toHaveBeenCalledWith(mockOptions[1].value); + + // Rerender with updated form state + rerender( + + ); + + await user.click(screen.getByRole('button', { name: 'Open' })); + expect( + screen.getByRole('option', { name: mockOptions[0].label }) + ).toBeVisible(); + await user.click( + screen.getByRole('option', { name: mockOptions[0].label }) + ); + + // Assert both values were selected + expect(fieldOnChange).toHaveBeenCalledWith( + `${mockOptions[1].value},${mockOptions[0].value}` + ); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/DimensionFilterAutocomplete.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/DimensionFilterAutocomplete.tsx new file mode 100644 index 00000000000..beb54d0ffe5 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/DimensionFilterAutocomplete.tsx @@ -0,0 +1,97 @@ +import { Autocomplete } from '@linode/ui'; +import React from 'react'; + +import { handleValueChange, resolveSelectedValues } from './utils'; + +import type { Item } from '../../../constants'; + +interface DimensionFilterAutocompleteProps { + /** + * Whether the autocomplete input should be disabled. + */ + disabled: boolean; + + /** + * Optional error message to display beneath the input. + */ + errorText?: string; + + /** + * Handler function called on input blur. + */ + fieldOnBlur: () => void; + + /** + * Callback triggered when the user selects a new value(s). + */ + fieldOnChange: (newValue: string | string[]) => void; + + /** + * Current raw string value (or null) from the form state. + */ + fieldValue: null | string; + + /** + * To control single-select/multi-select in the Autocomplete. + */ + multiple?: boolean; + /** + * Name of the field set in the form. + */ + name: string; + /** + * Placeholder text to display when no selection is made. + */ + placeholderText: string; + + /** + * The full list of selectable options for the autocomplete input. + */ + values: Item[]; +} + +/** + * Renders an Autocomplete input field for the DimensionFilter value field. + * This component supports both single and multiple selection based on config. + */ +export const DimensionFilterAutocomplete = ( + props: DimensionFilterAutocompleteProps +) => { + const { + multiple, + name, + fieldOnChange, + values, + disabled, + fieldOnBlur, + placeholderText, + errorText, + fieldValue, + } = props; + + return ( + value.value === option.value} + label="Value" + limitTags={1} + multiple={multiple} + onBlur={fieldOnBlur} + onChange={(_, selected, operation) => { + const newValue = handleValueChange( + selected, + operation, + multiple ?? false + ); + fieldOnChange(newValue); + }} + options={values} + placeholder={placeholderText} + sx={{ flex: 1 }} + value={resolveSelectedValues(values, fieldValue, multiple ?? false)} + /> + ); +}; diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/ValueFieldRenderer.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/ValueFieldRenderer.test.tsx new file mode 100644 index 00000000000..373f1a5867d --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/ValueFieldRenderer.test.tsx @@ -0,0 +1,128 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { vi } from 'vitest'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { ValueFieldRenderer } from './ValueFieldRenderer'; + +import type { + CloudPulseServiceType, + DimensionFilterOperatorType, +} from '@linode/api-v4'; + +vi.mock('./useFetchOptions', () => ({ + useFetchOptions: () => [ + { label: 'TCP', value: 'tcp' }, + { label: 'UDP', value: 'udp' }, + ], +})); + +const EQ: DimensionFilterOperatorType = 'eq'; +const IN: DimensionFilterOperatorType = 'in'; +const NB: CloudPulseServiceType = 'nodebalancer'; +describe('', () => { + const defaultProps = { + serviceType: NB, + name: `rule_criteria.rules.${0}.dimension_filters.${0}`, + dimensionLabel: 'protocol', + disabled: false, + entities: [], + errorText: '', + onBlur: vi.fn(), + onChange: vi.fn(), + operator: EQ, + value: null, + values: null, + }; + + it('renders a TextField if config type is textfield', () => { + const props = { + ...defaultProps, + dimensionLabel: 'port', // assuming this maps to textfield in valueFieldConfig + operator: EQ, + }; + + renderWithTheme(); + expect(screen.getByLabelText('Value')).toBeVisible(); + expect(screen.getByTestId('textfield-input')).toBeVisible(); + }); + + it('renders an Autocomplete if config type is autocomplete', () => { + const props = { + ...defaultProps, + dimensionLabel: 'protocol', // assuming this maps to autocomplete + operator: IN, + values: ['tcp', 'udp'], + }; + + renderWithTheme(); + expect(screen.getByRole('combobox')).toBeVisible(); + }); + + it('calls onChange when typing into TextField', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + const props = { + ...defaultProps, + dimensionLabel: 'port', + operator: EQ, + onChange, + }; + + renderWithTheme(); + const input = screen.getByLabelText('Value'); + await user.type(input, '8080'); + expect(onChange).toHaveBeenLastCalledWith('8080'); + }); + + it('calls onBlur from TextField', async () => { + const user = userEvent.setup(); + const onBlur = vi.fn(); + const props = { + ...defaultProps, + dimensionLabel: 'port', + operator: IN, + onBlur, + }; + + renderWithTheme(); + const input = screen.getByLabelText('Value'); + await user.click(input); + await user.tab(); // blur + expect(onBlur).toHaveBeenCalled(); + }); + + it('calls onChange from Autocomplete', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + const props = { + ...defaultProps, + dimensionLabel: 'protocol', + operator: IN, + onChange, + values: ['tcp', 'udp'], + }; + + renderWithTheme(); + const input = screen.getByRole('combobox'); + await user.click(input); + await user.type(input, 'TCP'); + await user.click(await screen.findByText('TCP')); + + expect(onChange).toHaveBeenLastCalledWith('tcp'); + }); + + it('returns TextField when no config and no operator is found', () => { + // fallback case + const props = { + ...defaultProps, + dimensionLabel: 'nonexistent', + operator: null, + }; + + renderWithTheme(); + expect(screen.getByTestId('textfield-input')).toBeVisible(); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/ValueFieldRenderer.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/ValueFieldRenderer.tsx new file mode 100644 index 00000000000..a75c2bc1bf6 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/ValueFieldRenderer.tsx @@ -0,0 +1,156 @@ +import { TextField } from '@linode/ui'; +import React, { useMemo } from 'react'; + +import { + MULTISELECT_PLACEHOLDER_TEXT, + SINGLESELECT_PLACEHOLDER_TEXT, + TEXTFIELD_PLACEHOLDER_TEXT, + valueFieldConfig, +} from './constants'; +import { DimensionFilterAutocomplete } from './DimensionFilterAutocomplete'; +import { getOperatorGroup, getStaticOptions } from './utils'; + +import type { OperatorGroup, ValueFieldConfig } from './constants'; +import type { + CloudPulseServiceType, + DimensionFilterOperatorType, +} from '@linode/api-v4'; + +interface ValueFieldRendererProps { + /** + * The dimension label extracted from the Dimension Data. + */ + dimensionLabel: null | string; + + /** + * Disables the input field when set to true. + */ + disabled: boolean; + + /** + * List of entity IDs used to filter resources like firewalls. + */ + entities?: string[]; + /** + * Error message to be displayed under the input field, if any. + */ + errorText: string | undefined; + + /** + * The name of the field set in the form. + */ + name: string; + + /** + * Triggered when the input field loses focus. + */ + onBlur: () => void; + + /** + * Callback fired when the value changes. + */ + onChange: (value: string | string[]) => void; + + /** + * The operator used in the current filter. Used to determine the type of input to show. + */ + operator: DimensionFilterOperatorType | null; + /** + * Service type of the alert + */ + serviceType?: CloudPulseServiceType | null; + + /** + * The currently selected value for the input field. + */ + value: null | string; + + /** + * List of pre-defined values, used for static autocomplete options. + */ + values: null | string[]; +} + +export const ValueFieldRenderer = (props: ValueFieldRendererProps) => { + const { + serviceType, + dimensionLabel, + disabled, + errorText, + name, + onBlur, + onChange, + operator, + value, + values, + } = props; + // Use operator group for config lookup + const operatorGroup = getOperatorGroup(operator); + let dimensionConfig: Record; + + if (dimensionLabel && valueFieldConfig[dimensionLabel]) { + // 1. Use dimension-specific config if available + dimensionConfig = valueFieldConfig[dimensionLabel]; + } else if (!values || values.length === 0) { + // 2. No dimension-specific config & no values → use emptyValue + dimensionConfig = valueFieldConfig['emptyValue']; + } else { + // 3. No dimension-specific config & values present → use * + dimensionConfig = valueFieldConfig['*']; + } + const config = dimensionConfig[operatorGroup]; + const staticOptions = useMemo( + () => + getStaticOptions( + serviceType ?? undefined, + dimensionLabel ?? '', + values ?? [] + ), + [dimensionLabel, serviceType, values] + ); + if (!config) return null; + + if (config.type === 'textfield') { + return ( + onChange(e.target.value)} + placeholder={config.placeholder ?? TEXTFIELD_PLACEHOLDER_TEXT} + sx={{ flex: 1 }} + type={config.inputType} + value={value ?? ''} + /> + ); + } + + if (config.type === 'autocomplete') { + const autocompletePlaceholder = config.multiple + ? MULTISELECT_PLACEHOLDER_TEXT + : SINGLESELECT_PLACEHOLDER_TEXT; + const items = staticOptions; + return ( + + ); + } + + return null; +}; diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/ValueSchemas.ts b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/ValueSchemas.ts new file mode 100644 index 00000000000..26e9c868b7a --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/ValueSchemas.ts @@ -0,0 +1,209 @@ +import { string } from 'yup'; + +import { + PORTS_CONSECUTIVE_COMMAS_ERROR_MESSAGE, + PORTS_ERROR_MESSAGE, + PORTS_HELPER_TEXT, + PORTS_LEADING_COMMA_ERROR_MESSAGE, + PORTS_LEADING_ZERO_ERROR_MESSAGE, + PORTS_LIMIT_ERROR_MESSAGE, + PORTS_RANGE_ERROR_MESSAGE, +} from 'src/features/CloudPulse/Utils/constants'; + +import { + CONFIG_ERROR_MESSAGE, + CONFIG_IDS_CONSECUTIVE_COMMAS_ERROR_MESSAGE, + CONFIGS_ERROR_MESSAGE, + CONFIGS_HELPER_TEXT, + PORT_HELPER_TEXT, + PORTS_TRAILING_COMMA_ERROR_MESSAGE, +} from '../../../constants'; + +const fieldErrorMessage = 'This field is required.'; +const DECIMAL_PORT_REGEX = /^[1-9]\d{0,4}$/; +const LEADING_ZERO_PORT_REGEX = /^0\d+/; +const CONFIG_NUMBER_REGEX = /^\d+$/; + +// Validation schema for a single input port +const singlePortSchema = string().test( + 'validate-single-port', + PORT_HELPER_TEXT, + function (value) { + if (!value || typeof value !== 'string') { + return this.createError({ message: fieldErrorMessage }); + } + + if (LEADING_ZERO_PORT_REGEX.test(value)) { + return this.createError({ + message: PORTS_LEADING_ZERO_ERROR_MESSAGE, + }); + } + + if (!DECIMAL_PORT_REGEX.test(value)) { + return this.createError({ message: PORTS_RANGE_ERROR_MESSAGE }); + } + const num = Number(value); + if (!Number.isInteger(num) || num < 1 || num > 65535) { + return this.createError({ message: PORTS_RANGE_ERROR_MESSAGE }); + } + + return true; + } +); + +// Validation schema for a multiple comma-separated ports +const commaSeparatedPortListSchema = string().test( + 'validate-port-list', + PORTS_HELPER_TEXT, + function (value) { + if (!value || typeof value !== 'string') { + return this.createError({ message: fieldErrorMessage }); + } + + if (value.includes(' ')) { + return this.createError({ message: PORTS_ERROR_MESSAGE }); + } + + if (value.trim().endsWith(',')) { + return this.createError({ message: PORTS_TRAILING_COMMA_ERROR_MESSAGE }); + } + + if (value.trim().startsWith(',')) { + return this.createError({ message: PORTS_LEADING_COMMA_ERROR_MESSAGE }); + } + + if (value.includes('.')) { + return this.createError({ message: PORTS_HELPER_TEXT }); + } + + const rawSegments = value.split(','); + + // Check for empty segments (consecutive commas, or commas with just spaces) + if (rawSegments.some((segment) => segment.trim() === '')) { + return this.createError({ + message: PORTS_CONSECUTIVE_COMMAS_ERROR_MESSAGE, + }); + } + + const ports = rawSegments.map((p) => p.trim()); + + if (ports.length > 15) { + return this.createError({ + message: PORTS_LIMIT_ERROR_MESSAGE, + }); + } + for (const port of ports) { + const trimmedPort = port.trim(); + + if (LEADING_ZERO_PORT_REGEX.test(trimmedPort)) { + return this.createError({ + message: PORTS_LEADING_ZERO_ERROR_MESSAGE, + }); + } + if (!DECIMAL_PORT_REGEX.test(trimmedPort)) { + return this.createError({ message: PORTS_HELPER_TEXT }); + } + + const num = Number(trimmedPort); + if (!Number.isInteger(num) || num < 1 || num > 65535) { + return this.createError({ message: PORTS_RANGE_ERROR_MESSAGE }); + } + } + + return true; + } +); +const singleConfigSchema = string() + .max(100, 'Value must be 100 characters or less.') + .test( + 'validate-single-config-schema', + CONFIG_ERROR_MESSAGE, + function (value) { + if (!value || typeof value !== 'string') { + return this.createError({ message: fieldErrorMessage }); + } + + if (!CONFIG_NUMBER_REGEX.test(value)) { + return this.createError({ message: CONFIG_ERROR_MESSAGE }); + } + return true; + } + ); + +const multipleConfigSchema = string() + .max(100, 'Value must be 100 characters or less.') + .test( + 'validate-multi-config-schema', + CONFIGS_ERROR_MESSAGE, + function (value) { + if (!value || typeof value !== 'string') { + return this.createError({ message: fieldErrorMessage }); + } + if (value.includes(' ')) { + return this.createError({ message: CONFIGS_ERROR_MESSAGE }); + } + + if (value.trim().endsWith(',')) { + return this.createError({ + message: PORTS_TRAILING_COMMA_ERROR_MESSAGE, + }); + } + + if (value.trim().startsWith(',')) { + return this.createError({ message: PORTS_LEADING_COMMA_ERROR_MESSAGE }); + } + + if (value.trim().includes(',,')) { + return this.createError({ + message: CONFIG_IDS_CONSECUTIVE_COMMAS_ERROR_MESSAGE, + }); + } + if (value.includes('.')) { + return this.createError({ message: CONFIGS_HELPER_TEXT }); + } + + const rawSegments = value.split(','); + // Check for empty segments + if (rawSegments.some((segment) => segment.trim() === '')) { + return this.createError({ + message: CONFIG_IDS_CONSECUTIVE_COMMAS_ERROR_MESSAGE, + }); + } + for (const configId of rawSegments) { + const trimmedConfigId = configId.trim(); + + if (!CONFIG_NUMBER_REGEX.test(trimmedConfigId)) { + return this.createError({ message: CONFIG_ERROR_MESSAGE }); + } + } + return true; + } + ); + +const baseValueSchema = string() + .required(fieldErrorMessage) + .nullable() + .test('nonNull', fieldErrorMessage, (value) => value !== null); + +interface GetValueSchemaParams { + dimensionLabel: string; + operator: string; +} + +export const getDimensionFilterValueSchema = ({ + dimensionLabel, + operator, +}: GetValueSchemaParams) => { + if (dimensionLabel === 'port') { + const portSchema = + operator === 'in' ? commaSeparatedPortListSchema : singlePortSchema; + return portSchema.concat(baseValueSchema); + } + if (dimensionLabel === 'config_id') { + const configSchema = + operator === 'in' ? multipleConfigSchema : singleConfigSchema; + return configSchema.concat(baseValueSchema); + } + + return baseValueSchema; +}; diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/constants.ts b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/constants.ts new file mode 100644 index 00000000000..2a9304ff064 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/constants.ts @@ -0,0 +1,207 @@ +import { PORTS_HELPER_TEXT } from 'src/features/CloudPulse/Utils/constants'; + +import { + CONFIG_ERROR_MESSAGE, + CONFIG_ID_PLACEHOLDER_TEXT, + CONFIGS_HELPER_TEXT, + CONFIGS_ID_PLACEHOLDER_TEXT, + PORT_HELPER_TEXT, + PORT_PLACEHOLDER_TEXT, + PORTS_PLACEHOLDER_TEXT, +} from '../../../constants'; + +export const MULTISELECT_PLACEHOLDER_TEXT = 'Select Values'; +export const TEXTFIELD_PLACEHOLDER_TEXT = 'Enter a Value'; +export const SINGLESELECT_PLACEHOLDER_TEXT = 'Select a Value'; + +/** + * Type definition for the value field renderer props. + * - 'autocomplete': Renders a select/multi-select dropdown. + * - 'textfield': Renders a free-form input field. + */ +export type ValueFieldType = 'autocomplete' | 'textfield'; + +/** + * Base configuration interface for the Value input components. + */ +export interface BaseConfig { + /** + * Specifies which type of input component to render. + */ + type: ValueFieldType; +} + +/** + * Configuration interface for the TextField-based Value input. + */ +export interface TextFieldConfig extends BaseConfig { + /** + * Optional helper text to render below the input field (e.g., hints or constraints). + */ + helperText?: string; + + /** + * - 'number': Renders an input that only accepts numeric values. + * - 'text': Accepts any textual input. + */ + inputType: 'number' | 'text'; + + /** + * Optional upper bound for numeric inputs (used with inputType: 'number'). + */ + max?: number; + + /** + * Optional lower bound for numeric inputs (used with inputType: 'number'). + */ + min?: number; + + /** + * Placeholder text to show in the field before a value is entered. + */ + placeholder?: string; + + /** + * Enforces that this config is for a textfield input. + */ + type: 'textfield'; +} + +/** + * Configuration interface for the Autocomplete-based Value input. + */ +export interface AutocompleteConfig extends BaseConfig { + /** + * Indicates whether the Autocomplete supports selecting multiple options. + */ + multiple: boolean; + + /** + * Optional placeholder to display when no value is selected. + */ + placeholder?: string; + + /** + * Enforces that this config is for an autocomplete input. + */ + type: 'autocomplete'; + + /** + * Flag to use a custom fetch function instead of the static options. + */ + useCustomFetch?: boolean; +} + +/** + * Union of configuration types used to dynamically render + * either a TextField or Autocomplete input component. + */ +export type ValueFieldConfig = AutocompleteConfig | TextFieldConfig; + +/** + * Operator grouping categories used to map to appropriate config. + */ +export type OperatorGroup = '*' | 'eq_neq' | 'in' | 'startswith_endswith'; + +/** + * Configuration map that defines the input UI to render + * based on a given dimension and the operator type. + */ +export type ValueFieldConfigMap = Record< + string, + Record +>; + +/** + * Full config for each dimension, operator group pair. + */ +export const valueFieldConfig: ValueFieldConfigMap = { + port: { + eq_neq: { + type: 'textfield', + inputType: 'number', + placeholder: PORT_PLACEHOLDER_TEXT, + min: 1, + max: 65535, + helperText: PORT_HELPER_TEXT, + }, + startswith_endswith: { + type: 'textfield', + inputType: 'number', + placeholder: PORT_PLACEHOLDER_TEXT, + helperText: PORT_HELPER_TEXT, + min: 1, + max: 65535, + }, + in: { + type: 'textfield', + inputType: 'text', + placeholder: PORTS_PLACEHOLDER_TEXT, + helperText: PORTS_HELPER_TEXT, + }, + '*': { + type: 'textfield', + inputType: 'number', + }, + }, + config_id: { + eq_neq: { + type: 'textfield', + inputType: 'number', + placeholder: CONFIG_ID_PLACEHOLDER_TEXT, + helperText: CONFIG_ERROR_MESSAGE, + }, + startswith_endswith: { + type: 'textfield', + inputType: 'number', + placeholder: CONFIG_ID_PLACEHOLDER_TEXT, + helperText: CONFIG_ERROR_MESSAGE, + }, + in: { + type: 'textfield', + inputType: 'text', + placeholder: CONFIGS_ID_PLACEHOLDER_TEXT, + helperText: CONFIGS_HELPER_TEXT, + }, + '*': { + type: 'textfield', + inputType: 'number', + }, + }, + emptyValue: { + eq_neq: { + type: 'textfield', + inputType: 'text', + }, + startswith_endswith: { + type: 'textfield', + inputType: 'text', + }, + in: { + type: 'textfield', + inputType: 'text', + }, + '*': { + type: 'textfield', + inputType: 'text', + }, + }, + '*': { + eq_neq: { + type: 'autocomplete', + multiple: false, + }, + startswith_endswith: { + type: 'textfield', + inputType: 'text', + }, + in: { + type: 'autocomplete', + multiple: true, + }, + '*': { + type: 'textfield', + inputType: 'text', + }, + }, +}; diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/useFetchOptions.ts b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/useFetchOptions.ts new file mode 100644 index 00000000000..6597f8280de --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/useFetchOptions.ts @@ -0,0 +1,122 @@ +import { useAllLinodesQuery } from '@linode/queries'; +import { useMemo } from 'react'; + +import { useResourcesQuery } from 'src/queries/cloudpulse/resources'; + +import { filterRegionByServiceType } from '../../../Utils/utils'; +import { + getFilteredFirewallResources, + getFirewallLinodes, + getLinodeRegions, +} from './utils'; + +import type { Item } from '../../../constants'; +import type { CloudPulseServiceType, Filter, Region } from '@linode/api-v4'; + +interface FetchOptionsProps { + /** + * The dimension label determines the filtering logic and return type. + */ + dimensionLabel: null | string; + /** + * List of firewall entity IDs to filter on. + */ + entities?: string[]; + /** + * List of regions to filter on. + */ + regions?: Region[]; + /** + * Service to apply specific transformations to dimension values. + */ + serviceType?: CloudPulseServiceType | null; + /** + * The type of monitoring to filter on. + */ + type: 'alerts' | 'metrics'; +} +/** + * Custom hook to return selectable options based on the dimension type. + * Handles fetching and transforming data for edge-cases. + */ +export function useFetchOptions( + props: FetchOptionsProps +): Item[] { + const { dimensionLabel, regions, entities, serviceType, type } = props; + + const supportedRegionIds = + (serviceType && + regions && + filterRegionByServiceType(type, regions, serviceType).map( + ({ id }) => id + )) || + []; + + // Create a filter for regions based on suppoerted region IDs + const regionFilter: Filter = + supportedRegionIds && supportedRegionIds.length > 0 + ? { + '+or': supportedRegionIds.map((regionId) => ({ + region: regionId, + })), + } + : {}; + + const filterLabels: string[] = [ + 'parent_vm_entity_id', + 'region_id', + 'associated_entity_region', + ]; + + // Fetch all firewall resources when dimension requires it + const { data: firewallResources } = useResourcesQuery( + filterLabels.includes(dimensionLabel ?? ''), + 'firewall' + ); + + // Filter firewall resources by the given entities list + const filteredFirewallResourcesIds = useMemo( + () => getFilteredFirewallResources(firewallResources, entities), + [firewallResources, entities] + ); + + const idFilter = filteredFirewallResourcesIds.length + ? { '+or': filteredFirewallResourcesIds.map((id) => ({ id })) } + : []; + + const combinedFilter: Filter = { + '+and': [idFilter, regionFilter].filter(Boolean) as Filter[], + }; + // Fetch all linodes with the combined filter + const { data: linodes } = useAllLinodesQuery( + {}, + combinedFilter, + filterLabels.includes(dimensionLabel ?? '') && + filteredFirewallResourcesIds.length > 0 && + supportedRegionIds.length > 0 + ); + + // Extract linodes from filtered firewall resources + const firewallLinodes = useMemo( + () => getFirewallLinodes(linodes ?? []), + [linodes] + ); + + // Extract unique regions from linodes + const linodeRegions = useMemo( + () => getLinodeRegions(linodes ?? []), + [linodes] + ); + + // Determine what options to return based on the dimension label + switch (dimensionLabel) { + case 'associated_entity_region': + return linodeRegions; + case 'parent_vm_entity_id': + return firewallLinodes; + case 'region_id': + return linodeRegions; + default: + return []; + } +} diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/utils.test.ts b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/utils.test.ts new file mode 100644 index 00000000000..014736e58fa --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/utils.test.ts @@ -0,0 +1,195 @@ +import { linodeFactory } from '@linode/utilities'; + +import { transformDimensionValue } from '../../../Utils/utils'; +import { + getFilteredFirewallResources, + getFirewallLinodes, + getLinodeRegions, + getOperatorGroup, + getStaticOptions, + handleValueChange, + resolveSelectedValues, +} from './utils'; + +import type { Linode } from '@linode/api-v4'; +import type { CloudPulseResources } from 'src/features/CloudPulse/shared/CloudPulseResourcesSelect'; + +describe('Utils', () => { + describe('resolveSelectedValues', () => { + const options = [ + { label: 'Option One', value: 'one' }, + { label: 'Option Two', value: 'two' }, + { label: 'Option Three', value: 'three' }, + ]; + + it('should return null if value is null and not multiple', () => { + expect(resolveSelectedValues(options, null, false)).toBeNull(); + }); + + it('should return empty array if value is null and multiple', () => { + expect(resolveSelectedValues(options, null, true)).toEqual([]); + }); + + it('should return matched option for single value', () => { + expect(resolveSelectedValues(options, 'two', false)).toEqual(options[1]); + }); + + it('should return matched options for multiple values', () => { + expect(resolveSelectedValues(options, 'one,two', true)).toEqual([ + options[0], + options[1], + ]); + }); + }); + + describe('handleValueChange', () => { + const selectedSingle = { label: 'One', value: 'one' }; + const selectedMultiple = [ + { label: 'One', value: 'one' }, + { label: 'Two', value: 'two' }, + ]; + + it('should return empty string if operation is not selectOption/removeOption', () => { + expect(handleValueChange(selectedSingle, 'blur', false)).toBe(''); + }); + + it('should return single value string', () => { + expect(handleValueChange(selectedSingle, 'selectOption', false)).toBe( + 'one' + ); + }); + + it('should return comma-separated string for multiple', () => { + expect(handleValueChange(selectedMultiple, 'selectOption', true)).toBe( + 'one,two' + ); + }); + }); + + describe('getOperatorGroup', () => { + it('should return correct group for eq/neq', () => { + expect(getOperatorGroup('eq')).toBe('eq_neq'); + expect(getOperatorGroup('neq')).toBe('eq_neq'); + }); + + it('should return correct group for startswith/endswith', () => { + expect(getOperatorGroup('startswith')).toBe('startswith_endswith'); + expect(getOperatorGroup('endswith')).toBe('startswith_endswith'); + }); + + it('should return in for operator in', () => { + expect(getOperatorGroup('in')).toBe('in'); + }); + + it('should return * for unknown/null operators', () => { + expect(getOperatorGroup(null)).toBe('*'); + }); + }); + + describe('getStaticOptions', () => { + it('should return transformed label/value pairs', () => { + expect( + getStaticOptions('nodebalancer', 'protocol', ['tcp', 'udp']) + ).toEqual([ + { + label: transformDimensionValue('nodebalancer', 'protocol', 'tcp'), + value: 'tcp', + }, + { + label: transformDimensionValue('nodebalancer', 'protocol', 'udp'), + value: 'udp', + }, + ]); + }); + + it('should return empty array if input is null', () => { + expect(getStaticOptions('linode', 'dim', null)).toEqual([]); + }); + }); + + describe('getFilteredFirewallResources', () => { + const resources: CloudPulseResources[] = [ + { + id: '1', + entities: { a: 'linode-1' }, + label: 'firewall-1', + }, + { + id: '2', + entities: { b: 'linode-2' }, + label: 'firewall-2', + }, + ]; + + it('should return matched resources by entity IDs', () => { + expect(getFilteredFirewallResources(resources, ['1'])).toEqual(['a']); + }); + + it('should return empty array if no match', () => { + expect(getFilteredFirewallResources(resources, ['3'])).toEqual([]); + }); + + it('should handle undefined inputs', () => { + expect(getFilteredFirewallResources(undefined, ['1'])).toEqual([]); + expect(getFilteredFirewallResources(resources, undefined)).toEqual([]); + }); + }); + + describe('getFirewallLinodes', () => { + const linodes: Linode[] = linodeFactory.buildList(2); + + it('should return linode options with transformed labels', () => { + expect(getFirewallLinodes(linodes)).toEqual([ + { + label: transformDimensionValue( + 'firewall', + 'parent_vm_entity_id', + linodes[0].label + ), + value: linodes[0].id.toString(), + }, + { + label: transformDimensionValue( + 'firewall', + 'parent_vm_entity_id', + linodes[1].label + ), + value: linodes[1].id.toString(), + }, + ]); + }); + + it('should handle empty linode list', () => { + expect(getFirewallLinodes([])).toEqual([]); + }); + }); + + describe('getLinodeRegions', () => { + it('should extract and deduplicate regions', () => { + const linodes = linodeFactory.buildList(3, { + region: 'us-east', + }); + linodes[1].region = 'us-west'; // introduce a second unique region + + const result = getLinodeRegions(linodes); + expect(result).toEqual([ + { + label: transformDimensionValue( + 'firewall', + 'region_id', + linodes[0].region + ), + value: 'us-east', + }, + { + label: transformDimensionValue( + 'firewall', + 'region_id', + linodes[1].region + ), + value: 'us-west', + }, + ]); + }); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/utils.ts b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/utils.ts new file mode 100644 index 00000000000..914f3c012fb --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/utils.ts @@ -0,0 +1,142 @@ +import { transformDimensionValue } from '../../../Utils/utils'; + +import type { Item } from '../../../constants'; +import type { OperatorGroup } from './constants'; +import type { + CloudPulseServiceType, + DimensionFilterOperatorType, + Linode, +} from '@linode/api-v4'; +import type { CloudPulseResources } from 'src/features/CloudPulse/shared/CloudPulseResourcesSelect'; + +/** + * Resolves the selected value(s) for the Autocomplete component from raw string. + * @param options - List of selectable options. + * @param value - The selected value(s) in raw string format. + * @param isMultiple - Whether multiple values are allowed. + * @returns - Matched option(s) for the Autocomplete input. + */ +export const resolveSelectedValues = ( + options: Item[], + value: null | string, + isMultiple: boolean +): Item | Item[] | null => { + if (!value) return isMultiple ? [] : null; + + if (isMultiple) { + return options.filter((option) => value.split(',').includes(option.value)); + } + + return options.find((option) => option.value === value) ?? null; +}; + +/** + * Converts selected option(s) from Autocomplete into a raw value string. + * @param selected - Currently selected value(s) from Autocomplete. + * @param operation - The triggered Autocomplete action (e.g., 'selectOption'). + * @param isMultiple - Whether multiple selections are enabled. + * @returns - Comma-separated string or single value. + */ +export const handleValueChange = ( + selected: Item | Item[] | null, + operation: string, + isMultiple: boolean +): string => { + if (!['removeOption', 'selectOption'].includes(operation)) return ''; + + if (isMultiple && Array.isArray(selected)) { + return selected.map((item) => item.value).join(','); + } + + if (!isMultiple && selected && !Array.isArray(selected)) { + return selected.value; + } + + return ''; +}; + +/** + * Resolves the operator into a corresponding group key. + * @param operator - The dimension filter operator. + * @returns - Mapped operator group used for config lookup. + */ +export const getOperatorGroup = ( + operator: DimensionFilterOperatorType | null +): OperatorGroup => { + if (operator === 'eq' || operator === 'neq') return 'eq_neq'; + if (operator === 'startswith' || operator === 'endswith') + return 'startswith_endswith'; + if (operator === 'in') return 'in'; + return '*'; // fallback for null/undefined/other +}; + +/** + * Converts a list of raw values to static options for Autocomplete. + * @param values - List of raw string values. + * @returns - List of label/value option objects. + */ +export const getStaticOptions = ( + serviceType: CloudPulseServiceType | undefined, + dimensionLabel: string, + values: null | string[] +): Item[] => { + return ( + values?.map((val: string) => ({ + label: transformDimensionValue(serviceType ?? null, dimensionLabel, val), + value: val, + })) ?? [] + ); +}; + +/** + * Filters firewall resources and returns matching entity IDs. + * @param firewallResources - List of firewall resource objects. + * @param entities - List of target firewall entity IDs. + * @returns - Flattened array of matching entity IDs. + */ +export const getFilteredFirewallResources = ( + firewallResources: CloudPulseResources[] | undefined, + entities: string[] | undefined +): string[] => { + if (!(firewallResources?.length && entities?.length)) return []; + + return firewallResources + .filter((firewall) => entities.includes(firewall.id)) + .flatMap((firewall) => + firewall.entities ? Object.keys(firewall.entities) : [] + ); +}; + +/** + * Extracts linode items from firewall resources by merging entities. + * @param resources - List of firewall resources with entity mappings. + * @returns - Flattened list of linode ID/label pairs as options. + */ +export const getFirewallLinodes = ( + linodes: Linode[] +): Item[] => { + if (!linodes) return []; + return linodes.map((linode) => ({ + label: transformDimensionValue( + 'firewall', + 'parent_vm_entity_id', + linode.label + ), + value: String(linode.id), + })); +}; + +/** + * Extracts unique region values from a list of linodes. + * @param linodes - Linode objects with region information. + * @returns - Deduplicated list of regions as options. + */ +export const getLinodeRegions = (linodes: Linode[]): Item[] => { + if (!linodes) return []; + const regions = new Set(); + linodes.forEach(({ region }) => region && regions.add(region)); + return Array.from(regions).map((region) => ({ + label: transformDimensionValue('firewall', 'region_id', region), + value: region, + })); +}; diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/MetricCriteria.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/MetricCriteria.tsx index 25358f22650..f41bf6426d3 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/MetricCriteria.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/MetricCriteria.tsx @@ -16,7 +16,7 @@ import { convertToSeconds } from '../utilities'; import { Metric } from './Metric'; import type { CreateAlertDefinitionForm, MetricCriteriaForm } from '../types'; -import type { AlertServiceType } from '@linode/api-v4'; +import type { CloudPulseServiceType } from '@linode/api-v4'; interface MetricCriteriaProps { /** @@ -26,7 +26,7 @@ interface MetricCriteriaProps { /** * serviceType used by the api to fetch the metric definitions */ - serviceType: AlertServiceType | null; + serviceType: CloudPulseServiceType | null; /** * function used to pass the scrape interval value to the parent component * @param maxInterval number value that takes the maximum scrape interval from the list of selected metrics diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/AlertEntityScopeSelect.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/AlertEntityScopeSelect.tsx index 5938b7ebdda..ab473a857b2 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/AlertEntityScopeSelect.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/AlertEntityScopeSelect.tsx @@ -15,7 +15,10 @@ import { import type { AlertFormMode } from '../../constants'; import type { CreateAlertDefinitionForm } from '../types'; -import type { AlertDefinitionScope, AlertServiceType } from '@linode/api-v4'; +import type { + AlertDefinitionScope, + CloudPulseServiceType, +} from '@linode/api-v4'; interface ScopeOption { disabled: boolean; label: string; @@ -27,7 +30,7 @@ interface AlertEntityScopeSelectProps { CreateAlertDefinitionForm, AlertDefinitionScope | null | undefined >; - serviceType: AlertServiceType | null; + serviceType: CloudPulseServiceType | null; } export const AlertEntityScopeSelect = (props: AlertEntityScopeSelectProps) => { const { name, serviceType, formMode = 'create' } = props; 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 b66f2c38abf..851f6e384cb 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/ResourceMultiSelect.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/ResourceMultiSelect.tsx @@ -7,7 +7,7 @@ import { useResourcesQuery } from 'src/queries/cloudpulse/resources'; import type { Item } from '../../constants'; import type { CreateAlertDefinitionForm } from '../types'; -import type { AlertServiceType } from '@linode/api-v4'; +import type { CloudPulseServiceType } from '@linode/api-v4'; interface CloudPulseResourceSelectProps { /** @@ -25,7 +25,7 @@ interface CloudPulseResourceSelectProps { /** * service type selected by the user */ - serviceType: AlertServiceType | null; + serviceType: CloudPulseServiceType | null; } export const CloudPulseMultiResourceSelect = ( diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/ServiceTypeSelect.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/ServiceTypeSelect.test.tsx index 9b426c4d01f..128924a929b 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/ServiceTypeSelect.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/ServiceTypeSelect.test.tsx @@ -1,4 +1,4 @@ -import { screen } from '@testing-library/react'; +import { screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import * as React from 'react'; @@ -7,8 +7,11 @@ import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; import { CloudPulseServiceSelect } from './ServiceTypeSelect'; +import type { AclpServices } from 'src/featureFlags'; + const queryMocks = vi.hoisted(() => ({ useCloudPulseServiceTypes: vi.fn().mockReturnValue({}), + useFlags: vi.fn(), })); vi.mock('src/queries/cloudpulse/services', async () => { @@ -19,6 +22,10 @@ vi.mock('src/queries/cloudpulse/services', async () => { }; }); +vi.mock('src/hooks/useFlags', () => ({ + useFlags: queryMocks.useFlags, +})); + const mockResponse = { data: [ serviceTypesFactory.build({ @@ -32,13 +39,33 @@ const mockResponse = { ], }; -queryMocks.useCloudPulseServiceTypes.mockReturnValue({ - data: mockResponse, - isError: true, - isLoading: false, - status: 'success', -}); +const aclpServicesFlag: Partial = { + linode: { + alerts: { enabled: true, beta: true }, + metrics: { enabled: true, beta: true }, + }, + dbaas: { + alerts: { enabled: true, beta: true }, + metrics: { enabled: true, beta: true }, + }, +}; + +const linodeLabel = 'Linode beta'; +const databasesLabel = 'Databases beta'; + describe('ServiceTypeSelect component tests', () => { + beforeEach(() => { + queryMocks.useCloudPulseServiceTypes.mockReturnValue({ + data: mockResponse, + isError: false, + isLoading: false, + status: 'success', + }); + queryMocks.useFlags.mockReturnValue({ + aclpServices: aclpServicesFlag, + }); + }); + it('should render the Autocomplete component', () => { const { getAllByText, getByTestId } = renderWithThemeAndHookFormContext({ component: ( @@ -58,14 +85,14 @@ describe('ServiceTypeSelect component tests', () => { await userEvent.click(screen.getByRole('button', { name: 'Open' })); expect( await screen.findByRole('option', { - name: 'Linode', + name: linodeLabel, }) - ).toBeInTheDocument(); + ).toBeVisible(); expect( screen.getByRole('option', { - name: 'Databases', + name: databasesLabel, }) - ).toBeInTheDocument(); + ).toBeVisible(); }); it('should be able to select a service type', async () => { @@ -76,10 +103,11 @@ describe('ServiceTypeSelect component tests', () => { }); await userEvent.click(screen.getByRole('button', { name: 'Open' })); await userEvent.click( - await screen.findByRole('option', { name: 'Linode' }) + await screen.findByRole('option', { name: linodeLabel }) ); expect(screen.getByRole('combobox')).toHaveAttribute('value', 'Linode'); }); + it('should render error messages when there is an API call failure', () => { queryMocks.useCloudPulseServiceTypes.mockReturnValue({ data: undefined, @@ -95,4 +123,75 @@ describe('ServiceTypeSelect component tests', () => { screen.getByText('Failed to fetch the service types.') ).toBeInTheDocument(); }); + + it('should render the service types based on the aclp services flag', async () => { + queryMocks.useFlags.mockReturnValue({ + aclpServices: { + linode: { + alerts: { enabled: true, beta: true }, + metrics: { enabled: true, beta: true }, + }, + dbaas: { + alerts: { enabled: false, beta: true }, + metrics: { enabled: false, beta: true }, + }, + }, + }); + + renderWithThemeAndHookFormContext({ + component: ( + + ), + }); + const serviceFilterDropdown = screen.getByTestId('servicetype-select'); + await userEvent.click( + within(serviceFilterDropdown).getByRole('button', { name: 'Open' }) + ); + expect(screen.getByRole('option', { name: linodeLabel })).toBeVisible(); + expect(screen.queryByRole('option', { name: databasesLabel })).toBeNull(); // Verify that Databases is NOT present (filtered out by the flag) + }); + + it('should not return service types that are missing in the flag', async () => { + queryMocks.useFlags.mockReturnValue({ + aclpServices: { + linode: { + alerts: { enabled: true, beta: true }, + metrics: { enabled: true, beta: true }, + }, + }, + }); + + renderWithThemeAndHookFormContext({ + component: ( + + ), + }); + const serviceFilterDropdown = screen.getByTestId('servicetype-select'); + await userEvent.click( + within(serviceFilterDropdown).getByRole('button', { name: 'Open' }) + ); + expect(screen.getByRole('option', { name: linodeLabel })).toBeVisible(); + expect(screen.queryByRole('option', { name: 'Databases' })).toBeNull(); + }); + + it('should not return service types that are missing the alerts property in the flag', async () => { + queryMocks.useFlags.mockReturnValue({ + aclpServices: { + linode: { + metrics: { enabled: true, beta: true }, + }, + }, + }); + + renderWithThemeAndHookFormContext({ + component: ( + + ), + }); + const serviceFilterDropdown = screen.getByTestId('servicetype-select'); + await userEvent.click( + within(serviceFilterDropdown).getByRole('button', { name: 'Open' }) + ); + expect(screen.queryByRole('option', { name: 'Linode' })).toBeNull(); + }); }); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/ServiceTypeSelect.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/ServiceTypeSelect.tsx index 3f65cef9da4..debae3a3f1b 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/ServiceTypeSelect.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/ServiceTypeSelect.tsx @@ -14,7 +14,7 @@ import { useCloudPulseServiceTypes } from 'src/queries/cloudpulse/services'; import type { Item } from '../../constants'; import type { CreateAlertDefinitionForm } from '../types'; -import type { AlertServiceType } from '@linode/api-v4'; +import type { CloudPulseServiceType } from '@linode/api-v4'; interface CloudPulseServiceSelectProps { /** @@ -29,7 +29,10 @@ interface CloudPulseServiceSelectProps { /** * name used for the component in the form */ - name: FieldPathByValue; + name: FieldPathByValue< + CreateAlertDefinitionForm, + CloudPulseServiceType | null + >; } export const CloudPulseServiceSelect = ( @@ -42,18 +45,24 @@ export const CloudPulseServiceSelect = ( isLoading: serviceTypesLoading, } = useCloudPulseServiceTypes(true); const { control } = useFormContext(); - const { aclpBetaServices } = useFlags(); + const { aclpServices } = useFlags(); const getServicesList = React.useMemo((): Item< string, - AlertServiceType + CloudPulseServiceType >[] => { + // Return only the service types that are enabled in the aclpServices flag return serviceOptions?.data?.length - ? serviceOptions.data.map((service) => ({ - label: service.label, - value: service.service_type as AlertServiceType, - })) + ? serviceOptions.data + .filter( + (service) => + aclpServices?.[service.service_type]?.alerts?.enabled ?? false + ) + .map((service) => ({ + label: service.label, + value: service.service_type, + })) : []; - }, [serviceOptions]); + }, [aclpServices, serviceOptions]); return ( { if (selected) { @@ -92,8 +101,10 @@ export const CloudPulseServiceSelect = ( const { key, ...rest } = props; return ( - {option.label}{' '} - {aclpBetaServices?.[option.value]?.alerts && } + + {option.label} + + {aclpServices?.[option.value]?.alerts?.beta && } ); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/schemas.ts b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/schemas.ts index 98713d46b85..26d64142211 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/schemas.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/schemas.ts @@ -6,216 +6,11 @@ import { } from '@linode/validation'; import { array, lazy, mixed, number, object, string } from 'yup'; -import { - PORTS_CONSECUTIVE_COMMAS_ERROR_MESSAGE, - PORTS_ERROR_MESSAGE, - PORTS_HELPER_TEXT, - PORTS_LEADING_COMMA_ERROR_MESSAGE, - PORTS_LEADING_ZERO_ERROR_MESSAGE, - PORTS_LIMIT_ERROR_MESSAGE, - PORTS_RANGE_ERROR_MESSAGE, -} from '../../Utils/constants'; -import { - CONFIG_ERROR_MESSAGE, - CONFIG_IDS_CONSECUTIVE_COMMAS_ERROR_MESSAGE, - CONFIGS_ERROR_MESSAGE, - CONFIGS_HELPER_TEXT, - PORT_HELPER_TEXT, - PORTS_TRAILING_COMMA_ERROR_MESSAGE, -} from '../constants'; - -import type { AlertSeverityType } from '@linode/api-v4'; +import { getDimensionFilterValueSchema } from './Criteria/DimensionFilterValue/ValueSchemas'; +import type { AlertSeverityType, CloudPulseServiceType } from '@linode/api-v4'; const fieldErrorMessage = 'This field is required.'; -const DECIMAL_PORT_REGEX = /^[1-9]\d{0,4}$/; -const LEADING_ZERO_PORT_REGEX = /^0\d+/; -const CONFIG_NUMBER_REGEX = /^\d+$/; - -// Validation schema for a single input port -const singlePortSchema = string().test( - 'validate-single-port', - PORT_HELPER_TEXT, - function (value) { - if (!value || typeof value !== 'string') { - return this.createError({ message: fieldErrorMessage }); - } - - if (LEADING_ZERO_PORT_REGEX.test(value)) { - return this.createError({ - message: PORTS_LEADING_ZERO_ERROR_MESSAGE, - }); - } - - if (!DECIMAL_PORT_REGEX.test(value)) { - return this.createError({ message: PORTS_RANGE_ERROR_MESSAGE }); - } - const num = Number(value); - if (!Number.isInteger(num) || num < 1 || num > 65535) { - return this.createError({ message: PORTS_RANGE_ERROR_MESSAGE }); - } - - return true; - } -); - -// Validation schema for a multiple comma-separated ports -const commaSeparatedPortListSchema = string().test( - 'validate-port-list', - PORTS_HELPER_TEXT, - function (value) { - if (!value || typeof value !== 'string') { - return this.createError({ message: fieldErrorMessage }); - } - - if (value.includes(' ')) { - return this.createError({ message: PORTS_ERROR_MESSAGE }); - } - - if (value.trim().endsWith(',')) { - return this.createError({ message: PORTS_TRAILING_COMMA_ERROR_MESSAGE }); - } - - if (value.trim().startsWith(',')) { - return this.createError({ message: PORTS_LEADING_COMMA_ERROR_MESSAGE }); - } - - if (value.includes('.')) { - return this.createError({ message: PORTS_HELPER_TEXT }); - } - - const rawSegments = value.split(','); - - // Check for empty segments (consecutive commas, or commas with just spaces) - if (rawSegments.some((segment) => segment.trim() === '')) { - return this.createError({ - message: PORTS_CONSECUTIVE_COMMAS_ERROR_MESSAGE, - }); - } - - const ports = rawSegments.map((p) => p.trim()); - - if (ports.length > 15) { - return this.createError({ - message: PORTS_LIMIT_ERROR_MESSAGE, - }); - } - for (const port of ports) { - const trimmedPort = port.trim(); - - if (LEADING_ZERO_PORT_REGEX.test(trimmedPort)) { - return this.createError({ - message: PORTS_LEADING_ZERO_ERROR_MESSAGE, - }); - } - if (!DECIMAL_PORT_REGEX.test(trimmedPort)) { - return this.createError({ message: PORTS_HELPER_TEXT }); - } - - const num = Number(trimmedPort); - if (!Number.isInteger(num) || num < 1 || num > 65535) { - return this.createError({ message: PORTS_RANGE_ERROR_MESSAGE }); - } - } - - return true; - } -); - -const singleConfigSchema = string() - .max(100, 'Value must be 100 characters or less.') - .test( - 'validate-single-config-schema', - CONFIG_ERROR_MESSAGE, - function (value) { - if (!value || typeof value !== 'string') { - return this.createError({ message: fieldErrorMessage }); - } - - if (!CONFIG_NUMBER_REGEX.test(value)) { - return this.createError({ message: CONFIG_ERROR_MESSAGE }); - } - return true; - } - ); - -const multipleConfigSchema = string() - .max(100, 'Value must be 100 characters or less.') - .test( - 'validate-multi-config-schema', - CONFIGS_ERROR_MESSAGE, - function (value) { - if (!value || typeof value !== 'string') { - return this.createError({ message: fieldErrorMessage }); - } - if (value.includes(' ')) { - return this.createError({ message: CONFIGS_ERROR_MESSAGE }); - } - - if (value.trim().endsWith(',')) { - return this.createError({ - message: PORTS_TRAILING_COMMA_ERROR_MESSAGE, - }); - } - - if (value.trim().startsWith(',')) { - return this.createError({ message: PORTS_LEADING_COMMA_ERROR_MESSAGE }); - } - - if (value.trim().includes(',,')) { - return this.createError({ - message: CONFIG_IDS_CONSECUTIVE_COMMAS_ERROR_MESSAGE, - }); - } - if (value.includes('.')) { - return this.createError({ message: CONFIGS_HELPER_TEXT }); - } - - const rawSegments = value.split(','); - // Check for empty segments - if (rawSegments.some((segment) => segment.trim() === '')) { - return this.createError({ - message: CONFIG_IDS_CONSECUTIVE_COMMAS_ERROR_MESSAGE, - }); - } - for (const configId of rawSegments) { - const trimmedConfigId = configId.trim(); - - if (!CONFIG_NUMBER_REGEX.test(trimmedConfigId)) { - return this.createError({ message: CONFIGS_ERROR_MESSAGE }); - } - } - return true; - } - ); - -const baseValueSchema = string() - .required(fieldErrorMessage) - .nullable() - .test('nonNull', fieldErrorMessage, (value) => value !== null); - -interface GetValueSchemaParams { - dimensionLabel: string; - operator: string; -} - -export const getDimensionFilterValueSchema = ({ - dimensionLabel, - operator, -}: GetValueSchemaParams) => { - if (dimensionLabel === 'port') { - const portSchema = - operator === 'in' ? commaSeparatedPortListSchema : singlePortSchema; - return portSchema.concat(baseValueSchema); - } - if (dimensionLabel === 'config_id') { - const configIdSchema = - operator === 'in' ? multipleConfigSchema : singleConfigSchema; - - return configIdSchema.concat(baseValueSchema); - } - return baseValueSchema; -}; export const dimensionFiltersSchema = dimensionFilters.concat( object({ dimension_label: string() @@ -281,8 +76,7 @@ export const alertDefinitionFormSchema = createAlertDefinitionSchema.concat( .required() .min(1, 'At least one metric criteria is required.'), }).required(), - serviceType: string() - .oneOf(['linode', 'dbaas', 'firewall', 'nodebalancer']) + serviceType: mixed() .required(fieldErrorMessage) .nullable() .test('nonNull', fieldErrorMessage, (value) => value !== null), diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/types.ts b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/types.ts index fbc1711bde3..0b490f5aa48 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/types.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/types.ts @@ -1,8 +1,8 @@ import type { AlertDefinitionScope, - AlertServiceType, AlertSeverityType, ChannelType, + CloudPulseServiceType, CreateAlertDefinitionPayload, DimensionFilter, DimensionFilterOperatorType, @@ -23,7 +23,7 @@ export interface CreateAlertDefinitionForm rules: MetricCriteriaForm[]; }; scope?: AlertDefinitionScope | null; - serviceType: AlertServiceType | null; + serviceType: CloudPulseServiceType | null; severity: AlertSeverityType | null; trigger_conditions: TriggerConditionForm; } diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/utilities.ts b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/utilities.ts index 8f110986b29..00b36ad74af 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/utilities.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/utilities.ts @@ -8,8 +8,8 @@ import type { } from './types'; import type { AlertDefinitionScope, - AlertServiceType, AlertSeverityType, + CloudPulseServiceType, CreateAlertDefinitionPayload, DimensionFilter, EditAlertPayloadWithService, @@ -49,7 +49,7 @@ export const filterFormValues = ( */ export const filterEditFormValues = ( formValues: CreateAlertDefinitionForm, - serviceType: AlertServiceType, + serviceType: CloudPulseServiceType, severity: AlertSeverityType, alertId: number, scope: AlertDefinitionScope diff --git a/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertDefinition.tsx b/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertDefinition.tsx index 43a1a037660..781b75f4358 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertDefinition.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertDefinition.tsx @@ -35,10 +35,11 @@ import { import type { CreateAlertDefinitionForm as EditAlertDefintionForm } from '../CreateAlert/types'; import type { Alert, - AlertServiceType, APIError, + CloudPulseServiceType, EditAlertPayloadWithService, } from '@linode/api-v4'; +import type { CrumbOverridesProps } from 'src/components/Breadcrumb/Crumbs'; export interface EditAlertProps { /** @@ -48,7 +49,7 @@ export interface EditAlertProps { /** * The type of service associated with the alert */ - serviceType: AlertServiceType; + serviceType: CloudPulseServiceType; } export const EditAlertDefinition = (props: EditAlertProps) => { @@ -121,17 +122,12 @@ export const EditAlertDefinition = (props: EditAlertProps) => { }); const definitionLanding = '/alerts/definitions'; - const overrides = [ + const overrides: CrumbOverridesProps[] = [ { label: 'Definitions', linkTo: definitionLanding, position: 1, }, - { - label: 'Edit', - linkTo: `${definitionLanding}/edit/${serviceType}/${alertId}`, - position: 2, - }, ]; const previousSubmitCount = React.useRef(0); diff --git a/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertLanding.tsx b/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertLanding.tsx index aa1dfb81a46..eedb272a915 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertLanding.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertLanding.tsx @@ -10,20 +10,15 @@ import { StyledPlaceholder } from '../AlertsDetail/AlertDetail'; import { EditAlertDefinition } from './EditAlertDefinition'; import { EditAlertResources } from './EditAlertResources'; -import type { AlertServiceType } from '@linode/api-v4'; +import type { CloudPulseServiceType } from '@linode/api-v4'; import type { CrumbOverridesProps } from 'src/components/Breadcrumb/Crumbs'; -const overrides = [ +const overrides: CrumbOverridesProps[] = [ { label: 'Definitions', linkTo: '/alerts/definitions', position: 1, }, - { - label: 'Edit', - linkTo: `/alerts/definitions/edit`, - position: 2, - }, ]; export const EditAlertLanding = () => { @@ -65,14 +60,14 @@ export const EditAlertLanding = () => { return ( ); } else { return ( ); } diff --git a/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertResources.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertResources.test.tsx index 1b7dabb62e6..038debeaa2d 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertResources.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertResources.test.tsx @@ -1,9 +1,7 @@ import { linodeFactory, regionFactory } from '@linode/utilities'; import { waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { createMemoryHistory } from 'history'; import React from 'react'; -import { Router } from 'react-router-dom'; import { alertFactory } from 'src/factories'; import { renderWithTheme } from 'src/utilities/testHelpers'; @@ -160,15 +158,12 @@ describe('EditAlertResources component tests', () => { reset: vi.fn(), }); - const push = vi.fn(); - const history = createMemoryHistory(); // Create a memory history for testing - history.push = push; - history.push('/alerts/definitions/edit/linode/1'); - const { getByTestId, getByText } = renderWithTheme( - - - + , + { + initialEntries: ['/alerts/definitions/edit/linode/1'], + initialRoute: '/alerts/definitions/edit/linode/1', + } ); expect(getByTestId(saveResources)).toBeInTheDocument(); diff --git a/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertResources.tsx b/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertResources.tsx index 6ac187211d7..fb24c4b98c3 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertResources.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertResources.tsx @@ -13,6 +13,7 @@ import { getAlertBoxStyles } from '../Utils/utils'; import { EditAlertResourcesConfirmDialog } from './EditAlertResourcesConfirmationDialog'; import type { EditAlertProps } from './EditAlertDefinition'; +import type { CrumbOverridesProps } from 'src/components/Breadcrumb/Crumbs'; export const EditAlertResources = (props: EditAlertProps) => { const theme = useTheme(); @@ -36,17 +37,12 @@ export const EditAlertResources = (props: EditAlertProps) => { }, [alertDetails]); const { newPathname, overrides } = React.useMemo(() => { - const overrides = [ + const overrides: CrumbOverridesProps[] = [ { label: 'Definitions', linkTo: definitionLanding, position: 1, }, - { - label: 'Edit', - linkTo: `${definitionLanding}/edit/${serviceType}/${alertId}`, - position: 2, - }, ]; return { newPathname: '/Definitions/Edit', overrides }; diff --git a/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertResourceUtils.ts b/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertResourceUtils.ts index c86a1701904..ddcdae208a3 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertResourceUtils.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertResourceUtils.ts @@ -12,7 +12,7 @@ import type { AlertFilterType, AlertResourceFiltersProps, } from '../AlertsResources/types'; -import type { AlertServiceType, Region } from '@linode/api-v4'; +import type { CloudPulseServiceType, Region } from '@linode/api-v4'; interface FilterResourceProps { /** @@ -146,7 +146,7 @@ export const getRegionOptions = ( */ export const getSupportedRegionIds = ( regions?: Region[], - serviceType?: AlertServiceType + serviceType?: CloudPulseServiceType ): string[] | undefined => { return filterRegionByServiceType('alerts', regions, serviceType).map( ({ id }) => id diff --git a/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.test.ts b/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.test.ts index ad9dd5edb47..037fb5cc174 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.test.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.test.ts @@ -6,6 +6,7 @@ import { alertFactory, serviceTypesFactory } from 'src/factories'; import { useContextualAlertsState } from '../../Utils/utils'; import { alertDefinitionFormSchema } from '../CreateAlert/schemas'; import { + alertsFromEnabledServices, convertAlertDefinitionValues, convertAlertsToTypeSet, convertSecondsToMinutes, @@ -15,6 +16,7 @@ import { getSchemaWithEntityIdValidation, getServiceTypeLabel, handleMultipleError, + transformDimensionValue, } from './utils'; import type { AlertValidationSchemaProps } from './utils'; @@ -23,7 +25,10 @@ import type { APIError, EditAlertPayloadWithService, } from '@linode/api-v4'; -import type { AclpAlertServiceTypeConfig } from 'src/featureFlags'; +import type { + AclpAlertServiceTypeConfig, + AclpServices, +} from 'src/featureFlags'; it('test getServiceTypeLabel method', () => { const services = serviceTypesFactory.buildList(3); @@ -33,8 +38,6 @@ it('test getServiceTypeLabel method', () => { service.label ); }); - expect(getServiceTypeLabel('test', { data: services })).toBe('test'); - expect(getServiceTypeLabel('', { data: services })).toBe(''); }); it('test convertSecondsToMinutes method', () => { expect(convertSecondsToMinutes(0)).toBe('0 minutes'); @@ -53,6 +56,7 @@ it('test convertSecondsToOptions method', () => { }); it('test filterAlerts method', () => { + alertFactory.resetSequenceNumber(); const alerts = [ ...alertFactory.buildList(12, { created_by: 'system' }), alertFactory.build({ @@ -139,7 +143,7 @@ describe('getSchemaWithEntityIdValidation', () => { it('should return baseSchema if maxSelectionCount is undefined', () => { const schema = getSchemaWithEntityIdValidation({ ...props, - serviceTypeObj: 'unknown', + serviceTypeObj: 'firewall', }); expect(schema).toBe(baseSchema); }); @@ -362,8 +366,8 @@ describe('filterRegionByServiceType', () => { ); }); - it('should return no regions for unknown service type', () => { - const result = filterRegionByServiceType('alerts', regions, 'unknown'); + it('should return no regions for nodebalancer service type', () => { + const result = filterRegionByServiceType('alerts', regions, 'nodebalancer'); expect(result).toHaveLength(0); }); @@ -416,9 +420,77 @@ describe('filterRegionByServiceType', () => { ); }); - it('should return no regions for unknown service type', () => { - const result = filterRegionByServiceType('alerts', regions, 'unknown'); + it('should return no regions for firewall service type', () => { + const result = filterRegionByServiceType('alerts', regions, 'firewall'); + + expect(result).toHaveLength(0); + }); +}); + +describe('alertsFromEnabledServices', () => { + const allAlerts = [ + ...alertFactory.buildList(3, { service_type: 'dbaas' }), + ...alertFactory.buildList(3, { service_type: 'linode' }), + ]; + const aclpServicesFlag: Partial = { + linode: { + alerts: { enabled: true, beta: true }, + metrics: { enabled: true, beta: true }, + }, + dbaas: { + alerts: { enabled: false, beta: true }, + metrics: { enabled: false, beta: true }, + }, + }; + + it('should return empty list when no alerts are provided', () => { + const result = alertsFromEnabledServices([], aclpServicesFlag); + expect(result).toHaveLength(0); + }); + + it('should return alerts from enabled services', () => { + const result = alertsFromEnabledServices(allAlerts, aclpServicesFlag); + expect(result).toHaveLength(3); + }); + it('should not return alerts from services that are missing in the flag', () => { + const result = alertsFromEnabledServices(allAlerts, { + linode: { + alerts: { enabled: true, beta: true }, + metrics: { enabled: true, beta: true }, + }, + }); + expect(result).toHaveLength(3); + }); + + it('should not return alerts from services that are missing the alerts property in the flag', () => { + const result = alertsFromEnabledServices(allAlerts, { + linode: { + metrics: { enabled: true, beta: true }, + }, + }); expect(result).toHaveLength(0); }); }); + +describe('transformDimensionValue', () => { + it('should apply service-specific transformations', () => { + expect(transformDimensionValue('linode', 'type', '')).toBe(''); + expect(transformDimensionValue('linode', 'operation', 'read')).toBe('Read'); + expect(transformDimensionValue('dbaas', 'node_type', 'primary')).toBe( + 'Primary' + ); + expect( + transformDimensionValue('firewall', 'interface_type', 'public') + ).toBe('PUBLIC'); + expect(transformDimensionValue('nodebalancer', 'protocol', 'http')).toBe( + 'HTTP' + ); + }); + + it('should fallback to capitalize for unknown dimensions', () => { + expect( + transformDimensionValue('linode', 'unknown_dimension', 'test_value') + ).toBe('Test_value'); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.ts b/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.ts index d107ed589e2..b3d37ec4e07 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.ts @@ -2,9 +2,9 @@ import { type Alert, type AlertDefinitionMetricCriteria, type AlertDefinitionType, - type AlertServiceType, type APIError, capabilityServiceTypeMapping, + type CloudPulseServiceType, type EditAlertPayloadWithService, type NotificationChannel, type Region, @@ -13,6 +13,10 @@ import { import type { FieldPath, FieldValues, UseFormSetError } from 'react-hook-form'; import { array, object, string } from 'yup'; +import { + DIMENSION_TRANSFORM_CONFIG, + TRANSFORMS, +} from '../../shared/DimensionTransform'; import { aggregationTypeMap, metricOperatorTypeMap } from '../constants'; import type { CloudPulseResources } from '../../shared/CloudPulseResourcesSelect'; @@ -21,7 +25,10 @@ import type { AlertDimensionsProp } from '../AlertsDetail/DisplayAlertDetailChip import type { CreateAlertDefinitionForm } from '../CreateAlert/types'; import type { MonitoringCapabilities } from '@linode/api-v4'; import type { Theme } from '@mui/material'; -import type { AclpAlertServiceTypeConfig } from 'src/featureFlags'; +import type { + AclpAlertServiceTypeConfig, + AclpServices, +} from 'src/featureFlags'; import type { ObjectSchema } from 'yup'; interface AlertChipBorderProps { @@ -81,7 +88,7 @@ export interface AlertValidationSchemaProps { /** * The service type that is linked with alert and for which the validation schema needs to be built */ - serviceTypeObj: null | string; + serviceTypeObj: CloudPulseServiceType | null; } interface HandleMultipleErrorProps { /** @@ -124,7 +131,7 @@ interface FilterRegionProps { /** * The service type for which the regions are being filtered */ - serviceType: AlertServiceType | null; + serviceType: CloudPulseServiceType | null; } interface SupportedRegionsProps { @@ -139,7 +146,7 @@ interface SupportedRegionsProps { /** * The service type for which the regions are being filtered */ - serviceType: AlertServiceType | null; + serviceType: CloudPulseServiceType | null; } interface FilterAlertsProps { @@ -167,7 +174,7 @@ interface FilterAlertsProps { * @returns The label for the given service type from available service types */ export const getServiceTypeLabel = ( - serviceType: string, + serviceType: CloudPulseServiceType, serviceTypeList: ServiceTypesList | undefined ) => { if (!serviceTypeList) { @@ -316,7 +323,7 @@ export const convertAlertDefinitionValues = ( trigger_conditions, scope, }: Alert, - serviceType: AlertServiceType + serviceType: CloudPulseServiceType ): EditAlertPayloadWithService => { return { scope, @@ -549,7 +556,7 @@ export const getSupportedRegions = (props: SupportedRegionsProps) => { export const filterRegionByServiceType = ( type: keyof MonitoringCapabilities, regions?: Region[], - serviceType?: null | string + serviceType?: CloudPulseServiceType | null ): Region[] => { if (!serviceType || !regions) return regions ?? []; const capability = capabilityServiceTypeMapping[serviceType]; @@ -576,3 +583,38 @@ export const convertSecondsToOptions = (seconds: number): string => { return `${hours} hr`; } }; + +/** + * Filters alerts based on the enabled services + * @param allAlerts list of all alerts + * @param aclpServices list of services with their statuses + * @returns list of alerts from enabled services + */ +export const alertsFromEnabledServices = ( + allAlerts: Alert[] | undefined, + aclpServices: Partial | undefined +) => { + // Return the alerts whose service type is enabled in the aclpServices flag + return allAlerts?.filter( + (alert) => aclpServices?.[alert.service_type]?.alerts?.enabled ?? false + ); +}; + +/** + * Transform a dimension value using the appropriate transform function + * @param serviceType - The cloud pulse service type + * @param dimensionLabel - The dimension label + * @param value - The value to transform + * @returns Transformed value + */ +export const transformDimensionValue = ( + serviceType: CloudPulseServiceType | null, + dimensionLabel: string, + value: string +): string => { + return ( + ( + serviceType && DIMENSION_TRANSFORM_CONFIG[serviceType]?.[dimensionLabel] + )?.(value) ?? TRANSFORMS.capitalize(value) + ); +}; diff --git a/packages/manager/src/features/CloudPulse/Alerts/constants.ts b/packages/manager/src/features/CloudPulse/Alerts/constants.ts index 6021bd1067f..d2c8aa7b743 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/constants.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/constants.ts @@ -90,10 +90,6 @@ export const dimensionOperatorOptions: Item< label: 'Equal', value: 'eq', }, - { - label: 'Ends with', - value: 'endswith', - }, { label: 'Not Equal', value: 'neq', @@ -102,6 +98,10 @@ export const dimensionOperatorOptions: Item< label: 'Starts with', value: 'startswith', }, + { + label: 'Ends with', + value: 'endswith', + }, { label: 'In', value: 'in', diff --git a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboard.tsx b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboard.tsx index 8d4d21dd011..36dcf2c988b 100644 --- a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboard.tsx +++ b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboard.tsx @@ -9,8 +9,12 @@ import { useGetCloudPulseMetricDefinitionsByServiceType, } from 'src/queries/cloudpulse/services'; +import { RESOURCE_FILTER_MAP } from '../Utils/constants'; import { useAclpPreference } from '../Utils/UserPreference'; -import { RenderWidgets } from '../Widget/CloudPulseWidgetRenderer'; +import { + renderPlaceHolder, + RenderWidgets, +} from '../Widget/CloudPulseWidgetRenderer'; import type { CloudPulseMetricsAdditionalFilters } from '../Widget/CloudPulseWidget'; import type { DateTimeWithPreset, JWETokenPayLoad } from '@linode/api-v4'; @@ -31,6 +35,11 @@ export interface DashboardProperties { */ duration: DateTimeWithPreset; + /** + * Selected linode region for the dashboard + */ + linodeRegion?: string; + /** * optional timestamp to pass as react query param to forcefully re-fetch data */ @@ -65,6 +74,7 @@ export const CloudPulseDashboard = (props: DashboardProperties) => { manualRefreshTimeStamp, resources, savePref, + linodeRegion, } = props; const { preferences } = useAclpPreference(); @@ -89,7 +99,7 @@ export const CloudPulseDashboard = (props: DashboardProperties) => { Boolean(dashboard?.service_type), dashboard?.service_type, {}, - dashboard?.service_type === 'dbaas' ? { platform: 'rdbms-default' } : {} + RESOURCE_FILTER_MAP[dashboard?.service_type ?? ''] ?? {} ); const { @@ -128,7 +138,19 @@ export const CloudPulseDashboard = (props: DashboardProperties) => { } if (isMetricDefinitionLoading || isDashboardLoading || isResourcesLoading) { - return ; + return ( + ({ + padding: theme.spacingFunction(16), + })} + /> + ); + } + + if (!dashboard) { + return renderPlaceHolder( + 'No visualizations are available at this moment. Create Dashboards to list here.' + ); } return ( @@ -138,6 +160,7 @@ export const CloudPulseDashboard = (props: DashboardProperties) => { duration={duration} isJweTokenFetching={isJweTokenFetching} jweToken={jweToken} + linodeRegion={linodeRegion} manualRefreshTimeStamp={manualRefreshTimeStamp} metricDefinitions={metricDefinitions} preferences={preferences} diff --git a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardLanding.tsx b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardLanding.tsx index b11e7197842..0f7ef75ea14 100644 --- a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardLanding.tsx +++ b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardLanding.tsx @@ -18,11 +18,12 @@ export interface FilterData { id: { [filterKey: string]: FilterValueType }; label: { [filterKey: string]: string[] }; } +export interface CloudPulseMetricsFilter { + [key: string]: FilterValueType; +} export interface DashboardProp { dashboard?: Dashboard; - filterValue: { - [key: string]: FilterValueType; - }; + filterValue: CloudPulseMetricsFilter; timeDuration?: DateTimeWithPreset; } diff --git a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardRenderer.tsx b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardRenderer.tsx index ae4eed38d37..c1f0d881df9 100644 --- a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardRenderer.tsx +++ b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardRenderer.tsx @@ -1,7 +1,13 @@ import React from 'react'; import { CloudPulseErrorPlaceholder } from '../shared/CloudPulseErrorPlaceholder'; -import { REFRESH, REGION, RESOURCE_ID, TAGS } from '../Utils/constants'; +import { + LINODE_REGION, + REFRESH, + REGION, + RESOURCE_ID, + TAGS, +} from '../Utils/constants'; import { checkIfAllMandatoryFiltersAreSelected, getMetricsCallCustomFilters, @@ -57,6 +63,12 @@ export const CloudPulseDashboardRenderer = React.memo( additionalFilters={getMetricsCall} dashboardId={dashboard.id} duration={timeDuration} + linodeRegion={ + filterValue[LINODE_REGION] && + typeof filterValue[LINODE_REGION] === 'string' + ? (filterValue[LINODE_REGION] as string) + : undefined + } manualRefreshTimeStamp={ filterValue[REFRESH] && typeof filterValue[REFRESH] === 'number' ? filterValue[REFRESH] diff --git a/packages/manager/src/features/CloudPulse/Overview/GlobalFilters.test.tsx b/packages/manager/src/features/CloudPulse/Overview/GlobalFilters.test.tsx index 1c10b05f6b2..79a911c5c22 100644 --- a/packages/manager/src/features/CloudPulse/Overview/GlobalFilters.test.tsx +++ b/packages/manager/src/features/CloudPulse/Overview/GlobalFilters.test.tsx @@ -1,6 +1,7 @@ import { screen } from '@testing-library/react'; import React from 'react'; +import { databaseInstanceFactory } from 'src/factories'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { GlobalFilters } from './GlobalFilters'; @@ -19,6 +20,19 @@ const setup = () => { /> ); }; + +const queryMocks = vi.hoisted(() => ({ + useResourcesQuery: vi.fn().mockReturnValue({}), +})); + +vi.mock('src/queries/cloudpulse/resources', async () => { + const actual = await vi.importActual('src/queries/cloudpulse/resources'); + return { + ...actual, + useResourcesQuery: queryMocks.useResourcesQuery, + }; +}); + describe('Global filters component test', () => { it('Should render refresh button', () => { setup(); @@ -40,4 +54,16 @@ describe('Global filters component test', () => { expect(timeRangeSelect).toBeInTheDocument(); }); + + it('Should show circle progress if resources call is loading', async () => { + queryMocks.useResourcesQuery.mockReturnValue({ + data: [{ ...databaseInstanceFactory.build(), clusterSize: 1 }], + isLoading: true, + }); + + setup(); + + const progress = await screen.findByTestId('circle-progress'); + expect(progress).toBeInTheDocument(); + }); }); diff --git a/packages/manager/src/features/CloudPulse/Overview/GlobalFilters.tsx b/packages/manager/src/features/CloudPulse/Overview/GlobalFilters.tsx index 1c236ed2e97..69663ae83a4 100644 --- a/packages/manager/src/features/CloudPulse/Overview/GlobalFilters.tsx +++ b/packages/manager/src/features/CloudPulse/Overview/GlobalFilters.tsx @@ -4,13 +4,19 @@ import { GridLegacy } from '@mui/material'; import * as React from 'react'; import Reload from 'src/assets/icons/refresh.svg'; +import { useResourcesQuery } from 'src/queries/cloudpulse/resources'; import { CloudPulseDashboardFilterBuilder } from '../shared/CloudPulseDashboardFilterBuilder'; import { CloudPulseDashboardSelect } from '../shared/CloudPulseDashboardSelect'; import { CloudPulseDateTimeRangePicker } from '../shared/CloudPulseDateTimeRangePicker'; import { CloudPulseTooltip } from '../shared/CloudPulseTooltip'; import { convertToGmt } from '../Utils/CloudPulseDateTimePickerUtils'; -import { DASHBOARD_ID, REFRESH, TIME_DURATION } from '../Utils/constants'; +import { + DASHBOARD_ID, + REFRESH, + RESOURCE_FILTER_MAP, + TIME_DURATION, +} from '../Utils/constants'; import { useAclpPreference } from '../Utils/UserPreference'; import type { FilterValueType } from '../Dashboard/CloudPulseDashboardLanding'; @@ -88,6 +94,14 @@ export const GlobalFilters = React.memo((props: GlobalFilterProperties) => { handleAnyFilterChange(REFRESH, Date.now(), []); }, []); + const { isLoading, isError } = useResourcesQuery( + selectedDashboard !== undefined, + selectedDashboard?.service_type ?? '', + {}, + + RESOURCE_FILTER_MAP[selectedDashboard?.service_type ?? ''] ?? {} + ); + return ( @@ -150,6 +164,8 @@ export const GlobalFilters = React.memo((props: GlobalFilterProperties) => { dashboard={selectedDashboard} emitFilterChange={emitFilterChange} handleToggleAppliedFilter={handleToggleAppliedFilter} + isError={isError} + isLoading={isLoading} isServiceAnalyticsIntegration={false} preferences={preferences} /> diff --git a/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.test.ts b/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.test.ts index 74c0ef38304..aa0428494d9 100644 --- a/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.test.ts +++ b/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.test.ts @@ -9,6 +9,10 @@ import { mapResourceIdToName, } from './CloudPulseWidgetUtils'; +import type { + DimensionNameProperties, + LabelNameOptionsProps, +} from './CloudPulseWidgetUtils'; import type { CloudPulseMetricsResponse } from '@linode/api-v4'; import type { MetricsDisplayRow } from 'src/components/LineGraph/MetricsDisplay'; @@ -60,7 +64,7 @@ describe('generateMaxUnit method', () => { }); describe('getLabelName method', () => { - const baseProps = { + const baseProps: LabelNameOptionsProps = { label: 'CPU Usage', metric: { entity_id: '123' }, resources: [{ id: '123', label: 'linode-1' }], @@ -116,6 +120,7 @@ it('test generateGraphData with metrics data', () => { resources: [{ id: '1', label: 'linode-1' }], status: 'success', unit: '%', + serviceType: 'linode', }); expect(result.areas[0].dataKey).toBe('linode-1'); @@ -139,7 +144,8 @@ it('test generateGraphData with metrics data', () => { }); describe('getDimensionName method', () => { - const baseProps = { + const baseProps: DimensionNameProperties = { + serviceType: 'linode', metric: { entity_id: '123' }, resources: [{ id: '123', label: 'linode-1' }], }; @@ -203,6 +209,29 @@ describe('getDimensionName method', () => { const result = getDimensionName(props); expect(result).toBe('123'); }); + + it('returns the transformed dimension value according to the service type', () => { + const props = { + ...baseProps, + metric: { + entity_id: '123', + metric_name: 'test', + node_id: 'primary-1', + operation: 'read', + }, + }; + const result = getDimensionName(props); + expect(result).toBe('linode-1 | test | primary-1 | Read'); + }); + + it('returns the actual value if dimension name is not found in the transform config', () => { + const props = { + ...baseProps, + metric: { entity_id: '123', metric_name: 'test', node_id: 'primary-1' }, + }; + const result = getDimensionName(props); + expect(result).toBe('linode-1 | test | primary-1'); + }); }); it('test mapResourceIdToName method', () => { diff --git a/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts b/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts index cacdff768b2..b90ef39de90 100644 --- a/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts +++ b/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts @@ -1,6 +1,7 @@ import { Alias } from '@linode/design-language-system'; import { getMetrics } from '@linode/utilities'; +import { DIMENSION_TRANSFORM_CONFIG } from '../shared/DimensionTransform'; import { convertValueToUnit, formatToolTip, @@ -17,6 +18,7 @@ import type { CloudPulseMetricsList, CloudPulseMetricsRequest, CloudPulseMetricsResponse, + CloudPulseServiceType, DateTimeWithPreset, TimeDuration, Widgets, @@ -26,7 +28,7 @@ import type { DataSet } from 'src/components/AreaChart/AreaChart'; import type { AreaProps } from 'src/components/AreaChart/AreaChart'; import type { MetricsDisplayRow } from 'src/components/LineGraph/MetricsDisplay'; -interface LabelNameOptionsProps { +export interface LabelNameOptionsProps { /** * Boolean to check if metric name should be hidden */ @@ -47,6 +49,11 @@ interface LabelNameOptionsProps { */ resources: CloudPulseResources[]; + /** + * service type of the data + */ + serviceType: CloudPulseServiceType; + /** * unit of the data */ @@ -69,6 +76,11 @@ interface GraphDataOptionsProps { */ resources: CloudPulseResources[]; + /** + * service type of the data + */ + serviceType: CloudPulseServiceType; + /** * status returned from react query ( pending | error | success) */ @@ -91,6 +103,11 @@ interface MetricRequestProps { */ entityIds: string[]; + /** + * selected linode region for the widget + */ + linodeRegion?: string; + /** * list of CloudPulse resources available */ @@ -102,7 +119,7 @@ interface MetricRequestProps { widget: Widgets; } -interface DimensionNameProperties { +export interface DimensionNameProperties { /** * Boolean to check if metric name should be hidden */ @@ -117,6 +134,11 @@ interface DimensionNameProperties { * resources list of CloudPulseResources available */ resources: CloudPulseResources[]; + + /** + * service type of the data + */ + serviceType: CloudPulseServiceType; } interface GraphData { @@ -146,7 +168,7 @@ interface GraphData { * @returns parameters which will be necessary to populate graph & legends */ export const generateGraphData = (props: GraphDataOptionsProps): GraphData => { - const { label, metricsList, resources, status, unit } = props; + const { label, metricsList, resources, serviceType, status, unit } = props; const legendRowsData: MetricsDisplayRow[] = []; const dimension: { [timestamp: number]: { [label: string]: number } } = {}; const areas: AreaProps[] = []; @@ -182,6 +204,7 @@ export const generateGraphData = (props: GraphDataOptionsProps): GraphData => { resources, unit, hideMetricName, + serviceType, }; const labelName = getLabelName(labelOptions); const data = seriesDataFormatter(transformedData.values, start, end); @@ -265,7 +288,7 @@ export const generateMaxUnit = ( export const getCloudPulseMetricRequest = ( props: MetricRequestProps ): CloudPulseMetricsRequest => { - const { duration, entityIds, resources, widget } = props; + const { duration, entityIds, resources, widget, linodeRegion } = props; const preset = duration.preset; return { @@ -292,6 +315,7 @@ export const getCloudPulseMetricRequest = ( unit: widget.time_granularity.unit, value: widget.time_granularity.value, }, + associated_entity_region: linodeRegion, }; }; @@ -300,14 +324,21 @@ export const getCloudPulseMetricRequest = ( * @returns generated label name for graph dimension */ export const getLabelName = (props: LabelNameOptionsProps): string => { - const { label, metric, resources, unit, hideMetricName = false } = props; + const { + label, + metric, + resources, + unit, + hideMetricName = false, + serviceType, + } = props; // aggregated metric, where metric keys will be 0 if (!Object.keys(metric).length) { // in this case return widget label and unit return `${label} (${unit})`; } - return getDimensionName({ metric, resources, hideMetricName }); + return getDimensionName({ metric, resources, hideMetricName, serviceType }); }; /** @@ -316,7 +347,7 @@ export const getLabelName = (props: LabelNameOptionsProps): string => { */ // ... existing code ... export const getDimensionName = (props: DimensionNameProperties): string => { - const { metric, resources, hideMetricName = false } = props; + const { metric, resources, hideMetricName = false, serviceType } = props; return Object.entries(metric) .map(([key, value]) => { if (key === 'entity_id') { @@ -334,7 +365,9 @@ export const getDimensionName = (props: DimensionNameProperties): string => { return ''; } - return value ?? ''; + return ( + DIMENSION_TRANSFORM_CONFIG[serviceType]?.[key]?.(value) ?? value ?? '' + ); }) .filter(Boolean) .join(' | '); diff --git a/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.test.ts b/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.test.ts index 0a42100a08b..217a73b1222 100644 --- a/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.test.ts +++ b/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.test.ts @@ -1,16 +1,17 @@ import { databaseQueries } from '@linode/queries'; import { DateTime } from 'luxon'; -import { dashboardFactory } from 'src/factories'; +import { dashboardFactory, databaseInstanceFactory } from 'src/factories'; import { RESOURCE_ID, RESOURCES } from './constants'; import { deepEqual, + filterBasedOnConfig, + filterUsingDependentFilters, getFilters, getTextFilterProperties, } from './FilterBuilder'; import { - buildXFilter, checkIfAllMandatoryFiltersAreSelected, constructAdditionalRequestFilters, getCustomSelectProperties, @@ -22,7 +23,10 @@ import { shouldDisableFilterByFilterKey, } from './FilterBuilder'; import { FILTER_CONFIG } from './FilterConfig'; -import { CloudPulseSelectTypes } from './models'; +import { CloudPulseAvailableViews, CloudPulseSelectTypes } from './models'; + +import type { CloudPulseResources } from '../shared/CloudPulseResourcesSelect'; +import type { CloudPulseServiceTypeFilters } from './models'; const mockDashboard = dashboardFactory.build(); @@ -111,7 +115,7 @@ it('test getResourceSelectionProperties method', () => { expect(handleResourcesSelection).toBeDefined(); expect(savePreferences).toEqual(false); expect(disabled).toEqual(false); - expect(JSON.stringify(xFilter)).toEqual('{"+and":[{"region":"us-east"}]}'); + expect(JSON.stringify(xFilter)).toEqual('{"region":"us-east"}'); expect(label).toEqual(name); } }); @@ -143,7 +147,7 @@ it('test getResourceSelectionProperties method with disabled true', () => { expect(handleResourcesSelection).toBeDefined(); expect(savePreferences).toEqual(false); expect(disabled).toEqual(true); - expect(JSON.stringify(xFilter)).toEqual('{"+and":[]}'); + expect(JSON.stringify(xFilter)).toEqual('{}'); expect(label).toEqual(name); } }); @@ -247,26 +251,6 @@ it('test getNodeTypeProperties with disabled true', () => { } }); -it('test buildXfilter method', () => { - const resourceSelectionConfig = linodeConfig?.filters.find( - (filterObj) => filterObj.name === 'Resources' - ); - - expect(resourceSelectionConfig).toBeDefined(); // fails if resources selection in not defined - - if (resourceSelectionConfig) { - let result = buildXFilter(resourceSelectionConfig, { - region: 'us-east', - }); - - expect(JSON.stringify(result)).toEqual('{"+and":[{"region":"us-east"}]}'); - - result = buildXFilter(resourceSelectionConfig, {}); - - expect(JSON.stringify(result)).toEqual('{"+and":[]}'); - } -}); - it('test checkIfAllMandatoryFiltersAreSelected method', () => { const resourceSelectionConfig = linodeConfig?.filters.find( (filterObj) => filterObj.name === 'Resources' @@ -501,3 +485,139 @@ it('should return the filters based on dashboard', () => { expect(filters?.length).toBe(1); }); + +describe('filterUsingDependentFilters', () => { + const mockData: CloudPulseResources[] = [ + { + ...databaseInstanceFactory.build(), + region: 'us-east', + engineType: 'mysql', + id: '1', + tags: ['test'], + }, + { + ...databaseInstanceFactory.build(), + region: 'us-west', + engineType: 'postgresql', + id: '2', + tags: ['test', 'test2'], + }, + ]; + it('should return the data passed if data or dependentFilters are undefined', () => { + expect(filterUsingDependentFilters(undefined, undefined)).toBeUndefined(); + expect(filterUsingDependentFilters(mockData, undefined)).toBe(mockData); + expect(filterUsingDependentFilters(undefined, {})).toBeUndefined(); + }); + + it('should filter based on a single key-value match', () => { + const filters = { engineType: 'mysql' }; + const result = filterUsingDependentFilters(mockData, filters); + expect(result).toEqual([mockData[0]]); + }); + + it('should filter when both resource and filter value are arrays', () => { + const filters = { tags: ['test', 'test2'] }; + const result = filterUsingDependentFilters(mockData, filters); + expect(result).toEqual([mockData[0], mockData[1]]); + }); + + it('should return empty array if no resource matches', () => { + const filters = { region: 'us-central' }; + const result = filterUsingDependentFilters(mockData, filters); + expect(result).toEqual([]); + }); + + it('should apply multiple filters simultaneously', () => { + let filters = { + engineType: 'postgresql', + region: 'us-east', + tags: 'test', + }; + let result = filterUsingDependentFilters(mockData, filters); + expect(result).toEqual([]); + + filters = { + engineType: 'postgresql', + region: 'us-east', + tags: 'test', + }; + + result = filterUsingDependentFilters(mockData, filters); + expect(result).toEqual([]); + + filters = { + engineType: 'postgresql', + region: 'us-west', + tags: 'test', + }; + + result = filterUsingDependentFilters(mockData, filters); + expect(result).toEqual([mockData[1]]); + }); +}); + +describe('filterBasedOnConfig', () => { + const config: CloudPulseServiceTypeFilters = { + configuration: { + dependency: [], // empty dependency + filterKey: 'resource_id', + filterType: 'string', + isFilterable: true, + isMetricsFilter: true, + isMultiSelect: true, + name: 'Database Clusters', + neededInViews: [CloudPulseAvailableViews.central], + placeholder: 'Select Database Clusters', + priority: 3, + }, + name: 'Resources', + }; + it('should return empty object if config has no dependencies', () => { + const dependentFilters = { engine: 'mysql', region: 'us-east' }; + const result = filterBasedOnConfig(config, dependentFilters); + expect(result).toEqual({}); + }); + + it('should return filtered values based on dependency keys', () => { + const dependentFilters = { + engine: 'mysql', + region: 'us-east', + status: 'running', + }; + const result = filterBasedOnConfig( + { + ...config, + configuration: { + ...config.configuration, + dependency: ['engine', 'status'], + }, + }, + dependentFilters + ); + expect(result).toEqual({ + engineType: 'mysql', + status: 'running', + }); + }); + + it('should work with array values in filters', () => { + const dependentFilters = { + engine: 'mysql', + tags: ['db', 'prod'], + }; + const result = filterBasedOnConfig( + { + ...config, + configuration: { + ...config.configuration, + dependency: ['engine', 'tags'], + }, + }, + dependentFilters + ); + expect(result).toEqual({ + engineType: 'mysql', + tags: ['db', 'prod'], + }); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.ts b/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.ts index f05ae918ec6..ff4448f5dd4 100644 --- a/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.ts +++ b/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.ts @@ -9,7 +9,10 @@ import { import { FILTER_CONFIG } from './FilterConfig'; import { CloudPulseAvailableViews, CloudPulseSelectTypes } from './models'; -import type { FilterValueType } from '../Dashboard/CloudPulseDashboardLanding'; +import type { + CloudPulseMetricsFilter, + FilterValueType, +} from '../Dashboard/CloudPulseDashboardLanding'; import type { CloudPulseCustomSelectProps } from '../shared/CloudPulseCustomSelect'; import type { CloudPulseNodeTypeFilterProps } from '../shared/CloudPulseNodeTypeFilter'; import type { CloudPulseRegionSelectProps } from '../shared/CloudPulseRegionSelect'; @@ -29,7 +32,6 @@ import type { AclpConfig, Dashboard, DateTimeWithPreset, - Filter, Filters, TimeDuration, } from '@linode/api-v4'; @@ -37,21 +39,19 @@ import type { interface CloudPulseFilterProperties { config: CloudPulseServiceTypeFilters; dashboard: Dashboard; - dependentFilters?: { - [key: string]: FilterValueType; - }; + dependentFilters?: CloudPulseMetricsFilter; isServiceAnalyticsIntegration: boolean; preferences?: AclpConfig; resource_ids?: number[] | undefined; + shouldDisable?: boolean; } interface CloudPulseMandatoryFilterCheckProps { dashboard: Dashboard; - filterValue: { - [key: string]: FilterValueType; - }; + filterValue: CloudPulseMetricsFilter; timeDuration: DateTimeWithPreset | undefined; } + /** * This function helps in building the properties needed for tags selection component * @@ -103,8 +103,9 @@ export const getTagsProperties = ( export const getRegionProperties = ( props: CloudPulseFilterProperties, handleRegionChange: ( + filterKey: string, region: string | undefined, - labels: [], + labels: string[], savePref?: boolean ) => void ): CloudPulseRegionSelectProps => { @@ -115,20 +116,25 @@ export const getRegionProperties = ( preferences, dependentFilters, config, + shouldDisable, } = props; return { - defaultValue: preferences?.[REGION], + defaultValue: preferences?.[filterKey], handleRegionChange, + filterKey, label, placeholder, savePreferences: !isServiceAnalyticsIntegration, selectedDashboard: dashboard, - disabled: shouldDisableFilterByFilterKey( - filterKey, - dependentFilters ?? {}, - dashboard - ), - xFilter: buildXFilter(config, dependentFilters ?? {}), + disabled: + shouldDisable || + shouldDisableFilterByFilterKey( + filterKey, + dependentFilters ?? {}, + dashboard + ), + xFilter: filterBasedOnConfig(config, dependentFilters ?? {}), + selectedEntities: (dependentFilters?.[RESOURCE_ID] ?? []) as string[], }; }; @@ -156,21 +162,24 @@ export const getResourcesProperties = ( dependentFilters, isServiceAnalyticsIntegration, preferences, + shouldDisable, } = props; return { defaultValue: preferences?.[RESOURCES], - disabled: shouldDisableFilterByFilterKey( - filterKey, - dependentFilters ?? {}, - dashboard, - preferences - ), + disabled: + shouldDisable || + shouldDisableFilterByFilterKey( + filterKey, + dependentFilters ?? {}, + dashboard, + preferences + ), handleResourcesSelection: handleResourceChange, label, placeholder, resourceType: dashboard.service_type, savePreferences: !isServiceAnalyticsIntegration, - xFilter: buildXFilter(config, dependentFilters ?? {}), + xFilter: filterBasedOnConfig(config, dependentFilters ?? {}), }; }; @@ -189,15 +198,18 @@ export const getNodeTypeProperties = ( isServiceAnalyticsIntegration, preferences, resource_ids, + shouldDisable, } = props; return { database_ids: resource_ids, defaultValue: preferences?.[NODE_TYPE], - disabled: shouldDisableFilterByFilterKey( - filterKey, - dependentFilters ?? {}, - dashboard - ), + disabled: + shouldDisable || + shouldDisableFilterByFilterKey( + filterKey, + dependentFilters ?? {}, + dashboard + ), handleNodeTypeChange, label, placeholder, @@ -238,6 +250,7 @@ export const getCustomSelectProperties = ( dependentFilters, isServiceAnalyticsIntegration, preferences, + shouldDisable, } = props; return { apiResponseIdField: apiIdField, @@ -248,11 +261,13 @@ export const getCustomSelectProperties = ( dashboard ), defaultValue: preferences?.[filterKey], - disabled: shouldDisableFilterByFilterKey( - filterKey, - dependentFilters ?? {}, - dashboard - ), + disabled: + shouldDisable || + shouldDisableFilterByFilterKey( + filterKey, + dependentFilters ?? {}, + dashboard + ), filterKey, filterType, isOptional, @@ -323,14 +338,17 @@ export const getTextFilterProperties = ( isServiceAnalyticsIntegration, preferences, dependentFilters, + shouldDisable, } = props; return { - disabled: shouldDisableFilterByFilterKey( - props.config.configuration.filterKey, - dependentFilters ?? {}, - dashboard - ), + disabled: + shouldDisable || + shouldDisableFilterByFilterKey( + props.config.configuration.filterKey, + dependentFilters ?? {}, + dashboard + ), defaultValue: preferences?.[props.config.configuration.filterKey], handleTextFilterChange, label, @@ -346,34 +364,29 @@ export const getTextFilterProperties = ( * * @param config - any cloudpulse service type filter config * @param dependentFilters - the filters that are selected so far - * @returns - a xFilter type of apiV4 + * @returns - filtered dependencies based on the provided config */ -export const buildXFilter = ( - config: CloudPulseServiceTypeFilters, - dependentFilters: { - [key: string]: FilterValueType | TimeDuration; +export const filterBasedOnConfig = ( + config: CloudPulseServiceTypeFilters | undefined, + dependentFilters: CloudPulseMetricsFilter +): CloudPulseMetricsFilter => { + if (!config) { + return {}; } -): Filter => { - const filters: Filter[] = []; - let orCondition: Filter[] = []; const { dependency } = config.configuration; + const filtered: CloudPulseMetricsFilter = {}; if (dependency) { dependency.forEach((key) => { const value = dependentFilters[key]; if (value !== undefined) { - if (Array.isArray(value)) { - orCondition = value.map((val) => ({ [key]: val })); - } else { - filters.push({ [key]: value }); - } + filtered[key === 'engine' ? 'engineType' : key] = value; } }); + return filtered; } - if (orCondition.length) { - return { '+and': filters, '+or': orCondition }; - } - return { '+and': filters }; + + return {}; }; /** @@ -471,9 +484,7 @@ export const checkIfAllMandatoryFiltersAreSelected = ( * @returns Constructs and returns the metrics call filters based on selected filters and service type */ export const getMetricsCallCustomFilters = ( - selectedFilters: { - [key: string]: FilterValueType; - }, + selectedFilters: CloudPulseMetricsFilter, dashboardId?: number ): CloudPulseMetricsAdditionalFilters[] => { const serviceTypeConfig = dashboardId @@ -633,3 +644,31 @@ export const getFilters = ( ) ); }; + +/** + * @param data The resources for which the filter needs to be applied + * @param dependentFilters The selected dependent filters that will be used to filter the resources + * @returns The filtered resources + */ +export const filterUsingDependentFilters = ( + data?: CloudPulseResources[], + dependentFilters?: CloudPulseMetricsFilter +): CloudPulseResources[] | undefined => { + if (!dependentFilters || !data) { + return data; + } + + return data.filter((resource) => { + return Object.entries(dependentFilters).every(([key, filterValue]) => { + const resourceValue = resource[key as keyof CloudPulseResources]; + + if (Array.isArray(resourceValue) && Array.isArray(filterValue)) { + return filterValue.some((val) => resourceValue.includes(String(val))); + } else if (Array.isArray(resourceValue)) { + return resourceValue.includes(String(filterValue)); + } else { + return resourceValue === filterValue; + } + }); + }); +}; diff --git a/packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts b/packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts index 4dda7780f3e..c93438a7e29 100644 --- a/packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts +++ b/packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts @@ -1,6 +1,10 @@ import { capabilityServiceTypeMapping } from '@linode/api-v4'; -import { INTERFACE_IDS_PLACEHOLDER_TEXT, RESOURCE_ID } from './constants'; +import { + INTERFACE_IDS_PLACEHOLDER_TEXT, + LINODE_REGION, + RESOURCE_ID, +} from './constants'; import { CloudPulseAvailableViews, CloudPulseSelectTypes } from './models'; import type { CloudPulseServiceTypeFilterMap } from './models'; @@ -194,6 +198,20 @@ export const NODEBALANCER_CONFIG: Readonly = { }, name: 'Ports', }, + { + configuration: { + filterKey: 'relative_time_duration', + filterType: 'string', + isFilterable: true, + isMetricsFilter: true, + isMultiSelect: false, + name: TIME_DURATION, + neededInViews: [], // we will have a static time duration component, no need render from filter builder + placeholder: 'Select a Duration', + priority: 4, + }, + name: TIME_DURATION, + }, ], serviceType: 'nodebalancer', }; @@ -215,6 +233,24 @@ export const FIREWALL_CONFIG: Readonly = { }, name: 'Firewalls', }, + { + configuration: { + dependency: ['resource_id'], + filterKey: LINODE_REGION, + filterType: 'string', + isFilterable: true, + isMetricsFilter: true, + isMultiSelect: false, + name: 'Linode Region', + neededInViews: [ + CloudPulseAvailableViews.central, + CloudPulseAvailableViews.service, + ], + placeholder: 'Select a Linode Region', + priority: 2, + }, + name: 'Linode Region', + }, { configuration: { filterKey: 'interface_type', @@ -261,6 +297,20 @@ export const FIREWALL_CONFIG: Readonly = { }, name: 'Interface IDs', }, + { + configuration: { + filterKey: 'relative_time_duration', + filterType: 'string', + isFilterable: true, + isMetricsFilter: true, + isMultiSelect: false, + name: TIME_DURATION, + neededInViews: [], // we will have a static time duration component, no need render from filter builder + placeholder: 'Select a Duration', + priority: 4, + }, + name: TIME_DURATION, + }, ], serviceType: 'firewall', }; diff --git a/packages/manager/src/features/CloudPulse/Utils/ReusableDashboardFilterUtils.test.ts b/packages/manager/src/features/CloudPulse/Utils/ReusableDashboardFilterUtils.test.ts index 38bdd63db44..71a9c75d244 100644 --- a/packages/manager/src/features/CloudPulse/Utils/ReusableDashboardFilterUtils.test.ts +++ b/packages/manager/src/features/CloudPulse/Utils/ReusableDashboardFilterUtils.test.ts @@ -132,7 +132,7 @@ it('test checkIfFilterBuilderNeeded method', () => { result = checkIfFilterBuilderNeeded({ ...mockDashboard, id: -1, - service_type: '', + service_type: 'linode', }); expect(result).toBe(false); // should be false for empty / undefined case diff --git a/packages/manager/src/features/CloudPulse/Utils/ReusableDashboardFilterUtils.ts b/packages/manager/src/features/CloudPulse/Utils/ReusableDashboardFilterUtils.ts index 3cc7ed5d6f5..10c2c59258f 100644 --- a/packages/manager/src/features/CloudPulse/Utils/ReusableDashboardFilterUtils.ts +++ b/packages/manager/src/features/CloudPulse/Utils/ReusableDashboardFilterUtils.ts @@ -3,7 +3,7 @@ import { FILTER_CONFIG } from './FilterConfig'; import { CloudPulseAvailableViews } from './models'; import type { DashboardProperties } from '../Dashboard/CloudPulseDashboard'; -import type { FilterValueType } from '../Dashboard/CloudPulseDashboardLanding'; +import type { CloudPulseMetricsFilter } from '../Dashboard/CloudPulseDashboardLanding'; import type { CloudPulseMetricsAdditionalFilters } from '../Widget/CloudPulseWidget'; import type { Dashboard, DateTimeWithPreset } from '@linode/api-v4'; @@ -18,7 +18,7 @@ interface ReusableDashboardFilterUtilProps { /** * The selected filter values */ - filterValue: { [key: string]: FilterValueType }; + filterValue: CloudPulseMetricsFilter; /** * The selected resource id */ diff --git a/packages/manager/src/features/CloudPulse/Utils/constants.ts b/packages/manager/src/features/CloudPulse/Utils/constants.ts index aa154f7c086..bac20ace791 100644 --- a/packages/manager/src/features/CloudPulse/Utils/constants.ts +++ b/packages/manager/src/features/CloudPulse/Utils/constants.ts @@ -1,3 +1,5 @@ +import type { Filter } from '@linode/api-v4'; + export const DASHBOARD_ID = 'dashboardId'; export const PRIMARY_NODE = 'primary'; @@ -6,6 +8,8 @@ export const SECONDARY_NODE = 'secondary'; export const REGION = 'region'; +export const LINODE_REGION = 'associated_entity_region'; + export const RESOURCES = 'resources'; export const INTERVAL = 'interval'; @@ -88,3 +92,27 @@ export const PLACEHOLDER_TEXT: Record = { [PORT]: PORTS_PLACEHOLDER_TEXT, [INTERFACE_ID]: INTERFACE_IDS_PLACEHOLDER_TEXT, }; + +export const ORDER_BY_LABLE_ASC = { + '+order': 'asc', + '+order_by': 'label', +}; + +export const RESOURCE_FILTER_MAP: Record = { + dbaas: { + platform: 'rdbms-default', + ...ORDER_BY_LABLE_ASC, + }, + linode: { + ...ORDER_BY_LABLE_ASC, + }, + nodebalancer: { + ...ORDER_BY_LABLE_ASC, + }, + firewall: { + ...ORDER_BY_LABLE_ASC, + }, + netloadbalancer: { + ...ORDER_BY_LABLE_ASC, + }, +}; diff --git a/packages/manager/src/features/CloudPulse/Utils/models.ts b/packages/manager/src/features/CloudPulse/Utils/models.ts index ed8961cf56c..5ff14b60fe8 100644 --- a/packages/manager/src/features/CloudPulse/Utils/models.ts +++ b/packages/manager/src/features/CloudPulse/Utils/models.ts @@ -1,8 +1,8 @@ import type { Capabilities, + CloudPulseServiceType, DatabaseEngine, DatabaseType, - MetricsServiceType, } from '@linode/api-v4'; import type { QueryFunction, QueryKey } from '@tanstack/react-query'; @@ -23,7 +23,7 @@ export interface CloudPulseServiceTypeFilterMap { /** * The service types like dbaas, linode etc., */ - readonly serviceType: MetricsServiceType; + readonly serviceType: CloudPulseServiceType; } /** diff --git a/packages/manager/src/features/CloudPulse/Utils/utils.test.ts b/packages/manager/src/features/CloudPulse/Utils/utils.test.ts index 14cca98863e..2ca235bbeba 100644 --- a/packages/manager/src/features/CloudPulse/Utils/utils.test.ts +++ b/packages/manager/src/features/CloudPulse/Utils/utils.test.ts @@ -1,6 +1,8 @@ import { regionFactory } from '@linode/utilities'; import { describe, expect, it } from 'vitest'; +import { serviceTypesFactory } from 'src/factories'; + import { INTERFACE_ID, INTERFACE_IDS_CONSECUTIVE_COMMAS_ERROR_MESSAGE, @@ -18,11 +20,14 @@ import { import { arePortsValid, areValidInterfaceIds, + getEnabledServiceTypes, isValidPort, useIsAclpSupportedRegion, validationFunction, } from './utils'; +import type { AclpServices } from 'src/featureFlags'; + describe('isValidPort', () => { it('should return valid for empty string and valid ports', () => { expect(isValidPort('')).toBe(undefined); @@ -270,3 +275,53 @@ describe('validate useIsAclpSupportedRegion function', () => { ).toBe(false); }); }); + +describe('getEnabledServiceTypes', () => { + const serviceTypesList = { + data: [ + serviceTypesFactory.build({ service_type: 'dbaas' }), + serviceTypesFactory.build({ service_type: 'linode' }), + ], + }; + + it('should return empty list when no service types are provided', () => { + const result = getEnabledServiceTypes(undefined, undefined); + expect(result).toHaveLength(0); + }); + + it('should return enabled service types', () => { + const aclpServicesFlag: Partial = { + linode: { + alerts: { enabled: false, beta: true }, + metrics: { enabled: false, beta: true }, + }, + dbaas: { + alerts: { enabled: true, beta: true }, + metrics: { enabled: true, beta: true }, + }, + }; + const result = getEnabledServiceTypes(serviceTypesList, aclpServicesFlag); + expect(result).toEqual(['dbaas']); + }); + + it('should not return the service type which is missing from the aclpServices flag', () => { + const aclpServicesFlag: Partial = { + linode: { + alerts: { enabled: true, beta: true }, + metrics: { enabled: true, beta: true }, + }, + }; + const result = getEnabledServiceTypes(serviceTypesList, aclpServicesFlag); + expect(result).not.toContain('dbaas'); + }); + + it('should not return the service type if the metrics property is not present in the aclpServices flag', () => { + const aclpServicesFlag: Partial = { + linode: { + alerts: { enabled: true, beta: true }, + }, + }; + const result = getEnabledServiceTypes(serviceTypesList, aclpServicesFlag); + expect(result).not.toContain('linode'); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Utils/utils.ts b/packages/manager/src/features/CloudPulse/Utils/utils.ts index 0cedaf846a2..6c56b0e9be6 100644 --- a/packages/manager/src/features/CloudPulse/Utils/utils.ts +++ b/packages/manager/src/features/CloudPulse/Utils/utils.ts @@ -26,6 +26,7 @@ import type { APIError, Capabilities, CloudPulseAlertsPayload, + CloudPulseServiceType, Dashboard, MonitoringCapabilities, ResourcePage, @@ -34,6 +35,7 @@ import type { TimeDuration, } from '@linode/api-v4'; import type { UseQueryResult } from '@tanstack/react-query'; +import type { AclpServices } from 'src/featureFlags'; import type { StatWithDummyPoint, WithStartAndEnd, @@ -61,10 +63,10 @@ interface AclpSupportedRegionProps { export const useIsACLPEnabled = (): { isACLPEnabled: boolean; } => { - const { data: account, error } = useAccount(); + const { data: account } = useAccount(); const flags = useFlags(); - if (error || !flags) { + if (!flags) { return { isACLPEnabled: false }; } @@ -214,15 +216,23 @@ export const seriesDataFormatter = ( /** * * @param rawServiceTypes list of service types returned from api response - * @returns converted service types list into string array + * @param aclpServices list of services with their statuses + * @returns enabled service types */ -export const formattedServiceTypes = ( - rawServiceTypes: ServiceTypesList | undefined -): string[] => { +export const getEnabledServiceTypes = ( + rawServiceTypes: ServiceTypesList | undefined, + aclpServices: Partial | undefined +): CloudPulseServiceType[] => { if (rawServiceTypes === undefined || rawServiceTypes.data.length === 0) { return []; } - return rawServiceTypes.data.map((obj: Service) => obj.service_type); + // Return the service types that are enabled in the aclpServices flag + return rawServiceTypes.data + .filter( + (obj: Service) => + aclpServices?.[obj.service_type]?.metrics?.enabled ?? false + ) + .map((obj: Service) => obj.service_type); }; /** @@ -233,7 +243,7 @@ export const formattedServiceTypes = ( */ export const getAllDashboards = ( queryResults: UseQueryResult, APIError[]>[], - serviceTypes: string[] + serviceTypes: CloudPulseServiceType[] ) => { let error = ''; let isLoading = false; diff --git a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx index 8875fae126d..660b488713e 100644 --- a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx +++ b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx @@ -24,6 +24,7 @@ import { ZoomIcon } from './components/Zoomer'; import type { FilterValueType } from '../Dashboard/CloudPulseDashboardLanding'; import type { CloudPulseResources } from '../shared/CloudPulseResourcesSelect'; import type { + CloudPulseServiceType, DateTimeWithPreset, Filters, MetricDefinition, @@ -79,6 +80,11 @@ export interface CloudPulseWidgetProperties { */ isJweTokenFetching: boolean; + /** + * Selected linode region for the widget + */ + linodeRegion?: string; + /** * List of resources available of selected service type */ @@ -92,7 +98,7 @@ export interface CloudPulseWidgetProperties { /** * Service type selected by user */ - serviceType: string; + serviceType: CloudPulseServiceType; /** * optional timestamp to pass as react query param to forcefully re-fetch data @@ -149,6 +155,7 @@ export const CloudPulseWidget = (props: CloudPulseWidgetProperties) => { timeStamp, unit, widget: widgetProp, + linodeRegion, } = props; const timezone = @@ -244,6 +251,7 @@ export const CloudPulseWidget = (props: CloudPulseWidgetProperties) => { entityIds, resources, widget, + linodeRegion, }), filters, // any additional dimension filters will be constructed and passed here }, @@ -268,6 +276,7 @@ export const CloudPulseWidget = (props: CloudPulseWidgetProperties) => { resources, status, unit, + serviceType, }); data = generatedData.dimensions; diff --git a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidgetRenderer.tsx b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidgetRenderer.tsx index 61d89ad0ce0..571b67c69e6 100644 --- a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidgetRenderer.tsx +++ b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidgetRenderer.tsx @@ -28,10 +28,11 @@ import type { interface WidgetProps { additionalFilters?: CloudPulseMetricsAdditionalFilters[]; - dashboard?: Dashboard | undefined; + dashboard: Dashboard; duration: DateTimeWithPreset; isJweTokenFetching: boolean; jweToken?: JWEToken | undefined; + linodeRegion?: string; manualRefreshTimeStamp?: number; metricDefinitions: ResourcePage | undefined; preferences?: AclpConfig; @@ -40,7 +41,7 @@ interface WidgetProps { savePref?: boolean; } -const renderPlaceHolder = (subtitle: string) => { +export const renderPlaceHolder = (subtitle: string) => { return ( @@ -64,6 +65,7 @@ export const RenderWidgets = React.memo( resourceList, resources, savePref, + linodeRegion, } = props; const getCloudPulseGraphProperties = ( @@ -79,7 +81,7 @@ export const RenderWidgets = React.memo( errorLabel: 'Error occurred while loading data.', isJweTokenFetching: false, resources: [], - serviceType: dashboard?.service_type ?? '', + serviceType: dashboard.service_type, timeStamp: manualRefreshTimeStamp, unit: widget.unit ?? '%', widget: { ...widget, time_granularity: autoIntervalOption }, @@ -117,7 +119,7 @@ export const RenderWidgets = React.memo( } }; - if (!dashboard || !dashboard.widgets?.length) { + if (!dashboard.widgets?.length) { return renderPlaceHolder( 'No visualizations are available at this moment. Create Dashboards to list here.' ); @@ -166,6 +168,7 @@ export const RenderWidgets = React.memo( authToken={jweToken?.token} availableMetrics={availMetrics} isJweTokenFetching={isJweTokenFetching} + linodeRegion={linodeRegion} resources={resourceList!} savePref={savePref} /> diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseComponentRenderer.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseComponentRenderer.tsx index 4bd0b7e3918..220b36f03bb 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseComponentRenderer.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseComponentRenderer.tsx @@ -53,6 +53,7 @@ const Components: { relative_time_duration: CloudPulseDateTimeRangePicker, resource_id: CloudPulseResourcesSelect, tags: CloudPulseTagsSelect, + associated_entity_region: CloudPulseRegionSelect, }; const buildComponent = (props: CloudPulseComponentRendererProps) => { diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseCustomSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseCustomSelect.tsx index ec7d18701d9..8c7555c12d3 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseCustomSelect.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseCustomSelect.tsx @@ -165,7 +165,7 @@ export const CloudPulseCustomSelect = React.memo( }); React.useEffect(() => { - if (!selectedResource) { + if (!selectedResource && !disabled) { setResource( getInitialDefaultSelections({ defaultValue, @@ -180,7 +180,7 @@ export const CloudPulseCustomSelect = React.memo( ); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [savePreferences, options, apiV4QueryKey, queriedResources]); // only execute this use efffect one time or if savePreferences or options or dataApiUrl changes + }, [savePreferences, options, apiV4QueryKey, queriedResources, disabled]); // only execute this use efffect one time or if savePreferences or options or dataApiUrl changes const handleChange = ( _: React.SyntheticEvent, diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.tsx index b87b9bf0063..9ee7727b24b 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.tsx @@ -1,4 +1,4 @@ -import { Button, ErrorState, Typography } from '@linode/ui'; +import { Button, CircleProgress, ErrorState, Typography } from '@linode/ui'; import { GridLegacy, useTheme } from '@mui/material'; import * as React from 'react'; @@ -11,6 +11,7 @@ import RenderComponent from '../shared/CloudPulseComponentRenderer'; import { DASHBOARD_ID, INTERFACE_ID, + LINODE_REGION, NODE_TYPE, PORT, REGION, @@ -30,7 +31,10 @@ import { import { FILTER_CONFIG } from '../Utils/FilterConfig'; import { type CloudPulseServiceTypeFilters } from '../Utils/models'; -import type { FilterValueType } from '../Dashboard/CloudPulseDashboardLanding'; +import type { + CloudPulseMetricsFilter, + FilterValueType, +} from '../Dashboard/CloudPulseDashboardLanding'; import type { CloudPulseResources } from './CloudPulseResourcesSelect'; import type { CloudPulseTags } from './CloudPulseTagsFilter'; import type { AclpConfig, Dashboard } from '@linode/api-v4'; @@ -55,6 +59,16 @@ export interface CloudPulseDashboardFilterBuilderProps { handleToggleAppliedFilter: (isVisible: boolean) => void; + /** + * Is cluster Call + */ + isError?: boolean; + + /** + * Property to disable filters + */ + isLoading?: boolean; + /** * this will handle the restrictions, if the parent of the component is going to be integrated in service analytics page */ @@ -80,19 +94,18 @@ export const CloudPulseDashboardFilterBuilder = React.memo( isServiceAnalyticsIntegration, preferences, resource_ids, + isError = false, + isLoading = false, } = props; - const [, setDependentFilters] = React.useState<{ - [key: string]: FilterValueType; - }>({}); + const [, setDependentFilters] = React.useState({}); const [showFilter, setShowFilter] = React.useState(true); const theme = useTheme(); - const dependentFilterReference: React.MutableRefObject<{ - [key: string]: FilterValueType; - }> = React.useRef({}); + const dependentFilterReference: React.MutableRefObject = + React.useRef({}); const checkAndUpdateDependentFilters = React.useCallback( (filterKey: string, value: FilterValueType) => { @@ -212,17 +225,23 @@ export const CloudPulseDashboardFilterBuilder = React.memo( const handleRegionChange = React.useCallback( ( + filterKey: string, region: string | undefined, labels: string[], savePref: boolean = false ) => { - const updatedPreferenceData = { - [REGION]: region, - [RESOURCES]: undefined, - [TAGS]: undefined, - }; + const updatedPreferenceData = + filterKey === REGION + ? { + [filterKey]: region, + [RESOURCES]: undefined, + [TAGS]: undefined, + } + : { + [filterKey]: region, + }; emitFilterChangeByFilterKey( - REGION, + filterKey, region, labels, savePref, @@ -261,6 +280,7 @@ export const CloudPulseDashboardFilterBuilder = React.memo( dependentFilters: dependentFilterReference.current, isServiceAnalyticsIntegration, preferences, + shouldDisable: isError || isLoading, }, handleTagsChange ); @@ -272,6 +292,21 @@ export const CloudPulseDashboardFilterBuilder = React.memo( isServiceAnalyticsIntegration, preferences, dependentFilters: dependentFilterReference.current, + shouldDisable: isError || isLoading, + }, + handleRegionChange + ); + } else if (config.configuration.filterKey === LINODE_REGION) { + return getRegionProperties( + { + config, + dashboard, + isServiceAnalyticsIntegration, + preferences, + dependentFilters: resource_ids?.length + ? { [RESOURCE_ID]: resource_ids } + : dependentFilterReference.current, + shouldDisable: isError || isLoading, }, handleRegionChange ); @@ -283,6 +318,7 @@ export const CloudPulseDashboardFilterBuilder = React.memo( dependentFilters: dependentFilterReference.current, isServiceAnalyticsIntegration, preferences, + shouldDisable: isError || isLoading, }, handleResourceChange ); @@ -301,6 +337,7 @@ export const CloudPulseDashboardFilterBuilder = React.memo( : ( dependentFilterReference.current[RESOURCE_ID] as string[] )?.map((id: string) => Number(id)), + shouldDisable: isError || isLoading, }, handleNodeTypeChange ); @@ -317,6 +354,7 @@ export const CloudPulseDashboardFilterBuilder = React.memo( dependentFilters: resource_ids?.length ? { [RESOURCE_ID]: resource_ids } : dependentFilterReference.current, + shouldDisable: isError || isLoading, }, handleTextFilterChange ); @@ -330,6 +368,7 @@ export const CloudPulseDashboardFilterBuilder = React.memo( : dependentFilterReference.current, isServiceAnalyticsIntegration, preferences, + shouldDisable: isError || isLoading, }, handleCustomSelectChange ); @@ -345,6 +384,8 @@ export const CloudPulseDashboardFilterBuilder = React.memo( handleCustomSelectChange, isServiceAnalyticsIntegration, preferences, + isError, + isLoading, ] ); @@ -438,21 +479,32 @@ export const CloudPulseDashboardFilterBuilder = React.memo( Filters - - - + {isLoading ? ( + + + + ) : ( + + + + )} ); }, @@ -466,6 +518,8 @@ function compareProps( return ( oldProps.dashboard?.id === newProps.dashboard?.id && oldProps.preferences?.[DASHBOARD_ID] === - newProps.preferences?.[DASHBOARD_ID] + newProps.preferences?.[DASHBOARD_ID] && + oldProps.isLoading === newProps.isLoading && + oldProps.isError === newProps.isError ); } diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardSelect.tsx index 2955cfb9062..07153cc8529 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardSelect.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardSelect.tsx @@ -5,9 +5,13 @@ import { useFlags } from 'src/hooks/useFlags'; import { useCloudPulseDashboardsQuery } from 'src/queries/cloudpulse/dashboards'; import { useCloudPulseServiceTypes } from 'src/queries/cloudpulse/services'; -import { formattedServiceTypes, getAllDashboards } from '../Utils/utils'; +import { getAllDashboards, getEnabledServiceTypes } from '../Utils/utils'; -import type { Dashboard, FilterValue } from '@linode/api-v4'; +import type { + CloudPulseServiceType, + Dashboard, + FilterValue, +} from '@linode/api-v4'; export interface CloudPulseDashboardSelectProps { /** @@ -48,10 +52,18 @@ export const CloudPulseDashboardSelect = React.memo( isLoading: serviceTypesLoading, } = useCloudPulseServiceTypes(true); - const { aclpBetaServices } = useFlags(); - const serviceTypes: string[] = formattedServiceTypes(serviceTypesList); - const serviceTypeMap: Map = new Map( - serviceTypesList?.data.map((item) => [item.service_type, item.label]) + const { aclpServices } = useFlags(); + + // Get formatted enabled service types based on the LD flag + const serviceTypes: CloudPulseServiceType[] = getEnabledServiceTypes( + serviceTypesList, + aclpServices + ); + + const serviceTypeMap: Map = new Map( + (serviceTypesList?.data || []) + .filter((item) => item?.service_type !== undefined) + .map((item) => [item.service_type, item.label ?? '']) ); const { @@ -125,10 +137,18 @@ export const CloudPulseDashboardSelect = React.memo( placeholder={placeHolder} renderGroup={(params) => ( - - {serviceTypeMap.get(params.group) || params.group}{' '} - {aclpBetaServices?.[params.group]?.metrics && } - + + + {serviceTypeMap.get(params.group as CloudPulseServiceType) || + params.group} + + {aclpServices?.[params.group as CloudPulseServiceType]?.metrics + ?.beta && } + {params.children} )} diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseNodeTypeFilter.test.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseNodeTypeFilter.test.tsx index 26806cf2dcb..09323742ef8 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseNodeTypeFilter.test.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseNodeTypeFilter.test.tsx @@ -20,14 +20,14 @@ const props: CloudPulseNodeTypeFilterProps = { }; const queryMocks = vi.hoisted(() => ({ - useAllDatabasesQuery: vi.fn().mockReturnValue({}), + useResourcesQuery: vi.fn().mockReturnValue({}), })); -vi.mock('@linode/queries', async () => { - const actual = await vi.importActual('@linode/queries'); +vi.mock('src/queries/cloudpulse/resources', async () => { + const actual = await vi.importActual('src/queries/cloudpulse/resources'); return { ...actual, - useAllDatabasesQuery: queryMocks.useAllDatabasesQuery, + useResourcesQuery: queryMocks.useResourcesQuery, }; }); @@ -50,8 +50,8 @@ describe('CloudPulseNodeTypeFilter', () => { }); it('initializes with Primary as default value when no preferences are saved', async () => { - queryMocks.useAllDatabasesQuery.mockReturnValue({ - data: databaseInstanceFactory.buildList(2), + queryMocks.useResourcesQuery.mockReturnValue({ + data: [{ ...databaseInstanceFactory.build(), clusterSize: 1 }], isError: false, isLoading: false, }); @@ -67,10 +67,16 @@ describe('CloudPulseNodeTypeFilter', () => { }); it('displays correct options in dropdown in case of maximum cluster size one', async () => { - queryMocks.useAllDatabasesQuery.mockReturnValue({ + queryMocks.useResourcesQuery.mockReturnValue({ data: [ - databaseInstanceFactory.build({ cluster_size: 1, id: 1 }), - databaseInstanceFactory.build({ cluster_size: 1, id: 2 }), + { + ...databaseInstanceFactory.build({ id: 1 }), + clusterSize: 1, + }, + { + ...databaseInstanceFactory.build({ id: 2 }), + clusterSize: 1, + }, ], isError: false, isLoading: false, @@ -87,10 +93,16 @@ describe('CloudPulseNodeTypeFilter', () => { }); it('displays correct options in dropdown if maximum cluster size is greater than one', async () => { - queryMocks.useAllDatabasesQuery.mockReturnValue({ + queryMocks.useResourcesQuery.mockReturnValue({ data: [ - databaseInstanceFactory.build({ cluster_size: 2, id: 1 }), - databaseInstanceFactory.build({ cluster_size: 3, id: 2 }), + { + ...databaseInstanceFactory.build({ id: 1 }), + clusterSize: 2, + }, + { + ...databaseInstanceFactory.build({ id: 2 }), + clusterSize: 3, + }, ], isError: false, isLoading: false, @@ -116,10 +128,16 @@ describe('CloudPulseNodeTypeFilter', () => { }); it('maintains selected value in preferences after re-render', async () => { - queryMocks.useAllDatabasesQuery.mockReturnValue({ + queryMocks.useResourcesQuery.mockReturnValue({ data: [ - databaseInstanceFactory.build({ cluster_size: 1, id: 1 }), - databaseInstanceFactory.build({ cluster_size: 3, id: 2 }), + { + ...databaseInstanceFactory.build({ id: 1 }), + clusterSize: 1, + }, + { + ...databaseInstanceFactory.build({ id: 2 }), + clusterSize: 3, + }, ], isError: false, isLoading: false, diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseNodeTypeFilter.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseNodeTypeFilter.tsx index cda1d448278..9f26fc63a11 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseNodeTypeFilter.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseNodeTypeFilter.tsx @@ -1,10 +1,11 @@ -import { useAllDatabasesQuery } from '@linode/queries'; import { Autocomplete } from '@linode/ui'; import * as React from 'react'; -import { PRIMARY_NODE } from '../Utils/constants'; +import { useResourcesQuery } from 'src/queries/cloudpulse/resources'; -import type { DatabaseInstance, FilterValue } from '@linode/api-v4'; +import { PRIMARY_NODE, RESOURCE_FILTER_MAP } from '../Utils/constants'; + +import type { FilterValue } from '@linode/api-v4'; export interface CloudPulseNodeType { id: string; @@ -88,7 +89,12 @@ export const CloudPulseNodeTypeFilter = React.memo( data: databaseClusters, isError, isLoading, - } = useAllDatabasesQuery(); // fetch all databases + } = useResourcesQuery( + !disabled, + 'dbaas', + {}, + RESOURCE_FILTER_MAP['dbaas'] ?? {} + ); // fetch all databases const isClusterSizeGreaterThanOne = React.useMemo< boolean | undefined @@ -98,8 +104,8 @@ export const CloudPulseNodeTypeFilter = React.memo( } // check if any cluster has a size greater than 1 for selected database ids return databaseClusters.some( - (cluster: DatabaseInstance) => - database_ids.includes(cluster.id) && cluster.cluster_size > 1 + ({ id, clusterSize }) => + database_ids.includes(Number(id)) && clusterSize && clusterSize > 1 ); }, [databaseClusters, database_ids]); diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.test.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.test.tsx index feb1bcc38c2..77a3e608ddb 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.test.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.test.tsx @@ -14,6 +14,8 @@ import type { Region } from '@linode/api-v4'; import type { useRegionsQuery } from '@linode/queries'; const props: CloudPulseRegionSelectProps = { + filterKey: 'region', + selectedEntities: [], handleRegionChange: vi.fn(), label: 'Region', selectedDashboard: undefined, @@ -152,13 +154,6 @@ describe('CloudPulseRegionSelect', () => { }); renderWithTheme(); - expect(queryMocks.useResourcesQuery).toHaveBeenLastCalledWith( - false, - undefined, - {}, - {} - ); // use resources should have called with enabled false since the region call failed - const errorMessage = screen.getByText('Failed to fetch Region.'); // should show regions failure only expect(errorMessage).not.toBeNull(); diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx index b385b4cf223..36760f146d0 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx @@ -6,17 +6,26 @@ import { RegionSelect } from 'src/components/RegionSelect/RegionSelect'; import { useFlags } from 'src/hooks/useFlags'; import { useResourcesQuery } from 'src/queries/cloudpulse/resources'; +import { useFetchOptions } from '../Alerts/CreateAlert/Criteria/DimensionFilterValue/useFetchOptions'; import { filterRegionByServiceType } from '../Alerts/Utils/utils'; -import { NO_REGION_MESSAGE } from '../Utils/constants'; -import { deepEqual } from '../Utils/FilterBuilder'; +import { + LINODE_REGION, + NO_REGION_MESSAGE, + RESOURCE_FILTER_MAP, +} from '../Utils/constants'; +import { deepEqual, filterUsingDependentFilters } from '../Utils/FilterBuilder'; import { FILTER_CONFIG } from '../Utils/FilterConfig'; -import type { Dashboard, Filter, FilterValue, Region } from '@linode/api-v4'; +import type { Item } from '../Alerts/constants'; +import type { CloudPulseMetricsFilter } from '../Dashboard/CloudPulseDashboardLanding'; +import type { Dashboard, FilterValue, Region } from '@linode/api-v4'; export interface CloudPulseRegionSelectProps { defaultValue?: FilterValue; disabled?: boolean; + filterKey: string; handleRegionChange: ( + filterKey: string, region: string | undefined, labels: string[], savePref?: boolean @@ -25,40 +34,36 @@ export interface CloudPulseRegionSelectProps { placeholder?: string; savePreferences?: boolean; selectedDashboard: Dashboard | undefined; - xFilter?: Filter; + selectedEntities: string[]; + xFilter?: CloudPulseMetricsFilter; } export const CloudPulseRegionSelect = React.memo( (props: CloudPulseRegionSelectProps) => { const { defaultValue, + filterKey, handleRegionChange, label, placeholder, savePreferences, selectedDashboard, + selectedEntities, disabled = false, xFilter, } = props; - const resourceFilterMap: Record = { - dbaas: { - platform: 'rdbms-default', - }, - }; - const { data: regions, isError, isLoading } = useRegionsQuery(); const { data: resources, isError: isResourcesError, isLoading: isResourcesLoading, } = useResourcesQuery( - selectedDashboard !== undefined && Boolean(regions?.length), + !disabled && selectedDashboard !== undefined && Boolean(regions?.length), selectedDashboard?.service_type, {}, { - ...(resourceFilterMap[selectedDashboard?.service_type ?? ''] ?? {}), - ...(xFilter ?? {}), // the usual xFilters + ...(RESOURCE_FILTER_MAP[selectedDashboard?.service_type ?? ''] ?? {}), } ); @@ -92,13 +97,13 @@ export const CloudPulseRegionSelect = React.memo( ? regions.find((regionObj) => regionObj.id === defaultValue) : undefined; // Notify parent and set internal state - handleRegionChange(region?.id, region ? [region.label] : []); + handleRegionChange(filterKey, region?.id, region ? [region.label] : []); setSelectedRegion(region?.id); } else { if (selectedRegion !== undefined) { setSelectedRegion(''); } - handleRegionChange(undefined, []); + handleRegionChange(filterKey, undefined, []); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [ @@ -106,13 +111,29 @@ export const CloudPulseRegionSelect = React.memo( regions, // Function to call on change ]); - const supportedRegions = React.useMemo(() => { + const linodeRegionIds = useFetchOptions({ + dimensionLabel: filterKey, + entities: selectedEntities, + regions, + serviceType, + type: 'metrics', + }).map((option: Item) => option.value); + + const supportedLinodeRegions = + regions?.filter((region) => linodeRegionIds?.includes(region.id)) ?? []; + + const supportedRegions = React.useMemo(() => { return filterRegionByServiceType('metrics', regions, serviceType); }, [regions, serviceType]); - const supportedRegionsFromResources = supportedRegions?.filter(({ id }) => - resources?.some(({ region }) => region === id) - ); + const supportedRegionsFromResources = + filterKey === LINODE_REGION + ? supportedLinodeRegions + : supportedRegions.filter(({ id }) => + filterUsingDependentFilters(resources, xFilter)?.some( + ({ region }) => region === id + ) + ); return ( { setSelectedRegion(region?.id ?? ''); handleRegionChange( + filterKey, region?.id, region ? [region.label] : [], savePreferences ); }} placeholder={placeholder ?? 'Select a Region'} - regions={supportedRegionsFromResources ?? []} + regions={supportedRegionsFromResources} value={ supportedRegionsFromResources?.length ? selectedRegion : undefined } diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.test.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.test.tsx index 199b175c046..3ef165cf42d 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.test.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.test.tsx @@ -73,7 +73,7 @@ describe('CloudPulseResourcesSelect component tests', () => { handleResourcesSelection={mockResourceHandler} label="Resources" region={'us-east'} - resourceType={'us-east'} + resourceType="linode" /> ); diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.tsx index 98fdd6499c7..c95f20d2236 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.tsx @@ -5,11 +5,14 @@ import React from 'react'; import { useFlags } from 'src/hooks/useFlags'; import { useResourcesQuery } from 'src/queries/cloudpulse/resources'; -import { deepEqual } from '../Utils/FilterBuilder'; +import { RESOURCE_FILTER_MAP } from '../Utils/constants'; +import { deepEqual, filterUsingDependentFilters } from '../Utils/FilterBuilder'; -import type { Filter, FilterValue } from '@linode/api-v4'; +import type { CloudPulseMetricsFilter } from '../Dashboard/CloudPulseDashboardLanding'; +import type { CloudPulseServiceType, FilterValue } from '@linode/api-v4'; export interface CloudPulseResources { + clusterSize?: number; engineType?: string; entities?: Record; id: string; @@ -28,10 +31,10 @@ export interface CloudPulseResourcesSelectProps { label: string; placeholder?: string; region?: string; - resourceType: string | undefined; + resourceType: CloudPulseServiceType | undefined; savePreferences?: boolean; tags?: string[]; - xFilter?: Filter; + xFilter?: CloudPulseMetricsFilter; } export const CloudPulseResourcesSelect = React.memo( @@ -45,20 +48,11 @@ export const CloudPulseResourcesSelect = React.memo( region, resourceType, savePreferences, - tags, xFilter, } = props; const flags = useFlags(); - const resourceFilterMap: Record = { - dbaas: { - '+order': 'asc', - '+order_by': 'label', - platform: 'rdbms-default', - }, - }; - const { data: resources, isError, @@ -67,16 +61,8 @@ export const CloudPulseResourcesSelect = React.memo( disabled !== undefined ? !disabled : Boolean(region && resourceType), resourceType, {}, - xFilter - ? { - ...(resourceFilterMap[resourceType ?? ''] ?? {}), - ...xFilter, // the usual xFilters - } - : { - ...(resourceFilterMap[resourceType ?? ''] ?? {}), - region, - ...(tags ?? []), - } + + RESOURCE_FILTER_MAP[resourceType ?? ''] ?? {} ); const [selectedResources, setSelectedResources] = @@ -90,8 +76,8 @@ export const CloudPulseResourcesSelect = React.memo( const isAutocompleteOpen = React.useRef(false); // Ref to track the open state of Autocomplete const getResourcesList = React.useMemo(() => { - return resources && resources.length > 0 ? resources : []; - }, [resources]); + return filterUsingDependentFilters(resources, xFilter) ?? []; + }, [resources, xFilter]); // Maximum resource selection limit is fetched from launchdarkly const maxResourceSelectionLimit = React.useMemo(() => { diff --git a/packages/manager/src/features/CloudPulse/shared/DimensionTransform.ts b/packages/manager/src/features/CloudPulse/shared/DimensionTransform.ts new file mode 100644 index 00000000000..0995f9fcc7f --- /dev/null +++ b/packages/manager/src/features/CloudPulse/shared/DimensionTransform.ts @@ -0,0 +1,35 @@ +import { capitalize } from '@linode/utilities'; + +import type { TransformFunction, TransformFunctionMap } from './types'; +import type { CloudPulseServiceType } from '@linode/api-v4'; + +// Transform functions to transform the dimension value +export const TRANSFORMS: TransformFunctionMap = { + capitalize: (value: string) => capitalize(value), + uppercase: (value: string) => value.toUpperCase(), + lowercase: (value: string) => value.toLowerCase(), +}; + +/** + * @description Configuration mapping service types to their dimension-specific transform functions. + * Defines how dimension values should be formatted/transformed for different CloudPulse services. + */ +export const DIMENSION_TRANSFORM_CONFIG: Partial< + Record> +> = { + linode: { + operation: TRANSFORMS.capitalize, + type: TRANSFORMS.capitalize, + pattern: TRANSFORMS.capitalize, + protocol: TRANSFORMS.capitalize, + }, + dbaas: { + node_type: TRANSFORMS.capitalize, + }, + firewall: { + interface_type: TRANSFORMS.uppercase, + }, + nodebalancer: { + protocol: TRANSFORMS.uppercase, + }, +}; diff --git a/packages/manager/src/features/CloudPulse/shared/types.ts b/packages/manager/src/features/CloudPulse/shared/types.ts new file mode 100644 index 00000000000..864198de637 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/shared/types.ts @@ -0,0 +1,6 @@ +// Transform keys for the dimension filter value transform function +export type TransformKey = 'capitalize' | 'lowercase' | 'uppercase'; + +export type TransformFunction = (value: string) => string; + +export type TransformFunctionMap = Record; diff --git a/packages/manager/src/features/DataStream/Destinations/DestinationCreate/DestinationCreate.tsx b/packages/manager/src/features/DataStream/Destinations/DestinationCreate/DestinationCreate.tsx index 3d318f52d59..13a7f176309 100644 --- a/packages/manager/src/features/DataStream/Destinations/DestinationCreate/DestinationCreate.tsx +++ b/packages/manager/src/features/DataStream/Destinations/DestinationCreate/DestinationCreate.tsx @@ -1,8 +1,10 @@ import { yupResolver } from '@hookform/resolvers/yup'; import { destinationType } from '@linode/api-v4'; +import { useCreateDestinationMutation } from '@linode/queries'; import { Autocomplete, Box, Button, Paper, TextField } from '@linode/ui'; import { createDestinationSchema } from '@linode/validation'; import { useTheme } from '@mui/material/styles'; +import { useNavigate } from '@tanstack/react-router'; import * as React from 'react'; import { Controller, FormProvider, useForm, useWatch } from 'react-hook-form'; @@ -12,12 +14,15 @@ import { getDestinationTypeOption } from 'src/features/DataStream/dataStreamUtil import { DestinationLinodeObjectStorageDetailsForm } from 'src/features/DataStream/Shared/DestinationLinodeObjectStorageDetailsForm'; import { destinationTypeOptions } from 'src/features/DataStream/Shared/types'; +import type { LandingHeaderProps } from 'src/components/LandingHeader'; import type { CreateDestinationForm } from 'src/features/DataStream/Shared/types'; export const DestinationCreate = () => { const theme = useTheme(); + const { mutateAsync: createDestination } = useCreateDestinationMutation(); + const navigate = useNavigate(); - const landingHeaderProps = { + const landingHeaderProps: LandingHeaderProps = { breadcrumbProps: { pathname: '/datastream/destinations/create', crumbOverrides: [ @@ -49,7 +54,12 @@ export const DestinationCreate = () => { name: 'type', }); - const onSubmit = handleSubmit(async () => {}); + const onSubmit = () => { + const payload = form.getValues(); + createDestination(payload).then(() => { + navigate({ to: '/datastream/destinations' }); + }); + }; return ( <> @@ -57,7 +67,7 @@ export const DestinationCreate = () => { - +
    + + + + Name + + + Type + + + ID + + + + Creation Time + + + + Last Modified + + + + + {destinations?.data.map((destination) => ( + + ))} + +
    + + + ); }; diff --git a/packages/manager/src/features/DataStream/Destinations/DestinationsLandingEmptyState.tsx b/packages/manager/src/features/DataStream/Destinations/DestinationsLandingEmptyState.tsx index d25cdfa1b63..1fe38d71f6e 100644 --- a/packages/manager/src/features/DataStream/Destinations/DestinationsLandingEmptyState.tsx +++ b/packages/manager/src/features/DataStream/Destinations/DestinationsLandingEmptyState.tsx @@ -1,4 +1,3 @@ -import { useNavigate } from '@tanstack/react-router'; import * as React from 'react'; import ComputeIcon from 'src/assets/icons/entityIcons/compute.svg'; @@ -11,8 +10,14 @@ import { } from 'src/features/DataStream/Destinations/DestinationsLandingEmptyStateData'; import { sendEvent } from 'src/utilities/analytics/utils'; -export const DestinationsLandingEmptyState = () => { - const navigate = useNavigate(); +interface DestinationsLandingEmptyStateProps { + navigateToCreate: () => void; +} + +export const DestinationsLandingEmptyState = ( + props: DestinationsLandingEmptyStateProps +) => { + const { navigateToCreate } = props; return ( <> @@ -27,7 +32,7 @@ export const DestinationsLandingEmptyState = () => { category: linkAnalyticsEvent.category, label: 'Create Destination', }); - navigate({ to: '/datastream/destinations/create' }); + navigateToCreate(); }, }, ]} diff --git a/packages/manager/src/features/DataStream/Destinations/constants.ts b/packages/manager/src/features/DataStream/Destinations/constants.ts new file mode 100644 index 00000000000..69d0814b7b0 --- /dev/null +++ b/packages/manager/src/features/DataStream/Destinations/constants.ts @@ -0,0 +1,3 @@ +export const DESTINATIONS_TABLE_DEFAULT_ORDER = 'desc'; +export const DESTINATIONS_TABLE_DEFAULT_ORDER_BY = 'created'; +export const DESTINATIONS_TABLE_PREFERENCE_KEY = 'destinations'; diff --git a/packages/manager/src/features/DataStream/Shared/DataStreamTabHeader/DataStreamTabHeader.test.tsx b/packages/manager/src/features/DataStream/Shared/DataStreamTabHeader/DataStreamTabHeader.test.tsx index ecdc5d03c18..d5d81c33419 100644 --- a/packages/manager/src/features/DataStream/Shared/DataStreamTabHeader/DataStreamTabHeader.test.tsx +++ b/packages/manager/src/features/DataStream/Shared/DataStreamTabHeader/DataStreamTabHeader.test.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; import { DataStreamTabHeader } from 'src/features/DataStream/Shared/DataStreamTabHeader/DataStreamTabHeader'; +import { streamStatusOptions } from 'src/features/DataStream/Shared/types'; import { renderWithTheme } from 'src/utilities/testHelpers'; describe('DataStreamTabHeader', () => { @@ -8,7 +9,8 @@ describe('DataStreamTabHeader', () => { const { getByText } = renderWithTheme( null} /> ); - expect(getByText('Create Stream')).toBeInTheDocument(); + + getByText('Create Stream'); }); it('should render a disabled create button', () => { @@ -25,4 +27,33 @@ describe('DataStreamTabHeader', () => { 'true' ); }); + + it('should render a search input', () => { + const { getByPlaceholderText } = renderWithTheme( + null} + onSearch={() => null} + searchValue={''} + /> + ); + + getByPlaceholderText('Search for a Stream'); + }); + + it('should render a select input', () => { + const selectValue = streamStatusOptions[0].value; + const { getByPlaceholderText, getByLabelText } = renderWithTheme( + null} + selectList={streamStatusOptions} + selectValue={selectValue} + /> + ); + + getByLabelText('Status'); + getByPlaceholderText('Select'); + }); }); diff --git a/packages/manager/src/features/DataStream/Shared/DataStreamTabHeader/DataStreamTabHeader.tsx b/packages/manager/src/features/DataStream/Shared/DataStreamTabHeader/DataStreamTabHeader.tsx index 5799da80cb5..c44571be93d 100644 --- a/packages/manager/src/features/DataStream/Shared/DataStreamTabHeader/DataStreamTabHeader.tsx +++ b/packages/manager/src/features/DataStream/Shared/DataStreamTabHeader/DataStreamTabHeader.tsx @@ -1,18 +1,27 @@ -import { Button } from '@linode/ui'; +import { Autocomplete, Button } from '@linode/ui'; import Grid from '@mui/material/Grid'; import { styled, useTheme } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; import * as React from 'react'; +import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField'; + import type { Theme } from '@mui/material/styles'; +import type { LabelValueOption } from 'src/features/DataStream/Shared/types'; export interface DataStreamTabHeaderProps { buttonDataAttrs?: { [key: string]: boolean | string }; createButtonText?: string; disabledCreateButton?: boolean; entity?: string; + isSearching?: boolean; loading?: boolean; onButtonClick?: () => void; + onSearch?: (label: string) => void; + onSelect?: (status: string) => void; + searchValue?: string; + selectList?: LabelValueOption[]; + selectValue?: string; spacingBottom?: 0 | 4 | 16 | 24; } @@ -24,6 +33,12 @@ export const DataStreamTabHeader = ({ loading, onButtonClick, spacingBottom = 24, + isSearching, + selectList, + onSelect, + selectValue, + searchValue, + onSearch, }: DataStreamTabHeaderProps) => { const theme = useTheme(); @@ -35,6 +50,7 @@ export const DataStreamTabHeader = ({ const customSmMdBetweenBreakpoint = useMediaQuery((theme: Theme) => theme.breakpoints.between(customBreakpoint, 'md') ); + const searchLabel = `Search for a ${entity}`; return ( - { - // @TODO (DPS-34192) Search input - both streams and destinations - } + {onSearch && searchValue !== undefined && ( + + )} - { - // @TODO (DPS-34193) Select status - only streams - } + {selectList && onSelect && ( + { + onSelect(option?.value ?? ''); + }} + options={selectList} + placeholder="Select" + value={selectList.find(({ value }) => value === selectValue)} + /> + )} {onButtonClick && (