diff --git a/package.json b/package.json index 388b9dd7ee5..7ea7b9bd378 100644 --- a/package.json +++ b/package.json @@ -8,8 +8,8 @@ "concurrently": "9.1.0", "husky": "^9.1.6", "typescript": "^5.7.3", - "vitest": "^3.2.4", - "@vitest/ui": "^3.2.4", + "vitest": "^4.0.10", + "@vitest/ui": "^4.0.10", "lint-staged": "^15.4.3", "eslint": "^9.24.0", "eslint-config-prettier": "^10.1.1", diff --git a/packages/api-v4/CHANGELOG.md b/packages/api-v4/CHANGELOG.md index f290607bda5..ba68e404cca 100644 --- a/packages/api-v4/CHANGELOG.md +++ b/packages/api-v4/CHANGELOG.md @@ -1,3 +1,25 @@ +## [2025-12-09] - v0.154.0 + + +### Changed: + +- Update database restoreWithBackup data to include region ([#13097](https://github.com/linode/manager/pull/13097)) +- Add private_network to `DatabaseBackupsPayload` ([#13174](https://github.com/linode/manager/pull/13174)) + +### Tech Stories: + +- Add `@types/node` as a devDependency ([#13119](https://github.com/linode/manager/pull/13119)) + +### Upcoming Features: + +- Add new API endpoints and types for Network Load Balancers ([#13078](https://github.com/linode/manager/pull/13078)) +- Update FirewallRuleType to support ruleset ([#13079](https://github.com/linode/manager/pull/13079)) +- Add additional status types `enabling`, `disabling`, `provisioning` in CloudPulse alerts ([#13127](https://github.com/linode/manager/pull/13127)) +- CloudPulse-Metrics: Update `entity_ids` type in `CloudPulseMetricsRequest` for metrics api in endpoints dahsboard ([#13133](https://github.com/linode/manager/pull/13133)) +- Add `deleted` property to `FirewallPrefixList` type after API update ([#13146](https://github.com/linode/manager/pull/13146)) +- Added Database Connection Pool types and endpoints ([#13148](https://github.com/linode/manager/pull/13148)) +- Add 'Cloud Firewall Rule Set' to AccountCapability type ([#13156](https://github.com/linode/manager/pull/13156)) + ## [2025-11-18] - v0.153.0 diff --git a/packages/api-v4/package.json b/packages/api-v4/package.json index f19a56eeb28..bc12f54b67b 100644 --- a/packages/api-v4/package.json +++ b/packages/api-v4/package.json @@ -1,6 +1,6 @@ { "name": "@linode/api-v4", - "version": "0.153.0", + "version": "0.154.0", "homepage": "https://github.com/linode/manager/tree/develop/packages/api-v4", "bugs": { "url": "https://github.com/linode/manager/issues" @@ -59,7 +59,8 @@ "@linode/tsconfig": "workspace:*", "axios-mock-adapter": "^1.22.0", "concurrently": "^9.0.1", - "tsup": "^8.4.0" + "tsup": "^8.4.0", + "@types/node": "^22.13.14" }, "lint-staged": { "*.{ts,tsx,js}": [ diff --git a/packages/api-v4/src/account/types.ts b/packages/api-v4/src/account/types.ts index 73d008377cf..2f88bc7b053 100644 --- a/packages/api-v4/src/account/types.ts +++ b/packages/api-v4/src/account/types.ts @@ -66,6 +66,7 @@ export const accountCapabilities = [ 'Block Storage', 'Block Storage Encryption', 'Cloud Firewall', + 'Cloud Firewall Rule Set', 'CloudPulse', 'Disk Encryption', 'Kubernetes', @@ -80,6 +81,7 @@ export const accountCapabilities = [ 'Managed Databases', 'Managed Databases Beta', 'NETINT Quadra T1U', + 'Network LoadBalancer', 'NodeBalancers', 'Object Storage Access Key Regions', 'Object Storage Endpoint Types', diff --git a/packages/api-v4/src/cloudpulse/types.ts b/packages/api-v4/src/cloudpulse/types.ts index 4ac6b19d7f8..07dca57b539 100644 --- a/packages/api-v4/src/cloudpulse/types.ts +++ b/packages/api-v4/src/cloudpulse/types.ts @@ -19,8 +19,15 @@ export type DimensionFilterOperatorType = | 'neq' | 'startswith'; export type AlertDefinitionType = 'system' | 'user'; -export type AlertStatusType = 'disabled' | 'enabled' | 'failed' | 'in progress'; export type AlertDefinitionScope = 'account' | 'entity' | 'region'; +export type AlertStatusType = + | 'disabled' + | 'disabling' + | 'enabled' + | 'enabling' + | 'failed' + | 'in progress' + | 'provisioning'; export type CriteriaConditionType = 'ALL'; export type MetricUnitType = | 'bit_per_second' @@ -152,7 +159,7 @@ export interface Metric { export interface CloudPulseMetricsRequest { absolute_time_duration: DateTimeWithPreset | undefined; associated_entity_region?: string; - entity_ids: number[] | string[]; + entity_ids: number[] | string[] | undefined; entity_region?: string; filters?: Filters[]; group_by?: string[]; diff --git a/packages/api-v4/src/databases/databases.ts b/packages/api-v4/src/databases/databases.ts index 02f4a5b23ca..e23f1ac3536 100644 --- a/packages/api-v4/src/databases/databases.ts +++ b/packages/api-v4/src/databases/databases.ts @@ -1,5 +1,7 @@ import { + createDatabaseConnectionPoolSchema, createDatabaseSchema, + updateDatabaseConnectionPoolSchema, updateDatabaseSchema, } from '@linode/validation/lib/databases.schema'; @@ -14,13 +16,14 @@ import Request, { import type { Filter, ResourcePage as Page, Params } from '../types'; import type { + ConnectionPool, CreateDatabasePayload, Database, DatabaseBackup, + DatabaseBackupsPayload, DatabaseCredentials, DatabaseEngine, DatabaseEngineConfig, - DatabaseFork, DatabaseInstance, DatabaseType, Engine, @@ -267,11 +270,14 @@ export const legacyRestoreWithBackup = ( * * Fully restore a backup to the cluster */ -export const restoreWithBackup = (engine: Engine, fork: DatabaseFork) => +export const restoreWithBackup = ( + engine: Engine, + data: DatabaseBackupsPayload, +) => Request( setURL(`${API_ROOT}/databases/${encodeURIComponent(engine)}/instances`), setMethod('POST'), - setData({ fork }), + setData(data), ); /** @@ -361,3 +367,73 @@ export const getDatabaseEngineConfig = (engine: Engine) => setURL(`${API_ROOT}/databases/${encodeURIComponent(engine)}/config`), setMethod('GET'), ); + +/** + * Get a paginated list of connection pools for a database + */ +export const getDatabaseConnectionPools = (databaseID: number) => + Request>( + setURL( + `${API_ROOT}/databases/postgresql/instances/${encodeURIComponent(databaseID)}/connection-pools`, + ), + setMethod('GET'), + ); + +/** + * Get a connection pool for a database + */ +export const getDatabaseConnectionPool = ( + databaseID: number, + poolName: string, +) => + Request( + setURL( + `${API_ROOT}/databases/postgresql/instances/${encodeURIComponent(databaseID)}/connection-pools/${encodeURIComponent(poolName)}`, + ), + setMethod('GET'), + ); + +/** + * Create a new connection pool for a database. Connection pools can only be created on active clusters + */ +export const createDatabaseConnectionPool = ( + databaseID: number, + data: ConnectionPool, +) => + Request( + setURL( + `${API_ROOT}/databases/postgresql/instances/${encodeURIComponent(databaseID)}/connection-pools`, + ), + setMethod('POST'), + setData(data, createDatabaseConnectionPoolSchema), + ); + +/** + * Update an existing connection pool. This may cause sudden closure of an in-use connection pool + */ +export const updateDatabaseConnectionPool = ( + databaseID: number, + poolName: string, + data: Omit, +) => + Request( + setURL( + `${API_ROOT}/databases/postgresql/instances/${encodeURIComponent(databaseID)}/connection-pools/${encodeURIComponent(poolName)}`, + ), + setMethod('PUT'), + setData(data, updateDatabaseConnectionPoolSchema), + ); + +/** + * Delete an existing connection pool. This may cause sudden closure of an in-use connection pool + */ +export const deleteDatabaseConnectionPool = ( + databaseID: number, + poolName: string, +) => + Request<{}>( + setURL( + `${API_ROOT}/databases/postgresql/instances/${encodeURIComponent(databaseID)}/connection-pools/${encodeURIComponent(poolName)}`, + ), + setMethod('DELETE'), + ); diff --git a/packages/api-v4/src/databases/types.ts b/packages/api-v4/src/databases/types.ts index 694493768e3..d42c0ef23e3 100644 --- a/packages/api-v4/src/databases/types.ts +++ b/packages/api-v4/src/databases/types.ts @@ -79,6 +79,12 @@ export interface DatabaseFork { source: number; } +export interface DatabaseBackupsPayload { + fork: DatabaseFork; + private_network?: null | PrivateNetwork; + region?: string; +} + export interface DatabaseCredentials { password: string; username: string; @@ -100,6 +106,7 @@ type MemberType = 'failover' | 'primary'; export interface DatabaseInstance { allow_list: string[]; cluster_size: ClusterSize; + connection_pool_port: null | number; connection_strings: ConnectionStrings[]; created: string; /** @Deprecated used by rdbms-legacy only, rdbms-default always encrypts */ @@ -163,12 +170,10 @@ interface ConnectionStrings { value: string; } -export type UpdatesFrequency = 'monthly' | 'weekly'; - export interface UpdatesSchedule { day_of_week: number; duration: number; - frequency: UpdatesFrequency; + frequency: 'monthly' | 'weekly'; hour_of_day: number; pending?: PendingUpdates[]; week_of_month: null | number; @@ -246,3 +251,13 @@ export interface UpdateDatabasePayload { updates?: UpdatesSchedule; version?: string; } + +export type PoolMode = 'session' | 'statement' | 'transaction'; + +export interface ConnectionPool { + database: string; + label: string; + mode: PoolMode; + size: number; + username: null | string; +} diff --git a/packages/api-v4/src/firewalls/types.ts b/packages/api-v4/src/firewalls/types.ts index 372d1f0d616..7f27fbc914a 100644 --- a/packages/api-v4/src/firewalls/types.ts +++ b/packages/api-v4/src/firewalls/types.ts @@ -36,16 +36,26 @@ export type UpdateFirewallRules = Omit< export type FirewallTemplateRules = UpdateFirewallRules; +/** + * The API may return either a full firewall rule object or a ruleset reference + * containing only the `ruleset` field. This interface supports both formats + * to ensure backward compatibility with existing implementations and avoid + * widespread refactoring. + */ export interface FirewallRuleType { - action: FirewallPolicyType; + action?: FirewallPolicyType | null; addresses?: null | { ipv4?: null | string[]; ipv6?: null | string[]; }; description?: null | string; label?: null | string; - ports?: string; - protocol: FirewallRuleProtocol; + ports?: null | string; + protocol?: FirewallRuleProtocol | null; + /** + * Present when the object represents a ruleset reference. + */ + ruleset?: null | number; } export interface FirewallDeviceEntity { @@ -124,6 +134,7 @@ export type FirewallPrefixListVisibility = 'private' | 'public' | 'restricted'; export interface FirewallPrefixList { created: string; + deleted: null | string; description: string; id: number; ipv4?: null | string[]; diff --git a/packages/api-v4/src/iam/iam.ts b/packages/api-v4/src/iam/iam.ts index 405e2a68ad0..006f634b428 100644 --- a/packages/api-v4/src/iam/iam.ts +++ b/packages/api-v4/src/iam/iam.ts @@ -1,6 +1,13 @@ import { BETA_API_ROOT } from '../constants'; -import Request, { setData, setMethod, setURL } from '../request'; +import Request, { + setData, + setMethod, + setParams, + setURL, + setXFilter, +} from '../request'; +import type { Filter, Params, ResourcePage } from '../types'; import type { AccessType, EntityByPermission, @@ -9,7 +16,6 @@ import type { PermissionType, } from './types'; import type { EntityType } from 'src/entities/types'; - /** * getUserRoles * @@ -93,6 +99,8 @@ export const getUserEntityPermissions = ( username: string, entityType: AccessType, entityId: number | string, + params?: Params, + filter?: Filter, ) => Request( setURL( @@ -101,6 +109,8 @@ export const getUserEntityPermissions = ( )}/permissions/${entityType}/${entityId}`, ), setMethod('GET'), + setParams(params), + setXFilter(filter), ); /** @@ -109,7 +119,10 @@ export const getUserEntityPermissions = ( * Returns the available entities for a given permission. */ export interface GetEntitiesByPermissionParams { + enabled?: boolean; entityType: EntityType; + filter?: Filter; + params?: Params; permission: PermissionType; username: string | undefined; } @@ -118,10 +131,14 @@ export const getUserEntitiesByPermission = ({ username, entityType, permission, + params, + filter, }: GetEntitiesByPermissionParams) => - Request( + Request>( setURL( `${BETA_API_ROOT}/iam/users/${username}/entities/${entityType}?permission=${permission}`, ), setMethod('GET'), + setParams(params), + setXFilter(filter), ); diff --git a/packages/api-v4/src/iam/types.ts b/packages/api-v4/src/iam/types.ts index 4005384e3d9..3ffeaa29952 100644 --- a/packages/api-v4/src/iam/types.ts +++ b/packages/api-v4/src/iam/types.ts @@ -457,7 +457,7 @@ export interface Roles { permissions: PermissionType[]; } export interface EntityByPermission { - id: number; + id: number | string; label: string; type: EntityType; } diff --git a/packages/api-v4/src/index.ts b/packages/api-v4/src/index.ts index 00e09bce480..3b90f57ec71 100644 --- a/packages/api-v4/src/index.ts +++ b/packages/api-v4/src/index.ts @@ -28,6 +28,8 @@ export * from './longview'; export * from './managed'; +export * from './netloadbalancers'; + export * from './network-transfer'; export * from './networking'; diff --git a/packages/api-v4/src/netloadbalancers/index.ts b/packages/api-v4/src/netloadbalancers/index.ts new file mode 100644 index 00000000000..7ef573ac031 --- /dev/null +++ b/packages/api-v4/src/netloadbalancers/index.ts @@ -0,0 +1,4 @@ +export * from './listeners'; +export * from './netloadbalancers'; +export * from './nodes'; +export * from './types'; diff --git a/packages/api-v4/src/netloadbalancers/listeners.ts b/packages/api-v4/src/netloadbalancers/listeners.ts new file mode 100644 index 00000000000..4f9db29fae8 --- /dev/null +++ b/packages/api-v4/src/netloadbalancers/listeners.ts @@ -0,0 +1,45 @@ +import { BETA_API_ROOT } from '../constants'; +import Request, { setMethod, setParams, setURL, setXFilter } from '../request'; + +import type { Filter, ResourcePage as Page, Params } from '../types'; +import type { NetworkLoadBalancerListener } from './types'; + +/** + * getNetworkLoadBalancerListeners + * + * Returns a paginated list of listeners for a Network Load Balancer. + * + * @param networkLoadBalancerId { number } The ID of the Network Load Balancer. + */ +export const getNetworkLoadBalancerListeners = ( + networkLoadBalancerId: number, + params?: Params, + filters?: Filter, +) => + Request>( + setURL( + `${BETA_API_ROOT}/netloadbalancers/${encodeURIComponent(networkLoadBalancerId)}/listeners`, + ), + setMethod('GET'), + setParams(params), + setXFilter(filters), + ); + +/** + * getNetworkLoadBalancerListener + * + * Returns detailed information about a single listener. + * + * @param networkLoadBalancerId { number } The ID of the Network Load Balancer. + * @param listenerId { number } The ID of the listener to retrieve. + */ +export const getNetworkLoadBalancerListener = ( + networkLoadBalancerId: number, + listenerId: number, +) => + Request( + setURL( + `${BETA_API_ROOT}/netloadbalancers/${encodeURIComponent(networkLoadBalancerId)}/listeners/${encodeURIComponent(listenerId)}`, + ), + setMethod('GET'), + ); diff --git a/packages/api-v4/src/netloadbalancers/netloadbalancers.ts b/packages/api-v4/src/netloadbalancers/netloadbalancers.ts new file mode 100644 index 00000000000..faca6f073b5 --- /dev/null +++ b/packages/api-v4/src/netloadbalancers/netloadbalancers.ts @@ -0,0 +1,33 @@ +import { BETA_API_ROOT } from '../constants'; +import Request, { setMethod, setParams, setURL, setXFilter } from '../request'; + +import type { Filter, ResourcePage as Page, Params } from '../types'; +import type { NetworkLoadBalancer } from './types'; + +/** + * getNetworkLoadBalancers + * + * Returns a paginated list of Network Load Balancers on your account. + */ +export const getNetworkLoadBalancers = (params?: Params, filters?: Filter) => + Request>( + setURL(`${BETA_API_ROOT}/netloadbalancers`), + setMethod('GET'), + setParams(params), + setXFilter(filters), + ); + +/** + * getNetworkLoadBalancer + * + * Returns detailed information about a single Network Load Balancer. + * + * @param networkLoadBalancerId { number } The ID of the Network Load Balancer to retrieve. + */ +export const getNetworkLoadBalancer = (networkLoadBalancerId: number) => + Request( + setURL( + `${BETA_API_ROOT}/netloadbalancers/${encodeURIComponent(networkLoadBalancerId)}`, + ), + setMethod('GET'), + ); diff --git a/packages/api-v4/src/netloadbalancers/nodes.ts b/packages/api-v4/src/netloadbalancers/nodes.ts new file mode 100644 index 00000000000..e7ad1045469 --- /dev/null +++ b/packages/api-v4/src/netloadbalancers/nodes.ts @@ -0,0 +1,62 @@ +import { BETA_API_ROOT } from '../constants'; +import Request, { setMethod, setParams, setURL, setXFilter } from '../request'; + +import type { Filter, ResourcePage as Page, Params } from '../types'; +import type { NetworkLoadBalancerNode } from './types'; + +/** + * getNetworkLoadBalancerNodes + * + * Returns a paginated list of nodes for a listener. + * + * @param networkLoadBalancerId { number } The ID of the Network Load Balancer. + * @param listenerId { number } The ID of the listener. + */ +interface GetNetworkLoadBalancerNodesOptions { + filters?: Filter; + listenerId: number; + networkLoadBalancerId: number; + params?: Params; +} + +export const getNetworkLoadBalancerNodes = ({ + networkLoadBalancerId, + listenerId, + params, + filters, +}: GetNetworkLoadBalancerNodesOptions) => + Request>( + setURL( + `${BETA_API_ROOT}/netloadbalancers/${encodeURIComponent(networkLoadBalancerId)}/listeners/${encodeURIComponent(listenerId)}/nodes`, + ), + setMethod('GET'), + setParams(params), + setXFilter(filters), + ); + +/** + * getNetworkLoadBalancerNode + * + * Returns detailed information about a single node. + * + * @param networkLoadBalancerId { number } The ID of the Network Load Balancer. + * @param listenerId { number } The ID of the listener. + * @param nodeId { number } The ID of the node to retrieve. + */ +interface GetNetworkLoadBalancerNodeOptions { + listenerId: number; + networkLoadBalancerId: number; + nodeId: number; +} + +export const getNetworkLoadBalancerNode = ({ + listenerId, + networkLoadBalancerId, + nodeId, +}: GetNetworkLoadBalancerNodeOptions) => + Request( + setURL( + `${BETA_API_ROOT}/netloadbalancers/${encodeURIComponent(networkLoadBalancerId)}/listeners/${encodeURIComponent(listenerId)}/nodes/${encodeURIComponent(nodeId)}`, + ), + setMethod('GET'), + ); diff --git a/packages/api-v4/src/netloadbalancers/types.ts b/packages/api-v4/src/netloadbalancers/types.ts new file mode 100644 index 00000000000..6feb36d197b --- /dev/null +++ b/packages/api-v4/src/netloadbalancers/types.ts @@ -0,0 +1,97 @@ +import type { LKEClusterInfo } from '../nodebalancers/types'; + +export interface LinodeInfo { + id: number; + label: string; + type: 'linode'; + url: string; +} + +export type NetworkLoadBalancerStatus = 'active' | 'canceled' | 'suspended'; + +export type NetworkLoadBalancerListenerProtocol = 'tcp' | 'udp'; + +export interface NetworkLoadBalancerListener { + created: string; + /** + * The unique ID of this listener + */ + id: number; + /** + * The label for this listener + */ + label: string; + /** + * The port the listener is configured to listen on + */ + port: number; + /** + * The protocol used by this listener + */ + protocol: NetworkLoadBalancerListenerProtocol; + updated: string; +} + +export interface NetworkLoadBalancerNode { + /** + * The IPv6 address of the node + */ + address_v6: string; + created: string; + /** + * The unique ID of this node + */ + id: number; + /** + * The label for this node + */ + label: string; + /** + * Information about the Linode this node is associated with (if available) + */ + linode_id: number; + updated: string; + weight: number; + weight_updated: string; +} + +export interface NetworkLoadBalancer { + /** + * Virtual IP addresses assigned to this Network Load Balancer + */ + address_v4: string; + address_v6: string; + /** + * When this Network Load Balancer was created + */ + created: string; + /** + * The unique ID of this Network Load Balancer + */ + id: number; + /** + * The label for this Network Load Balancer + */ + label: string; + last_composite_updated: string; + /** + * Listeners configured on this Network Load Balancer + */ + listeners: NetworkLoadBalancerListener[]; + /** + * Information about the LKE cluster this NLB is associated with + */ + lke_cluster?: LKEClusterInfo; + /** + * The region where this Network Load Balancer is deployed + */ + region: string; + /** + * The current status of this Network Load Balancer + */ + status: NetworkLoadBalancerStatus; + /** + * When this Network Load Balancer was last updated + */ + updated: string; +} diff --git a/packages/api-v4/src/regions/types.ts b/packages/api-v4/src/regions/types.ts index 4da65b28c95..e4fa034d08a 100644 --- a/packages/api-v4/src/regions/types.ts +++ b/packages/api-v4/src/regions/types.ts @@ -19,6 +19,7 @@ export type Capabilities = | 'Managed Databases' | 'Metadata' | 'NETINT Quadra T1U' + | 'Network LoadBalancer' | 'NodeBalancers' | 'Object Storage' | 'Placement Group' diff --git a/packages/manager/CHANGELOG.md b/packages/manager/CHANGELOG.md index dd5ff6f1a90..efe48704bd1 100644 --- a/packages/manager/CHANGELOG.md +++ b/packages/manager/CHANGELOG.md @@ -4,6 +4,100 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [2025-12-09] - v1.156.0 + + +### Added: + +- IAM Parent/Child: permissions switch account ([#13075](https://github.com/linode/manager/pull/13075)) +- Region select to Database Backups tab ([#13097](https://github.com/linode/manager/pull/13097)) +- IAM "New" and "Limited Availability" badges ([#13175](https://github.com/linode/manager/pull/13175)) + +### Changed: + +- Update icon svg files to match with Akamai design system ([#13032](https://github.com/linode/manager/pull/13032)) +- IAM: fix permission check for detaching volumes ([#13099](https://github.com/linode/manager/pull/13099)) +- Move Action column to the 2nd position in the Firewall Rules Table ([#13122](https://github.com/linode/manager/pull/13122)) +- Await permissions before rendering Linode Detail Header ([#13124](https://github.com/linode/manager/pull/13124)) +- Legal sign off in Logs Streams Create Checkout bar ([#13131](https://github.com/linode/manager/pull/13131)) +- Add Chip Support to Drawer Component Title ([#13135](https://github.com/linode/manager/pull/13135)) +- Logs Delivery UI changes after review ([#13140](https://github.com/linode/manager/pull/13140)) +- Logs: Many minor UI fixes and improvements ([#13166](https://github.com/linode/manager/pull/13166)) + +### Fixed: + +- Fix incorrect maintenance time display in the Upcoming maintenance table ([#13059](https://github.com/linode/manager/pull/13059)) +- IAM: the aria-label for the Users table action menu displays an incorrect username ([#13082](https://github.com/linode/manager/pull/13082)) +- Alignment with Linode row backup cell icon ([#13098](https://github.com/linode/manager/pull/13098)) +- Plans panel pagination bug fix ([#13100](https://github.com/linode/manager/pull/13100)) +- The `firewall_id` error on LKE pool update ([#13109](https://github.com/linode/manager/pull/13109)) +- Disabled Tab + Tooltip styles & accessibility ([#13113](https://github.com/linode/manager/pull/13113)) +- IAM: The StackScript/Linode selector is enabled in the Create Linode flow when the user doesn’t have the create_linode permission ([#13118](https://github.com/linode/manager/pull/13118)) +- DBaaS - Manage Networking VPC fields not handling error response ([#13121](https://github.com/linode/manager/pull/13121)) +- IAM: filtering by entity type at the Roles table ([#13129](https://github.com/linode/manager/pull/13129)) +- CloudPulse metrics volumes contextual view `not showing dimension values` and CloudPulse metrics `group by default selection retention` ([#13139](https://github.com/linode/manager/pull/13139)) +- IAM: disable/enable fields based on create_linode permission ([#13142](https://github.com/linode/manager/pull/13142)) +- IAM Permissions performance improvements: Create from Backup & Clone ([#13143](https://github.com/linode/manager/pull/13143)) +- IAM Permissions performance improvements: Firewall entity assignment ([#13153](https://github.com/linode/manager/pull/13153)) +- EntitiesSelect performance on large accounts ([#13168](https://github.com/linode/manager/pull/13168)) +- Optimize rendering of entities in AssignedRolesTable ([#13173](https://github.com/linode/manager/pull/13173)) +- Forking a Database Cluster with VPC into another region ([#13174](https://github.com/linode/manager/pull/13174)) + +### Tech Stories: + +- DBaaS: Replace the dropdowns in Database cluster settings page with CDS select web component ([#13057](https://github.com/linode/manager/pull/13057)) +- Replace Formik with React Hook Form in MaintenanceWindow ([#13060](https://github.com/linode/manager/pull/13060)) +- Update Vite from `7.1.11` to `7.2.2` ([#13119](https://github.com/linode/manager/pull/13119)) +- Fix circular imports in CloudPulse ([#13119](https://github.com/linode/manager/pull/13119)) +- Update vitest from `v3` to `v4` ([#13119](https://github.com/linode/manager/pull/13119)) + +### Tests: + +- Fix flakey vm-host test ([#13083](https://github.com/linode/manager/pull/13083)) +- Fixed various test failures when running tests against Prod environment ([#13107](https://github.com/linode/manager/pull/13107)) + +### Upcoming Features: + +- Implement feature flag and routing for NLB ([#13068](https://github.com/linode/manager/pull/13068)) +- Add new Firewall RuleSet row layout ([#13079](https://github.com/linode/manager/pull/13079)) +- Disable premium plan tab if corresponding g7 dedicated plans are available ([#13081](https://github.com/linode/manager/pull/13081)) +- Object storage summary page migrated to use table view ([#13087](https://github.com/linode/manager/pull/13087)) +- Scaffolding setup for widget level dimension filters in cloudpulse metrics and group by issue fix in cloudpulse metrics ([#13088](https://github.com/linode/manager/pull/13088)) +- Integrate Firewall-nodebalancer support for ACLP-Alerting ([#13089](https://github.com/linode/manager/pull/13089)) +- Add tooltip for Rules column header in Firewall Rules table ([#13090](https://github.com/linode/manager/pull/13090)) +- CloudPulse-Metrics: Enhance `CloudPulseWidgetUtils.ts` to handle id to label conversion of linode associated with volume in volumes service ([#13092](https://github.com/linode/manager/pull/13092)) +- Implement Filtering for Plans table ([#13093](https://github.com/linode/manager/pull/13093)) +- Update Firewall Rule Drawer to support referencing Rule Set ([#13094](https://github.com/linode/manager/pull/13094)) +- Edit Stream form: remove cluster IDs from the edited stream that no longer exist or have log generation disabled ([#13095](https://github.com/linode/manager/pull/13095)) +- Implement mocks and factories for Network LoadBalancer ([#13104](https://github.com/linode/manager/pull/13104)) +- New Rule Set Details drawer with Marked for Deletion status ([#13108](https://github.com/linode/manager/pull/13108)) +- ACLP-Alerting: Filtering entities for firewall system alerts, add tooltip text to Entity Type component ([#13110](https://github.com/linode/manager/pull/13110)) +- CloudPulse-Metrics: Remove filtering of firewalls and region filter dependency on firewall-select in Firewalls ([#13111](https://github.com/linode/manager/pull/13111)) +- Add NetworkLoadBalancersLanding component to render NLB list with pagination, loading/error and table columns ([#13112](https://github.com/linode/manager/pull/13112)) +- Implement filter for GPU plans in plans panel ([#13115](https://github.com/linode/manager/pull/13115)) +- Add integration changes with `CloudPulseWidget` for widget level dimension support in CloudPulse metrics ([#13116](https://github.com/linode/manager/pull/13116)) +- Destination Form: fixes and improvements for Sample Destination Object Name ([#13117](https://github.com/linode/manager/pull/13117)) +- Add `generateAddressesLabelV2` utility to support PrefixLists ([#13122](https://github.com/linode/manager/pull/13122)) +- Implement Listeners Table in Network LoadBalancer Detail page ([#13123](https://github.com/linode/manager/pull/13123)) +- Use new JSON-based fwRulesetsPrefixLists feature flag for Firewall RuleSets and Prefix Lists feature ([#13125](https://github.com/linode/manager/pull/13125)) +- Add support for additional status types and handle action menu accordingly in CloudPulse alerts ([#13127](https://github.com/linode/manager/pull/13127)) +- Add a Network Load Balancer Listener detail page (EntityDetail paper) with breadcrumbs ([#13130](https://github.com/linode/manager/pull/13130)) +- Implement Empty Landing State for Network Load Balancers ([#13132](https://github.com/linode/manager/pull/13132)) +- CloudPulse-Metrics: Update `FilterConfig.ts` to handle integration of endpoints dashboard for object-storage service in metrics page([#13133](https://github.com/linode/manager/pull/13133)) +- Add feature flag support for PgBouncer in DBaaS ([#13134](https://github.com/linode/manager/pull/13134)) +- ACLP-Alerting: Update aclpAlerting flag to have beta marker control ([#13137](https://github.com/linode/manager/pull/13137)) +- Update Firewall Rules Edit & Add Drawer to Support Prefix List Selection ([#13138](https://github.com/linode/manager/pull/13138)) +- CloudPulse-Metrics: Add tooltip for clusters filter in lke and fix preferences bug for nodebalancers filter in firewall-nodebalancer dashboard ([#13141](https://github.com/linode/manager/pull/13141)) +- Add and Integrate Prefix List Details Drawer ([#13146](https://github.com/linode/manager/pull/13146)) +- Implement Nodes table in Network LoadBalancer Listener detail page ([#13147](https://github.com/linode/manager/pull/13147)) +- Update useIsFirewallRulesetsPrefixlistsEnabled() to factor in account capability ([#13156](https://github.com/linode/manager/pull/13156)) +- CloudPulse-Metrics: Update tooltip msg for `Clusters` filter in LKE service dashboard ([#13157](https://github.com/linode/manager/pull/13157)) +- Integrate Prefix List details drawer with Edit and Add Rule drawer ([#13158](https://github.com/linode/manager/pull/13158)) +- Add Beta/New feature Chip support for RuleSets and Prefix Lists ([#13164](https://github.com/linode/manager/pull/13164)) +- UX/UI enhancements for RuleSets and Prefix Lists ([#13165](https://github.com/linode/manager/pull/13165)) +- Ensure a firewall can only reference a RuleSet once ([#13169](https://github.com/linode/manager/pull/13169)) +- Handle special PLs in PrefixList drawer ([#13172](https://github.com/linode/manager/pull/13172)) + ## [2025-11-18] - v1.155.0 diff --git a/packages/manager/cypress/component/features/firewalls/firewall-rule-table.spec.tsx b/packages/manager/cypress/component/features/firewalls/firewall-rule-table.spec.tsx index ebbd8f752ee..537f6fccf89 100644 --- a/packages/manager/cypress/component/features/firewalls/firewall-rule-table.spec.tsx +++ b/packages/manager/cypress/component/features/firewalls/firewall-rule-table.spec.tsx @@ -122,13 +122,13 @@ const verifyFirewallWithRules = ({ .within(() => { if (isSmallViewport) { // Column 'Protocol' is not visible for smaller screens. - cy.findByText(rule.protocol).should('not.exist'); + cy.findByText(rule.protocol!).should('not.exist'); } else { - cy.findByText(rule.protocol).should('be.visible'); + cy.findByText(rule.protocol!).should('be.visible'); } cy.findByText(rule.ports!).should('be.visible'); - cy.findByText(getRuleActionLabel(rule.action)).should('be.visible'); + cy.findByText(getRuleActionLabel(rule.action!)).should('be.visible'); }); }); }; 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 7c1a0643c1f..adb9afa816c 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 @@ -29,7 +29,8 @@ import { import { alertStatuses, DELETE_ALERT_SUCCESS_MESSAGE, - UPDATE_ALERT_SUCCESS_MESSAGE, + DISABLE_ALERT_SUCCESS_MESSAGE, + ENABLE_ALERT_SUCCESS_MESSAGE, } from 'src/features/CloudPulse/Alerts/constants'; import { formatDate } from 'src/utilities/formatDate'; @@ -426,7 +427,10 @@ describe('Integration Tests for CloudPulse Alerts Listing Page', () => { alertName, alias, confirmationText: `Are you sure you want to ${action.toLowerCase()} this alert definition?`, - successMessage: UPDATE_ALERT_SUCCESS_MESSAGE, + successMessage: + action === 'Disable' + ? DISABLE_ALERT_SUCCESS_MESSAGE + : ENABLE_ALERT_SUCCESS_MESSAGE, }); }); }); diff --git a/packages/manager/cypress/e2e/core/cloudpulse/dbaas-widgets-verification.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/dbaas-widgets-verification.spec.ts index c4968097b77..4f3d43a6bb1 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/dbaas-widgets-verification.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/dbaas-widgets-verification.spec.ts @@ -104,7 +104,7 @@ const dashboard = dashboardFactory.build({ widgets: metrics.map(({ name, title, unit, yLabel }) => widgetFactory.build({ entity_ids: [String(id)], - filters: [...dimensions], + filters: [], label: title, metric: name, unit, @@ -367,7 +367,7 @@ describe('Integration Tests for DBaaS Dashboard ', () => { (filter: DimensionFilter) => filter.dimension_label === 'node_type' ); - expect(nodeTypeFilter).to.have.length(2); + expect(nodeTypeFilter).to.have.length(1); expect(nodeTypeFilter[0].operator).to.equal('eq'); expect(nodeTypeFilter[0].value).to.equal('secondary'); }); @@ -462,7 +462,7 @@ describe('Integration Tests for DBaaS Dashboard ', () => { const nodeTypeFilter = filters.filter( (filter: DimensionFilter) => filter.dimension_label === 'node_type' ); - expect(nodeTypeFilter).to.have.length(2); + expect(nodeTypeFilter).to.have.length(1); expect(nodeTypeFilter[0].operator).to.equal('eq'); expect(nodeTypeFilter[0].value).to.equal('secondary'); @@ -537,7 +537,7 @@ describe('Integration Tests for DBaaS Dashboard ', () => { const nodeTypeFilter = filters.filter( (filter: DimensionFilter) => filter.dimension_label === 'node_type' ); - expect(nodeTypeFilter).to.have.length(2); + expect(nodeTypeFilter).to.have.length(1); expect(nodeTypeFilter[0].operator).to.equal('eq'); expect(nodeTypeFilter[0].value).to.equal('secondary'); }); diff --git a/packages/manager/cypress/e2e/core/delivery/create-stream.spec.ts b/packages/manager/cypress/e2e/core/delivery/create-stream.spec.ts index 1ca9b4cb643..c5327ed7854 100644 --- a/packages/manager/cypress/e2e/core/delivery/create-stream.spec.ts +++ b/packages/manager/cypress/e2e/core/delivery/create-stream.spec.ts @@ -166,7 +166,7 @@ describe('Create Stream', () => { }); }); - describe('given Kubernetes Audit Logs Stream Type', () => { + describe('given Kubernetes API Audit Logs Stream Type', () => { it('selects clusters and creates new stream', () => { // Mock API responses mockGetDestinations([mockDestination]); diff --git a/packages/manager/cypress/e2e/core/delivery/edit-stream.spec.ts b/packages/manager/cypress/e2e/core/delivery/edit-stream.spec.ts index 9006b1935be..ee47317290e 100644 --- a/packages/manager/cypress/e2e/core/delivery/edit-stream.spec.ts +++ b/packages/manager/cypress/e2e/core/delivery/edit-stream.spec.ts @@ -143,7 +143,7 @@ describe('Edit Stream', () => { }); }); - describe('given Kubernetes Audit Logs Stream Type', () => { + describe('given Kubernetes API Audit Logs Stream Type', () => { it('edits stream label and clusters and saves', () => { // Mock API responses mockGetDestinations([mockDestination]); @@ -192,7 +192,7 @@ describe('Edit Stream', () => { cy.findByLabelText('Stream Type') .should('be.visible') .should('be.disabled') - .should('have.attr', 'value', 'Kubernetes Audit Logs'); + .should('have.attr', 'value', 'Kubernetes API Audit Logs'); // Clusters table should be visible cy.findByText('Clusters').should('be.visible'); diff --git a/packages/manager/cypress/e2e/core/firewalls/update-firewall.spec.ts b/packages/manager/cypress/e2e/core/firewalls/update-firewall.spec.ts index 60999c917d6..0869a42dddb 100644 --- a/packages/manager/cypress/e2e/core/firewalls/update-firewall.spec.ts +++ b/packages/manager/cypress/e2e/core/firewalls/update-firewall.spec.ts @@ -227,9 +227,9 @@ describe('update firewall', () => { .should('be.visible') .closest('tr') .within(() => { - cy.findByText(inboundRule.protocol).should('be.visible'); + cy.findByText(inboundRule.protocol!).should('be.visible'); cy.findByText(inboundRule.ports!).should('be.visible'); - cy.findByText(getRuleActionLabel(inboundRule.action)).should( + cy.findByText(getRuleActionLabel(inboundRule.action!)).should( 'be.visible' ); }); @@ -242,9 +242,9 @@ describe('update firewall', () => { .should('be.visible') .closest('tr') .within(() => { - cy.findByText(outboundRule.protocol).should('be.visible'); + cy.findByText(outboundRule.protocol!).should('be.visible'); cy.findByText(outboundRule.ports!).should('be.visible'); - cy.findByText(getRuleActionLabel(outboundRule.action)).should( + cy.findByText(getRuleActionLabel(outboundRule.action!)).should( 'be.visible' ); }); diff --git a/packages/manager/cypress/e2e/core/kubernetes/lke-enterprise-create.spec.ts b/packages/manager/cypress/e2e/core/kubernetes/lke-enterprise-create.spec.ts index 32d93f7b6e7..b1a5509a488 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/lke-enterprise-create.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-enterprise-create.spec.ts @@ -289,21 +289,19 @@ describe('LKE Cluster Creation with LKE-E', () => { */ it('surfaces field-level errors on VPC fields', () => { // Intercept the create cluster request and force an error response - cy.intercept('POST', '/v4beta/lke/clusters', { - statusCode: 400, - body: { - errors: [ - { - reason: 'There is an error configuring this VPC.', - field: 'vpc_id', - }, - { - reason: 'There is an error configuring this subnet.', - field: 'subnet_id', - }, - ], - }, - }).as('createClusterError'); + mockCreateClusterError( + [ + { + reason: 'There is an error configuring this VPC.', + field: 'vpc_id', + }, + { + reason: 'There is an error configuring this subnet.', + field: 'subnet_id', + }, + ], + 400 + ).as('createClusterError'); cy.findByLabelText('Cluster Label').type(clusterLabel); cy.findByText('LKE Enterprise').click(); 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 6fbb52b8e7d..43dce38bf2c 100644 --- a/packages/manager/cypress/e2e/core/linodes/clone-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/clone-linode.spec.ts @@ -70,6 +70,7 @@ describe('clone linode', () => { beforeEach(() => { mockAppendFeatureFlags({ linodeInterfaces: { enabled: false }, + generationalPlansv2: { enabled: false, allowedPlans: [] }, }); }); diff --git a/packages/manager/cypress/e2e/core/linodes/linode-storage.spec.ts b/packages/manager/cypress/e2e/core/linodes/linode-storage.spec.ts index 8814b12c700..50c52ea14bb 100644 --- a/packages/manager/cypress/e2e/core/linodes/linode-storage.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/linode-storage.spec.ts @@ -160,12 +160,10 @@ describe('linode storage tab', () => { }); /* - * - Confirms UI flow end-to-end when a user attempts to delete a Linode disk with encryption enabled. - * - Confirms that disk deletion fails and toast notification appears. + * - Confirms UI flow end-to-end when a user deletes a Linode disk with encryption enabled. + * - Confirms that disk deletion succeeds and toast notification appears. */ - // TODO: Disk cannot be deleted if disk_encryption is 'enabled' - // TODO: edit result of this test if/when behavior of backend is updated. uncertain what expected behavior is for this disk config - it('delete disk fails when Linode uses disk encryption', () => { + it('deletes a disk when Linode Disk Encryption is enabled', () => { const diskName = randomLabel(); cy.defer(() => createTestLinode({ @@ -195,19 +193,17 @@ describe('linode storage tab', () => { cy.wait('@deleteDisk').its('response.statusCode').should('eq', 200); cy.findByText('Deleting', { exact: false }).should('be.visible'); ui.button.findByTitle('Add a Disk').should('be.enabled'); - // ui.toast.assertMessage( - // `Disk ${diskName} on Linode ${linode.label} has been deleted.` - // ); + ui.toast.assertMessage( + `Disk ${diskName} on Linode ${linode.label} has been deleted.` + ); ui.toast .findByMessage( `Disk ${diskName} on Linode ${linode.label} has been deleted.` ) .should('not.exist'); - // cy.findByLabelText('List of Disks').within(() => { - // cy.contains(diskName).should('not.exist'); - // }); + cy.findByLabelText('List of Disks').within(() => { - cy.contains(diskName).should('be.visible'); + cy.contains(diskName).should('not.exist'); }); }); }); 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 8b47a8bb2ed..b6c680627cc 100644 --- a/packages/manager/cypress/e2e/core/linodes/plan-selection.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/plan-selection.spec.ts @@ -86,6 +86,14 @@ const mockGPUType = [ }), ]; +const mockPremiumType = [ + linodeTypeFactory.build({ + class: 'premium', + id: 'premium-8', + label: 'Premium 8GB', + }), +]; + const mockAcceleratedType = [ linodeTypeFactory.build({ class: 'accelerated', @@ -99,6 +107,7 @@ const mockLinodeTypes = [ ...mockHighMemoryLinodeTypes, ...mockSharedLinodeTypes, ...mockGPUType, + ...mockPremiumType, ...mockAcceleratedType, ]; @@ -225,7 +234,7 @@ describe('displays linode plans panel based on availability', () => { cy.get(notices.unavailable).should('be.visible'); cy.findByRole('table', { name: planSelectionTable }).within(() => { - cy.findAllByRole('row').should('have.length', 2); + cy.findAllByRole('row').should('have.length', 3); cy.get('[id="g7-premium-64"]').should('be.disabled'); cy.findAllByTestId('disabled-plan-tooltip').should('have.length', 0); }); @@ -355,7 +364,7 @@ describe('displays kubernetes plans panel based on availability', () => { cy.get(notices.unavailable).should('be.visible'); cy.findByRole('table', { name: planSelectionTable }).within(() => { - cy.findAllByRole('row').should('have.length', 2); + cy.findAllByRole('row').should('have.length', 3); cy.get('[data-qa-plan-row="Premium 512 GB"]').should( 'have.attr', 'disabled' diff --git a/packages/manager/cypress/e2e/core/linodes/vm-host-maintenance-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/vm-host-maintenance-linode.spec.ts index 47a0266d53f..6c6cab74d15 100644 --- a/packages/manager/cypress/e2e/core/linodes/vm-host-maintenance-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/vm-host-maintenance-linode.spec.ts @@ -4,6 +4,7 @@ import { mockGetNotifications } from 'support/intercepts/events'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { mockGetLinode, mockGetLinodes } from 'support/intercepts/linodes'; import { mockGetProfile } from 'support/intercepts/profile'; +import { ui } from 'support/ui'; import { randomLabel } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; @@ -34,6 +35,8 @@ const mockMaintenanceScheduled = accountMaintenanceFactory.build({ type: 'reboot', description: 'scheduled', maintenance_policy_set: 'linode/power_off_on', + reason: + "Your Linode's host has reached the end of its life cycle and will be retired.", status: 'scheduled', start_time: '2022-01-17T23:45:46.960', }); @@ -48,6 +51,7 @@ const mockMaintenanceEmergency = accountMaintenanceFactory.build({ type: 'cold_migration', description: 'emergency', maintenance_policy_set: 'linode/power_off_on', + reason: "We must upgrade the OS of your Linode's host.", status: 'scheduled', }); @@ -107,6 +111,38 @@ describe('Host & VM maintenance notification banner', () => { }); }); + it('maintenance notification banner does not display platform maintenance messages', function () { + const mockPlatformMaintenance = accountMaintenanceFactory.build({ + entity: { + id: mockLinodes[1].id, + label: mockLinodes[1].label, + type: 'linode', + url: `/v4/linode/instances/${mockLinodes[1].id}`, + }, + type: 'reboot', + description: 'emergency', + maintenance_policy_set: 'linode/power_off_on', + // 'critical security update' in reason prevents message from displaying + reason: "We must apply a critical security update to your Linode's host.", + status: 'scheduled', + }); + mockGetMaintenance([mockPlatformMaintenance], []).as('getMaintenances'); + cy.visitWithLogin('/linodes'); + cy.wait([ + '@getLinodes', + '@getFeatureFlags', + '@getNotifications', + '@getMaintenances', + ]); + cy.get('#main-content').within(() => { + ui.button + .findByTitle('Create Linode') + .should('be.visible') + .should('be.enabled'); + cy.get('[data-testid="maintenance-banner').should('not.exist'); + }); + }); + it('banner present on details page when linode has pending maintenance', function () { const mockLinode = mockLinodes[0]; mockGetLinode(mockLinode.id, mockLinode).as('getLinode'); diff --git a/packages/manager/cypress/e2e/core/nodebalancers/nodebalancer-create-validation.spec.ts b/packages/manager/cypress/e2e/core/nodebalancers/nodebalancer-create-validation.spec.ts index 458e437b44f..6a2f6d0b78e 100644 --- a/packages/manager/cypress/e2e/core/nodebalancers/nodebalancer-create-validation.spec.ts +++ b/packages/manager/cypress/e2e/core/nodebalancers/nodebalancer-create-validation.spec.ts @@ -1,3 +1,5 @@ +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; + describe('NodeBalancer create page validation', () => { /** * This test ensures that the user sees a uniqueness error when @@ -5,6 +7,9 @@ describe('NodeBalancer create page validation', () => { * - they configure many UDP configs to use the same port */ it('renders a port uniqueness errors when you try to create a nodebalancer with configs using the same port and protocol', () => { + mockAppendFeatureFlags({ + udp: true, + }); cy.visitWithLogin('/nodebalancers/create'); // Configure the first config to use TCP on port 8080 diff --git a/packages/manager/cypress/support/api/linodes.ts b/packages/manager/cypress/support/api/linodes.ts index 7d8a15e66e5..2e128b90ac0 100644 --- a/packages/manager/cypress/support/api/linodes.ts +++ b/packages/manager/cypress/support/api/linodes.ts @@ -30,7 +30,11 @@ export const deleteAllTestLinodes = async (): Promise => { ); const deletePromises = linodes - .filter((linode: Linode) => isTestLabel(linode.label)) + .filter( + (linode: Linode) => + isTestLabel(linode.label) && + ['offline', 'running'].includes(linode.status) + ) .map((linode: Linode) => deleteLinode(linode.id)); await Promise.all(deletePromises); diff --git a/packages/manager/cypress/support/constants/alert.ts b/packages/manager/cypress/support/constants/alert.ts index c8e0f7a2f0e..c7f4641bd6e 100644 --- a/packages/manager/cypress/support/constants/alert.ts +++ b/packages/manager/cypress/support/constants/alert.ts @@ -44,4 +44,7 @@ export const statusMap: Record = { 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 ad60d8d68fd..a45d0f343eb 100644 --- a/packages/manager/cypress/support/constants/delivery.ts +++ b/packages/manager/cypress/support/constants/delivery.ts @@ -15,7 +15,7 @@ export const mockDestinationPayload: CreateDestinationPayload = { type: destinationType.AkamaiObjectStorage, details: { host: randomString(), - bucket_name: randomString(), + bucket_name: randomLabel(), access_key_id: randomString(), access_key_secret: randomString(), path: '/', diff --git a/packages/manager/cypress/support/intercepts/lke.ts b/packages/manager/cypress/support/intercepts/lke.ts index 4c2b6935e9a..280a5a7946e 100644 --- a/packages/manager/cypress/support/intercepts/lke.ts +++ b/packages/manager/cypress/support/intercepts/lke.ts @@ -10,7 +10,7 @@ import { latestEnterpriseTierKubernetesVersion, latestStandardTierKubernetesVersion, } from 'support/constants/lke'; -import { makeErrorResponse } from 'support/util/errors'; +import { APIErrorContents, makeErrorResponse } from 'support/util/errors'; import { apiMatcher } from 'support/util/intercepts'; import { paginateResponse } from 'support/util/paginate'; import { randomDomainName } from 'support/util/random'; @@ -185,7 +185,9 @@ export const mockCreateCluster = ( * @returns Cypress chainable. */ export const mockCreateClusterError = ( - errorMessage: string = 'An unknown error occurred.', + errorMessage: + | APIErrorContents + | APIErrorContents[] = 'An unknown error occurred.', statusCode: number = 500 ): Cypress.Chainable => { return cy.intercept( diff --git a/packages/manager/cypress/support/setup/defer-command.ts b/packages/manager/cypress/support/setup/defer-command.ts index 6196b6e976f..bc1c38bf225 100644 --- a/packages/manager/cypress/support/setup/defer-command.ts +++ b/packages/manager/cypress/support/setup/defer-command.ts @@ -1,150 +1,7 @@ import { LINODE_CREATE_TIMEOUT } from 'support/constants/linodes'; +import { enhanceError, isAxiosError } from 'support/util/api'; import { timeout } from 'support/util/backoff'; -import type { APIError } from '@linode/api-v4'; -import type { AxiosError } from 'axios'; - -type LinodeApiV4Error = { - errors: APIError[]; -}; - -/** - * Returns `true` if the given error is a Linode API schema validation error. - * - * Type guards `e` as an array of `APIError` objects. - * - * @param e - Error. - * - * @returns `true` if `e` is a Linode API schema validation error. - */ -const isValidationError = (e: any): e is APIError[] => { - // When a Linode APIv4 schema validation error occurs, an array of `APIError` - // objects is thrown rather than a typical `Error` type. - return ( - Array.isArray(e) && - e.every((item: any) => { - return 'reason' in item; - }) - ); -}; - -/** - * Returns `true` if the given error is an Axios error. - * - * Type guards `e` as an `AxiosError` instance. - * - * @param e - Error. - * - * @returns `true` if `e` is an `AxiosError`. - */ -const isAxiosError = (e: any): e is AxiosError => { - return !!e.isAxiosError; -}; - -/** - * Returns `true` if the given error is a Linode API v4 request error. - * - * Type guards `e` as an `AxiosError` instance. - * - * @param e - Error. - * - * @returns `true` if `e` is a Linode API v4 request error. - */ -const isLinodeApiError = (e: any): e is AxiosError => { - if (isAxiosError(e)) { - const responseData = e.response?.data as any; - return ( - responseData.errors && - Array.isArray(responseData.errors) && - responseData.errors.every((item: any) => { - return 'reason' in item; - }) - ); - } - return false; -}; - -/** - * Detects known error types and returns a new Error with more detailed message. - * - * Unknown error types are returned without modification. - * - * @param e - Error. - * - * @returns A new error with added information in message, or `e`. - */ -const enhanceError = (e: Error) => { - // Check for most specific error types first. - if (isLinodeApiError(e)) { - // If `e` is a Linode APIv4 error response, show the status code, error messages, - // and request URL when applicable. - const summary = e.response?.status - ? `Linode APIv4 request failed with status code ${e.response.status}` - : `Linode APIv4 request failed`; - - const errorDetails = e.response!.data.errors.map((error: APIError) => { - return error.field - ? `- ${error.reason} (field '${error.field}')` - : `- ${error.reason}`; - }); - - const requestInfo = - !!e.request?.responseURL && !!e.config?.method - ? `\nRequest: ${e.config.method.toUpperCase()} ${e.request.responseURL}` - : ''; - - return new Error(`${summary}\n${errorDetails.join('\n')}${requestInfo}`); - } - - if (isAxiosError(e)) { - // If `e` is an Axios error (but not a Linode API error specifically), show the - // status code, error messages, and request URL when applicable. - const summary = e.response?.status - ? `Request failed with status code ${e.response.status}` - : `Request failed`; - - const requestInfo = - !!e.request?.responseURL && !!e.config?.method - ? `\nRequest: ${e.config.method.toUpperCase()} ${e.request.responseURL}` - : ''; - - return new Error(`${summary}${requestInfo}`); - } - - // Handle cases where a validation error is thrown. - // These are arrays containing `APIError` objects; no additional request context - // is included so we only have the validation error messages themselves to work with. - if (isValidationError(e)) { - // Validation errors do not contain any additional context (request URL, payload, etc.). - // Show the validation error messages instead. - const multipleErrors = e.length > 1; - const summary = multipleErrors - ? 'Request failed with Linode schema validation errors' - : 'Request failed with Linode schema validation error'; - - // Format, accounting for 0, 1, or more errors. - const validationErrorMessage = multipleErrors - ? e - .map((error) => - error.field - ? `- ${error.reason} (field '${error.field}')` - : `- ${error.reason}` - ) - .join('\n') - : e - .map((error) => - error.field - ? `${error.reason} (field '${error.field}')` - : `${error.reason}` - ) - .join('\n'); - - return new Error(`${summary}\n${validationErrorMessage}`); - } - // Return `e` unmodified if it's not handled by any of the above cases. - return e; -}; - /** * Describes an object which can contain a label. */ diff --git a/packages/manager/cypress/support/util/api.ts b/packages/manager/cypress/support/util/api.ts index fd55777be19..b90bbaab130 100644 --- a/packages/manager/cypress/support/util/api.ts +++ b/packages/manager/cypress/support/util/api.ts @@ -2,14 +2,155 @@ * @file Utilities to help configure @linode/api-v4 package. */ -import { baseRequest } from '@linode/api-v4'; -import { AxiosHeaders } from 'axios'; +import { APIError, baseRequest } from '@linode/api-v4'; +import { AxiosError, AxiosHeaders } from 'axios'; // Note: This file is imported by Cypress plugins, and indirectly by Cypress // tests. Because Cypress has not been initiated when plugins are executed, we // cannot use any Cypress functionality in this module without causing a crash // at startup. +type LinodeApiV4Error = { + errors: APIError[]; +}; + +/** + * Returns `true` if the given error is a Linode API schema validation error. + * + * Type guards `e` as an array of `APIError` objects. + * + * @param e - Error. + * + * @returns `true` if `e` is a Linode API schema validation error. + */ +export const isValidationError = (e: any): e is APIError[] => { + // When a Linode APIv4 schema validation error occurs, an array of `APIError` + // objects is thrown rather than a typical `Error` type. + return ( + Array.isArray(e) && + e.every((item: any) => { + return 'reason' in item; + }) + ); +}; + +/** + * Returns `true` if the given error is an Axios error. + * + * Type guards `e` as an `AxiosError` instance. + * + * @param e - Error. + * + * @returns `true` if `e` is an `AxiosError`. + */ +export const isAxiosError = (e: any): e is AxiosError => { + return !!e.isAxiosError; +}; + +/** + * Returns `true` if the given error is a Linode API v4 request error. + * + * Type guards `e` as an `AxiosError` instance. + * + * @param e - Error. + * + * @returns `true` if `e` is a Linode API v4 request error. + */ +export const isLinodeApiError = (e: any): e is AxiosError => { + if (isAxiosError(e)) { + const responseData = e.response?.data as any; + return ( + responseData.errors && + Array.isArray(responseData.errors) && + responseData.errors.every((item: any) => { + return 'reason' in item; + }) + ); + } + return false; +}; + +/** + * Detects known error types and returns a new Error with more detailed message. + * + * Unknown error types are returned without modification. + * + * @param e - Error. + * + * @returns A new error with added information in message, or `e`. + */ +export const enhanceError = (e: Error) => { + // Check for most specific error types first. + if (isLinodeApiError(e)) { + // If `e` is a Linode APIv4 error response, show the status code, error messages, + // and request URL when applicable. + const summary = e.response?.status + ? `Linode APIv4 request failed with status code ${e.response.status}` + : `Linode APIv4 request failed`; + + const errorDetails = e.response!.data.errors.map((error: APIError) => { + return error.field + ? `- ${error.reason} (field '${error.field}')` + : `- ${error.reason}`; + }); + + const requestInfo = + !!e.request?.responseURL && !!e.config?.method + ? `\nRequest: ${e.config.method.toUpperCase()} ${e.request.responseURL}` + : ''; + + return new Error(`${summary}\n${errorDetails.join('\n')}${requestInfo}`); + } + + if (isAxiosError(e)) { + // If `e` is an Axios error (but not a Linode API error specifically), show the + // status code, error messages, and request URL when applicable. + const summary = e.response?.status + ? `Request failed with status code ${e.response.status}` + : `Request failed`; + + const requestInfo = + !!e.request?.responseURL && !!e.config?.method + ? `\nRequest: ${e.config.method.toUpperCase()} ${e.request.responseURL}` + : ''; + + return new Error(`${summary}${requestInfo}`); + } + + // Handle cases where a validation error is thrown. + // These are arrays containing `APIError` objects; no additional request context + // is included so we only have the validation error messages themselves to work with. + if (isValidationError(e)) { + // Validation errors do not contain any additional context (request URL, payload, etc.). + // Show the validation error messages instead. + const multipleErrors = e.length > 1; + const summary = multipleErrors + ? 'Request failed with Linode schema validation errors' + : 'Request failed with Linode schema validation error'; + + // Format, accounting for 0, 1, or more errors. + const validationErrorMessage = multipleErrors + ? e + .map((error) => + error.field + ? `- ${error.reason} (field '${error.field}')` + : `- ${error.reason}` + ) + .join('\n') + : e + .map((error) => + error.field + ? `${error.reason} (field '${error.field}')` + : `${error.reason}` + ) + .join('\n'); + + return new Error(`${summary}\n${validationErrorMessage}`); + } + // Return `e` unmodified if it's not handled by any of the above cases. + return e; +}; + /** * Default API root URL to use for replacement logic when using a URL override. * diff --git a/packages/manager/cypress/support/util/cleanup.ts b/packages/manager/cypress/support/util/cleanup.ts index 11432ce0ff7..847e1f79408 100644 --- a/packages/manager/cypress/support/util/cleanup.ts +++ b/packages/manager/cypress/support/util/cleanup.ts @@ -15,6 +15,7 @@ import { deleteAllTestSSHKeys } from 'support/api/profile'; import { deleteAllTestStackScripts } from 'support/api/stackscripts'; import { deleteAllTestTags } from 'support/api/tags'; import { deleteAllTestVolumes } from 'support/api/volumes'; +import { enhanceError } from 'support/util/api'; /** Types of resources that can be cleaned up. */ export type CleanUpResource = @@ -75,7 +76,20 @@ export const cleanUp = (resources: CleanUpResource | CleanUpResource[]) => { for (const resource of resourcesArray) { const cleanFunction = cleanUpMap[resource]; // Perform clean-up sequentially to avoid API rate limiting. - await cleanFunction(); + try { + await cleanFunction(); + } catch (e: any) { + // Log a warning but otherwise swallow errors if any resources fail to + // be cleaned up. There are a few cases where this is especially helpful: + // + // - Unplanned API issues or outages resulting in 5xx errors + // - 400 errors when inadevertently attempting to delete resources that are still busy (e.g. cleaning up a Linode that is the target of a clone operation) + const enhancedError = enhanceError(e); + console.warn( + 'An API error occurred while attempting to clean up one or more resources:' + ); + console.warn(enhancedError.message); + } } }; return cy.defer( diff --git a/packages/manager/package.json b/packages/manager/package.json index d3b16ad2d23..90cce025425 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.155.0", + "version": "1.156.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.15", + "akamai-cds-react-components": "0.0.1-alpha.18", "algoliasearch": "^4.14.3", "axios": "~1.12.0", "braintree-web": "^3.92.2", @@ -171,7 +171,7 @@ "cypress-vite": "^1.8.0", "dotenv": "^16.0.3", "factory.ts": "^0.5.1", - "glob": "^10.3.1", + "glob": "^10.5.0", "globals": "^16.0.0", "history": "4", "jsdom": "^24.1.1", @@ -180,7 +180,7 @@ "pdfreader": "^3.0.7", "redux-mock-store": "^1.5.3", "storybook": "^9.0.12", - "vite": "^7.1.11", + "vite": "^7.2.2", "vite-plugin-svgr": "^4.5.0" }, "browserslist": [ diff --git a/packages/manager/src/OAuth/OAuthCallback.tsx b/packages/manager/src/OAuth/OAuthCallback.tsx index 454bea3f446..28a3ff2ea49 100644 --- a/packages/manager/src/OAuth/OAuthCallback.tsx +++ b/packages/manager/src/OAuth/OAuthCallback.tsx @@ -1,11 +1,14 @@ import * as Sentry from '@sentry/react'; import { useNavigate } from '@tanstack/react-router'; +import { useSearch } from '@tanstack/react-router'; import React from 'react'; import { SplashScreen } from 'src/components/SplashScreen'; import { clearStorageAndRedirectToLogout, handleOAuthCallback } from './oauth'; +import type { LinkProps } from '@tanstack/react-router'; + /** * Login will redirect back to Cloud Manager with a URL like: * https://cloud.linode.com/oauth/callback?returnTo=%2F&state=066a6ad9-b19a-43bb-b99a-ef0b5d4fc58d&code=42ddf75dfa2cacbad897 @@ -14,24 +17,57 @@ import { clearStorageAndRedirectToLogout, handleOAuthCallback } from './oauth'; */ export const OAuthCallback = () => { const navigate = useNavigate(); - const authenticate = async () => { - try { - const { returnTo } = await handleOAuthCallback({ - params: location.search, - }); - - navigate({ to: returnTo }); - } catch (error) { - // eslint-disable-next-line no-console - console.error(error); - Sentry.captureException(error); - clearStorageAndRedirectToLogout(); - } - }; + const search = useSearch({ + from: '/oauth/callback', + }); + + const hasStartedAuth = React.useRef(false); + const isAuthenticating = React.useRef(false); React.useEffect(() => { + // Prevent running if already started or currently running + if (hasStartedAuth.current || isAuthenticating.current) { + return; + } + + hasStartedAuth.current = true; + isAuthenticating.current = true; + + const authenticate = async () => { + try { + const { returnTo } = await handleOAuthCallback({ + params: search, + }); + + // None of these paths are valid return destinations + const invalidReturnToPaths: LinkProps['to'][] = [ + '/logout', + '/admin/callback', + '/oauth/callback', + '/cancel', + ]; + + const isInvalidReturnTo = + !returnTo || invalidReturnToPaths.some((path) => returnTo === path); + + if (isInvalidReturnTo) { + navigate({ to: '/' }); + return; + } + + navigate({ to: returnTo }); + } catch (error) { + // eslint-disable-next-line no-console + console.error(error); + Sentry.captureException(error); + clearStorageAndRedirectToLogout(); + } finally { + isAuthenticating.current = false; + } + }; + authenticate(); - }, []); + }, [navigate, search]); return ; }; diff --git a/packages/manager/src/OAuth/oauth.ts b/packages/manager/src/OAuth/oauth.ts index 85b08c4618b..3d111dbecbe 100644 --- a/packages/manager/src/OAuth/oauth.ts +++ b/packages/manager/src/OAuth/oauth.ts @@ -187,10 +187,13 @@ export async function redirectToLogin() { * @returns Some information about the new session because authentication was successfull */ export async function handleOAuthCallback(options: AuthCallbackOptions) { + const paramsObject = + typeof options.params === 'string' + ? getQueryParamsFromQueryString(options.params) + : options.params; + const { data: params, error: parseParamsError } = await tryCatch( - OAuthCallbackParamsSchema.validate( - getQueryParamsFromQueryString(options.params) - ) + OAuthCallbackParamsSchema.validate(paramsObject) ); if (parseParamsError) { @@ -296,7 +299,9 @@ export async function handleLoginAsCustomerCallback( ) { const { data: params, error } = await tryCatch( LoginAsCustomerCallbackParamsSchema.validate( - getQueryParamsFromQueryString(options.params) + typeof options.params === 'string' + ? getQueryParamsFromQueryString(options.params) + : options.params ) ); diff --git a/packages/manager/src/OAuth/types.ts b/packages/manager/src/OAuth/types.ts index 6dd3e5d14a4..e0b5b0e3fe1 100644 --- a/packages/manager/src/OAuth/types.ts +++ b/packages/manager/src/OAuth/types.ts @@ -61,7 +61,7 @@ export interface AuthCallbackOptions { /** * The raw search or has params sent by the login server */ - params: string; + params: Record | string; } /** diff --git a/packages/manager/src/assets/icons/LKEminusSign.svg b/packages/manager/src/assets/icons/LKEminusSign.svg index 9c83ce56708..65bafe8f6c3 100644 --- a/packages/manager/src/assets/icons/LKEminusSign.svg +++ b/packages/manager/src/assets/icons/LKEminusSign.svg @@ -1,4 +1,3 @@ - - - + + \ No newline at end of file diff --git a/packages/manager/src/assets/icons/ResizeWindow.svg b/packages/manager/src/assets/icons/ResizeWindow.svg index c9ea7c2201a..17d9056b31e 100644 --- a/packages/manager/src/assets/icons/ResizeWindow.svg +++ b/packages/manager/src/assets/icons/ResizeWindow.svg @@ -1,4 +1,4 @@ - - - + + + \ No newline at end of file diff --git a/packages/manager/src/assets/icons/arrow-down.svg b/packages/manager/src/assets/icons/arrow-down.svg index 31850bef730..faad9650a57 100644 --- a/packages/manager/src/assets/icons/arrow-down.svg +++ b/packages/manager/src/assets/icons/arrow-down.svg @@ -1,3 +1,3 @@ - + diff --git a/packages/manager/src/assets/icons/arrow-left.svg b/packages/manager/src/assets/icons/arrow-left.svg new file mode 100644 index 00000000000..12643a41986 --- /dev/null +++ b/packages/manager/src/assets/icons/arrow-left.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/manager/src/assets/icons/arrow-up.svg b/packages/manager/src/assets/icons/arrow-up.svg index c08fd2dee22..5a84aed6d22 100644 --- a/packages/manager/src/assets/icons/arrow-up.svg +++ b/packages/manager/src/assets/icons/arrow-up.svg @@ -1,3 +1,3 @@ - + diff --git a/packages/manager/src/assets/icons/caution.svg b/packages/manager/src/assets/icons/caution.svg index 9d5f060687b..6073595fbd3 100644 --- a/packages/manager/src/assets/icons/caution.svg +++ b/packages/manager/src/assets/icons/caution.svg @@ -1,6 +1,5 @@ - - - - - + + + + diff --git a/packages/manager/src/assets/icons/chat.svg b/packages/manager/src/assets/icons/chat.svg index 3ed66931c0f..42e1da7e6bb 100644 --- a/packages/manager/src/assets/icons/chat.svg +++ b/packages/manager/src/assets/icons/chat.svg @@ -1,9 +1,3 @@ - - Linode Support Bot - - - - - - + + + \ No newline at end of file diff --git a/packages/manager/src/assets/icons/checkmark-enabled.svg b/packages/manager/src/assets/icons/checkmark-enabled.svg index 3fd216c9113..a7894d0300f 100644 --- a/packages/manager/src/assets/icons/checkmark-enabled.svg +++ b/packages/manager/src/assets/icons/checkmark-enabled.svg @@ -1,11 +1,3 @@ - - - - - - - - - - - + + + \ No newline at end of file diff --git a/packages/manager/src/assets/icons/code-file.svg b/packages/manager/src/assets/icons/code-file.svg index 9c6bcd734a0..4786f37bf26 100755 --- a/packages/manager/src/assets/icons/code-file.svg +++ b/packages/manager/src/assets/icons/code-file.svg @@ -1,6 +1,6 @@ - - - - - - + + + + + + \ No newline at end of file diff --git a/packages/manager/src/assets/icons/community.svg b/packages/manager/src/assets/icons/community.svg index c999ea0ee3a..47de15bf21f 100644 --- a/packages/manager/src/assets/icons/community.svg +++ b/packages/manager/src/assets/icons/community.svg @@ -1,13 +1,8 @@ - - - - - - - - - - - - + + + + + + + \ No newline at end of file diff --git a/packages/manager/src/assets/icons/copy.svg b/packages/manager/src/assets/icons/copy.svg index 36ad859323b..f5503f2d7a2 100644 --- a/packages/manager/src/assets/icons/copy.svg +++ b/packages/manager/src/assets/icons/copy.svg @@ -1 +1,3 @@ - \ No newline at end of file + + + diff --git a/packages/manager/src/assets/icons/credit-card.svg b/packages/manager/src/assets/icons/credit-card.svg index 3a26b0b5724..5af38da3f5f 100755 --- a/packages/manager/src/assets/icons/credit-card.svg +++ b/packages/manager/src/assets/icons/credit-card.svg @@ -1,5 +1,3 @@ - - - - - + + + \ No newline at end of file diff --git a/packages/manager/src/assets/icons/db-logo.svg b/packages/manager/src/assets/icons/db-logo.svg index 5776006d284..2fe5c3a22b9 100644 --- a/packages/manager/src/assets/icons/db-logo.svg +++ b/packages/manager/src/assets/icons/db-logo.svg @@ -1,23 +1,12 @@ - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/manager/src/assets/icons/divider-vertical.svg b/packages/manager/src/assets/icons/divider-vertical.svg index 79add159022..8bb38120b04 100644 --- a/packages/manager/src/assets/icons/divider-vertical.svg +++ b/packages/manager/src/assets/icons/divider-vertical.svg @@ -1,3 +1,3 @@ - + diff --git a/packages/manager/src/assets/icons/docs.svg b/packages/manager/src/assets/icons/docs.svg index 142cff7eaf0..85969006671 100644 --- a/packages/manager/src/assets/icons/docs.svg +++ b/packages/manager/src/assets/icons/docs.svg @@ -1,3 +1,3 @@ - - + + \ No newline at end of file diff --git a/packages/manager/src/assets/icons/document.svg b/packages/manager/src/assets/icons/document.svg index 045accc3d63..85969006671 100644 --- a/packages/manager/src/assets/icons/document.svg +++ b/packages/manager/src/assets/icons/document.svg @@ -1,11 +1,3 @@ - - Guides and Tutorials - - - - - - - - + + + \ No newline at end of file diff --git a/packages/manager/src/assets/icons/download.svg b/packages/manager/src/assets/icons/download.svg index 544a5e4dd57..75456d089ae 100644 --- a/packages/manager/src/assets/icons/download.svg +++ b/packages/manager/src/assets/icons/download.svg @@ -1,10 +1,3 @@ - - - - - - - - - - + + + \ No newline at end of file diff --git a/packages/manager/src/assets/icons/drag-indicator.svg b/packages/manager/src/assets/icons/drag-indicator.svg index edf19fc2e9e..08e721e58ac 100644 --- a/packages/manager/src/assets/icons/drag-indicator.svg +++ b/packages/manager/src/assets/icons/drag-indicator.svg @@ -1,10 +1,10 @@ - - - - - - - - - - + + + + + + + + + + \ No newline at end of file diff --git a/packages/manager/src/assets/icons/error.svg b/packages/manager/src/assets/icons/error.svg index bf6b870a888..35fa89b8079 100644 --- a/packages/manager/src/assets/icons/error.svg +++ b/packages/manager/src/assets/icons/error.svg @@ -1 +1,3 @@ - \ No newline at end of file + + + \ No newline at end of file diff --git a/packages/manager/src/assets/icons/fileUploadComplete.svg b/packages/manager/src/assets/icons/fileUploadComplete.svg index 39c8d4a518e..7b8a33409c1 100644 --- a/packages/manager/src/assets/icons/fileUploadComplete.svg +++ b/packages/manager/src/assets/icons/fileUploadComplete.svg @@ -1,3 +1,3 @@ - - - + + + \ No newline at end of file diff --git a/packages/manager/src/assets/icons/flag.svg b/packages/manager/src/assets/icons/flag.svg index 72902081752..d2e002fce75 100644 --- a/packages/manager/src/assets/icons/flag.svg +++ b/packages/manager/src/assets/icons/flag.svg @@ -1,17 +1,3 @@ - - - - - - - + + + \ No newline at end of file diff --git a/packages/manager/src/assets/icons/grid-view.svg b/packages/manager/src/assets/icons/grid-view.svg index f3d7bdaea11..30f513954bd 100644 --- a/packages/manager/src/assets/icons/grid-view.svg +++ b/packages/manager/src/assets/icons/grid-view.svg @@ -1 +1,3 @@ - + + + \ No newline at end of file diff --git a/packages/manager/src/assets/icons/group-by-tag.svg b/packages/manager/src/assets/icons/group-by-tag.svg index 8c81e0f20b6..d47ec90845d 100644 --- a/packages/manager/src/assets/icons/group-by-tag.svg +++ b/packages/manager/src/assets/icons/group-by-tag.svg @@ -1 +1,3 @@ - + + + \ No newline at end of file diff --git a/packages/manager/src/assets/icons/icon-feedback.svg b/packages/manager/src/assets/icons/icon-feedback.svg index aa987d40471..0704d80a68e 100644 --- a/packages/manager/src/assets/icons/icon-feedback.svg +++ b/packages/manager/src/assets/icons/icon-feedback.svg @@ -1,5 +1,3 @@ - - - - - + + + \ No newline at end of file diff --git a/packages/manager/src/assets/icons/kebab.svg b/packages/manager/src/assets/icons/kebab.svg index 7a41c5e6c9a..aca1bd7b88b 100644 --- a/packages/manager/src/assets/icons/kebab.svg +++ b/packages/manager/src/assets/icons/kebab.svg @@ -1,5 +1,3 @@ - - - - - + + + \ No newline at end of file diff --git a/packages/manager/src/assets/icons/lke-download.svg b/packages/manager/src/assets/icons/lke-download.svg index 3422a506036..89fa98bc4f9 100755 --- a/packages/manager/src/assets/icons/lke-download.svg +++ b/packages/manager/src/assets/icons/lke-download.svg @@ -1,5 +1,3 @@ - - - - - + + + \ No newline at end of file diff --git a/packages/manager/src/assets/icons/lock.svg b/packages/manager/src/assets/icons/lock.svg index 62caf228e55..ac2ba9d0a35 100644 --- a/packages/manager/src/assets/icons/lock.svg +++ b/packages/manager/src/assets/icons/lock.svg @@ -1,4 +1,4 @@ Encrypted - + diff --git a/packages/manager/src/assets/icons/marketplace.svg b/packages/manager/src/assets/icons/marketplace.svg index 60ba66381ad..3bbff41afbf 100644 --- a/packages/manager/src/assets/icons/marketplace.svg +++ b/packages/manager/src/assets/icons/marketplace.svg @@ -1,11 +1,6 @@ - - - Fill 1 - - - - - - - - + + + + + + \ No newline at end of file diff --git a/packages/manager/src/assets/icons/mongodb.svg b/packages/manager/src/assets/icons/mongodb.svg index c3b4e2d16d0..df7f08f4bad 100644 --- a/packages/manager/src/assets/icons/mongodb.svg +++ b/packages/manager/src/assets/icons/mongodb.svg @@ -1,9 +1,5 @@ - - - Path - - - - - - \ No newline at end of file + + + + + diff --git a/packages/manager/src/assets/icons/monitor-disabled.svg b/packages/manager/src/assets/icons/monitor-disabled.svg index 7be1239b37c..a4a9370738e 100644 --- a/packages/manager/src/assets/icons/monitor-disabled.svg +++ b/packages/manager/src/assets/icons/monitor-disabled.svg @@ -1,6 +1,3 @@ - - - - - - + + + \ No newline at end of file diff --git a/packages/manager/src/assets/icons/monitor-failed.svg b/packages/manager/src/assets/icons/monitor-failed.svg index 9916b7e2e53..e10a915ca57 100644 --- a/packages/manager/src/assets/icons/monitor-failed.svg +++ b/packages/manager/src/assets/icons/monitor-failed.svg @@ -1,9 +1,3 @@ - - - - - - - - - + + + \ No newline at end of file diff --git a/packages/manager/src/assets/icons/monitor-ok.svg b/packages/manager/src/assets/icons/monitor-ok.svg index 99cf24a29ef..a7894d0300f 100644 --- a/packages/manager/src/assets/icons/monitor-ok.svg +++ b/packages/manager/src/assets/icons/monitor-ok.svg @@ -1,9 +1,3 @@ - - - - - - - - - + + + \ No newline at end of file diff --git a/packages/manager/src/assets/icons/mysql.svg b/packages/manager/src/assets/icons/mysql.svg index 8144e0221a9..bc93f55a45a 100644 --- a/packages/manager/src/assets/icons/mysql.svg +++ b/packages/manager/src/assets/icons/mysql.svg @@ -1,12 +1,3 @@ - - - mysql-6 - - - - - - - - + + \ No newline at end of file diff --git a/packages/manager/src/assets/icons/objectStorage/folder.svg b/packages/manager/src/assets/icons/objectStorage/folder.svg index a406989c05d..41698a831ee 100644 --- a/packages/manager/src/assets/icons/objectStorage/folder.svg +++ b/packages/manager/src/assets/icons/objectStorage/folder.svg @@ -1,7 +1,3 @@ - - - - - - + + \ No newline at end of file diff --git a/packages/manager/src/assets/icons/objectStorage/object.svg b/packages/manager/src/assets/icons/objectStorage/object.svg index 65980b18464..1fed71e26a6 100644 --- a/packages/manager/src/assets/icons/objectStorage/object.svg +++ b/packages/manager/src/assets/icons/objectStorage/object.svg @@ -1,6 +1,3 @@ - - - - - - + + + \ No newline at end of file diff --git a/packages/manager/src/assets/icons/parent-child.svg b/packages/manager/src/assets/icons/parent-child.svg index 4edbab05ed4..14d2b660452 100644 --- a/packages/manager/src/assets/icons/parent-child.svg +++ b/packages/manager/src/assets/icons/parent-child.svg @@ -1 +1,6 @@ - \ No newline at end of file + + + + + + \ No newline at end of file diff --git a/packages/manager/src/assets/icons/payment/gPayButton.svg b/packages/manager/src/assets/icons/payment/gPayButton.svg index aa74f3f0d0e..b38b70cde4d 100644 --- a/packages/manager/src/assets/icons/payment/gPayButton.svg +++ b/packages/manager/src/assets/icons/payment/gPayButton.svg @@ -1,2 +1,2 @@ -image/svg+xml +image/svg+xml \ No newline at end of file diff --git a/packages/manager/src/assets/icons/payment/googlePay.svg b/packages/manager/src/assets/icons/payment/googlePay.svg index 8910b402132..ad4865b5102 100644 --- a/packages/manager/src/assets/icons/payment/googlePay.svg +++ b/packages/manager/src/assets/icons/payment/googlePay.svg @@ -14,4 +14,4 @@ - + \ No newline at end of file diff --git a/packages/manager/src/assets/icons/payment/jcb.svg b/packages/manager/src/assets/icons/payment/jcb.svg index e6d96ae3178..0c455365861 100644 --- a/packages/manager/src/assets/icons/payment/jcb.svg +++ b/packages/manager/src/assets/icons/payment/jcb.svg @@ -8,7 +8,7 @@ - + @@ -17,7 +17,7 @@ - + @@ -28,7 +28,7 @@ c0.56,0,1.213,0,1.68,0.093c5.411,0.28,9.422,3.079,9.422,7.93c0,3.825-2.705,7.09-7.743,7.743v0.187 c5.504,0.373,9.702,3.452,9.702,8.209c0,5.131-4.665,8.49-10.822,8.49h-26.307v34.517h24.908c9.329,0,16.978-7.557,16.978-16.979 V52.798H248.847L248.847,52.798z"/> - + @@ -39,7 +39,7 @@ c-0.187,0-0.654-0.094-0.933-0.094h-8.863v8.21h8.863c0.28,0,0.84,0,0.933-0.093C251.833,100.281,253.419,98.789,253.419,96.55z" /> - + @@ -49,7 +49,7 @@ - + @@ -64,4 +64,4 @@ H201.924L201.924,52.798z"/> - + \ No newline at end of file diff --git a/packages/manager/src/assets/icons/payment/mastercard.svg b/packages/manager/src/assets/icons/payment/mastercard.svg index 6ad92093a35..d1b2ff2071f 100644 --- a/packages/manager/src/assets/icons/payment/mastercard.svg +++ b/packages/manager/src/assets/icons/payment/mastercard.svg @@ -8,4 +8,4 @@ - + \ No newline at end of file diff --git a/packages/manager/src/assets/icons/pending.svg b/packages/manager/src/assets/icons/pending.svg index 03ee9107408..1f85c52dc65 100644 --- a/packages/manager/src/assets/icons/pending.svg +++ b/packages/manager/src/assets/icons/pending.svg @@ -1,9 +1,7 @@ - - - - - - - - - + + + + + + + \ No newline at end of file diff --git a/packages/manager/src/assets/icons/pointer.svg b/packages/manager/src/assets/icons/pointer.svg index 676f587837c..3453f1b8564 100644 --- a/packages/manager/src/assets/icons/pointer.svg +++ b/packages/manager/src/assets/icons/pointer.svg @@ -1,7 +1,3 @@ - - - icon_pointer - - - - + + + \ No newline at end of file diff --git a/packages/manager/src/assets/icons/postgresql.svg b/packages/manager/src/assets/icons/postgresql.svg index a3996622490..a658739b47f 100644 --- a/packages/manager/src/assets/icons/postgresql.svg +++ b/packages/manager/src/assets/icons/postgresql.svg @@ -1,22 +1,13 @@ - - - postgresql - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + + + + + + + + + + diff --git a/packages/manager/src/assets/icons/promotionalOffers/heavenly-bucket.svg b/packages/manager/src/assets/icons/promotionalOffers/heavenly-bucket.svg index 4e5769a64ac..b884f66395e 100644 --- a/packages/manager/src/assets/icons/promotionalOffers/heavenly-bucket.svg +++ b/packages/manager/src/assets/icons/promotionalOffers/heavenly-bucket.svg @@ -1,20 +1,3 @@ - - - - - - - - - - - - - - - - - - - + + \ No newline at end of file diff --git a/packages/manager/src/assets/icons/providers/akamai-logo-rgb-waveOnly.svg b/packages/manager/src/assets/icons/providers/akamai-logo-rgb-waveOnly.svg index ecc82648017..7469f871782 100644 --- a/packages/manager/src/assets/icons/providers/akamai-logo-rgb-waveOnly.svg +++ b/packages/manager/src/assets/icons/providers/akamai-logo-rgb-waveOnly.svg @@ -1,9 +1,5 @@ - - - Shape - - - - - + + + + \ No newline at end of file diff --git a/packages/manager/src/assets/icons/providers/github-logo.svg b/packages/manager/src/assets/icons/providers/github-logo.svg index ab5c5549ae6..300d344ca7d 100644 --- a/packages/manager/src/assets/icons/providers/github-logo.svg +++ b/packages/manager/src/assets/icons/providers/github-logo.svg @@ -1,14 +1,4 @@ - - - - - - + + + + \ No newline at end of file diff --git a/packages/manager/src/assets/icons/providers/google-logo.svg b/packages/manager/src/assets/icons/providers/google-logo.svg index 307886a522d..ff5a29eb93c 100644 --- a/packages/manager/src/assets/icons/providers/google-logo.svg +++ b/packages/manager/src/assets/icons/providers/google-logo.svg @@ -1,12 +1,6 @@ - - - GoogleG - - - + + + + + \ No newline at end of file diff --git a/packages/manager/src/assets/icons/reset.svg b/packages/manager/src/assets/icons/reset.svg index 40f4cca6ce9..ec01a0ed580 100644 --- a/packages/manager/src/assets/icons/reset.svg +++ b/packages/manager/src/assets/icons/reset.svg @@ -1,10 +1,3 @@ - - - Group - - - - - - + + \ No newline at end of file diff --git a/packages/manager/src/assets/icons/ssh-key.svg b/packages/manager/src/assets/icons/ssh-key.svg index 32459459a33..9859d9d10ef 100644 --- a/packages/manager/src/assets/icons/ssh-key.svg +++ b/packages/manager/src/assets/icons/ssh-key.svg @@ -1,7 +1,3 @@ - - - - - - - + + + \ No newline at end of file diff --git a/packages/manager/src/assets/icons/status.svg b/packages/manager/src/assets/icons/status.svg index ae7d06df074..e420b87b017 100644 --- a/packages/manager/src/assets/icons/status.svg +++ b/packages/manager/src/assets/icons/status.svg @@ -1,15 +1,3 @@ - - Group - - - - - - - - - - + + \ No newline at end of file diff --git a/packages/manager/src/assets/icons/support.svg b/packages/manager/src/assets/icons/support.svg index 5873f7d0fe6..e7afd2d9fac 100644 --- a/packages/manager/src/assets/icons/support.svg +++ b/packages/manager/src/assets/icons/support.svg @@ -1,16 +1,3 @@ - - Customer Support - - - - - - - - - - - - - + + + \ No newline at end of file diff --git a/packages/manager/src/assets/icons/swapSmall.svg b/packages/manager/src/assets/icons/swapSmall.svg index 3d69d430869..dd3dbfcef40 100644 --- a/packages/manager/src/assets/icons/swapSmall.svg +++ b/packages/manager/src/assets/icons/swapSmall.svg @@ -1,3 +1,3 @@ - + diff --git a/packages/manager/src/assets/icons/ticket.svg b/packages/manager/src/assets/icons/ticket.svg index afb766cc72a..e7afd2d9fac 100644 --- a/packages/manager/src/assets/icons/ticket.svg +++ b/packages/manager/src/assets/icons/ticket.svg @@ -1,3 +1,3 @@ - - - + + + \ No newline at end of file diff --git a/packages/manager/src/assets/icons/undo.svg b/packages/manager/src/assets/icons/undo.svg index 235305e5a9d..7fb586847b4 100644 --- a/packages/manager/src/assets/icons/undo.svg +++ b/packages/manager/src/assets/icons/undo.svg @@ -1,6 +1,3 @@ - - - - - - + + + \ No newline at end of file diff --git a/packages/manager/src/assets/icons/unsorted.svg b/packages/manager/src/assets/icons/unsorted.svg index 5b2a949d6a3..ad2220dafb8 100644 --- a/packages/manager/src/assets/icons/unsorted.svg +++ b/packages/manager/src/assets/icons/unsorted.svg @@ -1,3 +1,3 @@ - + diff --git a/packages/manager/src/assets/icons/view.svg b/packages/manager/src/assets/icons/view.svg index c69b1fbd3b8..ca689f80d64 100644 --- a/packages/manager/src/assets/icons/view.svg +++ b/packages/manager/src/assets/icons/view.svg @@ -1,22 +1,3 @@ - - - - icon/view - Created with Sketch. - - - - - - - - - - - - - - - - + + \ No newline at end of file diff --git a/packages/manager/src/assets/icons/youtube.svg b/packages/manager/src/assets/icons/youtube.svg index f362a87ae00..fc5cf2d235d 100644 --- a/packages/manager/src/assets/icons/youtube.svg +++ b/packages/manager/src/assets/icons/youtube.svg @@ -1,11 +1,5 @@ - - - Rectangle 2 - - - - - - - - + + + + + \ No newline at end of file diff --git a/packages/manager/src/assets/icons/zoomin.svg b/packages/manager/src/assets/icons/zoomin.svg index 389ea95c5da..4e96d715e67 100644 --- a/packages/manager/src/assets/icons/zoomin.svg +++ b/packages/manager/src/assets/icons/zoomin.svg @@ -1,18 +1,10 @@ - - - - - - - - - - - - - - - - - - + + + + + + + + + + \ No newline at end of file diff --git a/packages/manager/src/components/AvatarForProxy.tsx b/packages/manager/src/components/AvatarForProxy.tsx index d144b083e53..8c0d7e2785e 100644 --- a/packages/manager/src/components/AvatarForProxy.tsx +++ b/packages/manager/src/components/AvatarForProxy.tsx @@ -40,8 +40,11 @@ export const AvatarForProxy = ({ height = 34, width = 34 }: Props) => { const StyledProxyUserIcon = styled(ProxyUserIcon, { label: 'styledProxyUserIcon', -})(() => ({ +})(({ theme }) => ({ bottom: 0, left: 0, position: 'absolute', + '& path': { + fill: theme.tokens.alias.Content.Icon.Secondary.Default, + }, })); diff --git a/packages/manager/src/components/BackupStatus/BackupStatus.tsx b/packages/manager/src/components/BackupStatus/BackupStatus.tsx index 32b1a2751c0..95d5a831c44 100644 --- a/packages/manager/src/components/BackupStatus/BackupStatus.tsx +++ b/packages/manager/src/components/BackupStatus/BackupStatus.tsx @@ -12,11 +12,13 @@ const useStyles = makeStyles()( (theme: Theme, _params, classes) => ({ backupLink: { '&:hover': { + textDecoration: 'none', [`& .${classes.icon}`]: { fill: theme.palette.primary.main, }, }, display: 'flex', + alignItems: 'center', }, backupNotApplicable: { marginRight: theme.spacing(), @@ -30,6 +32,8 @@ const useStyles = makeStyles()( icon: { fill: theme.color.grey1, fontSize: 18, + top: -1, + position: 'relative', }, tooltip: { maxWidth: 275, diff --git a/packages/manager/src/components/IconGallery/IconGallery.stories.tsx b/packages/manager/src/components/IconGallery/IconGallery.stories.tsx new file mode 100644 index 00000000000..973d784f967 --- /dev/null +++ b/packages/manager/src/components/IconGallery/IconGallery.stories.tsx @@ -0,0 +1,178 @@ +import { Typography } from '@linode/ui'; +import Box from '@mui/material/Box'; +import { useTheme } from '@mui/material/styles'; +import TextField from '@mui/material/TextField'; +import React, { useMemo, useState } from 'react'; + +import { allIcons, IconGallery, type IconName } from './IconGallery'; + +import type { Meta, StoryObj } from '@storybook/react-vite'; + +const meta: Meta = { + title: 'Icons/Icon Gallery', + component: IconGallery, + parameters: { + docs: { + description: { + component: + 'A component for displaying individual SVG icons. Icons with defined colors (like database logos) preserve their original colors, while others can be customized with a global color override.', + }, + }, + }, + argTypes: { + globalColor: { + control: { type: 'color' }, + description: + "Override color for icons that don't have defined colors. Icons with fixed colors will not be affected.", + }, + size: { + control: { type: 'number' }, + description: 'Size of the icon in pixels', + }, + iconName: { + control: { type: 'select' }, + options: allIcons.map((icon) => icon.name), + description: 'The name of the icon to display', + }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const SingleIcon: Story = { + args: { + iconName: 'Add', + size: 48, + globalColor: '#108ad6', + }, +}; + +const AllIconsComponent = ({ + globalColor = '#108ad6', +}: { + globalColor?: string; +}) => { + const [searchTerm, setSearchTerm] = useState(''); + const theme = useTheme(); + + const filteredIcons = useMemo(() => { + if (!searchTerm.trim()) { + return allIcons; + } + + return allIcons.filter((icon) => + icon.name.toLowerCase().includes(searchTerm.toLowerCase()) + ); + }, [searchTerm]); + + const statsInfo = useMemo(() => { + const total = allIcons.length; + const filtered = filteredIcons.length; + + return { total, filtered }; + }, [filteredIcons]); + + return ( + + + Icon Gallery + + + + A searchable collection of {statsInfo.total} SVG icons. Icons with + currentColor in their SVG will respond to color changes, while others + preserve their original colors. + {globalColor && + ` Global color override is active for changeable icons.`} + + + + setSearchTerm(e.target.value)} + placeholder="Search icons..." + size="small" + sx={{ + minWidth: 300, + flexGrow: 1, + maxWidth: 400, + marginTop: 0, + zIndex: 1, + position: 'relative', + '& .MuiOutlinedInput-root': { + backgroundColor: theme.palette.background.paper, + '&.Mui-focused': { + backgroundColor: theme.palette.background.paper, + }, + }, + '& .MuiInputBase-input': { + backgroundColor: 'transparent', + color: theme.palette.text.primary, + zIndex: 2, + position: 'relative', + }, + }} + value={searchTerm} + variant="outlined" + /> + + + + Showing {statsInfo.filtered} of {statsInfo.total} icons + + + {filteredIcons.length > 0 ? ( + + {filteredIcons.map(({ name }) => ( + + ))} + + ) : ( + + + No icons found + + + Try adjusting your search term + + + )} + + ); +}; + +export const AllIcons: Story = { + args: { + globalColor: '#108ad6', + }, + argTypes: { + iconName: { table: { disable: true } }, + size: { table: { disable: true } }, + }, + render: (args) => , +}; diff --git a/packages/manager/src/components/IconGallery/IconGallery.tsx b/packages/manager/src/components/IconGallery/IconGallery.tsx new file mode 100644 index 00000000000..8c288d8c277 --- /dev/null +++ b/packages/manager/src/components/IconGallery/IconGallery.tsx @@ -0,0 +1,104 @@ +import { Typography } from '@linode/ui'; +import Box from '@mui/material/Box'; +import { useTheme } from '@mui/material/styles'; +import React from 'react'; + +const iconModules = import.meta.glob<{ + default: React.ComponentType>; +}>('../../assets/icons/**/*.svg', { eager: true }); + +export const allIcons = Object.entries(iconModules) + .map(([path, module]) => { + const filename = path.split('/').pop()?.replace('.svg', '') || ''; + + const name = filename + .replace(/[-_]/g, ' ') + .split(' ') + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(''); + + return { + name, + component: module.default, + path, + }; + }) + .sort((a, b) => a.name.localeCompare(b.name)); + +export type IconName = (typeof allIcons)[number]['name']; + +export interface IconGalleryProps { + globalColor?: string; + iconName: IconName; + size?: number; +} + +export const IconGallery: React.FC = ({ + iconName, + globalColor, + size = 48, +}) => { + const theme = useTheme(); + const iconData = allIcons.find((icon) => icon.name === iconName); + + if (!iconData) { + return Icon not found: {iconName}; + } + + const { component: IconComponent } = iconData; + + return ( + + + + + + {iconName} + + + ); +}; diff --git a/packages/manager/src/components/MaskableText/MaskableText.test.tsx b/packages/manager/src/components/MaskableText/MaskableText.test.tsx index 6bd0d5c999b..de4e2bfcb53 100644 --- a/packages/manager/src/components/MaskableText/MaskableText.test.tsx +++ b/packages/manager/src/components/MaskableText/MaskableText.test.tsx @@ -98,4 +98,66 @@ describe('MaskableText', () => { // Original text should be unmasked expect(getByText(plainText)).toBeVisible(); }); + + it.each<[MaskableTextProps['length'], number]>([ + // length prop expected masked length + [undefined, 12], // default fallback + ['plaintext', 12], // DEFAULT_MASKED_TEXT_LENGTH for JSX + ['ipv4', 15], // from MASKABLE_TEXT_LENGTH_MAP + ['ipv6', 30], // from MASKABLE_TEXT_LENGTH_MAP + [8, 8], // custom numeric value + ])( + 'should mask JSX list correctly when masking is enabled (length=%s)', + (lengthProp, expectedLength) => { + queryMocks.usePreferences.mockReturnValue({ data: preference }); + + const jsxList = ( +
    +
  • item1
  • +
  • item2
  • +
  • secret-value
  • +
+ ); + + const expectedMasked = '•'.repeat(expectedLength); + + const { getByText, queryByText } = renderWithTheme( + + ); + + // Masking works + expect(getByText(expectedMasked)).toBeVisible(); + + // The JSX list content must NOT show + expect(queryByText('item1')).not.toBeInTheDocument(); + expect(queryByText('item2')).not.toBeInTheDocument(); + expect(queryByText('secret-value')).not.toBeInTheDocument(); + } + ); + + it('should render JSX list unmasked when masking preference is disabled', () => { + queryMocks.usePreferences.mockReturnValue({ data: false }); + + const jsxList = ( +
    +
  • item1
  • +
  • item2
  • +
  • secret-value
  • +
+ ); + + const { getByText, queryByText } = renderWithTheme( + + ); + + const maskedText = '•'.repeat(8); + + // Original JSX content should be visible + expect(getByText('item1')).toBeVisible(); + expect(getByText('item2')).toBeVisible(); + expect(getByText('secret-value')).toBeVisible(); + + // Masked text should NOT appear + expect(queryByText(maskedText)).not.toBeInTheDocument(); + }); }); diff --git a/packages/manager/src/components/MaskableText/MaskableText.tsx b/packages/manager/src/components/MaskableText/MaskableText.tsx index 537f5fdf4cd..fd266fd5a8f 100644 --- a/packages/manager/src/components/MaskableText/MaskableText.tsx +++ b/packages/manager/src/components/MaskableText/MaskableText.tsx @@ -37,9 +37,10 @@ export interface MaskableTextProps { */ sxVisibilityTooltip?: SxProps; /** - * The original, maskable text; if the text is not masked, render this text or the styled text via children. + * The original, maskable content; can be a string or any JSX/ReactNode. + * If the text is not masked, render this text or the styled text via children. */ - text: string | undefined; + text: React.ReactNode | string | undefined; } export const MaskableText = (props: MaskableTextProps) => { @@ -59,11 +60,13 @@ export const MaskableText = (props: MaskableTextProps) => { const [isMasked, setIsMasked] = React.useState(maskedPreferenceSetting); - const unmaskedText = children ? ( - children - ) : ( - {text} - ); + const unmaskedText = + children ?? + (typeof text === 'string' ? ( + {text} + ) : ( + text // JSX (ReactNode) + )); // Return early based on the preference setting and the original text. @@ -75,6 +78,8 @@ export const MaskableText = (props: MaskableTextProps) => { return unmaskedText; } + const maskedText = createMaskedText(text, length); + return ( { )} {isMasked ? ( - {createMaskedText(text, length)} + {maskedText} ) : ( unmaskedText diff --git a/packages/manager/src/components/MultipleIPInput/MultipleIPInput.tsx b/packages/manager/src/components/MultipleIPInput/MultipleIPInput.tsx index 6515ff3ee81..f8ca08ba0e9 100644 --- a/packages/manager/src/components/MultipleIPInput/MultipleIPInput.tsx +++ b/packages/manager/src/components/MultipleIPInput/MultipleIPInput.tsx @@ -26,7 +26,7 @@ const useStyles = makeStyles()((theme: Theme) => ({ justifyContent: 'flex-start', }, paddingLeft: 0, - paddingTop: theme.spacing(1.5), + paddingTop: theme.spacingFunction(12), }, button: { '& > span': { @@ -70,6 +70,12 @@ export interface MultipeIPInputProps { */ buttonText?: string; + /** + * Whether the first input field can be removed. + * @default false + */ + canRemoveFirstInput?: boolean; + /** * Custom CSS class for additional styling. */ @@ -155,6 +161,7 @@ export const MultipleIPInput = React.memo((props: MultipeIPInputProps) => { const { adjustSpacingForVPCDualStack, buttonText, + canRemoveFirstInput, className, disabled, error, @@ -244,8 +251,8 @@ export const MultipleIPInput = React.memo((props: MultipeIPInputProps) => { { * used in DBaaS or for Linode VPC interfaces */} - {(idx > 0 || forDatabaseAccessControls || forVPCIPRanges) && ( + {(idx > 0 || + forDatabaseAccessControls || + forVPCIPRanges || + canRemoveFirstInput) && ( { children: (p: PaginationProps) => React.ReactNode; data: T[]; + /** + * When true, prevents page size changes from being persisted to the global PAGE_SIZE + * localStorage key. This is critical for components with custom page size options + * (e.g., plans panel with 15, 25, 50) to ensure they don't override the standard + * page size preference (25, 50, 75, 100) used by other tables across the application. + * + * Use this flag when: + * - Component has non-standard page size options (anything other than 25, 50, 75, 100) + * - Page size should be ephemeral (not persisted across sessions) + * - Component uses customOptions prop in PaginationFooter + * + * @default false + */ + noPageSizeOverride?: boolean; page?: number; pageSize?: number; pageSizeSetter?: (v: number) => void; @@ -69,9 +83,13 @@ export default class Paginate extends React.Component, State> { // Use the custom setter if one has been supplied. if (this.props.pageSizeSetter) { this.props.pageSizeSetter(pageSize); - } else { + } else if (!this.props.noPageSizeOverride) { + // Only persist to global PAGE_SIZE storage if noPageSizeOverride is not set. + // This ensures components with non-standard page sizes (e.g., 15, 25, 50) + // don't override the standard preference (25, 50, 75, 100) used across the app. storage.pageSize.set(pageSize); } + // If noPageSizeOverride is true, page size change is kept in local state only }; render() { diff --git a/packages/manager/src/components/PaginationFooter/PaginationFooter.tsx b/packages/manager/src/components/PaginationFooter/PaginationFooter.tsx index 98796549440..81ef208c458 100644 --- a/packages/manager/src/components/PaginationFooter/PaginationFooter.tsx +++ b/packages/manager/src/components/PaginationFooter/PaginationFooter.tsx @@ -26,6 +26,7 @@ interface Props extends PaginationProps { customOptions?: PaginationOption[]; handlePageChange: (page: number) => void; handleSizeChange: (pageSize: number) => void; + minPageSize?: number; } const baseOptions = [ @@ -43,13 +44,14 @@ export const PaginationFooter = (props: Props) => { fixedSize, handlePageChange, handleSizeChange, + minPageSize = MIN_PAGE_SIZE, page, pageSize, showAll, sx, } = props; - if (count <= MIN_PAGE_SIZE && !fixedSize) { + if (count <= minPageSize && !fixedSize) { return null; } @@ -103,8 +105,8 @@ export const PaginationFooter = (props: Props) => { onChange={(_e, value) => handleSizeChange(Number(value.value))} options={finalOptions} value={{ - label: defaultPagination?.label ?? '', - value: defaultPagination?.value ?? '', + label: defaultPagination?.label ?? finalOptions[0]?.label ?? '', + value: defaultPagination?.value ?? finalOptions[0]?.value ?? '', }} /> diff --git a/packages/manager/src/components/PrimaryNav/PrimaryLink.tsx b/packages/manager/src/components/PrimaryNav/PrimaryLink.tsx index 3d7afbb9783..60175c32da1 100644 --- a/packages/manager/src/components/PrimaryNav/PrimaryLink.tsx +++ b/packages/manager/src/components/PrimaryNav/PrimaryLink.tsx @@ -1,4 +1,4 @@ -import { BetaChip } from '@linode/ui'; +import { BetaChip, NewFeatureChip } from '@linode/ui'; import * as React from 'react'; import { StyledActiveLink, StyledPrimaryLinkBox } from './PrimaryNav.styles'; @@ -17,6 +17,7 @@ export interface BaseNavLink { export interface PrimaryLink extends BaseNavLink { betaChipClassName?: string; isBeta?: boolean; + isNew?: boolean; onClick?: (e: React.MouseEvent) => void; } @@ -35,6 +36,7 @@ const PrimaryLink = React.memo((props: PrimaryLinkProps) => { to, isActiveLink, isBeta, + isNew, isCollapsed, onClick, } = props; @@ -63,6 +65,7 @@ const PrimaryLink = React.memo((props: PrimaryLinkProps) => { {isBeta ? ( ) : null} + {isNew ? : null} ); diff --git a/packages/manager/src/components/PrimaryNav/PrimaryNav.test.tsx b/packages/manager/src/components/PrimaryNav/PrimaryNav.test.tsx index 220bf0121c8..774bcdc49c1 100644 --- a/packages/manager/src/components/PrimaryNav/PrimaryNav.test.tsx +++ b/packages/manager/src/components/PrimaryNav/PrimaryNav.test.tsx @@ -233,6 +233,7 @@ describe('PrimaryNav', () => { accountAlertLimit: 10, accountMetricLimit: 10, alertDefinitions: true, + beta: true, notificationChannels: false, recentActivity: false, }, @@ -274,6 +275,7 @@ describe('PrimaryNav', () => { accountAlertLimit: 10, accountMetricLimit: 10, alertDefinitions: true, + beta: false, notificationChannels: true, recentActivity: true, }, @@ -314,6 +316,7 @@ describe('PrimaryNav', () => { accountAlertLimit: 10, accountMetricLimit: 10, alertDefinitions: false, + beta: true, notificationChannels: false, recentActivity: false, }, @@ -561,4 +564,30 @@ describe('PrimaryNav', () => { ).toBeNull(); }); }); + + it('should show Network Load Balancers menu item if the user has the account capability and the flag is enabled', async () => { + const account = accountFactory.build({ + capabilities: ['Network LoadBalancer'], + }); + + queryMocks.useAccount.mockReturnValue({ + data: account, + isLoading: false, + error: null, + }); + + const flags: Partial = { + networkLoadBalancer: true, + }; + + const { findByTestId } = renderWithTheme(, { + flags, + }); + + const databaseNavItem = await findByTestId( + 'menu-item-Network Load Balancer' + ); + + expect(databaseNavItem).toBeVisible(); + }); }); diff --git a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx index 191648a49b1..d42cfe40962 100644 --- a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx +++ b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx @@ -22,6 +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 { useIsNetworkLoadBalancerEnabled } from 'src/features/NetworkLoadBalancers/utils'; import { useIsPlacementGroupsEnabled } from 'src/features/PlacementGroups/utils'; import { useFlags } from 'src/hooks/useFlags'; @@ -56,6 +57,7 @@ export type NavEntity = | 'Marketplace' | 'Metrics' | 'Monitor' + | 'Network Load Balancer' | 'NodeBalancers' | 'Object Storage' | 'Placement Groups' @@ -115,6 +117,9 @@ export const PrimaryNav = (props: PrimaryNavProps) => { const { isDatabasesEnabled, isDatabasesV2Beta } = useIsDatabasesEnabled(); const { isIAMBeta, isIAMEnabled } = useIsIAMEnabled(); + const showLimitedAvailabilityBadges = flags.iamLimitedAvailabilityBadges; + + const { isNetworkLoadBalancerEnabled } = useIsNetworkLoadBalancerEnabled(); const { data: preferences, @@ -202,6 +207,11 @@ export const PrimaryNav = (props: PrimaryNavProps) => { display: 'Firewalls', to: '/firewalls', }, + { + display: 'Network Load Balancer', + hide: !isNetworkLoadBalancerEnabled, + to: '/netloadbalancers', + }, { display: 'NodeBalancers', to: '/nodebalancers', @@ -238,11 +248,7 @@ export const PrimaryNav = (props: PrimaryNavProps) => { display: 'Alerts', hide: !isAlertsEnabled, to: '/alerts', - isBeta: flags.aclp?.beta, - }, - { - display: 'Longview', - to: '/longview', + isBeta: flags.aclpAlerting?.beta, }, { display: 'Logs', @@ -250,6 +256,10 @@ export const PrimaryNav = (props: PrimaryNavProps) => { to: '/logs/delivery', isBeta: isACLPLogsBeta, }, + { + display: 'Longview', + to: '/longview', + }, ], name: 'Monitor', }, @@ -266,6 +276,7 @@ export const PrimaryNav = (props: PrimaryNavProps) => { hide: !isIAMEnabled || iamRbacPrimaryNavChanges, to: '/iam', isBeta: isIAMBeta, + isNew: !isIAMBeta && showLimitedAvailabilityBadges, }, { display: 'Account', @@ -299,6 +310,7 @@ export const PrimaryNav = (props: PrimaryNavProps) => { hide: !isIAMEnabled, to: '/iam', isBeta: isIAMBeta, + isNew: !isIAMBeta && showLimitedAvailabilityBadges, }, { display: 'Quotas', @@ -340,6 +352,7 @@ export const PrimaryNav = (props: PrimaryNavProps) => { isIAMBeta, isIAMEnabled, iamRbacPrimaryNavChanges, + isNetworkLoadBalancerEnabled, limitsEvolution, ] ); diff --git a/packages/manager/src/components/SelectFirewallPanel/SelectFirewallPanel.test.tsx b/packages/manager/src/components/SelectFirewallPanel/SelectFirewallPanel.test.tsx index a887833d578..34770806cf6 100644 --- a/packages/manager/src/components/SelectFirewallPanel/SelectFirewallPanel.test.tsx +++ b/packages/manager/src/components/SelectFirewallPanel/SelectFirewallPanel.test.tsx @@ -17,16 +17,10 @@ const queryMocks = vi.hoisted(() => ({ usePermissions: vi.fn(() => ({ data: { delete_firewall: true, update_firewall: true }, })), - useQueryWithPermissions: vi.fn().mockReturnValue({ - data: [], - isLoading: false, - isError: false, - }), })); vi.mock('src/features/IAM/hooks/usePermissions', () => ({ usePermissions: queryMocks.usePermissions, - useQueryWithPermissions: queryMocks.useQueryWithPermissions, })); describe('SelectFirewallPanel', () => { diff --git a/packages/manager/src/components/ShowMore/ShowMore.tsx b/packages/manager/src/components/ShowMore/ShowMore.tsx index 8a3466d31c2..d020f3c24cd 100644 --- a/packages/manager/src/components/ShowMore/ShowMore.tsx +++ b/packages/manager/src/components/ShowMore/ShowMore.tsx @@ -37,14 +37,15 @@ export const ShowMore = (props: ShowMoreProps) => { data-qa-show-more-chip label={`+${items.length}`} onClick={handleClick} - sx={ - anchorEl + sx={{ + ...(anchorEl ? { backgroundColor: theme.palette.primary.main, color: theme.tokens.color.Neutrals.White, } - : null - } + : {}), + ...(chipProps?.sx || {}), // caller-provided `chipProps.sx` takes precedence and will override the default active styling. + }} /> { const [tabIndex, setTabIndex] = useState(initTab); - const sxHelpIcon = { - height: 20, - m: 0.5, - verticalAlign: 'sub', - width: 20, - }; - const tabChangeHandler = (index: number) => { setTabIndex(index); if (handleTabChange) { @@ -99,20 +92,39 @@ const TabbedPanel = React.memo((props: TabbedPanelProps) => { {tabs.map((tab, idx) => ( - - {tab.title} + <> + + {tab.title} + {tab.disabled && props.tabDisabledMessage && ( - - - - + + ({ + marginLeft: `-${theme.spacingFunction(12)}`, + marginTop: theme.spacingFunction(10), + })} + > + ({ + height: 20, + m: 0.5, + width: 20, + color: theme.tokens.component.Tab.Disabled.Icon, + '&:hover': { + color: theme.tokens.component.Tab.Hover.Icon, + cursor: 'pointer', + }, + })} + /> + )} - + ))} diff --git a/packages/manager/src/components/Tabs/Tab.tsx b/packages/manager/src/components/Tabs/Tab.tsx index e990b44eea9..fa45b47d67d 100644 --- a/packages/manager/src/components/Tabs/Tab.tsx +++ b/packages/manager/src/components/Tabs/Tab.tsx @@ -14,6 +14,12 @@ const useStyles = makeStyles()((theme: Theme) => ({ '&:hover': { backgroundColor: theme.color.grey7, }, + '&:disabled': { + opacity: 1, + color: theme.tokens.component.Tab.Disabled.Text, + cursor: 'not-allowed', + pointerEvents: 'none', + }, alignItems: 'center', borderBottom: '2px solid transparent', color: theme.textColors.linkActiveLight, diff --git a/packages/manager/src/components/Uploaders/FileUpload.styles.ts b/packages/manager/src/components/Uploaders/FileUpload.styles.ts index 2cb23052408..ec12185a33f 100644 --- a/packages/manager/src/components/Uploaders/FileUpload.styles.ts +++ b/packages/manager/src/components/Uploaders/FileUpload.styles.ts @@ -81,11 +81,17 @@ export const useStyles = makeStyles()((theme: Theme) => ({ '& g': { stroke: theme.palette.error.dark, }, + '& path': { + fill: theme.palette.error.dark, + }, color: theme.palette.error.dark, }, iconRight: { color: theme.textColors.headlineStatic, }, + success: { + color: theme.palette.success.dark, + }, overwriteNotice: { alignItems: 'center', borderBottom: `1px solid ${theme.color.grey2}`, diff --git a/packages/manager/src/components/Uploaders/FileUpload.tsx b/packages/manager/src/components/Uploaders/FileUpload.tsx index 7a13f312623..ae088b76440 100644 --- a/packages/manager/src/components/Uploaders/FileUpload.tsx +++ b/packages/manager/src/components/Uploaders/FileUpload.tsx @@ -96,7 +96,10 @@ export const FileUpload = React.memo((props: FileUploadProps) => { {props.percentCompleted === 100 ? ( { onMouseDown={(e) => onResizeStart(e)} title="Resize" > - + )} diff --git a/packages/manager/src/dev-tools/dev-tools.css b/packages/manager/src/dev-tools/dev-tools.css index feec0feee2a..244cedde76d 100644 --- a/packages/manager/src/dev-tools/dev-tools.css +++ b/packages/manager/src/dev-tools/dev-tools.css @@ -61,7 +61,7 @@ position: absolute; z-index: 2; right: 0px; - bottom: 16px; + bottom: 22px; color: white; cursor: ew-resize; background: transparent; diff --git a/packages/manager/src/factories/account.ts b/packages/manager/src/factories/account.ts index 50b1d74f529..f463fe6f83c 100644 --- a/packages/manager/src/factories/account.ts +++ b/packages/manager/src/factories/account.ts @@ -48,6 +48,7 @@ export const accountFactory = Factory.Sync.makeFactory({ 'Managed Databases', 'Managed Databases Beta', 'NETINT Quadra T1U', + 'Network LoadBalancer', 'NodeBalancers', 'Object Storage Access Key Regions', 'Object Storage Endpoint Types', diff --git a/packages/manager/src/factories/cloudpulse/alerts.ts b/packages/manager/src/factories/cloudpulse/alerts.ts index bd5c92c220d..81e061542b0 100644 --- a/packages/manager/src/factories/cloudpulse/alerts.ts +++ b/packages/manager/src/factories/cloudpulse/alerts.ts @@ -245,7 +245,7 @@ export const alertFactory = Factory.Sync.makeFactory({ updated_by: 'system', }); -const firewallDimensions: Dimension[] = [ +const firewallLinodeDimensions: Dimension[] = [ { label: 'VPC-Subnet', dimension_label: 'vpc_subnet_id', values: [] }, { label: 'Interface Type', @@ -257,29 +257,84 @@ const firewallDimensions: Dimension[] = [ { label: 'Linode Region', dimension_label: 'region_id', values: [] }, ]; +const firewallNodebalancerDimensions: Dimension[] = [ + { label: 'Protocol', dimension_label: 'protocol', values: ['TCP', 'UDP'] }, + { label: 'IP Version', dimension_label: 'ip_version', values: ['v4', 'v6'] }, + { label: 'NodeBalancer', dimension_label: 'nodebalancer_id', values: [] }, +]; + export const firewallMetricDefinitionFactory = Factory.Sync.makeFactory({ - label: 'Firewall Metric', - metric: 'firewall_metric', - unit: 'metric_unit', + label: 'Current connections (Linode)', + metric: 'fw_active_connections', + unit: 'Count', metric_type: 'gauge', - scrape_interval: '300s', + scrape_interval: '60s', is_alertable: true, - available_aggregate_functions: ['avg', 'sum', 'max', 'min', 'count'], - dimensions: firewallDimensions, + available_aggregate_functions: ['avg', 'max', 'min'], + dimensions: firewallLinodeDimensions, }); + export const firewallMetricDefinitionsResponse: MetricDefinition[] = [ firewallMetricDefinitionFactory.build({ - label: 'Current connections', + label: 'Current connections (Linode)', metric: 'fw_active_connections', - unit: 'count', + unit: 'Count', available_aggregate_functions: ['avg', 'max', 'min'], + dimensions: firewallLinodeDimensions, }), firewallMetricDefinitionFactory.build({ - label: 'Ingress packets accepted', + label: 'Ingress Packets Accepted (Linode)', metric: 'fw_ingress_packets_accepted', - unit: 'packets_per_second', + unit: 'packets/s', available_aggregate_functions: ['sum'], + dimensions: firewallLinodeDimensions, + }), + firewallMetricDefinitionFactory.build({ + label: 'Available Connections (Linode)', + metric: 'fw_available_connections', + unit: 'Count', + available_aggregate_functions: ['avg', 'max', 'min'], + dimensions: firewallLinodeDimensions, + }), + firewallMetricDefinitionFactory.build({ + label: 'Ingress Bytes Accepted (Linode)', + metric: 'fw_ingress_bytes_accepted', + unit: 'Bps', + available_aggregate_functions: ['sum'], + dimensions: firewallLinodeDimensions, + }), + firewallMetricDefinitionFactory.build({ + label: 'Ingress Bytes Accepted (Node Balancer)', + metric: 'nb_ingress_bytes_accepted', + unit: 'Bps', + scrape_interval: '300s', + available_aggregate_functions: ['sum'], + dimensions: firewallNodebalancerDimensions, + }), + firewallMetricDefinitionFactory.build({ + label: 'Ingress Bytes Dropped (Node Balancer)', + metric: 'nb_ingress_bytes_dropped', + unit: 'Bps', + scrape_interval: '300s', + available_aggregate_functions: ['sum'], + dimensions: firewallNodebalancerDimensions, + }), + firewallMetricDefinitionFactory.build({ + label: 'Ingress Packets Accepted (Node Balancer)', + metric: 'nb_ingress_packets_accepted', + unit: 'packets/s', + scrape_interval: '300s', + available_aggregate_functions: ['sum'], + dimensions: firewallNodebalancerDimensions, + }), + firewallMetricDefinitionFactory.build({ + label: 'Ingress Packets Dropped (Node Balancer)', + metric: 'nb_ingress_packets_dropped', + unit: 'packets/s', + scrape_interval: '300s', + available_aggregate_functions: ['sum'], + dimensions: firewallNodebalancerDimensions, }), ]; @@ -579,3 +634,21 @@ export const blockStorageMetricCriteria = }, ], }); + +export const firewallNodebalancerMetricCriteria = + Factory.Sync.makeFactory({ + label: 'Ingress Packets Dropped (Node Balancer)', + metric: 'nb_ingress_packets_dropped', + unit: 'packets/s', + aggregate_function: 'sum', + operator: 'gt', + threshold: 1000, + dimension_filters: [ + { + label: 'NodeBalancer', + dimension_label: 'nodebalancer_id', + operator: 'in', + value: '333', + }, + ], + }); diff --git a/packages/manager/src/factories/databases.ts b/packages/manager/src/factories/databases.ts index ff326fd2722..ac10c20c467 100644 --- a/packages/manager/src/factories/databases.ts +++ b/packages/manager/src/factories/databases.ts @@ -1,5 +1,6 @@ import { type ClusterSize, + type ConnectionPool, type Database, type DatabaseBackup, type DatabaseEngine, @@ -160,6 +161,7 @@ export const databaseInstanceFactory = ? ([1, 3][i % 2] as ClusterSize) : ([1, 2, 3][i % 3] as ClusterSize) ), + connection_pool_port: null, connection_strings: [], created: '2021-12-09T17:15:12', encrypted: false, @@ -211,6 +213,7 @@ export const databaseInstanceFactory = export const databaseFactory = Factory.Sync.makeFactory({ allow_list: [...IPv4List], cluster_size: Factory.each(() => pickRandom([1, 3])), + connection_pool_port: null, connection_strings: [ { driver: 'python', @@ -285,6 +288,15 @@ export const databaseEngineFactory = Factory.Sync.makeFactory({ version: Factory.each((i) => `${i}`), }); +export const databaseConnectionPoolFactory = + Factory.Sync.makeFactory({ + database: 'defaultdb', + mode: 'transaction', + label: Factory.each((i) => `pool/${i}`), + size: 10, + username: null, + }); + export const mysqlConfigResponse = { binlog_retention_period: { description: diff --git a/packages/manager/src/factories/delivery.ts b/packages/manager/src/factories/delivery.ts index f26c05bc914..94c422631cd 100644 --- a/packages/manager/src/factories/delivery.ts +++ b/packages/manager/src/factories/delivery.ts @@ -6,7 +6,7 @@ import type { Destination } from '@linode/api-v4'; export const destinationFactory = Factory.Sync.makeFactory({ details: { access_key_id: 'Access Id', - bucket_name: 'Bucket Name', + bucket_name: 'destinations-bucket-name', host: '3000', path: 'file', }, diff --git a/packages/manager/src/factories/featureFlags.ts b/packages/manager/src/factories/featureFlags.ts index d77be1679e6..d3a66e9c6ff 100644 --- a/packages/manager/src/factories/featureFlags.ts +++ b/packages/manager/src/factories/featureFlags.ts @@ -22,8 +22,16 @@ export const flagsFactory = Factory.Sync.makeFactory>({ accountAlertLimit: 10, accountMetricLimit: 10, alertDefinitions: true, + beta: true, recentActivity: false, notificationChannels: false, + editDisabledStatuses: [ + 'in progress', + 'failed', + 'provisioning', + 'enabling', + 'disabling', + ], }, aclpServices: { linode: { diff --git a/packages/manager/src/factories/firewalls.ts b/packages/manager/src/factories/firewalls.ts index 419ecc21c6f..4399e48663c 100644 --- a/packages/manager/src/factories/firewalls.ts +++ b/packages/manager/src/factories/firewalls.ts @@ -10,7 +10,11 @@ import { } from '@linode/api-v4/lib/firewalls/types'; import { Factory } from '@linode/utilities'; -import type { FirewallDeviceEntity } from '@linode/api-v4/lib/firewalls/types'; +import type { + FirewallDeviceEntity, + FirewallPrefixList, + FirewallRuleSet, +} from '@linode/api-v4/lib/firewalls/types'; export const firewallRuleFactory = Factory.Sync.makeFactory({ action: 'DROP', @@ -97,3 +101,36 @@ export const firewallSettingsFactory = vpc_interface: 1, }, }); + +export const firewallRuleSetFactory = Factory.Sync.makeFactory( + { + created: '2025-11-05T00:00:00', + deleted: null, + description: Factory.each((i) => `firewall-ruleset-${i} description`), + id: Factory.each((i) => i), + is_service_defined: false, + label: Factory.each((i) => `firewall-ruleset-${i}`), + rules: firewallRuleFactory.buildList(3), + type: 'inbound', + updated: '2025-11-05T00:00:00', + version: 1, + } +); + +export const firewallPrefixListFactory = + Factory.Sync.makeFactory({ + created: '2025-11-05T00:00:00', + deleted: null, + description: Factory.each((i) => `firewall-prefixlist-${i} description`), + id: Factory.each((i) => i), + ipv4: Factory.each((i) => + Array.from({ length: 5 }, (_, j) => `139.144.${i}.${j}`) + ), + ipv6: Factory.each((i) => + Array.from({ length: 5 }, (_, j) => `2600:3c05:e001:bc::${i}${j}`) + ), + name: Factory.each((i) => `pl:system:resolvers:test-${i}`), + updated: '2025-11-05T00:00:00', + version: 1, + visibility: 'public', + }); diff --git a/packages/manager/src/factories/index.ts b/packages/manager/src/factories/index.ts index d075b7f42c7..03dd55cb4cf 100644 --- a/packages/manager/src/factories/index.ts +++ b/packages/manager/src/factories/index.ts @@ -33,6 +33,7 @@ export * from './longviewSubscription'; export * from './longviewTopProcesses'; export * from './managed'; export * from './networking'; +export * from './networkLoadBalancer'; export * from './notification'; export * from './oauth'; export * from './objectStorage'; diff --git a/packages/manager/src/factories/networkLoadBalancer.ts b/packages/manager/src/factories/networkLoadBalancer.ts new file mode 100644 index 00000000000..25f5fc89337 --- /dev/null +++ b/packages/manager/src/factories/networkLoadBalancer.ts @@ -0,0 +1,43 @@ +import { Factory } from '@linode/utilities'; + +import type { + NetworkLoadBalancer, + NetworkLoadBalancerListener, + NetworkLoadBalancerNode, +} from '@linode/api-v4'; + +export const networkLoadBalancerFactory = + Factory.Sync.makeFactory({ + id: Factory.each((id) => id), + label: Factory.each((id) => `netloadbalancer-${id}-test${id}`), + region: 'us-east', + address_v4: '192.168.1.1', + address_v6: '2001:db8:85a3::8a2e:370:7334', + created: '2023-01-01T00:00:00Z', + updated: '2023-01-02T00:00:00Z', + status: 'active', + last_composite_updated: '', + listeners: [], + }); + +export const networkLoadBalancerListenerFactory = + Factory.Sync.makeFactory({ + created: '2023-01-01T00:00:00Z', + id: Factory.each((id) => id), + label: Factory.each((id) => `nlb-listener-${id}`), + updated: '2023-01-01T00:00:00Z', + port: Factory.each((id) => 80 + id), + protocol: 'tcp', + }); + +export const networkLoadBalancerNodeFactory = + Factory.Sync.makeFactory({ + address_v6: '2001:db8:85a3::8a2e:370:7334', + created: '2023-01-01T00:00:00Z', + id: Factory.each((id) => id), + label: Factory.each((id) => `nlb-node-${id}`), + updated: '2023-01-01T00:00:00Z', + linode_id: Factory.each((id) => id), + weight: 0, + weight_updated: '', + }); diff --git a/packages/manager/src/featureFlags.ts b/packages/manager/src/featureFlags.ts index ce42d10a2e6..3576eb95e94 100644 --- a/packages/manager/src/featureFlags.ts +++ b/packages/manager/src/featureFlags.ts @@ -149,6 +149,7 @@ interface AclpAlerting { accountAlertLimit: number; accountMetricLimit: number; alertDefinitions: boolean; + beta: boolean; editDisabledStatuses?: AlertStatusType[]; notificationChannels: boolean; recentActivity: boolean; @@ -171,6 +172,11 @@ interface MTC { supportedRegions: Region['id'][]; } +interface FirewallRulesetsAndPrefixLists extends BetaFeatureFlag { + ga: boolean; + la: boolean; +} + export interface Flags { acceleratedPlans: AcceleratedPlansFlag; aclp: AclpFlag; @@ -191,6 +197,7 @@ export interface Flags { cloudNat: CloudNatFlag; databaseAdvancedConfig: boolean; databaseBeta: boolean; + databasePgBouncer: boolean; databasePremium: boolean; databaseResize: boolean; databaseRestrictPlanResize: boolean; @@ -200,12 +207,13 @@ export interface Flags { dbaasV2: BetaFeatureFlag; dbaasV2MonitorMetrics: BetaFeatureFlag; disableLargestGbPlans: boolean; - firewallRulesetsPrefixlists: boolean; + fwRulesetsPrefixLists: FirewallRulesetsAndPrefixLists; gecko2: GeckoFeatureFlag; - generationalPlans: boolean; + generationalPlansv2: GenerationalPlansFlag; gpuv2: GpuV2; iam: BetaFeatureFlag; iamDelegation: BaseFeatureFlag; + iamLimitedAvailabilityBadges: boolean; iamRbacPrimaryNavChanges: boolean; ipv6Sharing: boolean; kubernetesBlackwellPlans: boolean; @@ -218,6 +226,7 @@ export interface Flags { marketplaceAppOverrides: MarketplaceAppOverride[]; metadata: boolean; mtc: MTC; + networkLoadBalancer: boolean; nodebalancerIpv6: boolean; nodebalancerVpc: boolean; objectStorageGen2: BaseFeatureFlag; @@ -341,6 +350,7 @@ export type ProductInformationBannerLocation = | 'Logs' | 'Longview' | 'Managed' + | 'Network LoadBalancers' | 'NodeBalancers' | 'Object Storage' | 'Placement Groups' @@ -386,3 +396,7 @@ export type AclpServices = { metrics?: AclpFlag; }; }; + +interface GenerationalPlansFlag extends BaseFeatureFlag { + allowedPlans: string[]; +} diff --git a/packages/manager/src/features/Account/AccountLanding.tsx b/packages/manager/src/features/Account/AccountLanding.tsx index 8a702687d98..0f626cc9850 100644 --- a/packages/manager/src/features/Account/AccountLanding.tsx +++ b/packages/manager/src/features/Account/AccountLanding.tsx @@ -24,6 +24,7 @@ import { useTabs } from 'src/hooks/useTabs'; import { sendSwitchAccountEvent } from 'src/utilities/analytics/customEventAnalytics'; import { PlatformMaintenanceBanner } from '../../components/PlatformMaintenanceBanner/PlatformMaintenanceBanner'; +import { useIsIAMDelegationEnabled } from '../IAM/hooks/useIsIAMEnabled'; import { usePermissions } from '../IAM/hooks/usePermissions'; import { SwitchAccountButton } from './SwitchAccountButton'; import { SwitchAccountDrawer } from './SwitchAccountDrawer'; @@ -60,6 +61,8 @@ export const AccountLanding = () => { globalGrantType: 'child_account_access', }); + const { isIAMDelegationEnabled } = useIsIAMDelegationEnabled(); + const { isParentTokenExpired } = useIsParentTokenExpired({ isProxyUser }); const { tabs, handleTabChange, tabIndex, getTabIndex } = useTabs([ @@ -124,8 +127,9 @@ export const AccountLanding = () => { }; const isBillingTabSelected = getTabIndex('/account/billing') === tabIndex; - const canSwitchBetweenParentOrProxyAccount = - (!isChildAccountAccessRestricted && isParentUser) || isProxyUser; + const canSwitchBetweenParentOrProxyAccount = isIAMDelegationEnabled + ? isParentUser + : (!isChildAccountAccessRestricted && isParentUser) || isProxyUser; const landingHeaderProps: LandingHeaderProps = { breadcrumbProps: { diff --git a/packages/manager/src/features/Account/Maintenance/MaintenanceTableRow.tsx b/packages/manager/src/features/Account/Maintenance/MaintenanceTableRow.tsx index d4fc3505a55..9645722c188 100644 --- a/packages/manager/src/features/Account/Maintenance/MaintenanceTableRow.tsx +++ b/packages/manager/src/features/Account/Maintenance/MaintenanceTableRow.tsx @@ -1,7 +1,4 @@ -import { - useAccountMaintenancePoliciesQuery, - useProfile, -} from '@linode/queries'; +import { useProfile } from '@linode/queries'; import { Stack, Tooltip } from '@linode/ui'; import { Hidden } from '@linode/ui'; import { capitalize, getFormattedStatus, truncate } from '@linode/utilities'; @@ -84,22 +81,18 @@ export const MaintenanceTableRow = (props: MaintenanceTableRowProps) => { const dateField = getMaintenanceDateField(tableType); const dateValue = props.maintenance[dateField]; - // Fetch policies to derive a start time when the API doesn't provide one - const { data: policies } = useAccountMaintenancePoliciesQuery(); - // Precompute for potential use; currently used via getUpcomingRelativeLabel React.useMemo( - () => deriveMaintenanceStartISO(props.maintenance, policies), - [policies, props.maintenance] + () => deriveMaintenanceStartISO(props.maintenance), + [props.maintenance] ); - const upcomingRelativeLabel = React.useMemo( - () => - tableType === 'upcoming' - ? getUpcomingRelativeLabel(props.maintenance, policies) - : undefined, - [policies, props.maintenance, tableType] - ); + const upcomingRelativeLabel = React.useMemo(() => { + if (tableType !== 'upcoming') { + return undefined; + } + return getUpcomingRelativeLabel(props.maintenance); + }, [props.maintenance, tableType]); return ( diff --git a/packages/manager/src/features/Account/Maintenance/utilities.test.ts b/packages/manager/src/features/Account/Maintenance/utilities.test.ts index b2c19304751..c6629ae3205 100644 --- a/packages/manager/src/features/Account/Maintenance/utilities.test.ts +++ b/packages/manager/src/features/Account/Maintenance/utilities.test.ts @@ -5,31 +5,12 @@ import { getUpcomingRelativeLabel, } from './utilities'; -import type { AccountMaintenance, MaintenancePolicy } from '@linode/api-v4'; +import type { AccountMaintenance } from '@linode/api-v4'; // Freeze time to a stable reference so relative labels are deterministic const NOW_ISO = '2025-10-27T12:00:00.000Z'; describe('Account Maintenance utilities', () => { - const policies: MaintenancePolicy[] = [ - { - description: 'Migrate', - is_default: true, - label: 'Migrate', - notification_period_sec: 3 * 60 * 60, // 3 hours - slug: 'linode/migrate', - type: 'linode_migrate', - }, - { - description: 'Power Off / On', - is_default: false, - label: 'Power Off / Power On', - notification_period_sec: 72 * 60 * 60, // 72 hours - slug: 'linode/power_off_on', - type: 'linode_power_off_on', - }, - ]; - const baseMaintenance: Omit & { when: string } = { complete_time: null, description: 'scheduled', @@ -60,33 +41,29 @@ describe('Account Maintenance utilities', () => { ...baseMaintenance, start_time: '2025-10-27T12:00:00.000Z', }; - expect(deriveMaintenanceStartISO(m, policies)).toBe( - '2025-10-27T12:00:00.000Z' - ); + expect(deriveMaintenanceStartISO(m)).toBe('2025-10-27T12:00:00.000Z'); }); - it('derives start_time from when + policy seconds when missing', () => { + it('uses when directly as start time (when already accounts for notification period)', () => { const m: AccountMaintenance = { ...baseMaintenance, start_time: null, - when: '2025-10-27T09:00:00.000Z', // +3h -> 12:00Z + when: '2025-10-27T09:00:00.000Z', }; - expect(deriveMaintenanceStartISO(m, policies)).toBe( - '2025-10-27T12:00:00.000Z' - ); + // `when` already accounts for notification_period_sec, so it IS the start time + expect(deriveMaintenanceStartISO(m)).toBe('2025-10-27T09:00:00.000Z'); }); - it('returns undefined when policy cannot be found', () => { + it('uses when directly for all statuses without needing policies', () => { const m: AccountMaintenance = { ...baseMaintenance, start_time: null, - // Use an intentionally unknown slug to exercise the no-policy fallback path. - // Even though the API default is typically 'linode/migrate', the client may - // not have policies loaded yet or could encounter a fetch error; this ensures - // we verify the graceful fallback behavior. + status: 'pending', + // Policies not needed - when IS the start time maintenance_policy_set: 'unknown/policy' as any, + when: '2025-10-27T09:00:00.000Z', }; - expect(deriveMaintenanceStartISO(m, policies)).toBeUndefined(); + expect(deriveMaintenanceStartISO(m)).toBe('2025-10-27T09:00:00.000Z'); }); }); @@ -100,7 +77,7 @@ describe('Account Maintenance utilities', () => { when: '2025-10-27T10:00:00.000Z', }; // NOW=12:00Z, when=10:00Z => "2 hours ago" - expect(getUpcomingRelativeLabel(m, policies)).toContain('hour'); + expect(getUpcomingRelativeLabel(m)).toContain('hour'); }); it('uses derived start to express time until maintenance (hours when <1 day)', () => { @@ -110,16 +87,15 @@ describe('Account Maintenance utilities', () => { when: '2025-10-27T09:00:00.000Z', }; // Allow any non-empty string; exact phrasing depends on Luxon locale - expect(getUpcomingRelativeLabel(m, policies)).toBeTypeOf('string'); + expect(getUpcomingRelativeLabel(m)).toBeTypeOf('string'); }); it('shows days+hours when >= 1 day away (avoids day-only rounding)', () => { const m: AccountMaintenance = { ...baseMaintenance, - maintenance_policy_set: 'linode/power_off_on', // 72h - when: '2025-10-25T20:00:00.000Z', // +72h => 2025-10-28T20:00Z; from NOW (27 12:00Z) => 1 day 8 hours + when: '2025-10-28T20:00:00.000Z', // from NOW (27 12:00Z) => 1 day 8 hours }; - const label = getUpcomingRelativeLabel(m, policies); + const label = getUpcomingRelativeLabel(m); expect(label).toBe('in 1 day 8 hours'); }); @@ -129,7 +105,7 @@ describe('Account Maintenance utilities', () => { ...baseMaintenance, start_time: '2025-10-30T04:00:00.000Z', }; - const label = getUpcomingRelativeLabel(m, policies); + const label = getUpcomingRelativeLabel(m); expect(label).toBe('in 2 days 16 hours'); }); @@ -139,7 +115,7 @@ describe('Account Maintenance utilities', () => { // NOW is 12:00Z; start in 37 minutes start_time: '2025-10-27T12:37:00.000Z', }; - const label = getUpcomingRelativeLabel(m, policies); + const label = getUpcomingRelativeLabel(m); expect(label).toBe('in 37 minutes'); }); @@ -149,8 +125,59 @@ describe('Account Maintenance utilities', () => { // NOW is 12:00Z; start in 30 seconds start_time: '2025-10-27T12:00:30.000Z', }; - const label = getUpcomingRelativeLabel(m, policies); + const label = getUpcomingRelativeLabel(m); expect(label).toBe('in 30 seconds'); }); + + it('uses when directly as start time (when already accounts for notification period)', () => { + // Real-world scenario: API returns when=2025-11-06T16:12:41 + // `when` already accounts for notification_period_sec, so it IS the start time + const m: AccountMaintenance = { + ...baseMaintenance, + start_time: null, + when: '2025-11-06T16:12:41', // No timezone indicator, should be parsed as UTC + }; + + const derivedStart = deriveMaintenanceStartISO(m); + // `when` equals start time (no addition needed) + expect(derivedStart).toBe('2025-11-06T16:12:41.000Z'); + }); + + it('shows correct relative time (when equals start)', () => { + // Scenario: when=2025-11-06T16:12:41 (when IS the start time) + // If now is 2025-11-06T16:14:41 (2 minutes after when), should show "2 minutes ago" + // Save original Date.now + const originalDateNow = Date.now; + + // Mock "now" to be 2 minutes after when (which is the start time) + const mockNow = '2025-11-06T16:14:41.000Z'; + Date.now = vi.fn(() => new Date(mockNow).getTime()); + + const m: AccountMaintenance = { + ...baseMaintenance, + start_time: null, + when: '2025-11-06T16:12:41', + }; + + const label = getUpcomingRelativeLabel(m); + // when=start=16:12:41, now=16:14:41, difference is 2 minutes in the past + expect(label).toContain('minute'); // Should show "2 minutes ago" or similar + + // Restore original Date.now + Date.now = originalDateNow; + }); + + it('handles date without timezone indicator correctly (parsed as UTC)', () => { + // Verify that dates without timezone are parsed as UTC + const m: AccountMaintenance = { + ...baseMaintenance, + start_time: null, + when: '2025-11-06T16:12:41', // No Z suffix or timezone + }; + + const derivedStart = deriveMaintenanceStartISO(m); + // `when` equals start time (no addition needed) + expect(derivedStart).toBe('2025-11-06T16:12:41.000Z'); + }); }); }); diff --git a/packages/manager/src/features/Account/Maintenance/utilities.ts b/packages/manager/src/features/Account/Maintenance/utilities.ts index 9d751506a38..bb1c7cbe883 100644 --- a/packages/manager/src/features/Account/Maintenance/utilities.ts +++ b/packages/manager/src/features/Account/Maintenance/utilities.ts @@ -4,7 +4,7 @@ import { DateTime } from 'luxon'; import { parseAPIDate } from 'src/utilities/date'; import type { MaintenanceTableType } from './MaintenanceTable'; -import type { AccountMaintenance, MaintenancePolicy } from '@linode/api-v4'; +import type { AccountMaintenance } from '@linode/api-v4'; export const COMPLETED_MAINTENANCE_FILTER = Object.freeze({ status: { '+or': ['completed', 'canceled'] }, @@ -56,37 +56,36 @@ export const getMaintenanceDateLabel = (type: MaintenanceTableType): string => { }; /** - * Derive the maintenance start when API `start_time` is absent by adding the - * policy notification window to the `when` (notice publish time). + * Derive the maintenance start timestamp. + * + * The `when` and `start_time` fields are equivalent timestamps representing + * when the maintenance will happen (or has happened). Prefer `start_time` if + * available, otherwise use `when`. */ export const deriveMaintenanceStartISO = ( - maintenance: AccountMaintenance, - policies?: MaintenancePolicy[] + maintenance: AccountMaintenance ): string | undefined => { if (maintenance.start_time) { return maintenance.start_time; } - const notificationSecs = policies?.find( - (p) => p.slug === maintenance.maintenance_policy_set - )?.notification_period_sec; - if (maintenance.when && notificationSecs) { - try { - return parseAPIDate(maintenance.when) - .plus({ seconds: notificationSecs }) - .toISO(); - } catch { - return undefined; - } + + if (!maintenance.when) { + return undefined; + } + + // `when` is a timestamp equivalent to `start_time` + try { + return parseAPIDate(maintenance.when).toISO(); + } catch { + return undefined; } - return undefined; }; /** * Build a user-friendly relative label for the Upcoming table. * * Behavior: - * - Prefers the actual or policy-derived start time to express time until maintenance - * - Falls back to the notice relative time when the start cannot be determined + * - Uses `start_time` if available, otherwise uses `when` (both are equivalent timestamps) * - Avoids day-only rounding by showing days + hours when >= 1 day * * Formatting rules: @@ -96,28 +95,29 @@ export const deriveMaintenanceStartISO = ( * - "in N seconds" when < 1 minute */ export const getUpcomingRelativeLabel = ( - maintenance: AccountMaintenance, - policies?: MaintenancePolicy[] + maintenance: AccountMaintenance ): string => { - const startISO = deriveMaintenanceStartISO(maintenance, policies); + const startISO = deriveMaintenanceStartISO(maintenance); + + // Use the derived start timestamp (from start_time or when) + const targetDT = startISO + ? parseAPIDate(startISO) + : maintenance.when + ? parseAPIDate(maintenance.when) + : null; - // Fallback: when start cannot be determined, show the notice time relative to now - if (!startISO) { - return maintenance.when - ? (parseAPIDate(maintenance.when).toRelative() ?? '—') - : '—'; + if (!targetDT) { + return '—'; } - // Prefer the actual or policy-derived start time to express "time until maintenance" - const startDT = parseAPIDate(startISO); - const now = DateTime.local(); - if (startDT <= now) { - return startDT.toRelative() ?? '—'; + const now = DateTime.utc(); + if (targetDT <= now) { + return targetDT.toRelative() ?? '—'; } // Avoid day-only rounding near boundaries by including hours alongside days. // For times under an hour, show exact minutes remaining; under a minute, show seconds. - const diff = startDT + const diff = targetDT .diff(now, ['days', 'hours', 'minutes', 'seconds']) .toObject(); let days = Math.floor(diff.days ?? 0); @@ -135,6 +135,17 @@ export const getUpcomingRelativeLabel = ( hours = 0; } + // Round up hours when we have significant minutes (>= 30) for better accuracy + if (days >= 1 && minutes >= 30) { + hours += 1; + minutes = 0; + // Check if rounding caused hours to overflow + if (hours === 24) { + days += 1; + hours = 0; + } + } + if (days >= 1) { const dayPart = pluralize('day', 'days', days); const hourPart = hours ? ` ${pluralize('hour', 'hours', hours)}` : ''; diff --git a/packages/manager/src/features/Account/SwitchAccountButton.test.tsx b/packages/manager/src/features/Account/SwitchAccountButton.test.tsx index b043e9f825c..2749e17c495 100644 --- a/packages/manager/src/features/Account/SwitchAccountButton.test.tsx +++ b/packages/manager/src/features/Account/SwitchAccountButton.test.tsx @@ -1,10 +1,30 @@ -import { screen } from '@testing-library/react'; +import { screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; import { SwitchAccountButton } from 'src/features/Account/SwitchAccountButton'; import { renderWithTheme } from 'src/utilities/testHelpers'; +const queryMocks = vi.hoisted(() => ({ + userPermissions: vi.fn(() => ({ + data: { + create_child_account_token: true, + }, + })), + useFlags: vi.fn().mockReturnValue({}), +})); +vi.mock('src/features/IAM/hooks/usePermissions', () => ({ + usePermissions: queryMocks.userPermissions, +})); + +vi.mock('src/hooks/useFlags', () => { + const actual = vi.importActual('src/hooks/useFlags'); + return { + ...actual, + useFlags: queryMocks.useFlags, + }; +}); + describe('SwitchAccountButton', () => { test('renders Switch Account button with SwapIcon', () => { renderWithTheme(); @@ -22,4 +42,59 @@ describe('SwitchAccountButton', () => { expect(onClickMock).toHaveBeenCalledTimes(1); }); + + test('enables the button when user has create_child_account_token permission', () => { + queryMocks.useFlags.mockReturnValue({ + iamDelegation: { enabled: true }, + }); + + renderWithTheme(); + + const button = screen.getByRole('button', { name: /switch account/i }); + expect(button).toBeEnabled(); + }); + + test('disables the button when user does not have create_child_account_token permission', async () => { + queryMocks.userPermissions.mockReturnValue({ + data: { + create_child_account_token: false, + }, + }); + + queryMocks.useFlags.mockReturnValue({ + iamDelegation: { enabled: true }, + }); + + renderWithTheme(); + + const button = screen.getByRole('button', { name: /switch account/i }); + expect(button).toBeDisabled(); + + // Check that the tooltip is properly configured + expect(button).toHaveAttribute('aria-describedby', 'button-tooltip'); + + // Hover over the button to show the tooltip + await userEvent.hover(button); + + // Wait for tooltip to appear and check its content + await waitFor(() => { + screen.getByRole('tooltip'); + }); + + expect( + screen.getByText('You do not have permission to switch accounts.') + ).toBeVisible(); + }); + + test('enables the button when iamDelegation flag is off', async () => { + queryMocks.useFlags.mockReturnValue({ + iamDelegation: { enabled: false }, + }); + + renderWithTheme(); + + const button = screen.getByRole('button', { name: /switch account/i }); + expect(button).toBeEnabled(); + expect(button).not.toHaveAttribute('aria-describedby', 'button-tooltip'); + }); }); diff --git a/packages/manager/src/features/Account/SwitchAccountButton.tsx b/packages/manager/src/features/Account/SwitchAccountButton.tsx index 33944ec21e5..ab1dd3b3e2b 100644 --- a/packages/manager/src/features/Account/SwitchAccountButton.tsx +++ b/packages/manager/src/features/Account/SwitchAccountButton.tsx @@ -3,11 +3,23 @@ import * as React from 'react'; import SwapIcon from 'src/assets/icons/swapSmall.svg'; +import { useIsIAMDelegationEnabled } from '../IAM/hooks/useIsIAMEnabled'; +import { usePermissions } from '../IAM/hooks/usePermissions'; + import type { ButtonProps } from '@linode/ui'; export const SwitchAccountButton = (props: ButtonProps) => { + const { isIAMDelegationEnabled } = useIsIAMDelegationEnabled(); + + const { data: permissions } = usePermissions('account', [ + 'create_child_account_token', + ]); + return ( + + + + + ); + } +); diff --git a/packages/manager/src/features/CloudPulse/Widget/components/DimensionFilters/CloudPulseDimensionFilterFields.test.tsx b/packages/manager/src/features/CloudPulse/Widget/components/DimensionFilters/CloudPulseDimensionFilterFields.test.tsx new file mode 100644 index 00000000000..0564530f0f6 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Widget/components/DimensionFilters/CloudPulseDimensionFilterFields.test.tsx @@ -0,0 +1,92 @@ +import { screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; + +import { CloudPulseDimensionFilterFields } from './CloudPulseDimensionFilterFields'; + +import type { MetricsDimensionFilterForm } from './types'; +import type { Dimension } from '@linode/api-v4'; + +const dimensionOptions: Dimension[] = [ + { + dimension_label: 'test', + values: ['XYZ', 'ZYX', 'YZX'], + label: 'Test', + }, + { + dimension_label: 'sample', + values: ['value1', 'value2', 'value3'], + label: 'Sample', + }, +]; + +describe('CloudPulse dimension filter field tests', () => { + it('renders the filter fields based on the dimension options', async () => { + const handleDelete = vi.fn(); + renderWithThemeAndHookFormContext({ + component: ( + + ), + useFormOptions: { + defaultValues: { + dimension_filters: [ + { + dimension_label: null, + operator: null, + value: null, + }, + ], + }, + }, + }); + const dataFieldContainer = screen.getByTestId('dimension-field'); + const dataFieldInput = within(dataFieldContainer).getByRole('button', { + name: 'Open', + }); + await userEvent.click(dataFieldInput); + dimensionOptions.forEach(({ label }) => { + screen.getByRole('option', { + name: label, + }); // implicit assertion + }); + await userEvent.click( + screen.getByRole('option', { name: dimensionOptions[0].label }) + ); + const operatorContainer = screen.getByTestId('operator'); + const operatorFieldInput = within(operatorContainer).getByRole('button', { + name: 'Open', + }); + await userEvent.click(operatorFieldInput); + screen.getByRole('option', { + name: 'In', + }); // implicit assertion + screen.getByRole('option', { + name: 'Equal', + }); + await userEvent.click(screen.getByRole('option', { name: 'Equal' })); + const valueContainer = screen.getByTestId('value'); + const valueFieldInput = within(valueContainer).getByRole('button', { + name: 'Open', + }); + await userEvent.click(valueFieldInput); + dimensionOptions[0].values.forEach((value) => { + screen.getByRole('option', { + name: value, + }); // implicit assertion + }); + await userEvent.click( + screen.getByRole('option', { name: dimensionOptions[0].values[2] }) + ); + + // click on delete and see if handle delete is called + await userEvent.click(screen.getByTestId('clear-icon')); + expect(handleDelete).toBeCalledTimes(1); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Widget/components/DimensionFilters/CloudPulseDimensionFilterFields.tsx b/packages/manager/src/features/CloudPulse/Widget/components/DimensionFilters/CloudPulseDimensionFilterFields.tsx new file mode 100644 index 00000000000..262336d9d1e --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Widget/components/DimensionFilters/CloudPulseDimensionFilterFields.tsx @@ -0,0 +1,236 @@ +import { Autocomplete, Box } from '@linode/ui'; +import { GridLegacy } from '@mui/material'; +import React from 'react'; +import { Controller, useFormContext, useWatch } from 'react-hook-form'; +import type { FieldPathByValue } from 'react-hook-form'; + +import { dimensionOperatorOptions } from 'src/features/CloudPulse/Alerts/constants'; +import { ClearIconButton } from 'src/features/CloudPulse/Alerts/CreateAlert/Criteria/ClearIconButton'; +import { ValueFieldRenderer } from 'src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/ValueFieldRenderer'; + +import type { + MetricsDimensionFilter, + MetricsDimensionFilterForm, +} from './types'; +import type { + CloudPulseServiceType, + Dimension, + DimensionFilterOperatorType, +} from '@linode/api-v4'; +import type { AssociatedEntityType } from 'src/features/CloudPulse/shared/types'; + +interface CloudPulseDimensionFilterFieldsProps { + /** + * The entity type associated with the service type + */ + associatedEntityType?: AssociatedEntityType; + /** + * The dimension filter data options to list in the Autocomplete component + */ + dimensionOptions: Dimension[]; + + /** + * The name (with the index) used for the component to set in form + */ + name: FieldPathByValue; + + /** + * Callback function to delete the DimensionFilter component + */ + onFilterDelete: () => void; + + /** + * The selected entities for the dimension filter + */ + selectedEntities?: string[]; + + /** + * The selected regions of the associated entities + */ + selectedRegions?: string[]; + + /** + * The service type of the associated metric + */ + serviceType: CloudPulseServiceType; +} + +export const CloudPulseDimensionFilterFields = React.memo( + (props: CloudPulseDimensionFilterFieldsProps) => { + const { + dimensionOptions, + name, + onFilterDelete, + selectedEntities, + serviceType, + selectedRegions, + associatedEntityType, + } = props; + + const { control, setValue } = useFormContext(); + + const dataFieldOptions = React.useMemo( + () => + dimensionOptions.map(({ label, dimension_label: dimensionLabel }) => ({ + label, + value: dimensionLabel, + })) ?? [], + [dimensionOptions] + ); + + const handleDataFieldChange = React.useCallback( + (selected: { label: string; value: string }, operation: string) => { + const fieldValue = { + dimension_label: null, + operator: null, + value: null, + }; + if (operation === 'selectOption') { + setValue(`${name}.dimension_label`, selected.value, { + shouldValidate: true, + shouldDirty: true, + }); + setValue(`${name}.operator`, fieldValue.operator); + setValue(`${name}.value`, fieldValue.value); + } else { + setValue(name, fieldValue); + } + }, + [name, setValue] + ); + + const dimensionFieldWatcher = useWatch({ + control, + name: `${name}.dimension_label`, + }); + + const dimensionOperatorWatcher = useWatch({ + control, + name: `${name}.operator`, + }); + + const selectedDimension = React.useMemo( + () => + dimensionOptions && dimensionFieldWatcher + ? (dimensionOptions.find( + ({ dimension_label: dimensionLabel }) => + dimensionLabel === dimensionFieldWatcher + ) ?? null) + : null, + [dimensionFieldWatcher, dimensionOptions] + ); + + return ( + + + ( + { + handleDataFieldChange(newValue, operation); + }} + options={dataFieldOptions} + placeholder="Select a Dimension" + value={ + dataFieldOptions.find( + (option) => option.value === field.value + ) ?? null + } + /> + )} + /> + + + ( + { + field.onChange( + operation === 'selectOption' ? newValue.value : null + ); + setValue(`${name}.value`, null); + }} + options={dimensionOperatorOptions} + placeholder="Select an Operator" + value={ + dimensionOperatorOptions.find( + (option) => option.value === field.value + ) ?? null + } + /> + )} + /> + + + ( + + )} + /> + + + ({ + marginTop: 6, + [theme.breakpoints.down('md')]: { + marginTop: 3, + }, + })} + > + + + + + ); + } +); diff --git a/packages/manager/src/features/CloudPulse/Widget/components/DimensionFilters/CloudPulseDimensionFilterRenderer.test.tsx b/packages/manager/src/features/CloudPulse/Widget/components/DimensionFilters/CloudPulseDimensionFilterRenderer.test.tsx new file mode 100644 index 00000000000..9a17bc814af --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Widget/components/DimensionFilters/CloudPulseDimensionFilterRenderer.test.tsx @@ -0,0 +1,188 @@ +import { screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { CloudPulseDimensionFilterRenderer } from './CloudPulseDimensionFilterRenderer'; + +import type { Dimension } from '@linode/api-v4'; + +const dimensionOptions: Dimension[] = [ + { + dimension_label: 'test', + values: ['XYZ', 'ZYX', 'YZX'], + label: 'Test', + }, + { + dimension_label: 'sample', + values: ['VALUE1', 'VALUE2', 'VALUE3'], + label: 'Sample', + }, +]; +const dimensionFilterForZerothIndex = 'dimension_filters.0-id'; +const addFilter = 'Add Filter'; + +describe('CloudPulse dimension filter field tests', () => { + it('renders the filter fields based on the dimension options', async () => { + const handleClose = vi.fn(); + const handleSubmit = vi.fn(); + const handleDimensionChange = vi.fn(); + renderWithTheme( + + ); + await userEvent.click(screen.getByTestId(addFilter)); + await selectADimensionAndValue( + screen.getByTestId(dimensionFilterForZerothIndex), + 0, + 'Equal', + 2 + ); + await userEvent.click(screen.getByTestId(addFilter)); + await selectADimensionAndValue( + screen.getByTestId('dimension_filters.1-id'), + 1, + 'Not Equal', + 0 + ); + await userEvent.click(screen.getByText('Apply')); + expect(handleClose).not.toHaveBeenCalled(); + expect(handleSubmit).toHaveBeenCalledTimes(1); + expect(handleDimensionChange).toHaveBeenCalledTimes(3); + expect(handleSubmit).toHaveBeenLastCalledWith({ + dimension_filters: [ + { + dimension_label: 'test', + operator: 'eq', + value: 'YZX', + }, + { + dimension_label: 'sample', + operator: 'neq', + value: 'VALUE1', + }, + ], + }); + }); + it('handles the cancel button correctly', async () => { + const handleClose = vi.fn(); + const handleSubmit = vi.fn(); + renderWithTheme( + + ); + await userEvent.click(screen.getByTestId(addFilter)); + await selectADimensionAndValue( + screen.getByTestId(dimensionFilterForZerothIndex), + 0, + 'Equal', + 2 + ); + await userEvent.click(screen.getByText('Cancel')); + expect(handleClose).toHaveBeenCalledTimes(1); + expect(handleSubmit).not.toHaveBeenCalled(); + }); + it('handles the case when a proper already selected dimension is passed', async () => { + const handleClose = vi.fn(); + const handleSubmit = vi.fn(); + const handleDimensionChange = vi.fn(); + renderWithTheme( + + ); + const dimensionContainer = screen.getByTestId( + dimensionFilterForZerothIndex + ); + const dimension = + within(dimensionContainer).getByPlaceholderText('Select a Dimension'); + expect(dimension).toHaveValue('Test'); + const operator = + within(dimensionContainer).getByPlaceholderText('Select an Operator'); + expect(operator).toHaveValue('Starts with'); + const value = + within(dimensionContainer).getByPlaceholderText('Enter a Value'); + expect(value).toHaveValue('ZYX'); + expect(screen.getByText('Apply')).toHaveAttribute('aria-disabled', 'true'); // form is not changed, so the apply button is disabled in this case + }); +}); + +const selectADimensionAndValue = async ( + dimensionFilterContainer: HTMLElement, + dimensionOptionIndex: number, + operator: string, + valueOptionIndex: number +) => { + const dataFieldContainer = within(dimensionFilterContainer).getByTestId( + 'dimension-field' + ); + const dataFieldInput = within(dataFieldContainer).getByRole('button', { + name: 'Open', + }); + await userEvent.click(dataFieldInput); + dimensionOptions.forEach(({ label }) => { + screen.getByRole('option', { + name: label, + }); // implicit assertion + }); + await userEvent.click( + screen.getByRole('option', { + name: dimensionOptions[dimensionOptionIndex].label, + }) + ); + const operatorContainer = within(dimensionFilterContainer).getByTestId( + 'operator' + ); + const operatorFieldInput = within(operatorContainer).getByRole('button', { + name: 'Open', + }); + await userEvent.click(operatorFieldInput); + screen.getByRole('option', { + name: 'In', + }); // implicit assertion + screen.getByRole('option', { + name: 'Equal', + }); + await userEvent.click(screen.getByRole('option', { name: operator })); + const valueContainer = within(dimensionFilterContainer).getByTestId('value'); + const valueFieldInput = within(valueContainer).getByRole('button', { + name: 'Open', + }); + await userEvent.click(valueFieldInput); + dimensionOptions[dimensionOptionIndex].values.forEach((value) => { + screen.getByRole('option', { + name: value, + }); // implicit assertion + }); + await userEvent.click( + screen.getByRole('option', { + name: dimensionOptions[dimensionOptionIndex].values[valueOptionIndex], + }) + ); +}; diff --git a/packages/manager/src/features/CloudPulse/Widget/components/DimensionFilters/CloudPulseDimensionFilterRenderer.tsx b/packages/manager/src/features/CloudPulse/Widget/components/DimensionFilters/CloudPulseDimensionFilterRenderer.tsx new file mode 100644 index 00000000000..42d7d6d13d3 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Widget/components/DimensionFilters/CloudPulseDimensionFilterRenderer.tsx @@ -0,0 +1,224 @@ +import { yupResolver } from '@hookform/resolvers/yup'; +import { ActionsPanel, Box, Button, Divider, Stack } from '@linode/ui'; +import React from 'react'; +import { + FormProvider, + useFieldArray, + useForm, + useWatch, +} from 'react-hook-form'; + +import { CloudPulseDimensionFilterFields } from './CloudPulseDimensionFilterFields'; +import { metricDimensionFiltersSchema } from './schema'; + +import type { + MetricsDimensionFilter, + MetricsDimensionFilterForm, +} from './types'; +import type { CloudPulseServiceType, Dimension } from '@linode/api-v4'; +import type { AssociatedEntityType } from 'src/features/CloudPulse/shared/types'; + +interface CloudPulseDimensionFilterRendererProps { + /** + * The entity type associated with the service type + */ + associatedEntityType?: AssociatedEntityType; + + /** + * The clear all trigger to reset the form + */ + clearAllTrigger: number; + /** + * The list of dimensions associated with the selected metric + */ + dimensionOptions: Dimension[]; + + /** + * Callback triggered to close the drawer + */ + onClose: () => void; + /** + * Callback to publish any change in form + * @param isDirty indicated the changes + */ + onDimensionChange: (isDirty: boolean) => void; + + /** + * Callback triggered on form submission + * @param data The form data on submission + */ + onSubmit: (data: MetricsDimensionFilterForm) => void; + /** + * The selected dimension filters for the metric + */ + selectedDimensions?: MetricsDimensionFilter[]; + + /** + * The selected entities for the dimension filter + */ + selectedEntities?: string[]; + + /** + * The selected regions of the associated entities + */ + selectedRegions?: string[]; + + /** + * The service type of the associated metric + */ + serviceType: CloudPulseServiceType; +} +export const CloudPulseDimensionFilterRenderer = React.memo( + (props: CloudPulseDimensionFilterRendererProps) => { + const { + selectedDimensions, + onSubmit, + clearAllTrigger, + onClose, + onDimensionChange, + dimensionOptions, + selectedEntities = [], + serviceType, + selectedRegions, + associatedEntityType, + } = props; + + const formMethods = useForm({ + defaultValues: { + dimension_filters: + selectedDimensions && selectedDimensions.length > 0 + ? selectedDimensions + : [], + }, + mode: 'onBlur', + resolver: yupResolver(metricDimensionFiltersSchema), + }); + const { control, handleSubmit, formState, setValue, clearErrors } = + formMethods; + + const { isDirty } = formState; + + const formRef = React.useRef(null); + const handleFormSubmit = handleSubmit(async (values) => { + onSubmit({ + dimension_filters: values.dimension_filters, + }); + }); + + const { append, fields, remove } = useFieldArray({ + control, + name: 'dimension_filters', + }); + + const dimensionFilterWatcher = useWatch({ + control, + name: 'dimension_filters', + }); + + React.useEffect(() => { + // set a single empty filter + if (clearAllTrigger > 0) { + setValue('dimension_filters', [], { + shouldDirty: true, + shouldValidate: false, + }); + clearErrors('dimension_filters'); + } + }, [clearAllTrigger, clearErrors, setValue]); + + React.useEffect(() => { + if (fields.length) { + onDimensionChange(true); + } else { + onDimensionChange(false); + } + }, [fields, onDimensionChange]); + + return ( + +
+ + + {fields?.length > 0 && + fields.map((field, index) => ( + + remove(index)} + selectedEntities={selectedEntities} + selectedRegions={selectedRegions} + serviceType={serviceType} + /> + ({ + display: 'none', + [theme.breakpoints.down('md')]: { + // only show the divider for smaller screens + display: + index === fields.length - 1 ? 'none' : 'flex', + }, + })} + /> + + ))} + + + + 0 && !isDirty, + sx: { + width: '65px', + }, + }} + secondaryButtonProps={{ + label: 'Cancel', + onClick: () => { + onClose(); + }, + buttonType: 'outlined', + sx: { + width: '70px', + }, + }} + sx={(theme) => ({ + display: 'flex', + justifyContent: 'flex-end', + gap: theme.spacingFunction(12), + })} + /> + +
+ ); + } +); diff --git a/packages/manager/src/features/CloudPulse/Widget/components/DimensionFilters/CloudPulseDimensionFiltersSelect.test.tsx b/packages/manager/src/features/CloudPulse/Widget/components/DimensionFilters/CloudPulseDimensionFiltersSelect.test.tsx new file mode 100644 index 00000000000..e2926846052 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Widget/components/DimensionFilters/CloudPulseDimensionFiltersSelect.test.tsx @@ -0,0 +1,56 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { CloudPulseDimensionFiltersSelect } from './CloudPulseDimensionFiltersSelect'; + +import type { Dimension } from '@linode/api-v4'; + +const dimensionOptions: Dimension[] = [ + { + dimension_label: 'test', + values: ['XYZ', 'ZYX', 'YZX'], + label: 'Test', + }, + { + dimension_label: 'sample', + values: ['VALUE1', 'VALUE2', 'VALUE3'], + label: 'Sample', + }, +]; + +describe('Tests for CloudPulse Dimension Filters Select', () => { + it('renders the CloudPulse Dimension Filters with icon and drawer', async () => { + const handleSubmit = vi.fn(); + renderWithTheme( + + ); + const badge = screen.queryByText('1'); + expect(badge).toBeInTheDocument(); // should be there since we passed a selected filter + await userEvent.click(screen.getByTestId('dimension-filter')); // click on icon + // check for drawer fields + const drawerOpen = screen.getByText('Test Metric'); + expect(drawerOpen).toBeInTheDocument(); + const selectText = screen.getByText('Select up to 5 filters.'); + expect(selectText).toBeInTheDocument(); + const applyButton = screen.getByText('Apply'); + expect(applyButton).toBeInTheDocument(); + const cancelButton = screen.getByText('Cancel'); + expect(cancelButton).toBeInTheDocument(); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Widget/components/DimensionFilters/CloudPulseDimensionFiltersSelect.tsx b/packages/manager/src/features/CloudPulse/Widget/components/DimensionFilters/CloudPulseDimensionFiltersSelect.tsx new file mode 100644 index 00000000000..f20c6da73fa --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Widget/components/DimensionFilters/CloudPulseDimensionFiltersSelect.tsx @@ -0,0 +1,115 @@ +import { IconButton } from '@linode/ui'; +import React from 'react'; + +import { CloudPulseTooltip } from 'src/features/CloudPulse/shared/CloudPulseTooltip'; +import { getAssociatedEntityType } from 'src/features/CloudPulse/Utils/FilterConfig'; + +import { CloudPulseDimensionFilterDrawer } from './CloudPulseDimensionFilterDrawer'; +import { CloudPulseDimensionFilterIconWithBadge } from './CloudPulseFilterIconWithBadge'; + +import type { MetricsDimensionFilter } from './types'; +import type { CloudPulseServiceType, Dimension } from '@linode/api-v4'; + +interface CloudPulseDimensionFiltersSelectProps { + /** + * The dashboardId for which the widget and dimension filters are being rendered + */ + dashboardId: number; + /** + * The list of available dimensions for the selected metric + */ + dimensionOptions: Dimension[]; + /** + * The label for the drawer, typically the name of the metric + */ + drawerLabel: string; + /** + * @param selectedDimensions The list of selected dimension filters + */ + handleSelectionChange: (selectedDimensions: MetricsDimensionFilter[]) => void; + + /** + * The selected dimension filters for the metric + */ + selectedDimensions?: MetricsDimensionFilter[]; + + /** + * The selected entities for the dimension filter + */ + selectedEntities?: string[]; + + /** + * The selected regions of the associated entities + */ + selectedRegions?: string[]; + + /** + * The service type of the associated metric + */ + serviceType: CloudPulseServiceType; +} + +export const CloudPulseDimensionFiltersSelect = React.memo( + (props: CloudPulseDimensionFiltersSelectProps) => { + const { + dimensionOptions, + selectedDimensions, + handleSelectionChange, + drawerLabel, + selectedEntities, + serviceType, + selectedRegions, + dashboardId, + } = props; + const [open, setOpen] = React.useState(false); + + const handleChangeInSelection = React.useCallback( + (selectedValue: MetricsDimensionFilter[], close: boolean) => { + if (close) { + handleSelectionChange(selectedValue); + setOpen(false); + } + }, + [handleSelectionChange] + ); + + const selectionCount = selectedDimensions?.length ?? 0; + + return ( + <> + + setOpen(true)} + size="small" + sx={(theme) => ({ + marginBlockEnd: 'auto', + color: selectionCount + ? theme.color.buttonPrimaryHover + : 'inherit', + padding: 0, + })} + > + + + + setOpen(false)} + open={open} + selectedDimensions={selectedDimensions} + selectedEntities={selectedEntities} + selectedRegions={selectedRegions} + serviceType={serviceType} + /> + + ); + } +); diff --git a/packages/manager/src/features/CloudPulse/Widget/components/DimensionFilters/CloudPulseFilterIconWithBadge.test.tsx b/packages/manager/src/features/CloudPulse/Widget/components/DimensionFilters/CloudPulseFilterIconWithBadge.test.tsx new file mode 100644 index 00000000000..64d61c6688e --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Widget/components/DimensionFilters/CloudPulseFilterIconWithBadge.test.tsx @@ -0,0 +1,31 @@ +import { screen } from '@testing-library/react'; +import React from 'react'; +import { describe, expect, it } from 'vitest'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { CloudPulseDimensionFilterIconWithBadge } from './CloudPulseFilterIconWithBadge'; + +describe('CloudPulseDimensionFilterIconWithBadge', () => { + it('renders the badge with correct count when count > 0', () => { + renderWithTheme(); + + // Badge content should be visible + const badge = screen.getByText('5'); + expect(badge).toBeInTheDocument(); + + const filter = screen.getByTestId('filled-filter'); + expect(filter).toBeInTheDocument(); + }); + + it('does not render the badge when count = 0', () => { + renderWithTheme(); + + // Badge should not be visible + const badge = screen.queryByText('0'); + expect(badge).not.toBeInTheDocument(); + + const filter = screen.getByTestId('filter'); + expect(filter).toBeInTheDocument(); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Widget/components/DimensionFilters/CloudPulseFilterIconWithBadge.tsx b/packages/manager/src/features/CloudPulse/Widget/components/DimensionFilters/CloudPulseFilterIconWithBadge.tsx new file mode 100644 index 00000000000..73ae0d49522 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Widget/components/DimensionFilters/CloudPulseFilterIconWithBadge.tsx @@ -0,0 +1,49 @@ +import Badge from '@mui/material/Badge'; +import React from 'react'; + +import FilterIcon from 'src/assets/icons/filter.svg'; +import FilledFilterIcon from 'src/assets/icons/filterfilled.svg'; +interface CloudPulseDimensionFilterIconWithBadgeProps { + /** + * The count to be displayed in the badge + */ + count: number; +} + +export const CloudPulseDimensionFilterIconWithBadge = React.memo( + ({ count }: CloudPulseDimensionFilterIconWithBadgeProps) => { + return ( + ({ + top: 3, // nudge up + right: 3, // nudge right + minWidth: 8, + width: 15, + height: 16, + borderRadius: '100%', + fontSize: 10, + lineHeight: 1, + color: theme.tokens.color.Neutrals.White, + backgroundColor: theme.palette.error.dark, + }), + }, + }} + > + {count === 0 ? ( + + ) : ( + + )} + + ); + } +); diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseCustomSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseCustomSelect.tsx index 556f1c5ad1a..f1b391996b1 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseCustomSelect.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseCustomSelect.tsx @@ -254,6 +254,7 @@ export const CloudPulseCustomSelect = React.memo( errorText={staticErrorText} isOptionEqualToValue={(option, value) => option.label === value.label} label={label || 'Select a Value'} + loading={isLoading} multiple={isMultiSelect} noMarginTop onChange={handleChange} diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseEndpointsSelect.test.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseEndpointsSelect.test.tsx index e38acc948bd..238f2c3c0f5 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseEndpointsSelect.test.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseEndpointsSelect.test.tsx @@ -2,7 +2,6 @@ import { screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import * as React from 'react'; -import { objectStorageBucketFactory } from 'src/factories'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { CloudPulseEndpointsSelect } from './CloudPulseEndpointsSelect'; @@ -24,37 +23,53 @@ vi.mock('src/queries/cloudpulse/resources', async () => { const mockEndpointHandler = vi.fn(); const SELECT_ALL = 'Select All'; const ARIA_SELECTED = 'aria-selected'; +const ARIA_DISABLED = 'aria-disabled'; -const mockBuckets: CloudPulseResources[] = [ +const mockEndpoints: CloudPulseResources[] = [ { - id: 'obj-bucket-1.us-east-1.linodeobjects.com', - label: 'obj-bucket-1.us-east-1.linodeobjects.com', + id: 'us-east-1.linodeobjects.com', + label: 'us-east-1.linodeobjects.com', region: 'us-east', - endpoint: 'us-east-1.linodeobjects.com', }, { - id: 'obj-bucket-2.us-east-2.linodeobjects.com', - label: 'obj-bucket-2.us-east-2.linodeobjects.com', + id: 'us-east-2.linodeobjects.com', + label: 'us-east-2.linodeobjects.com', region: 'us-east', - endpoint: 'us-east-2.linodeobjects.com', }, { - id: 'obj-bucket-1.br-gru-1.linodeobjects.com', - label: 'obj-bucket-1.br-gru-1.linodeobjects.com', + id: 'br-gru-1.linodeobjects.com', + label: 'br-gru-1.linodeobjects.com', region: 'us-east', - endpoint: 'br-gru-1.linodeobjects.com', }, ]; +const exceedingmockEndpoints: CloudPulseResources[] = Array.from( + { length: 8 }, + (_, i) => { + const idx = i + 1; + return { + id: `us-east-bucket-${idx}.com`, + label: `us-east-bucket-${idx}.com`, + region: 'us-east', + }; + } +); + describe('CloudPulseEndpointsSelect component tests', () => { beforeEach(() => { vi.clearAllMocks(); - objectStorageBucketFactory.resetSequenceNumber(); + queryMocks.useResourcesQuery.mockReturnValue({ + data: mockEndpoints, + isError: false, + isLoading: false, + status: 'success', + }); }); it('renders with the correct label and placeholder', () => { renderWithTheme( { it('should render disabled component if the props are undefined', () => { renderWithTheme( { }); it('should render endpoints', async () => { - queryMocks.useResourcesQuery.mockReturnValue({ - data: mockBuckets, - isError: false, - isLoading: false, - status: 'success', - }); - renderWithTheme( { expect( await screen.findByRole('option', { - name: mockBuckets[0].endpoint, + name: mockEndpoints[0].id, }) ).toBeVisible(); expect( await screen.findByRole('option', { - name: mockBuckets[1].endpoint, + name: mockEndpoints[1].id, }) ).toBeVisible(); }); it('should be able to deselect the selected endpoints', async () => { - queryMocks.useResourcesQuery.mockReturnValue({ - data: mockBuckets, - isError: false, - isLoading: false, - status: 'success', - }); - renderWithTheme( { // Check that both endpoints are deselected expect( await screen.findByRole('option', { - name: mockBuckets[0].endpoint, + name: mockEndpoints[0].id, }) ).toHaveAttribute(ARIA_SELECTED, 'false'); expect( await screen.findByRole('option', { - name: mockBuckets[1].endpoint, + name: mockEndpoints[1].id, }) ).toHaveAttribute(ARIA_SELECTED, 'false'); }); it('should select multiple endpoints', async () => { - queryMocks.useResourcesQuery.mockReturnValue({ - data: mockBuckets, - isError: false, - isLoading: false, - status: 'success', - }); - renderWithTheme( { await userEvent.click(await screen.findByRole('button', { name: 'Open' })); await userEvent.click( await screen.findByRole('option', { - name: mockBuckets[0].endpoint, + name: mockEndpoints[0].id, }) ); await userEvent.click( await screen.findByRole('option', { - name: mockBuckets[1].endpoint, + name: mockEndpoints[1].id, }) ); // Check that the correct endpoints are selected/not selected expect( await screen.findByRole('option', { - name: mockBuckets[0].endpoint, + name: mockEndpoints[0].id, }) ).toHaveAttribute(ARIA_SELECTED, 'true'); expect( await screen.findByRole('option', { - name: mockBuckets[1].endpoint, + name: mockEndpoints[1].id, }) ).toHaveAttribute(ARIA_SELECTED, 'true'); expect( await screen.findByRole('option', { - name: mockBuckets[2].endpoint, + name: mockEndpoints[2].id, }) ).toHaveAttribute(ARIA_SELECTED, 'false'); expect( @@ -213,6 +211,7 @@ describe('CloudPulseEndpointsSelect component tests', () => { renderWithTheme( { }) ).toBeVisible(); }); + + it('should handle endpoints selection limits correctly', async () => { + const user = userEvent.setup(); + + const allmockEndpoints = [...mockEndpoints, ...exceedingmockEndpoints]; + + queryMocks.useResourcesQuery.mockReturnValue({ + data: allmockEndpoints, + isError: false, + isLoading: false, + status: 'success', + }); + + const { queryByRole } = renderWithTheme( + + ); + + await user.click(screen.getByRole('button', { name: 'Open' })); + expect(screen.getByText('Select up to 10 Endpoints')).toBeVisible(); + + // Select the first 10 endpoints + for (let i = 0; i < 10; i++) { + const option = await screen.findByRole('option', { + name: allmockEndpoints[i].id, + }); + await user.click(option); + } + + // Check if we have 10 selected endpoints + const selectedOptions = screen + .getAllByRole('option') + .filter((option) => option.getAttribute(ARIA_SELECTED) === 'true'); + expect(selectedOptions.length).toBe(10); + + // Check that the 11th endpoint is disabled + expect( + screen.getByRole('option', { name: allmockEndpoints[10].id }) + ).toHaveAttribute(ARIA_DISABLED, 'true'); + + // Check "Select All" is not available when there are more endpoints than the limit + expect(queryByRole('option', { name: SELECT_ALL })).not.toBeInTheDocument(); + }); + + it('should handle "Select All" when resource count equals limit', async () => { + const user = userEvent.setup(); + + queryMocks.useResourcesQuery.mockReturnValue({ + data: [...mockEndpoints, ...exceedingmockEndpoints.slice(0, 7)], + isError: false, + isLoading: false, + status: 'success', + }); + + renderWithTheme( + + ); + + await user.click(screen.getByRole('button', { name: 'Open' })); + await user.click(screen.getByRole('option', { name: SELECT_ALL })); + await user.click(screen.getByRole('option', { name: 'Deselect All' })); + + // Check all endpoints are deselected + mockEndpoints.forEach((endpoint) => { + expect(screen.getByRole('option', { name: endpoint.id })).toHaveAttribute( + ARIA_SELECTED, + 'false' + ); + }); + }); }); diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseEndpointsSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseEndpointsSelect.tsx index a0578ea08b3..a2837c587da 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseEndpointsSelect.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseEndpointsSelect.tsx @@ -1,31 +1,29 @@ import { Autocomplete, SelectedIcon, StyledListItem } from '@linode/ui'; import { Box } from '@mui/material'; -import React, { useMemo } from 'react'; +import React from 'react'; +import { useFlags } from 'src/hooks/useFlags'; import { useResourcesQuery } from 'src/queries/cloudpulse/resources'; -import { RESOURCE_FILTER_MAP } from '../Utils/constants'; -import { deepEqual, filterEndpointsUsingRegion } from '../Utils/FilterBuilder'; +import { ENDPOINT, RESOURCE_FILTER_MAP } from '../Utils/constants'; +import { filterEndpointsUsingRegion } from '../Utils/FilterBuilder'; +import { FILTER_CONFIG } from '../Utils/FilterConfig'; +import { deepEqual } from '../Utils/utils'; import { CLOUD_PULSE_TEXT_FIELD_PROPS } from './styles'; import type { CloudPulseMetricsFilter, FilterValueType, } from '../Dashboard/CloudPulseDashboardLanding'; +import type { CloudPulseResources } from './CloudPulseResourcesSelect'; import type { CloudPulseServiceType, FilterValue } from '@linode/api-v4'; +import type { CloudPulseResourceTypeMapFlag } from 'src/featureFlags'; -export interface CloudPulseEndpoints { - /** - * The label of the endpoint which is 's3_endpoint' in the response from the API - */ - label: string; +export interface CloudPulseEndpointsSelectProps { /** - * The region of the endpoint + * The dashboard id for the endpoints filter */ - region: string; -} - -export interface CloudPulseEndpointsSelectProps { + dashboardId: number; /** * The default value of the endpoints filter */ @@ -38,6 +36,10 @@ export interface CloudPulseEndpointsSelectProps { * The function to handle the endpoints selection */ handleEndpointsSelection: (endpoints: string[], savePref?: boolean) => void; + /** + * Whether to restrict the selections + */ + hasRestrictedSelections?: boolean; /** * The label of the endpoints filter */ @@ -69,6 +71,7 @@ export const CloudPulseEndpointsSelect = React.memo( const { defaultValue, disabled, + dashboardId, handleEndpointsSelection, label, placeholder, @@ -76,39 +79,32 @@ export const CloudPulseEndpointsSelect = React.memo( serviceType, savePreferences, xFilter, + hasRestrictedSelections, } = props; + const flags = useFlags(); + + // Get the endpoints filter configuration for the dashboard + const endpointsFilterConfig = FILTER_CONFIG.get(dashboardId)?.filters.find( + (filter) => filter.configuration.filterKey === ENDPOINT + ); + const filterFn = endpointsFilterConfig?.configuration.filterFn; + const { - data: buckets, + data: validSortedEndpoints, isError, isLoading, } = useResourcesQuery( disabled !== undefined ? !disabled : Boolean(region && serviceType), serviceType, {}, - - RESOURCE_FILTER_MAP[serviceType ?? ''] ?? {} + RESOURCE_FILTER_MAP[serviceType ?? ''] ?? {}, + undefined, + filterFn ); - const validSortedEndpoints = useMemo(() => { - if (!buckets) return []; - - const visitedEndpoints = new Set(); - const uniqueEndpoints: CloudPulseEndpoints[] = []; - - buckets.forEach(({ endpoint, region }) => { - if (endpoint && region && !visitedEndpoints.has(endpoint)) { - visitedEndpoints.add(endpoint); - uniqueEndpoints.push({ label: endpoint, region }); - } - }); - - uniqueEndpoints.sort((a, b) => a.label.localeCompare(b.label)); - return uniqueEndpoints; - }, [buckets]); - const [selectedEndpoints, setSelectedEndpoints] = - React.useState(); + React.useState(); /** * This is used to track the open state of the autocomplete and useRef optimizes the re-renders that this component goes through and it is used for below @@ -117,17 +113,49 @@ export const CloudPulseEndpointsSelect = React.memo( */ const isAutocompleteOpen = React.useRef(false); // Ref to track the open state of Autocomplete - const getEndpointsList = React.useMemo(() => { + const getEndpointsList = React.useMemo(() => { return filterEndpointsUsingRegion(validSortedEndpoints, xFilter) ?? []; }, [validSortedEndpoints, xFilter]); + // Maximum endpoints selection limit is fetched from launchdarkly + const maxEndpointsSelectionLimit = React.useMemo(() => { + const obj = flags.aclpResourceTypeMap?.find( + (item: CloudPulseResourceTypeMapFlag) => + item.serviceType === serviceType + ); + return obj?.maxResourceSelections || 10; + }, [serviceType, flags.aclpResourceTypeMap]); + + const endpointsLimitReached = React.useMemo(() => { + return getEndpointsList.length > maxEndpointsSelectionLimit; + }, [getEndpointsList.length, maxEndpointsSelectionLimit]); + + // Disable Select All option if the number of available endpoints are greater than the limit + const disableSelectAll = hasRestrictedSelections + ? endpointsLimitReached + : false; + + const errorText = isError ? `Failed to fetch ${label || 'Endpoints'}.` : ''; + const helperText = + !isError && hasRestrictedSelections + ? `Select up to ${maxEndpointsSelectionLimit} ${label}` + : ''; + + // Check if the number of selected endpoints are greater than or equal to the limit + const maxSelectionsReached = React.useMemo(() => { + return ( + selectedEndpoints && + selectedEndpoints.length >= maxEndpointsSelectionLimit + ); + }, [selectedEndpoints, maxEndpointsSelectionLimit]); + // Once the data is loaded, set the state variable with value stored in preferences React.useEffect(() => { if (disabled && !selectedEndpoints) { return; } // To save default values, go through side effects if disabled is false - if (!buckets || !savePreferences || selectedEndpoints) { + if (!validSortedEndpoints || !savePreferences || selectedEndpoints) { if (selectedEndpoints) { setSelectedEndpoints([]); handleEndpointsSelection([]); @@ -145,7 +173,7 @@ export const CloudPulseEndpointsSelect = React.memo( setSelectedEndpoints(endpoints); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [buckets, region, xFilter, serviceType]); + }, [validSortedEndpoints, region, xFilter, serviceType]); return ( option.label === value.label} label={label || 'Endpoints'} limitTags={1} @@ -197,8 +227,20 @@ export const CloudPulseEndpointsSelect = React.memo( ? StyledListItem : 'li'; + const isMaxSelectionsReached = + maxSelectionsReached && + !isEndpointSelected && + !isSelectAllORDeslectAllOption; + return ( - + <> {option.label} diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseFirewallNodebalancersSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseFirewallNodebalancersSelect.tsx index 0833c8d4d92..9ab7b4b9e01 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseFirewallNodebalancersSelect.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseFirewallNodebalancersSelect.tsx @@ -6,8 +6,9 @@ import React from 'react'; import { useResourcesQuery } from 'src/queries/cloudpulse/resources'; import { PARENT_ENTITY_REGION, RESOURCE_FILTER_MAP } from '../Utils/constants'; -import { deepEqual, filterFirewallNodebalancers } from '../Utils/FilterBuilder'; -import { getAssociatedEntityType } from '../Utils/utils'; +import { filterFirewallNodebalancers } from '../Utils/FilterBuilder'; +import { getAssociatedEntityType } from '../Utils/FilterConfig'; +import { deepEqual } from '../Utils/utils'; import { CLOUD_PULSE_TEXT_FIELD_PROPS } from './styles'; import type { CloudPulseMetricsFilter } from '../Dashboard/CloudPulseDashboardLanding'; @@ -137,7 +138,11 @@ export const CloudPulseFirewallNodebalancersSelect = React.memo( return; } // To save default values, go through side effects - if (!getNodebalancersList || !savePreferences || selectedNodebalancers) { + if ( + !getNodebalancersList.length || + !savePreferences || + selectedNodebalancers + ) { if (selectedNodebalancers) { setSelectedNodebalancers([]); handleNodebalancersSelection([]); diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.test.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.test.tsx index 14d53e1a7e8..1b2d232e68d 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.test.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.test.tsx @@ -8,11 +8,7 @@ import { screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import * as React from 'react'; -import { - dashboardFactory, - databaseInstanceFactory, - firewallFactory, -} from 'src/factories'; +import { dashboardFactory, databaseInstanceFactory } from 'src/factories'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { NO_REGION_MESSAGE } from '../Utils/constants'; @@ -24,7 +20,6 @@ import type { useRegionsQuery } from '@linode/queries'; const props: CloudPulseRegionSelectProps = { filterKey: 'region', - selectedEntities: [], handleRegionChange: vi.fn(), label: 'Region', selectedDashboard: undefined, @@ -327,26 +322,10 @@ describe('CloudPulseRegionSelect', () => { id: 'ap-west', label: 'IN, Mumbai', capabilities: [capabilityServiceTypeMapping['firewall']], - }), - ], - isError: false, - isLoading: false, - }); - queryMocks.useResourcesQuery.mockReturnValue({ - data: [ - firewallFactory.build({ - id: 1, - entities: [{ id: 1, type: 'linode' }], - }), - ], - isError: false, - isLoading: false, - }); - queryMocks.useAllLinodesQuery.mockReturnValue({ - data: [ - linodeFactory.build({ - id: 1, - region: 'ap-west', + monitors: { + metrics: [capabilityServiceTypeMapping['firewall']], + alerts: [], + }, }), ], isError: false, @@ -362,7 +341,6 @@ describe('CloudPulseRegionSelect', () => { service_type: 'firewall', id: 4, })} - selectedEntities={['1']} /> ); await user.click(screen.getByRole('button', { name: 'Open' })); @@ -378,31 +356,16 @@ describe('CloudPulseRegionSelect', () => { id: 'ap-west', label: 'IN, Mumbai', capabilities: [capabilityServiceTypeMapping['firewall']], + monitors: { + metrics: [capabilityServiceTypeMapping['firewall']], + alerts: [], + }, }), ], isError: false, isLoading: false, }); - queryMocks.useResourcesQuery.mockReturnValue({ - data: [ - firewallFactory.build({ - id: 1, - entities: [{ id: 1, type: 'linode' }], - }), - ], - isError: false, - isLoading: false, - }); - queryMocks.useAllLinodesQuery.mockReturnValue({ - data: [ - linodeFactory.build({ - id: 1, - region: 'ap-west', - }), - ], - isError: false, - isLoading: false, - }); + renderWithTheme( { service_type: 'firewall', id: 4, })} - selectedEntities={['1']} /> ); expect(screen.getByDisplayValue('IN, Mumbai (ap-west)')).toBeVisible(); @@ -424,31 +386,16 @@ describe('CloudPulseRegionSelect', () => { id: 'ap-west', label: 'IN, Mumbai', capabilities: [capabilityServiceTypeMapping['firewall']], + monitors: { + metrics: [capabilityServiceTypeMapping['firewall']], + alerts: [], + }, }), ], isError: false, isLoading: false, }); - queryMocks.useResourcesQuery.mockReturnValue({ - data: [ - firewallFactory.build({ - id: 1, - entities: [{ id: 1, type: 'nodebalancer' }], - }), - ], - isError: false, - isLoading: false, - }); - queryMocks.useAllNodeBalancersQuery.mockReturnValue({ - data: [ - nodeBalancerFactory.build({ - id: 1, - region: 'ap-west', - }), - ], - isError: false, - isLoading: false, - }); + renderWithTheme( { service_type: 'firewall', id: 8, })} - selectedEntities={['1']} /> ); expect(screen.getByDisplayValue('IN, Mumbai (ap-west)')).toBeVisible(); diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx index c49ad4c0fd7..6dd24061ec6 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx @@ -6,22 +6,17 @@ import { RegionSelect } from 'src/components/RegionSelect/RegionSelect'; import { useFlags } from 'src/hooks/useFlags'; import { useResourcesQuery } from 'src/queries/cloudpulse/resources'; -import { useFirewallFetchOptions } from '../Alerts/CreateAlert/Criteria/DimensionFilterValue/useFirewallFetchOptions'; import { filterRegionByServiceType } from '../Alerts/Utils/utils'; import { NO_REGION_MESSAGE, PARENT_ENTITY_REGION, RESOURCE_FILTER_MAP, } from '../Utils/constants'; -import { deepEqual, filterUsingDependentFilters } from '../Utils/FilterBuilder'; -import { FILTER_CONFIG } from '../Utils/FilterConfig'; -import { - getAssociatedEntityType, - getResourcesFilterConfig, -} from '../Utils/utils'; +import { filterUsingDependentFilters } from '../Utils/FilterBuilder'; +import { FILTER_CONFIG, getResourcesFilterConfig } from '../Utils/FilterConfig'; +import { deepEqual } from '../Utils/utils'; import { CLOUD_PULSE_TEXT_FIELD_PROPS } from './styles'; -import type { Item } from '../Alerts/constants'; import type { CloudPulseMetricsFilter } from '../Dashboard/CloudPulseDashboardLanding'; import type { Dashboard, FilterValue, Region } from '@linode/api-v4'; @@ -39,7 +34,6 @@ export interface CloudPulseRegionSelectProps { placeholder?: string; savePreferences?: boolean; selectedDashboard: Dashboard | undefined; - selectedEntities: string[]; xFilter?: CloudPulseMetricsFilter; } @@ -53,7 +47,6 @@ export const CloudPulseRegionSelect = React.memo( placeholder, savePreferences, selectedDashboard, - selectedEntities, disabled = false, xFilter, } = props; @@ -69,7 +62,10 @@ export const CloudPulseRegionSelect = React.memo( isError: isResourcesError, isLoading: isResourcesLoading, } = useResourcesQuery( - !disabled && selectedDashboard !== undefined && Boolean(regions?.length), + filterKey !== PARENT_ENTITY_REGION && + !disabled && + selectedDashboard !== undefined && + Boolean(regions?.length), selectedDashboard?.service_type, {}, { @@ -93,50 +89,20 @@ export const CloudPulseRegionSelect = React.memo( const [selectedRegion, setSelectedRegion] = React.useState(); - // Get the associated entity type for the dashboard - const associatedEntityType = getAssociatedEntityType(dashboardId); - const { - values: linodeRegions, - isLoading: isLinodeRegionIdLoading, - isError: isLinodeRegionIdError, - } = useFirewallFetchOptions({ - dimensionLabel: filterKey, - entities: selectedEntities, - regions, - serviceType, - associatedEntityType, - type: 'metrics', - }); - const linodeRegionIds = linodeRegions.map( - (option: Item) => option.value - ); - - const supportedLinodeRegions = React.useMemo(() => { - return ( - regions?.filter((region) => linodeRegionIds?.includes(region.id)) ?? [] - ); - }, [regions, linodeRegionIds]); - const supportedRegions = React.useMemo(() => { return filterRegionByServiceType('metrics', regions, serviceType); }, [regions, serviceType]); const supportedRegionsFromResources = React.useMemo(() => { if (filterKey === PARENT_ENTITY_REGION) { - return supportedLinodeRegions; + return supportedRegions; } return supportedRegions.filter(({ id }) => filterUsingDependentFilters(resources, xFilter)?.some( ({ region }) => region === id ) ); - }, [ - filterKey, - supportedLinodeRegions, - supportedRegions, - resources, - xFilter, - ]); + }, [supportedRegions, resources, xFilter, filterKey]); const dependencyKey = supportedRegionsFromResources .map((region) => region.id) @@ -192,9 +158,14 @@ export const CloudPulseRegionSelect = React.memo( currentCapability={capability} data-testid="region-select" disableClearable={false} - disabled={!selectedDashboard || !regions || disabled || !resources} + disabled={ + !selectedDashboard || + !regions || + disabled || + (!resources && filterKey !== PARENT_ENTITY_REGION) + } errorText={ - isError || isResourcesError || isLinodeRegionIdError + isError || (isResourcesError && filterKey !== PARENT_ENTITY_REGION) ? `Failed to fetch ${label || 'Regions'}.` : '' } @@ -203,7 +174,8 @@ export const CloudPulseRegionSelect = React.memo( label={label || 'Region'} loading={ !disabled && - (isLoading || isResourcesLoading || isLinodeRegionIdLoading) + (isLoading || + (isResourcesLoading && filterKey !== PARENT_ENTITY_REGION)) } noMarginTop noOptionsText={ diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.test.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.test.tsx index 3ef165cf42d..5b404698265 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.test.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.test.tsx @@ -5,6 +5,7 @@ import * as React from 'react'; import { renderWithTheme } from 'src/utilities/testHelpers'; +import { CLUSTERS_TOOLTIP_TEXT } from '../Utils/constants'; import { CloudPulseResourcesSelect } from './CloudPulseResourcesSelect'; const queryMocks = vi.hoisted(() => ({ @@ -44,6 +45,21 @@ describe('CloudPulseResourcesSelect component tests', () => { expect(screen.getByPlaceholderText('Select Resources')).toBeInTheDocument(); }); + it('renders with the tooltip-text for lke service', async () => { + renderWithTheme( + + ); + + expect( + screen.getByRole('button', { name: CLUSTERS_TOOLTIP_TEXT }) + ).toBeVisible(); + }); + it('should render disabled component if the props are undefined or regions and service type does not have any resources', () => { renderWithTheme( ); }} - textFieldProps={{ ...CLOUD_PULSE_TEXT_FIELD_PROPS }} + textFieldProps={{ + ...CLOUD_PULSE_TEXT_FIELD_PROPS, + labelTooltipText: tooltipText, + }} value={selectedResources ?? []} /> ); diff --git a/packages/manager/src/features/CloudPulse/shared/DimensionTransform.ts b/packages/manager/src/features/CloudPulse/shared/DimensionTransform.ts index d90b7c1d669..c84ccfdf8bf 100644 --- a/packages/manager/src/features/CloudPulse/shared/DimensionTransform.ts +++ b/packages/manager/src/features/CloudPulse/shared/DimensionTransform.ts @@ -31,6 +31,9 @@ export const DIMENSION_TRANSFORM_CONFIG: Partial< interface_type: TRANSFORMS.uppercase, linode_id: TRANSFORMS.original, nodebalancer_id: TRANSFORMS.original, + protocol: TRANSFORMS.uppercase, + ip_version: TRANSFORMS.original, + region_id: TRANSFORMS.original, }, nodebalancer: { protocol: TRANSFORMS.uppercase, diff --git a/packages/manager/src/features/CloudPulse/shared/types.ts b/packages/manager/src/features/CloudPulse/shared/types.ts index 083a65b056d..a81f969298d 100644 --- a/packages/manager/src/features/CloudPulse/shared/types.ts +++ b/packages/manager/src/features/CloudPulse/shared/types.ts @@ -9,7 +9,7 @@ export type TransformFunction = (value: string) => string; export type TransformFunctionMap = Record; -export type AssociatedEntityType = 'both' | 'linode' | 'nodebalancer'; +export type AssociatedEntityType = 'linode' | 'nodebalancer'; export interface FirewallEntity { /** diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.style.ts b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.style.ts index d3cf4debf14..d50b9360eb7 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.style.ts +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.style.ts @@ -1,4 +1,4 @@ -import { Typography } from '@linode/ui'; +import { Stack, Typography } from '@linode/ui'; import { styled } from '@mui/material/styles'; import { DateCalendar } from '@mui/x-date-pickers'; @@ -36,7 +36,9 @@ export const StyledDateCalendar = styled(DateCalendar, { '.MuiYearCalendar-root': { width: '260px', }, - marginLeft: '0px', + height: 'auto', + margin: 0, + marginRight: theme.spacingFunction(40), width: '260px', })); @@ -44,3 +46,23 @@ export const StyledTypography = styled(Typography)(() => ({ lineHeight: '20px', marginTop: '4px', })); + +export const StyledDateTimeStack = styled(Stack, { + label: 'StyledDateTimeStack', +})(({ theme }) => ({ + flexDirection: 'row', + [theme.breakpoints.down('md')]: { + flexDirection: 'column', + marginTop: theme.spacingFunction(24), + marginBottom: theme.spacingFunction(16), + }, +})); + +export const StyledRegionStack = styled(Stack, { label: 'StyledRegionStack' })( + ({ theme }) => ({ + [theme.breakpoints.down('md')]: { + marginTop: theme.spacingFunction(24), + marginBottom: theme.spacingFunction(16), + }, + }) +); diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.tsx index 8565492ed79..deae3d8f4da 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.tsx @@ -1,4 +1,5 @@ -import { useDatabaseQuery } from '@linode/queries'; +import { useDatabaseQuery, useRegionsQuery } from '@linode/queries'; +import { useIsGeckoEnabled } from '@linode/shared'; import { Box, Button, @@ -14,15 +15,19 @@ import { Radio, RadioGroup, } from '@mui/material'; -import { GridLegacy } from '@mui/material'; import { LocalizationProvider } from '@mui/x-date-pickers'; import { AdapterLuxon } from '@mui/x-date-pickers/AdapterLuxon'; import { useParams } from '@tanstack/react-router'; +import { useFlags } from 'launchdarkly-react-client-sdk'; import { DateTime } from 'luxon'; import * as React from 'react'; +import { Controller, FormProvider, useForm, useWatch } from 'react-hook-form'; +import { RegionSelect } from 'src/components/RegionSelect/RegionSelect'; import { StyledDateCalendar, + StyledDateTimeStack, + StyledRegionStack, StyledTypography, } from 'src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.style'; import { @@ -38,9 +43,10 @@ import { BACKUPS_UNABLE_TO_RESTORE_TEXT, } from '../../constants'; import { useDatabaseDetailContext } from '../DatabaseDetailContext'; -import DatabaseBackupsDialog from './DatabaseBackupsDialog'; +import { DatabaseBackupsDialog } from './DatabaseBackupsDialog'; import DatabaseBackupsLegacy from './legacy/DatabaseBackupsLegacy'; +import type { DatabaseBackupsPayload } from '@linode/api-v4'; import type { TimeValidationError } from '@mui/x-date-pickers'; export interface TimeOption { @@ -50,6 +56,11 @@ export interface TimeOption { export type VersionOption = 'dateTime' | 'newest'; +export interface DatabaseBackupsValues extends DatabaseBackupsPayload { + date: DateTime | null; + time: DateTime | null; +} + export const DatabaseBackups = () => { const { disabled } = useDatabaseDetailContext(); const { databaseId, engine } = useParams({ @@ -57,13 +68,17 @@ export const DatabaseBackups = () => { }); const { isDatabasesV2GA } = useIsDatabasesEnabled(); + const flags = useFlags(); + const { isGeckoLAEnabled } = useIsGeckoEnabled( + flags.gecko2?.enabled, + flags.gecko2?.la + ); + const { data: regionsData } = useRegionsQuery(); + const [isRestoreDialogOpen, setIsRestoreDialogOpen] = React.useState(false); - const [selectedDate, setSelectedDate] = React.useState(null); - const [selectedTime, setSelectedTime] = React.useState(null); const [versionOption, setVersionOption] = React.useState( isDatabasesV2GA ? 'newest' : 'dateTime' ); - const [timePickerError, setTimePickerError] = React.useState(''); const { data: database, @@ -81,10 +96,6 @@ export const DatabaseBackups = () => { ? BACKUPS_UNABLE_TO_RESTORE_TEXT : ''; - const onRestoreDatabase = () => { - setIsRestoreDialogOpen(true); - }; - /** * Check whether date and time are within the valid range of available backups by providing the selected date and time. * When the date and time selections are valid, clear any existing error messages for the time picker. @@ -98,48 +109,38 @@ export const DatabaseBackups = () => { ); if (!isSelectedTimeInvalid) { - setTimePickerError(''); + clearErrors('time'); } } }; const handleOnError = (error: TimeValidationError) => { - if (error) { - switch (error) { - case 'maxTime': - setTimePickerError(BACKUPS_MAX_TIME_EXCEEDED_VALIDATON_TEXT); - break; - case 'minTime': - setTimePickerError(BACKUPS_MIN_TIME_EXCEEDED_VALIDATON_TEXT); - break; - case 'invalidDate': - setSelectedTime(null); - setTimePickerError(BACKUPS_INVALID_TIME_VALIDATON_TEXT); - } + switch (error) { + case 'maxTime': + setError('time', { + message: BACKUPS_MAX_TIME_EXCEEDED_VALIDATON_TEXT, + }); + break; + case 'minTime': + setError('time', { + message: BACKUPS_MIN_TIME_EXCEEDED_VALIDATON_TEXT, + }); + break; + case 'invalidDate': + setValue('time', null); + setError('time', { message: BACKUPS_INVALID_TIME_VALIDATON_TEXT }); } }; - /** Stores changes to the year, month, and day of the DateTime object provided by the calendar */ - const handleDateChange = (newDate: DateTime) => { - validateDateTime(newDate, selectedTime); - setSelectedDate(newDate); - }; - - /** Stores changes to the hours, minutes, and seconds of the DateTime object provided by the time picker */ - const handleTimeChange = (newTime: DateTime | null) => { - validateDateTime(selectedDate, newTime); - setSelectedTime(newTime); - }; - const configureMinTime = () => { - const canApplyMinTime = !!oldestBackup && !!selectedDate; - const isOnMinDate = selectedDate?.day === oldestBackup?.day; + const canApplyMinTime = !!oldestBackup && !!date; + const isOnMinDate = date?.day === oldestBackup?.day; return canApplyMinTime && isOnMinDate ? oldestBackup : undefined; }; const configureMaxTime = () => { const today = DateTime.utc(); - const isOnMaxDate = today.day === selectedDate?.day; + const isOnMaxDate = today.day === date?.day; return isOnMaxDate ? today : undefined; }; @@ -148,12 +149,36 @@ export const DatabaseBackups = () => { value: VersionOption ) => { setVersionOption(value); - setSelectedDate(null); - // Resetting state used for time picker - setSelectedTime(null); - setTimePickerError(''); + setValue('date', null); + setValue('time', null); + clearErrors('time'); }; + const form = useForm({ + defaultValues: { + fork: { + source: database?.id, + restore_time: undefined, + }, + date: null, + time: null, + region: database?.region, + }, + }); + + const { + control, + setValue, + setError, + clearErrors, + formState: { errors }, + } = form; + + const [date, time, region] = useWatch({ + control, + name: ['date', 'time', 'region'], + }); + if (isDefaultDatabase) { return ( @@ -183,106 +208,130 @@ export const DatabaseBackups = () => { {unableToRestoreCopy && ( )} - {isDatabasesV2GA && ( - - } - data-qa-dbaas-radio="Newest" - disabled={disabled} - label="Newest full backup plus incremental" - value="newest" - /> - } - data-qa-dbaas-radio="DateTime" - disabled={disabled} - label="Specific date & time" - value="dateTime" - /> - - )} - - + +
+ {isDatabasesV2GA && ( + + } + data-qa-dbaas-radio="Newest" + disabled={disabled} + label="Newest full backup plus incremental" + value="newest" + /> + } + data-qa-dbaas-radio="DateTime" + disabled={disabled} + label="Specific date & time" + value="dateTime" + /> + + )} Date - - - isDateOutsideBackup(date, oldestBackup?.startOf('day')) - } - value={selectedDate} + + ( + + { + validateDateTime(newDate, time); + field.onChange(newDate); + }} + shouldDisableDate={(date) => + isDateOutsideBackup(date, oldestBackup?.startOf('day')) + } + value={field.value} + /> + + )} + /> + ( + + Time (UTC) + { + validateDateTime(date, newTime); + field.onChange(newTime); + }} + onError={handleOnError} + sx={{ + width: '220px', + }} + timeSteps={{ hours: 1, minutes: 1, seconds: 1 }} + value={field.value} + views={['hours', 'minutes', 'seconds']} + /> + + )} + /> + + + ( + field.onChange(region.id)} + regions={regionsData ?? []} + value={region ?? null} + /> + )} /> - - - - - Time (UTC) - + + + + {database && ( + setIsRestoreDialogOpen(false)} + open={isRestoreDialogOpen} /> - - - - - - - - - {database && ( - setIsRestoreDialogOpen(false)} - open={isRestoreDialogOpen} - selectedDate={selectedDate} - selectedTime={selectedTime} - /> - )} + )} + +
); } diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackupsDialog.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackupsDialog.tsx index 58e1875727a..f6112e22157 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackupsDialog.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackupsDialog.tsx @@ -4,34 +4,45 @@ 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'; import { toDatabaseFork, toFormattedDate } from '../../utilities'; +import type { DatabaseBackupsValues } from './DatabaseBackups'; import type { Database } from '@linode/api-v4/lib/databases'; import type { DialogProps } from '@linode/ui'; -import type { DateTime } from 'luxon'; interface Props extends Omit { database: Database; onClose: () => void; open: boolean; - selectedDate?: DateTime | null; - selectedTime?: DateTime | null; } -export const DatabaseBackupDialog = (props: Props) => { - const { database, onClose, open, selectedDate, selectedTime } = props; +export const DatabaseBackupsDialog = (props: Props) => { + const { database, onClose, open } = props; const navigate = useNavigate(); const { enqueueSnackbar } = useSnackbar(); const [isRestoring, setIsRestoring] = useState(false); - const formattedDate = toFormattedDate(selectedDate, selectedTime); + const { control } = useFormContext(); + const [date, time, region] = useWatch({ + control, + name: ['date', 'time', 'region'], + }); + + const formattedDate = toFormattedDate(date, time); const { error, mutateAsync: restore } = useRestoreFromBackupMutation( database.engine, - toDatabaseFork(database.id, selectedDate, selectedTime) + { + 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 handleRestoreDatabase = () => { @@ -51,6 +62,9 @@ export const DatabaseBackupDialog = (props: Props) => { }); }; + const isClusterWithVPCAndForkingToDifferentRegion = + database.private_network !== null && database.region !== region; + return ( { subtitle={formattedDate && `From ${formattedDate} (UTC)`} title={`Restore ${database.label}`} > + {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 + the cluster into a different region, it will not be assigned to a VPC + by default. If your workflow requires a VPC, go to the cluster’s + Networking tab after the restore is complete and assign the cluster to + a VPC. + + )} ({ marginBottom: theme.spacingFunction(32) })}> Restoring a backup creates a fork from this backup. If you proceed and the fork is created successfully, you should remove the original @@ -94,5 +117,3 @@ export const DatabaseBackupDialog = (props: Props) => { ); }; - -export default DatabaseBackupDialog; diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseManageNetworkingDrawer.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseManageNetworkingDrawer.tsx index c3d9bca2806..c35d5bdf251 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseManageNetworkingDrawer.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseManageNetworkingDrawer.tsx @@ -46,14 +46,16 @@ const DatabaseManageNetworkingDrawer = (props: Props) => { }); const { - formState: { isDirty, isValid }, + formState: { isDirty, isValid, errors }, handleSubmit, reset, + setError, watch, } = form; - const onSubmit = (values: ManageNetworkingFormValues) => { - updateDatabase(values).then(() => { + const onSubmit = async (values: ManageNetworkingFormValues) => { + try { + await updateDatabase(values); enqueueSnackbar('Changes are being applied.', { variant: 'info', }); @@ -65,7 +67,11 @@ const DatabaseManageNetworkingDrawer = (props: Props) => { databaseId: database.id, }, }); - }); + } catch (errors) { + for (const error of errors) { + setError(error?.field ?? 'root', { message: error.reason }); + } + } }; const [publicAccess, subnetId, vpcId] = watch([ @@ -84,7 +90,6 @@ const DatabaseManageNetworkingDrawer = (props: Props) => { const isSaveDisabled = !isDirty || !isValid || !hasValidSelection; const { - error: manageNetworkingError, isPending: submitInProgress, mutateAsync: updateDatabase, reset: resetMutation, @@ -105,8 +110,8 @@ const DatabaseManageNetworkingDrawer = (props: Props) => { return ( - {manageNetworkingError && ( - + {errors.root?.message && ( + )}
diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/DatabaseSettings.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/DatabaseSettings.tsx index 5b559edd92e..d9b8c332039 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/DatabaseSettings.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/DatabaseSettings.tsx @@ -27,7 +27,7 @@ import { DatabaseSettingsMaintenance } from './DatabaseSettingsMaintenance'; import DatabaseSettingsMenuItem from './DatabaseSettingsMenuItem'; import DatabaseSettingsResetPasswordDialog from './DatabaseSettingsResetPasswordDialog'; import { DatabaseSettingsSuspendClusterDialog } from './DatabaseSettingsSuspendClusterDialog'; -import MaintenanceWindow from './MaintenanceWindow'; +import { MaintenanceWindow } from './MaintenanceWindow'; export const DatabaseSettings = () => { const { database, disabled } = useDatabaseDetailContext(); diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/MaintenanceWindow.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/MaintenanceWindow.tsx index a40bfd06db4..9b7048eb6dc 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/MaintenanceWindow.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/MaintenanceWindow.tsx @@ -1,69 +1,31 @@ +import { yupResolver } from '@hookform/resolvers/yup'; import { useDatabaseMutation } from '@linode/queries'; import { Autocomplete, + Box, FormControl, FormControlLabel, + InputLabel, Notice, Radio, RadioGroup, + Stack, TooltipIcon, Typography, } from '@linode/ui'; -import { Button } from 'akamai-cds-react-components'; -import { useFormik } from 'formik'; +import { updateMaintenanceSchema } from '@linode/validation'; +import { styled } from '@mui/material/styles'; +import { Button, Select } from 'akamai-cds-react-components'; import { DateTime } from 'luxon'; import { useSnackbar } from 'notistack'; import * as React from 'react'; -import { makeStyles } from 'tss-react/mui'; +import { useWatch } from 'react-hook-form'; +import { Controller, FormProvider, useForm } from 'react-hook-form'; import { Link } from 'src/components/Link'; import type { Database, UpdatesSchedule } from '@linode/api-v4/lib/databases'; -import type { APIError } from '@linode/api-v4/lib/types'; import type { SelectOption } from '@linode/ui'; -import type { Theme } from '@mui/material/styles'; - -const useStyles = makeStyles()((theme: Theme) => ({ - formControlDropdown: { - '& label': { - overflow: 'visible', - }, - marginRight: '3rem', - minWidth: '125px', - }, - sectionButton: { - alignSelf: 'end', - marginBottom: '1rem', - marginTop: '1rem', - minWidth: 214, - [theme.breakpoints.down('md')]: { - alignSelf: 'flex-start', - }, - }, - sectionText: { - [theme.breakpoints.down('md')]: { - marginBottom: '1rem', - }, - [theme.breakpoints.down('sm')]: { - width: '100%', - }, - width: '65%', - }, - sectionTitle: { - marginBottom: '0.25rem', - }, - sectionTitleAndText: { - width: '100%', - }, - topSection: { - alignItems: 'center', - display: 'flex', - justifyContent: 'space-between', - [theme.breakpoints.down('lg')]: { - flexDirection: 'column', - }, - }, -})); interface Props { database: Database; @@ -74,17 +36,9 @@ interface Props { export const MaintenanceWindow = (props: Props) => { const { database, disabled, timezone } = props; - const [maintenanceUpdateError, setMaintenanceUpdateError] = - React.useState(); - - // This will be set to `true` once a form field has been touched. This is used to disable the - // "Save Changes" button unless there have been changes to the form. - const [formTouched, setFormTouched] = React.useState(false); - const [modifiedWeekSelectionMap, setModifiedWeekSelectionMap] = React.useState[]>([]); - const { classes } = useStyles(); const { enqueueSnackbar } = useSnackbar(); const { mutateAsync: updateDatabase } = useDatabaseMutation( @@ -116,32 +70,23 @@ export const MaintenanceWindow = (props: Props) => { weekSelectionModifier(dayOfWeek.label, weekSelectionMap); }, []); - const handleSaveMaintenanceWindow = ( - values: Omit, - { - setSubmitting, - }: { - setSubmitting: (isSubmitting: boolean) => void; - } - ) => { + const onSubmit = async (values: Partial) => { // @TODO Update this to only send 'updates' and not 'allow_list' when the API supports it. // Additionally, at that time, enable the validationSchema which currently does not work // because allow_list is a required field in the schema. - updateDatabase({ - allow_list: database.allow_list, - updates: values as UpdatesSchedule, - }) - .then(() => { - setSubmitting(false); - enqueueSnackbar('Maintenance Window settings saved successfully.', { - variant: 'success', - }); - setFormTouched(false); - }) - .catch((e: APIError[]) => { - setMaintenanceUpdateError(e); - setSubmitting(false); + try { + await updateDatabase({ + allow_list: database.allow_list, + updates: values as UpdatesSchedule, }); + enqueueSnackbar('Maintenance Window settings saved successfully.', { + variant: 'success', + }); + // reset dirty state to disable Save Changes button + reset(getValues(), { keepValues: true, keepDirty: false }); + } catch (errors) { + setError('root', { message: errors[0].reason }); + } }; const utcOffsetInHours = timezone @@ -155,17 +100,31 @@ export const MaintenanceWindow = (props: Props) => { return null; }; - const { errors, handleSubmit, isSubmitting, setFieldValue, touched, values } = - useFormik({ - initialValues: { - day_of_week: database.updates?.day_of_week ?? 1, - frequency: database.updates?.frequency ?? 'weekly', - hour_of_day: database.updates?.hour_of_day ?? 20, - week_of_month: getInitialWeekOfMonth(), - }, - // validationSchema: updateDatabaseSchema, - onSubmit: handleSaveMaintenanceWindow, - }); + const form = useForm>({ + defaultValues: { + day_of_week: database.updates?.day_of_week ?? 1, + frequency: database.updates?.frequency ?? 'weekly', + hour_of_day: database.updates?.hour_of_day ?? 20, + week_of_month: getInitialWeekOfMonth(), + }, + mode: 'onBlur', + resolver: yupResolver(updateMaintenanceSchema), + }); + + const { + control, + formState: { isSubmitting, isDirty, errors }, + getValues, + handleSubmit, + reset, + setValue, + setError, + } = form; + + const [dayOfWeek, hourOfDay, frequency, weekOfMonth] = useWatch({ + control, + name: ['day_of_week', 'hour_of_day', 'frequency', 'week_of_month'], + }); const isLegacy = database.platform === 'rdbms-legacy'; @@ -176,202 +135,233 @@ export const MaintenanceWindow = (props: Props) => { "OS and database engine updates will be performed on the schedule below. Select the frequency, day, and time you'd prefer maintenance to occur."; return ( - -
-
- - {isLegacy - ? 'Maintenance Window' - : 'Set a Weekly Maintenance Window'} - - {maintenanceUpdateError ? ( - - {maintenanceUpdateError[0].reason} - - ) : null} - - {isLegacy ? typographyLegacyDatabase : typographyDatabase}{' '} - {database.cluster_size !== 3 - ? 'For non-HA plans, expect downtime during this window.' - : null} - -
- - - option.value === value.value - } - label="Day of Week" - noMarginTop - onChange={(_, day) => { - setFormTouched(true); - setFieldValue('day_of_week', day.value); - weekSelectionModifier(day.label, weekSelectionMap); - // If week_of_month is not null (i.e., the user has selected a value for "Repeats on" already), - // refresh the field value so that the selected option displays the chosen day. - if (values.week_of_month) { - setFieldValue('week_of_month', values.week_of_month); - } - }} - options={daySelectionMap} - placeholder="Choose a day" - renderOption={(props, option) => ( -
  • {option.label}
  • - )} - textFieldProps={{ - dataAttrs: { - 'data-qa-weekday-select': true, - }, - }} - value={daySelectionMap.find( - (thisOption) => thisOption.value === values.day_of_week - )} - /> -
    - -
    - option.value === 20 - )} - disableClearable - disabled={disabled} - errorText={ - touched.hour_of_day ? errors.hour_of_day : undefined - } - label="Time" - noMarginTop - onChange={(_, hour) => { - setFormTouched(true); - setFieldValue('hour_of_day', hour?.value); - }} - options={hourSelectionMap} - placeholder="Choose a time" - renderOption={(props, option) => ( -
  • {option.label}
  • - )} - textFieldProps={{ - dataAttrs: { - 'data-qa-time-select': true, - }, - }} - value={hourSelectionMap.find( - (thisOption) => thisOption.value === values.hour_of_day + + + + + + {isLegacy + ? 'Maintenance Window' + : 'Set a Weekly Maintenance Window'} + + {errors.root?.message && ( + + {errors.root?.message} + + )} + + {isLegacy ? typographyLegacyDatabase : typographyDatabase}{' '} + {database.cluster_size !== 3 && + 'For non-HA plans, expect downtime during this window.'} + + + + ( + + + Day of Week + + + { + const hour: { label: string; value: number } = + e.detail; + field.onChange(hour?.value); + }} + placeholder="Choose a time" + selected={hourSelectionMap.find( + (thisOption) => thisOption.value === hourOfDay + )} + valueFn={(time: { label: string }) => + `${time.label}` + } + /> + + + UTC is {utcOffsetText(utcOffsetInHours)} hours + compared to your local timezone. Click{' '} + here to view or + change your timezone settings. + + } + /> + + + )} /> -
    -
    -
    - {isLegacy && ( - ) => { - setFormTouched(true); - setFieldValue('frequency', e.target.value); - if (e.target.value === 'weekly') { - // If the frequency is weekly, set the 'week_of_month' field to null since that should only be specified for a monthly frequency. - setFieldValue('week_of_month', null); - } + + + {isLegacy && ( + ( + ) => { + field.onChange(e.target.value); + if (e.target.value === 'weekly') { + // If the frequency is weekly, set the 'week_of_month' field to null since that should only be specified for a monthly frequency. + setValue('week_of_month', null); + } - if (e.target.value === 'monthly') { - const dayOfWeek = - daySelectionMap.find( - (option) => option.value === values.day_of_week - ) ?? daySelectionMap[0]; + if (e.target.value === 'monthly') { + const _dayOfWeek = + daySelectionMap.find( + (option) => option.value === dayOfWeek + ) ?? daySelectionMap[0]; - weekSelectionModifier(dayOfWeek.label, weekSelectionMap); - setFieldValue( - 'week_of_month', - modifiedWeekSelectionMap[0].value - ); - } - }} - > - - {maintenanceFrequencyMap.map((option) => ( - } - key={option.value} - label={option.key} - value={option.value} - /> - ))} - - - )} -
    - {values.frequency === 'monthly' ? ( - - { - setFormTouched(true); - setFieldValue('week_of_month', week?.value); - }} - options={modifiedWeekSelectionMap} - placeholder="Repeats on" - renderOption={(props, option) => ( -
  • {option.label}
  • - )} - textFieldProps={{ - dataAttrs: { - 'data-qa-week-in-month-select': true, - }, - }} - value={modifiedWeekSelectionMap.find( - (thisOption) => thisOption.value === values.week_of_month + weekSelectionModifier( + _dayOfWeek.label, + weekSelectionMap + ); + setValue( + 'week_of_month', + modifiedWeekSelectionMap[0].value + ); + } + }} + > + + {maintenanceFrequencyMap.map((option) => ( + } + key={option.value} + label={option.key} + value={option.value} + /> + ))} + +
    + )} + /> + )} +
    + {frequency === 'monthly' && ( + ( + + { + field.onChange(week.value); + }} + options={modifiedWeekSelectionMap} + placeholder="Repeats on" + renderOption={(props, option) => ( +
  • {option.label}
  • + )} + textFieldProps={{ + dataAttrs: { + 'data-qa-week-in-month-select': true, + }, + }} + value={modifiedWeekSelectionMap.find( + (thisOption) => thisOption.value === weekOfMonth + )} + /> +
    )} /> - - ) : null} -
    -
    - -
    - + )} +
    + + + + + + +
    ); }; @@ -436,4 +426,36 @@ const utcOffsetText = (utcOffsetInHours: number) => { : `-${utcOffsetInHours}`; }; -export default MaintenanceWindow; +const StyledTypography = styled(Typography, { + label: 'StyledTypography', +})(({ theme }) => ({ + [theme.breakpoints.down('md')]: { + marginBottom: '1rem', + }, + [theme.breakpoints.down('sm')]: { + width: '100%', + }, + width: '65%', +})); + +const StyledStack = styled(Stack, { + label: 'StyledStack', +})(({ theme }) => ({ + justifyContent: 'space-between', + flexDirection: 'row', + [theme.breakpoints.down('md')]: { + flexDirection: 'column', + }, +})); + +const StyledButtonStack = styled(Stack, { + label: 'StyledButtonStack', +})(({ theme }) => ({ + alignSelf: 'end', + marginBottom: '1rem', + marginTop: '1rem', + minWidth: 214, + [theme.breakpoints.down('md')]: { + alignSelf: 'flex-start', + }, +})); diff --git a/packages/manager/src/features/Delivery/DeliveryLanding.tsx b/packages/manager/src/features/Delivery/DeliveryLanding.tsx index b4ec8e7fb4b..a9dbf37dda2 100644 --- a/packages/manager/src/features/Delivery/DeliveryLanding.tsx +++ b/packages/manager/src/features/Delivery/DeliveryLanding.tsx @@ -30,6 +30,7 @@ export const DeliveryLanding = React.memo(() => { breadcrumbProps: { pathname: '/logs/delivery', }, + docsLink: 'https://techdocs.akamai.com/cloud-computing/docs/log-delivery', removeCrumbX: 1, entity: 'Delivery', title: 'Logs', // TODO: Change to "Delivery" all "Logs" occurrences after adding LogsLanding page 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 37074f7da94..5d98fe50105 100644 --- a/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationCreate.test.tsx +++ b/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationCreate.test.tsx @@ -5,6 +5,7 @@ import userEvent from '@testing-library/user-event'; import React from 'react'; import { describe, expect } from 'vitest'; +import { accountFactory } from 'src/factories'; import { http, HttpResponse, server } from 'src/mocks/testServer'; import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; @@ -46,9 +47,9 @@ describe('DestinationCreate', () => { const destinationNameInput = screen.getByLabelText('Destination Name'); await userEvent.type(destinationNameInput, 'Test'); const hostInput = screen.getByLabelText('Host'); - await userEvent.type(hostInput, 'Test'); + await userEvent.type(hostInput, 'test'); const bucketInput = screen.getByLabelText('Bucket'); - await userEvent.type(bucketInput, 'Test'); + await userEvent.type(bucketInput, 'test'); const accessKeyIDInput = screen.getByLabelText('Access Key ID'); await userEvent.type(accessKeyIDInput, 'Test'); const secretAccessKeyInput = screen.getByLabelText('Secret Access Key'); @@ -57,8 +58,8 @@ describe('DestinationCreate', () => { await userEvent.type(logPathPrefixInput, 'Test'); expect(destinationNameInput).toHaveValue('Test'); - expect(hostInput).toHaveValue('Test'); - expect(bucketInput).toHaveValue('Test'); + expect(hostInput).toHaveValue('test'); + expect(bucketInput).toHaveValue('test'); expect(accessKeyIDInput).toHaveValue('Test'); expect(secretAccessKeyInput).toHaveValue('Test'); expect(logPathPrefixInput).toHaveValue('Test'); @@ -66,11 +67,11 @@ describe('DestinationCreate', () => { ); it('should render Sample Destination Object Name and change its value according to Log Path Prefix input', async () => { - const profileUid = 123; + const accountEuuid = 'XYZ-123'; const [month, day, year] = new Date().toLocaleDateString().split('/'); server.use( - http.get('*/profile', () => { - return HttpResponse.json(profileFactory.build({ uid: profileUid })); + http.get('*/account', () => { + return HttpResponse.json(accountFactory.build({ euuid: accountEuuid })); }) ); @@ -79,7 +80,7 @@ describe('DestinationCreate', () => { let samplePath; await waitFor(() => { samplePath = screen.getByText( - `/audit_logs/com.akamai.audit.login/${profileUid}/${year}/${month}/${day}/akamai_log-000166-1756015362-319597.gz` + `/audit_logs/com.akamai.audit/${accountEuuid}/${year}/${month}/${day}/akamai_log-000166-1756015362-319597-login.gz` ); expect(samplePath).toBeInTheDocument(); }); @@ -89,19 +90,19 @@ describe('DestinationCreate', () => { 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.gz' + '/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.gz' + '/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.gz' + '/akamai_log-000166-1756015362-319597-login.gz' ); }); @@ -113,9 +114,9 @@ describe('DestinationCreate', () => { const destinationNameInput = screen.getByLabelText('Destination Name'); await userEvent.type(destinationNameInput, 'Test'); const hostInput = screen.getByLabelText('Host'); - await userEvent.type(hostInput, 'Test'); + await userEvent.type(hostInput, 'test'); const bucketInput = screen.getByLabelText('Bucket'); - await userEvent.type(bucketInput, 'Test'); + await userEvent.type(bucketInput, 'test'); const accessKeyIDInput = screen.getByLabelText('Access Key ID'); await userEvent.type(accessKeyIDInput, 'Test'); const secretAccessKeyInput = screen.getByLabelText('Secret Access Key'); diff --git a/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationCreate.tsx b/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationCreate.tsx index e5e64e0bf37..11796efa544 100644 --- a/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationCreate.tsx +++ b/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationCreate.tsx @@ -32,6 +32,7 @@ export const DestinationCreate = () => { }, ], }, + docsLink: 'https://techdocs.akamai.com/cloud-computing/docs/log-delivery', removeCrumbX: [1, 2], title: 'Create 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 561272ddc52..04e577c91f9 100644 --- a/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationEdit.test.tsx +++ b/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationEdit.test.tsx @@ -52,7 +52,7 @@ describe('DestinationEdit', () => { assertInputHasValue('Destination Name', 'Destination 123'); }); assertInputHasValue('Host', '3000'); - assertInputHasValue('Bucket', 'Bucket Name'); + assertInputHasValue('Bucket', 'destinations-bucket-name'); assertInputHasValue('Access Key ID', 'Access Id'); assertInputHasValue('Secret Access Key', ''); assertInputHasValue('Log Path Prefix', 'file'); diff --git a/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationEdit.tsx b/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationEdit.tsx index 32af3c9908a..6d30c3cb7a3 100644 --- a/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationEdit.tsx +++ b/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationEdit.tsx @@ -46,6 +46,7 @@ export const DestinationEdit = () => { }, ], }, + docsLink: 'https://techdocs.akamai.com/cloud-computing/docs/log-delivery', removeCrumbX: [1, 2], title: `Edit Destination ${destinationId}`, }; diff --git a/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationForm.tsx b/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationForm.tsx index 92bcc1bcbed..b03e7c93395 100644 --- a/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationForm.tsx +++ b/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationForm.tsx @@ -1,6 +1,6 @@ import { destinationType } from '@linode/api-v4'; import { Autocomplete, Paper, TextField } from '@linode/ui'; -import { capitalize } from '@linode/utilities'; +import { capitalize, scrollErrorIntoViewV2 } from '@linode/utilities'; import Grid from '@mui/material/Grid'; import * as React from 'react'; import { useEffect } from 'react'; @@ -35,6 +35,7 @@ export const DestinationForm = (props: DestinationFormProps) => { setDestinationVerified, } = useVerifyDestination(); + const formRef = React.useRef(null); const { control, handleSubmit } = useFormContext(); const destination = useWatch({ control, @@ -45,7 +46,7 @@ export const DestinationForm = (props: DestinationFormProps) => { }, [destination, setDestinationVerified]); return ( -
    + @@ -107,9 +108,12 @@ export const DestinationForm = (props: DestinationFormProps) => { isSubmitting={isSubmitting} isTesting={isVerifyingDestination} mode={mode} - onSubmit={handleSubmit(onSubmit)} - onTestConnection={handleSubmit(() => - verifyDestination(destination) + onSubmit={handleSubmit(onSubmit, () => + scrollErrorIntoViewV2(formRef) + )} + onTestConnection={handleSubmit( + () => verifyDestination(destination), + () => scrollErrorIntoViewV2(formRef) )} /> diff --git a/packages/manager/src/features/Delivery/Destinations/DestinationTableRow.tsx b/packages/manager/src/features/Delivery/Destinations/DestinationTableRow.tsx index 8ef54400dda..ec5e03ef2b0 100644 --- a/packages/manager/src/features/Delivery/Destinations/DestinationTableRow.tsx +++ b/packages/manager/src/features/Delivery/Destinations/DestinationTableRow.tsx @@ -2,11 +2,11 @@ import { Hidden } from '@linode/ui'; import * as React from 'react'; import { DateTimeDisplay } from 'src/components/DateTimeDisplay'; -import { Link } from 'src/components/Link'; import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; import { getDestinationTypeOption } from 'src/features/Delivery/deliveryUtils'; import { DestinationActionMenu } from 'src/features/Delivery/Destinations/DestinationActionMenu'; +import { LinkWithTooltipAndEllipsis } from 'src/features/Delivery/Shared/LinkWithTooltipAndEllipsis'; import type { Destination } from '@linode/api-v4'; import type { DestinationHandlers } from 'src/features/Delivery/Destinations/DestinationActionMenu'; @@ -23,18 +23,18 @@ export const DestinationTableRow = React.memo( return ( - {destination.label} - + {getDestinationTypeOption(destination.type)?.label} {id} - + @@ -42,6 +42,9 @@ export const DestinationTableRow = React.memo( + + {destination.updated_by} + @@ -188,7 +196,7 @@ export const DestinationsLanding = () => { {...handlers} /> ))} - {destinations?.results === 0 && } + {destinations?.results === 0 && } { /> )} - + ); }; diff --git a/packages/manager/src/features/Delivery/Destinations/DestinationsLandingEmptyStateData.ts b/packages/manager/src/features/Delivery/Destinations/DestinationsLandingEmptyStateData.ts index d42329e9063..0a404921f66 100644 --- a/packages/manager/src/features/Delivery/Destinations/DestinationsLandingEmptyStateData.ts +++ b/packages/manager/src/features/Delivery/Destinations/DestinationsLandingEmptyStateData.ts @@ -1,3 +1,8 @@ +import { + docsLink, + guidesMoreLinkText, +} from 'src/utilities/emptyStateLandingUtils'; + import type { ResourcesHeaders, ResourcesLinks, @@ -16,10 +21,15 @@ export const linkAnalyticsEvent: ResourcesLinks['linkAnalyticsEvent'] = { }; export const gettingStartedGuides: ResourcesLinkSection = { - links: [], + links: [ + { + text: 'Getting started guide', + to: 'https://techdocs.akamai.com/cloud-computing/docs/log-delivery', + }, + ], moreInfo: { - text: '', - to: '', + text: guidesMoreLinkText, + to: docsLink, }, - title: '', + title: 'Getting Started Guides', }; diff --git a/packages/manager/src/features/Delivery/Shared/DeliveryTabHeader/DeliveryTabHeader.tsx b/packages/manager/src/features/Delivery/Shared/DeliveryTabHeader/DeliveryTabHeader.tsx index 790cf51fdb3..ddcb23fbca0 100644 --- a/packages/manager/src/features/Delivery/Shared/DeliveryTabHeader/DeliveryTabHeader.tsx +++ b/packages/manager/src/features/Delivery/Shared/DeliveryTabHeader/DeliveryTabHeader.tsx @@ -31,7 +31,7 @@ export const DeliveryTabHeader = ({ entity, onButtonClick, spacingBottom = 24, - isSearching, + isSearching = false, selectList, onSelect, selectValue, diff --git a/packages/manager/src/features/Delivery/Shared/FormSubmitBar/FormSubmitBar.tsx b/packages/manager/src/features/Delivery/Shared/FormSubmitBar/FormSubmitBar.tsx index 4ca05df6df1..640d6093ce5 100644 --- a/packages/manager/src/features/Delivery/Shared/FormSubmitBar/FormSubmitBar.tsx +++ b/packages/manager/src/features/Delivery/Shared/FormSubmitBar/FormSubmitBar.tsx @@ -1,5 +1,6 @@ import { Box, Button, Divider, Paper, Stack, Typography } from '@linode/ui'; import { capitalize } from '@linode/utilities'; +import { Link } from '@mui/material'; import * as React from 'react'; import { useMemo } from 'react'; @@ -64,38 +65,69 @@ export const FormSubmitBar = (props: StreamFormSubmitBarProps) => { )} - - Stream provisioning may take up to 45 minutes. - - - + + + + {formType === 'stream' && ( + + By using this service, you acknowledge your obligations under the + United States Department of Justice Bulk Sensitive Data Transaction + Rule ("BSD Rule"). You also agree that you will not use the + service to transfer, onward transfer, or otherwise make accessible + any United States government-related data or bulk United States + sensitive personal data to countries of concern or a covered person, + as each of those terms and concepts are defined in the{' '} + + BSD Rule + + . Anyone using the service is solely responsible for compliance with + the BSD Rule. + + )} ); diff --git a/packages/manager/src/features/Delivery/Shared/LabelValue.tsx b/packages/manager/src/features/Delivery/Shared/LabelValue.tsx index da41143ffc3..87bfb5323e2 100644 --- a/packages/manager/src/features/Delivery/Shared/LabelValue.tsx +++ b/packages/manager/src/features/Delivery/Shared/LabelValue.tsx @@ -1,5 +1,6 @@ -import { Box, Typography } from '@linode/ui'; +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'; interface LabelValueProps { @@ -7,6 +8,7 @@ interface LabelValueProps { compact?: boolean; 'data-testid'?: string; label: string; + smHideTooltip?: boolean; value: string; } @@ -17,8 +19,10 @@ export const LabelValue = (props: LabelValueProps) => { value, 'data-testid': dataTestId, children, + smHideTooltip, } = props; const theme = useTheme(); + const matchesSmDown = useMediaQuery(theme.breakpoints.down('sm')); return ( { > {label}: - {value} + + {value} + {children} ); }; -const StyledValue = styled(Box, { +const StyledValue = styled(Tooltip, { label: 'StyledValue', })(({ theme }) => ({ alignItems: 'center', @@ -51,4 +60,12 @@ const StyledValue = styled(Box, { 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), + }, })); diff --git a/packages/manager/src/features/Delivery/Shared/LinkWithTooltipAndEllipsis.tsx b/packages/manager/src/features/Delivery/Shared/LinkWithTooltipAndEllipsis.tsx new file mode 100644 index 00000000000..3e7f96dc1c0 --- /dev/null +++ b/packages/manager/src/features/Delivery/Shared/LinkWithTooltipAndEllipsis.tsx @@ -0,0 +1,58 @@ +import { Tooltip } from '@linode/ui'; +import { styled } from '@mui/material/styles'; +import * as React from 'react'; +import { useEffect, useRef, useState } from 'react'; + +import { Link } from 'src/components/Link'; + +const StyledLink = styled(Link, { label: 'StyledLink' })(({ theme }) => ({ + overflow: 'hidden', + textOverflow: 'ellipsis', + display: 'inline-block', + maxWidth: 350, + [theme.breakpoints.down('lg')]: { + maxWidth: 200, + }, + [theme.breakpoints.down('sm')]: { + maxWidth: 120, + }, + whiteSpace: 'nowrap', +})); + +interface EllipsisLinkWithTooltipProps { + children: string; + className?: string; + pendoId?: string; + style?: React.CSSProperties; + to: string; +} + +export const LinkWithTooltipAndEllipsis = ( + props: EllipsisLinkWithTooltipProps +) => { + const { to, children, pendoId, className, style } = props; + + const linkRef = useRef(null); + const [showTooltip, setShowTooltip] = useState(false); + + useEffect(() => { + const linkElement = linkRef.current; + if (linkElement) { + setShowTooltip(linkElement.scrollWidth > linkElement.clientWidth); + } + }, [children]); + + return ( + + + {children} + + + ); +}; diff --git a/packages/manager/src/features/Delivery/Shared/PathSample.tsx b/packages/manager/src/features/Delivery/Shared/PathSample.tsx index 3b594da1baa..85ee27521f4 100644 --- a/packages/manager/src/features/Delivery/Shared/PathSample.tsx +++ b/packages/manager/src/features/Delivery/Shared/PathSample.tsx @@ -1,5 +1,5 @@ import { streamType, type StreamType } from '@linode/api-v4'; -import { useProfile } from '@linode/queries'; +import { useAccount } from '@linode/queries'; import { Box, InputLabel, Stack, TooltipIcon, Typography } from '@linode/ui'; import { styled } from '@mui/material/styles'; import * as React from 'react'; @@ -15,8 +15,8 @@ const sxTooltipIcon = { }; const logType = { - [streamType.LKEAuditLogs]: 'com.akamai.audit.k8s', - [streamType.AuditLogs]: 'com.akamai.audit.login', + [streamType.LKEAuditLogs]: 'k8s', + [streamType.AuditLogs]: 'login', }; interface PathSampleProps { @@ -25,10 +25,9 @@ interface PathSampleProps { export const PathSample = (props: PathSampleProps) => { const { value } = props; - const fileName = 'akamai_log-000166-1756015362-319597.gz'; const sampleClusterId = useMemo( // eslint-disable-next-line sonarjs/pseudo-random - () => Math.floor(Math.random() * 90000) + 10000, + () => `lke${Math.floor(Math.random() * 90000) + 10000}`, [] ); @@ -43,7 +42,7 @@ export const PathSample = (props: PathSampleProps) => { name: 'stream.details.cluster_ids[0]', }); - const { data: profile } = useProfile(); + const { data: account } = useAccount(); const [month, day, year] = new Date().toLocaleDateString('en-US').split('/'); const setStreamType = (): StreamType => { @@ -51,6 +50,7 @@ export const PathSample = (props: PathSampleProps) => { }; const streamTypeValue = useMemo(setStreamType, [streamTypeFormValue]); + const fileName = `akamai_log-000166-1756015362-319597-${logType[streamTypeValue]}.gz`; const createSamplePath = (): string => { let partition = ''; @@ -59,11 +59,11 @@ export const PathSample = (props: PathSampleProps) => { partition = `${clusterId ?? sampleClusterId}/`; } - return `/${streamTypeValue}/${logType[streamTypeValue]}/${profile?.uid}/${partition}${year}/${month}/${day}`; + return `/${streamTypeValue}/com.akamai.audit/${account?.euuid}/${partition}${year}/${month}/${day}`; }; const defaultPath = useMemo(createSamplePath, [ - profile, + account, streamTypeValue, clusterId, ]); @@ -110,4 +110,5 @@ const StyledValue = styled('span', { label: 'StyledValue' })(({ theme }) => ({ minHeight: 34, padding: theme.spacingFunction(8), overflowWrap: 'anywhere', + wordBreak: 'break-all', })); diff --git a/packages/manager/src/features/Delivery/Shared/types.ts b/packages/manager/src/features/Delivery/Shared/types.ts index 8c447865f22..205e44573bd 100644 --- a/packages/manager/src/features/Delivery/Shared/types.ts +++ b/packages/manager/src/features/Delivery/Shared/types.ts @@ -33,7 +33,7 @@ export const streamTypeOptions: AutocompleteOption[] = [ }, { value: streamType.LKEAuditLogs, - label: 'Kubernetes Audit Logs', + label: 'Kubernetes API Audit Logs', }, ]; diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/CheckoutBar/StreamFormCheckoutBar.test.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/CheckoutBar/StreamFormCheckoutBar.test.tsx index 0d1da0680ce..fcd91ae3c84 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/CheckoutBar/StreamFormCheckoutBar.test.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/CheckoutBar/StreamFormCheckoutBar.test.tsx @@ -87,10 +87,10 @@ describe.skip('StreamFormCheckoutBar', () => { // change form type value await userEvent.click(streamTypesAutocomplete); - const kubernetesAuditLogs = await screen.findByText( - 'Kubernetes Audit Logs' + const kubernetesApiAuditLogs = await screen.findByText( + 'Kubernetes API Audit Logs' ); - await userEvent.click(kubernetesAuditLogs); + await userEvent.click(kubernetesApiAuditLogs); expect(getDeliveryPriceContext()).not.toEqual(initialPrice); }); 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 315d63f3c7c..fde6a031dd8 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 @@ -1,5 +1,6 @@ import { screen, + waitFor, waitForElementToBeRemoved, within, } from '@testing-library/react'; @@ -14,10 +15,6 @@ import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; import { StreamFormClusters } from './StreamFormClusters'; -const queryMocks = vi.hoisted(() => ({ - useOrderV2: vi.fn().mockReturnValue({}), -})); - const loadingTestId = 'circle-progress'; const testClustersDetails = [ { @@ -118,6 +115,48 @@ describe('StreamFormClusters', () => { ]); }); + it('should filter clusters by name', async () => { + await renderComponentWithoutSelectedClusters(); + const input = screen.getByPlaceholderText('Search'); + + // Type test value inside the search + await userEvent.click(input); + await userEvent.type(input, 'metrics'); + + await waitFor(() => + expect(getColumnsValuesFromTable()).toEqual(['metrics-stream-cluster']) + ); + }); + + it('should filter clusters by region', async () => { + await renderComponentWithoutSelectedClusters(); + const input = screen.getByPlaceholderText('Search'); + + // Type test value inside the search + await userEvent.click(input); + await userEvent.type(input, 'US,'); + + await waitFor(() => + expect(getColumnsValuesFromTable(2)).toEqual([ + 'US, Atalanta, GA', + 'US, Chicago, IL', + ]) + ); + }); + + it('should filter clusters by log generation status', async () => { + await renderComponentWithoutSelectedClusters(); + const input = screen.getByPlaceholderText('Search'); + + // Type test value inside the search + await userEvent.click(input); + await userEvent.type(input, 'enabled'); + + await waitFor(() => + expect(getColumnsValuesFromTable(3)).toEqual(['Enabled', 'Enabled']) + ); + }); + it('should toggle clusters checkboxes and header checkbox', async () => { await renderComponentWithoutSelectedClusters(); const table = screen.getByRole('table'); @@ -211,6 +250,56 @@ describe('StreamFormClusters', () => { expect(metricsStreamCheckbox).toBeChecked(); expect(prodClusterCheckbox).not.toBeChecked(); }); + + describe('and some of them are no longer eligible for log delivery', () => { + it('should remove non-eligible clusters and render table with properly selected clusters', async () => { + const modifiedClusters = clusters.map((cluster) => + cluster.id === 3 + ? { ...cluster, control_plane: { audit_logs_enabled: false } } + : cluster + ); + server.use( + http.get('*/lke/clusters', () => { + return HttpResponse.json(makeResourcePage(modifiedClusters)); + }) + ); + + renderWithThemeAndHookFormContext({ + component: , + useFormOptions: { + defaultValues: { + stream: { + details: { + cluster_ids: [2, 3], + is_auto_add_all_clusters_enabled: false, + }, + }, + }, + }, + }); + + const loadingElement = screen.queryByTestId(loadingTestId); + expect(loadingElement).toBeInTheDocument(); + await waitForElementToBeRemoved(loadingElement); + + const table = screen.getByRole('table'); + const headerCheckbox = within(table).getAllByRole('checkbox')[0]; + const gkeProdCheckbox = getCheckboxByClusterName( + 'gke-prod-europe-west1' + ); + const metricsStreamCheckbox = getCheckboxByClusterName( + 'metrics-stream-cluster' + ); + const prodClusterCheckbox = getCheckboxByClusterName('prod-cluster-eu'); + + await waitFor(() => { + expectCheckboxStateToBe(headerCheckbox, 'checked'); + }); + expect(gkeProdCheckbox).not.toBeChecked(); + expect(metricsStreamCheckbox).toBeChecked(); + expect(prodClusterCheckbox).not.toBeChecked(); + }); + }); }); it('should disable all table checkboxes if "Automatically include all" checkbox is selected', async () => { @@ -285,13 +374,6 @@ describe('StreamFormClusters', () => { expect(metricsStreamCheckbox).not.toBeChecked(); expect(prodClusterCheckbox).toBeChecked(); - // Sort by Cluster Name descending - queryMocks.useOrderV2.mockReturnValue({ - order: 'desc', - orderBy: 'label', - sortedData: clusters.reverse(), - }); - await userEvent.click(sortHeader); expect(gkeProdCheckbox).not.toBeChecked(); expect(metricsStreamCheckbox).not.toBeChecked(); 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 e4c828ad92f..c2d69760dd8 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClusters.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClusters.tsx @@ -1,4 +1,5 @@ -import { getAPIFilterFromQuery } from '@linode/search'; +import { useRegionsQuery } from '@linode/queries'; +import { useIsGeckoEnabled } from '@linode/shared'; import { Box, Checkbox, @@ -9,19 +10,27 @@ import { Typography, } from '@linode/ui'; import { capitalize } from '@linode/utilities'; -import React, { useEffect, useState } from 'react'; +import Grid from '@mui/material/Grid'; +import { styled, type Theme } from '@mui/material/styles'; +import useMediaQuery from '@mui/material/useMediaQuery'; +import { useFlags } from 'launchdarkly-react-client-sdk'; +import { enqueueSnackbar } from 'notistack'; +import React, { useEffect, useMemo, useState } from 'react'; import { useWatch } from 'react-hook-form'; import { Controller, useFormContext } from 'react-hook-form'; import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField'; +import { sortData } from 'src/components/OrderBy'; import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter'; import { MIN_PAGE_SIZE } from 'src/components/PaginationFooter/PaginationFooter.constants'; +import { RegionSelect } from 'src/components/RegionSelect/RegionSelect'; import { Table } from 'src/components/Table'; -import { StreamFormClusterTableContent } from 'src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClustersTable'; -import { useKubernetesClustersQuery } from 'src/queries/kubernetes'; +import { StreamFormClusterTableContent } from 'src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClustersTableContent'; +import { useAllKubernetesClustersQuery } from 'src/queries/kubernetes'; +import type { KubernetesCluster } from '@linode/api-v4'; import type { FormMode } from 'src/features/Delivery/Shared/types'; -import type { OrderByKeys } from 'src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClustersTable'; +import type { OrderByKeys } from 'src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClustersTableContent'; import type { StreamAndDestinationFormType } from 'src/features/Delivery/Streams/StreamForm/types'; const controlPaths = { @@ -39,50 +48,80 @@ export const StreamFormClusters = (props: StreamFormClustersProps) => { const { control, setValue, formState, trigger } = useFormContext(); + const xsDown = useMediaQuery((theme: Theme) => theme.breakpoints.down('sm')); + const { gecko2 } = useFlags(); + const { isGeckoLAEnabled } = useIsGeckoEnabled(gecko2?.enabled, gecko2?.la); + const { data: regions } = useRegionsQuery(); + const [order, setOrder] = useState<'asc' | 'desc'>('asc'); const [orderBy, setOrderBy] = useState('label'); const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(MIN_PAGE_SIZE); const [searchText, setSearchText] = useState(''); - - const { error: searchParseError, filter: searchFilter } = - getAPIFilterFromQuery(searchText, { - searchableFieldsWithoutOperator: ['label', 'region'], - }); - - const filter = { - ['+order']: order, - ['+order_by']: orderBy, - ...searchFilter, - }; + const [regionFilter, setRegionFilter] = useState(''); const { - data: clusters, + data: clusters = [], isLoading, error, - } = useKubernetesClustersQuery({ - filter, - params: { - page, - page_size: pageSize, - }, - }); + } = useAllKubernetesClustersQuery({ enabled: true }); - const idsWithLogsEnabled = clusters?.data - .filter((cluster) => cluster.control_plane.audit_logs_enabled) - .map(({ id }) => id); + const clusterIdsWithLogsEnabled = useMemo( + () => + clusters + ?.filter((cluster) => cluster.control_plane.audit_logs_enabled) + .map(({ id }) => id), + [clusters] + ); const [isAutoAddAllClustersEnabled, clusterIds] = useWatch({ control, name: [controlPaths.isAutoAddAllClustersEnabled, controlPaths.clusterIds], }); + const areArraysDifferent = (a: number[], b: number[]) => { + if (a.length !== b.length) { + return true; + } + + const setB = new Set(b); + + return !a.every((element) => setB.has(element)); + }; + + // Check for clusters that no longer have log generation enabled and remove them from cluster_ids useEffect(() => { - setValue( - controlPaths.clusterIds, - isAutoAddAllClustersEnabled ? idsWithLogsEnabled : clusterIds || [] - ); - }, [isLoading]); + if (!isLoading) { + const selectedClusterIds = clusterIds ?? []; + const filteredClusterIds = selectedClusterIds.filter((id) => + clusterIdsWithLogsEnabled.includes(id) + ); + + const nextValue = + (isAutoAddAllClustersEnabled + ? clusterIdsWithLogsEnabled + : filteredClusterIds) || []; + + if ( + !isAutoAddAllClustersEnabled && + areArraysDifferent(selectedClusterIds, filteredClusterIds) + ) { + enqueueSnackbar( + 'One or more clusters were removed from the selection because Log Generation is no longer enabled on them.', + { variant: 'info' } + ); + } + if (areArraysDifferent(selectedClusterIds, nextValue)) { + setValue(controlPaths.clusterIds, nextValue); + } + } + }, [ + isLoading, + clusterIds, + isAutoAddAllClustersEnabled, + setValue, + clusterIdsWithLogsEnabled, + ]); const handleOrderChange = (newOrderBy: OrderByKeys) => { if (orderBy === newOrderBy) { @@ -93,6 +132,56 @@ export const StreamFormClusters = (props: StreamFormClustersProps) => { } }; + const filteredClusters = + !searchText && !regionFilter + ? clusters + : clusters.filter((cluster) => { + const lowerSearch = searchText.toLowerCase(); + + let result = true; + + if (searchText) { + result = + cluster.label.toLowerCase().includes(lowerSearch) || + cluster.region.toLowerCase().includes(lowerSearch) || + (cluster.control_plane.audit_logs_enabled + ? 'enabled' + : 'disabled' + ).includes(lowerSearch); + } + + if (result && regionFilter) { + return cluster.region === regionFilter; + } + + return result; + }); + + const sortedAndFilteredClusters = sortData( + orderBy, + order + )(filteredClusters); + + // Paginate clusters + const indexOfFirstClusterInPage = (page - 1) * pageSize; + const indexOfLastClusterInPage = indexOfFirstClusterInPage + pageSize; + const paginatedClusters = sortedAndFilteredClusters.slice( + indexOfFirstClusterInPage, + indexOfLastClusterInPage + ); + + // If the current page is out of range after filtering, change to the last available page + useEffect(() => { + if (indexOfFirstClusterInPage >= sortedAndFilteredClusters.length) { + const lastPage = Math.max( + 1, + Math.ceil(sortedAndFilteredClusters.length / pageSize) + ); + + setPage(lastPage); + } + }, [sortedAndFilteredClusters, indexOfFirstClusterInPage, pageSize]); + return ( Clusters @@ -120,7 +209,10 @@ export const StreamFormClusters = (props: StreamFormClustersProps) => { onChange={async (_, checked) => { field.onChange(checked); if (checked) { - setValue(controlPaths.clusterIds, idsWithLogsEnabled); + setValue( + controlPaths.clusterIds, + clusterIdsWithLogsEnabled + ); } else { setValue(controlPaths.clusterIds, []); } @@ -132,25 +224,49 @@ export const StreamFormClusters = (props: StreamFormClustersProps) => { )} /> - setSearchText(value)} - placeholder="Search" - value={searchText} - /> + > + setSearchText(value)} + placeholder="Search" + value={searchText} + /> + { + setRegionFilter(region?.id ?? ''); + }} + regionFilter="core" + regions={regions ?? []} + sx={{ + width: '280px !important', + }} + value={regionFilter} + /> + {!isAutoAddAllClustersEnabled && formState.errors.stream?.details?.cluster_ids?.message && ( @@ -165,9 +281,9 @@ export const StreamFormClusters = (props: StreamFormClustersProps) => { name={controlPaths.clusterIds} render={({ field }) => ( { /> { ); }; + +const StyledGrid = styled(Grid)(({ theme }) => ({ + '& .MuiAutocomplete-root > .MuiBox-root': { + display: 'flex', + + '& > .MuiBox-root': { + margin: '0', + + '& > .MuiInputLabel-root': { + margin: 0, + marginRight: theme.spacingFunction(12), + }, + }, + }, +})); diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClustersTable.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClustersTableContent.tsx similarity index 91% rename from packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClustersTable.tsx rename to packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClustersTableContent.tsx index 85d327d72cd..5fad7cd8d3d 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClustersTable.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClustersTableContent.tsx @@ -10,13 +10,13 @@ import { TableRow } from 'src/components/TableRow'; import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; import { TableSortCell } from 'src/components/TableSortCell'; -import type { KubernetesCluster, ResourcePage } from '@linode/api-v4'; +import type { KubernetesCluster } from '@linode/api-v4'; import type { StreamAndDestinationFormType } from 'src/features/Delivery/Streams/StreamForm/types'; export type OrderByKeys = 'label' | 'region'; interface StreamFormClusterTableContentProps { - clusters: ResourcePage | undefined; + clusters: KubernetesCluster[] | undefined; field: ControllerRenderProps< StreamAndDestinationFormType, 'stream.details.cluster_ids' @@ -40,7 +40,7 @@ export const StreamFormClusterTableContent = ({ const selectedIds = field.value || []; const isAllSelected = - selectedIds.length === (idsWithLogsEnabled?.length ?? 0); + selectedIds.length > 0 && selectedIds.length === idsWithLogsEnabled?.length; const isIndeterminate = selectedIds.length > 0 && !isAllSelected; const toggleAllClusters = () => { @@ -62,11 +62,13 @@ export const StreamFormClusterTableContent = ({ - {!!clusters?.results && ( + {!!clusters && ( - {clusters?.results ? ( - clusters.data.map( + {clusters?.length ? ( + clusters.map( ({ label, region, diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/DestinationAkamaiObjectStorageDetailsSummary.test.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/DestinationAkamaiObjectStorageDetailsSummary.test.tsx index 930cc8aae30..968e97568f3 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/DestinationAkamaiObjectStorageDetailsSummary.test.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/DestinationAkamaiObjectStorageDetailsSummary.test.tsx @@ -37,7 +37,7 @@ describe('DestinationAkamaiObjectStorageDetailsSummary', () => { ); }); - it('renders info icon next to path when it is empty', async () => { + it('does not render log path when it is empty', async () => { const details = { bucket_name: 'test bucket', host: 'test host', @@ -49,6 +49,6 @@ describe('DestinationAkamaiObjectStorageDetailsSummary', () => { ); // Log Path info icon: - expect(screen.getByTestId('tooltip-info-icon')).toBeVisible(); + expect(screen.queryByText('tooltip-info-icon')).not.toBeInTheDocument(); }); }); 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 d9b1eba72ae..cd016649a83 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/DestinationAkamaiObjectStorageDetailsSummary.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/DestinationAkamaiObjectStorageDetailsSummary.tsx @@ -1,17 +1,9 @@ -import { streamType } from '@linode/api-v4'; -import { Stack, TooltipIcon, Typography } from '@linode/ui'; import React from 'react'; -import { getStreamTypeOption } from 'src/features/Delivery/deliveryUtils'; import { LabelValue } from 'src/features/Delivery/Shared/LabelValue'; import type { AkamaiObjectStorageDetails } from '@linode/api-v4'; -const sxTooltipIcon = { - marginLeft: '4px', - padding: '0px', -}; - export const DestinationAkamaiObjectStorageDetailsSummary = ( props: AkamaiObjectStorageDetails ) => { @@ -24,28 +16,16 @@ export const DestinationAkamaiObjectStorageDetailsSummary = ( - - {!path && ( - - Default paths: - {`${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/}`} - - } - /> - )} - + {!!path && } ); }; 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 13c18086d6e..656b8b70792 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 @@ -158,9 +158,9 @@ describe('StreamFormDelivery', () => { // Type the test value inside the input const bucketInput = screen.getByLabelText('Bucket'); - await userEvent.type(bucketInput, 'Test'); + await userEvent.type(bucketInput, 'test'); - expect(bucketInput.getAttribute('value')).toEqual('Test'); + expect(bucketInput.getAttribute('value')).toEqual('test'); }); it('should render Access Key ID input after adding a new destination name and allow to type text', async () => { 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 8dfe9f7c400..8a00438d52d 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/StreamFormDelivery.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/StreamFormDelivery.tsx @@ -6,6 +6,7 @@ import { CircleProgress, ErrorState, Paper, + Stack, Typography, } from '@linode/ui'; import { capitalize } from '@linode/utilities'; @@ -176,13 +177,39 @@ export const StreamFormDelivery = (props: StreamFormDeliveryProps) => { const { id, ...optionProps } = props; return (
  • - {option.create ? ( - <> - Create  "{option.label}" - - ) : ( - option.label - )} + + + + {option.create ? ( + + Create  "{option.label} + " + + ) : ( + option.label + )} + + {option.id && ( + + ID: {option.id} + + )} + +
  • ); }} 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 fddc01e4afc..4f329004c26 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamCreate.test.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamCreate.test.tsx @@ -56,9 +56,9 @@ describe('StreamCreate', () => { await waitFor(() => { expect(hostInput).toBeDefined(); }); - await userEvent.type(hostInput, 'Test'); + await userEvent.type(hostInput, 'test'); const bucketInput = screen.getByLabelText('Bucket'); - await userEvent.type(bucketInput, 'Test'); + await userEvent.type(bucketInput, 'test'); const accessKeyIDInput = screen.getByLabelText('Access Key ID'); await userEvent.type(accessKeyIDInput, 'Test'); const secretAccessKeyInput = screen.getByLabelText('Secret Access Key'); diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamCreate.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamCreate.tsx index 2b13c9a0655..6aa64e6aebd 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamCreate.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamCreate.tsx @@ -25,6 +25,7 @@ export const StreamCreate = () => { }, ], }, + docsLink: 'https://techdocs.akamai.com/cloud-computing/docs/log-delivery', removeCrumbX: [1, 2], title: 'Create Stream', }; 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 580b5590109..0b924f69c19 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamEdit.test.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamEdit.test.tsx @@ -65,7 +65,7 @@ describe('StreamEdit', () => { // Host: expect(screen.getByText('3000')).toBeVisible(); // Bucket: - expect(screen.getByText('Bucket Name')).toBeVisible(); + expect(screen.getByText('destinations-bucket-name')).toBeVisible(); // Access Key ID: expect(screen.getByTestId('access-key-id')).toHaveTextContent( '*****************' @@ -97,9 +97,9 @@ describe('StreamEdit', () => { await waitFor(() => { expect(hostInput).toBeDefined(); }); - await userEvent.type(hostInput, 'Test'); + await userEvent.type(hostInput, 'test'); const bucketInput = screen.getByLabelText('Bucket'); - await userEvent.type(bucketInput, 'Test'); + await userEvent.type(bucketInput, 'test'); const accessKeyIDInput = screen.getByLabelText('Access Key ID'); await userEvent.type(accessKeyIDInput, 'Test'); const secretAccessKeyInput = screen.getByLabelText('Secret Access Key'); diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamEdit.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamEdit.tsx index 57eeaaf2e8b..ccdb5bf11ce 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamEdit.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamEdit.tsx @@ -43,6 +43,7 @@ export const StreamEdit = () => { }, ], }, + docsLink: 'https://techdocs.akamai.com/cloud-computing/docs/log-delivery', removeCrumbX: [1, 2], title: `Edit Stream ${streamId}`, }; diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamForm.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamForm.tsx index 62ebd97048c..6ac55bb6ff8 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamForm.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamForm.tsx @@ -9,6 +9,7 @@ import { useUpdateStreamMutation, } from '@linode/queries'; import { Stack } from '@linode/ui'; +import { scrollErrorIntoViewV2 } from '@linode/utilities'; import Grid from '@mui/material/Grid'; import { useNavigate } from '@tanstack/react-router'; import { enqueueSnackbar } from 'notistack'; @@ -52,6 +53,7 @@ export const StreamForm = (props: StreamFormProps) => { setDestinationVerified, } = useVerifyDestination(); + const formRef = React.useRef(null); const form = useFormContext(); const { control, handleSubmit, trigger } = form; @@ -170,11 +172,13 @@ export const StreamForm = (props: StreamFormProps) => { if (isValid) { await verifyDestination(destination); + } else { + scrollErrorIntoViewV2(formRef); } }; return ( - + @@ -198,7 +202,9 @@ export const StreamForm = (props: StreamFormProps) => { isSubmitting={isSubmitting} isTesting={isVerifyingDestination} mode={mode} - onSubmit={handleSubmit(onSubmit)} + onSubmit={handleSubmit(onSubmit, () => + scrollErrorIntoViewV2(formRef) + )} onTestConnection={handleTestConnection} /> diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamFormGeneralInfo.test.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamFormGeneralInfo.test.tsx index c385377d3e4..4ca55775ecf 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamFormGeneralInfo.test.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamFormGeneralInfo.test.tsx @@ -43,14 +43,16 @@ describe('StreamFormGeneralInfo', () => { // Open the dropdown await userEvent.click(streamTypesAutocomplete); - // Select the "Kubernetes Audit Logs" option - const kubernetesAuditLogs = await screen.findByText( - 'Kubernetes Audit Logs' + // Select the "Kubernetes API Audit Logs" option + const kubernetesApiAuditLogs = await screen.findByText( + 'Kubernetes API Audit Logs' ); - await userEvent.click(kubernetesAuditLogs); + await userEvent.click(kubernetesApiAuditLogs); await waitFor(() => { - expect(streamTypesAutocomplete).toHaveValue('Kubernetes Audit Logs'); + expect(streamTypesAutocomplete).toHaveValue( + 'Kubernetes API Audit Logs' + ); }); }); }); diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamFormGeneralInfo.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamFormGeneralInfo.tsx index bc586e7d8d4..bf2def9dc75 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamFormGeneralInfo.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamFormGeneralInfo.tsx @@ -39,9 +39,9 @@ export const StreamFormGeneralInfo = (props: StreamFormGeneralInfoProps) => { const capitalizedMode = capitalize(mode); const description = { audit_logs: - 'Configuration and authentication audit logs that capture state-changing operations (mutations) on Linode cloud infrastructure resources and IAM authentication events. Delivered in cloudevents.io JSON format.', + 'Audit logs record state-changing operations on cloud resources and authentication events, delivered in CloudEvents JSON format.', lke_audit_logs: - 'Kubernetes API server audit logs that capture state-changing operations (mutations) on LKE-E cluster resources.', + 'Kubernetes API server audit logs capture state-changing operations on LKE-E cluster resources.', }; const pendoIds = { audit_logs: `Logs Delivery Streams ${capitalizedMode}-Audit Logs`, @@ -60,7 +60,10 @@ export const StreamFormGeneralInfo = (props: StreamFormGeneralInfoProps) => { const updateStreamDetails = (value: string) => { if (value === streamType.LKEAuditLogs) { - setValue('stream.details.is_auto_add_all_clusters_enabled', false); + setValue('stream.details', { + cluster_ids: [], + is_auto_add_all_clusters_enabled: false, + }); } else { setValue('stream.details', null); } @@ -135,7 +138,8 @@ export const StreamFormGeneralInfo = (props: StreamFormGeneralInfoProps) => { {description[selectedStreamType]} diff --git a/packages/manager/src/features/Delivery/Streams/StreamTableRow.tsx b/packages/manager/src/features/Delivery/Streams/StreamTableRow.tsx index 891c62cd830..8184c7f33f9 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamTableRow.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamTableRow.tsx @@ -2,7 +2,6 @@ import { Hidden } from '@linode/ui'; import * as React from 'react'; import { DateTimeDisplay } from 'src/components/DateTimeDisplay'; -import { Link } from 'src/components/Link'; import { StatusIcon } from 'src/components/StatusIcon/StatusIcon'; import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; @@ -10,6 +9,7 @@ import { getDestinationTypeOption, getStreamTypeOption, } from 'src/features/Delivery/deliveryUtils'; +import { LinkWithTooltipAndEllipsis } from 'src/features/Delivery/Shared/LinkWithTooltipAndEllipsis'; import { StreamActionMenu } from 'src/features/Delivery/Streams/StreamActionMenu'; import type { Stream, StreamStatus } from '@linode/api-v4'; @@ -26,12 +26,12 @@ export const StreamTableRow = React.memo((props: StreamTableRowProps) => { return ( - {stream.label} - + {getStreamTypeOption(stream.type)?.label} @@ -39,16 +39,19 @@ export const StreamTableRow = React.memo((props: StreamTableRowProps) => { {humanizeStreamStatus(status)} {id} - + {getDestinationTypeOption(stream.destinations[0]?.type)?.label} - + + + {stream.updated_by} + { within(screen.getByRole('table')).getByText('Status'); screen.getByText('ID'); screen.getByText('Destination Type'); - screen.getByText('Creation Time'); + screen.getByText('Last Modified'); + screen.getByText('Last Modified By'); // PaginationFooter const paginationFooterSelectPageSizeInput = screen.getAllByTestId( diff --git a/packages/manager/src/features/Delivery/Streams/StreamsLanding.tsx b/packages/manager/src/features/Delivery/Streams/StreamsLanding.tsx index ce0ade7b71c..1a2b4729691 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamsLanding.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamsLanding.tsx @@ -1,6 +1,6 @@ import { streamStatus } from '@linode/api-v4'; import { useStreamsQuery, useUpdateStreamMutation } from '@linode/queries'; -import { CircleProgress, ErrorState, Hidden } from '@linode/ui'; +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'; @@ -73,7 +73,6 @@ export const StreamsLanding = () => { const { data: streams, isLoading, - isFetching, error, } = useStreamsQuery( { @@ -177,10 +176,9 @@ export const StreamsLanding = () => { }; return ( - <> + { selectList={streamStatusOptions} selectValue={search?.status} /> - {isLoading ? ( ) : ( @@ -201,7 +198,10 @@ export const StreamsLanding = () => { direction={order} handleClick={handleOrderChange} label="label" - sx={{ width: '30%' }} + sx={{ + width: '30%', + maxWidth: '30%', + }} > Name @@ -229,17 +229,27 @@ export const StreamsLanding = () => { > ID - + Destination Type + Last Modified + + + + - Creation Time + Last Modified By @@ -249,7 +259,7 @@ export const StreamsLanding = () => { {streams?.data.map((stream) => ( ))} - {streams?.results === 0 && } + {streams?.results === 0 && }
    { /> )} - + ); }; diff --git a/packages/manager/src/features/Delivery/Streams/StreamsLandingEmptyStateData.ts b/packages/manager/src/features/Delivery/Streams/StreamsLandingEmptyStateData.ts index c62f70d5607..bf4d039cea6 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamsLandingEmptyStateData.ts +++ b/packages/manager/src/features/Delivery/Streams/StreamsLandingEmptyStateData.ts @@ -1,3 +1,8 @@ +import { + docsLink, + guidesMoreLinkText, +} from 'src/utilities/emptyStateLandingUtils'; + import type { ResourcesHeaders, ResourcesLinks, @@ -16,10 +21,15 @@ export const linkAnalyticsEvent: ResourcesLinks['linkAnalyticsEvent'] = { }; export const gettingStartedGuides: ResourcesLinkSection = { - links: [], + links: [ + { + text: 'Getting started guide', + to: 'https://techdocs.akamai.com/cloud-computing/docs/log-delivery', + }, + ], moreInfo: { - text: '', - to: '', + text: guidesMoreLinkText, + to: docsLink, }, - title: '', + title: 'Getting Started Guides', }; diff --git a/packages/manager/src/features/Delivery/Streams/constants.ts b/packages/manager/src/features/Delivery/Streams/constants.ts index e5035a18049..28d6f37de2c 100644 --- a/packages/manager/src/features/Delivery/Streams/constants.ts +++ b/packages/manager/src/features/Delivery/Streams/constants.ts @@ -1,3 +1,3 @@ export const STREAMS_TABLE_DEFAULT_ORDER = 'desc'; -export const STREAMS_TABLE_DEFAULT_ORDER_BY = 'created'; +export const STREAMS_TABLE_DEFAULT_ORDER_BY = 'updated'; export const STREAMS_TABLE_PREFERENCE_KEY = 'streams'; diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddLinodeDrawer.test.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddLinodeDrawer.test.tsx index c6143a282b6..553b89e432f 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddLinodeDrawer.test.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddLinodeDrawer.test.tsx @@ -16,16 +16,14 @@ const props = { }; const queryMocks = vi.hoisted(() => ({ + useAllFirewallsQuery: vi.fn().mockReturnValue({}), useParams: vi.fn().mockReturnValue({}), - useQueryWithPermissions: vi.fn().mockReturnValue({ - data: [], - isLoading: false, - isError: false, - }), + useGetAllUserEntitiesByPermission: vi.fn().mockReturnValue({}), })); -vi.mock('src/features/IAM/hooks/usePermissions', () => ({ - useQueryWithPermissions: queryMocks.useQueryWithPermissions, +vi.mock('src/features/IAM/hooks/useGetAllUserEntitiesByPermission', () => ({ + useGetAllUserEntitiesByPermission: + queryMocks.useGetAllUserEntitiesByPermission, })); vi.mock('@tanstack/react-router', async () => { @@ -36,9 +34,27 @@ vi.mock('@tanstack/react-router', async () => { }; }); +vi.mock('@linode/queries', async () => { + const actual = await vi.importActual('@linode/queries'); + return { + ...actual, + useAllFirewallsQuery: queryMocks.useAllFirewallsQuery, + }; +}); + describe('AddLinodeDrawer', () => { beforeEach(() => { queryMocks.useParams.mockReturnValue({ id: '1' }); + queryMocks.useGetAllUserEntitiesByPermission.mockReturnValue({ + data: [], + isLoading: false, + error: null, + }); + queryMocks.useAllFirewallsQuery.mockReturnValue({ + data: [], + isLoading: false, + error: null, + }); }); it('should contain helper text', () => { diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddLinodeDrawer.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddLinodeDrawer.tsx index ae6127c5be5..29bb8f7247b 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddLinodeDrawer.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddLinodeDrawer.tsx @@ -2,7 +2,6 @@ import { linodeQueries, useAddFirewallDeviceMutation, useAllFirewallsQuery, - useAllLinodesQuery, } from '@linode/queries'; import { LinodeSelect } from '@linode/shared'; import { @@ -21,7 +20,7 @@ import * as React from 'react'; import { Link } from 'src/components/Link'; import { SupportLink } from 'src/components/SupportLink'; import { getRestrictedResourceText } from 'src/features/Account/utils'; -import { useQueryWithPermissions } from 'src/features/IAM/hooks/usePermissions'; +import { useGetAllUserEntitiesByPermission } from 'src/features/IAM/hooks/useGetAllUserEntitiesByPermission'; import { getLinodeInterfaceType } from 'src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/utilities'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { useIsLinodeInterfacesEnabled } from 'src/utilities/linodes'; @@ -59,13 +58,16 @@ export const AddLinodeDrawer = (props: Props) => { const firewall = data?.find((firewall) => firewall.id === Number(id)); - const { data: availableLinodes, isLoading: availableLinodesLoading } = - useQueryWithPermissions( - useAllLinodesQuery({}, {}, open), - 'linode', - ['update_linode'], - open - ); + const { + data: availableLinodes, + filter: availableLinodesFilter, + isLoading: availableLinodesLoading, + error: availableLinodesError, + } = useGetAllUserEntitiesByPermission({ + entityType: 'linode', + permission: 'update_linode', + enabled: open, + }); const linodesUsingLinodeInterfaces = availableLinodes?.filter((l) => l.interface_generation === 'linode') ?? []; @@ -338,7 +340,10 @@ export const AddLinodeDrawer = (props: Props) => { if (error) { setLocalError('Could not load firewall data'); } - }, [error]); + if (availableLinodesError) { + setLocalError('Could not load linode data'); + } + }, [error, availableLinodesError]); return ( { disabled={ isLoadingAllFirewalls || availableLinodesLoading || disabled } + filter={availableLinodesFilter} helperText={helperText} loading={isLoadingAllFirewalls || availableLinodesLoading} multiple diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddNodebalancerDrawer.test.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddNodebalancerDrawer.test.tsx index 490cccd4ef7..e41b5cb61b4 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddNodebalancerDrawer.test.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddNodebalancerDrawer.test.tsx @@ -22,16 +22,12 @@ const queryMocks = vi.hoisted(() => ({ create_firewall_device: true, }, })), - useQueryWithPermissions: vi.fn().mockReturnValue({ - data: [], - isLoading: false, - isError: false, - }), + useGetAllUserEntitiesByPermission: vi.fn().mockReturnValue({}), + useAllFirewallsQuery: vi.fn().mockReturnValue({}), })); vi.mock('src/features/IAM/hooks/usePermissions', () => ({ usePermissions: queryMocks.userPermissions, - useQueryWithPermissions: queryMocks.useQueryWithPermissions, })); vi.mock('@tanstack/react-router', async () => { @@ -42,9 +38,32 @@ vi.mock('@tanstack/react-router', async () => { }; }); +vi.mock('src/features/IAM/hooks/useGetAllUserEntitiesByPermission', () => ({ + useGetAllUserEntitiesByPermission: + queryMocks.useGetAllUserEntitiesByPermission, +})); + +vi.mock('@linode/queries', async () => { + const actual = await vi.importActual('@linode/queries'); + return { + ...actual, + useAllFirewallsQuery: queryMocks.useAllFirewallsQuery, + }; +}); + describe('AddNodeBalancerDrawer', () => { beforeEach(() => { queryMocks.useParams.mockReturnValue({ id: '1' }); + queryMocks.useGetAllUserEntitiesByPermission.mockReturnValue({ + data: [], + isLoading: false, + error: null, + }); + queryMocks.useAllFirewallsQuery.mockReturnValue({ + data: [], + isLoading: false, + error: null, + }); }); it('should contain helper text', () => { diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceActionMenu.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceActionMenu.tsx index 5cccbf61343..33cef97f1f4 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceActionMenu.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceActionMenu.tsx @@ -6,48 +6,31 @@ export interface ActionHandlers { handleRemoveDevice: (device: FirewallDevice) => void; } -import { usePermissions } from 'src/features/IAM/hooks/usePermissions'; - import type { FirewallDevice } from '@linode/api-v4'; export interface FirewallDeviceActionMenuProps extends ActionHandlers { device: FirewallDevice; disabled: boolean; + isLinodeUpdatable: boolean; + isNodebalancerUpdatable: boolean; + isPermissionsLoading: boolean; } export const FirewallDeviceActionMenu = React.memo( (props: FirewallDeviceActionMenuProps) => { - const { device, disabled, handleRemoveDevice } = props; + const { + device, + disabled, + handleRemoveDevice, + isLinodeUpdatable, + isNodebalancerUpdatable, + isPermissionsLoading, + } = props; const { type } = device.entity; - const { data: linodePermissions, isLoading: isLinodePermissionsLoading } = - usePermissions( - 'linode', - ['update_linode'], - device?.entity.id, - type !== 'nodebalancer' - ); - - const { - data: nodebalancerPermissions, - isLoading: isNodebalancerPermissionsLoading, - } = usePermissions( - 'nodebalancer', - ['update_nodebalancer'], - device?.entity.id, - type === 'nodebalancer' - ); - const disabledDueToPermissions = - type === 'nodebalancer' - ? !nodebalancerPermissions?.update_nodebalancer - : !linodePermissions?.update_linode; - - const isPermissionsLoading = - type === 'nodebalancer' - ? isNodebalancerPermissionsLoading - : isLinodePermissionsLoading; + type === 'nodebalancer' ? !isNodebalancerUpdatable : !isLinodeUpdatable; return ( ); diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceLanding.test.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceLanding.test.tsx index 0e8467c28da..7ef14493bdd 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceLanding.test.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceLanding.test.tsx @@ -23,11 +23,6 @@ const queryMocks = vi.hoisted(() => ({ create_firewall_device: false, }, })), - useQueryWithPermissions: vi.fn().mockReturnValue({ - data: [], - isLoading: false, - isError: false, - }), })); vi.mock('@tanstack/react-router', async () => { @@ -51,7 +46,6 @@ vi.mock('src/hooks/useOrderV2', async () => { vi.mock('src/features/IAM/hooks/usePermissions', () => ({ usePermissions: queryMocks.usePermissions, - useQueryWithPermissions: queryMocks.useQueryWithPermissions, })); const baseProps = ( diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceRow.test.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceRow.test.tsx index 8bdf964c135..5f521cd2182 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceRow.test.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceRow.test.tsx @@ -2,7 +2,6 @@ import userEvent from '@testing-library/user-event'; import * as React from 'react'; import { accountFactory, firewallDeviceFactory } from 'src/factories'; -import { http, HttpResponse, server } from 'src/mocks/testServer'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { FirewallDeviceRow } from './FirewallDeviceRow'; @@ -10,22 +9,25 @@ import { FirewallDeviceRow } from './FirewallDeviceRow'; import type { FirewallDeviceEntityType } from '@linode/api-v4'; const queryMocks = vi.hoisted(() => ({ - userPermissions: vi.fn(() => ({ - data: { - update_linode: true, - }, - })), + useAccount: vi.fn().mockReturnValue({}), })); -vi.mock('src/features/IAM/hooks/usePermissions', () => ({ - usePermissions: queryMocks.userPermissions, -})); +vi.mock('@linode/queries', async () => { + const actual = await vi.importActual('@linode/queries'); + return { + ...actual, + useAccount: queryMocks.useAccount, + }; +}); const props = { device: firewallDeviceFactory.build(), disabled: false, handleRemoveDevice: vi.fn(), isLinodeRelatedDevice: true, + isLinodeUpdatable: true, + isNodebalancerUpdatable: true, + isPermissionsLoading: false, }; const INTERFACE_TEXT = 'Configuration Profile Interface'; @@ -36,11 +38,11 @@ describe('FirewallDeviceRow', () => { capabilities: ['Linode Interfaces'], }); - server.use( - http.get('*/v4*/account', () => { - return HttpResponse.json(account); - }) - ); + queryMocks.useAccount.mockReturnValue({ + data: account, + isLoading: false, + error: null, + }); const { getAllByRole, getByText, findByText } = renderWithTheme( , diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceTable.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceTable.tsx index ff0e392f369..ac68229bf9d 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceTable.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceTable.tsx @@ -9,6 +9,7 @@ import { TableContentWrapper } from 'src/components/TableContentWrapper/TableCon import { TableHead } from 'src/components/TableHead'; import { TableRow } from 'src/components/TableRow'; import { TableSortCell } from 'src/components/TableSortCell'; +import { useGetAllUserEntitiesByPermission } from 'src/features/IAM/hooks/useGetAllUserEntitiesByPermission'; import { useOrderV2 } from 'src/hooks/useOrderV2'; import { usePaginationV2 } from 'src/hooks/usePaginationV2'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; @@ -47,6 +48,26 @@ export const FirewallDeviceTable = React.memo( isLoading, } = useAllFirewallDevicesQuery(firewallId); + const { + data: updatableLinodes = [], + isLoading: isLinodePermissionsLoading, + error: linodePermissionsError, + } = useGetAllUserEntitiesByPermission({ + entityType: 'linode', + permission: 'update_linode', + enabled: type === 'linode', + }); + + const { + data: updatableNodebalancers = [], + isLoading: isNodebalancerPermissionsLoading, + error: nodebalancerPermissionsError, + } = useGetAllUserEntitiesByPermission({ + entityType: 'nodebalancer', + permission: 'update_nodebalancer', + enabled: type === 'nodebalancer', + }); + const devices = allDevices?.filter((device) => type === 'linode' && isLinodeInterfacesEnabled @@ -82,12 +103,20 @@ export const FirewallDeviceTable = React.memo( const isLinodeRelatedDevice = type === 'linode'; + const permissionsError = + linodePermissionsError || nodebalancerPermissionsError; + const _error = error ? getAPIErrorOrDefault( error, `Unable to retrieve ${formattedTypes[deviceType]}s` ) - : undefined; + : permissionsError + ? getAPIErrorOrDefault( + permissionsError, + `Unable to retrieve ${formattedTypes[deviceType]}s` + ) + : undefined; const ariaLabel = `List of ${formattedTypes[deviceType]}s attached to this firewall`; @@ -152,6 +181,16 @@ export const FirewallDeviceTable = React.memo( disabled={disabled} handleRemoveDevice={handleRemoveDevice} isLinodeRelatedDevice={isLinodeRelatedDevice} + isLinodeUpdatable={updatableLinodes?.some( + (linode) => linode.id === thisDevice.entity.id + )} + isNodebalancerUpdatable={updatableNodebalancers?.some( + (nodebalancer) => nodebalancer.id === thisDevice.entity.id + )} + isPermissionsLoading={ + isLinodePermissionsLoading || + isNodebalancerPermissionsLoading + } key={`device-row-${thisDevice.id}`} /> ))} diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/RemoveDeviceDialog.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/RemoveDeviceDialog.tsx index 830d257cd54..8bf5f6f991d 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/RemoveDeviceDialog.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/RemoveDeviceDialog.tsx @@ -55,7 +55,8 @@ export const RemoveDeviceDialog = React.memo((props: Props) => { const { data: firewallPermissions } = usePermissions( 'firewall', ['delete_firewall_device'], - firewallId + firewallId, + firewallId !== -1 ); const { data: linodePermissions } = usePermissions( diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallPrefixListDrawer.test.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallPrefixListDrawer.test.tsx new file mode 100644 index 00000000000..0bd18ab2a35 --- /dev/null +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallPrefixListDrawer.test.tsx @@ -0,0 +1,493 @@ +import { capitalize } from '@linode/utilities'; +import { within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { firewallPrefixListFactory } from 'src/factories'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import * as shared from '../../shared'; +import { FirewallPrefixListDrawer } from './FirewallPrefixListDrawer'; +import * as rulesShared from './shared'; +import { PREFIXLIST_MARKED_FOR_DELETION_TEXT } from './shared'; + +import type { FirewallPrefixListDrawerProps } from './FirewallPrefixListDrawer'; +import type { FirewallPrefixList } from '@linode/api-v4'; + +const queryMocks = vi.hoisted(() => ({ + useAllFirewallPrefixListsQuery: vi.fn().mockReturnValue({}), +})); + +vi.mock('@linode/queries', async () => { + const actual = await vi.importActual('@linode/queries'); + return { + ...actual, + useAllFirewallPrefixListsQuery: queryMocks.useAllFirewallPrefixListsQuery, + }; +}); + +vi.mock('@linode/utilities', async () => { + const actual = await vi.importActual('@linode/utilities'); + return { + ...actual, + getUserTimezone: vi.fn().mockReturnValue('utc'), + }; +}); + +const spy = vi.spyOn(shared, 'useIsFirewallRulesetsPrefixlistsEnabled'); +const combineSpy = vi.spyOn(rulesShared, 'combinePrefixLists'); + +// +// Helper to compute expected UI values/text +// +const computeExpectedElements = ( + category: 'inbound' | 'outbound', + context: FirewallPrefixListDrawerProps['context'] +) => { + let title = 'Prefix List details'; + let button = 'Close'; + let label = 'Name:'; + + if (context?.type === 'ruleset' && context.modeViewedFrom === 'create') { + title = `Add an ${capitalize(category)} Rule or Rule Set`; + button = `Back to ${capitalize(category)} Rule Set`; + label = 'Prefix List Name:'; + } + + if (context?.type === 'rule' && context.modeViewedFrom === 'create') { + title = `Add an ${capitalize(category)} Rule or Rule Set`; + button = `Back to ${capitalize(category)} Rule`; + label = 'Prefix List Name:'; + } + + if (context?.type === 'ruleset' && context.modeViewedFrom === 'view') { + title = `${capitalize(category)} Rule Set details`; + button = 'Back to the Rule Set'; + label = 'Prefix List Name:'; + } + + if (context?.type === 'rule' && context.modeViewedFrom === 'edit') { + title = 'Edit Rule'; + button = 'Back to Rule'; + label = 'Prefix List Name:'; + } + + // Default values when there is no specific drawer context + // (e.g., type === 'rule' and modeViewedFrom === undefined, + // meaning the drawer is opened directly from the Firewall Table row) + return { title, button, label }; +}; + +describe('FirewallPrefixListDrawer', () => { + beforeEach(() => { + spy.mockReturnValue({ + isFirewallRulesetsPrefixlistsFeatureEnabled: true, + isFirewallRulesetsPrefixListsBetaEnabled: false, + isFirewallRulesetsPrefixListsLAEnabled: false, + isFirewallRulesetsPrefixListsGAEnabled: false, + }); + }); + + // Default/base props + const baseProps: Omit = + { + isOpen: true, + onClose: () => {}, + selectedPrefixListLabel: 'pl-test', + }; + + const drawerProps: FirewallPrefixListDrawerProps[] = [ + { + ...baseProps, + category: 'inbound', + context: { + type: 'ruleset', + modeViewedFrom: 'create', + plRuleRef: { inIPv4Rule: true, inIPv6Rule: true }, + }, + }, + { + ...baseProps, + category: 'inbound', + context: { + type: 'rule', + modeViewedFrom: 'create', + plRuleRef: { inIPv4Rule: true, inIPv6Rule: true }, + }, + }, + { + ...baseProps, + category: 'outbound', + context: { + type: 'ruleset', + modeViewedFrom: 'view', + plRuleRef: { inIPv4Rule: true, inIPv6Rule: false }, + }, + }, + { + ...baseProps, + category: 'inbound', + context: { + type: 'rule', + modeViewedFrom: 'edit', + plRuleRef: { inIPv4Rule: false, inIPv6Rule: true }, + }, + }, + { + ...baseProps, + category: 'outbound', + context: { + type: 'rule', + plRuleRef: { inIPv4Rule: true, inIPv6Rule: true }, + }, + }, + ]; + + it.each(drawerProps)( + 'renders correct UI for category:$category, contextType:$context.type and modeViewedFrom:$context.modeViewedFrom', + ({ category, context, selectedPrefixListLabel }) => { + const mockData = firewallPrefixListFactory.build({ + name: selectedPrefixListLabel, + }); + queryMocks.useAllFirewallPrefixListsQuery.mockReturnValue({ + data: [mockData], + }); + + combineSpy.mockReturnValue([ + ...rulesShared.SPECIAL_PREFIX_LISTS, + mockData, + ]); + + const { getByText, getByRole } = renderWithTheme( + + ); + + // Compute expectations + const { title, button, label } = computeExpectedElements( + category, + context + ); + + // Title + expect(getByText(title)).toBeVisible(); + + // First label (Prefix List Name: OR Name:) + expect(getByText(label)).toBeVisible(); + + // Static detail labels + [ + 'ID:', + 'Description:', + 'Type:', + 'Visibility:', + 'Version:', + 'Created:', + 'Updated:', + ].forEach((l) => expect(getByText(l)).toBeVisible()); + + // Back or Cancel button + expect(getByRole('button', { name: button })).toBeVisible(); + } + ); + + // Marked for deletion tests + const deletionTestCases = [ + [ + 'should not display "Marked for deletion" when prefix list is active', + null, + ], + [ + 'should display "Marked for deletion" when prefix list is deleted', + '2025-07-24T04:23:17', + ], + ]; + + it.each(deletionTestCases)('%s', async (_, deletedTimeStamp) => { + const mockPrefixList = firewallPrefixListFactory.build({ + name: 'pl-test', + deleted: deletedTimeStamp, + }); + + queryMocks.useAllFirewallPrefixListsQuery.mockReturnValue({ + data: [mockPrefixList], + }); + combineSpy.mockReturnValue([ + ...rulesShared.SPECIAL_PREFIX_LISTS, + mockPrefixList, + ]); + + const { getByText, getByTestId, findByText, queryByText } = renderWithTheme( + + ); + + if (deletedTimeStamp) { + expect(getByText('Marked for deletion:')).toBeVisible(); + const tooltip = getByTestId('tooltip-info-icon'); + await userEvent.hover(tooltip); + expect( + await findByText(PREFIXLIST_MARKED_FOR_DELETION_TEXT) + ).toBeVisible(); + } else { + expect(queryByText('Marked for deletion:')).not.toBeInTheDocument(); + } + }); + + const prefixListVariants: Partial[] = [ + { name: 'pl::supports-both', ipv4: ['1.1.1.0/24'], ipv6: ['::1/128'] }, + { name: 'pl::supports-only-ipv4', ipv4: ['1.1.1.0/24'], ipv6: null }, + { name: 'pl::supports-only-ipv6', ipv4: null, ipv6: ['::1/128'] }, + { name: 'pl::supports-both-but-ipv4-empty', ipv4: [], ipv6: ['::1/128'] }, + { + name: 'pl::supports-both-but-ipv6-empty', + ipv4: ['1.1.1.0/24'], + ipv6: [], + }, + { name: 'pl::supports-both-but-both-empty', ipv4: [], ipv6: [] }, + ]; + + const ruleReferences: FirewallPrefixListDrawerProps['context'][] = [ + { plRuleRef: { inIPv4Rule: true, inIPv6Rule: false }, type: 'rule' }, + { plRuleRef: { inIPv4Rule: false, inIPv6Rule: true }, type: 'rule' }, + { plRuleRef: { inIPv4Rule: true, inIPv6Rule: true }, type: 'rule' }, + { plRuleRef: { inIPv4Rule: true, inIPv6Rule: true }, type: 'ruleset' }, + ]; + + const ipSectionTestCases = [ + // PL supports both + { + prefixList: prefixListVariants[0], + context: ruleReferences[0], + expectedIPv4: 'in use', + expectedIPv6: 'not in use', + }, + { + prefixList: prefixListVariants[0], + context: ruleReferences[1], + expectedIPv4: 'not in use', + expectedIPv6: 'in use', + }, + { + prefixList: prefixListVariants[0], + context: ruleReferences[2], + expectedIPv4: 'in use', + expectedIPv6: 'in use', + }, + { + prefixList: prefixListVariants[0], + context: ruleReferences[3], + expectedIPv4: 'in use', + expectedIPv6: 'in use', + }, + // PL supports only IPv4 + { + prefixList: prefixListVariants[1], + context: ruleReferences[0], + expectedIPv4: 'in use', + }, + // PL supports only IPv6 + { + prefixList: prefixListVariants[2], + context: ruleReferences[1], + expectedIPv6: 'in use', + }, + // PL IPv4 empty + { + prefixList: prefixListVariants[3], + context: ruleReferences[0], + expectedIPv4: 'in use', + expectedIPv6: 'not in use', + }, + { + prefixList: prefixListVariants[3], + context: ruleReferences[1], + expectedIPv4: 'not in use', + expectedIPv6: 'in use', + }, + // PL IPv6 empty + { + prefixList: prefixListVariants[4], + context: ruleReferences[0], + expectedIPv4: 'in use', + expectedIPv6: 'not in use', + }, + { + prefixList: prefixListVariants[4], + context: ruleReferences[1], + expectedIPv4: 'not in use', + expectedIPv6: 'in use', + }, + // PL both empty + { + prefixList: prefixListVariants[5], + context: ruleReferences[0], + expectedIPv4: 'in use', + expectedIPv6: 'not in use', + }, + { + prefixList: prefixListVariants[5], + context: ruleReferences[1], + expectedIPv4: 'not in use', + expectedIPv6: 'in use', + }, + ]; + + it.each(ipSectionTestCases)( + 'renders correct chip and IP addresses for Prefix List $prefixList.name with reference $context.plRuleRef', + ({ prefixList, context, expectedIPv4, expectedIPv6 }) => { + const selectedPrefixList = prefixList.name; + + const mockPrefixList = firewallPrefixListFactory.build({ ...prefixList }); + queryMocks.useAllFirewallPrefixListsQuery.mockReturnValue({ + data: [mockPrefixList], + }); + combineSpy.mockReturnValue([ + ...rulesShared.SPECIAL_PREFIX_LISTS, + mockPrefixList, + ]); + + const { getByTestId } = renderWithTheme( + + ); + + if (prefixList.ipv4 && expectedIPv4) { + const ipv4Chip = getByTestId('ipv4-chip'); + expect(ipv4Chip).toBeVisible(); + expect(ipv4Chip).toHaveTextContent(expectedIPv4); + + // Check IPv4 addresses + const ipv4Section = getByTestId('ipv4-section'); + const ipv4Content = prefixList.ipv4.length + ? prefixList.ipv4.join(', ') + : 'no IP addresses'; + expect(within(ipv4Section).getByText(ipv4Content)).toBeVisible(); + } + + if (prefixList.ipv6 && expectedIPv6) { + const ipv6Chip = getByTestId('ipv6-chip'); + expect(ipv6Chip).toBeVisible(); + expect(ipv6Chip).toHaveTextContent(expectedIPv6); + + // Check IPv6 addresses + const ipv6Section = getByTestId('ipv6-section'); + const ipv6Content = prefixList.ipv6.length + ? prefixList.ipv6.join(', ') + : 'no IP addresses'; + expect(within(ipv6Section).getByText(ipv6Content)).toBeVisible(); + } + } + ); +}); + +describe('FirewallPrefixListDrawer - Special "" Prefix Lists', () => { + beforeEach(() => { + spy.mockReturnValue({ + isFirewallRulesetsPrefixlistsFeatureEnabled: true, + isFirewallRulesetsPrefixListsBetaEnabled: false, + isFirewallRulesetsPrefixListsLAEnabled: false, + isFirewallRulesetsPrefixListsGAEnabled: false, + }); + }); + const specialPrefixListDescription = + 'System-defined PrefixLists, such as pl::vpcs: and pl::subnets:, for VPC interface firewalls are dynamic and update automatically. They manage access to and from the interface for addresses within the interface’s VPC or VPC subnet.'; + const plRuleRef = { inIPv4Rule: true, inIPv6Rule: true }; + const context: FirewallPrefixListDrawerProps['context'][] = [ + { + type: 'rule', + plRuleRef, + }, + { + modeViewedFrom: 'create', + type: 'ruleset', + plRuleRef, + }, + { modeViewedFrom: 'edit', type: 'rule', plRuleRef }, + { modeViewedFrom: 'view', type: 'ruleset', plRuleRef }, + ]; + const specialPLsTestCases = [ + { + name: 'pl::vpcs:', + description: specialPrefixListDescription, + context: context[0], + }, + { + name: 'pl::subnets:', + description: specialPrefixListDescription, + context: context[1], + }, + { + name: 'pl::vpcs:', + description: specialPrefixListDescription, + context: context[2], + }, + { + name: 'pl::subnets:', + description: specialPrefixListDescription, + context: context[3], + }, + ]; + + it.each(specialPLsTestCases)( + 'renders only Name and Description for special PL: $name, contextType: $context.type and modeViewedFrom: $context.modeViewedFrom', + ({ name, description, context }) => { + // API returns no matches, special PL logic must handle it + queryMocks.useAllFirewallPrefixListsQuery.mockReturnValue({ + data: [], + }); + combineSpy.mockReturnValue([...rulesShared.SPECIAL_PREFIX_LISTS]); + + const { getByText, queryByText } = renderWithTheme( + + ); + + const { label } = computeExpectedElements('inbound', context); + + // Name and Description should be visible + expect(getByText(label)).toBeVisible(); // First label (Prefix List Name: OR Name:) + expect(getByText(name)).toBeVisible(); + + expect(getByText('Description:')).toBeVisible(); + expect(getByText(description)).toBeVisible(); + + // All other fields must be hidden + const hiddenFields = [ + 'ID:', + 'Type:', + 'Visibility:', + 'Version:', + 'Created:', + 'Updated:', + ]; + + hiddenFields.forEach((label) => { + expect(queryByText(label)).not.toBeInTheDocument(); + }); + } + ); +}); diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallPrefixListDrawer.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallPrefixListDrawer.tsx new file mode 100644 index 00000000000..b846482f8b2 --- /dev/null +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallPrefixListDrawer.tsx @@ -0,0 +1,308 @@ +import { useAllFirewallPrefixListsQuery } from '@linode/queries'; +import { Box, Button, Drawer, Stack, TooltipIcon } from '@linode/ui'; +import { capitalize } from '@linode/utilities'; +import * as React from 'react'; + +import ArrowLeftIcon from 'src/assets/icons/arrow-left.svg'; +import { CopyTooltip } from 'src/components/CopyTooltip/CopyTooltip'; +import { DateTimeDisplay } from 'src/components/DateTimeDisplay'; + +import { + getFeatureChip, + useIsFirewallRulesetsPrefixlistsEnabled, +} from '../../shared'; +import { PrefixListIPSection } from './FirewallPrefixListIPSection'; +import { + combinePrefixLists, + getPrefixListType, + isSpecialPrefixList, + PREFIXLIST_MARKED_FOR_DELETION_TEXT, +} from './shared'; +import { + StyledLabel, + StyledListItem, + StyledWarningIcon, + useStyles, +} from './shared.styles'; + +import type { PrefixListRuleReference } from '../../shared'; +import type { FirewallRuleDrawerMode } from './FirewallRuleDrawer.types'; +import type { Category } from './shared'; + +export interface PrefixListDrawerContext { + modeViewedFrom?: FirewallRuleDrawerMode; // Optional in the case of normal rules + plRuleRef: PrefixListRuleReference; + type: 'rule' | 'ruleset'; +} + +export interface FirewallPrefixListDrawerProps { + category: Category; + context: PrefixListDrawerContext | undefined; + isOpen: boolean; + onClose: (options?: { closeAll: boolean }) => void; + selectedPrefixListLabel: string | undefined; +} + +export const FirewallPrefixListDrawer = React.memo( + (props: FirewallPrefixListDrawerProps) => { + const { category, context, onClose, isOpen, selectedPrefixListLabel } = + props; + + const { + isFirewallRulesetsPrefixlistsFeatureEnabled, + isFirewallRulesetsPrefixListsBetaEnabled, + isFirewallRulesetsPrefixListsGAEnabled, + } = useIsFirewallRulesetsPrefixlistsEnabled(); + + const isPrefixListSpecial = isSpecialPrefixList(selectedPrefixListLabel); + + const { classes } = useStyles(); + + const { + data: apiPL, + error, + isFetching, + } = useAllFirewallPrefixListsQuery( + // @TODO: Temporarily disabling this API call for `isPrefixListSpecial` + // since the API doesn't yet support special Prefix Lists. + // Remove this check and refactor related logic once API support is added. + isFirewallRulesetsPrefixlistsFeatureEnabled && !isPrefixListSpecial, + {}, + { name: selectedPrefixListLabel } + ); + + // Merge with hardcoded special PLs + const prefixLists = React.useMemo(() => combinePrefixLists(apiPL), [apiPL]); + + // Get the actual prefix list by name (name is unique) + const prefixListDetails = prefixLists.find( + (pl) => pl.name === selectedPrefixListLabel + ); + + const isIPv4Supported = + prefixListDetails?.ipv4 !== null && prefixListDetails?.ipv4 !== undefined; + const isIPv6Supported = + prefixListDetails?.ipv6 !== null && prefixListDetails?.ipv6 !== undefined; + + const isIPv4InUse = context?.plRuleRef.inIPv4Rule; + const isIPv6InUse = context?.plRuleRef.inIPv6Rule; + + // Returns Prefix List drawer title and back button text based on category and reference + const getDrawerTexts = ( + category: Category, + context?: PrefixListDrawerContext + ) => { + const defaultTexts = { title: 'Prefix List details', backButton: null }; + + if (!context) return defaultTexts; + + const { type, modeViewedFrom } = context; + + if (type === 'ruleset' && modeViewedFrom === 'create') { + return { + title: `Add an ${capitalize(category)} Rule or Rule Set`, + backButton: `Back to ${capitalize(category)} Rule Set`, + }; + } + + if (type === 'rule' && modeViewedFrom === 'create') { + return { + title: `Add an ${capitalize(category)} Rule or Rule Set`, + backButton: `Back to ${capitalize(category)} Rule`, + }; + } + + if (type === 'ruleset' && modeViewedFrom === 'view') { + return { + title: `${capitalize(category)} Rule Set details`, + backButton: 'Back to the Rule Set', + }; + } + + if (type === 'rule' && modeViewedFrom === 'edit') { + return { title: 'Edit Rule', backButton: 'Back to Rule' }; + } + + return defaultTexts; + }; + + const { title: titleText, backButton: backButtonText } = getDrawerTexts( + category, + context + ); + + const plFieldLabel = + context?.type === 'rule' && context.modeViewedFrom === undefined + ? 'Name' + : 'Prefix List Name'; + + const drawerFooter = ( + ({ + marginTop: theme.spacingFunction(16), + display: 'flex', + justifyContent: backButtonText ? 'flex-start' : 'flex-end', + })} + > + + + ); + + // For normal Prefix Lists: display all fields. + // For special Prefix Lists: display only 'Name' or 'Prefix List Name' and 'Description'. + const fields = [ + { + label: plFieldLabel, + value: prefixListDetails?.name ?? selectedPrefixListLabel, + }, + !isPrefixListSpecial && { + label: 'ID', + value: prefixListDetails?.id, + copy: true, + }, + { + label: 'Description', + value: prefixListDetails?.description, + column: true, + }, + !isPrefixListSpecial && + prefixListDetails?.name && { + label: 'Type', + value: getPrefixListType(prefixListDetails.name), + }, + !isPrefixListSpecial && + prefixListDetails?.visibility && { + label: 'Visibility', + value: capitalize(prefixListDetails.visibility), + }, + !isPrefixListSpecial && { + label: 'Version', + value: prefixListDetails?.version, + }, + !isPrefixListSpecial && + prefixListDetails?.created && { + label: 'Created', + value: , + }, + !isPrefixListSpecial && + prefixListDetails?.updated && { + label: 'Updated', + value: , + }, + ].filter(Boolean) as { + column?: boolean; + copy?: boolean; + label: string; + value: React.ReactNode | string; + }[]; + + return ( + onClose({ closeAll: true })} + open={isOpen} + title={titleText} + titleSuffix={ + getFeatureChip({ + isFirewallRulesetsPrefixlistsFeatureEnabled, + isFirewallRulesetsPrefixListsBetaEnabled, + isFirewallRulesetsPrefixListsGAEnabled, + }) ?? undefined + } + > + + {prefixListDetails && ( + <> + {fields.map((item, idx) => ( + + {item.label && ( + {item.label}: + )} + + {item.value} + + {item.copy && ( + + )} + + ))} + + {!isPrefixListSpecial && prefixListDetails.deleted && ( + + + ({ + color: theme.tokens.alias.Content.Text.Negative, + })} + > + Marked for deletion: + + ({ + color: theme.tokens.alias.Content.Text.Negative, + marginRight: theme.spacingFunction(4), + })} + value={prefixListDetails.deleted} + /> + + + )} + {!isPrefixListSpecial && ( + + {isIPv4Supported && ( + + )} + {isIPv6Supported && ( + + )} + + )} + + )} + + {drawerFooter} + + + ); + } +); diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallPrefixListIPSection.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallPrefixListIPSection.tsx new file mode 100644 index 00000000000..de9163f88c5 --- /dev/null +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallPrefixListIPSection.tsx @@ -0,0 +1,72 @@ +import { Chip, Paper } from '@linode/ui'; +import React from 'react'; + +import { StyledLabel, StyledListItem } from './shared.styles'; + +interface PrefixListIPSectionProps { + addresses: string[]; + inUse: boolean; + type: 'IPv4' | 'IPv6'; +} + +/** + * Displays a Prefix List IP section (IPv4 or IPv6) with usage indicator. + */ +export const PrefixListIPSection = ({ + type, + inUse, + addresses, +}: PrefixListIPSectionProps) => { + return ( + ({ + backgroundColor: theme.tokens.alias.Background.Neutral, + padding: theme.spacingFunction(12), + ...(inUse && { + border: `1px solid ${theme.tokens.alias.Border.Positive}`, + }), + })} + > + ({ + display: 'flex', + justifyContent: 'space-between', + marginBottom: theme.spacingFunction(4), + ...(!inUse && { + color: theme.tokens.alias.Content.Text.Primary.Disabled, + }), + })} + > + {type} + ({ + background: inUse + ? theme.tokens.component.Badge.Positive.Subtle.Background + : theme.tokens.component.Badge.Neutral.Subtle.Background, + color: inUse + ? theme.tokens.component.Badge.Positive.Subtle.Text + : theme.tokens.component.Badge.Neutral.Subtle.Text, + font: theme.font.bold, + fontSize: theme.tokens.font.FontSize.Xxxs, + marginRight: theme.spacingFunction(6), + flexShrink: 0, + })} + /> + + + ({ + ...(!inUse && { + color: theme.tokens.alias.Content.Text.Primary.Disabled, + }), + })} + > + {addresses.length > 0 ? addresses.join(', ') : no IP addresses} + + + ); +}; diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleActionMenu.test.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleActionMenu.test.tsx index 3888e665cb8..945ef63c842 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleActionMenu.test.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleActionMenu.test.tsx @@ -12,6 +12,7 @@ const props: FirewallRuleActionMenuProps = { handleCloneFirewallRule: vi.fn(), handleDeleteFirewallRule: vi.fn(), handleOpenRuleDrawerForEditing: vi.fn(), + isRuleSetRowEnabled: false, idx: 1, }; @@ -25,8 +26,37 @@ describe('Firewall rule action menu', () => { await userEvent.click(actionMenuButton); + // "Edit", "Clone" and "Delete" are all visible and enabled for (const action of ['Edit', 'Clone', 'Delete']) { expect(getByText(action)).toBeVisible(); } }); + + it('should include the correct actions when Firewall rules row is a RuleSet', async () => { + const { getByText, queryByText, queryByLabelText, findByRole } = + renderWithTheme( + + ); + + const actionMenuButton = queryByLabelText(/^Action menu for/)!; + + await userEvent.click(actionMenuButton); + + // "Edit" is visible but disabled, "Clone" is not present, and "Remove" is visible and enabled + for (const action of ['Edit', 'Remove']) { + expect(getByText(action)).toBeVisible(); + } + expect(queryByText('Clone')).toBeNull(); + + expect(getByText('Edit')).toBeDisabled(); + expect(getByText('Remove')).toBeEnabled(); + + // Hover over "Edit" and assert tooltip text + const editButton = getByText('Edit'); + await userEvent.hover(editButton); + const tooltip = await findByRole('tooltip'); + expect(tooltip).toHaveTextContent( + 'Edit your custom Rule Set\u2019s label, description, or rules, using the API. Rule Sets that are defined by a managed-service can only be updated by service accounts.' + ); + }); }); diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleActionMenu.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleActionMenu.tsx index 3f3313022a3..0cfecd14a0a 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleActionMenu.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleActionMenu.tsx @@ -1,3 +1,4 @@ +import { Box } from '@linode/ui'; import { useTheme } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; import * as React from 'react'; @@ -13,16 +14,20 @@ import type { export interface FirewallRuleActionMenuProps extends Partial { disabled: boolean; - handleCloneFirewallRule: (idx: number) => void; + handleCloneFirewallRule?: (idx: number) => void; // Cloning is NOT applicable in the case of ruleset handleDeleteFirewallRule: (idx: number) => void; - handleOpenRuleDrawerForEditing: (idx: number) => void; + handleOpenRuleDrawerForEditing?: (idx: number) => void; // Editing is NOT applicable in the case of ruleset idx: number; + isRuleSetRowEnabled: boolean; } export const FirewallRuleActionMenu = React.memo( (props: FirewallRuleActionMenuProps) => { const theme = useTheme(); - const matchesSmDown = useMediaQuery(theme.breakpoints.down('md')); + const matchesLgDown = useMediaQuery(theme.breakpoints.down('lg')); + + const rulesetEditActionToolTipText = + 'Edit your custom Rule Set\u2019s label, description, or rules, using the API. Rule Sets that are defined by a managed-service can only be updated by service accounts.'; const { disabled, @@ -30,47 +35,57 @@ export const FirewallRuleActionMenu = React.memo( handleDeleteFirewallRule, handleOpenRuleDrawerForEditing, idx, + isRuleSetRowEnabled, ...actionMenuProps } = props; const actions: Action[] = [ { - disabled, + disabled: disabled || isRuleSetRowEnabled, onClick: () => { - handleOpenRuleDrawerForEditing(idx); + handleOpenRuleDrawerForEditing?.(idx); }, title: 'Edit', + tooltip: isRuleSetRowEnabled ? rulesetEditActionToolTipText : undefined, }, - { - disabled, - onClick: () => { - handleCloneFirewallRule(idx); - }, - title: 'Clone', - }, + ...(!isRuleSetRowEnabled + ? [ + { + disabled, + onClick: () => { + handleCloneFirewallRule?.(idx); + }, + title: 'Clone', + }, + ] + : []), { disabled, onClick: () => { handleDeleteFirewallRule(idx); }, - title: 'Delete', + title: isRuleSetRowEnabled ? 'Remove' : 'Delete', }, ]; return ( <> - {!matchesSmDown && - actions.map((action) => { - return ( - - ); - })} - {matchesSmDown && ( + {!matchesLgDown && ( + + {actions.map((action) => { + return ( + + ); + })} + + )} + {matchesLgDown && ( ({ + useFirewallRuleSetQuery: vi.fn().mockReturnValue({}), +})); + +vi.mock('@linode/queries', async () => { + const actual = await vi.importActual('@linode/queries'); + return { + ...actual, + useFirewallRuleSetQuery: queryMocks.useFirewallRuleSetQuery, + }; +}); + +vi.mock('@linode/utilities', async () => { + const actual = await vi.importActual('@linode/utilities'); + return { + ...actual, + getUserTimezone: vi.fn().mockReturnValue('utc'), + }; +}); + +const mockHandleOpenPrefixListDrawer = vi.fn(); const mockOnClose = vi.fn(); const mockOnSubmit = vi.fn(); @@ -33,12 +61,23 @@ const props: FirewallRuleDrawerProps = { category: 'inbound', isOpen: true, mode: 'create', + inboundAndOutboundRules: [], + handleOpenPrefixListDrawer: mockHandleOpenPrefixListDrawer, onClose: mockOnClose, onSubmit: mockOnSubmit, }; +const spy = vi.spyOn(shared, 'useIsFirewallRulesetsPrefixlistsEnabled'); + describe('AddRuleDrawer', () => { it('renders the title', () => { + spy.mockReturnValue({ + isFirewallRulesetsPrefixlistsFeatureEnabled: false, + isFirewallRulesetsPrefixListsBetaEnabled: false, + isFirewallRulesetsPrefixListsLAEnabled: false, + isFirewallRulesetsPrefixListsGAEnabled: false, + }); + const { getByText } = renderWithTheme( ); @@ -66,20 +105,217 @@ describe('AddRuleDrawer', () => { }); }); +describe('AddRuleSetDrawer', () => { + beforeEach(() => { + spy.mockReturnValue({ + isFirewallRulesetsPrefixlistsFeatureEnabled: true, + isFirewallRulesetsPrefixListsBetaEnabled: false, + isFirewallRulesetsPrefixListsLAEnabled: false, + isFirewallRulesetsPrefixListsGAEnabled: false, + }); + }); + + it('renders the drawer title', () => { + const { getByText } = renderWithTheme( + + ); + + expect(getByText('Add an Inbound Rule or Rule Set')).toBeVisible(); + }); + + it('renders the selection cards', () => { + const { getByText } = renderWithTheme( + + ); + + expect(getByText(/Create a Rule/i)).toBeVisible(); + expect(getByText(/Reference Rule Set/i)).toBeVisible(); + }); + + it('renders the Rule Set form and its elements when selection card is clicked', async () => { + const { getByText, getByPlaceholderText, getByRole } = renderWithTheme( + + ); + + const ruleSetCard = getByText(/Reference Rule Set/i); + await userEvent.click(ruleSetCard); + + // Description + expect( + getByText( + 'RuleSets are reusable collections of Cloud Firewall rules that use the same fields as individual rules. They let you manage and update multiple rules as a group. You can then apply them across different firewalls by reference.' + ) + ).toBeVisible(); + + // Autocomplete field + expect(getByText('Rule Set')).toBeVisible(); + expect( + getByPlaceholderText('Type to search or select a Rule Set') + ).toBeVisible(); + + // Action buttons + expect(getByRole('button', { name: 'Add Rule' })).toBeVisible(); + expect(getByRole('button', { name: 'Cancel' })).toBeVisible(); + + // Footer text + expect( + getByText( + 'Rule changes don’t take effect immediately. You can add or delete rules before saving all your changes to this Firewall.' + ) + ).toBeVisible(); + }); + + it('shows validation message when Rule Set form is submitted without selecting a value', async () => { + const { getByText, getByRole } = renderWithTheme( + + ); + + // Click the Rule Set Selection card to open the Rule Set form + const ruleSetCard = getByText(/Reference Rule Set/i); + await userEvent.click(ruleSetCard); + + // Click the "Add Rule" button without selecting the Autocomplete field + const addRuleButton = getByRole('button', { name: 'Add Rule' }); + await userEvent.click(addRuleButton); + + // Expect the validation message to appear + getByText('Rule Set is required.'); + }); +}); + +describe('ViewRuleSetDetailsDrawer', () => { + beforeEach(() => { + spy.mockReturnValue({ + isFirewallRulesetsPrefixlistsFeatureEnabled: true, + isFirewallRulesetsPrefixListsBetaEnabled: false, + isFirewallRulesetsPrefixListsLAEnabled: false, + isFirewallRulesetsPrefixListsGAEnabled: false, + }); + }); + + const activeRuleSet = firewallRuleSetFactory.build({ id: 123 }); + const deletedRuleSet = firewallRuleSetFactory.build({ + id: 456, + deleted: '2025-07-24T04:23:17', + }); + + it.each([ + ['inbound', activeRuleSet], + ['outbound', activeRuleSet], + ['inbound', deletedRuleSet], + ['outbound', deletedRuleSet], + ] as [Category, FirewallRuleSet][])( + 'renders %s ruleset drawer (%s)', + async (category, mockData) => { + queryMocks.useFirewallRuleSetQuery.mockReturnValue({ + data: mockData, + isFetching: false, + error: null, + }); + + const { getByText, getByRole, getByTestId, findByText, queryByText } = + renderWithTheme( + + ); + + // Drawer title + expect( + getByText(`${capitalize(category)} Rule Set details`) + ).toBeVisible(); + + // Labels + const labels = [ + 'Label', + 'ID', + 'Description', + 'Service Defined', + 'Version', + 'Created', + 'Updated', + ]; + labels.forEach((label) => expect(getByText(`${label}:`)).toBeVisible()); + + // Check ID value + expect(getByText(`${mockData.id}`)).toBeVisible(); + + if (mockData.deleted) { + // Marked for deletion status section + expect(getByText('Marked for deletion:')).toBeVisible(); + expect(getByText('2025-07-24 04:23')).toBeVisible(); + // Tooltip icon should exist + const tooltipIcon = getByTestId('tooltip-info-icon'); + expect(tooltipIcon).toBeInTheDocument(); + + // Tooltip text should exist + await userEvent.hover(tooltipIcon); + expect( + await findByText(RULESET_MARKED_FOR_DELETION_TEXT) + ).toBeVisible(); + } else { + // Marked for deletion status section should not exist + expect(queryByText('Marked for deletion:')).not.toBeInTheDocument(); + } + + // Rules section + expect(getByText(`${capitalize(category)} Rules`)).toBeVisible(); + + // Cancel button + expect(getByRole('button', { name: 'Close' })).toBeVisible(); + } + ); +}); + +describe('EditRuleDrawer', () => { + it('should not show the Firewall RS & PL feature chip in the title in Edit mode', () => { + spy.mockReturnValue({ + isFirewallRulesetsPrefixlistsFeatureEnabled: true, + isFirewallRulesetsPrefixListsBetaEnabled: true, + isFirewallRulesetsPrefixListsLAEnabled: false, + isFirewallRulesetsPrefixListsGAEnabled: false, + }); + + const { getByTestId } = renderWithTheme( + + ); + + const titleContainer = getByTestId('drawer-title-container'); + + // The beta (chip) should NOT be in the title area + expect(within(titleContainer).queryByText('beta')).not.toBeInTheDocument(); + }); +}); + describe('utilities', () => { describe('formValueToIPs', () => { it('returns a complete set of IPs given a string form value', () => { - expect(formValueToIPs('all', [''].map(stringToExtendedIP))).toEqual( + expect(formValueToIPs('all', [''].map(stringToExtendedIP), [])).toEqual( allIPs ); - expect(formValueToIPs('allIPv4', [''].map(stringToExtendedIP))).toEqual({ + expect( + formValueToIPs('allIPv4', [''].map(stringToExtendedIP), []) + ).toEqual({ ipv4: ['0.0.0.0/0'], }); - expect(formValueToIPs('allIPv6', [''].map(stringToExtendedIP))).toEqual({ + expect( + formValueToIPs('allIPv6', [''].map(stringToExtendedIP), []) + ).toEqual({ ipv6: ['::/0'], }); expect( - formValueToIPs('ip/netmask', ['1.1.1.1'].map(stringToExtendedIP)) + formValueToIPs( + 'ip/netmask/prefixlist', + ['1.1.1.1'].map(stringToExtendedIP), + [] + ) ).toEqual({ ipv4: ['1.1.1.1'], }); @@ -98,22 +334,27 @@ describe('utilities', () => { }); describe('validateForm', () => { + const baseOptions = { + validatedIPs: [], + validatedPLs: [], + isFirewallRulesetsPrefixlistsFeatureEnabled: false, + }; + it('validates protocol', () => { - expect(validateForm({})).toHaveProperty( + expect(validateForm({}, baseOptions)).toHaveProperty( 'protocol', 'Protocol is required.' ); }); it('validates ports', () => { - expect(validateForm({ ports: '80', protocol: 'ICMP' })).toHaveProperty( - 'ports', - 'Ports are not allowed for ICMP protocols.' - ); expect( - validateForm({ ports: '443', protocol: 'IPENCAP' }) + validateForm({ ports: '80', protocol: 'ICMP' }, baseOptions) + ).toHaveProperty('ports', 'Ports are not allowed for ICMP protocols.'); + expect( + validateForm({ ports: '443', protocol: 'IPENCAP' }, baseOptions) ).toHaveProperty('ports', 'Ports are not allowed for IPENCAP protocols.'); expect( - validateForm({ ports: 'invalid-port', protocol: 'TCP' }) + validateForm({ ports: 'invalid-port', protocol: 'TCP' }, baseOptions) ).toHaveProperty('ports'); }); it('validates custom ports', () => { @@ -122,56 +363,77 @@ describe('utilities', () => { label: 'Firewalllabel', }; // SUCCESS CASES - expect(validateForm({ ports: '1234', protocol: 'TCP', ...rest })).toEqual( - {} - ); expect( - validateForm({ ports: '1,2,3,4,5', protocol: 'TCP', ...rest }) + validateForm({ ports: '1234', protocol: 'TCP', ...rest }, baseOptions) ).toEqual({}); expect( - validateForm({ ports: '1, 2, 3, 4, 5', protocol: 'TCP', ...rest }) + validateForm( + { ports: '1,2,3,4,5', protocol: 'TCP', ...rest }, + baseOptions + ) ).toEqual({}); - expect(validateForm({ ports: '1-20', protocol: 'TCP', ...rest })).toEqual( - {} - ); expect( - validateForm({ - ports: '1,2,3,4,5,6,7,8,9,10,11,12,13,14,15', - protocol: 'TCP', - ...rest, - }) + validateForm( + { ports: '1, 2, 3, 4, 5', protocol: 'TCP', ...rest }, + baseOptions + ) + ).toEqual({}); + expect( + validateForm({ ports: '1-20', protocol: 'TCP', ...rest }, baseOptions) + ).toEqual({}); + expect( + validateForm( + { + ports: '1,2,3,4,5,6,7,8,9,10,11,12,13,14,15', + protocol: 'TCP', + ...rest, + }, + baseOptions + ) ).toEqual({}); expect( - validateForm({ ports: '1-2,3-4', protocol: 'TCP', ...rest }) + validateForm( + { ports: '1-2,3-4', protocol: 'TCP', ...rest }, + baseOptions + ) ).toEqual({}); expect( - validateForm({ ports: '1,5-12', protocol: 'TCP', ...rest }) + validateForm({ ports: '1,5-12', protocol: 'TCP', ...rest }, baseOptions) ).toEqual({}); // FAILURE CASES expect( - validateForm({ ports: '1,21-12', protocol: 'TCP', ...rest }) + validateForm( + { ports: '1,21-12', protocol: 'TCP', ...rest }, + baseOptions + ) ).toHaveProperty( 'ports', 'Range must start with a smaller number and end with a larger number' ); expect( - validateForm({ ports: '1-21-45', protocol: 'TCP', ...rest }) + validateForm( + { ports: '1-21-45', protocol: 'TCP', ...rest }, + baseOptions + ) ).toHaveProperty('ports', 'Ranges must have 2 values'); expect( - validateForm({ ports: 'abc', protocol: 'TCP', ...rest }) + validateForm({ ports: 'abc', protocol: 'TCP', ...rest }, baseOptions) ).toHaveProperty('ports', 'Must be 1-65535'); expect( - validateForm({ ports: '1--20', protocol: 'TCP', ...rest }) + validateForm({ ports: '1--20', protocol: 'TCP', ...rest }, baseOptions) ).toHaveProperty('ports', 'Must be 1-65535'); expect( - validateForm({ ports: '-20', protocol: 'TCP', ...rest }) + validateForm({ ports: '-20', protocol: 'TCP', ...rest }, baseOptions) ).toHaveProperty('ports', 'Must be 1-65535'); expect( - validateForm({ - ports: '1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16', - protocol: 'TCP', - ...rest, - }) + validateForm( + { + ports: '1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16', + protocol: 'TCP', + ...rest, + }, + baseOptions + ) ).toHaveProperty( 'ports', 'Number of ports or port range endpoints exceeded. Max allowed is 15' @@ -226,11 +488,79 @@ describe('utilities', () => { ports: '80', protocol: 'TCP', }; - expect(validateForm({ label: value, ...rest })).toEqual(result); + expect(validateForm({ label: value, ...rest }, baseOptions)).toEqual( + result + ); }); }); + + it('handles addresses field when isFirewallRulesetsPrefixlistsFeatureEnabled is true', () => { + // Invalid cases + expect( + validateForm( + {}, + { + ...baseOptions, + isFirewallRulesetsPrefixlistsFeatureEnabled: true, + } + ) + ).toHaveProperty('addresses', 'Sources is a required field.'); + + expect( + validateForm( + { addresses: 'ip/netmask/prefixlist' }, + { + ...baseOptions, + isFirewallRulesetsPrefixlistsFeatureEnabled: true, + } + ) + ).toHaveProperty( + 'addresses', + 'Add an IP address in IP/mask format, or reference a Prefix List name.' + ); + + // Valid cases + expect( + validateForm( + { addresses: 'ip/netmask/prefixlist' }, + { + validatedIPs: [ + { address: '192.268.0.0' }, + { address: '192.268.0.1' }, + ], + validatedPLs: [ + { address: 'pl:system:test', inIPv4Rule: true, inIPv6Rule: true }, + ], + isFirewallRulesetsPrefixlistsFeatureEnabled: true, + } + ) + ).not.toHaveProperty('addresses'); + expect( + validateForm( + { addresses: 'ip/netmask/prefixlist' }, + { + validatedIPs: [{ address: '192.268.0.0' }], + validatedPLs: [], + isFirewallRulesetsPrefixlistsFeatureEnabled: true, + } + ) + ).not.toHaveProperty('addresses'); + expect( + validateForm( + { addresses: 'ip/netmask/prefixlist' }, + { + validatedIPs: [], + validatedPLs: [ + { address: 'pl:system:test', inIPv4Rule: true, inIPv6Rule: true }, + ], + isFirewallRulesetsPrefixlistsFeatureEnabled: true, + } + ) + ).not.toHaveProperty('addresses'); + }); + it('handles required fields', () => { - expect(validateForm({})).toEqual({ + expect(validateForm({}, baseOptions)).toEqual({ addresses: 'Sources is a required field.', label: 'Label is required.', ports: 'Ports is a required field.', @@ -239,11 +569,11 @@ describe('utilities', () => { }); }); - describe('getInitialIPs', () => { + describe('getInitialIPsOrPLs', () => { const ruleToModify: ExtendedFirewallRule = { action: 'ACCEPT', addresses: { - ipv4: ['1.2.3.4'], + ipv4: ['1.2.3.4', 'pl:system:test'], ipv6: ['::0'], }, originalIndex: 0, @@ -252,10 +582,8 @@ describe('utilities', () => { status: 'NEW', }; it('parses the IPs when no errors', () => { - expect(getInitialIPs(ruleToModify)).toEqual([ - { address: '1.2.3.4' }, - { address: '::0' }, - ]); + const { ips: initalIPs } = getInitialIPsOrPLs(ruleToModify); + expect(initalIPs).toEqual([{ address: '1.2.3.4' }, { address: '::0' }]); }); it('parses the IPs with no errors', () => { const errors: FirewallRuleError[] = [ @@ -267,13 +595,17 @@ describe('utilities', () => { reason: 'Invalid IP', }, ]; - expect(getInitialIPs({ ...ruleToModify, errors })).toEqual([ + const { ips: initalIPs } = getInitialIPsOrPLs({ + ...ruleToModify, + errors, + }); + expect(initalIPs).toEqual([ { address: '1.2.3.4', error: IP_ERROR_MESSAGE }, { address: '::0' }, ]); }); it('offsets error indices correctly', () => { - const result = getInitialIPs({ + const { ips: initialIPs } = getInitialIPsOrPLs({ ...ruleToModify, addresses: { ipv4: ['1.2.3.4'], @@ -289,11 +621,17 @@ describe('utilities', () => { }, ], }); - expect(result).toEqual([ + expect(initialIPs).toEqual([ { address: '1.2.3.4' }, { address: 'INVALID_IP', error: IP_ERROR_MESSAGE }, ]); }); + it('parses the PLs when no errors', () => { + const { pls: initalPLs } = getInitialIPsOrPLs(ruleToModify); + expect(initalPLs).toEqual([ + { address: 'pl:system:test', inIPv4Rule: true, inIPv6Rule: false }, + ]); + }); }); describe('classifyIPs', () => { @@ -322,12 +660,13 @@ describe('utilities', () => { }; it('correctly matches values to their representative type', () => { - const result = deriveTypeFromValuesAndIPs(formValues, []); + const result = deriveTypeFromValuesAndIPs(formValues, [], []); expect(result).toBe('https'); }); it('returns "custom" if there is no match', () => { const result = deriveTypeFromValuesAndIPs( { ...formValues, ports: '22-23' }, + [], [] ); expect(result).toBe('custom'); diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx index 1829f4fc21c..b766d7eb77a 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx @@ -1,43 +1,83 @@ -import { Drawer, Typography } from '@linode/ui'; +import { Drawer, Notice, Radio, Typography } from '@linode/ui'; import { capitalize } from '@linode/utilities'; +import { Grid } from '@mui/material'; import { Formik } from 'formik'; import * as React from 'react'; +import { SelectionCard } from 'src/components/SelectionCard/SelectionCard'; + +import { + getFeatureChip, + useIsFirewallRulesetsPrefixlistsEnabled, +} from '../../shared'; import { formValueToIPs, getInitialFormValues, - getInitialIPs, + getInitialIPsOrPLs, itemsToPortString, portStringToItems, validateForm, validateIPs, + validatePrefixLists, } from './FirewallRuleDrawer.utils'; import { FirewallRuleForm } from './FirewallRuleForm'; +import { FirewallRuleSetDetailsView } from './FirewallRuleSetDetailsView'; +import { FirewallRuleSetForm } from './FirewallRuleSetForm'; +import { firewallRuleCreateOptions } from './shared'; import type { FirewallOptionItem } from '../../shared'; import type { + FirewallCreateEntityType, FirewallRuleDrawerProps, + FormRuleSetState, FormState, } from './FirewallRuleDrawer.types'; +import type { ValidateFormOptions } from './FirewallRuleDrawer.utils'; import type { FirewallRuleProtocol, FirewallRuleType, } from '@linode/api-v4/lib/firewalls'; -import type { ExtendedIP } from 'src/utilities/ipUtils'; +import type { ExtendedIP, ExtendedPL } from 'src/utilities/ipUtils'; // ============================================================================= // // ============================================================================= export const FirewallRuleDrawer = React.memo( (props: FirewallRuleDrawerProps) => { - const { category, isOpen, mode, onClose, ruleToModify } = props; + const { + category, + handleOpenPrefixListDrawer, + isOpen, + mode, + onClose, + inboundAndOutboundRules, + ruleToModifyOrView, + } = props; + + const { + isFirewallRulesetsPrefixlistsFeatureEnabled, + isFirewallRulesetsPrefixListsBetaEnabled, + isFirewallRulesetsPrefixListsGAEnabled, + } = useIsFirewallRulesetsPrefixlistsEnabled(); - // Custom IPs are tracked separately from the form. The + /** + * State for the type of entity being created: either a firewall 'rule' or + * referencing an existing 'ruleset' in the firewall. + * Only relevant when `mode === 'create'`. + */ + const [createEntityType, setCreateEntityType] = + React.useState('rule'); + + // Custom IPs or PLs are tracked separately from the form. The or // component consumes this state. We use this on form submission if the - // `addresses` form value is "ip/netmask", which indicates the user has - // intended to specify custom IPs. + // `addresses` form value is "ip/netmask/prefixlist", which indicates the user has + // intended to specify custom IPs or PLs. const [ips, setIPs] = React.useState([{ address: '' }]); + const [pls, setPLs] = React.useState([ + { address: '', inIPv4Rule: false, inIPv6Rule: false }, + ]); + // Firewall Ports, like IPs, are tracked separately. The form.values state value // tracks the custom user input; the FirewallOptionItem[] array of port presets in the multi-select // is stored here. @@ -45,103 +85,272 @@ export const FirewallRuleDrawer = React.memo( FirewallOptionItem[] >([]); - // Reset state. If we're in EDIT mode, set IPs to the addresses of the rule we're modifying - // (along with any errors we may have). React.useEffect(() => { - if (mode === 'edit' && ruleToModify) { - setIPs(getInitialIPs(ruleToModify)); - setPresetPorts(portStringToItems(ruleToModify.ports)[0]); + // Reset state. If we're in EDIT mode, set IPs to the addresses of the rule we're modifying + // (along with any errors we may have). + if (mode === 'edit' && ruleToModifyOrView) { + const { ips, pls } = getInitialIPsOrPLs(ruleToModifyOrView); + setIPs(ips); + setPLs(pls); + setPresetPorts(portStringToItems(ruleToModifyOrView.ports)[0]); } else if (isOpen) { setPresetPorts([]); } else { setIPs([{ address: '' }]); + setPLs([]); + } + + // Reset the Create entity selection to 'rule' in two cases: + // 1. The ruleset feature flag is disabled - 'ruleset' is not allowed. + // 2. The drawer is closed - ensures the next time it opens, it starts with the default 'rule' selection. + if ( + mode === 'create' && + (!isFirewallRulesetsPrefixlistsFeatureEnabled || !isOpen) + ) { + setCreateEntityType('rule'); } - }, [mode, isOpen, ruleToModify]); + }, [ + mode, + isOpen, + ruleToModifyOrView, + isFirewallRulesetsPrefixlistsFeatureEnabled, + ]); const title = - mode === 'create' ? `Add an ${capitalize(category)} Rule` : 'Edit Rule'; + mode === 'create' + ? `Add an ${capitalize(category)} Rule${ + isFirewallRulesetsPrefixlistsFeatureEnabled ? ' or Rule Set' : '' + }` + : mode === 'edit' + ? 'Edit Rule' + : `${capitalize(category)} Rule Set details`; const addressesLabel = category === 'inbound' ? 'source' : 'destination'; - const onValidate = ({ - addresses, - description, - label, - ports, - protocol, - }: FormState) => { + const onValidateRule = (values: FormState) => { + const { addresses, description, label, ports, protocol } = values; + // The validated IPs may have errors, so set them to state so we see the errors. const validatedIPs = validateIPs(ips, { - allowEmptyAddress: addresses !== 'ip/netmask', + allowEmptyAddress: addresses !== 'ip/netmask/prefixlist', }); setIPs(validatedIPs); - const _ports = itemsToPortString(presetPorts, ports); + // The validated PLs may have errors, so set them to state so we see the errors. + const validatedPLs = validatePrefixLists(pls); + setPLs(validatedPLs); + + const _ports = itemsToPortString(presetPorts, ports!); + + const validateFormOptions: ValidateFormOptions = { + validatedIPs, + validatedPLs, + isFirewallRulesetsPrefixlistsFeatureEnabled, + }; return { - ...validateForm({ - addresses, - description, - label, - ports: _ports, - protocol, - }), + ...validateForm( + { + addresses, + description, + label, + ports: _ports, + protocol, + }, + validateFormOptions + ), // This is a bit of a trick. If this function DOES NOT return an empty object, Formik will call // `onSubmit()`. If there are IP errors, we add them to the return object so Formik knows there // is an issue with the form. ...validatedIPs.filter((thisIP) => Boolean(thisIP.error)), + // For PrefixLists + ...validatedPLs.filter((thisPL) => Boolean(thisPL.error)), }; }; - const onSubmit = (values: FormState) => { - const ports = itemsToPortString(presetPorts, values.ports); + const onSubmitRule = (values: FormState) => { + const ports = itemsToPortString(presetPorts, values.ports!); const protocol = values.protocol as FirewallRuleProtocol; - const addresses = formValueToIPs(values.addresses, ips); + const addresses = formValueToIPs(values.addresses!, ips, pls); const payload: FirewallRuleType = { action: values.action, addresses, ports, protocol, + label: values.label || null, + description: values.description || null, }; - - payload.label = values.label === '' ? null : values.label; - payload.description = - values.description === '' ? null : values.description; - props.onSubmit(category, payload); onClose(); }; + const onValidateRuleSet = (values: FormRuleSetState) => { + const errors: Record = {}; + if (!values.ruleset || values.ruleset === -1) { + errors.ruleset = 'Rule Set is required.'; + } + if (typeof values.ruleset !== 'number') { + errors.ruleset = 'Rule Set should be a number.'; + } + return errors; + }; + + const featureChip = + getFeatureChip({ + isFirewallRulesetsPrefixlistsFeatureEnabled, + isFirewallRulesetsPrefixListsBetaEnabled, + isFirewallRulesetsPrefixListsGAEnabled, + }) ?? undefined; + + // Do not show the Firewall RS & PL feature chip in Edit mode drawer title + const titleSuffix = mode === 'edit' ? undefined : featureChip; + return ( - - - {(formikProps) => { - return ( - + {mode === 'create' && isFirewallRulesetsPrefixlistsFeatureEnabled && ( + + {firewallRuleCreateOptions.map((option) => ( + setCreateEntityType(option.value)} + renderIcon={() => ( + + )} + subheadings={[]} + sxCardBase={(theme) => ({ + gap: 0, + '& .cardSubheadingTitle': { + fontSize: theme.tokens.font.FontSize.Xs, + }, + })} + sxCardBaseIcon={(theme) => ({ + svg: { fontSize: theme.tokens.font.FontSize.L }, + })} /> - ); - }} - - - Rule changes don’t take effect immediately. You can add or - delete rules before saving all your changes to this Firewall. - + ))} +
    + )} + + {(mode === 'edit' || + (mode === 'create' && createEntityType === 'rule')) && ( + + initialValues={getInitialFormValues(ruleToModifyOrView)} + onSubmit={onSubmitRule} + validate={onValidateRule} + validateOnBlur={false} + validateOnChange={false} + > + {(formikProps) => ( + <> + {formikProps.status && ( + + )} + { + handleOpenPrefixListDrawer( + prefixListLabel, + plRuleRef, + 'rule' + ); + }} + ips={ips} + mode={mode} + pls={pls} + presetPorts={presetPorts} + ruleErrors={ruleToModifyOrView?.errors} + setIPs={setIPs} + setPLs={setPLs} + setPresetPorts={setPresetPorts} + {...formikProps} + /> + + )} + + )} + + {mode === 'create' && + createEntityType === 'ruleset' && + isFirewallRulesetsPrefixlistsFeatureEnabled && ( + + initialValues={{ ruleset: -1 }} + onSubmit={(values) => { + props.onSubmit(category, values); + onClose(); + }} + validate={onValidateRuleSet} + validateOnBlur={true} + validateOnChange={true} + > + {(formikProps) => ( + <> + {formikProps.status && ( + + )} + { + handleOpenPrefixListDrawer( + prefixListLabel, + plRuleRef, + 'ruleset' + ); + }} + inboundAndOutboundRules={inboundAndOutboundRules} + ruleErrors={ruleToModifyOrView?.errors} + {...formikProps} + /> + + )} + + )} + + {mode === 'view' && ( + { + handleOpenPrefixListDrawer(prefixListLabel, plRuleRef, 'ruleset'); + }} + ruleset={ruleToModifyOrView?.ruleset} + /> + )} + + {(mode === 'create' || mode === 'edit') && ( + + Rule changes don’t take effect immediately. You can add or + delete rules before saving all your changes to this Firewall. + + )} ); } diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.types.ts b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.types.ts index d344e3146ab..b3ded393408 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.types.ts +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.types.ts @@ -1,4 +1,5 @@ -import type { FirewallOptionItem } from '../../shared'; +import type { FirewallOptionItem, PrefixListRuleReference } from '../../shared'; +import type { PrefixListDrawerContext } from './FirewallPrefixListDrawer'; import type { ExtendedFirewallRule } from './firewallRuleEditor'; import type { Category, FirewallRuleError } from './shared'; import type { @@ -6,17 +7,23 @@ import type { FirewallRuleType, } from '@linode/api-v4/lib/firewalls'; import type { FormikProps } from 'formik'; -import type { ExtendedIP } from 'src/utilities/ipUtils'; +import type { ExtendedIP, ExtendedPL } from 'src/utilities/ipUtils'; -export type FirewallRuleDrawerMode = 'create' | 'edit'; +export type FirewallRuleDrawerMode = 'create' | 'edit' | 'view'; export interface FirewallRuleDrawerProps { category: Category; + handleOpenPrefixListDrawer: ( + prefixListLabel: string, + plRuleRef: PrefixListRuleReference, + contextType: PrefixListDrawerContext['type'] + ) => void; + inboundAndOutboundRules: FirewallRuleType[]; isOpen: boolean; mode: FirewallRuleDrawerMode; onClose: () => void; onSubmit: (category: 'inbound' | 'outbound', rule: FirewallRuleType) => void; - ruleToModify?: ExtendedFirewallRule; + ruleToModifyOrView?: ExtendedFirewallRule; } export interface FormState { @@ -29,13 +36,38 @@ export interface FormState { type: string; } +export interface FormRuleSetState { + ruleset: number; +} + +export type FirewallCreateEntityType = 'rule' | 'ruleset'; + export interface FirewallRuleFormProps extends FormikProps { addressesLabel: string; category: Category; + closeDrawer: () => void; + handleOpenPrefixListDrawer: ( + prefixListLabel: string, + plRuleRef: PrefixListRuleReference + ) => void; ips: ExtendedIP[]; mode: FirewallRuleDrawerMode; + pls: ExtendedPL[]; presetPorts: FirewallOptionItem[]; ruleErrors?: FirewallRuleError[]; setIPs: (ips: ExtendedIP[]) => void; + setPLs: (pls: ExtendedPL[]) => void; setPresetPorts: (selected: FirewallOptionItem[]) => void; } + +export interface FirewallRuleSetFormProps + extends FormikProps { + category: Category; + closeDrawer: () => void; + handleOpenPrefixListDrawer: ( + prefixListLabel: string, + plRuleRef: PrefixListRuleReference + ) => void; + inboundAndOutboundRules: FirewallRuleType[]; + ruleErrors?: FirewallRuleError[]; +} diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.utils.ts b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.utils.ts index 25a7c9ac36d..0f8aa88f3dd 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.utils.ts +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.utils.ts @@ -13,6 +13,7 @@ import { allowNoneIPv4, allowNoneIPv6, allowsAllIPs, + buildPrefixListReferenceMap, predefinedFirewallFromRule, } from 'src/features/Firewalls/shared'; import { stringToExtendedIP } from 'src/utilities/ipUtils'; @@ -26,7 +27,7 @@ import type { FirewallRuleType, } from '@linode/api-v4/lib/firewalls'; import type { FirewallOptionItem } from 'src/features/Firewalls/shared'; -import type { ExtendedIP } from 'src/utilities/ipUtils'; +import type { ExtendedIP, ExtendedPL } from 'src/utilities/ipUtils'; export const IP_ERROR_MESSAGE = 'Must be a valid IPv4 or IPv6 range.'; @@ -42,7 +43,8 @@ export const IP_ERROR_MESSAGE = 'Must be a valid IPv4 or IPv6 range.'; */ export const deriveTypeFromValuesAndIPs = ( values: FormState, - ips: ExtendedIP[] + ips: ExtendedIP[], + pls: ExtendedPL[] ) => { if (values.type === 'custom') { return 'custom'; @@ -52,7 +54,7 @@ export const deriveTypeFromValuesAndIPs = ( const predefinedFirewall = predefinedFirewallFromRule({ action: 'ACCEPT', - addresses: formValueToIPs(values.addresses, ips), + addresses: formValueToIPs(values.addresses!, ips, pls), ports: values.ports, protocol, }); @@ -60,9 +62,9 @@ export const deriveTypeFromValuesAndIPs = ( if (predefinedFirewall) { return predefinedFirewall; } else if ( - values.protocol?.length > 0 || + (values.protocol && values.protocol?.length > 0) || (values.ports && values.ports?.length > 0) || - values.addresses?.length > 0 + (values.addresses && values.addresses?.length > 0) ) { return 'custom'; } @@ -74,7 +76,8 @@ export const deriveTypeFromValuesAndIPs = ( */ export const formValueToIPs = ( formValue: string, - ips: ExtendedIP[] + ips: ExtendedIP[], + pls: ExtendedPL[] ): FirewallRuleType['addresses'] => { switch (formValue) { case 'all': @@ -83,10 +86,34 @@ export const formValueToIPs = ( return { ipv4: [allIPv4] }; case 'allIPv6': return { ipv6: [allIPv6] }; - default: - // The user has selected "IP / Netmask" and entered custom IPs, so we need + default: { + // The user has selected "IP / Netmask / Prefix List" and entered custom IPs or selected PLs, so we need // to separate those into v4 and v6 addresses. - return classifyIPs(ips); + const classifiedIPs = classifyIPs(ips); + const classifiedPLs = classifyPLs(pls); + + const ruleIPv4 = [ + ...(classifiedIPs.ipv4 ?? []), + ...(classifiedPLs.ipv4 ?? []), + ]; + + const ruleIPv6 = [ + ...(classifiedIPs.ipv6 ?? []), + ...(classifiedPLs.ipv6 ?? []), + ]; + + const result: FirewallRuleType['addresses'] = {}; + + if (ruleIPv4.length > 0) { + result.ipv4 = ruleIPv4; + } + + if (ruleIPv6.length > 0) { + result.ipv6 = ruleIPv6; + } + + return result; + } } }; @@ -112,6 +139,34 @@ export const validateIPs = ( }); }; +export const validatePrefixLists = (pls: ExtendedPL[]): ExtendedPL[] => { + const seen = new Set(); + return pls.map((pl) => { + const { address, inIPv4Rule, inIPv6Rule } = pl; + + if (!pl.address) { + return { ...pl, error: 'Please select the Prefix List.' }; + } + + if (pl.inIPv4Rule === false && pl.inIPv6Rule === false) { + return { + ...pl, + error: 'At least one IPv4 or IPv6 option must be selected.', + }; + } + + if (seen.has(pl.address)) { + return { + ...pl, + error: 'This Prefix List is already selected.', + }; + } + + seen.add(pl.address); + return { address, inIPv4Rule, inIPv6Rule }; + }); +}; + /** * Given an array of IP addresses, filter out invalid addresses and categorize * them by "ipv4" and "ipv6." @@ -138,6 +193,28 @@ export const classifyIPs = (ips: ExtendedIP[]) => { ); }; +/** + * Given an array of Firewall Rule IP addresses, categorize + * Prefix List by "ipv4" and "ipv6." + */ +export const classifyPLs = (pls: ExtendedPL[]) => { + return pls.reduce<{ ipv4?: string[]; ipv6?: string[] }>((acc, pl) => { + if (pl.inIPv4Rule) { + if (!acc.ipv4) { + acc.ipv4 = []; + } + acc.ipv4.push(pl.address); + } + if (pl.inIPv6Rule) { + if (!acc.ipv6) { + acc.ipv6 = []; + } + acc.ipv6.push(pl.address); + } + return acc; + }, {}); +}; + const initialValues: FormState = { action: 'ACCEPT', addresses: '', @@ -163,7 +240,7 @@ export const getInitialFormValues = ( ports: portStringToItems(ruleToModify.ports)[1], protocol: ruleToModify.protocol, type: predefinedFirewallFromRule(ruleToModify) || '', - }; + } as FormState; }; export const getInitialAddressFormValue = ( @@ -181,21 +258,42 @@ export const getInitialAddressFormValue = ( return 'allIPv6'; } - return 'ip/netmask'; + return 'ip/netmask/prefixlist'; }; -// Get a list of Extended IP from an existing Firewall rule. This is necessary when opening the +// Get a list of Extended IP or Extended PL from an existing Firewall rule. This is necessary when opening the // drawer/form to modify an existing rule. -export const getInitialIPs = ( +export const getInitialIPsOrPLs = ( ruleToModify: ExtendedFirewallRule -): ExtendedIP[] => { +): { + ips: ExtendedIP[]; + pls: ExtendedPL[]; +} => { const { addresses } = ruleToModify; - const extendedIPv4 = (addresses?.ipv4 ?? []).map(stringToExtendedIP); - const extendedIPv6 = (addresses?.ipv6 ?? []).map(stringToExtendedIP); + // Exclude all prefix list entries (pl:*) from the FW Rule addresses when building extendedIPv4/extendedIPv6 + const extendedIPv4 = (addresses?.ipv4 ?? []) + .filter((ip) => !ip.startsWith('pl:')) + .map(stringToExtendedIP); + const extendedIPv6 = (addresses?.ipv6 ?? []) + .filter((ip) => !ip.startsWith('pl:')) + .map(stringToExtendedIP); const ips: ExtendedIP[] = [...extendedIPv4, ...extendedIPv6]; + // Build ExtendedPL from the FW Rule addresses + const prefixListMap = buildPrefixListReferenceMap({ + ipv4: addresses?.ipv4 ?? [], + ipv6: addresses?.ipv6 ?? [], + }); + const extendedPL = Object.entries(prefixListMap).map(([pl, reference]) => ({ + address: pl, + inIPv4Rule: reference.inIPv4Rule, + inIPv6Rule: reference.inIPv6Rule, + })); + const pls: ExtendedPL[] = extendedPL; + + // Errors ruleToModify.errors?.forEach((thisError) => { const { formField, ip } = thisError; @@ -223,7 +321,7 @@ export const getInitialIPs = ( ips[index].error = IP_ERROR_MESSAGE; }); - return ips; + return { ips, pls }; }; /** @@ -264,7 +362,7 @@ export const itemsToPortString = ( * and converts it to FirewallOptionItem[] and a custom input string. */ export const portStringToItems = ( - portString?: string + portString?: null | string ): [FirewallOptionItem[], string] => { // Handle empty input if (!portString) { @@ -305,13 +403,20 @@ export const portStringToItems = ( return [items, customInput.join(', ')]; }; -export const validateForm = ({ - addresses, - description, - label, - ports, - protocol, -}: Partial) => { +export interface ValidateFormOptions { + isFirewallRulesetsPrefixlistsFeatureEnabled: boolean; + validatedIPs: ExtendedIP[]; + validatedPLs: ExtendedPL[]; +} + +export const validateForm = ( + { addresses, description, label, ports, protocol }: Partial, + { + validatedIPs, + validatedPLs, + isFirewallRulesetsPrefixlistsFeatureEnabled, + }: ValidateFormOptions +) => { const errors: Partial = {}; if (label) { @@ -337,6 +442,14 @@ export const validateForm = ({ if (!addresses) { errors.addresses = 'Sources is a required field.'; + } else if ( + isFirewallRulesetsPrefixlistsFeatureEnabled && + addresses === 'ip/netmask/prefixlist' && + validatedIPs.length === 0 && + validatedPLs.length === 0 + ) { + errors.addresses = + 'Add an IP address in IP/mask format, or reference a Prefix List name.'; } if (!ports && protocol !== 'ICMP' && protocol !== 'IPENCAP') { diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleForm.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleForm.tsx index 2476c155bfc..401e0b63459 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleForm.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleForm.tsx @@ -1,8 +1,8 @@ import { ActionsPanel, Autocomplete, + Box, FormControlLabel, - Notice, Radio, RadioGroup, Select, @@ -15,14 +15,16 @@ import * as React from 'react'; import { MultipleIPInput } from 'src/components/MultipleIPInput/MultipleIPInput'; import { - addressOptions, firewallOptionItemsShort, portPresets, protocolOptions, + useAddressOptions, + useIsFirewallRulesetsPrefixlistsEnabled, } from 'src/features/Firewalls/shared'; import { ipFieldPlaceholder } from 'src/utilities/ipUtils'; import { enforceIPMasks } from './FirewallRuleDrawer.utils'; +import { MultiplePrefixListSelect } from './MultiplePrefixListSelect'; import { PORT_PRESETS, PORT_PRESETS_ITEMS } from './shared'; import type { FirewallRuleFormProps } from './FirewallRuleDrawer.types'; @@ -30,7 +32,7 @@ import type { FirewallOptionItem, FirewallPreset, } from 'src/features/Firewalls/shared'; -import type { ExtendedIP } from 'src/utilities/ipUtils'; +import type { ExtendedIP, ExtendedPL } from 'src/utilities/ipUtils'; const ipNetmaskTooltipText = 'If you do not specify a mask, /32 will be assumed for IPv4 addresses and /128 will be assumed for IPv6 addresses.'; @@ -39,23 +41,31 @@ export const FirewallRuleForm = React.memo((props: FirewallRuleFormProps) => { const { addressesLabel, category, + closeDrawer, errors, handleBlur, handleChange, + handleOpenPrefixListDrawer, handleSubmit, ips, + pls, mode, presetPorts, ruleErrors, setFieldError, setFieldValue, setIPs, + setPLs, setPresetPorts, - status, touched, values, } = props; + const { isFirewallRulesetsPrefixlistsFeatureEnabled } = + useIsFirewallRulesetsPrefixlistsEnabled(); + + const addressOptions = useAddressOptions(); + const hasCustomInput = presetPorts.some( (thisPort) => thisPort.value === PORT_PRESETS['CUSTOM'].value ); @@ -101,7 +111,7 @@ export const FirewallRuleForm = React.memo((props: FirewallRuleFormProps) => { if (!touched.label) { setFieldValue( 'label', - `${values.action.toLocaleLowerCase()}-${category}-${item?.label}` + `${values.action?.toLocaleLowerCase()}-${category}-${item?.label}` ); } @@ -147,10 +157,17 @@ export const FirewallRuleForm = React.memo((props: FirewallRuleFormProps) => { const handleAddressesChange = React.useCallback( (item: string) => { setFieldValue('addresses', item); - // Reset custom IPs - setIPs([{ address: '' }]); + // Reset custom IPs & PLs + if (isFirewallRulesetsPrefixlistsFeatureEnabled) { + // For "IP / Netmask / Prefix List": reset both custom IPs and PLs + setIPs([]); + setPLs([]); + } else { + // For "IP / Netmask": reset IPs to at least one empty input + setIPs([{ address: '' }]); + } }, - [setFieldValue, setIPs] + [setFieldValue, setIPs, setPLs, isFirewallRulesetsPrefixlistsFeatureEnabled] ); const handleActionChange = React.useCallback( @@ -173,6 +190,13 @@ export const FirewallRuleForm = React.memo((props: FirewallRuleFormProps) => { setIPs(_ipsWithMasks); }; + const handlePrefixListChange = React.useCallback( + (_pls: ExtendedPL[]) => { + setPLs(_pls); + }, + [setPLs] + ); + const handlePortPresetChange = React.useCallback( (items: FirewallOptionItem[]) => { // If the user is selecting "ALL", it doesn't make sense @@ -196,20 +220,12 @@ export const FirewallRuleForm = React.memo((props: FirewallRuleFormProps) => { return ( addressOptions.find( (thisOption) => thisOption.value === values.addresses - ) || undefined + ) ?? null ); }, [values]); return ( - {status && ( - - )} { /> { dataAttrs: { 'data-qa-port-select': true, }, - helperText: ['ICMP', 'IPENCAP'].includes(values.protocol) + helperText: ['ICMP', 'IPENCAP'].includes(values.protocol ?? '') ? `Ports are not allowed for ${values.protocol} protocols.` : undefined, }} @@ -303,27 +319,42 @@ export const FirewallRuleForm = React.memo((props: FirewallRuleFormProps) => { }} options={addressOptions} placeholder={`Select ${addressesLabel}s...`} + required textFieldProps={{ - InputProps: { - required: true, - }, dataAttrs: { 'data-qa-address-source-select': true, }, }} value={addressesValue} /> - {/* Show this field only if "IP / Netmask has been selected." */} - {values.addresses === 'ip/netmask' && ( - + {/* Show this field only if "IP / Netmask / Prefix List has been selected." */} + {values.addresses === 'ip/netmask/prefixlist' && ( + + isFirewallRulesetsPrefixlistsFeatureEnabled + ? theme.spacingFunction(24) + : 0 + } + > + 0 ? 'IP / Netmask' : ''} + tooltip={ipNetmaskTooltipText} + /> + {isFirewallRulesetsPrefixlistsFeatureEnabled && ( + + )} + )} @@ -351,17 +382,27 @@ export const FirewallRuleForm = React.memo((props: FirewallRuleFormProps) => { label: mode === 'create' ? 'Add Rule' : 'Add Changes', onClick: () => handleSubmit(), }} + secondaryButtonProps={{ + label: 'Cancel', + onClick: closeDrawer, + }} /> ); }); const StyledDiv = styled('div', { label: 'StyledDiv' })(({ theme }) => ({ - marginTop: theme.spacing(2), + marginTop: theme.spacingFunction(16), })); const StyledMultipleIPInput = styled(MultipleIPInput, { label: 'StyledMultipleIPInput', -})(({ theme }) => ({ - marginTop: theme.spacing(2), +})(({ theme, ips }) => ({ + ...(ips.length !== 0 ? { marginTop: theme.spacingFunction(16) } : {}), +})); + +const StyledMultiplePrefixListSelect = styled(MultiplePrefixListSelect, { + label: 'StyledMultipleIPInput', +})(({ theme, pls }) => ({ + ...(pls.length !== 0 ? { marginTop: theme.spacingFunction(16) } : {}), })); diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetDetailsView.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetDetailsView.tsx new file mode 100644 index 00000000000..5378e7a1386 --- /dev/null +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetDetailsView.tsx @@ -0,0 +1,205 @@ +import { useFirewallRuleSetQuery } from '@linode/queries'; +import { + ActionsPanel, + Box, + CircleProgress, + ErrorState, + NotFound, + Paper, + TooltipIcon, +} from '@linode/ui'; +import { capitalize } from '@linode/utilities'; +import * as React from 'react'; + +import { CopyTooltip } from 'src/components/CopyTooltip/CopyTooltip'; +import { DateTimeDisplay } from 'src/components/DateTimeDisplay'; + +import { + generateAddressesLabelV2, + useIsFirewallRulesetsPrefixlistsEnabled, +} from '../../shared'; +import { RULESET_MARKED_FOR_DELETION_TEXT } from './shared'; +import { + StyledChip, + StyledLabel, + StyledListItem, + StyledWarningIcon, + useStyles, +} from './shared.styles'; + +import type { PrefixListRuleReference } from '../../shared'; +import type { Category } from './shared'; +import type { FirewallRuleType } from '@linode/api-v4'; + +interface FirewallRuleSetDetailsViewProps { + category: Category; + closeDrawer: () => void; + handleOpenPrefixListDrawer: ( + prefixListLabel: string, + plRuleRef: PrefixListRuleReference + ) => void; + ruleset: FirewallRuleType['ruleset']; +} + +export const FirewallRuleSetDetailsView = ( + props: FirewallRuleSetDetailsViewProps +) => { + const { category, closeDrawer, handleOpenPrefixListDrawer, ruleset } = props; + + const { isFirewallRulesetsPrefixlistsFeatureEnabled } = + useIsFirewallRulesetsPrefixlistsEnabled(); + const { classes } = useStyles(); + + const isValidRuleSetId = ruleset !== undefined && ruleset !== null; + + const { + data: ruleSetDetails, + isFetching, + isError, + error, + } = useFirewallRuleSetQuery( + ruleset ?? -1, + isValidRuleSetId && isFirewallRulesetsPrefixlistsFeatureEnabled + ); + + if (!isValidRuleSetId) { + return ; + } + + if (isFetching) { + return ( + + + + ); + } + + if (isError) { + return ; + } + + return ( + + {[ + { label: 'Label', value: ruleSetDetails?.label }, + { label: 'ID', value: ruleSetDetails?.id, copy: true }, + { + label: 'Description', + value: ruleSetDetails?.description, + column: true, + }, + { + label: 'Service Defined', + value: ruleSetDetails?.is_service_defined ? 'Yes' : 'No', + }, + { label: 'Version', value: ruleSetDetails?.version }, + { + label: 'Created', + value: ruleSetDetails?.created && ( + + ), + }, + { + label: 'Updated', + value: ruleSetDetails?.updated && ( + + ), + }, + ].map((item, idx) => ( + + {item.label && ( + {item.label}: + )} + {item.value} + {item.copy && ( + + )} + + ))} + + {ruleSetDetails?.deleted && ( + + + ({ + color: theme.tokens.alias.Content.Text.Negative, + })} + > + Marked for deletion: + + ({ + color: theme.tokens.alias.Content.Text.Negative, + marginRight: theme.spacingFunction(4), + })} + value={ruleSetDetails.deleted} + /> + + + )} + + ({ + backgroundColor: theme.tokens.alias.Background.Neutral, + padding: theme.spacingFunction(12), + marginTop: theme.spacingFunction(8), + })} + > + ({ marginBottom: theme.spacingFunction(4) })} + > + {capitalize(category)} Rules + + {ruleSetDetails?.rules.map((rule, idx) => ( + ({ + padding: `${theme.spacingFunction(4)} 0`, + })} + > + + + {rule.protocol}; {rule.ports};  + {generateAddressesLabelV2({ + addresses: rule.addresses, + onPrefixListClick: handleOpenPrefixListDrawer, + showTruncateChip: false, + })} + + + ))} + + + + + ); +}; diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetForm.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetForm.tsx new file mode 100644 index 00000000000..1fec51c09c6 --- /dev/null +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetForm.tsx @@ -0,0 +1,250 @@ +import { useAllFirewallRuleSetsQuery } from '@linode/queries'; +import { + ActionsPanel, + Autocomplete, + Box, + Chip, + Paper, + SelectedIcon, + Stack, + Typography, +} from '@linode/ui'; +import { capitalize } from '@linode/utilities'; +import * as React from 'react'; + +import { CopyTooltip } from 'src/components/CopyTooltip/CopyTooltip'; +import { DateTimeDisplay } from 'src/components/DateTimeDisplay'; + +import { + generateAddressesLabelV2, + useIsFirewallRulesetsPrefixlistsEnabled, +} from '../../shared'; +import { StyledLabel, StyledListItem, useStyles } from './shared.styles'; + +import type { FirewallRuleSetFormProps } from './FirewallRuleDrawer.types'; + +export const FirewallRuleSetForm = React.memo( + (props: FirewallRuleSetFormProps) => { + const { + category, + closeDrawer, + errors, + handleOpenPrefixListDrawer, + handleSubmit, + inboundAndOutboundRules, + setFieldTouched, + setFieldValue, + touched, + values, + } = props; + + const { classes } = useStyles(); + + const { isFirewallRulesetsPrefixlistsFeatureEnabled } = + useIsFirewallRulesetsPrefixlistsEnabled(); + + const { data, error, isLoading } = useAllFirewallRuleSetsQuery( + isFirewallRulesetsPrefixlistsFeatureEnabled + ); + + const ruleSets = data ?? []; + + // Find the selected ruleset once + const selectedRuleSet = React.useMemo( + () => ruleSets.find((r) => r.id === values.ruleset) ?? null, + [ruleSets, values.ruleset] + ); + + // Build dropdown options + const ruleSetDropdownOptions = React.useMemo( + () => + ruleSets + // TODO: Firewall RuleSets: Remove this client-side filter once the API supports filtering by the 'type' field + .filter( + (ruleSet) => + ruleSet.type === category && + !inboundAndOutboundRules.some( + (rule) => rule.ruleset === ruleSet.id + ) + ) // Display only rule sets applicable to the given category and filter out rule sets already referenced by the FW + .map((ruleSet) => ({ + label: ruleSet.label, + value: ruleSet.id, + })), + [ruleSets] + ); + + const errorText = + error?.[0].reason ?? (touched.ruleset ? errors.ruleset : undefined); + + return ( +
    + + ({ marginTop: theme.spacingFunction(16) })} + > + RuleSets are reusable collections of Cloud Firewall rules that use + the same fields as individual rules. They let you manage and update + multiple rules as a group. You can then apply them across different + firewalls by reference. + + 0} + errorText={errorText} + label="Rule Set" + loading={isLoading} + onBlur={() => setFieldTouched('ruleset')} + onChange={(_, selectedRuleSet) => { + setFieldValue('ruleset', selectedRuleSet?.value); + }} + options={ruleSetDropdownOptions} + placeholder="Type to search or select a Rule Set" + renderOption={(props, option, { selected }) => { + const { key, ...rest } = props; + return ( +
  • + + + ({ + // eslint-disable-next-line @linode/cloud-manager/no-custom-fontWeight + fontWeight: theme.tokens.font.FontWeight.Semibold, + })} + > + {option.label} + + ({ + color: + theme.tokens.component.Dropdown.Text.Description, + })} + > + ID: {option.value} + + + {selected && } + +
  • + ); + }} + value={ + ruleSetDropdownOptions.find((o) => o.value === values.ruleset) ?? + null + } + /> + + {selectedRuleSet && ( + + {[ + { label: 'Label', value: selectedRuleSet.label }, + { label: 'ID', value: selectedRuleSet.id, copy: true }, + { label: null, value: selectedRuleSet.description }, + { + label: 'Service Defined', + value: selectedRuleSet.is_service_defined ? 'Yes' : 'No', + }, + { label: 'Version', value: selectedRuleSet.version }, + { + label: 'Created', + value: selectedRuleSet.created && ( + + ), + }, + { + label: 'Updated', + value: selectedRuleSet.updated && ( + + ), + }, + ].map((item, idx) => ( + + {item.label && ( + {item.label}: + )} + {item.value} + + {item.copy && ( + + )} + + ))} + + ({ + backgroundColor: theme.tokens.alias.Background.Neutral, + padding: theme.spacingFunction(12), + marginTop: theme.spacingFunction(8), + })} + > + ({ marginBottom: theme.spacingFunction(4) })} + > + {capitalize(category)} Rules + + {selectedRuleSet.rules.map((rule, idx) => ( + ({ + padding: `${theme.spacingFunction(4)} 0`, + })} + > + ({ + background: + rule.action === 'ACCEPT' + ? theme.tokens.component.Badge.Positive.Subtle + .Background + : theme.tokens.component.Badge.Negative.Subtle + .Background, + color: + rule.action === 'ACCEPT' + ? theme.tokens.component.Badge.Positive.Subtle.Text + : theme.tokens.component.Badge.Negative.Subtle.Text, + font: theme.font.bold, + width: '51px', + fontSize: theme.tokens.font.FontSize.Xxxs, + marginRight: theme.spacingFunction(6), + flexShrink: 0, + alignSelf: 'flex-start', + })} + /> + + {rule.protocol}; {rule.ports};  + {generateAddressesLabelV2({ + addresses: rule.addresses, + onPrefixListClick: handleOpenPrefixListDrawer, + showTruncateChip: false, + })} + + + ))} + + + )} +
    + + + + ); + } +); diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.tsx index 661625a14d8..69a4a59f3e6 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.tsx @@ -14,6 +14,7 @@ import { verticalListSortingStrategy, } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; +import { useFirewallRuleSetQuery } from '@linode/queries'; import { Box, LinkButton, Typography } from '@linode/ui'; import { Autocomplete } from '@linode/ui'; import { Hidden } from '@linode/ui'; @@ -24,6 +25,7 @@ import { prop, uniqBy } from 'ramda'; import * as React from 'react'; import Undo from 'src/assets/icons/undo.svg'; +import { Link } from 'src/components/Link'; import { MaskableText } from 'src/components/MaskableText/MaskableText'; import { Table } from 'src/components/Table'; import { TableBody } from 'src/components/TableBody'; @@ -31,11 +33,15 @@ 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 { TableRowLoading } from 'src/components/TableRowLoading/TableRowLoading'; import { generateAddressesLabel, + generateAddressesLabelV2, generateRuleLabel, predefinedFirewallFromRule as ruleToPredefinedFirewall, + useIsFirewallRulesetsPrefixlistsEnabled, } from 'src/features/Firewalls/shared'; +import { IPAddress } from 'src/features/Linodes/LinodesLanding/IPAddress'; import { CustomKeyboardSensor } from 'src/utilities/CustomKeyboardSensor'; import { FirewallRuleActionMenu } from './FirewallRuleActionMenu'; @@ -55,19 +61,23 @@ import type { ExtendedFirewallRule, RuleStatus } from './firewallRuleEditor'; import type { Category, FirewallRuleError } from './shared'; import type { DragEndEvent } from '@dnd-kit/core'; import type { FirewallPolicyType } from '@linode/api-v4/lib/firewalls/types'; -import type { FirewallOptionItem } from 'src/features/Firewalls/shared'; +import type { + FirewallOptionItem, + PrefixListRuleReference, +} from 'src/features/Firewalls/shared'; interface RuleRow { - action?: string; - addresses: string; + action?: null | string; + addresses?: null | React.ReactNode | string; description?: null | string; errors?: FirewallRuleError[]; id: number; index: number; label?: null | string; originalIndex: number; - ports: string; - protocol: string; + ports?: null | string; + protocol?: null | string; + ruleset?: null | number; status: RuleStatus; type: string; } @@ -80,6 +90,7 @@ interface RowActionHandlers { handleCloneFirewallRule: (idx: number) => void; handleDeleteFirewallRule: (idx: number) => void; handleOpenRuleDrawerForEditing: (idx: number) => void; + handleOpenRuleSetDrawerForViewing?: (ruleset: number) => void; handleReorder: (startIdx: number, endIdx: number) => void; handleUndo: (idx: number) => void; } @@ -87,6 +98,10 @@ interface RowActionHandlers { interface FirewallRuleTableProps extends RowActionHandlers { category: Category; disabled: boolean; + handleOpenPrefixListDrawer: ( + prefixListLabel: string, + plRuleRef: PrefixListRuleReference + ) => void; handlePolicyChange: ( category: Category, newPolicy: FirewallPolicyType @@ -103,6 +118,8 @@ export const FirewallRuleTable = (props: FirewallRuleTableProps) => { handleCloneFirewallRule, handleDeleteFirewallRule, handleOpenRuleDrawerForEditing, + handleOpenRuleSetDrawerForViewing, + handleOpenPrefixListDrawer, handlePolicyChange, handleReorder, handleUndo, @@ -113,13 +130,19 @@ export const FirewallRuleTable = (props: FirewallRuleTableProps) => { const theme = useTheme(); const smDown = useMediaQuery(theme.breakpoints.down('sm')); - const mdDown = useMediaQuery(theme.breakpoints.down('md')); const lgDown = useMediaQuery(theme.breakpoints.down('lg')); + const { isFirewallRulesetsPrefixlistsFeatureEnabled } = + useIsFirewallRulesetsPrefixlistsEnabled(); + const addressColumnLabel = category === 'inbound' ? 'sources' : 'destinations'; - const rowData = firewallRuleToRowData(rulesWithStatus); + const rowData = firewallRuleToRowData( + rulesWithStatus, + isFirewallRulesetsPrefixlistsFeatureEnabled, + handleOpenPrefixListDrawer + ); const openDrawerForCreating = React.useCallback(() => { openRuleDrawer(category, 'create'); @@ -196,27 +219,19 @@ export const FirewallRuleTable = (props: FirewallRuleTableProps) => { Label + Action Protocol Port Range - - {capitalize(addressColumnLabel)} - + {capitalize(addressColumnLabel)} - Action @@ -245,6 +260,9 @@ export const FirewallRuleTable = (props: FirewallRuleTableProps) => { handleOpenRuleDrawerForEditing={ handleOpenRuleDrawerForEditing } + handleOpenRuleSetDrawerForViewing={ + handleOpenRuleSetDrawerForViewing + } handleUndo={handleUndo} key={thisRuleRow.id} {...thisRuleRow} @@ -280,6 +298,7 @@ export interface FirewallRuleTableRowProps extends RuleRow { handleCloneFirewallRule: RowActionHandlersWithDisabled['handleCloneFirewallRule']; handleDeleteFirewallRule: RowActionHandlersWithDisabled['handleDeleteFirewallRule']; handleOpenRuleDrawerForEditing: RowActionHandlersWithDisabled['handleOpenRuleDrawerForEditing']; + handleOpenRuleSetDrawerForViewing?: RowActionHandlersWithDisabled['handleOpenRuleSetDrawerForViewing']; handleUndo: RowActionHandlersWithDisabled['handleUndo']; } @@ -292,6 +311,7 @@ const FirewallRuleTableRow = React.memo((props: FirewallRuleTableRowProps) => { handleCloneFirewallRule, handleDeleteFirewallRule, handleOpenRuleDrawerForEditing, + handleOpenRuleSetDrawerForViewing, handleUndo, id, index, @@ -300,17 +320,36 @@ const FirewallRuleTableRow = React.memo((props: FirewallRuleTableRowProps) => { ports, protocol, status, + ruleset, } = props; + const { isFirewallRulesetsPrefixlistsFeatureEnabled } = + useIsFirewallRulesetsPrefixlistsEnabled(); + + const isRuleSetRow = Boolean(ruleset); + const isRuleSetRowEnabled = + isRuleSetRow && isFirewallRulesetsPrefixlistsFeatureEnabled; + + const isValidRuleSetId = ruleset !== undefined && ruleset !== null; + + const { data: rulesetDetails, isLoading: isRuleSetLoading } = + useFirewallRuleSetQuery( + ruleset ?? -1, + isValidRuleSetId && isRuleSetRowEnabled + ); + const actionMenuProps = { disabled: status === 'PENDING_DELETION' || disabled, handleCloneFirewallRule, handleDeleteFirewallRule, handleOpenRuleDrawerForEditing, idx: index, + isRuleSetRowEnabled, }; const theme = useTheme(); + const lgDown = useMediaQuery(theme.breakpoints.down('lg')); + const smDown = useMediaQuery(theme.breakpoints.down('sm')); const { active, @@ -344,11 +383,29 @@ const FirewallRuleTableRow = React.memo((props: FirewallRuleTableRowProps) => { zIndex: isDragging ? 9999 : 0, } as const; + const [isHovered, setIsHovered] = React.useState(false); + + const handleMouseEnter = React.useCallback(() => { + setIsHovered(true); + }, []); + + const handleMouseLeave = React.useCallback(() => { + setIsHovered(false); + }, []); + + if (isRuleSetLoading) { + return ; + } + + const ruleSetCopyableId = `${rulesetDetails ? 'ID:' : 'Ruleset ID:'} ${ruleset}`; + return ( { {...listeners} sx={rowStyles} > - - - {label || ( - handleOpenRuleDrawerForEditing(index)} + {!isRuleSetRowEnabled && ( + <> + + + {label || ( + handleOpenRuleDrawerForEditing(index)} + > + Add a label + + )} + + + {capitalize(action?.toLocaleLowerCase() ?? '')} + + + + {protocol} + + + + + + {ports === '1-65535' ? 'All Ports' : ports} + + + + + + + + + )} + + {isRuleSetRowEnabled && ( + + - Add a label - - )} - - - - {protocol} - - - - - - {ports === '1-65535' ? 'All Ports' : ports} - - - - - + + + {rulesetDetails && ( + + handleOpenRuleSetDrawerForViewing?.(rulesetDetails.id) + } + > + {rulesetDetails?.label} + + )} + + + + + - - - {capitalize(action?.toLocaleLowerCase() ?? '')} - + )} + {status !== 'NOT_MODIFIED' ? ( @@ -551,14 +645,24 @@ export const ConditionalError = React.memo((props: ConditionalErrorProps) => { * of data. This also allows us to sort each column of the RuleTable. */ export const firewallRuleToRowData = ( - firewallRules: ExtendedFirewallRule[] + firewallRules: ExtendedFirewallRule[], + isFirewallRulesetsPrefixlistsEnabled?: boolean, + handleOpenPrefixListDrawer?: ( + prefixListLabel: string, + plRuleRef: PrefixListRuleReference + ) => void ): RuleRow[] => { return firewallRules.map((thisRule, idx) => { const ruleType = ruleToPredefinedFirewall(thisRule); return { ...thisRule, - addresses: generateAddressesLabel(thisRule.addresses), + addresses: isFirewallRulesetsPrefixlistsEnabled + ? generateAddressesLabelV2({ + addresses: thisRule.addresses, + onPrefixListClick: handleOpenPrefixListDrawer, + }) + : generateAddressesLabel(thisRule.addresses), id: idx + 1, // ids are 1-indexed, as id given to the useSortable hook cannot be 0 index: idx, ports: sortPortString(thisRule.ports || ''), diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRulesLanding.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRulesLanding.tsx index 913ea297f3d..517d7d90aeb 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRulesLanding.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRulesLanding.tsx @@ -7,13 +7,19 @@ import { import { ActionsPanel, Notice, Typography } from '@linode/ui'; import { styled } from '@mui/material/styles'; import { useQueryClient } from '@tanstack/react-query'; -import { useBlocker, useLocation, useNavigate } from '@tanstack/react-router'; +import { + useBlocker, + useLocation, + useNavigate, + useParams, +} from '@tanstack/react-router'; import { useSnackbar } from 'notistack'; import * as React from 'react'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; +import { FirewallPrefixListDrawer } from './FirewallPrefixListDrawer'; import { FirewallRuleDrawer } from './FirewallRuleDrawer'; import { hasModified as _hasModified, @@ -26,6 +32,7 @@ import { import { FirewallRuleTable } from './FirewallRuleTable'; import { parseFirewallRuleError } from './shared'; +import type { PrefixListDrawerContext } from './FirewallPrefixListDrawer'; import type { FirewallRuleDrawerMode } from './FirewallRuleDrawer.types'; import type { Category } from './shared'; import type { @@ -41,10 +48,13 @@ interface Props { rules: FirewallRules; } +type RulesDrawerEntityType = 'rule' | 'ruleset'; + interface Drawer { category: Category; + entityType?: RulesDrawerEntityType; // Not applicable for 'create', since 'create' can involve both entity types mode: FirewallRuleDrawerMode; - ruleIdx?: number; + ruleIdx?: number; // Rule row index or ruleset Id (not applicable for 'create') } // TODO: Refactor this code - Becoming too large and hard to maintain @@ -59,6 +69,18 @@ export const FirewallRulesLanding = React.memo((props: Props) => { const location = useLocation(); const { enqueueSnackbar } = useSnackbar(); + const getCategoryFromPath = (pathname: string): Category => + pathname.includes('inbound') ? 'inbound' : 'outbound'; + + const getDrawerEntityTypeFromPath = ( + pathname: string + ): RulesDrawerEntityType => + pathname.includes('/ruleset') ? 'ruleset' : 'rule'; + + const params = useParams({ strict: false }); + const category = getCategoryFromPath(location.pathname); + const entityType = getDrawerEntityTypeFromPath(location.pathname); + /** * inbound and outbound policy aren't part of any particular rule * so they are managed separately rather than through the reducer. @@ -83,9 +105,27 @@ export const FirewallRulesLanding = React.memo((props: Props) => { /** * Component state and handlers */ - const [ruleDrawer, setRuleDrawer] = React.useState({ + + // - Initialize the drawer state based on the current route. + // - Drawers can be accessed via the route ONLY for viewing rulesets or adding rules/rulesets. + // - Accessing the Edit Rule drawer via the route is not allowed (for now), + // since individual rules don't have unique IDs and are part of drag-and-drop feature. + const initialDrawer: Drawer = { + category, + mode: entityType === 'ruleset' ? 'view' : 'create', + entityType: entityType === 'ruleset' ? entityType : undefined, + ruleIdx: entityType === 'ruleset' ? Number(params.ruleId) : undefined, + }; + + const [ruleDrawer, setRuleDrawer] = React.useState(initialDrawer); + const [prefixListDrawer, setPrefixListDrawer] = React.useState<{ + category: Category; + context: PrefixListDrawerContext | undefined; + selectedPrefixListLabel: string | undefined; + }>({ category: 'inbound', - mode: 'create', + selectedPrefixListLabel: undefined, + context: undefined, }); const [submitting, setSubmitting] = React.useState(false); // @todo fine-grained error handling. @@ -95,26 +135,36 @@ export const FirewallRulesLanding = React.memo((props: Props) => { const [discardChangesModalOpen, setDiscardChangesModalOpen] = React.useState(false); - const openRuleDrawer = ( - category: Category, - mode: FirewallRuleDrawerMode, - idx?: number - ) => { + const openRuleDrawer = (options: { + category: Category; + entityType?: RulesDrawerEntityType; + idx?: number; + mode: FirewallRuleDrawerMode; + }) => { + const { category, mode, idx, entityType = 'rule' } = options; + setRuleDrawer({ category, mode, ruleIdx: idx, + entityType, }); + + let path: string; + + if (mode === 'create') { + path = `/firewalls/$id/rules/add/${category}`; + } else if (mode === 'edit') { + path = `/firewalls/$id/rules/${mode}/${category}/$ruleId`; + } else if (mode === 'view') { + path = `/firewalls/$id/rules/${mode}/${category}/ruleset/$ruleId`; + } else { + throw new Error(`Unknown mode: ${mode}`); + } + navigate({ params: { id: String(firewallID), ruleId: String(idx) }, - to: - category === 'inbound' && mode === 'create' - ? '/firewalls/$id/rules/add/inbound' - : category === 'inbound' && mode === 'edit' - ? `/firewalls/$id/rules/edit/inbound/$ruleId` - : category === 'outbound' && mode === 'create' - ? '/firewalls/$id/rules/add/outbound' - : `/firewalls/$id/rules/edit/outbound/$ruleId`, + to: path, }); }; @@ -126,6 +176,30 @@ export const FirewallRulesLanding = React.memo((props: Props) => { }); }; + const openPrefixListDrawer = ( + category: Category, + prefixListLabel: string, + context: PrefixListDrawerContext + ) => { + setPrefixListDrawer({ + category, + selectedPrefixListLabel: prefixListLabel, + context, + }); + }; + + const closePrefixListDrawer = (options?: { closeAll?: boolean }) => { + setPrefixListDrawer({ + selectedPrefixListLabel: undefined, + context: undefined, + category: prefixListDrawer.category, + }); + + if (options?.closeAll) { + closeRuleDrawer(); + } + }; + /** * Rule Editor state hand handlers */ @@ -293,7 +367,8 @@ export const FirewallRulesLanding = React.memo((props: Props) => { next.routeId === '/firewalls/$id/rules/add/inbound' || next.routeId === '/firewalls/$id/rules/add/outbound' || next.routeId === '/firewalls/$id/rules/edit/inbound/$ruleId' || - next.routeId === '/firewalls/$id/rules/edit/outbound/$ruleId'; + next.routeId === '/firewalls/$id/rules/edit/outbound/$ruleId' || + next.routeId === '/firewalls/$id/rules/view/$category/ruleset/$ruleId'; return !isNavigatingToAllowedRoute; }, @@ -323,13 +398,16 @@ export const FirewallRulesLanding = React.memo((props: Props) => { [outboundState] ); - // This is for the Rule Drawer. If there is a rule to modify, + const rulesByCategory = + ruleDrawer.category === 'inbound' ? inboundRules : outboundRules; + + // This is for the Rule Drawer. If there is a rule to modify or view, // we need to pass it to the drawer to pre-populate the form fields. - const ruleToModify = + const ruleToModifyOrView = ruleDrawer.ruleIdx !== undefined - ? ruleDrawer.category === 'inbound' - ? inboundRules[ruleDrawer.ruleIdx] - : outboundRules[ruleDrawer.ruleIdx] + ? ruleDrawer.entityType === 'ruleset' + ? rulesByCategory.find((r) => r.ruleset === ruleDrawer.ruleIdx) // Find ruleset by ruleset id + : rulesByCategory[ruleDrawer.ruleIdx] // find rule by rule index : undefined; return ( @@ -380,15 +458,36 @@ export const FirewallRulesLanding = React.memo((props: Props) => { handleCloneRule('inbound', idx) } handleDeleteFirewallRule={(idx) => handleDeleteRule('inbound', idx)} + handleOpenPrefixListDrawer={(prefixListLabel, plRuleRef) => { + openPrefixListDrawer('inbound', prefixListLabel, { + type: 'rule', + plRuleRef, + }); + }} handleOpenRuleDrawerForEditing={(idx: number) => - openRuleDrawer('inbound', 'edit', idx) + openRuleDrawer({ + category: 'inbound', + mode: 'edit', + idx, + entityType: 'rule', + }) + } + handleOpenRuleSetDrawerForViewing={(ruleset: number) => + openRuleDrawer({ + category: 'inbound', + mode: 'view', + idx: ruleset, + entityType: 'ruleset', + }) } handlePolicyChange={handlePolicyChange} handleReorder={(startIdx: number, endIdx: number) => handleReorder('inbound', startIdx, endIdx) } handleUndo={(idx) => handleUndo('inbound', idx)} - openRuleDrawer={openRuleDrawer} + openRuleDrawer={(category, mode) => { + openRuleDrawer({ category, mode }); + }} policy={policy.inbound} rulesWithStatus={inboundRules} /> @@ -401,31 +500,80 @@ export const FirewallRulesLanding = React.memo((props: Props) => { handleCloneRule('outbound', idx) } handleDeleteFirewallRule={(idx) => handleDeleteRule('outbound', idx)} + handleOpenPrefixListDrawer={(prefixListLabel, plRuleRef) => { + openPrefixListDrawer('outbound', prefixListLabel, { + type: 'rule', + plRuleRef, + }); + }} handleOpenRuleDrawerForEditing={(idx: number) => - openRuleDrawer('outbound', 'edit', idx) + openRuleDrawer({ + category: 'outbound', + mode: 'edit', + idx, + entityType: 'rule', + }) + } + handleOpenRuleSetDrawerForViewing={(ruleset: number) => + openRuleDrawer({ + category: 'outbound', + mode: 'view', + idx: ruleset, + entityType: 'ruleset', + }) } handlePolicyChange={handlePolicyChange} handleReorder={(startIdx: number, endIdx: number) => handleReorder('outbound', startIdx, endIdx) } handleUndo={(idx) => handleUndo('outbound', idx)} - openRuleDrawer={openRuleDrawer} + openRuleDrawer={(category, mode) => { + openRuleDrawer({ category, mode }); + }} policy={policy.outbound} rulesWithStatus={outboundRules} />
    { + openPrefixListDrawer(ruleDrawer.category, prefixListLabel, { + plRuleRef, + type: contextType, + modeViewedFrom: ruleDrawer.mode, + }); + }} + inboundAndOutboundRules={[ + ...(rules.inbound ?? []), + ...(rules.outbound ?? []), + ]} isOpen={ location.pathname.endsWith('add/inbound') || location.pathname.endsWith('add/outbound') || location.pathname.endsWith(`edit/inbound/${ruleDrawer.ruleIdx}`) || - location.pathname.endsWith(`edit/outbound/${ruleDrawer.ruleIdx}`) + location.pathname.endsWith(`edit/outbound/${ruleDrawer.ruleIdx}`) || + location.pathname.endsWith( + `view/inbound/ruleset/${ruleDrawer.ruleIdx}` + ) || + location.pathname.endsWith( + `view/outbound/ruleset/${ruleDrawer.ruleIdx}` + ) } mode={ruleDrawer.mode} onClose={closeRuleDrawer} onSubmit={ruleDrawer.mode === 'create' ? handleAddRule : handleEditRule} - ruleToModify={ruleToModify} + ruleToModifyOrView={ruleToModifyOrView} + /> + ({ + useAllFirewallPrefixListsQuery: vi.fn().mockReturnValue({}), +})); + +vi.mock('@linode/queries', async () => { + const actual = await vi.importActual('@linode/queries'); + return { + ...actual, + useAllFirewallPrefixListsQuery: queryMocks.useAllFirewallPrefixListsQuery, + }; +}); + +const spy = vi.spyOn(shared, 'useIsFirewallRulesetsPrefixlistsEnabled'); + +describe('MultiplePrefixListSelect', () => { + beforeEach(() => { + spy.mockReturnValue({ + isFirewallRulesetsPrefixlistsFeatureEnabled: true, + isFirewallRulesetsPrefixListsBetaEnabled: false, + isFirewallRulesetsPrefixListsLAEnabled: false, + isFirewallRulesetsPrefixListsGAEnabled: false, + }); + }); + + const baseProps = { + handleOpenPrefixListDrawer: vi.fn(), + onChange: vi.fn(), + }; + + const mockPrefixLists = [ + { + name: 'pl::supports-both', + ipv4: ['192.168.0.0/24'], + ipv6: ['2001:db8::/128'], + }, // PL supported (supports both) + { + name: 'pl::supports-only-ipv6', + ipv4: null, + ipv6: ['2001:db8:1::/128'], + }, // supported (supports only ipv6) + { + name: 'pl:system:supports-only-ipv4', + ipv4: ['10.0.0.0/16'], + ipv6: null, + }, // supported (supports only ipv4) + { name: 'pl:system:supports-both', ipv4: [], ipv6: [] }, // supported (supports both) + { name: 'pl:system:not-supported', ipv4: null, ipv6: null }, // unsupported + ]; + + queryMocks.useAllFirewallPrefixListsQuery.mockReturnValue({ + data: mockPrefixLists, + isFetching: false, + error: null, + }); + + it('should render the title only when at least one PL row is added', () => { + const { getByText } = renderWithTheme( + + ); + expect(getByText('Prefix List')).toBeVisible(); + }); + + it('should not render the title when no PL row is added', () => { + const { queryByText } = renderWithTheme( + + ); + expect(queryByText('Prefix List')).not.toBeInTheDocument(); + }); + + it('should add a new PL row (empty state) when clicking "Add a Prefix List"', async () => { + const { getByText } = renderWithTheme( + + ); + + await userEvent.click(getByText('Add a Prefix List')); + expect(baseProps.onChange).toHaveBeenCalledWith([ + { address: '', inIPv4Rule: false, inIPv6Rule: false }, + ]); + }); + + it('should remove a PL row when clicking delete (X)', async () => { + const pls = [ + { + address: 'pl::supports-both', + inIPv4Rule: true, + inIPv6Rule: false, + }, + ]; + const { getByTestId } = renderWithTheme( + + ); + + await userEvent.click(getByTestId('delete-pl-0')); + expect(baseProps.onChange).toHaveBeenCalledWith([]); + }); + + it('filters out unsupported PLs from dropdown', async () => { + const pls = [{ address: '', inIPv4Rule: false, inIPv6Rule: false }]; + const { getByRole, queryByText } = renderWithTheme( + + ); + + const input = getByRole('combobox'); + await userEvent.type(input, 'pl:system:not-supported'); + + expect(queryByText('pl:system:not-supported')).not.toBeInTheDocument(); + }); + + it('prevents duplicate selection of PLs', async () => { + const selectedPLs = [ + { + address: 'pl::supports-both', + inIPv4Rule: true, + inIPv6Rule: false, + }, + { + address: 'pl::supports-only-ipv6', + inIPv4Rule: false, + inIPv6Rule: true, + }, + { address: '', inIPv4Rule: false, inIPv6Rule: false }, + ]; + const { getAllByRole, findByText } = renderWithTheme( + + ); + + const inputs = getAllByRole('combobox'); + const lastEmptyInput = inputs[inputs.length - 1]; + + // Try to search already selected Prefix List + await userEvent.type(lastEmptyInput, 'pl::supports-only-ipv6'); + + // Display no option available message for already selected Prefix List in dropdown + const noOptionsMessage = await findByText( + 'You have no options to choose from' + ); + expect(noOptionsMessage).toBeInTheDocument(); + }); + + it('should render a PL select field for each string in PLs', () => { + const pls = [ + { + address: 'pl::supports-both', + inIPv4Rule: true, + inIPv6Rule: false, + }, + { + address: 'pl::supports-only-ipv6', + inIPv4Rule: false, + inIPv6Rule: true, + }, + { + address: 'pl:system:supports-only-ipv4', + inIPv6Rule: false, + inIPv4Rule: true, + }, + ]; + const { getByDisplayValue, queryAllByTestId } = renderWithTheme( + + ); + + expect(queryAllByTestId('prefixlist-select')).toHaveLength(3); + getByDisplayValue('pl::supports-both'); + getByDisplayValue('pl::supports-only-ipv6'); + getByDisplayValue('pl:system:supports-only-ipv4'); + }); + + it('defaults to IPv4 selected and IPv6 unselected when choosing a PL that supports both', async () => { + const pls = [{ address: '', inIPv4Rule: false, inIPv6Rule: false }]; + const { findByText, getByRole } = renderWithTheme( + + ); + + const input = getByRole('combobox'); + + // Type the PL name to filter the dropdown + await userEvent.type(input, 'pl::supports-both'); + + // Select the option from the autocomplete dropdown + const option = await findByText('pl::supports-both'); + await userEvent.click(option); + + expect(baseProps.onChange).toHaveBeenCalledWith([ + { + address: 'pl::supports-both', + inIPv4Rule: true, + inIPv6Rule: false, + }, + ]); + }); + + it('defaults to IPv4 selected and IPv6 unselected when choosing a PL that supports only IPv4', async () => { + const pls = [{ address: '', inIPv4Rule: false, inIPv6Rule: false }]; + const { findByText, getByRole } = renderWithTheme( + + ); + + const input = getByRole('combobox'); + + // Type the PL name to filter the dropdown + await userEvent.type(input, 'pl:system:supports-only-ipv4'); + + // Select the option from the autocomplete dropdown + const option = await findByText('pl:system:supports-only-ipv4'); + await userEvent.click(option); + + expect(baseProps.onChange).toHaveBeenCalledWith([ + { + address: 'pl:system:supports-only-ipv4', + inIPv4Rule: true, + inIPv6Rule: false, + }, + ]); + }); + + it('defaults to IPv4 unselected and IPv6 selected when choosing a PL that supports only IPv6', async () => { + const pls = [{ address: '', inIPv4Rule: false, inIPv6Rule: false }]; + const { findByText, getByRole } = renderWithTheme( + + ); + + const input = getByRole('combobox'); + + // Type the PL name to filter the dropdown + await userEvent.type(input, 'pl::supports-only-ipv6'); + + // Select the option from the autocomplete dropdown + const option = await findByText('pl::supports-only-ipv6'); + await userEvent.click(option); + + expect(baseProps.onChange).toHaveBeenCalledWith([ + { + address: 'pl::supports-only-ipv6', + inIPv4Rule: false, + inIPv6Rule: true, + }, + ]); + }); + + it('renders IPv4 checked + disabled, and IPv6 unchecked + enabled when a prefix list supports both but is only referenced in IPv4 Rule', async () => { + const pls = [ + { address: 'pl::supports-both', inIPv4Rule: true, inIPv6Rule: false }, + ]; + const { findByTestId } = renderWithTheme( + + ); + + const ipv4CheckboxWrapper = await findByTestId('ipv4-checkbox-0'); + const ipv6CheckboxWrapper = await findByTestId('ipv6-checkbox-0'); + + const ipv4Checkbox = within(ipv4CheckboxWrapper).getByRole('checkbox'); + const ipv6Checkbox = within(ipv6CheckboxWrapper).getByRole('checkbox'); + + // IPv4 Checked and Disabled + expect(ipv4Checkbox).toBeChecked(); + expect(ipv4Checkbox).toBeDisabled(); + + // IPv6 Unchecked and enabled (User can check/select IPv6 since this prefix list supports both IPv4 and IPv6) + expect(ipv6Checkbox).not.toBeChecked(); + expect(ipv6Checkbox).toBeEnabled(); + }); + + it('renders IPv6 checked + disabled, and IPv4 unchecked + enabled when a prefix list supports both but is only referenced in IPv6 Rule', async () => { + const pls = [ + { address: 'pl::supports-both', inIPv4Rule: false, inIPv6Rule: true }, + ]; + const { findByTestId } = renderWithTheme( + + ); + + const ipv4CheckboxWrapper = await findByTestId('ipv4-checkbox-0'); + const ipv6CheckboxWrapper = await findByTestId('ipv6-checkbox-0'); + + const ipv4Checkbox = within(ipv4CheckboxWrapper).getByRole('checkbox'); + const ipv6Checkbox = within(ipv6CheckboxWrapper).getByRole('checkbox'); + + // IPv4 Unchecked and Enabled (User can check/select IPv4 since this prefix list supports both IPv4 and IPv6) + expect(ipv4Checkbox).not.toBeChecked(); + expect(ipv4Checkbox).toBeEnabled(); + + // IPv6 Checked and Disabled + expect(ipv6Checkbox).toBeChecked(); + expect(ipv6Checkbox).toBeDisabled(); + }); + + it('renders both IPv4 and IPv6 as checked and enabled when the prefix list supports both and is referenced in both IPv4 & IPv6 Rule', async () => { + const pls = [ + { address: 'pl::supports-both', inIPv4Rule: true, inIPv6Rule: true }, + ]; + const { findByTestId } = renderWithTheme( + + ); + + const ipv4CheckboxWrapper = await findByTestId('ipv4-checkbox-0'); + const ipv6CheckboxWrapper = await findByTestId('ipv6-checkbox-0'); + + const ipv4Checkbox = within(ipv4CheckboxWrapper).getByRole('checkbox'); + const ipv6Checkbox = within(ipv6CheckboxWrapper).getByRole('checkbox'); + + // IPv4 Checked and Enabled + expect(ipv4Checkbox).toBeChecked(); + expect(ipv4Checkbox).toBeEnabled(); + + // IPv6 Checked and Enabled + expect(ipv6Checkbox).toBeChecked(); + expect(ipv6Checkbox).toBeEnabled(); + }); + + it('renders IPv6 unchecked + disabled, and IPv4 checked + disabled when PL only supports IPv4', async () => { + const pls = [ + { + address: 'pl:system:supports-only-ipv4', + inIPv4Rule: true, + inIPv6Rule: false, + }, + ]; + const { findByTestId } = renderWithTheme( + + ); + + const ipv4CheckboxWrapper = await findByTestId('ipv4-checkbox-0'); + const ipv6CheckboxWrapper = await findByTestId('ipv6-checkbox-0'); + + const ipv4Checkbox = within(ipv4CheckboxWrapper).getByRole('checkbox'); + const ipv6Checkbox = within(ipv6CheckboxWrapper).getByRole('checkbox'); + + // IPv4 Checked and Disabled + expect(ipv4Checkbox).toBeChecked(); + expect(ipv4Checkbox).toBeDisabled(); + + // IPV6 Unchecked and Disabled + expect(ipv6Checkbox).not.toBeChecked(); + expect(ipv6Checkbox).toBeDisabled(); + }); + + it('renders IPv4 checkbox unchecked + disabled, and IPv6 checked + disabled when PL only supports IPv6', async () => { + const pls = [ + { + address: 'pl::supports-only-ipv6', + inIPv4Rule: false, + inIPv6Rule: true, + }, + ]; + const { findByTestId } = renderWithTheme( + + ); + + const ipv4CheckboxWrapper = await findByTestId('ipv4-checkbox-0'); + const ipv6CheckboxWrapper = await findByTestId('ipv6-checkbox-0'); + + const ipv4Checkbox = within(ipv4CheckboxWrapper).getByRole('checkbox'); + const ipv6Checkbox = within(ipv6CheckboxWrapper).getByRole('checkbox'); + + // IPv4 Unchecked and Disabled + expect(ipv4Checkbox).not.toBeChecked(); + expect(ipv4Checkbox).toBeDisabled(); + + // IPV6 Checked and Disabled + expect(ipv6Checkbox).toBeChecked(); + expect(ipv6Checkbox).toBeDisabled(); + }); + + // Toggling of Checkbox is allowed only when PL supports both IPv4 & IPv6 + it('calls onChange with updated values when toggling checkboxes', async () => { + const pls = [ + { address: 'pl::supports-both', inIPv4Rule: true, inIPv6Rule: false }, + ]; + const { findByTestId } = renderWithTheme( + + ); + + const ipv6Checkbox = await findByTestId('ipv6-checkbox-0'); + await userEvent.click(ipv6Checkbox); + + expect(baseProps.onChange).toHaveBeenCalledWith([ + { address: 'pl::supports-both', inIPv4Rule: true, inIPv6Rule: true }, + ]); + }); + + it('calls handleOpenPrefixListDrawer with correct arguments when clicking "View Details"', async () => { + const pls = [ + { address: 'pl::supports-both', inIPv4Rule: true, inIPv6Rule: false }, + ]; + + const { getByText } = renderWithTheme( + + ); + + await userEvent.click(getByText('View Details')); + + expect(baseProps.handleOpenPrefixListDrawer).toHaveBeenCalledWith( + 'pl::supports-both', + { inIPv4Rule: true, inIPv6Rule: false } + ); + }); +}); diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/MultiplePrefixListSelect.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/MultiplePrefixListSelect.tsx new file mode 100644 index 00000000000..52609f4d052 --- /dev/null +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/MultiplePrefixListSelect.tsx @@ -0,0 +1,409 @@ +import { useAllFirewallPrefixListsQuery } from '@linode/queries'; +import { + Autocomplete, + Box, + Button, + Checkbox, + CloseIcon, + IconButton, + InputLabel, + LinkButton, + Stack, +} from '@linode/ui'; +import Grid from '@mui/material/Grid'; +import * as React from 'react'; +import { makeStyles } from 'tss-react/mui'; + +import { + getFeatureChip, + useIsFirewallRulesetsPrefixlistsEnabled, +} from 'src/features/Firewalls/shared'; + +import { + combinePrefixLists, + getPrefixListType, + groupPriority, + isSpecialPrefixList, +} from './shared'; + +import type { FirewallPrefixList } from '@linode/api-v4'; +import type { Theme } from '@mui/material/styles'; +import type { PrefixListRuleReference } from 'src/features/Firewalls/shared'; +import type { ExtendedPL } from 'src/utilities/ipUtils'; + +const useStyles = makeStyles()((theme: Theme) => ({ + addPL: { + '& span:first-of-type': { + justifyContent: 'flex-start', + }, + paddingLeft: 0, + paddingTop: theme.spacingFunction(12), // default when empty + }, + addPLReducedPadding: { + paddingTop: theme.spacingFunction(4), // when last row is selected + }, + autocomplete: { + "& [data-testid='inputLabelWrapper']": { + display: 'none', + }, + }, + button: { + '& > span': { + padding: 2, + }, + marginTop: theme.spacingFunction(8), + marginLeft: `-${theme.spacingFunction(8)}`, + height: 20, + width: 20, + padding: 0, + }, +})); + +const isPrefixListSupported = (pl: FirewallPrefixList) => { + // Whitelisting all the Special PrefixLists as supported ones. + if (isSpecialPrefixList(pl.name)) { + return true; + } + + return ( + (pl.ipv4 !== null && pl.ipv4 !== undefined) || + (pl.ipv6 !== null && pl.ipv6 !== undefined) + ); +}; + +const getSupportDetails = (pl: FirewallPrefixList) => ({ + isPLIPv4Unsupported: pl.ipv4 === null || pl.ipv4 === undefined, + isPLIPv6Unsupported: pl.ipv6 === null || pl.ipv6 === undefined, +}); + +/** + * Default selection state for a newly chosen Prefix List + */ +const getDefaultPLReferenceState = ( + support: null | ReturnType +): { inIPv4Rule: boolean; inIPv6Rule: boolean } => { + if (support === null) { + // Special Prefix List case + return { inIPv4Rule: true, inIPv6Rule: false }; + } + + const { isPLIPv4Unsupported, isPLIPv6Unsupported } = support; + + if (!isPLIPv4Unsupported && !isPLIPv6Unsupported) + return { inIPv4Rule: true, inIPv6Rule: false }; + + if (!isPLIPv4Unsupported && isPLIPv6Unsupported) + return { inIPv4Rule: true, inIPv6Rule: false }; + + if (isPLIPv4Unsupported && !isPLIPv6Unsupported) + return { inIPv4Rule: false, inIPv6Rule: true }; + + // Should not happen but safe fallback + return { inIPv4Rule: false, inIPv6Rule: false }; +}; + +export interface MultiplePrefixListSelectProps { + /** + * Custom CSS class for additional styling. + */ + className?: string; + + /** + * Disables the component (non-interactive). + * @default false + */ + disabled?: boolean; + + /** + * Opens the Prefix List details drawer for the given Prefix List. + */ + handleOpenPrefixListDrawer: ( + prefixListLabel: string, + plRuleRef: PrefixListRuleReference + ) => void; + + /** + * Callback triggered when PLs change, passing updated `pls`. + */ + onChange: (pls: ExtendedPL[]) => void; + + /** + * Placeholder text for an empty input field. + */ + placeholder?: string; + + /** + * Array of `ExtendedPL` objects representing managed PLs. + */ + pls: ExtendedPL[]; +} + +export const MultiplePrefixListSelect = React.memo( + (props: MultiplePrefixListSelectProps) => { + const { className, disabled, handleOpenPrefixListDrawer, onChange, pls } = + props; + const { classes, cx } = useStyles(); + const { + isFirewallRulesetsPrefixlistsFeatureEnabled, + isFirewallRulesetsPrefixListsBetaEnabled, + isFirewallRulesetsPrefixListsGAEnabled, + } = useIsFirewallRulesetsPrefixlistsEnabled(); + + const { data, isLoading } = useAllFirewallPrefixListsQuery( + isFirewallRulesetsPrefixlistsFeatureEnabled + ); + + const prefixLists = React.useMemo(() => combinePrefixLists(data), [data]); + + /** + * Filter prefix lists to include those that support IPv4, IPv6, or both, + * and map them to options with label, value, and PL IP support details. + */ + const supportedOptions = React.useMemo( + () => + prefixLists + .filter(isPrefixListSupported) + .map((pl) => ({ + label: pl.name!, + value: pl.id ?? pl.name, + support: !isSpecialPrefixList(pl.name) + ? getSupportDetails(pl as FirewallPrefixList) + : null, + })) + // The API does not seem to sort prefix lists by "name" to prioritize certain types. + // This sort ensures that Autocomplete's groupBy displays groups correctly without duplicates + // and that the dropdown shows groups in the desired order. + .sort((a, b) => { + const groupA = getPrefixListType(a.label!); + const groupB = getPrefixListType(b.label!); + + return groupPriority[groupA] - groupPriority[groupB]; + }), + [prefixLists] + ); + + /** + * Returns the list of prefix list options available for a specific row. + * Always includes the currently selected option, and excludes any options + * that are already selected in other rows. This prevents duplicate prefix + * list selection across rows. + */ + const getAvailableOptions = React.useCallback( + (idx: number, address: string) => + supportedOptions.filter( + (o) => + o.label === address || // allow current + !pls.some((p, i) => i !== idx && p.address === o.label) + ), + [supportedOptions, pls] + ); + + const updatePL = (idx: number, updated: Partial) => { + const newPLs = [...pls]; + newPLs[idx] = { ...newPLs[idx], ...updated }; + onChange(newPLs); + }; + + // Handlers + const handleSelectPL = (label: string, idx: number) => { + const match = supportedOptions.find((o) => o.label === label); + if (!match) return; + + updatePL(idx, { + address: label, + ...getDefaultPLReferenceState(match.support), + }); + }; + + const handleToggleIPv4 = (checked: boolean, idx: number) => { + updatePL(idx, { + inIPv4Rule: checked, + }); + }; + + const handleToggleIPv6 = (checked: boolean, idx: number) => { + updatePL(idx, { + inIPv6Rule: checked, + }); + }; + + const addNewInput = () => { + onChange([...pls, { address: '', inIPv4Rule: false, inIPv6Rule: false }]); + }; + + const removeInput = (idx: number) => { + const _pls = [...pls]; + _pls.splice(idx, 1); + onChange(_pls); + }; + + if (!pls) { + return null; + } + + const lastRowSelected = + pls.length > 0 && pls[pls.length - 1].address !== ''; + + const renderRow = (thisPL: ExtendedPL, idx: number) => { + const availableOptions = getAvailableOptions(idx, thisPL.address); + + const selectedOption = availableOptions.find( + (o) => o.label === thisPL.address + ); + + // Disabling a checkbox ensures that at least one option (IPv4 or IPv6) remains checked + const ipv4Unsupported = + selectedOption?.support?.isPLIPv4Unsupported === true; + const ipv6Unsupported = + selectedOption?.support?.isPLIPv6Unsupported === true; + + const ipv4Forced = + thisPL.inIPv4Rule === true && thisPL.inIPv6Rule === false; + const ipv6Forced = + thisPL.inIPv6Rule === true && thisPL.inIPv4Rule === false; + + const disableIPv4 = ipv4Unsupported || ipv4Forced; + const disableIPv6 = ipv6Unsupported || ipv6Forced; + + const getCheckboxTooltipText = ( + ipUnsupported?: boolean, + ipForced?: boolean + ) => { + if (ipUnsupported) { + return 'Not supported by this Prefix List'; + } + if (ipForced) { + return 'At least one array must be selected'; + } + return undefined; + }; + + return ( + + + 0} + disabled={disabled} + errorText={thisPL.error} + getOptionLabel={(option) => option.label} + groupBy={(option) => getPrefixListType(option.label)} + label="" + loading={isLoading} + noMarginTop + onChange={(_, selectedPrefixList) => { + handleSelectPL(selectedPrefixList?.label ?? '', idx); + }} + options={availableOptions} + placeholder="Type to search or select Prefix List" + value={ + availableOptions.find((o) => o.label === thisPL.address) ?? null + } + /> + {thisPL.address.length !== 0 && ( + + + + handleToggleIPv4(!thisPL.inIPv4Rule, idx)} + text="IPv4" + toolTipText={getCheckboxTooltipText( + ipv4Unsupported, + ipv4Forced + )} + /> + + + handleToggleIPv6(!thisPL.inIPv6Rule, idx)} + text="IPv6" + toolTipText={getCheckboxTooltipText( + ipv6Unsupported, + ipv6Forced + )} + /> + + + + { + handleOpenPrefixListDrawer(thisPL.address, { + inIPv4Rule: thisPL.inIPv4Rule, + inIPv6Rule: thisPL.inIPv6Rule, + }); + }} + > + View Details + + + + )} + + + removeInput(idx)} + > + + + + + ); + }; + + return ( +
    + {/* Display the title only when pls.length > 0 (i.e., at least one PL row is added) */} + {pls.length > 0 && ( + + Prefix List + {getFeatureChip({ + isFirewallRulesetsPrefixlistsFeatureEnabled, + isFirewallRulesetsPrefixListsBetaEnabled, + isFirewallRulesetsPrefixListsGAEnabled, + })} + + )} + + {pls.map((thisPL, idx) => renderRow(thisPL, idx))} + + +
    + ); + } +); diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/firewallRuleEditor.ts b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/firewallRuleEditor.ts index 2e07ec070e1..7614b2e8371 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/firewallRuleEditor.ts +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/firewallRuleEditor.ts @@ -250,6 +250,11 @@ export const removeICMPPort = ( const removeEmptyAddressArrays = (rules: ExtendedFirewallRule[]) => { return rules.map((rule) => { + // Ruleset references do not have addresses + if (rule.ruleset !== null && rule.ruleset !== undefined) { + return { ...rule }; + } + const keepIPv4 = rule.addresses?.ipv4 && rule.addresses.ipv4.length > 0; const keepIPv6 = rule.addresses?.ipv6 && rule.addresses.ipv6.length > 0; diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/shared.styles.ts b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/shared.styles.ts new file mode 100644 index 00000000000..391bf1ace37 --- /dev/null +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/shared.styles.ts @@ -0,0 +1,78 @@ +import { + Box, + Chip, + omittedProps, + styled, + Typography, + WarningIcon, +} from '@linode/ui'; +import { makeStyles } from 'tss-react/mui'; + +import type { FirewallPolicyType } from '@linode/api-v4'; +import type { Theme } from '@linode/ui'; + +interface StyledListItemProps { + paddingMultiplier?: number; // optional, default 1 +} + +export const StyledListItem = styled(Typography, { + label: 'StyledTypography', + shouldForwardProp: omittedProps(['paddingMultiplier']), +})(({ theme, paddingMultiplier = 1 }) => ({ + alignItems: 'center', + display: 'flex', + padding: `${theme.spacingFunction(4 * paddingMultiplier)} 0`, +})); + +export const StyledLabel = styled(Box, { + label: 'StyledLabelBox', +})(({ theme }) => ({ + font: theme.font.bold, + marginRight: theme.spacingFunction(4), +})); + +export const StyledWarningIcon = styled(WarningIcon, { + label: 'StyledWarningIcon', +})(({ theme }) => ({ + '& > path:nth-of-type(1)': { + fill: theme.tokens.alias.Content.Icon.Warning, + }, + '& > path:nth-of-type(2)': { + fill: theme.tokens.color.Neutrals[90], + }, + marginRight: theme.spacingFunction(4), + width: '16px', + height: '16px', +})); + +export const StyledChip = styled(Chip, { + shouldForwardProp: omittedProps(['action']), +})<{ action?: FirewallPolicyType | null }>(({ theme, action }) => ({ + background: + action === 'ACCEPT' + ? theme.tokens.component.Badge.Positive.Subtle.Background + : theme.tokens.component.Badge.Negative.Subtle.Background, + color: + action === 'ACCEPT' + ? theme.tokens.component.Badge.Positive.Subtle.Text + : theme.tokens.component.Badge.Negative.Subtle.Text, + font: theme.font.bold, + width: '51px', + fontSize: theme.tokens.font.FontSize.Xxxs, + marginRight: theme.spacingFunction(6), + flexShrink: 0, + alignSelf: 'flex-start', +})); + +export const useStyles = makeStyles()((theme: Theme) => ({ + copyIcon: { + '& svg': { + height: '1em', + width: '1em', + }, + color: theme.palette.primary.main, + display: 'inline-block', + position: 'relative', + marginTop: theme.spacingFunction(4), + }, +})); diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/shared.ts b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/shared.ts index 45b3a0a0115..e3fb2754362 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/shared.ts +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/shared.ts @@ -1,6 +1,6 @@ import { prop, sortBy } from 'ramda'; -import type { APIError } from '@linode/api-v4/lib/types'; +import type { APIError, FirewallPrefixList } from '@linode/api-v4/lib/types'; export type Category = 'inbound' | 'outbound'; export interface FirewallRuleError { @@ -32,6 +32,12 @@ export const PORT_PRESETS_ITEMS = sortBy( Object.values(PORT_PRESETS) ); +export const RULESET_MARKED_FOR_DELETION_TEXT = + 'This rule set will be automatically deleted when it’s no longer referenced by other firewalls.'; + +export const PREFIXLIST_MARKED_FOR_DELETION_TEXT = + 'This Prefix List will be automatically deleted when it’s no longer referenced by other firewalls.'; + /** * The API returns very good Firewall error messages that look like this: * @@ -115,3 +121,82 @@ export const sortString = (_a: string, _b: string) => { const stripHyphen = (str: string) => { return str.match(/-/) ? str.split('-')[0] : str; }; + +export const firewallRuleCreateOptions = [ + { + label: 'Create a Rule', + value: 'rule', + }, + { + label: 'Reference Rule Set', + value: 'ruleset', + }, +] as const; + +type PrefixListGroup = 'Account' | 'Other' | 'System'; + +export const groupPriority: Record = { + Account: 1, + System: 2, + Other: 3, +}; + +export const getPrefixListType = (name: string): PrefixListGroup => { + if (name.startsWith('pl::')) { + return 'Account'; + } + if (name.startsWith('pl:system:')) { + return 'System'; + } + return 'Other'; // Safe fallback +}; + +export type SpecialPrefixList = Partial; + +const SPECIAL_PREFIX_LISTS_DESCRIPTION = + 'System-defined PrefixLists, such as pl::vpcs: and pl::subnets:, for VPC interface firewalls are dynamic and update automatically. They manage access to and from the interface for addresses within the interface’s VPC or VPC subnet.'; + +export const SPECIAL_PREFIX_LISTS: SpecialPrefixList[] = [ + { name: 'pl::vpcs:', description: SPECIAL_PREFIX_LISTS_DESCRIPTION }, + { + name: 'pl::subnets:', + description: SPECIAL_PREFIX_LISTS_DESCRIPTION, + }, +]; + +export const SPECIAL_PREFIX_LIST_NAMES = SPECIAL_PREFIX_LISTS.map( + (pl) => pl.name +); + +export const isSpecialPrefixList = (name: string | undefined) => { + if (!name) return false; + return SPECIAL_PREFIX_LIST_NAMES.includes(name); +}; + +/** + * Combine API prefix lists with hardcoded special prefix lists. + * API results override special PLs if names collide. + * Ensures no duplicate prefix lists when combining hardcoded and API values. + * @TODO: Remove hardcoded special PLs once API supports them. + */ +export const combinePrefixLists = ( + apiPLs: (FirewallPrefixList | SpecialPrefixList)[] | undefined +): (FirewallPrefixList | SpecialPrefixList)[] => { + const map = new Map(); + + // Add hardcoded special PLs first + SPECIAL_PREFIX_LISTS.forEach((pl) => { + if (pl.name) { + map.set(pl.name, pl); + } + }); + + // Add API results (override if name matches with hardcoded special PLs) + (apiPLs ?? []).forEach((pl) => { + if (pl.name) { + map.set(pl.name, pl); + } + }); + + return Array.from(map.values()); +}; diff --git a/packages/manager/src/features/Firewalls/FirewallLanding/CreateFirewallDrawer.test.tsx b/packages/manager/src/features/Firewalls/FirewallLanding/CreateFirewallDrawer.test.tsx index b05c0bfbf0b..9f19c945bef 100644 --- a/packages/manager/src/features/Firewalls/FirewallLanding/CreateFirewallDrawer.test.tsx +++ b/packages/manager/src/features/Firewalls/FirewallLanding/CreateFirewallDrawer.test.tsx @@ -3,25 +3,29 @@ import userEvent from '@testing-library/user-event'; import * as React from 'react'; import { accountFactory } from 'src/factories'; -import { http, HttpResponse, server } from 'src/mocks/testServer'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { CreateFirewallDrawer } from './CreateFirewallDrawer'; const queryMocks = vi.hoisted(() => ({ - userPermissions: vi.fn(() => ({ + useAccount: vi.fn().mockReturnValue({}), + usePermissions: vi.fn().mockReturnValue({ data: { create_firewall: true }, - })), - useQueryWithPermissions: vi.fn().mockReturnValue({ - data: [], isLoading: false, - isError: false, + error: null, }), })); +vi.mock('@linode/queries', async () => { + const actual = await vi.importActual('@linode/queries'); + return { + ...actual, + useAccount: queryMocks.useAccount, + }; +}); + vi.mock('src/features/IAM/hooks/usePermissions', () => ({ - usePermissions: queryMocks.userPermissions, - useQueryWithPermissions: queryMocks.useQueryWithPermissions, + usePermissions: queryMocks.usePermissions, })); const props = { @@ -80,12 +84,14 @@ describe('Create Firewall Drawer', () => { }); it('shows custom firewall radio group if Linode Interfaces is enabled and can toggle radio group', async () => { - const account = accountFactory.build({ - capabilities: ['Linode Interfaces'], + queryMocks.useAccount.mockReturnValue({ + data: accountFactory.build({ + capabilities: ['Linode Interfaces'], + }), + isLoading: false, + error: null, }); - server.use(http.get('*/v4*/account', () => HttpResponse.json(account))); - const { getByLabelText, findByTestId } = renderWithTheme( , { @@ -119,8 +125,10 @@ describe('Create Firewall Drawer', () => { }); it('enables the submit button if the user has create_firewall permission', () => { - queryMocks.userPermissions.mockReturnValue({ + queryMocks.usePermissions.mockReturnValue({ data: { create_firewall: true }, + isLoading: false, + error: null, }); renderWithTheme(); @@ -129,8 +137,10 @@ describe('Create Firewall Drawer', () => { }); it('disables the submit button if the user does not have create_firewall permission', () => { - queryMocks.userPermissions.mockReturnValue({ + queryMocks.usePermissions.mockReturnValue({ data: { create_firewall: false }, + isLoading: false, + error: null, }); renderWithTheme(); diff --git a/packages/manager/src/features/Firewalls/FirewallLanding/CustomFirewallFields.test.tsx b/packages/manager/src/features/Firewalls/FirewallLanding/CustomFirewallFields.test.tsx index 9849e2b8a38..39272d09b29 100644 --- a/packages/manager/src/features/Firewalls/FirewallLanding/CustomFirewallFields.test.tsx +++ b/packages/manager/src/features/Firewalls/FirewallLanding/CustomFirewallFields.test.tsx @@ -6,19 +6,6 @@ import { CustomFirewallFields } from './CustomFirewallFields'; import type { LinodeCreateFormEventOptions } from 'src/utilities/analytics/types'; -const queryMocks = vi.hoisted(() => ({ - useQueryWithPermissions: vi.fn().mockReturnValue({ - data: [], - isLoading: false, - isError: false, - }), -})); - -vi.mock('src/features/IAM/hooks/usePermissions', () => ({ - ...vi.importActual('src/features/IAM/hooks/usePermissions'), - useQueryWithPermissions: queryMocks.useQueryWithPermissions, -})); - const props = { createFlow: undefined, firewallFormEventOptions: { diff --git a/packages/manager/src/features/Firewalls/FirewallLanding/CustomFirewallFields.tsx b/packages/manager/src/features/Firewalls/FirewallLanding/CustomFirewallFields.tsx index 79be6739f9e..ac460a10e65 100644 --- a/packages/manager/src/features/Firewalls/FirewallLanding/CustomFirewallFields.tsx +++ b/packages/manager/src/features/Firewalls/FirewallLanding/CustomFirewallFields.tsx @@ -1,8 +1,7 @@ -import { useAllFirewallsQuery, useAllLinodesQuery } from '@linode/queries'; +import { useAllFirewallsQuery } from '@linode/queries'; import { LinodeSelect } from '@linode/shared'; import { Box, - CircleProgress, FormControlLabel, Notice, Radio, @@ -14,7 +13,7 @@ import { Controller, useFormContext } from 'react-hook-form'; import { Link } from 'src/components/Link'; import { FIREWALL_LIMITS_CONSIDERATIONS_LINK } from 'src/constants'; -import { useQueryWithPermissions } from 'src/features/IAM/hooks/usePermissions'; +import { useGetAllUserEntitiesByPermission } from 'src/features/IAM/hooks/useGetAllUserEntitiesByPermission'; import { NodeBalancerSelect } from 'src/features/NodeBalancers/NodeBalancerSelect'; import { sendLinodeCreateFormInputEvent } from 'src/utilities/analytics/formEventAnalytics'; import { useIsLinodeInterfacesEnabled } from 'src/utilities/linodes'; @@ -60,16 +59,18 @@ export const CustomFirewallFields = (props: CustomFirewallProps) => { useAllFirewallsQuery(open); const { - data: permissableLinodes, - hasFiltered: hasFilteredLinodes, - isLoading: isLoadingLinodes, - } = useQueryWithPermissions(useAllLinodesQuery(), 'linode', [ - 'apply_linode_firewalls', - ]); - - const deviceSelectGuidance = hasFilteredLinodes - ? READ_ONLY_DEVICES_HIDDEN_MESSAGE - : undefined; + data: availableLinodes, + filter: availableLinodesFilter, + isLoading: availableLinodesLoading, + } = useGetAllUserEntitiesByPermission({ + entityType: 'linode', + permission: 'apply_linode_firewalls', + enabled: open, + }); + const deviceSelectGuidance = + availableLinodes?.length !== 0 + ? READ_ONLY_DEVICES_HIDDEN_MESSAGE + : undefined; const assignedServices = firewalls ?.map((firewall) => firewall.entities) @@ -111,10 +112,6 @@ export const CustomFirewallFields = (props: CustomFirewallProps) => { ); - if (isLoadingLinodes) { - return ; - } - return ( <> @@ -211,16 +208,17 @@ export const CustomFirewallFields = (props: CustomFirewallProps) => { { field.onChange(linodes.map((linode) => linode.id)); }} - options={permissableLinodes ?? []} + options={availableLinodes ?? []} optionsFilter={linodeOptionsFilter} value={field.value ?? null} /> diff --git a/packages/manager/src/features/Firewalls/FirewallLanding/FirewallLanding.tsx b/packages/manager/src/features/Firewalls/FirewallLanding/FirewallLanding.tsx index f115b326263..d25b54889da 100644 --- a/packages/manager/src/features/Firewalls/FirewallLanding/FirewallLanding.tsx +++ b/packages/manager/src/features/Firewalls/FirewallLanding/FirewallLanding.tsx @@ -1,5 +1,12 @@ import { useFirewallsQuery } from '@linode/queries'; -import { Button, CircleProgress, ErrorState, Hidden } from '@linode/ui'; +import { + Button, + CircleProgress, + ErrorState, + Hidden, + TooltipIcon, + useTheme, +} from '@linode/ui'; import { useLocation, useNavigate } from '@tanstack/react-router'; import * as React from 'react'; @@ -21,6 +28,7 @@ import { usePaginationV2 } from 'src/hooks/usePaginationV2'; import { useSecureVMNoticesEnabled } from 'src/hooks/useSecureVMNoticesEnabled'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; +import { useIsFirewallRulesetsPrefixlistsEnabled } from '../shared'; import { CreateFirewallDrawer } from './CreateFirewallDrawer'; import { FirewallDialog } from './FirewallDialog'; import { FirewallLandingEmptyState } from './FirewallLandingEmptyState'; @@ -48,6 +56,7 @@ const FirewallLanding = () => { }, preferenceKey, }); + const theme = useTheme(); const filter = { ['+order']: order, @@ -66,7 +75,11 @@ const FirewallLanding = () => { const [isModalOpen, setIsModalOpen] = React.useState(false); const [dialogMode, setDialogMode] = React.useState('enable'); const [isGenerateDialogOpen, setIsGenerateDialogOpen] = React.useState(false); + const { isFirewallRulesetsPrefixlistsFeatureEnabled } = + useIsFirewallRulesetsPrefixlistsEnabled(); + const rulesColumnTooltipText = + 'Includes both rules and Rule Sets in the count. Each Rule Set is counted as one rule, regardless of how many rules it contains.'; const flags = useFlags(); const { secureVMNoticesEnabled } = useSecureVMNoticesEnabled(); @@ -193,7 +206,18 @@ const FirewallLanding = () => { Status - Rules + + Rules + {isFirewallRulesetsPrefixlistsFeatureEnabled && ( + + )} + Services diff --git a/packages/manager/src/features/Firewalls/shared.test.ts b/packages/manager/src/features/Firewalls/shared.test.ts deleted file mode 100644 index 6597c23d93b..00000000000 --- a/packages/manager/src/features/Firewalls/shared.test.ts +++ /dev/null @@ -1,183 +0,0 @@ -import { renderHook, waitFor } from '@testing-library/react'; - -import { wrapWithTheme } from 'src/utilities/testHelpers'; - -import { - allIPv4, - allIPv6, - generateAddressesLabel, - predefinedFirewallFromRule, - useIsFirewallRulesetsPrefixlistsEnabled, -} from './shared'; - -import type { FirewallRuleType } from '@linode/api-v4/lib/firewalls/types'; - -const addresses = { - ipv4: [allIPv4], - ipv6: [allIPv6], -}; - -const limitedAddresses = { - ipv4: ['1.1.1.1'], - ipv6: ['::'], -}; - -describe('predefinedFirewallFromRule', () => { - const rule: FirewallRuleType = { - action: 'ACCEPT', - addresses, - ports: '', - protocol: 'TCP', - }; - - it('handles SSH', () => { - rule.ports = '22'; - expect(predefinedFirewallFromRule(rule)).toBe('ssh'); - }); - it('handles HTTP', () => { - rule.ports = '80'; - expect(predefinedFirewallFromRule(rule)).toBe('http'); - }); - it('handles HTTPS', () => { - rule.ports = '443'; - expect(predefinedFirewallFromRule(rule)).toBe('https'); - }); - it('handles MySQL', () => { - rule.ports = '3306'; - expect(predefinedFirewallFromRule(rule)).toBe('mysql'); - }); - it('handles DNS', () => { - rule.ports = '53'; - expect(predefinedFirewallFromRule(rule)).toBe('dns'); - }); - - it('returns `undefined` when given an unrecognizable rule', () => { - expect( - predefinedFirewallFromRule({ - action: 'ACCEPT', - addresses, - // Test another port - ports: '22-24', - protocol: 'TCP', - }) - ).toBeUndefined(); - - expect( - predefinedFirewallFromRule({ - action: 'ACCEPT', - addresses, - ports: '22', - // Test another protocol - protocol: 'UDP', - }) - ).toBeUndefined(); - - expect( - predefinedFirewallFromRule({ - action: 'ACCEPT', - // Test other addresses - addresses: limitedAddresses, - ports: '22', - protocol: 'TCP', - }) - ).toBeUndefined(); - }); -}); - -describe('generateAddressLabel', () => { - it('includes the All IPv4 label if appropriate', () => { - expect(generateAddressesLabel(addresses).includes('All IPv4')).toBe(true); - expect(generateAddressesLabel(limitedAddresses).includes('All IPv4')).toBe( - false - ); - }); - - it("doesn't include other IPv4 addresses if ALL are also specified", () => { - const result = generateAddressesLabel({ - ...addresses, - ipv4: [allIPv4, '1.1.1.1'], - }); - expect(result.includes('All IPv4')).toBe(true); - expect(result.includes('1.1.1.1')).toBe(false); - }); - - it('includes the All IPv6 label if appropriate', () => { - expect(generateAddressesLabel(addresses).includes('All IPv6')).toBe(true); - }); - - it("doesn't include other IPv6 addresses if ALL are also specified", () => { - const result = generateAddressesLabel({ - ...addresses, - ipv6: [allIPv6, '::1'], - }); - expect(result.includes('All IPv6')).toBe(true); - expect(result.includes('::1')).toBe(false); - }); - - it('includes all appropriate addresses', () => { - expect(generateAddressesLabel(addresses)).toBe('All IPv4, All IPv6'); - expect(generateAddressesLabel({ ipv4: ['1.1.1.1'] })).toBe('1.1.1.1'); - expect(generateAddressesLabel({ ipv6: ['::1'] })).toBe('::1'); - expect( - generateAddressesLabel({ ipv4: ['1.1.1.1, 2.2.2.2'], ipv6: ['::1'] }) - ).toBe('1.1.1.1, 2.2.2.2, ::1'); - expect( - generateAddressesLabel({ ipv4: ['1.1.1.1, 2.2.2.2'], ipv6: [allIPv6] }) - ).toBe('All IPv6, 1.1.1.1, 2.2.2.2'); - }); - - it('truncates large lists', () => { - expect( - generateAddressesLabel({ - ipv4: ['1.1.1.1', '2.2.2.2', '3.3.3.3', '4.4.4.4', '5.5.5.5'], - }) - ).toBe('1.1.1.1, 2.2.2.2, 3.3.3.3, plus 2 more'); - }); - - it('should always display "All IPv4" and "All IPv6", even if the label is truncated', () => { - expect( - generateAddressesLabel({ - ipv4: ['1.1.1.1', '2.2.2.2', '3.3.3.3', '4.4.4.4', '5.5.5.5'], - ipv6: ['::/0'], - }) - ).toBe('All IPv6, 1.1.1.1, 2.2.2.2, plus 3 more'); - }); - - it('returns "None" if necessary', () => { - expect(generateAddressesLabel({ ipv4: undefined, ipv6: undefined })).toBe( - 'None' - ); - }); -}); - -describe('useIsFirewallRulesetsPrefixlistsEnabled', () => { - it('returns true if the feature is enabled', async () => { - const options = { flags: { firewallRulesetsPrefixlists: true } }; - - const { result } = renderHook( - () => useIsFirewallRulesetsPrefixlistsEnabled(), - { - wrapper: (ui) => wrapWithTheme(ui, options), - } - ); - - await waitFor(() => { - expect(result.current.isFirewallRulesetsPrefixlistsEnabled).toBe(true); - }); - }); - - it('returns false if the feature is NOT enabled', async () => { - const options = { flags: { firewallRulesetsPrefixlists: false } }; - - const { result } = renderHook( - () => useIsFirewallRulesetsPrefixlistsEnabled(), - { - wrapper: (ui) => wrapWithTheme(ui, options), - } - ); - - await waitFor(() => { - expect(result.current.isFirewallRulesetsPrefixlistsEnabled).toBe(false); - }); - }); -}); diff --git a/packages/manager/src/features/Firewalls/shared.test.tsx b/packages/manager/src/features/Firewalls/shared.test.tsx new file mode 100644 index 00000000000..529fd807909 --- /dev/null +++ b/packages/manager/src/features/Firewalls/shared.test.tsx @@ -0,0 +1,551 @@ +/* eslint-disable react/jsx-no-useless-fragment */ +import { renderHook, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { accountFactory } from 'src/factories'; +import { http, HttpResponse, server } from 'src/mocks/testServer'; +import { renderWithTheme, wrapWithTheme } from 'src/utilities/testHelpers'; + +import { + allIPv4, + allIPv6, + buildPrefixListReferenceMap, + generateAddressesLabel, + generateAddressesLabelV2, + getFeatureChip, + predefinedFirewallFromRule, + useIsFirewallRulesetsPrefixlistsEnabled, +} from './shared'; + +import type { PrefixListReferenceMap } from './shared'; +import type { FirewallRuleType } from '@linode/api-v4/lib/firewalls/types'; + +const addresses = { + ipv4: [allIPv4], + ipv6: [allIPv6], +}; + +const limitedAddresses = { + ipv4: ['1.1.1.1'], + ipv6: ['::'], +}; + +describe('predefinedFirewallFromRule', () => { + const rule: FirewallRuleType = { + action: 'ACCEPT', + addresses, + ports: '', + protocol: 'TCP', + }; + + it('handles SSH', () => { + rule.ports = '22'; + expect(predefinedFirewallFromRule(rule)).toBe('ssh'); + }); + it('handles HTTP', () => { + rule.ports = '80'; + expect(predefinedFirewallFromRule(rule)).toBe('http'); + }); + it('handles HTTPS', () => { + rule.ports = '443'; + expect(predefinedFirewallFromRule(rule)).toBe('https'); + }); + it('handles MySQL', () => { + rule.ports = '3306'; + expect(predefinedFirewallFromRule(rule)).toBe('mysql'); + }); + it('handles DNS', () => { + rule.ports = '53'; + expect(predefinedFirewallFromRule(rule)).toBe('dns'); + }); + + it('returns `undefined` when given an unrecognizable rule', () => { + expect( + predefinedFirewallFromRule({ + action: 'ACCEPT', + addresses, + // Test another port + ports: '22-24', + protocol: 'TCP', + }) + ).toBeUndefined(); + + expect( + predefinedFirewallFromRule({ + action: 'ACCEPT', + addresses, + ports: '22', + // Test another protocol + protocol: 'UDP', + }) + ).toBeUndefined(); + + expect( + predefinedFirewallFromRule({ + action: 'ACCEPT', + // Test other addresses + addresses: limitedAddresses, + ports: '22', + protocol: 'TCP', + }) + ).toBeUndefined(); + }); +}); + +describe('generateAddressLabel', () => { + it('includes the All IPv4 label if appropriate', () => { + expect(generateAddressesLabel(addresses).includes('All IPv4')).toBe(true); + expect(generateAddressesLabel(limitedAddresses).includes('All IPv4')).toBe( + false + ); + }); + + it("doesn't include other IPv4 addresses if ALL are also specified", () => { + const result = generateAddressesLabel({ + ...addresses, + ipv4: [allIPv4, '1.1.1.1'], + }); + expect(result.includes('All IPv4')).toBe(true); + expect(result.includes('1.1.1.1')).toBe(false); + }); + + it('includes the All IPv6 label if appropriate', () => { + expect(generateAddressesLabel(addresses).includes('All IPv6')).toBe(true); + }); + + it("doesn't include other IPv6 addresses if ALL are also specified", () => { + const result = generateAddressesLabel({ + ...addresses, + ipv6: [allIPv6, '::1'], + }); + expect(result.includes('All IPv6')).toBe(true); + expect(result.includes('::1')).toBe(false); + }); + + it('includes all appropriate addresses', () => { + expect(generateAddressesLabel(addresses)).toBe('All IPv4, All IPv6'); + expect(generateAddressesLabel({ ipv4: ['1.1.1.1'] })).toBe('1.1.1.1'); + expect(generateAddressesLabel({ ipv6: ['::1'] })).toBe('::1'); + expect( + generateAddressesLabel({ ipv4: ['1.1.1.1, 2.2.2.2'], ipv6: ['::1'] }) + ).toBe('1.1.1.1, 2.2.2.2, ::1'); + expect( + generateAddressesLabel({ ipv4: ['1.1.1.1, 2.2.2.2'], ipv6: [allIPv6] }) + ).toBe('All IPv6, 1.1.1.1, 2.2.2.2'); + }); + + it('truncates large lists', () => { + expect( + generateAddressesLabel({ + ipv4: ['1.1.1.1', '2.2.2.2', '3.3.3.3', '4.4.4.4', '5.5.5.5'], + }) + ).toBe('1.1.1.1, 2.2.2.2, 3.3.3.3, plus 2 more'); + }); + + it('should always display "All IPv4" and "All IPv6", even if the label is truncated', () => { + expect( + generateAddressesLabel({ + ipv4: ['1.1.1.1', '2.2.2.2', '3.3.3.3', '4.4.4.4', '5.5.5.5'], + ipv6: ['::/0'], + }) + ).toBe('All IPv6, 1.1.1.1, 2.2.2.2, plus 3 more'); + }); + + it('returns "None" if necessary', () => { + expect(generateAddressesLabel({ ipv4: undefined, ipv6: undefined })).toBe( + 'None' + ); + }); +}); + +describe('buildPrefixListMap', () => { + it('returns empty map if no input', () => { + const result = buildPrefixListReferenceMap({}); + expect(result).toEqual({}); + }); + + it('maps IPv4 prefix lists correctly', () => { + const ipv4 = ['pl:example1', '192.168.0.1', 'pl:example2']; + const result: PrefixListReferenceMap = buildPrefixListReferenceMap({ + ipv4, + }); + + expect(result).toEqual({ + 'pl:example1': { inIPv4Rule: true, inIPv6Rule: false }, + 'pl:example2': { inIPv4Rule: true, inIPv6Rule: false }, + }); + }); + + it('maps IPv6 prefix lists correctly', () => { + const ipv6 = ['pl:example1', 'fe80::1', 'pl:example2']; + const result: PrefixListReferenceMap = buildPrefixListReferenceMap({ + ipv6, + }); + + expect(result).toEqual({ + 'pl:example1': { inIPv4Rule: false, inIPv6Rule: true }, + 'pl:example2': { inIPv4Rule: false, inIPv6Rule: true }, + }); + }); + + it('maps both IPv4 and IPv6 for the same prefix list', () => { + const ipv4 = ['pl:example1']; + const ipv6 = ['pl:example1']; + const result: PrefixListReferenceMap = buildPrefixListReferenceMap({ + ipv4, + ipv6, + }); + + expect(result).toEqual({ + 'pl:example1': { inIPv4Rule: true, inIPv6Rule: true }, + }); + }); + + it('ignores non-prefix list IPs', () => { + const ipv4 = ['192.168.0.1']; + const ipv6 = ['fe80::1']; + const result: PrefixListReferenceMap = buildPrefixListReferenceMap({ + ipv4, + ipv6, + }); + + expect(result).toEqual({}); + }); + + it('handles duplicates correctly', () => { + const ipv4 = ['pl:duplicate-example', 'pl:duplicate-example']; + const ipv6 = ['pl:duplicate-example']; + const result: PrefixListReferenceMap = buildPrefixListReferenceMap({ + ipv4, + ipv6, + }); + + expect(result).toEqual({ + 'pl:duplicate-example': { inIPv4Rule: true, inIPv6Rule: true }, + }); + }); +}); + +describe('generateAddressesLabelV2', () => { + const onPrefixListClick = vi.fn(); + + /** + * Expected display order of addresses: + * '192.168.1.1', + * '2001:db8:85a3::8a2e:370:7334/128', + * 'pl:system:test-1 (IPv4, IPv6)', + * 'pl::test-2 (IPv4)', + * 'pl::test-3 (IPv6)' + */ + const addresses: FirewallRuleType['addresses'] = { + ipv4: [ + 'pl:system:test-1', // PL attached to both IPv4/IPv6 + 'pl::test-2', // PL attached to IPv4 only + '192.168.1.1', // individual IP + ], + ipv6: [ + 'pl:system:test-1', // same system PL + 'pl::test-3', // PL attached to IPv6 only + '2001:db8:85a3::8a2e:370:7334/128', // individual IP + ], + }; + + it('renders PLs with correct Firewall IP reference labels', () => { + const result = generateAddressesLabelV2({ + addresses, + onPrefixListClick, + showTruncateChip: false, + }); + const { getByText } = renderWithTheme(<>{result}); + + // Check PLs with proper suffixes + expect(getByText('pl:system:test-1 (IPv4, IPv6)')).toBeVisible(); + expect(getByText('pl::test-2 (IPv4)')).toBeVisible(); + expect(getByText('pl::test-3 (IPv6)')).toBeVisible(); + }); + + it('renders individual IP addresses correctly', () => { + const result = generateAddressesLabelV2({ + addresses, + showTruncateChip: false, + }); + const { getByText } = renderWithTheme(<>{result}); + + expect(getByText('192.168.1.1')).toBeVisible(); + expect(getByText('2001:db8:85a3::8a2e:370:7334/128')).toBeVisible(); + }); + + it('triggers onPrefixListClick when PL is clicked', async () => { + const result = generateAddressesLabelV2({ + addresses, + onPrefixListClick, + showTruncateChip: false, + }); + const { getByText } = renderWithTheme(<>{result}); + + await userEvent.click(getByText('pl:system:test-1 (IPv4, IPv6)')); + expect(onPrefixListClick).toHaveBeenCalledWith('pl:system:test-1', { + inIPv4Rule: true, + inIPv6Rule: true, + }); + + await userEvent.click(getByText('pl::test-2 (IPv4)')); + expect(onPrefixListClick).toHaveBeenCalledWith('pl::test-2', { + inIPv4Rule: true, + inIPv6Rule: false, + }); + + await userEvent.click(getByText('pl::test-3 (IPv6)')); + expect(onPrefixListClick).toHaveBeenCalledWith('pl::test-3', { + inIPv4Rule: false, + inIPv6Rule: true, + }); + }); + + it('renders None if no addresses are provided', () => { + const result = generateAddressesLabelV2({ + addresses: { ipv4: [], ipv6: [] }, + }); + const { getByText } = renderWithTheme(<>{result}); + expect(getByText('None')).toBeVisible(); + }); + + it('renders "All IPv4, All IPv6" when allowAll type is present in addresses', () => { + const addressesAll = { ipv4: ['0.0.0.0/0'], ipv6: ['::/0'] }; + const result = generateAddressesLabelV2({ addresses: addressesAll }); + const { getByText } = renderWithTheme(<>{result}); + expect(getByText('All IPv4, All IPv6')).toBeVisible(); + }); + + it('handles truncation and shows Chip for hidden items', () => { + const result = generateAddressesLabelV2({ + addresses, + showTruncateChip: true, + truncateAt: 2, + }); + const { container } = renderWithTheme(<>{result}); + expect(container.textContent).toContain('+3'); // 5 total items (2 visible and 3 hidden) + }); + + it('renders only 1 visible item and shows a Chip for the remaining when showTruncateChip is true and truncateAt is 1', () => { + const result = generateAddressesLabelV2({ + addresses, + showTruncateChip: true, + truncateAt: 1, + }); + const { getByText, queryByText } = renderWithTheme(<>{result}); + + expect(getByText('192.168.1.1')).toBeVisible(); + + expect(getByText('+4')).toBeVisible(); // 5 total elements (1 shown + 4 hidden) + + // Hidden items are not rendered outside tooltip + expect(queryByText('2001:db8:85a3::8a2e:370:7334/128')).toBeNull(); + expect(queryByText('pl:system:test-1 (IPv4, IPv6)')).toBeNull(); + expect(queryByText('pl::test-2 (IPv4)')).toBeNull(); + expect(queryByText('pl::test-3 (IPv6)')).toBeNull(); + }); + + it('renders all items if showTruncateChip is false', () => { + const result = generateAddressesLabelV2({ + addresses, + showTruncateChip: false, + truncateAt: 1, // should be ignored + }); + const { getByText } = renderWithTheme(<>{result}); + + expect(getByText('pl:system:test-1 (IPv4, IPv6)')).toBeVisible(); + expect(getByText('pl::test-2 (IPv4)')).toBeVisible(); + expect(getByText('pl::test-3 (IPv6)')).toBeVisible(); + expect(getByText('192.168.1.1')).toBeVisible(); + expect(getByText('2001:db8:85a3::8a2e:370:7334/128')).toBeVisible(); + }); + + it('tooltip shows only hidden elements when showTruncateChip is true', async () => { + const result = generateAddressesLabelV2({ + addresses, + showTruncateChip: true, + truncateAt: 2, + }); + + const { findAllByTestId, getByText, queryByText } = renderWithTheme( + <>{result} // 5 total elements (2 shown + 3 hidden) + ); + + // Only the first 2 elements are visible outside tooltip + expect(getByText('192.168.1.1')).toBeVisible(); + expect(getByText('2001:db8:85a3::8a2e:370:7334/128')).toBeVisible(); + expect(queryByText('pl:system:test-1 (IPv4, IPv6)')).toBeNull(); + expect(queryByText('pl::test-2 (IPv4)')).toBeNull(); + expect(queryByText('pl::test-3 (IPv6)')).toBeNull(); + + // Chip shows correct hidden count + const chip = getByText('+3'); + expect(chip).toBeVisible(); + + // Hover on chip + await userEvent.hover(chip); + + // Query all items in the tooltip + const tooltipItems = await findAllByTestId('tooltip-item'); + + // Check that only hidden items are present in tooltip + expect(tooltipItems).toHaveLength(3); + expect(tooltipItems.map((item) => item.textContent)).toEqual([ + 'pl:system:test-1 (IPv4, IPv6)', + 'pl::test-2 (IPv4)', + 'pl::test-3 (IPv6)', + ]); + }); +}); + +describe('useIsFirewallRulesetsPrefixlistsEnabled', () => { + it('returns true if the feature is enabled AND the account has the capability', async () => { + const options = { + flags: { + fwRulesetsPrefixLists: { + enabled: true, + beta: false, + la: false, + ga: false, + }, + }, + }; + + const account = accountFactory.build({ + capabilities: ['Cloud Firewall Rule Set'], + }); + + server.use( + http.get('*/v4*/account', () => { + return HttpResponse.json(account); + }) + ); + + const { result } = renderHook( + () => useIsFirewallRulesetsPrefixlistsEnabled(), + { + wrapper: (ui) => wrapWithTheme(ui, options), + } + ); + + await waitFor(() => { + expect(result.current.isFirewallRulesetsPrefixlistsFeatureEnabled).toBe( + true + ); + }); + }); + + it('returns false if the feature is NOT enabled but the account has the capability', async () => { + const options = { + flags: { + fwRulesetsPrefixLists: { + enabled: false, + beta: false, + la: false, + ga: false, + }, + }, + }; + + const account = accountFactory.build({ + capabilities: ['Cloud Firewall Rule Set'], + }); + + server.use( + http.get('*/v4*/account', () => { + return HttpResponse.json(account); + }) + ); + + const { result } = renderHook( + () => useIsFirewallRulesetsPrefixlistsEnabled(), + { + wrapper: (ui) => wrapWithTheme(ui, options), + } + ); + + await waitFor(() => { + expect(result.current.isFirewallRulesetsPrefixlistsFeatureEnabled).toBe( + false + ); + }); + }); + + it('returns false if the feature is enabled but the account DOES NOT have the capability', async () => { + const options = { + flags: { + fwRulesetsPrefixLists: { + enabled: true, + beta: false, + la: false, + ga: false, + }, + }, + }; + + const account = accountFactory.build({ + capabilities: [], + }); + + server.use( + http.get('*/v4*/account', () => { + return HttpResponse.json(account); + }) + ); + + const { result } = renderHook( + () => useIsFirewallRulesetsPrefixlistsEnabled(), + { + wrapper: (ui) => wrapWithTheme(ui, options), + } + ); + + await waitFor(() => { + expect(result.current.isFirewallRulesetsPrefixlistsFeatureEnabled).toBe( + false + ); + }); + }); +}); + +describe('getFeatureChip', () => { + it('returns null if RS & PL feature is disabled', () => { + const result = getFeatureChip({ + isFirewallRulesetsPrefixlistsFeatureEnabled: false, + isFirewallRulesetsPrefixListsBetaEnabled: false, + isFirewallRulesetsPrefixListsGAEnabled: false, + }); + expect(result).toBeNull(); + }); + + it('returns BetaChip if Firewall RS & PL feature is enabled and Beta is true', () => { + const result = getFeatureChip({ + isFirewallRulesetsPrefixlistsFeatureEnabled: true, + isFirewallRulesetsPrefixListsBetaEnabled: true, + isFirewallRulesetsPrefixListsGAEnabled: false, + }); + const { getByText } = renderWithTheme(<>{result}); + expect(getByText('beta')).toBeVisible(); + }); + + it('returns NewFeatureChip if Firewall RS & PL feature is enabled, GA is true, and Beta is false', () => { + const result = getFeatureChip({ + isFirewallRulesetsPrefixlistsFeatureEnabled: true, + isFirewallRulesetsPrefixListsBetaEnabled: false, + isFirewallRulesetsPrefixListsGAEnabled: true, + }); + const { getByText } = renderWithTheme(<>{result}); + expect(getByText('new')).toBeVisible(); + }); + + it('returns null if feature is enabled but neither Beta nor GA', () => { + const result = getFeatureChip({ + isFirewallRulesetsPrefixlistsFeatureEnabled: true, + isFirewallRulesetsPrefixListsBetaEnabled: false, + isFirewallRulesetsPrefixListsGAEnabled: false, + }); + expect(result).toBeNull(); + }); +}); diff --git a/packages/manager/src/features/Firewalls/shared.ts b/packages/manager/src/features/Firewalls/shared.ts deleted file mode 100644 index 8e9199827b7..00000000000 --- a/packages/manager/src/features/Firewalls/shared.ts +++ /dev/null @@ -1,276 +0,0 @@ -import { truncateAndJoinList } from '@linode/utilities'; -import { capitalize } from '@linode/utilities'; - -import { useFlags } from 'src/hooks/useFlags'; - -import type { PORT_PRESETS } from './FirewallDetail/Rules/shared'; -import type { - Firewall, - FirewallRuleProtocol, - FirewallRuleType, -} from '@linode/api-v4/lib/firewalls/types'; - -export type FirewallPreset = 'dns' | 'http' | 'https' | 'mysql' | 'ssh'; - -export interface FirewallOptionItem { - label: L; - value: T; -} -// Predefined Firewall options for Select components (long-form). -export const firewallOptionItemsLong = [ - { - label: 'SSH (TCP 22 - All IPv4, All IPv6)', - value: 'ssh', - }, - { - label: 'HTTP (TCP 80 - All IPv4, All IPv6)', - value: 'http', - }, - { - label: 'HTTPS (TCP 443 - All IPv4, All IPv6)', - value: 'https', - }, - { - label: 'MySQL (TCP 3306 - All IPv4, All IPv6)', - value: 'mysql', - }, - { - label: 'DNS (TCP 53 - All IPv4, All IPv6)', - value: 'dns', - }, -]; - -// Predefined Firewall options for Select components (short-form). -export const firewallOptionItemsShort = [ - { - label: 'SSH', - value: 'ssh', - }, - { - label: 'HTTP', - value: 'http', - }, - { - label: 'HTTPS', - value: 'https', - }, - { - label: 'MySQL', - value: 'mysql', - }, - { - label: 'DNS', - value: 'dns', - }, -] as const; - -export const protocolOptions: FirewallOptionItem[] = [ - { label: 'TCP', value: 'TCP' }, - { label: 'UDP', value: 'UDP' }, - { label: 'ICMP', value: 'ICMP' }, - { label: 'IPENCAP', value: 'IPENCAP' }, -]; - -export const addressOptions = [ - { label: 'All IPv4, All IPv6', value: 'all' }, - { label: 'All IPv4', value: 'allIPv4' }, - { label: 'All IPv6', value: 'allIPv6' }, - { label: 'IP / Netmask', value: 'ip/netmask' }, -]; - -export const portPresets: Record = { - dns: '53', - http: '80', - https: '443', - mysql: '3306', - ssh: '22', -}; - -export const allIPv4 = '0.0.0.0/0'; -export const allIPv6 = '::/0'; - -export const allIPs = { - ipv4: [allIPv4], - ipv6: [allIPv6], -}; - -export interface PredefinedFirewall { - inbound: FirewallRuleType[]; - label: string; -} - -export const predefinedFirewalls: Record = { - dns: { - inbound: [ - { - action: 'ACCEPT', - addresses: allIPs, - label: `accept-inbound-DNS`, - ports: portPresets.dns, - protocol: 'TCP', - }, - ], - label: 'DNS', - }, - http: { - inbound: [ - { - action: 'ACCEPT', - addresses: allIPs, - label: `accept-inbound-HTTP`, - ports: portPresets.http, - protocol: 'TCP', - }, - ], - label: 'HTTP', - }, - https: { - inbound: [ - { - action: 'ACCEPT', - addresses: allIPs, - label: `accept-inbound-HTTPS`, - ports: portPresets.https, - protocol: 'TCP', - }, - ], - label: 'HTTPS', - }, - mysql: { - inbound: [ - { - action: 'ACCEPT', - addresses: allIPs, - label: `accept-inbound-MYSQL`, - ports: portPresets.mysql, - protocol: 'TCP', - }, - ], - label: 'MySQL', - }, - ssh: { - inbound: [ - { - action: 'ACCEPT', - addresses: allIPs, - label: `accept-inbound-SSH`, - ports: portPresets.ssh, - protocol: 'TCP', - }, - ], - label: 'SSH', - }, -}; - -export const predefinedFirewallFromRule = ( - rule: FirewallRuleType -): FirewallPreset | undefined => { - const { addresses, ports, protocol } = rule; - - // All predefined Firewalls have a protocol of TCP. - if (protocol !== 'TCP') { - return undefined; - } - - // All predefined Firewalls allow all IPs. - if (!allowsAllIPs(addresses)) { - return undefined; - } - - switch (ports) { - case portPresets.dns: - return 'dns'; - case portPresets.http: - return 'http'; - case portPresets.https: - return 'https'; - case portPresets.mysql: - return 'mysql'; - case portPresets.ssh: - return 'ssh'; - default: - return undefined; - } -}; - -export const allowsAllIPs = (addresses: FirewallRuleType['addresses']) => - allowAllIPv4(addresses) && allowAllIPv6(addresses); - -export const allowAllIPv4 = (addresses: FirewallRuleType['addresses']) => - addresses?.ipv4?.includes(allIPv4); - -export const allowAllIPv6 = (addresses: FirewallRuleType['addresses']) => - addresses?.ipv6?.includes(allIPv6); - -export const allowNoneIPv4 = (addresses: FirewallRuleType['addresses']) => - !addresses?.ipv4?.length; - -export const allowNoneIPv6 = (addresses: FirewallRuleType['addresses']) => - !addresses?.ipv6?.length; - -export const generateRuleLabel = (ruleType?: FirewallPreset) => - ruleType ? predefinedFirewalls[ruleType].label : 'Custom'; - -export const generateAddressesLabel = ( - addresses: FirewallRuleType['addresses'] -) => { - const strBuilder: string[] = []; - - const allowedAllIPv4 = allowAllIPv4(addresses); - const allowedAllIPv6 = allowAllIPv6(addresses); - - // First add the "All IPvX" strings so they always appear, even if the list - // ends up being truncated. - if (allowedAllIPv4) { - strBuilder.push('All IPv4'); - } - - if (allowedAllIPv6) { - strBuilder.push('All IPv6'); - } - - // Now we can look at the rest of the rules: - if (!allowedAllIPv4) { - addresses?.ipv4?.forEach((thisIP) => { - strBuilder.push(thisIP); - }); - } - - if (!allowedAllIPv6) { - addresses?.ipv6?.forEach((thisIP) => { - strBuilder.push(thisIP); - }); - } - - if (strBuilder.length > 0) { - return truncateAndJoinList(strBuilder, 3); - } - - // If no IPs are allowed. - return 'None'; -}; - -export const getFirewallDescription = (firewall: Firewall) => { - const description = [ - `Status: ${capitalize(firewall.status)}`, - `Services Assigned: ${firewall.entities.length}`, - ]; - return description.join(', '); -}; - -/** - * Returns whether or not features related to the Firewall Rulesets & Prefixlists project - * should be enabled. - * - * Note: Currently, this just uses the `firewallRulesetsPrefixlists` feature flag as a source of truth, - * but will eventually also look at account capabilities if available. - */ -export const useIsFirewallRulesetsPrefixlistsEnabled = () => { - const flags = useFlags(); - - // @TODO: Firewall Rulesets & Prefixlists - check for customer tag/account capability when it exists - return { - isFirewallRulesetsPrefixlistsEnabled: - flags.firewallRulesetsPrefixlists ?? false, - }; -}; diff --git a/packages/manager/src/features/Firewalls/shared.tsx b/packages/manager/src/features/Firewalls/shared.tsx new file mode 100644 index 00000000000..b236b0c695a --- /dev/null +++ b/packages/manager/src/features/Firewalls/shared.tsx @@ -0,0 +1,632 @@ +import { useAccount } from '@linode/queries'; +import { BetaChip, Box, Chip, NewFeatureChip, Tooltip } from '@linode/ui'; +import { + capitalize, + isFeatureEnabledV2, + truncateAndJoinList, +} from '@linode/utilities'; +import React from 'react'; + +import { Link } from 'src/components/Link'; +import { useFlags } from 'src/hooks/useFlags'; + +import type { PORT_PRESETS } from './FirewallDetail/Rules/shared'; +import type { + Firewall, + FirewallRuleProtocol, + FirewallRuleType, +} from '@linode/api-v4/lib/firewalls/types'; + +export type FirewallPreset = 'dns' | 'http' | 'https' | 'mysql' | 'ssh'; +export interface FirewallOptionItem { + label: L; + value: T; +} +// Predefined Firewall options for Select components (long-form). +export const firewallOptionItemsLong = [ + { + label: 'SSH (TCP 22 - All IPv4, All IPv6)', + value: 'ssh', + }, + { + label: 'HTTP (TCP 80 - All IPv4, All IPv6)', + value: 'http', + }, + { + label: 'HTTPS (TCP 443 - All IPv4, All IPv6)', + value: 'https', + }, + { + label: 'MySQL (TCP 3306 - All IPv4, All IPv6)', + value: 'mysql', + }, + { + label: 'DNS (TCP 53 - All IPv4, All IPv6)', + value: 'dns', + }, +]; + +// Predefined Firewall options for Select components (short-form). +export const firewallOptionItemsShort = [ + { + label: 'SSH', + value: 'ssh', + }, + { + label: 'HTTP', + value: 'http', + }, + { + label: 'HTTPS', + value: 'https', + }, + { + label: 'MySQL', + value: 'mysql', + }, + { + label: 'DNS', + value: 'dns', + }, +] as const; + +export const protocolOptions: FirewallOptionItem[] = [ + { label: 'TCP', value: 'TCP' }, + { label: 'UDP', value: 'UDP' }, + { label: 'ICMP', value: 'ICMP' }, + { label: 'IPENCAP', value: 'IPENCAP' }, +]; + +export const useAddressOptions = () => { + const { isFirewallRulesetsPrefixlistsFeatureEnabled } = + useIsFirewallRulesetsPrefixlistsEnabled(); + + return [ + { label: 'All IPv4, All IPv6', value: 'all' }, + { label: 'All IPv4', value: 'allIPv4' }, + { label: 'All IPv6', value: 'allIPv6' }, + { + label: isFirewallRulesetsPrefixlistsFeatureEnabled + ? 'IP / Netmask / Prefix List' + : 'IP / Netmask', + // We can keep this entire value even if the option is feature-flagged. + // Feature-flagging the label (without the "Prefix List" text) is sufficient. + value: 'ip/netmask/prefixlist', + }, + ]; +}; + +export const portPresets: Record = { + dns: '53', + http: '80', + https: '443', + mysql: '3306', + ssh: '22', +}; + +export const allIPv4 = '0.0.0.0/0'; +export const allIPv6 = '::/0'; + +export const allIPs = { + ipv4: [allIPv4], + ipv6: [allIPv6], +}; + +export const FW_RULESET_CAPABILITY = 'Cloud Firewall Rule Set'; + +export interface PredefinedFirewall { + inbound: FirewallRuleType[]; + label: string; +} + +export const predefinedFirewalls: Record = { + dns: { + inbound: [ + { + action: 'ACCEPT', + addresses: allIPs, + label: `accept-inbound-DNS`, + ports: portPresets.dns, + protocol: 'TCP', + }, + ], + label: 'DNS', + }, + http: { + inbound: [ + { + action: 'ACCEPT', + addresses: allIPs, + label: `accept-inbound-HTTP`, + ports: portPresets.http, + protocol: 'TCP', + }, + ], + label: 'HTTP', + }, + https: { + inbound: [ + { + action: 'ACCEPT', + addresses: allIPs, + label: `accept-inbound-HTTPS`, + ports: portPresets.https, + protocol: 'TCP', + }, + ], + label: 'HTTPS', + }, + mysql: { + inbound: [ + { + action: 'ACCEPT', + addresses: allIPs, + label: `accept-inbound-MYSQL`, + ports: portPresets.mysql, + protocol: 'TCP', + }, + ], + label: 'MySQL', + }, + ssh: { + inbound: [ + { + action: 'ACCEPT', + addresses: allIPs, + label: `accept-inbound-SSH`, + ports: portPresets.ssh, + protocol: 'TCP', + }, + ], + label: 'SSH', + }, +}; + +export const predefinedFirewallFromRule = ( + rule: FirewallRuleType +): FirewallPreset | undefined => { + const { addresses, ports, protocol } = rule; + + // All predefined Firewalls have a protocol of TCP. + if (protocol !== 'TCP') { + return undefined; + } + + // All predefined Firewalls allow all IPs. + if (!allowsAllIPs(addresses)) { + return undefined; + } + + switch (ports) { + case portPresets.dns: + return 'dns'; + case portPresets.http: + return 'http'; + case portPresets.https: + return 'https'; + case portPresets.mysql: + return 'mysql'; + case portPresets.ssh: + return 'ssh'; + default: + return undefined; + } +}; + +export const allowsAllIPs = (addresses: FirewallRuleType['addresses']) => + allowAllIPv4(addresses) && allowAllIPv6(addresses); + +export const allowAllIPv4 = (addresses: FirewallRuleType['addresses']) => + addresses?.ipv4?.includes(allIPv4); + +export const allowAllIPv6 = (addresses: FirewallRuleType['addresses']) => + addresses?.ipv6?.includes(allIPv6); + +export const allowNoneIPv4 = (addresses: FirewallRuleType['addresses']) => + !addresses?.ipv4?.length; + +export const allowNoneIPv6 = (addresses: FirewallRuleType['addresses']) => + !addresses?.ipv6?.length; + +export const generateRuleLabel = (ruleType?: FirewallPreset) => + ruleType ? predefinedFirewalls[ruleType].label : 'Custom'; + +export const generateAddressesLabel = ( + addresses: FirewallRuleType['addresses'] +) => { + const strBuilder: string[] = []; + + const allowedAllIPv4 = allowAllIPv4(addresses); + const allowedAllIPv6 = allowAllIPv6(addresses); + + // First add the "All IPvX" strings so they always appear, even if the list + // ends up being truncated. + if (allowedAllIPv4) { + strBuilder.push('All IPv4'); + } + + if (allowedAllIPv6) { + strBuilder.push('All IPv6'); + } + + // Now we can look at the rest of the rules: + if (!allowedAllIPv4) { + addresses?.ipv4?.forEach((thisIP) => { + strBuilder.push(thisIP); + }); + } + + if (!allowedAllIPv6) { + addresses?.ipv6?.forEach((thisIP) => { + strBuilder.push(thisIP); + }); + } + + if (strBuilder.length > 0) { + return truncateAndJoinList(strBuilder, 3); + } + + // If no IPs are allowed. + return 'None'; +}; + +export type PrefixListRuleReference = { + inIPv4Rule: boolean; + inIPv6Rule: boolean; +}; +export type PrefixListReferenceMap = Record; + +const isPrefixList = (ip: string) => ip.startsWith('pl:'); + +/** + * Builds a map of Prefix List (PL) labels to their firewall rule references. + * + * @param addresses - Object containing optional arrays of IPv4 and IPv6 addresses. + * Only addresses that are prefix lists (starting with 'pl:') are considered. + * @returns A map where each key is a PL label, and the value indicates whether + * the PL is referenced in the IPv4 and/or IPv6 firewall rule. + * + * @example + * const map = buildPrefixListReferenceMap({ + * ipv4: ['pl:system:test1', '1.2.3.4'], + * ipv6: ['pl:system:test1', '::1'] + * }); + * + * // Result: + * // { + * // 'pl:system:test1': { inIPv4Rule: true, inIPv6Rule: true } + * // } + */ +export const buildPrefixListReferenceMap = (addresses: { + ipv4?: string[]; + ipv6?: string[]; +}): PrefixListReferenceMap => { + const { ipv4 = [], ipv6 = [] } = addresses; + + const prefixListMap: PrefixListReferenceMap = {}; + + // Handle IPv4 + ipv4.forEach((ip) => { + if (isPrefixList(ip)) { + if (!prefixListMap[ip]) { + prefixListMap[ip] = { inIPv4Rule: false, inIPv6Rule: false }; + } + prefixListMap[ip].inIPv4Rule = true; + } + }); + + // Handle IPv6 + ipv6.forEach((ip) => { + if (isPrefixList(ip)) { + if (!prefixListMap[ip]) { + prefixListMap[ip] = { inIPv4Rule: false, inIPv6Rule: false }; + } + prefixListMap[ip].inIPv6Rule = true; + } + }); + + return prefixListMap; +}; + +/** + * Represents the Firewall Rule IP families to which a Prefix List (PL) is attached or referenced. + * + * Used to display a suffix next to the Prefix List label in the UI, e.g.,: + * "pl:system:example (IPv4)", "pl:system:example (IPv6)", or "pl:system:example (IPv4, IPv6)". + * + * The value indicates which firewall IPs the PL applies to: + * - "(IPv4)" -> PL is attached to Firewall Rule IPv4 only + * - "(IPv6)" -> PL is attached to Firewall Rule IPv6 only + * - "(IPv4, IPv6)" -> PL is attached to both Firewall Rule IPv4 and IPv6 + */ +export type FirewallRulePrefixListReferenceTag = + | '(IPv4)' + | '(IPv4, IPv6)' + | '(IPv6)'; + +interface GenerateAddressesLabelV2Options { + /** + * The list of addresses associated with a firewall rule. + */ + addresses: FirewallRuleType['addresses']; + /** + * Optional callback invoked when a prefix list label is clicked. + * + * @param prefixListLabel - The label of the clicked prefix list (e.g., "pl:system:test") + * @param plRuleRef - Indicates whether the PL is referenced in the IPv4 and/or IPv6 firewall rule + */ + onPrefixListClick?: ( + prefixListLabel: string, + plRuleRef: PrefixListRuleReference + ) => void; + /** + * Whether to show the truncation "+N" chip with a scrollable tooltip + * when there are more addresses than `truncateAt`. + * @default true + */ + showTruncateChip?: boolean; + /** + * Maximum number of addresses to show before truncation. + * Ignored if `showTruncateChip` is false. + * @default 1 + */ + truncateAt?: number; +} + +/** + * Generates IP addresses and clickable prefix lists (PLs) if available. + * Can render either a full list of elements or a truncated list with a "+N" chip and a full list tooltip. + * + * - Detects prefix lists (`pl::` or `pl:system:`) and renders them as clickable links. + * - Labels PLs across IPv4/IPv6 with rules reference suffixes: + * - Example PL: `pl:system:test` + * - Reference suffix: `(IPv4, IPv6)`, `(IPv4)`, or `(IPv6)` + * - Result: `pl:system:test (IPv4, IPv6)` or `pl:system:test (IPv4)` or `pl:system:test (IPv6)` + * - Supports optional truncation with a "+N" chip and a full list tooltip. + * - Reusable across components with configurable behavior. + * + * @param options - Configuration object including addresses, click handler, and truncation options. + * @returns React elements representing addresses and clickable PLs, or `'None'` if empty. + */ +export const generateAddressesLabelV2 = ( + options: GenerateAddressesLabelV2Options +) => { + const { + addresses, + onPrefixListClick, + showTruncateChip = true, + truncateAt = 1, + } = options; + const elements: React.ReactNode[] = []; + + const allowedAllIPv4 = allowAllIPv4(addresses); + const allowedAllIPv6 = allowAllIPv6(addresses); + + // First add "All IPvX" items + if (allowedAllIPv4 && allowedAllIPv6) { + elements.push('All IPv4, All IPv6'); + } else if (allowedAllIPv4) { + elements.push('All IPv4'); + } else if (allowedAllIPv6) { + elements.push('All IPv6'); + } + + // Add remaining IPv4 addresses that are not prefix lists + if (!allowedAllIPv4) { + addresses?.ipv4?.forEach((ip) => { + if (!isPrefixList(ip)) { + elements.push({ip}); + } + }); + } + + // Add remaining IPv6 addresses that are not prefix lists + if (!allowedAllIPv6) { + addresses?.ipv6?.forEach((ip) => { + if (!isPrefixList(ip)) { + elements.push({ip}); + } + }); + } + + // Build a map of prefix lists. + // NOTE: If "allowedAllIPv4" or "allowedAllIPv6" is true, we skip those IPs entirely + // because "All IPvX" is already represented, and there are no specific addresses to map. + const ipv4ForPLMapping = allowedAllIPv4 ? [] : (addresses?.ipv4 ?? []); + const ipv6ForPLMapping = allowedAllIPv6 ? [] : (addresses?.ipv6 ?? []); + + const prefixListReferenceMap = buildPrefixListReferenceMap({ + ipv4: ipv4ForPLMapping, + ipv6: ipv6ForPLMapping, + }); + + // Add prefix list links with merged labels (eg., "pl:system:test (IPv4, IPv6)") + Object.entries(prefixListReferenceMap).forEach(([pl, reference]) => { + let plRuleRefTag = '' as FirewallRulePrefixListReferenceTag; + if (reference.inIPv4Rule && reference.inIPv6Rule) { + plRuleRefTag = '(IPv4, IPv6)'; + } else if (reference.inIPv4Rule) { + plRuleRefTag = '(IPv4)'; + } else if (reference.inIPv6Rule) { + plRuleRefTag = '(IPv6)'; + } + + elements.push( + { + e.preventDefault(); + onPrefixListClick?.(pl, reference); + }} + > + {`${pl} ${plRuleRefTag}`} + + ); + }); + + // If no IPs are allowed + if (elements.length === 0) return 'None'; + + // Truncation / Chip logic + const hasMore = showTruncateChip && elements.length > truncateAt; + const truncated = hasMore ? elements.slice(0, truncateAt) : elements; + const hiddenElements = hasMore ? elements.slice(truncateAt) : []; + const hiddenCount = hiddenElements.length; + + const fullTooltip = ( + ({ + maxHeight: '40vh', + overflowY: 'auto', + px: theme.spacingFunction(16), + })} + > + ({ + display: 'flex', + flexDirection: 'column', + gap: 0.5, + px: theme.spacingFunction(20), + margin: 0, + })} + > + {hiddenElements.map((el, i) => ( +
  • + {el} +
  • + ))} +
    +
    + ); + + return ( + <> + ({ + // Only add gap if Chip is visible + marginRight: hasMore ? theme.spacingFunction(8) : 0, + })} + > + {truncated.map((el, idx) => ( + + {el} + {idx < truncated.length - 1 && ', '} + + ))} + + {hasMore && ( + ({ + minWidth: '248px', + paddingX: '0 !important', + paddingY: `${theme.spacingFunction(16)} !important`, + }), + }, + }} + title={fullTooltip} + > + ({ + cursor: 'pointer', + borderRadius: '12px', + minWidth: '33px', + borderColor: theme.tokens.component.Tag.Default.Border, + '&:hover': { + borderColor: theme.tokens.alias.Content.Icon.Primary.Hover, + }, + '& .MuiChip-label': { + // eslint-disable-next-line @linode/cloud-manager/no-custom-fontWeight + fontWeight: theme.tokens.font.FontWeight.Semibold, + }, + })} + variant="outlined" + /> + + )} + + ); +}; + +export const getFirewallDescription = (firewall: Firewall) => { + const description = [ + `Status: ${capitalize(firewall.status)}`, + `Services Assigned: ${firewall.entities.length}`, + ]; + return description.join(', '); +}; + +/** + * Returns whether or not features related to the Firewall Rulesets & Prefix Lists project + * should be enabled, and whether they are in beta, LA, or GA. + * + * Note: Currently, this just uses the `fwRulesetsPrefixlists` feature flag as a source of truth, + * but will eventually also look at account capabilities if available. + */ +export const useIsFirewallRulesetsPrefixlistsEnabled = () => { + const { data: account } = useAccount(); + const flags = useFlags(); + + if (!flags) { + return { + isFirewallRulesetsPrefixlistsFeatureEnabled: false, + isFirewallRulesetsPrefixListsBetaEnabled: false, + isFirewallRulesetsPrefixListsLAEnabled: false, + isFirewallRulesetsPrefixListsGAEnabled: false, + }; + } + + // @TODO: Firewall Rulesets & Prefix Lists - check for customer tag/account capability when it exists + return { + isFirewallRulesetsPrefixlistsFeatureEnabled: isFeatureEnabledV2( + FW_RULESET_CAPABILITY, + Boolean(flags.fwRulesetsPrefixLists?.enabled), + account?.capabilities ?? [] + ), + isFirewallRulesetsPrefixListsBetaEnabled: isFeatureEnabledV2( + FW_RULESET_CAPABILITY, + Boolean(flags.fwRulesetsPrefixLists?.beta), + account?.capabilities ?? [] + ), + isFirewallRulesetsPrefixListsLAEnabled: isFeatureEnabledV2( + FW_RULESET_CAPABILITY, + Boolean(flags.fwRulesetsPrefixLists?.la), + account?.capabilities ?? [] + ), + isFirewallRulesetsPrefixListsGAEnabled: isFeatureEnabledV2( + FW_RULESET_CAPABILITY, + Boolean(flags.fwRulesetsPrefixLists?.ga), + account?.capabilities ?? [] + ), + }; +}; + +/** + * Returns the feature chip for Firewall Rulesets & Prefix Lists. + * + * - Shows `` if the feature is in Beta. + * - Shows `` if the feature is in GA. + * - Returns `null` if the feature is disabled OR if the feature is enabled but no chip applies. + */ +export const getFeatureChip = ({ + isFirewallRulesetsPrefixlistsFeatureEnabled, + isFirewallRulesetsPrefixListsBetaEnabled, + isFirewallRulesetsPrefixListsGAEnabled, +}: Omit< + ReturnType, + 'isFirewallRulesetsPrefixListsLAEnabled' +>) => { + if (!isFirewallRulesetsPrefixlistsFeatureEnabled) return null; + if (isFirewallRulesetsPrefixListsBetaEnabled) return ; + if (isFirewallRulesetsPrefixListsGAEnabled) return ; + return null; +}; diff --git a/packages/manager/src/features/IAM/IAMLanding.tsx b/packages/manager/src/features/IAM/IAMLanding.tsx index 822ee434d84..588ef58e7f9 100644 --- a/packages/manager/src/features/IAM/IAMLanding.tsx +++ b/packages/manager/src/features/IAM/IAMLanding.tsx @@ -1,3 +1,4 @@ +import { Chip, NewFeatureChip, styled } from '@linode/ui'; import { Outlet, useLocation, useNavigate } from '@tanstack/react-router'; import * as React from 'react'; @@ -6,13 +7,21 @@ import { SuspenseLoader } from 'src/components/SuspenseLoader'; import { TabPanels } from 'src/components/Tabs/TabPanels'; import { Tabs } from 'src/components/Tabs/Tabs'; import { TanStackTabLinkList } from 'src/components/Tabs/TanStackTabLinkList'; +import { useFlags } from 'src/hooks/useFlags'; import { useTabs } from 'src/hooks/useTabs'; import { useDelegationRole } from './hooks/useDelegationRole'; -import { useIsIAMDelegationEnabled } from './hooks/useIsIAMEnabled'; +import { + useIsIAMDelegationEnabled, + useIsIAMEnabled, +} from './hooks/useIsIAMEnabled'; import { IAM_DOCS_LINK, ROLES_LEARN_MORE_LINK } from './Shared/constants'; export const IdentityAccessLanding = React.memo(() => { + const flags = useFlags(); + const { isIAMBeta, isIAMEnabled } = useIsIAMEnabled(); + const showLimitedAvailabilityBadges = + flags.iamLimitedAvailabilityBadges && isIAMEnabled && !isIAMBeta; const location = useLocation(); const navigate = useNavigate(); const { isParentAccount } = useDelegationRole(); @@ -49,7 +58,21 @@ export const IdentityAccessLanding = React.memo(() => { return ( <> - + + + + + ) : null, + }, + removeCrumbX: 1, + }} + spacingBottom={4} + /> }> @@ -61,3 +84,20 @@ export const IdentityAccessLanding = React.memo(() => { ); }); + +const StyledLimitedAvailabilityChip = styled(Chip, { + label: 'StyledLimitedAvailabilityChip', + shouldForwardProp: (prop) => prop !== 'color', +})(({ theme }) => ({ + '& .MuiChip-label': { + padding: 0, + }, + background: theme.tokens.component.Badge.Informative.Subtle.Background, + color: theme.tokens.component.Badge.Informative.Subtle.Text, + font: theme.font.bold, + fontSize: '12px', + lineHeight: '12px', + height: 16, + letterSpacing: '.22px', + padding: theme.spacingFunction(4), +})); diff --git a/packages/manager/src/features/IAM/Roles/RolesTable/RolesTable.tsx b/packages/manager/src/features/IAM/Roles/RolesTable/RolesTable.tsx index c5698da5077..b924f7ed18b 100644 --- a/packages/manager/src/features/IAM/Roles/RolesTable/RolesTable.tsx +++ b/packages/manager/src/features/IAM/Roles/RolesTable/RolesTable.tsx @@ -100,9 +100,7 @@ export const RolesTable = ({ roles = [] }: Props) => { }; const filteredRows = React.useMemo(() => { - if (!query) return roles; - - return getFilteredRows(query, filterableEntityType?.value); + return getFilteredRows(query ?? '', filterableEntityType?.value); }, [roles, query, filterableEntityType]); // Get just the list of entity types from this list of roles, to be used in the selection filter diff --git a/packages/manager/src/features/IAM/Shared/AssignedPermissionsPanel/AssignedPermissionsPanel.tsx b/packages/manager/src/features/IAM/Shared/AssignedPermissionsPanel/AssignedPermissionsPanel.tsx index 2f6b38c0dff..c9b33e75b56 100644 --- a/packages/manager/src/features/IAM/Shared/AssignedPermissionsPanel/AssignedPermissionsPanel.tsx +++ b/packages/manager/src/features/IAM/Shared/AssignedPermissionsPanel/AssignedPermissionsPanel.tsx @@ -3,7 +3,7 @@ import * as React from 'react'; import { Link } from 'src/components/Link'; import { ROLES_LEARN_MORE_LINK } from '../constants'; -import { Entities } from '../Entities/Entities'; +import { EntitiesSelect } from '../Entities/EntitiesSelect'; import { Permissions } from '../Permissions/Permissions'; import { type ExtendedRole, getFacadeRoleDescription } from '../utilities'; import { @@ -62,7 +62,7 @@ export const AssignedPermissionsPanel = ({ )} {mode !== 'change-role-for-entity' && ( - { it('renders correct data when it is an account access and type is an account', () => { renderWithTheme( - { it('renders correct data when it is an account access and type is not an account', () => { renderWithTheme( - { }); renderWithTheme( - { }); renderWithTheme( - { }); renderWithTheme( - { }); renderWithTheme( - { it('renders Autocomplete as readonly when mode is "change-role"', () => { renderWithTheme( - { const errorMessage = 'Entities are required.'; renderWithTheme( - { - const { data: entities } = useAllAccountEntities({}); + const { data: entities, isLoading } = useAllAccountEntities({}); const theme = useTheme(); + const [displayCount, setDisplayCount] = React.useState(INITIAL_DISPLAY_COUNT); + const [inputValue, setInputValue] = React.useState(''); + const memoizedEntities = React.useMemo(() => { if (access !== 'entity_access' || !entities) { return []; @@ -46,6 +52,30 @@ export const Entities = ({ return typeEntities ? mapEntitiesToOptions(typeEntities) : []; }, [entities, access, type]); + const filteredEntities = React.useMemo(() => { + if (!inputValue) { + return memoizedEntities; + } + + return memoizedEntities.filter((option) => + option.label.toLowerCase().includes(inputValue.toLowerCase()) + ); + }, [memoizedEntities, inputValue]); + + const visibleOptions = React.useMemo(() => { + const slice = filteredEntities.slice(0, displayCount); + + const selectedNotVisible = value.filter( + (selected) => !slice.some((opt) => opt.value === selected.value) + ); + + return [...slice, ...selectedNotVisible]; + }, [filteredEntities, displayCount, value]); + + React.useEffect(() => { + setDisplayCount(INITIAL_DISPLAY_COUNT); + }, [filteredEntities]); + if (access === 'account_access') { return ( <> @@ -75,16 +105,28 @@ export const Entities = ({ getOptionLabel={(option) => option.label} isOptionEqualToValue={(option, value) => option.value === value.value} label="Entities" + loading={isLoading} multiple noMarginTop - onChange={(_, newValue) => { - onChange(newValue || []); + onChange={(_, newValue, reason) => { + if ( + reason === 'selectOption' && + newValue.length === displayCount && + filteredEntities.length > displayCount + ) { + onChange(filteredEntities); + } else { + onChange(newValue || []); + } }} - options={memoizedEntities} + onInputChange={(_, value) => { + setInputValue(value); + }} + options={visibleOptions} placeholder={getPlaceholder( type, value.length, - memoizedEntities.length + filteredEntities.length )} readOnly={mode === 'change-role'} renderInput={(params) => ( @@ -97,10 +139,22 @@ export const Entities = ({ placeholder={getPlaceholder( type, value.length, - memoizedEntities.length + filteredEntities.length )} /> )} + slotProps={{ + listbox: { + onScroll: (e) => { + const { scrollTop, scrollHeight, clientHeight } = e.currentTarget; + if (scrollHeight - scrollTop <= clientHeight * 1.5) { + setDisplayCount((prev) => + Math.min(prev + 200, filteredEntities.length) + ); + } + }, + }, + }} sx={{ marginTop: 0, '& .MuiChip-root': { diff --git a/packages/manager/src/features/IAM/Users/UserDetailsLanding.tsx b/packages/manager/src/features/IAM/Users/UserDetailsLanding.tsx index 3fcd1a070bb..0d4d6ece9c0 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, styled } from '@linode/ui'; +import { Chip, NewFeatureChip, styled } from '@linode/ui'; import { Outlet, useLoaderData, useParams } from '@tanstack/react-router'; import React from 'react'; @@ -6,7 +6,11 @@ import { LandingHeader } from 'src/components/LandingHeader'; import { TabPanels } from 'src/components/Tabs/TabPanels'; import { Tabs } from 'src/components/Tabs/Tabs'; import { TanStackTabLinkList } from 'src/components/Tabs/TanStackTabLinkList'; -import { useIsIAMDelegationEnabled } from 'src/features/IAM/hooks/useIsIAMEnabled'; +import { + useIsIAMDelegationEnabled, + useIsIAMEnabled, +} from 'src/features/IAM/hooks/useIsIAMEnabled'; +import { useFlags } from 'src/hooks/useFlags'; import { useTabs } from 'src/hooks/useTabs'; import { useDelegationRole } from '../hooks/useDelegationRole'; @@ -18,6 +22,10 @@ import { } from '../Shared/constants'; export const UserDetailsLanding = () => { + const flags = useFlags(); + const { isIAMBeta, isIAMEnabled } = useIsIAMEnabled(); + const showLimitedAvailabilityBadges = + flags.iamLimitedAvailabilityBadges && isIAMEnabled && !isIAMBeta; const { username } = useParams({ from: '/iam/users/$username' }); const { isIAMDelegationEnabled } = useIsIAMDelegationEnabled(); const { isParentAccount } = useDelegationRole(); @@ -55,7 +63,14 @@ export const UserDetailsLanding = () => { breadcrumbProps={{ crumbOverrides: [ { - label: IAM_LABEL, + label: ( + <> + {IAM_LABEL} + {showLimitedAvailabilityBadges ? ( + + ) : null} + + ), position: 1, }, ], diff --git a/packages/manager/src/features/IAM/Users/UserRoles/AssignedEntities.tsx b/packages/manager/src/features/IAM/Users/UserRoles/AssignedEntities.tsx index f1c53a8b555..ee40b3929c0 100644 --- a/packages/manager/src/features/IAM/Users/UserRoles/AssignedEntities.tsx +++ b/packages/manager/src/features/IAM/Users/UserRoles/AssignedEntities.tsx @@ -34,7 +34,13 @@ export const AssignedEntities = ({ return sortByString(a.name, b.name, 'asc'); }); - const items = sortedEntities?.map((entity: CombinedEntity) => ( + // We don't need to send all items to the TruncatedList component for performance reasons, + // since past a certain count they will be hidden within the row. + const MAX_ITEMS_TO_RENDER = 15; + const entitiesToRender = sortedEntities.slice(0, MAX_ITEMS_TO_RENDER); + const totalCount = sortedEntities.length; + + const items = entitiesToRender?.map((entity: CombinedEntity) => ( ( - - - - - - )} + customOverflowButton={(numHiddenByTruncate) => { + const numHiddenItems = + totalCount <= MAX_ITEMS_TO_RENDER + ? numHiddenByTruncate + : totalCount - MAX_ITEMS_TO_RENDER + numHiddenByTruncate; + + return ( + + + + + + ); + }} justifyOverflowButtonRight listContainerSx={{ width: '100%', diff --git a/packages/manager/src/features/IAM/Users/UsersTable/UsersActionMenu.tsx b/packages/manager/src/features/IAM/Users/UsersTable/UsersActionMenu.tsx index defaca1680f..fcf5920de61 100644 --- a/packages/manager/src/features/IAM/Users/UsersTable/UsersActionMenu.tsx +++ b/packages/manager/src/features/IAM/Users/UsersTable/UsersActionMenu.tsx @@ -109,7 +109,7 @@ export const UsersActionMenu = (props: Props) => { return ( ); }; diff --git a/packages/manager/src/features/IAM/hooks/adapters/permissionAdapters.ts b/packages/manager/src/features/IAM/hooks/adapters/permissionAdapters.ts index 9565c8eec54..9db821fe078 100644 --- a/packages/manager/src/features/IAM/hooks/adapters/permissionAdapters.ts +++ b/packages/manager/src/features/IAM/hooks/adapters/permissionAdapters.ts @@ -6,9 +6,9 @@ import { nodeBalancerGrantsToPermissions } from './nodeBalancerGrantsToPermissio import { volumeGrantsToPermissions } from './volumeGrantsToPermissions'; import { vpcGrantsToPermissions } from './vpcGrantsToPermissions'; -import type { EntityBase } from '../usePermissions'; import type { AccessType, + AccountEntity, Grants, GrantType, PermissionType, @@ -161,6 +161,8 @@ export const fromGrants = ( return permissionsMap; }; +type EntityBase = Pick; + /** Combines a list of entities and the permissions associated with the entity */ export const toEntityPermissionMap = ( entities: EntityBase[] | undefined, diff --git a/packages/manager/src/features/IAM/hooks/useGetUserEntitiesByPermission.test.ts b/packages/manager/src/features/IAM/hooks/useGetAllUserEntitiesByPermission.test.ts similarity index 67% rename from packages/manager/src/features/IAM/hooks/useGetUserEntitiesByPermission.test.ts rename to packages/manager/src/features/IAM/hooks/useGetAllUserEntitiesByPermission.test.ts index 7def28d5023..0e6db14bd6a 100644 --- a/packages/manager/src/features/IAM/hooks/useGetUserEntitiesByPermission.test.ts +++ b/packages/manager/src/features/IAM/hooks/useGetAllUserEntitiesByPermission.test.ts @@ -2,13 +2,18 @@ import { renderHook, waitFor } from '@testing-library/react'; import { wrapWithTheme } from 'src/utilities/testHelpers'; -import { useGetUserEntitiesByPermission } from './useGetUserEntitiesByPermission'; +import { useGetAllUserEntitiesByPermission } from './useGetAllUserEntitiesByPermission'; -import type { APIError, Grants, Linode, Profile } from '@linode/api-v4'; -import type { UseQueryResult } from '@tanstack/react-query'; +import type { Grants, Linode, Profile } from '@linode/api-v4'; const queryMocks = vi.hoisted(() => ({ - useGetUserEntitiesByPermissionQuery: vi.fn(), + useAllFirewallsQuery: vi.fn(), + useAllImagesQuery: vi.fn(), + useAllLinodesQuery: vi.fn(), + useAllNodeBalancersQuery: vi.fn(), + useAllVolumesQuery: vi.fn(), + useAllVPCsQuery: vi.fn(), + useGetAllUserEntitiesByPermissionQuery: vi.fn(), useGrants: vi.fn(), useIsIAMEnabled: vi.fn(), entityPermissionMapFrom: vi.fn(), @@ -18,8 +23,14 @@ vi.mock(import('@linode/queries'), async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - useGetUserEntitiesByPermissionQuery: - queryMocks.useGetUserEntitiesByPermissionQuery, + useAllFirewallsQuery: queryMocks.useAllFirewallsQuery, + useAllImagesQuery: queryMocks.useAllImagesQuery, + useAllLinodesQuery: queryMocks.useAllLinodesQuery, + useAllNodeBalancersQuery: queryMocks.useAllNodeBalancersQuery, + useAllVolumesQuery: queryMocks.useAllVolumesQuery, + useAllVPCsQuery: queryMocks.useAllVPCsQuery, + useGetAllUserEntitiesByPermissionQuery: + queryMocks.useGetAllUserEntitiesByPermissionQuery, useGrants: queryMocks.useGrants, }; }); @@ -32,7 +43,7 @@ vi.mock('./adapters/permissionAdapters', () => ({ entityPermissionMapFrom: queryMocks.entityPermissionMapFrom, })); -describe('useGetUserEntitiesByPermission', () => { +describe('useGetAllUserEntitiesByPermission', () => { const mockLinodes: Linode[] = [ { id: 1, @@ -54,24 +65,34 @@ describe('useGetUserEntitiesByPermission', () => { } as Linode, ]; - const mockQuery = { + const mockLinodeQueryResult = { data: mockLinodes, isLoading: false, error: null, refetch: vi.fn(), isSuccess: true, isError: false, - } as unknown as UseQueryResult; + }; beforeEach(() => { vi.clearAllMocks(); - queryMocks.useGetUserEntitiesByPermissionQuery.mockReturnValue({ + + // Default mock for all entity query hooks + queryMocks.useAllLinodesQuery.mockReturnValue(mockLinodeQueryResult); + queryMocks.useAllFirewallsQuery.mockReturnValue(mockLinodeQueryResult); + queryMocks.useAllVolumesQuery.mockReturnValue(mockLinodeQueryResult); + queryMocks.useAllNodeBalancersQuery.mockReturnValue(mockLinodeQueryResult); + queryMocks.useAllImagesQuery.mockReturnValue(mockLinodeQueryResult); + queryMocks.useAllVPCsQuery.mockReturnValue(mockLinodeQueryResult); + + queryMocks.useGetAllUserEntitiesByPermissionQuery.mockReturnValue({ data: undefined, isLoading: false, error: null, }); queryMocks.useGrants.mockReturnValue({ data: undefined, + isLoading: false, }); }); @@ -91,7 +112,7 @@ describe('useGetUserEntitiesByPermission', () => { { id: 2, label: 'linode-2', type: 'linode' }, ]; - queryMocks.useGetUserEntitiesByPermissionQuery.mockReturnValue({ + queryMocks.useGetAllUserEntitiesByPermissionQuery.mockReturnValue({ data: mockEntitiesByPermission, isLoading: false, error: null, @@ -101,11 +122,9 @@ describe('useGetUserEntitiesByPermission', () => { const { result } = renderHook( () => - useGetUserEntitiesByPermission({ - query: mockQuery, + useGetAllUserEntitiesByPermission({ entityType: 'linode', permission: 'view_linode', - username: 'restricted-user', }), { wrapper: (ui) => wrapWithTheme(ui, { flags }), @@ -114,33 +133,28 @@ describe('useGetUserEntitiesByPermission', () => { await waitFor(() => { expect(result.current.data).toHaveLength(2); - expect(result.current.data?.[0]).toEqual(mockLinodes[0]); - // eslint-disable-next-line testing-library/no-wait-for-multiple-assertions - expect(result.current.data?.[1]).toEqual(mockLinodes[1]); }); }); - it('should filter out entities not found in allEntities', async () => { - const mockEntitiesByPermission = [ - { id: 1, label: 'linode-1', type: 'linode' }, - { id: 999, label: 'missing-linode', type: 'linode' }, - ]; - - queryMocks.useGetUserEntitiesByPermissionQuery.mockReturnValue({ - data: mockEntitiesByPermission, + it('should return empty array when user has no permissions', async () => { + queryMocks.useGetAllUserEntitiesByPermissionQuery.mockReturnValue({ + data: [], isLoading: false, error: null, }); + queryMocks.useAllLinodesQuery.mockReturnValue({ + ...mockLinodeQueryResult, + data: [], + }); + const flags = { iam: { beta: false, enabled: true } }; const { result } = renderHook( () => - useGetUserEntitiesByPermission({ - query: mockQuery, + useGetAllUserEntitiesByPermission({ entityType: 'linode', permission: 'view_linode', - username: 'restricted-user', }), { wrapper: (ui) => wrapWithTheme(ui, { flags }), @@ -148,28 +162,26 @@ describe('useGetUserEntitiesByPermission', () => { ); await waitFor(() => { - expect(result.current.data).toHaveLength(1); - // eslint-disable-next-line testing-library/no-wait-for-multiple-assertions - expect(result.current.data?.[0]?.id).toBe(1); + expect(result.current.data).toEqual([]); }); }); - it('should return empty array when user has no permissions', async () => { - queryMocks.useGetUserEntitiesByPermissionQuery.mockReturnValue({ - data: [], + it('should return empty array and error when permission query fails', async () => { + const mockError = [{ reason: 'Permission denied' }]; + + queryMocks.useGetAllUserEntitiesByPermissionQuery.mockReturnValue({ + data: undefined, isLoading: false, - error: null, + error: mockError, }); const flags = { iam: { beta: false, enabled: true } }; const { result } = renderHook( () => - useGetUserEntitiesByPermission({ - query: mockQuery, + useGetAllUserEntitiesByPermission({ entityType: 'linode', permission: 'view_linode', - username: 'restricted-user', }), { wrapper: (ui) => wrapWithTheme(ui, { flags }), @@ -177,7 +189,8 @@ describe('useGetUserEntitiesByPermission', () => { ); await waitFor(() => { - expect(result.current.data).toEqual([]); + expect(result.current.data).toEqual([]); // Security: don't return all + expect(result.current.error).toEqual(mockError); }); }); }); @@ -195,7 +208,7 @@ describe('useGetUserEntitiesByPermission', () => { }); it('should return all entities without filtering', async () => { - queryMocks.useGetUserEntitiesByPermissionQuery.mockReturnValue({ + queryMocks.useGetAllUserEntitiesByPermissionQuery.mockReturnValue({ data: [], isLoading: false, error: null, @@ -205,11 +218,9 @@ describe('useGetUserEntitiesByPermission', () => { const { result } = renderHook( () => - useGetUserEntitiesByPermission({ - query: mockQuery, + useGetAllUserEntitiesByPermission({ entityType: 'linode', permission: 'view_linode', - username: 'unrestricted-user', }), { wrapper: (ui) => wrapWithTheme(ui, { flags }), @@ -218,21 +229,25 @@ describe('useGetUserEntitiesByPermission', () => { await waitFor(() => { expect(result.current.data).toEqual(mockLinodes); - // eslint-disable-next-line testing-library/no-wait-for-multiple-assertions expect(result.current.data).toHaveLength(3); + expect(result.current.filter).toEqual({}); // No filter for unrestricted }); }); - it('should not call permission query for unrestricted users', async () => { + it('should call entity query without filter', async () => { + queryMocks.useGetAllUserEntitiesByPermissionQuery.mockReturnValue({ + data: [], + isLoading: false, + error: null, + }); + const flags = { iam: { beta: false, enabled: true } }; renderHook( () => - useGetUserEntitiesByPermission({ - query: mockQuery, + useGetAllUserEntitiesByPermission({ entityType: 'linode', permission: 'view_linode', - username: 'unrestricted-user', }), { wrapper: (ui) => wrapWithTheme(ui, { flags }), @@ -240,9 +255,11 @@ describe('useGetUserEntitiesByPermission', () => { ); await waitFor(() => { - expect( - queryMocks.useGetUserEntitiesByPermissionQuery - ).toHaveBeenCalled(); + expect(queryMocks.useAllLinodesQuery).toHaveBeenCalledWith( + {}, + {}, // Empty filter + true + ); }); }); }); @@ -266,21 +283,24 @@ describe('useGetUserEntitiesByPermission', () => { { id: 1, label: 'linode-1', type: 'linode' }, ]; - queryMocks.useGetUserEntitiesByPermissionQuery.mockReturnValue({ + queryMocks.useGetAllUserEntitiesByPermissionQuery.mockReturnValue({ data: mockEntitiesByPermission, isLoading: false, error: null, }); + queryMocks.useAllLinodesQuery.mockReturnValue({ + ...mockLinodeQueryResult, + data: [mockLinodes[0]], + }); + const flags = { iam: { beta: true, enabled: true } }; const { result } = renderHook( () => - useGetUserEntitiesByPermission({ - query: mockQuery, + useGetAllUserEntitiesByPermission({ entityType: 'linode', permission: 'view_linode', - username: 'beta-restricted-user', }), { wrapper: (ui) => wrapWithTheme(ui, { flags }), @@ -289,7 +309,6 @@ describe('useGetUserEntitiesByPermission', () => { await waitFor(() => { expect(result.current.data).toHaveLength(1); - // eslint-disable-next-line testing-library/no-wait-for-multiple-assertions expect(result.current.data?.[0]?.id).toBe(1); }); }); @@ -308,7 +327,7 @@ describe('useGetUserEntitiesByPermission', () => { }); it('should return all entities for unrestricted beta users', async () => { - queryMocks.useGetUserEntitiesByPermissionQuery.mockReturnValue({ + queryMocks.useGetAllUserEntitiesByPermissionQuery.mockReturnValue({ data: [], isLoading: false, error: null, @@ -318,11 +337,9 @@ describe('useGetUserEntitiesByPermission', () => { const { result } = renderHook( () => - useGetUserEntitiesByPermission({ - query: mockQuery, + useGetAllUserEntitiesByPermission({ entityType: 'linode', permission: 'view_linode', - username: 'beta-unrestricted-user', }), { wrapper: (ui) => wrapWithTheme(ui, { flags }), @@ -331,7 +348,6 @@ describe('useGetUserEntitiesByPermission', () => { await waitFor(() => { expect(result.current.data).toEqual(mockLinodes); - // eslint-disable-next-line testing-library/no-wait-for-multiple-assertions expect(result.current.data).toHaveLength(3); }); }); @@ -366,6 +382,7 @@ describe('useGetUserEntitiesByPermission', () => { queryMocks.useGrants.mockReturnValue({ data: mockGrants, + isLoading: false, }); queryMocks.entityPermissionMapFrom.mockReturnValue(mockPermissionMap); @@ -374,11 +391,9 @@ describe('useGetUserEntitiesByPermission', () => { const { result } = renderHook( () => - useGetUserEntitiesByPermission({ - query: mockQuery, + useGetAllUserEntitiesByPermission({ entityType: 'linode', permission: 'view_linode', - username: 'grants-restricted-user', }), { wrapper: (ui) => wrapWithTheme(ui, { flags }), @@ -387,7 +402,6 @@ describe('useGetUserEntitiesByPermission', () => { await waitFor(() => { expect(result.current.data).toHaveLength(2); - // eslint-disable-next-line testing-library/no-wait-for-multiple-assertions expect(queryMocks.entityPermissionMapFrom).toHaveBeenCalledWith( mockGrants, 'linode', @@ -408,6 +422,7 @@ describe('useGetUserEntitiesByPermission', () => { queryMocks.useGrants.mockReturnValue({ data: mockGrants, + isLoading: false, }); queryMocks.entityPermissionMapFrom.mockReturnValue(mockPermissionMap); @@ -416,11 +431,9 @@ describe('useGetUserEntitiesByPermission', () => { const { result } = renderHook( () => - useGetUserEntitiesByPermission({ - query: mockQuery, + useGetAllUserEntitiesByPermission({ entityType: 'linode', permission: 'view_linode', - username: 'grants-restricted-user', }), { wrapper: (ui) => wrapWithTheme(ui, { flags }), @@ -429,7 +442,6 @@ describe('useGetUserEntitiesByPermission', () => { await waitFor(() => { expect(result.current.data).toHaveLength(1); - // eslint-disable-next-line testing-library/no-wait-for-multiple-assertions expect(result.current.data?.[0]?.id).toBe(1); }); }); @@ -437,6 +449,7 @@ describe('useGetUserEntitiesByPermission', () => { it('should return empty array when grants are empty', async () => { queryMocks.useGrants.mockReturnValue({ data: {} as Grants, + isLoading: false, }); queryMocks.entityPermissionMapFrom.mockReturnValue({}); @@ -445,11 +458,9 @@ describe('useGetUserEntitiesByPermission', () => { const { result } = renderHook( () => - useGetUserEntitiesByPermission({ - query: mockQuery, + useGetAllUserEntitiesByPermission({ entityType: 'linode', permission: 'view_linode', - username: 'grants-restricted-user', }), { wrapper: (ui) => wrapWithTheme(ui, { flags }), @@ -460,6 +471,35 @@ describe('useGetUserEntitiesByPermission', () => { expect(result.current.data).toEqual([]); }); }); + + it('should call entity query without filter for legacy grants', async () => { + queryMocks.useGrants.mockReturnValue({ + data: {} as Grants, + isLoading: false, + }); + + const flags = { iam: { beta: false, enabled: false } }; + + renderHook( + () => + useGetAllUserEntitiesByPermission({ + entityType: 'linode', + permission: 'view_linode', + }), + { + wrapper: (ui) => wrapWithTheme(ui, { flags }), + } + ); + + await waitFor(() => { + // Legacy path calls query without filter + expect(queryMocks.useAllLinodesQuery).toHaveBeenCalledWith( + {}, + {}, + true + ); + }); + }); }); describe('Unrestricted User', () => { @@ -477,17 +517,16 @@ describe('useGetUserEntitiesByPermission', () => { it('should return all entities without grant filtering', async () => { queryMocks.useGrants.mockReturnValue({ data: undefined, + isLoading: false, }); const flags = { iam: { beta: false, enabled: false } }; const { result } = renderHook( () => - useGetUserEntitiesByPermission({ - query: mockQuery, + useGetAllUserEntitiesByPermission({ entityType: 'linode', permission: 'view_linode', - username: 'grants-unrestricted-user', }), { wrapper: (ui) => wrapWithTheme(ui, { flags }), @@ -496,7 +535,6 @@ describe('useGetUserEntitiesByPermission', () => { await waitFor(() => { expect(result.current.data).toEqual(mockLinodes); - // eslint-disable-next-line testing-library/no-wait-for-multiple-assertions expect(result.current.data).toHaveLength(3); }); }); @@ -504,17 +542,16 @@ describe('useGetUserEntitiesByPermission', () => { it('should not call entityPermissionMapFrom for unrestricted users', async () => { queryMocks.useGrants.mockReturnValue({ data: {} as Grants, + isLoading: false, }); const flags = { iam: { beta: false, enabled: false } }; renderHook( () => - useGetUserEntitiesByPermission({ - query: mockQuery, + useGetAllUserEntitiesByPermission({ entityType: 'linode', permission: 'view_linode', - username: 'grants-unrestricted-user', }), { wrapper: (ui) => wrapWithTheme(ui, { flags }), @@ -529,18 +566,18 @@ describe('useGetUserEntitiesByPermission', () => { }); describe('Loading States', () => { - it('should combine loading states from both queries', async () => { - const loadingQuery = { - ...mockQuery, - isLoading: true, - } as unknown as UseQueryResult; - - queryMocks.useGetUserEntitiesByPermissionQuery.mockReturnValue({ + it('should combine loading states from permission and entity queries', async () => { + queryMocks.useGetAllUserEntitiesByPermissionQuery.mockReturnValue({ data: undefined, isLoading: true, error: null, }); + queryMocks.useAllLinodesQuery.mockReturnValue({ + ...mockLinodeQueryResult, + isLoading: true, + }); + queryMocks.useIsIAMEnabled.mockReturnValue({ isIAMEnabled: true, isIAMBeta: false, @@ -551,11 +588,9 @@ describe('useGetUserEntitiesByPermission', () => { const { result } = renderHook( () => - useGetUserEntitiesByPermission({ - query: loadingQuery, + useGetAllUserEntitiesByPermission({ entityType: 'linode', permission: 'view_linode', - username: 'test-user', }), { wrapper: (ui) => wrapWithTheme(ui, { flags }), @@ -566,12 +601,17 @@ describe('useGetUserEntitiesByPermission', () => { }); it('should return isLoading false when all queries are complete', async () => { - queryMocks.useGetUserEntitiesByPermissionQuery.mockReturnValue({ + queryMocks.useGetAllUserEntitiesByPermissionQuery.mockReturnValue({ data: [], isLoading: false, error: null, }); + queryMocks.useAllLinodesQuery.mockReturnValue({ + ...mockLinodeQueryResult, + isLoading: false, + }); + queryMocks.useIsIAMEnabled.mockReturnValue({ isIAMEnabled: true, isIAMBeta: false, @@ -582,11 +622,9 @@ describe('useGetUserEntitiesByPermission', () => { const { result } = renderHook( () => - useGetUserEntitiesByPermission({ - query: mockQuery, + useGetAllUserEntitiesByPermission({ entityType: 'linode', permission: 'view_linode', - username: 'test-user', }), { wrapper: (ui) => wrapWithTheme(ui, { flags }), @@ -600,14 +638,10 @@ describe('useGetUserEntitiesByPermission', () => { }); describe('Error States', () => { - it('should combine error states from both queries', async () => { - const mockError = [{ reason: 'Test error' }]; - const errorQuery = { - ...mockQuery, - error: mockError, - } as unknown as UseQueryResult; + it('should return permission query error and empty data', async () => { + const mockError = [{ reason: 'Permission query error' }]; - queryMocks.useGetUserEntitiesByPermissionQuery.mockReturnValue({ + queryMocks.useGetAllUserEntitiesByPermissionQuery.mockReturnValue({ data: undefined, isLoading: false, error: mockError, @@ -623,26 +657,32 @@ describe('useGetUserEntitiesByPermission', () => { const { result } = renderHook( () => - useGetUserEntitiesByPermission({ - query: errorQuery, + useGetAllUserEntitiesByPermission({ entityType: 'linode', permission: 'view_linode', - username: 'test-user', }), { wrapper: (ui) => wrapWithTheme(ui, { flags }), } ); - expect(result.current.error).toBeTruthy(); + await waitFor(() => { + expect(result.current.error).toEqual(mockError); + expect(result.current.data).toEqual([]); // Security: empty on error + }); }); - it('should handle permission query errors', async () => { - const mockError = [{ reason: 'Permission query error' }]; + it('should handle entity query errors', async () => { + const mockError = [{ reason: 'Entity query error' }]; - queryMocks.useGetUserEntitiesByPermissionQuery.mockReturnValue({ - data: undefined, + queryMocks.useGetAllUserEntitiesByPermissionQuery.mockReturnValue({ + data: [{ id: 1, label: 'linode-1', type: 'linode' }], isLoading: false, + error: null, + }); + + queryMocks.useAllLinodesQuery.mockReturnValue({ + ...mockLinodeQueryResult, error: mockError, }); @@ -656,11 +696,9 @@ describe('useGetUserEntitiesByPermission', () => { const { result } = renderHook( () => - useGetUserEntitiesByPermission({ - query: mockQuery, + useGetAllUserEntitiesByPermission({ entityType: 'linode', permission: 'view_linode', - username: 'test-user', }), { wrapper: (ui) => wrapWithTheme(ui, { flags }), @@ -670,4 +708,44 @@ describe('useGetUserEntitiesByPermission', () => { expect(result.current.error).toEqual(mockError); }); }); + + describe('Custom Filter and Params', () => { + it('should pass custom params to entity query', async () => { + const customParams = { page: 1, page_size: 50 }; + + queryMocks.useGetAllUserEntitiesByPermissionQuery.mockReturnValue({ + data: [], + isLoading: false, + error: null, + }); + + queryMocks.useIsIAMEnabled.mockReturnValue({ + isIAMEnabled: true, + isIAMBeta: false, + profile: { restricted: false, username: 'test-user' } as Profile, + }); + + const flags = { iam: { beta: false, enabled: true } }; + + renderHook( + () => + useGetAllUserEntitiesByPermission({ + entityType: 'linode', + permission: 'view_linode', + params: customParams, + }), + { + wrapper: (ui) => wrapWithTheme(ui, { flags }), + } + ); + + await waitFor(() => { + expect(queryMocks.useAllLinodesQuery).toHaveBeenCalledWith( + customParams, + {}, + true + ); + }); + }); + }); }); diff --git a/packages/manager/src/features/IAM/hooks/useGetAllUserEntitiesByPermission.ts b/packages/manager/src/features/IAM/hooks/useGetAllUserEntitiesByPermission.ts new file mode 100644 index 00000000000..b1b6eab9076 --- /dev/null +++ b/packages/manager/src/features/IAM/hooks/useGetAllUserEntitiesByPermission.ts @@ -0,0 +1,202 @@ +import { + useAllFirewallsQuery, + useAllImagesQuery, + useAllLinodesQuery, + useAllNodeBalancersQuery, + useAllVolumesQuery, + useAllVPCsQuery, + useGetAllUserEntitiesByPermissionQuery, + useGrants, +} from '@linode/queries'; + +import { entityPermissionMapFrom } from './adapters/permissionAdapters'; +import { useIsIAMEnabled } from './useIsIAMEnabled'; +import { + BETA_ACCESS_TYPE_SCOPE, + LA_ACCOUNT_ADMIN_PERMISSIONS_TO_EXCLUDE, +} from './usePermissions'; + +import type { + APIError, + EntityType, + Filter, + Firewall, + GrantType, + Image, + Linode, + NodeBalancer, + Params, + PermissionType, + Volume, + VPC, +} from '@linode/api-v4'; +import type { UseQueryResult } from '@linode/queries'; + +type FullEntityType = Firewall | Image | Linode | NodeBalancer | Volume | VPC; + +interface UseGetEntitiesByPermissionProps { + enabled?: boolean; + entityType: EntityType; + filter?: Filter; + params?: Params; + permission: PermissionType; +} + +type EntityQueryResult = UseQueryResult< + T[] | undefined, + APIError[] +> & { filter: Filter }; + +/** + * Helper to call the right query hook based on entity type + * Doing this here allows the consumer to not have to worry about the different query hooks for each entity type + */ +const useEntityQuery = ( + entityType: EntityType, + filter: Filter, + params: Params, + enabled: boolean +): EntityQueryResult => { + const linodeQuery = useAllLinodesQuery( + params, + filter, + entityType === 'linode' && enabled + ); + const firewallQuery = useAllFirewallsQuery( + entityType === 'firewall' && enabled, + params, + filter + ); + const volumeQuery = useAllVolumesQuery( + params, + filter, + entityType === 'volume' && enabled + ); + const nodeBalancerQuery = useAllNodeBalancersQuery( + entityType === 'nodebalancer' && enabled, + params, + filter + ); + const imageQuery = useAllImagesQuery( + params, + filter, + entityType === 'image' && enabled + ); + const vpcQuery = useAllVPCsQuery({ + enabled: entityType === 'vpc' && enabled, + filter, + }); + + /** + * Return the appropriate query result based on entity type + */ + switch (entityType) { + case 'firewall': + return firewallQuery as EntityQueryResult; + case 'image': + return imageQuery as EntityQueryResult; + case 'linode': + return linodeQuery as EntityQueryResult; + case 'nodebalancer': + return nodeBalancerQuery as EntityQueryResult; + case 'volume': + return volumeQuery as EntityQueryResult; + case 'vpc': + return vpcQuery as EntityQueryResult; + default: + throw new Error(`Unsupported entity type: ${entityType}`); + } +}; + +export const useGetAllUserEntitiesByPermission = ({ + entityType, + permission, + enabled = true, + filter = {}, + params = {}, +}: UseGetEntitiesByPermissionProps) => { + const { isIAMBeta, isIAMEnabled, profile } = useIsIAMEnabled(); + + // Get entities by permission (Restricted IAM users only) + const { + data: entitiesByPermission, + isLoading: isEntitiesByPermissionLoading, + error: isEntitiesByPermissionError, + } = useGetAllUserEntitiesByPermissionQuery({ + username: profile?.username, + entityType, + permission, + enabled: enabled && isIAMEnabled, + }); + + /** + * Determine if we should use IAM permissions or legacy permissions + */ + const useBetaPermissions = + isIAMEnabled && + isIAMBeta && + BETA_ACCESS_TYPE_SCOPE.includes(entityType) && + LA_ACCOUNT_ADMIN_PERMISSIONS_TO_EXCLUDE.some((blacklistedPermission) => + permission.includes(blacklistedPermission) + ) === false; + const useLAPermissions = isIAMEnabled && !isIAMBeta; + const shouldUseIAMPermissions = useBetaPermissions || useLAPermissions; + + /** + * Extract entity IDs from the entities by permission data + */ + const entityIds = + shouldUseIAMPermissions && profile?.restricted + ? entitiesByPermission?.map((e) => e.id) + : undefined; + + /** + * Call entity query + */ + const entityQuery = useEntityQuery(entityType, filter, params, enabled); + + /** + * Legacy grants for non-IAM users + */ + const { data: grants, isLoading: grantsLoading } = useGrants( + !shouldUseIAMPermissions && profile?.restricted && enabled + ); + + /** + * Return IAM path + * + * In case a filter was used, we also return it to be used for client-side filtering + * ex: we also pass this filter to the LinodeSelect to avoid caching two different queries (with and without filter) + */ + if (shouldUseIAMPermissions) { + return { + ...entityQuery, + filter, + data: profile?.restricted + ? entityQuery.data?.filter((entity) => entityIds?.includes(entity.id)) + : entityQuery.data, + isLoading: isEntitiesByPermissionLoading || entityQuery.isLoading, + error: isEntitiesByPermissionError || entityQuery.error, + }; + } + + /** + * Legacy path with client-side filtering/mapping + */ + const entityPermissionsMap = profile?.restricted + ? entityPermissionMapFrom(grants, entityType as GrantType, profile) + : {}; + + const filteredEntities = profile?.restricted + ? entityQuery.data?.filter( + (entity: T) => entityPermissionsMap[entity.id]?.[permission] + ) + : entityQuery.data; + + return { + ...entityQuery, + data: filteredEntities ?? [], + isLoading: entityQuery.isLoading || grantsLoading, + error: entityQuery.error, + }; +}; diff --git a/packages/manager/src/features/IAM/hooks/useGetUserEntitiesByPermission.ts b/packages/manager/src/features/IAM/hooks/useGetUserEntitiesByPermission.ts deleted file mode 100644 index 2dbfe6386e0..00000000000 --- a/packages/manager/src/features/IAM/hooks/useGetUserEntitiesByPermission.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { - useGetUserEntitiesByPermissionQuery, - useGrants, -} from '@linode/queries'; - -import { entityPermissionMapFrom } from './adapters/permissionAdapters'; -import { useIsIAMEnabled } from './useIsIAMEnabled'; -import { - BETA_ACCESS_TYPE_SCOPE, - LA_ACCOUNT_ADMIN_PERMISSIONS_TO_EXCLUDE, -} from './usePermissions'; - -import type { EntityPermissionMap } from './adapters/permissionAdapters'; -import type { - APIError, - EntityType, - Firewall, - GrantType, - Image, - Linode, - NodeBalancer, - PermissionType, - Volume, - VPC, -} from '@linode/api-v4'; -import type { UseQueryResult } from '@linode/queries'; - -type FullEntityType = Firewall | Image | Linode | NodeBalancer | Volume | VPC; - -interface UseGetEntitiesByPermissionProps { - enabled?: boolean; - entityType: EntityType; - permission: PermissionType; - query: UseQueryResult; - username: string | undefined; -} - -export const useGetUserEntitiesByPermission = ({ - query, - entityType, - permission, - username, - enabled = true, -}: UseGetEntitiesByPermissionProps) => { - /** - * Get the full entities from the API - */ - const { - data: allEntities, - error: isAllEntitiesError, - isLoading: isAllEntitiesLoading, - ...restQueryResult - } = query; - const { isIAMBeta, isIAMEnabled, profile } = useIsIAMEnabled(); - - /** - * Get the entities by permission from the API - * This returns entities IDs which we need to map against the allEntities query - */ - const { - data: entitiesByPermission, - isLoading: isEntitiesByPermissionLoading, - error: isEntitiesByPermissionError, - } = useGetUserEntitiesByPermissionQuery({ - entityType, - permission, - username, - }); - - /** - * Beta/LA permission logic - * Will be cleaned up as soon as LA is fully adopted - */ - const useBetaPermissions = - isIAMEnabled && - isIAMBeta && - BETA_ACCESS_TYPE_SCOPE.includes(entityType) && - LA_ACCOUNT_ADMIN_PERMISSIONS_TO_EXCLUDE.some((blacklistedPermission) => - permission.includes(blacklistedPermission) - ) === false; - const useLAPermissions = isIAMEnabled && !isIAMBeta; - const shouldUseIAMPermissions = useBetaPermissions || useLAPermissions; - - /** - * Legacy Grants - only used for restricted users if IAM is disabled - */ - const { data: grants } = useGrants( - (!isIAMEnabled || !shouldUseIAMPermissions) && enabled - ); - let entityPermissionsMap: EntityPermissionMap = {}; - if (profile?.restricted) { - entityPermissionsMap = entityPermissionMapFrom( - grants, - entityType as GrantType, - profile - ); - } - - /** - * Map the entities by permission to the full entities - */ - const fullEntitiesFromEntitiesByPermission = entitiesByPermission - ?.map((entity) => allEntities?.find((e) => e.id === entity.id)) - .filter((e): e is T => e !== undefined); - - /** - * Build the entities list based on the user types (restricted or unrestricted) and the beta/LA permission logic - */ - const _availableEntities = shouldUseIAMPermissions - ? profile?.restricted - ? fullEntitiesFromEntitiesByPermission - : allEntities - : profile?.restricted - ? allEntities?.filter( - (entity: T) => - entityPermissionsMap[entity.id] && - entityPermissionsMap[entity.id][permission] - ) - : allEntities; - - const isLoading = isAllEntitiesLoading || isEntitiesByPermissionLoading; - const error = isAllEntitiesError || isEntitiesByPermissionError; - - return { - data: _availableEntities, - isLoading, - error, - ...restQueryResult, - }; -}; diff --git a/packages/manager/src/features/IAM/hooks/useIsIAMEnabled.ts b/packages/manager/src/features/IAM/hooks/useIsIAMEnabled.ts index 30024a756b7..8d5fc172f1e 100644 --- a/packages/manager/src/features/IAM/hooks/useIsIAMEnabled.ts +++ b/packages/manager/src/features/IAM/hooks/useIsIAMEnabled.ts @@ -50,12 +50,12 @@ export const checkIAMEnabled = async ( flags: FlagSet, profile: Profile | undefined ): Promise => { - if (!flags?.iam?.enabled) { + if (!flags?.iam?.enabled || !profile) { return false; } try { - if (profile?.username) { + if (profile.username) { // For restricted users ONLY, get permissions const permissions = await queryClient.ensureQueryData( queryOptions(iamQueries.user(profile.username)._ctx.accountPermissions) diff --git a/packages/manager/src/features/IAM/hooks/usePermissions.ts b/packages/manager/src/features/IAM/hooks/usePermissions.ts index 9054b1f8b3d..b184155af59 100644 --- a/packages/manager/src/features/IAM/hooks/usePermissions.ts +++ b/packages/manager/src/features/IAM/hooks/usePermissions.ts @@ -1,30 +1,20 @@ -import { getUserEntityPermissions } from '@linode/api-v4'; import { useGrants, useProfile, - useQueries, useUserAccountPermissions, useUserEntityPermissions, } from '@linode/queries'; -import { - entityPermissionMapFrom, - fromGrants, - toEntityPermissionMap, - toPermissionMap, -} from './adapters/permissionAdapters'; +import { fromGrants, toPermissionMap } from './adapters/permissionAdapters'; import { useIsIAMEnabled } from './useIsIAMEnabled'; import type { AccessType, AccountAdmin, - AccountEntity, APIError, - EntityType, FirewallAdmin, FirewallContributor, FirewallViewer, - GrantType, ImageAdmin, ImageContributor, ImageViewer, @@ -35,7 +25,6 @@ import type { NodeBalancerContributor, NodeBalancerViewer, PermissionType, - Profile, VolumeAdmin, VolumeContributor, VolumeViewer, @@ -242,126 +231,3 @@ export function usePermissions< ...restEntityPermissions, } as const; } - -export type EntityBase = Pick; - -/** - * Helper function to get the permissions for a list of entities. - * Used only for restricted users who need to check permissions for each entity. - * - * ⚠️ This is a performance bottleneck for restricted users who have many entities. - * It will need to be deprecated and refactored when we add the ability to filter entities by permission(s). - */ -export const useEntitiesPermissions = ( - entities: T[] | undefined, - entityType: EntityType, - profile?: Profile, - enabled = true -) => { - const queries = useQueries({ - queries: (entities || []).map((entity: T) => ({ - queryKey: [entityType, entity.id], - queryFn: () => - getUserEntityPermissions( - profile?.username || '', - entityType, - entity.id - ), - enabled: enabled && Boolean(profile?.restricted), - })), - }); - - const data = queries.map((query) => query.data); - const error = queries.map((query) => query.error); - const isError = queries.some((query) => query.isError); - const isLoading = queries.some((query) => query.isLoading); - - return { data, error, isError, isLoading }; -}; - -export type QueryWithPermissionsResult = { - data: T[]; - error: APIError[] | null; - hasFiltered: boolean; - isError: boolean; - isLoading: boolean; -} & Omit< - UseQueryResult, - 'data' | 'error' | 'isError' | 'isLoading' ->; - -export const useQueryWithPermissions = ( - useQueryResult: UseQueryResult, - entityType: EntityType, - permissionsToCheck: PermissionType[], - enabled?: boolean -): QueryWithPermissionsResult => { - const { - data: allEntities, - error: allEntitiesError, - isLoading: areEntitiesLoading, - isError: isEntitiesError, - ...restQueryResult - } = useQueryResult; - const { data: profile } = useProfile(); - const { isIAMBeta, isIAMEnabled } = useIsIAMEnabled(); - - const accessType = entityType; - - /** - * Apply the same Beta/LA permission logic as usePermissions. - * - Use Beta Permissions if: - * - The feature is beta - * - The access type is in the BETA_ACCESS_TYPE_SCOPE - * - The account permission is not in the LA_ACCOUNT_ADMIN_PERMISSIONS_TO_EXCLUDE - * - Use LA Permissions if: - * - The feature is not beta - */ - const useBetaPermissions = - isIAMEnabled && - isIAMBeta && - BETA_ACCESS_TYPE_SCOPE.includes(accessType) && - LA_ACCOUNT_ADMIN_PERMISSIONS_TO_EXCLUDE.some((blacklistedPermission) => - permissionsToCheck.includes(blacklistedPermission as AccountAdmin) - ) === false; - const useLAPermissions = isIAMEnabled && !isIAMBeta; - const shouldUsePermissionMap = useBetaPermissions || useLAPermissions; - - const { data: entityPermissions, isLoading: areEntityPermissionsLoading } = - useEntitiesPermissions( - allEntities, - entityType, - profile, - shouldUsePermissionMap && enabled - ); - const { data: grants } = useGrants( - (!isIAMEnabled || !shouldUsePermissionMap) && enabled - ); - - const entityPermissionsMap = shouldUsePermissionMap - ? toEntityPermissionMap( - allEntities, - entityPermissions, - permissionsToCheck, - profile?.restricted - ) - : entityPermissionMapFrom(grants, entityType as GrantType, profile); - - const entities: T[] | undefined = allEntities?.filter((entity: T) => { - const permissions = entityPermissionsMap[entity.id] ?? {}; - return ( - !profile?.restricted || - (permissions && - permissionsToCheck.every((permission) => permissions[permission])) - ); - }); - - return { - data: entities || [], - error: allEntitiesError, - hasFiltered: allEntities?.length !== entities?.length, - isError: isEntitiesError, - isLoading: areEntitiesLoading || areEntityPermissionsLoading, - ...restQueryResult, - } as const; -}; diff --git a/packages/manager/src/features/IAM/hooks/useQueryWithPermissions.test.ts b/packages/manager/src/features/IAM/hooks/useQueryWithPermissions.test.ts deleted file mode 100644 index c5ad8301161..00000000000 --- a/packages/manager/src/features/IAM/hooks/useQueryWithPermissions.test.ts +++ /dev/null @@ -1,303 +0,0 @@ -import { renderHook } from '@testing-library/react'; - -import { wrapWithTheme } from 'src/utilities/testHelpers'; - -import { useQueryWithPermissions } from './usePermissions'; - -import type { EntityBase } from './usePermissions'; -import type { APIError, PermissionType } from '@linode/api-v4'; -import type { UseQueryResult } from '@tanstack/react-query'; - -type Entity = { id: number; label: string }; - -const queryMocks = vi.hoisted(() => { - let entitiesPermsLoading = false; - - return { - useIsIAMEnabled: vi - .fn() - .mockReturnValue({ isIAMEnabled: true, isIAMBeta: true }), - useGrants: vi.fn().mockReturnValue({ data: null }), - useProfile: vi - .fn() - .mockReturnValue({ data: { username: 'user-1', restricted: true } }), - useQueries: Object.assign( - vi.fn().mockImplementation(({ queries }) => - (queries || []).map(() => ({ - data: null, - error: null, - isError: false, - isLoading: entitiesPermsLoading, - })) - ), - { - setEntitiesPermsLoading: (b: boolean) => { - entitiesPermsLoading = b; - }, - } - ), - }; -}); - -vi.mock(import('@linode/queries'), async (importOriginal) => { - const actual = await importOriginal(); - - return { - ...actual, - useGrants: queryMocks.useGrants, - useProfile: queryMocks.useProfile, - useQueries: queryMocks.useQueries, - }; -}); - -vi.mock('src/features/IAM/hooks/useIsIAMEnabled', async () => { - const actual = await vi.importActual( - 'src/features/IAM/hooks/useIsIAMEnabled' - ); - - return { - ...actual, - useIsIAMEnabled: queryMocks.useIsIAMEnabled, - }; -}); - -vi.mock('./adapters/permissionAdapters', () => ({ - toEntityPermissionMap: vi.fn( - (entities: EntityBase[] = [], entitiesPermissions: PermissionType[]) => { - const map: Record> = {}; - entities.forEach((e) => { - map[e.id] = entitiesPermissions?.reduce>( - (acc, p) => { - acc[p] = e.id === 1; - return acc; - }, - {} - ); - }); - - return map; - } - ), - entityPermissionMapFrom: vi.fn(() => { - return { - 1: { update_linode: true, resize_volume: true, create_volume: true }, - 2: { update_linode: true, resize_volume: true, create_volume: true }, - }; - }), -})); - -describe('useQueryWithPermissions', () => { - const entities: Entity[] = [ - { id: 1, label: 'one' }, - { id: 2, label: 'two' }, - ]; - - const baseQueryResult = { - data: entities, - error: null, - isError: false, - isLoading: false, - } as UseQueryResult; - - beforeEach(() => { - vi.clearAllMocks(); - queryMocks.useQueries.setEntitiesPermsLoading(false); - }); - - it('uses Beta permissions when IAM enabled + beta true + in scope; filters restricted entities', () => { - const flags = { iam: { beta: true, enabled: true } }; - queryMocks.useIsIAMEnabled.mockReturnValue({ - isIAMEnabled: true, - isIAMBeta: true, - }); - queryMocks.useProfile.mockReturnValue({ - data: { username: 'user-1', restricted: true }, - }); - - const { result } = renderHook( - () => - useQueryWithPermissions( - baseQueryResult, - 'linode', - ['update_linode'], - true - ), - { wrapper: (ui) => wrapWithTheme(ui, { flags }) } - ); - - expect(queryMocks.useGrants).toHaveBeenCalledWith(false); - - const calls = queryMocks.useQueries.mock.calls; - expect(calls.length).toBeGreaterThan(0); - const queryArgs = calls[0][0]; - expect( - queryArgs.queries.every((q: { enabled?: boolean }) => q.enabled === true) - ).toBe(true); - - expect(result.current.data.map((e) => e.id)).toEqual([]); - expect(result.current.hasFiltered).toBe(true); - expect(result.current.isLoading).toBe(false); - }); - - it('falls back to grants when IAM disabled', () => { - const flags = { iam: { beta: false, enabled: false } }; - queryMocks.useIsIAMEnabled.mockReturnValue({ - isIAMEnabled: false, - isIAMBeta: false, - }); - - const { result } = renderHook( - () => - useQueryWithPermissions( - baseQueryResult, - 'linode', - ['update_linode'], - true - ), - { wrapper: (ui) => wrapWithTheme(ui, { flags }) } - ); - - expect(queryMocks.useGrants).toHaveBeenCalledWith(true); - - const queryArgs = queryMocks.useQueries.mock.calls[0][0]; - expect( - queryArgs.queries.every((q: { enabled?: boolean }) => q.enabled === false) - ).toBe(true); - - expect(result.current.data.map((e) => e.id)).toEqual([1, 2]); - expect(result.current.hasFiltered).toBe(false); - }); - - it('falls back to grants when Beta true but entityType not in scope', () => { - const flags = { iam: { beta: true, enabled: true } }; - queryMocks.useIsIAMEnabled.mockReturnValue({ - isIAMEnabled: true, - isIAMBeta: true, - }); - - renderHook( - () => - useQueryWithPermissions( - baseQueryResult, - 'volume', - ['resize_volume'], - true - ), - { wrapper: (ui) => wrapWithTheme(ui, { flags }) } - ); - - expect(queryMocks.useGrants).toHaveBeenCalledWith(true); - const qArg = queryMocks.useQueries.mock.calls[0][0]; - expect( - qArg.queries.every((q: { enabled?: boolean }) => q.enabled === false) - ).toBe(true); - }); - - it('falls back to grants when Beta true but permission is in the LA exclusion list', () => { - const flags = { iam: { beta: true, enabled: true } }; - queryMocks.useIsIAMEnabled.mockReturnValue({ - isIAMEnabled: true, - isIAMBeta: true, - }); - - renderHook( - () => - useQueryWithPermissions( - baseQueryResult, - 'volume', - ['create_volume'], // blacklisted - true - ), - { wrapper: (ui) => wrapWithTheme(ui, { flags }) } - ); - - expect(queryMocks.useGrants).toHaveBeenCalledWith(true); - const queryArgs = queryMocks.useQueries.mock.calls[0][0]; - expect( - queryArgs.queries.every((q: { enabled?: boolean }) => q.enabled === false) - ).toBe(true); - }); - - it('uses LA permissions when IAM enabled + beta false', () => { - const flags = { iam: { beta: false, enabled: true } }; - queryMocks.useIsIAMEnabled.mockReturnValue({ - isIAMEnabled: true, - isIAMBeta: false, - }); - - renderHook( - () => - useQueryWithPermissions( - baseQueryResult, - 'linode', - ['update_linode'], - true - ), - { wrapper: (ui) => wrapWithTheme(ui, { flags }) } - ); - - expect(queryMocks.useGrants).toHaveBeenCalledWith(false); - const queryArgs = queryMocks.useQueries.mock.calls[0][0]; - expect( - queryArgs.queries.every((q: { enabled?: boolean }) => q.enabled === true) - ).toBe(true); - }); - - it('marks loading when entity permissions queries are loading', () => { - const flags = { iam: { beta: true, enabled: true } }; - queryMocks.useIsIAMEnabled.mockReturnValue({ - isIAMEnabled: true, - isIAMBeta: true, - }); - queryMocks.useQueries.setEntitiesPermsLoading(true); - - const { result } = renderHook( - () => - useQueryWithPermissions( - baseQueryResult, - 'linode', - ['update_linode'], - true - ), - { wrapper: (ui) => wrapWithTheme(ui, { flags }) } - ); - - expect(result.current.isLoading).toBe(true); - }); - - it('grants all permissions to unrestricted (admin) users without making permission API calls', () => { - const flags = { iam: { beta: true, enabled: true } }; - queryMocks.useIsIAMEnabled.mockReturnValue({ - isIAMEnabled: true, - isIAMBeta: true, - }); - queryMocks.useProfile.mockReturnValue({ - data: { username: 'admin-user', restricted: false }, - }); - - const { result } = renderHook( - () => - useQueryWithPermissions( - baseQueryResult, - 'linode', - ['update_linode', 'delete_linode', 'reboot_linode'], - true - ), - { wrapper: (ui) => wrapWithTheme(ui, { flags }) } - ); - - // Verify grants are NOT fetched (IAM is enabled) - expect(queryMocks.useGrants).toHaveBeenCalledWith(false); - - // Verify permission queries are disabled (no N API calls for unrestricted users!) - const queryArgs = queryMocks.useQueries.mock.calls[0][0]; - expect( - queryArgs.queries.every((q: { enabled?: boolean }) => q.enabled === false) - ).toBe(true); - - // Unrestricted users should see ALL entities - expect(result.current.data.map((e) => e.id)).toEqual([1, 2]); - expect(result.current.hasFiltered).toBe(false); - expect(result.current.isLoading).toBe(false); - }); -}); diff --git a/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.test.tsx b/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.test.tsx index 16823d67373..4c8e2045f80 100644 --- a/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.test.tsx +++ b/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.test.tsx @@ -4,19 +4,33 @@ import { userEvent } from '@testing-library/user-event'; import React from 'react'; import { imageFactory, linodeDiskFactory } from 'src/factories'; -import { makeResourcePage } from 'src/mocks/serverHandlers'; -import { http, HttpResponse, server } from 'src/mocks/testServer'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { CreateImageTab } from './CreateImageTab'; const queryMocks = vi.hoisted(() => ({ + useLinodeQuery: vi.fn().mockReturnValue({}), + useAllLinodesQuery: vi.fn().mockReturnValue({}), + useAllLinodeDisksQuery: vi.fn().mockReturnValue({}), + useRegionsQuery: vi.fn().mockReturnValue({}), + useCreateImageMutation: vi.fn().mockReturnValue({}), useSearch: vi.fn().mockReturnValue({ query: undefined }), usePermissions: vi.fn().mockReturnValue({}), - useQueryWithPermissions: vi.fn().mockReturnValue({}), - useLinodesPermissionsCheck: vi.fn().mockReturnValue({}), + useGetAllUserEntitiesByPermission: vi.fn().mockReturnValue({}), })); +vi.mock('@linode/queries', async () => { + const actual = await vi.importActual('@linode/queries'); + return { + ...actual, + useLinodeQuery: queryMocks.useLinodeQuery, + useAllLinodesQuery: queryMocks.useAllLinodesQuery, + useAllLinodeDisksQuery: queryMocks.useAllLinodeDisksQuery, + useRegionsQuery: queryMocks.useRegionsQuery, + useCreateImageMutation: queryMocks.useCreateImageMutation, + }; +}); + vi.mock('@tanstack/react-router', async () => { const actual = await vi.importActual('@tanstack/react-router'); return { @@ -27,16 +41,24 @@ vi.mock('@tanstack/react-router', async () => { vi.mock('src/features/IAM/hooks/usePermissions', () => ({ usePermissions: queryMocks.usePermissions, - useQueryWithPermissions: queryMocks.useQueryWithPermissions, +})); + +vi.mock('src/features/IAM/hooks/useGetAllUserEntitiesByPermission', () => ({ + useGetAllUserEntitiesByPermission: + queryMocks.useGetAllUserEntitiesByPermission, })); describe('CreateImageTab', () => { beforeEach(() => { queryMocks.usePermissions.mockReturnValue({ data: { create_image: true }, + isLoading: false, + error: null, }); - queryMocks.useLinodesPermissionsCheck.mockReturnValue({ - availableLinodes: [linodeFactory.build().id], + queryMocks.useGetAllUserEntitiesByPermission.mockReturnValue({ + data: [], + isLoading: false, + error: null, }); }); @@ -70,15 +92,26 @@ describe('CreateImageTab', () => { const linode = linodeFactory.build(); const disk = linodeDiskFactory.build(); - server.use( - http.get('*/v4*/linode/instances', () => { - return HttpResponse.json(makeResourcePage([linode])); - }), - http.get('*/v4*/linode/instances/:id/disks', () => { - return HttpResponse.json(makeResourcePage([disk])); - }) - ); - + queryMocks.useGetAllUserEntitiesByPermission.mockReturnValue({ + data: [linode], + isLoading: false, + error: null, + }); + queryMocks.useAllLinodesQuery.mockReturnValue({ + data: [linode], + isLoading: false, + error: null, + }); + queryMocks.useLinodeQuery.mockReturnValue({ + data: linode, + isLoading: false, + error: null, + }); + queryMocks.useAllLinodeDisksQuery.mockReturnValue({ + data: [disk], + isFetching: false, + error: null, + }); queryMocks.useSearch.mockReturnValue({ selectedDisk: disk.id, selectedLinode: linode.id, @@ -111,24 +144,31 @@ describe('CreateImageTab', () => { const disk = linodeDiskFactory.build(); const image = imageFactory.build(); - queryMocks.useLinodesPermissionsCheck.mockReturnValue({ - availableLinodes: [linode.id], + queryMocks.useGetAllUserEntitiesByPermission.mockReturnValue({ + data: [linode], + isLoading: false, + error: null, }); - queryMocks.useQueryWithPermissions.mockReturnValue({ + queryMocks.useAllLinodesQuery.mockReturnValue({ data: [linode], + isLoading: false, + error: null, + }); + queryMocks.useLinodeQuery.mockReturnValue({ + data: linode, + isLoading: false, + error: null, + }); + queryMocks.useAllLinodeDisksQuery.mockReturnValue({ + data: [disk], + isFetching: false, + error: null, + }); + queryMocks.useCreateImageMutation.mockReturnValue({ + mutateAsync: vi.fn().mockResolvedValue(image), + isLoading: false, + error: null, }); - - server.use( - http.get('*/v4*/linode/instances', () => { - return HttpResponse.json(makeResourcePage([linode])); - }), - http.get('*/v4*/linode/instances/:id/disks', () => { - return HttpResponse.json(makeResourcePage([disk])); - }), - http.post('*/v4/images', () => { - return HttpResponse.json(image); - }) - ); const { findByText, getByLabelText, getByText, queryByText } = renderWithTheme(); @@ -165,24 +205,31 @@ describe('CreateImageTab', () => { const region = regionFactory.build({ capabilities: [] }); const linode = linodeFactory.build({ region: region.id }); - queryMocks.useLinodesPermissionsCheck.mockReturnValue({ - availableLinodes: [linode.id], + queryMocks.useGetAllUserEntitiesByPermission.mockReturnValue({ + data: [linode], + isLoading: false, + error: null, }); - queryMocks.useQueryWithPermissions.mockReturnValue({ + queryMocks.useAllLinodesQuery.mockReturnValue({ data: [linode], + isLoading: false, + error: null, + }); + queryMocks.useLinodeQuery.mockReturnValue({ + data: linode, + isLoading: false, + error: null, + }); + queryMocks.useAllLinodeDisksQuery.mockReturnValue({ + data: [linode], + isFetching: false, + error: null, + }); + queryMocks.useRegionsQuery.mockReturnValue({ + data: [region], + isLoading: false, + error: null, }); - - server.use( - http.get('*/v4*/linode/instances', () => { - return HttpResponse.json(makeResourcePage([linode])); - }), - http.get('*/v4*/linode/instances/:id', () => { - return HttpResponse.json(linode); - }), - http.get('*/v4*/regions', () => { - return HttpResponse.json(makeResourcePage([region])); - }) - ); const { findByText, getByLabelText } = renderWithTheme(); @@ -206,27 +253,31 @@ describe('CreateImageTab', () => { const disk2 = linodeDiskFactory.build(); const image = imageFactory.build(); - queryMocks.useLinodesPermissionsCheck.mockReturnValue({ - availableLinodes: [linode.id], + queryMocks.useGetAllUserEntitiesByPermission.mockReturnValue({ + data: [linode], + isLoading: false, + error: null, }); - queryMocks.useQueryWithPermissions.mockReturnValue({ + queryMocks.useAllLinodesQuery.mockReturnValue({ data: [linode], + isLoading: false, + error: null, + }); + queryMocks.useLinodeQuery.mockReturnValue({ + data: linode, + isLoading: false, + error: null, + }); + queryMocks.useAllLinodeDisksQuery.mockReturnValue({ + data: [disk1, disk2], + isFetching: false, + error: null, + }); + queryMocks.useCreateImageMutation.mockReturnValue({ + mutateAsync: vi.fn().mockResolvedValue(image), + isLoading: false, + error: null, }); - - server.use( - http.get('*/v4*/linode/instances', () => { - return HttpResponse.json(makeResourcePage([linode])); - }), - http.get('*/v4*/linode/instances/:id', () => { - return HttpResponse.json(linode); - }), - http.get('*/v4*/linode/instances/:id/disks', () => { - return HttpResponse.json(makeResourcePage([disk1, disk2])); - }), - http.post('*/v4/images', () => { - return HttpResponse.json(image); - }) - ); const { findByText, getByLabelText, queryByText } = renderWithTheme( diff --git a/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx b/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx index 9a48643ff90..222dc1da043 100644 --- a/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx +++ b/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx @@ -1,7 +1,6 @@ import { yupResolver } from '@hookform/resolvers/yup'; import { useAllLinodeDisksQuery, - useAllLinodesQuery, useCreateImageMutation, useLinodeQuery, useRegionsQuery, @@ -28,10 +27,8 @@ import { Controller, useForm } from 'react-hook-form'; import { Link } from 'src/components/Link'; import { TagsInput } from 'src/components/TagsInput/TagsInput'; import { getRestrictedResourceText } from 'src/features/Account/utils'; -import { - usePermissions, - useQueryWithPermissions, -} from 'src/features/IAM/hooks/usePermissions'; +import { useGetAllUserEntitiesByPermission } from 'src/features/IAM/hooks/useGetAllUserEntitiesByPermission'; +import { usePermissions } from 'src/features/IAM/hooks/usePermissions'; import { useFlags } from 'src/hooks/useFlags'; import { useEventsPollingActions } from 'src/queries/events/events'; @@ -108,13 +105,16 @@ export const CreateImageTab = () => { selectedLinodeId !== null ); - const { data: linodes, isLoading } = useQueryWithPermissions( - useAllLinodesQuery({}, {}, Boolean(imagePermissions.create_image)), - 'linode', - ['view_linode', 'update_linode'], - Boolean(imagePermissions.create_image) - ); - const availableLinodes = linodes?.map((linode) => linode.id); + const { + data: linodes, + isLoading, + filter: linodeFilter, + } = useGetAllUserEntitiesByPermission({ + entityType: 'linode', + permission: 'update_linode', + enabled: Boolean(imagePermissions.create_image), + }); + const availableLinodes = linodes?.map((linode) => linode.id) ?? []; const { data: disks, @@ -160,7 +160,7 @@ export const CreateImageTab = () => { return (
    - {!canCreateImage && ( + {!canCreateImage && !isLoading && ( { !availableLinodes.includes(linode.id) } diff --git a/packages/manager/src/features/Images/ImagesLanding/EditImageDrawer.test.tsx b/packages/manager/src/features/Images/ImagesLanding/EditImageDrawer.test.tsx index 8b2f34fbdf0..e8080871237 100644 --- a/packages/manager/src/features/Images/ImagesLanding/EditImageDrawer.test.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/EditImageDrawer.test.tsx @@ -17,12 +17,10 @@ const props = { const queryMocks = vi.hoisted(() => ({ usePermissions: vi.fn().mockReturnValue({}), - useQueryWithPermissions: vi.fn().mockReturnValue({}), })); vi.mock('src/features/IAM/hooks/usePermissions', () => ({ usePermissions: queryMocks.usePermissions, - useQueryWithPermissions: queryMocks.useQueryWithPermissions, })); const mockUpdateImage = vi.fn(); @@ -40,9 +38,6 @@ beforeEach(() => { queryMocks.usePermissions.mockReturnValue({ data: { update_image: true, is_account_admin: true }, }); - queryMocks.useQueryWithPermissions.mockReturnValue({ - data: [props.image], - }); }); describe('EditImageDrawer', () => { diff --git a/packages/manager/src/features/Images/ImagesLanding/ImageRow.test.tsx b/packages/manager/src/features/Images/ImagesLanding/ImageRow.test.tsx index d54f0b77714..bc92f3bf696 100644 --- a/packages/manager/src/features/Images/ImagesLanding/ImageRow.test.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/ImageRow.test.tsx @@ -16,12 +16,10 @@ beforeAll(() => mockMatchMedia()); const queryMocks = vi.hoisted(() => ({ usePermissions: vi.fn().mockReturnValue({}), - useQueryWithPermissions: vi.fn().mockReturnValue({}), })); vi.mock('src/features/IAM/hooks/usePermissions', () => ({ usePermissions: queryMocks.usePermissions, - useQueryWithPermissions: queryMocks.useQueryWithPermissions, })); describe('Image Table Row', () => { @@ -166,9 +164,6 @@ describe('Image Table Row', () => { const image = imageFactory.build({ regions: [{ region: 'us-east', status: 'available' }], }); - queryMocks.useQueryWithPermissions.mockReturnValue({ - data: [image], - }); const { getByLabelText, getByText } = renderWithTheme( wrapWithTableBody() diff --git a/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.test.tsx b/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.test.tsx index 4701d9003c1..c39b1edca49 100644 --- a/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.test.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.test.tsx @@ -1,4 +1,3 @@ -import { linodeFactory } from '@linode/utilities'; import { waitForElementToBeRemoved } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import * as React from 'react'; @@ -12,23 +11,12 @@ import ImagesLanding from './ImagesLanding'; const queryMocks = vi.hoisted(() => ({ usePermissions: vi.fn().mockReturnValue({ data: { create_image: false } }), - useQueryWithPermissions: vi.fn().mockReturnValue({}), - useLinodesPermissionsCheck: vi.fn().mockReturnValue({}), })); vi.mock('src/features/IAM/hooks/usePermissions', () => ({ usePermissions: queryMocks.usePermissions, - useQueryWithPermissions: queryMocks.useQueryWithPermissions, })); -vi.mock('../utils.ts', async () => { - const actual = await vi.importActual('../utils'); - return { - ...actual, - useLinodesPermissionsCheck: queryMocks.useLinodesPermissionsCheck, - }; -}); - beforeAll(() => mockMatchMedia()); const loadingTestId = 'circle-progress'; @@ -202,9 +190,6 @@ describe('Images Landing Table', () => { it('should allow deploying to a new Linode', async () => { const image = imageFactory.build(); - queryMocks.useLinodesPermissionsCheck.mockReturnValue({ - availableLinodes: [linodeFactory.build()], - }); server.use( http.get('*/images', ({ request }) => { @@ -302,9 +287,6 @@ describe('Images Landing Table', () => { id: 'private/99999', label: 'vi-test-image', }); - queryMocks.useLinodesPermissionsCheck.mockReturnValue({ - availableLinodes: [], - }); server.use( http.get('*/images', ({ request }) => { diff --git a/packages/manager/src/features/Images/ImagesLanding/RebuildImageDrawer.test.tsx b/packages/manager/src/features/Images/ImagesLanding/RebuildImageDrawer.test.tsx index d7f96187e5c..df7ec5fb9b8 100644 --- a/packages/manager/src/features/Images/ImagesLanding/RebuildImageDrawer.test.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/RebuildImageDrawer.test.tsx @@ -3,8 +3,6 @@ import userEvent from '@testing-library/user-event'; import React from 'react'; import { imageFactory } from 'src/factories'; -import { makeResourcePage } from 'src/mocks/serverHandlers'; -import { http, HttpResponse, server } from 'src/mocks/testServer'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { RebuildImageDrawer } from './RebuildImageDrawer'; @@ -21,8 +19,9 @@ const props = { const mockNavigate = vi.fn(); const queryMocks = vi.hoisted(() => ({ + useAllLinodesQuery: vi.fn().mockReturnValue({}), useNavigate: vi.fn(() => mockNavigate), - useQueryWithPermissions: vi.fn().mockReturnValue({}), + useGetAllUserEntitiesByPermission: vi.fn().mockReturnValue({}), })); vi.mock('@tanstack/react-router', async () => { @@ -33,14 +32,23 @@ vi.mock('@tanstack/react-router', async () => { }; }); -vi.mock('src/features/IAM/hooks/usePermissions', () => ({ - useQueryWithPermissions: queryMocks.useQueryWithPermissions, +vi.mock('@linode/queries', async () => { + const actual = await vi.importActual('@linode/queries'); + return { + ...actual, + useAllLinodesQuery: queryMocks.useAllLinodesQuery, + }; +}); + +vi.mock('src/features/IAM/hooks/useGetAllUserEntitiesByPermission', () => ({ + useGetAllUserEntitiesByPermission: + queryMocks.useGetAllUserEntitiesByPermission, })); describe('RebuildImageDrawer', () => { beforeEach(() => { - vi.mocked(queryMocks.useQueryWithPermissions).mockReturnValue({ - loading: false, + queryMocks.useGetAllUserEntitiesByPermission.mockReturnValue({ + isLoading: false, }); }); @@ -55,16 +63,14 @@ describe('RebuildImageDrawer', () => { }); it('should allow selecting a Linode to rebuild', async () => { + queryMocks.useAllLinodesQuery.mockReturnValue({ + isLoading: false, + data: linodeFactory.buildList(5), + }); const { findByText, getByRole, getByText } = renderWithTheme( ); - server.use( - http.get('*/linode/instances', () => { - return HttpResponse.json(makeResourcePage(linodeFactory.buildList(5))); - }) - ); - await userEvent.click(getByRole('combobox')); await userEvent.click(await findByText('linode-1')); await userEvent.click(getByText('Rebuild Linode')); diff --git a/packages/manager/src/features/Images/ImagesLanding/RebuildImageDrawer.tsx b/packages/manager/src/features/Images/ImagesLanding/RebuildImageDrawer.tsx index f4366745cda..e84fb7fa223 100644 --- a/packages/manager/src/features/Images/ImagesLanding/RebuildImageDrawer.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/RebuildImageDrawer.tsx @@ -1,4 +1,3 @@ -import { useAllLinodesQuery } from '@linode/queries'; import { LinodeSelect } from '@linode/shared'; import { ActionsPanel, Divider, Drawer, Notice, Stack } from '@linode/ui'; import { useNavigate } from '@tanstack/react-router'; @@ -6,7 +5,7 @@ import * as React from 'react'; import { Controller, useForm } from 'react-hook-form'; import { DescriptionList } from 'src/components/DescriptionList/DescriptionList'; -import { useQueryWithPermissions } from 'src/features/IAM/hooks/usePermissions'; +import { useGetAllUserEntitiesByPermission } from 'src/features/IAM/hooks/useGetAllUserEntitiesByPermission'; import { REBUILD_LINODE_IMAGE_PARAM_NAME } from 'src/features/Linodes/LinodesDetail/LinodeRebuild/utils'; import type { APIError, Image, Linode } from '@linode/api-v4'; @@ -23,12 +22,16 @@ export const RebuildImageDrawer = (props: Props) => { const { image, imageError, isFetching, onClose, open } = props; const navigate = useNavigate(); - const { data: linodes, isLoading } = useQueryWithPermissions( - useAllLinodesQuery({}, {}, open), - 'linode', - ['rebuild_linode', 'view_linode'], - open - ); + const { + data: linodes, + filter: linodeFilter, + isLoading, + error: linodeError, + } = useGetAllUserEntitiesByPermission({ + entityType: 'linode', + permission: 'rebuild_linode', + enabled: open, + }); const availableLinodes = linodes?.map((linode) => linode.id); const { control, formState, handleSubmit, reset } = useForm<{ @@ -77,6 +80,10 @@ export const RebuildImageDrawer = (props: Props) => { /> )} + {linodeError && ( + + )} + @@ -90,6 +97,7 @@ export const RebuildImageDrawer = (props: Props) => { { field.onChange(linode?.id); diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ConfigureNodePool/ConfigureNodePoolForm.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ConfigureNodePool/ConfigureNodePoolForm.tsx index e450fdfef2e..bd19c9844aa 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ConfigureNodePool/ConfigureNodePoolForm.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ConfigureNodePool/ConfigureNodePoolForm.tsx @@ -46,7 +46,13 @@ export const ConfigureNodePoolForm = (props: Props) => { // @TODO allow users to edit Node Pool `label` and `tags` because the API supports it. (ECE-353) // label: nodePool.label, // tags: nodePool.tags, - firewall_id: nodePool.firewall_id, + + /** + * We set the default value of the form field to `undefined` if `nodePool.firewall_id` is null. + * This ensures the field remains controlled in React Hook Form and is properly initialized, + * preventing unexpected validation errors when the initial value is null. + */ + firewall_id: nodePool.firewall_id ?? undefined, update_strategy: nodePool.update_strategy, k8s_version: nodePool.k8s_version, }, @@ -87,7 +93,7 @@ export const ConfigureNodePoolForm = (props: Props) => { clusterTier={clusterTier ?? 'standard'} firewallSelectOptions={{ allowFirewallRemoval: clusterTier === 'standard', - ...(nodePool.firewall_id !== 0 && { + ...(nodePool.firewall_id !== null && { disableDefaultFirewallRadio: true, defaultFirewallRadioTooltip: "You can't use this option once an existing Firewall has been selected.", diff --git a/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlanContainer.tsx b/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlanContainer.tsx index c8e8c7501b3..94a2edd7b2f 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlanContainer.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlanContainer.tsx @@ -17,6 +17,57 @@ import type { KubernetesTier, LinodeTypeClass } from '@linode/api-v4'; import type { PlanSelectionDividers } from 'src/features/components/PlansPanel/PlanContainer'; import type { PlanWithAvailability } from 'src/features/components/PlansPanel/types'; +export interface PlanFilterRenderArgs { + /** + * Callback to notify parent of filter result + */ + onResult: (result: PlanFilterRenderResult) => void; + + /** + * All available plans (unfiltered) + */ + plans: PlanWithAvailability[]; + + /** + * Plan type/class (e.g., 'dedicated', 'gpu') + */ + planType: LinodeTypeClass | undefined; + + /** + * Reset pagination back to the first page + */ + resetPagination: () => void; + + /** + * Whether filters should be disabled (e.g., no region selected) + */ + shouldDisableFilters?: boolean; +} + +export interface PlanFilterRenderResult { + /** + * Optional empty state configuration for the table body + */ + emptyState?: null | { + message: string; + }; + + /** + * Filtered plans after applying filters + */ + filteredPlans: PlanWithAvailability[]; + + /** + * The filter UI component + */ + filterUI: React.ReactNode; + + /** + * Whether any filters are currently active + */ + hasActiveFilters: boolean; +} + export interface KubernetesPlanContainerProps { allDisabledPlans: PlanWithAvailability[]; getTypeCount: (planId: string) => number; @@ -24,6 +75,13 @@ export interface KubernetesPlanContainerProps { hasMajorityOfPlansDisabled: boolean; onAdd?: (key: string, value: number) => void; onSelect: (key: string) => void; + + /** + * Render prop for custom filter UI per tab + * Receives plan data and pagination helpers, returns a React element + */ + planFilters?: (args: PlanFilterRenderArgs) => React.ReactNode; + plans: PlanWithAvailability[]; planType?: LinodeTypeClass; selectedId?: string; @@ -42,6 +100,7 @@ export const KubernetesPlanContainer = ( onAdd, handleConfigurePool, onSelect, + planFilters, planType, plans, selectedId, @@ -51,10 +110,10 @@ export const KubernetesPlanContainer = ( wholePanelIsDisabled, } = props; const shouldDisplayNoRegionSelectedMessage = !selectedRegionId; - const { isGenerationalPlansEnabled } = useIsGenerationalPlansEnabled(); - - // Feature gate for pagination functionality - const isK8PlanPaginationEnabled = isGenerationalPlansEnabled; + const { isGenerationalPlansEnabled } = useIsGenerationalPlansEnabled( + plans, + planType + ); /** * This features allows us to divide the GPU plans into two separate tables. @@ -121,8 +180,74 @@ export const KubernetesPlanContainer = ( [planType] ); + // State to hold filter result from the filter component + const [filterResult, setFilterResult] = + React.useState(null); + + // Ref to store the pagination handler from Paginate component + // This allows us to reset pagination when filters change + const handlePageChangeRef = React.useRef<((page: number) => void) | null>( + null + ); + + // Callback for filter component to update result + const handleFilterResult = React.useCallback( + (result: PlanFilterRenderResult) => { + setFilterResult(result); + }, + [] + ); + + // Callback to reset pagination to page 1 + // Used by filter components when filters change + const resetPagination = React.useCallback(() => { + // Call the pagination handler to go to page 1 + handlePageChangeRef.current?.(1); + }, []); + + // Create filter state manager component if planFilters render prop is provided + // This component returns null but manages filter state via local React state + // State persists when switching tabs because Reach UI TabPanels stay mounted + // and communicates filtered results back to parent via the onResult callback + const filterStateManager = React.useMemo(() => { + if (isGenerationalPlansEnabled && planFilters) { + return planFilters({ + onResult: handleFilterResult, + planType, + plans, + resetPagination, + shouldDisableFilters: shouldDisplayNoRegionSelectedMessage, + }); + } + return null; + }, [ + isGenerationalPlansEnabled, + planFilters, + planType, + plans, + handleFilterResult, + resetPagination, + shouldDisplayNoRegionSelectedMessage, + ]); + + // Clear filter result when filters are disabled or removed + React.useEffect(() => { + if (!planFilters || !isGenerationalPlansEnabled) { + setFilterResult(null); + } + }, [isGenerationalPlansEnabled, planFilters]); + + // Use filtered plans if available, otherwise use all plans + const effectiveFilterResult = isGenerationalPlansEnabled + ? filterResult + : null; + const plansToDisplay = effectiveFilterResult?.filteredPlans ?? plans; + const tableEmptyState = shouldDisplayNoRegionSelectedMessage + ? null + : (effectiveFilterResult?.emptyState ?? null); + // Feature gate: if pagination is disabled, render the old way - if (!isK8PlanPaginationEnabled) { + if (!isGenerationalPlansEnabled) { return ( @@ -175,6 +300,7 @@ export const KubernetesPlanContainer = ( filterOptions={table} key={`k8-plan-filter-${idx}`} plans={filteredPlans} + planType={planType} renderPlanSelection={renderPlanSelection} shouldDisplayNoRegionSelectedMessage={ shouldDisplayNoRegionSelectedMessage @@ -187,6 +313,7 @@ export const KubernetesPlanContainer = ( + {({ count, data: paginatedPlans, @@ -211,33 +348,21 @@ export const KubernetesPlanContainer = ( pageSize, page, }) => { - const shouldDisplayPagination = - !shouldDisplayNoRegionSelectedMessage && - count > PLAN_PANEL_PAGE_SIZE_OPTIONS[0].value; - - const dividerTables = planSelectionDividers - .map((divider) => ({ - planType: divider.planType, - tables: divider.tables - .map((table) => ({ - filterOptions: table, - plans: table.planFilter - ? paginatedPlans.filter(table.planFilter) - : paginatedPlans, - })) - .filter((table) => table.plans.length > 0), - })) - .filter((divider) => divider.tables.length > 0); - - const activeDivider = dividerTables.find( - (divider) => divider.planType === planType - ); + // Store the handlePageChange function in ref so filters can call it + handlePageChangeRef.current = handlePageChange; - const hasActiveGpuDivider = planType === 'gpu' && activeDivider; + const shouldDisplayPagination = !shouldDisplayNoRegionSelectedMessage; return ( <> + {filterStateManager} + + {/* Render filter UI that was passed via callback */} + {effectiveFilterResult?.filterUI && ( + {effectiveFilterResult.filterUI} + )} + {shouldDisplayNoRegionSelectedMessage ? ( - ) : hasActiveGpuDivider ? ( - activeDivider.tables.map(({ filterOptions, plans }, idx) => ( - - {filterOptions.header ? ( - - - {filterOptions.header} - - - ) : null} - {renderPlanSelection(plans)} - - )) + ) : tableEmptyState ? ( + ({ + '& p': { fontSize: theme.tokens.font.FontSize.Xs }, + })} + text={tableEmptyState.message} + variant="info" + /> ) : ( renderPlanSelection(paginatedPlans) )} @@ -275,30 +395,16 @@ export const KubernetesPlanContainer = ( xs: 12, }} > - {hasActiveGpuDivider ? ( - activeDivider.tables.map( - ({ filterOptions, plans }, idx) => ( - - ) - ) - ) : ( - - )} + @@ -308,6 +414,7 @@ export const KubernetesPlanContainer = ( customOptions={PLAN_PANEL_PAGE_SIZE_OPTIONS} handlePageChange={handlePageChange} handleSizeChange={handlePageSizeChange} + minPageSize={PLAN_PANEL_PAGE_SIZE_OPTIONS[0].value} page={page} pageSize={pageSize} sx={{ diff --git a/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlanSelectionTable.tsx b/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlanSelectionTable.tsx index d048630a77e..5d2530d6f10 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlanSelectionTable.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlanSelectionTable.tsx @@ -9,12 +9,18 @@ import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; import { useIsGenerationalPlansEnabled } from 'src/utilities/linodes'; import { PLAN_SELECTION_NO_REGION_SELECTED_MESSAGE } from 'src/utilities/pricing/constants'; +import type { LinodeTypeClass } from '@linode/api-v4'; import type { PlanSelectionFilterOptionsTable } from 'src/features/components/PlansPanel/PlanContainer'; import type { PlanWithAvailability } from 'src/features/components/PlansPanel/types'; interface KubernetesPlanSelectionTableProps { + /** + * Optional message to display when filters result in no plans + */ + filterEmptyStateMessage?: string; filterOptions?: PlanSelectionFilterOptionsTable; plans?: PlanWithAvailability[]; + planType?: LinodeTypeClass; renderPlanSelection?: (plans: PlanWithAvailability[]) => React.JSX.Element[]; shouldDisplayNoRegionSelectedMessage: boolean; } @@ -33,12 +39,17 @@ export const KubernetesPlanSelectionTable = ( props: KubernetesPlanSelectionTableProps ) => { const { + filterEmptyStateMessage, filterOptions, plans, + planType, renderPlanSelection, shouldDisplayNoRegionSelectedMessage, } = props; - const { isGenerationalPlansEnabled } = useIsGenerationalPlansEnabled(); + const { isGenerationalPlansEnabled } = useIsGenerationalPlansEnabled( + plans, + planType + ); // Determine spacing based on feature flag: // - If generationalPlans is enabled (pagination mode) -> spacingBottom={0} @@ -79,6 +90,8 @@ export const KubernetesPlanSelectionTable = ( colSpan={9} message={PLAN_SELECTION_NO_REGION_SELECTED_MESSAGE} /> + ) : filterEmptyStateMessage ? ( + ) : ( ((plans && renderPlanSelection?.(plans)) ?? null) )} diff --git a/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlansPanel.tsx b/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlansPanel.tsx index 6ded2e38371..7cf653dfdd1 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlansPanel.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlansPanel.tsx @@ -3,6 +3,8 @@ import * as React from 'react'; import type { JSX } from 'react'; import { TabbedPanel } from 'src/components/TabbedPanel/TabbedPanel'; +import { createDedicatedPlanFiltersRenderProp } from 'src/features/components/PlansPanel/DedicatedPlanFilters'; +import { createGPUPlanFilterRenderProp } from 'src/features/components/PlansPanel/GpuFilters'; import { PlanInformation } from 'src/features/components/PlansPanel/PlanInformation'; import { determineInitialPlanCategoryTab, @@ -11,6 +13,7 @@ import { isMTCPlan, planTabInfoContent, replaceOrAppendPlaceholder512GbPlans, + useShouldDisablePremiumPlansTab, } from 'src/features/components/PlansPanel/utils'; import { useFlags } from 'src/hooks/useFlags'; @@ -82,6 +85,10 @@ export const KubernetesPlansPanel = (props: Props) => { Boolean(flags.soldOutChips) && Boolean(selectedRegionId) ); + const shouldDisablePremiumPlansTab = useShouldDisablePremiumPlansTab({ + types, + }); + const isPlanDisabledByAPL = (plan: 'shared' | LinodeTypeClass) => plan === 'shared' && Boolean(isAPLEnabled); @@ -109,7 +116,7 @@ export const KubernetesPlansPanel = (props: Props) => { { isLKE: true } ); - const tabs = Object.keys(plans).map( + const tabs = Object.keys(plans)?.map( (plan: Exclude) => { const plansMap: PlanSelectionType[] = plans[plan]!; const { @@ -125,6 +132,7 @@ export const KubernetesPlansPanel = (props: Props) => { }); return { + disabled: false, render: () => { return ( <> @@ -136,6 +144,7 @@ export const KubernetesPlansPanel = (props: Props) => { isSelectedRegionEligibleForPlan={isSelectedRegionEligibleForPlan( plan )} + plans={plansForThisLinodeTypeClass} planType={plan} regionsData={regionsData} /> @@ -146,6 +155,16 @@ export const KubernetesPlansPanel = (props: Props) => { hasMajorityOfPlansDisabled={hasMajorityOfPlansDisabled} onAdd={onAdd} onSelect={onSelect} + planFilters={(() => { + switch (plan) { + case 'dedicated': + return createDedicatedPlanFiltersRenderProp(); + case 'gpu': + return createGPUPlanFilterRenderProp(); + default: + return undefined; + } + })()} plans={plansForThisLinodeTypeClass} planType={plan} selectedId={selectedId} @@ -170,6 +189,26 @@ export const KubernetesPlansPanel = (props: Props) => { currentPlanHeading ); + // If there are no premium plans available, plans table will hide the premium tab. + // To override this behavior, we add the tab again and then disable it. + // If there are plans but they should be disabled, we disable the existing tab. + if ( + shouldDisablePremiumPlansTab && + !tabs.some((tab) => tab.title === planTabInfoContent.premium?.title) + ) { + tabs.push({ + disabled: true, + render: () =>
    , + title: planTabInfoContent.premium?.title, + }); + } else if (shouldDisablePremiumPlansTab) { + tabs.forEach((tab) => { + if (tab.title === planTabInfoContent.premium?.title) { + tab.disabled = true; + } + }); + } + return ( { initTab={initialTab >= 0 ? initialTab : 0} notice={notice} sx={{ padding: 0 }} + tabDisabledMessage={ + shouldDisablePremiumPlansTab + ? 'Premium CPUs are now called Dedicated G7 Plans.' + : undefined + } tabs={tabs} /> ); diff --git a/packages/manager/src/features/Kubernetes/kubeUtils.test.ts b/packages/manager/src/features/Kubernetes/kubeUtils.test.ts index ae1512add65..a3a7fcc59f2 100644 --- a/packages/manager/src/features/Kubernetes/kubeUtils.test.ts +++ b/packages/manager/src/features/Kubernetes/kubeUtils.test.ts @@ -3,6 +3,7 @@ import { renderHook } from '@testing-library/react'; import { kubeLinodeFactory, nodePoolFactory } from 'src/factories'; import { extendType } from 'src/utilities/extendType'; +import { wrapWithTheme } from 'src/utilities/testHelpers'; import { compareByKubernetesVersion, @@ -22,12 +23,11 @@ const queryMocks = vi.hoisted(() => ({ useAccount: vi.fn().mockReturnValue({}), useGrants: vi.fn().mockReturnValue({}), useAccountBetaQuery: vi.fn().mockReturnValue({}), - useFlags: vi.fn().mockReturnValue({}), useKubernetesTieredVersionsQuery: vi.fn().mockReturnValue({}), })); -vi.mock('@linode/queries', () => { - const actual = vi.importActual('@linode/queries'); +vi.mock('@linode/queries', async () => { + const actual = await vi.importActual('@linode/queries'); return { ...actual, useAccount: queryMocks.useAccount, @@ -36,14 +36,6 @@ vi.mock('@linode/queries', () => { }; }); -vi.mock('src/hooks/useFlags', () => { - const actual = vi.importActual('src/hooks/useFlags'); - return { - ...actual, - useFlags: queryMocks.useFlags, - }; -}); - vi.mock('src/queries/kubernetes', () => { const actual = vi.importActual('src/queries/kubernetes'); return { @@ -225,11 +217,10 @@ describe('helper functions', () => { queryMocks.useAccountBetaQuery.mockReturnValue({ data: accountBeta, }); - queryMocks.useFlags.mockReturnValue({ - apl: true, - }); - const { result } = renderHook(() => useAPLAvailability()); + const { result } = renderHook(() => useAPLAvailability(), { + wrapper: (ui) => wrapWithTheme(ui, { flags: { apl: true } }), + }); expect(result.current.showAPL).toBe(true); }); }); @@ -352,17 +343,21 @@ describe('hooks', () => { capabilities: [], }, }); - queryMocks.useFlags.mockReturnValue({ - lkeEnterprise2: { - enabled: true, - ga: true, - la: true, - phase2Mtc: { byoVPC: true, dualStack: true }, - postLa: true, - }, - }); - const { result } = renderHook(() => useIsLkeEnterpriseEnabled()); + const { result } = renderHook(() => useIsLkeEnterpriseEnabled(), { + wrapper: (ui) => + wrapWithTheme(ui, { + flags: { + lkeEnterprise2: { + enabled: true, + ga: true, + la: true, + phase2Mtc: { byoVPC: true, dualStack: true }, + postLa: true, + }, + }, + }), + }); expect(result.current).toStrictEqual({ isLkeEnterpriseGAFeatureEnabled: false, isLkeEnterpriseGAFlagEnabled: true, @@ -380,17 +375,21 @@ describe('hooks', () => { capabilities: ['Kubernetes Enterprise'], }, }); - queryMocks.useFlags.mockReturnValue({ - lkeEnterprise2: { - enabled: true, - ga: false, - la: true, - phase2Mtc: { byoVPC: false, dualStack: false }, - postLa: false, - }, - }); - const { result } = renderHook(() => useIsLkeEnterpriseEnabled()); + const { result } = renderHook(() => useIsLkeEnterpriseEnabled(), { + wrapper: (ui) => + wrapWithTheme(ui, { + flags: { + lkeEnterprise2: { + enabled: true, + ga: false, + la: true, + phase2Mtc: { byoVPC: false, dualStack: false }, + postLa: false, + }, + }, + }), + }); expect(result.current).toStrictEqual({ isLkeEnterpriseGAFeatureEnabled: false, isLkeEnterpriseGAFlagEnabled: false, @@ -412,17 +411,21 @@ describe('hooks', () => { ], }, }); - queryMocks.useFlags.mockReturnValue({ - lkeEnterprise2: { - enabled: true, - ga: false, - la: true, - phase2Mtc: { byoVPC: true, dualStack: true }, - postLa: false, - }, - }); - const { result } = renderHook(() => useIsLkeEnterpriseEnabled()); + const { result } = renderHook(() => useIsLkeEnterpriseEnabled(), { + wrapper: (ui) => + wrapWithTheme(ui, { + flags: { + lkeEnterprise2: { + enabled: true, + ga: false, + la: true, + phase2Mtc: { byoVPC: true, dualStack: true }, + postLa: false, + }, + }, + }), + }); expect(result.current).toStrictEqual({ isLkeEnterpriseGAFeatureEnabled: false, isLkeEnterpriseGAFlagEnabled: false, @@ -440,17 +443,20 @@ describe('hooks', () => { capabilities: ['Kubernetes Enterprise'], }, }); - queryMocks.useFlags.mockReturnValue({ - lkeEnterprise2: { - enabled: true, - ga: false, - la: true, - phase2Mtc: { byoVPC: true, dualStack: true }, - postLa: false, - }, + const { result } = renderHook(() => useIsLkeEnterpriseEnabled(), { + wrapper: (ui) => + wrapWithTheme(ui, { + flags: { + lkeEnterprise2: { + enabled: true, + ga: false, + la: true, + phase2Mtc: { byoVPC: true, dualStack: true }, + postLa: false, + }, + }, + }), }); - - const { result } = renderHook(() => useIsLkeEnterpriseEnabled()); expect(result.current).toStrictEqual({ isLkeEnterpriseGAFeatureEnabled: false, isLkeEnterpriseGAFlagEnabled: false, @@ -472,17 +478,21 @@ describe('hooks', () => { ], }, }); - queryMocks.useFlags.mockReturnValue({ - lkeEnterprise2: { - enabled: true, - ga: true, - la: true, - phase2Mtc: { byoVPC: true, dualStack: true }, - postLa: true, - }, - }); - const { result } = renderHook(() => useIsLkeEnterpriseEnabled()); + const { result } = renderHook(() => useIsLkeEnterpriseEnabled(), { + wrapper: (ui) => + wrapWithTheme(ui, { + flags: { + lkeEnterprise2: { + enabled: true, + ga: true, + la: true, + phase2Mtc: { byoVPC: true, dualStack: true }, + postLa: true, + }, + }, + }), + }); expect(result.current).toStrictEqual({ isLkeEnterpriseGAFeatureEnabled: true, isLkeEnterpriseGAFlagEnabled: true, diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Actions.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Actions.tsx index 1852a581950..b7469edc159 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/Actions.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/Actions.tsx @@ -83,7 +83,11 @@ export const Actions = ({ isAlertsBetaMode }: ActionProps) => { return ( -