diff --git a/packages/api-v4/CHANGELOG.md b/packages/api-v4/CHANGELOG.md index 4256b21626d..d0f37f982d8 100644 --- a/packages/api-v4/CHANGELOG.md +++ b/packages/api-v4/CHANGELOG.md @@ -1,3 +1,21 @@ +## [2026-03-16] - v0.158.0 + + +### Added: + +- Add `ACLP Logs Datacenter LKE-E` to the Region's `Capability` type ([#13467](https://github.com/linode/manager/pull/13467)) + +### Changed: + +- Export `HostEndpoint` and rename `private_access` to `public_access` ([#13413](https://github.com/linode/manager/pull/13413)) +- Switch delivery endpoints from Beta APIv4 to APIv4 ([#13461](https://github.com/linode/manager/pull/13461)) +- Update node balancer type for enterprise from premium_40GB to premium_40gb ([#13472](https://github.com/linode/manager/pull/13472)) + +### Upcoming Features: + +- Add `shared` to ImageType ([#13418](https://github.com/linode/manager/pull/13418)) +- Add logs to `CloudPulseServiceType` and `capabilityServiceTypeMapping` ([#13445](https://github.com/linode/manager/pull/13445)) + ## [2026-02-25] - v0.157.0 diff --git a/packages/api-v4/package.json b/packages/api-v4/package.json index bb258733a35..691ce5ea5c4 100644 --- a/packages/api-v4/package.json +++ b/packages/api-v4/package.json @@ -1,6 +1,6 @@ { "name": "@linode/api-v4", - "version": "0.157.0", + "version": "0.158.0", "homepage": "https://github.com/linode/manager/tree/develop/packages/api-v4", "bugs": { "url": "https://github.com/linode/manager/issues" @@ -41,7 +41,7 @@ "unpkg": "./lib/index.global.js", "dependencies": { "@linode/validation": "workspace:*", - "axios": "~1.12.0", + "axios": "~1.13.5", "ipaddr.js": "^2.0.0", "yup": "^1.4.0" }, diff --git a/packages/api-v4/src/cloudpulse/types.ts b/packages/api-v4/src/cloudpulse/types.ts index 89e798853d5..e72bef9d0a7 100644 --- a/packages/api-v4/src/cloudpulse/types.ts +++ b/packages/api-v4/src/cloudpulse/types.ts @@ -9,6 +9,7 @@ export type CloudPulseServiceType = | 'firewall' | 'linode' | 'lke' + | 'logs' | 'netloadbalancer' | 'nodebalancer' | 'objectstorage'; @@ -429,6 +430,7 @@ export const capabilityServiceTypeMapping: Record< blockstorage: 'Block Storage', lke: 'Kubernetes', netloadbalancer: 'Network LoadBalancer', + logs: 'Akamai Cloud Pulse Logs', }; /** diff --git a/packages/api-v4/src/databases/types.ts b/packages/api-v4/src/databases/types.ts index 59df60aa1c0..9974bd22d08 100644 --- a/packages/api-v4/src/databases/types.ts +++ b/packages/api-v4/src/databases/types.ts @@ -96,10 +96,10 @@ export type HostEndpointRole = | 'standby' | 'standby-connection-pool'; -interface HostEndpoint { +export interface HostEndpoint { address: string; port: number; - private_access: boolean; + public_access: boolean; role: HostEndpointRole; } @@ -120,8 +120,6 @@ type MemberType = 'failover' | 'primary'; export interface DatabaseInstance { allow_list: string[]; cluster_size: ClusterSize; - /** @Deprecated replaced by `endpoints` property */ - connection_pool_port: null | number; connection_strings: ConnectionStrings[]; created: string; /** @Deprecated used by rdbms-legacy only, rdbms-default always encrypts */ diff --git a/packages/api-v4/src/delivery/destinations.ts b/packages/api-v4/src/delivery/destinations.ts index 1dcd4d6442c..1626a17e4de 100644 --- a/packages/api-v4/src/delivery/destinations.ts +++ b/packages/api-v4/src/delivery/destinations.ts @@ -3,7 +3,7 @@ import { updateDestinationSchema, } from '@linode/validation'; -import { BETA_API_ROOT } from '../constants'; +import { API_ROOT } from '../constants'; import Request, { setData, setMethod, @@ -28,7 +28,7 @@ import type { export const getDestination = (destinationId: number) => Request( setURL( - `${BETA_API_ROOT}/monitor/streams/destinations/${encodeURIComponent(destinationId)}`, + `${API_ROOT}/monitor/streams/destinations/${encodeURIComponent(destinationId)}`, ), setMethod('GET'), ); @@ -39,7 +39,7 @@ export const getDestination = (destinationId: number) => */ export const getDestinations = (params?: Params, filter?: Filter) => Request>( - setURL(`${BETA_API_ROOT}/monitor/streams/destinations`), + setURL(`${API_ROOT}/monitor/streams/destinations`), setMethod('GET'), setParams(params), setXFilter(filter), @@ -53,7 +53,7 @@ export const getDestinations = (params?: Params, filter?: Filter) => export const createDestination = (data: CreateDestinationPayload) => Request( setData(data, createDestinationSchema), - setURL(`${BETA_API_ROOT}/monitor/streams/destinations`), + setURL(`${API_ROOT}/monitor/streams/destinations`), setMethod('POST'), ); @@ -70,7 +70,7 @@ export const updateDestination = ( Request( setData(data, updateDestinationSchema), setURL( - `${BETA_API_ROOT}/monitor/streams/destinations/${encodeURIComponent(destinationId)}`, + `${API_ROOT}/monitor/streams/destinations/${encodeURIComponent(destinationId)}`, ), setMethod('PUT'), ); @@ -83,7 +83,7 @@ export const updateDestination = ( export const deleteDestination = (destinationId: number) => Request<{}>( setURL( - `${BETA_API_ROOT}/monitor/streams/destinations/${encodeURIComponent(destinationId)}`, + `${API_ROOT}/monitor/streams/destinations/${encodeURIComponent(destinationId)}`, ), setMethod('DELETE'), ); @@ -96,6 +96,6 @@ export const deleteDestination = (destinationId: number) => export const verifyDestination = (data: CreateDestinationPayload) => Request( setData(data, createDestinationSchema), - setURL(`${BETA_API_ROOT}/monitor/streams/destinations/verify`), + setURL(`${API_ROOT}/monitor/streams/destinations/verify`), setMethod('POST'), ); diff --git a/packages/api-v4/src/delivery/streams.ts b/packages/api-v4/src/delivery/streams.ts index 621bafa7247..e1a5c41f3f1 100644 --- a/packages/api-v4/src/delivery/streams.ts +++ b/packages/api-v4/src/delivery/streams.ts @@ -1,6 +1,6 @@ import { createStreamSchema, updateStreamSchema } from '@linode/validation'; -import { BETA_API_ROOT } from '../constants'; +import { API_ROOT } from '../constants'; import Request, { setData, setMethod, @@ -20,7 +20,7 @@ import type { CreateStreamPayload, Stream, UpdateStreamPayload } from './types'; */ export const getStream = (streamId: number) => Request( - setURL(`${BETA_API_ROOT}/monitor/streams/${encodeURIComponent(streamId)}`), + setURL(`${API_ROOT}/monitor/streams/${encodeURIComponent(streamId)}`), setMethod('GET'), ); @@ -30,7 +30,7 @@ export const getStream = (streamId: number) => */ export const getStreams = (params?: Params, filter?: Filter) => Request>( - setURL(`${BETA_API_ROOT}/monitor/streams`), + setURL(`${API_ROOT}/monitor/streams`), setMethod('GET'), setParams(params), setXFilter(filter), @@ -44,7 +44,7 @@ export const getStreams = (params?: Params, filter?: Filter) => export const createStream = (data: CreateStreamPayload) => Request( setData(data, createStreamSchema), - setURL(`${BETA_API_ROOT}/monitor/streams`), + setURL(`${API_ROOT}/monitor/streams`), setMethod('POST'), ); @@ -57,7 +57,7 @@ export const createStream = (data: CreateStreamPayload) => export const updateStream = (streamId: number, data: UpdateStreamPayload) => Request( setData(data, updateStreamSchema), - setURL(`${BETA_API_ROOT}/monitor/streams/${encodeURIComponent(streamId)}`), + setURL(`${API_ROOT}/monitor/streams/${encodeURIComponent(streamId)}`), setMethod('PUT'), ); @@ -68,6 +68,6 @@ export const updateStream = (streamId: number, data: UpdateStreamPayload) => */ export const deleteStream = (streamId: number) => Request<{}>( - setURL(`${BETA_API_ROOT}/monitor/streams/${encodeURIComponent(streamId)}`), + setURL(`${API_ROOT}/monitor/streams/${encodeURIComponent(streamId)}`), setMethod('DELETE'), ); diff --git a/packages/api-v4/src/iam/delegation.ts b/packages/api-v4/src/iam/delegation.ts index 18f439153d3..219a18f313e 100644 --- a/packages/api-v4/src/iam/delegation.ts +++ b/packages/api-v4/src/iam/delegation.ts @@ -1,6 +1,7 @@ import { BETA_API_ROOT } from '../constants'; import Request, { setData, + setHeaders, setMethod, setParams, setURL, @@ -12,6 +13,7 @@ import type { Token } from '../profile'; import type { ResourcePage as Page } from '../types'; import type { ChildAccount, + ChildAccountTokenPayload, ChildAccountWithDelegates, GetChildAccountDelegatesParams, GetChildAccountsIamParams, @@ -98,12 +100,16 @@ export const getDelegatedChildAccount = ({ euuid }: { euuid: string }) => setMethod('GET'), ); -export const generateChildAccountToken = ({ euuid }: { euuid: string }) => +export const generateChildAccountToken = ({ + euuid, + headers, +}: ChildAccountTokenPayload) => Request( setURL( `${BETA_API_ROOT}/iam/delegation/profile/child-accounts/${encodeURIComponent(euuid)}/token`, ), setMethod('POST'), + setHeaders(headers), setData(euuid), ); diff --git a/packages/api-v4/src/iam/delegation.types.ts b/packages/api-v4/src/iam/delegation.types.ts index 953ca57c467..14791a5b2b7 100644 --- a/packages/api-v4/src/iam/delegation.types.ts +++ b/packages/api-v4/src/iam/delegation.types.ts @@ -1,4 +1,4 @@ -import type { Filter, Params } from 'src/types'; +import type { Filter, Params, RequestOptions } from '../types'; export interface ChildAccount { company: string; @@ -37,3 +37,7 @@ export interface UpdateChildAccountDelegatesParams { euuid: string; users: string[]; } + +export interface ChildAccountTokenPayload extends RequestOptions { + euuid: string; +} diff --git a/packages/api-v4/src/iam/types.ts b/packages/api-v4/src/iam/types.ts index 1da75c56215..fa36b3e8bf5 100644 --- a/packages/api-v4/src/iam/types.ts +++ b/packages/api-v4/src/iam/types.ts @@ -125,6 +125,7 @@ export type AccountAdmin = | 'view_account_login' | 'view_account_settings' | 'view_child_account' + | 'view_default_delegate_access' | 'view_enrolled_beta_program' | 'view_lock' | 'view_network_usage' diff --git a/packages/api-v4/src/images/types.ts b/packages/api-v4/src/images/types.ts index 42aa1a4f9e1..c2df6d30d69 100644 --- a/packages/api-v4/src/images/types.ts +++ b/packages/api-v4/src/images/types.ts @@ -2,7 +2,7 @@ export type ImageStatus = 'available' | 'creating' | 'pending_upload'; export type ImageCapabilities = 'cloud-init' | 'distributed-sites'; -type ImageType = 'automatic' | 'manual'; +type ImageType = 'automatic' | 'manual' | 'shared'; type SharegroupMemberStatus = 'active' | 'revoked'; diff --git a/packages/api-v4/src/nodebalancers/types.ts b/packages/api-v4/src/nodebalancers/types.ts index 4ee00d3070c..75a1ee7babf 100644 --- a/packages/api-v4/src/nodebalancers/types.ts +++ b/packages/api-v4/src/nodebalancers/types.ts @@ -10,7 +10,7 @@ type UDPStickiness = 'none' | 'session' | 'source_ip'; export type Stickiness = TCPStickiness | UDPStickiness; -type NodeBalancerType = 'common' | 'premium' | 'premium_40GB'; +type NodeBalancerType = 'common' | 'premium' | 'premium_40gb'; export interface LKEClusterInfo { id: number; diff --git a/packages/api-v4/src/object-storage/buckets.ts b/packages/api-v4/src/object-storage/buckets.ts index f4b3e09b681..20e71533d77 100644 --- a/packages/api-v4/src/object-storage/buckets.ts +++ b/packages/api-v4/src/object-storage/buckets.ts @@ -60,25 +60,6 @@ export const getBuckets = (params?: Params, filters?: Filter) => setURL(`${API_ROOT}/object-storage/buckets`), ); -/** - * getBucketsInCluster - * - * Gets a list of a user's Object Storage Buckets in the specified cluster. - */ -export const getBucketsInCluster = ( - clusterId: string, - params?: Params, - filters?: Filter, -) => - Request>( - setMethod('GET'), - setParams(params), - setXFilter(filters), - setURL( - `${API_ROOT}/object-storage/buckets/${encodeURIComponent(clusterId)}`, - ), - ); - /** * getBucketsInRegion * diff --git a/packages/api-v4/src/regions/types.ts b/packages/api-v4/src/regions/types.ts index e4fa034d08a..98abf0a6c56 100644 --- a/packages/api-v4/src/regions/types.ts +++ b/packages/api-v4/src/regions/types.ts @@ -1,6 +1,7 @@ import type { COUNTRY_CODE_TO_CONTINENT_CODE } from './constants'; export type Capabilities = + | 'ACLP Logs Datacenter LKE-E' | 'Backups' | 'Bare Metal' | 'Block Storage' diff --git a/packages/manager/CHANGELOG.md b/packages/manager/CHANGELOG.md index fe7c4ebf449..462ec02d89d 100644 --- a/packages/manager/CHANGELOG.md +++ b/packages/manager/CHANGELOG.md @@ -4,6 +4,73 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [2026-03-16] - v1.160.0 + + +### : + +- Add new Marketplace products ([#13475](https://github.com/linode/manager/pull/13475)) + +### Added: + +- IAM: adds the URL params to Assigned Roles and Assigned Entities tables ([#13397](https://github.com/linode/manager/pull/13397)) +- IAM Parent/Child: add permissions to UI ([#13409](https://github.com/linode/manager/pull/13409)) +- Add Gemma3, Qwen, Milvus, pgvector, and GPT-oss Markeplace apps ([#13420](https://github.com/linode/manager/pull/13420)) +- New condition for displaying volume metrics tab ([#13452](https://github.com/linode/manager/pull/13452)) +- New condition for displaying bucket metrics tab ([#13456](https://github.com/linode/manager/pull/13456)) +- Stream Create/Edit form: Show instructions in the Clusters section on how to enable Kubernetes API Audit log generation per cluster ([#13473](https://github.com/linode/manager/pull/13473)) + +### Changed: + +- Make firewall selection mandatory while creating linode and its interfaces ([#13410](https://github.com/linode/manager/pull/13410)) +- Delivery Logs - in Custom HTTPS form set authentication.details to undefined when None authentication is selected ([#13425](https://github.com/linode/manager/pull/13425)) +- IAM: update last login field for delegate users ([#13437](https://github.com/linode/manager/pull/13437)) +- Improvements for bucket selection in Akamai Object Storage Destination form ([#13444](https://github.com/linode/manager/pull/13444)) +- Delivery CRUD mocks update from Beta APIv4 to APIv4 ([#13461](https://github.com/linode/manager/pull/13461)) +- Stream Create/Edit form: in the Clusters table show only clusters with a region that has "ACLP Logs Datacenter LKE-E" capability ([#13467](https://github.com/linode/manager/pull/13467)) +- Improve UpdateDelegateDrawer & EntitiesSelect UI ([#13468](https://github.com/linode/manager/pull/13468)) + +### Fixed: + +- IAM Delegation: incorrect landing page after account switch, wrong top menu username, pagination disappears in the User Delegations table ([#13399](https://github.com/linode/manager/pull/13399)) +- IAM: style fix for long username ([#13411](https://github.com/linode/manager/pull/13411)) +- IAM Parent/Child: Use User infinite query in UpdateDelegationDrawer ([#13441](https://github.com/linode/manager/pull/13441)) +- Database Connection Pools table page size ([#13448](https://github.com/linode/manager/pull/13448)) +- IAM / Entities & Roles client-side pagination on page refresh ([#13451](https://github.com/linode/manager/pull/13451)) +- Linode Interfaces network connection incorrectly disabled when createing a Linode from a Backup ([#13458](https://github.com/linode/manager/pull/13458)) +- Unnecessary API requests for Delivery services while searching when feature access is not present ([#13459](https://github.com/linode/manager/pull/13459)) +- Await permissions to show error on User Detail ([#13462](https://github.com/linode/manager/pull/13462)) +- Throughtput quota conversion logic ([#13463](https://github.com/linode/manager/pull/13463)) +- IAM Parent/Child - SwitchAccount Drawer: hide search if no child account ([#13464](https://github.com/linode/manager/pull/13464)) +- Update node balancer type for enterprise and remove duplicate nodebalancer rows in VPC subnet table ([#13472](https://github.com/linode/manager/pull/13472)) +- Marketplace product updates + contact sales drawer fix ([#13476](https://github.com/linode/manager/pull/13476)) + +### Removed: + +- Remove New chip from Database Networking Tab and Access banner from Settings ([#13454](https://github.com/linode/manager/pull/13454)) + +### Tests: + +- Object storage summary tests added ([#13403](https://github.com/linode/manager/pull/13403)) +- Fix Cypress test failures following v1.159.0 release ([#13438](https://github.com/linode/manager/pull/13438)) +- Cypress tests for Postgresql Synchronous Replication Advanced Configuration ([#13440](https://github.com/linode/manager/pull/13440)) +- Fix cypress test failing for hostname endpoint changes ([#13457](https://github.com/linode/manager/pull/13457)) +- Fix test flakiness in account switching spec ([#13470](https://github.com/linode/manager/pull/13470)) + +### Upcoming Features: + +- Private Image Sharing tabs new layout (v2) ([#13407](https://github.com/linode/manager/pull/13407)) +- Empty message for `SwitchAccountDrawer` child accounts table ([#13412](https://github.com/linode/manager/pull/13412)) +- Use new hostname endpoint in Database Summary and Network tab ([#13413](https://github.com/linode/manager/pull/13413)) +- Add reusable `ImagesView` and `ImagesTable` components, and integrated them for the `Owned by me` Images tab (v2) ([#13418](https://github.com/linode/manager/pull/13418)) +- Add Recovery images tab (v2) ([#13432](https://github.com/linode/manager/pull/13432)) +- Add new Image Select table and use it in Linode Create > Images tab ([#13435](https://github.com/linode/manager/pull/13435)) +- Use new hostname endpoints in Service URIs and display public/private URIs for public VPCs ([#13439](https://github.com/linode/manager/pull/13439)) +- Integrate aclp-logs service to alerts with custom validation schemas, error texts ([#13445](https://github.com/linode/manager/pull/13445)) +- Logs service integration changes for onboarding with `CloudPulse Metrics` ([#13460](https://github.com/linode/manager/pull/13460)) +- Private Image Sharing: Add Share Groups tabs ([#13471](https://github.com/linode/manager/pull/13471)) +- Fix Database PgBouncer Connection Pool bugs ([#13474](https://github.com/linode/manager/pull/13474)) + ## [2026-02-26] - v1.159.1 diff --git a/packages/manager/cypress/e2e/core/cloudpulse/alerting-notification-channel-permission-tests.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/alerting-notification-channel-permission-tests.spec.ts index ad08eabc340..55e2978c839 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/alerting-notification-channel-permission-tests.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/alerting-notification-channel-permission-tests.spec.ts @@ -37,6 +37,7 @@ describe('Notification Channel Listing Page — Access Control', () => { mockAppendFeatureFlags(flags); cy.visitWithLogin('/linodes'); + ui.nav.findItemByTitle('Alerts').scrollIntoView(); ui.nav.findItemByTitle('Alerts').should('be.visible').click(); ui.tabList .findTabByTitle('Notification Channels') @@ -62,6 +63,7 @@ describe('Notification Channel Listing Page — Access Control', () => { mockAppendFeatureFlags(flags); cy.visitWithLogin('/linodes'); + ui.nav.findItemByTitle('Alerts').scrollIntoView(); ui.nav.findItemByTitle('Alerts').should('be.visible').click(); // Tab should not render at all diff --git a/packages/manager/cypress/e2e/core/databases/advanced-configuration.spec.ts b/packages/manager/cypress/e2e/core/databases/advanced-configuration.spec.ts index deee96b0079..4f2bedd849e 100644 --- a/packages/manager/cypress/e2e/core/databases/advanced-configuration.spec.ts +++ b/packages/manager/cypress/e2e/core/databases/advanced-configuration.spec.ts @@ -13,6 +13,7 @@ import { mockGetDatabaseEngineConfigs, mockGetDatabaseTypes, mockUpdateDatabase, + mockUpdateDatabaseError, } from 'support/intercepts/databases'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { ui } from 'support/ui'; @@ -40,15 +41,45 @@ import type { DatabaseClusterConfiguration } from 'support/constants/databases'; */ const getFlattenDefaultConfigs = ( engineConfig: Record, - prefix = '' + prefix = '', + includePrefix = true ): string[] => Object.entries(engineConfig).flatMap(([key, value]) => { const fullKey = prefix ? `${prefix}.${key}` : key; return typeof value === 'object' && value !== null && !Array.isArray(value) - ? getFlattenDefaultConfigs(value, fullKey) - : [fullKey]; + ? getFlattenDefaultConfigs( + value, + includePrefix ? fullKey : '', + includePrefix + ) + : [includePrefix ? fullKey : key]; }); +const flattenConfigsEngineLevel = ( + configs: Record +): Record => { + const result: Record = {}; + Object.entries(configs).forEach(([key, value]) => { + if ( + typeof value === 'object' && + value !== null && + // Only flatten if value is a config group (not a config leaf) + Object.values(value).every( + (v) => typeof v === 'object' && v !== null && !Array.isArray(v) + ) + ) { + // Nested group (e.g., pg, mysql) + Object.entries(value).forEach(([subKey, subValue]) => { + result[subKey] = subValue; + }); + } else { + // Top-level config + result[key] = value; + } + }); + return result; +}; + /** * Get list of advanced Configurations available for users to add/modify * @@ -100,9 +131,14 @@ const addConfigsToUI = ( ? false : value.example; + // Get all existing config keys from engine_config (handles nested structures) + const existingConfigKeys = new Set( + getFlattenDefaultConfigs(database.engine_config, '', false) + ); + // Process new configs to be added const newEntries = Object.entries(configsList) - .filter(([key]) => !database.engine_config[engineType][key]) + .filter(([key]) => !existingConfigKeys.has(key)) .slice(0, addSingle ? 1 : undefined); // Limit to 1 if addSingle, otherwise all if (newEntries.length > 0) { @@ -121,8 +157,20 @@ const addConfigsToUI = ( .within(() => { // Confirms configure drawer already renders default configs Object.keys(database.engine_config[engineType]).forEach((key) => { + cy.findByText(`${engineType}.${key}`).scrollIntoView(); cy.findByText(`${engineType}.${key}`).should('be.visible'); }); + Object.keys(database.engine_config) + .filter( + (key) => + key !== 'pg' && + key !== 'mysql' && + typeof database.engine_config[key] !== 'object' + ) + .forEach((key) => { + cy.findByText(key).scrollIntoView(); + cy.findByText(key).should('be.visible'); + }); // Adding configs one at a time from the dropdown cy.get( @@ -140,9 +188,24 @@ const addConfigsToUI = ( // Type value for non-boolean configs if (value.type !== 'boolean') { - cy.get(`[name="${flatKey}"]`).scrollIntoView(); - cy.get(`[name="${flatKey}"]`).should('be.visible').clear(); - cy.get(`[name="${flatKey}"]`).type(additionalConfigs[flatKey]); + if (value.enum) { + cy.findByText(flatKey).scrollIntoView(); + cy.findByText(flatKey) + .parent() + .within(() => { + ui.autocomplete.find().click(); + ui.autocomplete.find().clear(); + ui.autocomplete.find().type(`${additionalConfigs[flatKey]}`); + }); + ui.autocompletePopper + .findByTitle(`${additionalConfigs[flatKey]}`) + .click(); + } else { + cy.get(`[name="${flatKey}"]`).scrollIntoView(); + cy.get(`[name="${flatKey}"]`).should('be.visible'); + cy.get(`[name="${flatKey}"]`).clear(); + cy.get(`[name="${flatKey}"]`).type(additionalConfigs[flatKey]); + } } }); }); @@ -195,7 +258,7 @@ describe('Update database clusters', () => { ); mockGetAccount(accountFactory.build()).as('getAccount'); - mockGetDatabase(database).as('getDatabase').debug(); + mockGetDatabase(database).as('getDatabase'); mockGetDatabaseTypes(mockDatabaseNodeTypes).as('getDatabaseTypes'); mockGetDatabaseEngineConfigs(database.engine, mockConfigs); @@ -215,6 +278,7 @@ describe('Update database clusters', () => { }); // Confirms all the buttons are in the initial state - enabled/disabled + ui.cdsButton.findButtonByTitle('Configure').scrollIntoView(); ui.cdsButton .findButtonByTitle('Configure') .should('be.visible') @@ -226,18 +290,15 @@ describe('Update database clusters', () => { .findButtonByTitle('Add') .should('exist') .should('be.disabled'); - ui.button - .findByTitle('Save') - .scrollIntoView() - .should('be.visible') - .should('be.disabled'); + ui.button.findByTitle('Save').should('exist').should('be.disabled'); ui.button .findByTitle('Cancel') - .scrollIntoView() - .should('be.visible') + .should('exist') .should('be.enabled') - .click(); + .then((btn) => { + btn[0].click(); + }); ui.cdsButton .findButtonByTitle('Configure') @@ -247,9 +308,11 @@ describe('Update database clusters', () => { ui.drawer.findByTitle('Advanced Configuration').should('be.visible'); cy.get('[aria-label="Close drawer"]') - .should('be.visible') + .should('exist') .should('be.enabled') - .click(); + .then((btn) => { + btn[0].click(); + }); }); /* @@ -296,6 +359,7 @@ describe('Update database clusters', () => { cy.wait(['@getDatabase', '@getDatabaseTypes']); // Expand configure drawer to add configs + ui.cdsButton.findButtonByTitle('Configure').scrollIntoView(); ui.cdsButton .findButtonByTitle('Configure') .should('be.visible') @@ -313,31 +377,52 @@ describe('Update database clusters', () => { true ); + const isSyncReplicationQuorum = + singleConfig['synchronous_replication'] === 'quorum'; + const isInvaliClusterSize = + database.cluster_size < 3 && isSyncReplicationQuorum; + // Update advanced configurations with the newly added config - mockUpdateDatabase(database.id, database.engine, { - ...database, - engine_config: { - ...(database.engine_config as ConfigCategoryValues), - [engineType]: { - ...(existingConfig as ConfigCategoryValues), - ...singleConfig, + if (isInvaliClusterSize) { + mockUpdateDatabaseError( + database.id, + database.engine, + 'engine_config.synchronous_replication', + 'synchronous_replication is only supported for clusters with 3 nodes' + ).as('updateAdvancedConfiguration'); + } else { + mockUpdateDatabase(database.id, database.engine, { + ...database, + engine_config: { + ...(database.engine_config as ConfigCategoryValues), + [engineType]: { + ...(existingConfig as ConfigCategoryValues), + ...singleConfig, + }, }, - }, - }).as('updateAdvancedConfiguration'); - + }).as('updateAdvancedConfiguration'); + } // Save or Save and Restart Services as per the config added ui.button .findByTitle(saveRestartButton) - .scrollIntoView() - .should('be.visible') + .should('exist') .should('be.enabled') - .click(); + .then((btn) => { + btn[0].click(); + }); cy.wait('@updateAdvancedConfiguration'); - // Confirms newly added advacned Config on the Configuration tab tableview - cy.findByText(`${engineType}.${Object.keys(singleConfig)[0]}`).should( - 'be.visible' - ); + if (isInvaliClusterSize) { + // Verify error message is displayed for invalid synchronous replication + cy.findByText( + /synchronous_replication is only supported for clusters with 3 nodes/i + ).should('be.visible'); + } else { + // Confirms newly added advanced Config on the Configuration tab tableview + cy.findByText( + `${engineType}.${Object.keys(singleConfig)[0]}` + ).should('be.visible'); + } }); /* @@ -384,48 +469,83 @@ describe('Update database clusters', () => { cy.wait(['@getDatabase', '@getDatabaseTypes']); // Expand configure drawer to add configs + ui.cdsButton.findButtonByTitle('Configure').scrollIntoView(); ui.cdsButton .findButtonByTitle('Configure') .should('be.visible') .should('be.enabled') .click(); + const flatMockConfigs = flattenConfigsEngineLevel(mockConfigs); + // Add configs from the configList to the existing database cluster const { additionalConfigs: allConfig, saveButton: saveRestartButton, - } = addConfigsToUI( - mockConfigs[engineType], - database, - engineType, - false - ); + } = addConfigsToUI(flatMockConfigs, database, engineType, false); + + const nestedConfig: Record = {}; + const topLevelConfig: Record = {}; + // Separate nested engine configs and top-level configs + Object.entries(allConfig).forEach(([key, value]) => { + if (key in mockConfigs[engineType]) { + nestedConfig[key] = value; + } else { + topLevelConfig[key] = value; + } + }); + + const isSyncReplicationQuorum = + allConfig['synchronous_replication'] === 'quorum'; + const isInvalidClusterSize = + database.cluster_size < 3 && isSyncReplicationQuorum; // Update advanced configurations with the newly added config - mockUpdateDatabase(database.id, database.engine, { - ...database, - engine_config: { - ...(database.engine_config as ConfigCategoryValues), - [engineType]: { - ...(existingConfig as ConfigCategoryValues), - ...allConfig, + if (isInvalidClusterSize) { + mockUpdateDatabaseError( + database.id, + database.engine, + 'engine_config.synchronous_replication', + 'synchronous_replication is only supported for clusters with 3 nodes' + ).as('updateAdvancedConfiguration'); + } else { + mockUpdateDatabase(database.id, database.engine, { + ...database, + engine_config: { + ...(database.engine_config as ConfigCategoryValues), + [engineType]: { + ...(existingConfig as ConfigCategoryValues), + ...nestedConfig, + }, + ...topLevelConfig, }, - }, - }).as('updateAdvancedConfiguration'); + }).as('updateAdvancedConfiguration'); + } // Save or Save and Restart Services as per the config added ui.button .findByTitle(saveRestartButton) - .scrollIntoView() - .should('be.visible') + .should('exist') .should('be.enabled') - .click(); + .then((btn) => { + btn[0].click(); + }); cy.wait('@updateAdvancedConfiguration'); - // Confirms newly added advacned Config on the Configuration tab tableview - Object.keys(allConfig).forEach((key) => { - cy.findByText(`${engineType}.${key}`).should('be.visible'); - }); + if (isInvalidClusterSize) { + // Verify error message is displayed for invalid synchronous replication + cy.findByText( + /synchronous_replication is only supported for clusters with 3 nodes/i + ).should('be.visible'); + } else { + // Confirms newly added advanced Config on the Configuration tab tableview + Object.keys(nestedConfig).forEach((key) => { + cy.findByText(`${engineType}.${key}`).should('be.visible'); + }); + Object.keys(topLevelConfig).forEach((key) => { + cy.findByText(`${key}`).should('be.visible'); + }); + } }); /* @@ -469,6 +589,7 @@ describe('Update database clusters', () => { cy.wait(['@getDatabase', '@getDatabaseTypes']); // Expand configure drawer to add configs + ui.cdsButton.findButtonByTitle('Configure').scrollIntoView(); ui.cdsButton .findButtonByTitle('Configure') .should('be.visible') diff --git a/packages/manager/cypress/e2e/core/databases/update-database.spec.ts b/packages/manager/cypress/e2e/core/databases/update-database.spec.ts index 0e274db8967..0bc3ddea940 100644 --- a/packages/manager/cypress/e2e/core/databases/update-database.spec.ts +++ b/packages/manager/cypress/e2e/core/databases/update-database.spec.ts @@ -237,7 +237,6 @@ const suspendCluster = (label: string) => { * @param initialLabel - cluster name * @param updateAttemptLabel - cluster updated name * @param errorMessage - error thrown for updating a suspended/resuming cluster - * @param hostnameRegex - connection settings hostname * @param allowedIp - ip for manage access actions */ @@ -247,7 +246,6 @@ const validateSuspendResume = ( initialLabel: string, updateAttemptLabel: string, errorMessage: string, - hostnameRegex: RegExp, allowedIp: string ) => { cy.visit(`/databases/${engine}/${id}`); @@ -263,8 +261,6 @@ const validateSuspendResume = ( .click(); cy.findByText('Connection Details'); - // DBaaS hostnames are not available when database/cluster is suspended or resuming. - cy.findByText(hostnameRegex).should('be.visible'); // DBaaS passwords cannot be revealed when database/cluster is suspended or resuming. ui.cdsButton.findButtonByTitle('Show').should('be.enabled'); @@ -647,8 +643,6 @@ describe('Update database clusters', () => { const errorMessage = 'Your database is suspended; please wait until it becomes active to perform this operation.'; - const hostnameRegex = - /your hostnames? will appear here once (it is|they are) available./i; mockGetAccount(accountFactory.build()).as('getAccount'); mockGetDatabase(database).as('getDatabase'); @@ -719,7 +713,6 @@ describe('Update database clusters', () => { initialLabel, updateAttemptLabel, errorMessage, - hostnameRegex, allowedIp ); }); @@ -756,8 +749,6 @@ describe('Update database clusters', () => { }); const errorMessage = `Your database is ${action}; please wait until it becomes active to perform this operation.`; - const hostnameRegex = - /your hostnames? will appear here once (it is|they are) available./i; mockGetAccount(accountFactory.build()).as('getAccount'); mockGetDatabases([database]).as('getDatabases'); @@ -846,7 +837,6 @@ describe('Update database clusters', () => { initialLabel, updateAttemptLabel, errorMessage, - hostnameRegex, allowedIp ); }); diff --git a/packages/manager/cypress/e2e/core/delivery/create-destination.spec.ts b/packages/manager/cypress/e2e/core/delivery/create-destination.spec.ts index 0418ab542b0..c7c257790ad 100644 --- a/packages/manager/cypress/e2e/core/delivery/create-destination.spec.ts +++ b/packages/manager/cypress/e2e/core/delivery/create-destination.spec.ts @@ -8,9 +8,12 @@ import { mockTestConnection, } from 'support/intercepts/delivery'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { mockGetBuckets } from 'support/intercepts/object-storage'; import { ui } from 'support/ui'; import { logsDestinationForm } from 'support/ui/pages/logs-destination-form'; +import { objectStorageBucketFactory } from 'src/factories'; + import type { AkamaiObjectStorageDetailsExtended } from '@linode/api-v4'; describe('Create Destination', () => { @@ -22,11 +25,10 @@ describe('Create Destination', () => { bypassAccountCapabilities: true, }, }); + cy.visitWithLogin('/logs/delivery/destinations/create'); }); it('create destination with form', () => { - cy.visitWithLogin('/logs/delivery/destinations/create'); - // Give Destination a label logsDestinationForm.setLabel(mockDestinationPayload.label); @@ -100,4 +102,64 @@ describe('Create Destination', () => { cy.findByText(mockDestination.label).should('be.visible'); }); }); + + describe('Bucket end Endpoint fields', () => { + it('populates Bucket and Endpoint when selecting an existing bucket and manually entering data', () => { + mockGetBuckets([ + objectStorageBucketFactory.build({ + hostname: 'bucket-hostname.us-east-1.linodeobjects.com', + label: 'bucket-with-hostname', + region: 'us-east', + }), + objectStorageBucketFactory.build({ + hostname: 'bucket-s3.eu-central-1.linodeobjects.com', + label: 'bucket-with-s3-endpoint', + region: 'eu-central', + s3_endpoint: 'eu-central-1.linodeobjects.com', + }), + ]); + + // Default radio should be "Select Bucket associated with the account" + cy.findByLabelText('Select Bucket associated with the account').should( + 'be.checked' + ); + + // Endpoint should be disabled in bucket_from_account mode + cy.findByLabelText('Endpoint').should('be.disabled'); + + // Select a bucket without s3_endpoint - should use hostname as Endpoint + logsDestinationForm.selectBucketFromDropdown('bucket-with-hostname'); + cy.findByLabelText('Bucket').should('have.value', 'bucket-with-hostname'); + cy.findByLabelText('Endpoint').should( + 'have.value', + 'bucket-hostname.us-east-1.linodeobjects.com' + ); + + // Select a bucket with s3_endpoint - should use s3_endpoint as Endpoint + logsDestinationForm.selectBucketFromDropdown('bucket-with-s3-endpoint'); + cy.findByLabelText('Bucket').should( + 'have.value', + 'bucket-with-s3-endpoint' + ); + cy.findByLabelText('Endpoint').should( + 'have.value', + 'eu-central-1.linodeobjects.com' + ); + + // Switch to manual mode and fill in values + cy.findByLabelText('Enter Bucket manually').click(); + logsDestinationForm.setBucket('my-manual-bucket'); + logsDestinationForm.setEndpoint('my-endpoint.com'); + + cy.findByLabelText('Bucket').should('have.value', 'my-manual-bucket'); + cy.findByLabelText('Endpoint').should('have.value', 'my-endpoint.com'); + + // Switch back to bucket_from_account + cy.findByLabelText('Select Bucket associated with the account').click(); + + // Both fields should be cleared + cy.findByLabelText('Bucket').should('have.value', ''); + cy.findByLabelText('Endpoint').should('have.value', ''); + }); + }); }); 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 2bb0b56b2d4..d0bdae727f3 100644 --- a/packages/manager/cypress/e2e/core/delivery/create-stream.spec.ts +++ b/packages/manager/cypress/e2e/core/delivery/create-stream.spec.ts @@ -1,4 +1,5 @@ import { streamType } from '@linode/api-v4'; +import { regionFactory } from '@linode/utilities'; import { mockDestination } from 'support/constants/delivery'; import { mockGetAccount } from 'support/intercepts/account'; import { @@ -9,6 +10,7 @@ import { } from 'support/intercepts/delivery'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { mockGetClusters } from 'support/intercepts/lke'; +import { mockGetRegions } from 'support/intercepts/regions'; import { ui } from 'support/ui'; import { logsStreamForm } from 'support/ui/pages/logs-stream-form'; import { randomLabel } from 'support/util/random'; @@ -179,25 +181,55 @@ describe('Create Stream', () => { describe('given Kubernetes API Audit Logs Stream Type', () => { it('selects clusters and creates new stream', () => { + const regionWithCapabilityAndCluster = regionFactory.build({ + id: 'us-southeast', + label: 'Atlanta, GA', + capabilities: ['ACLP Logs Datacenter LKE-E', 'Object Storage'], + }); + const regionWithCapabilityNoCluster = regionFactory.build({ + id: 'us-chicago', + label: 'Chicago, IL', + capabilities: ['ACLP Logs Datacenter LKE-E', 'Object Storage'], + }); + const regionNoCapabilityWithCluster = regionFactory.build({ + id: 'us-west', + label: 'Fremont, CA', + capabilities: ['Object Storage'], + }); + + const cluster1 = kubernetesClusterFactory.build({ + id: 1, + label: 'cluster-1', + region: regionWithCapabilityAndCluster.id, + control_plane: { audit_logs_enabled: true }, + }); + const cluster2 = kubernetesClusterFactory.build({ + id: 2, + label: 'cluster-2', + region: regionWithCapabilityAndCluster.id, + control_plane: { audit_logs_enabled: false }, + }); + const cluster3 = kubernetesClusterFactory.build({ + id: 3, + label: 'cluster-3', + region: regionWithCapabilityAndCluster.id, + control_plane: { audit_logs_enabled: true }, + }); + const clusterNoCap = kubernetesClusterFactory.build({ + id: 4, + label: 'cluster-4', + region: regionNoCapabilityWithCluster.id, + control_plane: { audit_logs_enabled: true }, + }); + // Mock API responses mockGetDestinations([mockDestination]); - mockGetClusters([ - kubernetesClusterFactory.build({ - id: 1, - label: 'cluster-1', - control_plane: { audit_logs_enabled: true }, - }), - kubernetesClusterFactory.build({ - id: 2, - label: 'cluster-2', - control_plane: { audit_logs_enabled: false }, - }), - kubernetesClusterFactory.build({ - id: 3, - label: 'cluster-3', - control_plane: { audit_logs_enabled: true }, - }), + mockGetRegions([ + regionWithCapabilityAndCluster, + regionWithCapabilityNoCluster, + regionNoCapabilityWithCluster, ]); + mockGetClusters([cluster1, cluster2, cluster3, clusterNoCap]); // Visit the Create Stream page cy.visitWithLogin('/logs/delivery/streams/create'); @@ -213,9 +245,33 @@ describe('Create Stream', () => { // Select existing destination logsStreamForm.selectExistingDestination(mockDestination.label); + // Expect only 'Atlanta, GA' to be in Region Select (has capability and is in clusters) + ui.regionSelect.find().should('be.visible').click(); + + ui.autocompletePopper + .findByTitle(regionWithCapabilityAndCluster.id, { exact: false }) + .should('be.visible'); + + ui.autocompletePopper + .find() + .should('not.contain', regionWithCapabilityNoCluster.id); + + ui.autocompletePopper + .find() + .should('not.contain', regionNoCapabilityWithCluster.id); + + // Close the dropdown + ui.regionSelect.find().type('{esc}'); + cy.findByText('Clusters').should('be.visible'); cy.get('[data-testid="clusters-table"]').should('exist'); + // Expect only cluster-1, cluster-2, cluster-3 to be in table. + cy.findByText('cluster-1').should('be.visible'); + cy.findByText('cluster-2').should('be.visible'); + cy.findByText('cluster-3').should('be.visible'); + cy.findByText('cluster-4').should('not.exist'); + // Select cluster-1 and cluster-3 individually logsStreamForm.findClusterCheckbox('cluster-1').check(); diff --git a/packages/manager/cypress/e2e/core/delivery/edit-destination.spec.ts b/packages/manager/cypress/e2e/core/delivery/edit-destination.spec.ts index 9ae689d23b5..0042fef60c4 100644 --- a/packages/manager/cypress/e2e/core/delivery/edit-destination.spec.ts +++ b/packages/manager/cypress/e2e/core/delivery/edit-destination.spec.ts @@ -10,10 +10,12 @@ import { mockUpdateDestination, } from 'support/intercepts/delivery'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { mockGetBuckets } from 'support/intercepts/object-storage'; import { ui } from 'support/ui'; import { logsDestinationForm } from 'support/ui/pages/logs-destination-form'; import { randomLabel } from 'support/util/random'; +import { objectStorageBucketFactory } from 'src/factories'; import { getDestinationTypeOption } from 'src/features/Delivery/deliveryUtils'; import type { AkamaiObjectStorageDetailsExtended } from '@linode/api-v4'; @@ -120,4 +122,53 @@ describe('Edit Destination', () => { cy.findByText(newLabel).should('be.visible'); }); }); + + describe('Bucket end Endpoint fields', () => { + it('populates Bucket and Endpoint when selecting an existing bucket and manually entering data', () => { + mockGetBuckets([ + objectStorageBucketFactory.build({ + hostname: 'bucket-hostname.us-east-1.linodeobjects.com', + label: 'bucket-with-hostname', + region: 'us-east', + }), + objectStorageBucketFactory.build({ + hostname: 'bucket-s3.eu-central-1.linodeobjects.com', + label: 'bucket-with-s3-endpoint', + region: 'eu-central', + s3_endpoint: 'eu-central-1.linodeobjects.com', + }), + ]); + + // Edit mode defaults to manual bucket entry + cy.findByLabelText('Enter Bucket manually').should('be.checked'); + + // Endpoint should be enabled in manual mode + cy.findByLabelText('Endpoint').should('be.enabled'); + + // Switch to bucket_from_account - Bucket and Endpoint should be cleared + cy.findByLabelText('Select Bucket associated with the account').click(); + cy.findByLabelText('Bucket').should('have.value', ''); + cy.findByLabelText('Endpoint').should('have.value', ''); + cy.findByLabelText('Endpoint').should('be.disabled'); + + // Select a bucket without s3_endpoint - should use hostname as Endpoint + logsDestinationForm.selectBucketFromDropdown('bucket-with-hostname'); + cy.findByLabelText('Bucket').should('have.value', 'bucket-with-hostname'); + cy.findByLabelText('Endpoint').should( + 'have.value', + 'bucket-hostname.us-east-1.linodeobjects.com' + ); + + // Select a bucket with s3_endpoint - should use s3_endpoint + logsDestinationForm.selectBucketFromDropdown('bucket-with-s3-endpoint'); + cy.findByLabelText('Bucket').should( + 'have.value', + 'bucket-with-s3-endpoint' + ); + cy.findByLabelText('Endpoint').should( + 'have.value', + 'eu-central-1.linodeobjects.com' + ); + }); + }); }); 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 95942ae99b4..9b5795ae89c 100644 --- a/packages/manager/cypress/e2e/core/delivery/edit-stream.spec.ts +++ b/packages/manager/cypress/e2e/core/delivery/edit-stream.spec.ts @@ -1,3 +1,4 @@ +import { regionFactory } from '@linode/utilities'; import { mockAuditLogsStream, mockAuditLogsStreamPayload, @@ -15,6 +16,7 @@ import { } from 'support/intercepts/delivery'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { mockGetClusters } from 'support/intercepts/lke'; +import { mockGetRegions } from 'support/intercepts/regions'; import { ui } from 'support/ui'; import { logsStreamForm } from 'support/ui/pages/logs-stream-form'; import { randomLabel } from 'support/util/random'; @@ -147,28 +149,61 @@ describe('Edit Stream', () => { describe('given Kubernetes API Audit Logs Stream Type', () => { it('edits stream label and clusters and saves', () => { + const regionWithCapabilityAndCluster = regionFactory.build({ + id: 'us-southeast', + label: 'Atlanta, GA', + capabilities: ['ACLP Logs Datacenter LKE-E', 'Object Storage'], + }); + const regionWithCapabilityNoCluster = regionFactory.build({ + id: 'us-chicago', + label: 'Chicago, IL', + capabilities: ['ACLP Logs Datacenter LKE-E', 'Object Storage'], + }); + const regionNoCapabilityWithCluster = regionFactory.build({ + id: 'us-west', + label: 'Fremont, CA', + capabilities: ['Object Storage'], + }); + + const cluster1 = kubernetesClusterFactory.build({ + id: 1, + label: 'cluster-1', + region: regionWithCapabilityAndCluster.id, + control_plane: { audit_logs_enabled: true }, + }); + const cluster2 = kubernetesClusterFactory.build({ + id: 2, + label: 'cluster-2', + region: regionWithCapabilityAndCluster.id, + control_plane: { audit_logs_enabled: false }, + }); + const cluster3 = kubernetesClusterFactory.build({ + id: 3, + label: 'cluster-3', + region: regionWithCapabilityAndCluster.id, + control_plane: { audit_logs_enabled: true }, + }); + const cluster4 = kubernetesClusterFactory.build({ + id: 4, + label: 'cluster-no-cap', + region: regionNoCapabilityWithCluster.id, + control_plane: { audit_logs_enabled: true }, + }); + // Mock API responses mockGetDestinations([mockDestination]); mockGetDestination(mockDestination); mockGetStreams([mockLKEAuditLogsStream]); - mockGetStream(mockLKEAuditLogsStream); - mockGetClusters([ - kubernetesClusterFactory.build({ - id: 1, - label: 'cluster-1', - control_plane: { audit_logs_enabled: true }, - }), - kubernetesClusterFactory.build({ - id: 2, - label: 'cluster-2', - control_plane: { audit_logs_enabled: false }, - }), - kubernetesClusterFactory.build({ - id: 3, - label: 'cluster-3', - control_plane: { audit_logs_enabled: true }, - }), + mockGetStream({ + ...mockLKEAuditLogsStream, + details: { cluster_ids: [1, 3] }, + }); + mockGetRegions([ + regionWithCapabilityAndCluster, + regionWithCapabilityNoCluster, + regionNoCapabilityWithCluster, ]); + mockGetClusters([cluster1, cluster2, cluster3, cluster4]); // Visit the Edit Stream page cy.visitWithLogin( @@ -196,10 +231,34 @@ describe('Edit Stream', () => { .should('be.disabled') .should('have.attr', 'value', 'Kubernetes API Audit Logs'); + // Expect only 'Atlanta, GA' to be in Region Select (has capability and is in clusters) + ui.regionSelect.find().should('be.visible').click(); + + ui.autocompletePopper + .findByTitle(regionWithCapabilityAndCluster.id, { exact: false }) + .should('be.visible'); + + ui.autocompletePopper + .find() + .should('not.contain', regionWithCapabilityNoCluster.id); + + ui.autocompletePopper + .find() + .should('not.contain', regionNoCapabilityWithCluster.id); + + // Close the dropdown + ui.regionSelect.find().type('{esc}'); + // Clusters table should be visible cy.findByText('Clusters').should('be.visible'); cy.get('[data-testid="clusters-table"]').should('exist'); + // Verify Clusters Table content + cy.findByText('cluster-1').should('be.visible'); + cy.findByText('cluster-2').should('be.visible'); + cy.findByText('cluster-3').should('be.visible'); + cy.findByText('cluster-4').should('not.exist'); + // Initially selected clusters should be checked logsStreamForm.findClusterCheckbox('cluster-1').should('be.checked'); logsStreamForm.findClusterCheckbox('cluster-3').should('be.checked'); diff --git a/packages/manager/cypress/e2e/core/general/gdpr-agreement.spec.ts b/packages/manager/cypress/e2e/core/general/gdpr-agreement.spec.ts index c89c6cbc6c9..9aaa67132c6 100644 --- a/packages/manager/cypress/e2e/core/general/gdpr-agreement.spec.ts +++ b/packages/manager/cypress/e2e/core/general/gdpr-agreement.spec.ts @@ -1,9 +1,12 @@ import { linodeFactory, regionFactory } from '@linode/utilities'; +import { firewallFactory } from '@src/factories'; import { mockGetAccountAgreements } from 'support/intercepts/account'; +import { mockGetFirewalls } from 'support/intercepts/firewalls'; import { mockCreateLinode } from 'support/intercepts/linodes'; import { mockGetRegions } from 'support/intercepts/regions'; import { ui } from 'support/ui'; -import { randomLabel, randomString } from 'support/util/random'; +import { linodeCreatePage } from 'support/ui/pages'; +import { randomLabel, randomNumber, randomString } from 'support/util/random'; import type { Region } from '@linode/api-v4'; @@ -100,6 +103,11 @@ describe('GDPR agreement', () => { }); it('needs the agreement checked to submit the form', () => { + const mockFirewall = firewallFactory.build({ + id: randomNumber(), + label: randomLabel(), + }); + mockGetFirewalls([mockFirewall]).as('getFirewalls'); mockGetRegions(mockRegions).as('getRegions'); mockGetAccountAgreements({ billing_agreement: false, @@ -127,6 +135,12 @@ describe('GDPR agreement', () => { cy.findByLabelText('Root Password').type(rootpass); + // Select a firewall + linodeCreatePage.selectFirewall( + mockFirewall.label, + 'Public Interface Firewall' + ); + cy.get('[data-testid="eu-agreement-checkbox"]') .as('euAgreement') .scrollIntoView(); diff --git a/packages/manager/cypress/e2e/core/helpAndSupport/open-support-ticket.spec.ts b/packages/manager/cypress/e2e/core/helpAndSupport/open-support-ticket.spec.ts index f979b0cd28a..b41149900de 100644 --- a/packages/manager/cypress/e2e/core/helpAndSupport/open-support-ticket.spec.ts +++ b/packages/manager/cypress/e2e/core/helpAndSupport/open-support-ticket.spec.ts @@ -6,6 +6,7 @@ import 'cypress-file-upload'; import { mockGetAccount } from 'support/intercepts/account'; import { mockGetDomains } from 'support/intercepts/domains'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { mockGetFirewalls } from 'support/intercepts/firewalls'; import { mockCreateLinodeAccountLimitError, mockGetLinodeDetails, @@ -31,6 +32,7 @@ import { } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; +import { firewallFactory } from 'src/factories'; import { accountFactory, domainFactory, @@ -378,6 +380,10 @@ describe('open support tickets', () => { planLabel: 'Nanode 1 GB', planId: 'g6-nanode-1', }; + const mockFirewall = firewallFactory.build({ + id: randomNumber(), + label: randomLabel(), + }); const mockLinode = linodeFactory.build(); @@ -393,6 +399,7 @@ describe('open support tickets', () => { mockGetSupportTicket(mockAccountLimitTicket); mockGetSupportTicketReplies(mockAccountLimitTicket.id, []); mockGetLinodes([mockLinode]); + mockGetFirewalls([mockFirewall]).as('getFirewalls'); cy.visitWithLogin('/linodes/create'); @@ -401,6 +408,8 @@ describe('open support tickets', () => { linodeCreatePage.selectRegionById(mockRegion.id); linodeCreatePage.selectPlan(mockPlan.planType, mockPlan.planLabel); linodeCreatePage.setRootPassword(randomString(32)); + // Select a firewall + linodeCreatePage.selectFirewall(mockFirewall.label, 'Assign Firewall'); // Attempt to create Linode and confirm mocked account limit error with support link is present. ui.button diff --git a/packages/manager/cypress/e2e/core/images/create-linode-from-image.spec.ts b/packages/manager/cypress/e2e/core/images/create-linode-from-image.spec.ts index 40aef7bf9ed..3206e9ed69e 100644 --- a/packages/manager/cypress/e2e/core/images/create-linode-from-image.spec.ts +++ b/packages/manager/cypress/e2e/core/images/create-linode-from-image.spec.ts @@ -1,7 +1,9 @@ import { linodeFactory } from '@linode/utilities'; -import { imageFactory } from '@src/factories'; +import { firewallFactory, imageFactory } from '@src/factories'; +import { mockGetFirewalls } from 'support/intercepts/firewalls'; import { mockGetAllImages } from 'support/intercepts/images'; import { ui } from 'support/ui'; +import { linodeCreatePage } from 'support/ui/pages'; import { apiMatcher } from 'support/util/intercepts'; import { randomLabel, randomNumber, randomString } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; @@ -20,8 +22,14 @@ const mockImage = imageFactory.build({ label: randomLabel(), }); +const mockFirewall = firewallFactory.build({ + id: randomNumber(), + label: randomLabel(), +}); + const createLinodeWithImageMock = (url: string, preselectedImage: boolean) => { mockGetAllImages([mockImage]).as('mockImage'); + mockGetFirewalls([mockFirewall]).as('getFirewalls'); cy.intercept('POST', apiMatcher('linode/instances'), (req) => { req.reply({ @@ -52,6 +60,11 @@ const createLinodeWithImageMock = (url: string, preselectedImage: boolean) => { cy.findByText('Shared CPU').click(); cy.get('[id="g6-nanode-1"][type="radio"]').click(); cy.get('[id="root-password"]').type(randomString(32)); + // Select a firewall + linodeCreatePage.selectFirewall( + mockFirewall.label, + 'Public Interface Firewall' + ); ui.button .findByTitle('Create Linode') diff --git a/packages/manager/cypress/e2e/core/linodes/alerts-create.spec.ts b/packages/manager/cypress/e2e/core/linodes/alerts-create.spec.ts index 43325319275..329debdf69c 100644 --- a/packages/manager/cypress/e2e/core/linodes/alerts-create.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/alerts-create.spec.ts @@ -2,15 +2,21 @@ import { regionAvailabilityFactory, regionFactory } from '@linode/utilities'; import { mockGetAccountSettings } from 'support/intercepts/account'; import { mockGetAlertDefinition } from 'support/intercepts/cloudpulse'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { mockGetFirewalls } from 'support/intercepts/firewalls'; import { interceptCreateLinode } from 'support/intercepts/linodes'; import { mockGetRegionAvailability, mockGetRegions, } from 'support/intercepts/regions'; import { ui } from 'support/ui'; -import { randomLabel, randomString } from 'support/util/random'; +import { linodeCreatePage } from 'support/ui/pages'; +import { randomLabel, randomNumber, randomString } from 'support/util/random'; -import { accountSettingsFactory, alertFactory } from 'src/factories'; +import { + accountSettingsFactory, + alertFactory, + firewallFactory, +} from 'src/factories'; import { ALERTS_BETA_MODE_BANNER_TEXT, ALERTS_BETA_MODE_BUTTON_TEXT, @@ -18,6 +24,11 @@ import { ALERTS_LEGACY_MODE_BUTTON_TEXT, } from 'src/features/Linodes/constants'; +const mockFirewall = firewallFactory.build({ + id: randomNumber(), + label: randomLabel(), +}); + describe('Create flow when beta alerts enabled by region and feature flag', function () { beforeEach(() => { const mockEnabledRegion = regionFactory.build({ @@ -54,6 +65,7 @@ describe('Create flow when beta alerts enabled by region and feature flag', func interfaces_for_new_linodes: 'legacy_config_default_but_linode_allowed', }); mockGetAccountSettings(mockInitialAccountSettings).as('getSettings'); + mockGetFirewalls([mockFirewall]).as('getFirewalls'); }); it('Alerts panel becomes visible after switching to region w/ alerts enabled', function () { @@ -88,6 +100,11 @@ describe('Create flow when beta alerts enabled by region and feature flag', func const enabledRegion = this.mockRegions[0]; mockGetRegionAvailability(enabledRegion.id, []).as('getRegionAvailability'); ui.regionSelect.find().type(`${enabledRegion.label}{enter}`); + // Select a firewall + linodeCreatePage.selectFirewall( + mockFirewall.label, + 'Public Interface Firewall' + ); // legacy alerts panel appears cy.wait('@getRegionAvailability'); @@ -208,6 +225,11 @@ describe('Create flow when beta alerts enabled by region and feature flag', func const enabledRegion = this.mockRegions[0]; mockGetRegionAvailability(enabledRegion.id, []).as('getRegionAvailability'); ui.regionSelect.find().type(`${enabledRegion.label}{enter}`); + // Select a firewall + linodeCreatePage.selectFirewall( + mockFirewall.label, + 'Public Interface Firewall' + ); // legacy alerts panel appears cy.wait('@getRegionAvailability'); @@ -437,6 +459,11 @@ describe('Create flow when beta alerts enabled by region and feature flag', func 'getRegionAvailability' ); ui.regionSelect.find().type(`${disabledRegion.label}{enter}`); + // Select a firewall + linodeCreatePage.selectFirewall( + mockFirewall.label, + 'Public Interface Firewall' + ); cy.wait('@getRegionAvailability'); // enter plan and password form fields to enable "View Code Snippets" button 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 434d839b5ef..c611bfd31d7 100644 --- a/packages/manager/cypress/e2e/core/linodes/clone-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/clone-linode.spec.ts @@ -20,6 +20,7 @@ import { LINODE_CREATE_TIMEOUT } from 'support/constants/linodes'; import { mockGetLinodeConfigs } from 'support/intercepts/configs'; import { interceptEvents } from 'support/intercepts/events'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { mockGetFirewalls } from 'support/intercepts/firewalls'; import { interceptCloneLinode, mockCloneLinode, @@ -44,6 +45,8 @@ import { } from 'support/util/random'; import { chooseRegion, extendRegion } from 'support/util/regions'; +import { firewallFactory } from 'src/factories'; + import type { Linode } from '@linode/api-v4'; /** @@ -195,8 +198,13 @@ describe('clone linode', () => { id: mockLinode.id + 1, label: newLinodeLabel, }; + const mockFirewall = firewallFactory.build({ + id: randomNumber(), + label: randomLabel(), + }); mockGetVLANs([mockVlan]); + mockGetFirewalls([mockFirewall]).as('getFirewalls'); mockCreateLinode(mockLinode).as('createLinode'); mockGetLinodeDetails(mockLinode.id, mockLinode).as('getLinode'); mockGetLinodeVolumes(clonedLinode.id, [mockVolume]).as('getLinodeVolumes'); @@ -229,6 +237,8 @@ describe('clone linode', () => { .type(mockVlan.cidr_block); }); + // Select a firewall + linodeCreatePage.selectFirewall(mockFirewall.label, 'Assign Firewall'); // Confirm that VLAN attachment is listed in summary, then create Linode. cy.get('[data-qa-linode-create-summary]').scrollIntoView(); cy.get('[data-qa-linode-create-summary]').within(() => { diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-blackwell.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-blackwell.spec.ts index 93e7ef5fc69..035c51d9169 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode-blackwell.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-blackwell.spec.ts @@ -5,6 +5,7 @@ import { regionFactory, } from '@linode/utilities'; import { LINODE_CREATE_TIMEOUT } from 'support/constants/linodes'; +import { mockGetFirewalls } from 'support/intercepts/firewalls'; import { mockCreateLinode, mockGetLinodeTypes, @@ -14,7 +15,11 @@ import { mockGetRegions, } from 'support/intercepts/regions'; import { ui } from 'support/ui'; +import { linodeCreatePage } from 'support/ui/pages'; import { randomLabel, randomString } from 'support/util/random'; +import { randomNumber } from 'support/util/random'; + +import { firewallFactory } from 'src/factories'; const mockEnabledRegion = regionFactory.build({ id: 'us-east', @@ -37,10 +42,15 @@ const mockBlackwellLinodeTypes = new Array(4).fill(null).map((_, index) => const selectedBlackwell = mockBlackwellLinodeTypes[0]; describe('smoketest for Nvidia blackwell GPUs in linodes/create page', () => { + const mockFirewall = firewallFactory.build({ + id: randomNumber(), + label: randomLabel(), + }); beforeEach(() => { mockGetRegions([mockEnabledRegion, mockDisabledRegion]).as('getRegions'); mockGetLinodeTypes(mockBlackwellLinodeTypes).as('getLinodeTypes'); + mockGetFirewalls([mockFirewall]).as('getFirewalls'); }); /* @@ -120,6 +130,11 @@ describe('smoketest for Nvidia blackwell GPUs in linodes/create page', () => { cy.findByLabelText('Linode Label').type(newLinodeLabel); cy.get('[type="password"]').should('be.visible').scrollIntoView(); cy.get('[id="root-password"]').type(randomString(12)); + // Select a firewall + linodeCreatePage.selectFirewall( + mockFirewall.label, + 'Public Interface Firewall' + ); cy.scrollTo('bottom'); const mockLinode = linodeFactory.build({ label: randomLabel(), diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-in-core-region.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-in-core-region.spec.ts index df59f00ebe3..77d3236aaca 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode-in-core-region.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-in-core-region.spec.ts @@ -1,5 +1,6 @@ import { linodeFactory, regionFactory } from '@linode/utilities'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { mockGetFirewalls } from 'support/intercepts/firewalls'; import { mockCreateLinode } from 'support/intercepts/linodes'; import { mockGetRegionAvailability, @@ -8,6 +9,9 @@ import { import { ui } from 'support/ui'; import { linodeCreatePage } from 'support/ui/pages'; import { randomLabel, randomString } from 'support/util/random'; +import { randomNumber } from 'support/util/random'; + +import { firewallFactory } from 'src/factories'; describe('Create Linode in a Core Region', () => { /* @@ -30,6 +34,10 @@ describe('Create Linode in a Core Region', () => { region: mockRegion1.id, }); const rootPass = randomString(32); + const mockFirewall = firewallFactory.build({ + id: randomNumber(), + label: randomLabel(), + }); mockAppendFeatureFlags({ gecko2: { @@ -39,6 +47,7 @@ describe('Create Linode in a Core Region', () => { }).as('getFeatureFlags'); mockGetRegions(mockRegions).as('getRegions'); mockGetRegionAvailability(mockRegion1.id, []).as('getRegionAvailability'); + mockGetFirewalls([mockFirewall]).as('getFirewalls'); mockCreateLinode(mockLinode).as('createLinode'); cy.visitWithLogin('/linodes/create'); @@ -55,6 +64,11 @@ describe('Create Linode in a Core Region', () => { linodeCreatePage.selectImage('Debian 11'); linodeCreatePage.setRootPassword(rootPass); linodeCreatePage.selectPlan('Shared CPU', 'Nanode 1 GB'); + // Select a firewall + linodeCreatePage.selectFirewall( + mockFirewall.label, + 'Public Interface Firewall' + ); ui.button .findByTitle('Create Linode') diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-in-distributed-region.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-in-distributed-region.spec.ts index 8f1ccfd6833..be42ffc1206 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode-in-distributed-region.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-in-distributed-region.spec.ts @@ -4,6 +4,7 @@ import { regionFactory, } from '@linode/utilities'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { mockGetFirewalls } from 'support/intercepts/firewalls'; import { mockCreateLinode, mockGetLinodeTypes, @@ -14,9 +15,11 @@ import { } from 'support/intercepts/regions'; import { ui } from 'support/ui'; import { linodeCreatePage } from 'support/ui/pages'; -import { randomLabel, randomString } from 'support/util/random'; +import { randomLabel, randomNumber, randomString } from 'support/util/random'; import { extendRegion } from 'support/util/regions'; +import { firewallFactory } from 'src/factories'; + import type { Region } from '@linode/api-v4'; describe('Create Linode in Distributed Region', () => { @@ -42,6 +45,10 @@ describe('Create Linode in Distributed Region', () => { label: randomLabel(), region: mockRegion.id, }); + const mockFirewall = firewallFactory.build({ + id: randomNumber(), + label: randomLabel(), + }); const rootPass = randomString(32); mockAppendFeatureFlags({ @@ -51,6 +58,7 @@ describe('Create Linode in Distributed Region', () => { }, }).as('getFeatureFlags'); mockGetRegions([mockRegion]).as('getRegions'); + mockGetFirewalls([mockFirewall]).as('getFirewalls'); mockGetLinodeTypes(mockLinodeTypes).as('getLinodeTypes'); mockGetRegionAvailability(mockRegion.id, []).as('getRegionAvailability'); mockCreateLinode(mockLinode).as('createLinode'); @@ -75,6 +83,12 @@ describe('Create Linode in Distributed Region', () => { .click(); }); + // Select a firewall + linodeCreatePage.selectFirewall( + mockFirewall.label, + 'Public Interface Firewall' + ); + ui.button .findByTitle('Create Linode') .should('be.visible') diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-mobile.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-mobile.spec.ts index ffc2939ce95..c12df22e5e3 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode-mobile.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-mobile.spec.ts @@ -4,12 +4,15 @@ import { linodeFactory } from '@linode/utilities'; import { MOBILE_VIEWPORTS } from 'support/constants/environment'; +import { mockGetFirewalls } from 'support/intercepts/firewalls'; import { mockCreateLinode } from 'support/intercepts/linodes'; import { ui } from 'support/ui'; import { linodeCreatePage } from 'support/ui/pages'; import { randomLabel, randomNumber, randomString } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; +import { firewallFactory } from 'src/factories'; + describe('Linode create mobile smoke', () => { MOBILE_VIEWPORTS.forEach((viewport) => { /* @@ -23,7 +26,11 @@ describe('Linode create mobile smoke', () => { label: randomLabel(), region: mockLinodeRegion.id, }); - + const mockFirewall = firewallFactory.build({ + id: randomNumber(), + label: randomLabel(), + }); + mockGetFirewalls([mockFirewall]).as('getFirewalls'); mockCreateLinode(mockLinode).as('createLinode'); cy.viewport(viewport.width, viewport.height); @@ -34,6 +41,11 @@ describe('Linode create mobile smoke', () => { linodeCreatePage.selectPlanCard('Shared CPU', 'Nanode 1 GB'); linodeCreatePage.setLabel(mockLinode.label); linodeCreatePage.setRootPassword(randomString(32)); + // Select a firewall + linodeCreatePage.selectFirewall( + mockFirewall.label, + 'Public Interface Firewall' + ); cy.get('[data-qa-linode-create-summary]').scrollIntoView(); cy.get('[data-qa-linode-create-summary]').within(() => { diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-view-code-snippet.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-view-code-snippet.spec.ts index b74046af2fc..358c01392a1 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode-view-code-snippet.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-view-code-snippet.spec.ts @@ -3,11 +3,14 @@ */ import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { mockGetFirewalls } from 'support/intercepts/firewalls'; import { ui } from 'support/ui'; import { linodeCreatePage } from 'support/ui/pages'; -import { randomLabel, randomString } from 'support/util/random'; +import { randomLabel, randomNumber, randomString } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; +import { firewallFactory } from 'src/factories'; + describe('Create Linode flow to validate code snippet modal', () => { beforeEach(() => { mockAppendFeatureFlags({ @@ -24,6 +27,11 @@ describe('Create Linode flow to validate code snippet modal', () => { const mockLinodeRegion = chooseRegion({ capabilities: ['Linodes'], }); + const mockFirewall = firewallFactory.build({ + id: randomNumber(), + label: randomLabel(), + }); + mockGetFirewalls([mockFirewall]).as('getFirewalls'); cy.visitWithLogin('/linodes/create'); // Set Linode label, distribution, plan type, password, etc. @@ -32,6 +40,8 @@ describe('Create Linode flow to validate code snippet modal', () => { linodeCreatePage.selectRegionById(mockLinodeRegion.id); linodeCreatePage.selectPlan('Shared CPU', 'Nanode 1 GB'); linodeCreatePage.setRootPassword(rootPass); + // Select a firewall + linodeCreatePage.selectFirewall(mockFirewall.label, 'Assign Firewall'); // View Code Snippets and confirm it's provisioned as expected. ui.button diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-vm-host-maintenance.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-vm-host-maintenance.spec.ts index 7f0e8989401..256aecd29e3 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode-vm-host-maintenance.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-vm-host-maintenance.spec.ts @@ -1,13 +1,14 @@ import { linodeFactory, regionFactory } from '@linode/utilities'; import { mockGetAccountSettings } from 'support/intercepts/account'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { mockGetFirewalls } from 'support/intercepts/firewalls'; import { mockCreateLinode } from 'support/intercepts/linodes'; import { mockGetRegions } from 'support/intercepts/regions'; import { ui } from 'support/ui'; import { linodeCreatePage } from 'support/ui/pages'; -import { randomLabel, randomString } from 'support/util/random'; +import { randomLabel, randomNumber, randomString } from 'support/util/random'; -import { accountSettingsFactory } from 'src/factories'; +import { accountSettingsFactory, firewallFactory } from 'src/factories'; const mockEnabledRegion = regionFactory.build({ capabilities: ['Linodes', 'Maintenance Policy'], }); @@ -35,7 +36,12 @@ describe('vmHostMaintenance feature flag', () => { label: randomLabel(), region: mockEnabledRegion.id, }); + const mockFirewall = firewallFactory.build({ + id: randomNumber(), + label: randomLabel(), + }); mockCreateLinode(mockLinode).as('createLinode'); + mockGetFirewalls([mockFirewall]).as('getFirewalls'); cy.visitWithLogin('/linodes/create'); cy.wait(['@getAccountSettings', '@getFeatureFlags', '@getRegions']); @@ -82,6 +88,8 @@ describe('vmHostMaintenance feature flag', () => { planLabel: 'Nanode 1 GB', }; linodeCreatePage.selectPlan(mockPlan.planType, mockPlan.planLabel); + // Select a firewall + linodeCreatePage.selectFirewall(mockFirewall.label, 'Firewall'); cy.scrollTo('bottom'); ui.button .findByTitle('View Code Snippets') diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-with-add-ons.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-with-add-ons.spec.ts index 3dbdf20a47f..c5ec7562dd2 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode-with-add-ons.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-with-add-ons.spec.ts @@ -1,4 +1,5 @@ import { linodeFactory } from '@linode/utilities'; +import { mockGetFirewalls } from 'support/intercepts/firewalls'; import { mockCreateLinode, mockGetLinodeDetails, @@ -8,7 +9,13 @@ import { linodeCreatePage } from 'support/ui/pages'; import { randomLabel, randomNumber, randomString } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; +import { firewallFactory } from 'src/factories'; + describe('Create Linode with Add-ons', () => { + const mockFirewall = firewallFactory.build({ + id: randomNumber(), + label: randomLabel(), + }); /* * - Confirms UI flow to create a Linode with backups using mock API data. * - Confirms that backups is reflected in create summary section. @@ -25,6 +32,7 @@ describe('Create Linode with Add-ons', () => { mockCreateLinode(mockLinode).as('createLinode'); mockGetLinodeDetails(mockLinode.id, mockLinode); + mockGetFirewalls([mockFirewall]).as('getFirewalls'); cy.visitWithLogin('/linodes/create'); @@ -33,6 +41,11 @@ describe('Create Linode with Add-ons', () => { linodeCreatePage.selectRegionById(linodeRegion.id); linodeCreatePage.selectPlan('Shared CPU', 'Nanode 1 GB'); linodeCreatePage.setRootPassword(randomString(32)); + // Select a firewall + linodeCreatePage.selectFirewall( + mockFirewall.label, + 'Public Interface Firewall' + ); linodeCreatePage.checkBackups(); linodeCreatePage.checkEUAgreements(); @@ -78,6 +91,7 @@ describe('Create Linode with Add-ons', () => { mockCreateLinode(mockLinode).as('createLinode'); mockGetLinodeDetails(mockLinode.id, mockLinode); + mockGetFirewalls([mockFirewall]).as('getFirewalls'); cy.visitWithLogin('/linodes/create'); @@ -86,8 +100,15 @@ describe('Create Linode with Add-ons', () => { linodeCreatePage.selectRegionById(linodeRegion.id); linodeCreatePage.selectPlan('Shared CPU', 'Nanode 1 GB'); linodeCreatePage.setRootPassword(randomString(32)); + // Select a firewall + linodeCreatePage.selectFirewall( + mockFirewall.label, + 'Public Interface Firewall' + ); linodeCreatePage.checkEUAgreements(); linodeCreatePage.selectInterfaceGeneration('legacy_config'); + // Select a firewall + linodeCreatePage.selectFirewall(mockFirewall.label, 'Firewall'); linodeCreatePage.checkPrivateIPs(); // Confirm Private IP assignment indicator is shown in Linode summary. diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-with-disk-encryption.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-with-disk-encryption.spec.ts index 093b89bd120..351d0e56400 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode-with-disk-encryption.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-with-disk-encryption.spec.ts @@ -3,9 +3,10 @@ import { linodeTypeFactory, regionFactory, } from '@linode/utilities'; -import { accountFactory } from '@src/factories'; +import { accountFactory, firewallFactory } from '@src/factories'; import { mockGetAccount } from 'support/intercepts/account'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { mockGetFirewalls } from 'support/intercepts/firewalls'; import { mockCreateLinode, mockGetLinodeTypes, @@ -17,7 +18,7 @@ import { import { ui } from 'support/ui'; import { linodeCreatePage } from 'support/ui/pages'; import { makeFeatureFlagData } from 'support/util/feature-flags'; -import { randomLabel, randomString } from 'support/util/random'; +import { randomLabel, randomNumber, randomString } from 'support/util/random'; import { extendRegion } from 'support/util/regions'; import { @@ -156,6 +157,10 @@ describe('Create Linode with Disk Encryption', () => { label: randomLabel(), region: distributedRegion.id, }); + const mockFirewall = firewallFactory.build({ + id: randomNumber(), + label: randomLabel(), + }); mockAppendFeatureFlags({ gecko2: { @@ -167,6 +172,7 @@ describe('Create Linode with Disk Encryption', () => { mockGetLinodeTypes([mockLinodeType]); mockGetRegionAvailability(distributedRegion.id, []); mockCreateLinode(mockLinode).as('createLinode'); + mockGetFirewalls([mockFirewall]).as('getFirewalls'); cy.visitWithLogin('/linodes/create'); cy.get('[data-qa-linode-region]').within(() => { @@ -185,7 +191,11 @@ describe('Create Linode with Disk Encryption', () => { linodeCreatePage.setLabel(mockLinode.label); linodeCreatePage.setRootPassword(randomString(32)); - + // Select a firewall + linodeCreatePage.selectFirewall( + mockFirewall.label, + 'Public Interface Firewall' + ); // Select mock Nanode plan type. cy.get('[data-qa-plan-row="Nanode 1 GB"]').click(); diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-with-firewall.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-with-firewall.spec.ts index b87f8dea920..c1ff82e4451 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode-with-firewall.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-with-firewall.spec.ts @@ -424,17 +424,8 @@ describe('Create Linode with Firewall (Linode Interfaces)', () => { // Switch to legacy Config Interfaces linodeCreatePage.selectLegacyConfigInterfacesType(); - // Confirm that mocked Firewall is shown in the Autocomplete, and then select it. - cy.findByLabelText('Firewall').should('be.visible'); - cy.get('[data-qa-autocomplete="Firewall"]').within(() => { - cy.get('[data-testid="textfield-input"]').click(); - cy.focused().type(`${mockFirewall.label}`); - }); - - ui.autocompletePopper - .findByTitle(mockFirewall.label) - .should('be.visible') - .click(); + // Select a firewall + linodeCreatePage.selectFirewall(mockFirewall.label, 'Firewall'); // Confirm Firewall assignment indicator is shown in Linode summary. cy.get('[data-qa-linode-create-summary]').scrollIntoView(); @@ -494,17 +485,10 @@ describe('Create Linode with Firewall (Linode Interfaces)', () => { // Confirm the Linode Interfaces section is shown. assertNewLinodeInterfacesIsAvailable(); - // Confirm that mocked Firewall is shown in the Autocomplete, and then select it. - cy.findByLabelText('Public Interface Firewall').should('be.visible'); - cy.get('[data-qa-autocomplete="Public Interface Firewall"]').within(() => { - cy.get('[data-testid="textfield-input"]').click(); - cy.focused().type(`${mockFirewall.label}`); - }); - - ui.autocompletePopper - .findByTitle(mockFirewall.label) - .should('be.visible') - .click(); + linodeCreatePage.selectFirewall( + mockFirewall.label, + 'Public Interface Firewall' + ); // Confirm Firewall assignment indicator is shown in Linode summary. cy.get('[data-qa-linode-create-summary]').scrollIntoView(); @@ -593,17 +577,7 @@ describe('Create Linode with Firewall (Linode Interfaces)', () => { `Firewall ${mockFirewall.label} successfully created` ); - // Confirm that mocked Firewall is shown in the Autocomplete, and then select it. - cy.findByLabelText('Firewall').should('be.visible'); - cy.get('[data-qa-autocomplete="Firewall"]').within(() => { - cy.get('[data-testid="textfield-input"]').click(); - cy.focused().type(`${mockFirewall.label}`); - }); - - ui.autocompletePopper - .findByTitle(mockFirewall.label) - .should('be.visible') - .click(); + linodeCreatePage.selectFirewall(mockFirewall.label, 'Firewall'); // Confirm Firewall assignment indicator is shown in Linode summary. cy.get('[data-qa-linode-create-summary]').scrollIntoView(); @@ -688,17 +662,10 @@ describe('Create Linode with Firewall (Linode Interfaces)', () => { `Firewall ${mockFirewall.label} successfully created` ); - // Confirm that mocked Firewall is shown in the Autocomplete, and then select it. - cy.findByLabelText('Public Interface Firewall').should('be.visible'); - cy.get('[data-qa-autocomplete="Public Interface Firewall"]').within(() => { - cy.get('[data-testid="textfield-input"]').click(); - cy.focused().type(`${mockFirewall.label}`); - }); - - ui.autocompletePopper - .findByTitle(mockFirewall.label) - .should('be.visible') - .click(); + linodeCreatePage.selectFirewall( + mockFirewall.label, + 'Public Interface Firewall' + ); // Confirm Firewall assignment indicator is shown in Linode summary. cy.get('[data-qa-linode-create-summary]').scrollIntoView(); @@ -936,6 +903,150 @@ describe('Create Linode with Firewall (Linode Interfaces)', () => { ui.toast.assertMessage(`Your Linode ${mockLinode.label} is being created.`); }); + /* + * Legacy Configuration Profile Interfaces + * - Confirms UI flow to create a Linode with "No firewall" option. + * - Confirms that no firewall is reflected in create summary section. + * - Confirms that outgoing Linode Create API request specifies no Firewall to be attached. + */ + it('can assign "No firewall" option during Linode Create flow (legacy)', () => { + const mockLinode = linodeFactory.build({ + id: randomNumber(), + label: randomLabel(), + region: linodeRegion.id, + }); + + mockCreateLinode(mockLinode).as('createLinode'); + mockGetLinodeDetails(mockLinode.id, mockLinode); + + cy.visitWithLogin('/linodes/create'); + + linodeCreatePage.setLabel(mockLinode.label); + linodeCreatePage.selectImage('Debian 12'); + linodeCreatePage.selectRegionById(linodeRegion.id); + linodeCreatePage.selectPlan('Shared CPU', 'Nanode 1 GB'); + linodeCreatePage.setRootPassword(randomString(32)); + + // Confirm the Linode Interfaces section is shown. + assertNewLinodeInterfacesIsAvailable(); + + // Switch to legacy Config Interfaces + linodeCreatePage.selectLegacyConfigInterfacesType(); + + // Select a firewall + linodeCreatePage.selectFirewall( + 'No firewall - traffic is unprotected (not recommended)', + 'Firewall' + ); + + // Create Linode and confirm contents of outgoing API request payload. + ui.button + .findByTitle('Create Linode') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.wait('@createLinode').then((xhr) => { + const requestPayload = xhr.request.body; + const firewallId = requestPayload['firewall_id']; + expect(firewallId).to.equal(-1); + }); + + // Confirm redirect to new Linode. + cy.url().should('endWith', `/linodes/${mockLinode.id}`); + // Confirm toast notification should appear on Linode create. + ui.toast.assertMessage(`Your Linode ${mockLinode.label} is being created.`); + }); + + /* + * Linode Interfaces + * - Confirms UI flow to create a Linode with "No firewall" option. + * - Confirms that no firewall is reflected in create summary section. + * - Confirms that outgoing Linode Create API request specifies no Firewall to be attached. + */ + it('can assign "No firewall" option during Linode Create flow (Linode Interfaces)', () => { + const mockLinode = linodeFactory.build({ + id: randomNumber(), + label: randomLabel(), + region: linodeRegion.id, + }); + + mockCreateLinode(mockLinode).as('createLinode'); + mockGetLinodeDetails(mockLinode.id, mockLinode); + + cy.visitWithLogin('/linodes/create'); + + linodeCreatePage.setLabel(mockLinode.label); + linodeCreatePage.selectImage('Debian 12'); + linodeCreatePage.selectRegionById(linodeRegion.id); + linodeCreatePage.selectPlan('Shared CPU', 'Nanode 1 GB'); + linodeCreatePage.setRootPassword(randomString(32)); + + // Confirm the Linode Interfaces section is shown. + assertNewLinodeInterfacesIsAvailable(); + + linodeCreatePage.selectFirewall( + 'No firewall - traffic is unprotected (not recommended)', + 'Public Interface Firewall' + ); + + // Create Linode and confirm contents of outgoing API request payload. + ui.button + .findByTitle('Create Linode') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.wait('@createLinode').then((xhr) => { + const requestPayload = xhr.request.body; + const firewallId = requestPayload['interfaces'][0]['firewall_id']; + expect(firewallId).to.equal(-1); + }); + + // Confirm redirect to new Linode. + cy.url().should('endWith', `/linodes/${mockLinode.id}`); + // Confirm toast notification should appear on Linode create. + ui.toast.assertMessage(`Your Linode ${mockLinode.label} is being created.`); + }); + + /* + * Mocks no selection made in firewall dropdown. + * Confirms that correct validation error message is shown on the page when attempting to create a Linode. + */ + it('displays validation error related to firewall if no selection made in firewall dropdown', () => { + const mockLinode = linodeFactory.build({ + id: randomNumber(), + label: randomLabel(), + region: linodeRegion.id, + }); + + mockCreateLinode(mockLinode).as('createLinode'); + mockGetLinodeDetails(mockLinode.id, mockLinode); + + cy.visitWithLogin('/linodes/create'); + + linodeCreatePage.setLabel(mockLinode.label); + linodeCreatePage.selectImage('Debian 12'); + linodeCreatePage.selectRegionById(linodeRegion.id); + linodeCreatePage.selectPlan('Shared CPU', 'Nanode 1 GB'); + linodeCreatePage.setRootPassword(randomString(32)); + + // Confirm the Linode Interfaces section is shown. + assertNewLinodeInterfacesIsAvailable(); + + // Create Linode and confirm contents of outgoing API request payload. + ui.button + .findByTitle('Create Linode') + .should('be.visible') + .should('be.enabled') + .click(); + + // Confirm the correct validation error related to firewall show up on the page. + cy.findByText('Select an option or create a new Firewall.').should( + 'be.visible' + ); + }); + /* * - Mocks the internal header to enable the Generate Compliant Firewall banner. * - Mocks an error response to the Create Firewall call. diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-with-ssh-key.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-with-ssh-key.spec.ts index fac8dd00c69..b15ecf35878 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode-with-ssh-key.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-with-ssh-key.spec.ts @@ -1,5 +1,6 @@ import { linodeFactory, sshKeyFactory } from '@linode/utilities'; import { mockGetUser, mockGetUsers } from 'support/intercepts/account'; +import { mockGetFirewalls } from 'support/intercepts/firewalls'; import { mockCreateLinode } from 'support/intercepts/linodes'; import { mockCreateSSHKey } from 'support/intercepts/profile'; import { ui } from 'support/ui'; @@ -7,9 +8,13 @@ import { linodeCreatePage } from 'support/ui/pages'; import { randomLabel, randomNumber, randomString } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; -import { accountUserFactory } from 'src/factories'; +import { accountUserFactory, firewallFactory } from 'src/factories'; describe('Create Linode with SSH Key', () => { + const mockFirewall = firewallFactory.build({ + id: randomNumber(), + label: randomLabel(), + }); /* * - Confirms UI flow when creating a Linode with an authorized SSH key. * - Confirms that existing SSH keys are listed on page and can be selected. @@ -34,6 +39,7 @@ describe('Create Linode with SSH Key', () => { mockGetUsers([mockUser]); mockGetUser(mockUser); + mockGetFirewalls([mockFirewall]).as('getFirewalls'); mockCreateLinode(mockLinode).as('createLinode'); cy.visitWithLogin('/linodes/create'); @@ -43,6 +49,11 @@ describe('Create Linode with SSH Key', () => { linodeCreatePage.selectRegionById(linodeRegion.id); linodeCreatePage.selectPlan('Shared CPU', 'Nanode 1 GB'); linodeCreatePage.setRootPassword(randomString(32)); + // Select a firewall + linodeCreatePage.selectFirewall( + mockFirewall.label, + 'Public Interface Firewall' + ); // Confirm that SSH key is listed, then select it. cy.findByText(mockSshKey.label).scrollIntoView(); @@ -101,6 +112,7 @@ describe('Create Linode with SSH Key', () => { mockGetUsers([mockUser]); mockCreateLinode(mockLinode).as('createLinode'); mockCreateSSHKey(mockSshKey).as('createSSHKey'); + mockGetFirewalls([mockFirewall]).as('getFirewalls'); cy.visitWithLogin('/linodes/create'); @@ -109,6 +121,11 @@ describe('Create Linode with SSH Key', () => { linodeCreatePage.selectRegionById(linodeRegion.id); linodeCreatePage.selectPlan('Shared CPU', 'Nanode 1 GB'); linodeCreatePage.setRootPassword(randomString(32)); + // Select a firewall + linodeCreatePage.selectFirewall( + mockFirewall.label, + 'Public Interface Firewall' + ); // Confirm that no SSH keys are listed for the mocked user. cy.findByText(mockUser.username).scrollIntoView(); diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-with-user-data.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-with-user-data.spec.ts index 9fb53cba649..8330b471b9d 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode-with-user-data.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-with-user-data.spec.ts @@ -1,4 +1,5 @@ import { linodeFactory, regionFactory } from '@linode/utilities'; +import { mockGetFirewalls } from 'support/intercepts/firewalls'; import { mockGetAllImages, mockGetImage } from 'support/intercepts/images'; import { mockCreateLinode, @@ -10,7 +11,7 @@ import { linodeCreatePage } from 'support/ui/pages'; import { randomLabel, randomNumber, randomString } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; -import { imageFactory } from 'src/factories'; +import { firewallFactory, imageFactory } from 'src/factories'; describe('Create Linode with user data', () => { /* @@ -26,10 +27,15 @@ describe('Create Linode with user data', () => { label: randomLabel(), region: linodeRegion.id, }); + const mockFirewall = firewallFactory.build({ + id: randomNumber(), + label: randomLabel(), + }); const userDataFixturePath = 'user-data/user-data-config-basic.yml'; mockCreateLinode(mockLinode).as('createLinode'); mockGetLinodeDetails(mockLinode.id, mockLinode); + mockGetFirewalls([mockFirewall]).as('getFirewalls'); cy.visitWithLogin('/linodes/create'); @@ -40,6 +46,11 @@ describe('Create Linode with user data', () => { linodeCreatePage.selectRegionById(linodeRegion.id); linodeCreatePage.selectPlan('Shared CPU', 'Nanode 1 GB'); linodeCreatePage.setRootPassword(randomString(32)); + // Select a firewall + linodeCreatePage.selectFirewall( + mockFirewall.label, + 'Public Interface Firewall' + ); // Expand "Add User Data" accordion and enter user data config. ui.accordionHeading diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-with-vlan.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-with-vlan.spec.ts index 1ed9356b23b..f55e562ee2b 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode-with-vlan.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-with-vlan.spec.ts @@ -4,6 +4,7 @@ import { mockGetAccountSettings, } from 'support/intercepts/account'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { mockGetFirewalls } from 'support/intercepts/firewalls'; import { mockCreateLinode } from 'support/intercepts/linodes'; import { mockGetRegion, mockGetRegions } from 'support/intercepts/regions'; import { mockGetVLANs } from 'support/intercepts/vlans'; @@ -21,6 +22,7 @@ import { chooseRegion } from 'support/util/regions'; import { accountFactory, accountSettingsFactory, + firewallFactory, VLANFactory, } from 'src/factories'; @@ -56,7 +58,13 @@ describe('Create Linode with VLANs (Legacy)', () => { region: mockLinodeRegion.id, }); + const mockFirewall = firewallFactory.build({ + id: randomNumber(), + label: randomLabel(), + }); + mockGetVLANs([mockVlan]); + mockGetFirewalls([mockFirewall]).as('getFirewalls'); mockCreateLinode(mockLinode).as('createLinode'); cy.visitWithLogin('/linodes/create'); @@ -89,6 +97,9 @@ describe('Create Linode with VLANs (Legacy)', () => { .type(mockVlan.cidr_block); }); + // Select a firewall + linodeCreatePage.selectFirewall(mockFirewall.label, 'Assign Firewall'); + // Confirm that VLAN attachment is listed in summary, then create Linode. cy.get('[data-qa-linode-create-summary]').scrollIntoView(); cy.get('[data-qa-linode-create-summary]').within(() => { @@ -146,7 +157,13 @@ describe('Create Linode with VLANs (Legacy)', () => { region: mockLinodeRegion.id, }); + const mockFirewall = firewallFactory.build({ + id: randomNumber(), + label: randomLabel(), + }); + mockGetVLANs([]); + mockGetFirewalls([mockFirewall]).as('getFirewalls'); mockCreateLinode(mockLinode).as('createLinode'); cy.visitWithLogin('/linodes/create'); @@ -175,6 +192,9 @@ describe('Create Linode with VLANs (Legacy)', () => { .click(); }); + // Select a firewall + linodeCreatePage.selectFirewall(mockFirewall.label, 'Assign Firewall'); + // Confirm that VLAN attachment is listed in summary, then create Linode. cy.get('[data-qa-linode-create-summary]').scrollIntoView(); cy.get('[data-qa-linode-create-summary]').within(() => { @@ -305,7 +325,13 @@ describe('Create Linode with VLANs (Linode Interfaces)', () => { region: mockLinodeRegion.id, }); + const mockFirewall = firewallFactory.build({ + id: randomNumber(), + label: randomLabel(), + }); + mockGetVLANs([mockVlan]); + mockGetFirewalls([mockFirewall]).as('getFirewalls'); mockCreateLinode(mockLinode).as('createLinode'); cy.visitWithLogin('/linodes/create'); @@ -338,6 +364,9 @@ describe('Create Linode with VLANs (Linode Interfaces)', () => { .should('be.enabled') .type(mockVlan.cidr_block); + // Select a firewall + linodeCreatePage.selectFirewall(mockFirewall.label, 'Firewall'); + // Confirm that VLAN attachment is listed in summary, then create Linode. cy.get('[data-qa-linode-create-summary]').scrollIntoView(); cy.get('[data-qa-linode-create-summary]').within(() => { @@ -471,7 +500,13 @@ describe('Create Linode with VLANs (Linode Interfaces)', () => { region: mockLinodeRegion.id, }); + const mockFirewall = firewallFactory.build({ + id: randomNumber(), + label: randomLabel(), + }); + mockGetVLANs([]); + mockGetFirewalls([mockFirewall]).as('getFirewalls'); mockCreateLinode(mockLinode).as('createLinode'); cy.visitWithLogin('/linodes/create'); @@ -505,6 +540,9 @@ describe('Create Linode with VLANs (Linode Interfaces)', () => { .should('be.enabled') .type(mockVlan.cidr_block); + // Select a firewall + linodeCreatePage.selectFirewall(mockFirewall.label, 'Firewall'); + // Confirm that VLAN attachment is listed in summary, then create Linode. cy.get('[data-qa-linode-create-summary]').scrollIntoView(); cy.get('[data-qa-linode-create-summary]').within(() => { diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-with-vpc.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-with-vpc.spec.ts index 0f9fa5ab253..b1a97b5da75 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode-with-vpc.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-with-vpc.spec.ts @@ -9,6 +9,7 @@ import { } from 'support/intercepts/account'; import { mockGetLinodeConfig } from 'support/intercepts/configs'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { mockGetFirewalls } from 'support/intercepts/firewalls'; import { mockCreateLinode, mockGetLinodeDetails, @@ -36,6 +37,7 @@ import { chooseRegion } from 'support/util/regions'; import { accountFactory, accountSettingsFactory, + firewallFactory, linodeConfigFactory, subnetFactory, vpcFactory, @@ -106,8 +108,14 @@ describe('Create Linode with VPCs (Legacy)', () => { ], }; + const mockFirewall = firewallFactory.build({ + id: randomNumber(), + label: randomLabel(), + }); + mockGetVPCs([mockVPC]).as('getVPCs'); mockGetVPC(mockVPC).as('getVPC'); + mockGetFirewalls([mockFirewall]).as('getFirewalls'); mockCreateLinode(mockLinode).as('createLinode'); mockGetLinodeDetails(mockLinode.id, mockLinode); @@ -137,6 +145,9 @@ describe('Create Linode with VPCs (Legacy)', () => { `${mockSubnet.label} (${mockSubnet.ipv4})` ); + // Select a firewall for the VPC interface + linodeCreatePage.selectFirewall(mockFirewall.label, 'Assign Firewall'); + // Confirm VPC assignment indicator is shown in Linode summary. cy.get('[data-qa-linode-create-summary]').scrollIntoView(); cy.get('[data-qa-linode-create-summary]').within(() => { @@ -240,7 +251,13 @@ describe('Create Linode with VPCs (Legacy)', () => { ], }; + const mockFirewall = firewallFactory.build({ + id: randomNumber(), + label: randomLabel(), + }); + mockGetVPCs([]); + mockGetFirewalls([mockFirewall]).as('getFirewalls'); mockCreateLinode(mockLinode).as('createLinode'); cy.visitWithLogin('/linodes/create'); @@ -293,6 +310,9 @@ describe('Create Linode with VPCs (Legacy)', () => { cy.findByLabelText('Clear').click(); }); + // Select a firewall for the VPC interface + linodeCreatePage.selectFirewall(mockFirewall.label, 'Assign Firewall'); + // Try to submit the form without a subnet selected ui.button .findByTitle('Create Linode') @@ -460,6 +480,11 @@ describe('Create Linode with VPCs (Linode Interfaces)', () => { ], }; + const mockFirewall = firewallFactory.build({ + id: randomNumber(), + label: randomLabel(), + }); + mockGetFirewalls([mockFirewall]).as('getFirewalls'); mockGetVPCs([mockVPC]).as('getVPCs'); mockGetVPC(mockVPC).as('getVPC'); mockCreateLinode(mockLinode).as('createLinode'); @@ -500,6 +525,9 @@ describe('Create Linode with VPCs (Linode Interfaces)', () => { `${mockSubnet.label} (${mockSubnet.ipv4})` ); + // Select a firewall for the VPC interface + linodeCreatePage.selectFirewall(mockFirewall.label, 'Firewall'); + // Confirm VPC assignment indicator is shown in Linode summary. cy.get('[data-qa-linode-create-summary]').scrollIntoView(); cy.get('[data-qa-linode-create-summary]').within(() => { @@ -572,6 +600,11 @@ describe('Create Linode with VPCs (Linode Interfaces)', () => { region: linodeRegion.id, }); + const mockFirewall = firewallFactory.build({ + id: randomNumber(), + label: randomLabel(), + }); + const mockInterface = linodeConfigInterfaceFactoryWithVPC.build({ active: true, primary: true, @@ -601,6 +634,7 @@ describe('Create Linode with VPCs (Linode Interfaces)', () => { mockGetVPCs([mockVPC]).as('getVPCs'); mockGetVPC(mockVPC).as('getVPC'); + mockGetFirewalls([mockFirewall]).as('getFirewalls'); mockCreateLinode(mockLinode).as('createLinode'); mockGetLinodeDetails(mockLinode.id, mockLinode); @@ -636,6 +670,12 @@ describe('Create Linode with VPCs (Linode Interfaces)', () => { `${mockSubnet.label} (${mockSubnet.ipv4})` ); + // Select a firewall for the VPC interface + linodeCreatePage.selectFirewall( + mockFirewall.label, + 'VPC Interface Firewall' + ); + // Confirm VPC assignment indicator is shown in Linode summary. cy.get('[data-qa-linode-create-summary]').scrollIntoView(); cy.get('[data-qa-linode-create-summary]').within(() => { @@ -737,6 +777,13 @@ describe('Create Linode with VPCs (Linode Interfaces)', () => { ], }; + const mockFirewall = firewallFactory.build({ + id: randomNumber(), + label: randomLabel(), + }); + + mockGetFirewalls([mockFirewall]).as('getFirewalls'); + mockGetVPCs([]); mockCreateLinode(mockLinode).as('createLinode'); cy.visitWithLogin('/linodes/create'); @@ -755,6 +802,8 @@ describe('Create Linode with VPCs (Linode Interfaces)', () => { // Select VPC card linodeCreatePage.selectInterface('vpc'); + // Select a firewall for the VPC interface + linodeCreatePage.selectFirewall(mockFirewall.label, 'Firewall'); cy.findByText('Create VPC').should('be.visible').click(); @@ -896,6 +945,11 @@ describe('Create Linode with VPCs (Linode Interfaces)', () => { region: linodeRegion.id, }); + const mockFirewall = firewallFactory.build({ + id: randomNumber(), + label: randomLabel(), + }); + const mockInterface = linodeConfigInterfaceFactoryWithVPC.build({ active: true, primary: true, @@ -924,6 +978,7 @@ describe('Create Linode with VPCs (Linode Interfaces)', () => { }; mockGetVPCs([]); + mockGetFirewalls([mockFirewall]).as('getFirewalls'); mockCreateLinode(mockLinode).as('createLinode'); cy.visitWithLogin('/linodes/create'); @@ -1009,6 +1064,12 @@ describe('Create Linode with VPCs (Linode Interfaces)', () => { .should('be.visible') .click(); + // Select a firewall for the VPC interface + linodeCreatePage.selectFirewall( + mockFirewall.label, + 'VPC Interface Firewall' + ); + // Create Linode and confirm contents of outgoing API request payload. ui.button .findByTitle('Create Linode') diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts index eb6eec87f92..dd43845b810 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts @@ -13,6 +13,7 @@ import { authenticate } from 'support/api/authentication'; import { LINODE_CREATE_TIMEOUT } from 'support/constants/linodes'; import { mockGetAccount, mockGetUser } from 'support/intercepts/account'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { mockGetFirewalls } from 'support/intercepts/firewalls'; import { interceptCreateLinode, mockCreateLinode, @@ -31,10 +32,17 @@ import { cleanUp } from 'support/util/cleanup'; import { randomLabel, randomNumber, randomString } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; -import { accountFactory, accountUserFactory } from 'src/factories'; +import { + accountFactory, + accountUserFactory, + firewallFactory, +} from 'src/factories'; let username: string; - +const mockFirewall = firewallFactory.build({ + id: randomNumber(), + label: randomLabel(), +}); authenticate(); describe('Create Linode', () => { before(() => { @@ -54,6 +62,12 @@ describe('Create Linode', () => { describe('End-to-end', () => { // Run an end-to-end test to create a basic Linode for each plan type described below. describe('By plan type', () => { + beforeEach(() => { + mockAppendFeatureFlags({ + linodeInterfaces: { enabled: true }, + }); + mockGetFirewalls([mockFirewall]).as('getFirewalls'); + }); [ { planId: 'g6-nanode-1', @@ -81,6 +95,7 @@ describe('Create Linode', () => { const linodeRegion = chooseRegion({ capabilities: ['Linodes', 'Vlans'], }); + mockGetFirewalls([mockFirewall]).as('getFirewalls'); const linodeLabel = randomLabel(); @@ -108,6 +123,11 @@ describe('Create Linode', () => { planConfig.planLabel ); linodeCreatePage.setRootPassword(randomString(32)); + // Select a firewall + linodeCreatePage.selectFirewall( + 'No firewall - traffic is unprotected (not recommended)', + 'Public Interface Firewall' + ); // Confirm information in summary is shown as expected. cy.get('[data-qa-linode-create-summary]').scrollIntoView(); @@ -227,6 +247,7 @@ describe('Create Linode', () => { }).as('getFeatureFlags'); mockGetRegions(mockRegions).as('getRegions'); mockGetLinodeTypes([...mockAcceleratedType]).as('getLinodeTypes'); + mockGetFirewalls([mockFirewall]).as('getFirewalls'); mockCreateLinode(mockLinode).as('createLinode'); cy.visitWithLogin('/linodes/create'); @@ -243,6 +264,8 @@ describe('Create Linode', () => { linodeCreatePage.selectRegionById(linodeRegion.id); linodeCreatePage.selectPlan('Accelerated', mockAcceleratedType[0].label); linodeCreatePage.setRootPassword(randomString(32)); + // Select a firewall + linodeCreatePage.selectFirewall(mockFirewall.label, 'Assign Firewall'); // Confirm information in summary is shown as expected. cy.get('[data-qa-linode-create-summary]').scrollIntoView(); @@ -297,6 +320,7 @@ describe('Create Linode', () => { const createLinodeErrorMessage = 'An error has occurred during Linode creation flow'; + mockGetFirewalls([mockFirewall]).as('getFirewalls'); mockCreateLinodeError(createLinodeErrorMessage).as('createLinodeError'); cy.visitWithLogin('/linodes/create'); @@ -306,6 +330,8 @@ describe('Create Linode', () => { linodeCreatePage.selectRegionById(linodeRegion.id); linodeCreatePage.selectPlan('Shared CPU', 'Nanode 1 GB'); linodeCreatePage.setRootPassword(randomString(32)); + // Select a firewall + linodeCreatePage.selectFirewall(mockFirewall.label, 'Assign Firewall'); // Create Linode by clicking the button. ui.button diff --git a/packages/manager/cypress/e2e/core/linodes/smoke-linode-landing-table.spec.ts b/packages/manager/cypress/e2e/core/linodes/smoke-linode-landing-table.spec.ts index efaa1174406..f4c1a1ba98f 100644 --- a/packages/manager/cypress/e2e/core/linodes/smoke-linode-landing-table.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/smoke-linode-landing-table.spec.ts @@ -93,21 +93,29 @@ describe('linode landing checks', () => { it('checks the landing page side menu items', () => { cy.findByTitle('Akamai - Cloud Manager').should('be.visible'); - cy.findByTestId('menu-item-Linodes').should('be.visible'); - cy.findByTestId('menu-item-Volumes').should('be.visible'); - cy.findByTestId('menu-item-NodeBalancers').should('be.visible'); - cy.findByTestId('menu-item-Firewalls').should('be.visible'); - cy.findByTestId('menu-item-StackScripts').should('be.visible'); - cy.findByTestId('menu-item-Images').should('be.visible'); - cy.findByTestId('menu-item-Domains').should('be.visible'); - cy.findByTestId('menu-item-Kubernetes').should('be.visible'); - cy.findByTestId('menu-item-Object Storage').should('be.visible'); - cy.findByTestId('menu-item-Longview').should('be.visible'); - cy.findByTestId('menu-item-Marketplace').should('be.visible'); - cy.findByTestId('menu-item-Billing').scrollIntoView(); - cy.findByTestId('menu-item-Billing').should('be.visible'); - cy.findByTestId('menu-item-Account Settings').should('be.visible'); - cy.findByTestId('menu-item-Help & Support').should('be.visible'); + + const expectedNavItems = [ + 'Linodes', + 'Volumes', + 'NodeBalancers', + 'Firewalls', + 'StackScripts', + 'Images', + 'Domains', + 'Kubernetes', + 'Object Storage', + 'Longview', + 'Quick Deploy Apps', + 'Partner Referrals', + 'Billing', + 'Account Settings', + 'Help & Support', + ]; + + expectedNavItems.forEach((navItem) => { + ui.nav.findItemByTitle(navItem).scrollIntoView(); + ui.nav.findItemByTitle(navItem).should('be.visible'); + }); }); it('checks the landing top menu items', () => { @@ -271,7 +279,7 @@ describe('linode landing checks', () => { cy.get(commonLocators.topMenuCreateItemsLocator.marketplaceOneClickLink) .should('be.visible') .within(() => { - cy.findByText('Marketplace').should('be.visible'); + cy.findByText('Quick Deploy Apps').should('be.visible'); cy.findByText('Deploy applications with ease').should('be.visible'); }); }); diff --git a/packages/manager/cypress/e2e/core/objectStorage/enable-object-storage.spec.ts b/packages/manager/cypress/e2e/core/objectStorage/enable-object-storage.spec.ts index 708b19561b8..ce5a8e16584 100644 --- a/packages/manager/cypress/e2e/core/objectStorage/enable-object-storage.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorage/enable-object-storage.spec.ts @@ -5,7 +5,6 @@ import { profileFactory, regionFactory } from '@linode/utilities'; import { accountFactory, accountSettingsFactory, - objectStorageClusterFactory, objectStorageKeyFactory, } from '@src/factories'; import { @@ -19,7 +18,6 @@ import { mockCreateAccessKey, mockGetAccessKeys, mockGetBuckets, - mockGetClusters, mockGetObjectStorageTypes, } from 'support/intercepts/object-storage'; import { mockGetProfile } from 'support/intercepts/profile'; @@ -27,13 +25,7 @@ import { mockGetRegions } from 'support/intercepts/regions'; import { ui } from 'support/ui'; import { randomLabel } from 'support/util/random'; -import type { - AccountSettings, - ObjectStorageCluster, - ObjectStorageClusterID, - PriceType, - Region, -} from '@linode/api-v4'; +import type { AccountSettings, PriceType, Region } from '@linode/api-v4'; // Various messages, notes, and warnings that may be shown when enabling Object Storage // under different circumstances. @@ -101,29 +93,6 @@ describe('Object Storage enrollment', () => { }), ]; - // Clusters with special pricing are currently hardcoded rather than - // retrieved via API, so we have to mock the cluster API request to correspond - // with that hardcoded data. - // - // Because the IDs used in the mocks don't correspond with any actual clusters, - // we have to cast them as `ObjectStorageClusterID` to satisfy TypeScript. - const mockClusters: ObjectStorageCluster[] = [ - // Regions with special pricing. - objectStorageClusterFactory.build({ - id: 'br-gru-0' as ObjectStorageClusterID, - region: 'br-gru', - }), - objectStorageClusterFactory.build({ - id: 'id-cgk-1' as ObjectStorageClusterID, - region: 'id-cgk', - }), - // A region that does not have special pricing. - objectStorageClusterFactory.build({ - id: 'us-east-1', - region: 'us-east', - }), - ]; - const mockPrices: PriceType[] = [ { id: 'distributed_network_transfer', @@ -209,18 +178,12 @@ describe('Object Storage enrollment', () => { 'getObjectStorageTypes' ); mockGetAccountSettings(mockAccountSettings).as('getAccountSettings'); - mockGetClusters(mockClusters).as('getClusters'); mockGetBuckets([]).as('getBuckets'); mockGetRegions(mockRegions).as('getRegions'); mockGetAccessKeys([]); cy.visitWithLogin('/object-storage/buckets'); - cy.wait([ - '@getAccountSettings', - '@getClusters', - '@getBuckets', - '@getRegions', - ]); + cy.wait(['@getAccountSettings', '@getBuckets', '@getRegions']); // Confirm that empty-state message is shown before proceeding. cy.findByText('S3-compatible storage solution').should('be.visible'); diff --git a/packages/manager/cypress/e2e/core/objectStorage/object-storage-summary-page.spec.ts b/packages/manager/cypress/e2e/core/objectStorage/object-storage-summary-page.spec.ts new file mode 100644 index 00000000000..af828a4a796 --- /dev/null +++ b/packages/manager/cypress/e2e/core/objectStorage/object-storage-summary-page.spec.ts @@ -0,0 +1,183 @@ +import { regionFactory } from '@linode/utilities'; +import { authenticate } from 'support/api/authentication'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { + mockGetObjectStorageEndpoints, + mockGetObjectStorageQuotas, + mockGetObjectStorageQuotaUsages, +} from 'support/intercepts/object-storage'; +import { ui } from 'support/ui'; +import { randomDomainName, randomLabel } from 'support/util/random'; + +import { objectStorageEndpointsFactory } from 'src/factories'; +import { quotaFactory, quotaUsageFactory } from 'src/factories/quotas'; + +const mockFeatureFlags = { + objSummaryPage: true, +}; + +const placeholderText = 'Select an Object Storage S3 endpoint'; + +const mockDomain = randomDomainName(); + +const mockRegions = regionFactory.buildList(4, { + capabilities: ['Object Storage'], +}); + +const mockEndpoints = [ + objectStorageEndpointsFactory.build({ + endpoint_type: 'E0', + region: mockRegions[0].id, + s3_endpoint: `${mockRegions[0].id}-1.${mockDomain}`, + }), + objectStorageEndpointsFactory.build({ + endpoint_type: 'E1', + region: mockRegions[1].id, + s3_endpoint: `${mockRegions[1].id}-1.${mockDomain}`, + }), + objectStorageEndpointsFactory.build({ + endpoint_type: 'E1', + region: mockRegions[2].id, + s3_endpoint: `${mockRegions[2].id}-1.${mockDomain}`, + }), + objectStorageEndpointsFactory.build({ + endpoint_type: 'E2', + region: mockRegions[3].id, + s3_endpoint: `${mockRegions[3].id}-1.${mockDomain}`, + }), +]; + +const mockSelectedEndpoint = mockEndpoints[1]; +const selectedDomain = mockSelectedEndpoint.s3_endpoint || ''; + +const mockQuotas = [ + quotaFactory.build({ + quota_id: `obj-bytes-${selectedDomain}`, + quota_type: 'obj-bytes', + description: randomLabel(50), + endpoint_type: mockSelectedEndpoint.endpoint_type, + quota_limit: 10, + quota_name: 'Total Capacity', + resource_metric: 'byte', + s3_endpoint: selectedDomain, + }), + quotaFactory.build({ + quota_id: `obj-buckets-${selectedDomain}`, + quota_type: 'obj-buckets', + description: randomLabel(50), + endpoint_type: mockSelectedEndpoint.endpoint_type, + quota_limit: 78, + quota_name: 'Number of Objects', + resource_metric: 'bucket', + s3_endpoint: selectedDomain, + }), + quotaFactory.build({ + quota_id: `obj-objects-${selectedDomain}`, + quota_type: 'obj-objects', + description: randomLabel(50), + endpoint_type: mockSelectedEndpoint.endpoint_type, + quota_limit: 400, + quota_name: 'Number of Buckets', + resource_metric: 'object', + s3_endpoint: selectedDomain, + }), +]; + +const mockQuotaUsages = [ + quotaUsageFactory.build({ + quota_limit: mockQuotas[0].quota_limit, + usage: Math.round(mockQuotas[0].quota_limit * 0.1), + }), + quotaUsageFactory.build({ + quota_limit: mockQuotas[1].quota_limit, + usage: Math.round(mockQuotas[1].quota_limit * 0.1), + }), + quotaUsageFactory.build({ + quota_limit: mockQuotas[2].quota_limit, + usage: Math.round(mockQuotas[2].quota_limit * 0.1), + }), +]; + +authenticate(); +describe('Object storage summary page test', () => { + beforeEach(() => { + mockAppendFeatureFlags(mockFeatureFlags).as('getFeatureFlags'); + + mockGetObjectStorageEndpoints(mockEndpoints).as( + 'getObjectStorageEndpoints' + ); + + cy.wrap(selectedDomain).as('selectedDomain'); + cy.wrap(mockEndpoints).as('mockEndpoints'); + cy.wrap(mockQuotas).as('mockQuotas'); + cy.wrap(mockQuotaUsages).as('mockQuotaUsages'); + + mockGetObjectStorageQuotas(selectedDomain, mockQuotas).as('getQuotas'); + + mockGetObjectStorageQuotaUsages( + selectedDomain, + 'bytes', + mockQuotaUsages[0] + ); + + mockGetObjectStorageQuotaUsages( + selectedDomain, + 'buckets', + mockQuotaUsages[1] + ); + + mockGetObjectStorageQuotaUsages( + selectedDomain, + 'objects', + mockQuotaUsages[2] + ).as('getQuotaUsages'); + }); + + it('should display table with user quotas', () => { + cy.visitWithLogin('/object-storage/summary'); + + cy.wait(['@getFeatureFlags', '@getObjectStorageEndpoints']); + + // Object Storage Endpoint field is blank + cy.findByPlaceholderText(placeholderText) + .should('be.visible') + .should('be.enabled'); + + const endpointSelect = ui.autocomplete.findByLabel(''); + endpointSelect.should('be.visible').type(selectedDomain); + ui.autocompletePopper + .findByTitle(selectedDomain, { exact: false }) + .should('be.visible') + .click(); + endpointSelect.click(); + + cy.wait(['@getQuotas', '@getQuotaUsages']); + + cy.findByTestId('table-endpoint-summary') + .find('tbody') + .within(() => { + cy.get('[data-testid="table-row-empty"]').should('not.exist'); + + cy.get('td') + .should('have.length', 3) + .each((_, index) => { + cy.get('td') + .eq(index) + .within(() => { + const { usage } = mockQuotaUsages[index]; + const { quota_limit, resource_metric } = mockQuotas[index]; + + cy.findByText(selectedDomain, { exact: false }).should( + 'be.visible' + ); + cy.findByText(`${usage} of ${quota_limit}`, { + exact: false, + }).should('be.visible'); + cy.findByText(resource_metric, { + exact: false, + }).should('be.visible'); + }); + }); + }); + }); +}); diff --git a/packages/manager/cypress/e2e/core/objectStorage/object-storage.smoke.spec.ts b/packages/manager/cypress/e2e/core/objectStorage/object-storage.smoke.spec.ts index 9d36a170faf..c11d6fd890f 100644 --- a/packages/manager/cypress/e2e/core/objectStorage/object-storage.smoke.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorage/object-storage.smoke.spec.ts @@ -42,7 +42,7 @@ describe('object storage smoke tests', () => { mockGetAccount(accountFactory.build({ capabilities: ['Object Storage'] })); mockAppendFeatureFlags({ gecko2: false, - objMultiCluster: false, + objMultiCluster: true, objectStorageGen2: { enabled: false }, }).as('getFeatureFlags'); @@ -179,22 +179,22 @@ describe('object storage smoke tests', () => { */ it('can delete object storage bucket - smoke', () => { const bucketLabel = randomLabel(); - const bucketCluster = 'us-southeast-1'; + const region = 'us-southeast'; const bucketMock = objectStorageBucketFactory.build({ - cluster: bucketCluster, - hostname: `${bucketLabel}.${bucketCluster}.linodeobjects.com`, + region: region, + hostname: `${bucketLabel}.${region}.linodeobjects.com`, label: bucketLabel, objects: 0, }); mockGetAccount(accountFactory.build({ capabilities: ['Object Storage'] })); mockAppendFeatureFlags({ - objMultiCluster: false, + objMultiCluster: true, objectStorageGen2: { enabled: false }, }); mockGetBuckets([bucketMock]).as('getBuckets'); - mockDeleteBucket(bucketLabel, bucketCluster).as('deleteBucket'); + mockDeleteBucket(bucketLabel, region).as('deleteBucket'); cy.visitWithLogin('/object-storage/buckets'); cy.wait('@getBuckets'); diff --git a/packages/manager/cypress/e2e/core/oneClickApps/one-click-apps.spec.ts b/packages/manager/cypress/e2e/core/oneClickApps/one-click-apps.spec.ts index 8c1e15ab9bd..f1779baa308 100644 --- a/packages/manager/cypress/e2e/core/oneClickApps/one-click-apps.spec.ts +++ b/packages/manager/cypress/e2e/core/oneClickApps/one-click-apps.spec.ts @@ -1,4 +1,5 @@ import { linodeFactory } from '@linode/utilities'; +import { mockGetFirewalls } from 'support/intercepts/firewalls'; import { mockGetAllImages } from 'support/intercepts/images'; import { mockCreateLinode } from 'support/intercepts/linodes'; import { @@ -7,11 +8,12 @@ import { mockGetStackScripts, } from 'support/intercepts/stackscripts'; import { ui } from 'support/ui'; +import { linodeCreatePage } from 'support/ui/pages'; import { getRandomOCAId } from 'support/util/one-click-apps'; -import { randomLabel, randomString } from 'support/util/random'; +import { randomLabel, randomNumber, randomString } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; -import { imageFactory } from 'src/factories'; +import { firewallFactory, imageFactory } from 'src/factories'; import { stackScriptFactory } from 'src/factories/stackscripts'; import { getMarketplaceAppLabel } from 'src/features/Linodes/LinodeCreate/Tabs/Marketplace/utilities'; import { oneClickApps } from 'src/features/OneClickApps/oneClickApps'; @@ -166,10 +168,15 @@ describe('OneClick Apps (OCA)', () => { const linode = linodeFactory.build({ label: linodeLabel, }); + const mockFirewall = firewallFactory.build({ + id: randomNumber(), + label: randomLabel(), + }); mockGetAllImages(images); mockGetStackScripts([stackscript]).as('getStackScripts'); mockGetStackScript(stackscript.id, stackscript); + mockGetFirewalls([mockFirewall]).as('getFirewalls'); cy.visitWithLogin(`/linodes/create/marketplace`); @@ -242,6 +249,12 @@ describe('OneClick Apps (OCA)', () => { // Create the Linode mockCreateLinode(linode).as('createLinode'); + // Select a firewall + linodeCreatePage.selectFirewall( + mockFirewall.label, + 'Public Interface Firewall' + ); + ui.button .findByTitle('Create Linode') .should('be.visible') diff --git a/packages/manager/cypress/e2e/core/parentChild/account-switching.spec.ts b/packages/manager/cypress/e2e/core/parentChild/account-switching.spec.ts index 3d88c570b0d..0e59a8ba6a4 100644 --- a/packages/manager/cypress/e2e/core/parentChild/account-switching.spec.ts +++ b/packages/manager/cypress/e2e/core/parentChild/account-switching.spec.ts @@ -22,6 +22,7 @@ import { mockGetUser, } from 'support/intercepts/account'; import { mockGetEvents, mockGetNotifications } from 'support/intercepts/events'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { mockAllApiRequests } from 'support/intercepts/general'; import { mockGetRolePermissionsError, @@ -151,6 +152,10 @@ const mockAlternateChildAccountToken = appTokenFactory.build({ const mockErrorMessage = 'An unknown error has occurred.'; describe('Parent/Child account switching', () => { + beforeEach(() => { + // Disable IAM delegation to use legacy child accounts flow for all tests + mockAppendFeatureFlags({ iamDelegation: false }); + }); /* * Tests to confirm that Parent account users can switch to Child accounts as expected. */ @@ -361,7 +366,7 @@ describe('Parent/Child account switching', () => { // Confirm no results message. mockGetChildAccounts([]).as('getEmptySearchResults'); cy.findByPlaceholderText('Search').click(); - cy.focused().type('Fake Name'); + cy.focused().type('Fake Name', { delay: 50 }); cy.wait('@getEmptySearchResults'); cy.contains(mockChildAccount.company).should('not.exist'); @@ -373,7 +378,7 @@ describe('Parent/Child account switching', () => { mockGetChildAccounts([mockChildAccount]).as('getSearchResults'); cy.findByPlaceholderText('Search').click(); cy.focused().clear(); - cy.focused().type(mockChildAccount.company); + cy.focused().type(mockChildAccount.company, { delay: 50 }); cy.wait('@getSearchResults'); cy.findByText(mockChildAccount.company).should('be.visible'); diff --git a/packages/manager/cypress/e2e/core/placementGroups/create-linode-with-placement-groups.spec.ts b/packages/manager/cypress/e2e/core/placementGroups/create-linode-with-placement-groups.spec.ts index dfd189af374..f592606a2e1 100644 --- a/packages/manager/cypress/e2e/core/placementGroups/create-linode-with-placement-groups.spec.ts +++ b/packages/manager/cypress/e2e/core/placementGroups/create-linode-with-placement-groups.spec.ts @@ -1,5 +1,6 @@ import { linodeFactory, regionFactory } from '@linode/utilities'; import { mockGetAccount } from 'support/intercepts/account'; +import { mockGetFirewalls } from 'support/intercepts/firewalls'; import { mockCreateLinode, mockGetLinodeDetails, @@ -11,10 +12,14 @@ import { import { mockGetRegions } from 'support/intercepts/regions'; import { ui } from 'support/ui/'; import { linodeCreatePage } from 'support/ui/pages'; -import { randomNumber, randomString } from 'support/util/random'; +import { randomLabel, randomNumber, randomString } from 'support/util/random'; import { extendRegion } from 'support/util/regions'; -import { accountFactory, placementGroupFactory } from 'src/factories'; +import { + accountFactory, + firewallFactory, + placementGroupFactory, +} from 'src/factories'; import { CANNOT_CHANGE_PLACEMENT_GROUP_POLICY_MESSAGE } from 'src/features/PlacementGroups/constants'; const mockAccount = accountFactory.build(); @@ -37,12 +42,18 @@ const mockDallasRegion = extendRegion( }) ); +const mockFirewall = firewallFactory.build({ + id: randomNumber(), + label: randomLabel(), +}); + const mockRegions = [mockNewarkRegion, mockDallasRegion]; describe('Linode create flow with Placement Group', () => { beforeEach(() => { mockGetAccount(mockAccount); mockGetRegions(mockRegions).as('getRegions'); + mockGetFirewalls([mockFirewall]).as('getFirewalls'); }); /* @@ -90,6 +101,8 @@ describe('Linode create flow with Placement Group', () => { // Choose plan cy.findByText('Shared CPU').click(); cy.get('[id="g6-nanode-1"]').click(); + // Select a firewall + linodeCreatePage.selectFirewall(mockFirewall.label, 'Assign Firewall'); // Choose Placement Group // No Placement Group available @@ -241,6 +254,8 @@ describe('Linode create flow with Placement Group', () => { linodeCreatePage.selectPlan('Shared CPU', 'Nanode 1 GB'); linodeCreatePage.setRootPassword(randomString(32)); linodeCreatePage.setLabel(mockLinode.label); + // Select a firewall + linodeCreatePage.selectFirewall(mockFirewall.label, 'Assign Firewall'); // Confirm that mocked Placement Group is shown in the Autocomplete, and then select it. cy.findByText( diff --git a/packages/manager/cypress/e2e/core/stackscripts/create-stackscripts.spec.ts b/packages/manager/cypress/e2e/core/stackscripts/create-stackscripts.spec.ts index 2d793df73f5..903f449da50 100644 --- a/packages/manager/cypress/e2e/core/stackscripts/create-stackscripts.spec.ts +++ b/packages/manager/cypress/e2e/core/stackscripts/create-stackscripts.spec.ts @@ -2,6 +2,7 @@ import { createImage, getLinodeDisks, resizeLinodeDisk } from '@linode/api-v4'; import { createLinodeRequestFactory } from '@linode/utilities'; import { authenticate } from 'support/api/authentication'; import { interceptGetAccountAvailability } from 'support/intercepts/account'; +import { mockGetFirewalls } from 'support/intercepts/firewalls'; import { interceptGetAllImages } from 'support/intercepts/images'; import { interceptCreateLinode } from 'support/intercepts/linodes'; import { @@ -9,6 +10,7 @@ import { interceptGetStackScripts, } from 'support/intercepts/stackscripts'; import { ui } from 'support/ui'; +import { linodeCreatePage } from 'support/ui/pages'; import { SimpleBackoffMethod } from 'support/util/backoff'; import { cleanUp } from 'support/util/cleanup'; import { chooseImage } from 'support/util/images'; @@ -18,10 +20,16 @@ import { pollLinodeDiskSize, pollLinodeStatus, } from 'support/util/polling'; -import { randomLabel, randomPhrase, randomString } from 'support/util/random'; +import { + randomLabel, + randomNumber, + randomPhrase, + randomString, +} from 'support/util/random'; import { chooseRegion, getRegionByLabel } from 'support/util/regions'; import { getFilteredImagesForImageSelect } from 'src/components/ImageSelect/utilities'; +import { firewallFactory } from 'src/factories'; import type { Image } from '@linode/api-v4'; @@ -179,6 +187,11 @@ const createLinodeAndImage = async () => { return image; }; +const mockFirewall = firewallFactory.build({ + id: randomNumber(), + label: randomLabel(), +}); + authenticate(); describe('Create stackscripts', () => { before(() => { @@ -186,6 +199,7 @@ describe('Create stackscripts', () => { }); beforeEach(() => { cy.tag('method:e2e', 'purpose:dcTesting'); + mockGetFirewalls([mockFirewall]).as('getFirewalls'); }); /* @@ -288,6 +302,11 @@ describe('Create stackscripts', () => { cy.findByLabelText('Example Title').should('be.visible').click(); cy.focused().type('{selectall}{backspace}'); cy.focused().type(randomString(12)); + // Select a firewall + linodeCreatePage.selectFirewall( + 'No firewall - traffic is unprotected (not recommended)', + 'Public Interface Firewall' + ); ui.button .findByTitle('Create Linode') @@ -389,6 +408,11 @@ describe('Create stackscripts', () => { cy.findByText(privateImage.label).as('qaPrivateImage').scrollIntoView(); cy.get('@qaPrivateImage').should('be.visible').click(); + // Select a firewall + linodeCreatePage.selectFirewall( + 'No firewall - traffic is unprotected (not recommended)', + 'Public Interface Firewall' + ); interceptCreateLinode().as('createLinode'); fillOutLinodeForm( linodeLabel, diff --git a/packages/manager/cypress/e2e/core/stackscripts/smoke-community-stackscripts.spec.ts b/packages/manager/cypress/e2e/core/stackscripts/smoke-community-stackscripts.spec.ts index 7e137192517..f698d9e4cc0 100644 --- a/packages/manager/cypress/e2e/core/stackscripts/smoke-community-stackscripts.spec.ts +++ b/packages/manager/cypress/e2e/core/stackscripts/smoke-community-stackscripts.spec.ts @@ -8,6 +8,7 @@ import { mockGetStackScripts, } from 'support/intercepts/stackscripts'; import { ui } from 'support/ui'; +import { linodeCreatePage } from 'support/ui/pages'; import { cleanUp } from 'support/util/cleanup'; import { randomLabel, randomString } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; @@ -392,6 +393,12 @@ describe('Community Stackscripts integration tests', () => { cy.get('[data-qa-radio]').click({ force: true }); }); + // Select a firewall + linodeCreatePage.selectFirewall( + 'No firewall - traffic is unprotected (not recommended)', + 'Public Interface Firewall' + ); + // Input root password // Weak or fair root password cannot rebuild the linode cy.get('[id="root-password"]').clear(); diff --git a/packages/manager/cypress/support/constants/databases.ts b/packages/manager/cypress/support/constants/databases.ts index 048b10e2494..6094ef2e698 100644 --- a/packages/manager/cypress/support/constants/databases.ts +++ b/packages/manager/cypress/support/constants/databases.ts @@ -564,6 +564,16 @@ export const databaseConfigurationsAdvConfig: DatabaseClusterConfiguration[] = [ version: '8', ip: randomIp(), }, + { + clusterSize: 2, + dbType: 'postgresql', + engine: 'PostgreSQL', + label: randomLabel(), + linodeType: 'g6-nanode-1', + region: chooseRegion({ capabilities: ['Managed Databases'] }), + version: '13', + ip: randomIp(), + }, { clusterSize: 3, dbType: 'postgresql', diff --git a/packages/manager/cypress/support/intercepts/databases.ts b/packages/manager/cypress/support/intercepts/databases.ts index 77af59f9fb1..693a636e1d6 100644 --- a/packages/manager/cypress/support/intercepts/databases.ts +++ b/packages/manager/cypress/support/intercepts/databases.ts @@ -240,6 +240,33 @@ export const mockUpdateSuspendResumeDatabase = ( ); }; +/** + * Intercepts PUT request to update database and mocks error with custom field and reason. + * + * @param id - Database ID. + * @param engine - Database engine type. + * @param field - Error field name. + * @param reason - Error reason message. + * + * @returns Cypress chainable. + */ +export const mockUpdateDatabaseError = ( + id: number, + engine: string, + field: string, + reason: string +): Cypress.Chainable => { + const error = makeErrorResponse({ + field, + reason, + }); + return cy.intercept( + 'PUT', + apiMatcher(`databases/${engine}/instances/${id}`), + error + ); +}; + /** * Intercepts POST request to reset an active database's password and mocks response. * diff --git a/packages/manager/cypress/support/ui/constants.ts b/packages/manager/cypress/support/ui/constants.ts index 3ca596c5182..9ac78aa7878 100644 --- a/packages/manager/cypress/support/ui/constants.ts +++ b/packages/manager/cypress/support/ui/constants.ts @@ -57,7 +57,7 @@ export const pages: Page[] = [ { go: () => { loadAppNoLogin(routes.createLinode); - cy.get('[data-reach-tab]').contains('Marketplace').click(); + cy.get('[data-reach-tab]').contains('Quick Deploy Apps').click(); }, name: 'Tab', }, diff --git a/packages/manager/cypress/support/ui/pages/linode-create-page.ts b/packages/manager/cypress/support/ui/pages/linode-create-page.ts index 47e370d7ffd..b3f9741a81f 100644 --- a/packages/manager/cypress/support/ui/pages/linode-create-page.ts +++ b/packages/manager/cypress/support/ui/pages/linode-create-page.ts @@ -157,4 +157,30 @@ export const linodeCreatePage = { selectInterface: (type: 'public' | 'vlan' | 'vpc') => { cy.get(`[data-qa-interface-type-option="${type}"]`).click(); }, + + /** + * Selects a firewall from the firewall dropdown. + * + * @param firewallLabel - Label of the firewall to select. + * @param interfaceType - Optional interface type for the firewall dropdown label (e.g., 'Public Interface Firewall', 'VPC Interface Firewall'). + */ + selectFirewall: ( + firewallLabel: string, + dropdownLabel: + | 'Assign Firewall' + | 'Firewall' + | 'Public Interface Firewall' + | 'VPC Interface Firewall' + ) => { + cy.findByLabelText(dropdownLabel).should('be.visible'); + cy.get(`[data-qa-autocomplete="${dropdownLabel}"]`).within(() => { + cy.get('[data-testid="textfield-input"]').click(); + cy.focused().type(firewallLabel); + }); + + ui.autocompletePopper + .findByTitle(firewallLabel) + .should('be.visible') + .click(); + }, }; diff --git a/packages/manager/cypress/support/ui/pages/logs-destination-form.ts b/packages/manager/cypress/support/ui/pages/logs-destination-form.ts index e129c119e41..12e9bc35fdf 100644 --- a/packages/manager/cypress/support/ui/pages/logs-destination-form.ts +++ b/packages/manager/cypress/support/ui/pages/logs-destination-form.ts @@ -4,6 +4,8 @@ * Create/Edit Stream Page */ +import { ui } from 'support/ui'; + import type { AkamaiObjectStorageDetailsExtended } from '@linode/api-v4'; export const logsDestinationForm = { @@ -21,17 +23,30 @@ export const logsDestinationForm = { }, /** - * Sets destination's host + * Selects a bucket from the "Select Bucket associated with the account" Autocomplete dropdown + * + * @param bucketLabel - bucket label to select from the dropdown + */ + selectBucketFromDropdown: (bucketLabel: string) => { + cy.findByLabelText('Bucket') + .should('be.visible') + .should('be.enabled') + .click(); + ui.autocompletePopper.findByTitle(bucketLabel).should('be.visible').click(); + }, + + /** + * Sets destination's endpoint * - * @param host - destination host to set + * @param endpoint - destination endpoint to set */ - setHost: (host: string) => { - cy.findByLabelText('Host') + setEndpoint: (endpoint: string) => { + cy.findByLabelText('Endpoint') .should('be.visible') .should('be.enabled') - .should('have.attr', 'placeholder', 'Host for the destination') + .should('have.attr', 'placeholder', 'Endpoint for the destination') .clear(); - cy.focused().type(host); + cy.focused().type(endpoint); }, /** @@ -79,12 +94,15 @@ export const logsDestinationForm = { * @param data - object with destination details of AkamaiObjectStorageDetails type */ fillDestinationDetailsForm: (data: AkamaiObjectStorageDetailsExtended) => { - // Give Destination a host - logsDestinationForm.setHost(data.host); + // Switch to manual bucket entry + cy.findByLabelText('Enter Bucket manually').click(); // Give Destination a bucket logsDestinationForm.setBucket(data.bucket_name); + // Give Destination an endpoint + logsDestinationForm.setEndpoint(data.host); + // Give the Destination Access Key ID logsDestinationForm.setAccessKeyId(data.access_key_id); diff --git a/packages/manager/cypress/support/util/linodes.ts b/packages/manager/cypress/support/util/linodes.ts index ba990a3c94d..95494c39820 100644 --- a/packages/manager/cypress/support/util/linodes.ts +++ b/packages/manager/cypress/support/util/linodes.ts @@ -135,7 +135,7 @@ export const createTestLinode = async ( const resolvedCreatePayload = { ...createLinodeRequestFactory.build({ interface_generation: 'legacy_config', - firewall_id: null, + firewall_id: -1, booted: false, image: 'linode/ubuntu24.04', label: randomLabel(), diff --git a/packages/manager/package.json b/packages/manager/package.json index 085511e5975..248080fb44a 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.159.1", + "version": "1.160.0", "private": true, "type": "module", "bugs": { @@ -47,7 +47,7 @@ "@xterm/xterm": "^5.5.0", "akamai-cds-react-components": "0.1.0", "algoliasearch": "^4.14.3", - "axios": "~1.12.0", + "axios": "~1.13.5", "braintree-web": "^3.92.2", "chart.js": "~2.9.4", "copy-to-clipboard": "^3.0.8", @@ -59,13 +59,13 @@ "he": "^1.2.0", "immer": "^9.0.6", "ipaddr.js": "^1.9.1", - "jspdf": "^4.0.0", + "jspdf": "^4.2.0", "jspdf-autotable": "^5.0.2", "launchdarkly-react-client-sdk": "3.0.10", "libphonenumber-js": "^1.10.6", "logic-query-parser": "^0.0.5", "luxon": "3.4.4", - "markdown-it": "^14.1.0", + "markdown-it": "^14.1.1", "md5": "^2.2.1", "notistack": "^3.0.1", "qrcode.react": "^4.2.0", diff --git a/packages/manager/public/assets/gemma3.svg b/packages/manager/public/assets/gemma3.svg new file mode 100644 index 00000000000..a11483caa3f --- /dev/null +++ b/packages/manager/public/assets/gemma3.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/manager/public/assets/gptoss.svg b/packages/manager/public/assets/gptoss.svg new file mode 100644 index 00000000000..e9546beda4b --- /dev/null +++ b/packages/manager/public/assets/gptoss.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/manager/public/assets/marketplace/ateme-flow.jpeg b/packages/manager/public/assets/marketplace/ateme-flow.jpeg new file mode 100644 index 00000000000..c6d36ec68ba Binary files /dev/null and b/packages/manager/public/assets/marketplace/ateme-flow.jpeg differ diff --git a/packages/manager/public/assets/marketplace/bitmovin-dark.svg b/packages/manager/public/assets/marketplace/bitmovin-dark.svg new file mode 100644 index 00000000000..855edf634a8 --- /dev/null +++ b/packages/manager/public/assets/marketplace/bitmovin-dark.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/packages/manager/public/assets/marketplace/bitmovin-light.svg b/packages/manager/public/assets/marketplace/bitmovin-light.svg new file mode 100644 index 00000000000..f0dd163ce53 --- /dev/null +++ b/packages/manager/public/assets/marketplace/bitmovin-light.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/packages/manager/public/assets/marketplace/bitmovin-live-encoder.jpeg b/packages/manager/public/assets/marketplace/bitmovin-live-encoder.jpeg new file mode 100644 index 00000000000..6d88dfad325 Binary files /dev/null and b/packages/manager/public/assets/marketplace/bitmovin-live-encoder.jpeg differ diff --git a/packages/manager/public/assets/marketplace/bitmovin-playback.jpeg b/packages/manager/public/assets/marketplace/bitmovin-playback.jpeg new file mode 100644 index 00000000000..aad039ac86b Binary files /dev/null and b/packages/manager/public/assets/marketplace/bitmovin-playback.jpeg differ diff --git a/packages/manager/public/assets/marketplace/bitmovin-vod-encoder.jpeg b/packages/manager/public/assets/marketplace/bitmovin-vod-encoder.jpeg new file mode 100644 index 00000000000..53eb4cbc361 Binary files /dev/null and b/packages/manager/public/assets/marketplace/bitmovin-vod-encoder.jpeg differ diff --git a/packages/manager/public/assets/marketplace/cloudcasa-dark.svg b/packages/manager/public/assets/marketplace/cloudcasa-dark.svg new file mode 100644 index 00000000000..881326cf2bf --- /dev/null +++ b/packages/manager/public/assets/marketplace/cloudcasa-dark.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/manager/public/assets/marketplace/cloudcasa-diagram.jpeg b/packages/manager/public/assets/marketplace/cloudcasa-diagram.jpeg new file mode 100644 index 00000000000..401722fca6d Binary files /dev/null and b/packages/manager/public/assets/marketplace/cloudcasa-diagram.jpeg differ diff --git a/packages/manager/public/assets/marketplace/cloudcasa-light.svg b/packages/manager/public/assets/marketplace/cloudcasa-light.svg new file mode 100644 index 00000000000..4065e292dd7 --- /dev/null +++ b/packages/manager/public/assets/marketplace/cloudcasa-light.svg @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/manager/public/assets/marketplace/cloudcasa-process-flow.jpg b/packages/manager/public/assets/marketplace/cloudcasa-process-flow.jpg new file mode 100644 index 00000000000..59e6d262b5d Binary files /dev/null and b/packages/manager/public/assets/marketplace/cloudcasa-process-flow.jpg differ diff --git a/packages/manager/public/assets/marketplace/portainer-architecture.jpeg b/packages/manager/public/assets/marketplace/portainer-architecture.jpeg new file mode 100644 index 00000000000..b65eba7fa3f Binary files /dev/null and b/packages/manager/public/assets/marketplace/portainer-architecture.jpeg differ diff --git a/packages/manager/public/assets/marketplace/portainer-dark.svg b/packages/manager/public/assets/marketplace/portainer-dark.svg new file mode 100644 index 00000000000..8d077c6ec95 --- /dev/null +++ b/packages/manager/public/assets/marketplace/portainer-dark.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/packages/manager/public/assets/marketplace/portainer-light.svg b/packages/manager/public/assets/marketplace/portainer-light.svg new file mode 100644 index 00000000000..1a970b42d0c --- /dev/null +++ b/packages/manager/public/assets/marketplace/portainer-light.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/packages/manager/public/assets/marketplace/rad-security-dark.svg b/packages/manager/public/assets/marketplace/rad-security-dark.svg index 33de0a34ee2..a520419dd8f 100644 --- a/packages/manager/public/assets/marketplace/rad-security-dark.svg +++ b/packages/manager/public/assets/marketplace/rad-security-dark.svg @@ -1,11 +1,11 @@ - - - - - - - - - - + + + + + + + + + + diff --git a/packages/manager/public/assets/marketplace/rad-security-light.svg b/packages/manager/public/assets/marketplace/rad-security-light.svg index ee76e11aecb..4099f481c6c 100644 --- a/packages/manager/public/assets/marketplace/rad-security-light.svg +++ b/packages/manager/public/assets/marketplace/rad-security-light.svg @@ -1,63 +1,63 @@ - - - - - - - - - - + + + + + + + + + + - + - + - + - + - + - + - + - + - + diff --git a/packages/manager/public/assets/marketplace/sftpgo-dark.svg b/packages/manager/public/assets/marketplace/sftpgo-dark.svg index 47d4f502811..f9f3813a3ac 100644 --- a/packages/manager/public/assets/marketplace/sftpgo-dark.svg +++ b/packages/manager/public/assets/marketplace/sftpgo-dark.svg @@ -1,31 +1,38 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SFTPGo + diff --git a/packages/manager/public/assets/marketplace/sftpgo-light.svg b/packages/manager/public/assets/marketplace/sftpgo-light.svg index 47d4f502811..f9f3813a3ac 100644 --- a/packages/manager/public/assets/marketplace/sftpgo-light.svg +++ b/packages/manager/public/assets/marketplace/sftpgo-light.svg @@ -1,31 +1,38 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SFTPGo + diff --git a/packages/manager/public/assets/milvus.svg b/packages/manager/public/assets/milvus.svg new file mode 100644 index 00000000000..27cc4a9349f --- /dev/null +++ b/packages/manager/public/assets/milvus.svg @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/manager/public/assets/pgvector.svg b/packages/manager/public/assets/pgvector.svg new file mode 100644 index 00000000000..1b0b11c41df --- /dev/null +++ b/packages/manager/public/assets/pgvector.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/manager/public/assets/qwen.svg b/packages/manager/public/assets/qwen.svg new file mode 100644 index 00000000000..5a6a0516ce1 --- /dev/null +++ b/packages/manager/public/assets/qwen.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/manager/public/assets/white/gemma3.svg b/packages/manager/public/assets/white/gemma3.svg new file mode 100644 index 00000000000..0c89e9c3989 --- /dev/null +++ b/packages/manager/public/assets/white/gemma3.svg @@ -0,0 +1,12 @@ + + + + + + + \ No newline at end of file diff --git a/packages/manager/public/assets/white/gptoss.svg b/packages/manager/public/assets/white/gptoss.svg new file mode 100644 index 00000000000..f33e1102ac9 --- /dev/null +++ b/packages/manager/public/assets/white/gptoss.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/manager/public/assets/white/milvus.svg b/packages/manager/public/assets/white/milvus.svg new file mode 100644 index 00000000000..1f75afabddf --- /dev/null +++ b/packages/manager/public/assets/white/milvus.svg @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/manager/public/assets/white/pgvector.svg b/packages/manager/public/assets/white/pgvector.svg new file mode 100644 index 00000000000..e6f421e9da9 --- /dev/null +++ b/packages/manager/public/assets/white/pgvector.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/manager/public/assets/white/qwen.svg b/packages/manager/public/assets/white/qwen.svg new file mode 100644 index 00000000000..fd5a7b6ad30 --- /dev/null +++ b/packages/manager/public/assets/white/qwen.svg @@ -0,0 +1,13 @@ + + + + + + + + + \ No newline at end of file diff --git a/packages/manager/src/Router.tsx b/packages/manager/src/Router.tsx index 00d916f1854..e5d09950dd4 100644 --- a/packages/manager/src/Router.tsx +++ b/packages/manager/src/Router.tsx @@ -10,6 +10,7 @@ import { useGlobalErrors } from 'src/hooks/useGlobalErrors'; import { useIsACLPEnabled } from './features/CloudPulse/Utils/utils'; import { useIsDatabasesEnabled } from './features/Databases/utilities'; import { ErrorBoundaryFallback } from './features/ErrorBoundary/ErrorBoundaryFallback'; +import { useIsPrivateImageSharingEnabled } from './features/Images/utils'; import { useIsPlacementGroupsEnabled } from './features/PlacementGroups/utils'; import { router } from './routes'; @@ -22,6 +23,7 @@ export const Router = () => { const { isDatabasesEnabled } = useIsDatabasesEnabled(); const { isPlacementGroupsEnabled } = useIsPlacementGroupsEnabled(); const { isACLPEnabled } = useIsACLPEnabled(); + const { isPrivateImageSharingEnabled } = useIsPrivateImageSharingEnabled(); const flags = useFlags(); // Update the router's context @@ -31,6 +33,7 @@ export const Router = () => { flags, globalErrors, isACLPEnabled, + isPrivateImageSharingEnabled, isDatabasesEnabled, isPlacementGroupsEnabled, profile, diff --git a/packages/manager/src/assets/icons/no-results-state.svg b/packages/manager/src/assets/icons/no-results-state.svg new file mode 100644 index 00000000000..e7723054193 --- /dev/null +++ b/packages/manager/src/assets/icons/no-results-state.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/packages/manager/src/components/ImageSelect/ImageSelectTable.tsx b/packages/manager/src/components/ImageSelect/ImageSelectTable.tsx new file mode 100644 index 00000000000..0e27105f5c1 --- /dev/null +++ b/packages/manager/src/components/ImageSelect/ImageSelectTable.tsx @@ -0,0 +1,336 @@ +import { + useAllImagesQuery, + useAllTagsQuery, + useProfile, + useRegionsQuery, +} from '@linode/queries'; +import { getAPIFilterFromQuery } from '@linode/search'; +import { + Autocomplete, + Box, + CircleProgress, + ErrorState, + Hidden, + Notice, + Stack, + TooltipIcon, + useTheme, +} from '@linode/ui'; +import useMediaQuery from '@mui/material/useMediaQuery'; +import { Pagination } from 'akamai-cds-react-components/Pagination'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeaderCell, + TableRow, +} from 'akamai-cds-react-components/Table'; +import React, { useState } from 'react'; + +import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField'; +import { SHARE_GROUP_COLUMN_HEADER_TOOLTIP } from 'src/features/Images/constants'; +import { usePaginationV2 } from 'src/hooks/usePaginationV2'; + +import { + DEFAULT_CLIENT_SIDE_PAGE_SIZE, + IMAGE_SELECT_TABLE_PREFERENCE_KEY, + TABLE_CELL_BASE_STYLE, +} from './constants'; +import { ImageSelectTableRow } from './ImageSelectTableRow'; + +import type { IMAGE_SELECT_TABLE_PENDO_IDS } from './constants'; +import type { Filter, Image } from '@linode/api-v4'; +import type { LinkProps } from '@tanstack/react-router'; + +interface Props { + /** + * The route this table is rendered on. Used to persist pagination and + * sort state in the URL. + */ + currentRoute: LinkProps['to']; + /** + * Error message to display above the table, e.g. from form validation. + */ + errorText?: string; + /** + * Callback fired when the user selects an image row. + */ + onSelect: (image: Image) => void; + /** + * An object containing Pendo IDs for elements in this component. + */ + pendoIDs: typeof IMAGE_SELECT_TABLE_PENDO_IDS; + /** + * The ID of the currently selected image. + */ + selectedImageId?: null | string; +} + +type OptionType = { label: string; value: string }; + +export const ImageSelectTable = (props: Props) => { + const { currentRoute, errorText, onSelect, pendoIDs, selectedImageId } = + props; + + const theme = useTheme(); + const [query, setQuery] = useState(''); + const [selectedTag, setSelectedTag] = useState(null); + const [selectedRegion, setSelectedRegion] = useState(null); + const matchesSmDown = useMediaQuery(theme.breakpoints.down('md')); + + const { data: profile } = useProfile(); + const { data: tags } = useAllTagsQuery(); + const { data: regions } = useRegionsQuery(); + + const { filter: searchFilter, error: filterError } = getAPIFilterFromQuery( + query, + { + filterShapeOverrides: { + '+contains': { + field: 'region', + filter: (value) => ({ regions: { region: value } }), + }, + '+eq': { + field: 'region', + filter: (value) => ({ regions: { region: value } }), + }, + }, + searchableFieldsWithoutOperator: ['label', 'tags'], + } + ); + + const combinedFilter = buildImageFilter({ + searchFilter, + selectedRegion, + selectedTag, + }); + + const { + data: imagesData, + error: imagesError, + isFetching, + isLoading, + } = useAllImagesQuery( + {}, + { + ...combinedFilter, + is_public: false, + type: 'manual', + } + ); + + const pagination = usePaginationV2({ + clientSidePaginationData: imagesData, + currentRoute, + defaultPageSize: DEFAULT_CLIENT_SIDE_PAGE_SIZE, + initialPage: 1, + preferenceKey: IMAGE_SELECT_TABLE_PREFERENCE_KEY, + }); + + const tagOptions = + tags?.map((tag) => ({ label: tag.label, value: tag.label })) ?? []; + + const regionOptions = + regions?.map((r) => ({ label: r.label, value: r.id })) ?? []; + + const selectedTagOption = + tagOptions.find((t) => t.value === selectedTag) ?? null; + + const selectedRegionOption = + regionOptions.find((r) => r.value === selectedRegion) ?? null; + + const handlePageChange = (event: CustomEvent<{ page: number }>) => { + pagination.handlePageChange(Number(event.detail)); + }; + + const handlePageSizeChange = (event: CustomEvent<{ pageSize: number }>) => { + const newSize = event.detail.pageSize; + pagination.handlePageSizeChange(newSize); + }; + + return ( + + {errorText && } + + + { + setQuery(q); + pagination.handlePageChange(1); + }} + placeholder="Search images" + value={query} + /> + + + { + setSelectedTag((value as null | OptionType)?.value ?? null); + pagination.handlePageChange(1); + }} + options={tagOptions} + placeholder="Filter by tag" + textFieldProps={{ + hideLabel: true, + }} + value={selectedTagOption} + /> + + + { + setSelectedRegion((value as null | OptionType)?.value ?? null); + pagination.handlePageChange(1); + }} + options={regionOptions} + placeholder="Filter by region" + textFieldProps={{ + hideLabel: true, + }} + value={selectedRegionOption} + /> + + + + + + + + Image + + + Replicated in + + + + + Share Group + + + + + + + Size + + + + Created + + + Image ID + + + + + {isLoading && } + {imagesError && ( + + )} + {!isLoading && !imagesError && imagesData?.length === 0 && ( + + + No items to display. + + + )} + {!isLoading && + !imagesError && + pagination.paginatedData.map((image) => ( + onSelect(image)} + pendoIDs={pendoIDs} + regions={regions ?? []} + selected={image.id === selectedImageId} + timezone={profile?.timezone} + /> + ))} + +
+ + + + ); +}; + +interface BuildImageFilterParams { + searchFilter: Filter; + selectedRegion: null | string; + selectedTag: null | string; +} + +/** + * Merges the search filter with optional tag and region dropdown filters + * into a single API filter object. + */ +const buildImageFilter = ({ + searchFilter, + selectedRegion, + selectedTag, +}: BuildImageFilterParams) => { + return { + ...searchFilter, + ...(selectedTag ? { tags: { '+contains': selectedTag } } : {}), + ...(selectedRegion ? { regions: { region: selectedRegion } } : {}), + }; +}; diff --git a/packages/manager/src/components/ImageSelect/ImageSelectTableRow.tsx b/packages/manager/src/components/ImageSelect/ImageSelectTableRow.tsx new file mode 100644 index 00000000000..c77f5e7b140 --- /dev/null +++ b/packages/manager/src/components/ImageSelect/ImageSelectTableRow.tsx @@ -0,0 +1,160 @@ +import { + FormControlLabel, + Hidden, + ListItem, + Radio, + TooltipIcon, +} from '@linode/ui'; +import { convertStorageUnit, pluralize } from '@linode/utilities'; +import useMediaQuery from '@mui/material/useMediaQuery'; +import { TableCell, TableRow } from 'akamai-cds-react-components/Table'; +import React from 'react'; + +import CloudInitIcon from 'src/assets/icons/cloud-init.svg'; +import { + PlanTextTooltip, + StyledFormattedRegionList, +} from 'src/features/components/PlansPanel/PlansAvailabilityNotice.styles'; +import { formatDate } from 'src/utilities/formatDate'; + +import { + IMAGE_SELECT_TABLE_PENDO_IDS, + TABLE_CELL_BASE_STYLE, +} from './constants'; + +import type { Image, ImageRegion, Region } from '@linode/api-v4'; +import type { Theme } from '@linode/ui'; + +interface Props { + image: Image; + onSelect: () => void; + pendoIDs: typeof IMAGE_SELECT_TABLE_PENDO_IDS; + regions: Region[]; + selected: boolean; + timezone?: string; +} + +export const ImageSelectTableRow = (props: Props) => { + const { image, onSelect, pendoIDs, regions, selected, timezone } = props; + + const { + capabilities, + created, + id, + image_sharing, + label, + regions: imageRegions, + size, + status, + type, + } = image; + + const matchesLgDown = useMediaQuery((theme: Theme) => + theme.breakpoints.down('lg') + ); + + const getSizeDisplay = () => { + if (status === 'available') { + const sizeInGB = convertStorageUnit('MB', size, 'GB'); + const formatted = Intl.NumberFormat('en-US', { + maximumFractionDigits: 2, + minimumFractionDigits: 0, + }).format(sizeInGB); + return `${formatted} GB`; + } + return 'Pending'; + }; + + const getShareGroupDisplay = () => { + if (image_sharing?.shared_by?.sharegroup_label) { + return image_sharing.shared_by.sharegroup_label; + } + + return '—'; + }; + + const getRegionListItem = (imageRegion: ImageRegion) => { + const matchingRegion = regions.find((r) => r.id === imageRegion.region); + + return matchingRegion + ? `${matchingRegion.label} (${imageRegion.region})` + : imageRegion.region; + }; + + const FormattedRegionList = () => ( + + {imageRegions.map((region: ImageRegion, idx) => { + return ( + + {getRegionListItem(region)} + + ); + })} + + ); + + return ( + + + } + label={label} + onChange={onSelect} + sx={{ gap: 2 }} + /> + {type === 'manual' && capabilities.includes('cloud-init') && ( + } + sxTooltipIcon={{ + padding: 0, + }} + text="This image supports our Metadata service via cloud-init." + /> + )} + + + + 0 + ? pluralize('Region', 'Regions', imageRegions.length) + : '—' + } + tooltipText={} + /> + + + + + {getShareGroupDisplay()} + + + + + {getSizeDisplay()} + + + + {formatDate(created, { timezone })} + + + {id} + + + ); +}; diff --git a/packages/manager/src/components/ImageSelect/constants.ts b/packages/manager/src/components/ImageSelect/constants.ts new file mode 100644 index 00000000000..473fac3ef6b --- /dev/null +++ b/packages/manager/src/components/ImageSelect/constants.ts @@ -0,0 +1,15 @@ +export const TABLE_CELL_BASE_STYLE = { + boxSizing: 'border-box' as const, +}; + +export const IMAGE_SELECT_TABLE_PREFERENCE_KEY = 'image-select-table'; +export const DEFAULT_CLIENT_SIDE_PAGE_SIZE = 10; + +export const IMAGE_SELECT_TABLE_PENDO_IDS = { + searchImagesBar: 'Linodes Create Images-Search click', + tagFilterSelect: 'Linodes Create Images-Filter by Tag click', + regionFilterSelect: 'Linodes Create Images-Filter by Region click', + metadataSupportedIcon: 'Linodes Create Images-Metadata Supported icon', + replicatedRegionPopover: 'Linodes Create Images-Replicated in', + shareGroupInfoIcon: 'Linodes Create Images-Share Group info icon', +}; diff --git a/packages/manager/src/dev-tools/FeatureFlagTool.tsx b/packages/manager/src/dev-tools/FeatureFlagTool.tsx index 6dbe9d0ab4b..a5cf596c319 100644 --- a/packages/manager/src/dev-tools/FeatureFlagTool.tsx +++ b/packages/manager/src/dev-tools/FeatureFlagTool.tsx @@ -36,6 +36,7 @@ const options: { flag: keyof Flags; label: string }[] = [ }, { flag: 'gecko2', label: 'Gecko' }, { flag: 'generationalPlansv2', label: 'Generational compute plans' }, + { flag: 'hostnameEndpoints', label: 'Hostname Endpoints' }, { flag: 'kubernetesBlackwellPlans', label: 'Kubernetes Blackwell Plans' }, { flag: 'limitsEvolution', label: 'Limits Evolution' }, { flag: 'linodeDiskEncryption', label: 'Linode Disk Encryption (LDE)' }, diff --git a/packages/manager/src/factories/cloudpulse/alerts.ts b/packages/manager/src/factories/cloudpulse/alerts.ts index 9f7a9881211..92af2ac38e2 100644 --- a/packages/manager/src/factories/cloudpulse/alerts.ts +++ b/packages/manager/src/factories/cloudpulse/alerts.ts @@ -731,3 +731,68 @@ export const networkLoadBalancerMetricCriteria: MetricDefinition[] = [ ], }, ]; + +const logsDimensions: Dimension[] = [ + { + label: 'Status Code', + dimension_label: 'status_code', + values: [], + }, +]; + +export const logsMetricCriteria: MetricDefinition[] = [ + { + label: 'Successful Upload Count', + metric: 'success_upload_count', + unit: 'Count', + scrape_interval: '300s', + metric_type: 'gauge', + is_alertable: true, + available_aggregate_functions: ['sum'], + dimensions: logsDimensions, + }, + { + label: 'Error Upload Count', + metric: 'error_upload_count', + unit: 'Count', + scrape_interval: '300s', + metric_type: 'gauge', + is_alertable: true, + available_aggregate_functions: ['sum'], + dimensions: logsDimensions, + }, + { + label: 'Error Upload Rate', + metric: 'error_upload_rate', + unit: 'Percent', + scrape_interval: '300s', + metric_type: 'gauge', + is_alertable: true, + available_aggregate_functions: ['avg'], + dimensions: logsDimensions, + }, +]; + +export const logsAlertMetricCriteria = + Factory.Sync.makeFactory({ + label: 'Successful Upload Count', + metric: 'success_upload_count', + unit: 'Count', + aggregate_function: 'sum', + operator: 'eq', + threshold: 1500, + dimension_filters: [ + { + label: 'Status Code', + dimension_label: 'status_code', + operator: 'in', + value: '203,402', + }, + { + label: 'Status Code', + dimension_label: 'status_code', + operator: 'eq', + value: '503', + }, + ], + }); diff --git a/packages/manager/src/factories/databases.ts b/packages/manager/src/factories/databases.ts index 0c1218b2288..a22cceee850 100644 --- a/packages/manager/src/factories/databases.ts +++ b/packages/manager/src/factories/databases.ts @@ -161,8 +161,6 @@ export const databaseInstanceFactory = ? ([1, 3][i % 2] as ClusterSize) : ([1, 2, 3][i % 3] as ClusterSize) ), - connection_pool_port: - null /** @Deprecated replaced by `endpoints` property */, connection_strings: [], created: '2021-12-09T17:15:12', encrypted: false, @@ -179,7 +177,7 @@ export const databaseInstanceFactory = { address: 'public-db-mysql-primary-0.b.linodeb.net', role: 'primary', - private_access: false, + public_access: true, port: 3306, }, ], @@ -191,7 +189,7 @@ export const databaseInstanceFactory = { address: 'public-db-mysql-primary-0.b.linodeb.net', role: 'primary', - private_access: false, + public_access: true, port: 3306, }, ], @@ -203,9 +201,7 @@ export const databaseInstanceFactory = members: { '2.2.2.2': 'primary', }, - platform: Factory.each((i) => - adb10(i) ? 'rdbms-legacy' : 'rdbms-default' - ), + platform: 'rdbms-default', region: Factory.each((i) => possibleRegions[i % possibleRegions.length]), status: Factory.each((i) => possibleStatuses[i % possibleStatuses.length]), type: Factory.each((i) => possibleTypes[i % possibleTypes.length]), @@ -230,8 +226,6 @@ export const databaseInstanceFactory = export const databaseFactory = Factory.Sync.makeFactory({ allow_list: [...IPv4List], cluster_size: Factory.each(() => pickRandom([1, 3])), - connection_pool_port: - null /** @Deprecated replaced by `endpoints` property */, connection_strings: [ { driver: 'python', @@ -253,7 +247,7 @@ export const databaseFactory = Factory.Sync.makeFactory({ { address: 'public-db-mysql-primary-0.b.linodeb.net', role: 'primary', - private_access: false, + public_access: true, port: 3306, }, ], @@ -265,7 +259,7 @@ export const databaseFactory = Factory.Sync.makeFactory({ { address: 'public-db-mysql-primary-0.b.linodeb.net', role: 'primary', - private_access: false, + public_access: true, port: 3306, }, ], @@ -277,7 +271,7 @@ export const databaseFactory = Factory.Sync.makeFactory({ '2.2.2.2': 'primary', }, oldest_restore_time: '2024-09-15T17:15:12', - platform: Factory.each((i) => (adb10(i) ? 'rdbms-legacy' : 'rdbms-default')), + platform: 'rdbms-default', private_network: null, port: 3306, region: 'us-east', @@ -418,13 +412,6 @@ export const mysqlConfigResponse = { type: 'boolean', }, }, - service_log: { - description: - 'Store logs for the service so that they are available in the HTTP API and console.', - example: true, - requires_restart: false, - type: ['boolean', 'null'], - }, }; export const postgresConfigResponse = { @@ -499,6 +486,7 @@ export const postgresConfigResponse = { description: 'Synchronous replication type. Note that the service plan also needs to support synchronous replication.', enum: ['quorum', 'off'], + example: 'quorum', requires_restart: false, default: 'off', type: 'string', diff --git a/packages/manager/src/featureFlags.ts b/packages/manager/src/featureFlags.ts index a9a9a394867..8c24c608d0d 100644 --- a/packages/manager/src/featureFlags.ts +++ b/packages/manager/src/featureFlags.ts @@ -242,6 +242,7 @@ export interface Flags { gecko2: GeckoFeatureFlag; generationalPlansv2: GenerationalPlansFlag; gpuv2: GpuV2; + hostnameEndpoints: boolean; iam: BaseFeatureFlag; iamDelegation: BaseFeatureFlag; iamLimitedAvailabilityBadges: boolean; diff --git a/packages/manager/src/features/Account/DefaultFirewalls.tsx b/packages/manager/src/features/Account/DefaultFirewalls.tsx index 84af676e298..5197c6d3038 100644 --- a/packages/manager/src/features/Account/DefaultFirewalls.tsx +++ b/packages/manager/src/features/Account/DefaultFirewalls.tsx @@ -126,6 +126,7 @@ export const DefaultFirewalls = () => { label="Configuration Profile Interfaces Firewall" onChange={(e, firewall) => field.onChange(firewall.id)} placeholder={DEFAULT_FIREWALL_PLACEHOLDER} + showNoFirewallOption={false} value={field.value} /> )} @@ -142,6 +143,7 @@ export const DefaultFirewalls = () => { label="Linode Interfaces - Public Interface Firewall" onChange={(e, firewall) => field.onChange(firewall.id)} placeholder={DEFAULT_FIREWALL_PLACEHOLDER} + showNoFirewallOption={false} value={field.value} /> )} @@ -158,6 +160,7 @@ export const DefaultFirewalls = () => { label="Linode Interfaces - VPC Interface Firewall" onChange={(e, firewall) => field.onChange(firewall.id)} placeholder={DEFAULT_FIREWALL_PLACEHOLDER} + showNoFirewallOption={false} value={field.value} /> )} @@ -177,6 +180,7 @@ export const DefaultFirewalls = () => { label="NodeBalancers Firewall" onChange={(e, firewall) => field.onChange(firewall.id)} placeholder={DEFAULT_FIREWALL_PLACEHOLDER} + showNoFirewallOption={false} value={field.value} /> )} diff --git a/packages/manager/src/features/Account/Quotas/QuotasTable/QuotasTable.test.tsx b/packages/manager/src/features/Account/Quotas/QuotasTable/QuotasTable.test.tsx index 8c65e44c0bb..85b5c7da24f 100644 --- a/packages/manager/src/features/Account/Quotas/QuotasTable/QuotasTable.test.tsx +++ b/packages/manager/src/features/Account/Quotas/QuotasTable/QuotasTable.test.tsx @@ -121,4 +121,59 @@ describe('QuotasTable', () => { ).toBeInTheDocument(); }); }); + + it('should display object storage thoughput quotas correctly', async () => { + queryMocks.useAllQuotasQuery.mockReturnValue({ + data: [ + quotaFactory.build({ + quota_name: 'Ingress Throughput (per endpoint)', + description: + 'Current total ingress bandwidth per account, per endpoint', + quota_limit: 1250000000, + quota_type: 'obj-total-ingress-throughput', + resource_metric: 'byte_per_second', + has_usage: false, + }), + ], + isFetching: false, + }); + + queryMocks.useQueries.mockReturnValue([ + { + data: quotaUsageFactory.build({ + quota_limit: 1250000000, + usage: 10, + }), + isLoading: false, + }, + ]); + + const { getByLabelText, getByText } = renderWithTheme( + + ); + + await waitFor(() => { + expect(getByText('Ingress Throughput')).toBeInTheDocument(); + expect(getByText(`10 Gbps`)).toBeInTheDocument(); + expect( + getByLabelText( + 'Current total ingress bandwidth per account, per endpoint' + ) + ).toBeInTheDocument(); + expect(getByText('Not applicable')).toBeInTheDocument(); + expect( + getByLabelText('Action menu for quota Ingress Throughput') + ).toBeInTheDocument(); + }); + }); }); diff --git a/packages/manager/src/features/Account/Quotas/utils.ts b/packages/manager/src/features/Account/Quotas/utils.ts index ad5ec6b0f13..4c926cfbabe 100644 --- a/packages/manager/src/features/Account/Quotas/utils.ts +++ b/packages/manager/src/features/Account/Quotas/utils.ts @@ -249,7 +249,7 @@ export const convertResourceMetric = ({ return { convertedUsage: 0, convertedResourceMetric: 'Gbps', - convertedLimit: readableBytes(initialLimit, { + convertedLimit: readableBytes(initialLimit * 8, { unit: 'GB', base10: true, }).value, diff --git a/packages/manager/src/features/Account/SwitchAccountButton.tsx b/packages/manager/src/features/Account/SwitchAccountButton.tsx index ef6ce0568ef..c1f4a07f1b5 100644 --- a/packages/manager/src/features/Account/SwitchAccountButton.tsx +++ b/packages/manager/src/features/Account/SwitchAccountButton.tsx @@ -19,10 +19,15 @@ export const SwitchAccountButton = (props: ButtonProps) => { }, font: theme.tokens.alias.Typography.Label.Semibold.S, marginTop: theme.tokens.spacing.S4, + ...(isDelegateUserType && { + '&.MuiButton-root': { + textTransform: 'none', + }, + }), })} {...props} > - {isDelegateUserType ? 'Switch back to your account' : 'Switch Account'} + {isDelegateUserType ? 'Switch Back to Your Account' : 'Switch Account'} ); }; diff --git a/packages/manager/src/features/Account/SwitchAccountDrawer.test.tsx b/packages/manager/src/features/Account/SwitchAccountDrawer.test.tsx index 36f4e9fcd7b..4ae55c8d34b 100644 --- a/packages/manager/src/features/Account/SwitchAccountDrawer.test.tsx +++ b/packages/manager/src/features/Account/SwitchAccountDrawer.test.tsx @@ -9,7 +9,11 @@ import { SwitchAccountDrawer } from './SwitchAccountDrawer'; const queryMocks = vi.hoisted(() => ({ useProfile: vi.fn().mockReturnValue({}), - useGetListMyDelegatedChildAccountsQuery: vi.fn().mockReturnValue({}), + useMyDelegatedChildAccountsQuery: vi.fn().mockReturnValue({}), + useChildAccountsInfiniteQuery: vi.fn().mockReturnValue({}), + useIsIAMDelegationEnabled: vi + .fn() + .mockReturnValue({ isIAMDelegationEnabled: true }), })); vi.mock('@linode/queries', async () => { @@ -17,8 +21,19 @@ vi.mock('@linode/queries', async () => { return { ...actual, useProfile: queryMocks.useProfile, - useGetListMyDelegatedChildAccountsQuery: - queryMocks.useGetListMyDelegatedChildAccountsQuery, + useMyDelegatedChildAccountsQuery: + queryMocks.useMyDelegatedChildAccountsQuery, + useChildAccountsInfiniteQuery: queryMocks.useChildAccountsInfiniteQuery, + }; +}); + +vi.mock('src/features/IAM/hooks/useIsIAMEnabled', async () => { + const actual = await vi.importActual( + 'src/features/IAM/hooks/useIsIAMEnabled' + ); + return { + ...actual, + useIsIAMDelegationEnabled: queryMocks.useIsIAMDelegationEnabled, }; }); @@ -29,16 +44,34 @@ const props = { }; describe('SwitchAccountDrawer', () => { + const accounts = accountFactory.buildList(5, { + company: 'Test Account 1', + }); + beforeEach(() => { queryMocks.useProfile.mockReturnValue({}); - queryMocks.useGetListMyDelegatedChildAccountsQuery.mockReturnValue({ - data: accountFactory.buildList(5, { - company: 'Test Account 1', - euuid: '123', - }), + queryMocks.useIsIAMDelegationEnabled.mockReturnValue({ + isIAMDelegationEnabled: true, + }); + queryMocks.useMyDelegatedChildAccountsQuery.mockReturnValue({ + data: { data: accounts, results: accounts.length, page: 1, pages: 1 }, isLoading: false, isRefetching: false, }); + queryMocks.useChildAccountsInfiniteQuery.mockReturnValue({ + data: { + pages: [ + { data: accounts, results: accounts.length, page: 1, pages: 1 }, + ], + pageParams: [], + }, + isInitialLoading: false, + isRefetching: false, + isFetchingNextPage: false, + hasNextPage: false, + fetchNextPage: vi.fn(), + refetch: vi.fn(), + }); }); it('should have a title', () => { @@ -94,4 +127,20 @@ describe('SwitchAccountDrawer', () => { expect(props.onClose).toHaveBeenCalledTimes(1); }); }); + + it('should display an empty state when no child accounts are found', async () => { + queryMocks.useMyDelegatedChildAccountsQuery.mockReturnValue({ + data: { data: [], results: 0, page: 1, pages: 1 }, + isLoading: false, + isRefetching: false, + }); + const { getByText } = renderWithTheme(); + + expect(getByText('You don’t have access to other accounts.')).toBeVisible(); + expect( + getByText( + 'You must be added to a delegation by an account administrator to have access to other accounts.' + ) + ).toBeVisible(); + }); }); diff --git a/packages/manager/src/features/Account/SwitchAccountDrawer.tsx b/packages/manager/src/features/Account/SwitchAccountDrawer.tsx index 72d894d0712..f8df9fa34fe 100644 --- a/packages/manager/src/features/Account/SwitchAccountDrawer.tsx +++ b/packages/manager/src/features/Account/SwitchAccountDrawer.tsx @@ -3,6 +3,7 @@ import { useMyDelegatedChildAccountsQuery, } from '@linode/queries'; import { + Box, Button, Drawer, LinkButton, @@ -14,6 +15,7 @@ import { import React, { useMemo, useState } from 'react'; import ErrorStateCloud from 'src/assets/icons/error-state-cloud.svg'; +import NoResultsState from 'src/assets/icons/no-results-state.svg'; import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField'; import { useParentChildAuthentication } from 'src/features/Account/SwitchAccounts/useParentChildAuthentication'; import { useSwitchToParentAccount } from 'src/features/Account/SwitchAccounts/useSwitchToParentAccount'; @@ -103,7 +105,7 @@ export const SwitchAccountDrawer = (props: Props) => { } : undefined, }, - isIAMDelegationEnabled === false + isIAMDelegationEnabled === false && (isParentUserType || isProxyUserType) ); const { @@ -159,12 +161,18 @@ export const SwitchAccountDrawer = (props: Props) => { userType: isIAMDelegationEnabled ? 'delegate' : 'proxy', }); onClose(event); - location.reload(); + + // Only redirect to /linodes for IAM delegate users + if (isIAMDelegationEnabled) { + location.replace('/linodes'); + } else { + location.reload(); + } } catch { // Error is handled by createTokenError. } }, - [createToken, isProxyUserType, updateCurrentToken, revokeToken] + [createToken, isIAMDelegationEnabled, updateCurrentToken, revokeToken] ); const [isSwitchingChildAccounts, setIsSwitchingChildAccounts] = @@ -211,6 +219,7 @@ export const SwitchAccountDrawer = (props: Props) => { const hasError = isIAMDelegationEnabled ? delegatedChildAccountsError : childAccountInfiniteError; + return ( {createTokenErrorReason && ( @@ -219,118 +228,148 @@ export const SwitchAccountDrawer = (props: Props) => { {isParentTokenError.length > 0 && ( )} - ({ - margin: `${theme.spacingFunction(24)} 0`, - })} - > - Select an account to view and manage its settings and configurations - {isProxyOrDelegateUserType && ( - <> - {' or '} - { - sendSwitchToParentAccountEvent(); - handleSwitchToParentAccount(); - }} - > - switch back to your account - - - )} - . - - - {hasError && ( - - - Unable to load data. - - Try again or contact support if the issue persists. + {childAccounts && + childAccounts.length === 0 && + isIAMDelegationEnabled && + !Object.prototype.hasOwnProperty.call(filter, 'company') ? ( + + + + You don’t have access to other accounts. + + + You must be added to a delegation by an account administrator to + have access to other accounts. - - )} - {!hasError && ( + + ) : ( <> - - {searchQuery && - childAccounts && - childAccounts.length === 0 && - !isLoading && ( - - No search results - + ({ + margin: `${theme.spacingFunction(24)} 0`, + })} + > + Select an account to view and manage its settings and configurations + {isProxyOrDelegateUserType && ( + <> + {' or '} + { + sendSwitchToParentAccountEvent(); + handleSwitchToParentAccount(); + }} + > + switch back to your account + + )} + . + + + {hasError ? ( + + + Unable to load data. + + Try again or contact support if the issue persists. + + + + ) : ( + <> + {((childAccounts && childAccounts.length !== 0) || + searchQuery) && ( + + )} + {isIAMDelegationEnabled && + searchQuery && + childAccounts && + childAccounts.length === 0 && + !isLoading && ( + + No search results + + )} + + {isIAMDelegationEnabled && ( + + )} + {!isIAMDelegationEnabled && ( + + )} + + )} )} - {isIAMDelegationEnabled && ( - - )} - {!isIAMDelegationEnabled && ( - - )} ); }; diff --git a/packages/manager/src/features/Account/SwitchAccounts/ChildAccountList.tsx b/packages/manager/src/features/Account/SwitchAccounts/ChildAccountList.tsx index 5b93722145e..067748156a5 100644 --- a/packages/manager/src/features/Account/SwitchAccounts/ChildAccountList.tsx +++ b/packages/manager/src/features/Account/SwitchAccounts/ChildAccountList.tsx @@ -2,8 +2,6 @@ import { Box, CircleProgress, LinkButton, Notice, Stack } from '@linode/ui'; import React from 'react'; import { Waypoint } from 'react-waypoint'; -import { useIsIAMDelegationEnabled } from 'src/features/IAM/hooks/useIsIAMEnabled'; - import type { ChildAccount, Filter, UserType } from '@linode/api-v4'; export interface ChildAccountListProps { @@ -43,8 +41,6 @@ export const ChildAccountList = React.memo( fetchNextPage, isFetchingNextPage, }: ChildAccountListProps) => { - const { isIAMDelegationEnabled } = useIsIAMDelegationEnabled(); - if (isLoading) { return ( @@ -53,11 +49,7 @@ export const ChildAccountList = React.memo( ); } - if ( - !isIAMDelegationEnabled && - childAccounts && - childAccounts.length === 0 - ) { + if (childAccounts && childAccounts.length === 0) { return ( There are no child accounts @@ -69,21 +61,6 @@ export const ChildAccountList = React.memo( ); } - if ( - isIAMDelegationEnabled && - childAccounts && - childAccounts.length === 0 && - !Object.prototype.hasOwnProperty.call(filter, 'company') - ) { - return ( - - You don't have access to other accounts. You must be added to a - delegation by your account administrator to have access to other - accounts. - - ); - } - const renderChildAccounts = childAccounts?.map((childAccount, idx) => { const euuid = childAccount.euuid; return ( diff --git a/packages/manager/src/features/Account/SwitchAccounts/ChildAccountsTable.test.tsx b/packages/manager/src/features/Account/SwitchAccounts/ChildAccountsTable.test.tsx new file mode 100644 index 00000000000..e226097a2d2 --- /dev/null +++ b/packages/manager/src/features/Account/SwitchAccounts/ChildAccountsTable.test.tsx @@ -0,0 +1,67 @@ +import React from 'react'; + +import { accountFactory } from 'src/factories'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { ChildAccountsTable } from './ChildAccountsTable'; + +import type { ChildAccountsTableProps } from './ChildAccountsTable'; + +const childAccounts = accountFactory.buildList(5).map((account, i) => ({ + ...account, + company: `Child Account ${i}`, +})); + +const childAccountsWithMoreThan25 = accountFactory + .buildList(30) + .map((account, i) => ({ + ...account, + company: `Child Account ${i}`, + })); + +const props: ChildAccountsTableProps = { + childAccounts, + currentTokenWithBearer: 'Bearer 123', + onSwitchAccount: vi.fn(), + page: 1, + pageSize: 25, + setIsSwitchingChildAccounts: vi.fn(), + totalResults: 0, + userType: undefined, + isLoading: false, + isSwitchingChildAccounts: false, + onClose: vi.fn(), + onPageChange: vi.fn(), + onPageSizeChange: vi.fn(), +}; + +describe('ChildAccountsTable', () => { + it('should display a list of child accounts', async () => { + const { getByTestId, getAllByText } = renderWithTheme( + + ); + + expect(getByTestId('child-accounts-table')).toHaveAttribute( + 'aria-label', + 'List of Child Accounts' + ); + + childAccounts.forEach((account) => { + expect(getAllByText(account.company)).toHaveLength(1); + }); + }); + + it('should display pagination when there are more than 25 child accounts', async () => { + const firstPageAccounts = childAccountsWithMoreThan25.slice(0, 25); + + const { getByTestId } = renderWithTheme( + + ); + + expect(getByTestId('child-accounts-table-pagination')).toBeVisible(); + }); +}); diff --git a/packages/manager/src/features/Account/SwitchAccounts/ChildAccountsTable.tsx b/packages/manager/src/features/Account/SwitchAccounts/ChildAccountsTable.tsx index 1f4c316003d..524960b2cb0 100644 --- a/packages/manager/src/features/Account/SwitchAccounts/ChildAccountsTable.tsx +++ b/packages/manager/src/features/Account/SwitchAccounts/ChildAccountsTable.tsx @@ -12,7 +12,7 @@ import { MIN_PAGE_SIZE } from 'src/components/PaginationFooter/PaginationFooter. import type { Account, UserType } from '@linode/api-v4'; -interface ChildAccountsTableProps { +export interface ChildAccountsTableProps { childAccounts?: Account[]; currentTokenWithBearer?: string; isLoading: boolean; @@ -76,7 +76,10 @@ export const ChildAccountsTable = (props: ChildAccountsTableProps) => { return ( <> - +
{childAccounts?.map((childAccount, idx) => ( @@ -111,6 +114,7 @@ export const ChildAccountsTable = (props: ChildAccountsTableProps) => { {totalResults > MIN_PAGE_SIZE && ( ) => handlePageChange(Number(e.detail)) diff --git a/packages/manager/src/features/Account/SwitchAccounts/SessionExpirationDialog.tsx b/packages/manager/src/features/Account/SwitchAccounts/SessionExpirationDialog.tsx index 383afb8d172..807e681cd02 100644 --- a/packages/manager/src/features/Account/SwitchAccounts/SessionExpirationDialog.tsx +++ b/packages/manager/src/features/Account/SwitchAccounts/SessionExpirationDialog.tsx @@ -25,7 +25,6 @@ export const SessionExpirationDialog = React.memo( ); const { isProxyUserType, isDelegateUserType } = useDelegationRole(); const { isIAMDelegationEnabled } = useIsIAMDelegationEnabled(); - const [timeRemaining, setTimeRemaining] = React.useState<{ minutes: number; seconds: number; @@ -121,7 +120,6 @@ export const SessionExpirationDialog = React.memo( setTokenInLocalStorage({ prefix: tokenPrefix, - token: { ...proxyToken, token: `Bearer ${proxyToken.token}`, @@ -145,7 +143,7 @@ export const SessionExpirationDialog = React.memo( */ useEffect(() => { const checkTokenExpiry = () => { - const expiryString = isIAMDelegationEnabled + const expiryString = isProxyUserType ? getStorage('authentication/proxy_token/expire') : getStorage('authentication/delegate_token/expire'); diff --git a/packages/manager/src/features/Account/SwitchAccounts/useParentChildAuthentication.tsx b/packages/manager/src/features/Account/SwitchAccounts/useParentChildAuthentication.tsx index d7866b8eb6e..b43bc169a04 100644 --- a/packages/manager/src/features/Account/SwitchAccounts/useParentChildAuthentication.tsx +++ b/packages/manager/src/features/Account/SwitchAccounts/useParentChildAuthentication.tsx @@ -47,18 +47,22 @@ export const useParentChildAuthentication = () => { const createToken = useCallback( async (euuid: string): Promise => { - return isIAMDelegationEnabled - ? generateProxyToken({ euuid }) - : createProxyToken({ - euuid, - headers: { - /** - * Headers are required for proxy or delegate users when obtaining a proxy or delegate token. - * For 'proxy' or 'delegate' userType, use the stored parent token in the request. - */ - Authorization: getStorage('authentication/parent_token/token'), - }, - }); + const tokenParent = getStorage('authentication/parent_token/token'); + + const mutationFn = isIAMDelegationEnabled + ? generateProxyToken + : createProxyToken; + + return mutationFn({ + euuid, + headers: { + /** + * Headers are required for proxy or delegate users when obtaining a proxy or delegate token. + * For 'proxy' or 'delegate' userType, use the stored parent token in the request. + */ + Authorization: tokenParent, + }, + }); }, [createProxyToken, generateProxyToken, isIAMDelegationEnabled] ); diff --git a/packages/manager/src/features/Account/SwitchAccounts/useSwitchToParentAccount.ts b/packages/manager/src/features/Account/SwitchAccounts/useSwitchToParentAccount.ts index f9ea6ec5efb..94a98755342 100644 --- a/packages/manager/src/features/Account/SwitchAccounts/useSwitchToParentAccount.ts +++ b/packages/manager/src/features/Account/SwitchAccounts/useSwitchToParentAccount.ts @@ -55,7 +55,13 @@ export const useSwitchToParentAccount = ({ } onClose?.(); - location.reload(); + + // For switch back to parent, always redirect to /linodes for delegate users + if (isDelegateUserType) { + location.replace('/linodes'); + } else { + location.reload(); + } } catch (error) { setSubmitting(false); throw error; diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.tsx index 6207bf3eb6d..f2a7443d523 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.tsx @@ -179,8 +179,8 @@ export const AlertResources = React.memo((props: AlertResourcesProp) => { const filteredTypes = alertClass === 'shared' ? Object.keys(databaseTypeClassMap).filter( - (type) => type !== 'dedicated' - ) + (type) => type !== 'dedicated' + ) : [alertClass]; // Apply type filter only for DBaaS user alerts with a valid alertClass based on above filtered types @@ -205,7 +205,10 @@ export const AlertResources = React.memo((props: AlertResourcesProp) => { isLoading: isResourcesLoading, } = useResourcesQuery( Boolean( - serviceType && (serviceType === 'firewall' || supportedRegionIds?.length) + serviceType && + (serviceType === 'firewall' || + serviceType === 'logs' || + supportedRegionIds?.length) ), // Enable query only if serviceType and supportedRegionIds are available, in case of firewall only serviceType is needed serviceType, {}, @@ -468,8 +471,8 @@ export const AlertResources = React.memo((props: AlertResourcesProp) => { new Set( regionFilteredResources ? regionFilteredResources.flatMap( - ({ tags }) => tags ?? [] - ) + ({ tags }) => tags ?? [] + ) : [] ) ), diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/constants.ts b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/constants.ts index 57a793cc4b2..b5254d736a3 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/constants.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/constants.ts @@ -116,6 +116,13 @@ export const serviceTypeBasedColumns: ServiceColumns = { sortingKey: 'region', }, ], + logs: [ + { + accessor: ({ label }) => label, + label: 'Entity', + sortingKey: 'label', + }, + ], }; export const serviceToFiltersMap: Partial< @@ -138,6 +145,7 @@ export const serviceToFiltersMap: Partial< { component: AlertsEndpointFilter, filterKey: 'endpoint' }, ], blockstorage: [{ component: AlertsRegionFilter, filterKey: 'region' }], + logs: [], }; export const applicableAdditionalFilterKeys: AlertAdditionalFilterKey[] = [ 'engineType', // Extendable in future for filter keys like 'tags', 'plan', etc. diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/ValueSchemas.ts b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/ValueSchemas.ts index 89e705be80b..c3e9dd3ed2a 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/ValueSchemas.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/ValueSchemas.ts @@ -19,6 +19,9 @@ import { INTERFACE_ID_ERROR_MESSAGE, PORT_HELPER_TEXT, PORTS_TRAILING_COMMA_ERROR_MESSAGE, + STATUS_CODE_ERROR_MESSAGE, + STATUS_CODES_ERROR_MESSAGE, + STATUS_CODES_HELPER_TEXT, } from '../../../constants'; const LENGTH_ERROR_MESSAGE = 'Value must be 100 characters or less.'; @@ -240,6 +243,73 @@ const multipleInterfacesSchema = string() } ); +const singleStatusCodeSchema = string() + .max(100, LENGTH_ERROR_MESSAGE) + .test( + 'validate-single-status-code-schema', + STATUS_CODE_ERROR_MESSAGE, + function (value) { + if (!value || typeof value !== 'string') { + return this.createError({ message: fieldErrorMessage }); + } + + if (!CONFIG_NUMBER_REGEX.test(value)) { + return this.createError({ message: STATUS_CODE_ERROR_MESSAGE }); + } + + return true; + } + ); + +const multipleStatusCodeSchema = string() + .max(100, LENGTH_ERROR_MESSAGE) + .test( + 'validate-multi-status-code-schema', + STATUS_CODES_ERROR_MESSAGE, + function (value) { + if (!value || typeof value !== 'string') { + return this.createError({ message: fieldErrorMessage }); + } + if (value.includes(' ')) { + return this.createError({ message: STATUS_CODES_ERROR_MESSAGE }); + } + + if (value.trim().endsWith(',')) { + return this.createError({ + message: PORTS_TRAILING_COMMA_ERROR_MESSAGE, + }); + } + + if (value.trim().startsWith(',')) { + return this.createError({ message: PORTS_LEADING_COMMA_ERROR_MESSAGE }); + } + + if (value.trim().includes(',,')) { + return this.createError({ + message: CONFIG_IDS_CONSECUTIVE_COMMAS_ERROR_MESSAGE, + }); + } + if (value.includes('.')) { + return this.createError({ message: STATUS_CODES_HELPER_TEXT }); + } + + const rawSegments = value.split(','); + // Check for empty segments + if (rawSegments.some((segment) => segment.trim() === '')) { + return this.createError({ + message: CONFIG_IDS_CONSECUTIVE_COMMAS_ERROR_MESSAGE, + }); + } + for (const configId of rawSegments) { + const trimmedConfigId = configId.trim(); + + if (!CONFIG_NUMBER_REGEX.test(trimmedConfigId)) { + return this.createError({ message: STATUS_CODE_ERROR_MESSAGE }); + } + } + return true; + } + ); const baseValueSchema = string() .nullable() .required(fieldErrorMessage) @@ -269,6 +339,11 @@ export const getDimensionFilterValueSchema = ({ operator === 'in' ? multipleInterfacesSchema : singleInterfaceSchema; return interfaceSchema.concat(baseValueSchema); } + if (dimensionLabel === 'status_code') { + const statusCodeSchema = + operator === 'in' ? multipleStatusCodeSchema : singleStatusCodeSchema; + return statusCodeSchema.concat(baseValueSchema); + } if (['endswith', 'startswith'].includes(operator)) { return string().max(100, LENGTH_ERROR_MESSAGE).concat(baseValueSchema); } diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/constants.ts b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/constants.ts index e44366b03f2..8eedc70ace5 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/constants.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/constants.ts @@ -15,6 +15,10 @@ import { PORT_HELPER_TEXT, PORT_PLACEHOLDER_TEXT, PORTS_PLACEHOLDER_TEXT, + STATUS_CODE_HELPER_TEXT, + STATUS_CODE_PLACEHOLDER_TEXT, + STATUS_CODES_HELPER_TEXT, + STATUS_CODES_PLACEHOLDER_TEXT, VIP_HELPER_TEXT, VIP_PLACEHOLDER_TEXT, } from '../../../constants'; @@ -366,6 +370,34 @@ export const valueFieldConfig: ValueFieldConfigMap = { inputType: 'text', }, }, + status_code: { + eq_neq: { + type: 'textfield', + inputType: 'number', + min: 0, + max: Number.MAX_SAFE_INTEGER, + placeholder: STATUS_CODE_PLACEHOLDER_TEXT, + helperText: STATUS_CODE_HELPER_TEXT, + }, + startswith_endswith: { + type: 'textfield', + inputType: 'number', + min: 0, + max: Number.MAX_SAFE_INTEGER, + placeholder: STATUS_CODE_PLACEHOLDER_TEXT, + helperText: STATUS_CODE_HELPER_TEXT, + }, + in: { + type: 'textfield', + inputType: 'text', + placeholder: STATUS_CODES_PLACEHOLDER_TEXT, + helperText: STATUS_CODES_HELPER_TEXT, + }, + '*': { + type: 'textfield', + inputType: 'number', + }, + }, emptyValue: { eq_neq: { type: 'textfield', diff --git a/packages/manager/src/features/CloudPulse/Alerts/constants.ts b/packages/manager/src/features/CloudPulse/Alerts/constants.ts index 4a8876f5407..7ac7d64f669 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/constants.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/constants.ts @@ -1,7 +1,5 @@ import type { FieldPath } from 'react-hook-form'; -import { PORTS_HELPER_TEXT } from '../Utils/constants'; - import type { CreateAlertDefinitionForm } from './CreateAlert/types'; import type { AlertDefinitionScope, @@ -277,27 +275,17 @@ export const CONFIGS_ID_PLACEHOLDER_TEXT = 'e.g., 1234,5678'; export const INTERFACE_ID_ERROR_MESSAGE = 'Enter a valid interface ID number.'; export const INTERFACE_ID_HELPER_TEXT = 'Enter an interface ID number.'; -export const PLACEHOLDER_TEXT_MAP: Record> = { - port: { - in: PORTS_PLACEHOLDER_TEXT, - default: PORT_PLACEHOLDER_TEXT, - }, - config_id: { - in: CONFIGS_ID_PLACEHOLDER_TEXT, - default: CONFIG_ID_PLACEHOLDER_TEXT, - }, -}; -export const HELPER_TEXT_MAP: Record> = { - port: { - in: PORTS_HELPER_TEXT, - default: PORT_HELPER_TEXT, - }, - config_id: { - in: CONFIGS_HELPER_TEXT, - default: CONFIG_ERROR_MESSAGE, - }, -}; +export const STATUS_CODE_PLACEHOLDER_TEXT = 'e.g., 200'; +export const STATUS_CODES_PLACEHOLDER_TEXT = 'e.g., 200,403,500'; + +export const STATUS_CODE_HELPER_TEXT = 'Enter a status code number.'; +export const STATUS_CODES_HELPER_TEXT = + 'Enter one or more status codes separated by commas.'; + +export const STATUS_CODE_ERROR_MESSAGE = 'Enter a valid status code number.'; +export const STATUS_CODES_ERROR_MESSAGE = + 'Enter valid status codes as integers separated by commas.'; export const entityLabelMap = { linode: 'Linode', diff --git a/packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts b/packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts index ef456d70de2..68c495dea83 100644 --- a/packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts +++ b/packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts @@ -9,6 +9,7 @@ import { PARENT_ENTITY_REGION, REGION, RESOURCE_ID, + STATUS_CODE_PLACEHOLDER_TEXT, } from './constants'; import { CloudPulseAvailableViews, CloudPulseSelectTypes } from './models'; import { filterKubernetesClusters, getValidSortedEndpoints } from './utils'; @@ -657,6 +658,45 @@ export const NETLOADBALANCER_CONFIG: Readonly = ], serviceType: 'netloadbalancer', }; + +export const LOGS_CONFIG: Readonly = { + capability: capabilityServiceTypeMapping['logs'], + filters: [ + { + configuration: { + filterKey: 'resource_id', + filterType: 'string', + isFilterable: true, + isMetricsFilter: true, + isMultiSelect: true, + name: 'Stream Names', + neededInViews: [CloudPulseAvailableViews.central], + placeholder: 'Select Stream Names', + priority: 2, + }, + name: 'Stream Names', + }, + { + configuration: { + filterKey: 'status_code', + filterType: 'string', + isFilterable: true, + isMetricsFilter: false, + name: 'Status Code', + dimensionKey: 'status_code', + neededInViews: [ + CloudPulseAvailableViews.central, + CloudPulseAvailableViews.service, + ], + isOptional: true, + placeholder: STATUS_CODE_PLACEHOLDER_TEXT, + priority: 2, + }, + name: 'Status Code', + }, + ], + serviceType: 'logs', +}; export const FILTER_CONFIG: Readonly< Map > = new Map([ @@ -670,6 +710,7 @@ export const FILTER_CONFIG: Readonly< [8, FIREWALL_NODEBALANCER_CONFIG], [9, LKE_CONFIG], [10, ENDPOINT_DASHBOARD_CONFIG], + [11, LOGS_CONFIG], ]); /** diff --git a/packages/manager/src/features/CloudPulse/Utils/constants.ts b/packages/manager/src/features/CloudPulse/Utils/constants.ts index c0f95fff64c..738ab4fafe1 100644 --- a/packages/manager/src/features/CloudPulse/Utils/constants.ts +++ b/packages/manager/src/features/CloudPulse/Utils/constants.ts @@ -51,6 +51,8 @@ export const INTERFACE_ID = 'interface_id'; export const FIREWALL = 'Firewall'; +export const STATUS_CODE = 'status_code'; + export const PORTS_HELPER_TEXT = 'Enter one or more port numbers (1-65535) separated by commas.'; @@ -72,6 +74,22 @@ export const PORTS_LEADING_COMMA_ERROR_MESSAGE = export const PORTS_LIMIT_ERROR_MESSAGE = 'Port list must be 100 characters or less.'; +export const STATUS_CODE_HELPER_TEXT = + 'Enter one or more Status Codes separated by commas.'; + +export const STATUS_CODE_ERROR_MESSAGE = + 'Enter valid status codes as integers separated by commas without spaces.'; + +export const STATUS_CODE_CONSECUTIVE_COMMAS_ERROR_MESSAGE = + 'Use a single comma to separate status codes.'; +export const STATUS_CODE_LEADING_COMMA_ERROR_MESSAGE = + 'First character must be an integer.'; + +export const STATUS_CODE_LIMIT_ERROR_MESSAGE = + 'Status code list must be 100 characters or less.'; + +export const STATUS_CODE_PLACEHOLDER_TEXT = 'e.g., 200,400'; + export const PORTS_PLACEHOLDER_TEXT = 'e.g., 80,443,3000'; export const INTERFACE_IDS_HELPER_TEXT = diff --git a/packages/manager/src/features/CloudPulse/Utils/models.ts b/packages/manager/src/features/CloudPulse/Utils/models.ts index 4252612aff5..5743e1f0ea2 100644 --- a/packages/manager/src/features/CloudPulse/Utils/models.ts +++ b/packages/manager/src/features/CloudPulse/Utils/models.ts @@ -12,6 +12,7 @@ import type { NetworkLoadBalancer, NodeBalancer, ObjectStorageBucket, + Stream, Volume, } from '@linode/api-v4'; import type { QueryFunction, QueryKey } from '@tanstack/react-query'; @@ -68,6 +69,7 @@ export type QueryFunctionType = | NetworkLoadBalancer[] | NodeBalancer[] | ObjectStorageBucket[] + | Stream[] | Volume[]; /** * The non array types of QueryFunctionType like DatabaseEngine|DatabaseType diff --git a/packages/manager/src/features/CloudPulse/Utils/utils.ts b/packages/manager/src/features/CloudPulse/Utils/utils.ts index 54e32ba7f59..e172d74127c 100644 --- a/packages/manager/src/features/CloudPulse/Utils/utils.ts +++ b/packages/manager/src/features/CloudPulse/Utils/utils.ts @@ -20,6 +20,11 @@ import { PORTS_LEADING_ZERO_ERROR_MESSAGE, PORTS_LIMIT_ERROR_MESSAGE, PORTS_RANGE_ERROR_MESSAGE, + STATUS_CODE, + STATUS_CODE_CONSECUTIVE_COMMAS_ERROR_MESSAGE, + STATUS_CODE_ERROR_MESSAGE, + STATUS_CODE_LEADING_COMMA_ERROR_MESSAGE, + STATUS_CODE_LIMIT_ERROR_MESSAGE, } from './constants'; import type { FetchOptions } from '../Alerts/CreateAlert/Criteria/DimensionFilterValue/constants'; @@ -391,6 +396,31 @@ export const areValidInterfaceIds = ( return undefined; }; +export const areValidStatusCodes = ( + statusCodes: string +): string | undefined => { + if (statusCodes === '') { + return undefined; + } + + if (statusCodes.length > 100) { + return STATUS_CODE_LIMIT_ERROR_MESSAGE; + } + if (statusCodes.startsWith(',')) { + return STATUS_CODE_LEADING_COMMA_ERROR_MESSAGE; + } + + if (statusCodes.includes(',,')) { + return STATUS_CODE_CONSECUTIVE_COMMAS_ERROR_MESSAGE; + } + + if (!/^[\d,]+$/.test(statusCodes)) { + return STATUS_CODE_ERROR_MESSAGE; + } + + return undefined; +}; + /** * @param filterKey * @returns validation function for the filter key @@ -401,6 +431,7 @@ export const validationFunction: Record< > = { [PORT]: arePortsValid, [INTERFACE_ID]: areValidInterfaceIds, + [STATUS_CODE]: areValidStatusCodes, }; /** diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseComponentRenderer.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseComponentRenderer.tsx index 0a90f3801d4..daa4287d10d 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseComponentRenderer.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseComponentRenderer.tsx @@ -64,6 +64,7 @@ const Components: { associated_entity_region: CloudPulseRegionSelect, endpoint: CloudPulseEndpointsSelect, nodebalancer_id: CloudPulseFirewallNodebalancersSelect, + status_code: CloudPulseTextFilter, }; const buildComponent = (props: CloudPulseComponentRendererProps) => { diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.tsx index f8c7ec342ae..cfb02502aa5 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.tsx @@ -20,6 +20,7 @@ import { REGION, RESOURCE_ID, RESOURCES, + STATUS_CODE, TAGS, } from '../Utils/constants'; import { @@ -379,7 +380,8 @@ export const CloudPulseDashboardFilterBuilder = React.memo( ); } else if ( config.configuration.filterKey === PORT || - config.configuration.filterKey === INTERFACE_ID + config.configuration.filterKey === INTERFACE_ID || + config.configuration.filterKey === STATUS_CODE ) { return getTextFilterProperties( { diff --git a/packages/manager/src/features/Databases/DatabaseDetail/ConnectionDetailsHostDisplay.tsx b/packages/manager/src/features/Databases/DatabaseDetail/ConnectionDetailsHostDisplay.tsx new file mode 100644 index 00000000000..533bb3cc6c3 --- /dev/null +++ b/packages/manager/src/features/Databases/DatabaseDetail/ConnectionDetailsHostDisplay.tsx @@ -0,0 +1,62 @@ +import { TooltipIcon } from '@linode/ui'; +import { styled } from '@mui/material/styles'; +import * as React from 'react'; + +import { CopyTooltip } from 'src/components/CopyTooltip/CopyTooltip'; + +import { + SUMMARY_HOST_TOOLTIP_COPY, + SUMMARY_PRIVATE_HOST_COPY, +} from '../constants'; + +import type { HostEndpoint } from '@linode/api-v4/lib/databases/types'; + +interface ConnectionDetailsHostDisplayProps { + host: HostEndpoint; +} + +export const ConnectionDetailsHostDisplay = ( + props: ConnectionDetailsHostDisplayProps +) => { + const { host } = props; + + return ( + <> + {host?.address} + + + + ); +}; + +export const StyledCopyTooltip = styled(CopyTooltip, { + label: 'StyledCopyTooltip', +})(({ theme }) => ({ + '& svg': { + height: theme.spacingFunction(16), + width: theme.spacingFunction(16), + }, + '&:hover': { + backgroundColor: 'transparent', + }, + display: 'inline-flex', + marginLeft: theme.spacingFunction(4), +})); diff --git a/packages/manager/src/features/Databases/DatabaseDetail/ConnectionDetailsHostRows.test.tsx b/packages/manager/src/features/Databases/DatabaseDetail/ConnectionDetailsHostRows.test.tsx index 043831e70c9..c46c0fed9fc 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/ConnectionDetailsHostRows.test.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/ConnectionDetailsHostRows.test.tsx @@ -17,6 +17,10 @@ const PRIVATE_STANDBY = `private-${DEFAULT_STANDBY}`; const LEGACY_PRIMARY = 'db-mysql-legacy-primary.net'; const LEGACY_SECONDARY = 'db-mysql-legacy-secondary.net'; +/** + * @TODO - delete this file after API releases hostname endpoint changes + */ + describe('ConnectionDetailsHostRows', () => { it('should display Host and Read-only Host fields for a default database with no VPC configured', () => { const database = databaseFactory.build({ diff --git a/packages/manager/src/features/Databases/DatabaseDetail/ConnectionDetailsHostRows.tsx b/packages/manager/src/features/Databases/DatabaseDetail/ConnectionDetailsHostRows.tsx index 0cc591d1eac..87ec404e099 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/ConnectionDetailsHostRows.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/ConnectionDetailsHostRows.tsx @@ -26,6 +26,8 @@ interface ConnectionDetailsHostRowsProps { type HostContentMode = 'default' | 'private' | 'public'; /** + * @deprecated Delete this file in favor of ConnectionDetailsHostRows2 after the API releases hostname endpoint changes. + * * This component is responsible for conditionally rendering the Private Host, Public Host, and Read-only Host rows that get displayed in * the Connection Details tables that appear in the Database Summary and Networking tabs */ export const ConnectionDetailsHostRows = ( diff --git a/packages/manager/src/features/Databases/DatabaseDetail/ConnectionDetailsHostRows2.test.tsx b/packages/manager/src/features/Databases/DatabaseDetail/ConnectionDetailsHostRows2.test.tsx new file mode 100644 index 00000000000..9d894b5b816 --- /dev/null +++ b/packages/manager/src/features/Databases/DatabaseDetail/ConnectionDetailsHostRows2.test.tsx @@ -0,0 +1,180 @@ +import { screen } from '@testing-library/react'; +import React from 'react'; + +import { databaseFactory } from 'src/factories/databases'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { ConnectionDetailsHostRows2 } from './ConnectionDetailsHostRows2'; + +const DEFAULT_PRIMARY = 'db-mysql-default-primary.net'; +const DEFAULT_STANDBY = 'db-mysql-default-standby.net'; + +const PRIVATE_PRIMARY = `private-${DEFAULT_PRIMARY}`; +const PRIVATE_STANDBY = `private-${DEFAULT_STANDBY}`; + +describe('ConnectionDetailsHostRows2', () => { + it('should display Host and Read-only Host fields for a default database with no VPC configured', () => { + const database = databaseFactory.build({ + hosts: { + primary: DEFAULT_PRIMARY, + standby: DEFAULT_STANDBY, + endpoints: [ + { + role: 'primary', + address: DEFAULT_PRIMARY, + port: 15847, + public_access: true, + }, + { + role: 'standby', + address: DEFAULT_STANDBY, + port: 15847, + public_access: true, + }, + ], + }, + platform: 'rdbms-default', + private_network: null, // No VPC configured, so Host and Read-only Host fields render + }); + + renderWithTheme(); + + expect(screen.getByText('Host')).toBeVisible(); + expect(screen.getByText(DEFAULT_PRIMARY)).toBeVisible(); + + expect(screen.getByText('Read-only Host')).toBeVisible(); + expect(screen.getByText(DEFAULT_STANDBY)).toBeVisible(); + }); + + it('should display N/A for default DB with blank read-only Host field', () => { + const database = databaseFactory.build({ + hosts: { + primary: DEFAULT_PRIMARY, + standby: undefined, + endpoints: [ + { + role: 'primary', + address: DEFAULT_PRIMARY, + port: 15847, + public_access: true, + }, + ], + }, + platform: 'rdbms-default', + }); + + renderWithTheme(); + + expect(screen.getByText('N/A')).toBeVisible(); + }); + + it('should display provisioning text when hosts are not available', () => { + const database = databaseFactory.build({ + hosts: undefined, + platform: 'rdbms-default', + }); + + const { getByText } = renderWithTheme( + + ); + + const hostNameProvisioningText = getByText( + 'Your hostname will appear here once it is available.' + ); + + expect(hostNameProvisioningText).toBeInTheDocument(); + }); + + it('should display Private variations of Host and Read-only fields when a VPC is configured with public access set to false', () => { + const database = databaseFactory.build({ + hosts: { + primary: PRIVATE_PRIMARY, + standby: PRIVATE_STANDBY, + endpoints: [ + { + role: 'primary', + address: PRIVATE_PRIMARY, + port: 15847, + public_access: false, + }, + { + role: 'standby', + address: PRIVATE_STANDBY, + port: 15847, + public_access: false, + }, + ], + }, + platform: 'rdbms-default', + private_network: { + public_access: false, + subnet_id: 1, + vpc_id: 123, + }, // VPC configuration with public access set to false + }); + + renderWithTheme(); + + expect(screen.getByText('Private Host')).toBeVisible(); + expect(screen.getByText(PRIVATE_PRIMARY)).toBeVisible(); + expect(screen.getByText('Private Read-only Host')).toBeVisible(); + expect(screen.getByText(PRIVATE_STANDBY)).toBeVisible(); + }); + + it('should display Private and Public variations of Host and Read-only Host fields when a VPC is configured with public access set to true', () => { + const database = databaseFactory.build({ + hosts: { + primary: PRIVATE_PRIMARY, + standby: PRIVATE_STANDBY, + endpoints: [ + { + role: 'primary', + address: `public-${DEFAULT_PRIMARY}`, + port: 15847, + public_access: true, + }, + { + role: 'standby', + address: `public-${DEFAULT_STANDBY}`, + port: 15847, + public_access: true, + }, + { + role: 'primary', + address: PRIVATE_PRIMARY, + port: 15847, + public_access: false, + }, + { + role: 'standby', + address: PRIVATE_STANDBY, + port: 15847, + public_access: false, + }, + ], + }, + platform: 'rdbms-default', + private_network: { + public_access: true, + subnet_id: 1, + vpc_id: 123, + }, // VPC configuration with public access set to true + }); + + renderWithTheme(); + + // Verify that Private and Public Host and Readonly-host fields are rendered + expect(screen.getByText('Private Host')).toBeVisible(); + expect(screen.getByText('Public Host')).toBeVisible(); + expect(screen.getByText('Private Read-only Host')).toBeVisible(); + expect(screen.getByText('Public Read-only Host')).toBeVisible(); + + // Verify that the Private and Public hostname is rendered correctly + expect(screen.getByText(PRIVATE_PRIMARY)).toBeVisible(); + expect(screen.getByText(`public-${DEFAULT_PRIMARY}`)).toBeVisible(); + + // Verify that the Private and Public read-only hostname is rendered correctly + expect(screen.getByText(PRIVATE_STANDBY)).toBeVisible(); + expect(screen.getByText(`public-${DEFAULT_STANDBY}`)).toBeVisible(); + }); +}); diff --git a/packages/manager/src/features/Databases/DatabaseDetail/ConnectionDetailsHostRows2.tsx b/packages/manager/src/features/Databases/DatabaseDetail/ConnectionDetailsHostRows2.tsx new file mode 100644 index 00000000000..f1adca94a1b --- /dev/null +++ b/packages/manager/src/features/Databases/DatabaseDetail/ConnectionDetailsHostRows2.tsx @@ -0,0 +1,90 @@ +import { Typography } from '@linode/ui'; +import * as React from 'react'; + +import { ConnectionDetailsHostDisplay } from './ConnectionDetailsHostDisplay'; +import { ConnectionDetailsRow } from './ConnectionDetailsRow'; +import { useStyles } from './DatabaseSummary/DatabaseSummaryConnectionDetails.style'; + +import type { Database } from '@linode/api-v4/lib/databases/types'; + +interface ConnectionDetailsHostRowsProps { + database: Database; + isSummaryTab?: boolean; +} + +/** + * This component is responsible for conditionally rendering the Private Host, Public Host, and Read-only Host rows that get displayed in + * the Connection Details tables that appear in the Database Summary and Networking tabs */ +export const ConnectionDetailsHostRows2 = ( + props: ConnectionDetailsHostRowsProps +) => { + const { database, isSummaryTab } = props; + const { classes } = useStyles(); + + const hasVPC = Boolean(database?.private_network?.vpc_id); + const hasPublicVPC = hasVPC && database?.private_network?.public_access; + + const getPrimaryHostContent = (mode?: 'private' | 'public') => { + const isPublic = mode === 'private' ? false : true; + const primaryHost = database.hosts?.endpoints.find( + (endpoint) => + endpoint.role === 'primary' && endpoint.public_access === isPublic + ); + + if (!primaryHost) { + return ( + + + Your hostname will appear here once it is available. + + + ); + } + + return ; + }; + + const getReadOnlyHostContent = (mode?: 'private' | 'public') => { + const isPublic = mode === 'private' ? false : true; + const readOnlyHost = database.hosts?.endpoints.find( + (endpoint) => + endpoint.role === 'standby' && endpoint.public_access === isPublic + ); + + if (!readOnlyHost) { + return 'N/A'; + } + + return ; + }; + + return ( + <> + + {getPrimaryHostContent(hasVPC ? 'private' : 'public')} + + {hasPublicVPC && ( + + {getPrimaryHostContent('public')} + + )} + + {getReadOnlyHostContent(hasVPC ? 'private' : 'public')} + + {hasPublicVPC && ( + + {getReadOnlyHostContent('public')} + + )} + + ); +}; diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseConnectionPoolRow.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseConnectionPoolRow.tsx index 38d88d7bd58..c29d42b0ae0 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseConnectionPoolRow.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseConnectionPoolRow.tsx @@ -6,10 +6,12 @@ import { ActionMenu } from 'src/components/ActionMenu/ActionMenu'; import { CONNECTION_POOL_LABEL_CELL_STYLES } from 'src/features/Databases/constants'; import { StyledActionMenuWrapper } from 'src/features/Databases/shared.styles'; -import type { ConnectionPool } from '@linode/api-v4'; +import type { ConnectionPool, DatabaseStatus } from '@linode/api-v4'; import type { Action } from 'src/components/ActionMenu/ActionMenu'; interface Props { + /** Status of the Database */ + databaseStatus: DatabaseStatus; /** * Function called when the delete button in the Action Menu is pressed. */ @@ -25,12 +27,17 @@ interface Props { } export const DatabaseConnectionPoolRow = (props: Props) => { - const { pool, onDelete, onEdit } = props; + const { pool, onDelete, onEdit, databaseStatus } = props; + const editDisabled = databaseStatus === 'provisioning'; const connectionPoolActions: Action[] = [ { onClick: () => onEdit(pool), title: 'Edit', + disabled: editDisabled, + tooltip: editDisabled + ? 'Your Database Cluster is currently provisioning.' + : '', }, { onClick: () => onDelete(pool), diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseConnectionPools.test.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseConnectionPools.test.tsx index d0f617f20e9..0c987570a1e 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseConnectionPools.test.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseConnectionPools.test.tsx @@ -14,6 +14,23 @@ import { DatabaseConnectionPools } from './DatabaseConnectionPools'; const mockDatabase = databaseFactory.build({ platform: 'rdbms-default', private_network: null, + hosts: { + primary: 'db-mysql-primary-0.b.linodeb.net', + endpoints: [ + { + role: 'primary', + address: 'db-mysql-primary-0.b.linodeb.net', + port: 15847, + public_access: true, + }, + { + role: 'primary-connection-pool', + address: 'public-db-mysql-primary-0.b.linodeb.net', + port: 15848, + public_access: true, + }, + ], + }, engine: 'postgresql', id: 1, }); @@ -109,13 +126,15 @@ describe('DatabaseConnectionPools Component', () => { expect(errorStateText).toBeInTheDocument(); }); - it('should render service URI component if there are connection pools', () => { + it('should render service URI component if there are connection pools and hostnameEndpoints flag is true', () => { queryMocks.useDatabaseConnectionPoolsQuery.mockReturnValue({ data: makeResourcePage([mockConnectionPool]), isLoading: false, }); - renderWithTheme(); + renderWithTheme(, { + flags: { hostnameEndpoints: true }, + }); const serviceURIText = screen.getByText('Service URI'); expect(serviceURIText).toBeInTheDocument(); }); diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseConnectionPools.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseConnectionPools.tsx index e1fb2238719..b6664b2cc9f 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseConnectionPools.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseConnectionPools.tsx @@ -7,6 +7,7 @@ import { Stack, Typography, } from '@linode/ui'; +import Grid from '@mui/material/Grid'; import { useTheme } from '@mui/material/styles'; import { Pagination } from 'akamai-cds-react-components/Pagination'; import { @@ -20,14 +21,18 @@ import { import React from 'react'; import { Link } from 'src/components/Link'; -import { - MIN_PAGE_SIZE, - PAGE_SIZES, -} from 'src/components/PaginationFooter/PaginationFooter.constants'; +import { MIN_PAGE_SIZE } from 'src/components/PaginationFooter/PaginationFooter.constants'; +import { DEFAULT_PAGE_SIZES } from 'src/features/Databases/constants'; import { CONNECTION_POOL_LABEL_CELL_STYLES, MANAGE_CONNECTION_POOLS_LEARN_MORE_LINK, } from 'src/features/Databases/constants'; +import { + StyledGridContainer, + StyledLabelTypography, + StyledValueGrid, +} from 'src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryClusterConfiguration.style'; +import { useFlags } from 'src/hooks/useFlags'; import { usePaginationV2 } from 'src/hooks/usePaginationV2'; import { makeSettingsItemStyles } from '../../shared.styles'; @@ -47,6 +52,7 @@ interface Props { export const DatabaseConnectionPools = ({ database }: Props) => { const { classes } = makeSettingsItemStyles(); const theme = useTheme(); + const flags = useFlags(); const isDatabaseInactive = database.status !== 'active'; const [deletePoolLabelSelection, setDeletePoolLabelSelection] = @@ -70,15 +76,8 @@ export const DatabaseConnectionPools = ({ database }: Props) => { page_size: pagination.pageSize, }); - if (connectionPoolsLoading) { - return ; - } - - if (connectionPoolsError) { - return ( - - ); - } + const hasVPC = Boolean(database?.private_network?.vpc_id); + const hasPublicVPC = hasVPC && database.private_network?.public_access; return ( <> @@ -110,9 +109,42 @@ export const DatabaseConnectionPools = ({ database }: Props) => { Add Pool - {connectionPools && connectionPools.data.length > 0 && ( - - )} + {flags?.hostnameEndpoints && + connectionPools && + connectionPools.data.length > 0 && ( + + + + {hasPublicVPC ? 'Public Service URI' : 'Service URI'} + + + + + + {hasPublicVPC && ( + <> + + + Private Service URI + + + + + + + )} + + )}
{ + {connectionPoolsLoading && } + {connectionPoolsError && ( + + )} {connectionPools?.data.length === 0 ? ( { ) : ( connectionPools?.data.map((pool) => ( setDeletePoolLabelSelection(pool.label)} onEdit={() => setEditPoolSelection(pool)} @@ -183,7 +220,7 @@ export const DatabaseConnectionPools = ({ database }: Props) => { ) => pagination.handlePageSizeChange(Number(e.detail.pageSize))} page={pagination.page} pageSize={pagination.pageSize} - pageSizes={PAGE_SIZES} + pageSizes={DEFAULT_PAGE_SIZES} style={{ borderLeft: `1px solid ${theme.tokens.alias.Border.Normal}`, borderRight: `1px solid ${theme.tokens.alias.Border.Normal}`, diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseManageNetworking.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseManageNetworking.tsx index a441e72e98c..27b0993432b 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseManageNetworking.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseManageNetworking.tsx @@ -15,6 +15,7 @@ import { useFlags } from 'src/hooks/useFlags'; import { MANAGE_NETWORKING_LEARN_MORE_LINK } from '../../constants'; import { makeSettingsItemStyles } from '../../shared.styles'; import { ConnectionDetailsHostRows } from '../ConnectionDetailsHostRows'; +import { ConnectionDetailsHostRows2 } from '../ConnectionDetailsHostRows2'; import { ConnectionDetailsRow } from '../ConnectionDetailsRow'; import { StyledGridContainer } from '../DatabaseSummary/DatabaseSummaryClusterConfiguration.style'; import DatabaseManageNetworkingDrawer from './DatabaseManageNetworkingDrawer'; @@ -122,8 +123,11 @@ export const DatabaseManageNetworking = ({ database }: Props) => { )} - - + {flags.hostnameEndpoints ? ( + + ) : ( + + )} {hasVPCConfigured && ( {database?.private_network?.public_access ? 'Yes' : 'No'} diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseNetworking.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseNetworking.tsx index 55d2c4050b0..dfcc395a451 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseNetworking.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseNetworking.tsx @@ -20,6 +20,9 @@ export const DatabaseNetworking = () => { {ACCESS_CONTROLS_IN_SETTINGS_TEXT} ); + const pgBouncerEnabled = + flags.databasePgBouncer && database.engine === 'postgresql'; + if (!isVPCEnabled) { navigate({ to: `/databases/$engine/$databaseId/summary`, @@ -40,9 +43,7 @@ export const DatabaseNetworking = () => { disabled={disabled} /> - {flags.databasePgBouncer && database.engine === 'postgresql' && ( - - )} + {pgBouncerEnabled && } ); diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummary.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummary.tsx index 79f7cdfae6c..7c195b20347 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummary.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummary.tsx @@ -5,6 +5,11 @@ import { styled } from '@mui/material/styles'; import * as React from 'react'; import ClusterConfiguration from 'src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryClusterConfiguration'; +import { + StyledGridContainer, + StyledLabelTypography, + StyledValueGrid, +} from 'src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryClusterConfiguration.style'; import ConnectionDetails from 'src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryConnectionDetails'; import { useFlags } from 'src/hooks/useFlags'; @@ -16,17 +21,20 @@ export const DatabaseSummary = () => { const { database } = useDatabaseDetailContext(); const flags = useFlags(); + const pgBouncerEnabled = + flags.databasePgBouncer && database.engine === 'postgresql'; + const { data: connectionPools } = useDatabaseConnectionPoolsQuery( database.id, - flags.databasePgBouncer, + pgBouncerEnabled, {} ); const showPgBouncerConnectionDetails = - flags.databasePgBouncer && - database.engine === 'postgresql' && - connectionPools && - connectionPools.data.length > 0; + pgBouncerEnabled && connectionPools && connectionPools.data.length > 0; + + const hasVPC = Boolean(database?.private_network?.vpc_id); + const hasPublicVPC = hasVPC && database.private_network?.public_access; return ( @@ -47,7 +55,7 @@ export const DatabaseSummary = () => { > - {showPgBouncerConnectionDetails && ( + {flags.hostnameEndpoints && showPgBouncerConnectionDetails && ( { PgBouncer Connection Details - + + + + {hasPublicVPC ? 'Public Service URI' : 'Service URI'} + + + + + + {hasPublicVPC && ( + <> + + + Private Service URI + + + + + + + )} + )} diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryConnectionDetails.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryConnectionDetails.tsx index 5329032f66c..4e99bfa1a9b 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryConnectionDetails.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryConnectionDetails.tsx @@ -10,6 +10,7 @@ import { useFlags } from 'src/hooks/useFlags'; import { isDefaultDatabase } from '../../utilities'; import { ConnectionDetailsHostRows } from '../ConnectionDetailsHostRows'; +import { ConnectionDetailsHostRows2 } from '../ConnectionDetailsHostRows2'; import { ConnectionDetailsRow } from '../ConnectionDetailsRow'; import { ServiceURI } from '../ServiceURI'; import { StyledGridContainer } from './DatabaseSummaryClusterConfiguration.style'; @@ -116,17 +117,32 @@ export const DatabaseSummaryConnectionDetails = (props: Props) => { ); + const hasPublicVPC = hasVPC && database.private_network?.public_access; + const showServiceURIs = flags.hostnameEndpoints && flags.databasePgBouncer; + return ( <> Connection Details - {flags.databasePgBouncer && ( - + {showServiceURIs && ( + )} + {showServiceURIs && hasPublicVPC && ( + + + + )} {username} @@ -136,7 +152,11 @@ export const DatabaseSummaryConnectionDetails = (props: Props) => { {isLegacy ? database.engine : 'defaultdb'} - + {flags.hostnameEndpoints ? ( + + ) : ( + + )} {database.port} diff --git a/packages/manager/src/features/Databases/DatabaseDetail/ServiceURI.test.tsx b/packages/manager/src/features/Databases/DatabaseDetail/ServiceURI.test.tsx index b9379d57746..050d13a2f01 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/ServiceURI.test.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/ServiceURI.test.tsx @@ -8,19 +8,142 @@ import { renderWithTheme } from 'src/utilities/testHelpers'; import { ServiceURI } from './ServiceURI'; -const mockDatabase = databaseFactory.build({ - connection_pool_port: 100, - engine: 'postgresql', - id: 1, - platform: 'rdbms-default', - private_network: null, -}); - const mockCredentials = { password: 'password123', username: 'lnroot', }; +const DEFAULT_PRIMARY = 'db-postgres-default-primary.net'; +const DEFAULT_STANDBY = 'db-postgres-default-standby.net'; + +const PRIVATE_PRIMARY = `private-${DEFAULT_PRIMARY}`; +const PRIVATE_STANDBY = `private-${DEFAULT_STANDBY}`; + +const PRIMARY_PUBLIC_CONNECTION_POOL = + 'public-db-postgres-primary-connection-pool-0.b.linodeb.net'; +const PRIMARY_PRIVATE_CONNECTION_POOL = + 'private-db-postgres-primary-connection-pool-0.b.linodeb.net'; + +const databaseWithNoVPC = databaseFactory.build({ + engine: 'postgresql', + hosts: { + primary: DEFAULT_PRIMARY, + standby: DEFAULT_STANDBY, + endpoints: [ + { + role: 'primary', + address: DEFAULT_PRIMARY, + port: 3306, + public_access: true, + }, + { + role: 'primary-connection-pool', + address: PRIMARY_PUBLIC_CONNECTION_POOL, + port: 15848, + public_access: true, + }, + { + role: 'standby-connection-pool', + address: + 'public-replica-db-postgres-standby-connection-pool-0.b.linodeb.net', + port: 15848, + public_access: true, + }, + ], + }, + platform: 'rdbms-default', + private_network: null, // No VPC configured +}); + +const databaseWithPrivateVPC = databaseFactory.build({ + engine: 'postgresql', + hosts: { + primary: PRIVATE_PRIMARY, + standby: PRIVATE_STANDBY, + endpoints: [ + { + role: 'primary', + address: PRIVATE_PRIMARY, + port: 3306, + public_access: false, + }, + { + role: 'primary-connection-pool', + address: PRIMARY_PRIVATE_CONNECTION_POOL, + port: 15848, + public_access: false, + }, + { + role: 'standby-connection-pool', + address: + 'private-replica-db-postgres-standby-connection-pool-0.b.linodeb.net', + port: 15848, + public_access: false, + }, + ], + }, + platform: 'rdbms-default', + private_network: { + public_access: false, + subnet_id: 1, + vpc_id: 123, + }, +}); + +const databaseWithPublicVPC = databaseFactory.build({ + engine: 'postgresql', + hosts: { + primary: PRIVATE_PRIMARY, + standby: PRIVATE_STANDBY, + endpoints: [ + { + role: 'primary', + address: PRIVATE_PRIMARY, + port: 3306, + public_access: false, + }, + { + role: 'primary-connection-pool', + address: PRIMARY_PRIVATE_CONNECTION_POOL, + port: 15848, + public_access: false, + }, + { + role: 'standby-connection-pool', + address: + 'private-replica-db-postgres-standby-connection-pool-0.b.linodeb.net', + port: 15848, + public_access: false, + }, + { + role: 'primary', + address: DEFAULT_PRIMARY, + port: 3306, + public_access: true, + }, + { + role: 'primary-connection-pool', + address: PRIMARY_PUBLIC_CONNECTION_POOL, + port: 15848, + public_access: true, + }, + { + role: 'standby-connection-pool', + address: + 'public-replica-db-postgres-standby-connection-pool-0.b.linodeb.net', + port: 15848, + public_access: true, + }, + ], + }, + platform: 'rdbms-default', + private_network: { + public_access: true, + subnet_id: 1, + vpc_id: 123, + }, +}); + // Hoist query mocks const queryMocks = vi.hoisted(() => { return { @@ -41,8 +164,9 @@ describe('ServiceURI', () => { queryMocks.useDatabaseCredentialsQuery.mockReturnValue({ data: mockCredentials, }); + const { container } = renderWithTheme( - + ); const revealPasswordBtn = screen.getByRole('button', { @@ -52,7 +176,7 @@ describe('ServiceURI', () => { expect(revealPasswordBtn).toBeInTheDocument(); expect(serviceURIText).toBe( - `postgres://{click to reveal password}@db-mysql-primary-0.b.linodeb.net:100/{connection pool label}?sslmode=require` + `postgres://{click to reveal password}@${PRIMARY_PUBLIC_CONNECTION_POOL}:15848/{connection pool label}?sslmode=require` ); // eslint-disable-next-line testing-library/no-container @@ -65,7 +189,8 @@ describe('ServiceURI', () => { data: mockCredentials, refetch: vi.fn(), }); - renderWithTheme(); + + renderWithTheme(); const revealPasswordBtn = screen.getByRole('button', { name: '{click to reveal password}', @@ -75,7 +200,7 @@ describe('ServiceURI', () => { const serviceURIText = screen.getByTestId('service-uri').textContent; expect(revealPasswordBtn).not.toBeInTheDocument(); expect(serviceURIText).toBe( - `postgres://lnroot:password123@db-mysql-primary-0.b.linodeb.net:100/{connection pool label}?sslmode=require` + `postgres://lnroot:password123@${PRIMARY_PUBLIC_CONNECTION_POOL}:15848/{connection pool label}?sslmode=require` ); }); @@ -84,7 +209,7 @@ describe('ServiceURI', () => { error: new Error('Failed to fetch credentials'), }); - renderWithTheme(); + renderWithTheme(); const errorRetryBtn = screen.getByRole('button', { name: '{error. click to retry}', @@ -96,8 +221,9 @@ describe('ServiceURI', () => { queryMocks.useDatabaseCredentialsQuery.mockReturnValue({ data: mockCredentials, }); - const { container } = renderWithTheme( - + + renderWithTheme( + ); const revealPasswordBtn = screen.getByRole('button', { @@ -107,12 +233,8 @@ describe('ServiceURI', () => { expect(revealPasswordBtn).toBeInTheDocument(); expect(serviceURIText).toBe( - `postgres://{click to reveal password}@db-mysql-primary-0.b.linodeb.net:3306/defaultdb?sslmode=require` + `postgres://{click to reveal password}@${DEFAULT_PRIMARY}:3306/defaultdb?sslmode=require` ); - - // eslint-disable-next-line testing-library/no-container - const copyButton = container.querySelector('[data-qa-copy-btn]'); - expect(copyButton).toBeInTheDocument(); }); it('should reveal general service URI password after clicking reveal button', async () => { @@ -120,7 +242,9 @@ describe('ServiceURI', () => { data: mockCredentials, refetch: vi.fn(), }); - renderWithTheme(); + renderWithTheme( + + ); const revealPasswordBtn = screen.getByRole('button', { name: '{click to reveal password}', @@ -130,7 +254,107 @@ describe('ServiceURI', () => { const serviceURIText = screen.getByTestId('service-uri').textContent; expect(revealPasswordBtn).not.toBeInTheDocument(); expect(serviceURIText).toBe( - `postgres://password123@db-mysql-primary-0.b.linodeb.net:3306/defaultdb?sslmode=require` + `postgres://password123@${DEFAULT_PRIMARY}:3306/defaultdb?sslmode=require` + ); + }); + + it('should render private service URI component if there is a private-only VPC', async () => { + queryMocks.useDatabaseCredentialsQuery.mockReturnValue({ + data: mockCredentials, + }); + + renderWithTheme(); + + const revealPasswordBtn = screen.getByRole('button', { + name: '{click to reveal password}', + }); + const serviceURIText = screen.getByTestId('service-uri').textContent; + + expect(revealPasswordBtn).toBeInTheDocument(); + expect(serviceURIText).toBe( + `postgres://{click to reveal password}@${PRIMARY_PRIVATE_CONNECTION_POOL}:15848/{connection pool label}?sslmode=require` + ); + }); + + it('should render private general service URI component if there is a private-only VPC', async () => { + queryMocks.useDatabaseCredentialsQuery.mockReturnValue({ + data: mockCredentials, + }); + + renderWithTheme( + + ); + + const revealPasswordBtn = screen.getByRole('button', { + name: '{click to reveal password}', + }); + const serviceURIText = screen.getByTestId('service-uri').textContent; + + expect(revealPasswordBtn).toBeInTheDocument(); + expect(serviceURIText).toBe( + `postgres://{click to reveal password}@${PRIVATE_PRIMARY}:3306/defaultdb?sslmode=require` + ); + }); + + it('should render public service URI component if there is a VPC with public access', async () => { + queryMocks.useDatabaseCredentialsQuery.mockReturnValue({ + data: mockCredentials, + }); + + renderWithTheme(); + + const revealPasswordBtn = screen.getByRole('button', { + name: '{click to reveal password}', + }); + const serviceURIText = screen.getByTestId('service-uri').textContent; + + expect(revealPasswordBtn).toBeInTheDocument(); + expect(serviceURIText).toBe( + `postgres://{click to reveal password}@${PRIMARY_PUBLIC_CONNECTION_POOL}:15848/{connection pool label}?sslmode=require` + ); + }); + + it('should render private service URI component if there is a VPC with public access and showPrivateVPC is true', async () => { + queryMocks.useDatabaseCredentialsQuery.mockReturnValue({ + data: mockCredentials, + }); + + renderWithTheme( + + ); + + const revealPasswordBtn = screen.getByRole('button', { + name: '{click to reveal password}', + }); + const serviceURIText = screen.getByTestId('service-uri').textContent; + + expect(revealPasswordBtn).toBeInTheDocument(); + expect(serviceURIText).toBe( + `postgres://{click to reveal password}@${PRIMARY_PRIVATE_CONNECTION_POOL}:15848/{connection pool label}?sslmode=require` + ); + }); + + it('should render general private service URI if there is a VPC with public access, isGeneralServiceURI is true, and showPrivateVPC is true', () => { + queryMocks.useDatabaseCredentialsQuery.mockReturnValue({ + data: mockCredentials, + }); + + renderWithTheme( + + ); + + const revealPasswordBtn = screen.getByRole('button', { + name: '{click to reveal password}', + }); + const serviceURIText = screen.getByTestId('service-uri').textContent; + + expect(revealPasswordBtn).toBeInTheDocument(); + expect(serviceURIText).toBe( + `postgres://{click to reveal password}@${PRIVATE_PRIMARY}:3306/defaultdb?sslmode=require` ); }); }); diff --git a/packages/manager/src/features/Databases/DatabaseDetail/ServiceURI.tsx b/packages/manager/src/features/Databases/DatabaseDetail/ServiceURI.tsx index 0cfe994211d..d417979e572 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/ServiceURI.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/ServiceURI.tsx @@ -1,5 +1,5 @@ import { useDatabaseCredentialsQuery } from '@linode/queries'; -import { Button } from '@linode/ui'; +import { Button, TooltipIcon } from '@linode/ui'; import { Grid, styled } from '@mui/material'; import copy from 'copy-to-clipboard'; import { enqueueSnackbar } from 'notistack'; @@ -7,21 +7,22 @@ import React, { useState } from 'react'; import { Code } from 'src/components/Code/Code'; import { CopyTooltip } from 'src/components/CopyTooltip/CopyTooltip'; -import { - StyledGridContainer, - StyledLabelTypography, - StyledValueGrid, -} from 'src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryClusterConfiguration.style'; +import { StyledValueGrid } from 'src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryClusterConfiguration.style'; import type { Database, DatabaseCredentials } from '@linode/api-v4'; interface ServiceURIProps { database: Database; isGeneralServiceURI?: boolean; + showPrivateVPC?: boolean; } export const ServiceURI = (props: ServiceURIProps) => { - const { database, isGeneralServiceURI = false } = props; + const { + database, + isGeneralServiceURI = false, + showPrivateVPC = false, + } = props; const [hidePassword, setHidePassword] = useState(true); const [isCopying, setIsCopying] = useState(false); @@ -36,6 +37,26 @@ export const ServiceURI = (props: ServiceURIProps) => { refetch: getDatabaseCredentials, } = useDatabaseCredentialsQuery(database.engine, database.id, !hidePassword); + const hasVPC = Boolean(database?.private_network?.vpc_id); + const hasPublicVPC = hasVPC && database.private_network?.public_access; + // If there is a VPC, use VPC public access unless we want to explicitly show private access, otherwise default to public + const publicAccess = + hasPublicVPC && showPrivateVPC + ? false + : hasVPC + ? database.private_network?.public_access + : true; + + const primaryHost = database.hosts?.endpoints.find( + (endpoint) => + endpoint.role === 'primary' && endpoint.public_access === publicAccess + ); + const primaryConnectionPoolHost = database.hosts?.endpoints.find( + (endpoint) => + endpoint.role === 'primary-connection-pool' && + endpoint.public_access === publicAccess + ); + const handleCopy = async () => { if (!credentials) { try { @@ -43,7 +64,7 @@ export const ServiceURI = (props: ServiceURIProps) => { const { data } = await getDatabaseCredentials(); if (data) { // copy with revealed credentials - copy(getServiceURIText(isGeneralServiceURI, data)); + copy(getServiceURIText(data, isGeneralServiceURI)); } else { enqueueSnackbar( 'There was an error retrieving cluster credentials. Please try again.', @@ -62,13 +83,13 @@ export const ServiceURI = (props: ServiceURIProps) => { }; const getServiceURIText = ( - isGeneralServiceURI: boolean, - credentials: DatabaseCredentials | undefined + credentials: DatabaseCredentials | undefined, + isGeneralServiceURI?: boolean ) => { if (isGeneralServiceURI) { - return `${engine}://${credentials?.password}@${database.hosts?.primary}:${database.port}/defaultdb?sslmode=require`; + return `${engine}://${credentials?.password}@${primaryHost?.address}:${primaryHost?.port}/defaultdb?sslmode=require`; } - return `postgres://${credentials?.username}:${credentials?.password}@${database.hosts?.primary}:${database.connection_pool_port}/{connection pool label}?sslmode=require`; + return `postgres://${credentials?.username}:${credentials?.password}@${primaryConnectionPoolHost?.address}:${primaryConnectionPoolHost?.port}/{connection pool label}?sslmode=require`; }; const getCredentials = (isGeneralServiceURI: boolean) => { @@ -110,7 +131,7 @@ export const ServiceURI = (props: ServiceURIProps) => { ); - const ServiceURIJSX = (isGeneralServiceURI: boolean) => ( + return ( { sx={{ overflowX: 'auto', overflowY: 'hidden', - p: isGeneralServiceURI ? '0' : null, + p: '0', }} whiteSpace="pre" > @@ -128,16 +149,17 @@ export const ServiceURI = (props: ServiceURIProps) => { : hidePassword || (!credentialsError && !credentials) ? RevealPasswordButton : getCredentials(isGeneralServiceURI)} - {!isGeneralServiceURI ? ( + {isGeneralServiceURI ? ( <> - @{database.hosts?.primary}:{database.connection_pool_port}/ - {'{connection pool label}'} - ?sslmode=require + @{primaryHost?.address}: + {`${primaryHost?.port}/defaultdb?sslmode=require`} ) : ( <> - @{database.hosts?.primary}: - {`${database.port}/defaultdb?sslmode=require`} + @{primaryConnectionPoolHost?.address}: + {primaryConnectionPoolHost?.port}/ + {'{connection pool label}'} + ?sslmode=require )} @@ -149,33 +171,29 @@ export const ServiceURI = (props: ServiceURIProps) => { + + )} + {hasPublicVPC && showPrivateVPC && ( + + )} ); - - if (isGeneralServiceURI) { - return ServiceURIJSX(isGeneralServiceURI); - } - - return ( - - - Service URI - - {ServiceURIJSX(isGeneralServiceURI)} - - ); }; -const StyledCode = styled(Code, { +export const StyledCode = styled(Code, { label: 'StyledCode', })(() => ({ margin: 0, diff --git a/packages/manager/src/features/Databases/DatabaseDetail/index.tsx b/packages/manager/src/features/Databases/DatabaseDetail/index.tsx index 420abc5e2c6..989f7684524 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/index.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/index.tsx @@ -3,14 +3,7 @@ import { useDatabaseQuery, useDatabaseTypesQuery, } from '@linode/queries'; -import { - BetaChip, - CircleProgress, - ErrorState, - NewFeatureChip, - Notice, - Typography, -} from '@linode/ui'; +import { BetaChip, CircleProgress, ErrorState, Notice } from '@linode/ui'; import { useEditableLabelState } from '@linode/utilities'; import { Outlet, @@ -20,7 +13,6 @@ import { } from '@tanstack/react-router'; import * as React from 'react'; -import { DismissibleBanner } from 'src/components/DismissibleBanner/DismissibleBanner'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { LandingHeader } from 'src/components/LandingHeader'; import { TabPanels } from 'src/components/Tabs/TabPanels'; @@ -69,7 +61,7 @@ export const DatabaseDetail = () => { const settingsTabPath = `/databases/$engine/$databaseId/settings`; - const { tabs, tabIndex, handleTabChange, getTabIndex } = useTabs([ + const { tabs, tabIndex, handleTabChange } = useTabs([ { to: `/databases/$engine/$databaseId/summary`, title: 'Summary', @@ -84,7 +76,6 @@ export const DatabaseDetail = () => { to: `/databases/$engine/$databaseId/networking`, title: 'Networking', hide: !isVPCEnabled, - chip: , }, { to: `/databases/$engine/$databaseId/backups`, @@ -153,8 +144,6 @@ export const DatabaseDetail = () => { }); }; - const onSettingsTab = tabIndex === getTabIndex(settingsTabPath); - return ( { variant="warning" /> )} - {isVPCEnabled && onSettingsTab && ( - - - The Manage Access settings were moved and are now available in the - Networking tab. - - - )} - diff --git a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLandingTable.tsx b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLandingTable.tsx index 9bb985eed80..3eaeba8e5e9 100644 --- a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLandingTable.tsx +++ b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLandingTable.tsx @@ -10,7 +10,9 @@ import { } from 'akamai-cds-react-components/Table'; import React from 'react'; +import { MIN_PAGE_SIZE } from 'src/components/PaginationFooter/PaginationFooter.constants'; import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; +import { DEFAULT_PAGE_SIZES } from 'src/features/Databases/constants'; import { DatabaseSettingsDeleteClusterDialog } from 'src/features/Databases/DatabaseDetail/DatabaseSettings/DatabaseSettingsDeleteClusterDialog'; import DatabaseSettingsResetPasswordDialog from 'src/features/Databases/DatabaseDetail/DatabaseSettings/DatabaseSettingsResetPasswordDialog'; import { ManageAccessControlDrawer } from 'src/features/Databases/DatabaseDetail/ManageAccessControlDrawer'; @@ -54,8 +56,6 @@ const DatabaseLandingTable = ({ preferenceKey, queryParamsPrefix: dbPlatformType, }); - const PAGE_SIZES = [25, 50, 75, 100]; - const MIN_PAGE_SIZE = 25; const [selectedDatabase, setSelectedDatabase] = React.useState({} as DatabaseInstance); @@ -245,7 +245,7 @@ const DatabaseLandingTable = ({ ) => pagination.handlePageSizeChange(Number(e.detail.pageSize))} page={pagination.page} pageSize={pagination.pageSize} - pageSizes={PAGE_SIZES} + pageSizes={DEFAULT_PAGE_SIZES} style={{ borderLeft: `1px solid ${theme.tokens.alias.Border.Normal}`, borderRight: `1px solid ${theme.tokens.alias.Border.Normal}`, diff --git a/packages/manager/src/features/Databases/constants.ts b/packages/manager/src/features/Databases/constants.ts index 2f5572989d5..7dd4ac4f34a 100644 --- a/packages/manager/src/features/Databases/constants.ts +++ b/packages/manager/src/features/Databases/constants.ts @@ -92,3 +92,5 @@ export const usernameOptions = [ { label: defaultUsername, value: defaultUsername }, { label: 'akmadmin', value: 'akmadmin' }, ]; // Currently the only options for the username field + +export const DEFAULT_PAGE_SIZES = [25, 50, 75, 100]; diff --git a/packages/manager/src/features/Databases/utilities.test.ts b/packages/manager/src/features/Databases/utilities.test.ts index 1f3648fbdd4..dbc95ab1275 100644 --- a/packages/manager/src/features/Databases/utilities.test.ts +++ b/packages/manager/src/features/Databases/utilities.test.ts @@ -545,7 +545,7 @@ describe('getReadOnlyHost', () => { { address: 'public-primary.example.com', role: 'primary' as HostEndpointRole, - private_access: false, + public_access: true, port: 12345, }, ], @@ -565,7 +565,7 @@ describe('getReadOnlyHost', () => { { address: 'public-primary.example.com', role: 'primary' as HostEndpointRole, - private_access: false, + public_access: true, port: 12345, }, ], 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 8bfd950652b..0082a289411 100644 --- a/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationCreate.test.tsx +++ b/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationCreate.test.tsx @@ -5,7 +5,7 @@ import userEvent from '@testing-library/user-event'; import React from 'react'; import { describe, expect, it, vi } from 'vitest'; -import { accountFactory } from 'src/factories'; +import { accountFactory, objectStorageBucketFactory } from 'src/factories'; import { http, HttpResponse, server } from 'src/mocks/testServer'; import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; @@ -14,11 +14,49 @@ import { DestinationCreate } from './DestinationCreate'; import type { CreateDestinationPayload } from '@linode/api-v4'; import type { Flags } from 'src/featureFlags'; +const mockBuckets = [ + objectStorageBucketFactory.build({ + hostname: 'bucket-with-hostname.us-east-1.linodeobjects.com', + label: 'bucket-with-hostname', + region: 'us-east', + }), + objectStorageBucketFactory.build({ + hostname: 'bucket-with-s3-endpoint.eu-central-1.linodeobjects.com', + label: 'bucket-with-s3-endpoint', + region: 'eu-central', + s3_endpoint: 'eu-central-1.linodeobjects.com', + }), +]; + +const queryMocks = vi.hoisted(() => ({ + useObjectStorageBuckets: vi.fn().mockReturnValue({ + data: undefined, + error: null, + isPending: true, + }), +})); + +vi.mock('src/queries/object-storage/queries', async () => { + const actual = await vi.importActual('src/queries/object-storage/queries'); + return { + ...actual, + useObjectStorageBuckets: queryMocks.useObjectStorageBuckets, + }; +}); + const testConnectionButtonText = 'Test Connection'; const createDestinationButtonText = 'Create Destination'; const addCustomHeaderButtonText = 'Add Custom Header'; describe('DestinationCreate', () => { + beforeEach(() => { + queryMocks.useObjectStorageBuckets.mockReturnValue({ + data: { buckets: mockBuckets }, + error: null, + isPending: false, + }); + }); + const renderDestinationCreate = ( flags: Partial, defaultValues?: Partial @@ -64,18 +102,31 @@ describe('DestinationCreate', () => { expect(destinationNameInput).toHaveValue('Test Destination'); }); - it('should render Host input and allow to type text', async () => { + it('should render Endpoint input as disabled in bucket_from_account mode', () => { renderDestinationCreate(flags); - const hostInput = screen.getByLabelText('Host'); - await userEvent.type(hostInput, 'test-host.com'); + const endpointInput = screen.getByLabelText('Endpoint'); + expect(endpointInput).toBeDisabled(); + }); - expect(hostInput).toHaveValue('test-host.com'); + it('should render Endpoint input and allow to type text in manual mode', async () => { + renderDestinationCreate(flags); + + const manualRadio = screen.getByLabelText('Enter Bucket manually'); + await userEvent.click(manualRadio); + + const endpointInput = screen.getByLabelText('Endpoint'); + await userEvent.type(endpointInput, 'test-host.com'); + + expect(endpointInput).toHaveValue('test-host.com'); }); - it('should render Bucket input and allow to type text', async () => { + it('should render Bucket input and allow to type text in manual mode', async () => { renderDestinationCreate(flags); + const manualRadio = screen.getByLabelText('Enter Bucket manually'); + await userEvent.click(manualRadio); + const bucketInput = screen.getByLabelText('Bucket'); await userEvent.type(bucketInput, 'test-bucket'); @@ -155,13 +206,117 @@ describe('DestinationCreate', () => { ); }); + describe('Bucket selection behavior', () => { + it('should default to "Select Bucket associated with the account" radio in create mode', () => { + renderDestinationCreate(flags); + + const bucketFromAccountRadio = screen.getByLabelText( + 'Select Bucket associated with the account' + ); + expect(bucketFromAccountRadio).toBeChecked(); + }); + + it('should disable the Endpoint field when "Select Bucket associated with the account" is selected', () => { + renderDestinationCreate(flags); + + const endpointInput = screen.getByLabelText('Endpoint'); + expect(endpointInput).toBeDisabled(); + }); + + it('should enable the Endpoint field when "Enter Bucket manually" is selected', async () => { + renderDestinationCreate(flags); + + const manualRadio = screen.getByLabelText('Enter Bucket manually'); + await userEvent.click(manualRadio); + + const endpointInput = screen.getByLabelText('Endpoint'); + expect(endpointInput).toBeEnabled(); + }); + + it('should clear Bucket and Endpoint when switching to "Select Bucket associated with the account"', async () => { + renderDestinationCreate(flags); + + // Switch to manual mode and fill in values + const manualRadio = screen.getByLabelText('Enter Bucket manually'); + await userEvent.click(manualRadio); + + const bucketInput = screen.getByLabelText('Bucket'); + await userEvent.type(bucketInput, 'my-manual-bucket'); + expect(bucketInput).toHaveValue('my-manual-bucket'); + + const endpointInput = screen.getByLabelText('Endpoint'); + await userEvent.type(endpointInput, 'my-endpoint.com'); + expect(endpointInput).toHaveValue('my-endpoint.com'); + + // Switch back to bucket_from_account + const bucketFromAccountRadio = screen.getByLabelText( + 'Select Bucket associated with the account' + ); + await userEvent.click(bucketFromAccountRadio); + + // Both fields should be cleared + const bucketAutocomplete = screen.getByLabelText('Bucket'); + expect(bucketAutocomplete).toHaveValue(''); + expect(screen.getByLabelText('Endpoint')).toHaveValue(''); + }); + + it('should set Bucket and Endpoint from hostname when selecting a bucket without s3_endpoint', async () => { + renderDestinationCreate(flags); + + // Open the Bucket Autocomplete and select a bucket with only hostname + const bucketAutocomplete = screen.getByLabelText('Bucket'); + await userEvent.click(bucketAutocomplete); + + const bucketOption = await screen.findByText('bucket-with-hostname'); + await userEvent.click(bucketOption); + + // Bucket should display the selected bucket label + await waitFor(() => { + expect(bucketAutocomplete).toHaveValue('bucket-with-hostname'); + }); + + // Endpoint should be auto-filled with the bucket's hostname + expect(screen.getByLabelText('Endpoint')).toHaveValue( + 'bucket-with-hostname.us-east-1.linodeobjects.com' + ); + }); + + it('should set Bucket and Endpoint from s3_endpoint when selecting a bucket with s3_endpoint', async () => { + renderDestinationCreate(flags); + + // Open the Bucket Autocomplete and select a bucket with s3_endpoint + const bucketAutocomplete = screen.getByLabelText('Bucket'); + await userEvent.click(bucketAutocomplete); + + const bucketOption = await screen.findByText( + 'bucket-with-s3-endpoint' + ); + await userEvent.click(bucketOption); + + // Bucket should display the selected bucket label + await waitFor(() => { + expect(bucketAutocomplete).toHaveValue('bucket-with-s3-endpoint'); + }); + + // Endpoint should be auto-filled with the bucket's s3_endpoint (takes priority over hostname) + expect(screen.getByLabelText('Endpoint')).toHaveValue( + 'eu-central-1.linodeobjects.com' + ); + }); + }); + describe('given Test Connection and Create Destination buttons', () => { const fillOutAkamaiObjectStorageForm = async () => { const destinationNameInput = screen.getByLabelText('Destination Name'); await userEvent.type(destinationNameInput, 'Test'); - const hostInput = screen.getByLabelText('Host'); - await userEvent.type(hostInput, 'test'); + + // Switch to manual bucket entry to allow typing + const manualRadio = screen.getByLabelText('Enter Bucket manually'); + await userEvent.click(manualRadio); + + const endpointInput = screen.getByLabelText('Endpoint'); + await userEvent.type(endpointInput, 'test'); const bucketInput = screen.getByLabelText('Bucket'); await userEvent.type(bucketInput, 'test'); const accessKeyIDInput = screen.getByLabelText('Access Key ID'); 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 c65cd49587c..4e24b0946cd 100644 --- a/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationEdit.test.tsx +++ b/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationEdit.test.tsx @@ -7,7 +7,10 @@ import userEvent from '@testing-library/user-event'; import React from 'react'; import { describe, expect } from 'vitest'; -import { akamaiObjectStorageDestinationFactory } from 'src/factories'; +import { + akamaiObjectStorageDestinationFactory, + objectStorageBucketFactory, +} from 'src/factories'; import { DestinationEdit } from 'src/features/Delivery/Destinations/DestinationForm/DestinationEdit'; import { http, HttpResponse, server } from 'src/mocks/testServer'; import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; @@ -19,6 +22,36 @@ const mockDestination = akamaiObjectStorageDestinationFactory.build({ label: `Destination ${destinationId}`, }); +const mockBuckets = [ + objectStorageBucketFactory.build({ + hostname: 'bucket-with-hostname.us-east-1.linodeobjects.com', + label: 'bucket-with-hostname', + region: 'us-east', + }), + objectStorageBucketFactory.build({ + hostname: 'bucket-with-s3-endpoint.eu-central-1.linodeobjects.com', + label: 'bucket-with-s3-endpoint', + region: 'eu-central', + s3_endpoint: 'eu-central-1.linodeobjects.com', + }), +]; + +const queryMocks = vi.hoisted(() => ({ + useObjectStorageBuckets: vi.fn().mockReturnValue({ + data: undefined, + error: null, + isPending: true, + }), +})); + +vi.mock('src/queries/object-storage/queries', async () => { + const actual = await vi.importActual('src/queries/object-storage/queries'); + return { + ...actual, + useObjectStorageBuckets: queryMocks.useObjectStorageBuckets, + }; +}); + vi.mock('@tanstack/react-router', async () => { const actual = await vi.importActual('@tanstack/react-router'); return { @@ -28,6 +61,13 @@ vi.mock('@tanstack/react-router', async () => { }); describe('DestinationEdit', () => { + beforeEach(() => { + queryMocks.useObjectStorageBuckets.mockReturnValue({ + data: { buckets: mockBuckets }, + error: null, + isPending: false, + }); + }); const assertInputHasValue = (inputLabel: string, inputValue: string) => { expect(screen.getByLabelText(inputLabel)).toHaveValue(inputValue); }; @@ -51,13 +91,129 @@ describe('DestinationEdit', () => { await waitFor(() => { assertInputHasValue('Destination Name', 'Destination 123'); }); - assertInputHasValue('Host', 'destinations-bucket-name.host.com'); + assertInputHasValue('Endpoint', 'destinations-bucket-name.host.com'); assertInputHasValue('Bucket', 'destinations-bucket-name'); assertInputHasValue('Access Key ID', 'Access Id'); assertInputHasValue('Secret Access Key', ''); assertInputHasValue('Log Path Prefix (optional)', 'file'); }); + describe('Bucket selection behavior in edit mode', () => { + const renderEditWithMockDestination = async () => { + server.use( + http.get(`*/monitor/streams/destinations/${destinationId}`, () => { + return HttpResponse.json(mockDestination); + }) + ); + + renderWithThemeAndHookFormContext({ + component: , + }); + + const loadingElement = screen.queryByTestId(loadingTestId); + await waitForElementToBeRemoved(loadingElement); + }; + + it('should default to "Enter Bucket manually" radio in edit mode', async () => { + await renderEditWithMockDestination(); + + const manualRadio = screen.getByLabelText('Enter Bucket manually'); + expect(manualRadio).toBeChecked(); + }); + + it('should enable the Endpoint field in manual mode', async () => { + await renderEditWithMockDestination(); + + await waitFor(() => { + expect(screen.getByLabelText('Endpoint')).toBeEnabled(); + }); + }); + + it('should clear Bucket and Endpoint when switching to "Select Bucket associated with the account"', async () => { + await renderEditWithMockDestination(); + + await waitFor(() => { + assertInputHasValue('Bucket', 'destinations-bucket-name'); + }); + assertInputHasValue('Endpoint', 'destinations-bucket-name.host.com'); + + // Switch to bucket_from_account + const bucketFromAccountRadio = screen.getByLabelText( + 'Select Bucket associated with the account' + ); + await userEvent.click(bucketFromAccountRadio); + + // Both fields should be cleared + expect(screen.getByLabelText('Bucket')).toHaveValue(''); + expect(screen.getByLabelText('Endpoint')).toHaveValue(''); + }); + + it('should disable the Endpoint field after switching to "Select Bucket associated with the account"', async () => { + await renderEditWithMockDestination(); + + const bucketFromAccountRadio = screen.getByLabelText( + 'Select Bucket associated with the account' + ); + await userEvent.click(bucketFromAccountRadio); + + expect(screen.getByLabelText('Endpoint')).toBeDisabled(); + }); + + it('should set Bucket and Endpoint from hostname when selecting a bucket without s3_endpoint', async () => { + await renderEditWithMockDestination(); + + // Switch to bucket_from_account to show the Autocomplete + const bucketFromAccountRadio = screen.getByLabelText( + 'Select Bucket associated with the account' + ); + await userEvent.click(bucketFromAccountRadio); + + // Open the Bucket Autocomplete and select a bucket with only hostname + const bucketAutocomplete = screen.getByLabelText('Bucket'); + await userEvent.click(bucketAutocomplete); + + const bucketOption = await screen.findByText('bucket-with-hostname'); + await userEvent.click(bucketOption); + + // Bucket should display the selected bucket label + await waitFor(() => { + expect(bucketAutocomplete).toHaveValue('bucket-with-hostname'); + }); + + // Endpoint should be auto-filled with the bucket's hostname + expect(screen.getByLabelText('Endpoint')).toHaveValue( + 'bucket-with-hostname.us-east-1.linodeobjects.com' + ); + }); + + it('should set Bucket and Endpoint from s3_endpoint when selecting a bucket with s3_endpoint', async () => { + await renderEditWithMockDestination(); + + // Switch to bucket_from_account to show the Autocomplete + const bucketFromAccountRadio = screen.getByLabelText( + 'Select Bucket associated with the account' + ); + await userEvent.click(bucketFromAccountRadio); + + // Open the Bucket Autocomplete and select a bucket with s3_endpoint + const bucketAutocomplete = screen.getByLabelText('Bucket'); + await userEvent.click(bucketAutocomplete); + + const bucketOption = await screen.findByText('bucket-with-s3-endpoint'); + await userEvent.click(bucketOption); + + // Bucket should display the selected bucket label + await waitFor(() => { + expect(bucketAutocomplete).toHaveValue('bucket-with-s3-endpoint'); + }); + + // Endpoint should be auto-filled with the bucket's s3_endpoint + expect(screen.getByLabelText('Endpoint')).toHaveValue( + 'eu-central-1.linodeobjects.com' + ); + }); + }); + describe('given Test Connection and Edit Destination buttons', () => { const testConnectionButtonText = 'Test Connection'; const saveDestinationButtonText = 'Save Changes'; diff --git a/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationForm.tsx b/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationForm.tsx index 67e1bba84bf..b7097f784d6 100644 --- a/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationForm.tsx +++ b/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationForm.tsx @@ -36,6 +36,7 @@ interface DestinationFormProps { const customHttpsDetailsControlPaths = { authenticationType: 'details.authentication.type', + authenticationDetails: 'details.authentication.details', basicAuthenticationPassword: 'details.authentication.details.basic_authentication_password', basicAuthenticationUser: diff --git a/packages/manager/src/features/Delivery/Shared/DestinationAkamaiObjectStorageDetailsForm.tsx b/packages/manager/src/features/Delivery/Shared/DestinationAkamaiObjectStorageDetailsForm.tsx index b5b17fc7f58..673f1b07d4c 100644 --- a/packages/manager/src/features/Delivery/Shared/DestinationAkamaiObjectStorageDetailsForm.tsx +++ b/packages/manager/src/features/Delivery/Shared/DestinationAkamaiObjectStorageDetailsForm.tsx @@ -1,10 +1,23 @@ -import { Box, Divider, TextField, Typography } from '@linode/ui'; -import { capitalize } from '@mui/material'; -import React from 'react'; +import { useRegionsQuery } from '@linode/queries'; +import { useIsGeckoEnabled } from '@linode/shared'; +import { + Autocomplete, + Box, + Divider, + Radio, + RadioGroup, + TextField, + Typography, +} from '@linode/ui'; +import { capitalize, FormControlLabel } from '@mui/material'; +import React, { useState } from 'react'; import { Controller, useFormContext, useWatch } from 'react-hook-form'; import { HideShowText } from 'src/components/PasswordInput/HideShowText'; +import { RegionSelect } from 'src/components/RegionSelect/RegionSelect'; import { PathSample } from 'src/features/Delivery/Shared/PathSample'; +import { useFlags } from 'src/hooks/useFlags'; +import { useObjectStorageBuckets } from 'src/queries/object-storage/queries'; import type { FormMode, FormType } from 'src/features/Delivery/Shared/types'; @@ -33,50 +46,164 @@ export const DestinationAkamaiObjectStorageDetailsForm = ({ entity, mode, }: DestinationLinodeObjectStorageDetailsFormProps) => { - const { control } = useFormContext(); + const { gecko2 } = useFlags(); + const { isGeckoLAEnabled } = useIsGeckoEnabled(gecko2?.enabled, gecko2?.la); + + const { data: regions, isPending: areRegionsLoading } = useRegionsQuery(); + const { data: objectStorageBucketsResponse, isPending: areBucketsLoading } = + useObjectStorageBuckets(); + const objectStorageBuckets = objectStorageBucketsResponse?.buckets || []; + + const { control, setValue } = useFormContext(); + const path = useWatch({ control, name: controlPaths?.path, }); + const pendoPageId = `Logs Delivery ${capitalize(entity)}s ${capitalize(mode)}${entity === 'destination' ? '' : ' New Destination'}-`; + const [selectedBucketConfiguration, setSelectedBucketConfiguration] = + useState( + mode === 'edit' ? 'bucket_entered_manually' : 'bucket_from_account' + ); + const [selectedRegion, setSelectedRegion] = useState(null); + + const getBucketsForRegion = (regionId: null | string) => + regionId + ? objectStorageBuckets.filter(({ region }) => region === regionId) + : objectStorageBuckets; + + const buckets = getBucketsForRegion(selectedRegion); + + const clearBucketFields = () => { + setValue(controlPaths.bucketName, ''); + setValue(controlPaths.host, ''); + }; + + const handleRegionChange = (regionId: string | undefined) => { + const newRegion = regionId || null; + setSelectedRegion(newRegion); + + if (getBucketsForRegion(newRegion).length === 0) { + clearBucketFields(); + } + }; + + const handleBucketConfigurationChange = (value: string) => { + setSelectedBucketConfiguration(value); + if (value === 'bucket_from_account') { + clearBucketFields(); + setSelectedRegion(null); + } + }; + return ( <> - ( - { - field.onChange(value); + + Bucket + + handleBucketConfigurationChange(value)} + sx={{ '&[role="radiogroup"]': { mb: 0 } }} + value={selectedBucketConfiguration} + > + } + label="Select Bucket associated with the account" + value="bucket_from_account" + /> + } + label="Enter Bucket manually" + value="bucket_entered_manually" + /> + + {selectedBucketConfiguration === 'bucket_from_account' ? ( + <> + handleRegionChange(region?.id)} + regionFilter="core" + regions={regions ?? []} + textFieldProps={{ + optional: true, }} - placeholder="Host for the destination" - value={field.value} + value={selectedRegion} /> - )} - /> + ( + { + field.onChange(bucket?.label || ''); + setValue( + controlPaths?.host, + bucket?.s3_endpoint || bucket?.hostname || '' + ); + }} + options={buckets} + textFieldProps={{ + inputProps: { + 'data-pendo-id': `${pendoPageId}Bucket`, + }, + }} + value={ + buckets.find(({ label }) => label === field.value) ?? null + } + /> + )} + /> + + ) : ( + ( + { + field.onChange(value); + }} + value={field.value} + /> + )} + /> + )} ( { field.onChange(value); }} + placeholder="Endpoint for the destination" value={field.value} /> )} diff --git a/packages/manager/src/features/Delivery/Shared/DestinationCustomHttpsDetailsForm.tsx b/packages/manager/src/features/Delivery/Shared/DestinationCustomHttpsDetailsForm.tsx index e9d89bb1be9..68f26825c52 100644 --- a/packages/manager/src/features/Delivery/Shared/DestinationCustomHttpsDetailsForm.tsx +++ b/packages/manager/src/features/Delivery/Shared/DestinationCustomHttpsDetailsForm.tsx @@ -1,3 +1,4 @@ +import { authenticationType } from '@linode/api-v4'; import { Autocomplete, Divider, TextField, Typography } from '@linode/ui'; import { useTheme } from '@mui/material/styles'; import React from 'react'; @@ -18,6 +19,7 @@ import type { FormMode, FormType } from 'src/features/Delivery/Shared/types'; interface DestinationCustomHttpsDetailsFormProps { controlPaths: { + authenticationDetails: string; authenticationType: string; basicAuthenticationPassword: string; basicAuthenticationUser: string; @@ -40,7 +42,7 @@ export const DestinationCustomHttpsDetailsForm = ( const { controlPaths } = props; const theme = useTheme(); - const { control } = useFormContext(); + const { control, setValue } = useFormContext(); const selectedAuthenticationType = useWatch({ control, @@ -59,6 +61,9 @@ export const DestinationCustomHttpsDetailsForm = ( label="Authentication" onBlur={field.onBlur} onChange={(_, { value }) => { + if (value === authenticationType.None) { + setValue(controlPaths.authenticationDetails, undefined); + } field.onChange(value); }} options={authenticationTypeOptions} 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 156fbc30dba..322bfde465c 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,3 +1,4 @@ +import { regionFactory } from '@linode/utilities'; import { screen, waitFor, @@ -6,7 +7,7 @@ import { } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; -import { describe, expect, it } from 'vitest'; +import { beforeEach, describe, expect, it } from 'vitest'; import { kubernetesClusterFactory } from 'src/factories'; import { makeResourcePage } from 'src/mocks/serverHandlers'; @@ -20,7 +21,7 @@ const testClustersDetails = [ { label: 'gke-prod-europe-west1', id: 1, - region: 'US, Atalanta, GA', + region: 'us-southeast', control_plane: { audit_logs_enabled: false, }, @@ -28,7 +29,7 @@ const testClustersDetails = [ { label: 'metrics-stream-cluster', id: 2, - region: 'US, Chicago, IL', + region: 'us-chicago', control_plane: { audit_logs_enabled: true, }, @@ -36,21 +37,59 @@ const testClustersDetails = [ { label: 'prod-cluster-eu', id: 3, - region: 'NL, Amsterdam', + region: 'nl-ams', + control_plane: { + audit_logs_enabled: true, + }, + }, + { + label: 'cluster-no-capability', + id: 4, + region: 'us-east', control_plane: { audit_logs_enabled: true, }, }, ]; -const clusters = kubernetesClusterFactory.buildList(3).map((cluster, idx) => ({ +const clusters = kubernetesClusterFactory.buildList(4).map((cluster, idx) => ({ ...cluster, ...testClustersDetails[idx], })); +const regions = [ + regionFactory.build({ + id: 'us-southeast', + label: 'Atlanta, GA', + country: 'us', + capabilities: ['ACLP Logs Datacenter LKE-E', 'Object Storage'], + }), + regionFactory.build({ + id: 'us-chicago', + label: 'Chicago, IL', + country: 'us', + capabilities: ['ACLP Logs Datacenter LKE-E', 'Object Storage'], + }), + regionFactory.build({ + id: 'nl-ams', + label: 'Amsterdam', + country: 'nl', + capabilities: ['ACLP Logs Datacenter LKE-E', 'Object Storage'], + }), + regionFactory.build({ + id: 'us-east', + label: 'Newark, NJ', + country: 'us', + capabilities: ['Object Storage'], + }), +]; + const renderComponentWithoutSelectedClusters = async () => { server.use( http.get('*/lke/clusters', () => { return HttpResponse.json(makeResourcePage(clusters)); + }), + http.get('*/regions', () => { + return HttpResponse.json(makeResourcePage(regions)); }) ); @@ -134,12 +173,12 @@ describe('StreamFormClusters', () => { // Type test value inside the search await userEvent.click(input); - await userEvent.type(input, 'US,'); + await userEvent.type(input, 'us-'); await waitFor(() => expect(getColumnsValuesFromTable(2)).toEqual([ - 'US, Atalanta, GA', - 'US, Chicago, IL', + 'US, Atlanta, GA (us-southeast)', + 'US, Chicago, IL (us-chicago)', ]) ); }); @@ -231,6 +270,9 @@ describe('StreamFormClusters', () => { server.use( http.get('*/lke/clusters', () => { return HttpResponse.json(makeResourcePage(clusters)); + }), + http.get('*/regions', () => { + return HttpResponse.json(makeResourcePage(regions)); }) ); @@ -276,6 +318,9 @@ describe('StreamFormClusters', () => { server.use( http.get('*/lke/clusters', () => { return HttpResponse.json(makeResourcePage(modifiedClusters)); + }), + http.get('*/regions', () => { + return HttpResponse.json(makeResourcePage(regions)); }) ); @@ -394,4 +439,56 @@ describe('StreamFormClusters', () => { expect(metricsStreamCheckbox).not.toBeChecked(); expect(prodClusterCheckbox).toBeChecked(); }); + + describe('capability filtering', () => { + describe('given clusters table', () => { + it('should only display clusters in regions with "ACLP Logs Datacenter LKE-E" capability', async () => { + await renderComponentWithoutSelectedClusters(); + + const tableRows = getColumnsValuesFromTable(); + expect(tableRows).toContain('gke-prod-europe-west1'); + expect(tableRows).not.toContain('cluster-no-capability'); + }); + }); + + describe('given regions dropdown', () => { + beforeEach(() => { + const regionWithCapabilityButNoClusters = regionFactory.build({ + id: 'ap-south', + label: 'Singapore', + country: 'sg', + capabilities: ['ACLP Logs Datacenter LKE-E', 'Object Storage'], + }); + + const allRegions = [...regions, regionWithCapabilityButNoClusters]; + + server.use( + http.get('*/lke/clusters', () => { + return HttpResponse.json(makeResourcePage(clusters)); + }), + http.get('*/regions', () => { + return HttpResponse.json(makeResourcePage(allRegions)); + }) + ); + }); + + it('should only display regions that have clusters and the required capability', async () => { + await renderComponentWithoutSelectedClusters(); + + const regionSelect = screen.getByPlaceholderText('Select Region'); + await userEvent.click(regionSelect); + + const regionOptions = await screen.findAllByRole('option'); + const regionOptionLabels = regionOptions.map( + ({ textContent }) => textContent + ); + + expect(regionOptionLabels).toEqual([ + 'US, Atlanta, GA (us-southeast)', + 'US, Chicago, IL (us-chicago)', + 'NL, Amsterdam (nl-ams)', + ]); + }); + }); + }); }); 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 e6dd87f1a14..1ff4c4b448a 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClusters.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClusters.tsx @@ -17,10 +17,10 @@ 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 { Controller, useFormContext, useWatch } from 'react-hook-form'; import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField'; +import { Link } from 'src/components/Link'; import { sortData } from 'src/components/OrderBy'; import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter'; import { MIN_PAGE_SIZE } from 'src/components/PaginationFooter/PaginationFooter.constants'; @@ -44,6 +44,11 @@ interface StreamFormClustersProps { mode: FormMode; } +const logGenerationOptions = [ + { label: 'Enabled', value: true }, + { label: 'Disabled', value: false }, +]; + export const StreamFormClusters = (props: StreamFormClustersProps) => { const { mode } = props; const { control, setValue, formState, trigger } = @@ -52,11 +57,12 @@ export const StreamFormClusters = (props: StreamFormClustersProps) => { const xsDown = useMediaQuery((theme: Theme) => theme.breakpoints.down('sm')); const { gecko2 } = useFlags(); const { isGeckoLAEnabled } = useIsGeckoEnabled(gecko2?.enabled, gecko2?.la); - const { data: regions } = useRegionsQuery(); - const logGenerationOptions = [ - { label: 'Enabled', value: true }, - { label: 'Disabled', value: false }, - ]; + const { data: regions = [] } = useRegionsQuery(); + const { + data: clusters = [], + isLoading, + error, + } = useAllKubernetesClustersQuery({ enabled: true }); const [order, setOrder] = useState<'asc' | 'desc'>('asc'); const [orderBy, setOrderBy] = useState('label'); @@ -66,18 +72,40 @@ export const StreamFormClusters = (props: StreamFormClustersProps) => { const [regionFilter, setRegionFilter] = useState(''); const [logGenerationFilter, setLogGenerationFilter] = useState(); - const { - data: clusters = [], - isLoading, - error, - } = useAllKubernetesClustersQuery({ enabled: true }); + const eligibleRegions = useMemo( + () => + regions?.filter(({ capabilities }) => + capabilities.includes('ACLP Logs Datacenter LKE-E') + ), + [regions] + ); + + const eligibleClusters = useMemo(() => { + const regionMap = new Map( + eligibleRegions.map(({ id, label }) => [id, label]) + ); + + return clusters + .filter(({ region }) => regionMap.has(region)) + .map((cluster) => ({ + ...cluster, + region: regionMap.get(cluster.region) + ? `${regionMap.get(cluster.region)} (${cluster.region})` + : cluster.region, + })); + }, [clusters, eligibleRegions]); + + const visibleRegions = useMemo(() => { + const clusterRegions = new Set(clusters.map(({ region }) => region)); + return eligibleRegions.filter(({ id }) => clusterRegions.has(id)); + }, [clusters, eligibleRegions]); const clusterIdsWithLogsEnabled = useMemo( () => - clusters + eligibleClusters ?.filter((cluster) => cluster.control_plane.audit_logs_enabled) .map(({ id }) => id), - [clusters] + [eligibleClusters] ); const [isAutoAddAllClustersEnabled, clusterIds] = useWatch({ @@ -138,10 +166,10 @@ export const StreamFormClusters = (props: StreamFormClustersProps) => { } }; - const filteredClusters = - !searchText && !regionFilter && logGenerationFilter === undefined - ? clusters - : clusters.filter((cluster) => { + const filteredClusters = useMemo(() => { + return !searchText && !regionFilter && logGenerationFilter === undefined + ? eligibleClusters + : eligibleClusters.filter((cluster) => { const lowerSearch = searchText.toLowerCase(); let result = true; @@ -167,32 +195,31 @@ export const StreamFormClusters = (props: StreamFormClustersProps) => { return result; }); + }, [searchText, regionFilter, logGenerationFilter, eligibleClusters]); - const sortedAndFilteredClusters = sortData( - orderBy, - order - )(filteredClusters); + const sortedAndFilteredClusters = useMemo( + () => sortData(orderBy, order)(filteredClusters), + [orderBy, order, filteredClusters] + ); // Paginate clusters - const indexOfFirstClusterInPage = (page - 1) * pageSize; + const maxPage = Math.max( + 1, + Math.ceil(sortedAndFilteredClusters.length / pageSize) + ); + const safePage = page > maxPage ? maxPage : page; + + if (safePage !== page) { + setPage(safePage); + } + + const indexOfFirstClusterInPage = (safePage - 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 @@ -235,6 +262,19 @@ export const StreamFormClusters = (props: StreamFormClustersProps) => { )} /> + + Select the LKE clusters that will send audit logs to the configured + destination. Logging must be enabled for a cluster before it can be + selected. To enable logging for a cluster, use the Linode API{' '} + + update the cluster + {' '} + to set audit_logs_enabled to true. + { /> { @@ -273,7 +313,7 @@ export const StreamFormClusters = (props: StreamFormClustersProps) => { }} placeholder="Select Region" regionFilter="core" - regions={regions ?? []} + regions={visibleRegions ?? []} sx={{ width: '160px !important', }} @@ -320,7 +360,7 @@ export const StreamFormClusters = (props: StreamFormClustersProps) => { eventCategory="Clusters Table" handlePageChange={setPage} handleSizeChange={setPageSize} - page={page} + page={safePage} pageSize={pageSize} /> 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 9218718a04f..ed85f82349a 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 @@ -1,5 +1,9 @@ import { destinationType } from '@linode/api-v4'; -import { screen, waitForElementToBeRemoved } from '@testing-library/react'; +import { + screen, + waitFor, + waitForElementToBeRemoved, +} from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; import { beforeEach, describe, expect } from 'vitest'; @@ -7,6 +11,7 @@ import { beforeEach, describe, expect } from 'vitest'; import { akamaiObjectStorageDestinationFactory, customHttpsDestinationFactory, + objectStorageBucketFactory, } from 'src/factories'; import { makeResourcePage } from 'src/mocks/serverHandlers'; import { http, HttpResponse, server } from 'src/mocks/testServer'; @@ -24,10 +29,46 @@ const mockDestinations = [ ...customHttpsDestinationFactory.buildList(2), ]; +const mockBuckets = [ + objectStorageBucketFactory.build({ + hostname: 'bucket-with-hostname.us-east-1.linodeobjects.com', + label: 'bucket-with-hostname', + region: 'us-east', + }), + objectStorageBucketFactory.build({ + hostname: 'bucket-with-s3-endpoint.eu-central-1.linodeobjects.com', + label: 'bucket-with-s3-endpoint', + region: 'eu-central', + s3_endpoint: 'eu-central-1.linodeobjects.com', + }), +]; + +const queryMocks = vi.hoisted(() => ({ + useObjectStorageBuckets: vi.fn().mockReturnValue({ + data: undefined, + error: null, + isPending: true, + }), +})); + +vi.mock('src/queries/object-storage/queries', async () => { + const actual = await vi.importActual('src/queries/object-storage/queries'); + return { + ...actual, + useObjectStorageBuckets: queryMocks.useObjectStorageBuckets, + }; +}); + describe('StreamFormDelivery', () => { const setDisableTestConnection = () => {}; beforeEach(async () => { + queryMocks.useObjectStorageBuckets.mockReturnValue({ + data: { buckets: mockBuckets }, + error: null, + isPending: false, + }); + server.use( http.get('*/monitor/streams/destinations', () => { return HttpResponse.json(makeResourcePage(mockDestinations)); @@ -188,25 +229,36 @@ describe('StreamFormDelivery', () => { }); describe('and new Destination Name is added', () => { - it('should render Host input and allow to type text', async () => { + it('should render Endpoint input as disabled in bucket_from_account mode and allow to type text after switching to manual mode', async () => { await renderComponentAndAddNewDestinationName( destinationType.AkamaiObjectStorage, flags ); - // Type the test value inside the input - const hostInput = screen.getByLabelText('Host'); - await userEvent.type(hostInput, 'Test'); + // Endpoint is disabled when bucket_from_account is selected + const endpointInput = screen.getByLabelText('Endpoint'); + expect(endpointInput).toBeDisabled(); + + // Switch to manual mode + const manualRadio = screen.getByLabelText('Enter Bucket manually'); + await userEvent.click(manualRadio); - expect(hostInput.getAttribute('value')).toEqual('Test'); + // Now Endpoint should be enabled + expect(endpointInput).toBeEnabled(); + await userEvent.type(endpointInput, 'Test'); + expect(endpointInput.getAttribute('value')).toEqual('Test'); }); - it('should render Bucket input and allow to type text', async () => { + it('should render Bucket input and allow to type text in manual mode', async () => { await renderComponentAndAddNewDestinationName( destinationType.AkamaiObjectStorage, flags ); + // Switch to manual mode + const manualRadio = screen.getByLabelText('Enter Bucket manually'); + await userEvent.click(manualRadio); + // Type the test value inside the input const bucketInput = screen.getByLabelText('Bucket'); await userEvent.type(bucketInput, 'test'); @@ -256,6 +308,123 @@ describe('StreamFormDelivery', () => { expect(logPathPrefixInput.getAttribute('value')).toEqual('Test'); }); }); + + describe('Bucket selection behavior', () => { + it('should default to "Select Bucket associated with the account" radio', async () => { + await renderComponentAndAddNewDestinationName( + destinationType.AkamaiObjectStorage, + flags + ); + + const bucketFromAccountRadio = screen.getByLabelText( + 'Select Bucket associated with the account' + ); + expect(bucketFromAccountRadio).toBeChecked(); + }); + + it('should disable the Endpoint field when "Select Bucket associated with the account" is selected', async () => { + await renderComponentAndAddNewDestinationName( + destinationType.AkamaiObjectStorage, + flags + ); + + const endpointInput = screen.getByLabelText('Endpoint'); + expect(endpointInput).toBeDisabled(); + }); + + it('should enable the Endpoint field when "Enter Bucket manually" is selected', async () => { + await renderComponentAndAddNewDestinationName( + destinationType.AkamaiObjectStorage, + flags + ); + + const manualRadio = screen.getByLabelText('Enter Bucket manually'); + await userEvent.click(manualRadio); + + const endpointInput = screen.getByLabelText('Endpoint'); + expect(endpointInput).toBeEnabled(); + }); + + it('should clear Bucket and Endpoint when switching to "Select Bucket associated with the account"', async () => { + await renderComponentAndAddNewDestinationName( + destinationType.AkamaiObjectStorage, + flags + ); + + // Switch to manual mode and fill in values + const manualRadio = screen.getByLabelText('Enter Bucket manually'); + await userEvent.click(manualRadio); + + const bucketInput = screen.getByLabelText('Bucket'); + await userEvent.type(bucketInput, 'my-manual-bucket'); + expect(bucketInput).toHaveValue('my-manual-bucket'); + + const endpointInput = screen.getByLabelText('Endpoint'); + await userEvent.type(endpointInput, 'my-endpoint.com'); + expect(endpointInput).toHaveValue('my-endpoint.com'); + + // Switch back to bucket_from_account + const bucketFromAccountRadio = screen.getByLabelText( + 'Select Bucket associated with the account' + ); + await userEvent.click(bucketFromAccountRadio); + + // Both fields should be cleared + const bucketAutocomplete = screen.getByLabelText('Bucket'); + expect(bucketAutocomplete).toHaveValue(''); + expect(screen.getByLabelText('Endpoint')).toHaveValue(''); + }); + + it('should set Bucket and Endpoint from hostname when selecting a bucket without s3_endpoint', async () => { + await renderComponentAndAddNewDestinationName( + destinationType.AkamaiObjectStorage, + flags + ); + + // Open the Bucket Autocomplete and select a bucket with only hostname + const bucketAutocomplete = screen.getByLabelText('Bucket'); + await userEvent.click(bucketAutocomplete); + + const bucketOption = await screen.findByText('bucket-with-hostname'); + await userEvent.click(bucketOption); + + // Bucket should display the selected bucket label + await waitFor(() => { + expect(bucketAutocomplete).toHaveValue('bucket-with-hostname'); + }); + + // Endpoint should be auto-filled with the bucket's hostname + expect(screen.getByLabelText('Endpoint')).toHaveValue( + 'bucket-with-hostname.us-east-1.linodeobjects.com' + ); + }); + + it('should set Bucket and Endpoint from s3_endpoint when selecting a bucket with s3_endpoint', async () => { + await renderComponentAndAddNewDestinationName( + destinationType.AkamaiObjectStorage, + flags + ); + + // Open the Bucket Autocomplete and select a bucket with s3_endpoint + const bucketAutocomplete = screen.getByLabelText('Bucket'); + await userEvent.click(bucketAutocomplete); + + const bucketOption = await screen.findByText( + 'bucket-with-s3-endpoint' + ); + await userEvent.click(bucketOption); + + // Bucket should display the selected bucket label + await waitFor(() => { + expect(bucketAutocomplete).toHaveValue('bucket-with-s3-endpoint'); + }); + + // Endpoint should be auto-filled with the bucket's s3_endpoint + expect(screen.getByLabelText('Endpoint')).toHaveValue( + 'eu-central-1.linodeobjects.com' + ); + }); + }); }); }); 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 2c7052b1006..3fb88ce8308 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/StreamFormDelivery.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/StreamFormDelivery.tsx @@ -57,6 +57,7 @@ const akamaiObjectStorageDetailsControlPaths = { const customHttpsDetailsControlPaths = { authenticationType: 'destination.details.authentication.type', + authenticationDetails: 'destination.details.authentication.details', basicAuthenticationPassword: 'destination.details.authentication.details.basic_authentication_password', basicAuthenticationUser: 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 700f766fc9c..49744d734af 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamCreate.test.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamCreate.test.tsx @@ -55,11 +55,15 @@ describe('StreamCreate', () => { { exact: false } ); await userEvent.click(createNewTestDestination); - const hostInput = screen.getByLabelText('Host'); + + const manualRadio = screen.getByLabelText('Enter Bucket manually'); + await userEvent.click(manualRadio); + + const endpointInput = screen.getByLabelText('Endpoint'); await waitFor(() => { - expect(hostInput).toBeDefined(); + expect(endpointInput).toBeDefined(); }); - await userEvent.type(hostInput, 'test'); + await userEvent.type(endpointInput, 'test'); const bucketInput = screen.getByLabelText('Bucket'); await userEvent.type(bucketInput, 'test'); const accessKeyIDInput = screen.getByLabelText('Access Key ID'); 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 ee51755cfd2..7aa98b387db 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamEdit.test.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamEdit.test.tsx @@ -101,11 +101,16 @@ describe('StreamEdit', () => { { exact: false } ); await userEvent.click(createNewTestDestination); - const hostInput = screen.getByLabelText('Host'); + + // Switch to manual bucket entry mode + const manualRadio = screen.getByLabelText('Enter Bucket manually'); + await userEvent.click(manualRadio); + + const endpointInput = screen.getByLabelText('Endpoint'); await waitFor(() => { - expect(hostInput).toBeDefined(); + expect(endpointInput).toBeDefined(); }); - await userEvent.type(hostInput, 'test'); + await userEvent.type(endpointInput, 'test'); const bucketInput = screen.getByLabelText('Bucket'); await userEvent.type(bucketInput, 'test'); const accessKeyIDInput = screen.getByLabelText('Access Key ID'); diff --git a/packages/manager/src/features/Firewalls/components/FirewallSelect.test.tsx b/packages/manager/src/features/Firewalls/components/FirewallSelect.test.tsx index 5489c7cff9c..3b038dd20b8 100644 --- a/packages/manager/src/features/Firewalls/components/FirewallSelect.test.tsx +++ b/packages/manager/src/features/Firewalls/components/FirewallSelect.test.tsx @@ -8,6 +8,10 @@ import { renderWithTheme } from 'src/utilities/testHelpers'; import { FirewallSelect } from './FirewallSelect'; +const NO_FIREWALL_ID = -1; +const NO_FIREWALL_LABEL = + 'No firewall - traffic is unprotected (not recommended)'; + describe('FirewallSelect', () => { it('renders a default label', () => { const { getByText } = renderWithTheme(); @@ -50,4 +54,75 @@ describe('FirewallSelect', () => { expect(getByText(firewall.label)).toBeVisible(); } }); + + it('renders "No firewall" option in the dropdown by default', async () => { + const firewalls = firewallFactory.buildList(2); + + server.use( + http.get('*/v4/networking/firewalls', () => { + return HttpResponse.json(makeResourcePage(firewalls)); + }) + ); + + const { getByLabelText, getByText } = renderWithTheme( + + ); + + await userEvent.click(getByLabelText('Firewall')); + + expect(getByText(NO_FIREWALL_LABEL)).toBeVisible(); + }); + + it('does not render "No firewall" option when showNoFirewallOption is false', async () => { + const firewalls = firewallFactory.buildList(2); + + server.use( + http.get('*/v4/networking/firewalls', () => { + return HttpResponse.json(makeResourcePage(firewalls)); + }) + ); + + const { getByLabelText, queryByText } = renderWithTheme( + + ); + + await userEvent.click(getByLabelText('Firewall')); + + expect(queryByText(NO_FIREWALL_LABEL)).not.toBeInTheDocument(); + }); + + it('displays warning notice when "No firewall" is selected and warningMessageForNoFirewallOption is provided', () => { + const warningMessage = 'This Linode is not secured with a Cloud Firewall.'; + + const { getByText } = renderWithTheme( + + ); + + expect(getByText(warningMessage)).toBeVisible(); + }); + + it('does not display warning notice when "No firewall" is selected but warningMessageForNoFirewallOption is not provided', () => { + const { queryByRole } = renderWithTheme( + + ); + + expect(queryByRole('alert')).not.toBeInTheDocument(); + }); + + it('shows "No firewall" as selected when value is NO_FIREWALL_ID', async () => { + server.use( + http.get('*/v4/networking/firewalls', () => { + return HttpResponse.json(makeResourcePage([])); + }) + ); + + const { findByDisplayValue } = renderWithTheme( + + ); + + await findByDisplayValue(NO_FIREWALL_LABEL); + }); }); diff --git a/packages/manager/src/features/Firewalls/components/FirewallSelect.tsx b/packages/manager/src/features/Firewalls/components/FirewallSelect.tsx index 0b17aaf6de8..6f25528e252 100644 --- a/packages/manager/src/features/Firewalls/components/FirewallSelect.tsx +++ b/packages/manager/src/features/Firewalls/components/FirewallSelect.tsx @@ -1,5 +1,5 @@ import { useAllFirewallsQuery } from '@linode/queries'; -import { Autocomplete, InputAdornment } from '@linode/ui'; +import { Autocomplete, InputAdornment, Notice, Stack } from '@linode/ui'; import React, { useMemo } from 'react'; import { useDefaultFirewallChipInformation } from 'src/hooks/useDefaultFirewallChipInformation'; @@ -10,6 +10,15 @@ import { FirewallSelectOption } from './FirewallSelectOption'; import type { Firewall } from '@linode/api-v4'; import type { EnhancedAutocompleteProps } from '@linode/ui'; +// -1 is used as the value for the "firewall_id" if user doesn't want to use a firewall. +// If null or undefined were used instead, the API would default to using the default firewall, which is not the intended behavior when a user explicitly selects "No firewall". +const NO_FIREWALL_ID = -1; + +const noFirewallOption = { + label: 'No firewall - traffic is unprotected (not recommended)', + id: NO_FIREWALL_ID, +} as Firewall; + interface Props extends Omit< EnhancedAutocompleteProps, @@ -31,10 +40,18 @@ interface Props * All Firewall will show if this is omitted. */ options?: Firewall[]; + /** + * Show an additional "No firewall (not recommended)" option in the dropdown, which has a value of `-1`. + */ + showNoFirewallOption?: boolean; /** * The ID of the selected Firewall */ value: null | number | undefined; + /** + * Warning notice when no firewall is selected. + */ + warningMessageForNoFirewallOption?: string; } /** @@ -47,50 +64,80 @@ interface Props export const FirewallSelect = ( props: Props ) => { - const { errorText, hideDefaultChips, label, loading, value, ...rest } = props; + const { + errorText, + hideDefaultChips, + label, + loading, + options, + showNoFirewallOption = true, + value, + warningMessageForNoFirewallOption, + ...rest + } = props; const { data: firewalls, error, isLoading } = useAllFirewallsQuery(); const { defaultNumEntities, isDefault, tooltipText } = useDefaultFirewallChipInformation(value, hideDefaultChips); + const firewallOptions = useMemo( + () => [ + ...(options ? options : (firewalls ?? [])), + ...(showNoFirewallOption ? [noFirewallOption] : []), + ], + [firewalls, options, showNoFirewallOption] + ); + const selectedFirewall = useMemo( - () => firewalls?.find((firewall) => firewall.id === value) ?? null, + () => + value === NO_FIREWALL_ID + ? noFirewallOption + : (firewalls?.find((firewall) => firewall.id === value) ?? null), [firewalls, value] ); return ( - - aria-label={label === '' ? 'Firewall' : undefined} - errorText={errorText ?? error?.[0].reason} - label={label ?? 'Firewall'} - loading={isLoading || loading} - noMarginTop - options={firewalls ?? []} - placeholder="None" - renderOption={({ key, ...props }, option, state) => ( - + + aria-label={label === '' ? 'Firewall' : undefined} + errorText={errorText ?? error?.[0].reason} + label={label ?? 'Firewall'} + loading={isLoading || loading} + noMarginTop + options={firewallOptions} + placeholder="Select a Firewall" + renderOption={({ key, ...props }, option, state) => ( + + )} + textFieldProps={{ + InputProps: { + endAdornment: isDefault && !hideDefaultChips && ( + + + + ), + }, + }} + value={selectedFirewall!} + {...rest} + /> + {value === NO_FIREWALL_ID && warningMessageForNoFirewallOption && ( + )} - textFieldProps={{ - InputProps: { - endAdornment: isDefault && !hideDefaultChips && ( - - - - ), - }, - }} - value={selectedFirewall!} - {...rest} - /> + ); }; diff --git a/packages/manager/src/features/IAM/Delegations/AccountDelegations.test.tsx b/packages/manager/src/features/IAM/Delegations/AccountDelegations.test.tsx index 6827a0013b3..b98a0b6c970 100644 --- a/packages/manager/src/features/IAM/Delegations/AccountDelegations.test.tsx +++ b/packages/manager/src/features/IAM/Delegations/AccountDelegations.test.tsx @@ -13,8 +13,17 @@ const mocks = vi.hoisted(() => ({ mockUseGetChildAccountsQuery: vi.fn(), useParams: vi.fn().mockReturnValue({}), useSearch: vi.fn().mockReturnValue({}), + usePermissions: vi.fn().mockReturnValue({}), })); +vi.mock('src/features/IAM/hooks/usePermissions', async () => { + const actual = await vi.importActual('src/features/IAM/hooks/usePermissions'); + return { + ...actual, + usePermissions: mocks.usePermissions, + }; +}); + vi.mock('@tanstack/react-router', async () => { const actual = await vi.importActual('@tanstack/react-router'); return { @@ -53,6 +62,10 @@ describe('AccountDelegations', () => { data: { data: mockDelegations, results: mockDelegations.length }, isLoading: false, }); + mocks.usePermissions.mockReturnValue({ + data: { list_all_child_accounts: true }, + isLoading: false, + }); }); it('should render the delegations table with data', async () => { @@ -93,4 +106,23 @@ describe('AccountDelegations', () => { expect(emptyElement).toBeInTheDocument(); }); }); + + it('should not render if user does not have permissions', () => { + mocks.usePermissions.mockReturnValue({ + data: { + list_all_child_accounts: false, + }, + isLoading: false, + }); + + renderWithTheme(, { + flags: { iamDelegation: { enabled: true }, iam: { enabled: true } }, + initialRoute: '/iam', + }); + expect( + screen.queryByText( + 'You do not have permission to view account delegations.' + ) + ).toBeVisible(); + }); }); diff --git a/packages/manager/src/features/IAM/Delegations/AccountDelegations.tsx b/packages/manager/src/features/IAM/Delegations/AccountDelegations.tsx index 2393e8649f3..ca54e98572f 100644 --- a/packages/manager/src/features/IAM/Delegations/AccountDelegations.tsx +++ b/packages/manager/src/features/IAM/Delegations/AccountDelegations.tsx @@ -1,5 +1,5 @@ import { useGetChildAccountsQuery } from '@linode/queries'; -import { CircleProgress, Paper, Stack } from '@linode/ui'; +import { CircleProgress, Notice, Paper, Stack } from '@linode/ui'; import { useMediaQuery, useTheme } from '@mui/material'; import { useNavigate, useSearch } from '@tanstack/react-router'; import React from 'react'; @@ -10,6 +10,7 @@ import { useOrderV2 } from 'src/hooks/useOrderV2'; import { usePaginationV2 } from 'src/hooks/usePaginationV2'; import { useIsIAMDelegationEnabled } from '../hooks/useIsIAMEnabled'; +import { usePermissions } from '../hooks/usePermissions'; import { AccountDelegationsTable } from './AccountDelegationsTable'; const DELEGATIONS_ROUTE = '/iam/delegations'; @@ -17,6 +18,10 @@ const DELEGATIONS_ROUTE = '/iam/delegations'; export const AccountDelegations = () => { const navigate = useNavigate(); const { isIAMDelegationEnabled } = useIsIAMDelegationEnabled(); + const { data: permissions, isLoading: isPermissionsLoading } = usePermissions( + 'account', + ['list_all_child_accounts'] + ); const { company } = useSearch({ from: '/iam', @@ -78,12 +83,22 @@ export const AccountDelegations = () => { }); }; - if (isLoading) { + if (isLoading || isPermissionsLoading) { return ; } + + if (!permissions?.list_all_child_accounts) { + return ( + + You do not have permission to view account delegations. + + ); + } + if (!isIAMDelegationEnabled) { return null; } + return ( ({ marginTop: theme.tokens.spacing.S16 })}> mockMatchMedia()); + +const mocks = vi.hoisted(() => ({ + useAccountUsersInfiniteQuery: vi.fn(), + mockUseUpdateChildAccountDelegatesQuery: vi.fn(), + mockMutateAsync: vi.fn(), +})); + +vi.mock('@linode/queries', async () => { + const actual = await vi.importActual('@linode/queries'); + return { + ...actual, + useAccountUsersInfiniteQuery: mocks.useAccountUsersInfiniteQuery, + useUpdateChildAccountDelegatesQuery: + mocks.mockUseUpdateChildAccountDelegatesQuery, + }; +}); + +const mockUsers: User[] = [ + { + email: 'user1@example.com', + last_login: null, + password_created: null, + restricted: false, + ssh_keys: [], + tfa_enabled: false, + user_type: 'default', + username: 'user1', + verified_phone_number: null, + }, + { + email: 'user2@example.com', + last_login: null, + password_created: null, + restricted: false, + ssh_keys: [], + tfa_enabled: false, + user_type: 'default', + username: 'user2', + verified_phone_number: null, + }, +]; + +const mockChildAccountWithDelegates: ChildAccountWithDelegates = { + company: 'Test Company', + euuid: 'E1234567-89AB-CDEF-0123-456789ABCDEF', + users: ['user1'], +}; + +const defaultProps = { + delegation: mockChildAccountWithDelegates, + formattedCurrentUsers: [ + { label: mockUsers[0].username, value: mockUsers[0].username }, + ], + onClose: vi.fn(), +}; + +describe('UpdateDelegationsDrawer', () => { + beforeEach(() => { + vi.clearAllMocks(); + + mocks.useAccountUsersInfiniteQuery.mockReturnValue({ + data: { pages: [{ data: mockUsers }] }, + isFetching: false, + }); + + mocks.mockUseUpdateChildAccountDelegatesQuery.mockReturnValue({ + mutateAsync: mocks.mockMutateAsync, + }); + + mocks.mockMutateAsync.mockResolvedValue({}); + }); + + it('renders the drawer with current delegates', () => { + renderWithTheme(); + + const companyName = screen.getByText(/test company/i); + expect(companyName).toBeInTheDocument(); + const userName = screen.getByText(/user1/i); + expect(userName).toBeInTheDocument(); + }); + + it('allows adding a new delegate', async () => { + renderWithTheme(); + + const user = userEvent.setup(); + + const autocompleteInput = screen.getByRole('combobox'); + await user.click(autocompleteInput); + + await waitFor(async () => { + screen.getByRole('option', { name: 'user2' }); + }); + + const user2Option = screen.getByRole('option', { name: 'user2' }); + await user.click(user2Option); + + const submitButton = screen.getByRole('button', { name: /save changes/i }); + await user.click(submitButton); + + await waitFor(() => { + expect(mocks.mockMutateAsync).toHaveBeenCalledWith({ + euuid: mockChildAccountWithDelegates.euuid, + users: ['user1', 'user2'], + }); + }); + }); + + it('allows sending an empty payload', async () => { + renderWithTheme(); + + const user = userEvent.setup(); + + // Open the autocomplete and deselect the preselected user (user1) + const autocompleteInput = screen.getByRole('combobox'); + await user.click(autocompleteInput); + + await waitFor(() => { + // Ensure options are rendered + expect(screen.getByRole('option', { name: 'user1' })).toBeInTheDocument(); + }); + + const user1Option = screen.getByRole('option', { name: 'user1' }); + await user.click(user1Option); // toggles off the selected user + + // Submit with no users selected + const submitButton = screen.getByRole('button', { name: /save changes/i }); + await user.click(submitButton); + + await waitFor(() => { + expect(mocks.mockMutateAsync).toHaveBeenCalledWith({ + euuid: mockChildAccountWithDelegates.euuid, + users: [], + }); + }); + }); +}); diff --git a/packages/manager/src/features/IAM/Delegations/UpdateDelegationForm.tsx b/packages/manager/src/features/IAM/Delegations/UpdateDelegationForm.tsx index 248519be736..e5b0120c081 100644 --- a/packages/manager/src/features/IAM/Delegations/UpdateDelegationForm.tsx +++ b/packages/manager/src/features/IAM/Delegations/UpdateDelegationForm.tsx @@ -1,5 +1,19 @@ -import { useUpdateChildAccountDelegatesQuery } from '@linode/queries'; -import { ActionsPanel, Autocomplete, Notice, Typography } from '@linode/ui'; +import { + useAccountUsersInfiniteQuery, + useAllAccountUsersQuery, + useUpdateChildAccountDelegatesQuery, +} from '@linode/queries'; +import { + ActionsPanel, + Autocomplete, + CloseIcon, + IconButton, + Notice, + Paper, + Stack, + Typography, +} from '@linode/ui'; +import { useDebouncedValue } from '@linode/utilities'; import { useTheme } from '@mui/material'; import { enqueueSnackbar } from 'notistack'; import * as React from 'react'; @@ -9,7 +23,11 @@ import { usePermissions } from '../hooks/usePermissions'; import { INTERNAL_ERROR_NO_CHANGES_SAVED } from '../Shared/constants'; import { getPlaceholder } from '../Shared/Entities/utils'; -import type { ChildAccount, ChildAccountWithDelegates } from '@linode/api-v4'; +import type { + ChildAccount, + ChildAccountWithDelegates, + Filter, +} from '@linode/api-v4'; interface UpdateDelegationsFormValues { users: UserOption[]; @@ -23,23 +41,43 @@ interface UserOption { interface DelegationsFormProps { delegation: ChildAccount | ChildAccountWithDelegates; formattedCurrentUsers: UserOption[]; - isLoading: boolean; onClose: () => void; - userOptions: UserOption[]; } + export const UpdateDelegationForm = ({ delegation, formattedCurrentUsers, - isLoading, onClose, - userOptions, }: DelegationsFormProps) => { const theme = useTheme(); + const [inputValue, setInputValue] = React.useState(''); + const [allUserSelected, setAllUserSelected] = React.useState(false); + const debouncedInputValue = useDebouncedValue(inputValue); const { data: permissions } = usePermissions('account', [ 'update_delegate_users', ]); + const apiFilter: Filter = { + user_type: 'parent', + username: { '+contains': debouncedInputValue }, + }; + + const { data, error, fetchNextPage, hasNextPage, isFetching } = + useAccountUsersInfiniteQuery(apiFilter); + + const totalUserCount = data?.pages[0]?.results ?? 0; + + const { + data: allUsers, + isFetching: isFetchingAllUsers, + refetch: refetchAllUsers, + } = useAllAccountUsersQuery(allUserSelected, { + user_type: 'parent', + }); + + const isSelectAllFetching = allUserSelected && isFetchingAllUsers; + const { mutateAsync: updateDelegates } = useUpdateChildAccountDelegatesQuery(); @@ -55,8 +93,36 @@ export const UpdateDelegationForm = ({ handleSubmit, reset, setError, + setValue, + watch, } = form; + const selectedUsers = watch('users'); + + const users = + allUserSelected && allUsers + ? allUsers.map((user) => ({ + label: user.username, + value: user.username, + })) + : !inputValue && + totalUserCount > 0 && + selectedUsers.length >= totalUserCount + ? selectedUsers + : (data?.pages.flatMap((page) => { + return page.data.map((user) => ({ + label: user.username, + value: user.username, + })); + }) ?? []); + + const isSearching = + inputValue.length > 0 && debouncedInputValue !== inputValue; + + const isLoadingOptions = isFetching || isFetchingAllUsers; + + const showNoOptionsText = !isLoadingOptions && !isSearching; + const onSubmit = async (values: UpdateDelegationsFormValues) => { const usersList = values.users.map((user) => user.value); @@ -76,9 +142,21 @@ export const UpdateDelegationForm = ({ } }; + const onSelectAllClick = async () => { + setAllUserSelected(true); + const { data } = await refetchAllUsers(); + if (data) { + setValue( + 'users', + data.map((user) => ({ label: user.username, value: user.username })) + ); + } + }; + const handleClose = () => { reset(); onClose(); + setAllUserSelected(false); }; return ( @@ -111,31 +189,108 @@ export const UpdateDelegationForm = ({ name="users" render={({ field, fieldState }) => ( option.value === value.value } - label={'Delegate Users'} - loading={isLoading} + label="Delegate Users" + loading={isFetching || isFetchingAllUsers} multiple noMarginTop + noOptionsText={showNoOptionsText ? 'No users found' : ' '} onChange={(_, newValue) => { field.onChange(newValue || []); }} - options={userOptions} - placeholder={getPlaceholder( - 'delegates', - field.value.length, - userOptions.length - )} + onInputChange={(_, value) => { + setInputValue(value); + }} + onSelectAllClick={(_event) => { + const allCurrentOptionsSelected = + totalUserCount > 0 && + selectedUsers.length >= totalUserCount; + if (allCurrentOptionsSelected) { + setValue('users', []); + setAllUserSelected(false); + } else { + onSelectAllClick(); + } + }} + options={users} + renderTags={() => null} + slotProps={{ + listbox: { + onScroll: (event: React.SyntheticEvent) => { + const listboxNode = event.currentTarget; + if ( + listboxNode.scrollTop + listboxNode.clientHeight >= + listboxNode.scrollHeight && + hasNextPage + ) { + fetchNextPage(); + } + }, + }, + }} textFieldProps={{ hideLabel: true, + helperText: isSelectAllFetching + ? 'Fetching all users...' + : undefined, + InputProps: isSelectAllFetching + ? { startAdornment: null } + : undefined, + placeholder: getPlaceholder( + 'delegates', + selectedUsers.length, + totalUserCount + ), }} value={field.value} /> )} /> + + Users in the account delegation + {isFetchingAllUsers ? '' : ` (${selectedUsers.length})`}: + + ({ + backgroundColor: isFetchingAllUsers + ? theme.tokens.alias.Interaction.Background.Disabled + : theme.palette.background.paper, + maxHeight: 370, + overflowY: 'auto', + p: 2, + py: 1, + })} + variant="outlined" + > + + {selectedUsers.length === 0 && ( + + No users selected + + )} + {selectedUsers.map((user) => ( + + setValue( + 'users', + selectedUsers.filter((u) => u.value !== user.value) + ) + } + username={user.label} + /> + ))} + + ); }; + +interface DelegationUserRowProps { + isSubmitting: boolean; + onRemove: () => void; + username: string; +} + +const DelegationUserRow = ({ + onRemove, + username, + isSubmitting, +}: DelegationUserRowProps) => { + return ( + + {username} + + + + + ); +}; diff --git a/packages/manager/src/features/IAM/Delegations/UpdateDelegationsDrawer.test.tsx b/packages/manager/src/features/IAM/Delegations/UpdateDelegationsDrawer.test.tsx index c370d8a08a8..3fa42f2b00e 100644 --- a/packages/manager/src/features/IAM/Delegations/UpdateDelegationsDrawer.test.tsx +++ b/packages/manager/src/features/IAM/Delegations/UpdateDelegationsDrawer.test.tsx @@ -1,5 +1,4 @@ -import { screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { screen } from '@testing-library/react'; import React from 'react'; import { vi } from 'vitest'; @@ -7,51 +6,10 @@ import { mockMatchMedia, renderWithTheme } from 'src/utilities/testHelpers'; import { UpdateDelegationsDrawer } from './UpdateDelegationsDrawer'; -import type { ChildAccountWithDelegates, User } from '@linode/api-v4'; +import type { ChildAccountWithDelegates } from '@linode/api-v4'; beforeAll(() => mockMatchMedia()); -const mocks = vi.hoisted(() => ({ - mockUseAccountUsers: vi.fn(), - mockUseUpdateChildAccountDelegatesQuery: vi.fn(), - mockMutateAsync: vi.fn(), -})); - -vi.mock('@linode/queries', async () => { - const actual = await vi.importActual('@linode/queries'); - return { - ...actual, - useAccountUsers: mocks.mockUseAccountUsers, - useUpdateChildAccountDelegatesQuery: - mocks.mockUseUpdateChildAccountDelegatesQuery, - }; -}); - -const mockUsers: User[] = [ - { - email: 'user1@example.com', - last_login: null, - password_created: null, - restricted: false, - ssh_keys: [], - tfa_enabled: false, - user_type: 'default', - username: 'user1', - verified_phone_number: null, - }, - { - email: 'user2@example.com', - last_login: null, - password_created: null, - restricted: false, - ssh_keys: [], - tfa_enabled: false, - user_type: 'default', - username: 'user2', - verified_phone_number: null, - }, -]; - const mockChildAccountWithDelegates: ChildAccountWithDelegates = { company: 'Test Company', euuid: 'E1234567-89AB-CDEF-0123-456789ABCDEF', @@ -65,21 +23,6 @@ const defaultProps = { }; describe('UpdateDelegationsDrawer', () => { - beforeEach(() => { - vi.clearAllMocks(); - - mocks.mockUseAccountUsers.mockReturnValue({ - data: { data: mockUsers }, - isLoading: false, - }); - - mocks.mockUseUpdateChildAccountDelegatesQuery.mockReturnValue({ - mutateAsync: mocks.mockMutateAsync, - }); - - mocks.mockMutateAsync.mockResolvedValue({}); - }); - it('renders the drawer with current delegates', () => { renderWithTheme(); @@ -90,59 +33,4 @@ describe('UpdateDelegationsDrawer', () => { const userName = screen.getByText(/user1/i); expect(userName).toBeInTheDocument(); }); - - it('allows adding a new delegate', async () => { - renderWithTheme(); - - const user = userEvent.setup(); - - const autocompleteInput = screen.getByRole('combobox'); - await user.click(autocompleteInput); - - await waitFor(async () => { - screen.getByRole('option', { name: 'user2' }); - }); - - const user2Option = screen.getByRole('option', { name: 'user2' }); - await user.click(user2Option); - - const submitButton = screen.getByRole('button', { name: /save changes/i }); - await user.click(submitButton); - - await waitFor(() => { - expect(mocks.mockMutateAsync).toHaveBeenCalledWith({ - euuid: mockChildAccountWithDelegates.euuid, - users: ['user1', 'user2'], - }); - }); - }); - - it('allows sending an empty payload', async () => { - renderWithTheme(); - - const user = userEvent.setup(); - - // Open the autocomplete and deselect the preselected user (user1) - const autocompleteInput = screen.getByRole('combobox'); - await user.click(autocompleteInput); - - await waitFor(() => { - // Ensure options are rendered - expect(screen.getByRole('option', { name: 'user1' })).toBeInTheDocument(); - }); - - const user1Option = screen.getByRole('option', { name: 'user1' }); - await user.click(user1Option); // toggles off the selected user - - // Submit with no users selected - const submitButton = screen.getByRole('button', { name: /save changes/i }); - await user.click(submitButton); - - await waitFor(() => { - expect(mocks.mockMutateAsync).toHaveBeenCalledWith({ - euuid: mockChildAccountWithDelegates.euuid, - users: [], - }); - }); - }); }); diff --git a/packages/manager/src/features/IAM/Delegations/UpdateDelegationsDrawer.tsx b/packages/manager/src/features/IAM/Delegations/UpdateDelegationsDrawer.tsx index 3c01e8ce5fa..af44912eafa 100644 --- a/packages/manager/src/features/IAM/Delegations/UpdateDelegationsDrawer.tsx +++ b/packages/manager/src/features/IAM/Delegations/UpdateDelegationsDrawer.tsx @@ -1,4 +1,3 @@ -import { useAccountUsers } from '@linode/queries'; import { Drawer } from '@linode/ui'; import React from 'react'; @@ -17,11 +16,6 @@ export const UpdateDelegationsDrawer = ({ onClose, open, }: Props) => { - const { data: allParentAccounts, isLoading } = useAccountUsers({ - enabled: open, - filters: { user_type: 'parent' }, - }); - const formattedCurrentUsers = React.useMemo(() => { if (delegation && 'users' in delegation && delegation.users) { return delegation.users.map((username) => ({ @@ -32,23 +26,13 @@ export const UpdateDelegationsDrawer = ({ return []; }, [delegation]); - const userOptions = React.useMemo(() => { - if (!allParentAccounts?.data) return []; - return allParentAccounts.data.map((user) => ({ - label: user.username, - value: user.username, - })); - }, [allParentAccounts]); - return ( {delegation && ( )} diff --git a/packages/manager/src/features/IAM/Roles/Defaults/DefaultEntityAccess.test.tsx b/packages/manager/src/features/IAM/Roles/Defaults/DefaultEntityAccess.test.tsx index 1efc96ba25c..6c9fdb5f8d6 100644 --- a/packages/manager/src/features/IAM/Roles/Defaults/DefaultEntityAccess.test.tsx +++ b/packages/manager/src/features/IAM/Roles/Defaults/DefaultEntityAccess.test.tsx @@ -17,6 +17,7 @@ const queryMocks = vi.hoisted(() => ({ useIsDefaultDelegationRolesForChildAccount: vi .fn() .mockReturnValue({ isDefaultDelegationRolesForChildAccount: true }), + usePermissions: vi.fn().mockReturnValue({}), })); vi.mock('src/features/IAM/hooks/useDelegationRole', () => ({ @@ -41,6 +42,14 @@ vi.mock('src/queries/entities/entities', async () => { }; }); +vi.mock('src/features/IAM/hooks/usePermissions', async () => { + const actual = await vi.importActual('src/features/IAM/hooks/usePermissions'); + return { + ...actual, + usePermissions: queryMocks.usePermissions, + }; +}); + vi.mock('@tanstack/react-router', async () => { const actual = await vi.importActual('@tanstack/react-router'); return { @@ -51,6 +60,14 @@ vi.mock('@tanstack/react-router', async () => { }); describe('DefaultEntityAccess', () => { + beforeEach(() => { + vi.clearAllMocks(); + + queryMocks.usePermissions.mockReturnValue({ + data: { view_default_delegate_access: true }, + isLoading: false, + }); + }); it('should render', async () => { queryMocks.useGetDefaultDelegationAccessQuery.mockReturnValue({ data: { @@ -92,4 +109,21 @@ describe('DefaultEntityAccess', () => { renderWithTheme(); expect(screen.getByText(ERROR_STATE_TEXT)).toBeVisible(); }); + + it('should not render if user does not have permissions', () => { + queryMocks.usePermissions.mockReturnValue({ + data: { + view_default_delegate_access: false, + }, + isLoading: false, + }); + + renderWithTheme(); + + expect( + screen.queryByText( + 'You do not have permission to view default entity access for delegate users.' + ) + ).toBeVisible(); + }); }); diff --git a/packages/manager/src/features/IAM/Roles/Defaults/DefaultEntityAccess.tsx b/packages/manager/src/features/IAM/Roles/Defaults/DefaultEntityAccess.tsx index 633c1b3d914..61552a9bf45 100644 --- a/packages/manager/src/features/IAM/Roles/Defaults/DefaultEntityAccess.tsx +++ b/packages/manager/src/features/IAM/Roles/Defaults/DefaultEntityAccess.tsx @@ -2,12 +2,14 @@ import { useGetDefaultDelegationAccessQuery } from '@linode/queries'; import { CircleProgress, ErrorState, + Notice, Paper, Stack, Typography, } from '@linode/ui'; import * as React from 'react'; +import { usePermissions } from '../../hooks/usePermissions'; import { AssignedEntitiesTable } from '../../Shared/AssignedEntitiesTable/AssignedEntitiesTable'; import { ERROR_STATE_TEXT, @@ -16,20 +18,35 @@ import { import { NoAssignedRoles } from '../../Shared/NoAssignedRoles/NoAssignedRoles'; export const DefaultEntityAccess = () => { + const { data: permissions, isLoading: isPermissionsLoading } = usePermissions( + 'account', + ['view_default_delegate_access'] + ); const { data: defaultAccess, isLoading: defaultAccessLoading, error, - } = useGetDefaultDelegationAccessQuery({ enabled: true }); + } = useGetDefaultDelegationAccessQuery({ + enabled: permissions?.view_default_delegate_access, + }); const hasAssignedEntities = defaultAccess ? defaultAccess.entity_access.length > 0 : false; - if (defaultAccessLoading) { + if (defaultAccessLoading || isPermissionsLoading) { return ; } + if (!permissions?.view_default_delegate_access) { + return ( + + You do not have permission to view default entity access for delegate + users. + + ); + } + if (error) { return ; } diff --git a/packages/manager/src/features/IAM/Roles/Defaults/DefaultRoles.test.tsx b/packages/manager/src/features/IAM/Roles/Defaults/DefaultRoles.test.tsx index b27283a6393..e55dccdde9d 100644 --- a/packages/manager/src/features/IAM/Roles/Defaults/DefaultRoles.test.tsx +++ b/packages/manager/src/features/IAM/Roles/Defaults/DefaultRoles.test.tsx @@ -14,9 +14,12 @@ const loadingTestId = 'circle-progress'; const queryMocks = vi.hoisted(() => ({ useGetDefaultDelegationAccessQuery: vi.fn().mockReturnValue({}), useLocation: vi.fn().mockReturnValue({}), + useSearch: vi.fn().mockReturnValue({}), + useNavigate: vi.fn(() => vi.fn()), useIsDefaultDelegationRolesForChildAccount: vi .fn() .mockReturnValue({ isDefaultDelegationRolesForChildAccount: true }), + usePermissions: vi.fn().mockReturnValue({}), })); vi.mock('@tanstack/react-router', async () => { @@ -24,6 +27,8 @@ vi.mock('@tanstack/react-router', async () => { return { ...actual, useLocation: queryMocks.useLocation, + useSearch: queryMocks.useSearch, + useNavigate: queryMocks.useNavigate, }; }); @@ -35,11 +40,28 @@ vi.mock('@linode/queries', async () => { queryMocks.useGetDefaultDelegationAccessQuery, }; }); + +vi.mock('src/features/IAM/hooks/usePermissions', async () => { + const actual = await vi.importActual('src/features/IAM/hooks/usePermissions'); + return { + ...actual, + usePermissions: queryMocks.usePermissions, + }; +}); + vi.mock('src/features/IAM/hooks/useDelegationRole', () => ({ useIsDefaultDelegationRolesForChildAccount: queryMocks.useIsDefaultDelegationRolesForChildAccount, })); describe('DefaultRoles', () => { + beforeEach(() => { + vi.clearAllMocks(); + + queryMocks.usePermissions.mockReturnValue({ + data: { view_default_delegate_access: true }, + isLoading: false, + }); + }); it('should render', async () => { queryMocks.useGetDefaultDelegationAccessQuery.mockReturnValue({ data: { @@ -53,7 +75,6 @@ describe('DefaultRoles', () => { isLoading: false, }); const { queryByTestId } = renderWithTheme(); - await waitForElementToBeRemoved(queryByTestId(loadingTestId)); expect(screen.getByText('Default Roles for Delegate Users')).toBeVisible(); expect(screen.getByRole('table')).toBeVisible(); @@ -84,4 +105,21 @@ describe('DefaultRoles', () => { renderWithTheme(); expect(screen.getByText(ERROR_STATE_TEXT)).toBeVisible(); }); + + it('should not render if user does not have permissions', () => { + queryMocks.usePermissions.mockReturnValue({ + data: { + view_default_delegate_access: false, + }, + isLoading: false, + }); + + renderWithTheme(); + + expect( + screen.queryByText( + 'You do not have permission to view default roles for delegate users.' + ) + ).toBeVisible(); + }); }); diff --git a/packages/manager/src/features/IAM/Roles/Defaults/DefaultRoles.tsx b/packages/manager/src/features/IAM/Roles/Defaults/DefaultRoles.tsx index 7c979c02d71..11e60aba199 100644 --- a/packages/manager/src/features/IAM/Roles/Defaults/DefaultRoles.tsx +++ b/packages/manager/src/features/IAM/Roles/Defaults/DefaultRoles.tsx @@ -1,7 +1,14 @@ import { useGetDefaultDelegationAccessQuery } from '@linode/queries'; -import { CircleProgress, ErrorState, Paper, Typography } from '@linode/ui'; +import { + CircleProgress, + ErrorState, + Notice, + Paper, + Typography, +} from '@linode/ui'; import * as React from 'react'; +import { usePermissions } from '../../hooks/usePermissions'; import { AssignedRolesTable } from '../../Shared/AssignedRolesTable/AssignedRolesTable'; import { ERROR_STATE_TEXT, @@ -10,20 +17,35 @@ import { import { NoAssignedRoles } from '../../Shared/NoAssignedRoles/NoAssignedRoles'; export const DefaultRoles = () => { + const { data: permissions, isLoading: isPermissionsLoading } = usePermissions( + 'account', + ['view_default_delegate_access'] + ); const { data: defaultRolesData, isLoading: defaultRolesLoading, error, - } = useGetDefaultDelegationAccessQuery({ enabled: true }); + } = useGetDefaultDelegationAccessQuery({ + enabled: permissions?.view_default_delegate_access, + }); + const hasAssignedRoles = defaultRolesData ? defaultRolesData.account_access.length > 0 || defaultRolesData.entity_access.length > 0 : false; - if (defaultRolesLoading) { + if (defaultRolesLoading || isPermissionsLoading) { return ; } + if (!permissions?.view_default_delegate_access) { + return ( + + You do not have permission to view default roles for delegate users. + + ); + } + if (error) { return ; } diff --git a/packages/manager/src/features/IAM/Roles/Roles.tsx b/packages/manager/src/features/IAM/Roles/Roles.tsx index 45aa59d204e..d14073ebb3a 100644 --- a/packages/manager/src/features/IAM/Roles/Roles.tsx +++ b/packages/manager/src/features/IAM/Roles/Roles.tsx @@ -19,7 +19,8 @@ export const RolesLanding = () => { permissions?.list_role_permissions ); const { isIAMDelegationEnabled } = useIsIAMDelegationEnabled(); - const { isChildUserType, isProfileLoading } = useDelegationRole(); + const { isChildUserType, isProfileLoading, isDelegateUserType } = + useDelegationRole(); const { roles } = React.useMemo(() => { if (!accountRoles) { @@ -41,7 +42,9 @@ export const RolesLanding = () => { return ( <> - {isChildUserType && isIAMDelegationEnabled && } + {(isChildUserType || isDelegateUserType) && isIAMDelegationEnabled && ( + + )} ({ marginTop: theme.tokens.spacing.S16 })}> Roles diff --git a/packages/manager/src/features/IAM/Roles/RolesTable/AssignSelectedRolesDrawer.tsx b/packages/manager/src/features/IAM/Roles/RolesTable/AssignSelectedRolesDrawer.tsx index 2e593f8a0fd..83b70dbbc67 100644 --- a/packages/manager/src/features/IAM/Roles/RolesTable/AssignSelectedRolesDrawer.tsx +++ b/packages/manager/src/features/IAM/Roles/RolesTable/AssignSelectedRolesDrawer.tsx @@ -167,7 +167,7 @@ export const AssignSelectedRolesDrawer = ({ 1 ? `s` : ``} to Users`} + title={`Assign Selected Role${selectedRoles.length > 1 ? `s` : ``} to a User`} >
diff --git a/packages/manager/src/features/IAM/Shared/AssignedEntitiesTable/AssignedEntitiesTable.test.tsx b/packages/manager/src/features/IAM/Shared/AssignedEntitiesTable/AssignedEntitiesTable.test.tsx index 46ee9ab8a92..5674585a269 100644 --- a/packages/manager/src/features/IAM/Shared/AssignedEntitiesTable/AssignedEntitiesTable.test.tsx +++ b/packages/manager/src/features/IAM/Shared/AssignedEntitiesTable/AssignedEntitiesTable.test.tsx @@ -12,6 +12,7 @@ const queryMocks = vi.hoisted(() => ({ useAllAccountEntities: vi.fn().mockReturnValue({}), useParams: vi.fn().mockReturnValue({}), useSearch: vi.fn().mockReturnValue({}), + useNavigate: vi.fn(() => vi.fn()), useUserRoles: vi.fn().mockReturnValue({}), })); @@ -37,6 +38,7 @@ vi.mock('@tanstack/react-router', async () => { ...actual, useParams: queryMocks.useParams, useSearch: queryMocks.useSearch, + useNavigate: queryMocks.useNavigate, }; }); @@ -102,14 +104,11 @@ describe('AssignedEntitiesTable', () => { data: mockEntities, }); - renderWithTheme(); + queryMocks.useSearch.mockReturnValue({ query: 'NonExistentRole' }); - const searchInput = screen.getByPlaceholderText('Search'); - await userEvent.type(searchInput, 'NonExistentRole'); + renderWithTheme(); - await waitFor(() => { - expect(screen.getByText('No items to display.')).toBeVisible(); - }); + expect(screen.getByText('No items to display.')).toBeVisible(); }); it('should filter roles based on search query', async () => { diff --git a/packages/manager/src/features/IAM/Shared/AssignedEntitiesTable/AssignedEntitiesTable.tsx b/packages/manager/src/features/IAM/Shared/AssignedEntitiesTable/AssignedEntitiesTable.tsx index db1305c2183..0e4b7e62c19 100644 --- a/packages/manager/src/features/IAM/Shared/AssignedEntitiesTable/AssignedEntitiesTable.tsx +++ b/packages/manager/src/features/IAM/Shared/AssignedEntitiesTable/AssignedEntitiesTable.tsx @@ -4,7 +4,7 @@ import { } from '@linode/queries'; import { Select, Typography, useTheme } from '@linode/ui'; import Grid from '@mui/material/Grid'; -import { useSearch } from '@tanstack/react-router'; +import { useNavigate, useSearch } from '@tanstack/react-router'; import React from 'react'; import { ActionMenu } from 'src/components/ActionMenu/ActionMenu'; @@ -55,6 +55,9 @@ interface Props { username?: string; } +const DEFAULTS_ENTITIES_URL = '/iam/roles/defaults/entity-access'; +const USER_ENTITIES_URL = '/iam/users/$username/entities'; + export const AssignedEntitiesTable = ({ username }: Props) => { const theme = useTheme(); const { data: permissions } = usePermissions('account', [ @@ -62,31 +65,52 @@ export const AssignedEntitiesTable = ({ username }: Props) => { 'update_default_delegate_access', 'list_entities', ]); + const navigate = useNavigate(); const { isDefaultDelegationRolesForChildAccount } = useIsDefaultDelegationRolesForChildAccount(); - const { selectedRole: selectedRoleSearchParam } = useSearch({ - strict: false, + const { + query: queryParam, + entityType: entityTypeParam, + order: orderParam, + selectedRole: selectedRoleSearchParam, + orderBy: orderByParam, + } = useSearch({ + from: isDefaultDelegationRolesForChildAccount + ? DEFAULTS_ENTITIES_URL + : USER_ENTITIES_URL, }); - const [order, setOrder] = React.useState<'asc' | 'desc'>('asc'); - const [orderBy, setOrderBy] = React.useState('entity_name'); + const order: 'asc' | 'desc' = orderParam ?? 'asc'; + + const ORDERABLE_KEYS = ['entity_name', 'entity_type', 'role_name'] as const; + const isValidOrderBy = (v: unknown): v is OrderByKeys => + ORDERABLE_KEYS.includes(v as OrderByKeys); + const orderBy: OrderByKeys = isValidOrderBy(orderByParam) + ? orderByParam + : 'entity_name'; const handleOrderChange = (newOrderBy: OrderByKeys) => { - if (orderBy === newOrderBy) { - setOrder(order === 'asc' ? 'desc' : 'asc'); - } else { - setOrderBy(newOrderBy); - setOrder('asc'); - } + const nextOrder: 'asc' | 'desc' = + orderBy === newOrderBy ? (order === 'asc' ? 'desc' : 'asc') : 'asc'; + navigate({ + to: isDefaultDelegationRolesForChildAccount + ? DEFAULTS_ENTITIES_URL + : USER_ENTITIES_URL, + params: isDefaultDelegationRolesForChildAccount + ? undefined + : { username: username || '' }, + search: (prev) => ({ + ...prev, + order: nextOrder, + orderBy: newOrderBy, + }), + }); }; - const [query, setQuery] = React.useState(selectedRoleSearchParam ?? ''); - - const [entityType, setEntityType] = React.useState( - ALL_ENTITIES_OPTION - ); + // Use the router `query` param, falling back to `selectedRole` for initial value + const appliedQuery = queryParam ?? selectedRoleSearchParam ?? ''; const [drawerMode, setDrawerMode] = React.useState('assign-role'); @@ -149,6 +173,14 @@ export const AssignedEntitiesTable = ({ username }: Props) => { return { filterableOptions, roles }; }, [assignedRoles, entities]); + const selectedEntityTypeOption = React.useMemo(() => { + const value = entityTypeParam ?? ALL_ENTITIES_OPTION.value; + return ( + filterableOptions.find((opt) => opt.value === value) || + ALL_ENTITIES_OPTION + ); + }, [filterableOptions, entityTypeParam]); + const handleChangeRole = (role: EntitiesRole, mode: DrawerModes) => { setIsChangeRoleForEntityDrawerOpen(true); setSelectedRole(role); @@ -176,9 +208,9 @@ export const AssignedEntitiesTable = ({ username }: Props) => { }; const filteredRoles = getFilteredRoles({ - entityType: entityType?.value as 'all' | EntityType, + entityType: entityTypeParam ?? 'all', getSearchableFields, - query, + query: appliedQuery, roles, }) as EntitiesRole[]; @@ -197,8 +229,8 @@ export const AssignedEntitiesTable = ({ username }: Props) => { const pagination = usePaginationV2({ currentRoute: isDefaultDelegationRolesForChildAccount - ? '/iam/roles/defaults/entity-access' - : `/iam/users/$username/entities`, + ? DEFAULTS_ENTITIES_URL + : USER_ENTITIES_URL, initialPage: 1, preferenceKey: ENTITIES_TABLE_PREFERENCE_KEY, clientSidePaginationData: filteredAndSortedRoles, @@ -309,24 +341,50 @@ export const AssignedEntitiesTable = ({ username }: Props) => { hideLabel label="Filter" onSearch={(value) => { - pagination.handlePageChange(1); - setQuery(value); + navigate({ + to: isDefaultDelegationRolesForChildAccount + ? DEFAULTS_ENTITIES_URL + : USER_ENTITIES_URL, + params: + isDefaultDelegationRolesForChildAccount && !username + ? undefined + : username, + search: (prev) => ({ + ...prev, + page: 1, + query: value !== '' ? value : undefined, + }), + }); }} placeholder="Search" sx={{ height: 34 }} - value={query} + value={appliedQuery} />
diff --git a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.test.tsx b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.test.tsx index 1bf10ef230e..814084b4731 100644 --- a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.test.tsx +++ b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.test.tsx @@ -12,6 +12,8 @@ import { AssignedRolesTable } from './AssignedRolesTable'; const queryMocks = vi.hoisted(() => ({ useAllAccountEntities: vi.fn().mockReturnValue({}), useParams: vi.fn().mockReturnValue({}), + useNavigate: vi.fn(() => vi.fn()), + useSearch: vi.fn().mockReturnValue({}), useAccountRoles: vi.fn().mockReturnValue({}), useUserRoles: vi.fn().mockReturnValue({}), useGetDefaultDelegationAccessQuery: vi.fn().mockReturnValue({}), @@ -44,6 +46,8 @@ vi.mock('@tanstack/react-router', async () => { return { ...actual, useParams: queryMocks.useParams, + useNavigate: queryMocks.useNavigate, + useSearch: queryMocks.useSearch, }; }); @@ -125,14 +129,11 @@ describe('AssignedRolesTable', () => { data: mockEntities, }); - renderWithTheme(); + queryMocks.useSearch.mockReturnValue({ query: 'NonExistentRole' }); - const searchInput = screen.getByPlaceholderText('Search'); - await userEvent.type(searchInput, 'NonExistentRole'); + renderWithTheme(); - await waitFor(() => { - expect(screen.getByText('No items to display.')).toBeVisible(); - }); + expect(screen.getByText('No items to display.')).toBeVisible(); }); it('should filter roles based on search query', async () => { @@ -150,8 +151,7 @@ describe('AssignedRolesTable', () => { renderWithTheme(); - const searchInput = screen.getByPlaceholderText('Search'); - await userEvent.type(searchInput, 'account_linode_admin'); + queryMocks.useSearch.mockReturnValue({ query: 'account_linode_admin' }); await waitFor(() => { expect(screen.queryByText('account_linode_admin')).toBeVisible(); @@ -173,9 +173,7 @@ describe('AssignedRolesTable', () => { renderWithTheme(); - const autocomplete = screen.getByPlaceholderText('All Assigned Roles'); - await userEvent.type(autocomplete, 'Firewall Roles'); - + queryMocks.useSearch.mockReturnValue({ roleType: 'firewall' }); await waitFor(() => { expect(screen.queryByText('account_firewall_creator')).toBeVisible(); }); diff --git a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.tsx b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.tsx index d9172e4a0bd..11b9e92e8c5 100644 --- a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.tsx +++ b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.tsx @@ -6,7 +6,7 @@ import { import { Button, CircleProgress, Select, Typography } from '@linode/ui'; import { useTheme } from '@mui/material'; import Grid from '@mui/material/Grid'; -import { useNavigate, useParams } from '@tanstack/react-router'; +import { useNavigate, useParams, useSearch } from '@tanstack/react-router'; import React from 'react'; import { CollapsibleTable } from 'src/components/CollapsibleTable/CollapsibleTable'; @@ -70,23 +70,35 @@ const ALL_ROLES_OPTION: SelectOption = { label: 'All Assigned Roles', value: 'all', }; + +const DEFAULTS_ROLES_URL = '/iam/roles/defaults/roles'; +const USER_ROLES_URL = '/iam/users/$username/roles'; + export const AssignedRolesTable = () => { const { username } = useParams({ strict: false }); const navigate = useNavigate(); const theme = useTheme(); - const [order, setOrder] = React.useState<'asc' | 'desc'>('asc'); - const [orderBy, setOrderBy] = React.useState('name'); + const { isDefaultDelegationRolesForChildAccount } = + useIsDefaultDelegationRolesForChildAccount(); + + const { + query: queryParam, + roleType: roleTypeParam, + order: orderParam, + } = useSearch({ + from: isDefaultDelegationRolesForChildAccount + ? DEFAULTS_ROLES_URL + : USER_ROLES_URL, + }); + const order: 'asc' | 'desc' = orderParam ?? 'asc'; + const orderBy: OrderByKeys = 'name'; const [isInitialLoad, setIsInitialLoad] = React.useState(true); const { data: permissions } = usePermissions('account', [ 'is_account_admin', 'update_default_delegate_access', ]); - // Determine if we're on the default roles view based on delegation role and path - const { isDefaultDelegationRolesForChildAccount } = - useIsDefaultDelegationRolesForChildAccount(); - const permissionToCheck = isDefaultDelegationRolesForChildAccount ? permissions?.update_default_delegate_access : permissions?.is_account_admin; @@ -109,13 +121,23 @@ export const AssignedRolesTable = () => { : userRolesLoading; const handleOrderChange = (newOrderBy: OrderByKeys) => { - if (orderBy === newOrderBy) { - setOrder(order === 'asc' ? 'desc' : 'asc'); - } else { - setOrderBy(newOrderBy); - setOrder('asc'); - } + const nextOrder: 'asc' | 'desc' = + orderBy === newOrderBy ? (order === 'asc' ? 'desc' : 'asc') : 'asc'; setIsInitialLoad(false); + navigate({ + to: isDefaultDelegationRolesForChildAccount + ? DEFAULTS_ROLES_URL + : USER_ROLES_URL, + params: + isDefaultDelegationRolesForChildAccount && !username + ? undefined + : username, + search: (prev) => ({ + ...prev, + order: nextOrder, + orderBy: newOrderBy, + }), + }); }; const [isChangeRoleDrawerOpen, setIsChangeRoleDrawerOpen] = @@ -200,11 +222,12 @@ export const AssignedRolesTable = () => { return { filterableOptions, roles }; }, [assignedRoles, accountRoles, entities]); - const [query, setQuery] = React.useState(''); - - const [entityType, setEntityType] = React.useState( - ALL_ROLES_OPTION - ); + const selectedEntityTypeOption = React.useMemo(() => { + const value = roleTypeParam ?? ALL_ROLES_OPTION.value; + return ( + filterableOptions.find((opt) => opt.value === value) || ALL_ROLES_OPTION + ); + }, [filterableOptions, roleTypeParam]); const handleViewEntities = (roleName: AccountRoleType | EntityRoleType) => { const selectedRole = roleName; @@ -219,9 +242,9 @@ export const AssignedRolesTable = () => { const filteredAndSortedRoles = React.useMemo(() => { const rolesToFilter = getFilteredRoles({ - entityType: entityType?.value as 'all' | AccessType, + entityType: roleTypeParam ?? 'all', getSearchableFields, - query, + query: queryParam ?? '', roles, }) as RoleView[]; @@ -246,12 +269,12 @@ export const AssignedRolesTable = () => { } return 0; }); - }, [roles, query, entityType, order, orderBy, isInitialLoad]); + }, [roles, queryParam, roleTypeParam, order, orderBy, isInitialLoad]); const pagination = usePaginationV2({ currentRoute: isDefaultDelegationRolesForChildAccount - ? '/iam/roles/defaults/roles' - : '/iam/users/$username/roles', + ? DEFAULTS_ROLES_URL + : USER_ROLES_URL, initialPage: 1, preferenceKey: ASSIGNED_ROLES_TABLE_PREFERENCE_KEY, clientSidePaginationData: filteredAndSortedRoles, @@ -402,23 +425,48 @@ export const AssignedRolesTable = () => { hideLabel label="Filter" onSearch={(value) => { - pagination.handlePageChange(1); - setQuery(value); + navigate({ + to: isDefaultDelegationRolesForChildAccount + ? DEFAULTS_ROLES_URL + : USER_ROLES_URL, + params: + isDefaultDelegationRolesForChildAccount && !username + ? undefined + : username, + search: (prev) => ({ + ...prev, + page: 1, + query: value !== '' ? value : undefined, + }), + }); }} placeholder="Search" - value={query} + value={queryParam ?? ''} /> { diff --git a/packages/manager/src/features/IAM/hooks/adapters/accountGrantsToPermissions.ts b/packages/manager/src/features/IAM/hooks/adapters/accountGrantsToPermissions.ts index 79ef3967717..97ad56524b2 100644 --- a/packages/manager/src/features/IAM/hooks/adapters/accountGrantsToPermissions.ts +++ b/packages/manager/src/features/IAM/hooks/adapters/accountGrantsToPermissions.ts @@ -89,5 +89,6 @@ export const accountGrantsToPermissions = ( update_default_delegate_access: true, view_child_account: true, create_child_account_token: true, + view_default_delegate_access: true, } as Record; }; diff --git a/packages/manager/src/features/IAM/hooks/useDelegationRole.ts b/packages/manager/src/features/IAM/hooks/useDelegationRole.ts index 4a609001f42..89c77280bee 100644 --- a/packages/manager/src/features/IAM/hooks/useDelegationRole.ts +++ b/packages/manager/src/features/IAM/hooks/useDelegationRole.ts @@ -39,7 +39,7 @@ export const useDelegationRole = (): DelegationRole => { /** * isDefaultDelegationRolesForChildAccount is true if: * - IAM Delegation is enabled for the account - * - The current user is a child account + * - The current user is a child or delegate account * - The current route includes '/iam/roles/defaults' * * This flag is used to determine if the component should show or fetch/update delegated default roles @@ -47,13 +47,13 @@ export const useDelegationRole = (): DelegationRole => { */ export const useIsDefaultDelegationRolesForChildAccount = () => { const { isIAMDelegationEnabled } = useIsIAMDelegationEnabled(); - const { isChildUserType } = useDelegationRole(); + const { isChildUserType, isDelegateUserType } = useDelegationRole(); const location = useLocation(); return { isDefaultDelegationRolesForChildAccount: (isIAMDelegationEnabled && - isChildUserType && + (isChildUserType || isDelegateUserType) && location.pathname.includes('/iam/roles/defaults')) ?? false, }; diff --git a/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.test.tsx b/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.test.tsx index c39b1edca49..9927ca73eb6 100644 --- a/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.test.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.test.tsx @@ -7,7 +7,7 @@ import { makeResourcePage } from 'src/mocks/serverHandlers'; import { http, HttpResponse, server } from 'src/mocks/testServer'; import { mockMatchMedia, renderWithTheme } from 'src/utilities/testHelpers'; -import ImagesLanding from './ImagesLanding'; +import { ImagesLanding } from './ImagesLanding'; const queryMocks = vi.hoisted(() => ({ usePermissions: vi.fn().mockReturnValue({ data: { create_image: false } }), diff --git a/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx b/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx index 15aafc8ff91..d597cc237f4 100644 --- a/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx @@ -568,5 +568,3 @@ export const ImagesLanding = () => { ); }; - -export default ImagesLanding; diff --git a/packages/manager/src/features/Images/ImagesLanding/imagesLandingLazyRoute.ts b/packages/manager/src/features/Images/ImagesLanding/imagesLandingLazyRoute.ts index c89a9c5eea9..1bdf4a302be 100644 --- a/packages/manager/src/features/Images/ImagesLanding/imagesLandingLazyRoute.ts +++ b/packages/manager/src/features/Images/ImagesLanding/imagesLandingLazyRoute.ts @@ -1,6 +1,6 @@ import { createLazyRoute } from '@tanstack/react-router'; -import { ImagesLanding } from 'src/features/Images/ImagesLanding/ImagesLanding'; +import { ImagesLanding } from './ImagesLanding'; export const imagesLandingLazyRoute = createLazyRoute('/images')({ component: ImagesLanding, diff --git a/packages/manager/src/features/Images/ImagesLanding/v2/ImageLibrary/ImageLibraryTabs.test.tsx b/packages/manager/src/features/Images/ImagesLanding/v2/ImageLibrary/ImageLibraryTabs.test.tsx new file mode 100644 index 00000000000..36249ee5897 --- /dev/null +++ b/packages/manager/src/features/Images/ImagesLanding/v2/ImageLibrary/ImageLibraryTabs.test.tsx @@ -0,0 +1,375 @@ +import { linodeFactory } from '@linode/utilities'; +import { waitForElementToBeRemoved } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import * as React from 'react'; + +import { imageFactory } from 'src/factories'; +import { makeResourcePage } from 'src/mocks/serverHandlers'; +import { http, HttpResponse, server } from 'src/mocks/testServer'; +import { mockMatchMedia, renderWithTheme } from 'src/utilities/testHelpers'; + +import { ImageLibraryTabs } from './ImageLibraryTabs'; + +const queryMocks = vi.hoisted(() => ({ + useLocation: vi.fn(), + usePermissions: vi.fn().mockReturnValue({ data: { create_image: false } }), + useQueryWithPermissions: vi.fn().mockReturnValue({}), + useLinodesPermissionsCheck: vi.fn().mockReturnValue({}), + useSearch: vi.fn().mockReturnValue({}), + useParams: vi.fn(), +})); + +vi.mock('src/features/IAM/hooks/usePermissions', () => ({ + usePermissions: queryMocks.usePermissions, + useQueryWithPermissions: queryMocks.useQueryWithPermissions, +})); + +vi.mock('@tanstack/react-router', async () => { + const actual = await vi.importActual('@tanstack/react-router'); + return { + ...actual, + useLocation: queryMocks.useLocation, + useSearch: queryMocks.useSearch, + useParams: queryMocks.useParams, + }; +}); + +vi.mock('../utils.ts', async () => { + const actual = await vi.importActual('../utils'); + return { + ...actual, + useLinodesPermissionsCheck: queryMocks.useLinodesPermissionsCheck, + }; +}); + +beforeAll(() => mockMatchMedia()); + +const loadingTestId = 'circle-progress'; + +describe('ImageLibraryTabs', () => { + beforeEach(() => { + queryMocks.usePermissions.mockReturnValue({ + data: { + update_image: true, + delete_image: true, + rebuild_linode: true, + create_linode: true, + replicate_image: true, + }, + }); + + queryMocks.useLocation.mockReturnValue({ + pathname: '/images/image-library', + }); + }); + + // For Custom images (Owned by me) + describe('For Custom images (Owned by me)', () => { + beforeEach(() => { + queryMocks.useParams.mockReturnValue({ imageType: 'owned-by-me' }); + }); + + it("should render 'Owned by me' tab", async () => { + const { getByText } = renderWithTheme(, { + initialRoute: '/images/image-library/owned-by-me', + }); + + expect(getByText('Owned by me')).toBeVisible(); + }); + + // Test Image action navigations for CUSTOM images (Owned by me) + it('should allow opening the Edit Image drawer', async () => { + const image = imageFactory.build(); + + server.use( + http.get('*/images', ({ request }) => { + const filter = request.headers.get('x-filter'); + + if (filter?.includes('manual')) { + return HttpResponse.json(makeResourcePage([image])); + } + return HttpResponse.json(makeResourcePage([])); + }) + ); + + const { getByText, findByLabelText, router } = renderWithTheme( + , + { + initialRoute: '/images/image-library/owned-by-me/', + } + ); + + const actionMenu = await findByLabelText( + `Action menu for Image ${image.label}` + ); + await userEvent.click(actionMenu); + await userEvent.click(getByText('Edit')); + + expect(router.state.location.pathname).toBe( + `/images/image-library/owned-by-me/${encodeURIComponent(image.id)}/edit` + ); + }); + + it('should allow opening the Restore Image drawer', async () => { + const image = imageFactory.build(); + + server.use( + http.get('*/images', ({ request }) => { + const filter = request.headers.get('x-filter'); + + if (filter?.includes('manual')) { + return HttpResponse.json(makeResourcePage([image])); + } + return HttpResponse.json(makeResourcePage([])); + }) + ); + + const { router, getByText, findByLabelText } = renderWithTheme( + , + { + initialRoute: '/images/image-library/owned-by-me/', + } + ); + + const actionMenu = await findByLabelText( + `Action menu for Image ${image.label}` + ); + await userEvent.click(actionMenu); + await userEvent.click(getByText('Rebuild an Existing Linode')); + + expect(router.state.location.pathname).toBe( + `/images/image-library/owned-by-me/${encodeURIComponent(image.id)}/rebuild` + ); + }); + + 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 }) => { + const filter = request.headers.get('x-filter'); + + if (filter?.includes('manual')) { + return HttpResponse.json(makeResourcePage([image])); + } + return HttpResponse.json(makeResourcePage([])); + }) + ); + + const { findByLabelText, getByText, queryAllByTestId, router } = + renderWithTheme(, { + initialRoute: '/images/image-library/owned-by-me/', + }); + + const loadingElement = queryAllByTestId(loadingTestId); + await waitForElementToBeRemoved(loadingElement); + + const actionMenu = await findByLabelText( + `Action menu for Image ${image.label}` + ); + await userEvent.click(actionMenu); + await userEvent.click(getByText('Deploy to New Linode')); + + expect(router.state.location.pathname).toBe('/linodes/create/images'); + + expect(router.state.location.search).toStrictEqual({ + imageID: image.id, + }); + }); + + it('should allow deleting an image', async () => { + const image = imageFactory.build(); + + server.use( + http.get('*/images', ({ request }) => { + const filter = request.headers.get('x-filter'); + + if (filter?.includes('manual')) { + return HttpResponse.json(makeResourcePage([image])); + } + return HttpResponse.json(makeResourcePage([])); + }) + ); + + const { router, findByLabelText, getByText } = renderWithTheme( + , + { + initialRoute: '/images/image-library/owned-by-me/', + } + ); + + const actionMenu = await findByLabelText( + `Action menu for Image ${image.label}` + ); + await userEvent.click(actionMenu); + await userEvent.click(getByText('Delete')); + + expect(router.state.location.pathname).toBe( + `/images/image-library/owned-by-me/${encodeURIComponent(image.id)}/delete` + ); + }); + }); + + // For Recovery images + describe('For Recovery images', () => { + beforeEach(() => { + queryMocks.useParams.mockReturnValue({ imageType: 'recovery-images' }); + }); + + it("should render 'Recovery images' tab", async () => { + const { getByText } = renderWithTheme(, { + initialRoute: '/images/image-library/recovery-images', + }); + + expect(getByText('Recovery images')).toBeVisible(); + }); + + // Test Images Action navigations for RECOVERY images + it('should allow opening the Edit Image drawer', async () => { + const image = imageFactory.build({ type: 'automatic' }); + + server.use( + http.get('*/images', ({ request }) => { + const filter = request.headers.get('x-filter'); + + if (filter?.includes('automatic')) { + return HttpResponse.json(makeResourcePage([image])); + } + return HttpResponse.json(makeResourcePage([])); + }) + ); + + const { getByText, findByLabelText, router } = renderWithTheme( + , + { + initialRoute: '/images/image-library/recovery-images', + } + ); + + const actionMenu = await findByLabelText( + `Action menu for Image ${image.label}` + ); + await userEvent.click(actionMenu); + await userEvent.click(getByText('Edit')); + + expect(router.state.location.pathname).toBe( + `/images/image-library/recovery-images/${encodeURIComponent(image.id)}/edit` + ); + }); + + it('should allow opening the Restore Image drawer', async () => { + const image = imageFactory.build(); + + server.use( + http.get('*/images', ({ request }) => { + const filter = request.headers.get('x-filter'); + + if (filter?.includes('automatic')) { + return HttpResponse.json(makeResourcePage([image])); + } + return HttpResponse.json(makeResourcePage([])); + }) + ); + + const { router, getByText, findByLabelText } = renderWithTheme( + , + { + initialRoute: '/images/image-library/recovery-images', + } + ); + + const actionMenu = await findByLabelText( + `Action menu for Image ${image.label}` + ); + await userEvent.click(actionMenu); + await userEvent.click(getByText('Rebuild an Existing Linode')); + + expect(router.state.location.pathname).toBe( + `/images/image-library/recovery-images/${encodeURIComponent(image.id)}/rebuild` + ); + }); + + 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 }) => { + const filter = request.headers.get('x-filter'); + + if (filter?.includes('automatic')) { + return HttpResponse.json(makeResourcePage([image])); + } + return HttpResponse.json(makeResourcePage([])); + }) + ); + + const { findByLabelText, getByText, queryAllByTestId, router } = + renderWithTheme(, { + initialRoute: '/images/image-library/recovery-images', + }); + + const loadingElement = queryAllByTestId(loadingTestId); + await waitForElementToBeRemoved(loadingElement); + + const actionMenu = await findByLabelText( + `Action menu for Image ${image.label}` + ); + await userEvent.click(actionMenu); + await userEvent.click(getByText('Deploy to New Linode')); + + expect(router.state.location.pathname).toBe('/linodes/create/images'); + + expect(router.state.location.search).toStrictEqual({ + imageID: image.id, + }); + }); + + it('should allow deleting an image', async () => { + const image = imageFactory.build(); + + server.use( + http.get('*/images', ({ request }) => { + const filter = request.headers.get('x-filter'); + + if (filter?.includes('automatic')) { + return HttpResponse.json(makeResourcePage([image])); + } + return HttpResponse.json(makeResourcePage([])); + }) + ); + + const { router, findByLabelText, getByText } = renderWithTheme( + , + { + initialRoute: '/images/image-library/recovery-images', + } + ); + + const actionMenu = await findByLabelText( + `Action menu for Image ${image.label}` + ); + await userEvent.click(actionMenu); + await userEvent.click(getByText('Delete')); + + expect(router.state.location.pathname).toBe( + `/images/image-library/recovery-images/${encodeURIComponent(image.id)}/delete` + ); + }); + }); + + it('should render Owned (custom), Shared and Recovery tabs under Images Library Tab', async () => { + const { getByText } = renderWithTheme(, { + initialRoute: '/images/image-library', + }); + + expect(getByText('Owned by me', { selector: 'button' })).toBeVisible(); + expect(getByText('Shared with me', { selector: 'button' })).toBeVisible(); + expect(getByText('Recovery images', { selector: 'button' })).toBeVisible(); + }); +}); diff --git a/packages/manager/src/features/Images/ImagesLanding/v2/ImageLibrary/ImageLibraryTabs.tsx b/packages/manager/src/features/Images/ImagesLanding/v2/ImageLibrary/ImageLibraryTabs.tsx new file mode 100644 index 00000000000..ea4d45933a6 --- /dev/null +++ b/packages/manager/src/features/Images/ImagesLanding/v2/ImageLibrary/ImageLibraryTabs.tsx @@ -0,0 +1,187 @@ +import { imageQueries, useImageQuery, useQueryClient } from '@linode/queries'; +import { BetaChip, Drawer, Notice, Stack } from '@linode/ui'; +import { useNavigate, useParams } from '@tanstack/react-router'; +import * as React from 'react'; + +import { SuspenseLoader } from 'src/components/SuspenseLoader'; +import { SafeTabPanel } from 'src/components/Tabs/SafeTabPanel'; +import { Tab } from 'src/components/Tabs/Tab'; +import { TabList } from 'src/components/Tabs/TabList'; +import { TabPanels } from 'src/components/Tabs/TabPanels'; +import { Tabs } from 'src/components/Tabs/Tabs'; + +import { getSubTabIndex } from '../../../utils'; +import { DeleteImageDialog } from '../../DeleteImageDialog'; +import { EditImageDrawer } from '../../EditImageDrawer'; +import { ManageImageReplicasForm } from '../../ImageRegions/ManageImageRegionsForm'; +import { RebuildImageDrawer } from '../../RebuildImageDrawer'; +import { imageLibrarySubTabs as subTabs } from './imageLibraryTabsConfig'; +import { ImagesView } from './ImagesView'; + +import type { Handlers as ImageHandlers } from '../../ImagesActionMenu'; +import type { Image } from '@linode/api-v4'; +import type { ImageAction } from 'src/routes/images'; + +export const ImageLibraryTabs = () => { + const navigate = useNavigate(); + + const imageActionParams = useParams({ + from: '/images/image-library/$imageType/$imageId/$action', + shouldThrow: false, + }); + + const imageTypeParams = useParams({ + from: '/images/image-library/$imageType', + shouldThrow: false, + }); + + const queryClient = useQueryClient(); + + const { + data: selectedImage, + isLoading: isFetchingSelectedImage, + error: selectedImageError, + } = useImageQuery( + imageActionParams?.imageId ?? '', + !!imageActionParams?.imageId + ); + + const actionHandler = (image: Image, action: ImageAction) => { + navigate({ + params: { + action, + imageId: image.id, + imageType: imageTypeParams?.imageType ?? 'owned-by-me', + }, + search: (prev) => prev, + to: '/images/image-library/$imageType/$imageId/$action', + }); + }; + + const handleEdit = (image: Image) => { + actionHandler(image, 'edit'); + }; + + const handleRebuild = (image: Image) => { + actionHandler(image, 'rebuild'); + }; + + const handleDelete = (image: Image) => { + actionHandler(image, 'delete'); + }; + + const handleCloseDialog = () => { + navigate({ + search: (prev) => prev, + to: '/images/image-library/$imageType', + params: { + imageType: imageTypeParams?.imageType ?? 'owned-by-me', + }, + }); + }; + + const handleManageRegions = (image: Image) => { + actionHandler(image, 'manage-replicas'); + }; + + const onCancelFailedClick = () => { + queryClient.invalidateQueries({ + queryKey: imageQueries.paginated._def, + }); + }; + + const handleDeployNewLinode = (imageId: string) => { + navigate({ + to: '/linodes/create/images', + search: { + imageID: imageId, + }, + }); + }; + + const handlers: ImageHandlers = { + onCancelFailed: onCancelFailedClick, + onDelete: handleDelete, + onDeploy: handleDeployNewLinode, + onEdit: handleEdit, + onManageRegions: handleManageRegions, + onRebuild: handleRebuild, + }; + + const subTabIndex = getSubTabIndex(subTabs, imageTypeParams?.imageType); + + const onTabChange = (index: number) => { + // - Update the "imageType" param. + // - This switches between "Owned by me", "Shared with me" and "Recovery images" sub-tabs within the Image Library tab. + navigate({ + to: `/images/image-library/$imageType`, + params: { + imageType: subTabs[index].type, + }, + }); + }; + + return ( + + + + {subTabs.map((tab) => ( + + {tab.title} {tab.isBeta ? : null} + + ))} + + }> + + {subTabs.map((tab, idx) => ( + + {tab.type === 'owned-by-me' && ( + + )} + {tab.type === 'shared-with-me' && ( + + Share with me is coming soon... + + )} + {tab.type === 'recovery-images' && ( + + )} + + ))} + + + + + + + + + + + ); +}; diff --git a/packages/manager/src/features/Images/ImagesLanding/v2/ImageLibrary/ImagesTable.styles.ts b/packages/manager/src/features/Images/ImagesLanding/v2/ImageLibrary/ImagesTable.styles.ts new file mode 100644 index 00000000000..15932720246 --- /dev/null +++ b/packages/manager/src/features/Images/ImagesLanding/v2/ImageLibrary/ImagesTable.styles.ts @@ -0,0 +1,34 @@ +import { Box, Paper, Typography } from '@linode/ui'; +import { styled } from '@mui/material/styles'; + +export const StyledImageContainer = styled(Paper, { + label: 'StyledImageContainer', +})(({ theme }) => ({ + border: `1px solid ${theme.tokens.alias.Border.Normal}`, + marginBottom: theme.spacingFunction(24), + padding: 0, +})); + +export const StyledImageTableHeader = styled(Box, { + label: 'StyledImageTableHeader', +})(({ theme }) => ({ + padding: `${theme.spacingFunction(16)} ${theme.spacingFunction(24)} 0`, +})); + +export const StyledImageTableSubheader = styled(Typography, { + label: 'StyledImageTableSubheader', +})(({ theme }) => ({ + marginTop: theme.spacingFunction(8), +})); + +export const StyledImageTableContainer = styled(Box, { + label: 'StyledImageTableContainer', +})(({ theme }) => ({ + padding: `${theme.spacingFunction(16)} ${theme.spacingFunction(24)} ${theme.spacingFunction(24)}`, + '& .MuiTable-root': { + border: 'none', + }, + '& [data-qa-table-pagination]': { + border: 'none', + }, +})); diff --git a/packages/manager/src/features/Images/ImagesLanding/v2/ImageLibrary/ImagesTable.tsx b/packages/manager/src/features/Images/ImagesLanding/v2/ImageLibrary/ImagesTable.tsx new file mode 100644 index 00000000000..a38d56e0a5b --- /dev/null +++ b/packages/manager/src/features/Images/ImagesLanding/v2/ImageLibrary/ImagesTable.tsx @@ -0,0 +1,218 @@ +import { + Box, + Button, + Hidden, + Typography, + ZeroStateSearchNarrowIcon, +} from '@linode/ui'; +import React from 'react'; + +import { DocsLink } from 'src/components/DocsLink/DocsLink'; +import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter'; +import { Table } from 'src/components/Table'; +import { TableBody } from 'src/components/TableBody'; +import { TableCell } from 'src/components/TableCell'; +import { TableHead } from 'src/components/TableHead'; +import { TableRow } from 'src/components/TableRow'; +import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; +import { TableRowError } from 'src/components/TableRowError/TableRowError'; +import { TableSortCell } from 'src/components/TableSortCell'; + +import { ImageRow } from '../../ImageRow'; +import { + StyledImageContainer, + StyledImageTableContainer, + StyledImageTableHeader, + StyledImageTableSubheader, +} from './ImagesTable.styles'; + +import type { Handlers as ImageHandlers } from '../../ImagesActionMenu'; +import type { + ImageConfig, + ImageViewTableColConfig, +} from './imageLibraryTabsConfig'; +import type { APIError, Event, Image } from '@linode/api-v4'; +import type { Order } from 'src/hooks/useOrderV2'; + +interface HeaderProps { + buttonProps?: { + buttonText: string; + disabled?: boolean; + onButtonClick: () => void; + tooltipText?: string; + }; + description?: React.ReactNode; + docsLink?: ImageConfig['docsLink']; + title: string; +} + +interface ImagesTableProps { + columns: ImageViewTableColConfig[]; + emptyMessage: ImageConfig['emptyMessage']; + error?: APIError[] | null; + eventCategory: string; + events: { + [k: string]: Event | undefined; + }; + handleOrderChange: (newOrderBy: string, newOrder: Order) => void; + handlers: ImageHandlers; + headerProps?: HeaderProps; + images: Image[]; + order: Order; + orderBy: string; + pagination: { + count: number; + handlePageChange: (newPage: number) => void; + handlePageSizeChange: (newSize: number) => void; + page: number; + pageSize: number; + }; + query?: string; +} + +export const ImagesTable = (props: ImagesTableProps) => { + const { + headerProps, + images, + orderBy, + order, + handleOrderChange, + columns, + events, + handlers, + error, + query, + pagination, + eventCategory, + emptyMessage, + } = props; + + return ( + + {headerProps && headerProps.title && ( + + + {headerProps.title} + + {headerProps.docsLink && ( + + )} + {headerProps.buttonProps && ( + + )} + + + {headerProps.description && ( + + {headerProps.description} + + )} + + )} + +
+ + + {columns.map((col, idx) => { + const cell = col.sortableProps ? ( + + {col.name} + + ) : ( + {col.name} + ); + + return col.hiddenOn ? ( + + {cell} + + ) : ( + cell + ); + })} + + + + + {!error && images?.length === 0 && ( + ({ + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: theme.spacingFunction(4), + p: `${theme.spacingFunction(24)} ${theme.spacingFunction(32)}`, + })} + > + + {emptyMessage.main} + {!query && emptyMessage.instruction && ( + + {emptyMessage.instruction} + + )} + + } + /> + )} + {error && query && ( + + )} + {images?.map((image) => ( + + ))} + +
+ + + + ); +}; diff --git a/packages/manager/src/features/Images/ImagesLanding/v2/ImageLibrary/ImagesView.test.tsx b/packages/manager/src/features/Images/ImagesLanding/v2/ImageLibrary/ImagesView.test.tsx new file mode 100644 index 00000000000..fb503342c00 --- /dev/null +++ b/packages/manager/src/features/Images/ImagesLanding/v2/ImageLibrary/ImagesView.test.tsx @@ -0,0 +1,330 @@ +import { waitForElementToBeRemoved } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import * as React from 'react'; + +import { imageFactory } from 'src/factories'; +import { makeResourcePage } from 'src/mocks/serverHandlers'; +import { http, HttpResponse, server } from 'src/mocks/testServer'; +import { mockMatchMedia, renderWithTheme } from 'src/utilities/testHelpers'; + +import { ImagesView } from './ImagesView'; + +const queryMocks = vi.hoisted(() => ({ + useLocation: vi.fn(), + usePermissions: vi.fn().mockReturnValue({ data: { create_image: false } }), + useQueryWithPermissions: vi.fn().mockReturnValue({}), + useLinodesPermissionsCheck: vi.fn().mockReturnValue({}), + useSearch: vi.fn().mockReturnValue({}), +})); + +vi.mock('src/features/IAM/hooks/usePermissions', () => ({ + usePermissions: queryMocks.usePermissions, + useQueryWithPermissions: queryMocks.useQueryWithPermissions, +})); + +vi.mock('@tanstack/react-router', async () => { + const actual = await vi.importActual('@tanstack/react-router'); + return { + ...actual, + useLocation: queryMocks.useLocation, + useSearch: queryMocks.useSearch, + }; +}); + +vi.mock('../utils.ts', async () => { + const actual = await vi.importActual('../utils'); + return { + ...actual, + useLinodesPermissionsCheck: queryMocks.useLinodesPermissionsCheck, + }; +}); + +const mockHandlers = { + onDelete: vi.fn(), + onDeploy: vi.fn(), + onEdit: vi.fn(), + onRebuild: vi.fn(), + onManageReplicas: vi.fn(), +}; + +beforeAll(() => mockMatchMedia()); + +const loadingTestId = 'circle-progress'; + +describe('ImagesView component', () => { + beforeEach(() => { + queryMocks.usePermissions.mockReturnValue({ + data: { + update_image: true, + delete_image: true, + rebuild_linode: true, + create_linode: true, + replicate_image: true, + }, + }); + + queryMocks.useLocation.mockReturnValue({ + pathname: '/images/image-library', + }); + }); + + // For Custom Images (Owned by me) + describe('For Custom Images (Owned by me)', () => { + it("should render 'Owned by me' tab with items", async () => { + server.use( + http.get('*/images', () => { + const images = imageFactory.buildList(3, { + regions: [ + { region: 'us-east', status: 'available' }, + { region: 'us-southeast', status: 'pending' }, + ], + }); + return HttpResponse.json(makeResourcePage(images)); + }) + ); + + const { getByText, queryAllByTestId } = renderWithTheme( + , + { + initialRoute: '/images/image-library/owned-by-me', + } + ); + + const loadingElement = queryAllByTestId(loadingTestId); + await waitForElementToBeRemoved(loadingElement); + + // Custom Images table should render + getByText('Owned by me'); + + // Static text and table column headers + expect(getByText('Image')).toBeVisible(); + expect(getByText('Replicated in')).toBeVisible(); + expect(getByText('Original Image')).toBeVisible(); + expect(getByText('All Replicas')).toBeVisible(); + expect(getByText('Created')).toBeVisible(); + expect(getByText('Image ID')).toBeVisible(); + }); + + it("should render 'Owned by me' (manual) empty state", async () => { + server.use( + http.get('*/images', ({ request }) => { + return HttpResponse.json( + makeResourcePage( + request.headers.get('x-filter')?.includes('automatic') + ? [imageFactory.build({ type: 'automatic' })] + : [] + ) + ); + }) + ); + + const { findByText } = renderWithTheme( + , + { + initialRoute: '/images/image-library/owned-by-me', + } + ); + + expect(await findByText('No custom images to display')).toBeVisible(); + }); + + it('disables the action menu buttons if user does not have permissions to edit images', async () => { + queryMocks.usePermissions.mockReturnValue({ + data: { create_image: false }, + }); + const image = imageFactory.build({ + id: 'private/99999', + label: 'vi-test-image', + }); + queryMocks.useLinodesPermissionsCheck.mockReturnValue({ + availableLinodes: [], + }); + + server.use( + http.get('*/images', ({ request }) => { + const filter = request.headers.get('x-filter'); + + if (filter?.includes('manual')) { + return HttpResponse.json(makeResourcePage([image])); + } + return HttpResponse.json(makeResourcePage([])); + }) + ); + + const { findByLabelText } = renderWithTheme( + , + { + initialRoute: '/images/image-library/owned-by-me', + } + ); + + const actionMenu = await findByLabelText( + `Action menu for Image ${image.label}` + ); + + await userEvent.click(actionMenu); + + const disabledEditText = await findByLabelText( + "You don't have permissions to edit this Image. Please contact your account administrator to request the necessary permissions." + ); + const disabledDeleteText = await findByLabelText( + "You don't have permissions to delete this Image. Please contact your account administrator to request the necessary permissions." + ); + const disabledLinodeCreationText = await findByLabelText( + "You don't have permissions to create Linodes. Please contact your account administrator to request the necessary permissions." + ); + + expect(disabledEditText).toBeVisible(); + expect(disabledDeleteText).toBeVisible(); + expect(disabledLinodeCreationText).toBeVisible(); + }); + + it('should disable create button if user lacks create_image permission', async () => { + queryMocks.usePermissions.mockReturnValue({ + data: { create_image: false }, + }); + + const { getByText, queryAllByTestId } = renderWithTheme( + , + { + initialRoute: '/images/image-library/owned-by-me', + } + ); + + const loadingElement = queryAllByTestId(loadingTestId); + await waitForElementToBeRemoved(loadingElement); + + const createButton = getByText('Create Image'); + expect(createButton).toBeDisabled(); + expect(createButton).toHaveAttribute( + 'data-qa-tooltip', + "You don't have permissions to create Images. Please contact your account administrator to request the necessary permissions." + ); + }); + + it('should enable create button if user has create_image permission', async () => { + queryMocks.usePermissions.mockReturnValue({ + data: { create_image: true }, + }); + + const { getByText, queryAllByTestId } = renderWithTheme( + , + { + initialRoute: '/images/image-library/owned-by-me', + } + ); + + const loadingElement = queryAllByTestId(loadingTestId); + await waitForElementToBeRemoved(loadingElement); + + const createButton = getByText('Create Image'); + expect(createButton).toBeEnabled(); + }); + }); + + // For Recovery images + describe('For Recovery images', () => { + it("should render 'Recovery images tab' with items", async () => { + server.use( + http.get('*/images', () => { + const images = imageFactory.buildList(3, { + regions: [ + { region: 'us-east', status: 'available' }, + { region: 'us-southeast', status: 'pending' }, + ], + type: 'automatic', + }); + return HttpResponse.json(makeResourcePage(images)); + }) + ); + + const { getByText, queryAllByTestId } = renderWithTheme( + , + { + initialRoute: '/images/image-library/recovery-images', + } + ); + + const loadingElement = queryAllByTestId(loadingTestId); + await waitForElementToBeRemoved(loadingElement); + + // Recovery images table should render + getByText('Recovery images'); + + // Static text and table column headers + expect(getByText('Image')).toBeVisible(); + expect(getByText('Status')).toBeVisible(); + expect(getByText('Size')).toBeVisible(); + expect(getByText('Created')).toBeVisible(); + expect(getByText('Expires')).toBeVisible(); + }); + + it("should render 'Recovery images' (automatic) empty state", async () => { + server.use( + http.get('*/images', () => { + return HttpResponse.json(makeResourcePage([])); + }) + ); + + const { findByText } = renderWithTheme( + , + { + initialRoute: '/images/image-library/recovery-images', + } + ); + + expect(await findByText('No recovery images to display')).toBeVisible(); + }); + + it('disables the action menu buttons if user does not have permissions to edit images', async () => { + queryMocks.usePermissions.mockReturnValue({ + data: { create_image: false }, + }); + const image = imageFactory.build({ + id: 'private/99999', + label: 'vi-test-image', + }); + queryMocks.useLinodesPermissionsCheck.mockReturnValue({ + availableLinodes: [], + }); + + server.use( + http.get('*/images', ({ request }) => { + const filter = request.headers.get('x-filter'); + + if (filter?.includes('automatic')) { + return HttpResponse.json(makeResourcePage([image])); + } + return HttpResponse.json(makeResourcePage([])); + }) + ); + + const { findByLabelText } = renderWithTheme( + , + { + initialRoute: '/images/image-library/recovery-images', + } + ); + + const actionMenu = await findByLabelText( + `Action menu for Image ${image.label}` + ); + + await userEvent.click(actionMenu); + + const disabledEditText = await findByLabelText( + "You don't have permissions to edit this Image. Please contact your account administrator to request the necessary permissions." + ); + const disabledDeleteText = await findByLabelText( + "You don't have permissions to delete this Image. Please contact your account administrator to request the necessary permissions." + ); + const disabledLinodeCreationText = await findByLabelText( + "You don't have permissions to create Linodes. Please contact your account administrator to request the necessary permissions." + ); + + expect(disabledEditText).toBeVisible(); + expect(disabledDeleteText).toBeVisible(); + expect(disabledLinodeCreationText).toBeVisible(); + }); + }); +}); diff --git a/packages/manager/src/features/Images/ImagesLanding/v2/ImageLibrary/ImagesView.tsx b/packages/manager/src/features/Images/ImagesLanding/v2/ImageLibrary/ImagesView.tsx new file mode 100644 index 00000000000..02793595192 --- /dev/null +++ b/packages/manager/src/features/Images/ImagesLanding/v2/ImageLibrary/ImagesView.tsx @@ -0,0 +1,223 @@ +import { useImagesQuery } from '@linode/queries'; +import { getAPIFilterFromQuery } from '@linode/search'; +import { CircleProgress, ErrorState } from '@linode/ui'; +import { useNavigate, useSearch } from '@tanstack/react-router'; +import * as React from 'react'; + +import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField'; +import { DocumentTitleSegment } from 'src/components/DocumentTitle'; +import { usePermissions } from 'src/features/IAM/hooks/usePermissions'; +import { useOrderV2 } from 'src/hooks/useOrderV2'; +import { usePaginationV2 } from 'src/hooks/usePaginationV2'; +import { + isEventImageUpload, + isEventInProgressDiskImagize, +} from 'src/queries/events/event.helpers'; +import { useEventsInfiniteQuery } from 'src/queries/events/events'; + +import { getEventsForImages } from '../../../utils'; +import { IMAGES_CONFIG } from './imageLibraryTabsConfig'; +import { ImagesTable } from './ImagesTable'; + +import type { ImageLibraryType } from '../../../utils'; +import type { Handlers as ImageHandlers } from '../../ImagesActionMenu'; +import type { Filter } from '@linode/api-v4'; + +interface Props { + handlers: ImageHandlers; + type: Exclude; +} + +export const ImagesView = (props: Props) => { + const { handlers, type } = props; + + const config = IMAGES_CONFIG[type]; + + const navigate = useNavigate(); + const search = useSearch({ from: '/images' }); + + const { data: permissions } = usePermissions('account', ['create_image']); + const canCreateImage = permissions?.create_image; + + /** + * At the time of writing: `label`, `tags`, `size`, `status`, `region` are filterable. + * + * Some fields like `status` and `region` can't be used in complex filters using '+or' / '+and' + * + * Using `tags` in a '+or' is currently broken. See ARB-5792 + */ + const { error: searchParseError, filter } = getAPIFilterFromQuery( + search.query, + { + // Because Images have an array of region objects, we need to transform + // search queries like "region: us-east" to { regions: { region: "us-east" } } + // rather than the default behavior which is { region: { '+contains': "us-east" } } + filterShapeOverrides: { + '+contains': { + field: 'region', + filter: (value) => ({ regions: { region: value } }), + }, + '+eq': { + field: 'region', + filter: (value) => ({ regions: { region: value } }), + }, + }, + searchableFieldsWithoutOperator: ['label', 'tags'], + } + ); + + const pagination = usePaginationV2({ + currentRoute: '/images/image-library/$imageType', + preferenceKey: config.preferenceKey, + searchParams: (prev) => ({ + ...prev, + query: search.query, + }), + }); + + const { + handleOrderChange: handleImagesOrderChange, + order: imagesOrder, + orderBy: imagesOrderBy, + } = useOrderV2({ + initialRoute: { + defaultOrder: { + order: config.orderDefault, + orderBy: config.orderByDefault, + }, + from: '/images/image-library/$imageType', + }, + preferenceKey: config.preferenceKey, + prefix: config.type, + }); + + const imagesFilter: Filter = { + ['+order']: imagesOrder, + ['+order_by']: imagesOrderBy, + ...filter, + }; + + const { + data: images, + error: imagesError, + isFetching: imagesIsFetching, + isLoading: imagesLoading, + } = useImagesQuery( + { + page: pagination.page, + page_size: pagination.pageSize, + }, + { + ...imagesFilter, + is_public: false, + type: config.type, + }, + { + enabled: config.isEnabled(type), + // Refetch custom (manual) images every 30 seconds. + // We do this because we have no /v4/account/events we can use + // to update Image region statuses. We should make the API + // team and Images team implement events for this. + refetchInterval: config.type === 'manual' ? 30_000 : undefined, + // If we have a search query, disable retries to keep the UI + // snappy if the user inputs an invalid X-Filter. Otherwise, + // pass undefined to use the default retry behavior. + retry: search.query ? false : undefined, + } + ); + + const { events } = useEventsInfiniteQuery(); + + const imageEvents = + events?.filter( + (event) => + isEventInProgressDiskImagize(event) || isEventImageUpload(event) + ) ?? []; + + // Private images with the associated events tied in. + const imagesEvents = getEventsForImages(images?.data ?? [], imageEvents); + + const onSearch = (query: string) => { + navigate({ + search: (prev) => ({ + ...prev, + page: undefined, + query: query || undefined, + }), + to: '/images/image-library/$imageType', + params: { imageType: type }, + }); + }; + + if (imagesLoading) { + return ; + } + + if (!search.query && imagesError) { + return ( + + + + + ); + } + + return ( + <> + + + navigate({ + search: () => ({}), + to: config.buttonProps?.navigateTo ?? '/', + }), + disabled: !canCreateImage, + tooltipText: !canCreateImage + ? config.buttonProps.disabledToolTipText + : undefined, + } + : undefined, + docsLink: config.docsLink, + description: config.description, + }} + images={images?.data ?? []} + order={imagesOrder} + orderBy={imagesOrderBy} + pagination={{ + page: pagination.page, + pageSize: pagination.pageSize, + count: images?.results ?? 0, + handlePageChange: pagination.handlePageChange, + handlePageSizeChange: pagination.handlePageSizeChange, + }} + query={search.query} + /> + + ); +}; diff --git a/packages/manager/src/features/Images/ImagesLanding/v2/ImageLibrary/imageLibraryTabsConfig.tsx b/packages/manager/src/features/Images/ImagesLanding/v2/ImageLibrary/imageLibraryTabsConfig.tsx new file mode 100644 index 00000000000..a6875dbd2d9 --- /dev/null +++ b/packages/manager/src/features/Images/ImagesLanding/v2/ImageLibrary/imageLibraryTabsConfig.tsx @@ -0,0 +1,175 @@ +import * as React from 'react'; + +import { Link } from 'src/components/Link'; +import { getRestrictedResourceText } from 'src/features/Account/utils'; + +import { + AUTOMATIC_IMAGES_DEFAULT_ORDER, + AUTOMATIC_IMAGES_DEFAULT_ORDER_BY, + AUTOMATIC_IMAGES_PREFERENCE_KEY, + MANUAL_IMAGES_DEFAULT_ORDER, + MANUAL_IMAGES_DEFAULT_ORDER_BY, + MANUAL_IMAGES_PREFERENCE_KEY, +} from '../../../constants'; + +import type { ImageLibraryType, ImageSubTab } from '../../../utils'; +import type { Image } from '@linode/api-v4'; +import type { HiddenProps } from '@linode/ui'; + +export interface ImageViewTableColConfig { + /** Breakpoint to hide the column (e.g., 'smDown', 'mdUp', etc) */ + hiddenOn?: Exclude; + + /** Column name */ + name: string; + + /** + * Provide sortableProps to enable sorting for this column. + */ + sortableProps?: { + /** API field used for sorting this column */ + label: string; + }; +} + +export interface ImageConfig { + buttonProps?: { + buttonText: string; + disabledToolTipText?: string; + navigateTo?: string; + }; + columns: ImageViewTableColConfig[]; + description: React.ReactNode; + docsLink?: { href: string; label?: string }; + emptyMessage: { + instruction?: string; + main: string; + }; + eventCategory: string; + isEnabled: (subType: ImageLibraryType | undefined) => boolean; + orderByDefault: string; + orderDefault: 'asc' | 'desc'; + preferenceKey: string; + title: string; + type: Image['type']; +} + +export const imageLibrarySubTabs: ImageSubTab[] = [ + { type: 'owned-by-me', title: 'Owned by me' }, + { + type: 'shared-with-me', + title: 'Shared with me', + isBeta: true, + }, + { type: 'recovery-images', title: 'Recovery images' }, +]; + +const CUSTOM_IMAGES_TABLE_COLUMNS: ImageViewTableColConfig[] = [ + { name: 'Image', sortableProps: { label: 'label' } }, + { + name: 'Status', + hiddenOn: 'smDown', + }, + { + name: 'Replicated in', + hiddenOn: 'smDown', + }, + { name: 'Original Image', sortableProps: { label: 'size' } }, + { + name: 'All Replicas', + hiddenOn: 'mdDown', + }, + { + name: 'Created', + sortableProps: { label: 'created' }, + hiddenOn: 'mdDown', + }, + { + name: 'Image ID', + hiddenOn: 'mdDown', + }, +]; + +const RECOVERY_IMAGES_TABLE_COLUMNS: ImageViewTableColConfig[] = [ + { name: 'Image', sortableProps: { label: 'label' } }, + { + name: 'Status', + hiddenOn: 'smDown', + }, + { name: 'Size', sortableProps: { label: 'size' } }, + { + name: 'Created', + sortableProps: { label: 'created' }, + hiddenOn: 'smDown', + }, + { + name: 'Expires', + hiddenOn: 'smDown', + }, +]; + +export const IMAGES_CONFIG: Omit< + Record, + 'shared-with-me' +> = { + 'owned-by-me': { + title: 'Owned by me', + description: ( + <> + These are{' '} + + encrypted + {' '} + images you manually uploaded or captured from an existing compute + instance disk. You can deploy an image to a compute instance in any + region. If you deploy the instance in a different region from where the + image is stored, you may experience slower linode deployment times. + + ), + type: 'manual', + orderByDefault: MANUAL_IMAGES_DEFAULT_ORDER_BY, + orderDefault: MANUAL_IMAGES_DEFAULT_ORDER, + preferenceKey: MANUAL_IMAGES_PREFERENCE_KEY, + isEnabled: (subType) => subType === 'owned-by-me', + columns: CUSTOM_IMAGES_TABLE_COLUMNS, + buttonProps: { + buttonText: 'Create Image', + navigateTo: '/images/create', + disabledToolTipText: getRestrictedResourceText({ + action: 'create', + isSingular: false, + resourceType: 'Images', + }), + }, + eventCategory: 'Custom Images Table', + emptyMessage: { + main: 'No custom images to display', + instruction: + 'Click \u2018Create Image\u2019 to create your first custom image', + }, + }, + 'recovery-images': { + title: 'Recovery images', + description: ( + <> + These are images we automatically capture when Linode disks are deleted. + They will be deleted after the indicated expiration date. + + ), + type: 'automatic', + orderByDefault: AUTOMATIC_IMAGES_DEFAULT_ORDER_BY, + orderDefault: AUTOMATIC_IMAGES_DEFAULT_ORDER, + preferenceKey: AUTOMATIC_IMAGES_PREFERENCE_KEY, + isEnabled: (subType) => subType === 'recovery-images', + columns: RECOVERY_IMAGES_TABLE_COLUMNS, + eventCategory: 'Recovery Images Table', + emptyMessage: { + main: 'No recovery images to display', + }, + docsLink: { + label: 'Recover a deleted Linode', + href: 'https://techdocs.akamai.com/cloud-computing/docs/images#recover-a-deleted', + }, + }, + // "shared-with-me" images config will go here +}; diff --git a/packages/manager/src/features/Images/ImagesLanding/v2/ImageLibrary/imageLibraryTabsLazyRoute.tsx b/packages/manager/src/features/Images/ImagesLanding/v2/ImageLibrary/imageLibraryTabsLazyRoute.tsx new file mode 100644 index 00000000000..cd1ad40634a --- /dev/null +++ b/packages/manager/src/features/Images/ImagesLanding/v2/ImageLibrary/imageLibraryTabsLazyRoute.tsx @@ -0,0 +1,9 @@ +import { createLazyRoute } from '@tanstack/react-router'; + +import { ImageLibraryTabs } from './ImageLibraryTabs'; + +export const imageLibraryTabsLazyRoute = createLazyRoute( + '/images/image-library' +)({ + component: ImageLibraryTabs, +}); diff --git a/packages/manager/src/features/Images/ImagesLanding/v2/ImagesLandingV2.tsx b/packages/manager/src/features/Images/ImagesLanding/v2/ImagesLandingV2.tsx new file mode 100644 index 00000000000..69bfb3eb266 --- /dev/null +++ b/packages/manager/src/features/Images/ImagesLanding/v2/ImagesLandingV2.tsx @@ -0,0 +1,47 @@ +import { BetaChip } from '@linode/ui'; +import { Outlet } from '@tanstack/react-router'; +import React from 'react'; + +import { LandingHeader } from 'src/components/LandingHeader'; +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 { useTabs } from 'src/hooks/useTabs'; + +export const ImagesLandingV2 = () => { + const { handleTabChange, tabIndex, tabs } = useTabs([ + { + title: 'Image Library', + to: '/images/image-library', + }, + { + title: 'Share Groups', + to: '/images/share-groups', + chip: , + }, + ]); + + return ( + <> + + + + + }> + + + + + + + ); +}; diff --git a/packages/manager/src/features/Images/ImagesLanding/v2/ShareGroups/ShareGroupsTabs.test.tsx b/packages/manager/src/features/Images/ImagesLanding/v2/ShareGroups/ShareGroupsTabs.test.tsx new file mode 100644 index 00000000000..6d517924ed3 --- /dev/null +++ b/packages/manager/src/features/Images/ImagesLanding/v2/ShareGroups/ShareGroupsTabs.test.tsx @@ -0,0 +1,104 @@ +import { userEvent } from '@testing-library/user-event/dist/cjs/setup/index.js'; +import * as React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { ShareGroupsTabs } from './ShareGroupsTabs'; + +const queryMocks = vi.hoisted(() => ({ + useNavigate: vi.fn(), + useParams: vi.fn(), + useLocation: vi.fn(), +})); + +vi.mock('@tanstack/react-router', async () => { + const actual = await vi.importActual('@tanstack/react-router'); + return { + ...actual, + useLocation: queryMocks.useLocation, + useNavigate: queryMocks.useNavigate, + useParams: queryMocks.useParams, + }; +}); + +describe('ShareGroupsTabs', () => { + beforeEach(() => { + vi.clearAllMocks(); + const mockNavigate = vi.fn(); + queryMocks.useNavigate.mockReturnValue(mockNavigate); + }); + + it('should render all share groups tabs', async () => { + queryMocks.useParams.mockReturnValue({ shareGroupsType: 'owned-groups' }); + + const { getByText } = renderWithTheme(, { + initialRoute: '/images/share-groups/owned-groups', + }); + + expect(getByText('Owned groups')).toBeVisible(); + expect(getByText('Joined groups')).toBeVisible(); + expect(getByText('My membership requests')).toBeVisible(); + }); + + it('should navigate to owned-groups tab when clicked', async () => { + queryMocks.useParams.mockReturnValue({ shareGroupsType: 'owned-groups' }); + const mockNavigate = vi.fn(); + queryMocks.useNavigate.mockReturnValue(mockNavigate); + + const { getByText } = renderWithTheme(, { + initialRoute: '/images/share-groups/owned-groups', + }); + + const ownedGroupsTab = getByText('Owned groups', { selector: 'button' }); + await userEvent.click(ownedGroupsTab); + + expect(mockNavigate).toHaveBeenCalledWith({ + to: '/images/share-groups/$shareGroupsType', + params: { + shareGroupsType: 'owned-groups', + }, + }); + }); + + it('should navigate to joined-groups tab when clicked', async () => { + queryMocks.useParams.mockReturnValue({ shareGroupsType: 'owned-groups' }); + const mockNavigate = vi.fn(); + queryMocks.useNavigate.mockReturnValue(mockNavigate); + + const { getByText } = renderWithTheme(, { + initialRoute: '/images/share-groups/owned-groups', + }); + + const joinedGroupsTab = getByText('Joined groups', { selector: 'button' }); + await userEvent.click(joinedGroupsTab); + + expect(mockNavigate).toHaveBeenCalledWith({ + to: '/images/share-groups/$shareGroupsType', + params: { + shareGroupsType: 'joined-groups', + }, + }); + }); + + it('should navigate to membership-requests tab when clicked', async () => { + queryMocks.useParams.mockReturnValue({ shareGroupsType: 'owned-groups' }); + const mockNavigate = vi.fn(); + queryMocks.useNavigate.mockReturnValue(mockNavigate); + + const { getByText } = renderWithTheme(, { + initialRoute: '/images/share-groups/owned-groups', + }); + + const membershipTab = getByText('My membership requests', { + selector: 'button', + }); + await userEvent.click(membershipTab); + + expect(mockNavigate).toHaveBeenCalledWith({ + to: '/images/share-groups/$shareGroupsType', + params: { + shareGroupsType: 'membership-requests', + }, + }); + }); +}); diff --git a/packages/manager/src/features/Images/ImagesLanding/v2/ShareGroups/ShareGroupsTabs.tsx b/packages/manager/src/features/Images/ImagesLanding/v2/ShareGroups/ShareGroupsTabs.tsx new file mode 100644 index 00000000000..a093a541ec8 --- /dev/null +++ b/packages/manager/src/features/Images/ImagesLanding/v2/ShareGroups/ShareGroupsTabs.tsx @@ -0,0 +1,71 @@ +import { BetaChip, Notice, Stack } from '@linode/ui'; +import { useNavigate, useParams } from '@tanstack/react-router'; +import React from 'react'; + +import { SuspenseLoader } from 'src/components/SuspenseLoader'; +import { SafeTabPanel } from 'src/components/Tabs/SafeTabPanel'; +import { Tab } from 'src/components/Tabs/Tab'; +import { TabList } from 'src/components/Tabs/TabList'; +import { TabPanels } from 'src/components/Tabs/TabPanels'; +import { Tabs } from 'src/components/Tabs/Tabs'; +import { getSubTabIndex } from 'src/features/Images/utils'; + +import { shareGroupsSubTabs as subTabs } from './shareGroupsTabsConfig'; + +export const ShareGroupsTabs = () => { + const navigate = useNavigate(); + + const shareGroupsTypeParams = useParams({ + from: '/images/share-groups/$shareGroupsType', + shouldThrow: false, + }); + + const onTabChange = (index: number) => { + navigate({ + to: `/images/share-groups/$shareGroupsType`, + params: { + shareGroupsType: subTabs[index].type, + }, + }); + }; + + const subTabIndex = getSubTabIndex( + subTabs, + shareGroupsTypeParams?.shareGroupsType + ); + + return ( + + + + {subTabs.map((tab) => ( + + {tab.title} {tab.isBeta ? : null} + + ))} + + }> + + {subTabs.map((tab, index) => ( + + {tab.type === 'owned-groups' && ( + Owned Groups is coming soon... + )} + {tab.type === 'joined-groups' && ( + + Joined Groups is coming soon... + + )} + {tab.type === 'membership-requests' && ( + + Membership Requests is coming soon... + + )} + + ))} + + + + + ); +}; diff --git a/packages/manager/src/features/Images/ImagesLanding/v2/ShareGroups/shareGroupsTabsConfig.tsx b/packages/manager/src/features/Images/ImagesLanding/v2/ShareGroups/shareGroupsTabsConfig.tsx new file mode 100644 index 00000000000..7c82202fc28 --- /dev/null +++ b/packages/manager/src/features/Images/ImagesLanding/v2/ShareGroups/shareGroupsTabsConfig.tsx @@ -0,0 +1,16 @@ +import type { ImageSubTab, ShareGroupsType } from 'src/features/Images/utils'; + +export const shareGroupsSubTabs: ImageSubTab[] = [ + { + type: 'owned-groups', + title: 'Owned groups', + }, + { + type: 'joined-groups', + title: 'Joined groups', + }, + { + type: 'membership-requests', + title: 'My membership requests', + }, +]; diff --git a/packages/manager/src/features/Images/ImagesLanding/v2/ShareGroups/shareGroupsTabsLazyRoute.tsx b/packages/manager/src/features/Images/ImagesLanding/v2/ShareGroups/shareGroupsTabsLazyRoute.tsx new file mode 100644 index 00000000000..820293d0319 --- /dev/null +++ b/packages/manager/src/features/Images/ImagesLanding/v2/ShareGroups/shareGroupsTabsLazyRoute.tsx @@ -0,0 +1,9 @@ +import { createLazyRoute } from '@tanstack/react-router'; + +import { ShareGroupsTabs } from './ShareGroupsTabs'; + +export const shareGroupsTabsLazyRoute = createLazyRoute('/images/share-groups')( + { + component: ShareGroupsTabs, + } +); diff --git a/packages/manager/src/features/Images/ImagesLanding/v2/imagesLandingV2LazyRoute.tsx b/packages/manager/src/features/Images/ImagesLanding/v2/imagesLandingV2LazyRoute.tsx new file mode 100644 index 00000000000..b7a5ead83eb --- /dev/null +++ b/packages/manager/src/features/Images/ImagesLanding/v2/imagesLandingV2LazyRoute.tsx @@ -0,0 +1,9 @@ +import { createLazyRoute } from '@tanstack/react-router'; + +import { ImagesLandingV2 } from './ImagesLandingV2'; + +export const imagesLandingV2LazyRoute = createLazyRoute( + '/images/image-library' +)({ + component: ImagesLandingV2, +}); diff --git a/packages/manager/src/features/Images/constants.ts b/packages/manager/src/features/Images/constants.ts index ac206464950..88ae9abc38a 100644 --- a/packages/manager/src/features/Images/constants.ts +++ b/packages/manager/src/features/Images/constants.ts @@ -6,3 +6,6 @@ export const MANUAL_IMAGES_DEFAULT_ORDER = 'asc'; export const MANUAL_IMAGES_DEFAULT_ORDER_BY = 'label'; export const AUTOMATIC_IMAGES_DEFAULT_ORDER = 'asc'; export const AUTOMATIC_IMAGES_DEFAULT_ORDER_BY = 'label'; + +export const SHARE_GROUP_COLUMN_HEADER_TOOLTIP = + "Displays the share group for images shared with you; your custom images don't display a group name."; diff --git a/packages/manager/src/features/Images/utils.test.tsx b/packages/manager/src/features/Images/utils.test.tsx index bdfc503faab..e7ab420a401 100644 --- a/packages/manager/src/features/Images/utils.test.tsx +++ b/packages/manager/src/features/Images/utils.test.tsx @@ -7,9 +7,13 @@ import { wrapWithTheme } from 'src/utilities/testHelpers'; import { getEventsForImages, getImageLabelForLinode, + getImageTypeToImageLibraryType, + getSubTabIndex, useIsPrivateImageSharingEnabled, } from './utils'; +import type { ImageLibraryType, ImageSubTab } from './utils'; + describe('getImageLabelForLinode', () => { it('handles finding an image and getting the label', () => { const linode = linodeFactory.build({ @@ -89,3 +93,44 @@ describe('useIsPrivateImageSharingEnabled', () => { }); }); }); + +describe('getSubTabIndex', () => { + const subTabs: ImageSubTab[] = [ + { type: 'owned-by-me', title: 'Owned by me' }, + { type: 'shared-with-me', title: 'Shared with me', isBeta: true }, + { type: 'recovery-images', title: 'Recovery images' }, + ]; + + it('returns 0 if selectedTab is undefined', () => { + expect(getSubTabIndex(subTabs, undefined)).toBe(0); + }); + + it('returns the correct index when selectedTab matches a tab key', () => { + expect(getSubTabIndex(subTabs, 'owned-by-me')).toBe(0); + expect(getSubTabIndex(subTabs, 'shared-with-me')).toBe(1); + expect(getSubTabIndex(subTabs, 'recovery-images')).toBe(2); + }); + + it('returns 0 if selectedTab does not exist in subTabs', () => { + // @ts-expect-error intentionally passing an unexpected value + expect(getSubTabIndex(subTabs, 'hey')).toBe(0); + }); + + it('works with an empty subTabs array', () => { + expect(getSubTabIndex([], 'owned-by-me')).toBe(0); + }); +}); + +describe('getImageTypeToImageLibraryType', () => { + it('returns "owned-by-me" when image type is "manual"', () => { + expect(getImageTypeToImageLibraryType('manual')).toBe('owned-by-me'); + }); + + it('returns "recovery-images" when image type is "automatic"', () => { + expect(getImageTypeToImageLibraryType('automatic')).toBe('recovery-images'); + }); + + it('returns "shared-with-me" when image type is "shared"', () => { + expect(getImageTypeToImageLibraryType('shared')).toBe('shared-with-me'); + }); +}); diff --git a/packages/manager/src/features/Images/utils.ts b/packages/manager/src/features/Images/utils.ts index dc491d18c10..78d1646cb47 100644 --- a/packages/manager/src/features/Images/utils.ts +++ b/packages/manager/src/features/Images/utils.ts @@ -5,6 +5,28 @@ import { useFlags } from 'src/hooks/useFlags'; import type { Event, Image, Linode } from '@linode/api-v4'; +export type ImageLibraryType = + | 'owned-by-me' + | 'recovery-images' + | 'shared-with-me'; + +export type ShareGroupsType = + | 'joined-groups' + | 'membership-requests' + | 'owned-groups'; + +/** + * Generic configuration for image sub-tabs within the Images feature for Image Library and Share Groups. + */ +export interface ImageSubTab { + /** Whether this tab represents a beta feature */ + isBeta?: boolean; + /** Display title for the tab */ + title: string; + /** The type this tab represents */ + type: T; +} + export const getImageLabelForLinode = (linode: Linode, images: Image[]) => { const image = images?.find((image) => image.id === linode.image); return image?.label ?? linode.image; @@ -55,3 +77,42 @@ export const useIsPrivateImageSharingEnabled = () => { // @TODO Private Image Sharing: check for customer tag/account capability when it exists return { isPrivateImageSharingEnabled: flags.privateImageSharing ?? false }; }; + +/** + * Returns the index of the currently selected sub-tab from an array of sub-tabs. + * + * @param subTabs - Array of sub-tabs with `type` and `title` properties. + * @param selectedTab - The type of currently selected sub-tab. + * Currently, this value comes from 'imageType' param on the Image Library tab. + * + * @returns the index of the selected sub-tab + */ +export const getSubTabIndex = ( + subTabs: ImageSubTab[], + selectedTab: ImageLibraryType | ShareGroupsType | undefined +) => { + if (selectedTab === undefined) { + return 0; + } + + const tabIndex = subTabs.findIndex((tab) => tab.type === selectedTab); + + if (tabIndex === -1) { + return 0; + } + + return tabIndex; +}; + +export const getImageTypeToImageLibraryType = ( + imageType: Image['type'] +): ImageLibraryType => { + switch (imageType) { + case 'automatic': + return 'recovery-images'; + case 'manual': + return 'owned-by-me'; + default: + return 'shared-with-me'; + } +}; diff --git a/packages/manager/src/features/Kubernetes/NodePoolFirewallSelect.tsx b/packages/manager/src/features/Kubernetes/NodePoolFirewallSelect.tsx index a272cbebdc8..cc0008e5a50 100644 --- a/packages/manager/src/features/Kubernetes/NodePoolFirewallSelect.tsx +++ b/packages/manager/src/features/Kubernetes/NodePoolFirewallSelect.tsx @@ -137,6 +137,7 @@ export const NodePoolFirewallSelect = (props: NodePoolFirewallSelectProps) => { } }} placeholder="Select firewall" + showNoFirewallOption={false} value={field.value} /> )} diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Firewall.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Firewall.tsx index 93edc25385b..08c40cadcbc 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/Firewall.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/Firewall.tsx @@ -14,6 +14,7 @@ import { useFlags } from 'src/hooks/useFlags'; import { useSecureVMNoticesEnabled } from 'src/hooks/useSecureVMNoticesEnabled'; import { sendLinodeCreateFormInputEvent } from 'src/utilities/analytics/formEventAnalytics'; +import { WARNING_MESSAGE_FOR_NO_FIREWALL_OPTION } from '../constants'; import { useGetLinodeCreateType } from './Tabs/utils/useGetLinodeCreateType'; import type { CreateLinodeRequest } from '@linode/api-v4'; @@ -108,8 +109,11 @@ export const Firewall = () => { }); } }} - placeholder="None" + placeholder="Select a Firewall" value={field.value} + warningMessageForNoFirewallOption={ + WARNING_MESSAGE_FOR_NO_FIREWALL_OPTION + } /> { @@ -61,8 +63,11 @@ export const Firewall = () => { errorText={fieldState.error?.message} onBlur={field.onBlur} onChange={(e, firewall) => field.onChange(firewall?.id ?? null)} - placeholder="None" + placeholder="Select a Firewall" value={field.value} + warningMessageForNoFirewallOption={ + WARNING_MESSAGE_FOR_NO_FIREWALL_OPTION + } /> { label={`${labelMap[interfaceType ?? 'public']} Interface Firewall`} onBlur={field.onBlur} onChange={(e, firewall) => field.onChange(firewall?.id ?? null)} - placeholder="None" + placeholder="Select a Firewall" value={field.value} + warningMessageForNoFirewallOption={ + WARNING_MESSAGE_FOR_NO_FIREWALL_OPTION + } /> @@ -31,7 +30,7 @@ const disabledReasonMap: Partial< }; export const InterfaceGeneration = () => { - const { setValue } = useFormContext(); + const { setValue } = useFormContext(); const { field } = useController< LinodeCreateFormValues, @@ -42,12 +41,17 @@ export const InterfaceGeneration = () => { const { data: accountSettings } = useAccountSettings(); + const { data: firewallSettings } = useFirewallSettingsQuery(); + const disabledReason = accountSettings && disabledReasonMap[accountSettings.interfaces_for_new_linodes]; const disabled = disabledReason !== undefined; + const createType = useGetLinodeCreateType(); + const isCreatingFromBackup = createType === 'Backups'; + return ( @@ -75,6 +79,14 @@ export const InterfaceGeneration = () => { if (value === 'linode') { setValue('private_ip', undefined); } + + // if Configuration Profile Interfaces is selected and user is on Backups tab, reset VLAN and VPC + // fields to prevent validation errors - config profile interfaces are not compatible with backups + if (value === 'legacy_config' && isCreatingFromBackup) { + setValue('linodeInterfaces', [ + getDefaultInterfacePayload('public', firewallSettings), + ]); + } }} value={field.value ?? 'linode'} > @@ -85,9 +97,7 @@ export const InterfaceGeneration = () => { label={ - ({ font: theme.font.bold })}> - Linode Interfaces (Recommended) - + Linode Interfaces (Recommended) { disabled={disabled} label={ - ({ font: theme.font.bold })}> - Configuration Profile Interfaces (Legacy) - + Configuration Profile Interfaces (Legacy) = { describe('InterfaceType', () => { it('renders all interface type options', () => { const { getByText, getByRole } = renderWithThemeAndHookFormContext({ - component: , + component: , useFormOptions: { defaultValues: defaultFormValues, }, @@ -44,7 +44,7 @@ describe('InterfaceType', () => { it('renders tooltip icons for each interface type', () => { const { getAllByRole } = renderWithThemeAndHookFormContext({ - component: , + component: , useFormOptions: { defaultValues: defaultFormValues, }, @@ -57,7 +57,7 @@ describe('InterfaceType', () => { it('selects the correct radio based on form value', () => { const { getByDisplayValue } = renderWithThemeAndHookFormContext({ - component: , + component: , useFormOptions: { defaultValues: { ...defaultFormValues, @@ -76,7 +76,7 @@ describe('InterfaceType', () => { it('allows user to change interface type selection', async () => { const { getByDisplayValue } = renderWithThemeAndHookFormContext({ - component: , + component: , useFormOptions: { defaultValues: defaultFormValues, }, diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Networking/InterfaceType.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Networking/InterfaceType.tsx index c4b449c4dd9..0ec8e06176e 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/Networking/InterfaceType.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/Networking/InterfaceType.tsx @@ -11,17 +11,17 @@ import { FormControlLabel, Stack } from '@mui/material'; import { useSnackbar } from 'notistack'; import type { ChangeEvent } from 'react'; import React from 'react'; -import { useController, useFormContext, useWatch } from 'react-hook-form'; +import { useController, useFormContext } from 'react-hook-form'; import { FormLabel } from 'src/components/FormLabel'; -import { useGetLinodeCreateType } from '../Tabs/utils/useGetLinodeCreateType'; import { getDefaultFirewallForInterfacePurpose } from './utilities'; import type { LinodeCreateFormValues } from '../utilities'; import type { InterfacePurpose } from '@linode/api-v4'; interface Props { + disabled: boolean; index: number; } @@ -46,7 +46,7 @@ const interfaceTypes = [ }, ] as const; -export const InterfaceType = ({ index }: Props) => { +export const InterfaceType = ({ disabled, index }: Props) => { const queryClient = useQueryClient(); const { enqueueSnackbar } = useSnackbar(); @@ -59,24 +59,14 @@ export const InterfaceType = ({ index }: Props) => { name: `linodeInterfaces.${index}.purpose`, }); - const interfaceGeneration = useWatch({ - control, - name: 'interface_generation', - }); - - const createType = useGetLinodeCreateType(); - const isCreatingFromBackup = createType === 'Backups'; - - const disabled = isCreatingFromBackup && interfaceGeneration !== 'linode'; - const onChange = async (value: InterfacePurpose) => { // Change the interface purpose (Public, VPC, VLAN) field.onChange(value); // VLAN interfaces do not support Firewalls, so set - // the Firewall ID to `null` to be safe and early return. + // the Firewall ID to `-1` to be safe and early return. if (value === 'vlan') { - setValue(`linodeInterfaces.${index}.firewall_id`, null); + setValue(`linodeInterfaces.${index}.firewall_id`, -1); return; } @@ -136,9 +126,7 @@ export const InterfaceType = ({ index }: Props) => { key={interfaceType.purpose} label={ - ({ font: theme.font.bold })}> - {interfaceType.label} - + {interfaceType.label} { name: `linodeInterfaces.${index}.purpose`, }); + const createType = useGetLinodeCreateType(); + const isCreatingFromBackup = createType === 'Backups'; + + const disableInterfaceType = + isCreatingFromBackup && interfaceGeneration !== 'linode'; + return ( {errors.linodeInterfaces?.[index]?.message && ( @@ -44,9 +51,13 @@ export const LinodeInterface = ({ index }: Props) => { variant="error" /> )} - - {interfaceType === 'vlan' && } - {interfaceType === 'vpc' && } + + {interfaceType === 'vlan' && !disableInterfaceType && ( + + )} + {interfaceType === 'vpc' && !disableInterfaceType && ( + + )} {interfaceGeneration === 'linode' && interfaceType !== 'vlan' && ( diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Summary/Summary.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Summary/Summary.tsx index 62642e6213c..01558429a41 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/Summary/Summary.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/Summary/Summary.tsx @@ -109,7 +109,7 @@ export const Summary = ({ isAlertsBetaMode }: SummaryProps) => { const hasFirewall = interfaceGeneration === 'linode' - ? linodeInterfaces.some((i) => i.firewall_id) + ? linodeInterfaces.some((i) => i.firewall_id && i.firewall_id !== -1) : firewallId; const hasBetaAclpAlertsAssigned = diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Images.test.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Images.test.tsx index e3d3f068610..569a9f1b4db 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Images.test.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Images.test.tsx @@ -1,10 +1,16 @@ import React from 'react'; -import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; +import { + renderWithThemeAndHookFormContext, + resizeScreenSize, +} from 'src/utilities/testHelpers'; import { Images } from './Images'; const queryMocks = vi.hoisted(() => ({ + useIsPrivateImageSharingEnabled: vi.fn(() => ({ + isPrivateImageSharingEnabled: false, + })), useNavigate: vi.fn(), useParams: vi.fn(), useSearch: vi.fn(), @@ -29,11 +35,22 @@ vi.mock('src/features/IAM/hooks/usePermissions', () => ({ usePermissions: queryMocks.userPermissions, })); +vi.mock('src/features/Images/utils', async () => { + const actual = await vi.importActual('src/features/Images/utils'); + return { + ...actual, + useIsPrivateImageSharingEnabled: queryMocks.useIsPrivateImageSharingEnabled, + }; +}); + describe('Images', () => { beforeEach(() => { queryMocks.useNavigate.mockReturnValue(vi.fn()); queryMocks.useSearch.mockReturnValue({}); queryMocks.useParams.mockReturnValue({}); + queryMocks.useIsPrivateImageSharingEnabled.mockReturnValue({ + isPrivateImageSharingEnabled: false, + }); }); it('renders a header', () => { @@ -73,4 +90,52 @@ describe('Images', () => { expect(getByPlaceholderText('Choose an image')).toBeVisible(); expect(getByPlaceholderText('Choose an image')).toBeEnabled(); }); + + describe('when isPrivateImageSharingEnabled is true', () => { + beforeEach(() => { + queryMocks.useIsPrivateImageSharingEnabled.mockReturnValue({ + isPrivateImageSharingEnabled: true, + }); + // Mock matchMedia at a width wider than MUI's `lg` breakpoint (1200px) + // so that columns wrapped in are not hidden. + resizeScreenSize(1280); + }); + + it('renders the search images field', () => { + const { getByPlaceholderText } = renderWithThemeAndHookFormContext({ + component: , + }); + + expect(getByPlaceholderText('Search images')).toBeVisible(); + }); + + it('renders the filter by tag field', () => { + const { getByPlaceholderText } = renderWithThemeAndHookFormContext({ + component: , + }); + + expect(getByPlaceholderText('Filter by tag')).toBeVisible(); + }); + + it('renders the filter by region field', () => { + const { getByPlaceholderText } = renderWithThemeAndHookFormContext({ + component: , + }); + + expect(getByPlaceholderText('Filter by region')).toBeVisible(); + }); + + it('renders the table column headers', () => { + const { getByText } = renderWithThemeAndHookFormContext({ + component: , + }); + + expect(getByText('Image')).toBeVisible(); + expect(getByText('Replicated in')).toBeVisible(); + expect(getByText('Share Group')).toBeVisible(); + expect(getByText('Size')).toBeVisible(); + expect(getByText('Created')).toBeVisible(); + expect(getByText('Image ID')).toBeVisible(); + }); + }); }); diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Images.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Images.tsx index 717cdc85696..9c2836f0547 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Images.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Images.tsx @@ -5,11 +5,14 @@ import React from 'react'; import { useController, useFormContext, useWatch } from 'react-hook-form'; import ComputeIcon from 'src/assets/icons/entityIcons/compute.svg'; +import { IMAGE_SELECT_TABLE_PENDO_IDS } from 'src/components/ImageSelect/constants'; import { ImageSelect } from 'src/components/ImageSelect/ImageSelect'; +import { ImageSelectTable } from 'src/components/ImageSelect/ImageSelectTable'; import { getAPIFilterForImageSelect } from 'src/components/ImageSelect/utilities'; import { Link } from 'src/components/Link'; import { Placeholder } from 'src/components/Placeholder/Placeholder'; import { usePermissions } from 'src/features/IAM/hooks/usePermissions'; +import { useIsPrivateImageSharingEnabled } from 'src/features/Images/utils'; import { Region } from '../Region'; import { getGeneratedLinodeLabel } from '../utilities'; @@ -26,10 +29,12 @@ export const Images = () => { getValues, setValue, } = useFormContext(); + const { field, fieldState } = useController({ control, name: 'image', }); + const queryClient = useQueryClient(); const { data: permissions } = usePermissions('account', ['create_linode']); @@ -40,6 +45,8 @@ export const Images = () => { const selectedRegion = regions?.find((r) => r.id === regionId); + const { isPrivateImageSharingEnabled } = useIsPrivateImageSharingEnabled(); + const onChange = async (image: Image | null) => { field.onChange(image?.id ?? null); @@ -88,17 +95,27 @@ export const Images = () => { Choose an Image - - - + ) : ( + + + + )} ); diff --git a/packages/manager/src/features/Linodes/LinodeCreate/index.tsx b/packages/manager/src/features/Linodes/LinodeCreate/index.tsx index 8743bd46ac4..a6851e6502f 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/index.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/index.tsx @@ -129,7 +129,7 @@ export const LinodeCreate = () => { to: '/linodes/create/os', }, { - title: 'Marketplace', + title: 'Quick Deploy Apps', to: '/linodes/create/marketplace', }, { diff --git a/packages/manager/src/features/Linodes/LinodeCreate/resolvers.ts b/packages/manager/src/features/Linodes/LinodeCreate/resolvers.ts index dedae51d7de..dd798b23e45 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/resolvers.ts +++ b/packages/manager/src/features/Linodes/LinodeCreate/resolvers.ts @@ -38,6 +38,18 @@ export const getLinodeCreateResolver = ( values.linodeInterfaces = values.linodeInterfaces.map( getCleanedLinodeInterfaceValues ); + if ( + values.interface_generation === 'legacy_config' || + tab === 'Clone Linode' + ) { + // firewall_id is required in the form under interfaces object when using linode interfaces, but not when using legacy interfaces. + // If the user selects legacy interfaces, we set firewall_id to -1 to bypass the firewall requirement in the validation schema. + values.linodeInterfaces.forEach((linodeInterface) => { + linodeInterface.firewall_id = -1; + }); + } else { + values.firewall_id = -1; + } } else { values.linodeInterfaces = []; values.interfaces = @@ -52,6 +64,12 @@ export const getLinodeCreateResolver = ( values.metadata = undefined; } + // For the Clone Linode flow, we need not send firewall_id in the payload as API will take care of assigning the firewall_id based on the source Linode's configuration. + if (tab === 'Clone Linode' && !values.firewall_id) { + // The Clone Linode flow does not have the firewall_id field under interfaces object, so we set firewall_id to -1 to bypass the firewall requirement in the validation schema. + values.firewall_id = -1; + } + const { errors } = await yupResolver( schema, {}, diff --git a/packages/manager/src/features/Linodes/LinodeCreate/schemas.ts b/packages/manager/src/features/Linodes/LinodeCreate/schemas.ts index 2bc6cc15084..f51432af0fd 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/schemas.ts +++ b/packages/manager/src/features/Linodes/LinodeCreate/schemas.ts @@ -7,6 +7,7 @@ import { array, boolean, number, object, string } from 'yup'; import { CreateLinodeInterfaceFormSchema } from '../LinodesDetail/LinodeNetworking/LinodeInterfaces/AddInterfaceDrawer/utilities'; import type { LinodeCreateFormValues } from './utilities'; +import type { InterfaceGenerationType } from '@linode/api-v4/lib/linodes/types'; import type { ObjectSchema } from 'yup'; /** @@ -17,6 +18,12 @@ import type { ObjectSchema } from 'yup'; export const CreateLinodeSchema: ObjectSchema = BaseCreateLinodeSchema.concat( object({ + firewall_id: number().when('interface_generation', { + is: (value: InterfaceGenerationType) => value === 'legacy_config', + then: (schema) => + schema.required('Select an option or create a new Firewall.'), + otherwise: (schema) => schema.nullable().notRequired(), + }), firewallOverride: boolean(), hasSignedEUAgreement: boolean(), interfaces: array(ConfigProfileInterfaceSchema).required(), diff --git a/packages/manager/src/features/Linodes/LinodeCreate/shared/LinodeSelectTable.tsx b/packages/manager/src/features/Linodes/LinodeCreate/shared/LinodeSelectTable.tsx index e46f3cebac8..8497d2552a6 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/shared/LinodeSelectTable.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/shared/LinodeSelectTable.tsx @@ -149,7 +149,8 @@ export const LinodeSelectTable = (props: Props) => { private_ip: linode.interface_generation !== 'linode' && hasPrivateIP, region: linode.region, type: linode.type ?? '', - interface_generation: undefined, + interface_generation: + createPath === 'clone' ? undefined : prev.interface_generation, })); if (!isLabelFieldDirty) { diff --git a/packages/manager/src/features/Linodes/LinodeCreate/utilities.test.tsx b/packages/manager/src/features/Linodes/LinodeCreate/utilities.test.tsx index 10ffa39e821..8dc0a441f17 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/utilities.test.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/utilities.test.tsx @@ -489,6 +489,7 @@ describe('getDoesEmployeeNeedToAssignFirewall', () => { vpc: null, default_route: null, vlan: null, + firewall_id: null, }, ], 'linode' @@ -545,6 +546,7 @@ describe('getDoesEmployeeNeedToAssignFirewall', () => { public: null, default_route: null, vlan: { vlan_label: 'my-vlan-1' }, + firewall_id: null, }, ], 'linode' @@ -565,6 +567,7 @@ describe('getDoesEmployeeNeedToAssignFirewall', () => { vpc: null, vlan: null, default_route: null, + firewall_id: null, }, { vpc: null, @@ -572,6 +575,7 @@ describe('getDoesEmployeeNeedToAssignFirewall', () => { public: null, default_route: null, vlan: { vlan_label: 'my-vlan-1' }, + firewall_id: null, }, ], 'linode' diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/AddInterfaceDrawer/AddInterfaceForm.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/AddInterfaceDrawer/AddInterfaceForm.tsx index 82b2370844e..f71a3fad372 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/AddInterfaceDrawer/AddInterfaceForm.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/AddInterfaceDrawer/AddInterfaceForm.tsx @@ -45,7 +45,7 @@ export const AddInterfaceForm = (props: Props) => { ) ?? []; const form = useForm({ defaultValues: { - firewall_id: null, + firewall_id: undefined, public: {}, vlan: {}, vpc: { @@ -58,7 +58,7 @@ export const AddInterfaceForm = (props: Props) => { async resolver(rawValues, context, options) { const valuesWithOnlySelectedInterface = getCleanedLinodeInterfaceValues( structuredClone(rawValues) - ); + ) as CreateInterfaceFormValues; const { errors, values } = await yupResolver( CreateLinodeInterfaceFormSchema diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/AddInterfaceDrawer/InterfaceFirewall.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/AddInterfaceDrawer/InterfaceFirewall.tsx index e697993df09..a1e98f02870 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/AddInterfaceDrawer/InterfaceFirewall.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/AddInterfaceDrawer/InterfaceFirewall.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { useController } from 'react-hook-form'; import { FirewallSelect } from 'src/features/Firewalls/components/FirewallSelect'; +import { WARNING_MESSAGE_FOR_NO_FIREWALL_OPTION } from 'src/features/Linodes/constants'; import type { CreateInterfaceFormValues } from './utilities'; @@ -18,8 +19,9 @@ export const InterfaceFirewall = () => { errorText={fieldState.error?.message} onBlur={field.onBlur} onChange={(e, firewall) => field.onChange(firewall?.id ?? null)} - placeholder="None" + placeholder="Select a Firewall" value={field.value} + warningMessageForNoFirewallOption={WARNING_MESSAGE_FOR_NO_FIREWALL_OPTION} /> ); }; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/AddInterfaceDrawer/InterfaceType.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/AddInterfaceDrawer/InterfaceType.tsx index 83465ed7068..d09a81afcbc 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/AddInterfaceDrawer/InterfaceType.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/AddInterfaceDrawer/InterfaceType.tsx @@ -39,9 +39,9 @@ export const InterfaceType = (props: Props) => { field.onChange(value); // VLAN interfaces do not support Firewalls, so set - // the Firewall ID to `null` to be safe and early return. + // the Firewall ID to `-1` to be safe and early return. if (value === 'vlan') { - setValue('firewall_id', null); + setValue('firewall_id', -1); return; } diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/AddInterfaceDrawer/utilities.ts b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/AddInterfaceDrawer/utilities.ts index 9e3c41835f3..7f464ce0fba 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/AddInterfaceDrawer/utilities.ts +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/AddInterfaceDrawer/utilities.ts @@ -9,6 +9,9 @@ import type { InferType } from 'yup'; export const CreateLinodeInterfaceFormSchema = CreateLinodeInterfaceSchema.concat( object({ + firewall_id: number().required( + 'Select an option or create a new Firewall.' + ), purpose: string() .oneOf(['vpc', 'vlan', 'public']) .required('You must selected an Interface type.'), diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/EditInterfaceDrawer/EditInterfaceFirewall.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/EditInterfaceDrawer/EditInterfaceFirewall.tsx index c83e38e881d..0a38715062d 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/EditInterfaceDrawer/EditInterfaceFirewall.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/EditInterfaceDrawer/EditInterfaceFirewall.tsx @@ -33,6 +33,7 @@ export const EditInterfaceFirewall = ({ showSuccessNotice }: Props) => { field.onChange(firewall?.id ?? null)} + showNoFirewallOption={false} value={field.value} /> )} diff --git a/packages/manager/src/features/Linodes/constants.ts b/packages/manager/src/features/Linodes/constants.ts index b1326b91364..63a2cd6c581 100644 --- a/packages/manager/src/features/Linodes/constants.ts +++ b/packages/manager/src/features/Linodes/constants.ts @@ -51,3 +51,6 @@ export const LINODE_LOCKED_DELETE_INTERFACE_TOOLTIP = export const LINODE_REBUILD_LOCKED_NOTICE_TEXT = 'This Linode is currently locked and cannot be rebuilt. Please remove the lock to proceed.'; + +export const WARNING_MESSAGE_FOR_NO_FIREWALL_OPTION = + 'This Linode, or its Linode interface, is not secured with a Cloud Firewall. Add a firewall to help protect your resources and simplify security management.'; diff --git a/packages/manager/src/features/Marketplace/ProductDetails/ContactSalesDrawer.tsx b/packages/manager/src/features/Marketplace/ProductDetails/ContactSalesDrawer.tsx index be4ae28a475..d84e49d66a9 100644 --- a/packages/manager/src/features/Marketplace/ProductDetails/ContactSalesDrawer.tsx +++ b/packages/manager/src/features/Marketplace/ProductDetails/ContactSalesDrawer.tsx @@ -142,12 +142,16 @@ export const ContactSalesDrawer = (props: ContactSalesDrawerProps) => { const onSubmit = handleSubmit(async (values) => { try { - if ( - values?.additional_emails?.length === 1 && - values?.additional_emails[0].trim() === '' - ) { + const cleanedAdditionalEmails = values.additional_emails?.filter((e) => + e?.trim() + ); + + if (!cleanedAdditionalEmails?.length) { delete values.additional_emails; + } else { + values.additional_emails = cleanedAdditionalEmails; } + await createPartnerReferral(values); enqueueSnackbar( 'Your request has been received by Akamai. After we forward it to the partner, you will receive a confirmation email.', diff --git a/packages/manager/src/features/Marketplace/ProductDetails/pages/cambria-stream.ts b/packages/manager/src/features/Marketplace/ProductDetails/pages/cambria-stream.ts index 1109714aa2f..54f75b5cb62 100644 --- a/packages/manager/src/features/Marketplace/ProductDetails/pages/cambria-stream.ts +++ b/packages/manager/src/features/Marketplace/ProductDetails/pages/cambria-stream.ts @@ -40,14 +40,14 @@ Ready to simplify your live and file-based media workflows? Submit an enquiry to const documentationMarkdown = ` | Specification | Details | | :---- | :---- | -| Deployment model | Customer-managed software; deployable on-prem, cloud, or hybrid environments | -| Packaging & orchestration | Containerised (Docker); Kubernetes-ready for scheduling and horizontal scaling | -| Product scope | Live encoding & packaging (Cambria Stream) | -| Supported video codecs | Cambria Stream Ingest a wide range of live inputs, then encode and package them as HLS, MPEG-DASH, CMAR, SRT and Zixi. | -| File orchestration | Centralised job distribution, load balancing, and node monitoring via Cambria Cluster | -| Hybrid processing | On-prem and cloud resources managed within a single workflow, including workload bursting | -| Automation & integration | REST APIs for workflow automation and integration with existing media systems | -| Scalability model | Horizontal scaling based on available compute resources (CPU/GPU/VPU, environment-dependent) | +| **Deployment model** | Customer-managed software; deployable on-prem, cloud, or hybrid environments | +| **Packaging & orchestration** | Containerised (Docker); Kubernetes-ready for scheduling and horizontal scaling | +| **Product scope** | Live encoding & packaging (Cambria Stream) | +| **Supported video codecs** | Cambria Stream Ingest a wide range of live inputs, then encode and package them as HLS, MPEG-DASH, CMAR, SRT and Zixi. | +| **File orchestration** | Centralised job distribution, load balancing, and node monitoring via Cambria Cluster | +| **Hybrid processing** | On-prem and cloud resources managed within a single workflow, including workload bursting | +| **Automation & integration** | REST APIs for workflow automation and integration with existing media systems | +| **Scalability model** | Horizontal scaling based on available compute resources (CPU/GPU/VPU, environment-dependent) | ### **Architectural diagrams** diff --git a/packages/manager/src/features/Marketplace/ProductDetails/pages/cloudcasa.ts b/packages/manager/src/features/Marketplace/ProductDetails/pages/cloudcasa.ts new file mode 100644 index 00000000000..1ae9f47a4f7 --- /dev/null +++ b/packages/manager/src/features/Marketplace/ProductDetails/pages/cloudcasa.ts @@ -0,0 +1,91 @@ +/** + * Product tab details for slug cloudcasa. + * + * Content is provided as Markdown strings which are rendered at runtime. + */ + +import type { ProductTabDetails } from '.'; + +const overviewMarkdown = ` +CloudCasa delivers Kubernetes-native backup, disaster recovery, and migration for containerized applications running on Akamai Cloud's managed Kubernetes services, including LKE (Linode Kubernetes Engine). It solves the critical challenge of protecting stateful cloud-native workloads by ensuring application data, configurations, and persistent volumes are recoverable in the event of accidental deletion, ransomware, infrastructure failure, or regional outages. Designed specifically for modern Kubernetes environments, CloudCasa eliminates the complexity of manual backup scripts and infrastructure-dependent recovery processes. This enables organizations to confidently run production workloads on Akamai Cloud with enterprise-grade data protection. + +The platform operates using Kubernetes-native APIs and integrates directly with Akamai LKE clusters without requiring additional infrastructure management. CloudCasa captures full application context - including namespaces, deployments, services, and persistent volume data - and stores backups securely in object storage such as Akamai Object Storage or S3-compatible targets. It supports automated scheduling, application-consistent backups through pre- and post-backup hooks, cross-cluster restores, and cluster migration capabilities. With a SaaS-based control plane, customers can centrally manage protection policies, monitor backup health, and perform granular restores through a simplified web interface. + +CloudCasa's key differentiator is its Kubernetes-first, infrastructure-agnostic design, allowing seamless protection across hybrid and multi-cloud environments - not just Akamai. It is ideal for DevOps teams, MSPs, and enterprises seeking simple, agentless backup with built-in ransomware resilience and rapid recovery objectives. It also offers the most cost-effective solution on the market, costing less than half compared to alternatives, while refusing to compromise on features and functionality. In fact, CloudCasa offers the most comprehensive Kubernetes-native solution for both containers and containerized VMs, all in a very lightweight offering that is easy to deploy and simple to use. + +### Key features + +- **Most cost-effective Kubernetes backup for Akamai LKE:** Enterprise-grade protection at a fraction of the cost from competitors. +- **Comprehensive Kubernetes data protection with RBAC and ACLs:** Protects cluster state, namespaces, persistent volumes, and application data with immutable backups. Offering granular file-level recovery with built in RBAC and ACL controls. +- **Migration and recovery into Akamai LKE:** Move or recover Kubernetes workloads from other platforms directly into Akamai. +- **Protect Akamai LKE workloads with automated, policy-driven backups:** Schedule and manage Kubernetes-native backups for applications and persistent volumes running on Akamai Cloud, ensuring consistent and reliable data protection. +- **Accelerate disaster recovery across regions and clusters:** Rapidly restore entire applications or individual resources to the same or different Akamai LKE clusters to minimize downtime and meet RTO objectives. +- **Strengthen ransomware resilience with immutable backup storage:** Store backups in secure, S3-compatible object storage (including Akamai Object Storage) to protect against data corruption or malicious deletion. +- **Granular recovery options:** Recover entire clusters, individual namespaces, specific resources, even single files from PVC's. +- **Ensure application consistency with built-in app hooks:** Execute pre- and post-backup hooks to capture application-aware, transactionally consistent backups for stateful workloads. +- **Reduce infrastructure overhead with agentless, Kubernetes-native integration:** Deploy quickly to Akamai LKE using Helm and leverage native APIs without managing additional backup infrastructure. + +### Use cases + +**Granular Kubernetes backup & restore** + +Recover exactly what you need - whether it's a single namespace, deployment, or persistent volume - without restoring the entire cluster, reducing downtime and operational disruption. + +**Self-service backup & recovery** + +Empower DevOps and application teams to initiate backups and perform restores directly through a simple UI, eliminating ticket bottlenecks and accelerating recovery time. + +**Compliance & data retention** + +Enforce policy-based retention schedules and maintain auditable backup records to meet regulatory and internal governance requirements. + +**Ransomware protection (immutable backups)** + +Safeguard critical workloads with immutable, object-storage-based backups that prevent alteration or deletion, ensuring clean recovery points after an attack. + +**Kubernetes migration to Akamai LKE** + +Seamlessly move applications and persistent data from other Kubernetes environments into Akamai LKE without re-architecting workloads or rebuilding clusters. + +**Disaster recovery into LKE (active/standby)** + +Replicate and restore applications into a secondary Akamai LKE cluster to support active/standby configurations and maintain business continuity during regional outages. + +Protect your Kubernetes workloads on Akamai LKE in minutes with CloudCasa's backup and disaster recovery platform. Request a personalized demo to see how CloudCasa streamlines backup, migration, and ransomware protection, and ask for a free trial of our offering. Our onboarding resources, guided setup, and expert support team ensure you can deploy quickly and confidently secure your Kubernetes workloads. + +`.trim(); + +const documentationMarkdown = ` +| Specification | Details | +| :---- | :---- | +| **Deployment Model** | Self-hosted, helm-based installation | +| **Supported Data Sources** | Kubernetes Version 1.23 or later and KubeVirt workloads | +| **API Type** | RESTful API | +| **CLI Tools** | Helm 3 (if using Helm installation method) kubectl access to the cluster | +| **Security** | TLS 1.3, AES-256 encryption at rest | + + +![Reference Architecture](/assets/marketplace/cloudcasa-diagram.jpeg) + +**Process Flow** + +![Process FLow](/assets/marketplace/cloudcasa-process-flow.jpg) +`.trim(); + +const pricingMarkdown = ` +Pricing details will be discussed directly with the third-party provider Sales team after your request is received, and the third-party provider contacts you. Costs of the product you will be purchasing from the third-party provider will be charged by the third-party provider. For the referral motion, Akamai is not a party in the purchase contract. + +The full price of the product cost should be clarified between you and the third-party provider within the agreed upon terms and conditions of the purchase contract. +`.trim(); + +const supportMarkdown = ` +For product support, reach out to the vendor directly. You can find contact information in the product documentation and on the vendor's website. + +`.trim(); + +export const cloudcasa: ProductTabDetails = { + overview: overviewMarkdown, + documentation: documentationMarkdown, + pricing: pricingMarkdown, + support: supportMarkdown, +}; diff --git a/packages/manager/src/features/Marketplace/ProductDetails/pages/index.ts b/packages/manager/src/features/Marketplace/ProductDetails/pages/index.ts index cea07aca002..26c4b79f44d 100644 --- a/packages/manager/src/features/Marketplace/ProductDetails/pages/index.ts +++ b/packages/manager/src/features/Marketplace/ProductDetails/pages/index.ts @@ -1,15 +1,20 @@ import { apiMetrics } from './api-metrics'; import { cambriaStream } from './cambria-stream'; +import { cloudcasa } from './cloudcasa'; import { dynamicAdInsertion } from './dynamic-ad-insertion'; import { heroEncoder } from './hero-encoder'; +import { liveEncoder } from './live-encoder'; import { multiplayerGameServerHostingOrchestration } from './multiplayer-game-server-hosting-orchestration'; import { myota } from './myota'; +import { playback } from './playback'; +import { portainer } from './portainer'; import { radSecurityPlatform } from './rad-security-platform'; import { scaleflexSmartMediaCloudAndDam } from './scaleflex-smart-media-cloud-and-dam'; import { sftpgo } from './sftpgo'; import { synadiaPlatform } from './synadia-platform'; -import { titan } from './titan'; +import { titanVideoProcessingAndCompression } from './titan-video-processing-and-compression'; import { vindralLive } from './vindral-live'; +import { vodEncoder } from './vod-encoder'; /** * Tab content structure for product details page. @@ -29,17 +34,22 @@ export interface ProductTabDetails { const detailsMap: Record = { 'api-metrics': apiMetrics, 'cambria-stream': cambriaStream, + cloudcasa, 'dynamic-ad-insertion': dynamicAdInsertion, 'hero-encoder': heroEncoder, + 'live-encoder': liveEncoder, 'multiplayer-game-server-hosting-orchestration': multiplayerGameServerHostingOrchestration, myota, + playback, + portainer, 'rad-security-platform': radSecurityPlatform, 'scaleflex-smart-media-cloud-and-dam': scaleflexSmartMediaCloudAndDam, sftpgo, 'synadia-platform': synadiaPlatform, - titan, + 'titan-video-processing-and-compression': titanVideoProcessingAndCompression, 'vindral-live': vindralLive, + 'vod-encoder': vodEncoder, // Add more products here as you add their details files }; diff --git a/packages/manager/src/features/Marketplace/ProductDetails/pages/live-encoder.ts b/packages/manager/src/features/Marketplace/ProductDetails/pages/live-encoder.ts new file mode 100644 index 00000000000..6ab83d0c5b1 --- /dev/null +++ b/packages/manager/src/features/Marketplace/ProductDetails/pages/live-encoder.ts @@ -0,0 +1,78 @@ +/** + * Product tab details for slug live-encoder. + * + * Content is provided as Markdown strings which are rendered at runtime. + */ + +import type { ProductTabDetails } from '.'; + +const overviewMarkdown = ` +Bitmovin's Live Encoder is a SaaS product on Akamai Connected Cloud, enabling reliable, high-quality streaming with fast startup times for social platforms, sports, news, entertainment, and large-scale events. + +It features an intuitive UI and robust API for quick configuration, management, and scaling. Supporting up to 4K resolution, it accepts RTMP(S), SRT, and Zixi inputs, and streams in codecs like h.264/AVC, h.265/HEVC, and VP9. Outputs include HLS and DASH, with features such as SCTE-35 monetization, input redundancy, Live to VOD, DRM protection, graphic overlays, and integration with other Bitmovin products (VOD Encoder, Player, and Analytics). + +### Key features + +- **Resilient and reliable:** Bitmovin's Live Encoder was built on the same backbone as our VOD Encoder, ensuring stream uptime is constant and reliable for global playback distribution. +- **Multi-protocol and codec support:** Ingest your live broadcasts seamlessly with support for RTMP, RTMPS, SRT, or Zixi single and redundant inputs, encoding h.264/AVC, h.265/HEVC, or royalty-free VP9 and package in HLS or DASH for distribution to end-user devices, helping you ensure your content can always be made available and viewable to your users on any device. +- **Live2VOD:** Stream and record your live content to give your users the ability to view it after the live event has ended or clip parts of it while it is still running to promote your ongoing stream. +- **Customizable with a robust API library:** Gain access to our extensive API library that enables you and your team to build out and customize the Bitmovin Live Encoder to fit your live streaming workflow needs. +- **Content security:** Keep your content protected with multi-DRM integrations that enable you to cost-effectively and securely deliver your media over the internet. +- **Simple and scalable:** Manage your live encoding process through our simple and Intuitive UI or API to get streaming quickly with the Bitmovin Live Encoder and scale the number of your live streams to meet your usage needs easily without worrying about peaks. + + +### Use cases + +**SaaS scale for 1000s of encodings per day** + +Deploy and manage large-scale live streaming operations effortlessly with Bitmovin's fully managed SaaS platform on the Akamai Connected Cloud. The Live Encoder automates infrastructure tasks - including compute allocation, storage, and networking - so teams can start or stop live events instantly without system configuration. Dynamic scaling ensures reliable performance during peak traffic, while consumption - based billing supports rapid recovery for 24/7 channels through on-demand redundancy and backup servers. + +**How Live Encoder can help generate revenue** + +Enable multiple monetization models - including SVOD, AVOD, and hybrid approaches - with Bitmovin's Live Encoder on the Akamai Connected Cloud. Support for SCTE‑35 ad markers, server ‑ and client‑side ad insertion (SSAI/CSAI), and DRM ensures secure, targeted ad delivery while protecting premium content. Integration with Akamai's global CDN and Bitmovin Analytics provides reliable distribution and actionable insights into viewer engagement and ad performance, allowing platforms to refine monetization strategies and maximize revenue. + +**Reduce operating costs with Live Encoder** + +Lower total cost of ownership with Bitmovin's Live Encoder on the Akamai Connected Cloud by optimizing encoding ladders to minimize egress and data transfer costs while maintaining broadcast-level quality. Unlike open-source implementations that demand heavy customization as scale increases, Bitmovin provides a secure, managed solution optimized for efficiency and performance. Its pay‑as‑you‑go model supports both continuous and event-based streaming, enabling flexible cost control without compromising reliability or viewer experience. + +Ready to deliver stunning live events with our encoder? Contact our team to schedule a personalized demo and discuss your specific video streaming requirements. + +`.trim(); + +const documentationMarkdown = ` +| Specification | Details | +| :---- | :---- | +| **Input resolutions** | SD (480i, 576i), HD (720p, 1080i, 1080p), 4K (2160p), Portrait (mobile) | +| **Input video codecs** | MPEG-2, MPEG-4, AVC/H.264, HEVC/H.265 | +| **Input audio codecs** | AAC, AC3, PCM | +| **Input transport protocols** | RTMP, Zixi Receiver, SRT Caller, SRT Listener | +| **Output file formats** | MPEG-2 TS, MP4, fMP4, MOV, WebM, CMAF, and more | +| **Output video codecs** | AVC/H.264, HEVC/H.265, VP9 | +| **Output audio codecs** | AAC-LC, HE-AACv1, HE-AACv2, AC3, Vorbis, Opus | +| **DRM** | Widevine, PlayReady, Marlin, FairPlay | +| **SCTE-35** | Inband MPEG-TS, API based cue insertion | +| **Subtitles & Closed captions** | WebVTT & OCR into WebVTT, CEA-608/708, Burnt-in Subtitles, and more | +| **Output streaming protocols** | MPEG-DASH, Apple HLS, DASH-IF Live Media Ingest, Progressive MP4 | + +### Live Encoder Streaming Workflow + +![Live Encoder Streaming Workflow](/assets/marketplace/bitmovin-live-encoder.jpeg) +`.trim(); + +const pricingMarkdown = ` +Pricing details will be discussed directly with the third-party provider Sales team after your request is received, and the third-party provider contacts you. Costs of the product you will be purchasing from the third-party provider will be charged by the third-party provider. For the referral motion, Akamai is not a party in the purchase contract. + +The full price of the product cost should be clarified between you and the third-party provider within the agreed upon terms and conditions of the purchase contract. +`.trim(); + +const supportMarkdown = ` +For product support, reach out to the vendor directly. You can find contact information in the product documentation and on the vendor's website. + +`.trim(); + +export const liveEncoder: ProductTabDetails = { + overview: overviewMarkdown, + documentation: documentationMarkdown, + pricing: pricingMarkdown, + support: supportMarkdown, +}; diff --git a/packages/manager/src/features/Marketplace/ProductDetails/pages/multiplayer-game-server-hosting-orchestration.ts b/packages/manager/src/features/Marketplace/ProductDetails/pages/multiplayer-game-server-hosting-orchestration.ts index e781adf5e01..4f8d06b2e45 100644 --- a/packages/manager/src/features/Marketplace/ProductDetails/pages/multiplayer-game-server-hosting-orchestration.ts +++ b/packages/manager/src/features/Marketplace/ProductDetails/pages/multiplayer-game-server-hosting-orchestration.ts @@ -62,7 +62,6 @@ const documentationMarkdown = ` | **Compliance – Security** | SOC 1, SOC2, and SOC 3 (upon request) | | **Compliance – Regional** | GDPR, EU-US Privacy Shield, Swiss-US Privacy Shield, HIPAA (upon request), FedRAMP (upon request) | -### "Fully details" variation (with some modifications) ![Edgegap Architecture](/assets/marketplace/eg-architecture.jpeg) `.trim(); diff --git a/packages/manager/src/features/Marketplace/ProductDetails/pages/playback.ts b/packages/manager/src/features/Marketplace/ProductDetails/pages/playback.ts new file mode 100644 index 00000000000..1be594b4204 --- /dev/null +++ b/packages/manager/src/features/Marketplace/ProductDetails/pages/playback.ts @@ -0,0 +1,79 @@ +/** + * Product tab details for slug playback. + * + * Content is provided as Markdown strings which are rendered at runtime. + */ + +import type { ProductTabDetails } from '.'; + +const overviewMarkdown = ` +Bitmovin's Playback solution, Player and Analytics, are fully managed SaaS tools designed to enhance streaming platforms by delivering high-quality content and superior viewer experiences across every device. + +The Player provides a robust features set, such as adaptive bitrate streaming, offline playback, ad integrations, and CMCD support, with dedicated SDKs for the broadest range of devices, including web, mobile, smart TVs, and game consoles to maximize viewer reach. Bitmovin's Analytics provides real-time monitoring and actionable insights on audience, playback errors and quality of experience (QoE) metrics, allowing platforms to access over 200 parameters to quickly identify and resolve issues, optimize streams, and boost viewer retention and engagement. + +### Key features + +- **Reach more viewers faster:** Easy to deploy on Smart TVs, Mobile & Connected TV devices. +- **Monetize your content:** Built-in monetization support for SSAI, CSAI, and SGAI-enabled workflows. +- **Modular architecture:** Reduce your bounce rate by loading only the parts that you need for faster load times and lower distribution costs. +- **Configurable ABR:** Deliver stunning quality in all environments. +- **Real-time actionable insights:** Easily deploy and view actionable data that helps you increase viewer engagement and retention, track and fix playback issues before they impact your users, and optimize for the best viewing experience. +- **Improved quality assurance:** Stream Lab integration for automated, real-device playback testing. + +### Use cases + +**Advanced Monetization Capabilities** + +Increase audience reach and ad revenue with Bitmovin's Player and Analytics (Playback) solutions. Flexible ad management supports both server‑side (SSAI) and client‑side (CSAI) ad insertion, ensuring smooth, targeted ad delivery across devices. With real‑time playback insights from Bitmovin Analytics and efficient global delivery powered by Akamai's CDN, platforms can optimize ad performance, boost conversions, and maintain a consistently high‑quality viewing experience. + +**Lower Development Costs** + +Reduce engineering effort and operational overhead with Bitmovin's dedicated Player SDKs. Pre‑built cross‑platform components eliminate the need for manual maintenance and customization common with open‑source or in‑house players. Continuous automated testing - over 150,000 daily tests and weekly updates - ensures reliability, while Stream Lab allows real‑device validation of active streams. With Bitmovin Analytics and Akamai CMCD data pre‑integrated, developers can identify and resolve playback or CDN issues faster, improving efficiency and minimizing downtime. + +**Flawless Video Experiences** + +Deliver premium, consistent playback across every device to strengthen viewer satisfaction and reduce churn. Bitmovin's Player, built with dedicated SDKs and a cross‑platform UI framework, ensures brand consistency and supports advanced features such as low‑latency streaming and multi‑view experiences. With Bitmovin Analytics providing session‑level insights, teams can proactively detect and resolve playback issues, integrate with observability tools for holistic user analysis, and maintain the highest quality of experience to grow and retain subscribers. + +Ready to deliver flawless playback on every device? Contact our team to schedule a personalized demo and discuss your specific video streaming requirements. + +`.trim(); + +const documentationMarkdown = ` +| Specification | Details | +| :---- | :---- | +| **Device SDKs** | Web, iOS, Android, smart TVs, set-top boxes, consoles, HbbTV, and more | +| **Browser SDKs** | Chrome, Edge, Firefox, Opera, Safari | +| **Cross-platform SDKs** | React Native SDK, Flutter SDK | +| **Workflows & Protocols** | Video on demand (HLS, MPEG-DASH, HSS) and live (Low-Latency HLS/DASH over CMAF) | +| **Functions & Qualities** | ABR, Multi-Language Audio, Offline Playback, Playlists, Live DVR, and more | +| **Subtitles & Closed Captions** | WebVTT, SRT, TTML/DFXP, CEA-608/708, Multi-Language CC | +| **Content protection** | Widevine, PlayReady, FairPlay, Client-side Watermarking, Offline DRM, and more | +| **Advertising capabilities** | SSAI, CSAI, SGAI, VAST 3.0/4.0/4.1, VMAP + Ad Scheduling & Targeting, and more | +| **Other Playback Features** | Multi-view playback, Preview Thumbnails, Audio Only Player, and more | +| **Collectors** | Bitmovin Player, Shaka Player, dash.js, hls.js, video.js, Dolby OptiView, and more | +| **AI Features** | AI Session Interpreter, AI Anomaly Detection, Assistant | + + + +### Playback Workflow + +![Playback Workflow](/assets/marketplace/bitmovin-playback.jpeg) +`.trim(); + +const pricingMarkdown = ` +Pricing details will be discussed directly with the third-party provider Sales team after your request is received, and the third-party provider contacts you. Costs of the product you will be purchasing from the third-party provider will be charged by the third-party provider. For the referral motion, Akamai is not a party in the purchase contract. + +The full price of the product cost should be clarified between you and the third-party provider within the agreed upon terms and conditions of the purchase contract. +`.trim(); + +const supportMarkdown = ` +For product support, reach out to the vendor directly. You can find contact information in the product documentation and on the vendor's website. + +`.trim(); + +export const playback: ProductTabDetails = { + overview: overviewMarkdown, + documentation: documentationMarkdown, + pricing: pricingMarkdown, + support: supportMarkdown, +}; diff --git a/packages/manager/src/features/Marketplace/ProductDetails/pages/portainer.ts b/packages/manager/src/features/Marketplace/ProductDetails/pages/portainer.ts new file mode 100644 index 00000000000..66f1165c52e --- /dev/null +++ b/packages/manager/src/features/Marketplace/ProductDetails/pages/portainer.ts @@ -0,0 +1,102 @@ +/** + * Product tab details for slug portainer. + * + * Content is provided as Markdown strings which are rendered at runtime. + */ + +import type { ProductTabDetails } from '.'; + +const overviewMarkdown = ` +Portainer is an operator control plane for governing Docker, Podman, and Kubernetes environments from a single self-hosted system. It solves the operational gap that sits above managed Kubernetes services like Akamai LKE: how to centralize access control, enforce security policy, standardize deployments, and manage fleets of clusters without needing a dedicated platform engineering team. It is designed for the IT operations, infrastructure, and security teams that make up the majority of Akamai's enterprise customer base. + +Portainer connects to Kubernetes environments through lightweight agents and provides centralized role-based access control with Active Directory, LDAP, and OIDC integration; governed application deployment through form-based or manifest-driven workflows; a GitOps engine with deterministic policy enforcement; and fleet-wide governance with automatic drift remediation. All user actions are audit logged and can be streamed to SIEM platforms, supporting the compliance requirements common in financial services, healthcare, defense, and public sector environments. Portainer runs entirely within the customer boundary with no external SaaS dependency, aligning directly with Zero Trust and data sovereignty requirements. + +Portainer is the natural operational layer above Akamai LKE for teams running multiple clusters across cloud regions, hybrid environments, or distributed edge sites. It scales from a single cluster to one hundred thousand edge environments from one control plane and is designed for the eighty percent of enterprises that need modern container operations without the overhead of building and maintaining a bespoke internal platform. + +### Key features + +- **Govern access across every environment from one place:** Portainer centralizes authentication and role-based access control with support for Active Directory, LDAP, and OIDC, eliminating the need to distribute credentials or manage access cluster by cluster. +- **Deploy applications safely without Kubernetes expertise:** Guided form-based workflows and manifest-driven deployment options let IT generalists and developers ship applications consistently, with guardrails enforced automatically by the control plane. +- **Enforce security policy across your entire fleet automatically:** Fleet Governance Policies install, propagate, and auto-remediate policy across all connected clusters, so drift is detected and corrected without manual intervention. +- **Manage thousands of edge and remote environments reliably:** Asynchronous edge agents enable offline-safe operation across disconnected, air-gapped, and industrial sites, with updates queuing safely until connectivity is restored. +- **Maintain a complete audit trail for compliance:** Every user action and system event is logged at the control plane level and can be streamed to SIEM platforms such as Splunk or Microsoft Sentinel. +- **Adopt GitOps without rebuilding your infrastructure:** A centralized GitOps engine monitors repositories and enforces desired state deterministically, without embedding controllers inside each managed cluster. +- **Operate confidently in regulated and sovereign environments:** Portainer runs entirely within the customer boundary with no external SaaS dependency, and supports FIPS-140-3 compliant operation for government and defense requirements. + + +### Use cases + +**Governing Kubernetes Fleet Operations Across Multiple Clusters** + +Enterprises running Kubernetes across multiple environments quickly outgrow cluster-level tooling and need a single control plane for access, policy, and deployment governance. A global energy company with 100+ developers replaced a custom-built internal platform - which had taken five developers two years to build - with Portainer, achieving a 15% increase in developer productivity, $1.7M in savings, and a 99.99% uptime target across both Kubernetes and Docker Swarm environments. A leading French bank consolidated governance across 1,500+ containerized environments, reducing application onboarding from five days to three hours and recapturing $60K per year in platform team productivity. + +**Enabling Container Adoption Without Kubernetes Specialists** + +Most enterprise IT teams do not have dedicated Kubernetes engineers, yet increasingly face pressure to run containerized applications delivered by ISVs or internal development teams. A U.S. automotive manufacturer running 24x7 production across three shifts used Portainer to give non-technical shift staff the ability to monitor, restart, and triage containers without any CLI knowledge. A Turkish fashion retailer with 500 global stores adopted Portainer specifically because it allowed their 100-person IT team - most with no prior Kubernetes experience - to deploy and manage workloads through an intuitive GUI, without YAML expertise and at a fraction of the cost of OpenShift. + +**Securing and Auditing Container Operations in Regulated Environments** + +Financial services, healthcare, government, and defense organizations require centralized audit logging, enforced security policy, and verifiable compliance posture as conditions of operating. San Diego Superior Court containerized its public-facing infrastructure with Portainer, reducing outages from multiple per month to one in twelve months, improving availability to 99.9999%, and generating an estimated $10M in annual productivity savings - with Active Directory integration driving adoption across a Windows-centric IT team. A global AI-powered surgical intelligence platform operating in thousands of operating rooms used Portainer to enforce governance across globally distributed Kubernetes environments while cutting developer onboarding from six months to weeks. + +**Managing Distributed and Edge Deployments at Scale** + +Organizations operating across distributed sites - factories, vehicles, retail stores, city infrastructure - need a control plane that works reliably under intermittent connectivity, with no dependency on always-on networks. Cummins used Portainer to consolidate 35 separate telematics software variants into one unified architecture deployed over-the-air across thousands of connected vehicles, operating under low-bandwidth and intermittent connectivity constraints. Volkswagen built its Shopfloor Integration Management platform on Portainer to remotely deploy and lifecycle-manage IoT microservices across thousands of shopfloor devices globally. A U.S. building materials manufacturer uses Portainer to push updates simultaneously to edge cameras and sensors across 68 plants, replacing up to 40 manual daily deployments with one-click fleet rollouts and saving $100K per year. + +**Enabling MSPs and Service Providers to Deliver Self-Service Container Platforms** + +Managed service providers and technology companies building container platforms for their customers need a control plane that isolates tenants, reduces support burden, and scales without adding headcount. ilionx, a European managed IT services provider, replaced Rancher with Portainer after finding it too complex to operate, eliminating a daily bottleneck of 70–80 manual container restarts and enabling customers to manage their own isolated environments without contacting support. A major U.S. satellite and streaming radio provider managing 250 Kubernetes nodes estimated that operating without Portainer would require hiring two or more additional full-time engineers to handle update tasks alone. + + +Getting started with Portainer is straightforward and low-risk. Portainer offers a **free perpetual tier for up to three nodes** - no time limit and no credit card required - giving teams immediate hands-on access to the full management interface against real workloads. + +For organizations evaluating at production scale, a **30-day free trial** of Portainer Business is available, supported by a structured **Proof of Concept guide** that walks teams through deployment, configuration, and validation against their own environment and use cases. + +For enterprise evaluations, Portainer's solutions engineering team provides direct technical support throughout the trial period, including architecture reviews and guided onboarding. To get started, contact the Portainer team to arrange a live demo tailored to your infrastructure and requirements. +`.trim(); + +const documentationMarkdown = ` +| Specification | Details | +| :---- | :---- | +| **Deployment Model** | Self-hosted; runs as a container entirely within the customer environment - on-premises, private cloud, air-gapped, hybrid, or edge | +| **Supported Container Runtimes** | Docker, Podman, Kubernetes (any distribution), Docker Swarm | +| **Agent Types** | LAN Agent (trusted networks), Remote Agent (outbound-only, no inbound firewall rules), Async Edge Agent (disconnected and OT environments) | +| **Fleet Scale** | Up to 100,000 edge environments managed from a single control plane | +| **Identity and Authentication** | Active Directory, LDAP, OIDC-compatible identity providers, local users for air-gapped environments | +| **Access Control** | Role-based access control (RBAC) with predefined roles: Environment Administrator, Operator, Namespace Operator, Standard User, Read-Only, Helpdesk | +| **GitOps Execution** | Centralized server-side GitOps engine; supports Git repository monitoring via schedule or webhook; compatible with Docker, Podman, and Kubernetes | +| **Security and Compliance** | FIPS 140-3 compliant operation; AES-256 encryption of internal database at rest; TLS for agent communication; SIEM integration via webhook (Splunk, Microsoft Sentinel, Elastic) | +| **Audit Logging** | Full user-action-level audit logging at the control plane; exportable to SIEM platforms | +| **Policy Enforcement** | OPA Gatekeeper integration; fleet-wide policy propagation with automatic drift remediation across all connected clusters | +| **External Dependencies** | None; no SaaS control plane, no call-home requirement, no cloud dependency | + +Portainer uses a lightweight hub-and-spoke architecture consisting of two components: the Portainer Server and the Portainer Agent. + +The Portainer Server is the central control plane, running as a container on a dedicated management environment. It serves the UI and REST API, stores all configuration in an embedded BoltDB database, and manages identity, RBAC, GitOps execution, policy enforcement, and audit logging across all connected environments. It requires persistent storage and runs on Docker Standalone, Docker Swarm, or Kubernetes. + +Agents are deployed to each managed environment and act as execution proxies to the underlying container runtime. Three agent types support different network topologies. The Standard Agent accepts inbound connections from the Server on TCP port 9001, suited for private LAN environments. The Edge Agent reverses the connection direction — the agent calls home to the Server on ports 9443 and 8000 — eliminating the need for exposed inbound ports, suited for remote or internet-connected environments. The Async Edge Agent operates on the same outbound model but uses periodic snapshots rather than a live tunnel, designed for IoT, industrial, and satellite-connected environments with intermittent or limited connectivity. + +A single Portainer Server manages Docker, Podman, Kubernetes, and Swarm environments simultaneously, with tested scale to 100,000 active edge environments + + +### Portainer Architecture + +![Portainer Architecture](/assets/marketplace/portainer-architecture.jpeg) +`.trim(); + +const pricingMarkdown = ` +Pricing details will be discussed directly with the third-party provider Sales team after your request is received, and the third-party provider contacts you. Costs of the product you will be purchasing from the third-party provider will be charged by the third-party provider. For the referral motion, Akamai is not a party in the purchase contract. + +The full price of the product cost should be clarified between you and the third-party provider within the agreed upon terms and conditions of the purchase contract. +`.trim(); + +const supportMarkdown = ` +For product support, reach out to the vendor directly. You can find contact information in the product documentation and on the vendor's website. + +`.trim(); + +export const portainer: ProductTabDetails = { + overview: overviewMarkdown, + documentation: documentationMarkdown, + pricing: pricingMarkdown, + support: supportMarkdown, +}; diff --git a/packages/manager/src/features/Marketplace/ProductDetails/pages/scaleflex-smart-media-cloud-and-dam.ts b/packages/manager/src/features/Marketplace/ProductDetails/pages/scaleflex-smart-media-cloud-and-dam.ts index 4d31cf0ac6c..8ed828106e7 100644 --- a/packages/manager/src/features/Marketplace/ProductDetails/pages/scaleflex-smart-media-cloud-and-dam.ts +++ b/packages/manager/src/features/Marketplace/ProductDetails/pages/scaleflex-smart-media-cloud-and-dam.ts @@ -47,13 +47,13 @@ Interested? Book a demo with our team. In this demo, you will: const documentationMarkdown = ` | Specification | Details | | :---- | :---- | -| Deployment Model | Cloud-native SaaS, composable MACH architecture with online hub and headless API access | -| Supported Data Sources | Visual assets (images, videos, documents), ingress via drag-and-drop, CSV, and API | -| API Type | RESTful headless APIs for media upload, management, optimization and enrichment platform functions; CLI and widgets available | -| Programming Languages | Language-agnostic REST APIs; typical SDK use in JavaScript or any language capable of REST calls | -| Availability SLA | 99.9% uptime | -| Security | Standard token/API auth, permissions/roles, SSO / MFA | -| Compliance | GDPR compliance, MACH Alliance certified, 10 Core accredited DAM vendor | +| **Deployment Model** | Cloud-native SaaS, composable MACH architecture with online hub and headless API access | +| **Supported Data Sources** | Visual assets (images, videos, documents), ingress via drag-and-drop, CSV, and API | +| **API Type** | RESTful headless APIs for media upload, management, optimization and enrichment platform functions; CLI and widgets available | +| **Programming Languages** | Language-agnostic REST APIs; typical SDK use in JavaScript or any language capable of REST calls | +| **Availability SLA** | 99.9% uptime | +| **Security** | Standard token/API auth, permissions/roles, SSO / MFA | +| **Compliance** | GDPR compliance, MACH Alliance certified, 10 Core accredited DAM vendor | Full documentation under: [https://docs.scaleflex.com/](https://docs.scaleflex.com/) diff --git a/packages/manager/src/features/Marketplace/ProductDetails/pages/synadia-platform.ts b/packages/manager/src/features/Marketplace/ProductDetails/pages/synadia-platform.ts index 4aa390d1dd6..a8b01954bfb 100644 --- a/packages/manager/src/features/Marketplace/ProductDetails/pages/synadia-platform.ts +++ b/packages/manager/src/features/Marketplace/ProductDetails/pages/synadia-platform.ts @@ -37,14 +37,14 @@ Ready to start your free trial of Synadia Platform on the Akamai Cloud? Contact const documentationMarkdown = ` | Specification | Details | | :---- | :---- | -| Deployment model | Managed BYOC on Akamai Cloud or self-hosted. | -| Cloud & edge support | Native operation across Akamai, AWS, Azure, GCP, private clouds, and constrained edge environments. | -| Operational footprint | Lightweight, single-binary core with minimal dependencies and simplified operations at scale. | -| Security | Zero-trust security with mTLS, fine-grained authorization, and no shared secrets or stored credentials. | -| Latency profile | Millisecond-level data and service propagation with local-first access via mirrors and replicas. | -| Architecture | Event-driven, real-time messaging and streaming built on a single global connective fabric. | -| Replication & locality | Built-in geo-replication, mirroring, and digital twins for fast local reads and global consistency. | -| Data capabilities | Unified support for messaging, durable streams, key-value storage, and object storage. | +| **Deployment model** | Managed BYOC on Akamai Cloud or self-hosted. | +| **Cloud & edge support** | Native operation across Akamai, AWS, Azure, GCP, private clouds, and constrained edge environments. | +| **Operational footprint** | Lightweight, single-binary core with minimal dependencies and simplified operations at scale. | +| **Security** | Zero-trust security with mTLS, fine-grained authorization, and no shared secrets or stored credentials. | +| **Latency profile** | Millisecond-level data and service propagation with local-first access via mirrors and replicas. | +| **Architecture** | Event-driven, real-time messaging and streaming built on a single global connective fabric. | +| **Replication & locality** | Built-in geo-replication, mirroring, and digital twins for fast local reads and global consistency. | +| **Data capabilities** | Unified support for messaging, durable streams, key-value storage, and object storage. | The Synadia Platform, running on Akamai, enables a globally connected NATS supercluster to act as a single logical event fabric spanning regions, clouds, and the edge. At the core, multiple regional NATS clusters form a supercluster, providing high availability, low latency, and seamless data routing across geographies. diff --git a/packages/manager/src/features/Marketplace/ProductDetails/pages/titan.ts b/packages/manager/src/features/Marketplace/ProductDetails/pages/titan-video-processing-and-compression.ts similarity index 65% rename from packages/manager/src/features/Marketplace/ProductDetails/pages/titan.ts rename to packages/manager/src/features/Marketplace/ProductDetails/pages/titan-video-processing-and-compression.ts index 8b648a7fe13..a2cb7e9b707 100644 --- a/packages/manager/src/features/Marketplace/ProductDetails/pages/titan.ts +++ b/packages/manager/src/features/Marketplace/ProductDetails/pages/titan-video-processing-and-compression.ts @@ -1,22 +1,28 @@ import type { ProductTabDetails } from '.'; const overviewMarkdown = ` -Ateme is a global leader of video compression and delivery solutions helping tier-one content providers, service providers and streaming platforms to boost their viewership and subscription engagement. With Atemesolutions you can build a platform for streaming, OTT or IPTV services that captivates your audience with outstanding experiences. And you do that while saving infrastructure costs and minimizing your carbon footprint. +Ateme is a global leader of video compression and delivery solutions helping tier-one content providers, service providers and streaming platforms to boost their viewership and subscription engagement. With Ateme solutions you can build a platform for streaming, OTT or IPTV services that captivates your audience with outstanding experiences. And you do that while saving infrastructure costs and minimizing your carbon footprint. Ateme helps by improving your end user experiences, reduces TCO, and simplifies operation. With the partnership with Akamai, Ateme expands its video streaming ecosystem offering further with Akamai Cloud and CDN. -Ateme’s solutions are deployed by professional service teams on Akamai Cloud. The solution leverages Akamai shared compute instances, Block Storage, VLANs & Node Balancer, LKE Kubernetes platform, and Akamai CDN. +Ateme's solutions are deployed by professional service teams on Akamai Cloud. The solution leverages Akamai shared compute instances, Block Storage, VLANs & Node Balancer, LKE Kubernetes platform, and Akamai CDN. ### **Benefits** -* **Offer a premium viewing experience:** Deliver the highest quality content at the lowest bitrate to delight your audiences with all kinds of content – including UHD, 4K, and HDR. Ensure low latency on all platforms and broadcast-level latency on streaming platforms, with reduced rebuffering. +* **Offer a premium viewing experience:** Deliver the highest quality content at the lowest bitrate to delight your audiences with all kinds of content - including UHD, 4K, and HDR. Ensure low latency on all platforms and broadcast-level latency on streaming platforms, with reduced rebuffering. * **Outstanding video experiences:** Launch broadcast-quality, low-latency TV services quickly and securely. Wow your audience with the ultimate viewing experience on any screen with 4K HDR and immersive sound. Engage with them based on personalized channels and interactive content enabled by 5G. * **Reduce total cost of ownership:** Save bandwidth and storage requirements with the unmatched compression efficiency of TITAN encoders/decoders, faster-than-real-time capabilities, and simultaneous processing of all formats. Reduce waste and complexity with a single workflow for both live and file transcoding. Optimize your delivery platform by repurposing available cloud-native resources for file transcoding. * **Go green:** Save bandwidth and storage requirements with the unmatched compression efficiency of Kyrion and TITAN. Optimize your delivery platform by repurposing available cloud-native resources for file transcoding. Reduce waste and complexity with a single workflow for both live and file transcoding. Go green – with no compromise on video quality. `.trim(); +const documentationMarkdown = ` +The Ateme Live OTT solution provides a robust, three-tier delivery workflow on Akamai. The process begins with the Cloud Gateway (TITAN Edge), which ingests live feeds via a public SRT stream. Next, the Live Transcoders (TITAN Live) encode the video pipeline into adaptive bitrate video layers, which are then delivered through CMAF ingest to the final component, the Live Packager (NEA Live). This component formats the content for seamless delivery to end users via the Akamai Global CDN and OTT video players. + +![Ateme Flow](/assets/marketplace/ateme-flow.jpeg) +`.trim(); + const pricingMarkdown = ` Pricing details will be discussed directly with the third-party provider Sales team after your request is received, and the third-party provider contacts you. Costs of the product you will be purchasing from the third-party provider will be charged by the third-party provider. For the referral motion, Akamai is not a party in the purchase contract. @@ -27,8 +33,9 @@ const supportMarkdown = ` For product support, reach out to the vendor directly. You can find contact information in the product documentation and on the vendor's website. `.trim(); -export const titan: ProductTabDetails = { +export const titanVideoProcessingAndCompression: ProductTabDetails = { overview: overviewMarkdown, + documentation: documentationMarkdown, pricing: pricingMarkdown, support: supportMarkdown, }; diff --git a/packages/manager/src/features/Marketplace/ProductDetails/pages/vod-encoder.ts b/packages/manager/src/features/Marketplace/ProductDetails/pages/vod-encoder.ts new file mode 100644 index 00000000000..082968617c5 --- /dev/null +++ b/packages/manager/src/features/Marketplace/ProductDetails/pages/vod-encoder.ts @@ -0,0 +1,74 @@ +/** + * Product tab details for slug vod-encoder. + * + * Content is provided as Markdown strings which are rendered at runtime. + */ + +import type { ProductTabDetails } from '.'; + +const overviewMarkdown = ` +Bitmovin's VOD Encoder is a fully managed SaaS solution that uses distributed processing and content-aware encoding to scale quickly and make content available in the highest quality at the lowest bitrates with the fastest turnaround times. + +With features like Per-Title, Per-Shot, and Multi-Pass encoding, Bitmovin enables video streaming companies to be more cost-efficient while providing an optimal viewing experience. It supports multiple codecs, including AV1, VP9, HEVC/H.265, and H.264, and can integrate with a range of DRMs to encode and secure content up to 8K UHD with HDR, including Dolby Vision. + +### Key features +- **Content-aware optimization:** Reduce total cost of ownership without sacrificing video quality through Per-Title and Per-Scene encoding that adapts bitrate ladders to each piece of content. +- **Cost-efficient, universal delivery:** Multi-codec outputs - including AV1, VP9, HEVC/H.265, and H.264 - create the most cost-efficient delivery to the widest range of viewing devices. +- **High-speed processing:** Leverage massively distributed, parallel processing that transcodes video up to 100x faster than real time, accelerating large-scale VOD workflows. +- **Optimized bitrate distribution:** Apply multi-pass and Smart Chunking features to eliminate unnecessary data use while maintaining top-tier Quality of Experience (QoE). +- **Advanced quality-based features:** Ensure premium visual and audio performance across devices with 4K and 8K UHD, HDR, Dolby Vision and Dolby Atmos support. + +### Use cases +**Monetization for Streaming Platforms** + +Enable multiple revenue streams across AVOD, SVOD, and TVOD models with seamless monetization workflows. Bitmovin's VOD Encoder integrates with major ad networks for Server-Side Ad Insertion (SSAI) and supports third-party subscriber management and paywall solutions to power subscription and transactional services. Built-in DRM and forensic watermarking protect premium content and revenue from unauthorized access or redistribution. + +**Cost-efficient Video on Demand** + +Reduce operating costs while maintaining top-tier video quality and workflow scalability. Bitmovin's VOD Encoder combines Per-Title and Per-Shot content-aware encoding with multi-codec output to minimize bitrate and storage usage without compromising performance. Automated orchestration intelligently distributes workloads across compute resources, enabling horizontal scaling and faster processing for large content libraries at a lower total cost of ownership. + +**Elevated Quality of Experience for Subscriber Retention** + +Enhance viewer satisfaction and reduce churn through superior streaming performance across all devices and network conditions. Bitmovin's VOD Encoder utilizes Multi-Pass, Smart Chunking, and Per-Title optimization to deliver consistently high-quality playback with reduced bandwidth requirements. Supporting resolutions up to 8K and advanced features like HDR and Dolby Vision, it helps streaming platforms strengthen brand loyalty, attract new audiences, and retain existing subscribers through exceptional visual experiences. + +Ready to deliver stunning video with our encoder? Contact our team to schedule a personalized demo and discuss your specific video streaming requirements. +`.trim(); + +const documentationMarkdown = ` +| Specification | Details | +| :---- | :---- | +| **Input formats** | MPEG-1/2/4, H.261, H.262, H.263, H.264, H.265, VP6, VP8, VP9, and more| +| **Input file formats** | MP4, MKV, MOV, AVI, MXF, LXF, GXF, MPEG-2 TS/PS, and more| +| **Input audio codecs** | AAC, MP3, DTS Express, FLAC, Dolby Digital, Dolby Atmos, and more| +| **Output file formats** | MPEG-2 TS, MP4, fMP4, MOV, WebM, CMAF, and more| +| **Output video codecs** | XDCAM HD 422 (MPEG-2), H.264, H.265, H.266, VP8, VP9, AV1, and more| +| **Output audio codecs** | AAC-LC, MP2, MP3, Vorbis, Dolby Digital (Plus), Dolby Atmos, and more| +| **DRM / Content protection** | DASH ClearKey, Multi-DRM (Widevine, PlayReady, Marlin, FairPlay), Forensic Watermarking, and more| +| **Subtitles & Closed captions** | WebVTT & OCR into WebVTT, CEA-608/708, Burnt-in Subtitles, and more| +| **Streaming protocols** | MPEG-DASH, Apple HLS, Progressive MP4, Smooth Streaming| +| **AI features** | AI Scene Analysis, Contextual Advertising, AI Vertical Video| +| **Innovative features** | Per-title, Multi-pass, Per-Shot encoding, Smart chunking, Multi-codec| + + + +#### VOD Encoder Streaming Workflow + +![VOD Encoder Streaming Workflow](/assets/marketplace/bitmovin-vod-encoder.jpeg) +`.trim(); + +const pricingMarkdown = ` +Pricing details will be discussed directly with the third-party provider Sales team after your request is received, and the third-party provider contacts you. Costs of the product you will be purchasing from the third-party provider will be charged by the third-party provider. For the referral motion, Akamai is not a party in the purchase contract. + +The full price of the product cost should be clarified between you and the third-party provider within the agreed upon terms and conditions of the purchase contract. +`.trim(); + +const supportMarkdown = ` +For product support, reach out to the vendor directly. You can find contact information in the product documentation and on the vendor's website. +`.trim(); + +export const vodEncoder: ProductTabDetails = { + overview: overviewMarkdown, + documentation: documentationMarkdown, + pricing: pricingMarkdown, + support: supportMarkdown, +}; diff --git a/packages/manager/src/features/Marketplace/products.ts b/packages/manager/src/features/Marketplace/products.ts index 6b8a0df8cd9..c5b205ed88f 100644 --- a/packages/manager/src/features/Marketplace/products.ts +++ b/packages/manager/src/features/Marketplace/products.ts @@ -35,6 +35,23 @@ export const PRODUCTS: Product[] = [ name: 'SaaS & APIs', }, }, + { + categories: ['Kubernetes', 'Enterprise', 'Other Software and APIs'], + id: 'cloudcasa', + name: 'CloudCasa', + partner: { + email: 'info@catalogicsoftware.com', + logoDarkMode: 'cloudcasa-dark.svg', + logoLightMode: 'cloudcasa-light.svg', + name: 'CloudCasa by Catalogic', + url: 'https://cloudcasa.io/', + }, + shortDescription: + 'CloudCasa is a Kubernetes-native data protection platform that delivers cost-effective backup, granular recovery, and mobility across cloud, on-prem, and edge environments.', + type: { + name: 'Kubernetes', + }, + }, { categories: [ 'Compute', @@ -80,6 +97,23 @@ export const PRODUCTS: Product[] = [ name: 'SaaS & APIs', }, }, + { + categories: ['Media & Entertainment, Gaming', 'Other Software and APIs'], + id: 'live-encoder', + name: 'Live Encoder', + partner: { + email: 'akamai@bitmovin.com', + logoDarkMode: 'bitmovin-dark.svg', + logoLightMode: 'bitmovin-light.svg', + name: 'Bitmovin', + url: 'https://bitmovin.com/live-encoding-live-streaming', + }, + shortDescription: + 'Power live experiences through Akamai and Bitmovin with unbeatable reliability, quality and scalability, ensuring platforms can support and stream any event size.', + type: { + name: 'SaaS & APIs', + }, + }, { categories: [ 'AI', @@ -106,6 +140,49 @@ export const PRODUCTS: Product[] = [ name: 'SaaS & APIs', }, }, + { + categories: [ + 'Data Analytics', + 'Media & Entertainment, Gaming', + 'Other Software and APIs', + ], + id: 'playback', + name: 'Playback', + partner: { + email: 'akamai@bitmovin.com', + logoDarkMode: 'bitmovin-dark.svg', + logoLightMode: 'bitmovin-light.svg', + name: 'Bitmovin', + url: 'https://bitmovin.com/video-player', + }, + shortDescription: + 'Stream in the highest quality across the widest range of devices, and enhance engagement and retention with real-time Analytics to identify issues and optimize video performance.', + type: { + name: 'SaaS & APIs', + }, + }, + { + categories: [ + 'Development Tools', + 'Enterprise', + 'Kubernetes', + 'Other Software and APIs', + ], + id: 'portainer', + name: 'Portainer', + partner: { + email: 'partners@portainer.io', + logoDarkMode: 'portainer-dark.svg', + logoLightMode: 'portainer-light.svg', + name: 'Portainer.io Limited', + url: 'https://www.portainer.io/', + }, + shortDescription: + 'Portainer is a universal container management platform that lets IT teams manage Docker and Kubernetes fleets across cloud and edge to enforce security and simplify operations at any scale.', + type: { + name: 'SaaS & APIs', + }, + }, { categories: ['AI', 'Enterprise'], id: 'rad-security-platform', @@ -191,8 +268,8 @@ export const PRODUCTS: Product[] = [ }, { categories: ['Media & Entertainment, Gaming'], - id: 'titan', - name: 'Titan', + id: 'titan-video-processing-and-compression', + name: 'TITAN Video Processing and Compression', partner: { email: 'inquiries@ateme.com', logoDarkMode: 'ateme-dark.svg', @@ -227,6 +304,23 @@ export const PRODUCTS: Product[] = [ name: 'SaaS & APIs', }, }, + { + categories: ['Media & Entertainment, Gaming', 'Other Software and APIs'], + id: 'vod-encoder', + name: 'VOD Encoder', + partner: { + email: 'akamai@bitmovin.com', + logoDarkMode: 'bitmovin-dark.svg', + logoLightMode: 'bitmovin-light.svg', + name: 'Bitmovin', + url: 'https://bitmovin.com/encoding-service', + }, + shortDescription: + 'Process entire content libraries in up to 100x real-time and minimize file size while maintaining the highest quality and security with support for the latest codecs.', + type: { + name: 'SaaS & APIs', + }, + }, { categories: ['Media & Entertainment, Gaming'], id: 'dynamic-ad-insertion', diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/SummaryPanel.test.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/SummaryPanel.test.tsx index 6ea7fc427be..e0500ff86b2 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/SummaryPanel.test.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/SummaryPanel.test.tsx @@ -162,9 +162,9 @@ describe('SummaryPanel', () => { expect(typeElement).toBeVisible(); }); - it('displays type: Enterprise if the nodebalancer is premium_40GB', () => { + it('displays type: Enterprise if the nodebalancer is premium_40gb', () => { queryMocks.useNodeBalancerQuery.mockReturnValue({ - data: nodeBalancerFactory.build({ type: 'premium_40GB' }), + data: nodeBalancerFactory.build({ type: 'premium_40gb' }), }); const { getByText } = renderWithTheme(); diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/SummaryPanel.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/SummaryPanel.tsx index 2ef256ac4c2..46b759d43b5 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/SummaryPanel.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/SummaryPanel.tsx @@ -112,7 +112,7 @@ export const SummaryPanel = () => { Type: {nodebalancer.type === 'common' && 'Basic'} {nodebalancer.type === 'premium' && 'Premium'} - {nodebalancer.type === 'premium_40GB' && 'Enterprise'} + {nodebalancer.type === 'premium_40gb' && 'Enterprise'} diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/MetricsTab/MetricsTab.tsx b/packages/manager/src/features/ObjectStorage/BucketDetail/MetricsTab/MetricsTab.tsx index bc7076fc263..32c76b1ba19 100644 --- a/packages/manager/src/features/ObjectStorage/BucketDetail/MetricsTab/MetricsTab.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketDetail/MetricsTab/MetricsTab.tsx @@ -3,16 +3,16 @@ import * as React from 'react'; import { CloudPulseDashboardWithFilters } from 'src/features/CloudPulse/Dashboard/CloudPulseDashboardWithFilters'; interface Props { - bucketName: string; + hostname: string; region: string; } -export const MetricsTab = ({ bucketName, region }: Props) => { +export const MetricsTab = ({ hostname, region }: Props) => { return ( ); }; diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectsTab/ObjectDetailsDrawer.tsx b/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectsTab/ObjectDetailsDrawer.tsx index 88d566f93ac..09e39c87f89 100644 --- a/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectsTab/ObjectDetailsDrawer.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectsTab/ObjectDetailsDrawer.tsx @@ -6,7 +6,6 @@ import * as React from 'react'; import { CopyTooltip } from 'src/components/CopyTooltip/CopyTooltip'; import { Link } from 'src/components/Link'; -import { useIsObjectStorageGen2Enabled } from 'src/features/ObjectStorage/hooks/useIsObjectStorageGen2Enabled'; import { useObjectStorageBuckets } from 'src/queries/object-storage/queries'; import { formatDate } from 'src/utilities/formatDate'; @@ -40,9 +39,8 @@ export const ObjectDetailsDrawer = React.memo( let formattedLastModified; const { data: profile } = useProfile(); - const { isObjectStorageGen2Enabled } = useIsObjectStorageGen2Enabled(); const { data: bucketsData, isLoading: isLoadingEndpointData } = - useObjectStorageBuckets(isObjectStorageGen2Enabled); + useObjectStorageBuckets(); const isLoadingEndpoint = isLoadingEndpointData || !bucketsData; diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/index.tsx b/packages/manager/src/features/ObjectStorage/BucketDetail/index.tsx index b1110d69e7f..3fead4de35e 100644 --- a/packages/manager/src/features/ObjectStorage/BucketDetail/index.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketDetail/index.tsx @@ -1,3 +1,4 @@ +import { useRegionQuery } from '@linode/queries'; import { BetaChip, CircleProgress, ErrorState } from '@linode/ui'; import { useParams } from '@tanstack/react-router'; import * as React from 'react'; @@ -9,7 +10,6 @@ import { SafeTabPanel } from 'src/components/Tabs/SafeTabPanel'; import { TabPanels } from 'src/components/Tabs/TabPanels'; import { Tabs } from 'src/components/Tabs/Tabs'; import { TanStackTabLinkList } from 'src/components/Tabs/TanStackTabLinkList'; -import { useIsObjectStorageGen2Enabled } from 'src/features/ObjectStorage/hooks/useIsObjectStorageGen2Enabled'; import { useFlags } from 'src/hooks/useFlags'; import { useTabs } from 'src/hooks/useTabs'; import { useCloudPulseServiceByServiceType } from 'src/queries/cloudpulse/services'; @@ -41,6 +41,7 @@ const BucketMetrics = React.lazy(() => const BUCKET_DETAILS_URL = '/object-storage/buckets/$clusterId/$bucketName'; const ENDPOINT_TYPES_WITH_NO_METRICS_SUPPORT = ['E0', 'E1']; +const OBJECT_STORAGE_METRICS_KEY = 'Object Storage'; export const BucketDetailLanding = React.memo(() => { const { bucketName, clusterId } = useParams({ @@ -48,23 +49,41 @@ export const BucketDetailLanding = React.memo(() => { }); const { aclpServices, objectStorageContextualMetrics } = useFlags(); - const { isObjectStorageGen2Enabled } = useIsObjectStorageGen2Enabled(); const { isError: aclpServiceError, isLoading: aclServiceLoading } = useCloudPulseServiceByServiceType('objectstorage', true); const { data: bucketsData, - isLoading, + isLoading: bucketsLoading, error, isPending, - } = useObjectStorageBuckets(isObjectStorageGen2Enabled); + } = useObjectStorageBuckets(); const bucket = bucketsData?.buckets.find(({ label }) => label === bucketName); + const { + data: region, + isLoading: regionLoading, + error: regionError, + } = useRegionQuery(bucket?.region || ''); + const { endpoint_type } = bucket ?? {}; const isGen2Endpoint = endpoint_type === 'E2' || endpoint_type === 'E3'; + const regionSupportsMetrics = region?.monitors?.metrics?.includes( + OBJECT_STORAGE_METRICS_KEY + ); + + const isBucketMetricsTabHidden = + !endpoint_type || + ENDPOINT_TYPES_WITH_NO_METRICS_SUPPORT.includes(endpoint_type) || + aclpServiceError || + !aclpServices?.objectstorage?.metrics?.enabled || + !objectStorageContextualMetrics || + !regionSupportsMetrics || + !!regionError; + const { handleTabChange, tabIndex, tabs, getTabIndex } = useTabs([ { title: 'Objects', @@ -82,17 +101,12 @@ export const BucketDetailLanding = React.memo(() => { { title: 'Metrics', to: `${BUCKET_DETAILS_URL}/metrics`, - hide: - !endpoint_type || - ENDPOINT_TYPES_WITH_NO_METRICS_SUPPORT.includes(endpoint_type) || - aclpServiceError || - !aclpServices?.objectstorage?.metrics?.enabled || - !objectStorageContextualMetrics, + hide: isBucketMetricsTabHidden, chip: aclpServices?.objectstorage?.metrics?.beta ? : null, }, ]); - if (isPending || isLoading || aclServiceLoading) { + if (isPending || bucketsLoading || regionLoading || aclServiceLoading) { return ; } @@ -148,7 +162,10 @@ export const BucketDetailLanding = React.memo(() => { {!!metricsTabIndex && ( - + )} diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDetailsDrawer.test.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDetailsDrawer.test.tsx index 88486866dba..26ac6c68455 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDetailsDrawer.test.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDetailsDrawer.test.tsx @@ -98,9 +98,6 @@ describe('BucketDetailsDrawer: Legacy UI', () => { selectedBucket={bucket} /> ), - options: { - flags: { objMultiCluster: false }, - }, }); expect(screen.getByText(bucket.label)).toBeInTheDocument(); @@ -122,9 +119,6 @@ describe('BucketDetailsDrawer: Legacy UI', () => { selectedBucket={bucket} /> ), - options: { - flags: { objMultiCluster: false }, - }, }); expect(screen.queryByText(bucket.label)).not.toBeInTheDocument(); @@ -139,9 +133,6 @@ describe('BucketDetailsDrawer: Legacy UI', () => { selectedBucket={bucket} /> ), - options: { - flags: { objMultiCluster: false }, - }, }); expect(screen.getByTestId('cluster')).toHaveTextContent(region.id); @@ -156,9 +147,6 @@ describe('BucketDetailsDrawer: Legacy UI', () => { selectedBucket={undefined} /> ), - options: { - flags: { objMultiCluster: false }, - }, }); expect(screen.getByText('Bucket Detail')).toBeInTheDocument(); @@ -176,7 +164,7 @@ describe('BucketDetailsDrawer: Legacy UI', () => { /> ), options: { - flags: { objMultiCluster: false }, + flags: { objectStorageGen2: { enabled: true } }, }, }); @@ -198,7 +186,7 @@ describe('BucketDetailsDrawer: Legacy UI', () => { /> ), options: { - flags: { objMultiCluster: false }, + flags: { objectStorageGen2: { enabled: true } }, }, }); @@ -227,7 +215,7 @@ describe('BucketDetailDrawer: Gen2 UI', () => { /> ), options: { - flags: { objMultiCluster: false, objectStorageGen2: { enabled: true } }, + flags: { objectStorageGen2: { enabled: true } }, }, }); @@ -254,7 +242,7 @@ describe('BucketDetailDrawer: Gen2 UI', () => { /> ), options: { - flags: { objMultiCluster: false, objectStorageGen2: { enabled: true } }, + flags: { objectStorageGen2: { enabled: true } }, }, }); diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketLanding.test.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketLanding.test.tsx deleted file mode 100644 index 2db84b05ae7..00000000000 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketLanding.test.tsx +++ /dev/null @@ -1,258 +0,0 @@ -import { screen, waitFor } from '@testing-library/react'; -import * as React from 'react'; - -import { - objectStorageBucketFactory, - objectStorageClusterFactory, -} from 'src/factories/objectStorage'; -import { makeResourcePage } from 'src/mocks/serverHandlers'; -import { http, HttpResponse, server } from 'src/mocks/testServer'; -import { renderWithTheme } from 'src/utilities/testHelpers'; - -import { BucketLanding } from './BucketLanding'; - -const queryMocks = vi.hoisted(() => ({ - useNavigate: vi.fn(() => vi.fn()), - useOrderV2: vi.fn().mockReturnValue({}), - useSearch: vi.fn(), -})); - -vi.mock('@tanstack/react-router', async () => { - const actual = await vi.importActual('@tanstack/react-router'); - return { - ...actual, - useNavigate: queryMocks.useNavigate, - useSearch: queryMocks.useSearch, - }; -}); - -vi.mock('src/hooks/useOrderV2', async () => { - const actual = await vi.importActual('src/hooks/useOrderV2'); - return { - ...actual, - useOrderV2: queryMocks.useOrderV2, - }; -}); - -describe('ObjectStorageLanding', () => { - beforeAll(() => { - server.listen(); - queryMocks.useSearch.mockReturnValue({ - order: 'asc', - orderBy: 'label', - }); - }); - afterEach(() => server.resetHandlers()); - afterAll(() => server.close()); - - it('renders a loading state', () => { - // Mock Buckets - server.use( - http.get('*/object-storage/buckets', () => { - return HttpResponse.json(makeResourcePage([])); - }) - ); - - renderWithTheme(); - - screen.getByTestId('circle-progress'); - }); - - it('renders an empty state', async () => { - // Mock Clusters - server.use( - http.get('*/object-storage/clusters', () => { - const clusters = objectStorageClusterFactory.buildList(4); - return HttpResponse.json(makeResourcePage(clusters)); - }) - ); - - // Mock Buckets - server.use( - http.get('*/object-storage/buckets/*', () => { - return HttpResponse.json(makeResourcePage([])); - }) - ); - - renderWithTheme(); - - await screen.findByTestId('placeholder-button'); - }); - - it('renders per-cluster errors', async () => { - objectStorageBucketFactory.resetSequenceNumber(); - objectStorageClusterFactory.resetSequenceNumber(); - - const downCluster = objectStorageClusterFactory.build({ - region: 'us-west', - }); - - // Mock Clusters - server.use( - http.get('*/object-storage/clusters', () => { - const upClusters = objectStorageClusterFactory.buildList(1, { - region: 'ap-south-1', - }); - return HttpResponse.json( - makeResourcePage([downCluster, ...upClusters]) - ); - }) - ); - - // Mock Buckets - server.use( - http.get( - '*/object-storage/buckets/cluster-1', - () => { - return HttpResponse.json([{ reason: 'Cluster offline!' }], { - status: 500, - }); - }, - { - once: true, - } - ), - http.get('*/object-storage/buckets/*', () => { - return HttpResponse.json( - makeResourcePage( - objectStorageBucketFactory.buildList(2, { cluster: 'ap-south-1' }) - ) - ); - }) - ); - - renderWithTheme(); - - await screen.findByText( - /^There was an error loading buckets in US, Fremont, CA/ - ); - }); - - it('renders general error state', async () => { - // Mock Clusters - server.use( - http.get('*/object-storage/clusters', () => { - const clusters = objectStorageClusterFactory.buildList(1); - return HttpResponse.json(makeResourcePage(clusters)); - }) - ); - - // Mock Buckets - server.use( - http.get('*/object-storage/buckets/*', () => { - return HttpResponse.json([{ reason: 'Cluster offline!' }], { - status: 500, - }); - }) - ); - renderWithTheme(); - - await screen.findByText(/^There was an error retrieving your buckets/); - }); - - it('renders rows for each Bucket', async () => { - const buckets = objectStorageBucketFactory.buildList(2); - queryMocks.useOrderV2.mockReturnValue({ - order: 'asc', - orderBy: 'label', - sortedData: buckets, - }); - - // Mock Clusters - server.use( - http.get('*/object-storage/clusters', () => { - const clusters = objectStorageClusterFactory.buildList(1); - return HttpResponse.json(makeResourcePage(clusters)); - }) - ); - - // Mock Buckets - server.use( - http.get('*/object-storage/buckets/*', () => { - return HttpResponse.json(makeResourcePage(buckets)); - }) - ); - - renderWithTheme(); - - await screen.findByText(buckets[0].label); - await screen.findByText(buckets[1].label); - }); - - it('renders a "Total usage" section using base2 calculations if there is more than one Bucket', async () => { - const buckets = objectStorageBucketFactory.buildList(2, { - size: 1024 * 1024 * 1024 * 5, // 5 GB in base2 (5 GiB) - }); - - // Mock Clusters - server.use( - http.get('*/object-storage/clusters', () => { - const clusters = objectStorageClusterFactory.buildList(1); - return HttpResponse.json(makeResourcePage(clusters)); - }) - ); - - // Mock Buckets - server.use( - http.get('*/object-storage/buckets/*', () => { - return HttpResponse.json(makeResourcePage(buckets)); - }) - ); - - renderWithTheme(); - - await screen.findByText(/Total storage used: 10 GB/); - }); - - it('renders error notice for multiple regions', async () => { - objectStorageBucketFactory.resetSequenceNumber(); - objectStorageClusterFactory.resetSequenceNumber(); - - // Create multiple down clusters in different regions - const downClusters = [ - objectStorageClusterFactory.build({ region: 'us-west' }), - objectStorageClusterFactory.build({ region: 'ap-south' }), - objectStorageClusterFactory.build({ region: 'eu-west' }), - ]; - - // Mock Clusters - server.use( - http.get('*/object-storage/clusters', () => { - const upCluster = objectStorageClusterFactory.build({ - region: 'us-east', - }); - return HttpResponse.json( - makeResourcePage([...downClusters, upCluster]) - ); - }) - ); - - // Mock bucket errors for each down cluster - server.use( - ...downClusters.map((cluster) => - http.get(`*/object-storage/buckets/${cluster.id}`, () => { - return HttpResponse.json([{ reason: 'Cluster offline!' }], { - status: 500, - }); - }) - ), - // Mock successful response for up cluster - http.get('*/object-storage/buckets/*', () => { - return HttpResponse.json( - makeResourcePage( - objectStorageBucketFactory.buildList(1, { cluster: 'us-east' }) - ) - ); - }) - ); - - renderWithTheme(); - - await waitFor(() => { - const errorRegions = ['US, Fremont, CA', 'SG, Singapore', 'GB, London']; - for (const region of errorRegions) { - expect(screen.queryByText(region)).toBeInTheDocument(); - } - }); - }); -}); diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketLanding.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketLanding.tsx deleted file mode 100644 index 3bfbb0c81ae..00000000000 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketLanding.tsx +++ /dev/null @@ -1,307 +0,0 @@ -import { useProfile, useRegionsQuery } from '@linode/queries'; -import { CircleProgress, ErrorState, Notice, Typography } from '@linode/ui'; -import { readableBytes, useOpenClose } from '@linode/utilities'; -import Grid from '@mui/material/Grid'; -import * as React from 'react'; -import { makeStyles } from 'tss-react/mui'; - -import { DocumentTitleSegment } from 'src/components/DocumentTitle'; -import { Link } from 'src/components/Link'; -import { TransferDisplay } from 'src/components/TransferDisplay/TransferDisplay'; -import { TypeToConfirmDialog } from 'src/components/TypeToConfirmDialog/TypeToConfirmDialog'; -import { useOrderV2 } from 'src/hooks/useOrderV2'; -import { - useDeleteBucketMutation, - useObjectStorageBuckets, -} from 'src/queries/object-storage/queries'; -import { isBucketError } from 'src/queries/object-storage/requests'; -import { - sendDeleteBucketEvent, - sendDeleteBucketFailedEvent, -} from 'src/utilities/analytics/customEventAnalytics'; - -import { CancelNotice } from '../CancelNotice'; -import { BucketDetailsDrawer } from './BucketDetailsDrawer'; -import { BucketLandingEmptyState } from './BucketLandingEmptyState'; -import { BucketTable } from './BucketTable'; - -import type { - APIError, - ObjectStorageBucket, - ObjectStorageCluster, - ObjectStorageEndpoint, -} from '@linode/api-v4'; -import type { Theme } from '@mui/material/styles'; - -interface Props { - isCreateBucketDrawerOpen?: boolean; -} - -const useStyles = makeStyles()((theme: Theme) => ({ - copy: { - marginTop: theme.spacing(), - }, -})); - -export const BucketLanding = (props: Props) => { - const { isCreateBucketDrawerOpen } = props; - const { data: profile } = useProfile(); - - const isRestrictedUser = profile?.restricted; - - const { - data: objectStorageBucketsResponse, - error: bucketsErrors, - isLoading: areBucketsLoading, - } = useObjectStorageBuckets(); - - const { mutateAsync: deleteBucket } = useDeleteBucketMutation(); - - const { classes } = useStyles(); - - const removeBucketConfirmationDialog = useOpenClose(); - const [isLoading, setIsLoading] = React.useState(false); - const [error, setError] = React.useState(undefined); - const [bucketDetailDrawerOpen, setBucketDetailDrawerOpen] = - React.useState(false); - const [selectedBucket, setSelectedBucket] = React.useState< - ObjectStorageBucket | undefined - >(undefined); - - const handleClickDetails = (bucket: ObjectStorageBucket) => { - setBucketDetailDrawerOpen(true); - setSelectedBucket(bucket); - }; - - const closeBucketDetailDrawer = () => { - setBucketDetailDrawerOpen(false); - }; - - const handleClickRemove = (bucket: ObjectStorageBucket) => { - setSelectedBucket(bucket); - setError(undefined); - removeBucketConfirmationDialog.open(); - }; - - const removeBucket = () => { - // This shouldn't happen, but just in case (and to get TS to quit complaining...) - if (!selectedBucket) { - return; - } - - setError(undefined); - setIsLoading(true); - - const { cluster, label } = selectedBucket; - - deleteBucket({ cluster, label }) - .then(() => { - removeBucketConfirmationDialog.close(); - setIsLoading(false); - - // @analytics - sendDeleteBucketEvent(cluster); - }) - .catch((e) => { - // @analytics - sendDeleteBucketFailedEvent(cluster); - - setIsLoading(false); - setError(e); - }); - }; - - const { - handleOrderChange, - order, - orderBy, - sortedData: orderedData, - } = useOrderV2({ - data: objectStorageBucketsResponse?.buckets, - initialRoute: { - defaultOrder: { - order: 'asc', - orderBy: 'label', - }, - from: '/object-storage/buckets', - }, - preferenceKey: 'object-storage-buckets', - }); - - const closeRemoveBucketConfirmationDialog = React.useCallback(() => { - removeBucketConfirmationDialog.close(); - }, [removeBucketConfirmationDialog]); - - const unavailableClusters = - objectStorageBucketsResponse?.errors.map((error) => - isBucketError(error) ? error.cluster : error.endpoint - ) || []; - - if (isRestrictedUser) { - return ; - } - - if (bucketsErrors) { - return ( - - ); - } - - if (areBucketsLoading || objectStorageBucketsResponse === undefined) { - return ; - } - - if (objectStorageBucketsResponse?.buckets.length === 0) { - return ( - <> - {unavailableClusters.length > 0 && ( - - )} - - - ); - } - - const totalUsage = sumBucketUsage(objectStorageBucketsResponse.buckets); - const bucketLabel = selectedBucket ? selectedBucket.label : ''; - - return ( - - - {unavailableClusters.length > 0 && ( - - )} - - - {/* If there's more than one Bucket, display the total usage. */} - {objectStorageBucketsResponse.buckets.length > 1 ? ( - - Total storage used: {readableBytes(totalUsage).formatted} - - ) : null} - 1 ? 8 : 18} - /> - - - - - Warning: Deleting a bucket is permanent and - can’t be undone. - - - - A bucket must be empty before deleting it. Please{' '} - - delete all objects - - , or use{' '} - - another tool - {' '} - to force deletion. - - {/* If the user is attempting to delete their last Bucket, remind them - that they will still be billed unless they cancel Object Storage in - Account Settings. */} - {objectStorageBucketsResponse?.buckets.length === 1 && ( - - )} - - - - ); -}; - -const RenderEmpty = () => { - return ; -}; - -interface UnavailableClustersDisplayProps { - unavailableClusters: (ObjectStorageCluster | ObjectStorageEndpoint)[]; -} - -const UnavailableClustersDisplay = React.memo( - ({ unavailableClusters }: UnavailableClustersDisplayProps) => { - const { data: regions } = useRegionsQuery(); - - const regionsAffected = unavailableClusters.map( - (cluster) => - regions?.find((region) => region.id === cluster.region)?.label ?? - cluster.region - ); - - return ; - } -); - -interface BannerProps { - regionsAffected: string[]; -} - -const Banner = React.memo(({ regionsAffected }: BannerProps) => { - const moreThanOneRegionAffected = regionsAffected.length > 1; - - return ( - - - There was an error loading buckets in{' '} - {moreThanOneRegionAffected - ? 'the following regions:' - : `${regionsAffected[0]}.`} -
    - {moreThanOneRegionAffected && - regionsAffected.map((thisRegion) => ( -
  • {thisRegion}
  • - ))} -
- If you have buckets in{' '} - {moreThanOneRegionAffected ? 'these regions' : regionsAffected[0]}, you - may not see them listed below. -
-
- ); -}); - -export const sumBucketUsage = (buckets: ObjectStorageBucket[]) => { - return buckets.reduce((acc, thisBucket) => { - acc += thisBucket.size; - return acc; - }, 0); -}; diff --git a/packages/manager/src/features/ObjectStorage/ObjectStorageLanding.tsx b/packages/manager/src/features/ObjectStorage/ObjectStorageLanding.tsx index e54bd60784a..00dd326b68f 100644 --- a/packages/manager/src/features/ObjectStorage/ObjectStorageLanding.tsx +++ b/packages/manager/src/features/ObjectStorage/ObjectStorageLanding.tsx @@ -30,11 +30,6 @@ const SummaryLanding = React.lazy(() => default: module.SummaryLanding, })) ); -const BucketLanding = React.lazy(() => - import('./BucketLanding/BucketLanding').then((module) => ({ - default: module.BucketLanding, - })) -); const AccessKeyLanding = React.lazy(() => import('./AccessKeyLanding/AccessKeyLanding').then((module) => ({ default: module.AccessKeyLanding, @@ -179,13 +174,9 @@ export const ObjectStorageLanding = () => { )} - {isObjMultiClusterEnabled ? ( - - ) : ( - - )} + { return ( - +
Content Stored diff --git a/packages/manager/src/features/ObjectStorage/utilities.test.ts b/packages/manager/src/features/ObjectStorage/utilities.test.ts index 7db2198d8d3..346aa230a9e 100644 --- a/packages/manager/src/features/ObjectStorage/utilities.test.ts +++ b/packages/manager/src/features/ObjectStorage/utilities.test.ts @@ -47,13 +47,13 @@ const mockHostname = 'my-bucket.linodeobjects.com'; describe('Object Storage utilities', () => { describe('generateObjectUrl', () => { it('returns the correct URL', () => { - expect(generateObjectUrl(mockHostname, 'my-object')).toBe( - 'https://my-bucket.linodeobjects.com/my-object' + expect(generateObjectUrl(mockHostname, 'my-folder/my-object')).toBe( + 'https://my-bucket.linodeobjects.com/my-folder/my-object' ); }); it('encodes the URL for special characters', () => { - expect(generateObjectUrl(mockHostname, 'my-object?')).toBe( - 'https://my-bucket.linodeobjects.com/my-object%3F' + expect(generateObjectUrl(mockHostname, 'my-object!@#$%^&*()_+<>.,')).toBe( + 'https://my-bucket.linodeobjects.com/my-object!@#$%25%5E&*()_+%3C%3E.,' ); }); }); diff --git a/packages/manager/src/features/ObjectStorage/utilities.ts b/packages/manager/src/features/ObjectStorage/utilities.ts index bd0bf35b84c..2b3bf462d6b 100644 --- a/packages/manager/src/features/ObjectStorage/utilities.ts +++ b/packages/manager/src/features/ObjectStorage/utilities.ts @@ -6,7 +6,7 @@ import type { ObjectStorageEndpoint } from '@linode/api-v4/lib/object-storage'; import type { FormikProps } from 'formik'; export const generateObjectUrl = (hostname: string, objectName: string) => { - return `https://${hostname}/${encodeURIComponent(objectName)}`; + return `https://${hostname}/${encodeURI(objectName)}`; }; // Objects ending with a / and having a size of 0 are often used to represent diff --git a/packages/manager/src/features/OneClickApps/oneClickApps.ts b/packages/manager/src/features/OneClickApps/oneClickApps.ts index 4042bdd713c..0d4ec40f5f2 100644 --- a/packages/manager/src/features/OneClickApps/oneClickApps.ts +++ b/packages/manager/src/features/OneClickApps/oneClickApps.ts @@ -68,7 +68,7 @@ export const oneClickApps: Record = { 401702: { alt_description: 'React and Node.js stack.', alt_name: 'Web stack', - categories: [], + categories: ['Stacks'], colors: { end: '256291', start: '30383a', @@ -479,7 +479,7 @@ export const oneClickApps: Record = { alt_description: 'Video / media library storage and sharing across TVs, phones, computers, and more.', alt_name: 'Media server', - categories: [], + categories: ['Media and Entertainment'], colors: { end: '332c37', start: 'e5a00d', @@ -1831,7 +1831,6 @@ export const oneClickApps: Record = { start: '85A355', }, description: `Distributed, masterless, replicating NoSQL database cluster.`, - isNew: false, logo_url: 'apachecassandra.svg', related_guides: [ { @@ -1891,7 +1890,6 @@ export const oneClickApps: Record = { start: 'AAAAAA', }, description: `High performance, BSD license key/value database.`, - isNew: false, logo_url: 'valkey.svg', related_guides: [ { @@ -1911,7 +1909,6 @@ export const oneClickApps: Record = { start: 'FFBA01', }, description: `OSI approved open source secrets platform.`, - isNew: false, logo_url: 'openbao.svg', related_guides: [ { @@ -1931,7 +1928,6 @@ export const oneClickApps: Record = { start: '9D29FB', }, description: `Time series database supporting native query and visualization.`, - isNew: false, logo_url: 'influxdb.svg', related_guides: [ { @@ -1952,7 +1948,6 @@ export const oneClickApps: Record = { start: '1D678F', }, description: `Fast, open-source unified analytics engine for large-scale data processing.`, - isNew: true, logo_url: 'apachespark.svg', related_guides: [ { @@ -1993,7 +1988,6 @@ export const oneClickApps: Record = { start: '9BF0E1', }, description: `Platform for building developer portals designed to simplify and unify software development processes.`, - isNew: true, logo_url: 'backstage.svg', related_guides: [ { @@ -2014,7 +2008,6 @@ export const oneClickApps: Record = { }, description: 'Netfoundry Edge Router is a software gateway that enables secure, zero-trust, high-performance connectivity between local networks, cloud environments, and applications.', - isNew: true, logo_url: 'netfoundry.svg', related_guides: [ { @@ -2036,7 +2029,6 @@ export const oneClickApps: Record = { }, description: 'ArangoDB is a multi-model database that combines the power of graphs, documents, and key-value pairs in a single engine. It provides ACID transactions, flexible data modeling, and powerful query capabilities with AQL (ArangoDB Query Language). Perfect for applications requiring complex data relationships, real-time analytics, and scalable performance.', - isNew: true, logo_url: 'arangodb.svg', related_guides: [ { @@ -2059,7 +2051,6 @@ export const oneClickApps: Record = { }, description: 'Memgraph is a high-performance, in-memory graph database designed for real-time analytics and streaming data. It uses the Cypher query language and supports powerful graph algorithms out of the box. With built-in support for streaming data, Memgraph makes it easy to build apps that respond instantly to changes in complex connected data.', - isNew: true, logo_url: 'memgraph.svg', related_guides: [ { @@ -2082,7 +2073,6 @@ export const oneClickApps: Record = { }, description: 'Neo4j is a high-performance, open-source property graph database designed to store and query data as a network of connected nodes and relationships. It is optimized for handling complex, interconnected data and supports the Cypher query language for expressive and efficient graph queries.', - isNew: true, logo_url: 'neo4j.svg', related_guides: [ { @@ -2121,7 +2111,6 @@ export const oneClickApps: Record = { start: 'ffffff', }, description: `Cribl Stream is an observability pipeline that helps organizations collect, reduce, enrich, and route telemetry data in real-time. It connects with 80+ sources and destinations, enabling you to handle data from any source to any analytics tool. Cribl Stream helps reduce data volume and optimize log processing to cut costs, enhance data security with encryption and access controls, and transform data using AI-powered tools. The platform scales from small to enterprise-level deployments and acts as a universal data management layer, giving organizations more control and efficiency in handling their telemetry data across various systems.`, - isNew: true, logo_url: 'cribl.svg', related_guides: [ { @@ -2141,7 +2130,6 @@ export const oneClickApps: Record = { start: '648c19', }, description: `Jaeger all-in-one is a complete distributed tracing solution deployed as a single Docker container that includes the Jaeger UI, Collector, Query service, Agent, and in-memory storage. This integrated setup is designed for development, testing, and quick deployment scenarios where you need full tracing capabilities without complex distributed architecture. Jaeger helps developers track request flows across microservices, identify performance bottlenecks, analyze service dependencies, and troubleshoot errors in distributed applications. The all-in-one image supports various tracing protocols including Zipkin and Jaeger's own formats, making it ideal for getting started with distributed tracing.`, - isNew: true, logo_url: 'jaeger.svg', related_guides: [ { @@ -2179,7 +2167,7 @@ export const oneClickApps: Record = { alt_description: 'Weaviate is an open-source vector database designed to store and index both data objects and their vector embeddings.', alt_name: 'Open-source vector database.', - categories: ['Vector Databases'], + categories: ['Databases', 'Vector Databases', 'AI'], colors: { end: 'c4d132', start: '53b83d', @@ -2202,7 +2190,7 @@ export const oneClickApps: Record = { alt_description: 'ChromaDB is an open-source vector database designed for building AI applications. It lets you easily store, index, and query embeddings.', alt_name: 'Open-source vector database.', - categories: ['Vector Databases'], + categories: ['Databases', 'Vector Databases', 'AI'], colors: { end: 'ffde2c', start: '327eff', @@ -2243,4 +2231,118 @@ export const oneClickApps: Record = { summary: 'Open-source AI web and chat interface.', website: 'https://docs.openwebui.com/getting-started/', }, + 1972184: { + alt_description: + 'PostgreSQL extension for vector similarity search and AI embeddings.', + alt_name: 'Open-source vector database extension', + categories: ['Databases', 'Vector Databases', 'AI'], + colors: { + end: '336791', + start: '4ea8de', + }, + description: + 'pgvector is an open-source PostgreSQL extension that enables vector similarity search directly within your database. It allows you to store embeddings and perform efficient nearest neighbor queries, making it ideal for AI applications such as semantic search, recommendation systems, and retrieval-augmented generation (RAG).', + isNew: true, + logo_url: 'pgvector.svg', + related_guides: [ + { + href: 'https://www.linode.com/docs/marketplace-docs/guides/pgvector', + title: 'Deploy pgvector', + }, + ], + summary: + 'PostgreSQL extension for vector similarity search and AI workloads.', + website: 'https://github.com/pgvector/pgvector', + }, + 2011033: { + alt_description: + 'Open-source vector database built for scalable AI similarity search.', + alt_name: 'Open-source vector database', + categories: ['Databases', 'Vector Databases', 'AI'], + colors: { + end: '00bfa6', + start: '1f2937', + }, + description: + 'Milvus is an open-source vector database designed for high-performance similarity search and AI workloads. It enables you to store, index, and search massive embedding datasets efficiently, making it ideal for semantic search, recommendation systems, computer vision, and retrieval-augmented generation (RAG) applications.', + isNew: true, + logo_url: 'milvus.svg', + related_guides: [ + { + href: 'https://www.linode.com/docs/marketplace-docs/guides/milvus', + title: 'Deploy Milvus', + }, + ], + summary: + 'High-performance open-source vector database for AI applications.', + website: 'https://milvus.io/', + }, + 2011885: { + alt_description: + 'Open-source large language model for self-hosted AI inference.', + alt_name: 'Open-source LLM', + categories: ['Chat', 'LLM', 'AI'], + colors: { + end: '111827', + start: '10b981', + }, + description: + 'GPT-OSS is an open-source large language model designed for self-hosted AI inference and application development. It enables organizations to deploy powerful text generation, chat, and reasoning capabilities on their own infrastructure, supporting use cases such as chatbots, code generation, content creation, and AI-powered automation.', + isNew: true, + logo_url: 'gptoss.svg', + related_guides: [ + { + href: 'https://www.linode.com/docs/marketplace-docs/guides/gpt-oss-with-openwebui', + title: 'Deploy GPT-OSS', + }, + ], + summary: 'Open-source large language model for self-hosted AI workloads.', + website: 'https://github.com/open-source-gpt/gpt-oss', + }, + 1997012: { + alt_description: + 'Lightweight open large language model optimized for efficient AI inference.', + alt_name: 'Open large language model', + categories: ['Chat', 'LLM', 'AI'], + colors: { + end: '1a73e8', + start: '34a853', + }, + description: + 'Gemma 3 is an open large language model designed for efficient, high-performance AI inference across a variety of workloads. Optimized for text generation, chat, and reasoning tasks, it enables developers to deploy scalable AI applications such as assistants, code generation tools, and content automation directly on their own infrastructure.', + isNew: true, + logo_url: 'gemma3.svg', + related_guides: [ + { + href: 'https://www.linode.com/docs/marketplace-docs/guides/gemma3', + title: 'Deploy Gemma 3', + }, + ], + summary: + 'Efficient open large language model for self-hosted AI applications.', + website: 'https://ai.google.dev/gemma', + }, + 2015845: { + alt_description: + 'Open large language model series for advanced reasoning, coding, and chat.', + alt_name: 'Open large language model', + categories: ['Chat', 'LLM', 'AI'], + colors: { + end: '1f2937', + start: '7c3aed', + }, + description: + 'Qwen is an open large language model series designed for high-performance text generation, reasoning, and coding tasks. It supports chat-based interactions, instruction following, and advanced AI application development, making it well-suited for assistants, code generation tools, automation workflows, and enterprise AI deployments.', + isNew: true, + logo_url: 'qwen.svg', + related_guides: [ + { + href: 'https://www.linode.com/docs/marketplace-docs/guides/qwen/', + title: 'Deploy Qwen with Open WebUI', + }, + ], + summary: + 'Open large language model for reasoning, coding, and chat applications.', + website: 'https://qwenlm.github.io/', + }, }; diff --git a/packages/manager/src/features/Search/useAPISearch.ts b/packages/manager/src/features/Search/useAPISearch.ts index 9c225c188d3..53520e6b928 100644 --- a/packages/manager/src/features/Search/useAPISearch.ts +++ b/packages/manager/src/features/Search/useAPISearch.ts @@ -13,6 +13,7 @@ import { import { getAPIFilterFromQuery } from '@linode/search'; import { useDebouncedValue } from '@linode/utilities'; +import { useIsACLPLogsEnabled } from 'src/features/Delivery/deliveryUtils'; import { useKubernetesClustersInfiniteQuery } from 'src/queries/kubernetes'; import { databaseToSearchableItem, @@ -119,6 +120,7 @@ const entities = [ searchOptions: { searchableFieldsWithoutOperator: ['label'], }, + requireACLPLogsEnabled: true, }, { getSearchableItem: destinationToSearchableItem, @@ -127,6 +129,7 @@ const entities = [ searchOptions: { searchableFieldsWithoutOperator: ['label'], }, + requireACLPLogsEnabled: true, }, ]; @@ -142,11 +145,12 @@ const entities = [ * and do the filtering client-side. */ export const useAPISearch = ({ enabled, query }: Props) => { - const deboundedQuery = useDebouncedValue(query); + const debouncedQuery = useDebouncedValue(query); + const { isACLPLogsEnabled } = useIsACLPLogsEnabled(); const result = entities.map((entity) => { const { error, filter } = getAPIFilterFromQuery( - deboundedQuery, + debouncedQuery, entity.searchOptions ); @@ -155,7 +159,10 @@ export const useAPISearch = ({ enabled, query }: Props) => { parseError: error, ...entity.query( entity.baseFilter ? { ...entity.baseFilter, ...filter } : filter, - enabled && error === null && Boolean(deboundedQuery) + enabled && + error === null && + Boolean(debouncedQuery) && + (!entity.requireACLPLogsEnabled || isACLPLogsEnabled) ), }; }); diff --git a/packages/manager/src/features/Search/useClientSideSearch.ts b/packages/manager/src/features/Search/useClientSideSearch.ts index fa885192829..3f86b7584ac 100644 --- a/packages/manager/src/features/Search/useClientSideSearch.ts +++ b/packages/manager/src/features/Search/useClientSideSearch.ts @@ -11,6 +11,7 @@ import { useAllVolumesQuery, } from '@linode/queries'; +import { useIsACLPLogsEnabled } from 'src/features/Delivery/deliveryUtils'; import { useAllKubernetesClustersQuery } from 'src/queries/kubernetes'; import { useObjectStorageBuckets } from 'src/queries/object-storage/queries'; import { @@ -28,6 +29,7 @@ import { volumeToSearchableItem, } from 'src/store/selectors/getSearchEntities'; +import { useIsPrivateImageSharingEnabled } from '../Images/utils'; import { search } from './utils'; import type { SearchableEntityType } from './search.interfaces'; @@ -42,6 +44,9 @@ interface Props { * based on a user's seach query. */ export const useClientSideSearch = ({ enabled, query }: Props) => { + const { isPrivateImageSharingEnabled } = useIsPrivateImageSharingEnabled(); + const { isACLPLogsEnabled } = useIsACLPLogsEnabled(); + const { data: domains, error: domainsError, @@ -93,16 +98,19 @@ export const useClientSideSearch = ({ enabled, query }: Props) => { data: streams, error: streamsError, isLoading: streamsLoading, - } = useAllStreamsQuery({}, {}, enabled); + } = useAllStreamsQuery({}, {}, enabled && isACLPLogsEnabled); const { data: destinations, error: destinationsError, isLoading: destinationsLoading, - } = useAllDestinationsQuery({}, {}, enabled); + } = useAllDestinationsQuery({}, {}, enabled && isACLPLogsEnabled); const searchableDomains = domains?.map(domainToSearchableItem) ?? []; const searchableVolumes = volumes?.map(volumeToSearchableItem) ?? []; - const searchableImages = privateImages?.map(imageToSearchableItem) ?? []; + const searchableImages = + privateImages?.map((img) => + imageToSearchableItem(img, isPrivateImageSharingEnabled) + ) ?? []; const searchableNodebalancers = nodebals?.map(nodeBalToSearchableItem) ?? []; const searchableFirewalls = firewalls?.map(firewallToSearchableItem) ?? []; const searchableDatabases = databases?.map(databaseToSearchableItem) ?? []; diff --git a/packages/manager/src/features/TopMenu/UserMenu/UserMenu.tsx b/packages/manager/src/features/TopMenu/UserMenu/UserMenu.tsx index 4d1ad28e8bf..9cae73e0e82 100644 --- a/packages/manager/src/features/TopMenu/UserMenu/UserMenu.tsx +++ b/packages/manager/src/features/TopMenu/UserMenu/UserMenu.tsx @@ -57,8 +57,7 @@ export const UserMenu = React.memo(() => { const { data: parentProfile } = useProfile({ headers: proxyHeaders }); - const userName = - (isProxyOrDelegateUserType ? parentProfile : profile)?.username ?? ''; + const userName = (isProxyUserType ? parentProfile : profile)?.username ?? ''; const matchesSmDown = useMediaQuery((theme: Theme) => theme.breakpoints.down('sm') @@ -84,9 +83,10 @@ export const UserMenu = React.memo(() => { setStorage('is_delegate_user_type', 'true'); } - enqueueSnackbar(`Account switched to ${companyNameOrEmail}.`, { - variant: 'success', - }); + const message = companyNameOrEmail + ? `Account switched to ${companyNameOrEmail}.` + : 'Account switched.'; + enqueueSnackbar(message, { variant: 'success' }); } }, [ isProxyOrDelegateUserType, diff --git a/packages/manager/src/features/TopMenu/UserMenu/UserMenuPopover.tsx b/packages/manager/src/features/TopMenu/UserMenu/UserMenuPopover.tsx index 8fc36fdae33..d5b6997d94e 100644 --- a/packages/manager/src/features/TopMenu/UserMenu/UserMenuPopover.tsx +++ b/packages/manager/src/features/TopMenu/UserMenu/UserMenuPopover.tsx @@ -224,6 +224,7 @@ export const UserMenuPopover = (props: UserMenuPopoverProps) => { backgroundColor: theme.tokens.alias.Background.Normal, paddingX: theme.tokens.spacing.S24, paddingY: theme.tokens.spacing.S16, + maxWidth: 304, }), }, }} @@ -235,8 +236,11 @@ export const UserMenuPopover = (props: UserMenuPopoverProps) => { gap={(theme) => theme.tokens.spacing.S16} minWidth={250} > - theme.tokens.spacing.S8}> - {canSwitchBetweenParentOrProxyAccount && ( + (companyNameOrEmail ? theme.tokens.spacing.S8 : 0)} + > + {canSwitchBetweenParentOrProxyAccount && companyNameOrEmail && ( ({ color: theme.tokens.alias.Content.Text.Primary.Default, @@ -250,10 +254,11 @@ export const UserMenuPopover = (props: UserMenuPopoverProps) => { sx={(theme) => ({ color: theme.tokens.alias.Content.Text.Primary.Default, font: theme.tokens.alias.Typography.Label.Bold.L, + overflowWrap: 'break-word', })} > - {canSwitchBetweenParentOrProxyAccount && companyNameOrEmail - ? companyNameOrEmail + {canSwitchBetweenParentOrProxyAccount + ? companyNameOrEmail || null : userName} {canSwitchBetweenParentOrProxyAccount && ( diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.tsx index 89d105ae4c8..6787031e6dc 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.tsx @@ -29,6 +29,7 @@ import { Link } from 'src/components/Link'; import { RemovableSelectionsListTable } from 'src/components/RemovableSelectionsList/RemovableSelectionsListTable'; import { FirewallSelect } from 'src/features/Firewalls/components/FirewallSelect'; import { useGetAllUserEntitiesByPermission } from 'src/features/IAM/hooks/useGetAllUserEntitiesByPermission'; +import { WARNING_MESSAGE_FOR_NO_FIREWALL_OPTION } from 'src/features/Linodes/constants'; import { getDefaultFirewallForInterfacePurpose } from 'src/features/Linodes/LinodeCreate/Networking/utilities'; import { REMOVABLE_SELECTIONS_LINODES_TABLE_HEADERS, @@ -753,6 +754,9 @@ export const SubnetAssignLinodesDrawer = ( setFieldValue('selectedFirewall', firewall?.id) } value={values.selectedFirewall} + warningMessageForNoFirewallOption={ + WARNING_MESSAGE_FOR_NO_FIREWALL_OPTION + } /> )} diff --git a/packages/manager/src/features/VPCs/VPCDetail/VPCSubnetsTable.tsx b/packages/manager/src/features/VPCs/VPCDetail/VPCSubnetsTable.tsx index fe49e01abee..37fea394caa 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/VPCSubnetsTable.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/VPCSubnetsTable.tsx @@ -315,6 +315,13 @@ export const VPCSubnetsTable = (props: Props) => { const getTableItems = (): TableItem[] => { return subnets.data.map((subnet) => { + // NodeBalancers can have same VPC subnet as its frontend and backend configuration. + // and this can create duplicate entries in the resources count and also in the list of nodebalancers assigned to a subnet. + // To avoid this, we are creating a unique list of nodebalancers based on their id. + const uniqueNodebalancers = Array.from( + new Map(subnet.nodebalancers.map((nb) => [nb.id, nb])).values() + ); + const OuterTableCells = ( <> @@ -326,7 +333,7 @@ export const VPCSubnetsTable = (props: Props) => { )} - {`${isNodebalancerVPCEnabled ? subnet.linodes.length + subnet.nodebalancers.length : subnet.linodes.length}`} + {`${isNodebalancerVPCEnabled ? subnet.linodes.length + uniqueNodebalancers.length : subnet.linodes.length}`} @@ -336,7 +343,7 @@ export const VPCSubnetsTable = (props: Props) => { handleEdit={handleSubnetEdit} handleUnassignLinodes={handleSubnetUnassignLinodes} numLinodes={subnet.linodes.length} - numNodebalancers={subnet.nodebalancers.length} + numNodebalancers={uniqueNodebalancers.length} subnet={subnet} vpcId={vpcId} /> @@ -376,7 +383,7 @@ export const VPCSubnetsTable = (props: Props) => { )}
- {isNodebalancerVPCEnabled && subnet.nodebalancers?.length > 0 && ( + {isNodebalancerVPCEnabled && uniqueNodebalancers.length > 0 && ( { {SubnetNodebalancerTableRowHead} - {subnet.nodebalancers.map((nb) => ( + {uniqueNodebalancers.map((nb) => ( { useCloudPulseServiceByServiceType('blockstorage', true); const { volumeId } = useParams({ from: '/volumes/$volumeId' }); - const { data: volume, isLoading, error } = useVolumeQuery(volumeId); + const { + data: volume, + isLoading: volumeLoading, + error: volumeError, + } = useVolumeQuery(volumeId); + const { + data: region, + isLoading: regionLoading, + error: regionError, + } = useRegionQuery(volume?.region || ''); + + const regionSupportsMetrics = region?.monitors?.metrics?.includes( + BLOCK_STORAGE_METRICS_KEY + ); + + const isVolumeMetricsTabHidden = + aclpServiceError || + !blockStorageContextualMetrics || + !aclpServices?.blockstorage?.metrics?.enabled || + !regionSupportsMetrics || + !!regionError; + const { tabs, handleTabChange, tabIndex } = useTabs([ { to: '/volumes/$volumeId/summary', @@ -32,19 +54,16 @@ export const VolumeDetails = () => { { to: '/volumes/$volumeId/metrics', title: 'Metrics', - hide: - aclpServiceError || - !blockStorageContextualMetrics || - !aclpServices?.blockstorage?.metrics?.enabled, + hide: isVolumeMetricsTabHidden, chip: aclpServices?.blockstorage?.metrics?.beta ? : null, }, ]); - if (!volumeSummaryPage || error) { - return ; + if (!volumeSummaryPage || volumeError) { + return ; } - if (isLoading || aclServiceLoading || !volume) { + if (volumeLoading || regionLoading || aclServiceLoading || !volume) { return ; } diff --git a/packages/manager/src/features/Volumes/VolumeDrawers/ResizeVolumeDrawer.tsx b/packages/manager/src/features/Volumes/VolumeDrawers/ResizeVolumeDrawer.tsx index 165b4a7e742..d82b04445fa 100644 --- a/packages/manager/src/features/Volumes/VolumeDrawers/ResizeVolumeDrawer.tsx +++ b/packages/manager/src/features/Volumes/VolumeDrawers/ResizeVolumeDrawer.tsx @@ -5,6 +5,7 @@ import { useFormik } from 'formik'; import { useSnackbar } from 'notistack'; import React from 'react'; +import { Link } from 'src/components/Link'; import { usePermissions } from 'src/features/IAM/hooks/usePermissions'; import { useEventsPollingActions } from 'src/queries/events/events'; import { @@ -35,6 +36,7 @@ export const ResizeVolumeDrawer = (props: Props) => { volume?.id ); const canResizeVolume = permissions?.resize_volume; + const isVolumeAttached = volume?.linode_id || volume?.linode_label; const { mutateAsync: resizeVolume } = useResizeVolumeMutation(); @@ -123,6 +125,20 @@ export const ResizeVolumeDrawer = (props: Props) => { regionId={volume?.region ?? ''} value={values.size ?? -1} /> + + {isVolumeAttached && ( + + Detach this volume from a Linode before resizing to prevent data + loss. For maximum safety, power off the Linode first.{' '} + + Learn more about volume resizing. + + + )} + { expect(mockNavigate).not.toHaveBeenCalled(); }); - it('should handle page clamping to 1 for empty data', async () => { + it('should not reset page when data is empty (e.g. still loading) to preserve URL on reload', async () => { queryClient.setQueryData(['profile', 'preferences'], { pageSizes: { 'test-key': 25 }, }); @@ -826,9 +826,9 @@ describe('usePaginationV2', () => { expect(result.current.page).toBe(1); }); - await waitFor(() => { - expect(mockNavigate).toHaveBeenCalled(); - }); + // Should not trigger navigation when totalCount is 0 so that page from URL + // is preserved on reload while data is still loading + expect(mockNavigate).not.toHaveBeenCalled(); }); it('should auto-reset page when data changes and current page becomes invalid', async () => { diff --git a/packages/manager/src/hooks/usePaginationV2.ts b/packages/manager/src/hooks/usePaginationV2.ts index 09b224a0617..ae794ad1214 100644 --- a/packages/manager/src/hooks/usePaginationV2.ts +++ b/packages/manager/src/hooks/usePaginationV2.ts @@ -143,10 +143,15 @@ export const usePaginationV2 = ({ }, [clientSidePaginationData, clampedPage, pageSize]); React.useEffect(() => { - if (paginatedData !== undefined && clampedPage !== page) { + if ( + paginatedData !== undefined && + totalCount !== undefined && + totalCount > 0 && + clampedPage !== page + ) { setPage(clampedPage); } - }, [clampedPage, page, paginatedData, setPage]); + }, [clampedPage, page, paginatedData, setPage, totalCount]); return { handlePageChange: setPage, diff --git a/packages/manager/src/mocks/presets/crud/handlers/delivery.ts b/packages/manager/src/mocks/presets/crud/handlers/delivery.ts index ec62a6204e3..5b0e39e0a7e 100644 --- a/packages/manager/src/mocks/presets/crud/handlers/delivery.ts +++ b/packages/manager/src/mocks/presets/crud/handlers/delivery.ts @@ -34,7 +34,7 @@ import type { export const getStreams = () => [ http.get( - '*/v4beta/monitor/streams', + '*/v4/monitor/streams', async ({ request, }): Promise< @@ -53,7 +53,7 @@ export const getStreams = () => [ } ), http.get( - '*/v4beta/monitor/streams/:id', + '*/v4/monitor/streams/:id', async ({ params }): Promise> => { const id = Number(params.id); const stream = await mswDB.get('streams', id); @@ -69,7 +69,7 @@ export const getStreams = () => [ export const createStreams = (mockState: MockState) => [ http.post( - '*/v4beta/monitor/streams', + '*/v4/monitor/streams', async ({ request }): Promise> => { const payload = await request.clone().json(); const destinations = await mswDB.getAll('destinations'); @@ -94,7 +94,7 @@ export const createStreams = (mockState: MockState) => [ id: stream.id, label: stream.label, type: 'stream', - url: `/v4beta/delivery/streams`, + url: `/v4/delivery/streams`, }, }, mockState, @@ -108,7 +108,7 @@ export const createStreams = (mockState: MockState) => [ export const updateStream = (mockState: MockState) => [ http.put( - '*/v4beta/monitor/streams/:id', + '*/v4/monitor/streams/:id', async ({ params, request, @@ -140,7 +140,7 @@ export const updateStream = (mockState: MockState) => [ id: stream.id, label: stream.label, type: 'stream', - url: `/v4beta/monitor/streams/${stream.id}`, + url: `/v4/monitor/streams/${stream.id}`, }, }, mockState, @@ -154,7 +154,7 @@ export const updateStream = (mockState: MockState) => [ export const deleteStream = (mockState: MockState) => [ http.delete( - '*/v4beta/monitor/streams/:id', + '*/v4/monitor/streams/:id', async ({ params }): Promise> => { const id = Number(params.id); const stream = await mswDB.get('streams', id); @@ -172,7 +172,7 @@ export const deleteStream = (mockState: MockState) => [ id: stream.id, label: stream.label, type: 'domain', - url: `/v4beta/monitor/streams/${stream.id}`, + url: `/v4/monitor/streams/${stream.id}`, }, }, mockState, @@ -186,7 +186,7 @@ export const deleteStream = (mockState: MockState) => [ export const getDestinations = () => [ http.get( - '*/v4beta/monitor/streams/destinations', + '*/v4/monitor/streams/destinations', async ({ request, }): Promise< @@ -205,7 +205,7 @@ export const getDestinations = () => [ } ), http.get( - '*/v4beta/monitor/streams/destinations/:id', + '*/v4/monitor/streams/destinations/:id', async ({ params, }): Promise> => { @@ -223,7 +223,7 @@ export const getDestinations = () => [ export const createDestinations = (mockState: MockState) => [ http.post( - '*/v4beta/monitor/streams/destinations', + '*/v4/monitor/streams/destinations', async ({ request, }): Promise> => { @@ -278,7 +278,7 @@ export const createDestinations = (mockState: MockState) => [ id: destination.id, label: destination.label, type: 'destination', - url: `/v4beta/delivery/streams/destinations`, + url: `/v4/delivery/streams/destinations`, }, }, mockState, @@ -292,7 +292,7 @@ export const createDestinations = (mockState: MockState) => [ export const updateDestination = (mockState: MockState) => [ http.put( - '*/v4beta/monitor/streams/destinations/:id', + '*/v4/monitor/streams/destinations/:id', async ({ params, request, @@ -322,7 +322,7 @@ export const updateDestination = (mockState: MockState) => [ id: destination.id, label: destination.label, type: 'stream', - url: `/v4beta/monitor/streams/${destination.id}`, + url: `/v4/monitor/streams/${destination.id}`, }, }, mockState, @@ -336,7 +336,7 @@ export const updateDestination = (mockState: MockState) => [ export const deleteDestination = (mockState: MockState) => [ http.delete( - '*/v4beta/monitor/streams/destinations/:id', + '*/v4/monitor/streams/destinations/:id', async ({ params }): Promise> => { const id = Number(params.id); const destination = await mswDB.get('destinations', id); @@ -367,7 +367,7 @@ export const deleteDestination = (mockState: MockState) => [ id: destination.id, label: destination.label, type: 'domain', - url: `/v4beta/monitor/streams/${destination.id}`, + url: `/v4/monitor/streams/${destination.id}`, }, }, mockState, @@ -381,7 +381,7 @@ export const deleteDestination = (mockState: MockState) => [ export const verifyDestination = () => [ http.post( - '*/v4beta/monitor/streams/destinations/verify', + '*/v4/monitor/streams/destinations/verify', async (): Promise> => { return makeResponse({}, 200); } diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index 30a2a54d24c..fb44b19121c 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -85,6 +85,8 @@ import { lkeEnterpriseTypeFactory, lkeHighAvailabilityTypeFactory, lkeStandardAvailabilityTypeFactory, + logsAlertMetricCriteria, + logsMetricCriteria, longviewActivePlanFactory, longviewClientFactory, longviewSubscriptionFactory, @@ -124,6 +126,7 @@ import { serviceTypesFactory, stackScriptFactory, staticObjects, + streamFactory, subnetFactory, supportReplyFactory, supportTicketFactory, @@ -214,32 +217,86 @@ const makeMockDatabase = (params: PathParams): Database => { db.ssl_connection = true; } - if (db.engine === 'postgresql') { - db.connection_pool_port = 100; /** @Deprecated replaced by `endpoints` property */ - } - const database = databaseFactory.build(db); - if (database.platform !== 'rdbms-default') { - delete database.private_network; - } + // Mock a database cluster with a public VPC Configuration + database.private_network = { + public_access: true, + subnet_id: 123, + vpc_id: 10, + }; - if (database.platform === 'rdbms-default' && !!database.private_network) { + if (database.private_network) { // When a database is configured with a VPC, the primary and standby hostnames are prepended with 'private-' in the backend database.hosts = { primary: 'private-db-mysql-primary-0.b.linodeb.net', standby: 'private-db-mysql-standby-0.b.linodeb.net', + /** + * The contents of the hosts.endpoints vary based off whether the VPC has public access or not. + * If private_network public_access is true, the endpoints should return both public and private addresses. + * If private_network public_access is false, the endpoints should only return private addresses. + */ endpoints: [ { - address: 'private-db-mysql-primary-0.b.linodeb.net', role: 'primary', - private_access: true, - port: 12345, + address: 'public-db-mysql-primary-0.b.linodeb.net', + port: 3306, + public_access: true, + }, + { + role: 'primary', + address: 'private-db-mysql-primary-0.b.linodeb.net', + port: 3306, + public_access: false, + }, + { + role: 'standby', + address: 'public-replica-db-mysql-standby-0.b.linodeb.net', + port: 3306, + public_access: true, + }, + { + role: 'standby', + address: 'private-replica-db-mysql-standby-0.b.linodeb.net', + port: 3306, + public_access: false, + }, + { + role: 'primary-connection-pool', + address: 'public-db-mysql-primary-0.b.linodeb.net', + port: 15848, + public_access: true, + }, + { + role: 'primary-connection-pool', + address: 'private-db-mysql-primary-0.b.linodeb.net', + port: 15848, + public_access: false, }, ], }; } + // Uncomment the lines below to mock a database cluster without a VPC configuration + // database.private_network = null; + // database.hosts = { + // primary: 'db-mysql-primary-0.b.linodeb.net', + // endpoints: [ + // { + // role: 'primary', + // address: 'db-mysql-primary-0.b.linodeb.net', + // port: 3306, + // public_access: true, + // }, + // { + // role: 'primary-connection-pool', + // address: 'public-db-mysql-primary-0.b.linodeb.net', + // port: 15848, + // public_access: true, + // }, + // ], + // }; + return database; }; @@ -3467,6 +3524,12 @@ export const handlers = [ rules: [firewallMetricRulesFactory.build()], }, }), + alertFactory.build({ + id: 494, + label: 'Logs-alert', + service_type: 'logs', + type: 'user', + }), ...alertFactory.buildList(3, { status: 'enabling', type: 'user' }), ...alertFactory.buildList(3, { status: 'disabling', type: 'user' }), ...alertFactory.buildList(3, { status: 'provisioning', type: 'user' }), @@ -3569,6 +3632,19 @@ export const handlers = [ }) ); } + if (params.id === '494' && params.serviceType === 'logs') { + return HttpResponse.json( + alertFactory.build({ + id: 494, + label: 'Logs-alert', + service_type: 'logs', + type: 'user', + rule_criteria: { + rules: [logsAlertMetricCriteria.build()], + }, + }) + ); + } if (params.id !== undefined) { return HttpResponse.json( alertFactory.build({ @@ -3840,6 +3916,12 @@ export const handlers = [ regions: 'us-iad,us-east,eu-west', alert: serviceAlertFactory.build({ scope: ['entity'] }), }), + serviceTypesFactory.build({ + label: 'Logs', + service_type: 'logs', + regions: undefined, + alert: serviceAlertFactory.build({ scope: ['entity'] }), + }), ], }; @@ -3856,6 +3938,7 @@ export const handlers = [ blockstorage: 'Volumes', lke: 'LKE Enterprise', netloadbalancer: 'Network Load Balancers', + logs: 'Logs', }; const response = serviceTypesFactory.build({ service_type: `${serviceType}`, @@ -3989,6 +4072,16 @@ export const handlers = [ ); } + if (params.serviceType === 'logs') { + response.data.push( + dashboardFactory.build({ + id: 11, + service_type: 'logs', + label: 'Log Delivery Status', + }) + ); + } + return HttpResponse.json(response); }), http.get( @@ -4260,6 +4353,9 @@ export const handlers = [ if (params.serviceType === 'netloadbalancer') { return HttpResponse.json({ data: networkLoadBalancerMetricCriteria }); } + if (params.serviceType === 'logs') { + return HttpResponse.json({ data: logsMetricCriteria }); + } return HttpResponse.json(response); } ), @@ -4442,6 +4538,41 @@ export const handlers = [ ]; serviceType = 'netloadbalancer'; dashboardLabel = 'Network Load Balancer'; + } else if (id === '11') { + serviceType = 'logs'; + dashboardLabel = 'Log Delivery Status'; + widgets = [ + { + metric: 'success_upload_count', + unit: 'Count', + label: 'Success Upload', + color: 'default', + size: 6, + chart_type: 'area', + y_label: 'success_upload_count', + aggregate_function: 'sum', + }, + { + metric: 'error_upload_count', + unit: 'Count', + label: 'Error Upload', + color: 'default', + size: 6, + chart_type: 'area', + y_label: 'error_upload_count', + aggregate_function: 'sum', + }, + { + metric: 'error_upload_rate', + unit: '%', + label: 'Error Rate', + color: 'default', + size: 12, + chart_type: 'area', + y_label: 'error_upload_rate', + aggregate_function: 'avg', + }, + ]; } else { serviceType = 'linode'; dashboardLabel = 'Linode Service I/O Statistics'; @@ -4663,6 +4794,9 @@ export const handlers = [ }, }); }), + http.get('*/monitor/streams', () => { + return HttpResponse.json(makeResourcePage(streamFactory.buildList(10))); + }), ...entityTransfers, ...statusPage, ...databases, diff --git a/packages/manager/src/queries/cloudpulse/queries.ts b/packages/manager/src/queries/cloudpulse/queries.ts index 0b8c56e05ec..7ac0ca73537 100644 --- a/packages/manager/src/queries/cloudpulse/queries.ts +++ b/packages/manager/src/queries/cloudpulse/queries.ts @@ -10,6 +10,7 @@ import { } from '@linode/api-v4'; import { databaseQueries, + deliveryQueries, firewallQueries, getAllLinodesRequest, networkLoadBalancerQueries, @@ -142,6 +143,8 @@ export const queryFactory = createQueryKeys(key, { }; case 'lke': return kubernetesQueries.lists._ctx.all; + case 'logs': + return deliveryQueries.streams._ctx.all(params, filters); case 'netloadbalancer': return networkLoadBalancerQueries.netloadbalancers._ctx.all( params, diff --git a/packages/manager/src/queries/object-storage/queries.ts b/packages/manager/src/queries/object-storage/queries.ts index 587929f36b0..7670e05273b 100644 --- a/packages/manager/src/queries/object-storage/queries.ts +++ b/packages/manager/src/queries/object-storage/queries.ts @@ -36,7 +36,6 @@ import { OBJECT_STORAGE_DELIMITER as delimiter } from 'src/constants'; import { useFlags } from 'src/hooks/useFlags'; import { - getAllBucketsFromClusters, getAllBucketsFromEndpoints, getAllBucketsFromRegions, getAllObjectStorageClusters, @@ -147,53 +146,54 @@ export const useObjectStorageClusters = (enabled: boolean = true) => enabled, }); -export const useObjectStorageBuckets = (enabled = true) => { +export const useObjectStorageBuckets = (enabled: boolean = true) => { const flags = useFlags(); - const { data: account } = useAccount(); - const { data: allRegions } = useRegionsQuery(); - - const isObjMultiClusterEnabled = isFeatureEnabledV2( - 'Object Storage Access Key Regions', - Boolean(flags.objMultiCluster), - account?.capabilities ?? [] + const { data: account, isLoading: accountIsLoading } = useAccount(enabled); + + // TODO: always use regions query once dynamic Object Storage capability resolution is enabled + const isObjectStorageGen2Enabled = + account === undefined + ? undefined + : isFeatureEnabledV2( + 'Object Storage Endpoint Types', + Boolean(flags.objectStorageGen2?.enabled), + account.capabilities ?? [] + ); + const endpointsQueryEnabled = enabled && isObjectStorageGen2Enabled === true; + const regionsQueryEnabled = enabled && isObjectStorageGen2Enabled === false; + + const { data: allRegions, isLoading: regionsAreLoading } = + useRegionsQuery(regionsQueryEnabled); + const objRegions = allRegions?.filter((r) => + r.capabilities.includes('Object Storage') ); - const isObjectStorageGen2Enabled = isFeatureEnabledV2( - 'Object Storage Endpoint Types', - Boolean(flags.objectStorageGen2?.enabled), - account?.capabilities ?? [] - ); - - const endpointsQueryEnabled = enabled && isObjectStorageGen2Enabled; - const clustersQueryEnabled = enabled && !isObjMultiClusterEnabled; - // Endpoints contain all the regions that support Object Storage. - const { data: endpoints } = useObjectStorageEndpoints(endpointsQueryEnabled); - const { data: clusters } = useObjectStorageClusters(clustersQueryEnabled); - - const regions = - isObjMultiClusterEnabled && !isObjectStorageGen2Enabled - ? allRegions?.filter((r) => r.capabilities.includes('Object Storage')) - : undefined; - - const queryEnabled = - enabled && - ((isObjectStorageGen2Enabled && Boolean(endpoints)) || - (isObjMultiClusterEnabled && Boolean(regions)) || - Boolean(clusters)); + const { data: endpoints, isLoading: endpointsAreLoading } = + useObjectStorageEndpoints(endpointsQueryEnabled); - const queryFn = isObjectStorageGen2Enabled + const bucketsQueryEnabled = + (endpointsQueryEnabled && Boolean(endpoints)) || + (regionsQueryEnabled && Boolean(objRegions)); + const queryFn = endpointsQueryEnabled ? () => getAllBucketsFromEndpoints(endpoints) - : isObjMultiClusterEnabled - ? () => getAllBucketsFromRegions(regions) - : () => getAllBucketsFromClusters(clusters); + : () => getAllBucketsFromRegions(objRegions); - return useQuery>({ - enabled: queryEnabled, + const dependencyIsLoading = + accountIsLoading || regionsAreLoading || endpointsAreLoading; + + const bucketsQuery = useQuery< + BucketsResponseType + >({ + enabled: bucketsQueryEnabled, queryFn, queryKey: objectStorageQueries.buckets.queryKey, retry: false, }); + return { + ...bucketsQuery, + isLoading: bucketsQuery.isLoading || dependencyIsLoading, + }; }; export const useObjectStorageAccessKeys = (params: Params) => diff --git a/packages/manager/src/queries/object-storage/requests.ts b/packages/manager/src/queries/object-storage/requests.ts index fe5074fb622..4d9170f4c9c 100644 --- a/packages/manager/src/queries/object-storage/requests.ts +++ b/packages/manager/src/queries/object-storage/requests.ts @@ -1,6 +1,5 @@ import { getBuckets, - getBucketsInCluster, getBucketsInRegion, getClusters, getObjectStorageEndpoints, @@ -40,9 +39,8 @@ export const getAllObjectStorageEndpoints = () => * @deprecated This type will be deprecated and removed when OBJ Gen2 is in GA. */ export interface BucketError { - cluster: ObjectStorageCluster; error: APIError[]; - region?: Region; + region: Region; } /** @@ -74,47 +72,9 @@ export type BucketsResponseType = T extends true export function isBucketError( error: BucketError | BucketErrorGen2 ): error is BucketError { - return (error as BucketError).cluster !== undefined; + return (error as BucketError).region !== undefined; } -/** - * @deprecated This function is deprecated and will be removed in the future. - */ -export const getAllBucketsFromClusters = async ( - clusters: ObjectStorageCluster[] | undefined -) => { - if (clusters === undefined) { - return { buckets: [], errors: [] } as BucketsResponse; - } - - const promises = clusters.map((cluster) => - getAll((params) => - getBucketsInCluster(cluster.id, params) - )() - .then((data) => data.data) - .catch((error) => ({ - cluster, - error, - })) - ); - - const data = await Promise.all(promises); - - const bucketsPerCluster = data.filter((item) => - Array.isArray(item) - ) as ObjectStorageBucket[][]; - - const buckets = bucketsPerCluster.reduce((acc, val) => acc.concat(val), []); - - const errors = data.filter((item) => !Array.isArray(item)) as BucketError[]; - - if (errors.length === clusters.length) { - throw new Error('Unable to get Object Storage buckets.'); - } - - return { buckets, errors } as BucketsResponse; -}; - /** * @deprecated This function is deprecated and will be removed in the future. */ diff --git a/packages/manager/src/routes/IAM/index.ts b/packages/manager/src/routes/IAM/index.ts index 30e6802d089..a13e2dcfaba 100644 --- a/packages/manager/src/routes/IAM/index.ts +++ b/packages/manager/src/routes/IAM/index.ts @@ -8,9 +8,11 @@ import { rootRoute } from '../root'; import { IAMRoute } from './IAMRoute'; import type { TableSearchParams } from '../types'; -import type { User } from '@linode/api-v4'; +import type { AccessType, EntityType, User } from '@linode/api-v4'; -interface IamEntitiesSearchParams { +interface IamEntitiesSearchParams extends TableSearchParams { + entityType?: 'all' | EntityType; + query?: string; selectedRole?: string; } @@ -20,6 +22,11 @@ interface IamUsersSearchParams extends TableSearchParams { users?: string; } +interface IamUserRolesSearchParams extends TableSearchParams { + query?: string; + roleType?: 'all' | AccessType; +} + const iamRoute = createRoute({ component: IAMRoute, getParentRoute: () => rootRoute, @@ -78,6 +85,7 @@ const iamUsersCatchAllRoute = createRoute({ const iamRolesRoute = createRoute({ getParentRoute: () => iamTabsRoute, path: 'roles', + validateSearch: (search: IamUserRolesSearchParams) => search, beforeLoad: async ({ context }) => { const isIAMEnabled = await checkIAMEnabled( context.queryClient, @@ -106,7 +114,9 @@ const iamDefaultsTabsRoute = createRoute({ const profile = context?.profile; const userType = profile?.user_type; - if (userType !== 'child' || !isDelegationEnabled) { + const isChildOrDelegate = userType === 'child' || userType === 'delegate'; + + if (!isChildOrDelegate || !isDelegationEnabled) { throw redirect({ to: '/iam/roles', replace: true, @@ -122,6 +132,7 @@ const iamDefaultsTabsRoute = createRoute({ const iamDefaultRolesRoute = createRoute({ getParentRoute: () => iamDefaultsTabsRoute, path: 'roles', + validateSearch: (search: IamUserRolesSearchParams) => search, }).lazy(() => import('src/features/IAM/Roles/Defaults/defaultRolesLazyRoute').then( (m) => m.defaultRolesLazyRoute @@ -131,6 +142,7 @@ const iamDefaultRolesRoute = createRoute({ const iamDefaultEntityAccessRoute = createRoute({ getParentRoute: () => iamDefaultsTabsRoute, path: 'entity-access', + validateSearch: (search: IamEntitiesSearchParams) => search, }).lazy(() => import('src/features/IAM/Roles/Defaults/defaultEntityAccessLazyRoute').then( (m) => m.defaultEntityAccessLazyRoute @@ -166,7 +178,9 @@ const iamDelegationsRoute = createRoute({ } const isChildAccount = profile?.user_type === 'child'; - if (!isDelegationEnabled || isChildAccount) { + const isDelegateAccount = profile?.user_type === 'delegate'; + const isChildOrDelegate = isChildAccount || isDelegateAccount; + if (!isDelegationEnabled || isChildOrDelegate) { throw redirect({ to: '/iam/users', replace: true, @@ -303,6 +317,7 @@ const iamUserNameDetailsRoute = createRoute({ const iamUserNameRolesRoute = createRoute({ getParentRoute: () => iamUserNameRoute, path: 'roles', + validateSearch: (search: IamUserRolesSearchParams) => search, beforeLoad: async ({ context, params }) => { const isIAMEnabled = await checkIAMEnabled( context.queryClient, diff --git a/packages/manager/src/routes/images/ImagesRoute.tsx b/packages/manager/src/routes/images/ImagesRoute.tsx index 5cb3213de28..06bcd8a8d25 100644 --- a/packages/manager/src/routes/images/ImagesRoute.tsx +++ b/packages/manager/src/routes/images/ImagesRoute.tsx @@ -1,14 +1,47 @@ import { Typography } from '@linode/ui'; -import { Outlet } from '@tanstack/react-router'; -import React from 'react'; +import { Outlet, useLocation, useNavigate } from '@tanstack/react-router'; +import React, { useEffect } from 'react'; import { DismissibleBanner } from 'src/components/DismissibleBanner/DismissibleBanner'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { Link } from 'src/components/Link'; import { ProductInformationBanner } from 'src/components/ProductInformationBanner/ProductInformationBanner'; import { SuspenseLoader } from 'src/components/SuspenseLoader'; +import { useIsPrivateImageSharingEnabled } from 'src/features/Images/utils'; export const ImagesRoute = () => { + const navigate = useNavigate(); + const location = useLocation(); + const { isPrivateImageSharingEnabled } = useIsPrivateImageSharingEnabled(); + + // -------------- + // NOTE: This might not be strictly required, but useEffect ensures that there's no stale state + // when feature flags are toggled at runtime so the user doesn't have to reload the application manually to see the correct state. + // --------------- + // Handle navigation between legacy /images and new tabbed UI based on feature flag. + // Redirects to the appropriate route on initial load and when the feature flag toggles at runtime. + // - When flag is ON: /images redirects to /images/image-library + // - When flag is OFF: /images/image-library or /images/share-groups redirect back to /images (legacy) + useEffect(() => { + if (location.pathname.startsWith('/images')) { + // Redirect to Image Library tab when feature flag is enabled + if (isPrivateImageSharingEnabled && location.pathname === '/images') { + navigate({ + to: '/images/image-library/$imageType', + params: { imageType: 'owned-by-me' }, + replace: true, + }); + } else if ( + // Redirect to legacy route when feature flag is disabled + !isPrivateImageSharingEnabled && + (location.pathname.startsWith('/images/image-library') || + location.pathname.startsWith('/images/share-groups')) + ) { + navigate({ to: '/images', replace: true }); + } + } + }, [isPrivateImageSharingEnabled, location.pathname, navigate]); + return ( }> { + // When private image sharing is enabled, redirect to Image Library tab with default 'owned-by-me' sub-tab + if (context.isPrivateImageSharingEnabled) { + throw redirect({ + to: '/images/image-library/$imageType', + params: { imageType: 'owned-by-me' }, + }); + } + }, getParentRoute: () => imagesRoute, path: '/', validateSearch: (search: ImagesSearchParams) => search, @@ -52,7 +76,14 @@ const imagesIndexRoute = createRoute({ ); const imageActionRoute = createRoute({ - beforeLoad: async ({ params }) => { + beforeLoad: async ({ context, params }) => { + // Prevent access if private image sharing is enabled + if (context.isPrivateImageSharingEnabled) { + throw redirect({ + to: '/images/image-library/$imageType', + params: { imageType: 'owned-by-me' }, + }); + } if (!(params.action in imageActions)) { throw redirect({ search: () => ({}), @@ -118,8 +149,182 @@ const imagesCreateUploadRoute = createRoute({ ) ); +// V2 routes - Image Library tab and Share Groups tab + +// Image Library tab - contains sub-tabs for 'Owned by me', 'Shared with me', and 'Recovery images' +const imageLibraryLandingRoute = createRoute({ + getParentRoute: () => imagesRoute, + path: 'image-library', + validateSearch: (search: ImagesSearchParams) => search, +}).lazy(() => + import('src/features/Images/ImagesLanding/v2/imagesLandingV2LazyRoute').then( + (m) => m.imagesLandingV2LazyRoute + ) +); + +const imageLibraryIndexRoute = createRoute({ + beforeLoad: ({ context }) => { + if (!context.isPrivateImageSharingEnabled) { + throw redirect({ + to: '/images', + }); + } + + const normalizedPath = location.pathname.endsWith('/') + ? location.pathname.slice(0, -1) + : location.pathname; + + if ( + context.isPrivateImageSharingEnabled && + normalizedPath === '/images/image-library' + ) { + throw redirect({ + to: '/images/image-library/$imageType', + params: { imageType: 'owned-by-me' }, + }); + } + }, + getParentRoute: () => imageLibraryLandingRoute, + path: '/', + validateSearch: (search: ImagesSearchParams) => search, +}).lazy(() => + import( + 'src/features/Images/ImagesLanding/v2/ImageLibrary/imageLibraryTabsLazyRoute' + ).then((m) => m.imageLibraryTabsLazyRoute) +); + +const imageLibraryTypeRoute = createRoute({ + beforeLoad: async ({ context, params }) => { + if ( + context.isPrivateImageSharingEnabled && + !imageLibrarySubTabs.map((tab) => tab.type).includes(params.imageType) + ) { + throw redirect({ + to: '/images/image-library/$imageType', + params: { imageType: 'owned-by-me' }, + }); + } + }, + getParentRoute: () => imageLibraryIndexRoute, + params: { + parse: ({ imageType }: ImageLibraryTypeRouteParams) => ({ + imageType, + }), + stringify: ({ imageType }: ImageLibraryTypeRouteParams) => ({ + imageType, + }), + }, + path: '$imageType', + validateSearch: (search: ImagesSearchParams) => search, +}); + +const imageActionRouteV2 = createRoute({ + beforeLoad: async ({ params }) => { + if (!(params.action in imageActions)) { + throw redirect({ + search: () => ({}), + to: '/images', + }); + } + }, + getParentRoute: () => imageLibraryTypeRoute, + params: { + parse: ({ action, imageId }: ImageActionRouteParams) => ({ + action, + imageId, + }), + stringify: ({ action, imageId }: ImageActionRouteParams) => ({ + action, + imageId, + }), + }, + path: '$imageId/$action', + validateSearch: (search: ImagesSearchParams) => search, +}).lazy(() => + import('src/features/Images/ImagesLanding/v2/imagesLandingV2LazyRoute').then( + (m) => m.imagesLandingV2LazyRoute + ) +); + +// Share Groups tab +const shareGroupsLandingRoute = createRoute({ + getParentRoute: () => imagesRoute, + path: 'share-groups', + validateSearch: (search: ImagesSearchParams) => search, +}).lazy(() => + import('src/features/Images/ImagesLanding/v2/imagesLandingV2LazyRoute').then( + (m) => m.imagesLandingV2LazyRoute + ) +); + +const shareGroupsIndexRoute = createRoute({ + beforeLoad: ({ context }) => { + if (!context.isPrivateImageSharingEnabled) { + throw redirect({ + to: '/images', + }); + } + + const normalizedPath = location.pathname.endsWith('/') + ? location.pathname.slice(0, -1) + : location.pathname; + + if ( + context.isPrivateImageSharingEnabled && + normalizedPath === '/images/share-groups' + ) { + throw redirect({ + to: '/images/share-groups/$shareGroupsType', + params: { shareGroupsType: 'owned-groups' }, + }); + } + }, + getParentRoute: () => shareGroupsLandingRoute, + path: '/', + validateSearch: (search: ImagesSearchParams) => search, +}).lazy(() => + import( + 'src/features/Images/ImagesLanding/v2/ShareGroups/shareGroupsTabsLazyRoute' + ).then((m) => m.shareGroupsTabsLazyRoute) +); + +const shareGroupsTypeRoute = createRoute({ + beforeLoad: async ({ context, params }) => { + if ( + context.isPrivateImageSharingEnabled && + !shareGroupsSubTabs + .map((tab) => tab.type) + .includes(params.shareGroupsType) + ) { + throw redirect({ + to: '/images/share-groups/$shareGroupsType', + params: { shareGroupsType: 'owned-groups' }, + }); + } + }, + getParentRoute: () => shareGroupsIndexRoute, + params: { + parse: ({ shareGroupsType }: ShareGroupsTypeRouteParams) => ({ + shareGroupsType, + }), + stringify: ({ shareGroupsType }: ShareGroupsTypeRouteParams) => ({ + shareGroupsType, + }), + }, + path: '$shareGroupsType', + validateSearch: (search: ImagesSearchParams) => search, +}); + export const imagesRouteTree = imagesRoute.addChildren([ imagesIndexRoute.addChildren([imageActionRoute]), + imageLibraryLandingRoute.addChildren([ + imageLibraryIndexRoute.addChildren([ + imageLibraryTypeRoute.addChildren([imageActionRouteV2]), + ]), + ]), + shareGroupsLandingRoute.addChildren([ + shareGroupsIndexRoute.addChildren([shareGroupsTypeRoute]), + ]), imagesCreateRoute.addChildren([ imagesCreateIndexRoute, imagesCreateDiskRoute, diff --git a/packages/manager/src/routes/index.tsx b/packages/manager/src/routes/index.tsx index 7ebe79b15e1..5565d3459e8 100644 --- a/packages/manager/src/routes/index.tsx +++ b/packages/manager/src/routes/index.tsx @@ -106,6 +106,7 @@ export const router = createRouter({ isACLPEnabled: false, isDatabasesEnabled: false, isPlacementGroupsEnabled: false, + isPrivateImageSharingEnabled: false, profile: undefined, queryClient: new QueryClient(), }, diff --git a/packages/manager/src/routes/types.ts b/packages/manager/src/routes/types.ts index a8bf782b878..771e5c1824a 100644 --- a/packages/manager/src/routes/types.ts +++ b/packages/manager/src/routes/types.ts @@ -11,6 +11,7 @@ export type RouterContext = { isACLPEnabled?: boolean; isDatabasesEnabled?: boolean; isPlacementGroupsEnabled?: boolean; + isPrivateImageSharingEnabled?: boolean; profile?: Profile; queryClient: QueryClient; }; diff --git a/packages/manager/src/store/selectors/getSearchEntities.ts b/packages/manager/src/store/selectors/getSearchEntities.ts index 10a92442ca4..a6a30f838e8 100644 --- a/packages/manager/src/store/selectors/getSearchEntities.ts +++ b/packages/manager/src/store/selectors/getSearchEntities.ts @@ -7,6 +7,7 @@ import { getStreamDescription, } from 'src/features/Delivery/deliveryUtils'; import { getFirewallDescription } from 'src/features/Firewalls/shared'; +import { getImageTypeToImageLibraryType } from 'src/features/Images/utils'; import { getDescriptionForCluster } from 'src/features/Kubernetes/kubeUtils'; import type { @@ -74,25 +75,32 @@ export const volumeToSearchableItem = (volume: Volume): SearchableItem => ({ value: volume.id, }); -export const imageToSearchableItem = (image: Image): SearchableItem => ({ - data: { - created: image.created, - description: - image.description && image.description.length > 1 - ? image.description - : `${image.size} MB, Replicated in ${pluralize( - 'region', - 'regions', - image.regions.length - )}`, - icon: 'image', - path: `/images?query="${image.label}"`, - tags: image.tags, - }, - entityType: 'image', - label: image.label, - value: image.id, -}); +export const imageToSearchableItem = ( + image: Image, + isPrivateImageSharingEnabled: boolean +): SearchableItem => { + return { + data: { + created: image.created, + description: + image.description && image.description.length > 1 + ? image.description + : `${image.size} MB, Replicated in ${pluralize( + 'region', + 'regions', + image.regions.length + )}`, + icon: 'image', + path: isPrivateImageSharingEnabled + ? `/images/image-library/${getImageTypeToImageLibraryType(image.type)}?query="${image.label}"` + : `/images?query="${image.label}"`, + tags: image.tags, + }, + entityType: 'image', + label: image.label, + value: image.id, + }; +}; export const domainToSearchableItem = (domain: Domain): SearchableItem => ({ data: { @@ -207,6 +215,7 @@ export const streamToSearchableItem = (stream: Stream): SearchableItem => ({ description: getStreamDescription(stream), path: `/logs/delivery/streams/${stream.id}/edit`, status: stream.status, + created: stream.created, }, entityType: 'stream', label: stream.label, @@ -219,6 +228,7 @@ export const destinationToSearchableItem = ( data: { description: getDestinationDescription(destination), path: `/logs/delivery/destinations/${destination.id}/edit`, + created: destination.created, }, entityType: 'destination', label: destination.label, diff --git a/packages/queries/CHANGELOG.md b/packages/queries/CHANGELOG.md index 4f53ad09ab8..a4b7b082086 100644 --- a/packages/queries/CHANGELOG.md +++ b/packages/queries/CHANGELOG.md @@ -1,3 +1,10 @@ +## [2026-03-16] - v0.22.0 + + +### Fixed: + +- IAM: fix useGetChildAccountsQuery ([#13419](https://github.com/linode/manager/pull/13419)) + ## [2026-02-25] - v0.21.0 diff --git a/packages/queries/package.json b/packages/queries/package.json index 12be9ddd82d..d83e0b688f0 100644 --- a/packages/queries/package.json +++ b/packages/queries/package.json @@ -1,6 +1,6 @@ { "name": "@linode/queries", - "version": "0.21.0", + "version": "0.22.0", "description": "Linode Utility functions library", "main": "src/index.js", "module": "src/index.ts", diff --git a/packages/queries/src/account/users.ts b/packages/queries/src/account/users.ts index eaa607e3c9c..96b2a8a29f7 100644 --- a/packages/queries/src/account/users.ts +++ b/packages/queries/src/account/users.ts @@ -7,6 +7,7 @@ import { useQueryClient, } from '@tanstack/react-query'; +import { delegationQueries } from '../iam/delegation'; import { profileQueries, useProfile } from '../profile'; import { accountQueries } from './queries'; @@ -82,6 +83,9 @@ export const useUpdateUserMutation = (username: string) => { queryClient.invalidateQueries({ queryKey: accountQueries.users._ctx.paginated._def, }); + queryClient.invalidateQueries({ + queryKey: delegationQueries.childAccounts._def, + }); queryClient.setQueryData( accountQueries.users._ctx.user(user.username).queryKey, user, @@ -113,6 +117,9 @@ export const useAccountUserDeleteMutation = (username: string) => { queryClient.invalidateQueries({ queryKey: accountQueries.users._ctx.paginated._def, }); + queryClient.invalidateQueries({ + queryKey: delegationQueries.childAccounts._def, + }); queryClient.removeQueries({ queryKey: accountQueries.users._ctx.user(username).queryKey, }); diff --git a/packages/queries/src/databases/databases.ts b/packages/queries/src/databases/databases.ts index cf15ce40e18..7357985e112 100644 --- a/packages/queries/src/databases/databases.ts +++ b/packages/queries/src/databases/databases.ts @@ -243,13 +243,11 @@ export const useUpdateDatabaseConnectionPoolMutation = ( { mutationFn: (data) => updateDatabaseConnectionPool(databaseId, poolName, data), - onSuccess(connectionPool) { - queryClient.setQueryData( - databaseQueries - .database('postgresql', databaseId) - ._ctx.connectionPools._ctx.pool(connectionPool.label).queryKey, - connectionPool, - ); + onSuccess() { + queryClient.invalidateQueries({ + queryKey: databaseQueries.database('postgresql', databaseId)._ctx + .connectionPools.queryKey, + }); }, }, ); @@ -266,10 +264,6 @@ export const useDeleteDatabaseConnectionPoolMutation = ( queryClient.invalidateQueries( databaseQueries.database('postgresql', databaseId)._ctx.connectionPools, ); - queryClient.removeQueries({ - queryKey: databaseQueries.database('postgresql', databaseId)._ctx - .connectionPools.queryKey, - }); }, }); }; diff --git a/packages/queries/src/iam/delegation.ts b/packages/queries/src/iam/delegation.ts index ceed9d79255..36c2bf95f1e 100644 --- a/packages/queries/src/iam/delegation.ts +++ b/packages/queries/src/iam/delegation.ts @@ -21,6 +21,7 @@ import type { Account, APIError, ChildAccount, + ChildAccountTokenPayload, ChildAccountWithDelegates, GetChildAccountDelegatesParams, GetChildAccountsIamParams, @@ -99,7 +100,6 @@ export const useGetChildAccountsQuery = ({ > => { return useQuery({ ...delegationQueries.childAccounts({ params, users, filter }), - placeholderData: keepPreviousData, enabled, }); }; @@ -174,8 +174,7 @@ export const useUpdateChildAccountDelegatesQuery = (): UseMutationResult< onSuccess(_data, { euuid }) { // Invalidate all child accounts queryClient.invalidateQueries({ - queryKey: delegationQueries.childAccounts({ params: {}, users: true }) - .queryKey, + queryKey: delegationQueries.childAccounts._def, }); // Invalidate all child account delegates queryClient.invalidateQueries({ @@ -235,13 +234,10 @@ export const useGetChildAccountQuery = ( * - Audience: Clients that need temporary auth to perform actions in the child account. * - Data: Token for `POST /iam/delegation/child-accounts/:euuid/token`. */ -export const useGenerateChildAccountTokenQuery = (): UseMutationResult< - Token, - APIError[], - { euuid: string } -> => { - return useMutation({ - mutationFn: generateChildAccountToken, +export const useGenerateChildAccountTokenQuery = () => { + return useMutation({ + mutationFn: ({ euuid, headers }: ChildAccountTokenPayload) => + generateChildAccountToken({ euuid, headers }), }); }; diff --git a/packages/queries/src/regions/regions.ts b/packages/queries/src/regions/regions.ts index 0a735b36aaf..2752869ef11 100644 --- a/packages/queries/src/regions/regions.ts +++ b/packages/queries/src/regions/regions.ts @@ -76,10 +76,11 @@ export const useRegionQuery = (regionId: string) => { }); }; -export const useRegionsQuery = () => +export const useRegionsQuery = (enabled: boolean = true) => useQuery({ ...regionQueries.regions, ...queryPresets.longLived, + enabled, select: (regions: Region[]) => regions.map((region) => ({ ...region, diff --git a/packages/ui/CHANGELOG.md b/packages/ui/CHANGELOG.md index 5fd2e64e839..697b97e47d8 100644 --- a/packages/ui/CHANGELOG.md +++ b/packages/ui/CHANGELOG.md @@ -1,3 +1,10 @@ +## [2026-03-16] - v0.25.0 + + +### Upcoming Features: + +- Add `ZeroStateSearchNarrowIcon` to UI package ([#13418](https://github.com/linode/manager/pull/13418)) + ## [2026-02-25] - v0.24.0 diff --git a/packages/ui/package.json b/packages/ui/package.json index 7667cb57000..914c69b150a 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -2,7 +2,7 @@ "name": "@linode/ui", "author": "Linode", "description": "Linode UI component library", - "version": "0.24.0", + "version": "0.25.0", "type": "module", "main": "src/index.ts", "module": "src/index.ts", diff --git a/packages/ui/src/assets/icons/index.ts b/packages/ui/src/assets/icons/index.ts index de34a548e07..bbbd03d8bac 100644 --- a/packages/ui/src/assets/icons/index.ts +++ b/packages/ui/src/assets/icons/index.ts @@ -31,3 +31,4 @@ export { default as VisibilityHideIcon } from './visibilityHide.svg'; export { default as VisibilityShowIcon } from './visibilityShow.svg'; export { default as WarningOutlinedIcon } from './warning-outlined.svg'; export { default as WarningIcon } from './warning.svg'; +export { default as ZeroStateSearchNarrowIcon } from './zero-state-search-narrow.svg'; diff --git a/packages/ui/src/assets/icons/zero-state-search-narrow.svg b/packages/ui/src/assets/icons/zero-state-search-narrow.svg new file mode 100644 index 00000000000..1d0a68c26d6 --- /dev/null +++ b/packages/ui/src/assets/icons/zero-state-search-narrow.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/packages/ui/src/components/Autocomplete/Autocomplete.tsx b/packages/ui/src/components/Autocomplete/Autocomplete.tsx index 2dd35db0ca3..060b7888848 100644 --- a/packages/ui/src/components/Autocomplete/Autocomplete.tsx +++ b/packages/ui/src/components/Autocomplete/Autocomplete.tsx @@ -48,6 +48,10 @@ export interface EnhancedAutocompleteProps< noMarginTop?: boolean; /** Element to show when the Autocomplete search yields no results. */ noOptionsText?: JSX.Element | string; + /** Handler called when the Select All option is clicked. */ + onSelectAllClick?: ( + event: React.MouseEvent, + ) => void; placeholder?: string; renderInput?: (_params: AutocompleteRenderInputParams) => React.ReactNode; /** Label for the "select all" option. */ @@ -96,6 +100,7 @@ export const Autocomplete = < keepSearchEnabledOnMobile = false, onBlur, onChange, + onSelectAllClick, options, placeholder, renderInput, @@ -191,6 +196,18 @@ export const Autocomplete = < const isSelectAllOption = option === selectAllOption; const ListItem = isSelectAllOption ? StyledListItem : 'li'; + // If this is the Select All option, add a click handler + const handleClick = ( + event: React.MouseEvent, + ) => { + if (isSelectAllOption && onSelectAllClick) { + onSelectAllClick(event); + } + if (props.onClick) { + props.onClick(event); + } + }; + return renderOption ? ( renderOption(props, option, state, ownerState) ) : ( @@ -198,9 +215,10 @@ export const Autocomplete = < {...props} data-pendo-id={ rest.getOptionLabel ? rest.getOptionLabel(option) : option.label - } // Adding data-pendo-id for better tracking in Pendo analytics, using the option label as the identifier for the option element. + } data-qa-option key={props.key} + onClick={isSelectAllOption ? handleClick : props.onClick} > <> ({ }), type: Factory.each((i) => { if (i === 1) { - return 'premium_40GB'; + return 'premium_40gb'; } if (i % 2 === 0) { return 'premium'; diff --git a/packages/validation/CHANGELOG.md b/packages/validation/CHANGELOG.md index 141033358bc..055619222c3 100644 --- a/packages/validation/CHANGELOG.md +++ b/packages/validation/CHANGELOG.md @@ -1,3 +1,10 @@ +## [2026-03-16] - v0.83.0 + + +### Changed: + +- Delivery Logs - additional validation in Endpoint URL and Custom Header Name fields ([#13392](https://github.com/linode/manager/pull/13392)) + ## [2026-02-25] - v0.82.0 diff --git a/packages/validation/package.json b/packages/validation/package.json index af8cf34afc5..1c10510cd5e 100644 --- a/packages/validation/package.json +++ b/packages/validation/package.json @@ -1,6 +1,6 @@ { "name": "@linode/validation", - "version": "0.82.0", + "version": "0.83.0", "description": "Yup validation schemas for use with the Linode APIv4", "type": "module", "main": "lib/index.cjs", diff --git a/packages/validation/src/delivery.schema.ts b/packages/validation/src/delivery.schema.ts index 8bc1b35011c..cdd66ff8449 100644 --- a/packages/validation/src/delivery.schema.ts +++ b/packages/validation/src/delivery.schema.ts @@ -56,20 +56,15 @@ const clientCertificateDetailsSchema = object({ }).test( 'all-or-nothing-cert-details', 'If any certificate detail is provided, all are required.', - function (value, context) { + function (value) { if (!value) { return true; } - const { - client_ca_certificate, - client_certificate, - client_private_key, - tls_hostname, - } = value; + const { client_ca_certificate, client_certificate, client_private_key } = + value; const fields = [ - tls_hostname, client_ca_certificate, client_certificate, client_private_key, @@ -82,39 +77,30 @@ const clientCertificateDetailsSchema = object({ } const errors: ValidationError[] = []; - if (!hasValue(tls_hostname)) { - errors.push( - context.createError({ - path: `${this.path}.tls_hostname`, - message: - 'TLS Hostname is required when other Client Certificate details are provided.', - }), - ); - } if (!hasValue(client_ca_certificate)) { errors.push( - context.createError({ + this.createError({ path: `${this.path}.client_ca_certificate`, message: - 'CA Certificate is required when other Client Certificate details are provided.', + 'CA Certificate is required when other client certificate details are provided.', }), ); } if (!hasValue(client_certificate)) { errors.push( - context.createError({ + this.createError({ path: `${this.path}.client_certificate`, message: - 'Client Certificate is required when other Client Certificate details are provided.', + 'Client Certificate is required when other client certificate details are provided.', }), ); } if (!hasValue(client_private_key)) { errors.push( - context.createError({ + this.createError({ path: `${this.path}.client_private_key`, message: - 'Client Key is required when other Client Certificate details are provided.', + 'Client Key is required when other client certificate details are provided.', }), ); } @@ -123,15 +109,41 @@ const clientCertificateDetailsSchema = object({ }, ); +const forbiddenCustomHeaderNames = [ + 'content-type', + 'encoding', + 'authorization', + 'host', + 'akamai', +]; + const customHeaderSchema = object({ name: string() .max(maxLength, maxLengthMessage) - .required('Custom Header Name is required.'), + .required('Custom Header Name is required.') + .test( + 'non-empty-name', + 'Custom Header Name cannot be empty or whitespace only.', + (value) => hasValue(value), + ) + .test( + 'forbidden-custom-header-name', + 'This header name is not allowed.', + (value) => + !forbiddenCustomHeaderNames.includes(value.trim().toLowerCase()), + ), value: string() .max(maxLength, maxLengthMessage) - .required('Custom Header Value is required'), + .required('Custom Header Value is required.') + .test( + 'non-empty-value', + 'Custom Header Value cannot be empty or whitespace only.', + (value) => hasValue(value), + ), }); +const urlRgx = /^(https?:\/\/)?(www\.)?[a-zA-Z0-9-]+(\.[a-zA-Z]+)+(\/\S*)?$/; + const customHTTPSDetailsSchema = object({ authentication: authenticationSchema.required(), client_certificate_details: clientCertificateDetailsSchema.optional(), @@ -139,11 +151,49 @@ const customHTTPSDetailsSchema = object({ .oneOf(['application/json', 'application/json; charset=utf-8']) .nullable() .optional(), - custom_headers: array().of(customHeaderSchema).min(1).optional(), + custom_headers: array() + .of(customHeaderSchema) + .min(1) + .optional() + .test( + 'unique-header-names', + 'Custom Header Names must be unique.', + function (headers) { + if (!headers || headers.length === 0) { + return true; + } + + const seenNames = new Set(); + const errors: ValidationError[] = []; + + headers.forEach((header, index) => { + const trimmedName = header?.name?.trim().toLowerCase(); + if (!trimmedName) { + return; + } + + if (seenNames.has(trimmedName)) { + errors.push( + this.createError({ + path: `${this.path}[${index}].name`, + message: 'Custom Header Name must be unique.', + }), + ); + } else { + seenNames.add(trimmedName); + } + }); + + return errors.length === 0 || new ValidationError(errors); + }, + ), data_compression: string().oneOf(['gzip', 'None']).required(), endpoint_url: string() .max(maxLength, maxLengthMessage) - .required('Endpoint URL is required.'), + .required('Endpoint URL is required.') + .test('is-valid-url', 'Endpoint URL must be a valid URL.', (value) => + urlRgx.test(value), + ), }); const hostRgx = @@ -298,7 +348,7 @@ const detailsShouldNotExistOrBeNull = (schema: MixedSchema) => const streamSchemaBase = object({ label: string() - .min(3, 'Stream name must have at least 3 characters') + .min(3, 'Stream name must have at least 3 characters.') .max(maxLength, maxLengthMessage) .required('Stream name is required.'), status: mixed<'active' | 'inactive' | 'provisioning'>().oneOf([ @@ -338,7 +388,7 @@ export const updateStreamSchema = streamSchemaBase return detailsShouldNotExistOrBeNull(mixed()); }), }) - .noUnknown('Object contains unknown fields'); + .noUnknown('Object contains unknown fields.'); export const streamAndDestinationFormSchema = object({ stream: streamSchemaBase.shape({ @@ -349,7 +399,7 @@ export const streamAndDestinationFormSchema = object({ otherwise: (schema) => schema .nullable() - .equals([null], 'Details must be null for audit_logs type'), + .equals([null], 'Details must be null for audit_logs type.'), }) as Schema | null>, }), destination: destinationFormSchema.defined().when('stream.destinations', { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d86446c627a..c3be8dba7c4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -94,8 +94,8 @@ importers: specifier: workspace:* version: link:../validation axios: - specifier: ~1.12.0 - version: 1.12.0 + specifier: ~1.13.5 + version: 1.13.5 ipaddr.js: specifier: ^2.0.0 version: 2.2.0 @@ -111,7 +111,7 @@ importers: version: 22.18.1 axios-mock-adapter: specifier: ^1.22.0 - version: 1.22.0(axios@1.12.0) + version: 1.22.0(axios@1.13.5) concurrently: specifier: ^9.0.1 version: 9.1.0 @@ -221,8 +221,8 @@ importers: specifier: ^4.14.3 version: 4.24.0 axios: - specifier: ~1.12.0 - version: 1.12.0 + specifier: ~1.13.5 + version: 1.13.5 braintree-web: specifier: ^3.92.2 version: 3.112.1 @@ -257,11 +257,11 @@ importers: specifier: ^1.9.1 version: 1.9.1 jspdf: - specifier: ^4.0.0 - version: 4.0.0 + specifier: ^4.2.0 + version: 4.2.0 jspdf-autotable: specifier: ^5.0.2 - version: 5.0.2(jspdf@4.0.0) + version: 5.0.2(jspdf@4.2.0) launchdarkly-react-client-sdk: specifier: 3.0.10 version: 3.0.10(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -275,8 +275,8 @@ importers: specifier: 3.4.4 version: 3.4.4 markdown-it: - specifier: ^14.1.0 - version: 14.1.0 + specifier: ^14.1.1 + version: 14.1.1 md5: specifier: ^2.2.1 version: 2.3.0 @@ -361,7 +361,7 @@ importers: version: 9.1.17(@types/react@19.1.6)(storybook@9.1.17(@testing-library/dom@10.4.0)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(prettier@3.5.3)(vite@7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))) '@storybook/react-vite': specifier: ^9.1.17 - version: 9.1.17(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.53.5)(storybook@9.1.17(@testing-library/dom@10.4.0)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(prettier@3.5.3)(vite@7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)))(typescript@5.9.3)(vite@7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + version: 9.1.17(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.58.0)(storybook@9.1.17(@testing-library/dom@10.4.0)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(prettier@3.5.3)(vite@7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)))(typescript@5.9.3)(vite@7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) '@swc/core': specifier: ^1.10.9 version: 1.10.11 @@ -445,7 +445,7 @@ importers: version: 4.0.1(vite@7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) '@vitest/coverage-v8': specifier: ^3.2.4 - version: 3.2.4(vitest@4.0.16(@types/node@22.18.1)(@vitest/ui@4.0.10)(jiti@2.4.2)(jsdom@24.1.3)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + version: 3.2.4(vitest@4.0.18(@types/node@22.18.1)(@vitest/ui@4.0.10(vitest@4.0.10))(jiti@2.4.2)(jsdom@24.1.3)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) '@vueless/storybook-dark-mode': specifier: ^9.0.5 version: 9.0.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -523,7 +523,7 @@ importers: version: 7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) vite-plugin-svgr: specifier: ^4.5.0 - version: 4.5.0(rollup@4.53.5)(typescript@5.9.3)(vite@7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + version: 4.5.0(rollup@4.58.0)(typescript@5.9.3)(vite@7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) packages/queries: dependencies: @@ -607,7 +607,7 @@ importers: version: link:../tsconfig '@storybook/react-vite': specifier: ^9.0.12 - version: 9.0.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.53.5)(storybook@9.1.17(@testing-library/dom@10.4.0)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(prettier@3.5.3)(vite@7.3.0(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)))(typescript@5.9.3)(vite@7.3.0(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + version: 9.0.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.58.0)(storybook@9.1.17(@testing-library/dom@10.4.0)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(prettier@3.5.3)(vite@7.3.1(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)))(typescript@5.9.3)(vite@7.3.1(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) '@testing-library/dom': specifier: ^10.1.0 version: 10.4.0 @@ -628,10 +628,10 @@ importers: version: 19.1.6(@types/react@19.1.6) storybook: specifier: ^9.1.17 - version: 9.1.17(@testing-library/dom@10.4.0)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(prettier@3.5.3)(vite@7.3.0(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + version: 9.1.17(@testing-library/dom@10.4.0)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(prettier@3.5.3)(vite@7.3.1(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) vite-plugin-svgr: specifier: ^4.5.0 - version: 4.5.0(rollup@4.53.5)(typescript@5.9.3)(vite@7.3.0(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + version: 4.5.0(rollup@4.58.0)(typescript@5.9.3)(vite@7.3.1(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) packages/tsconfig: {} @@ -676,7 +676,7 @@ importers: version: link:../tsconfig '@storybook/react-vite': specifier: ^9.0.12 - version: 9.0.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.53.5)(storybook@9.1.17(@testing-library/dom@10.4.0)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(prettier@3.5.3)(vite@7.3.0(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)))(typescript@5.9.3)(vite@7.3.0(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + version: 9.0.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.58.0)(storybook@9.1.17(@testing-library/dom@10.4.0)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(prettier@3.5.3)(vite@7.3.1(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)))(typescript@5.9.3)(vite@7.3.1(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) '@testing-library/dom': specifier: ^10.1.0 version: 10.4.0 @@ -700,10 +700,10 @@ importers: version: 19.1.6(@types/react@19.1.6) storybook: specifier: ^9.1.17 - version: 9.1.17(@testing-library/dom@10.4.0)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(prettier@3.5.3)(vite@7.3.0(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + version: 9.1.17(@testing-library/dom@10.4.0)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(prettier@3.5.3)(vite@7.3.1(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) vite-plugin-svgr: specifier: ^4.5.0 - version: 4.5.0(rollup@4.53.5)(typescript@5.9.3)(vite@7.3.0(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + version: 4.5.0(rollup@4.58.0)(typescript@5.9.3)(vite@7.3.1(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) packages/utilities: dependencies: @@ -788,8 +788,8 @@ importers: specifier: ^3.1.4 version: 3.1.12 markdown-it: - specifier: ^14.1.0 - version: 14.1.0 + specifier: ^14.1.1 + version: 14.1.1 simple-git: specifier: ^3.19.0 version: 3.27.0 @@ -918,6 +918,10 @@ packages: resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==} engines: {node: '>=6.9.0'} + '@babel/runtime@7.28.6': + resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} + engines: {node: '>=6.9.0'} + '@babel/template@7.27.0': resolution: {integrity: sha512-2ncevenBqXI6qRMukPlXwHKHchC7RyMuu4xv5JBXRfOGVcTy1mXCD12qrp7Jsoxll1EV3+9sE4GugBVRjT2jFA==} engines: {node: '>=6.9.0'} @@ -1074,8 +1078,8 @@ packages: cpu: [ppc64] os: [aix] - '@esbuild/aix-ppc64@0.27.2': - resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==} + '@esbuild/aix-ppc64@0.27.3': + resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] @@ -1092,8 +1096,8 @@ packages: cpu: [arm64] os: [android] - '@esbuild/android-arm64@0.27.2': - resolution: {integrity: sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==} + '@esbuild/android-arm64@0.27.3': + resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==} engines: {node: '>=18'} cpu: [arm64] os: [android] @@ -1110,8 +1114,8 @@ packages: cpu: [arm] os: [android] - '@esbuild/android-arm@0.27.2': - resolution: {integrity: sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==} + '@esbuild/android-arm@0.27.3': + resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==} engines: {node: '>=18'} cpu: [arm] os: [android] @@ -1128,8 +1132,8 @@ packages: cpu: [x64] os: [android] - '@esbuild/android-x64@0.27.2': - resolution: {integrity: sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==} + '@esbuild/android-x64@0.27.3': + resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==} engines: {node: '>=18'} cpu: [x64] os: [android] @@ -1146,8 +1150,8 @@ packages: cpu: [arm64] os: [darwin] - '@esbuild/darwin-arm64@0.27.2': - resolution: {integrity: sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==} + '@esbuild/darwin-arm64@0.27.3': + resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] @@ -1164,8 +1168,8 @@ packages: cpu: [x64] os: [darwin] - '@esbuild/darwin-x64@0.27.2': - resolution: {integrity: sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==} + '@esbuild/darwin-x64@0.27.3': + resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==} engines: {node: '>=18'} cpu: [x64] os: [darwin] @@ -1182,8 +1186,8 @@ packages: cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-arm64@0.27.2': - resolution: {integrity: sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==} + '@esbuild/freebsd-arm64@0.27.3': + resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] @@ -1200,8 +1204,8 @@ packages: cpu: [x64] os: [freebsd] - '@esbuild/freebsd-x64@0.27.2': - resolution: {integrity: sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==} + '@esbuild/freebsd-x64@0.27.3': + resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] @@ -1218,8 +1222,8 @@ packages: cpu: [arm64] os: [linux] - '@esbuild/linux-arm64@0.27.2': - resolution: {integrity: sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==} + '@esbuild/linux-arm64@0.27.3': + resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==} engines: {node: '>=18'} cpu: [arm64] os: [linux] @@ -1236,8 +1240,8 @@ packages: cpu: [arm] os: [linux] - '@esbuild/linux-arm@0.27.2': - resolution: {integrity: sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==} + '@esbuild/linux-arm@0.27.3': + resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==} engines: {node: '>=18'} cpu: [arm] os: [linux] @@ -1254,8 +1258,8 @@ packages: cpu: [ia32] os: [linux] - '@esbuild/linux-ia32@0.27.2': - resolution: {integrity: sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==} + '@esbuild/linux-ia32@0.27.3': + resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==} engines: {node: '>=18'} cpu: [ia32] os: [linux] @@ -1272,8 +1276,8 @@ packages: cpu: [loong64] os: [linux] - '@esbuild/linux-loong64@0.27.2': - resolution: {integrity: sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==} + '@esbuild/linux-loong64@0.27.3': + resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==} engines: {node: '>=18'} cpu: [loong64] os: [linux] @@ -1290,8 +1294,8 @@ packages: cpu: [mips64el] os: [linux] - '@esbuild/linux-mips64el@0.27.2': - resolution: {integrity: sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==} + '@esbuild/linux-mips64el@0.27.3': + resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] @@ -1308,8 +1312,8 @@ packages: cpu: [ppc64] os: [linux] - '@esbuild/linux-ppc64@0.27.2': - resolution: {integrity: sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==} + '@esbuild/linux-ppc64@0.27.3': + resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] @@ -1326,8 +1330,8 @@ packages: cpu: [riscv64] os: [linux] - '@esbuild/linux-riscv64@0.27.2': - resolution: {integrity: sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==} + '@esbuild/linux-riscv64@0.27.3': + resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] @@ -1344,8 +1348,8 @@ packages: cpu: [s390x] os: [linux] - '@esbuild/linux-s390x@0.27.2': - resolution: {integrity: sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==} + '@esbuild/linux-s390x@0.27.3': + resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==} engines: {node: '>=18'} cpu: [s390x] os: [linux] @@ -1362,8 +1366,8 @@ packages: cpu: [x64] os: [linux] - '@esbuild/linux-x64@0.27.2': - resolution: {integrity: sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==} + '@esbuild/linux-x64@0.27.3': + resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==} engines: {node: '>=18'} cpu: [x64] os: [linux] @@ -1380,8 +1384,8 @@ packages: cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-arm64@0.27.2': - resolution: {integrity: sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==} + '@esbuild/netbsd-arm64@0.27.3': + resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] @@ -1398,8 +1402,8 @@ packages: cpu: [x64] os: [netbsd] - '@esbuild/netbsd-x64@0.27.2': - resolution: {integrity: sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==} + '@esbuild/netbsd-x64@0.27.3': + resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] @@ -1416,8 +1420,8 @@ packages: cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-arm64@0.27.2': - resolution: {integrity: sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==} + '@esbuild/openbsd-arm64@0.27.3': + resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] @@ -1434,8 +1438,8 @@ packages: cpu: [x64] os: [openbsd] - '@esbuild/openbsd-x64@0.27.2': - resolution: {integrity: sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==} + '@esbuild/openbsd-x64@0.27.3': + resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] @@ -1446,8 +1450,8 @@ packages: cpu: [arm64] os: [openharmony] - '@esbuild/openharmony-arm64@0.27.2': - resolution: {integrity: sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==} + '@esbuild/openharmony-arm64@0.27.3': + resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] @@ -1464,8 +1468,8 @@ packages: cpu: [x64] os: [sunos] - '@esbuild/sunos-x64@0.27.2': - resolution: {integrity: sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==} + '@esbuild/sunos-x64@0.27.3': + resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==} engines: {node: '>=18'} cpu: [x64] os: [sunos] @@ -1482,8 +1486,8 @@ packages: cpu: [arm64] os: [win32] - '@esbuild/win32-arm64@0.27.2': - resolution: {integrity: sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==} + '@esbuild/win32-arm64@0.27.3': + resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==} engines: {node: '>=18'} cpu: [arm64] os: [win32] @@ -1500,8 +1504,8 @@ packages: cpu: [ia32] os: [win32] - '@esbuild/win32-ia32@0.27.2': - resolution: {integrity: sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==} + '@esbuild/win32-ia32@0.27.3': + resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==} engines: {node: '>=18'} cpu: [ia32] os: [win32] @@ -1518,8 +1522,8 @@ packages: cpu: [x64] os: [win32] - '@esbuild/win32-x64@0.27.2': - resolution: {integrity: sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==} + '@esbuild/win32-x64@0.27.3': + resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==} engines: {node: '>=18'} cpu: [x64] os: [win32] @@ -2120,8 +2124,8 @@ packages: cpu: [arm] os: [android] - '@rollup/rollup-android-arm-eabi@4.53.5': - resolution: {integrity: sha512-iDGS/h7D8t7tvZ1t6+WPK04KD0MwzLZrG0se1hzBjSi5fyxlsiggoJHwh18PCFNn7tG43OWb6pdZ6Y+rMlmyNQ==} + '@rollup/rollup-android-arm-eabi@4.58.0': + resolution: {integrity: sha512-mr0tmS/4FoVk1cnaeN244A/wjvGDNItZKR8hRhnmCzygyRXYtKF5jVDSIILR1U97CTzAYmbgIj/Dukg62ggG5w==} cpu: [arm] os: [android] @@ -2135,8 +2139,8 @@ packages: cpu: [arm64] os: [android] - '@rollup/rollup-android-arm64@4.53.5': - resolution: {integrity: sha512-wrSAViWvZHBMMlWk6EJhvg8/rjxzyEhEdgfMMjREHEq11EtJ6IP6yfcCH57YAEca2Oe3FNCE9DSTgU70EIGmVw==} + '@rollup/rollup-android-arm64@4.58.0': + resolution: {integrity: sha512-+s++dbp+/RTte62mQD9wLSbiMTV+xr/PeRJEc/sFZFSBRlHPNPVaf5FXlzAL77Mr8FtSfQqCN+I598M8U41ccQ==} cpu: [arm64] os: [android] @@ -2150,8 +2154,8 @@ packages: cpu: [arm64] os: [darwin] - '@rollup/rollup-darwin-arm64@4.53.5': - resolution: {integrity: sha512-S87zZPBmRO6u1YXQLwpveZm4JfPpAa6oHBX7/ghSiGH3rz/KDgAu1rKdGutV+WUI6tKDMbaBJomhnT30Y2t4VQ==} + '@rollup/rollup-darwin-arm64@4.58.0': + resolution: {integrity: sha512-MFWBwTcYs0jZbINQBXHfSrpSQJq3IUOakcKPzfeSznONop14Pxuqa0Kg19GD0rNBMPQI2tFtu3UzapZpH0Uc1Q==} cpu: [arm64] os: [darwin] @@ -2165,8 +2169,8 @@ packages: cpu: [x64] os: [darwin] - '@rollup/rollup-darwin-x64@4.53.5': - resolution: {integrity: sha512-YTbnsAaHo6VrAczISxgpTva8EkfQus0VPEVJCEaboHtZRIb6h6j0BNxRBOwnDciFTZLDPW5r+ZBmhL/+YpTZgA==} + '@rollup/rollup-darwin-x64@4.58.0': + resolution: {integrity: sha512-yiKJY7pj9c9JwzuKYLFaDZw5gma3fI9bkPEIyofvVfsPqjCWPglSHdpdwXpKGvDeYDms3Qal8qGMEHZ1M/4Udg==} cpu: [x64] os: [darwin] @@ -2180,8 +2184,8 @@ packages: cpu: [arm64] os: [freebsd] - '@rollup/rollup-freebsd-arm64@4.53.5': - resolution: {integrity: sha512-1T8eY2J8rKJWzaznV7zedfdhD1BqVs1iqILhmHDq/bqCUZsrMt+j8VCTHhP0vdfbHK3e1IQ7VYx3jlKqwlf+vw==} + '@rollup/rollup-freebsd-arm64@4.58.0': + resolution: {integrity: sha512-x97kCoBh5MOevpn/CNK9W1x8BEzO238541BGWBc315uOlN0AD/ifZ1msg+ZQB05Ux+VF6EcYqpiagfLJ8U3LvQ==} cpu: [arm64] os: [freebsd] @@ -2195,8 +2199,8 @@ packages: cpu: [x64] os: [freebsd] - '@rollup/rollup-freebsd-x64@4.53.5': - resolution: {integrity: sha512-sHTiuXyBJApxRn+VFMaw1U+Qsz4kcNlxQ742snICYPrY+DDL8/ZbaC4DVIB7vgZmp3jiDaKA0WpBdP0aqPJoBQ==} + '@rollup/rollup-freebsd-x64@4.58.0': + resolution: {integrity: sha512-Aa8jPoZ6IQAG2eIrcXPpjRcMjROMFxCt1UYPZZtCxRV68WkuSigYtQ/7Zwrcr2IvtNJo7T2JfDXyMLxq5L4Jlg==} cpu: [x64] os: [freebsd] @@ -2210,8 +2214,8 @@ packages: cpu: [arm] os: [linux] - '@rollup/rollup-linux-arm-gnueabihf@4.53.5': - resolution: {integrity: sha512-dV3T9MyAf0w8zPVLVBptVlzaXxka6xg1f16VAQmjg+4KMSTWDvhimI/Y6mp8oHwNrmnmVl9XxJ/w/mO4uIQONA==} + '@rollup/rollup-linux-arm-gnueabihf@4.58.0': + resolution: {integrity: sha512-Ob8YgT5kD/lSIYW2Rcngs5kNB/44Q2RzBSPz9brf2WEtcGR7/f/E9HeHn1wYaAwKBni+bdXEwgHvUd0x12lQSA==} cpu: [arm] os: [linux] @@ -2225,8 +2229,8 @@ packages: cpu: [arm] os: [linux] - '@rollup/rollup-linux-arm-musleabihf@4.53.5': - resolution: {integrity: sha512-wIGYC1x/hyjP+KAu9+ewDI+fi5XSNiUi9Bvg6KGAh2TsNMA3tSEs+Sh6jJ/r4BV/bx/CyWu2ue9kDnIdRyafcQ==} + '@rollup/rollup-linux-arm-musleabihf@4.58.0': + resolution: {integrity: sha512-K+RI5oP1ceqoadvNt1FecL17Qtw/n9BgRSzxif3rTL2QlIu88ccvY+Y9nnHe/cmT5zbH9+bpiJuG1mGHRVwF4Q==} cpu: [arm] os: [linux] @@ -2240,8 +2244,8 @@ packages: cpu: [arm64] os: [linux] - '@rollup/rollup-linux-arm64-gnu@4.53.5': - resolution: {integrity: sha512-Y+qVA0D9d0y2FRNiG9oM3Hut/DgODZbU9I8pLLPwAsU0tUKZ49cyV1tzmB/qRbSzGvY8lpgGkJuMyuhH7Ma+Vg==} + '@rollup/rollup-linux-arm64-gnu@4.58.0': + resolution: {integrity: sha512-T+17JAsCKUjmbopcKepJjHWHXSjeW7O5PL7lEFaeQmiVyw4kkc5/lyYKzrv6ElWRX/MrEWfPiJWqbTvfIvjM1Q==} cpu: [arm64] os: [linux] @@ -2255,8 +2259,8 @@ packages: cpu: [arm64] os: [linux] - '@rollup/rollup-linux-arm64-musl@4.53.5': - resolution: {integrity: sha512-juaC4bEgJsyFVfqhtGLz8mbopaWD+WeSOYr5E16y+1of6KQjc0BpwZLuxkClqY1i8sco+MdyoXPNiCkQou09+g==} + '@rollup/rollup-linux-arm64-musl@4.58.0': + resolution: {integrity: sha512-cCePktb9+6R9itIJdeCFF9txPU7pQeEHB5AbHu/MKsfH/k70ZtOeq1k4YAtBv9Z7mmKI5/wOLYjQ+B9QdxR6LA==} cpu: [arm64] os: [linux] @@ -2265,8 +2269,13 @@ packages: cpu: [loong64] os: [linux] - '@rollup/rollup-linux-loong64-gnu@4.53.5': - resolution: {integrity: sha512-rIEC0hZ17A42iXtHX+EPJVL/CakHo+tT7W0pbzdAGuWOt2jxDFh7A/lRhsNHBcqL4T36+UiAgwO8pbmn3dE8wA==} + '@rollup/rollup-linux-loong64-gnu@4.58.0': + resolution: {integrity: sha512-iekUaLkfliAsDl4/xSdoCJ1gnnIXvoNz85C8U8+ZxknM5pBStfZjeXgB8lXobDQvvPRCN8FPmmuTtH+z95HTmg==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.58.0': + resolution: {integrity: sha512-68ofRgJNl/jYJbxFjCKE7IwhbfxOl1muPN4KbIqAIe32lm22KmU7E8OPvyy68HTNkI2iV/c8y2kSPSm2mW/Q9Q==} cpu: [loong64] os: [linux] @@ -2285,8 +2294,13 @@ packages: cpu: [ppc64] os: [linux] - '@rollup/rollup-linux-ppc64-gnu@4.53.5': - resolution: {integrity: sha512-T7l409NhUE552RcAOcmJHj3xyZ2h7vMWzcwQI0hvn5tqHh3oSoclf9WgTl+0QqffWFG8MEVZZP1/OBglKZx52Q==} + '@rollup/rollup-linux-ppc64-gnu@4.58.0': + resolution: {integrity: sha512-dpz8vT0i+JqUKuSNPCP5SYyIV2Lh0sNL1+FhM7eLC457d5B9/BC3kDPp5BBftMmTNsBarcPcoz5UGSsnCiw4XQ==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.58.0': + resolution: {integrity: sha512-4gdkkf9UJ7tafnweBCR/mk4jf3Jfl0cKX9Np80t5i78kjIH0ZdezUv/JDI2VtruE5lunfACqftJ8dIMGN4oHew==} cpu: [ppc64] os: [linux] @@ -2300,8 +2314,8 @@ packages: cpu: [riscv64] os: [linux] - '@rollup/rollup-linux-riscv64-gnu@4.53.5': - resolution: {integrity: sha512-7OK5/GhxbnrMcxIFoYfhV/TkknarkYC1hqUw1wU2xUN3TVRLNT5FmBv4KkheSG2xZ6IEbRAhTooTV2+R5Tk0lQ==} + '@rollup/rollup-linux-riscv64-gnu@4.58.0': + resolution: {integrity: sha512-YFS4vPnOkDTD/JriUeeZurFYoJhPf9GQQEF/v4lltp3mVcBmnsAdjEWhr2cjUCZzZNzxCG0HZOvJU44UGHSdzw==} cpu: [riscv64] os: [linux] @@ -2315,8 +2329,8 @@ packages: cpu: [riscv64] os: [linux] - '@rollup/rollup-linux-riscv64-musl@4.53.5': - resolution: {integrity: sha512-GwuDBE/PsXaTa76lO5eLJTyr2k8QkPipAyOrs4V/KJufHCZBJ495VCGJol35grx9xryk4V+2zd3Ri+3v7NPh+w==} + '@rollup/rollup-linux-riscv64-musl@4.58.0': + resolution: {integrity: sha512-x2xgZlFne+QVNKV8b4wwaCS8pwq3y14zedZ5DqLzjdRITvreBk//4Knbcvm7+lWmms9V9qFp60MtUd0/t/PXPw==} cpu: [riscv64] os: [linux] @@ -2330,8 +2344,8 @@ packages: cpu: [s390x] os: [linux] - '@rollup/rollup-linux-s390x-gnu@4.53.5': - resolution: {integrity: sha512-IAE1Ziyr1qNfnmiQLHBURAD+eh/zH1pIeJjeShleII7Vj8kyEm2PF77o+lf3WTHDpNJcu4IXJxNO0Zluro8bOw==} + '@rollup/rollup-linux-s390x-gnu@4.58.0': + resolution: {integrity: sha512-jIhrujyn4UnWF8S+DHSkAkDEO3hLX0cjzxJZPLF80xFyzyUIYgSMRcYQ3+uqEoyDD2beGq7Dj7edi8OnJcS/hg==} cpu: [s390x] os: [linux] @@ -2345,8 +2359,8 @@ packages: cpu: [x64] os: [linux] - '@rollup/rollup-linux-x64-gnu@4.53.5': - resolution: {integrity: sha512-Pg6E+oP7GvZ4XwgRJBuSXZjcqpIW3yCBhK4BcsANvb47qMvAbCjR6E+1a/U2WXz1JJxp9/4Dno3/iSJLcm5auw==} + '@rollup/rollup-linux-x64-gnu@4.58.0': + resolution: {integrity: sha512-+410Srdoh78MKSJxTQ+hZ/Mx+ajd6RjjPwBPNd0R3J9FtL6ZA0GqiiyNjCO9In0IzZkCNrpGymSfn+kgyPQocg==} cpu: [x64] os: [linux] @@ -2360,18 +2374,23 @@ packages: cpu: [x64] os: [linux] - '@rollup/rollup-linux-x64-musl@4.53.5': - resolution: {integrity: sha512-txGtluxDKTxaMDzUduGP0wdfng24y1rygUMnmlUJ88fzCCULCLn7oE5kb2+tRB+MWq1QDZT6ObT5RrR8HFRKqg==} + '@rollup/rollup-linux-x64-musl@4.58.0': + resolution: {integrity: sha512-ZjMyby5SICi227y1MTR3VYBpFTdZs823Rs/hpakufleBoufoOIB6jtm9FEoxn/cgO7l6PM2rCEl5Kre5vX0QrQ==} cpu: [x64] os: [linux] + '@rollup/rollup-openbsd-x64@4.58.0': + resolution: {integrity: sha512-ds4iwfYkSQ0k1nb8LTcyXw//ToHOnNTJtceySpL3fa7tc/AsE+UpUFphW126A6fKBGJD5dhRvg8zw1rvoGFxmw==} + cpu: [x64] + os: [openbsd] + '@rollup/rollup-openharmony-arm64@4.53.3': resolution: {integrity: sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==} cpu: [arm64] os: [openharmony] - '@rollup/rollup-openharmony-arm64@4.53.5': - resolution: {integrity: sha512-3DFiLPnTxiOQV993fMc+KO8zXHTcIjgaInrqlG8zDp1TlhYl6WgrOHuJkJQ6M8zHEcntSJsUp1XFZSY8C1DYbg==} + '@rollup/rollup-openharmony-arm64@4.58.0': + resolution: {integrity: sha512-fd/zpJniln4ICdPkjWFhZYeY/bpnaN9pGa6ko+5WD38I0tTqk9lXMgXZg09MNdhpARngmxiCg0B0XUamNw/5BQ==} cpu: [arm64] os: [openharmony] @@ -2385,8 +2404,8 @@ packages: cpu: [arm64] os: [win32] - '@rollup/rollup-win32-arm64-msvc@4.53.5': - resolution: {integrity: sha512-nggc/wPpNTgjGg75hu+Q/3i32R00Lq1B6N1DO7MCU340MRKL3WZJMjA9U4K4gzy3dkZPXm9E1Nc81FItBVGRlA==} + '@rollup/rollup-win32-arm64-msvc@4.58.0': + resolution: {integrity: sha512-YpG8dUOip7DCz3nr/JUfPbIUo+2d/dy++5bFzgi4ugOGBIox+qMbbqt/JoORwvI/C9Kn2tz6+Bieoqd5+B1CjA==} cpu: [arm64] os: [win32] @@ -2400,8 +2419,8 @@ packages: cpu: [ia32] os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.53.5': - resolution: {integrity: sha512-U/54pTbdQpPLBdEzCT6NBCFAfSZMvmjr0twhnD9f4EIvlm9wy3jjQ38yQj1AGznrNO65EWQMgm/QUjuIVrYF9w==} + '@rollup/rollup-win32-ia32-msvc@4.58.0': + resolution: {integrity: sha512-b9DI8jpFQVh4hIXFr0/+N/TzLdpBIoPzjt0Rt4xJbW3mzguV3mduR9cNgiuFcuL/TeORejJhCWiAXe3E/6PxWA==} cpu: [ia32] os: [win32] @@ -2410,8 +2429,8 @@ packages: cpu: [x64] os: [win32] - '@rollup/rollup-win32-x64-gnu@4.53.5': - resolution: {integrity: sha512-2NqKgZSuLH9SXBBV2dWNRCZmocgSOx8OJSdpRaEcRlIfX8YrKxUT6z0F1NpvDVhOsl190UFTRh2F2WDWWCYp3A==} + '@rollup/rollup-win32-x64-gnu@4.58.0': + resolution: {integrity: sha512-CSrVpmoRJFN06LL9xhkitkwUcTZtIotYAF5p6XOR2zW0Zz5mzb3IPpcoPhB02frzMHFNo1reQ9xSF5fFm3hUsQ==} cpu: [x64] os: [win32] @@ -2425,8 +2444,8 @@ packages: cpu: [x64] os: [win32] - '@rollup/rollup-win32-x64-msvc@4.53.5': - resolution: {integrity: sha512-JRpZUhCfhZ4keB5v0fe02gQJy05GqboPOaxvjugW04RLSYYoB/9t2lx2u/tMs/Na/1NXfY8QYjgRljRpN+MjTQ==} + '@rollup/rollup-win32-x64-msvc@4.58.0': + resolution: {integrity: sha512-QFsBgQNTnh5K0t/sBsjJLq24YVqEIVkGpfN2VHsnN90soZyhaiA9UUHufcctVNL4ypJY0wrwad0wslx2KJQ1/w==} cpu: [x64] os: [win32] @@ -3227,8 +3246,8 @@ packages: '@vitest/expect@4.0.10': resolution: {integrity: sha512-3QkTX/lK39FBNwARCQRSQr0TP9+ywSdxSX+LgbJ2M1WmveXP72anTbnp2yl5fH+dU6SUmBzNMrDHs80G8G2DZg==} - '@vitest/expect@4.0.16': - resolution: {integrity: sha512-eshqULT2It7McaJkQGLkPjPjNph+uevROGuIMJdG3V+0BSR2w9u6J9Lwu+E8cK5TETlfou8GRijhafIMhXsimA==} + '@vitest/expect@4.0.18': + resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==} '@vitest/mocker@3.2.4': resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} @@ -3252,8 +3271,8 @@ packages: vite: optional: true - '@vitest/mocker@4.0.16': - resolution: {integrity: sha512-yb6k4AZxJTB+q9ycAvsoxGn+j/po0UaPgajllBgt1PzoMAAmJGYFdDk0uCcRcxb3BrME34I6u8gHZTQlkqSZpg==} + '@vitest/mocker@4.0.18': + resolution: {integrity: sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==} peerDependencies: msw: ^2.4.9 vite: ^6.0.0 || ^7.0.0-0 @@ -3269,20 +3288,20 @@ packages: '@vitest/pretty-format@4.0.10': resolution: {integrity: sha512-99EQbpa/zuDnvVjthwz5bH9o8iPefoQZ63WV8+bsRJZNw3qQSvSltfut8yu1Jc9mqOYi7pEbsKxYTi/rjaq6PA==} - '@vitest/pretty-format@4.0.16': - resolution: {integrity: sha512-eNCYNsSty9xJKi/UdVD8Ou16alu7AYiS2fCPRs0b1OdhJiV89buAXQLpTbe+X8V9L6qrs9CqyvU7OaAopJYPsA==} + '@vitest/pretty-format@4.0.18': + resolution: {integrity: sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==} '@vitest/runner@4.0.10': resolution: {integrity: sha512-EXU2iSkKvNwtlL8L8doCpkyclw0mc/t4t9SeOnfOFPyqLmQwuceMPA4zJBa6jw0MKsZYbw7kAn+gl7HxrlB8UQ==} - '@vitest/runner@4.0.16': - resolution: {integrity: sha512-VWEDm5Wv9xEo80ctjORcTQRJ539EGPB3Pb9ApvVRAY1U/WkHXmmYISqU5E79uCwcW7xYUV38gwZD+RV755fu3Q==} + '@vitest/runner@4.0.18': + resolution: {integrity: sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==} '@vitest/snapshot@4.0.10': resolution: {integrity: sha512-2N4X2ZZl7kZw0qeGdQ41H0KND96L3qX1RgwuCfy6oUsF2ISGD/HpSbmms+CkIOsQmg2kulwfhJ4CI0asnZlvkg==} - '@vitest/snapshot@4.0.16': - resolution: {integrity: sha512-sf6NcrYhYBsSYefxnry+DR8n3UV4xWZwWxYbCJUt2YdvtqzSPR7VfGrY0zsv090DAbjFZsi7ZaMi1KnSRyK1XA==} + '@vitest/snapshot@4.0.18': + resolution: {integrity: sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==} '@vitest/spy@3.2.4': resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} @@ -3290,8 +3309,8 @@ packages: '@vitest/spy@4.0.10': resolution: {integrity: sha512-AsY6sVS8OLb96GV5RoG8B6I35GAbNrC49AO+jNRF9YVGb/g9t+hzNm1H6kD0NDp8tt7VJLs6hb7YMkDXqu03iw==} - '@vitest/spy@4.0.16': - resolution: {integrity: sha512-4jIOWjKP0ZUaEmJm00E0cOBLU+5WE0BpeNr3XN6TEF05ltro6NJqHWxXD0kA8/Zc8Nh23AT8WQxwNG+WeROupw==} + '@vitest/spy@4.0.18': + resolution: {integrity: sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==} '@vitest/ui@4.0.10': resolution: {integrity: sha512-oWtNM89Np+YsQO3ttT5i1Aer/0xbzQzp66NzuJn/U16bB7MnvSzdLKXgk1kkMLYyKSSzA2ajzqMkYheaE9opuQ==} @@ -3304,8 +3323,8 @@ packages: '@vitest/utils@4.0.10': resolution: {integrity: sha512-kOuqWnEwZNtQxMKg3WmPK1vmhZu9WcoX69iwWjVz+jvKTsF1emzsv3eoPcDr6ykA3qP2bsCQE7CwqfNtAVzsmg==} - '@vitest/utils@4.0.16': - resolution: {integrity: sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA==} + '@vitest/utils@4.0.18': + resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==} '@vueless/storybook-dark-mode@9.0.5': resolution: {integrity: sha512-JU0bQe+KHvmg04k2yprzVkM0d8xdKwqFaFuQmO7afIUm//ttroDpfHfPzwLZuTDW9coB5bt2+qMSHZOBbt0w4g==} @@ -3329,6 +3348,11 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + agent-base@7.1.1: resolution: {integrity: sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==} engines: {node: '>= 14'} @@ -3489,8 +3513,8 @@ packages: peerDependencies: axios: '>= 0.17.0' - axios@1.12.0: - resolution: {integrity: sha512-oXTDccv8PcfjZmPGlWsPSwtOJCZ/b6W5jAMCNcfwJbCzDckwG0jrYJFaWH1yvivfCXjVzV/SPDEhMB3Q+DSurg==} + axios@1.13.5: + resolution: {integrity: sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==} axobject-query@4.1.0: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} @@ -3503,9 +3527,9 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - balanced-match@3.0.1: - resolution: {integrity: sha512-vjtV3hiLqYDNRoiAv0zC4QaGAMPomEoq83PRmYIofPswwZurCeWR5LByXm7SyoL0Zh5+2z0+HC7jG8gSZJUh0w==} - engines: {node: '>= 16'} + balanced-match@4.0.3: + resolution: {integrity: sha512-1pHv8LX9CpKut1Zp4EXey7Z8OfH11ONNH6Dhi2WDUt31VVZFXZzKwXcysBgqSumFCmR+0dqjMK5v5JiFHzi0+g==} + engines: {node: 20 || >=22} base64-arraybuffer@1.0.2: resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==} @@ -3534,9 +3558,9 @@ packages: brace-expansion@2.0.2: resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} - brace-expansion@4.0.1: - resolution: {integrity: sha512-YClrbvTCXGe70pU2JiEiPLYXO9gQkyxYeKpJIQHVS/gOs6EWMQP2RYBwjFLNT322Ji8TOC3IMPfsYCedNpzKfA==} - engines: {node: '>= 18'} + brace-expansion@5.0.2: + resolution: {integrity: sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==} + engines: {node: 20 || >=22} braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} @@ -3815,8 +3839,8 @@ packages: copy-to-clipboard@3.3.3: resolution: {integrity: sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==} - core-js@3.39.0: - resolution: {integrity: sha512-raM0ew0/jJUqkJ0E6e8UDtl+y/7ktFivgWvqw8dNSQeNWoSDLvQ1H/RN3aPXB9tBd4/FhyR4RDPGhsNIMsAn7g==} + core-js@3.48.0: + resolution: {integrity: sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==} core-util-is@1.0.2: resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==} @@ -4070,6 +4094,10 @@ packages: resolution: {integrity: sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==} engines: {node: '>=0.3.1'} + diff@5.2.2: + resolution: {integrity: sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A==} + engines: {node: '>=0.3.1'} + doctrine@2.1.0: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} @@ -4090,6 +4118,9 @@ packages: dompurify@3.2.4: resolution: {integrity: sha512-ysFSFEDVduQpyhzAob/kkuJjf5zWkZD8/A9ywSp1byueyuCfHamrCBa14/Oc2iiB0e51B+NpxSl5gmzn+Ms/mg==} + dompurify@3.3.1: + resolution: {integrity: sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==} + dot-case@3.0.4: resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==} @@ -4193,8 +4224,8 @@ packages: engines: {node: '>=18'} hasBin: true - esbuild@0.27.2: - resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==} + esbuild@0.27.3: + resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} engines: {node: '>=18'} hasBin: true @@ -4508,6 +4539,10 @@ packages: resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==} engines: {node: '>= 6'} + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + formik@2.1.7: resolution: {integrity: sha512-n1wviIh0JsvHqj9PufNvOV+fS7mFwh9FfMxxTMnTrKR/uVYMS06DKaivXBlJdDF0qEwTcPHxSmIQ3deFHL3Hsg==} peerDependencies: @@ -5098,8 +5133,8 @@ packages: peerDependencies: jspdf: ^2 || ^3 - jspdf@4.0.0: - resolution: {integrity: sha512-w12U97Z6edKd2tXDn3LzTLg7C7QLJlx0BPfM3ecjK2BckUl9/81vZ+r5gK4/3KQdhAcEZhENUxRhtgYBj75MqQ==} + jspdf@4.2.0: + resolution: {integrity: sha512-hR/hnRevAXXlrjeqU5oahOE+Ln9ORJUB5brLHHqH67A+RBQZuFr5GkbI9XQI8OUFSEezKegsi45QRpc4bGj75Q==} jsprim@2.0.2: resolution: {integrity: sha512-gqXddjPqQ6G40VdnI6T6yObEC+pDNvyP95wdQhkWkg7crHH3km5qP1FsOXEkzEQwnz6gz5qGTn1c2Y52wP3OyQ==} @@ -5287,8 +5322,8 @@ packages: map-or-similar@1.5.0: resolution: {integrity: sha512-0aF7ZmVon1igznGI4VS30yugpduQW3y3GkcgGJOp7d8x8QrizhigUxjI/m2UojsXXto+jLAH3KSz+xOJTiORjg==} - markdown-it@14.1.0: - resolution: {integrity: sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==} + markdown-it@14.1.1: + resolution: {integrity: sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==} hasBin: true math-intrinsics@1.1.0: @@ -6027,8 +6062,8 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true - rollup@4.53.5: - resolution: {integrity: sha512-iTNAbFSlRpcHeeWu73ywU/8KuU/LZmNCSxp6fjQkJBD3ivUb8tpDrXhIxEzA05HlYMEwmtaUnb3RP+YNv162OQ==} + rollup@4.58.0: + resolution: {integrity: sha512-wbT0mBmWbIvvq8NeEYWWvevvxnOyhKChir47S66WCxw1SXqhw7ssIYejnQEVt7XYQpsj2y8F9PM+Cr3SNEa0gw==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true @@ -6755,8 +6790,8 @@ packages: yaml: optional: true - vite@7.3.0: - resolution: {integrity: sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==} + vite@7.3.1: + resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -6829,18 +6864,18 @@ packages: jsdom: optional: true - vitest@4.0.16: - resolution: {integrity: sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==} + vitest@4.0.18: + resolution: {integrity: sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@opentelemetry/api': ^1.9.0 '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 4.0.16 - '@vitest/browser-preview': 4.0.16 - '@vitest/browser-webdriverio': 4.0.16 - '@vitest/ui': 4.0.16 + '@vitest/browser-playwright': 4.0.18 + '@vitest/browser-preview': 4.0.18 + '@vitest/browser-webdriverio': 4.0.18 + '@vitest/ui': 4.0.18 happy-dom: '*' jsdom: '*' peerDependenciesMeta: @@ -7227,6 +7262,8 @@ snapshots: '@babel/runtime@7.28.4': {} + '@babel/runtime@7.28.6': {} + '@babel/template@7.27.0': dependencies: '@babel/code-frame': 7.26.2 @@ -7297,7 +7334,7 @@ snapshots: combined-stream: 1.0.8 extend: 3.0.2 forever-agent: 0.6.1 - form-data: 4.0.4 + form-data: 4.0.5 http-signature: 1.4.0 is-typedarray: 1.0.0 isstream: 0.1.2 @@ -7431,7 +7468,7 @@ snapshots: '@esbuild/aix-ppc64@0.25.3': optional: true - '@esbuild/aix-ppc64@0.27.2': + '@esbuild/aix-ppc64@0.27.3': optional: true '@esbuild/android-arm64@0.25.12': @@ -7440,7 +7477,7 @@ snapshots: '@esbuild/android-arm64@0.25.3': optional: true - '@esbuild/android-arm64@0.27.2': + '@esbuild/android-arm64@0.27.3': optional: true '@esbuild/android-arm@0.25.12': @@ -7449,7 +7486,7 @@ snapshots: '@esbuild/android-arm@0.25.3': optional: true - '@esbuild/android-arm@0.27.2': + '@esbuild/android-arm@0.27.3': optional: true '@esbuild/android-x64@0.25.12': @@ -7458,7 +7495,7 @@ snapshots: '@esbuild/android-x64@0.25.3': optional: true - '@esbuild/android-x64@0.27.2': + '@esbuild/android-x64@0.27.3': optional: true '@esbuild/darwin-arm64@0.25.12': @@ -7467,7 +7504,7 @@ snapshots: '@esbuild/darwin-arm64@0.25.3': optional: true - '@esbuild/darwin-arm64@0.27.2': + '@esbuild/darwin-arm64@0.27.3': optional: true '@esbuild/darwin-x64@0.25.12': @@ -7476,7 +7513,7 @@ snapshots: '@esbuild/darwin-x64@0.25.3': optional: true - '@esbuild/darwin-x64@0.27.2': + '@esbuild/darwin-x64@0.27.3': optional: true '@esbuild/freebsd-arm64@0.25.12': @@ -7485,7 +7522,7 @@ snapshots: '@esbuild/freebsd-arm64@0.25.3': optional: true - '@esbuild/freebsd-arm64@0.27.2': + '@esbuild/freebsd-arm64@0.27.3': optional: true '@esbuild/freebsd-x64@0.25.12': @@ -7494,7 +7531,7 @@ snapshots: '@esbuild/freebsd-x64@0.25.3': optional: true - '@esbuild/freebsd-x64@0.27.2': + '@esbuild/freebsd-x64@0.27.3': optional: true '@esbuild/linux-arm64@0.25.12': @@ -7503,7 +7540,7 @@ snapshots: '@esbuild/linux-arm64@0.25.3': optional: true - '@esbuild/linux-arm64@0.27.2': + '@esbuild/linux-arm64@0.27.3': optional: true '@esbuild/linux-arm@0.25.12': @@ -7512,7 +7549,7 @@ snapshots: '@esbuild/linux-arm@0.25.3': optional: true - '@esbuild/linux-arm@0.27.2': + '@esbuild/linux-arm@0.27.3': optional: true '@esbuild/linux-ia32@0.25.12': @@ -7521,7 +7558,7 @@ snapshots: '@esbuild/linux-ia32@0.25.3': optional: true - '@esbuild/linux-ia32@0.27.2': + '@esbuild/linux-ia32@0.27.3': optional: true '@esbuild/linux-loong64@0.25.12': @@ -7530,7 +7567,7 @@ snapshots: '@esbuild/linux-loong64@0.25.3': optional: true - '@esbuild/linux-loong64@0.27.2': + '@esbuild/linux-loong64@0.27.3': optional: true '@esbuild/linux-mips64el@0.25.12': @@ -7539,7 +7576,7 @@ snapshots: '@esbuild/linux-mips64el@0.25.3': optional: true - '@esbuild/linux-mips64el@0.27.2': + '@esbuild/linux-mips64el@0.27.3': optional: true '@esbuild/linux-ppc64@0.25.12': @@ -7548,7 +7585,7 @@ snapshots: '@esbuild/linux-ppc64@0.25.3': optional: true - '@esbuild/linux-ppc64@0.27.2': + '@esbuild/linux-ppc64@0.27.3': optional: true '@esbuild/linux-riscv64@0.25.12': @@ -7557,7 +7594,7 @@ snapshots: '@esbuild/linux-riscv64@0.25.3': optional: true - '@esbuild/linux-riscv64@0.27.2': + '@esbuild/linux-riscv64@0.27.3': optional: true '@esbuild/linux-s390x@0.25.12': @@ -7566,7 +7603,7 @@ snapshots: '@esbuild/linux-s390x@0.25.3': optional: true - '@esbuild/linux-s390x@0.27.2': + '@esbuild/linux-s390x@0.27.3': optional: true '@esbuild/linux-x64@0.25.12': @@ -7575,7 +7612,7 @@ snapshots: '@esbuild/linux-x64@0.25.3': optional: true - '@esbuild/linux-x64@0.27.2': + '@esbuild/linux-x64@0.27.3': optional: true '@esbuild/netbsd-arm64@0.25.12': @@ -7584,7 +7621,7 @@ snapshots: '@esbuild/netbsd-arm64@0.25.3': optional: true - '@esbuild/netbsd-arm64@0.27.2': + '@esbuild/netbsd-arm64@0.27.3': optional: true '@esbuild/netbsd-x64@0.25.12': @@ -7593,7 +7630,7 @@ snapshots: '@esbuild/netbsd-x64@0.25.3': optional: true - '@esbuild/netbsd-x64@0.27.2': + '@esbuild/netbsd-x64@0.27.3': optional: true '@esbuild/openbsd-arm64@0.25.12': @@ -7602,7 +7639,7 @@ snapshots: '@esbuild/openbsd-arm64@0.25.3': optional: true - '@esbuild/openbsd-arm64@0.27.2': + '@esbuild/openbsd-arm64@0.27.3': optional: true '@esbuild/openbsd-x64@0.25.12': @@ -7611,13 +7648,13 @@ snapshots: '@esbuild/openbsd-x64@0.25.3': optional: true - '@esbuild/openbsd-x64@0.27.2': + '@esbuild/openbsd-x64@0.27.3': optional: true '@esbuild/openharmony-arm64@0.25.12': optional: true - '@esbuild/openharmony-arm64@0.27.2': + '@esbuild/openharmony-arm64@0.27.3': optional: true '@esbuild/sunos-x64@0.25.12': @@ -7626,7 +7663,7 @@ snapshots: '@esbuild/sunos-x64@0.25.3': optional: true - '@esbuild/sunos-x64@0.27.2': + '@esbuild/sunos-x64@0.27.3': optional: true '@esbuild/win32-arm64@0.25.12': @@ -7635,7 +7672,7 @@ snapshots: '@esbuild/win32-arm64@0.25.3': optional: true - '@esbuild/win32-arm64@0.27.2': + '@esbuild/win32-arm64@0.27.3': optional: true '@esbuild/win32-ia32@0.25.12': @@ -7644,7 +7681,7 @@ snapshots: '@esbuild/win32-ia32@0.25.3': optional: true - '@esbuild/win32-ia32@0.27.2': + '@esbuild/win32-ia32@0.27.3': optional: true '@esbuild/win32-x64@0.25.12': @@ -7653,7 +7690,7 @@ snapshots: '@esbuild/win32-x64@0.25.3': optional: true - '@esbuild/win32-x64@0.27.2': + '@esbuild/win32-x64@0.27.3': optional: true '@eslint-community/eslint-utils@4.4.1(eslint@9.31.0(jiti@2.4.2))': @@ -7888,12 +7925,12 @@ snapshots: '@istanbuljs/schema@0.1.3': {} - '@joshwooding/vite-plugin-react-docgen-typescript@0.6.0(typescript@5.9.3)(vite@7.3.0(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))': + '@joshwooding/vite-plugin-react-docgen-typescript@0.6.0(typescript@5.9.3)(vite@7.3.1(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))': dependencies: glob: 10.5.0 magic-string: 0.30.17 react-docgen-typescript: 2.2.2(typescript@5.9.3) - vite: 7.3.0(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + vite: 7.3.1(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) optionalDependencies: typescript: 5.9.3 @@ -8018,7 +8055,7 @@ snapshots: '@mui/private-theming@7.1.0(@types/react@19.1.6)(react@19.1.0)': dependencies: - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.28.6 '@mui/utils': 7.1.0(@types/react@19.1.6)(react@19.1.0) prop-types: 15.8.1 react: 19.1.0 @@ -8027,7 +8064,7 @@ snapshots: '@mui/styled-engine@7.1.0(@emotion/react@11.13.5(@types/react@19.1.6)(react@19.1.0))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@19.1.6)(react@19.1.0))(@types/react@19.1.6)(react@19.1.0))(react@19.1.0)': dependencies: - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.28.6 '@emotion/cache': 11.13.5 '@emotion/serialize': 1.3.3 '@emotion/sheet': 1.4.0 @@ -8040,7 +8077,7 @@ snapshots: '@mui/system@7.1.0(@emotion/react@11.13.5(@types/react@19.1.6)(react@19.1.0))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@19.1.6)(react@19.1.0))(@types/react@19.1.6)(react@19.1.0))(@types/react@19.1.6)(react@19.1.0)': dependencies: - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.27.1 '@mui/private-theming': 7.1.0(@types/react@19.1.6)(react@19.1.0) '@mui/styled-engine': 7.1.0(@emotion/react@11.13.5(@types/react@19.1.6)(react@19.1.0))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@19.1.6)(react@19.1.0))(@types/react@19.1.6)(react@19.1.0))(react@19.1.0) '@mui/types': 7.4.2(@types/react@19.1.6) @@ -8056,13 +8093,13 @@ snapshots: '@mui/types@7.4.2(@types/react@19.1.6)': dependencies: - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.27.1 optionalDependencies: '@types/react': 19.1.6 '@mui/types@7.4.6(@types/react@19.1.6)': dependencies: - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.28.6 optionalDependencies: '@types/react': 19.1.6 @@ -8211,21 +8248,21 @@ snapshots: '@rolldown/pluginutils@1.0.0-beta.32': {} - '@rollup/pluginutils@5.1.3(rollup@4.53.5)': + '@rollup/pluginutils@5.1.3(rollup@4.58.0)': dependencies: '@types/estree': 1.0.7 estree-walker: 2.0.2 picomatch: 4.0.3 optionalDependencies: - rollup: 4.53.5 + rollup: 4.58.0 - '@rollup/pluginutils@5.2.0(rollup@4.53.5)': + '@rollup/pluginutils@5.2.0(rollup@4.58.0)': dependencies: '@types/estree': 1.0.8 estree-walker: 2.0.2 picomatch: 4.0.3 optionalDependencies: - rollup: 4.53.5 + rollup: 4.58.0 '@rollup/rollup-android-arm-eabi@4.40.1': optional: true @@ -8233,7 +8270,7 @@ snapshots: '@rollup/rollup-android-arm-eabi@4.53.3': optional: true - '@rollup/rollup-android-arm-eabi@4.53.5': + '@rollup/rollup-android-arm-eabi@4.58.0': optional: true '@rollup/rollup-android-arm64@4.40.1': @@ -8242,7 +8279,7 @@ snapshots: '@rollup/rollup-android-arm64@4.53.3': optional: true - '@rollup/rollup-android-arm64@4.53.5': + '@rollup/rollup-android-arm64@4.58.0': optional: true '@rollup/rollup-darwin-arm64@4.40.1': @@ -8251,7 +8288,7 @@ snapshots: '@rollup/rollup-darwin-arm64@4.53.3': optional: true - '@rollup/rollup-darwin-arm64@4.53.5': + '@rollup/rollup-darwin-arm64@4.58.0': optional: true '@rollup/rollup-darwin-x64@4.40.1': @@ -8260,7 +8297,7 @@ snapshots: '@rollup/rollup-darwin-x64@4.53.3': optional: true - '@rollup/rollup-darwin-x64@4.53.5': + '@rollup/rollup-darwin-x64@4.58.0': optional: true '@rollup/rollup-freebsd-arm64@4.40.1': @@ -8269,7 +8306,7 @@ snapshots: '@rollup/rollup-freebsd-arm64@4.53.3': optional: true - '@rollup/rollup-freebsd-arm64@4.53.5': + '@rollup/rollup-freebsd-arm64@4.58.0': optional: true '@rollup/rollup-freebsd-x64@4.40.1': @@ -8278,7 +8315,7 @@ snapshots: '@rollup/rollup-freebsd-x64@4.53.3': optional: true - '@rollup/rollup-freebsd-x64@4.53.5': + '@rollup/rollup-freebsd-x64@4.58.0': optional: true '@rollup/rollup-linux-arm-gnueabihf@4.40.1': @@ -8287,7 +8324,7 @@ snapshots: '@rollup/rollup-linux-arm-gnueabihf@4.53.3': optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.53.5': + '@rollup/rollup-linux-arm-gnueabihf@4.58.0': optional: true '@rollup/rollup-linux-arm-musleabihf@4.40.1': @@ -8296,7 +8333,7 @@ snapshots: '@rollup/rollup-linux-arm-musleabihf@4.53.3': optional: true - '@rollup/rollup-linux-arm-musleabihf@4.53.5': + '@rollup/rollup-linux-arm-musleabihf@4.58.0': optional: true '@rollup/rollup-linux-arm64-gnu@4.40.1': @@ -8305,7 +8342,7 @@ snapshots: '@rollup/rollup-linux-arm64-gnu@4.53.3': optional: true - '@rollup/rollup-linux-arm64-gnu@4.53.5': + '@rollup/rollup-linux-arm64-gnu@4.58.0': optional: true '@rollup/rollup-linux-arm64-musl@4.40.1': @@ -8314,13 +8351,16 @@ snapshots: '@rollup/rollup-linux-arm64-musl@4.53.3': optional: true - '@rollup/rollup-linux-arm64-musl@4.53.5': + '@rollup/rollup-linux-arm64-musl@4.58.0': optional: true '@rollup/rollup-linux-loong64-gnu@4.53.3': optional: true - '@rollup/rollup-linux-loong64-gnu@4.53.5': + '@rollup/rollup-linux-loong64-gnu@4.58.0': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.58.0': optional: true '@rollup/rollup-linux-loongarch64-gnu@4.40.1': @@ -8332,7 +8372,10 @@ snapshots: '@rollup/rollup-linux-ppc64-gnu@4.53.3': optional: true - '@rollup/rollup-linux-ppc64-gnu@4.53.5': + '@rollup/rollup-linux-ppc64-gnu@4.58.0': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.58.0': optional: true '@rollup/rollup-linux-riscv64-gnu@4.40.1': @@ -8341,7 +8384,7 @@ snapshots: '@rollup/rollup-linux-riscv64-gnu@4.53.3': optional: true - '@rollup/rollup-linux-riscv64-gnu@4.53.5': + '@rollup/rollup-linux-riscv64-gnu@4.58.0': optional: true '@rollup/rollup-linux-riscv64-musl@4.40.1': @@ -8350,7 +8393,7 @@ snapshots: '@rollup/rollup-linux-riscv64-musl@4.53.3': optional: true - '@rollup/rollup-linux-riscv64-musl@4.53.5': + '@rollup/rollup-linux-riscv64-musl@4.58.0': optional: true '@rollup/rollup-linux-s390x-gnu@4.40.1': @@ -8359,7 +8402,7 @@ snapshots: '@rollup/rollup-linux-s390x-gnu@4.53.3': optional: true - '@rollup/rollup-linux-s390x-gnu@4.53.5': + '@rollup/rollup-linux-s390x-gnu@4.58.0': optional: true '@rollup/rollup-linux-x64-gnu@4.40.1': @@ -8368,7 +8411,7 @@ snapshots: '@rollup/rollup-linux-x64-gnu@4.53.3': optional: true - '@rollup/rollup-linux-x64-gnu@4.53.5': + '@rollup/rollup-linux-x64-gnu@4.58.0': optional: true '@rollup/rollup-linux-x64-musl@4.40.1': @@ -8377,13 +8420,16 @@ snapshots: '@rollup/rollup-linux-x64-musl@4.53.3': optional: true - '@rollup/rollup-linux-x64-musl@4.53.5': + '@rollup/rollup-linux-x64-musl@4.58.0': + optional: true + + '@rollup/rollup-openbsd-x64@4.58.0': optional: true '@rollup/rollup-openharmony-arm64@4.53.3': optional: true - '@rollup/rollup-openharmony-arm64@4.53.5': + '@rollup/rollup-openharmony-arm64@4.58.0': optional: true '@rollup/rollup-win32-arm64-msvc@4.40.1': @@ -8392,7 +8438,7 @@ snapshots: '@rollup/rollup-win32-arm64-msvc@4.53.3': optional: true - '@rollup/rollup-win32-arm64-msvc@4.53.5': + '@rollup/rollup-win32-arm64-msvc@4.58.0': optional: true '@rollup/rollup-win32-ia32-msvc@4.40.1': @@ -8401,13 +8447,13 @@ snapshots: '@rollup/rollup-win32-ia32-msvc@4.53.3': optional: true - '@rollup/rollup-win32-ia32-msvc@4.53.5': + '@rollup/rollup-win32-ia32-msvc@4.58.0': optional: true '@rollup/rollup-win32-x64-gnu@4.53.3': optional: true - '@rollup/rollup-win32-x64-gnu@4.53.5': + '@rollup/rollup-win32-x64-gnu@4.58.0': optional: true '@rollup/rollup-win32-x64-msvc@4.40.1': @@ -8416,7 +8462,7 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.53.3': optional: true - '@rollup/rollup-win32-x64-msvc@4.53.5': + '@rollup/rollup-win32-x64-msvc@4.58.0': optional: true '@sentry-internal/browser-utils@9.19.0': @@ -8510,12 +8556,12 @@ snapshots: transitivePeerDependencies: - '@types/react' - '@storybook/builder-vite@9.0.12(storybook@9.1.17(@testing-library/dom@10.4.0)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(prettier@3.5.3)(vite@7.3.0(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)))(vite@7.3.0(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))': + '@storybook/builder-vite@9.0.12(storybook@9.1.17(@testing-library/dom@10.4.0)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(prettier@3.5.3)(vite@7.3.1(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)))(vite@7.3.1(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))': dependencies: - '@storybook/csf-plugin': 9.0.12(storybook@9.1.17(@testing-library/dom@10.4.0)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(prettier@3.5.3)(vite@7.3.0(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))) - storybook: 9.1.17(@testing-library/dom@10.4.0)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(prettier@3.5.3)(vite@7.3.0(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + '@storybook/csf-plugin': 9.0.12(storybook@9.1.17(@testing-library/dom@10.4.0)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(prettier@3.5.3)(vite@7.3.1(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))) + storybook: 9.1.17(@testing-library/dom@10.4.0)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(prettier@3.5.3)(vite@7.3.1(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) ts-dedent: 2.2.0 - vite: 7.3.0(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + vite: 7.3.1(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) '@storybook/builder-vite@9.1.17(storybook@9.1.17(@testing-library/dom@10.4.0)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(prettier@3.5.3)(vite@7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)))(vite@7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))': dependencies: @@ -8524,9 +8570,9 @@ snapshots: ts-dedent: 2.2.0 vite: 7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) - '@storybook/csf-plugin@9.0.12(storybook@9.1.17(@testing-library/dom@10.4.0)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(prettier@3.5.3)(vite@7.3.0(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)))': + '@storybook/csf-plugin@9.0.12(storybook@9.1.17(@testing-library/dom@10.4.0)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(prettier@3.5.3)(vite@7.3.1(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)))': dependencies: - storybook: 9.1.17(@testing-library/dom@10.4.0)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(prettier@3.5.3)(vite@7.3.0(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + storybook: 9.1.17(@testing-library/dom@10.4.0)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(prettier@3.5.3)(vite@7.3.1(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) unplugin: 1.16.0 '@storybook/csf-plugin@9.1.17(storybook@9.1.17(@testing-library/dom@10.4.0)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(prettier@3.5.3)(vite@7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)))': @@ -8541,11 +8587,11 @@ snapshots: react: 19.1.0 react-dom: 19.1.0(react@19.1.0) - '@storybook/react-dom-shim@9.0.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(storybook@9.1.17(@testing-library/dom@10.4.0)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(prettier@3.5.3)(vite@7.3.0(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)))': + '@storybook/react-dom-shim@9.0.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(storybook@9.1.17(@testing-library/dom@10.4.0)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(prettier@3.5.3)(vite@7.3.1(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)))': dependencies: react: 19.1.0 react-dom: 19.1.0(react@19.1.0) - storybook: 9.1.17(@testing-library/dom@10.4.0)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(prettier@3.5.3)(vite@7.3.0(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + storybook: 9.1.17(@testing-library/dom@10.4.0)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(prettier@3.5.3)(vite@7.3.1(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) '@storybook/react-dom-shim@9.1.17(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(storybook@9.1.17(@testing-library/dom@10.4.0)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(prettier@3.5.3)(vite@7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)))': dependencies: @@ -8553,30 +8599,30 @@ snapshots: react-dom: 19.1.0(react@19.1.0) storybook: 9.1.17(@testing-library/dom@10.4.0)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(prettier@3.5.3)(vite@7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) - '@storybook/react-vite@9.0.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.53.5)(storybook@9.1.17(@testing-library/dom@10.4.0)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(prettier@3.5.3)(vite@7.3.0(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)))(typescript@5.9.3)(vite@7.3.0(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))': + '@storybook/react-vite@9.0.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.58.0)(storybook@9.1.17(@testing-library/dom@10.4.0)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(prettier@3.5.3)(vite@7.3.1(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)))(typescript@5.9.3)(vite@7.3.1(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))': dependencies: - '@joshwooding/vite-plugin-react-docgen-typescript': 0.6.0(typescript@5.9.3)(vite@7.3.0(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) - '@rollup/pluginutils': 5.1.3(rollup@4.53.5) - '@storybook/builder-vite': 9.0.12(storybook@9.1.17(@testing-library/dom@10.4.0)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(prettier@3.5.3)(vite@7.3.0(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)))(vite@7.3.0(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) - '@storybook/react': 9.0.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(storybook@9.1.17(@testing-library/dom@10.4.0)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(prettier@3.5.3)(vite@7.3.0(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)))(typescript@5.9.3) + '@joshwooding/vite-plugin-react-docgen-typescript': 0.6.0(typescript@5.9.3)(vite@7.3.1(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + '@rollup/pluginutils': 5.1.3(rollup@4.58.0) + '@storybook/builder-vite': 9.0.12(storybook@9.1.17(@testing-library/dom@10.4.0)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(prettier@3.5.3)(vite@7.3.1(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)))(vite@7.3.1(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + '@storybook/react': 9.0.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(storybook@9.1.17(@testing-library/dom@10.4.0)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(prettier@3.5.3)(vite@7.3.1(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)))(typescript@5.9.3) find-up: 7.0.0 magic-string: 0.30.17 react: 19.1.0 react-docgen: 8.0.0 react-dom: 19.1.0(react@19.1.0) resolve: 1.22.8 - storybook: 9.1.17(@testing-library/dom@10.4.0)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(prettier@3.5.3)(vite@7.3.0(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + storybook: 9.1.17(@testing-library/dom@10.4.0)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(prettier@3.5.3)(vite@7.3.1(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) tsconfig-paths: 4.2.0 - vite: 7.3.0(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + vite: 7.3.1(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) transitivePeerDependencies: - rollup - supports-color - typescript - '@storybook/react-vite@9.1.17(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.53.5)(storybook@9.1.17(@testing-library/dom@10.4.0)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(prettier@3.5.3)(vite@7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)))(typescript@5.9.3)(vite@7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))': + '@storybook/react-vite@9.1.17(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.58.0)(storybook@9.1.17(@testing-library/dom@10.4.0)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(prettier@3.5.3)(vite@7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)))(typescript@5.9.3)(vite@7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))': dependencies: '@joshwooding/vite-plugin-react-docgen-typescript': 0.6.1(typescript@5.9.3)(vite@7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) - '@rollup/pluginutils': 5.2.0(rollup@4.53.5) + '@rollup/pluginutils': 5.2.0(rollup@4.58.0) '@storybook/builder-vite': 9.1.17(storybook@9.1.17(@testing-library/dom@10.4.0)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(prettier@3.5.3)(vite@7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)))(vite@7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) '@storybook/react': 9.1.17(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(storybook@9.1.17(@testing-library/dom@10.4.0)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(prettier@3.5.3)(vite@7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)))(typescript@5.9.3) find-up: 7.0.0 @@ -8593,13 +8639,13 @@ snapshots: - supports-color - typescript - '@storybook/react@9.0.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(storybook@9.1.17(@testing-library/dom@10.4.0)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(prettier@3.5.3)(vite@7.3.0(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)))(typescript@5.9.3)': + '@storybook/react@9.0.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(storybook@9.1.17(@testing-library/dom@10.4.0)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(prettier@3.5.3)(vite@7.3.1(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)))(typescript@5.9.3)': dependencies: '@storybook/global': 5.0.0 - '@storybook/react-dom-shim': 9.0.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(storybook@9.1.17(@testing-library/dom@10.4.0)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(prettier@3.5.3)(vite@7.3.0(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))) + '@storybook/react-dom-shim': 9.0.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(storybook@9.1.17(@testing-library/dom@10.4.0)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(prettier@3.5.3)(vite@7.3.1(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) - storybook: 9.1.17(@testing-library/dom@10.4.0)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(prettier@3.5.3)(vite@7.3.0(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + storybook: 9.1.17(@testing-library/dom@10.4.0)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(prettier@3.5.3)(vite@7.3.1(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) optionalDependencies: typescript: 5.9.3 @@ -9275,7 +9321,7 @@ snapshots: transitivePeerDependencies: - '@swc/helpers' - '@vitest/coverage-v8@3.2.4(vitest@4.0.16(@types/node@22.18.1)(@vitest/ui@4.0.10)(jiti@2.4.2)(jsdom@24.1.3)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))': + '@vitest/coverage-v8@3.2.4(vitest@4.0.18(@types/node@22.18.1)(@vitest/ui@4.0.10(vitest@4.0.10))(jiti@2.4.2)(jsdom@24.1.3)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 @@ -9290,7 +9336,7 @@ snapshots: std-env: 3.9.0 test-exclude: 7.0.1 tinyrainbow: 2.0.0 - vitest: 4.0.16(@types/node@22.18.1)(@vitest/ui@4.0.10)(jiti@2.4.2)(jsdom@24.1.3)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + vitest: 4.0.18(@types/node@22.18.1)(@vitest/ui@4.0.10(vitest@4.0.10))(jiti@2.4.2)(jsdom@24.1.3)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) transitivePeerDependencies: - supports-color @@ -9311,12 +9357,12 @@ snapshots: chai: 6.2.1 tinyrainbow: 3.0.3 - '@vitest/expect@4.0.16': + '@vitest/expect@4.0.18': dependencies: '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.3 - '@vitest/spy': 4.0.16 - '@vitest/utils': 4.0.16 + '@vitest/spy': 4.0.18 + '@vitest/utils': 4.0.18 chai: 6.2.1 tinyrainbow: 3.0.3 @@ -9329,14 +9375,14 @@ snapshots: msw: 2.6.5(@types/node@22.18.1)(typescript@5.9.3) vite: 7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) - '@vitest/mocker@3.2.4(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(vite@7.3.0(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))': + '@vitest/mocker@3.2.4(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(vite@7.3.1(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: msw: 2.6.5(@types/node@22.18.1)(typescript@5.9.3) - vite: 7.3.0(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + vite: 7.3.1(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) '@vitest/mocker@4.0.10(msw@2.6.5(@types/node@22.18.1)(typescript@5.7.3))(vite@7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))': dependencies: @@ -9347,14 +9393,14 @@ snapshots: msw: 2.6.5(@types/node@22.18.1)(typescript@5.7.3) vite: 7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) - '@vitest/mocker@4.0.16(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(vite@7.3.0(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))': + '@vitest/mocker@4.0.18(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(vite@7.3.1(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))': dependencies: - '@vitest/spy': 4.0.16 + '@vitest/spy': 4.0.18 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: msw: 2.6.5(@types/node@22.18.1)(typescript@5.9.3) - vite: 7.3.0(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + vite: 7.3.1(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) '@vitest/pretty-format@3.2.4': dependencies: @@ -9364,7 +9410,7 @@ snapshots: dependencies: tinyrainbow: 3.0.3 - '@vitest/pretty-format@4.0.16': + '@vitest/pretty-format@4.0.18': dependencies: tinyrainbow: 3.0.3 @@ -9373,9 +9419,9 @@ snapshots: '@vitest/utils': 4.0.10 pathe: 2.0.3 - '@vitest/runner@4.0.16': + '@vitest/runner@4.0.18': dependencies: - '@vitest/utils': 4.0.16 + '@vitest/utils': 4.0.18 pathe: 2.0.3 '@vitest/snapshot@4.0.10': @@ -9384,9 +9430,9 @@ snapshots: magic-string: 0.30.21 pathe: 2.0.3 - '@vitest/snapshot@4.0.16': + '@vitest/snapshot@4.0.18': dependencies: - '@vitest/pretty-format': 4.0.16 + '@vitest/pretty-format': 4.0.18 magic-string: 0.30.21 pathe: 2.0.3 @@ -9396,7 +9442,7 @@ snapshots: '@vitest/spy@4.0.10': {} - '@vitest/spy@4.0.16': {} + '@vitest/spy@4.0.18': {} '@vitest/ui@4.0.10(vitest@4.0.10)': dependencies: @@ -9420,9 +9466,9 @@ snapshots: '@vitest/pretty-format': 4.0.10 tinyrainbow: 3.0.3 - '@vitest/utils@4.0.16': + '@vitest/utils@4.0.18': dependencies: - '@vitest/pretty-format': 4.0.16 + '@vitest/pretty-format': 4.0.18 tinyrainbow: 3.0.3 '@vueless/storybook-dark-mode@9.0.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': @@ -9445,6 +9491,9 @@ snapshots: acorn@8.15.0: {} + acorn@8.16.0: + optional: true + agent-base@7.1.1: dependencies: debug: 4.4.0 @@ -9628,16 +9677,16 @@ snapshots: axe-core@4.10.2: {} - axios-mock-adapter@1.22.0(axios@1.12.0): + axios-mock-adapter@1.22.0(axios@1.13.5): dependencies: - axios: 1.12.0 + axios: 1.13.5 fast-deep-equal: 3.1.3 is-buffer: 2.0.5 - axios@1.12.0: + axios@1.13.5: dependencies: follow-redirects: 1.15.11 - form-data: 4.0.4 + form-data: 4.0.5 proxy-from-env: 1.1.0 transitivePeerDependencies: - debug @@ -9646,13 +9695,13 @@ snapshots: babel-plugin-macros@3.1.0: dependencies: - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.28.6 cosmiconfig: 7.1.0 resolve: 1.22.8 balanced-match@1.0.2: {} - balanced-match@3.0.1: {} + balanced-match@4.0.3: {} base64-arraybuffer@1.0.2: optional: true @@ -9677,9 +9726,9 @@ snapshots: dependencies: balanced-match: 1.0.2 - brace-expansion@4.0.1: + brace-expansion@5.0.2: dependencies: - balanced-match: 3.0.1 + balanced-match: 4.0.3 braces@3.0.3: dependencies: @@ -9761,9 +9810,9 @@ snapshots: canvg@3.0.11: dependencies: - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.28.6 '@types/raf': 3.4.3 - core-js: 3.39.0 + core-js: 3.48.0 raf: 3.4.1 regenerator-runtime: 0.13.11 rgbcolor: 1.0.1 @@ -9965,7 +10014,7 @@ snapshots: dependencies: toggle-selection: 1.0.6 - core-js@3.39.0: + core-js@3.48.0: optional: true core-util-is@1.0.2: {} @@ -10240,6 +10289,8 @@ snapshots: diff@5.2.0: {} + diff@5.2.2: {} + doctrine@2.1.0: dependencies: esutils: 2.0.3 @@ -10254,13 +10305,18 @@ snapshots: dom-helpers@5.2.1: dependencies: - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.28.6 csstype: 3.1.3 dompurify@3.2.4: optionalDependencies: '@types/trusted-types': 2.0.7 + dompurify@3.3.1: + optionalDependencies: + '@types/trusted-types': 2.0.7 + optional: true + dot-case@3.0.4: dependencies: no-case: 3.0.4 @@ -10477,34 +10533,34 @@ snapshots: '@esbuild/win32-ia32': 0.25.3 '@esbuild/win32-x64': 0.25.3 - esbuild@0.27.2: + esbuild@0.27.3: optionalDependencies: - '@esbuild/aix-ppc64': 0.27.2 - '@esbuild/android-arm': 0.27.2 - '@esbuild/android-arm64': 0.27.2 - '@esbuild/android-x64': 0.27.2 - '@esbuild/darwin-arm64': 0.27.2 - '@esbuild/darwin-x64': 0.27.2 - '@esbuild/freebsd-arm64': 0.27.2 - '@esbuild/freebsd-x64': 0.27.2 - '@esbuild/linux-arm': 0.27.2 - '@esbuild/linux-arm64': 0.27.2 - '@esbuild/linux-ia32': 0.27.2 - '@esbuild/linux-loong64': 0.27.2 - '@esbuild/linux-mips64el': 0.27.2 - '@esbuild/linux-ppc64': 0.27.2 - '@esbuild/linux-riscv64': 0.27.2 - '@esbuild/linux-s390x': 0.27.2 - '@esbuild/linux-x64': 0.27.2 - '@esbuild/netbsd-arm64': 0.27.2 - '@esbuild/netbsd-x64': 0.27.2 - '@esbuild/openbsd-arm64': 0.27.2 - '@esbuild/openbsd-x64': 0.27.2 - '@esbuild/openharmony-arm64': 0.27.2 - '@esbuild/sunos-x64': 0.27.2 - '@esbuild/win32-arm64': 0.27.2 - '@esbuild/win32-ia32': 0.27.2 - '@esbuild/win32-x64': 0.27.2 + '@esbuild/aix-ppc64': 0.27.3 + '@esbuild/android-arm': 0.27.3 + '@esbuild/android-arm64': 0.27.3 + '@esbuild/android-x64': 0.27.3 + '@esbuild/darwin-arm64': 0.27.3 + '@esbuild/darwin-x64': 0.27.3 + '@esbuild/freebsd-arm64': 0.27.3 + '@esbuild/freebsd-x64': 0.27.3 + '@esbuild/linux-arm': 0.27.3 + '@esbuild/linux-arm64': 0.27.3 + '@esbuild/linux-ia32': 0.27.3 + '@esbuild/linux-loong64': 0.27.3 + '@esbuild/linux-mips64el': 0.27.3 + '@esbuild/linux-ppc64': 0.27.3 + '@esbuild/linux-riscv64': 0.27.3 + '@esbuild/linux-s390x': 0.27.3 + '@esbuild/linux-x64': 0.27.3 + '@esbuild/netbsd-arm64': 0.27.3 + '@esbuild/netbsd-x64': 0.27.3 + '@esbuild/openbsd-arm64': 0.27.3 + '@esbuild/openbsd-x64': 0.27.3 + '@esbuild/openharmony-arm64': 0.27.3 + '@esbuild/sunos-x64': 0.27.3 + '@esbuild/win32-arm64': 0.27.3 + '@esbuild/win32-ia32': 0.27.3 + '@esbuild/win32-x64': 0.27.3 escalade@3.2.0: {} @@ -10866,6 +10922,14 @@ snapshots: hasown: 2.0.2 mime-types: 2.1.35 + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + formik@2.1.7(react@19.1.0): dependencies: deepmerge: 2.2.1 @@ -11460,19 +11524,19 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 - jspdf-autotable@5.0.2(jspdf@4.0.0): + jspdf-autotable@5.0.2(jspdf@4.2.0): dependencies: - jspdf: 4.0.0 + jspdf: 4.2.0 - jspdf@4.0.0: + jspdf@4.2.0: dependencies: - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.28.6 fast-png: 6.4.0 fflate: 0.8.2 optionalDependencies: canvg: 3.0.11 - core-js: 3.39.0 - dompurify: 3.2.4 + core-js: 3.48.0 + dompurify: 3.3.1 html2canvas: 1.4.1 jsprim@2.0.2: @@ -11692,7 +11756,7 @@ snapshots: map-or-similar@1.5.0: {} - markdown-it@14.1.0: + markdown-it@14.1.1: dependencies: argparse: 2.0.1 entities: 4.5.0 @@ -11773,7 +11837,7 @@ snapshots: minimatch@5.1.6: dependencies: - brace-expansion: 4.0.1 + brace-expansion: 5.0.2 minimatch@9.0.5: dependencies: @@ -11802,7 +11866,7 @@ snapshots: browser-stdout: 1.3.1 chokidar: 3.6.0 debug: 4.4.3(supports-color@8.1.1) - diff: 5.2.0 + diff: 5.2.2 escape-string-regexp: 4.0.0 find-up: 5.0.0 glob: 8.1.0 @@ -12311,7 +12375,7 @@ snapshots: react-transition-group@4.4.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.27.1 dom-helpers: 5.2.1 loose-envify: 1.4.0 prop-types: 15.8.1 @@ -12533,32 +12597,35 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.53.3 fsevents: 2.3.3 - rollup@4.53.5: + rollup@4.58.0: dependencies: '@types/estree': 1.0.8 optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.53.5 - '@rollup/rollup-android-arm64': 4.53.5 - '@rollup/rollup-darwin-arm64': 4.53.5 - '@rollup/rollup-darwin-x64': 4.53.5 - '@rollup/rollup-freebsd-arm64': 4.53.5 - '@rollup/rollup-freebsd-x64': 4.53.5 - '@rollup/rollup-linux-arm-gnueabihf': 4.53.5 - '@rollup/rollup-linux-arm-musleabihf': 4.53.5 - '@rollup/rollup-linux-arm64-gnu': 4.53.5 - '@rollup/rollup-linux-arm64-musl': 4.53.5 - '@rollup/rollup-linux-loong64-gnu': 4.53.5 - '@rollup/rollup-linux-ppc64-gnu': 4.53.5 - '@rollup/rollup-linux-riscv64-gnu': 4.53.5 - '@rollup/rollup-linux-riscv64-musl': 4.53.5 - '@rollup/rollup-linux-s390x-gnu': 4.53.5 - '@rollup/rollup-linux-x64-gnu': 4.53.5 - '@rollup/rollup-linux-x64-musl': 4.53.5 - '@rollup/rollup-openharmony-arm64': 4.53.5 - '@rollup/rollup-win32-arm64-msvc': 4.53.5 - '@rollup/rollup-win32-ia32-msvc': 4.53.5 - '@rollup/rollup-win32-x64-gnu': 4.53.5 - '@rollup/rollup-win32-x64-msvc': 4.53.5 + '@rollup/rollup-android-arm-eabi': 4.58.0 + '@rollup/rollup-android-arm64': 4.58.0 + '@rollup/rollup-darwin-arm64': 4.58.0 + '@rollup/rollup-darwin-x64': 4.58.0 + '@rollup/rollup-freebsd-arm64': 4.58.0 + '@rollup/rollup-freebsd-x64': 4.58.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.58.0 + '@rollup/rollup-linux-arm-musleabihf': 4.58.0 + '@rollup/rollup-linux-arm64-gnu': 4.58.0 + '@rollup/rollup-linux-arm64-musl': 4.58.0 + '@rollup/rollup-linux-loong64-gnu': 4.58.0 + '@rollup/rollup-linux-loong64-musl': 4.58.0 + '@rollup/rollup-linux-ppc64-gnu': 4.58.0 + '@rollup/rollup-linux-ppc64-musl': 4.58.0 + '@rollup/rollup-linux-riscv64-gnu': 4.58.0 + '@rollup/rollup-linux-riscv64-musl': 4.58.0 + '@rollup/rollup-linux-s390x-gnu': 4.58.0 + '@rollup/rollup-linux-x64-gnu': 4.58.0 + '@rollup/rollup-linux-x64-musl': 4.58.0 + '@rollup/rollup-openbsd-x64': 4.58.0 + '@rollup/rollup-openharmony-arm64': 4.58.0 + '@rollup/rollup-win32-arm64-msvc': 4.58.0 + '@rollup/rollup-win32-ia32-msvc': 4.58.0 + '@rollup/rollup-win32-x64-gnu': 4.58.0 + '@rollup/rollup-win32-x64-msvc': 4.58.0 fsevents: 2.3.3 rrweb-cssom@0.7.1: {} @@ -12817,13 +12884,13 @@ snapshots: - utf-8-validate - vite - storybook@9.1.17(@testing-library/dom@10.4.0)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(prettier@3.5.3)(vite@7.3.0(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)): + storybook@9.1.17(@testing-library/dom@10.4.0)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(prettier@3.5.3)(vite@7.3.1(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)): dependencies: '@storybook/global': 5.0.0 '@testing-library/jest-dom': 6.9.1 '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.0) '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(vite@7.3.0(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + '@vitest/mocker': 3.2.4(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(vite@7.3.1(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) '@vitest/spy': 3.2.4 better-opn: 3.0.2 esbuild: 0.25.12 @@ -12987,7 +13054,7 @@ snapshots: terser@5.36.0: dependencies: '@jridgewell/source-map': 0.3.11 - acorn: 8.15.0 + acorn: 8.16.0 commander: 2.20.3 source-map-support: 0.5.21 optional: true @@ -13338,9 +13405,9 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 - vite-plugin-svgr@4.5.0(rollup@4.53.5)(typescript@5.9.3)(vite@7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)): + vite-plugin-svgr@4.5.0(rollup@4.58.0)(typescript@5.9.3)(vite@7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)): dependencies: - '@rollup/pluginutils': 5.2.0(rollup@4.53.5) + '@rollup/pluginutils': 5.2.0(rollup@4.58.0) '@svgr/core': 8.1.0(typescript@5.9.3) '@svgr/plugin-jsx': 8.1.0(@svgr/core@8.1.0(typescript@5.9.3)) vite: 7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) @@ -13349,12 +13416,12 @@ snapshots: - supports-color - typescript - vite-plugin-svgr@4.5.0(rollup@4.53.5)(typescript@5.9.3)(vite@7.3.0(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)): + vite-plugin-svgr@4.5.0(rollup@4.58.0)(typescript@5.9.3)(vite@7.3.1(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)): dependencies: - '@rollup/pluginutils': 5.2.0(rollup@4.53.5) + '@rollup/pluginutils': 5.2.0(rollup@4.58.0) '@svgr/core': 8.1.0(typescript@5.9.3) '@svgr/plugin-jsx': 8.1.0(@svgr/core@8.1.0(typescript@5.9.3)) - vite: 7.3.0(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + vite: 7.3.1(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) transitivePeerDependencies: - rollup - supports-color @@ -13376,13 +13443,13 @@ snapshots: tsx: 4.19.3 yaml: 2.6.1 - vite@7.3.0(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1): + vite@7.3.1(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1): dependencies: - esbuild: 0.27.2 + esbuild: 0.27.3 fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 postcss: 8.5.6 - rollup: 4.53.5 + rollup: 4.58.0 tinyglobby: 0.2.15 optionalDependencies: '@types/node': 22.18.1 @@ -13433,15 +13500,15 @@ snapshots: - tsx - yaml - vitest@4.0.16(@types/node@22.18.1)(@vitest/ui@4.0.10)(jiti@2.4.2)(jsdom@24.1.3)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1): + vitest@4.0.18(@types/node@22.18.1)(@vitest/ui@4.0.10(vitest@4.0.10))(jiti@2.4.2)(jsdom@24.1.3)(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1): dependencies: - '@vitest/expect': 4.0.16 - '@vitest/mocker': 4.0.16(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(vite@7.3.0(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) - '@vitest/pretty-format': 4.0.16 - '@vitest/runner': 4.0.16 - '@vitest/snapshot': 4.0.16 - '@vitest/spy': 4.0.16 - '@vitest/utils': 4.0.16 + '@vitest/expect': 4.0.18 + '@vitest/mocker': 4.0.18(msw@2.6.5(@types/node@22.18.1)(typescript@5.9.3))(vite@7.3.1(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + '@vitest/pretty-format': 4.0.18 + '@vitest/runner': 4.0.18 + '@vitest/snapshot': 4.0.18 + '@vitest/spy': 4.0.18 + '@vitest/utils': 4.0.18 es-module-lexer: 1.7.0 expect-type: 1.3.0 magic-string: 0.30.21 @@ -13453,7 +13520,7 @@ snapshots: tinyexec: 1.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vite: 7.3.0(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + vite: 7.3.1(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.18.1 diff --git a/scripts/package.json b/scripts/package.json index ccc9b8c3884..8a9ec3dd44b 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -13,7 +13,7 @@ }, "devDependencies": { "@types/markdown-it": "^14.1.2", - "markdown-it": "^14.1.0", + "markdown-it": "^14.1.1", "@types/node": "^22.13.14", "chalk": "^5.2.0", "commander": "^6.2.1",