diff --git a/packages/api-v4/CHANGELOG.md b/packages/api-v4/CHANGELOG.md index f7407c4d4c8..4256b21626d 100644 --- a/packages/api-v4/CHANGELOG.md +++ b/packages/api-v4/CHANGELOG.md @@ -1,3 +1,33 @@ +## [2026-02-25] - v0.157.0 + + +### Added: + +- New quota properties ([#13177](https://github.com/linode/manager/pull/13177)) +- `Maintenance Policy` to Linode Capabilities ([#13269](https://github.com/linode/manager/pull/13269)) + +### Changed: + +- Adjust Custom HTTPS Destination types ([#13274](https://github.com/linode/manager/pull/13274)) +- Adjust Custom HTTPS Destination types: content type, data compression, custom headers ([#13331](https://github.com/linode/manager/pull/13331)) +- Delivery Logs - adjust DestinationDetailsPayload type for Custom HTTPS destinations ([#13380](https://github.com/linode/manager/pull/13380)) +- New fields in the NodeBalancer details object and NodeBalancerVPC object to align with recent API updates ([#13394](https://github.com/linode/manager/pull/13394)) + +### Removed: + +- The value 'in-progress' from cloudpulse/types.ts ([#13406](https://github.com/linode/manager/pull/13406)) + +### Tech Stories: + +- Clean up unused marketplace v2 apiv4 endpoints ([#13396](https://github.com/linode/manager/pull/13396)) + +### Upcoming Features: + +- RESPROT2- Added lock permissions to IAM types (AccountAdmin and AccountViewer ) ([#13305](https://github.com/linode/manager/pull/13305)) +- Rename the marketplace contact sales POST API route ([#13368](https://github.com/linode/manager/pull/13368)) +- Deprecate connection_pool_port, add endpoints property to DatabaseHosts ([#13386](https://github.com/linode/manager/pull/13386)) +- Update types for network load balancer integration with `CloudPulse Metrics` ([#13387](https://github.com/linode/manager/pull/13387)) + ## [2026-01-26] - v0.156.0 diff --git a/packages/api-v4/package.json b/packages/api-v4/package.json index 47665198bc8..bb258733a35 100644 --- a/packages/api-v4/package.json +++ b/packages/api-v4/package.json @@ -1,6 +1,6 @@ { "name": "@linode/api-v4", - "version": "0.156.0", + "version": "0.157.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 58661ef0b5e..89e798853d5 100644 --- a/packages/api-v4/src/cloudpulse/types.ts +++ b/packages/api-v4/src/cloudpulse/types.ts @@ -9,6 +9,7 @@ export type CloudPulseServiceType = | 'firewall' | 'linode' | 'lke' + | 'netloadbalancer' | 'nodebalancer' | 'objectstorage'; export type AlertClass = 'dedicated' | 'shared'; @@ -26,7 +27,6 @@ export type AlertStatusType = | 'enabled' | 'enabling' | 'failed' - | 'in progress' | 'provisioning'; export type CriteriaConditionType = 'ALL'; export type MetricUnitType = @@ -428,6 +428,7 @@ export const capabilityServiceTypeMapping: Record< objectstorage: 'Object Storage', blockstorage: 'Block Storage', lke: 'Kubernetes', + netloadbalancer: 'Network LoadBalancer', }; /** diff --git a/packages/api-v4/src/databases/types.ts b/packages/api-v4/src/databases/types.ts index d42c0ef23e3..59df60aa1c0 100644 --- a/packages/api-v4/src/databases/types.ts +++ b/packages/api-v4/src/databases/types.ts @@ -90,7 +90,21 @@ export interface DatabaseCredentials { username: string; } +export type HostEndpointRole = + | 'primary' + | 'primary-connection-pool' + | 'standby' + | 'standby-connection-pool'; + +interface HostEndpoint { + address: string; + port: number; + private_access: boolean; + role: HostEndpointRole; +} + interface DatabaseHosts { + endpoints: HostEndpoint[]; primary: string; secondary?: string; standby?: string; @@ -106,6 +120,7 @@ type MemberType = 'failover' | 'primary'; export interface DatabaseInstance { allow_list: string[]; cluster_size: ClusterSize; + /** @Deprecated replaced by `endpoints` property */ connection_pool_port: null | number; connection_strings: ConnectionStrings[]; created: string; diff --git a/packages/api-v4/src/delivery/types.ts b/packages/api-v4/src/delivery/types.ts index 84f2d82460a..384cf4a7b26 100644 --- a/packages/api-v4/src/delivery/types.ts +++ b/packages/api-v4/src/delivery/types.ts @@ -58,7 +58,7 @@ export interface Destination extends DestinationCore, AuditData { export type DestinationDetails = | AkamaiObjectStorageDetails - | CustomHTTPsDetails; + | CustomHTTPSDetails; export interface AkamaiObjectStorageDetails { access_key_id: string; @@ -72,26 +72,50 @@ export interface AkamaiObjectStorageDetailsExtended access_key_secret: string; } -type ContentType = 'application/json' | 'application/json; charset=utf-8'; -type DataCompressionType = 'gzip' | 'None'; +export const contentType = { + Json: 'application/json', + JsonUtf8: 'application/json; charset=utf-8', +} as const; + +export type ContentType = (typeof contentType)[keyof typeof contentType] | null; + +export const dataCompressionType = { + Gzip: 'gzip', + None: 'None', +} as const; -export interface CustomHTTPsDetails { +export type DataCompressionType = + (typeof dataCompressionType)[keyof typeof dataCompressionType]; + +export interface CustomHTTPSDetails { authentication: Authentication; client_certificate_details?: ClientCertificateDetails; - content_type: ContentType; + content_type?: ContentType; custom_headers?: CustomHeader[]; data_compression: DataCompressionType; endpoint_url: string; } +export interface CustomHTTPSDetailsExtended extends CustomHTTPSDetails { + authentication: Authentication & { + details?: AuthenticationDetailsExtended; + }; +} + interface ClientCertificateDetails { - client_ca_certificate: string; - client_certificate: string; - client_private_key: string; - tls_hostname: string; + client_ca_certificate?: string; + client_certificate?: string; + client_private_key?: string; + tls_hostname?: string; } -type AuthenticationType = 'basic' | 'none'; +export const authenticationType = { + Basic: 'basic', + None: 'none', +} as const; + +export type AuthenticationType = + (typeof authenticationType)[keyof typeof authenticationType]; interface Authentication { details?: AuthenticationDetails; @@ -99,11 +123,14 @@ interface Authentication { } interface AuthenticationDetails { - basic_authentication_password: string; basic_authentication_user: string; } -interface CustomHeader { +interface AuthenticationDetailsExtended extends AuthenticationDetails { + basic_authentication_password: string; +} + +export interface CustomHeader { name: string; value: string; } @@ -134,7 +161,7 @@ export interface AkamaiObjectStorageDetailsPayload export type DestinationDetailsPayload = | AkamaiObjectStorageDetailsPayload - | CustomHTTPsDetails; + | CustomHTTPSDetailsExtended; export interface CreateDestinationPayload { details: DestinationDetailsPayload; diff --git a/packages/api-v4/src/iam/delegation.ts b/packages/api-v4/src/iam/delegation.ts index 22e5da8f911..18f439153d3 100644 --- a/packages/api-v4/src/iam/delegation.ts +++ b/packages/api-v4/src/iam/delegation.ts @@ -1,5 +1,11 @@ import { BETA_API_ROOT } from '../constants'; -import Request, { setData, setMethod, setParams, setURL } from '../request'; +import Request, { + setData, + setMethod, + setParams, + setURL, + setXFilter, +} from '../request'; import type { Account } from '../account'; import type { Token } from '../profile'; @@ -18,22 +24,27 @@ import type { IamUserRoles } from './types'; export const getChildAccountsIam = ({ params, users, -}: GetChildAccountsIamParams) => - users + filter, +}: GetChildAccountsIamParams) => { + return users ? Request>( setURL(`${BETA_API_ROOT}/iam/delegation/child-accounts?users=true`), setMethod('GET'), - setParams({ ...params }), + setParams(params), + setXFilter(filter), ) : Request>( setURL(`${BETA_API_ROOT}/iam/delegation/child-accounts`), setMethod('GET'), - setParams({ ...params }), + setParams(params), + setXFilter(filter), ); +}; export const getDelegatedChildAccountsForUser = ({ username, params, + filter, }: GetDelegatedChildAccountsForUserParams) => Request>( setURL( @@ -41,6 +52,7 @@ export const getDelegatedChildAccountsForUser = ({ ), setMethod('GET'), setParams(params), + setXFilter(filter), ); export const getChildAccountDelegates = ({ @@ -69,11 +81,13 @@ export const updateChildAccountDelegates = ({ export const getMyDelegatedChildAccounts = ({ params, + filter, }: GetMyDelegatedChildAccountsParams) => Request>( setURL(`${BETA_API_ROOT}/iam/delegation/profile/child-accounts`), setMethod('GET'), setParams(params), + setXFilter(filter), ); export const getDelegatedChildAccount = ({ euuid }: { euuid: string }) => diff --git a/packages/api-v4/src/iam/delegation.types.ts b/packages/api-v4/src/iam/delegation.types.ts index 2cb19ea628d..953ca57c467 100644 --- a/packages/api-v4/src/iam/delegation.types.ts +++ b/packages/api-v4/src/iam/delegation.types.ts @@ -1,4 +1,4 @@ -import type { Params } from 'src/types'; +import type { Filter, Params } from 'src/types'; export interface ChildAccount { company: string; @@ -6,6 +6,8 @@ export interface ChildAccount { } export interface GetChildAccountsIamParams { + enabled?: boolean; + filter?: Filter; params?: Params; users?: boolean; } @@ -15,11 +17,13 @@ export interface ChildAccountWithDelegates extends ChildAccount { } export interface GetMyDelegatedChildAccountsParams { + filter?: Filter; params?: Params; } export interface GetDelegatedChildAccountsForUserParams { enabled?: boolean; + filter?: Filter; params?: Params; username: string; } diff --git a/packages/api-v4/src/iam/types.ts b/packages/api-v4/src/iam/types.ts index 4196c0c25cb..1da75c56215 100644 --- a/packages/api-v4/src/iam/types.ts +++ b/packages/api-v4/src/iam/types.ts @@ -76,11 +76,13 @@ export type AccountAdmin = | 'cancel_account' | 'cancel_service_transfer' | 'create_child_account_token' + | 'create_lock' | 'create_profile_pat' | 'create_profile_ssh_key' | 'create_profile_tfa_secret' | 'create_service_transfer' | 'create_user' + | 'delete_lock' | 'delete_profile_pat' | 'delete_profile_phone_number' | 'delete_profile_ssh_key' @@ -98,6 +100,7 @@ export type AccountAdmin = | 'list_delegate_users' | 'list_enrolled_beta_programs' | 'list_entities' + | 'list_locks' | 'list_role_permissions' | 'list_service_transfers' | 'list_user_delegate_accounts' @@ -123,6 +126,7 @@ export type AccountAdmin = | 'view_account_settings' | 'view_child_account' | 'view_enrolled_beta_program' + | 'view_lock' | 'view_network_usage' | 'view_profile_security_question' | 'view_region_available_service' @@ -258,6 +262,7 @@ export type AccountViewer = | 'list_default_firewalls' | 'list_enrolled_beta_programs' | 'list_entities' + | 'list_locks' | 'list_role_permissions' | 'list_service_transfers' | 'list_user_grants' @@ -266,6 +271,7 @@ export type AccountViewer = | 'view_account_login' | 'view_account_settings' | 'view_enrolled_beta_program' + | 'view_lock' | 'view_network_usage' | 'view_region_available_service' | 'view_service_transfer' diff --git a/packages/api-v4/src/linodes/types.ts b/packages/api-v4/src/linodes/types.ts index 04e915b48ad..79b35707a30 100644 --- a/packages/api-v4/src/linodes/types.ts +++ b/packages/api-v4/src/linodes/types.ts @@ -46,7 +46,13 @@ export interface Linode { label: string; lke_cluster_id: null | number; locks: LockType[]; - maintenance_policy?: MaintenancePolicySlug; + /** + * The maintenance policy configured for this Linode. + * + * Will be `null` if the Maintenance Policy feature is not enabled or the Linode's + * region does not support maintenance policies. + */ + maintenance_policy: MaintenancePolicySlug | null; placement_group: LinodePlacementGroupPayload | null; region: string; site_type: RegionSite; @@ -75,6 +81,7 @@ export interface LinodeBackups { export type LinodeCapabilities = | 'Block Storage Encryption' | 'Block Storage Performance B1' + | 'Maintenance Policy' | 'SMTP Enabled'; export type Window = diff --git a/packages/api-v4/src/marketplace/marketplace.ts b/packages/api-v4/src/marketplace/marketplace.ts index 845326f7807..2181943f4fe 100644 --- a/packages/api-v4/src/marketplace/marketplace.ts +++ b/packages/api-v4/src/marketplace/marketplace.ts @@ -1,68 +1,15 @@ import { createPartnerReferralSchema } from '@linode/validation'; import { BETA_API_ROOT } from 'src/constants'; -import Request, { - setData, - setMethod, - setParams, - setURL, - setXFilter, -} from 'src/request'; +import Request, { setData, setMethod, setURL } from 'src/request'; -import type { - MarketplaceCategory, - MarketplacePartner, - MarketplacePartnerReferralPayload, - MarketplaceProduct, - MarketplaceType, -} from './types'; -import type { Filter, ResourcePage as Page, Params } from 'src/types'; - -export const getMarketplaceProducts = (params?: Params, filters?: Filter) => - Request>( - setURL(`${BETA_API_ROOT}/marketplace/products`), - setMethod('GET'), - setParams(params), - setXFilter(filters), - ); - -export const getMarketplaceProduct = (productId: number) => - Request( - setURL( - `${BETA_API_ROOT}/marketplace/products/${encodeURIComponent(productId)}/details`, - ), - setMethod('GET'), - ); - -export const getMarketplaceCategories = (params?: Params, filters?: Filter) => - Request>( - setURL(`${BETA_API_ROOT}/marketplace/categories`), - setMethod('GET'), - setParams(params), - setXFilter(filters), - ); - -export const getMarketplaceTypes = (params?: Params, filters?: Filter) => - Request>( - setURL(`${BETA_API_ROOT}/marketplace/types`), - setMethod('GET'), - setParams(params), - setXFilter(filters), - ); - -export const getMarketplacePartners = (params?: Params, filters?: Filter) => - Request>( - setURL(`${BETA_API_ROOT}/marketplace/partners`), - setMethod('GET'), - setParams(params), - setXFilter(filters), - ); +import type { MarketplacePartnerReferralPayload } from './types'; export const createPartnerReferral = ( data: MarketplacePartnerReferralPayload, ) => Request<{}>( - setURL(`${BETA_API_ROOT}/marketplace/referral`), + setURL(`${BETA_API_ROOT}/marketplace/contact`), setMethod('POST'), setData(data, createPartnerReferralSchema), ); diff --git a/packages/api-v4/src/marketplace/types.ts b/packages/api-v4/src/marketplace/types.ts index 179b7591c0e..ee8b4525fbd 100644 --- a/packages/api-v4/src/marketplace/types.ts +++ b/packages/api-v4/src/marketplace/types.ts @@ -1,62 +1,3 @@ -export interface MarketplaceProductDetail { - documentation?: string; - overview?: { - description: string; - }; - pricing?: string; - support?: string; -} - -export interface MarketplaceProduct { - category_ids: number[]; - created_at: string; - created_by: string; - details?: MarketplaceProductDetail; - id: number; - info_banner?: string; - logo_url: string; - name: string; - partner_id: number; - product_tags?: string[]; - short_description: string; - tile_tag?: string; - type_id: number; - updated_at?: string; - updated_by?: string; -} - -export interface MarketplaceCategory { - created_at: string; - created_by: string; - id: number; - name: string; - products_count: number; - updated_at?: string; - updated_by?: string; -} - -export interface MarketplaceType { - created_at: string; - created_by: string; - id: number; - name: string; - products_count: number; - updated_at?: string; - updated_by?: string; -} - -export interface MarketplacePartner { - created_at: string; - created_by: string; - id: number; - logo_url_dark_mode: string; - logo_url_light_mode: string; - name: string; - updated_at?: string; - updated_by?: string; - url: string; -} - export interface MarketplacePartnerReferralPayload { account_executive_email?: string; additional_emails?: string[]; @@ -65,8 +6,9 @@ export interface MarketplacePartnerReferralPayload { country_code: string; email: string; name: string; - partner_id: number; + partner_name: string; phone: string; phone_country_code: string; + product_name: string; tc_consent_given: boolean; } diff --git a/packages/api-v4/src/nodebalancers/types.ts b/packages/api-v4/src/nodebalancers/types.ts index 77d577d33c6..4ee00d3070c 100644 --- a/packages/api-v4/src/nodebalancers/types.ts +++ b/packages/api-v4/src/nodebalancers/types.ts @@ -10,7 +10,7 @@ type UDPStickiness = 'none' | 'session' | 'source_ip'; export type Stickiness = TCPStickiness | UDPStickiness; -type NodeBalancerType = 'common' | 'premium'; +type NodeBalancerType = 'common' | 'premium' | 'premium_40GB'; export interface LKEClusterInfo { id: number; @@ -33,6 +33,8 @@ export interface NodeBalancer { */ client_udp_sess_throttle?: number; created: string; + frontend_address_type: 'public' | 'vpc'; + frontend_vpc_subnet_id: null | number; hostname: string; id: number; ipv4: string; @@ -145,6 +147,7 @@ export interface NodeBalancerVpcConfig { ipv4_range: null | string; ipv6_range: null | string; nodebalancer_id: number; + purpose: 'backend' | 'frontend'; subnet_id: number; vpc_id: number; } diff --git a/packages/api-v4/src/object-storage/types.ts b/packages/api-v4/src/object-storage/types.ts index 7aeb59b614e..0bb6fd6b507 100644 --- a/packages/api-v4/src/object-storage/types.ts +++ b/packages/api-v4/src/object-storage/types.ts @@ -105,7 +105,7 @@ export interface ObjectStorageBucket { hostname: string; label: string; objects: number; - region?: string; + region: string; s3_endpoint?: string; size: number; // Size of bucket in bytes } diff --git a/packages/api-v4/src/quotas/quotas.ts b/packages/api-v4/src/quotas/quotas.ts index 25f1ee8f34d..e32c94bd071 100644 --- a/packages/api-v4/src/quotas/quotas.ts +++ b/packages/api-v4/src/quotas/quotas.ts @@ -11,10 +11,11 @@ import type { Filter, ResourcePage as Page, Params } from 'src/types'; * * @param type { QuotaType } retrieve a quota within this service type. * @param id { number } the quota ID to look up. + * @param collection { string } quota collection name (quotas/global-quotas). */ -export const getQuota = (type: QuotaType, id: number) => +export const getQuota = (type: QuotaType, collection: string, id: number) => Request( - setURL(`${BETA_API_ROOT}/${type}/quotas/${id}`), + setURL(`${BETA_API_ROOT}/${type}/${collection}/${id}`), setMethod('GET'), ); @@ -26,14 +27,16 @@ export const getQuota = (type: QuotaType, id: number) => * This request can be filtered on `quota_name`, `service_name` and `scope`. * * @param type { QuotaType } retrieve quotas within this service type. + * @param collection { string } quota collection name (quotas/global-quotas). */ export const getQuotas = ( type: QuotaType, + collection: string, params: Params = {}, filter: Filter = {}, ) => Request>( - setURL(`${BETA_API_ROOT}/${type}/quotas`), + setURL(`${BETA_API_ROOT}/${type}/${collection}`), setMethod('GET'), setXFilter(filter), setParams(params), @@ -45,10 +48,15 @@ export const getQuotas = ( * Returns the usage for a single quota within a particular service specified by `type`. * * @param type { QuotaType } retrieve a quota within this service type. + * @param collection { string } quota collection name (quotas/global-quotas). * @param id { string } the quota ID to look up. */ -export const getQuotaUsage = (type: QuotaType, id: string) => +export const getQuotaUsage = ( + type: QuotaType, + collection: string, + id: string, +) => Request( - setURL(`${BETA_API_ROOT}/${type}/quotas/${id}/usage`), + setURL(`${BETA_API_ROOT}/${type}/${collection}/${id}/usage`), setMethod('GET'), ); diff --git a/packages/api-v4/src/quotas/types.ts b/packages/api-v4/src/quotas/types.ts index f7f9f37d773..fe094430757 100644 --- a/packages/api-v4/src/quotas/types.ts +++ b/packages/api-v4/src/quotas/types.ts @@ -17,6 +17,11 @@ export interface Quota { */ endpoint_type?: ObjectStorageEndpointTypes; + /** + * Sets usage column to be n/a when value is false. + */ + has_usage?: boolean; + /** * A unique identifier for the quota. */ @@ -33,6 +38,11 @@ export interface Quota { */ quota_name: string; + /** + * Customer facing id describing the quota. + */ + quota_type: string; + /** * The region slug to which this limit applies. * diff --git a/packages/manager/.changeset/pr-13299-upcoming-features-1768999121997.md b/packages/manager/.changeset/pr-13299-upcoming-features-1768999121997.md deleted file mode 100644 index 5ba4c7ca4d2..00000000000 --- a/packages/manager/.changeset/pr-13299-upcoming-features-1768999121997.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -Fix error handling in ChildAccountList component ([#13299](https://github.com/linode/manager/pull/13299)) diff --git a/packages/manager/CHANGELOG.md b/packages/manager/CHANGELOG.md index d9126407e8b..58209ceea97 100644 --- a/packages/manager/CHANGELOG.md +++ b/packages/manager/CHANGELOG.md @@ -4,6 +4,110 @@ 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/). +## [2026-02-25] - v1.159.0 + + +### : + +- DBaaS Autocomplete highlight for VPC, Add, and Edit Connection Pool ([#13373](https://github.com/linode/manager/pull/13373)) + +### Added: + +- Support for throughput quotas ([#13177](https://github.com/linode/manager/pull/13177)) +- IAM Delegation: empty state for user delegations ([#13314](https://github.com/linode/manager/pull/13314)) +- IAM Delegation: update users table and hide a tab for delegate profile ([#13357](https://github.com/linode/manager/pull/13357)) +- Ability to restrict the number of selectable values in cloudpulse metrics and alerts dimension filter value field ([#13361](https://github.com/linode/manager/pull/13361)) +- Truncation for delegate usernames ([#13366](https://github.com/linode/manager/pull/13366)) +- IAM Delegations: notifications and error state for tables ([#13374](https://github.com/linode/manager/pull/13374)) +- Light/Dark theme identifier in Cloud Manager for Pendo ([#13381](https://github.com/linode/manager/pull/13381)) +- Add Pendo Analytics unique IDs for `CloudPulse metrics` ([#13402](https://github.com/linode/manager/pull/13402)) + +### Changed: + +- Update copy & URL for feedback link ([#13306](https://github.com/linode/manager/pull/13306)) +- Logs Log path sample info tooltip show content restricted by account capablities ([#13307](https://github.com/linode/manager/pull/13307)) +- Logs Stream Create - filter clusters by Log Generation ([#13335](https://github.com/linode/manager/pull/13335)) +- Logs - (optional) text added to Log Path Prefix field label ([#13338](https://github.com/linode/manager/pull/13338)) +- Hide placeholder once a value is selected in Autocomplete multi-select mode ([#13341](https://github.com/linode/manager/pull/13341)) +- IAM Parent/Child - Enable server side filters, pagination and search on Child Delegations ([#13342](https://github.com/linode/manager/pull/13342)) +- Logs Stream and Destination tables mobile view ([#13343](https://github.com/linode/manager/pull/13343)) +- UIE-10060 : Support new GPU v3 RTX Pro 6000 Blackwell plans in Kubernetes for both LKE and LKE-E ([#13347](https://github.com/linode/manager/pull/13347)) +- Logs Stream and Destination landing mobile layout corrected ([#13349](https://github.com/linode/manager/pull/13349)) +- Add an aclpLogs.new flag and a NEW chip for Delivery Logs based on the flag's value ([#13358](https://github.com/linode/manager/pull/13358)) +- Use binary based formulas for bits rollup in `Cloudpulse metrics` ([#13369](https://github.com/linode/manager/pull/13369)) +- Display front end IP and backend VPCs for Nodebalancer ([#13394](https://github.com/linode/manager/pull/13394)) +- Improve Linode plans' display for Dedicated and GPU tabs ([#13408](https://github.com/linode/manager/pull/13408)) + +### Fixed: + +- Only show Maintenance Policy for Linodes that actually have a Maintenance Policy ([#13269](https://github.com/linode/manager/pull/13269)) +- IAM Delegation: "Remove" button in remove assignment confirmation popup is not disabled after clicking it ([#13290](https://github.com/linode/manager/pull/13290)) +- IAM Delegation: The selected user type is not applied after reloading the page ([#13332](https://github.com/linode/manager/pull/13332)) +- Replaced `name` to `label` for ACLP-Alerting CreateNotificationChannelForm interface to keep it consistent with API error message fields ([#13345](https://github.com/linode/manager/pull/13345)) +- IAM: Assigned Roles table pagination fixes ([#13346](https://github.com/linode/manager/pull/13346)) +- Database advanced config inline errors not displaying ([#13350](https://github.com/linode/manager/pull/13350)) +- Removes fr-par-2 from the list of regions in the Machine Images upload page ([#13354](https://github.com/linode/manager/pull/13354)) +- DBaaS Backup / delete dialog bugs ([#13355](https://github.com/linode/manager/pull/13355)) +- Replaced `recipients` to `details.email.usernames` for ACLP-Alerting CreateNotificationChannelForm interface to be consistent with API error message fields ([#13362](https://github.com/linode/manager/pull/13362)) +- IAM: styling issue when tables are loading, UX copy updates ([#13375](https://github.com/linode/manager/pull/13375)) +- Broken Linode CLI link in the Linode Create code snippets dialog ([#13378](https://github.com/linode/manager/pull/13378)) +- Error handling for dependent API failures in the ACLP - Edit Alert feature ([#13379](https://github.com/linode/manager/pull/13379)) +- IAM Delegation: normalizes the search value for Users table ([#13382](https://github.com/linode/manager/pull/13382)) +- IAM Delegation: error handling in remove role/entity confirmation dialog, visible “View User Detail” and “Delete User” options for delegate user ([#13384](https://github.com/linode/manager/pull/13384)) +- IAM: a pagination for Assigned Entities table ([#13385](https://github.com/linode/manager/pull/13385)) +- Invalidating notification channel queries on ACLP-Alerting operations ([#13395](https://github.com/linode/manager/pull/13395)) +- Fix Open Re-direction vulnerability in Account Cancel flow ([#13400](https://github.com/linode/manager/pull/13400)) +- Show the Blackwell Limited Availability Banner only for Blackwell Enabled customers ([#13414](https://github.com/linode/manager/pull/13414)) + +### Removed: + +- Occurence of `in-progress` in ACLP-Alerting ([#13406](https://github.com/linode/manager/pull/13406)) + +### Tech Stories: + +- Clean up unused marketplace v2 mocks ([#13396](https://github.com/linode/manager/pull/13396)) + +### Tests: + +- Fix `create-linode-with-add-ons.spec.ts` after Linode Interfaces GA ([#13325](https://github.com/linode/manager/pull/13325)) +- Add spec for delete notification channel ([#13327](https://github.com/linode/manager/pull/13327)) +- Fix flaky clone-linode.spec.ts ([#13353](https://github.com/linode/manager/pull/13353)) +- Fix flaky machine-image-upload.spec.ts tests ([#13354](https://github.com/linode/manager/pull/13354)) +- Add spec for create nofitication channel ([#13383](https://github.com/linode/manager/pull/13383)) + +### Upcoming Features: + +- Marketplace details and added tabs to the Products details page ([#13271](https://github.com/linode/manager/pull/13271)) +- Add Custom HTTPS destination type with proper fields to Create Destination forms ([#13274](https://github.com/linode/manager/pull/13274)) +- DBaaS PgBouncer section to display Add New Connection Pool drawer ([#13276](https://github.com/linode/manager/pull/13276)) +- Refactor Marketplace V2 and add filters to the Products landing page ([#13292](https://github.com/linode/manager/pull/13292)) +- IAM Parent/Child - Enable server side filters on User Delegations ([#13298](https://github.com/linode/manager/pull/13298)) +- Fix error handling in ChildAccountList component ([#13299](https://github.com/linode/manager/pull/13299)) +- Add Edit Connection Pool Drawer ([#13304](https://github.com/linode/manager/pull/13304)) +- RESPROT2 - Display/Disable Lock/Unlock action in Linode list and detail action menu ([#13305](https://github.com/linode/manager/pull/13305)) +- Utils and Hooks set up for supporting zoom in inside the charts in `CloudPulse metrics graphs` ([#13308](https://github.com/linode/manager/pull/13308)) +- Add learn more documentation link for PgBouncer in DBaaS ([#13315](https://github.com/linode/manager/pull/13315)) +- Changes for providing ability to zoom in inside the `CloudPulse Metrics Graphs` ([#13317](https://github.com/linode/manager/pull/13317)) +- IAM Parent/Child - Enable server side filters on Switch Account drawer ([#13318](https://github.com/linode/manager/pull/13318)) +- DBaaS PgBouncer updating Add/Edit Pool drawer fields to use autocomplete ([#13326](https://github.com/linode/manager/pull/13326)) +- Add Additional Options section to the Custom HTTPS destination type ([#13331](https://github.com/linode/manager/pull/13331)) +- IAM Parent/Child: Align proxy logic with delegate users ([#13336](https://github.com/linode/manager/pull/13336)) +- Implemented Add Lock Dialog accessible from Linode action menu ([#13339](https://github.com/linode/manager/pull/13339)) +- Pagination, search, filtering to ACLP-Alerting Notification Channel show details, Catch-all routing to Notification channel URL endpoints ([#13344](https://github.com/linode/manager/pull/13344)) +- Implemented Remove Lock Dialog from Linode Action Menu ([#13348](https://github.com/linode/manager/pull/13348)) +- Support Placement Group Policy Update in line with Placement Group Aware Maintenance program ([#13351](https://github.com/linode/manager/pull/13351)) +- Add Partner Referrals beta launch global banner in Cloud Manager ([#13364](https://github.com/linode/manager/pull/13364)) +- Implement the Contact Sales Drawer for Marketplace products ([#13368](https://github.com/linode/manager/pull/13368)) +- Add new Marketplace products ([#13370](https://github.com/linode/manager/pull/13370)) +- Implements disabling of delete and rebuild actions when a Linode has active locks ([#13377](https://github.com/linode/manager/pull/13377)) +- Delivery Logs - selected destination summary in a Create Stream form for Custom HTTPS destinations, edit Custom HTTPS destination ([#13380](https://github.com/linode/manager/pull/13380)) +- Deprecate connection_pool_port, add endpoints mock data for Databases ([#13386](https://github.com/linode/manager/pull/13386)) +- Integrate Network Load Balancer service in `CloudPulse metrics` ([#13387](https://github.com/linode/manager/pull/13387)) +- IAM Delegation: Switch back to parent account UI ([#13391](https://github.com/linode/manager/pull/13391)) +- Add Pendo IDs for Marketplace filter options and product cards ([#13393](https://github.com/linode/manager/pull/13393)) +- Add 'Learn more' link to Marketplace v2 global banner ([#13405](https://github.com/linode/manager/pull/13405)) +- Add Blackwell GPU related banners in the Linode Create page ([#13408](https://github.com/linode/manager/pull/13408)) + ## [2026-01-26] - v1.158.0 diff --git a/packages/manager/cypress/e2e/core/account/account-cancellation.spec.ts b/packages/manager/cypress/e2e/core/account/account-cancellation.spec.ts index d68aba62661..d1301b21db3 100644 --- a/packages/manager/cypress/e2e/core/account/account-cancellation.spec.ts +++ b/packages/manager/cypress/e2e/core/account/account-cancellation.spec.ts @@ -29,8 +29,8 @@ import { import { accountFactory } from 'src/factories/account'; import { CHILD_USER_CLOSE_ACCOUNT_TOOLTIP_TEXT, + DELEGATE_USER_CLOSE_ACCOUNT_TOOLTIP_TEXT, PARENT_USER_CLOSE_ACCOUNT_TOOLTIP_TEXT, - PROXY_USER_CLOSE_ACCOUNT_TOOLTIP_TEXT, } from 'src/features/Account/constants'; import type { CancelAccount } from '@linode/api-v4'; @@ -302,7 +302,7 @@ describe('Parent/Child account cancellation', () => { .trigger('mouseover'); // Click the button first, then confirm the tooltip is shown. ui.tooltip - .findByText(PROXY_USER_CLOSE_ACCOUNT_TOOLTIP_TEXT) + .findByText(DELEGATE_USER_CLOSE_ACCOUNT_TOOLTIP_TEXT) .should('be.visible'); }); }); diff --git a/packages/manager/cypress/e2e/core/account/personal-access-tokens.spec.ts b/packages/manager/cypress/e2e/core/account/personal-access-tokens.spec.ts index 9e5554532c1..510c6e45461 100644 --- a/packages/manager/cypress/e2e/core/account/personal-access-tokens.spec.ts +++ b/packages/manager/cypress/e2e/core/account/personal-access-tokens.spec.ts @@ -15,7 +15,7 @@ import { ui } from 'support/ui'; import { randomLabel, randomString } from 'support/util/random'; import { appTokenFactory } from 'src/factories/oauth'; -import { PROXY_USER_RESTRICTED_TOOLTIP_TEXT } from 'src/features/Account/constants'; +import { DELEGATE_USER_RESTRICTED_TOOLTIP_TEXT } from 'src/features/Account/constants'; import type { Token } from '@linode/api-v4'; @@ -370,7 +370,7 @@ describe('Personal access tokens', () => { }); ui.tooltip - .findByText(PROXY_USER_RESTRICTED_TOOLTIP_TEXT) + .findByText(DELEGATE_USER_RESTRICTED_TOOLTIP_TEXT) .should('be.visible'); // Confirm that token has not been renamed, initiate revocation. @@ -405,7 +405,7 @@ describe('Personal access tokens', () => { .click(); ui.tooltip - .findByText(PROXY_USER_RESTRICTED_TOOLTIP_TEXT) + .findByText(DELEGATE_USER_RESTRICTED_TOOLTIP_TEXT) .should('be.visible'); // Confirm that token is removed from list after revoking. diff --git a/packages/manager/cypress/e2e/core/account/quotas-storage.spec.ts b/packages/manager/cypress/e2e/core/account/quotas-storage.spec.ts index 2dcd97bf324..411e0e157e2 100644 --- a/packages/manager/cypress/e2e/core/account/quotas-storage.spec.ts +++ b/packages/manager/cypress/e2e/core/account/quotas-storage.spec.ts @@ -24,119 +24,136 @@ import { getQuotaIncreaseMessage } from 'src/features/Account/Quotas/utils'; import type { Quota } from '@linode/api-v4'; +const mockFeatureFlags = { + limitsEvolution: { + enabled: true, + requestForIncreaseDisabledForInternalAccountsOnly: false, + }, + objectStorageGlobalQuotas: false, +}; + const placeholderText = 'Select an Object Storage S3 endpoint'; + +const mockDomain = randomDomainName(); + +const mockRegions = regionFactory.buildList(4, { + capabilities: ['Object Storage'], +}); + +const mockEndpoints = [ + objectStorageEndpointsFactory.build({ + endpoint_type: 'E0', + region: mockRegions[0].id, + s3_endpoint: `${mockRegions[0].id}-1.${mockDomain}`, + }), + objectStorageEndpointsFactory.build({ + endpoint_type: 'E1', + region: mockRegions[1].id, + s3_endpoint: `${mockRegions[1].id}-1.${mockDomain}`, + }), + objectStorageEndpointsFactory.build({ + endpoint_type: 'E1', + region: mockRegions[2].id, + s3_endpoint: `${mockRegions[2].id}-1.${mockDomain}`, + }), + objectStorageEndpointsFactory.build({ + endpoint_type: 'E2', + region: mockRegions[3].id, + s3_endpoint: `${mockRegions[3].id}-1.${mockDomain}`, + }), +]; + +const mockSelectedEndpoint = mockEndpoints[1]; +const selectedDomain = mockSelectedEndpoint.s3_endpoint || ''; + +const mockQuotas = [ + quotaFactory.build({ + quota_id: `obj-bytes-${selectedDomain}`, + quota_type: 'obj-bytes', + description: randomLabel(50), + endpoint_type: mockSelectedEndpoint.endpoint_type, + quota_limit: 10, + quota_name: randomLabel(15), + resource_metric: 'byte', + s3_endpoint: selectedDomain, + }), + quotaFactory.build({ + quota_id: `obj-buckets-${selectedDomain}`, + quota_type: 'obj-buckets', + description: randomLabel(50), + endpoint_type: mockSelectedEndpoint.endpoint_type, + quota_limit: 78, + quota_name: randomLabel(15), + resource_metric: 'bucket', + s3_endpoint: selectedDomain, + }), + quotaFactory.build({ + quota_id: `obj-objects-${selectedDomain}`, + quota_type: 'obj-objects', + description: randomLabel(50), + endpoint_type: mockSelectedEndpoint.endpoint_type, + quota_limit: 400, + quota_name: randomLabel(15), + resource_metric: 'object', + s3_endpoint: selectedDomain, + }), +]; + +const mockQuotaUsages = [ + quotaUsageFactory.build({ + quota_limit: mockQuotas[0].quota_limit, + usage: Math.round(mockQuotas[0].quota_limit * 0.1), + }), + quotaUsageFactory.build({ + quota_limit: mockQuotas[1].quota_limit, + usage: Math.round(mockQuotas[1].quota_limit * 0.1), + }), + quotaUsageFactory.build({ + quota_limit: mockQuotas[2].quota_limit, + usage: Math.round(mockQuotas[2].quota_limit * 0.1), + }), +]; + describe('Quota workflow tests', () => { beforeEach(() => { - // object storage mocks - const mockDomain = randomDomainName(); - const mockRegions = regionFactory.buildList(4, { - capabilities: ['Object Storage'], - }); - const mockEndpoints = [ - objectStorageEndpointsFactory.build({ - endpoint_type: 'E0', - region: mockRegions[0].id, - s3_endpoint: `${mockRegions[0].id}-1.${mockDomain}`, - }), - objectStorageEndpointsFactory.build({ - endpoint_type: 'E1', - region: mockRegions[1].id, - s3_endpoint: `${mockRegions[1].id}-1.${mockDomain}`, - }), - objectStorageEndpointsFactory.build({ - endpoint_type: 'E1', - region: mockRegions[2].id, - s3_endpoint: `${mockRegions[2].id}-1.${mockDomain}`, - }), - objectStorageEndpointsFactory.build({ - endpoint_type: 'E2', - region: mockRegions[3].id, - s3_endpoint: `${mockRegions[3].id}-1.${mockDomain}`, - }), - ]; - - const mockSelectedEndpoint = mockEndpoints[1]; - const selectedDomain = mockSelectedEndpoint.s3_endpoint || ''; - const mockQuotas = [ - quotaFactory.build({ - quota_id: `obj-bytes-${selectedDomain}`, - description: randomLabel(50), - endpoint_type: mockSelectedEndpoint.endpoint_type, - quota_limit: 10, - quota_name: randomLabel(15), - resource_metric: 'byte', - s3_endpoint: selectedDomain, - }), - quotaFactory.build({ - quota_id: `obj-buckets-${selectedDomain}`, - description: randomLabel(50), - endpoint_type: mockSelectedEndpoint.endpoint_type, - quota_limit: 78, - quota_name: randomLabel(15), - resource_metric: 'bucket', - s3_endpoint: selectedDomain, - }), - quotaFactory.build({ - quota_id: `obj-objects-${selectedDomain}`, - description: randomLabel(50), - endpoint_type: mockSelectedEndpoint.endpoint_type, - quota_limit: 400, - quota_name: randomLabel(15), - resource_metric: 'object', - s3_endpoint: selectedDomain, - }), - ]; - const mockQuotaUsages = [ - quotaUsageFactory.build({ - quota_limit: mockQuotas[0].quota_limit, - usage: Math.round(mockQuotas[0].quota_limit * 0.1), - }), - quotaUsageFactory.build({ - quota_limit: mockQuotas[1].quota_limit, - usage: Math.round(mockQuotas[1].quota_limit * 0.1), - }), - quotaUsageFactory.build({ - quota_limit: mockQuotas[2].quota_limit, - usage: Math.round(mockQuotas[2].quota_limit * 0.1), - }), - ]; + mockAppendFeatureFlags(mockFeatureFlags).as('getFeatureFlags'); + + mockGetObjectStorageEndpoints(mockEndpoints).as( + 'getObjectStorageEndpoints' + ); + cy.wrap(selectedDomain).as('selectedDomain'); cy.wrap(mockEndpoints).as('mockEndpoints'); cy.wrap(mockQuotas).as('mockQuotas'); cy.wrap(mockQuotaUsages).as('mockQuotaUsages'); + + mockGetObjectStorageQuotas(selectedDomain, mockQuotas).as('getQuotas'); + mockGetObjectStorageQuotaUsages( selectedDomain, 'bytes', mockQuotaUsages[0] ); + mockGetObjectStorageQuotaUsages( selectedDomain, 'buckets', mockQuotaUsages[1] ); + mockGetObjectStorageQuotaUsages( selectedDomain, 'objects', mockQuotaUsages[2] ).as('getQuotaUsages'); - mockGetObjectStorageEndpoints(mockEndpoints).as( - 'getObjectStorageEndpoints' - ); - mockGetObjectStorageQuotas(selectedDomain, mockQuotas).as('getQuotas'); }); describe('Quota storage table', () => { - beforeEach(() => { - // TODO M3-10003 - Remove all limitsEvolution references once `limitsEvolution` feature flag is removed. - mockAppendFeatureFlags({ - limitsEvolution: { - enabled: true, - }, - }).as('getFeatureFlags'); - }); - it('Quotas and quota usages display properly', function () { - cy.visitWithLogin('/account/quotas'); + it('Quotas and quota usages display properly', () => { + cy.visitWithLogin('/quotas'); + cy.wait(['@getFeatureFlags', '@getObjectStorageEndpoints']); + // Quotas table placeholder text is shown cy.get('[data-testid="table-row-empty"]').should('be.visible'); @@ -144,62 +161,70 @@ describe('Quota workflow tests', () => { cy.findByPlaceholderText(placeholderText) .should('be.visible') .should('be.enabled'); + ui.autocomplete .findByLabel('Object Storage Endpoint') .should('be.visible') - .type(this.selectedDomain); + .type(selectedDomain); + ui.autocompletePopper - .findByTitle(this.selectedDomain, { exact: false }) + .findByTitle(selectedDomain, { exact: false }) .should('be.visible') .click(); + cy.wait(['@getQuotas', '@getQuotaUsages']); + cy.get('table[data-testid="table-endpoint-quotas"]') .find('tbody') .within(() => { - cy.get('tr').should('have.length', 3); cy.get('[data-testid="table-row-empty"]').should('not.exist'); - cy.get('tr').should('have.length', 3); - cy.get('tr').each((row, rowIndex) => { - cy.wrap(row).within(() => { - cy.get('td') - .eq(0) - .within(() => { - cy.findByText(this.mockQuotas[rowIndex].quota_name, { - exact: false, - }).should('be.visible'); - cy.get( - `[aria-label="${this.mockQuotas[rowIndex].description}"]` - ).should('be.visible'); - }); - cy.get('td') - .eq(1) - .within(() => { - cy.findByText(this.mockQuotas[rowIndex].quota_limit, { - exact: false, - }).should('be.visible'); - cy.findByText(this.mockQuotas[rowIndex].resource_metric, { - exact: false, - }).should('be.visible'); - }); - cy.get('td') - .eq(2) + + cy.get('tr') + .should('have.length', 3) + .each((_, rowIndex) => { + cy.get('tr') + .eq(rowIndex) .within(() => { - // quota usage - const strUsage = `${this.mockQuotaUsages[rowIndex].usage} of ${this.mockQuotaUsages[rowIndex].quota_limit}`; - cy.findByText(strUsage, { exact: false }).should( - 'be.visible' - ); + cy.get('td') + .eq(0) + .within(() => { + cy.findByText(mockQuotas[rowIndex].quota_name, { + exact: false, + }).should('be.visible'); + cy.get( + `[aria-label="${mockQuotas[rowIndex].description}"]` + ).should('be.visible'); + }); + cy.get('td') + .eq(1) + .within(() => { + cy.findByText(mockQuotas[rowIndex].quota_limit, { + exact: false, + }).should('be.visible'); + cy.findByText(mockQuotas[rowIndex].resource_metric, { + exact: false, + }).should('be.visible'); + }); + cy.get('td') + .eq(2) + .within(() => { + // quota usage + const strUsage = `${mockQuotaUsages[rowIndex].usage} of ${mockQuotaUsages[rowIndex].quota_limit}`; + cy.findByText(strUsage, { + exact: false, + }).should('be.visible'); + }); }); }); - }); }); // selecting new object storage endpoint triggers update of quotas and quota usages - const updatedEndpoint = this.mockEndpoints[this.mockEndpoints.length - 1]; + const updatedEndpoint = mockEndpoints[mockEndpoints.length - 1]; const updatedDomain = updatedEndpoint.s3_endpoint || ''; const updatedQuotas = [ quotaFactory.build({ quota_id: `obj-bytes-${updatedDomain}`, + quota_type: 'obj-bytes', description: randomLabel(50), endpoint_type: updatedEndpoint.endpoint_type, quota_limit: 20, @@ -209,6 +234,7 @@ describe('Quota workflow tests', () => { }), quotaFactory.build({ quota_id: `obj-buckets-${updatedDomain}`, + quota_type: 'obj-buckets', description: randomLabel(50), endpoint_type: updatedEndpoint.endpoint_type, quota_limit: 122, @@ -218,6 +244,7 @@ describe('Quota workflow tests', () => { }), quotaFactory.build({ quota_id: `obj-objects-${updatedDomain}`, + quota_type: 'obj-objects', description: randomLabel(50), endpoint_type: updatedEndpoint.endpoint_type, quota_limit: 450, @@ -240,21 +267,25 @@ describe('Quota workflow tests', () => { usage: Math.round(updatedQuotas[2].quota_limit * 0.1), }), ]; + mockGetObjectStorageQuotaUsages( updatedDomain, 'bytes', updatedQuotaUsages[0] ); + mockGetObjectStorageQuotaUsages( updatedDomain, 'buckets', updatedQuotaUsages[1] ); + mockGetObjectStorageQuotaUsages( updatedDomain, 'objects', updatedQuotaUsages[2] ).as('getUpdatedQuotaUsages'); + mockGetObjectStorageQuotas(updatedDomain, updatedQuotas).as( 'getUpdatedQuotas' ); @@ -277,46 +308,48 @@ describe('Quota workflow tests', () => { cy.get('table[data-testid="table-endpoint-quotas"]') .find('tbody') .within(() => { - cy.get('tr').should('have.length', 3); cy.get('[data-testid="table-row-empty"]').should('not.exist'); - cy.get('tr').should('have.length', 3); - cy.get('tr').each((row, rowIndex) => { - cy.wrap(row).within(() => { - cy.get('td') - .eq(0) - .within(() => { - cy.findByText(updatedQuotas[rowIndex].quota_name, { - exact: false, - }).should('be.visible'); - cy.get( - `[aria-label="${updatedQuotas[rowIndex].description}"]` - ).should('be.visible'); - }); - cy.get('td') - .eq(1) - .within(() => { - cy.findByText(updatedQuotas[rowIndex].quota_limit, { - exact: false, - }).should('be.visible'); - cy.findByText(updatedQuotas[rowIndex].resource_metric, { - exact: false, - }).should('be.visible'); - }); - cy.get('td') - .eq(2) + cy.get('tr') + .should('have.length', 3) + .each((_, rowIndex) => { + cy.get('tr') + .eq(rowIndex) .within(() => { - // quota usage - const strUsage = `${updatedQuotaUsages[rowIndex].usage} of ${updatedQuotaUsages[rowIndex].quota_limit}`; - cy.findByText(strUsage, { exact: false }).should( - 'be.visible' - ); + cy.get('td') + .eq(0) + .within(() => { + cy.findByText(updatedQuotas[rowIndex].quota_name, { + exact: false, + }).should('be.visible'); + cy.get( + `[aria-label="${updatedQuotas[rowIndex].description}"]` + ).should('be.visible'); + }); + cy.get('td') + .eq(1) + .within(() => { + cy.findByText(updatedQuotas[rowIndex].quota_limit, { + exact: false, + }).should('be.visible'); + cy.findByText(updatedQuotas[rowIndex].resource_metric, { + exact: false, + }).should('be.visible'); + }); + cy.get('td') + .eq(2) + .within(() => { + // quota usage + const strUsage = `${updatedQuotaUsages[rowIndex].usage} of ${updatedQuotaUsages[rowIndex].quota_limit}`; + cy.findByText(strUsage, { exact: false }).should( + 'be.visible' + ); + }); }); }); - }); }); }); - it('Quota error results in error message being displayed', function () { + it('Quota error results in error message being displayed', () => { const errorMsg = 'Request failed.'; mockGetObjectStorageQuotaError(errorMsg).as('getQuotasError'); @@ -326,29 +359,24 @@ describe('Quota workflow tests', () => { ui.autocomplete .findByLabel('Object Storage Endpoint') .should('be.visible') - .type(this.selectedDomain); + .type(selectedDomain); ui.autocompletePopper - .findByTitle(this.selectedDomain, { exact: false }) + .findByTitle(selectedDomain, { exact: false }) .should('be.visible') .click(); cy.wait('@getQuotasError'); - cy.get('[data-qa-error-msg="true"]') - .should('be.visible') - .should('have.text', errorMsg); + cy.get('[data-testid="endpoint-quotas-table-container"]').within(() => { + cy.get('[data-qa-error-msg="true"]') + .should('be.visible') + .should('have.text', errorMsg); + }); }); }); describe('Quota Request Increase workflow', () => { - beforeEach(() => { - mockAppendFeatureFlags({ - limitsEvolution: { - enabled: true, - requestForIncreaseDisabledForInternalAccountsOnly: false, - }, - }).as('getFeatureFlags'); - }); + beforeEach(() => {}); // this test executed in context of internal user, using mockApiInternalUser() - it('Quota Request Increase workflow follows proper sequence', function () { + it('Quota Request Increase workflow follows proper sequence', () => { const mockProfile = profileFactory.build({ email: 'mock-user@linode.com', restricted: false, @@ -358,22 +386,22 @@ describe('Quota workflow tests', () => { const ticketSummary = 'Increase Object Storage Quota'; const expectedResults = [ { - newQuotaLimit: this.mockQuotas[0].quota_limit * 2, + newQuotaLimit: mockQuotas[0].quota_limit * 2, description: randomLabel(), metric: 'Bytes', }, { - newQuotaLimit: this.mockQuotas[1].quota_limit * 2, + newQuotaLimit: mockQuotas[1].quota_limit * 2, description: randomLabel(), metric: 'Buckets', }, { - newQuotaLimit: this.mockQuotas[2].quota_limit * 2, + newQuotaLimit: mockQuotas[2].quota_limit * 2, description: randomLabel(), metric: 'Objects', }, ]; - this.mockQuotas.forEach((mockQuota: Quota, index: number) => { + mockQuotas.forEach((mockQuota: Quota, index: number) => { cy.visitWithLogin('/account/quotas'); cy.wait([ '@getFeatureFlags', @@ -405,9 +433,9 @@ describe('Quota workflow tests', () => { ui.autocomplete .findByLabel('Object Storage Endpoint') .should('be.visible') - .type(this.selectedDomain); + .type(selectedDomain); ui.autocompletePopper - .findByTitle(this.selectedDomain, { exact: false }) + .findByTitle(selectedDomain, { exact: false }) .should('be.visible') .click(); cy.wait(['@getQuotas', '@getQuotaUsages']); @@ -492,7 +520,7 @@ describe('Quota workflow tests', () => { }); }); - it('Quota error results in error message being displayed', function () { + it('Quota error results in error message being displayed', () => { const errorMsg = 'Request failed.'; mockGetObjectStorageQuotaError(errorMsg).as('getQuotasError'); cy.visitWithLogin('/account/quotas'); @@ -502,22 +530,23 @@ describe('Quota workflow tests', () => { ui.autocomplete .findByLabel('Object Storage Endpoint') .should('be.visible') - .type(this.selectedDomain); + .type(selectedDomain); ui.autocompletePopper - .findByTitle(this.selectedDomain, { exact: false }) + .findByTitle(selectedDomain, { exact: false }) .should('be.visible') .click(); cy.wait('@getQuotasError'); - cy.get('[data-qa-error-msg="true"]') - .should('be.visible') - .should('have.text', errorMsg); + + cy.get('[data-testid="endpoint-quotas-table-container"]').within(() => { + cy.get('[data-qa-error-msg="true"]') + .should('be.visible') + .should('have.text', errorMsg); + }); }); // this test executed in context of internal user, using mockApiInternalUser() - it('Quota Request Increase error results in error message being displayed', function () { - mockGetObjectStorageQuotas(this.selectedDomain, this.mockQuotas).as( - 'getQuotas' - ); + it('Quota Request Increase error results in error message being displayed', () => { + mockGetObjectStorageQuotas(selectedDomain, mockQuotas).as('getQuotas'); mockApiInternalUser(); const errorMessage = 'Ticket creation failed.'; mockCreateSupportTicketError(errorMessage).as('createTicketError'); @@ -528,14 +557,14 @@ describe('Quota workflow tests', () => { ui.autocomplete .findByLabel('Object Storage Endpoint') .should('be.visible') - .type(this.selectedDomain); + .type(selectedDomain); ui.autocompletePopper - .findByTitle(this.selectedDomain, { exact: false }) + .findByTitle(selectedDomain, { exact: false }) .should('be.visible') .click(); // Quotas increase request workflow ui.actionMenu - .findByTitle(`Action menu for quota ${this.mockQuotas[0].quota_name}`) + .findByTitle(`Action menu for quota ${mockQuotas[0].quota_name}`) .should('be.visible') .should('be.enabled') .click(); @@ -548,7 +577,7 @@ describe('Quota workflow tests', () => { .should('be.visible') .should('be.enabled') .clear(); - cy.focused().type((this.mockQuotas[0].quota_limit + 1).toString()); + cy.focused().type((mockQuotas[0].quota_limit + 1).toString()); cy.findByLabelText('Description (required)') .should('be.visible') .should('be.enabled') @@ -562,7 +591,7 @@ describe('Quota workflow tests', () => { }); }); - describe('Feature flag determines if Request Increase is enabled', function () { + describe('Feature flag determines if Request Increase is enabled', () => { it('Request Increase enabled for normal user when requestForIncreaseDisabledForAll is false and requestForIncreaseDisabledForInternalAccountsOnly is false', function () { mockAppendFeatureFlags({ limitsEvolution: { @@ -576,15 +605,15 @@ describe('Quota workflow tests', () => { ui.autocomplete .findByLabel('Object Storage Endpoint') .should('be.visible') - .type(this.selectedDomain); + .type(selectedDomain); ui.autocompletePopper - .findByTitle(this.selectedDomain, { exact: false }) + .findByTitle(selectedDomain, { exact: false }) .should('be.visible') .click(); cy.wait(['@getQuotas', '@getQuotaUsages']); ui.actionMenu - .findByTitle(`Action menu for quota ${this.mockQuotas[1].quota_name}`) + .findByTitle(`Action menu for quota ${mockQuotas[1].quota_name}`) .should('be.visible') .should('be.enabled') .click(); @@ -610,15 +639,15 @@ describe('Quota workflow tests', () => { ui.autocomplete .findByLabel('Object Storage Endpoint') .should('be.visible') - .type(this.selectedDomain); + .type(selectedDomain); ui.autocompletePopper - .findByTitle(this.selectedDomain, { exact: false }) + .findByTitle(selectedDomain, { exact: false }) .should('be.visible') .click(); cy.wait(['@getQuotas', '@getQuotaUsages']); ui.actionMenu - .findByTitle(`Action menu for quota ${this.mockQuotas[1].quota_name}`) + .findByTitle(`Action menu for quota ${mockQuotas[1].quota_name}`) .should('be.visible') .should('be.enabled') .click(); @@ -640,15 +669,15 @@ describe('Quota workflow tests', () => { ui.autocomplete .findByLabel('Object Storage Endpoint') .should('be.visible') - .type(this.selectedDomain); + .type(selectedDomain); ui.autocompletePopper - .findByTitle(this.selectedDomain, { exact: false }) + .findByTitle(selectedDomain, { exact: false }) .should('be.visible') .click(); cy.wait(['@getQuotas', '@getQuotaUsages']); ui.actionMenu - .findByTitle(`Action menu for quota ${this.mockQuotas[1].quota_name}`) + .findByTitle(`Action menu for quota ${mockQuotas[1].quota_name}`) .should('be.visible') .should('be.enabled') .click(); @@ -671,15 +700,15 @@ describe('Quota workflow tests', () => { ui.autocomplete .findByLabel('Object Storage Endpoint') .should('be.visible') - .type(this.selectedDomain); + .type(selectedDomain); ui.autocompletePopper - .findByTitle(this.selectedDomain, { exact: false }) + .findByTitle(selectedDomain, { exact: false }) .should('be.visible') .click(); cy.wait(['@getQuotas', '@getQuotaUsages']); ui.actionMenu - .findByTitle(`Action menu for quota ${this.mockQuotas[1].quota_name}`) + .findByTitle(`Action menu for quota ${mockQuotas[1].quota_name}`) .should('be.visible') .should('be.enabled') .click(); @@ -702,15 +731,15 @@ describe('Quota workflow tests', () => { ui.autocomplete .findByLabel('Object Storage Endpoint') .should('be.visible') - .type(this.selectedDomain); + .type(selectedDomain); ui.autocompletePopper - .findByTitle(this.selectedDomain, { exact: false }) + .findByTitle(selectedDomain, { exact: false }) .should('be.visible') .click(); cy.wait(['@getQuotas', '@getQuotaUsages']); ui.actionMenu - .findByTitle(`Action menu for quota ${this.mockQuotas[1].quota_name}`) + .findByTitle(`Action menu for quota ${mockQuotas[1].quota_name}`) .should('be.visible') .should('be.enabled') .click(); @@ -734,15 +763,15 @@ describe('Quota workflow tests', () => { ui.autocomplete .findByLabel('Object Storage Endpoint') .should('be.visible') - .type(this.selectedDomain); + .type(selectedDomain); ui.autocompletePopper - .findByTitle(this.selectedDomain, { exact: false }) + .findByTitle(selectedDomain, { exact: false }) .should('be.visible') .click(); cy.wait(['@getQuotas', '@getQuotaUsages']); ui.actionMenu - .findByTitle(`Action menu for quota ${this.mockQuotas[1].quota_name}`) + .findByTitle(`Action menu for quota ${mockQuotas[1].quota_name}`) .should('be.visible') .should('be.enabled') .click(); diff --git a/packages/manager/cypress/e2e/core/cloudpulse/alert-notification-channel-create.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/alert-notification-channel-create.spec.ts new file mode 100644 index 00000000000..456d058ac95 --- /dev/null +++ b/packages/manager/cypress/e2e/core/cloudpulse/alert-notification-channel-create.spec.ts @@ -0,0 +1,237 @@ +/** + * @file Integration Tests for CloudPulse Alerting — Notification Channel Creation Validation + */ +import { profileFactory } from '@linode/utilities'; +import { mockGetAccount, mockGetUsers } from 'support/intercepts/account'; +import { + mockCreateAlertChannelError, + mockCreateAlertChannelSuccess, + mockGetAlertChannels, +} from 'support/intercepts/cloudpulse'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { mockGetProfile } from 'support/intercepts/profile'; +import { ui } from 'support/ui'; + +import { + accountFactory, + accountUserFactory, + flagsFactory, + notificationChannelFactory, +} from 'src/factories'; +import { CREATE_CHANNEL_SUCCESS_MESSAGE } from 'src/features/CloudPulse/Alerts/constants'; + +// Define mock data for the test. + +const mockAccount = accountFactory.build(); +const mockProfile = profileFactory.build({ + restricted: false, +}); +const notificationChannels = notificationChannelFactory.buildList(5); +const createNotificationChannel = notificationChannelFactory.build({ + label: 'Test Channel Name', + channel_type: 'email', + content: { + email: { + email_addresses: ['user1', 'user2'], + message: 'You have a new Alert', + subject: 'Sample Alert', + }, + }, +}); + +describe('CloudPulse Alerting - Notification Channel Creation Validation', () => { + /** + * Verify successful creation of a new email notification channel with success snackbar + * Verifies the payload sent to the API and the UI listing of the newly created channel. + * Verifies server error handling during channel creation. + */ + beforeEach(() => { + mockGetAccount(mockAccount); + mockGetProfile(mockProfile); + const mockflags = flagsFactory.build({ + aclpAlerting: { + notificationChannels: true, + }, + }); + mockAppendFeatureFlags(mockflags); + mockGetAlertChannels(notificationChannels).as( + 'getAlertNotificationChannels' + ); + mockCreateAlertChannelSuccess(createNotificationChannel).as( + 'createAlertChannelNew' + ); + + // Mock 2 users for recipient selection + const users = [ + accountUserFactory.build({ username: 'user1' }), + accountUserFactory.build({ username: 'user2' }), + ]; + + mockGetUsers(users).as('getAccountUsers'); + + // Visit Notification Channels page + cy.visitWithLogin('/alerts/notification-channels'); + }); + it('should create email notification channel, verify payload and UI listing', () => { + // Open Create Channel page + ui.button + .findByTitle('Create Channel') + .should('be.visible') + .and('be.enabled') + .click(); + + // Verify breadcrumb heading + ui.breadcrumb.find().within(() => { + cy.contains('Notification Channels').should('be.visible'); + cy.contains('Create Channel').should('be.visible'); + }); + + // Verify Channel Settings heading + ui.heading.findByText('Channel Settings').should('be.visible'); + + // Select notification type (Email) + cy.get('[data-qa-textfield-label="Type"]').should('be.visible'); + ui.autocomplete + .findByLabel('channel-type-select') + .should('be.visible') + .click(); + ui.autocompletePopper.findByTitle('Email').click(); + + // Enter channel name + cy.get('[data-qa-textfield-label="Name"]').should('be.visible'); + cy.findByPlaceholderText('Enter a name for the channel') + .should('be.visible') + .type('Test Channel Name'); + + cy.get('[data-qa-textfield-helper-text="true"]') + .should('be.visible') + .and('have.text', 'Select up to 10 Recipients'); + + // Open recipients autocomplete and select users + cy.get('[data-qa-textfield-label="Recipients"]').should('be.visible'); + ui.autocomplete + .findByLabel('recipients-select') + .should('be.visible') + .click(); + ui.autocompletePopper.findByTitle('user1').click(); + ui.autocompletePopper.findByTitle('user2').click(); + + // Verify selected chips + cy.get('[data-tag-index]') + .should('have.length', 2) + .each(($chip, index) => { + const expectedUsers = ['user1', 'user2']; + cy.wrap($chip) + .find('.MuiChip-label') + .should('contain.text', expectedUsers[index]); + }); + + // Verify Cancel button is enabled + ui.buttonGroup + .findButtonByTitle('Cancel') + .should('be.visible') + .and('be.enabled'); + + // Verify Submit button is enabled and click + ui.buttonGroup + .findButtonByTitle('Submit') + .should('be.visible') + .and('be.enabled') + .click(); + + // Validate API request payload for notification channel creation + cy.wait('@createAlertChannelNew').then((interception) => { + expect(interception) + .to.have.property('response') + .with.property('statusCode', 200); + + const payload = interception.request.body; + + // Top-level fields + expect(payload.label).to.equal('Test Channel Name'); + expect(payload.channel_type).to.equal('email'); + + // Email details validation + expect(payload.details).to.have.property('email'); + expect(payload.details.email.usernames).to.have.length(2); + + const expectedRecipients = ['user1', 'user2']; + + expectedRecipients.forEach((username, index) => { + expect(payload.details.email.usernames[index]).to.equal(username); + }); + }); + + // Verify success toast + ui.toast.assertMessage(CREATE_CHANNEL_SUCCESS_MESSAGE); + + cy.wait('@getAlertNotificationChannels'); + + // Verify navigation back to Notification Channels listing page + cy.url().should('include', '/alerts/notification-channels'); + ui.tabList.find().within(() => { + cy.get('[data-testid="Notification Channels"]').should( + 'have.text', + 'Notification Channels' + ); + }); + // Verify the newly created channel appears in the listing + const expected = createNotificationChannel; + + cy.findByPlaceholderText('Search for Notification Channels').as( + 'searchInput' + ); + cy.get('@searchInput').clear(); + cy.get('@searchInput').type(expected.label); + + cy.get('[data-qa="notification-channels-table"]') + .find('tbody:visible') + .within(() => { + cy.get('tr').should('have.length', 1); + cy.get('tr') + .first() + .within(() => { + cy.findByText(expected.label).should('be.visible'); + cy.findByText('Email').should('be.visible'); + }); + }); + }); + it('should display server related message when API returns an error during channel creation', () => { + mockCreateAlertChannelError('Internal Server Error', 500).as( + 'createAlertChannelServerError' + ); + + cy.visitWithLogin('/alerts/notification-channels'); + + // Open Create Channel drawer + ui.button + .findByTitle('Create Channel') + .should('be.visible') + .and('be.enabled') + .click(); + + // Select notification type + ui.autocomplete.findByLabel('channel-type-select').click(); + ui.autocompletePopper.findByTitle('Email').click(); + + // Enter channel name + cy.findByPlaceholderText('Enter a name for the channel').type( + 'Error Channel' + ); + + // Select recipients + ui.autocomplete.findByLabel('recipients-select').click(); + ui.autocompletePopper.findByTitle('user1').click(); + + // Submit form + ui.buttonGroup.findButtonByTitle('Submit').click(); + + // Wait for the intercepted API call + cy.wait('@createAlertChannelServerError') + .its('response.statusCode') + .should('eq', 500); + + // Verify toast message + ui.toast.assertMessage('Internal Server Error'); + }); +}); diff --git a/packages/manager/cypress/e2e/core/cloudpulse/alert-notification-channel-list.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/alert-notification-channel-list.spec.ts index eddcbd0d53d..a3a4f789d6e 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/alert-notification-channel-list.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/alert-notification-channel-list.spec.ts @@ -3,7 +3,11 @@ */ import { profileFactory } from '@linode/utilities'; import { mockGetAccount } from 'support/intercepts/account'; -import { mockGetAlertChannels } from 'support/intercepts/cloudpulse'; +import { + mockDeleteChannel, + mockDeleteChannelError, + mockGetAlertChannels, +} from 'support/intercepts/cloudpulse'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { mockGetProfile } from 'support/intercepts/profile'; import { ui } from 'support/ui'; @@ -13,6 +17,12 @@ import { flagsFactory, notificationChannelFactory, } from 'src/factories'; +import { + channelTypeMap, + DELETE_CHANNEL_FAILED_MESSAGE, + DELETE_CHANNEL_SUCCESS_MESSAGE, + DELETE_CHANNEL_TOOLTIP_TEXT, +} from 'src/features/CloudPulse/Alerts/constants'; import { ChannelAlertsTooltipText, ChannelListingTableLabelMap, @@ -29,6 +39,7 @@ const sortOrderMap = { const LabelLookup = Object.fromEntries( ChannelListingTableLabelMap.map((item) => [item.colName, item.label]) ); + type SortOrder = 'ascending' | 'descending'; interface VerifyChannelSortingParams { @@ -37,60 +48,120 @@ interface VerifyChannelSortingParams { sortOrder: SortOrder; } -const notificationChannels = notificationChannelFactory - .buildList(26) - .map((ch, i) => { - const isEmail = i % 2 === 0; - const alerts = { - alert_count: isEmail ? 5 : 3, - url: `monitor/alert-channels/${i + 1}/alerts`, - type: 'alerts-definitions', - }; - - if (isEmail) { - return { - ...ch, - id: i + 1, - label: `Channel-${i + 1}`, - type: 'user', - created_by: 'user', - updated_by: 'user', - channel_type: 'email', - updated: new Date(2024, 0, i + 1).toISOString(), - alerts, - content: { - email: { - email_addresses: [`test-${i + 1}@example.com`], - subject: 'Test Subject', - message: 'Test message', - }, - }, - } as NotificationChannel; - } else { - return { - ...ch, - id: i + 1, - label: `Channel-${i + 1}`, - type: 'system', - created_by: 'system', - updated_by: 'system', - channel_type: 'webhook', - updated: new Date(2024, 0, i + 1).toISOString(), - alerts, - content: { - webhook: { - webhook_url: `https://example.com/webhook/${i + 1}`, - http_headers: [ - { - header_key: 'Authorization', - header_value: 'Bearer secret-token', - }, - ], - }, - }, - } as NotificationChannel; - } - }); +// Helper to generate alerts +const generateAlerts = (numAlerts: number) => ({ + alert_count: numAlerts, + type: 'alerts-definitions' as const, + url: '/monitor/alert-channels/alerts', +}); + +const guaranteedChannels: NotificationChannel[] = [ + notificationChannelFactory.build({ + id: 1, + label: 'Email-System-0Alerts', + type: 'system', + channel_type: 'email', + alerts: generateAlerts(0), + }), + notificationChannelFactory.build({ + id: 2, + label: 'Email-User-0Alerts', + type: 'user', + channel_type: 'email', + alerts: generateAlerts(0), + }), + notificationChannelFactory.build({ + id: 3, + label: 'Webhook-System-3Alerts', + type: 'system', + channel_type: 'webhook', + alerts: generateAlerts(3), + }), + notificationChannelFactory.build({ + id: 4, + label: 'Webhook-User-3Alerts', + type: 'user', + channel_type: 'webhook', + alerts: generateAlerts(3), + }), + notificationChannelFactory.build({ + id: 5, + label: 'email-User-3Alerts', + type: 'user', + channel_type: 'email', + alerts: generateAlerts(3), + }), +]; + +// Generate remaining channels up to 26 +const remainingChannels: NotificationChannel[] = Array.from( + { length: 26 - guaranteedChannels.length }, + (_, idx) => { + const id = guaranteedChannels.length + idx + 1; + const type: 'system' | 'user' = Math.random() < 0.5 ? 'user' : 'system'; + const channelType: 'email' | 'webhook' = + Math.random() < 0.5 ? 'email' : 'webhook'; + const alertsCount = Math.random() < 0.5 ? 0 : 3; + + return notificationChannelFactory.build({ + id, + label: `Channel-${id}`, + type, + channel_type: channelType, + alerts: generateAlerts(alertsCount), + }); + } +); + +const notificationChannels = [...guaranteedChannels, ...remainingChannels]; + +/** + * Finds a notification channel by channel_type, owner type, and alerts length, + * and returns its NotificationChannel object. + * + * Throws an error if no matching channel is found. + * This guarantees the return type is always 'NotificationChannel'. + */ +const findChannel = ( + // List of all notification channels to search + channels: NotificationChannel[], + + channelType: NotificationChannel['channel_type'], + + // Owner/type of the channel (e.g. 'user', 'system') + channelOwnerType: NotificationChannel['type'], + + // Expected number of alerts (use 0 for "no alerts") + alertsLength: number +): NotificationChannel => { + // Find the first channel that matches all criteria + const channel = channels.find( + (ch) => + ch.channel_type === channelType && + ch.type === channelOwnerType && + // Special handling for zero alerts: + // alerts may be undefined or an empty array + (alertsLength === 0 + ? !ch.alerts || ch.alerts.alert_count === 0 + : ch.alerts?.alert_count === alertsLength) + ); + + // Fail fast if no matching channel is found + if (!channel) { + throw new Error( + `No channel found with channel_type=${channelType}, type=${channelOwnerType}, alertsLength=${alertsLength}` + ); + } + + // Safe to return: channel is guaranteed to exist + return channel; +}; +const { label: userChannelLabel, id: userChannelId } = findChannel( + notificationChannels, + 'email', // channel_type + 'user', // channel owner/type + 0 // alertsLength (0 = no alerts) +); const isEmailContent = ( content: NotificationChannel['content'] @@ -167,7 +238,7 @@ describe('Notification Channel Listing Page', () => { mockGetAlertChannels(notificationChannels).as( 'getAlertNotificationChannels' ); - + mockDeleteChannel(userChannelId).as('deleteNotificationChannel'); cy.visitWithLogin('/alerts/notification-channels'); ui.pagination.findPageSizeSelect().click(); @@ -247,7 +318,9 @@ describe('Notification Channel Listing Page', () => { cy.findByText(String(expected.alerts.alert_count)).should( 'be.visible' ); - cy.findByText('Email').should('be.visible'); + cy.findByText(channelTypeMap[expected.channel_type]).should( + 'be.visible' + ); cy.get('td').eq(3).should('have.text', expected.created_by); cy.findByText( formatDate(expected.updated, { @@ -349,4 +422,151 @@ describe('Notification Channel Listing Page', () => { VerifyChannelSortingParams(column, 'descending', descending); }); }); + + it('deletes a user-type email notification channel with no alerts', () => { + cy.findByPlaceholderText('Search for Notification Channels').as( + 'searchInput' + ); + + cy.get('@searchInput').clear(); + cy.get('@searchInput').type(userChannelLabel); + + ui.actionMenu + .findByTitle(`Action menu for Notification Channel ${userChannelLabel}`) + .should('be.visible') + .click(); + + ui.actionMenuItem.findByTitle('Delete').should('be.visible').click(); + + ui.dialog + .findByTitle(`Delete ${userChannelLabel}?`) + .should('be.visible') + .within(() => { + // Focus the "Alert Label" confirmation input + cy.findByLabelText('Notification Channel Label').click(); + + // Type the alert label to enable the Delete button + cy.focused().type(userChannelLabel); + + // Click the Delete button to confirm + ui.buttonGroup + .findButtonByTitle('Delete') + .should('be.enabled') + .should('be.visible') + .click(); + }); + ui.toast.assertMessage(DELETE_CHANNEL_SUCCESS_MESSAGE); + }); + + it('disable deletion of a user-type email notification channel with alerts', () => { + // --- Arrange: Find a channel that has at least 1 alert --- + const { label: userChannelLabel } = findChannel( + notificationChannels, + 'email', // channel_type + 'user', // owner/type + 3 // alertsLength: at least 1 alert + ); + + // --- Act: Search for the channel --- + cy.findByPlaceholderText('Search for Notification Channels').as( + 'searchInput' + ); + + cy.get('@searchInput').clear(); + cy.get('@searchInput').type(userChannelLabel); + + // --- Act: Open action menu --- + ui.actionMenu + .findByTitle(`Action menu for Notification Channel ${userChannelLabel}`) + .should('be.visible') + .click(); + + ui.tooltip.findByText(DELETE_CHANNEL_TOOLTIP_TEXT).should('be.visible'); + + // --- Act: Click Delete action --- + ui.actionMenuItem + .findByTitle('Delete') + .should('be.visible') + .should('be.disabled'); + }); + + it('ensures system-type channels never show the Delete button', () => { + // --- User-type email channel with alerts --- + const { label: systemChannelLabel } = findChannel( + notificationChannels, + 'email', // channel_type + 'system', // type/owner + 0 // alertsLength = 0 + ); + + // --- Act: Search for the channel --- + cy.findByPlaceholderText('Search for Notification Channels').as( + 'searchInput' + ); + + cy.get('@searchInput').clear(); + cy.get('@searchInput').type(systemChannelLabel); + + // Open action menu + ui.actionMenu + .findByTitle(`Action menu for Notification Channel ${systemChannelLabel}`) + .should('be.visible') + .click(); + + // Delete button should NOT exist for system-type channels + cy.get('div[data-qa-action-menu="true"]') // targets the opened popover + .within(() => { + // Assert Delete button does NOT exist + cy.get('[data-qa-action-menu-item="Delete"]').should('not.exist'); + + // Optionally assert Show Details exists + cy.get('[data-qa-action-menu-item="Show Details"]').should( + 'be.visible' + ); + }); + }); + it('displays an error when deleting a notification channel fails', () => { + const notificationChannel = notificationChannelFactory.build({ + id: 123, + label: 'Channel-error', + type: 'user', + created_by: 'user', + updated_by: 'user', + channel_type: 'email', + alerts: generateAlerts(0), + }); + const userChannelLabel = notificationChannel.label; + mockGetAlertChannels([notificationChannel]); + + // Arrange: Mock the DELETE API to return a 500 error + mockDeleteChannelError(123).as('deleteChannel'); + cy.visitWithLogin('/alerts/notification-channels'); + + // Act: Attempt to delete the channel + ui.actionMenu + .findByTitle(`Action menu for Notification Channel ${userChannelLabel}`) + .should('be.visible') + .click(); + + ui.actionMenuItem.findByTitle('Delete').should('be.visible').click(); + + ui.dialog + .findByTitle(`Delete ${userChannelLabel}?`) + .should('be.visible') + .within(() => { + // Focus the "Alert Label" confirmation input + cy.findByLabelText('Notification Channel Label').click(); + + // Type the alert label to enable the Delete button + cy.focused().type(userChannelLabel); + + // Click the Delete button to confirm + ui.buttonGroup + .findButtonByTitle('Delete') + .should('be.enabled') + .should('be.visible') + .click(); + }); + ui.toast.assertMessage(DELETE_CHANNEL_FAILED_MESSAGE); + }); }); diff --git a/packages/manager/cypress/e2e/core/cloudpulse/alerts-listing-page.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/alerts-listing-page.spec.ts index f2b1701a8de..b46aebd7060 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/alerts-listing-page.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/alerts-listing-page.spec.ts @@ -110,7 +110,7 @@ interface AlertToggleOptions extends AlertActionOptions { const statusList: AlertStatusType[] = [ 'enabled', 'disabled', - 'in progress', + 'provisioning', 'failed', ]; const serviceTypes: CloudPulseServiceType[] = ['linode', 'dbaas']; diff --git a/packages/manager/cypress/e2e/core/cloudpulse/groupby-tags.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/groupby-tags.spec.ts index 8f6165c5ce7..3d97d185bf3 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/groupby-tags.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/groupby-tags.spec.ts @@ -22,12 +22,7 @@ import type { } from '@linode/api-v4'; const mockAccount = accountFactory.build(); -const statusList: AlertStatusType[] = [ - 'enabled', - 'disabled', - 'in progress', - 'failed', -]; +const statusList: AlertStatusType[] = ['enabled', 'disabled', 'failed']; const serviceTypes: CloudPulseServiceType[] = ['linode', 'dbaas']; const tagSequence = ['LinodeTags', 'DBaaSTags', 'bothTags', 'No Tags']; 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 index c1848efea09..e09ab3637a0 100644 --- 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 @@ -7,7 +7,7 @@ import { import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { ui } from 'support/ui'; -import { destinationFactory } from 'src/factories'; +import { akamaiObjectStorageDestinationFactory } from 'src/factories'; import type { Destination } from '@linode/api-v4'; @@ -95,7 +95,7 @@ function editDestinationViaActionMenu( const mockDestinations: Destination[] = new Array(3) .fill(null) .map((_item: null, index: number): Destination => { - return destinationFactory.build({ + return akamaiObjectStorageDestinationFactory.build({ label: `Destination ${index}`, }); }); diff --git a/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts b/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts index 5f2b2ef1b3d..2ee572c5e8b 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts @@ -1469,8 +1469,6 @@ describe('LKE Cluster Creation with LKE-E', () => { validEnterprisePlanTabs.forEach((tab) => { ui.tabList.findTabByTitle(tab).should('be.visible'); }); - // Confirm the GPU tab is not visible in the plans panel for LKE-E. - ui.tabList.findTabByTitle('GPU').should('not.exist'); // Add a node pool for each selected plan, and confirm that the // selected node pool plan is added to the checkout bar. @@ -1910,106 +1908,51 @@ describe('smoketest for Nvidia Blackwell GPUs in kubernetes/create page', () => mockGetRegionAvailability(mockRegion.id, mockRegionAvailability).as( 'getRegionAvailability' ); + mockGetTieredKubernetesVersions('enterprise', [ + latestEnterpriseTierKubernetesVersion, + ]).as('getEnterpriseTieredVersions'); }); - describe('standard tier', () => { - it('enabled feature flag includes blackwells', () => { - mockAppendFeatureFlags({ - kubernetesBlackwellPlans: true, - }).as('getFeatureFlags'); - cy.visitWithLogin('/kubernetes/create'); - cy.wait(['@getFeatureFlags', '@getRegions', '@getLinodeTypes']); - - ui.regionSelect.find().click(); - ui.regionSelect.find().clear(); - ui.regionSelect.find().type(`${mockRegion.label}{enter}`); - cy.wait('@getRegionAvailability'); - // Navigate to "GPU" tab - ui.tabList.findTabByTitle('GPU').scrollIntoView(); - ui.tabList.findTabByTitle('GPU').should('be.visible').click(); - - cy.findByRole('table', { - name: 'List of Linode Plans', - }).within(() => { - cy.get('tbody tr') - .should('have.length', 4) - .each((row, index) => { - cy.wrap(row).within(() => { - cy.get('td') - .eq(0) - .within(() => { - cy.findByText(mockBlackwellLinodeTypes[index].label).should( - 'be.visible' - ); - }); - ui.button - .findByTitle('Configure Pool') - .should('be.visible') - .should('be.enabled'); - }); - }); - }); - }); - - it('disabled feature flag excludes blackwells', () => { - mockAppendFeatureFlags({ - kubernetesBlackwellPlans: false, - }).as('getFeatureFlags'); - - cy.visitWithLogin('/kubernetes/create'); - cy.wait(['@getFeatureFlags', '@getRegions', '@getLinodeTypes']); - - ui.regionSelect.find().click(); - ui.regionSelect.find().clear(); - ui.regionSelect.find().type(`${mockRegion.label}{enter}`); - cy.wait('@getRegionAvailability'); - // Navigate to "GPU" tab - // "GPU" tab hidden - ui.tabList.findTabByTitle('GPU').should('not.exist'); - }); - }); - describe('enterprise tier hides GPU tab', () => { - beforeEach(() => { - // necessary to prevent crash after selecting Enterprise button - mockGetTieredKubernetesVersions('enterprise', [ - latestEnterpriseTierKubernetesVersion, - ]).as('getEnterpriseTieredVersions'); - }); - it('enabled feature flag', () => { - mockAppendFeatureFlags({ - kubernetesBlackwellPlans: true, - }).as('getFeatureFlags'); + it('both tiers should include blackwell GPUs', () => { + cy.visitWithLogin('/kubernetes/create'); + cy.wait(['@getRegions', '@getLinodeTypes']); - cy.visitWithLogin('/kubernetes/create'); - cy.wait(['@getFeatureFlags', '@getRegions', '@getLinodeTypes']); - - cy.findByText('LKE Enterprise').click(); - cy.wait(['@getEnterpriseTieredVersions']); - ui.regionSelect.find().click(); - ui.regionSelect.find().clear(); - ui.regionSelect.find().type(`${mockRegion.label}{enter}`); - cy.wait('@getRegionAvailability'); - // "GPU" tab hidden - ui.tabList.findTabByTitle('GPU').should('not.exist'); + ui.regionSelect.find().click(); + ui.regionSelect.find().clear(); + ui.regionSelect.find().type(`${mockRegion.label}{enter}`); + cy.wait('@getRegionAvailability'); + // Navigate to "GPU" tab + ui.tabList.findTabByTitle('GPU').scrollIntoView(); + ui.tabList.findTabByTitle('GPU').should('be.visible').click(); + + cy.findByRole('table', { + name: 'List of Linode Plans', + }).within(() => { + cy.get('tbody tr') + .should('have.length', 4) + .each((row, index) => { + cy.wrap(row).within(() => { + cy.get('td') + .eq(0) + .within(() => { + cy.findByText(mockBlackwellLinodeTypes[index].label).should( + 'be.visible' + ); + }); + ui.button + .findByTitle('Configure Pool') + .should('be.visible') + .should('be.enabled'); + }); + }); }); - it('disabled feature flag', () => { - mockAppendFeatureFlags({ - kubernetesBlackwellPlans: false, - }).as('getFeatureFlags'); - - cy.visitWithLogin('/kubernetes/create'); - cy.wait(['@getFeatureFlags', '@getRegions', '@getLinodeTypes']); - - ui.regionSelect.find().click(); - ui.regionSelect.find().clear(); - ui.regionSelect.find().type(`${mockRegion.label}{enter}`); - cy.findByText('LKE Enterprise').click(); - cy.wait(['@getEnterpriseTieredVersions']); - 2; - // "GPU" tab hidden - ui.tabList.findTabByTitle('GPU').should('not.exist'); - }); + cy.findByText('LKE Enterprise').click(); + cy.wait(['@getEnterpriseTieredVersions']); + ui.regionSelect.find().click(); + ui.regionSelect.find().clear(); + ui.regionSelect.find().type(`${mockRegion.label}{enter}`); + ui.tabList.findTabByTitle('GPU').should('exist'); }); }); diff --git a/packages/manager/cypress/e2e/core/linodes/clone-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/clone-linode.spec.ts index 43dce38bf2c..434d839b5ef 100644 --- a/packages/manager/cypress/e2e/core/linodes/clone-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/clone-linode.spec.ts @@ -16,10 +16,7 @@ import { dcPricingMockLinodeTypes, dcPricingRegionDifferenceNotice, } from 'support/constants/dc-specific-pricing'; -import { - LINODE_CLONE_TIMEOUT, - LINODE_CREATE_TIMEOUT, -} from 'support/constants/linodes'; +import { LINODE_CREATE_TIMEOUT } from 'support/constants/linodes'; import { mockGetLinodeConfigs } from 'support/intercepts/configs'; import { interceptEvents } from 'support/intercepts/events'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; @@ -47,7 +44,7 @@ import { } from 'support/util/random'; import { chooseRegion, extendRegion } from 'support/util/regions'; -import type { Event, Linode } from '@linode/api-v4'; +import type { Linode } from '@linode/api-v4'; /** * Returns the Cloud Manager URL to clone a given Linode. @@ -138,34 +135,17 @@ describe('clone linode', () => { const newLinodeId = xhr.response?.body?.id; assert.equal(xhr.response?.statusCode, 200); cy.url().should('endWith', `linodes/${newLinodeId}/metrics`); - }); - - ui.toast.assertMessage(`Your Linode ${newLinodeLabel} is being created.`); - // Change the way to check the clone progress due to M3-9860 - cy.wait('@cloneEvents').then((xhr) => { - const eventData: Event[] = xhr.response?.body?.data; - const cloneEvent = eventData.filter( - (event: Event) => event['action'] === 'linode_clone' + ui.toast.assertMessage( + `Your Linode ${newLinodeLabel} is being created.` ); - cy.get('[id="menu-button--notification-events-menu"]') - .should('be.visible') - .click(); - cy.get(`[data-qa-event="${cloneEvent[0]['id']}"]`).should('be.visible'); - cy.get('[data-testid="linear-progress"]').should('be.visible'); - // The progress bar should disappear when the clone is done. - cy.get('[data-testid="linear-progress"]', { - timeout: LINODE_CLONE_TIMEOUT, - }).should('not.exist'); + // breadcrumb link + cy.get('[data-testid="editable-text').within(() => { + cy.findByText(newLinodeLabel).should('be.visible'); + }); + // status of new clone is offline + cy.findByText('OFFLINE').should('be.visible'); }); - - cy.visit('/linodes'); - cy.findByText(newLinodeLabel, { timeout: LINODE_CLONE_TIMEOUT }).should( - 'be.visible' - ); - cy.findByText(linode.label, { timeout: LINODE_CLONE_TIMEOUT }).should( - 'be.visible' - ); }); }); diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-with-add-ons.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-with-add-ons.spec.ts index 125d3673317..3dbdf20a47f 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode-with-add-ons.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-with-add-ons.spec.ts @@ -67,7 +67,7 @@ describe('Create Linode with Add-ons', () => { * - Confirms that Private IP is reflected in create summary section. * - Confirms that outgoing Linode Create API request specifies the private IPs to be enabled. */ - it('can select private IP during Linode Create flow', () => { + it('can select private IP during Linode Create flow when using legacy config interfaces', () => { const linodeRegion = chooseRegion({ capabilities: ['Linodes'] }); const mockLinode = linodeFactory.build({ @@ -87,6 +87,7 @@ describe('Create Linode with Add-ons', () => { linodeCreatePage.selectPlan('Shared CPU', 'Nanode 1 GB'); linodeCreatePage.setRootPassword(randomString(32)); linodeCreatePage.checkEUAgreements(); + linodeCreatePage.selectInterfaceGeneration('legacy_config'); linodeCreatePage.checkPrivateIPs(); // Confirm Private IP assignment indicator is shown in Linode summary. diff --git a/packages/manager/cypress/e2e/core/linodes/plan-selection.spec.ts b/packages/manager/cypress/e2e/core/linodes/plan-selection.spec.ts index bd92c821f9c..512f2956736 100644 --- a/packages/manager/cypress/e2e/core/linodes/plan-selection.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/plan-selection.spec.ts @@ -143,6 +143,11 @@ const notices = { unavailable: '[data-qa-error="true"]', }; +const GPU_GENERAL_AVAILABILITY_NOTICE = + 'New GPU instances are now generally available. Deploy an RTX 4000 Ada GPU instance in select core compute regions in North America, Europe, and Asia.'; +const GPU_NO_AVAILABILITY_ERROR = + 'GPU Plans are not currently available in this region.'; + authenticate(); describe('displays linode plans panel based on availability', () => { beforeEach(() => { @@ -399,10 +404,14 @@ describe('displays specific linode plans for GPU', () => { .click(); // GPU tab - // Should display two separate tables + // Confirm that the expected notice/error banners are present: + // + // - General availability notice explaining that Nvidia Ada plans are available. + // - Region availability error explaining that GPU plans are unavailable in the mocked region. cy.findByText('GPU').click(); cy.get(linodePlansPanel).within(() => { - cy.findAllByRole('alert').should('have.length', 3); + cy.contains(GPU_GENERAL_AVAILABILITY_NOTICE).should('be.visible'); + cy.contains(GPU_NO_AVAILABILITY_ERROR).should('be.visible'); cy.get(notices.unavailable).should('be.visible'); cy.findByRole('table', { @@ -440,11 +449,14 @@ describe('displays specific kubernetes plans for GPU', () => { .click(); // GPU tab - // Should display two separate tables + // Confirm that the expected notice/error banners are present: + // + // - General availability notice explaining that Nvidia Ada plans are available. + // - Region availability error explaining that GPU plans are unavailable in the mocked region. cy.findByText('GPU').click(); cy.get(k8PlansPanel).within(() => { - cy.findAllByRole('alert').should('have.length', 3); - cy.get(notices.unavailable).should('be.visible'); + cy.contains(GPU_GENERAL_AVAILABILITY_NOTICE).should('be.visible'); + cy.contains(GPU_NO_AVAILABILITY_ERROR).should('be.visible'); cy.findByRole('table', { name: 'List of Linode Plans', diff --git a/packages/manager/cypress/support/constants/alert.ts b/packages/manager/cypress/support/constants/alert.ts index c7f4641bd6e..52bc5b07828 100644 --- a/packages/manager/cypress/support/constants/alert.ts +++ b/packages/manager/cypress/support/constants/alert.ts @@ -43,7 +43,6 @@ export const statusMap: Record = { disabled: 'Disabled', enabled: 'Enabled', failed: 'Failed', - 'in progress': 'In Progress', disabling: 'Disabling', enabling: 'Enabling', provisioning: 'Provisioning', diff --git a/packages/manager/cypress/support/constants/delivery.ts b/packages/manager/cypress/support/constants/delivery.ts index 290974a6a2f..3c051308bd5 100644 --- a/packages/manager/cypress/support/constants/delivery.ts +++ b/packages/manager/cypress/support/constants/delivery.ts @@ -1,7 +1,10 @@ import { destinationType, streamType } from '@linode/api-v4'; import { randomLabel, randomString } from 'support/util/random'; -import { destinationFactory, streamFactory } from 'src/factories'; +import { + akamaiObjectStorageDestinationFactory, + streamFactory, +} from 'src/factories'; import type { CreateDestinationPayload, @@ -22,11 +25,12 @@ export const mockDestinationPayload: CreateDestinationPayload = { }, }; -export const mockDestination: Destination = destinationFactory.build({ - id: 1290, - ...mockDestinationPayload, - version: '1.0', -}); +export const mockDestination: Destination = + akamaiObjectStorageDestinationFactory.build({ + id: 1290, + ...mockDestinationPayload, + version: '1.0', + }); export const mockDestinationPayloadWithId = { id: mockDestination.id, diff --git a/packages/manager/cypress/support/intercepts/cloudpulse.ts b/packages/manager/cypress/support/intercepts/cloudpulse.ts index e681218faed..fd4c7a8165e 100644 --- a/packages/manager/cypress/support/intercepts/cloudpulse.ts +++ b/packages/manager/cypress/support/intercepts/cloudpulse.ts @@ -615,3 +615,97 @@ export const mockGetCloudPulseServiceByType = ( makeResponse(service) ); }; + +/** + * Intercepts a DELETE request for a specific notification channel and mocks the backend response. + * + * This helper uses Cypress `cy.intercept()` to stub a DELETE API call to the + * alert channels endpoint (`/monitor/alert-channels/:id`) and returns a mocked + * response with the given status code. This allows tests to simulate both + * successful and failing delete operations without hitting a real backend. + * + * @param channelId - The ID of the notification channel to delete. + * @param statusCode - The HTTP status code to mock (default: 200). + * + * @returns A Cypress.Chainable that can be `as()` aliased and awaited with `cy.wait()`. + */ +export const mockDeleteChannel = ( + channelId: number +): Cypress.Chainable => { + return cy.intercept( + 'DELETE', + apiMatcher(`/monitor/alert-channels/${channelId}`), + { + statusCode: 200, + body: {}, + } + ); +}; + +/** + * Mocks a DELETE request for a specific notification channel and simulates + * a server error response. + * This function uses Cypress's `cy.intercept()` to stub the DELETE API call + * + * @param channelId - The ID of the notification channel to delete. + * @returns Cypress.Chainable that can be aliased with `.as()` and awaited with `cy.wait()`. + */ +export const mockDeleteChannelError = ( + channelId: number +): Cypress.Chainable => { + return cy.intercept( + 'DELETE', + apiMatcher(`/monitor/alert-channels/${channelId}`), + { + statusCode: 500, + body: { + message: 'Internal server error', + }, + } + ); +}; + +/** + * Mocks successful creation of an alert channel (200). + * Intercepts POST requests to create alert channels and returns the provided channel object. + * + * @param {NotificationChannel} channel - The notification channel object to return in the response. + * @returns {Cypress.Chainable} - A Cypress chainable used to continue the test flow. + */ +export const mockCreateAlertChannelSuccess = ( + channel: NotificationChannel +): Cypress.Chainable => { + return cy.intercept( + 'POST', + apiMatcher('/monitor/alert-channels'), + makeResponse(channel) // defaults to 200 + ); +}; + +/** + * Mocks error responses when creating alert channels. + * Intercepts POST requests to create alert channels and returns an error response. + * + * @param {Object | string} errorPayload - Either an object with field and reason properties for validation errors, + * or a string error message for server errors. + * @param {number} statusCode - The HTTP status code for the error response (default is 400). + * @returns {Cypress.Chainable} - A Cypress chainable used to continue the test flow. + * + * @example + * // Mock a validation error (400) + * mockCreateAlertChannelError({ field: 'name', reason: 'Required' }, 400); + * + * @example + * // Mock a server error (500) + * mockCreateAlertChannelError('Internal server error', 500); + */ +export const mockCreateAlertChannelError = ( + errorPayload: string | { field: string; reason: string }, + statusCode: number = 400 +): Cypress.Chainable => { + return cy.intercept( + 'POST', + apiMatcher('/monitor/alert-channels'), + makeErrorResponse(errorPayload, statusCode) + ); +}; diff --git a/packages/manager/cypress/support/ui/pages/linode-create-page.ts b/packages/manager/cypress/support/ui/pages/linode-create-page.ts index c272642c6e4..47e370d7ffd 100644 --- a/packages/manager/cypress/support/ui/pages/linode-create-page.ts +++ b/packages/manager/cypress/support/ui/pages/linode-create-page.ts @@ -140,6 +140,15 @@ export const linodeCreatePage = { cy.findByText('Configuration Profile Interfaces (Legacy)').click(); }, + /** + * Selects an interface generation. + * + * @param generation - The interface generation to select. + */ + selectInterfaceGeneration: (generation: 'legacy_config' | 'linode') => { + cy.get(`[data-qa-interfaces-option="${generation}"]`).click(); + }, + /** * Select the interfaces' type. * diff --git a/packages/manager/cypress/support/ui/pages/logs-destination-form.ts b/packages/manager/cypress/support/ui/pages/logs-destination-form.ts index 01621e540b8..e129c119e41 100644 --- a/packages/manager/cypress/support/ui/pages/logs-destination-form.ts +++ b/packages/manager/cypress/support/ui/pages/logs-destination-form.ts @@ -43,7 +43,6 @@ export const logsDestinationForm = { cy.findByLabelText('Bucket') .should('be.visible') .should('be.enabled') - .should('have.attr', 'placeholder', 'Bucket') .clear(); cy.focused().type(bucketName); }, @@ -57,7 +56,6 @@ export const logsDestinationForm = { cy.findByLabelText('Access Key ID') .should('be.visible') .should('be.enabled') - .should('have.attr', 'placeholder', 'Access Key ID') .clear(); cy.focused().type(accessKeyId); }, @@ -71,7 +69,6 @@ export const logsDestinationForm = { cy.findByLabelText('Secret Access Key') .should('be.visible') .should('be.enabled') - .should('have.attr', 'placeholder', 'Secret Access Key') .clear(); cy.focused().type(secretAccessKey); }, diff --git a/packages/manager/package.json b/packages/manager/package.json index 20510a085fa..af5d2a71767 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.158.0", + "version": "1.159.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.19", + "akamai-cds-react-components": "0.1.0", "algoliasearch": "^4.14.3", "axios": "~1.12.0", "braintree-web": "^3.92.2", diff --git a/packages/manager/public/assets/marketplace/APIContext-dark.svg b/packages/manager/public/assets/marketplace/APIContext-dark.svg new file mode 100644 index 00000000000..9316a12c917 --- /dev/null +++ b/packages/manager/public/assets/marketplace/APIContext-dark.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/packages/manager/public/assets/marketplace/APIContext-light.svg b/packages/manager/public/assets/marketplace/APIContext-light.svg new file mode 100644 index 00000000000..ef4cca1d9aa --- /dev/null +++ b/packages/manager/public/assets/marketplace/APIContext-light.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/packages/manager/public/assets/marketplace/Myota-dark.svg b/packages/manager/public/assets/marketplace/Myota-dark.svg new file mode 100644 index 00000000000..b1dd7e0f440 --- /dev/null +++ b/packages/manager/public/assets/marketplace/Myota-dark.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/packages/manager/public/assets/marketplace/Myota-light.svg b/packages/manager/public/assets/marketplace/Myota-light.svg new file mode 100644 index 00000000000..f0f1a6232a7 --- /dev/null +++ b/packages/manager/public/assets/marketplace/Myota-light.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/packages/manager/public/assets/marketplace/Scaleflex-Asset-lifecycle.jpeg b/packages/manager/public/assets/marketplace/Scaleflex-Asset-lifecycle.jpeg new file mode 100644 index 00000000000..595209e906f Binary files /dev/null and b/packages/manager/public/assets/marketplace/Scaleflex-Asset-lifecycle.jpeg differ diff --git a/packages/manager/public/assets/marketplace/Scaleflex-Create-with-VXP.jpeg b/packages/manager/public/assets/marketplace/Scaleflex-Create-with-VXP.jpeg new file mode 100644 index 00000000000..4fbe6e8cd2a Binary files /dev/null and b/packages/manager/public/assets/marketplace/Scaleflex-Create-with-VXP.jpeg differ diff --git a/packages/manager/public/assets/marketplace/Scaleflex-vxp-architecture.jpeg b/packages/manager/public/assets/marketplace/Scaleflex-vxp-architecture.jpeg new file mode 100644 index 00000000000..36042b7ef5e Binary files /dev/null and b/packages/manager/public/assets/marketplace/Scaleflex-vxp-architecture.jpeg differ diff --git a/packages/manager/public/assets/marketplace/Synadia-dark.svg b/packages/manager/public/assets/marketplace/Synadia-dark.svg new file mode 100644 index 00000000000..02c37b28bc2 --- /dev/null +++ b/packages/manager/public/assets/marketplace/Synadia-dark.svg @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/manager/public/assets/marketplace/Synadia-light.svg b/packages/manager/public/assets/marketplace/Synadia-light.svg new file mode 100644 index 00000000000..a508b24c155 --- /dev/null +++ b/packages/manager/public/assets/marketplace/Synadia-light.svg @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/manager/public/assets/marketplace/Vindral-dark.svg b/packages/manager/public/assets/marketplace/Vindral-dark.svg new file mode 100644 index 00000000000..65074ca86bf --- /dev/null +++ b/packages/manager/public/assets/marketplace/Vindral-dark.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/packages/manager/public/assets/marketplace/Vindral-light.svg b/packages/manager/public/assets/marketplace/Vindral-light.svg new file mode 100644 index 00000000000..e22cb5fa4c0 --- /dev/null +++ b/packages/manager/public/assets/marketplace/Vindral-light.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/packages/manager/public/assets/marketplace/YoSpace-dark.svg b/packages/manager/public/assets/marketplace/YoSpace-dark.svg new file mode 100644 index 00000000000..50225d15ab6 --- /dev/null +++ b/packages/manager/public/assets/marketplace/YoSpace-dark.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/packages/manager/public/assets/marketplace/YoSpace-light.svg b/packages/manager/public/assets/marketplace/YoSpace-light.svg new file mode 100644 index 00000000000..50225d15ab6 --- /dev/null +++ b/packages/manager/public/assets/marketplace/YoSpace-light.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/packages/manager/public/assets/marketplace/Yospace-VOD-workflow.jpeg b/packages/manager/public/assets/marketplace/Yospace-VOD-workflow.jpeg new file mode 100644 index 00000000000..6d3a96f65e5 Binary files /dev/null and b/packages/manager/public/assets/marketplace/Yospace-VOD-workflow.jpeg differ diff --git a/packages/manager/public/assets/marketplace/Yospace-live-streaming-workflow.jpeg b/packages/manager/public/assets/marketplace/Yospace-live-streaming-workflow.jpeg new file mode 100644 index 00000000000..ff612be2d0e Binary files /dev/null and b/packages/manager/public/assets/marketplace/Yospace-live-streaming-workflow.jpeg differ diff --git a/packages/manager/public/assets/marketplace/ateme-dark.svg b/packages/manager/public/assets/marketplace/ateme-dark.svg new file mode 100644 index 00000000000..ab6b48360fc --- /dev/null +++ b/packages/manager/public/assets/marketplace/ateme-dark.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/packages/manager/public/assets/marketplace/ateme-light.svg b/packages/manager/public/assets/marketplace/ateme-light.svg new file mode 100644 index 00000000000..3611a1ff171 --- /dev/null +++ b/packages/manager/public/assets/marketplace/ateme-light.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/packages/manager/public/assets/marketplace/capella-api-integration.jpeg b/packages/manager/public/assets/marketplace/capella-api-integration.jpeg new file mode 100644 index 00000000000..ee52fdcd1ef Binary files /dev/null and b/packages/manager/public/assets/marketplace/capella-api-integration.jpeg differ diff --git a/packages/manager/public/assets/marketplace/capella-cloud-auto-scale.jpeg b/packages/manager/public/assets/marketplace/capella-cloud-auto-scale.jpeg new file mode 100644 index 00000000000..64fffbca5b9 Binary files /dev/null and b/packages/manager/public/assets/marketplace/capella-cloud-auto-scale.jpeg differ diff --git a/packages/manager/public/assets/marketplace/capella-live-streaming.jpeg b/packages/manager/public/assets/marketplace/capella-live-streaming.jpeg new file mode 100644 index 00000000000..f895d411d48 Binary files /dev/null and b/packages/manager/public/assets/marketplace/capella-live-streaming.jpeg differ diff --git a/packages/manager/public/assets/marketplace/capella-logo-dark.svg b/packages/manager/public/assets/marketplace/capella-logo-dark.svg new file mode 100644 index 00000000000..2272a012b76 --- /dev/null +++ b/packages/manager/public/assets/marketplace/capella-logo-dark.svg @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/manager/public/assets/marketplace/capella-logo-light.svg b/packages/manager/public/assets/marketplace/capella-logo-light.svg new file mode 100644 index 00000000000..2272a012b76 --- /dev/null +++ b/packages/manager/public/assets/marketplace/capella-logo-light.svg @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/manager/public/assets/marketplace/capella-one-platform.jpeg b/packages/manager/public/assets/marketplace/capella-one-platform.jpeg new file mode 100644 index 00000000000..551e60b1c6e Binary files /dev/null and b/packages/manager/public/assets/marketplace/capella-one-platform.jpeg differ diff --git a/packages/manager/public/assets/marketplace/edgegap-dark.svg b/packages/manager/public/assets/marketplace/edgegap-dark.svg new file mode 100644 index 00000000000..6a98ba740ae --- /dev/null +++ b/packages/manager/public/assets/marketplace/edgegap-dark.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/manager/public/assets/marketplace/edgegap-light.svg b/packages/manager/public/assets/marketplace/edgegap-light.svg new file mode 100644 index 00000000000..60b3c31debd --- /dev/null +++ b/packages/manager/public/assets/marketplace/edgegap-light.svg @@ -0,0 +1,159 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/manager/public/assets/marketplace/eg-architecture.jpeg b/packages/manager/public/assets/marketplace/eg-architecture.jpeg new file mode 100644 index 00000000000..870d3746c0d Binary files /dev/null and b/packages/manager/public/assets/marketplace/eg-architecture.jpeg differ diff --git a/packages/manager/public/assets/marketplace/fully-managed.jpeg b/packages/manager/public/assets/marketplace/fully-managed.jpeg new file mode 100644 index 00000000000..4d9a15c9799 Binary files /dev/null and b/packages/manager/public/assets/marketplace/fully-managed.jpeg differ diff --git a/packages/manager/public/assets/marketplace/hybrid.jpeg b/packages/manager/public/assets/marketplace/hybrid.jpeg new file mode 100644 index 00000000000..fda7d7649d1 Binary files /dev/null and b/packages/manager/public/assets/marketplace/hybrid.jpeg differ diff --git a/packages/manager/public/assets/marketplace/me-workflow-mgmt.jpeg b/packages/manager/public/assets/marketplace/me-workflow-mgmt.jpeg new file mode 100644 index 00000000000..e47ebb3323b Binary files /dev/null and b/packages/manager/public/assets/marketplace/me-workflow-mgmt.jpeg differ diff --git a/packages/manager/public/assets/marketplace/mediaexcel-dark.svg b/packages/manager/public/assets/marketplace/mediaexcel-dark.svg new file mode 100644 index 00000000000..17fce966df7 --- /dev/null +++ b/packages/manager/public/assets/marketplace/mediaexcel-dark.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/manager/public/assets/marketplace/mediaexcel-light.svg b/packages/manager/public/assets/marketplace/mediaexcel-light.svg new file mode 100644 index 00000000000..17fce966df7 --- /dev/null +++ b/packages/manager/public/assets/marketplace/mediaexcel-light.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/manager/public/assets/marketplace/myota-cyberstorage-architecture.jpeg b/packages/manager/public/assets/marketplace/myota-cyberstorage-architecture.jpeg new file mode 100644 index 00000000000..b3353684af7 Binary files /dev/null and b/packages/manager/public/assets/marketplace/myota-cyberstorage-architecture.jpeg differ diff --git a/packages/manager/public/assets/marketplace/myota-process-flow.jpeg b/packages/manager/public/assets/marketplace/myota-process-flow.jpeg new file mode 100644 index 00000000000..8eb9ebfa188 Binary files /dev/null and b/packages/manager/public/assets/marketplace/myota-process-flow.jpeg differ diff --git a/packages/manager/public/assets/marketplace/rad-security-architecture.jpeg b/packages/manager/public/assets/marketplace/rad-security-architecture.jpeg new file mode 100644 index 00000000000..76816a935fc Binary files /dev/null and b/packages/manager/public/assets/marketplace/rad-security-architecture.jpeg differ diff --git a/packages/manager/public/assets/marketplace/rad-security-dark.svg b/packages/manager/public/assets/marketplace/rad-security-dark.svg new file mode 100644 index 00000000000..33de0a34ee2 --- /dev/null +++ b/packages/manager/public/assets/marketplace/rad-security-dark.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/packages/manager/public/assets/marketplace/rad-security-light.svg b/packages/manager/public/assets/marketplace/rad-security-light.svg new file mode 100644 index 00000000000..ee76e11aecb --- /dev/null +++ b/packages/manager/public/assets/marketplace/rad-security-light.svg @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/manager/public/assets/marketplace/scaleflex-dark.svg b/packages/manager/public/assets/marketplace/scaleflex-dark.svg new file mode 100644 index 00000000000..a6b85d60232 --- /dev/null +++ b/packages/manager/public/assets/marketplace/scaleflex-dark.svg @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/manager/public/assets/marketplace/scaleflex-light.svg b/packages/manager/public/assets/marketplace/scaleflex-light.svg new file mode 100644 index 00000000000..f6117073123 --- /dev/null +++ b/packages/manager/public/assets/marketplace/scaleflex-light.svg @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/manager/public/assets/marketplace/self-managed.jpeg b/packages/manager/public/assets/marketplace/self-managed.jpeg new file mode 100644 index 00000000000..1b3e7a67a58 Binary files /dev/null and b/packages/manager/public/assets/marketplace/self-managed.jpeg differ diff --git a/packages/manager/public/assets/marketplace/sftpgo-architecture.jpeg b/packages/manager/public/assets/marketplace/sftpgo-architecture.jpeg new file mode 100644 index 00000000000..a1333085368 Binary files /dev/null and b/packages/manager/public/assets/marketplace/sftpgo-architecture.jpeg differ diff --git a/packages/manager/public/assets/marketplace/sftpgo-dark.svg b/packages/manager/public/assets/marketplace/sftpgo-dark.svg new file mode 100644 index 00000000000..47d4f502811 --- /dev/null +++ b/packages/manager/public/assets/marketplace/sftpgo-dark.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/manager/public/assets/marketplace/sftpgo-light.svg b/packages/manager/public/assets/marketplace/sftpgo-light.svg new file mode 100644 index 00000000000..47d4f502811 --- /dev/null +++ b/packages/manager/public/assets/marketplace/sftpgo-light.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/manager/public/assets/marketplace/synadia-akamai-arch.jpeg b/packages/manager/public/assets/marketplace/synadia-akamai-arch.jpeg new file mode 100644 index 00000000000..91000ae3e0d Binary files /dev/null and b/packages/manager/public/assets/marketplace/synadia-akamai-arch.jpeg differ diff --git a/packages/manager/src/GoTo.tsx b/packages/manager/src/GoTo.tsx index 791cb6d675c..6f02c421910 100644 --- a/packages/manager/src/GoTo.tsx +++ b/packages/manager/src/GoTo.tsx @@ -4,7 +4,7 @@ import { useNavigate } from '@tanstack/react-router'; import * as React from 'react'; import { useIsDatabasesEnabled } from './features/Databases/utilities'; -import { useIsMarketplaceV2Enabled } from './features/Marketplace/utils'; +import { useIsMarketplaceV2Enabled } from './features/Marketplace/shared'; import { useIsNetworkLoadBalancerEnabled } from './features/NetworkLoadBalancers/utils'; import { useIsPlacementGroupsEnabled } from './features/PlacementGroups/utils'; import { useGlobalKeyboardListener } from './hooks/useGlobalKeyboardListener'; diff --git a/packages/manager/src/LinodeThemeWrapper.tsx b/packages/manager/src/LinodeThemeWrapper.tsx index bbae51a017f..e239be4da57 100644 --- a/packages/manager/src/LinodeThemeWrapper.tsx +++ b/packages/manager/src/LinodeThemeWrapper.tsx @@ -15,11 +15,20 @@ export const LinodeThemeWrapper = (props: Props) => { const { children, theme: themeOverride } = props; const { colorMode } = useColorMode(); + const activeTheme = themeOverride ?? colorMode; + + // Set custom data attribute on document body for third-party tools (like Pendo) to detect theme + // Pendo can use this as a selector: body[data-theme="dark"] or body[data-theme="light"] + React.useEffect(() => { + document.body.setAttribute('data-theme', activeTheme); + return () => { + document.body.removeAttribute('data-theme'); + }; + }, [activeTheme]); + return ( - - {children} - + {children} ); }; diff --git a/packages/manager/src/components/AreaChart/AreaChart.tsx b/packages/manager/src/components/AreaChart/AreaChart.tsx index a37fdbb1632..cc3df40f521 100644 --- a/packages/manager/src/components/AreaChart/AreaChart.tsx +++ b/packages/manager/src/components/AreaChart/AreaChart.tsx @@ -8,6 +8,7 @@ import { Area, CartesianGrid, Legend, + ReferenceArea, ResponsiveContainer, Tooltip, XAxis, @@ -26,6 +27,7 @@ import { } from './utils'; import type { TooltipProps } from 'recharts'; +import type { CategoricalChartFunc } from 'recharts/types/chart/generateCategoricalChart'; import type { MetricsDisplayRow } from 'src/components/LineGraph/MetricsDisplay'; export interface DataSet { @@ -47,6 +49,32 @@ export interface AreaProps { dataKey: string; } +interface ZoomCallbacks { + /** + * Callback fired on mouse down event on the chart + */ + onMouseDown?: CategoricalChartFunc; + /** + * Callback fired on mouse move event on the chart + */ + onMouseMove?: CategoricalChartFunc; + /** + * Callback fired on mouse up event on the chart + */ + onMouseUp?: CategoricalChartFunc; +} + +interface ReferenceAreaProps { + /** + * Ending x-axis value of the reference area + */ + referenceEnd: number; + /** + * Starting x-axis value of the reference area + */ + referenceStart: number; +} + interface XAxisProps { /** * format for the x-axis timestamp @@ -118,6 +146,11 @@ export interface AreaChartProps { */ margin?: { bottom: number; left: number; right: number; top: number }; + /** + * reference area to be highlighted on the chart + */ + referenceArea?: null | ReferenceAreaProps; + /** * control the visibility of dots for each data points */ @@ -171,6 +204,11 @@ export interface AreaChartProps { * y-axis properties */ yAxisProps?: YAxisProps; + + /** + * zoom callbacks (onMouseDown, onMouseMove, onMouseUp) + */ + zoomCallbacks?: ZoomCallbacks; } export const AreaChart = (props: AreaChartProps) => { @@ -195,9 +233,13 @@ export const AreaChart = (props: AreaChartProps) => { xAxisTickCount, yAxisProps, tooltipCustomValueFormatter, + zoomCallbacks, + referenceArea, } = props; const theme = useTheme(); + const { onMouseDown, onMouseMove, onMouseUp } = zoomCallbacks ?? {}; + const { referenceStart, referenceEnd } = referenceArea ?? {}; const [activeSeries, setActiveSeries] = React.useState>([]); const handleLegendClick = (dataKey: string) => { @@ -280,7 +322,14 @@ export const AreaChart = (props: AreaChartProps) => { height={height} width={width} > - <_AreaChart aria-label={ariaLabel} data={data} margin={margin}> + <_AreaChart + aria-label={ariaLabel} + data={data} + margin={margin} + onMouseDown={onMouseDown} + onMouseMove={onMouseMove} + onMouseUp={onMouseUp} + > { wrapperStyle={legendStyles} /> )} + {referenceStart !== undefined && referenceEnd !== undefined && ( + + )} {areas.map(({ color, dataKey }) => ( { +export const AvatarForDelegateUser = ({ height = 34, width = 34 }: Props) => { return ( ({ @@ -32,14 +32,14 @@ export const AvatarForProxy = ({ height = 34, width = 34 }: Props) => { width: `calc(${width}px - 6px)`, })} > - + ); }; -const StyledProxyUserIcon = styled(ProxyUserIcon, { - label: 'styledProxyUserIcon', +const StyledDelegateUserIcon = styled(DelegateUserIcon, { + label: 'styledDelegateUserIcon', })(({ theme }) => ({ bottom: 0, left: 0, diff --git a/packages/manager/src/components/Breadcrumb/Breadcrumb.tsx b/packages/manager/src/components/Breadcrumb/Breadcrumb.tsx index f8e273d77f6..6f9b14cfa49 100644 --- a/packages/manager/src/components/Breadcrumb/Breadcrumb.tsx +++ b/packages/manager/src/components/Breadcrumb/Breadcrumb.tsx @@ -5,6 +5,7 @@ import { Crumbs } from './Crumbs'; import type { CrumbOverridesProps } from './Crumbs'; import type { EditableProps, LabelProps } from './types'; +import type { SxProps, Theme } from '@linode/ui'; export interface BreadcrumbProps { /** @@ -43,6 +44,10 @@ export interface BreadcrumbProps { * A number indicating the position of the crumb to remove. Not zero indexed. */ removeCrumbX?: number | number[]; + /* + * Optional Styles + */ + sx?: SxProps; } /** @@ -61,6 +66,7 @@ export const Breadcrumb = (props: BreadcrumbProps) => { onEditHandlers, pathname, removeCrumbX, + sx, } = props; const url = pathname && pathname.slice(1); @@ -87,7 +93,10 @@ export const Breadcrumb = (props: BreadcrumbProps) => { {...breadcrumbDataAttrs} > { - if (value && value !== textFieldValue) { + if (value !== textFieldValue) { setTextFieldValue(value); } }, [value]); diff --git a/packages/manager/src/components/EntityDetail/EntityDetail.stories.tsx b/packages/manager/src/components/EntityDetail/EntityDetail.stories.tsx index d9cb9f55a56..c4d39e4f11d 100644 --- a/packages/manager/src/components/EntityDetail/EntityDetail.stories.tsx +++ b/packages/manager/src/components/EntityDetail/EntityDetail.stories.tsx @@ -29,10 +29,12 @@ export const LinodeExample: Story = {

Linode Details:

diff --git a/packages/manager/src/components/Flag.tsx b/packages/manager/src/components/Flag.tsx index 62217818a30..dd1eb0a25c1 100644 --- a/packages/manager/src/components/Flag.tsx +++ b/packages/manager/src/components/Flag.tsx @@ -8,6 +8,7 @@ import type { BoxProps } from '@linode/ui'; const COUNTRY_FLAG_OVERRIDES = { uk: 'gb', + xi: 'gb', }; // Countries that need a css border in the Flag component (countries that have DCs) diff --git a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx index ebe54255732..d1fd3fd6e33 100644 --- a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx +++ b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx @@ -22,7 +22,7 @@ import { useIsACLPEnabled } from 'src/features/CloudPulse/Utils/utils'; import { useIsDatabasesEnabled } from 'src/features/Databases/utilities'; import { useIsACLPLogsEnabled } from 'src/features/Delivery/deliveryUtils'; import { useIsIAMEnabled } from 'src/features/IAM/hooks/useIsIAMEnabled'; -import { useIsMarketplaceV2Enabled } from 'src/features/Marketplace/utils'; +import { useIsMarketplaceV2Enabled } from 'src/features/Marketplace/shared'; import { useIsNetworkLoadBalancerEnabled } from 'src/features/NetworkLoadBalancers/utils'; import { useIsPlacementGroupsEnabled } from 'src/features/PlacementGroups/utils'; import { useFlags } from 'src/hooks/useFlags'; @@ -106,7 +106,8 @@ export const PrimaryNav = (props: PrimaryNavProps) => { const isManaged = accountSettings?.managed ?? false; const { isACLPEnabled } = useIsACLPEnabled(); - const { isACLPLogsEnabled, isACLPLogsBeta } = useIsACLPLogsEnabled(); + const { isACLPLogsEnabled, isACLPLogsBeta, isACLPLogsNew } = + useIsACLPLogsEnabled(); const isAlertsEnabled = isACLPEnabled && @@ -273,6 +274,7 @@ export const PrimaryNav = (props: PrimaryNavProps) => { hide: !isACLPLogsEnabled, to: '/logs/delivery', isBeta: isACLPLogsBeta, + isNew: !isACLPLogsBeta && isACLPLogsNew, }, { display: 'Longview', @@ -350,6 +352,7 @@ export const PrimaryNav = (props: PrimaryNavProps) => { isPlacementGroupsEnabled, isACLPEnabled, isACLPLogsBeta, + isACLPLogsNew, isACLPLogsEnabled, isIAMEnabled, isMarketplaceV2FeatureEnabled, diff --git a/packages/manager/src/components/QuotaUsageBar/QuotaUsageBar.test.tsx b/packages/manager/src/components/QuotaUsageBar/QuotaUsageBar.test.tsx index 665c47ba819..1eb39ebc1bc 100644 --- a/packages/manager/src/components/QuotaUsageBar/QuotaUsageBar.test.tsx +++ b/packages/manager/src/components/QuotaUsageBar/QuotaUsageBar.test.tsx @@ -5,27 +5,47 @@ import { renderWithTheme } from 'src/utilities/testHelpers'; import { QuotaUsageBar } from './QuotaUsageBar'; describe('QuotaUsageBanner', () => { - it('should display quota usage in proper units', () => { - const { getByText } = renderWithTheme( - - ); - - const quotaUsageText = getByText('1 of 10 Bytes used'); - expect(quotaUsageText).toBeVisible(); - }); - - it.each([1000000000, 100000000, 10000000, 1000000])( - 'should display content usage in proper format', - (usage) => { + it.each([ + { usage: 1, limit: 10, expectedText: '1 of 10 Bytes used' }, + { usage: 0, limit: 109951162777600, expectedText: '0 of 100 TB used' }, + { + usage: 1000000, + limit: 109951162777600, + expectedText: '<0.01 of 100 TB used', + }, + { + usage: 10000000, + limit: 109951162777600, + expectedText: '<0.01 of 100 TB used', + }, + { + usage: 100000000, + limit: 109951162777600, + expectedText: '<0.01 of 100 TB used', + }, + { + usage: 1000000000, + limit: 109951162777600, + expectedText: '<0.01 of 100 TB used', + }, + { usage: 1, limit: 107374182400, expectedText: '<0.01 of 100 GB used' }, + { + usage: 10737419, + limit: 107374182400, + expectedText: '0.01 of 100 GB used', + }, + { + usage: 5368709, + limit: 107374182400, + expectedText: '<0.01 of 100 GB used', + }, + ])( + 'should display correct byte quota usage text for $usage bytes used out of $limit bytes', + ({ usage, limit, expectedText }) => { const { getByText } = renderWithTheme( - + ); - - const quotaUsageText = getByText('<0.01 of 100 TB used'); + const quotaUsageText = getByText(expectedText); expect(quotaUsageText).toBeVisible(); } ); diff --git a/packages/manager/src/components/QuotaUsageBar/QuotaUsageBar.tsx b/packages/manager/src/components/QuotaUsageBar/QuotaUsageBar.tsx index d041a256567..76228724299 100644 --- a/packages/manager/src/components/QuotaUsageBar/QuotaUsageBar.tsx +++ b/packages/manager/src/components/QuotaUsageBar/QuotaUsageBar.tsx @@ -28,7 +28,8 @@ export const QuotaUsageBar = ({ limit, usage, resourceMetric }: Props) => { const convertedLimitString = convertedLimit.toLocaleString(); // Special case to display storage usage - if (convertedUsage === 0 && convertedResourceMetric === 'TB') { + if (convertedUsage === 0 && usage > 0) { + // assumes that the minimum converted non-zero value is expressed with an accuracy of 2 decimal places convertedUsageString = '<0.01'; } diff --git a/packages/manager/src/components/SelectionCard/SelectionCard.tsx b/packages/manager/src/components/SelectionCard/SelectionCard.tsx index 4e4ab92047e..faf4cec13bf 100644 --- a/packages/manager/src/components/SelectionCard/SelectionCard.tsx +++ b/packages/manager/src/components/SelectionCard/SelectionCard.tsx @@ -20,6 +20,10 @@ export interface SelectionCardProps { * Additional CSS classes to apply to the root element. */ className?: string; + /** + * An optional data-pendo-id for analytics tracking + */ + 'data-pendo-id'?: string; /** * An optional custom data-testid * @default selection-card @@ -63,6 +67,10 @@ export interface SelectionCardProps { * An optional variant to render on the right side. */ renderVariant?: () => JSX.Element | null; + /** + * An optional prop to set the ARIA role of the selection card. + */ + role?: string; /** * An array of subheadings to display below the heading. * @example ['Linode 1GB', 'Linode 2GB', 'Linode 4GB'] @@ -125,6 +133,7 @@ export const SelectionCard = React.memo((props: SelectionCardProps) => { onClick, renderIcon, renderVariant, + role, subheadings, sxCardBase, sxCardBaseHeading, @@ -167,6 +176,7 @@ export const SelectionCard = React.memo((props: SelectionCardProps) => { const cardGrid = ( { id={id} onClick={handleClick} onKeyPress={handleKeyPress} + role={role} size={gridSize ?? { lg: 4, sm: 6, xl: 3, xs: 12 }} sx={sxGrid} tabIndex={0} diff --git a/packages/manager/src/components/TruncatedUsername.test.tsx b/packages/manager/src/components/TruncatedUsername.test.tsx new file mode 100644 index 00000000000..c4dc96384c0 --- /dev/null +++ b/packages/manager/src/components/TruncatedUsername.test.tsx @@ -0,0 +1,37 @@ +import { waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import * as React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { TruncatedUsername } from './TruncatedUsername'; + +describe('TruncatedUsername', () => { + it('should render the truncated username and tooltip if it exceeds the max length', async () => { + const { getByText, getByRole } = renderWithTheme( + + ); + + const text = getByText(/a-very-long-username-that-exc.../); + + expect(text).toBeInTheDocument(); + + await userEvent.hover(text); + + await waitFor(() => { + expect(getByRole('tooltip')).toBeInTheDocument(); + }); + + expect(getByRole('tooltip')).toHaveTextContent( + 'a-very-long-username-that-exceeds-thirty-two-characters' + ); + }); + + it('should render the full username if it does not exceed the max length', () => { + const { getByText, getByRole } = renderWithTheme( + + ); + expect(getByText('short-username')).toBeInTheDocument(); + expect(() => getByRole('tooltip')).toThrow(); + }); +}); diff --git a/packages/manager/src/components/TruncatedUsername.tsx b/packages/manager/src/components/TruncatedUsername.tsx new file mode 100644 index 00000000000..055485b7d44 --- /dev/null +++ b/packages/manager/src/components/TruncatedUsername.tsx @@ -0,0 +1,50 @@ +import { Tooltip, Typography } from '@linode/ui'; +import { truncateEnd } from '@linode/utilities'; +import * as React from 'react'; +import type { ComponentProps } from 'react'; + +import type { SxProps, Theme } from '@linode/ui'; + +interface Props { + /** + * Optional Styles + */ + sx?: SxProps; + /** + * Optional tooltip placement + * @default 'bottom' + */ + tooltipPlacement?: ComponentProps['placement']; + /** The username to truncate + */ + username: string; +} + +/** + * A TruncatedUsername component that has the following features + * - Truncates usernames longer than 32 characters + * - Shows full username in a tooltip on hover if it exceeds 32 characters + * + * Note: This component is reused across CM and is not IAM-specific. + * It handles usernames longer than 32 characters by truncating them and showing a tooltip. + * While regular usernames are limited to 32 characters by validation, + * this is mainly used for delegate usernames that has format: {delegate-parentUsername-HASH}. + */ +export const TruncatedUsername = (props: Props) => { + const { username, tooltipPlacement = 'bottom', sx } = props; + + return ( + 32 ? username : null} + > + + {truncateEnd(username, 32)} + + + ); +}; diff --git a/packages/manager/src/constants.ts b/packages/manager/src/constants.ts index 11bcf8ca075..5d7a2f8fdb3 100644 --- a/packages/manager/src/constants.ts +++ b/packages/manager/src/constants.ts @@ -251,7 +251,7 @@ export const ADDRESSES = { // Linode Community URL accessible from the TopMenu Community icon export const LINODE_COMMUNITY_URL = 'https://linode.com/community'; -export const FEEDBACK_LINK = 'https://www.linode.com/feedback/'; +export const FEEDBACK_LINK = 'https://www.akamai.com/cloud/feedback'; export const DEVELOPERS_LINK = 'https://developers.linode.com'; @@ -285,6 +285,7 @@ export const DISALLOWED_IMAGE_REGIONS = [ 'au-mel', 'sg-sin-2', 'jp-tyo-3', + 'fr-par-2', ]; // Default tooltip text for actions without permission diff --git a/packages/manager/src/dev-tools/FeatureFlagTool.tsx b/packages/manager/src/dev-tools/FeatureFlagTool.tsx index 6a6d8e27948..b800fb52cf9 100644 --- a/packages/manager/src/dev-tools/FeatureFlagTool.tsx +++ b/packages/manager/src/dev-tools/FeatureFlagTool.tsx @@ -46,7 +46,13 @@ const options: { flag: keyof Flags; label: string }[] = [ { flag: 'nodebalancerVpc', label: 'NodeBalancer-VPC Integration' }, { flag: 'objMultiCluster', label: 'OBJ Multi-Cluster' }, { flag: 'objectStorageGen2', label: 'OBJ Gen2' }, + { flag: 'objectStorageGlobalQuotas', label: 'OBJ Global Quotas' }, + { + flag: 'placementGroupPolicyUpdate', + label: 'Placement Group Policy Update', + }, { flag: 'privateImageSharing', label: 'Private Image Sharing' }, + { flag: 'resourceLock', label: 'Resource Lock' }, { flag: 'selfServeBetas', label: 'Self Serve Betas' }, { flag: 'supportTicketSeverity', label: 'Support Ticket Severity' }, { flag: 'dbaasV2', label: 'Databases V2 Beta' }, @@ -87,6 +93,7 @@ const options: { flag: keyof Flags; label: string }[] = [ }, { flag: 'objSummaryPage', label: 'OBJ Summary Page' }, { flag: 'vpcIpv6', label: 'VPC IPv6' }, + { flag: 'marketplaceV2GlobalBanner', label: 'Marketplace V2 Global Banner' }, ]; interface RenderFlagItemProps { diff --git a/packages/manager/src/dev-tools/components/ExtraPresetProfileAndGrants.tsx b/packages/manager/src/dev-tools/components/ExtraPresetProfileAndGrants.tsx index 696b43cabff..8c227e4c6f5 100644 --- a/packages/manager/src/dev-tools/components/ExtraPresetProfileAndGrants.tsx +++ b/packages/manager/src/dev-tools/components/ExtraPresetProfileAndGrants.tsx @@ -248,6 +248,7 @@ export const ExtraPresetProfileAndGrants = ({ + diff --git a/packages/manager/src/factories/cloudpulse/alerts.ts b/packages/manager/src/factories/cloudpulse/alerts.ts index 81e061542b0..9f7a9881211 100644 --- a/packages/manager/src/factories/cloudpulse/alerts.ts +++ b/packages/manager/src/factories/cloudpulse/alerts.ts @@ -652,3 +652,82 @@ export const firewallNodebalancerMetricCriteria = }, ], }); + +const networkLoadBalancerDimensions: Dimension[] = [ + { + label: 'Port', + dimension_label: 'port', + values: [], + }, + { + label: 'Protocol', + dimension_label: 'protocol', + values: ['tcp', 'udp'], + }, + { + label: 'IP Version', + dimension_label: 'ip_version', + values: ['v6', 'v4'], + }, + { + label: 'VIP', + dimension_label: 'ip', + values: [], + }, +]; +export const networkLoadBalancerMetricCriteria: MetricDefinition[] = [ + { + label: 'Ingress Traffic Rate', + metric: 'nlb_ingress_traffic', + unit: 'Bps', + metric_type: 'gauge', + scrape_interval: '60s', + is_alertable: true, + available_aggregate_functions: ['sum'], + dimensions: networkLoadBalancerDimensions, + }, + { + label: 'Ingress Packets Rate', + metric: 'nlb_ingress_packets', + unit: 'packets/s', + metric_type: 'gauge', + scrape_interval: '60s', + is_alertable: true, + available_aggregate_functions: ['sum'], + dimensions: networkLoadBalancerDimensions, + }, + { + label: 'Ingress Traffic Rate Per backend', + metric: 'nlb_backend_ingress_traffic', + unit: 'Bps', + metric_type: 'gauge', + scrape_interval: '60s', + is_alertable: true, + available_aggregate_functions: ['sum'], + dimensions: [ + ...networkLoadBalancerDimensions, + { + label: 'Node ID', + dimension_label: 'node_id', + values: [], + }, + ], + }, + { + label: 'Ingress Packets Rate Per backend', + metric: 'nlb_backend_ingress_packets', + unit: 'packets/s', + metric_type: 'gauge', + scrape_interval: '60s', + is_alertable: true, + available_aggregate_functions: ['sum'], + dimensions: [ + ...networkLoadBalancerDimensions, + { + label: 'Node ID', + dimension_label: 'node_id', + values: [], + }, + ], + }, +]; diff --git a/packages/manager/src/factories/databases.ts b/packages/manager/src/factories/databases.ts index 438422d0905..0c1218b2288 100644 --- a/packages/manager/src/factories/databases.ts +++ b/packages/manager/src/factories/databases.ts @@ -161,7 +161,8 @@ export const databaseInstanceFactory = ? ([1, 3][i % 2] as ClusterSize) : ([1, 2, 3][i % 3] as ClusterSize) ), - connection_pool_port: null, + connection_pool_port: + null /** @Deprecated replaced by `endpoints` property */, connection_strings: [], created: '2021-12-09T17:15:12', encrypted: false, @@ -174,10 +175,26 @@ export const databaseInstanceFactory = ? { primary: 'db-mysql-primary-0.b.linodeb.net', secondary: 'db-mysql-secondary-0.b.linodeb.net', + endpoints: [ + { + address: 'public-db-mysql-primary-0.b.linodeb.net', + role: 'primary', + private_access: false, + port: 3306, + }, + ], } : { primary: 'db-mysql-primary-0.b.linodeb.net', standby: 'db-mysql-secondary-0.b.linodeb.net', + endpoints: [ + { + address: 'public-db-mysql-primary-0.b.linodeb.net', + role: 'primary', + private_access: false, + port: 3306, + }, + ], } ), id: Factory.each((i) => i), @@ -213,7 +230,8 @@ export const databaseInstanceFactory = export const databaseFactory = Factory.Sync.makeFactory({ allow_list: [...IPv4List], cluster_size: Factory.each(() => pickRandom([1, 3])), - connection_pool_port: null, + connection_pool_port: + null /** @Deprecated replaced by `endpoints` property */, connection_strings: [ { driver: 'python', @@ -231,10 +249,26 @@ export const databaseFactory = Factory.Sync.makeFactory({ ? { primary: 'db-mysql-primary-0.b.linodeb.net', secondary: 'db-mysql-secondary-0.b.linodeb.net', + endpoints: [ + { + address: 'public-db-mysql-primary-0.b.linodeb.net', + role: 'primary', + private_access: false, + port: 3306, + }, + ], } : { primary: 'db-mysql-primary-0.b.linodeb.net', standby: 'db-mysql-secondary-0.b.linodeb.net', + endpoints: [ + { + address: 'public-db-mysql-primary-0.b.linodeb.net', + role: 'primary', + private_access: false, + port: 3306, + }, + ], } ), id: Factory.each((i) => i), @@ -461,6 +495,14 @@ export const postgresConfigResponse = { type: 'integer', }, }, + synchronous_replication: { + description: + 'Synchronous replication type. Note that the service plan also needs to support synchronous replication.', + enum: ['quorum', 'off'], + requires_restart: false, + default: 'off', + type: 'string', + }, }; export const databaseEngineConfigFactory = Factory.each((i) => adb10(i) ? mysqlConfigResponse : postgresConfigResponse diff --git a/packages/manager/src/factories/delivery.ts b/packages/manager/src/factories/delivery.ts index c3c19a3f472..d44c0be2971 100644 --- a/packages/manager/src/factories/delivery.ts +++ b/packages/manager/src/factories/delivery.ts @@ -3,27 +3,52 @@ import { Factory } from '@linode/utilities'; import type { Destination } from '@linode/api-v4'; -export const destinationFactory = Factory.Sync.makeFactory({ - details: { - access_key_id: 'Access Id', - bucket_name: 'destinations-bucket-name', - host: 'destinations-bucket-name.host.com', - path: 'file', - }, - id: Factory.each((id) => id), - label: Factory.each((id) => `Destination ${id}`), - type: destinationType.AkamaiObjectStorage, - version: '1.0', - updated: '2025-07-30', - updated_by: 'username', - created: '2025-07-30', - created_by: 'username', -}); +let destinationIdCounter = 0; +const nextDestinationId = () => ++destinationIdCounter; + +export const akamaiObjectStorageDestinationFactory = + Factory.Sync.makeFactory({ + details: { + access_key_id: 'Access Id', + bucket_name: 'destinations-bucket-name', + host: 'destinations-bucket-name.host.com', + path: 'file', + }, + id: Factory.each(() => nextDestinationId()), + label: Factory.each((id) => `Akamai Object Storage Destination ${id}`), + type: destinationType.AkamaiObjectStorage, + version: '1.0', + updated: '2025-07-30', + updated_by: 'username', + created: '2025-07-30', + created_by: 'username', + }); + +export const customHttpsDestinationFactory = + Factory.Sync.makeFactory({ + details: { + authentication: { + type: 'none', + }, + data_compression: 'None', + endpoint_url: 'https://example.com/endpoint', + content_type: 'application/json', + custom_headers: [{ name: 'X-Test', value: '1' }], + }, + id: Factory.each(() => nextDestinationId()), + label: Factory.each((id) => `Custom HTTPS Destination ${id}`), + type: destinationType.CustomHttps, + version: '1.0', + updated: '2025-07-30', + updated_by: 'username', + created: '2025-07-30', + created_by: 'username', + }); export const streamFactory = Factory.Sync.makeFactory({ created_by: 'username', destinations: Factory.each(() => [ - { ...destinationFactory.build(), id: 123 }, + { ...akamaiObjectStorageDestinationFactory.build(), id: 123 }, ]), details: null, updated: '2025-07-30', diff --git a/packages/manager/src/factories/featureFlags.ts b/packages/manager/src/factories/featureFlags.ts index 8688b363d69..c76e128dc0e 100644 --- a/packages/manager/src/factories/featureFlags.ts +++ b/packages/manager/src/factories/featureFlags.ts @@ -25,13 +25,7 @@ export const flagsFactory = Factory.Sync.makeFactory>({ beta: true, recentActivity: false, notificationChannels: true, - editDisabledStatuses: [ - 'in progress', - 'failed', - 'provisioning', - 'enabling', - 'disabling', - ], + editDisabledStatuses: ['failed', 'provisioning', 'enabling', 'disabling'], }, aclpServices: { linode: { diff --git a/packages/manager/src/factories/quotas.ts b/packages/manager/src/factories/quotas.ts index 9fdbde146be..d3be1d0f7bd 100644 --- a/packages/manager/src/factories/quotas.ts +++ b/packages/manager/src/factories/quotas.ts @@ -7,8 +7,10 @@ export const quotaFactory = Factory.Sync.makeFactory({ quota_id: Factory.each((id) => id.toString()), quota_limit: 50, quota_name: 'Linode Dedicated vCPUs', + quota_type: 'linode-dedicated-cpus', region_applied: 'us-east', resource_metric: 'CPU', + has_usage: true, }); export const quotaUsageFactory = Factory.Sync.makeFactory({ diff --git a/packages/manager/src/featureFlags.ts b/packages/manager/src/featureFlags.ts index 2fe9b36763d..3862ee35e25 100644 --- a/packages/manager/src/featureFlags.ts +++ b/packages/manager/src/featureFlags.ts @@ -99,6 +99,11 @@ interface AclpFlag { */ enabled: boolean; + /** + * This property indicates whether to enable zoom in charts or not + */ + enableZoomInCharts?: boolean; + /** * This property indicates for which unit, we need to humanize the values e.g., count, iops etc., */ @@ -120,6 +125,14 @@ interface AclpLogsFlag extends BetaFeatureFlag { * This property indicates whether to bypass account capabilities check or not */ bypassAccountCapabilities?: boolean; + /** + * This property indicates whether to show Custom HTTPS destination type + */ + customHttpsEnabled?: boolean; + /** + * This property indicates whether the feature is new or not + */ + new?: boolean; } interface LkeEnterpriseFlag extends BaseFeatureFlag { @@ -161,6 +174,7 @@ interface AclpAlerting { alertDefinitions: boolean; beta: boolean; editDisabledStatuses?: AlertStatusType[]; + maxDimensionFiltersValues?: number; maxEmailChannelRecipients?: number; notificationChannels: boolean; recentActivity: boolean; @@ -189,6 +203,10 @@ interface FirewallRulesetsAndPrefixLists extends BetaFeatureFlag { la: boolean; } +interface ResourceLockFlag { + linodes: boolean; +} + export interface Flags { acceleratedPlans: AcceleratedPlansFlag; aclp: AclpFlag; @@ -228,15 +246,16 @@ export interface Flags { iamDelegation: BaseFeatureFlag; iamLimitedAvailabilityBadges: boolean; ipv6Sharing: boolean; - kubernetesBlackwellPlans: boolean; limitsEvolution: LimitsEvolution; linodeCloneFirewall: boolean; + linodeCreateBanner: LinodeCreateBanner; linodeDiskEncryption: boolean; linodeInterfaces: LinodeInterfacesFlag; lkeEnterprise2: LkeEnterpriseFlag; mainContentBanner: MainContentBanner; marketplaceAppOverrides: MarketplaceAppOverride[]; marketplaceV2: boolean; + marketplaceV2GlobalBanner: boolean; metadata: boolean; mtc: MTC; networkLoadBalancer: boolean; @@ -244,13 +263,16 @@ export interface Flags { nodebalancerVpc: boolean; objectStorageContextualMetrics: boolean; objectStorageGen2: BaseFeatureFlag; + objectStorageGlobalQuotas: boolean; objMultiCluster: boolean; objSummaryPage: boolean; + placementGroupPolicyUpdate: boolean; privateImageSharing: boolean; productInformationBanners: ProductInformationBannerFlag[]; promos: boolean; promotionalOffers: PromotionalOffer[]; referralBannerText: BannerContent; + resourceLock: ResourceLockFlag; secureVmCopy: SecureVMCopy; selfServeBetas: boolean; soldOutChips: boolean; @@ -414,3 +436,8 @@ export type AclpServices = { interface GenerationalPlansFlag extends BaseFeatureFlag { allowedPlans: string[]; } + +interface LinodeCreateBanner extends BaseFeatureFlag { + message?: string; + pendo_id?: string; +} diff --git a/packages/manager/src/features/Account/AccountLanding.tsx b/packages/manager/src/features/Account/AccountLanding.tsx index 9e87f756a9b..31e9230144b 100644 --- a/packages/manager/src/features/Account/AccountLanding.tsx +++ b/packages/manager/src/features/Account/AccountLanding.tsx @@ -1,4 +1,4 @@ -import { useAccount, useProfile } from '@linode/queries'; +import { useAccount } from '@linode/queries'; import { Outlet, useLocation, @@ -23,6 +23,7 @@ import { useTabs } from 'src/hooks/useTabs'; import { sendSwitchAccountEvent } from 'src/utilities/analytics/customEventAnalytics'; import { PlatformMaintenanceBanner } from '../../components/PlatformMaintenanceBanner/PlatformMaintenanceBanner'; +import { useDelegationRole } from '../IAM/hooks/useDelegationRole'; import { useIsIAMDelegationEnabled } from '../IAM/hooks/useIsIAMEnabled'; import { usePermissions } from '../IAM/hooks/usePermissions'; import { SwitchAccountButton } from './SwitchAccountButton'; @@ -37,7 +38,12 @@ export const AccountLanding = () => { strict: false, }); const { data: account } = useAccount(); - const { data: profile } = useProfile(); + const { + isProxyOrDelegateUserType, + isChildUserType, + isParentUserType, + profileUserType, + } = useDelegationRole(); const { limitsEvolution } = useFlags(); const { data: permissions } = usePermissions('account', [ @@ -48,13 +54,10 @@ export const AccountLanding = () => { const sessionContext = React.useContext(switchAccountSessionContext); const isAkamaiAccount = account?.billing_source === 'akamai'; - const isProxyUser = profile?.user_type === 'proxy'; - const isChildUser = profile?.user_type === 'child'; - const isParentUser = profile?.user_type === 'parent'; const showQuotasTab = limitsEvolution?.enabled ?? false; - const isReadOnly = !permissions.make_billing_payment || isChildUser; + const isReadOnly = !permissions.make_billing_payment || isChildUserType; const isChildAccountAccessRestricted = useRestrictedGlobalGrantCheck({ globalGrantType: 'child_account_access', @@ -62,7 +65,9 @@ export const AccountLanding = () => { const { isIAMDelegationEnabled } = useIsIAMDelegationEnabled(); - const { isParentTokenExpired } = useIsParentTokenExpired({ isProxyUser }); + const { isParentTokenExpired } = useIsParentTokenExpired({ + isProxyOrDelegateUserType, + }); const { tabs, handleTabChange, tabIndex, getTabIndex } = useTabs([ { @@ -124,8 +129,9 @@ export const AccountLanding = () => { const isBillingTabSelected = getTabIndex('/account/billing') === tabIndex; const canSwitchBetweenParentOrProxyAccount = isIAMDelegationEnabled - ? isParentUser - : (!isChildAccountAccessRestricted && isParentUser) || isProxyUser; + ? isParentUserType + : (!isChildAccountAccessRestricted && isParentUserType) || + isProxyOrDelegateUserType; const landingHeaderProps: LandingHeaderProps = { breadcrumbProps: { @@ -134,7 +140,7 @@ export const AccountLanding = () => { buttonDataAttrs: { disabled: isReadOnly, tooltipText: getRestrictedResourceText({ - isChildUser, + isChildUserType, resourceType: 'Account', }), }, @@ -181,7 +187,7 @@ export const AccountLanding = () => { setIsDrawerOpen(false)} open={isDrawerOpen} - userType={profile?.user_type} + userType={profileUserType} /> ); diff --git a/packages/manager/src/features/Account/AccountLogins.tsx b/packages/manager/src/features/Account/AccountLogins.tsx index 79cdae2ec1c..500a5108049 100644 --- a/packages/manager/src/features/Account/AccountLogins.tsx +++ b/packages/manager/src/features/Account/AccountLogins.tsx @@ -1,4 +1,4 @@ -import { useAccountLoginsQuery, useProfile } from '@linode/queries'; +import { useAccountLoginsQuery } from '@linode/queries'; import { Notice, Typography } from '@linode/ui'; import { Hidden } from '@linode/ui'; import * as React from 'react'; @@ -18,6 +18,7 @@ import { TableSortCell } from 'src/components/TableSortCell'; import { useOrderV2 } from 'src/hooks/useOrderV2'; import { usePaginationV2 } from 'src/hooks/usePaginationV2'; +import { useDelegationRole } from '../IAM/hooks/useDelegationRole'; import { usePermissions } from '../IAM/hooks/usePermissions'; import AccountLoginsTableRow from './AccountLoginsTableRow'; import { getRestrictedResourceText } from './utils'; @@ -74,8 +75,7 @@ const AccountLogins = () => { }, filter ); - const { data: profile } = useProfile(); - const isChildUser = profile?.user_type === 'child'; + const { isChildUserType } = useDelegationRole(); const canViewAccountLogins = permissions.list_account_logins; const renderTableContent = () => { @@ -172,7 +172,7 @@ const AccountLogins = () => { ) : ( { }) .then((response) => { setIsClosingAccount(false); - /** shoot the user off to survey monkey to answer some questions */ + /** Store survey link as state param for security so that it is not exposed in URL */ navigate({ to: '/cancel', - search: { survey_link: response.survey_link }, + state: (prev) => ({ ...prev, surveyLink: response.survey_link }), }); }) .catch((e: APIError[]) => { diff --git a/packages/manager/src/features/Account/CloseAccountSetting.test.tsx b/packages/manager/src/features/Account/CloseAccountSetting.test.tsx index debc63e48fc..059f5c1b277 100644 --- a/packages/manager/src/features/Account/CloseAccountSetting.test.tsx +++ b/packages/manager/src/features/Account/CloseAccountSetting.test.tsx @@ -7,8 +7,8 @@ import { renderWithTheme } from 'src/utilities/testHelpers'; import CloseAccountSetting from './CloseAccountSetting'; import { CHILD_USER_CLOSE_ACCOUNT_TOOLTIP_TEXT, + DELEGATE_USER_CLOSE_ACCOUNT_TOOLTIP_TEXT, PARENT_USER_CLOSE_ACCOUNT_TOOLTIP_TEXT, - PROXY_USER_CLOSE_ACCOUNT_TOOLTIP_TEXT, } from './constants'; // Mock the useProfile hook to immediately return the expected data, circumventing the HTTP request and loading state. @@ -105,7 +105,7 @@ describe('Close Account Settings', () => { expect(getByRole('tooltip')).toBeInTheDocument(); }); - expect(getByText(PROXY_USER_CLOSE_ACCOUNT_TOOLTIP_TEXT)).toBeVisible(); + expect(getByText(DELEGATE_USER_CLOSE_ACCOUNT_TOOLTIP_TEXT)).toBeVisible(); expect(button).toHaveAttribute('aria-describedby', 'button-tooltip'); expect(button).not.toHaveAttribute('disabled'); expect(button).toHaveAttribute('aria-disabled', 'true'); diff --git a/packages/manager/src/features/Account/CloseAccountSetting.tsx b/packages/manager/src/features/Account/CloseAccountSetting.tsx index 7f8b6fdc955..f0c0e1d59b4 100644 --- a/packages/manager/src/features/Account/CloseAccountSetting.tsx +++ b/packages/manager/src/features/Account/CloseAccountSetting.tsx @@ -6,8 +6,8 @@ import { usePermissions } from '../IAM/hooks/usePermissions'; import CloseAccountDialog from './CloseAccountDialog'; import { CHILD_USER_CLOSE_ACCOUNT_TOOLTIP_TEXT, + DELEGATE_USER_CLOSE_ACCOUNT_TOOLTIP_TEXT, PARENT_USER_CLOSE_ACCOUNT_TOOLTIP_TEXT, - PROXY_USER_CLOSE_ACCOUNT_TOOLTIP_TEXT, } from './constants'; export const CloseAccountSetting = () => { @@ -17,7 +17,7 @@ export const CloseAccountSetting = () => { const { data: permissions } = usePermissions('account', ['cancel_account']); - // Disable the Close Account button for users with a Parent/Proxy/Child user type. + // Disable the Close Account button for users with a Parent/Proxy/Delegate/Child user type. const isCloseAccountDisabled = Boolean(profile?.user_type !== 'default'); let closeAccountButtonTooltipText; @@ -28,8 +28,11 @@ export const CloseAccountSetting = () => { case 'child': closeAccountButtonTooltipText = CHILD_USER_CLOSE_ACCOUNT_TOOLTIP_TEXT; break; + case 'delegate': + closeAccountButtonTooltipText = DELEGATE_USER_CLOSE_ACCOUNT_TOOLTIP_TEXT; + break; case 'proxy': - closeAccountButtonTooltipText = PROXY_USER_CLOSE_ACCOUNT_TOOLTIP_TEXT; + closeAccountButtonTooltipText = DELEGATE_USER_CLOSE_ACCOUNT_TOOLTIP_TEXT; break; default: closeAccountButtonTooltipText = PARENT_USER_CLOSE_ACCOUNT_TOOLTIP_TEXT; diff --git a/packages/manager/src/features/Account/Quotas/Quotas.test.tsx b/packages/manager/src/features/Account/Quotas/Quotas.test.tsx index dde8d2e82bd..0f1e09932fc 100644 --- a/packages/manager/src/features/Account/Quotas/Quotas.test.tsx +++ b/packages/manager/src/features/Account/Quotas/Quotas.test.tsx @@ -7,8 +7,16 @@ import { renderWithTheme } from 'src/utilities/testHelpers'; import { Quotas } from './Quotas'; +import type { Quota, QuotaUsage } from '@linode/api-v4'; + const queryMocks = vi.hoisted(() => ({ getQuotasFilters: vi.fn().mockReturnValue({}), + getQuotaVisibilityFilter: vi + .fn() + .mockReturnValue({ isVisible: (quota: Quota) => true }), + getQuotaMapper: vi + .fn() + .mockReturnValue({ mapQuota: (quota: Quota, usage: QuotaUsage) => quota }), useFlags: vi.fn().mockReturnValue({}), useGetLocationsForQuotaService: vi.fn().mockReturnValue({}), useObjectStorageEndpoints: vi.fn().mockReturnValue({}), @@ -34,9 +42,12 @@ vi.mock('@linode/queries', async () => { vi.mock('./utils', () => ({ getQuotasFilters: queryMocks.getQuotasFilters, + getQuotaVisibilityFilter: queryMocks.getQuotaVisibilityFilter, + getQuotaMapper: queryMocks.getQuotaMapper, useGetLocationsForQuotaService: queryMocks.useGetLocationsForQuotaService, convertResourceMetric: queryMocks.convertResourceMetric, pluralizeMetric: queryMocks.pluralizeMetric, + QUOTA_ROW_MIN_HEIGHT: 58, })); describe('Quotas', () => { diff --git a/packages/manager/src/features/Account/Quotas/Quotas.tsx b/packages/manager/src/features/Account/Quotas/Quotas.tsx index c8134935c23..c5e3560f0f6 100644 --- a/packages/manager/src/features/Account/Quotas/Quotas.tsx +++ b/packages/manager/src/features/Account/Quotas/Quotas.tsx @@ -12,8 +12,9 @@ import * as React from 'react'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { Link } from 'src/components/Link'; +import { useFlags } from 'src/hooks/useFlags'; -import { QuotasTable } from './QuotasTable'; +import { QuotasTable } from './QuotasTable/QuotasTable'; import { useGetLocationsForQuotaService } from './utils'; import type { Quota } from '@linode/api-v4'; @@ -22,7 +23,7 @@ import type { Theme } from '@mui/material'; export const Quotas = () => { const navigate = useNavigate(); - + const { objectStorageGlobalQuotas } = useFlags(); const [selectedLocation, setSelectedLocation] = React.useState>(null); const locationData = useGetLocationsForQuotaService('object-storage'); @@ -39,6 +40,27 @@ export const Quotas = () => { return ( <> + + {objectStorageGlobalQuotas && ( + ({ + marginTop: theme.spacingFunction(16), + })} + variant="outlined" + > + Object Storage: global + + + + )} + ({ marginTop: theme.spacingFunction(16), @@ -46,7 +68,9 @@ export const Quotas = () => { variant="outlined" > - Object Storage + + Object Storage{objectStorageGlobalQuotas ? ': per-endpoint' : ''} + @@ -105,8 +129,14 @@ export const Quotas = () => { . - + + ({ }, useQueries: vi.fn().mockReturnValue([]), useQuotaUsageQuery: vi.fn().mockReturnValue({}), - useQuotasQuery: vi.fn().mockReturnValue({}), + useAllQuotasQuery: vi.fn().mockReturnValue({}), })); vi.mock('@linode/queries', async () => { @@ -25,7 +25,7 @@ vi.mock('@linode/queries', async () => { ...actual, quotaQueries: queryMocks.quotaQueries, useQuotaUsageQuery: queryMocks.useQuotaUsageQuery, - useQuotasQuery: queryMocks.useQuotasQuery, + useAllQuotasQuery: queryMocks.useAllQuotasQuery, }; }); @@ -41,6 +41,7 @@ describe('QuotasTable', () => { it('should render', async () => { const { getByRole, getByTestId, getByText } = renderWithTheme( { isLoading: false, }, ]); - queryMocks.useQuotasQuery.mockReturnValue({ - data: { - data: quotas, - page: 1, - pages: 1, - results: 1, - }, + queryMocks.useAllQuotasQuery.mockReturnValue({ + data: quotas, isFetching: false, }); queryMocks.useQuotaUsageQuery.mockReturnValue({ @@ -96,6 +92,7 @@ describe('QuotasTable', () => { const { getByLabelText, getByTestId, getByText } = renderWithTheme( ; selectedService: SelectOption; } export const QuotasTable = (props: QuotasTableProps) => { - const { selectedLocation, selectedService } = props; + const { selectedLocation, selectedService, isGlobalScope } = props; const navigate = useNavigate(); - const pagination = usePaginationV2({ - currentRoute: '/quotas', - initialPage: 1, - preferenceKey: 'quotas-table', - }); - const hasSelectedLocation = Boolean(selectedLocation); + + const hasSelectedLocation = Boolean(selectedLocation?.value); + const collectionName = isGlobalScope ? 'global-quotas' : 'quotas'; + const [supportModalOpen, setSupportModalOpen] = React.useState(false); const [selectedQuota, setSelectedQuota] = React.useState(); const [convertedResourceMetrics, setConvertedResourceMetrics] = @@ -48,47 +42,21 @@ export const QuotasTable = (props: QuotasTableProps) => { limit: 0, metric: '', }); - const filters: Filter = getQuotasFilters({ - location: selectedLocation, - service: selectedService, - }); const { - data: quotas, - error: quotasError, + data: quotasWithUsage, + errorMessage: quotasErrorMessage, + queries: quotaUsageQueries, isFetching: isFetchingQuotas, - } = useQuotasQuery( - selectedService.value, - { - page: pagination.page, - page_size: pagination.pageSize, - }, - filters, - Boolean(selectedLocation?.value) - ); - - // Quota Usage Queries - // For each quota, fetch the usage in parallel - // This will only fetch for the paginated set - const quotaIds = quotas?.data.map((quota) => quota.quota_id) ?? []; - const quotaUsageQueries = useQueries({ - queries: quotaIds.map((quotaId) => - quotaQueries.service(selectedService.value)._ctx.usage(quotaId) - ), + } = useGetQuotas({ + selectedLocation: selectedLocation?.value, + selectedService: selectedService.value, + collectionName, + enabled: isGlobalScope ? true : hasSelectedLocation, }); - // Combine the quotas with their usage - const quotasWithUsage = React.useMemo( - () => - quotas?.data.map((quota, index) => ({ - ...quota, - usage: quotaUsageQueries?.[index]?.data, - })) ?? [], - [quotas, quotaUsageQueries] - ); - - if (quotasError) { - return ; + if (quotasErrorMessage) { + return ; } const onIncreaseQuotaTicketCreated = ( @@ -123,34 +91,36 @@ export const QuotasTable = (props: QuotasTableProps) => { - {hasSelectedLocation && isFetchingQuotas ? ( + {isFetchingQuotas ? ( - ) : !selectedLocation ? ( + ) : !isGlobalScope && !hasSelectedLocation ? ( ) : quotasWithUsage.length === 0 ? ( ) : ( quotasWithUsage.map((quota, index) => { - const hasQuotaUsage = quota.usage?.usage !== null; - return ( { )} - {selectedLocation && !isFetchingQuotas && ( - - )} setSupportModalOpen(false)} diff --git a/packages/manager/src/features/Account/Quotas/QuotasTableRow.tsx b/packages/manager/src/features/Account/Quotas/QuotasTable/QuotasTableRow.tsx similarity index 87% rename from packages/manager/src/features/Account/Quotas/QuotasTableRow.tsx rename to packages/manager/src/features/Account/Quotas/QuotasTable/QuotasTableRow.tsx index 1ce94846b28..f9e638533ec 100644 --- a/packages/manager/src/features/Account/Quotas/QuotasTableRow.tsx +++ b/packages/manager/src/features/Account/Quotas/QuotasTable/QuotasTableRow.tsx @@ -9,20 +9,23 @@ import { TableRow } from 'src/components/TableRow/TableRow'; import { useFlags } from 'src/hooks/useFlags'; import { useIsAkamaiAccount } from 'src/hooks/useIsAkamaiAccount'; -import { convertResourceMetric, getQuotaError, pluralizeMetric } from './utils'; +import { + convertResourceMetric, + getQuotaError, + pluralizeMetric, +} from '../utils'; +import type { QuotaWithUsage } from '../utils'; import type { Quota, QuotaUsage } from '@linode/api-v4'; import type { UseQueryResult } from '@tanstack/react-query'; import type { Action } from 'src/components/ActionMenu/ActionMenu'; -interface QuotaWithUsage extends Quota { - usage?: QuotaUsage; -} - interface QuotasTableRowProps { - hasQuotaUsage: boolean; + hasUsage: boolean; index: number; + isDataPresent: boolean; quota: QuotaWithUsage; + quotaRowMinHeight: number; quotaUsageQueries: UseQueryResult[]; setConvertedResourceMetrics: (resourceMetric: { limit: number; @@ -32,13 +35,13 @@ interface QuotasTableRowProps { setSupportModalOpen: (open: boolean) => void; } -const quotaRowMinHeight = 58; - export const QuotasTableRow = (props: QuotasTableRowProps) => { const { - hasQuotaUsage, + hasUsage, + isDataPresent, index, quota, + quotaRowMinHeight, quotaUsageQueries, setSelectedQuota, setSupportModalOpen, @@ -122,27 +125,26 @@ export const QuotasTableRow = (props: QuotasTableRowProps) => { {getQuotaError(quotaUsageQueries, index)} - ) : hasQuotaUsage ? ( + ) : hasUsage && isDataPresent ? ( - ) : ( + ) : hasUsage ? ( Data not available + ) : ( + Not applicable )} - {hasQuotaUsage ? ( - - - - ) : ( - - )} + + + + ); }; diff --git a/packages/manager/src/features/Account/Quotas/hooks/useGetQuotas.ts b/packages/manager/src/features/Account/Quotas/hooks/useGetQuotas.ts new file mode 100644 index 00000000000..c451995bc23 --- /dev/null +++ b/packages/manager/src/features/Account/Quotas/hooks/useGetQuotas.ts @@ -0,0 +1,77 @@ +import { quotaQueries, useAllQuotasQuery, useQueries } from '@linode/queries'; +import * as React from 'react'; + +import { + getQuotaMapper, + getQuotasFilters, + getQuotaVisibilityFilter, +} from 'src/features/Account/Quotas/utils'; + +import type { Filter, QuotaType } from '@linode/api-v4'; + +interface Props { + collectionName: string; + enabled: boolean; + selectedLocation: string; + selectedService: QuotaType; +} + +export const useGetQuotas = ({ + selectedLocation, + selectedService, + collectionName, + enabled = true, +}: Props) => { + const filters: Filter = getQuotasFilters({ + location: { label: '', value: selectedLocation }, + service: { label: '', value: selectedService }, + }); + + const visiblityFilter = getQuotaVisibilityFilter(selectedService); + const quotaMapper = getQuotaMapper(selectedService); + + const { + data: quotas, + error: quotasError, + isError: isQuotasError, + isFetching: isFetchingQuotas, + } = useAllQuotasQuery(selectedService, collectionName, {}, filters, enabled); + + // Quota Usage Queries + // For each quota with has_usage == true, + // fetch the usage in parallel + // This will only fetch for the paginated set + const quotaIdsHavingUsage = + quotas + ?.filter( + (quota) => quota.has_usage === true || quota.has_usage === undefined + ) + .map((quota) => quota.quota_id) ?? []; + const quotaUsageQueries = useQueries({ + queries: quotaIdsHavingUsage.map((quotaId) => + quotaQueries.service(selectedService, collectionName)._ctx.usage(quotaId) + ), + }); + + // Combine the quotas with their usage + const filteredQuotasWithUsage = React.useMemo( + () => + quotas + ?.filter((quota) => visiblityFilter.isVisible(quota)) + .map((quota, index) => + quotaMapper.mapQuota(quota, quotaUsageQueries?.[index]?.data || null) + ) ?? [], + [quotas, quotaUsageQueries] + ); + + return { + data: filteredQuotasWithUsage, + queries: quotaUsageQueries, + errorMessage: + (quotasError && quotasError[0]?.reason) || + quotaUsageQueries.find((query) => query.isError)?.error.message, + isError: isQuotasError || quotaUsageQueries.some((query) => query.isError), + isFetching: + isFetchingQuotas || quotaUsageQueries.some((query) => query.isFetching), + }; +}; diff --git a/packages/manager/src/features/Account/Quotas/utils.ts b/packages/manager/src/features/Account/Quotas/utils.ts index 3ea45ecb21f..ba5ff4cafcf 100644 --- a/packages/manager/src/features/Account/Quotas/utils.ts +++ b/packages/manager/src/features/Account/Quotas/utils.ts @@ -17,6 +17,18 @@ import type { import type { SelectOption } from '@linode/ui'; import type { UseQueryResult } from '@tanstack/react-query'; +const INCLUDED_OBJ_QUOTA_TYPES = [ + 'obj-bytes', + 'obj-objects', + 'obj-buckets', + 'obj-total-ingress-throughput', + 'obj-total-egress-throughput', + 'obj-total-concurrent-requests', +]; +const INCLUDED_OBJ_GLOBAL_QUOTA_TYPES = ['keys']; + +export const QUOTA_ROW_MIN_HEIGHT = 58; + type UseGetLocationsForQuotaService = | { isFetchingRegions: boolean; @@ -80,6 +92,44 @@ interface GetQuotasFiltersProps { service: SelectOption; } +export interface QuotaWithUsage extends Quota { + usage: null | QuotaUsage; +} + +export const getQuotaVisibilityFilter = (service: QuotaType) => { + return { + isVisible(quota: Quota) { + if (service === 'object-storage') { + return ( + INCLUDED_OBJ_QUOTA_TYPES.includes(quota.quota_type) || + INCLUDED_OBJ_GLOBAL_QUOTA_TYPES.includes(quota.quota_type) + ); + } + + return true; + }, + }; +}; + +export const getQuotaMapper = (service: QuotaType) => { + return { + mapQuota(quota: Quota, usage: null | QuotaUsage): QuotaWithUsage { + if (service === 'object-storage') { + return { + ...quota, + quota_name: quota.quota_name.replace(' (per endpoint)', ''), + usage, + }; + } + + return { + ...quota, + usage, + }; + }, + }; +}; + /** * Function to get the filters for the quotas query */ @@ -145,17 +195,19 @@ export const getQuotaIncreaseMessage = ({ } return { - description: `**User**: ${profile.username}
\n**Email**: ${ - profile.email - }
\n**Quota Name**: ${ - quota.quota_name - }
\n**Current Quota**: ${convertedMetrics.limit?.toLocaleString()} ${ - convertedMetrics.metric - }
\n**New Quota Requested**: ${quantity?.toLocaleString()} ${ - convertedMetrics.metric - }
\n**Needed in**: ${ - neededIn - }
\n**${regionAppliedLabel}**: ${regionAppliedValue}`, + description: + `**User**: ${profile.username}
\n**Email**: ${ + profile.email + }
\n**Quota Name**: ${ + quota.quota_name + }
\n**Current Quota**: ${convertedMetrics.limit?.toLocaleString()} ${ + convertedMetrics.metric + }
\n**New Quota Requested**: ${quantity?.toLocaleString()} ${ + convertedMetrics.metric + }
\n**Needed in**: ${neededIn}
\n` + + (regionAppliedValue + ? `**${regionAppliedLabel}**: ${regionAppliedValue}` + : ''), neededIn: 'Fewer than 7 days', notes: '', quantity: String(quantity), @@ -193,6 +245,18 @@ export const convertResourceMetric = ({ }; } + if (initialResourceMetric === 'byte_per_second') { + return { + convertedUsage: 0, + convertedResourceMetric: 'Gbps', + convertedLimit: readableBytes(initialLimit, { + unit: 'GB', + round: 0, + base10: true, + }).value, + }; + } + return { convertedUsage: initialUsage, convertedLimit: initialLimit, diff --git a/packages/manager/src/features/Account/SwitchAccountButton.tsx b/packages/manager/src/features/Account/SwitchAccountButton.tsx index 33944ec21e5..ef6ce0568ef 100644 --- a/packages/manager/src/features/Account/SwitchAccountButton.tsx +++ b/packages/manager/src/features/Account/SwitchAccountButton.tsx @@ -3,9 +3,13 @@ import * as React from 'react'; import SwapIcon from 'src/assets/icons/swapSmall.svg'; +import { useDelegationRole } from '../IAM/hooks/useDelegationRole'; + import type { ButtonProps } from '@linode/ui'; export const SwitchAccountButton = (props: ButtonProps) => { + const { isDelegateUserType } = useDelegationRole(); + return ( ); }; diff --git a/packages/manager/src/features/Account/SwitchAccountDrawer.test.tsx b/packages/manager/src/features/Account/SwitchAccountDrawer.test.tsx index b4fa59ab5f8..36f4e9fcd7b 100644 --- a/packages/manager/src/features/Account/SwitchAccountDrawer.test.tsx +++ b/packages/manager/src/features/Account/SwitchAccountDrawer.test.tsx @@ -9,7 +9,7 @@ import { SwitchAccountDrawer } from './SwitchAccountDrawer'; const queryMocks = vi.hoisted(() => ({ useProfile: vi.fn().mockReturnValue({}), - useAllListMyDelegatedChildAccountsQuery: vi.fn().mockReturnValue({}), + useGetListMyDelegatedChildAccountsQuery: vi.fn().mockReturnValue({}), })); vi.mock('@linode/queries', async () => { @@ -17,8 +17,8 @@ vi.mock('@linode/queries', async () => { return { ...actual, useProfile: queryMocks.useProfile, - useAllListMyDelegatedChildAccountsQuery: - queryMocks.useAllListMyDelegatedChildAccountsQuery, + useGetListMyDelegatedChildAccountsQuery: + queryMocks.useGetListMyDelegatedChildAccountsQuery, }; }); @@ -31,7 +31,7 @@ const props = { describe('SwitchAccountDrawer', () => { beforeEach(() => { queryMocks.useProfile.mockReturnValue({}); - queryMocks.useAllListMyDelegatedChildAccountsQuery.mockReturnValue({ + queryMocks.useGetListMyDelegatedChildAccountsQuery.mockReturnValue({ data: accountFactory.buildList(5, { company: 'Test Account 1', euuid: '123', diff --git a/packages/manager/src/features/Account/SwitchAccountDrawer.tsx b/packages/manager/src/features/Account/SwitchAccountDrawer.tsx index e509d7f18f2..d18e6f105f1 100644 --- a/packages/manager/src/features/Account/SwitchAccountDrawer.tsx +++ b/packages/manager/src/features/Account/SwitchAccountDrawer.tsx @@ -1,19 +1,29 @@ import { - useAllListMyDelegatedChildAccountsQuery, useChildAccountsInfiniteQuery, + useMyDelegatedChildAccountsQuery, } from '@linode/queries'; -import { Drawer, LinkButton, Notice, Typography } from '@linode/ui'; +import { + Button, + Drawer, + LinkButton, + Notice, + Stack, + Typography, + useTheme, +} from '@linode/ui'; import React, { useMemo, useState } from 'react'; +import ErrorStateCloud from 'src/assets/icons/error-state-cloud.svg'; import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField'; -import { PARENT_USER_SESSION_EXPIRED } from 'src/features/Account/constants'; import { useParentChildAuthentication } from 'src/features/Account/SwitchAccounts/useParentChildAuthentication'; +import { useSwitchToParentAccount } from 'src/features/Account/SwitchAccounts/useSwitchToParentAccount'; import { setTokenInLocalStorage } from 'src/features/Account/SwitchAccounts/utils'; import { useIsIAMDelegationEnabled } from 'src/features/IAM/hooks/useIsIAMEnabled'; import { sendSwitchToParentAccountEvent } from 'src/utilities/analytics/customEventAnalytics'; -import { getStorage, setStorage, storage } from 'src/utilities/storage'; +import { getStorage, storage } from 'src/utilities/storage'; import { ChildAccountList } from './SwitchAccounts/ChildAccountList'; +import { ChildAccountsTable } from './SwitchAccounts/ChildAccountsTable'; import { updateParentTokenInLocalStorage } from './SwitchAccounts/utils'; import type { APIError, Filter, UserType } from '@linode/api-v4'; @@ -34,13 +44,18 @@ interface HandleSwitchToChildAccountProps { export const SwitchAccountDrawer = (props: Props) => { const { onClose, open, userType } = props; - const [isSubmitting, setSubmitting] = React.useState(false); + const theme = useTheme(); const [isParentTokenError, setIsParentTokenError] = React.useState< APIError[] >([]); const [searchQuery, setSearchQuery] = React.useState(''); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(25); const { isIAMDelegationEnabled } = useIsIAMDelegationEnabled(); - const isProxyUser = userType === 'proxy'; + const isParentUserType = userType === 'parent'; + const isProxyUserType = userType === 'proxy'; + const isDelegateUserType = userType === 'delegate'; + const isProxyOrDelegateUserType = isProxyUserType || isDelegateUserType; const currentParentTokenWithBearer = getStorage('authentication/parent_token/token') ?? ''; const currentTokenWithBearer = storage.authentication.token.get() ?? ''; @@ -50,9 +65,18 @@ export const SwitchAccountDrawer = (props: Props) => { error: createTokenError, revokeToken, updateCurrentToken, - validateParentToken, } = useParentChildAuthentication(); + const { handleSwitchToParentAccount, isSubmitting } = + useSwitchToParentAccount({ + isDelegateUserType, + isProxyUserType, + onClose, + onTokenExpired: (error) => { + setIsParentTokenError([error]); + }, + }); + const createTokenErrorReason = createTokenError?.[0]?.reason; const filter: Filter = { @@ -73,30 +97,30 @@ export const SwitchAccountDrawer = (props: Props) => { } = useChildAccountsInfiniteQuery( { filter, - headers: - userType === 'proxy' - ? { - Authorization: currentTokenWithBearer, - } - : undefined, + headers: isProxyOrDelegateUserType + ? { + Authorization: currentTokenWithBearer, + } + : undefined, }, isIAMDelegationEnabled === false ); + const { - data: allChildAccounts, - error: allChildAccountsError, - isLoading: allChildAccountsLoading, - isRefetching: allChildAccountsIsRefetching, - refetch: refetchAllChildAccounts, - } = useAllListMyDelegatedChildAccountsQuery({ - params: {}, - enabled: isIAMDelegationEnabled, + data: delegatedChildAccounts, + error: delegatedChildAccountsError, + isLoading: delegatedChildAccountsLoading, + isRefetching: delegatedChildAccountsIsRefetching, + refetch: refetchDelegatedChildAccounts, + } = useMyDelegatedChildAccountsQuery({ + params: { + page, + page_size: pageSize, + }, + filter, + enabled: isIAMDelegationEnabled && isParentUserType, }); - const refetchFn = isIAMDelegationEnabled - ? refetchAllChildAccounts - : refetchChildAccounts; - const handleSwitchToChildAccount = React.useCallback( async ({ currentTokenWithBearer, @@ -105,10 +129,11 @@ export const SwitchAccountDrawer = (props: Props) => { onClose, userType, }: HandleSwitchToChildAccountProps) => { - const isProxyUser = userType === 'proxy'; + const isProxyOrDelegateUserType = + userType === 'proxy' || userType === 'delegate'; try { - if (isProxyUser) { + if (isProxyOrDelegateUserType) { // Revoke proxy token before switching accounts. await revokeToken().catch(() => { /* Allow user account switching; tokens will expire naturally. */ @@ -121,74 +146,71 @@ export const SwitchAccountDrawer = (props: Props) => { const proxyToken = await createToken(euuid); setTokenInLocalStorage({ - prefix: 'authentication/proxy_token', + prefix: isProxyUserType + ? 'authentication/proxy_token' + : 'authentication/delegate_token', token: { ...proxyToken, token: `Bearer ${proxyToken.token}`, }, }); - updateCurrentToken({ userType: 'proxy' }); + updateCurrentToken({ + userType: isProxyUserType ? 'proxy' : 'delegate', + }); onClose(event); location.reload(); - } catch (error) { + } catch { // Error is handled by createTokenError. } }, - [createToken, updateCurrentToken, revokeToken] + [createToken, isProxyUserType, updateCurrentToken, revokeToken] ); - const handleSwitchToParentAccount = React.useCallback(async () => { - if (!validateParentToken()) { - const expiredTokenError: APIError = { - field: 'token', - reason: PARENT_USER_SESSION_EXPIRED, - }; - - setIsParentTokenError([expiredTokenError]); - - return; - } - - // Flag to prevent multiple clicks on the switch account link. - setSubmitting(true); - - // Revoke proxy token before switching to parent account. - await revokeToken().catch(() => { - /* Allow user account switching; tokens will expire naturally. */ - }); - - updateCurrentToken({ userType: 'parent' }); - - // Reset flag for proxy user to display success toast once. - setStorage('is_proxy_user', 'false'); - - onClose(); - location.reload(); - }, [onClose, revokeToken, validateParentToken, updateCurrentToken]); - const [isSwitchingChildAccounts, setIsSwitchingChildAccounts] = useState(false); + const isLoading = + isInitialLoading || + isSubmitting || + isSwitchingChildAccounts || + isRefetching || + delegatedChildAccountsLoading || + delegatedChildAccountsIsRefetching; + + const refetchFn = isIAMDelegationEnabled + ? refetchDelegatedChildAccounts + : refetchChildAccounts; const handleClose = () => { setIsSwitchingChildAccounts(false); + setSearchQuery(''); onClose(); }; const childAccounts = useMemo(() => { if (isIAMDelegationEnabled) { - if (searchQuery && allChildAccounts) { - // Client-side filter: match company field with searchQuery (case-insensitive, contains) - const normalizedQuery = searchQuery.toLowerCase(); - return allChildAccounts.filter((account) => - account.company?.toLowerCase().includes(normalizedQuery) - ); - } - return allChildAccounts; + return delegatedChildAccounts?.data || []; } return data?.pages.flatMap((page) => page.data); - }, [isIAMDelegationEnabled, searchQuery, allChildAccounts, data]); + }, [isIAMDelegationEnabled, delegatedChildAccounts, data]); + + const handlePageChange = (newPage: number) => { + setPage(newPage); + }; + + const handlePageSizeChange = (newPageSize: number) => { + setPageSize(newPageSize); + setPage(1); // Reset to first page when page size changes + }; + + const handleSearchQueryChange = (query: string) => { + setSearchQuery(query); + setPage(1); // Reset to first page when search query changes + }; + const hasError = isIAMDelegationEnabled + ? delegatedChildAccountsError + : childAccountInfiniteError; return ( {createTokenErrorReason && ( @@ -203,7 +225,7 @@ export const SwitchAccountDrawer = (props: Props) => { })} > Select an account to view and manage its settings and configurations - {isProxyUser && ( + {isProxyOrDelegateUserType && ( <> {' or '} { )} . - {isIAMDelegationEnabled && - allChildAccounts && - allChildAccounts.length !== 0 && ( - <> - - {searchQuery && childAccounts && childAccounts.length === 0 && ( - + + {hasError && ( + + + Unable to load data. + + Try again or contact support if the issue persists. + + + + )} + {!hasError && ( + <> + + {searchQuery && + childAccounts && + childAccounts.length === 0 && + !isLoading && ( + No search results )} - - )} + + )} + {isIAMDelegationEnabled && ( + + )} {!isIAMDelegationEnabled && ( - )} - ); }; diff --git a/packages/manager/src/features/Account/SwitchAccounts/ChildAccountList.test.tsx b/packages/manager/src/features/Account/SwitchAccounts/ChildAccountList.test.tsx index 4e62477a014..8c4db5fe0da 100644 --- a/packages/manager/src/features/Account/SwitchAccounts/ChildAccountList.test.tsx +++ b/packages/manager/src/features/Account/SwitchAccounts/ChildAccountList.test.tsx @@ -26,10 +26,6 @@ const props: ChildAccountListProps = { onClose: vi.fn(), onSwitchAccount: vi.fn(), userType: undefined, - errors: { - childAccountInfiniteError: false, - allChildAccountsError: null, - }, fetchNextPage: vi.fn(), filter: {}, hasNextPage: false, diff --git a/packages/manager/src/features/Account/SwitchAccounts/ChildAccountList.tsx b/packages/manager/src/features/Account/SwitchAccounts/ChildAccountList.tsx index d0994f51578..5b93722145e 100644 --- a/packages/manager/src/features/Account/SwitchAccounts/ChildAccountList.tsx +++ b/packages/manager/src/features/Account/SwitchAccounts/ChildAccountList.tsx @@ -1,16 +1,7 @@ -import { - Box, - Button, - CircleProgress, - LinkButton, - Notice, - Stack, - Typography, -} from '@linode/ui'; +import { Box, CircleProgress, LinkButton, Notice, Stack } from '@linode/ui'; import React from 'react'; import { Waypoint } from 'react-waypoint'; -import ErrorStateCloud from 'src/assets/icons/error-state-cloud.svg'; import { useIsIAMDelegationEnabled } from 'src/features/IAM/hooks/useIsIAMEnabled'; import type { ChildAccount, Filter, UserType } from '@linode/api-v4'; @@ -18,10 +9,6 @@ import type { ChildAccount, Filter, UserType } from '@linode/api-v4'; export interface ChildAccountListProps { childAccounts: ChildAccount[] | undefined; currentTokenWithBearer: string; - errors: { - allChildAccountsError: Error | null; - childAccountInfiniteError: boolean; - }; fetchNextPage: () => void; filter: Filter; hasNextPage: boolean; @@ -52,39 +39,12 @@ export const ChildAccountList = React.memo( onClose, onSwitchAccount, userType, - refetchFn, - errors, hasNextPage, fetchNextPage, isFetchingNextPage, }: ChildAccountListProps) => { const { isIAMDelegationEnabled } = useIsIAMDelegationEnabled(); - const hasError = isIAMDelegationEnabled - ? errors.allChildAccountsError - : errors.childAccountInfiniteError; - - if (hasError) { - return ( - - - Unable to load data. - - Try again or contact support if the issue persists. - - - - ); - } - if (isLoading) { return ( diff --git a/packages/manager/src/features/Account/SwitchAccounts/ChildAccountsTable.tsx b/packages/manager/src/features/Account/SwitchAccounts/ChildAccountsTable.tsx new file mode 100644 index 00000000000..1f4c316003d --- /dev/null +++ b/packages/manager/src/features/Account/SwitchAccounts/ChildAccountsTable.tsx @@ -0,0 +1,129 @@ +import { Box, CircleProgress, LinkButton, useTheme } from '@linode/ui'; +import { Pagination } from 'akamai-cds-react-components/Pagination'; +import { + Table, + TableBody, + TableCell, + TableRow, +} from 'akamai-cds-react-components/Table'; +import React from 'react'; + +import { MIN_PAGE_SIZE } from 'src/components/PaginationFooter/PaginationFooter.constants'; + +import type { Account, UserType } from '@linode/api-v4'; + +interface ChildAccountsTableProps { + childAccounts?: Account[]; + currentTokenWithBearer?: string; + isLoading: boolean; + isSwitchingChildAccounts: boolean; + onClose: () => void; + onPageChange: (page: number) => void; + onPageSizeChange: (pageSize: number) => void; + onSwitchAccount: ({ + currentTokenWithBearer, + euuid, + event, + onClose, + userType, + }: { + currentTokenWithBearer?: string; + euuid: string; + event: React.MouseEvent; + onClose: (e: React.SyntheticEvent) => void; + userType: undefined | UserType; + }) => void; + page: number; + pageSize: number; + setIsSwitchingChildAccounts: (value: boolean) => void; + totalResults: number; + userType: undefined | UserType; +} + +export const ChildAccountsTable = (props: ChildAccountsTableProps) => { + const { + childAccounts, + currentTokenWithBearer, + isLoading, + isSwitchingChildAccounts, + onClose, + onSwitchAccount, + setIsSwitchingChildAccounts, + userType, + page, + pageSize, + totalResults, + onPageChange, + onPageSizeChange, + } = props; + + const theme = useTheme(); + const handlePageChange = (newPage: number) => { + onPageChange(newPage); + }; + + const handlePageSizeChange = (newPageSize: number) => { + onPageSizeChange(newPageSize); + }; + + if (isLoading) { + return ( + + + + ); + } + + return ( + <> + + + {childAccounts?.map((childAccount, idx) => ( + + + { + setIsSwitchingChildAccounts(true); + onSwitchAccount({ + currentTokenWithBearer, + euuid: childAccount.euuid, + event, + onClose, + userType, + }); + }} + sx={{ + textOverflow: 'ellipsis', + overflow: 'hidden', + textAlign: 'left', + whiteSpace: 'nowrap', + }} + > + {childAccount.company} + + + + ))} + +
+ {totalResults > MIN_PAGE_SIZE && ( + ) => + handlePageChange(Number(e.detail)) + } + onPageSizeChange={( + e: CustomEvent<{ page: number; pageSize: number }> + ) => handlePageSizeChange(Number(e.detail.pageSize))} + page={page} + pageSize={pageSize} + pageSizes={[25, 50, 75, 100]} + style={{ marginTop: theme.spacingFunction(12) }} + /> + )} + + ); +}; diff --git a/packages/manager/src/features/Account/SwitchAccounts/SessionExpirationDialog.tsx b/packages/manager/src/features/Account/SwitchAccounts/SessionExpirationDialog.tsx index b809a19c6a9..abf84a1390f 100644 --- a/packages/manager/src/features/Account/SwitchAccounts/SessionExpirationDialog.tsx +++ b/packages/manager/src/features/Account/SwitchAccounts/SessionExpirationDialog.tsx @@ -8,6 +8,7 @@ import { ConfirmationDialog } from 'src/components/ConfirmationDialog/Confirmati import { sessionExpirationContext as _sessionExpirationContext } from 'src/context/sessionExpirationContext'; import { useParentChildAuthentication } from 'src/features/Account/SwitchAccounts/useParentChildAuthentication'; import { setTokenInLocalStorage } from 'src/features/Account/SwitchAccounts/utils'; +import { useDelegationRole } from 'src/features/IAM/hooks/useDelegationRole'; import { parseAPIDate } from 'src/utilities/date'; import { getStorage, setStorage } from 'src/utilities/storage'; @@ -21,6 +22,7 @@ export const SessionExpirationDialog = React.memo( const sessionExpirationContext = React.useContext( _sessionExpirationContext ); + const { isProxyUserType, isDelegateUserType } = useDelegationRole(); const [timeRemaining, setTimeRemaining] = React.useState<{ minutes: number; @@ -90,8 +92,12 @@ export const SessionExpirationDialog = React.memo( updateCurrentToken({ userType: 'parent' }); - // Reset flag for proxy user to display success toast once. - setStorage('is_proxy_user', 'false'); + // Reset flag for proxy or delegate user to display success toast once. + if (isProxyUserType) { + setStorage('is_proxy_user_type', 'false'); + } else if (isDelegateUserType) { + setStorage('is_delegate_user_type', 'false'); + } setLogoutLoading(false); onClose(); @@ -107,14 +113,18 @@ export const SessionExpirationDialog = React.memo( const proxyToken = await createToken(euuid); setTokenInLocalStorage({ - prefix: 'authentication/proxy_token', + prefix: isProxyUserType + ? 'authentication/proxy_token' + : 'authentication/delegate_token', token: { ...proxyToken, token: `Bearer ${proxyToken.token}`, }, }); - updateCurrentToken({ userType: 'proxy' }); + updateCurrentToken({ + userType: isProxyUserType ? 'proxy' : 'delegate', + }); onClose(); location.reload(); } catch (error) { @@ -129,7 +139,9 @@ export const SessionExpirationDialog = React.memo( */ useEffect(() => { const checkTokenExpiry = () => { - const expiryString = getStorage('authentication/proxy_token/expire'); + const expiryString = isProxyUserType + ? getStorage('authentication/proxy_token/expire') + : getStorage('authentication/delegate_token/expire'); if (!expiryString) { return; diff --git a/packages/manager/src/features/Account/SwitchAccounts/useIsParentTokenExpired.test.tsx b/packages/manager/src/features/Account/SwitchAccounts/useIsParentTokenExpired.test.tsx index 70484252363..4e8ff17f1f5 100644 --- a/packages/manager/src/features/Account/SwitchAccounts/useIsParentTokenExpired.test.tsx +++ b/packages/manager/src/features/Account/SwitchAccounts/useIsParentTokenExpired.test.tsx @@ -35,12 +35,12 @@ const wrapper = ({ children }: any) => ( // Utility function to render the hook with the wrapper and initialProps const renderUseIsParentTokenExpiredHook = ({ - isProxyUser, + isProxyOrDelegateUserType, }: { - isProxyUser: boolean; + isProxyOrDelegateUserType: boolean; }) => - renderHook(() => useIsParentTokenExpired({ isProxyUser }), { - initialProps: isProxyUser, + renderHook(() => useIsParentTokenExpired({ isProxyOrDelegateUserType }), { + initialProps: isProxyOrDelegateUserType, wrapper, }); @@ -52,7 +52,7 @@ describe('useIsParentTokenExpired', () => { it('should not mark parent token as expired if it is valid', () => { const { result } = renderUseIsParentTokenExpiredHook({ - isProxyUser: true, + isProxyOrDelegateUserType: true, }); expect(result.current.isParentTokenExpired).toBe(false); @@ -61,7 +61,7 @@ describe('useIsParentTokenExpired', () => { it('should mark parent token as expired if it is invalid', () => { queryMocks.isParentTokenValid.mockReturnValue(false); const { result } = renderUseIsParentTokenExpiredHook({ - isProxyUser: true, + isProxyOrDelegateUserType: true, }); expect(result.current.isParentTokenExpired).toBe(true); @@ -69,7 +69,7 @@ describe('useIsParentTokenExpired', () => { it('should not update the session context when isParentTokenExpired is false', () => { renderUseIsParentTokenExpiredHook({ - isProxyUser: true, + isProxyOrDelegateUserType: true, }); expect(mockUpdateState).not.toHaveBeenCalled(); @@ -77,9 +77,10 @@ describe('useIsParentTokenExpired', () => { it('should react to isProxyUser changes', () => { const { rerender, result } = renderHook( - ({ isProxyUser }) => useIsParentTokenExpired({ isProxyUser }), + ({ isProxyOrDelegateUserType }) => + useIsParentTokenExpired({ isProxyOrDelegateUserType }), { - initialProps: { isProxyUser: false }, + initialProps: { isProxyOrDelegateUserType: false }, wrapper, } ); @@ -89,7 +90,7 @@ describe('useIsParentTokenExpired', () => { // Change mock value, rerender with isProxyUser true queryMocks.isParentTokenValid.mockReturnValue(false); - rerender({ isProxyUser: true }); + rerender({ isProxyOrDelegateUserType: true }); // Now, if the token is invalid, isParentTokenExpired should be true expect(result.current.isParentTokenExpired).toBe(true); diff --git a/packages/manager/src/features/Account/SwitchAccounts/useIsParentTokenExpired.tsx b/packages/manager/src/features/Account/SwitchAccounts/useIsParentTokenExpired.tsx index a821e2bfc09..d7b877009ee 100644 --- a/packages/manager/src/features/Account/SwitchAccounts/useIsParentTokenExpired.tsx +++ b/packages/manager/src/features/Account/SwitchAccounts/useIsParentTokenExpired.tsx @@ -4,18 +4,18 @@ import { isParentTokenValid } from 'src/features/Account/SwitchAccounts/utils'; // Checks and reacts to the expiration status of parent tokens. export const useIsParentTokenExpired = ({ - isProxyUser, + isProxyOrDelegateUserType, }: { - isProxyUser: boolean; + isProxyOrDelegateUserType: boolean; }) => { const [isParentTokenExpired, setIsParentTokenExpired] = React.useState(false); React.useEffect(() => { - if (isProxyUser) { + if (isProxyOrDelegateUserType) { const isExpired = !isParentTokenValid(); setIsParentTokenExpired(isExpired); } - }, [isProxyUser]); + }, [isProxyOrDelegateUserType]); return { isParentTokenExpired }; }; diff --git a/packages/manager/src/features/Account/SwitchAccounts/useParentChildAuthentication.tsx b/packages/manager/src/features/Account/SwitchAccounts/useParentChildAuthentication.tsx index 79742411df4..d7866b8eb6e 100644 --- a/packages/manager/src/features/Account/SwitchAccounts/useParentChildAuthentication.tsx +++ b/packages/manager/src/features/Account/SwitchAccounts/useParentChildAuthentication.tsx @@ -53,8 +53,8 @@ export const useParentChildAuthentication = () => { euuid, headers: { /** - * Headers are required for proxy users when obtaining a proxy token. - * For 'proxy' userType, use the stored parent token in the request. + * Headers are required for proxy or delegate users when obtaining a proxy or delegate token. + * For 'proxy' or 'delegate' userType, use the stored parent token in the request. */ Authorization: getStorage('authentication/parent_token/token'), }, @@ -82,7 +82,11 @@ export const useParentChildAuthentication = () => { }, [currentTokenWithBearer]); const updateCurrentToken = useCallback( - ({ userType }: { userType: Extract }) => { + ({ + userType, + }: { + userType: Extract; + }) => { updateCurrentTokenBasedOnUserType({ userType }); }, [] diff --git a/packages/manager/src/features/Account/SwitchAccounts/useSwitchToParentAccount.ts b/packages/manager/src/features/Account/SwitchAccounts/useSwitchToParentAccount.ts new file mode 100644 index 00000000000..f9ea6ec5efb --- /dev/null +++ b/packages/manager/src/features/Account/SwitchAccounts/useSwitchToParentAccount.ts @@ -0,0 +1,77 @@ +import React from 'react'; + +import { PARENT_USER_SESSION_EXPIRED } from 'src/features/Account/constants'; +import { setStorage } from 'src/utilities/storage'; + +import { useParentChildAuthentication } from './useParentChildAuthentication'; + +import type { APIError } from '@linode/api-v4'; + +interface UseSwitchToParentAccountProps { + isDelegateUserType?: boolean; + isProxyUserType?: boolean; + onClose?: () => void; + onTokenExpired?: (error: APIError) => void; +} + +export const useSwitchToParentAccount = ({ + isDelegateUserType, + isProxyUserType, + onClose, + onTokenExpired, +}: UseSwitchToParentAccountProps = {}) => { + const [isSubmitting, setSubmitting] = React.useState(false); + + const { revokeToken, updateCurrentToken, validateParentToken } = + useParentChildAuthentication(); + + const handleSwitchToParentAccount = React.useCallback(async () => { + if (!validateParentToken()) { + const expiredTokenError: APIError = { + field: 'token', + reason: PARENT_USER_SESSION_EXPIRED, + }; + + onTokenExpired?.(expiredTokenError); + return; + } + + // Flag to prevent multiple clicks on the switch account button. + setSubmitting(true); + + try { + // Revoke proxy or delegate token before switching to parent account. + await revokeToken().catch(() => { + /* Allow user account switching; tokens will expire naturally. */ + }); + + updateCurrentToken({ userType: 'parent' }); + + // Reset flag for proxy or delegate user to display success toast once. + if (isProxyUserType) { + setStorage('is_proxy_user_type', 'false'); + } else if (isDelegateUserType) { + setStorage('is_delegate_user_type', 'false'); + } + + onClose?.(); + location.reload(); + } catch (error) { + setSubmitting(false); + throw error; + } + }, [ + validateParentToken, + onTokenExpired, + revokeToken, + updateCurrentToken, + isProxyUserType, + isDelegateUserType, + onClose, + ]); + + return { + handleSwitchToParentAccount, + isSubmitting, + }; +}; diff --git a/packages/manager/src/features/Account/SwitchAccounts/utils.ts b/packages/manager/src/features/Account/SwitchAccounts/utils.ts index 8775d74c568..3368a1197a7 100644 --- a/packages/manager/src/features/Account/SwitchAccounts/utils.ts +++ b/packages/manager/src/features/Account/SwitchAccounts/utils.ts @@ -8,7 +8,7 @@ export interface ProxyTokenCreationParams { */ euuid: string; /** - * The parent token used to create the proxy token (includes 'Bearer' prefix). + * The parent token used to create the proxy or delegate token (includes 'Bearer' prefix). */ token: string; /** @@ -43,7 +43,7 @@ export const updateParentTokenInLocalStorage = ({ export const isParentTokenValid = (): boolean => { const now = new Date().toISOString(); - // From a proxy user, check whether parent token is still valid before switching. + // From a proxy or delegate user, check whether parent token is still valid before switching. if ( now > new Date(getStorage('authentication/parent_token/expire')).toISOString() @@ -55,7 +55,7 @@ export const isParentTokenValid = (): boolean => { /** * Set token information in the local storage. - * This allows us to store a token for later use, such as switching between parent and proxy accounts. + * This allows us to store a token for later use, such as switching between parent and proxy or delegate accounts. */ export const setTokenInLocalStorage = ({ prefix, @@ -81,7 +81,7 @@ export const setTokenInLocalStorage = ({ export const updateCurrentTokenBasedOnUserType = ({ userType, }: { - userType: 'parent' | 'proxy'; + userType: 'delegate' | 'parent' | 'proxy'; }) => { const storageKeyPrefix = `authentication/${userType}_token`; diff --git a/packages/manager/src/features/Account/constants.ts b/packages/manager/src/features/Account/constants.ts index e51f2b2107a..4b7bb56aa35 100644 --- a/packages/manager/src/features/Account/constants.ts +++ b/packages/manager/src/features/Account/constants.ts @@ -27,10 +27,10 @@ export const PARENT_USER_CLOSE_ACCOUNT_TOOLTIP_TEXT = 'Remove child accounts before closing the account.'; export const PARENT_USER_SESSION_EXPIRED = `Session expired. Please log in again to your ${PARENT_USER} account.`; -// Proxy User Messaging -export const PROXY_USER_RESTRICTED_TOOLTIP_TEXT = +// Delegate User Messaging +export const DELEGATE_USER_RESTRICTED_TOOLTIP_TEXT = 'You can\u{2019}t perform this action on child accounts.'; -export const PROXY_USER_CLOSE_ACCOUNT_TOOLTIP_TEXT = `Contact ${CUSTOMER_SUPPORT} to close this account.`; +export const DELEGATE_USER_CLOSE_ACCOUNT_TOOLTIP_TEXT = `Contact ${CUSTOMER_SUPPORT} to close this account.`; // Child User Messaging export const CHILD_USER_CLOSE_ACCOUNT_TOOLTIP_TEXT = `Contact your ${PARENT_USER} to close your account.`; diff --git a/packages/manager/src/features/Account/utils.ts b/packages/manager/src/features/Account/utils.ts index 275a018261b..d817b1378b6 100644 --- a/packages/manager/src/features/Account/utils.ts +++ b/packages/manager/src/features/Account/utils.ts @@ -26,7 +26,7 @@ export type ActionType = interface GetRestrictedResourceText { action?: ActionType | ActionType[]; includeContactInfo?: boolean; - isChildUser?: boolean; + isChildUserType?: boolean; isSingular?: boolean; resourceType: GrantTypeMap; } @@ -52,7 +52,7 @@ export type RestrictedGlobalGrantType = export const getRestrictedResourceText = ({ action = 'edit', includeContactInfo = true, - isChildUser = false, + isChildUserType = false, isSingular = true, resourceType, }: GetRestrictedResourceText): string => { @@ -60,7 +60,7 @@ export const getRestrictedResourceText = ({ ? 'this ' + resourceType.replace(/s$/, '') : resourceType; - const contactPerson = isChildUser ? PARENT_USER : ADMINISTRATOR; + const contactPerson = isChildUserType ? PARENT_USER : ADMINISTRATOR; const actionText = formatAction(action); diff --git a/packages/manager/src/features/Billing/BillingLanding/BillingLanding.tsx b/packages/manager/src/features/Billing/BillingLanding/BillingLanding.tsx index edf91e61435..05740e6b4ea 100644 --- a/packages/manager/src/features/Billing/BillingLanding/BillingLanding.tsx +++ b/packages/manager/src/features/Billing/BillingLanding/BillingLanding.tsx @@ -1,4 +1,4 @@ -import { useAccount, useProfile } from '@linode/queries'; +import { useAccount } from '@linode/queries'; import { useNavigate } from '@tanstack/react-router'; import * as React from 'react'; @@ -15,6 +15,7 @@ import { sendSwitchAccountEvent } from 'src/utilities/analytics/customEventAnaly import { PlatformMaintenanceBanner } from '../../../components/PlatformMaintenanceBanner/PlatformMaintenanceBanner'; import { SwitchAccountButton } from '../../Account/SwitchAccountButton'; import { SwitchAccountDrawer } from '../../Account/SwitchAccountDrawer'; +import { useDelegationRole } from '../../IAM/hooks/useDelegationRole'; import { usePermissions } from '../../IAM/hooks/usePermissions'; import { BillingDetail } from '../BillingDetail'; @@ -22,9 +23,14 @@ import type { LandingHeaderProps } from 'src/components/LandingHeader'; export const BillingLanding = () => { const navigate = useNavigate(); + const { + isProxyOrDelegateUserType, + isChildUserType, + isParentUserType, + profileUserType, + } = useDelegationRole(); const { data: account } = useAccount(); - const { data: profile } = useProfile(); const { data: permissions } = usePermissions('account', [ 'make_billing_payment', @@ -34,24 +40,24 @@ export const BillingLanding = () => { const sessionContext = React.useContext(switchAccountSessionContext); const isAkamaiAccount = account?.billing_source === 'akamai'; - const isProxyUser = profile?.user_type === 'proxy'; - const isChildUser = profile?.user_type === 'child'; - const isParentUser = profile?.user_type === 'parent'; - const contactPerson = isChildUser ? PARENT_USER : ADMINISTRATOR; + const contactPerson = isChildUserType ? PARENT_USER : ADMINISTRATOR; const isChildAccountAccessRestricted = useRestrictedGlobalGrantCheck({ globalGrantType: 'child_account_access', }); const { isIAMDelegationEnabled } = useIsIAMDelegationEnabled(); - const { isParentTokenExpired } = useIsParentTokenExpired({ isProxyUser }); + const { isParentTokenExpired } = useIsParentTokenExpired({ + isProxyOrDelegateUserType, + }); - const isReadOnly = !permissions.make_billing_payment || isChildUser; + const isReadOnly = !permissions.make_billing_payment || isChildUserType; const canSwitchBetweenParentOrProxyAccount = isIAMDelegationEnabled - ? isParentUser - : (!isChildAccountAccessRestricted && isParentUser) || isProxyUser; + ? isParentUserType + : (!isChildAccountAccessRestricted && isParentUserType) || + isProxyOrDelegateUserType; const handleAccountSwitch = () => { if (isParentTokenExpired) { @@ -104,7 +110,7 @@ export const BillingLanding = () => { setIsDrawerOpen(false)} open={isDrawerOpen} - userType={profile?.user_type} + userType={profileUserType} /> ); diff --git a/packages/manager/src/features/Billing/BillingPanels/BillingSummary/PaymentDrawer/PaymentDrawer.tsx b/packages/manager/src/features/Billing/BillingPanels/BillingSummary/PaymentDrawer/PaymentDrawer.tsx index 45d76b458d6..d335205d5cf 100644 --- a/packages/manager/src/features/Billing/BillingPanels/BillingSummary/PaymentDrawer/PaymentDrawer.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/BillingSummary/PaymentDrawer/PaymentDrawer.tsx @@ -1,5 +1,5 @@ import { makePayment } from '@linode/api-v4/lib/account'; -import { accountQueries, useAccount, useProfile } from '@linode/queries'; +import { accountQueries, useAccount } from '@linode/queries'; import { Button, Divider, @@ -22,6 +22,7 @@ import { Currency } from 'src/components/Currency'; import { LinearProgress } from 'src/components/LinearProgress'; import { SupportLink } from 'src/components/SupportLink'; import { getRestrictedResourceText } from 'src/features/Account/utils'; +import { useDelegationRole } from 'src/features/IAM/hooks/useDelegationRole'; import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; import { isCreditCardExpired } from 'src/utilities/creditCard'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; @@ -89,7 +90,7 @@ export const PaymentDrawer = (props: Props) => { isLoading: accountLoading, refetch: accountRefetch, } = useAccount(); - const { data: profile } = useProfile(); + const { isChildUserType } = useDelegationRole(); const { classes, cx } = useStyles(); const { enqueueSnackbar } = useSnackbar(); @@ -113,13 +114,11 @@ export const PaymentDrawer = (props: Props) => { const minimumPayment = getMinimumPayment(account?.balance || 0); const paymentTooLow = +usd < +minimumPayment; - - const isChildUser = profile?.user_type === 'child'; const isReadOnly = useRestrictedGlobalGrantCheck({ globalGrantType: 'account_access', permittedGrantLevel: 'read_write', - }) || isChildUser; + }) || isChildUserType; React.useEffect(() => { setUSD(getMinimumPayment(account?.balance || 0)); @@ -241,7 +240,7 @@ export const PaymentDrawer = (props: Props) => { {isReadOnly && ( { (preferences) => preferences?.maskSensitiveData ); - const isChildUser = Boolean(profile?.user_type === 'child'); + const isChildUserType = Boolean(profile?.user_type === 'child'); const taxIdIsVerifyingNotification = notifications?.find((notification) => { return notification.type === 'tax_id_verifying'; @@ -78,7 +78,7 @@ export const ContactInformation = React.memo((props: Props) => { const { data: permissions } = usePermissions('account', ['update_account']); - const isReadOnly = !permissions.update_account || isChildUser; + const isReadOnly = !permissions.update_account || isChildUserType; const handleEditDrawerOpen = () => { navigate({ @@ -146,7 +146,7 @@ export const ContactInformation = React.memo((props: Props) => { onClick={handleEditDrawerOpen} tooltipText={getRestrictedResourceText({ includeContactInfo: false, - isChildUser, + isChildUserType, resourceType: 'Account', })} > diff --git a/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/UpdateContactInformationForm/UpdateContactInformationForm.tsx b/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/UpdateContactInformationForm/UpdateContactInformationForm.tsx index 50660a66c97..392879c4750 100644 --- a/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/UpdateContactInformationForm/UpdateContactInformationForm.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/UpdateContactInformationForm/UpdateContactInformationForm.tsx @@ -4,7 +4,6 @@ import { useMutateAccount, useMutateAccountAgreements, useNotificationsQuery, - useProfile, } from '@linode/queries'; import { ActionsPanel, @@ -32,6 +31,7 @@ import { TAX_ID_AGREEMENT_TEXT, TAX_ID_HELPER_TEXT, } from 'src/features/Billing/constants'; +import { useDelegationRole } from 'src/features/IAM/hooks/useDelegationRole'; import { usePermissions } from 'src/features/IAM/hooks/usePermissions'; import { getErrorMap } from 'src/utilities/errorUtils'; @@ -53,19 +53,17 @@ const UpdateContactInformationForm = ({ focusEmail, onClose }: Props) => { const { mutateAsync: updateAccountAgreements } = useMutateAccountAgreements(); const { classes } = useStyles(); const emailRef = React.useRef(undefined); - const { data: profile } = useProfile(); + const { isChildUserType, isParentUserType } = useDelegationRole(); const [billingAgreementChecked, setBillingAgreementChecked] = React.useState(false); const { isTaxIdEnabled } = useIsTaxIdEnabled(); - const isChildUser = profile?.user_type === 'child'; - const isParentUser = profile?.user_type === 'parent'; const { data: permissions } = usePermissions('account', [ 'acknowledge_account_agreement', 'update_account', ]); - const isAccountReadOnly = !permissions.update_account || isChildUser; + const isAccountReadOnly = !permissions.update_account || isChildUserType; const isAcknowledgeAgreementDisabled = - !permissions.acknowledge_account_agreement || isChildUser; + !permissions.acknowledge_account_agreement || isChildUserType; const formik = useFormik({ enableReinitialize: true, @@ -86,7 +84,7 @@ const UpdateContactInformationForm = ({ focusEmail, onClose }: Props) => { async onSubmit(values) { const clonedValues = { ...values }; - if (isParentUser) { + if (isParentUserType) { // This is a disabled field that we want to omit from payload. delete clonedValues.company; } @@ -267,7 +265,7 @@ const UpdateContactInformationForm = ({ focusEmail, onClose }: Props) => { { { const { onClose, open, paymentMethods } = props; - const { data: profile } = useProfile(); + const { isChildUserType } = useDelegationRole(); const [isProcessing, setIsProcessing] = React.useState(false); const [noticeMessage, setNoticeMessage] = React.useState< PaymentMessage | undefined >(undefined); - const isChildUser = profile?.user_type === 'child'; const { data: permissions } = usePermissions('account', [ 'create_payment_method', ]); - const isReadOnly = !permissions?.create_payment_method || isChildUser; + const isReadOnly = !permissions?.create_payment_method || isChildUserType; React.useEffect(() => { if (open) { @@ -102,7 +101,7 @@ export const AddPaymentMethodDrawer = (props: Props) => { { const queryClient = useQueryClient(); const addPaymentMethodRouteMatch = search.action === 'add-payment-method'; - const isChildUser = profile?.user_type === 'child'; + const isChildUserType = profile?.user_type === 'child'; const { data: permissions } = usePermissions('account', [ 'create_payment_method', ]); - const isReadOnly = !permissions?.create_payment_method || isChildUser; + const isReadOnly = !permissions?.create_payment_method || isChildUserType; const doDelete = () => { setDeleteLoading(true); @@ -133,7 +133,7 @@ const PaymentInformation = (props: Props) => { } tooltipText={getRestrictedResourceText({ includeContactInfo: false, - isChildUser, + isChildUserType, resourceType: 'Account', })} > @@ -144,7 +144,7 @@ const PaymentInformation = (props: Props) => { {!isAkamaiCustomer ? ( { - window.location = realLocation; +const queryMocks = vi.hoisted(() => ({ + useLocation: vi.fn(), +})); + +vi.mock('@tanstack/react-router', async () => { + const actual = await vi.importActual('@tanstack/react-router'); + return { + ...actual, + useLocation: queryMocks.useLocation, + }; }); afterEach(() => { - window.location = realLocation; + (window as Partial).location = realLocation; }); describe('CancelLanding', () => { it('does not render the body when there is no survey_link in the state', () => { + queryMocks.useLocation.mockReturnValue({ + state: {}, + }); const { queryByTestId } = renderWithTheme(, { initialEntries: ['/cancel'], initialRoute: '/cancel', @@ -25,8 +36,11 @@ describe('CancelLanding', () => { }); it('renders the body when there is a survey_link in the state', () => { + queryMocks.useLocation.mockReturnValue({ + state: { surveyLink: 'https://linode.com' }, + }); const { queryByTestId } = renderWithTheme(, { - initialEntries: ['/cancel?survey_link=https://linode.com'], + initialEntries: ['/cancel'], initialRoute: '/cancel', }); expect(queryByTestId('body')).toBeInTheDocument(); @@ -38,11 +52,17 @@ describe('CancelLanding', () => { const mockAssign = vi.fn(); delete (window as Partial).location; - window.location = { ...realLocation, assign: mockAssign }; + (window as Partial).location = { + ...realLocation, + assign: mockAssign, + }; const surveyLink = 'https://linode.com'; + queryMocks.useLocation.mockReturnValue({ + state: { surveyLink }, + }); const { getByTestId } = renderWithTheme(, { - initialEntries: ['/cancel?survey_link=' + encodeURIComponent(surveyLink)], + initialEntries: ['/cancel'], initialRoute: '/cancel', }); const button = getByTestId('survey-button'); diff --git a/packages/manager/src/features/CancelLanding/CancelLanding.tsx b/packages/manager/src/features/CancelLanding/CancelLanding.tsx index 4d31cd45108..561a6bc4e9f 100644 --- a/packages/manager/src/features/CancelLanding/CancelLanding.tsx +++ b/packages/manager/src/features/CancelLanding/CancelLanding.tsx @@ -1,6 +1,6 @@ import { Button, H1Header, Typography } from '@linode/ui'; import { useTheme } from '@mui/material/styles'; -import { redirect, useSearch } from '@tanstack/react-router'; +import { redirect, useLocation } from '@tanstack/react-router'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; @@ -37,10 +37,11 @@ const useStyles = makeStyles()((theme: Theme) => ({ export const CancelLanding = React.memo(() => { const { classes } = useStyles(); - const search = useSearch({ from: '/cancel' }); + const location = useLocation(); + const locationState = location.state; const theme = useTheme(); - const surveyLink = search.survey_link; + const surveyLink = locationState.surveyLink; if (!surveyLink) { throw redirect({ to: '/' }); diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertRegions/AlertRegions.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertRegions/AlertRegions.tsx index 266cf98f8c9..1c4cb89caf1 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertRegions/AlertRegions.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertRegions/AlertRegions.tsx @@ -43,6 +43,10 @@ interface AlertRegionsProps { * The service type for which the regions are being selected. */ serviceType: CloudPulseServiceType | null; + /** + * Callback to set error flag on API failure + */ + setError?: (hasError: boolean) => void; /** * The selected regions. */ @@ -57,12 +61,17 @@ export const AlertRegions = React.memo((props: AlertRegionsProps) => { errorText, mode, scrollElement, + setError, } = props; const [searchText, setSearchText] = React.useState(''); - const { data: regions, isLoading: isRegionsLoading } = useRegionsQuery(); + const { + data: regions, + isLoading: isRegionsLoading, + isError: isRegionsError, + } = useRegionsQuery(); const [selectedRegions, setSelectedRegions] = React.useState(value); const [showSelected, setShowSelected] = React.useState(false); - const { data: resources, isLoading: isResourcesLoading } = useResourcesQuery( + const { data: resources, isLoading: isResourcesLoading, isError } = useResourcesQuery( Boolean(serviceType && regions?.length), serviceType === null ? undefined : serviceType, {}, @@ -71,6 +80,13 @@ export const AlertRegions = React.memo((props: AlertRegionsProps) => { getFilterFn(serviceType) ); + React.useEffect(() => { + const hasError = isError || isRegionsError; + if (setError) { + setError(hasError); + } + }, [setError, isError, isRegionsError]); + const titleRef = React.useRef(null); // Reference to the component title, used for scrolling to the title when the table's page size or page number changes. const handleSelectionChange = React.useCallback( diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertListing.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertListing.tsx index ab02de71775..b4694d8829b 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertListing.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertListing.tsx @@ -261,7 +261,12 @@ export const AlertListing = () => { ? StyledListItem : 'li'; return ( - + {option.label}{' '} {aclpServices?.[option.value]?.alerts?.beta && } diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertTableRow.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertTableRow.test.tsx index fdc608e3f35..e99a0188e61 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertTableRow.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertTableRow.test.tsx @@ -148,7 +148,7 @@ describe('Alert Row', () => { }); it("should disable 'Disable' action item in menu if alert has no enabled/disabled status", async () => { - const alert = alertFactory.build({ status: 'in progress', type: 'user' }); + const alert = alertFactory.build({ status: 'failed', type: 'user' }); const { getByLabelText, getByText } = renderWithTheme( { ); const ActionMenu = getByLabelText(`Action menu for Alert ${alert.label}`); await userEvent.click(ActionMenu); - expect(getByText('In Progress')).toBeInTheDocument(); + expect(getByText('Failed')).toBeInTheDocument(); expect(getByText('Disable').closest('li')).toHaveAttribute( 'aria-disabled', 'true' @@ -171,7 +171,7 @@ describe('Alert Row', () => { }); it("should disable 'Edit' action item in menu if alert has no enabled/disabled status", async () => { - const alert = alertFactory.build({ status: 'in progress', type: 'user' }); + const alert = alertFactory.build({ status: 'disabling', type: 'user' }); const { getByLabelText, getByText } = renderWithTheme( { { flags: { aclpAlerting: { - editDisabledStatuses: ['failed', 'in progress'], + editDisabledStatuses: ['failed', 'disabling'], accountAlertLimit: 10, accountMetricLimit: 100, beta: true, @@ -200,7 +200,7 @@ describe('Alert Row', () => { ); const ActionMenu = getByLabelText(`Action menu for Alert ${alert.label}`); await userEvent.click(ActionMenu); - expect(getByText('In Progress')).toBeInTheDocument(); + expect(getByText('Disabling')).toBeInTheDocument(); expect(getByText('Edit').closest('li')).toHaveAttribute( 'aria-disabled', 'true' diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/constants.ts b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/constants.ts index e6ee4f53937..c12474af104 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/constants.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/constants.ts @@ -34,7 +34,6 @@ export const statusToActionMap: Record = disabled: 'Enable', enabled: 'Disable', failed: 'Disable', - 'in progress': 'Disable', provisioning: 'Disable', disabling: 'Enable', enabling: 'Disable', diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.test.tsx index 1d16187a1ba..9e55fef14b8 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.test.tsx @@ -79,16 +79,16 @@ describe('AlertResources component tests', () => { }); it('should render circle progress if api calls are in fetching state', () => { queryMocks.useResourcesQuery.mockReturnValue({ - data: linodes, + data: undefined, isError: false, isLoading: true, }); - const { getByTestId, queryByText } = renderWithTheme( + const { getByTestId, getByText } = renderWithTheme( ); expect(getByTestId('circle-progress')).toBeInTheDocument(); - expect(queryByText(searchPlaceholder)).not.toBeInTheDocument(); - expect(queryByText(regionPlaceholder)).not.toBeInTheDocument(); + expect(getByText(searchPlaceholder)).not.toBeVisible(); + expect(getByText(regionPlaceholder)).not.toBeVisible(); }); it('should render error state if api call fails', () => { diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.tsx index c67616e6a13..6207bf3eb6d 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.tsx @@ -104,6 +104,10 @@ export interface AlertResourcesProp { * The service type associated with the alerts like DBaaS, Linode etc., */ serviceType?: CloudPulseServiceType; + /** + * Callback to set the error on API Failure + */ + setError?: (hasError: boolean) => void; } export const AlertResources = React.memo((props: AlertResourcesProp) => { @@ -120,6 +124,7 @@ export const AlertResources = React.memo((props: AlertResourcesProp) => { maxSelectionCount, scrollElement, serviceType, + setError, } = props; const [searchText, setSearchText] = React.useState(); const [filteredRegions, setFilteredRegions] = React.useState(); @@ -174,8 +179,8 @@ export const AlertResources = React.memo((props: AlertResourcesProp) => { const filteredTypes = alertClass === 'shared' ? Object.keys(databaseTypeClassMap).filter( - (type) => type !== 'dedicated' - ) + (type) => type !== 'dedicated' + ) : [alertClass]; // Apply type filter only for DBaaS user alerts with a valid alertClass based on above filtered types @@ -209,6 +214,13 @@ export const AlertResources = React.memo((props: AlertResourcesProp) => { filterFn ); + React.useEffect(() => { + const hasError = isResourcesError || isRegionsError; + if (setError) { + setError(hasError); + } + }, [setError, isResourcesError, isRegionsError]); + const regionFilteredResources = React.useMemo(() => { if ( (serviceType === 'objectstorage' || serviceType === 'blockstorage') && @@ -354,10 +366,6 @@ export const AlertResources = React.memo((props: AlertResourcesProp) => { !isDataLoadingError && !isSelectionsNeeded && alertResourceIds.length === 0; const showEditInformation = isSelectionsNeeded && alertType === 'system'; - if (isResourcesLoading || isRegionsLoading) { - return ; - } - if (isNoResources) { return ( @@ -385,7 +393,6 @@ export const AlertResources = React.memo((props: AlertResourcesProp) => { serviceToFiltersMap[serviceType ?? ''] ?? serviceToFiltersMap['']; const noticeStyles: React.CSSProperties = { alignItems: 'center', - backgroundColor: theme.tokens.alias.Background.Normal, borderRadius: 1, display: 'flex', flexWrap: 'nowrap', @@ -396,22 +403,33 @@ export const AlertResources = React.memo((props: AlertResourcesProp) => { maxSelectionCount && selectedResources ? Math.max(0, maxSelectionCount - selectedResources.length) : undefined; + + const isLoading = isRegionsLoading || isResourcesLoading; return ( + {isLoading && } {!hideLabel && ( - + {alertLabel || 'Entities'} {/* It can be either the passed alert label or just Resources */} )} {showEditInformation && ( - + You can enable or disable this system alert for each entities you have access to. Select the entities listed below you want to enable the alert for. )} - + { new Set( regionFilteredResources ? regionFilteredResources.flatMap( - ({ tags }) => tags ?? [] - ) + ({ tags }) => tags ?? [] + ) : [] ) ), diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/DisplayAlertResources.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/DisplayAlertResources.tsx index 3bffebb91c4..53ea123f5e0 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/DisplayAlertResources.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/DisplayAlertResources.tsx @@ -226,7 +226,7 @@ export const DisplayAlertResources = React.memo( }} title={ maxSelectionCount !== undefined && - isRootCheckBoxDisabled ? ( + isRootCheckBoxDisabled ? ( @@ -301,7 +301,7 @@ export const DisplayAlertResources = React.memo( }} title={ isItemCheckboxDisabled && - maxSelectionCount !== undefined ? ( + maxSelectionCount !== undefined ? ( @@ -339,7 +339,7 @@ export const DisplayAlertResources = React.memo( message="Table data is unavailable. Please try again later." /> )} - {paginatedData.length === 0 && ( + {!isDataLoadingError && paginatedData.length === 0 && ( { const { dataFieldDisabled, dimensionOptions, name, onFilterDelete } = props; - const { control, resetField } = useFormContext(); + const { control, resetField, setValue } = + useFormContext(); const dataFieldOptions = dimensionOptions.map((dimension) => ({ @@ -90,10 +91,17 @@ export const DimensionFilterField = (props: DimensionFilterFieldProps) => { const selectedDimension = dimensionOptions && dimensionFieldWatcher ? (dimensionOptions.find( - (dim) => dim.dimension_label === dimensionFieldWatcher - ) ?? null) + (dim) => dim.dimension_label === dimensionFieldWatcher + ) ?? null) : null; + const handleError = React.useCallback( + (hasError: boolean) => { + setValue('hasAPIError', hasError); + }, + [setValue] + ); + return ( { entities={entities} entityType={entityType ?? undefined} errorText={fieldState.error?.message} + handleError={handleError} name={name} onBlur={field.onBlur} onChange={field.onChange} diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/BlockStorageDimensionFilterAutocomplete.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/BlockStorageDimensionFilterAutocomplete.test.tsx index 4cc6895b63f..db7d23e291d 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/BlockStorageDimensionFilterAutocomplete.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/BlockStorageDimensionFilterAutocomplete.test.tsx @@ -41,6 +41,7 @@ describe('', () => { serviceType: 'blockstorage', type: 'alerts', values: [], + handleError: vi.fn(), }; beforeEach(() => { diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/BlockStorageDimensionFilterAutocomplete.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/BlockStorageDimensionFilterAutocomplete.tsx index 0d48cae694d..3105851d02b 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/BlockStorageDimensionFilterAutocomplete.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/BlockStorageDimensionFilterAutocomplete.tsx @@ -4,7 +4,12 @@ import React from 'react'; import { useBlockStorageFetchOptions } from './useBlockStorageFetchOptions'; import { useCleanupStaleValues } from './useCleanupStaleValues'; -import { handleValueChange, resolveSelectedValues } from './utils'; +import { + handleValueChange, + isMaxSelectionsReached, + isOptionDisabled, + resolveSelectedValues, +} from './utils'; import type { DimensionFilterAutocompleteProps } from './constants'; @@ -26,9 +31,11 @@ export const BlockStorageDimensionFilterAutocomplete = ( selectedRegions, serviceType, type, + handleError, + maxSelections, } = props; - const { data: regions } = useRegionsQuery(); + const { data: regions, isError: isRegionsError } = useRegionsQuery(); const { values, isLoading, isError } = useBlockStorageFetchOptions({ entities, dimensionLabel, @@ -39,22 +46,56 @@ export const BlockStorageDimensionFilterAutocomplete = ( serviceType, }); + React.useEffect(() => { + const hasError = isError || isRegionsError; + if (handleError) { + handleError(hasError); + } + }, [isError, isRegionsError, handleError]); + useCleanupStaleValues({ options: values, fieldValue, multiple, onChange: fieldOnChange, isLoading, + isError, }); + const maxReached = React.useMemo(() => { + return isMaxSelectionsReached( + multiple ?? false, + fieldValue ?? '', + maxSelections + ); + }, [fieldValue, maxSelections, multiple]); + + const showHelperText = !errorText && maxSelections !== undefined && multiple; + const disableSelectAll = + maxSelections !== undefined && multiple + ? values.length > maxSelections + : false; + return ( { + return isOptionDisabled({ + maxReached, + value: fieldValue ?? undefined, + multiple: multiple ?? false, + option, + }); + }} + helperText={ + showHelperText ? `Select up to ${maxSelections} values` : undefined + } isOptionEqualToValue={(option, value) => value.value === option.value} label="Value" limitTags={1} diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/DimensionFilterAutocomplete.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/DimensionFilterAutocomplete.test.tsx index 4ebaa011935..f01e39400c2 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/DimensionFilterAutocomplete.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/DimensionFilterAutocomplete.test.tsx @@ -31,6 +31,7 @@ describe('', () => { selectedRegions: [], serviceType: 'nodebalancer', values: mockOptions.map((o) => o.value), + handleError: vi.fn(), type: 'alerts', }; diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/DimensionFilterAutocomplete.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/DimensionFilterAutocomplete.tsx index 2d226865500..9e3efe5b55a 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/DimensionFilterAutocomplete.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/DimensionFilterAutocomplete.tsx @@ -4,6 +4,8 @@ import React, { useMemo } from 'react'; import { getStaticOptions, handleValueChange, + isMaxSelectionsReached, + isOptionDisabled, resolveSelectedValues, } from './utils'; @@ -28,18 +30,44 @@ export const DimensionFilterAutocomplete = ( serviceType, dimensionLabel, values, + maxSelections, } = props; const options = useMemo( () => getStaticOptions(serviceType, dimensionLabel ?? '', values ?? []), [dimensionLabel, serviceType, values] ); + const maxReached = React.useMemo(() => { + return isMaxSelectionsReached( + multiple ?? false, + fieldValue ?? '', + maxSelections + ); + }, [fieldValue, maxSelections, multiple]); + + const showHelperText = !errorText && maxSelections !== undefined && multiple; + const disableSelectAll = + maxSelections !== undefined && multiple + ? options.length > maxSelections + : false; return ( { + return isOptionDisabled({ + maxReached, + value: fieldValue ?? undefined, + multiple: multiple ?? false, + option, + }); + }} + helperText={ + showHelperText ? `Select up to ${maxSelections} values` : undefined + } isOptionEqualToValue={(option, value) => value.value === option.value} label="Value" limitTags={1} 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 0d178ecd584..2588f4dfe9a 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 @@ -39,6 +39,7 @@ describe('', () => { serviceType: 'firewall', type: 'alerts', entityType: 'linode', + handleError: vi.fn(), }; beforeEach(() => { 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 6d5d2715c0f..de283ba4e14 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 @@ -4,7 +4,12 @@ import React from 'react'; import { useCleanupStaleValues } from './useCleanupStaleValues'; import { useFirewallFetchOptions } from './useFirewallFetchOptions'; -import { handleValueChange, resolveSelectedValues } from './utils'; +import { + handleValueChange, + isMaxSelectionsReached, + isOptionDisabled, + resolveSelectedValues, +} from './utils'; import type { DimensionFilterAutocompleteProps } from './constants'; @@ -31,9 +36,11 @@ export const FirewallDimensionFilterAutocomplete = ( serviceType, type, selectedRegions, + handleError, + maxSelections, } = props; - const { data: regions } = useRegionsQuery(); + const { data: regions, isError: isRegionsError } = useRegionsQuery(); const { values, isLoading, isError } = useFirewallFetchOptions({ associatedEntityType: entityType, @@ -53,16 +60,49 @@ export const FirewallDimensionFilterAutocomplete = ( multiple, onChange: fieldOnChange, isLoading, + isError, }); + React.useEffect(() => { + const hasError = isError || isRegionsError; + if (handleError) { + handleError(hasError); + } + }, [isError, isRegionsError, handleError]); + const maxReached = React.useMemo(() => { + return isMaxSelectionsReached( + multiple ?? false, + fieldValue ?? '', + maxSelections + ); + }, [fieldValue, maxSelections, multiple]); + + const showHelperText = !errorText && maxSelections !== undefined && multiple; + const disableSelectAll = + maxSelections !== undefined && multiple + ? values.length > maxSelections + : false; + return ( { + return isOptionDisabled({ + maxReached, + value: fieldValue ?? undefined, + multiple: multiple ?? false, + option, + }); + }} + helperText={ + showHelperText ? `Select up to ${maxSelections} values` : undefined + } isOptionEqualToValue={(option, value) => value.value === option.value} label="Value" limitTags={1} diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/ObjectStorageDimensionFilterAutocomplete.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/ObjectStorageDimensionFilterAutocomplete.test.tsx index 8cb609102b7..3fbe05accf8 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/ObjectStorageDimensionFilterAutocomplete.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/ObjectStorageDimensionFilterAutocomplete.test.tsx @@ -40,6 +40,7 @@ describe('', () => { selectedRegions: ['us-east'], serviceType: 'objectstorage', type: 'alerts', + handleError: vi.fn(), }; beforeEach(() => { 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 106a62104ff..0f2d59a480d 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 @@ -4,7 +4,12 @@ import React from 'react'; import { useCleanupStaleValues } from './useCleanupStaleValues'; import { useObjectStorageFetchOptions } from './useObjectStorageFetchOptions'; -import { handleValueChange, resolveSelectedValues } from './utils'; +import { + handleValueChange, + isMaxSelectionsReached, + isOptionDisabled, + resolveSelectedValues, +} from './utils'; import type { DimensionFilterAutocompleteProps } from './constants'; @@ -29,9 +34,11 @@ export const ObjectStorageDimensionFilterAutocomplete = ( selectedRegions, serviceType, type, + handleError, + maxSelections, } = props; - const { data: regions } = useRegionsQuery(); + const { data: regions, isError: isRegionsError } = useRegionsQuery(); const { values, isLoading, isError } = useObjectStorageFetchOptions({ entities, dimensionLabel, @@ -48,17 +55,50 @@ export const ObjectStorageDimensionFilterAutocomplete = ( multiple, onChange: fieldOnChange, isLoading, + isError, }); + React.useEffect(() => { + const hasError = isError || isRegionsError; + if (handleError) { + handleError(hasError); + } + }, [isError, isRegionsError, handleError]); + const maxReached = React.useMemo(() => { + return isMaxSelectionsReached( + multiple ?? false, + fieldValue ?? '', + maxSelections + ); + }, [fieldValue, maxSelections, multiple]); + + const showHelperText = !errorText && maxSelections !== undefined && multiple; + const disableSelectAll = + maxSelections !== undefined && multiple + ? values.length > maxSelections + : false; + return ( { + return isOptionDisabled({ + maxReached, + value: fieldValue ?? undefined, + multiple: multiple ?? false, + option, + }); + }} + helperText={ + showHelperText ? `Select up to ${maxSelections} values` : undefined + } isOptionEqualToValue={(option, value) => value.value === option.value} label="Value" limitTags={1} diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/ValueFieldRenderer.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/ValueFieldRenderer.test.tsx index d632857c973..3f7b93e83f2 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/ValueFieldRenderer.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/ValueFieldRenderer.test.tsx @@ -71,6 +71,7 @@ describe('', () => { operator: EQ, value: null, values: null, + handleError: vi.fn(), }; it('renders a TextField if config type is textfield', () => { diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/ValueFieldRenderer.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/ValueFieldRenderer.tsx index bb85115f0fd..4ec61677a16 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/ValueFieldRenderer.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/ValueFieldRenderer.tsx @@ -1,6 +1,8 @@ import { TextField } from '@linode/ui'; import React from 'react'; +import { useFlags } from 'src/hooks/useFlags'; + import { BlockStorageDimensionFilterAutocomplete } from './BlockStorageDimensionFilterAutocomplete'; import { MULTISELECT_PLACEHOLDER_TEXT, @@ -44,7 +46,10 @@ interface ValueFieldRendererProps { * Error message to be displayed under the input field, if any. */ errorText: string | undefined; - + /** + * Callback triggered when a dependent API has an error. + */ + handleError?: (hasError: boolean) => void; /** * The name of the field set in the form. */ @@ -97,17 +102,24 @@ export const ValueFieldRenderer = (props: ValueFieldRendererProps) => { entities, entityType, errorText, + handleError, name, onBlur, onChange, operator, scope, - selectedRegions, - serviceType, - type = 'alerts', value, values, + type = 'alerts', + selectedRegions, + serviceType, } = props; + + const flags = useFlags(); + + const maxDimensionFiltersValues = + flags.aclpAlerting?.maxDimensionFiltersValues ?? undefined; + // Use operator group for config lookup const operatorGroup = getOperatorGroup(operator); let dimensionConfig: Record; @@ -157,6 +169,7 @@ export const ValueFieldRenderer = (props: ValueFieldRendererProps) => { dimensionLabel, disabled, errorText, + handleError, fieldOnBlur: onBlur, fieldOnChange: onChange, fieldValue: value, @@ -165,6 +178,7 @@ export const ValueFieldRenderer = (props: ValueFieldRendererProps) => { placeholderText: config.placeholder ?? autocompletePlaceholder, serviceType: serviceType ?? null, type, + maxSelections: maxDimensionFiltersValues, }; // Determine custom fetch behaviour if there are same dimension_labels across service types diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/ValueSchemas.ts b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/ValueSchemas.ts index 9f182a14dda..89e705be80b 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/ValueSchemas.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/ValueSchemas.ts @@ -272,5 +272,35 @@ export const getDimensionFilterValueSchema = ({ if (['endswith', 'startswith'].includes(operator)) { return string().max(100, LENGTH_ERROR_MESSAGE).concat(baseValueSchema); } + + if (operator === 'in') { + // here it is always autocomplete with comma separated values + return string() + .test( + 'max-comma-values', + 'More than max values selected', + function (value) { + if (!value) return true; + + const { maxDimensionFilterValues } = this.options.context ?? {}; + + if (!maxDimensionFilterValues) return true; // if not passed, skip the check + + const count = value + .split(',') + .map((s) => s.trim()) + .filter(Boolean).length; + + return ( + count <= maxDimensionFilterValues || + this.createError({ + message: `Select up to ${maxDimensionFilterValues} values`, + }) + ); + } + ) + .concat(baseValueSchema); + } + return baseValueSchema; }; diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/constants.ts b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/constants.ts index ff218f9c9a1..e44366b03f2 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/constants.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/constants.ts @@ -10,9 +10,13 @@ import { CONFIGS_HELPER_TEXT, CONFIGS_ID_PLACEHOLDER_TEXT, INTERFACE_ID_HELPER_TEXT, + NODE_ID_HELPER_TEXT, + NODE_ID_PLACEHOLDER_TEXT, PORT_HELPER_TEXT, PORT_PLACEHOLDER_TEXT, PORTS_PLACEHOLDER_TEXT, + VIP_HELPER_TEXT, + VIP_PLACEHOLDER_TEXT, } from '../../../constants'; import type { Item } from '../../../constants'; @@ -157,6 +161,52 @@ export const valueFieldConfig: ValueFieldConfigMap = { inputType: 'number', }, }, + ip: { + eq_neq: { + type: 'textfield', + inputType: 'text', + placeholder: VIP_PLACEHOLDER_TEXT, + }, + startswith_endswith: { + type: 'textfield', + inputType: 'text', + placeholder: VIP_PLACEHOLDER_TEXT, + }, + in: { + type: 'textfield', + inputType: 'text', + placeholder: VIP_PLACEHOLDER_TEXT, + helperText: VIP_HELPER_TEXT, + }, + '*': { + type: 'textfield', + inputType: 'text', + placeholder: VIP_PLACEHOLDER_TEXT, + }, + }, + node_id: { + eq_neq: { + type: 'textfield', + inputType: 'text', + placeholder: NODE_ID_PLACEHOLDER_TEXT, + }, + startswith_endswith: { + type: 'textfield', + inputType: 'text', + placeholder: NODE_ID_PLACEHOLDER_TEXT, + }, + in: { + type: 'textfield', + inputType: 'text', + placeholder: NODE_ID_PLACEHOLDER_TEXT, + helperText: NODE_ID_HELPER_TEXT, + }, + '*': { + type: 'textfield', + inputType: 'text', + placeholder: NODE_ID_PLACEHOLDER_TEXT, + }, + }, linode_id: { eq_neq: { type: 'autocomplete', @@ -428,6 +478,14 @@ export interface DimensionFilterAutocompleteProps { * Current raw string value (or null) from the form state. */ fieldValue: null | string; + /** + * Callback triggered when a dependent API has an error. + */ + handleError?: (hasError: boolean) => void; + /** + * The maximum number of selections allowed (for multi-select). + */ + maxSelections?: number; /** * To control single-select/multi-select in the Autocomplete. */ 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 index ff10bb4c066..15d7d664a37 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/useCleanupStaleValues.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/useCleanupStaleValues.ts @@ -11,15 +11,17 @@ export const useCleanupStaleValues = ({ multiple, onChange, isLoading, + isError, }: { fieldValue: null | string | string[]; + isError?: boolean; isLoading?: boolean; multiple?: boolean; onChange: (value: null | string | string[]) => void; options: Item[]; }) => { useEffect(() => { - if (isLoading) { + if (isLoading || isError) { return; } @@ -50,5 +52,5 @@ export const useCleanupStaleValues = ({ } } } - }, [options, fieldValue, multiple, onChange, isLoading]); + }, [options, fieldValue, multiple, onChange, isLoading, isError]); }; diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/utils.test.ts b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/utils.test.ts index 7162f14f7b1..82f5dc023ef 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/utils.test.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/utils.test.ts @@ -8,6 +8,8 @@ import { getOperatorGroup, getStaticOptions, handleValueChange, + isMaxSelectionsReached, + isOptionDisabled, resolveSelectedValues, scopeBasedFilteredResources, transformDimensionValue, @@ -325,3 +327,102 @@ describe('Utils', () => { }); }); }); +describe('isMaxSelectionsReached', () => { + it('returns false when multiple is false', () => { + expect(isMaxSelectionsReached(false, 'a,b,c', 2)).toBe(false); + }); + + it('returns false when value is empty string', () => { + expect(isMaxSelectionsReached(true, '', 2)).toBe(false); + }); + + it('returns false when maxSelections is undefined', () => { + expect(isMaxSelectionsReached(true, 'a,b,c', undefined)).toBe(false); + }); + + it('returns false when selections are less than maxSelections', () => { + expect(isMaxSelectionsReached(true, 'a,b', 3)).toBe(false); + }); + + it('returns true when selections equal maxSelections', () => { + expect(isMaxSelectionsReached(true, 'a,b,c', 3)).toBe(true); + }); + + it('returns true when selections exceed maxSelections', () => { + expect(isMaxSelectionsReached(true, 'a,b,c,d', 3)).toBe(true); + }); + + it('counts single value correctly', () => { + expect(isMaxSelectionsReached(true, 'a', 1)).toBe(true); + }); +}); + +describe('isOptionDisabled', () => { + const optionA = { label: 'A', value: 'a' }; + const optionB = { label: 'B', value: 'b' }; + + it('returns false when maxReached is false', () => { + expect( + isOptionDisabled({ + maxReached: false, + multiple: true, + value: 'a,b', + option: optionA, + }) + ).toBe(false); + }); + + it('returns false when multiple is false even if maxReached is true', () => { + expect( + isOptionDisabled({ + maxReached: true, + multiple: false, + value: 'a,b', + option: optionA, + }) + ).toBe(false); + }); + + it('disables option when maxReached is true and option is NOT selected', () => { + expect( + isOptionDisabled({ + maxReached: true, + multiple: true, + value: 'a', + option: optionB, // already selected? NO + }) + ).toBe(true); + }); + + it('does NOT disable option when maxReached is true and option IS selected', () => { + expect( + isOptionDisabled({ + maxReached: true, + multiple: true, + value: 'a,b', + option: optionA, // already selected + }) + ).toBe(false); + }); + + it('handles undefined value safely', () => { + expect( + isOptionDisabled({ + maxReached: true, + multiple: true, + option: optionA, + }) + ).toBe(true); + }); + + it('handles empty value string', () => { + expect( + isOptionDisabled({ + maxReached: true, + multiple: true, + value: '', + option: optionA, + }) + ).toBe(true); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/utils.ts b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/utils.ts index 0fe8f769e91..0ffed4dde06 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/utils.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/utils.ts @@ -16,6 +16,25 @@ import type { import type { CloudPulseResources } from 'src/features/CloudPulse/shared/CloudPulseResourcesSelect'; import type { FirewallEntity } from 'src/features/CloudPulse/shared/types'; +interface MaxSelectionControlProps { + /** + * Indicates if the maximum selections have been reached + */ + maxReached: boolean; + /** + * Indicates whether multiple select is enabled + */ + multiple: boolean; + /** + * The option item to check if it should be disabled + */ + option: Item; + /** + * The current value of the field as a comma-separated string + */ + value?: string; +} + /** * Transform a dimension value using the appropriate transform function * @param serviceType - The cloud pulse service type @@ -282,3 +301,54 @@ export const getBlockStorageLinodes = ( value: String(linode.id), })); }; + +/** + * @param multiple - Indicates whether multiple select is enabled + * @param value - The value of the field as a comma-separated string + * @param maxSelections - The maximum number of selections allowed + * @returns - Boolean indicating if the maximum selections have been reached + */ +export const isMaxSelectionsReached = ( + multiple: boolean, + value: string, + maxSelections?: number +): boolean => { + if (!multiple || value === '' || maxSelections === undefined) { + return false; + } + + const values = value?.split(',') || []; + + return values.length >= maxSelections; +}; + +/** + * @param maxReached - The boolean indicating if max selections have been reached + * @param value - The current value of the field as a comma-separated string + * @param multiple - Indicates whether multiple select is enabled + * @param option - The option item to check if it should be disabled + * @returns - Boolean indicating if the option should be disabled + */ +export const isOptionDisabled = ({ + maxReached, + value, + multiple, + option, +}: MaxSelectionControlProps): boolean => { + if ( + !maxReached || + option.label.trim() === 'Select All' || + option.label.trim() === 'Deselect All' + ) { + return false; + } + + const values = value?.split(',') || []; + + // Allow already selected options (so user can unselect) + if (multiple) { + return !values.some((selected) => selected === option.value); + } + + return false; +}; diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/MetricCriteria.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/MetricCriteria.tsx index 0f3845c0f44..fa2c180ab7a 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/MetricCriteria.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/MetricCriteria.tsx @@ -48,7 +48,11 @@ export const MetricCriteriaField = (props: MetricCriteriaProps) => { { is_alertable: true } ); - const { control } = useFormContext(); + const { control, setValue } = useFormContext(); + + React.useEffect(() => { + setValue('hasAPIError', isMetricDefinitionError); + }, [isMetricDefinitionError, setValue]); const metricCriteriaWatcher = useWatch({ control, name }); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/AlertEntityScopeSelect.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/AlertEntityScopeSelect.tsx index 086b1811d62..c4cd67d3b85 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/AlertEntityScopeSelect.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/AlertEntityScopeSelect.tsx @@ -111,6 +111,7 @@ export const AlertEntityScopeSelect = (props: AlertEntityScopeSelectProps) => { diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/AddChannelListing.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/AddChannelListing.tsx index 49ca526bb03..fa6e3c60ff6 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/AddChannelListing.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/AddChannelListing.tsx @@ -58,6 +58,10 @@ export const AddChannelListing = (props: AddChannelListingProps) => { isLoading: notificationChannelsLoading, } = useAllAlertNotificationChannelsQuery(); + React.useEffect(() => { + setValue('hasAPIError', notificationChannelsError); + }, [setValue, notificationChannelsError]); + const notifications = React.useMemo(() => { if (!notificationData) return []; diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Regions/CloudPulseModifyAlertRegions.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Regions/CloudPulseModifyAlertRegions.tsx index 9a4fcefaa67..23e72e17c2b 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Regions/CloudPulseModifyAlertRegions.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Regions/CloudPulseModifyAlertRegions.tsx @@ -29,6 +29,14 @@ export const CloudPulseModifyAlertRegions = React.memo( }); }; const titleRef = React.useRef(null); + + const setError = React.useCallback( + (isError: boolean) => { + setValue('hasAPIError', isError); + }, + [setValue] + ); + return (
diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Resources/CloudPulseModifyAlertResources.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Resources/CloudPulseModifyAlertResources.tsx index 9abedb51d48..f46a01f0155 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Resources/CloudPulseModifyAlertResources.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Resources/CloudPulseModifyAlertResources.tsx @@ -43,6 +43,13 @@ export const CloudPulseModifyAlertResources = React.memo( }); }; + const setError = React.useCallback( + (hasError: boolean) => { + setValue('hasAPIError', hasError); + }, + [setValue] + ); + const titleRef = React.useRef(null); return ( @@ -71,6 +78,7 @@ export const CloudPulseModifyAlertResources = React.memo( maxSelectionCount={maxSelectionCount} scrollElement={titleRef.current} serviceType={serviceTypeWatcher || undefined} + setError={setError} /> diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/schemas.ts b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/schemas.ts index 04f48098d79..bb8d5b24b92 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/schemas.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/schemas.ts @@ -72,6 +72,7 @@ export const triggerConditionSchema = triggerConditionValidation.concat( export const alertDefinitionFormSchema = createAlertDefinitionSchema.concat( object({ + hasAPIError: mixed().optional(), entity_ids: array().of(string().defined()).optional(), entity_type: mixed() .oneOf(['linode', 'nodebalancer']) diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/types.ts b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/types.ts index 2e4f1319e06..2167949ce07 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/types.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/types.ts @@ -20,6 +20,7 @@ export interface CreateAlertDefinitionForm > { entity_ids?: string[]; entity_type?: AssociatedEntityType; + hasAPIError?: boolean; regions?: string[]; rule_criteria: { rules: MetricCriteriaForm[]; diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/utilities.ts b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/utilities.ts index d5a18e9cfca..c05b8862198 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/utilities.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/utilities.ts @@ -26,6 +26,7 @@ export const filterFormValues = ( 'rule_criteria', 'trigger_conditions', 'entity_type', + 'hasAPIError', ]); const severity = formValues.severity ?? 1; const entityIds = formValues.entity_ids; @@ -60,6 +61,8 @@ export const filterEditFormValues = ( 'severity', 'rule_criteria', 'trigger_conditions', + 'entity_type', + 'hasAPIError', ]); const entityIds = formValues.entity_ids; const rules = formValues.rule_criteria.rules; diff --git a/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertDefinition.tsx b/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertDefinition.tsx index 5ad4b8be165..01e7938c2ea 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertDefinition.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertDefinition.tsx @@ -1,6 +1,6 @@ import { yupResolver } from '@hookform/resolvers/yup'; import { isEmpty } from '@linode/api-v4'; -import { ActionsPanel, Paper, TextField, Typography } from '@linode/ui'; +import { ActionsPanel, Notice, Paper, TextField, Typography } from '@linode/ui'; import { scrollErrorIntoView } from '@linode/utilities'; import { useNavigate } from '@tanstack/react-router'; import { useSnackbar } from 'notistack'; @@ -70,8 +70,8 @@ export const EditAlertDefinition = (props: EditAlertProps) => { const entityType = serviceType === 'firewall' ? alertDetails.rule_criteria.rules[0]?.label.includes( - entityLabelMap['nodebalancer'] - ) + entityLabelMap['nodebalancer'] + ) ? 'nodebalancer' : 'linode' : undefined; @@ -85,6 +85,10 @@ export const EditAlertDefinition = (props: EditAlertProps) => { entity_type: entityType, }, mode: 'onBlur', + context: { + maxDimensionFilterValues: + flags.aclpAlerting?.maxDimensionFiltersValues ?? undefined, + }, resolver: yupResolver( getSchemaWithEntityIdValidation({ aclpAlertServiceTypeConfig: flags.aclpAlertServiceTypeConfig ?? [], @@ -109,6 +113,7 @@ export const EditAlertDefinition = (props: EditAlertProps) => { error: serviceMetadataError, } = useCloudPulseServiceByServiceType(serviceType ?? '', !!serviceType); + const hasAPIError = useWatch({ control, name: 'hasAPIError' }); const onSubmit = handleSubmit(async (values) => { const editPayload: EditAlertPayloadWithService = filterEditFormValues( values, @@ -176,6 +181,13 @@ export const EditAlertDefinition = (props: EditAlertProps) => { return ( + {hasAPIError && ( + + )}
@@ -246,6 +258,7 @@ export const EditAlertDefinition = (props: EditAlertProps) => { label: 'Submit', loading: formState.isSubmitting, type: 'submit', + disabled: hasAPIError, }} secondaryButtonProps={{ label: 'Cancel', diff --git a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/CreateChannel/CreateNotificationChannel.tsx b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/CreateChannel/CreateNotificationChannel.tsx index d741b0fa820..384ea085db8 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/CreateChannel/CreateNotificationChannel.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/CreateChannel/CreateNotificationChannel.tsx @@ -32,9 +32,13 @@ const overrides: CrumbOverridesProps[] = [ ]; const initialValues: CreateNotificationChannelForm = { - type: null, - name: '', - recipients: [], + channel_type: null, + label: '', + details: { + email: { + usernames: [], + }, + }, }; export const CreateNotificationChannel = () => { @@ -58,7 +62,7 @@ export const CreateNotificationChannel = () => { setError, } = formMethods; - const channelTypeWatcher = useWatch({ control, name: 'type' }); + const channelTypeWatcher = useWatch({ control, name: 'channel_type' }); const { mutateAsync: createChannel } = useCreateNotificationChannel(); @@ -90,23 +94,30 @@ export const CreateNotificationChannel = () => { return ( - + Channel Settings { // Reset the name field when the channel type changes const handleChannelTypeChange = (value: ChannelType | null) => { field.onChange(value); - resetField('name', { defaultValue: '' }); - resetField('recipients', { defaultValue: [] }); + resetField('label', { defaultValue: '' }); + resetField('details.email.usernames', { defaultValue: [] }); }; return ( @@ -124,7 +135,7 @@ export const CreateNotificationChannel = () => { ( { {channelTypeWatcher === 'email' && ( ( diff --git a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/CreateChannel/schemas.ts b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/CreateChannel/schemas.ts index 5d89dfa557f..16c6f99f975 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/CreateChannel/schemas.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/CreateChannel/schemas.ts @@ -8,7 +8,7 @@ const specialStartRegex = /^[^a-zA-Z0-9]/; const specialEndRegex = /[^a-zA-Z0-9]$/; export const createNotificationChannelSchema = object({ - name: string() + label: string() .required(fieldErrorMessage) .matches( /^[^*#&+:<>"?@%{}\\/]+$/, @@ -25,12 +25,16 @@ export const createNotificationChannelSchema = object({ ); } ), - type: mixed() + channel_type: mixed() .required(fieldErrorMessage) .nullable() .test('nonNull', fieldErrorMessage, (value) => value !== null), - recipients: array() - .of(string().defined()) - .required(fieldErrorMessage) - .min(1, fieldErrorMessage), + details: object({ + email: object({ + usernames: array() + .of(string().defined()) + .required(fieldErrorMessage) + .min(1, fieldErrorMessage), + }), + }), }); diff --git a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/CreateChannel/types.ts b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/CreateChannel/types.ts index 680f1a919ed..8679fa43715 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/CreateChannel/types.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/CreateChannel/types.ts @@ -1,7 +1,14 @@ -import type { ChannelType } from '@linode/api-v4'; +import type { + ChannelType, + CreateNotificationChannelPayload, +} from '@linode/api-v4'; -export interface CreateNotificationChannelForm { - name: string; - recipients: string[]; - type: ChannelType | null; +export interface CreateNotificationChannelForm + extends Omit { + channel_type: ChannelType | null; + details: { + email: { + usernames: string[]; + }; + }; } diff --git a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/CreateChannel/utilities.ts b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/CreateChannel/utilities.ts index 239a148af5d..ece6ff343a1 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/CreateChannel/utilities.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/CreateChannel/utilities.ts @@ -5,12 +5,12 @@ export const filterCreateChannelFormValues = ( formValues: CreateNotificationChannelForm ): CreateNotificationChannelPayload => { return { - channel_type: formValues.type ?? 'email', + channel_type: formValues.channel_type ?? 'email', details: { email: { - usernames: formValues.recipients, + usernames: formValues.details.email.usernames, }, }, - label: formValues.name, + label: formValues.label, }; }; diff --git a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/EditChannel/EditNotificationChannel.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/EditChannel/EditNotificationChannel.test.tsx index 904a3ef709f..b6316886f21 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/EditChannel/EditNotificationChannel.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/EditChannel/EditNotificationChannel.test.tsx @@ -218,7 +218,7 @@ describe('EditNotificationChannel component', () => { it('should show field-specific error when API returns field error', async () => { queryMocks.mutateAsync.mockRejectedValue([ - { field: 'name', reason: 'Name already exists' }, + { field: 'label', reason: 'Name already exists' }, ]); const user = userEvent.setup(); diff --git a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/EditChannel/EditNotificationChannel.tsx b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/EditChannel/EditNotificationChannel.tsx index 4db10e77e84..cd8b96f64db 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/EditChannel/EditNotificationChannel.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/EditChannel/EditNotificationChannel.tsx @@ -55,12 +55,16 @@ export const EditNotificationChannel = ( const formMethods = useForm({ defaultValues: { - name: channelData.label, - type: channelData.channel_type, - recipients: - channelData.channel_type === 'email' - ? (channelData.details?.email.usernames ?? []) - : [], + label: channelData.label, + channel_type: channelData.channel_type, + details: { + email: { + usernames: + channelData.channel_type === 'email' + ? (channelData.details?.email.usernames ?? []) + : [], + }, + }, }, mode: 'onBlur', resolver: yupResolver(createNotificationChannelSchema), @@ -69,7 +73,7 @@ export const EditNotificationChannel = ( const { control, handleSubmit, formState } = formMethods; const handleRecipientsError = React.useCallback(() => { - formMethods.resetField('recipients', { defaultValue: [] }); + formMethods.resetField('details.email.usernames', { defaultValue: [] }); }, [formMethods]); const onSubmit = handleSubmit(async (values) => { @@ -112,7 +116,7 @@ export const EditNotificationChannel = ( ( ( ( { + const usernames = formValues.details.email.usernames; return { channelId, - label: formValues.name, + label: formValues.label, details: { email: { - usernames: formValues.recipients, + usernames, }, }, }; diff --git a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationChannelDetail/NotificationChannelAlerts.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationChannelDetail/NotificationChannelAlerts.test.tsx index 781ad80f827..c1675b2fcc5 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationChannelDetail/NotificationChannelAlerts.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationChannelDetail/NotificationChannelAlerts.test.tsx @@ -1,12 +1,15 @@ -import { screen } from '@testing-library/react'; import React from 'react'; import { notificationChannelAlertsFactory } from 'src/factories/cloudpulse/channels'; import { serviceTypesFactory } from 'src/factories/cloudpulse/services'; import { renderWithTheme } from 'src/utilities/testHelpers'; +import { getAssociatedAlerts } from '../Utils/utils'; import { NotificationChannelAlerts } from './NotificationChannelAlerts'; +import type { Item } from '../../constants'; +import type { CloudPulseServiceType } from '@linode/api-v4'; + const queryMocks = vi.hoisted(() => ({ useAllAlertsByNotificationChannelIdQuery: vi.fn(), useCloudPulseServiceTypes: vi.fn(), @@ -35,12 +38,18 @@ describe('NotificationChannelAlerts', () => { const alertNameText = 'Alert Name'; const serviceTypeText = 'Service'; + const mockFlags = { + aclpServices: { + dbaas: { alerts: { beta: true, enabled: true } }, + linode: { alerts: { beta: true, enabled: true } }, + }, + }; beforeEach(() => { queryMocks.useCloudPulseServiceTypes.mockReturnValue({ data: { data: mockServiceTypes, }, - isFetching: false, + isServiceTypesLoading: false, }); hookMocks.useOrderV2.mockReturnValue({ @@ -57,81 +66,89 @@ describe('NotificationChannelAlerts', () => { it('should render loading state while fetching alerts', () => { queryMocks.useAllAlertsByNotificationChannelIdQuery.mockReturnValue({ - data: null, + data: undefined, isError: false, isLoading: true, }); - renderWithTheme(); - - expect(screen.getByText(associatedAlertsText)).toBeVisible(); - screen.getByTestId('circle-progress'); - }); - - it('should render loading state while fetching service types', () => { - queryMocks.useAllAlertsByNotificationChannelIdQuery.mockReturnValue({ - data: [], - isError: false, - isLoading: false, - }); - - queryMocks.useCloudPulseServiceTypes.mockReturnValue({ - data: { - data: mockServiceTypes, - }, - isFetching: true, - }); - - renderWithTheme(); + const { getByText, getByTestId } = renderWithTheme( + , + { + flags: mockFlags, + } + ); - expect(screen.getByText(associatedAlertsText)).toBeVisible(); - screen.getByTestId('circle-progress'); + expect(getByText(associatedAlertsText)).toBeVisible(); + expect(getByTestId('table-row-loading')).toBeVisible(); }); it('should render error state when alerts query fails', () => { + const mockError = [{ reason: 'Error loading alerts' }]; + queryMocks.useAllAlertsByNotificationChannelIdQuery.mockReturnValue({ - data: null, + data: undefined, + error: mockError, isError: true, isLoading: false, }); - renderWithTheme(); + const { getByText } = renderWithTheme( + , + { + flags: mockFlags, + } + ); - expect(screen.getByText(associatedAlertsText)).toBeVisible(); - expect( - screen.getByText('Unable to load alerts for this channel.') - ).toBeVisible(); + expect(getByText(associatedAlertsText)).toBeVisible(); + expect(getByText('Error loading alerts')).toBeVisible(); }); it('should render notice when no alerts are associated', () => { queryMocks.useAllAlertsByNotificationChannelIdQuery.mockReturnValue({ data: [], + error: undefined, isError: false, isLoading: false, }); - renderWithTheme(); + const { getByText } = renderWithTheme( + , + { + flags: mockFlags, + } + ); - expect(screen.getByText(associatedAlertsText)).toBeVisible(); + expect(getByText(associatedAlertsText)).toBeVisible(); expect( - screen.getByText( - /No alerts are associated with this notification channel./ - ) + getByText(/No alerts are associated with this notification channel./) ).toBeVisible(); expect( - screen.getByText( + getByText( /Add or assign alerts to start receiving notifications through this channel./ ) ).toBeVisible(); }); - it('should render table with alerts when service_type is present', async () => { - const alerts = notificationChannelAlertsFactory.buildList(3, { - service_type: 'linode', - }); + it('should render alerts with multiple service types correctly', () => { + const alerts = [ + ...notificationChannelAlertsFactory.buildList(2, { + service_type: 'linode', + }), + ...notificationChannelAlertsFactory.buildList(2, { + service_type: 'dbaas', + }), + ]; + + const alertsWithServiceLabel = alerts.map((alert) => ({ + ...alert, + service_type_label: mockServiceTypes.find( + (st) => st.service_type === alert.service_type + )?.label, + })); queryMocks.useAllAlertsByNotificationChannelIdQuery.mockReturnValue({ data: alerts, + error: undefined, isError: false, isLoading: false, }); @@ -140,32 +157,108 @@ describe('NotificationChannelAlerts', () => { handleOrderChange: vi.fn(), order: 'asc', orderBy: 'label', - sortedData: alerts, + sortedData: alertsWithServiceLabel, }); - renderWithTheme(); + const { getByText } = renderWithTheme( + , + { + flags: mockFlags, + } + ); - expect(screen.getByText(associatedAlertsText)).toBeVisible(); - expect(screen.getByText(alertNameText)).toBeVisible(); - expect(screen.getByText(serviceTypeText)).toBeVisible(); + expect(getByText(associatedAlertsText)).toBeVisible(); + expect(getByText(alertNameText)).toBeVisible(); + expect(getByText(serviceTypeText)).toBeVisible(); alerts.forEach((alert) => { - expect(screen.getByText(alert.label)).toBeVisible(); + expect(getByText(alert.label)).toBeVisible(); }); }); - it('should render alerts with multiple service types correctly', () => { + it('should filter alerts by search text', () => { const alerts = [ - ...notificationChannelAlertsFactory.buildList(2, { + notificationChannelAlertsFactory.build({ + label: 'Database Alert', + service_type: 'dbaas', + }), + notificationChannelAlertsFactory.build({ + label: 'CPU Alert', service_type: 'linode', }), - ...notificationChannelAlertsFactory.buildList(2, { + notificationChannelAlertsFactory.build({ + label: 'Memory Alert', + service_type: 'linode', + }), + ]; + + // Use the utility function to filter alerts by search text + const filteredAlerts = getAssociatedAlerts(alerts, [], 'cpu'); + + const alertsWithServiceLabel = filteredAlerts.map((alert) => ({ + ...alert, + service_type_label: mockServiceTypes.find( + (st) => st.service_type === alert.service_type + )?.label, + })); + + queryMocks.useAllAlertsByNotificationChannelIdQuery.mockReturnValue({ + data: alerts, + error: undefined, + isError: false, + isLoading: false, + }); + + hookMocks.useOrderV2.mockReturnValue({ + handleOrderChange: vi.fn(), + order: 'asc', + orderBy: 'label', + sortedData: alertsWithServiceLabel, + }); + + const { getByText, queryByText } = renderWithTheme( + , + { + flags: mockFlags, + } + ); + + expect(getByText('CPU Alert')).toBeVisible(); + expect(queryByText('Database Alert')).not.toBeInTheDocument(); + expect(queryByText('Memory Alert')).not.toBeInTheDocument(); + }); + + it('should filter alerts by service type', () => { + const alerts = [ + notificationChannelAlertsFactory.build({ + label: 'Database Alert 1', service_type: 'dbaas', }), + notificationChannelAlertsFactory.build({ + label: 'Database Alert 2', + service_type: 'dbaas', + }), + notificationChannelAlertsFactory.build({ + label: 'Linode Alert', + service_type: 'linode', + }), + ]; + + const serviceFilters: Item[] = [ + { label: 'Databases', value: 'dbaas' }, ]; + const filteredAlerts = getAssociatedAlerts(alerts, serviceFilters, ''); + + const alertsWithServiceLabel = filteredAlerts.map((alert) => ({ + ...alert, + service_type_label: mockServiceTypes.find( + (st) => st.service_type === alert.service_type + )?.label, + })); queryMocks.useAllAlertsByNotificationChannelIdQuery.mockReturnValue({ data: alerts, + error: undefined, isError: false, isLoading: false, }); @@ -174,17 +267,126 @@ describe('NotificationChannelAlerts', () => { handleOrderChange: vi.fn(), order: 'asc', orderBy: 'label', - sortedData: alerts, + sortedData: alertsWithServiceLabel, }); - renderWithTheme(); + const { getByText, queryByText } = renderWithTheme( + , + { + flags: mockFlags, + } + ); - expect(screen.getByText(associatedAlertsText)).toBeVisible(); - expect(screen.getByText(alertNameText)).toBeVisible(); - expect(screen.getByText(serviceTypeText)).toBeVisible(); + expect(getByText('Database Alert 1')).toBeVisible(); + expect(getByText('Database Alert 2')).toBeVisible(); + expect(queryByText('Linode Alert')).not.toBeInTheDocument(); + }); - alerts.forEach((alert) => { - expect(screen.getByText(alert.label)).toBeVisible(); + it('should filter alerts by both search text and service type', () => { + const alerts = [ + notificationChannelAlertsFactory.build({ + label: 'Database CPU Alert', + service_type: 'dbaas', + }), + notificationChannelAlertsFactory.build({ + label: 'Database Memory Alert', + service_type: 'dbaas', + }), + notificationChannelAlertsFactory.build({ + label: 'Linode CPU Alert', + service_type: 'linode', + }), + notificationChannelAlertsFactory.build({ + label: 'Linode Memory Alert', + service_type: 'linode', + }), + ]; + + const serviceFilters: Item[] = [ + { label: 'Databases', value: 'dbaas' }, + ]; + const filteredAlerts = getAssociatedAlerts(alerts, serviceFilters, 'cpu'); + + const alertsWithServiceLabel = filteredAlerts.map((alert) => ({ + ...alert, + service_type_label: mockServiceTypes.find( + (st) => st.service_type === alert.service_type + )?.label, + })); + + queryMocks.useAllAlertsByNotificationChannelIdQuery.mockReturnValue({ + data: alerts, + error: undefined, + isError: false, + isLoading: false, + }); + + hookMocks.useOrderV2.mockReturnValue({ + handleOrderChange: vi.fn(), + order: 'asc', + orderBy: 'label', + sortedData: alertsWithServiceLabel, }); + + const { getByText, queryByText } = renderWithTheme( + , + { + flags: mockFlags, + } + ); + + expect(getByText('Database CPU Alert')).toBeVisible(); + expect(queryByText('Database Memory Alert')).not.toBeInTheDocument(); + expect(queryByText('Linode CPU Alert')).not.toBeInTheDocument(); + expect(queryByText('Linode Memory Alert')).not.toBeInTheDocument(); + }); + it('should render the Beta flag for the services in the service column', async () => { + const alerts = [ + notificationChannelAlertsFactory.build({ + label: 'Database CPU Alert', + service_type: 'dbaas', + }), + notificationChannelAlertsFactory.build({ + label: 'Database Memory Alert', + service_type: 'dbaas', + }), + notificationChannelAlertsFactory.build({ + label: 'Linode CPU Alert', + service_type: 'linode', + }), + notificationChannelAlertsFactory.build({ + label: 'Linode Memory Alert', + service_type: 'linode', + }), + ]; + + const alertsWithServiceLabel = alerts.map((alert) => ({ + ...alert, + service_type_label: mockServiceTypes.find( + (st) => st.service_type === alert.service_type + )?.label, + })); + + queryMocks.useAllAlertsByNotificationChannelIdQuery.mockReturnValue({ + data: alerts, + error: undefined, + isError: false, + isLoading: false, + }); + + hookMocks.useOrderV2.mockReturnValue({ + handleOrderChange: vi.fn(), + order: 'asc', + orderBy: 'label', + sortedData: alertsWithServiceLabel, + }); + + const { getAllByText } = renderWithTheme( + , + { + flags: mockFlags, + } + ); + expect(getAllByText(/beta/i)).toHaveLength(alerts.length); }); }); diff --git a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationChannelDetail/NotificationChannelAlerts.tsx b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationChannelDetail/NotificationChannelAlerts.tsx index 1bc70f05031..ec97fc50b7d 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationChannelDetail/NotificationChannelAlerts.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationChannelDetail/NotificationChannelAlerts.tsx @@ -1,20 +1,43 @@ -import { CircleProgress, Notice, Typography } from '@linode/ui'; +import { + Autocomplete, + BetaChip, + Box, + Notice, + SelectedIcon, + Stack, + StyledListItem, + Typography, +} from '@linode/ui'; import * as React from 'react'; +import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField'; +import Paginate from 'src/components/Paginate'; +import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter'; import { Table } from 'src/components/Table'; import { TableBody } from 'src/components/TableBody'; +import { TableContentWrapper } from 'src/components/TableContentWrapper/TableContentWrapper'; import { TableHead } from 'src/components/TableHead'; import { TableRow } from 'src/components/TableRow'; -import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; import { TableSortCell } from 'src/components/TableSortCell/TableSortCell'; +import { useFlags } from 'src/hooks/useFlags'; import { useOrderV2 } from 'src/hooks/useOrderV2'; import { useAllAlertsByNotificationChannelIdQuery } from 'src/queries/cloudpulse/alerts'; import { useCloudPulseServiceTypes } from 'src/queries/cloudpulse/services'; +import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; -import { getServiceTypeLabel } from '../../Utils/utils'; +import { + alertsFromEnabledServices, + getServiceTypeLabel, +} from '../../Utils/utils'; +import { getAssociatedAlerts, getServicesList } from '../Utils/utils'; import { NotificationChannelAlertsTableRow } from './NotificationChannelAlertsTableRow'; -import type { NotificationChannelAlerts as NotificationChannelAlertsType } from '@linode/api-v4'; +import type { Item } from '../../constants'; +import type { + CloudPulseServiceType, + NotificationChannelAlerts as NotificationChannelAlertsType, +} from '@linode/api-v4'; +import type { Order } from '@linode/utilities'; interface NotificationChannelAlertsProps { /** @@ -27,22 +50,42 @@ export const NotificationChannelAlerts = React.memo( (props: NotificationChannelAlertsProps) => { const { channelId } = props; - const { data: serviceTypeList, isFetching } = - useCloudPulseServiceTypes(true); + const { aclpServices } = useFlags(); + const { data: serviceTypeList } = useCloudPulseServiceTypes(true); const { - data: channelAlerts, - isError: isChannelAlertsError, - isLoading: isChannelAlertsLoading, + data: allAlerts, + error, + isError, + isLoading, } = useAllAlertsByNotificationChannelIdQuery(channelId); + const channelAlerts = alertsFromEnabledServices(allAlerts, aclpServices); + const _error = error + ? getAPIErrorOrDefault(error, 'Error in fetching the alerts.') + : undefined; + const [searchText, setSearchText] = React.useState(''); + const [serviceFilters, setServiceFilters] = React.useState< + Item[] + >([]); + + const servicesList = React.useMemo( + () => getServicesList(serviceTypeList, aclpServices), + [aclpServices, serviceTypeList] + ); + + const associatedAlerts = React.useMemo( + () => getAssociatedAlerts(channelAlerts, serviceFilters, searchText), + [channelAlerts, searchText, serviceFilters] + ); + const associatedAlertsWithServiceLabels = React.useMemo(() => { - return channelAlerts?.map((alert) => ({ + return associatedAlerts.map((alert) => ({ ...alert, service_type_label: alert.service_type ? getServiceTypeLabel(alert.service_type, serviceTypeList) : undefined, })); - }, [channelAlerts, serviceTypeList]); + }, [associatedAlerts, serviceTypeList]); const { handleOrderChange, order, orderBy, sortedData } = useOrderV2({ @@ -57,31 +100,7 @@ export const NotificationChannelAlerts = React.memo( preferenceKey: 'notification-channel-alerts', }); - if (isChannelAlertsLoading || isFetching) { - return ( - <> - - Associated Alerts - - - - ); - } - - if (isChannelAlertsError) { - return ( - <> - - Associated Alerts - - - Unable to load alerts for this channel. - - - ); - } - - if (!channelAlerts?.length) { + if (!isLoading && !isError && !channelAlerts?.length) { return ( <> @@ -98,50 +117,146 @@ export const NotificationChannelAlerts = React.memo( } return ( - <> - - Associated Alerts - - - - - - Alert Name - - - Service - - - - - {sortedData && sortedData.length > 0 ? ( - sortedData.map((alert) => ( - + Associated Alerts + + + { + setServiceFilters(selected); + }} + options={servicesList} + placeholder={serviceFilters.length > 0 ? '' : 'Select a Service'} + renderOption={(props, option, { selected }) => { + const { key, ...rest } = props; + const ListItem = + key === 'Select All ' || key === 'Deselect All ' + ? StyledListItem + : 'li'; + return ( + + {option.label}{' '} + {aclpServices?.[option.value]?.alerts?.beta && } + + + ); + }} + sx={{ + width: { lg: '250px', md: '300px', sm: '400px', xs: '300px' }, + }} + value={serviceFilters} + /> + + + {({ + count, + data: paginatedAndOrderedAlerts, + handlePageChange, + handlePageSizeChange, + page, + pageSize, + }) => { + const handleTableSort = (orderBy: string, order?: Order) => { + if (order) { + handleOrderChange(orderBy, order); + handlePageChange(1); + } + }; + return ( + <> +
+ + + + Alert Name + + + Service + + + + + + {paginatedAndOrderedAlerts.map((alert) => ( + + ))} + +
+ - )) - ) : ( - - )} - - - + + ); + }} + + ); } ); diff --git a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationChannelDetail/NotificationChannelAlertsTableRow.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationChannelDetail/NotificationChannelAlertsTableRow.test.tsx index 0cdd6bef774..b3c8baf1f1f 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationChannelDetail/NotificationChannelAlertsTableRow.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationChannelDetail/NotificationChannelAlertsTableRow.test.tsx @@ -1,4 +1,3 @@ -import { screen } from '@testing-library/react'; import React from 'react'; import { notificationChannelAlertsFactory } from 'src/factories/cloudpulse/channels'; @@ -6,6 +5,22 @@ import { renderWithTheme, wrapWithTableBody } from 'src/utilities/testHelpers'; import { NotificationChannelAlertsTableRow } from './NotificationChannelAlertsTableRow'; +import type { AclpServices } from 'src/featureFlags'; + +const aclpServicesFlag: Partial = { + linode: { + alerts: { enabled: true, beta: true }, + metrics: { enabled: true, beta: true }, + }, + dbaas: { + alerts: { enabled: true, beta: true }, + metrics: { enabled: true, beta: true }, + }, +}; +const mockFlags = { + aclpServices: aclpServicesFlag, +}; + describe('NotificationChannelAlertsTableRow', () => { it('should render alert with link', () => { const alert = notificationChannelAlertsFactory.build({ @@ -14,19 +29,22 @@ describe('NotificationChannelAlertsTableRow', () => { service_type: 'linode', }); - renderWithTheme( + const { getByRole, getByText } = renderWithTheme( wrapWithTableBody( - ) + ), + { + flags: mockFlags, + } ); - const link = screen.getByRole('link', { name: 'Test Alert' }); + const link = getByRole('link', { name: 'Test Alert' }); expect(link).toBeVisible(); expect(link).toHaveAttribute('href', '/alerts/definitions/detail/linode/1'); - expect(screen.getByText('Linode')).toBeVisible(); + expect(getByText('Linode')).toBeVisible(); }); it('should render Service Type cell', () => { @@ -36,18 +54,21 @@ describe('NotificationChannelAlertsTableRow', () => { service_type: 'dbaas', }); - renderWithTheme( + const { getAllByRole, getByText } = renderWithTheme( wrapWithTableBody( - ) + ), + { + flags: mockFlags, + } ); // Should have two cells (Alert Name and Service Type) - expect(screen.getAllByRole('cell')).toHaveLength(2); - expect(screen.getByText('Managed Databases')).toBeVisible(); + expect(getAllByRole('cell')).toHaveLength(2); + expect(getByText('Managed Databases')).toBeVisible(); }); it('should render multiple service types correctly', () => { @@ -63,17 +84,20 @@ describe('NotificationChannelAlertsTableRow', () => { service_type: 'dbaas', }); - const { rerender } = renderWithTheme( + const { getByText, rerender } = renderWithTheme( wrapWithTableBody( - ) + ), + { + flags: mockFlags, + } ); - expect(screen.getByText('Linode Alert')).toBeVisible(); - expect(screen.getByText('Linode')).toBeVisible(); + expect(getByText('Linode Alert')).toBeVisible(); + expect(getByText('Linode')).toBeVisible(); rerender( wrapWithTableBody( @@ -84,7 +108,7 @@ describe('NotificationChannelAlertsTableRow', () => { ) ); - expect(screen.getByText('Database Alert')).toBeVisible(); - expect(screen.getByText('Managed Databases')).toBeVisible(); + expect(getByText('Database Alert')).toBeVisible(); + expect(getByText('Managed Databases')).toBeVisible(); }); }); diff --git a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationChannelDetail/NotificationChannelAlertsTableRow.tsx b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationChannelDetail/NotificationChannelAlertsTableRow.tsx index b69661d82eb..a6103d76460 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationChannelDetail/NotificationChannelAlertsTableRow.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationChannelDetail/NotificationChannelAlertsTableRow.tsx @@ -1,8 +1,10 @@ +import { BetaChip } from '@linode/ui'; import * as React from 'react'; import { Link } from 'src/components/Link'; import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; +import { useFlags } from 'src/hooks/useFlags'; import type { NotificationChannelAlerts } from '@linode/api-v4'; interface NotificationChannelAlertsTableRowProps { @@ -19,11 +21,14 @@ interface NotificationChannelAlertsTableRowProps { export const NotificationChannelAlertsTableRow = React.memo( (props: NotificationChannelAlertsTableRowProps) => { const { alert, serviceTypeLabel } = props; + + const { aclpServices } = useFlags(); + const { label, service_type, id } = alert; return ( @@ -35,7 +40,10 @@ export const NotificationChannelAlertsTableRow = React.memo( {label} - {serviceTypeLabel} + + {serviceTypeLabel}{' '} + {aclpServices?.[service_type]?.alerts?.beta && } + ); } diff --git a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationChannelDetail/NotificationChannelDetail.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationChannelDetail/NotificationChannelDetail.test.tsx index ce3e25bdd4e..793e75b9ccd 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationChannelDetail/NotificationChannelDetail.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationChannelDetail/NotificationChannelDetail.test.tsx @@ -1,4 +1,3 @@ -import { screen } from '@testing-library/react'; import React from 'react'; import { notificationChannelFactory } from 'src/factories/cloudpulse/channels'; @@ -43,6 +42,12 @@ vi.mock('@tanstack/react-router', async () => { // Shared Setup const initialRoute = '/alerts/notification-channels/detail/1'; +const mockFlags = { + aclpServices: { + dbaas: { alerts: { enabled: true, beta: true } }, + linode: { alerts: { enabled: true, beta: true } }, + }, +}; beforeEach(() => { queryMocks.useParams.mockReturnValue({ @@ -76,13 +81,14 @@ describe('NotificationChannelDetail component tests', () => { isLoading: false, }); - renderWithTheme(, { + const { getByText } = renderWithTheme(, { + flags: mockFlags, initialRoute, }); // Assert error message is displayed expect( - screen.getByText( + getByText( 'An error occurred while loading the notification channel. Please try again later.' ) ).toBeVisible(); @@ -96,6 +102,7 @@ describe('NotificationChannelDetail component tests', () => { }); const { getByTestId } = renderWithTheme(, { + flags: mockFlags, initialRoute, }); @@ -117,13 +124,17 @@ describe('NotificationChannelDetail component tests', () => { isLoading: false, }); - renderWithTheme(, { - initialRoute, - }); - const link = screen.getByTestId('link-text'); + const { getByTestId, getByRole } = renderWithTheme( + , + { + flags: mockFlags, + initialRoute, + } + ); + const link = getByTestId('link-text'); expect(link).toBeVisible(); expect(link).toHaveTextContent('Notification Channels'); - const breadcrumbLink = screen.getByRole('link', { + const breadcrumbLink = getByRole('link', { name: /notification channels/i, }); expect(breadcrumbLink).toHaveAttribute( @@ -174,23 +185,27 @@ describe('NotificationChannelDetail component tests', () => { sortedData: alerts, }); - renderWithTheme(, { - initialRoute, - }); + const { getByRole, getByText } = renderWithTheme( + , + { + flags: mockFlags, + initialRoute, + } + ); // Verify Overview section details - expect(screen.getByText('Overview')).toBeVisible(); - expect(screen.getByText('Email Notifications')).toBeVisible(); - expect(screen.getByText('Email')).toBeVisible(); - expect(screen.getByText('admin_user')).toBeVisible(); - expect(screen.getByText('ops_user')).toBeVisible(); - - // Verify Settings/Recipients section details - expect(screen.getByText('Settings')).toBeVisible(); - expect(screen.getByText(/Recipients/)).toBeVisible(); - expect(screen.getByText('admin')).toBeVisible(); - expect(screen.getByText('ops_team')).toBeVisible(); - expect(screen.getByText('Associated Alerts')).toBeVisible(); - expect(screen.getByText('Critical CPU Alert')).toBeVisible(); + expect(getByText('Overview')).toBeVisible(); + expect(getByText('Email Notifications')).toBeVisible(); + expect(getByText('Email')).toBeVisible(); + expect(getByText('admin_user')).toBeVisible(); + expect(getByText('ops_user')).toBeVisible(); + + // Verify Details/Recipients section details + expect(getByRole('heading', { level: 2, name: 'Details' })).toBeVisible(); + expect(getByText(/Recipients/)).toBeVisible(); + expect(getByText('admin')).toBeVisible(); + expect(getByText('ops_team')).toBeVisible(); + expect(getByText('Associated Alerts')).toBeVisible(); + expect(getByText('Critical CPU Alert')).toBeVisible(); }); }); diff --git a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationChannelDetail/NotificationChannelDetail.tsx b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationChannelDetail/NotificationChannelDetail.tsx index 4713f80ad96..96488bd684a 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationChannelDetail/NotificationChannelDetail.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationChannelDetail/NotificationChannelDetail.tsx @@ -3,10 +3,12 @@ import { useTheme } from '@mui/material'; import { useParams } from '@tanstack/react-router'; import React from 'react'; +import AlertsIcon from 'src/assets/icons/entityIcons/alerts.svg'; import { Breadcrumb } from 'src/components/Breadcrumb/Breadcrumb'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { useNotificationChannelQuery } from 'src/queries/cloudpulse/alerts'; +import { StyledPlaceholder } from '../../AlertsDetail/AlertDetail'; import { getAlertBoxStyles } from '../../Utils/utils'; import { NotificationChannelAlerts } from './NotificationChannelAlerts'; import { NotificationChannelDetailOverview } from './NotificationChannelDetailOverview'; @@ -51,7 +53,7 @@ export const NotificationChannelDetail = () => { ); } - if (isError || !channelDetails) { + if (isError) { return ( <> { ); } + if (!channelDetails) { + return ( + <> + + + + + + ); + } return ( <> @@ -90,7 +109,7 @@ export const NotificationChannelDetail = () => { /> { updated_by: 'jane_smith', }); - renderWithTheme( + const { getByText } = renderWithTheme( ); - expect(screen.getByText('Overview')).toBeVisible(); - expect(screen.getByText('Name:')).toBeVisible(); - expect(screen.getByText('Production Alerts')).toBeVisible(); - expect(screen.getByText('Channel Type:')).toBeVisible(); - expect(screen.getByText('Email')).toBeVisible(); - expect(screen.getByText('Created by:')).toBeVisible(); - expect(screen.getByText('john_doe')).toBeVisible(); - expect(screen.getByText('Creation Time:')).toBeVisible(); + expect(getByText('Overview')).toBeVisible(); + expect(getByText('Name:')).toBeVisible(); + expect(getByText('Production Alerts')).toBeVisible(); + expect(getByText('Channel Type:')).toBeVisible(); + expect(getByText('Email')).toBeVisible(); + expect(getByText('Created by:')).toBeVisible(); + expect(getByText('john_doe')).toBeVisible(); + expect(getByText('Creation Time:')).toBeVisible(); expect( - screen.getByText( + getByText( formatDate(created, { format: 'MMM dd, yyyy, h:mm a', timezone: mockProfile.timezone, }) ) ).toBeVisible(); - expect(screen.getByText('Last Modified:')).toBeVisible(); + expect(getByText('Last Modified:')).toBeVisible(); expect( - screen.getByText( + getByText( formatDate(updated, { format: dateTimeFormat, timezone: mockProfile.timezone, }) ) ).toBeVisible(); - expect(screen.getByText('Last Modified by:')).toBeVisible(); - expect(screen.getByText('jane_smith')).toBeVisible(); + expect(getByText('Last Modified by:')).toBeVisible(); + expect(getByText('jane_smith')).toBeVisible(); }); it('should format dates with user timezone', () => { @@ -89,12 +88,12 @@ describe('NotificationChannelDetailOverview', () => { updated, }); - renderWithTheme( + const { getByText } = renderWithTheme( ); expect( - screen.getByText( + getByText( formatDate(created, { format: dateTimeFormat, timezone: customProfile.timezone, @@ -102,7 +101,7 @@ describe('NotificationChannelDetailOverview', () => { ) ).toBeVisible(); expect( - screen.getByText( + getByText( formatDate(updated, { format: dateTimeFormat, timezone: customProfile.timezone, diff --git a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationChannelDetail/NotificationChannelDetailOverview.tsx b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationChannelDetail/NotificationChannelDetailOverview.tsx index 96eb1711373..fa081ec2503 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationChannelDetail/NotificationChannelDetailOverview.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationChannelDetail/NotificationChannelDetailOverview.tsx @@ -32,12 +32,12 @@ export const NotificationChannelDetailOverview = React.memo( return ( <> - + Overview { renderWithTheme(); // Verify header - expect(screen.getByText('Settings')).toBeVisible(); + expect(screen.getByText('Details')).toBeVisible(); expect(screen.getByText(/Recipients/)).toBeVisible(); // Verify all recipients are visible diff --git a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationChannelDetail/NotificationChannelDetailRecipients.tsx b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationChannelDetail/NotificationChannelDetailRecipients.tsx index bc71566dd13..8ed4d17495c 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationChannelDetail/NotificationChannelDetailRecipients.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationChannelDetail/NotificationChannelDetailRecipients.tsx @@ -30,7 +30,7 @@ export const NotificationChannelRecipients = React.memo( return ( <> - Settings + Details { + describe('getNotificationChannelActionsList', () => { + const handlers = { + handleDelete: vi.fn(), + handleDetails: vi.fn(), + handleEdit: vi.fn(), + }; + it('should return proper actions for system channel type', () => { + const actions = getNotificationChannelActionsList({ + alertsCount: 0, + handlers, + }); + + expect(actions.system).toHaveLength(1); + expect(actions.system[0].title).toBe('Show Details'); + expect(actions.system[0].onClick).toBe(handlers.handleDetails); + }); + + it('should disable delete action and show tooltip when alertsCount > 0', () => { + const actions = getNotificationChannelActionsList({ + alertsCount: 2, + handlers, + }); + + const deleteAction = actions.user.find((action) => + action.title.includes('Delete') + ); + + expect(deleteAction?.disabled).toBe(true); + expect(deleteAction?.tooltip).toBe(DELETE_CHANNEL_TOOLTIP_TEXT); + }); + + it('should enable delete action when no alerts are associated', () => { + const actions = getNotificationChannelActionsList({ + alertsCount: 0, + handlers, + }); + + const deleteAction = actions.user.find((action) => + action.title.includes('Delete') + ); + + expect(deleteAction?.disabled).toBe(false); + expect(deleteAction?.tooltip).toBeUndefined(); + }); + }); + + describe('getServicesList', () => { + it('should return an empty array when serviceTypeList is undefined', () => { + expect(getServicesList(undefined, undefined)).toEqual([]); + }); + + it('should return an empty array when serviceTypeList has no data', () => { + expect(getServicesList({ data: [] }, {})).toEqual([]); + }); + + it('should return only services with alerts enabled in flags', () => { + const services = [ + serviceTypesFactory.build({ + label: 'Linode', + service_type: 'linode', + }), + serviceTypesFactory.build({ + label: 'Managed Databases', + service_type: 'dbaas', + }), + serviceTypesFactory.build({ + label: 'Object Storage', + service_type: 'objectstorage', + }), + ]; + + const aclpServices = { + dbaas: { alerts: { enabled: false, beta: false } }, + linode: { alerts: { enabled: true, beta: false } }, + }; + + expect(getServicesList({ data: services }, aclpServices)).toEqual([ + { label: 'Linode', value: 'linode' }, + ]); + }); + }); + + describe('getAssociatedAlerts', () => { + it('should return an empty array when alerts are undefined', () => { + expect(getAssociatedAlerts(undefined, [], '')).toEqual([]); + }); + + it('should filter alerts by service types', () => { + const alerts = [ + notificationChannelAlertsFactory.build({ + label: 'DB Alert', + service_type: 'dbaas', + }), + notificationChannelAlertsFactory.build({ + label: 'Linode Alert', + service_type: 'linode', + }), + ]; + + const filtered = getAssociatedAlerts( + alerts, + [{ label: 'Databases', value: 'dbaas' }], + '' + ); + + expect(filtered).toHaveLength(1); + expect(filtered[0].label).toBe('DB Alert'); + }); + + it('should filter alerts by search text', () => { + const alerts = [ + notificationChannelAlertsFactory.build({ + label: 'CPU Alert', + service_type: 'linode', + }), + notificationChannelAlertsFactory.build({ + label: 'Memory Alert', + service_type: 'linode', + }), + ]; + + const filtered = getAssociatedAlerts(alerts, [], 'cpu'); + + expect(filtered).toHaveLength(1); + expect(filtered[0].label).toBe('CPU Alert'); + }); + + it('should filter alerts by both service type and search text', () => { + const alerts = [ + notificationChannelAlertsFactory.build({ + label: 'DB CPU Alert', + service_type: 'dbaas', + }), + notificationChannelAlertsFactory.build({ + label: 'DB Memory Alert', + service_type: 'dbaas', + }), + notificationChannelAlertsFactory.build({ + label: 'Linode CPU Alert', + service_type: 'linode', + }), + ]; + + const filtered = getAssociatedAlerts( + alerts, + [{ label: 'Databases', value: 'dbaas' }], + 'cpu' + ); + + expect(filtered).toHaveLength(1); + expect(filtered[0].label).toBe('DB CPU Alert'); + }); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/Utils/utils.ts b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/Utils/utils.ts index 17db329dc97..6e5189cff3e 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/Utils/utils.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/Utils/utils.ts @@ -1,8 +1,15 @@ import { DELETE_CHANNEL_TOOLTIP_TEXT } from '../../constants'; +import type { Item } from '../../constants'; import type { NotificationChannelActionHandlers } from '../NotificationsChannelsListing/NotificationChannelActionMenu'; -import type { AlertNotificationType } from '@linode/api-v4'; +import type { + AlertNotificationType, + CloudPulseServiceType, + NotificationChannelAlerts, + ServiceTypesList, +} from '@linode/api-v4'; import type { Action } from 'src/components/ActionMenu/ActionMenu'; +import type { AclpServices } from 'src/featureFlags'; interface NotificationChannelActionsListProps { /** @@ -44,3 +51,60 @@ export const getNotificationChannelActionsList = ( ], }; }; + +/** + * Returns a filtered and mapped list of service types for use in the Autocomplete. + * @param serviceTypeList List of available service types from the API + * @param aclpServices Feature flag configuration for service types + * @returns Filtered list of service items that have alerts enabled + */ +export const getServicesList = ( + serviceTypeList: ServiceTypesList | undefined, + aclpServices: Partial | undefined +): Item[] => { + if (!serviceTypeList || !serviceTypeList.data.length) { + return []; + } + return serviceTypeList.data + .filter( + (service) => + aclpServices?.[service.service_type]?.alerts?.enabled ?? false + ) + .map((service) => ({ + label: service.label, + value: service.service_type, + })); +}; + +/** + * Returns a filtered list of alerts based on service filters and search text. + * @param channelAlerts List of alerts associated with the notification channel + * @param serviceFilters Selected service type filters + * @param searchText Search text to filter alerts by label + * @returns Filtered list of alerts + */ +export const getAssociatedAlerts = ( + channelAlerts: NotificationChannelAlerts[] | undefined, + serviceFilters: Item[], + searchText: string +): NotificationChannelAlerts[] => { + if (!channelAlerts) { + return []; + } + let filteredAlerts = channelAlerts; + + if (serviceFilters && serviceFilters.length > 0) { + filteredAlerts = filteredAlerts.filter((alert) => + serviceFilters.some( + (serviceFilter) => serviceFilter.value === alert.service_type + ) + ); + } + + if (searchText) { + filteredAlerts = filteredAlerts.filter(({ label }) => + label.toLowerCase().includes(searchText.toLowerCase()) + ); + } + return filteredAlerts; +}; diff --git a/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertsActionMenu.ts b/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertsActionMenu.ts index 3ff182a8769..e3c21d6724c 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertsActionMenu.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertsActionMenu.ts @@ -41,7 +41,6 @@ export const getAlertTypeToActionsList = ( }, { disabled: - alertStatus === 'in progress' || alertStatus === 'failed' || alertStatus === 'provisioning' || alertStatus === 'enabling' || diff --git a/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.test.ts b/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.test.ts index b727035cec1..6a7252f2df3 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.test.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.test.ts @@ -5,7 +5,11 @@ import { } from '@linode/utilities'; import { act, renderHook } from '@testing-library/react'; -import { alertFactory, notificationChannelFactory, serviceTypesFactory } from 'src/factories'; +import { + alertFactory, + notificationChannelFactory, + serviceTypesFactory, +} from 'src/factories'; import { useContextualAlertsState } from '../../Utils/utils'; import { transformDimensionValue } from '../CreateAlert/Criteria/DimensionFilterValue/utils'; diff --git a/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.ts b/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.ts index e35430a7f77..be6d91cf037 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.ts @@ -21,7 +21,12 @@ import type { AssociatedEntityType } from '../../shared/types'; import type { AlertRegion } from '../AlertRegions/DisplayAlertRegions'; import type { AlertDimensionsProp } from '../AlertsDetail/DisplayAlertDetailChips'; import type { CreateAlertDefinitionForm } from '../CreateAlert/types'; -import type { Firewall, Linode, MonitoringCapabilities } from '@linode/api-v4'; +import type { + Firewall, + Linode, + MonitoringCapabilities, + NotificationChannelAlerts, +} from '@linode/api-v4'; import type { Theme } from '@mui/material'; import type { AclpAlertServiceTypeConfig, @@ -301,7 +306,6 @@ export const filterAlerts = (props: FilterAlertsProps): Alert[] => { alerts?.filter(({ label, status, type, scope, regions }) => { return ( (status === 'enabled' || - status === 'in progress' || status === 'provisioning' || status === 'enabling') && (!selectedType || type === selectedType) && @@ -615,10 +619,12 @@ export const convertSecondsToOptions = (seconds: number): string => { * @param aclpServices list of services with their statuses * @returns list of alerts from enabled services */ -export const alertsFromEnabledServices = ( - allAlerts: Alert[] | undefined, +export const alertsFromEnabledServices = < + T extends Alert | NotificationChannelAlerts, +>( + allAlerts: T[] | undefined, aclpServices: Partial | undefined -) => { +): T[] | undefined => { // Return the alerts whose service type is enabled in the aclpServices flag return allAlerts?.filter( (alert) => aclpServices?.[alert.service_type]?.alerts?.enabled ?? false diff --git a/packages/manager/src/features/CloudPulse/Alerts/constants.ts b/packages/manager/src/features/CloudPulse/Alerts/constants.ts index 7f51ea2e586..4a8876f5407 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/constants.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/constants.ts @@ -133,7 +133,6 @@ export const alertStatusToIconStatusMap: Record = { disabled: 'inactive', enabled: 'active', failed: 'error', - 'in progress': 'other', provisioning: 'other', disabling: 'other', enabling: 'other', @@ -182,7 +181,6 @@ export const alertStatuses: Record = { disabled: 'Disabled', enabled: 'Enabled', failed: 'Failed', - 'in progress': 'In Progress', disabling: 'Disabling', enabling: 'Enabling', provisioning: 'Provisioning', @@ -254,6 +252,13 @@ export const PORTS_PLACEHOLDER_TEXT = 'e.g., 80,443,3000'; export const PORT_PLACEHOLDER_TEXT = 'e.g., 80'; +export const VIP_PLACEHOLDER_TEXT = 'Enter VIP address'; +export const NODE_ID_PLACEHOLDER_TEXT = 'Enter Node ID'; +export const NODE_ID_HELPER_TEXT = + 'Enter one or more Node IDs separated by commas.'; +export const VIP_HELPER_TEXT = + 'Enter one or more VIP addresses separated by commas.'; + export const CONFIGS_HELPER_TEXT = 'Enter one or more configuration IDs separated by commas.'; diff --git a/packages/manager/src/features/CloudPulse/GroupBy/CloudPulseGroupByDrawer.tsx b/packages/manager/src/features/CloudPulse/GroupBy/CloudPulseGroupByDrawer.tsx index 400da9e137c..c7b37b546f2 100644 --- a/packages/manager/src/features/CloudPulse/GroupBy/CloudPulseGroupByDrawer.tsx +++ b/packages/manager/src/features/CloudPulse/GroupBy/CloudPulseGroupByDrawer.tsx @@ -129,6 +129,7 @@ export const CloudPulseGroupByDrawer = React.memo( key={key} {...rest} aria-disabled={!isSelectAllORDeselectAllOption && isDisabled} + data-pendo-id={option.label} // Adding data-pendo-id for better tracking in Pendo analytics, using the option label as the identifier for the option element. > {option.label} diff --git a/packages/manager/src/features/CloudPulse/GroupBy/WidgetFilterGroupByRenderer.test.tsx b/packages/manager/src/features/CloudPulse/GroupBy/WidgetFilterGroupByRenderer.test.tsx index 0c326e38996..7e7a65f0560 100644 --- a/packages/manager/src/features/CloudPulse/GroupBy/WidgetFilterGroupByRenderer.test.tsx +++ b/packages/manager/src/features/CloudPulse/GroupBy/WidgetFilterGroupByRenderer.test.tsx @@ -53,7 +53,7 @@ describe('Widget Group By Renderer', () => { }); renderWithTheme(component); - const groupByIcon = screen.getByTestId('group-by'); + const groupByIcon = screen.getByTestId('widget-group-by'); expect(groupByIcon).toBeInTheDocument(); expect(groupByIcon).toBeDisabled(); @@ -72,7 +72,7 @@ describe('Widget Group By Renderer', () => { renderWithTheme(component); - const groupByIcon = screen.getByTestId('group-by'); + const groupByIcon = screen.getByTestId('widget-group-by'); await groupByIcon.click(); @@ -96,7 +96,7 @@ describe('Widget Group By Renderer', () => { }); renderWithTheme(component); - const groupByIcon = screen.getByTestId('group-by'); + const groupByIcon = screen.getByTestId('widget-group-by'); expect(groupByIcon).toBeEnabled(); const drawer = screen.queryByTestId('drawer'); @@ -113,7 +113,7 @@ describe('Widget Group By Renderer', () => { }); renderWithTheme(component); - const groupByIcon = screen.getByTestId('group-by'); + const groupByIcon = screen.getByTestId('widget-group-by'); await groupByIcon.click(); diff --git a/packages/manager/src/features/CloudPulse/GroupBy/WidgetFilterGroupByRenderer.tsx b/packages/manager/src/features/CloudPulse/GroupBy/WidgetFilterGroupByRenderer.tsx index 9eeeee7b1b3..87196a0b3a6 100644 --- a/packages/manager/src/features/CloudPulse/GroupBy/WidgetFilterGroupByRenderer.tsx +++ b/packages/manager/src/features/CloudPulse/GroupBy/WidgetFilterGroupByRenderer.tsx @@ -101,7 +101,7 @@ export const WidgetFilterGroupByRenderer = ( aria-label="Group By Dashboard Metrics" color="inherit" data-qa-selected={isSelected} - data-testid="group-by" + data-testid="widget-group-by" disabled={isDisabled} onClick={() => setOpen(true)} size="small" diff --git a/packages/manager/src/features/CloudPulse/Utils/CloudPulseZoomInUtils.test.ts b/packages/manager/src/features/CloudPulse/Utils/CloudPulseZoomInUtils.test.ts new file mode 100644 index 00000000000..7a9646a843b --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Utils/CloudPulseZoomInUtils.test.ts @@ -0,0 +1,225 @@ +import { describe, expect, it } from 'vitest'; + +import { + computeLegendRowsBasedOnData, + computeZoomedInData, + getMetricsFromDimensionData, +} from './CloudPulseZoomInUtils'; +import { formatToolTip } from './unitConversion'; + +import type { ZoomState } from '../Widget/components/useZoomController'; +import type { DataSet } from 'src/components/AreaChart/AreaChart'; +import type { MetricsDisplayRow } from 'src/components/LineGraph/MetricsDisplay'; + +describe('computeZoomedInData', () => { + const mockData: DataSet[] = [ + { timestamp: 1000, metric1: 10, metric2: 20 }, + { timestamp: 2000, metric1: 15, metric2: 25 }, + { timestamp: 3000, metric1: 20, metric2: 30 }, + { timestamp: 4000, metric1: 25, metric2: 35 }, + { timestamp: 5000, metric1: 30, metric2: 40 }, + ]; + + it('should return original data when zoom is at default (dataMin/dataMax)', () => { + const zoom: ZoomState = { left: 'dataMin', right: 'dataMax' }; + const result = computeZoomedInData({ data: mockData, zoom }); + expect(result).toBe(mockData); + }); + + it('should return empty array when data is empty', () => { + const zoom: ZoomState = { left: 1000, right: 3000 }; + const result = computeZoomedInData({ data: [], zoom }); + expect(result).toEqual([]); + }); + + it('should filter data based on zoom range', () => { + const zoom: ZoomState = { left: 2000, right: 4000 }; + const result = computeZoomedInData({ data: mockData, zoom }); + expect(result).toHaveLength(3); + expect(result[0].timestamp).toBe(2000); + expect(result[2].timestamp).toBe(4000); + }); + + it('should handle zoom with dataMin as left', () => { + const zoom: ZoomState = { left: 'dataMin', right: 3000 }; + const result = computeZoomedInData({ data: mockData, zoom }); + expect(result).toHaveLength(3); + expect(result[0].timestamp).toBe(1000); + expect(result[2].timestamp).toBe(3000); + }); + + it('should handle zoom with dataMax as right', () => { + const zoom: ZoomState = { left: 3000, right: 'dataMax' }; + const result = computeZoomedInData({ data: mockData, zoom }); + expect(result).toHaveLength(3); + expect(result[0].timestamp).toBe(3000); + expect(result[2].timestamp).toBe(5000); + }); + + it('should return empty array when left is greater than right', () => { + const zoom: ZoomState = { left: 4000, right: 2000 }; + const result = computeZoomedInData({ data: mockData, zoom }); + expect(result).toEqual([]); + }); +}); + +describe('getMetricsFromDimensionData', () => { + it('should return zeros for empty data', () => { + const result = getMetricsFromDimensionData([]); + expect(result).toEqual({ + average: 0, + last: 0, + length: 0, + max: 0, + total: 0, + }); + }); + + it('should calculate metrics correctly for valid data', () => { + const data = [10, 20, 30, 40, 50]; + const result = getMetricsFromDimensionData(data); + expect(result).toEqual({ + average: 30, + last: 50, + length: 5, + max: 50, + total: 150, + }); + }); + + it('should handle single value', () => { + const data = [42]; + const result = getMetricsFromDimensionData(data); + expect(result).toEqual({ + average: 42, + last: 42, + length: 1, + max: 42, + total: 42, + }); + }); + + it('should ignore NaN values', () => { + const data = [10, NaN, 30, NaN, 50]; + const result = getMetricsFromDimensionData(data); + expect(result.total).toBe(90); + expect(result.max).toBe(50); + }); + it('should return 0 as last when last value is NaN', () => { + const data = [10, 20, NaN]; + const result = getMetricsFromDimensionData(data); + + expect(result.last).toBe(0); + }); +}); + +describe('computeLegendRowsBasedOnData', () => { + const mockData: DataSet[] = [ + { timestamp: 1000, cpu: 10, memory: 20 }, + { timestamp: 2000, cpu: 15, memory: 25 }, + { timestamp: 3000, cpu: 20, memory: 30 }, + ]; + const failMessage = 'Result should not be undefined'; + + const mockLegendRows: MetricsDisplayRow[] = [ + { + legendTitle: 'cpu', + legendColor: 'blue', + data: { average: 0, last: 0, length: 0, max: 0, total: 0 }, + format: (value: number) => formatToolTip(value, 'MB'), + }, + { + legendTitle: 'memory', + legendColor: 'red', + data: { average: 0, last: 0, length: 0, max: 0, total: 0 }, + format: (value: number) => formatToolTip(value, 'MB'), + }, + ]; + + it('should return undefined when legendRows is undefined', () => { + const zoom: ZoomState = { left: 'dataMin', right: 'dataMax' }; + const result = computeLegendRowsBasedOnData({ + zoom, + data: mockData, + }); + expect(result).toBeUndefined(); + }); + + it('should return undefined when data is empty', () => { + const zoom: ZoomState = { left: 'dataMin', right: 'dataMax' }; + const result = computeLegendRowsBasedOnData({ + zoom, + data: [], + }); + expect(result).toBeUndefined(); + }); + + it('should return original rows when not zoomed', () => { + const zoom: ZoomState = { left: 'dataMin', right: 'dataMax' }; + const result = computeLegendRowsBasedOnData({ + zoom, + data: mockData, + legendRows: mockLegendRows, + }); + expect(result).toEqual(mockLegendRows); + }); + + it('should compute metrics based on zoomed data', () => { + const zoom: ZoomState = { left: 2000, right: 3000 }; + const result = computeLegendRowsBasedOnData({ + zoom, + data: mockData, + legendRows: mockLegendRows, + }); + + if (result) { + expect(result).toHaveLength(2); + expect(result[0].legendTitle).toBe('cpu'); + expect(result[0].data.total).toBe(35); + expect(result[0].data.max).toBe(20); + expect(result[0].data.last).toBe(20); + expect(result[1].legendTitle).toBe('memory'); + expect(result[1].data.total).toBe(55); + expect(result[1].data.average).toBe(27.5); + expect(result[1].data.last).toBe(30); + } else { + expect.fail(failMessage); + } + }); + + it('should preserve legend colors and titles', () => { + const zoom: ZoomState = { left: 1000, right: 2000 }; + const result = computeLegendRowsBasedOnData({ + zoom, + data: mockData, + legendRows: mockLegendRows, + }); + + if (!result) { + expect.fail(failMessage); + } + + expect(result[0].legendColor).toBe('blue'); + expect(result[1].legendColor).toBe('red'); + }); + + it('should handle missing values in data', () => { + const dataWithMissing: DataSet[] = [ + { timestamp: 1000, cpu: 10 }, + { timestamp: 2000, memory: 25 }, + ]; + const zoom: ZoomState = { left: 1000, right: 2000 }; + const result = computeLegendRowsBasedOnData({ + zoom, + data: dataWithMissing, + legendRows: mockLegendRows, + }); + + if (!result) { + expect.fail(failMessage); + } + + expect(result[0].data.total).toBe(10); + expect(result[1].data.total).toBe(25); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Utils/CloudPulseZoomInUtils.ts b/packages/manager/src/features/CloudPulse/Utils/CloudPulseZoomInUtils.ts new file mode 100644 index 00000000000..e9527fc97a6 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Utils/CloudPulseZoomInUtils.ts @@ -0,0 +1,141 @@ +import { type Metrics, roundTo } from '@linode/utilities'; + +import { humanizeLargeData } from './utils'; + +import type { ZoomState } from '../Widget/components/useZoomController'; +import type { DataSet } from 'src/components/AreaChart/AreaChart'; +import type { MetricsDisplayRow } from 'src/components/LineGraph/MetricsDisplay'; + +interface ZoomStateData { + /** + * The data to be processed according to the zoom state + */ + data: DataSet[]; + /** + * Indicates if the unit is humanizable + */ + isHumanizableUnit?: boolean; + /** + * The legend rows to be processed according to zoom state + */ + legendRows?: MetricsDisplayRow[]; + + /** + * The unit of measurement for formatting + */ + unit?: string; + + /** + * The current zoom state + */ + zoom: ZoomState; +} + +/** + * @param data The data for which to compute the zoomed-in subset + * @param zoom The current zoom state + * @returns The subset of data that falls within the zoomed-in range + */ +export const computeZoomedInData = ({ + data, + zoom, +}: ZoomStateData): DataSet[] => { + if (!data || data.length === 0) { + return data; + } + if (zoom.left === 'dataMin' && zoom.right === 'dataMax') { + return data; + } + + const minZoom = zoom.left === 'dataMin' ? data[0].timestamp : zoom.left; // left zoom boundary + const maxZoom = + zoom.right === 'dataMax' ? data[data.length - 1].timestamp : zoom.right; // right zoom boundary + return data.filter( + ({ timestamp }) => timestamp >= minZoom && timestamp <= maxZoom + ); +}; + +/** + * @param zoom The current zoom state + * @param data The data to compute legend rows from + * @param legendRows The original legend rows + * @returns The computed legend rows based on the zoomed-in data + */ +export const computeLegendRowsBasedOnData = ({ + data, + zoom, + legendRows, + unit, + isHumanizableUnit, +}: ZoomStateData) => { + if (!legendRows || !data || !data.length) return undefined; + + // If not zoomed, return original rows unchanged + if (zoom.left === 'dataMin' && zoom.right === 'dataMax') { + return legendRows; + } + + const minZoom = zoom.left === 'dataMin' ? data[0].timestamp : zoom.left; // left zoom boundary + const maxZoom = + zoom.right === 'dataMax' ? data[data.length - 1].timestamp : zoom.right; // right zoom boundary + + return legendRows.map((legendRow) => { + const values: number[] = []; + + for (const dataRow of data) { + const value = dataRow[legendRow.legendTitle]; + if ( + typeof value === 'number' && + !Number.isNaN(value) && + dataRow.timestamp >= minZoom && + dataRow.timestamp <= maxZoom + ) { + values.push(value); + } + } + + return { + ...legendRow, + format: isHumanizableUnit + ? (value: number) => `${humanizeLargeData(value)} ${unit}` // continue to humanize values + : (value: number) => `${roundTo(value)} ${unit}`, // only round the values, units and values are already scaled up + data: getMetricsFromDimensionData(values), + }; + }); +}; + +/** + * @param data The data of the current dimension + * @returns The max, avg, last, length, total from the data + */ +export const getMetricsFromDimensionData = (data: number[]): Metrics => { + // If there's no data + if (!data || !Array.isArray(data) || data.length < 1) { + return { average: 0, last: 0, length: 0, max: 0, total: 0 }; + } + + let max = 0; + let sum = 0; + + // The data is large, so we get everything we need in one iteration + data.forEach((value): void => { + if (value === null || value === undefined || Number.isNaN(value)) { + return; + } + + if (value > max) { + max = value; + } + + sum += value; + }); + + const length = data.length; + + // Safeguard against dividing by 0 + const average = length > 0 ? sum / length : 0; + + const last = data[length - 1] || 0; + + return { average, last, length, max, total: sum }; +}; diff --git a/packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts b/packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts index 9ab7b01b533..ef456d70de2 100644 --- a/packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts +++ b/packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts @@ -560,6 +560,103 @@ export const LKE_CONFIG: Readonly = { ], serviceType: 'lke', }; +export const NETLOADBALANCER_CONFIG: Readonly = + { + capability: capabilityServiceTypeMapping['netloadbalancer'], + filters: [ + { + configuration: { + filterKey: 'region', + children: ['resource_id'], + filterType: 'string', + isFilterable: false, + isMetricsFilter: false, + name: 'Region', + priority: 1, + neededInViews: [CloudPulseAvailableViews.central], + }, + name: 'Region', + }, + { + configuration: { + dependency: ['region'], + filterKey: 'resource_id', + filterType: 'string', + isFilterable: true, + isMetricsFilter: true, + isMultiSelect: true, + name: 'Network Load Balancers', + neededInViews: [CloudPulseAvailableViews.central], + placeholder: 'Select Network Load Balancers', + priority: 2, + }, + name: 'Network Load Balancers', + }, + { + configuration: { + filterKey: 'ip_version', + filterType: 'string', + isFilterable: true, + isMetricsFilter: false, + isOptional: true, + isMultiSelect: true, + name: 'IP Versions', + neededInViews: [ + CloudPulseAvailableViews.central, + CloudPulseAvailableViews.service, + ], + options: [ + { + id: 'v6', + label: 'IPv6', + }, + { + id: 'v4', + label: 'IPv4', + }, + ], + placeholder: 'Select IP Versions', + priority: 2, + type: CloudPulseSelectTypes.static, + dimensionKey: 'ip_version', + }, + name: 'IP Versions', + }, + { + configuration: { + filterKey: 'port', + filterType: 'string', + isFilterable: true, + isMetricsFilter: false, + isOptional: true, + name: 'Ports', + dimensionKey: 'port', + neededInViews: [ + CloudPulseAvailableViews.central, + CloudPulseAvailableViews.service, + ], + placeholder: 'e.g., 80,443,3000', + priority: 4, + }, + name: 'Ports', + }, + { + configuration: { + filterKey: 'relative_time_duration', + filterType: 'string', + isFilterable: true, + isMetricsFilter: true, + isMultiSelect: false, + name: TIME_DURATION, + neededInViews: [], // we will have a static time duration component, no need render from filter builder + placeholder: 'Select a Duration', + priority: 4, + }, + name: TIME_DURATION, + }, + ], + serviceType: 'netloadbalancer', + }; export const FILTER_CONFIG: Readonly< Map > = new Map([ @@ -567,6 +664,7 @@ export const FILTER_CONFIG: Readonly< [2, LINODE_CONFIG], [3, NODEBALANCER_CONFIG], [4, FIREWALL_CONFIG], + [5, NETLOADBALANCER_CONFIG], [6, OBJECTSTORAGE_CONFIG_BUCKET], [7, BLOCKSTORAGE_CONFIG], [8, FIREWALL_NODEBALANCER_CONFIG], diff --git a/packages/manager/src/features/CloudPulse/Utils/constants.ts b/packages/manager/src/features/CloudPulse/Utils/constants.ts index 2e830288e4d..c0f95fff64c 100644 --- a/packages/manager/src/features/CloudPulse/Utils/constants.ts +++ b/packages/manager/src/features/CloudPulse/Utils/constants.ts @@ -103,6 +103,7 @@ export const NO_REGION_MESSAGE: Record = { 7: 'No volumes configured in any regions.', 8: 'No firewalls configured in any Nodebalancer regions.', 9: 'No LKE clusters configured in any regions.', + 5: 'No Network Load Balancers configured in any regions.', }; export const HELPER_TEXT: Record = { diff --git a/packages/manager/src/features/CloudPulse/Utils/models.ts b/packages/manager/src/features/CloudPulse/Utils/models.ts index 248210d1f9c..4252612aff5 100644 --- a/packages/manager/src/features/CloudPulse/Utils/models.ts +++ b/packages/manager/src/features/CloudPulse/Utils/models.ts @@ -9,6 +9,7 @@ import type { Firewall, KubernetesCluster, Linode, + NetworkLoadBalancer, NodeBalancer, ObjectStorageBucket, Volume, @@ -64,6 +65,7 @@ export type QueryFunctionType = | Firewall[] | KubernetesCluster[] | Linode[] + | NetworkLoadBalancer[] | NodeBalancer[] | ObjectStorageBucket[] | Volume[]; diff --git a/packages/manager/src/features/CloudPulse/Utils/unitConversion.test.ts b/packages/manager/src/features/CloudPulse/Utils/unitConversion.test.ts index a38b17a929a..114e5f19566 100644 --- a/packages/manager/src/features/CloudPulse/Utils/unitConversion.test.ts +++ b/packages/manager/src/features/CloudPulse/Utils/unitConversion.test.ts @@ -22,18 +22,18 @@ describe('Unit conversion', () => { it('should roll up value based on unit', () => { expect(convertValueToUnit(2048, 'KB')).toBe(2); - expect(convertValueToUnit(3000000, 'Mb')).toBe(3); + expect(convertValueToUnit(3000000, 'Mb')).toBe(2.86); expect(convertValueToUnit(60000, 'min')).toBe(1); }); it('should generate a tooltip', () => { - expect(formatToolTip(1000, 'b')).toBe('1 Kb'); + expect(formatToolTip(1024, 'b')).toBe('1 Kb'); expect(formatToolTip(2048, 'B')).toBe('2 KB'); expect(formatToolTip(1000, 'ms')).toBe('1 s'); }); it('should generate maximum unit based on the base unit & value', () => { - expect(generateUnitByBaseUnit(1000000, 'b')).toBe('Mb'); + expect(generateUnitByBaseUnit(1000000, 'b')).toBe('Kb'); expect(generateUnitByBaseUnit(2048, 'B')).toBe('KB'); expect(generateUnitByBaseUnit(60001, 'ms')).toBe('min'); }); diff --git a/packages/manager/src/features/CloudPulse/Utils/unitConversion.ts b/packages/manager/src/features/CloudPulse/Utils/unitConversion.ts index 06d4687027d..c8b8687a8b6 100644 --- a/packages/manager/src/features/CloudPulse/Utils/unitConversion.ts +++ b/packages/manager/src/features/CloudPulse/Utils/unitConversion.ts @@ -46,24 +46,24 @@ const multiplier: { [label: string]: number } = { Bps: 1, GB: Math.pow(2, 30), GBps: Math.pow(2, 30), - Gb: 1e9, - Gbps: 1e9, + Gb: Math.pow(2, 30), + Gbps: Math.pow(2, 30), KB: Math.pow(2, 10), KBps: Math.pow(2, 10), - Kb: 1e3, - Kbps: 1e3, + Kb: Math.pow(2, 10), + Kbps: Math.pow(2, 10), MB: Math.pow(2, 20), MBps: Math.pow(2, 20), - Mb: 1e6, - Mbps: 1e6, + Mb: Math.pow(2, 20), + Mbps: Math.pow(2, 20), PB: Math.pow(2, 50), PBps: Math.pow(2, 50), - Pb: 1e15, - Pbps: 1e15, + Pb: Math.pow(2, 50), + Pbps: Math.pow(2, 50), TB: Math.pow(2, 40), TBps: Math.pow(2, 40), - Tb: 1e12, - Tbps: 1e12, + Tb: Math.pow(2, 40), + Tbps: Math.pow(2, 40), b: 1, bps: 1, d: 86400000, diff --git a/packages/manager/src/features/CloudPulse/Utils/utils.ts b/packages/manager/src/features/CloudPulse/Utils/utils.ts index f5dcb3dde17..54e32ba7f59 100644 --- a/packages/manager/src/features/CloudPulse/Utils/utils.ts +++ b/packages/manager/src/features/CloudPulse/Utils/utils.ts @@ -694,5 +694,5 @@ export const humanizeLargeData = (value: number) => { if (value >= 1000) { return +(value / 1000).toFixed(1) + 'K'; } - return `${roundTo(value, 1)}`; + return `${roundTo(value, 2)}`; }; diff --git a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx index de7a0b3ef16..9f188c4978b 100644 --- a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx +++ b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx @@ -172,6 +172,7 @@ export const CloudPulseWidget = (props: CloudPulseWidgetProperties) => { const [groupBy, setGroupBy] = React.useState( props.widget.group_by ); + const [isZoomed, setIsZoomed] = React.useState(false); const theme = useTheme(); const { @@ -401,6 +402,10 @@ export const CloudPulseWidget = (props: CloudPulseWidgetProperties) => { }, [savePref, updatePreferences, widget.label] ); + + const handleZoomStateChange = React.useCallback((zoomed: boolean) => { + setIsZoomed(zoomed); + }, []); const { data: metricsList, error, @@ -428,6 +433,7 @@ export const CloudPulseWidget = (props: CloudPulseWidgetProperties) => { label: widget.label, timeStamp, url: flags.aclpReadEndpoint!, + shouldRefresh: !isZoomed, } ); let data: DataSet[] = []; @@ -462,6 +468,19 @@ export const CloudPulseWidget = (props: CloudPulseWidgetProperties) => { const hours = end.diff(start, 'hours').hours; const tickFormat = hours <= 24 ? 'hh:mm a' : 'LLL dd'; + const zoomResetKey = React.useMemo(() => { + const { preset, start, end, timeZone } = props.duration; + + if (preset) { + return `preset:${preset}`; + } + if (!start || !end || !timeZone) { + return 'custom:missing-params'; + } + + return `custom:${start},${end},${timeZone}`; + }, [props.duration]); + React.useEffect(() => { if ( filteredSelections.length !== (dimensionFilters?.length ?? 0) && @@ -585,12 +604,16 @@ export const CloudPulseWidget = (props: CloudPulseWidgetProperties) => { metricsApiCallError === jweTokenExpiryError || isJweTokenFetching } // keep loading until we are trying to fetch the refresh token + onZoomChange={handleZoomStateChange} showDot showLegend={data.length !== 0} timezone={timezone} unit={`${currentUnit}${unit.endsWith('ps') ? '/s' : ''}`} variant={variant} xAxis={{ tickFormat, tickGap: 60 }} + zoomResetKey={ + zoomResetKey // key to reset zoom when duration changes + } />
diff --git a/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseLineGraph.test.tsx b/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseLineGraph.test.tsx index 0e5cd7ab381..1957e880c64 100644 --- a/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseLineGraph.test.tsx +++ b/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseLineGraph.test.tsx @@ -33,12 +33,14 @@ class ResizeObserver { unobserve() {} } +const zoomResetKey = 'test-zoom'; + describe('CloudPulseLineGraph', () => { window.ResizeObserver = ResizeObserver; it('should render AreaChart when data is provided', () => { const { container, getByRole } = renderWithTheme( - + ); const table = getByRole('table'); @@ -53,7 +55,11 @@ describe('CloudPulseLineGraph', () => { it('should show error state', () => { const { getByText } = renderWithTheme( - + ); expect(getByText('Test error')).toBeInTheDocument(); @@ -66,7 +72,7 @@ describe('CloudPulseLineGraph', () => { }; const { getByText } = renderWithTheme( - + ); expect(getByText('No data to display')).toBeInTheDocument(); diff --git a/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseLineGraph.tsx b/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseLineGraph.tsx index 4fe33e746e9..118aa38e028 100644 --- a/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseLineGraph.tsx +++ b/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseLineGraph.tsx @@ -1,4 +1,4 @@ -import { CircleProgress, ErrorState, Typography } from '@linode/ui'; +import { Button, CircleProgress, ErrorState, Typography } from '@linode/ui'; import { roundTo } from '@linode/utilities'; import { Box, useMediaQuery, useTheme } from '@mui/material'; import * as React from 'react'; @@ -6,17 +6,38 @@ import * as React from 'react'; import { AreaChart } from 'src/components/AreaChart/AreaChart'; import { useFlags } from 'src/hooks/useFlags'; +import { + computeLegendRowsBasedOnData, + computeZoomedInData, +} from '../../Utils/CloudPulseZoomInUtils'; import { humanizeLargeData } from '../../Utils/utils'; +import { useZoomController } from './useZoomController'; -import type { AreaChartProps } from 'src/components/AreaChart/AreaChart'; +import type { + AreaChartProps, + DataSet, +} from 'src/components/AreaChart/AreaChart'; export interface CloudPulseLineGraph extends AreaChartProps { + data: DataSet[]; error?: string; loading?: boolean; + onZoomChange?: (isZoomed: boolean) => void; + zoomResetKey: string; } export const CloudPulseLineGraph = React.memo((props: CloudPulseLineGraph) => { - const { error, loading, unit, ...rest } = props; + const { + error, + loading, + unit, + data, + legendRows, + zoomResetKey, + onZoomChange, + showLegend, + ...rest + } = props; const flags = useFlags(); const theme = useTheme(); @@ -24,6 +45,53 @@ export const CloudPulseLineGraph = React.memo((props: CloudPulseLineGraph) => { // to reduce the x-axis tick count for small screen const isSmallScreen = useMediaQuery(theme.breakpoints.down('sm')); + const isHumanizableUnit = + flags.aclp?.humanizableUnits?.some( + (unitElement) => unitElement.toLowerCase() === unit.toLowerCase() + ) ?? false; + + const isZoomEnabled = flags.aclp?.enableZoomInCharts ?? false; // default to false + + const { + zoom, + isZoomed, + zoomOut: resetZoom, + zoomCallbacks, + } = useZoomController(zoomResetKey); + + const zoomedData = React.useMemo(() => { + if (!isZoomEnabled) { + return data; + } + return computeZoomedInData({ data, zoom }); + }, [data, zoom, isZoomEnabled]); + + const zoomedLegendRows = React.useMemo(() => { + if (!isZoomEnabled) { + return legendRows; + } + return computeLegendRowsBasedOnData({ + zoom, + data: zoomedData, + legendRows, + unit: props.unit, + isHumanizableUnit, + }); + }, [ + isHumanizableUnit, + isZoomEnabled, + legendRows, + props.unit, + zoom, + zoomedData, + ]); + + React.useEffect(() => { + if (onZoomChange) { + onZoomChange(isZoomed); + } + }, [isZoomed, onZoomChange]); + if (loading) { return ; } @@ -33,10 +101,6 @@ export const CloudPulseLineGraph = React.memo((props: CloudPulseLineGraph) => { } const noDataMessage = 'No data to display'; - const isHumanizableUnit = - flags.aclp?.humanizableUnits?.some( - (unitElement) => unitElement.toLowerCase() === unit.toLowerCase() - ) ?? false; return ( { ) : ( - `${humanizeLargeData(value)} ${unit}` - : undefined - } - unit={unit} - xAxisTickCount={ - isSmallScreen ? undefined : Math.min(rest.data.length, 7) - } - yAxisProps={ - isHumanizableUnit - ? { - tickFormat: (value: number) => `${humanizeLargeData(value)}`, - } - : { - tickFormat: (value: number) => `${roundTo(value, 3)}`, - } - } - /> + + {isZoomed && ( + + )} + 0 ? showLegend : false} + tooltipCustomValueFormatter={ + isHumanizableUnit + ? (value, unit) => `${humanizeLargeData(value)} ${unit}` + : undefined + } + unit={unit} + xAxisTickCount={ + isSmallScreen ? undefined : Math.min(zoomedData.length, 7) + } + yAxisProps={ + isHumanizableUnit + ? { + tickFormat: (value: number) => + `${humanizeLargeData(value)}`, + } + : { + tickFormat: (value: number) => `${roundTo(value, 3)}`, + } + } + zoomCallbacks={isZoomEnabled ? zoomCallbacks : undefined} + /> + )} - {rest.data.length === 0 && ( + {zoomedData.length === 0 && ( (); + const { control, resetField } = + useFormContext(); const dataFieldOptions = React.useMemo( () => @@ -86,17 +87,14 @@ export const CloudPulseDimensionFilterFields = React.memo( value: null, }; if (operation === 'selectOption') { - setValue(`${name}.dimension_label`, selected.value, { - shouldValidate: true, - shouldDirty: true, + resetField(name, { + defaultValue: { ...fieldValue, dimension_label: selected.value }, }); - setValue(`${name}.operator`, fieldValue.operator); - setValue(`${name}.value`, fieldValue.value); } else { - setValue(name, fieldValue); + resetField(name, { defaultValue: fieldValue }); } }, - [name, setValue] + [name, resetField] ); const dimensionFieldWatcher = useWatch({ @@ -179,7 +177,7 @@ export const CloudPulseDimensionFilterFields = React.memo( field.onChange( operation === 'selectOption' ? newValue.value : null ); - setValue(`${name}.value`, null); + resetField(`${name}.value`, { defaultValue: null }); }} options={dimensionOperatorOptions} placeholder="Select an Operator" diff --git a/packages/manager/src/features/CloudPulse/Widget/components/DimensionFilters/CloudPulseDimensionFilterRenderer.tsx b/packages/manager/src/features/CloudPulse/Widget/components/DimensionFilters/CloudPulseDimensionFilterRenderer.tsx index 42d7d6d13d3..00fb29c7632 100644 --- a/packages/manager/src/features/CloudPulse/Widget/components/DimensionFilters/CloudPulseDimensionFilterRenderer.tsx +++ b/packages/manager/src/features/CloudPulse/Widget/components/DimensionFilters/CloudPulseDimensionFilterRenderer.tsx @@ -8,6 +8,8 @@ import { useWatch, } from 'react-hook-form'; +import { useFlags } from 'src/hooks/useFlags'; + import { CloudPulseDimensionFilterFields } from './CloudPulseDimensionFilterFields'; import { metricDimensionFiltersSchema } from './schema'; @@ -83,6 +85,8 @@ export const CloudPulseDimensionFilterRenderer = React.memo( associatedEntityType, } = props; + const flags = useFlags(); + const formMethods = useForm({ defaultValues: { dimension_filters: @@ -92,6 +96,10 @@ export const CloudPulseDimensionFilterRenderer = React.memo( }, mode: 'onBlur', resolver: yupResolver(metricDimensionFiltersSchema), + context: { + maxDimensionFilterValues: + flags.aclpAlerting?.maxDimensionFiltersValues ?? undefined, + }, }); const { control, handleSubmit, formState, setValue, clearErrors } = formMethods; diff --git a/packages/manager/src/features/CloudPulse/Widget/components/useZoomController.test.ts b/packages/manager/src/features/CloudPulse/Widget/components/useZoomController.test.ts new file mode 100644 index 00000000000..c783502cbdc --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Widget/components/useZoomController.test.ts @@ -0,0 +1,139 @@ +import { act, renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useZoomController } from './useZoomController'; + +import type { CategoricalChartState } from 'recharts/types/chart/types'; + +describe('useZoomController', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it('should initialize with default zoom state', () => { + const { result } = renderHook(() => useZoomController('test-key')); + + expect(result.current.zoom).toEqual({ + left: 'dataMin', + right: 'dataMax', + }); + expect(result.current.isZoomed).toBe(false); + }); + it('should set refAreaLeft on mouse down with valid payload', () => { + const { result } = renderHook(() => useZoomController('test-key')); + const mockEvent: CategoricalChartState = { + activePayload: [{ payload: { timestamp: 1000 } }], + }; + act(() => { + result.current.zoomCallbacks.onMouseDown(mockEvent); + }); + expect(result.current.zoom.refAreaLeft).toBeUndefined(); + }); + it('should not set refAreaLeft on mouse down without payload', () => { + const { result } = renderHook(() => useZoomController('test-key')); + const mockEvent: CategoricalChartState = {}; + act(() => { + result.current.zoomCallbacks.onMouseDown(mockEvent); + }); + expect(result.current.zoom.refAreaLeft).toBeUndefined(); + }); + it('should update refAreaRight on mouse move', () => { + const { result } = renderHook(() => useZoomController('test-key')); + + act(() => { + result.current.zoomCallbacks.onMouseDown({ + activePayload: [{ payload: { timestamp: 1000 } }], + }); + }); + + act(() => { + result.current.zoomCallbacks.onMouseMove({ + activePayload: [{ payload: { timestamp: 2000 } }], + }); + }); + + expect(result.current.zoom.refAreaLeft).toBe(1000); + expect(result.current.zoom.refAreaRight).toBe(2000); + }); + it('should apply zoom on mouse up with valid drag', () => { + const { result } = renderHook(() => useZoomController('test-key')); + act(() => { + result.current.zoomCallbacks.onMouseDown({ + activePayload: [{ payload: { timestamp: 1000 } }], + }); + result.current.zoomCallbacks.onMouseMove({ + activePayload: [{ payload: { timestamp: 2000 } }], + }); + result.current.zoomCallbacks.onMouseUp(); + }); + expect(result.current.zoom.left).toBe(1000); + expect(result.current.zoom.right).toBe(2000); + expect(result.current.zoom.refAreaLeft).toBeUndefined(); + expect(result.current.zoom.refAreaRight).toBeUndefined(); + expect(result.current.isZoomed).toBe(true); + }); + it('should handle reverse drag (right to left)', () => { + const { result } = renderHook(() => useZoomController('test-key')); + act(() => { + result.current.zoomCallbacks.onMouseDown({ + activePayload: [{ payload: { timestamp: 2000 } }], + }); + result.current.zoomCallbacks.onMouseMove({ + activePayload: [{ payload: { timestamp: 1000 } }], + }); + result.current.zoomCallbacks.onMouseUp(); + }); + expect(result.current.zoom.left).toBe(1000); + expect(result.current.zoom.right).toBe(2000); + }); + it('should reset zoom on zoomOut', () => { + const { result } = renderHook(() => useZoomController('test-key')); + act(() => { + result.current.zoomCallbacks.onMouseDown({ + activePayload: [{ payload: { timestamp: 1000 } }], + }); + result.current.zoomCallbacks.onMouseMove({ + activePayload: [{ payload: { timestamp: 2000 } }], + }); + result.current.zoomCallbacks.onMouseUp(); + }); + act(() => { + result.current.zoomOut(); + }); + expect(result.current.zoom).toEqual({ + left: 'dataMin', + right: 'dataMax', + }); + expect(result.current.isZoomed).toBe(false); + }); + it('should reset zoom when zoomResetKey changes', () => { + const { result, rerender } = renderHook( + ({ key }) => useZoomController(key), + { initialProps: { key: 'key1' } } + ); + act(() => { + result.current.zoomCallbacks.onMouseDown({ + activePayload: [{ payload: { timestamp: 1000 } }], + }); + result.current.zoomCallbacks.onMouseMove({ + activePayload: [{ payload: { timestamp: 2000 } }], + }); + result.current.zoomCallbacks.onMouseUp(); + }); + rerender({ key: 'key2' }); + expect(result.current.zoom).toEqual({ + left: 'dataMin', + right: 'dataMax', + }); + }); + it('should clear refArea on mouse up without drag', () => { + const { result } = renderHook(() => useZoomController('test-key')); + act(() => { + result.current.zoomCallbacks.onMouseDown({ + activePayload: [{ payload: { timestamp: 1000 } }], + }); + result.current.zoomCallbacks.onMouseUp(); + }); + expect(result.current.zoom.refAreaLeft).toBeUndefined(); + expect(result.current.zoom.refAreaRight).toBeUndefined(); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Widget/components/useZoomController.ts b/packages/manager/src/features/CloudPulse/Widget/components/useZoomController.ts new file mode 100644 index 00000000000..de5c43925be --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Widget/components/useZoomController.ts @@ -0,0 +1,135 @@ +/** + * useZoomController + * + * A reusable hook to manage drag-to-zoom state for time-series charts. + * This hook is UI-agnostic and intended to be wired into CloudPulseLineGraph component + */ +import * as React from 'react'; + +import type { CategoricalChartState } from 'recharts/types/chart/types'; + +export type ZoomState = { + /** + * The left boundary of the zoomed area, can be 'dataMin' or a specific timestamp + */ + left: 'dataMin' | number; + /** + * The left boundary of the area being dragged for zooming, which will be cleared on mouse up + */ + refAreaLeft?: number; + /** + * The right boundary of the area being dragged for zooming, which will be cleared on mouse up + */ + refAreaRight?: number; + /** + * The right boundary of the zoomed area, can be 'dataMax' or a specific timestamp + */ + right: 'dataMax' | number; +}; + +// Initial zoom state covering the entire data range, with left and right set to dataMin and dataMax +const initialZoomState: ZoomState = { + left: 'dataMin', + right: 'dataMax', + refAreaLeft: undefined, + refAreaRight: undefined, +}; + +export const useZoomController = (zoomResetKey: string) => { + const [zoom, setZoom] = React.useState(initialZoomState); // Current zoom state (dataMin/dataMax when not zoomed) + + const dragStartRef = React.useRef(null); // Tracks the timestamp where the drag started + const isDraggingRef = React.useRef(false); // Tracks if dragging is in progress + + const onMouseDown = React.useCallback((e: CategoricalChartState) => { + const payload = e?.activePayload?.[0]?.payload; + if (payload?.timestamp === undefined) return; + + // set the drag start timestamp + dragStartRef.current = payload.timestamp; + isDraggingRef.current = false; + }, []); + + const onMouseMove = React.useCallback((e: CategoricalChartState) => { + const dragStart = dragStartRef.current; + if (dragStart === null) return; + + const payload = e?.activePayload?.[0]?.payload; + if (payload?.timestamp === undefined) return; + + if (!isDraggingRef.current) { + isDraggingRef.current = true; + setZoom((prev) => ({ + ...prev, + refAreaLeft: dragStart, + refAreaRight: payload.timestamp, // Set initial right to show drag + })); + return; + } + + setZoom((prev) => ({ + ...prev, + refAreaRight: payload.timestamp, // Set initial right to show drag + })); + }, []); + + const onMouseUp = React.useCallback(() => { + if (!isDraggingRef.current) { + dragStartRef.current = null; + return; + } + + isDraggingRef.current = false; // Reset dragging state on completion + + setZoom((prev) => { + if ( + prev.refAreaLeft === undefined || + prev.refAreaRight === undefined || + prev.refAreaLeft === prev.refAreaRight + ) { + return { + ...prev, + refAreaLeft: undefined, + refAreaRight: undefined, + }; + } + + const [from, to] = + prev.refAreaLeft < prev.refAreaRight + ? [prev.refAreaLeft, prev.refAreaRight] + : [prev.refAreaRight, prev.refAreaLeft]; // Handle reverse drag + + return { + ...prev, + left: from, + right: to, + refAreaLeft: undefined, + refAreaRight: undefined, + }; + }); + + dragStartRef.current = null; + }, []); + + const zoomOut = React.useCallback(() => { + setZoom(initialZoomState); // On zoom out, reset to initial state + }, []); + + // Reset when parent explicitly says so + React.useEffect(() => { + setZoom(initialZoomState); + }, [zoomResetKey]); // Here zoomResetKey is usually the timestamp selected from time range picker + + const isZoomed = zoom.left !== 'dataMin' || zoom.right !== 'dataMax'; + + return { + zoom, + isZoomed, + zoomOut, + zoomCallbacks: { + onMouseDown, + onMouseMove, + onMouseUp, + }, + }; +}; diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseCustomSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseCustomSelect.tsx index f1b391996b1..b468f7e38bb 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseCustomSelect.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseCustomSelect.tsx @@ -250,6 +250,7 @@ export const CloudPulseCustomSelect = React.memo( return ( option.label === value.label} diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseEndpointsSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseEndpointsSelect.tsx index a2837c587da..0ede3aa01e6 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseEndpointsSelect.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseEndpointsSelect.tsx @@ -238,6 +238,7 @@ export const CloudPulseEndpointsSelect = React.memo( aria-disabled={ hasRestrictedSelections ? isMaxSelectionsReached : false } + data-pendo-id={option.label} // Adding data-pendo-id for better tracking in Pendo analytics, using the option label as the identifier for the option element. data-qa-option key={key} > diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseFirewallNodebalancersSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseFirewallNodebalancersSelect.tsx index 9ab7b4b9e01..7a839abf9b1 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseFirewallNodebalancersSelect.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseFirewallNodebalancersSelect.tsx @@ -218,7 +218,12 @@ export const CloudPulseFirewallNodebalancersSelect = React.memo( : 'li'; return ( - + <> {option.label} diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.tsx index d4b6249b31b..8c46dcabe81 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.tsx @@ -143,6 +143,7 @@ export const CloudPulseResourcesSelect = React.memo( diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseTextFilter.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseTextFilter.tsx index 372340f5ec9..69c7c7bca32 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseTextFilter.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseTextFilter.tsx @@ -118,6 +118,7 @@ export const CloudPulseTextFilter = React.memo( return ( { name="private_network.vpc_id" render={({ field, fieldState }) => ( { name="private_network.subnet_id" render={({ field, fieldState }) => ( ({ + useDatabaseEngineConfig: vi.fn().mockReturnValue({}), + useDatabaseMutation: vi.fn().mockReturnValue({}), +})); + +const props = { + open: true, + onClose: vi.fn(), +}; + +vi.mock('@linode/queries', async () => { + const actual = await vi.importActual('@linode/queries'); + return { + ...actual, + useDatabaseEngineConfig: queryMocks.useDatabaseEngineConfig, + useDatabaseMutation: queryMocks.useDatabaseMutation, + }; +}); + +queryMocks.useDatabaseEngineConfig.mockReturnValue({ + data: postgresConfigResponse, +}); + +describe('DatabaseAdvancedConfigurationDrawer', () => { + it('should display an input section and description for each existing config', () => { + const database = databaseInstanceFactory.build({ + engine: 'postgresql', + }); + database.engine_config = { + pglookout: { + max_failover_replication_time_lag: 60, + }, + synchronous_replication: 'off', + }; + + renderWithThemeAndHookFormContext({ + component: ( + + ), + useFormOptions: { + defaultValues: { + configs: convertExistingConfigsToArray( + database.engine_config, + postgresConfigResponse + ), + }, + }, + }); + + const pglookoutLabel = screen.getByText( + 'pglookout.max_failover_replication_time_lag' + ); + const pglookoutDescription = screen.getByText( + 'Number of seconds of master unavailability before triggering database failover to standby' + ); + const synchronousReplicationLabel = screen.getByText( + 'synchronous_replication' + ); + const synchronousReplicationDescription = screen.getByText( + 'Synchronous replication type. Note that the service plan also needs to support synchronous replication.' + ); + expect(pglookoutLabel).toBeVisible(); + expect(pglookoutDescription).toBeVisible(); + expect(synchronousReplicationLabel).toBeVisible(); + expect(synchronousReplicationDescription).toBeVisible(); + }); + + it('should disable the save button until an option is updated', async () => { + const database = databaseInstanceFactory.build({ + engine: 'postgresql', + }); + database.engine_config = { synchronous_replication: 'off' }; + + renderWithThemeAndHookFormContext({ + component: ( + + ), + useFormOptions: { + defaultValues: { + configs: convertExistingConfigsToArray( + database.engine_config, + postgresConfigResponse + ), + }, + }, + }); + + const saveBtn = screen.getByRole('button', { name: 'Save' }); + expect(saveBtn).toBeDisabled(); + expect(saveBtn).toBeVisible(); + + const input = screen.getAllByRole('combobox')[1]; + expect(input).toHaveAttribute('value', 'off'); + + await userEvent.click(input); + const option = await screen.findByText('quorum'); + await userEvent.click(option); + expect(input).toHaveAttribute('value', 'quorum'); + expect(saveBtn).toBeEnabled(); + }); + + it('should display a badge if the option requires a restart and update the save button if the option is updated', async () => { + const database = databaseInstanceFactory.build({ + engine: 'postgresql', + }); + database.engine_config = { pg_stat_monitor_enable: true }; + + renderWithThemeAndHookFormContext({ + component: ( + + ), + useFormOptions: { + defaultValues: { + configs: convertExistingConfigsToArray( + database.engine_config, + postgresConfigResponse + ), + }, + }, + }); + + const restartBadge = screen.getByText('restarts service'); + expect(restartBadge).toBeVisible(); + + const toggle = screen.getByRole('checkbox'); + expect(toggle).toBeEnabled(); + expect(toggle).toBeChecked(); + + await userEvent.click(toggle); + expect(toggle).not.toBeChecked(); + + const saveAndRestartBtn = screen.getByRole('button', { + name: 'Save and Restart Service', + }); + expect(saveAndRestartBtn).toBeEnabled(); + expect(saveAndRestartBtn).toBeVisible(); + }); + + it('should display inline form errors', async () => { + const database = databaseInstanceFactory.build({ + engine: 'postgresql', + }); + database.cluster_size = 1; + database.engine_config = { synchronous_replication: 'off' }; + + window.HTMLElement.prototype.scrollIntoView = vi.fn(); + + renderWithThemeAndHookFormContext({ + component: ( + + ), + useFormOptions: { + defaultValues: { + configs: convertExistingConfigsToArray( + database.engine_config, + postgresConfigResponse + ), + }, + }, + }); + + queryMocks.useDatabaseMutation.mockReturnValue({ + mutateAsync: vi.fn().mockRejectedValue([ + { + field: 'engine_config.synchronous_replication', + reason: + 'synchronous_replication is only supported for clusters with 3 nodes', + }, + ]), + }); + + const saveBtn = screen.getByRole('button', { name: 'Save' }); + expect(saveBtn).toBeDisabled(); + expect(saveBtn).toBeVisible(); + + const input = screen.getAllByRole('combobox')[1]; + await userEvent.click(input); + + const option = await screen.findByText('quorum'); + await userEvent.click(option); + expect(input).toHaveAttribute('value', 'quorum'); + await userEvent.click(saveBtn); + + const error = screen.getByText( + 'synchronous_replication is only supported for clusters with 3 nodes' + ); + expect(error).toBeVisible(); + }); +}); diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseAdvancedConfiguration/DatabaseAdvancedConfigurationDrawer.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseAdvancedConfiguration/DatabaseAdvancedConfigurationDrawer.tsx index a048aa4cfe4..033206afd43 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseAdvancedConfiguration/DatabaseAdvancedConfigurationDrawer.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseAdvancedConfiguration/DatabaseAdvancedConfigurationDrawer.tsx @@ -15,7 +15,7 @@ import Grid from '@mui/material/Grid'; import { Button } from 'akamai-cds-react-components'; import { enqueueSnackbar } from 'notistack'; import React, { useEffect, useMemo, useState } from 'react'; -import { Controller, useFieldArray, useForm } from 'react-hook-form'; +import { Controller, get, useFieldArray, useForm } from 'react-hook-form'; import type { SubmitHandler } from 'react-hook-form'; import { Link } from 'src/components/Link'; @@ -31,14 +31,12 @@ import { convertExistingConfigsToArray, findConfigItem, formatConfigPayload, - getConfigAPIError, getDefaultConfigValue, hasRestartCluster, } from './utilities'; import type { ConfigurationOption } from './DatabaseConfigurationSelect'; import type { - APIError, Database, DatabaseInstance, UpdateDatabasePayload, @@ -51,7 +49,7 @@ interface Props { open: boolean; } -interface FormValues { +interface Configs { configs: ConfigurationOption[]; } @@ -61,9 +59,6 @@ export const DatabaseAdvancedConfigurationDrawer = (props: Props) => { const [selectedConfig, setSelectedConfig] = useState(null); - const [updateDatabaseError, setUpdateDatabaseError] = useState< - APIError[] | null - >(null); const formContainerRef = React.useRef(null); const { isPending: isUpdating, mutateAsync: updateDatabase } = @@ -83,17 +78,16 @@ export const DatabaseAdvancedConfigurationDrawer = (props: Props) => { const { control, - formState: { isDirty }, + formState: { isDirty, errors }, handleSubmit, + setError, reset, watch, - } = useForm({ + } = useForm({ defaultValues: { configs: existingConfigurations }, mode: 'onBlur', resolver: yupResolver( - createDynamicAdvancedConfigSchema( - configurations - ) as ObjectSchema + createDynamicAdvancedConfigSchema(configurations) as ObjectSchema ), }); @@ -102,14 +96,14 @@ export const DatabaseAdvancedConfigurationDrawer = (props: Props) => { name: 'configs', }); - const configs = watch('configs'); - useEffect(() => { if (existingConfigurations.length > 0 || open) { reset({ configs: existingConfigurations }); } }, [existingConfigurations, open]); + const configs = watch('configs'); + const usedConfigs = useMemo( () => new Set(fields.map((config) => config.label)), [fields] @@ -140,11 +134,11 @@ export const DatabaseAdvancedConfigurationDrawer = (props: Props) => { const handleClose = () => { reset(); - setUpdateDatabaseError(null); setSelectedConfig(null); onClose(); }; - const onSubmit: SubmitHandler = async (formData) => { + + const onSubmit: SubmitHandler = async (formData) => { const payload: UpdateDatabasePayload = { engine_config: formatConfigPayload(formData.configs, configurations), }; @@ -155,8 +149,10 @@ export const DatabaseAdvancedConfigurationDrawer = (props: Props) => { variant: 'success', }); }) - .catch((error) => { - setUpdateDatabaseError(error); + .catch((errors) => { + for (const error of errors) { + setError(error?.field ?? 'root', { message: error.reason }); + } scrollErrorIntoViewV2(formContainerRef); }); }; @@ -164,9 +160,9 @@ export const DatabaseAdvancedConfigurationDrawer = (props: Props) => { return ( - {Boolean(updateDatabaseError) && !updateDatabaseError?.[0].field && ( + {errors.root?.message && ( - {updateDatabaseError?.[0].reason} + {errors.root.message} )} @@ -222,17 +218,18 @@ export const DatabaseAdvancedConfigurationDrawer = (props: Props) => { key={config.label} name={`configs.${index}.value`} render={({ field, fieldState }) => { + const configName = + config.category === 'other' + ? `engine_config.${config.label}` + : `engine_config.${config.category}.${config.label}`; return ( { - setUpdateDatabaseError(null); - field.onBlur(); - }} + onBlur={field.onBlur} onChange={field.onChange} onRemove={() => handleRemoveConfig(index)} /> diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseAdvancedConfiguration/utilities.test.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseAdvancedConfiguration/utilities.test.tsx index 62dd5d9b2ca..aa0c4205d5d 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseAdvancedConfiguration/utilities.test.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseAdvancedConfiguration/utilities.test.tsx @@ -6,13 +6,11 @@ import { findConfigItem, formatConfigPayload, formatConfigValue, - getConfigAPIError, getDefaultConfigValue, } from './utilities'; import type { ConfigurationOption } from './DatabaseConfigurationSelect'; import type { - APIError, DatabaseEngineConfig, DatabaseInstanceAdvancedConfig, } from '@linode/api-v4'; @@ -258,41 +256,3 @@ describe('getDefaultConfigValue', () => { expect(getDefaultConfigValue(config)).toBe(0); }); }); - -describe('getConfigAPIError', () => { - const mockConfig: ConfigurationOption = { - label: 'connect_timeout', - category: 'mysql', - value: 100, - type: 'number', - }; - - const mockErrors: APIError[] = [ - { - field: 'engine_config.mysql.connect_timeout', - reason: 'Invalid value for connect_timeout', - }, - { - field: 'engine_config.mysql.default_time_zone', - reason: 'Invalid value for default_time_zone', - }, - ]; - - it('should return the error reason if a matching error is found', () => { - const result = getConfigAPIError(mockConfig, mockErrors); - expect(result).toBe('Invalid value for connect_timeout'); - }); - - it('should return undefined if no matching error is found', () => { - const result = getConfigAPIError( - { ...mockConfig, label: 'non_existent_config' }, - mockErrors - ); - expect(result).toBeUndefined(); - }); - - it('should return undefined if updateDatabaseError is null', () => { - const result = getConfigAPIError(mockConfig, null); - expect(result).toBeUndefined(); - }); -}); diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseAdvancedConfiguration/utilities.ts b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseAdvancedConfiguration/utilities.ts index 5f8288b0b10..2781e03ac71 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseAdvancedConfiguration/utilities.ts +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseAdvancedConfiguration/utilities.ts @@ -1,6 +1,5 @@ import type { ConfigurationOption } from './DatabaseConfigurationSelect'; import type { - APIError, ConfigCategoryValues, ConfigurationItem, DatabaseEngineConfig, @@ -216,25 +215,6 @@ export const getDefaultConfigValue = (config: ConfigurationOption) => { : ''; }; -/** - * Finds the API error for a specific configuration. - */ -export const getConfigAPIError = ( - config: ConfigurationOption, - updateDatabaseError: APIError[] | null -): string | undefined => { - if (!updateDatabaseError || !Array.isArray(updateDatabaseError)) { - return undefined; - } - - const error = updateDatabaseError.find( - (error) => - error.field === `engine_config.${config.category}.${config.label}` - ); - - return error?.reason; -}; - /** * Determines if a restart is required based on dirty fields. */ diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.tsx index deae3d8f4da..0a676b3684c 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.tsx @@ -315,10 +315,12 @@ export const DatabaseBackups = () => { buttonType="primary" data-qa-settings-button="restore" disabled={ - versionOption === 'dateTime' && - (!date || !time || !!errors.time) + Boolean(unableToRestoreCopy) || + (versionOption === 'dateTime' && + (!date || !time || !!errors.time)) } onClick={() => setIsRestoreDialogOpen(true)} + tooltipText={unableToRestoreCopy} > Restore diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackupsDialog.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackupsDialog.tsx index f6112e22157..ffdeb40c111 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackupsDialog.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackupsDialog.tsx @@ -3,7 +3,6 @@ import { ActionsPanel, Dialog, Notice, Typography } from '@linode/ui'; import { useNavigate } from '@tanstack/react-router'; import { useSnackbar } from 'notistack'; import * as React from 'react'; -import { useState } from 'react'; import { useFormContext, useWatch } from 'react-hook-form'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; @@ -24,7 +23,6 @@ export const DatabaseBackupsDialog = (props: Props) => { const { database, onClose, open } = props; const navigate = useNavigate(); const { enqueueSnackbar } = useSnackbar(); - const [isRestoring, setIsRestoring] = useState(false); const { control } = useFormContext(); const [date, time, region] = useWatch({ @@ -34,19 +32,25 @@ export const DatabaseBackupsDialog = (props: Props) => { const formattedDate = toFormattedDate(date, time); - const { error, mutateAsync: restore } = useRestoreFromBackupMutation( - database.engine, - { - fork: toDatabaseFork(database.id, date, time), - region, - // Assign same VPC when forking to the same region, otherwise set VPC to null - private_network: - database.region === region ? database.private_network : null, - } - ); + const { + error, + mutateAsync: restore, + reset, + isPending, + } = useRestoreFromBackupMutation(database.engine, { + fork: toDatabaseFork(database.id, date, time), + region, + // Assign same VPC when forking to the same region, otherwise set VPC to null + private_network: + database.region === region ? database.private_network : null, + }); + + const _onClose = () => { + onClose(); + reset(); + }; const handleRestoreDatabase = () => { - setIsRestoring(true); restore().then((database: Database) => { navigate({ to: `/databases/$engine/$databaseId`, @@ -58,7 +62,7 @@ export const DatabaseBackupsDialog = (props: Props) => { enqueueSnackbar('Your database is being restored.', { variant: 'success', }); - onClose(); + _onClose(); }); }; @@ -67,11 +71,20 @@ export const DatabaseBackupsDialog = (props: Props) => { return ( + {error && ( + + )} {isClusterWithVPCAndForkingToDifferentRegion && ( // Show warning when forking a cluster with VPC to a different region The database cluster is currently assigned to a VPC. When you restore @@ -91,13 +104,13 @@ export const DatabaseBackupsDialog = (props: Props) => { primaryButtonProps={{ 'data-testid': 'submit', label: 'Restore', - loading: isRestoring, + loading: isPending, onClick: handleRestoreDatabase, }} secondaryButtonProps={{ 'data-testid': 'cancel', label: 'Cancel', - onClick: onClose, + onClick: _onClose, }} sx={{ display: 'flex', @@ -105,15 +118,6 @@ export const DatabaseBackupsDialog = (props: Props) => { paddingBottom: '0', }} /> - {error ? ( - - ) : null} ); }; diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseAddConnectionPoolDrawer.test.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseAddConnectionPoolDrawer.test.tsx new file mode 100644 index 00000000000..861bade28c2 --- /dev/null +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseAddConnectionPoolDrawer.test.tsx @@ -0,0 +1,144 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import * as React from 'react'; +import { describe, it } from 'vitest'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { DatabaseAddConnectionPoolDrawer } from './DatabaseAddConnectionPoolDrawer'; + +const mockProps = { + databaseId: 123, + onClose: vi.fn(), + open: true, +}; + +const poolLabel = 'Pool Label'; +const addPoolBtnText = 'Add Pool'; + +// Hoist query mocks +const queryMocks = vi.hoisted(() => { + return { + useCreateDatabaseConnectionPoolMutation: vi.fn(), + }; +}); + +vi.mock('@linode/queries', async () => { + const actual = await vi.importActual('@linode/queries'); + return { + ...actual, + useCreateDatabaseConnectionPoolMutation: + queryMocks.useCreateDatabaseConnectionPoolMutation, + }; +}); + +describe('DatabaseAddConnectionPoolDrawer Component', () => { + beforeEach(() => { + vi.resetAllMocks(); + queryMocks.useCreateDatabaseConnectionPoolMutation.mockReturnValue({}); + queryMocks.useCreateDatabaseConnectionPoolMutation.mockReturnValue({ + mutateAsync: vi.fn().mockResolvedValue({}), + isLoading: false, + reset: vi.fn(), + }); + }); + + it('Should render the drawer title', () => { + renderWithTheme(); + + const addPoolDrawerTitle = screen.getByText('Add a New Connection Pool'); + expect(addPoolDrawerTitle).toBeInTheDocument(); + }); + + it('Should submit expected payload with valid selection, then close the drawer', async () => { + const expectedPayloadValues = { + label: 'test-pool', + database: 'defaultdb', + size: 10, + mode: 'transaction', + username: null, // Test default 'Reuse inbound user' option which gets provided as null to the API + }; + renderWithTheme(); + // Fill out and submit the form + const poolLabelInput = screen.getByLabelText(poolLabel); + const addPoolBtn = screen.getByText(addPoolBtnText); + await userEvent.type(poolLabelInput, expectedPayloadValues.label); + await userEvent.click(addPoolBtn); + // Test that the mutation was called with expected payload + expect( + queryMocks.useCreateDatabaseConnectionPoolMutation().mutateAsync + ).toHaveBeenCalledExactlyOnceWith(expectedPayloadValues); + // Test that onClose was called to close the drawer + expect(mockProps.onClose).toHaveBeenCalled(); + }); + + it('Should show error notice on root error', async () => { + const mockErrorMessage = 'This is a root level error'; + queryMocks.useCreateDatabaseConnectionPoolMutation.mockReturnValue({ + mutateAsync: vi + .fn() + .mockRejectedValue([{ field: 'root', reason: mockErrorMessage }]), + isLoading: false, + reset: vi.fn(), + }); + + renderWithTheme(); + + // Fill out and submit the form + const poolLabelInput = screen.getByLabelText(poolLabel); + const addPoolBtn = screen.getByText(addPoolBtnText); + await userEvent.type(poolLabelInput, 'test-pool'); + await userEvent.click(addPoolBtn); + + // Check that the error notice is displayed + const errorNotice = await screen.findByText(mockErrorMessage); + expect(errorNotice).toBeInTheDocument(); + }); + + it('Should display inline errors', async () => { + const mockRejectedFieldErrorsMap = { + label: 'Label error message', + size: 'Size error message', + mode: 'Mode error message', + database: 'Database error message', + username: 'Username error message', + }; + queryMocks.useCreateDatabaseConnectionPoolMutation.mockReturnValue({ + mutateAsync: vi.fn().mockRejectedValue([ + { field: 'label', reason: mockRejectedFieldErrorsMap.label }, + { field: 'size', reason: mockRejectedFieldErrorsMap.size }, + { field: 'mode', reason: mockRejectedFieldErrorsMap.mode }, + { field: 'database', reason: mockRejectedFieldErrorsMap.database }, + { field: 'username', reason: mockRejectedFieldErrorsMap.username }, + ]), + isLoading: false, + reset: vi.fn(), + }); + + renderWithTheme(); + + // Fill out and submit the form + const poolLabelInput = screen.getByLabelText(poolLabel); + const addPoolBtn = screen.getByText(addPoolBtnText); + await userEvent.type(poolLabelInput, 'test-pool'); + await userEvent.click(addPoolBtn); + + // Check that inline errors are displayed + const labelError = await screen.findByText( + mockRejectedFieldErrorsMap.label + ); + const sizeError = await screen.findByText(mockRejectedFieldErrorsMap.size); + const modeError = await screen.findByText(mockRejectedFieldErrorsMap.mode); + const databaseError = await screen.findByText( + mockRejectedFieldErrorsMap.database + ); + const usernameError = await screen.findByText( + mockRejectedFieldErrorsMap.username + ); + expect(labelError).toBeInTheDocument(); + expect(sizeError).toBeInTheDocument(); + expect(modeError).toBeInTheDocument(); + expect(databaseError).toBeInTheDocument(); + expect(usernameError).toBeInTheDocument(); + }); +}); diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseAddConnectionPoolDrawer.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseAddConnectionPoolDrawer.tsx new file mode 100644 index 00000000000..8942b6f7997 --- /dev/null +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseAddConnectionPoolDrawer.tsx @@ -0,0 +1,235 @@ +import { yupResolver } from '@hookform/resolvers/yup'; +import { useCreateDatabaseConnectionPoolMutation } from '@linode/queries'; +import { + ActionsPanel, + Autocomplete, + Drawer, + Notice, + Stack, + TextField, + Typography, +} from '@linode/ui'; +import { createDatabaseConnectionPoolSchema } from '@linode/validation'; +import { useSnackbar } from 'notistack'; +import * as React from 'react'; +import { Controller, useForm, useWatch } from 'react-hook-form'; + +import { Link } from 'src/components/Link'; +import { + databaseNamesOptions, + defaultUsername, + poolModeOptions, + usernameOptions, +} from 'src/features/Databases/constants'; + +import { MANAGE_CONNECTION_POOLS_LEARN_MORE_LINK } from '../../constants'; + +import type { ConnectionPool } from '@linode/api-v4'; + +interface Props { + databaseId: number; + onClose: () => void; + open: boolean; +} + +export const DatabaseAddConnectionPoolDrawer = (props: Props) => { + const { databaseId, onClose, open } = props; + const { enqueueSnackbar } = useSnackbar(); + + const { + isPending: submitInProgress, + mutateAsync: createDatabaseConnectionPool, + reset: resetMutation, + } = useCreateDatabaseConnectionPoolMutation(databaseId); + + const { + control, + formState: { errors }, + handleSubmit, + reset, + setError, + } = useForm({ + defaultValues: { + database: 'defaultdb', + label: '', + mode: 'transaction', + size: 10, + username: defaultUsername, + }, + mode: 'onBlur', + resolver: yupResolver(createDatabaseConnectionPoolSchema), + }); + + const [mode, database, username] = useWatch({ + control, + name: ['mode', 'database', 'username'], + }); + + const handleOnClose = () => { + onClose(); + reset(); + resetMutation?.(); + }; + + const onSubmit = async (values: ConnectionPool) => { + const payload = { + ...values, + username: values.username === defaultUsername ? null : values.username, + }; // Provide inbound user as null in the API + + try { + await createDatabaseConnectionPool(payload); + enqueueSnackbar('Connection Pool added successfully.', { + variant: 'success', + }); + handleOnClose(); + } catch (errors) { + for (const error of errors) { + setError(error?.field ?? 'root', { message: error.reason }); + } + } + }; + + return ( + + {errors.root?.message && ( + + )} + + Add a PgBouncer connection pool to minimize the use of your server + resources.{' '} + Learn more. + + + + ( + { + field.onChange(e.target.value); + }} + onClear={() => field.onChange('')} + placeholder="Enter a pool label" + /> + )} + /> + + ( + { + field.onChange(option.value); + }} + options={databaseNamesOptions} + value={databaseNamesOptions.find( + (option) => option.value === database + )} + /> + )} + /> + + ( + { + field.onChange(option.value); + }} + options={poolModeOptions} + value={poolModeOptions.find((option) => option.value === mode)} + /> + )} + /> + + ( + { + const value = + e.target.value.length > 0 + ? Number(e.target.value) + : e.target.value; + field.onChange(value); + }} + style={{ width: '178px' }} + type="number" + /> + )} + /> + + ( + { + field.onChange(option.value); + }} + options={usernameOptions} + value={usernameOptions.find( + (option) => option.value === username + )} + /> + )} + /> + + + + + + ); +}; diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseConnectionPoolRow.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseConnectionPoolRow.tsx index 714b18bb197..38d88d7bd58 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseConnectionPoolRow.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseConnectionPoolRow.tsx @@ -14,6 +14,10 @@ interface Props { * Function called when the delete button in the Action Menu is pressed. */ onDelete: (pool: ConnectionPool) => void; + /** + * Function called when the edit button in the Action Menu is pressed. + */ + onEdit: (pool: ConnectionPool) => void; /** * Payment method type and data. */ @@ -21,12 +25,12 @@ interface Props { } export const DatabaseConnectionPoolRow = (props: Props) => { - const { pool, onDelete } = props; + const { pool, onDelete, onEdit } = props; const connectionPoolActions: Action[] = [ { - onClick: () => null, - title: 'Edit', // TODO: UIE-9395 Implement edit functionality + onClick: () => onEdit(pool), + title: 'Edit', }, { onClick: () => onDelete(pool), diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseConnectionPools.test.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseConnectionPools.test.tsx index c9341cd3254..d0f617f20e9 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseConnectionPools.test.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseConnectionPools.test.tsx @@ -41,7 +41,7 @@ vi.mock('@linode/queries', async () => { }; }); -describe('DatabaseManageNetworkingDrawer Component', () => { +describe('DatabaseConnectionPools Component', () => { beforeEach(() => { vi.resetAllMocks(); }); @@ -130,4 +130,24 @@ describe('DatabaseManageNetworkingDrawer Component', () => { const serviceURIText = screen.queryByText('Service URI'); expect(serviceURIText).not.toBeInTheDocument(); }); + + it('should disable the Add Pool button when the database cluster is not active', () => { + const provisioningDatabase = databaseFactory.build({ + platform: 'rdbms-default', + private_network: null, + engine: 'postgresql', + id: 1, + status: 'provisioning', + }); + queryMocks.useDatabaseConnectionPoolsQuery.mockReturnValue({ + data: makeResourcePage([]), + isLoading: false, + }); + + renderWithTheme( + + ); + const addPoolBtn = screen.getByRole('button'); + expect(addPoolBtn).toBeDisabled(); + }); }); diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseConnectionPools.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseConnectionPools.tsx index 74a3c3c4270..e1fb2238719 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseConnectionPools.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseConnectionPools.tsx @@ -19,19 +19,25 @@ import { } from 'akamai-cds-react-components/Table'; import React from 'react'; +import { Link } from 'src/components/Link'; import { MIN_PAGE_SIZE, PAGE_SIZES, } from 'src/components/PaginationFooter/PaginationFooter.constants'; -import { CONNECTION_POOL_LABEL_CELL_STYLES } from 'src/features/Databases/constants'; +import { + CONNECTION_POOL_LABEL_CELL_STYLES, + MANAGE_CONNECTION_POOLS_LEARN_MORE_LINK, +} from 'src/features/Databases/constants'; import { usePaginationV2 } from 'src/hooks/usePaginationV2'; import { makeSettingsItemStyles } from '../../shared.styles'; import { ServiceURI } from '../ServiceURI'; +import { DatabaseAddConnectionPoolDrawer } from './DatabaseAddConnectionPoolDrawer'; import { DatabaseConnectionPoolDeleteDialog } from './DatabaseConnectionPoolDeleteDialog'; import { DatabaseConnectionPoolRow } from './DatabaseConnectionPoolRow'; +import { DatabaseEditConnectionPoolDrawer } from './DatabaseEditConnectionPoolDrawer'; -import type { Database } from '@linode/api-v4'; +import type { ConnectionPool, Database } from '@linode/api-v4'; interface Props { database: Database; @@ -41,9 +47,13 @@ interface Props { export const DatabaseConnectionPools = ({ database }: Props) => { const { classes } = makeSettingsItemStyles(); const theme = useTheme(); + const isDatabaseInactive = database.status !== 'active'; const [deletePoolLabelSelection, setDeletePoolLabelSelection] = - React.useState(); + React.useState(null); + const [isAddPoolDrawerOpen, setIsAddPoolDrawerOpen] = React.useState(false); + const [editPoolSelection, setEditPoolSelection] = + React.useState(null); const pagination = usePaginationV2({ currentRoute: '/databases/$engine/$databaseId/networking', @@ -79,15 +89,23 @@ export const DatabaseConnectionPools = ({ database }: Props) => { Manage PgBouncer connection pools to minimize the use of your server - resources. + resources.{' '} + + Learn more. +
@@ -146,6 +164,7 @@ export const DatabaseConnectionPools = ({ database }: Props) => { setDeletePoolLabelSelection(pool.label)} + onEdit={() => setEditPoolSelection(pool)} pool={pool} /> )) @@ -179,6 +198,19 @@ export const DatabaseConnectionPools = ({ database }: Props) => { open={Boolean(deletePoolLabelSelection)} poolLabel={deletePoolLabelSelection ?? ''} /> + setIsAddPoolDrawerOpen(false)} + open={isAddPoolDrawerOpen} + /> + {editPoolSelection && ( + setEditPoolSelection(null)} + open={Boolean(editPoolSelection)} + pool={editPoolSelection} + /> + )} ); }; diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseEditConnectionPoolDrawer.test.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseEditConnectionPoolDrawer.test.tsx new file mode 100644 index 00000000000..caaf0c54d39 --- /dev/null +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseEditConnectionPoolDrawer.test.tsx @@ -0,0 +1,140 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import * as React from 'react'; +import { describe, it } from 'vitest'; + +import { databaseConnectionPoolFactory } from 'src/factories'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { DatabaseEditConnectionPoolDrawer } from './DatabaseEditConnectionPoolDrawer'; + +const mockProps = { + databaseId: 123, + onClose: vi.fn(), + open: true, + pool: databaseConnectionPoolFactory.build({ + label: 'test-pool', + mode: 'session', + size: 22, + username: 'akmadmin', + }), +}; + +// Hoist query mocks +const queryMocks = vi.hoisted(() => { + return { + useUpdateDatabaseConnectionPoolMutation: vi.fn(), + }; +}); + +vi.mock('@linode/queries', async () => { + const actual = await vi.importActual('@linode/queries'); + return { + ...actual, + useUpdateDatabaseConnectionPoolMutation: + queryMocks.useUpdateDatabaseConnectionPoolMutation, + }; +}); + +describe('DatabaseEditConnectionPoolDrawer Component', () => { + beforeEach(() => { + vi.resetAllMocks(); + queryMocks.useUpdateDatabaseConnectionPoolMutation.mockReturnValue({}); + queryMocks.useUpdateDatabaseConnectionPoolMutation.mockReturnValue({ + mutateAsync: vi.fn().mockResolvedValue({}), + isLoading: false, + reset: vi.fn(), + }); + }); + + it('Should render the drawer title, prefilled inputs, and actions', () => { + renderWithTheme(); + + const drawerTitle = screen.getByText('Edit Connection Pool'); + expect(drawerTitle).toBeInTheDocument(); + + const poolLabelInput = screen.getByLabelText('Pool Label'); + expect(poolLabelInput).toBeVisible(); + expect(poolLabelInput).toHaveValue('test-pool'); + // Label should not be editable + expect(poolLabelInput).not.toBeEnabled(); + + const databaseNameInput = screen.getByLabelText('Database Name'); + const poolModeInput = screen.getByLabelText('Pool Mode'); + const poolSizeInput = screen.getByLabelText('Pool Size'); + const usernameInput = screen.getByLabelText('Username'); + + expect(databaseNameInput).toBeVisible(); + expect(databaseNameInput).toHaveValue('defaultdb'); + + expect(poolModeInput).toBeVisible(); + expect(poolModeInput).toHaveValue('Session'); + + expect(poolSizeInput).toBeVisible(); + expect(poolSizeInput).toHaveValue(22); + + expect(usernameInput).toBeVisible(); + expect(usernameInput).toHaveValue('akmadmin'); + + const saveBtn = screen.getByText('Save'); + const cancelBtn = screen.getByText('Cancel'); + expect(saveBtn).toBeVisible(); + expect(cancelBtn).toBeVisible(); + }); + + it('Should show error notice on root error', async () => { + const mockErrorMessage = 'This is a root level error'; + queryMocks.useUpdateDatabaseConnectionPoolMutation.mockReturnValue({ + mutateAsync: vi + .fn() + .mockRejectedValue([{ field: 'root', reason: mockErrorMessage }]), + isLoading: false, + reset: vi.fn(), + }); + + renderWithTheme(); + + // Edit and submit the filled form + const poolModeSelect = screen.getByLabelText('Pool Mode'); + await userEvent.click(poolModeSelect); + await userEvent.click(screen.getByText('Statement')); + const saveBtn = screen.getByText('Save'); + await userEvent.click(saveBtn); + + // Check that the error notice is displayed + const errorNotice = screen.getByText(mockErrorMessage); + expect(errorNotice).toBeInTheDocument(); + }); + + it('Should display inline errors', async () => { + queryMocks.useUpdateDatabaseConnectionPoolMutation.mockReturnValue({ + mutateAsync: vi.fn().mockRejectedValue([ + { field: 'size', reason: 'Size error message' }, + { field: 'mode', reason: 'Mode error message' }, + { field: 'database', reason: 'Database error message' }, + { field: 'username', reason: 'Username error message' }, + ]), + isLoading: false, + reset: vi.fn(), + }); + + renderWithTheme(); + + // Edit and submit the filled form + const poolModeSelect = screen.getByLabelText('Pool Mode'); + await userEvent.click(poolModeSelect); + await userEvent.click(screen.getByText('Statement')); + const saveBtn = screen.getByText('Save'); + await userEvent.click(saveBtn); + + // Check that inline errors are displayed + const sizeError = screen.getByText('Size error message'); + const modeError = screen.getByText('Mode error message'); + const databaseError = screen.getByText('Database error message'); + const usernameError = screen.getByText('Username error message'); + expect(sizeError).toBeVisible(); + expect(modeError).toBeVisible(); + expect(databaseError).toBeVisible(); + expect(usernameError).toBeVisible(); + }); +}); diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseEditConnectionPoolDrawer.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseEditConnectionPoolDrawer.tsx new file mode 100644 index 00000000000..fb67f4771e5 --- /dev/null +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseEditConnectionPoolDrawer.tsx @@ -0,0 +1,211 @@ +import { yupResolver } from '@hookform/resolvers/yup'; +import { useUpdateDatabaseConnectionPoolMutation } from '@linode/queries'; +import { + ActionsPanel, + Autocomplete, + Drawer, + Notice, + Stack, + TextField, +} from '@linode/ui'; +import { updateDatabaseConnectionPoolSchema } from '@linode/validation'; +import { enqueueSnackbar } from 'notistack'; +import * as React from 'react'; +import { Controller, useForm, useWatch } from 'react-hook-form'; + +import { + databaseNamesOptions, + defaultUsername, + poolModeOptions, + usernameOptions, +} from 'src/features/Databases/constants'; + +import type { ConnectionPool } from '@linode/api-v4'; +interface Props { + databaseId: number; + onClose: () => void; + open: boolean; + pool: ConnectionPool; +} + +export const DatabaseEditConnectionPoolDrawer = (props: Props) => { + const { databaseId, onClose, open, pool } = props; + + const { + isPending: submitInProgress, + mutateAsync: updateDatabaseConnectionPool, + reset: resetMutation, + } = useUpdateDatabaseConnectionPoolMutation(databaseId, pool.label); + + const { + control, + formState: { errors, isDirty }, + handleSubmit, + reset, + setError, + } = useForm>({ + defaultValues: { + ...pool, + username: pool.username === null ? defaultUsername : pool.username, + }, + mode: 'onBlur', + resolver: yupResolver(updateDatabaseConnectionPoolSchema), + }); + + const handleOnClose = () => { + onClose(); + reset(); + resetMutation?.(); + }; + + const onSubmit = async (_values: ConnectionPool) => { + const { label, ...values } = _values; // remove label since it is not editable + const payload = { + ...values, + username: values.username === defaultUsername ? null : values.username, + }; // Provide inbound user as null in the API + + try { + await updateDatabaseConnectionPool(payload); + enqueueSnackbar(`Connection Pool ${label} edited successfully.`, { + variant: 'success', + }); + handleOnClose(); + } catch (errors) { + for (const error of errors) { + setError(error?.field ?? 'root', { message: error.reason }); + } + } + }; + + const [mode, database, username] = useWatch({ + control, + name: ['mode', 'database', 'username'], + }); + + return ( + + {errors.root?.message && ( + + )} +
+ + ( + + )} + /> + ( + { + field.onChange(option.value); + }} + options={databaseNamesOptions} + value={databaseNamesOptions.find( + (option) => option.value === database + )} + /> + )} + /> + ( + { + field.onChange(option.value); + }} + options={poolModeOptions} + value={poolModeOptions.find((option) => option.value === mode)} + /> + )} + /> + ( + { + const value = + e.target.value.length > 0 + ? Number(e.target.value) + : e.target.value; + field.onChange(value); + }} + style={{ width: '178px' }} + type="number" + /> + )} + /> + ( + { + field.onChange(option.value); + }} + options={usernameOptions} + value={usernameOptions.find( + (option) => option.value === username + )} + /> + )} + /> + + + +
+ ); +}; diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/DatabaseSettings.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/DatabaseSettings.tsx index d9b8c332039..7af56886759 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/DatabaseSettings.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/DatabaseSettings.tsx @@ -22,7 +22,7 @@ import { useFlags } from 'src/hooks/useFlags'; import AccessControls from '../AccessControls'; import { useDatabaseDetailContext } from '../DatabaseDetailContext'; -import DatabaseSettingsDeleteClusterDialog from './DatabaseSettingsDeleteClusterDialog'; +import { DatabaseSettingsDeleteClusterDialog } from './DatabaseSettingsDeleteClusterDialog'; import { DatabaseSettingsMaintenance } from './DatabaseSettingsMaintenance'; import DatabaseSettingsMenuItem from './DatabaseSettingsMenuItem'; import DatabaseSettingsResetPasswordDialog from './DatabaseSettingsResetPasswordDialog'; diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/DatabaseSettingsDeleteClusterDialog.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/DatabaseSettingsDeleteClusterDialog.tsx index 620707f3244..4ccf79ce2f0 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/DatabaseSettingsDeleteClusterDialog.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/DatabaseSettingsDeleteClusterDialog.tsx @@ -17,35 +17,33 @@ interface Props { open: boolean; } -export const DatabaseSettingsDeleteClusterDialog: React.FC = (props) => { +export const DatabaseSettingsDeleteClusterDialog = (props: Props) => { const { databaseEngine, databaseID, databaseLabel, onClose, open } = props; const { enqueueSnackbar } = useSnackbar(); - const { mutateAsync: deleteDatabase } = useDeleteDatabaseMutation( - databaseEngine, - databaseID - ); - const defaultError = 'There was an error deleting this Database Cluster.'; - const [error, setError] = React.useState(''); - const [isLoading, setIsLoading] = React.useState(false); + const { + mutateAsync: deleteDatabase, + error, + isPending, + reset, + } = useDeleteDatabaseMutation(databaseEngine, databaseID); const navigate = useNavigate(); + const _onClose = () => { + onClose(); + reset(); + }; + const onDeleteCluster = () => { - setIsLoading(true); - deleteDatabase() - .then(() => { - setIsLoading(false); - enqueueSnackbar('Database Cluster deleted successfully.', { - variant: 'success', - }); - onClose(); - navigate({ - to: '/databases', - }); - }) - .catch((e) => { - setIsLoading(false); - setError(getAPIErrorOrDefault(e, defaultError)[0].reason); + deleteDatabase().then(() => { + enqueueSnackbar('Database Cluster deleted successfully.', { + variant: 'success', }); + _onClose(); + reset(); + navigate({ + to: '/databases', + }); + }); }; return ( @@ -59,13 +57,23 @@ export const DatabaseSettingsDeleteClusterDialog: React.FC = (props) => { }} expand label={'Cluster Name'} - loading={isLoading} + loading={isPending} onClick={onDeleteCluster} - onClose={onClose} + onClose={_onClose} open={open} title={`Delete Database Cluster ${databaseLabel}`} > - {error ? : null} + {error ? ( + + ) : null} Warning: Deleting your entire database will delete @@ -76,5 +84,3 @@ export const DatabaseSettingsDeleteClusterDialog: React.FC = (props) => { ); }; - -export default DatabaseSettingsDeleteClusterDialog; diff --git a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLandingTable.tsx b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLandingTable.tsx index dba7dd1599a..9bb985eed80 100644 --- a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLandingTable.tsx +++ b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLandingTable.tsx @@ -11,7 +11,7 @@ import { import React from 'react'; import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; -import DatabaseSettingsDeleteClusterDialog from 'src/features/Databases/DatabaseDetail/DatabaseSettings/DatabaseSettingsDeleteClusterDialog'; +import { DatabaseSettingsDeleteClusterDialog } from 'src/features/Databases/DatabaseDetail/DatabaseSettings/DatabaseSettingsDeleteClusterDialog'; import DatabaseSettingsResetPasswordDialog from 'src/features/Databases/DatabaseDetail/DatabaseSettings/DatabaseSettingsResetPasswordDialog'; import { ManageAccessControlDrawer } from 'src/features/Databases/DatabaseDetail/ManageAccessControlDrawer'; import DatabaseLogo from 'src/features/Databases/DatabaseLanding/DatabaseLogo'; diff --git a/packages/manager/src/features/Databases/constants.ts b/packages/manager/src/features/Databases/constants.ts index 3e3845fc9b1..2f5572989d5 100644 --- a/packages/manager/src/features/Databases/constants.ts +++ b/packages/manager/src/features/Databases/constants.ts @@ -71,7 +71,24 @@ export const ADVANCED_CONFIG_LEARN_MORE_LINK = 'https://techdocs.akamai.com/cloud-computing/docs/advanced-configuration-parameters'; export const MANAGE_NETWORKING_LEARN_MORE_LINK = 'https://techdocs.akamai.com/cloud-computing/docs/aiven-manage-database#manage-networking'; +export const MANAGE_CONNECTION_POOLS_LEARN_MORE_LINK = + 'https://techdocs.akamai.com/cloud-computing/docs/aiven-manage-database#manage-pgbouncer-connection-pools'; +// Styles export const CONNECTION_POOL_LABEL_CELL_STYLES = { flex: '.5 1 20.5%', }; + +export const defaultUsername = 'Reuse inbound user'; // Represented as null in the API +export const poolModeOptions = [ + { label: 'Transaction', value: 'transaction' }, + { label: 'Session', value: 'session' }, + { label: 'Statement', value: 'statement' }, +]; +export const databaseNamesOptions = [ + { label: 'defaultdb', value: 'defaultdb' }, +]; // Currently the only option for the database name field, but more may be introduced later. +export const usernameOptions = [ + { label: defaultUsername, value: defaultUsername }, + { label: 'akmadmin', value: 'akmadmin' }, +]; // Currently the only options for the username field diff --git a/packages/manager/src/features/Databases/utilities.test.ts b/packages/manager/src/features/Databases/utilities.test.ts index 859f24b9aef..1f3648fbdd4 100644 --- a/packages/manager/src/features/Databases/utilities.test.ts +++ b/packages/manager/src/features/Databases/utilities.test.ts @@ -27,6 +27,7 @@ import type { AccountCapability, Database, Engine, + HostEndpointRole, PendingUpdates, } from '@linode/api-v4'; @@ -540,6 +541,14 @@ describe('getReadOnlyHost', () => { primary: 'primary.example.com', standby: 'standby.example.com', secondary: 'secondary.example.com', + endpoints: [ + { + address: 'public-primary.example.com', + role: 'primary' as HostEndpointRole, + private_access: false, + port: 12345, + }, + ], }; db.hosts = mockHosts; const result = getReadOnlyHost(db); @@ -552,6 +561,14 @@ describe('getReadOnlyHost', () => { const mockHosts = { primary: 'primary.example.com', secondary: 'secondary.example.com', + endpoints: [ + { + address: 'public-primary.example.com', + role: 'primary' as HostEndpointRole, + private_access: false, + port: 12345, + }, + ], }; db.hosts = mockHosts; const result = getReadOnlyHost(db); diff --git a/packages/manager/src/features/Delivery/DeliveryLanding.tsx b/packages/manager/src/features/Delivery/DeliveryLanding.tsx index a9dbf37dda2..951789c98aa 100644 --- a/packages/manager/src/features/Delivery/DeliveryLanding.tsx +++ b/packages/manager/src/features/Delivery/DeliveryLanding.tsx @@ -1,3 +1,4 @@ +import { NewFeatureChip } from '@linode/ui'; import * as React from 'react'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; @@ -11,6 +12,7 @@ import { SafeTabPanel } from 'src/components/Tabs/SafeTabPanel'; import { TabPanels } from 'src/components/Tabs/TabPanels'; import { Tabs } from 'src/components/Tabs/Tabs'; import { TanStackTabLinkList } from 'src/components/Tabs/TanStackTabLinkList'; +import { useIsACLPLogsEnabled } from 'src/features/Delivery/deliveryUtils'; import { useTabs } from 'src/hooks/useTabs'; const Destinations = React.lazy(() => @@ -26,9 +28,14 @@ const Streams = React.lazy(() => ); export const DeliveryLanding = React.memo(() => { + const { isACLPLogsNew } = useIsACLPLogsEnabled(); + const landingHeaderProps: LandingHeaderProps = { breadcrumbProps: { pathname: '/logs/delivery', + labelOptions: { + suffixComponent: isACLPLogsNew ? : undefined, + }, }, docsLink: 'https://techdocs.akamai.com/cloud-computing/docs/log-delivery', removeCrumbX: 1, diff --git a/packages/manager/src/features/Delivery/Destinations/DestinationActionMenu.test.tsx b/packages/manager/src/features/Delivery/Destinations/DestinationActionMenu.test.tsx index 9c1c6b0662b..710487ff3e0 100644 --- a/packages/manager/src/features/Delivery/Destinations/DestinationActionMenu.test.tsx +++ b/packages/manager/src/features/Delivery/Destinations/DestinationActionMenu.test.tsx @@ -2,7 +2,7 @@ import { screen } from '@testing-library/react'; import { userEvent } from '@testing-library/user-event'; import * as React from 'react'; -import { destinationFactory } from 'src/factories'; +import { akamaiObjectStorageDestinationFactory } from 'src/factories'; import { DestinationActionMenu } from 'src/features/Delivery/Destinations/DestinationActionMenu'; import { renderWithTheme } from 'src/utilities/testHelpers'; @@ -12,7 +12,7 @@ describe('DestinationActionMenu', () => { it('should include proper Stream actions', async () => { renderWithTheme( diff --git a/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationCreate.test.tsx b/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationCreate.test.tsx index 5d98fe50105..8bfd950652b 100644 --- a/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationCreate.test.tsx +++ b/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationCreate.test.tsx @@ -3,7 +3,7 @@ import { profileFactory } from '@linode/utilities'; import { screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; -import { describe, expect } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { accountFactory } from 'src/factories'; import { http, HttpResponse, server } from 'src/mocks/testServer'; @@ -12,9 +12,15 @@ import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; import { DestinationCreate } from './DestinationCreate'; import type { CreateDestinationPayload } from '@linode/api-v4'; +import type { Flags } from 'src/featureFlags'; + +const testConnectionButtonText = 'Test Connection'; +const createDestinationButtonText = 'Create Destination'; +const addCustomHeaderButtonText = 'Add Custom Header'; describe('DestinationCreate', () => { const renderDestinationCreate = ( + flags: Partial, defaultValues?: Partial ) => { renderWithThemeAndHookFormContext({ @@ -25,176 +31,559 @@ describe('DestinationCreate', () => { ...defaultValues, }, }, + options: { flags }, }); }; - it('should render disabled Destination Type input with proper selection', async () => { - renderDestinationCreate(); + describe('when customHttpsEnabled feature flag is set to false', () => { + const flags = { + aclpLogs: { + enabled: true, + beta: false, + customHttpsEnabled: false, + }, + }; - const destinationTypeAutocomplete = - screen.getByLabelText('Destination Type'); + it('should render disabled Destination Type input with Akamai Object Storage selected', () => { + renderDestinationCreate(flags); - expect(destinationTypeAutocomplete).toBeDisabled(); - expect(destinationTypeAutocomplete).toHaveValue('Akamai Object Storage'); - }); + const destinationTypeAutocomplete = + screen.getByLabelText('Destination Type'); - it( - 'should render all inputs for Akamai Object Storage type and allow to fill out them', - { timeout: 10000 }, - async () => { - renderDestinationCreate({ label: '' }); - - const destinationNameInput = screen.getByLabelText('Destination Name'); - await userEvent.type(destinationNameInput, 'Test'); - const hostInput = screen.getByLabelText('Host'); - await userEvent.type(hostInput, 'test'); - const bucketInput = screen.getByLabelText('Bucket'); - await userEvent.type(bucketInput, 'test'); - const accessKeyIDInput = screen.getByLabelText('Access Key ID'); - await userEvent.type(accessKeyIDInput, 'Test'); - const secretAccessKeyInput = screen.getByLabelText('Secret Access Key'); - await userEvent.type(secretAccessKeyInput, 'Test'); - const logPathPrefixInput = screen.getByLabelText('Log Path Prefix'); - await userEvent.type(logPathPrefixInput, 'Test'); - - expect(destinationNameInput).toHaveValue('Test'); - expect(hostInput).toHaveValue('test'); - expect(bucketInput).toHaveValue('test'); - expect(accessKeyIDInput).toHaveValue('Test'); - expect(secretAccessKeyInput).toHaveValue('Test'); - expect(logPathPrefixInput).toHaveValue('Test'); - } - ); - - it('should render Sample Destination Object Name and change its value according to Log Path Prefix input', async () => { - const accountEuuid = 'XYZ-123'; - const [month, day, year] = new Date().toLocaleDateString().split('/'); - server.use( - http.get('*/account', () => { - return HttpResponse.json(accountFactory.build({ euuid: accountEuuid })); - }) - ); - - renderDestinationCreate(); - - let samplePath; - await waitFor(() => { - samplePath = screen.getByText( - `/audit_logs/com.akamai.audit/${accountEuuid}/${year}/${month}/${day}/akamai_log-000166-1756015362-319597-login.gz` - ); - expect(samplePath).toBeInTheDocument(); + expect(destinationTypeAutocomplete).toBeDisabled(); + expect(destinationTypeAutocomplete).toHaveValue('Akamai Object Storage'); }); - // Type the test value inside the input - const logPathPrefixInput = screen.getByLabelText('Log Path Prefix'); - - await userEvent.type(logPathPrefixInput, 'test'); - // sample path should be created based on *log path* value - expect(samplePath!.textContent).toEqual( - '/test/akamai_log-000166-1756015362-319597-login.gz' - ); - - await userEvent.clear(logPathPrefixInput); - await userEvent.type(logPathPrefixInput, '/test'); - expect(samplePath!.textContent).toEqual( - '/test/akamai_log-000166-1756015362-319597-login.gz' - ); - - await userEvent.clear(logPathPrefixInput); - await userEvent.type(logPathPrefixInput, '/'); - expect(samplePath!.textContent).toEqual( - '/akamai_log-000166-1756015362-319597-login.gz' - ); - }); - describe('given Test Connection and Create Destination buttons', () => { - const testConnectionButtonText = 'Test Connection'; - const createDestinationButtonText = 'Create Destination'; - - const fillOutForm = async () => { - const destinationNameInput = screen.getByLabelText('Destination Name'); - await userEvent.type(destinationNameInput, 'Test'); - const hostInput = screen.getByLabelText('Host'); - await userEvent.type(hostInput, 'test'); - const bucketInput = screen.getByLabelText('Bucket'); - await userEvent.type(bucketInput, 'test'); - const accessKeyIDInput = screen.getByLabelText('Access Key ID'); - await userEvent.type(accessKeyIDInput, 'Test'); - const secretAccessKeyInput = screen.getByLabelText('Secret Access Key'); - await userEvent.type(secretAccessKeyInput, 'Test'); - const logPathPrefixInput = screen.getByLabelText('Log Path Prefix'); - await userEvent.type(logPathPrefixInput, 'Test'); - }; + describe('and Destination Type is set to Akamai Object Storage', () => { + it('should render Destination Name input and allow to type text', async () => { + renderDestinationCreate(flags); + + const destinationNameInput = screen.getByLabelText('Destination Name'); + await userEvent.type(destinationNameInput, 'Test Destination'); + + expect(destinationNameInput).toHaveValue('Test Destination'); + }); + + it('should render Host input and allow to type text', async () => { + renderDestinationCreate(flags); - describe('when form properly filled out and Test Connection button clicked and connection verified positively', () => { - const createDestinationSpy = vi.fn(); - const verifyDestinationSpy = vi.fn(); + const hostInput = screen.getByLabelText('Host'); + await userEvent.type(hostInput, 'test-host.com'); - it("should enable Create Destination button and perform proper call when it's clicked", async () => { + expect(hostInput).toHaveValue('test-host.com'); + }); + + it('should render Bucket input and allow to type text', async () => { + renderDestinationCreate(flags); + + const bucketInput = screen.getByLabelText('Bucket'); + await userEvent.type(bucketInput, 'test-bucket'); + + expect(bucketInput).toHaveValue('test-bucket'); + }); + + it('should render Access Key ID input and allow to type text', async () => { + renderDestinationCreate(flags); + + const accessKeyIdInput = screen.getByLabelText('Access Key ID'); + await userEvent.type(accessKeyIdInput, 'test-access-key'); + + expect(accessKeyIdInput).toHaveValue('test-access-key'); + }); + + it('should render Secret Access Key input and allow to type text', async () => { + renderDestinationCreate(flags); + + const secretAccessKeyInput = screen.getByLabelText('Secret Access Key'); + await userEvent.type(secretAccessKeyInput, 'test-secret-key'); + + expect(secretAccessKeyInput).toHaveValue('test-secret-key'); + }); + + it('should render Log Path Prefix input and allow to type text', async () => { + renderDestinationCreate(flags); + + const logPathPrefixInput = screen.getByLabelText( + 'Log Path Prefix (optional)' + ); + await userEvent.type(logPathPrefixInput, 'test-path'); + + expect(logPathPrefixInput).toHaveValue('test-path'); + }); + + it('should render Sample Destination Object Name and change its value according to Log Path Prefix input', async () => { + const accountEuuid = 'XYZ-123'; + const [month, day, year] = new Date().toLocaleDateString().split('/'); server.use( - http.post('*/monitor/streams/destinations/verify', () => { - verifyDestinationSpy(); - return HttpResponse.json({}); - }), - http.post('*/monitor/streams/destinations', () => { - createDestinationSpy(); - return HttpResponse.json({}); - }), - http.get('*/profile', () => { - return HttpResponse.json(profileFactory.build()); + http.get('*/account', () => { + return HttpResponse.json( + accountFactory.build({ euuid: accountEuuid }) + ); }) ); - renderDestinationCreate(); + renderDestinationCreate(flags); - const testConnectionButton = screen.getByRole('button', { - name: testConnectionButtonText, - }); - const createDestinationButton = screen.getByRole('button', { - name: createDestinationButtonText, + let samplePath; + await waitFor(() => { + samplePath = screen.getByText( + `/audit_logs/com.akamai.audit/${accountEuuid}/${year}/${month}/${day}/akamai_log-000166-1756015362-319597-login.gz` + ); + expect(samplePath).toBeInTheDocument(); }); + // Type the test value inside the input + const logPathPrefixInput = screen.getByLabelText( + 'Log Path Prefix (optional)' + ); - await fillOutForm(); - expect(createDestinationButton).toBeDisabled(); - await userEvent.click(testConnectionButton); - expect(verifyDestinationSpy).toHaveBeenCalled(); + await userEvent.type(logPathPrefixInput, 'test'); + // sample path should be created based on *log path* value + expect(samplePath!.textContent).toEqual( + '/test/akamai_log-000166-1756015362-319597-login.gz' + ); - await waitFor(() => { - expect(createDestinationButton).toBeEnabled(); + await userEvent.clear(logPathPrefixInput); + await userEvent.type(logPathPrefixInput, '/test'); + expect(samplePath!.textContent).toEqual( + '/test/akamai_log-000166-1756015362-319597-login.gz' + ); + + await userEvent.clear(logPathPrefixInput); + await userEvent.type(logPathPrefixInput, '/'); + expect(samplePath!.textContent).toEqual( + '/akamai_log-000166-1756015362-319597-login.gz' + ); + }); + + describe('given Test Connection and Create Destination buttons', () => { + const fillOutAkamaiObjectStorageForm = async () => { + const destinationNameInput = + screen.getByLabelText('Destination Name'); + await userEvent.type(destinationNameInput, 'Test'); + const hostInput = screen.getByLabelText('Host'); + await userEvent.type(hostInput, 'test'); + const bucketInput = screen.getByLabelText('Bucket'); + await userEvent.type(bucketInput, 'test'); + const accessKeyIDInput = screen.getByLabelText('Access Key ID'); + await userEvent.type(accessKeyIDInput, 'Test'); + const secretAccessKeyInput = + screen.getByLabelText('Secret Access Key'); + await userEvent.type(secretAccessKeyInput, 'Test'); + const logPathPrefixInput = screen.getByLabelText( + 'Log Path Prefix (optional)' + ); + await userEvent.type(logPathPrefixInput, 'Test'); + }; + + describe('when form properly filled out and Test Connection button clicked and connection verified positively', () => { + const createDestinationSpy = vi.fn(); + const verifyDestinationSpy = vi.fn(); + + it("should enable Create Destination button and perform proper call when it's clicked", async () => { + server.use( + http.post('*/monitor/streams/destinations/verify', () => { + verifyDestinationSpy(); + return HttpResponse.json({}); + }), + http.post('*/monitor/streams/destinations', () => { + createDestinationSpy(); + return HttpResponse.json({}); + }), + http.get('*/profile', () => { + return HttpResponse.json(profileFactory.build()); + }) + ); + + renderDestinationCreate(flags); + + const testConnectionButton = screen.getByRole('button', { + name: testConnectionButtonText, + }); + const createDestinationButton = screen.getByRole('button', { + name: createDestinationButtonText, + }); + + await fillOutAkamaiObjectStorageForm(); + expect(createDestinationButton).toBeDisabled(); + await userEvent.click(testConnectionButton); + expect(verifyDestinationSpy).toHaveBeenCalled(); + + await waitFor(() => { + expect(createDestinationButton).toBeEnabled(); + }); + + await userEvent.click(createDestinationButton); + expect(createDestinationSpy).toHaveBeenCalled(); + }); }); - await userEvent.click(createDestinationButton); - expect(createDestinationSpy).toHaveBeenCalled(); + describe('when form properly filled out and Test Connection button clicked and connection verified negatively', () => { + const verifyDestinationSpy = vi.fn(); + + it('should not enable Create Destination button', async () => { + server.use( + http.post('*/monitor/streams/destinations/verify', () => { + verifyDestinationSpy(); + return HttpResponse.error(); + }), + http.get('*/profile', () => { + return HttpResponse.json(profileFactory.build()); + }) + ); + + renderDestinationCreate(flags); + + const testConnectionButton = screen.getByRole('button', { + name: testConnectionButtonText, + }); + const createDestinationButton = screen.getByRole('button', { + name: createDestinationButtonText, + }); + + await fillOutAkamaiObjectStorageForm(); + expect(createDestinationButton).toBeDisabled(); + await userEvent.click(testConnectionButton); + expect(verifyDestinationSpy).toHaveBeenCalled(); + expect(createDestinationButton).toBeDisabled(); + }); + }); }); }); + }); - describe('when form properly filled out and Test Connection button clicked and connection verified negatively', () => { - const verifyDestinationSpy = vi.fn(); + describe('when customHttpsEnabled feature flag is set to true', () => { + const flags = { + aclpLogs: { + enabled: true, + beta: false, + customHttpsEnabled: true, + }, + }; - it('should not enable Create Destination button', async () => { - server.use( - http.post('*/monitor/streams/destinations/verify', () => { - verifyDestinationSpy(); - return HttpResponse.error(); - }), - http.get('*/profile', () => { - return HttpResponse.json(profileFactory.build()); - }) - ); + it('should render enabled Destination Type input with Akamai Object Storage selected and allow to select Custom HTTPS', async () => { + renderDestinationCreate(flags); + + const destinationTypeAutocomplete = + screen.getByLabelText('Destination Type'); + + expect(destinationTypeAutocomplete).toBeEnabled(); + expect(destinationTypeAutocomplete).toHaveValue('Akamai Object Storage'); + + await userEvent.click(destinationTypeAutocomplete); + const customHttpsOption = await screen.findByText('Custom HTTPS'); + await userEvent.click(customHttpsOption); + + expect(destinationTypeAutocomplete).toHaveValue('Custom HTTPS'); + }); + + describe('and Destination Type is set to Custom HTTPS', () => { + const selectCustomHttpsDestinationType = async () => { + renderDestinationCreate(flags); + + const destinationTypeAutocomplete = + screen.getByLabelText('Destination Type'); + await userEvent.click(destinationTypeAutocomplete); + const customHttpsOption = await screen.findByText('Custom HTTPS'); + await userEvent.click(customHttpsOption); + }; + + it('should render Destination Name input and allow to type text', async () => { + await selectCustomHttpsDestinationType(); + + const destinationNameInput = screen.getByLabelText('Destination Name'); + await userEvent.type(destinationNameInput, 'Test Destination'); + + expect(destinationNameInput).toHaveValue('Test Destination'); + }); + + it('should render Authentication autocomplete with None selected and allow to select Basic', async () => { + await selectCustomHttpsDestinationType(); + + const authenticationAutocomplete = + screen.getByLabelText('Authentication'); + + expect(authenticationAutocomplete).toHaveValue('None'); + + await userEvent.click(authenticationAutocomplete); + const basicAuthentication = await screen.findByText('Basic'); + await userEvent.click(basicAuthentication); + + expect(authenticationAutocomplete).toHaveValue('Basic'); + }); - renderDestinationCreate(); + describe('and Authentication is set to Basic', () => { + it('should render Username input and allow to type text', async () => { + await selectCustomHttpsDestinationType(); - const testConnectionButton = screen.getByRole('button', { - name: testConnectionButtonText, + const authenticationAutocomplete = + screen.getByLabelText('Authentication'); + await userEvent.click(authenticationAutocomplete); + const basicAuthentication = await screen.findByText('Basic'); + await userEvent.click(basicAuthentication); + + const usernameInput = screen.getByLabelText('Username'); + await userEvent.type(usernameInput, 'test-user'); + + expect(usernameInput).toHaveValue('test-user'); + }); + + it('should render Password input and allow to type text', async () => { + await selectCustomHttpsDestinationType(); + + const authenticationAutocomplete = + screen.getByLabelText('Authentication'); + await userEvent.click(authenticationAutocomplete); + const basicAuthentication = await screen.findByText('Basic'); + await userEvent.click(basicAuthentication); + + const passwordInput = screen.getByLabelText('Password'); + await userEvent.type(passwordInput, 'test-password'); + + expect(passwordInput).toHaveValue('test-password'); + }); + }); + + it('should render Endpoint URL input and allow to type text', async () => { + await selectCustomHttpsDestinationType(); + + const endpointUrlInput = screen.getByLabelText('Endpoint URL'); + await userEvent.type(endpointUrlInput, 'https://test-endpoint.com'); + + expect(endpointUrlInput).toHaveValue('https://test-endpoint.com'); + }); + + describe('Client Certificate fields', () => { + it('should render TLS Hostname input and allow to type text', async () => { + await selectCustomHttpsDestinationType(); + + const tlsHostnameInput = screen.getByLabelText('TLS Hostname'); + await userEvent.type(tlsHostnameInput, 'test-tls-hostname'); + + expect(tlsHostnameInput).toHaveValue('test-tls-hostname'); + }); + + it('should render CA Certificate input and allow to type text', async () => { + await selectCustomHttpsDestinationType(); + + const caCertificateInput = screen.getByLabelText('CA Certificate'); + await userEvent.type(caCertificateInput, 'test-ca-certificate'); + + expect(caCertificateInput).toHaveValue('test-ca-certificate'); + }); + + it('should render Client Certificate input and allow to type text', async () => { + await selectCustomHttpsDestinationType(); + + const clientCertificateInput = + screen.getByLabelText('Client Certificate'); + await userEvent.type( + clientCertificateInput, + 'test-client-certificate' + ); + + expect(clientCertificateInput).toHaveValue('test-client-certificate'); + }); + + it('should render Client Key input and allow to type text', async () => { + await selectCustomHttpsDestinationType(); + + const clientKeyInput = screen.getByLabelText('Client Key'); + await userEvent.type(clientKeyInput, 'test-client-key'); + + expect(clientKeyInput).toHaveValue('test-client-key'); + }); + }); + + describe('HTTPS Headers fields', () => { + it('should render Content Type autocomplete and allow to select application/json', async () => { + await selectCustomHttpsDestinationType(); + + const contentTypeAutocomplete = screen.getByLabelText('Content Type'); + expect(contentTypeAutocomplete).toHaveValue(''); + + await userEvent.click(contentTypeAutocomplete); + const jsonOption = await screen.findByText('application/json'); + await userEvent.click(jsonOption); + + expect(contentTypeAutocomplete).toHaveValue('application/json'); + }); + + it('should render Content Type autocomplete and allow to select application/json; charset=utf-8', async () => { + await selectCustomHttpsDestinationType(); + + const contentTypeAutocomplete = screen.getByLabelText('Content Type'); + + await userEvent.click(contentTypeAutocomplete); + const jsonUtf8Option = await screen.findByText( + 'application/json; charset=utf-8' + ); + await userEvent.click(jsonUtf8Option); + + expect(contentTypeAutocomplete).toHaveValue( + 'application/json; charset=utf-8' + ); }); - const createDestinationButton = screen.getByRole('button', { - name: createDestinationButtonText, + + describe('Custom Headers', () => { + it('should add a custom header when clicking Add Custom Header button and allow typing in Custom Header fields', async () => { + await selectCustomHttpsDestinationType(); + + const addCustomHeaderButton = screen.getByRole('button', { + name: addCustomHeaderButtonText, + }); + await userEvent.click(addCustomHeaderButton); + + const headerNameInput = screen.getByLabelText('Name'); + expect(headerNameInput).toBeInTheDocument(); + + const headerValueInput = screen.getByLabelText('Value'); + expect(headerValueInput).toBeInTheDocument(); + + await userEvent.type(headerNameInput, 'X-Custom-Header'); + expect(headerNameInput).toHaveValue('X-Custom-Header'); + + await userEvent.type(headerValueInput, 'custom-value'); + expect(headerValueInput).toHaveValue('custom-value'); + }); + + it('should update custom header title when Name is typed', async () => { + await selectCustomHttpsDestinationType(); + + const addCustomHeaderButton = screen.getByRole('button', { + name: addCustomHeaderButtonText, + }); + await userEvent.click(addCustomHeaderButton); + + screen.getByText('Custom Header 1'); + + const headerNameInput = screen.getByLabelText('Name'); + await userEvent.type(headerNameInput, 'Authorization'); + + expect( + screen.queryByText('Custom Header 1') + ).not.toBeInTheDocument(); + screen.getByText('Authorization'); + }); + + it('should remove custom header when clicking close button', async () => { + await selectCustomHttpsDestinationType(); + + const addCustomHeaderButton = screen.getByRole('button', { + name: addCustomHeaderButtonText, + }); + await userEvent.click(addCustomHeaderButton); + + const headerNameInput = screen.getByLabelText('Name'); + expect(headerNameInput).toBeInTheDocument(); + + const closeButton = screen.getByRole('button', { name: '' }); + await userEvent.click(closeButton); + + expect(screen.queryByLabelText('Name')).not.toBeInTheDocument(); + }); + + it('should allow adding multiple custom headers', async () => { + await selectCustomHttpsDestinationType(); + + const addCustomHeaderButton = screen.getByRole('button', { + name: addCustomHeaderButtonText, + }); + + await userEvent.click(addCustomHeaderButton); + screen.getByText('Custom Header 1'); + + await userEvent.click(addCustomHeaderButton); + expect(screen.getByText('Custom Header 2')).toBeInTheDocument(); + }); + }); + }); + + describe('given Test Connection and Create Destination buttons', () => { + const fillOutCustomHttpsForm = async () => { + const destinationTypeAutocomplete = + screen.getByLabelText('Destination Type'); + await userEvent.click(destinationTypeAutocomplete); + const customHttpsOption = await screen.findByText('Custom HTTPS'); + await userEvent.click(customHttpsOption); + const destinationNameInput = + screen.getByLabelText('Destination Name'); + await userEvent.type(destinationNameInput, 'Test'); + const endpointUrlInput = screen.getByLabelText('Endpoint URL'); + await userEvent.type(endpointUrlInput, 'https://test-endpoint.com'); + }; + + describe('when form properly filled out and Test Connection button clicked and connection verified positively', () => { + const createDestinationSpy = vi.fn(); + const verifyDestinationSpy = vi.fn(); + + it("should enable Create Destination button and perform proper call when it's clicked", async () => { + server.use( + http.post('*/monitor/streams/destinations/verify', () => { + verifyDestinationSpy(); + return HttpResponse.json({}); + }), + http.post('*/monitor/streams/destinations', () => { + createDestinationSpy(); + return HttpResponse.json({}); + }), + http.get('*/profile', () => { + return HttpResponse.json(profileFactory.build()); + }) + ); + + renderDestinationCreate(flags); + + const testConnectionButton = screen.getByRole('button', { + name: testConnectionButtonText, + }); + const createDestinationButton = screen.getByRole('button', { + name: createDestinationButtonText, + }); + + await fillOutCustomHttpsForm(); + expect(createDestinationButton).toBeDisabled(); + await userEvent.click(testConnectionButton); + expect(verifyDestinationSpy).toHaveBeenCalled(); + + await waitFor(() => { + expect(createDestinationButton).toBeEnabled(); + }); + + await userEvent.click(createDestinationButton); + expect(createDestinationSpy).toHaveBeenCalled(); + }); }); - await fillOutForm(); - expect(createDestinationButton).toBeDisabled(); - await userEvent.click(testConnectionButton); - expect(verifyDestinationSpy).toHaveBeenCalled(); - expect(createDestinationButton).toBeDisabled(); + describe('when form properly filled out and Test Connection button clicked and connection verified negatively', () => { + const verifyDestinationSpy = vi.fn(); + + it('should not enable Create Destination button', async () => { + server.use( + http.post('*/monitor/streams/destinations/verify', () => { + verifyDestinationSpy(); + return HttpResponse.error(); + }), + http.get('*/profile', () => { + return HttpResponse.json(profileFactory.build()); + }) + ); + + renderDestinationCreate(flags); + + const testConnectionButton = screen.getByRole('button', { + name: testConnectionButtonText, + }); + const createDestinationButton = screen.getByRole('button', { + name: createDestinationButtonText, + }); + + await fillOutCustomHttpsForm(); + expect(createDestinationButton).toBeDisabled(); + await userEvent.click(testConnectionButton); + expect(verifyDestinationSpy).toHaveBeenCalled(); + expect(createDestinationButton).toBeDisabled(); + }); + }); }); }); }); diff --git a/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationCreate.tsx b/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationCreate.tsx index 11796efa544..bceccd99968 100644 --- a/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationCreate.tsx +++ b/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationCreate.tsx @@ -52,7 +52,10 @@ export const DestinationCreate = () => { const formValues = form.getValues(); const destination: CreateDestinationPayload = { ...formValues, - details: getDestinationPayloadDetails(formValues.details), + details: getDestinationPayloadDetails( + formValues.details, + formValues.type + ), }; createDestination(destination) diff --git a/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationEdit.test.tsx b/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationEdit.test.tsx index 4e169f7e167..c65cd49587c 100644 --- a/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationEdit.test.tsx +++ b/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationEdit.test.tsx @@ -7,14 +7,14 @@ import userEvent from '@testing-library/user-event'; import React from 'react'; import { describe, expect } from 'vitest'; -import { destinationFactory } from 'src/factories'; +import { akamaiObjectStorageDestinationFactory } from 'src/factories'; import { DestinationEdit } from 'src/features/Delivery/Destinations/DestinationForm/DestinationEdit'; import { http, HttpResponse, server } from 'src/mocks/testServer'; import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; const loadingTestId = 'circle-progress'; const destinationId = 123; -const mockDestination = destinationFactory.build({ +const mockDestination = akamaiObjectStorageDestinationFactory.build({ id: destinationId, label: `Destination ${destinationId}`, }); @@ -55,7 +55,7 @@ describe('DestinationEdit', () => { assertInputHasValue('Bucket', 'destinations-bucket-name'); assertInputHasValue('Access Key ID', 'Access Id'); assertInputHasValue('Secret Access Key', ''); - assertInputHasValue('Log Path Prefix', 'file'); + assertInputHasValue('Log Path Prefix (optional)', 'file'); }); describe('given Test Connection and Edit Destination buttons', () => { diff --git a/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationEdit.tsx b/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationEdit.tsx index 6d30c3cb7a3..f29decb6f2e 100644 --- a/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationEdit.tsx +++ b/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationEdit.tsx @@ -83,7 +83,10 @@ export const DestinationEdit = () => { const destination: UpdateDestinationPayloadWithId = { id: destinationId, ...omitProps(formValues, ['type']), - details: getDestinationPayloadDetails(formValues.details), + details: getDestinationPayloadDetails( + formValues.details, + formValues.type + ), }; updateDestination(destination) diff --git a/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationForm.tsx b/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationForm.tsx index c344de0234e..67e1bba84bf 100644 --- a/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationForm.tsx +++ b/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationForm.tsx @@ -1,4 +1,9 @@ -import { destinationType } from '@linode/api-v4'; +import { + authenticationType, + dataCompressionType, + type DestinationType, + destinationType, +} from '@linode/api-v4'; import { Autocomplete, Paper, TextField } from '@linode/ui'; import { capitalize, scrollErrorIntoViewV2 } from '@linode/utilities'; import Grid from '@mui/material/Grid'; @@ -8,8 +13,12 @@ import type { SubmitHandler } from 'react-hook-form'; import { useFormContext } from 'react-hook-form'; import { Controller, useWatch } from 'react-hook-form'; -import { getDestinationTypeOption } from 'src/features/Delivery/deliveryUtils'; +import { + getDestinationTypeOption, + useIsACLPLogsEnabled, +} from 'src/features/Delivery/deliveryUtils'; import { DestinationAkamaiObjectStorageDetailsForm } from 'src/features/Delivery/Shared/DestinationAkamaiObjectStorageDetailsForm'; +import { DestinationCustomHttpsDetailsForm } from 'src/features/Delivery/Shared/DestinationCustomHttpsDetailsForm'; import { FormSubmitBar } from 'src/features/Delivery/Shared/FormSubmitBar/FormSubmitBar'; import { destinationTypeOptions } from 'src/features/Delivery/Shared/types'; import { useVerifyDestination } from 'src/features/Delivery/Shared/useVerifyDestination'; @@ -25,9 +34,27 @@ interface DestinationFormProps { onSubmit: SubmitHandler; } +const customHttpsDetailsControlPaths = { + authenticationType: 'details.authentication.type', + basicAuthenticationPassword: + 'details.authentication.details.basic_authentication_password', + basicAuthenticationUser: + 'details.authentication.details.basic_authentication_user', + clientCaCertificate: + 'details.client_certificate_details.client_ca_certificate', + clientCertificate: 'details.client_certificate_details.client_certificate', + clientPrivateKey: 'details.client_certificate_details.client_private_key', + tlsHostname: 'details.client_certificate_details.tls_hostname', + contentType: 'details.content_type', + customHeaders: 'details.custom_headers', + dataCompression: 'details.data_compression', + endpointUrl: 'details.endpoint_url', +} as const; + export const DestinationForm = (props: DestinationFormProps) => { const { mode, isSubmitting, onSubmit } = props; + const { isACLPLogsCustomHttpsEnabled } = useIsACLPLogsEnabled(); const { verifyDestination, isPending: isVerifyingDestination, @@ -36,7 +63,8 @@ export const DestinationForm = (props: DestinationFormProps) => { } = useVerifyDestination(); const formRef = React.useRef(null); - const { control, handleSubmit } = useFormContext(); + const { control, handleSubmit, getValues, reset } = + useFormContext(); const destination = useWatch({ control, }) as DestinationFormType; @@ -45,6 +73,27 @@ export const DestinationForm = (props: DestinationFormProps) => { setDestinationVerified(false); }, [destination, setDestinationVerified]); + const resetForm = (destType: DestinationType) => { + const currentValues = getValues(); + const newDestinationDetails = + destType === destinationType.AkamaiObjectStorage + ? { + path: '', + } + : { + authentication: { + type: authenticationType.None, + }, + data_compression: dataCompressionType.Gzip, + }; + + reset({ + ...currentValues, + type: destType, + details: newDestinationDetails, + }); + }; + return (
@@ -56,10 +105,11 @@ export const DestinationForm = (props: DestinationFormProps) => { render={({ field }) => ( { + resetForm(value as DestinationType); field.onChange(value); }} options={destinationTypeOptions} @@ -97,6 +147,14 @@ export const DestinationForm = (props: DestinationFormProps) => { mode={mode} /> )} + {isACLPLogsCustomHttpsEnabled && + destination.type === destinationType.CustomHttps && ( + + )} diff --git a/packages/manager/src/features/Delivery/Destinations/DestinationsLanding.test.tsx b/packages/manager/src/features/Delivery/Destinations/DestinationsLanding.test.tsx index 90f3dda8a8b..3921315e7f0 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'; +import { akamaiObjectStorageDestinationFactory } from 'src/factories'; import { DestinationsLanding } from 'src/features/Delivery/Destinations/DestinationsLanding'; import { mockMatchMedia, renderWithTheme } from 'src/utilities/testHelpers'; @@ -36,8 +36,11 @@ vi.mock('@linode/queries', async () => { }; }); -const destination = destinationFactory.build({ id: 1 }); -const destinations = [destination, ...destinationFactory.buildList(30)]; +const destination = akamaiObjectStorageDestinationFactory.build({ id: 1 }); +const destinations = [ + destination, + ...akamaiObjectStorageDestinationFactory.buildList(30), +]; describe('Destinations Landing Table', () => { const renderComponent = () => { diff --git a/packages/manager/src/features/Delivery/Destinations/DestinationsLanding.tsx b/packages/manager/src/features/Delivery/Destinations/DestinationsLanding.tsx index a0263faa8ac..8158e83d221 100644 --- a/packages/manager/src/features/Delivery/Destinations/DestinationsLanding.tsx +++ b/packages/manager/src/features/Delivery/Destinations/DestinationsLanding.tsx @@ -1,11 +1,11 @@ import { useDestinationsQuery } from '@linode/queries'; import { CircleProgress, ErrorState, Hidden, Paper } from '@linode/ui'; import { TableBody, TableHead, TableRow } from '@mui/material'; -import Table from '@mui/material/Table'; import { useNavigate, useSearch } from '@tanstack/react-router'; import * as React from 'react'; import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter'; +import { Table } from 'src/components/Table'; import { TableCell } from 'src/components/TableCell'; import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; import { TableSortCell } from 'src/components/TableSortCell'; diff --git a/packages/manager/src/features/Delivery/Shared/CustomHeaders.tsx b/packages/manager/src/features/Delivery/Shared/CustomHeaders.tsx new file mode 100644 index 00000000000..919256e4252 --- /dev/null +++ b/packages/manager/src/features/Delivery/Shared/CustomHeaders.tsx @@ -0,0 +1,144 @@ +import { + CloseIcon, + IconButton, + LinkButton, + Stack, + TextField, + Typography, +} from '@linode/ui'; +import Grid from '@mui/material/Grid'; +import * as React from 'react'; +import { useEffect } from 'react'; +import type { Control } from 'react-hook-form'; +import { + Controller, + useFieldArray, + useFormContext, + useWatch, +} from 'react-hook-form'; + +interface CustomHeaderTitleProps { + control: Control; + controlPath: string; + index: number; +} + +const CustomHeaderTitle = (props: CustomHeaderTitleProps) => { + const { control, controlPath, index } = props; + + const headerName = useWatch({ + control, + name: `${controlPath}[${index}].name`, + }); + + return ( + + {headerName?.length ? headerName : `Custom Header ${index + 1}`} + + ); +}; + +interface CustomHeadersProps { + controlPath: string; +} + +export const CustomHeaders = (props: CustomHeadersProps) => { + const { controlPath } = props; + + const { control, unregister } = useFormContext(); + + const { append, fields, remove } = useFieldArray({ + control, + name: controlPath, + }); + + useEffect(() => { + if (fields.length === 0) { + unregister(controlPath); + } + }, [fields, controlPath, unregister]); + + const addNewField = () => { + append({ name: '', value: '' }); + }; + + const removeField = (index: number) => { + remove(index); + if (fields.length === 1) { + unregister(controlPath); + } + }; + + return ( + <> + + {fields?.map((field, index) => ( + ({ + backgroundColor: theme.tokens.alias.Background.Neutral, + maxWidth: 416, + p: theme.spacingFunction(16), + })} + > + + + removeField(index)} sx={{ p: 0 }}> + + + + + ( + + )} + /> + ( + + )} + /> + + + ))} + + ({ + mt: theme.spacingFunction(16), + font: theme.tokens.alias.Typography.Label.Semibold.S, + })} + > + Add Custom Header + + + ); +}; diff --git a/packages/manager/src/features/Delivery/Shared/DeliveryTabHeader/DeliveryTabHeader.tsx b/packages/manager/src/features/Delivery/Shared/DeliveryTabHeader/DeliveryTabHeader.tsx index ddcb23fbca0..e12e758ad37 100644 --- a/packages/manager/src/features/Delivery/Shared/DeliveryTabHeader/DeliveryTabHeader.tsx +++ b/packages/manager/src/features/Delivery/Shared/DeliveryTabHeader/DeliveryTabHeader.tsx @@ -41,13 +41,6 @@ export const DeliveryTabHeader = ({ const theme = useTheme(); const xsDown = useMediaQuery((theme: Theme) => theme.breakpoints.down('sm')); - const customBreakpoint = 636; - const customXsDownBreakpoint = useMediaQuery((theme: Theme) => - theme.breakpoints.down(customBreakpoint) - ); - const customSmMdBetweenBreakpoint = useMediaQuery((theme: Theme) => - theme.breakpoints.between(customBreakpoint, 'md') - ); const searchLabel = `Search for a ${entity}`; return ( @@ -66,23 +59,15 @@ export const DeliveryTabHeader = ({ alignItems: 'center', display: 'flex', flexWrap: xsDown ? 'wrap' : 'nowrap', - gap: 3, + gap: `${theme.spacingFunction(24)} 0`, justifyContent: onSearch && searchValue !== undefined ? 'space-between' : 'flex-end', flex: '1 1 auto', - marginLeft: customSmMdBetweenBreakpoint - ? theme.spacingFunction(16) - : customXsDownBreakpoint - ? theme.spacingFunction(8) - : undefined, - marginRight: customSmMdBetweenBreakpoint - ? theme.spacingFunction(16) - : customXsDownBreakpoint - ? theme.spacingFunction(8) - : undefined, + marginLeft: 0, + marginRight: 0, }} > {onSearch && searchValue !== undefined && ( @@ -142,6 +127,9 @@ export const DeliveryTabHeader = ({ data-pendo-id={`Logs Delivery ${entity}s-Create ${entity}`} disabled={disabledCreateButton} onClick={onButtonClick} + sx={{ + whiteSpace: 'nowrap', + }} {...buttonDataAttrs} > {createButtonText ?? `Create ${entity}`} @@ -157,7 +145,10 @@ const StyledActions = styled('div')(({ theme }) => ({ display: 'flex', gap: theme.spacingFunction(24), justifyContent: 'flex-end', - marginLeft: 'auto', + + [theme.breakpoints.up('sm')]: { + marginLeft: 'auto', + }, '& .MuiAutocomplete-root > .MuiBox-root': { display: 'flex', diff --git a/packages/manager/src/features/Delivery/Shared/DestinationAkamaiObjectStorageDetailsForm.tsx b/packages/manager/src/features/Delivery/Shared/DestinationAkamaiObjectStorageDetailsForm.tsx index bf0b76015cb..b5b17fc7f58 100644 --- a/packages/manager/src/features/Delivery/Shared/DestinationAkamaiObjectStorageDetailsForm.tsx +++ b/packages/manager/src/features/Delivery/Shared/DestinationAkamaiObjectStorageDetailsForm.tsx @@ -77,7 +77,6 @@ export const DestinationAkamaiObjectStorageDetailsForm = ({ onChange={(value) => { field.onChange(value); }} - placeholder="Bucket" value={field.value} /> )} @@ -95,7 +94,6 @@ export const DestinationAkamaiObjectStorageDetailsForm = ({ label="Access Key ID" onBlur={field.onBlur} onChange={(value) => field.onChange(value)} - placeholder="Access Key ID" value={field.value} /> )} @@ -113,7 +111,6 @@ export const DestinationAkamaiObjectStorageDetailsForm = ({ label="Secret Access Key" onBlur={field.onBlur} onChange={(value) => field.onChange(value)} - placeholder="Secret Access Key" value={field.value} /> )} @@ -140,6 +137,7 @@ export const DestinationAkamaiObjectStorageDetailsForm = ({ label="Log Path Prefix" onBlur={field.onBlur} onChange={(value) => field.onChange(value)} + optional placeholder="Prefix for log storage path" sx={{ maxWidth: 416 }} value={field.value} diff --git a/packages/manager/src/features/Delivery/Shared/DestinationCustomHttpsDetailsForm.tsx b/packages/manager/src/features/Delivery/Shared/DestinationCustomHttpsDetailsForm.tsx new file mode 100644 index 00000000000..e9d89bb1be9 --- /dev/null +++ b/packages/manager/src/features/Delivery/Shared/DestinationCustomHttpsDetailsForm.tsx @@ -0,0 +1,222 @@ +import { Autocomplete, Divider, TextField, Typography } from '@linode/ui'; +import { useTheme } from '@mui/material/styles'; +import React from 'react'; +import { Controller, useFormContext, useWatch } from 'react-hook-form'; + +import { HideShowText } from 'src/components/PasswordInput/HideShowText'; +import { + getAuthenticationTypeOption, + getContentTypeOption, +} from 'src/features/Delivery/deliveryUtils'; +import { CustomHeaders } from 'src/features/Delivery/Shared/CustomHeaders'; +import { + authenticationTypeOptions, + contentTypeOptions, +} from 'src/features/Delivery/Shared/types'; + +import type { FormMode, FormType } from 'src/features/Delivery/Shared/types'; + +interface DestinationCustomHttpsDetailsFormProps { + controlPaths: { + authenticationType: string; + basicAuthenticationPassword: string; + basicAuthenticationUser: string; + clientCaCertificate: string; + clientCertificate: string; + clientPrivateKey: string; + contentType: string; + customHeaders: string; + dataCompression: string; + endpointUrl: string; + tlsHostname: string; + }; + entity: FormType; + mode: FormMode; +} + +export const DestinationCustomHttpsDetailsForm = ( + props: DestinationCustomHttpsDetailsFormProps +) => { + const { controlPaths } = props; + const theme = useTheme(); + + const { control } = useFormContext(); + + const selectedAuthenticationType = useWatch({ + control, + name: controlPaths.authenticationType, + }); + + return ( + <> + ( + { + field.onChange(value); + }} + options={authenticationTypeOptions} + value={getAuthenticationTypeOption(field.value)} + /> + )} + /> + {selectedAuthenticationType === 'basic' && ( + <> + ( + { + field.onChange(value); + }} + value={field.value} + /> + )} + /> + ( + field.onChange(value)} + value={field.value} + /> + )} + /> + + )} + ( + { + field.onChange(value); + }} + value={field.value} + /> + )} + /> + + + Additional Options + + + Client Certificate  + + (optional) + + + ( + { + field.onChange(value); + }} + value={field.value} + /> + )} + /> + ( + { + field.onChange(value); + }} + value={field.value} + /> + )} + /> + ( + { + field.onChange(value); + }} + value={field.value} + /> + )} + /> + ( + { + field.onChange(value); + }} + value={field.value} + /> + )} + /> + + HTTPS Headers  + + (optional) + + + ( + { + field.onChange(value?.value || null); + }} + options={contentTypeOptions} + value={field.value ? getContentTypeOption(field.value) : null} + /> + )} + /> + + + ); +}; diff --git a/packages/manager/src/features/Delivery/Shared/LabelValue.tsx b/packages/manager/src/features/Delivery/Shared/LabelValue.tsx index 87bfb5323e2..c9b6be97412 100644 --- a/packages/manager/src/features/Delivery/Shared/LabelValue.tsx +++ b/packages/manager/src/features/Delivery/Shared/LabelValue.tsx @@ -1,51 +1,88 @@ import { Box, Tooltip, Typography } from '@linode/ui'; import { styled, useTheme } from '@mui/material/styles'; -import useMediaQuery from '@mui/material/useMediaQuery'; import * as React from 'react'; +import { useEffect, useRef, useState } from 'react'; + +const maxWidth = 416; +const labelWidth = 160; +const valueWidth = maxWidth - labelWidth; interface LabelValueProps { - children?: React.ReactNode; - compact?: boolean; 'data-testid'?: string; label: string; - smHideTooltip?: boolean; value: string; } - export const LabelValue = (props: LabelValueProps) => { - const { - compact = false, - label, - value, - 'data-testid': dataTestId, - children, - smHideTooltip, - } = props; + const { label, value, 'data-testid': dataTestId } = props; const theme = useTheme(); - const matchesSmDown = useMediaQuery(theme.breakpoints.down('sm')); + const labelRef = useRef(null); + const [isLabelOverflowing, setIsLabelOverflowing] = useState(false); + const valueRef = useRef(null); + const [isValueOverflowing, setIsValueOverflowing] = useState(false); + + useEffect(() => { + const checkLabelOverflow = () => { + if (labelRef.current) { + setIsLabelOverflowing( + labelRef.current.scrollWidth > labelRef.current.clientWidth + ); + } + }; + const checkValueOverflow = () => { + if (valueRef.current) { + setIsValueOverflowing( + valueRef.current.scrollWidth > valueRef.current.clientWidth + ); + } + }; + checkLabelOverflow(); + checkValueOverflow(); + }); return ( - - {label}: - - - {value} - - {children} + + + + {label} + + + : + + + + + + + {value} + + + ); }; @@ -53,19 +90,19 @@ export const LabelValue = (props: LabelValueProps) => { const StyledValue = styled(Tooltip, { label: 'StyledValue', })(({ theme }) => ({ - alignItems: 'center', backgroundColor: theme.tokens.alias.Interaction.Background.Disabled, border: `1px solid ${theme.tokens.alias.Border.Neutral}`, borderRadius: 4, - display: 'flex', height: theme.spacingFunction(24), - padding: theme.spacingFunction(4, 8), - [theme.breakpoints.down('sm')]: { - display: 'block', - maxWidth: '174px', - textOverflow: 'ellipsis', - overflow: 'hidden', - whiteSpace: 'nowrap', - padding: theme.spacingFunction(1, 8), - }, + lineHeight: theme.spacingFunction(24), + maxWidth: valueWidth, + padding: theme.spacingFunction(1, 8), +})); + +const StyledLabel = styled(Tooltip, { + label: 'StyledLabel', +})(({ theme }) => ({ + height: theme.spacingFunction(24), + lineHeight: theme.spacingFunction(24), + maxWidth: labelWidth, })); diff --git a/packages/manager/src/features/Delivery/Shared/PathSample.tsx b/packages/manager/src/features/Delivery/Shared/PathSample.tsx index 85ee27521f4..b18e11e85ab 100644 --- a/packages/manager/src/features/Delivery/Shared/PathSample.tsx +++ b/packages/manager/src/features/Delivery/Shared/PathSample.tsx @@ -6,7 +6,10 @@ import * as React from 'react'; import { useMemo } from 'react'; import { useFormContext, useWatch } from 'react-hook-form'; -import { getStreamTypeOption } from 'src/features/Delivery/deliveryUtils'; +import { + getStreamTypeOption, + useIsLkeEAuditLogsTypeSelectionEnabled, +} from 'src/features/Delivery/deliveryUtils'; const sxTooltipIcon = { marginLeft: '4px', @@ -43,6 +46,8 @@ export const PathSample = (props: PathSampleProps) => { }); const { data: account } = useAccount(); + const isLkeEAuditLogsTypeSelectionEnabled = + useIsLkeEAuditLogsTypeSelectionEnabled(); const [month, day, year] = new Date().toLocaleDateString('en-US').split('/'); const setStreamType = (): StreamType => { @@ -92,7 +97,9 @@ export const PathSample = (props: PathSampleProps) => { text={ Default paths: - {`${getStreamTypeOption(streamType.LKEAuditLogs)?.label} - {stream_type}/{log_type}/ {account}/{partition}/ {%Y/%m/%d/}`} + {isLkeEAuditLogsTypeSelectionEnabled && ( + {`${getStreamTypeOption(streamType.LKEAuditLogs)?.label} - {stream_type}/{log_type}/ {account}/{partition}/ {%Y/%m/%d/}`} + )} {`${getStreamTypeOption(streamType.AuditLogs)?.label} - {stream_type}/{log_type}/ {account}/{%Y/%m/%d/}`} } diff --git a/packages/manager/src/features/Delivery/Shared/types.ts b/packages/manager/src/features/Delivery/Shared/types.ts index ee312d80896..9373632cf7c 100644 --- a/packages/manager/src/features/Delivery/Shared/types.ts +++ b/packages/manager/src/features/Delivery/Shared/types.ts @@ -1,9 +1,15 @@ -import { destinationType, streamStatus, streamType } from '@linode/api-v4'; +import { + authenticationType, + contentType, + destinationType, + streamStatus, + streamType, +} from '@linode/api-v4'; import type { AkamaiObjectStorageDetailsExtended, CreateDestinationPayload, - CustomHTTPsDetails, + CustomHTTPSDetailsExtended, } from '@linode/api-v4'; export type FormMode = 'create' | 'edit'; @@ -55,9 +61,31 @@ export const streamStatusOptions: AutocompleteOption[] = [ }, ]; +export const authenticationTypeOptions: AutocompleteOption[] = [ + { + value: authenticationType.Basic, + label: 'Basic', + }, + { + value: authenticationType.None, + label: 'None', + }, +]; + +export const contentTypeOptions: AutocompleteOption[] = [ + { + value: contentType.Json, + label: contentType.Json, + }, + { + value: contentType.JsonUtf8, + label: contentType.JsonUtf8, + }, +]; + export type DestinationDetailsForm = | AkamaiObjectStorageDetailsExtended - | CustomHTTPsDetails; + | CustomHTTPSDetailsExtended; export interface DestinationForm extends Omit { diff --git a/packages/manager/src/features/Delivery/Shared/useVerifyDestination.ts b/packages/manager/src/features/Delivery/Shared/useVerifyDestination.ts index 0c321b299f4..93c49b82cee 100644 --- a/packages/manager/src/features/Delivery/Shared/useVerifyDestination.ts +++ b/packages/manager/src/features/Delivery/Shared/useVerifyDestination.ts @@ -17,7 +17,10 @@ export const useVerifyDestination = () => { try { const payload = { ...destination, - details: getDestinationPayloadDetails(destination.details), + details: getDestinationPayloadDetails( + destination.details, + destination.type + ), }; await callVerifyDestination(payload); diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClusters.test.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClusters.test.tsx index fde6a031dd8..156fbc30dba 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClusters.test.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClusters.test.tsx @@ -128,7 +128,7 @@ describe('StreamFormClusters', () => { ); }); - it('should filter clusters by region', async () => { + it('should filter clusters by region with search input', async () => { await renderComponentWithoutSelectedClusters(); const input = screen.getByPlaceholderText('Search'); @@ -144,7 +144,7 @@ describe('StreamFormClusters', () => { ); }); - it('should filter clusters by log generation status', async () => { + it('should filter clusters by log generation status with search input', async () => { await renderComponentWithoutSelectedClusters(); const input = screen.getByPlaceholderText('Search'); @@ -157,6 +157,21 @@ describe('StreamFormClusters', () => { ); }); + it('should filter clusters by log generation status with autocomplete', async () => { + await renderComponentWithoutSelectedClusters(); + const input = screen.getByPlaceholderText('Log Generation'); + + await userEvent.click(input); + await userEvent.type(input, 'enabled'); + + const enabledOption = screen.getAllByText('Enabled')[0]; + await userEvent.click(enabledOption); + + await waitFor(() => + expect(getColumnsValuesFromTable(3)).toEqual(['Enabled', 'Enabled']) + ); + }); + it('should toggle clusters checkboxes and header checkbox', async () => { await renderComponentWithoutSelectedClusters(); const table = screen.getByRole('table'); diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClusters.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClusters.tsx index c2d69760dd8..e6dd87f1a14 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClusters.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClusters.tsx @@ -1,6 +1,7 @@ import { useRegionsQuery } from '@linode/queries'; import { useIsGeckoEnabled } from '@linode/shared'; import { + Autocomplete, Box, Checkbox, CircleProgress, @@ -52,6 +53,10 @@ export const StreamFormClusters = (props: StreamFormClustersProps) => { const { gecko2 } = useFlags(); const { isGeckoLAEnabled } = useIsGeckoEnabled(gecko2?.enabled, gecko2?.la); const { data: regions } = useRegionsQuery(); + const logGenerationOptions = [ + { label: 'Enabled', value: true }, + { label: 'Disabled', value: false }, + ]; const [order, setOrder] = useState<'asc' | 'desc'>('asc'); const [orderBy, setOrderBy] = useState('label'); @@ -59,6 +64,7 @@ export const StreamFormClusters = (props: StreamFormClustersProps) => { const [pageSize, setPageSize] = useState(MIN_PAGE_SIZE); const [searchText, setSearchText] = useState(''); const [regionFilter, setRegionFilter] = useState(''); + const [logGenerationFilter, setLogGenerationFilter] = useState(); const { data: clusters = [], @@ -133,7 +139,7 @@ export const StreamFormClusters = (props: StreamFormClustersProps) => { }; const filteredClusters = - !searchText && !regionFilter + !searchText && !regionFilter && logGenerationFilter === undefined ? clusters : clusters.filter((cluster) => { const lowerSearch = searchText.toLowerCase(); @@ -151,7 +157,12 @@ export const StreamFormClusters = (props: StreamFormClustersProps) => { } if (result && regionFilter) { - return cluster.region === regionFilter; + result = cluster.region === regionFilter; + } + + if (result && logGenerationFilter) { + result = + cluster.control_plane.audit_logs_enabled === logGenerationFilter; } return result; @@ -252,20 +263,32 @@ export const StreamFormClusters = (props: StreamFormClustersProps) => { placeholder="Search" value={searchText} /> - { - setRegionFilter(region?.id ?? ''); - }} - regionFilter="core" - regions={regions ?? []} - sx={{ - width: '280px !important', - }} - value={regionFilter} - /> + + { + setRegionFilter(region?.id ?? ''); + }} + placeholder="Select Region" + regionFilter="core" + regions={regions ?? []} + sx={{ + width: '160px !important', + }} + value={regionFilter} + /> + setLogGenerationFilter(option?.value)} + options={logGenerationOptions} + placeholder="Log Generation" + sx={{ + width: '160px !important', + }} + /> + {!isAutoAddAllClustersEnabled && @@ -321,3 +344,12 @@ const StyledGrid = styled(Grid)(({ theme }) => ({ }, }, })); + +const StyledSelectsWrapper = styled('div')({ + display: 'flex', + gap: '20px', + + '& .MuiAutocomplete-root [data-testid="inputLabelWrapper"] ': { + width: 0, + }, +}); diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/DestinationAkamaiObjectStorageDetailsSummary.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/DestinationAkamaiObjectStorageDetailsSummary.tsx index cd016649a83..532310a2891 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/DestinationAkamaiObjectStorageDetailsSummary.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/DestinationAkamaiObjectStorageDetailsSummary.tsx @@ -16,13 +16,11 @@ export const DestinationAkamaiObjectStorageDetailsSummary = ( {!!path && } diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/DestinationCustomHTTPSDetailsSummary.test.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/DestinationCustomHTTPSDetailsSummary.test.tsx new file mode 100644 index 00000000000..7b33c465185 --- /dev/null +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/DestinationCustomHTTPSDetailsSummary.test.tsx @@ -0,0 +1,135 @@ +import { dataCompressionType } from '@linode/api-v4'; +import { screen } from '@testing-library/react'; +import React from 'react'; +import { expect } from 'vitest'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { DestinationCustomHTTPSDetailsSummary } from './DestinationCustomHTTPSDetailsSummary'; + +import type { CustomHTTPSDetails } from '@linode/api-v4'; + +describe('DestinationCustomHTTPSDetailsSummary', () => { + it('renders basic authentication details correctly', () => { + const details: CustomHTTPSDetails = { + authentication: { + type: 'basic', + details: { + basic_authentication_user: 'testuser', + }, + }, + endpoint_url: 'https://example.com/', + data_compression: dataCompressionType.Gzip, + }; + + renderWithTheme(); + + // Authentication: + expect(screen.getByText('basic')).toBeVisible(); + // Endpoint URL: + expect(screen.getByText('https://example.com/')).toBeVisible(); + // Username: + expect(screen.getByText('testuser')).toBeVisible(); + // Password: + expect(screen.getByTestId('password')).toHaveTextContent( + '*****************' + ); + }); + + it('renders none authentication without username and password', () => { + const details: CustomHTTPSDetails = { + authentication: { + type: 'none', + }, + endpoint_url: 'https://example.com/', + data_compression: dataCompressionType.Gzip, + }; + + renderWithTheme(); + + // Authentication: + expect(screen.getByText('none')).toBeVisible(); + // Endpoint URL: + expect(screen.getByText('https://example.com/')).toBeVisible(); + // Username: + expect(screen.queryByText('Username')).not.toBeInTheDocument(); + // Password: + expect(screen.queryByTestId('password')).not.toBeInTheDocument(); + }); + + it('renders client certificate details when provided', () => { + const details: CustomHTTPSDetails = { + authentication: { type: 'none' }, + endpoint_url: 'https://example.com/', + client_certificate_details: { + tls_hostname: 'tls.example.com', + client_ca_certificate: 'ca-cert-content', + client_certificate: 'client-cert-content', + client_private_key: 'private-key-content', + }, + data_compression: dataCompressionType.Gzip, + }; + + renderWithTheme(); + + expect(screen.getByText('Additional Options')).toBeVisible(); + expect(screen.queryByTestId('client-certificate-header')).toBeVisible(); + // TLS Hostname: + expect(screen.getByText('tls.example.com')).toBeVisible(); + // CA Certificate: + expect(screen.getByText('ca-cert-content')).toBeVisible(); + // Client Certificate: + expect(screen.getByText('client-cert-content')).toBeVisible(); + // Client Key: + expect(screen.getByText('private-key-content')).toBeVisible(); + }); + + it('renders content type when provided', () => { + const details: CustomHTTPSDetails = { + authentication: { type: 'none' }, + endpoint_url: 'https://example.com/', + content_type: 'application/json', + data_compression: dataCompressionType.Gzip, + }; + + renderWithTheme(); + + expect(screen.getByText('HTTPS Headers')).toBeVisible(); + expect(screen.getByText('application/json')).toBeVisible(); + }); + + it('renders custom headers when provided', () => { + const details: CustomHTTPSDetails = { + authentication: { type: 'none' }, + endpoint_url: 'https://example.com/', + custom_headers: [ + { name: 'X-Custom-Header', value: 'custom-value' }, + { name: 'Authorization', value: 'Bearer token123' }, + ], + data_compression: dataCompressionType.Gzip, + }; + + renderWithTheme(); + + expect(screen.getByText('HTTPS Headers')).toBeVisible(); + // Custom Header 1: + expect(screen.getByText('X-Custom-Header')).toBeVisible(); + expect(screen.getByText('custom-value')).toBeVisible(); + // Custom Header 2: + expect(screen.getByText('Authorization')).toBeVisible(); + expect(screen.getByText('Bearer token123')).toBeVisible(); + }); + + it('does not render Additional Options section when no optional fields provided', () => { + const details: CustomHTTPSDetails = { + authentication: { type: 'none' }, + endpoint_url: 'https://example.com/', + data_compression: dataCompressionType.Gzip, + }; + + renderWithTheme(); + + expect(screen.queryByText('Additional Options')).not.toBeInTheDocument(); + expect(screen.queryByText('HTTPS Headers')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/DestinationCustomHTTPSDetailsSummary.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/DestinationCustomHTTPSDetailsSummary.tsx new file mode 100644 index 00000000000..87600df00dc --- /dev/null +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/DestinationCustomHTTPSDetailsSummary.tsx @@ -0,0 +1,89 @@ +import { Divider, Typography } from '@linode/ui'; +import React from 'react'; + +import { LabelValue } from 'src/features/Delivery/Shared/LabelValue'; + +import type { CustomHTTPSDetails } from '@linode/api-v4'; + +export const DestinationCustomHTTPSDetailsSummary = ( + props: CustomHTTPSDetails +) => { + const { + authentication, + endpoint_url, + client_certificate_details, + content_type, + custom_headers, + } = props; + + return ( + <> + + + {authentication.type === 'basic' && ( + <> + + + + )} + + {(!!client_certificate_details || !!content_type || !!custom_headers) && ( + <> + + Additional Options + + {!!client_certificate_details && ( + <> + + Client Certificate + + + + + + + )} + {(!!content_type || !!custom_headers) && ( + + HTTPS Headers + + )} + {!!content_type && ( + + )} + {!!custom_headers && + custom_headers.map(({ name, value }, idx) => ( + + ))} + + )} + + ); +}; diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/StreamFormDelivery.test.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/StreamFormDelivery.test.tsx index 656b8b70792..9218718a04f 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/StreamFormDelivery.test.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/StreamFormDelivery.test.tsx @@ -4,16 +4,25 @@ import userEvent from '@testing-library/user-event'; import React from 'react'; import { beforeEach, describe, expect } from 'vitest'; -import { destinationFactory } from 'src/factories'; +import { + akamaiObjectStorageDestinationFactory, + customHttpsDestinationFactory, +} from 'src/factories'; import { makeResourcePage } from 'src/mocks/serverHandlers'; import { http, HttpResponse, server } from 'src/mocks/testServer'; import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; import { StreamFormDelivery } from './StreamFormDelivery'; +import type { DestinationType } from '@linode/api-v4'; +import type { Flags } from 'src/featureFlags'; + const loadingTestId = 'circle-progress'; -const mockDestinations = destinationFactory.buildList(5); +const mockDestinations = [ + ...akamaiObjectStorageDestinationFactory.buildList(2), + ...customHttpsDestinationFactory.buildList(2), +]; describe('StreamFormDelivery', () => { const setDisableTestConnection = () => {}; @@ -26,70 +35,10 @@ describe('StreamFormDelivery', () => { ); }); - it('should render disabled Destination Type input with proper selection', async () => { - renderWithThemeAndHookFormContext({ - component: ( - - ), - useFormOptions: { - defaultValues: { - destination: { - type: destinationType.AkamaiObjectStorage, - }, - }, - }, - }); - - const loadingElement = screen.queryByTestId(loadingTestId); - expect(loadingElement).toBeInTheDocument(); - await waitForElementToBeRemoved(loadingElement); - - const destinationTypeAutocomplete = - screen.getByLabelText('Destination Type'); - - expect(destinationTypeAutocomplete).toBeDisabled(); - expect(destinationTypeAutocomplete).toHaveValue('Akamai Object Storage'); - }); - - it('should render Destination Name input and allow to select an existing option', async () => { - renderWithThemeAndHookFormContext({ - component: ( - - ), - useFormOptions: { - defaultValues: { - destination: { - label: '', - type: destinationType.AkamaiObjectStorage, - }, - }, - }, - }); - - const loadingElement = screen.queryByTestId(loadingTestId); - expect(loadingElement).toBeInTheDocument(); - await waitForElementToBeRemoved(loadingElement); - - const destinationNameAutocomplete = - screen.getByLabelText('Destination Name'); - - // Open the dropdown - await userEvent.click(destinationNameAutocomplete); - - // Select the "Destination 1" option - const firstDestination = await screen.findByText('Destination 1'); - await userEvent.click(firstDestination); - - expect(destinationNameAutocomplete).toHaveValue('Destination 1'); - }); - - const renderComponentAndAddNewDestinationName = async () => { + const renderComponentAndAddNewDestinationName = async ( + destinationTypeToSet: DestinationType, + flags: Partial + ) => { renderWithThemeAndHookFormContext({ component: ( { }, }, }, + options: { flags }, }); const loadingElement = screen.queryByTestId(loadingTestId); expect(loadingElement).toBeInTheDocument(); await waitForElementToBeRemoved(loadingElement); + if ( + flags.aclpLogs?.customHttpsEnabled && + destinationTypeToSet === destinationType.CustomHttps + ) { + const destinationTypeAutocomplete = + screen.getByLabelText('Destination Type'); + + expect(destinationTypeAutocomplete).toBeEnabled(); + await userEvent.click(destinationTypeAutocomplete); + const customHttpsOption = await screen.findByText('Custom HTTPS'); + await userEvent.click(customHttpsOption); + expect(destinationTypeAutocomplete).toHaveValue('Custom HTTPS'); + } + const destinationNameAutocomplete = screen.getByLabelText('Destination Name'); @@ -131,65 +95,532 @@ describe('StreamFormDelivery', () => { await userEvent.click(createNewTestDestination); }; - it('should render Destination Name input and allow to add a new option', async () => { - await renderComponentAndAddNewDestinationName(); - - const destinationNameAutocomplete = - screen.getByLabelText('Destination Name'); - - // Move focus away from the dropdown - await userEvent.tab(); - - expect(destinationNameAutocomplete).toHaveValue('New test destination'); - }); - - it('should render Host input after adding a new destination name and allow to type text', async () => { - await renderComponentAndAddNewDestinationName(); - - // Type the test value inside the input - const hostInput = screen.getByLabelText('Host'); - await userEvent.type(hostInput, 'Test'); + describe('when customHttpsEnabled feature flag is set to false', () => { + const flags = { + aclpLogs: { + enabled: true, + beta: false, + customHttpsEnabled: false, + }, + }; + + it('should render disabled Destination Type input with Akamai Object Storage selected', async () => { + renderWithThemeAndHookFormContext({ + component: ( + + ), + useFormOptions: { + defaultValues: { + destination: { + type: destinationType.AkamaiObjectStorage, + }, + }, + }, + }); - expect(hostInput.getAttribute('value')).toEqual('Test'); - }); + const loadingElement = screen.queryByTestId(loadingTestId); + expect(loadingElement).toBeInTheDocument(); + await waitForElementToBeRemoved(loadingElement); - it('should render Bucket input after adding a new destination name and allow to type text', async () => { - await renderComponentAndAddNewDestinationName(); + const destinationTypeAutocomplete = + screen.getByLabelText('Destination Type'); - // Type the test value inside the input - const bucketInput = screen.getByLabelText('Bucket'); - await userEvent.type(bucketInput, 'test'); + expect(destinationTypeAutocomplete).toBeDisabled(); + expect(destinationTypeAutocomplete).toHaveValue('Akamai Object Storage'); + }); - expect(bucketInput.getAttribute('value')).toEqual('test'); + describe('and Destination Type is set to Akamai Object Storage', () => { + it('should render Destination Name input and allow to select an existing option', async () => { + renderWithThemeAndHookFormContext({ + component: ( + + ), + useFormOptions: { + defaultValues: { + destination: { + label: '', + type: destinationType.AkamaiObjectStorage, + }, + }, + }, + }); + + const loadingElement = screen.queryByTestId(loadingTestId); + expect(loadingElement).toBeInTheDocument(); + await waitForElementToBeRemoved(loadingElement); + + const destinationNameAutocomplete = + screen.getByLabelText('Destination Name'); + + // Open the dropdown + await userEvent.click(destinationNameAutocomplete); + + // Select the "Destination 1" option + const firstDestination = await screen.findByText( + 'Akamai Object Storage Destination 1' + ); + await userEvent.click(firstDestination); + + expect(destinationNameAutocomplete).toHaveValue( + 'Akamai Object Storage Destination 1' + ); + }); + + it('should render Destination Name input and allow to add a new option', async () => { + await renderComponentAndAddNewDestinationName( + destinationType.AkamaiObjectStorage, + flags + ); + + const destinationNameAutocomplete = + screen.getByLabelText('Destination Name'); + + // Move focus away from the dropdown + await userEvent.tab(); + + expect(destinationNameAutocomplete).toHaveValue('New test destination'); + }); + + describe('and new Destination Name is added', () => { + it('should render Host input and allow to type text', async () => { + await renderComponentAndAddNewDestinationName( + destinationType.AkamaiObjectStorage, + flags + ); + + // Type the test value inside the input + const hostInput = screen.getByLabelText('Host'); + await userEvent.type(hostInput, 'Test'); + + expect(hostInput.getAttribute('value')).toEqual('Test'); + }); + + it('should render Bucket input and allow to type text', async () => { + await renderComponentAndAddNewDestinationName( + destinationType.AkamaiObjectStorage, + flags + ); + + // Type the test value inside the input + const bucketInput = screen.getByLabelText('Bucket'); + await userEvent.type(bucketInput, 'test'); + + expect(bucketInput.getAttribute('value')).toEqual('test'); + }); + + it('should render Access Key ID input and allow to type text', async () => { + await renderComponentAndAddNewDestinationName( + destinationType.AkamaiObjectStorage, + flags + ); + + // Type the test value inside the input + const accessKeyIDInput = screen.getByLabelText('Access Key ID'); + await userEvent.type(accessKeyIDInput, 'Test'); + + expect(accessKeyIDInput.getAttribute('value')).toEqual('Test'); + }); + + it('should render Secret Access Key input and allow to type text', async () => { + await renderComponentAndAddNewDestinationName( + destinationType.AkamaiObjectStorage, + flags + ); + + // Type the test value inside the input + const secretAccessKeyInput = + screen.getByLabelText('Secret Access Key'); + await userEvent.type(secretAccessKeyInput, 'Test'); + + expect(secretAccessKeyInput.getAttribute('value')).toEqual('Test'); + }); + + it('should render Log Path Prefix input and allow to type text', async () => { + await renderComponentAndAddNewDestinationName( + destinationType.AkamaiObjectStorage, + flags + ); + + // Type the test value inside the input + const logPathPrefixInput = screen.getByLabelText( + 'Log Path Prefix (optional)' + ); + await userEvent.type(logPathPrefixInput, 'Test'); + + expect(logPathPrefixInput.getAttribute('value')).toEqual('Test'); + }); + }); + }); }); - it('should render Access Key ID input after adding a new destination name and allow to type text', async () => { - await renderComponentAndAddNewDestinationName(); - - // Type the test value inside the input - const accessKeyIDInput = screen.getByLabelText('Access Key ID'); - await userEvent.type(accessKeyIDInput, 'Test'); - - expect(accessKeyIDInput.getAttribute('value')).toEqual('Test'); - }); + describe('when customHttpsEnabled feature flag is set to true', () => { + const flags = { + aclpLogs: { + enabled: true, + beta: false, + customHttpsEnabled: true, + }, + }; + + it('should render enabled Destination Type input with Akamai Object Storage selected and allow to select Custom HTTPS', async () => { + renderWithThemeAndHookFormContext({ + component: ( + + ), + useFormOptions: { + defaultValues: { + destination: { + type: destinationType.AkamaiObjectStorage, + }, + }, + }, + options: { + flags, + }, + }); - it('should render Secret Access Key input after adding a new destination name and allow to type text', async () => { - await renderComponentAndAddNewDestinationName(); + const loadingElement = screen.queryByTestId(loadingTestId); + expect(loadingElement).toBeInTheDocument(); + await waitForElementToBeRemoved(loadingElement); - // Type the test value inside the input - const secretAccessKeyInput = screen.getByLabelText('Secret Access Key'); - await userEvent.type(secretAccessKeyInput, 'Test'); + const destinationTypeAutocomplete = + screen.getByLabelText('Destination Type'); - expect(secretAccessKeyInput.getAttribute('value')).toEqual('Test'); - }); + expect(destinationTypeAutocomplete).toBeEnabled(); + expect(destinationTypeAutocomplete).toHaveValue('Akamai Object Storage'); + await userEvent.click(destinationTypeAutocomplete); + const customHttpsOption = await screen.findByText('Custom HTTPS'); + await userEvent.click(customHttpsOption); + expect(destinationTypeAutocomplete).toHaveValue('Custom HTTPS'); + }); - it('should render Log Path Prefix input after adding a new destination name and allow to type text', async () => { - await renderComponentAndAddNewDestinationName(); + describe('and Destination Type is set to Custom HTTPS', () => { + it('should render Destination Name input and allow to select an existing option', async () => { + renderWithThemeAndHookFormContext({ + component: ( + + ), + useFormOptions: { + defaultValues: { + destination: { + label: '', + type: destinationType.CustomHttps, + }, + }, + }, + options: { + flags, + }, + }); - // Type the test value inside the input - const logPathPrefixInput = screen.getByLabelText('Log Path Prefix'); - await userEvent.type(logPathPrefixInput, 'Test'); + const loadingElement = screen.queryByTestId(loadingTestId); + expect(loadingElement).toBeInTheDocument(); + await waitForElementToBeRemoved(loadingElement); - expect(logPathPrefixInput.getAttribute('value')).toEqual('Test'); + const destinationNameAutocomplete = + screen.getByLabelText('Destination Name'); + + // Open the dropdown + await userEvent.click(destinationNameAutocomplete); + + // Select the "Custom HTTPS Destination 2" option + const customHttpsDestination = await screen.findByText( + 'Custom HTTPS Destination 2' + ); + await userEvent.click(customHttpsDestination); + + expect(destinationNameAutocomplete).toHaveValue( + 'Custom HTTPS Destination 2' + ); + }); + + it('should render Destination Name input and allow to add a new option', async () => { + await renderComponentAndAddNewDestinationName( + destinationType.CustomHttps, + flags + ); + + const destinationNameAutocomplete = + screen.getByLabelText('Destination Name'); + + // Move focus away from the dropdown + await userEvent.tab(); + + expect(destinationNameAutocomplete).toHaveValue('New test destination'); + }); + + describe('and new Destination Name is added', () => { + it('should render Authentication autocomplete with None selected and allow to select Basic', async () => { + await renderComponentAndAddNewDestinationName( + destinationType.CustomHttps, + flags + ); + + const authenticationAutocomplete = + screen.getByLabelText('Authentication'); + + expect(authenticationAutocomplete).toHaveValue('None'); + + // Open the dropdown + await userEvent.click(authenticationAutocomplete); + + // Select the "Basic" option + const basicAuthentication = await screen.findByText('Basic'); + await userEvent.click(basicAuthentication); + + expect(authenticationAutocomplete).toHaveValue('Basic'); + }); + + describe('and Authentication is set to Basic', () => { + it('should render Username input and allow to type text', async () => { + await renderComponentAndAddNewDestinationName( + destinationType.CustomHttps, + flags + ); + + // Select the "Basic" Authentication option + const authenticationAutocomplete = + screen.getByLabelText('Authentication'); + await userEvent.click(authenticationAutocomplete); + const basicAuthentication = await screen.findByText('Basic'); + await userEvent.click(basicAuthentication); + + expect(authenticationAutocomplete).toHaveValue('Basic'); + + // Type the test value inside the input + const usernameInput = screen.getByLabelText('Username'); + await userEvent.type(usernameInput, 'Test'); + + expect(usernameInput.getAttribute('value')).toEqual('Test'); + }); + + it('should render Password input and allow to type text', async () => { + await renderComponentAndAddNewDestinationName( + destinationType.CustomHttps, + flags + ); + + // Select the "Basic" Authentication option + const authenticationAutocomplete = + screen.getByLabelText('Authentication'); + await userEvent.click(authenticationAutocomplete); + const basicAuthentication = await screen.findByText('Basic'); + await userEvent.click(basicAuthentication); + + expect(authenticationAutocomplete).toHaveValue('Basic'); + + // Type the test value inside the input + const passwordInput = screen.getByLabelText('Password'); + await userEvent.type(passwordInput, 'Test'); + + expect(passwordInput.getAttribute('value')).toEqual('Test'); + }); + }); + + it('should render Endpoint URL input and allow to type text', async () => { + await renderComponentAndAddNewDestinationName( + destinationType.CustomHttps, + flags + ); + + // Type the test value inside the input + const endpointUrlInput = screen.getByLabelText('Endpoint URL'); + await userEvent.type(endpointUrlInput, 'Test'); + + expect(endpointUrlInput.getAttribute('value')).toEqual('Test'); + }); + + describe('Client Certificate fields', () => { + it('should render TLS Hostname input and allow to type text', async () => { + await renderComponentAndAddNewDestinationName( + destinationType.CustomHttps, + flags + ); + + const tlsHostnameInput = screen.getByLabelText('TLS Hostname'); + await userEvent.type(tlsHostnameInput, 'test'); + + expect(tlsHostnameInput).toHaveValue('test'); + }); + + it('should render CA Certificate input and allow to type text', async () => { + await renderComponentAndAddNewDestinationName( + destinationType.CustomHttps, + flags + ); + + const caCertificateInput = screen.getByLabelText('CA Certificate'); + await userEvent.type(caCertificateInput, 'test'); + + expect(caCertificateInput).toHaveValue('test'); + }); + + it('should render Client Certificate input and allow to type text', async () => { + await renderComponentAndAddNewDestinationName( + destinationType.CustomHttps, + flags + ); + + const clientCertificateInput = + screen.getByLabelText('Client Certificate'); + await userEvent.type(clientCertificateInput, 'test'); + + expect(clientCertificateInput).toHaveValue('test'); + }); + + it('should render Client Key input and allow to type text', async () => { + await renderComponentAndAddNewDestinationName( + destinationType.CustomHttps, + flags + ); + + const clientKeyInput = screen.getByLabelText('Client Key'); + await userEvent.type(clientKeyInput, 'test'); + + expect(clientKeyInput).toHaveValue('test'); + }); + }); + + describe('HTTPS Headers fields', () => { + it('should render Content Type autocomplete and allow to select application/json', async () => { + await renderComponentAndAddNewDestinationName( + destinationType.CustomHttps, + flags + ); + + const contentTypeAutocomplete = + screen.getByLabelText('Content Type'); + expect(contentTypeAutocomplete).toHaveValue(''); + + await userEvent.click(contentTypeAutocomplete); + const jsonOption = await screen.findByText('application/json'); + await userEvent.click(jsonOption); + + expect(contentTypeAutocomplete).toHaveValue('application/json'); + }); + + it('should render Content Type autocomplete and allow to select application/json; charset=utf-8', async () => { + await renderComponentAndAddNewDestinationName( + destinationType.CustomHttps, + flags + ); + + const contentTypeAutocomplete = + screen.getByLabelText('Content Type'); + + await userEvent.click(contentTypeAutocomplete); + const jsonUtf8Option = await screen.findByText( + 'application/json; charset=utf-8' + ); + await userEvent.click(jsonUtf8Option); + + expect(contentTypeAutocomplete).toHaveValue( + 'application/json; charset=utf-8' + ); + }); + + describe('Custom Headers', () => { + const addCustomHeaderButtonText = 'Add Custom Header'; + + it('should add a custom header when clicking Add Custom Header button and allow typing in Custom Header fields', async () => { + await renderComponentAndAddNewDestinationName( + destinationType.CustomHttps, + flags + ); + + const addCustomHeaderButton = screen.getByRole('button', { + name: addCustomHeaderButtonText, + }); + await userEvent.click(addCustomHeaderButton); + + const headerNameInput = screen.getByLabelText('Name'); + expect(headerNameInput).toBeInTheDocument(); + + const headerValueInput = screen.getByLabelText('Value'); + expect(headerValueInput).toBeInTheDocument(); + + await userEvent.type(headerNameInput, 'X-Custom-Header'); + expect(headerNameInput).toHaveValue('X-Custom-Header'); + + await userEvent.type(headerValueInput, 'custom-value'); + expect(headerValueInput).toHaveValue('custom-value'); + }); + + it('should update custom header title when Name is typed', async () => { + await renderComponentAndAddNewDestinationName( + destinationType.CustomHttps, + flags + ); + + const addCustomHeaderButton = screen.getByRole('button', { + name: addCustomHeaderButtonText, + }); + await userEvent.click(addCustomHeaderButton); + + // Verify default title is shown initially + screen.getByText('Custom Header 1'); + + const headerNameInput = screen.getByLabelText('Name'); + await userEvent.type(headerNameInput, 'Authorization'); + + // Verify default title is replaced with the typed name + expect( + screen.queryByText('Custom Header 1') + ).not.toBeInTheDocument(); + screen.getByText('Authorization'); + }); + + it('should remove custom header when clicking close button', async () => { + await renderComponentAndAddNewDestinationName( + destinationType.CustomHttps, + flags + ); + + const addCustomHeaderButton = screen.getByRole('button', { + name: addCustomHeaderButtonText, + }); + await userEvent.click(addCustomHeaderButton); + + const headerNameInput = screen.getByLabelText('Name'); + expect(headerNameInput).toBeInTheDocument(); + + const closeButton = screen.getByRole('button', { name: '' }); + await userEvent.click(closeButton); + + expect(screen.queryByLabelText('Name')).not.toBeInTheDocument(); + }); + + it('should allow adding multiple custom headers', async () => { + await renderComponentAndAddNewDestinationName( + destinationType.CustomHttps, + flags + ); + + const addCustomHeaderButton = screen.getByRole('button', { + name: addCustomHeaderButtonText, + }); + + await userEvent.click(addCustomHeaderButton); + screen.getByText('Custom Header 1'); + + await userEvent.click(addCustomHeaderButton); + expect(screen.getByText('Custom Header 2')).toBeInTheDocument(); + }); + }); + }); + }); + }); }); }); diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/StreamFormDelivery.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/StreamFormDelivery.tsx index 9034a9bc41d..2c7052b1006 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/StreamFormDelivery.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/StreamFormDelivery.tsx @@ -1,4 +1,8 @@ -import { destinationType } from '@linode/api-v4'; +import { + authenticationType, + dataCompressionType, + destinationType, +} from '@linode/api-v4'; import { useAllDestinationsQuery } from '@linode/queries'; import { Autocomplete, @@ -15,13 +19,21 @@ import { useTheme } from '@mui/material/styles'; import React, { useEffect, useState } from 'react'; import { Controller, useFormContext, useWatch } from 'react-hook-form'; -import { getDestinationTypeOption } from 'src/features/Delivery/deliveryUtils'; +import { + getDestinationTypeOption, + useIsACLPLogsEnabled, +} from 'src/features/Delivery/deliveryUtils'; import { DestinationAkamaiObjectStorageDetailsForm } from 'src/features/Delivery/Shared/DestinationAkamaiObjectStorageDetailsForm'; +import { DestinationCustomHttpsDetailsForm } from 'src/features/Delivery/Shared/DestinationCustomHttpsDetailsForm'; import { destinationTypeOptions } from 'src/features/Delivery/Shared/types'; import { DestinationAkamaiObjectStorageDetailsSummary } from 'src/features/Delivery/Streams/StreamForm/Delivery/DestinationAkamaiObjectStorageDetailsSummary'; +import { DestinationCustomHTTPSDetailsSummary } from 'src/features/Delivery/Streams/StreamForm/Delivery/DestinationCustomHTTPSDetailsSummary'; import type { AkamaiObjectStorageDetails, + AkamaiObjectStorageDetailsExtended, + CustomHTTPSDetails, + CustomHTTPSDetailsExtended, DestinationType, } from '@linode/api-v4'; import type { FormMode } from 'src/features/Delivery/Shared/types'; @@ -35,7 +47,7 @@ interface DestinationName { type?: DestinationType; } -const controlPaths = { +const akamaiObjectStorageDetailsControlPaths = { accessKeyId: 'destination.details.access_key_id', accessKeySecret: 'destination.details.access_key_secret', bucketName: 'destination.details.bucket_name', @@ -43,6 +55,26 @@ const controlPaths = { path: 'destination.details.path', } as const; +const customHttpsDetailsControlPaths = { + authenticationType: 'destination.details.authentication.type', + basicAuthenticationPassword: + 'destination.details.authentication.details.basic_authentication_password', + basicAuthenticationUser: + 'destination.details.authentication.details.basic_authentication_user', + clientCertificateDetails: 'destination.details.client_certificate_details', + clientCaCertificate: + 'destination.details.client_certificate_details.client_ca_certificate', + clientCertificate: + 'destination.details.client_certificate_details.client_certificate', + clientPrivateKey: + 'destination.details.client_certificate_details.client_private_key', + tlsHostname: 'destination.details.client_certificate_details.tls_hostname', + contentType: 'destination.details.content_type', + customHeaders: 'destination.details.custom_headers', + dataCompression: 'destination.details.data_compression', + endpointUrl: 'destination.details.endpoint_url', +} as const; + interface StreamFormDeliveryProps { mode: FormMode; setDisableTestConnection: (disable: boolean) => void; @@ -51,8 +83,9 @@ interface StreamFormDeliveryProps { export const StreamFormDelivery = (props: StreamFormDeliveryProps) => { const { mode, setDisableTestConnection } = props; + const { isACLPLogsCustomHttpsEnabled } = useIsACLPLogsEnabled(); const theme = useTheme(); - const { control, setValue, clearErrors } = + const { control, setValue, getValues, reset } = useFormContext(); const { data: destinations, isLoading, error } = useAllDestinationsQuery(); @@ -90,10 +123,40 @@ export const StreamFormDelivery = (props: StreamFormDeliveryProps) => { const findDestination = (id: number) => destinations?.find((destination) => destination.id === id); - const restDestinationForm = () => { - Object.values(controlPaths).forEach((controlPath) => - setValue(controlPath, '') - ); + const resetDestinationForm = ( + destType: DestinationType, + destinationLabel?: null | string + ) => { + const currentValues = getValues(); + const newDestinationDetails = + destType === destinationType.AkamaiObjectStorage + ? { + path: '', + } + : { + authentication: { + type: authenticationType.None, + }, + client_certificate_details: { + client_ca_certificate: '', + client_certificate: '', + client_private_key: '', + tls_hostname: '', + }, + data_compression: dataCompressionType.Gzip, + }; + + reset({ + stream: { + ...currentValues.stream, + destinations: [], + }, + destination: { + ...currentValues.destination, + label: destinationLabel || '', + details: newDestinationDetails, + }, + }); }; const getDestinationForm = () => ( @@ -104,12 +167,14 @@ export const StreamFormDelivery = (props: StreamFormDeliveryProps) => { render={({ field, fieldState }) => ( { field.onChange(value); + resetDestinationForm(value as DestinationType); + setCreatingNewDestination(false); }} options={destinationTypeOptions} textFieldProps={{ @@ -152,18 +217,24 @@ export const StreamFormDelivery = (props: StreamFormDeliveryProps) => { const id = newValue?.id; if (id === undefined && selectedDestinations.length > 0) { - restDestinationForm(); + resetDestinationForm( + selectedDestinationType, + (newValue?.label || newValue) as null | string + ); } - setValue('stream.destinations', id ? [id] : []); - const selectedDestination = id ? findDestination(id) : undefined; - if (selectedDestination) { - setValue('destination.details', { - ...selectedDestination.details, - access_key_secret: '', - }); - } else { - clearErrors('destination.details'); + if (id) { + setValue('stream.destinations', [id]); + const selectedDestination = findDestination(id); + if (selectedDestination) { + setValue( + 'destination.details', + selectedDestinationType === + destinationType.AkamaiObjectStorage + ? (selectedDestination.details as AkamaiObjectStorageDetailsExtended) + : (selectedDestination.details as CustomHTTPSDetailsExtended) + ); + } } field.onChange(newValue?.label || newValue); @@ -226,7 +297,7 @@ export const StreamFormDelivery = (props: StreamFormDeliveryProps) => { <> {creatingNewDestination && !selectedDestinations?.length && ( @@ -239,6 +310,24 @@ export const StreamFormDelivery = (props: StreamFormDeliveryProps) => { )} )} + {isACLPLogsCustomHttpsEnabled && + selectedDestinationType === destinationType.CustomHttps && ( + <> + {creatingNewDestination && !selectedDestinations?.length && ( + + )} + {selectedDestinations?.[0] && ( + + )} + + )} ); diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamCreate.test.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamCreate.test.tsx index 388cbc762ea..700f766fc9c 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamCreate.test.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamCreate.test.tsx @@ -4,14 +4,17 @@ import userEvent from '@testing-library/user-event'; import React from 'react'; import { describe, expect } from 'vitest'; -import { destinationFactory } from 'src/factories'; +import { akamaiObjectStorageDestinationFactory } from 'src/factories'; import { StreamCreate } from 'src/features/Delivery/Streams/StreamForm/StreamCreate'; import { makeResourcePage } from 'src/mocks/serverHandlers'; import { http, HttpResponse, server } from 'src/mocks/testServer'; import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; const mockDestinations = [ - destinationFactory.build({ id: 1, label: 'Destination 1' }), + akamaiObjectStorageDestinationFactory.build({ + id: 1, + label: 'Destination 1', + }), ]; describe('StreamCreate', () => { @@ -63,7 +66,9 @@ describe('StreamCreate', () => { await userEvent.type(accessKeyIDInput, 'Test'); const secretAccessKeyInput = screen.getByLabelText('Secret Access Key'); await userEvent.type(secretAccessKeyInput, 'Test'); - const logPathPrefixInput = screen.getByLabelText('Log Path Prefix'); + const logPathPrefixInput = screen.getByLabelText( + 'Log Path Prefix (optional)' + ); await userEvent.type(logPathPrefixInput, 'Test'); }; diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamEdit.test.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamEdit.test.tsx index deab21e2c4f..ee51755cfd2 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamEdit.test.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamEdit.test.tsx @@ -7,7 +7,10 @@ import userEvent from '@testing-library/user-event'; import React from 'react'; import { describe, expect } from 'vitest'; -import { destinationFactory, streamFactory } from 'src/factories'; +import { + akamaiObjectStorageDestinationFactory, + streamFactory, +} from 'src/factories'; import { StreamEdit } from 'src/features/Delivery/Streams/StreamForm/StreamEdit'; import { makeResourcePage } from 'src/mocks/serverHandlers'; import { http, HttpResponse, server } from 'src/mocks/testServer'; @@ -15,7 +18,9 @@ import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; const loadingTestId = 'circle-progress'; const streamId = 123; -const mockDestinations = [destinationFactory.build({ id: 1 })]; +const mockDestinations = [ + akamaiObjectStorageDestinationFactory.build({ id: 1 }), +]; const mockStream = streamFactory.build({ id: streamId, label: `Stream ${streamId}`, @@ -60,7 +65,10 @@ describe('StreamEdit', () => { await waitFor(() => { assertInputHasValue('Destination Type', 'Akamai Object Storage'); }); - assertInputHasValue('Destination Name', 'Destination 1'); + assertInputHasValue( + 'Destination Name', + 'Akamai Object Storage Destination 1' + ); // Host: expect(screen.getByText('destinations-bucket-name.host.com')).toBeVisible(); @@ -104,7 +112,9 @@ describe('StreamEdit', () => { await userEvent.type(accessKeyIDInput, 'Test'); const secretAccessKeyInput = screen.getByLabelText('Secret Access Key'); await userEvent.type(secretAccessKeyInput, 'Test'); - const logPathPrefixInput = screen.getByLabelText('Log Path Prefix'); + const logPathPrefixInput = screen.getByLabelText( + 'Log Path Prefix (optional)' + ); await userEvent.type(logPathPrefixInput, 'Test'); }; diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamForm.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamForm.tsx index 19b01c72a73..b0eb935c4f3 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamForm.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamForm.tsx @@ -106,7 +106,10 @@ export const StreamForm = (props: StreamFormProps) => { try { const destinationPayload: CreateDestinationPayload = { ...destination, - details: getDestinationPayloadDetails(destination.details), + details: getDestinationPayloadDetails( + destination.details, + destination.type + ), }; const { id } = await createDestination(destinationPayload); destinationId = id; diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamFormGeneralInfo.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamFormGeneralInfo.tsx index 602477cf379..56097376e38 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamFormGeneralInfo.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamFormGeneralInfo.tsx @@ -1,5 +1,4 @@ import { streamType } from '@linode/api-v4'; -import { useAccount } from '@linode/queries'; import { Autocomplete, Box, @@ -16,6 +15,7 @@ import { Controller, useFormContext, useWatch } from 'react-hook-form'; import { getStreamTypeOption, isFormInEditMode, + useIsLkeEAuditLogsTypeSelectionEnabled, } from 'src/features/Delivery/deliveryUtils'; import { streamTypeOptions } from 'src/features/Delivery/Shared/types'; @@ -36,10 +36,8 @@ export const StreamFormGeneralInfo = (props: StreamFormGeneralInfoProps) => { const theme = useTheme(); const { control, setValue } = useFormContext(); - const { data: account } = useAccount(); - const isLkeEAuditLogsTypeSelectionDisabled = !account?.capabilities?.includes( - 'Akamai Cloud Pulse Logs LKE-E Audit' - ); + const isLkeEAuditLogsTypeSelectionDisabled = + !useIsLkeEAuditLogsTypeSelectionEnabled(); const capitalizedMode = capitalize(mode); const description = { diff --git a/packages/manager/src/features/Delivery/Streams/StreamsLanding.tsx b/packages/manager/src/features/Delivery/Streams/StreamsLanding.tsx index 34b7fb5f5a2..cfa7d246829 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamsLanding.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamsLanding.tsx @@ -2,12 +2,12 @@ import { streamStatus } from '@linode/api-v4'; import { useStreamsQuery, useUpdateStreamMutation } from '@linode/queries'; import { CircleProgress, ErrorState, Hidden, Paper } from '@linode/ui'; import { TableBody, TableCell, TableHead, TableRow } from '@mui/material'; -import Table from '@mui/material/Table'; import { useNavigate, useSearch } from '@tanstack/react-router'; import { enqueueSnackbar } from 'notistack'; import * as React from 'react'; import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter'; +import { Table } from 'src/components/Table'; import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; import { TableSortCell } from 'src/components/TableSortCell'; import { DeliveryTabHeader } from 'src/features/Delivery/Shared/DeliveryTabHeader/DeliveryTabHeader'; diff --git a/packages/manager/src/features/Delivery/deliveryUtils.test.ts b/packages/manager/src/features/Delivery/deliveryUtils.test.ts index 7df8135efc7..8345d438544 100644 --- a/packages/manager/src/features/Delivery/deliveryUtils.test.ts +++ b/packages/manager/src/features/Delivery/deliveryUtils.test.ts @@ -1,20 +1,39 @@ -import { destinationType } from '@linode/api-v4'; +import { + authenticationType, + contentType, + destinationType, + streamType, +} from '@linode/api-v4'; import { expect } from 'vitest'; import { + getAuthenticationTypeOption, + getContentTypeOption, getDestinationPayloadDetails, getDestinationTypeOption, + getStreamTypeOption, } from 'src/features/Delivery/deliveryUtils'; -import { destinationTypeOptions } from 'src/features/Delivery/Shared/types'; +import { + authenticationTypeOptions, + contentTypeOptions, + destinationTypeOptions, + streamTypeOptions, +} from 'src/features/Delivery/Shared/types'; import type { AkamaiObjectStorageDetailsExtended, AkamaiObjectStorageDetailsPayload, + CustomHTTPSDetails, + CustomHTTPSDetailsExtended, } from '@linode/api-v4'; describe('delivery utils functions', () => { describe('getDestinationTypeOption ', () => { - it('should return option object matching provided value', () => { + it('should return option for CustomHttps', () => { + const result = getDestinationTypeOption(destinationType.CustomHttps); + expect(result).toEqual(destinationTypeOptions[0]); + }); + it('should return option option for AkamaiObjectStorage', () => { const result = getDestinationTypeOption( destinationType.AkamaiObjectStorage ); @@ -22,35 +41,232 @@ describe('delivery utils functions', () => { }); it('should return undefined when no option is a match', () => { - const result = getDestinationTypeOption('random value'); - expect(result).toEqual(undefined); + const result = getDestinationTypeOption('invalid'); + expect(result).toBeUndefined(); + }); + }); + + describe('getStreamTypeOption', () => { + it('should return option for AuditLogs', () => { + const result = getStreamTypeOption(streamType.AuditLogs); + expect(result).toEqual(streamTypeOptions[0]); + }); + + it('should return option for LKEAuditLogs', () => { + const result = getStreamTypeOption(streamType.LKEAuditLogs); + expect(result).toEqual(streamTypeOptions[1]); + }); + + it('should return undefined when no option is a match', () => { + const result = getStreamTypeOption('invalid'); + expect(result).toBeUndefined(); + }); + }); + + describe('getAuthenticationTypeOption', () => { + it('should return option for basic authentication', () => { + const result = getAuthenticationTypeOption(authenticationType.Basic); + expect(result).toEqual(authenticationTypeOptions[0]); + }); + + it('should return option for none authentication', () => { + const result = getAuthenticationTypeOption(authenticationType.None); + expect(result).toEqual(authenticationTypeOptions[1]); + }); + + it('should return undefined when no option is a match', () => { + const result = getAuthenticationTypeOption('invalid'); + expect(result).toBeUndefined(); + }); + }); + + describe('getContentTypeOption', () => { + it('should return option for application/json', () => { + const result = getContentTypeOption(contentType.Json); + expect(result).toEqual(contentTypeOptions[0]); + }); + + it('should return option for application/json; charset=utf-8', () => { + const result = getContentTypeOption(contentType.JsonUtf8); + expect(result).toEqual(contentTypeOptions[1]); + }); + + it('should return undefined when no option is a match', () => { + const result = getContentTypeOption('invalid'); + expect(result).toBeUndefined(); }); }); - describe('getDestinationPayloadDetails ', () => { - const testDetails: AkamaiObjectStorageDetailsExtended = { - path: 'testpath', - access_key_id: 'keyId', - access_key_secret: 'secret', - bucket_name: 'name', - host: 'host', - }; + describe('given getDestinationPayloadDetails ', () => { + describe('and AkamaiObjectStorage destination type ', () => { + const baseAkamaiObjectStorageDetails: AkamaiObjectStorageDetailsExtended = + { + path: 'testpath', + access_key_id: 'keyId', + access_key_secret: 'secret', + bucket_name: 'name', + host: 'host', + }; + + it('should return payload details with path', () => { + const result = getDestinationPayloadDetails( + baseAkamaiObjectStorageDetails, + destinationType.AkamaiObjectStorage + ) as AkamaiObjectStorageDetailsPayload; - it('should return payload details with path', () => { - const result = getDestinationPayloadDetails( - testDetails - ) as AkamaiObjectStorageDetailsPayload; + expect(result.path).toEqual(baseAkamaiObjectStorageDetails.path); + }); - expect(result.path).toEqual(testDetails.path); + it('should return details without path property', () => { + const result = getDestinationPayloadDetails( + { + ...baseAkamaiObjectStorageDetails, + path: '', + }, + destinationType.AkamaiObjectStorage + ) as AkamaiObjectStorageDetailsPayload; + + expect(result.path).toEqual(undefined); + }); }); - it('should return details without path property', () => { - const result = getDestinationPayloadDetails({ - ...testDetails, - path: '', - }) as AkamaiObjectStorageDetailsPayload; + describe('and CustomHttps destination type', () => { + const baseCustomHTTPSDetails: CustomHTTPSDetailsExtended = { + authentication: { + type: 'none', + }, + data_compression: 'gzip', + endpoint_url: 'https://example.com', + }; + + it('should return details unchanged when all optional fields are populated', () => { + const details: CustomHTTPSDetailsExtended = { + ...baseCustomHTTPSDetails, + content_type: 'application/json', + client_certificate_details: { + client_ca_certificate: 'cert', + client_certificate: 'cert', + client_private_key: 'key', + tls_hostname: 'hostname', + }, + }; + + const result = getDestinationPayloadDetails( + details, + destinationType.CustomHttps + ); + + expect(result).toEqual(details); + }); + + it('should omit content_type when it is null', () => { + const details: CustomHTTPSDetailsExtended = { + ...baseCustomHTTPSDetails, + content_type: null, + }; + + const result = getDestinationPayloadDetails( + details, + destinationType.CustomHttps + ) as CustomHTTPSDetails; + + expect(result.content_type).toBeUndefined(); + }); + + it('should omit client_certificate_details when all its properties are empty strings', () => { + const details: CustomHTTPSDetailsExtended = { + ...baseCustomHTTPSDetails, + client_certificate_details: { + client_ca_certificate: '', + client_certificate: '', + client_private_key: '', + tls_hostname: '', + }, + }; + + const result = getDestinationPayloadDetails( + details, + destinationType.CustomHttps + ) as CustomHTTPSDetailsExtended; + + expect(result.client_certificate_details).toBeUndefined(); + }); + + it('should omit client_certificate_details when all its properties are not defined', () => { + const details: CustomHTTPSDetailsExtended = { + ...baseCustomHTTPSDetails, + client_certificate_details: {}, + }; + + const result = getDestinationPayloadDetails( + details, + destinationType.CustomHttps + ) as CustomHTTPSDetailsExtended; + + expect(result.client_certificate_details).toBeUndefined(); + }); + + it('should omit client_certificate_details when any of its properties is empty', () => { + const details: CustomHTTPSDetailsExtended = { + ...baseCustomHTTPSDetails, + client_certificate_details: { + client_ca_certificate: 'some-cert', + client_certificate: '', + client_private_key: 'key', + tls_hostname: 'hostname', + }, + }; + + const result = getDestinationPayloadDetails( + details, + destinationType.CustomHttps + ) as CustomHTTPSDetailsExtended; + + expect(result.client_certificate_details).toBeUndefined(); + }); + + it('should keep client_certificate_details when all properties have values', () => { + const details: CustomHTTPSDetailsExtended = { + ...baseCustomHTTPSDetails, + client_certificate_details: { + client_ca_certificate: 'ca-cert', + client_certificate: 'cert', + client_private_key: 'key', + tls_hostname: 'hostname', + }, + }; + + const result = getDestinationPayloadDetails( + details, + destinationType.CustomHttps + ) as CustomHTTPSDetailsExtended; + + expect(result.client_certificate_details).toBeDefined(); + expect(result.client_certificate_details).toEqual( + details.client_certificate_details + ); + }); + + it('should omit both content_type and client_certificate_details when both are empty', () => { + const details: CustomHTTPSDetailsExtended = { + ...baseCustomHTTPSDetails, + content_type: null, + client_certificate_details: { + client_ca_certificate: '', + client_certificate: '', + client_private_key: '', + tls_hostname: '', + }, + }; + + const result = getDestinationPayloadDetails( + details, + destinationType.CustomHttps + ) as CustomHTTPSDetailsExtended; - expect(result.path).toEqual(undefined); + expect(result.content_type).toBeUndefined(); + expect(result.client_certificate_details).toBeUndefined(); + }); }); }); }); diff --git a/packages/manager/src/features/Delivery/deliveryUtils.ts b/packages/manager/src/features/Delivery/deliveryUtils.ts index 4710be9407e..8f411c60119 100644 --- a/packages/manager/src/features/Delivery/deliveryUtils.ts +++ b/packages/manager/src/features/Delivery/deliveryUtils.ts @@ -1,6 +1,7 @@ import { type Destination, type DestinationDetailsPayload, + destinationType, isEmpty, type Stream, type StreamDetailsType, @@ -12,11 +13,17 @@ import { omitProps } from '@linode/ui'; import { isFeatureEnabledV2 } from '@linode/utilities'; import { + authenticationTypeOptions, + contentTypeOptions, destinationTypeOptions, streamTypeOptions, } from 'src/features/Delivery/Shared/types'; import { useFlags } from 'src/hooks/useFlags'; +import type { + CustomHTTPSDetailsExtended, + DestinationType, +} from '@linode/api-v4'; import type { AutocompleteOption, DestinationDetailsForm, @@ -26,11 +33,13 @@ import type { /** * Hook to determine if the ACLP Logs feature is enabled for the current user. - * @returns {{ isACLPLogsEnabled: boolean, isACLPLogsBeta: boolean }} An object indicating if the feature is enabled and if it is in beta. + * @returns {{ isACLPLogsEnabled: boolean, isACLPLogsBeta: boolean, isACLPLogsNew: boolean, isACLPLogsCustomHttpsEnabled: boolean }} */ export const useIsACLPLogsEnabled = (): { isACLPLogsBeta: boolean; + isACLPLogsCustomHttpsEnabled: boolean; isACLPLogsEnabled: boolean; + isACLPLogsNew: boolean; } => { const { data: account } = useAccount(); const flags = useFlags(); @@ -45,6 +54,8 @@ export const useIsACLPLogsEnabled = (): { return { isACLPLogsBeta: !!flags.aclpLogs?.beta, + isACLPLogsCustomHttpsEnabled: !!flags.aclpLogs?.customHttpsEnabled, + isACLPLogsNew: !!flags.aclpLogs?.new, isACLPLogsEnabled, }; }; @@ -59,6 +70,18 @@ export const getStreamTypeOption = ( ): AutocompleteOption | undefined => streamTypeOptions.find(({ value }) => value === streamTypeValue); +export const getAuthenticationTypeOption = ( + authenticationTypeValue: string +): AutocompleteOption | undefined => + authenticationTypeOptions.find( + ({ value }) => value === authenticationTypeValue + ); + +export const getContentTypeOption = ( + contentTypeValue: string +): AutocompleteOption | undefined => + contentTypeOptions.find(({ value }) => value === contentTypeValue); + export const isFormInEditMode = (mode: FormMode) => mode === 'edit'; export const getStreamPayloadDetails = ( @@ -81,9 +104,38 @@ export const getStreamPayloadDetails = ( }; export const getDestinationPayloadDetails = ( - details: DestinationDetailsForm + details: DestinationDetailsForm, + type: DestinationType ): DestinationDetailsPayload => { - if ('path' in details && details.path === '') { + if (type === destinationType.CustomHttps) { + const propsToRemove: any[] = []; + const customHTTPSDetails = details as CustomHTTPSDetailsExtended; + + if (!customHTTPSDetails.content_type) { + propsToRemove.push('content_type'); + } + + if (customHTTPSDetails.client_certificate_details) { + const certDetails = customHTTPSDetails.client_certificate_details; + const shouldRemoveCertDetails = [ + certDetails.client_ca_certificate, + certDetails.client_certificate, + certDetails.client_private_key, + certDetails.tls_hostname, + ].some((val) => !val); + + if (shouldRemoveCertDetails) { + propsToRemove.push('client_certificate_details'); + } + } + + if (propsToRemove.length > 0) { + return omitProps( + customHTTPSDetails, + propsToRemove + ) as CustomHTTPSDetailsExtended; + } + } else if ('path' in details && details.path === '') { return omitProps(details, ['path']); } @@ -97,3 +149,10 @@ export const getStreamDescription = (stream: Stream) => { export const getDestinationDescription = (destination: Destination) => { return `${getDestinationTypeOption(destination.type)?.label}`; }; + +export const useIsLkeEAuditLogsTypeSelectionEnabled = (): boolean => { + const { data: account } = useAccount(); + return !!account?.capabilities?.includes( + 'Akamai Cloud Pulse Logs LKE-E Audit' + ); +}; diff --git a/packages/manager/src/features/Events/EventRow.tsx b/packages/manager/src/features/Events/EventRow.tsx index 6f9ce1e96e1..7eb4701a7bc 100644 --- a/packages/manager/src/features/Events/EventRow.tsx +++ b/packages/manager/src/features/Events/EventRow.tsx @@ -10,6 +10,7 @@ import { DateTimeDisplay } from 'src/components/DateTimeDisplay'; import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; import { TextTooltip } from 'src/components/TextTooltip'; +import { TruncatedUsername } from 'src/components/TruncatedUsername'; import { formatProgressEvent, @@ -56,7 +57,7 @@ export const EventRow = (props: EventRowProps) => { )} - + { username={username} width={24} /> - {username} + diff --git a/packages/manager/src/features/Footer.tsx b/packages/manager/src/features/Footer.tsx index a501ca9a677..0055860c2fb 100644 --- a/packages/manager/src/features/Footer.tsx +++ b/packages/manager/src/features/Footer.tsx @@ -57,7 +57,7 @@ export const Footer = React.memo(() => { v{packageJson.version} API Reference - Provide Feedback + Suggest Improvements { const flags = useFlags(); - const { data: profile } = useProfile(); + const { isChildUserType, isProxyOrDelegateUserType, profile } = + useDelegationRole(); const sessionContext = React.useContext(switchAccountSessionContext); const sessionExpirationContext = React.useContext(_sessionExpirationContext); - const isChildUser = profile?.user_type === 'child'; - const isProxyUser = profile?.user_type === 'proxy'; const { data: securityQuestions } = useSecurityQuestions({ - enabled: isChildUser, + enabled: isChildUserType, }); const suppliedMaintenances = flags.apiMaintenance?.maintenances; // The data (ID, and sometimes the title and body) we supply regarding maintenance events in LD. @@ -58,7 +59,7 @@ export const GlobalNotifications = () => { - {isProxyUser && ( + {isProxyOrDelegateUserType && ( <> { )} - {isChildUser && !isVerified && ( + {isChildUserType && !isVerified && ( { Object.keys(flags.taxCollectionBanner).length > 0 ? ( ) : null} + {flags.marketplaceV2GlobalBanner ? : null} ); }; diff --git a/packages/manager/src/features/GlobalNotifications/MarketplaceV2Banner.tsx b/packages/manager/src/features/GlobalNotifications/MarketplaceV2Banner.tsx new file mode 100644 index 00000000000..f0ee947407a --- /dev/null +++ b/packages/manager/src/features/GlobalNotifications/MarketplaceV2Banner.tsx @@ -0,0 +1,24 @@ +import { Typography } from '@linode/ui'; +import * as React from 'react'; + +import { DismissibleBanner } from 'src/components/DismissibleBanner/DismissibleBanner'; +import { Link } from 'src/components/Link'; + +export const MarketplaceV2Banner = () => { + return ( + + + Partner Referrals Beta now available + + + The Partner Referrals Beta lets you explore products from Akamai + qualified partners. Alongside this launch, we've renamed{' '} + Marketplace to Quick Deploy Apps to + better reflect its purpose.{' '} + + Learn more + + + + ); +}; diff --git a/packages/manager/src/features/IAM/Delegations/AccountDelegations.test.tsx b/packages/manager/src/features/IAM/Delegations/AccountDelegations.test.tsx index 6351aec3157..6827a0013b3 100644 --- a/packages/manager/src/features/IAM/Delegations/AccountDelegations.test.tsx +++ b/packages/manager/src/features/IAM/Delegations/AccountDelegations.test.tsx @@ -10,7 +10,9 @@ beforeAll(() => mockMatchMedia()); const mocks = vi.hoisted(() => ({ mockNavigate: vi.fn(), - mockUseGetAllChildAccountsQuery: vi.fn(), + mockUseGetChildAccountsQuery: vi.fn(), + useParams: vi.fn().mockReturnValue({}), + useSearch: vi.fn().mockReturnValue({}), })); vi.mock('@tanstack/react-router', async () => { @@ -18,6 +20,8 @@ vi.mock('@tanstack/react-router', async () => { return { ...actual, useNavigate: () => mocks.mockNavigate, + useParams: mocks.useParams, + useSearch: mocks.useSearch, }; }); @@ -25,7 +29,7 @@ vi.mock('@linode/queries', async () => { const actual = await vi.importActual('@linode/queries'); return { ...actual, - useGetAllChildAccountsQuery: mocks.mockUseGetAllChildAccountsQuery, + useGetChildAccountsQuery: mocks.mockUseGetChildAccountsQuery, }; }); @@ -45,8 +49,9 @@ const mockDelegations = [ describe('AccountDelegations', () => { beforeEach(() => { vi.clearAllMocks(); - mocks.mockUseGetAllChildAccountsQuery.mockReturnValue({ - data: mockDelegations, + mocks.mockUseGetChildAccountsQuery.mockReturnValue({ + data: { data: mockDelegations, results: mockDelegations.length }, + isLoading: false, }); }); @@ -54,6 +59,7 @@ describe('AccountDelegations', () => { renderWithTheme(, { flags: { iamDelegation: { enabled: true }, + iam: { enabled: true }, }, initialRoute: '/iam', }); @@ -72,12 +78,13 @@ describe('AccountDelegations', () => { }); it('should render empty state when no delegations', async () => { - mocks.mockUseGetAllChildAccountsQuery.mockReturnValue({ - data: [], + mocks.mockUseGetChildAccountsQuery.mockReturnValue({ + data: { data: [], results: 0 }, + isLoading: false, }); renderWithTheme(, { - flags: { iamDelegation: { enabled: true } }, + flags: { iamDelegation: { enabled: true }, iam: { enabled: true } }, initialRoute: '/iam', }); diff --git a/packages/manager/src/features/IAM/Delegations/AccountDelegations.tsx b/packages/manager/src/features/IAM/Delegations/AccountDelegations.tsx index b321df9cd72..2393e8649f3 100644 --- a/packages/manager/src/features/IAM/Delegations/AccountDelegations.tsx +++ b/packages/manager/src/features/IAM/Delegations/AccountDelegations.tsx @@ -1,24 +1,24 @@ -import { useGetAllChildAccountsQuery } from '@linode/queries'; +import { useGetChildAccountsQuery } from '@linode/queries'; import { CircleProgress, Paper, Stack } from '@linode/ui'; import { useMediaQuery, useTheme } from '@mui/material'; import { useNavigate, useSearch } from '@tanstack/react-router'; import React from 'react'; import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField'; -import Paginate from 'src/components/Paginate'; import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter'; -import { useFlags } from 'src/hooks/useFlags'; import { useOrderV2 } from 'src/hooks/useOrderV2'; import { usePaginationV2 } from 'src/hooks/usePaginationV2'; +import { useIsIAMDelegationEnabled } from '../hooks/useIsIAMEnabled'; import { AccountDelegationsTable } from './AccountDelegationsTable'; const DELEGATIONS_ROUTE = '/iam/delegations'; export const AccountDelegations = () => { const navigate = useNavigate(); - const flags = useFlags(); - const { query } = useSearch({ + const { isIAMDelegationEnabled } = useIsIAMDelegationEnabled(); + + const { company } = useSearch({ from: '/iam', }); const theme = useTheme(); @@ -29,73 +29,59 @@ export const AccountDelegations = () => { const numColsLg = isLgDown ? 3 : 2; const numCols = isSmDown ? 2 : numColsLg; - // TODO: UIE-9292 - replace this with API filtering - const { - data: childAccountsWithDelegates, - error, - isLoading, - } = useGetAllChildAccountsQuery({ - params: {}, - users: true, - }); - - const pagination = usePaginationV2({ - currentRoute: '/iam/delegations', - initialPage: 1, - preferenceKey: 'iam-delegations-pagination', - }); - const { handleOrderChange, order, orderBy } = useOrderV2({ initialRoute: { defaultOrder: { order: 'asc', orderBy: 'company', }, - from: '/iam/delegations', + from: DELEGATIONS_ROUTE, }, - preferenceKey: 'iam-delegations-order', + preferenceKey: 'iam-delegations-pagination', }); - // Apply search filter - const filteredDelegations = React.useMemo(() => { - if (!childAccountsWithDelegates) return []; - if (!query?.trim()) return childAccountsWithDelegates; - - const searchTerm = query.toLowerCase().trim(); - return childAccountsWithDelegates.filter((delegation) => - delegation.company?.toLowerCase().includes(searchTerm) - ); - }, [childAccountsWithDelegates, query]); - - // Sort filtered data globally - const sortedDelegations = React.useMemo(() => { - if (!filteredDelegations.length) return []; - - return [...filteredDelegations].sort((a, b) => { - const aValue = a.company || ''; - const bValue = b.company || ''; + const pagination = usePaginationV2({ + currentRoute: DELEGATIONS_ROUTE, + preferenceKey: 'iam-delegations-pagination', + initialPage: 1, + searchParams: (prev) => ({ + ...prev, + company: company || undefined, + }), + }); - const comparison = aValue.localeCompare(bValue, undefined, { - numeric: true, - sensitivity: 'base', - }); + const filter = { + ['+order']: order, + ['+order_by']: orderBy, + ...(company && { company: { '+contains': company } }), + }; - return order === 'asc' ? comparison : -comparison; - }); - }, [filteredDelegations, order]); + const { + data: childAccountsWithDelegates, + isFetching, + isLoading, + error, + } = useGetChildAccountsQuery({ + params: { + page: pagination.page, + page_size: pagination.pageSize, + }, + users: true, + filter, + }); const handleSearch = (value: string) => { pagination.handlePageChange(1); navigate({ to: DELEGATIONS_ROUTE, - search: { query: value || undefined }, + search: { company: value || undefined }, }); }; if (isLoading) { return ; } - if (!flags.iamDelegation?.enabled) { + if (!isIAMDelegationEnabled) { return null; } return ( @@ -115,46 +101,29 @@ export const AccountDelegations = () => { }} debounceTime={250} hideLabel + isSearching={isFetching} label="Search" onSearch={handleSearch} placeholder="Search" - value={query ?? ''} + value={company ?? ''} /> - - + - {({ - count, - data: paginatedData, - handlePageChange, - handlePageSizeChange, - }) => ( - <> - - - - )} - + /> ); }; diff --git a/packages/manager/src/features/IAM/Delegations/AccountDelegationsTableRow.tsx b/packages/manager/src/features/IAM/Delegations/AccountDelegationsTableRow.tsx index 8873a1156cb..195c2cc85c5 100644 --- a/packages/manager/src/features/IAM/Delegations/AccountDelegationsTableRow.tsx +++ b/packages/manager/src/features/IAM/Delegations/AccountDelegationsTableRow.tsx @@ -118,13 +118,13 @@ export const AccountDelegationsTableRow = ({ delegation, index }: Props) => { sx={{ fontStyle: 'italic', textTransform: 'capitalize' }} variant="body1" > - no delegate users added + No Users Added )} { it('renders the drawer with current delegates', () => { renderWithTheme(); - const header = screen.getByRole('heading', { name: /update delegations/i }); + const header = screen.getByRole('heading', { name: /update delegation/i }); expect(header).toBeInTheDocument(); const companyName = screen.getByText(/test company/i); expect(companyName).toBeInTheDocument(); @@ -106,7 +106,7 @@ describe('UpdateDelegationsDrawer', () => { const user2Option = screen.getByRole('option', { name: 'user2' }); await user.click(user2Option); - const submitButton = screen.getByRole('button', { name: /update/i }); + const submitButton = screen.getByRole('button', { name: /save changes/i }); await user.click(submitButton); await waitFor(() => { @@ -135,7 +135,7 @@ describe('UpdateDelegationsDrawer', () => { await user.click(user1Option); // toggles off the selected user // Submit with no users selected - const submitButton = screen.getByRole('button', { name: /update/i }); + const submitButton = screen.getByRole('button', { name: /save changes/i }); await user.click(submitButton); await waitFor(() => { diff --git a/packages/manager/src/features/IAM/Delegations/UpdateDelegationsDrawer.tsx b/packages/manager/src/features/IAM/Delegations/UpdateDelegationsDrawer.tsx index 32c0633e121..3c01e8ce5fa 100644 --- a/packages/manager/src/features/IAM/Delegations/UpdateDelegationsDrawer.tsx +++ b/packages/manager/src/features/IAM/Delegations/UpdateDelegationsDrawer.tsx @@ -41,7 +41,7 @@ export const UpdateDelegationsDrawer = ({ }, [allParentAccounts]); return ( - + {delegation && ( { flags.iamLimitedAvailabilityBadges && isIAMEnabled; const location = useLocation(); const navigate = useNavigate(); - const { isParentAccount } = useDelegationRole(); + const { isParentUserType } = useDelegationRole(); const { isIAMDelegationEnabled } = useIsIAMDelegationEnabled(); const { tabs, tabIndex, handleTabChange } = useTabs([ @@ -37,7 +37,7 @@ export const IdentityAccessLanding = React.memo(() => { title: 'Roles', }, { - hide: !isIAMDelegationEnabled || !isParentAccount, + hide: !isIAMDelegationEnabled || !isParentUserType, to: `/iam/delegations`, title: 'Account Delegations', }, diff --git a/packages/manager/src/features/IAM/Roles/Defaults/DefaultEntityAccess.test.tsx b/packages/manager/src/features/IAM/Roles/Defaults/DefaultEntityAccess.test.tsx index a7cef391390..1efc96ba25c 100644 --- a/packages/manager/src/features/IAM/Roles/Defaults/DefaultEntityAccess.test.tsx +++ b/packages/manager/src/features/IAM/Roles/Defaults/DefaultEntityAccess.test.tsx @@ -3,7 +3,10 @@ import React from 'react'; import { renderWithTheme } from 'src/utilities/testHelpers'; -import { NO_ASSIGNED_DEFAULT_ENTITIES_TEXT } from '../../Shared/constants'; +import { + ERROR_STATE_TEXT, + NO_ASSIGNED_DEFAULT_ENTITIES_TEXT, +} from '../../Shared/constants'; import { DefaultEntityAccess } from './DefaultEntityAccess'; const queryMocks = vi.hoisted(() => ({ @@ -77,4 +80,16 @@ describe('DefaultEntityAccess', () => { expect(screen.getByText(NO_ASSIGNED_DEFAULT_ENTITIES_TEXT)).toBeVisible(); }); + + it('should show error state when api fails', () => { + queryMocks.useGetDefaultDelegationAccessQuery.mockReturnValue({ + data: null, + error: [{ reason: 'An unexpected error occurred' }], + isLoading: false, + status: 'error', + }); + + renderWithTheme(); + expect(screen.getByText(ERROR_STATE_TEXT)).toBeVisible(); + }); }); diff --git a/packages/manager/src/features/IAM/Roles/Defaults/DefaultEntityAccess.tsx b/packages/manager/src/features/IAM/Roles/Defaults/DefaultEntityAccess.tsx index 3137ff2dc9b..633c1b3d914 100644 --- a/packages/manager/src/features/IAM/Roles/Defaults/DefaultEntityAccess.tsx +++ b/packages/manager/src/features/IAM/Roles/Defaults/DefaultEntityAccess.tsx @@ -1,14 +1,26 @@ import { useGetDefaultDelegationAccessQuery } from '@linode/queries'; -import { CircleProgress, Paper, Stack, Typography } from '@linode/ui'; +import { + CircleProgress, + ErrorState, + Paper, + Stack, + Typography, +} from '@linode/ui'; import * as React from 'react'; import { AssignedEntitiesTable } from '../../Shared/AssignedEntitiesTable/AssignedEntitiesTable'; -import { NO_ASSIGNED_DEFAULT_ENTITIES_TEXT } from '../../Shared/constants'; +import { + ERROR_STATE_TEXT, + NO_ASSIGNED_DEFAULT_ENTITIES_TEXT, +} from '../../Shared/constants'; import { NoAssignedRoles } from '../../Shared/NoAssignedRoles/NoAssignedRoles'; export const DefaultEntityAccess = () => { - const { data: defaultAccess, isLoading: defaultAccessLoading } = - useGetDefaultDelegationAccessQuery({ enabled: true }); + const { + data: defaultAccess, + isLoading: defaultAccessLoading, + error, + } = useGetDefaultDelegationAccessQuery({ enabled: true }); const hasAssignedEntities = defaultAccess ? defaultAccess.entity_access.length > 0 @@ -18,6 +30,10 @@ export const DefaultEntityAccess = () => { return ; } + if (error) { + return ; + } + return ( {hasAssignedEntities ? ( diff --git a/packages/manager/src/features/IAM/Roles/Defaults/DefaultRoles.test.tsx b/packages/manager/src/features/IAM/Roles/Defaults/DefaultRoles.test.tsx index 27fdf9f4600..b27283a6393 100644 --- a/packages/manager/src/features/IAM/Roles/Defaults/DefaultRoles.test.tsx +++ b/packages/manager/src/features/IAM/Roles/Defaults/DefaultRoles.test.tsx @@ -3,7 +3,10 @@ import React from 'react'; import { renderWithTheme } from 'src/utilities/testHelpers'; -import { NO_ASSIGNED_DEFAULT_ROLES_TEXT } from '../../Shared/constants'; +import { + ERROR_STATE_TEXT, + NO_ASSIGNED_DEFAULT_ROLES_TEXT, +} from '../../Shared/constants'; import { DefaultRoles } from './DefaultRoles'; const loadingTestId = 'circle-progress'; @@ -69,4 +72,16 @@ describe('DefaultRoles', () => { expect(screen.getByText(NO_ASSIGNED_DEFAULT_ROLES_TEXT)).toBeVisible(); expect(screen.getByText('Add New Default Roles')).toBeVisible(); }); + + it('should show error state when api fails', () => { + queryMocks.useGetDefaultDelegationAccessQuery.mockReturnValue({ + data: null, + error: [{ reason: 'An unexpected error occurred' }], + isLoading: false, + status: 'error', + }); + + renderWithTheme(); + expect(screen.getByText(ERROR_STATE_TEXT)).toBeVisible(); + }); }); diff --git a/packages/manager/src/features/IAM/Roles/Defaults/DefaultRoles.tsx b/packages/manager/src/features/IAM/Roles/Defaults/DefaultRoles.tsx index 3f9430a447f..7c979c02d71 100644 --- a/packages/manager/src/features/IAM/Roles/Defaults/DefaultRoles.tsx +++ b/packages/manager/src/features/IAM/Roles/Defaults/DefaultRoles.tsx @@ -1,14 +1,20 @@ import { useGetDefaultDelegationAccessQuery } from '@linode/queries'; -import { CircleProgress, Paper, Typography } from '@linode/ui'; +import { CircleProgress, ErrorState, Paper, Typography } from '@linode/ui'; import * as React from 'react'; import { AssignedRolesTable } from '../../Shared/AssignedRolesTable/AssignedRolesTable'; -import { NO_ASSIGNED_DEFAULT_ROLES_TEXT } from '../../Shared/constants'; +import { + ERROR_STATE_TEXT, + NO_ASSIGNED_DEFAULT_ROLES_TEXT, +} from '../../Shared/constants'; import { NoAssignedRoles } from '../../Shared/NoAssignedRoles/NoAssignedRoles'; export const DefaultRoles = () => { - const { data: defaultRolesData, isLoading: defaultRolesLoading } = - useGetDefaultDelegationAccessQuery({ enabled: true }); + const { + data: defaultRolesData, + isLoading: defaultRolesLoading, + error, + } = useGetDefaultDelegationAccessQuery({ enabled: true }); const hasAssignedRoles = defaultRolesData ? defaultRolesData.account_access.length > 0 || defaultRolesData.entity_access.length > 0 @@ -17,6 +23,11 @@ export const DefaultRoles = () => { if (defaultRolesLoading) { return ; } + + if (error) { + return ; + } + return ( {hasAssignedRoles ? ( diff --git a/packages/manager/src/features/IAM/Roles/Roles.tsx b/packages/manager/src/features/IAM/Roles/Roles.tsx index bed8fcf04e2..45aa59d204e 100644 --- a/packages/manager/src/features/IAM/Roles/Roles.tsx +++ b/packages/manager/src/features/IAM/Roles/Roles.tsx @@ -19,7 +19,7 @@ export const RolesLanding = () => { permissions?.list_role_permissions ); const { isIAMDelegationEnabled } = useIsIAMDelegationEnabled(); - const { isChildAccount, isProfileLoading } = useDelegationRole(); + const { isChildUserType, isProfileLoading } = useDelegationRole(); const { roles } = React.useMemo(() => { if (!accountRoles) { @@ -41,7 +41,7 @@ export const RolesLanding = () => { return ( <> - {isChildAccount && isIAMDelegationEnabled && } + {isChildUserType && isIAMDelegationEnabled && } ({ marginTop: theme.tokens.spacing.S16 })}> Roles diff --git a/packages/manager/src/features/IAM/Roles/RolesTable/AssignSelectedRolesDrawer.tsx b/packages/manager/src/features/IAM/Roles/RolesTable/AssignSelectedRolesDrawer.tsx index 80f19031356..2e593f8a0fd 100644 --- a/packages/manager/src/features/IAM/Roles/RolesTable/AssignSelectedRolesDrawer.tsx +++ b/packages/manager/src/features/IAM/Roles/RolesTable/AssignSelectedRolesDrawer.tsx @@ -13,7 +13,7 @@ import { Typography, } from '@linode/ui'; import { useDebouncedValue } from '@linode/utilities'; -import { useTheme } from '@mui/material'; +import { Stack, useTheme } from '@mui/material'; import Grid from '@mui/material/Grid'; import { enqueueSnackbar } from 'notistack'; import React, { useCallback, useState } from 'react'; @@ -25,6 +25,7 @@ import { AssignSingleSelectedRole } from 'src/features/IAM/Roles/RolesTable/Assi import { usePermissions } from '../../hooks/usePermissions'; import { INTERNAL_ERROR_NO_CHANGES_SAVED } from '../../Shared/constants'; +import { DelegateUserChip } from '../../Shared/DelegateUserChip'; import { mergeAssignedRolesIntoExistingRoles } from '../../Shared/utilities'; import type { AssignNewRoleFormValues } from '../../Shared/utilities'; @@ -98,6 +99,7 @@ export const AssignSelectedRolesDrawer = ({ return users?.map((user: User) => ({ label: user.username, value: user.username, + userType: user.user_type, })); }, [accountUsers]); @@ -165,7 +167,7 @@ export const AssignSelectedRolesDrawer = ({ 1 ? `s` : ``}`} + title={`Assign Selected Role${selectedRoles.length > 1 ? `s` : ``} to Users`} > @@ -210,12 +212,29 @@ export const AssignSelectedRolesDrawer = ({ }} options={getUserOptions() || []} placeholder="Select a User" + renderOption={(props, option) => ( +
  • + + {option.label} + {option.userType === 'delegate' && } + +
  • + )} slotProps={{ listbox: { onScroll: handleScroll, }, }} - textFieldProps={{ hideLabel: true }} + textFieldProps={{ + hideLabel: true, + sx: { + '& .MuiInputBase-input': { + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }, + }, + }} /> )} rules={{ required: 'Select a user.' }} diff --git a/packages/manager/src/features/IAM/Roles/RolesTable/RolesTable.tsx b/packages/manager/src/features/IAM/Roles/RolesTable/RolesTable.tsx index 3b331b7a77d..bf6dcbb563a 100644 --- a/packages/manager/src/features/IAM/Roles/RolesTable/RolesTable.tsx +++ b/packages/manager/src/features/IAM/Roles/RolesTable/RolesTable.tsx @@ -77,13 +77,6 @@ export const RolesTable = ({ roles = [] }: Props) => { const { data: permissions } = usePermissions('account', ['is_account_admin']); const isAccountAdmin = permissions?.is_account_admin; - const pagination = usePaginationV2({ - currentRoute: '/iam/roles', - defaultPageSize: DEFAULT_PAGE_SIZE, - initialPage: 1, - preferenceKey: ROLES_TABLE_PREFERENCE_KEY, - }); - // Filtering const getFilteredRows = ( text: string, @@ -113,18 +106,21 @@ export const RolesTable = ({ roles = [] }: Props) => { return sortRows(filteredRows, sort.order, sort.column); }, [filteredRows, sort]); - const paginatedRows = React.useMemo(() => { - const start = (pagination.page - 1) * pagination.pageSize; - return sortedRows.slice(start, start + pagination.pageSize); - }, [sortedRows, pagination.page, pagination.pageSize]); + const pagination = usePaginationV2({ + currentRoute: '/iam/roles', + defaultPageSize: DEFAULT_PAGE_SIZE, + initialPage: 1, + preferenceKey: ROLES_TABLE_PREFERENCE_KEY, + clientSidePaginationData: sortedRows, + }); const areAllSelected = React.useMemo(() => { return ( - !!paginatedRows?.length && + !!pagination.paginatedData?.length && !!selectedRows?.length && - paginatedRows?.length === selectedRows?.length + pagination.paginatedData?.length === selectedRows?.length ); - }, [paginatedRows, selectedRows]); + }, [pagination.paginatedData, selectedRows]); const handleSort = (event: CustomEvent, column: string) => { setSort({ column, order: event.detail as Order }); @@ -132,7 +128,7 @@ export const RolesTable = ({ roles = [] }: Props) => { const handleSelect = (event: CustomEvent, row: 'all' | RoleView) => { if (row === 'all') { - setSelectedRows(areAllSelected ? [] : paginatedRows); + setSelectedRows(areAllSelected ? [] : pagination.paginatedData); } else if (selectedRows.includes(row)) { setSelectedRows(selectedRows.filter((r) => r !== row)); } else { @@ -145,12 +141,10 @@ export const RolesTable = ({ roles = [] }: Props) => { to: location.pathname, search: { query: fs !== '' ? fs : undefined }, }); - pagination.handlePageChange(1); }; const handleChangeEntityTypeFilter = (_: never, entityType: SelectOption) => { setFilterableEntityType(entityType ?? ALL_ROLES_OPTION); - pagination.handlePageChange(1); }; const assignRoleRow = (row: RoleView) => { @@ -170,7 +164,6 @@ export const RolesTable = ({ roles = [] }: Props) => { const handlePageSizeChange = (event: CustomEvent<{ pageSize: number }>) => { const newSize = event.detail.pageSize; pagination.handlePageSizeChange(newSize); - pagination.handlePageChange(1); }; return ( @@ -287,14 +280,14 @@ export const RolesTable = ({ roles = [] }: Props) => { - {!paginatedRows?.length ? ( + {!pagination.paginatedData?.length ? ( No items to display. ) : ( - paginatedRows.map((roleRow) => ( + pagination.paginatedData.map((roleRow) => ( { {sortedRows.length !== 0 && sortedRows.length > DEFAULT_PAGE_SIZE && ( { const [order, setOrder] = React.useState<'asc' | 'desc'>('asc'); const [orderBy, setOrderBy] = React.useState('entity_name'); - const pagination = usePaginationV2({ - currentRoute: isDefaultDelegationRolesForChildAccount - ? '/iam/roles/defaults/entity-access' - : `/iam/users/$username/entities`, - initialPage: 1, - preferenceKey: ENTITIES_TABLE_PREFERENCE_KEY, - }); - const handleOrderChange = (newOrderBy: OrderByKeys) => { if (orderBy === newOrderBy) { setOrder(order === 'asc' ? 'desc' : 'asc'); @@ -181,12 +173,6 @@ export const AssignedEntitiesTable = ({ username }: Props) => { } else { setIsRemoveAssignmentDialogOpen(false); } - // If we just deleted the last one on a page, reset to the previous page. - const removedLastOnPage = - filteredAndSortedRoles.length % pagination.pageSize === 1; - if (removedLastOnPage) { - pagination.handlePageChange(pagination.page - 1); - } }; const filteredRoles = getFilteredRoles({ @@ -209,15 +195,28 @@ export const AssignedEntitiesTable = ({ username }: Props) => { return 0; }); + const pagination = usePaginationV2({ + currentRoute: isDefaultDelegationRolesForChildAccount + ? '/iam/roles/defaults/entity-access' + : `/iam/users/$username/entities`, + initialPage: 1, + preferenceKey: ENTITIES_TABLE_PREFERENCE_KEY, + clientSidePaginationData: filteredAndSortedRoles, + }); + + const filteredAndSortedRolesCount = React.useMemo(() => { + return filteredAndSortedRoles.length; + }, [filteredAndSortedRoles]); + const renderTableBody = () => { if (entitiesLoading || loading) { - return ; + return ; } if (entitiesError || error) { return ( ); @@ -230,59 +229,54 @@ export const AssignedEntitiesTable = ({ username }: Props) => { if (assignedRoles && entities) { return ( <> - {filteredAndSortedRoles - .slice( - (pagination.page - 1) * pagination.pageSize, - pagination.page * pagination.pageSize - ) - .map((el: EntitiesRole) => { - const actions: Action[] = [ - { - disabled: !permissionToCheck, - onClick: () => { - handleChangeRole(el, 'change-role-for-entity'); - }, - title: 'Change Role', - tooltip: !permissionToCheck - ? 'You do not have permission to change this role.' - : undefined, + {pagination.paginatedData.map((el: EntitiesRole) => { + const actions: Action[] = [ + { + disabled: !permissionToCheck, + onClick: () => { + handleChangeRole(el, 'change-role-for-entity'); }, - { - disabled: !permissionToCheck, - onClick: () => { - handleRemoveAssignment(el); - }, - title: isDefaultDelegationRolesForChildAccount - ? 'Remove' - : 'Remove Assignment', - tooltip: !permissionToCheck - ? 'You do not have permission to remove this assignment.' - : undefined, + title: 'Change Role', + tooltip: !permissionToCheck + ? 'You do not have permission to change this role.' + : undefined, + }, + { + disabled: !permissionToCheck, + onClick: () => { + handleRemoveAssignment(el); }, - ]; - - return ( - - - {el.entity_name} - - - - {getFormattedEntityType(el.entity_type)} - - - - {el.role_name} - - - - - - ); - })} + title: isDefaultDelegationRolesForChildAccount + ? 'Remove' + : 'Remove Assignment', + tooltip: !permissionToCheck + ? 'You do not have permission to remove this assignment.' + : undefined, + }, + ]; + + return ( + + + {el.entity_name} + + + + {getFormattedEntityType(el.entity_type)} + + + + {el.role_name} + + + + + + ); + })} ); } @@ -384,9 +378,9 @@ export const AssignedEntitiesTable = ({ username }: Props) => { role={selectedRole} username={username} /> - {filteredRoles.length > PAGE_SIZES[0] && ( + {filteredAndSortedRolesCount > PAGE_SIZES[0] && ( { const assignedRolesLoading = isDefaultDelegationRolesForChildAccount ? defaultRolesLoading : userRolesLoading; - const pagination = usePaginationV2({ - currentRoute: isDefaultDelegationRolesForChildAccount - ? '/iam/roles/defaults/roles' - : '/iam/users/$username/roles', - initialPage: 1, - preferenceKey: ASSIGNED_ROLES_TABLE_PREFERENCE_KEY, - }); const handleOrderChange = (newOrderBy: OrderByKeys) => { if (orderBy === newOrderBy) { @@ -177,12 +170,6 @@ export const AssignedRolesTable = () => { } else { setIsUnassignRoleDialogOpen(false); } - // If we just deleted the last one on a page, reset to the previous page. - const removedLastOnPage = - filteredAndSortedRoles.length % pagination.pageSize === 1; - if (removedLastOnPage) { - pagination.handlePageChange(pagination.page - 1); - } }; const { data: accountRoles, isLoading: accountPermissionsLoading } = @@ -261,91 +248,95 @@ export const AssignedRolesTable = () => { }); }, [roles, query, entityType, order, orderBy, isInitialLoad]); + const pagination = usePaginationV2({ + currentRoute: isDefaultDelegationRolesForChildAccount + ? '/iam/roles/defaults/roles' + : '/iam/users/$username/roles', + initialPage: 1, + preferenceKey: ASSIGNED_ROLES_TABLE_PREFERENCE_KEY, + clientSidePaginationData: filteredAndSortedRoles, + }); + + const filteredAndSortedRolesCount = React.useMemo(() => { + return filteredAndSortedRoles.length; + }, [filteredAndSortedRoles]); + const memoizedTableItems: TableItem[] = React.useMemo(() => { - return filteredAndSortedRoles - .slice( - (pagination.page - 1) * pagination.pageSize, - pagination.page * pagination.pageSize - ) - .map((role: ExtendedRoleView) => { - const OuterTableCells = ( - <> - {role.access === 'account_access' ? ( - - - {role.entity_type === 'account' - ? 'All Entities' - : `All ${getFormattedEntityType(role.entity_type)}s`} - - - ) : ( - - - - )} - - { + const OuterTableCells = ( + <> + {role.access === 'account_access' ? ( + + + {role.entity_type === 'account' + ? 'All Entities' + : `All ${getFormattedEntityType(role.entity_type)}s`} + + + ) : ( + + - - ); - - const InnerTable = ( - + + + + ); + + const InnerTable = ( + + + Description + + - - Description - - - {role.permissions.length ? ( - role.description - ) : ( - <> - {getFacadeRoleDescription(role)}{' '} - Learn more. - - )} - - - - ); - - return { - InnerTable, - OuterTableCells, - id: role.id, - label: role.name, - }; - }); + {role.permissions.length ? ( + role.description + ) : ( + <> + {getFacadeRoleDescription(role)}{' '} + Learn more. + + )} + + +
    + ); + + return { + InnerTable, + OuterTableCells, + id: role.id, + label: role.name, + }; + }); }, [filteredAndSortedRoles, pagination]); - const filteredAndSortedRolesCount = React.useMemo(() => { - return filteredAndSortedRoles.length; - }, [filteredAndSortedRoles]); - if (accountPermissionsLoading || entitiesLoading || assignedRolesLoading) { return ; } diff --git a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/ChangeRoleDrawer.tsx b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/ChangeRoleDrawer.tsx index 78836d51596..1b3d83de509 100644 --- a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/ChangeRoleDrawer.tsx +++ b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/ChangeRoleDrawer.tsx @@ -14,6 +14,7 @@ import { } from '@linode/ui'; import { useTheme } from '@mui/material/styles'; import { useParams } from '@tanstack/react-router'; +import { enqueueSnackbar } from 'notistack'; import React from 'react'; import { Controller, useForm } from 'react-hook-form'; @@ -147,6 +148,8 @@ export const ChangeRoleDrawer = ({ mode, onClose, open, role }: Props) => { await mutationFn(updatedUserRoles); + enqueueSnackbar(`Role changed.`, { variant: 'success' }); + handleClose(); } catch (errors) { setError('root', { @@ -169,7 +172,9 @@ export const ChangeRoleDrawer = ({ mode, onClose, open, role }: Props) => { Select a role you want{' '} {role?.access === 'account_access' - ? 'to assign.' + ? isDefaultDelegationRolesForChildAccount + ? 'to assign by default to new delegate users.' + : 'to assign.' : 'the entities to be attached to.'}{' '} Learn more about roles and permissions @@ -178,7 +183,7 @@ export const ChangeRoleDrawer = ({ mode, onClose, open, role }: Props) => { - Change from role {role?.name} to: + Change the role from {role?.name} to: ({ useParams: vi.fn().mockReturnValue({ username: 'test_user' }), useAccountRoles: vi.fn().mockReturnValue({}), useUserRoles: vi.fn().mockReturnValue({}), + useUpdateDefaultDelegationAccessQuery: vi.fn().mockReturnValue({}), + useIsDefaultDelegationRolesForChildAccount: vi + .fn() + .mockReturnValue({ isDefaultDelegationRolesForChildAccount: false }), +})); + +vi.mock('src/features/IAM/hooks/useDelegationRole', () => ({ + useIsDefaultDelegationRolesForChildAccount: + queryMocks.useIsDefaultDelegationRolesForChildAccount, })); vi.mock('@linode/queries', async () => { @@ -39,6 +49,8 @@ vi.mock('@linode/queries', async () => { ...actual, useAccountRoles: queryMocks.useAccountRoles, useUserRoles: queryMocks.useUserRoles, + useUpdateDefaultDelegationAccessQuery: + queryMocks.useUpdateDefaultDelegationAccessQuery, }; }); @@ -63,6 +75,7 @@ vi.mock('@linode/api-v4', async () => { describe('UnassignRoleConfirmationDialog', () => { beforeEach(() => { + vi.clearAllMocks(); queryMocks.useParams.mockReturnValue({ username: 'test_user', }); @@ -140,4 +153,27 @@ describe('UnassignRoleConfirmationDialog', () => { }); }); }); + + it('displays error message when there is an API error', async () => { + const apiError = [{ reason: 'Failed to load user roles' }]; + + queryMocks.useUpdateDefaultDelegationAccessQuery.mockReturnValue({ + mutateAsync: vi.fn().mockRejectedValue(apiError), + isPending: false, + error: apiError, + }); + queryMocks.useIsDefaultDelegationRolesForChildAccount.mockReturnValue({ + isDefaultDelegationRolesForChildAccount: true, + }); + + renderWithTheme(); + + const removeButton = screen.getByText('Remove'); + expect(removeButton).toBeVisible(); + + await userEvent.click(removeButton); + await expect( + screen.getByText(INTERNAL_ERROR_NO_CHANGES_SAVED) + ).toBeVisible(); + }); }); diff --git a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/UnassignRoleConfirmationDialog.tsx b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/UnassignRoleConfirmationDialog.tsx index d40141fbd80..3fba173c925 100644 --- a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/UnassignRoleConfirmationDialog.tsx +++ b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/UnassignRoleConfirmationDialog.tsx @@ -42,14 +42,17 @@ export const UnassignRoleConfirmationDialog = (props: Props) => { ? defaultRolesData : userRolesData; const { - error, + error: userRolesError, isPending, mutateAsync: updateUserRoles, reset, } = useUserRolesMutation(username); - const { mutateAsync: updateDefaultRoles, isPending: isDefaultRolesPending } = - useUpdateDefaultDelegationAccessQuery(); + const { + mutateAsync: updateDefaultRoles, + isPending: isDefaultRolesPending, + error: defaultDelegationRolesError, + } = useUpdateDefaultDelegationAccessQuery(); const mutationFn = isDefaultDelegationRolesForChildAccount ? updateDefaultRoles @@ -69,18 +72,25 @@ export const UnassignRoleConfirmationDialog = (props: Props) => { assignedRoles, initialRole, }); + try { + await mutationFn(updatedUserRoles); - await mutationFn(updatedUserRoles); - - enqueueSnackbar(`Role ${role?.name} has been deleted successfully.`, { - variant: 'success', - }); - if (onSuccess) { - onSuccess(); + enqueueSnackbar(`Role ${role?.name} has been deleted successfully.`, { + variant: 'success', + }); + if (onSuccess) { + onSuccess(); + } + onClose(); + } catch { + // error is handled by react-query and shown via } - onClose(); }; + const error = isDefaultDelegationRolesForChildAccount + ? defaultDelegationRolesError + : userRolesError; + return ( { label: 'Remove', loading: isPending || isDefaultRolesPending, onClick: onDelete, + disabled: isPending || isDefaultRolesPending, }} secondaryButtonProps={{ label: 'Cancel', diff --git a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/UpdateEntitiesDrawer.tsx b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/UpdateEntitiesDrawer.tsx index 75a2f14f597..ed1859789b2 100644 --- a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/UpdateEntitiesDrawer.tsx +++ b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/UpdateEntitiesDrawer.tsx @@ -7,6 +7,7 @@ import { import { ActionsPanel, Drawer, Notice, Typography } from '@linode/ui'; import { useTheme } from '@mui/material'; import { useParams } from '@tanstack/react-router'; +import { enqueueSnackbar } from 'notistack'; import React from 'react'; import { Controller, FormProvider, useForm } from 'react-hook-form'; @@ -113,6 +114,8 @@ export const UpdateEntitiesDrawer = ({ onClose, open, role }: Props) => { entity_access: entityAccess, }); + enqueueSnackbar(`List of entities updated.`, { variant: 'success' }); + handleClose(); } catch (errors) { for (const error of errors) { diff --git a/packages/manager/src/features/IAM/Shared/DelegateUserChip.tsx b/packages/manager/src/features/IAM/Shared/DelegateUserChip.tsx new file mode 100644 index 00000000000..8c8dbdd202e --- /dev/null +++ b/packages/manager/src/features/IAM/Shared/DelegateUserChip.tsx @@ -0,0 +1,26 @@ +import { Chip, omittedProps, styled } from '@linode/ui'; +import * as React from 'react'; + +interface Props { + // When true, hide the chip on screens smaller than 'sm' + hideBelowSm?: boolean; +} + +export const DelegateUserChip = ({ hideBelowSm = false }: Props) => { + return ; +}; + +const StyledChip = styled(Chip, { + label: 'StyledChip', + shouldForwardProp: omittedProps(['hideBelowSm']), +})<{ hideBelowSm?: boolean }>(({ theme, ...props }) => ({ + 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, + ...(props.hideBelowSm && { + [theme.breakpoints.down('sm')]: { display: 'none' }, + }), +})); 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 ee0911fe1a6..659b6d4b4d6 100644 --- a/packages/manager/src/features/IAM/Shared/RemoveAssignmentConfirmationDialog/RemoveAssignmentConfirmationDialog.test.tsx +++ b/packages/manager/src/features/IAM/Shared/RemoveAssignmentConfirmationDialog/RemoveAssignmentConfirmationDialog.test.tsx @@ -5,6 +5,7 @@ import React from 'react'; import { accountRolesFactory } from 'src/factories/accountRoles'; import { renderWithTheme } from 'src/utilities/testHelpers'; +import { INTERNAL_ERROR_NO_CHANGES_SAVED } from '../constants'; import { RemoveAssignmentConfirmationDialog } from './RemoveAssignmentConfirmationDialog'; import type { EntitiesRole } from '../types'; @@ -29,6 +30,7 @@ const props = { const queryMocks = vi.hoisted(() => ({ useAccountRoles: vi.fn().mockReturnValue({}), useUserRoles: vi.fn().mockReturnValue({}), + useUpdateDefaultDelegationAccessQuery: vi.fn().mockReturnValue({}), useIsDefaultDelegationRolesForChildAccount: vi .fn() .mockReturnValue({ isDefaultDelegationRolesForChildAccount: false }), @@ -45,6 +47,8 @@ vi.mock('@linode/queries', async () => { ...actual, useAccountRoles: queryMocks.useAccountRoles, useUserRoles: queryMocks.useUserRoles, + useUpdateDefaultDelegationAccessQuery: + queryMocks.useUpdateDefaultDelegationAccessQuery, }; }); @@ -60,6 +64,10 @@ vi.mock('@linode/api-v4', async () => { }); describe('RemoveAssignmentConfirmationDialog', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it('should render', async () => { renderWithTheme( @@ -145,4 +153,27 @@ describe('RemoveAssignmentConfirmationDialog', () => { expect(paragraph).toHaveTextContent(mockRole.entity_name); expect(paragraph).toHaveTextContent(mockRole.role_name); }); + + it('displays error message when there is an API error', async () => { + const apiError = [{ reason: 'Failed to load user roles' }]; + + queryMocks.useUpdateDefaultDelegationAccessQuery.mockReturnValue({ + mutateAsync: vi.fn().mockRejectedValue(apiError), + isPending: false, + error: apiError, + }); + + queryMocks.useIsDefaultDelegationRolesForChildAccount.mockReturnValue({ + isDefaultDelegationRolesForChildAccount: true, + }); + + renderWithTheme(); + const removeButton = screen.getByText('Remove'); + expect(removeButton).toBeVisible(); + + await userEvent.click(removeButton); + await expect( + screen.getByText(INTERNAL_ERROR_NO_CHANGES_SAVED) + ).toBeVisible(); + }); }); diff --git a/packages/manager/src/features/IAM/Shared/RemoveAssignmentConfirmationDialog/RemoveAssignmentConfirmationDialog.tsx b/packages/manager/src/features/IAM/Shared/RemoveAssignmentConfirmationDialog/RemoveAssignmentConfirmationDialog.tsx index 5dff89f0051..b61b4288e0c 100644 --- a/packages/manager/src/features/IAM/Shared/RemoveAssignmentConfirmationDialog/RemoveAssignmentConfirmationDialog.tsx +++ b/packages/manager/src/features/IAM/Shared/RemoveAssignmentConfirmationDialog/RemoveAssignmentConfirmationDialog.tsx @@ -32,14 +32,19 @@ export const RemoveAssignmentConfirmationDialog = (props: Props) => { const { enqueueSnackbar } = useSnackbar(); const { - error, - isPending, + error: userRolesError, + isPending: isUserRolesPending, mutateAsync: updateUserRoles, reset, } = useUserRolesMutation(username ?? ''); - const { mutateAsync: updateDefaultDelegationRoles } = - useUpdateDefaultDelegationAccessQuery(); + const { + mutateAsync: updateDefaultDelegationRoles, + isPending: isDefaultDelegationRolesPending, + error: defaultDelegationRolesError, + } = useUpdateDefaultDelegationAccessQuery(); + + const isPending = isUserRolesPending || isDefaultDelegationRolesPending; const { data: assignedUserRoles } = useUserRoles( username ?? '', @@ -64,7 +69,7 @@ export const RemoveAssignmentConfirmationDialog = (props: Props) => { : assignedUserRoles; const onDelete = async () => { - if (!role || !assignedRoles) return; + if (!role || !assignedRoles || isPending) return; const { role_name, entity_id, entity_type } = role; @@ -74,19 +79,25 @@ export const RemoveAssignmentConfirmationDialog = (props: Props) => { entity_id, entity_type ); - - await mutationFn({ - ...assignedRoles, - entity_access: updatedUserEntityRoles, - }); - - enqueueSnackbar(`Entity access removed`, { - variant: 'success', - }); - - onSuccess?.(); - onClose(); + try { + await mutationFn({ + ...assignedRoles, + entity_access: updatedUserEntityRoles, + }); + + enqueueSnackbar(`Entity access removed`, { + variant: 'success', + }); + + onSuccess?.(); + onClose(); + } catch { + // error is handled by react-query and shown via + } }; + const error = isDefaultDelegationRolesForChildAccount + ? defaultDelegationRolesError + : userRolesError; return ( { label: 'Remove', loading: isPending, onClick: onDelete, + disabled: isPending, }} secondaryButtonProps={{ label: 'Cancel', diff --git a/packages/manager/src/features/IAM/Shared/constants.ts b/packages/manager/src/features/IAM/Shared/constants.ts index 871ff815baa..c62c8c2e308 100644 --- a/packages/manager/src/features/IAM/Shared/constants.ts +++ b/packages/manager/src/features/IAM/Shared/constants.ts @@ -11,6 +11,8 @@ export const NO_ASSIGNED_ENTITIES_TEXT = `The user doesn't have any entity acces export const NO_ASSIGNED_DEFAULT_ENTITIES_TEXT = `There are no default entity access roles assigned yet. Once you assign the default role on specific entities, these entities will show up here.`; +export const NO_ACCOUNT_DELEGATIONS_TEXT = `The user is not added to any account delegations. Once the user is added to an account delegation for specific child accounts, their list will show up here.`; + export const INTERNAL_ERROR_NO_CHANGES_SAVED = `Internal Error. No changes were saved.`; export const LAST_ACCOUNT_ADMIN_ERROR = 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 18f608a5c66..8b729db6fcd 100644 --- a/packages/manager/src/features/IAM/Users/UserDelegations/UserDelegations.test.tsx +++ b/packages/manager/src/features/IAM/Users/UserDelegations/UserDelegations.test.tsx @@ -1,36 +1,28 @@ import { childAccountFactory } from '@linode/utilities'; -import { screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { screen } from '@testing-library/react'; import React from 'react'; +import { accountRolesFactory } from 'src/factories/accountRoles'; import { renderWithTheme } from 'src/utilities/testHelpers'; +import { NO_ACCOUNT_DELEGATIONS_TEXT } from '../../Shared/constants'; import { UserDelegations } from './UserDelegations'; -const mockChildAccounts = [ - { - company: 'Test Account 1', - euuid: '123', - }, - { - company: 'Test Account 2', - euuid: '456', - }, -]; - const queryMocks = vi.hoisted(() => ({ - useAllGetDelegatedChildAccountsForUserQuery: vi.fn().mockReturnValue({}), useParams: vi.fn().mockReturnValue({}), useSearch: vi.fn().mockReturnValue({}), - useIsIAMDelegationEnabled: vi.fn().mockReturnValue({}), + useNavigate: vi.fn().mockReturnValue(vi.fn()), + useGetDelegatedChildAccountsForUserQuery: vi.fn().mockReturnValue({}), + useAccountRoles: vi.fn().mockReturnValue({}), })); vi.mock('@linode/queries', async () => { const actual = await vi.importActual('@linode/queries'); return { ...actual, - useAllGetDelegatedChildAccountsForUserQuery: - queryMocks.useAllGetDelegatedChildAccountsForUserQuery, + useGetDelegatedChildAccountsForUserQuery: + queryMocks.useGetDelegatedChildAccountsForUserQuery, + useAccountRoles: queryMocks.useAccountRoles, }; }); @@ -40,16 +32,7 @@ vi.mock('@tanstack/react-router', async () => { ...actual, useParams: queryMocks.useParams, useSearch: queryMocks.useSearch, - }; -}); - -vi.mock('src/features/IAM/hooks/useIsIAMEnabled', async () => { - const actual = await vi.importActual( - 'src/features/IAM/hooks/useIsIAMEnabled' - ); - return { - ...actual, - useIsIAMDelegationEnabled: queryMocks.useIsIAMDelegationEnabled, + useNavigate: queryMocks.useNavigate, }; }); @@ -58,86 +41,49 @@ describe('UserDelegations', () => { queryMocks.useParams.mockReturnValue({ username: 'test-user', }); - queryMocks.useAllGetDelegatedChildAccountsForUserQuery.mockReturnValue({ - data: mockChildAccounts, + queryMocks.useSearch.mockReturnValue({ query: '' }); + queryMocks.useNavigate.mockReturnValue(vi.fn()); + // Ensure IAM is considered enabled + queryMocks.useAccountRoles.mockReturnValue({ + data: accountRolesFactory.build(), isLoading: false, }); - queryMocks.useSearch.mockReturnValue({ - query: '', - }); - queryMocks.useIsIAMDelegationEnabled.mockReturnValue({ - isIAMDelegationEnabled: true, - }); }); - it('renders the correct number of child accounts', () => { - renderWithTheme(, { - flags: { - iamDelegation: { - enabled: true, - }, - }, - }); - - screen.getByText('Test Account 1'); - screen.getByText('Test Account 2'); - }); - - it('shows pagination when there are more than 25 child accounts', () => { - queryMocks.useAllGetDelegatedChildAccountsForUserQuery.mockReturnValue({ - data: childAccountFactory.buildList(30), + it('should display no roles text if no roles are assigned to user', async () => { + queryMocks.useGetDelegatedChildAccountsForUserQuery.mockReturnValue({ + data: { data: childAccountFactory.buildList(0), results: 0 }, isLoading: false, }); renderWithTheme(, { flags: { + iam: { enabled: true }, iamDelegation: { enabled: true, }, }, }); - - const tabelRows = screen.getAllByRole('row'); - const paginationRow = screen.getByRole('navigation', { - name: 'pagination navigation', - }); - expect(tabelRows).toHaveLength(27); // 25 rows + header row + pagination row - expect(paginationRow).toBeInTheDocument(); + expect(screen.getByText('This list is empty')).toBeVisible(); + expect(screen.getByText(NO_ACCOUNT_DELEGATIONS_TEXT)).toBeVisible(); }); - it('filters child accounts by search', async () => { - queryMocks.useAllGetDelegatedChildAccountsForUserQuery.mockReturnValue({ - data: childAccountFactory.buildList(30), + it('should display table if user has delegations', async () => { + queryMocks.useGetDelegatedChildAccountsForUserQuery.mockReturnValue({ + data: { data: childAccountFactory.buildList(2), results: 2 }, + isLoading: false, }); renderWithTheme(, { flags: { + iam: { enabled: true }, iamDelegation: { enabled: true, }, }, }); - const paginationRow = screen.getByRole('navigation', { - name: 'pagination navigation', - }); - - screen.getByText('child-account-31'); - screen.getByText('child-account-32'); - - expect(paginationRow).toBeInTheDocument(); - - const searchInput = screen.getByPlaceholderText('Search'); - await userEvent.type(searchInput, 'child-account-31'); - - screen.getByText('child-account-31'); - - await waitFor(() => { - expect(screen.queryByText('Child Account 32')).not.toBeInTheDocument(); - }); - await waitFor(() => { - expect(paginationRow).not.toBeInTheDocument(); - }); + expect(screen.getByText('Account Delegations')).toBeVisible(); }); }); diff --git a/packages/manager/src/features/IAM/Users/UserDelegations/UserDelegations.tsx b/packages/manager/src/features/IAM/Users/UserDelegations/UserDelegations.tsx index b3ff008e566..bca7ac4d982 100644 --- a/packages/manager/src/features/IAM/Users/UserDelegations/UserDelegations.tsx +++ b/packages/manager/src/features/IAM/Users/UserDelegations/UserDelegations.tsx @@ -1,184 +1,51 @@ -import { useAllGetDelegatedChildAccountsForUserQuery } from '@linode/queries'; -import { - CircleProgress, - ErrorState, - Paper, - Stack, - Typography, -} from '@linode/ui'; -import { useNavigate, useParams, useSearch } from '@tanstack/react-router'; -import * as React from 'react'; +import { useGetDelegatedChildAccountsForUserQuery } from '@linode/queries'; +import { CircleProgress, ErrorState } from '@linode/ui'; +import { useParams } from '@tanstack/react-router'; +import React from 'react'; -import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField'; -import Paginate from 'src/components/Paginate'; -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'; -import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; -import { TableSortCell } from 'src/components/TableSortCell'; -import { useIsIAMDelegationEnabled } from 'src/features/IAM/hooks/useIsIAMEnabled'; -import { NO_DELEGATED_USERS_TEXT } from 'src/features/IAM/Shared/constants'; -import { useOrderV2 } from 'src/hooks/useOrderV2'; -import { usePaginationV2 } from 'src/hooks/usePaginationV2'; +import { DocumentTitleSegment } from 'src/components/DocumentTitle'; -import type { Theme } from '@mui/material'; +import { + ERROR_STATE_TEXT, + NO_ACCOUNT_DELEGATIONS_TEXT, +} from '../../Shared/constants'; +import { NoAssignedRoles } from '../../Shared/NoAssignedRoles/NoAssignedRoles'; +import { UserDelegationsTable } from './UserDelegationsTable'; export const UserDelegations = () => { const { username } = useParams({ from: '/iam/users/$username' }); - const { isIAMDelegationEnabled } = useIsIAMDelegationEnabled(); - const { query } = useSearch({ - from: '/iam/users/$username/delegations', - }); - const navigate = useNavigate(); - // TODO: UIE-9298 - Replace with API filtering const { data: allDelegatedChildAccounts, - isLoading: allDelegatedChildAccountsLoading, - error: allDelegatedChildAccountsError, - } = useAllGetDelegatedChildAccountsForUserQuery({ + isLoading, + error, + } = useGetDelegatedChildAccountsForUserQuery({ username, }); - const handleSearch = (value: string) => { - pagination.handlePageChange(1); - navigate({ - to: '/iam/users/$username/delegations', - params: { username }, - search: { query: value || undefined }, - }); - }; - - const childAccounts = React.useMemo(() => { - if (!allDelegatedChildAccounts) { - return []; - } - - if (query?.trim() === '') { - return allDelegatedChildAccounts; - } - - return allDelegatedChildAccounts.filter((childAccount) => - childAccount.company.toLowerCase().includes(query?.toLowerCase() ?? '') - ); - }, [allDelegatedChildAccounts, query]); - - const { handleOrderChange, order, orderBy, sortedData } = useOrderV2({ - data: childAccounts, - initialRoute: { - defaultOrder: { - order: 'asc', - orderBy: 'company', - }, - from: '/iam/users/$username/delegations', - }, - preferenceKey: 'user-delegations', - }); - - const pagination = usePaginationV2({ - currentRoute: '/iam/users/$username/delegations', - preferenceKey: 'user-delegations', - initialPage: 1, - }); - - if (!isIAMDelegationEnabled) { - return null; - } + const hasDelegatedChildAccounts = allDelegatedChildAccounts + ? allDelegatedChildAccounts.data.length > 0 + : false; - if (allDelegatedChildAccountsLoading) { + if (isLoading) { return ; } - if (allDelegatedChildAccountsError) { - return ; + if (error) { + return ; } return ( - - - Account Delegations - + + {hasDelegatedChildAccounts ? ( + + ) : ( + - - - - - Account - - - - - - {({ - count, - data: paginatedData, - handlePageChange, - handlePageSizeChange, - }) => ( - <> - {paginatedData?.length === 0 && ( - - )} - {paginatedData?.map((childAccount) => ( - - {childAccount.company} - - ))} - {count > 25 && ( - - ({ - padding: 0, - '& > div': { - border: 'none', - borderTop: `1px solid ${theme.borderColors.divider}`, - }, - })} - > - - - - )} - - )} - - -
    -
    -
    + )} + ); }; diff --git a/packages/manager/src/features/IAM/Users/UserDelegations/UserDelegationsTable.test.tsx b/packages/manager/src/features/IAM/Users/UserDelegations/UserDelegationsTable.test.tsx new file mode 100644 index 00000000000..ab73b64b9dc --- /dev/null +++ b/packages/manager/src/features/IAM/Users/UserDelegations/UserDelegationsTable.test.tsx @@ -0,0 +1,142 @@ +import { childAccountFactory } from '@linode/utilities'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { accountRolesFactory } from 'src/factories/accountRoles'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { UserDelegationsTable } from './UserDelegationsTable'; + +const mockChildAccounts = { + data: [ + { + company: 'Test Account 1', + euuid: '123', + }, + { + company: 'Test Account 2', + euuid: '456', + }, + ], +}; + +const queryMocks = vi.hoisted(() => ({ + useGetDelegatedChildAccountsForUserQuery: vi.fn().mockReturnValue({}), + useParams: vi.fn().mockReturnValue({}), + useSearch: vi.fn().mockReturnValue({}), + useAccountRoles: vi.fn().mockReturnValue({}), +})); + +vi.mock('@linode/queries', async () => { + const actual = await vi.importActual('@linode/queries'); + return { + ...actual, + useGetDelegatedChildAccountsForUserQuery: + queryMocks.useGetDelegatedChildAccountsForUserQuery, + useAccountRoles: queryMocks.useAccountRoles, + }; +}); + +vi.mock('@tanstack/react-router', async () => { + const actual = await vi.importActual('@tanstack/react-router'); + return { + ...actual, + useParams: queryMocks.useParams, + useSearch: queryMocks.useSearch, + }; +}); + +describe('UserDelegationsTable', () => { + beforeEach(() => { + queryMocks.useParams.mockReturnValue({ + username: 'test-user', + }); + queryMocks.useGetDelegatedChildAccountsForUserQuery.mockReturnValue({ + data: mockChildAccounts, + isLoading: false, + }); + queryMocks.useSearch.mockReturnValue({ + query: '', + }); + // Ensure IAM is considered enabled + queryMocks.useAccountRoles.mockReturnValue({ + data: accountRolesFactory.build(), + isLoading: false, + }); + }); + + it('renders the correct number of child accounts', () => { + renderWithTheme(, { + flags: { + iam: { enabled: true }, + iamDelegation: { + enabled: true, + }, + }, + }); + + screen.getByText('Test Account 1'); + screen.getByText('Test Account 2'); + }); + + it('shows pagination when there are more than 25 child accounts', () => { + queryMocks.useGetDelegatedChildAccountsForUserQuery.mockReturnValue({ + data: { data: childAccountFactory.buildList(30), results: 30 }, + isLoading: false, + }); + + renderWithTheme(, { + flags: { + iam: { enabled: true }, + iamDelegation: { + enabled: true, + }, + }, + }); + + const tabelRows = screen.getAllByRole('row'); + const paginationRow = screen.getByRole('navigation', { + name: 'pagination navigation', + }); + expect(tabelRows).toHaveLength(32); // 30 rows + header row + pagination row + expect(paginationRow).toBeInTheDocument(); + }); + + it('filters child accounts by search', async () => { + queryMocks.useGetDelegatedChildAccountsForUserQuery.mockReturnValue({ + data: { data: childAccountFactory.buildList(30), results: 30 }, + isLoading: false, + }); + + renderWithTheme(, { + flags: { + iam: { enabled: true }, + iamDelegation: { + enabled: true, + }, + }, + }); + + const paginationRow = screen.getByRole('navigation', { + name: 'pagination navigation', + }); + + screen.getByText('child-account-31'); + screen.getByText('child-account-32'); + + expect(paginationRow).toBeInTheDocument(); + + const searchInput = screen.getByPlaceholderText('Search'); + await userEvent.type(searchInput, 'child-account-31'); + + screen.getByText('child-account-31'); + + await waitFor(() => { + expect(screen.queryByText('Child Account 32')).not.toBeInTheDocument(); + }); + await waitFor(() => { + expect(paginationRow).not.toBeInTheDocument(); + }); + }); +}); diff --git a/packages/manager/src/features/IAM/Users/UserDelegations/UserDelegationsTable.tsx b/packages/manager/src/features/IAM/Users/UserDelegations/UserDelegationsTable.tsx new file mode 100644 index 00000000000..7448aa5aba8 --- /dev/null +++ b/packages/manager/src/features/IAM/Users/UserDelegations/UserDelegationsTable.tsx @@ -0,0 +1,165 @@ +import { useGetDelegatedChildAccountsForUserQuery } from '@linode/queries'; +import { + CircleProgress, + ErrorState, + Paper, + Stack, + Typography, +} from '@linode/ui'; +import { useNavigate, useParams, useSearch } from '@tanstack/react-router'; +import * as React from 'react'; + +import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField'; +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'; +import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; +import { TableSortCell } from 'src/components/TableSortCell'; +import { useIsIAMDelegationEnabled } from 'src/features/IAM/hooks/useIsIAMEnabled'; +import { NO_DELEGATED_USERS_TEXT } from 'src/features/IAM/Shared/constants'; +import { useOrderV2 } from 'src/hooks/useOrderV2'; +import { usePaginationV2 } from 'src/hooks/usePaginationV2'; + +import type { Theme } from '@mui/material'; + +export const UserDelegationsTable = () => { + const { username } = useParams({ from: '/iam/users/$username' }); + const { isIAMDelegationEnabled } = useIsIAMDelegationEnabled(); + const { company } = useSearch({ + from: '/iam/users/$username/delegations', + }); + const navigate = useNavigate(); + + const { handleOrderChange, order, orderBy } = useOrderV2({ + initialRoute: { + defaultOrder: { + order: 'asc', + orderBy: 'company', + }, + from: '/iam/users/$username/delegations', + }, + preferenceKey: 'user-delegations', + }); + + const pagination = usePaginationV2({ + currentRoute: '/iam/users/$username/delegations', + preferenceKey: 'user-delegations', + initialPage: 1, + searchParams: (prev) => ({ + ...prev, + company: company || undefined, + }), + }); + + const filter = { + company: { + '+contains': company, + }, + ['+order']: order, + ['+order_by']: orderBy, + }; + + const { + data: childAccounts, + isFetching: isFetchingChildAccounts, + isLoading: isLoadingChildAccounts, + error: errorChildAccounts, + } = useGetDelegatedChildAccountsForUserQuery({ + params: { + page: pagination.page, + page_size: pagination.pageSize, + }, + username, + filter, + }); + + const handleSearch = (value: string) => { + pagination.handlePageChange(1); + navigate({ + to: '/iam/users/$username/delegations', + params: { username }, + search: { company: value || undefined }, + }); + }; + + if (!isIAMDelegationEnabled) { + return null; + } + + if (isLoadingChildAccounts) { + return ; + } + + if (errorChildAccounts) { + return ; + } + + return ( + + + Account Delegations + + + + + + Account + + + + + {childAccounts?.data.length === 0 && ( + + )} + {childAccounts?.data?.map((childAccount) => ( + + {childAccount.company} + + ))} + {(childAccounts?.results ?? 0) > pagination.pageSize && ( + + ({ + padding: 0, + '& > div': { + border: 'none', + borderTop: `1px solid ${theme.borderColors.divider}`, + }, + })} + > + + + + )} + +
    +
    +
    + ); +}; \ No newline at end of file diff --git a/packages/manager/src/features/IAM/Users/UserDetails/DeleteUserPanel.tsx b/packages/manager/src/features/IAM/Users/UserDetails/DeleteUserPanel.tsx index f5ad9e9d906..3cfea9708e6 100644 --- a/packages/manager/src/features/IAM/Users/UserDetails/DeleteUserPanel.tsx +++ b/packages/manager/src/features/IAM/Users/UserDetails/DeleteUserPanel.tsx @@ -20,12 +20,13 @@ export const DeleteUserPanel = ({ canDeleteUser, activeUser }: Props) => { const navigate = useNavigate(); const { profileUserName } = useDelegationRole(); - const isProxyUser = activeUser.user_type === 'proxy'; + const isProxyOrDelegateUserType = + activeUser.user_type === 'proxy' || activeUser.user_type === 'delegate'; const tooltipText = profileUserName === activeUser.username ? 'You can\u{2019}t delete the currently active user.' - : isProxyUser + : isProxyOrDelegateUserType ? `You can\u{2019}t delete a ${PARENT_USER}.` : undefined; @@ -38,7 +39,7 @@ export const DeleteUserPanel = ({ canDeleteUser, activeUser }: Props) => { buttonType="outlined" disabled={ profileUserName === activeUser.username || - isProxyUser || + isProxyOrDelegateUserType || !canDeleteUser } onClick={() => setIsDeleteDialogOpen(true)} diff --git a/packages/manager/src/features/IAM/Users/UserDetails/UserEmailPanel.tsx b/packages/manager/src/features/IAM/Users/UserDetails/UserEmailPanel.tsx index 613476c11a2..92c559d9557 100644 --- a/packages/manager/src/features/IAM/Users/UserDetails/UserEmailPanel.tsx +++ b/packages/manager/src/features/IAM/Users/UserDetails/UserEmailPanel.tsx @@ -20,7 +20,8 @@ export const UserEmailPanel = ({ activeUser }: Props) => { const { enqueueSnackbar } = useSnackbar(); const { profileUserName } = useDelegationRole(); - const isProxyUser = activeUser?.user_type === 'proxy'; + const isProxyOrDelegateUserType = + activeUser?.user_type === 'proxy' || activeUser?.user_type === 'delegate'; const { mutateAsync: updateProfile } = useMutateProfile(); @@ -45,15 +46,18 @@ export const UserEmailPanel = ({ activeUser }: Props) => { } }; - const disabledReason = isProxyUser + const disabledReason = isProxyOrDelegateUserType ? RESTRICTED_FIELD_TOOLTIP - : profileUserName !== activeUser.username - ? 'You can\u{2019}t change another user\u{2019}s email address.' - : undefined; + : activeUser.user_type === 'delegate' && + profileUserName !== activeUser.username + ? 'E-mail addresses of delegate users are not displayed.' + : profileUserName !== activeUser.username + ? 'You can\u{2019}t change another user\u{2019}s email address.' + : undefined; // This should be disabled if this is NOT the current user or if the proxy user is viewing their own profile. const disableEmailField = - profileUserName !== activeUser.username || isProxyUser; + profileUserName !== activeUser.username || isProxyOrDelegateUserType; return ( diff --git a/packages/manager/src/features/IAM/Users/UserDetails/UsernamePanel.tsx b/packages/manager/src/features/IAM/Users/UserDetails/UsernamePanel.tsx index 013cdadfc92..c07ab6ba4f4 100644 --- a/packages/manager/src/features/IAM/Users/UserDetails/UsernamePanel.tsx +++ b/packages/manager/src/features/IAM/Users/UserDetails/UsernamePanel.tsx @@ -20,7 +20,8 @@ export const UsernamePanel = ({ activeUser, canUpdateUser }: Props) => { const navigate = useNavigate(); const { enqueueSnackbar } = useSnackbar(); - const isProxyUser = activeUser?.user_type === 'proxy'; + const isProxyOrDelegateUserType = + activeUser?.user_type === 'proxy' || activeUser?.user_type === 'delegate'; const { mutateAsync } = useUpdateUserMutation(activeUser.username); @@ -53,7 +54,7 @@ export const UsernamePanel = ({ activeUser, canUpdateUser }: Props) => { const tooltipForDisabledUsernameField = !canUpdateUser ? 'Restricted users cannot update their username. Please contact an account administrator.' - : isProxyUser + : isProxyOrDelegateUserType ? RESTRICTED_FIELD_TOOLTIP : undefined; diff --git a/packages/manager/src/features/IAM/Users/UserDetailsLanding.tsx b/packages/manager/src/features/IAM/Users/UserDetailsLanding.tsx index d0500deca04..b909ee87a60 100644 --- a/packages/manager/src/features/IAM/Users/UserDetailsLanding.tsx +++ b/packages/manager/src/features/IAM/Users/UserDetailsLanding.tsx @@ -1,4 +1,4 @@ -import { Chip, NewFeatureChip, styled } from '@linode/ui'; +import { NewFeatureChip } from '@linode/ui'; import { Outlet, useLoaderData, useParams } from '@tanstack/react-router'; import React from 'react'; @@ -20,6 +20,7 @@ import { USER_ENTITIES_LINK, USER_ROLES_LINK, } from '../Shared/constants'; +import { DelegateUserChip } from '../Shared/DelegateUserChip'; export const UserDetailsLanding = () => { const flags = useFlags(); @@ -28,7 +29,7 @@ export const UserDetailsLanding = () => { flags.iamLimitedAvailabilityBadges && isIAMEnabled; const { username } = useParams({ from: '/iam/users/$username' }); const { isIAMDelegationEnabled } = useIsIAMDelegationEnabled(); - const { isParentAccount } = useDelegationRole(); + const { isParentUserType } = useDelegationRole(); const { isDelegateUserForChildAccount } = useLoaderData({ from: '/iam/users/$username', }); @@ -50,7 +51,7 @@ export const UserDetailsLanding = () => { { to: `/iam/users/$username/delegations`, title: 'Account Delegations', - hide: !isIAMDelegationEnabled || !isParentAccount, + hide: !isIAMDelegationEnabled || !isParentUserType, }, ]); @@ -80,10 +81,19 @@ export const UserDetailsLanding = () => { labelOptions: { noCap: true, suffixComponent: isDelegateUserForChildAccount ? ( - + ) : null, }, pathname: location.pathname, + sx: { + flexWrap: 'nowrap', + '& > div:nth-of-type(3) h1': { + display: '-webkit-box', + '-webkit-line-clamp': '1', + '-webkit-box-orient': 'vertical', + overflow: 'hidden', + }, + }, }} docsLink={docsLink} removeCrumbX={4} @@ -99,14 +109,3 @@ 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/UserRoles/AssignNewRoleDrawer.tsx b/packages/manager/src/features/IAM/Users/UserRoles/AssignNewRoleDrawer.tsx index 5b488887e75..f210de4250d 100644 --- a/packages/manager/src/features/IAM/Users/UserRoles/AssignNewRoleDrawer.tsx +++ b/packages/manager/src/features/IAM/Users/UserRoles/AssignNewRoleDrawer.tsx @@ -168,7 +168,7 @@ export const AssignNewRoleDrawer = ({ {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.' + ? 'Add a role you want to assign by default to new delegate users. 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 @@ -219,9 +219,7 @@ export const AssignNewRoleDrawer = ({ { ]); const { isIAMDelegationEnabled } = useIsIAMDelegationEnabled(); + const { isChildUserType, isDelegateUserType } = useDelegationRole(); + const canViewUser = permissions.view_user; - // Determine if the current user is a child account with isIAMDelegationEnabled enabled + // Determine if the current user is a child or delegate profile with isIAMDelegationEnabled enabled // If so, we need to show the 'User type' column in the table - const isChildWithDelegationEnabled = - isIAMDelegationEnabled && Boolean(profile?.user_type === 'child'); + const isChildOrDelegateWithDelegationEnabled = + isIAMDelegationEnabled && (isChildUserType || isDelegateUserType); return ( @@ -54,28 +57,33 @@ export const UserRow = ({ onDelete, user }: Props) => { username={user.username} /> - - {canViewUser ? ( - - {user.username} - - ) : ( - user.username - )} - + 32 ? user.username : null} + > + + {canViewUser ? ( + + {truncateEnd(user.username, 32)} + + ) : ( + truncateEnd(user.username, 32) + )} + + {user.tfa_enabled && } - {isChildWithDelegationEnabled && ( + {isChildOrDelegateWithDelegationEnabled && ( {user.user_type === 'child' ? 'User' : 'Delegate User'} @@ -88,7 +96,7 @@ export const UserRow = ({ onDelete, user }: Props) => { display: { sm: 'table-cell', xs: 'none' }, }} > - {isChildWithDelegationEnabled ? ( + {isChildOrDelegateWithDelegationEnabled ? ( user.user_type === 'child' ? ( ) : ( diff --git a/packages/manager/src/features/IAM/Users/UsersTable/Users.tsx b/packages/manager/src/features/IAM/Users/UsersTable/Users.tsx index 57a4c3e54e4..0b928d251a1 100644 --- a/packages/manager/src/features/IAM/Users/UsersTable/Users.tsx +++ b/packages/manager/src/features/IAM/Users/UsersTable/Users.tsx @@ -1,4 +1,4 @@ -import { useAccountUsers, useProfile } from '@linode/queries'; +import { useAccountUsers } from '@linode/queries'; import { getAPIFilterFromQuery } from '@linode/search'; import { Button, Paper, Select } from '@linode/ui'; import { Grid, useMediaQuery } from '@mui/material'; @@ -13,6 +13,7 @@ import { TableBody } from 'src/components/TableBody'; import { useOrderV2 } from 'src/hooks/useOrderV2'; import { usePaginationV2 } from 'src/hooks/usePaginationV2'; +import { useDelegationRole } from '../../hooks/useDelegationRole'; import { useIsIAMDelegationEnabled } from '../../hooks/useIsIAMEnabled'; import { usePermissions } from '../../hooks/usePermissions'; import { UserDeleteConfirmation } from '../../Shared/UserDeleteConfirmation'; @@ -31,9 +32,10 @@ const ALL_USERS_OPTION: SelectOption = { export const UsersLanding = () => { const navigate = useNavigate(); const { isIAMDelegationEnabled } = useIsIAMDelegationEnabled(); - const { data: profile } = useProfile(); - const { query } = useSearch({ + const { isChildUserType, isDelegateUserType } = useDelegationRole(); + + const { query, users: usersParam } = useSearch({ from: '/iam', }); const [isCreateDrawerOpen, setIsCreateDrawerOpen] = @@ -61,26 +63,52 @@ export const UsersLanding = () => { preferenceKey: 'iam-account-users-order', }); - const queryParams = new URLSearchParams(location.search); - const { error: searchError, filter } = getAPIFilterFromQuery(query, { searchableFieldsWithoutOperator: ['username', 'email'], }); - // Determine if the current user is a child account with isIAMDelegationEnabled enabled + // Determine if the current user is a child or delegate profile with isIAMDelegationEnabled enabled // If so, we need to show both 'child' and 'delegate_user' users in the table - const isChildWithDelegationEnabled = - isIAMDelegationEnabled && Boolean(profile?.user_type === 'child'); + const isChildOrDelegateWithDelegationEnabled = + isIAMDelegationEnabled && (isChildUserType || isDelegateUserType); + + const filterableOptions = React.useMemo( + () => [ + ALL_USERS_OPTION, + { + label: 'Users', + value: 'users', + }, + { + label: 'Delegate Users', + value: 'delegate', + }, + ], + [] + ); + + // Initialize userType based on URL parameter + const getInitialUserType = React.useMemo(() => { + if (!usersParam || usersParam === 'all') { + return ALL_USERS_OPTION; + } + return ( + filterableOptions.find((option) => option.value === usersParam) || + ALL_USERS_OPTION + ); + }, [usersParam, filterableOptions]); const [userType, setUserType] = React.useState( - ALL_USERS_OPTION + getInitialUserType ); const usersFilter: Filter = { ['+order']: order.order, ['+order_by']: order.orderBy, ...filter, - ...(isChildWithDelegationEnabled && userType && userType.value !== 'all' + ...(isChildOrDelegateWithDelegationEnabled && + userType && + userType.value !== 'all' ? { user_type: userType.value === 'users' ? 'child' : 'delegate', } @@ -101,38 +129,26 @@ export const UsersLanding = () => { }, }); - const filterableOptions = [ - ALL_USERS_OPTION, - { - label: 'Users', - value: 'users', - }, - { - label: 'Delegate Users', - value: 'delegate', - }, - ]; - const isSmDown = useMediaQuery(theme.breakpoints.down('sm')); const isLgDown = useMediaQuery(theme.breakpoints.up('lg')); - const numColsLg = isLgDown ? 4 : 3; + const numColsLg = isLgDown + ? isChildOrDelegateWithDelegationEnabled + ? 5 + : 4 + : 3; const numCols = isSmDown ? 2 : numColsLg; const handleSearch = (value: string) => { - queryParams.set('page', '1'); - if (value) { - queryParams.set('query', value); - } else { - queryParams.delete('query'); - } + const nextQuery = value === '' ? undefined : String(value); navigate({ to: '/iam/users', - search: { - users: queryParams.get('users') ?? 'all', - query: value, - }, + search: (prev) => ({ + ...prev, + query: nextQuery, + page: 1, + }), }); }; @@ -141,8 +157,17 @@ export const UsersLanding = () => { setSelectedUsername(username); }; - const canCreateUser = permissions.create_user; + const handleDeleteDialogClose = () => { + const removedLastOnPage = + users && users?.data.length % pagination.pageSize === 1; + setIsDeleteDialogOpen(false); + if (removedLastOnPage) { + pagination.handlePageChange(pagination.page - 1); + } + }; + + const canCreateUser = permissions.create_user; return ( ({ marginTop: theme.tokens.spacing.S16 })}> @@ -175,7 +200,7 @@ export const UsersLanding = () => { placeholder="Filter" value={query ?? ''} /> - {isChildWithDelegationEnabled && ( + {isChildOrDelegateWithDelegationEnabled && ( option.isDisabled || false} id="plan-filter-generation" + isOptionEqualToValue={(option, value) => { + if (!option || !value) { + return false; + } + return option.value === value.value; + }} label="Dedicated Plans" onChange={handleGenerationChange} - options={GENERATION_OPTIONS} + options={generationOptions} placeholder="Select a plan" sx={{ width: 360 }} value={selectedGenerationOption} @@ -188,7 +230,7 @@ const DedicatedPlanFiltersComponent = React.memo( return { filteredPlans, filterUI, - hasActiveFilters: generation !== PLAN_FILTER_ALL, + hasActiveFilters: generation !== PLAN_FILTER_ALL_AVAILABLE, }; }, [ disabled, diff --git a/packages/manager/src/features/components/PlansPanel/GpuFilters.tsx b/packages/manager/src/features/components/PlansPanel/GpuFilters.tsx index 0fcaabe5c46..f1f438dec8f 100644 --- a/packages/manager/src/features/components/PlansPanel/GpuFilters.tsx +++ b/packages/manager/src/features/components/PlansPanel/GpuFilters.tsx @@ -5,16 +5,18 @@ * uses local React state to manage filter selections. */ -import { Select } from '@linode/ui'; +import { Autocomplete } from '@linode/ui'; import * as React from 'react'; import { PLAN_FILTER_ALL, + PLAN_FILTER_ALL_AVAILABLE, PLAN_FILTER_GPU_RTX_4000_ADA, PLAN_FILTER_GPU_RTX_6000, PLAN_FILTER_GPU_RTX_PRO_6000, } from './constants'; -import { filterPlansByGpuType } from './utils/planFilters'; +import { getIsPlanDisabled } from './utils'; +import { filterPlansByGpuType, getGpuRank } from './utils/planFilters'; import type { PlanFilterRenderArgs, @@ -24,14 +26,19 @@ import type { PlanWithAvailability } from './types'; import type { PlanFilterGPU } from './types/planFilters'; import type { SelectOption } from '@linode/ui'; +type GPUOptionWithDisabled = SelectOption & { + isDisabled: boolean; +}; const ALL_GPU_OPTIONS: SelectOption[] = [ - { label: 'All', value: PLAN_FILTER_ALL }, + { label: 'All Available Plans', value: PLAN_FILTER_ALL_AVAILABLE }, + { label: 'All Plans', value: PLAN_FILTER_ALL }, { label: 'RTX PRO 6000 Blackwell', value: PLAN_FILTER_GPU_RTX_PRO_6000 }, { label: 'RTX 4000 Ada', value: PLAN_FILTER_GPU_RTX_4000_ADA }, { label: 'Quadro RTX 6000', value: PLAN_FILTER_GPU_RTX_6000 }, ]; interface GPUPlanFilterComponentProps { + disabled?: boolean; onResult: (result: PlanFilterRenderResult) => void; plans: PlanWithAvailability[]; resetPagination: () => void; @@ -39,11 +46,12 @@ interface GPUPlanFilterComponentProps { const GPUPlanFilterComponent = React.memo( (props: GPUPlanFilterComponentProps) => { - const { onResult, plans, resetPagination } = props; + const { disabled = false, onResult, plans, resetPagination } = props; // Local state - persists automatically because component stays mounted - const [gpuType, setGpuType] = - React.useState(PLAN_FILTER_ALL); + const [gpuType, setGpuType] = React.useState( + PLAN_FILTER_ALL_AVAILABLE + ); const previousFilters = React.useRef<{ gpuType?: PlanFilterGPU; @@ -51,12 +59,48 @@ const GPUPlanFilterComponent = React.memo( // Compute available GPU options based on plans const GPU_OPTIONS_BASED_ON_AVAILABLE_PLANS = React.useMemo(() => { - return ALL_GPU_OPTIONS.filter((option) => { - if (option.value === 'all') { - return true; + const options = ALL_GPU_OPTIONS.reduce( + (acc: GPUOptionWithDisabled[], option) => { + if ( + option.value === PLAN_FILTER_ALL || + option.value === PLAN_FILTER_ALL_AVAILABLE + ) { + acc.push({ + ...option, + isDisabled: false, + }); + } else { + const filteredPlans = filterPlansByGpuType(plans, option.value); + if (filteredPlans.length > 0) { + acc.push({ + ...option, + isDisabled: filteredPlans.every((plan) => + getIsPlanDisabled(plan) + ), + }); + } + } + return acc; + }, + [] + ); + // Sort options: available first, then all, then by generation (Blackwell > Ada > Quadro) + return options.sort((a, b) => { + // "available" always comes first + if (a.value === 'available') return -1; + if (b.value === 'available') return 1; + + // "all" always comes second + if (a.value === 'all') return -1; + if (b.value === 'all') return 1; + + // enabled options before disabled + if (a.isDisabled !== b.isDisabled) { + return Number(a.isDisabled) - Number(b.isDisabled); } - const filteredPlans = filterPlansByGpuType(plans, option.value); - return filteredPlans.length > 0; + + // generation order blackwell > ada > quadro + return getGpuRank(b.value) - getGpuRank(a.value); }); }, [plans]); @@ -78,26 +122,23 @@ const GPUPlanFilterComponent = React.memo( }, [gpuType, resetPagination]); const handleGpuTypeChange = React.useCallback( - ( - _event: React.SyntheticEvent, - option: null | SelectOption - ) => { - const newGpuType = - (option?.value as PlanFilterGPU | undefined) ?? PLAN_FILTER_ALL; + (_event: React.SyntheticEvent, option: GPUOptionWithDisabled) => { + const newGpuType = option?.value ?? PLAN_FILTER_ALL_AVAILABLE; setGpuType(newGpuType); }, [] ); - const filteredPlans = React.useMemo(() => { - return filterPlansByGpuType(plans, gpuType); - }, [gpuType, plans]); + const filteredPlans = React.useMemo( + () => filterPlansByGpuType(plans, gpuType), + [gpuType, plans] + ); const selectedGpuType = React.useMemo(() => { return ( GPU_OPTIONS_BASED_ON_AVAILABLE_PLANS.find( (opt) => opt.value === gpuType - ) ?? null + ) ?? undefined ); }, [gpuType, GPU_OPTIONS_BASED_ON_AVAILABLE_PLANS]); @@ -109,9 +150,12 @@ const GPUPlanFilterComponent = React.memo( marginTop: -16, }} > -