diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eab44266804..57a52f14260 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,7 +34,7 @@ jobs: version: 10 - uses: actions/setup-node@v4 with: - node-version: "20.17" + node-version-file: "package.json" cache: "pnpm" - run: pnpm install --frozen-lockfile - run: pnpm run --filter ${{ matrix.package }} lint @@ -49,7 +49,7 @@ jobs: version: 10 - uses: actions/setup-node@v4 with: - node-version: "20.17" + node-version-file: "package.json" cache: "pnpm" - run: pnpm install --frozen-lockfile - run: pnpm run --filter @linode/validation build @@ -69,7 +69,7 @@ jobs: version: 10 - uses: actions/setup-node@v4 with: - node-version: "20.17" + node-version-file: "package.json" cache: "pnpm" - run: pnpm install --frozen-lockfile - uses: actions/download-artifact@v4 @@ -89,7 +89,7 @@ jobs: version: 10 - uses: actions/setup-node@v4 with: - node-version: "20.17" + node-version-file: "package.json" cache: "pnpm" - uses: actions/download-artifact@v4 with: @@ -113,7 +113,7 @@ jobs: version: 10 - uses: actions/setup-node@v4 with: - node-version: "20.17" + node-version-file: "package.json" cache: "pnpm" - run: pnpm install --frozen-lockfile @@ -154,7 +154,7 @@ jobs: version: 10 - uses: actions/setup-node@v4 with: - node-version: "20.17" + node-version-file: "package.json" cache: "pnpm" - uses: actions/download-artifact@v4 with: @@ -177,7 +177,7 @@ jobs: version: 10 - uses: actions/setup-node@v4 with: - node-version: "20.17" + node-version-file: "package.json" cache: "pnpm" - run: pnpm install --frozen-lockfile - run: pnpm run --filter @linode/search test @@ -192,7 +192,7 @@ jobs: version: 10 - uses: actions/setup-node@v4 with: - node-version: "20.17" + node-version-file: "package.json" cache: "pnpm" - run: pnpm install --frozen-lockfile - run: pnpm run --filter @linode/ui test @@ -208,7 +208,7 @@ jobs: version: 10 - uses: actions/setup-node@v4 with: - node-version: "20.17" + node-version-file: "package.json" cache: "pnpm" - uses: actions/download-artifact@v4 with: @@ -228,7 +228,7 @@ jobs: version: 10 - uses: actions/setup-node@v4 with: - node-version: "20.17" + node-version-file: "package.json" cache: "pnpm" - uses: actions/download-artifact@v4 with: @@ -248,7 +248,7 @@ jobs: version: 10 - uses: actions/setup-node@v4 with: - node-version: "20.17" + node-version-file: "package.json" cache: "pnpm" - uses: actions/download-artifact@v4 with: @@ -272,7 +272,7 @@ jobs: version: 10 - uses: actions/setup-node@v4 with: - node-version: "20.17" + node-version-file: "package.json" cache: "pnpm" - run: pnpm install --frozen-lockfile - run: pnpm run --filter @linode/ui typecheck @@ -288,7 +288,7 @@ jobs: version: 10 - uses: actions/setup-node@v4 with: - node-version: "20.17" + node-version-file: "package.json" cache: "pnpm" - uses: actions/download-artifact@v4 with: @@ -308,7 +308,7 @@ jobs: version: 10 - uses: actions/setup-node@v4 with: - node-version: "20.17" + node-version-file: "package.json" cache: "pnpm" - uses: actions/download-artifact@v4 with: @@ -328,7 +328,7 @@ jobs: version: 10 - uses: actions/setup-node@v4 with: - node-version: "20.17" + node-version-file: "package.json" cache: "pnpm" - uses: actions/download-artifact@v4 with: @@ -352,7 +352,7 @@ jobs: version: 10 - uses: actions/setup-node@v4 with: - node-version: "20.17" + node-version-file: "package.json" cache: "pnpm" - uses: actions/download-artifact@v4 with: @@ -381,7 +381,7 @@ jobs: version: 10 - uses: actions/setup-node@v4 with: - node-version: "20.17" + node-version-file: "package.json" cache: "pnpm" - uses: actions/download-artifact@v4 with: @@ -419,7 +419,7 @@ jobs: version: 10 - uses: actions/setup-node@v4 with: - node-version: "20.17" + node-version-file: "package.json" cache: "pnpm" - uses: actions/download-artifact@v4 with: diff --git a/.github/workflows/coverage_badge.yml b/.github/workflows/coverage_badge.yml index 639f21e3e0d..44d5902256d 100644 --- a/.github/workflows/coverage_badge.yml +++ b/.github/workflows/coverage_badge.yml @@ -21,10 +21,9 @@ jobs: run_install: false version: 10 - - name: Use Node.js v20.17 LTS - uses: actions/setup-node@v4 + - uses: actions/setup-node@v4 with: - node-version: "20.17" + node-version-file: "package.json" cache: "pnpm" - name: Install Dependencies diff --git a/.github/workflows/e2e_schedule_and_push.yml b/.github/workflows/e2e_schedule_and_push.yml index ba726631bbb..bf66d7cd3eb 100644 --- a/.github/workflows/e2e_schedule_and_push.yml +++ b/.github/workflows/e2e_schedule_and_push.yml @@ -43,7 +43,7 @@ jobs: version: 10 - uses: actions/setup-node@v4 with: - node-version: "20.17" + node-version-file: "package.json" - run: | echo "GITHUB_TOKEN=${{ secrets.GITHUB_TOKEN }}" >> $GITHUB_ENV - run: | diff --git a/.github/workflows/eslint_review.yml b/.github/workflows/eslint_review.yml index 44430dfec58..cb1424cd786 100644 --- a/.github/workflows/eslint_review.yml +++ b/.github/workflows/eslint_review.yml @@ -18,7 +18,7 @@ jobs: version: 10 - uses: actions/setup-node@v4 with: - node-version: "20.17" + node-version-file: "package.json" cache: "pnpm" - run: pnpm install - uses: abailly-akamai/action-eslint@8ad68ba04fa60924ef7607b07deb5989f38f5ed6 # v1.0.2 diff --git a/.nvmrc b/.nvmrc index 65da8ce3917..6e77d0a7496 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -20.17 +22.19 diff --git a/CODEOWNERS b/CODEOWNERS index 8b6b2774004..b5eef9f001a 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1,5 +1,50 @@ -# Default code owners -* @linode/frontend +# Default Team +# This is a catch all: any change that is not captured by a codeowner rule +# below will result in`@linode/cloud-manager-code-reviewers` being assigned +# for PR review. +* @linode/cloud-manager-code-reviewers -# Frontend SDET code owners for Cypress tests +# Cypress E2E Tests +# This is also a catch all: any change to E2E tests outside of the team-owned +# files and directories will result in `@linode/frontend-sdet` being assigned +# for PR review. /packages/manager/cypress/ @linode/frontend-sdet + +# UI Package +/packages/ui @linode/ui-platform-design-systems + +# Metrics & Alerts +/packages/api-v4/src/cloudpulse @linode/metrics-alerts +/packages/validation/src/cloudpulse.schema.ts @linode/metrics-alerts +/packages/manager/cypress/e2e/core/cloudpulse @linode/metrics-alerts +/packages/manager/cypress/support/util/cloudpulse.ts @linode/metrics-alerts +/packages/manager/cypress/support/constants/cloudpulse.ts @linode/metrics-alerts +/packages/manager/cypress/support/intercepts/cloudpulse.ts @linode/metrics-alerts +/packages/manager/src/routes/alerts @linode/metrics-alerts +/packages/manager/src/routes/metrics @linode/metrics-alerts +/packages/manager/src/factories/cloudpulse @linode/metrics-alerts +/packages/manager/src/features/CloudPulse @linode/metrics-alerts +/packages/manager/src/queries/cloudpulse @linode/metrics-alerts + +# IAM +/packages/api-v4/src/iam @linode/iam +/packages/manager/src/routes/IAM @linode/iam +/packages/manager/cypress/component/features/IAM @linode/iam +/packages/queries/src/iam @linode/iam +/packages/manager/src/features/IAM @linode/iam +/packages/manager/src/mocks/presets/crud/seeds/delegation.ts @linode/iam +/packages/manager/src/mocks/presets/crud/handlers/delegation.ts @linode/iam +/packages/manager/src/mocks/presets/crud/delegation.ts @linode/iam +/packages/utilities/src/factories/delegation.ts @linode/iam + +# DBaaS +/packages/manager/src/features/Databases @linode/dbaas-ui +/packages/manager/src/routes/databases @linode/dbaas-ui +/packages/manager/src/queries/databases @linode/dbaas-ui +/packages/manager/src/factories/databases.ts @linode/dbaas-ui +/packages/queries/src/databases @linode/dbaas-ui +/packages/api-v4/src/databases @linode/dbaas-ui +/packages/validation/src/databases.schema.ts @linode/dbaas-ui +/packages/manager/cypress/e2e/core/databases @linode/dbaas-ui +/packages/manager/cypress/support/constants/databases.ts @linode/dbaas-ui +/packages/manager/cypress/support/intercepts/databases.ts @linode/dbaas-ui diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index 3e36f22e05f..b4b99f6102a 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -7,17 +7,17 @@ 5. After your OAuth App has been created, copy the ID (not the secret). 6. In `packages/manager`, copy the contents of `.env.example` and paste them into a new file called `.env`. 7. In `.env` set `REACT_APP_CLIENT_ID` to the ID from step 5. -8. Install Node.js 20.17 LTS. We recommend using [Volta](https://volta.sh/): +8. Install Node.js 22.19 LTS. We recommend using [Volta](https://volta.sh/): ```bash curl https://get.volta.sh | bash ## Add volta to your .*rc file, or open a new terminal window. - volta install node@20.17 + volta install node@22.19 node --version - ## v20.17.0 + ## v22.19.0 ``` 9. Install pnpm v10 using Volta or view the [pnpm docs](https://pnpm.io/installation) for more installation methods diff --git a/package.json b/package.json index 31a477cbe00..c2fa7452a5f 100644 --- a/package.json +++ b/package.json @@ -72,11 +72,13 @@ "resolutions": { "semver": "^7.5.2", "yaml": "^2.3.0", - "form-data": "^4.0.4" + "form-data": "^4.0.4", + "brace-expansion@>=1.0.0 <=1.1.11": ">=1.1.12", + "brace-expansion@>=2.0.0 <=2.0.1": ">=2.0.2" }, "version": "0.0.0", "volta": { - "node": "20.17.0" + "node": "22.19.0" }, "workspaces": { "packages": [ diff --git a/packages/api-v4/CHANGELOG.md b/packages/api-v4/CHANGELOG.md index 7ce5c1d5d85..d31de352b07 100644 --- a/packages/api-v4/CHANGELOG.md +++ b/packages/api-v4/CHANGELOG.md @@ -1,3 +1,24 @@ +## [2025-10-07] - v0.150.0 + + +### Added: + +- IAM Parent/Child - Implement new delegation types and endpoints definitions ([#12895](https://github.com/linode/manager/pull/12895)) +- CloudPulse-Metrics: Update `CloudPulseServiceType` and constant `capabilityServiceTypeMapping` at `types.ts` ([#12905](https://github.com/linode/manager/pull/12905)) +- Region VPC availability types and endpoints ([#12919](https://github.com/linode/manager/pull/12919)) + +### Changed: + +- All kubernetes endpoints from `/v4` to `/v4beta`; clean up duplicate endpoints ([#12867](https://github.com/linode/manager/pull/12867)) +- CloudPulse-Alerts: Update `CloudPulseAlertsPayload` in types.ts ([#12870](https://github.com/linode/manager/pull/12870)) +- ACLP: update `group_by` property to optional for `Widgets` and `CloudPulseMetricRequest` interface ([#12887](https://github.com/linode/manager/pull/12887)) +- CloudPulse-Metrics: Update `CloudPulseMetricsRequest` and `JWETokenPayLoad` type at `types.ts` ([#12912](https://github.com/linode/manager/pull/12912)) + +### Upcoming Features: + +- Update Destination's details interface ([#12851](https://github.com/linode/manager/pull/12851)) +- Logs Delivery Stream details type update and UpdateDestinationPayload update according to API docs ([#12898](https://github.com/linode/manager/pull/12898)) + ## [2025-09-23] - v0.149.0 ### Added: diff --git a/packages/api-v4/package.json b/packages/api-v4/package.json index 2f80884cfe8..559860d778f 100644 --- a/packages/api-v4/package.json +++ b/packages/api-v4/package.json @@ -1,6 +1,6 @@ { "name": "@linode/api-v4", - "version": "0.149.0", + "version": "0.150.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/cloudpulse/alerts.ts b/packages/api-v4/src/cloudpulse/alerts.ts index ea31bdfdff1..4518b92c486 100644 --- a/packages/api-v4/src/cloudpulse/alerts.ts +++ b/packages/api-v4/src/cloudpulse/alerts.ts @@ -132,9 +132,9 @@ export const updateServiceAlerts = ( entityId: string, payload: CloudPulseAlertsPayload, ) => - Request<{}>( + Request( setURL( - `${API_ROOT}/${serviceType}/instances/${encodeURIComponent(entityId)}`, + `${API_ROOT}/monitor/services/${encodeURIComponent(serviceType)}/alert-definitions/${encodeURIComponent(entityId)}`, ), setMethod('PUT'), setData(payload), diff --git a/packages/api-v4/src/cloudpulse/types.ts b/packages/api-v4/src/cloudpulse/types.ts index 8e2974fb5a2..110fea9476a 100644 --- a/packages/api-v4/src/cloudpulse/types.ts +++ b/packages/api-v4/src/cloudpulse/types.ts @@ -7,8 +7,8 @@ export type CloudPulseServiceType = | 'dbaas' | 'firewall' | 'linode' - | 'nodebalancer'; - + | 'nodebalancer' + | 'objectstorage'; export type AlertClass = 'dedicated' | 'shared'; export type DimensionFilterOperatorType = | 'endswith' @@ -72,7 +72,7 @@ export interface Widgets { color: string; entity_ids: string[]; filters: Filters[]; - group_by: string[]; + group_by?: string[]; label: string; metric: string; namespace_id: number; @@ -133,7 +133,7 @@ export interface Dimension { } export interface JWETokenPayLoad { - entity_ids: number[]; + entity_ids?: number[]; } export interface JWEToken { @@ -148,9 +148,10 @@ export interface Metric { export interface CloudPulseMetricsRequest { absolute_time_duration: DateTimeWithPreset | undefined; associated_entity_region?: string; - entity_ids: number[]; + entity_ids: number[] | string[]; + entity_region?: string; filters?: Filters[]; - group_by: string[]; + group_by?: string[]; metrics: Metric[]; relative_time_duration: TimeDuration | undefined; time_granularity: TimeGranularity | undefined; @@ -375,6 +376,7 @@ export const capabilityServiceTypeMapping: Record< dbaas: 'Managed Databases', nodebalancer: 'NodeBalancers', firewall: 'Cloud Firewall', + objectstorage: 'Object Storage', }; /** @@ -389,10 +391,10 @@ export interface CloudPulseAlertsPayload { * Array of enabled system alert IDs in ACLP (Beta) mode. * Only included in Beta mode. */ - system?: number[]; + system_alerts?: number[]; /** * Array of enabled user alert IDs in ACLP (Beta) mode. * Only included in Beta mode. */ - user?: number[]; + user_alerts?: number[]; } diff --git a/packages/api-v4/src/delivery/destinations.ts b/packages/api-v4/src/delivery/destinations.ts index 7069a23288b..1dcd4d6442c 100644 --- a/packages/api-v4/src/delivery/destinations.ts +++ b/packages/api-v4/src/delivery/destinations.ts @@ -1,4 +1,7 @@ -import { destinationSchema } from '@linode/validation'; +import { + createDestinationSchema, + updateDestinationSchema, +} from '@linode/validation'; import { BETA_API_ROOT } from '../constants'; import Request, { @@ -49,7 +52,7 @@ export const getDestinations = (params?: Params, filter?: Filter) => */ export const createDestination = (data: CreateDestinationPayload) => Request( - setData(data, destinationSchema), + setData(data, createDestinationSchema), setURL(`${BETA_API_ROOT}/monitor/streams/destinations`), setMethod('POST'), ); @@ -65,7 +68,7 @@ export const updateDestination = ( data: UpdateDestinationPayload, ) => Request( - setData(data, destinationSchema), + setData(data, updateDestinationSchema), setURL( `${BETA_API_ROOT}/monitor/streams/destinations/${encodeURIComponent(destinationId)}`, ), @@ -92,7 +95,7 @@ export const deleteDestination = (destinationId: number) => */ export const verifyDestination = (data: CreateDestinationPayload) => Request( - setData(data, destinationSchema), + setData(data, createDestinationSchema), setURL(`${BETA_API_ROOT}/monitor/streams/destinations/verify`), setMethod('POST'), ); diff --git a/packages/api-v4/src/delivery/types.ts b/packages/api-v4/src/delivery/types.ts index 15b3129fdd3..b9e6fbc48f4 100644 --- a/packages/api-v4/src/delivery/types.ts +++ b/packages/api-v4/src/delivery/types.ts @@ -21,7 +21,7 @@ export interface AuditData { export interface Stream extends AuditData { destinations: Destination[]; - details: StreamDetails; + details: StreamDetailsType; id: number; label: string; primary_destination_id: number; @@ -36,6 +36,8 @@ export interface StreamDetails { is_auto_add_all_clusters_enabled?: boolean; } +export type StreamDetailsType = null | StreamDetails; + export const destinationType = { CustomHttps: 'custom_https', LinodeObjectStorage: 'linode_object_storage', @@ -103,7 +105,7 @@ interface CustomHeader { export interface CreateStreamPayload { destinations: number[]; - details: StreamDetails; + details?: StreamDetailsType; label: string; status?: StreamStatus; type: StreamType; @@ -111,23 +113,31 @@ export interface CreateStreamPayload { export interface UpdateStreamPayload { destinations: number[]; - details: StreamDetails; + details?: StreamDetailsType; label: string; status: StreamStatus; - type: StreamType; } export interface UpdateStreamPayloadWithId extends UpdateStreamPayload { id: number; } +export interface LinodeObjectStorageDetailsPayload + extends Omit { + path?: string; +} + +export type DestinationDetailsPayload = + | CustomHTTPsDetails + | LinodeObjectStorageDetailsPayload; + export interface CreateDestinationPayload { - details: CustomHTTPsDetails | LinodeObjectStorageDetails; + details: DestinationDetailsPayload; label: string; type: DestinationType; } -export type UpdateDestinationPayload = CreateDestinationPayload; +export type UpdateDestinationPayload = Omit; export interface UpdateDestinationPayloadWithId extends UpdateDestinationPayload { diff --git a/packages/api-v4/src/iam/delegation.ts b/packages/api-v4/src/iam/delegation.ts new file mode 100644 index 00000000000..dba6dcc0016 --- /dev/null +++ b/packages/api-v4/src/iam/delegation.ts @@ -0,0 +1,107 @@ +import { BETA_API_ROOT } from '../constants'; +import Request, { setData, setMethod, setParams, setURL } from '../request'; + +import type { Account } from '../account'; +import type { Token } from '../profile'; +import type { ResourcePage as Page } from '../types'; +import type { + ChildAccount, + ChildAccountWithDelegates, + GetChildAccountDelegatesParams, + GetChildAccountsIamParams, + GetDelegatedChildAccountsForUserParams, + GetMyDelegatedChildAccountsParams, + UpdateChildAccountDelegatesParams, +} from './delegation.types'; +import type { IamUserRoles } from './types'; + +export const getChildAccountsIam = ({ + params, + users, +}: GetChildAccountsIamParams) => + users + ? Request>( + setURL(`${BETA_API_ROOT}/iam/delegation/child-accounts?users=true`), + setMethod('GET'), + setParams({ ...params }), + ) + : Request>( + setURL(`${BETA_API_ROOT}/iam/delegation/child-accounts`), + setMethod('GET'), + setParams({ ...params }), + ); + +export const getDelegatedChildAccountsForUser = ({ + username, + params, +}: GetDelegatedChildAccountsForUserParams) => + Request>( + setURL( + `${BETA_API_ROOT}/iam/delegation/users/${encodeURIComponent(username)}/child-accounts`, + ), + setMethod('GET'), + setParams(params), + ); + +export const getChildAccountDelegates = ({ + euuid, + params, +}: GetChildAccountDelegatesParams) => + Request>( + setURL( + `${BETA_API_ROOT}/iam/delegation/child-accounts/${encodeURIComponent(euuid)}/users`, + ), + setMethod('GET'), + setParams(params), + ); + +export const updateChildAccountDelegates = ({ + euuid, + data, +}: UpdateChildAccountDelegatesParams) => + Request>( + setURL( + `${BETA_API_ROOT}/iam/delegation/child-accounts/${encodeURIComponent(euuid)}/users`, + ), + setMethod('PUT'), + setData(data), + ); + +export const getMyDelegatedChildAccounts = ({ + params, +}: GetMyDelegatedChildAccountsParams) => + Request>( + setURL(`${BETA_API_ROOT}/iam/delegation/profile/child-accounts`), + setMethod('GET'), + setParams(params), + ); + +export const getDelegatedChildAccount = ({ euuid }: { euuid: string }) => + Request( + setURL( + `${BETA_API_ROOT}/iam/delegation/profile/child-accounts/${encodeURIComponent(euuid)}`, + ), + setMethod('GET'), + ); + +export const generateChildAccountToken = ({ euuid }: { euuid: string }) => + Request( + setURL( + `${BETA_API_ROOT}/iam/delegation/profile/child-accounts/${encodeURIComponent(euuid)}/token`, + ), + setMethod('POST'), + setData(euuid), + ); + +export const getDefaultDelegationAccess = () => + Request( + setURL(`${BETA_API_ROOT}/iam/delegation/default-role-permissions`), + setMethod('GET'), + ); + +export const updateDefaultDelegationAccess = (data: IamUserRoles) => + Request( + setURL(`${BETA_API_ROOT}/iam/delegation/default-role-permissions`), + setMethod('PUT'), + setData(data), + ); diff --git a/packages/api-v4/src/iam/delegation.types.ts b/packages/api-v4/src/iam/delegation.types.ts new file mode 100644 index 00000000000..2eafc480b7a --- /dev/null +++ b/packages/api-v4/src/iam/delegation.types.ts @@ -0,0 +1,34 @@ +import type { Params } from 'src/types'; + +export interface ChildAccount { + company: string; + euuid: string; +} + +export interface GetChildAccountsIamParams { + params?: Params; + users?: boolean; +} + +export interface ChildAccountWithDelegates extends ChildAccount { + users: string[]; +} + +export interface GetMyDelegatedChildAccountsParams { + params?: Params; +} + +export interface GetDelegatedChildAccountsForUserParams { + params?: Params; + username: string; +} + +export interface GetChildAccountDelegatesParams { + euuid: string; + params?: Params; +} + +export interface UpdateChildAccountDelegatesParams { + data: string[]; + euuid: string; +} diff --git a/packages/api-v4/src/iam/index.ts b/packages/api-v4/src/iam/index.ts index 8442040a86a..9bc43a8eb93 100644 --- a/packages/api-v4/src/iam/index.ts +++ b/packages/api-v4/src/iam/index.ts @@ -1,3 +1,5 @@ -export * from './iam'; +export * from './delegation'; +export * from './delegation.types'; +export * from './iam'; export * from './types'; diff --git a/packages/api-v4/src/kubernetes/kubernetes.ts b/packages/api-v4/src/kubernetes/kubernetes.ts index 8b61b41caa3..18b49bd7fbc 100644 --- a/packages/api-v4/src/kubernetes/kubernetes.ts +++ b/packages/api-v4/src/kubernetes/kubernetes.ts @@ -1,6 +1,6 @@ import { createKubeClusterSchema } from '@linode/validation/lib/kubernetes.schema'; -import { API_ROOT, BETA_API_ROOT } from '../constants'; +import { BETA_API_ROOT } from '../constants'; import Request, { setData, setMethod, @@ -24,22 +24,9 @@ import type { /** * getKubernetesClusters * - * Gets a list of a user's Kubernetes clusters - */ -export const getKubernetesClusters = (params?: Params, filters?: Filter) => - Request>( - setMethod('GET'), - setParams(params), - setXFilter(filters), - setURL(`${API_ROOT}/lke/clusters`), - ); - -/** - * getKubernetesClustersBeta - * * Gets a list of a user's Kubernetes clusters from beta API */ -export const getKubernetesClustersBeta = (params?: Params, filters?: Filter) => +export const getKubernetesClusters = (params?: Params, filters?: Filter) => Request>( setMethod('GET'), setParams(params), @@ -50,49 +37,23 @@ export const getKubernetesClustersBeta = (params?: Params, filters?: Filter) => /** * getKubernetesCluster * - * Return details about a single Kubernetes cluster - */ -export const getKubernetesCluster = (clusterID: number) => - Request( - setMethod('GET'), - setURL(`${API_ROOT}/lke/clusters/${encodeURIComponent(clusterID)}`), - ); - -/** - * getKubernetesClusterBeta - * * Return details about a single Kubernetes cluster from beta API */ -export const getKubernetesClusterBeta = (clusterID: number) => +export const getKubernetesCluster = (clusterID: number) => Request( setMethod('GET'), setURL(`${BETA_API_ROOT}/lke/clusters/${encodeURIComponent(clusterID)}`), ); /** - * createKubernetesClusters - * - * Create a new cluster. - */ -export const createKubernetesCluster = (data: CreateKubeClusterPayload) => { - return Request( - setMethod('POST'), - setURL(`${API_ROOT}/lke/clusters`), - setData(data, createKubeClusterSchema), - ); -}; - -/** - * createKubernetesClustersBeta + * createKubernetesCluster * * Create a new cluster with the beta API: * 1. When the feature flag for APL is enabled and APL is set to enabled in the UI * 2. When the LKE-E feature is enabled * - * duplicated function of createKubernetesCluster - * necessary to call BETA_API_ROOT in a separate function based on feature flag */ -export const createKubernetesClusterBeta = (data: CreateKubeClusterPayload) => { +export const createKubernetesCluster = (data: CreateKubeClusterPayload) => { return Request( setMethod('POST'), setURL(`${BETA_API_ROOT}/lke/clusters`), @@ -111,7 +72,7 @@ export const updateKubernetesCluster = ( ) => Request( setMethod('PUT'), - setURL(`${API_ROOT}/lke/clusters/${encodeURIComponent(clusterID)}`), + setURL(`${BETA_API_ROOT}/lke/clusters/${encodeURIComponent(clusterID)}`), setData(data), ); @@ -123,7 +84,7 @@ export const updateKubernetesCluster = ( export const deleteKubernetesCluster = (clusterID: number) => Request<{}>( setMethod('DELETE'), - setURL(`${API_ROOT}/lke/clusters/${encodeURIComponent(clusterID)}`), + setURL(`${BETA_API_ROOT}/lke/clusters/${encodeURIComponent(clusterID)}`), ); /** @@ -138,7 +99,7 @@ export const getKubeConfig = (clusterId: number) => Request( setMethod('GET'), setURL( - `${API_ROOT}/lke/clusters/${encodeURIComponent(clusterId)}/kubeconfig`, + `${BETA_API_ROOT}/lke/clusters/${encodeURIComponent(clusterId)}/kubeconfig`, ), ); @@ -153,7 +114,7 @@ export const resetKubeConfig = (clusterId: number) => Request<{}>( setMethod('DELETE'), setURL( - `${API_ROOT}/lke/clusters/${encodeURIComponent(clusterId)}/kubeconfig`, + `${BETA_API_ROOT}/lke/clusters/${encodeURIComponent(clusterId)}/kubeconfig`, ), ); @@ -168,16 +129,16 @@ export const getKubernetesVersions = (params?: Params, filters?: Filter) => setMethod('GET'), setXFilter(filters), setParams(params), - setURL(`${API_ROOT}/lke/versions`), + setURL(`${BETA_API_ROOT}/lke/versions`), ); -/** getKubernetesTieredVersionsBeta +/** getKubernetesTieredVersions * * Returns a paginated list of available Kubernetes tiered versions from the beta API. * */ -export const getKubernetesTieredVersionsBeta = ( +export const getKubernetesTieredVersions = ( tier: string, params?: Params, filters?: Filter, @@ -198,19 +159,16 @@ export const getKubernetesTieredVersionsBeta = ( export const getKubernetesVersion = (versionID: string) => Request( setMethod('GET'), - setURL(`${API_ROOT}/lke/versions/${encodeURIComponent(versionID)}`), + setURL(`${BETA_API_ROOT}/lke/versions/${encodeURIComponent(versionID)}`), ); -/** getKubernetesTieredVersionBeta +/** getKubernetesTieredVersion * * Returns a single tiered Kubernetes version by ID from the beta API. * */ -export const getKubernetesTieredVersionBeta = ( - tier: string, - versionID: string, -) => +export const getKubernetesTieredVersion = (tier: string, versionID: string) => Request( setMethod('GET'), setURL( @@ -236,7 +194,7 @@ export const getKubernetesClusterEndpoints = ( setXFilter(filters), setParams(params), setURL( - `${API_ROOT}/lke/clusters/${encodeURIComponent(clusterID)}/api-endpoints`, + `${BETA_API_ROOT}/lke/clusters/${encodeURIComponent(clusterID)}/api-endpoints`, ), ); @@ -249,7 +207,7 @@ export const getKubernetesClusterDashboard = (clusterID: number) => Request( setMethod('GET'), setURL( - `${API_ROOT}/lke/clusters/${encodeURIComponent(clusterID)}/dashboard`, + `${BETA_API_ROOT}/lke/clusters/${encodeURIComponent(clusterID)}/dashboard`, ), ); @@ -262,19 +220,9 @@ export const getKubernetesClusterDashboard = (clusterID: number) => export const recycleClusterNodes = (clusterID: number) => Request<{}>( setMethod('POST'), - setURL(`${API_ROOT}/lke/clusters/${encodeURIComponent(clusterID)}/recycle`), - ); - -/** - * getKubernetesTypes - * - * Returns a paginated list of available Kubernetes types; used for dynamic pricing. - */ -export const getKubernetesTypes = (params?: Params) => - Request>( - setURL(`${API_ROOT}/lke/types`), - setMethod('GET'), - setParams(params), + setURL( + `${BETA_API_ROOT}/lke/clusters/${encodeURIComponent(clusterID)}/recycle`, + ), ); /** @@ -282,7 +230,7 @@ export const getKubernetesTypes = (params?: Params) => * * Returns a paginated list of available Kubernetes types from beta API; used for dynamic pricing. */ -export const getKubernetesTypesBeta = (params?: Params) => +export const getKubernetesTypes = (params?: Params) => Request>( setURL(`${BETA_API_ROOT}/lke/types`), setMethod('GET'), @@ -298,7 +246,7 @@ export const getKubernetesClusterControlPlaneACL = (clusterId: number) => Request( setMethod('GET'), setURL( - `${API_ROOT}/lke/clusters/${encodeURIComponent( + `${BETA_API_ROOT}/lke/clusters/${encodeURIComponent( clusterId, )}/control_plane_acl`, ), @@ -316,7 +264,7 @@ export const updateKubernetesClusterControlPlaneACL = ( Request( setMethod('PUT'), setURL( - `${API_ROOT}/lke/clusters/${encodeURIComponent( + `${BETA_API_ROOT}/lke/clusters/${encodeURIComponent( clusterID, )}/control_plane_acl`, ), diff --git a/packages/api-v4/src/regions/regions.ts b/packages/api-v4/src/regions/regions.ts index 19ac6b8ca49..f217d0555b8 100644 --- a/packages/api-v4/src/regions/regions.ts +++ b/packages/api-v4/src/regions/regions.ts @@ -3,7 +3,7 @@ import Request, { setMethod, setParams, setURL, setXFilter } from '../request'; import { Region } from './types'; import type { Filter, ResourcePage as Page, Params } from '../types'; -import type { RegionAvailability } from './types'; +import type { RegionAvailability, RegionVPCAvailability } from './types'; /** * getRegions @@ -67,3 +67,29 @@ export const getRegionAvailability = (regionId: string) => ), setMethod('GET'), ); + +/** + * getRegionsVPCAvailabilities + * + * Returns the availability of VPC IPv6 prefix lengths for all regions. + */ +export const getRegionsVPCAvailabilities = (params?: Params, filter?: Filter) => + Request>( + setURL(`${BETA_API_ROOT}/regions/vpc-availability`), + setMethod('GET'), + setParams(params), + setXFilter(filter), + ); + +/** + * getRegionVPCAvailability + * + * Returns the availability of VPC IPv6 prefix lengths for a specified region. + */ +export const getRegionVPCAvailability = (regionId: string) => + Request( + setURL( + `${BETA_API_ROOT}/regions/${encodeURIComponent(regionId)}/vpc-availability`, + ), + setMethod('GET'), + ); diff --git a/packages/api-v4/src/regions/types.ts b/packages/api-v4/src/regions/types.ts index 63f1fe5b4f6..4da65b28c95 100644 --- a/packages/api-v4/src/regions/types.ts +++ b/packages/api-v4/src/regions/types.ts @@ -69,6 +69,12 @@ export interface RegionAvailability { region: string; } +export interface RegionVPCAvailability { + available: boolean; // True if Region has VPC capabilities + available_ipv6_prefix_lengths: number[]; + region: string; +} + type CountryCode = keyof typeof COUNTRY_CODE_TO_CONTINENT_CODE; export type Country = Lowercase; diff --git a/packages/manager/CHANGELOG.md b/packages/manager/CHANGELOG.md index 209e79f1e83..30cbb0f1c62 100644 --- a/packages/manager/CHANGELOG.md +++ b/packages/manager/CHANGELOG.md @@ -4,6 +4,74 @@ 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-10-07] - v1.152.0 + + +### Added: + +- IAM RBAC: disable fields in the drawer ([#12892](https://github.com/linode/manager/pull/12892)) +- IAM delegation feature flag ([#12906](https://github.com/linode/manager/pull/12906)) +- Split WireGuard into separate server and client apps; add Jaeger and Cribl Marketplace apps ([#12907](https://github.com/linode/manager/pull/12907)) +- IAM RBAC: disable fields in the drawer for deleting and managing images ([#12909](https://github.com/linode/manager/pull/12909)) +- IAM Delegation: replace query with the new delegation ones ([#12913](https://github.com/linode/manager/pull/12913)) +- IAM delegation mock data ([#12914](https://github.com/linode/manager/pull/12914)) +- ConnectionDetailsRow and ConnectionDetailsHostRows components to manage connection details table content ([#12939](https://github.com/linode/manager/pull/12939)) + +### Changed: + +- UIE/RBAC LA gating for useQueryWithPermissions ([#12880](https://github.com/linode/manager/pull/12880)) +- Improve role selection UX in change role drawer ([#12901](https://github.com/linode/manager/pull/12901)) +- IAM RBAC: replace grants with usePermission hook for Firewalls ([#12902](https://github.com/linode/manager/pull/12902)) +- Getting started link on the volume details page ([#12904](https://github.com/linode/manager/pull/12904)) +- ACLP: update default `ACLP Time Range Picker Preset` to `1 hour` ([#12915](https://github.com/linode/manager/pull/12915)) +- Check Region VPC availability for IPv6 prefix lengths instead of hardcoded prefix lengths ([#12919](https://github.com/linode/manager/pull/12919)) +- IAM Delegation: remove ProxyUserTable ([#12921](https://github.com/linode/manager/pull/12921)) +- Add padding inside the ManagedDashboardCard component ([#12923](https://github.com/linode/manager/pull/12923)) +- Assorted VPC IPv4 and VPC IPv6 copy ([#12924](https://github.com/linode/manager/pull/12924)) +- IAM RBAC: replace grants with usePermission hook in Linodes ([#12932](https://github.com/linode/manager/pull/12932)) +- DBaaS - Host field in connection details table renders based on VPC configuration and host fields are synced between Details and Networking tabs ([#12939](https://github.com/linode/manager/pull/12939)) + +### Fixed: + +- IAM RBAC: fix tooltips in volumes ([#12881](https://github.com/linode/manager/pull/12881)) +- Disable `Add Metric and Add Dimension Filter` without serviceType; skip `useResources` if no supported regions in CloudPulse Alerting ([#12891](https://github.com/linode/manager/pull/12891)) +- Navigation after successful volume deletion ([#12894](https://github.com/linode/manager/pull/12894)) +- LKE create request for standard cluster can contain LKE-E-specific payload data ([#12916](https://github.com/linode/manager/pull/12916)) +- Inaccurate Upgrade Version modal copy for LKE-E clusters and overly verbose modal title ([#12922](https://github.com/linode/manager/pull/12922)) +- Use abs value for Assign User Autocomplete next fetch ([#12925](https://github.com/linode/manager/pull/12925)) +- CloudPulse-Metrics: Update `CloudPulseDashboardFilterBuilder.tsx` and `CloudPulseRegionSelect.tsx` to handle saved preference clearance for linode region filter ([#12926](https://github.com/linode/manager/pull/12926)) +- IAM: Hide IAM Beta badge in User Menu for LA ([#12933](https://github.com/linode/manager/pull/12933)) +- Always show tax id's when available irrespective of date filtering ([#12942](https://github.com/linode/manager/pull/12942)) + +### Tech Stories: + +- Refactor IAM permission/entities truncation utilities ([#12825](https://github.com/linode/manager/pull/12825)) +- Update Node.js from `20.17` to `22.19` ([#12838](https://github.com/linode/manager/pull/12838)) +- Clean up logic for toggling between kubernetes `/v4` and `/v4beta` endpoints ([#12867](https://github.com/linode/manager/pull/12867)) +- Add dependency resolution for `brace-expansion` ([#12869](https://github.com/linode/manager/pull/12869)) +- IAM - Improve type safety in `usePermissions` ([#12893](https://github.com/linode/manager/pull/12893)) +- Remove deprecated `lkeEnterprise` flag from Flags interface ([#12911](https://github.com/linode/manager/pull/12911)) + +### Tests: + +- Add tests for Linode Interface Networking table - details drawer and adding a VLAN interface ([#12842](https://github.com/linode/manager/pull/12842)) +- Fix flaky Object Storage Multicluster object upload test ([#12847](https://github.com/linode/manager/pull/12847)) +- Add LKE-E Post-LA feature flag smoke tests ([#12886](https://github.com/linode/manager/pull/12886)) +- Smoke tests for nvidia blackwell gpu plan selection ([#12917](https://github.com/linode/manager/pull/12917)) +- Update vpcCreateDrawer.setSubnetIpRange page utility for Cypress tests ([#12924](https://github.com/linode/manager/pull/12924)) + +### Upcoming Features: + +- Fix Datastream Stream/Destinations table search input focus, and empty search results layout ([#12802](https://github.com/linode/manager/pull/12802)) +- IAM RBAC: Implements IAM RBAC permissions for VPC Details page ([#12810](https://github.com/linode/manager/pull/12810)) +- Generate Destination's sample Path based on Stream Type or custom value ([#12851](https://github.com/linode/manager/pull/12851)) +- CloudPulse-Alerts: Add `useAlertsMutation.ts`, update `AlertInformationActionTable.tsx` to handle api integration for mutliple services ([#12870](https://github.com/linode/manager/pull/12870)) +- ACLP: add `Group By` option on `Global Filters` and `Widget Filters` ([#12887](https://github.com/linode/manager/pull/12887)) +- Logs Delivery fixes after devcloud release ([#12898](https://github.com/linode/manager/pull/12898)) +- CloudPulse-Metrics: Add new component at `CloudPulseEndpointsSelect.tsx` ([#12905](https://github.com/linode/manager/pull/12905)) +- ACLP-Alerting: Object Storage service onboarding for Alerts UI ([#12910](https://github.com/linode/manager/pull/12910)) +- CloudPulse-Metrics: Handle special conditions for `objectstorage` service addition, add related filters at `FilterConfig.ts`, integrate related component `CloudPulseEndpointsSelect.tsx` ([#12912](https://github.com/linode/manager/pull/12912)) + ## [2025-10-02] - v1.151.2 ### Fixed: diff --git a/packages/manager/Dockerfile b/packages/manager/Dockerfile index 8bdc220a59b..7f65e774d78 100644 --- a/packages/manager/Dockerfile +++ b/packages/manager/Dockerfile @@ -3,7 +3,7 @@ ARG IMAGE_REGISTRY=docker.io # Node.js version to use. -ARG NODE_VERSION=20.17.0 +ARG NODE_VERSION=22.19.0 # Cypress version. ARG CYPRESS_VERSION=14.3.0 @@ -41,21 +41,21 @@ CMD pnpm start:manager:ci # # Uses Cypress factory image. For more information, see: # https://github.com/cypress-io/cypress-docker-images/tree/master/factory#usage -FROM ${IMAGE_REGISTRY}/cypress/factory:5.2.1 AS e2e-build +FROM ${IMAGE_REGISTRY}/cypress/factory:6.0.1 AS e2e-build ARG CYPRESS_VERSION ARG NODE_VERSION # Add Chrome apt repo RUN apt-get update \ - && apt-get install -y wget gnupg2 \ - && wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add - \ - && echo "deb http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list \ - && apt-get update + && apt-get install -y wget gnupg2 \ + && wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | gpg --dearmor > /usr/share/keyrings/google-chrome-archive-keyring.gpg \ + && echo "deb [signed-by=/usr/share/keyrings/google-chrome-archive-keyring.gpg] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list \ + && apt-get update RUN apt-get install -y google-chrome-stable \ - && rm -rf /var/cache/apt/* \ - && rm -rf /var/lib/apt/lists* \ - && apt-get clean \ - && npm install -g cypress@${CYPRESS_VERSION} pnpm bun yarn + && rm -rf /var/cache/apt/* \ + && rm -rf /var/lib/apt/lists* \ + && apt-get clean \ + && npm install -g cypress@${CYPRESS_VERSION} pnpm bun yarn USER node WORKDIR /home/node/app diff --git a/packages/manager/cypress/component/features/IAM/truncated-list.spec.tsx b/packages/manager/cypress/component/features/IAM/truncated-list.spec.tsx new file mode 100644 index 00000000000..db669757700 --- /dev/null +++ b/packages/manager/cypress/component/features/IAM/truncated-list.spec.tsx @@ -0,0 +1,135 @@ +import { TruncatedList } from '@src/features/IAM/Shared/TruncatedList'; +import * as React from 'react'; +import { checkComponentA11y } from 'support/util/accessibility'; +import { componentTests } from 'support/util/components'; + +const mockPermissions = [ + 'list_linode_firewalls', + 'list_firewall_devices', + 'view_linode_disk', + 'list_billing_payments', + 'list_billing_invoices', + 'list_payment_methods', + 'view_billing_invoice', + 'list_invoice_items', + 'view_payment_method', + 'view_billing_payment', +]; + +const permissionList = mockPermissions.map((permission) => ( +
{permission}
+)); + +componentTests('TruncatedList', (mount) => { + describe('wide breakpoint', () => { + beforeEach(() => { + cy.viewport(1600, 600); + }); + + it('renders all list items', () => { + mount( + {permissionList} + ); + cy.findAllByTestId('permission-list-item').should( + 'have.length', + mockPermissions.length + ); + mockPermissions.forEach((permission) => { + cy.findByText(permission).should('be.visible'); + }); + }); + }); + + describe('medium breakpoint', () => { + beforeEach(() => { + cy.viewport(600, 600); + }); + + it('renders a truncated list with the correct number of items', () => { + mount( + {permissionList} + ); + cy.findAllByTestId('permission-list-item').should( + 'have.length', + mockPermissions.length + ); + mockPermissions.slice(0, 7).forEach((permission) => { + cy.findByText(permission).should('be.visible'); + }); + cy.findByText('Expand (+3)').should('be.visible').click(); + mockPermissions.forEach((permission) => { + cy.findByText(permission).should('be.visible'); + }); + cy.findByText('Hide').should('be.visible').click(); + mockPermissions.slice(0, 7).forEach((permission) => { + cy.findByText(permission).should('be.visible'); + }); + cy.get('[class*="visible-overflow-button"]').should('not.exist'); + }); + + it('allows rendering a custom overflow button', () => { + const handleClick = cy.spy().as('handleClick'); + + mount( + ( + + )} + dataTestId="permission" + > + {permissionList} + + ); + cy.findByRole('button', { name: 'Custom Overflow Button' }) + .should('be.visible') + .click(); + cy.get('@handleClick').should('have.been.called'); + }); + + it('floats the overflow button to the right with justifyOverflowButtonRight', () => { + mount( + + {permissionList} + + ); + + cy.get('[class*="visible-overflow-button"]') + .should('be.visible') + .should('have.css', 'justify-content', 'end'); + }); + }); + + describe('small breakpoint', () => { + beforeEach(() => { + cy.viewport(400, 600); + }); + + it('renders a truncated list with the correct number of items', () => { + mount( + {permissionList} + ); + cy.findAllByTestId('permission-list-item').should( + 'have.length', + mockPermissions.length + ); + mockPermissions.slice(0, 4).forEach((permission) => { + cy.findByText(permission).should('be.visible'); + }); + cy.findByText('Expand (+5)').should('be.visible').click(); + mockPermissions.forEach((permission) => { + cy.findByText(permission).should('be.visible'); + }); + cy.findByText('Hide').should('be.visible').click(); + mockPermissions.slice(0, 4).forEach((permission) => { + cy.findByText(permission).should('be.visible'); + }); + }); + }); + + describe('Accessibility checks', () => { + it('passes aXe accessibility', () => { + mount({permissionList}); + checkComponentA11y(); + }); + }); +}); 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 d77503cb1df..61c93e1f830 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 @@ -8,6 +8,7 @@ import { databaseConfigurations, mockDatabaseNodeTypes, } from 'support/constants/databases'; +import { mockTieredStandardVersions } from 'support/constants/lke'; import { mockGetUser } from 'support/intercepts/account'; import { mockGetLinodeConfigs } from 'support/intercepts/configs'; import { @@ -30,7 +31,7 @@ import { mockGetCluster, mockGetClusterPools, mockGetDashboardUrl, - mockGetKubernetesVersions, + mockGetTieredKubernetesVersions, mockRecycleAllNodes, mockUpdateCluster, } from 'support/intercepts/lke'; @@ -301,19 +302,29 @@ describe('restricted user details pages', () => { .should('be.visible') .should('be.enabled') .click(); - ['Edit', 'Manage Tags', 'Resize', 'Clone', 'Attach', 'Delete'].forEach( - (menuItem: string) => { + [ + 'Show Config', + 'Edit', + 'Manage Tags', + 'Resize', + 'Clone', + 'Attach', + 'Delete', + ].forEach((menuItem: string) => { + if (menuItem === 'Show Config') { + ui.actionMenuItem.findByTitle(menuItem).should('not.be.disabled'); + } else { ui.actionMenuItem.findByTitle(menuItem).should('be.disabled'); + // Optionally check tooltip for disabled items - if (menuItem !== 'Manage Tags') { - const tooltipMessage = `You don't have permissions to ${menuItem.toLocaleLowerCase()} this Volume. Please contact your ${ADMINISTRATOR} to request the necessary permissions.`; - ui.button - .findByAttribute('aria-label', tooltipMessage) - .trigger('mouseover'); - ui.tooltip.findByText(tooltipMessage); - } + const tooltipMessage = `You don't have permissions to ${menuItem === 'Manage Tags' ? 'edit' : menuItem.toLocaleLowerCase()} this Volume. Please contact your ${ADMINISTRATOR} to request the necessary permissions.`; + ui.button + .findByAttribute('aria-label', tooltipMessage) + .first() + .trigger('mouseover'); + ui.tooltip.findByText(tooltipMessage); } - ); + }); }); databaseConfigurations.forEach( @@ -409,8 +420,8 @@ describe('restricted user details pages', () => { it.skip("should disable action elements and buttons in the 'Kubernetes' details page", () => { // TODO: M3-9585 Not working for kubernets. Skip this test for now. - const oldVersion = '1.25'; - const newVersion = '1.26'; + const oldVersion = mockTieredStandardVersions[0].id; + const newVersion = mockTieredStandardVersions[1].id; const mockCluster = kubernetesClusterFactory.build({ k8s_version: oldVersion, @@ -433,7 +444,7 @@ describe('restricted user details pages', () => { const mockNodePools = nodePoolFactory.buildList(2); mockGetCluster(mockCluster).as('getCluster'); - mockGetKubernetesVersions([newVersion, oldVersion]).as('getVersions'); + mockGetTieredKubernetesVersions('standard', mockTieredStandardVersions); mockGetClusterPools(mockCluster.id, mockNodePools).as('getNodePools'); mockUpdateCluster(mockCluster.id, mockClusterUpdated).as('updateCluster'); mockGetDashboardUrl(mockCluster.id); @@ -451,9 +462,7 @@ describe('restricted user details pages', () => { .click(); ui.dialog - .findByTitle( - `Upgrade Kubernetes version to ${newVersion} on ${mockCluster.label}?` - ) + .findByTitle(`Upgrade Cluster ${mockCluster.label} to ${newVersion}`) .should('be.visible') .within(() => { upgradeNotes.forEach((note: string) => { 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 69a5c06847d..e2b882a866b 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 @@ -499,4 +499,4 @@ describe('Create Firewall Alert Successfully', () => { }); }); }); -}); \ No newline at end of file +}); 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 315ce8a3135..335b2ae55eb 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 @@ -42,11 +42,13 @@ const regions = [ capabilities: ['Managed Databases'], id: 'us-ord', label: 'Chicago, IL', + monitors: { alerts: ['Managed Databases'] }, }), regionFactory.build({ capabilities: ['Managed Databases'], id: 'us-east', label: 'Newark', + monitors: { alerts: ['Managed Databases'] }, }), ]; const databases: Database[] = databaseFactory @@ -267,4 +269,4 @@ describe('Integration Tests for Edit Alert', () => { ui.toast.assertMessage('Alert entities successfully updated.'); }); }); -}); \ No newline at end of file +}); 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 b81ff900f00..1539bc30bfa 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts @@ -5,6 +5,7 @@ import { dedicatedTypeFactory, linodeTypeFactory, pluralize, + regionAvailabilityFactory, regionFactory, } from '@linode/utilities'; import { @@ -21,12 +22,10 @@ import { dedicatedNodeCount, dedicatedType, latestEnterpriseTierKubernetesVersion, - latestKubernetesVersion, mockedLKEClusterTypes, mockedLKEEnterprisePrices, mockTieredEnterpriseVersions, mockTieredStandardVersions, - mockTieredVersions, nanodeNodeCount, nanodeType, } from 'support/constants/lke'; @@ -43,7 +42,6 @@ import { mockGetClusters, mockGetControlPlaneACL, mockGetDashboardUrl, - mockGetKubernetesVersions, mockGetLKEClusterTypes, mockGetTieredKubernetesVersions, } from 'support/intercepts/lke'; @@ -153,7 +151,7 @@ describe('LKE Cluster Creation', () => { exclude: ['au-mel', 'eu-west', 'id-cgk', 'br-gru'], }); const clusterLabel = randomLabel(); - const clusterVersion = '1.31'; + const clusterVersion = mockTieredStandardVersions[0].id; const mockedLKECluster = kubernetesClusterFactory.build({ label: clusterLabel, region: clusterRegion.id, @@ -184,7 +182,9 @@ describe('LKE Cluster Creation', () => { mockGetLinodeTypes(mockedLKEClusterTypes).as('getLinodeTypes'); mockGetLKEClusterTypes(mockedLKEClusterPrices).as('getLKEClusterTypes'); mockGetClusters([mockedLKECluster]).as('getClusters'); - mockGetKubernetesVersions([clusterVersion]).as('getKubernetesVersions'); + mockGetTieredKubernetesVersions('standard', mockTieredStandardVersions).as( + 'getKubernetesVersions' + ); cy.visitWithLogin('/kubernetes/clusters'); @@ -662,7 +662,10 @@ describe('LKE Cluster Creation with ACL', () => { describe('with LKE IPACL account capability', () => { beforeEach(() => { - mockGetKubernetesVersions([clusterVersion]).as('getLKEVersions'); + mockGetTieredKubernetesVersions( + 'standard', + mockTieredStandardVersions + ).as('getLKEVersions'); mockGetRegions([mockRegion]).as('getRegions'); mockGetLinodeTypes(mockLinodeTypes).as('getLinodeTypes'); mockGetRegionAvailability(mockRegion.id, []).as('getRegionAvailability'); @@ -935,10 +938,12 @@ describe('LKE Cluster Creation with ACL', () => { ).as('getAccount'); mockGetTieredKubernetesVersions('enterprise', [ latestEnterpriseTierKubernetesVersion, - ]).as('getTieredKubernetesVersions'); - mockGetKubernetesVersions([latestKubernetesVersion]).as( - 'getKubernetesVersions' - ); + ]).as('getTieredEnterpriseVersions'); + mockGetTieredKubernetesVersions( + 'standard', + mockTieredStandardVersions + ).as('getTieredStandardVersions'); + mockGetLinodeTypes(mockedLKEClusterTypes).as('getLinodeTypes'); mockGetLKEClusterTypes(mockedLKEEnterprisePrices).as( 'getLKEEnterpriseClusterTypes' @@ -969,7 +974,7 @@ describe('LKE Cluster Creation with ACL', () => { .click(); cy.url().should('endWith', '/kubernetes/create'); - cy.wait(['@getKubernetesVersions', '@getTieredKubernetesVersions']); + cy.wait(['@getTieredStandardVersions', '@getTieredEnterpriseVersions']); // Select enterprise tier. cy.get(`[data-qa-select-card-heading="LKE Enterprise"]`) @@ -1094,7 +1099,6 @@ describe('LKE Cluster Creation with ACL', () => { '@getCluster', '@getClusterPools', '@createCluster', - '@getLKEEnterpriseClusterTypes', '@getLinodeTypes', '@getApiEndpoints', '@getControlPlaneACL', @@ -1321,10 +1325,12 @@ describe('LKE Cluster Creation with LKE-E', () => { ).as('getAccount'); mockGetTieredKubernetesVersions('enterprise', [ latestEnterpriseTierKubernetesVersion, - ]).as('getTieredKubernetesVersions'); - mockGetKubernetesVersions([latestKubernetesVersion]).as( - 'getKubernetesVersions' - ); + ]).as('getEnterpriseTieredVersions'); + mockGetTieredKubernetesVersions( + 'standard', + mockTieredStandardVersions + ).as('getStandardTieredVersions'); + mockGetLinodeTypes(mockedLKEClusterTypes).as('getLinodeTypes'); mockGetLKEClusterTypes(mockedLKEEnterprisePrices).as( 'getLKEEnterpriseClusterTypes' @@ -1361,8 +1367,8 @@ describe('LKE Cluster Creation with LKE-E', () => { cy.url().should('endWith', '/kubernetes/create'); cy.wait([ - '@getKubernetesVersions', - '@getTieredKubernetesVersions', + '@getEnterpriseTieredVersions', + '@getStandardTieredVersions', '@getLinodeTypes', ]); @@ -1535,7 +1541,6 @@ describe('LKE Cluster Creation with LKE-E', () => { '@getCluster', '@getClusterPools', '@createCluster', - '@getLKEEnterpriseClusterTypes', '@getApiEndpoints', '@getControlPlaneACL', ]); @@ -1633,7 +1638,6 @@ describe('LKE cluster creation with LKE-E Post-LA', () => { capabilities: ['Kubernetes Enterprise'], }) ); - mockGetKubernetesVersions(mockTieredVersions.map((version) => version.id)); mockGetTieredKubernetesVersions('standard', mockTieredStandardVersions); mockGetTieredKubernetesVersions('enterprise', mockTieredEnterpriseVersions); }); @@ -1839,16 +1843,161 @@ describe('LKE cluster creation with LKE-E Post-LA', () => { expect(nodePools[0]).to.be.an('object'); expect(nodePools[0]['type']).to.equal(mockPlan.id); expect(nodePools[0]['count']).to.equal(3); - - // TODO M3-10590 - Uncomment and adjust according to chosen resolution. - // expect(nodePools[0]['update_strategy']).to.be.undefined; - // expect(nodePools[0]['firewall_id']).to.be.undefined; + expect(nodePools[0]['update_strategy']).to.be.undefined; + expect(nodePools[0]['firewall_id']).to.be.undefined; }); cy.url().should('endWith', `kubernetes/clusters/${mockCluster.id}/summary`); }); }); +/* + * Each test provided w/ array of 12 mock linode types. Type excluded if: + - flag enabled and id includes 'blackwell' + - enterprise tier and id includes 'gpu' + * If visible in table, rows are always enabled +*/ +describe('smoketest for Nvidia Blackwell GPUs in kubernetes/create page', () => { + const mockRegion = regionFactory.build({ + id: 'us-east', + label: 'Newark, NJ', + capabilities: [ + 'GPU Linodes', + 'Linodes', + 'Kubernetes', + 'Kubernetes Enterprise', + ], + }); + + const mockBlackwellLinodeTypes = new Array(4).fill(null).map((_, index) => + linodeTypeFactory.build({ + id: `g3-gpu-rtxpro6000-blackwell-${index + 1}`, + label: `RTX PRO 6000 Blackwell x${index + 1}`, + class: 'gpu', + }) + ); + beforeEach(() => { + mockGetAccount( + accountFactory.build({ + capabilities: ['Linodes', 'Kubernetes', 'Kubernetes Enterprise'], + }) + ); + mockGetRegions([mockRegion]).as('getRegions'); + + mockGetLinodeTypes(mockBlackwellLinodeTypes).as('getLinodeTypes'); + const mockRegionAvailability = mockBlackwellLinodeTypes.map((type) => + regionAvailabilityFactory.build({ + plan: type.label, + available: true, + region: mockRegion.id, + }) + ); + mockGetRegionAvailability(mockRegion.id, mockRegionAvailability).as( + 'getRegionAvailability' + ); + }); + + describe('standard tier', () => { + it('enabled feature flag includes blackwells', () => { + mockAppendFeatureFlags({ + kubernetesBlackwellPlans: true, + }).as('getFeatureFlags'); + cy.visitWithLogin('/kubernetes/create'); + cy.wait(['@getFeatureFlags', '@getRegions', '@getLinodeTypes']); + + ui.regionSelect.find().click(); + ui.regionSelect.find().clear(); + ui.regionSelect.find().type(`${mockRegion.label}{enter}`); + cy.wait('@getRegionAvailability'); + // Navigate to "GPU" tab + ui.tabList.findTabByTitle('GPU').scrollIntoView(); + ui.tabList.findTabByTitle('GPU').should('be.visible').click(); + + cy.findByRole('table', { + name: 'List of NVIDIA RTX PRO 6000 Blackwell Server Edition Plans', + }).within(() => { + cy.get('tbody tr') + .should('have.length', 4) + .each((row, index) => { + cy.wrap(row).within(() => { + cy.get('td') + .eq(0) + .within(() => { + cy.findByText(mockBlackwellLinodeTypes[index].label).should( + 'be.visible' + ); + }); + ui.button + .findByTitle('Configure Pool') + .should('be.visible') + .should('be.enabled'); + }); + }); + }); + }); + + it('disabled feature flag excludes blackwells', () => { + mockAppendFeatureFlags({ + kubernetesBlackwellPlans: false, + }).as('getFeatureFlags'); + + cy.visitWithLogin('/kubernetes/create'); + cy.wait(['@getFeatureFlags', '@getRegions', '@getLinodeTypes']); + + ui.regionSelect.find().click(); + ui.regionSelect.find().clear(); + ui.regionSelect.find().type(`${mockRegion.label}{enter}`); + cy.wait('@getRegionAvailability'); + // Navigate to "GPU" tab + // "GPU" tab hidden + ui.tabList.findTabByTitle('GPU').should('not.exist'); + }); + }); + describe('enterprise tier hides GPU tab', () => { + beforeEach(() => { + // necessary to prevent crash after selecting Enterprise button + mockGetTieredKubernetesVersions('enterprise', [ + latestEnterpriseTierKubernetesVersion, + ]).as('getEnterpriseTieredVersions'); + }); + it('enabled feature flag', () => { + mockAppendFeatureFlags({ + kubernetesBlackwellPlans: true, + }).as('getFeatureFlags'); + + cy.visitWithLogin('/kubernetes/create'); + cy.wait(['@getFeatureFlags', '@getRegions', '@getLinodeTypes']); + + cy.findByText('LKE Enterprise').click(); + cy.wait(['@getEnterpriseTieredVersions']); + ui.regionSelect.find().click(); + ui.regionSelect.find().clear(); + ui.regionSelect.find().type(`${mockRegion.label}{enter}`); + cy.wait('@getRegionAvailability'); + // "GPU" tab hidden + ui.tabList.findTabByTitle('GPU').should('not.exist'); + }); + + it('disabled feature flag', () => { + mockAppendFeatureFlags({ + kubernetesBlackwellPlans: false, + }).as('getFeatureFlags'); + + cy.visitWithLogin('/kubernetes/create'); + cy.wait(['@getFeatureFlags', '@getRegions', '@getLinodeTypes']); + + ui.regionSelect.find().click(); + ui.regionSelect.find().clear(); + ui.regionSelect.find().type(`${mockRegion.label}{enter}`); + cy.findByText('LKE Enterprise').click(); + cy.wait(['@getEnterpriseTieredVersions']); + 2; + // "GPU" tab hidden + ui.tabList.findTabByTitle('GPU').should('not.exist'); + }); + }); +}); + /** * Returns each plan in an array which is similar to the given plan. * 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 index f4a4737a056..32d93f7b6e7 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/lke-enterprise-create.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-enterprise-create.spec.ts @@ -6,12 +6,10 @@ import { linodeTypeFactory, regionFactory } from '@linode/utilities'; import { clusterPlans, latestEnterpriseTierKubernetesVersion, - latestKubernetesVersion, mockedLKEClusterTypes, mockedLKEEnterprisePrices, mockTieredEnterpriseVersions, mockTieredStandardVersions, - mockTieredVersions, } from 'support/constants/lke'; import { mockGetAccount } from 'support/intercepts/account'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; @@ -21,7 +19,6 @@ import { mockCreateCluster, mockCreateClusterError, mockGetCluster, - mockGetKubernetesVersions, mockGetLKEClusterTypes, mockGetTieredKubernetesVersions, } from 'support/intercepts/lke'; @@ -106,9 +103,9 @@ describe('LKE Cluster Creation with LKE-E', () => { mockGetTieredKubernetesVersions('enterprise', [ latestEnterpriseTierKubernetesVersion, - ]).as('getTieredKubernetesVersions'); - mockGetKubernetesVersions([latestKubernetesVersion]).as( - 'getKubernetesVersions' + ]).as('getEnterpriseTieredVersions'); + mockGetTieredKubernetesVersions('standard', mockTieredStandardVersions).as( + 'getStandardTieredVersions' ); mockGetLinodeTypes(mockedLKEClusterTypes).as('getLinodeTypes'); @@ -127,11 +124,7 @@ describe('LKE Cluster Creation with LKE-E', () => { ui.button.findByTitle('Create Cluster').click(); cy.url().should('endWith', '/kubernetes/create'); - cy.wait([ - '@getKubernetesVersions', - '@getTieredKubernetesVersions', - '@getLinodeTypes', - ]); + cy.wait(['@getLinodeTypes']); }); describe('LKE-E Phase 2 Networking Configurations', () => { @@ -499,7 +492,6 @@ describe('LKE Enterprise cluster creation with LKE-E Post-LA', () => { capabilities: ['Kubernetes Enterprise'], }) ); - mockGetKubernetesVersions(mockTieredVersions.map((version) => version.id)); mockGetTieredKubernetesVersions('standard', mockTieredStandardVersions); mockGetTieredKubernetesVersions('enterprise', mockTieredEnterpriseVersions); }); 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 index a331de55b59..9607636fb5a 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/lke-enterprise-read.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-enterprise-read.spec.ts @@ -16,7 +16,7 @@ import { import { mockGetCluster, mockGetClusterPools, - mockGetKubernetesVersions, + mockGetTieredKubernetesVersions, } from 'support/intercepts/lke'; import { mockGetProfile } from 'support/intercepts/profile'; import { mockGetVPC } from 'support/intercepts/vpc'; @@ -138,7 +138,7 @@ describe('LKE-E Cluster Summary - VPC Section', () => { */ it('shows linked VPC in summary for cluster with a VPC', () => { mockGetCluster(mockClusterWithVPC).as('getCluster'); - mockGetKubernetesVersions().as('getVersions'); + mockGetTieredKubernetesVersions('enterprise').as('getTieredVersions'); mockGetClusterPools(mockClusterWithVPC.id, []).as('getNodePools'); mockGetVPC(mockVPC).as('getVPC'); mockGetProfile(mockProfile).as('getProfile'); @@ -147,7 +147,7 @@ describe('LKE-E Cluster Summary - VPC Section', () => { cy.wait([ '@getCluster', '@getNodePools', - '@getVersions', + '@getTieredVersions', '@getVPC', '@getProfile', ]); @@ -174,14 +174,19 @@ describe('LKE-E Cluster Summary - VPC Section', () => { */ it('does not show linked VPC in summary when cluster does not specify a VPC', () => { mockGetCluster(mockClusterWithoutVPC).as('getCluster'); - mockGetKubernetesVersions().as('getVersions'); + mockGetTieredKubernetesVersions('enterprise').as('getTieredVersions'); mockGetClusterPools(mockClusterWithoutVPC.id, []).as('getNodePools'); mockGetProfile(mockProfile).as('getProfile'); cy.visitWithLogin( `/kubernetes/clusters/${mockClusterWithoutVPC.id}/summary` ); - cy.wait(['@getCluster', '@getNodePools', '@getVersions', '@getProfile']); + cy.wait([ + '@getCluster', + '@getNodePools', + '@getTieredVersions', + '@getProfile', + ]); // Confirm that no VPC label or link is shown in the summary section cy.get('[data-qa-kube-entity-footer]').within(() => { @@ -218,7 +223,7 @@ describe('LKE-E Node Pools', () => { ).as('getAccount'); mockGetCluster(mockClusterWithVPC).as('getCluster'); - mockGetKubernetesVersions().as('getVersions'); + mockGetTieredKubernetesVersions('enterprise').as('getTieredVersions'); mockGetClusterPools(mockClusterWithVPC.id, mockNodePools).as( 'getNodePools' ); @@ -229,7 +234,12 @@ describe('LKE-E Node Pools', () => { ); cy.visitWithLogin(`/kubernetes/clusters/${mockClusterWithVPC.id}/summary`); - cy.wait(['@getCluster', '@getNodePools', '@getVersions', '@getProfile']); + cy.wait([ + '@getCluster', + '@getNodePools', + '@getTieredVersions', + '@getProfile', + ]); // Confirm VPC IP columns are present in the table header cy.get('[aria-label="List of Your Cluster Nodes"] thead').within(() => { diff --git a/packages/manager/cypress/e2e/core/kubernetes/lke-landing-page.spec.ts b/packages/manager/cypress/e2e/core/kubernetes/lke-landing-page.spec.ts index 97b14b8b6f5..e27d00a5da1 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/lke-landing-page.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-landing-page.spec.ts @@ -6,7 +6,6 @@ import { mockGetClusterPools, mockGetClusters, mockGetKubeconfig, - mockGetKubernetesVersions, mockGetTieredKubernetesVersions, mockRecycleAllNodes, mockUpdateCluster, @@ -19,6 +18,8 @@ import { getRegionById } from 'support/util/regions'; import { accountFactory, kubernetesClusterFactory, + kubernetesEnterpriseTierVersionFactory, + kubernetesStandardTierVersionFactory, nodePoolFactory, } from 'src/factories'; @@ -187,19 +188,22 @@ describe('LKE landing page', () => { }); it('does not show an Upgrade chip when there is no new kubernetes standard version', () => { - const oldVersion = '1.25'; - const newVersion = '1.26'; + const mockStandardTierVersions = + kubernetesStandardTierVersionFactory.buildList(2); + const newVersion = mockStandardTierVersions[1].id; const cluster = kubernetesClusterFactory.build({ k8s_version: newVersion, }); mockGetClusters([cluster]).as('getClusters'); - mockGetKubernetesVersions([newVersion, oldVersion]).as('getVersions'); + mockGetTieredKubernetesVersions('standard', mockStandardTierVersions).as( + 'getTieredVersions' + ); cy.visitWithLogin(`/kubernetes/clusters`); - cy.wait(['@getClusters', '@getVersions']); + cy.wait(['@getClusters', '@getTieredVersions']); cy.findByText(newVersion).should('be.visible'); @@ -207,8 +211,9 @@ describe('LKE landing page', () => { }); it('does not show an Upgrade chip when there is no new kubernetes enterprise version', () => { - const oldVersion = '1.31.1+lke1'; - const newVersion = '1.32.1+lke2'; + const mockEnterpriseTierVersions = + kubernetesEnterpriseTierVersionFactory.buildList(2); + const newVersion = mockEnterpriseTierVersions[1].id; mockGetAccount( accountFactory.build({ @@ -227,10 +232,10 @@ describe('LKE landing page', () => { }); mockGetClusters([cluster]).as('getClusters'); - mockGetTieredKubernetesVersions('enterprise', [ - { id: newVersion, tier: 'enterprise' }, - { id: oldVersion, tier: 'enterprise' }, - ]).as('getTieredVersions'); + mockGetTieredKubernetesVersions( + 'enterprise', + mockEnterpriseTierVersions + ).as('getTieredVersions'); cy.visitWithLogin(`/kubernetes/clusters`); @@ -242,8 +247,10 @@ describe('LKE landing page', () => { }); it('can upgrade the standard kubernetes version from the landing page', () => { - const oldVersion = '1.25'; - const newVersion = '1.26'; + const mockStandardTierVersions = + kubernetesStandardTierVersionFactory.buildList(2); + const oldVersion = mockStandardTierVersions[0].id; + const newVersion = mockStandardTierVersions[1].id; const cluster = kubernetesClusterFactory.build({ k8s_version: oldVersion, @@ -254,13 +261,15 @@ describe('LKE landing page', () => { mockGetCluster(cluster).as('getCluster'); mockGetClusters([cluster]).as('getClusters'); - mockGetKubernetesVersions([newVersion, oldVersion]).as('getVersions'); + mockGetTieredKubernetesVersions('standard', mockStandardTierVersions).as( + 'getTieredVersions' + ); mockUpdateCluster(cluster.id, updatedCluster).as('updateCluster'); mockRecycleAllNodes(cluster.id).as('recycleAllNodes'); cy.visitWithLogin(`/kubernetes/clusters`); - cy.wait(['@getClusters', '@getVersions']); + cy.wait(['@getClusters', '@getTieredVersions']); cy.findByText(oldVersion).should('be.visible'); @@ -269,9 +278,7 @@ describe('LKE landing page', () => { cy.wait(['@getCluster']); ui.dialog - .findByTitle( - `Upgrade Kubernetes version to ${newVersion} on ${cluster.label}?` - ) + .findByTitle(`Upgrade Cluster ${cluster.label} to ${newVersion}`) .should('be.visible'); mockGetClusters([updatedCluster]).as('getClusters'); @@ -300,8 +307,11 @@ describe('LKE landing page', () => { }); it('can upgrade the enterprise kubernetes version from the landing page', () => { - const oldVersion = '1.31.1+lke1'; - const newVersion = '1.32.1+lke2'; + const mockEnterpriseTierVersions = + kubernetesEnterpriseTierVersionFactory.buildList(2); + const oldVersion = mockEnterpriseTierVersions[0].id; + + const newVersion = mockEnterpriseTierVersions[1].id; mockGetAccount( accountFactory.build({ @@ -323,10 +333,10 @@ describe('LKE landing page', () => { mockGetCluster(cluster).as('getCluster'); mockGetClusters([cluster]).as('getClusters'); - mockGetTieredKubernetesVersions('enterprise', [ - { id: newVersion, tier: 'enterprise' }, - { id: oldVersion, tier: 'enterprise' }, - ]).as('getTieredVersions'); + mockGetTieredKubernetesVersions( + 'enterprise', + mockEnterpriseTierVersions + ).as('getTieredVersions'); mockUpdateCluster(cluster.id, updatedCluster).as('updateCluster'); mockRecycleAllNodes(cluster.id).as('recycleAllNodes'); @@ -341,9 +351,7 @@ describe('LKE landing page', () => { cy.wait(['@getCluster']); ui.dialog - .findByTitle( - `Upgrade Kubernetes version to ${newVersion} on ${cluster.label}?` - ) + .findByTitle(`Upgrade Cluster ${cluster.label} to ${newVersion}`) .should('be.visible'); mockGetClusters([updatedCluster]).as('getClusters'); 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 index 0791247057c..af91ff19658 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/lke-standard-read.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-standard-read.spec.ts @@ -9,7 +9,7 @@ import { mockGetLinodes } from 'support/intercepts/linodes'; import { mockGetCluster, mockGetClusterPools, - mockGetKubernetesVersions, + mockGetTieredKubernetesVersions, } from 'support/intercepts/lke'; import { mockGetProfile } from 'support/intercepts/profile'; @@ -65,12 +65,17 @@ describe('LKE Cluster Summary', () => { ).as('getAccount'); mockGetCluster(mockCluster).as('getCluster'); - mockGetKubernetesVersions().as('getVersions'); + mockGetTieredKubernetesVersions('standard').as('getTieredVersions'); mockGetClusterPools(mockCluster.id, []).as('getNodePools'); mockGetProfile(mockProfile).as('getProfile'); cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}/summary`); - cy.wait(['@getCluster', '@getNodePools', '@getVersions', '@getProfile']); + cy.wait([ + '@getCluster', + '@getNodePools', + '@getTieredVersions', + '@getProfile', + ]); // Confirm that no VPC label or link is shown in the summary section cy.get('[data-qa-kube-entity-footer]').within(() => { @@ -104,12 +109,17 @@ describe('LKE Node Pools', () => { mockGetCluster(mockCluster).as('getCluster'); mockGetClusterPools(mockCluster.id, mockNodePools).as('getNodePools'); - mockGetKubernetesVersions().as('getVersions'); + mockGetTieredKubernetesVersions('standard').as('getTieredVersions'); mockGetProfile(profileFactory.build()).as('getProfile'); mockGetLinodes(mockLinodes).as('getLinodes'); cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}/summary`); - cy.wait(['@getCluster', '@getNodePools', '@getVersions', '@getProfile']); + cy.wait([ + '@getCluster', + '@getNodePools', + '@getTieredVersions', + '@getProfile', + ]); // Confirm VPC IP columns are not present in the node table cy.get('[aria-label="List of Your Cluster Nodes"] thead').within(() => { diff --git a/packages/manager/cypress/e2e/core/kubernetes/lke-summary-page.spec.ts b/packages/manager/cypress/e2e/core/kubernetes/lke-summary-page.spec.ts index a840a3f6272..f57449815fb 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/lke-summary-page.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-summary-page.spec.ts @@ -7,7 +7,7 @@ import { mockGetControlPlaneACL, mockGetDashboardUrl, mockGetKubeconfig, - mockGetKubernetesVersions, + mockGetTieredKubernetesVersions, mockUpdateCluster, } from 'support/intercepts/lke'; import { ui } from 'support/ui'; @@ -99,7 +99,7 @@ describe('LKE summary page', () => { }; mockGetCluster(mockCluster).as('getCluster'); - mockGetKubernetesVersions().as('getVersions'); + mockGetTieredKubernetesVersions('standard').as('getTieredVersions'); mockGetClusterPools(mockCluster.id, mockNodePools).as('getNodePools'); mockGetApiEndpoints(mockCluster.id).as('getApiEndpoints'); mockGetDashboardUrl(mockCluster.id).as('getDashboardUrl'); @@ -110,7 +110,7 @@ describe('LKE summary page', () => { cy.wait([ '@getCluster', '@getNodePools', - '@getVersions', + '@getTieredVersions', '@getApiEndpoints', '@getDashboardUrl', ]); @@ -162,7 +162,7 @@ describe('LKE summary page', () => { }; mockGetCluster(mockCluster).as('getCluster'); - mockGetKubernetesVersions().as('getVersions'); + mockGetTieredKubernetesVersions('standard').as('getTieredVersions'); mockGetClusterPools(mockCluster.id, mockNodePools).as('getNodePools'); mockGetApiEndpoints(mockCluster.id).as('getApiEndpoints'); mockGetDashboardUrl(mockCluster.id).as('getDashboardUrl'); @@ -173,7 +173,7 @@ describe('LKE summary page', () => { cy.wait([ '@getCluster', '@getNodePools', - '@getVersions', + '@getTieredVersions', '@getApiEndpoints', '@getDashboardUrl', ]); @@ -232,7 +232,7 @@ describe('LKE summary page', () => { }; mockGetCluster(mockCluster).as('getCluster'); - mockGetKubernetesVersions().as('getVersions'); + mockGetTieredKubernetesVersions('standard').as('getTieredVersions'); mockGetClusterPools(mockCluster.id, mockNodePools).as('getNodePools'); mockGetApiEndpoints(mockCluster.id).as('getApiEndpoints'); mockGetDashboardUrl(mockCluster.id).as('getDashboardUrl'); @@ -243,7 +243,7 @@ describe('LKE summary page', () => { cy.wait([ '@getCluster', '@getNodePools', - '@getVersions', + '@getTieredVersions', '@getApiEndpoints', '@getDashboardUrl', ]); @@ -287,7 +287,7 @@ describe('LKE summary page', () => { }; mockGetCluster(mockCluster).as('getCluster'); - mockGetKubernetesVersions().as('getVersions'); + mockGetTieredKubernetesVersions('standard').as('getTieredVersions'); mockGetClusterPools(mockCluster.id, mockNodePools).as('getNodePools'); mockGetApiEndpoints(mockCluster.id).as('getApiEndpoints'); mockGetDashboardUrl(mockCluster.id).as('getDashboardUrl'); @@ -298,7 +298,7 @@ describe('LKE summary page', () => { cy.wait([ '@getCluster', '@getNodePools', - '@getVersions', + '@getTieredVersions', '@getApiEndpoints', '@getDashboardUrl', ]); 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 3720db12603..001dae86248 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/lke-update.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-update.spec.ts @@ -23,7 +23,6 @@ import { mockGetControlPlaneACL, mockGetControlPlaneACLError, mockGetDashboardUrl, - mockGetKubernetesVersions, mockGetTieredKubernetesVersions, mockRecycleAllNodes, mockRecycleNode, @@ -48,6 +47,7 @@ import { kubernetesClusterFactory, kubernetesControlPlaneACLFactory, kubernetesControlPlaneACLOptionsFactory, + kubernetesStandardTierVersionFactory, nodePoolFactory, } from 'src/factories'; import { extendType } from 'src/utilities/extendType'; @@ -97,13 +97,13 @@ describe('LKE cluster updates', () => { mockGetCluster(mockCluster).as('getCluster'); mockGetClusterPools(mockCluster.id, mockNodePools).as('getNodePools'); - mockGetKubernetesVersions().as('getVersions'); + mockGetTieredKubernetesVersions('standard').as('getTieredVersions'); mockUpdateCluster(mockCluster.id, mockClusterWithHA).as('updateCluster'); mockGetDashboardUrl(mockCluster.id); mockGetApiEndpoints(mockCluster.id); cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`); - cy.wait(['@getCluster', '@getNodePools', '@getVersions']); + cy.wait(['@getCluster', '@getNodePools', '@getTieredVersions']); // Initiate high availability upgrade and agree to changes. ui.button @@ -145,8 +145,10 @@ describe('LKE cluster updates', () => { * - Confirms that Kubernetes upgrade prompt is hidden when up-to-date. */ it('can upgrade standard kubernetes version from the details page', () => { - const oldVersion = '1.25'; - const newVersion = '1.26'; + const mockTieredStandardVersions = + kubernetesStandardTierVersionFactory.buildList(2); + const oldVersion = mockTieredStandardVersions[0].id; + const newVersion = mockTieredStandardVersions[1].id; const mockCluster = kubernetesClusterFactory.build({ k8s_version: oldVersion, @@ -158,7 +160,7 @@ describe('LKE cluster updates', () => { k8s_version: newVersion, }; - const upgradePrompt = 'A new version of Kubernetes is available (1.26).'; + const upgradePrompt = `A new version of Kubernetes is available (${newVersion}).`; const upgradeNotes = [ 'This upgrades the control plane on your cluster', @@ -169,14 +171,17 @@ describe('LKE cluster updates', () => { ]; mockGetCluster(mockCluster).as('getCluster'); - mockGetKubernetesVersions([newVersion, oldVersion]).as('getVersions'); + mockGetTieredKubernetesVersions( + 'standard', + mockTieredStandardVersions + ).as('getTieredVersions'); mockGetClusterPools(mockCluster.id, mockNodePools).as('getNodePools'); mockUpdateCluster(mockCluster.id, mockClusterUpdated).as('updateCluster'); mockGetDashboardUrl(mockCluster.id); mockGetApiEndpoints(mockCluster.id); cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`); - cy.wait(['@getCluster', '@getNodePools', '@getVersions']); + cy.wait(['@getCluster', '@getNodePools', '@getTieredVersions']); // Confirm that upgrade prompt is shown. cy.findByText(upgradePrompt).should('be.visible'); @@ -187,9 +192,7 @@ describe('LKE cluster updates', () => { .click(); ui.dialog - .findByTitle( - `Upgrade Kubernetes version to ${newVersion} on ${mockCluster.label}?` - ) + .findByTitle(`Upgrade Cluster ${mockCluster.label} to ${newVersion}`) .should('be.visible') .within(() => { upgradeNotes.forEach((note: string) => { @@ -293,7 +296,7 @@ describe('LKE cluster updates', () => { const upgradeNotes = [ 'This upgrades the control plane on your cluster', - 'Worker nodes within each node pool can then be upgraded separately.', + 'Existing worker nodes are updated automatically or manually, depending on the update strategy defined for each node pool.', // Confirm that the old version and new version are both shown. oldVersion, newVersion, @@ -327,9 +330,7 @@ describe('LKE cluster updates', () => { .click(); ui.dialog - .findByTitle( - `Upgrade Kubernetes version to ${newVersion} on ${mockCluster.label}?` - ) + .findByTitle(`Upgrade Cluster ${mockCluster.label} to ${newVersion}`) .should('be.visible') .within(() => { upgradeNotes.forEach((note: string) => { @@ -387,12 +388,17 @@ describe('LKE cluster updates', () => { mockGetCluster(mockCluster).as('getCluster'); mockGetClusterPools(mockCluster.id, [mockNodePool]).as('getNodePools'); mockGetLinodes([mockLinode]).as('getLinodes'); - mockGetKubernetesVersions().as('getVersions'); + mockGetTieredKubernetesVersions('standard').as('getTieredVersions'); mockGetDashboardUrl(mockCluster.id); mockGetApiEndpoints(mockCluster.id); cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`); - cy.wait(['@getCluster', '@getNodePools', '@getLinodes', '@getVersions']); + cy.wait([ + '@getCluster', + '@getNodePools', + '@getLinodes', + '@getTieredVersions', + ]); // Recycle individual node. ui.button @@ -525,7 +531,7 @@ describe('LKE cluster updates', () => { mockGetCluster(mockCluster).as('getCluster'); mockGetClusterPools(mockCluster.id, [mockNodePool]).as('getNodePools'); - mockGetKubernetesVersions().as('getVersions'); + mockGetTieredKubernetesVersions('standard').as('getTieredVersions'); mockGetDashboardUrl(mockCluster.id); mockGetApiEndpoints(mockCluster.id); mockGetAccount( @@ -539,7 +545,12 @@ describe('LKE cluster updates', () => { }); cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`); - cy.wait(['@getAccount', '@getCluster', '@getNodePools', '@getVersions']); + cy.wait([ + '@getAccount', + '@getCluster', + '@getNodePools', + '@getTieredVersions', + ]); // Click "Autoscale Pool", enable autoscaling, and set min and max values. mockUpdateNodePool(mockCluster.id, mockNodePoolAutoscale).as( @@ -677,7 +688,7 @@ describe('LKE cluster updates', () => { mockGetCluster(mockCluster).as('getCluster'); mockGetClusterPools(mockCluster.id, [mockNodePool]).as('getNodePools'); - mockGetKubernetesVersions().as('getVersions'); + mockGetTieredKubernetesVersions('enterprise').as('getTieredVersions'); mockGetDashboardUrl(mockCluster.id); mockGetApiEndpoints(mockCluster.id); mockGetAccount( @@ -691,7 +702,12 @@ describe('LKE cluster updates', () => { }); cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`); - cy.wait(['@getAccount', '@getCluster', '@getNodePools', '@getVersions']); + cy.wait([ + '@getAccount', + '@getCluster', + '@getNodePools', + '@getTieredVersions', + ]); // Click "Autoscale Pool", enable autoscaling, and set min and max values. mockUpdateNodePool(mockCluster.id, mockNodePoolAutoscale).as( @@ -801,12 +817,17 @@ describe('LKE cluster updates', () => { 'getNodePools' ); mockGetLinodes(mockLinodes).as('getLinodes'); - mockGetKubernetesVersions().as('getVersions'); + mockGetTieredKubernetesVersions('standard').as('getTieredVersions'); mockGetDashboardUrl(mockCluster.id); mockGetApiEndpoints(mockCluster.id); cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`); - cy.wait(['@getCluster', '@getNodePools', '@getLinodes', '@getVersions']); + cy.wait([ + '@getCluster', + '@getNodePools', + '@getLinodes', + '@getTieredVersions', + ]); // Confirm that nodes are listed with correct details. mockNodePoolInitial.nodes.forEach((node: PoolNodeResponse) => { @@ -945,7 +966,7 @@ describe('LKE cluster updates', () => { mockGetCluster(mockCluster).as('getCluster'); mockGetClusterPools(mockCluster.id, mockNodePools).as('getNodePools'); - mockGetKubernetesVersions().as('getVersions'); + mockGetTieredKubernetesVersions('standard').as('getTieredVersions'); mockResetKubeconfig(mockCluster.id).as('resetKubeconfig'); mockGetDashboardUrl(mockCluster.id); mockGetApiEndpoints(mockCluster.id); @@ -957,7 +978,7 @@ describe('LKE cluster updates', () => { ]; cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`); - cy.wait(['@getCluster', '@getNodePools', '@getVersions']); + cy.wait(['@getCluster', '@getNodePools', '@getTieredVersions']); // Click "Reset" button, proceed through confirmation dialog. cy.findByText('Reset').should('be.visible').click({ force: true }); @@ -1088,7 +1109,7 @@ describe('LKE cluster updates', () => { mockGetCluster(mockCluster).as('getCluster'); mockGetClusterPools(mockCluster.id, [mockNodePool]).as('getNodePools'); - mockGetKubernetesVersions().as('getVersions'); + mockGetTieredKubernetesVersions('standard').as('getTieredVersions'); mockAddNodePool(mockCluster.id, mockNewNodePool).as('addNodePool'); mockDeleteNodePool(mockCluster.id, mockNewNodePool.id).as( 'deleteNodePool' @@ -1097,7 +1118,12 @@ describe('LKE cluster updates', () => { mockGetApiEndpoints(mockCluster.id); cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`); - cy.wait(['@getRegions', '@getCluster', '@getNodePools', '@getVersions']); + cy.wait([ + '@getRegions', + '@getCluster', + '@getNodePools', + '@getTieredVersions', + ]); // Assert that initial node pool is shown on the page. cy.findByText('Dedicated 8 GB', { selector: 'h3' }).should('be.visible'); @@ -1210,12 +1236,12 @@ describe('LKE cluster updates', () => { }); mockGetCluster(mockCluster).as('getCluster'); - mockGetKubernetesVersions().as('getVersions'); + mockGetTieredKubernetesVersions('standard').as('getTieredVersions'); mockGetClusterPools(mockCluster.id, mockNodePools).as('getNodePools'); mockUpdateCluster(mockCluster.id, mockNewCluster).as('updateCluster'); cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}/summary`); - cy.wait(['@getCluster', '@getNodePools', '@getVersions']); + cy.wait(['@getCluster', '@getNodePools', '@getTieredVersions']); // LKE clusters can be renamed by clicking on the cluster's name in the breadcrumbs towards the top of the page. cy.get('[data-testid="editable-text"] > [data-testid="button"]').click(); @@ -1246,14 +1272,14 @@ describe('LKE cluster updates', () => { const mockErrorMessage = 'API request fails'; mockGetCluster(mockCluster).as('getCluster'); - mockGetKubernetesVersions().as('getVersions'); + mockGetTieredKubernetesVersions('standard').as('getTieredVersions'); mockGetClusterPools(mockCluster.id, mockNodePools).as('getNodePools'); mockUpdateClusterError(mockCluster.id, mockErrorMessage).as( 'updateClusterError' ); cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}/summary`); - cy.wait(['@getCluster', '@getNodePools', '@getVersions']); + cy.wait(['@getCluster', '@getNodePools', '@getTieredVersions']); // LKE cluster can be renamed by clicking on the cluster's name in the breadcrumbs towards the top of the page. cy.get('[data-testid="editable-text"] > [data-testid="button"]').click(); @@ -1308,7 +1334,7 @@ describe('LKE cluster updates', () => { mockGetClusterPools(mockCluster.id, [mockNodePoolNoTags]).as( 'getNodePoolsNoTags' ); - mockGetKubernetesVersions().as('getVersions'); + mockGetTieredKubernetesVersions('standard').as('getTieredVersions'); mockGetControlPlaneACL(mockCluster.id, { acl: { enabled: false } }).as( 'getControlPlaneAcl' ); @@ -1320,7 +1346,7 @@ describe('LKE cluster updates', () => { cy.wait([ '@getCluster', '@getNodePoolsNoTags', - '@getVersions', + '@getTieredVersions', '@getType', '@getControlPlaneAcl', ]); @@ -1425,7 +1451,7 @@ describe('LKE cluster updates', () => { mockGetClusterPools(mockCluster.id, [mockNodePoolInitial]).as( 'getNodePools' ); - mockGetKubernetesVersions().as('getVersions'); + mockGetTieredKubernetesVersions('standard').as('getTieredVersions'); mockGetControlPlaneACL(mockCluster.id, { acl: { enabled: false } }).as( 'getControlPlaneAcl' ); @@ -1446,7 +1472,7 @@ describe('LKE cluster updates', () => { cy.wait([ '@getCluster', '@getNodePools', - '@getVersions', + '@getTieredVersions', '@getType', '@getControlPlaneAcl', ]); @@ -1589,7 +1615,7 @@ describe('LKE cluster updates', () => { cy.wait([ '@getCluster', '@getNodePools', - '@getVersions', + '@getTieredVersions', '@getType', '@getControlPlaneAcl', ]); @@ -1771,7 +1797,7 @@ describe('LKE cluster updates', () => { cy.wait([ '@getCluster', '@getNodePools', - '@getVersions', + '@getTieredVersions', '@getType', '@getControlPlaneAcl', ]); @@ -2238,7 +2264,7 @@ describe('LKE cluster updates', () => { ); mockGetLinodes(mockLinodes).as('getLinodes'); mockGetLinodeType(mockPlanType).as('getLinodeType'); - mockGetKubernetesVersions().as('getVersions'); + mockGetTieredKubernetesVersions('standard').as('getTieredVersions'); mockGetDashboardUrl(mockCluster.id); mockGetApiEndpoints(mockCluster.id); @@ -2248,7 +2274,7 @@ describe('LKE cluster updates', () => { '@getCluster', '@getNodePools', '@getLinodes', - '@getVersions', + '@getTieredVersions', '@getLinodeType', ]); @@ -2378,7 +2404,7 @@ describe('LKE cluster updates', () => { mockGetCluster(mockCluster).as('getCluster'); mockGetClusterPools(mockCluster.id, [mockNodePool]).as('getNodePools'); - mockGetKubernetesVersions().as('getVersions'); + mockGetTieredKubernetesVersions('standard').as('getTieredVersions'); mockAddNodePool(mockCluster.id, mockNewNodePool).as('addNodePool'); mockGetLinodeType(mockPlanType).as('getLinodeType'); mockGetLinodeTypes(dcPricingMockLinodeTypes); @@ -2390,7 +2416,7 @@ describe('LKE cluster updates', () => { '@getRegions', '@getCluster', '@getNodePools', - '@getVersions', + '@getTieredVersions', '@getLinodeType', ]); @@ -2506,7 +2532,7 @@ describe('LKE cluster updates', () => { ); mockGetLinodes(mockLinodes).as('getLinodes'); mockGetLinodeType(mockPlanType).as('getLinodeType'); - mockGetKubernetesVersions().as('getVersions'); + mockGetTieredKubernetesVersions('standard').as('getTieredVersions'); mockGetDashboardUrl(mockCluster.id); mockGetApiEndpoints(mockCluster.id); @@ -2516,7 +2542,7 @@ describe('LKE cluster updates', () => { '@getCluster', '@getNodePools', '@getLinodes', - '@getVersions', + '@getTieredVersions', '@getLinodeType', ]); @@ -2637,7 +2663,7 @@ describe('LKE cluster updates', () => { mockGetCluster(mockCluster).as('getCluster'); mockGetClusterPools(mockCluster.id, [mockNodePool]).as('getNodePools'); - mockGetKubernetesVersions().as('getVersions'); + mockGetTieredKubernetesVersions('standard').as('getTieredVersions'); mockAddNodePool(mockCluster.id, mockNewNodePool).as('addNodePool'); mockGetLinodeType(mockPlanType).as('getLinodeType'); mockGetLinodeTypes(dcPricingMockLinodeTypes); @@ -2649,7 +2675,7 @@ describe('LKE cluster updates', () => { '@getRegions', '@getCluster', '@getNodePools', - '@getVersions', + '@getTieredVersions', '@getLinodeType', ]); diff --git a/packages/manager/cypress/e2e/core/kubernetes/smoke-lke-enterprise.spec.ts b/packages/manager/cypress/e2e/core/kubernetes/smoke-lke-enterprise.spec.ts index 86f121919f5..1a9f1ec1076 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/smoke-lke-enterprise.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/smoke-lke-enterprise.spec.ts @@ -1,10 +1,9 @@ /** * Tests basic functionality for LKE-E feature-flagged work. - * TODO: M3-10365 - Add `postLa` smoke tests to this file. * TODO: M3-8838 - Delete this spec file once LKE-E is released to GA. */ -import { regionFactory } from '@linode/utilities'; +import { linodeTypeFactory, regionFactory } from '@linode/utilities'; import { accountFactory, kubernetesClusterFactory, @@ -15,23 +14,32 @@ import { import { latestEnterpriseTierKubernetesVersion, minimumNodeNotice, + mockTieredEnterpriseVersions, + mockTieredStandardVersions, } from 'support/constants/lke'; import { mockGetAccount } from 'support/intercepts/account'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { + mockGetLinodeType, + mockGetLinodeTypes, +} from 'support/intercepts/linodes'; import { mockCreateCluster, mockGetCluster, mockGetClusterPools, + mockGetClusters, mockGetTieredKubernetesVersions, } from 'support/intercepts/lke'; -import { mockGetClusters } from 'support/intercepts/lke'; import {} from 'support/intercepts/profile'; import { mockGetRegions } from 'support/intercepts/regions'; import { mockGetVPC } from 'support/intercepts/vpc'; import { ui } from 'support/ui'; +import { lkeClusterCreatePage } from 'support/ui/pages'; import { addNodes } from 'support/util/lke'; import { randomLabel } from 'support/util/random'; +import { extendType } from 'src/utilities/extendType'; + const mockCluster = kubernetesClusterFactory.build({ id: 1, vpc_id: 123, @@ -39,6 +47,12 @@ const mockCluster = kubernetesClusterFactory.build({ tier: 'enterprise', }); +const mockPlan = extendType( + linodeTypeFactory.build({ + class: 'dedicated', + }) +); + const mockVPC = vpcFactory.build({ id: 123, label: 'lke-e-vpc', @@ -48,18 +62,10 @@ const mockVPC = vpcFactory.build({ const mockNodePools = [nodePoolFactory.build()]; // Mock a valid region for LKE-E to avoid test flake. -const mockRegions = [ - regionFactory.build({ - capabilities: ['Linodes', 'Kubernetes', 'Kubernetes Enterprise', 'VPCs'], - id: 'us-iad', - label: 'Washington, DC', - }), -]; +const mockRegion = regionFactory.build({ + capabilities: ['Linodes', 'Kubernetes', 'Kubernetes Enterprise', 'VPCs'], +}); -/** - * - Confirms VPC and IP Stack selections are shown with the respective `phase2Mtc` feature flags enabled. - * - Confirms VPC and IP Stack selections are not shown in create flow with their respective `phase2Mtc` feature flags disabled. - */ describe('LKE-E Cluster Create', () => { beforeEach(() => { mockGetAccount( @@ -71,290 +77,446 @@ describe('LKE-E Cluster Create', () => { ], }) ).as('getAccount'); - }); - - it('Simple Page Check - Phase 2 MTC BYO VPC Flag ON', () => { - mockAppendFeatureFlags({ - lkeEnterprise2: { - enabled: true, - la: true, - postLa: false, - phase2Mtc: { byoVPC: true, dualStack: false }, - }, - }).as('getFeatureFlags'); - + mockGetRegions([mockRegion]); + mockGetLinodeTypes([mockPlan]); + mockGetLinodeType(mockPlan); + mockGetTieredKubernetesVersions('standard', mockTieredStandardVersions); + mockGetTieredKubernetesVersions('enterprise', mockTieredEnterpriseVersions); mockCreateCluster(mockCluster).as('createCluster'); - mockGetTieredKubernetesVersions('enterprise', [ - latestEnterpriseTierKubernetesVersion, - ]).as('getTieredKubernetesVersions'); - mockGetRegions(mockRegions); - - cy.visitWithLogin('/kubernetes/create'); - cy.findByText('Add Node Pools').should('be.visible'); - - cy.findByLabelText('Cluster Label').click(); - cy.focused().type(mockCluster.label); - - cy.findByText('LKE Enterprise').click(); - - ui.regionSelect.find().click().type(`${mockRegions[0].label}`); - ui.regionSelect.findItemByRegionId(mockRegions[0].id).click(); - - cy.findByLabelText('Kubernetes Version').should('be.visible').click(); - cy.findByText(latestEnterpriseTierKubernetesVersion.id) - .should('be.visible') - .click(); + }); - // Confirms LKE-E Phase 2 VPC options do not display with the Dual Stack flag OFF. - cy.findByText('IP Stack').should('not.exist'); - cy.findByText('IPv4', { exact: true }).should('not.exist'); - cy.findByText('IPv4 + IPv6 (dual-stack)').should('not.exist'); + /* + * Smoke tests to confirm the state of the LKE Create page when the LKE-E + * Post-LA feature flag is enabled and disabled. + * + * The Post-LA feature flag introduces the "Configure Node Pool" button and + * flow when choosing node pools during the create flow. When disabled, it's + * expected that users can add node pools from directly within the plan table. + * When the flag is enabled, users instead select the plan they want and + * configure the pool from within a new drawer. Additional configuration options + * are available for LKE-E clusters as well. + */ + describe('Post-LA feature flag', () => { + /* + * - Confirms the state of the LKE create page when the LKE-E "postLa" flag is enabled. + * - Confirms that node pools are configured via new drawer. + */ + it('Simple Page Check - Post LA Flag ON', () => { + mockAppendFeatureFlags({ + lkeEnterprise2: { + enabled: true, + la: true, + postLa: true, + phase2Mtc: { byoVPC: false, dualStack: false }, + }, + }); - // Confirms LKE-E Phase 2 VPC options display with the BYO VPC flag ON. - cy.findByText('Automatically generate a VPC for this cluster').should( - 'be.visible' - ); - cy.findByText('Use an existing VPC').should('be.visible'); + cy.visitWithLogin('/kubernetes/create'); - cy.findByText('Shared CPU').should('be.visible').click(); - addNodes('Linode 2 GB'); + lkeClusterCreatePage.setLabel(randomLabel()); + lkeClusterCreatePage.selectRegionById(mockRegion.id, [mockRegion]); + lkeClusterCreatePage.selectPlanTab('Dedicated CPU'); + lkeClusterCreatePage.selectNodePoolPlan(mockPlan.formattedLabel); - // Bypass ACL validation - cy.get('input[name="acl-acknowledgement"]').check(); + // Confirm that the "Configure Node Pool" drawer appears. + lkeClusterCreatePage.withinNodePoolDrawer(mockPlan.formattedLabel, () => { + ui.button + .findByTitle('Add Pool') + .should('be.visible') + .should('be.enabled') + .click(); + }); - // Confirm change is reflected in checkout bar. - cy.get('[data-testid="kube-checkout-bar"]').within(() => { - cy.findByText('Linode 2 GB Plan').should('be.visible'); - cy.findByTitle('Remove Linode 2GB Node Pool').should('be.visible'); + // Confirm that "Edit Configuration" button is shown for each node pool + // in the order summary section. + lkeClusterCreatePage.withinOrderSummary(() => { + cy.contains(mockPlan.formattedLabel) + .closest('[data-testid="node-pool-summary"]') + .within(() => { + cy.findByText('Edit Configuration').should('be.visible'); + }); + }); + }); - cy.get('[data-qa-notice="true"]').within(() => { - cy.findByText(minimumNodeNotice).should('be.visible'); + /* + * - Confirms the state of the LKE create page when the LKE-E "postLa" flag is disabled. + * - Confirms that node pools are added directly via the plan table. + */ + it('Simple Page Check - Post LA Flag OFF', () => { + mockAppendFeatureFlags({ + lkeEnterprise2: { + enabled: true, + la: true, + postLa: false, + phase2Mtc: { byoVPC: false, dualStack: false }, + }, }); - ui.button - .findByTitle('Create Cluster') - .should('be.visible') - .should('be.enabled') - .click(); + cy.visitWithLogin('/kubernetes/create'); + + lkeClusterCreatePage.setLabel(randomLabel()); + lkeClusterCreatePage.selectRegionById(mockRegion.id, [mockRegion]); + lkeClusterCreatePage.selectPlanTab('Dedicated CPU'); + + // Add a node pool with a custom number of nodes, confirm that + // it gets added to the summary as expected. + lkeClusterCreatePage.addNodePool(mockPlan.formattedLabel, 5); + + lkeClusterCreatePage.withinOrderSummary(() => { + cy.contains(mockPlan.formattedLabel) + .closest('[data-testid="node-pool-summary"]') + .within(() => { + // Confirm that fields to edit the node pool size are present and enabled. + cy.findByLabelText('Subtract 1') + .should('be.visible') + .should('be.enabled'); + cy.findByLabelText('Add 1') + .should('be.visible') + .should('be.enabled'); + cy.findByLabelText('Edit Quantity').should('have.value', '5'); + }); + }); }); - - cy.wait('@createCluster'); - cy.url().should( - 'endWith', - `/kubernetes/clusters/${mockCluster.id}/summary` - ); }); - it('Simple Page Check - Phase 2 MTC Dual Stack Flag ON', () => { - mockAppendFeatureFlags({ - lkeEnterprise2: { - enabled: true, - la: true, - postLa: false, - phase2Mtc: { byoVPC: false, dualStack: true }, - }, - }).as('getFeatureFlags'); - - mockCreateCluster(mockCluster).as('createCluster'); - mockGetTieredKubernetesVersions('enterprise', [ - latestEnterpriseTierKubernetesVersion, - ]).as('getTieredKubernetesVersions'); - mockGetRegions(mockRegions); + /** + * - Confirms that VPC options are shown when the `phase2Mtc.byoVPC` feature is enabled. + * - Confirms that IP stack selections are shown when the `phase2Mtc.dualStack` feature is enabled. + * - Confirms that VPC options and IP stack selections are absent when respective `phase2Mtc` options are disabled. + */ + describe('Phase 2 MTC feature flag', () => { + it('Simple Page Check - Phase 2 MTC BYO VPC Flag ON', () => { + mockAppendFeatureFlags({ + lkeEnterprise2: { + enabled: true, + la: true, + postLa: false, + phase2Mtc: { byoVPC: true, dualStack: false }, + }, + }).as('getFeatureFlags'); + + cy.visitWithLogin('/kubernetes/create'); + cy.findByText('Add Node Pools').should('be.visible'); + + cy.findByLabelText('Cluster Label').click(); + cy.focused().type(mockCluster.label); + + cy.findByText('LKE Enterprise').click(); + + ui.regionSelect.find().click().type(`${mockRegion.label}`); + ui.regionSelect.findItemByRegionId(mockRegion.id, [mockRegion]).click(); + + cy.findByLabelText('Kubernetes Version').should('be.visible').click(); + cy.findByText(latestEnterpriseTierKubernetesVersion.id) + .should('be.visible') + .click(); - cy.visitWithLogin('/kubernetes/create'); - cy.findByText('Add Node Pools').should('be.visible'); + // Confirms LKE-E Phase 2 VPC options do not display with the Dual Stack flag OFF. + cy.findByText('IP Stack').should('not.exist'); + cy.findByText('IPv4', { exact: true }).should('not.exist'); + cy.findByText('IPv4 + IPv6 (dual-stack)').should('not.exist'); - cy.findByLabelText('Cluster Label').click(); - cy.focused().type(mockCluster.label); + // Confirms LKE-E Phase 2 VPC options display with the BYO VPC flag ON. + cy.findByText('Automatically generate a VPC for this cluster').should( + 'be.visible' + ); + cy.findByText('Use an existing VPC').should('be.visible'); + + cy.findByText('Dedicated CPU').should('be.visible').click(); + addNodes(mockPlan.formattedLabel); + + // Bypass ACL validation + cy.get('input[name="acl-acknowledgement"]').check(); + + // Confirm change is reflected in checkout bar. + cy.get('[data-testid="kube-checkout-bar"]').within(() => { + cy.findByText(`${mockPlan.formattedLabel} Plan`).should('be.visible'); + cy.findByTitle(`Remove ${mockPlan.label} Node Pool`).should( + 'be.visible' + ); + + cy.get('[data-qa-notice="true"]').within(() => { + cy.findByText(minimumNodeNotice).should('be.visible'); + }); + + ui.button + .findByTitle('Create Cluster') + .should('be.visible') + .should('be.enabled') + .click(); + }); - cy.findByText('LKE Enterprise').click(); + cy.wait('@createCluster'); + cy.url().should( + 'endWith', + `/kubernetes/clusters/${mockCluster.id}/summary` + ); + }); - ui.regionSelect.find().click().type(`${mockRegions[0].label}`); - ui.regionSelect.findItemByRegionId(mockRegions[0].id).click(); + it('Simple Page Check - Phase 2 MTC Dual Stack Flag ON', () => { + mockAppendFeatureFlags({ + lkeEnterprise2: { + enabled: true, + la: true, + postLa: false, + phase2Mtc: { byoVPC: false, dualStack: true }, + }, + }).as('getFeatureFlags'); - cy.findByLabelText('Kubernetes Version').should('be.visible').click(); - cy.findByText(latestEnterpriseTierKubernetesVersion.id) - .should('be.visible') - .click(); + cy.visitWithLogin('/kubernetes/create'); + cy.findByText('Add Node Pools').should('be.visible'); - // Confirms LKE-E Phase 2 IP Stack displays with the Dual Stack flag ON. - cy.findByText('IP Stack').should('be.visible'); - cy.findByText('IPv4', { exact: true }).should('be.visible'); - cy.findByText('IPv4 + IPv6 (dual-stack)').should('be.visible'); + cy.findByLabelText('Cluster Label').click(); + cy.focused().type(mockCluster.label); - // Confirms LKE-E Phase 2 VPC options do not display with the BYO VPC flag OFF. - cy.findByText('Automatically generate a VPC for this cluster').should( - 'not.exist' - ); - cy.findByText('Use an existing VPC').should('not.exist'); + cy.findByText('LKE Enterprise').click(); - cy.findByText('Shared CPU').should('be.visible').click(); - addNodes('Linode 2 GB'); + ui.regionSelect.find().click().type(`${mockRegion.label}`); + ui.regionSelect.findItemByRegionId(mockRegion.id, [mockRegion]).click(); - // Bypass ACL validation - cy.get('input[name="acl-acknowledgement"]').check(); + cy.findByLabelText('Kubernetes Version').should('be.visible').click(); + cy.findByText(latestEnterpriseTierKubernetesVersion.id) + .should('be.visible') + .click(); - // Confirm change is reflected in checkout bar. - cy.get('[data-testid="kube-checkout-bar"]').within(() => { - cy.findByText('Linode 2 GB Plan').should('be.visible'); - cy.findByTitle('Remove Linode 2GB Node Pool').should('be.visible'); + // Confirms LKE-E Phase 2 IP Stack displays with the Dual Stack flag ON. + cy.findByText('IP Stack').should('be.visible'); + cy.findByText('IPv4', { exact: true }).should('be.visible'); + cy.findByText('IPv4 + IPv6 (dual-stack)').should('be.visible'); - cy.get('[data-qa-notice="true"]').within(() => { - cy.findByText(minimumNodeNotice).should('be.visible'); + // Confirms LKE-E Phase 2 VPC options do not display with the BYO VPC flag OFF. + cy.findByText('Automatically generate a VPC for this cluster').should( + 'not.exist' + ); + cy.findByText('Use an existing VPC').should('not.exist'); + + cy.findByText('Dedicated CPU').should('be.visible').click(); + addNodes(mockPlan.formattedLabel); + + // Bypass ACL validation + cy.get('input[name="acl-acknowledgement"]').check(); + + // Confirm change is reflected in checkout bar. + cy.get('[data-testid="kube-checkout-bar"]').within(() => { + cy.findByText(`${mockPlan.formattedLabel} Plan`).should('be.visible'); + cy.findByTitle(`Remove ${mockPlan.label} Node Pool`).should( + 'be.visible' + ); + + cy.get('[data-qa-notice="true"]').within(() => { + cy.findByText(minimumNodeNotice).should('be.visible'); + }); + + ui.button + .findByTitle('Create Cluster') + .should('be.visible') + .should('be.enabled') + .click(); }); - ui.button - .findByTitle('Create Cluster') - .should('be.visible') - .should('be.enabled') - .click(); + cy.wait('@createCluster'); + cy.url().should( + 'endWith', + `/kubernetes/clusters/${mockCluster.id}/summary` + ); }); - cy.wait('@createCluster'); - cy.url().should( - 'endWith', - `/kubernetes/clusters/${mockCluster.id}/summary` - ); - }); + it('Simple Page Check - Phase 2 MTC Flags Both ON', () => { + mockAppendFeatureFlags({ + lkeEnterprise2: { + enabled: true, + la: true, + postLa: false, + phase2Mtc: { byoVPC: true, dualStack: true }, + }, + }).as('getFeatureFlags'); - it('Simple Page Check - Phase 2 MTC Flags Both ON', () => { - mockAppendFeatureFlags({ - lkeEnterprise2: { - enabled: true, - la: true, - postLa: false, - phase2Mtc: { byoVPC: true, dualStack: true }, - }, - }).as('getFeatureFlags'); + cy.visitWithLogin('/kubernetes/create'); + cy.findByText('Add Node Pools').should('be.visible'); - mockCreateCluster(mockCluster).as('createCluster'); - mockGetTieredKubernetesVersions('enterprise', [ - latestEnterpriseTierKubernetesVersion, - ]).as('getTieredKubernetesVersions'); - mockGetRegions(mockRegions); + cy.findByLabelText('Cluster Label').click(); + cy.focused().type(mockCluster.label); - cy.visitWithLogin('/kubernetes/create'); - cy.findByText('Add Node Pools').should('be.visible'); + cy.findByText('LKE Enterprise').click(); - cy.findByLabelText('Cluster Label').click(); - cy.focused().type(mockCluster.label); + ui.regionSelect.find().click().type(`${mockRegion.label}`); + ui.regionSelect.findItemByRegionId(mockRegion.id, [mockRegion]).click(); - cy.findByText('LKE Enterprise').click(); + cy.findByLabelText('Kubernetes Version').should('be.visible').click(); + cy.findByText(latestEnterpriseTierKubernetesVersion.id) + .should('be.visible') + .click(); - ui.regionSelect.find().click().type(`${mockRegions[0].label}`); - ui.regionSelect.findItemByRegionId(mockRegions[0].id).click(); + // Confirms LKE-E Phase 2 IP Stack and VPC options display with both flags ON. + cy.findByText('IP Stack').should('be.visible'); + cy.findByText('IPv4', { exact: true }).should('be.visible'); + cy.findByText('IPv4 + IPv6 (dual-stack)').should('be.visible'); + cy.findByText('Automatically generate a VPC for this cluster').should( + 'be.visible' + ); + cy.findByText('Use an existing VPC').should('be.visible'); + + cy.findByText('Dedicated CPU').should('be.visible').click(); + addNodes(mockPlan.formattedLabel); + + // Bypass ACL validation + cy.get('input[name="acl-acknowledgement"]').check(); + + // Confirm change is reflected in checkout bar. + cy.get('[data-testid="kube-checkout-bar"]').within(() => { + cy.findByText(`${mockPlan.formattedLabel} Plan`).should('be.visible'); + cy.findByTitle(`Remove ${mockPlan.label} Node Pool`).should( + 'be.visible' + ); + + cy.get('[data-qa-notice="true"]').within(() => { + cy.findByText(minimumNodeNotice).should('be.visible'); + }); + + ui.button + .findByTitle('Create Cluster') + .should('be.visible') + .should('be.enabled') + .click(); + }); - cy.findByLabelText('Kubernetes Version').should('be.visible').click(); - cy.findByText(latestEnterpriseTierKubernetesVersion.id) - .should('be.visible') - .click(); + cy.wait('@createCluster'); + cy.url().should( + 'endWith', + `/kubernetes/clusters/${mockCluster.id}/summary` + ); + }); - // Confirms LKE-E Phase 2 IP Stack and VPC options display with both flags ON. - cy.findByText('IP Stack').should('be.visible'); - cy.findByText('IPv4', { exact: true }).should('be.visible'); - cy.findByText('IPv4 + IPv6 (dual-stack)').should('be.visible'); - cy.findByText('Automatically generate a VPC for this cluster').should( - 'be.visible' - ); - cy.findByText('Use an existing VPC').should('be.visible'); + it('Simple Page Check - Phase 2 MTC Flags Both OFF', () => { + mockAppendFeatureFlags({ + lkeEnterprise2: { + enabled: true, + la: true, + postLa: false, + phase2Mtc: { byoVPC: false, dualStack: false }, + }, + }).as('getFeatureFlags'); - cy.findByText('Shared CPU').should('be.visible').click(); - addNodes('Linode 2 GB'); + cy.visitWithLogin('/kubernetes/create'); + cy.findByText('Add Node Pools').should('be.visible'); - // Bypass ACL validation - cy.get('input[name="acl-acknowledgement"]').check(); + cy.findByLabelText('Cluster Label').click(); + cy.focused().type(mockCluster.label); - // Confirm change is reflected in checkout bar. - cy.get('[data-testid="kube-checkout-bar"]').within(() => { - cy.findByText('Linode 2 GB Plan').should('be.visible'); - cy.findByTitle('Remove Linode 2GB Node Pool').should('be.visible'); + cy.findByText('LKE Enterprise').click(); - cy.get('[data-qa-notice="true"]').within(() => { - cy.findByText(minimumNodeNotice).should('be.visible'); - }); + ui.regionSelect.find().click().type(`${mockRegion.label}`); + ui.regionSelect.findItemByRegionId(mockRegion.id, [mockRegion]).click(); - ui.button - .findByTitle('Create Cluster') + cy.findByLabelText('Kubernetes Version').should('be.visible').click(); + cy.findByText(latestEnterpriseTierKubernetesVersion.id) .should('be.visible') - .should('be.enabled') .click(); - }); - - cy.wait('@createCluster'); - cy.url().should( - 'endWith', - `/kubernetes/clusters/${mockCluster.id}/summary` - ); - }); - - it('Simple Page Check - Phase 2 MTC Flags Both OFF', () => { - mockAppendFeatureFlags({ - lkeEnterprise2: { - enabled: true, - la: true, - postLa: false, - phase2Mtc: { byoVPC: false, dualStack: false }, - }, - }).as('getFeatureFlags'); - mockCreateCluster(mockCluster).as('createCluster'); - mockGetTieredKubernetesVersions('enterprise', [ - latestEnterpriseTierKubernetesVersion, - ]).as('getTieredKubernetesVersions'); - mockGetRegions(mockRegions); - - cy.visitWithLogin('/kubernetes/create'); - cy.findByText('Add Node Pools').should('be.visible'); - - cy.findByLabelText('Cluster Label').click(); - cy.focused().type(mockCluster.label); + // Confirms LKE-E Phase 2 IP Stack and VPC options do not display with both flags OFF. + cy.findByText('IP Stack').should('not.exist'); + cy.findByText('IPv4', { exact: true }).should('not.exist'); + cy.findByText('IPv4 + IPv6 (dual-stack)').should('not.exist'); + cy.findByText('Automatically generate a VPC for this cluster').should( + 'not.exist' + ); + cy.findByText('Use an existing VPC').should('not.exist'); + + cy.findByText('Dedicated CPU').should('be.visible').click(); + addNodes(mockPlan.formattedLabel); + + // Bypass ACL validation + cy.get('input[name="acl-acknowledgement"]').check(); + + // Confirm change is reflected in checkout bar. + cy.get('[data-testid="kube-checkout-bar"]').within(() => { + cy.findByText(`${mockPlan.formattedLabel} Plan`).should('be.visible'); + cy.findByTitle(`Remove ${mockPlan.label} Node Pool`).should( + 'be.visible' + ); + + cy.get('[data-qa-notice="true"]').within(() => { + cy.findByText(minimumNodeNotice).should('be.visible'); + }); + + ui.button + .findByTitle('Create Cluster') + .should('be.visible') + .should('be.enabled') + .click(); + }); - cy.findByText('LKE Enterprise').click(); + cy.wait('@createCluster'); + cy.url().should( + 'endWith', + `/kubernetes/clusters/${mockCluster.id}/summary` + ); + }); + }); - ui.regionSelect.find().click().type(`${mockRegions[0].label}`); - ui.regionSelect.findItemByRegionId(mockRegions[0].id).click(); + describe('Phase 2 MTC & Post-LA feature flags', () => { + it('Simple Page Check - Phase 2 MTC Flags and Post-LA Flag ON', () => { + mockAppendFeatureFlags({ + lkeEnterprise2: { + enabled: true, + la: true, + postLa: true, + phase2Mtc: { byoVPC: true, dualStack: true }, + }, + }); - cy.findByLabelText('Kubernetes Version').should('be.visible').click(); - cy.findByText(latestEnterpriseTierKubernetesVersion.id) - .should('be.visible') - .click(); + cy.visitWithLogin('/kubernetes/create'); - // Confirms LKE-E Phase 2 IP Stack and VPC options do not display with both flags OFF. - cy.findByText('IP Stack').should('not.exist'); - cy.findByText('IPv4', { exact: true }).should('not.exist'); - cy.findByText('IPv4 + IPv6 (dual-stack)').should('not.exist'); - cy.findByText('Automatically generate a VPC for this cluster').should( - 'not.exist' - ); - cy.findByText('Use an existing VPC').should('not.exist'); + lkeClusterCreatePage.setLabel(randomLabel()); + lkeClusterCreatePage.selectClusterTier('enterprise'); + lkeClusterCreatePage.selectRegionById(mockRegion.id, [mockRegion]); + lkeClusterCreatePage.selectPlanTab('Dedicated CPU'); - cy.findByText('Shared CPU').should('be.visible').click(); - addNodes('Linode 2 GB'); + // Confirm that IP stack selection and VPC options are present. + cy.findByText('IPv4') + .should('be.visible') + .closest('input') + .should('be.enabled'); - // Bypass ACL validation - cy.get('input[name="acl-acknowledgement"]').check(); + cy.findByText('IPv4 + IPv6 (dual-stack)') + .should('be.visible') + .closest('input') + .should('be.enabled'); - // Confirm change is reflected in checkout bar. - cy.get('[data-testid="kube-checkout-bar"]').within(() => { - cy.findByText('Linode 2 GB Plan').should('be.visible'); - cy.findByTitle('Remove Linode 2GB Node Pool').should('be.visible'); + cy.findByText('Automatically generate a VPC for this cluster') + .should('be.visible') + .closest('input') + .should('be.enabled'); - cy.get('[data-qa-notice="true"]').within(() => { - cy.findByText(minimumNodeNotice).should('be.visible'); + cy.findByText('Use an existing VPC') + .should('be.visible') + .closest('input') + .should('be.enabled'); + + // Confirm that node pools are configured via new drawer rather than directly within table. + lkeClusterCreatePage.selectNodePoolPlan(mockPlan.formattedLabel); + lkeClusterCreatePage.withinNodePoolDrawer(mockPlan.formattedLabel, () => { + // Confirm that Enterprise-tier specific options are present. + cy.findByText('Update Strategy').should('be.visible'); + cy.findByText('Use default firewall').should('be.visible'); + cy.findByText('Select existing firewall').should('be.visible'); + + ui.button + .findByTitle('Add Pool') + .should('be.visible') + .should('be.enabled') + .click(); }); - ui.button - .findByTitle('Create Cluster') - .should('be.visible') - .should('be.enabled') - .click(); + lkeClusterCreatePage.withinOrderSummary(() => { + cy.contains(mockPlan.formattedLabel) + .closest('[data-testid="node-pool-summary"]') + .within(() => { + cy.findByText('3 Nodes').should('be.visible'); + cy.findByText('Edit Configuration').should('be.visible'); + }); + }); }); - - cy.wait('@createCluster'); - cy.url().should( - 'endWith', - `/kubernetes/clusters/${mockCluster.id}/summary` - ); }); }); @@ -373,129 +535,265 @@ describe('LKE-E Cluster Read', () => { ], }) ).as('getAccount'); - }); - - it('Simple Page Check - Phase 2 MTC BYO VPC Flag ON', () => { - mockAppendFeatureFlags({ - lkeEnterprise2: { - enabled: true, - la: true, - phase2Mtc: { byoVPC: true, dualStack: false }, - }, - }).as('getFeatureFlags'); - mockGetClusters([mockCluster]).as('getClusters'); mockGetCluster(mockCluster).as('getCluster'); mockGetClusterPools(mockCluster.id, mockNodePools).as('getNodePools'); mockGetVPC(mockVPC).as('getVPC'); + }); - cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}/summary`); - cy.wait(['@getCluster', '@getVPC', '@getNodePools']); - - // Confirm linked VPC is present - cy.get('[data-qa-kube-entity-footer]').within(() => { - cy.contains('VPC:').should('exist'); - cy.findByTestId('assigned-lke-cluster-label').should( - 'contain.text', - mockVPC.label - ); - }); + /* + * Smoke tests to confirm the state of the LKE cluster details page when the + * LKE-E "phase2Mtc" feature flag is enabled. + */ + describe('Phase 2 MTC feature flag', () => { + /* + * - Confirms the state of the LKE cluster details page when the Phase 2 BYO VPC feature is enabled. + * - Confirms that attached VPC label is displayed in the cluster summary. + * - Confirms that VPC IP columns are not present when Phase 2 dual stack flag is disabled. + */ + it('Simple Page Check - Phase 2 MTC BYO VPC Flag ON', () => { + mockAppendFeatureFlags({ + lkeEnterprise2: { + enabled: true, + la: true, + phase2Mtc: { byoVPC: true, dualStack: false }, + }, + }).as('getFeatureFlags'); + + cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}/summary`); + cy.wait(['@getCluster', '@getVPC', '@getNodePools']); + + // Confirm linked VPC is present + cy.get('[data-qa-kube-entity-footer]').within(() => { + cy.contains('VPC:').should('exist'); + cy.findByTestId('assigned-lke-cluster-label').should( + 'contain.text', + mockVPC.label + ); + }); - // Confirm VPC IP columns are not present in the node table header - 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'); + // Confirm VPC IP columns are not present in the node table header + 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'); + }); }); - }); - it('Simple Page Check - Phase 2 MTC Dual Stack Flag ON', () => { - mockAppendFeatureFlags({ - lkeEnterprise2: { - enabled: true, - la: true, - phase2Mtc: { byoVPC: false, dualStack: true }, - }, - }).as('getFeatureFlags'); + /* + * - Confirms the state of the LKE cluster details page when the Phase 2 dual stack feature is enabled. + * - Confirms that VPC node pool table IP columns are present. + * - Confirms that attached VPC label is absent in the cluster summary when the BYO VPC feature is disabled. + */ + it('Simple Page Check - Phase 2 MTC Dual Stack Flag ON', () => { + mockAppendFeatureFlags({ + lkeEnterprise2: { + enabled: true, + la: true, + phase2Mtc: { byoVPC: false, dualStack: true }, + }, + }).as('getFeatureFlags'); + + cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}/summary`); + cy.wait(['@getCluster', '@getNodePools']); + + // Confirm linked VPC is not present + cy.get('[data-qa-kube-entity-footer]').within(() => { + cy.contains('VPC:').should('not.exist'); + cy.findByTestId('assigned-lke-cluster-label').should('not.exist'); + }); - mockGetClusters([mockCluster]).as('getClusters'); - mockGetCluster(mockCluster).as('getCluster'); - mockGetClusterPools(mockCluster.id, mockNodePools).as('getNodePools'); + // Confirm VPC IP columns are present in the node 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'); + }); + }); - cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}/summary`); - cy.wait(['@getCluster', '@getNodePools']); + /* + * - Confirms the state of the LKE cluster details page when the Phase 2 dual stack and BYO VPC features are enabled. + * - Confirms that VPC node pool table IP columns are present. + * - Confirms that attached VPC label is displayed in the cluster summary. + */ + it('Simple Page Check - Phase 2 MTC Flags Both ON', () => { + mockAppendFeatureFlags({ + lkeEnterprise2: { + enabled: true, + la: true, + phase2Mtc: { byoVPC: true, dualStack: true }, + }, + }).as('getFeatureFlags'); + + cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}/summary`); + cy.wait(['@getCluster', '@getVPC', '@getNodePools']); + + // Confirm linked VPC is present + cy.get('[data-qa-kube-entity-footer]').within(() => { + cy.contains('VPC:').should('exist'); + cy.findByTestId('assigned-lke-cluster-label').should( + 'contain.text', + mockVPC.label + ); + }); - // Confirm linked VPC is not present - cy.get('[data-qa-kube-entity-footer]').within(() => { - cy.contains('VPC:').should('not.exist'); - cy.findByTestId('assigned-lke-cluster-label').should('not.exist'); + // Confirm VPC IP columns are present in the node 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 columns are present in the node 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'); + /* + * - Confirms the state of the LKE cluster details page when the "phase2Mtc" feature is disabled. + * - Confirms that no VPC label is shown in the cluster summary. + * - Confirms that IPv4 and IPv6 node pool table columns are absent. + */ + it('Simple Page Check - Phase 2 MTC Flags Both OFF', () => { + mockAppendFeatureFlags({ + lkeEnterprise2: { + enabled: true, + la: true, + phase2Mtc: { byoVPC: false, dualStack: false }, + }, + }).as('getFeatureFlags'); + + cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}/summary`); + cy.wait(['@getCluster', '@getNodePools']); + + // Confirm linked VPC is not present + cy.get('[data-qa-kube-entity-footer]').within(() => { + cy.contains('VPC:').should('not.exist'); + cy.findByTestId('assigned-lke-cluster-label').should('not.exist'); + }); + + // Confirm VPC IP columns are not present in the node table header + 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'); + }); }); }); - it('Simple Page Check - Phase 2 MTC Flags Both ON', () => { - mockAppendFeatureFlags({ - lkeEnterprise2: { - enabled: true, - la: true, - phase2Mtc: { byoVPC: true, dualStack: true }, - }, - }).as('getFeatureFlags'); - - mockGetClusters([mockCluster]).as('getClusters'); - mockGetCluster(mockCluster).as('getCluster'); - mockGetClusterPools(mockCluster.id, mockNodePools).as('getNodePools'); - mockGetVPC(mockVPC).as('getVPC'); - - cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}/summary`); - cy.wait(['@getCluster', '@getVPC', '@getNodePools']); + /* + * Smoke tests to confirm the state of the LKE cluster details page when the + * LKE-E "postLa" feature flag is enabled and disabled. + */ + describe('Post-LA feature flags', () => { + /* + * - Confirms the state of the LKE cluster details page when the "postLa" feature flag is enabled. + * - Confirms that update strategy and firewall options are present in the Add Node Pool drawer. + */ + it('Simple Page Check - Post-LA Flag ON', () => { + mockAppendFeatureFlags({ + lkeEnterprise2: { + enabled: true, + la: true, + postLa: true, + phase2Mtc: { byoVPC: false, dualStack: false }, + }, + }).as('getFeatureFlags'); + + cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}/summary`); + ui.button + .findByTitle('Add a Node Pool') + .should('be.visible') + .should('be.enabled') + .click(); - // Confirm linked VPC is present - cy.get('[data-qa-kube-entity-footer]').within(() => { - cy.contains('VPC:').should('exist'); - cy.findByTestId('assigned-lke-cluster-label').should( - 'contain.text', - mockVPC.label - ); + ui.drawer + .findByTitle(`Add a Node Pool: ${mockCluster.label}`) + .should('be.visible') + .within(() => { + cy.findByText('Update Strategy').scrollIntoView(); + cy.findByText('Update Strategy').should('be.visible'); + cy.findByText('Use default firewall').should('be.visible'); + cy.findByText('Select existing firewall').should('be.visible'); + }); }); - // Confirm VPC IP columns are present in the node 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'); + /* + * - Confirms the state of the LKE cluster details page when the "postLa" feature flag is disabled. + * - Confirms that update strategy and firewall options are absent in the Add Node Pool drawer. + */ + it('Simple Page Check - Post-LA Flag OFF', () => { + mockAppendFeatureFlags({ + lkeEnterprise2: { + enabled: true, + la: true, + phase2Mtc: { byoVPC: false, dualStack: false }, + postLa: false, + }, + }).as('getFeatureFlags'); + + cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}/summary`); + ui.button + .findByTitle('Add a Node Pool') + .should('be.visible') + .should('be.enabled') + .click(); + + ui.drawer + .findByTitle(`Add a Node Pool: ${mockCluster.label}`) + .should('be.visible') + .within(() => { + cy.findByText('Update Strategy').should('not.exist'); + cy.findByText('Use default firewall').should('not.exist'); + cy.findByText('Select existing firewall').should('not.exist'); + }); }); }); - it('Simple Page Check - Phase 2 MTC Flags Both OFF', () => { - mockAppendFeatureFlags({ - lkeEnterprise2: { - enabled: true, - la: true, - phase2Mtc: { byoVPC: false, dualStack: false }, - }, - }).as('getFeatureFlags'); + /* + * Smoke tests to confirm the state of the LKE cluster details page when the + * 'phase2Mtc' and 'postLa' LKE-E feature flags are both enabled. + */ + describe('Phase 2 MTC & Post-LA feature flags', () => { + /* + * - Confirms the state of LKE details page when "phase2Mtc" and "postLa" are both enabled. + * - Confirms that update strategy and Firewall options are present in Add Node Pool drawer. + * - Confirms that attached VPC is shown in the summary, and IPv4 and IPv6 node pool table columns are present. + */ + it('Simple Page Check - Phase 2 MTC Flags and Post-LA Flag ON', () => { + mockAppendFeatureFlags({ + lkeEnterprise2: { + enabled: true, + la: true, + phase2Mtc: { byoVPC: true, dualStack: true }, + postLa: true, + }, + }); - mockGetClusters([mockCluster]).as('getClusters'); - mockGetCluster(mockCluster).as('getCluster'); - mockGetClusterPools(mockCluster.id, mockNodePools).as('getNodePools'); + cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}/summary`); - cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}/summary`); - cy.wait(['@getCluster', '@getNodePools']); + // Confirm that VPC label is shown in summary, and that IPv4 and IPv6 + // node pool table columns are present. + cy.get('[data-qa-kube-entity-footer]').within(() => { + cy.contains('VPC:').should('exist'); + cy.findByTestId('assigned-lke-cluster-label').should( + 'contain.text', + mockVPC.label + ); + }); - // Confirm linked VPC is not present - cy.get('[data-qa-kube-entity-footer]').within(() => { - cy.contains('VPC:').should('not.exist'); - cy.findByTestId('assigned-lke-cluster-label').should('not.exist'); - }); + cy.findByLabelText('List of Your Cluster Nodes').within(() => { + cy.contains('th', 'VPC IPv4').should('be.visible'); + cy.contains('th', 'VPC IPv6').should('be.visible'); + }); - // Confirm VPC IP columns are not present in the node table header - 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'); + ui.button + .findByTitle('Add a Node Pool') + .should('be.visible') + .should('be.enabled') + .click(); + + ui.drawer + .findByTitle(`Add a Node Pool: ${mockCluster.label}`) + .should('be.visible') + .within(() => { + cy.findByText('Update Strategy').scrollIntoView(); + cy.findByText('Update Strategy').should('be.visible'); + cy.findByText('Use default firewall').should('be.visible'); + cy.findByText('Select existing firewall').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 87cbfc09cf0..ab7c8e4646e 100644 --- a/packages/manager/cypress/e2e/core/linodes/alerts-create.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/alerts-create.spec.ts @@ -299,24 +299,24 @@ describe('Create flow when beta alerts enabled by region and feature flag', func cy.get('pre code').should('be.visible'); /** alert in code snippet * "alerts": { - * "system": [ + * "system_alerts": [ * 1, * 2, * ], - * "user": [ + * "user_alerts": [ * 2 * ] * } */ - const strAlertSnippet = `alerts '{"system": [${alertDefinitions[0].id},${alertDefinitions[1].id}],"user":[${alertDefinitions[2].id}]}`; + const strAlertSnippet = `alerts '{"system_alerts": [${alertDefinitions[0].id},${alertDefinitions[1].id}],"user_alerts":[${alertDefinitions[2].id}]}`; cy.contains(strAlertSnippet).should('be.visible'); // cURL tab ui.tabList.findTabByTitle('cURL').should('be.visible').click(); // hard to consolidate text within multiple spans in

         cy.get('pre code').within(() => {
           cy.contains('alerts');
-          cy.contains('system');
-          cy.contains('user');
+          cy.contains('system_alerts');
+          cy.contains('user_alerts');
         });
         ui.button
           .findByTitle('Close')
@@ -341,11 +341,11 @@ describe('Create flow when beta alerts enabled by region and feature flag', func
       .click();
     cy.wait('@createLinode').then((intercept) => {
       const alerts = intercept.request.body['alerts'];
-      expect(alerts.system.length).to.equal(2);
-      expect(alerts.system[0]).to.eq(alertDefinitions[0].id);
-      expect(alerts.system[1]).to.eq(alertDefinitions[1].id);
-      expect(alerts.user.length).to.equal(1);
-      expect(alerts.user[0]).to.eq(alertDefinitions[2].id);
+      expect(alerts.system_alerts.length).to.equal(2);
+      expect(alerts.system_alerts[0]).to.eq(alertDefinitions[0].id);
+      expect(alerts.system_alerts[1]).to.eq(alertDefinitions[1].id);
+      expect(alerts.user_alerts.length).to.equal(1);
+      expect(alerts.user_alerts[0]).to.eq(alertDefinitions[2].id);
     });
   });
 
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 d2b7e3267a4..7513f2b8fe2 100644
--- a/packages/manager/cypress/e2e/core/linodes/alerts-edit.spec.ts
+++ b/packages/manager/cypress/e2e/core/linodes/alerts-edit.spec.ts
@@ -37,21 +37,21 @@ const mockEnabledLegacyAlerts = {
 };
 
 const mockDisabledBetaAlerts = {
-  system: [],
-  user: [],
+  system_alerts: [],
+  user_alerts: [],
 };
 
 const mockEnabledBetaAlerts = {
-  system: [1, 2],
-  user: [3],
+  system_alerts: [1, 2],
+  user_alerts: [3],
 };
 
 /*
  * UI of Linode alerts tab based on beta and legacy alert values in linode.alerts. Dependent on region support for alerts
  * Legacy alerts = 0, Beta alerts = [] (empty arrays or no values at all) => legacy disabled for `beta` stage OR beta disabled for `ga` stage
  * Legacy alerts > 0, Beta alerts = [] (empty arrays or no values at all) => legacy enabled
- * Legacy alerts = 0, Beta alerts has values (either system, user, or both) => beta enabled
- * Legacy alerts > 0, Beta alerts has values (either system, user, or both) => beta enabled
+ * Legacy alerts = 0, Beta alerts has values (either system_alerts, user_alerts, or both) => beta enabled
+ * Legacy alerts > 0, Beta alerts has values (either system_alerts, user_alerts, or both) => beta enabled
  *
  * Note: Here, "disabled" means that all toggles are in the OFF state, but it's still editable (not read-only)
  */
diff --git a/packages/manager/cypress/e2e/core/linodes/linode-network.spec.ts b/packages/manager/cypress/e2e/core/linodes/linode-network.spec.ts
index 1a47787c0cd..87840e9ae1a 100644
--- a/packages/manager/cypress/e2e/core/linodes/linode-network.spec.ts
+++ b/packages/manager/cypress/e2e/core/linodes/linode-network.spec.ts
@@ -1,5 +1,6 @@
 import {
   linodeInterfaceFactoryPublic,
+  linodeInterfaceFactoryVlan,
   linodeInterfaceFactoryVPC,
 } from '@linode/utilities';
 import { linodeFactory } from '@linode/utilities';
@@ -22,16 +23,18 @@ import {
   mockCreateLinodeInterface,
   mockGetLinodeDetails,
   mockGetLinodeFirewalls,
+  mockGetLinodeInterface,
   mockGetLinodeInterfaces,
   mockGetLinodeIPAddresses,
 } from 'support/intercepts/linodes';
 import { mockUpdateIPAddress } from 'support/intercepts/networking';
-import { mockGetVPCs } from 'support/intercepts/vpc';
+import { mockGetVPC, mockGetVPCs } from 'support/intercepts/vpc';
 import { ui } from 'support/ui';
 
-import type { IPRange } from '@linode/api-v4';
+import type { IPRange, LinodeIPsResponse } from '@linode/api-v4';
 
 describe('IP Addresses', () => {
+  // TODO M3-9775: Set mock linode interface type to legacy once Linode Interfaces is GA.
   const mockLinode = linodeFactory.build();
   const linodeIPv4 = mockLinode.ipv4[0];
   const mockRDNS = `${linodeIPv4}.ip.linodeusercontent.com`;
@@ -253,11 +256,11 @@ describe('Firewalls', () => {
   });
 });
 
-describe('Linode Interfaces', () => {
+describe('Linode Interfaces enabled', () => {
   beforeEach(() => {
     mockGetAccount(
       accountFactory.build({
-        capabilities: ['Linode Interfaces'],
+        capabilities: ['Linodes', 'Linode Interfaces'],
       })
     );
     mockAppendFeatureFlags({
@@ -265,162 +268,673 @@ describe('Linode Interfaces', () => {
     });
   });
 
-  it('allows the user to add a public network interface with a firewall', () => {
-    const linode = linodeFactory.build({ interface_generation: 'linode' });
-    const firewalls = firewallFactory.buildList(3);
-    const linodeInterface = linodeInterfaceFactoryPublic.build();
-
-    const selectedFirewall = firewalls[1];
+  describe('Linode with legacy config-based interfaces', () => {
+    const mockLinode = linodeFactory.build({
+      interface_generation: 'legacy_config',
+    });
 
-    mockGetLinodeDetails(linode.id, linode).as('getLinode');
-    mockGetLinodeInterfaces(linode.id, { interfaces: [] }).as('getInterfaces');
-    mockGetFirewalls(firewalls).as('getFirewalls');
-    mockCreateLinodeInterface(linode.id, linodeInterface).as('createInterface');
-    mockGetLinodeInterfaceFirewalls(linode.id, linodeInterface.id, [
-      selectedFirewall,
-    ]).as('getInterfaceFirewalls');
+    const mockLinodeIPv4 = ipAddressFactory.build({
+      linode_id: mockLinode.id,
+      public: true,
+      type: 'ipv4',
+      region: mockLinode.region,
+      interface_id: null,
+    });
 
-    cy.visitWithLogin(`/linodes/${linode.id}/networking`);
+    const mockLinodeIPs: LinodeIPsResponse = {
+      ipv4: {
+        public: [mockLinodeIPv4],
+        private: [],
+        reserved: [],
+        shared: [],
+        vpc: [],
+      },
+    };
 
-    cy.wait(['@getLinode', '@getInterfaces']);
+    beforeEach(() => {
+      mockGetLinodeDetails(mockLinode.id, mockLinode);
+      mockGetLinodeFirewalls(mockLinode.id, []);
+      mockGetLinodeIPAddresses(mockLinode.id, mockLinodeIPs);
+    });
 
-    ui.button.findByTitle('Add Network Interface').scrollIntoView().click();
+    /*
+     * - Confirms network tab Firewall table is present for Linodes with config-based interfaces.
+     * - Confirms that "Add Firewall" button is present and enabled for Linodes with config-based interfaces.
+     */
+    it('shows the Firewall table for Linodes with config-based interfaces', () => {
+      cy.visitWithLogin(`/linodes/${mockLinode.id}/networking`);
+
+      ui.button
+        .findByTitle('Add Firewall')
+        .should('be.visible')
+        .should('be.enabled');
+
+      cy.get('[data-qa-linode-firewalls-table]')
+        .should('be.visible')
+        .within(() => {
+          cy.findByText('No Firewalls are assigned.').should('be.visible');
+        });
+    });
 
-    ui.drawer.findByTitle('Add Network Interface').within(() => {
-      // Verify firewalls fetch
-      cy.wait('@getFirewalls');
+    /*
+     * - Confirms that network tab IP Addresses table is present for Linodes with config-based interfaces.
+     * - Confirms that IP address add and delete buttons are present for Linodes with config-based interfaces.
+     */
+    it('shows the IP address add and remove buttons for Linodes with config-based interfaces', () => {
+      cy.visitWithLogin(`/linodes/${mockLinode.id}/networking`);
+
+      ui.button
+        .findByTitle('Add an IP Address')
+        .should('be.visible')
+        .should('be.enabled');
+
+      cy.findByLabelText('Linode IP Addresses').should('be.visible');
+      cy.findByText(mockLinodeIPv4.address)
+        .should('be.visible')
+        .closest('tr')
+        .within(() => {
+          cy.findByText('Public – IPv4').should('be.visible');
+          ui.button.findByTitle('Delete').should('be.visible');
+        });
+    });
 
-      // Try submitting the form
-      ui.button.findByAttribute('type', 'submit').should('be.enabled').click();
+    /**
+     * - Confirms the Networking Interface table doesn't exist for config-based interfaces
+     */
+    it('does not show the Linode Interface networking table', () => {
+      cy.visitWithLogin(`/linodes/${mockLinode.id}/networking`);
 
-      // Verify a validation error shows
-      cy.findByText('You must selected an Interface type.').should(
-        'be.visible'
-      );
+      cy.get('[data-qa-linode-interfaces-table]').should('not.exist');
+      cy.findByText('Add Network Interface').should('not.exist');
+      cy.findByText('Interface Settings').should('not.exist');
+    });
+  });
 
-      // Select the public interface type
-      cy.findByLabelText('Public').click();
+  describe('Linode with Linode-based interfaces', () => {
+    const mockLinode = linodeFactory.build({
+      interface_generation: 'linode',
+    });
 
-      // Verify a validation error goes away
-      cy.findByText('You must selected an Interface type.').should('not.exist');
+    const mockLinodeIPv4 = ipAddressFactory.build({
+      linode_id: mockLinode.id,
+      public: true,
+      type: 'ipv4',
+      region: mockLinode.region,
+      interface_id: null,
+    });
 
-      // Select a Firewall
-      ui.autocomplete.findByLabel('Firewall').click();
-      ui.autocompletePopper.findByTitle(selectedFirewall.label).click();
+    const mockLinodeIPs: LinodeIPsResponse = {
+      ipv4: {
+        public: [mockLinodeIPv4],
+        private: [],
+        reserved: [],
+        shared: [],
+        vpc: [],
+      },
+    };
 
-      mockGetLinodeInterfaces(linode.id, { interfaces: [linodeInterface] });
+    const mockFirewalls = firewallFactory.buildList(3);
 
-      ui.button.findByAttribute('type', 'submit').should('be.enabled').click();
+    beforeEach(() => {
+      mockGetLinodeDetails(mockLinode.id, mockLinode).as('getLinode');
+      mockGetLinodeIPAddresses(mockLinode.id, mockLinodeIPs).as('getLinodeIPs');
+      mockGetLinodeInterfaces(mockLinode.id, { interfaces: [] }).as(
+        'getInterfaces'
+      );
     });
 
-    cy.wait('@createInterface').then((xhr) => {
-      const requestPayload = xhr.request.body;
+    /*
+     * - Confirms that network tab Firewall table is absent for Linodes using new Linode-based interfaces.
+     * - Confirms that IP address add and delete buttons are absent for Linodes using new Linode-based interfaces.
+     */
+    it('hides Firewall table and IP address buttons', () => {
+      cy.visitWithLogin(`/linodes/${mockLinode.id}/networking`);
+
+      // Confirm Firewalls section is absent
+      cy.get('[data-qa-linode-firewalls-table]').should('not.exist');
+      cy.findByText('Add Firewall').should('not.exist');
+
+      // Confirm add IP and delete IP buttons are missing from IP address section
+      cy.findByLabelText('Linode IP Addresses').should('be.visible');
+      cy.findByText('Add an IP Address').should('not.exist');
+      cy.findByText(mockLinodeIPv4.address)
+        .should('be.visible')
+        .closest('tr')
+        .within(() => {
+          cy.findByText('Public – IPv4').should('be.visible');
+          cy.findByText('Delete').should('not.exist');
+        });
+    });
 
-      // Confirm that request payload includes a Public interface only
-      expect(requestPayload['public']).to.be.an('object');
-      expect(requestPayload['vpc']).to.equal(null);
-      expect(requestPayload['vlan']).to.equal(null);
+    it('confirms the Network Interfaces table functions as expected', () => {
+      const publicInterface = linodeInterfaceFactoryPublic.build();
+      mockGetLinodeInterfaces(mockLinode.id, {
+        interfaces: [publicInterface],
+      }).as('getInterfaces');
+
+      cy.visitWithLogin(`/linodes/${mockLinode.id}/networking`);
+
+      ui.button
+        .findByTitle('Add Network Interface')
+        .should('be.visible')
+        .should('be.enabled');
+      ui.button
+        .findByTitle('Interface Settings')
+        .should('be.visible')
+        .should('be.enabled');
+
+      // Confirm table heading row
+      cy.get('[data-qa-linode-interfaces-table]')
+        .should('be.visible')
+        .within(() => {
+          cy.findByText('Type').should('be.visible');
+          cy.findByText('ID').should('be.visible');
+          cy.findByText('MAC Address').should('be.visible');
+          cy.findByText('IP Addresses').should('be.visible');
+          cy.findByText('Version').should('be.visible');
+          cy.findByText('Firewall').should('be.visible');
+          cy.findByText('Updated').should('be.visible');
+          cy.findByText('Created').should('be.visible');
+        });
+
+      // Confirm interface row's action menu
+      cy.findByText(publicInterface.mac_address)
+        .should('be.visible')
+        .closest('tr')
+        .within(() => {
+          ui.actionMenu
+            .findByTitle(
+              `Action menu for Public Interface (${publicInterface.id})`
+            )
+            .should('be.visible')
+            .should('be.enabled')
+            .click();
+
+          ui.actionMenuItem
+            .findByTitle('Details')
+            .should('be.visible')
+            .should('be.enabled');
+          ui.actionMenuItem
+            .findByTitle('Edit')
+            .should('be.visible')
+            .should('be.enabled');
+          ui.actionMenuItem
+            .findByTitle('Delete')
+            .should('be.visible')
+            .should('be.enabled');
+        });
     });
 
-    ui.toast.assertMessage('Successfully added network interface.');
+    describe('Adding a Linode Interface', () => {
+      it('allows the user to add a VLAN interface', () => {
+        const mockLinodeInterface = linodeInterfaceFactoryVlan.build();
 
-    // Verify the interface row shows upon creation
-    cy.findByText(linodeInterface.mac_address)
-      .closest('tr')
-      .within(() => {
-        // Verify we fetch the interfaces firewalls and the label shows
-        cy.wait('@getInterfaceFirewalls');
-        cy.findByText(selectedFirewall.label).should('be.visible');
+        mockGetFirewalls(mockFirewalls).as('getFirewalls');
+        mockCreateLinodeInterface(mockLinode.id, mockLinodeInterface).as(
+          'createInterface'
+        );
+        mockGetLinodeInterfaceFirewalls(
+          mockLinode.id,
+          mockLinodeInterface.id,
+          []
+        ).as('getInterfaceFirewalls');
+
+        cy.visitWithLogin(`/linodes/${mockLinode.id}/networking`);
+
+        cy.wait(['@getLinode', '@getInterfaces']);
+
+        ui.button.findByTitle('Add Network Interface').scrollIntoView().click();
+
+        ui.drawer.findByTitle('Add Network Interface').within(() => {
+          // Verify firewalls fetch
+          cy.wait('@getFirewalls');
+
+          // Try submitting the form
+          ui.button
+            .findByAttribute('type', 'submit')
+            .should('be.enabled')
+            .click();
+
+          // Verify a validation error shows
+          cy.findByText('You must selected an Interface type.').should(
+            'be.visible'
+          );
+
+          // Select the public interface type
+          cy.findByLabelText('VLAN').click();
+
+          // Verify a validation error goes away
+          cy.findByText('You must selected an Interface type.').should(
+            'not.exist'
+          );
+
+          ui.button
+            .findByAttribute('type', 'submit')
+            .should('be.enabled')
+            .click();
+
+          // Verify an error shows because a VLAN was not selected nor created
+          cy.findByText('VLAN label is required.').should('be.visible');
+
+          // Verify VLAN label and IPAM selects
+          // Type label for VLAN
+          ui.autocomplete
+            .findByLabel('VLAN')
+            .should('be.visible')
+            .click()
+            .type('testVLAN');
+
+          cy.findByText('IPAM Address').should('be.visible').click();
+          cy.findByText(
+            'IPAM address must use IP/netmask format, e.g. 192.0.2.0/24.'
+          ).should('be.visible');
+
+          // Verify VLAN error disappears
+          cy.findByText('VLAN label is required.').should('not.exist');
+
+          // Verify firewall select doees not appear
+          cy.findByText('Firewall').should('not.exist');
+
+          mockGetLinodeInterfaces(mockLinode.id, {
+            interfaces: [mockLinodeInterface],
+          });
+
+          mockGetLinodeInterfaces(mockLinode.id, {
+            interfaces: [mockLinodeInterface],
+          });
+
+          ui.button
+            .findByAttribute('type', 'submit')
+            .should('be.enabled')
+            .click();
+        });
+
+        cy.wait('@createInterface').then((xhr) => {
+          const requestPayload = xhr.request.body;
+
+          // Confirm that request payload includes a VLAN interface only
+          expect(requestPayload['public']).to.equal(null);
+          expect(requestPayload['vpc']).to.equal(null);
+          expect(requestPayload['vlan']).to.be.an('object');
+        });
+
+        ui.toast.assertMessage('Successfully added network interface.');
+
+        // Verify the interface row shows upon creation
+        cy.findByText(mockLinodeInterface.mac_address)
+          .closest('tr')
+          .within(() => {
+            // Verify we fetch the interfaces firewalls and the label shows
+            cy.wait('@getInterfaceFirewalls');
+            cy.findByText('None').should('be.visible');
+
+            // Verify the interface type shows
+            cy.findByText('VLAN').should('be.visible');
+          });
+      });
+
+      it('allows the user to add a public network interface with a firewall', () => {
+        const mockLinodeInterface = linodeInterfaceFactoryPublic.build();
+        const selectedMockFirewall = mockFirewalls[1];
 
-        // Verify the interface type shows
-        cy.findByText('Public').should('be.visible');
+        mockGetFirewalls(mockFirewalls).as('getFirewalls');
+        mockCreateLinodeInterface(mockLinode.id, mockLinodeInterface).as(
+          'createInterface'
+        );
+        mockGetLinodeInterfaceFirewalls(mockLinode.id, mockLinodeInterface.id, [
+          selectedMockFirewall,
+        ]).as('getInterfaceFirewalls');
+
+        cy.visitWithLogin(`/linodes/${mockLinode.id}/networking`);
+
+        cy.wait(['@getLinode', '@getInterfaces']);
+
+        ui.button.findByTitle('Add Network Interface').scrollIntoView().click();
+
+        ui.drawer.findByTitle('Add Network Interface').within(() => {
+          // Verify firewalls fetch
+          cy.wait('@getFirewalls');
+
+          // Try submitting the form
+          ui.button
+            .findByAttribute('type', 'submit')
+            .should('be.enabled')
+            .click();
+
+          // Verify a validation error shows
+          cy.findByText('You must selected an Interface type.').should(
+            'be.visible'
+          );
+
+          // Select the public interface type
+          cy.findByLabelText('Public').click();
+
+          // Verify a validation error goes away
+          cy.findByText('You must selected an Interface type.').should(
+            'not.exist'
+          );
+
+          // Select a Firewall
+          ui.autocomplete.findByLabel('Firewall').click();
+          ui.autocompletePopper.findByTitle(selectedMockFirewall.label).click();
+
+          mockGetLinodeInterfaces(mockLinode.id, {
+            interfaces: [mockLinodeInterface],
+          });
+
+          ui.button
+            .findByAttribute('type', 'submit')
+            .should('be.enabled')
+            .click();
+        });
+
+        cy.wait('@createInterface').then((xhr) => {
+          const requestPayload = xhr.request.body;
+
+          // Confirm that request payload includes a Public interface only
+          expect(requestPayload['public']).to.be.an('object');
+          expect(requestPayload['vpc']).to.equal(null);
+          expect(requestPayload['vlan']).to.equal(null);
+        });
+
+        ui.toast.assertMessage('Successfully added network interface.');
+
+        // Verify the interface row shows upon creation
+        cy.findByText(mockLinodeInterface.mac_address)
+          .closest('tr')
+          .within(() => {
+            // Verify we fetch the interfaces firewalls and the label shows
+            cy.wait('@getInterfaceFirewalls');
+            cy.findByText(selectedMockFirewall.label).should('be.visible');
+
+            // Verify the interface type shows
+            cy.findByText('Public').should('be.visible');
+          });
       });
-  });
 
-  it('allows the user to add a VPC network interface with a firewall', () => {
-    const linode = linodeFactory.build({ interface_generation: 'linode' });
-    const firewalls = firewallFactory.buildList(3);
-    const subnets = subnetFactory.buildList(3);
-    const vpcs = vpcFactory.buildList(3, { subnets });
-    const linodeInterface = linodeInterfaceFactoryVPC.build();
+      it('allows the user to add a VPC network interface with a firewall', () => {
+        const linode = linodeFactory.build({ interface_generation: 'linode' });
+        const firewalls = firewallFactory.buildList(3);
+        const subnets = subnetFactory.buildList(3);
+        const vpcs = vpcFactory.buildList(3, { subnets });
+        const linodeInterface = linodeInterfaceFactoryVPC.build();
 
-    const selectedFirewall = firewalls[1];
-    const selectedVPC = vpcs[1];
-    const selectedSubnet = selectedVPC.subnets[0];
+        const selectedFirewall = firewalls[1];
+        const selectedVPC = vpcs[1];
+        const selectedSubnet = selectedVPC.subnets[0];
 
-    mockGetLinodeDetails(linode.id, linode).as('getLinode');
-    mockGetLinodeInterfaces(linode.id, { interfaces: [] }).as('getInterfaces');
-    mockGetFirewalls(firewalls).as('getFirewalls');
-    mockGetVPCs(vpcs).as('getVPCs');
-    mockCreateLinodeInterface(linode.id, linodeInterface).as('createInterface');
-    mockGetLinodeInterfaceFirewalls(linode.id, linodeInterface.id, [
-      selectedFirewall,
-    ]).as('getInterfaceFirewalls');
+        mockGetLinodeDetails(linode.id, linode).as('getLinode');
+        mockGetLinodeInterfaces(linode.id, { interfaces: [] }).as(
+          'getInterfaces'
+        );
+        mockGetFirewalls(firewalls).as('getFirewalls');
+        mockGetVPCs(vpcs).as('getVPCs');
+        mockCreateLinodeInterface(linode.id, linodeInterface).as(
+          'createInterface'
+        );
+        mockGetLinodeInterfaceFirewalls(linode.id, linodeInterface.id, [
+          selectedFirewall,
+        ]).as('getInterfaceFirewalls');
 
-    cy.visitWithLogin(`/linodes/${linode.id}/networking`);
+        cy.visitWithLogin(`/linodes/${linode.id}/networking`);
 
-    cy.wait(['@getLinode', '@getInterfaces']);
+        cy.wait(['@getLinode', '@getInterfaces']);
 
-    ui.button.findByTitle('Add Network Interface').scrollIntoView().click();
+        ui.button.findByTitle('Add Network Interface').scrollIntoView().click();
 
-    ui.drawer.findByTitle('Add Network Interface').within(() => {
-      // Verify firewalls fetch
-      cy.wait('@getFirewalls');
+        ui.drawer.findByTitle('Add Network Interface').within(() => {
+          // Verify firewalls fetch
+          cy.wait('@getFirewalls');
 
-      cy.findByLabelText('VPC').click();
+          cy.findByLabelText('VPC').click();
 
-      // Verify VPCs fetch
-      cy.wait('@getVPCs');
+          // Verify VPCs fetch
+          cy.wait('@getVPCs');
 
-      // Select a VPC
-      ui.autocomplete.findByLabel('VPC').click();
-      ui.autocompletePopper.findByTitle(selectedVPC.label).click();
+          // Select a VPC
+          ui.autocomplete.findByLabel('VPC').click();
+          ui.autocompletePopper.findByTitle(selectedVPC.label).click();
 
-      // Select a Firewall
-      ui.autocomplete.findByLabel('Firewall').click();
-      ui.autocompletePopper.findByTitle(selectedFirewall.label).click();
+          // Select a Firewall
+          ui.autocomplete.findByLabel('Firewall').click();
+          ui.autocompletePopper.findByTitle(selectedFirewall.label).click();
 
-      // Submit the form
-      ui.button.findByAttribute('type', 'submit').should('be.enabled').click();
+          // Submit the form
+          ui.button
+            .findByAttribute('type', 'submit')
+            .should('be.enabled')
+            .click();
 
-      // Verify an error shows because a subnet is not selected
-      cy.findByText('Subnet is required.').should('be.visible');
+          // Verify an error shows because a subnet is not selected
+          cy.findByText('Subnet is required.').should('be.visible');
 
-      // Select a Subnet
-      ui.autocomplete.findByLabel('Subnet').click();
-      ui.autocompletePopper
-        .findByTitle(`${selectedSubnet.label} (${selectedSubnet.ipv4})`)
-        .click();
+          // Select a Subnet
+          ui.autocomplete.findByLabel('Subnet').click();
+          ui.autocompletePopper
+            .findByTitle(`${selectedSubnet.label} (${selectedSubnet.ipv4})`)
+            .click();
 
-      // Verify the error goes away
-      cy.findByText('Subnet is required.').should('not.exist');
+          // Verify the error goes away
+          cy.findByText('Subnet is required.').should('not.exist');
 
-      mockGetLinodeInterfaces(linode.id, { interfaces: [linodeInterface] });
+          mockGetLinodeInterfaces(linode.id, { interfaces: [linodeInterface] });
 
-      ui.button.findByAttribute('type', 'submit').should('be.enabled').click();
-    });
+          ui.button
+            .findByAttribute('type', 'submit')
+            .should('be.enabled')
+            .click();
+        });
 
-    cy.wait('@createInterface').then((xhr) => {
-      const requestPayload = xhr.request.body;
+        cy.wait('@createInterface').then((xhr) => {
+          const requestPayload = xhr.request.body;
+
+          // Confirm that request payload includes VPC interface only
+          expect(requestPayload['public']).to.equal(null);
+          expect(requestPayload['vpc']['subnet_id']).to.equal(
+            selectedSubnet.id
+          );
+          expect(requestPayload['vlan']).to.equal(null);
+        });
 
-      // Confirm that request payload includes VPC interface only
-      expect(requestPayload['public']).to.equal(null);
-      expect(requestPayload['vpc']['subnet_id']).to.equal(selectedSubnet.id);
-      expect(requestPayload['vlan']).to.equal(null);
+        ui.toast.assertMessage('Successfully added network interface.');
+
+        // Verify the interface row shows upon creation
+        cy.findByText(linodeInterface.mac_address)
+          .closest('tr')
+          .within(() => {
+            // Verify we fetch the interfaces firewalls and the label shows
+            cy.wait('@getInterfaceFirewalls');
+            cy.findByText(selectedFirewall.label).should('be.visible');
+
+            // Verify the interface type shows
+            cy.findByText('VPC').should('be.visible');
+          });
+      });
     });
 
-    ui.toast.assertMessage('Successfully added network interface.');
+    describe('Interface Details drawer', () => {
+      it('confirms the details drawer for a public interface', () => {
+        const linodeInterface = linodeInterfaceFactoryPublic.build({
+          public: {
+            ipv6: {
+              ranges: [
+                {
+                  range: '2600:3c06:e001:149::/64',
+                  route_target: null,
+                },
+                {
+                  range: '2600:3c06:e001:149::/56',
+                  route_target: null,
+                },
+              ],
+              shared: [],
+              slaac: [
+                { address: '2600:3c06::2000:13ff:fe6b:31b0', prefix: '64' },
+              ],
+            },
+          },
+        });
+        mockGetLinodeInterfaces(mockLinode.id, {
+          interfaces: [linodeInterface],
+        }).as('getInterfaces');
+        mockGetLinodeInterface(
+          mockLinode.id,
+          linodeInterface.id,
+          linodeInterface
+        );
 
-    // Verify the interface row shows upon creation
-    cy.findByText(linodeInterface.mac_address)
-      .closest('tr')
-      .within(() => {
-        // Verify we fetch the interfaces firewalls and the label shows
-        cy.wait('@getInterfaceFirewalls');
-        cy.findByText(selectedFirewall.label).should('be.visible');
+        cy.visitWithLogin(`/linodes/${mockLinode.id}/networking`);
+
+        // Open up the detail drawer
+        cy.findByText(linodeInterface.mac_address)
+          .should('be.visible')
+          .closest('tr')
+          .within(() => {
+            ui.actionMenu
+              .findByTitle(
+                `Action menu for Public Interface (${linodeInterface.id})`
+              )
+              .should('be.visible')
+              .should('be.enabled')
+              .click();
+
+            ui.actionMenuItem
+              .findByTitle('Details')
+              .should('be.visible')
+              .should('be.enabled')
+              .click();
+          });
+
+        // Confirm drawer content
+        ui.drawer
+          .findByTitle(`Network Interface Details (ID: ${linodeInterface.id})`)
+          .within(() => {
+            cy.findByText('IPv4 Default Route').should('be.visible');
+            cy.findByText('IPv6 Default Route').should('be.visible');
+            cy.findByText('Type').should('be.visible');
+            cy.findByText('Public').should('be.visible');
+            cy.findByText('IPv4 Addresses').should('be.visible');
+            cy.findByText(
+              `${linodeInterface.public?.ipv4.addresses[0].address} (Primary)`
+            ).should('be.visible');
+            cy.findByText('2600:3c06::2000:13ff:fe6b:31b0 (SLAAC)').should(
+              'be.visible'
+            );
+            cy.findByText('2600:3c06:e001:149::/64 (Range)').should(
+              'be.visible'
+            );
+            cy.findByText('2600:3c06:e001:149::/56 (Range)').should(
+              'be.visible'
+            );
+          });
+      });
+
+      it('confirms the details drawer for a VLAN interface', () => {
+        const linodeInterface = linodeInterfaceFactoryVlan.build();
+        mockGetLinodeInterfaces(mockLinode.id, {
+          interfaces: [linodeInterface],
+        }).as('getInterfaces');
+        mockGetLinodeInterface(
+          mockLinode.id,
+          linodeInterface.id,
+          linodeInterface
+        );
+
+        cy.visitWithLogin(`/linodes/${mockLinode.id}/networking`);
 
-        // Verify the interface type shows
-        cy.findByText('VPC').should('be.visible');
+        // Open up the detail drawer
+        cy.findByText(linodeInterface.mac_address)
+          .should('be.visible')
+          .closest('tr')
+          .within(() => {
+            ui.actionMenu
+              .findByTitle(
+                `Action menu for VLAN Interface (${linodeInterface.id})`
+              )
+              .should('be.visible')
+              .should('be.enabled')
+              .click();
+
+            ui.actionMenuItem
+              .findByTitle('Details')
+              .should('be.visible')
+              .should('be.enabled')
+              .click();
+          });
+
+        // Confirm drawer content
+        ui.drawer
+          .findByTitle(`Network Interface Details (ID: ${linodeInterface.id})`)
+          .within(() => {
+            cy.findByText('Type').should('be.visible');
+            cy.findByText('VLAN').should('be.visible');
+            cy.findByText('VLAN Label').should('be.visible');
+            cy.findByText(`${linodeInterface.vlan?.vlan_label}`).should(
+              'be.visible'
+            );
+            cy.findByText('IPAM Address').should('be.visible');
+            cy.findByText(`${linodeInterface.vlan?.ipam_address}`).should(
+              'be.visible'
+            );
+          });
+      });
+
+      it('confirms the details drawer for a VPC interface', () => {
+        const linodeInterface = linodeInterfaceFactoryVPC.build();
+        const mockSubnet = subnetFactory.build({
+          id: linodeInterface.vpc?.subnet_id,
+        });
+        const mockVPC = vpcFactory.build({
+          id: linodeInterface.vpc?.vpc_id,
+          subnets: [mockSubnet],
+        });
+
+        mockGetVPC(mockVPC);
+        mockGetLinodeInterfaces(mockLinode.id, {
+          interfaces: [linodeInterface],
+        }).as('getInterfaces');
+        mockGetLinodeInterface(
+          mockLinode.id,
+          linodeInterface.id,
+          linodeInterface
+        );
+
+        cy.visitWithLogin(`/linodes/${mockLinode.id}/networking`);
+
+        // Open up the details drawer
+        cy.findByText(linodeInterface.mac_address)
+          .should('be.visible')
+          .closest('tr')
+          .within(() => {
+            ui.actionMenu
+              .findByTitle(
+                `Action menu for VPC Interface (${linodeInterface.id})`
+              )
+              .should('be.visible')
+              .should('be.enabled')
+              .click();
+
+            ui.actionMenuItem
+              .findByTitle('Details')
+              .should('be.visible')
+              .should('be.enabled')
+              .click();
+          });
+
+        // Confirm drawer content
+        ui.drawer
+          .findByTitle(`Network Interface Details (ID: ${linodeInterface.id})`)
+          .within(() => {
+            cy.findByText('IPv4 Default Route').should('be.visible');
+            cy.findByText('Type').should('be.visible');
+            cy.findByText('VPC').should('be.visible');
+            cy.findByText('VPC Label').should('be.visible');
+            cy.findByText(`${mockVPC.label}`).should('be.visible');
+            cy.findByText('Subnet Label').should('be.visible');
+            cy.findByText(`${mockSubnet.label}`).should('be.visible');
+            cy.findByText('IPv4 Addresses').should('be.visible');
+          });
       });
+    });
   });
 });
diff --git a/packages/manager/cypress/e2e/core/objectStorageMulticluster/object-storage-objects-multicluster.spec.ts b/packages/manager/cypress/e2e/core/objectStorageMulticluster/object-storage-objects-multicluster.spec.ts
index 38e6e618872..95814459df2 100644
--- a/packages/manager/cypress/e2e/core/objectStorageMulticluster/object-storage-objects-multicluster.spec.ts
+++ b/packages/manager/cypress/e2e/core/objectStorageMulticluster/object-storage-objects-multicluster.spec.ts
@@ -45,12 +45,13 @@ const getNonEmptyBucketMessage = (bucketLabel: string) => {
 const setUpBucketMulticluster = (
   label: string,
   regionId: string,
-  cors_enabled: boolean = true
+  cors_enabled: boolean = false
 ) => {
   return createBucket(
     createObjectStorageBucketFactoryGen1.build({
       // to avoid 400 responses from the API.
       cluster: undefined,
+      // disable CORS to avoid 400 responses from the API.
       cors_enabled,
       label,
 
@@ -153,6 +154,21 @@ describe('Object Storage Multicluster objects', () => {
       { name: '2.jpg', path: 'object-storage-files/2.jpg' },
     ];
 
+    cy.on('fail', (err) => {
+      if (
+        err.name === 'CypressError' &&
+        err.message.includes('uploadObject') &&
+        err.message.includes('Timed out')
+      ) {
+        // Handle the timeout error and retry
+        uploadFile(bucketFiles[0].path, bucketFiles[0].name);
+        cy.wait('@uploadObject', { timeout: 160000 });
+        // Return false to prevent test failure
+        return false;
+      }
+      throw err;
+    });
+
     cy.defer(
       () => setUpBucketMulticluster(bucketLabel, bucketRegionId),
       'creating Object Storage bucket'
diff --git a/packages/manager/cypress/e2e/core/vpc/vpc-create.spec.ts b/packages/manager/cypress/e2e/core/vpc/vpc-create.spec.ts
index ef9807d401d..fd95d254972 100644
--- a/packages/manager/cypress/e2e/core/vpc/vpc-create.spec.ts
+++ b/packages/manager/cypress/e2e/core/vpc/vpc-create.spec.ts
@@ -110,7 +110,7 @@ describe('VPC create flow', () => {
         cy.findByText('Subnet Label').should('be.visible').click();
         cy.focused().type(mockSubnets[0].label);
 
-        cy.findByText('Subnet IP Address Range').should('be.visible').click();
+        cy.findByText('Subnet IPv4 Range (CIDR)').should('be.visible').click();
         cy.focused().type(`{selectAll}{backspace}`);
       });
 
@@ -123,7 +123,7 @@ describe('VPC create flow', () => {
     cy.findByText(ipValidationErrorMessage1).should('be.visible');
 
     // Enter a random non-IP address string to further test client side validation.
-    cy.findByText('Subnet IP Address Range').should('be.visible').click();
+    cy.findByText('Subnet IPv4 Range (CIDR)').should('be.visible').click();
     cy.focused().type(`{selectAll}{backspace}`);
     cy.focused().type(randomString(18));
 
@@ -136,7 +136,7 @@ describe('VPC create flow', () => {
     cy.findByText(ipValidationErrorMessage2).should('be.visible');
 
     // Enter a valid IP address with an invalid network prefix to further test client side validation.
-    cy.findByText('Subnet IP Address Range').should('be.visible').click();
+    cy.findByText('Subnet IPv4 Range (CIDR)').should('be.visible').click();
     cy.focused().type(`{selectAll}{backspace}`);
     cy.focused().type(mockInvalidIpRange);
 
@@ -149,7 +149,7 @@ describe('VPC create flow', () => {
     cy.findByText(ipValidationErrorMessage2).should('be.visible');
 
     // Replace invalid IP address range with valid range.
-    cy.findByText('Subnet IP Address Range').should('be.visible').click();
+    cy.findByText('Subnet IPv4 Range (CIDR)').should('be.visible').click();
     cy.focused().type(`{selectAll}{backspace}`);
     cy.focused().type(mockSubnets[0].ipv4!);
 
@@ -165,7 +165,7 @@ describe('VPC create flow', () => {
     getSubnetNodeSection(1)
       .should('be.visible')
       .within(() => {
-        cy.findByText('Subnet IP Address Range').should('be.visible').click();
+        cy.findByText('Subnet IPv4 Range (CIDR)').should('be.visible').click();
         cy.focused().type(`{selectAll}{backspace}`);
         cy.focused().type(mockSubnetToDelete.ipv4!);
       });
@@ -209,7 +209,9 @@ describe('VPC create flow', () => {
           cy.findByText('Subnet Label').should('be.visible').click();
           cy.focused().type(mockSubnet.label);
 
-          cy.findByText('Subnet IP Address Range').should('be.visible').click();
+          cy.findByText('Subnet IPv4 Range (CIDR)')
+            .should('be.visible')
+            .click();
           cy.focused().type(`{selectAll}{backspace}`);
           cy.focused().type(`${randomIp()}/${randomNumber(0, 32)}`);
         });
diff --git a/packages/manager/cypress/e2e/core/vpc/vpc-landing-page.spec.ts b/packages/manager/cypress/e2e/core/vpc/vpc-landing-page.spec.ts
index 50ad31d690a..62f9e387b66 100644
--- a/packages/manager/cypress/e2e/core/vpc/vpc-landing-page.spec.ts
+++ b/packages/manager/cypress/e2e/core/vpc/vpc-landing-page.spec.ts
@@ -28,25 +28,28 @@ describe('VPC landing page', () => {
     cy.wait('@getVPCs');
 
     // Confirm each VPC is listed with expected data.
-    mockVPCs.forEach((mockVPC) => {
-      const regionLabel = getRegionById(mockVPC.region).label;
-      cy.findByText(mockVPC.label)
-        .should('be.visible')
-        .closest('tr')
-        .within(() => {
-          cy.findByText(regionLabel).should('be.visible');
-
-          ui.button
-            .findByTitle('Edit')
-            .should('be.visible')
-            .should('be.enabled');
-
-          ui.button
-            .findByTitle('Delete')
-            .should('be.visible')
-            .should('be.enabled');
-        });
-    });
+    const regionLabel = getRegionById(mockVPCs[0].region).label;
+    cy.findByText(mockVPCs[0].label)
+      .should('be.visible')
+      .closest('tr')
+      .within(() => {
+        cy.findByText(regionLabel).should('be.visible');
+
+        ui.actionMenu
+          .findByTitle(`Action menu for VPC ${mockVPCs[0].label}`)
+          .should('be.visible')
+          .click();
+
+        ui.actionMenuItem
+          .findByTitle('Edit')
+          .should('be.visible')
+          .should('be.enabled');
+
+        ui.actionMenuItem
+          .findByTitle('Delete')
+          .should('be.visible')
+          .should('be.enabled');
+      });
   });
 
   /*
@@ -112,7 +115,11 @@ describe('VPC landing page', () => {
       .should('be.visible')
       .closest('tr')
       .within(() => {
-        ui.button.findByTitle('Edit').should('be.visible').click();
+        ui.actionMenu
+          .findByTitle(`Action menu for VPC ${mockVPCs[1].label}`)
+          .should('be.visible')
+          .click();
+        ui.actionMenuItem.findByTitle('Edit').should('be.visible').click();
       });
 
     // Confirm correct information is shown and update label and description.
@@ -149,7 +156,11 @@ describe('VPC landing page', () => {
       .should('be.visible')
       .closest('tr')
       .within(() => {
-        ui.button.findByTitle('Edit').should('be.visible').click();
+        ui.actionMenu
+          .findByTitle(`Action menu for VPC ${mockUpdatedVPC.label}`)
+          .should('be.visible')
+          .click();
+        ui.actionMenuItem.findByTitle('Edit').should('be.visible').click();
       });
 
     ui.drawer
@@ -179,7 +190,11 @@ describe('VPC landing page', () => {
       .should('be.visible')
       .closest('tr')
       .within(() => {
-        ui.button
+        ui.actionMenu
+          .findByTitle(`Action menu for VPC ${mockVPCs[0].label}`)
+          .should('be.visible')
+          .click();
+        ui.actionMenuItem
           .findByTitle('Delete')
           .should('be.visible')
           .should('be.enabled')
@@ -192,7 +207,6 @@ describe('VPC landing page', () => {
       .within(() => {
         cy.findByLabelText('VPC Label').should('be.visible').click();
         cy.focused().type(mockVPCs[0].label);
-
         ui.button
           .findByTitle('Delete')
           .should('be.visible')
@@ -211,7 +225,11 @@ describe('VPC landing page', () => {
       .should('be.visible')
       .closest('tr')
       .within(() => {
-        ui.button
+        ui.actionMenu
+          .findByTitle(`Action menu for VPC ${mockVPCs[1].label}`)
+          .should('be.visible')
+          .click();
+        ui.actionMenuItem
           .findByTitle('Delete')
           .should('be.visible')
           .should('be.enabled')
@@ -269,7 +287,11 @@ describe('VPC landing page', () => {
       .should('be.visible')
       .closest('tr')
       .within(() => {
-        ui.button
+        ui.actionMenu
+          .findByTitle(`Action menu for VPC ${mockVPCs[0].label}`)
+          .should('be.visible')
+          .click();
+        ui.actionMenuItem
           .findByTitle('Delete')
           .should('be.visible')
           .should('be.enabled')
@@ -312,7 +334,11 @@ describe('VPC landing page', () => {
       .should('be.visible')
       .closest('tr')
       .within(() => {
-        ui.button
+        ui.actionMenu
+          .findByTitle(`Action menu for VPC ${mockVPCs[1].label}`)
+          .should('be.visible')
+          .click();
+        ui.actionMenuItem
           .findByTitle('Delete')
           .should('be.visible')
           .should('be.enabled')
diff --git a/packages/manager/cypress/e2e/core/vpc/vpc-linodes-update.spec.ts b/packages/manager/cypress/e2e/core/vpc/vpc-linodes-update.spec.ts
index 566894a6d20..6a5615b8c00 100644
--- a/packages/manager/cypress/e2e/core/vpc/vpc-linodes-update.spec.ts
+++ b/packages/manager/cypress/e2e/core/vpc/vpc-linodes-update.spec.ts
@@ -140,7 +140,7 @@ describe('VPC assign/unassign flows', () => {
       .click();
 
     ui.drawer
-      .findByTitle(`Assign Linodes to subnet: ${mockSubnet.label} (0.0.0.0/0)`)
+      .findByTitle(`Assign Linodes to subnet: ${mockSubnet.label}`)
       .should('be.visible')
       .within(() => {
         // confirm that the user is warned that a reboot / shutdown is required
@@ -167,7 +167,7 @@ describe('VPC assign/unassign flows', () => {
           .click();
 
         // Auto-assign IPv4 checkbox checked by default
-        cy.findByLabelText('Auto-assign VPC IPv4 address').should('be.checked');
+        cy.findByLabelText('Auto-assign VPC IPv4').should('be.checked');
 
         cy.wait('@getLinodeConfigs');
 
@@ -277,7 +277,7 @@ describe('VPC assign/unassign flows', () => {
       .click();
 
     ui.drawer
-      .findByTitle(`Assign Linodes to subnet: ${mockSubnet.label} (0.0.0.0/0)`)
+      .findByTitle(`Assign Linodes to subnet: ${mockSubnet.label}`)
       .should('be.visible')
       .within(() => {
         // confirm that the user is warned that a reboot / shutdown is required
@@ -304,9 +304,7 @@ describe('VPC assign/unassign flows', () => {
           .click();
 
         // Uncheck auto-assign checkbox and type in VPC IPv4
-        cy.findByLabelText('Auto-assign VPC IPv4 address')
-          .should('be.checked')
-          .click();
+        cy.findByLabelText('Auto-assign VPC IPv4').should('be.checked').click();
         cy.findByLabelText('VPC IPv4').should('be.visible').click();
         cy.focused().type(mockVPCInterface.ipv4?.vpc ?? '10.0.0.7');
 
diff --git a/packages/manager/cypress/support/intercepts/cloudpulse.ts b/packages/manager/cypress/support/intercepts/cloudpulse.ts
index 27a8236571f..da8a091b869 100644
--- a/packages/manager/cypress/support/intercepts/cloudpulse.ts
+++ b/packages/manager/cypress/support/intercepts/cloudpulse.ts
@@ -595,4 +595,4 @@ export const mockGetCloudPulseServiceByType = (
     apiMatcher(`monitor/services/${serviceType}`),
     makeResponse(service)
   );
-};
\ No newline at end of file
+};
diff --git a/packages/manager/cypress/support/intercepts/linodes.ts b/packages/manager/cypress/support/intercepts/linodes.ts
index 28516154615..26c50864836 100644
--- a/packages/manager/cypress/support/intercepts/linodes.ts
+++ b/packages/manager/cypress/support/intercepts/linodes.ts
@@ -684,6 +684,27 @@ export const mockGetLinodeInterfaces = (
   );
 };
 
+/**
+ * Mocks GET request to get a single Linode Interface.
+ *
+ * @param linodeId - ID of Linode to get interface associated with it
+ * @param interfaceId - ID of interface to get
+ * @param interfaces - the mocked Linode interface
+ *
+ * @returns Cypress Chainable.
+ */
+export const mockGetLinodeInterface = (
+  linodeId: number,
+  interfaceId: number,
+  linodeInterface: LinodeInterface
+): Cypress.Chainable => {
+  return cy.intercept(
+    'GET',
+    apiMatcher(`linode/instances/${linodeId}/interfaces/${interfaceId}`),
+    linodeInterface
+  );
+};
+
 /**
  * Intercepts POST request to create a Linode Interface.
  *
diff --git a/packages/manager/cypress/support/intercepts/lke.ts b/packages/manager/cypress/support/intercepts/lke.ts
index 3890968e67d..4c2b6935e9a 100644
--- a/packages/manager/cypress/support/intercepts/lke.ts
+++ b/packages/manager/cypress/support/intercepts/lke.ts
@@ -7,7 +7,6 @@ import {
   kubernetesDashboardUrlFactory,
 } from '@src/factories';
 import {
-  kubernetesVersions,
   latestEnterpriseTierKubernetesVersion,
   latestStandardTierKubernetesVersion,
 } from 'support/constants/lke';
@@ -24,32 +23,9 @@ import type {
   KubernetesControlPlaneACLPayload,
   KubernetesTier,
   KubernetesTieredVersion,
-  KubernetesVersion,
   PriceType,
 } from '@linode/api-v4';
 
-// TODO M3-10442: Examine `mockGetKubernetesVersions` and consider modifying/adding alternative util that mocks response containing tiered version objects.
-/**
- * Intercepts GET request to retrieve Kubernetes versions and mocks response.
- *
- * @param versions - Optional array of strings containing mocked versions.
- *
- * @returns Cypress chainable.
- */
-export const mockGetKubernetesVersions = (versions?: string[] | undefined) => {
-  const versionObjects = (versions ? versions : kubernetesVersions).map(
-    (kubernetesVersionString: string): KubernetesVersion => {
-      return { id: kubernetesVersionString };
-    }
-  );
-
-  return cy.intercept(
-    'GET',
-    apiMatcher('lke/versions*'),
-    paginateResponse(versionObjects)
-  );
-};
-
 /**
  * Intercepts GET request to retrieve tiered Kubernetes versions and mocks response.
  *
diff --git a/packages/manager/cypress/support/plugins/node-version-check.ts b/packages/manager/cypress/support/plugins/node-version-check.ts
index bda06bd4cec..f01b43bd0d9 100644
--- a/packages/manager/cypress/support/plugins/node-version-check.ts
+++ b/packages/manager/cypress/support/plugins/node-version-check.ts
@@ -2,7 +2,7 @@ import type { CypressPlugin } from './plugin';
 
 // Supported major versions of Node.js.
 // Running Cypress using other versions will cause a warning to be displayed.
-const supportedVersions = [18, 20];
+const supportedVersions = [20, 22, 24];
 
 /**
  * Returns a string describing the version of Node.js that is running the tests.
diff --git a/packages/manager/cypress/support/ui/pages/vpc-create-drawer.ts b/packages/manager/cypress/support/ui/pages/vpc-create-drawer.ts
index 20c5635dc4b..9713b2e5267 100644
--- a/packages/manager/cypress/support/ui/pages/vpc-create-drawer.ts
+++ b/packages/manager/cypress/support/ui/pages/vpc-create-drawer.ts
@@ -57,7 +57,7 @@ export const vpcCreateDrawer = {
    * @param subnetIndex - Optional index of subnet for which to update IP range.
    */
   setSubnetIpRange: (subnetIpRange: string, subnetIndex: number = 0) => {
-    cy.findByText('Subnet IP Address Range', {
+    cy.findByText('Subnet IPv4 Range (CIDR)', {
       selector: `label[for="subnet-ipv4-${subnetIndex}"]`,
     })
       .should('be.visible')
diff --git a/packages/manager/package.json b/packages/manager/package.json
index 2ce25a30033..6232508998d 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.151.2",
+  "version": "1.152.0",
   "private": true,
   "type": "module",
   "bugs": {
@@ -145,7 +145,7 @@
     "@types/markdown-it": "^14.1.2",
     "@types/md5": "^2.1.32",
     "@types/mocha": "^10.0.2",
-    "@types/node": "^20.17.0",
+    "@types/node": "^22.13.14",
     "@types/ramda": "0.25.16",
     "@types/react": "^19.1.6",
     "@types/react-csv": "^1.1.3",
diff --git a/packages/manager/public/assets/cribl.svg b/packages/manager/public/assets/cribl.svg
new file mode 100644
index 00000000000..7f1999c6f4f
--- /dev/null
+++ b/packages/manager/public/assets/cribl.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/manager/public/assets/jaeger.svg b/packages/manager/public/assets/jaeger.svg
new file mode 100644
index 00000000000..5afce684c30
--- /dev/null
+++ b/packages/manager/public/assets/jaeger.svg
@@ -0,0 +1,90 @@
+
+
+  
+  
+    
+  
+  
+    
+      
+      
+      
+      
+      
+      
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+  
+  
+  
+
\ No newline at end of file
diff --git a/packages/manager/public/assets/white/cribl.svg b/packages/manager/public/assets/white/cribl.svg
new file mode 100644
index 00000000000..1024e1ae3ec
--- /dev/null
+++ b/packages/manager/public/assets/white/cribl.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/manager/public/assets/white/jaeger.svg b/packages/manager/public/assets/white/jaeger.svg
new file mode 100644
index 00000000000..aa15ba8a690
--- /dev/null
+++ b/packages/manager/public/assets/white/jaeger.svg
@@ -0,0 +1,79 @@
+
+
+  
+  
+    
+  
+  
+    
+    
+    
+    
+    
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+    
+    
+    
+    
+  
+  
+  
+  
+
\ No newline at end of file
diff --git a/packages/manager/src/App.tsx b/packages/manager/src/App.tsx
index ace9e20e6e7..cac1e8d2b6c 100644
--- a/packages/manager/src/App.tsx
+++ b/packages/manager/src/App.tsx
@@ -20,6 +20,9 @@ export const App = withDocumentTitleProvider(
       window.location.pathname === '/oauth/callback' ||
       window.location.pathname === '/admin/callback';
 
+    const { isLoading } = useInitialRequests();
+    const { areFeatureFlagsLoading } = useSetupFeatureFlags();
+
     if (isAuthCallback) {
       return (
         
@@ -29,9 +32,6 @@ export const App = withDocumentTitleProvider(
       );
     }
 
-    const { isLoading } = useInitialRequests();
-    const { areFeatureFlagsLoading } = useSetupFeatureFlags();
-
     if (isLoading || areFeatureFlagsLoading) {
       return ;
     }
diff --git a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx
index 03aa8db3ff5..e618d8103a0 100644
--- a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx
+++ b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx
@@ -40,7 +40,6 @@ export type NavEntity =
   | 'Cloud Load Balancers'
   | 'Dashboard'
   | 'Databases'
-  | 'Delivery'
   | 'Domains'
   | 'Firewalls'
   | 'Help & Support'
@@ -49,6 +48,7 @@ export type NavEntity =
   | 'Kubernetes'
   | 'Linodes'
   | 'Login History'
+  | 'Logs'
   | 'Longview'
   | 'Maintenance'
   | 'Managed'
@@ -240,7 +240,7 @@ export const PrimaryNav = (props: PrimaryNavProps) => {
                 to: '/longview',
               },
               {
-                display: 'Delivery',
+                display: 'Logs',
                 hide: !flags.aclpLogs?.enabled,
                 to: '/logs/delivery',
                 isBeta: flags.aclpLogs?.beta,
diff --git a/packages/manager/src/dev-tools/FeatureFlagTool.tsx b/packages/manager/src/dev-tools/FeatureFlagTool.tsx
index d3e9336f14c..4b22c2b28a3 100644
--- a/packages/manager/src/dev-tools/FeatureFlagTool.tsx
+++ b/packages/manager/src/dev-tools/FeatureFlagTool.tsx
@@ -54,6 +54,7 @@ const options: { flag: keyof Flags; label: string }[] = [
   },
   { flag: 'apicliButtonCopy', label: 'APICLI Button Copy' },
   { flag: 'iam', label: 'Identity and Access Beta' },
+  { flag: 'iamDelegation', label: 'IAM Delegation (Parent/Child)' },
   { flag: 'iamRbacPrimaryNavChanges', label: 'IAM Primary Nav Changes' },
   {
     flag: 'linodeCloneFirewall',
diff --git a/packages/manager/src/dev-tools/load.ts b/packages/manager/src/dev-tools/load.ts
index 489e4989c09..eb6079b3e23 100644
--- a/packages/manager/src/dev-tools/load.ts
+++ b/packages/manager/src/dev-tools/load.ts
@@ -79,6 +79,14 @@ export async function loadDevTools() {
     // Merge the contexts
     const mergedContext: MockState = {
       ...initialContext,
+      childAccounts: [
+        ...initialContext.childAccounts,
+        ...(seedContext?.childAccounts || []),
+      ],
+      delegations: [
+        ...initialContext.delegations,
+        ...(seedContext?.delegations || []),
+      ],
       domains: [...initialContext.domains, ...(seedContext?.domains || [])],
       eventQueue: [
         ...initialContext.eventQueue,
@@ -92,6 +100,7 @@ export async function loadDevTools() {
         ...initialContext.firewalls,
         ...(seedContext?.firewalls || []),
       ],
+      entities: [...initialContext.entities, ...(seedContext?.entities || [])],
       kubernetesClusters: [
         ...initialContext.kubernetesClusters,
         ...(seedContext?.kubernetesClusters || []),
diff --git a/packages/manager/src/factories/cloudpulse/services.ts b/packages/manager/src/factories/cloudpulse/services.ts
index 6e9c00f9eda..b794146457a 100644
--- a/packages/manager/src/factories/cloudpulse/services.ts
+++ b/packages/manager/src/factories/cloudpulse/services.ts
@@ -8,6 +8,7 @@ const serviceTypes: CloudPulseServiceType[] = [
   'nodebalancer',
   'dbaas',
   'firewall',
+  'objectstorage',
 ];
 
 export const serviceAlertFactory = Factory.Sync.makeFactory({
diff --git a/packages/manager/src/factories/delivery.ts b/packages/manager/src/factories/delivery.ts
index 3fbb843b8df..da8c1f2400b 100644
--- a/packages/manager/src/factories/delivery.ts
+++ b/packages/manager/src/factories/delivery.ts
@@ -27,7 +27,7 @@ export const streamFactory = Factory.Sync.makeFactory({
   destinations: Factory.each(() => [
     { ...destinationFactory.build(), id: 123 },
   ]),
-  details: {},
+  details: null,
   updated: '2025-07-30',
   updated_by: 'username',
   id: Factory.each((id) => id),
diff --git a/packages/manager/src/featureFlags.ts b/packages/manager/src/featureFlags.ts
index 743afea1d6e..96e3c77ae00 100644
--- a/packages/manager/src/featureFlags.ts
+++ b/packages/manager/src/featureFlags.ts
@@ -175,6 +175,7 @@ export interface Flags {
   gecko2: GeckoFeatureFlag;
   gpuv2: GpuV2;
   iam: BetaFeatureFlag;
+  iamDelegation: BaseFeatureFlag;
   iamRbacPrimaryNavChanges: boolean;
   ipv6Sharing: boolean;
   kubernetesBlackwellPlans: boolean;
@@ -182,7 +183,6 @@ export interface Flags {
   linodeCloneFirewall: boolean;
   linodeDiskEncryption: boolean;
   linodeInterfaces: LinodeInterfacesFlag;
-  lkeEnterprise: LkeEnterpriseFlag;
   lkeEnterprise2: LkeEnterpriseFlag;
   mainContentBanner: MainContentBanner;
   marketplaceAppOverrides: MarketplaceAppOverride[];
diff --git a/packages/manager/src/features/Account/SwitchAccountDrawer.tsx b/packages/manager/src/features/Account/SwitchAccountDrawer.tsx
index 33f245d5403..0bdf4754103 100644
--- a/packages/manager/src/features/Account/SwitchAccountDrawer.tsx
+++ b/packages/manager/src/features/Account/SwitchAccountDrawer.tsx
@@ -42,7 +42,7 @@ export const SwitchAccountDrawer = (props: Props) => {
 
   const {
     createToken,
-    createTokenError,
+    error: createTokenError,
     revokeToken,
     updateCurrentToken,
     validateParentToken,
diff --git a/packages/manager/src/features/Account/SwitchAccounts/ChildAccountList.tsx b/packages/manager/src/features/Account/SwitchAccounts/ChildAccountList.tsx
index ab10d75d8af..2ef7c4fe026 100644
--- a/packages/manager/src/features/Account/SwitchAccounts/ChildAccountList.tsx
+++ b/packages/manager/src/features/Account/SwitchAccounts/ChildAccountList.tsx
@@ -1,4 +1,7 @@
-import { useChildAccountsInfiniteQuery } from '@linode/queries';
+import {
+  useAllListMyDelegatedChildAccountsQuery,
+  useChildAccountsInfiniteQuery,
+} from '@linode/queries';
 import {
   Box,
   Button,
@@ -8,10 +11,11 @@ import {
   Stack,
   Typography,
 } from '@linode/ui';
-import React, { useState } from 'react';
+import React, { useMemo, useState } from 'react';
 import { Waypoint } from 'react-waypoint';
 
 import ErrorStateCloud from 'src/assets/icons/error-state-cloud.svg';
+import { useIsIAMDelegationEnabled } from 'src/features/IAM/hooks/useIsIAMEnabled';
 
 import type { Filter, UserType } from '@linode/api-v4';
 
@@ -39,6 +43,8 @@ export const ChildAccountList = React.memo(
     searchQuery,
     userType,
   }: ChildAccountListProps) => {
+    const { isIAMDelegationEnabled } = useIsIAMDelegationEnabled();
+
     const filter: Filter = {
       ['+order']: 'asc',
       ['+order_by']: 'company',
@@ -56,22 +62,54 @@ export const ChildAccountList = React.memo(
       isInitialLoading,
       isRefetching,
       refetch: refetchChildAccounts,
-    } = useChildAccountsInfiniteQuery({
-      filter,
-      headers:
-        userType === 'proxy'
-          ? {
-              Authorization: currentTokenWithBearer,
-            }
-          : undefined,
+    } = useChildAccountsInfiniteQuery(
+      {
+        filter,
+        headers:
+          userType === 'proxy'
+            ? {
+                Authorization: currentTokenWithBearer,
+              }
+            : undefined,
+      },
+      isIAMDelegationEnabled === false
+    );
+    const {
+      data: allChildAccounts,
+      error: allChildAccountsError,
+      isLoading: allChildAccountsLoading,
+      isRefetching: allChildAccountsIsRefetching,
+      refetch: refetchAllChildAccounts,
+    } = useAllListMyDelegatedChildAccountsQuery({
+      params: {},
+      enabled: isIAMDelegationEnabled,
     });
-    const childAccounts = data?.pages.flatMap((page) => page.data);
+
+    const refetchFn = isIAMDelegationEnabled
+      ? refetchAllChildAccounts
+      : refetchChildAccounts;
+
+    const childAccounts = useMemo(() => {
+      if (isIAMDelegationEnabled) {
+        if (searchQuery && allChildAccounts) {
+          // Client-side filter: match company field with searchQuery (case-insensitive, contains)
+          const normalizedQuery = searchQuery.toLowerCase();
+          return allChildAccounts.filter((account) =>
+            account.company?.toLowerCase().includes(normalizedQuery)
+          );
+        }
+        return allChildAccounts;
+      }
+      return data?.pages.flatMap((page) => page.data);
+    }, [isIAMDelegationEnabled, searchQuery, allChildAccounts, data]);
 
     if (
       isInitialLoading ||
       isLoading ||
       isSwitchingChildAccounts ||
-      isRefetching
+      isRefetching ||
+      allChildAccountsLoading ||
+      allChildAccountsIsRefetching
     ) {
       return (
         
@@ -80,7 +118,7 @@ export const ChildAccountList = React.memo(
       );
     }
 
-    if (childAccounts?.length === 0) {
+    if (childAccounts && childAccounts.length === 0) {
       return (
         
           There are no child accounts
@@ -92,7 +130,7 @@ export const ChildAccountList = React.memo(
       );
     }
 
-    if (isError) {
+    if (isError || allChildAccountsError) {
       return (
         
           
@@ -102,7 +140,7 @@ export const ChildAccountList = React.memo(
           
           
+            
+          
+        )}
+        justifyOverflowButtonRight
+        listContainerSx={{
+          width: '100%',
           overflow: 'hidden',
-          height: 24,
+          maxHeight: 24,
         }}
       >
         {items}
-      
-      {numHiddenItems > 0 && (
-        
-          
-            
-          
-        
-      )}
+      
     
   );
 };
diff --git a/packages/manager/src/features/IAM/Users/UserRoles/UserRoles.test.tsx b/packages/manager/src/features/IAM/Users/UserRoles/UserRoles.test.tsx
index a14d7e6cf9d..3565e7d17c2 100644
--- a/packages/manager/src/features/IAM/Users/UserRoles/UserRoles.test.tsx
+++ b/packages/manager/src/features/IAM/Users/UserRoles/UserRoles.test.tsx
@@ -5,7 +5,6 @@ import React from 'react';
 import { accountEntityFactory } from 'src/factories/accountEntities';
 import { accountRolesFactory } from 'src/factories/accountRoles';
 import { userRolesFactory } from 'src/factories/userRoles';
-import { makeResourcePage } from 'src/mocks/serverHandlers';
 import { renderWithTheme } from 'src/utilities/testHelpers';
 
 import {
@@ -22,7 +21,7 @@ const mockEntities = [
 ];
 
 const queryMocks = vi.hoisted(() => ({
-  useAccountEntities: vi.fn().mockReturnValue({}),
+  useAllAccountEntities: vi.fn().mockReturnValue({}),
   useParams: vi.fn().mockReturnValue({}),
   useSearch: vi.fn().mockReturnValue({}),
   useAccountRoles: vi.fn().mockReturnValue({}),
@@ -43,7 +42,7 @@ vi.mock('src/queries/entities/entities', async () => {
   const actual = await vi.importActual('src/queries/entities/entities');
   return {
     ...actual,
-    useAccountEntities: queryMocks.useAccountEntities,
+    useAllAccountEntities: queryMocks.useAllAccountEntities,
   };
 });
 
@@ -108,8 +107,8 @@ describe('UserRoles', () => {
       data: accountRolesFactory.build(),
     });
 
-    queryMocks.useAccountEntities.mockReturnValue({
-      data: makeResourcePage(mockEntities),
+    queryMocks.useAllAccountEntities.mockReturnValue({
+      data: mockEntities,
     });
 
     renderWithTheme();
@@ -140,8 +139,8 @@ describe('UserRoles', () => {
       data: accountRolesFactory.build(),
     });
 
-    queryMocks.useAccountEntities.mockReturnValue({
-      data: makeResourcePage(mockEntities),
+    queryMocks.useAllAccountEntities.mockReturnValue({
+      data: mockEntities,
     });
 
     renderWithTheme();
@@ -167,8 +166,8 @@ describe('UserRoles', () => {
       data: accountRolesFactory.build(),
     });
 
-    queryMocks.useAccountEntities.mockReturnValue({
-      data: makeResourcePage(mockEntities),
+    queryMocks.useAllAccountEntities.mockReturnValue({
+      data: mockEntities,
     });
 
     renderWithTheme();
@@ -185,8 +184,8 @@ describe('UserRoles', () => {
       data: accountRolesFactory.build(),
     });
 
-    queryMocks.useAccountEntities.mockReturnValue({
-      data: makeResourcePage(mockEntities),
+    queryMocks.useAllAccountEntities.mockReturnValue({
+      data: mockEntities,
     });
 
     renderWithTheme();
diff --git a/packages/manager/src/features/IAM/Users/UsersTable/ProxyUserTable.tsx b/packages/manager/src/features/IAM/Users/UsersTable/ProxyUserTable.tsx
deleted file mode 100644
index 6895a04a313..00000000000
--- a/packages/manager/src/features/IAM/Users/UsersTable/ProxyUserTable.tsx
+++ /dev/null
@@ -1,68 +0,0 @@
-import { useAccountUsers } from '@linode/queries';
-import { Typography } from '@linode/ui';
-import React from 'react';
-
-import { Table } from 'src/components/Table';
-import { TableBody } from 'src/components/TableBody';
-import { PARENT_USER } from 'src/features/Account/constants';
-
-import { UsersLandingProxyTableHead } from './UsersLandingProxyTableHead';
-import { UsersLandingTableBody } from './UsersLandingTableBody';
-
-import type { Order } from './UsersLandingTableHead';
-
-interface Props {
-  canListUsers: boolean | undefined;
-  handleDelete: (username: string) => void;
-  isProxyUser: boolean;
-  order: Order;
-}
-
-export const ProxyUserTable = ({
-  handleDelete,
-  isProxyUser,
-  canListUsers,
-  order,
-}: Props) => {
-  const {
-    data: proxyUser,
-    error: proxyUserError,
-    isLoading: isLoadingProxyUser,
-  } = useAccountUsers({
-    enabled: isProxyUser && canListUsers,
-    filters: { user_type: 'proxy' },
-  });
-
-  const proxyNumCols = 3;
-
-  return (
-    <>
-       ({
-          marginBottom: theme.spacing(2),
-          marginTop: theme.spacing(3),
-          textTransform: 'capitalize',
-          [theme.breakpoints.down('md')]: {
-            marginLeft: theme.spacing(1),
-          },
-        })}
-        variant="h3"
-      >
-        {PARENT_USER} Settings
-      
-
-      
-        
-        
-          
-        
-      
- - ); -}; diff --git a/packages/manager/src/features/IAM/Users/UsersTable/Users.tsx b/packages/manager/src/features/IAM/Users/UsersTable/Users.tsx index a32f4e194d8..81de8087324 100644 --- a/packages/manager/src/features/IAM/Users/UsersTable/Users.tsx +++ b/packages/manager/src/features/IAM/Users/UsersTable/Users.tsx @@ -1,6 +1,6 @@ -import { useAccountUsers, useProfile } from '@linode/queries'; +import { useAccountUsers } from '@linode/queries'; import { getAPIFilterFromQuery } from '@linode/search'; -import { Button, Paper, Stack, Typography } from '@linode/ui'; +import { Button, Paper, Stack } from '@linode/ui'; import { useMediaQuery } from '@mui/material'; import { useTheme } from '@mui/material/styles'; import { useNavigate, useSearch } from '@tanstack/react-router'; @@ -16,7 +16,6 @@ import { usePaginationV2 } from 'src/hooks/usePaginationV2'; import { usePermissions } from '../../hooks/usePermissions'; import { UserDeleteConfirmation } from '../../Shared/UserDeleteConfirmation'; import { CreateUserDrawer } from './CreateUserDrawer'; -import { ProxyUserTable } from './ProxyUserTable'; import { UsersLandingTableBody } from './UsersLandingTableBody'; import { UsersLandingTableHead } from './UsersLandingTableHead'; @@ -31,7 +30,6 @@ export const UsersLanding = () => { React.useState(false); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = React.useState(false); const [selectedUsername, setSelectedUsername] = React.useState(''); - const { data: profile } = useProfile(); const theme = useTheme(); const { data: permissions } = usePermissions('account', ['create_user']); const pagination = usePaginationV2({ @@ -50,9 +48,6 @@ export const UsersLanding = () => { preferenceKey: 'iam-account-users-order', }); - const isProxyUser = - profile?.user_type === 'child' || profile?.user_type === 'proxy'; - const queryParams = new URLSearchParams(location.search); const { error: searchError, filter } = getAPIFilterFromQuery(query, { @@ -108,14 +103,6 @@ export const UsersLanding = () => { return ( - {isProxyUser && ( - - )} ({ marginTop: theme.tokens.spacing.S16 })}> { marginBottom={2} spacing={2} > - {isProxyUser ? ( - ({ - [theme.breakpoints.down('md')]: { - marginLeft: theme.tokens.spacing.S8, - }, - })} - variant="h3" - > - User Settings - - ) : ( - - )} + - +
Firewall diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/AddInterfaceDrawer/VPC/VPCIPAddresses.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/AddInterfaceDrawer/VPC/VPCIPAddresses.tsx index a1f81c52c59..de618d4b849 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/AddInterfaceDrawer/VPC/VPCIPAddresses.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/AddInterfaceDrawer/VPC/VPCIPAddresses.tsx @@ -35,11 +35,7 @@ export const VPCIPAddresses = () => { return ( {fields.map((field, index) => ( - + ))} {isDualStackVPC && } diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/AddInterfaceDrawer/VPC/VPCIPv4Address.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/AddInterfaceDrawer/VPC/VPCIPv4Address.tsx index 908950efdd1..52e5416d7a6 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/AddInterfaceDrawer/VPC/VPCIPv4Address.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/AddInterfaceDrawer/VPC/VPCIPv4Address.tsx @@ -5,7 +5,6 @@ import { Stack, TextField, TooltipIcon, - Typography, } from '@linode/ui'; import React from 'react'; import { Controller, useFormContext } from 'react-hook-form'; @@ -17,11 +16,10 @@ import type { CreateInterfaceFormValues } from '../utilities'; interface Props { index: number; - isDualStackVPC: boolean; } export const VPCIPv4Address = (props: Props) => { - const { index, isDualStackVPC } = props; + const { index } = props; const { control, formState: { errors }, @@ -45,24 +43,11 @@ export const VPCIPv4Address = (props: Props) => { } - label="Auto-assign VPC IPv4 address" + label="Auto-assign VPC IPv4" onChange={(e, checked) => field.onChange(checked ? 'auto' : '')} sx={{ pl: 0.4, mr: 0 }} /> - - Automatically assign an IPv4 address as{' '} - {isDualStackVPC ? 'a' : 'the'} private IP address for this - Linode in the VPC. - - ) : ( - VPC_AUTO_ASSIGN_IPV4_TOOLTIP - ) - } - /> + {field.value !== 'auto' && ( { const { control, - getValues, formState: { errors }, } = useFormContext(); - const { vpc } = getValues(); - const { data: subnet } = useSubnetQuery( - vpc?.vpc_id ?? -1, - vpc?.subnet_id ?? -1, - Boolean(vpc?.vpc_id && vpc?.subnet_id) - ); - const error = errors.vpc?.ipv6?.message; return ( @@ -49,27 +41,16 @@ export const VPCIPv6Address = () => { } - label="Auto-assign VPC IPv6 address" + label="Auto-assign VPC IPv6" onChange={(e, checked) => field.onChange(checked ? 'auto' : '')} sx={{ pl: 0.4, mr: 0 }} /> - - Automatically assign an IPv6 address as a private IP address - for this Linode in the VPC. A /52 IPv6 network - prefix is allocated for the VPC. - - } - /> + {field.value !== 'auto' && ( { * We currently enforce a hard limit of one IPv4 address per VPC interface. * See VPC-2044. * - * @todo Eventually, when the API supports it, we should all the user to append/remove more VPC IPs + * @todo Eventually, when the API supports it, we should allow the user to append/remove more VPC IPs */ const { fields } = useFieldArray({ control, @@ -50,7 +50,6 @@ export const VPCIPAddresses = (props: Props) => { {fields.map((field, index) => ( diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/EditInterfaceDrawer/VPCInterface/VPCIPv4Address.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/EditInterfaceDrawer/VPCInterface/VPCIPv4Address.tsx index 9fde079098b..fc88f4b5f06 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/EditInterfaceDrawer/VPCInterface/VPCIPv4Address.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/EditInterfaceDrawer/VPCInterface/VPCIPv4Address.tsx @@ -5,7 +5,6 @@ import { Stack, TextField, TooltipIcon, - Typography, } from '@linode/ui'; import React from 'react'; import { Controller, useFormContext } from 'react-hook-form'; @@ -20,12 +19,11 @@ import type { interface Props { index: number; - isDualStackVPC: boolean; linodeInterface: LinodeInterface; } export const VPCIPv4Address = (props: Props) => { - const { index, linodeInterface, isDualStackVPC } = props; + const { index, linodeInterface } = props; const { control, formState: { errors }, @@ -50,7 +48,7 @@ export const VPCIPv4Address = (props: Props) => { } - label="Auto-assign VPC IPv4 address" + label="Auto-assign VPC IPv4" onChange={(e, checked) => field.onChange( checked @@ -63,17 +61,7 @@ export const VPCIPv4Address = (props: Props) => { /> - Automatically assign an IPv4 address as{' '} - {isDualStackVPC ? 'a' : 'the'} private IP address for - this Linode in the VPC. - - ) : ( - VPC_AUTO_ASSIGN_IPV4_TOOLTIP - ) - } + text={VPC_AUTO_ASSIGN_IPV4_TOOLTIP} /> {field.value !== 'auto' && ( diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/EditInterfaceDrawer/VPCInterface/VPCIPv6Address.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/EditInterfaceDrawer/VPCInterface/VPCIPv6Address.tsx index eeaa072c716..26bf66d49a1 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/EditInterfaceDrawer/VPCInterface/VPCIPv6Address.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/EditInterfaceDrawer/VPCInterface/VPCIPv6Address.tsx @@ -1,4 +1,3 @@ -import { useSubnetQuery } from '@linode/queries'; import { Checkbox, FormControlLabel, @@ -6,15 +5,15 @@ import { Stack, TextField, TooltipIcon, - Typography, } from '@linode/ui'; import React from 'react'; import { Controller, useFormContext } from 'react-hook-form'; -import { Code } from 'src/components/Code/Code'; import { ErrorMessage } from 'src/components/ErrorMessage'; -import { generateVPCIPv6InputHelperText } from 'src/features/VPCs/utils'; -import { useVPCDualStack } from 'src/hooks/useVPCDualStack'; +import { + VPC_AUTO_ASSIGN_IPV6_TOOLTIP, + VPC_IPV6_INPUT_HELPER_TEXT, +} from 'src/features/VPCs/constants'; import type { LinodeInterface, @@ -32,18 +31,6 @@ export const VPCIPv6Address = (props: Props) => { formState: { errors }, } = useFormContext(); - const { isDualStackEnabled } = useVPCDualStack(); - - const { data: subnet } = useSubnetQuery( - linodeInterface.vpc?.vpc_id ?? -1, - linodeInterface.vpc?.subnet_id ?? -1, - Boolean( - isDualStackEnabled && - linodeInterface.vpc?.vpc_id && - linodeInterface.vpc?.subnet_id - ) - ); - const error = errors.vpc?.ipv6?.message; return ( @@ -62,7 +49,7 @@ export const VPCIPv6Address = (props: Props) => { } - label="Auto-assign VPC IPv6 address" + label="Auto-assign VPC IPv6" onChange={(e, checked) => field.onChange( checked ? 'auto' : linodeInterface.vpc?.ipv6?.slaac[0].range @@ -70,23 +57,12 @@ export const VPCIPv6Address = (props: Props) => { } sx={{ pl: 0.3, mr: 0 }} /> - - Automatically assign an IPv6 address as a private IP address - for this Linode in the VPC. A /52 IPv6 network - prefix is allocated for the VPC. - - } - /> + {field.value !== 'auto' && ( { const { ipam_address, vlan_label } = props; return ( @@ -20,9 +20,7 @@ export const VlanInterfaceDetailsContent = (props: { IPAM Address - - - + ); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/LinodeInterfacesTable.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/LinodeInterfacesTable.tsx index 5530bfb6172..80013557fa2 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/LinodeInterfacesTable.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/LinodeInterfacesTable.tsx @@ -18,7 +18,7 @@ interface Props { export const LinodeInterfacesTable = ({ handlers, linodeId }: Props) => { return ( -
+
Type diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRescue/StandardRescueDialog.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRescue/StandardRescueDialog.tsx index 622be8630c3..6bb3bc119a8 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRescue/StandardRescueDialog.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRescue/StandardRescueDialog.tsx @@ -1,10 +1,8 @@ import { useAllLinodeDisksQuery, useAllVolumesQuery, - useGrants, useLinodeQuery, useLinodeRescueMutation, - useProfile, } from '@linode/queries'; import { ActionsPanel, @@ -20,6 +18,7 @@ import { styled, useTheme } from '@mui/material/styles'; import { useSnackbar } from 'notistack'; import * as React from 'react'; +import { usePermissions } from 'src/features/IAM/hooks/usePermissions'; import { useEventsPollingActions } from 'src/queries/events/events'; import { deviceSlots } from '../LinodeConfigs/constants'; @@ -95,13 +94,12 @@ export const StandardRescueDialog = (props: Props) => { } = useAllVolumesQuery({}, { region: linode?.region }, open); const isLoading = isLoadingLinodes || isLoadingDisks || isLoadingVolumes; - const { data: profile } = useProfile(); - const { data: grants } = useGrants(); - - const isReadOnly = - Boolean(profile?.restricted) && - grants?.linode.find((grant) => grant.id === linodeId)?.permissions === - 'read_only'; + const { data: permissions } = usePermissions( + 'linode', + ['rescue_linode'], + linodeId, + open + ); // We need the API to allow us to filter on `linode_id` // const { data: volumes } = useAllVolumesQuery( @@ -173,7 +171,7 @@ export const StandardRescueDialog = (props: Props) => { })) ?? [], }; - const disabled = isReadOnly; + const disabled = !permissions.rescue_linode; const onSubmit = () => { rescueLinode(createDevicesFromStrings(rescueDevices)) @@ -224,7 +222,7 @@ export const StandardRescueDialog = (props: Props) => { ) : (
- {isReadOnly && } + {!permissions.rescue_linode && } {linodeId ? : null} ({ resize_linode: false, delete_linode: false, clone_linode: false, + create_image: true, }, })), })); @@ -209,6 +210,7 @@ describe('LinodeDiskActionMenu', () => { resize_linode: false, delete_linode: false, clone_linode: false, + create_image: false, }, }); @@ -242,6 +244,7 @@ describe('LinodeDiskActionMenu', () => { resize_linode: true, delete_linode: true, clone_linode: true, + create_image: true, }, }); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeDiskActionMenu.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeDiskActionMenu.tsx index 3602f0dc84a..0c27e67ce6d 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeDiskActionMenu.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeDiskActionMenu.tsx @@ -14,22 +14,13 @@ interface Props { onDelete: () => void; onRename: () => void; onResize: () => void; - readOnly?: boolean; } export const LinodeDiskActionMenu = (props: Props) => { const navigate = useNavigate(); const [isOpen, setIsOpen] = React.useState(false); - const { - disk, - linodeId, - linodeStatus, - onDelete, - onRename, - onResize, - readOnly, - } = props; + const { disk, linodeId, linodeStatus, onDelete, onRename, onResize } = props; const { data: permissions, isLoading } = usePermissions( 'linode', @@ -38,6 +29,10 @@ export const LinodeDiskActionMenu = (props: Props) => { isOpen ); + const { data: imagePermissions } = usePermissions('account', [ + 'create_image', + ]); + const poweredOnTooltip = linodeStatus !== 'offline' ? 'Your Linode must be fully powered down in order to perform this action.' @@ -67,7 +62,7 @@ export const LinodeDiskActionMenu = (props: Props) => { : poweredOnTooltip, }, { - disabled: readOnly || !!swapTooltip, + disabled: !imagePermissions.create_image || !!swapTooltip, onClick: () => navigate({ to: `/images/create/disk`, @@ -77,7 +72,9 @@ export const LinodeDiskActionMenu = (props: Props) => { }, }), title: 'Create Disk Image', - tooltip: readOnly ? noPermissionTooltip : swapTooltip, + tooltip: !imagePermissions.create_image + ? noPermissionTooltip + : swapTooltip, }, { disabled: !permissions.clone_linode, diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeDiskRow.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeDiskRow.tsx index ded59fdcd2b..a922f53e8da 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeDiskRow.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeDiskRow.tsx @@ -18,20 +18,11 @@ interface Props { onDelete: () => void; onRename: () => void; onResize: () => void; - readOnly: boolean; } export const LinodeDiskRow = React.memo((props: Props) => { const { data: events } = useInProgressEvents(); - const { - disk, - linodeId, - linodeStatus, - onDelete, - onRename, - onResize, - readOnly, - } = props; + const { disk, linodeId, linodeStatus, onDelete, onRename, onResize } = props; const diskEventLabelMap: Partial> = { disk_create: 'Creating', @@ -80,7 +71,6 @@ export const LinodeDiskRow = React.memo((props: Props) => { onDelete={onDelete} onRename={onRename} onResize={onResize} - readOnly={readOnly} /> diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeDisks.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeDisks.tsx index 9b962de61b9..33a4bcc7927 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeDisks.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeDisks.tsx @@ -1,8 +1,4 @@ -import { - useAllLinodeDisksQuery, - useGrants, - useLinodeQuery, -} from '@linode/queries'; +import { useAllLinodeDisksQuery, useLinodeQuery } from '@linode/queries'; import { Box, Button, Paper, Stack, Typography } from '@linode/ui'; import { Hidden } from '@linode/ui'; import Grid from '@mui/material/Grid'; @@ -41,7 +37,6 @@ export const LinodeDisks = () => { const { data: disks, error, isLoading } = useAllLinodeDisksQuery(id); const { data: linode } = useLinodeQuery(id); - const { data: grants } = useGrants(); const { data: permissions } = usePermissions( 'linode', @@ -59,10 +54,6 @@ export const LinodeDisks = () => { const linodeTotalDisk = linode?.specs.disk ?? 0; - const readOnly = - grants !== undefined && - grants.linode.some((g) => g.id === id && g.permissions === 'read_only'); - const usedDiskSpace = addUsedDiskSpace(disks ?? []); const hasFreeDiskSpace = linodeTotalDisk > usedDiskSpace; @@ -107,7 +98,6 @@ export const LinodeDisks = () => { onDelete={() => onDelete(disk)} onRename={() => onRename(disk)} onResize={() => onResize(disk)} - readOnly={readOnly} /> )); }; diff --git a/packages/manager/src/features/Managed/ManagedDashboardCard/ManagedChartPanel.styles.tsx b/packages/manager/src/features/Managed/ManagedDashboardCard/ManagedChartPanel.styles.tsx index 16612857359..a7c2bf37a36 100644 --- a/packages/manager/src/features/Managed/ManagedDashboardCard/ManagedChartPanel.styles.tsx +++ b/packages/manager/src/features/Managed/ManagedDashboardCard/ManagedChartPanel.styles.tsx @@ -29,6 +29,9 @@ export const StyledGraphControlsDiv = styled('div', { top: 52, width: 1, }, + '& .MuiPaper-root': { + padding: `0 0 0 ${theme.spacingFunction(24)}`, + }, alignItems: 'center', display: 'flex', minHeight: 460, diff --git a/packages/manager/src/features/Managed/ManagedDashboardCard/ManagedDashboardCard.styles.tsx b/packages/manager/src/features/Managed/ManagedDashboardCard/ManagedDashboardCard.styles.tsx index aac50be3668..9d03064cd63 100644 --- a/packages/manager/src/features/Managed/ManagedDashboardCard/ManagedDashboardCard.styles.tsx +++ b/packages/manager/src/features/Managed/ManagedDashboardCard/ManagedDashboardCard.styles.tsx @@ -18,6 +18,7 @@ export const StyledOuterContainerGrid = styled(Grid, { background: theme.bg.bgPaper, flexDirection: 'column', margin: '-8px', + padding: theme.spacingFunction(12), [theme.breakpoints.up('sm')]: { flexDirection: 'row', flexWrap: 'nowrap', diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/SummaryPanel.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/SummaryPanel.tsx index e2003220d02..6b2f59db1b6 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/SummaryPanel.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/SummaryPanel.tsx @@ -16,15 +16,12 @@ import * as React from 'react'; import { Link } from 'src/components/Link'; import { TagCell } from 'src/components/TagCell/TagCell'; import { usePermissions } from 'src/features/IAM/hooks/usePermissions'; -import { useKubernetesBetaEndpoint } from 'src/features/Kubernetes/kubeUtils'; import { IPAddress } from 'src/features/Linodes/LinodesLanding/IPAddress'; import { useKubernetesClusterQuery } from 'src/queries/kubernetes'; import { useIsNodebalancerVPCEnabled } from '../../utils'; export const SummaryPanel = () => { - const { isUsingBetaEndpoint } = useKubernetesBetaEndpoint(); - const { id } = useParams({ from: '/nodebalancers/$id/summary', }); @@ -77,7 +74,6 @@ export const SummaryPanel = () => { const { status: clusterStatus } = useKubernetesClusterQuery({ enabled: Boolean(nodebalancer?.lke_cluster), id: nodebalancer?.lke_cluster?.id ?? -1, - isUsingBetaEndpoint, options: { refetchOnMount: false, refetchOnReconnect: false, diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerSelect.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerSelect.tsx index 2d1b624b9c3..a91724ebde0 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerSelect.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerSelect.tsx @@ -5,6 +5,8 @@ import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; import * as React from 'react'; import type { JSX } from 'react'; +import { useQueryWithPermissions } from '../IAM/hooks/usePermissions'; + import type { APIError, NodeBalancer } from '@linode/api-v4'; import type { SxProps, Theme } from '@mui/material/styles'; @@ -95,7 +97,20 @@ export const NodeBalancerSelect = ( const { data, error, isLoading } = useAllNodeBalancersQuery(); - const nodebalancers = optionsFilter ? data?.filter(optionsFilter) : data; + const { + data: availableNodebalancers, + error: availableNodebalancersError, + isLoading: availableNodebalancersLoading, + } = useQueryWithPermissions( + useAllNodeBalancersQuery(), + 'nodebalancer', + ['update_nodebalancer'], + Boolean(optionsFilter) + ); + + const nodebalancers = optionsFilter + ? availableNodebalancers.filter(optionsFilter) + : data; React.useEffect(() => { /** We want to clear the input value when the value prop changes to null. @@ -116,7 +131,10 @@ export const NodeBalancerSelect = ( disableCloseOnSelect={multiple} disabled={disabled} disablePortal={true} - errorText={error?.[0].reason ?? errorText} + errorText={ + (error?.[0].reason || availableNodebalancersError?.[0].reason) ?? + errorText + } getOptionLabel={(nodebalancer: NodeBalancer) => renderOptionLabel ? renderOptionLabel(nodebalancer) : nodebalancer.label } @@ -124,11 +142,15 @@ export const NodeBalancerSelect = ( id={id} inputValue={inputValue} label={label ? label : multiple ? 'NodeBalancers' : 'NodeBalancer'} - loading={isLoading || loading} + loading={isLoading || availableNodebalancersLoading || loading} multiple={multiple} noMarginTop={noMarginTop} noOptionsText={ - noOptionsMessage ?? getDefaultNoOptionsMessage(error, isLoading) + noOptionsMessage ?? + getDefaultNoOptionsMessage( + error || availableNodebalancersError, + isLoading || availableNodebalancersLoading + ) } onBlur={onBlur} onChange={(_, value) => diff --git a/packages/manager/src/features/OneClickApps/oneClickApps.ts b/packages/manager/src/features/OneClickApps/oneClickApps.ts index 085e1b05143..604338a6e3b 100644 --- a/packages/manager/src/features/OneClickApps/oneClickApps.ts +++ b/packages/manager/src/features/OneClickApps/oneClickApps.ts @@ -86,23 +86,22 @@ export const oneClickApps: Record = { summary: `Build production-ready apps with the MERN stack: MongoDB, Express, React, and Node.js.`, }, 401706: { - alt_description: 'Virtual private network.', - alt_name: 'Free VPN', + alt_description: 'Virtual private network server.', + alt_name: 'Free VPN Server', categories: ['Security'], colors: { end: '51171a', start: '88171a', }, - description: `Configuring WireGuard® is as simple as configuring SSH. A connection is established by an exchange of public keys between server and client, and only a client whose public key is present in the server's configuration file is considered authorized. WireGuard sets up - standard network interfaces which behave similarly to other common network interfaces, like eth0. This makes it possible to configure and manage WireGuard interfaces using standard networking tools such as ifconfig and ip. "WireGuard" is a registered trademark of Jason A. Donenfeld.`, + description: `Deploy a WireGuard® server to create a central VPN hub for secure network connections. This server automatically configures WireGuard with sensible defaults, sets up NAT for full-tunnel capability, and implements security best practices. The server acts as a central point where multiple WireGuard clients can connect by adding their public keys to the server's configuration. WireGuard uses state-of-the-art cryptography and is designed to be faster and more secure than traditional VPN protocols. "WireGuard" is a registered trademark of Jason A. Donenfeld.`, logo_url: 'wireguard.svg', related_guides: [ { href: 'https://www.linode.com/docs/products/tools/marketplace/guides/wireguard/', - title: 'Deploy WireGuard through the Linode Marketplace', + title: 'Deploy WireGuard Server through the Linode Marketplace', }, ], - summary: `Modern VPN which utilizes state-of-the-art cryptography. It aims to be faster and leaner than other VPN protocols and has a smaller source code footprint.`, + summary: `Modern VPN server which acts as a central hub for secure client connections using state-of-the-art cryptography.`, website: 'https://www.wireguard.com/', }, 401707: { @@ -1832,7 +1831,7 @@ export const oneClickApps: Record = { start: '85A355', }, description: `Distributed, masterless, replicating NoSQL database cluster.`, - isNew: true, + isNew: false, logo_url: 'apachecassandra.svg', related_guides: [ { @@ -1892,7 +1891,7 @@ export const oneClickApps: Record = { start: 'AAAAAA', }, description: `High performance, BSD license key/value database.`, - isNew: true, + isNew: false, logo_url: 'valkey.svg', related_guides: [ { @@ -1912,7 +1911,7 @@ export const oneClickApps: Record = { start: 'FFBA01', }, description: `OSI approved open source secrets platform.`, - isNew: true, + isNew: false, logo_url: 'openbao.svg', related_guides: [ { @@ -1932,7 +1931,7 @@ export const oneClickApps: Record = { start: '9D29FB', }, description: `Time series database supporting native query and visualization.`, - isNew: true, + isNew: false, logo_url: 'influxdb.svg', related_guides: [ { @@ -2094,4 +2093,63 @@ export const oneClickApps: Record = { summary: 'Leading graph database for connected data applications.', website: 'https://neo4j.com/', }, + 1914317: { + alt_description: 'Virtual private network client.', + alt_name: 'Free VPN Client', + categories: ['Security'], + colors: { + end: '51171a', + start: '88171a', + }, + description: `Deploy a WireGuard® client to securely connect your Linode to a remote WireGuard server for private networking, tunneling, or secure remote access. This client automatically configures the WireGuard connection using the server's public key and endpoint information you provide. The client is ideal for creating secure point-to-point connections, accessing private networks through a VPN tunnel, or routing traffic through a central WireGuard server. WireGuard uses state-of-the-art cryptography and is designed to be faster and more secure than traditional VPN protocols. "WireGuard" is a registered trademark of Jason A. Donenfeld.`, + logo_url: 'wireguard.svg', + related_guides: [ + { + href: 'https://www.linode.com/docs/products/tools/marketplace/guides/wireguard/', + title: 'Deploy WireGuard Client through the Linode Marketplace', + }, + ], + summary: `Modern VPN client that connects to a remote WireGuard server for secure network access using state-of-the-art cryptography.`, + website: 'https://www.wireguard.com/', + }, + 1902903: { + alt_description: 'Observability pipeline for data management.', + alt_name: 'Telemetry data routing and optimization', + categories: ['Development'], + colors: { + end: '04cccc', + start: 'ffffff', + }, + description: `Cribl Stream is an observability pipeline that helps organizations collect, reduce, enrich, and route telemetry data in real-time. It connects with 80+ sources and destinations, enabling you to handle data from any source to any analytics tool. Cribl Stream helps reduce data volume and optimize log processing to cut costs, enhance data security with encryption and access controls, and transform data using AI-powered tools. The platform scales from small to enterprise-level deployments and acts as a universal data management layer, giving organizations more control and efficiency in handling their telemetry data across various systems.`, + isNew: true, + logo_url: 'cribl.svg', + related_guides: [ + { + href: 'https://www.linode.com/docs/products/tools/marketplace/guides/cribl/', + title: 'Deploy Cribl through the Linode Marketplace', + }, + ], + summary: `Observability pipeline for collecting, reducing, enriching, and routing telemetry data in real-time across 80+ sources and destinations.`, + website: 'https://cribl.io/products/stream/', + }, + 1902904: { + alt_description: 'All-in-one distributed tracing platform.', + alt_name: 'Microservices tracing and observability', + categories: ['Development'], + colors: { + end: '68cfe3', + start: '648c19', + }, + description: `Jaeger all-in-one is a complete distributed tracing solution deployed as a single Docker container that includes the Jaeger UI, Collector, Query service, Agent, and in-memory storage. This integrated setup is designed for development, testing, and quick deployment scenarios where you need full tracing capabilities without complex distributed architecture. Jaeger helps developers track request flows across microservices, identify performance bottlenecks, analyze service dependencies, and troubleshoot errors in distributed applications. The all-in-one image supports various tracing protocols including Zipkin and Jaeger's own formats, making it ideal for getting started with distributed tracing.`, + isNew: true, + logo_url: 'jaeger.svg', + related_guides: [ + { + href: 'https://www.linode.com/docs/products/tools/marketplace/guides/jaeger/', + title: 'Deploy Jaeger through the Linode Marketplace', + }, + ], + summary: `All-in-one distributed tracing platform with integrated UI, collector, and storage for monitoring microservices.`, + website: 'https://www.jaegertracing.io/', + }, }; diff --git a/packages/manager/src/features/Search/useClientSideSearch.ts b/packages/manager/src/features/Search/useClientSideSearch.ts index 7c5cf8e0706..fa885192829 100644 --- a/packages/manager/src/features/Search/useClientSideSearch.ts +++ b/packages/manager/src/features/Search/useClientSideSearch.ts @@ -11,7 +11,6 @@ import { useAllVolumesQuery, } from '@linode/queries'; -import { useKubernetesBetaEndpoint } from 'src/features/Kubernetes/kubeUtils'; import { useAllKubernetesClustersQuery } from 'src/queries/kubernetes'; import { useObjectStorageBuckets } from 'src/queries/object-storage/queries'; import { @@ -48,12 +47,11 @@ export const useClientSideSearch = ({ enabled, query }: Props) => { error: domainsError, isLoading: domainsLoading, } = useAllDomainsQuery(enabled); - const { isUsingBetaEndpoint } = useKubernetesBetaEndpoint(); const { data: clusters, error: lkeClustersError, isLoading: lkeClustersLoading, - } = useAllKubernetesClustersQuery({ enabled, isUsingBetaEndpoint }); + } = useAllKubernetesClustersQuery({ enabled }); const { data: volumes, error: volumesError, @@ -115,9 +113,9 @@ export const useClientSideSearch = ({ enabled, query }: Props) => { objectStorageBuckets?.buckets.map(bucketToSearchableItem) ?? []; const searchableClusters = clusters?.map(kubernetesClusterToSearchableItem) ?? []; - const searchableStreams = streams?.data?.map(streamToSearchableItem) ?? []; + const searchableStreams = streams?.map(streamToSearchableItem) ?? []; const searchableDestinations = - destinations?.data?.map(destinationToSearchableItem) ?? []; + destinations?.map(destinationToSearchableItem) ?? []; const searchableItems = [ ...searchableLinodes, diff --git a/packages/manager/src/features/Support/SupportTickets/SupportTicketProductSelectionFields.tsx b/packages/manager/src/features/Support/SupportTickets/SupportTicketProductSelectionFields.tsx index 511e4d6b154..685c38f237c 100644 --- a/packages/manager/src/features/Support/SupportTickets/SupportTicketProductSelectionFields.tsx +++ b/packages/manager/src/features/Support/SupportTickets/SupportTicketProductSelectionFields.tsx @@ -11,7 +11,6 @@ import { Autocomplete, FormHelperText, TextField } from '@linode/ui'; import React from 'react'; import { Controller, useFormContext } from 'react-hook-form'; -import { useKubernetesBetaEndpoint } from 'src/features/Kubernetes/kubeUtils'; import { useAllKubernetesClustersQuery } from 'src/queries/kubernetes'; import { useObjectStorageBuckets } from 'src/queries/object-storage/queries'; @@ -71,14 +70,12 @@ export const SupportTicketProductSelectionFields = (props: Props) => { isLoading: nodebalancersLoading, } = useAllNodeBalancersQuery(entityType === 'nodebalancer_id'); - const { isUsingBetaEndpoint } = useKubernetesBetaEndpoint(); const { data: clusters, error: clustersError, isLoading: clustersLoading, } = useAllKubernetesClustersQuery({ enabled: entityType === 'lkecluster_id', - isUsingBetaEndpoint, }); const { diff --git a/packages/manager/src/features/TopMenu/UserMenu/UserMenuPopover.tsx b/packages/manager/src/features/TopMenu/UserMenu/UserMenuPopover.tsx index aed316a400e..91d51143ab3 100644 --- a/packages/manager/src/features/TopMenu/UserMenu/UserMenuPopover.tsx +++ b/packages/manager/src/features/TopMenu/UserMenu/UserMenuPopover.tsx @@ -40,7 +40,7 @@ export const UserMenuPopover = (props: UserMenuPopoverProps) => { const { data: account } = useAccount(); const { data: profile } = useProfile(); - const { isIAMEnabled } = useIsIAMEnabled(); + const { isIAMEnabled, isIAMBeta } = useIsIAMEnabled(); const isChildAccountAccessRestricted = useRestrictedGlobalGrantCheck({ globalGrantType: 'child_account_access', @@ -114,7 +114,7 @@ export const UserMenuPopover = (props: UserMenuPopoverProps) => { : iamRbacPrimaryNavChanges && !isIAMEnabled ? '/users' : '/account/users', - isBeta: iamRbacPrimaryNavChanges && isIAMEnabled, + isBeta: iamRbacPrimaryNavChanges && isIAMEnabled && isIAMBeta, }, { display: 'Quotas', diff --git a/packages/manager/src/features/VPCs/VPCCreate/FormComponents/SubnetContent.test.tsx b/packages/manager/src/features/VPCs/VPCCreate/FormComponents/SubnetContent.test.tsx index 4a92bbffa41..0229bb0ab68 100644 --- a/packages/manager/src/features/VPCs/VPCCreate/FormComponents/SubnetContent.test.tsx +++ b/packages/manager/src/features/VPCs/VPCCreate/FormComponents/SubnetContent.test.tsx @@ -20,7 +20,7 @@ describe('Subnet form content', () => { expect(getByText('Subnets')).toBeVisible(); expect(getByText('Subnet Label')).toBeVisible(); - expect(getByText('Subnet IP Address Range')).toBeVisible(); + expect(getByText('Subnet IPv4 Range (CIDR)')).toBeVisible(); expect(getByText('Add another Subnet')).toBeVisible(); }); }); diff --git a/packages/manager/src/features/VPCs/VPCCreate/FormComponents/VPCTopSectionContent.test.tsx b/packages/manager/src/features/VPCs/VPCCreate/FormComponents/VPCTopSectionContent.test.tsx index 9dfa388b178..97345c9b573 100644 --- a/packages/manager/src/features/VPCs/VPCCreate/FormComponents/VPCTopSectionContent.test.tsx +++ b/packages/manager/src/features/VPCs/VPCCreate/FormComponents/VPCTopSectionContent.test.tsx @@ -1,9 +1,11 @@ +import { regionFactory, regionVPCAvailabilityFactory } from '@linode/utilities'; import { waitFor } from '@testing-library/react'; import { screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import * as React from 'react'; import { accountFactory } from 'src/factories'; +import { makeResourcePage } from 'src/mocks/serverHandlers'; import { http, HttpResponse, server } from 'src/mocks/testServer'; import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; @@ -45,7 +47,7 @@ describe('VPC Top Section form content', () => { capabilities: ['VPC Dual Stack'], }); - server.use(http.get('*/v4*/account', () => HttpResponse.json(account))); + server.use(http.get('*/account', () => HttpResponse.json(account))); renderWithThemeAndHookFormContext({ component: , @@ -74,15 +76,38 @@ describe('VPC Top Section form content', () => { expect(NetworkingIPStackRadios[1]).not.toBeChecked(); // Dual Stack }); - it('renders VPC IPv6 Prefix Length options with /52 selected if Dual Stack is checked and the customer is enterprise', async () => { + it('renders VPC IPv6 Prefix Length options with /52 selected if the selected region has multiple prefix lengths available', async () => { const account = accountFactory.build({ capabilities: ['VPC Dual Stack', 'VPC IPv6 Large Prefixes'], }); - server.use(http.get('*/v4*/account', () => HttpResponse.json(account))); + server.use(http.get('*/account', () => HttpResponse.json(account))); + server.use( + http.get('*/regions/vpc-availability*', () => + HttpResponse.json( + makeResourcePage([ + regionVPCAvailabilityFactory.build({ + region: 'us-east', + available_ipv6_prefix_lengths: [48, 52], + }), + ]) + ) + ) + ); renderWithThemeAndHookFormContext({ - component: , + component: ( + + ), // @TODO VPC IPv6: Remove this flag check once VPC IPv6 is in GA options: { flags: { @@ -99,6 +124,15 @@ describe('VPC Top Section form content', () => { }, }); + const regionSelect = screen.getByPlaceholderText('Select a Region'); + + await userEvent.click(regionSelect); + await userEvent.type(regionSelect, 'US, Newark, NJ (us-east)'); + await waitFor(async () => { + const selectedRegionOption = screen.getByText('US, Newark, NJ (us-east)'); + await userEvent.click(selectedRegionOption); + }); + await waitFor(() => { expect(screen.getByText('IP Stack')).toBeVisible(); }); @@ -110,16 +144,28 @@ describe('VPC Top Section form content', () => { expect(screen.getByText('VPC IPv6 Prefix Length')).toBeVisible(); const IPv6CIDRRadios = screen.getAllByRole('radio'); - expect(IPv6CIDRRadios[2]).toBeChecked(); // /52 - expect(IPv6CIDRRadios[3]).not.toBeChecked(); // /48 + expect(IPv6CIDRRadios[2]).not.toBeChecked(); // /48 + expect(IPv6CIDRRadios[3]).toBeChecked(); // /52 }); - it('does not render VPC IPv6 Prefix Length options if the customer is not enterprise', async () => { + it('does not render VPC IPv6 Prefix Length options if there are none available or only /52 available', async () => { const account = accountFactory.build({ capabilities: ['VPC Dual Stack'], }); - server.use(http.get('*/v4*/account', () => HttpResponse.json(account))); + server.use(http.get('*/account', () => HttpResponse.json(account))); + server.use( + http.get('*/regions/vpc-availability*', () => + HttpResponse.json( + makeResourcePage([ + regionVPCAvailabilityFactory.build({ + region: 'us-east', + available_ipv6_prefix_lengths: [52], + }), + ]) + ) + ) + ); renderWithThemeAndHookFormContext({ component: , diff --git a/packages/manager/src/features/VPCs/VPCCreate/FormComponents/VPCTopSectionContent.tsx b/packages/manager/src/features/VPCs/VPCCreate/FormComponents/VPCTopSectionContent.tsx index 5911da09fa6..1303dcdcd83 100644 --- a/packages/manager/src/features/VPCs/VPCCreate/FormComponents/VPCTopSectionContent.tsx +++ b/packages/manager/src/features/VPCs/VPCCreate/FormComponents/VPCTopSectionContent.tsx @@ -1,3 +1,4 @@ +import { useRegionsVPCAvailabilitiesQuery } from '@linode/queries'; import { useIsGeckoEnabled } from '@linode/shared'; import { Box, @@ -65,13 +66,21 @@ export const VPCTopSectionContent = (props: Props) => { name: 'subnets', }); - const subnets = useWatch({ control, name: 'subnets' }); - const vpcIPv6 = useWatch({ control, name: 'ipv6' }); + const [subnets, vpcIPv6, regionId] = useWatch({ + control, + name: ['subnets', 'ipv6', 'region'], + }); const { data: permissions } = usePermissions('account', ['create_vpc']); - const { isDualStackEnabled, isDualStackSelected, isEnterpriseCustomer } = - useVPCDualStack(vpcIPv6); + const { isDualStackEnabled, isDualStackSelected } = useVPCDualStack(vpcIPv6); + + const { data: regionsVPCAvailabilities } = + useRegionsVPCAvailabilitiesQuery(isDualStackEnabled); + + const availableRegionIPv6PrefixLengths = regionsVPCAvailabilities?.find( + (region) => region.region === regionId + )?.available_ipv6_prefix_lengths; return ( <> @@ -204,7 +213,7 @@ export const VPCTopSectionContent = (props: Props) => { sm: 12, xs: 12, }} - heading="IPv4 + IPv6 (dual-stack)" + heading="IPv4 + IPv6 (Dual Stack)" onClick={() => { field.onChange([ { @@ -235,7 +244,9 @@ export const VPCTopSectionContent = (props: Props) => { The VPC supports both IPv4 and IPv6 addresses. - {RFC1918HelperText} + + For IPv4, {RFC1918HelperText} + For IPv6, the VPC is assigned an IPv6 prefix length of /52 by default. @@ -255,45 +266,42 @@ export const VPCTopSectionContent = (props: Props) => { /> )} - {isDualStackSelected && isEnterpriseCustomer && ( - ( - field.onChange([{ range: value }])} - value={field.value} - > - - VPC IPv6 Prefix Length - - {errors.ipv6 && ( - - )} - <> - } - disabled={!permissions?.create_vpc} - label="/52" - value="/52" - /> - } - disabled={!permissions?.create_vpc} - label="/48" - value="/48" - /> - - - )} - /> - )} + {isDualStackSelected && + availableRegionIPv6PrefixLengths && + availableRegionIPv6PrefixLengths.length > 1 && ( // Hide /52 if it's the only prefix length + ( + field.onChange([{ range: value }])} + style={{ margin: 0 }} + value={field.value} + > + + VPC IPv6 Prefix Length + + {errors.ipv6 && ( + + )} + {availableRegionIPv6PrefixLengths.map((prefixLength) => ( + } + disabled={!permissions?.create_vpc} + key={prefixLength} + label={`/${prefixLength}`} + value={`/${prefixLength}`} + /> + ))} + + )} + /> + )} ); }; diff --git a/packages/manager/src/features/VPCs/VPCCreate/MultipleSubnetInput.test.tsx b/packages/manager/src/features/VPCs/VPCCreate/MultipleSubnetInput.test.tsx index 6a2ed8e1d24..9e7d6199be0 100644 --- a/packages/manager/src/features/VPCs/VPCCreate/MultipleSubnetInput.test.tsx +++ b/packages/manager/src/features/VPCs/VPCCreate/MultipleSubnetInput.test.tsx @@ -41,7 +41,7 @@ describe('MultipleSubnetInput', () => { }); expect(getAllByText('Subnet Label')).toHaveLength(3); - expect(getAllByText('Subnet IP Address Range')).toHaveLength(3); + expect(getAllByText('Subnet IPv4 Range (CIDR)')).toHaveLength(3); getByDisplayValue('subnet 0'); getByDisplayValue('subnet 1'); getByDisplayValue('subnet 2'); diff --git a/packages/manager/src/features/VPCs/VPCCreate/SubnetNode.test.tsx b/packages/manager/src/features/VPCs/VPCCreate/SubnetNode.test.tsx index b897c639fa7..e5e2482248a 100644 --- a/packages/manager/src/features/VPCs/VPCCreate/SubnetNode.test.tsx +++ b/packages/manager/src/features/VPCs/VPCCreate/SubnetNode.test.tsx @@ -65,7 +65,7 @@ describe('SubnetNode', () => { }); screen.getByText('Subnet Label'); - screen.getByText('Subnet IP Address Range'); + screen.getByText('Subnet IPv4 Range (CIDR)'); }); it('should show a removable button if not a drawer', () => { @@ -133,7 +133,7 @@ describe('SubnetNode', () => { }, }); - expect(screen.getByText('IPv6 Prefix Length')).toBeVisible(); + expect(screen.getByText('Subnet IPv6 Prefix Length')).toBeVisible(); const select = screen.getByRole('combobox'); expect(select).toHaveValue('/56'); }); diff --git a/packages/manager/src/features/VPCs/VPCCreate/SubnetNode.tsx b/packages/manager/src/features/VPCs/VPCCreate/SubnetNode.tsx index a0f1fbac83c..6476b71926c 100644 --- a/packages/manager/src/features/VPCs/VPCCreate/SubnetNode.tsx +++ b/packages/manager/src/features/VPCs/VPCCreate/SubnetNode.tsx @@ -78,7 +78,7 @@ export const SubnetNode = (props: Props) => { errorText={fieldState.error?.message} helperText={!shouldDisplayIPv6 && availableIPv4HelperText} inputId={`subnet-ipv4-${idx}`} - label="Subnet IP Address Range" + label="Subnet IPv4 Range (CIDR)" onBlur={field.onBlur} onChange={field.onChange} value={field.value} @@ -96,11 +96,14 @@ export const SubnetNode = (props: Props) => { numberOfAvailableIPv4Linodes, calculateAvailableIPv6Linodes(field.value) )}`} - label="IPv6 Prefix Length" + label="Subnet IPv6 Prefix Length" onChange={(_, option) => field.onChange(option.value)} options={SUBNET_IPV6_PREFIX_LENGTHS} sx={{ width: 140, + '& .MuiInputLabel-root': { + overflow: 'visible', + }, }} value={SUBNET_IPV6_PREFIX_LENGTHS.find( (option) => option.value === field.value diff --git a/packages/manager/src/features/VPCs/VPCCreate/VPCCreate.test.tsx b/packages/manager/src/features/VPCs/VPCCreate/VPCCreate.test.tsx index 868507687e7..d9b888e7cea 100644 --- a/packages/manager/src/features/VPCs/VPCCreate/VPCCreate.test.tsx +++ b/packages/manager/src/features/VPCs/VPCCreate/VPCCreate.test.tsx @@ -34,7 +34,7 @@ describe('VPC create page', () => { expect(getByText('Description')).toBeVisible(); expect(getByText('Subnets')).toBeVisible(); expect(getByText('Subnet Label')).toBeVisible(); - expect(getByText('Subnet IP Address Range')).toBeVisible(); + expect(getByText('Subnet IPv4 Range (CIDR)')).toBeVisible(); expect(getByText('Add another Subnet')).toBeVisible(); expect(getByText('Create VPC')).toBeVisible(); }); @@ -46,7 +46,7 @@ describe('VPC create page', () => { await userEvent.click(addSubnet); const subnetLabels = screen.getAllByText('Subnet Label'); - const subnetIps = screen.getAllByText('Subnet IP Address Range'); + const subnetIps = screen.getAllByText('Subnet IPv4 Range (CIDR)'); expect(subnetLabels).toHaveLength(2); expect(subnetIps).toHaveLength(2); @@ -55,14 +55,14 @@ describe('VPC create page', () => { await userEvent.click(deleteSubnet); const subnetLabelAfter = screen.getAllByText('Subnet Label'); - const subnetIpsAfter = screen.getAllByText('Subnet IP Address Range'); + const subnetIpsAfter = screen.getAllByText('Subnet IPv4 Range (CIDR)'); expect(subnetLabelAfter).toHaveLength(1); expect(subnetIpsAfter).toHaveLength(1); }); it('should display that a subnet ip is invalid and require a subnet label if a user adds an invalid subnet ip', async () => { renderWithTheme(); - const subnetIp = screen.getByText('Subnet IP Address Range'); + const subnetIp = screen.getByText('Subnet IPv4 Range (CIDR)'); expect(subnetIp).toBeInTheDocument(); const createVPCButton = screen.getByText('Create VPC'); expect(createVPCButton).toBeInTheDocument(); @@ -99,7 +99,7 @@ describe('VPC create page', () => { const description = screen.getByRole('textbox', { name: /description/i }); expect(description).toBeDisabled(); expect(getByLabelText('Subnet Label')).toBeDisabled(); - expect(getByLabelText('Subnet IP Address Range')).toBeDisabled(); + expect(getByLabelText('Subnet IPv4 Range (CIDR)')).toBeDisabled(); expect(getByText('Add another Subnet')).toBeDisabled(); expect(getByText('Create VPC')).toBeDisabled(); }); @@ -117,7 +117,7 @@ describe('VPC create page', () => { const description = screen.getByRole('textbox', { name: /description/i }); expect(description).toBeEnabled(); expect(getByLabelText('Subnet Label')).toBeEnabled(); - expect(getByLabelText('Subnet IP Address Range')).toBeEnabled(); + expect(getByLabelText('Subnet IPv4 Range (CIDR)')).toBeEnabled(); expect(getByText('Add another Subnet')).toBeEnabled(); expect(getByText('Create VPC')).toBeEnabled(); }); diff --git a/packages/manager/src/features/VPCs/VPCCreateDrawer/VPCCreateDrawer.test.tsx b/packages/manager/src/features/VPCs/VPCCreateDrawer/VPCCreateDrawer.test.tsx index fff4e5bc42c..ba8c730315f 100644 --- a/packages/manager/src/features/VPCs/VPCCreateDrawer/VPCCreateDrawer.test.tsx +++ b/packages/manager/src/features/VPCs/VPCCreateDrawer/VPCCreateDrawer.test.tsx @@ -38,7 +38,7 @@ describe('VPC Create Drawer', () => { expect(getByText('Description')).toBeVisible(); expect(getByText('Subnets')).toBeVisible(); expect(getByText('Subnet Label')).toBeVisible(); - expect(getByText('Subnet IP Address Range')).toBeVisible(); + expect(getByText('Subnet IPv4 Range (CIDR)')).toBeVisible(); expect(getByText('Add another Subnet')).toBeVisible(); expect(getByRole('button', { name: 'Create VPC' })).toBeVisible(); expect(getByText('Cancel')).toBeVisible(); diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetActionMenu.test.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetActionMenu.test.tsx index 06ec7e74c87..945889997cf 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/SubnetActionMenu.test.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetActionMenu.test.tsx @@ -7,6 +7,27 @@ import { renderWithTheme } from 'src/utilities/testHelpers'; import { SubnetActionMenu } from './SubnetActionMenu'; +const queryMocks = vi.hoisted(() => ({ + userPermissions: vi.fn(() => ({ + data: { + update_linode: true, + delete_linode: true, + update_vpc: true, + }, + })), + useQueryWithPermissions: vi.fn().mockReturnValue({ + data: [ + { id: 1, label: 'linode-1' }, + { id: 2, label: 'linode-2' }, + ], + isLoading: false, + isError: false, + }), +})); +vi.mock('src/features/IAM/hooks/usePermissions', () => ({ + usePermissions: queryMocks.userPermissions, + useQueryWithPermissions: queryMocks.useQueryWithPermissions, +})); afterEach(() => { vi.clearAllMocks(); }); @@ -105,4 +126,68 @@ describe('SubnetActionMenu', () => { await userEvent.click(assignButton); expect(props.handleAssignLinodes).toHaveBeenCalled(); }); + + it('should disable the Assign Linodes button if user does not have update_linode permission', async () => { + queryMocks.userPermissions.mockReturnValue({ + data: { + update_linode: false, + delete_linode: false, + update_vpc: false, + }, + }); + const view = renderWithTheme(); + const actionMenu = view.getByLabelText(`Action menu for Subnet subnet-1`); + await userEvent.click(actionMenu); + + const assignButton = view.getByRole('menuitem', { name: 'Assign Linodes' }); + expect(assignButton).toHaveAttribute('aria-disabled', 'true'); + }); + + it('should enable the Assign Linodes button if user has update_linode and update_vpc permissions', async () => { + queryMocks.userPermissions.mockReturnValue({ + data: { + update_linode: true, + delete_linode: false, + update_vpc: true, + }, + }); + const view = renderWithTheme(); + const actionMenu = view.getByLabelText(`Action menu for Subnet subnet-1`); + await userEvent.click(actionMenu); + + const assignButton = view.getByRole('menuitem', { name: 'Assign Linodes' }); + expect(assignButton).not.toHaveAttribute('aria-disabled', 'true'); + }); + + it('should disable the Edit button if user does not have update_vpc permission', async () => { + queryMocks.userPermissions.mockReturnValue({ + data: { + update_linode: false, + delete_linode: false, + update_vpc: false, + }, + }); + const view = renderWithTheme(); + const actionMenu = view.getByLabelText(`Action menu for Subnet subnet-1`); + await userEvent.click(actionMenu); + + const editButton = view.getByRole('menuitem', { name: 'Edit' }); + expect(editButton).toHaveAttribute('aria-disabled', 'true'); + }); + + it('should enable the Edit button if user has update_vpc permission', async () => { + queryMocks.userPermissions.mockReturnValue({ + data: { + update_linode: false, + delete_linode: false, + update_vpc: true, + }, + }); + const view = renderWithTheme(); + const actionMenu = view.getByLabelText(`Action menu for Subnet subnet-1`); + await userEvent.click(actionMenu); + + const editButton = view.getByRole('menuitem', { name: 'Edit' }); + expect(editButton).not.toHaveAttribute('aria-disabled', 'true'); + }); }); diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetActionMenu.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetActionMenu.tsx index 0fad2132dc0..347a70b52d2 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/SubnetActionMenu.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetActionMenu.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; import { ActionMenu } from 'src/components/ActionMenu/ActionMenu'; +import { usePermissions } from 'src/features/IAM/hooks/usePermissions'; import { useIsNodebalancerVPCEnabled } from 'src/features/NodeBalancers/utils'; import type { Subnet } from '@linode/api-v4'; @@ -29,31 +30,49 @@ export const SubnetActionMenu = (props: Props) => { numLinodes, numNodebalancers, subnet, + vpcId, } = props; const flags = useIsNodebalancerVPCEnabled(); + const { data: permissions } = usePermissions('vpc', ['update_vpc'], vpcId); + const canUpdateVPC = permissions?.update_vpc; + const actions: Action[] = [ { onClick: () => { handleAssignLinodes(subnet); }, title: 'Assign Linodes', + disabled: !canUpdateVPC, + tooltip: !canUpdateVPC + ? 'You do not have permission to assign Linode to this subnet.' + : undefined, }, { onClick: () => { handleUnassignLinodes(subnet); }, title: 'Unassign Linodes', + disabled: !canUpdateVPC, + tooltip: !canUpdateVPC + ? 'You do not have permission to unassign Linode from this subnet.' + : undefined, }, { onClick: () => { handleEdit(subnet); }, title: 'Edit', + // TODO: change to 'update_vpc_subnet' once it's available + disabled: !canUpdateVPC, + tooltip: !canUpdateVPC + ? 'You do not have permission to edit this subnet.' + : undefined, }, { - disabled: numLinodes !== 0 || numNodebalancers !== 0, + // TODO: change to 'delete_vpc_subnet' once it's available + disabled: numLinodes !== 0 || numNodebalancers !== 0 || !canUpdateVPC, onClick: () => { handleDelete(subnet); }, @@ -61,7 +80,9 @@ export const SubnetActionMenu = (props: Props) => { tooltip: numLinodes > 0 || numNodebalancers > 0 ? `${flags.isNodebalancerVPCEnabled ? 'Resources' : 'Linodes'} assigned to a subnet must be unassigned before the subnet can be deleted.` - : '', + : !canUpdateVPC + ? 'You do not have permission to delete this subnet.' + : undefined, }, ]; diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.test.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.test.tsx index 549911f7cba..b744e8f27cb 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.test.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.test.tsx @@ -59,9 +59,7 @@ describe('Subnet Assign Linodes Drawer', () => { ); - const header = getByText( - 'Assign Linodes to subnet: subnet-1 (10.0.0.0/24)' - ); + const header = getByText('Assign Linodes to subnet: subnet-1'); expect(header).toBeVisible(); const notice = getByTestId('subnet-linode-action-notice'); expect(notice).toBeVisible(); diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.tsx index 563a51cccc7..ada602f3e86 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.tsx @@ -4,8 +4,6 @@ import { getAllLinodeConfigs, useAllLinodesQuery, useFirewallSettingsQuery, - useGrants, - useProfile, } from '@linode/queries'; import { LinodeSelect } from '@linode/shared'; import { @@ -26,15 +24,21 @@ import { useTheme } from '@mui/material/styles'; import { useFormik } from 'formik'; import * as React from 'react'; -import { Code } from 'src/components/Code/Code'; import { DownloadCSV } from 'src/components/DownloadCSV/DownloadCSV'; import { Link } from 'src/components/Link'; import { RemovableSelectionsListTable } from 'src/components/RemovableSelectionsList/RemovableSelectionsListTable'; import { FirewallSelect } from 'src/features/Firewalls/components/FirewallSelect'; +import { + usePermissions, + useQueryWithPermissions, +} from 'src/features/IAM/hooks/usePermissions'; import { getDefaultFirewallForInterfacePurpose } from 'src/features/Linodes/LinodeCreate/Networking/utilities'; import { REMOVABLE_SELECTIONS_LINODES_TABLE_HEADERS, VPC_AUTO_ASSIGN_IPV4_TOOLTIP, + VPC_AUTO_ASSIGN_IPV6_TOOLTIP, + VPC_IPV4_INPUT_HELPER_TEXT, + VPC_IPV6_INPUT_HELPER_TEXT, VPC_MULTIPLE_CONFIGURATIONS_LEARN_MORE_LINK, } from 'src/features/VPCs/constants'; import { useUnassignLinode } from 'src/hooks/useUnassignLinode'; @@ -48,7 +52,6 @@ import { REGIONAL_LINODE_MESSAGE, } from '../constants'; import { - generateVPCIPv6InputHelperText, getLinodeInterfaceIPv4Ranges, getLinodeInterfacePrimaryIPv4, getVPCInterfacePayload, @@ -146,16 +149,19 @@ export const SubnetAssignLinodesDrawer = ( const [allowPublicIPv6Access, setAllowPublicIPv6Access] = React.useState(false); - const { data: profile } = useProfile(); - const { data: grants } = useGrants(); - const vpcPermissions = grants?.vpc.find((v) => v.id === vpcId); + const { data: permissions } = usePermissions('vpc', ['update_vpc'], vpcId); + // TODO: change update_linode to create_linode_config_profile_interface once it's available + // TODO: change delete_linode to delete_linode_config_profile_interface once it's available + // TODO: refactor useQueryWithPermissions once API filter is available + const { data: filteredLinodes } = useQueryWithPermissions( + useAllLinodesQuery(), + 'linode', + ['update_linode', 'delete_linode'], + open + ); - // @TODO VPC: this logic for vpc grants/perms appears a lot - commenting a todo here in case we want to move this logic to a parent component - // there isn't a 'view VPC/Subnet' grant that does anything, so all VPCs get returned even for restricted users - // with permissions set to 'None'. Therefore, we're treating those as read_only as well - const userCannotAssignLinodes = - Boolean(profile?.restricted) && - (vpcPermissions?.permissions === 'read_only' || grants?.vpc.length === 0); + const userCanAssignLinodes = + permissions?.update_vpc && filteredLinodes?.length > 0; const downloadCSV = async () => { await getCSVData(); @@ -582,11 +588,9 @@ export const SubnetAssignLinodesDrawer = ( isFetching={isFetching} onClose={handleOnClose} open={open} - title={`Assign Linodes to subnet: ${subnet?.label ?? 'Unknown'} (${ - subnet?.ipv4 ?? subnet?.ipv6 ?? 'Unknown' - })`} + title={`Assign Linodes to subnet: ${subnet?.label ?? 'Unknown'}`} > - {userCannotAssignLinodes && ( + {!userCanAssignLinodes && ( {REGIONAL_LINODE_MESSAGE} { setFieldValue('selectedLinode', selected); @@ -633,38 +637,23 @@ export const SubnetAssignLinodesDrawer = ( /> } data-testid="vpc-ipv4-checkbox" - disabled={userCannotAssignLinodes} - label={Auto-assign VPC IPv4 address} + disabled={!userCanAssignLinodes} + label={Auto-assign VPC IPv4} sx={{ marginRight: 0 }} /> - - Automatically assign an IPv4 address as{' '} - {showIPv6Content ? 'a' : 'the'} private IP address for - this Linode in the VPC. - - ) : ( - VPC_AUTO_ASSIGN_IPV4_TOOLTIP - ) - } - /> + {!autoAssignVPCIPv4Address && ( { setFieldValue('chosenIPv4', e.target.value); setAssignLinodesErrors({}); }} - style={{ - marginBottom: showIPv6Content ? theme.spacingFunction(24) : 0, - }} value={values.chosenIPv4} /> )} @@ -692,30 +681,20 @@ export const SubnetAssignLinodesDrawer = ( /> } data-testid="vpc-ipv6-checkbox" - disabled={userCannotAssignLinodes} - label={ - Auto-assign VPC IPv6 address - } + disabled={!userCanAssignLinodes} + label={Auto-assign VPC IPv6} sx={{ marginRight: 0 }} /> - Automatically assign an IPv6 address as a private IP - address for this Linode in the VPC. A /52{' '} - IPv6 network prefix is allocated for the VPC. - - } + text={VPC_AUTO_ASSIGN_IPV6_TOOLTIP} /> {!autoAssignVPCIPv6Address && ( { @@ -737,7 +716,7 @@ export const SubnetAssignLinodesDrawer = ( . { setFieldValue('selectedConfig', value); @@ -760,7 +739,7 @@ export const SubnetAssignLinodesDrawer = ( } showIPv6Content={showIPv6Content} sx={{ margin: `${theme.spacingFunction(16)} 0` }} - userCannotAssignLinodes={userCannotAssignLinodes} + userCannotAssignLinodes={!userCanAssignLinodes} /> {/* Display the 'Assign additional [IPv4] ranges' section if the Configuration Profile section has been populated, or @@ -801,7 +780,7 @@ export const SubnetAssignLinodesDrawer = ( diff --git a/packages/manager/src/features/VPCs/VPCLanding/VPCRow.test.tsx b/packages/manager/src/features/VPCs/VPCLanding/VPCRow.test.tsx index 3da8b5343c7..32f2f31e0cd 100644 --- a/packages/manager/src/features/VPCs/VPCLanding/VPCRow.test.tsx +++ b/packages/manager/src/features/VPCs/VPCLanding/VPCRow.test.tsx @@ -25,11 +25,11 @@ vi.mock('src/features/IAM/hooks/usePermissions', () => ({ })); describe('VPC Table Row', () => { - it('should render a VPC row', () => { + it('should render a VPC row', async () => { const vpc = vpcFactory.build({ id: 24, subnets: [subnetFactory.build()] }); resizeScreenSize(1600); - const { getByText } = renderWithTheme( + const { getByText, getByLabelText } = renderWithTheme( wrapWithTableBody( { ) ); + const actionMenu = getByLabelText(`Action menu for VPC ${vpc.label}`); + await userEvent.click(actionMenu); // Check to see if the row rendered some data expect(getByText(vpc.label)).toBeVisible(); expect(getByText(vpc.id)).toBeVisible(); @@ -52,7 +54,7 @@ describe('VPC Table Row', () => { it('should have a delete button that calls the provided callback when clicked', async () => { const vpc = vpcFactory.build(); const handleDelete = vi.fn(); - const { getByTestId } = renderWithTheme( + const { getByTestId, getByLabelText } = renderWithTheme( wrapWithTableBody( { /> ) ); + const actionMenu = getByLabelText(`Action menu for VPC ${vpc.label}`); + await userEvent.click(actionMenu); + const deleteBtn = getByTestId('Delete'); await userEvent.click(deleteBtn); expect(handleDelete).toHaveBeenCalled(); @@ -70,7 +75,7 @@ describe('VPC Table Row', () => { it('should have an edit button that calls the provided callback when clicked', async () => { const vpc = vpcFactory.build(); const handleEdit = vi.fn(); - const { getByTestId } = renderWithTheme( + const { getByTestId, getByLabelText } = renderWithTheme( wrapWithTableBody( { /> ) ); + const actionMenu = getByLabelText(`Action menu for VPC ${vpc.label}`); + await userEvent.click(actionMenu); + const editButton = getByTestId('Edit'); await userEvent.click(editButton); expect(handleEdit).toHaveBeenCalled(); @@ -94,7 +102,7 @@ describe('VPC Table Row', () => { }); const vpc = vpcFactory.build(); const handleEdit = vi.fn(); - const { getByTestId } = renderWithTheme( + const { getByTestId, getByLabelText } = renderWithTheme( wrapWithTableBody( { /> ) ); + const actionMenu = getByLabelText(`Action menu for VPC ${vpc.label}`); + await userEvent.click(actionMenu); + const editButton = getByTestId('Edit'); - expect(editButton).toBeDisabled(); + expect(editButton).toHaveAttribute('aria-disabled', 'true'); const deleteButton = getByTestId('Delete'); - expect(deleteButton).toBeDisabled(); + expect(deleteButton).toHaveAttribute('aria-disabled', 'true'); }); it('should enable "Edit" and "Delete" button if user has "update_vpc" and "delete_vpc" permissions', async () => { queryMocks.userPermissions.mockReturnValue({ @@ -118,7 +129,7 @@ describe('VPC Table Row', () => { }); const vpc = vpcFactory.build(); const handleEdit = vi.fn(); - const { getByTestId } = renderWithTheme( + const { getByTestId, getByLabelText } = renderWithTheme( wrapWithTableBody( { /> ) ); + const actionMenu = getByLabelText(`Action menu for VPC ${vpc.label}`); + await userEvent.click(actionMenu); + const editButton = getByTestId('Edit'); expect(editButton).toBeEnabled(); const deleteButton = getByTestId('Delete'); diff --git a/packages/manager/src/features/VPCs/VPCLanding/VPCRow.tsx b/packages/manager/src/features/VPCs/VPCLanding/VPCRow.tsx index ad7ac6e70ca..eae57e2347a 100644 --- a/packages/manager/src/features/VPCs/VPCLanding/VPCRow.tsx +++ b/packages/manager/src/features/VPCs/VPCLanding/VPCRow.tsx @@ -2,7 +2,7 @@ import { useRegionsQuery } from '@linode/queries'; import { Hidden } from '@linode/ui'; import * as React from 'react'; -import { InlineMenuAction } from 'src/components/InlineMenuAction/InlineMenuAction'; +import { type Action, ActionMenu } from 'src/components/ActionMenu/ActionMenu'; import { Link } from 'src/components/Link'; import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; @@ -15,7 +15,6 @@ import { } from '../utils'; import type { VPC } from '@linode/api-v4/lib/vpcs/types'; -import type { Action } from 'src/components/ActionMenu/ActionMenu'; interface Props { handleDeleteVPC: () => void; @@ -33,15 +32,18 @@ export const VPCRow = ({ const { id, label, subnets } = vpc; const { data: regions } = useRegionsQuery(); + const [isOpen, setIsOpen] = React.useState(false); + const regionLabel = regions?.find((r) => r.id === vpc.region)?.label ?? ''; const numResources = isNodebalancerVPCEnabled ? getUniqueResourcesFromSubnets(vpc.subnets) : getUniqueLinodesFromSubnets(vpc.subnets); - const { data: permissions } = usePermissions( + const { data: permissions, isLoading } = usePermissions( 'vpc', ['update_vpc', 'delete_vpc'], - vpc.id + vpc.id, + isOpen ); const actions: Action[] = [ @@ -87,16 +89,12 @@ export const VPCRow = ({ {numResources} - {actions.map((action) => ( - - ))} + setIsOpen(true)} + /> ); diff --git a/packages/manager/src/features/VPCs/components/VPCRangesDescription.tsx b/packages/manager/src/features/VPCs/components/VPCRangesDescription.tsx index 4c1ee246cb8..e6d0b4df449 100644 --- a/packages/manager/src/features/VPCs/components/VPCRangesDescription.tsx +++ b/packages/manager/src/features/VPCs/components/VPCRangesDescription.tsx @@ -39,8 +39,8 @@ export const VPCIPv6RangesDescription = (props: TypographyProps) => { export const DualStackVPCRangesDescription = (props: TypographyProps) => { return ( - If you need more IPs, you can add IPv4 and IPv6 address ranges to let your - VPC connect to services running on this Linode.{' '} + You can add IPv4 and IPv6 address ranges to let your VPC connect to + services running on this Linode.{' '} Learn more. ); diff --git a/packages/manager/src/features/VPCs/constants.tsx b/packages/manager/src/features/VPCs/constants.tsx index 6a40c4217c4..2bb73f1fd31 100644 --- a/packages/manager/src/features/VPCs/constants.tsx +++ b/packages/manager/src/features/VPCs/constants.tsx @@ -1,4 +1,4 @@ -import { Typography } from '@linode/ui'; +import { Stack, Typography } from '@linode/ui'; import React from 'react'; import { Code } from 'src/components/Code/Code'; @@ -31,18 +31,20 @@ export const MULTIPLE_CONFIGURATIONS_MESSAGE = export const VPC_AUTO_ASSIGN_IPV4_TOOLTIP = 'Automatically assign an IPv4 address as the private IP address for this Linode in the VPC.'; -export const VPC_AUTO_ASSIGN_IPV6_TOOLTIP = ( - - Automatically assign an IPv6 address from the subnet’s allocated{' '} - /64 prefix block. - -); +export const VPC_IPV4_INPUT_HELPER_TEXT = + 'Define an IP address derived from the subnet IPv4 range.'; + +export const VPC_AUTO_ASSIGN_IPV6_TOOLTIP = + 'Automatically assign an IPv6 address for this Linode in the VPC.'; + +export const VPC_IPV6_INPUT_HELPER_TEXT = + 'Define a /64 prefix derived from the subnet IPv6 range.'; export const CANNOT_CREATE_VPC_MESSAGE = "You don't have permissions to create a new VPC. Please contact an account administrator for details."; export const VPC_CREATE_FORM_SUBNET_HELPER_TEXT = - 'Each VPC can further segment itself into distinct networks through the use of multiple subnets. These subnets can isolate various functionality of an application.'; + 'A VPC can be divided into multiple subnets to create isolated network segments. Subnets help separate different parts of your application, such as databases, frontend services, and backend services.'; export const VPC_CREATE_FORM_VPC_HELPER_TEXT = 'A VPC is an isolated network that enables private communication between Compute Instances within the same data center.'; @@ -59,11 +61,27 @@ export const ASSIGN_IP_RANGES_TITLE = 'Assign additional IP ranges'; export const PUBLIC_IPV4_ACCESS_CHECKBOX_TOOLTIP = 'Allow IPv4 access to the internet using 1:1 NAT on the VPC interface.'; -export const PUBLIC_IPV6_ACCESS_CHECKBOX_TOOLTIP = - "To enable IPv6 internet access, assign a globally routed IPv6 prefix to the subnet and enable the interface's Public setting."; +export const PUBLIC_IPV6_ACCESS_CHECKBOX_TOOLTIP = ( + + + Enable to allow two-way IPv6 traffic between your VPC and the internet. + + + Disable to restrict IPv6 traffic to within the VPC. + + + When enabled, Linodes will be publicly reachable over IPv6 unless + restricted by a Cloud Firewall. + + +); -export const RFC1918HelperText = - 'The VPC can use the entire RFC 1918 specified range for subnetting except for 192.168.128.0/17.'; +export const RFC1918HelperText = ( + + VPCs can use the full RFC 1918 private IP address range for subnetting, + except for 192.168.128.0/17, which is reserved. + +); // Linode Config dialog helper text for unrecommended configurations export const LINODE_UNREACHABLE_HELPER_TEXT = diff --git a/packages/manager/src/features/VPCs/utils.test.ts b/packages/manager/src/features/VPCs/utils.test.ts index bf5e3997a9b..0d6682a0337 100644 --- a/packages/manager/src/features/VPCs/utils.test.ts +++ b/packages/manager/src/features/VPCs/utils.test.ts @@ -12,7 +12,6 @@ import { } from 'src/factories/subnets'; import { - generateVPCIPv6InputHelperText, getLinodeInterfaceIPv4Ranges, getLinodeInterfacePrimaryIPv4, getUniqueLinodesFromSubnets, @@ -385,18 +384,3 @@ describe('transformLinodeInterfaceErrorsToFormikErrors', () => { ]); }); }); - -describe('generateVPCIPv6InputHelperText', () => { - it('returns null when subnetIPv6Range is falsy', () => { - expect(generateVPCIPv6InputHelperText(undefined)).toBeNull(); - expect(generateVPCIPv6InputHelperText('')).toBeNull(); - }); - - it('returns helper text that correctly represents the number of fixed hextets', () => { - const result = generateVPCIPv6InputHelperText('2600:3c03::/64'); - expect(result).toBe('The first 4 hextets of 2600:3c03::/64 are fixed.'); - - const result2 = generateVPCIPv6InputHelperText('2600:3c03::/56'); - expect(result2).toBe('The first 3.5 hextets of 2600:3c03::/56 are fixed.'); - }); -}); diff --git a/packages/manager/src/features/VPCs/utils.ts b/packages/manager/src/features/VPCs/utils.ts index 6d4057c487a..5e96fb4388e 100644 --- a/packages/manager/src/features/VPCs/utils.ts +++ b/packages/manager/src/features/VPCs/utils.ts @@ -240,15 +240,3 @@ export const transformLinodeInterfaceErrorsToFormikErrors = ( return errors; }; - -export const generateVPCIPv6InputHelperText = (subnetIPv6Range?: string) => { - if (!subnetIPv6Range) { - return null; - } - - const [, ipv6Mask] = subnetIPv6Range.split('/'); - - const fixedHextets = Number(ipv6Mask) / 16; - - return `The first ${fixedHextets} hextets of ${subnetIPv6Range} are fixed.`; -}; diff --git a/packages/manager/src/features/Volumes/Dialogs/DeleteVolumeDialog.tsx b/packages/manager/src/features/Volumes/Dialogs/DeleteVolumeDialog.tsx index 13c4a5b65e4..938da3ddabf 100644 --- a/packages/manager/src/features/Volumes/Dialogs/DeleteVolumeDialog.tsx +++ b/packages/manager/src/features/Volumes/Dialogs/DeleteVolumeDialog.tsx @@ -9,13 +9,15 @@ import type { APIError, Volume } from '@linode/api-v4'; interface Props { isFetching?: boolean; onClose: () => void; + onDeleteSuccess?: () => void; open: boolean; volume: undefined | Volume; volumeError?: APIError[] | null; } export const DeleteVolumeDialog = (props: Props) => { - const { isFetching, onClose, open, volume, volumeError } = props; + const { isFetching, onClose, onDeleteSuccess, open, volume, volumeError } = + props; const { error, @@ -27,7 +29,12 @@ export const DeleteVolumeDialog = (props: Props) => { const onDelete = () => { deleteVolume({ id: volume?.id ?? -1 }).then(() => { - onClose(); + if (onDeleteSuccess) { + onDeleteSuccess(); + } else { + onClose(); + } + checkForNewEvents(); }); }; diff --git a/packages/manager/src/features/Volumes/Partials/VolumesActionMenu.tsx b/packages/manager/src/features/Volumes/Partials/VolumesActionMenu.tsx index 2452ba4f9b2..5da3152a1ae 100644 --- a/packages/manager/src/features/Volumes/Partials/VolumesActionMenu.tsx +++ b/packages/manager/src/features/Volumes/Partials/VolumesActionMenu.tsx @@ -72,6 +72,13 @@ export const VolumesActionMenu = (props: Props) => { disabled: !volumePermissions?.update_volume, onClick: handlers.handleManageTags, title: 'Manage Tags', + tooltip: !volumePermissions?.update_volume + ? getRestrictedResourceText({ + action: 'edit', + isSingular: true, + resourceType: 'Volumes', + }) + : undefined, }, RESIZE: { disabled: !volumePermissions?.resize_volume, diff --git a/packages/manager/src/features/Volumes/VolumeCreate.tsx b/packages/manager/src/features/Volumes/VolumeCreate.tsx index 498c5995610..a49d2a1d4e6 100644 --- a/packages/manager/src/features/Volumes/VolumeCreate.tsx +++ b/packages/manager/src/features/Volumes/VolumeCreate.tsx @@ -170,22 +170,6 @@ export const VolumeCreate = () => { ) .map((thisRegion) => thisRegion.id) ?? []; - const renderSelectTooltip = (tooltipText: string) => { - return ( - - ); - }; - const { enqueueSnackbar } = useSnackbar(); const { @@ -426,12 +410,10 @@ export const VolumeCreate = () => { onBlur={handleBlur} onChange={(e, region) => handleRegionChange(region)} regions={regions ?? []} + tooltipText="Volumes must be created in a region. You can choose to create a Volume in a region and attach it later to a Linode in the same region." value={values.region} width={400} /> - {renderSelectTooltip( - 'Volumes must be created in a region. You can choose to create a Volume in a region and attach it later to a Linode in the same region.' - )} { }} value={values.linode_id} /> - {renderSelectTooltip( - 'If you select a Linode, the Volume will be automatically created in that Linode’s region and attached upon creation.' - )} + {shouldDisplayClientLibraryCopy && values.encryption === 'enabled' && ( diff --git a/packages/manager/src/features/Volumes/VolumeDetails/VolumeDetails.tsx b/packages/manager/src/features/Volumes/VolumeDetails/VolumeDetails.tsx index d29983324a9..9a500492981 100644 --- a/packages/manager/src/features/Volumes/VolumeDetails/VolumeDetails.tsx +++ b/packages/manager/src/features/Volumes/VolumeDetails/VolumeDetails.tsx @@ -36,6 +36,13 @@ export const VolumeDetails = () => { return ; } + const navigateToVolumes = () => { + navigate({ + search: (prev) => prev, + to: '/volumes', + }); + }; + const navigateToVolumeSummary = () => { navigate({ search: (prev) => prev, @@ -62,7 +69,10 @@ export const VolumeDetails = () => { - + ); }; diff --git a/packages/manager/src/features/Volumes/VolumeDetails/VolumeDetailsHeader.tsx b/packages/manager/src/features/Volumes/VolumeDetails/VolumeDetailsHeader.tsx index a717462fd9f..8420243d2cb 100644 --- a/packages/manager/src/features/Volumes/VolumeDetails/VolumeDetailsHeader.tsx +++ b/packages/manager/src/features/Volumes/VolumeDetails/VolumeDetailsHeader.tsx @@ -28,7 +28,7 @@ export const VolumeDetailsHeader = ({ volume }: Props) => { pathname: `/volumes/${volume.label}`, }} docsLabel="Getting Started" - docsLink="https://techdocs.akamai.com/cloud-computing/docs/faqs-for-compute-instances" + docsLink="https://techdocs.akamai.com/cloud-computing/docs/block-storage" entity="Volume" spacingBottom={16} /> diff --git a/packages/manager/src/features/Volumes/VolumeDrawers/VolumeDrawers.tsx b/packages/manager/src/features/Volumes/VolumeDrawers/VolumeDrawers.tsx index 409617819e7..6745c377f16 100644 --- a/packages/manager/src/features/Volumes/VolumeDrawers/VolumeDrawers.tsx +++ b/packages/manager/src/features/Volumes/VolumeDrawers/VolumeDrawers.tsx @@ -14,9 +14,13 @@ import { VolumeDetailsDrawer } from './VolumeDetailsDrawer'; interface Props { onCloseHandler: () => void; + onDeleteSuccessHandler: () => void; } -export const VolumeDrawers = ({ onCloseHandler }: Props) => { +export const VolumeDrawers = ({ + onCloseHandler, + onDeleteSuccessHandler, +}: Props) => { const params = useParams({ strict: false }); const { @@ -85,6 +89,7 @@ export const VolumeDrawers = ({ onCloseHandler }: Props) => { { pageSize={pagination.pageSize} /> - + ); }; diff --git a/packages/manager/src/mocks/mockState.ts b/packages/manager/src/mocks/mockState.ts index 2a6d78f4d1e..c6b05eacaee 100644 --- a/packages/manager/src/mocks/mockState.ts +++ b/packages/manager/src/mocks/mockState.ts @@ -22,11 +22,14 @@ export const getStateSeederGroups = ( }; export const emptyStore: MockState = { + childAccounts: [], cloudnats: [], configInterfaces: [], + delegations: [], destinations: [], domainRecords: [], domains: [], + entities: [], eventQueue: [], firewallDevices: [], firewalls: [], diff --git a/packages/manager/src/mocks/presets/baseline/crud.ts b/packages/manager/src/mocks/presets/baseline/crud.ts index 51b4519e118..c6140d30212 100644 --- a/packages/manager/src/mocks/presets/baseline/crud.ts +++ b/packages/manager/src/mocks/presets/baseline/crud.ts @@ -6,7 +6,9 @@ import { import { linodeCrudPreset } from 'src/mocks/presets/crud/linodes'; import { cloudNATCrudPreset } from '../crud/cloudnats'; +import { childAccountsCrudPreset } from '../crud/delegation'; import { domainCrudPreset } from '../crud/domains'; +import { entityCrudPreset } from '../crud/entities'; import { firewallCrudPreset } from '../crud/firewalls'; import { kubernetesCrudPreset } from '../crud/kubernetes'; import { nodeBalancerCrudPreset } from '../crud/nodebalancers'; @@ -21,9 +23,11 @@ import type { MockPresetBaseline } from 'src/mocks/types'; export const baselineCrudPreset: MockPresetBaseline = { group: { id: 'General' }, handlers: [ + ...childAccountsCrudPreset.handlers, ...cloudNATCrudPreset.handlers, ...domainCrudPreset.handlers, ...deliveryCrudPreset.handlers, + ...entityCrudPreset.handlers, ...firewallCrudPreset.handlers, ...kubernetesCrudPreset.handlers, ...linodeCrudPreset.handlers, diff --git a/packages/manager/src/mocks/presets/crud/delegation.ts b/packages/manager/src/mocks/presets/crud/delegation.ts new file mode 100644 index 00000000000..fc3a6ad52b6 --- /dev/null +++ b/packages/manager/src/mocks/presets/crud/delegation.ts @@ -0,0 +1,24 @@ +import { + childAccountDelegates, + defaultDelegationAccess, + delegatedChildAccounts, + generateChildAccountToken, + getChildAccounts, + getDelegatedChildAccountsForUser, +} from 'src/mocks/presets/crud/handlers/delegation'; + +import type { MockPresetCrud } from 'src/mocks/types'; + +export const childAccountsCrudPreset: MockPresetCrud = { + group: { id: 'Child Accounts' }, + handlers: [ + getChildAccounts, + getDelegatedChildAccountsForUser, + childAccountDelegates, + delegatedChildAccounts, + generateChildAccountToken, + defaultDelegationAccess, + ], + id: 'child-accounts:crud', + label: 'Child Accounts CRUD', +}; diff --git a/packages/manager/src/mocks/presets/crud/entities.ts b/packages/manager/src/mocks/presets/crud/entities.ts new file mode 100644 index 00000000000..fa735b49440 --- /dev/null +++ b/packages/manager/src/mocks/presets/crud/entities.ts @@ -0,0 +1,10 @@ +import { getEntities } from 'src/mocks/presets/crud/handlers/entities'; + +import type { MockPresetCrud } from 'src/mocks/types'; + +export const entityCrudPreset: MockPresetCrud = { + group: { id: 'Entities' }, + handlers: [getEntities], + id: 'entities:crud', + label: 'Entities CRUD', +}; diff --git a/packages/manager/src/mocks/presets/crud/handlers/delegation.ts b/packages/manager/src/mocks/presets/crud/handlers/delegation.ts new file mode 100644 index 00000000000..35901534041 --- /dev/null +++ b/packages/manager/src/mocks/presets/crud/handlers/delegation.ts @@ -0,0 +1,318 @@ +import { http } from 'msw'; + +import { accountFactory } from 'src/factories/account'; +import { mswDB } from 'src/mocks/indexedDB'; +import { + makeNotFoundResponse, + makePaginatedResponse, + makeResponse, +} from 'src/mocks/utilities/response'; + +import type { + Account, + ChildAccount, + IamUserRoles, + Token, +} from '@linode/api-v4'; +import type { StrictResponse } from 'msw'; +import type { MockState } from 'src/mocks/types'; +import type { + APIErrorResponse, + APIPaginatedResponse, +} from 'src/mocks/utilities/response'; + +export const getChildAccounts = () => [ + http.get( + '*/v4*/iam/delegation/child-accounts*', + async ({ + request, + }): Promise< + StrictResponse> + > => { + const childAccounts = await mswDB.getAll('childAccounts'); + const delegations = await mswDB.getAll('delegations'); + const withUsers = request.url.includes('users=true'); + + if (!childAccounts || !delegations) { + return makeNotFoundResponse(); + } + + return makePaginatedResponse({ + data: childAccounts.map((account) => ({ + ...account, + users: withUsers + ? delegations + .filter((d) => d.childAccountEuuid === account.euuid) + .map((d) => d.username) + : undefined, + })), + request, + }); + } + ), + + http.get( + '*/v4*/iam/delegation/child-accounts/:id', + async ({ + params, + }): Promise> => { + const id = Number(params.id); + const entity = await mswDB.get('childAccounts', id); + + if (!entity) { + return makeNotFoundResponse(); + } + + return makeResponse(entity); + } + ), +]; + +export const getDelegatedChildAccountsForUser = () => [ + http.get( + '*/v4*/iam/delegation/users/:username/child-accounts', + async ({ + params, + request, + }): Promise< + StrictResponse> + > => { + const username = params.username; + const delegations = await mswDB.getAll('delegations'); + const childAccounts = await mswDB.getAll('childAccounts'); + + if (!childAccounts || !delegations) { + return makeNotFoundResponse(); + } + + const userDelegations = delegations.filter( + (d) => d.username === username + ); + + return makePaginatedResponse({ + data: childAccounts.filter((account) => + userDelegations.some((d) => d.childAccountEuuid === account.euuid) + ), + request, + }); + } + ), +]; + +export const childAccountDelegates = (mockState: MockState) => [ + http.get( + '*/v4*/iam/delegation/child-accounts/:euuid/users', + async ({ + params, + request, + }): Promise< + StrictResponse> + > => { + const euuid = params.euuid as string; + const delegations = await mswDB.getAll('delegations'); + + if (!delegations) { + return makeNotFoundResponse(); + } + + // Get all usernames delegated to this specific child account + const delegateUsernames = delegations + .filter((d) => d.childAccountEuuid === euuid) + .map((d) => d.username); + + return makePaginatedResponse({ + data: delegateUsernames, + request, + }); + } + ), + + http.put( + '*/v4*/iam/delegation/child-accounts/:euuid/users', + async ({ + params, + request, + }): Promise< + StrictResponse> + > => { + const euuid = params.euuid as string; + const requestData = (await request.json()) as { users: string[] }; + const newUsernames = requestData?.users || []; + + // Get current delegations + const allDelegations = await mswDB.getAll('delegations'); + if (!allDelegations) { + return makeNotFoundResponse(); + } + + // Find and delete delegations for this child account + const delegationsToDelete = allDelegations.filter( + (d) => d.childAccountEuuid === euuid + ); + + for (const delegation of delegationsToDelete) { + await mswDB.delete('delegations', delegation.id, mockState); + } + + // Add new delegations + for (const username of newUsernames) { + await mswDB.add( + 'delegations', + { + childAccountEuuid: euuid, + username, + id: Math.floor(Math.random() * 1000000), + }, + mockState + ); + } + + return makePaginatedResponse({ + data: newUsernames, + request, + }); + } + ), +]; + +export const delegatedChildAccounts = () => [ + http.get( + '*/v4*/iam/delegation/profile/child-accounts', + async ({ + request, + }): Promise< + StrictResponse> + > => { + // For mocking purposes, we'll simulate getting the current user's delegated accounts + // In real implementation, this would use authentication context + const delegations = await mswDB.getAll('delegations'); + const childAccounts = await mswDB.getAll('childAccounts'); + + if (!childAccounts) { + return makeNotFoundResponse(); + } + + const allDelegations = await mswDB.getAll('delegations'); + const mockCurrentUser = allDelegations?.[0]?.username || 'mockuser'; + const userDelegations = delegations?.filter( + (d) => d.username === mockCurrentUser + ); + + const delegatedAccounts = childAccounts + .filter((account) => + userDelegations?.some((d) => d.childAccountEuuid === account.euuid) + ) + .map((childAccount) => ({ + ...accountFactory.build({ + company: childAccount.company, + euuid: childAccount.euuid, + }), + ...childAccount, + })); + + return makePaginatedResponse({ + data: delegatedAccounts, + request, + }); + } + ), + + http.get( + '*/v4*/iam/delegation/profile/child-accounts/:euuid', + async ({ params }): Promise> => { + const euuid = params.euuid as string; + const childAccount = await mswDB.getAll('childAccounts'); + + if (!childAccount) { + return makeNotFoundResponse(); + } + + const account = childAccount.find((acc) => acc.euuid === euuid); + + if (!account) { + return makeNotFoundResponse(); + } + + // Convert ChildAccount to full Account + const fullAccount = { + ...accountFactory.build({ + company: account.company, + euuid: account.euuid, + }), + ...account, + }; + + return makeResponse(fullAccount); + } + ), +]; + +export const generateChildAccountToken = () => [ + http.post( + '*/v4*/iam/delegation/profile/child-accounts/:euuid/token', + async ({ params }): Promise> => { + const euuid = params.euuid as string; + + // Verify the child account exists + const childAccounts = await mswDB.getAll('childAccounts'); + if (!childAccounts?.some((acc) => acc.euuid === euuid)) { + return makeNotFoundResponse(); + } + + // Generate mock token + const mockToken: Token = { + id: Math.floor(Math.random() * 10000), + created: new Date().toISOString(), + expiry: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), // 24 hours + label: `Child Account Token - ${euuid}`, + scopes: '*', + token: `mock_token_${euuid}_${Date.now()}`, + }; + + return makeResponse(mockToken); + } + ), +]; + +export const defaultDelegationAccess = () => [ + http.get( + '*/v4*/iam/delegation/default-role-permissions', + async (): Promise> => { + // Mock default delegation access + const mockDefaultAccess: IamUserRoles = { + account_access: [ + 'account_linode_admin', + 'account_linode_creator', + 'account_firewall_creator', + ], + entity_access: [ + { + id: 12345678, + type: 'linode' as const, + roles: ['linode_contributor'], + }, + { + id: 45678901, + type: 'firewall' as const, + roles: ['firewall_admin'], + }, + ], + }; + + return makeResponse(mockDefaultAccess); + } + ), + + http.put( + '*/v4*/iam/delegation/default-role-permissions', + async ({ + request, + }): Promise> => { + const requestData = (await request.json()) as IamUserRoles; + + // In a real implementation, you'd validate and store this + // For mocking, just return what was sent + return makeResponse(requestData); + } + ), +]; diff --git a/packages/manager/src/mocks/presets/crud/handlers/delivery.ts b/packages/manager/src/mocks/presets/crud/handlers/delivery.ts index c7ae5c8ad5b..b8dcf4a8cd0 100644 --- a/packages/manager/src/mocks/presets/crud/handlers/delivery.ts +++ b/packages/manager/src/mocks/presets/crud/handlers/delivery.ts @@ -1,3 +1,4 @@ +import { destinationType } from '@linode/api-v4'; import { DateTime } from 'luxon'; import { http } from 'msw'; @@ -11,7 +12,12 @@ import { makeResponse, } from 'src/mocks/utilities/response'; -import type { Destination, Stream } from '@linode/api-v4'; +import type { + CreateDestinationPayload, + Destination, + LinodeObjectStorageDetails, + Stream, +} from '@linode/api-v4'; import type { StrictResponse } from 'msw'; import type { MockState } from 'src/mocks/types'; import type { @@ -67,7 +73,7 @@ export const createStreams = (mockState: MockState) => [ destinations: payload['destinations'].map((destinationId: number) => destinations?.find(({ id }) => id === destinationId) ), - details: payload['details'], + details: payload['details'] ?? null, created: DateTime.now().toISO(), updated: DateTime.now().toISO(), }); @@ -214,11 +220,17 @@ export const createDestinations = (mockState: MockState) => [ async ({ request, }): Promise> => { - const payload = await request.clone().json(); + const payload: CreateDestinationPayload = await request.clone().json(); + const details = payload.details; const destination = destinationFactory.build({ - label: payload['label'], - type: payload['type'], - details: payload['details'], + label: payload.label, + type: payload.type, + details: { + ...details, + ...(payload.type === destinationType.LinodeObjectStorage + ? { path: (details as LinodeObjectStorageDetails).path ?? null } + : {}), + }, created: DateTime.now().toISO(), updated: DateTime.now().toISO(), }); diff --git a/packages/manager/src/mocks/presets/crud/handlers/entities.ts b/packages/manager/src/mocks/presets/crud/handlers/entities.ts new file mode 100644 index 00000000000..f39ae9e6b81 --- /dev/null +++ b/packages/manager/src/mocks/presets/crud/handlers/entities.ts @@ -0,0 +1,50 @@ +import { http } from 'msw'; + +import { mswDB } from 'src/mocks/indexedDB'; +import { + makeNotFoundResponse, + makePaginatedResponse, + makeResponse, +} from 'src/mocks/utilities/response'; + +import type { Entity } from '@linode/api-v4'; +import type { StrictResponse } from 'msw'; +import type { + APIErrorResponse, + APIPaginatedResponse, +} from 'src/mocks/utilities/response'; + +export const getEntities = () => [ + http.get( + '*/v4*/entities', + async ({ + request, + }): Promise< + StrictResponse> + > => { + const entities = await mswDB.getAll('entities'); + + if (!entities) { + return makeNotFoundResponse(); + } + return makePaginatedResponse({ + data: entities, + request, + }); + } + ), + + http.get( + '*/v4*/entities/:id', + async ({ params }): Promise> => { + const id = Number(params.id); + const entity = await mswDB.get('entities', id); + + if (!entity) { + return makeNotFoundResponse(); + } + + return makeResponse(entity); + } + ), +]; diff --git a/packages/manager/src/mocks/presets/crud/seeds/delegation.ts b/packages/manager/src/mocks/presets/crud/seeds/delegation.ts new file mode 100644 index 00000000000..b35a6d8a2f5 --- /dev/null +++ b/packages/manager/src/mocks/presets/crud/seeds/delegation.ts @@ -0,0 +1,57 @@ +import { + childAccountFactory, + mockDelegateUsersList, + pickRandomMultiple, +} from '@linode/utilities'; + +import { getSeedsCountMap } from 'src/dev-tools/utils'; +import { mswDB } from 'src/mocks/indexedDB'; + +import type { MockSeeder, MockState } from 'src/mocks/types'; + +export const delegationSeeder: MockSeeder = { + canUpdateCount: true, + desc: 'Child Accounts and Delegations Seeds', + group: { id: 'Child Accounts' }, + id: 'child-accounts:crud', + label: 'Child Accounts & Delegations', + + seeder: async (mockState: MockState) => { + const seedsCountMap = getSeedsCountMap(); + const count = seedsCountMap[delegationSeeder.id] ?? 3; // Default to 3 child accounts + + // 1. Seed Child Accounts (basic account info only) + const childAccounts = childAccountFactory.buildList(count); + + // 2. Seed Delegations (many-to-many relationships) + const delegations = []; + let delegationId = 1; + + for (const childAccount of childAccounts) { + // Randomly assign 1-3 users to each child account + const numDelegates = Math.floor(Math.random() * 3) + 1; + const selectedUsers = pickRandomMultiple( + mockDelegateUsersList, + numDelegates + ); + + for (const username of selectedUsers) { + delegations.push({ + id: delegationId++, + childAccountEuuid: childAccount.euuid, + username, + }); + } + } + + const updatedMockState = { + ...mockState, + childAccounts: mockState.childAccounts.concat(childAccounts), + delegations: mockState.delegations.concat(delegations), + }; + + await mswDB.saveStore(updatedMockState, 'seedState'); + + return updatedMockState; + }, +}; diff --git a/packages/manager/src/mocks/presets/crud/seeds/entities.ts b/packages/manager/src/mocks/presets/crud/seeds/entities.ts new file mode 100644 index 00000000000..7bbfd8c9cbc --- /dev/null +++ b/packages/manager/src/mocks/presets/crud/seeds/entities.ts @@ -0,0 +1,28 @@ +import { getSeedsCountMap } from 'src/dev-tools/utils'; +import { entityFactory } from 'src/factories'; +import { mswDB } from 'src/mocks/indexedDB'; + +import type { MockSeeder, MockState } from 'src/mocks/types'; + +export const entitiesSeeder: MockSeeder = { + canUpdateCount: true, + desc: 'Entities Seeds', + group: { id: 'Entities' }, + id: 'entities:crud', + label: 'Entities', + + seeder: async (mockState: MockState) => { + const seedsCountMap = getSeedsCountMap(); + const count = seedsCountMap[entitiesSeeder.id] ?? 0; + const entities = entityFactory.buildList(count); + + const updatedMockState = { + ...mockState, + entities: mockState.entities.concat(entities), + }; + + await mswDB.saveStore(updatedMockState, 'seedState'); + + return updatedMockState; + }, +}; diff --git a/packages/manager/src/mocks/presets/crud/seeds/index.ts b/packages/manager/src/mocks/presets/crud/seeds/index.ts index c46a700914a..6d82f0d18f1 100644 --- a/packages/manager/src/mocks/presets/crud/seeds/index.ts +++ b/packages/manager/src/mocks/presets/crud/seeds/index.ts @@ -1,5 +1,7 @@ import { cloudNATSeeder } from './cloudnats'; +import { delegationSeeder } from './delegation'; import { domainSeeder } from './domains'; +import { entitiesSeeder } from './entities'; import { firewallSeeder } from './firewalls'; import { kubernetesSeeder } from './kubernetes'; import { linodesSeeder } from './linodes'; @@ -12,7 +14,9 @@ import { vpcSeeder } from './vpcs'; export const dbSeeders = [ cloudNATSeeder, + delegationSeeder, domainSeeder, + entitiesSeeder, firewallSeeder, ipAddressSeeder, kubernetesSeeder, diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index 35dab25ceb8..f7871b31f79 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -195,9 +195,17 @@ const makeMockDatabase = (params: PathParams): Database => { db.ssl_connection = true; } const database = databaseFactory.build(db); + if (database.platform !== 'rdbms-default') { delete database.private_network; } + + if (database.platform === 'rdbms-default' && !!database.private_network) { + // When a database is configured with a VPC, the primary host is prepended with 'private-' + const privateHost = `private-${database.hosts.primary}`; + database.hosts.primary = privateHost; + } + return database; }; @@ -1008,8 +1016,8 @@ export const handlers = [ label: 'aclp-supported-region-linode-1', region: 'us-iad', alerts: { - user: [21, 22, 23, 24, 25], - system: [19, 20], + user_alerts: [21, 22, 23, 24, 25], + system_alerts: [19, 20], cpu: 0, io: 0, network_in: 0, @@ -1024,8 +1032,8 @@ export const handlers = [ label: 'aclp-supported-region-linode-2', region: 'us-east', alerts: { - user: [], - system: [], + user_alerts: [], + system_alerts: [], cpu: 10, io: 10000, network_in: 0, @@ -1043,8 +1051,8 @@ export const handlers = [ label: 'aclp-supported-region-linode-3', region: 'us-iad', alerts: { - user: [], - system: [], + user_alerts: [], + system_alerts: [], cpu: 0, io: 0, network_in: 0, @@ -1340,6 +1348,16 @@ export const handlers = [ region: 'us-mia', s3_endpoint: 'us-mia-1.linodeobjects.com', }), + objectStorageEndpointsFactory.build({ + endpoint_type: 'E3', + region: 'ap-west', + s3_endpoint: 'ap-west-1.linodeobjects.com', + }), + objectStorageEndpointsFactory.build({ + endpoint_type: 'E3', + region: 'us-iad', + s3_endpoint: 'us-iad-1.linodeobjects.com', + }), ]; return HttpResponse.json(makeResourcePage(endpoints)); }), @@ -1441,14 +1459,49 @@ export const handlers = [ Math.random() * 4 )}` as ObjectStorageEndpointTypes; - const buckets = objectStorageBucketFactoryGen2.buildList(1, { - cluster: `${region}-1`, - endpoint_type: randomEndpointType, - hostname: `obj-bucket-${randomBucketNumber}.${region}.linodeobjects.com`, - label: `obj-bucket-${randomBucketNumber}`, - region, - }); - + const buckets = + region !== 'ap-west' && region !== 'us-iad' + ? objectStorageBucketFactoryGen2.buildList(1, { + cluster: `${region}-1`, + endpoint_type: randomEndpointType, + hostname: `obj-bucket-${randomBucketNumber}.${region}.linodeobjects.com`, + label: `obj-bucket-${randomBucketNumber}`, + region, + }) + : []; + if (region === 'ap-west') { + buckets.push( + objectStorageBucketFactoryGen2.build({ + cluster: `ap-west-1`, + endpoint_type: 'E3', + s3_endpoint: 'ap-west-1.linodeobjects.com', + hostname: `obj-bucket-804.ap-west.linodeobjects.com`, + label: `obj-bucket-804`, + region, + }) + ); + buckets.push( + objectStorageBucketFactoryGen2.build({ + cluster: `ap-west-1`, + endpoint_type: 'E3', + s3_endpoint: 'ap-west-1.linodeobjects.com', + hostname: `obj-bucket-902.ap-west.linodeobjects.com`, + label: `obj-bucket-902`, + region, + }) + ); + } + if (region === 'us-iad') + buckets.push( + objectStorageBucketFactoryGen2.build({ + cluster: `us-iad-1`, + endpoint_type: 'E3', + s3_endpoint: 'us-iad-1.linodeobjects.com', + hostname: `obj-bucket-230.us-iad.linodeobjects.com`, + label: `obj-bucket-230`, + region, + }) + ); return HttpResponse.json({ data: buckets.slice( (page - 1) * pageSize, @@ -2865,6 +2918,13 @@ export const handlers = [ scope: 'region', regions: ['us-east'], }), + ...alertFactory.buildList(6, { + service_type: serviceType === 'dbaas' ? 'dbaas' : 'linode', + type: 'user', + scope: 'entity', + regions: ['us-east'], + entity_ids: ['5', '6'], + }), ], }); } @@ -2934,6 +2994,13 @@ export const handlers = [ rules: [firewallMetricRulesFactory.build()], }, }), + alertFactory.build({ + id: 550, + label: 'Object Storage - testing', + type: 'user', + service_type: 'objectstorage', + entity_ids: ['obj-bucket-804.ap-west.linodeobjects.com'], + }), ]; return HttpResponse.json(makeResourcePage(alerts)); }), @@ -2954,6 +3021,17 @@ export const handlers = [ }) ); } + if (params.id === '550' && params.serviceType === 'objectstorage') { + return HttpResponse.json( + alertFactory.build({ + id: 550, + type: 'user', + label: 'object-storage -testing', + service_type: 'objectstorage', + entity_ids: ['obj-bucket-804.ap-west.linodeobjects.com'], + }) + ); + } if (params.id !== undefined) { return HttpResponse.json( alertFactory.build({ @@ -3041,6 +3119,12 @@ export const handlers = [ regions: 'us-iad,us-east', alert: serviceAlertFactory.build({ scope: ['entity'] }), }), + serviceTypesFactory.build({ + label: 'Object Storage', + service_type: 'objectstorage', + regions: 'us-iad,us-east', + alert: serviceAlertFactory.build({ scope: ['entity'] }), + }), ], }; @@ -3053,6 +3137,7 @@ export const handlers = [ dbaas: 'Databases', nodebalancer: 'NodeBalancers', firewall: 'Firewalls', + objectstorage: 'Object Storage', }; const response = serviceTypesFactory.build({ service_type: `${serviceType}`, @@ -3060,6 +3145,7 @@ export const handlers = [ alert: serviceAlertFactory.build({ evaluation_period_seconds: [300], polling_interval_seconds: [300], + scope: ['entity'], }), }); @@ -3128,6 +3214,16 @@ export const handlers = [ ); } + if (params.serviceType === 'objectstorage') { + response.data.push( + dashboardFactory.build({ + id: 6, + label: 'Object Storage Dashboard', + service_type: 'objectstorage', + }) + ); + } + return HttpResponse.json(response); }), http.get( @@ -3414,6 +3510,9 @@ export const handlers = [ } else if (id === '4') { serviceType = 'firewall'; dashboardLabel = 'Firewall Service I/O Statistics'; + } else if (id === '6') { + serviceType = 'objectstorage'; + dashboardLabel = 'Object Storage Service I/O Statistics'; } else { serviceType = 'linode'; dashboardLabel = 'Linode Service I/O Statistics'; @@ -3554,9 +3653,8 @@ export const handlers = [ }, { metric: { - entity_id: '789', + entity_id: 'obj-bucket-383.ap-west.linodeobjects.com', metric_name: 'average_cpu_usage', - node_id: 'primary-3', }, values: [ [1721854379, '0.3744841110560275'], diff --git a/packages/manager/src/mocks/types.ts b/packages/manager/src/mocks/types.ts index 5b13155d66c..36b6d539767 100644 --- a/packages/manager/src/mocks/types.ts +++ b/packages/manager/src/mocks/types.ts @@ -1,9 +1,11 @@ import type { + ChildAccount, CloudNAT, Config, Destination, Domain, DomainRecord, + Entity, Event, Firewall, FirewallDevice, @@ -120,9 +122,11 @@ export interface MockPresetExtra extends MockPresetBase { */ export type MockPresetCrudGroup = { id: + | 'Child Accounts' | 'CloudNATs' | 'Delivery' | 'Domains' + | 'Entities' | 'Firewalls' | 'IP Addresses' | 'Kubernetes' @@ -135,9 +139,12 @@ export type MockPresetCrudGroup = { | 'VPCs'; }; export type MockPresetCrudId = + | 'child-accounts-for-user:crud' + | 'child-accounts:crud' | 'cloudnats:crud' | 'delivery:crud' | 'domains:crud' + | 'entities:crud' | 'firewalls:crud' | 'ip-addresses:crud' | 'kubernetes:crud' @@ -156,15 +163,24 @@ export interface MockPresetCrud extends MockPresetBase { export type MockHandler = (mockState: MockState) => HttpHandler[]; +interface Delegation { + childAccountEuuid: string; + id: number; + username: string; +} + /** * Stateful data shared among mocks. */ export interface MockState { + childAccounts: ChildAccount[]; cloudnats: CloudNAT[]; configInterfaces: [number, Interface][]; // number is Config ID + delegations: Delegation[]; destinations: Destination[]; domainRecords: DomainRecord[]; domains: Domain[]; + entities: Entity[]; eventQueue: Event[]; firewallDevices: [number, FirewallDevice][]; // number is Firewall ID firewalls: Firewall[]; diff --git a/packages/manager/src/queries/cloudpulse/alerts.ts b/packages/manager/src/queries/cloudpulse/alerts.ts index 8b78ef91fa8..c0ea1b86b13 100644 --- a/packages/manager/src/queries/cloudpulse/alerts.ts +++ b/packages/manager/src/queries/cloudpulse/alerts.ts @@ -15,6 +15,7 @@ import { } from '@tanstack/react-query'; import { queryFactory } from './queries'; +import { invalidateAclpAlerts } from './useAlertsMutation'; import type { Alert, @@ -248,48 +249,12 @@ export const useServiceAlertsMutation = ( entityId: string ) => { const queryClient = useQueryClient(); - return useMutation<{}, APIError[], CloudPulseAlertsPayload>({ + return useMutation({ mutationFn: (payload: CloudPulseAlertsPayload) => { return updateServiceAlerts(serviceType, entityId, payload); }, onSuccess(_, payload) { - const allAlerts = queryClient.getQueryData( - queryFactory.alerts._ctx.all().queryKey - ); - - // Get alerts previously enabled for this entity - const oldEnabledAlertIds = - allAlerts - ?.filter((alert) => alert.entity_ids.includes(entityId)) - .map((alert) => alert.id) || []; - - // Combine enabled user and system alert IDs from payload - const newEnabledAlertIds = [ - ...(payload.user ?? []), - ...(payload.system ?? []), - ]; - - // Get unique list of all enabled alert IDs for cache invalidation - const alertIdsToInvalidate = Array.from( - new Set([...oldEnabledAlertIds, ...newEnabledAlertIds]) - ); - - queryClient.invalidateQueries({ - queryKey: queryFactory.resources(serviceType).queryKey, - }); - - queryClient.invalidateQueries({ - queryKey: queryFactory.alerts._ctx.all().queryKey, - }); - - alertIdsToInvalidate.forEach((alertId) => { - queryClient.invalidateQueries({ - queryKey: queryFactory.alerts._ctx.alertByServiceTypeAndId( - serviceType, - String(alertId) - ).queryKey, - }); - }); + invalidateAclpAlerts(queryClient, serviceType, entityId, payload); }, }); }; diff --git a/packages/manager/src/queries/cloudpulse/queries.ts b/packages/manager/src/queries/cloudpulse/queries.ts index d53fedbfb23..78f5cb06872 100644 --- a/packages/manager/src/queries/cloudpulse/queries.ts +++ b/packages/manager/src/queries/cloudpulse/queries.ts @@ -16,6 +16,11 @@ import { } from '@linode/queries'; import { createQueryKeys } from '@lukemorales/query-key-factory'; +import { objectStorageQueries } from '../object-storage/queries'; +import { + getAllBucketsFromEndpoints, + getAllObjectStorageEndpoints, +} from '../object-storage/requests'; import { fetchCloudPulseMetrics } from './metrics'; import { getAllAlertsRequest, @@ -121,12 +126,18 @@ export const queryFactory = createQueryKeys(key, { queryFn: () => getAllLinodesRequest(params, filters), // since we don't have query factory implementation, in linodes.ts, once it is ready we will reuse that, untill then we will use same query keys queryKey: ['linodes', params, filters], }; - case 'nodebalancer': return nodebalancerQueries.nodebalancers._ctx.all(params, filters); + case 'objectstorage': + return { + queryFn: () => getAllBuckets(), + queryKey: [ + ...objectStorageQueries.endpoints.queryKey, + objectStorageQueries.buckets.queryKey[1], + ], + }; case 'volumes': return volumeQueries.lists._ctx.all(params, filters); // in this we don't need to define our own query factory, we will reuse existing implementation in volumes.ts - default: return volumeQueries.lists._ctx.all(params, filters); // default to volumes } @@ -134,6 +145,23 @@ export const queryFactory = createQueryKeys(key, { token: (serviceType: string | undefined, request: JWETokenPayLoad) => ({ queryFn: () => getJWEToken(request, serviceType!), - queryKey: [serviceType, { resource_ids: request.entity_ids.sort() }], + queryKey: [serviceType, { resource_ids: request.entity_ids?.sort() }], }), }); + +const getAllBuckets = async () => { + const endpoints = await getAllObjectStorageEndpoints(); + + // Get all the buckets from the endpoints + const allBuckets = await getAllBucketsFromEndpoints(endpoints); + + // Throw the error if we encounter any error for any single call. + if (allBuckets.errors.length) { + throw new Error('Unable to fetch the data.'); + } + + // Filter the E0, E1 endpoint_type out and return the buckets + return allBuckets.buckets.filter( + (bucket) => bucket.endpoint_type !== 'E0' && bucket.endpoint_type !== 'E1' + ); +}; diff --git a/packages/manager/src/queries/cloudpulse/resources.ts b/packages/manager/src/queries/cloudpulse/resources.ts index 4fa2e79d4eb..074c6c98eb9 100644 --- a/packages/manager/src/queries/cloudpulse/resources.ts +++ b/packages/manager/src/queries/cloudpulse/resources.ts @@ -14,7 +14,11 @@ export const useResourcesQuery = ( useQuery({ ...queryFactory.resources(resourceType, params, filters), enabled, + retry: resourceType === 'objectstorage' ? false : 3, select: (resources) => { + if (!enabled) { + return []; // Return empty array if the query is not enabled + } return resources.map((resource) => { const entities: Record = {}; @@ -33,13 +37,18 @@ export const useResourcesQuery = ( } }); } + const id = + resourceType === 'objectstorage' + ? resource.hostname + : String(resource.id); return { engineType: resource.engine, - id: String(resource.id), - label: resource.label, + id, + label: resourceType === 'objectstorage' ? id : resource.label, region: resource.region, regions: resource.regions ? resource.regions : [], tags: resource.tags, + endpoint: resource.s3_endpoint, entities, clusterSize: resource.cluster_size, }; diff --git a/packages/manager/src/queries/cloudpulse/services.ts b/packages/manager/src/queries/cloudpulse/services.ts index b85b82ee659..583a395b317 100644 --- a/packages/manager/src/queries/cloudpulse/services.ts +++ b/packages/manager/src/queries/cloudpulse/services.ts @@ -1,3 +1,4 @@ +import { queryPresets } from '@linode/queries'; import { keepPreviousData, useQuery } from '@tanstack/react-query'; import { queryFactory } from './queries'; @@ -23,6 +24,8 @@ export const useGetCloudPulseMetricDefinitionsByServiceType = ( return useQuery, APIError[]>({ ...queryFactory.metricsDefinitons(serviceType, params, filter), enabled, + ...queryPresets.oneTimeFetch, // It is a configuration and not need to be refetched + retry: 2, }); }; diff --git a/packages/manager/src/queries/cloudpulse/useAlertsMutation.ts b/packages/manager/src/queries/cloudpulse/useAlertsMutation.ts new file mode 100644 index 00000000000..49ff9f3a751 --- /dev/null +++ b/packages/manager/src/queries/cloudpulse/useAlertsMutation.ts @@ -0,0 +1,122 @@ +import { + type CloudPulseAlertsPayload, + type CloudPulseServiceType, + type DeepPartial, + type Linode, +} from '@linode/api-v4'; +import { useLinodeUpdateMutation } from '@linode/queries'; + +import { queryFactory } from './queries'; + +import type { Alert, LinodeAlerts } from '@linode/api-v4/lib/cloudpulse'; +import type { QueryClient } from '@linode/queries'; + +/** + * The alert type overrides for a given service type. + * It contains the payload transformer function type and the response type. + * This is used for types only, not to be used anywhere else. + */ +interface AlertTypeOverrides { + linode: (basePayload: LinodeAlerts) => DeepPartial; + // Future overrides go here (e.g. dbaas, ...) +} + +/** + * The type of the payload transformer function for a given service type. + */ +type AlertPayloadTransformerFn = + T extends keyof AlertTypeOverrides + ? AlertTypeOverrides[T] + : (basePayload: CloudPulseAlertsPayload) => CloudPulseAlertsPayload; + +/** + * Type of the service payload transformer map + */ +export type ServicePayloadTransformerMap = Partial<{ + [K in CloudPulseServiceType]: AlertPayloadTransformerFn; +}>; + +/** + * Service payload transformer map + */ +export const servicePayloadTransformerMap: ServicePayloadTransformerMap = { + linode: (basePayload: LinodeAlerts) => ({ alerts: basePayload }), + // Future transformers go here (e.g. dbaas, ...) +}; + +/** + * + * @param serviceType service type + * @param entityId entity id + * @returns alerts mutation + */ +export const useAlertsMutation = ( + serviceType: CloudPulseServiceType, + entityId: string +) => { + // linode api alerts mutation + const { mutateAsync: updateLinode } = useLinodeUpdateMutation( + Number(entityId) + ); + + switch (serviceType) { + case 'linode': + return updateLinode; + default: + return (_payload: CloudPulseAlertsPayload) => + Promise.reject(new Error('Error encountered')); + } +}; + +/** + * Invalidates the alerts cache + * @param qc The query client + * @param serviceType The service type + * @param entityId The entity id + * @param payload The payload + */ +export const invalidateAclpAlerts = ( + queryClient: QueryClient, + serviceType: string, + entityId: string | undefined, + payload: CloudPulseAlertsPayload +) => { + if (!entityId) return; + + const allAlerts = queryClient.getQueryData( + queryFactory.alerts._ctx.alertsByServiceType(serviceType).queryKey + ); + + // Get alerts previously enabled for this entity + const oldEnabledAlertIds = + allAlerts + ?.filter((alert) => alert.entity_ids.includes(entityId)) + .map((alert) => alert.id) || []; + + // Combine enabled user and system alert IDs from payload + const newEnabledAlertIds = [ + ...(payload.user_alerts ?? []), + ...(payload.system_alerts ?? []), + ]; + + // Get unique list of all enabled alert IDs for cache invalidation + const alertIdsToInvalidate = [...oldEnabledAlertIds, ...newEnabledAlertIds]; + + queryClient.invalidateQueries({ + queryKey: queryFactory.alerts._ctx.all().queryKey, + }); + + queryClient.invalidateQueries({ + queryKey: + queryFactory.alerts._ctx.alertsByServiceType(serviceType).queryKey, + }); + + alertIdsToInvalidate.forEach((alertId) => { + queryClient.invalidateQueries({ + queryKey: queryFactory.alerts._ctx.alertByServiceTypeAndId( + serviceType, + String(alertId) + ).queryKey, + }); + }); +}; diff --git a/packages/manager/src/queries/entities/entities.ts b/packages/manager/src/queries/entities/entities.ts index 0f3dc308d1d..50e6fe5525a 100644 --- a/packages/manager/src/queries/entities/entities.ts +++ b/packages/manager/src/queries/entities/entities.ts @@ -3,11 +3,26 @@ import { useQuery } from '@tanstack/react-query'; import { entitiesQueries } from './queries'; -import type { AccountEntity, APIError, ResourcePage } from '@linode/api-v4'; +import type { + AccountEntity, + APIError, + Filter, + Params, + ResourcePage, +} from '@linode/api-v4'; -export const useAccountEntities = () => { - return useQuery, APIError[]>({ - ...entitiesQueries.entities, +export const useAllAccountEntities = ({ + enabled = true, + filter = {}, + params = {}, +}) => + useQuery({ + enabled, + ...entitiesQueries.all(params, filter), + }); + +export const useAccountEntities = (params: Params, filter: Filter) => + useQuery, APIError[]>({ + ...entitiesQueries.paginated(params, filter), ...queryPresets.shortLived, }); -}; diff --git a/packages/manager/src/queries/entities/queries.ts b/packages/manager/src/queries/entities/queries.ts index e5d237dbe58..00e76fcff89 100644 --- a/packages/manager/src/queries/entities/queries.ts +++ b/packages/manager/src/queries/entities/queries.ts @@ -1,10 +1,26 @@ import { getAccountEntities } from '@linode/api-v4'; +import { getAll } from '@linode/utilities'; import { createQueryKeys } from '@lukemorales/query-key-factory'; +import type { AccountEntity, Filter, Params } from '@linode/api-v4'; + +// TODO: Temporary—use getAll since API can’t filter yet. +// Switch to paginated + API filtering (X-Filter) when supported. +const getAllAccountEntitiesRequest = ( + _params: Params = {}, + _filter: Filter = {} +) => + getAll((params) => + getAccountEntities({ ...params, ..._params }) + )().then((data) => data.data); + export const entitiesQueries = createQueryKeys('entities', { - entities: { - queryFn: ({ pageParam }) => - getAccountEntities({ page: pageParam as number, page_size: 500 }), - queryKey: null, - }, + all: (params: Params = {}, filter: Filter = {}) => ({ + queryFn: () => getAllAccountEntitiesRequest(params, filter), + queryKey: [params, filter], + }), + paginated: (params: Params, filter: Filter) => ({ + queryFn: () => getAccountEntities(params), + queryKey: [params, filter], + }), }); diff --git a/packages/manager/src/queries/kubernetes.ts b/packages/manager/src/queries/kubernetes.ts index ecd0c84e735..5cbdbfa53fd 100644 --- a/packages/manager/src/queries/kubernetes.ts +++ b/packages/manager/src/queries/kubernetes.ts @@ -1,21 +1,16 @@ import { createKubernetesCluster, - createKubernetesClusterBeta, createNodePool, deleteKubernetesCluster, deleteNodePool, getKubeConfig, getKubernetesCluster, - getKubernetesClusterBeta, getKubernetesClusterControlPlaneACL, getKubernetesClusterDashboard, getKubernetesClusterEndpoints, getKubernetesClusters, - getKubernetesClustersBeta, - getKubernetesTieredVersionsBeta, + getKubernetesTieredVersions, getKubernetesTypes, - getKubernetesTypesBeta, - getKubernetesVersions, getNodePool, getNodePools, recycleAllNodes, @@ -46,7 +41,6 @@ import type { KubernetesDashboardResponse, KubernetesEndpointResponse, KubernetesTieredVersion, - KubernetesVersion, UpdateNodePoolData, } from '@linode/api-v4'; import type { @@ -64,12 +58,10 @@ export const kubernetesQueries = createQueryKeys('kubernetes', { queryFn: () => getKubernetesClusterControlPlaneACL(id), queryKey: [id], }, - cluster: (isUsingBetaEndpoint: boolean = false) => ({ - queryFn: isUsingBetaEndpoint - ? () => getKubernetesClusterBeta(id) - : () => getKubernetesCluster(id), - queryKey: [isUsingBetaEndpoint ? 'v4beta' : 'v4'], - }), + cluster: { + queryFn: () => getKubernetesCluster(id), + queryKey: null, + }, dashboard: { queryFn: () => getKubernetesClusterDashboard(id), queryKey: null, @@ -146,44 +138,28 @@ export const kubernetesQueries = createQueryKeys('kubernetes', { }), lists: { contextQueries: { - all: (isUsingBetaEndpoint: boolean = false) => ({ - queryFn: () => - isUsingBetaEndpoint - ? getAllKubernetesClustersBeta() - : getAllKubernetesClusters(), - queryKey: [isUsingBetaEndpoint ? 'v4beta' : 'v4'], - }), + all: { + queryFn: () => getAllKubernetesClusters(), + queryKey: null, + }, infinite: (filter: Filter = {}) => ({ queryFn: ({ pageParam }) => getKubernetesClusters({ page: pageParam as number }, filter), queryKey: [filter], }), - paginated: ( - params: Params, - filter: Filter, - isUsingBetaEndpoint: boolean = false - ) => ({ - queryFn: () => - isUsingBetaEndpoint - ? getKubernetesClustersBeta(params, filter) - : getKubernetesClusters(params, filter), - queryKey: [params, filter, isUsingBetaEndpoint ? 'v4beta' : 'v4'], + paginated: (params: Params, filter: Filter) => ({ + queryFn: () => getKubernetesClusters(params, filter), + queryKey: [params, filter], }), }, queryKey: null, }, tieredVersions: (tier: string) => ({ - queryFn: () => getAllKubernetesTieredVersionsBeta(tier), + queryFn: () => getAllKubernetesTieredVersions(tier), queryKey: [tier], }), - types: (isUsingBetaEndpoint: boolean = false) => ({ - queryFn: isUsingBetaEndpoint - ? getAllKubernetesTypesBeta - : () => getAllKubernetesTypes(), - queryKey: [isUsingBetaEndpoint ? 'v4beta' : 'v4'], - }), - versions: { - queryFn: () => getAllKubernetesVersions(), + types: { + queryFn: () => getAllKubernetesTypes(), queryKey: null, }, }); @@ -191,11 +167,10 @@ export const kubernetesQueries = createQueryKeys('kubernetes', { export const useKubernetesClusterQuery = ({ enabled = true, id = -1, - isUsingBetaEndpoint = false, options = {}, }) => { return useQuery({ - ...kubernetesQueries.cluster(id)._ctx.cluster(isUsingBetaEndpoint), + ...kubernetesQueries.cluster(id), enabled, ...options, }); @@ -222,7 +197,6 @@ export const useKubernetesClustersInfiniteQuery = ( interface KubernetesClustersQueryOptions { enabled?: boolean; filter: Filter; - isUsingBetaEndpoint: boolean; params: Params; } @@ -230,14 +204,9 @@ export const useKubernetesClustersQuery = ({ enabled = true, filter, params, - isUsingBetaEndpoint = false, }: KubernetesClustersQueryOptions) => { return useQuery, APIError[]>({ - ...kubernetesQueries.lists._ctx.paginated( - params, - filter, - isUsingBetaEndpoint - ), + ...kubernetesQueries.lists._ctx.paginated(params, filter), enabled, placeholderData: keepPreviousData, }); @@ -255,14 +224,9 @@ export const useKubernetesClusterMutation = (id: number) => { queryClient.invalidateQueries({ queryKey: kubernetesQueries.cluster(id)._ctx.acl.queryKey, }); - // queryClient.setQueryData( - // kubernetesQueries.cluster(id).queryKey, - // data - // ); - // Temporary cache update logic for APL - queryClient.setQueriesData( - { queryKey: kubernetesQueries.cluster(id)._ctx.cluster._def }, - (oldData) => ({ ...oldData, ...data }) + queryClient.setQueryData( + kubernetesQueries.cluster(id).queryKey, + data ); }, } @@ -342,27 +306,6 @@ export const useCreateKubernetesClusterMutation = () => { }); }; -/** - * duplicated function of useCreateKubernetesClusterMutation - * necessary to call BETA_API_ROOT in a separate function based on feature flag - */ - -export const useCreateKubernetesClusterBetaMutation = () => { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: createKubernetesClusterBeta, - onSuccess() { - queryClient.invalidateQueries({ - queryKey: kubernetesQueries.lists.queryKey, - }); - // If a restricted user creates an entity, we must make sure grants are up to date. - queryClient.invalidateQueries({ - queryKey: profileQueries.grants.queryKey, - }); - }, - }); -}; - export const useCreateNodePoolMutation = (clusterId: number) => { const queryClient = useQueryClient(); return useMutation({ @@ -468,12 +411,6 @@ export const useKubernetesDashboardQuery = ( }); }; -export const useKubernetesVersionQuery = () => - useQuery({ - ...kubernetesQueries.versions, - ...queryPresets.oneTimeFetch, - }); - export const useKubernetesTieredVersionsQuery = ( tier: string, enabled = true @@ -489,12 +426,9 @@ export const useKubernetesTieredVersionsQuery = ( * Avoiding fetching all Kubernetes Clusters if possible. * Before you use this, consider implementing infinite scroll instead. */ -export const useAllKubernetesClustersQuery = ({ - enabled = false, - isUsingBetaEndpoint = false, -}) => { +export const useAllKubernetesClustersQuery = ({ enabled = false }) => { return useQuery({ - ...kubernetesQueries.lists._ctx.all(isUsingBetaEndpoint), + ...kubernetesQueries.lists._ctx.all, enabled, }); }; @@ -537,19 +471,9 @@ const getAllKubernetesClusters = () => getKubernetesClusters(params, filters) )().then((data) => data.data); -const getAllKubernetesClustersBeta = () => - getAll((params, filters) => - getKubernetesClustersBeta(params, filters) - )().then((data) => data.data); - -const getAllKubernetesVersions = () => - getAll((params, filters) => - getKubernetesVersions(params, filters) - )().then((data) => data.data); - -const getAllKubernetesTieredVersionsBeta = (tier: string) => +const getAllKubernetesTieredVersions = (tier: string) => getAll((params, filters) => - getKubernetesTieredVersionsBeta(tier, params, filters) + getKubernetesTieredVersions(tier, params, filters) )().then((data) => data.data); const getAllAPIEndpointsForCluster = (clusterId: number) => @@ -562,13 +486,8 @@ const getAllKubernetesTypes = () => (results) => results.data ); -const getAllKubernetesTypesBeta = () => - getAll((params) => getKubernetesTypesBeta(params))().then( - (results) => results.data - ); - -export const useKubernetesTypesQuery = (isUsingBetaEndpoint?: boolean) => +export const useKubernetesTypesQuery = () => useQuery({ ...queryPresets.oneTimeFetch, - ...kubernetesQueries.types(isUsingBetaEndpoint), + ...kubernetesQueries.types, }); diff --git a/packages/queries/CHANGELOG.md b/packages/queries/CHANGELOG.md index 98ff6d86594..6906ca12c33 100644 --- a/packages/queries/CHANGELOG.md +++ b/packages/queries/CHANGELOG.md @@ -1,3 +1,26 @@ +## [2025-10-07] - v0.15.0 + + +### Added: + +- IAM RBAC: useAllAccountEntities to fetch all pages client-side via getAll, preventing missing items on large accounts ([#12888](https://github.com/linode/manager/pull/12888)) +- IAM Parent/Child - Implement new delegation query hooks ([#12895](https://github.com/linode/manager/pull/12895)) +- IAM Delegation: useAllListMyDelegatedChildAccountsQuery to fetch all data ([#12913](https://github.com/linode/manager/pull/12913)) +- Region VPC availability queries ([#12919](https://github.com/linode/manager/pull/12919)) + +### Changed: + +- ACLP: update metric definition queries cache time to inifinity ([#12887](https://github.com/linode/manager/pull/12887)) + +### Removed: + +- `isUsingBetaEndpoint` logic for kubernetes queries since all kubernetes endpoints +now use /v4beta ([#12867](https://github.com/linode/manager/pull/12867)) + +### Upcoming Features: + +- Logs Delivery Streams/Destinations update useAll queries ([#12802](https://github.com/linode/manager/pull/12802)) + ## [2025-09-23] - v0.14.0 ### Upcoming Features: diff --git a/packages/queries/package.json b/packages/queries/package.json index da2ebc09b46..283eddb8e89 100644 --- a/packages/queries/package.json +++ b/packages/queries/package.json @@ -1,6 +1,6 @@ { "name": "@linode/queries", - "version": "0.14.0", + "version": "0.15.0", "description": "Linode Utility functions library", "main": "src/index.js", "module": "src/index.ts", diff --git a/packages/queries/src/account/account.ts b/packages/queries/src/account/account.ts index 6f554902b29..735fe1c87c5 100644 --- a/packages/queries/src/account/account.ts +++ b/packages/queries/src/account/account.ts @@ -31,17 +31,22 @@ export const useMutateAccount = () => mutationFn: updateAccountInfo, }); -export const useChildAccountsInfiniteQuery = (options: RequestOptions) => { +export const useChildAccountsInfiniteQuery = ( + options: RequestOptions, + enabled = true, +) => { const { data: profile } = useProfile(); const { data: grants } = useGrants(); const hasExplicitAuthToken = Boolean(options.headers?.Authorization); - const enabled = - (Boolean(profile?.user_type === 'parent') && !profile?.restricted) || - Boolean(grants?.global?.child_account_access) || - hasExplicitAuthToken; + + const isEnabled = enabled + ? (Boolean(profile?.user_type === 'parent') && !profile?.restricted) || + Boolean(grants?.global?.child_account_access) || + hasExplicitAuthToken + : false; return useInfiniteQuery, APIError[]>({ - enabled, + enabled: isEnabled, getNextPageParam: ({ page, pages }) => { if (page === pages) { return undefined; diff --git a/packages/queries/src/delivery/delivery.ts b/packages/queries/src/delivery/delivery.ts index 314656eed11..503947d666b 100644 --- a/packages/queries/src/delivery/delivery.ts +++ b/packages/queries/src/delivery/delivery.ts @@ -33,7 +33,6 @@ import type { UpdateDestinationPayloadWithId, UpdateStreamPayloadWithId, } from '@linode/api-v4'; -import type { GetAllData } from '@linode/utilities'; export const getAllStreams = ( passedParams: Params = {}, @@ -41,7 +40,7 @@ export const getAllStreams = ( ) => getAll((params, filter) => getStreams({ ...params, ...passedParams }, { ...filter, ...passedFilter }), - )(); + )().then((data) => data.data); export const getAllDestinations = ( passedParams: Params = {}, @@ -52,7 +51,7 @@ export const getAllDestinations = ( { ...params, ...passedParams }, { ...filter, ...passedFilter }, ), - )(); + )().then((data) => data.data); export const deliveryQueries = createQueryKeys('delivery', { stream: (id: number) => ({ @@ -111,7 +110,7 @@ export const useAllStreamsQuery = ( filter: Filter = {}, enabled = true, ) => - useQuery, APIError[]>({ + useQuery({ ...deliveryQueries.streams._ctx.all(params, filter), enabled, }); @@ -209,7 +208,7 @@ export const useAllDestinationsQuery = ( filter: Filter = {}, enabled = true, ) => - useQuery, APIError[]>({ + useQuery({ ...deliveryQueries.destinations._ctx.all(params, filter), enabled, }); diff --git a/packages/queries/src/iam/delegation.ts b/packages/queries/src/iam/delegation.ts new file mode 100644 index 00000000000..41a3f51499e --- /dev/null +++ b/packages/queries/src/iam/delegation.ts @@ -0,0 +1,269 @@ +import { + generateChildAccountToken, + getChildAccountDelegates, + getChildAccountsIam, + getDefaultDelegationAccess, + getDelegatedChildAccount, + getDelegatedChildAccountsForUser, + getMyDelegatedChildAccounts, + updateChildAccountDelegates, + updateDefaultDelegationAccess, +} from '@linode/api-v4'; +import { getAll } from '@linode/utilities'; +import { createQueryKeys } from '@lukemorales/query-key-factory'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; + +import type { + Account, + APIError, + ChildAccount, + ChildAccountWithDelegates, + GetChildAccountDelegatesParams, + GetChildAccountsIamParams, + GetDelegatedChildAccountsForUserParams, + IamUserRoles, + Params, + ResourcePage, + Token, +} from '@linode/api-v4'; +import type { UseMutationResult, UseQueryResult } from '@tanstack/react-query'; + +export const delegationQueries = createQueryKeys('delegation', { + childAccounts: ({ params, users }: GetChildAccountsIamParams) => ({ + queryFn: () => getChildAccountsIam({ params, users }), + queryKey: [params], + }), + delegatedChildAccountsForUser: ({ + username, + params, + }: GetDelegatedChildAccountsForUserParams) => ({ + queryFn: () => getDelegatedChildAccountsForUser({ username, params }), + queryKey: [username, params], + }), + childAccountDelegates: ({ + euuid, + params, + }: GetChildAccountDelegatesParams) => ({ + queryFn: () => getChildAccountDelegates({ euuid, params }), + queryKey: [euuid, params], + }), + myDelegatedChildAccounts: { + contextQueries: { + all: (params: Params) => ({ + queryFn: () => getAllMyDelegatedChildAccounts(params), + queryKey: [params], + }), + paginated: (params: Params) => ({ + queryFn: () => getMyDelegatedChildAccounts({ params }), + queryKey: [params], + }), + }, + queryKey: null, + }, + delegatedChildAccount: (euuid: string) => ({ + queryFn: () => getDelegatedChildAccount({ euuid }), + queryKey: [euuid], + }), + defaultAccess: { + queryFn: getDefaultDelegationAccess, + queryKey: null, + }, +}); + +/** + * List all child accounts (gets all child accounts from customerParentChild table for the parent account) + * - Purpose: Get ALL child accounts under a parent account, optionally with their delegate users + * - Scope: All child accounts for the parent (inventory view) + * - Audience: Parent account administrators managing delegation. + * - CRUD: GET /iam/delegation/child-accounts?users=true (optional) + */ +export const useGetChildAccountsQuery = ({ + params, + users, +}: GetChildAccountsIamParams): UseQueryResult< + ResourcePage, + APIError[] +> => { + return useQuery({ + ...delegationQueries.childAccounts({ params, users }), + }); +}; + +/** + * List delegated child accounts for a user + * - Purpose: Get child accounts that a SPECIFIC user is delegated to manage (which child accounts a specific user can access) + * - Scope: Filtered by username - only child accounts where that user has active delegation + * - Audience: Parent account administrators auditing a user’s delegated access. + * - CRUD: GET /iam/delegation/users/:username/child-accounts + */ +export const useGetDelegatedChildAccountsForUserQuery = ({ + username, + params, +}: GetDelegatedChildAccountsForUserParams): UseQueryResult< + ResourcePage, + APIError[] +> => { + return useQuery({ + ...delegationQueries.delegatedChildAccountsForUser({ username, params }), + }); +}; + +/** + * List delegates for a child account + * - Purpose: Get all delegate users for a SPECIFIC child account + * - Scope: Filtered by child account euuid - only users delegated to that account + * - Audience: Parent account administrators managing delegates for a SPECIFIC child account. + * - CRUD: GET /iam/delegation/child-accounts/:euuid/users + */ +export const useGetChildAccountDelegatesQuery = ({ + euuid, + params, +}: GetChildAccountDelegatesParams): UseQueryResult< + ResourcePage, + APIError[] +> => { + return useQuery({ + ...delegationQueries.childAccountDelegates({ + euuid, + params, + }), + }); +}; + +/** + * Update delegates for a child account + * - Purpose: Replace the full set of parent users delegated to a child account. + * - Scope: Requires parent-account context, valid parent→child relationship, and authorization; payload must be non-empty. + * - Audience: Parent account administrators assigning/removing delegates for a SPECIFIC child account. + * - CRUD: PUT /iam/delegation/child-accounts/:euuid/users + */ +export const useUpdateChildAccountDelegatesQuery = (): UseMutationResult< + ResourcePage, + APIError[], + { data: string[]; euuid: string } +> => { + const queryClient = useQueryClient(); + return useMutation< + ResourcePage, + APIError[], + { data: string[]; euuid: string } + >({ + mutationFn: updateChildAccountDelegates, + onSuccess(_data, { euuid }) { + // Invalidate all child account delegates + queryClient.invalidateQueries({ + queryKey: delegationQueries.childAccountDelegates({ euuid }).queryKey, + }); + }, + }); +}; + +/** + * List my delegated child accounts (gets child accounts where user has view_child_account permission). + * - Purpose: Get child accounts that the current authenticated user can manage via delegation. + * - Scope: Only child accounts where the caller has an active delegate and required view permission. + * - Audience: Needing to return accounts the caller can actually access. + * - CRUD: GET /iam/delegation/profile/child-accounts + */ +export const useGetMyDelegatedChildAccountsQuery = ( + params: Params, +): UseQueryResult, APIError[]> => { + return useQuery({ + ...delegationQueries.myDelegatedChildAccounts._ctx.paginated(params), + }); +}; + +/** + * List all my delegated child accounts (fetches all pages of child accounts where user has view_child_account permission) + * - Purpose: Retrieve the full list of child accounts the current caller can manage via delegation, across all pages. + * - Scope: Only child accounts where the caller has an active delegate and required view permission; returns all results, not paginated. + * - Audience: Callers needing the complete set of accessible accounts for the current user. + * - Data: Account[] (limited profile fields) for `GET /iam/delegation/profile/child-accounts` (all pages). + * - Usage: Pass `enabled` to control query activation (e.g., only if IAM Delegation is enabled). + */ +export const useAllListMyDelegatedChildAccountsQuery = ({ + params = {}, + enabled = true, +}) => { + return useQuery({ + enabled, + ...delegationQueries.myDelegatedChildAccounts._ctx.all(params), + }); +}; + +/** + * Get child account + * - Purpose: Get SPECIFIC child account that the current authenticated user can manage via delegation. + * - Scope: Only child accounts where the caller has active delegation and required view permission. + * - Audience: The current user needing to see which accounts they can actually access. + * - CRUD: GET /iam/delegation/profile/child-accounts/:euuid + */ +export const useGetChildAccountQuery = ( + euuid: string, +): UseQueryResult => { + return useQuery({ + ...delegationQueries.delegatedChildAccount(euuid), + }); +}; + +/** + * Create child account token + * - Purpose: Create a short‑lived bearer token to act on a child account as a proxy/delegate. + * - Scope: For a parent user delegated on the target child account identified by `euuid`. + * - Audience: Clients that need temporary auth to perform actions in the child account. + * - Data: Token for `POST /iam/delegation/child-accounts/:euuid/token`. + */ +export const useGenerateChildAccountTokenQuery = (): UseMutationResult< + Token, + APIError[], + { euuid: string } +> => { + return useMutation({ + mutationFn: generateChildAccountToken, + }); +}; + +/** + * Get default delegation access + * - Purpose: View the default access (roles/permissions) applied to new delegates on this child account. + * - Scope: Child-account context; restricted to authorized, non-delegate callers. + * - Audience: Child account administrators reviewing default delegate access. + * - Data: IamUserRoles with `account_access` and `entity_access` for `GET /iam/delegation/default-role-permissions`. + */ +export const useGetDefaultDelegationAccessQuery = (): UseQueryResult< + IamUserRoles, + APIError[] +> => { + return useQuery({ + ...delegationQueries.defaultAccess, + }); +}; + +/** + * Update default delegation access + * - Purpose: Update the default access (roles/permissions) applied to new delegates on this child account. + * - Scope: Child-account context; restricted to authorized, non-delegate callers; validates entity IDs. + * - Audience: Child account administrators configuring default delegate access. + * - Data: Request/Response IamUserRoles for `PUT /iam/delegation/default-role-permissions`. + */ +export const useUpdateDefaultDelegationAccessQuery = (): UseMutationResult< + IamUserRoles, + APIError[], + IamUserRoles +> => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: updateDefaultDelegationAccess, + onSuccess(data) { + queryClient.setQueryData(delegationQueries.defaultAccess.queryKey, data); + }, + }); +}; + +/** + * Fetches all my delegated child accounts for the current user (all pages). + */ +const getAllMyDelegatedChildAccounts = (_params: Params = {}) => + getAll((params) => + getMyDelegatedChildAccounts({ params: { ...params, ..._params } }), + )().then((data) => data.data); diff --git a/packages/queries/src/iam/index.ts b/packages/queries/src/iam/index.ts index cb812bcecee..8af2cd08614 100644 --- a/packages/queries/src/iam/index.ts +++ b/packages/queries/src/iam/index.ts @@ -1,2 +1,3 @@ +export * from './delegation'; export * from './iam'; export * from './keys'; diff --git a/packages/queries/src/regions/regions.ts b/packages/queries/src/regions/regions.ts index 1377c5c4b6f..0a735b36aaf 100644 --- a/packages/queries/src/regions/regions.ts +++ b/packages/queries/src/regions/regions.ts @@ -1,4 +1,8 @@ -import { getRegion, getRegionAvailability } from '@linode/api-v4/lib/regions'; +import { + getRegion, + getRegionAvailability, + getRegionVPCAvailability, +} from '@linode/api-v4/lib/regions'; import { getNewRegionLabel } from '@linode/utilities'; import { createQueryKeys } from '@lukemorales/query-key-factory'; import { queryOptions, useQuery, useQueryClient } from '@tanstack/react-query'; @@ -7,9 +11,14 @@ import { queryPresets } from '../base'; import { getAllRegionAvailabilitiesRequest, getAllRegionsRequest, + getAllRegionVPCAvailabilitiesRequest, } from './requests'; -import type { Region, RegionAvailability } from '@linode/api-v4/lib/regions'; +import type { + Region, + RegionAvailability, + RegionVPCAvailability, +} from '@linode/api-v4/lib/regions'; import type { APIError } from '@linode/api-v4/lib/types'; export const regionQueries = createQueryKeys('regions', { @@ -23,6 +32,19 @@ export const regionQueries = createQueryKeys('regions', { queryFn: () => getRegionAvailability(regionId), queryKey: [regionId], }), + vpc: { + contextQueries: { + all: { + queryFn: getAllRegionVPCAvailabilitiesRequest, + queryKey: null, + }, + region: (regionId: string) => ({ + queryFn: () => getRegionVPCAvailability(regionId), + queryKey: [regionId], + }), + }, + queryKey: null, + }, }, queryKey: null, }, @@ -80,3 +102,19 @@ export const useRegionAvailabilityQuery = ( enabled, }); }; + +export const useRegionsVPCAvailabilitiesQuery = (enabled: boolean = false) => + useQuery({ + ...regionQueries.availability._ctx.vpc._ctx.all, + enabled, + }); + +export const useRegionVPCAvailabilityQuery = ( + regionId: string, + enabled: boolean = false, +) => { + return useQuery({ + ...regionQueries.availability._ctx.vpc._ctx.region(regionId), + enabled, + }); +}; diff --git a/packages/queries/src/regions/requests.ts b/packages/queries/src/regions/requests.ts index 490101e19c3..74e0575aaff 100644 --- a/packages/queries/src/regions/requests.ts +++ b/packages/queries/src/regions/requests.ts @@ -1,7 +1,15 @@ -import { getRegionAvailabilities, getRegions } from '@linode/api-v4'; +import { + getRegionAvailabilities, + getRegions, + getRegionsVPCAvailabilities, +} from '@linode/api-v4'; import { getAll } from '@linode/utilities'; -import type { Region, RegionAvailability } from '@linode/api-v4'; +import type { + Region, + RegionAvailability, + RegionVPCAvailability, +} from '@linode/api-v4'; export const getAllRegionsRequest = () => getAll((params) => getRegions(params))().then((data) => data.data); @@ -10,3 +18,8 @@ export const getAllRegionAvailabilitiesRequest = () => getAll((params, filters) => getRegionAvailabilities(params, filters), )().then((data) => data.data); + +export const getAllRegionVPCAvailabilitiesRequest = () => + getAll((params, filters) => + getRegionsVPCAvailabilities(params, filters), + )().then((data) => data.data); diff --git a/packages/shared/CHANGELOG.md b/packages/shared/CHANGELOG.md index dc146b6d71c..fbc2689765d 100644 --- a/packages/shared/CHANGELOG.md +++ b/packages/shared/CHANGELOG.md @@ -1,3 +1,9 @@ +## [2025-10-07] - v0.9.0 + +### Changed: + +- Update `useIsLinodeAclpSubscribed` to reflect updated API fields ([#12870](https://github.com/linode/manager/pull/12870)) + ## [2025-09-09] - v0.8.0 ### Tests: diff --git a/packages/shared/package.json b/packages/shared/package.json index 781bde21a8d..f586871fe92 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -1,6 +1,6 @@ { "name": "@linode/shared", - "version": "0.8.0", + "version": "0.9.0", "description": "Linode shared feature component library", "main": "src/index.ts", "module": "src/index.ts", @@ -49,4 +49,4 @@ "@types/react-dom": "^19.1.6", "vite-plugin-svgr": "^3.2.0" } -} \ No newline at end of file +} diff --git a/packages/shared/src/hooks/useIsLinodeAclpSubscribed.test.ts b/packages/shared/src/hooks/useIsLinodeAclpSubscribed.test.ts index 1c9aac39bb5..acb95442383 100644 --- a/packages/shared/src/hooks/useIsLinodeAclpSubscribed.test.ts +++ b/packages/shared/src/hooks/useIsLinodeAclpSubscribed.test.ts @@ -47,8 +47,8 @@ describe('useIsLinodeAclpSubscribed', () => { network_in: 0, network_out: 0, transfer_quota: 0, - system: [], - user: [], + system_alerts: [], + user_alerts: [], }, }, }); @@ -67,8 +67,8 @@ describe('useIsLinodeAclpSubscribed', () => { network_in: 0, network_out: 0, transfer_quota: 0, - system: [], - user: [], + system_alerts: [], + user_alerts: [], }, }, }); @@ -87,8 +87,8 @@ describe('useIsLinodeAclpSubscribed', () => { network_in: 0, network_out: 0, transfer_quota: 0, - system: [], - user: [], + system_alerts: [], + user_alerts: [], }, }, }); @@ -107,8 +107,8 @@ describe('useIsLinodeAclpSubscribed', () => { network_in: 0, network_out: 0, transfer_quota: 0, - system: [100], - user: [], + system_alerts: [100], + user_alerts: [], }, }, }); @@ -127,8 +127,8 @@ describe('useIsLinodeAclpSubscribed', () => { network_in: 0, network_out: 0, transfer_quota: 0, - system: [100], - user: [200], + system_alerts: [100], + user_alerts: [200], }, }, }); diff --git a/packages/shared/src/hooks/useIsLinodeAclpSubscribed.ts b/packages/shared/src/hooks/useIsLinodeAclpSubscribed.ts index df71aa5b692..a2667c199e2 100644 --- a/packages/shared/src/hooks/useIsLinodeAclpSubscribed.ts +++ b/packages/shared/src/hooks/useIsLinodeAclpSubscribed.ts @@ -39,8 +39,8 @@ export const useIsLinodeAclpSubscribed = ( (linode.alerts.transfer_quota ?? 0) > 0; const hasAclpAlerts = - (linode.alerts.system?.length ?? 0) > 0 || - (linode.alerts.user?.length ?? 0) > 0; + (linode.alerts.system_alerts?.length ?? 0) > 0 || + (linode.alerts.user_alerts?.length ?? 0) > 0; // Always subscribed if ACLP alerts exist. For GA stage, default to subscribed if no alerts exist. return ( diff --git a/packages/utilities/CHANGELOG.md b/packages/utilities/CHANGELOG.md index 8dd220b6fb8..45657d0fdc0 100644 --- a/packages/utilities/CHANGELOG.md +++ b/packages/utilities/CHANGELOG.md @@ -1,3 +1,10 @@ +## [2025-10-07] - v0.10.0 + + +### Added: + +- Added `regionVPCAvailabilityFactory` in regions.ts ([#12919](https://github.com/linode/manager/pull/12919)) + ## [2025-09-23] - v0.9.0 ### Changed: diff --git a/packages/utilities/package.json b/packages/utilities/package.json index 0de28898e10..1d7b0441c4f 100644 --- a/packages/utilities/package.json +++ b/packages/utilities/package.json @@ -1,6 +1,6 @@ { "name": "@linode/utilities", - "version": "0.9.0", + "version": "0.10.0", "description": "Linode Utility functions library", "main": "src/index.ts", "module": "src/index.ts", @@ -46,4 +46,4 @@ "@types/react-dom": "^19.1.6", "factory.ts": "^0.5.1" } -} \ No newline at end of file +} diff --git a/packages/utilities/src/__data__/regionsData.ts b/packages/utilities/src/__data__/regionsData.ts index 84587c53e5c..371d18f1a66 100644 --- a/packages/utilities/src/__data__/regionsData.ts +++ b/packages/utilities/src/__data__/regionsData.ts @@ -13,6 +13,7 @@ export const regions: Region[] = [ 'VPCs', 'Block Storage Migrations', 'Managed Databases', + 'Object Storage', ], country: 'in', id: 'ap-west', @@ -27,7 +28,10 @@ export const regions: Region[] = [ }, site_type: 'core', status: 'ok', - monitors: { alerts: ['Cloud Firewall'], metrics: [] }, + monitors: { + alerts: ['Cloud Firewall', 'Object Storage'], + metrics: ['Object Storage'], + }, }, { capabilities: [ @@ -113,7 +117,7 @@ export const regions: Region[] = [ }, site_type: 'core', status: 'ok', - monitors: { alerts: ['Linodes'], metrics: ['Linodes'] }, + monitors: { alerts: ['Linodes', 'Object Storage'], metrics: ['Linodes'] }, }, { capabilities: [ diff --git a/packages/utilities/src/factories/delegation.ts b/packages/utilities/src/factories/delegation.ts new file mode 100644 index 00000000000..d57349eac7e --- /dev/null +++ b/packages/utilities/src/factories/delegation.ts @@ -0,0 +1,89 @@ +import { Factory } from './factoryProxy'; + +import type { + Account, + ChildAccount, + ChildAccountWithDelegates, + IamUserRoles, +} from '@linode/api-v4'; + +export const mockDelegateUsersList = [ + 'John Doe', + 'Jill Smith', + 'Jack Black', + 'Barbara White', + 'Tom Brown', + 'Sam Davis', + 'Alice Wilson', + 'Bob Taylor', + 'Charlie Moore', + 'Diana Harris', + 'Ethan Clark', + 'Fiona Scott', + 'George Green', + 'Hannah Brown', + 'Isaac Lee', + 'Julia Davis', + 'Kevin Wilson', + 'Linda Moore', + 'Michael Harris', + 'Nancy Taylor', + 'Oliver Clark', + 'Patricia Scott', + 'Quincy Green', +]; + +export const childAccountFactory = Factory.Sync.makeFactory({ + company: Factory.each((i) => `child-account-${i}`), + euuid: Factory.each(() => window.crypto.randomUUID()), +}); + +export const childAccountWithDelegatesFactory = + Factory.Sync.makeFactory({ + company: Factory.each((i) => `child-account-${i}`), + euuid: Factory.each(() => window.crypto.randomUUID()), + users: [], + }); + +export const delegatedChildAccountsForUserFactory = + Factory.Sync.makeFactory({ + company: Factory.each((i) => `child-account-${i}`), + euuid: Factory.each(() => window.crypto.randomUUID()), + }); + +export const childAccountDelegatesFactory = Factory.Sync.makeFactory( + [], +); + +export const myDelegatedChildAccountsFactory = + Factory.Sync.makeFactory({ + euuid: Factory.each(() => window.crypto.randomUUID()), + active_promotions: [], + active_since: '', + address_1: '', + address_2: '', + balance: 0, + balance_uninvoiced: 0, + billing_source: 'linode', + capabilities: [], + city: '', + company: 'Parent Account Company', + country: '', + credit_card: { + expiry: '', + last_four: '', + }, + email: 'parent@acme.com', + first_name: 'Parent', + last_name: 'Account', + phone: '', + state: '', + tax_id: '', + zip: '', + }); + +export const delegateDefaultAccessFactory = + Factory.Sync.makeFactory({ + account_access: [], + entity_access: [], + }); diff --git a/packages/utilities/src/factories/index.ts b/packages/utilities/src/factories/index.ts index c4fb376173f..7faf7f76996 100644 --- a/packages/utilities/src/factories/index.ts +++ b/packages/utilities/src/factories/index.ts @@ -1,6 +1,7 @@ export * from './accountAvailability'; export * from './betas'; export * from './config'; +export * from './delegation'; export * from './factoryProxy'; export * from './grants'; export * from './linodeConfigInterface'; diff --git a/packages/utilities/src/factories/regions.ts b/packages/utilities/src/factories/regions.ts index 27b3b6ee165..adf4f746459 100644 --- a/packages/utilities/src/factories/regions.ts +++ b/packages/utilities/src/factories/regions.ts @@ -5,6 +5,7 @@ import type { DNSResolvers, Region, RegionAvailability, + RegionVPCAvailability, } from '@linode/api-v4/lib/regions/types'; export const resolverFactory = Factory.Sync.makeFactory({ @@ -59,3 +60,10 @@ export const regionAvailabilityFactory = plan: 'g6-standard-7', region: 'us-east', }); + +export const regionVPCAvailabilityFactory = + Factory.Sync.makeFactory({ + available: true, + available_ipv6_prefix_lengths: [52], + region: 'us-east', + }); diff --git a/packages/utilities/src/helpers/grants.test.ts b/packages/utilities/src/helpers/grants.test.ts deleted file mode 100644 index ac5ad7f5fbf..00000000000 --- a/packages/utilities/src/helpers/grants.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { grantsFactory } from '../factories'; -import { getEntityIdsByPermission } from './grants'; - -const grants = grantsFactory.build({ - linode: [ - { id: 0, permissions: 'read_only' }, - { id: 1, permissions: 'read_write' }, - { id: 2, permissions: 'read_only' }, - { id: 3, permissions: null }, - ], -}); - -describe('getEntityIdsByPermission', () => { - it('should return an empty array when there is no grant data', () => { - expect(getEntityIdsByPermission(undefined, 'linode', 'read_write')).toEqual( - [], - ); - }); - it('should return read-only entity ids with read_only permission', () => { - expect(getEntityIdsByPermission(grants, 'linode', 'read_only')).toEqual([ - 0, 2, - ]); - }); - it('should return all entity ids if a permission level is omitted', () => { - expect(getEntityIdsByPermission(grants, 'linode')).toEqual([0, 1, 2, 3]); - }); -}); diff --git a/packages/utilities/src/helpers/grants.ts b/packages/utilities/src/helpers/grants.ts deleted file mode 100644 index 7ff57f237f6..00000000000 --- a/packages/utilities/src/helpers/grants.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { GrantLevel, Grants, GrantType } from '@linode/api-v4'; - -/** - * Gets entity ids for a specified permission level given a user's grants - * @param grants user grants (probably from React Query) - * @param entity the entity type you want grants for - * @param permission the level of permission you want ids for. Omit this for all entity ids. - * @returns a list of entity ids that match given paramaters - */ -export const getEntityIdsByPermission = ( - grants: Grants | undefined, - entity: GrantType, - permission?: GrantLevel, -) => { - if (!grants) { - return []; - } - - if (permission === undefined) { - return grants[entity].map((grant) => grant.id); - } - - return grants[entity] - .filter((grant) => grant.permissions === permission) - .map((grant) => grant.id); -}; diff --git a/packages/utilities/src/helpers/index.ts b/packages/utilities/src/helpers/index.ts index 6ccb85f515f..4e9ff3495d1 100644 --- a/packages/utilities/src/helpers/index.ts +++ b/packages/utilities/src/helpers/index.ts @@ -24,7 +24,6 @@ export * from './getDisplayName'; export * from './getIsLegacyInterfaceArray'; export * from './getNewRegionLabel'; export * from './getUserTimezone'; -export * from './grants'; export * from './groupByTags'; export * from './initWindows'; export * from './isNilOrEmpty'; diff --git a/packages/utilities/src/helpers/random.ts b/packages/utilities/src/helpers/random.ts index c780a7d4bca..b263bdcf813 100644 --- a/packages/utilities/src/helpers/random.ts +++ b/packages/utilities/src/helpers/random.ts @@ -10,6 +10,17 @@ export const pickRandom = (items: T[]): T => { return items[Math.floor(Math.random() * items.length)]; }; +/** + * Similar to pickRandom, but picks multiple items from an array + * @param items { T[] } an array of any kind + * @param count { number } the number of items to pick + * @returns {T[]} an array of the given type + */ +export const pickRandomMultiple = (items: T[], count: number): T[] => { + // eslint-disable-next-line sonarjs/pseudo-random + return items.sort(() => Math.random() - 0.5).slice(0, count); +}; + /** * Generates a random date between two dates * @param start {Date} the start date diff --git a/packages/validation/CHANGELOG.md b/packages/validation/CHANGELOG.md index 704ffd9b103..1381f78933a 100644 --- a/packages/validation/CHANGELOG.md +++ b/packages/validation/CHANGELOG.md @@ -1,3 +1,11 @@ +## [2025-10-07] - v0.76.0 + + +### Upcoming Features: + +- Update validation schema for Destination - Details - Path ([#12851](https://github.com/linode/manager/pull/12851)) +- Logs Delivery Stream and Destination details validation change for Update schemas ([#12898](https://github.com/linode/manager/pull/12898)) + ## [2025-09-23] - v0.75.0 ### Changed: diff --git a/packages/validation/package.json b/packages/validation/package.json index 00692ae8baa..14e7040c24c 100644 --- a/packages/validation/package.json +++ b/packages/validation/package.json @@ -1,6 +1,6 @@ { "name": "@linode/validation", - "version": "0.75.0", + "version": "0.76.0", "description": "Yup validation schemas for use with the Linode APIv4", "type": "module", "main": "lib/index.cjs", diff --git a/packages/validation/src/delivery.schema.ts b/packages/validation/src/delivery.schema.ts index 05dc69d2cd6..28203983c26 100644 --- a/packages/validation/src/delivery.schema.ts +++ b/packages/validation/src/delivery.schema.ts @@ -1,4 +1,4 @@ -import { array, boolean, mixed, number, object, string } from 'yup'; +import { array, boolean, lazy, mixed, number, object, string } from 'yup'; import type { InferType, MixedSchema, Schema } from 'yup'; @@ -57,7 +57,7 @@ const customHTTPsDetailsSchema = object({ endpoint_url: string().max(maxLength, maxLengthMessage).required(), }); -const linodeObjectStorageDetailsSchema = object({ +const linodeObjectStorageDetailsBaseSchema = object({ host: string().max(maxLength, maxLengthMessage).required('Host is required.'), bucket_name: string() .max(maxLength, maxLengthMessage) @@ -65,7 +65,7 @@ const linodeObjectStorageDetailsSchema = object({ region: string() .max(maxLength, maxLengthMessage) .required('Region is required.'), - path: string().max(maxLength, maxLengthMessage).required('Path is required.'), + path: string().max(maxLength, maxLengthMessage).defined(), access_key_id: string() .max(maxLength, maxLengthMessage) .required('Access Key ID is required.'), @@ -74,24 +74,69 @@ const linodeObjectStorageDetailsSchema = object({ .required('Access Key Secret is required.'), }); -export const destinationSchema = object().shape({ +const linodeObjectStorageDetailsPayloadSchema = + linodeObjectStorageDetailsBaseSchema.shape({ + path: string().max(maxLength, maxLengthMessage).optional(), + }); + +const destinationSchemaBase = object().shape({ label: string() .max(maxLength, maxLengthMessage) .required('Destination name is required.'), type: string().oneOf(['linode_object_storage', 'custom_https']).required(), details: mixed< | InferType - | InferType + | InferType + >() + .defined() + .required() + .when('type', { + is: 'linode_object_storage', + then: () => linodeObjectStorageDetailsBaseSchema, + otherwise: () => customHTTPsDetailsSchema, + }), +}); + +export const destinationFormSchema = destinationSchemaBase; + +export const createDestinationSchema = destinationSchemaBase.shape({ + details: mixed< + | InferType + | InferType >() .defined() .required() .when('type', { is: 'linode_object_storage', - then: () => linodeObjectStorageDetailsSchema, + then: () => linodeObjectStorageDetailsPayloadSchema, otherwise: () => customHTTPsDetailsSchema, }), }); +export const updateDestinationSchema = createDestinationSchema + .omit(['type']) + .shape({ + details: lazy((value) => { + if ('bucket_name' in value) { + return linodeObjectStorageDetailsPayloadSchema.noUnknown( + 'Object contains unknown fields for Linode Object Storage Details.', + ); + } + if ('client_certificate_details' in value) { + return customHTTPsDetailsSchema.noUnknown( + 'Object contains unknown fields for Custom HTTPS Details.', + ); + } + + // fallback schema: force error + return mixed().test({ + name: 'details-schema', + message: 'Details object does not match any known schema.', + test: () => false, + }); + }), + }); + // Logs Delivery Stream const streamDetailsBase = object({ @@ -111,13 +156,13 @@ const streamDetailsSchema = streamDetailsBase.test( }, ); -const detailsShouldBeEmpty = (schema: MixedSchema) => +const detailsShouldNotExistOrBeNull = (schema: MixedSchema) => schema - .defined() + .nullable() .test( - 'details-should-be-empty', - 'Empty details for type `audit_logs`', - (value) => Object.keys(value).length === 0, + 'details-should-not-exist', + 'Details should be null or no details passed for type `audit_logs`', + (value, ctx) => !('details' in ctx) || value === null, ); const streamSchemaBase = object({ @@ -130,35 +175,49 @@ const streamSchemaBase = object({ .oneOf(['audit_logs', 'lke_audit_logs']) .required('Stream type is required.'), destinations: array().of(number().defined()).ensure().min(1).required(), - details: mixed | object>() - .when('type', { - is: 'lke_audit_logs', - then: () => streamDetailsSchema.required(), - otherwise: detailsShouldBeEmpty, - }) - .required(), + details: mixed().when('type', { + is: 'lke_audit_logs', + then: () => streamDetailsSchema.required(), + otherwise: detailsShouldNotExistOrBeNull, + }), }); export const createStreamSchema = streamSchemaBase; -export const updateStreamSchema = streamSchemaBase.shape({ - status: mixed<'active' | 'inactive'>() - .oneOf(['active', 'inactive']) - .required(), -}); +export const updateStreamSchema = streamSchemaBase + .omit(['type']) + .shape({ + status: mixed<'active' | 'inactive'>() + .oneOf(['active', 'inactive']) + .required(), + details: lazy((value) => { + if ( + value && + typeof value === 'object' && + ('cluster_ids' in value || 'is_auto_add_all_clusters_enabled' in value) + ) { + return streamDetailsSchema.required(); + } + + // fallback schema: detailsShouldNotExistOrBeNull + return detailsShouldNotExistOrBeNull(mixed()); + }), + }) + .noUnknown('Object contains unknown fields'); export const streamAndDestinationFormSchema = object({ stream: streamSchemaBase.shape({ destinations: array().of(number().required()).required(), - details: mixed | object>() - .when('type', { - is: 'lke_audit_logs', - then: () => streamDetailsBase.required(), - otherwise: detailsShouldBeEmpty, - }) - .required(), + details: mixed().when('type', { + is: 'lke_audit_logs', + then: () => streamDetailsBase.required(), + otherwise: (schema) => + schema + .nullable() + .equals([null], 'Details must be null for audit_logs type'), + }) as Schema | null>, }), - destination: destinationSchema.defined().when('stream.destinations', { + destination: destinationFormSchema.defined().when('stream.destinations', { is: (value: never[]) => !value?.length, then: (schema) => schema, otherwise: (schema) => diff --git a/packages/validation/src/linodes.schema.ts b/packages/validation/src/linodes.schema.ts index bcbe5d49bec..4de83f20225 100644 --- a/packages/validation/src/linodes.schema.ts +++ b/packages/validation/src/linodes.schema.ts @@ -363,15 +363,41 @@ const DiskEncryptionSchema = string() .oneOf(['enabled', 'disabled']) .notRequired(); -export const alertsSchema = object({ - cpu: number() - .required('CPU Usage is required.') +/** + * A number field schema with conditional validation for legacy alert fields. + * @param label - The label used in the required error message. + * @returns A number schema with conditional validation. + */ +const legacyAlertsFieldSchema = ( + label: + | 'CPU Usage' + | 'Disk I/O Rate' + | 'Incoming Traffic' + | 'Outbound Traffic' + | 'Transfer Quota', +) => + // If system_alerts and user_alerts are undefined, then it is legacy alerts context. + // If it is legacy alerts context, then the field is required. + number().when(['system_alerts', 'user_alerts'], { + is: (systemAlerts?: number[], userAlerts?: number[]) => { + return systemAlerts === undefined && userAlerts === undefined; + }, + then: (schema) => schema.required(`${label} is required.`), + otherwise: (schema) => schema.notRequired(), + }); + +export const UpdateLinodeAlertsSchema = object({ + // Legacy numeric-threshold alerts. All fields are required to update legacy alerts, but not for ACLP alerts. + cpu: legacyAlertsFieldSchema('CPU Usage') .min(0, 'Must be between 0 and 4800') .max(4800, 'Must be between 0 and 4800'), - network_in: number().required('Incoming Traffic is required.'), - network_out: number().required('Outbound Traffic is required.'), - transfer_quota: number().required('Transfer Quota is required.'), - io: number().required('Disk I/O Rate is required.'), + network_in: legacyAlertsFieldSchema('Incoming Traffic'), + network_out: legacyAlertsFieldSchema('Outbound Traffic'), + transfer_quota: legacyAlertsFieldSchema('Transfer Quota'), + io: legacyAlertsFieldSchema('Disk I/O Rate'), + // ACLP alerts. All fields are required to update ACLP alerts, but not for legacy alerts. + system_alerts: array().of(number().defined()).notRequired(), + user_alerts: array().of(number().defined()).notRequired(), }); const schedule = object({ @@ -420,7 +446,7 @@ export const UpdateLinodeSchema = object({ .max(64, LINODE_LABEL_CHAR_REQUIREMENT), tags: array().of(string()).notRequired(), watchdog_enabled: boolean().notRequired(), - alerts: alertsSchema.notRequired().default(undefined), + alerts: UpdateLinodeAlertsSchema.notRequired().default(undefined), backups, }); @@ -656,9 +682,9 @@ const CreateVlanInterfaceSchema = object({ ipam_address: string().nullable(), }); -const AclpAlertsPayloadSchema = object({ - system: array().of(number().defined()).required(), - user: array().of(number().defined()).required(), +const CreateLinodeAclpAlertsSchema = object({ + system_alerts: array().of(number().defined()).required(), + user_alerts: array().of(number().defined()).required(), }); export const CreateVPCInterfaceSchema = object({ @@ -830,5 +856,5 @@ export const CreateLinodeSchema = object({ .oneOf(['linode/migrate', 'linode/power_off_on', undefined]) .notRequired() .nullable(), - alerts: AclpAlertsPayloadSchema.notRequired().default(undefined), + alerts: CreateLinodeAclpAlertsSchema.notRequired().default(undefined), }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8e0b8a9b0f0..4537fd1e4cd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,8 @@ overrides: semver: ^7.5.2 yaml: ^2.3.0 form-data: ^4.0.4 + brace-expansion@>=1.0.0 <=1.1.11: '>=1.1.12' + brace-expansion@>=2.0.0 <=2.0.1: '>=2.0.2' importers: @@ -84,7 +86,7 @@ importers: version: 8.29.0(eslint@9.31.0(jiti@2.4.2))(typescript@5.7.3) vitest: specifier: ^3.1.2 - version: 3.1.2(@types/debug@4.1.12)(@types/node@20.17.6)(@vitest/ui@3.1.2)(jiti@2.4.2)(jsdom@24.1.3)(msw@2.6.5(@types/node@20.17.6)(typescript@5.7.3))(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + version: 3.1.2(@types/debug@4.1.12)(@types/node@22.18.1)(@vitest/ui@3.1.2)(jiti@2.4.2)(jsdom@24.1.3)(msw@2.6.5(@types/node@22.18.1)(typescript@5.7.3))(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) packages/api-v4: dependencies: @@ -356,7 +358,7 @@ importers: version: 9.0.12(@types/react@19.1.6)(storybook@9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3)) '@storybook/react-vite': specifier: ^9.0.12 - version: 9.0.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.50.1)(storybook@9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3))(typescript@5.7.3)(vite@6.3.6(@types/node@20.17.6)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + version: 9.0.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.50.1)(storybook@9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3))(typescript@5.7.3)(vite@6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) '@swc/core': specifier: ^1.10.9 version: 1.10.11 @@ -412,8 +414,8 @@ importers: specifier: ^10.0.2 version: 10.0.9 '@types/node': - specifier: ^20.17.0 - version: 20.17.6 + specifier: ^22.13.14 + version: 22.18.1 '@types/ramda': specifier: 0.25.16 version: 0.25.16 @@ -440,7 +442,7 @@ importers: version: 4.4.5 '@vitejs/plugin-react-swc': specifier: ^3.7.2 - version: 3.7.2(vite@6.3.6(@types/node@20.17.6)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + version: 3.7.2(vite@6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) '@vitest/coverage-v8': specifier: ^3.1.2 version: 3.1.2(vitest@3.1.2) @@ -482,7 +484,7 @@ importers: version: 1.14.0(cypress@14.3.0) cypress-vite: specifier: ^1.6.0 - version: 1.6.0(vite@6.3.6(@types/node@20.17.6)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + version: 1.6.0(vite@6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) dotenv: specifier: ^16.0.3 version: 16.4.5 @@ -506,7 +508,7 @@ importers: version: 2.2.1(mocha@10.8.2) msw: specifier: ^2.2.3 - version: 2.6.5(@types/node@20.17.6)(typescript@5.7.3) + version: 2.6.5(@types/node@22.18.1)(typescript@5.7.3) pdfreader: specifier: ^3.0.7 version: 3.0.7 @@ -518,10 +520,10 @@ importers: version: 9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3) vite: specifier: ^6.3.6 - version: 6.3.6(@types/node@20.17.6)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + version: 6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) vite-plugin-svgr: specifier: ^3.2.0 - version: 3.3.0(rollup@4.50.1)(typescript@5.7.3)(vite@6.3.6(@types/node@20.17.6)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + version: 3.3.0(rollup@4.50.1)(typescript@5.7.3)(vite@6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) packages/queries: dependencies: @@ -573,7 +575,7 @@ importers: version: 4.2.0 vite: specifier: '*' - version: 6.3.6(@types/node@20.17.6)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + version: 6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) devDependencies: '@linode/tsconfig': specifier: workspace:* @@ -605,7 +607,7 @@ importers: version: link:../tsconfig '@storybook/react-vite': specifier: ^9.0.12 - version: 9.0.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.50.1)(storybook@9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3))(typescript@5.7.3)(vite@6.3.6(@types/node@20.17.6)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + version: 9.0.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.50.1)(storybook@9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3))(typescript@5.7.3)(vite@6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) '@testing-library/dom': specifier: ^10.1.0 version: 10.4.0 @@ -629,7 +631,7 @@ importers: version: 9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3) vite-plugin-svgr: specifier: ^3.2.0 - version: 3.3.0(rollup@4.50.1)(typescript@5.7.3)(vite@6.3.6(@types/node@20.17.6)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + version: 3.3.0(rollup@4.50.1)(typescript@5.7.3)(vite@6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) packages/tsconfig: {} @@ -674,7 +676,7 @@ importers: version: link:../tsconfig '@storybook/react-vite': specifier: ^9.0.12 - version: 9.0.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.50.1)(storybook@9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3))(typescript@5.7.3)(vite@6.3.6(@types/node@20.17.6)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + version: 9.0.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.50.1)(storybook@9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3))(typescript@5.7.3)(vite@6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) '@testing-library/dom': specifier: ^10.1.0 version: 10.4.0 @@ -701,7 +703,7 @@ importers: version: 9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3) vite-plugin-svgr: specifier: ^3.2.0 - version: 3.3.0(rollup@4.50.1)(typescript@5.7.3)(vite@6.3.6(@types/node@20.17.6)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + version: 3.3.0(rollup@4.50.1)(typescript@5.7.3)(vite@6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) packages/utilities: dependencies: @@ -771,8 +773,8 @@ importers: specifier: ^14.1.2 version: 14.1.2 '@types/node': - specifier: ^20.17.0 - version: 20.17.6 + specifier: ^22.13.14 + version: 22.18.1 chalk: specifier: ^5.2.0 version: 5.4.1 @@ -781,7 +783,7 @@ importers: version: 6.2.1 inquirer: specifier: ^12.9.4 - version: 12.9.4(@types/node@20.17.6) + version: 12.9.4(@types/node@22.18.1) junit2json: specifier: ^3.1.4 version: 3.1.12 @@ -2594,8 +2596,8 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} - '@types/node@20.17.6': - resolution: {integrity: sha512-VEI7OdvK2wP7XHnsuXbAJnEpEkF6NjSN45QJlL4VGqZSXsnicpesdTWsg9RISeSdYd3yeRj/y3k5KGjUXYnFwQ==} + '@types/node@22.18.1': + resolution: {integrity: sha512-rzSDyhn4cYznVG+PCzGe1lwuMYJrcBS1fc3JqSa2PvtABwWo+dZ1ij5OVok3tqfpEBCBoaR4d7upFJk73HRJDw==} '@types/novnc__novnc@1.5.0': resolution: {integrity: sha512-9DrDJK1hUT6Cbp4t03IsU/DsR6ndnIrDgZVrzITvspldHQ7n81F3wUDfq89zmPM3wg4GErH11IQa0QuTgLMf+w==} @@ -3062,12 +3064,6 @@ packages: bluebird@3.7.2: resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} - brace-expansion@1.1.11: - resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} - - brace-expansion@2.0.1: - resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==} - brace-expansion@2.0.2: resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} @@ -3319,9 +3315,6 @@ packages: resolution: {integrity: sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==} engines: {node: '>=4.0.0'} - concat-map@0.0.1: - resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - concurrently@9.1.0: resolution: {integrity: sha512-VxkzwMAn4LP7WyMnJNbHN5mKV9L2IbyDjpzemKr99sXNR3GqRNMMHdm7prV1ws9wg7ETj6WUkNOigZVsptwbgg==} engines: {node: '>=18'} @@ -4011,8 +4004,8 @@ packages: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} - foreground-child@3.3.0: - resolution: {integrity: sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==} + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} forever-agent@0.6.1: @@ -6084,8 +6077,8 @@ packages: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} - undici-types@6.19.8: - resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==} + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} unicorn-magic@0.1.0: resolution: {integrity: sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==} @@ -7021,33 +7014,33 @@ snapshots: '@humanwhocodes/retry@0.4.2': {} - '@inquirer/checkbox@4.2.2(@types/node@20.17.6)': + '@inquirer/checkbox@4.2.2(@types/node@22.18.1)': dependencies: - '@inquirer/core': 10.2.0(@types/node@20.17.6) + '@inquirer/core': 10.2.0(@types/node@22.18.1) '@inquirer/figures': 1.0.13 - '@inquirer/type': 3.0.8(@types/node@20.17.6) + '@inquirer/type': 3.0.8(@types/node@22.18.1) ansi-escapes: 4.3.2 yoctocolors-cjs: 2.1.2 optionalDependencies: - '@types/node': 20.17.6 + '@types/node': 22.18.1 - '@inquirer/confirm@5.1.0(@types/node@20.17.6)': + '@inquirer/confirm@5.1.0(@types/node@22.18.1)': dependencies: - '@inquirer/core': 10.1.1(@types/node@20.17.6) - '@inquirer/type': 3.0.1(@types/node@20.17.6) - '@types/node': 20.17.6 + '@inquirer/core': 10.1.1(@types/node@22.18.1) + '@inquirer/type': 3.0.1(@types/node@22.18.1) + '@types/node': 22.18.1 - '@inquirer/confirm@5.1.16(@types/node@20.17.6)': + '@inquirer/confirm@5.1.16(@types/node@22.18.1)': dependencies: - '@inquirer/core': 10.2.0(@types/node@20.17.6) - '@inquirer/type': 3.0.8(@types/node@20.17.6) + '@inquirer/core': 10.2.0(@types/node@22.18.1) + '@inquirer/type': 3.0.8(@types/node@22.18.1) optionalDependencies: - '@types/node': 20.17.6 + '@types/node': 22.18.1 - '@inquirer/core@10.1.1(@types/node@20.17.6)': + '@inquirer/core@10.1.1(@types/node@22.18.1)': dependencies: '@inquirer/figures': 1.0.8 - '@inquirer/type': 3.0.1(@types/node@20.17.6) + '@inquirer/type': 3.0.1(@types/node@22.18.1) ansi-escapes: 4.3.2 cli-width: 4.1.0 mute-stream: 2.0.0 @@ -7058,10 +7051,10 @@ snapshots: transitivePeerDependencies: - '@types/node' - '@inquirer/core@10.2.0(@types/node@20.17.6)': + '@inquirer/core@10.2.0(@types/node@22.18.1)': dependencies: '@inquirer/figures': 1.0.13 - '@inquirer/type': 3.0.8(@types/node@20.17.6) + '@inquirer/type': 3.0.8(@types/node@22.18.1) ansi-escapes: 4.3.2 cli-width: 4.1.0 mute-stream: 2.0.0 @@ -7069,106 +7062,106 @@ snapshots: wrap-ansi: 6.2.0 yoctocolors-cjs: 2.1.2 optionalDependencies: - '@types/node': 20.17.6 + '@types/node': 22.18.1 - '@inquirer/editor@4.2.18(@types/node@20.17.6)': + '@inquirer/editor@4.2.18(@types/node@22.18.1)': dependencies: - '@inquirer/core': 10.2.0(@types/node@20.17.6) - '@inquirer/external-editor': 1.0.1(@types/node@20.17.6) - '@inquirer/type': 3.0.8(@types/node@20.17.6) + '@inquirer/core': 10.2.0(@types/node@22.18.1) + '@inquirer/external-editor': 1.0.1(@types/node@22.18.1) + '@inquirer/type': 3.0.8(@types/node@22.18.1) optionalDependencies: - '@types/node': 20.17.6 + '@types/node': 22.18.1 - '@inquirer/expand@4.0.18(@types/node@20.17.6)': + '@inquirer/expand@4.0.18(@types/node@22.18.1)': dependencies: - '@inquirer/core': 10.2.0(@types/node@20.17.6) - '@inquirer/type': 3.0.8(@types/node@20.17.6) + '@inquirer/core': 10.2.0(@types/node@22.18.1) + '@inquirer/type': 3.0.8(@types/node@22.18.1) yoctocolors-cjs: 2.1.2 optionalDependencies: - '@types/node': 20.17.6 + '@types/node': 22.18.1 - '@inquirer/external-editor@1.0.1(@types/node@20.17.6)': + '@inquirer/external-editor@1.0.1(@types/node@22.18.1)': dependencies: chardet: 2.1.0 iconv-lite: 0.6.3 optionalDependencies: - '@types/node': 20.17.6 + '@types/node': 22.18.1 '@inquirer/figures@1.0.13': {} '@inquirer/figures@1.0.8': {} - '@inquirer/input@4.2.2(@types/node@20.17.6)': + '@inquirer/input@4.2.2(@types/node@22.18.1)': dependencies: - '@inquirer/core': 10.2.0(@types/node@20.17.6) - '@inquirer/type': 3.0.8(@types/node@20.17.6) + '@inquirer/core': 10.2.0(@types/node@22.18.1) + '@inquirer/type': 3.0.8(@types/node@22.18.1) optionalDependencies: - '@types/node': 20.17.6 + '@types/node': 22.18.1 - '@inquirer/number@3.0.18(@types/node@20.17.6)': + '@inquirer/number@3.0.18(@types/node@22.18.1)': dependencies: - '@inquirer/core': 10.2.0(@types/node@20.17.6) - '@inquirer/type': 3.0.8(@types/node@20.17.6) + '@inquirer/core': 10.2.0(@types/node@22.18.1) + '@inquirer/type': 3.0.8(@types/node@22.18.1) optionalDependencies: - '@types/node': 20.17.6 + '@types/node': 22.18.1 - '@inquirer/password@4.0.18(@types/node@20.17.6)': + '@inquirer/password@4.0.18(@types/node@22.18.1)': dependencies: - '@inquirer/core': 10.2.0(@types/node@20.17.6) - '@inquirer/type': 3.0.8(@types/node@20.17.6) + '@inquirer/core': 10.2.0(@types/node@22.18.1) + '@inquirer/type': 3.0.8(@types/node@22.18.1) ansi-escapes: 4.3.2 optionalDependencies: - '@types/node': 20.17.6 - - '@inquirer/prompts@7.8.4(@types/node@20.17.6)': - dependencies: - '@inquirer/checkbox': 4.2.2(@types/node@20.17.6) - '@inquirer/confirm': 5.1.16(@types/node@20.17.6) - '@inquirer/editor': 4.2.18(@types/node@20.17.6) - '@inquirer/expand': 4.0.18(@types/node@20.17.6) - '@inquirer/input': 4.2.2(@types/node@20.17.6) - '@inquirer/number': 3.0.18(@types/node@20.17.6) - '@inquirer/password': 4.0.18(@types/node@20.17.6) - '@inquirer/rawlist': 4.1.6(@types/node@20.17.6) - '@inquirer/search': 3.1.1(@types/node@20.17.6) - '@inquirer/select': 4.3.2(@types/node@20.17.6) + '@types/node': 22.18.1 + + '@inquirer/prompts@7.8.4(@types/node@22.18.1)': + dependencies: + '@inquirer/checkbox': 4.2.2(@types/node@22.18.1) + '@inquirer/confirm': 5.1.16(@types/node@22.18.1) + '@inquirer/editor': 4.2.18(@types/node@22.18.1) + '@inquirer/expand': 4.0.18(@types/node@22.18.1) + '@inquirer/input': 4.2.2(@types/node@22.18.1) + '@inquirer/number': 3.0.18(@types/node@22.18.1) + '@inquirer/password': 4.0.18(@types/node@22.18.1) + '@inquirer/rawlist': 4.1.6(@types/node@22.18.1) + '@inquirer/search': 3.1.1(@types/node@22.18.1) + '@inquirer/select': 4.3.2(@types/node@22.18.1) optionalDependencies: - '@types/node': 20.17.6 + '@types/node': 22.18.1 - '@inquirer/rawlist@4.1.6(@types/node@20.17.6)': + '@inquirer/rawlist@4.1.6(@types/node@22.18.1)': dependencies: - '@inquirer/core': 10.2.0(@types/node@20.17.6) - '@inquirer/type': 3.0.8(@types/node@20.17.6) + '@inquirer/core': 10.2.0(@types/node@22.18.1) + '@inquirer/type': 3.0.8(@types/node@22.18.1) yoctocolors-cjs: 2.1.2 optionalDependencies: - '@types/node': 20.17.6 + '@types/node': 22.18.1 - '@inquirer/search@3.1.1(@types/node@20.17.6)': + '@inquirer/search@3.1.1(@types/node@22.18.1)': dependencies: - '@inquirer/core': 10.2.0(@types/node@20.17.6) + '@inquirer/core': 10.2.0(@types/node@22.18.1) '@inquirer/figures': 1.0.13 - '@inquirer/type': 3.0.8(@types/node@20.17.6) + '@inquirer/type': 3.0.8(@types/node@22.18.1) yoctocolors-cjs: 2.1.2 optionalDependencies: - '@types/node': 20.17.6 + '@types/node': 22.18.1 - '@inquirer/select@4.3.2(@types/node@20.17.6)': + '@inquirer/select@4.3.2(@types/node@22.18.1)': dependencies: - '@inquirer/core': 10.2.0(@types/node@20.17.6) + '@inquirer/core': 10.2.0(@types/node@22.18.1) '@inquirer/figures': 1.0.13 - '@inquirer/type': 3.0.8(@types/node@20.17.6) + '@inquirer/type': 3.0.8(@types/node@22.18.1) ansi-escapes: 4.3.2 yoctocolors-cjs: 2.1.2 optionalDependencies: - '@types/node': 20.17.6 + '@types/node': 22.18.1 - '@inquirer/type@3.0.1(@types/node@20.17.6)': + '@inquirer/type@3.0.1(@types/node@22.18.1)': dependencies: - '@types/node': 20.17.6 + '@types/node': 22.18.1 - '@inquirer/type@3.0.8(@types/node@20.17.6)': + '@inquirer/type@3.0.8(@types/node@22.18.1)': optionalDependencies: - '@types/node': 20.17.6 + '@types/node': 22.18.1 '@isaacs/cliui@8.0.2': dependencies: @@ -7181,12 +7174,12 @@ snapshots: '@istanbuljs/schema@0.1.3': {} - '@joshwooding/vite-plugin-react-docgen-typescript@0.6.0(typescript@5.7.3)(vite@6.3.6(@types/node@20.17.6)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))': + '@joshwooding/vite-plugin-react-docgen-typescript@0.6.0(typescript@5.7.3)(vite@6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))': dependencies: glob: 10.4.5 magic-string: 0.30.17 react-docgen-typescript: 2.2.2(typescript@5.7.3) - vite: 6.3.6(@types/node@20.17.6)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + vite: 6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) optionalDependencies: typescript: 5.7.3 @@ -7706,12 +7699,12 @@ snapshots: transitivePeerDependencies: - '@types/react' - '@storybook/builder-vite@9.0.12(storybook@9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3))(vite@6.3.6(@types/node@20.17.6)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))': + '@storybook/builder-vite@9.0.12(storybook@9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3))(vite@6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))': dependencies: '@storybook/csf-plugin': 9.0.12(storybook@9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3)) storybook: 9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3) ts-dedent: 2.2.0 - vite: 6.3.6(@types/node@20.17.6)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + vite: 6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) '@storybook/csf-plugin@9.0.12(storybook@9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3))': dependencies: @@ -7736,11 +7729,11 @@ snapshots: react-dom: 19.1.0(react@19.1.0) storybook: 9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3) - '@storybook/react-vite@9.0.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.50.1)(storybook@9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3))(typescript@5.7.3)(vite@6.3.6(@types/node@20.17.6)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))': + '@storybook/react-vite@9.0.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.50.1)(storybook@9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3))(typescript@5.7.3)(vite@6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))': dependencies: - '@joshwooding/vite-plugin-react-docgen-typescript': 0.6.0(typescript@5.7.3)(vite@6.3.6(@types/node@20.17.6)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + '@joshwooding/vite-plugin-react-docgen-typescript': 0.6.0(typescript@5.7.3)(vite@6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) '@rollup/pluginutils': 5.1.3(rollup@4.50.1) - '@storybook/builder-vite': 9.0.12(storybook@9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3))(vite@6.3.6(@types/node@20.17.6)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + '@storybook/builder-vite': 9.0.12(storybook@9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3))(vite@6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) '@storybook/react': 9.0.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(storybook@9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3))(typescript@5.7.3) find-up: 7.0.0 magic-string: 0.30.17 @@ -7750,7 +7743,7 @@ snapshots: resolve: 1.22.8 storybook: 9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3) tsconfig-paths: 4.2.0 - vite: 6.3.6(@types/node@20.17.6)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + vite: 6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) transitivePeerDependencies: - rollup - supports-color @@ -8115,9 +8108,9 @@ snapshots: '@types/ms@2.1.0': optional: true - '@types/node@20.17.6': + '@types/node@22.18.1': dependencies: - undici-types: 6.19.8 + undici-types: 6.21.0 '@types/novnc__novnc@1.5.0': {} @@ -8179,11 +8172,11 @@ snapshots: '@types/xml2js@0.4.14': dependencies: - '@types/node': 20.17.6 + '@types/node': 22.18.1 '@types/yauzl@2.10.3': dependencies: - '@types/node': 20.17.6 + '@types/node': 22.18.1 optional: true '@types/zxcvbn@4.4.5': {} @@ -8360,10 +8353,10 @@ snapshots: '@ungap/structured-clone@1.3.0': {} - '@vitejs/plugin-react-swc@3.7.2(vite@6.3.6(@types/node@20.17.6)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))': + '@vitejs/plugin-react-swc@3.7.2(vite@6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))': dependencies: '@swc/core': 1.10.11 - vite: 6.3.6(@types/node@20.17.6)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + vite: 6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) transitivePeerDependencies: - '@swc/helpers' @@ -8381,7 +8374,7 @@ snapshots: std-env: 3.9.0 test-exclude: 7.0.1 tinyrainbow: 2.0.0 - vitest: 3.1.2(@types/debug@4.1.12)(@types/node@20.17.6)(@vitest/ui@3.1.2)(jiti@2.4.2)(jsdom@24.1.3)(msw@2.6.5(@types/node@20.17.6)(typescript@5.7.3))(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + vitest: 3.1.2(@types/debug@4.1.12)(@types/node@22.18.1)(@vitest/ui@3.1.2)(jiti@2.4.2)(jsdom@24.1.3)(msw@2.6.5(@types/node@22.18.1)(typescript@5.7.3))(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) transitivePeerDependencies: - supports-color @@ -8399,14 +8392,14 @@ snapshots: chai: 5.2.0 tinyrainbow: 2.0.0 - '@vitest/mocker@3.1.2(msw@2.6.5(@types/node@20.17.6)(typescript@5.7.3))(vite@6.3.6(@types/node@20.17.6)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))': + '@vitest/mocker@3.1.2(msw@2.6.5(@types/node@22.18.1)(typescript@5.7.3))(vite@6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))': dependencies: '@vitest/spy': 3.1.2 estree-walker: 3.0.3 magic-string: 0.30.17 optionalDependencies: - msw: 2.6.5(@types/node@20.17.6)(typescript@5.7.3) - vite: 6.3.6(@types/node@20.17.6)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + msw: 2.6.5(@types/node@22.18.1)(typescript@5.7.3) + vite: 6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) '@vitest/pretty-format@3.0.9': dependencies: @@ -8444,7 +8437,7 @@ snapshots: sirv: 3.0.1 tinyglobby: 0.2.13 tinyrainbow: 2.0.0 - vitest: 3.1.2(@types/debug@4.1.12)(@types/node@20.17.6)(@vitest/ui@3.1.2)(jiti@2.4.2)(jsdom@24.1.3)(msw@2.6.5(@types/node@20.17.6)(typescript@5.7.3))(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + vitest: 3.1.2(@types/debug@4.1.12)(@types/node@22.18.1)(@vitest/ui@3.1.2)(jiti@2.4.2)(jsdom@24.1.3)(msw@2.6.5(@types/node@22.18.1)(typescript@5.7.3))(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) '@vitest/utils@3.0.9': dependencies: @@ -8700,15 +8693,6 @@ snapshots: bluebird@3.7.2: {} - brace-expansion@1.1.11: - dependencies: - balanced-match: 1.0.2 - concat-map: 0.0.1 - - brace-expansion@2.0.1: - dependencies: - balanced-match: 1.0.2 - brace-expansion@2.0.2: dependencies: balanced-match: 1.0.2 @@ -8970,8 +8954,6 @@ snapshots: common-tags@1.8.2: {} - concat-map@0.0.1: {} - concurrently@9.1.0: dependencies: chalk: 4.1.2 @@ -9086,11 +9068,11 @@ snapshots: dependencies: cypress: 14.3.0 - cypress-vite@1.6.0(vite@6.3.6(@types/node@20.17.6)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)): + cypress-vite@1.6.0(vite@6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)): dependencies: chokidar: 3.6.0 debug: 4.4.0(supports-color@8.1.1) - vite: 6.3.6(@types/node@20.17.6)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + vite: 6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) transitivePeerDependencies: - supports-color @@ -9847,7 +9829,7 @@ snapshots: dependencies: is-callable: 1.2.7 - foreground-child@3.3.0: + foreground-child@3.3.1: dependencies: cross-spawn: 7.0.6 signal-exit: 4.1.0 @@ -9977,7 +9959,7 @@ snapshots: glob@10.4.5: dependencies: - foreground-child: 3.3.0 + foreground-child: 3.3.1 jackspeak: 3.4.3 minimatch: 9.0.5 minipass: 7.1.2 @@ -10161,17 +10143,17 @@ snapshots: inject-stylesheet@6.0.1: {} - inquirer@12.9.4(@types/node@20.17.6): + inquirer@12.9.4(@types/node@22.18.1): dependencies: - '@inquirer/core': 10.2.0(@types/node@20.17.6) - '@inquirer/prompts': 7.8.4(@types/node@20.17.6) - '@inquirer/type': 3.0.8(@types/node@20.17.6) + '@inquirer/core': 10.2.0(@types/node@22.18.1) + '@inquirer/prompts': 7.8.4(@types/node@22.18.1) + '@inquirer/type': 3.0.8(@types/node@22.18.1) ansi-escapes: 4.3.2 mute-stream: 2.0.0 run-async: 4.0.6 rxjs: 7.8.2 optionalDependencies: - '@types/node': 20.17.6 + '@types/node': 22.18.1 internal-slot@1.1.0: dependencies: @@ -10756,7 +10738,7 @@ snapshots: minimatch@3.1.2: dependencies: - brace-expansion: 1.1.11 + brace-expansion: 2.0.2 minimatch@5.1.6: dependencies: @@ -10764,7 +10746,7 @@ snapshots: minimatch@9.0.5: dependencies: - brace-expansion: 2.0.1 + brace-expansion: 2.0.2 minimist@1.2.8: {} @@ -10847,12 +10829,12 @@ snapshots: ms@2.1.3: {} - msw@2.6.5(@types/node@20.17.6)(typescript@5.7.3): + msw@2.6.5(@types/node@22.18.1)(typescript@5.7.3): dependencies: '@bundled-es-modules/cookie': 2.0.1 '@bundled-es-modules/statuses': 1.0.1 '@bundled-es-modules/tough-cookie': 0.1.6 - '@inquirer/confirm': 5.1.0(@types/node@20.17.6) + '@inquirer/confirm': 5.1.0(@types/node@22.18.1) '@mswjs/interceptors': 0.37.1 '@open-draft/deferred-promise': 2.2.0 '@open-draft/until': 2.1.0 @@ -12118,7 +12100,7 @@ snapshots: has-symbols: 1.1.0 which-boxed-primitive: 1.1.1 - undici-types@6.19.8: {} + undici-types@6.21.0: {} unicorn-magic@0.1.0: {} @@ -12221,13 +12203,13 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 - vite-node@3.1.2(@types/node@20.17.6)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1): + vite-node@3.1.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1): dependencies: cac: 6.7.14 - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.1(supports-color@8.1.1) es-module-lexer: 1.6.0 pathe: 2.0.3 - vite: 6.3.6(@types/node@20.17.6)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + vite: 6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) transitivePeerDependencies: - '@types/node' - jiti @@ -12242,18 +12224,18 @@ snapshots: - tsx - yaml - vite-plugin-svgr@3.3.0(rollup@4.50.1)(typescript@5.7.3)(vite@6.3.6(@types/node@20.17.6)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)): + vite-plugin-svgr@3.3.0(rollup@4.50.1)(typescript@5.7.3)(vite@6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)): dependencies: '@rollup/pluginutils': 5.1.3(rollup@4.50.1) '@svgr/core': 8.1.0(typescript@5.7.3) '@svgr/plugin-jsx': 8.1.0(@svgr/core@8.1.0(typescript@5.7.3)) - vite: 6.3.6(@types/node@20.17.6)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + vite: 6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) transitivePeerDependencies: - rollup - supports-color - typescript - vite@6.3.6(@types/node@20.17.6)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1): + vite@6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1): dependencies: esbuild: 0.25.9 fdir: 6.5.0(picomatch@4.0.3) @@ -12262,17 +12244,17 @@ snapshots: rollup: 4.50.1 tinyglobby: 0.2.15 optionalDependencies: - '@types/node': 20.17.6 + '@types/node': 22.18.1 fsevents: 2.3.3 jiti: 2.4.2 terser: 5.36.0 tsx: 4.19.3 yaml: 2.6.1 - vitest@3.1.2(@types/debug@4.1.12)(@types/node@20.17.6)(@vitest/ui@3.1.2)(jiti@2.4.2)(jsdom@24.1.3)(msw@2.6.5(@types/node@20.17.6)(typescript@5.7.3))(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1): + vitest@3.1.2(@types/debug@4.1.12)(@types/node@22.18.1)(@vitest/ui@3.1.2)(jiti@2.4.2)(jsdom@24.1.3)(msw@2.6.5(@types/node@22.18.1)(typescript@5.7.3))(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1): dependencies: '@vitest/expect': 3.1.2 - '@vitest/mocker': 3.1.2(msw@2.6.5(@types/node@20.17.6)(typescript@5.7.3))(vite@6.3.6(@types/node@20.17.6)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + '@vitest/mocker': 3.1.2(msw@2.6.5(@types/node@22.18.1)(typescript@5.7.3))(vite@6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) '@vitest/pretty-format': 3.1.2 '@vitest/runner': 3.1.2 '@vitest/snapshot': 3.1.2 @@ -12289,12 +12271,12 @@ snapshots: tinyglobby: 0.2.13 tinypool: 1.0.2 tinyrainbow: 2.0.0 - vite: 6.3.6(@types/node@20.17.6)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) - vite-node: 3.1.2(@types/node@20.17.6)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + vite: 6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + vite-node: 3.1.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) why-is-node-running: 2.3.0 optionalDependencies: '@types/debug': 4.1.12 - '@types/node': 20.17.6 + '@types/node': 22.18.1 '@vitest/ui': 3.1.2(vitest@3.1.2) jsdom: 24.1.3 transitivePeerDependencies: diff --git a/scripts/package.json b/scripts/package.json index c59dfae4e8c..ccc9b8c3884 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -14,7 +14,7 @@ "devDependencies": { "@types/markdown-it": "^14.1.2", "markdown-it": "^14.1.0", - "@types/node": "^20.17.0", + "@types/node": "^22.13.14", "chalk": "^5.2.0", "commander": "^6.2.1", "inquirer": "^12.9.4",