diff --git a/package.json b/package.json index c2fa7452a5f..388b9dd7ee5 100644 --- a/package.json +++ b/package.json @@ -8,8 +8,8 @@ "concurrently": "9.1.0", "husky": "^9.1.6", "typescript": "^5.7.3", - "vitest": "^3.1.2", - "@vitest/ui": "^3.1.2", + "vitest": "^3.2.4", + "@vitest/ui": "^3.2.4", "lint-staged": "^15.4.3", "eslint": "^9.24.0", "eslint-config-prettier": "^10.1.1", diff --git a/packages/api-v4/CHANGELOG.md b/packages/api-v4/CHANGELOG.md index 53cbaae5811..f20aa9cbf00 100644 --- a/packages/api-v4/CHANGELOG.md +++ b/packages/api-v4/CHANGELOG.md @@ -1,3 +1,16 @@ +## [2025-11-04] - v0.152.0 + + +### Changed: + +- Change `/linode/instances//clone` endpoint to use `v4beta` ([#13045](https://github.com/linode/manager/pull/13045)) + +### Upcoming Features: + +- Add endpoints for `/v4/images/sharegroups/members` and `/v4/images/sharegroups/tokens` ([#12984](https://github.com/linode/manager/pull/12984)) +- Add endpoints for `/v4/images/sharegroups` and `/v4/images/sharegroups/images` ([#12985](https://github.com/linode/manager/pull/12985)) +- Add new `filters` prop in AclpWidget type and update `Filters` type to use `DimensionFilterOperatorType` for operator ([#13006](https://github.com/linode/manager/pull/13006)) + ## [2025-10-21] - v0.151.0 diff --git a/packages/api-v4/package.json b/packages/api-v4/package.json index d7a53a25e4f..30b01a473a0 100644 --- a/packages/api-v4/package.json +++ b/packages/api-v4/package.json @@ -1,6 +1,6 @@ { "name": "@linode/api-v4", - "version": "0.151.0", + "version": "0.152.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/types.ts b/packages/api-v4/src/cloudpulse/types.ts index 20c9f32e8b2..787cc496c44 100644 --- a/packages/api-v4/src/cloudpulse/types.ts +++ b/packages/api-v4/src/cloudpulse/types.ts @@ -89,7 +89,7 @@ export interface Widgets { export interface Filters { dimension_label: string; - operator: string; + operator: DimensionFilterOperatorType; value: string; } @@ -111,6 +111,7 @@ export interface AclpConfig { export interface AclpWidget { aggregateFunction: string; + filters: Filters[]; groupBy?: string[]; label: string; size: number; diff --git a/packages/api-v4/src/databases/types.ts b/packages/api-v4/src/databases/types.ts index f9356701066..694493768e3 100644 --- a/packages/api-v4/src/databases/types.ts +++ b/packages/api-v4/src/databases/types.ts @@ -139,7 +139,7 @@ type ReadonlyCount = 0 | 2; export type MySQLReplicationType = 'asynch' | 'none' | 'semi_synch'; export interface CreateDatabasePayload { - allow_list?: string[]; + allow_list: string[]; cluster_size?: ClusterSize; /** @Deprecated used by rdbms-legacy only, rdbms-default always encrypts */ encrypted?: boolean; diff --git a/packages/api-v4/src/delivery/types.ts b/packages/api-v4/src/delivery/types.ts index c0810f6dde1..4d98fea32d7 100644 --- a/packages/api-v4/src/delivery/types.ts +++ b/packages/api-v4/src/delivery/types.ts @@ -60,12 +60,16 @@ export type DestinationDetails = export interface AkamaiObjectStorageDetails { access_key_id: string; - access_key_secret: string; bucket_name: string; host: string; path: string; } +export interface AkamaiObjectStorageDetailsExtended + extends AkamaiObjectStorageDetails { + access_key_secret: string; +} + type ContentType = 'application/json' | 'application/json; charset=utf-8'; type DataCompressionType = 'gzip' | 'None'; @@ -122,7 +126,7 @@ export interface UpdateStreamPayloadWithId extends UpdateStreamPayload { } export interface AkamaiObjectStorageDetailsPayload - extends Omit { + extends Omit { path?: string; } diff --git a/packages/api-v4/src/iam/delegation.ts b/packages/api-v4/src/iam/delegation.ts index dba6dcc0016..333546ad6e9 100644 --- a/packages/api-v4/src/iam/delegation.ts +++ b/packages/api-v4/src/iam/delegation.ts @@ -57,14 +57,14 @@ export const getChildAccountDelegates = ({ export const updateChildAccountDelegates = ({ euuid, - data, + users, }: UpdateChildAccountDelegatesParams) => Request>( setURL( `${BETA_API_ROOT}/iam/delegation/child-accounts/${encodeURIComponent(euuid)}/users`, ), setMethod('PUT'), - setData(data), + setData(users), ); export const getMyDelegatedChildAccounts = ({ diff --git a/packages/api-v4/src/iam/delegation.types.ts b/packages/api-v4/src/iam/delegation.types.ts index 4d9006d0cbd..2cb19ea628d 100644 --- a/packages/api-v4/src/iam/delegation.types.ts +++ b/packages/api-v4/src/iam/delegation.types.ts @@ -30,6 +30,6 @@ export interface GetChildAccountDelegatesParams { } export interface UpdateChildAccountDelegatesParams { - data: string[]; euuid: string; + users: string[]; } diff --git a/packages/api-v4/src/images/sharegroup.ts b/packages/api-v4/src/images/sharegroup.ts new file mode 100644 index 00000000000..a79e6c9b988 --- /dev/null +++ b/packages/api-v4/src/images/sharegroup.ts @@ -0,0 +1,414 @@ +import { + addSharegroupImagesSchema, + addSharegroupMemberSchema, + createSharegroupSchema, + generateSharegroupTokenSchema, + updateSharegroupImageSchema, + updateSharegroupMemberSchema, + updateSharegroupSchema, + updateSharegroupTokenSchema, +} from '@linode/validation/lib/images.schema'; + +import { BETA_API_ROOT } from '../constants'; +import Request, { + setData, + setMethod, + setParams, + setURL, + setXFilter, +} from '../request'; + +import type { Filter, ResourcePage as Page, Params } from '../types'; +import type { + AddSharegroupImagesPayload, + AddSharegroupMemberPayload, + CreateSharegroupPayload, + GenerateSharegroupTokenPayload, + Image, + Sharegroup, + SharegroupMember, + SharegroupToken, + UpdateSharegroupImagePayload, + UpdateSharegroupMemberPayload, + UpdateSharegroupPayload, +} from './types'; + +/** + * Create a Image Sharegroup. + * + * @param data { createSharegroupPayload } the sharegroup details + */ +export const createSharegroup = (data: CreateSharegroupPayload) => { + return Request( + setURL(`${BETA_API_ROOT}/images/sharegroups`), + setMethod('POST'), + setData(data, createSharegroupSchema), + ); +}; + +/** + * Add Images to the Sharegroup + * + * @param sharegroupId { string } ID of the sharegroup to add images + * @param data { AddSharegroupImagesPayload } the image details + */ +export const addImagesToSharegroup = ( + sharegroupId: number, + data: AddSharegroupImagesPayload, +) => { + return Request( + setURL( + `${BETA_API_ROOT}/images/sharegroups/${encodeURIComponent(sharegroupId)}/images`, + ), + setMethod('POST'), + setData(data, addSharegroupImagesSchema), + ); +}; + +/** + * Add Member to the Sharegroup + * + * @param sharegroupId {string} ID of the Sharegroup to add member + * @param data {AddSharegroupMemberPayload} the Member details + */ +export const addMembersToSharegroup = ( + sharegroupId: number, + data: AddSharegroupMemberPayload, +) => { + return Request( + setURL( + `${BETA_API_ROOT}/images/sharegroups/${encodeURIComponent(sharegroupId)}/members`, + ), + setMethod('POST'), + setData(data, addSharegroupMemberSchema), + ); +}; + +/** + * Generate user token for the Sharegroup + * + * @param data {GenerateSharegroupTokenPayload} the token details + */ +export const generateSharegroupToken = ( + data: GenerateSharegroupTokenPayload, +) => { + return Request( + setURL(`${BETA_API_ROOT}/images/sharegroups/tokens`), + setMethod('POST'), + setData(data, generateSharegroupTokenSchema), + ); +}; + +/** + * Returns a paginated list of Sharegroups + */ +export const getSharegroups = (params: Params = {}, filters: Filter = {}) => + Request>( + setURL(`${BETA_API_ROOT}/images/sharegroups`), + setMethod('GET'), + setParams(params), + setXFilter(filters), + ); + +/** + * Lists all the sharegroups a given private image is present in. + * + * @param imageId { string } ID of the Image to look up. + */ +export const getSharegroupsFromImage = ( + imageId: string, + params: Params = {}, + filters: Filter = {}, +) => + Request>( + setURL(`${BETA_API_ROOT}/images/${imageId}/sharegroups`), + setMethod('GET'), + setParams(params), + setXFilter(filters), + ); + +/** + * Get information about a single Sharegroup + * + * @param sharegroupId {string} ID of the Sharegroup to look up + */ +export const getSharegroup = (sharegroupId: string) => + Request( + setURL( + `${BETA_API_ROOT}/images/sharegroups/${encodeURIComponent(sharegroupId)}`, + ), + setMethod('GET'), + ); + +/** + * Get details of the Sharegroup the token has been accepted into + * + * @param token_uuid {string} Token UUID of the user + */ +export const getSharegroupFromToken = (token_uuid: string) => { + Request( + setURL( + `${BETA_API_ROOT}/images/sharegroups/tokens/${encodeURIComponent(token_uuid)}/sharegroup`, + ), + setMethod('GET'), + ); +}; + +/** + * Get a paginated list of Images present in a Sharegroup + * + * @param sharegroupId {string} ID of the Sharegroup to look up + */ +export const getSharegroupImages = ( + sharegroupId: string, + params: Params = {}, + filters: Filter = {}, +) => + Request>( + setURL( + `${BETA_API_ROOT}/images/sharegroups/${encodeURIComponent(sharegroupId)}/images`, + ), + setMethod('GET'), + setParams(params), + setXFilter(filters), + ); + +/** + * Get a paginated list of Sharegroup Images the token has been accepted into + * + * @param token_uuid {string} Token UUID of the user + */ +export const getSharegroupImagesFromToken = ( + token_uuid: string, + params: Params = {}, + filters: Filter = {}, +) => { + Request>( + setURL( + `${BETA_API_ROOT}/images/sharegroups/tokens/${encodeURIComponent(token_uuid)}/sharegroups/images`, + ), + setMethod('GET'), + setParams(params), + setXFilter(filters), + ); +}; + +/** + * Get a paginated list of members part of the Sharegroup + * + * @param sharegroupId {string} ID of the Sharegroup to look up + */ +export const getSharegroupMembers = ( + sharegroupId: string, + params: Params = {}, + filters: Filter = {}, +) => { + Request>( + setURL( + `${BETA_API_ROOT}/images/sharegroups/${encodeURIComponent(sharegroupId)}/members`, + ), + setMethod('GET'), + setParams(params), + setXFilter(filters), + ); +}; + +/** + * Get member details of a user from the Sharegroup + * + * @param sharegroupId {string} ID of the Sharegroup to look up + * @param token_uuid {string} Token UUID of the user to look up + */ +export const getSharegroupMember = ( + sharegroupId: string, + token_uuid: string, +) => { + Request( + setURL( + `${BETA_API_ROOT}/images/sharegroups/${encodeURIComponent(sharegroupId)}/members/${encodeURIComponent(token_uuid)}`, + ), + setMethod('GET'), + ); +}; + +/** + * Returns a paginated list of tokens created by the user + */ +export const getUserSharegroupTokens = ( + params: Params = {}, + filters: Filter = {}, +) => { + Request>( + setURL(`${BETA_API_ROOT}/images/sharegroups/tokens`), + setMethod('GET'), + setParams(params), + setXFilter(filters), + ); +}; + +/** + * Get details about a specific token created by the user + * + * @param token_uuid Token UUID of the user to look up + */ +export const getUserSharegroupToken = (token_uuid: string) => { + Request( + setURL( + `${BETA_API_ROOT}/images/sharegroups/tokens/${encodeURIComponent(token_uuid)}`, + ), + setMethod('GET'), + ); +}; + +/** + * Update a Sharegroup. + * + * @param sharegroupId {string} ID of the Sharegroup to update + * @param data { updateSharegroupPayload } the sharegroup details + */ +export const updateSharegroup = ( + sharegroupId: string, + data: UpdateSharegroupPayload, +) => { + return Request( + setURL( + `${BETA_API_ROOT}/images/sharegroups/${encodeURIComponent(sharegroupId)}`, + ), + setMethod('PUT'), + setData(data, updateSharegroupSchema), + ); +}; + +/** + * Update an Image in a Sharegroup. + * + * @param sharegroupId {string} ID of the Sharegroup the image belongs to + * @param imageId {string} ID of the Image to update + * @param data { UpdateSharegroupImagePayload } the updated image details + */ +interface UpdateSharegroupImage { + data: UpdateSharegroupImagePayload; + imageId: string; + sharegroupId: string; +} +export const updateSharegroupImage = ({ + sharegroupId, + imageId, + data, +}: UpdateSharegroupImage) => { + return Request( + setURL( + `${BETA_API_ROOT}/images/sharegroup/${encodeURIComponent(sharegroupId)}/images/${encodeURIComponent(imageId)}}`, + ), + setMethod('PUT'), + setData(data, updateSharegroupImageSchema), + ); +}; + +/** + * Update a Sharegroup member's label + * + * @param token_uuid {string} token UUID of the user + * @param data {UpdateSharegroupMemberPayload} the updated label + */ +interface UpdateSharegroupMember { + data: UpdateSharegroupMemberPayload; + sharegroupId: string; + token_uuid: string; +} + +export const updateSharegroupMember = ({ + sharegroupId, + token_uuid, + data, +}: UpdateSharegroupMember) => { + return Request( + setURL( + `${BETA_API_ROOT}/images/sharegroups/${encodeURIComponent(sharegroupId)}/members/${encodeURIComponent(token_uuid)}`, + ), + setMethod('PUT'), + setData(data, updateSharegroupMemberSchema), + ); +}; + +/** + * Update a user token's label + * + * @param token_uuid {string} token UUID of the user + * @param data {UpdateSharegroupMemberPayload} the updated label + */ +export const updateSharegroupToken = ( + token_uuid: string, + data: UpdateSharegroupMemberPayload, +) => { + return Request( + setURL( + `${BETA_API_ROOT}/images/sharegroups/tokens/${encodeURIComponent(token_uuid)}`, + ), + setMethod('PUT'), + setData(data, updateSharegroupTokenSchema), + ); +}; + +/** + * Delete a sharegroup + * + * @param sharegroupId {string} ID of the sharegroup to delete + */ +export const deleteSharegroup = (sharegroupId: string) => { + return Request<{}>( + setURL( + `${BETA_API_ROOT}/images/sharegroups/${encodeURIComponent(sharegroupId)}`, + ), + setMethod('DELETE'), + ); +}; + +/** + * Delete a sharegroup Image + * + * @param sharegroupId {string} ID of the sharegroup to delete + * @param imageId {string} ID of the image to delete + */ +export const deleteSharegroupImage = ( + sharegroupId: string, + imageId: string, +) => { + return Request<{}>( + setURL( + `${BETA_API_ROOT}/images/sharegroups/${encodeURIComponent(sharegroupId)}/images/${encodeURIComponent(imageId)}`, + ), + setMethod('DELETE'), + ); +}; + +/** + * Delete a sharegroup Member + * + * @param token_uuid {string} Token UUID of the member to delete + */ +export const deleteSharegroupMember = ( + sharegroupId: string, + token_uuid: string, +) => { + return Request<{}>( + setURL( + `${BETA_API_ROOT}/images/sharegroups/${encodeURIComponent(sharegroupId)}/members/${encodeURIComponent(token_uuid)}`, + ), + setMethod('DELETE'), + ); +}; + +/** + * Delete a user token + * + * @param token_uuid {string} Token UUID of the user to delete + */ +export const deleteSharegroupToken = (token_uuid: string) => { + return Request<{}>( + setURL( + `${BETA_API_ROOT}/images/sharegroups/tokens/${encodeURIComponent(token_uuid)}`, + ), + setMethod('DELETE'), + ); +}; diff --git a/packages/api-v4/src/images/types.ts b/packages/api-v4/src/images/types.ts index 8eb4c68fbe9..42aa1a4f9e1 100644 --- a/packages/api-v4/src/images/types.ts +++ b/packages/api-v4/src/images/types.ts @@ -4,6 +4,8 @@ export type ImageCapabilities = 'cloud-init' | 'distributed-sites'; type ImageType = 'automatic' | 'manual'; +type SharegroupMemberStatus = 'active' | 'revoked'; + export type ImageRegionStatus = | 'available' | 'creating' @@ -18,6 +20,19 @@ export interface ImageRegion { status: ImageRegionStatus; } +export interface ImageSharingData { + shared_by: null | { + sharegroup_id: number; + sharegroup_label: string; + sharegroup_uuid: string; + source_image_id: number; + }; + shared_with: null | { + sharegroup_count: number; + sharegroup_list_url: string; + }; +} + export interface Image { /** * A list of the capabilities of this image. @@ -59,11 +74,21 @@ export interface Image { */ id: string; + /** + * Image sharing attributes for private and shared images. + */ + image_sharing?: ImageSharingData; + /** * Whether this image is marked for public distribution. */ is_public: boolean; + /** + * Whether this image has a shared copy. + */ + is_shared?: boolean; + /** * A short description of this image. */ @@ -157,3 +182,186 @@ export interface UpdateImageRegionsPayload { */ regions: string[]; } + +export interface Sharegroup { + /** + * The timestamp of when the Sharegroup was created + */ + created: string; + /** + * A detailed description for the Sharegroup + */ + description: string; + /** + * The timestamp of when the Sharegroup would expire + */ + expiry?: string; + /** + * The ID of the this Sharegroup. + */ + id: number; + /** + * The number of images shared in the Sharegroup + */ + images_count?: number; + /** + * A boolean that indicates if the Sharegroup is suspended + */ + is_suspended: boolean; + /** + * A short title for the Sharegroup + */ + label: string; + /** + * The number of members present in the Sharegroup + */ + members_count?: number; + /** + * The timestamp of when the Sharegroup was last updated + */ + updated: string; + /** + * A unique identifier for the sharegroup which can be used to generate member tokens + */ + uuid: string; +} + +export interface SharegroupImagePayload { + /** + * A detailed description of this Image. + */ + description?: string; + /** + * ID of the private image that will be added to the Sharegroup + */ + id: string; + /** + * A short title of this Image. + * + * Defaults to the label of the private image it is being created from if not provided. + */ + label?: string; +} + +export interface CreateSharegroupPayload { + /** + * A detailed description of this Sharegroup. + */ + description?: string; + /** + * An array of images that will be shared in the Sharegroup + */ + images?: SharegroupImagePayload[]; + /** + * A short title of this Sharegroup. + */ + label: string; +} + +export type UpdateSharegroupPayload = Omit; + +export interface AddSharegroupImagesPayload { + /** + * An array of images that will be shared in the Sharegroup + */ + images: SharegroupImagePayload[]; +} + +export type UpdateSharegroupImagePayload = Omit; + +export interface AddSharegroupMemberPayload { + /** + * The title given to the user in the sharegroup + */ + label: string; + /** + * The user token shared by the user to join the sharegroup + */ + token: string; +} + +export type UpdateSharegroupMemberPayload = Omit< + AddSharegroupMemberPayload, + 'token' +>; + +export interface SharegroupMember { + /** + * The timestamp of when the member was added to the sharegroup + */ + created: string; + /** + * The timestamp of when the member's token expires + */ + expiry: string; + /** + * The title given to the user in the sharegroup + */ + label: string; + /** + * The status of the member in the current sharegroup + */ + status: SharegroupMemberStatus; + /** + * A unique identifier for member tokens + */ + token_uuid: string; + /** + * The timestamp of when the member's information was last updated + */ + updated: string; +} + +export interface GenerateSharegroupTokenPayload { + /** + * The title given to the user in the sharegroup + */ + label?: string; + /** + * The sharegroup UUID for which a user token will be generated + */ + valid_for_sharegroup_uuid: string; +} + +export interface SharegroupToken { + /** + * The timestamp of when the token was created + */ + created: string; + /** + * The timestamp of when the token will expire + */ + expiry: string; + /** + * The title given to the user in the sharegroup + */ + label: string; + /** + * The sharegroup label this token is created for + */ + sharegroup_label: string; + /** + * The sharegroup UUID the token is created for + */ + sharegroup_uuid: string; + /** + * The current status of this token + */ + status: string; + /** + * A unique member token to join the sharegroup + */ + token: string; + /** + * A unique identifier for each token generated + */ + token_uuid: string; + /** + * The timestamp of when the token was last updated + */ + updated: string; + /** + * The sharegroup UUID the token is valid for + */ + valid_for_sharegroup_uuid: string; +} diff --git a/packages/api-v4/src/linodes/actions.ts b/packages/api-v4/src/linodes/actions.ts index b06b7395acc..0c13ae6e645 100644 --- a/packages/api-v4/src/linodes/actions.ts +++ b/packages/api-v4/src/linodes/actions.ts @@ -1,6 +1,6 @@ import { RebuildLinodeSchema } from '@linode/validation/lib/linodes.schema'; -import { API_ROOT } from '../constants'; +import { API_ROOT, BETA_API_ROOT } from '../constants'; import Request, { setData, setMethod, setURL } from '../request'; import type { @@ -185,7 +185,7 @@ export const rescueMetalLinode = (linodeId: number): Promise<{}> => export const cloneLinode = (sourceLinodeId: number, data: LinodeCloneData) => { return Request( setURL( - `${API_ROOT}/linode/instances/${encodeURIComponent(sourceLinodeId)}/clone`, + `${BETA_API_ROOT}/linode/instances/${encodeURIComponent(sourceLinodeId)}/clone`, ), setMethod('POST'), setData(data), diff --git a/packages/manager/CHANGELOG.md b/packages/manager/CHANGELOG.md index 3331204a547..d19d5e3d253 100644 --- a/packages/manager/CHANGELOG.md +++ b/packages/manager/CHANGELOG.md @@ -4,6 +4,56 @@ 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-11-04] - v1.154.0 + +### Added: + +- IAM Parent/Child: hide User details tab for delegate user and add a badge ([#12982](https://github.com/linode/manager/pull/12982)) + +### Changed: + +- IAM Parent/Child: redirect route /delegations for non-parent users ([#13007](https://github.com/linode/manager/pull/13007)) +- Prevent database queries from sending legacy filter and remove unused banner components ([#13015](https://github.com/linode/manager/pull/13015)) +- Replace table and paginator in DBaaS with CDS web components ([#12989](https://github.com/linode/manager/pull/12989)) + +### Fixed: + +- NodeBalancer Configuration form unresponsiveness for larger VPC deployments ([#12991](https://github.com/linode/manager/pull/12991)) +- IAM Roles Table styles and responsive enhancements ([#12997](https://github.com/linode/manager/pull/12997)) +- IAM Account Delegation Tables sorting & filtering ([#13003](https://github.com/linode/manager/pull/13003)) +- IAM - Ensure useEntitiesPermissions does not run for admin users ([#13012](https://github.com/linode/manager/pull/13012)) +- IAM Parent/Child: fix spacing and add notification ([#13013](https://github.com/linode/manager/pull/13013)) +- Upcoming maintenance "When" shows time until start using start_time or policy‑derived start; shows "X days Y hours" when ≥ 1 day ([#13020](https://github.com/linode/manager/pull/13020), [#13045](https://github.com/linode/manager/pull/13045)) +- Add self-service maintenance action in LinodeMaintenanceBanner for power_off_on and include all maintenance types in dev tools preset ([#13024](https://github.com/linode/manager/pull/13024)) +- IAM: Linodes without required permissions visible and selectable in Assign/Unassign Linodes selector ([#13030](https://github.com/linode/manager/pull/13030)) +- Enhance `enabled` checks for queries ran within `useQueryWithPermissions` ([#13039](https://github.com/linode/manager/pull/13039)) +- Add new `mtc` feature flag, extend it to support valid regions for MTC Linode Migration, and replace the invalid region ID `no-east` ([#13026](https://github.com/linode/manager/pull/13026)) + +### Tech Stories: + +- Update Vite to v7 ([#12792](https://github.com/linode/manager/pull/12792)) +- Upgrade Cypress to v15.4.0 ([#12824](https://github.com/linode/manager/pull/12824)) + +### Tests: + +- Add Linode Interface related tests: deleting an interface, editing interfaces, and updating interface settings ([#12876](https://github.com/linode/manager/pull/12876)) +- Fix "lke-update.spec.ts" LKE-E node pool drawer test that's broken in DevCloud ([#12884](https://github.com/linode/manager/pull/12884)) +- Add Logs Destination Landing, Create and Edit e2e tests ([#12936](https://github.com/linode/manager/pull/12936)) + +### Upcoming Features: + +- IAM: Account Delegations Drawer ([#12970](https://github.com/linode/manager/pull/12970)) +- IAM: Default Roles Table ([#12990](https://github.com/linode/manager/pull/12990)) +- Add support for `privateImageSharing` feature flag for Private Image Sharing feature ([#12992](https://github.com/linode/manager/pull/12992)) +- Logs Delivery Destinations/Stream Delete confirmation modal error state reset fix ([#12996](https://github.com/linode/manager/pull/12996)) +- Stream form bug fixes ([#12999](https://github.com/linode/manager/pull/12999)) +- Add type, utility and mock setup for supporting widget level dimension filters ([#13006](https://github.com/linode/manager/pull/13006)) +- IAM Delegation: Parent Account UI fix ([#13011](https://github.com/linode/manager/pull/13011)) +- CloudPulse-Metrics: Update `FilterConfig.ts` to make firewall a single-select filter and to filter firewalls based on dashboard ([#13014](https://github.com/linode/manager/pull/13014)) +- ACLP-Alerting: Add hook to cleanup stale value from Alerting form ([#13018](https://github.com/linode/manager/pull/13018)) +- CloudPulse-Metrics: Hide scroll bar for filters in all browsers, introduce shared prop in `styles.ts`. ([#13028](https://github.com/linode/manager/pull/13028)) +- CloudPulse-Metrics: Add optional-filter component at `CloudPulseFirewallNodebalancersSelect.tsx` and integrate it with existing firewall-nodebalancer filters ([#13029](https://github.com/linode/manager/pull/13029)) + ## [2025-10-28] - v1.153.2 ### Changed: diff --git a/packages/manager/Dockerfile b/packages/manager/Dockerfile index 7f65e774d78..ae55b12944e 100644 --- a/packages/manager/Dockerfile +++ b/packages/manager/Dockerfile @@ -6,7 +6,7 @@ ARG IMAGE_REGISTRY=docker.io ARG NODE_VERSION=22.19.0 # Cypress version. -ARG CYPRESS_VERSION=14.3.0 +ARG CYPRESS_VERSION=15.4.0 # Node.js base image for Cloud Manager CI tasks. # diff --git a/packages/manager/cypress/e2e/core/databases/create-database.spec.ts b/packages/manager/cypress/e2e/core/databases/create-database.spec.ts index 27faaca482b..22f29d8c1c9 100644 --- a/packages/manager/cypress/e2e/core/databases/create-database.spec.ts +++ b/packages/manager/cypress/e2e/core/databases/create-database.spec.ts @@ -48,6 +48,7 @@ describe('create a database cluster, mocked data', () => { status: 'provisioning', type: configuration.linodeType, version: configuration.version, + platform: 'rdbms-default', }); // Database mock once instance has been provisioned. @@ -239,7 +240,7 @@ describe('create a database cluster, mocked data', () => { cy.findByText(databaseMock.label) .should('be.visible') - .closest('tr') + .closest('cds-table-row') .within(() => { cy.findByText(`${configuration.engine} v${configuration.version}`, { exact: false, @@ -260,7 +261,7 @@ describe('create a database cluster, mocked data', () => { cy.findByText(databaseMock.label) .should('be.visible') - .closest('tr') + .closest('cds-table-row') .within(() => { cy.findByText('Active').should('be.visible'); }); diff --git a/packages/manager/cypress/e2e/core/delivery/create-destination.spec.ts b/packages/manager/cypress/e2e/core/delivery/create-destination.spec.ts new file mode 100644 index 00000000000..e035a3445b4 --- /dev/null +++ b/packages/manager/cypress/e2e/core/delivery/create-destination.spec.ts @@ -0,0 +1,99 @@ +import { + mockDestination, + mockDestinationPayload, +} from 'support/constants/delivery'; +import { + mockCreateDestination, + mockGetDestinations, + mockTestConnection, +} from 'support/intercepts/delivery'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { ui } from 'support/ui'; +import { logsDestinationForm } from 'support/ui/pages/logs-destination-form'; + +import type { AkamaiObjectStorageDetailsExtended } from '@linode/api-v4'; + +describe('Create Destination', () => { + before(() => { + mockAppendFeatureFlags({ + aclpLogs: { enabled: true, beta: true }, + }); + }); + + it('create destination with form', () => { + cy.visitWithLogin('/logs/delivery/destinations/create'); + + // Give Destination a label + logsDestinationForm.setLabel(mockDestinationPayload.label); + + logsDestinationForm.fillDestinationDetailsForm( + mockDestinationPayload.details as AkamaiObjectStorageDetailsExtended + ); + + // Create Destination should be disabled before test connection + cy.findByRole('button', { name: 'Create Destination' }).should( + 'be.disabled' + ); + + // Test connection of the destination form - failure + mockTestConnection(400); + ui.button + .findByTitle('Test Connection') + .should('be.enabled') + .should('have.attr', 'type', 'button') + .click(); + + ui.toast.assertMessage( + 'Delivery connection test failed. Verify your delivery settings and try again.' + ); + + // Create Destination should be disabled after test connection failed + cy.findByRole('button', { name: 'Create Destination' }).should( + 'be.disabled' + ); + + // Test connection of the destination form - success + mockTestConnection(200); + ui.button + .findByTitle('Test Connection') + .should('be.enabled') + .should('have.attr', 'type', 'button') + .click(); + + ui.toast.assertMessage( + `Delivery connection test completed successfully. Data can now be sent using this configuration.` + ); + + // Submit the destination create form - failure + mockCreateDestination({}, 400); + cy.findByRole('button', { name: 'Create Destination' }) + .should('be.enabled') + .should('have.attr', 'type', 'button') + .click(); + + ui.toast.assertMessage(`There was an issue creating your destination`); + + // Submit the destination create form - success + mockCreateDestination(mockDestination); + mockGetDestinations([mockDestination]); + cy.findByRole('button', { name: 'Create Destination' }) + .should('be.enabled') + .should('have.attr', 'type', 'button') + .click(); + + ui.toast.assertMessage( + `Destination ${mockDestination.label} created successfully` + ); + + // Verify we redirect to the destinations landing page upon successful creation + cy.url().should('endWith', 'destinations'); + + // Verify the newly created destination shows on the Destinations landing page + cy.findByText(mockDestination.label) + .closest('tr') + .within(() => { + // Verify Destination label shows + cy.findByText(mockDestination.label).should('be.visible'); + }); + }); +}); diff --git a/packages/manager/cypress/e2e/core/delivery/destinations-empty-landing-page.spec.ts b/packages/manager/cypress/e2e/core/delivery/destinations-empty-landing-page.spec.ts new file mode 100644 index 00000000000..ff8de40f562 --- /dev/null +++ b/packages/manager/cypress/e2e/core/delivery/destinations-empty-landing-page.spec.ts @@ -0,0 +1,37 @@ +import { mockGetDestinations } from 'support/intercepts/delivery'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { ui } from 'support/ui'; + +describe('Destinations empty landing page', () => { + beforeEach(() => { + mockAppendFeatureFlags({ + aclpLogs: { + enabled: true, + beta: true, + }, + }); + }); + + /** + * - Confirms Destinations landing page empty state is shown when no Destinations are present: + * - Confirms that clicking "Create Destination" navigates user to create destination page. + */ + it('shows the empty state when there are no destinations', () => { + mockGetDestinations([]).as('getDestinations'); + + cy.visitWithLogin('/logs/delivery/destinations'); + cy.wait(['@getDestinations']); + + // Confirm empty Destinations Landing Text + cy.findByText('Create a destination for cloud logs').should('be.visible'); + + // confirms clicking on 'Create Domain' button + ui.button + .findByTitle('Create Destination') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.url().should('endWith', '/logs/delivery/destinations/create'); + }); +}); diff --git a/packages/manager/cypress/e2e/core/delivery/destinations-non-empty-landing-page.spec.ts b/packages/manager/cypress/e2e/core/delivery/destinations-non-empty-landing-page.spec.ts new file mode 100644 index 00000000000..ea06005cebf --- /dev/null +++ b/packages/manager/cypress/e2e/core/delivery/destinations-non-empty-landing-page.spec.ts @@ -0,0 +1,154 @@ +import { + interceptDeleteDestination, + mockDeleteDestination, + mockGetDestination, + mockGetDestinations, +} from 'support/intercepts/delivery'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { ui } from 'support/ui'; + +import { destinationFactory } from 'src/factories'; + +import type { Destination } from '@linode/api-v4'; + +function checkActionMenu(tableAlias: string, mockDestinations: Destination[]) { + mockDestinations.forEach((destination) => { + cy.get(tableAlias) + .find('tbody tr') + .should('contain', destination.label) + .then(() => { + // If the row contains the label, proceed with clicking the action menu + ui.actionMenu + .findByTitle(`Action menu for Destination ${destination.label}`) + .should('be.visible') + .click(); + + // Check that all items are enabled + ui.actionMenuItem + .findByTitle('Edit') + .should('be.visible') + .should('be.enabled'); + + ui.actionMenuItem + .findByTitle('Delete') + .should('be.visible') + .should('be.enabled'); + }); + + // Close the action menu by clicking on Delivery Title of the screen + cy.get('body').click(0, 0); + }); +} + +function deleteItem(tableAlias: string, destination: Destination) { + cy.get(tableAlias) + .find('tbody tr') + .should('contain', destination.label) + .then(() => { + // If the row contains the label, proceed with clicking the action menu + ui.actionMenu + .findByTitle(`Action menu for Destination ${destination.label}`) + .should('be.visible') + .click(); + + mockDeleteDestination(404); // @TODO remove after API release on prod + interceptDeleteDestination().as('deleteDestination'); + + // Delete destination + ui.actionMenuItem.findByTitle('Delete').click(); + + // Find confirmation modal + cy.findByText( + `Are you sure you want to delete "${destination.label}" destination?` + ); + ui.button.findByTitle('Delete').click(); + + cy.wait('@deleteDestination'); + + // Close confirmation modal after failure + ui.button.findByTitle('Cancel').click(); + }); +} + +function editItemViaActionMenu(tableAlias: string, destination: Destination) { + cy.get(tableAlias) + .find('tbody tr') + .should('contain', destination.label) + .then(() => { + // If the row contains the label, proceed with clicking the action menu + ui.actionMenu + .findByTitle(`Action menu for Destination ${destination.label}`) + .should('be.visible') + .click(); + + mockGetDestination(destination); + // Edit destination redirect + ui.actionMenuItem.findByTitle('Edit').click(); + cy.url().should('endWith', `/destinations/${destination.id}/edit`); + }); +} + +const mockDestinations: Destination[] = new Array(3) + .fill(null) + .map((_item: null, index: number): Destination => { + return destinationFactory.build({ + label: `Destination ${index}`, + }); + }); + +describe('destinations landing checks for non-empty state', () => { + beforeEach(() => { + mockAppendFeatureFlags({ + aclpLogs: { enabled: true, beta: true }, + }); + + // Mock setup to display the Destinations landing page in a non-empty state + mockGetDestinations(mockDestinations).as('getDestinations'); + + // Alias the mockDestinations array + cy.wrap(mockDestinations).as('mockDestinations'); + }); + + it('checks create destination button is enabled and user can see existing destinations', () => { + // Login and wait for application to load + cy.visitWithLogin('/logs/delivery/destinations'); + cy.wait('@getDestinations'); + cy.url().should('endWith', '/destinations'); + + cy.get('table').should('exist').as('destinationsTable'); + + // Assert that Create Destination button is visible and enabled + ui.button + .findByTitle('Create Destination') + .should('be.visible') + .and('be.enabled'); + + // Assert that the correct number of Destinations entries are present in the DestinationsTable + cy.get('@destinationsTable') + .find('tbody tr') + .should('have.length', mockDestinations.length); + + checkActionMenu('@destinationsTable', mockDestinations); // For the recovery destination table + }); + + it('checks actions from destination menu actions', () => { + cy.visitWithLogin('/logs/delivery/destinations'); + cy.wait('@getDestinations'); + cy.get('table').should('exist').as('destinationsTable'); + + const exampleDestination = mockDestinations[0]; + deleteItem('@destinationsTable', exampleDestination); + + mockGetDestination(exampleDestination).as('getDestination'); + + // Redirect to destination edit page via name + cy.findByText(exampleDestination.label).click(); + cy.url().should('endWith', `/destinations/${exampleDestination.id}/edit`); + cy.wait('@getDestination'); + + cy.visit('/logs/delivery/destinations'); + cy.get('table').should('exist').as('destinationsTable'); + cy.wait('@getDestinations'); + editItemViaActionMenu('@destinationsTable', exampleDestination); + }); +}); diff --git a/packages/manager/cypress/e2e/core/delivery/edit-destination.spec.ts b/packages/manager/cypress/e2e/core/delivery/edit-destination.spec.ts new file mode 100644 index 00000000000..ffce8d67d0c --- /dev/null +++ b/packages/manager/cypress/e2e/core/delivery/edit-destination.spec.ts @@ -0,0 +1,111 @@ +import { + mockDestination, + mockDestinationPayload, + mockDestinationPayloadWithId, +} from 'support/constants/delivery'; +import { + mockGetDestination, + mockGetDestinations, + mockTestConnection, + mockUpdateDestination, +} from 'support/intercepts/delivery'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { ui } from 'support/ui'; +import { logsDestinationForm } from 'support/ui/pages/logs-destination-form'; +import { randomLabel } from 'support/util/random'; + +import { getDestinationTypeOption } from 'src/features/Delivery/deliveryUtils'; + +import type { AkamaiObjectStorageDetailsExtended } from '@linode/api-v4'; + +describe('Edit Destination', () => { + beforeEach(() => { + mockAppendFeatureFlags({ + aclpLogs: { enabled: true, beta: true }, + }); + cy.visitWithLogin(`/logs/delivery/destinations/${mockDestination.id}/edit`); + mockGetDestination(mockDestination); + }); + + it('destination type edit should be disabled', () => { + cy.findByLabelText('Destination Type') + .should('be.visible') + .should('be.disabled') + .should( + 'have.attr', + 'value', + getDestinationTypeOption(mockDestination.type)?.label + ); + }); + + it('edit destination with incorrect data', () => { + logsDestinationForm.fillDestinationDetailsForm( + mockDestinationPayload.details as AkamaiObjectStorageDetailsExtended + ); + + // Create Destination should be disabled before test connection + cy.findByRole('button', { name: 'Edit Destination' }).should('be.disabled'); + // Test connection of the destination form + mockTestConnection(400); + ui.button + .findByTitle('Test Connection') + .should('be.enabled') + .should('have.attr', 'type', 'button') + .click(); + + ui.toast.assertMessage( + 'Delivery connection test failed. Verify your delivery settings and try again.' + ); + + // Create Destination should be disabled after test connection failed + cy.findByRole('button', { name: 'Edit Destination' }).should('be.disabled'); + }); + + it('edit destination with correct data', () => { + const newLabel = randomLabel(); + // Give Destination a new label + logsDestinationForm.setLabel(newLabel); + + logsDestinationForm.fillDestinationDetailsForm( + mockDestinationPayload.details as AkamaiObjectStorageDetailsExtended + ); + + // Create Destination should be disabled before test connection + cy.findByRole('button', { name: 'Edit Destination' }).should('be.disabled'); + // Test connection of the destination form + mockTestConnection(); + ui.button + .findByTitle('Test Connection') + .should('be.enabled') + .should('have.attr', 'type', 'button') + .click(); + + ui.toast.assertMessage( + `Delivery connection test completed successfully. Data can now be sent using this configuration.` + ); + + const updatedDestination = { ...mockDestination, label: newLabel }; + mockUpdateDestination(mockDestinationPayloadWithId, updatedDestination); + mockGetDestinations([updatedDestination]); + // Submit the destination edit form + cy.findByRole('button', { name: 'Edit Destination' }) + .should('be.enabled') + .should('have.attr', 'type', 'button') + .click(); + + ui.toast.assertMessage( + `Destination ${updatedDestination.label} edited successfully` + ); + + // Verify we redirect to the destinations landing page upon successful edit + cy.url().should('endWith', 'destinations'); + + // Verify the edited destination shows on the Destinations landing page + cy.findByText(newLabel) + .closest('tr') + .within(() => { + // Verify Destination label shows + cy.findByText(newLabel).should('be.visible'); + }); + }); +}); 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 f80a54e3809..07bd5f6edd6 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/lke-update.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-update.spec.ts @@ -1239,11 +1239,20 @@ describe('LKE cluster updates', () => { }); it('can add a node pool with an update strategy on an LKE enterprise cluster', () => { - const cluster = kubernetesClusterFactory.build({ + const clusterRegion = regionFactory.build({ + capabilities: ['Linodes', 'Kubernetes', 'Kubernetes Enterprise'], + id: 'us-east', + }); + const mockCluster = kubernetesClusterFactory.build({ + k8s_version: latestKubernetesVersion, + region: clusterRegion.id, tier: 'enterprise', }); + const mockNodePool = nodePoolFactory.build({ + type: 'g6-dedicated-4', + }); const account = accountFactory.build({ - capabilities: ['Kubernetes Enterprise'], + capabilities: ['Linodes', 'Kubernetes', 'Kubernetes Enterprise'], }); const type = linodeTypeFactory.build({ class: 'dedicated', @@ -1258,13 +1267,15 @@ describe('LKE cluster updates', () => { }); mockGetAccount(account).as('getAccount'); - mockGetCluster(cluster).as('getCluster'); - mockGetClusterPools(cluster.id, []).as('getNodePools'); + mockGetRegions([clusterRegion]).as('getRegions'); + mockGetCluster(mockCluster).as('getCluster'); + mockGetClusterPools(mockCluster.id, [mockNodePool]).as('getNodePools'); + mockGetClusterPools(mockCluster.id, []).as('getNodePools'); mockGetLinodeTypes([type]).as('getTypes'); - cy.visitWithLogin(`/kubernetes/clusters/${cluster.id}`); + cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`); - cy.wait(['@getCluster', '@getNodePools', '@getAccount']); + cy.wait(['@getAccount', '@getCluster', '@getNodePools', '@getRegions']); ui.button .findByTitle('Add a Node Pool') @@ -1282,33 +1293,35 @@ describe('LKE cluster updates', () => { cy.findByLabelText('Add 1').should('be.enabled').click(); }); - interceptCreateNodePool(cluster.id).as('createNodePool'); + interceptCreateNodePool(mockCluster.id).as('createNodePool'); - ui.drawer.findByTitle(`Add a Node Pool: ${cluster.label}`).within(() => { - cy.findByLabelText('Update Strategy') - .should('be.visible') - .should('be.enabled') - .should('have.value', 'On Recycle Updates') // Should default to "On Recycle" - .click(); // Open the Autocomplete + ui.drawer + .findByTitle(`Add a Node Pool: ${mockCluster.label}`) + .within(() => { + cy.findByLabelText('Update Strategy') + .should('be.visible') + .should('be.enabled') + .should('have.value', 'On Recycle Updates') // Should default to "On Recycle" + .click(); // Open the Autocomplete - ui.autocompletePopper - .findByTitle('Rolling Updates') // Select "Rolling Updates" - .should('be.visible') - .should('be.enabled') - .click(); + ui.autocompletePopper + .findByTitle('Rolling Updates') // Select "Rolling Updates" + .should('be.visible') + .should('be.enabled') + .click(); - // Verify the field's value actually changed - cy.findByLabelText('Update Strategy').should( - 'have.value', - 'Rolling Updates' - ); + // Verify the field's value actually changed + cy.findByLabelText('Update Strategy').should( + 'have.value', + 'Rolling Updates' + ); - ui.button - .findByTitle('Add pool') - .should('be.enabled') - .should('be.visible') - .click(); - }); + ui.button + .findByTitle('Add pool') + .should('be.enabled') + .should('be.visible') + .click(); + }); cy.wait('@createNodePool').then((intercept) => { const payload = intercept.request.body; 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 87840e9ae1a..d689f2f064e 100644 --- a/packages/manager/cypress/e2e/core/linodes/linode-network.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/linode-network.spec.ts @@ -2,6 +2,7 @@ import { linodeInterfaceFactoryPublic, linodeInterfaceFactoryVlan, linodeInterfaceFactoryVPC, + linodeInterfaceSettingsFactory, } from '@linode/utilities'; import { linodeFactory } from '@linode/utilities'; import { @@ -21,17 +22,25 @@ import { } from 'support/intercepts/firewalls'; import { mockCreateLinodeInterface, + mockDeleteLinodeInterface, mockGetLinodeDetails, mockGetLinodeFirewalls, mockGetLinodeInterface, mockGetLinodeInterfaces, + mockGetLinodeInterfaceSettings, mockGetLinodeIPAddresses, + mockUpdateLinodeInterface, + mockUpdateLinodeInterfaceSettings, } from 'support/intercepts/linodes'; import { mockUpdateIPAddress } from 'support/intercepts/networking'; import { mockGetVPC, mockGetVPCs } from 'support/intercepts/vpc'; import { ui } from 'support/ui'; -import type { IPRange, LinodeIPsResponse } from '@linode/api-v4'; +import type { + FirewallDevice, + IPRange, + LinodeIPsResponse, +} from '@linode/api-v4'; describe('IP Addresses', () => { // TODO M3-9775: Set mock linode interface type to legacy once Linode Interfaces is GA. @@ -465,6 +474,169 @@ describe('Linode Interfaces enabled', () => { }); }); + it('confirms deletion of a an interface works as expected', () => { + const mockLinodeInterface = linodeInterfaceFactoryPublic.build(); + mockGetLinodeInterfaces(mockLinode.id, { + interfaces: [mockLinodeInterface], + }).as('getInterfaces'); + mockGetLinodeInterface( + mockLinode.id, + mockLinodeInterface.id, + mockLinodeInterface + ); + mockDeleteLinodeInterface(mockLinode.id, mockLinodeInterface.id); + + cy.visitWithLogin(`/linodes/${mockLinode.id}/networking`); + + cy.findByText('No Network Interfaces exist on this Linode.').should( + 'not.exist' + ); + + cy.findByText(mockLinodeInterface.mac_address) + .should('be.visible') + .closest('tr') + .within(() => { + ui.actionMenu + .findByTitle( + `Action menu for Public Interface (${mockLinodeInterface.id})` + ) + .should('be.visible') + .should('be.enabled') + .click(); + ui.actionMenuItem + .findByTitle('Delete') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + mockGetLinodeInterfaces(mockLinode.id, { + interfaces: [], + }).as('getInterfaces'); + + ui.dialog + .findByTitle(`Delete Public Interface (ID: ${mockLinodeInterface.id})?`) + .should('be.visible') + .within(() => { + cy.findByText( + 'Are you sure you want to delete this Public interface?' + ); + ui.button + .findByTitle('Delete') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.findByText('No Network Interfaces exist on this Linode.').should( + 'be.visible' + ); + }); + + it('confirms the Interface Settings form', () => { + const vpcInterface = linodeInterfaceFactoryVPC.build({ id: 1 }); + const publicInterface = linodeInterfaceFactoryPublic.build({ id: 2 }); + const interfaceSettings = linodeInterfaceSettingsFactory.build({ + default_route: { + ipv4_interface_id: publicInterface.id, + ipv6_interface_id: publicInterface.id, + }, + }); + const updatedInterfaceSettings = linodeInterfaceSettingsFactory.build({ + default_route: { + ipv4_interface_id: vpcInterface.id, + ipv6_interface_id: publicInterface.id, + }, + }); + + mockGetLinodeInterfaces(mockLinode.id, { + interfaces: [vpcInterface, publicInterface], + }).as('getInterfaces'); + mockGetLinodeInterfaceSettings(mockLinode.id, interfaceSettings); + mockUpdateLinodeInterfaceSettings( + mockLinode.id, + updatedInterfaceSettings + ); + + cy.visitWithLogin(`/linodes/${mockLinode.id}/networking`); + + ui.button + .findByTitle('Interface Settings') + .should('be.visible') + .should('be.enabled') + .click(); + + // Verify the Interface Setting Drawer's contents + ui.drawer + .findByTitle('Interface Settings') + .should('be.visible') + .within(() => { + cy.findByText('Default Route Selection').should('be.visible'); + + // Confirm drawer reflects current Default Route values + ui.autocomplete + .findByLabel('Default IPv4 Route') + .should('be.visible') + .should('have.value', 'Public Interface (ID: 2)'); + + ui.autocomplete + .findByLabel('Default IPv6 Route') + .should('be.visible') + .should('have.value', 'Public Interface (ID: 2)'); + + cy.findByText('Enable Network Helper') + .should('be.visible') + .should('be.enabled'); + ui.button + .findByTitle('Save') + .should('be.visible') + .should('not.be.enabled'); + + // Update Default IPv4 Route + ui.autocomplete + .findByLabel('Default IPv4 Route') + .type('VPC Interface (ID: 1)'); + + ui.autocompletePopper + .findByTitle('VPC Interface (ID: 1)', { exact: false }) + .should('be.visible') + .click(); + + // Confirm save button becomes enabled once changes are made + ui.button + .findByTitle('Save') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // confirm toast upon success + ui.toast.assertMessage('Successfully updated interface settings.'); + + // re-open drawer + ui.button + .findByTitle('Interface Settings') + .should('be.visible') + .should('be.enabled') + .click(); + + // confirm settings have updated + ui.drawer + .findByTitle('Interface Settings') + .should('be.visible') + .within(() => { + ui.autocomplete + .findByLabel('Default IPv4 Route') + .should('be.visible') + .should('have.value', 'VPC Interface (ID: 1)'); + + ui.autocomplete + .findByLabel('Default IPv6 Route') + .should('be.visible') + .should('have.value', 'Public Interface (ID: 2)'); + }); + }); + describe('Adding a Linode Interface', () => { it('allows the user to add a VLAN interface', () => { const mockLinodeInterface = linodeInterfaceFactoryVlan.build(); @@ -936,5 +1108,425 @@ describe('Linode Interfaces enabled', () => { }); }); }); + + describe('Editing a Linode Interface', () => { + it('confirms VLAN interfaces cannot be edited', () => { + const linodeInterface = linodeInterfaceFactoryVlan.build(); + mockGetLinodeInterfaces(mockLinode.id, { + interfaces: [linodeInterface], + }).as('getInterfaces'); + mockGetLinodeInterface( + mockLinode.id, + linodeInterface.id, + linodeInterface + ); + + cy.visitWithLogin(`/linodes/${mockLinode.id}/networking`); + + // Confirm edit drawer is disabled + 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('Edit') + .should('be.visible') + .should('not.be.enabled'); + + ui.tooltip + .findByText('VLAN interfaces cannot be edited.') + .should('be.visible') + .should('be.enabled') + .click(); + }); + }); + + /** + * - Confirms adding an IPv4 address and marking it as primary + * - Confirms adding an IPv6 /56 range + * - Confirms adding an IPv6 /64 range + * - Confirms updating a firewall + */ + it('confirms editing a public interface', () => { + const linodeInterface = linodeInterfaceFactoryPublic.build(); + const updatedLinodeInterface = linodeInterfaceFactoryPublic.build({ + id: linodeInterface.id, + public: { + ipv4: { + addresses: [ + { + address: '10.0.0.1', + primary: true, + }, + ], + shared: [], + }, + ipv6: { + ranges: [ + { + range: '2600:3c06:e001:149::/64', + route_target: null, + }, + { + range: '2600:3c06:e001:149::/56', + route_target: null, + }, + ], + shared: [], + slaac: [], + }, + }, + }); + const selectedFirewall = mockFirewalls[1]; + const mockFirewallDevice = { + id: linodeInterface.id, + entity: { + id: linodeInterface.id, + label: mockLinode.label, + parent_entity: null, + type: 'linode_interface', + url: '', + }, + created: '', + updated: '', + }; + + mockGetLinodeInterfaces(mockLinode.id, { + interfaces: [linodeInterface], + }).as('getInterfaces'); + mockGetLinodeInterface( + mockLinode.id, + linodeInterface.id, + linodeInterface + ); + mockGetFirewalls(mockFirewalls); + mockUpdateLinodeInterface( + mockLinode.id, + linodeInterface.id, + updatedLinodeInterface + ); + mockGetLinodeInterfaceFirewalls(mockLinode.id, linodeInterface.id, []); + mockAddFirewallDevice( + selectedFirewall.id, + mockFirewallDevice as FirewallDevice + ); + + cy.visitWithLogin(`/linodes/${mockLinode.id}/networking`); + 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('Edit') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + ui.drawer + .findByTitle(`Edit Network Interface (ID: ${linodeInterface.id})`) + .should('be.visible') + .within(() => { + // IPv4 Section + cy.findByText('IPv4 Addresses'); + + // Confirm primary chip exists for first public IPv4 address + cy.findByText('10.0.0.0') + .closest('tr') + .within(() => { + cy.findByText('Primary').should('be.visible'); + }); + + // Allocate IPv4 address, then reset form and confirm no changes saved + ui.button + .findByTitle('Add IPv4 Address') + .should('be.visible') + .should('be.enabled') + .click(); + cy.findByText('IP allocated on save').should('be.visible'); + ui.button + .findByTitle('Reset') + .should('be.visible') + .should('be.enabled') + .click(); + cy.findByText('IP allocated on save').should('not.exist'); + + // Allocate public IPv4 address and make it primary + ui.button + .findByTitle('Add IPv4 Address') + .should('be.visible') + .should('be.enabled') + .click(); + + ui.button + .findByTitle('Make Primary') + .should('be.visible') + .should('be.enabled') + .click(); + + // Confirm primary chip no longer is visible for first public IPv4 address + // Remove first IPv4 address + cy.findByText('10.0.0.0') + .closest('tr') + .within(() => { + cy.findByText('Primary').should('not.exist'); + ui.button + .findByTitle('Remove') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.findByText('10.0.0.0').should('not.exist'); + + cy.findByText('IP allocated on save') + .should('be.visible') + .closest('tr') + .within(() => { + cy.findByText('Primary').should('be.visible'); + }); + + // IPv6 Section + cy.findByText('IPv6 Ranges').should('be.visible'); + cy.findByText( + 'No IPv6 ranges are assigned to this interface.' + ).should('be.visible'); + + // Add IPv6 /64 range and confirm result + ui.button + .findByTitle('Add IPv6 /64 Range') + .should('be.visible') + .should('be.enabled') + .click(); + cy.findByText('/64 range allocated on save').should('be.visible'); + + // Add /56 range and confirm result + ui.button + .findByTitle('Add IPv6 /56 Range') + .should('be.visible') + .should('be.enabled') + .click(); + cy.findByText('/56 range allocated on save').should('be.visible'); + + // Select a Firewall + ui.autocomplete.findByLabel('Firewall').click(); + ui.autocompletePopper.findByTitle(selectedFirewall.label).click(); + + mockGetLinodeInterfaceFirewalls(mockLinode.id, linodeInterface.id, [ + selectedFirewall, + ]); + + ui.button + .findByTitle('Save') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Confirm toast + ui.toast.assertMessage('Interface successfully updated.'); + + // Reopen Edit drawer and confirm changes + cy.findByText(linodeInterface.mac_address) + .should('be.visible') + .closest('tr') + .within(() => { + ui.actionMenu + .findByTitle( + `Action menu for Public Interface (${linodeInterface.id})` + ) + .click(); + + ui.actionMenuItem.findByTitle('Edit').click(); + }); + + ui.drawer + .findByTitle(`Edit Network Interface (ID: ${linodeInterface.id})`) + .should('be.visible') + .within(() => { + // Confirm IPs + cy.findByText('10.0.0.1').should('be.visible'); + cy.findByText('2600:3c06:e001:149::/64').should('be.visible'); + cy.findByText('2600:3c06:e001:149::/56').should('be.visible'); + ui.autocomplete + .findByLabel('Firewall') + .should('have.value', selectedFirewall.label); + }); + }); + + /** + * - Confirms auto-assigning an IPv4 address + * - Confirms IPv4 ranges can be added + * - Confirms updating a firewall + */ + it('confirms editing a VPC interface', () => { + const linodeInterface = linodeInterfaceFactoryVPC.build({ + vpc: { + ipv4: { + addresses: [ + { + address: '10.0.0.0', + primary: true, + }, + ], + ranges: [], + }, + }, + }); + const updatedLinodeInterface = linodeInterfaceFactoryVPC.build({ + id: linodeInterface.id, + vpc: { + ipv4: { + addresses: [ + { + address: '10.0.0.0', + primary: true, + }, + ], + ranges: [{ range: '10.0.0.1' }], + }, + }, + }); + const selectedFirewall = mockFirewalls[1]; + const mockFirewallDevice = { + id: linodeInterface.id, + entity: { + id: linodeInterface.id, + label: mockLinode.label, + parent_entity: null, + type: 'linode_interface', + url: '', + }, + created: '', + updated: '', + }; + + mockGetLinodeInterfaces(mockLinode.id, { + interfaces: [linodeInterface], + }).as('getInterfaces'); + mockGetLinodeInterface( + mockLinode.id, + linodeInterface.id, + linodeInterface + ); + mockGetFirewalls(mockFirewalls); + mockUpdateLinodeInterface( + mockLinode.id, + linodeInterface.id, + updatedLinodeInterface + ); + mockGetLinodeInterfaceFirewalls(mockLinode.id, linodeInterface.id, []); + mockAddFirewallDevice( + selectedFirewall.id, + mockFirewallDevice as FirewallDevice + ); + + cy.visitWithLogin(`/linodes/${mockLinode.id}/networking`); + 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('Edit') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + ui.drawer + .findByTitle(`Edit Network Interface (ID: ${linodeInterface.id})`) + .should('be.visible') + .within(() => { + // confirm VPC IPv4 address and public IPv4 address checkboxes exist + cy.findByLabelText('VPC IPv4 (required)').should('be.visible'); + cy.findByText('Auto-assign VPC IPv4') + .should('be.visible') + .should('be.enabled'); + cy.findByText('Allow public IPv4 access (1:1 NAT)').should( + 'be.visible' + ); + + // Add an IPv4 range + ui.button + .findByTitle('Add IPv4 Range') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.findByLabelText('VPC IPv4 Range 0').type('10.0.0.1'); + // Select a Firewall + ui.autocomplete.findByLabel('Firewall').click(); + ui.autocompletePopper.findByTitle(selectedFirewall.label).click(); + + mockGetLinodeInterfaceFirewalls(mockLinode.id, linodeInterface.id, [ + selectedFirewall, + ]); + + ui.button + .findByTitle('Save') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Confirm toast + ui.toast.assertMessage('Interface successfully updated.'); + + // Reopen Edit drawer and confirm changes + cy.findByText(linodeInterface.mac_address) + .should('be.visible') + .closest('tr') + .within(() => { + ui.actionMenu + .findByTitle( + `Action menu for VPC Interface (${linodeInterface.id})` + ) + .click(); + + ui.actionMenuItem.findByTitle('Edit').click(); + }); + + ui.drawer + .findByTitle(`Edit Network Interface (ID: ${linodeInterface.id})`) + .should('be.visible') + .within(() => { + cy.findByLabelText('VPC IPv4 Range 0').should( + 'have.value', + '10.0.0.1' + ); + cy.findByLabelText('VPC IPv4 (required)').should( + 'have.value', + '10.0.0.0' + ); + ui.autocomplete + .findByLabel('Firewall') + .should('have.value', selectedFirewall.label); + }); + }); + }); }); }); diff --git a/packages/manager/cypress/e2e/core/nodebalancers/nodebalancer-create-with-vpc.spec.ts b/packages/manager/cypress/e2e/core/nodebalancers/nodebalancer-create-with-vpc.spec.ts index 9331704d64a..2cd454153fb 100644 --- a/packages/manager/cypress/e2e/core/nodebalancers/nodebalancer-create-with-vpc.spec.ts +++ b/packages/manager/cypress/e2e/core/nodebalancers/nodebalancer-create-with-vpc.spec.ts @@ -1,15 +1,12 @@ import { linodeFactory, nodeBalancerFactory } from '@linode/utilities'; import { authenticate } from 'support/api/authentication'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; -import { - mockGetLinodeIPAddresses, - mockGetLinodes, -} from 'support/intercepts/linodes'; +import { mockGetLinodes } from 'support/intercepts/linodes'; import { mockCreateNodeBalancer, mockGetNodeBalancer, } from 'support/intercepts/nodebalancers'; -import { mockGetSubnets, mockGetVPCs } from 'support/intercepts/vpc'; +import { mockGetVPC, mockGetVPCIPs, mockGetVPCs } from 'support/intercepts/vpc'; import { ui } from 'support/ui'; import { randomIp, randomLabel, randomNumber } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; @@ -28,24 +25,29 @@ describe('Create a NodeBalancer with VPCs', () => { capabilities: ['VPCs', 'NodeBalancers'], }); - const mockSubnet = subnetFactory.build({ + const mockLinode = linodeFactory.build({ id: randomNumber(), - ipv4: `10.0.0.0/24`, label: randomLabel(), - linodes: [], + region: region.id, }); - const mockVPC = vpcFactory.build({ + const mockSubnet = subnetFactory.build({ id: randomNumber(), + ipv4: `10.0.0.0/24`, label: randomLabel(), - region: region.id, - subnets: [mockSubnet], + linodes: [ + { + id: mockLinode.id, + interfaces: [], + }, + ], }); - const mockLinode = linodeFactory.build({ + const mockVPC = vpcFactory.build({ id: randomNumber(), label: randomLabel(), region: region.id, + subnets: [mockSubnet], }); const mockLinodeVPCIPv4 = vpcIPv4Factory.build({ @@ -63,40 +65,16 @@ describe('Create a NodeBalancer with VPCs', () => { ipv4: randomIp(), }); - const mockUpdatedSubnet = { - ...mockSubnet, - linodes: [ - { - id: mockLinode.id, - interfaces: [], - }, - ], - nodebalancers: [ - { - id: mockNodeBalancer.id, - ipv4_range: '10.0.0.4/30', - }, - ], - }; - mockAppendFeatureFlags({ nodebalancerVpc: true, }).as('getFeatureFlags'); mockGetVPCs([mockVPC]).as('getVPCs'); - mockGetSubnets(mockVPC.id, [mockSubnet]).as('getSubnets'); + mockGetVPC(mockVPC).as('getVPC'); mockGetLinodes([mockLinode]).as('getLinodes'); mockCreateNodeBalancer(mockNodeBalancer).as('createNodeBalancer'); mockGetNodeBalancer(mockNodeBalancer); - mockGetLinodeIPAddresses(mockLinode.id, { - ipv4: { - private: [], - public: [], - reserved: [], - shared: [], - vpc: [mockLinodeVPCIPv4], - }, - }).as('getLinodeIPAddresses'); + mockGetVPCIPs(mockVPC.id, [mockLinodeVPCIPv4]).as('getVPCIPs'); cy.visitWithLogin('/nodebalancers/create'); cy.wait('@getFeatureFlags'); @@ -118,6 +96,9 @@ describe('Create a NodeBalancer with VPCs', () => { .should('be.visible') .click(); + // The "Node IP Address Select" should fetch the selected VPC and its IPs to render options. + cy.wait(['@getVPC', '@getVPCIPs']); + // Confirm that VPC's subnet gets selected cy.findByLabelText('Subnet').should( 'have.value', @@ -132,7 +113,7 @@ describe('Create a NodeBalancer with VPCs', () => { cy.findByText(`NodeBalancer IPv4 CIDR for ${mockSubnet.label}`).click(); cy.focused().clear(); - cy.focused().type(`${mockUpdatedSubnet.nodebalancers[0].ipv4_range}`); + cy.focused().type('10.0.0.4/30'); // node backend config cy.findByText('Label').click(); cy.focused().type(randomLabel()); diff --git a/packages/manager/cypress/e2e/core/nodebalancers/nodebalancers-create-in-complex-form.spec.ts b/packages/manager/cypress/e2e/core/nodebalancers/nodebalancers-create-in-complex-form.spec.ts index 8f4da5a306b..37e315110ed 100644 --- a/packages/manager/cypress/e2e/core/nodebalancers/nodebalancers-create-in-complex-form.spec.ts +++ b/packages/manager/cypress/e2e/core/nodebalancers/nodebalancers-create-in-complex-form.spec.ts @@ -88,7 +88,7 @@ describe('create NodeBalancer to test the submission of multiple nodes and multi .findByTitle(nodeBal_2.ipv4) .should('be.visible') .click(); - cy.findByLabelText('Weight').should('be.visible').click(); + cy.findAllByLabelText('Weight').last().should('be.visible').click(); cy.focused().clear(); cy.focused().type('50'); diff --git a/packages/manager/cypress/support/api/delivery.ts b/packages/manager/cypress/support/api/delivery.ts new file mode 100644 index 00000000000..d251e9f1cb8 --- /dev/null +++ b/packages/manager/cypress/support/api/delivery.ts @@ -0,0 +1,45 @@ +import { + deleteDestination, + deleteStream, + getDestinations, + getStreams, +} from '@linode/api-v4'; +import { isTestLabel } from 'support/api/common'; +import { pageSize } from 'support/constants/api'; +import { depaginate } from 'support/util/paginate'; + +import type { Destination, Stream } from '@linode/api-v4'; + +/** + * Deletes all destinations which are prefixed with the test entity prefix. + * + * @returns Promise that resolves when destinations have been deleted. + */ +export const deleteAllTestDestinations = async (): Promise => { + const destinations = await depaginate((page: number) => + getDestinations({ page, page_size: pageSize }) + ); + + const deletionPromises = destinations + .filter((destination: Destination) => isTestLabel(destination.label)) + .map((destination: Destination) => deleteDestination(destination.id)); + + await Promise.all(deletionPromises); +}; + +/** + * Deletes all streams which are prefixed with the test entity prefix. + * + * @returns Promise that resolves when streams have been deleted. + */ +export const deleteAllTestStreams = async (): Promise => { + const streams = await depaginate((page: number) => + getStreams({ page, page_size: pageSize }) + ); + + const deletionPromises = streams + .filter((destination: Stream) => isTestLabel(destination.label)) + .map((destination: Stream) => deleteStream(destination.id)); + + await Promise.all(deletionPromises); +}; diff --git a/packages/manager/cypress/support/constants/delivery.ts b/packages/manager/cypress/support/constants/delivery.ts new file mode 100644 index 00000000000..1d3a03f5e36 --- /dev/null +++ b/packages/manager/cypress/support/constants/delivery.ts @@ -0,0 +1,29 @@ +import { destinationType } from '@linode/api-v4'; +import { randomLabel, randomString } from 'support/util/random'; + +import { destinationFactory } from 'src/factories'; + +import type { Destination } from '@linode/api-v4'; + +export const mockDestinationPayload = { + label: randomLabel(), + type: destinationType.AkamaiObjectStorage, + details: { + host: randomString(), + bucket_name: randomString(), + access_key_id: randomString(), + access_key_secret: randomString(), + path: '/', + }, +}; + +export const mockDestination: Destination = destinationFactory.build({ + id: 1290, + ...mockDestinationPayload, + version: '1.0', +}); + +export const mockDestinationPayloadWithId = { + id: mockDestination.id, + ...mockDestinationPayload, +}; diff --git a/packages/manager/cypress/support/intercepts/delivery.ts b/packages/manager/cypress/support/intercepts/delivery.ts new file mode 100644 index 00000000000..03dff6f8db8 --- /dev/null +++ b/packages/manager/cypress/support/intercepts/delivery.ts @@ -0,0 +1,136 @@ +/** + * @file Cypress intercepts and mocks for Logs Delivery API requests. + */ + +import { apiMatcher } from 'support/util/intercepts'; +import { paginateResponse } from 'support/util/paginate'; +import { makeResponse } from 'support/util/response'; + +import type { + Destination, + UpdateDestinationPayloadWithId, +} from '@linode/api-v4'; + +/** + * Intercepts GET request to fetch destination instance and mocks response. + * + * @param destination - Response destinations. + * + * @returns Cypress chainable. + */ +export const mockGetDestination = ( + destination: Destination +): Cypress.Chainable => { + return cy.intercept( + 'GET', + apiMatcher(`monitor/streams/destinations/${destination.id}`), + makeResponse(destination) + ); +}; + +/** + * Intercepts GET request to mock destination data. + * + * @param destinations - an array of mock destination objects. + * + * @returns Cypress chainable. + */ +export const mockGetDestinations = ( + destinations: Destination[] +): Cypress.Chainable => { + return cy.intercept( + 'GET', + apiMatcher('monitor/streams/destinations*'), + paginateResponse(destinations) + ); +}; + +/** + * Intercepts POST request to create a Destination record. + * + * @returns Cypress chainable. + */ +export const interceptCreateDestination = (): Cypress.Chainable => { + return cy.intercept('POST', apiMatcher('monitor/streams/destinations*')); +}; + +/** + * Intercepts DELETE request to delete Destination record. + * + * @returns Cypress chainable. + */ +export const interceptDeleteDestination = (): Cypress.Chainable => { + return cy.intercept('DELETE', apiMatcher(`monitor/streams/destinations/*`)); +}; + +/** + * Intercepts PUT request to update a destination and mocks response. + * + * @param destination - Destination data to update. + * @param responseBody - Full updated destination object. + * + * @returns Cypress chainable. + */ +export const mockUpdateDestination = ( + destination: UpdateDestinationPayloadWithId, + responseBody: Destination +): Cypress.Chainable => { + return cy.intercept( + 'PUT', + apiMatcher(`monitor/streams/destinations/${destination.id}`), + makeResponse(responseBody) + ); +}; + +/** + * Intercepts POST request to create a destination and mocks response. + * + * @param responseCode + * @param responseBody - Full destination object returned when created. + * + * @returns Cypress chainable. + */ +export const mockCreateDestination = ( + responseBody = {}, + responseCode = 200 +): Cypress.Chainable => { + return cy.intercept( + 'POST', + apiMatcher(`monitor/streams/destinations`), + makeResponse(responseBody ?? {}, responseCode) + ); +}; + +/** + * Intercepts POST request to verify destination connection. + * + * @param responseCode - status code of the response. + * @param responseBody - response body content. + * + * @returns Cypress chainable. + */ +export const mockTestConnection = ( + responseCode = 200, + responseBody = {} +): Cypress.Chainable => { + return cy.intercept( + 'POST', + apiMatcher(`monitor/streams/destinations/verify`), + makeResponse(responseBody, responseCode) + ); +}; + +/** + * Intercept DELETE mock request to delete a Destination record. + * + * @returns Cypress chainable. + */ +export const mockDeleteDestination = ( + responseCode = 200 +): Cypress.Chainable => { + return cy.intercept( + 'DELETE', + apiMatcher(`monitor/streams/destinations/*`), + makeResponse({}, responseCode) + ); +}; diff --git a/packages/manager/cypress/support/intercepts/linodes.ts b/packages/manager/cypress/support/intercepts/linodes.ts index 26c50864836..c5f0bd405bf 100644 --- a/packages/manager/cypress/support/intercepts/linodes.ts +++ b/packages/manager/cypress/support/intercepts/linodes.ts @@ -15,6 +15,7 @@ import type { Linode, LinodeInterface, LinodeInterfaces, + LinodeInterfaceSettings, LinodeIPsResponse, LinodeType, Stats, @@ -684,6 +685,44 @@ export const mockGetLinodeInterfaces = ( ); }; +/** + * Mocks GET request to get a Linode's Interface Settings. + * + * @param linodeId - ID of Linode to get interfaces associated with it + * @param settings - the mocked Linode Settings + * + * @returns Cypress Chainable. + */ +export const mockGetLinodeInterfaceSettings = ( + linodeId: number, + settings: LinodeInterfaceSettings +): Cypress.Chainable => { + return cy.intercept( + 'GET', + apiMatcher(`linode/instances/${linodeId}/interfaces/settings`), + settings + ); +}; + +/** + * Intercepts PUT request to edit Linode's interface settings + * + * @param linodeId - ID of Linode for intercepted request. + * @param updatedLinode - the mocked Linode Settings + * + * @returns Cypress chainable. + */ +export const mockUpdateLinodeInterfaceSettings = ( + linodeId: number, + settings: LinodeInterfaceSettings +): Cypress.Chainable => { + return cy.intercept( + 'PUT', + apiMatcher(`linode/instances/${linodeId}/interfaces/settings`), + settings + ); +}; + /** * Mocks GET request to get a single Linode Interface. * @@ -705,6 +744,45 @@ export const mockGetLinodeInterface = ( ); }; +/** + * Intercepts PUT request to edit Linode's interface settings + * + * @param linodeId - ID of Linode for intercepted request. + * @param updatedLinode - the mocked Linode Settings + * + * @returns Cypress chainable. + */ +export const mockUpdateLinodeInterface = ( + linodeId: number, + interfaceId: number, + linodeInterface: LinodeInterface +): Cypress.Chainable => { + return cy.intercept( + 'PUT', + apiMatcher(`linode/instances/${linodeId}/interfaces/${interfaceId}`), + linodeInterface + ); +}; + +/** + * Intercepts DELETE request to delete linode interface and mocks response. + * + * @param linodeId - ID of Linode for intercepted request. + * @param interfaceId - ID of interface for intercepted request. + * + * @returns Cypress chainable. + */ +export const mockDeleteLinodeInterface = ( + linodeId: number, + interfaceId: number +): Cypress.Chainable => { + return cy.intercept( + 'DELETE', + apiMatcher(`linode/instances/${linodeId}/interfaces/${interfaceId}`), + makeResponse({}) + ); +}; + /** * Intercepts POST request to create a Linode Interface. * diff --git a/packages/manager/cypress/support/intercepts/vpc.ts b/packages/manager/cypress/support/intercepts/vpc.ts index d2c179b8520..2fa804ef810 100644 --- a/packages/manager/cypress/support/intercepts/vpc.ts +++ b/packages/manager/cypress/support/intercepts/vpc.ts @@ -7,7 +7,7 @@ import { apiMatcher } from 'support/util/intercepts'; import { paginateResponse } from 'support/util/paginate'; import { makeResponse } from 'support/util/response'; -import type { Subnet, VPC } from '@linode/api-v4'; +import type { Subnet, VPC, VPCIP } from '@linode/api-v4'; export const MOCK_DELETE_VPC_ERROR = 'Before deleting this VPC, you must remove all of its Linodes'; @@ -34,6 +34,25 @@ export const mockGetVPCs = (vpcs: VPC[]): Cypress.Chainable => { return cy.intercept('GET', apiMatcher('vpcs*'), paginateResponse(vpcs)); }; +/** + * Intercepts GET request to fetch a VPC's IPs and mocks response. + * + * @param vpcId - The ID of the VPC. + * @param vpcIPs - Array of VPC IPs with which to mock response. + * + * @returns Cypress chainable. + */ +export const mockGetVPCIPs = ( + vpcId: number, + vpcIPs: VPCIP[] +): Cypress.Chainable => { + return cy.intercept( + 'GET', + apiMatcher(`vpcs/${vpcId}/ips*`), + paginateResponse(vpcIPs) + ); +}; + /** * Intercepts POST request to create a VPC and mocks the response. * diff --git a/packages/manager/cypress/support/ui/pages/logs-destination-form.ts b/packages/manager/cypress/support/ui/pages/logs-destination-form.ts new file mode 100644 index 00000000000..1a15feffa7a --- /dev/null +++ b/packages/manager/cypress/support/ui/pages/logs-destination-form.ts @@ -0,0 +1,98 @@ +/** + * @file Page utilities for Logs Delivery Destination Form. + * Create/Edit Destination Page + * Create/Edit Stream Page + */ + +import type { AkamaiObjectStorageDetailsExtended } from '@linode/api-v4'; + +export const logsDestinationForm = { + /** + * Sets destination's label + * + * @param label - destination label to set + */ + setLabel: (label: string) => { + cy.findByLabelText('Destination Name') + .should('be.visible') + .should('be.enabled') + .should('have.attr', 'placeholder', 'Destination Name') + .clear(); + cy.focused().type(label); + }, + + /** + * Sets destination's host + * + * @param host - destination host to set + */ + setHost: (host: string) => { + cy.findByLabelText('Host') + .should('be.visible') + .should('be.enabled') + .should('have.attr', 'placeholder', 'Host') + .clear(); + cy.focused().type(host); + }, + + /** + * Sets destination's bucket name + * + * @param bucketName - destination bucket name to set + */ + setBucket: (bucketName: string) => { + cy.findByLabelText('Bucket') + .should('be.visible') + .should('be.enabled') + .should('have.attr', 'placeholder', 'Bucket') + .clear(); + cy.focused().type(bucketName); + }, + + /** + * Sets destination's Access Key ID + * + * @param accessKeyId - destination access key id to set + */ + setAccessKeyId: (accessKeyId: string) => { + cy.findByLabelText('Access Key ID') + .should('be.visible') + .should('be.enabled') + .should('have.attr', 'placeholder', 'Access Key ID') + .clear(); + cy.focused().type(accessKeyId); + }, + + /** + * Sets destination's Secret Access Key + * + * @param secretAccessKey - destination secret access key to set + */ + setSecretAccessKey: (secretAccessKey: string) => { + cy.findByLabelText('Secret Access Key') + .should('be.visible') + .should('be.enabled') + .should('have.attr', 'placeholder', 'Secret Access Key') + .clear(); + cy.focused().type(secretAccessKey); + }, + + /** + * Fills all form fields related to destination's details (AkamaiObjectStorageDetails type) + * + * @param data - object with destination details of AkamaiObjectStorageDetails type + */ + fillDestinationDetailsForm: (data: AkamaiObjectStorageDetailsExtended) => { + // Give Destination a host + logsDestinationForm.setHost(data.host); + + // Give Destination a bucket + logsDestinationForm.setBucket(data.bucket_name); + + // Give the Destination Access Key ID + logsDestinationForm.setAccessKeyId(data.access_key_id); + + // Give the Destination Secret Access Key + logsDestinationForm.setSecretAccessKey(data.access_key_secret); + }, +}; diff --git a/packages/manager/cypress/support/util/cleanup.ts b/packages/manager/cypress/support/util/cleanup.ts index 3dd61106092..11432ce0ff7 100644 --- a/packages/manager/cypress/support/util/cleanup.ts +++ b/packages/manager/cypress/support/util/cleanup.ts @@ -1,3 +1,4 @@ +import { deleteAllTestDestinations } from 'support/api/delivery'; import { deleteAllTestDomains } from 'support/api/domains'; import { cancelAllTestEntityTransfers } from 'support/api/entityTransfer'; import { deleteAllTestFirewalls } from 'support/api/firewalls'; @@ -17,6 +18,7 @@ import { deleteAllTestVolumes } from 'support/api/volumes'; /** Types of resources that can be cleaned up. */ export type CleanUpResource = + | 'destinations' | 'domains' | 'firewalls' | 'images' @@ -39,6 +41,7 @@ type CleanUpMap = { // Map `CleanUpResource` strings to the clean up functions they execute. const cleanUpMap: CleanUpMap = { + destinations: () => deleteAllTestDestinations(), domains: () => deleteAllTestDomains(), firewalls: () => deleteAllTestFirewalls(), images: () => deleteAllTestImages(), diff --git a/packages/manager/cypress/vite.config.ts b/packages/manager/cypress/vite.config.ts index b600661f33b..136bc9292cf 100644 --- a/packages/manager/cypress/vite.config.ts +++ b/packages/manager/cypress/vite.config.ts @@ -7,7 +7,10 @@ import svgr from 'vite-plugin-svgr'; const DIRNAME = new URL('.', import.meta.url).pathname; export default defineConfig({ - plugins: [react(), svgr({ exportAsDefault: true })], + plugins: [ + react(), + svgr({ svgrOptions: { exportType: 'default' }, include: '**/*.svg' }), + ], build: { rollupOptions: { // Suppress "SOURCEMAP_ERROR" warnings. diff --git a/packages/manager/package.json b/packages/manager/package.json index 80098dd7cfd..4079788a8d8 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.153.2", + "version": "1.154.0", "private": true, "type": "module", "bugs": { @@ -45,7 +45,7 @@ "@tanstack/react-query-devtools": "5.51.24", "@tanstack/react-router": "^1.111.11", "@xterm/xterm": "^5.5.0", - "akamai-cds-react-components": "0.0.1-alpha.14", + "akamai-cds-react-components": "0.0.1-alpha.15", "algoliasearch": "^4.14.3", "axios": "~1.12.0", "braintree-web": "^3.92.2", @@ -128,7 +128,7 @@ "@storybook/addon-docs": "^9.0.12", "@storybook/react-vite": "^9.0.12", "@swc/core": "^1.10.9", - "@testing-library/cypress": "^10.0.3", + "@testing-library/cypress": "^10.1.0", "@testing-library/dom": "^10.1.0", "@testing-library/jest-dom": "~6.4.2", "@testing-library/react": "~16.0.0", @@ -154,21 +154,21 @@ "@types/redux-mock-store": "^1.0.1", "@types/throttle-debounce": "^1.0.0", "@types/zxcvbn": "^4.4.0", - "@vitejs/plugin-react-swc": "^3.7.2", - "@vitest/coverage-v8": "^3.1.2", + "@vitejs/plugin-react-swc": "^4.0.1", + "@vitest/coverage-v8": "^3.2.4", "@vueless/storybook-dark-mode": "^9.0.5", "axe-core": "^4.10.2", "chai-string": "^1.5.0", "concurrently": "^9.1.0", "css-mediaquery": "^0.1.2", - "cypress": "14.3.0", - "cypress-axe": "^1.6.0", + "cypress": "15.4.0", + "cypress-axe": "^1.7.0", "cypress-file-upload": "^5.0.8", "cypress-mochawesome-reporter": "^3.8.2", "cypress-multi-reporters": "^2.0.5", "cypress-on-fix": "^1.1.0", "cypress-real-events": "^1.14.0", - "cypress-vite": "^1.6.0", + "cypress-vite": "^1.8.0", "dotenv": "^16.0.3", "factory.ts": "^0.5.1", "glob": "^10.3.1", @@ -180,8 +180,8 @@ "pdfreader": "^3.0.7", "redux-mock-store": "^1.5.3", "storybook": "^9.0.12", - "vite": "^6.3.6", - "vite-plugin-svgr": "^3.2.0" + "vite": "^7.1.11", + "vite-plugin-svgr": "^4.5.0" }, "browserslist": [ ">1%", diff --git a/packages/manager/src/assets/icons/filter.svg b/packages/manager/src/assets/icons/filter.svg new file mode 100644 index 00000000000..a27728327cf --- /dev/null +++ b/packages/manager/src/assets/icons/filter.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/manager/src/assets/icons/filterfilled.svg b/packages/manager/src/assets/icons/filterfilled.svg new file mode 100644 index 00000000000..bdbd7f01881 --- /dev/null +++ b/packages/manager/src/assets/icons/filterfilled.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/manager/src/components/InlineMenuAction/InlineMenuAction.tsx b/packages/manager/src/components/InlineMenuAction/InlineMenuAction.tsx index ab123c2dd13..2c5161a23ad 100644 --- a/packages/manager/src/components/InlineMenuAction/InlineMenuAction.tsx +++ b/packages/manager/src/components/InlineMenuAction/InlineMenuAction.tsx @@ -4,6 +4,8 @@ import * as React from 'react'; import { Link } from 'src/components/Link'; +import type { SxProps } from '@mui/material/styles'; + interface InlineMenuActionProps { /** Required action text */ actionText: string; @@ -23,6 +25,8 @@ interface InlineMenuActionProps { loading?: boolean; /** Optional onClick handler */ onClick?: (e: React.MouseEvent) => void; + /** Optional custom styles */ + sx?: SxProps; /** Optional tooltip text for help icon */ tooltip?: string; /** Optional tooltip event handler for sending analytics */ @@ -38,6 +42,7 @@ export const InlineMenuAction = (props: InlineMenuActionProps) => { href, loading, onClick, + sx, tooltip, tooltipAnalyticsEvent, ...rest @@ -45,7 +50,7 @@ export const InlineMenuAction = (props: InlineMenuActionProps) => { if (href) { return ( - + {actionText} ); @@ -58,7 +63,10 @@ export const InlineMenuAction = (props: InlineMenuActionProps) => { disabled={disabled} loading={loading} onClick={onClick} - sx={buttonHeight !== undefined ? { height: buttonHeight } : {}} + sx={{ + ...sx, + height: buttonHeight !== undefined ? buttonHeight : undefined, + }} tooltipAnalyticsEvent={tooltipAnalyticsEvent} tooltipText={tooltip} {...rest} diff --git a/packages/manager/src/components/MaintenanceBanner/LinodeMaintenanceBanner.tsx b/packages/manager/src/components/MaintenanceBanner/LinodeMaintenanceBanner.tsx index 9240f0ddb50..e1991b8848a 100644 --- a/packages/manager/src/components/MaintenanceBanner/LinodeMaintenanceBanner.tsx +++ b/packages/manager/src/components/MaintenanceBanner/LinodeMaintenanceBanner.tsx @@ -1,10 +1,15 @@ +import { scheduleOrQueueMigration } from '@linode/api-v4/lib/linodes'; import { useAllAccountMaintenanceQuery } from '@linode/queries'; -import { Notice, Typography } from '@linode/ui'; +import { ActionsPanel, LinkButton, Notice, Typography } from '@linode/ui'; +import { useDialog } from '@linode/utilities'; +import { useSnackbar } from 'notistack'; import React from 'react'; import { PENDING_MAINTENANCE_FILTER } from 'src/features/Account/Maintenance/utilities'; +import { useFlags } from 'src/hooks/useFlags'; import { isPlatformMaintenance } from 'src/hooks/usePlatformMaintenance'; +import { ConfirmationDialog } from '../ConfirmationDialog/ConfirmationDialog'; import { DateTimeDisplay } from '../DateTimeDisplay'; import { Link } from '../Link'; @@ -13,6 +18,8 @@ interface Props { } export const LinodeMaintenanceBanner = ({ linodeId }: Props) => { + const flags = useFlags(); + const { enqueueSnackbar } = useSnackbar(); const { data: allMaintenance } = useAllAccountMaintenanceQuery( {}, PENDING_MAINTENANCE_FILTER, @@ -31,45 +38,122 @@ export const LinodeMaintenanceBanner = ({ linodeId }: Props) => { const maintenanceStartTime = linodeMaintenance?.start_time || linodeMaintenance?.when; + const { closeDialog, dialog, handleError, openDialog, submitDialog } = + useDialog((id: number) => scheduleOrQueueMigration(id)); + + const isScheduled = Boolean(maintenanceStartTime); + + const actionLabel = isScheduled + ? 'enter the migration queue' + : 'schedule your migration'; + const showMigrateAction = + Boolean(flags.vmHostMaintenance?.hasQueue) && + linodeId !== undefined && + linodeMaintenance?.type === 'power_off_on'; + + const onSubmit = () => { + if (!linodeId) { + return; + } + submitDialog(linodeId) + .then(() => { + const successMessage = isScheduled + ? 'Your Linode has been entered into the migration queue.' + : 'Your migration has been scheduled.'; + enqueueSnackbar(successMessage, { variant: 'success' }); + }) + .catch(() => { + const errorMessage = isScheduled + ? 'An error occurred entering the migration queue.' + : 'An error occurred scheduling your migration.'; + handleError(errorMessage); + }); + }; + + const actions = () => ( + + ); + if (!linodeMaintenance) return null; return ( - - - Linode {linodeMaintenance.entity.label} {linodeMaintenance.description}{' '} - maintenance {maintenanceTypeLabel} will begin{' '} - - {maintenanceStartTime ? ( + <> + + + Linode {linodeMaintenance.entity.label}{' '} + {linodeMaintenance.description} maintenance {maintenanceTypeLabel}{' '} + will begin{' '} + + {maintenanceStartTime ? ( + <> + ({ + font: theme.font.bold, + })} + value={maintenanceStartTime} + />{' '} + at{' '} + ({ + font: theme.font.bold, + })} + value={maintenanceStartTime} + /> + + ) : ( + 'soon' + )} + + . For more details, view{' '} + + Account Maintenance + + {showMigrateAction ? ( <> - ({ - font: theme.font.bold, - })} - value={maintenanceStartTime} - />{' '} - at{' '} - ({ - font: theme.font.bold, - })} - value={maintenanceStartTime} - /> + {' or '} + openDialog(linodeId)}> + {actionLabel} + + {' now.'} ) : ( - 'soon' + '.' )} - - . For more details, view{' '} - + + {showMigrateAction && ( + closeDialog()} + open={dialog.isOpen} + title="Confirm Migration" > - Account Maintenance - - . - - + + Are you sure you want to{' '} + {isScheduled + ? 'enter the migration queue now' + : 'schedule your migration now'} + ? + + + )} + ); }; diff --git a/packages/manager/src/components/PlatformMaintenanceBanner/LinodePlatformMaintenanceBanner.tsx b/packages/manager/src/components/PlatformMaintenanceBanner/LinodePlatformMaintenanceBanner.tsx index 9963f22a970..e730df5cd3e 100644 --- a/packages/manager/src/components/PlatformMaintenanceBanner/LinodePlatformMaintenanceBanner.tsx +++ b/packages/manager/src/components/PlatformMaintenanceBanner/LinodePlatformMaintenanceBanner.tsx @@ -52,7 +52,11 @@ export const LinodePlatformMaintenanceBanner = (props: { return ( <> - + diff --git a/packages/manager/src/components/RegionSelect/RegionSelect.tsx b/packages/manager/src/components/RegionSelect/RegionSelect.tsx index b89dbe71c46..e8a314c64a2 100644 --- a/packages/manager/src/components/RegionSelect/RegionSelect.tsx +++ b/packages/manager/src/components/RegionSelect/RegionSelect.tsx @@ -150,6 +150,7 @@ export const RegionSelect = < textFieldProps={{ ...props.textFieldProps, InputProps: { + ...props.textFieldProps?.InputProps, endAdornment: isGeckoLAEnabled && selectedRegion && ( ({selectedRegion?.id}) diff --git a/packages/manager/src/dev-tools/FeatureFlagTool.tsx b/packages/manager/src/dev-tools/FeatureFlagTool.tsx index eebdafe4010..f08b7cea774 100644 --- a/packages/manager/src/dev-tools/FeatureFlagTool.tsx +++ b/packages/manager/src/dev-tools/FeatureFlagTool.tsx @@ -35,11 +35,11 @@ const options: { flag: keyof Flags; label: string }[] = [ { flag: 'linodeDiskEncryption', label: 'Linode Disk Encryption (LDE)' }, { flag: 'linodeInterfaces', label: 'Linode Interfaces' }, { flag: 'lkeEnterprise2', label: 'LKE-Enterprise' }, - { flag: 'mtc2025', label: 'MTC 2025' }, { flag: 'nodebalancerIpv6', label: 'NodeBalancer Dual Stack (IPv6)' }, { flag: 'nodebalancerVpc', label: 'NodeBalancer-VPC Integration' }, { flag: 'objMultiCluster', label: 'OBJ Multi-Cluster' }, { flag: 'objectStorageGen2', label: 'OBJ Gen2' }, + { flag: 'privateImageSharing', label: 'Private Image Sharing' }, { flag: 'selfServeBetas', label: 'Self Serve Betas' }, { flag: 'supportTicketSeverity', label: 'Support Ticket Severity' }, { flag: 'dbaasV2', label: 'Databases V2 Beta' }, diff --git a/packages/manager/src/dev-tools/components/ExtraPresetMaintenance.tsx b/packages/manager/src/dev-tools/components/ExtraPresetMaintenance.tsx index d1266c97001..c257d22db49 100644 --- a/packages/manager/src/dev-tools/components/ExtraPresetMaintenance.tsx +++ b/packages/manager/src/dev-tools/components/ExtraPresetMaintenance.tsx @@ -109,6 +109,9 @@ const renderMaintenanceFields = ( + + + , diff --git a/packages/manager/src/factories/delivery.ts b/packages/manager/src/factories/delivery.ts index 9c875d70441..5bc57f475c5 100644 --- a/packages/manager/src/factories/delivery.ts +++ b/packages/manager/src/factories/delivery.ts @@ -6,7 +6,6 @@ import type { Destination } from '@linode/api-v4'; export const destinationFactory = Factory.Sync.makeFactory({ details: { access_key_id: 'Access Id', - access_key_secret: 'Access Secret', bucket_name: 'Bucket Name', host: '3000', path: 'file', diff --git a/packages/manager/src/factories/index.ts b/packages/manager/src/factories/index.ts index a6556176da2..d075b7f42c7 100644 --- a/packages/manager/src/factories/index.ts +++ b/packages/manager/src/factories/index.ts @@ -13,6 +13,7 @@ export * from './cloudpulse/channels'; export * from './cloudpulse/services'; export * from './dashboards'; export * from './databases'; +export * from './delivery'; export * from './disk'; export * from './domain'; export * from './entityTransfers'; diff --git a/packages/manager/src/featureFlags.ts b/packages/manager/src/featureFlags.ts index 1a87e7429f1..b96989841a2 100644 --- a/packages/manager/src/featureFlags.ts +++ b/packages/manager/src/featureFlags.ts @@ -1,4 +1,5 @@ import type { OCA } from './features/OneClickApps/types'; +import type { Region } from '@linode/api-v4'; import type { CloudPulseServiceType, TPAProvider, @@ -70,6 +71,7 @@ interface LinodeInterfacesFlag extends BaseFeatureFlag { interface VMHostMaintenanceFlag extends BaseFeatureFlag { beta: boolean; + hasQueue?: boolean; new: boolean; } @@ -95,6 +97,11 @@ interface AclpFlag { * This property indicates whether the feature is enabled */ enabled: boolean; + + /** + * This property indicates whether to show widget dimension filters or not + */ + showWidgetDimensionFilters?: boolean; } interface LkeEnterpriseFlag extends BaseFeatureFlag { @@ -144,6 +151,17 @@ interface LimitsEvolution { requestForIncreaseDisabledForInternalAccountsOnly: boolean; } +interface MTC { + /** + * Whether the MTC feature is enabled. + */ + enabled: boolean; + /** + * Region IDs where MTC is supported (Only used for Linode Migration region dropdown). + */ + supportedRegions: Region['id'][]; +} + export interface Flags { acceleratedPlans: AcceleratedPlansFlag; aclp: AclpFlag; @@ -188,12 +206,13 @@ export interface Flags { mainContentBanner: MainContentBanner; marketplaceAppOverrides: MarketplaceAppOverride[]; metadata: boolean; - mtc2025: boolean; + mtc: MTC; nodebalancerIpv6: boolean; nodebalancerVpc: boolean; objectStorageGen2: BaseFeatureFlag; objMultiCluster: boolean; objSummaryPage: boolean; + privateImageSharing: boolean; productInformationBanners: ProductInformationBannerFlag[]; promos: boolean; promotionalOffers: PromotionalOffer[]; diff --git a/packages/manager/src/features/Account/Maintenance/MaintenanceTable.test.tsx b/packages/manager/src/features/Account/Maintenance/MaintenanceTable.test.tsx index 643e0d43ff4..2d1e112c326 100644 --- a/packages/manager/src/features/Account/Maintenance/MaintenanceTable.test.tsx +++ b/packages/manager/src/features/Account/Maintenance/MaintenanceTable.test.tsx @@ -8,7 +8,6 @@ import * as React from 'react'; import { accountMaintenanceFactory } from 'src/factories'; import { makeResourcePage } from 'src/mocks/serverHandlers'; import { http, HttpResponse, server } from 'src/mocks/testServer'; -import { parseAPIDate } from 'src/utilities/date'; import { formatDate } from 'src/utilities/formatDate'; import { mockMatchMedia, @@ -18,6 +17,7 @@ import { import { MaintenanceTable } from './MaintenanceTable'; import { MaintenanceTableRow } from './MaintenanceTableRow'; +import { getUpcomingRelativeLabel } from './utilities'; beforeAll(() => mockMatchMedia()); @@ -45,11 +45,10 @@ describe('Maintenance Table Row', () => { ); const { getByText } = within(screen.getByTestId('relative-date')); - if (maintenance.when) { - expect( - getByText(parseAPIDate(maintenance.when).toRelative()!) - ).toBeInTheDocument(); - } + // The upcoming relative label prefers the actual or policy-derived start time; + // falls back to the notice time when start cannot be determined. + const expected = getUpcomingRelativeLabel(maintenance); + expect(getByText(expected)).toBeInTheDocument(); }); }); diff --git a/packages/manager/src/features/Account/Maintenance/MaintenanceTableRow.tsx b/packages/manager/src/features/Account/Maintenance/MaintenanceTableRow.tsx index 6a322be998e..d4fc3505a55 100644 --- a/packages/manager/src/features/Account/Maintenance/MaintenanceTableRow.tsx +++ b/packages/manager/src/features/Account/Maintenance/MaintenanceTableRow.tsx @@ -1,4 +1,7 @@ -import { useProfile } from '@linode/queries'; +import { + useAccountMaintenancePoliciesQuery, + useProfile, +} from '@linode/queries'; import { Stack, Tooltip } from '@linode/ui'; import { Hidden } from '@linode/ui'; import { capitalize, getFormattedStatus, truncate } from '@linode/utilities'; @@ -19,7 +22,11 @@ import { useInProgressEvents } from 'src/queries/events/events'; import { parseAPIDate } from 'src/utilities/date'; import { formatDate } from 'src/utilities/formatDate'; -import { getMaintenanceDateField } from './utilities'; +import { + deriveMaintenanceStartISO, + getMaintenanceDateField, + getUpcomingRelativeLabel, +} from './utilities'; import type { MaintenanceTableType } from './MaintenanceTable'; import type { AccountMaintenance } from '@linode/api-v4/lib/account/types'; @@ -77,6 +84,23 @@ export const MaintenanceTableRow = (props: MaintenanceTableRowProps) => { const dateField = getMaintenanceDateField(tableType); const dateValue = props.maintenance[dateField]; + // Fetch policies to derive a start time when the API doesn't provide one + const { data: policies } = useAccountMaintenancePoliciesQuery(); + + // Precompute for potential use; currently used via getUpcomingRelativeLabel + React.useMemo( + () => deriveMaintenanceStartISO(props.maintenance, policies), + [policies, props.maintenance] + ); + + const upcomingRelativeLabel = React.useMemo( + () => + tableType === 'upcoming' + ? getUpcomingRelativeLabel(props.maintenance, policies) + : undefined, + [policies, props.maintenance, tableType] + ); + return ( @@ -114,7 +138,11 @@ export const MaintenanceTableRow = (props: MaintenanceTableRowProps) => { {(tableType === 'upcoming' || tableType === 'completed') && ( - {when ? parseAPIDate(when).toRelative() : '—'} + {tableType === 'upcoming' + ? upcomingRelativeLabel + : when + ? parseAPIDate(when).toRelative() + : '—'} )} @@ -137,7 +165,11 @@ export const MaintenanceTableRow = (props: MaintenanceTableRowProps) => { - {when ? parseAPIDate(when).toRelative() : '—'} + {tableType === 'upcoming' + ? upcomingRelativeLabel + : when + ? parseAPIDate(when).toRelative() + : '—'} diff --git a/packages/manager/src/features/Account/Maintenance/utilities.test.ts b/packages/manager/src/features/Account/Maintenance/utilities.test.ts new file mode 100644 index 00000000000..b2c19304751 --- /dev/null +++ b/packages/manager/src/features/Account/Maintenance/utilities.test.ts @@ -0,0 +1,156 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; + +import { + deriveMaintenanceStartISO, + getUpcomingRelativeLabel, +} from './utilities'; + +import type { AccountMaintenance, MaintenancePolicy } from '@linode/api-v4'; + +// Freeze time to a stable reference so relative labels are deterministic +const NOW_ISO = '2025-10-27T12:00:00.000Z'; + +describe('Account Maintenance utilities', () => { + const policies: MaintenancePolicy[] = [ + { + description: 'Migrate', + is_default: true, + label: 'Migrate', + notification_period_sec: 3 * 60 * 60, // 3 hours + slug: 'linode/migrate', + type: 'linode_migrate', + }, + { + description: 'Power Off / On', + is_default: false, + label: 'Power Off / Power On', + notification_period_sec: 72 * 60 * 60, // 72 hours + slug: 'linode/power_off_on', + type: 'linode_power_off_on', + }, + ]; + + const baseMaintenance: Omit & { when: string } = { + complete_time: null, + description: 'scheduled', + entity: { id: 1, label: 'linode-1', type: 'linode', url: '' }, + maintenance_policy_set: 'linode/migrate', + not_before: null, + reason: 'Test', + source: 'platform', + start_time: null, + status: 'scheduled', + type: 'migrate', + when: '2025-10-27T09:00:00.000Z', + }; + + // Mock Date.now used by Luxon under the hood for relative calculations + let realDateNow: () => number; + beforeAll(() => { + realDateNow = Date.now; + vi.spyOn(Date, 'now').mockImplementation(() => new Date(NOW_ISO).getTime()); + }); + afterAll(() => { + Date.now = realDateNow; + }); + + describe('deriveMaintenanceStartISO', () => { + it('returns provided start_time when available', () => { + const m: AccountMaintenance = { + ...baseMaintenance, + start_time: '2025-10-27T12:00:00.000Z', + }; + expect(deriveMaintenanceStartISO(m, policies)).toBe( + '2025-10-27T12:00:00.000Z' + ); + }); + + it('derives start_time from when + policy seconds when missing', () => { + const m: AccountMaintenance = { + ...baseMaintenance, + start_time: null, + when: '2025-10-27T09:00:00.000Z', // +3h -> 12:00Z + }; + expect(deriveMaintenanceStartISO(m, policies)).toBe( + '2025-10-27T12:00:00.000Z' + ); + }); + + it('returns undefined when policy cannot be found', () => { + const m: AccountMaintenance = { + ...baseMaintenance, + start_time: null, + // Use an intentionally unknown slug to exercise the no-policy fallback path. + // Even though the API default is typically 'linode/migrate', the client may + // not have policies loaded yet or could encounter a fetch error; this ensures + // we verify the graceful fallback behavior. + maintenance_policy_set: 'unknown/policy' as any, + }; + expect(deriveMaintenanceStartISO(m, policies)).toBeUndefined(); + }); + }); + + describe('getUpcomingRelativeLabel', () => { + it('falls back to notice-relative when start cannot be determined', () => { + const m: AccountMaintenance = { + ...baseMaintenance, + start_time: null, + // Force the no-policy path to validate the notice-relative fallback + maintenance_policy_set: 'unknown/policy' as any, + when: '2025-10-27T10:00:00.000Z', + }; + // NOW=12:00Z, when=10:00Z => "2 hours ago" + expect(getUpcomingRelativeLabel(m, policies)).toContain('hour'); + }); + + it('uses derived start to express time until maintenance (hours when <1 day)', () => { + const m: AccountMaintenance = { + ...baseMaintenance, + // when 09:00Z + 3h = 12:00Z; NOW=12:00Z -> label likely "a few seconds ago" or similar + when: '2025-10-27T09:00:00.000Z', + }; + // Allow any non-empty string; exact phrasing depends on Luxon locale + expect(getUpcomingRelativeLabel(m, policies)).toBeTypeOf('string'); + }); + + it('shows days+hours when >= 1 day away (avoids day-only rounding)', () => { + const m: AccountMaintenance = { + ...baseMaintenance, + maintenance_policy_set: 'linode/power_off_on', // 72h + when: '2025-10-25T20:00:00.000Z', // +72h => 2025-10-28T20:00Z; from NOW (27 12:00Z) => 1 day 8 hours + }; + const label = getUpcomingRelativeLabel(m, policies); + expect(label).toBe('in 1 day 8 hours'); + }); + + it('formats days and hours precisely when start_time is known', () => { + // From NOW (2025-10-27T12:00Z) to 2025-10-30T04:00Z is 2 days 16 hours + const m: AccountMaintenance = { + ...baseMaintenance, + start_time: '2025-10-30T04:00:00.000Z', + }; + const label = getUpcomingRelativeLabel(m, policies); + expect(label).toBe('in 2 days 16 hours'); + }); + + it('shows exact minutes when under one hour', () => { + const m: AccountMaintenance = { + ...baseMaintenance, + // NOW is 12:00Z; start in 37 minutes + start_time: '2025-10-27T12:37:00.000Z', + }; + const label = getUpcomingRelativeLabel(m, policies); + expect(label).toBe('in 37 minutes'); + }); + + it('shows seconds when under one minute', () => { + const m: AccountMaintenance = { + ...baseMaintenance, + // NOW is 12:00Z; start in 30 seconds + start_time: '2025-10-27T12:00:30.000Z', + }; + const label = getUpcomingRelativeLabel(m, policies); + expect(label).toBe('in 30 seconds'); + }); + }); +}); diff --git a/packages/manager/src/features/Account/Maintenance/utilities.ts b/packages/manager/src/features/Account/Maintenance/utilities.ts index e382dbbe3f3..9d751506a38 100644 --- a/packages/manager/src/features/Account/Maintenance/utilities.ts +++ b/packages/manager/src/features/Account/Maintenance/utilities.ts @@ -1,4 +1,10 @@ +import { pluralize } from '@linode/utilities'; +import { DateTime } from 'luxon'; + +import { parseAPIDate } from 'src/utilities/date'; + import type { MaintenanceTableType } from './MaintenanceTable'; +import type { AccountMaintenance, MaintenancePolicy } from '@linode/api-v4'; export const COMPLETED_MAINTENANCE_FILTER = Object.freeze({ status: { '+or': ['completed', 'canceled'] }, @@ -48,3 +54,100 @@ export const getMaintenanceDateField = ( export const getMaintenanceDateLabel = (type: MaintenanceTableType): string => { return maintenanceDateColumnMap[type][1]; }; + +/** + * Derive the maintenance start when API `start_time` is absent by adding the + * policy notification window to the `when` (notice publish time). + */ +export const deriveMaintenanceStartISO = ( + maintenance: AccountMaintenance, + policies?: MaintenancePolicy[] +): string | undefined => { + if (maintenance.start_time) { + return maintenance.start_time; + } + const notificationSecs = policies?.find( + (p) => p.slug === maintenance.maintenance_policy_set + )?.notification_period_sec; + if (maintenance.when && notificationSecs) { + try { + return parseAPIDate(maintenance.when) + .plus({ seconds: notificationSecs }) + .toISO(); + } catch { + return undefined; + } + } + return undefined; +}; + +/** + * Build a user-friendly relative label for the Upcoming table. + * + * Behavior: + * - Prefers the actual or policy-derived start time to express time until maintenance + * - Falls back to the notice relative time when the start cannot be determined + * - Avoids day-only rounding by showing days + hours when >= 1 day + * + * Formatting rules: + * - "in X days Y hours" when >= 1 day + * - "in X hours" when >= 1 hour and < 1 day + * - "in N minutes" when < 1 hour + * - "in N seconds" when < 1 minute + */ +export const getUpcomingRelativeLabel = ( + maintenance: AccountMaintenance, + policies?: MaintenancePolicy[] +): string => { + const startISO = deriveMaintenanceStartISO(maintenance, policies); + + // Fallback: when start cannot be determined, show the notice time relative to now + if (!startISO) { + return maintenance.when + ? (parseAPIDate(maintenance.when).toRelative() ?? '—') + : '—'; + } + + // Prefer the actual or policy-derived start time to express "time until maintenance" + const startDT = parseAPIDate(startISO); + const now = DateTime.local(); + if (startDT <= now) { + return startDT.toRelative() ?? '—'; + } + + // Avoid day-only rounding near boundaries by including hours alongside days. + // For times under an hour, show exact minutes remaining; under a minute, show seconds. + const diff = startDT + .diff(now, ['days', 'hours', 'minutes', 'seconds']) + .toObject(); + let days = Math.floor(diff.days ?? 0); + let hours = Math.floor(diff.hours ?? 0); + let minutes = Math.round(diff.minutes ?? 0); + const seconds = Math.round(diff.seconds ?? 0); + + // Normalize minute/hour boundaries + if (minutes === 60) { + hours += 1; + minutes = 0; + } + if (hours === 24) { + days += 1; + hours = 0; + } + + if (days >= 1) { + const dayPart = pluralize('day', 'days', days); + const hourPart = hours ? ` ${pluralize('hour', 'hours', hours)}` : ''; + return `in ${dayPart}${hourPart}`; + } + + if (hours >= 1) { + return `in ${pluralize('hour', 'hours', hours)}`; + } + + // Under one hour: show minutes; under one minute: show seconds + if (minutes === 0) { + return `in ${pluralize('second', 'seconds', Math.max(0, seconds))}`; + } + return `in ${pluralize('minute', 'minutes', Math.max(0, minutes))}`; +}; diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/FirewallDimensionFilterAutocomplete.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/FirewallDimensionFilterAutocomplete.test.tsx index 58d14782764..7a5dfee8293 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/FirewallDimensionFilterAutocomplete.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/FirewallDimensionFilterAutocomplete.test.tsx @@ -1,4 +1,4 @@ -import { screen } from '@testing-library/react'; +import { screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; import { vi } from 'vitest'; @@ -242,4 +242,115 @@ describe('', () => { await screen.findByRole('option', { name: 'us-east' }) ).toBeVisible(); }); + it('cleans up invalid single value (string)', async () => { + queryMocks.useFirewallFetchOptions.mockReturnValue({ + values: [{ label: 'Linode-1', value: '1' }], + isLoading: false, + isError: false, + }); + const fieldOnChange = vi.fn(); + const { rerender } = renderWithTheme( + + ); + // Simulate update to trigger effect + queryMocks.useFirewallFetchOptions.mockReturnValue({ + values: [{ label: 'Linode-1', value: '1' }], + isLoading: false, + isError: false, + }); + rerender( + + ); + await waitFor(() => { + expect(fieldOnChange).toHaveBeenCalledWith(null); + }); + }); + + it('cleans up invalid multi value (comma-separated string)', async () => { + queryMocks.useFirewallFetchOptions.mockReturnValue({ + values: [ + { label: 'Linode-1', value: '1' }, + { label: 'Linode-2', value: '2' }, + ], + isLoading: false, + isError: false, + }); + const fieldOnChange = vi.fn(); + const { rerender } = renderWithTheme( + + ); + queryMocks.useFirewallFetchOptions.mockReturnValue({ + values: [ + { label: 'Linode-1', value: '1' }, + { label: 'Linode-2', value: '2' }, + ], + isLoading: false, + isError: false, + }); + rerender( + + ); + await waitFor(() => { + expect(fieldOnChange).toHaveBeenCalledWith('1,2'); + }); + }); + + it('cleans up all invalid multi values (comma-separated string)', async () => { + queryMocks.useFirewallFetchOptions.mockReturnValue({ + values: [ + { label: 'Linode-1', value: '1' }, + { label: 'Linode-2', value: '2' }, + ], + isLoading: false, + isError: false, + }); + const fieldOnChange = vi.fn(); + const { rerender } = renderWithTheme( + + ); + queryMocks.useFirewallFetchOptions.mockReturnValue({ + values: [ + { label: 'Linode-1', value: '1' }, + { label: 'Linode-2', value: '2' }, + ], + isLoading: false, + isError: false, + }); + rerender( + + ); + await waitFor(() => { + expect(fieldOnChange).toHaveBeenCalledWith(''); + }); + }); }); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/FirewallDimensionFilterAutocomplete.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/FirewallDimensionFilterAutocomplete.tsx index fc7a140c85e..5dd6f3f1f2d 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/FirewallDimensionFilterAutocomplete.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/FirewallDimensionFilterAutocomplete.tsx @@ -2,6 +2,7 @@ import { useRegionsQuery } from '@linode/queries'; import { Autocomplete } from '@linode/ui'; import React from 'react'; +import { useCleanupStaleValues } from './useCleanupStaleValues'; import { useFirewallFetchOptions } from './useFirewallFetchOptions'; import { handleValueChange, resolveSelectedValues } from './utils'; @@ -39,6 +40,15 @@ export const FirewallDimensionFilterAutocomplete = ( type, scope, }); + + useCleanupStaleValues({ + options: values, + fieldValue, + multiple, + onChange: fieldOnChange, + isLoading, + }); + return ( ', () => { 'us-east-1.linodeobjects.com,us-west-1.linodeobjects.com' ); }); + it('field cleanup removes invalid values', async () => { + queryMocks.useObjectStorageFetchOptions.mockReturnValue({ + values: [ + { + label: 'us-east-1.linodeobjects.com', + value: 'us-east-1.linodeobjects.com', + }, + ], + isLoading: false, + isError: false, + }); + + const fieldOnChange = vi.fn(); + + const { rerender } = renderWithTheme( + + ); + + queryMocks.useObjectStorageFetchOptions.mockReturnValue({ + values: [ + { + label: 'us-west-1.linodeobjects.com', + value: 'us-west-1.linodeobjects.com', + }, + ], + isLoading: false, + isError: false, + }); + // Trigger the effect by rerendering with the same props + rerender( + + ); + // fieldOnChange should be called to clean up the invalid value + await waitFor(() => { + expect(fieldOnChange).toHaveBeenCalledWith(null); + }); + }); }); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/ObjectStorageDimensionFilterAutocomplete.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/ObjectStorageDimensionFilterAutocomplete.tsx index 777f8eb35bb..106a62104ff 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/ObjectStorageDimensionFilterAutocomplete.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/ObjectStorageDimensionFilterAutocomplete.tsx @@ -2,6 +2,7 @@ import { useRegionsQuery } from '@linode/queries'; import { Autocomplete } from '@linode/ui'; import React from 'react'; +import { useCleanupStaleValues } from './useCleanupStaleValues'; import { useObjectStorageFetchOptions } from './useObjectStorageFetchOptions'; import { handleValueChange, resolveSelectedValues } from './utils'; @@ -41,6 +42,14 @@ export const ObjectStorageDimensionFilterAutocomplete = ( serviceType, }); + useCleanupStaleValues({ + options: values, + fieldValue, + multiple, + onChange: fieldOnChange, + isLoading, + }); + return ( [] = [ + { label: 'Option 1', value: 'opt1' }, + { label: 'Option 2', value: 'opt2' }, + { label: 'Option 3', value: 'opt3' }, +]; + +describe('useCleanupStaleValues - loading state', () => { + it('should not call onChange when isLoading is true', () => { + const onChange = vi.fn(); + + renderHook(() => + useCleanupStaleValues({ + onChange, + options: mockOptions, + fieldValue: STALE_VALUE, + isLoading: true, + multiple: false, + }) + ); + + expect(onChange).not.toHaveBeenCalled(); + }); +}); + +describe('useCleanupStaleValues - null or empty values', () => { + it('should not call onChange when fieldValue is null', () => { + const onChange = vi.fn(); + + renderHook(() => + useCleanupStaleValues({ + onChange, + options: mockOptions, + fieldValue: null, + isLoading: false, + multiple: false, + }) + ); + + expect(onChange).not.toHaveBeenCalled(); + }); + + it('should not call onChange when fieldValue is an empty array', () => { + const onChange = vi.fn(); + + renderHook(() => + useCleanupStaleValues({ + onChange, + options: mockOptions, + fieldValue: [], + isLoading: false, + multiple: true, + }) + ); + + expect(onChange).not.toHaveBeenCalled(); + }); + + it('should not call onChange when fieldValue is an empty string', () => { + const onChange = vi.fn(); + + renderHook(() => + useCleanupStaleValues({ + onChange, + options: mockOptions, + fieldValue: '', + isLoading: false, + multiple: false, + }) + ); + + expect(onChange).not.toHaveBeenCalled(); + }); +}); + +describe('useCleanupStaleValues - single selection mode', () => { + it('should not call onChange when value is valid', () => { + const onChange = vi.fn(); + + renderHook(() => + useCleanupStaleValues({ + onChange, + options: mockOptions, + fieldValue: 'opt1', + isLoading: false, + multiple: false, + }) + ); + + expect(onChange).not.toHaveBeenCalled(); + }); + + it('should call onChange with null when value is not in options', () => { + const onChange = vi.fn(); + + renderHook(() => + useCleanupStaleValues({ + onChange, + options: mockOptions, + fieldValue: STALE_VALUE, + isLoading: false, + multiple: false, + }) + ); + + expect(onChange).toHaveBeenCalledWith(null); + expect(onChange).toHaveBeenCalledTimes(1); + }); + + it('should handle array fieldValue by using the first element', () => { + const onChange = vi.fn(); + + renderHook(() => + useCleanupStaleValues({ + onChange, + options: mockOptions, + fieldValue: [STALE_VALUE], + isLoading: false, + multiple: false, + }) + ); + + expect(onChange).toHaveBeenCalledWith(null); + }); + + it('should not call onChange when array fieldValue contains valid value', () => { + const onChange = vi.fn(); + + renderHook(() => + useCleanupStaleValues({ + onChange, + options: mockOptions, + fieldValue: ['opt2'], + isLoading: false, + multiple: false, + }) + ); + + expect(onChange).not.toHaveBeenCalled(); + }); + + it('should clear value when options no longer include it', () => { + const onChange = vi.fn(); + + const { rerender } = renderHook( + ({ options }) => + useCleanupStaleValues({ + onChange, + options, + fieldValue: 'opt2', + isLoading: false, + multiple: false, + }), + { initialProps: { options: mockOptions } } + ); + + expect(onChange).not.toHaveBeenCalled(); + + const newOptions: Item[] = [ + { label: 'Option 1', value: 'opt1' }, + { label: 'Option 3', value: 'opt3' }, + ]; + + rerender({ options: newOptions }); + + expect(onChange).toHaveBeenCalledWith(null); + expect(onChange).toHaveBeenCalledTimes(1); + }); +}); + +describe('useCleanupStaleValues - multiple selection mode', () => { + it('should not call onChange when all values are valid', () => { + const onChange = vi.fn(); + + renderHook(() => + useCleanupStaleValues({ + onChange, + options: mockOptions, + fieldValue: ['opt1', 'opt2'], + isLoading: false, + multiple: true, + }) + ); + + expect(onChange).not.toHaveBeenCalled(); + }); + + it('should filter out stale values from array', () => { + const onChange = vi.fn(); + + renderHook(() => + useCleanupStaleValues({ + onChange, + options: mockOptions, + fieldValue: ['opt1', STALE_VALUE, 'opt2'], + isLoading: false, + multiple: true, + }) + ); + + expect(onChange).toHaveBeenCalledWith(['opt1', 'opt2']); + expect(onChange).toHaveBeenCalledTimes(1); + }); + + it('should handle comma-separated string values', () => { + const onChange = vi.fn(); + + renderHook(() => + useCleanupStaleValues({ + onChange, + options: mockOptions, + fieldValue: `opt1,${STALE_VALUE},opt2`, + isLoading: false, + multiple: true, + }) + ); + + expect(onChange).toHaveBeenCalledWith('opt1,opt2'); + expect(onChange).toHaveBeenCalledTimes(1); + }); + + it('should call onChange when all values are stale', () => { + const onChange = vi.fn(); + + renderHook(() => + useCleanupStaleValues({ + onChange, + options: mockOptions, + fieldValue: ['stale1', 'stale2'], + isLoading: false, + multiple: true, + }) + ); + + expect(onChange).toHaveBeenCalledWith([]); + expect(onChange).toHaveBeenCalledTimes(1); + }); + + it('should cleanup values when options are updated', () => { + const onChange = vi.fn(); + + const { rerender } = renderHook( + ({ options }) => + useCleanupStaleValues({ + onChange, + options, + fieldValue: ['opt1', 'opt2'], + isLoading: false, + multiple: true, + }), + { initialProps: { options: mockOptions } } + ); + + expect(onChange).not.toHaveBeenCalled(); + + const newOptions: Item[] = [ + { label: 'Option 1', value: 'opt1' }, + { label: 'Option 3', value: 'opt3' }, + ]; + + rerender({ options: newOptions }); + + expect(onChange).toHaveBeenCalledWith(['opt1']); + expect(onChange).toHaveBeenCalledTimes(1); + }); +}); + +describe('useCleanupStaleValues - edge cases', () => { + it('should handle empty options array', () => { + const onChange = vi.fn(); + + renderHook(() => + useCleanupStaleValues({ + onChange, + options: [], + fieldValue: 'opt1', + isLoading: false, + multiple: false, + }) + ); + + expect(onChange).toHaveBeenCalledWith(null); + }); + + it('should not call onChange repeatedly on subsequent renders', () => { + const onChange = vi.fn(); + + const { rerender } = renderHook(() => + useCleanupStaleValues({ + onChange, + options: mockOptions, + fieldValue: ['opt1', 'opt2'], + isLoading: false, + multiple: true, + }) + ); + + expect(onChange).not.toHaveBeenCalled(); + + rerender(); + rerender(); + rerender(); + + expect(onChange).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/useCleanupStaleValues.ts b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/useCleanupStaleValues.ts new file mode 100644 index 00000000000..ff10bb4c066 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/useCleanupStaleValues.ts @@ -0,0 +1,54 @@ +import { useEffect } from 'react'; + +import type { Item } from '../../../constants'; + +/** + * Cleans up stale form values that are no longer in the options list. + */ +export const useCleanupStaleValues = ({ + options, + fieldValue, + multiple, + onChange, + isLoading, +}: { + fieldValue: null | string | string[]; + isLoading?: boolean; + multiple?: boolean; + onChange: (value: null | string | string[]) => void; + options: Item[]; +}) => { + useEffect(() => { + if (isLoading) { + return; + } + + if (!fieldValue || (Array.isArray(fieldValue) && fieldValue.length === 0)) { + return; + } + + const validValues = options.map((o) => o.value); + + if (multiple) { + const isArray = Array.isArray(fieldValue); + const selected = isArray + ? fieldValue + : fieldValue.split(',').filter((v) => v.trim() !== ''); + + const filtered = selected.filter((v) => validValues.includes(v)); + + if (filtered.length !== selected.length) { + onChange(isArray ? filtered : filtered.join(',')); + } + } else { + const value = Array.isArray(fieldValue) ? fieldValue[0] : fieldValue; + + if (value) { + const isStillValid = validValues.includes(value); + if (!isStillValid) { + onChange(null); + } + } + } + }, [options, fieldValue, multiple, onChange, isLoading]); +}; diff --git a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboard.tsx b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboard.tsx index 417c566d9b0..e8148e9fcc6 100644 --- a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboard.tsx +++ b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboard.tsx @@ -11,7 +11,10 @@ import { import { RESOURCE_FILTER_MAP } from '../Utils/constants'; import { useAclpPreference } from '../Utils/UserPreference'; -import { getAssociatedEntityType } from '../Utils/utils'; +import { + getAssociatedEntityType, + getResourcesFilterConfig, +} from '../Utils/utils'; import { renderPlaceHolder, RenderWidgets, @@ -112,6 +115,9 @@ export const CloudPulseDashboard = (props: DashboardProperties) => { isLoading: isDashboardLoading, } = useCloudPulseDashboardByIdQuery(dashboardId); + // Get the resources filter configuration for the dashboard + const resourcesFilterConfig = getResourcesFilterConfig(dashboardId); + const filterFn = resourcesFilterConfig?.filterFn; // Get the associated entity type for the dashboard const associatedEntityType = getAssociatedEntityType(dashboardId); @@ -124,7 +130,8 @@ export const CloudPulseDashboard = (props: DashboardProperties) => { dashboard?.service_type, {}, RESOURCE_FILTER_MAP[dashboard?.service_type ?? ''] ?? {}, - associatedEntityType + associatedEntityType, + filterFn ); const { diff --git a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardRenderer.tsx b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardRenderer.tsx index b596b7026ab..c74e2d17614 100644 --- a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardRenderer.tsx +++ b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardRenderer.tsx @@ -80,9 +80,11 @@ export const CloudPulseDashboardRenderer = React.memo( : undefined } resources={ - filterValue[RESOURCE_ID] && Array.isArray(filterValue[RESOURCE_ID]) - ? (filterValue[RESOURCE_ID] as string[]) - : [] + Array.isArray(filterValue[RESOURCE_ID]) + ? filterValue[RESOURCE_ID].map(String) + : typeof filterValue[RESOURCE_ID] === 'string' + ? [filterValue[RESOURCE_ID]] + : [] } savePref={true} serviceType={dashboard.service_type} diff --git a/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.test.ts b/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.test.ts index c4fcb62af9a..798a0e85fec 100644 --- a/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.test.ts +++ b/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.test.ts @@ -1,4 +1,5 @@ import { databaseQueries } from '@linode/queries'; +import { nodeBalancerFactory } from '@linode/utilities'; import { DateTime } from 'luxon'; import { @@ -12,9 +13,11 @@ import { deepEqual, filterBasedOnConfig, filterEndpointsUsingRegion, + filterFirewallNodebalancers, filterUsingDependentFilters, getEndpointsProperties, getFilters, + getFirewallNodebalancersProperties, getTextFilterProperties, } from './FilterBuilder'; import { @@ -43,7 +46,9 @@ const dbaasConfig = FILTER_CONFIG.get(1); const nodeBalancerConfig = FILTER_CONFIG.get(3); -const firewallConfig = FILTER_CONFIG.get(4); +const linodeFirewallConfig = FILTER_CONFIG.get(4); + +const nodebalancerFirewallConfig = FILTER_CONFIG.get(8); const dbaasDashboard = dashboardFactory.build({ service_type: 'dbaas', id: 1 }); @@ -134,6 +139,37 @@ it('test getResourceSelectionProperties method', () => { } }); +it('test getResourceSelectionProperties method for linode-firewall', () => { + const resourceSelectionConfig = linodeFirewallConfig?.filters.find( + (filterObj) => filterObj.name === 'Firewalls' + ); + + expect(resourceSelectionConfig).toBeDefined(); + + if (resourceSelectionConfig) { + const { + disabled, + handleResourcesSelection, + label, + savePreferences, + filterFn, + } = getResourcesProperties( + { + config: resourceSelectionConfig, + dashboard: { ...mockDashboard, id: 4 }, + isServiceAnalyticsIntegration: true, + }, + vi.fn() + ); + const { name } = resourceSelectionConfig.configuration; + expect(handleResourcesSelection).toBeDefined(); + expect(savePreferences).toEqual(false); + expect(disabled).toEqual(false); + expect(label).toEqual(name); + expect(filterFn).toBeDefined(); + } +}); + it('test getResourceSelectionProperties method with disabled true', () => { const resourceSelectionConfig = linodeConfig?.filters.find( (filterObj) => filterObj.name === 'Resources' @@ -349,6 +385,7 @@ it('test getCustomSelectProperties method', () => { isMultiSelect: isMultiSelectApi, savePreferences: savePreferencesApi, type, + filterFn, } = getCustomSelectProperties( { config: customSelectEngineConfig, @@ -365,6 +402,7 @@ it('test getCustomSelectProperties method', () => { expect(savePreferencesApi).toEqual(false); expect(isMultiSelectApi).toEqual(true); expect(label).toEqual(name); + expect(filterFn).not.toBeDefined(); } }); @@ -393,7 +431,7 @@ it('test getTextFilterProperties method for port', () => { }); it('test getTextFilterProperties method for interface_id', () => { - const interfaceIdFilterConfig = firewallConfig?.filters.find( + const interfaceIdFilterConfig = linodeFirewallConfig?.filters.find( (filterObj) => filterObj.name === 'Interface IDs' ); @@ -455,6 +493,49 @@ it('test getEndpointsProperties method', () => { expect(xFilter).toEqual({ region: 'us-east' }); } }); +it('test getFirewallNodebalancersProperties', () => { + const nodebalancersConfig = nodebalancerFirewallConfig?.filters.find( + (filterObj) => filterObj.name === 'NodeBalancers' + ); + + expect(nodebalancersConfig).toBeDefined(); + + if (nodebalancersConfig) { + const nodebalancersProperties = getFirewallNodebalancersProperties( + { + config: nodebalancersConfig, + dashboard: dashboardFactory.build({ service_type: 'firewall', id: 8 }), + dependentFilters: { + resource_id: '1', + associated_entity_region: 'us-east', + }, + isServiceAnalyticsIntegration: false, + }, + vi.fn() + ); + const { + label, + disabled, + selectedDashboard, + savePreferences, + handleNodebalancersSelection, + defaultValue, + xFilter, + } = nodebalancersProperties; + + expect(nodebalancersProperties).toBeDefined(); + expect(label).toEqual(nodebalancersConfig.configuration.name); + expect(selectedDashboard.service_type).toEqual('firewall'); + expect(savePreferences).toEqual(true); + expect(disabled).toEqual(false); + expect(handleNodebalancersSelection).toBeDefined(); + expect(defaultValue).toEqual(undefined); + expect(xFilter).toEqual({ + resource_id: '1', + associated_entity_region: 'us-east', + }); + } +}); it('test getFiltersForMetricsCallFromCustomSelect method', () => { const result = getMetricsCallCustomFilters( @@ -636,6 +717,76 @@ describe('filterEndpointsUsingRegion', () => { }); }); +describe('filterFirewallNodebalancers', () => { + const mockData = [ + nodeBalancerFactory.build({ + id: 1, + label: 'nodebalancer-1', + region: 'us-east', + }), + nodeBalancerFactory.build({ + id: 2, + label: 'nodebalancer-2', + region: 'us-west', + }), + ]; + const mockFirewalls: CloudPulseResources[] = [ + { + id: '1', + label: 'firewall-1', + entities: { '1': 'nodebalancer-1' }, + }, + ]; + + it('should return undefined if data is undefined', () => { + expect( + filterFirewallNodebalancers( + undefined, + { associated_entity_region: 'us-east', resource_id: '1' }, + mockFirewalls + ) + ).toEqual(undefined); + }); + + it('should return undefined if xFilter/firewalls is empty or undefined', () => { + const result = filterFirewallNodebalancers( + mockData, + undefined, + mockFirewalls + ); + const result2 = filterFirewallNodebalancers(mockData, {}, mockFirewalls); + const result3 = filterFirewallNodebalancers( + mockData, + { associated_entity_region: 'us-east', resource_id: '1' }, + [] + ); + const result4 = filterFirewallNodebalancers( + mockData, + { associated_entity_region: 'us-east', resource_id: '1' }, + undefined + ); + expect(result).toEqual(undefined); + expect(result2).toEqual(undefined); + expect(result3).toEqual(undefined); + expect(result4).toEqual(undefined); + }); + + it('should filter nodebalancers based on xFilter', () => { + const result = filterFirewallNodebalancers( + mockData, + { associated_entity_region: 'us-east', resource_id: '1' }, + mockFirewalls + ); + expect(result).toEqual([ + { + id: '1', + label: 'nodebalancer-1', + associated_entity_region: 'us-east', + }, + ]); + }); +}); + describe('filterBasedOnConfig', () => { const config: CloudPulseServiceTypeFilters = { configuration: { diff --git a/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.ts b/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.ts index f8d38a62740..6ec4a6480b5 100644 --- a/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.ts +++ b/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.ts @@ -8,6 +8,7 @@ import { } from './constants'; import { FILTER_CONFIG } from './FilterConfig'; import { CloudPulseAvailableViews, CloudPulseSelectTypes } from './models'; +import { getAssociatedEntityType } from './utils'; import type { CloudPulseMetricsFilter, @@ -16,6 +17,10 @@ import type { import type { CloudPulseCustomSelectProps } from '../shared/CloudPulseCustomSelect'; import type { CloudPulseEndpointsSelectProps } from '../shared/CloudPulseEndpointsSelect'; import type { CloudPulseEndpoints } from '../shared/CloudPulseEndpointsSelect'; +import type { + CloudPulseFirewallNodebalancersSelectProps, + CloudPulseNodebalancers, +} from '../shared/CloudPulseFirewallNodebalancersSelect'; import type { CloudPulseNodeTypeFilterProps } from '../shared/CloudPulseNodeTypeFilter'; import type { CloudPulseRegionSelectProps } from '../shared/CloudPulseRegionSelect'; import type { @@ -29,12 +34,14 @@ import type { import type { CloudPulseTextFilterProps } from '../shared/CloudPulseTextFilter'; import type { CloudPulseTimeRangeSelectProps } from '../shared/CloudPulseTimeRangeSelect'; import type { CloudPulseMetricsAdditionalFilters } from '../Widget/CloudPulseWidget'; +import type { MetricsDimensionFilter } from '../Widget/components/DimensionFilters/types'; import type { CloudPulseServiceTypeFilters } from './models'; import type { AclpConfig, Dashboard, DateTimeWithPreset, Filters, + NodeBalancer, TimeDuration, } from '@linode/api-v4'; @@ -182,6 +189,8 @@ export const getResourcesProperties = ( resourceType: dashboard.service_type, savePreferences: !isServiceAnalyticsIntegration, xFilter: filterBasedOnConfig(config, dependentFilters ?? {}), + associatedEntityType: getAssociatedEntityType(dashboard.id), + filterFn: config.configuration.filterFn, }; }; @@ -246,6 +255,7 @@ export const getCustomSelectProperties = ( options, placeholder, isOptional, + filterFn, } = props.config.configuration; const { dashboard, @@ -284,6 +294,7 @@ export const getCustomSelectProperties = ( type: options ? CloudPulseSelectTypes.static : CloudPulseSelectTypes.dynamic, + filterFn, }; }; @@ -403,6 +414,47 @@ export const getEndpointsProperties = ( }; }; +/** + * + * @param props The cloudpulse filter properties selected so far + * @param handleFirewallNodebalancersChange The callback function when selection of nodebalancers changes + * @returns CloudPulseFirewallNodebalancersSelectProps + */ +export const getFirewallNodebalancersProperties = ( + props: CloudPulseFilterProperties, + handleFirewallNodebalancersChange: ( + nodebalancers: CloudPulseNodebalancers[], + savePref?: boolean + ) => void +): CloudPulseFirewallNodebalancersSelectProps => { + const { filterKey, name: label, placeholder } = props.config.configuration; + const { + config, + dashboard, + dependentFilters, + isServiceAnalyticsIntegration, + preferences, + shouldDisable, + } = props; + return { + defaultValue: preferences?.[config.configuration.filterKey], + selectedDashboard: dashboard, + disabled: + shouldDisable || + shouldDisableFilterByFilterKey( + filterKey, + dependentFilters ?? {}, + dashboard, + preferences + ), + handleNodebalancersSelection: handleFirewallNodebalancersChange, + label, + placeholder, + savePreferences: !isServiceAnalyticsIntegration, + xFilter: filterBasedOnConfig(config, dependentFilters ?? {}), + isOptional: config.configuration.isOptional, + }; +}; /** * This function helps in builder the xFilter needed to passed in a apiV4 call * @@ -578,6 +630,27 @@ export const constructAdditionalRequestFilters = ( return filters; }; +/** + * @param dimensionFilters The selected dimension filters from the dimension filter component + * @returns The list of filters for the metric API call, based the additional custom select components + */ +export const constructWidgetDimensionFilters = ( + dimensionFilters: MetricsDimensionFilter[] +): Filters[] => { + const filters: Filters[] = []; + for (const { dimension_label, operator, value } of dimensionFilters) { + if (dimension_label && operator && value) { + // push to the filters + filters.push({ + dimension_label, + operator, + value, + }); + } + } + return filters; +}; + /** * * @param filterKey The filterKey of the actual filter @@ -743,3 +816,45 @@ export const filterEndpointsUsingRegion = ( return data.filter(({ region }) => region === regionFromFilter); }; + +/** + * + * @param data The nodebalancers for which the filter needs to be applied + * @param xFilter The selected filters that will be used to filter the nodebalancers + * @param firewalls The firewalls for which the filter needs to be applied + * @returns The filtered nodebalancers + */ + +export const filterFirewallNodebalancers = ( + data?: NodeBalancer[], + xFilter?: CloudPulseMetricsFilter, + firewalls?: CloudPulseResources[] +): CloudPulseNodebalancers[] | undefined => { + // If data is undefined or xFilter/firewalls is undefined or empty, return undefined + if (!data || !xFilter || !Object.keys(xFilter).length || !firewalls?.length) { + return undefined; + } + + // Map the nodebalancers to the CloudPulseNodebalancers interface + const nodebalancers: CloudPulseNodebalancers[] = data.map((nodebalancer) => ({ + id: String(nodebalancer.id), + label: nodebalancer.label, + associated_entity_region: nodebalancer.region, + })); + + const firewallObj = firewalls.find( + (firewall) => firewall.id === String(xFilter[RESOURCE_ID]) + ); + + return nodebalancers.filter((nodebalancer) => { + return Object.entries(xFilter).every(([key, filterValue]) => { + // If the filter key is the resource id, check if the nodebalancer is associated with the selected firewall + if (key === RESOURCE_ID) { + return firewallObj?.entities?.[nodebalancer.id]; + } + const nodebalancerValue = + nodebalancer[key as keyof CloudPulseNodebalancers]; + return nodebalancerValue === filterValue; + }); + }); +}; diff --git a/packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts b/packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts index 052c51eecbc..a68c9968303 100644 --- a/packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts +++ b/packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts @@ -1,15 +1,20 @@ import { capabilityServiceTypeMapping } from '@linode/api-v4'; +import { queryFactory } from 'src/queries/cloudpulse/queries'; + import { ENDPOINT, INTERFACE_IDS_PLACEHOLDER_TEXT, + NODEBALANCER_ID, PARENT_ENTITY_REGION, REGION, RESOURCE_ID, } from './constants'; import { CloudPulseAvailableViews, CloudPulseSelectTypes } from './models'; +import { filterFirewallResources } from './utils'; import type { CloudPulseServiceTypeFilterMap } from './models'; +import type { Firewall } from '@linode/api-v4'; const TIME_DURATION = 'Time Range'; @@ -233,6 +238,8 @@ export const FIREWALL_CONFIG: Readonly = { placeholder: 'Select Firewalls', priority: 1, associatedEntityType: 'linode', + filterFn: (resources: Firewall[]) => + filterFirewallResources(resources, 'linode'), }, name: 'Firewalls', }, @@ -316,6 +323,7 @@ export const FIREWALL_CONFIG: Readonly = { }, ], serviceType: 'firewall', + associatedEntityType: 'linode', }; export const FIREWALL_NODEBALANCER_CONFIG: Readonly = @@ -328,14 +336,16 @@ export const FIREWALL_NODEBALANCER_CONFIG: Readonly + filterFirewallResources(resources, 'nodebalancer'), }, - name: 'Firewalls', + name: 'Firewall', }, { configuration: { @@ -354,8 +364,28 @@ export const FIREWALL_NODEBALANCER_CONFIG: Readonly = diff --git a/packages/manager/src/features/CloudPulse/Utils/constants.ts b/packages/manager/src/features/CloudPulse/Utils/constants.ts index b219474e9f0..2095c2852be 100644 --- a/packages/manager/src/features/CloudPulse/Utils/constants.ts +++ b/packages/manager/src/features/CloudPulse/Utils/constants.ts @@ -16,6 +16,8 @@ export const PARENT_ENTITY_REGION = 'associated_entity_region'; export const RESOURCES = 'resources'; +export const NODEBALANCER_ID = 'nodebalancer_id'; + export const INTERVAL = 'interval'; export const TIME_DURATION = 'dateTimeDuration'; @@ -46,6 +48,8 @@ export const PORT = 'port'; export const INTERFACE_ID = 'interface_id'; +export const FIREWALL = 'Firewall'; + export const PORTS_HELPER_TEXT = 'Enter one or more port numbers (1-65535) separated by commas.'; diff --git a/packages/manager/src/features/CloudPulse/Utils/models.ts b/packages/manager/src/features/CloudPulse/Utils/models.ts index d7b830f8077..0386c8bd7a2 100644 --- a/packages/manager/src/features/CloudPulse/Utils/models.ts +++ b/packages/manager/src/features/CloudPulse/Utils/models.ts @@ -3,7 +3,13 @@ import type { Capabilities, CloudPulseServiceType, DatabaseEngine, + DatabaseInstance, DatabaseType, + Firewall, + Linode, + NodeBalancer, + ObjectStorageBucket, + Volume, } from '@linode/api-v4'; import type { QueryFunction, QueryKey } from '@tanstack/react-query'; @@ -11,6 +17,10 @@ import type { QueryFunction, QueryKey } from '@tanstack/react-query'; * The CloudPulseServiceTypeMap has list of filters to be built for different service types like dbaas, linode etc.,The properties here are readonly as it is only for reading and can't be modified in code */ export interface CloudPulseServiceTypeFilterMap { + /** + * The associated entity type for the service type + */ + readonly associatedEntityType?: AssociatedEntityType; /** * Current capability corresponding to a service type */ @@ -18,9 +28,7 @@ export interface CloudPulseServiceTypeFilterMap { /** * The list of filters for a service type */ - readonly filters: CloudPulseServiceTypeFilters[]; - /** * The service types like dbaas, linode etc., */ @@ -46,7 +54,15 @@ export interface CloudPulseServiceTypeFilters { /** * As of now, the list of possible custom filters are engine, database type, this union type will be expanded if we start enhancing our custom select config */ -export type QueryFunctionType = DatabaseEngine[] | DatabaseType[]; +export type QueryFunctionType = + | DatabaseEngine[] + | DatabaseInstance[] + | DatabaseType[] + | Firewall[] + | Linode[] + | NodeBalancer[] + | ObjectStorageBucket[] + | Volume[]; /** * The non array types of QueryFunctionType like DatabaseEngine|DatabaseType @@ -103,6 +119,16 @@ export interface CloudPulseServiceTypeFiltersConfiguration { */ dependency?: string[]; + /** + * If this filter is part of metric-definitions API, this field holds the dimension key + */ + dimensionKey?: string; + + /** + * This is an optional field, it is used to filter the resources + */ + filterFn?: (resources: QueryFunctionType) => QueryFunctionType; + /** * This is the field that will be sent in the metrics api call or xFilter */ diff --git a/packages/manager/src/features/CloudPulse/Utils/utils.test.ts b/packages/manager/src/features/CloudPulse/Utils/utils.test.ts index 8215a83b3fa..657235635bb 100644 --- a/packages/manager/src/features/CloudPulse/Utils/utils.test.ts +++ b/packages/manager/src/features/CloudPulse/Utils/utils.test.ts @@ -2,6 +2,10 @@ import { regionFactory } from '@linode/utilities'; import { describe, expect, it } from 'vitest'; import { serviceTypesFactory } from 'src/factories'; +import { + firewallEntityfactory, + firewallFactory, +} from 'src/factories/firewalls'; import { INTERFACE_ID, @@ -20,13 +24,20 @@ import { import { arePortsValid, areValidInterfaceIds, + filterFirewallResources, getAssociatedEntityType, getEnabledServiceTypes, + getFilteredDimensions, + getResourcesFilterConfig, + isValidFilter, isValidPort, useIsAclpSupportedRegion, validationFunction, } from './utils'; +import type { FetchOptions } from '../Alerts/CreateAlert/Criteria/DimensionFilterValue/constants'; +import type { MetricsDimensionFilter } from '../Widget/components/DimensionFilters/types'; +import type { Dimension } from '@linode/api-v4'; import type { AclpServices } from 'src/featureFlags'; describe('isValidPort', () => { @@ -340,17 +351,328 @@ describe('getEnabledServiceTypes', () => { expect(result).not.toContain('linode'); }); + describe('getResourcesFilterConfig', () => { + it('should return undefined if the dashboard id is not provided', () => { + expect(getResourcesFilterConfig(undefined)).toBeUndefined(); + }); + + it('should return the resources filter configuration for the linode-firewalldashboard', () => { + const resourcesFilterConfig = getResourcesFilterConfig(4); + expect(resourcesFilterConfig).toBeDefined(); + expect(resourcesFilterConfig?.associatedEntityType).toBe('linode'); + expect(resourcesFilterConfig?.filterFn).toBeDefined(); + }); + + it('should return the resources filter configuration for the nodebalancer-firewall dashboard', () => { + const resourcesFilterConfig = getResourcesFilterConfig(8); + expect(resourcesFilterConfig).toBeDefined(); + expect(resourcesFilterConfig?.associatedEntityType).toBe('nodebalancer'); + expect(resourcesFilterConfig?.filterFn).toBeDefined(); + }); + }); + describe('getAssociatedEntityType', () => { - it('should return both if the dashboard id is not provided', () => { - expect(getAssociatedEntityType(undefined)).toBe('both'); + it('should return undefined if the dashboard id is not provided', () => { + expect(getAssociatedEntityType(undefined)).toBeUndefined(); }); - it('should return the associated entity type for linode firewall dashboard', () => { + it('should return the associated entity type for the linode-firewall dashboard', () => { expect(getAssociatedEntityType(4)).toBe('linode'); }); - it('should return the associated entity type for nodebalancer firewall dashboard', () => { + it('should return the associated entity type for the nodebalancer-firewall dashboard', () => { expect(getAssociatedEntityType(8)).toBe('nodebalancer'); }); }); + + describe('filterFirewallResources', () => { + it('should return the filtered firewall resources for linode', () => { + const resources = [ + firewallFactory.build({ + entities: [ + firewallEntityfactory.build({ + id: 1, + label: 'linode-1', + type: 'linode', + }), + ], + }), + firewallFactory.build({ + entities: [ + firewallEntityfactory.build({ + id: 33, + label: null, + type: 'linode_interface', + parent_entity: { + id: 2, + label: 'linode-2', + type: 'linode', + }, + }), + ], + }), + firewallFactory.build({ + entities: [ + firewallEntityfactory.build({ + id: 3, + label: null, + type: 'linode', + }), + ], + }), + firewallFactory.build({ + entities: [ + firewallEntityfactory.build({ + id: 4, + label: null, + type: 'linode_interface', + parent_entity: { + id: 3, + label: null, + type: 'linode', + }, + }), + ], + }), + firewallFactory.build({ + entities: [ + firewallEntityfactory.build({ + id: 2, + label: 'nodebalancer-1', + type: 'nodebalancer', + }), + ], + }), + ]; + expect(filterFirewallResources(resources, 'linode')).toEqual([ + resources[0], + resources[1], + ]); + }); + }); +}); + +describe('isValidFilter', () => { + const valuedDim: Dimension = { + dimension_label: 'browser', + label: 'Browser', + values: ['chrome', 'firefox', 'safari'], + }; + + const staticDim: Dimension = { + dimension_label: 'browser', + label: 'Browser', + values: [], + }; + + it('returns false when operator is missing', () => { + const filter = { + dimension_label: 'browser', + operator: null, + value: 'chrome', + }; + expect(isValidFilter(filter, [valuedDim])).toBe(false); + }); + + it('returns false when the dimension_label is not present in options', () => { + const filter: MetricsDimensionFilter = { + dimension_label: 'os', + operator: 'eq', + value: 'linux', + }; + expect(isValidFilter(filter, [valuedDim])).toBe(false); + }); + + it('returns true for static dimensions (no values array) regardless of value', () => { + const filter: MetricsDimensionFilter = { + dimension_label: 'browser', + operator: 'eq', + value: 'chrome', + }; + expect(isValidFilter(filter, [staticDim])).toBe(true); + }); + + it('allows pattern operators ("endswith" / "startswith") even without validating values', () => { + const f1: MetricsDimensionFilter = { + dimension_label: 'browser', + operator: 'endswith', + value: 'fox', + }; + const f2: MetricsDimensionFilter = { + dimension_label: 'browser', + operator: 'startswith', + value: 'chr', + }; + expect(isValidFilter(f1, [valuedDim])).toBe(true); + expect(isValidFilter(f2, [valuedDim])).toBe(true); + }); + + it('returns true when multiple comma-separated values are all valid', () => { + const filter: MetricsDimensionFilter = { + dimension_label: 'browser', + operator: 'in', + value: 'chrome,firefox', + }; + expect(isValidFilter(filter, [valuedDim])).toBe(true); + }); + + it('returns false when value is empty string for a dimension that expects values', () => { + const filter: MetricsDimensionFilter = { + dimension_label: 'browser', + operator: 'eq', + value: '', + }; + expect(isValidFilter(filter, [valuedDim])).toBe(false); + }); +}); + +describe('getFilteredDimensions', () => { + it('returns [] when no dimensionFilters provided', () => { + const dimensions: Dimension[] = [ + { dimension_label: 'linode_id', values: [], label: 'Linode' }, + { dimension_label: 'vpc_subnet_id', values: [], label: 'Linode' }, + ]; + + const linodes: FetchOptions = { + values: [{ label: 'L1', value: 'lin-1' }], + isError: false, + isLoading: false, + }; + const vpcs: FetchOptions = { + values: [{ label: 'V1', value: 'vpc-1' }], + isError: false, + isLoading: false, + }; + + const result = getFilteredDimensions({ + dimensions, + linodes, + vpcs, + dimensionFilters: [], + }); + expect(result).toEqual([]); + }); + + it('merges linode and vpc values into metric dimensions and keeps valid filters', () => { + const dimensions: Dimension[] = [ + { dimension_label: 'linode_id', values: [], label: 'Linode' }, + { dimension_label: 'vpc_subnet_id', values: [], label: 'VPC subnet ID' }, + { + dimension_label: 'browser', + values: ['chrome', 'firefox'], + label: 'browser', + }, + ]; + + const linodes: FetchOptions = { + values: [{ label: 'L1', value: 'lin-1' }], + isError: false, + isLoading: false, + }; + const vpcs: FetchOptions = { + values: [{ label: 'V1', value: 'vpc-1' }], + isError: false, + isLoading: false, + }; + + const filters: MetricsDimensionFilter[] = [ + { dimension_label: 'linode_id', operator: 'eq', value: 'lin-1' }, + { dimension_label: 'vpc_subnet_id', operator: 'eq', value: 'vpc-1' }, + { dimension_label: 'browser', operator: 'in', value: 'chrome' }, + ]; + + const result = getFilteredDimensions({ + dimensions, + linodes, + vpcs, + dimensionFilters: filters, + }); + + // all three filters are valid against mergedDimensions + expect(result).toHaveLength(3); + expect(result).toEqual(expect.arrayContaining(filters)); + }); + + it('filters out invalid filters (values not present in merged dimension values)', () => { + const dimensions: Dimension[] = [ + { dimension_label: 'linode_id', values: [], label: 'Linode' }, + { + dimension_label: 'vpc_subnet_id', + values: [], + label: 'VPC subnet Id', + }, + { + dimension_label: 'browser', + values: ['chrome', 'firefox'], + label: 'Browser', + }, + ]; + + const linodes: FetchOptions = { + values: [{ label: 'L1', value: 'lin-1' }], + isError: false, + isLoading: false, + }; + const vpcs: FetchOptions = { + values: [{ label: 'V1', value: 'vpc-1' }], + isError: false, + isLoading: false, + }; + + const filters: MetricsDimensionFilter[] = [ + { dimension_label: 'linode_id', operator: 'eq', value: 'lin-1' }, + { dimension_label: 'vpc_subnet_id', operator: 'eq', value: 'vpc-1' }, + // invalid browser value -- should be removed + { dimension_label: 'browser', operator: 'in', value: 'edge' }, + ]; + + const result = getFilteredDimensions({ + dimensions, + linodes, + vpcs, + dimensionFilters: filters, + }); + + // only the two valid filters should remain + expect(result).toHaveLength(2); + expect(result).toEqual( + expect.arrayContaining([ + { dimension_label: 'linode_id', operator: 'eq', value: 'lin-1' }, + { dimension_label: 'vpc_subnet_id', operator: 'eq', value: 'vpc-1' }, + ]) + ); + // invalid 'browser' filter must be absent + expect(result).toEqual( + expect.not.arrayContaining([ + { dimension_label: 'browser', operator: 'in', value: 'edge' }, + ]) + ); + }); + + it('returns [] when dimensions is empty', () => { + const linodes: FetchOptions = { + values: [{ label: 'L1', value: 'lin-1' }], + isError: false, + isLoading: false, + }; + const vpcs: FetchOptions = { + values: [{ label: 'V1', value: 'vpc-1' }], + isError: false, + isLoading: false, + }; + + const filters: MetricsDimensionFilter[] = [ + { dimension_label: 'linode_id', operator: 'eq', value: 'lin-1' }, + ]; + + const result = getFilteredDimensions({ + dimensions: [], + linodes, + vpcs, + dimensionFilters: filters, + }); + + // with no metric definitions, mergedDimensions is undefined and filters should not pass validation + expect(result).toEqual([]); + }); }); diff --git a/packages/manager/src/features/CloudPulse/Utils/utils.ts b/packages/manager/src/features/CloudPulse/Utils/utils.ts index 7b6310212d9..c49c50500ae 100644 --- a/packages/manager/src/features/CloudPulse/Utils/utils.ts +++ b/packages/manager/src/features/CloudPulse/Utils/utils.ts @@ -5,6 +5,8 @@ import React from 'react'; import { convertData } from 'src/features/Longview/shared/formatters'; import { useFlags } from 'src/hooks/useFlags'; +import { valueFieldConfig } from '../Alerts/CreateAlert/Criteria/DimensionFilterValue/constants'; +import { getOperatorGroup } from '../Alerts/CreateAlert/Criteria/DimensionFilterValue/utils'; import { arraysEqual } from '../Alerts/Utils/utils'; import { INTERFACE_ID, @@ -23,6 +25,10 @@ import { } from './constants'; import { FILTER_CONFIG } from './FilterConfig'; +import type { FetchOptions } from '../Alerts/CreateAlert/Criteria/DimensionFilterValue/constants'; +import type { AssociatedEntityType } from '../shared/types'; +import type { MetricsDimensionFilter } from '../Widget/components/DimensionFilters/types'; +import type { CloudPulseServiceTypeFiltersConfiguration } from './models'; import type { Alert, APIError, @@ -30,6 +36,9 @@ import type { CloudPulseAlertsPayload, CloudPulseServiceType, Dashboard, + Dimension, + Firewall, + FirewallDeviceEntity, MonitoringCapabilities, ResourcePage, Service, @@ -58,6 +67,25 @@ interface AclpSupportedRegionProps { type: keyof MonitoringCapabilities; } +interface FilterProps { + /** + * The dimension filters to be validated + */ + dimensionFilters: MetricsDimensionFilter[] | undefined; + /** + * The dimension options associated with the metric + */ + dimensions: Dimension[]; + /** + * The fetch options for linodes + */ + linodes: FetchOptions; + /** + * The fetch options for vpcs + */ + vpcs: FetchOptions; +} + /** * * @returns an object that contains boolean property to check whether aclp is enabled or not @@ -396,19 +424,166 @@ export const useIsAclpSupportedRegion = ( return region?.monitors?.[type]?.includes(capability) ?? false; }; +/** + * Checks if the given value is a valid number according to the specified config. + * @param raw The value to validate + * @param config Optional configuration object with min and max properties + */ +const isValueAValidNumber = ( + value: string, + config: undefined | { max?: number; min?: number } +): boolean => { + const trimmed = value.trim(); + if (trimmed === '') return false; + // try to parse as finite number + const num = Number(trimmed); + if (!Number.isFinite(num)) return false; + + // If min/max are integers (or present) enforce range. + if (config?.min !== undefined && num < config.min) return false; + if (config?.max !== undefined && num > config.max) return false; + + // If min/max are integers and config min/max are integers, it likely expects integer inputs + // (e.g. ports, ids). We'll enforce integer if both min and max are integer values. + if ( + config && + Number.isInteger(config.min ?? 0) && + Number.isInteger(config.max ?? 0) + ) { + // If both min and max exist and are integers, require the input be integer. + // If only one exists and it's an integer, still reasonable to require integer. + if (!Number.isInteger(num)) return false; + } + + return true; +}; + +/** + * @param filter The filter associated with the metric + * @param options The dimension options associated with the metric + * @returns boolean + */ +export const isValidFilter = ( + filter: MetricsDimensionFilter, + options: Dimension[] +): boolean => { + if (!filter.operator || !filter.dimension_label || !filter.value) + return false; + + const operator = filter.operator; + const operatorGroup = getOperatorGroup(operator); + + if (!operatorGroup.includes(operator)) return false; + + const dimension = options.find( + ({ dimension_label: dimensionLabel }) => + dimensionLabel === filter.dimension_label + ); + if (!dimension) return false; + + const dimensionConfig = + valueFieldConfig[filter.dimension_label] ?? valueFieldConfig['*']; + + const dimensionFieldConfig = dimensionConfig[operatorGroup]; + + if ( + dimensionFieldConfig.type === 'textfield' && + dimensionFieldConfig.inputType === 'number' + ) { + return isValueAValidNumber( + String(filter.value ?? ''), + dimensionFieldConfig + ); + } else if ( + dimensionFieldConfig.type === 'textfield' || + !dimension.values || + !dimension.values.length + ) { + return true; + } + + const validValues = new Set(dimension.values); + return (filter.value ?? '') + .split(',') + .every((value) => validValues.has(value)); +}; + +/** + * @param linodes The list of linode according to the supported regions + * @param vpcs The list of vpcs according to the supported regions + * @param dimensionFilters The array of dimension filters selected + * @returns The filtered dimension filter based on the selections + */ +export const getFilteredDimensions = ( + filterProps: FilterProps +): MetricsDimensionFilter[] => { + const { dimensions, linodes, vpcs, dimensionFilters } = filterProps; + + const mergedDimensions = dimensions.map((dim) => + dim.dimension_label === 'linode_id' + ? { ...dim, values: linodes.values.map((lin) => lin.value) } + : dim.dimension_label === 'vpc_subnet_id' + ? { ...dim, values: vpcs.values.map((vpc) => vpc.value) } + : dim + ); + return dimensionFilters?.length + ? dimensionFilters.filter((filter) => + isValidFilter(filter, mergedDimensions ?? []) + ) + : []; +}; + /** * @param dashboardId The id of the dashboard - * @returns The associated entity type for the dashboard + * @returns The resources filter configuration for the dashboard */ -export const getAssociatedEntityType = (dashboardId: number | undefined) => { +export const getResourcesFilterConfig = ( + dashboardId: number | undefined +): CloudPulseServiceTypeFiltersConfiguration | undefined => { if (!dashboardId) { - return 'both'; + return undefined; } // Get the associated entity type for the dashboard const filterConfig = FILTER_CONFIG.get(dashboardId); - return ( - filterConfig?.filters.find( - (filter) => filter.configuration.filterKey === RESOURCE_ID - )?.configuration.associatedEntityType ?? 'both' + return filterConfig?.filters.find( + (filter) => filter.configuration.filterKey === RESOURCE_ID + )?.configuration; +}; + +/** + * @param dashboardId The id of the dashboard + * @returns The associated entity type for the dashboard + */ +export const getAssociatedEntityType = ( + dashboardId: number | undefined +): AssociatedEntityType | undefined => { + if (!dashboardId) { + return undefined; + } + return FILTER_CONFIG.get(dashboardId)?.associatedEntityType; +}; + +/** + * + * @param resources Firewall resources + * @param entityType Associated entity type + * @returns Filtered firewall resources based on the associated entity type + */ +export const filterFirewallResources = ( + resources: Firewall[], + entityType: AssociatedEntityType +) => { + return resources.filter((resource) => + resource.entities.some((entity: FirewallDeviceEntity) => { + // If the entity type is linode_interface, it should be associated with a linode and have a parent entity label + if ( + entity.type === 'linode_interface' && + entityType === 'linode' && + entity.parent_entity?.label + ) { + return true; + } + return entity.label && entity.type === entityType; + }) ); }; diff --git a/packages/manager/src/features/CloudPulse/Widget/components/DimensionFilters/schema.ts b/packages/manager/src/features/CloudPulse/Widget/components/DimensionFilters/schema.ts new file mode 100644 index 00000000000..c45788b7d7e --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Widget/components/DimensionFilters/schema.ts @@ -0,0 +1,36 @@ +import { array, lazy, object, string } from 'yup'; + +import { getDimensionFilterValueSchema } from 'src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/ValueSchemas'; + +const fieldErrorMessage = 'This field is required.'; + +/** + * Yup schema for validating a single dimension filter + */ +export const dimensionFiltersSchema = object({ + dimension_label: string() + .required(fieldErrorMessage) + .nullable() + .test('nonNull', fieldErrorMessage, (value) => value !== null), + operator: string() + .oneOf(['eq', 'neq', 'startswith', 'endswith', 'in']) + .required(fieldErrorMessage) + .nullable() + .test('nonNull', fieldErrorMessage, (value) => value !== null), + value: lazy((_, context) => { + const { dimension_label, operator } = context.parent; + return getDimensionFilterValueSchema({ + dimensionLabel: dimension_label, + operator, + }) + .defined() + .nullable(); + }), +}); + +/** + * Yup schema for validating the entire dimension filters form + */ +export const metricDimensionFiltersSchema = object({ + dimension_filters: array().of(dimensionFiltersSchema).required(), +}); diff --git a/packages/manager/src/features/CloudPulse/Widget/components/DimensionFilters/types.ts b/packages/manager/src/features/CloudPulse/Widget/components/DimensionFilters/types.ts new file mode 100644 index 00000000000..7b67c3bf426 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Widget/components/DimensionFilters/types.ts @@ -0,0 +1,30 @@ +export interface MetricsDimensionFilterForm { + /** + * A list of filters applied on different metric dimensions. + */ + dimension_filters: MetricsDimensionFilter[]; +} + +export interface MetricsDimensionFilter { + /** + * The label or name of the metric dimension to filter on. + */ + dimension_label: null | string; + + /** + * The comparison operator used for filtering. + */ + operator: MetricsDimensionFilterOperatorType | null; + + /** + * The value to compare against. + */ + value: null | string; +} + +export type MetricsDimensionFilterOperatorType = + | 'endswith' + | 'eq' + | 'in' + | 'neq' + | 'startswith'; diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseComponentRenderer.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseComponentRenderer.tsx index 75ab29be939..0a90f3801d4 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseComponentRenderer.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseComponentRenderer.tsx @@ -6,6 +6,7 @@ import NullComponent from 'src/components/NullComponent'; import { CloudPulseCustomSelect } from './CloudPulseCustomSelect'; import { CloudPulseDateTimeRangePicker } from './CloudPulseDateTimeRangePicker'; import { CloudPulseEndpointsSelect } from './CloudPulseEndpointsSelect'; +import { CloudPulseFirewallNodebalancersSelect } from './CloudPulseFirewallNodebalancersSelect'; import { CloudPulseNodeTypeFilter } from './CloudPulseNodeTypeFilter'; import { CloudPulseRegionSelect } from './CloudPulseRegionSelect'; import { CloudPulseResourcesSelect } from './CloudPulseResourcesSelect'; @@ -15,6 +16,7 @@ import { CloudPulseTextFilter } from './CloudPulseTextFilter'; import type { CloudPulseCustomSelectProps } from './CloudPulseCustomSelect'; import type { CloudPulseDateTimeRangePickerProps } from './CloudPulseDateTimeRangePicker'; import type { CloudPulseEndpointsSelectProps } from './CloudPulseEndpointsSelect'; +import type { CloudPulseFirewallNodebalancersSelectProps } from './CloudPulseFirewallNodebalancersSelect'; import type { CloudPulseNodeTypeFilterProps } from './CloudPulseNodeTypeFilter'; import type { CloudPulseRegionSelectProps } from './CloudPulseRegionSelect'; import type { CloudPulseResourcesSelectProps } from './CloudPulseResourcesSelect'; @@ -27,6 +29,7 @@ export interface CloudPulseComponentRendererProps { | CloudPulseCustomSelectProps | CloudPulseDateTimeRangePickerProps | CloudPulseEndpointsSelectProps + | CloudPulseFirewallNodebalancersSelectProps | CloudPulseNodeTypeFilterProps | CloudPulseRegionSelectProps | CloudPulseResourcesSelectProps @@ -41,6 +44,7 @@ const Components: { | CloudPulseCustomSelectProps | CloudPulseDateTimeRangePickerProps | CloudPulseEndpointsSelectProps + | CloudPulseFirewallNodebalancersSelectProps | CloudPulseNodeTypeFilterProps | CloudPulseRegionSelectProps | CloudPulseResourcesSelectProps @@ -59,6 +63,7 @@ const Components: { tags: CloudPulseTagsSelect, associated_entity_region: CloudPulseRegionSelect, endpoint: CloudPulseEndpointsSelect, + nodebalancer_id: CloudPulseFirewallNodebalancersSelect, }; const buildComponent = (props: CloudPulseComponentRendererProps) => { diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseCustomSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseCustomSelect.tsx index 8c7555c12d3..746ae8e9bf1 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseCustomSelect.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseCustomSelect.tsx @@ -12,6 +12,7 @@ import type { FilterValueType } from '../Dashboard/CloudPulseDashboardLanding'; import type { CloudPulseServiceTypeFiltersOptions, QueryFunctionAndKey, + QueryFunctionType, } from '../Utils/models'; import type { AclpConfig, FilterValue } from '@linode/api-v4'; @@ -55,6 +56,10 @@ export interface CloudPulseCustomSelectProps { */ errorText?: string; + /** + * The filter function to apply to the resources + */ + filterFn?: (resources: QueryFunctionType) => QueryFunctionType; /** * The filterKey that needs to be used */ @@ -144,6 +149,7 @@ export const CloudPulseCustomSelect = React.memo( savePreferences, type, isOptional, + filterFn, } = props; const [selectedResource, setResource] = React.useState< @@ -162,6 +168,7 @@ export const CloudPulseCustomSelect = React.memo( filter: {}, idField: apiResponseIdField ?? 'id', labelField: apiResponseLabelField ?? 'label', + filterFn, }); React.useEffect(() => { diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.test.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.test.tsx index 485e9de56e9..1c7a7fedf16 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.test.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.test.tsx @@ -127,11 +127,12 @@ describe('CloudPulseDashboardFilterBuilder component tests', () => { emitFilterChange={vi.fn()} handleToggleAppliedFilter={vi.fn()} isServiceAnalyticsIntegration={false} - resource_ids={[1, 2]} + resource_ids={[1]} /> ); - expect(getByPlaceholderText('Select Firewalls')).toBeVisible(); + expect(getByPlaceholderText('Select a Firewall')).toBeVisible(); expect(getByPlaceholderText('Select a NodeBalancer Region')).toBeVisible(); + expect(getByPlaceholderText('Select NodeBalancers')).toBeVisible(); }); }); diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.tsx index 1d5c22700bb..4b033145e94 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.tsx @@ -11,8 +11,10 @@ import RenderComponent from '../shared/CloudPulseComponentRenderer'; import { DASHBOARD_ID, ENDPOINT, + FIREWALL, INTERFACE_ID, NODE_TYPE, + NODEBALANCER_ID, PARENT_ENTITY_REGION, PORT, REGION, @@ -24,6 +26,7 @@ import { getCustomSelectProperties, getEndpointsProperties, getFilters, + getFirewallNodebalancersProperties, getNodeTypeProperties, getRegionProperties, getResourcesProperties, @@ -37,6 +40,7 @@ import type { CloudPulseMetricsFilter, FilterValueType, } from '../Dashboard/CloudPulseDashboardLanding'; +import type { CloudPulseNodebalancers } from './CloudPulseFirewallNodebalancersSelect'; import type { CloudPulseResources } from './CloudPulseResourcesSelect'; import type { CloudPulseTags } from './CloudPulseTagsFilter'; import type { AclpConfig, Dashboard } from '@linode/api-v4'; @@ -243,6 +247,7 @@ export const CloudPulseDashboardFilterBuilder = React.memo( } : { [filterKey]: region, + [NODEBALANCER_ID]: undefined, }; emitFilterChangeByFilterKey( filterKey, @@ -265,6 +270,23 @@ export const CloudPulseDashboardFilterBuilder = React.memo( [emitFilterChangeByFilterKey] ); + const handleFirewallNodebalancersChange = React.useCallback( + (nodebalancers: CloudPulseNodebalancers[], savePref: boolean = false) => { + emitFilterChangeByFilterKey( + NODEBALANCER_ID, + nodebalancers.map((nodebalancer) => nodebalancer.id), + nodebalancers.map((nodebalancer) => nodebalancer.label), + savePref, + { + [NODEBALANCER_ID]: nodebalancers.map( + (nodebalancer) => nodebalancer.id + ), + } + ); + }, + [emitFilterChangeByFilterKey] + ); + const handleCustomSelectChange = React.useCallback( ( filterKey: string, @@ -324,7 +346,10 @@ export const CloudPulseDashboardFilterBuilder = React.memo( }, handleRegionChange ); - } else if (config.configuration.filterKey === RESOURCE_ID) { + } else if ( + config.configuration.filterKey === RESOURCE_ID && + config.configuration.name !== FIREWALL + ) { return getResourcesProperties( { config, @@ -382,18 +407,33 @@ export const CloudPulseDashboardFilterBuilder = React.memo( }, handleEndpointsChange ); - } else { - return getCustomSelectProperties( + } else if (config.configuration.filterKey === NODEBALANCER_ID) { + return getFirewallNodebalancersProperties( { config, dashboard, dependentFilters: resource_ids?.length - ? { [RESOURCE_ID]: resource_ids } + ? { + ...dependentFilterReference.current, + [RESOURCE_ID]: resource_ids.map(String), + } : dependentFilterReference.current, isServiceAnalyticsIntegration, preferences, shouldDisable: isError || isLoading, }, + handleFirewallNodebalancersChange + ); + } else { + return getCustomSelectProperties( + { + config, + dashboard, + dependentFilters: dependentFilterReference.current, + isServiceAnalyticsIntegration, + preferences, + shouldDisable: isError || isLoading, + }, handleCustomSelectChange ); } @@ -407,6 +447,7 @@ export const CloudPulseDashboardFilterBuilder = React.memo( handleResourceChange, handleEndpointsChange, handleCustomSelectChange, + handleFirewallNodebalancersChange, isServiceAnalyticsIntegration, preferences, isError, @@ -443,7 +484,8 @@ export const CloudPulseDashboardFilterBuilder = React.memo( > {RenderComponent({ componentKey: - filter.configuration.type !== undefined + filter.configuration.type !== undefined || + filter.configuration.name === FIREWALL ? 'customSelect' : filter.configuration.filterKey, componentProps: { ...getProps(filter) }, diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseEndpointsSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseEndpointsSelect.tsx index 42cf343176e..a0578ea08b3 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseEndpointsSelect.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseEndpointsSelect.tsx @@ -6,6 +6,7 @@ import { useResourcesQuery } from 'src/queries/cloudpulse/resources'; import { RESOURCE_FILTER_MAP } from '../Utils/constants'; import { deepEqual, filterEndpointsUsingRegion } from '../Utils/FilterBuilder'; +import { CLOUD_PULSE_TEXT_FIELD_PROPS } from './styles'; import type { CloudPulseMetricsFilter, @@ -205,19 +206,7 @@ export const CloudPulseEndpointsSelect = React.memo( ); }} - textFieldProps={{ - InputProps: { - sx: { - '::-webkit-scrollbar': { - display: 'none', - }, - maxHeight: '55px', - msOverflowStyle: 'none', - overflow: 'auto', - scrollbarWidth: 'none', - }, - }, - }} + textFieldProps={{ ...CLOUD_PULSE_TEXT_FIELD_PROPS }} value={selectedEndpoints ?? []} /> ); diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseFirewallNodebalancersSelect.test.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseFirewallNodebalancersSelect.test.tsx new file mode 100644 index 00000000000..f76f36cc241 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseFirewallNodebalancersSelect.test.tsx @@ -0,0 +1,348 @@ +import { nodeBalancerFactory } from '@linode/utilities'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import * as React from 'react'; + +import { dashboardFactory } from 'src/factories'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { CloudPulseFirewallNodebalancersSelect } from './CloudPulseFirewallNodebalancersSelect'; + +import type { CloudPulseResources } from './CloudPulseResourcesSelect'; + +const queryMocks = vi.hoisted(() => ({ + useAllNodeBalancersQuery: vi.fn().mockReturnValue({}), + useResourcesQuery: vi.fn().mockReturnValue({}), +})); + +vi.mock('@linode/queries', async () => { + const actual = await vi.importActual('@linode/queries'); + return { + ...actual, + useAllNodeBalancersQuery: queryMocks.useAllNodeBalancersQuery, + }; +}); + +vi.mock('src/queries/cloudpulse/resources', async () => { + const actual = await vi.importActual('src/queries/cloudpulse/resources'); + return { + ...actual, + useResourcesQuery: queryMocks.useResourcesQuery, + }; +}); + +const mockNodebalancerHandler = vi.fn(); +const SELECT_ALL = 'Select All'; +const ARIA_SELECTED = 'aria-selected'; + +const mockNodebalancers = [ + nodeBalancerFactory.build({ + id: 1, + label: 'nodebalancer-1', + region: 'us-east', + }), + nodeBalancerFactory.build({ + id: 2, + label: 'nodebalancer-2', + region: 'us-west', + }), + nodeBalancerFactory.build({ + id: 3, + label: 'nodebalancer-3', + region: 'us-east', + }), +]; + +const mockFirewalls: CloudPulseResources[] = [ + { + id: '1', + label: 'firewall-1', + entities: { '1': 'nodebalancer-1', '3': 'nodebalancer-3' }, + }, + { + id: '2', + label: 'firewall-2', + entities: { '2': 'nodebalancer-2' }, + }, +]; + +const mockDashboard = dashboardFactory.build({ + service_type: 'firewall', + id: 8, +}); + +describe('CloudPulseFirewallNodebalancersSelect component tests', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders with the correct label and placeholder', () => { + queryMocks.useAllNodeBalancersQuery.mockReturnValue({ + data: mockNodebalancers, + isError: false, + isLoading: false, + }); + queryMocks.useResourcesQuery.mockReturnValue({ + data: mockFirewalls, + isError: false, + isLoading: false, + }); + renderWithTheme( + + ); + + expect(screen.getByLabelText('NodeBalancers (optional)')).toBeVisible(); + expect(screen.getByPlaceholderText('Select NodeBalancers')).toBeVisible(); + }); + + it('should render disabled component when disabled prop is true', () => { + queryMocks.useAllNodeBalancersQuery.mockReturnValue({ + data: mockNodebalancers, + isError: false, + isLoading: false, + }); + queryMocks.useResourcesQuery.mockReturnValue({ + data: mockFirewalls, + isError: false, + isLoading: false, + }); + renderWithTheme( + + ); + + expect(screen.getByTestId('textfield-input')).toBeDisabled(); + }); + + it('should render nodebalancers when data is available', async () => { + queryMocks.useAllNodeBalancersQuery.mockReturnValue({ + data: mockNodebalancers, + isError: false, + isLoading: false, + }); + queryMocks.useResourcesQuery.mockReturnValue({ + data: mockFirewalls, + isError: false, + isLoading: false, + }); + renderWithTheme( + + ); + + await userEvent.click(await screen.findByRole('button', { name: 'Open' })); + + // Should show nodebalancers that are associated with the selected firewall + expect( + await screen.findByRole('option', { + name: 'nodebalancer-1', + }) + ).toBeVisible(); + expect( + await screen.findByRole('option', { + name: 'nodebalancer-3', + }) + ).toBeVisible(); + + // Should not show nodebalancer-2 as it's not associated with firewall-1 + expect( + screen.queryByRole('option', { + name: 'nodebalancer-2', + }) + ).not.toBeInTheDocument(); + }); + + it('should be able to select and deselect the nodebalancers', async () => { + queryMocks.useAllNodeBalancersQuery.mockReturnValue({ + data: mockNodebalancers, + isError: false, + isLoading: false, + }); + queryMocks.useResourcesQuery.mockReturnValue({ + data: mockFirewalls, + isError: false, + isLoading: false, + }); + renderWithTheme( + + ); + + await userEvent.click(await screen.findByRole('button', { name: 'Open' })); + await userEvent.click( + await screen.findByRole('option', { name: SELECT_ALL }) + ); + + // Check that nodebalancers are selected + expect( + await screen.findByRole('option', { + name: 'nodebalancer-1', + }) + ).toHaveAttribute(ARIA_SELECTED, 'true'); + expect( + await screen.findByRole('option', { + name: 'nodebalancer-3', + }) + ).toHaveAttribute(ARIA_SELECTED, 'true'); + + // Close the autocomplete to trigger the handler call + await userEvent.click(await screen.findByRole('button', { name: 'Close' })); + + // Should call the handler with the selected nodebalancers + expect(mockNodebalancerHandler).toHaveBeenCalledWith( + [ + { + id: '1', + label: 'nodebalancer-1', + associated_entity_region: 'us-east', + }, + { + id: '3', + label: 'nodebalancer-3', + associated_entity_region: 'us-east', + }, + ], + true + ); + + await userEvent.click(await screen.findByRole('button', { name: 'Open' })); + await userEvent.click( + await screen.findByRole('option', { name: 'Deselect All' }) + ); + + // Check that nodebalancers are deselected + expect( + await screen.findByRole('option', { + name: 'nodebalancer-1', + }) + ).toHaveAttribute(ARIA_SELECTED, 'false'); + expect( + await screen.findByRole('option', { + name: 'nodebalancer-3', + }) + ).toHaveAttribute(ARIA_SELECTED, 'false'); + }); + + it('should show appropriate error message on nodebalancers call failure', async () => { + queryMocks.useAllNodeBalancersQuery.mockReturnValue({ + data: null, + isError: true, + isLoading: false, + }); + queryMocks.useResourcesQuery.mockReturnValue({ + data: mockFirewalls, + isError: false, + isLoading: false, + }); + renderWithTheme( + + ); + + // The error message should be visible immediately when isError is true + expect(screen.getByText('Failed to fetch NodeBalancers.')).toBeVisible(); + }); + + it('should filter nodebalancers based on xFilter and firewalls', async () => { + queryMocks.useAllNodeBalancersQuery.mockReturnValue({ + data: mockNodebalancers, + isError: false, + isLoading: false, + }); + queryMocks.useResourcesQuery.mockReturnValue({ + data: mockFirewalls, + isError: false, + isLoading: false, + }); + renderWithTheme( + + ); + + await userEvent.click(await screen.findByRole('button', { name: 'Open' })); + + // Should only show nodebalancers associated with firewall-1 in us-east + expect( + await screen.findByRole('option', { + name: 'nodebalancer-1', + }) + ).toBeVisible(); + expect( + await screen.findByRole('option', { + name: 'nodebalancer-3', + }) + ).toBeVisible(); + + // Should not show nodebalancer-2 (associated with firewall-2) + expect( + screen.queryByRole('option', { + name: 'nodebalancer-2', + }) + ).not.toBeInTheDocument(); + }); + + it('should handle default values correctly', async () => { + queryMocks.useAllNodeBalancersQuery.mockReturnValue({ + data: mockNodebalancers, + isError: false, + isLoading: false, + }); + queryMocks.useResourcesQuery.mockReturnValue({ + data: mockFirewalls, + isError: false, + isLoading: false, + }); + + const defaultValue = ['1', '3']; // IDs of nodebalancers to select by default + + renderWithTheme( + + ); + + await userEvent.click(await screen.findByRole('button', { name: 'Open' })); + + // Should show that nodebalancer-1 and nodebalancer-3 are selected by default + expect( + await screen.findByRole('option', { + name: 'nodebalancer-1', + }) + ).toHaveAttribute(ARIA_SELECTED, 'true'); + expect( + await screen.findByRole('option', { + name: 'nodebalancer-3', + }) + ).toHaveAttribute(ARIA_SELECTED, 'true'); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseFirewallNodebalancersSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseFirewallNodebalancersSelect.tsx new file mode 100644 index 00000000000..0833c8d4d92 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseFirewallNodebalancersSelect.tsx @@ -0,0 +1,248 @@ +import { useAllNodeBalancersQuery } from '@linode/queries'; +import { Autocomplete, SelectedIcon, StyledListItem } from '@linode/ui'; +import { Box } from '@mui/material'; +import React from 'react'; + +import { useResourcesQuery } from 'src/queries/cloudpulse/resources'; + +import { PARENT_ENTITY_REGION, RESOURCE_FILTER_MAP } from '../Utils/constants'; +import { deepEqual, filterFirewallNodebalancers } from '../Utils/FilterBuilder'; +import { getAssociatedEntityType } from '../Utils/utils'; +import { CLOUD_PULSE_TEXT_FIELD_PROPS } from './styles'; + +import type { CloudPulseMetricsFilter } from '../Dashboard/CloudPulseDashboardLanding'; +import type { Dashboard, FilterValue } from '@linode/api-v4'; + +export interface CloudPulseNodebalancers { + /** + * The region of the nodebalancer + */ + associated_entity_region: string; + /** + * The id of the nodebalancer + */ + id: string; + /** + * The label of the nodebalancer + */ + label: string; +} + +export interface CloudPulseFirewallNodebalancersSelectProps { + /** + * The default value of the nodebalancers filter + */ + defaultValue?: Partial; + /** + * Whether the nodebalancers filter is disabled + */ + disabled?: boolean; + /** + * The function to handle the nodebalancers selection + */ + handleNodebalancersSelection: ( + nodebalancers: CloudPulseNodebalancers[], + savePref?: boolean + ) => void; + /** + * Whether the nodebalancers filter is optional + */ + isOptional?: boolean; + /** + * The label of the nodebalancers filter + */ + label: string; + /** + * The placeholder of the nodebalancers filter + */ + placeholder?: string; + /** + * Whether to save the preferences + */ + savePreferences?: boolean; + /** + * The selected dashboard + */ + selectedDashboard: Dashboard; + /** + * The dependent filters of the nodebalancers + */ + xFilter?: CloudPulseMetricsFilter; +} + +export const CloudPulseFirewallNodebalancersSelect = React.memo( + (props: CloudPulseFirewallNodebalancersSelectProps) => { + const { + defaultValue, + disabled, + handleNodebalancersSelection, + label, + placeholder, + savePreferences, + xFilter, + isOptional, + selectedDashboard, + } = props; + + const serviceType = selectedDashboard.service_type; + const region = xFilter?.[PARENT_ENTITY_REGION]; + + // Get the associated entity type for the selected dashboard + const associatedEntityType = getAssociatedEntityType(selectedDashboard.id); + + const { data: firewalls } = useResourcesQuery( + disabled !== undefined ? !disabled : Boolean(region), + serviceType, + {}, + + RESOURCE_FILTER_MAP[serviceType] ?? {}, + associatedEntityType + ); + + const [selectedNodebalancers, setSelectedNodebalancers] = + React.useState(); + + /** + * This is used to track the open state of the autocomplete and useRef optimizes the re-renders that this component goes through and it is used for below + * When the autocomplete is already closed, we should publish the resources on clear action and deselect action as well since onclose will not be triggered at that time + * When the autocomplete is open, we should not publish any resources on clear action until the autocomplete is close + */ + const isAutocompleteOpen = React.useRef(false); // Ref to track the open state of Autocomplete + + const { + data: nodebalancers, + isError, + isLoading, + } = useAllNodeBalancersQuery( + true, + {}, + { + '+order': 'asc', + '+order_by': 'label', + } + ); + + // Get the list of nodebalancers that are associated with the selected firewall + const getNodebalancersList = React.useMemo< + CloudPulseNodebalancers[] + >(() => { + return ( + filterFirewallNodebalancers(nodebalancers, xFilter, firewalls) ?? [] + ); + }, [firewalls, nodebalancers, xFilter]); + + // Once the data is loaded, set the state variable with value stored in preferences + React.useEffect(() => { + if (disabled && !selectedNodebalancers) { + return; + } + // To save default values, go through side effects + if (!getNodebalancersList || !savePreferences || selectedNodebalancers) { + if (selectedNodebalancers) { + setSelectedNodebalancers([]); + handleNodebalancersSelection([]); + } + } else { + // Get the default nodebalancers from the nodebalancer ids stored in preferences + const defaultNodebalancers = + defaultValue && Array.isArray(defaultValue) + ? defaultValue.map((nodebalancer) => String(nodebalancer)) + : []; + const nodebalancers = getNodebalancersList.filter((nodebalancerObj) => + defaultNodebalancers.includes(nodebalancerObj.id) + ); + + handleNodebalancersSelection(nodebalancers); + setSelectedNodebalancers(nodebalancers); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [getNodebalancersList]); + + return ( + option.label === value.label} + label={label || 'NodeBalancers'} + limitTags={1} + loading={isLoading} + multiple + noMarginTop + onChange={(_e, nodebalancerSelections) => { + setSelectedNodebalancers(nodebalancerSelections); + + if (!isAutocompleteOpen.current) { + handleNodebalancersSelection( + nodebalancerSelections, + savePreferences + ); + } + }} + onClose={() => { + isAutocompleteOpen.current = false; + handleNodebalancersSelection( + selectedNodebalancers ?? [], + savePreferences + ); + }} + onOpen={() => { + isAutocompleteOpen.current = true; + }} + options={getNodebalancersList} + placeholder={ + selectedNodebalancers?.length + ? '' + : placeholder || 'Select NodeBalancers' + } + renderOption={(props, option) => { + const { key, ...rest } = props; + const isNodebalancerSelected = selectedNodebalancers?.some( + (item) => item.id === option.id + ); + + const isSelectAllORDeslectAllOption = + option.label === 'Select All ' || option.label === 'Deselect All '; + + const ListItem = isSelectAllORDeslectAllOption + ? StyledListItem + : 'li'; + + return ( + + <> + {option.label} + + + + ); + }} + textFieldProps={{ + ...CLOUD_PULSE_TEXT_FIELD_PROPS, + optional: isOptional, + }} + value={selectedNodebalancers ?? []} + /> + ); + }, + compareProps +); + +function compareProps( + prevProps: CloudPulseFirewallNodebalancersSelectProps, + nextProps: CloudPulseFirewallNodebalancersSelectProps +): boolean { + if (prevProps.selectedDashboard.id !== nextProps.selectedDashboard.id) { + return false; + } + if (!deepEqual(prevProps.xFilter, nextProps.xFilter)) { + return false; + } + + // Ignore function props in comparison + return true; +} diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx index 81ca38b099f..49f7f6ebbd5 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx @@ -16,6 +16,7 @@ import { import { deepEqual, filterUsingDependentFilters } from '../Utils/FilterBuilder'; import { FILTER_CONFIG } from '../Utils/FilterConfig'; import { getAssociatedEntityType } from '../Utils/utils'; +import { CLOUD_PULSE_TEXT_FIELD_PROPS } from './styles'; import type { Item } from '../Alerts/constants'; import type { CloudPulseMetricsFilter } from '../Dashboard/CloudPulseDashboardLanding'; @@ -210,6 +211,7 @@ export const CloudPulseRegionSelect = React.memo( }} placeholder={placeholder ?? 'Select a Region'} regions={supportedRegionsFromResources} + textFieldProps={{ ...CLOUD_PULSE_TEXT_FIELD_PROPS }} value={ supportedRegionsFromResources?.length ? (selectedRegion ?? null) diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.tsx index cbf097c063b..ed1950ee874 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.tsx @@ -7,8 +7,11 @@ import { useResourcesQuery } from 'src/queries/cloudpulse/resources'; import { RESOURCE_FILTER_MAP } from '../Utils/constants'; import { deepEqual, filterUsingDependentFilters } from '../Utils/FilterBuilder'; +import { CLOUD_PULSE_TEXT_FIELD_PROPS } from './styles'; import type { CloudPulseMetricsFilter } from '../Dashboard/CloudPulseDashboardLanding'; +import type { QueryFunctionType } from '../Utils/models'; +import type { AssociatedEntityType } from './types'; import type { CloudPulseServiceType, FilterValue } from '@linode/api-v4'; export interface CloudPulseResources { @@ -23,8 +26,16 @@ export interface CloudPulseResources { } export interface CloudPulseResourcesSelectProps { + /** + * The associated entity type for the dashboard + */ + associatedEntityType?: AssociatedEntityType; defaultValue?: Partial; disabled?: boolean; + /** + * The filter function to apply to the resources + */ + filterFn?: (resources: QueryFunctionType) => QueryFunctionType; handleResourcesSelection: ( resources: CloudPulseResources[], savePref?: boolean @@ -50,6 +61,8 @@ export const CloudPulseResourcesSelect = React.memo( resourceType, savePreferences, xFilter, + associatedEntityType, + filterFn, } = props; const flags = useFlags(); @@ -63,7 +76,9 @@ export const CloudPulseResourcesSelect = React.memo( resourceType, {}, - RESOURCE_FILTER_MAP[resourceType ?? ''] ?? {} + RESOURCE_FILTER_MAP[resourceType ?? ''] ?? {}, + associatedEntityType, // This is based on the filter configuration, used to keep associated entity id to label mapping for the supported entity type + filterFn ); const [selectedResources, setSelectedResources] = @@ -188,19 +203,7 @@ export const CloudPulseResourcesSelect = React.memo( ); }} - textFieldProps={{ - InputProps: { - sx: { - '::-webkit-scrollbar': { - display: 'none', - }, - maxHeight: '55px', - msOverflowStyle: 'none', - overflow: 'auto', - scrollbarWidth: 'none', - }, - }, - }} + textFieldProps={{ ...CLOUD_PULSE_TEXT_FIELD_PROPS }} value={selectedResources ?? []} /> ); diff --git a/packages/manager/src/features/CloudPulse/shared/styles.ts b/packages/manager/src/features/CloudPulse/shared/styles.ts new file mode 100644 index 00000000000..44512f5d0f1 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/shared/styles.ts @@ -0,0 +1,19 @@ +import type { TextFieldProps } from '@linode/ui'; + +/** + * Shared text field props for CloudPulse components + * Provides consistent scrollbar hiding and height constraints + */ +export const CLOUD_PULSE_TEXT_FIELD_PROPS: Partial = { + InputProps: { + sx: { + '::-webkit-scrollbar': { + display: 'none', + }, + maxHeight: '55px', + msOverflowStyle: 'none', + overflow: 'auto', + scrollbarWidth: 'none', + }, + }, +}; diff --git a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseClusterData.tsx b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseClusterData.tsx index ae6c3ff1b62..05ec1fe03eb 100644 --- a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseClusterData.tsx +++ b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseClusterData.tsx @@ -1,7 +1,10 @@ +import { useRegionsQuery } from '@linode/queries'; import { useIsGeckoEnabled } from '@linode/shared'; import { Divider, Typography } from '@linode/ui'; -import Grid from '@mui/material/Grid'; +import { getCapabilityFromPlanType } from '@linode/utilities'; +import Box from '@mui/material/Box'; import React from 'react'; +import { Controller, useFormContext } from 'react-hook-form'; import { RegionSelect } from 'src/components/RegionSelect/RegionSelect'; import { RegionHelperText } from 'src/components/SelectRegionPanel/RegionHelperText'; @@ -13,36 +16,26 @@ import { DatabaseEngineSelect } from 'src/features/Databases/DatabaseCreate/Data import { useFlags } from 'src/hooks/useFlags'; import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; -import type { - ClusterSize, - DatabaseEngine, - Engine, - PrivateNetwork, - Region, -} from '@linode/api-v4'; -import type { FormikErrors } from 'formik'; -export interface DatabaseCreateValues { - allow_list: { - address: string; - error: string; - }[]; - cluster_size: ClusterSize; - engine: Engine; - label: string; - private_network: PrivateNetwork; - region: string; - type: string; -} +import type { DatabaseCreateValues } from './DatabaseCreate'; +import type { PlanSelectionWithDatabaseType } from 'src/features/components/PlansPanel/types'; interface Props { - engines: DatabaseEngine[] | undefined; - errors: FormikErrors; - onChange: (filed: string, value: any) => void; - regionsData: Region[]; - values: DatabaseCreateValues; + selectedPlan?: PlanSelectionWithDatabaseType; } + +const labelToolTip = ( + + Label must: +
    +
  • Begin with an alpha character
  • +
  • Contain only alpha characters or single hyphens
  • +
  • Be between 3 - 32 characters
  • +
+
+); + export const DatabaseClusterData = (props: Props) => { - const { engines, errors, onChange, regionsData, values } = props; + const { selectedPlan } = props; const isRestricted = useRestrictedGlobalGrantCheck({ globalGrantType: 'add_databases', }); @@ -52,54 +45,88 @@ export const DatabaseClusterData = (props: Props) => { flags.gecko2?.la ); - const labelToolTip = ( - - Label must: -
    -
  • Begin with an alpha character
  • -
  • Contain only alpha characters or single hyphens
  • -
  • Be between 3 - 32 characters
  • -
-
- ); + const { data: regionsData } = useRegionsQuery(); + + const { control, setValue, reset, getValues } = + useFormContext(); + + const resetVPCConfiguration = () => { + reset({ + ...getValues(), + private_network: { + vpc_id: null, + subnet_id: null, + public_access: false, + }, + }); + }; + + const handleRegionChange = (value: string) => { + setValue('region', value); + + // When the selected region has changed, reset VPC configuration + resetVPCConfiguration(); + + // Validate plan selection + if (flags.databasePremium && selectedPlan) { + const newRegion = regionsData?.find((region) => region.id === value); + + const isPlanAvailableInRegion = Boolean( + newRegion?.capabilities.includes( + getCapabilityFromPlanType(selectedPlan.class) + ) + ); + // Clear plan selection if plan is not available in the selected region + if (!isPlanAvailableInRegion) { + setValue('type', ''); + } + } + }; return ( <> - + Name Your Cluster - onChange('label', e.target.value)} - tooltipText={labelToolTip} - value={values.label} + ( + + )} /> - +
- + Select Engine and Region - - - - onChange('region', region.id)} - regions={regionsData} - value={values.region} + + + + ( + handleRegionChange(region.id)} + regions={regionsData ?? []} + value={field.value ?? undefined} + /> + )} /> - + ); }; diff --git a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.tsx b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.tsx index 15080cb3e19..d9b3b0d8aa2 100644 --- a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.tsx +++ b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.tsx @@ -1,3 +1,4 @@ +import { yupResolver } from '@hookform/resolvers/yup'; import { useCreateDatabaseMutation, useDatabaseEnginesQuery, @@ -6,16 +7,12 @@ import { useRegionsQuery, } from '@linode/queries'; import { CircleProgress, Divider, ErrorState, Notice, Paper } from '@linode/ui'; -import { - formatStorageUnits, - getCapabilityFromPlanType, - scrollErrorIntoViewV2, -} from '@linode/utilities'; +import { formatStorageUnits, scrollErrorIntoViewV2 } from '@linode/utilities'; import { getDynamicDatabaseSchema } from '@linode/validation/lib/databases.schema'; import Grid from '@mui/material/Grid'; import { useNavigate } from '@tanstack/react-router'; -import { useFormik } from 'formik'; import * as React from 'react'; +import { Controller, FormProvider, useForm } from 'react-hook-form'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { ErrorMessage } from 'src/components/ErrorMessage'; @@ -32,14 +29,10 @@ import { import { DatabaseNodeSelector } from 'src/features/Databases/DatabaseCreate/DatabaseNodeSelector'; import { DatabaseSummarySection } from 'src/features/Databases/DatabaseCreate/DatabaseSummarySection'; import { DatabaseLogo } from 'src/features/Databases/DatabaseLanding/DatabaseLogo'; -import { enforceIPMasks } from 'src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.utils'; import { typeLabelDetails } from 'src/features/Linodes/presentation'; import { useFlags } from 'src/hooks/useFlags'; import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; -import { handleAPIErrors } from 'src/utilities/formikErrorUtils'; -import { validateIPs } from 'src/utilities/ipUtils'; -import { ACCESS_CONTROLS_IP_VALIDATION_ERROR_TEXT } from '../constants'; import { DatabaseCreateAccessControls } from './DatabaseCreateAccessControls'; import { DatabaseCreateNetworkingConfiguration } from './DatabaseCreateNetworkingConfiguration'; @@ -48,13 +41,23 @@ import type { ClusterSize, CreateDatabasePayload, Engine, + PrivateNetwork, VPC, } from '@linode/api-v4/lib/databases/types'; import type { APIError } from '@linode/api-v4/lib/types'; import type { PlanSelectionWithDatabaseType } from 'src/features/components/PlansPanel/types'; -import type { DatabaseCreateValues } from 'src/features/Databases/DatabaseCreate/DatabaseClusterData'; import type { ExtendedIP } from 'src/utilities/ipUtils'; +export interface DatabaseCreateValues { + allow_list: ExtendedIP[]; + cluster_size: ClusterSize; + engine: Engine; + label: string; + private_network?: PrivateNetwork; + region: string; + type: string; +} + export const DatabaseCreate = () => { const navigate = useNavigate(); const isRestricted = useRestrictedGlobalGrantCheck({ @@ -66,11 +69,8 @@ export const DatabaseCreate = () => { isLoading: regionsLoading, } = useRegionsQuery(); - const { - data: engines, - error: enginesError, - isLoading: enginesLoading, - } = useDatabaseEnginesQuery(true); + const { error: enginesError, isLoading: enginesLoading } = + useDatabaseEnginesQuery(true); const { data: dbtypes, @@ -86,89 +86,11 @@ export const DatabaseCreate = () => { const formRef = React.useRef(null); const { mutateAsync: createDatabase } = useCreateDatabaseMutation(); - const [createError, setCreateError] = React.useState(); const [ipErrorsFromAPI, setIPErrorsFromAPI] = React.useState(); const [selectedTab, setSelectedTab] = React.useState(0); const [selectedVPC, setSelectedVPC] = React.useState(null); const isVPCSelected = Boolean(selectedVPC); - const handleIPBlur = (ips: ExtendedIP[]) => { - const ipsWithMasks = enforceIPMasks(ips); - setFieldValue('allow_list', ipsWithMasks); - }; - - const handleIPValidation = () => { - const validatedIps = validateIPs(values.allow_list, { - allowEmptyAddress: true, - errorMessage: ACCESS_CONTROLS_IP_VALIDATION_ERROR_TEXT, - }); - - if (validatedIps.some((ip) => ip.error)) { - setFieldValue('allow_list', validatedIps); - } else { - setFieldValue( - 'allow_list', - validatedIps.map((ip) => { - delete ip.error; - return { - ...ip, - }; - }) - ); - } - }; - - const submitForm = async () => { - if (values.allow_list.some((ip) => ip.error)) { - return; - } - - setCreateError(undefined); - setSubmitting(true); - - const _allow_list = values.allow_list.reduce((accum, ip) => { - if (ip.address !== '') { - return [...accum, ip.address]; - } - return accum; - }, []); - const hasVpc = - values.private_network.vpc_id && values.private_network.subnet_id; - const privateNetwork = hasVpc ? values.private_network : null; - - const createPayload: CreateDatabasePayload = { - ...values, - allow_list: _allow_list, - private_network: privateNetwork, - }; - - // TODO (UIE-8831): Remove post VPC release, since it will always be in create payload - if (!isVPCEnabled) { - delete createPayload.private_network; - } - try { - const response = await createDatabase(createPayload); - navigate({ - to: `/databases/$engine/$databaseId`, - params: { - engine: response.engine, - databaseId: response.id, - }, - }); - } catch (errors) { - const ipErrors = errors.filter( - (error: APIError) => error.field === 'allow_list' - ); - if (ipErrors) { - setIPErrorsFromAPI(ipErrors); - } - const parentFields = ['private_network']; // List of parent fields that need the full key from the errors response - handleAPIErrors(errors, setFieldError, setCreateError, parentFields); - } - - setSubmitting(false); - }; - const initialValues: DatabaseCreateValues = { allow_list: [ { @@ -176,7 +98,7 @@ export const DatabaseCreate = () => { error: '', }, ], - cluster_size: -1 as ClusterSize, + cluster_size: 3, engine: 'mysql/8' as Engine, label: '', region: '', @@ -188,41 +110,36 @@ export const DatabaseCreate = () => { }, }; + const form = useForm({ + defaultValues: initialValues, + mode: 'onBlur', + // @ts-expect-error allow_list gets transformed to an array of strings in the onSubmit function + resolver: yupResolver(getDynamicDatabaseSchema(isVPCSelected)), + }); + const { - errors, + control, + formState: { isSubmitting, errors }, handleSubmit, - isSubmitting, - resetForm, - setFieldError, - setFieldValue, - setSubmitting, - values, - } = useFormik({ - initialValues, - onSubmit: submitForm, - validate: () => { - handleIPValidation(); - scrollErrorIntoViewV2(formRef); - }, - validateOnChange: false, - validationSchema: getDynamicDatabaseSchema(isVPCSelected), - }); // TODO (UIE-8903): Replace deprecated Formik with React Hook Form + setError, + setValue, + watch, + } = form; + + const [allowList, clusterSize, region, type, engine] = watch([ + 'allow_list', + 'cluster_size', + 'region', + 'type', + 'engine', + ]); const { data: regionAvailabilities } = useRegionAvailabilityQuery( - values.region || '', - Boolean(flags.soldOutChips) && Boolean(values.region) + region || '', + Boolean(flags.soldOutChips) && Boolean(region) ); - React.useEffect(() => { - if (setFieldValue) { - setFieldValue( - 'cluster_size', - values.cluster_size < 1 ? 3 : values.cluster_size - ); - } - }, [setFieldValue, values.cluster_size, values.engine]); - - const selectedEngine = values.engine.split('/')[0] as Engine; + const selectedEngine = engine.split('/')[0] as Engine; const displayTypes: PlanSelectionWithDatabaseType[] = React.useMemo(() => { if (!dbtypes) { @@ -253,27 +170,24 @@ export const DatabaseCreate = () => { }, [dbtypes, selectedEngine]); const selectedPlan = React.useMemo(() => { - return displayTypes?.find((type) => type.id === values.type); - }, [displayTypes, values.type]); + return displayTypes?.find((displayType) => displayType.id === type); + }, [displayTypes, type]); if (flags.databasePremium && selectedPlan) { const isLimitedAvailability = getIsLimitedAvailability({ plan: selectedPlan, regionAvailabilities, - selectedRegionId: values.region, + selectedRegionId: region, }); if (isLimitedAvailability) { - setFieldValue('type', ''); + setValue('type', ''); } } const accessControlsConfiguration: AccessProps = { disabled: isRestricted, errors: ipErrorsFromAPI, - ips: values.allow_list, - onBlur: handleIPBlur, - onChange: (ips: ExtendedIP[]) => setFieldValue('allow_list', ips), variant: isVPCEnabled ? 'networking' : 'standard', }; @@ -283,59 +197,69 @@ export const DatabaseCreate = () => { return; } setSelectedTab(index); - setFieldValue('type', undefined); - setFieldValue('cluster_size', 3); + setValue('type', ''); + setValue('cluster_size', 3); }; - if (regionsLoading || !regionsData || enginesLoading || typesLoading) { - return ; - } + const onSubmit = async (values: DatabaseCreateValues) => { + if (allowList.some((ip) => ip.error)) { + return; + } - if (regionsError || typesError || enginesError) { - return ; - } + const _allowList = allowList.reduce((accum, ip) => { + if (ip.address !== '') { + return [...accum, ip.address]; + } + return accum; + }, []); - const handleNodeChange = (size: ClusterSize | undefined) => { - setFieldValue('cluster_size', size); - }; + const hasVpc = + values.private_network && + values.private_network.vpc_id && + values.private_network.subnet_id; - const handleNetworkingConfigurationChange = (vpc: null | VPC) => { - setSelectedVPC(vpc); - }; + const createPayload: CreateDatabasePayload = { + ...values, + allow_list: _allowList, + private_network: hasVpc ? values.private_network : null, + }; - const handleResetForm = (partialValues?: Partial) => { - if (partialValues) { - resetForm({ - values: { - ...values, - ...partialValues, - }, - }); - } else { - resetForm(); + // TODO (UIE-8831): Remove post VPC release, since it will always be in create payload + if (!isVPCEnabled) { + setValue('private_network', undefined); } - }; - // Custom region change handler that validates plan selection - const handleRegionChange = (field: string, value: any) => { - setFieldValue(field, value); - - // If this is a region change and a plan is selected - if (field === 'region' && flags.databasePremium && selectedPlan) { - const newRegion = regionsData?.find((region) => region.id === value); - - const isPlanAvailableInRegion = Boolean( - newRegion?.capabilities.includes( - getCapabilityFromPlanType(selectedPlan.class) - ) + try { + const response = await createDatabase(createPayload); + navigate({ + to: `/databases/$engine/$databaseId`, + params: { + engine: response.engine, + databaseId: response.id, + }, + }); + } catch (errors) { + const ipErrors = errors.filter( + (error: APIError) => error.field === 'allow_list' ); - // Clear the plan selection if plan is not available in the newly selected region - if (!isPlanAvailableInRegion) { - setFieldValue('type', ''); + if (ipErrors) { + setIPErrorsFromAPI(ipErrors); + } + + for (const error of errors) { + setError(error?.field ?? 'root', { message: error.reason }); } } }; + if (regionsLoading || !regionsData || enginesLoading || typesLoading) { + return ; + } + + if (regionsError || typesError || enginesError) { + return ; + } + return ( <> @@ -351,111 +275,112 @@ export const DatabaseCreate = () => { }} title="Create" /> -
- {isRestricted && ( - - )} - - {createError && ( - - - + + + scrollErrorIntoViewV2(formRef) )} - - - - { - setFieldValue('type', selected); - }} - regionsData={regionsData} - selectedId={values.type} - selectedRegionID={values.region} - types={displayTypes} + ref={formRef} + > + {isRestricted && ( + - - - - { - handleNodeChange(v); - }} - selectedClusterSize={values.cluster_size} - selectedEngine={selectedEngine} - selectedPlan={selectedPlan} - selectedTab={selectedTab} - /> - - - {isVPCEnabled ? ( - - setFieldValue(field, value) - } - onNetworkingConfigurationChange={ - handleNetworkingConfigurationChange - } - privateNetworkValues={values.private_network} - resetFormFields={handleResetForm} - selectedRegionId={values.region} - /> - ) : ( - )} - - - - - - - Your database node(s) will take approximately 15-30 minutes to - provision. - - - Create Database Cluster - - - - + + {errors.root?.message && ( + + + + )} + + + + ( + + )} + /> + + + + ( + + )} + /> + + + {isVPCEnabled ? ( + + ) : ( + + )} + + + + + + + Your database node(s) will take approximately 15-30 minutes to + provision. + + + Create Database Cluster + + + + + ); }; diff --git a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreateAccessControls.test.tsx b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreateAccessControls.test.tsx index 44c34632f96..7a2c9c6ba52 100644 --- a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreateAccessControls.test.tsx +++ b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreateAccessControls.test.tsx @@ -1,12 +1,13 @@ import { fireEvent } from '@testing-library/react'; import React from 'react'; -import { renderWithTheme } from 'src/utilities/testHelpers'; +import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; import { useIsDatabasesEnabled } from '../utilities'; import { DatabaseCreateAccessControls } from './DatabaseCreateAccessControls'; import type { IsDatabasesEnabled } from '../utilities'; +import type { DatabaseCreateValues } from './DatabaseCreate'; vi.mock('src/features/Databases/utilities'); @@ -21,13 +22,11 @@ describe('DatabaseCreateAccessControls', () => { } as IsDatabasesEnabled); const ips = [{ address: '' }]; - const { container, getAllByText, getAllByTestId } = renderWithTheme( - {}} - onChange={() => {}} - /> - ); + const { container, getAllByText, getAllByTestId } = + renderWithThemeAndHookFormContext({ + component: , + useFormOptions: { defaultValues: { allow_list: ips } }, + }); expect(getAllByText('Manage Access')).toHaveLength(1); expect(getAllByTestId('domain-transfer-input')).toHaveLength(1); @@ -56,13 +55,11 @@ describe('DatabaseCreateAccessControls', () => { { address: '2.2.2.2' }, { address: '3.3.3.3/128' }, ]; - const { container, getAllByText, getAllByTestId } = renderWithTheme( - {}} - onChange={() => {}} - /> - ); + const { container, getAllByText, getAllByTestId } = + renderWithThemeAndHookFormContext({ + component: , + useFormOptions: { defaultValues: { allow_list: ips } }, + }); expect(getAllByText('Manage Access')).toHaveLength(1); expect(getAllByTestId('domain-transfer-input')).toHaveLength(3); @@ -87,13 +84,11 @@ describe('DatabaseCreateAccessControls', () => { } as IsDatabasesEnabled); const ips = [{ address: '1.1.1.1/32' }]; - const { container, getAllByText, getAllByTestId } = renderWithTheme( - {}} - onChange={() => {}} - /> - ); + const { container, getAllByText, getAllByTestId } = + renderWithThemeAndHookFormContext({ + component: , + useFormOptions: { defaultValues: { allow_list: ips } }, + }); expect(getAllByText('Manage Access')).toHaveLength(1); expect(getAllByTestId('domain-transfer-input')).toHaveLength(1); diff --git a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreateAccessControls.tsx b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreateAccessControls.tsx index da30ca59413..e2dfa3b587f 100644 --- a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreateAccessControls.tsx +++ b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreateAccessControls.tsx @@ -3,75 +3,66 @@ import { Notice, Radio, RadioGroup, + styled, Typography, } from '@linode/ui'; -import Grid from '@mui/material/Grid'; +import Box from '@mui/material/Box'; import { useState } from 'react'; import * as React from 'react'; import type { ChangeEvent } from 'react'; -import { makeStyles } from 'tss-react/mui'; +import { Controller, useFormContext, useWatch } from 'react-hook-form'; import { Link } from 'src/components/Link'; import { MultipleIPInput } from 'src/components/MultipleIPInput/MultipleIPInput'; -import { ipV6FieldPlaceholder } from 'src/utilities/ipUtils'; +import { enforceIPMasks } from 'src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.utils'; +import { ipV6FieldPlaceholder, validateIPs } from 'src/utilities/ipUtils'; +import { ACCESS_CONTROLS_IP_VALIDATION_ERROR_TEXT } from '../constants'; + +import type { DatabaseCreateValues } from './DatabaseCreate'; import type { APIError } from '@linode/api-v4/lib/types'; -import type { Theme } from '@mui/material/styles'; import type { ExtendedIP } from 'src/utilities/ipUtils'; -const useStyles = makeStyles()((theme: Theme) => ({ - container: { - marginTop: theme.spacing(3), - maxWidth: 450, - }, - header: { - marginBottom: theme.spacing(0.5), - }, - multipleIPInput: { - marginLeft: theme.spacing(4), - }, - subHeader: { - marginTop: theme.spacing(2), - }, -})); - export type AccessOption = 'none' | 'specific'; export type AccessVariant = 'networking' | 'standard'; export interface AccessProps { disabled?: boolean; errors?: APIError[]; - ips: ExtendedIP[]; - onBlur: (ips: ExtendedIP[]) => void; - onChange: (ips: ExtendedIP[]) => void; variant?: AccessVariant; } export const DatabaseCreateAccessControls = (props: AccessProps) => { - const { - disabled = false, - errors, - ips, - onBlur, - onChange, - variant = 'standard', - } = props; - const { classes } = useStyles(); + const { disabled = false, errors, variant = 'standard' } = props; const [accessOption, setAccessOption] = useState('specific'); - const handleAccessOptionChange = (_: ChangeEvent, value: AccessOption) => { - setAccessOption(value); - if (value === 'none') { - onChange([{ address: '', error: '' }]); + const handleIPValidation = (ips: ExtendedIP[]) => { + const validatedIps = validateIPs(ips, { + allowEmptyAddress: true, + errorMessage: ACCESS_CONTROLS_IP_VALIDATION_ERROR_TEXT, + }); + const validatedIpsWithMasks = enforceIPMasks(validatedIps); + if (validatedIpsWithMasks.some((ip) => ip.error)) { + setValue('allow_list', validatedIpsWithMasks); + } else { + setValue( + 'allow_list', + validatedIpsWithMasks.map((ip) => { + delete ip.error; + return { + ...ip, + }; + }) + ); } }; + const { control, setValue } = useFormContext(); + const ips = useWatch({ control, name: 'allow_list' }); + return ( - - + + Manage Access @@ -82,12 +73,11 @@ export const DatabaseCreateAccessControls = (props: AccessProps) => { . - + (Note: You can modify access controls after your database cluster is active.) - - + {errors && errors.map((apiError: APIError) => ( { variant="error" /> ))} - - } - data-qa-dbaas-radio="Specific" - disabled={disabled} - label="Specific Access (recommended)" - value="specific" - /> - 1 ? 'Add Another IP' : 'Add an IP'} - className={classes.multipleIPInput} - disabled={accessOption === 'none' || disabled} - ips={ips} - onBlur={onBlur} - onChange={onChange} - placeholder={ipV6FieldPlaceholder} - title="Allowed IP Addresses or Ranges" - /> - } - data-qa-dbaas-radio="None" - disabled={disabled} - label="No Access (Deny connections from all IP addresses)" - value="none" - /> - - - + ( + { + setAccessOption(value); + if (value === 'none') { + field.onChange([{ address: '', error: '' }]); + } + }} + value={accessOption} + > + } + data-qa-dbaas-radio="Specific" + disabled={disabled} + label="Specific Access (recommended)" + value="specific" + /> + 1 ? 'Add Another IP' : 'Add an IP'} + disabled={accessOption === 'none' || disabled} + ips={ips} + onBlur={() => handleIPValidation(ips)} + onChange={field.onChange} + placeholder={ipV6FieldPlaceholder} + title="Allowed IP Addresses or Ranges" + /> + } + data-qa-dbaas-radio="None" + disabled={disabled} + label="No Access (Deny connections from all IP addresses)" + value="none" + /> + + )} + /> + + ); }; + +const StyledMultipleIPInput = styled(MultipleIPInput, { + label: 'StyledMultipleIPInput', +})(({ theme }) => ({ + marginLeft: theme.spacingFunction(32), +})); diff --git a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreateNetworkingConfiguration.test.tsx b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreateNetworkingConfiguration.test.tsx index d3ab82b5cbb..1f0a42b1516 100644 --- a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreateNetworkingConfiguration.test.tsx +++ b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreateNetworkingConfiguration.test.tsx @@ -3,10 +3,11 @@ import { screen } from '@testing-library/react'; import * as React from 'react'; import { describe, it, vi } from 'vitest'; -import { renderWithTheme } from 'src/utilities/testHelpers'; +import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; import { DatabaseCreateNetworkingConfiguration } from './DatabaseCreateNetworkingConfiguration'; +import type { DatabaseCreateValues } from './DatabaseCreate'; import type { AccessProps } from './DatabaseCreateAccessControls'; import type { PrivateNetwork } from '@linode/api-v4'; @@ -35,9 +36,6 @@ describe('DatabaseCreateNetworkingConfiguration', () => { const mockAccessControlConfig: AccessProps = { disabled: false, errors: [], - ips: [], - onBlur: vi.fn(), - onChange: vi.fn(), variant: 'networking', }; @@ -57,6 +55,8 @@ describe('DatabaseCreateNetworkingConfiguration', () => { selectedRegionId: 'us-east', }; + const ips = [{ address: '' }]; + beforeEach(() => { vi.resetAllMocks(); queryMocks.useRegionQuery.mockReturnValue({ data: mockRegion }); @@ -67,7 +67,10 @@ describe('DatabaseCreateNetworkingConfiguration', () => { }); it('renders the networking configuration heading and description', () => { - renderWithTheme(); + renderWithThemeAndHookFormContext({ + component: , + useFormOptions: { defaultValues: { allow_list: ips } }, + }); const ConfigureNetworkingLabel = screen.getByText('Configure Networking', { exact: true, }); @@ -82,7 +85,10 @@ describe('DatabaseCreateNetworkingConfiguration', () => { }); it('renders DatabaseCreateAccessControls and DatabaseVPCSelector', () => { - renderWithTheme(); + renderWithThemeAndHookFormContext({ + component: , + useFormOptions: { defaultValues: { allow_list: ips } }, + }); const vpcSelector = screen.getByTestId('database-vpc-selector'); expect(vpcSelector).toBeInTheDocument(); const manageAccessLabel = screen.getByText('Manage Access'); diff --git a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreateNetworkingConfiguration.tsx b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreateNetworkingConfiguration.tsx index fd6542314e1..30d03f0a799 100644 --- a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreateNetworkingConfiguration.tsx +++ b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreateNetworkingConfiguration.tsx @@ -4,34 +4,19 @@ import * as React from 'react'; import { DatabaseCreateAccessControls } from './DatabaseCreateAccessControls'; import { DatabaseVPCSelector } from './DatabaseVPCSelector'; -import type { DatabaseCreateValues } from './DatabaseClusterData'; import type { AccessProps } from './DatabaseCreateAccessControls'; -import type { PrivateNetwork, VPC } from '@linode/api-v4'; +import type { VPC } from '@linode/api-v4'; import type { Theme } from '@mui/material/styles'; -import type { FormikErrors } from 'formik'; interface NetworkingConfigurationProps { accessControlsConfiguration: AccessProps; - errors: FormikErrors; - onChange: (field: string, value: boolean | null | number) => void; - onNetworkingConfigurationChange: (vpcSelected: null | VPC) => void; - privateNetworkValues: PrivateNetwork; - resetFormFields: (partialValues?: Partial) => void; - selectedRegionId: string; + onChange: (selectedVPC: null | VPC) => void; } export const DatabaseCreateNetworkingConfiguration = ( props: NetworkingConfigurationProps ) => { - const { - accessControlsConfiguration, - errors, - onNetworkingConfigurationChange, - onChange, - selectedRegionId, - resetFormFields, - privateNetworkValues, - } = props; + const { accessControlsConfiguration, onChange } = props; return ( <> @@ -45,15 +30,7 @@ export const DatabaseCreateNetworkingConfiguration = ( - + ); }; diff --git a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseEngineSelect.tsx b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseEngineSelect.tsx index 4385caa656d..4136955e12d 100644 --- a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseEngineSelect.tsx +++ b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseEngineSelect.tsx @@ -1,21 +1,16 @@ +import { useDatabaseEnginesQuery } from '@linode/queries'; import { Autocomplete, Box, InputAdornment } from '@linode/ui'; import Grid from '@mui/material/Grid'; import React from 'react'; +import { Controller, useFormContext, useWatch } from 'react-hook-form'; import { getEngineOptions } from 'src/features/Databases/DatabaseCreate/utilities'; import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; -import type { DatabaseEngine } from '@linode/api-v4'; +import type { DatabaseCreateValues } from './DatabaseCreate'; -interface Props { - engines: DatabaseEngine[] | undefined; - errorText: string | undefined; - onChange: (filed: string, value: any) => void; - value: string; -} - -export const DatabaseEngineSelect = (props: Props) => { - const { engines, errorText, onChange, value } = props; +export const DatabaseEngineSelect = () => { + const { data: engines } = useDatabaseEnginesQuery(true); const isRestricted = useRestrictedGlobalGrantCheck({ globalGrantType: 'add_databases', }); @@ -27,65 +22,73 @@ export const DatabaseEngineSelect = (props: Props) => { return getEngineOptions(engines); }, [engines]); + const { control } = useFormContext(); + + const engineValue = useWatch({ control, name: 'engine' }); const selectedEngine = React.useMemo(() => { - return engineOptions.find((val) => val.value === value); - }, [value, engineOptions]); + return engineOptions.find((val) => val.value === engineValue); + }, [engineValue, engineOptions]); return ( - { - if (option.engine.match(/mysql/i)) { - return 'MySQL'; - } - if (option.engine.match(/postgresql/i)) { - return 'PostgreSQL'; - } - return 'Other'; - }} - isOptionEqualToValue={(option, value) => option.value === value.value} - label="Database Engine" - onChange={(_, selected) => { - onChange('engine', selected.value); - }} - options={engineOptions ?? []} - placeholder="Select a Database Engine" - renderOption={(props, option) => { - const { key, ...rest } = props; - return ( -
  • - - - {option.flag} - - {option.label} - -
  • - ); - }} - textFieldProps={{ - InputProps: { - startAdornment: ( - - - {selectedEngine?.flag} - - - ), - }, - }} - value={selectedEngine} + ( + { + if (option.engine.match(/mysql/i)) { + return 'MySQL'; + } + if (option.engine.match(/postgresql/i)) { + return 'PostgreSQL'; + } + return 'Other'; + }} + label="Database Engine" + onChange={(_, selected) => { + field.onChange(selected.value); + }} + options={engineOptions ?? []} + placeholder="Select a Database Engine" + renderOption={(props, option) => { + const { key, ...rest } = props; + return ( +
  • + + + {option.flag} + + {option.label} + +
  • + ); + }} + textFieldProps={{ + InputProps: { + startAdornment: ( + + + {selectedEngine?.flag} + + + ), + }, + }} + value={selectedEngine} + /> + )} /> ); }; diff --git a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseVPCSelector.test.tsx b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseVPCSelector.test.tsx index 731d6d3d76e..1ca6bade784 100644 --- a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseVPCSelector.test.tsx +++ b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseVPCSelector.test.tsx @@ -5,10 +5,9 @@ import * as React from 'react'; import { describe, expect, it, vi } from 'vitest'; import { subnetFactory, vpcFactory } from 'src/factories'; +import { DatabaseVPCSelector } from 'src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseVPCSelector'; import { renderWithTheme } from 'src/utilities/testHelpers'; -import { DatabaseVPCSelector } from './DatabaseVPCSelector'; - import type { PrivateNetwork } from '@linode/api-v4'; // Hoist query mocks diff --git a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseVPCSelector.tsx b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseVPCSelector.tsx index 0df9bd13875..843d55aefdf 100644 --- a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseVPCSelector.tsx +++ b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseVPCSelector.tsx @@ -6,10 +6,10 @@ import { Checkbox, FormHelperText, Notice, - TooltipIcon, Typography, } from '@linode/ui'; import * as React from 'react'; +import { Controller, useFormContext, useWatch } from 'react-hook-form'; import { Link } from 'src/components/Link'; import { useFlags } from 'src/hooks/useFlags'; @@ -17,35 +17,25 @@ import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { MANAGE_NETWORKING_LEARN_MORE_LINK } from '../constants'; -import type { DatabaseCreateValues } from './DatabaseClusterData'; -import type { PrivateNetwork, VPC } from '@linode/api-v4'; +import type { DatabaseCreateValues } from './DatabaseCreate'; +import type { VPC } from '@linode/api-v4'; import type { Theme } from '@mui/material/styles'; -import type { FormikErrors } from 'formik'; interface DatabaseVPCSelectorProps { - errors: FormikErrors; // TODO (UIE-8903): Replace deprecated Formik with React Hook Form - mode: 'create' | 'networking'; - onChange: (field: string, value: boolean | null | number) => void; - onConfigurationChange?: (vpc: null | VPC) => void; - privateNetworkValues: PrivateNetwork; - resetFormFields?: (partialValues?: Partial) => void; - selectedRegionId: string; + onChange: (selectedVPC: null | VPC) => void; } export const DatabaseVPCSelector = (props: DatabaseVPCSelectorProps) => { - const { - errors, - mode, - onConfigurationChange, - onChange, - selectedRegionId, - resetFormFields, - privateNetworkValues, - } = props; - + const { onChange } = props; const flags = useFlags(); - const isCreate = mode === 'create'; - const { data: selectedRegion } = useRegionQuery(selectedRegionId); + const { control, setValue } = useFormContext(); + + const [region, vpcId, subnetId] = useWatch({ + control, + name: ['region', 'private_network.vpc_id', 'private_network.subnet_id'], + }); + + const { data: selectedRegion } = useRegionQuery(region); const regionSupportsVPCs = selectedRegion?.capabilities.includes('VPCs'); const { @@ -54,77 +44,27 @@ export const DatabaseVPCSelector = (props: DatabaseVPCSelectorProps) => { isLoading, } = useAllVPCsQuery({ enabled: regionSupportsVPCs, - filter: { region: selectedRegionId }, + filter: { region }, }); const vpcErrorMessage = vpcsError && getAPIErrorOrDefault(vpcsError, 'Unable to load VPCs')[0].reason; - const selectedVPC = vpcs?.find( - (vpc) => vpc.id === privateNetworkValues.vpc_id - ); + const selectedVPC = vpcs?.find((vpc) => vpc.id === vpcId); const selectedSubnet = selectedVPC?.subnets.find( - (subnet) => subnet.id === privateNetworkValues.subnet_id + (subnet) => subnet.id === subnetId ); - const prevRegionId = React.useRef(undefined); const regionHasVPCs = Boolean(vpcs && vpcs.length > 0); const disableVPCSelectors = !!vpcsError || !regionSupportsVPCs || !regionHasVPCs; - const resetVPCConfiguration = () => { - resetFormFields?.({ - private_network: { - vpc_id: null, - subnet_id: null, - public_access: false, - }, - }); - }; - - React.useEffect(() => { - // When the selected region has changed, reset VPC configuration. - // Then switch back to default validation behavior - if (prevRegionId.current && prevRegionId.current !== selectedRegionId) { - resetVPCConfiguration(); - onConfigurationChange?.(null); - } - prevRegionId.current = selectedRegionId; - }, [selectedRegionId]); - - const vpcHelperTextCopy = !selectedRegionId + const vpcHelperTextCopy = !region ? 'In the Select Engine and Region section, select a region with an existing VPC to see available VPCs.' : 'No VPC is available in the selected region.'; - /** Returns dynamic marginTop value used to center TooltipIcon in different scenarios */ - const getVPCTooltipIconMargin = () => { - const margins = { - longHelperText: '.75rem', - shortHelperText: '1.75rem', - noHelperText: '2.75rem', - errorText: '1.5rem', - errorTextWithLongHelperText: '-.5rem', - }; - if (disableVPCSelectors && vpcsError) - return margins.errorTextWithLongHelperText; - if (errors?.private_network?.vpc_id) return margins.errorText; - if (disableVPCSelectors && !selectedRegionId) return margins.longHelperText; - if (disableVPCSelectors && selectedRegionId) return margins.shortHelperText; - return margins.noHelperText; - }; - - const accessNotice = isCreate && ( - ({ - marginTop: theme.spacingFunction(20), - })} - text="The cluster will have public access by default if a VPC is not assigned." - variant="info" - /> - ); - return ( <> { Assign a VPC {flags.databaseVpcBeta && } - Assign this cluster to an existing VPC.{' '} { Learn more. - - { - onChange('private_network.subnet_id', null); // Always reset subnet selection when VPC changes - if (!value) { - onChange('private_network.public_access', false); - } - onConfigurationChange?.(value ?? null); - onChange('private_network.vpc_id', value?.id ?? null); - }} - options={vpcs ?? []} - placeholder="Select a VPC" - sx={{ width: '354px' }} - value={selectedVPC ?? null} - /> - + ( + { + setValue('private_network.subnet_id', null); // Always reset subnet selection when VPC changes + if (!value) { + setValue('private_network.public_access', false); + } + onChange(value ?? null); // Update VPC in DatabaseCreate.tsx + field.onChange(value?.id ?? null); + }} + options={vpcs ?? []} + placeholder="Select a VPC" + sx={{ width: '354px' }} + textFieldProps={{ + tooltipText: + 'A cluster may be assigned only to a VPC in the same region', + }} + value={selectedVPC ?? null} + /> + )} /> - {selectedVPC ? ( <> - `${subnet.label} (${subnet.ipv4})`} - label="Subnet" - onChange={(e, value) => { - onChange('private_network.subnet_id', value?.id ?? null); - }} - options={selectedVPC?.subnets ?? []} - placeholder="Select a subnet" - value={selectedSubnet ?? null} + ( + `${subnet.label} (${subnet.ipv4})`} + label="Subnet" + onChange={(e, value) => { + field.onChange(value?.id ?? null); + }} + options={selectedVPC?.subnets ?? []} + placeholder="Select a subnet" + value={selectedSubnet ?? null} + /> + )} /> ({ marginTop: theme.spacingFunction(20), })} > - { - onChange('private_network.public_access', value ?? null); - }} - text={'Enable public access'} - toolTipText={ - 'Adds a public endpoint to the database in addition to the private VPC endpoint.' - } + ( + <> + { + field.onChange(value ?? null); + }} + text={'Enable public access'} + toolTipText={ + 'Adds a public endpoint to the database in addition to the private VPC endpoint.' + } + /> + {fieldState.error?.message && ( + + {fieldState.error?.message} + + )} + + )} /> - {errors?.private_network?.public_access && ( - - {errors?.private_network?.public_access} - - )} ) : ( - accessNotice + ({ + marginTop: theme.spacingFunction(20), + })} + text="The cluster will have public access by default if a VPC is not assigned." + variant="info" + /> )} ); diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseManageNetworkingDrawer.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseManageNetworkingDrawer.tsx index 127882110f1..bd5c235a3fa 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseManageNetworkingDrawer.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseManageNetworkingDrawer.tsx @@ -6,7 +6,7 @@ import { useFormik } from 'formik'; import { enqueueSnackbar } from 'notistack'; import * as React from 'react'; -import { DatabaseVPCSelector } from '../../DatabaseCreate/DatabaseVPCSelector'; +import { DatabaseVPCSelector } from './DatabaseVPCSelector'; import type { Database, diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseVPCSelector.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseVPCSelector.tsx new file mode 100644 index 00000000000..6f5c7b7b220 --- /dev/null +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseVPCSelector.tsx @@ -0,0 +1,243 @@ +import { useAllVPCsQuery, useRegionQuery } from '@linode/queries'; +import { + Autocomplete, + BetaChip, + Box, + Checkbox, + FormHelperText, + Notice, + TooltipIcon, + Typography, +} from '@linode/ui'; +import * as React from 'react'; + +import { Link } from 'src/components/Link'; +import { MANAGE_NETWORKING_LEARN_MORE_LINK } from 'src/features/Databases/constants'; +import { useFlags } from 'src/hooks/useFlags'; +import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; + +import type { ClusterSize, Engine, PrivateNetwork, VPC } from '@linode/api-v4'; +import type { Theme } from '@mui/material/styles'; +import type { FormikErrors } from 'formik'; + +interface DatabaseCreateValuesFormik { + allow_list: { + address: string; + error: string; + }[]; + cluster_size: ClusterSize; + engine: Engine; + label: string; + private_network: PrivateNetwork; + region: string; + type: string; +} + +interface DatabaseVPCSelectorProps { + errors: FormikErrors; // TODO (UIE-8903): Replace deprecated Formik with React Hook Form + mode: 'create' | 'networking'; + onChange: (field: string, value: boolean | null | number) => void; + onConfigurationChange?: (vpc: null | VPC) => void; + privateNetworkValues: PrivateNetwork; + resetFormFields?: ( + partialValues?: Partial + ) => void; + selectedRegionId: string; +} + +export const DatabaseVPCSelector = (props: DatabaseVPCSelectorProps) => { + const { + errors, + mode, + onConfigurationChange, + onChange, + selectedRegionId, + resetFormFields, + privateNetworkValues, + } = props; + + const flags = useFlags(); + const isCreate = mode === 'create'; + const { data: selectedRegion } = useRegionQuery(selectedRegionId); + const regionSupportsVPCs = selectedRegion?.capabilities.includes('VPCs'); + + const { + data: vpcs, + error: vpcsError, + isLoading, + } = useAllVPCsQuery({ + enabled: regionSupportsVPCs, + filter: { region: selectedRegionId }, + }); + + const vpcErrorMessage = + vpcsError && + getAPIErrorOrDefault(vpcsError, 'Unable to load VPCs')[0].reason; + + const selectedVPC = vpcs?.find( + (vpc) => vpc.id === privateNetworkValues.vpc_id + ); + + const selectedSubnet = selectedVPC?.subnets.find( + (subnet) => subnet.id === privateNetworkValues.subnet_id + ); + + const prevRegionId = React.useRef(undefined); + const regionHasVPCs = Boolean(vpcs && vpcs.length > 0); + const disableVPCSelectors = + !!vpcsError || !regionSupportsVPCs || !regionHasVPCs; + + const resetVPCConfiguration = () => { + resetFormFields?.({ + private_network: { + vpc_id: null, + subnet_id: null, + public_access: false, + }, + }); + }; + + React.useEffect(() => { + // When the selected region has changed, reset VPC configuration. + // Then switch back to default validation behavior + if (prevRegionId.current && prevRegionId.current !== selectedRegionId) { + resetVPCConfiguration(); + onConfigurationChange?.(null); + } + prevRegionId.current = selectedRegionId; + }, [selectedRegionId]); + + const vpcHelperTextCopy = !selectedRegionId + ? 'In the Select Engine and Region section, select a region with an existing VPC to see available VPCs.' + : 'No VPC is available in the selected region.'; + + /** Returns dynamic marginTop value used to center TooltipIcon in different scenarios */ + const getVPCTooltipIconMargin = () => { + const margins = { + longHelperText: '.75rem', + shortHelperText: '1.75rem', + noHelperText: '2.75rem', + errorText: '1.5rem', + errorTextWithLongHelperText: '-.5rem', + }; + if (disableVPCSelectors && vpcsError) + return margins.errorTextWithLongHelperText; + if (errors?.private_network?.vpc_id) return margins.errorText; + if (disableVPCSelectors && !selectedRegionId) return margins.longHelperText; + if (disableVPCSelectors && selectedRegionId) return margins.shortHelperText; + return margins.noHelperText; + }; + + const accessNotice = isCreate && ( + ({ + marginTop: theme.spacingFunction(20), + })} + text="The cluster will have public access by default if a VPC is not assigned." + variant="info" + /> + ); + + return ( + <> + ({ + display: 'flex', + marginTop: theme.spacingFunction(20), + marginBottom: theme.spacingFunction(4), + })} + > + Assign a VPC + {flags.databaseVpcBeta && } + + + + Assign this cluster to an existing VPC.{' '} + + Learn more. + + + + { + onChange('private_network.subnet_id', null); // Always reset subnet selection when VPC changes + if (!value) { + onChange('private_network.public_access', false); + } + onConfigurationChange?.(value ?? null); + onChange('private_network.vpc_id', value?.id ?? null); + }} + options={vpcs ?? []} + placeholder="Select a VPC" + sx={{ width: '354px' }} + value={selectedVPC ?? null} + /> + + + + {selectedVPC ? ( + <> + `${subnet.label} (${subnet.ipv4})`} + label="Subnet" + onChange={(e, value) => { + onChange('private_network.subnet_id', value?.id ?? null); + }} + options={selectedVPC?.subnets ?? []} + placeholder="Select a subnet" + value={selectedSubnet ?? null} + /> + ({ + marginTop: theme.spacingFunction(20), + })} + > + { + onChange('private_network.public_access', value ?? null); + }} + text={'Enable public access'} + toolTipText={ + 'Adds a public endpoint to the database in addition to the private VPC endpoint.' + } + /> + {errors?.private_network?.public_access && ( + + {errors?.private_network?.public_access} + + )} + + + ) : ( + accessNotice + )} + + ); +}; diff --git a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseEmptyState.tsx b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseEmptyState.tsx index 9d39ebb9639..2bee76c80c1 100644 --- a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseEmptyState.tsx +++ b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseEmptyState.tsx @@ -27,7 +27,7 @@ export const DatabaseEmptyState = () => { headers.logo = ( ); - } + } // TODO (UIE-8634): Determine if condition is still necessary return ( { }; }); +const defaultFlags = { dbaasV2: { beta: false, enabled: true } }; + beforeAll(() => mockMatchMedia()); const loadingTestId = 'circle-progress'; -const accountEndpoint = '*/v4*/account'; +const accountEndpoint = '*/account'; const databaseInstancesEndpoint = '*/databases/instances'; -const managedDBBetaCapability = 'Managed Databases Beta'; const managedDBCapability = 'Managed Databases'; -const newDBTabTitle = 'New Database Clusters'; -const legacyDBTabTitle = 'Legacy Database Clusters'; - describe('Database Table Row', () => { it('should render a database row', async () => { const database = databaseInstanceFactory.build(); @@ -75,49 +72,53 @@ describe('Database Table Row', () => { describe('Database Table', () => { it('should render database landing table with items', async () => { const mockAccount = accountFactory.build({ - capabilities: [managedDBBetaCapability], + capabilities: [managedDBCapability], }); server.use( - http.get('*/account', () => { + http.get(accountEndpoint, () => { return HttpResponse.json(mockAccount); - }) - ); - server.use( + }), http.get(databaseInstancesEndpoint, () => { const databases = databaseInstanceFactory.buildList(1, { status: 'active', + platform: 'rdbms-default', }); return HttpResponse.json(makeResourcePage(databases)); }) ); - const { getAllByText, getByTestId, queryAllByText } = renderWithTheme( - - ); + const { getAllByText, getByTestId, queryAllByText, queryByText } = + renderWithTheme(, { + flags: defaultFlags, + }); // Loading state should render expect(getByTestId(loadingTestId)).toBeInTheDocument(); - await waitForElementToBeRemoved(getByTestId(loadingTestId)); + await waitForElementToBeRemoved(getByTestId(loadingTestId), { + timeout: 3000, + }); // Static text and table column headers getAllByText('Cluster Label'); getAllByText('Status'); - getAllByText('Configuration'); getAllByText('Engine'); getAllByText('Region'); getAllByText('Created'); // Check to see if the mocked API data rendered in the table queryAllByText('Active'); + + // Check that logo renders + queryByText('Powered by'); }); it('should render database landing with empty state', async () => { const mockAccount = accountFactory.build({ - capabilities: [managedDBBetaCapability], + capabilities: [managedDBCapability], }); server.use( - http.get('*/account', () => { + http.get(accountEndpoint, () => { return HttpResponse.json(mockAccount); }) ); @@ -127,7 +128,7 @@ describe('Database Table', () => { }) ); const { getByTestId, getByText } = renderWithTheme(, { - flags: { dbaasV2: { beta: true, enabled: true } }, + flags: defaultFlags, }); await waitForElementToBeRemoved(getByTestId(loadingTestId)); @@ -138,149 +139,6 @@ describe('Database Table', () => { ) ).toBeInTheDocument(); }); - - it('should render tabs with legacy and new databases ', async () => { - server.use( - http.get(accountEndpoint, () => { - return HttpResponse.json( - accountFactory.build({ - capabilities: [managedDBCapability, managedDBBetaCapability], - }) - ); - }) - ); - server.use( - http.get(databaseInstancesEndpoint, () => { - const databases = databaseInstanceFactory.buildList(5, { - status: 'active', - }); - - return HttpResponse.json(makeResourcePage(databases)); - }) - ); - - const { getByTestId } = renderWithTheme(, { - flags: { dbaasV2: { beta: true, enabled: true } }, - }); - - // Loading state should render - expect(getByTestId(loadingTestId)).toBeInTheDocument(); - - await waitForElementToBeRemoved(getByTestId(loadingTestId)); - - const newDatabasesTab = screen.getByText(newDBTabTitle); - const legacyDatabasesTab = screen.getByText(legacyDBTabTitle); - - expect(newDatabasesTab).toBeInTheDocument(); - expect(legacyDatabasesTab).toBeInTheDocument(); - }); - - it('should render logo in new databases tab ', async () => { - server.use( - http.get(accountEndpoint, () => { - return HttpResponse.json( - accountFactory.build({ - capabilities: [managedDBCapability, managedDBBetaCapability], - }) - ); - }) - ); - server.use( - http.get(databaseInstancesEndpoint, () => { - const databases = databaseInstanceFactory.buildList(5, { - status: 'active', - }); - return HttpResponse.json(makeResourcePage(databases)); - }) - ); - - const { getByTestId } = renderWithTheme(, { - flags: { dbaasV2: { beta: true, enabled: true } }, - }); - - expect(getByTestId(loadingTestId)).toBeInTheDocument(); - - await waitForElementToBeRemoved(getByTestId(loadingTestId)); - - const newDatabaseTab = screen.getByText(newDBTabTitle); - fireEvent.click(newDatabaseTab); - - expect(screen.getByText('Powered by')).toBeInTheDocument(); - }); - - it('should render a single legacy database table without logo ', async () => { - server.use( - http.get(databaseInstancesEndpoint, () => { - const databases = databaseInstanceFactory.buildList(5, { - status: 'active', - }); - return HttpResponse.json(makeResourcePage(databases)); - }) - ); - - const { getByTestId } = renderWithTheme(, { - flags: { dbaasV2: { beta: false, enabled: false } }, - }); - - expect(getByTestId(loadingTestId)).toBeInTheDocument(); - - await waitForElementToBeRemoved(getByTestId(loadingTestId)); - - const tables = screen.getAllByRole('table'); - expect(tables).toHaveLength(1); - - const table = tables[0]; - - const headers = within(table).getAllByRole('columnheader'); - expect( - headers.some((header) => header.textContent === 'Configuration') - ).toBe(true); - expect(headers.some((header) => header.textContent === 'Nodes')).toBe( - false - ); - - expect(screen.queryByText(legacyDBTabTitle)).toBeNull(); - expect(screen.queryByText(newDBTabTitle)).toBeNull(); - expect(screen.queryByText('Powered by')).toBeNull(); - }); - - it('should render a single new database table ', async () => { - server.use( - http.get(accountEndpoint, () => { - return HttpResponse.json( - accountFactory.build({ - capabilities: [managedDBBetaCapability], - }) - ); - }) - ); - server.use( - http.get(databaseInstancesEndpoint, () => { - const databases = databaseInstanceFactory.buildList(5, { - platform: 'rdbms-default', - status: 'active', - }); - return HttpResponse.json(makeResourcePage(databases)); - }) - ); - - const { getByTestId } = renderWithTheme(, { - flags: { dbaasV2: { beta: true, enabled: true } }, - }); - - expect(getByTestId(loadingTestId)).toBeInTheDocument(); - - await waitForElementToBeRemoved(getByTestId(loadingTestId)); - - const tables = screen.getAllByRole('table'); - expect(tables).toHaveLength(1); - - expect(screen.getByText('Cluster Label')).toBeInTheDocument(); - - expect(screen.queryByText(legacyDBTabTitle)).toBeInTheDocument(); - expect(screen.queryByText(newDBTabTitle)).toBeInTheDocument(); - expect(screen.queryByText('Powered by')).toBeInTheDocument(); - }); }); describe('Database Landing', () => { @@ -330,7 +188,7 @@ describe('Database Landing', () => { const { getByLabelText, getByTestId } = renderWithTheme( , { - flags: { dbaasV2: { beta: false, enabled: true } }, + flags: defaultFlags, } ); @@ -361,7 +219,7 @@ describe('Database Landing', () => { const { getByLabelText, getByTestId, getByText } = renderWithTheme( , { - flags: { dbaasV2: { beta: false, enabled: true } }, + flags: defaultFlags, } ); diff --git a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLanding.tsx b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLanding.tsx index 7c51a9a8e49..d033335b735 100644 --- a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLanding.tsx +++ b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLanding.tsx @@ -5,17 +5,10 @@ import { useNavigate } from '@tanstack/react-router'; import * as React from 'react'; import { LandingHeader } from 'src/components/LandingHeader'; -import { SafeTabPanel } from 'src/components/Tabs/SafeTabPanel'; -import { Tab } from 'src/components/Tabs/Tab'; -import { TabList } from 'src/components/Tabs/TabList'; -import { TabPanels } from 'src/components/Tabs/TabPanels'; -import { Tabs } from 'src/components/Tabs/Tabs'; import { getRestrictedResourceText } from 'src/features/Account/utils'; import { DatabaseEmptyState } from 'src/features/Databases/DatabaseLanding/DatabaseEmptyState'; import DatabaseLandingTable from 'src/features/Databases/DatabaseLanding/DatabaseLandingTable'; import { useIsDatabasesEnabled } from 'src/features/Databases/utilities'; -import { DatabaseClusterInfoBanner } from 'src/features/GlobalNotifications/DatabaseClusterInfoBanner'; -import { DatabaseMigrationInfoBanner } from 'src/features/GlobalNotifications/DatabaseMigrationInfoBanner'; import { useOrderV2 } from 'src/hooks/useOrderV2'; import { usePaginationV2 } from 'src/hooks/usePaginationV2'; import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; @@ -28,35 +21,26 @@ export const DatabaseLanding = () => { const newDatabasesPagination = usePaginationV2({ currentRoute: '/databases', preferenceKey, - queryParamsPrefix: 'new', - }); - const legacyDatabasesPagination = usePaginationV2({ - currentRoute: '/databases', - preferenceKey, - queryParamsPrefix: 'legacy', + queryParamsPrefix: 'new', // TODO (UIE-8634): Determine if we still need this prefix }); const isRestricted = useRestrictedGlobalGrantCheck({ globalGrantType: 'add_databases', }); - const { - isDatabasesV2Enabled, - isDatabasesV2GA, - isUserExistingBeta, - isUserNewBeta, - } = useIsDatabasesEnabled(); + const { isDatabasesV2GA, isUserExistingBeta, isUserNewBeta } = + useIsDatabasesEnabled(); - const { isLoading: isTypeLoading } = useDatabaseTypesQuery({ - platform: isDatabasesV2Enabled ? 'rdbms-default' : 'rdbms-legacy', + const { isLoading: isTypesLoading } = useDatabaseTypesQuery({ + platform: 'rdbms-default', }); const isDefaultEnabled = isUserExistingBeta || isUserNewBeta || isDatabasesV2GA; const { - handleOrderChange: newDatabaseHandleOrderChange, - order: newDatabaseOrder, - orderBy: newDatabaseOrderBy, + handleOrderChange: databaseHandleOrderChange, + order: databaseOrder, + orderBy: databaseOrderBy, } = useOrderV2({ initialRoute: { defaultOrder: { @@ -65,123 +49,50 @@ export const DatabaseLanding = () => { }, from: '/databases', }, - preferenceKey: `new-${preferenceKey}-order`, + preferenceKey: `new-${preferenceKey}-order`, // TODO (UIE-8634): Determine if we still need 'new-' prefix }); - const newDatabasesFilter: Record = { - ['+order']: newDatabaseOrder, - ['+order_by']: newDatabaseOrderBy, + const databasesFilter: Record = { + ['+order']: databaseOrder, + ['+order_by']: databaseOrderBy, ['platform']: 'rdbms-default', }; const { - data: newDatabases, - error: newDatabasesError, - isLoading: newDatabasesIsLoading, + data: databases, + error: databasesError, + isLoading: databasesIsLoading, } = useDatabasesQuery( { page: newDatabasesPagination.page, page_size: newDatabasesPagination.pageSize, }, - newDatabasesFilter, - isDefaultEnabled - ); - - const { - handleOrderChange: legacyDatabaseHandleOrderChange, - order: legacyDatabaseOrder, - orderBy: legacyDatabaseOrderBy, - } = useOrderV2({ - initialRoute: { - defaultOrder: { - order: 'desc', - orderBy: 'label', - }, - from: '/databases', - }, - preferenceKey: `legacy-${preferenceKey}-order`, - }); - - const legacyDatabasesFilter: Record = { - ['+order']: legacyDatabaseOrder, - ['+order_by']: legacyDatabaseOrderBy, - }; - - if (isUserExistingBeta || isDatabasesV2GA) { - legacyDatabasesFilter['platform'] = 'rdbms-legacy'; - } - - const { - data: legacyDatabases, - error: legacyDatabasesError, - isLoading: legacyDatabasesIsLoading, - } = useDatabasesQuery( - { - page: legacyDatabasesPagination.page, - page_size: legacyDatabasesPagination.pageSize, - }, - legacyDatabasesFilter, - !isUserNewBeta + databasesFilter, + isDefaultEnabled // TODO (UIE-8634): Determine if check if still necessary ); - const error = newDatabasesError || legacyDatabasesError; - if (error) { + if (databasesError) { return ( ); } - if (newDatabasesIsLoading || legacyDatabasesIsLoading || isTypeLoading) { + if (databasesIsLoading || isTypesLoading) { return ; } - const showEmpty = !newDatabases?.data.length && !legacyDatabases?.data.length; + const showEmpty = !databases?.data.length; if (showEmpty) { return ; } - const isV2Enabled = isDatabasesV2Enabled || isDatabasesV2GA; - const showTabs = isV2Enabled && !!legacyDatabases?.data.length; - const isNewDatabase = isV2Enabled && !!newDatabases?.data.length; - const showSuspend = isDatabasesV2GA && !!newDatabases?.data.length; - const docsLink = isV2Enabled - ? 'https://techdocs.akamai.com/cloud-computing/docs/aiven-database-clusters' - : 'https://techdocs.akamai.com/cloud-computing/docs/managed-databases'; - - const legacyTable = () => { - return ( - - ); - }; - - const defaultTable = () => { - return ( - - ); - }; - - const singleTable = () => { - return isNewDatabase ? defaultTable() : legacyTable(); - }; - return ( { }} createButtonText="Create Database Cluster" disabledCreateButton={isRestricted} - docsLink={docsLink} + docsLink="https://techdocs.akamai.com/cloud-computing/docs/aiven-database-clusters" onButtonClick={() => navigate({ to: '/databases/create' })} title="Database Clusters" /> - {showTabs && !isDatabasesV2GA && } - {showTabs && isDatabasesV2GA && } - {showTabs ? ( - - - New Database Clusters - Legacy Database Clusters - - - {defaultTable()} - {legacyTable()} - - - ) : ( - singleTable() - )} + ); diff --git a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLandingTable.tsx b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLandingTable.tsx index b6e566dc046..f5834cc32bf 100644 --- a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLandingTable.tsx +++ b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLandingTable.tsx @@ -1,14 +1,16 @@ import { Hidden } from '@linode/ui'; +import { useTheme } from '@mui/material/styles'; +import { Pagination } from 'akamai-cds-react-components/Pagination'; +import { + Table, + TableBody, + TableHead, + TableHeaderCell, + TableRow, +} from 'akamai-cds-react-components/Table'; import React from 'react'; -import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter'; -import { Table } from 'src/components/Table'; -import { TableBody } from 'src/components/TableBody'; -import { TableCell } from 'src/components/TableCell'; -import { TableHead } from 'src/components/TableHead'; -import { TableRow } from 'src/components/TableRow/TableRow'; import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; -import { TableSortCell } from 'src/components/TableSortCell'; import AddAccessControlDrawer from 'src/features/Databases/DatabaseDetail/AddAccessControlDrawer'; import DatabaseSettingsDeleteClusterDialog from 'src/features/Databases/DatabaseDetail/DatabaseSettings/DatabaseSettingsDeleteClusterDialog'; import DatabaseSettingsResetPasswordDialog from 'src/features/Databases/DatabaseDetail/DatabaseSettings/DatabaseSettingsResetPasswordDialog'; @@ -32,7 +34,6 @@ interface Props { order: 'asc' | 'desc'; orderBy: string; results: number | undefined; - showSuspend?: boolean; } const DatabaseLandingTable = ({ data, @@ -42,6 +43,7 @@ const DatabaseLandingTable = ({ orderBy, results, }: Props) => { + const theme = useTheme(); const { data: events } = useInProgressEvents(); const { isDatabasesV2GA } = useIsDatabasesEnabled(); @@ -52,6 +54,8 @@ const DatabaseLandingTable = ({ preferenceKey, queryParamsPrefix: dbPlatformType, }); + const PAGE_SIZES = [25, 50, 75, 100]; + const MIN_PAGE_SIZE = 25; const [selectedDatabase, setSelectedDatabase] = React.useState({} as DatabaseInstance); @@ -92,117 +96,164 @@ const DatabaseLandingTable = ({ return ( <> - - - - - Cluster Label - - +
    + + - Status - - {isNewDatabase && ( - - Plan - - )} - - + handleOrderChange('label', order === 'asc' ? 'desc' : 'asc') + } + sortable + sorted={orderBy === 'label' ? order : undefined} + style={{ + flex: '0 1 20.5%', + }} > - {isNewDatabase ? 'Nodes' : 'Configuration'} - - - - Engine - - - + + handleOrderChange('status', order === 'asc' ? 'desc' : 'asc') + } + sortable + sorted={orderBy === 'status' ? order : undefined} > - Region - - - - + {isNewDatabase && ( + + handleOrderChange('type', order === 'asc' ? 'desc' : 'asc') + } + sortable + sorted={orderBy === 'type' ? order : undefined} + > + Plan + + )} + + + handleOrderChange( + 'cluster_size', + order === 'asc' ? 'desc' : 'asc' + ) + } + sortable + sorted={orderBy === 'cluster_size' ? order : undefined} + > + {isNewDatabase ? 'Nodes' : 'Configuration'} + + + + handleOrderChange('engine', order === 'asc' ? 'desc' : 'asc') + } + sortable + sorted={orderBy === 'engine' ? order : undefined} > - Created - - - {isDatabasesV2GA && isNewDatabase && } - - - - {data?.map((database: DatabaseInstance) => ( - handleDelete(database), - handleManageAccessControls: () => - handleManageAccessControls(database), - handleResetPassword: () => handleResetPassword(database), - handleSuspend: () => handleSuspend(database), - }} - isNewDatabase={isNewDatabase} - key={database.id} - /> - ))} - {data?.length === 0 && ( - - )} - -
    - + Engine + + + + handleOrderChange( + 'region', + order === 'asc' ? 'desc' : 'asc' + ) + } + sortable + sorted={orderBy === 'region' ? order : undefined} + > + Region + + + + + handleOrderChange( + 'created', + order === 'asc' ? 'desc' : 'asc' + ) + } + sortable + sorted={orderBy === 'created' ? order : undefined} + > + Created + + + {isDatabasesV2GA && isNewDatabase && ( + + )} + + + + {data?.length === 0 ? ( + + ) : ( + data?.map((database: DatabaseInstance) => ( + handleDelete(database), + handleManageAccessControls: () => + handleManageAccessControls(database), + handleResetPassword: () => handleResetPassword(database), + handleSuspend: () => handleSuspend(database), + }} + isNewDatabase={isNewDatabase} + key={database.id} + /> + )) + )} + + + + {(results || 0) > MIN_PAGE_SIZE && ( + ) => + pagination.handlePageChange(Number(e.detail)) + } + onPageSizeChange={( + e: CustomEvent<{ page: number; pageSize: number }> + ) => pagination.handlePageSizeChange(Number(e.detail.pageSize))} + page={pagination.page} + pageSize={pagination.pageSize} + pageSizes={PAGE_SIZES} + style={{ + borderLeft: `1px solid ${theme.tokens.alias.Border.Normal}`, + borderRight: `1px solid ${theme.tokens.alias.Border.Normal}`, + borderTop: 0, + }} + /> + )} + {isNewDatabase && ( <> diff --git a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseRow.tsx b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseRow.tsx index ba13caa0258..6e68f13784c 100644 --- a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseRow.tsx +++ b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseRow.tsx @@ -3,14 +3,12 @@ import { useProfile, useRegionsQuery, } from '@linode/queries'; -import { Chip } from '@linode/ui'; -import { Hidden } from '@linode/ui'; +import { Chip, Hidden, styled } from '@linode/ui'; import { formatStorageUnits } from '@linode/utilities'; +import { TableCell, TableRow } from 'akamai-cds-react-components/Table'; import * as React from 'react'; import { Link } from 'src/components/Link'; -import { TableCell } from 'src/components/TableCell'; -import { TableRow } from 'src/components/TableRow'; import { DatabaseStatusDisplay } from 'src/features/Databases/DatabaseDetail/DatabaseStatusDisplay'; import { DatabaseEngineVersion } from 'src/features/Databases/DatabaseEngineVersion'; import { DatabaseActionMenu } from 'src/features/Databases/DatabaseLanding/DatabaseActionMenu'; @@ -36,6 +34,24 @@ interface Props { isNewDatabase?: boolean; } +const DatabaseActionMenuStyledWrapper = styled(TableCell, { + label: 'DatabaseActionMenuStyledWrapper', +})(({ theme }) => ({ + justifyContent: 'flex-end', + display: 'flex', + alignItems: 'center', + maxWidth: 40, + '& button': { + padding: 0, + color: theme.tokens.alias.Content.Icon.Primary.Default, + backgroundColor: 'transparent', + }, + '& button:hover': { + backgroundColor: 'transparent', + color: theme.tokens.alias.Content.Icon.Primary.Hover, + }, +})); + export const DatabaseRow = ({ database, events, @@ -80,21 +96,30 @@ export const DatabaseRow = ({ ({ borderColor: theme.color.green, mx: 2 })} + sx={(theme) => ({ borderColor: theme.color.green, mx: 0, my: 0 })} variant="outlined" /> ); return ( - - + + {isDatabasesV2GA && isLinkInactive ? ( label ) : ( {label} )} - + {isNewDatabase && {formattedPlan}} @@ -123,7 +148,7 @@ export const DatabaseRow = ({ {isDatabasesV2GA && isNewDatabase && ( - + - + )} ); diff --git a/packages/manager/src/features/Databases/utilities.test.ts b/packages/manager/src/features/Databases/utilities.test.ts index ec00271ec19..859f24b9aef 100644 --- a/packages/manager/src/features/Databases/utilities.test.ts +++ b/packages/manager/src/features/Databases/utilities.test.ts @@ -156,7 +156,7 @@ describe('useIsDatabasesEnabled', () => { }); }); - it('should return correctly for V1 restricted user non-beta', async () => { + it('should return correctly for V2 restricted user existing beta', async () => { server.use( http.get('*/v4*/account', () => { return HttpResponse.json({}, { status: 403 }); @@ -164,11 +164,6 @@ describe('useIsDatabasesEnabled', () => { ); // default - queryMocks.useDatabaseTypesQuery.mockReturnValueOnce({ - data: null, - }); - - // legacy queryMocks.useDatabaseTypesQuery.mockReturnValueOnce({ data: databaseTypeFactory.buildList(1), }); @@ -184,63 +179,13 @@ describe('useIsDatabasesEnabled', () => { ...[{ platform: 'rdbms-default' }, true] ); - expect(queryMocks.useDatabaseTypesQuery).toHaveBeenNthCalledWith( - 2, - ...[{ platform: 'rdbms-legacy' }, true] - ); - - await waitFor(() => { - expect(result.current.isDatabasesEnabled).toBe(true); - expect(result.current.isDatabasesV2Enabled).toBe(false); - - expect(result.current.isDatabasesV2Beta).toBe(false); - expect(result.current.isUserExistingBeta).toBe(false); - expect(result.current.isUserNewBeta).toBe(false); - - expect(result.current.isDatabasesV2GA).toBe(false); - }); - }); - - it('should return correctly for V1 & V2 restricted user existing beta', async () => { - server.use( - http.get('*/v4*/account', () => { - return HttpResponse.json({}, { status: 403 }); - }) - ); - - // default - queryMocks.useDatabaseTypesQuery.mockReturnValueOnce({ - data: databaseTypeFactory.buildList(1), - }); - - // legacy - queryMocks.useDatabaseTypesQuery.mockReturnValueOnce({ - data: databaseTypeFactory.buildList(1), - }); - - const flags = { dbaasV2: { beta: true, enabled: true } }; - - const { result } = renderHook(() => useIsDatabasesEnabled(), { - wrapper: (ui) => wrapWithTheme(ui, { flags }), - }); - - expect(queryMocks.useDatabaseTypesQuery).toHaveBeenNthCalledWith( - 1, - ...[{ platform: 'rdbms-default' }, true] - ); - - expect(queryMocks.useDatabaseTypesQuery).toHaveBeenNthCalledWith( - 2, - ...[{ platform: 'rdbms-legacy' }, true] - ); - await waitFor(() => { expect(result.current.isDatabasesEnabled).toBe(true); expect(result.current.isDatabasesV2Enabled).toBe(true); expect(result.current.isDatabasesV2Beta).toBe(true); expect(result.current.isUserExistingBeta).toBe(true); - expect(result.current.isUserNewBeta).toBe(false); + expect(result.current.isUserNewBeta).toBe(true); expect(result.current.isDatabasesV2GA).toBe(false); }); @@ -258,11 +203,6 @@ describe('useIsDatabasesEnabled', () => { data: databaseTypeFactory.buildList(1), }); - // legacy - queryMocks.useDatabaseTypesQuery.mockReturnValueOnce({ - data: null, - }); - const flags = { dbaasV2: { beta: true, enabled: true } }; const { result } = renderHook(() => useIsDatabasesEnabled(), { @@ -274,17 +214,12 @@ describe('useIsDatabasesEnabled', () => { ...[{ platform: 'rdbms-default' }, true] ); - expect(queryMocks.useDatabaseTypesQuery).toHaveBeenNthCalledWith( - 2, - ...[{ platform: 'rdbms-legacy' }, true] - ); - await waitFor(() => { expect(result.current.isDatabasesEnabled).toBe(true); expect(result.current.isDatabasesV2Enabled).toBe(true); expect(result.current.isDatabasesV2Beta).toBe(true); - expect(result.current.isUserExistingBeta).toBe(false); + expect(result.current.isUserExistingBeta).toBe(true); expect(result.current.isUserNewBeta).toBe(true); expect(result.current.isDatabasesV2GA).toBe(false); @@ -303,11 +238,6 @@ describe('useIsDatabasesEnabled', () => { data: databaseTypeFactory.buildList(1), }); - // legacy - queryMocks.useDatabaseTypesQuery.mockReturnValueOnce({ - data: null, - }); - const flags = { dbaasV2: { beta: false, enabled: true } }; const { result } = renderHook(() => useIsDatabasesEnabled(), { @@ -319,11 +249,6 @@ describe('useIsDatabasesEnabled', () => { ...[{ platform: 'rdbms-default' }, true] ); - expect(queryMocks.useDatabaseTypesQuery).toHaveBeenNthCalledWith( - 2, - ...[{ platform: 'rdbms-legacy' }, true] - ); - await waitFor(() => { expect(result.current.isDatabasesEnabled).toBe(true); expect(result.current.isDatabasesV2Enabled).toBe(true); diff --git a/packages/manager/src/features/Databases/utilities.ts b/packages/manager/src/features/Databases/utilities.ts index 706c5ded2cf..8760f8d392c 100644 --- a/packages/manager/src/features/Databases/utilities.ts +++ b/packages/manager/src/features/Databases/utilities.ts @@ -56,11 +56,6 @@ export const useIsDatabasesEnabled = (): IsDatabasesEnabled => { checkRestrictedUser ); - const { data: legacyTypes } = useDatabaseTypesQuery( - { platform: 'rdbms-legacy' }, - checkRestrictedUser - ); - if (account) { const isDatabasesV1Enabled = isFeatureEnabledV2( 'Managed Databases', @@ -97,18 +92,17 @@ export const useIsDatabasesEnabled = (): IsDatabasesEnabled => { }; } - const hasLegacyTypes: boolean = !!legacyTypes; const hasDefaultTypes: boolean = !!types && hasV2Flag; return { - isDatabasesEnabled: hasLegacyTypes || hasDefaultTypes, + isDatabasesEnabled: hasDefaultTypes, isDatabasesV2Beta: hasDefaultTypes && hasV2BetaFlag, isDatabasesV2Enabled: hasDefaultTypes, - isDatabasesV2GA: (hasLegacyTypes || hasDefaultTypes) && hasV2GAFlag, + isDatabasesV2GA: hasDefaultTypes && hasV2GAFlag, - isUserExistingBeta: hasLegacyTypes && hasDefaultTypes && hasV2BetaFlag, - isUserNewBeta: !hasLegacyTypes && hasDefaultTypes && hasV2BetaFlag, + isUserExistingBeta: hasDefaultTypes && hasV2BetaFlag, + isUserNewBeta: hasDefaultTypes && hasV2BetaFlag, }; }; diff --git a/packages/manager/src/features/Delivery/Destinations/DeleteDestinationDialog.tsx b/packages/manager/src/features/Delivery/Destinations/DeleteDestinationDialog.tsx index 020f86a47e8..ee2a8391531 100644 --- a/packages/manager/src/features/Delivery/Destinations/DeleteDestinationDialog.tsx +++ b/packages/manager/src/features/Delivery/Destinations/DeleteDestinationDialog.tsx @@ -2,8 +2,10 @@ import { useDeleteDestinationMutation } from '@linode/queries'; import { ActionsPanel } from '@linode/ui'; import { enqueueSnackbar } from 'notistack'; import * as React from 'react'; +import { useEffect } from 'react'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; +import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import type { Destination } from '@linode/api-v4'; @@ -15,24 +17,37 @@ interface Props { export const DeleteDestinationDialog = React.memo((props: Props) => { const { onClose, open, destination } = props; - const { - mutateAsync: deleteDestination, - isPending, - error, - } = useDeleteDestinationMutation(); + const { mutateAsync: deleteDestination, isPending } = + useDeleteDestinationMutation(); + const [deleteError, setDeleteError] = React.useState(); const handleDelete = () => { const { id, label } = destination as Destination; deleteDestination({ id, - }).then(() => { - onClose(); - return enqueueSnackbar(`Destination ${label} deleted successfully`, { - variant: 'success', + }) + .then(() => { + onClose(); + return enqueueSnackbar(`Destination ${label} deleted successfully`, { + variant: 'success', + }); + }) + .catch((error) => { + setDeleteError( + getAPIErrorOrDefault( + error, + 'There was an issue deleting your destination' + )[0].reason + ); }); - }); }; + useEffect(() => { + if (open) { + setDeleteError(undefined); + } + }, [open]); + const actions = ( { return ( { assertInputHasValue('Host', '3000'); assertInputHasValue('Bucket', 'Bucket Name'); assertInputHasValue('Access Key ID', 'Access Id'); - assertInputHasValue('Secret Access Key', 'Access Secret'); + assertInputHasValue('Secret Access Key', ''); assertInputHasValue('Log Path Prefix', 'file'); }); @@ -93,6 +93,10 @@ describe('DestinationEdit', () => { name: editDestinationButtonText, }); + // Enter Secret Access Key + const secretAccessKeyInput = screen.getByLabelText('Secret Access Key'); + await userEvent.type(secretAccessKeyInput, 'Test'); + expect(editDestinationButton).toBeDisabled(); await userEvent.click(testConnectionButton); expect(verifyDestinationSpy).toHaveBeenCalled(); @@ -131,6 +135,10 @@ describe('DestinationEdit', () => { name: editDestinationButtonText, }); + // Enter Secret Access Key + const secretAccessKeyInput = screen.getByLabelText('Secret Access Key'); + await userEvent.type(secretAccessKeyInput, 'Test'); + expect(editDestinationButton).toBeDisabled(); await userEvent.click(testConnectionButton); expect(verifyDestinationSpy).toHaveBeenCalled(); diff --git a/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationEdit.tsx b/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationEdit.tsx index 6e8ef48e0b2..32af3c9908a 100644 --- a/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationEdit.tsx +++ b/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationEdit.tsx @@ -14,6 +14,7 @@ import { FormProvider, useForm } from 'react-hook-form'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { LandingHeader } from 'src/components/LandingHeader'; +import { getDestinationPayloadDetails } from 'src/features/Delivery/deliveryUtils'; import { DestinationForm } from 'src/features/Delivery/Destinations/DestinationForm/DestinationForm'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; @@ -77,9 +78,11 @@ export const DestinationEdit = () => { }, [destination, form]); const onSubmit = () => { + const formValues = form.getValues(); const destination: UpdateDestinationPayloadWithId = { id: destinationId, - ...omitProps(form.getValues(), ['type']), + ...omitProps(formValues, ['type']), + details: getDestinationPayloadDetails(formValues.details), }; updateDestination(destination) diff --git a/packages/manager/src/features/Delivery/Destinations/DestinationsLanding.test.tsx b/packages/manager/src/features/Delivery/Destinations/DestinationsLanding.test.tsx index 767f1acb704..90f3dda8a8b 100644 --- a/packages/manager/src/features/Delivery/Destinations/DestinationsLanding.test.tsx +++ b/packages/manager/src/features/Delivery/Destinations/DestinationsLanding.test.tsx @@ -3,7 +3,7 @@ import userEvent from '@testing-library/user-event'; import * as React from 'react'; import { beforeEach, describe, expect } from 'vitest'; -import { destinationFactory } from 'src/factories/delivery'; +import { destinationFactory } from 'src/factories'; import { DestinationsLanding } from 'src/features/Delivery/Destinations/DestinationsLanding'; import { mockMatchMedia, renderWithTheme } from 'src/utilities/testHelpers'; @@ -204,6 +204,56 @@ describe('Destinations Landing Table', () => { await checkClosedModal(deleteDestinationModal); }); + + it('should show error when cannot delete destination', async () => { + const mockDeleteDestinationMutation = vi.fn().mockRejectedValue([ + { + reason: + 'Destination with id 1 is attached to a stream and cannot be deleted', + }, + ]); + queryMocks.useDeleteDestinationMutation.mockReturnValue({ + mutateAsync: mockDeleteDestinationMutation, + }); + + renderComponent(); + await clickOnActionMenu(); + await clickOnActionMenuItem('Delete'); + + const deleteDestinationModal = screen.getByText('Delete Destination'); + expect(deleteDestinationModal).toBeInTheDocument(); + + let errorIcon = screen.queryByTestId('ErrorOutlineIcon'); + expect(errorIcon).not.toBeInTheDocument(); + + // get delete Destination button + const deleteDestinationButton = screen.getByRole('button', { + name: 'Delete', + }); + await userEvent.click(deleteDestinationButton); + + expect(mockDeleteDestinationMutation).toHaveBeenCalledWith({ + id: 1, + }); + + // check for error state in modal + screen.getByTestId('ErrorOutlineIcon'); + + // close modal with Cancel button + const cancelModalDialogButton = screen.getByRole('button', { + name: 'Cancel', + }); + await userEvent.click(cancelModalDialogButton); + await checkClosedModal(deleteDestinationModal); + + // open delete confirmation modal again + await clickOnActionMenu(); + await clickOnActionMenuItem('Delete'); + + // check for error state to be reset + errorIcon = screen.queryByTestId('ErrorOutlineIcon'); + expect(errorIcon).not.toBeInTheDocument(); + }); }); }); }); diff --git a/packages/manager/src/features/Delivery/Destinations/DestinationsLanding.tsx b/packages/manager/src/features/Delivery/Destinations/DestinationsLanding.tsx index 81e7750f376..6012361be59 100644 --- a/packages/manager/src/features/Delivery/Destinations/DestinationsLanding.tsx +++ b/packages/manager/src/features/Delivery/Destinations/DestinationsLanding.tsx @@ -123,7 +123,6 @@ export const DestinationsLanding = () => { void; onSearch?: (label: string) => void; onSelect?: (status: string) => void; @@ -30,7 +29,6 @@ export const DeliveryTabHeader = ({ createButtonText, disabledCreateButton, entity, - loading, onButtonClick, spacingBottom = 24, isSearching, @@ -115,7 +113,6 @@ export const DeliveryTabHeader = ({ - handleSelect(event, 'all')} - selectable selected={areAllSelected} > handleSort(event, 'name')} sortable sorted={sort?.column === 'name' ? sort.order : undefined} - style={{ minWidth: '26%' }} + style={{ + minWidth: COLUMN_WIDTHS.name, + ...TABLE_CELL_BASE_STYLE, + }} > Role + + handleSort(event, 'access')} + sortable + sorted={sort?.column === 'access' ? sort.order : undefined} + style={{ + minWidth: COLUMN_WIDTHS.access, + ...TABLE_CELL_BASE_STYLE, + }} + > + Role Type + + + + + Description + + handleSort(event, 'access')} - sortable - sorted={sort?.column === 'access' ? sort.order : undefined} - style={{ minWidth: '14%' }} - > - Role Type - - - Description - - + style={{ + minWidth: COLUMN_WIDTHS.actions, + ...TABLE_CELL_BASE_STYLE, + }} + /> @@ -262,27 +298,46 @@ export const RolesTable = ({ roles = [] }: Props) => { selected={selectedRows.includes(roleRow)} > {roleRow.name} - - {capitalizeAllWords(roleRow.access, '_')} - - - {roleRow.permissions.length ? ( - roleRow.description - ) : ( - - {getFacadeRoleDescription(roleRow)}{' '} - Learn more. - - )} - + + + {capitalizeAllWords(roleRow.access, '_')} + + + + + {roleRow.permissions.length ? ( + roleRow.description + ) : ( + + {getFacadeRoleDescription(roleRow)}{' '} + Learn more. + + )} + + { @@ -310,7 +370,7 @@ export const RolesTable = ({ roles = [] }: Props) => { onPageSizeChange={handlePageSizeChange} page={pagination.page} pageSize={pagination.pageSize} - style={{ borderTop: 0 }} + style={{ border: 0 }} /> )} diff --git a/packages/manager/src/features/IAM/Roles/RolesTable/RolesTableActionMenu.tsx b/packages/manager/src/features/IAM/Roles/RolesTable/RolesTableActionMenu.tsx index 1e2faabcd0e..124e3bd4d31 100644 --- a/packages/manager/src/features/IAM/Roles/RolesTable/RolesTableActionMenu.tsx +++ b/packages/manager/src/features/IAM/Roles/RolesTable/RolesTableActionMenu.tsx @@ -18,6 +18,9 @@ export const RolesTableActionMenu = ({ buttonHeight={40} disabled={!canUpdateUserGrants} onClick={onClick} + sx={{ + whiteSpace: 'nowrap', + }} tooltip={ !canUpdateUserGrants ? 'You do not have permission to assign roles.' diff --git a/packages/manager/src/features/IAM/Roles/RolesTable/RolesTableExpandedRow.tsx b/packages/manager/src/features/IAM/Roles/RolesTable/RolesTableExpandedRow.tsx index 3c2e8d2e6ee..a0bd698cd67 100644 --- a/packages/manager/src/features/IAM/Roles/RolesTable/RolesTableExpandedRow.tsx +++ b/packages/manager/src/features/IAM/Roles/RolesTable/RolesTableExpandedRow.tsx @@ -16,11 +16,7 @@ export const RolesTableExpandedRow = ({ permissions }: Props) => { return ( ({ useAllAccountEntities: vi.fn().mockReturnValue({}), diff --git a/packages/manager/src/features/IAM/Users/UserEntities/AssignedEntitiesTable.tsx b/packages/manager/src/features/IAM/Shared/AssignedEntitiesTable/AssignedEntitiesTable.tsx similarity index 84% rename from packages/manager/src/features/IAM/Users/UserEntities/AssignedEntitiesTable.tsx rename to packages/manager/src/features/IAM/Shared/AssignedEntitiesTable/AssignedEntitiesTable.tsx index fc922a54522..3f44e4b2ab1 100644 --- a/packages/manager/src/features/IAM/Users/UserEntities/AssignedEntitiesTable.tsx +++ b/packages/manager/src/features/IAM/Shared/AssignedEntitiesTable/AssignedEntitiesTable.tsx @@ -1,7 +1,10 @@ -import { useUserRoles } from '@linode/queries'; +import { + useGetDefaultDelegationAccessQuery, + useUserRoles, +} from '@linode/queries'; import { Select, Typography, useTheme } from '@linode/ui'; import Grid from '@mui/material/Grid'; -import { useParams, useSearch } from '@tanstack/react-router'; +import { useSearch } from '@tanstack/react-router'; import React from 'react'; import { ActionMenu } from 'src/components/ActionMenu/ActionMenu'; @@ -20,19 +23,23 @@ import { TableSortCell } from 'src/components/TableSortCell'; import { usePaginationV2 } from 'src/hooks/usePaginationV2'; import { useAllAccountEntities } from 'src/queries/entities/entities'; +import { useIsDefaultDelegationRolesForChildAccount } from '../../hooks/useDelegationRole'; import { usePermissions } from '../../hooks/usePermissions'; -import { ENTITIES_TABLE_PREFERENCE_KEY } from '../../Shared/constants'; -import { RemoveAssignmentConfirmationDialog } from '../../Shared/RemoveAssignmentConfirmationDialog/RemoveAssignmentConfirmationDialog'; +import { + addEntityNamesToRoles, + getSearchableFields, +} from '../../Users/UserEntities/utils'; +import { ENTITIES_TABLE_PREFERENCE_KEY } from '../constants'; +import { RemoveAssignmentConfirmationDialog } from '../RemoveAssignmentConfirmationDialog/RemoveAssignmentConfirmationDialog'; import { getFilteredRoles, getFormattedEntityType, groupAccountEntitiesByType, mapEntityTypesForSelect, -} from '../../Shared/utilities'; +} from '../utilities'; import { ChangeRoleForEntityDrawer } from './ChangeRoleForEntityDrawer'; -import { addEntityNamesToRoles, getSearchableFields } from './utils'; -import type { DrawerModes, EntitiesRole } from '../../Shared/types'; +import type { DrawerModes, EntitiesRole } from '../types'; import type { EntityType } from '@linode/api-v4'; import type { SelectOption } from '@linode/ui'; import type { Action } from 'src/components/ActionMenu/ActionMenu'; @@ -44,13 +51,17 @@ const ALL_ENTITIES_OPTION: SelectOption = { type OrderByKeys = 'entity_name' | 'entity_type' | 'role_name'; -export const AssignedEntitiesTable = () => { - const { username } = useParams({ - from: '/iam/users/$username', - }); +interface Props { + username?: string; +} + +export const AssignedEntitiesTable = ({ username }: Props) => { const theme = useTheme(); const { data: permissions } = usePermissions('account', ['is_account_admin']); + const { isDefaultDelegationRolesForChildAccount } = + useIsDefaultDelegationRolesForChildAccount(); + const { selectedRole: selectedRoleSearchParam } = useSearch({ strict: false, }); @@ -59,7 +70,9 @@ export const AssignedEntitiesTable = () => { const [orderBy, setOrderBy] = React.useState('entity_name'); const pagination = usePaginationV2({ - currentRoute: '/iam/users/$username/entities', + currentRoute: isDefaultDelegationRolesForChildAccount + ? '/iam/roles/defaults/entity-access' + : `/iam/users/$username/entities`, initialPage: 1, preferenceKey: ENTITIES_TABLE_PREFERENCE_KEY, }); @@ -93,10 +106,30 @@ export const AssignedEntitiesTable = () => { } = useAllAccountEntities({}); const { - data: assignedRoles, - error: assignedRolesError, - isLoading: assignedRolesLoading, - } = useUserRoles(username ?? ''); + data: assignedUserRoles, + error: assignedUserRolesError, + isLoading: assignedUserRolesLoading, + } = useUserRoles(username ?? '', !isDefaultDelegationRolesForChildAccount); + + const { + data: delegateDefaultRoles, + error: delegateDefaultRolesError, + isLoading: delegateDefaultRolesLoading, + } = useGetDefaultDelegationAccessQuery({ + enabled: isDefaultDelegationRolesForChildAccount, + }); + + const assignedRoles = isDefaultDelegationRolesForChildAccount + ? delegateDefaultRoles + : assignedUserRoles; + + const error = isDefaultDelegationRolesForChildAccount + ? delegateDefaultRolesError + : assignedUserRolesError; + + const loading = isDefaultDelegationRolesForChildAccount + ? delegateDefaultRolesLoading + : assignedUserRolesLoading; const { filterableOptions, roles } = React.useMemo(() => { if (!assignedRoles || !entities) { @@ -158,11 +191,11 @@ export const AssignedEntitiesTable = () => { }); const renderTableBody = () => { - if (entitiesLoading || assignedRolesLoading) { + if (entitiesLoading || loading) { return ; } - if (entitiesError || assignedRolesError) { + if (entitiesError || error) { return ( { onClose={() => setIsChangeRoleForEntityDrawerOpen(false)} open={isChangeRoleForEntityDrawerOpen} role={selectedRole} + username={username} /> handleRemoveAssignmentDialogClose()} open={isRemoveAssignmentDialogOpen} role={selectedRole} + username={username} /> {filteredRoles.length > PAGE_SIZES[0] && ( ({ - useParams: vi.fn().mockReturnValue({}), + useParams: vi.fn().mockReturnValue({ username: 'test_user' }), useAccountRoles: vi.fn().mockReturnValue({}), useUserRoles: vi.fn().mockReturnValue({}), })); @@ -47,6 +47,7 @@ const props = { onClose: vi.fn(), open: true, role: mockRole, + username: 'test_user', }; const mockUpdateUserRole = vi.fn(); diff --git a/packages/manager/src/features/IAM/Users/UserEntities/ChangeRoleForEntityDrawer.tsx b/packages/manager/src/features/IAM/Shared/AssignedEntitiesTable/ChangeRoleForEntityDrawer.tsx similarity index 67% rename from packages/manager/src/features/IAM/Users/UserEntities/ChangeRoleForEntityDrawer.tsx rename to packages/manager/src/features/IAM/Shared/AssignedEntitiesTable/ChangeRoleForEntityDrawer.tsx index cd00a998cc1..9d76decc962 100644 --- a/packages/manager/src/features/IAM/Users/UserEntities/ChangeRoleForEntityDrawer.tsx +++ b/packages/manager/src/features/IAM/Shared/AssignedEntitiesTable/ChangeRoleForEntityDrawer.tsx @@ -1,5 +1,7 @@ import { useAccountRoles, + useGetDefaultDelegationAccessQuery, + useUpdateDefaultDelegationAccessQuery, useUserRoles, useUserRolesMutation, } from '@linode/queries'; @@ -11,31 +13,35 @@ import { Typography, } from '@linode/ui'; import { useTheme } from '@mui/material/styles'; -import { useParams } from '@tanstack/react-router'; +import { useSnackbar } from 'notistack'; import React from 'react'; import { Controller, useForm } from 'react-hook-form'; import { Link } from 'src/components/Link'; -import { AssignedPermissionsPanel } from '../../Shared/AssignedPermissionsPanel/AssignedPermissionsPanel'; +import { useIsDefaultDelegationRolesForChildAccount } from '../../hooks/useDelegationRole'; +import { AssignedPermissionsPanel } from '../AssignedPermissionsPanel/AssignedPermissionsPanel'; import { INTERNAL_ERROR_NO_CHANGES_SAVED, ROLES_LEARN_MORE_LINK, -} from '../../Shared/constants'; +} from '../constants'; import { changeRoleForEntity, getAllRoles, getRoleByName, -} from '../../Shared/utilities'; + isAccountRole, + isEntityRole, +} from '../utilities'; -import type { DrawerModes, EntitiesRole } from '../../Shared/types'; -import type { ExtendedEntityRole } from '../../Shared/utilities'; +import type { DrawerModes, EntitiesRole } from '../types'; +import type { ExtendedEntityRole } from '../utilities'; interface Props { mode: DrawerModes; onClose: () => void; open: boolean; role: EntitiesRole | undefined; + username?: string; } export const ChangeRoleForEntityDrawer = ({ @@ -43,18 +49,34 @@ export const ChangeRoleForEntityDrawer = ({ onClose, open, role, + username, }: Props) => { const theme = useTheme(); - const { username } = useParams({ - from: '/iam/users/$username', - }); + const { enqueueSnackbar } = useSnackbar(); + + const { isDefaultDelegationRolesForChildAccount } = + useIsDefaultDelegationRolesForChildAccount(); const { data: accountRoles, isLoading: accountPermissionsLoading } = useAccountRoles(); - const { data: assignedRoles } = useUserRoles(username ?? ''); + const { data: assignedUserRoles } = useUserRoles( + username ?? '', + !isDefaultDelegationRolesForChildAccount + ); + + const { data: delegateDefaultRoles } = useGetDefaultDelegationAccessQuery({ + enabled: isDefaultDelegationRolesForChildAccount, + }); + + const assignedRoles = isDefaultDelegationRolesForChildAccount + ? delegateDefaultRoles + : assignedUserRoles; - const { mutateAsync: updateUserRoles } = useUserRolesMutation(username); + const { mutateAsync: updateUserRoles } = useUserRolesMutation(username ?? ''); + + const { mutateAsync: updateDefaultDelegationRoles } = + useUpdateDefaultDelegationAccessQuery(); // filtered roles by entity_type and access const allRoles = React.useMemo(() => { @@ -62,10 +84,30 @@ export const ChangeRoleForEntityDrawer = ({ return []; } - return getAllRoles(accountRoles).filter( - (el) => el.entity_type === role?.entity_type && el.access === role?.access - ); - }, [accountRoles, role]); + return getAllRoles(accountRoles).filter((el) => { + const matchesRoleContext = + el.entity_type === role?.entity_type && + el.access === role?.access && + el.value !== role?.role_name; + + // Exclude account roles already assigned to the user + if (isAccountRole(el)) { + return ( + !assignedRoles?.account_access.includes(el.value) && + matchesRoleContext + ); + } + // Exclude entity roles already assigned to the user + if (isEntityRole(el)) { + return ( + !assignedRoles?.entity_access.some((entity) => + entity.roles.includes(el.value) + ) && matchesRoleContext + ); + } + return true; + }); + }, [accountRoles, role, assignedRoles]); const { control, @@ -93,6 +135,10 @@ export const ChangeRoleForEntityDrawer = ({ return getRoleByName(accountRoles, selectedOptions.value); }, [selectedOptions, accountRoles]); + const mutationFn = isDefaultDelegationRolesForChildAccount + ? updateDefaultDelegationRoles + : updateUserRoles; + const onSubmit = async (data: { roleName: ExtendedEntityRole }) => { if (role?.role_name === data.roleName.label) { handleClose(); @@ -112,11 +158,15 @@ export const ChangeRoleForEntityDrawer = ({ newRole ); - await updateUserRoles({ + await mutationFn({ ...assignedRoles!, entity_access: updatedEntityRoles, }); + enqueueSnackbar(`Role changed`, { + variant: 'success', + }); + handleClose(); } catch (errors) { for (const error of errors) { diff --git a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.test.tsx b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.test.tsx index ecd6448b229..1bf10ef230e 100644 --- a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.test.tsx +++ b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.test.tsx @@ -14,19 +14,25 @@ const queryMocks = vi.hoisted(() => ({ useParams: vi.fn().mockReturnValue({}), useAccountRoles: vi.fn().mockReturnValue({}), useUserRoles: vi.fn().mockReturnValue({}), + useGetDefaultDelegationAccessQuery: vi.fn().mockReturnValue({}), + useIsDefaultDelegationRolesForChildAccount: vi.fn().mockReturnValue({ + isDefaultDelegationRolesForChildAccount: false, + }), })); vi.mock('@linode/queries', async () => { - const actual = await vi.importActual('@linode/queries'); + const actual = await vi.importActual('@linode/queries'); return { ...actual, useAccountRoles: queryMocks.useAccountRoles, useUserRoles: queryMocks.useUserRoles, + useGetDefaultDelegationAccessQuery: + queryMocks.useGetDefaultDelegationAccessQuery, }; }); vi.mock('src/queries/entities/entities', async () => { - const actual = await vi.importActual('src/queries/entities/entities'); + const actual = await vi.importActual('src/queries/entities/entities'); return { ...actual, useAllAccountEntities: queryMocks.useAllAccountEntities, @@ -41,6 +47,11 @@ vi.mock('@tanstack/react-router', async () => { }; }); +vi.mock('../../hooks/useDelegationRole', () => ({ + useIsDefaultDelegationRolesForChildAccount: + queryMocks.useIsDefaultDelegationRolesForChildAccount, +})); + const mockEntities = [ accountEntityFactory.build({ id: 7, @@ -53,6 +64,9 @@ const mockEntities = [ }), ]; +const mockUserRoles = userRolesFactory.build(); +const mockAccountRoles = accountRolesFactory.build(); + describe('AssignedRolesTable', () => { beforeEach(() => { queryMocks.useParams.mockReturnValue({ @@ -72,11 +86,11 @@ describe('AssignedRolesTable', () => { it('should display roles and menu when data is available', async () => { queryMocks.useUserRoles.mockReturnValue({ - data: userRolesFactory.build(), + data: mockUserRoles, }); queryMocks.useAccountRoles.mockReturnValue({ - data: accountRolesFactory.build(), + data: mockAccountRoles, }); queryMocks.useAllAccountEntities.mockReturnValue({ @@ -100,11 +114,11 @@ describe('AssignedRolesTable', () => { it('should display empty state when no roles match filters', async () => { queryMocks.useUserRoles.mockReturnValue({ - data: userRolesFactory.build(), + data: mockUserRoles, }); queryMocks.useAccountRoles.mockReturnValue({ - data: accountRolesFactory.build(), + data: mockAccountRoles, }); queryMocks.useAllAccountEntities.mockReturnValue({ @@ -123,11 +137,11 @@ describe('AssignedRolesTable', () => { it('should filter roles based on search query', async () => { queryMocks.useUserRoles.mockReturnValue({ - data: userRolesFactory.build(), + data: mockUserRoles, }); queryMocks.useAccountRoles.mockReturnValue({ - data: accountRolesFactory.build(), + data: mockAccountRoles, }); queryMocks.useAllAccountEntities.mockReturnValue({ @@ -146,11 +160,11 @@ describe('AssignedRolesTable', () => { it('should filter roles based on selected resource type', async () => { queryMocks.useUserRoles.mockReturnValue({ - data: userRolesFactory.build(), + data: mockUserRoles, }); queryMocks.useAccountRoles.mockReturnValue({ - data: accountRolesFactory.build(), + data: mockAccountRoles, }); queryMocks.useAllAccountEntities.mockReturnValue({ @@ -166,4 +180,27 @@ describe('AssignedRolesTable', () => { expect(screen.queryByText('account_firewall_creator')).toBeVisible(); }); }); + + it('should show different button text for default roles view', async () => { + queryMocks.useIsDefaultDelegationRolesForChildAccount.mockReturnValue({ + isDefaultDelegationRolesForChildAccount: true, + }); + + queryMocks.useGetDefaultDelegationAccessQuery.mockReturnValue({ + data: mockUserRoles, + }); + + queryMocks.useAccountRoles.mockReturnValue({ + data: mockAccountRoles, + }); + + queryMocks.useAllAccountEntities.mockReturnValue({ + data: mockEntities, + }); + + renderWithTheme(); + + expect(screen.getByText('Add New Default Roles')).toBeVisible(); + expect(screen.queryByText('Assign New Roles')).not.toBeInTheDocument(); + }); }); diff --git a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.tsx b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.tsx index cb20cbaac04..c071ff0803d 100644 --- a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.tsx +++ b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.tsx @@ -1,4 +1,8 @@ -import { useAccountRoles, useUserRoles } from '@linode/queries'; +import { + useAccountRoles, + useGetDefaultDelegationAccessQuery, + useUserRoles, +} from '@linode/queries'; import { Button, CircleProgress, Select, Typography } from '@linode/ui'; import { useTheme } from '@mui/material'; import Grid from '@mui/material/Grid'; @@ -17,6 +21,7 @@ import { TableSortCell } from 'src/components/TableSortCell/TableSortCell'; import { usePaginationV2 } from 'src/hooks/usePaginationV2'; import { useAllAccountEntities } from 'src/queries/entities/entities'; +import { useIsDefaultDelegationRolesForChildAccount } from '../../hooks/useDelegationRole'; import { usePermissions } from '../../hooks/usePermissions'; import { AssignedEntities } from '../../Users/UserRoles/AssignedEntities'; import { AssignNewRoleDrawer } from '../../Users/UserRoles/AssignNewRoleDrawer'; @@ -65,9 +70,8 @@ const ALL_ROLES_OPTION: SelectOption = { label: 'All Assigned Roles', value: 'all', }; - export const AssignedRolesTable = () => { - const { username } = useParams({ from: '/iam/users/$username' }); + const { username } = useParams({ strict: false }); const navigate = useNavigate(); const theme = useTheme(); @@ -76,8 +80,30 @@ export const AssignedRolesTable = () => { const [isInitialLoad, setIsInitialLoad] = React.useState(true); const { data: permissions } = usePermissions('account', ['is_account_admin']); + // Determine if we're on the default roles view based on delegation role and path + const { isDefaultDelegationRolesForChildAccount } = + useIsDefaultDelegationRolesForChildAccount(); + + const { data: defaultRolesData, isLoading: defaultRolesLoading } = + useGetDefaultDelegationAccessQuery({ + enabled: isDefaultDelegationRolesForChildAccount, + }); + + const { data: userRolesData, isLoading: userRolesLoading } = useUserRoles( + username ?? '', + !isDefaultDelegationRolesForChildAccount + ); + + const assignedRoles = isDefaultDelegationRolesForChildAccount + ? defaultRolesData + : userRolesData; + const assignedRolesLoading = isDefaultDelegationRolesForChildAccount + ? defaultRolesLoading + : userRolesLoading; const pagination = usePaginationV2({ - currentRoute: '/iam/users/$username/roles', + currentRoute: isDefaultDelegationRolesForChildAccount + ? '/iam/roles/defaults/roles' + : '/iam/users/$username/roles', initialPage: 1, preferenceKey: ASSIGNED_ROLES_TABLE_PREFERENCE_KEY, }); @@ -139,9 +165,6 @@ export const AssignedRolesTable = () => { {} ); - const { data: assignedRoles, isLoading: assignedRolesLoading } = useUserRoles( - username ?? '' - ); const { filterableOptions, roles } = React.useMemo(() => { if (!assignedRoles || !accountRoles) { return { filterableOptions: [], roles: [] }; @@ -173,8 +196,10 @@ export const AssignedRolesTable = () => { const handleViewEntities = (roleName: AccountRoleType | EntityRoleType) => { const selectedRole = roleName; navigate({ - to: '/iam/users/$username/entities', - params: { username }, + to: isDefaultDelegationRolesForChildAccount + ? '/iam/roles/defaults/entity-access' + : '/iam/users/$username/entities', + params: { username: username || '' }, search: { selectedRole }, }); }; @@ -388,7 +413,9 @@ export const AssignedRolesTable = () => { : undefined } > - Assign New Roles + {isDefaultDelegationRolesForChildAccount + ? 'Add New Default Roles' + : 'Assign New Roles'} @@ -424,6 +451,7 @@ export const AssignedRolesTable = () => { onClose={() => setIsRemoveAssignmentDialogOpen(false)} open={isRemoveAssignmentDialogOpen} role={selectedRoleDetails} + username={username} /> {filteredAndSortedRolesCount > PAGE_SIZES[0] && ( { const theme = useTheme(); - const { username } = useParams({ from: '/iam/users/$username' }); - + const { username } = useParams({ strict: false }); const { data: accountRoles, isLoading: accountPermissionsLoading } = useAccountRoles(); - const { data: assignedRoles } = useUserRoles(username ?? ''); + const { isDefaultDelegationRolesForChildAccount } = + useIsDefaultDelegationRolesForChildAccount(); + const { data: defaultRolesData } = useGetDefaultDelegationAccessQuery({ + enabled: isDefaultDelegationRolesForChildAccount, + }); + const { data: userRolesData } = useUserRoles( + username ?? '', + !isDefaultDelegationRolesForChildAccount + ); + + const assignedRoles = isDefaultDelegationRolesForChildAccount + ? defaultRolesData + : userRolesData; const { mutateAsync: updateUserRoles } = useUserRolesMutation(username); + const { mutateAsync: updateDefaultRoles } = + useUpdateDefaultDelegationAccessQuery(); + + const mutationFn = isDefaultDelegationRolesForChildAccount + ? updateDefaultRoles + : updateUserRoles; const formattedAssignedEntities: EntitiesOption[] = React.useMemo(() => { if (!role || !role.entity_names || !role.entity_ids) { return []; @@ -132,7 +152,7 @@ export const ChangeRoleDrawer = ({ mode, onClose, open, role }: Props) => { newRole, }); - await updateUserRoles(updatedUserRoles); + await mutationFn(updatedUserRoles); handleClose(); } catch (errors) { diff --git a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/UnassignRoleConfirmationDialog.test.tsx b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/UnassignRoleConfirmationDialog.test.tsx index 5632c5bcdf5..a6dcfefdccd 100644 --- a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/UnassignRoleConfirmationDialog.test.tsx +++ b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/UnassignRoleConfirmationDialog.test.tsx @@ -28,7 +28,7 @@ const props = { }; const queryMocks = vi.hoisted(() => ({ - useParams: vi.fn().mockReturnValue({}), + useParams: vi.fn().mockReturnValue({ username: 'test_user' }), useAccountRoles: vi.fn().mockReturnValue({}), useUserRoles: vi.fn().mockReturnValue({}), })); diff --git a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/UnassignRoleConfirmationDialog.tsx b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/UnassignRoleConfirmationDialog.tsx index 12d37c6f68a..6a568d48506 100644 --- a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/UnassignRoleConfirmationDialog.tsx +++ b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/UnassignRoleConfirmationDialog.tsx @@ -1,4 +1,9 @@ -import { useUserRoles, useUserRolesMutation } from '@linode/queries'; +import { + useGetDefaultDelegationAccessQuery, + useUpdateDefaultDelegationAccessQuery, + useUserRoles, + useUserRolesMutation, +} from '@linode/queries'; import { ActionsPanel, Notice, Typography } from '@linode/ui'; import { useParams } from '@tanstack/react-router'; import { useSnackbar } from 'notistack'; @@ -6,6 +11,7 @@ import React from 'react'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; +import { useIsDefaultDelegationRolesForChildAccount } from '../../hooks/useDelegationRole'; import { deleteUserRole, getErrorMessage } from '../utilities'; import type { ExtendedRoleView } from '../types'; @@ -19,10 +25,22 @@ interface Props { export const UnassignRoleConfirmationDialog = (props: Props) => { const { onClose: _onClose, onSuccess, open, role } = props; - const { username } = useParams({ from: '/iam/users/$username' }); - const { enqueueSnackbar } = useSnackbar(); + const { username } = useParams({ strict: false }); + const { isDefaultDelegationRolesForChildAccount } = + useIsDefaultDelegationRolesForChildAccount(); + const { data: defaultRolesData } = useGetDefaultDelegationAccessQuery({ + enabled: isDefaultDelegationRolesForChildAccount, + }); + + const { data: userRolesData } = useUserRoles( + username ?? '', + !isDefaultDelegationRolesForChildAccount + ); + const assignedRoles = isDefaultDelegationRolesForChildAccount + ? defaultRolesData + : userRolesData; const { error, isPending, @@ -30,7 +48,12 @@ export const UnassignRoleConfirmationDialog = (props: Props) => { reset, } = useUserRolesMutation(username); - const { data: assignedRoles } = useUserRoles(username ?? ''); + const { mutateAsync: updateDefaultRoles, isPending: isDefaultRolesPending } = + useUpdateDefaultDelegationAccessQuery(); + + const mutationFn = isDefaultDelegationRolesForChildAccount + ? updateDefaultRoles + : updateUserRoles; const onClose = () => { reset(); // resets the error state of the useMutation @@ -47,7 +70,7 @@ export const UnassignRoleConfirmationDialog = (props: Props) => { initialRole, }); - await updateUserRoles(updatedUserRoles); + await mutationFn(updatedUserRoles); enqueueSnackbar(`Role ${role?.name} has been deleted successfully.`, { variant: 'success', @@ -64,7 +87,7 @@ export const UnassignRoleConfirmationDialog = (props: Props) => { { const theme = useTheme(); + const { username } = useParams({ strict: false }); + const { isDefaultDelegationRolesForChildAccount } = + useIsDefaultDelegationRolesForChildAccount(); + const { data: defaultRolesData } = useGetDefaultDelegationAccessQuery({ + enabled: isDefaultDelegationRolesForChildAccount, + }); - const { username } = useParams({ from: '/iam/users/$username' }); - - const { data: assignedRoles } = useUserRoles(username ?? ''); + const { data: userRolesData } = useUserRoles( + username, + !isDefaultDelegationRolesForChildAccount + ); + const assignedRoles = isDefaultDelegationRolesForChildAccount + ? defaultRolesData + : userRolesData; const { mutateAsync: updateUserRoles } = useUserRolesMutation(username); + const { mutateAsync: updateDefaultRoles } = + useUpdateDefaultDelegationAccessQuery(); + + const mutationFn = isDefaultDelegationRolesForChildAccount + ? updateDefaultRoles + : updateUserRoles; + const formattedAssignedEntities: EntitiesOption[] = React.useMemo(() => { if (!role || !role.entity_names || !role.entity_ids) { return []; @@ -85,7 +108,7 @@ export const UpdateEntitiesDrawer = ({ onClose, open, role }: Props) => { role!.entity_type ); - await updateUserRoles({ + await mutationFn({ ...assignedRoles!, entity_access: entityAccess, }); diff --git a/packages/manager/src/features/IAM/Shared/Entities/utils.ts b/packages/manager/src/features/IAM/Shared/Entities/utils.ts index 6f970b1ecde..aab965e3fd6 100644 --- a/packages/manager/src/features/IAM/Shared/Entities/utils.ts +++ b/packages/manager/src/features/IAM/Shared/Entities/utils.ts @@ -3,6 +3,8 @@ import { groupAccountEntitiesByType } from '../utilities'; import type { EntitiesOption } from '../types'; import type { AccessType, AccountEntity, EntityType } from '@linode/api-v4'; +type PlaceholderType = 'delegates' | AccessType; + export const placeholderMap: Record = { account: 'Select Account', database: 'Select Databases', @@ -17,6 +19,7 @@ export const placeholderMap: Record = { stackscript: 'Select Stackscripts', volume: 'Select Volumes', vpc: 'Select VPCs', + delegates: 'Select Users', }; export const getCreateLinkForEntityType = (entityType: AccessType): string => { @@ -34,7 +37,7 @@ export const getCreateLinkForEntityType = (entityType: AccessType): string => { }; export const getPlaceholder = ( - type: AccessType, + type: PlaceholderType, currentValueLength: number, possibleEntitiesLength: number ): string => { diff --git a/packages/manager/src/features/IAM/Shared/Permissions/Permissions.style.ts b/packages/manager/src/features/IAM/Shared/Permissions/Permissions.style.ts index 1958819a40f..1b482c5f822 100644 --- a/packages/manager/src/features/IAM/Shared/Permissions/Permissions.style.ts +++ b/packages/manager/src/features/IAM/Shared/Permissions/Permissions.style.ts @@ -4,7 +4,7 @@ import { styled } from '@mui/material/styles'; export const StyledTitle = styled(Typography, { label: 'StyledTitle' })( ({ theme }) => ({ font: theme.tokens.alias.Typography.Label.Bold.S, - marginBottom: theme.tokens.spacing.S4, + marginBottom: theme.tokens.spacing.S8, }) ); @@ -12,5 +12,5 @@ export const StyledPermissionItem = styled('span', { label: 'StyledPermissionItem', })(({ theme }) => ({ display: 'inline-block', - padding: `0px ${theme.tokens.spacing.S6} ${theme.tokens.spacing.S2}`, + padding: `0px ${theme.tokens.spacing.S6} ${theme.tokens.spacing.S4}`, })); diff --git a/packages/manager/src/features/IAM/Shared/RemoveAssignmentConfirmationDialog/RemoveAssignmentConfirmationDialog.test.tsx b/packages/manager/src/features/IAM/Shared/RemoveAssignmentConfirmationDialog/RemoveAssignmentConfirmationDialog.test.tsx index b54cc27ed1c..8ebc5444dac 100644 --- a/packages/manager/src/features/IAM/Shared/RemoveAssignmentConfirmationDialog/RemoveAssignmentConfirmationDialog.test.tsx +++ b/packages/manager/src/features/IAM/Shared/RemoveAssignmentConfirmationDialog/RemoveAssignmentConfirmationDialog.test.tsx @@ -23,12 +23,20 @@ const props = { onSuccess: vi.fn(), open: true, role: mockRole, + username: 'test_user', }; const queryMocks = vi.hoisted(() => ({ - useParams: vi.fn().mockReturnValue({}), useAccountRoles: vi.fn().mockReturnValue({}), useUserRoles: vi.fn().mockReturnValue({}), + useIsDefaultDelegationRolesForChildAccount: vi + .fn() + .mockReturnValue({ isDefaultDelegationRolesForChildAccount: false }), +})); + +vi.mock('src/features/IAM/hooks/useDelegationRole', () => ({ + useIsDefaultDelegationRolesForChildAccount: + queryMocks.useIsDefaultDelegationRolesForChildAccount, })); vi.mock('@linode/queries', async () => { @@ -40,14 +48,6 @@ vi.mock('@linode/queries', async () => { }; }); -vi.mock('@tanstack/react-router', async () => { - const actual = await vi.importActual('@tanstack/react-router'); - return { - ...actual, - useParams: queryMocks.useParams, - }; -}); - const mockDeleteUserRole = vi.fn(); vi.mock('@linode/api-v4', async () => { return { @@ -60,14 +60,10 @@ vi.mock('@linode/api-v4', async () => { }); describe('RemoveAssignmentConfirmationDialog', () => { - beforeEach(() => { - queryMocks.useParams.mockReturnValue({ - username: 'test_user', - }); - }); - it('should render', async () => { - renderWithTheme(); + renderWithTheme( + + ); const headerText = screen.getByText( 'Remove the Test entity from the firewall_admin role assignment?' @@ -131,4 +127,22 @@ describe('RemoveAssignmentConfirmationDialog', () => { }); }); }); + + it('should render when isDefaultDelegationRolesForChildAccount is true', async () => { + queryMocks.useIsDefaultDelegationRolesForChildAccount.mockReturnValue({ + isDefaultDelegationRolesForChildAccount: true, + }); + renderWithTheme(); + + const headerText = screen.getByText( + 'Remove the Test entity from the list?' + ); + expect(headerText).toBeVisible(); + + const paragraph = screen.getByText(/Delegated users won’t get the/i); + + expect(paragraph).toBeVisible(); + expect(paragraph).toHaveTextContent(mockRole.entity_name); + expect(paragraph).toHaveTextContent(mockRole.role_name); + }); }); diff --git a/packages/manager/src/features/IAM/Shared/RemoveAssignmentConfirmationDialog/RemoveAssignmentConfirmationDialog.tsx b/packages/manager/src/features/IAM/Shared/RemoveAssignmentConfirmationDialog/RemoveAssignmentConfirmationDialog.tsx index 4531f0be1cf..6eff0ef3bbc 100644 --- a/packages/manager/src/features/IAM/Shared/RemoveAssignmentConfirmationDialog/RemoveAssignmentConfirmationDialog.tsx +++ b/packages/manager/src/features/IAM/Shared/RemoveAssignmentConfirmationDialog/RemoveAssignmentConfirmationDialog.tsx @@ -1,11 +1,16 @@ -import { useUserRoles, useUserRolesMutation } from '@linode/queries'; +import { + useGetDefaultDelegationAccessQuery, + useUpdateDefaultDelegationAccessQuery, + useUserRoles, + useUserRolesMutation, +} from '@linode/queries'; import { ActionsPanel, Notice, Typography } from '@linode/ui'; -import { useParams } from '@tanstack/react-router'; import { useSnackbar } from 'notistack'; import React from 'react'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; +import { useIsDefaultDelegationRolesForChildAccount } from '../../hooks/useDelegationRole'; import { deleteUserEntity, getErrorMessage } from '../utilities'; import type { EntitiesRole } from '../types'; @@ -15,11 +20,14 @@ interface Props { onSuccess?: () => void; open: boolean; role: EntitiesRole | undefined; + username?: string; } export const RemoveAssignmentConfirmationDialog = (props: Props) => { - const { onClose: _onClose, onSuccess, open, role } = props; - const { username } = useParams({ from: '/iam/users/$username' }); + const { onClose: _onClose, onSuccess, open, role, username } = props; + + const { isDefaultDelegationRolesForChildAccount } = + useIsDefaultDelegationRolesForChildAccount(); const { enqueueSnackbar } = useSnackbar(); @@ -28,15 +36,33 @@ export const RemoveAssignmentConfirmationDialog = (props: Props) => { isPending, mutateAsync: updateUserRoles, reset, - } = useUserRolesMutation(username); + } = useUserRolesMutation(username ?? ''); + + const { mutateAsync: updateDefaultDelegationRoles } = + useUpdateDefaultDelegationAccessQuery(); + + const { data: assignedUserRoles } = useUserRoles( + username ?? '', + !isDefaultDelegationRolesForChildAccount + ); - const { data: assignedRoles } = useUserRoles(username ?? ''); + const { data: delegateDefaultRoles } = useGetDefaultDelegationAccessQuery({ + enabled: isDefaultDelegationRolesForChildAccount, + }); const onClose = () => { reset(); // resets the error state of the useMutation _onClose(); }; + const mutationFn = isDefaultDelegationRolesForChildAccount + ? updateDefaultDelegationRoles + : updateUserRoles; + + const assignedRoles = isDefaultDelegationRolesForChildAccount + ? delegateDefaultRoles + : assignedUserRoles; + const onDelete = async () => { if (!role || !assignedRoles) return; @@ -49,7 +75,7 @@ export const RemoveAssignmentConfirmationDialog = (props: Props) => { entity_type ); - await updateUserRoles({ + await mutationFn({ ...assignedRoles, entity_access: updatedUserEntityRoles, }); @@ -81,14 +107,26 @@ export const RemoveAssignmentConfirmationDialog = (props: Props) => { error={getErrorMessage(error)} onClose={onClose} open={open} - title={`Remove the ${role?.entity_name} entity from the ${role?.role_name} role assignment?`} + title={ + isDefaultDelegationRolesForChildAccount + ? `Remove the ${role?.entity_name} entity from the list?` + : `Remove the ${role?.entity_name} entity from the ${role?.role_name} role assignment?` + } > - - You’re about to remove the {role?.entity_name} entity - from the {role?.role_name} role for{' '} - {username}. This change will be applied immediately. - + {isDefaultDelegationRolesForChildAccount ? ( + + Delegated users won’t get the {role?.role_name} access on the{' '} + {role?.entity_name} entity by default. + + ) : ( + + You’re about to remove the {role?.entity_name}{' '} + entity from the {role?.role_name} role for{' '} + {username}. This change will be applied + immediately. + + )} ); diff --git a/packages/manager/src/features/IAM/Shared/constants.ts b/packages/manager/src/features/IAM/Shared/constants.ts index 6525d064472..6601da3840f 100644 --- a/packages/manager/src/features/IAM/Shared/constants.ts +++ b/packages/manager/src/features/IAM/Shared/constants.ts @@ -11,10 +11,15 @@ export const INTERNAL_ERROR_NO_CHANGES_SAVED = `Internal Error. No changes were export const LAST_ACCOUNT_ADMIN_ERROR = 'Failed to unassign the role. You need to have at least one user with the account_admin role on your account.'; -export const NO_DELEGATIONS_TEXT = 'No delegate users found.'; + export const ERROR_STATE_TEXT = 'An unexpected error occurred. Refresh the page or try again later.'; +// Delegation error messages +export const NO_DELEGATIONS_TEXT = 'No delegate users found.'; +export const DELEGATION_VALIDATION_ERROR = + 'At least one user must be selected as a delegate.'; + // Links export const IAM_DOCS_LINK = 'https://techdocs.akamai.com/cloud-computing/docs/identity-and-access-cm'; @@ -44,6 +49,3 @@ export const ROLES_TABLE_PREFERENCE_KEY = 'roles'; export const ENTITIES_TABLE_PREFERENCE_KEY = 'entities'; export const ASSIGNED_ROLES_TABLE_PREFERENCE_KEY = 'assigned-roles'; - -export const ACCOUNT_DELEGATIONS_TABLE_PREFERENCE_KEY = - 'iam-account-delegations'; diff --git a/packages/manager/src/features/IAM/Users/UserDelegations/UserDelegations.test.tsx b/packages/manager/src/features/IAM/Users/UserDelegations/UserDelegations.test.tsx index 167a7cf4c52..83e805ea987 100644 --- a/packages/manager/src/features/IAM/Users/UserDelegations/UserDelegations.test.tsx +++ b/packages/manager/src/features/IAM/Users/UserDelegations/UserDelegations.test.tsx @@ -21,6 +21,7 @@ const mockChildAccounts = [ const queryMocks = vi.hoisted(() => ({ useAllGetDelegatedChildAccountsForUserQuery: vi.fn().mockReturnValue({}), useParams: vi.fn().mockReturnValue({}), + useSearch: vi.fn().mockReturnValue({}), })); vi.mock('@linode/queries', async () => { @@ -37,6 +38,7 @@ vi.mock('@tanstack/react-router', async () => { return { ...actual, useParams: queryMocks.useParams, + useSearch: queryMocks.useSearch, }; }); @@ -49,6 +51,9 @@ describe('UserDelegations', () => { data: mockChildAccounts, isLoading: false, }); + queryMocks.useSearch.mockReturnValue({ + query: '', + }); }); it('renders the correct number of child accounts', () => { diff --git a/packages/manager/src/features/IAM/Users/UserDelegations/UserDelegations.tsx b/packages/manager/src/features/IAM/Users/UserDelegations/UserDelegations.tsx index 36502670ecf..557f1904adc 100644 --- a/packages/manager/src/features/IAM/Users/UserDelegations/UserDelegations.tsx +++ b/packages/manager/src/features/IAM/Users/UserDelegations/UserDelegations.tsx @@ -6,7 +6,7 @@ import { Stack, Typography, } from '@linode/ui'; -import { useParams } from '@tanstack/react-router'; +import { useNavigate, useParams, useSearch } from '@tanstack/react-router'; import * as React from 'react'; import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField'; @@ -28,7 +28,10 @@ import type { Theme } from '@mui/material'; export const UserDelegations = () => { const { username } = useParams({ from: '/iam/users/$username' }); const { isIAMDelegationEnabled } = useIsIAMDelegationEnabled(); - const [search, setSearch] = React.useState(''); + const { query } = useSearch({ + from: '/iam/users/$username/delegations', + }); + const navigate = useNavigate(); // TODO: UIE-9298 - Replace with API filtering const { @@ -40,7 +43,12 @@ export const UserDelegations = () => { }); const handleSearch = (value: string) => { - setSearch(value); + pagination.handlePageChange(1); + navigate({ + to: '/iam/users/$username/delegations', + params: { username }, + search: { query: value || undefined }, + }); }; const childAccounts = React.useMemo(() => { @@ -48,14 +56,14 @@ export const UserDelegations = () => { return []; } - if (search.length === 0) { + if (query?.trim() === '') { return allDelegatedChildAccounts; } return allDelegatedChildAccounts.filter((childAccount) => - childAccount.company.toLowerCase().includes(search.toLowerCase()) + childAccount.company.toLowerCase().includes(query?.toLowerCase() ?? '') ); - }, [allDelegatedChildAccounts, search]); + }, [allDelegatedChildAccounts, query]); const { handleOrderChange, order, orderBy, sortedData } = useOrderV2({ data: childAccounts, @@ -92,6 +100,7 @@ export const UserDelegations = () => { Account Delegations { onSearch={handleSearch} placeholder="Search" sx={{ mt: 3 }} - value={search} + value={query ?? ''} />
    diff --git a/packages/manager/src/features/IAM/Users/UserDetailsLanding.tsx b/packages/manager/src/features/IAM/Users/UserDetailsLanding.tsx index 23ed63b0a9f..3fcd1a070bb 100644 --- a/packages/manager/src/features/IAM/Users/UserDetailsLanding.tsx +++ b/packages/manager/src/features/IAM/Users/UserDetailsLanding.tsx @@ -1,4 +1,5 @@ -import { Outlet, useParams } from '@tanstack/react-router'; +import { Chip, styled } from '@linode/ui'; +import { Outlet, useLoaderData, useParams } from '@tanstack/react-router'; import React from 'react'; import { LandingHeader } from 'src/components/LandingHeader'; @@ -20,11 +21,15 @@ export const UserDetailsLanding = () => { const { username } = useParams({ from: '/iam/users/$username' }); const { isIAMDelegationEnabled } = useIsIAMDelegationEnabled(); const { isParentAccount } = useDelegationRole(); + const { isDelegateUserForChildAccount } = useLoaderData({ + from: '/iam/users/$username', + }); const { tabs, tabIndex, handleTabChange } = useTabs([ { to: `/iam/users/$username/details`, title: 'User Details', + hide: isDelegateUserForChildAccount, }, { to: `/iam/users/$username/roles`, @@ -56,6 +61,9 @@ export const UserDetailsLanding = () => { ], labelOptions: { noCap: true, + suffixComponent: isDelegateUserForChildAccount ? ( + + ) : null, }, pathname: location.pathname, }} @@ -73,3 +81,14 @@ export const UserDetailsLanding = () => { ); }; + +const StyledChip = styled(Chip, { + label: 'StyledChip', +})(({ theme }) => ({ + textTransform: theme.tokens.font.Textcase.Uppercase, + marginLeft: theme.spacingFunction(4), + color: theme.tokens.component.Badge.Informative.Subtle.Text, + backgroundColor: theme.tokens.component.Badge.Informative.Subtle.Background, + font: theme.font.extrabold, + fontSize: theme.tokens.font.FontSize.Xxxs, +})); diff --git a/packages/manager/src/features/IAM/Users/UserEntities/UserEntities.tsx b/packages/manager/src/features/IAM/Users/UserEntities/UserEntities.tsx index b203ace51b3..0b469b44a89 100644 --- a/packages/manager/src/features/IAM/Users/UserEntities/UserEntities.tsx +++ b/packages/manager/src/features/IAM/Users/UserEntities/UserEntities.tsx @@ -13,12 +13,12 @@ import React from 'react'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { usePermissions } from '../../hooks/usePermissions'; +import { AssignedEntitiesTable } from '../../Shared/AssignedEntitiesTable/AssignedEntitiesTable'; import { ERROR_STATE_TEXT, NO_ASSIGNED_ENTITIES_TEXT, } from '../../Shared/constants'; import { NoAssignedRoles } from '../../Shared/NoAssignedRoles/NoAssignedRoles'; -import { AssignedEntitiesTable } from './AssignedEntitiesTable'; export const UserEntities = () => { const theme = useTheme(); @@ -71,7 +71,7 @@ export const UserEntities = () => { View and manage entities attached to user's entity access roles. - + ) : ( { const theme = useTheme(); - const { username } = useParams({ - from: '/iam/users/$username', - }); const queryClient = useQueryClient(); - + const { username } = useParams({ strict: false }); const { data: accountRoles } = useAccountRoles(); - + const { isDefaultDelegationRolesForChildAccount } = + useIsDefaultDelegationRolesForChildAccount(); const form = useForm({ defaultValues: { roles: [ @@ -95,23 +96,38 @@ export const AssignNewRoleDrawer = ({ return true; }); - }, [accountRoles, assignedRoles, roles]); + }, [accountRoles, assignedRoles]); - const { mutateAsync: updateUserRoles, isPending } = + const { mutateAsync: updateUserRoles, isPending: isUserRolesPending } = useUserRolesMutation(username); + const { mutateAsync: updateDefaultRoles, isPending: isDefaultRolesPending } = + useUpdateDefaultDelegationAccessQuery(); + const onSubmit = async (values: AssignNewRoleFormValues) => { try { - const queryKey = iamQueries.user(username)._ctx.roles.queryKey; - const currentRoles = queryClient.getQueryData(queryKey); - - const mergedRoles = mergeAssignedRolesIntoExistingRoles( - values, - structuredClone(currentRoles) - ); - - await updateUserRoles(mergedRoles); - + if (isDefaultDelegationRolesForChildAccount) { + const currentDefaultRoles = queryClient.getQueryData( + delegationQueries.defaultAccess.queryKey + ); + const mergedDefaultRoles = mergeAssignedRolesIntoExistingRoles( + values, + structuredClone(currentDefaultRoles) + ); + await updateDefaultRoles(mergedDefaultRoles); + } else { + if (!username) { + return; + } + const queryKey = iamQueries.user(username ?? '')._ctx.roles.queryKey; + const currentRoles = queryClient.getQueryData(queryKey); + + const mergedRoles = mergeAssignedRolesIntoExistingRoles( + values, + structuredClone(currentRoles) + ); + await updateUserRoles(mergedRoles); + } enqueueSnackbar(`Roles added.`, { variant: 'success' }); handleClose(); } catch (error) { @@ -135,7 +151,15 @@ export const AssignNewRoleDrawer = ({ }, [open, reset]); return ( - +
    {formState.errors.root?.message && ( @@ -143,9 +167,9 @@ export const AssignNewRoleDrawer = ({ )} - Select a role you want to assign to a user. Some roles require - selecting entities they should apply to. Configure the first role - and continue adding roles or save the assignment.{' '} + {isDefaultDelegationRolesForChildAccount + ? 'Select roles to be assigned to new delegate users by default. Some roles require selecting entities they should apply to. Configure the first role and continue adding roles or save the assignment.' + : 'Select a role you want to assign to a user. Some roles require selecting entities they should apply to. Configure the first role and continue adding roles or save the assignment.'}{' '} Learn more about roles and permissions @@ -195,9 +219,14 @@ export const AssignNewRoleDrawer = ({ { {canViewUser ? ( - + {user.username} ) : ( diff --git a/packages/manager/src/features/IAM/hooks/useDelegationRole.ts b/packages/manager/src/features/IAM/hooks/useDelegationRole.ts index 694f0413188..0c66195d7e6 100644 --- a/packages/manager/src/features/IAM/hooks/useDelegationRole.ts +++ b/packages/manager/src/features/IAM/hooks/useDelegationRole.ts @@ -1,4 +1,7 @@ import { useProfile } from '@linode/queries'; +import { useLocation } from '@tanstack/react-router'; + +import { useIsIAMDelegationEnabled } from './useIsIAMEnabled'; import type { UserType } from '@linode/api-v4'; @@ -25,3 +28,26 @@ export const useDelegationRole = (): DelegationRole => { profileUserName: profile?.username, }; }; + +/** + * isDefaultDelegationRolesForChildAccount is true if: + * - IAM Delegation is enabled for the account + * - The current user is a child account + * - The current route includes '/iam/roles/defaults' + * + * This flag is used to determine if the component should show or fetch/update delegated default roles + * instead of regular user roles, and to adjust UI/logic for the delegate context. + */ +export const useIsDefaultDelegationRolesForChildAccount = () => { + const { isIAMDelegationEnabled } = useIsIAMDelegationEnabled(); + const { isChildAccount } = useDelegationRole(); + const location = useLocation(); + + return { + isDefaultDelegationRolesForChildAccount: + (isIAMDelegationEnabled && + isChildAccount && + location.pathname.includes('/iam/roles/defaults')) ?? + false, + }; +}; diff --git a/packages/manager/src/features/IAM/hooks/useIsIAMEnabled.ts b/packages/manager/src/features/IAM/hooks/useIsIAMEnabled.ts index c7e17fc8682..098c164b835 100644 --- a/packages/manager/src/features/IAM/hooks/useIsIAMEnabled.ts +++ b/packages/manager/src/features/IAM/hooks/useIsIAMEnabled.ts @@ -30,6 +30,7 @@ export const useIsIAMEnabled = () => { isIAMBeta: flags.iam?.beta, isIAMEnabled: flags?.iam?.enabled && Boolean(roles || permissions), isLoading: isLoadingRoles || isLoadingPermissions, + accountRoles: roles, }; }; diff --git a/packages/manager/src/features/IAM/hooks/usePermissions.ts b/packages/manager/src/features/IAM/hooks/usePermissions.ts index b266e03ff90..df66ce638dc 100644 --- a/packages/manager/src/features/IAM/hooks/usePermissions.ts +++ b/packages/manager/src/features/IAM/hooks/usePermissions.ts @@ -240,6 +240,13 @@ export function usePermissions< export type EntityBase = Pick; +/** + * Helper function to get the permissions for a list of entities. + * Used only for restricted users who need to check permissions for each entity. + * + * ⚠️ This is a performance bottleneck for restricted users who have many entities. + * It will need to be deprecated and refactored when we add the ability to filter entities by permission(s). + */ export const useEntitiesPermissions = ( entities: T[] | undefined, entityType: EntityType, @@ -255,7 +262,7 @@ export const useEntitiesPermissions = ( entityType, entity.id ), - enabled, + enabled: enabled && Boolean(profile?.restricted), })), }); diff --git a/packages/manager/src/features/IAM/hooks/useQueryWithPermissions.test.ts b/packages/manager/src/features/IAM/hooks/useQueryWithPermissions.test.ts index 1ecd51aab28..c5ad8301161 100644 --- a/packages/manager/src/features/IAM/hooks/useQueryWithPermissions.test.ts +++ b/packages/manager/src/features/IAM/hooks/useQueryWithPermissions.test.ts @@ -264,4 +264,40 @@ describe('useQueryWithPermissions', () => { expect(result.current.isLoading).toBe(true); }); + + it('grants all permissions to unrestricted (admin) users without making permission API calls', () => { + const flags = { iam: { beta: true, enabled: true } }; + queryMocks.useIsIAMEnabled.mockReturnValue({ + isIAMEnabled: true, + isIAMBeta: true, + }); + queryMocks.useProfile.mockReturnValue({ + data: { username: 'admin-user', restricted: false }, + }); + + const { result } = renderHook( + () => + useQueryWithPermissions( + baseQueryResult, + 'linode', + ['update_linode', 'delete_linode', 'reboot_linode'], + true + ), + { wrapper: (ui) => wrapWithTheme(ui, { flags }) } + ); + + // Verify grants are NOT fetched (IAM is enabled) + expect(queryMocks.useGrants).toHaveBeenCalledWith(false); + + // Verify permission queries are disabled (no N API calls for unrestricted users!) + const queryArgs = queryMocks.useQueries.mock.calls[0][0]; + expect( + queryArgs.queries.every((q: { enabled?: boolean }) => q.enabled === false) + ).toBe(true); + + // Unrestricted users should see ALL entities + expect(result.current.data.map((e) => e.id)).toEqual([1, 2]); + expect(result.current.hasFiltered).toBe(false); + expect(result.current.isLoading).toBe(false); + }); }); diff --git a/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx b/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx index 2c29e7ee8dd..9a48643ff90 100644 --- a/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx +++ b/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx @@ -109,7 +109,7 @@ export const CreateImageTab = () => { ); const { data: linodes, isLoading } = useQueryWithPermissions( - useAllLinodesQuery(), + useAllLinodesQuery({}, {}, Boolean(imagePermissions.create_image)), 'linode', ['view_linode', 'update_linode'], Boolean(imagePermissions.create_image) diff --git a/packages/manager/src/features/Images/ImagesLanding/RebuildImageDrawer.tsx b/packages/manager/src/features/Images/ImagesLanding/RebuildImageDrawer.tsx index 6c8480a7dfa..f4366745cda 100644 --- a/packages/manager/src/features/Images/ImagesLanding/RebuildImageDrawer.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/RebuildImageDrawer.tsx @@ -24,7 +24,7 @@ export const RebuildImageDrawer = (props: Props) => { const navigate = useNavigate(); const { data: linodes, isLoading } = useQueryWithPermissions( - useAllLinodesQuery(), + useAllLinodesQuery({}, {}, open), 'linode', ['rebuild_linode', 'view_linode'], open diff --git a/packages/manager/src/features/Images/utils.test.tsx b/packages/manager/src/features/Images/utils.test.tsx index 875078cb129..bdfc503faab 100644 --- a/packages/manager/src/features/Images/utils.test.tsx +++ b/packages/manager/src/features/Images/utils.test.tsx @@ -1,8 +1,14 @@ import { linodeFactory } from '@linode/utilities'; +import { renderHook, waitFor } from '@testing-library/react'; import { eventFactory, imageFactory } from 'src/factories'; +import { wrapWithTheme } from 'src/utilities/testHelpers'; -import { getEventsForImages, getImageLabelForLinode } from './utils'; +import { + getEventsForImages, + getImageLabelForLinode, + useIsPrivateImageSharingEnabled, +} from './utils'; describe('getImageLabelForLinode', () => { it('handles finding an image and getting the label', () => { @@ -57,3 +63,29 @@ describe('getEventsForImages', () => { }); }); }); + +describe('useIsPrivateImageSharingEnabled', () => { + it('returns true if the feature is enabled', async () => { + const options = { flags: { privateImageSharing: true } }; + + const { result } = renderHook(() => useIsPrivateImageSharingEnabled(), { + wrapper: (ui) => wrapWithTheme(ui, options), + }); + + await waitFor(() => { + expect(result.current.isPrivateImageSharingEnabled).toBe(true); + }); + }); + + it('returns false if the feature is NOT enabled', async () => { + const options = { flags: { privateImageSharing: false } }; + + const { result } = renderHook(() => useIsPrivateImageSharingEnabled(), { + wrapper: (ui) => wrapWithTheme(ui, options), + }); + + await waitFor(() => { + expect(result.current.isPrivateImageSharingEnabled).toBe(false); + }); + }); +}); diff --git a/packages/manager/src/features/Images/utils.ts b/packages/manager/src/features/Images/utils.ts index 7d1bf6594bd..dc491d18c10 100644 --- a/packages/manager/src/features/Images/utils.ts +++ b/packages/manager/src/features/Images/utils.ts @@ -1,6 +1,7 @@ import { useRegionsQuery } from '@linode/queries'; import { DISALLOWED_IMAGE_REGIONS } from 'src/constants'; +import { useFlags } from 'src/hooks/useFlags'; import type { Event, Image, Linode } from '@linode/api-v4'; @@ -39,3 +40,18 @@ export const useRegionsThatSupportImageStorage = () => { ) ?? [], }; }; + +/** + * Returns whether or not features related to the Private Image Sharing project + * should be enabled. + * + * Currently, this just uses the `privateImageSharing` feature flag as a source of truth, + * but will eventually also look at account capabilities. + */ + +export const useIsPrivateImageSharingEnabled = () => { + const flags = useFlags(); + + // @TODO Private Image Sharing: check for customer tag/account capability when it exists + return { isPrivateImageSharingEnabled: flags.privateImageSharing ?? false }; +}; diff --git a/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlansPanel.tsx b/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlansPanel.tsx index 493d3b2b559..6ded2e38371 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlansPanel.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlansPanel.tsx @@ -87,7 +87,7 @@ export const KubernetesPlansPanel = (props: Props) => { const _types = types.filter((type) => { // Do not display MTC plans if the feature flag is not enabled. - if (!flags.mtc2025 && isMTCPlan(type)) { + if (!flags.mtc?.enabled && isMTCPlan(type)) { return false; } diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeFirewalls/AddFirewallForm.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeFirewalls/AddFirewallForm.tsx index b59e2a99dc4..1090d86c5b1 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeFirewalls/AddFirewallForm.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeFirewalls/AddFirewallForm.tsx @@ -39,11 +39,12 @@ export const AddFirewallForm = (props: Props) => { const { mutateAsync } = useAddFirewallDeviceMutation(); - const { data: availableFirewalls } = useQueryWithPermissions( - useAllFirewallsQuery(), - 'firewall', - ['create_firewall_device'] - ); + const { + data: availableFirewalls, + isLoading: isLoadingAllAvailableFirewalls, + } = useQueryWithPermissions(useAllFirewallsQuery(), 'firewall', [ + 'create_firewall_device', + ]); const form = useForm({ resolver: yupResolver(schema) as Resolver, @@ -73,8 +74,10 @@ export const AddFirewallForm = (props: Props) => { name="firewallId" render={({ field, fieldState }) => ( field.onChange(value?.id)} options={availableFirewalls} placeholder="Select a Firewall" diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/InterfaceSettingsForm.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/InterfaceSettingsForm.tsx index ce8bdd55ef0..018159069f8 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/InterfaceSettingsForm.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/InterfaceSettingsForm.tsx @@ -54,7 +54,7 @@ export const InterfaceSettingsForm = (props: Props) => { const interfaces = interfacesData?.interfaces.map((networkInterface) => ({ ...networkInterface, - label: `${getLinodeInterfaceType(networkInterface)} Interface (ID : ${networkInterface.id})`, + label: `${getLinodeInterfaceType(networkInterface)} Interface (ID: ${networkInterface.id})`, })); const { mutateAsync: updateSettings } = diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/VPCIPv4Address.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/VPCIPv4Address.tsx index f6cabb33ee7..e21d95274c3 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/VPCIPv4Address.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/VPCIPv4Address.tsx @@ -40,7 +40,8 @@ export const VPCIPv4Address = (props: Props) => { // Auto-assign should be checked if any of the following are true // - field value matches the identifier // - field value is undefined (because the API's default behavior is to auto-assign) - const shouldAutoAssign = fieldValue === autoAssignValue || fieldValue === undefined; + const shouldAutoAssign = + fieldValue === autoAssignValue || fieldValue === undefined; return ( diff --git a/packages/manager/src/features/Linodes/MigrateLinode/ConfigureForm.tsx b/packages/manager/src/features/Linodes/MigrateLinode/ConfigureForm.tsx index b998ebfcfe9..2156ec17e8f 100644 --- a/packages/manager/src/features/Linodes/MigrateLinode/ConfigureForm.tsx +++ b/packages/manager/src/features/Linodes/MigrateLinode/ConfigureForm.tsx @@ -6,7 +6,6 @@ import * as React from 'react'; import { Flag } from 'src/components/Flag'; import { PlacementGroupsSelect } from 'src/components/PlacementGroupsSelect/PlacementGroupsSelect'; import { RegionSelect } from 'src/components/RegionSelect/RegionSelect'; -import { MTC_SUPPORTED_REGIONS } from 'src/features/components/PlansPanel/constants'; import { NO_PLACEMENT_GROUPS_IN_SELECTED_REGION_MESSAGE } from 'src/features/PlacementGroups/constants'; import { useIsPlacementGroupsEnabled } from 'src/features/PlacementGroups/utils'; import { useFlags } from 'src/hooks/useFlags'; @@ -155,9 +154,9 @@ export const ConfigureForm = React.memo((props: Props) => { return false; } - // If mtc2025 flag is enabled, apply MTC region filtering. - if (flags.mtc2025) { - const isMtcRegion = MTC_SUPPORTED_REGIONS.includes(eachRegion.id); + // If mtc flag is enabled, apply MTC region filtering. + if (flags.mtc?.enabled) { + const isMtcRegion = flags.mtc.supportedRegions.includes(eachRegion.id); // For MTC Linodes, only show MTC regions. // For non-MTC Linodes, exclude MTC regions. diff --git a/packages/manager/src/features/NodeBalancers/ConfigNodeIPSelect.tsx b/packages/manager/src/features/NodeBalancers/ConfigNodeIPSelect.tsx index 967150688d7..c3372bc445d 100644 --- a/packages/manager/src/features/NodeBalancers/ConfigNodeIPSelect.tsx +++ b/packages/manager/src/features/NodeBalancers/ConfigNodeIPSelect.tsx @@ -70,19 +70,17 @@ export const ConfigNodeIPSelect = React.memo((props: Props) => { subnetId, } = props; - const { linodesData, linodeIpsData, error, isLoading, subnetsData } = - useGetLinodeIPAndVPCData({ - region, - vpcId, - subnetId, - }); + const { linodes, error, isLoading, vpc, vpcIPs } = useGetLinodeIPAndVPCData({ + region, + vpcId, + }); let options: NodeOption[] = []; if (region && !vpcId) { - options = getPrivateIPOptions(linodesData); + options = getPrivateIPOptions(linodes); } else if (region && vpcId && subnetId) { - options = getVPCIPOptions(linodeIpsData, linodesData, subnetsData?.data); + options = getVPCIPOptions(vpcIPs, linodes, vpc?.subnets); } const noOptionsText = useMemo(() => { diff --git a/packages/manager/src/features/NodeBalancers/ConfigNodeIPSelect.utils.ts b/packages/manager/src/features/NodeBalancers/ConfigNodeIPSelect.utils.ts index 48e647b9c89..d3c8858dbbe 100644 --- a/packages/manager/src/features/NodeBalancers/ConfigNodeIPSelect.utils.ts +++ b/packages/manager/src/features/NodeBalancers/ConfigNodeIPSelect.utils.ts @@ -1,4 +1,6 @@ -import type { Linode, LinodeIPsResponse, Subnet } from '@linode/api-v4'; +import { listToItemsByID } from '@linode/queries'; + +import type { Linode, Subnet, VPCIP } from '@linode/api-v4'; export interface PrivateIPOption { /** @@ -8,14 +10,14 @@ export interface PrivateIPOption { /** * The Linode associated with the private IPv4 address */ - linode: Partial; + linode: Linode; } export interface VPCIPOption extends PrivateIPOption { /** * The Subnet associated with the VPC IPv4 address */ - subnet: Partial; + subnet: Subnet; } /** @@ -40,55 +42,38 @@ export const getPrivateIPOptions = (linodes: Linode[] | undefined) => { }; export const getVPCIPOptions = ( - vpcIps: LinodeIPsResponse[] | undefined, + vpcIps: undefined | VPCIP[], linodes: Linode[] | undefined, - subnets?: Subnet[] | undefined + subnets: Subnet[] | undefined ) => { if (!vpcIps || !subnets) { return []; } + const linodesMap = listToItemsByID(linodes ?? [], 'id'); + const subnetsMap = listToItemsByID(subnets ?? [], 'id'); + const options: VPCIPOption[] = []; - const linodeLabelMap = (linodes ?? []).reduce( - (acc: Record, linode) => { - acc[linode.id] = linode.label; - return acc; - }, - {} - ); - const subnetLabelMap = (subnets ?? []).reduce( - (acc: Record, subnet) => { - acc[subnet.id] = subnet.label; - return acc; - }, - {} - ); + for (const ip of vpcIps) { + if (!ip.address || !ip.linode_id) { + continue; + } - vpcIps.forEach(({ ipv4 }) => { - if (ipv4.vpc) { - const vpcData = ipv4.vpc - .filter((vpc) => vpc.address && vpc.subnet_id in subnetLabelMap) - .map((vpc) => { - const linode: Partial = { - label: linodeLabelMap[vpc.linode_id], - id: vpc.linode_id, - }; - return { - label: vpc.address, - linode, - subnet: { - id: vpc.subnet_id, - label: subnetLabelMap[vpc.subnet_id], - }, - }; - }); + const subnet = subnetsMap[ip.subnet_id]; + const linode = linodesMap[ip.linode_id]; - if (vpcData) { - options.push(...vpcData); - } + if (!linode || !subnet) { + // Safeguard against linode or subnet being undefined + continue; } - }); - return options.sort((a, b) => a.label.localeCompare(b.label)); + options.push({ + label: ip.address, + subnet, + linode, + }); + } + + return options; }; diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerConfigNode.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerConfigNode.tsx index 2a21eee4f9f..fc5b49e8660 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerConfigNode.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerConfigNode.tsx @@ -188,6 +188,7 @@ export const NodeBalancerConfigNode = React.memo( disabled={disabled} errorGroup={`${configIdx}`} errorText={nodesErrorMap.port} + inputId={`node-port-${configIdx}-${idx}`} inputProps={{ 'data-node-idx': idx }} label="Port" noMarginTop @@ -208,6 +209,7 @@ export const NodeBalancerConfigNode = React.memo( disabled={disabled} errorGroup={`${configIdx}`} errorText={nodesErrorMap.weight} + inputId={`node-weight-${configIdx}-${idx}`} inputProps={{ 'data-node-idx': idx }} label="Weight" noMarginTop diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerSelect.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerSelect.tsx index a91724ebde0..adac3c1aacb 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerSelect.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerSelect.tsx @@ -102,7 +102,7 @@ export const NodeBalancerSelect = ( error: availableNodebalancersError, isLoading: availableNodebalancersLoading, } = useQueryWithPermissions( - useAllNodeBalancersQuery(), + useAllNodeBalancersQuery(Boolean(optionsFilter)), 'nodebalancer', ['update_nodebalancer'], Boolean(optionsFilter) diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.test.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.test.tsx index b744e8f27cb..a920de4eaea 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.test.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.test.tsx @@ -17,6 +17,17 @@ const queryMocks = vi.hoisted(() => ({ useFirewallSettingsQuery: vi.fn().mockReturnValue({}), })); +const iamMocks = vi.hoisted(() => ({ + usePermissions: vi.fn().mockReturnValue({ data: { update_vpc: true } }), + useQueryWithPermissions: vi.fn().mockReturnValue({ + data: [], + isLoading: false, + error: null, + isError: false, + hasFiltered: false, + }), +})); + vi.mock('@linode/queries', async () => { const actual = await vi.importActual('@linode/queries'); return { @@ -25,6 +36,11 @@ vi.mock('@linode/queries', async () => { }; }); +vi.mock('src/features/IAM/hooks/usePermissions', () => ({ + usePermissions: iamMocks.usePermissions, + useQueryWithPermissions: iamMocks.useQueryWithPermissions, +})); + const props = { isFetching: false, onClose: vi.fn(), @@ -48,6 +64,17 @@ describe('Subnet Assign Linodes Drawer', () => { region: props.vpcRegion, }); + beforeEach(() => { + // Set up the default mock to return the linode + iamMocks.useQueryWithPermissions.mockReturnValue({ + data: [linode], + isLoading: false, + error: null, + isError: false, + hasFiltered: false, + }); + }); + server.use( http.get('*/linode/instances', () => { return HttpResponse.json(makeResourcePage([linode])); diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.tsx index ada602f3e86..c8ff57284f2 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.tsx @@ -149,40 +149,44 @@ export const SubnetAssignLinodesDrawer = ( const [allowPublicIPv6Access, setAllowPublicIPv6Access] = React.useState(false); - 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'], + // We only want the linodes from the same region as the VPC + const query = useAllLinodesQuery( + {}, + { + region: vpcRegion, + }, open ); - const userCanAssignLinodes = - permissions?.update_vpc && filteredLinodes?.length > 0; - const downloadCSV = async () => { - await getCSVData(); + // getCSVData + await query.refetch(); csvRef.current.link.click(); }; - // We only want the linodes from the same region as the VPC - const { data: linodes, refetch: getCSVData } = useAllLinodesQuery( - {}, - { - region: vpcRegion, - } - ); + 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, isLoading: isLoadingFilteredLinodes } = + useQueryWithPermissions( + query, + 'linode', + ['update_linode', 'delete_linode'], + open + ); + const userCanAssignLinodes = + permissions?.update_vpc && filteredLinodes?.length > 0; // We need to filter to the linodes from this region that are not already // assigned to this subnet const findUnassignedLinodes = React.useCallback(() => { - return linodes?.filter((linode) => { + if (!filteredLinodes) return []; + + return filteredLinodes?.filter((linode) => { return !subnet?.linodes.some((linodeInfo) => linodeInfo.id === linode.id); }); - }, [subnet, linodes]); + }, [subnet, filteredLinodes]); const [linodeOptionsToAssign, setLinodeOptionsToAssign] = React.useState< Linode[] @@ -192,10 +196,10 @@ export const SubnetAssignLinodesDrawer = ( // and update that list whenever this subnet or the list of all linodes in this subnet's region changes. This takes // care of the MUI invalid value warning that was occurring before in the Linodes autocomplete [M3-6752] React.useEffect(() => { - if (linodes) { + if (filteredLinodes) { setLinodeOptionsToAssign(findUnassignedLinodes() ?? []); } - }, [linodes, setLinodeOptionsToAssign, findUnassignedLinodes]); + }, [filteredLinodes, setLinodeOptionsToAssign, findUnassignedLinodes]); // Determine the configId based on the number of configurations function getConfigId(inputs: { @@ -551,7 +555,7 @@ export const SubnetAssignLinodesDrawer = ( try { const data = await getAllLinodeConfigs(linode.id); setLinodeConfigs(data); - } catch (errors) { + } catch { // force error to appear at top of drawer setAssignLinodesErrors({ none: 'Could not load configurations for selected linode', @@ -585,7 +589,7 @@ export const SubnetAssignLinodesDrawer = ( return ( ( + useAllLinodesQuery({}, {}, open), + 'linode', + ['delete_linode'], + open + ); const userCanUnassignLinodes = permissions.update_vpc && filteredLinodes?.length > 0; @@ -362,6 +363,7 @@ export const SubnetUnassignLinodesDrawer = React.memo( disabled={!userCanUnassignLinodes} errorText={linodesError ? linodesError[0].reason : undefined} label="Linodes" + loading={isLoadingFilteredLinodes} multiple onChange={(_, value) => { setSelectedLinodes(value); diff --git a/packages/manager/src/features/components/PlansPanel/PlansPanel.tsx b/packages/manager/src/features/components/PlansPanel/PlansPanel.tsx index 5732c5b6df3..4a38be20d5d 100644 --- a/packages/manager/src/features/components/PlansPanel/PlansPanel.tsx +++ b/packages/manager/src/features/components/PlansPanel/PlansPanel.tsx @@ -117,7 +117,7 @@ export const PlansPanel = (props: PlansPanelProps) => { } // Do not display MTC plans if the feature flag is not enabled. - if (!flags.mtc2025 && isMTCPlan(type)) { + if (!flags.mtc?.enabled && isMTCPlan(type)) { return false; } diff --git a/packages/manager/src/features/components/PlansPanel/constants.ts b/packages/manager/src/features/components/PlansPanel/constants.ts index 3e0a58f6356..748c923288e 100644 --- a/packages/manager/src/features/components/PlansPanel/constants.ts +++ b/packages/manager/src/features/components/PlansPanel/constants.ts @@ -35,10 +35,6 @@ export const ACCELERATED_COMPUTE_INSTANCES_LINK = // List of plan types that belong to the MTC plan group. export const MTC_AVAILABLE_PLAN_TYPES = ['g8-premium-128-ht']; -// Only use this for the RegionSelect dropdown in the Linode Migration flow and for mocks in serverHandlers.ts -// Note: We need to find a way to determine MTC supported regions from the API rather than relying on client-side hardcoded values here. -export const MTC_SUPPORTED_REGIONS = ['no-east', 'us-iad']; - export const DEDICATED_512_GB_PLAN: ExtendedType = { accelerated_devices: 0, addons: { diff --git a/packages/manager/src/hooks/useDataForLinodesInVPC.ts b/packages/manager/src/hooks/useDataForLinodesInVPC.ts index 174803463fc..07f15b693ba 100644 --- a/packages/manager/src/hooks/useDataForLinodesInVPC.ts +++ b/packages/manager/src/hooks/useDataForLinodesInVPC.ts @@ -1,77 +1,50 @@ import { - linodeQueries, useAllLinodesQuery, - useQueries, - useSubnetsQuery, + useVPCIPsQuery, + useVPCQuery, } from '@linode/queries'; -import { useMemo } from 'react'; - -import type { APIError, LinodeIPsResponse } from '@linode/api-v4'; -import type { UseQueryOptions } from '@linode/queries'; export const useGetLinodeIPAndVPCData = (props: { region?: string; - subnetId?: number; vpcId?: null | number; }) => { - const { region, vpcId, subnetId } = props; - - const isSubnetSelected = useMemo( - () => vpcId !== undefined && subnetId !== undefined, - [vpcId, subnetId] - ); + const { region, vpcId } = props; const { - data: linodesData, + data: linodes, error: linodesError, isLoading: linodesIsLoading, } = useAllLinodesQuery({}, { region }, region !== undefined); const { - data: subnetsData, - error: subnetsError, - isLoading: subnetsIsLoading, - } = useSubnetsQuery(Number(vpcId), {}, {}, isSubnetSelected); + data: vpc, + error: vpcError, + isLoading: vpcLoading, + } = useVPCQuery(vpcId ?? -1, vpcId !== undefined); - const linodeIPQueries = useQueries({ - queries: - linodesData?.map>( - ({ id }) => ({ - ...linodeQueries.linode(id)._ctx.ips, - enabled: isSubnetSelected, - }) - ) ?? [], - }); + const { + data: vpcIPs, + error: vpcIPsError, + isLoading: isVPCIPsLoading, + } = useVPCIPsQuery(vpcId ?? -1, {}, vpcId !== undefined); if (region && !vpcId) { return { - linodesData, + linodes, error: linodesError, isLoading: linodesIsLoading, }; } - const linodeIpsData: LinodeIPsResponse[] = []; - let isIpLoading: boolean = false; - const ipError: APIError[] = []; - - linodeIPQueries.forEach(({ data, isLoading, error }) => { - if (data) { - linodeIpsData.push(data); - } - if (isLoading) { - isIpLoading = true; - } - if (error) { - ipError.push(...error); - } - }); - return { - linodesData, - linodeIpsData, - error: [...(linodesError ?? []), ...(subnetsError ?? []), ...ipError], - isLoading: linodesIsLoading ?? subnetsIsLoading ?? isIpLoading, - subnetsData, + linodes, + vpcIPs, + error: [ + ...(linodesError ?? []), + ...(vpcError ?? []), + ...(vpcIPsError ?? []), + ], + isLoading: linodesIsLoading ?? vpcLoading ?? isVPCIPsLoading, + vpc, }; }; diff --git a/packages/manager/src/hooks/usePaginationV2.ts b/packages/manager/src/hooks/usePaginationV2.ts index c8731b3296f..2a3590496b2 100644 --- a/packages/manager/src/hooks/usePaginationV2.ts +++ b/packages/manager/src/hooks/usePaginationV2.ts @@ -18,6 +18,13 @@ export interface UsePaginationV2Props { * The route to which the pagination params are applied. */ currentRoute: ToSubOptions['to']; + /** + * The default page size to use when no preference exists. + * @default MIN_PAGE_SIZE + * + * @warning For API-driven data, this should be set to the minimum page size supported by the API (25 to 500). + */ + defaultPageSize?: number; /** * The initial page pagination is set to - defaults to 1, it's unusual to set this. * @default 1 @@ -40,6 +47,7 @@ export interface UsePaginationV2Props { export const usePaginationV2 = ({ currentRoute, + defaultPageSize = MIN_PAGE_SIZE, initialPage = 1, preferenceKey, queryParamsPrefix, @@ -64,8 +72,8 @@ export const usePaginationV2 = ({ search[pageSizeKey as keyof TableSearchParams] || search.pageSize; const preferredPageSize = preferenceKey - ? (pageSizePreferences?.[preferenceKey] ?? MIN_PAGE_SIZE) - : MIN_PAGE_SIZE; + ? (pageSizePreferences?.[preferenceKey] ?? defaultPageSize) + : defaultPageSize; const page = searchParamPage ? Number(searchParamPage) : initialPage; const pageSize = searchParamPageSize diff --git a/packages/manager/src/mocks/presets/crud/handlers/delegation.ts b/packages/manager/src/mocks/presets/crud/handlers/delegation.ts index 7053cccafcb..7fb21aca9e5 100644 --- a/packages/manager/src/mocks/presets/crud/handlers/delegation.ts +++ b/packages/manager/src/mocks/presets/crud/handlers/delegation.ts @@ -143,8 +143,7 @@ export const childAccountDelegates = (mockState: MockState) => [ StrictResponse> > => { const euuid = params.euuid as string; - const requestData = (await request.json()) as { users: string[] }; - const newUsernames = requestData?.users || []; + const newUsernames = (await request.json()) as string[]; // Get current delegations const allDelegations = await mswDB.getAll('delegations'); @@ -292,18 +291,7 @@ export const defaultDelegationAccess = () => [ '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'], - }, - ], + entity_access: [], }; return makeResponse(mockDefaultAccess); diff --git a/packages/manager/src/mocks/presets/crud/handlers/delivery.ts b/packages/manager/src/mocks/presets/crud/handlers/delivery.ts index a5729b908bf..782cb73fbc2 100644 --- a/packages/manager/src/mocks/presets/crud/handlers/delivery.ts +++ b/packages/manager/src/mocks/presets/crud/handlers/delivery.ts @@ -1,8 +1,9 @@ import { destinationType } from '@linode/api-v4'; +import { omitProps } from '@linode/ui'; import { DateTime } from 'luxon'; import { http } from 'msw'; -import { destinationFactory, streamFactory } from 'src/factories/delivery'; +import { destinationFactory, streamFactory } from 'src/factories'; import { mswDB } from 'src/mocks/indexedDB'; import { queueEvents } from 'src/mocks/utilities/events'; import { @@ -14,6 +15,7 @@ import { import type { AkamaiObjectStorageDetails, + AkamaiObjectStorageDetailsPayload, CreateDestinationPayload, Destination, Stream, @@ -221,7 +223,10 @@ export const createDestinations = (mockState: MockState) => [ request, }): Promise> => { const payload: CreateDestinationPayload = await request.clone().json(); - const details = payload.details; + const details = omitProps( + payload.details as AkamaiObjectStorageDetailsPayload, + ['access_key_secret'] + ); const destination = destinationFactory.build({ label: payload.label, type: payload.type, diff --git a/packages/manager/src/mocks/presets/crud/handlers/users.ts b/packages/manager/src/mocks/presets/crud/handlers/users.ts index 1ac9e4e44e8..eabb3eb3212 100644 --- a/packages/manager/src/mocks/presets/crud/handlers/users.ts +++ b/packages/manager/src/mocks/presets/crud/handlers/users.ts @@ -72,7 +72,9 @@ export const addUserToMockState = async (mockState: MockState, user: User) => { await mswDB.add('users', delegateUser, mockState); // Create child accounts - const childAccounts = childAccountFactory.buildList(4); + const childAccounts = childAccountFactory.buildList(4, { + company: `child-account-${user.username}`, + }); // Create delegations pointing to our active user (parent) for (const childAccount of childAccounts) { diff --git a/packages/manager/src/mocks/presets/extra/account/customMaintenance.ts b/packages/manager/src/mocks/presets/extra/account/customMaintenance.ts index 571f7a98b24..b504f641da3 100644 --- a/packages/manager/src/mocks/presets/extra/account/customMaintenance.ts +++ b/packages/manager/src/mocks/presets/extra/account/customMaintenance.ts @@ -2,7 +2,7 @@ import { http, HttpResponse } from 'msw'; import { makeResourcePage } from 'src/mocks/serverHandlers'; -import type { AccountMaintenance } from '@linode/api-v4'; +import type { AccountMaintenance, MaintenancePolicy } from '@linode/api-v4'; import type { MockPresetExtra } from 'src/mocks/types'; let customMaintenanceData: AccountMaintenance[] | null = null; @@ -13,6 +13,7 @@ export const setCustomMaintenanceData = (data: AccountMaintenance[] | null) => { const mockCustomMaintenance = () => { return [ + // Account maintenance items (supports filtering and pagination similar to prod) http.get('*/account/maintenance', ({ request }) => { const url = new URL(request.url); @@ -59,6 +60,30 @@ const mockCustomMaintenance = () => { return HttpResponse.json(makeResourcePage(accountMaintenance)); }), + + // Maintenance policies used to derive start times from notice `when` + http.get('*/maintenance/policies', () => { + const policies: MaintenancePolicy[] = [ + { + description: 'Migrate', + is_default: true, + label: 'Migrate', + notification_period_sec: 3 * 60 * 60, // 3 hours + slug: 'linode/migrate', + type: 'linode_migrate', + }, + { + description: 'Power Off / Power On', + is_default: false, + label: 'Power Off / Power On', + notification_period_sec: 72 * 60 * 60, // 72 hours + slug: 'linode/power_off_on', + type: 'linode_power_off_on', + }, + ]; + + return HttpResponse.json(makeResourcePage(policies)); + }), ]; }; diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index 19ad6106f06..14d3237a9b9 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -136,7 +136,6 @@ import { maintenancePolicyFactory } from 'src/factories/maintenancePolicy'; import { userAccountPermissionsFactory } from 'src/factories/userAccountPermissions'; import { userEntityPermissionsFactory } from 'src/factories/userEntityPermissions'; import { userRolesFactory } from 'src/factories/userRoles'; -import { MTC_SUPPORTED_REGIONS } from 'src/features/components/PlansPanel/constants'; import type { AccountMaintenance, @@ -814,7 +813,7 @@ export const handlers = [ }), linodeFactory.build({ label: 'mtc-custom-plan-linode-2', - region: 'no-east', + region: 'no-osl-1', type: 'g8-premium-128-ht', id: 1002, }), @@ -1006,7 +1005,7 @@ export const handlers = [ id, backups: { enabled: false }, label: 'mtc-custom-plan-linode-2', - region: 'no-east', + region: 'no-osl-1', type: 'g8-premium-128-ht', }), ]; @@ -2656,7 +2655,7 @@ export const handlers = [ }), // MTC plans are region-specific. The supported regions list below is hardcoded for testing purposes and will expand over time. // The availability of MTC plans is fully handled by this endpoint, which determines the plan's availability status (true/false) for the selected region. - ...(MTC_SUPPORTED_REGIONS.includes(selectedRegion) + ...(['no-osl-1', 'us-iad', 'us-iad-2'].includes(selectedRegion) ? [ regionAvailabilityFactory.build({ available: true, // In supported regions, this can be `true` (plan available) or `false` (plan sold-out). @@ -3407,6 +3406,27 @@ export const handlers = [ scrape_interval: '30s', unit: 'ops_per_second', }, + { + label: 'Network Traffic', + metric: 'vm_network_bytes_total', + unit: 'Kbps', + metric_type: 'gauge', + scrape_interval: '300s', + is_alertable: true, + available_aggregate_functions: ['avg'], + dimensions: [ + { + label: 'Traffic Pattern', + dimension_label: 'pattern', + values: ['publicin', 'publicout', 'privatein', 'privateout'], + }, + { + label: 'Protocol', + dimension_label: 'protocol', + values: ['ipv4', 'ipv6'], + }, + ], + }, ], }; @@ -3566,18 +3586,91 @@ export const handlers = [ http.get('*/monitor/dashboards/:id', ({ params }) => { let serviceType: string; let dashboardLabel: string; + let widgets; const id = params.id; if (id === '1') { serviceType = 'dbaas'; dashboardLabel = 'DBaaS Service I/O Statistics'; + widgets = [ + { + metric: 'cpu_usage', + unit: '%', + label: 'CPU Usage', + color: 'default', + size: 12, + chart_type: 'area', + y_label: 'cpu_usage', + group_by: ['entity_id'], + aggregate_function: 'avg', + }, + { + metric: 'memory_usage', + unit: '%', + label: 'Memory Usage', + color: 'default', + size: 6, + chart_type: 'area', + y_label: 'memory_usage', + group_by: ['entity_id'], + aggregate_function: 'avg', + }, + ]; } else if (id === '3') { serviceType = 'nodebalancer'; dashboardLabel = 'NodeBalancer Service I/O Statistics'; + widgets = [ + { + metric: 'nb_ingress_traffic_rate', + unit: 'Bps', + label: 'Ingress Traffic Rate', + color: 'default', + size: 12, + chart_type: 'line', + y_label: 'nb_ingress_traffic_rate', + group_by: ['entity_id'], + aggregate_function: 'sum', + }, + { + metric: 'nb_egress_traffic_rate', + unit: 'Bps', + label: 'Egress Traffic Rate', + color: 'default', + size: 12, + chart_type: 'line', + y_label: 'nb_egress_traffic_rate', + group_by: ['entity_id'], + aggregate_function: 'sum', + }, + ]; } else if (id === '4') { serviceType = 'firewall'; dashboardLabel = 'Firewall Service I/O Statistics'; + widgets = [ + { + metric: 'fw_active_connections', + unit: 'Count', + label: 'Current Connections', + color: 'default', + size: 12, + chart_type: 'line', + y_label: 'fw_active_connections', + group_by: ['entity_id', 'linode_id', 'interface_id'], + aggregate_function: 'avg', + }, + { + metric: 'fw_available_connections', + unit: 'Count', + label: 'Available Connections', + color: 'default', + size: 12, + chart_type: 'line', + y_label: 'fw_available_connections', + group_by: ['entity_id', 'linode_id', 'interface_id'], + aggregate_function: 'avg', + }, + ]; } else if (id === '6') { serviceType = 'objectstorage'; dashboardLabel = 'Object Storage Service I/O Statistics'; @@ -3590,6 +3683,48 @@ export const handlers = [ } else { serviceType = 'linode'; dashboardLabel = 'Linode Service I/O Statistics'; + widgets = [ + { + metric: 'vm_cpu_time_total', + unit: '%', + label: 'CPU Usage by Instance', + color: 'default', + size: 12, + chart_type: 'area', + y_label: 'vm_cpu_time_total', + group_by: ['entity_id'], + aggregate_function: 'avg', + }, + { + metric: 'vm_local_disk_iops_total', + unit: 'IOPS', + label: 'Local Disk I/O by Instance', + color: 'default', + size: 12, + chart_type: 'area', + y_label: 'vm_local_disk_iops_total', + group_by: ['entity_id'], + aggregate_function: 'avg', + }, + { + metric: 'vm_network_bytes_total', + unit: 'Kbps', + label: 'Network Traffic In by Instance', + color: 'default', + size: 12, + chart_type: 'area', + y_label: 'vm_network_bytes_total', + group_by: ['entity_id'], + aggregate_function: 'avg', + filters: [ + { + dimension_label: 'pattern', + operator: 'in', + value: 'publicin', + }, + ], + }, + ]; } const response = { @@ -3599,7 +3734,7 @@ export const handlers = [ service_type: serviceType, type: 'standard', updated: null, - widgets: [ + widgets: widgets || [ { aggregate_function: 'avg', chart_type: 'area', diff --git a/packages/manager/src/mocks/utilities/response.ts b/packages/manager/src/mocks/utilities/response.ts index 2ecfdbb7b5d..9a4d9c28edb 100644 --- a/packages/manager/src/mocks/utilities/response.ts +++ b/packages/manager/src/mocks/utilities/response.ts @@ -196,10 +196,10 @@ export const makePaginatedResponse = ({ if ( !a || !b || - !(orderBy in a) || - !(orderBy in b) || typeof a !== 'object' || - typeof b !== 'object' + typeof b !== 'object' || + !(orderBy in a) || + !(orderBy in b) ) { return 0; } diff --git a/packages/manager/src/queries/cloudpulse/customfilters.ts b/packages/manager/src/queries/cloudpulse/customfilters.ts index 734447fc7ec..e1735e43290 100644 --- a/packages/manager/src/queries/cloudpulse/customfilters.ts +++ b/packages/manager/src/queries/cloudpulse/customfilters.ts @@ -24,6 +24,10 @@ interface CustomFilterQueryProps { * The xFilter that is used to filter the results in API */ filter?: Filter; + /** + * The filter function to filter the results + */ + filterFn?: (resources: QueryFunctionType) => QueryFunctionType; /** * The id field to consider from the response of the custom filter call */ @@ -46,7 +50,7 @@ interface CustomFilterQueryProps { export const useGetCustomFiltersQuery = ( queryProps: CustomFilterQueryProps ) => { - const { apiV4QueryKey, enabled, idField, labelField } = queryProps; + const { apiV4QueryKey, enabled, idField, labelField, filterFn } = queryProps; return useQuery< QueryFunctionType, unknown, @@ -56,14 +60,16 @@ export const useGetCustomFiltersQuery = ( enabled: enabled && apiV4QueryKey !== undefined, ...(apiV4QueryKey ?? { queryFn: () => [], queryKey: [''] }), select: ( - filters: QueryFunctionType + filterOptions: QueryFunctionType ): CloudPulseServiceTypeFiltersOptions[] => { + // apply the filter function if it is provided + const filteredFilterOptions = filterFn?.(filterOptions) ?? filterOptions; // whatever field we receive, just return id and label - return filters - .map((filter): CloudPulseServiceTypeFiltersOptions => { + return filteredFilterOptions + .map((filterOption): CloudPulseServiceTypeFiltersOptions => { return { - id: getStringValue(filter, idField) ?? '', - label: getStringValue(filter, labelField) ?? '', + id: getStringValue(filterOption, idField) ?? '', + label: getStringValue(filterOption, labelField) ?? '', }; }) .filter(({ id, label }) => id.length && label.length); diff --git a/packages/manager/src/queries/cloudpulse/resources.ts b/packages/manager/src/queries/cloudpulse/resources.ts index a6b322007e6..59641f942b5 100644 --- a/packages/manager/src/queries/cloudpulse/resources.ts +++ b/packages/manager/src/queries/cloudpulse/resources.ts @@ -5,13 +5,15 @@ import { queryFactory } from './queries'; import type { Filter, FirewallDeviceEntity, Params } from '@linode/api-v4'; import type { CloudPulseResources } from 'src/features/CloudPulse/shared/CloudPulseResourcesSelect'; import type { AssociatedEntityType } from 'src/features/CloudPulse/shared/types'; +import type { QueryFunctionType } from 'src/features/CloudPulse/Utils/models'; export const useResourcesQuery = ( enabled = false, resourceType: string | undefined, params?: Params, filters?: Filter, - associatedEntityType: AssociatedEntityType = 'both' + associatedEntityType: AssociatedEntityType = 'both', + filterFn?: (resources: QueryFunctionType) => QueryFunctionType ) => useQuery({ ...queryFactory.resources(resourceType, params, filters), @@ -21,7 +23,8 @@ export const useResourcesQuery = ( if (!enabled) { return []; // Return empty array if the query is not enabled } - return resources.map((resource) => { + const fileteredResources = filterFn?.(resources) ?? resources; + return fileteredResources.map((resource) => { const entities: Record = {}; // handle separately for firewall resource type diff --git a/packages/manager/src/routes/IAM/index.ts b/packages/manager/src/routes/IAM/index.ts index 773180df4c8..a0a5dc32178 100644 --- a/packages/manager/src/routes/IAM/index.ts +++ b/packages/manager/src/routes/IAM/index.ts @@ -1,3 +1,5 @@ +import { accountQueries, profileQueries } from '@linode/queries'; +import { queryOptions } from '@tanstack/react-query'; import { createRoute, redirect } from '@tanstack/react-router'; import { checkIAMEnabled } from 'src/features/IAM/hooks/useIsIAMEnabled'; @@ -157,6 +159,61 @@ const iamDelegationsCatchAllRoute = createRoute({ const iamUserNameRoute = createRoute({ getParentRoute: () => iamRoute, path: '/users/$username', + loader: async ({ context, params, location }) => { + const isIAMEnabled = await checkIAMEnabled( + context.queryClient, + context.flags + ); + const { username } = params; + const isIAMDelegationEnabled = context.flags?.iamDelegation?.enabled; + + if (isIAMEnabled && username && isIAMDelegationEnabled) { + const profile = await context.queryClient.ensureQueryData( + queryOptions(profileQueries.profile()) + ); + + const isChildAccount = profile?.user_type === 'child'; + + if (!profile.restricted && isChildAccount) { + const user = await context.queryClient.ensureQueryData( + queryOptions(accountQueries.users._ctx.user(username)) + ); + + const isChildAccount = profile?.user_type === 'child'; + const isDelegateUser = user?.user_type === 'delegate'; + + // Determine if the current account is a child account with isIAMDelegationEnabled enabled + // If so, we need to hide 'View User Details' and 'Account Delegations' tabs for delegate users + const isDelegateUserForChildAccount = isChildAccount && isDelegateUser; + + // There is no detail view for delegate users in a child account + if ( + isDelegateUserForChildAccount && + location.pathname.endsWith('/details') + ) { + throw redirect({ + to: '/iam/users/$username/roles', + params: { username }, + replace: true, + }); + } + + // We may not need to return all this data tho I can't think of a reason why we wouldn't, + // considering several views served by this route rely on it. + return { + user, + profile, + isIAMDelegationEnabled, + isDelegateUserForChildAccount, + }; + } + } + + return { + isIAMEnabled, + username, + }; + }, }).lazy(() => import('src/features/IAM/Users/userDetailsLandingLazyRoute').then( (m) => m.userDetailsLandingLazyRoute @@ -250,6 +307,19 @@ const iamUserNameEntitiesRoute = createRoute({ const iamUserNameDelegationsRoute = createRoute({ getParentRoute: () => iamUserNameRoute, path: 'delegations', + beforeLoad: async ({ context, params }) => { + const isDelegationEnabled = context?.flags?.iamDelegation?.enabled; + const profile = context?.profile; + const userType = profile?.user_type; + const { username } = params; + + if (userType !== 'parent' || !isDelegationEnabled) { + throw redirect({ + to: '/iam/users/$username/details', + params: { username }, + }); + } + }, }).lazy(() => import( 'src/features/IAM/Users/UserDelegations/userDelegationsLazyRoute' diff --git a/packages/manager/vite.config.ts b/packages/manager/vite.config.ts index 7b31c96ccf1..da562e52401 100644 --- a/packages/manager/vite.config.ts +++ b/packages/manager/vite.config.ts @@ -13,7 +13,11 @@ export default defineConfig({ outDir: 'build', }, envPrefix: 'REACT_APP_', - plugins: [react(), svgr({ exportAsDefault: true }), urlCanParsePolyfill()], + plugins: [ + react(), + svgr({ svgrOptions: { exportType: 'default' }, include: '**/*.svg' }), + urlCanParsePolyfill(), + ], resolve: { alias: { src: `${DIRNAME}/src`, diff --git a/packages/queries/src/iam/delegation.ts b/packages/queries/src/iam/delegation.ts index d6a281357fe..5fabd3900cf 100644 --- a/packages/queries/src/iam/delegation.ts +++ b/packages/queries/src/iam/delegation.ts @@ -25,6 +25,7 @@ import type { Params, ResourcePage, Token, + UpdateChildAccountDelegatesParams, } from '@linode/api-v4'; import type { UseMutationResult, UseQueryResult } from '@tanstack/react-query'; @@ -212,7 +213,7 @@ export const useGetChildAccountDelegatesQuery = ({ euuid, params, }: GetChildAccountDelegatesParams): UseQueryResult< - ResourcePage, + ResourcePage, APIError[] > => { return useQuery({ @@ -233,16 +234,23 @@ export const useGetChildAccountDelegatesQuery = ({ export const useUpdateChildAccountDelegatesQuery = (): UseMutationResult< ResourcePage, APIError[], - { data: string[]; euuid: string } + UpdateChildAccountDelegatesParams > => { const queryClient = useQueryClient(); return useMutation< ResourcePage, APIError[], - { data: string[]; euuid: string } + UpdateChildAccountDelegatesParams >({ - mutationFn: updateChildAccountDelegates, + mutationFn: (data) => updateChildAccountDelegates(data), onSuccess(_data, { euuid }) { + queryClient.invalidateQueries({ + queryKey: delegationQueries.childAccounts({ params: {}, users: true }) + .queryKey, + }); + queryClient.invalidateQueries({ + queryKey: delegationQueries.allChildAccounts._def, + }); // Invalidate all child account delegates queryClient.invalidateQueries({ queryKey: delegationQueries.childAccountDelegates({ euuid }).queryKey, @@ -323,11 +331,11 @@ export const useGenerateChildAccountTokenQuery = (): UseMutationResult< * - 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[] -> => { +export const useGetDefaultDelegationAccessQuery = ({ + enabled = true, +}): UseQueryResult => { return useQuery({ + enabled, ...delegationQueries.defaultAccess, }); }; diff --git a/packages/queries/src/iam/iam.ts b/packages/queries/src/iam/iam.ts index 935ca3d5629..237958e52e3 100644 --- a/packages/queries/src/iam/iam.ts +++ b/packages/queries/src/iam/iam.ts @@ -30,15 +30,23 @@ export const useAccountRoles = (enabled = true) => { }); }; -export const useUserRolesMutation = (username: string) => { +export const useUserRolesMutation = (username: string | undefined) => { const queryClient = useQueryClient(); + return useMutation({ - mutationFn: (data) => updateUserRoles(username, data), + mutationFn: (data) => { + if (!username) { + throw new Error('Username is required'); + } + return updateUserRoles(username, data); + }, onSuccess: (role) => { - queryClient.setQueryData( - iamQueries.user(username)._ctx.roles.queryKey, - role, - ); + if (username) { + queryClient.setQueryData( + iamQueries.user(username)._ctx.roles.queryKey, + role, + ); + } }, }); }; diff --git a/packages/queries/src/linodes/linodes.ts b/packages/queries/src/linodes/linodes.ts index 6867e423043..26ca14a74c0 100644 --- a/packages/queries/src/linodes/linodes.ts +++ b/packages/queries/src/linodes/linodes.ts @@ -383,6 +383,14 @@ export const useCreateLinodeMutation = () => { }); } + // Invalidate all VPC queries if the Linode was created with a VPC. + // We have to invalidate all VPC queries because the new "Linode Interfaces" payload + // does not include the VPC ID. It only includes the Subnet ID. + // The VPC ID is necessary for more granular invalidation, but it is not available here. + if (variables.interfaces?.some((i) => i.vpc)) { + queryClient.invalidateQueries({ queryKey: vpcQueries._def }); + } + for (const linodeInterface of variables.interfaces ?? []) { if (linodeInterface.firewall_id) { // If the interface has a Firewall, invalidate that Firewall diff --git a/packages/queries/src/vpcs/vpcs.ts b/packages/queries/src/vpcs/vpcs.ts index 61c4dea3e08..0ac1c78d075 100644 --- a/packages/queries/src/vpcs/vpcs.ts +++ b/packages/queries/src/vpcs/vpcs.ts @@ -65,7 +65,7 @@ export const vpcQueries = createQueryKeys('vpcs', { }, queryKey: null, }, - vpcIps: (vpcId: number, filter: Filter = {}) => ({ + vpcIps: (filter: Filter = {}) => ({ queryFn: () => getAllVPCIPsRequest(vpcId, filter), queryKey: [filter], }), @@ -121,7 +121,7 @@ export const useVPCIPsQuery = ( enabled: boolean = false, ) => useQuery({ - ...vpcQueries.vpc(id)._ctx.vpcIps(id, filter), + ...vpcQueries.vpc(id)._ctx.vpcIps(filter), enabled, }); diff --git a/packages/shared/package.json b/packages/shared/package.json index f586871fe92..8055af74a58 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -47,6 +47,6 @@ "@testing-library/user-event": "^14.5.2", "@types/react": "^19.1.6", "@types/react-dom": "^19.1.6", - "vite-plugin-svgr": "^3.2.0" + "vite-plugin-svgr": "^4.5.0" } } diff --git a/packages/shared/vitest.config.ts b/packages/shared/vitest.config.ts index ba5b20959d2..243b2d4dd38 100644 --- a/packages/shared/vitest.config.ts +++ b/packages/shared/vitest.config.ts @@ -2,7 +2,9 @@ import svgr from 'vite-plugin-svgr'; import { defineConfig } from 'vitest/config'; export default defineConfig({ - plugins: [svgr({ exportAsDefault: true })], + plugins: [ + svgr({ svgrOptions: { exportType: 'default' }, include: '**/*.svg' }), + ], test: { environment: 'jsdom', diff --git a/packages/ui/CHANGELOG.md b/packages/ui/CHANGELOG.md index 1e36e42671e..ce399a43343 100644 --- a/packages/ui/CHANGELOG.md +++ b/packages/ui/CHANGELOG.md @@ -1,3 +1,10 @@ +## [2025-11-04] - v0.23.0 + + +### Fixed: + +- Misaligned focus indicator on the Toggle component causing visual inconsistency when navigating via keyboard ([#12988](https://github.com/linode/manager/pull/12988)) + ## [2025-10-21] - v0.22.0 diff --git a/packages/ui/package.json b/packages/ui/package.json index 13f7f5783d3..8396ba40bf9 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -2,7 +2,7 @@ "name": "@linode/ui", "author": "Linode", "description": "Linode UI component library", - "version": "0.22.0", + "version": "0.23.0", "type": "module", "main": "src/index.ts", "module": "src/index.ts", @@ -54,6 +54,6 @@ "@types/luxon": "3.4.2", "@types/react": "^19.1.6", "@types/react-dom": "^19.1.6", - "vite-plugin-svgr": "^3.2.0" + "vite-plugin-svgr": "^4.5.0" } } diff --git a/packages/ui/src/components/Toggle/Toggle.tsx b/packages/ui/src/components/Toggle/Toggle.tsx index e95b44e650e..78daf559784 100644 --- a/packages/ui/src/components/Toggle/Toggle.tsx +++ b/packages/ui/src/components/Toggle/Toggle.tsx @@ -35,6 +35,7 @@ export const Toggle = (props: ToggleProps) => { sx={{ position: 'relative', display: 'inline-flex', + overflow: 'visible', }} > { height="16px" sx={{ position: 'absolute', - top: size === 'medium' ? '18px' : '15.5px', - left: size === 'medium' ? '20px' : tooltipText ? '18px' : '25px', + top: size === 'medium' ? '20px' : '15.5px', + left: size === 'medium' ? '22px' : tooltipText ? '18px' : '25px', fill: 'white', zIndex: 1, pointerEvents: 'none', @@ -76,6 +77,8 @@ export const Toggle = (props: ToggleProps) => { left: '-6px', }, }), + overflow: 'visible', + margin: '2px', ...sx, }} /> diff --git a/packages/ui/vitest.config.ts b/packages/ui/vitest.config.ts index 5c794ec63e3..a50c6e09086 100644 --- a/packages/ui/vitest.config.ts +++ b/packages/ui/vitest.config.ts @@ -2,7 +2,9 @@ import svgr from 'vite-plugin-svgr'; import { defineConfig } from 'vitest/config'; export default defineConfig({ - plugins: [svgr({ exportAsDefault: true })], + plugins: [ + svgr({ svgrOptions: { exportType: 'default' }, include: '**/*.svg' }), + ], test: { environment: 'jsdom', setupFiles: './testSetup.ts', diff --git a/packages/utilities/src/__data__/regionsData.ts b/packages/utilities/src/__data__/regionsData.ts index a730cbfae83..13bb19985e6 100644 --- a/packages/utilities/src/__data__/regionsData.ts +++ b/packages/utilities/src/__data__/regionsData.ts @@ -30,7 +30,13 @@ export const regions: Region[] = [ status: 'ok', monitors: { alerts: ['Cloud Firewall', 'Object Storage'], - metrics: ['Block Storage', 'Object Storage'], + metrics: [ + 'Object Storage', + 'Cloud Firewall', + 'Linodes', + 'Managed Databases', + 'Block Storage', + ], }, }, { @@ -117,6 +123,45 @@ export const regions: Region[] = [ }, site_type: 'core', status: 'ok', + monitors: { + alerts: ['Linodes', 'Object Storage'], + metrics: [ + 'Object Storage', + 'Cloud Firewall', + 'Linodes', + 'Managed Databases', + ], + }, + }, + { + capabilities: [ + 'Linodes', + 'NodeBalancers', + 'Block Storage', + 'Object Storage', + 'Kubernetes', + 'Cloud Firewall', + 'Vlans', + 'VPCs', + 'Managed Databases', + 'Metadata', + 'Premium Plans', + 'Placement Group', + 'Maintenance Policy', + ], + country: 'us', + id: 'us-iad-2', + label: 'Washington 2, DC', + placement_group_limits: { + maximum_linodes_per_pg: 10, + maximum_pgs_per_customer: 5, + }, + resolvers: { + ipv4: '139.144.192.62, 139.144.192.60, 139.144.192.61, 139.144.192.53, 139.144.192.54, 139.144.192.67, 139.144.192.69, 139.144.192.66, 139.144.192.52, 139.144.192.68', + ipv6: '2600:3c05::f03c:93ff:feb6:43b6, 2600:3c05::f03c:93ff:feb6:4365, 2600:3c05::f03c:93ff:feb6:43c2, 2600:3c05::f03c:93ff:feb6:e441, 2600:3c05::f03c:93ff:feb6:94ef, 2600:3c05::f03c:93ff:feb6:94ba, 2600:3c05::f03c:93ff:feb6:94a8, 2600:3c05::f03c:93ff:feb6:9413, 2600:3c05::f03c:93ff:feb6:9443, 2600:3c05::f03c:93ff:feb6:94e0', + }, + site_type: 'core', + status: 'ok', monitors: { alerts: ['Linodes', 'Object Storage'], metrics: ['Linodes'] }, }, { @@ -135,7 +180,7 @@ export const regions: Region[] = [ 'Placement Group', ], country: 'no', - id: 'no-east', + id: 'no-osl-1', label: 'Oslo', placement_group_limits: { maximum_linodes_per_pg: 10, @@ -177,7 +222,15 @@ export const regions: Region[] = [ }, site_type: 'core', status: 'ok', - monitors: { alerts: [], metrics: [] }, + monitors: { + alerts: ['Cloud Firewall'], + metrics: [ + 'Linodes', + 'Managed Databases', + 'Cloud Firewall', + 'NodeBalancers', + ], + }, }, { capabilities: [ @@ -610,7 +663,7 @@ export const regions: Region[] = [ }, site_type: 'core', status: 'ok', - monitors: { alerts: ['Linodes'], metrics: [] }, + monitors: { alerts: ['Linodes'], metrics: ['NodeBalancers'] }, }, { capabilities: [ diff --git a/packages/validation/CHANGELOG.md b/packages/validation/CHANGELOG.md index 46691a4d598..176d6748a14 100644 --- a/packages/validation/CHANGELOG.md +++ b/packages/validation/CHANGELOG.md @@ -1,3 +1,10 @@ +## [2025-11-04] - v0.78.0 + +### Upcoming Features: + +- Add validation schemas for creating and updating Sharegroup Members and Tokens ([#12984](https://github.com/linode/manager/pull/12984)) +- Add validation schemas for creating and updating Sharegroups and Sharegroup Images ([#12985](https://github.com/linode/manager/pull/12985)) + ## [2025-10-21] - v0.77.0 ### Changed: diff --git a/packages/validation/package.json b/packages/validation/package.json index fd357b0f4d7..07ae8252d08 100644 --- a/packages/validation/package.json +++ b/packages/validation/package.json @@ -1,6 +1,6 @@ { "name": "@linode/validation", - "version": "0.77.0", + "version": "0.78.0", "description": "Yup validation schemas for use with the Linode APIv4", "type": "module", "main": "lib/index.cjs", diff --git a/packages/validation/src/databases.schema.ts b/packages/validation/src/databases.schema.ts index 44b4f52e77e..6e1dc16382a 100644 --- a/packages/validation/src/databases.schema.ts +++ b/packages/validation/src/databases.schema.ts @@ -14,8 +14,6 @@ export const createDatabaseSchema = object({ cluster_size: number() .oneOf([1, 2, 3], 'Nodes are required') .required('Nodes are required'), - replication_type: string().notRequired().nullable(), // TODO (UIE-8214) remove POST GA - replication_commit_type: string().notRequired().nullable(), // TODO (UIE-8214) remove POST GA }); export const getDynamicDatabaseSchema = (isVPCSelected: boolean) => { diff --git a/packages/validation/src/images.schema.ts b/packages/validation/src/images.schema.ts index 9ac35a1c027..40d12f49610 100644 --- a/packages/validation/src/images.schema.ts +++ b/packages/validation/src/images.schema.ts @@ -39,3 +39,49 @@ export const updateImageRegionsSchema = object({ .required('Regions are required.') .min(1, 'Must specify at least one region.'), }); + +export const sharegroupImageSchema = object({ + id: string().required('Image ID is required'), + label: labelSchema.optional(), + description: string().optional(), +}); + +export const addSharegroupImagesSchema = object({ + images: array(sharegroupImageSchema).required('Images are required.'), +}); + +export const updateSharegroupImageSchema = object({ + label: labelSchema.optional(), + description: string().optional(), +}); + +export const createSharegroupSchema = object({ + label: labelSchema.required('Label is required.'), + description: string().optional(), + images: array(sharegroupImageSchema).notRequired(), +}); + +export const updateSharegroupSchema = object({ + label: labelSchema.optional(), + description: string().optional(), +}); + +export const addSharegroupMemberSchema = object({ + token: string().required('Token is required.'), + label: labelSchema.required('Label is required.'), +}); + +export const updateSharegroupMemberSchema = object({ + label: labelSchema.required('Label is required.'), +}); + +export const generateSharegroupTokenSchema = object({ + label: labelSchema.optional(), + valid_for_sharegroup_uuid: boolean().required( + 'Valid sharegroup UUID required.', + ), +}); + +export const updateSharegroupTokenSchema = object({ + label: labelSchema.required('Label is required.'), +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2ba7e30eb0e..7bccdfcdc81 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -28,8 +28,8 @@ importers: specifier: ^8.38.0 version: 8.38.0(eslint@9.31.0(jiti@2.4.2))(typescript@5.7.3) '@vitest/ui': - specifier: ^3.1.2 - version: 3.1.2(vitest@3.1.2) + specifier: ^3.2.4 + version: 3.2.4(vitest@3.2.4) concurrently: specifier: 9.1.0 version: 9.1.0 @@ -85,8 +85,8 @@ importers: specifier: ^8.29.0 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@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) + specifier: ^3.2.4 + version: 3.2.4(@types/debug@4.1.12)(@types/node@22.18.1)(@vitest/ui@3.2.4)(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: @@ -114,7 +114,7 @@ importers: version: 9.1.0 tsup: specifier: ^8.4.0 - version: 8.4.0(@swc/core@1.10.11)(jiti@2.4.2)(postcss@8.5.6)(tsx@4.19.3)(typescript@5.7.3)(yaml@2.6.1) + version: 8.4.0(@swc/core@1.13.5)(jiti@2.4.2)(postcss@8.5.6)(tsx@4.19.3)(typescript@5.7.3)(yaml@2.6.1) packages/manager: dependencies: @@ -212,8 +212,8 @@ importers: specifier: ^5.5.0 version: 5.5.0 akamai-cds-react-components: - specifier: 0.0.1-alpha.14 - version: 0.0.1-alpha.14(@linode/design-language-system@5.0.0)(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + specifier: 0.0.1-alpha.15 + version: 0.0.1-alpha.15(@linode/design-language-system@5.0.0)(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) algoliasearch: specifier: ^4.14.3 version: 4.24.0 @@ -349,7 +349,7 @@ importers: devDependencies: '@4tw/cypress-drag-drop': specifier: ^2.3.0 - version: 2.3.0(cypress@14.3.0) + version: 2.3.0(cypress@15.4.0) '@storybook/addon-a11y': specifier: ^9.0.12 version: 9.0.12(storybook@9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3)) @@ -358,13 +358,13 @@ 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@22.18.1)(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@7.1.11(@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 '@testing-library/cypress': - specifier: ^10.0.3 - version: 10.0.3(cypress@14.3.0) + specifier: ^10.1.0 + version: 10.1.0(cypress@15.4.0) '@testing-library/dom': specifier: ^10.1.0 version: 10.4.0 @@ -441,11 +441,11 @@ importers: specifier: ^4.4.0 version: 4.4.5 '@vitejs/plugin-react-swc': - specifier: ^3.7.2 - 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)) + specifier: ^4.0.1 + version: 4.0.1(vite@7.1.11(@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) + specifier: ^3.2.4 + version: 3.2.4(vitest@3.2.4) '@vueless/storybook-dark-mode': specifier: ^9.0.5 version: 9.0.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -462,17 +462,17 @@ importers: specifier: ^0.1.2 version: 0.1.2 cypress: - specifier: 14.3.0 - version: 14.3.0 + specifier: 15.4.0 + version: 15.4.0 cypress-axe: - specifier: ^1.6.0 - version: 1.6.0(axe-core@4.10.2)(cypress@14.3.0) + specifier: ^1.7.0 + version: 1.7.0(axe-core@4.10.2)(cypress@15.4.0) cypress-file-upload: specifier: ^5.0.8 - version: 5.0.8(cypress@14.3.0) + version: 5.0.8(cypress@15.4.0) cypress-mochawesome-reporter: specifier: ^3.8.2 - version: 3.8.2(cypress@14.3.0)(mocha@10.8.2) + version: 3.8.2(cypress@15.4.0)(mocha@10.8.2) cypress-multi-reporters: specifier: ^2.0.5 version: 2.0.5(mocha@10.8.2) @@ -481,10 +481,10 @@ importers: version: 1.1.0 cypress-real-events: specifier: ^1.14.0 - version: 1.14.0(cypress@14.3.0) + version: 1.14.0(cypress@15.4.0) cypress-vite: - specifier: ^1.6.0 - 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)) + specifier: ^1.8.0 + version: 1.8.0(vite@7.1.11(@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 @@ -519,11 +519,11 @@ importers: specifier: ^9.0.12 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@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + specifier: ^7.1.11 + version: 7.1.11(@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@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + specifier: ^4.5.0 + version: 4.5.0(rollup@4.50.1)(typescript@5.7.3)(vite@7.1.11(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) packages/queries: dependencies: @@ -575,7 +575,7 @@ importers: version: 4.2.0 vite: specifier: '*' - 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) + version: 7.1.11(@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:* @@ -607,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@22.18.1)(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@7.1.11(@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 @@ -630,8 +630,8 @@ importers: specifier: ^9.0.12 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@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + specifier: ^4.5.0 + version: 4.5.0(rollup@4.50.1)(typescript@5.7.3)(vite@7.1.11(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) packages/tsconfig: {} @@ -676,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@22.18.1)(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@7.1.11(@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 @@ -702,8 +702,8 @@ importers: specifier: ^9.0.12 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@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + specifier: ^4.5.0 + version: 4.5.0(rollup@4.50.1)(typescript@5.7.3)(vite@7.1.11(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) packages/utilities: dependencies: @@ -765,7 +765,7 @@ importers: version: 9.1.0 tsup: specifier: ^8.4.0 - version: 8.4.0(@swc/core@1.10.11)(jiti@2.4.2)(postcss@8.5.6)(tsx@4.19.3)(typescript@5.7.3)(yaml@2.6.1) + version: 8.4.0(@swc/core@1.13.5)(jiti@2.4.2)(postcss@8.5.6)(tsx@4.19.3)(typescript@5.7.3)(yaml@2.6.1) scripts: devDependencies: @@ -976,12 +976,8 @@ packages: '@bundled-es-modules/tough-cookie@0.1.6': resolution: {integrity: sha512-dvMHbL464C0zI+Yqxbz6kZ5TOEp7GLW+pry/RWndAR8MJQAXZ2rPmIs8tziTZjeIyhSNZgZbCePtfSbdWqStJw==} - '@colors/colors@1.5.0': - resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} - engines: {node: '>=0.1.90'} - - '@cypress/request@3.0.8': - resolution: {integrity: sha512-h0NFgh1mJmm1nr4jCwkGHwKneVYKghUyWe6TMNrk0B9zsjAJxpg8C4/+BAcmLgCPa1vj1V8rNUaILl+zYRUWBQ==} + '@cypress/request@3.0.9': + resolution: {integrity: sha512-I3l7FdGRXluAS44/0NguwWlO83J18p0vlr2FYHrJkWdNYhgVoiYo61IXPqaOsL+vNxU1ZqMACzItGK3/KKDsdw==} engines: {node: '>= 6'} '@cypress/xvfb@1.2.4': @@ -1925,6 +1921,9 @@ packages: react: ^16.8.0 || 17.x react-dom: ^16.8.0 || 17.x + '@rolldown/pluginutils@1.0.0-beta.32': + resolution: {integrity: sha512-QReCdvxiUZAPkvp1xpAg62IeNzykOFA6syH2CnClif4YmALN1XKpB39XneL80008UbtMShthSVDKmrx05N1q/g==} + '@rollup/pluginutils@5.1.3': resolution: {integrity: sha512-Pnsb6f32CD2W3uCaLZIzDmeFyQ2b8UWMFI7xtwUezpcGBDVDW6y9XgAWIlARiGAo6eNF5FK5aQTr0LFyNyqq5A==} engines: {node: '>=14.0.0'} @@ -1934,6 +1933,15 @@ packages: rollup: optional: true + '@rollup/pluginutils@5.2.0': + resolution: {integrity: sha512-qWJ2ZTbmumwiLFomfzTyt5Kng4hwPi9rwCYN4SHb6eaRU1KNO4ccxINHr/VhH4GgPlt1XfSTLX2LBTme8ne4Zw==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + '@rollup/rollup-android-arm-eabi@4.40.1': resolution: {integrity: sha512-kxz0YeeCrRUHz3zyqvd7n+TVRlNyTifBsmnmNPtk3hQURUyG9eAB+usz6DAwagMusjx/zb3AjvDUvhFGDAexGw==} cpu: [arm] @@ -2330,60 +2338,120 @@ packages: cpu: [arm64] os: [darwin] + '@swc/core-darwin-arm64@1.13.5': + resolution: {integrity: sha512-lKNv7SujeXvKn16gvQqUQI5DdyY8v7xcoO3k06/FJbHJS90zEwZdQiMNRiqpYw/orU543tPaWgz7cIYWhbopiQ==} + engines: {node: '>=10'} + cpu: [arm64] + os: [darwin] + '@swc/core-darwin-x64@1.10.11': resolution: {integrity: sha512-szObinnq2o7spXMDU5pdunmUeLrfV67Q77rV+DyojAiGJI1RSbEQotLOk+ONOLpoapwGUxOijFG4IuX1xiwQ2g==} engines: {node: '>=10'} cpu: [x64] os: [darwin] + '@swc/core-darwin-x64@1.13.5': + resolution: {integrity: sha512-ILd38Fg/w23vHb0yVjlWvQBoE37ZJTdlLHa8LRCFDdX4WKfnVBiblsCU9ar4QTMNdeTBEX9iUF4IrbNWhaF1Ng==} + engines: {node: '>=10'} + cpu: [x64] + os: [darwin] + '@swc/core-linux-arm-gnueabihf@1.10.11': resolution: {integrity: sha512-tVE8aXQwd8JUB9fOGLawFJa76nrpvp3dvErjozMmWSKWqtoeO7HV83aOrVtc8G66cj4Vq7FjTE9pOJeV1FbKRw==} engines: {node: '>=10'} cpu: [arm] os: [linux] + '@swc/core-linux-arm-gnueabihf@1.13.5': + resolution: {integrity: sha512-Q6eS3Pt8GLkXxqz9TAw+AUk9HpVJt8Uzm54MvPsqp2yuGmY0/sNaPPNVqctCX9fu/Nu8eaWUen0si6iEiCsazQ==} + engines: {node: '>=10'} + cpu: [arm] + os: [linux] + '@swc/core-linux-arm64-gnu@1.10.11': resolution: {integrity: sha512-geFkENU5GMEKO7FqHOaw9HVlpQEW10nICoM6ubFc0hXBv8dwRXU4vQbh9s/isLSFRftw1m4jEEWixAnXSw8bxQ==} engines: {node: '>=10'} cpu: [arm64] os: [linux] + '@swc/core-linux-arm64-gnu@1.13.5': + resolution: {integrity: sha512-aNDfeN+9af+y+M2MYfxCzCy/VDq7Z5YIbMqRI739o8Ganz6ST+27kjQFd8Y/57JN/hcnUEa9xqdS3XY7WaVtSw==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + '@swc/core-linux-arm64-musl@1.10.11': resolution: {integrity: sha512-2mMscXe/ivq8c4tO3eQSbQDFBvagMJGlalXCspn0DgDImLYTEnt/8KHMUMGVfh0gMJTZ9q4FlGLo7mlnbx99MQ==} engines: {node: '>=10'} cpu: [arm64] os: [linux] + '@swc/core-linux-arm64-musl@1.13.5': + resolution: {integrity: sha512-9+ZxFN5GJag4CnYnq6apKTnnezpfJhCumyz0504/JbHLo+Ue+ZtJnf3RhyA9W9TINtLE0bC4hKpWi8ZKoETyOQ==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + '@swc/core-linux-x64-gnu@1.10.11': resolution: {integrity: sha512-eu2apgDbC4xwsigpl6LS+iyw6a3mL6kB4I+6PZMbFF2nIb1Dh7RGnu70Ai6mMn1o80fTmRSKsCT3CKMfVdeNFg==} engines: {node: '>=10'} cpu: [x64] os: [linux] + '@swc/core-linux-x64-gnu@1.13.5': + resolution: {integrity: sha512-WD530qvHrki8Ywt/PloKUjaRKgstQqNGvmZl54g06kA+hqtSE2FTG9gngXr3UJxYu/cNAjJYiBifm7+w4nbHbA==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + '@swc/core-linux-x64-musl@1.10.11': resolution: {integrity: sha512-0n+wPWpDigwqRay4IL2JIvAqSKCXv6nKxPig9M7+epAlEQlqX+8Oq/Ap3yHtuhjNPb7HmnqNJLCXT1Wx+BZo0w==} engines: {node: '>=10'} cpu: [x64] os: [linux] + '@swc/core-linux-x64-musl@1.13.5': + resolution: {integrity: sha512-Luj8y4OFYx4DHNQTWjdIuKTq2f5k6uSXICqx+FSabnXptaOBAbJHNbHT/06JZh6NRUouaf0mYXN0mcsqvkhd7Q==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + '@swc/core-win32-arm64-msvc@1.10.11': resolution: {integrity: sha512-7+bMSIoqcbXKosIVd314YjckDRPneA4OpG1cb3/GrkQTEDXmWT3pFBBlJf82hzJfw7b6lfv6rDVEFBX7/PJoLA==} engines: {node: '>=10'} cpu: [arm64] os: [win32] + '@swc/core-win32-arm64-msvc@1.13.5': + resolution: {integrity: sha512-cZ6UpumhF9SDJvv4DA2fo9WIzlNFuKSkZpZmPG1c+4PFSEMy5DFOjBSllCvnqihCabzXzpn6ykCwBmHpy31vQw==} + engines: {node: '>=10'} + cpu: [arm64] + os: [win32] + '@swc/core-win32-ia32-msvc@1.10.11': resolution: {integrity: sha512-6hkLl4+3KjP/OFTryWxpW7YFN+w4R689TSPwiII4fFgsFNupyEmLWWakKfkGgV2JVA59L4Oi02elHy/O1sbgtw==} engines: {node: '>=10'} cpu: [ia32] os: [win32] + '@swc/core-win32-ia32-msvc@1.13.5': + resolution: {integrity: sha512-C5Yi/xIikrFUzZcyGj9L3RpKljFvKiDMtyDzPKzlsDrKIw2EYY+bF88gB6oGY5RGmv4DAX8dbnpRAqgFD0FMEw==} + engines: {node: '>=10'} + cpu: [ia32] + os: [win32] + '@swc/core-win32-x64-msvc@1.10.11': resolution: {integrity: sha512-kKNE2BGu/La2k2WFHovenqZvGQAHRIU+rd2/6a7D6EiQ6EyimtbhUqjCCZ+N1f5fIAnvM+sMdLiQJq4jdd/oOQ==} engines: {node: '>=10'} cpu: [x64] os: [win32] + '@swc/core-win32-x64-msvc@1.13.5': + resolution: {integrity: sha512-YrKdMVxbYmlfybCSbRtrilc6UA8GF5aPmGKBdPvjrarvsmf4i7ZHGCEnLtfOMd3Lwbs2WUZq3WdMbozYeLU93Q==} + engines: {node: '>=10'} + cpu: [x64] + os: [win32] + '@swc/core@1.10.11': resolution: {integrity: sha512-3zGU5y3S20cAwot9ZcsxVFNsSVaptG+dKdmAxORSE3EX7ixe1Xn5kUwLlgIsM4qrwTUWCJDLNhRS+2HLFivcDg==} engines: {node: '>=10'} @@ -2393,12 +2461,24 @@ packages: '@swc/helpers': optional: true + '@swc/core@1.13.5': + resolution: {integrity: sha512-WezcBo8a0Dg2rnR82zhwoR6aRNxeTGfK5QCD6TQ+kg3xx/zNT02s/0o+81h/3zhvFSB24NtqEr8FTw88O5W/JQ==} + engines: {node: '>=10'} + peerDependencies: + '@swc/helpers': '>=0.5.17' + peerDependenciesMeta: + '@swc/helpers': + optional: true + '@swc/counter@0.1.3': resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} '@swc/types@0.1.17': resolution: {integrity: sha512-V5gRru+aD8YVyCOMAjMpWR1Ui577DD5KSJsHP8RAxopAH22jFz6GZd/qxqjO6MJHQhcsjvjOFXyDhyLQUnMveQ==} + '@swc/types@0.1.24': + resolution: {integrity: sha512-tjTMh3V4vAORHtdTprLlfoMptu1WfTZG9Rsca6yOKyNYsRr+MUXutKmliB17orgSZk5DpnDxs8GUdd/qwYxOng==} + '@tanstack/history@1.99.13': resolution: {integrity: sha512-JMd7USmnp8zV8BRGIjALqzPxazvKtQ7PGXQC7n39HpbqdsmfV2ePCzieO84IvN+mwsTrXErpbjI4BfKCa+ZNCg==} engines: {node: '>=12'} @@ -2440,11 +2520,11 @@ packages: '@tanstack/store@0.7.0': resolution: {integrity: sha512-CNIhdoUsmD2NolYuaIs8VfWM467RK6oIBAW4nPEKZhg1smZ+/CwtCdpURgp7nxSqOaV9oKkzdWD80+bC66F/Jg==} - '@testing-library/cypress@10.0.3': - resolution: {integrity: sha512-TeZJMCNtiS59cPWalra7LgADuufO5FtbqQBYxuAgdX6ZFAR2D9CtQwAG8VbgvFcchW3K414va/+7P4OkQ80UVg==} + '@testing-library/cypress@10.1.0': + resolution: {integrity: sha512-tNkNtYRqPQh71xXKuMizr146zlellawUfDth7A/urYU4J66g0VGZ063YsS0gqS79Z58u1G/uo9UxN05qvKXMag==} engines: {node: '>=12', npm: '>=6'} peerDependencies: - cypress: ^12.0.0 || ^13.0.0 || ^14.0.0 + cypress: ^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 '@testing-library/dom@10.4.0': resolution: {integrity: sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==} @@ -2503,6 +2583,9 @@ packages: '@types/chai@5.0.1': resolution: {integrity: sha512-5T8ajsg3M/FOncpLYW7sdOcD6yf4+722sze/tc4KQV0P8Z2rAr3SAuHCIkYmYpt8VbcQlnz8SxlOlPQYefe4cA==} + '@types/chai@5.2.2': + resolution: {integrity: sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==} + '@types/chart.js@2.9.41': resolution: {integrity: sha512-3dvkDvueckY83UyUXtJMalYoH6faOLkWQoaTlJgB4Djde3oORmNP0Jw85HtzTuXyliUHcdp704s0mZFQKio/KQ==} @@ -2672,6 +2755,9 @@ packages: '@types/throttle-debounce@1.1.1': resolution: {integrity: sha512-VhX9p0l8p3TS27XU+CnDfhdnzW7HpVgtKiYDh/lfucSAz8s9uTt0q4aPwcYIr+q+3/NghlU3smXBW6ItvfJKYQ==} + '@types/tmp@0.2.6': + resolution: {integrity: sha512-chhaNf2oKHlRkDGt+tiKE2Z5aJ6qalm7Z9rlLdBwmOiAAf09YQvvoLXjWK4HWPF1xU/fqvMgfNfpVoBscA/tKA==} + '@types/tough-cookie@4.0.5': resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} @@ -2799,16 +2885,17 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} - '@vitejs/plugin-react-swc@3.7.2': - resolution: {integrity: sha512-y0byko2b2tSVVf5Gpng1eEhX1OvPC7x8yns1Fx8jDzlJp4LS6CMkCPfLw47cjyoMrshQDoQw4qcgjsU9VvlCew==} + '@vitejs/plugin-react-swc@4.0.1': + resolution: {integrity: sha512-NQhPjysi5duItyrMd5JWZFf2vNOuSMyw+EoZyTBDzk+DkfYD8WNrsUs09sELV2cr1P15nufsN25hsUBt4CKF9Q==} + engines: {node: ^20.19.0 || >=22.12.0} peerDependencies: - vite: ^4 || ^5 || ^6 + vite: ^4 || ^5 || ^6 || ^7 - '@vitest/coverage-v8@3.1.2': - resolution: {integrity: sha512-XDdaDOeaTMAMYW7N63AqoK32sYUWbXnTkC6tEbVcu3RlU1bB9of32T+PGf8KZvxqLNqeXhafDFqCkwpf2+dyaQ==} + '@vitest/coverage-v8@3.2.4': + resolution: {integrity: sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==} peerDependencies: - '@vitest/browser': 3.1.2 - vitest: 3.1.2 + '@vitest/browser': 3.2.4 + vitest: 3.2.4 peerDependenciesMeta: '@vitest/browser': optional: true @@ -2816,14 +2903,14 @@ packages: '@vitest/expect@3.0.9': resolution: {integrity: sha512-5eCqRItYgIML7NNVgJj6TVCmdzE7ZVgJhruW0ziSQV4V7PvLkDL1bBkBdcTs/VuIz0IxPb5da1IDSqc1TR9eig==} - '@vitest/expect@3.1.2': - resolution: {integrity: sha512-O8hJgr+zREopCAqWl3uCVaOdqJwZ9qaDwUP7vy3Xigad0phZe9APxKhPcDNqYYi0rX5oMvwJMSCAXY2afqeTSA==} + '@vitest/expect@3.2.4': + resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} - '@vitest/mocker@3.1.2': - resolution: {integrity: sha512-kOtd6K2lc7SQ0mBqYv/wdGedlqPdM/B38paPY+OwJ1XiNi44w3Fpog82UfOibmHaV9Wod18A09I9SCKLyDMqgw==} + '@vitest/mocker@3.2.4': + resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} peerDependencies: msw: ^2.4.9 - vite: ^5.0.0 || ^6.0.0 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 peerDependenciesMeta: msw: optional: true @@ -2833,31 +2920,31 @@ packages: '@vitest/pretty-format@3.0.9': resolution: {integrity: sha512-OW9F8t2J3AwFEwENg3yMyKWweF7oRJlMyHOMIhO5F3n0+cgQAJZBjNgrF8dLwFTEXl5jUqBLXd9QyyKv8zEcmA==} - '@vitest/pretty-format@3.1.2': - resolution: {integrity: sha512-R0xAiHuWeDjTSB3kQ3OQpT8Rx3yhdOAIm/JM4axXxnG7Q/fS8XUwggv/A4xzbQA+drYRjzkMnpYnOGAc4oeq8w==} + '@vitest/pretty-format@3.2.4': + resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} - '@vitest/runner@3.1.2': - resolution: {integrity: sha512-bhLib9l4xb4sUMPXnThbnhX2Yi8OutBMA8Yahxa7yavQsFDtwY/jrUZwpKp2XH9DhRFJIeytlyGpXCqZ65nR+g==} + '@vitest/runner@3.2.4': + resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} - '@vitest/snapshot@3.1.2': - resolution: {integrity: sha512-Q1qkpazSF/p4ApZg1vfZSQ5Yw6OCQxVMVrLjslbLFA1hMDrT2uxtqMaw8Tc/jy5DLka1sNs1Y7rBcftMiaSH/Q==} + '@vitest/snapshot@3.2.4': + resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} '@vitest/spy@3.0.9': resolution: {integrity: sha512-/CcK2UDl0aQ2wtkp3YVWldrpLRNCfVcIOFGlVGKO4R5eajsH393Z1yiXLVQ7vWsj26JOEjeZI0x5sm5P4OGUNQ==} - '@vitest/spy@3.1.2': - resolution: {integrity: sha512-OEc5fSXMws6sHVe4kOFyDSj/+4MSwst0ib4un0DlcYgQvRuYQ0+M2HyqGaauUMnjq87tmUaMNDxKQx7wNfVqPA==} + '@vitest/spy@3.2.4': + resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} - '@vitest/ui@3.1.2': - resolution: {integrity: sha512-+YPgKiLpFEyBVJNHDkRcSDcLrrnr20lyU4HQoI9Jtq1MdvoX8usql9h38mQw82MBU1Zo5BPC6sw+sXZ6NS18CQ==} + '@vitest/ui@3.2.4': + resolution: {integrity: sha512-hGISOaP18plkzbWEcP/QvtRW1xDXF2+96HbEX6byqQhAUbiS5oH6/9JwW+QsQCIYON2bI6QZBF+2PvOmrRZ9wA==} peerDependencies: - vitest: 3.1.2 + vitest: 3.2.4 '@vitest/utils@3.0.9': resolution: {integrity: sha512-ilHM5fHhZ89MCp5aAaM9uhfl1c2JdxVxl3McqsdVyVNN6JffnEen8UMCdRTzOhGXNQGo5GNL9QugHrz727Wnng==} - '@vitest/utils@3.1.2': - resolution: {integrity: sha512-5GGd0ytZ7BH3H6JTj9Kw7Prn1Nbg0wZVrIvou+UWxm54d+WoXXgAgjFJ8wn3LdagWLFSEfpPeyYrByZaGEZHLg==} + '@vitest/utils@3.2.4': + resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} '@vueless/storybook-dark-mode@9.0.5': resolution: {integrity: sha512-JU0bQe+KHvmg04k2yprzVkM0d8xdKwqFaFuQmO7afIUm//ttroDpfHfPzwLZuTDW9coB5bt2+qMSHZOBbt0w4g==} @@ -2892,14 +2979,14 @@ packages: ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} - akamai-cds-react-components@0.0.1-alpha.14: - resolution: {integrity: sha512-YDOlMSwtlueVQFXkyDiuX/Q1Uc/WXgn7jAtJHR6cFddOzCdvcF2kP0nO5G+iPqE0JBRLLPQXl1dYdHGcPgJMbw==} + akamai-cds-react-components@0.0.1-alpha.15: + resolution: {integrity: sha512-uJZLFjzxlV/hmGYiziIa7F6m55qSG0KMela7TLFbx+V1OFeAy71ULOGpOXGo9sU38GbUr8w4xw5YxLEcJPyRoA==} peerDependencies: react: ^18.0.0 react-dom: ^18.0.0 - akamai-cds-web-components@0.0.1-alpha.14: - resolution: {integrity: sha512-PV/bh/FM00aJFDx+V9zkRLexxNcJVI615VXK1/kLbs6SZksgvUydQ5V82mrvNPjdQdhhA1H8trN92CF9KGpVzg==} + akamai-cds-web-components@0.0.1-alpha.15: + resolution: {integrity: sha512-Hb4P7tyqq7iE3lg/kSD1++jGuj9aFxbhcZ9MaQ9nWgFLbjm46EI0QQzALNUdXjwlaK5x+/uUri8IHveyiGzgtA==} peerDependencies: '@linode/design-language-system': ^4.0.0 @@ -3004,13 +3091,13 @@ packages: resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==} engines: {node: '>=4'} + ast-v8-to-istanbul@0.3.4: + resolution: {integrity: sha512-cxrAnZNLBnQwBPByK4CeDaw5sWZtMilJE/Q3iDA0aamgaIVNDF9T6K2/8DfYDZEejZ2jNnDrG9m8MY72HFd0KA==} + astral-regex@2.0.0: resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} engines: {node: '>=8'} - async@3.2.6: - resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} - asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} @@ -3214,10 +3301,6 @@ packages: resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==} engines: {node: '>= 16'} - check-more-types@2.24.0: - resolution: {integrity: sha512-Pj779qHxV2tuapviy1bSZNEL1maXr13bPYpsvSDB68HlYcYuhlDrmGd63i0JHMCLKzc7rUSNIrpdJlhVlNwrxA==} - engines: {node: '>= 0.8.0'} - chokidar@3.6.0: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} @@ -3242,8 +3325,8 @@ packages: resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} engines: {node: '>=18'} - cli-table3@0.6.5: - resolution: {integrity: sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==} + cli-table3@0.6.1: + resolution: {integrity: sha512-w0q/enDHhPLq44ovMGdQeeDLvwxwavsJX7oQGYt/LrBlYsyaxyDnp6z3QzFut/6kLLKnlcUVJLrpB7KBfgG/RA==} engines: {node: 10.* || >= 12.*} cli-truncate@2.1.0: @@ -3296,6 +3379,10 @@ packages: colorette@2.0.20: resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + colors@1.4.0: + resolution: {integrity: sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==} + engines: {node: '>=0.1.90'} + combined-stream@1.0.8: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} @@ -3406,12 +3493,12 @@ packages: csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} - cypress-axe@1.6.0: - resolution: {integrity: sha512-C/ij50G8eebBrl/WsGT7E+T/SFyIsRZ3Epx9cRTLrPL9Y1GcxlQGFoAVdtSFWRrHSCWXq9HC6iJQMaI89O9yvQ==} + cypress-axe@1.7.0: + resolution: {integrity: sha512-zzJpvAAjauEB3GZl0KYXb8i3w6MztWAt2WM3czYTFyNVC30alDmqCm9E7GwZ4bgkldZJlmHakaVEyu73R5St4w==} engines: {node: '>=10'} peerDependencies: axe-core: ^3 || ^4 - cypress: ^10 || ^11 || ^12 || ^13 || ^14 + cypress: ^10 || ^11 || ^12 || ^13 || ^14 || ^15 cypress-file-upload@5.0.8: resolution: {integrity: sha512-+8VzNabRk3zG6x8f8BWArF/xA/W0VK4IZNx3MV0jFWrJS/qKn8eHfa5nU73P9fOQAgwHFJx7zjg4lwOnljMO8g==} @@ -3440,14 +3527,14 @@ packages: peerDependencies: cypress: ^4.x || ^5.x || ^6.x || ^7.x || ^8.x || ^9.x || ^10.x || ^11.x || ^12.x || ^13.x || ^14.x - cypress-vite@1.6.0: - resolution: {integrity: sha512-6oZPDvHgLEZjuFgoejtRuyph369zbVn7fjh4hzhMar3XvKT5YhTEoA+KixksMuxNEaLn9uqA4HJVz6l7BybwBQ==} + cypress-vite@1.8.0: + resolution: {integrity: sha512-rPkIpDzCIo+upsDkFa/NlrnzVumuQ45UcwL7a2k/n8WFIwsW8QYuQaWU2JiIKExP/LNQew3H3Hbs/bp26xC0Fw==} peerDependencies: - vite: ^2.9.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 + vite: ^2.9.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 - cypress@14.3.0: - resolution: {integrity: sha512-rRfPl9Z0/CczuYybBEoLbDVuT1OGkhYaJ0+urRCshgiDRz6QnoA0KQIQnPx7MJ3zy+VCsbUU1pV74n+6cbJEdg==} - engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + cypress@15.4.0: + resolution: {integrity: sha512-+GC/Y/LXAcaMCzfuM7vRx5okRmonceZbr0ORUAoOrZt/5n2eGK8yh04bok1bWSjZ32wRHrZESqkswQ6biArN5w==} + engines: {node: ^20.1.0 || ^22.0.0 || >=24.0.0} hasBin: true d3-array@3.2.4: @@ -3691,8 +3778,8 @@ packages: resolution: {integrity: sha512-uDn+FE1yrDzyC0pCo961B2IHbdM8y/ACZsKD4dG6WqrjV53BADjwa7D+1aom2rsNVfLyDgU/eigvlJGJ08OQ4w==} engines: {node: '>= 0.4'} - es-module-lexer@1.6.0: - resolution: {integrity: sha512-qqnD1yMU6tk/jnaMosogGySTZP8YtUgAffA9nMN+E/rjxcfRQ6IEk7IiozUjgxKoFHBGjTLnrHB/YC45r/59EQ==} + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} @@ -4109,9 +4196,6 @@ packages: get-tsconfig@4.8.1: resolution: {integrity: sha512-k9PN+cFBmaLWtVz29SkUoqU5O0slLuHJXt/2P+tMVFT+phsSGXGkp9t3rQIqdz0e+06EHNGs3oM6ZX1s2zHxRg==} - getos@3.2.1: - resolution: {integrity: sha512-U56CfOK17OKgTVqozZjUKNdkfEv6jk5WISBJ8SHoagjE6L69zOwl3Z+O8myjY9MEW3i2HPWQBt/LTbCgcC973Q==} - getpass@0.1.7: resolution: {integrity: sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==} @@ -4201,6 +4285,10 @@ packages: resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} engines: {node: '>= 0.4'} + hasha@5.2.2: + resolution: {integrity: sha512-Hrp5vIK/xr5SkeN2onO32H0MgNZ0f17HRNH39WfL0SYUNOTZ5Lz1TJ8Pajo/87dYGEFlLMm7mIc/k/s6Bvz9HQ==} + engines: {node: '>=8'} + hasown@2.0.2: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} @@ -4556,6 +4644,9 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + js-yaml@4.1.0: resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} hasBin: true @@ -4652,10 +4743,6 @@ packages: react: ^16.6.3 || ^17.0.0 || ^18.0.0 react-dom: ^16.8.4 || ^17.0.0 || ^18.0.0 - lazy-ass@1.6.0: - resolution: {integrity: sha512-cc8oEVoctTvsFZ/Oje/kGnHbpWHYBe8IAJe4C0QNc3t8uM/0Y8+erSz/7Y1ALuXTEZTMvxXwO6YbX1ey3ujiZw==} - engines: {node: '> 0.8'} - levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} @@ -4771,6 +4858,9 @@ packages: loupe@3.1.3: resolution: {integrity: sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==} + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lower-case@2.0.2: resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==} @@ -5817,6 +5907,9 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + strip-literal@3.0.0: + resolution: {integrity: sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA==} + stylis@4.2.0: resolution: {integrity: sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==} @@ -5851,6 +5944,12 @@ packages: resolution: {integrity: sha512-fWZqNBZNNFp/7mTUy1fSsydhKsAKJ+u90Nk7kOK5Gcq9vObaqLBLjWFDBkyVU9Vvc6Y71VbOevMuGhqv02bT+Q==} engines: {node: ^14.18.0 || >=16.0.0} + systeminformation@5.27.7: + resolution: {integrity: sha512-saaqOoVEEFaux4v0K8Q7caiauRwjXC4XbD2eH60dxHXbpKxQ8kH9Rf7Jh+nryKpOUSEFxtCdBlSUx0/lO6rwRg==} + engines: {node: '>=8.0.0'} + os: [darwin, linux, win32, freebsd, openbsd, netbsd, sunos, android] + hasBin: true + tcomb-validation@3.4.1: resolution: {integrity: sha512-urVVMQOma4RXwiVCa2nM2eqrAomHROHvWPuj6UkDGz/eb5kcy0x6P0dVt6kzpUZtYMNoAqJLWmz1BPtxrtjtrA==} @@ -5905,12 +6004,16 @@ packages: resolution: {integrity: sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==} engines: {node: '>=12.0.0'} + tinyglobby@0.2.14: + resolution: {integrity: sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==} + engines: {node: '>=12.0.0'} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} - tinypool@1.0.2: - resolution: {integrity: sha512-al6n+QEANGFOMf/dmUMsuS5/r9B06uwlyNjZZql/zv8J7ybHCgoihBNORZCY2mzUuAnomQa2JdhyHKzZxPCrFA==} + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} engines: {node: ^18.0.0 || >=20.0.0} tinyrainbow@2.0.0: @@ -5921,6 +6024,10 @@ packages: resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} engines: {node: '>=14.0.0'} + tinyspy@4.0.3: + resolution: {integrity: sha512-t2T/WLB2WRgZ9EpE4jgPJ9w+i66UZfDc8wHh0xrwiRNN+UwH98GIJkTeZqX9rg0i0ptwzqW+uYeIF0T4F8LR7A==} + engines: {node: '>=14.0.0'} + tldts-core@6.1.61: resolution: {integrity: sha512-In7VffkDWUPgwa+c9picLUxvb0RltVwTkSgMNFgvlGSWveCzGBemBqTsgJCL4EDFWZ6WH0fKTsot6yNhzy3ZzQ==} @@ -5928,8 +6035,8 @@ packages: resolution: {integrity: sha512-rv8LUyez4Ygkopqn+M6OLItAOT9FF3REpPQDkdMx5ix8w4qkuE7Vo2o/vw1nxKQYmJDV8JpAMJQr1b+lTKf0FA==} hasBin: true - tmp@0.2.3: - resolution: {integrity: sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w==} + tmp@0.2.5: + resolution: {integrity: sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==} engines: {node: '>=14.14'} to-regex-range@5.0.1: @@ -6042,6 +6149,10 @@ packages: resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} engines: {node: '>=10'} + type-fest@0.8.1: + resolution: {integrity: sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==} + engines: {node: '>=8'} + type-fest@2.19.0: resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==} engines: {node: '>=12.20'} @@ -6189,29 +6300,29 @@ packages: victory-vendor@36.9.2: resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==} - vite-node@3.1.2: - resolution: {integrity: sha512-/8iMryv46J3aK13iUXsei5G/A3CUlW4665THCPS+K8xAaqrVWiGB4RfXMQXCLjpK9P2eK//BczrVkn5JLAk6DA==} + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true - vite-plugin-svgr@3.3.0: - resolution: {integrity: sha512-vWZMCcGNdPqgziYFKQ3Y95XP0d0YGp28+MM3Dp9cTa/px5CKcHHrIoPl2Jw81rgVm6/ZUNONzjXbZQZ7Kw66og==} + vite-plugin-svgr@4.5.0: + resolution: {integrity: sha512-W+uoSpmVkSmNOGPSsDCWVW/DDAyv+9fap9AZXBvWiQqrboJ08j2vh0tFxTD/LjwqwAd3yYSVJgm54S/1GhbdnA==} peerDependencies: - vite: ^2.6.0 || 3 || 4 + vite: '>=2.6.0' - vite@6.3.6: - resolution: {integrity: sha512-0msEVHJEScQbhkbVTb/4iHZdJ6SXp/AvxL2sjwYQFfBqleHtnCqv1J3sa9zbWz/6kW1m9Tfzn92vW+kZ1WV6QA==} - engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + vite@7.1.11: + resolution: {integrity: sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg==} + engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: - '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@types/node': ^20.19.0 || >=22.12.0 jiti: '>=1.21.0' - less: '*' + less: ^4.0.0 lightningcss: ^1.21.0 - sass: '*' - sass-embedded: '*' - stylus: '*' - sugarss: '*' + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 terser: ^5.16.0 tsx: ^4.8.1 yaml: ^2.3.0 @@ -6239,16 +6350,56 @@ packages: yaml: optional: true - vitest@3.1.2: - resolution: {integrity: sha512-WaxpJe092ID1C0mr+LH9MmNrhfzi8I65EX/NRU/Ld016KqQNRgxSOlGNP1hHN+a/F8L15Mh8klwaF77zR3GeDQ==} + vite@7.1.3: + resolution: {integrity: sha512-OOUi5zjkDxYrKhTV3V7iKsoS37VUM7v40+HuwEmcrsf11Cdx9y3DIr2Px6liIcZFwt3XSRpQvFpL3WVy7ApkGw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.3.0 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@3.2.4: + resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@types/debug': ^4.1.12 '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 - '@vitest/browser': 3.1.2 - '@vitest/ui': 3.1.2 + '@vitest/browser': 3.2.4 + '@vitest/ui': 3.2.4 happy-dom: '*' jsdom: '*' peerDependenciesMeta: @@ -6450,9 +6601,9 @@ packages: snapshots: - '@4tw/cypress-drag-drop@2.3.0(cypress@14.3.0)': + '@4tw/cypress-drag-drop@2.3.0(cypress@15.4.0)': dependencies: - cypress: 14.3.0 + cypress: 15.4.0 '@adobe/css-tools@4.4.1': {} @@ -6534,8 +6685,8 @@ snapshots: '@ampproject/remapping@2.3.0': dependencies: - '@jridgewell/gen-mapping': 0.3.8 - '@jridgewell/trace-mapping': 0.3.25 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 '@babel/code-frame@7.26.2': dependencies: @@ -6558,7 +6709,7 @@ snapshots: '@babel/traverse': 7.25.9 '@babel/types': 7.27.0 convert-source-map: 2.0.0 - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.1(supports-color@8.1.1) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 7.6.3 @@ -6569,8 +6720,8 @@ snapshots: dependencies: '@babel/parser': 7.27.0 '@babel/types': 7.27.0 - '@jridgewell/gen-mapping': 0.3.8 - '@jridgewell/trace-mapping': 0.3.25 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 '@babel/helper-compilation-targets@7.25.9': @@ -6629,7 +6780,7 @@ snapshots: '@babel/parser': 7.27.0 '@babel/template': 7.27.0 '@babel/types': 7.27.0 - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.1(supports-color@8.1.1) globals: 11.12.0 transitivePeerDependencies: - supports-color @@ -6678,10 +6829,7 @@ snapshots: '@types/tough-cookie': 4.0.5 tough-cookie: 4.1.4 - '@colors/colors@1.5.0': - optional: true - - '@cypress/request@3.0.8': + '@cypress/request@3.0.9': dependencies: aws-sign2: 0.7.0 aws4: 1.13.2 @@ -6985,7 +7133,7 @@ snapshots: '@eslint/config-array@0.21.0': dependencies: '@eslint/object-schema': 2.1.6 - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.0 minimatch: 3.1.2 transitivePeerDependencies: - supports-color @@ -6999,7 +7147,7 @@ snapshots: '@eslint/eslintrc@3.3.1': dependencies: ajv: 6.12.6 - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.0 espree: 10.4.0 globals: 14.0.0 ignore: 5.3.2 @@ -7202,12 +7350,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@22.18.1)(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@7.1.11(@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@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + vite: 7.1.11(@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 @@ -7215,7 +7363,6 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/trace-mapping': 0.3.31 - optional: true '@jridgewell/gen-mapping@0.3.8': dependencies: @@ -7235,8 +7382,7 @@ snapshots: '@jridgewell/sourcemap-codec@1.5.0': {} - '@jridgewell/sourcemap-codec@1.5.5': - optional: true + '@jridgewell/sourcemap-codec@1.5.5': {} '@jridgewell/trace-mapping@0.3.25': dependencies: @@ -7247,11 +7393,10 @@ snapshots: dependencies: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - optional: true '@kwsites/file-exists@1.1.1': dependencies: - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.1(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -7326,7 +7471,7 @@ snapshots: '@mui/private-theming@7.1.0(@types/react@19.1.6)(react@19.1.0)': dependencies: - '@babel/runtime': 7.27.1 + '@babel/runtime': 7.28.4 '@mui/utils': 7.1.0(@types/react@19.1.6)(react@19.1.0) prop-types: 15.8.1 react: 19.1.0 @@ -7335,7 +7480,7 @@ snapshots: '@mui/styled-engine@7.1.0(@emotion/react@11.13.5(@types/react@19.1.6)(react@19.1.0))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@19.1.6)(react@19.1.0))(@types/react@19.1.6)(react@19.1.0))(react@19.1.0)': dependencies: - '@babel/runtime': 7.27.1 + '@babel/runtime': 7.28.4 '@emotion/cache': 11.13.5 '@emotion/serialize': 1.3.3 '@emotion/sheet': 1.4.0 @@ -7348,7 +7493,7 @@ snapshots: '@mui/system@7.1.0(@emotion/react@11.13.5(@types/react@19.1.6)(react@19.1.0))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@19.1.6)(react@19.1.0))(@types/react@19.1.6)(react@19.1.0))(@types/react@19.1.6)(react@19.1.0)': dependencies: - '@babel/runtime': 7.27.1 + '@babel/runtime': 7.28.4 '@mui/private-theming': 7.1.0(@types/react@19.1.6)(react@19.1.0) '@mui/styled-engine': 7.1.0(@emotion/react@11.13.5(@types/react@19.1.6)(react@19.1.0))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@19.1.6)(react@19.1.0))(@types/react@19.1.6)(react@19.1.0))(react@19.1.0) '@mui/types': 7.4.2(@types/react@19.1.6) @@ -7364,7 +7509,7 @@ snapshots: '@mui/types@7.4.2(@types/react@19.1.6)': dependencies: - '@babel/runtime': 7.27.1 + '@babel/runtime': 7.28.4 optionalDependencies: '@types/react': 19.1.6 @@ -7517,6 +7662,8 @@ snapshots: react: 19.1.0 react-dom: 19.1.0(react@19.1.0) + '@rolldown/pluginutils@1.0.0-beta.32': {} + '@rollup/pluginutils@5.1.3(rollup@4.50.1)': dependencies: '@types/estree': 1.0.7 @@ -7525,6 +7672,14 @@ snapshots: optionalDependencies: rollup: 4.50.1 + '@rollup/pluginutils@5.2.0(rollup@4.50.1)': + dependencies: + '@types/estree': 1.0.8 + estree-walker: 2.0.2 + picomatch: 4.0.3 + optionalDependencies: + rollup: 4.50.1 + '@rollup/rollup-android-arm-eabi@4.40.1': optional: true @@ -7735,12 +7890,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@22.18.1)(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@7.1.11(@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@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + vite: 7.1.11(@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: @@ -7765,11 +7920,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@22.18.1)(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@7.1.11(@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@22.18.1)(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@7.1.11(@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@22.18.1)(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@7.1.11(@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 @@ -7779,7 +7934,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@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + vite: 7.1.11(@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 @@ -7868,33 +8023,63 @@ snapshots: '@swc/core-darwin-arm64@1.10.11': optional: true + '@swc/core-darwin-arm64@1.13.5': + optional: true + '@swc/core-darwin-x64@1.10.11': optional: true + '@swc/core-darwin-x64@1.13.5': + optional: true + '@swc/core-linux-arm-gnueabihf@1.10.11': optional: true + '@swc/core-linux-arm-gnueabihf@1.13.5': + optional: true + '@swc/core-linux-arm64-gnu@1.10.11': optional: true + '@swc/core-linux-arm64-gnu@1.13.5': + optional: true + '@swc/core-linux-arm64-musl@1.10.11': optional: true + '@swc/core-linux-arm64-musl@1.13.5': + optional: true + '@swc/core-linux-x64-gnu@1.10.11': optional: true + '@swc/core-linux-x64-gnu@1.13.5': + optional: true + '@swc/core-linux-x64-musl@1.10.11': optional: true + '@swc/core-linux-x64-musl@1.13.5': + optional: true + '@swc/core-win32-arm64-msvc@1.10.11': optional: true + '@swc/core-win32-arm64-msvc@1.13.5': + optional: true + '@swc/core-win32-ia32-msvc@1.10.11': optional: true + '@swc/core-win32-ia32-msvc@1.13.5': + optional: true + '@swc/core-win32-x64-msvc@1.10.11': optional: true + '@swc/core-win32-x64-msvc@1.13.5': + optional: true + '@swc/core@1.10.11': dependencies: '@swc/counter': 0.1.3 @@ -7911,12 +8096,32 @@ snapshots: '@swc/core-win32-ia32-msvc': 1.10.11 '@swc/core-win32-x64-msvc': 1.10.11 + '@swc/core@1.13.5': + dependencies: + '@swc/counter': 0.1.3 + '@swc/types': 0.1.24 + optionalDependencies: + '@swc/core-darwin-arm64': 1.13.5 + '@swc/core-darwin-x64': 1.13.5 + '@swc/core-linux-arm-gnueabihf': 1.13.5 + '@swc/core-linux-arm64-gnu': 1.13.5 + '@swc/core-linux-arm64-musl': 1.13.5 + '@swc/core-linux-x64-gnu': 1.13.5 + '@swc/core-linux-x64-musl': 1.13.5 + '@swc/core-win32-arm64-msvc': 1.13.5 + '@swc/core-win32-ia32-msvc': 1.13.5 + '@swc/core-win32-x64-msvc': 1.13.5 + '@swc/counter@0.1.3': {} '@swc/types@0.1.17': dependencies: '@swc/counter': 0.1.3 + '@swc/types@0.1.24': + dependencies: + '@swc/counter': 0.1.3 + '@tanstack/history@1.99.13': {} '@tanstack/query-core@5.51.24': {} @@ -7959,11 +8164,11 @@ snapshots: '@tanstack/store@0.7.0': {} - '@testing-library/cypress@10.0.3(cypress@14.3.0)': + '@testing-library/cypress@10.1.0(cypress@15.4.0)': dependencies: - '@babel/runtime': 7.27.1 + '@babel/runtime': 7.28.4 '@testing-library/dom': 10.4.0 - cypress: 14.3.0 + cypress: 15.4.0 '@testing-library/dom@10.4.0': dependencies: @@ -8047,6 +8252,10 @@ snapshots: dependencies: '@types/deep-eql': 4.0.2 + '@types/chai@5.2.2': + dependencies: + '@types/deep-eql': 4.0.2 + '@types/chart.js@2.9.41': dependencies: moment: 2.30.1 @@ -8202,6 +8411,8 @@ snapshots: '@types/throttle-debounce@1.1.1': {} + '@types/tmp@0.2.6': {} + '@types/tough-cookie@4.0.5': {} '@types/trusted-types@2.0.7': {} @@ -8259,7 +8470,7 @@ snapshots: '@typescript-eslint/types': 8.29.0 '@typescript-eslint/typescript-estree': 8.29.0(typescript@5.7.3) '@typescript-eslint/visitor-keys': 8.29.0 - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.0 eslint: 9.31.0(jiti@2.4.2) typescript: 5.7.3 transitivePeerDependencies: @@ -8271,7 +8482,7 @@ snapshots: '@typescript-eslint/types': 8.38.0 '@typescript-eslint/typescript-estree': 8.38.0(typescript@5.7.3) '@typescript-eslint/visitor-keys': 8.38.0 - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.0 eslint: 9.31.0(jiti@2.4.2) typescript: 5.7.3 transitivePeerDependencies: @@ -8281,7 +8492,7 @@ snapshots: dependencies: '@typescript-eslint/tsconfig-utils': 8.38.0(typescript@5.7.3) '@typescript-eslint/types': 8.38.0 - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.1(supports-color@8.1.1) typescript: 5.7.3 transitivePeerDependencies: - supports-color @@ -8304,7 +8515,7 @@ snapshots: dependencies: '@typescript-eslint/typescript-estree': 8.29.0(typescript@5.7.3) '@typescript-eslint/utils': 8.29.0(eslint@9.31.0(jiti@2.4.2))(typescript@5.7.3) - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.1(supports-color@8.1.1) eslint: 9.31.0(jiti@2.4.2) ts-api-utils: 2.1.0(typescript@5.7.3) typescript: 5.7.3 @@ -8316,7 +8527,7 @@ snapshots: '@typescript-eslint/types': 8.38.0 '@typescript-eslint/typescript-estree': 8.38.0(typescript@5.7.3) '@typescript-eslint/utils': 8.38.0(eslint@9.31.0(jiti@2.4.2))(typescript@5.7.3) - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.0 eslint: 9.31.0(jiti@2.4.2) ts-api-utils: 2.1.0(typescript@5.7.3) typescript: 5.7.3 @@ -8331,7 +8542,7 @@ snapshots: dependencies: '@typescript-eslint/types': 8.29.0 '@typescript-eslint/visitor-keys': 8.29.0 - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.1(supports-color@8.1.1) fast-glob: 3.3.2 is-glob: 4.0.3 minimatch: 9.0.5 @@ -8347,7 +8558,7 @@ snapshots: '@typescript-eslint/tsconfig-utils': 8.38.0(typescript@5.7.3) '@typescript-eslint/types': 8.38.0 '@typescript-eslint/visitor-keys': 8.38.0 - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.0 fast-glob: 3.3.2 is-glob: 4.0.3 minimatch: 9.0.5 @@ -8391,18 +8602,20 @@ snapshots: '@ungap/structured-clone@1.3.0': {} - '@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))': + '@vitejs/plugin-react-swc@4.0.1(vite@7.1.11(@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@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + '@rolldown/pluginutils': 1.0.0-beta.32 + '@swc/core': 1.13.5 + vite: 7.1.11(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) transitivePeerDependencies: - '@swc/helpers' - '@vitest/coverage-v8@3.1.2(vitest@3.1.2)': + '@vitest/coverage-v8@3.2.4(vitest@3.2.4)': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 - debug: 4.4.0(supports-color@8.1.1) + ast-v8-to-istanbul: 0.3.4 + debug: 4.4.1(supports-color@8.1.1) istanbul-lib-coverage: 3.2.2 istanbul-lib-report: 3.0.1 istanbul-lib-source-maps: 5.0.6 @@ -8412,7 +8625,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@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: 3.2.4(@types/debug@4.1.12)(@types/node@22.18.1)(@vitest/ui@3.2.4)(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 @@ -8423,38 +8636,40 @@ snapshots: chai: 5.2.0 tinyrainbow: 2.0.0 - '@vitest/expect@3.1.2': + '@vitest/expect@3.2.4': dependencies: - '@vitest/spy': 3.1.2 - '@vitest/utils': 3.1.2 + '@types/chai': 5.2.2 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 chai: 5.2.0 tinyrainbow: 2.0.0 - '@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/mocker@3.2.4(msw@2.6.5(@types/node@22.18.1)(typescript@5.7.3))(vite@7.1.3(@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 + '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.17 optionalDependencies: 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) + vite: 7.1.3(@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: tinyrainbow: 2.0.0 - '@vitest/pretty-format@3.1.2': + '@vitest/pretty-format@3.2.4': dependencies: tinyrainbow: 2.0.0 - '@vitest/runner@3.1.2': + '@vitest/runner@3.2.4': dependencies: - '@vitest/utils': 3.1.2 + '@vitest/utils': 3.2.4 pathe: 2.0.3 + strip-literal: 3.0.0 - '@vitest/snapshot@3.1.2': + '@vitest/snapshot@3.2.4': dependencies: - '@vitest/pretty-format': 3.1.2 + '@vitest/pretty-format': 3.2.4 magic-string: 0.30.17 pathe: 2.0.3 @@ -8462,20 +8677,20 @@ snapshots: dependencies: tinyspy: 3.0.2 - '@vitest/spy@3.1.2': + '@vitest/spy@3.2.4': dependencies: - tinyspy: 3.0.2 + tinyspy: 4.0.3 - '@vitest/ui@3.1.2(vitest@3.1.2)': + '@vitest/ui@3.2.4(vitest@3.2.4)': dependencies: - '@vitest/utils': 3.1.2 + '@vitest/utils': 3.2.4 fflate: 0.8.2 flatted: 3.3.3 pathe: 2.0.3 sirv: 3.0.1 - tinyglobby: 0.2.13 + tinyglobby: 0.2.14 tinyrainbow: 2.0.0 - 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: 3.2.4(@types/debug@4.1.12)(@types/node@22.18.1)(@vitest/ui@3.2.4)(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: @@ -8483,10 +8698,10 @@ snapshots: loupe: 3.1.3 tinyrainbow: 2.0.0 - '@vitest/utils@3.1.2': + '@vitest/utils@3.2.4': dependencies: - '@vitest/pretty-format': 3.1.2 - loupe: 3.1.3 + '@vitest/pretty-format': 3.2.4 + loupe: 3.2.1 tinyrainbow: 2.0.0 '@vueless/storybook-dark-mode@9.0.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': @@ -8511,7 +8726,7 @@ snapshots: agent-base@7.1.1: dependencies: - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.1(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -8527,17 +8742,17 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 - akamai-cds-react-components@0.0.1-alpha.14(@linode/design-language-system@5.0.0)(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + akamai-cds-react-components@0.0.1-alpha.15(@linode/design-language-system@5.0.0)(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: '@lit/react': 1.0.8(@types/react@19.1.6) - akamai-cds-web-components: 0.0.1-alpha.14(@linode/design-language-system@5.0.0) + akamai-cds-web-components: 0.0.1-alpha.15(@linode/design-language-system@5.0.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) transitivePeerDependencies: - '@linode/design-language-system' - '@types/react' - akamai-cds-web-components@0.0.1-alpha.14(@linode/design-language-system@5.0.0): + akamai-cds-web-components@0.0.1-alpha.15(@linode/design-language-system@5.0.0): dependencies: '@linode/design-language-system': 5.0.0 lit: 3.3.1 @@ -8668,9 +8883,13 @@ snapshots: dependencies: tslib: 2.8.1 - astral-regex@2.0.0: {} + ast-v8-to-istanbul@0.3.4: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 9.0.1 - async@3.2.6: {} + astral-regex@2.0.0: {} asynckit@0.4.0: {} @@ -8706,7 +8925,7 @@ snapshots: babel-plugin-macros@3.1.0: dependencies: - '@babel/runtime': 7.27.1 + '@babel/runtime': 7.28.4 cosmiconfig: 7.1.0 resolve: 1.22.8 @@ -8815,7 +9034,7 @@ snapshots: canvg@3.0.11: dependencies: - '@babel/runtime': 7.27.1 + '@babel/runtime': 7.28.4 '@types/raf': 3.4.3 core-js: 3.39.0 raf: 3.4.1 @@ -8842,7 +9061,7 @@ snapshots: assertion-error: 2.0.1 check-error: 2.1.1 deep-eql: 5.0.2 - loupe: 3.1.3 + loupe: 3.2.1 pathval: 2.0.0 chalk@3.0.0: @@ -8881,8 +9100,6 @@ snapshots: check-error@2.1.1: {} - check-more-types@2.24.0: {} - chokidar@3.6.0: dependencies: anymatch: 3.1.3 @@ -8911,11 +9128,11 @@ snapshots: dependencies: restore-cursor: 5.1.0 - cli-table3@0.6.5: + cli-table3@0.6.1: dependencies: string-width: 4.2.3 optionalDependencies: - '@colors/colors': 1.5.0 + colors: 1.4.0 cli-truncate@2.1.0: dependencies: @@ -8971,6 +9188,9 @@ snapshots: colorette@2.0.20: {} + colors@1.4.0: + optional: true + combined-stream@1.0.8: dependencies: delayed-stream: 1.0.0 @@ -9067,19 +9287,19 @@ snapshots: csstype@3.1.3: {} - cypress-axe@1.6.0(axe-core@4.10.2)(cypress@14.3.0): + cypress-axe@1.7.0(axe-core@4.10.2)(cypress@15.4.0): dependencies: axe-core: 4.10.2 - cypress: 14.3.0 + cypress: 15.4.0 - cypress-file-upload@5.0.8(cypress@14.3.0): + cypress-file-upload@5.0.8(cypress@15.4.0): dependencies: - cypress: 14.3.0 + cypress: 15.4.0 - cypress-mochawesome-reporter@3.8.2(cypress@14.3.0)(mocha@10.8.2): + cypress-mochawesome-reporter@3.8.2(cypress@15.4.0)(mocha@10.8.2): dependencies: commander: 10.0.1 - cypress: 14.3.0 + cypress: 15.4.0 fs-extra: 10.1.0 mochawesome: 7.1.3(mocha@10.8.2) mochawesome-merge: 4.4.1 @@ -9089,7 +9309,7 @@ snapshots: cypress-multi-reporters@2.0.5(mocha@10.8.2): dependencies: - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.0 lodash: 4.17.21 mocha: 10.8.2 semver: 7.6.3 @@ -9098,42 +9318,42 @@ snapshots: cypress-on-fix@1.1.0: dependencies: - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.0 transitivePeerDependencies: - supports-color - cypress-real-events@1.14.0(cypress@14.3.0): + cypress-real-events@1.14.0(cypress@15.4.0): dependencies: - cypress: 14.3.0 + cypress: 15.4.0 - 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)): + cypress-vite@1.8.0(vite@7.1.11(@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@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + debug: 4.4.1(supports-color@8.1.1) + vite: 7.1.11(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) transitivePeerDependencies: - supports-color - cypress@14.3.0: + cypress@15.4.0: dependencies: - '@cypress/request': 3.0.8 + '@cypress/request': 3.0.9 '@cypress/xvfb': 1.2.4(supports-color@8.1.1) '@types/sinonjs__fake-timers': 8.1.1 '@types/sizzle': 2.3.9 + '@types/tmp': 0.2.6 arch: 2.2.0 blob-util: 2.0.2 bluebird: 3.7.2 buffer: 5.7.1 cachedir: 2.4.0 chalk: 4.1.2 - check-more-types: 2.24.0 ci-info: 4.1.0 cli-cursor: 3.1.0 - cli-table3: 0.6.5 + cli-table3: 0.6.1 commander: 6.2.1 common-tags: 1.8.2 dayjs: 1.11.13 - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.1(supports-color@8.1.1) enquirer: 2.4.1 eventemitter2: 6.4.7 execa: 4.1.0 @@ -9141,9 +9361,8 @@ snapshots: extract-zip: 2.0.1(supports-color@8.1.1) figures: 3.2.0 fs-extra: 9.1.0 - getos: 3.2.1 + hasha: 5.2.2 is-installed-globally: 0.4.0 - lazy-ass: 1.6.0 listr2: 3.14.0(enquirer@2.4.1) lodash: 4.17.21 log-symbols: 4.1.0 @@ -9155,7 +9374,8 @@ snapshots: request-progress: 3.0.0 semver: 7.6.3 supports-color: 8.1.1 - tmp: 0.2.3 + systeminformation: 5.27.7 + tmp: 0.2.5 tree-kill: 1.2.2 untildify: 4.0.0 yauzl: 2.10.0 @@ -9237,11 +9457,9 @@ snapshots: optionalDependencies: supports-color: 8.1.1 - debug@4.4.0(supports-color@8.1.1): + debug@4.4.0: dependencies: ms: 2.1.3 - optionalDependencies: - supports-color: 8.1.1 debug@4.4.1(supports-color@8.1.1): dependencies: @@ -9301,7 +9519,7 @@ snapshots: dom-helpers@5.2.1: dependencies: - '@babel/runtime': 7.27.1 + '@babel/runtime': 7.28.4 csstype: 3.1.3 dompurify@3.2.4: @@ -9437,7 +9655,7 @@ snapshots: iterator.prototype: 1.1.5 safe-array-concat: 1.1.3 - es-module-lexer@1.6.0: {} + es-module-lexer@1.7.0: {} es-object-atoms@1.1.1: dependencies: @@ -9462,7 +9680,7 @@ snapshots: esbuild-register@3.6.0(esbuild@0.25.3): dependencies: - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.0 esbuild: 0.25.3 transitivePeerDependencies: - supports-color @@ -9665,7 +9883,7 @@ snapshots: ajv: 6.12.6 chalk: 4.1.2 cross-spawn: 7.0.6 - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.0 escape-string-regexp: 4.0.0 eslint-scope: 8.4.0 eslint-visitor-keys: 4.2.1 @@ -9755,7 +9973,7 @@ snapshots: extract-zip@2.0.1(supports-color@8.1.1): dependencies: - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.1(supports-color@8.1.1) get-stream: 5.2.0 yauzl: 2.10.0 optionalDependencies: @@ -9808,6 +10026,10 @@ snapshots: optionalDependencies: picomatch: 4.0.2 + fdir@6.4.4(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.3 + fdir@6.5.0(picomatch@4.0.3): optionalDependencies: picomatch: 4.0.3 @@ -9979,10 +10201,6 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 - getos@3.2.1: - dependencies: - async: 3.2.6 - getpass@0.1.7: dependencies: assert-plus: 1.0.0 @@ -10068,6 +10286,11 @@ snapshots: dependencies: has-symbols: 1.1.0 + hasha@5.2.2: + dependencies: + is-stream: 2.0.1 + type-fest: 0.8.1 + hasown@2.0.2: dependencies: function-bind: 1.1.2 @@ -10126,7 +10349,7 @@ snapshots: http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.1 - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.0 transitivePeerDependencies: - supports-color @@ -10139,7 +10362,7 @@ snapshots: https-proxy-agent@7.0.5: dependencies: agent-base: 7.1.1 - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.0 transitivePeerDependencies: - supports-color @@ -10376,8 +10599,8 @@ snapshots: istanbul-lib-source-maps@5.0.6: dependencies: - '@jridgewell/trace-mapping': 0.3.25 - debug: 4.4.0(supports-color@8.1.1) + '@jridgewell/trace-mapping': 0.3.31 + debug: 4.4.1(supports-color@8.1.1) istanbul-lib-coverage: 3.2.2 transitivePeerDependencies: - supports-color @@ -10409,6 +10632,8 @@ snapshots: js-tokens@4.0.0: {} + js-tokens@9.0.1: {} + js-yaml@4.1.0: dependencies: argparse: 2.0.1 @@ -10535,8 +10760,6 @@ snapshots: react: 19.1.0 react-dom: 19.1.0(react@19.1.0) - lazy-ass@1.6.0: {} - levn@0.4.1: dependencies: prelude-ls: 1.2.1 @@ -10556,7 +10779,7 @@ snapshots: dependencies: chalk: 5.4.1 commander: 13.1.0 - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.0 execa: 8.0.1 lilconfig: 3.1.3 listr2: 8.2.5 @@ -10574,7 +10797,7 @@ snapshots: log-update: 4.0.0 p-map: 4.0.0 rfdc: 1.4.1 - rxjs: 7.8.1 + rxjs: 7.8.2 through: 2.3.8 wrap-ansi: 7.0.0 optionalDependencies: @@ -10669,6 +10892,8 @@ snapshots: loupe@3.1.3: {} + loupe@3.2.1: {} + lower-case@2.0.2: dependencies: tslib: 2.8.1 @@ -10794,7 +11019,7 @@ snapshots: mocha-junit-reporter@2.2.1(mocha@10.8.2): dependencies: - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.0 md5: 2.3.0 mkdirp: 3.0.1 mocha: 10.8.2 @@ -11290,7 +11515,7 @@ snapshots: react-transition-group@4.4.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: - '@babel/runtime': 7.27.1 + '@babel/runtime': 7.28.4 dom-helpers: 5.2.1 loose-envify: 1.4.0 prop-types: 15.8.1 @@ -11662,7 +11887,7 @@ snapshots: dependencies: '@kwsites/file-exists': 1.1.1 '@kwsites/promise-deferred': 1.1.1 - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.0 transitivePeerDependencies: - supports-color @@ -11861,6 +12086,10 @@ snapshots: strip-json-comments@3.1.1: {} + strip-literal@3.0.0: + dependencies: + js-tokens: 9.0.1 + stylis@4.2.0: {} sucrase@3.35.0: @@ -11895,6 +12124,8 @@ snapshots: '@pkgr/core': 0.2.0 tslib: 2.8.1 + systeminformation@5.27.7: {} + tcomb-validation@3.4.1: dependencies: tcomb: 3.2.29 @@ -11949,24 +12180,31 @@ snapshots: fdir: 6.4.4(picomatch@4.0.2) picomatch: 4.0.2 + tinyglobby@0.2.14: + dependencies: + fdir: 6.4.4(picomatch@4.0.3) + picomatch: 4.0.3 + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 - tinypool@1.0.2: {} + tinypool@1.1.1: {} tinyrainbow@2.0.0: {} tinyspy@3.0.2: {} + tinyspy@4.0.3: {} + tldts-core@6.1.61: {} tldts@6.1.61: dependencies: tldts-core: 6.1.61 - tmp@0.2.3: {} + tmp@0.2.5: {} to-regex-range@5.0.1: dependencies: @@ -12029,13 +12267,13 @@ snapshots: optionalDependencies: '@mui/material': 7.1.0(@emotion/react@11.13.5(@types/react@19.1.6)(react@19.1.0))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@19.1.6)(react@19.1.0))(@types/react@19.1.6)(react@19.1.0))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - tsup@8.4.0(@swc/core@1.10.11)(jiti@2.4.2)(postcss@8.5.6)(tsx@4.19.3)(typescript@5.7.3)(yaml@2.6.1): + tsup@8.4.0(@swc/core@1.13.5)(jiti@2.4.2)(postcss@8.5.6)(tsx@4.19.3)(typescript@5.7.3)(yaml@2.6.1): dependencies: bundle-require: 5.1.0(esbuild@0.25.3) cac: 6.7.14 chokidar: 4.0.3 consola: 3.4.2 - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.0 esbuild: 0.25.3 joycon: 3.1.1 picocolors: 1.1.1 @@ -12048,7 +12286,7 @@ snapshots: tinyglobby: 0.2.13 tree-kill: 1.2.2 optionalDependencies: - '@swc/core': 1.10.11 + '@swc/core': 1.13.5 postcss: 8.5.6 typescript: 5.7.3 transitivePeerDependencies: @@ -12076,6 +12314,8 @@ snapshots: type-fest@0.21.3: {} + type-fest@0.8.1: {} + type-fest@2.19.0: {} type-fest@4.27.0: {} @@ -12249,13 +12489,13 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.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): + vite-node@3.2.4(@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.1(supports-color@8.1.1) - es-module-lexer: 1.6.0 + es-module-lexer: 1.7.0 pathe: 2.0.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) + vite: 7.1.11(@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 @@ -12270,18 +12510,34 @@ snapshots: - tsx - yaml - 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)): + vite-plugin-svgr@4.5.0(rollup@4.50.1)(typescript@5.7.3)(vite@7.1.11(@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) + '@rollup/pluginutils': 5.2.0(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@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + vite: 7.1.11(@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@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1): + vite@7.1.11(@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) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.50.1 + tinyglobby: 0.2.15 + optionalDependencies: + '@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 + + vite@7.1.3(@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) @@ -12297,33 +12553,35 @@ snapshots: 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@3.2.4(@types/debug@4.1.12)(@types/node@22.18.1)(@vitest/ui@3.2.4)(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@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 - '@vitest/spy': 3.1.2 - '@vitest/utils': 3.1.2 + '@types/chai': 5.2.2 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(msw@2.6.5(@types/node@22.18.1)(typescript@5.7.3))(vite@7.1.3(@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.2.4 + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 chai: 5.2.0 - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.1(supports-color@8.1.1) expect-type: 1.2.1 magic-string: 0.30.17 pathe: 2.0.3 + picomatch: 4.0.3 std-env: 3.9.0 tinybench: 2.9.0 tinyexec: 0.3.2 - tinyglobby: 0.2.13 - tinypool: 1.0.2 + tinyglobby: 0.2.15 + tinypool: 1.1.1 tinyrainbow: 2.0.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) - 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) + vite: 7.1.3(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + vite-node: 3.2.4(@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': 22.18.1 - '@vitest/ui': 3.1.2(vitest@3.1.2) + '@vitest/ui': 3.2.4(vitest@3.2.4) jsdom: 24.1.3 transitivePeerDependencies: - jiti