diff --git a/packages/api-v4/.changeset/pr-13495-upcoming-features-1773415302171.md b/packages/api-v4/.changeset/pr-13495-upcoming-features-1773415302171.md new file mode 100644 index 00000000000..57adf7b899d --- /dev/null +++ b/packages/api-v4/.changeset/pr-13495-upcoming-features-1773415302171.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Upcoming Features +--- + +Replaced `content` with `details` in cloudpulse/types.ts for ACLP-Alerts Notification Channels ([#13495](https://github.com/linode/manager/pull/13495)) diff --git a/packages/api-v4/.changeset/pr-13505-removed-1774025410092.md b/packages/api-v4/.changeset/pr-13505-removed-1774025410092.md new file mode 100644 index 00000000000..f666727c404 --- /dev/null +++ b/packages/api-v4/.changeset/pr-13505-removed-1774025410092.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Removed +--- + +Old `failed` property from `DatabaseStatus` ([#13505](https://github.com/linode/manager/pull/13505)) diff --git a/packages/api-v4/.changeset/pr-13517-upcoming-features-1774273074612.md b/packages/api-v4/.changeset/pr-13517-upcoming-features-1774273074612.md new file mode 100644 index 00000000000..da8a5c3974f --- /dev/null +++ b/packages/api-v4/.changeset/pr-13517-upcoming-features-1774273074612.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Upcoming Features +--- + +Reserved IPs: Added new API endpoints ([#13517](https://github.com/linode/manager/pull/13517)) diff --git a/packages/api-v4/src/databases/types.ts b/packages/api-v4/src/databases/types.ts index 9974bd22d08..c62888a29cc 100644 --- a/packages/api-v4/src/databases/types.ts +++ b/packages/api-v4/src/databases/types.ts @@ -32,7 +32,6 @@ export interface DatabaseEngine { export type DatabaseStatus = | 'active' | 'degraded' - | 'failed' | 'migrated' | 'migrating' | 'provisioning' diff --git a/packages/api-v4/src/images/index.ts b/packages/api-v4/src/images/index.ts index 7425f0c62e4..1855d298118 100644 --- a/packages/api-v4/src/images/index.ts +++ b/packages/api-v4/src/images/index.ts @@ -1,3 +1,5 @@ export * from './images'; +export * from './sharegroups'; + export * from './types'; diff --git a/packages/api-v4/src/images/sharegroup.ts b/packages/api-v4/src/images/sharegroups.ts similarity index 100% rename from packages/api-v4/src/images/sharegroup.ts rename to packages/api-v4/src/images/sharegroups.ts diff --git a/packages/api-v4/src/networking/networking.ts b/packages/api-v4/src/networking/networking.ts index cdf3bfb60a5..71f4a415b5c 100644 --- a/packages/api-v4/src/networking/networking.ts +++ b/packages/api-v4/src/networking/networking.ts @@ -1,6 +1,7 @@ import { allocateIPSchema, assignAddressesSchema, + reserveIPSchema, shareAddressesSchema, updateIPSchema, } from '@linode/validation/lib/networking.schema'; @@ -14,14 +15,16 @@ import Request, { setXFilter, } from '../request'; -import type { Filter, ResourcePage as Page, Params } from '../types'; +import type { Filter, ResourcePage as Page, Params, PriceType } from '../types'; import type { + AllocateIPPayload, CreateIPv6RangePayload, IPAddress, IPAssignmentPayload, IPRange, IPRangeInformation, IPSharingPayload, + ReserveIPPayload, } from './types'; /** @@ -52,16 +55,24 @@ export const getIP = (address: string) => * Sets RDNS on an IP Address. Forward DNS must already be set up for reverse * DNS to be applied. If you set the RDNS to null for public IPv4 addresses, * it will be reset to the default members.linode.com RDNS value. + * Also setting “reserved” field to true converts an Ephemeral IP to an Reserved IP. + * setting “reserved” field set to false converts a Reserved IP to an Ephemeral IP. + * An Ephemeral IP is an IP that’s assigned to a Linode but not reserved. * * @param address { string } The address to operate on. * @param rdns { string } The reverse DNS assigned to this address. For public * IPv4 addresses, this will be set to a default value provided by Linode if not * explicitly set. + * @param reserved { boolean } Whether to reserve the IP address. */ -export const updateIP = (address: string, rdns: null | string = null) => +export const updateIP = ( + address: string, + rdns: null | string = null, + reserved?: boolean, +) => Request( setURL(`${API_ROOT}/networking/ips/${encodeURIComponent(address)}`), - setData({ rdns }, updateIPSchema), + setData({ rdns, reserved }, updateIPSchema), setMethod('PUT'), ); @@ -77,8 +88,11 @@ export const updateIP = (address: string, rdns: null | string = null) => * address. * @param payload.linode_id { number } The ID of a Linode you you have access to * that this address will be allocated to. + * @param payload.reserved { boolean } Whether to reserve the IP address. + * @param payload.region { string } The ID of the Region in which this address * will be allocated. + * Required when reserving an IP address, not required when allocating an ephemeral IP address. */ -export const allocateIp = (payload: any) => +export const allocateIp = (payload: AllocateIPPayload) => Request( setURL(`${API_ROOT}/networking/ips/`), setData(payload, allocateIPSchema), @@ -194,3 +208,93 @@ export const createIPv6Range = (payload: CreateIPv6RangePayload) => { setData(payload), ); }; + +// Reserve IP queries +/** + * getReservedIps + * + * Returns a paginated list of all Reserved IP addresses on this account. + */ +export const getReservedIPs = (params?: Params, filters?: Filter) => + Request>( + setMethod('GET'), + setParams(params), + setXFilter(filters), + setURL(`${BETA_API_ROOT}/networking/reserved/ips`), + ); + +/** + * Returns information about a single Reserved IP Address on your Account. + * + * @param address { string } The address to operate on. + */ +export const getReservedIP = (address: string) => + Request( + setURL( + `${BETA_API_ROOT}/networking/reserved/ips/${encodeURIComponent(address)}`, + ), + setMethod('GET'), + ); + +/** + * Tags associated with the reserved IP can be updated. + * The tags associated with the reserved IP will be completely replaced with the values specified in the request body, + * rather than just adding additional tags to what’s already there + * + * @param address { string } The address to operate on. + * @param tags { string[] | null } The tags to associate with this reserved IP. If null, all tags will be removed. + */ +export const updateReservedIP = ( + address: string, + tags: null | string[] = null, +) => + Request( + setURL( + `${BETA_API_ROOT}/networking/reserved/ips/${encodeURIComponent(address)}`, + ), + setData({ tags }), + setMethod('PUT'), + ); + +/** + * Makes one of the IP address available in the provided region as reserved. + * Only IPv4 addresses may be reserved through this endpoint. + * + * @param payload { Object } + * @param payload.region { string } The ID of the Region in which these + * assignments are to take place. All IPs and Linodes must exist in this Region. + * @param payload.tags { string[] } A list of tags to associate with this reserved IP. + */ +export const reserveIP = (payload: ReserveIPPayload) => + Request( + setURL(`${BETA_API_ROOT}/networking/reserved/ips`), + setData(payload, reserveIPSchema), + setMethod('POST'), + ); + +/** + * unReserveIP + * + * Unreserves an IP address, making it available for general use. + * Any Tag associations will be removed when the IP address is un-reserved via this endpoint. + * + */ +export const unReserveIP = (ipAddress: string) => + Request( + setURL( + `${BETA_API_ROOT}/networking/reserved/ips/${encodeURIComponent(ipAddress)}`, + ), + setMethod('DELETE'), + ); + +/** + * getReservedIPsTypes + * + * Returns a paginated list of available Reserved IP types; used for pricing. + */ +export const getReservedIPsTypes = (params?: Params) => + Request>( + setURL(`${API_ROOT}/networking/reserved/ips/types`), + setMethod('GET'), + setParams(params), + ); diff --git a/packages/api-v4/src/networking/types.ts b/packages/api-v4/src/networking/types.ts index 88805c44ffd..45252fb763b 100644 --- a/packages/api-v4/src/networking/types.ts +++ b/packages/api-v4/src/networking/types.ts @@ -1,13 +1,23 @@ +export interface AssignedEntity { + id: number; + label: string; + type: string; + url: string; +} + export interface IPAddress { address: string; + assigned_entity: AssignedEntity | null; gateway: null | string; interface_id: null | number; - linode_id: number; + linode_id: null | number; prefix: number; public: boolean; rdns: null | string; region: string; + reserved: boolean; subnet_mask: string; + tags: string[]; type: string; vpc_nat_1_1?: null | { address: string; @@ -16,6 +26,14 @@ export interface IPAddress { }; } +export interface AllocateIPPayload { + linode_id?: number; + public: boolean; + region?: string; + reserved?: boolean; + type: string; +} + export interface IPRangeBaseData { prefix: number; range: string; @@ -51,3 +69,8 @@ export interface CreateIPv6RangePayload { prefix_length: IPv6Prefix; route_target?: string; } + +export interface ReserveIPPayload { + region: string; + tags?: string[]; +} diff --git a/packages/api-v4/src/nodebalancers/types.ts b/packages/api-v4/src/nodebalancers/types.ts index 75a1ee7babf..64d2991055a 100644 --- a/packages/api-v4/src/nodebalancers/types.ts +++ b/packages/api-v4/src/nodebalancers/types.ts @@ -253,6 +253,7 @@ export interface CreateNodeBalancerPayload { client_udp_sess_throttle?: number; configs: CreateNodeBalancerConfig[]; firewall_id?: number; + ipv4?: string; // must be a reserved unassigned IP owned by the customer. label?: string; region?: string; tags?: string[]; diff --git a/packages/api-v4/src/tags/types.ts b/packages/api-v4/src/tags/types.ts index 06bb5a7ef47..043183022d4 100644 --- a/packages/api-v4/src/tags/types.ts +++ b/packages/api-v4/src/tags/types.ts @@ -3,6 +3,10 @@ export interface Tag { } export interface TagRequest { + domains?: number[]; label: string; linodes?: number[]; + nodebalancers?: number[]; + reserved_ipv4_addresses?: string[]; + volumes?: number[]; } diff --git a/packages/manager/.changeset/pr-13449-tests-1772265280573.md b/packages/manager/.changeset/pr-13449-tests-1772265280573.md new file mode 100644 index 00000000000..77c72eea0fd --- /dev/null +++ b/packages/manager/.changeset/pr-13449-tests-1772265280573.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Adding spec for show details notification channel ([#13449](https://github.com/linode/manager/pull/13449)) diff --git a/packages/manager/.changeset/pr-13455-upcoming-features-1772701630712.md b/packages/manager/.changeset/pr-13455-upcoming-features-1772701630712.md new file mode 100644 index 00000000000..b7bb02ef020 --- /dev/null +++ b/packages/manager/.changeset/pr-13455-upcoming-features-1772701630712.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Allow simultaneous v1 (Legacy) and v2 (ACLP) alerting in Linode edit flow ([#13455](https://github.com/linode/manager/pull/13455)) diff --git a/packages/manager/.changeset/pr-13465-fixed-1772658612856.md b/packages/manager/.changeset/pr-13465-fixed-1772658612856.md new file mode 100644 index 00000000000..d751f1fc9ea --- /dev/null +++ b/packages/manager/.changeset/pr-13465-fixed-1772658612856.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Database Advanced Config field tooltip error ([#13465](https://github.com/linode/manager/pull/13465)) diff --git a/packages/manager/.changeset/pr-13482-upcoming-features-1773352263642.md b/packages/manager/.changeset/pr-13482-upcoming-features-1773352263642.md new file mode 100644 index 00000000000..b225c429c00 --- /dev/null +++ b/packages/manager/.changeset/pr-13482-upcoming-features-1773352263642.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Use ImageSelectTable in Linode Rebuild dialog ([#13482](https://github.com/linode/manager/pull/13482)) diff --git a/packages/manager/.changeset/pr-13483-fixed-1773803172611.md b/packages/manager/.changeset/pr-13483-fixed-1773803172611.md new file mode 100644 index 00000000000..b7f0b3e6369 --- /dev/null +++ b/packages/manager/.changeset/pr-13483-fixed-1773803172611.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Remove unintended validation on optional email fields in Contact Sales Drawer ([#13483](https://github.com/linode/manager/pull/13483)) diff --git a/packages/manager/.changeset/pr-13486-upcoming-features-1773317968547.md b/packages/manager/.changeset/pr-13486-upcoming-features-1773317968547.md new file mode 100644 index 00000000000..fccd48d8ad7 --- /dev/null +++ b/packages/manager/.changeset/pr-13486-upcoming-features-1773317968547.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Reserve IP: Add the new feature Reserved IPs to side nav ([#13486](https://github.com/linode/manager/pull/13486)) diff --git a/packages/manager/.changeset/pr-13489-changed-1773395119430.md b/packages/manager/.changeset/pr-13489-changed-1773395119430.md new file mode 100644 index 00000000000..7620425ef83 --- /dev/null +++ b/packages/manager/.changeset/pr-13489-changed-1773395119430.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +Streams and Destinations Landing page initial load spinner added before empty state ([#13489](https://github.com/linode/manager/pull/13489)) diff --git a/packages/manager/.changeset/pr-13491-fixed-1773404088592.md b/packages/manager/.changeset/pr-13491-fixed-1773404088592.md new file mode 100644 index 00000000000..a1e5947318c --- /dev/null +++ b/packages/manager/.changeset/pr-13491-fixed-1773404088592.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +In Stream Create and Edit forms in the Clusters table filtering by region resulted in an empty clusters list ([#13491](https://github.com/linode/manager/pull/13491)) diff --git a/packages/manager/.changeset/pr-13495-upcoming-features-1773415251134.md b/packages/manager/.changeset/pr-13495-upcoming-features-1773415251134.md new file mode 100644 index 00000000000..6fbc6d46bca --- /dev/null +++ b/packages/manager/.changeset/pr-13495-upcoming-features-1773415251134.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Migrated to `details` from `content` in ACLP-Alerts Notification Channels ([#13495](https://github.com/linode/manager/pull/13495)) diff --git a/packages/manager/.changeset/pr-13496-upcoming-features-1773424202209.md b/packages/manager/.changeset/pr-13496-upcoming-features-1773424202209.md new file mode 100644 index 00000000000..5a19c99e0ee --- /dev/null +++ b/packages/manager/.changeset/pr-13496-upcoming-features-1773424202209.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +ACLP-Metrics updated Tooltip messages for Group-by, DimensionFilter icons when in disabled state for the Widgets ([#13496](https://github.com/linode/manager/pull/13496)) diff --git a/packages/manager/.changeset/pr-13496-upcoming-features-1774327007124.md b/packages/manager/.changeset/pr-13496-upcoming-features-1774327007124.md new file mode 100644 index 00000000000..273b80f0f09 --- /dev/null +++ b/packages/manager/.changeset/pr-13496-upcoming-features-1774327007124.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Add alternate tool tip texts for group-by, dimension filter widget icons when disabled in ACLP-Metrics ([#13496](https://github.com/linode/manager/pull/13496)) diff --git a/packages/manager/.changeset/pr-13497-upcoming-features-1773644964686.md b/packages/manager/.changeset/pr-13497-upcoming-features-1773644964686.md new file mode 100644 index 00000000000..10d7185c541 --- /dev/null +++ b/packages/manager/.changeset/pr-13497-upcoming-features-1773644964686.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Ability to download widget data as CSV in `CloudPulse metrics dashboards` ([#13497](https://github.com/linode/manager/pull/13497)) diff --git a/packages/manager/.changeset/pr-13498-fixed-1782319675458.md b/packages/manager/.changeset/pr-13498-fixed-1782319675458.md new file mode 100644 index 00000000000..faf46176c4a --- /dev/null +++ b/packages/manager/.changeset/pr-13498-fixed-1782319675458.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Marketplace Fixes: Improved texts and tooltips. Changed submit enable behavior in contact sales form ([#13498](https://github.com/linode/manager/pull/13498)) diff --git a/packages/manager/.changeset/pr-13499-added-1773854043259.md b/packages/manager/.changeset/pr-13499-added-1773854043259.md new file mode 100644 index 00000000000..273219196d7 --- /dev/null +++ b/packages/manager/.changeset/pr-13499-added-1773854043259.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Added +--- + +Add DeepSeek-R1 and OpenClaw to Quick Deploy Apps ([#13499](https://github.com/linode/manager/pull/13499)) diff --git a/packages/manager/.changeset/pr-13501-fixed-1774516931626.md b/packages/manager/.changeset/pr-13501-fixed-1774516931626.md new file mode 100644 index 00000000000..dac8c1c12a0 --- /dev/null +++ b/packages/manager/.changeset/pr-13501-fixed-1774516931626.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Product content received in markdown format can have links. Added capability in markdown to open these links in new tab ([#13501](https://github.com/linode/manager/pull/13501)) diff --git a/packages/manager/.changeset/pr-13502-fixed-1773765480516.md b/packages/manager/.changeset/pr-13502-fixed-1773765480516.md new file mode 100644 index 00000000000..ab3519f4ccf --- /dev/null +++ b/packages/manager/.changeset/pr-13502-fixed-1773765480516.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Improve loading pattern on Account Delegation landing page ([#13502](https://github.com/linode/manager/pull/13502)) diff --git a/packages/manager/.changeset/pr-13503-tech-stories-1774357306209.md b/packages/manager/.changeset/pr-13503-tech-stories-1774357306209.md new file mode 100644 index 00000000000..558a5b76fb4 --- /dev/null +++ b/packages/manager/.changeset/pr-13503-tech-stories-1774357306209.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Bump jspdf from 4.2.0 to 4.2.1 ([#13503](https://github.com/linode/manager/pull/13503)) diff --git a/packages/manager/.changeset/pr-13505-fixed-1773782546945.md b/packages/manager/.changeset/pr-13505-fixed-1773782546945.md new file mode 100644 index 00000000000..d107c7c29de --- /dev/null +++ b/packages/manager/.changeset/pr-13505-fixed-1773782546945.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Disable Database credential buttons for resuming state ([#13505](https://github.com/linode/manager/pull/13505)) diff --git a/packages/manager/.changeset/pr-13506-upcoming-features-1773858733897.md b/packages/manager/.changeset/pr-13506-upcoming-features-1773858733897.md new file mode 100644 index 00000000000..1237cfeba79 --- /dev/null +++ b/packages/manager/.changeset/pr-13506-upcoming-features-1773858733897.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Implement owned groups landing page content ([#13506](https://github.com/linode/manager/pull/13506)) diff --git a/packages/manager/.changeset/pr-13507-upcoming-features-1773834491714.md b/packages/manager/.changeset/pr-13507-upcoming-features-1773834491714.md new file mode 100644 index 00000000000..a99add16147 --- /dev/null +++ b/packages/manager/.changeset/pr-13507-upcoming-features-1773834491714.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Custom HTTPS destination form: improve the UX and update copy ([#13507](https://github.com/linode/manager/pull/13507)) diff --git a/packages/manager/.changeset/pr-13509-upcoming-features-1773840689079.md b/packages/manager/.changeset/pr-13509-upcoming-features-1773840689079.md new file mode 100644 index 00000000000..bf66d1f8188 --- /dev/null +++ b/packages/manager/.changeset/pr-13509-upcoming-features-1773840689079.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Configure new feature chip and banner messaging for ACLP Linode Alerts and Metrics ([#13509](https://github.com/linode/manager/pull/13509)) diff --git a/packages/manager/.changeset/pr-13512-fixed-1791257453414.md b/packages/manager/.changeset/pr-13512-fixed-1791257453414.md new file mode 100644 index 00000000000..aefb788a3f4 --- /dev/null +++ b/packages/manager/.changeset/pr-13512-fixed-1791257453414.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Updated rate limiting error message in contact sales drawer ([#13512](https://github.com/linode/manager/pull/13512)) diff --git a/packages/manager/.changeset/pr-13517-upcoming-features-1774354565322.md b/packages/manager/.changeset/pr-13517-upcoming-features-1774354565322.md new file mode 100644 index 00000000000..a4045f31bdd --- /dev/null +++ b/packages/manager/.changeset/pr-13517-upcoming-features-1774354565322.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Reserved IPs - Add new endpoints, types and Queries ([#13517](https://github.com/linode/manager/pull/13517)) diff --git a/packages/manager/.changeset/pr-13524-fixed-1774351558003.md b/packages/manager/.changeset/pr-13524-fixed-1774351558003.md new file mode 100644 index 00000000000..899f86a96c6 --- /dev/null +++ b/packages/manager/.changeset/pr-13524-fixed-1774351558003.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Destination Form: omit the tls_hostname field from the request if it is empty or contains only whitespace ([#13524](https://github.com/linode/manager/pull/13524)) diff --git a/packages/manager/.changeset/pr-13526-tests-1774359841483.md b/packages/manager/.changeset/pr-13526-tests-1774359841483.md new file mode 100644 index 00000000000..5a0c30babb1 --- /dev/null +++ b/packages/manager/.changeset/pr-13526-tests-1774359841483.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Fix test failures in lke-create.spec.ts following feature flag change ([#13526](https://github.com/linode/manager/pull/13526)) diff --git a/packages/manager/.changeset/pr-13530-added-1774372673714.md b/packages/manager/.changeset/pr-13530-added-1774372673714.md new file mode 100644 index 00000000000..fc237976009 --- /dev/null +++ b/packages/manager/.changeset/pr-13530-added-1774372673714.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Added +--- + +IAM: Add Pendo IDs for Parent/Child ([#13530](https://github.com/linode/manager/pull/13530)) diff --git a/packages/manager/.changeset/pr-13531-added-1784124788855.md b/packages/manager/.changeset/pr-13531-added-1784124788855.md new file mode 100644 index 00000000000..80ab14e8a2e --- /dev/null +++ b/packages/manager/.changeset/pr-13531-added-1784124788855.md @@ -0,0 +1,10 @@ +--- +"@linode/manager": Added +--- + +New marketplace products + - Norsk + - Clouddat + Updated marketplace products + - sftpgo + ([#13531](https://github.com/linode/manager/pull/13531)) diff --git a/packages/manager/cypress/e2e/core/cloudpulse/aclp-support.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/aclp-support.spec.ts index aecf5f176ad..0058b94f5cd 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/aclp-support.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/aclp-support.spec.ts @@ -14,10 +14,10 @@ import { import { randomLabel, randomNumber } from 'support/util/random'; import { - METRICS_BETA_MODE_BANNER_TEXT, - METRICS_BETA_MODE_BUTTON_TEXT, - METRICS_LEGACY_MODE_BANNER_TEXT, - METRICS_LEGACY_MODE_BUTTON_TEXT, + METRICS_ACLP_MODE_BETA_AND_NEW_PHASE_BUTTON_TEXT, + METRICS_ACLP_MODE_BETA_PHASE_BANNER_TEXT, + METRICS_LEGACY_MODE_BETA_PHASE_BANNER_TEXT, + METRICS_LEGACY_MODE_BETA_PHASE_BUTTON_TEXT, } from 'src/features/Linodes/constants'; import type { Stats } from '@linode/api-v4'; @@ -34,10 +34,26 @@ describe('ACLP Components UI varies according to ACLP support by region and user mockAppendFeatureFlags({ aclpServices: { linode: { - alerts: { beta: false, enabled: false }, - metrics: { beta: true, enabled: true }, + alerts: { + beta: false, // irrelevant since we are no longer using this service-specific beta flag + enabled: false, + }, + metrics: { + beta: true, // irrelevant since we are no longer using this service-specific beta flag + enabled: true, + }, }, }, + // For Metrics + aclp: { + beta: true, // relevant for this test suite + new: false, // relevant for this test suite + }, + // For Alerts + aclpAlerting: { + beta: false, // relevant for this test suite + new: false, // relevant for this test suite + }, }).as('getFeatureFlags'); }); describe('toggle user preference when region supports aclp', function () { @@ -69,7 +85,7 @@ describe('ACLP Components UI varies according to ACLP support by region and user }); // UI displays beta metrics, can switch to legacy view it('user preference enables aclp', function () { - mockGetUserPreferences({ isAclpMetricsBeta: true }).as( + mockGetUserPreferences({ isAclpMetricsMode: true }).as( 'getUserPreferences' ); cy.visitWithLogin(`/linodes/${this.mockLinodeId}/metrics`); @@ -94,10 +110,12 @@ describe('ACLP Components UI varies according to ACLP support by region and user cy.get('[data-testid="metrics-preference-banner-text"]').should( 'be.visible' ); - cy.contains(METRICS_BETA_MODE_BANNER_TEXT).should('be.visible'); + cy.contains(METRICS_ACLP_MODE_BETA_PHASE_BANNER_TEXT).should( + 'be.visible' + ); ui.button - .findByTitle(METRICS_BETA_MODE_BUTTON_TEXT) + .findByTitle(METRICS_ACLP_MODE_BETA_AND_NEW_PHASE_BUTTON_TEXT) .should('be.visible') .should('be.enabled'); // UI displays mock error msg @@ -107,7 +125,7 @@ describe('ACLP Components UI varies according to ACLP support by region and user // UI displays legacy metrics, can switch to beta view it('user preference disables aclp', function () { - mockGetUserPreferences({ isAclpMetricsBeta: false }).as( + mockGetUserPreferences({ isAclpMetricsMode: false }).as( 'getUserPreferences' ); const mockLegacyStats: Stats = generateMockLegacyStats(); @@ -137,19 +155,25 @@ describe('ACLP Components UI varies according to ACLP support by region and user ); // expect legacy metrics view of LinodeSummary component to be displayed cy.get('[data-testid="linode-summary"]').should('be.visible'); - cy.contains(METRICS_LEGACY_MODE_BANNER_TEXT).should('be.visible'); + cy.contains(METRICS_LEGACY_MODE_BETA_PHASE_BANNER_TEXT).should( + 'be.visible' + ); // switch to beta metrics ui.button - .findByTitle(METRICS_LEGACY_MODE_BUTTON_TEXT) + .findByTitle(METRICS_LEGACY_MODE_BETA_PHASE_BUTTON_TEXT) .should('be.visible') .should('be.enabled') .click(); // wait for dashboard query to complete cy.wait('@getDashboardError'); - cy.contains(METRICS_BETA_MODE_BANNER_TEXT).should('be.visible'); - cy.contains(METRICS_LEGACY_MODE_BANNER_TEXT).should('not.exist'); + cy.contains(METRICS_ACLP_MODE_BETA_PHASE_BANNER_TEXT).should( + 'be.visible' + ); + cy.contains(METRICS_LEGACY_MODE_BETA_PHASE_BANNER_TEXT).should( + 'not.exist' + ); ui.button - .findByTitle(METRICS_BETA_MODE_BUTTON_TEXT) + .findByTitle(METRICS_ACLP_MODE_BETA_AND_NEW_PHASE_BUTTON_TEXT) .should('be.visible') .should('be.enabled'); }); @@ -182,7 +206,7 @@ describe('ACLP Components UI varies according to ACLP support by region and user }); // UI displays legacy metrics, no option to switch to beta view it('user preference enables aclp', function () { - mockGetUserPreferences({ isAclpMetricsBeta: true }).as( + mockGetUserPreferences({ isAclpMetricsMode: true }).as( 'getUserPreferences' ); cy.visitWithLogin(`/linodes/${this.mockLinodeId}/metrics`); @@ -191,7 +215,7 @@ describe('ACLP Components UI varies according to ACLP support by region and user // UI displays legacy metrics, no option to switch to beta view it('user preference disables aclp', function () { - mockGetUserPreferences({ isAclpMetricsBeta: false }).as( + mockGetUserPreferences({ isAclpMetricsMode: false }).as( 'getUserPreferences' ); cy.visitWithLogin(`/linodes/${this.mockLinodeId}/metrics`); diff --git a/packages/manager/cypress/e2e/core/cloudpulse/alerts-service-ld-flags.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/alerts-service-ld-flags.spec.ts index 5aca3acbcc0..aaed233fc2e 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/alerts-service-ld-flags.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/alerts-service-ld-flags.spec.ts @@ -92,7 +92,7 @@ describe('Linode ACLP Metrics and Alerts Flag Behavior', () => { cy.get('[data-qa-autocomplete-popper]') .should('be.visible') .and('have.text', NO_OPTIONS_TEXT) - .and('not.contain.text', 'Linode beta'); + .and('not.contain.text', 'Linodes beta'); }); it('should show no available services in the Service dropdown when Linode alerts are disabled but beta is true', () => { @@ -115,7 +115,7 @@ describe('Linode ACLP Metrics and Alerts Flag Behavior', () => { cy.get('[data-qa-autocomplete-popper]') .should('be.visible') .and('have.text', NO_OPTIONS_TEXT) - .and('not.contain.text', 'Linode beta'); + .and('not.contain.text', 'Linodes beta'); }); it('should show no options and exclude Linode beta in Service dropdown when alerts are disabled but beta is true', () => { @@ -138,7 +138,7 @@ describe('Linode ACLP Metrics and Alerts Flag Behavior', () => { cy.get('[data-qa-autocomplete-popper]') .should('be.visible') .and('contain.text', 'You have no options to choose from') - .and('not.contain.text', 'Linode beta'); + .and('not.contain.text', 'Linodes beta'); }); it('should show Linode without beta tag in Service dropdown when alerts are enabled but not in beta', () => { diff --git a/packages/manager/cypress/e2e/core/cloudpulse/metrics-service-ld-flags.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/metrics-service-ld-flags.spec.ts index 0d694ee95d8..f8763928c6b 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/metrics-service-ld-flags.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/metrics-service-ld-flags.spec.ts @@ -88,7 +88,7 @@ describe('Linode ACLP Metrics and Alerts Flag Behavior', () => { mockGetCloudPulseServices([serviceType]).as('fetchServices'); mockGetUserPreferences({}); }); - it('should display "Linode" with a beta tag in the Service dropdown on the Metrics page when metrics.beta is enabled and the service is enabled', () => { + it('should display "Linodes" with a beta tag in the Service dropdown on the Metrics page when metrics.beta is enabled and the service is enabled', () => { mockAppendFeatureFlags(flagsFactory.build()); mockGetCloudPulseDashboard(id, dashboard); mockGetCloudPulseDashboards(serviceType, [dashboard]).as('fetchDashboard'); @@ -122,7 +122,7 @@ describe('Linode ACLP Metrics and Alerts Flag Behavior', () => { .click(); }); - it('should display "Linode" without a beta tag in the Service dropdown on the Metrics page when metrics.beta is false and the service is enabled', () => { + it('should display "Linodes" without a beta tag in the Service dropdown on the Metrics page when metrics.beta is false and the service is enabled', () => { const mockflags = flagsFactory.build({ aclpServices: { linode: { @@ -160,7 +160,7 @@ describe('Linode ACLP Metrics and Alerts Flag Behavior', () => { .click(); }); - it('should not display "Linode" with a beta tag in the Service dropdown on the Metrics page when metrics.beta is true and enabled is false', () => { + it('should not display "Linodes" with a beta tag in the Service dropdown on the Metrics page when metrics.beta is true and enabled is false', () => { // Mock the feature flags to disable metrics for Linode const mockflags = flagsFactory.build({ 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 0bc3ddea940..e225347f4e6 100644 --- a/packages/manager/cypress/e2e/core/databases/update-database.spec.ts +++ b/packages/manager/cypress/e2e/core/databases/update-database.spec.ts @@ -263,7 +263,7 @@ const validateSuspendResume = ( cy.findByText('Connection Details'); // DBaaS passwords cannot be revealed when database/cluster is suspended or resuming. - ui.cdsButton.findButtonByTitle('Show').should('be.enabled'); + ui.cdsButton.findButtonByTitle('Show').should('be.disabled'); // Navigate to "Settings" tab. ui.tabList.findTabByTitle('Settings').click(); diff --git a/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts b/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts index c7f6e7e4ed1..d6f56152c4e 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts @@ -1296,7 +1296,7 @@ describe('LKE Cluster Creation with LKE-E', () => { * - Confirms that HA is enabled by default with LKE-E selection * - Confirms an LKE-E supported region can be selected * - Confirms an LKE-E supported k8 version can be selected - * - Confirms the APL section is disabled while it remains unsupported + * - Confirms that the APL section is present and enabled * - Confirms the VPC & Firewall placeholder section displays with correct copy * - Confirms ACL is enabled by default * - Confirms the checkout bar displays the correct LKE-E info @@ -1448,15 +1448,14 @@ describe('LKE Cluster Creation with LKE-E', () => { .should('be.enabled') .click(); - // Confirm the APL section is disabled and unsupported. + // Confirm that APL selection is enabled and no option is selected by default. cy.findByTestId('apl-label').should('be.visible'); - cy.findByTestId('apl-coming-soon-chip').should( - 'have.text', - 'coming soon' - ); - cy.findByTestId('apl-radio-button-yes').should('be.disabled'); + cy.findByTestId('apl-radio-button-yes').within(() => { + cy.findByRole('radio').should('be.enabled').should('not.be.checked'); + }); cy.findByTestId('apl-radio-button-no').within(() => { - cy.findByRole('radio').should('be.disabled').should('be.checked'); + cy.findByRole('radio').should('be.enabled').should('not.be.checked'); + cy.findByRole('radio').check(); }); // Confirm the VPC/Firewall section displays. @@ -1866,10 +1865,10 @@ describe('LKE cluster creation with LKE-E Post-LA', () => { /* * Each test provided w/ array of 12 mock linode types. Type excluded if: - - flag enabled and id includes 'blackwell' - - enterprise tier and id includes 'gpu' + * - flag enabled and id includes 'blackwell' + * - enterprise tier and id includes 'gpu' * If visible in table, rows are always enabled -*/ + */ describe('smoketest for Nvidia Blackwell GPUs in kubernetes/create page', () => { const mockRegion = regionFactory.build({ id: 'us-east', 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 329debdf69c..cf6c88e778e 100644 --- a/packages/manager/cypress/e2e/core/linodes/alerts-create.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/alerts-create.spec.ts @@ -18,10 +18,10 @@ import { firewallFactory, } from 'src/factories'; import { - ALERTS_BETA_MODE_BANNER_TEXT, - ALERTS_BETA_MODE_BUTTON_TEXT, - ALERTS_LEGACY_MODE_BANNER_TEXT, - ALERTS_LEGACY_MODE_BUTTON_TEXT, + ALERTS_ACLP_MODE_BETA_AND_NEW_PHASE_BUTTON_TEXT, + ALERTS_ACLP_MODE_BETA_PHASE_BANNER_TEXT, + ALERTS_LEGACY_MODE_BETA_PHASE_BANNER_TEXT, + ALERTS_LEGACY_MODE_BETA_PHASE_BUTTON_TEXT, } from 'src/features/Linodes/constants'; const mockFirewall = firewallFactory.build({ @@ -50,15 +50,20 @@ describe('Create flow when beta alerts enabled by region and feature flag', func aclpServices: { linode: { alerts: { - beta: true, + beta: true, // "beta" here is irrelevant since we are no longer using this service-specific beta flag enabled: true, }, metrics: { - beta: false, + beta: false, // "beta" here is irrelevant since we are no longer using this service-specific beta flag enabled: false, }, }, }, + aclp: { beta: false, new: false }, + aclpAlerting: { + beta: true, // relevant for this test suite + new: false, // relevant for this test suite + }, }).as('getFeatureFlags'); // mock network interface type in case test account has setting that disables
 snippet
     const mockInitialAccountSettings = accountSettingsFactory.build({
@@ -185,7 +190,7 @@ describe('Create flow when beta alerts enabled by region and feature flag', func
     });
   });
 
-  it('create flow after switching to beta alerts', function () {
+  it('create flow after switching to aclp alerts', function () {
     const alertDefinitions = [
       alertFactory.build({
         description: randomLabel(),
@@ -243,10 +248,10 @@ describe('Create flow when beta alerts enabled by region and feature flag', func
           .should('be.enabled')
           .click();
         ui.accordion.findByTitle('Alerts').within(() => {
-          // switch to beta
+          // switch to ACLP
           // alerts are off/false but enabled, can switch to on/true
           ui.button
-            .findByTitle(ALERTS_LEGACY_MODE_BUTTON_TEXT)
+            .findByTitle(ALERTS_LEGACY_MODE_BETA_PHASE_BUTTON_TEXT)
             .should('be.visible')
             .should('be.enabled')
             .click();
@@ -385,7 +390,7 @@ describe('Create flow when beta alerts enabled by region and feature flag', func
     });
   });
 
-  it('can toggle from legacy to beta alerts and back to legacy', function () {
+  it('can toggle alerts from legacy to aclp and back to legacy', function () {
     cy.visitWithLogin('/linodes/create');
     cy.wait(['@getFeatureFlags', '@getRegions']);
     ui.regionSelect.find().click();
@@ -402,7 +407,7 @@ describe('Create flow when beta alerts enabled by region and feature flag', func
       cy.get('[data-testid="notice-info"]')
         .should('be.visible')
         .within(() => {
-          cy.contains(ALERTS_LEGACY_MODE_BANNER_TEXT);
+          cy.contains(ALERTS_LEGACY_MODE_BETA_PHASE_BANNER_TEXT);
         });
     });
     // legacy alert form, inputs are ON but readonly
@@ -418,24 +423,23 @@ describe('Create flow when beta alerts enabled by region and feature flag', func
       });
     });
 
-    // upgrade from legacy alerts to beta alerts
+    // upgrade from legacy alerts to ACLP alerts
     ui.button
-      .findByTitle(ALERTS_LEGACY_MODE_BUTTON_TEXT)
+      .findByTitle(ALERTS_LEGACY_MODE_BETA_PHASE_BUTTON_TEXT)
       .should('be.visible')
       .should('be.enabled')
       .click();
     cy.get('[data-qa-panel="Alerts"]')
       .should('be.visible')
       .within(() => {
-        cy.get('[data-testid="betaChip"]').should('be.visible');
         cy.get('[data-testid="notice-info"]')
           .should('be.visible')
           .within(() => {
-            cy.contains(ALERTS_BETA_MODE_BANNER_TEXT);
+            cy.contains(ALERTS_ACLP_MODE_BETA_PHASE_BANNER_TEXT);
           });
         // possible to downgrade from ACLP alerts to legacy alerts
         ui.button
-          .findByTitle(ALERTS_BETA_MODE_BUTTON_TEXT)
+          .findByTitle(ALERTS_ACLP_MODE_BETA_AND_NEW_PHASE_BUTTON_TEXT)
           .should('be.visible')
           .should('be.enabled');
       });
diff --git a/packages/manager/cypress/e2e/core/linodes/alerts-edit.spec.ts b/packages/manager/cypress/e2e/core/linodes/alerts-edit.spec.ts
index 7513f2b8fe2..5ac4612995a 100644
--- a/packages/manager/cypress/e2e/core/linodes/alerts-edit.spec.ts
+++ b/packages/manager/cypress/e2e/core/linodes/alerts-edit.spec.ts
@@ -12,11 +12,11 @@ import { randomLabel } from 'support/util/random';
 
 import { alertFactory } from 'src/factories';
 import {
-  ALERTS_BETA_MODE_BANNER_TEXT,
-  ALERTS_BETA_MODE_BUTTON_TEXT,
+  ALERTS_ACLP_MODE_BETA_AND_NEW_PHASE_BUTTON_TEXT,
+  ALERTS_ACLP_MODE_BETA_PHASE_BANNER_TEXT,
   ALERTS_BETA_PROMPT,
-  ALERTS_LEGACY_MODE_BANNER_TEXT,
-  ALERTS_LEGACY_MODE_BUTTON_TEXT,
+  ALERTS_LEGACY_MODE_BETA_PHASE_BANNER_TEXT,
+  ALERTS_LEGACY_MODE_BETA_PHASE_BUTTON_TEXT,
   ALERTS_LEGACY_PROMPT,
 } from 'src/features/Linodes/constants';
 
@@ -62,15 +62,20 @@ describe('region enables alerts', function () {
       aclpServices: {
         linode: {
           alerts: {
-            beta: true,
+            beta: true, // irrelevant since we are no longer using this service-specific beta flag
             enabled: true,
           },
           metrics: {
-            beta: false,
+            beta: false, // irrelevant since we are no longer using this service-specific beta flag
             enabled: false,
           },
         },
       },
+      aclp: { beta: false, new: false },
+      aclpAlerting: {
+        beta: true, // relevant for this test suite
+        new: false, // relevant for this test suite
+      },
     }).as('getFeatureFlags');
     const mockEnabledRegion = regionFactory.build({
       capabilities: ['Linodes'],
@@ -142,7 +147,7 @@ describe('region enables alerts', function () {
         cy.get('[data-testid="notice-info"]')
           .should('be.visible')
           .within(() => {
-            cy.contains(ALERTS_LEGACY_MODE_BANNER_TEXT);
+            cy.contains(ALERTS_LEGACY_MODE_BETA_PHASE_BANNER_TEXT);
           });
         // alerts are disabled so all toggles are off
         ui.toggle.find().each(($toggle) => {
@@ -152,7 +157,7 @@ describe('region enables alerts', function () {
 
     // upgrade from legacy alerts to ACLP alerts
     ui.button
-      .findByTitle(ALERTS_LEGACY_MODE_BUTTON_TEXT)
+      .findByTitle(ALERTS_LEGACY_MODE_BETA_PHASE_BUTTON_TEXT)
       .should('be.visible')
       .should('be.enabled')
       .click();
@@ -167,11 +172,11 @@ describe('region enables alerts', function () {
         cy.get('[data-testid="notice-info"]')
           .should('be.visible')
           .within(() => {
-            cy.contains(ALERTS_BETA_MODE_BANNER_TEXT);
+            cy.contains(ALERTS_ACLP_MODE_BETA_PHASE_BANNER_TEXT);
           });
         // possible to downgrade from ACLP alerts to legacy alerts
         ui.button
-          .findByTitle(ALERTS_BETA_MODE_BUTTON_TEXT)
+          .findByTitle(ALERTS_ACLP_MODE_BETA_AND_NEW_PHASE_BUTTON_TEXT)
           .should('be.visible')
           .should('be.enabled');
 
@@ -203,7 +208,7 @@ describe('region enables alerts', function () {
         cy.get('[data-testid="notice-info"]')
           .should('be.visible')
           .within(() => {
-            cy.contains(ALERTS_LEGACY_MODE_BANNER_TEXT);
+            cy.contains(ALERTS_LEGACY_MODE_BETA_PHASE_BANNER_TEXT);
           });
 
         // alerts are enabled so all toggles are on if val > 0
@@ -214,7 +219,7 @@ describe('region enables alerts', function () {
 
     // upgrade from legacy alerts to ACLP alerts
     ui.button
-      .findByTitle(ALERTS_LEGACY_MODE_BUTTON_TEXT)
+      .findByTitle(ALERTS_LEGACY_MODE_BETA_PHASE_BUTTON_TEXT)
       .should('be.visible')
       .should('be.enabled')
       .click();
@@ -228,7 +233,7 @@ describe('region enables alerts', function () {
         cy.get('[data-testid="notice-info"]')
           .should('be.visible')
           .within(() => {
-            cy.contains(ALERTS_BETA_MODE_BANNER_TEXT);
+            cy.contains(ALERTS_ACLP_MODE_BETA_PHASE_BANNER_TEXT);
           });
         cy.wait(['@getAlertDefinitions']);
         // toggles in table are on but can be turned off
@@ -266,7 +271,7 @@ describe('region enables alerts', function () {
         cy.get('[data-testid="notice-info"]')
           .should('be.visible')
           .within(() => {
-            cy.contains(ALERTS_BETA_MODE_BANNER_TEXT);
+            cy.contains(ALERTS_ACLP_MODE_BETA_PHASE_BANNER_TEXT);
           });
         cy.wait(['@getAlertDefinitions']);
         assertLinodeAlertsEnabled(this.alertDefinitions);
@@ -274,7 +279,7 @@ describe('region enables alerts', function () {
 
     // downgrade from ACLP alerts to legacy alerts
     ui.button
-      .findByTitle(ALERTS_BETA_MODE_BUTTON_TEXT)
+      .findByTitle(ALERTS_ACLP_MODE_BETA_AND_NEW_PHASE_BUTTON_TEXT)
       .should('be.visible')
       .should('be.enabled')
       .click();
@@ -289,7 +294,7 @@ describe('region enables alerts', function () {
         cy.get('[data-testid="notice-info"]')
           .should('be.visible')
           .within(() => {
-            cy.contains(ALERTS_LEGACY_MODE_BANNER_TEXT);
+            cy.contains(ALERTS_LEGACY_MODE_BETA_PHASE_BANNER_TEXT);
           });
         // alerts are disabled so all toggles are off
         ui.toggle.find().each(($toggle) => {
@@ -301,7 +306,7 @@ describe('region enables alerts', function () {
         });
         // possible to upgrade to beta
         ui.button
-          .findByTitle(ALERTS_LEGACY_MODE_BUTTON_TEXT)
+          .findByTitle(ALERTS_LEGACY_MODE_BETA_PHASE_BUTTON_TEXT)
           .should('be.visible')
           .should('be.enabled');
 
@@ -333,13 +338,13 @@ describe('region enables alerts', function () {
         cy.get('[data-testid="notice-info"]')
           .should('be.visible')
           .within(() => {
-            cy.contains(ALERTS_BETA_MODE_BANNER_TEXT);
+            cy.contains(ALERTS_ACLP_MODE_BETA_PHASE_BANNER_TEXT);
           });
         cy.wait(['@getAlertDefinitions']);
         assertLinodeAlertsEnabled(this.alertDefinitions);
         // downgrade from ACLP alerts to legacy alerts
         ui.button
-          .findByTitle(ALERTS_BETA_MODE_BUTTON_TEXT)
+          .findByTitle(ALERTS_ACLP_MODE_BETA_AND_NEW_PHASE_BUTTON_TEXT)
           .should('be.visible')
           .should('be.enabled')
           .click();
@@ -356,7 +361,7 @@ describe('region enables alerts', function () {
         cy.get('[data-testid="notice-info"]')
           .should('be.visible')
           .within(() => {
-            cy.contains(ALERTS_LEGACY_MODE_BANNER_TEXT);
+            cy.contains(ALERTS_LEGACY_MODE_BETA_PHASE_BANNER_TEXT);
           });
         // turn the toggles off
         ui.toggle
@@ -405,7 +410,7 @@ describe('region enables alerts', function () {
         cy.get('[data-testid="notice-info"]')
           .should('be.visible')
           .within(() => {
-            cy.contains(ALERTS_BETA_MODE_BANNER_TEXT);
+            cy.contains(ALERTS_ACLP_MODE_BETA_PHASE_BANNER_TEXT);
           });
         cy.wait(['@getAlertDefinitions']);
         // toggles in table are on but can be turned off
@@ -444,13 +449,13 @@ describe('region enables alerts', function () {
         cy.get('[data-testid="notice-info"]')
           .should('be.visible')
           .within(() => {
-            cy.contains(ALERTS_LEGACY_MODE_BANNER_TEXT);
+            cy.contains(ALERTS_LEGACY_MODE_BETA_PHASE_BANNER_TEXT);
           });
       });
 
     // upgrade from legacy alerts to ACLP alerts
     ui.button
-      .findByTitle(ALERTS_LEGACY_MODE_BUTTON_TEXT)
+      .findByTitle(ALERTS_LEGACY_MODE_BETA_PHASE_BUTTON_TEXT)
       .should('be.visible')
       .should('be.enabled')
       .click();
@@ -465,7 +470,7 @@ describe('region enables alerts', function () {
         cy.get('[data-testid="notice-info"]')
           .should('be.visible')
           .within(() => {
-            cy.contains(ALERTS_BETA_MODE_BANNER_TEXT);
+            cy.contains(ALERTS_ACLP_MODE_BETA_PHASE_BANNER_TEXT);
           });
         cy.wait(['@getAlertDefinitions']);
         cy.get('table[data-testid="alert-table"]')
@@ -558,8 +563,12 @@ describe('region disables alerts. beta alerts not available regardless of linode
         cy.contains('Alerts').should('be.visible');
         cy.get('[data-testid="notice-info"]').should('not.exist');
         // not possible to upgrade or downgrade
-        cy.findByText(ALERTS_LEGACY_MODE_BUTTON_TEXT).should('not.exist');
-        cy.findByText(ALERTS_BETA_MODE_BUTTON_TEXT).should('not.exist');
+        cy.findByText(ALERTS_LEGACY_MODE_BETA_PHASE_BUTTON_TEXT).should(
+          'not.exist'
+        );
+        cy.findByText(ALERTS_ACLP_MODE_BETA_AND_NEW_PHASE_BUTTON_TEXT).should(
+          'not.exist'
+        );
         // alerts are disabled so all toggles are off but are not readonly
         ui.toggle.find().each(($toggle) => {
           cy.wrap($toggle)
@@ -591,8 +600,12 @@ describe('region disables alerts. beta alerts not available regardless of linode
         cy.contains('Alerts').should('be.visible');
         cy.get('[data-testid="notice-info"]').should('not.exist');
         // not possible to upgrade or downgrade
-        cy.findByText(ALERTS_LEGACY_MODE_BUTTON_TEXT).should('not.exist');
-        cy.findByText(ALERTS_BETA_MODE_BUTTON_TEXT).should('not.exist');
+        cy.findByText(ALERTS_LEGACY_MODE_BETA_PHASE_BUTTON_TEXT).should(
+          'not.exist'
+        );
+        cy.findByText(ALERTS_ACLP_MODE_BETA_AND_NEW_PHASE_BUTTON_TEXT).should(
+          'not.exist'
+        );
         // legacy alerts are enabled
         ui.toggle.find().each(($toggle) => {
           cy.wrap($toggle)
diff --git a/packages/manager/cypress/e2e/core/objectStorage/object-storage.e2e.spec.ts b/packages/manager/cypress/e2e/core/objectStorage/object-storage.e2e.spec.ts
index 16c7ab3a57e..b5eb697d416 100644
--- a/packages/manager/cypress/e2e/core/objectStorage/object-storage.e2e.spec.ts
+++ b/packages/manager/cypress/e2e/core/objectStorage/object-storage.e2e.spec.ts
@@ -78,12 +78,11 @@ describe('object storage end-to-end tests', () => {
     cy.tag('purpose:syntheticTesting');
     const bucketLabel = randomLabel();
     const bucketClusterObj = chooseCluster();
-    const bucketCluster = bucketClusterObj.id;
-    const bucketRegion = getRegionById(bucketClusterObj.region).label;
-    const bucketHostname = `${bucketLabel}.${bucketClusterObj.domain}`;
+    const bucketRegion = getRegionById(bucketClusterObj.region);
+    let bucketHostname: string;
     interceptGetBuckets().as('getBuckets');
     interceptCreateBucket().as('createBucket');
-    interceptDeleteBucket(bucketLabel, bucketCluster).as('deleteBucket');
+    interceptDeleteBucket(bucketLabel, bucketRegion.id).as('deleteBucket');
     interceptGetNetworkUtilization().as('getNetworkUtilization');
 
     mockGetAccount(accountFactory.build({ capabilities: ['Object Storage'] }));
@@ -110,7 +109,7 @@ describe('object storage end-to-end tests', () => {
         cy.findByLabelText('Bucket Name (required)').click();
         cy.focused().type(bucketLabel);
         ui.regionSelect.find().click();
-        cy.focused().type(`${bucketRegion}{enter}`);
+        cy.focused().type(`${bucketRegion.label}{enter}`);
 
         ui.buttonGroup
           .findButtonByTitle('Create Bucket')
@@ -118,7 +117,9 @@ describe('object storage end-to-end tests', () => {
           .click();
       });
 
-    cy.wait(['@createBucket', '@getBuckets']);
+    cy.wait(['@createBucket', '@getBuckets']).then(([createBucket]) => {
+      bucketHostname = createBucket?.response?.body?.hostname;
+    });
     ui.drawer.find().should('not.exist');
 
     // Confirm that bucket is created, initiate deletion.
@@ -126,7 +127,7 @@ describe('object storage end-to-end tests', () => {
       .should('be.visible')
       .closest('tr')
       .within(() => {
-        cy.findByText(bucketRegion).should('be.visible');
+        cy.findByText(bucketRegion.label).should('be.visible');
         cy.findByText(bucketHostname).should('be.visible');
         ui.button.findByTitle('Delete').should('be.visible').click();
       });
diff --git a/packages/manager/cypress/support/constants/cloudpulse.ts b/packages/manager/cypress/support/constants/cloudpulse.ts
index 9a59bc37b9e..35bfbbfaeb1 100644
--- a/packages/manager/cypress/support/constants/cloudpulse.ts
+++ b/packages/manager/cypress/support/constants/cloudpulse.ts
@@ -4,12 +4,12 @@
  */
 
 export const cloudPulseServiceMap: Record = {
+  blockstorage: 'Volumes',
   dbaas: 'Databases',
   linode: 'Linodes',
   nodebalancer: 'NodeBalancers',
   firewall: 'Firewalls',
   objectstorage: 'Object Storage',
-  blockstorage: 'Volumes',
   lke: 'Kubernetes',
   netloadbalancer: 'Netloadbalancer',
   logs: 'Logs',
diff --git a/packages/manager/package.json b/packages/manager/package.json
index 248080fb44a..3a19a5b098c 100644
--- a/packages/manager/package.json
+++ b/packages/manager/package.json
@@ -52,14 +52,14 @@
     "chart.js": "~2.9.4",
     "copy-to-clipboard": "^3.0.8",
     "country-region-data": "^3.0.0",
-    "dompurify": "^3.2.4",
+    "dompurify": "^3.3.2",
     "flag-icons": "^6.6.5",
     "font-logos": "^0.18.0",
     "formik": "~2.1.3",
     "he": "^1.2.0",
     "immer": "^9.0.6",
     "ipaddr.js": "^1.9.1",
-    "jspdf": "^4.2.0",
+    "jspdf": "^4.2.1",
     "jspdf-autotable": "^5.0.2",
     "launchdarkly-react-client-sdk": "3.0.10",
     "libphonenumber-js": "^1.10.6",
diff --git a/packages/manager/public/assets/deepseek.svg b/packages/manager/public/assets/deepseek.svg
new file mode 100644
index 00000000000..bd63ac59dfa
--- /dev/null
+++ b/packages/manager/public/assets/deepseek.svg
@@ -0,0 +1,11 @@
+
+
+  
+    
+  
+  
+
\ No newline at end of file
diff --git a/packages/manager/public/assets/marketplace/Norsk-dark.svg b/packages/manager/public/assets/marketplace/Norsk-dark.svg
new file mode 100644
index 00000000000..cdf4cb59270
--- /dev/null
+++ b/packages/manager/public/assets/marketplace/Norsk-dark.svg
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/manager/public/assets/marketplace/Norsk-light.svg b/packages/manager/public/assets/marketplace/Norsk-light.svg
new file mode 100644
index 00000000000..1528c08c22a
--- /dev/null
+++ b/packages/manager/public/assets/marketplace/Norsk-light.svg
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/manager/public/assets/marketplace/clouddat.svg b/packages/manager/public/assets/marketplace/clouddat.svg
new file mode 100644
index 00000000000..ce1b445fc27
--- /dev/null
+++ b/packages/manager/public/assets/marketplace/clouddat.svg
@@ -0,0 +1,453 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/manager/public/assets/marketplace/data-expedition-dark.svg b/packages/manager/public/assets/marketplace/data-expedition-dark.svg
new file mode 100644
index 00000000000..2b4adb9858b
--- /dev/null
+++ b/packages/manager/public/assets/marketplace/data-expedition-dark.svg
@@ -0,0 +1 @@
+dei_logo2016_hori_rev_rgb
\ No newline at end of file
diff --git a/packages/manager/public/assets/marketplace/data-expedition-light.svg b/packages/manager/public/assets/marketplace/data-expedition-light.svg
new file mode 100644
index 00000000000..1f33194503e
--- /dev/null
+++ b/packages/manager/public/assets/marketplace/data-expedition-light.svg
@@ -0,0 +1 @@
+dei_logo2016_hori_rgb
\ No newline at end of file
diff --git a/packages/manager/public/assets/marketplace/norsk_platform_architecture.svg b/packages/manager/public/assets/marketplace/norsk_platform_architecture.svg
new file mode 100644
index 00000000000..bd511b8bab4
--- /dev/null
+++ b/packages/manager/public/assets/marketplace/norsk_platform_architecture.svg
@@ -0,0 +1,155 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/manager/public/assets/marketplace/norsk_process_flow.svg b/packages/manager/public/assets/marketplace/norsk_process_flow.svg
new file mode 100644
index 00000000000..0361aee68b5
--- /dev/null
+++ b/packages/manager/public/assets/marketplace/norsk_process_flow.svg
@@ -0,0 +1,49 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/manager/public/assets/marketplace/sftpgo-dark.svg b/packages/manager/public/assets/marketplace/sftpgo-dark.svg
index f9f3813a3ac..4f656582f93 100644
--- a/packages/manager/public/assets/marketplace/sftpgo-dark.svg
+++ b/packages/manager/public/assets/marketplace/sftpgo-dark.svg
@@ -1,38 +1,31 @@
-
-  
-    
-      
-      
-    
-    
-    
-      
-      
-      
-      
-    
-  
-
-  
-    
-    
-    
-    
-  
-
-  
-    
-    
-    
-  
-
-  
-    SFTPGo
-  
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
 
diff --git a/packages/manager/public/assets/marketplace/sftpgo-light.svg b/packages/manager/public/assets/marketplace/sftpgo-light.svg
index f9f3813a3ac..4f656582f93 100644
--- a/packages/manager/public/assets/marketplace/sftpgo-light.svg
+++ b/packages/manager/public/assets/marketplace/sftpgo-light.svg
@@ -1,38 +1,31 @@
-
-  
-    
-      
-      
-    
-    
-    
-      
-      
-      
-      
-    
-  
-
-  
-    
-    
-    
-    
-  
-
-  
-    
-    
-    
-  
-
-  
-    SFTPGo
-  
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
 
diff --git a/packages/manager/public/assets/openclaw.svg b/packages/manager/public/assets/openclaw.svg
new file mode 100644
index 00000000000..bcbc1e10cb4
--- /dev/null
+++ b/packages/manager/public/assets/openclaw.svg
@@ -0,0 +1,22 @@
+
+  
+    
+      
+      
+    
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+
diff --git a/packages/manager/public/assets/white/deepseek.svg b/packages/manager/public/assets/white/deepseek.svg
new file mode 100644
index 00000000000..2695c47be28
--- /dev/null
+++ b/packages/manager/public/assets/white/deepseek.svg
@@ -0,0 +1,11 @@
+
+
+  
+    
+  
+  
+
\ No newline at end of file
diff --git a/packages/manager/public/assets/white/openclaw.svg b/packages/manager/public/assets/white/openclaw.svg
new file mode 100644
index 00000000000..557c10eba47
--- /dev/null
+++ b/packages/manager/public/assets/white/openclaw.svg
@@ -0,0 +1,38 @@
+
+
+  
+    
+  
+  
+    
+    
+    
+  
+  
+    
+    
+    
+    
+  
+  
+  
+    
+      
+      
+      
+    
+    
+  
+
\ No newline at end of file
diff --git a/packages/manager/src/GoTo.tsx b/packages/manager/src/GoTo.tsx
index 6f02c421910..8790b2a3e70 100644
--- a/packages/manager/src/GoTo.tsx
+++ b/packages/manager/src/GoTo.tsx
@@ -7,6 +7,7 @@ import { useIsDatabasesEnabled } from './features/Databases/utilities';
 import { useIsMarketplaceV2Enabled } from './features/Marketplace/shared';
 import { useIsNetworkLoadBalancerEnabled } from './features/NetworkLoadBalancers/utils';
 import { useIsPlacementGroupsEnabled } from './features/PlacementGroups/utils';
+import { useIsReserveIpEnabled } from './features/ReservedIps/utils';
 import { useGlobalKeyboardListener } from './hooks/useGlobalKeyboardListener';
 
 import type { SelectOption } from '@linode/ui';
@@ -22,6 +23,7 @@ export const GoTo = React.memo(() => {
   const { isDatabasesEnabled } = useIsDatabasesEnabled();
   const { isMarketplaceV2FeatureEnabled } = useIsMarketplaceV2Enabled();
   const { isNetworkLoadBalancerEnabled } = useIsNetworkLoadBalancerEnabled();
+  const { isReserveIpEnabled } = useIsReserveIpEnabled();
 
   const { goToOpen, setGoToOpen } = useGlobalKeyboardListener();
 
@@ -63,6 +65,11 @@ export const GoTo = React.memo(() => {
         display: 'NodeBalancers',
         href: '/nodebalancers',
       },
+      {
+        display: 'Reserved IPs',
+        hide: !isReserveIpEnabled,
+        href: '/reserved-ips',
+      },
       {
         display: 'Firewalls',
         href: '/firewalls',
@@ -130,6 +137,7 @@ export const GoTo = React.memo(() => {
       isMarketplaceV2FeatureEnabled,
       isNetworkLoadBalancerEnabled,
       isPlacementGroupsEnabled,
+      isReserveIpEnabled,
     ]
   );
 
diff --git a/packages/manager/src/components/CopyTooltip/CopyTooltip.test.tsx b/packages/manager/src/components/CopyTooltip/CopyTooltip.test.tsx
index 078c8bd7b1c..5e028a17012 100644
--- a/packages/manager/src/components/CopyTooltip/CopyTooltip.test.tsx
+++ b/packages/manager/src/components/CopyTooltip/CopyTooltip.test.tsx
@@ -44,7 +44,7 @@ describe('CopyTooltip', () => {
     expect(getByText(mockText)).toBeVisible();
   });
 
-  it('should disable the tooltip text with the disable property', async () => {
+  it('should disable the tooltip with the disable property', async () => {
     const { getByLabelText } = renderWithTheme(
       
     );
@@ -53,6 +53,23 @@ describe('CopyTooltip', () => {
     expect(copyIconButton).toBeDisabled();
   });
 
+  it('should display tooltip reason with the disabledReason property', async () => {
+    const { getByLabelText, findByRole } = renderWithTheme(
+      
+    );
+
+    const copyIconButton = getByLabelText(`Copy ${mockText} to clipboard`);
+
+    await userEvent.hover(copyIconButton);
+    const copiedTooltip = await findByRole('tooltip');
+    expect(copiedTooltip).toBeInTheDocument();
+    expect(copiedTooltip).toHaveTextContent('Tooltip disabled');
+  });
+
   it('should mask and toggle visibility of tooltip text with the masked property', async () => {
     const { getByLabelText, getByTestId, getByText, queryByText } =
       renderWithTheme(
diff --git a/packages/manager/src/components/CopyTooltip/CopyTooltip.tsx b/packages/manager/src/components/CopyTooltip/CopyTooltip.tsx
index 6db536e4098..5c3dc060b80 100644
--- a/packages/manager/src/components/CopyTooltip/CopyTooltip.tsx
+++ b/packages/manager/src/components/CopyTooltip/CopyTooltip.tsx
@@ -24,6 +24,10 @@ export interface CopyTooltipProps {
    * @default false
    */
   disabled?: boolean;
+  /**
+   * Optionally display disabled reason as tooltip
+   */
+  disabledReason?: string;
   /**
    * If true, the component is in controlled mode for text masking, meaning the parent component handles the visibility toggle.
    * @default false
@@ -63,6 +67,7 @@ export const CopyTooltip = (props: CopyTooltipProps) => {
     className,
     copyableText,
     disabled,
+    disabledReason,
     isMaskingControlled,
     masked,
     maskedTextLength,
@@ -105,6 +110,20 @@ export const CopyTooltip = (props: CopyTooltipProps) => {
     
   );
 
+  if (disabled && disabledReason) {
+    return (
+      
+        {CopyButton}
+      
+    );
+  }
+
   if (disabled) {
     return CopyButton;
   }
@@ -134,6 +153,7 @@ export const StyledIconButton = styled('button', {
   label: 'StyledIconButton',
   shouldForwardProp: omittedProps([
     'copyableText',
+    'disabledReason',
     'text',
     'onClickCallback',
     'masked',
diff --git a/packages/manager/src/components/DownloadCSV/DownloadCSV.tsx b/packages/manager/src/components/DownloadCSV/DownloadCSV.tsx
index e7cfd0eabf1..31ad310f2c2 100644
--- a/packages/manager/src/components/DownloadCSV/DownloadCSV.tsx
+++ b/packages/manager/src/components/DownloadCSV/DownloadCSV.tsx
@@ -13,10 +13,11 @@ interface DownloadCSVProps {
   className?: string;
   csvRef?: React.RefObject;
   data: unknown[];
+  dataPendoId?: string;
   disabled?: boolean;
   filename: string;
   headers: { key: string; label: string }[];
-  iconStyles?: React.CSSProperties;
+  iconStyles?: SxProps;
   onClick: (() => void) | ((e: React.MouseEvent) => void);
   sx?: SxProps;
   text?: string;
@@ -36,6 +37,7 @@ export const DownloadCSV = ({
   className,
   csvRef,
   data,
+  dataPendoId,
   filename,
   headers,
   onClick,
@@ -46,13 +48,19 @@ export const DownloadCSV = ({
 }: DownloadCSVProps) => {
   const renderButton =
     buttonType === 'styledLink' ? (
-      
+      
         
         {text}
       
     ) : (
       
-                  
-                )}
+                {/* Show save button only in edit mode. Service types listed in
+                    SERVICES_WITH_EXTERNAL_SAVE manage their own save externally
+                    (e.g. linode handles it in the parent component). */}
+                {isEditMode &&
+                  !SERVICES_WITH_EXTERNAL_SAVE.includes(serviceType) && (
+                    
+                      
+                    
+                  )}
               
             )}
           
diff --git a/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertReusableComponent.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertReusableComponent.test.tsx
index a04c4336ede..89635cf943a 100644
--- a/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertReusableComponent.test.tsx
+++ b/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertReusableComponent.test.tsx
@@ -126,11 +126,33 @@ describe('Alert Resuable Component for contextual view', () => {
   });
 
   it('Should show header for edit mode', async () => {
-    renderWithTheme(component, {
-      initialEntries: ['/alerts/definitions'],
-      initialRoute: '/alerts/definitions',
-    });
-    await userEvent.click(screen.getByText('Manage Alerts'));
+    // For service types not in SERVICES_WITH_MANAGE_ALERTS_IN_FILTER_ROW (e.g. dbaas),
+    // the 'Alerts' heading and 'Manage Alerts' button appear together in the section header.
+    renderWithTheme(
+      ,
+      {
+        initialEntries: ['/alerts/definitions'],
+        initialRoute: '/alerts/definitions',
+      }
+    );
     expect(screen.getByText('Alerts')).toBeVisible();
+    expect(screen.getByTestId('manage-alerts')).toBeVisible();
+  });
+
+  it('Should not show Alerts heading for linode service type but still show Manage Alerts button in filter row', () => {
+    // For service types in SERVICES_WITH_MANAGE_ALERTS_IN_FILTER_ROW (e.g. linode), the 'Alerts' heading
+    // belongs to the service owner and is not rendered here. The Manage Alerts button moves to the filter row.
+    renderWithTheme(component); // component uses serviceType='linode' with entityId
+
+    expect(
+      screen.queryByRole('heading', { level: 2, name: 'Alerts' })
+    ).not.toBeInTheDocument();
+    expect(screen.getByTestId('manage-alerts')).toBeVisible();
   });
 });
diff --git a/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertReusableComponent.tsx b/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertReusableComponent.tsx
index 928456ad510..92fd61ada69 100644
--- a/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertReusableComponent.tsx
+++ b/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertReusableComponent.tsx
@@ -24,6 +24,7 @@ import type {
   CloudPulseAlertsPayload,
   CloudPulseServiceType,
 } from '@linode/api-v4';
+import type { SxProps, Theme } from '@linode/ui';
 
 interface AlertReusableComponentProps {
   /**
@@ -41,6 +42,14 @@ interface AlertReusableComponentProps {
    */
   isLegacyAlertAvailable?: boolean;
 
+  /**
+   * Called once when this component is ready - i.e. alerts have loaded
+   * successfully without error. Always receives `true`.
+   * Service owners can use this to enable or disable save buttons that depend
+   * on the readiness of this component before allowing any action.
+   */
+  onStatusChange?: (isReady: boolean) => void;
+
   /**
    * Called when an alert is toggled on or off.
    * @param payload enabled alerts ids
@@ -51,6 +60,11 @@ interface AlertReusableComponentProps {
     hasUnsavedChanges?: boolean
   ) => void;
 
+  /**
+   * Custom sx styles for the Paper wrapper component
+   */
+  paperSx?: SxProps;
+
   /**
    * Region ID for the selected entity
    */
@@ -62,11 +76,21 @@ interface AlertReusableComponentProps {
   serviceType: CloudPulseServiceType;
 }
 
+/**
+ * Service types that display the Manage Alerts button inline with the search/filter row.
+ * For all other service types, the button appears inline with the Alerts section header.
+ */
+const SERVICES_WITH_MANAGE_ALERTS_IN_FILTER_ROW: CloudPulseServiceType[] = [
+  'linode',
+];
+
 export const AlertReusableComponent = (props: AlertReusableComponentProps) => {
   const {
     entityId,
     entityName,
     onToggleAlert,
+    onStatusChange,
+    paperSx,
     serviceType,
     regionId,
     isLegacyAlertAvailable,
@@ -77,6 +101,13 @@ export const AlertReusableComponent = (props: AlertReusableComponentProps) => {
     isLoading,
   } = useAlertDefinitionByServiceTypeQuery(serviceType);
 
+  React.useEffect(() => {
+    // Only notify the parent when ready
+    if (!isLoading && !error) {
+      onStatusChange?.(true);
+    }
+  }, [isLoading, error, onStatusChange]);
+
   const [searchText, setSearchText] = React.useState('');
   const [selectedType, setSelectedType] = React.useState<
     AlertDefinitionType | undefined
@@ -100,24 +131,26 @@ export const AlertReusableComponent = (props: AlertReusableComponentProps) => {
   }
 
   return (
-    
+    
       
-        {entityId && (
-          
-            
-              Alerts
-              {aclpServices?.[serviceType]?.alerts?.beta && }
+        {/* Not in SERVICES_WITH_MANAGE_ALERTS_IN_FILTER_ROW: Show Manage Alerts button inline with the Alerts section header */}
+        {entityId &&
+          !SERVICES_WITH_MANAGE_ALERTS_IN_FILTER_ROW.includes(serviceType) && (
+            
+              
+                Alerts
+                {aclpServices?.[serviceType]?.alerts?.beta && }
+              
+              
             
-            
-          
-        )}
+          )}
         
           
              {
                 hideLabel: true,
               }}
             />
+            {/* In SERVICES_WITH_MANAGE_ALERTS_IN_FILTER_ROW: Show Manage Alerts button inline with the search/filter row (right-aligned) */}
+            {entityId &&
+              SERVICES_WITH_MANAGE_ALERTS_IN_FILTER_ROW.includes(
+                serviceType
+              ) && (
+                
+              )}
           
 
            {
     const recipients = hasUserNames
       ? detailEmail.usernames
       : [detailEmail.recipient_type];
-
     return (
       <>
         {recipients.map((value) => (
diff --git a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelTableRow.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelTableRow.test.tsx
index b491acdbfde..154791e4b28 100644
--- a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelTableRow.test.tsx
+++ b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelTableRow.test.tsx
@@ -72,6 +72,76 @@ describe('NotificationChannelTableRow', () => {
     expect(screen.getByText('Email')).toBeVisible();
   });
 
+  it('should render channel type as Slack for slack type', () => {
+    const channel = notificationChannelFactory.build({
+      channel_type: 'slack',
+      details: {
+        slack: {
+          slack_channel: 'channel',
+          slack_webhook_url: 'url',
+        },
+      },
+    });
+
+    renderWithTheme(
+      wrapWithTableBody(
+        
+      )
+    );
+
+    expect(screen.getByText('Slack')).toBeVisible();
+  });
+
+  it('should render channel type as PagerDuty for pagerduty type', () => {
+    const channel = notificationChannelFactory.build({
+      channel_type: 'pagerduty',
+      details: {
+        pagerduty: {
+          attributes: [],
+          description: 'desc',
+          service_api_key: 'key',
+        },
+      },
+    });
+
+    renderWithTheme(
+      wrapWithTableBody(
+        
+      )
+    );
+
+    expect(screen.getByText('PagerDuty')).toBeVisible();
+  });
+
+  it('should render channel type as Webhook for webhook type', () => {
+    const channel = notificationChannelFactory.build({
+      channel_type: 'webhook',
+      details: {
+        webhook: {
+          http_headers: [],
+          webhook_url: 'url',
+        },
+      },
+    });
+
+    renderWithTheme(
+      wrapWithTableBody(
+        
+      )
+    );
+
+    expect(screen.getByText('Webhook')).toBeVisible();
+  });
+
   it('should render zero alerts count when no alerts are associated', () => {
     const channel = notificationChannelFactory.build({
       alerts: { alert_count: 0 },
diff --git a/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.ts b/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.ts
index 2222c596e14..7cb85e6b648 100644
--- a/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.ts
+++ b/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.ts
@@ -250,8 +250,8 @@ export const getChipLabels = (
   if (value.channel_type === 'email') {
     const recipients =
       value.details.email.recipient_type === 'user'
-        ? value.details?.email?.usernames
-        : [value.details?.email?.recipient_type];
+        ? value.details.email.usernames
+        : [value.details.email.recipient_type];
 
     return {
       label: 'To',
@@ -260,17 +260,17 @@ export const getChipLabels = (
   } else if (value.channel_type === 'slack') {
     return {
       label: 'Slack Webhook URL',
-      values: [value.details?.slack.slack_webhook_url ?? ''],
+      values: [value.details.slack.slack_webhook_url ?? ''],
     };
   } else if (value.channel_type === 'pagerduty') {
     return {
       label: 'Service API Key',
-      values: [value.details?.pagerduty.service_api_key ?? ''],
+      values: [value.details.pagerduty.service_api_key ?? ''],
     };
   } else {
     return {
       label: 'Webhook URL',
-      values: [value.details?.webhook.webhook_url ?? ''],
+      values: [value.details.webhook.webhook_url ?? ''],
     };
   }
 };
diff --git a/packages/manager/src/features/CloudPulse/Context/CloudPulseContextProvider.test.tsx b/packages/manager/src/features/CloudPulse/Context/CloudPulseContextProvider.test.tsx
new file mode 100644
index 00000000000..50e3f5a4c1b
--- /dev/null
+++ b/packages/manager/src/features/CloudPulse/Context/CloudPulseContextProvider.test.tsx
@@ -0,0 +1,121 @@
+import { renderHook } from '@testing-library/react';
+import React from 'react';
+
+import { dashboardFactory } from 'src/factories';
+import { renderWithTheme } from 'src/utilities/testHelpers';
+
+import { CloudPulseContext } from './CloudPulseContext';
+import { CloudPulseContextProvider } from './CloudPulseContextProvider';
+
+import type { FilterData } from '../Dashboard/CloudPulseDashboardLanding';
+
+describe('CloudPulseContextProvider', () => {
+  it('should render children correctly', () => {
+    const TestChild = () => 
Test Child
; + const { getByTestId } = renderWithTheme( + + + + ); + const childElement = getByTestId('test-child'); + expect(childElement).toHaveTextContent('Test Child'); + }); + it('should provide context methods to children', () => { + const { result } = renderHook(() => React.useContext(CloudPulseContext), { + wrapper: CloudPulseContextProvider, + }); + expect(result.current.setGlobalFilterData).toBeDefined(); + expect(result.current.getGlobalFilterData).toBeDefined(); + expect(result.current.setGlobalSelectedDashboard).toBeDefined(); + expect(result.current.getGlobalSelectedDashboard).toBeDefined(); + expect(result.current.setGlobalGroupBy).toBeDefined(); + expect(result.current.getGlobalGroupBy).toBeDefined(); + }); + it('should set and get filter data correctly', () => { + const { result } = renderHook(() => React.useContext(CloudPulseContext), { + wrapper: CloudPulseContextProvider, + }); + + const mockFilterData: FilterData = { + id: { region: 'us-east', serviceType: 'linode' }, + label: { region: ['US East'], serviceType: ['Linode'] }, + }; + + result.current.setGlobalFilterData(mockFilterData); + const retrievedData = result.current.getGlobalFilterData(); + + expect(retrievedData).toEqual(mockFilterData); + }); + it('should return undefined when no filter data has been set', () => { + const { result } = renderHook(() => React.useContext(CloudPulseContext), { + wrapper: CloudPulseContextProvider, + }); + + const retrievedData = result.current.getGlobalFilterData(); + expect(retrievedData).toBeUndefined(); + }); + it('should handle complex filter data with arrays', () => { + const { result } = renderHook(() => React.useContext(CloudPulseContext), { + wrapper: CloudPulseContextProvider, + }); + + const complexFilterData: FilterData = { + id: { + regions: ['us-east', 'us-west'], + linodeIds: [123, 456, 789], + tags: ['production', 'monitoring'], + }, + label: { + regions: ['US East', 'US West'], + linodeIds: ['Linode 123', 'Linode 456', 'Linode 789'], + tags: ['production', 'monitoring'], + }, + }; + + result.current.setGlobalFilterData(complexFilterData); + expect(result.current.getGlobalFilterData()).toEqual(complexFilterData); + }); + it('should set and get dashboard correctly', () => { + const { result } = renderHook(() => React.useContext(CloudPulseContext), { + wrapper: CloudPulseContextProvider, + }); + + const mockDashboard = dashboardFactory.build({ + id: 123, + label: 'Test Dashboard', + }); + + result.current.setGlobalSelectedDashboard(mockDashboard); + const retrievedDashboard = result.current.getGlobalSelectedDashboard(); + + expect(retrievedDashboard).toEqual(mockDashboard); + }); + it('should return undefined when no dashboard has been set', () => { + const { result } = renderHook(() => React.useContext(CloudPulseContext), { + wrapper: CloudPulseContextProvider, + }); + + const retrievedDashboard = result.current.getGlobalSelectedDashboard(); + expect(retrievedDashboard).toBeUndefined(); + }); + it('should set and get group by correctly', () => { + const { result } = renderHook(() => React.useContext(CloudPulseContext), { + wrapper: CloudPulseContextProvider, + }); + + const mockGroupBy = ['region', 'service_type']; + + result.current.setGlobalGroupBy(mockGroupBy); + const retrievedGroupBy = result.current.getGlobalGroupBy(); + + expect(retrievedGroupBy).toEqual(mockGroupBy); + }); + it('should return empty array when no group by has been set', () => { + const { result } = renderHook(() => React.useContext(CloudPulseContext), { + wrapper: CloudPulseContextProvider, + }); + + const retrievedGroupBy = result.current.getGlobalGroupBy(); + expect(retrievedGroupBy).toEqual([]); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Context/useCloudPulseContext.tsx b/packages/manager/src/features/CloudPulse/Context/useCloudPulseContext.tsx new file mode 100644 index 00000000000..c523f3a3dad --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Context/useCloudPulseContext.tsx @@ -0,0 +1,7 @@ +import React from 'react'; + +import { CloudPulseContext } from './CloudPulseContext'; + +export const useCloudPulseContext = () => { + return React.useContext(CloudPulseContext); +}; diff --git a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx index 4403dd89672..5c7cda7433e 100644 --- a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx +++ b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx @@ -514,7 +514,6 @@ export const CloudPulseWidget = (props: CloudPulseWidgetProperties) => { vpcFetch.isLoading, linodeFromVolumes.isLoading, ]); - const filterData = getGlobalFilterData(); return ( diff --git a/packages/manager/src/features/CloudPulse/Widget/csv/CloudPulseWidgetCSVDownloader.test.tsx b/packages/manager/src/features/CloudPulse/Widget/csv/CloudPulseWidgetCSVDownloader.test.tsx new file mode 100644 index 00000000000..04b326f2868 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Widget/csv/CloudPulseWidgetCSVDownloader.test.tsx @@ -0,0 +1,78 @@ +import { waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { widgetFactory } from 'src/factories'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { FILTER_CONFIG } from '../../Utils/FilterConfig'; +import { CloudPulseWidgetCSVDownloader } from './CloudPulseWidgetCSVDownloader'; + +import type { CSVDataProps } from './CloudPulseWidgetCSVUtils'; + +const mockEnqueueSnackbar = vi.fn(); +vi.mock('notistack', async () => { + const actual = await vi.importActual('notistack'); + return { + ...actual, + useSnackbar: () => ({ + enqueueSnackbar: mockEnqueueSnackbar, + }), + }; +}); +const baseProps: CSVDataProps = { + dashboardName: 'Test Dashboard', + data: [{ timestamp: 1718000000000, value: 42 }], + dimensionFilters: [], + dimensionOptions: [], + duration: { start: '2026-01-01', end: '2026-01-02' }, + filterConfig: + FILTER_CONFIG.get(1) ?? + vi.mockObject({ + capability: 'Managed Databases', + filters: [], + serviceType: 'dbaas', + }), + filters: { id: {}, label: {} }, + groupBy: [], + isDataLoading: false, + serviceType: 'linode', + widget: widgetFactory.build({ label: 'Test Widget' }), +}; + +describe('CloudPulseWidgetCSVDownloader', () => { + beforeEach(() => { + mockEnqueueSnackbar.mockClear(); + }); + it('should render download button disabled when data is loading', () => { + const { getByRole } = renderWithTheme( + + ); + const button = getByRole('button'); + expect(button).toBeDisabled(); + }); + it('should render download button enabled when data is available', () => { + const { getByRole } = renderWithTheme( + + ); + const button = getByRole('button'); + expect(button).toBeEnabled(); + const csvLink = getByRole('link', { hidden: true }); + expect(csvLink).toHaveAttribute('download', 'Test Widget.csv'); + }); + it('should show success message when download is clicked', async () => { + const { getByRole } = renderWithTheme( + + ); + const button = getByRole('button'); + + await userEvent.click(button); + + await waitFor(() => { + expect(mockEnqueueSnackbar).toHaveBeenCalledWith('Downloaded CSV.', { + variant: 'success', + autoHideDuration: 5000, + }); + }); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Widget/csv/CloudPulseWidgetCSVDownloader.tsx b/packages/manager/src/features/CloudPulse/Widget/csv/CloudPulseWidgetCSVDownloader.tsx index 375f03476ce..1bbfe3c27fa 100644 --- a/packages/manager/src/features/CloudPulse/Widget/csv/CloudPulseWidgetCSVDownloader.tsx +++ b/packages/manager/src/features/CloudPulse/Widget/csv/CloudPulseWidgetCSVDownloader.tsx @@ -40,6 +40,7 @@ export const CloudPulseWidgetCSVDownloader = React.memo( buttonType="styledLink" csvRef={csvRef} data={csvData} + dataPendoId={`Widget CSV Download - ${widget.label ?? 'widget'}`} disabled={!enableDownloadIcon} filename={`${widget.label ?? 'widget'}.csv`} headers={[]} diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseAdvancedConfiguration/DatabaseConfigurationItem.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseAdvancedConfiguration/DatabaseConfigurationItem.tsx index 1d42d1f0bde..9cb39fa948b 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseAdvancedConfiguration/DatabaseConfigurationItem.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseAdvancedConfiguration/DatabaseConfigurationItem.tsx @@ -95,6 +95,11 @@ export const DatabaseConfigurationItem = (props: Props) => { placeholder={ configItem.isNew ? String(configItem?.example ?? '') : '' } + slotProps={{ + htmlInput: { + step: 'any', // UIE-10285: Fix edge-case tooltip + }, + }} type="number" value={configItem.value} /> diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseStatusDisplay.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseStatusDisplay.tsx index 35536eb8601..e8bc2981db4 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseStatusDisplay.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseStatusDisplay.tsx @@ -15,7 +15,6 @@ import type { Status } from 'src/components/StatusIcon/StatusIcon'; export const databaseStatusMap: Record = { active: 'active', degraded: 'inactive', - failed: 'error', migrated: 'inactive', migrating: 'other', provisioning: 'other', diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryConnectionDetails.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryConnectionDetails.tsx index fe9c64ad16e..94f6f00fb0a 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 { DB_ROOT_USERNAME } from 'src/constants'; import { CLUSTER_PROVISIONING_TEXT, CREDENTIALS_ERROR_TEXT, + DISABLE_CREDENTIAL_STATES, DISABLED_PASSWORD_BUTTON_TEXT, } from 'src/features/Databases/constants'; import { useFlags } from 'src/hooks/useFlags'; @@ -74,9 +75,7 @@ export const DatabaseSummaryConnectionDetails = (props: Props) => { } }, [showCredentials, credentialsError]); - const disableShowBtn = ['failed', 'provisioning', 'suspended'].includes( - database.status - ); + const disableShowBtn = DISABLE_CREDENTIAL_STATES.includes(database.status); const credentialsBtn = (handleClick: () => void, btnText: string) => { return ( diff --git a/packages/manager/src/features/Databases/DatabaseDetail/ServiceURI.test.tsx b/packages/manager/src/features/Databases/DatabaseDetail/ServiceURI.test.tsx index 8c34adcc068..a5edaf965db 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/ServiceURI.test.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/ServiceURI.test.tsx @@ -8,6 +8,8 @@ import { renderWithTheme } from 'src/utilities/testHelpers'; import { ServiceURI } from './ServiceURI'; +import type { DatabaseStatus, Engine } from '@linode/api-v4'; + const mockCredentials = { password: 'password123', username: 'lnroot', @@ -53,6 +55,7 @@ const databaseWithNoVPC = databaseFactory.build({ }, platform: 'rdbms-default', private_network: null, // No VPC configured + status: 'active', }); const databaseWithPrivateVPC = databaseFactory.build({ @@ -88,6 +91,7 @@ const databaseWithPrivateVPC = databaseFactory.build({ subnet_id: 1, vpc_id: 123, }, + status: 'active', }); const databaseWithPublicVPC = databaseFactory.build({ @@ -142,6 +146,7 @@ const databaseWithPublicVPC = databaseFactory.build({ subnet_id: 1, vpc_id: 123, }, + status: 'active', }); // Hoist query mocks @@ -160,11 +165,12 @@ vi.mock('@linode/queries', async () => { }); describe('ServiceURI', () => { - it('should render the service URI component and copy icon', async () => { - queryMocks.useDatabaseCredentialsQuery.mockReturnValue({ - data: mockCredentials, - }); + queryMocks.useDatabaseCredentialsQuery.mockReturnValue({ + data: mockCredentials, + refetch: vi.fn(), + }); + it('should render the PgBouncer service URI component and copy icon', async () => { const { container } = renderWithTheme( ); @@ -185,11 +191,6 @@ describe('ServiceURI', () => { }); it('should reveal password after clicking reveal button', async () => { - queryMocks.useDatabaseCredentialsQuery.mockReturnValue({ - data: mockCredentials, - refetch: vi.fn(), - }); - renderWithTheme(); const revealPasswordBtn = screen.getByRole('button', { @@ -205,10 +206,6 @@ describe('ServiceURI', () => { }); it('should render general service URI if isGeneralServiceURI is true', () => { - queryMocks.useDatabaseCredentialsQuery.mockReturnValue({ - data: mockCredentials, - }); - renderWithTheme( ); @@ -224,11 +221,25 @@ describe('ServiceURI', () => { ); }); - it('should reveal general service URI password after clicking reveal button', async () => { - queryMocks.useDatabaseCredentialsQuery.mockReturnValue({ - data: mockCredentials, - refetch: vi.fn(), + it('should render general service URI with ssl-mode=REQUIRED if isGeneralServiceURI is true and the engine is mysql', () => { + const mockDb = { + ...databaseWithNoVPC, + engine: 'mysql' as Engine, + }; + renderWithTheme(); + + const revealPasswordBtn = screen.getByRole('button', { + name: '{click to reveal password}', }); + const serviceURIText = screen.getByTestId('service-uri').textContent; + + expect(revealPasswordBtn).toBeInTheDocument(); + expect(serviceURIText).toBe( + `mysql://{click to reveal password}@${DEFAULT_PRIMARY}:3306/defaultdb?ssl-mode=REQUIRED` + ); + }); + + it('should reveal general service URI password after clicking reveal button', async () => { renderWithTheme( ); @@ -241,15 +252,11 @@ describe('ServiceURI', () => { const serviceURIText = screen.getByTestId('service-uri').textContent; expect(revealPasswordBtn).not.toBeInTheDocument(); expect(serviceURIText).toBe( - `postgres://password123@${DEFAULT_PRIMARY}:3306/defaultdb?sslmode=require` + `postgres://lnroot: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', { @@ -264,10 +271,6 @@ describe('ServiceURI', () => { }); it('should render private general service URI component if there is a private-only VPC', async () => { - queryMocks.useDatabaseCredentialsQuery.mockReturnValue({ - data: mockCredentials, - }); - renderWithTheme( ); @@ -284,10 +287,6 @@ describe('ServiceURI', () => { }); 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', { @@ -302,10 +301,6 @@ describe('ServiceURI', () => { }); 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( ); @@ -322,10 +317,6 @@ describe('ServiceURI', () => { }); 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( { `postgres://{click to reveal password}@${PRIVATE_PRIMARY}:3306/defaultdb?sslmode=require` ); }); + + it('should render private service URI placeholder text if there is a VPC with public access, isGeneralServiceURI and showPrivateVPC is true, but hosts are not yet available', () => { + const mockDb = { + ...databaseWithPublicVPC, + hosts: null, + }; + + renderWithTheme( + + ); + + const serviceURIText = screen.getByTestId('service-uri').textContent; + expect(serviceURIText).toBe( + 'Your Service URI will appear here once it is available.' + ); + }); + + it('should disable the reveal password and copy icon if the Database is suspended', async () => { + const mockDatabase = { + ...databaseWithNoVPC, + status: 'suspended' as DatabaseStatus, + }; + + const { container } = renderWithTheme( + + ); + + const revealPasswordBtn = screen.getByRole('button', { + name: '{click to reveal password}', + }); + // eslint-disable-next-line testing-library/no-container + const copyButton = container.querySelector('[data-qa-copy-btn]'); + expect(revealPasswordBtn).toBeDisabled(); + expect(copyButton).toBeDisabled(); + }); }); diff --git a/packages/manager/src/features/Databases/DatabaseDetail/ServiceURI.tsx b/packages/manager/src/features/Databases/DatabaseDetail/ServiceURI.tsx index 492990975fd..9de14c9889d 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, TooltipIcon } from '@linode/ui'; +import { Button, TooltipIcon, Typography } from '@linode/ui'; import { Grid, styled } from '@mui/material'; import copy from 'copy-to-clipboard'; import { enqueueSnackbar } from 'notistack'; @@ -10,6 +10,7 @@ import { CopyTooltip } from 'src/components/CopyTooltip/CopyTooltip'; import { CLUSTER_PROVISIONING_TEXT, CREDENTIALS_ERROR_TEXT, + DISABLE_CREDENTIAL_STATES, DISABLED_PASSWORD_BUTTON_TEXT, } from 'src/features/Databases/constants'; import { StyledValueGrid } from 'src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryClusterConfiguration.style'; @@ -33,6 +34,8 @@ export const ServiceURI = (props: ServiceURIProps) => { const [isCopying, setIsCopying] = useState(false); const engine = database.engine === 'postgresql' ? 'postgres' : database.engine; + const generalSslmode = + engine === 'mysql' ? 'ssl-mode=REQUIRED' : 'sslmode=require'; const { data: credentials, @@ -86,24 +89,19 @@ export const ServiceURI = (props: ServiceURIProps) => { isGeneralServiceURI?: boolean ) => { if (isGeneralServiceURI) { - return `${engine}://${credentials?.password}@${primaryHost?.address}:${primaryHost?.port}/defaultdb?sslmode=require`; + return `${engine}://${credentials?.username}:${credentials?.password}@${primaryHost?.address}:${primaryHost?.port}/defaultdb?${generalSslmode}`; } return `postgres://${credentials?.username}:${credentials?.password}@${primaryConnectionPoolHost?.address}:${primaryConnectionPoolHost?.port}/{connection pool label}?sslmode=require`; }; - const getCredentials = (isGeneralServiceURI: boolean) => { - return !isGeneralServiceURI - ? `${credentials?.username}:${credentials?.password}` - : credentials?.password; - }; - // hide loading state if the user clicks on the copy icon const showBtnLoading = !hidePassword && !isCopying && (credentialsLoading || credentialsFetching); - const disablePasswordBtn = ['failed', 'provisioning', 'suspended'].includes( + const disablePasswordBtn = DISABLE_CREDENTIAL_STATES.includes( database.status ); + const disabledPasswordTooltipText = database.status === 'provisioning' ? CLUSTER_PROVISIONING_TEXT @@ -139,9 +137,33 @@ export const ServiceURI = (props: ServiceURIProps) => { ); } - return getCredentials(isGeneralServiceURI); + return `${credentials?.username}:${credentials?.password}`; }; + if ( + (isGeneralServiceURI && !primaryHost) || + (engine === 'postgres' && !primaryConnectionPoolHost) + ) { + return ( + + + + Your Service URI will appear here once it is available. + + + + ); + } + return ( { {isGeneralServiceURI ? ( <> @{primaryHost?.address}: - {`${primaryHost?.port}/defaultdb?sslmode=require`} + {`${primaryHost?.port}/defaultdb?${generalSslmode}`} ) : ( <> @@ -177,6 +199,8 @@ export const ServiceURI = (props: ServiceURIProps) => { ) : ( diff --git a/packages/manager/src/features/Databases/constants.ts b/packages/manager/src/features/Databases/constants.ts index ff43995a6af..bfb3e3b28dd 100644 --- a/packages/manager/src/features/Databases/constants.ts +++ b/packages/manager/src/features/Databases/constants.ts @@ -66,7 +66,7 @@ export const CREDENTIALS_ERROR_TEXT = 'There was an error retrieving cluster credentials. Please try again.'; export const DISABLED_PASSWORD_BUTTON_TEXT = - 'Your root password is unavailable when your Database Cluster is in a failed or suspended state.'; + 'Your root password is unavailable when your Database Cluster is in a suspended or resuming state.'; export const CLUSTER_PROVISIONING_TEXT = 'Your Database Cluster is currently provisioning.'; @@ -103,3 +103,9 @@ export const usernameOptions = [ ]; // Currently the only options for the username field export const DEFAULT_PAGE_SIZES = [25, 50, 75, 100]; +export const DISABLE_CREDENTIAL_STATES = [ + 'provisioning', + 'resuming', + 'suspending', + 'suspended', +]; 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 0082a289411..4df080726c4 100644 --- a/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationCreate.test.tsx +++ b/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationCreate.test.tsx @@ -454,8 +454,9 @@ describe('DestinationCreate', () => { it('should render Authentication autocomplete with None selected and allow to select Basic', async () => { await selectCustomHttpsDestinationType(); - const authenticationAutocomplete = - screen.getByLabelText('Authentication'); + const authenticationAutocomplete = screen.getByLabelText( + 'Authentication Type' + ); expect(authenticationAutocomplete).toHaveValue('None'); @@ -470,8 +471,9 @@ describe('DestinationCreate', () => { it('should render Username input and allow to type text', async () => { await selectCustomHttpsDestinationType(); - const authenticationAutocomplete = - screen.getByLabelText('Authentication'); + const authenticationAutocomplete = screen.getByLabelText( + 'Authentication Type' + ); await userEvent.click(authenticationAutocomplete); const basicAuthentication = await screen.findByText('Basic'); await userEvent.click(basicAuthentication); @@ -485,8 +487,9 @@ describe('DestinationCreate', () => { it('should render Password input and allow to type text', async () => { await selectCustomHttpsDestinationType(); - const authenticationAutocomplete = - screen.getByLabelText('Authentication'); + const authenticationAutocomplete = screen.getByLabelText( + 'Authentication Type' + ); await userEvent.click(authenticationAutocomplete); const basicAuthentication = await screen.findByText('Basic'); await userEvent.click(basicAuthentication); @@ -507,7 +510,7 @@ describe('DestinationCreate', () => { expect(endpointUrlInput).toHaveValue('https://test-endpoint.com'); }); - describe('Client Certificate fields', () => { + describe('Client Certificate Authentication fields', () => { it('should render TLS Hostname input and allow to type text', async () => { await selectCustomHttpsDestinationType(); @@ -539,10 +542,10 @@ describe('DestinationCreate', () => { expect(clientCertificateInput).toHaveValue('test-client-certificate'); }); - it('should render Client Key input and allow to type text', async () => { + it('should render Client Private Key input and allow to type text', async () => { await selectCustomHttpsDestinationType(); - const clientKeyInput = screen.getByLabelText('Client Key'); + const clientKeyInput = screen.getByLabelText('Client Private Key'); await userEvent.type(clientKeyInput, 'test-client-key'); expect(clientKeyInput).toHaveValue('test-client-key'); diff --git a/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationForm.tsx b/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationForm.tsx index b7097f784d6..9fe4652b943 100644 --- a/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationForm.tsx +++ b/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationForm.tsx @@ -15,6 +15,7 @@ import { Controller, useWatch } from 'react-hook-form'; import { getDestinationTypeOption, + isFormInEditMode, useIsACLPLogsEnabled, } from 'src/features/Delivery/deliveryUtils'; import { DestinationAkamaiObjectStorageDetailsForm } from 'src/features/Delivery/Shared/DestinationAkamaiObjectStorageDetailsForm'; @@ -106,7 +107,9 @@ export const DestinationForm = (props: DestinationFormProps) => { render={({ field }) => ( { diff --git a/packages/manager/src/features/Delivery/Destinations/DestinationsLanding.tsx b/packages/manager/src/features/Delivery/Destinations/DestinationsLanding.tsx index 8158e83d221..4576a0a2207 100644 --- a/packages/manager/src/features/Delivery/Destinations/DestinationsLanding.tsx +++ b/packages/manager/src/features/Delivery/Destinations/DestinationsLanding.tsx @@ -63,6 +63,7 @@ export const DestinationsLanding = () => { const { data: destinations, isLoading, + isFetching, error, } = useDestinationsQuery( { @@ -93,6 +94,10 @@ export const DestinationsLanding = () => { ); } + if (isLoading) { + return ; + } + if (destinations?.results === 0 && !search?.label) { return ( @@ -125,7 +130,7 @@ export const DestinationsLanding = () => { onSearch={onSearch} searchValue={search?.label ?? ''} /> - {isLoading ? ( + {isFetching ? ( ) : ( <> diff --git a/packages/manager/src/features/Delivery/Destinations/constants.ts b/packages/manager/src/features/Delivery/Destinations/constants.ts index 69d0814b7b0..b3da8331755 100644 --- a/packages/manager/src/features/Delivery/Destinations/constants.ts +++ b/packages/manager/src/features/Delivery/Destinations/constants.ts @@ -1,3 +1,5 @@ export const DESTINATIONS_TABLE_DEFAULT_ORDER = 'desc'; export const DESTINATIONS_TABLE_DEFAULT_ORDER_BY = 'created'; export const DESTINATIONS_TABLE_PREFERENCE_KEY = 'destinations'; + +export const MASKED_VALUE = '*****************'; diff --git a/packages/manager/src/features/Delivery/Shared/CustomHeaders.tsx b/packages/manager/src/features/Delivery/Shared/CustomHeaders.tsx index 919256e4252..c478bb9eeed 100644 --- a/packages/manager/src/features/Delivery/Shared/CustomHeaders.tsx +++ b/packages/manager/src/features/Delivery/Shared/CustomHeaders.tsx @@ -4,6 +4,7 @@ import { LinkButton, Stack, TextField, + TooltipIcon, Typography, } from '@linode/ui'; import Grid from '@mui/material/Grid'; @@ -21,10 +22,11 @@ interface CustomHeaderTitleProps { control: Control; controlPath: string; index: number; + tooltipText: string; } const CustomHeaderTitle = (props: CustomHeaderTitleProps) => { - const { control, controlPath, index } = props; + const { control, controlPath, index, tooltipText } = props; const headerName = useWatch({ control, @@ -32,9 +34,27 @@ const CustomHeaderTitle = (props: CustomHeaderTitleProps) => { }); return ( - - {headerName?.length ? headerName : `Custom Header ${index + 1}`} - + + + {headerName?.length ? headerName : `Custom Header ${index + 1}`} + + + ); }; @@ -91,6 +111,7 @@ export const CustomHeaders = (props: CustomHeadersProps) => { control={control} controlPath={controlPath} index={index} + tooltipText="A custom HTTPS header to include in the delivery request." /> removeField(index)} sx={{ p: 0 }}> @@ -105,6 +126,7 @@ export const CustomHeaders = (props: CustomHeadersProps) => { aria-required errorText={fieldState.error?.message} label="Name" + labelTooltipText="The name of the custom header to include in the delivery request." onBlur={controllerField.onBlur} onChange={controllerField.onChange} value={controllerField.value} @@ -119,6 +141,7 @@ export const CustomHeaders = (props: CustomHeadersProps) => { aria-required errorText={fieldState.error?.message} label="Value" + labelTooltipText="The value of the custom header to include in the delivery request." multiline onBlur={controllerField.onBlur} onChange={controllerField.onChange} diff --git a/packages/manager/src/features/Delivery/Shared/DestinationCustomHttpsDetailsForm.tsx b/packages/manager/src/features/Delivery/Shared/DestinationCustomHttpsDetailsForm.tsx index 68f26825c52..a98c5bee670 100644 --- a/packages/manager/src/features/Delivery/Shared/DestinationCustomHttpsDetailsForm.tsx +++ b/packages/manager/src/features/Delivery/Shared/DestinationCustomHttpsDetailsForm.tsx @@ -1,5 +1,12 @@ import { authenticationType } from '@linode/api-v4'; -import { Autocomplete, Divider, TextField, Typography } from '@linode/ui'; +import { + Autocomplete, + Divider, + Stack, + TextField, + TooltipIcon, + Typography, +} from '@linode/ui'; import { useTheme } from '@mui/material/styles'; import React from 'react'; import { Controller, useFormContext, useWatch } from 'react-hook-form'; @@ -58,7 +65,7 @@ export const DestinationCustomHttpsDetailsForm = ( { if (value === authenticationType.None) { @@ -67,6 +74,10 @@ export const DestinationCustomHttpsDetailsForm = ( field.onChange(value); }} options={authenticationTypeOptions} + textFieldProps={{ + labelTooltipText: + 'The authentication method used for requests sent to your HTTPS endpoint.', + }} value={getAuthenticationTypeOption(field.value)} /> )} @@ -113,6 +124,7 @@ export const DestinationCustomHttpsDetailsForm = ( aria-required errorText={fieldState.error?.message} label="Endpoint URL" + labelTooltipText="The HTTPS endpoint for audit log delivery." onBlur={field.onBlur} onChange={(value) => { field.onChange(value); @@ -123,16 +135,24 @@ export const DestinationCustomHttpsDetailsForm = ( /> - Additional Options - - - Client Certificate  - - (optional) - + Connection Settings + + + Client Certificate Authentication  + + (optional) + + + + { @@ -156,6 +177,7 @@ export const DestinationCustomHttpsDetailsForm = ( { @@ -172,6 +194,7 @@ export const DestinationCustomHttpsDetailsForm = ( { @@ -187,7 +210,8 @@ export const DestinationCustomHttpsDetailsForm = ( render={({ field, fieldState }) => ( { @@ -217,6 +241,10 @@ export const DestinationCustomHttpsDetailsForm = ( field.onChange(value?.value || null); }} options={contentTypeOptions} + textFieldProps={{ + labelTooltipText: + 'The format and character encoding of the delivered audit log data.', + }} value={field.value ? getContentTypeOption(field.value) : null} /> )} diff --git a/packages/manager/src/features/Delivery/Shared/LabelValue.tsx b/packages/manager/src/features/Delivery/Shared/LabelValue.tsx index c9b6be97412..f1cbda2a65f 100644 --- a/packages/manager/src/features/Delivery/Shared/LabelValue.tsx +++ b/packages/manager/src/features/Delivery/Shared/LabelValue.tsx @@ -3,17 +3,28 @@ import { styled, useTheme } from '@mui/material/styles'; import * as React from 'react'; import { useEffect, useRef, useState } from 'react'; +import { CopyTooltip } from 'src/components/CopyTooltip/CopyTooltip'; + const maxWidth = 416; const labelWidth = 160; const valueWidth = maxWidth - labelWidth; interface LabelValueProps { + copyable?: boolean; 'data-testid'?: string; + disableValueTooltip?: boolean; label: string; value: string; } + export const LabelValue = (props: LabelValueProps) => { - const { label, value, 'data-testid': dataTestId } = props; + const { + copyable, + 'data-testid': dataTestId, + disableValueTooltip, + label, + value, + } = props; const theme = useTheme(); const labelRef = useRef(null); const [isLabelOverflowing, setIsLabelOverflowing] = useState(false); @@ -41,68 +52,93 @@ export const LabelValue = (props: LabelValueProps) => { return ( - - - + + - {label} - - - : - - - - - + + {label} + + + :  + + + + + - - {value} - - + + {value} + + +
+ {copyable && } ); }; -const StyledValue = styled(Tooltip, { - label: 'StyledValue', -})(({ theme }) => ({ - backgroundColor: theme.tokens.alias.Interaction.Background.Disabled, - border: `1px solid ${theme.tokens.alias.Border.Neutral}`, - borderRadius: 4, - height: theme.spacingFunction(24), - lineHeight: theme.spacingFunction(24), - maxWidth: valueWidth, - padding: theme.spacingFunction(1, 8), -})); - -const StyledLabel = styled(Tooltip, { - label: 'StyledLabel', +const StyledCopyTooltip = styled(CopyTooltip, { + label: 'StyledCopyTooltip', })(({ theme }) => ({ - height: theme.spacingFunction(24), - lineHeight: theme.spacingFunction(24), - maxWidth: labelWidth, + '& svg': { + height: theme.spacingFunction(16), + width: theme.spacingFunction(16), + }, + '&:hover': { + backgroundColor: 'transparent', + }, + display: 'inline-flex', + marginLeft: theme.spacingFunction(12), })); 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 322bfde465c..ef5b7ace07a 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 @@ -200,6 +200,7 @@ describe('StreamFormClusters', () => { await renderComponentWithoutSelectedClusters(); const input = screen.getByPlaceholderText('Log Generation'); + // Enabled filter option await userEvent.click(input); await userEvent.type(input, 'enabled'); @@ -209,6 +210,18 @@ describe('StreamFormClusters', () => { await waitFor(() => expect(getColumnsValuesFromTable(3)).toEqual(['Enabled', 'Enabled']) ); + + // Disabled filter option + await userEvent.clear(input); + await userEvent.click(input); + await userEvent.type(input, 'disabled'); + + const disabledOption = screen.getAllByText('Disabled')[0]; + await userEvent.click(disabledOption); + + await waitFor(() => + expect(getColumnsValuesFromTable(3)).toEqual(['Disabled']) + ); }); it('should toggle clusters checkboxes and header checkbox', async () => { 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 1ff4c4b448a..808f8c9d9a4 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClusters.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClusters.tsx @@ -29,10 +29,12 @@ import { Table } from 'src/components/Table'; import { StreamFormClusterTableContent } from 'src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClustersTableContent'; import { useAllKubernetesClustersQuery } from 'src/queries/kubernetes'; -import type { KubernetesCluster } from '@linode/api-v4'; import type { FormMode } from 'src/features/Delivery/Shared/types'; import type { OrderByKeys } from 'src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClustersTableContent'; -import type { StreamAndDestinationFormType } from 'src/features/Delivery/Streams/StreamForm/types'; +import type { + ExtendedKubernetesCluster, + StreamAndDestinationFormType, +} from 'src/features/Delivery/Streams/StreamForm/types'; const controlPaths = { isAutoAddAllClustersEnabled: @@ -80,7 +82,7 @@ export const StreamFormClusters = (props: StreamFormClustersProps) => { [regions] ); - const eligibleClusters = useMemo(() => { + const eligibleClusters: ExtendedKubernetesCluster[] = useMemo(() => { const regionMap = new Map( eligibleRegions.map(({ id, label }) => [id, label]) ); @@ -89,7 +91,7 @@ export const StreamFormClusters = (props: StreamFormClustersProps) => { .filter(({ region }) => regionMap.has(region)) .map((cluster) => ({ ...cluster, - region: regionMap.get(cluster.region) + regionLabel: regionMap.get(cluster.region) ? `${regionMap.get(cluster.region)} (${cluster.region})` : cluster.region, })); @@ -188,7 +190,7 @@ export const StreamFormClusters = (props: StreamFormClustersProps) => { result = cluster.region === regionFilter; } - if (result && logGenerationFilter) { + if (result && logGenerationFilter !== undefined) { result = cluster.control_plane.audit_logs_enabled === logGenerationFilter; } @@ -198,7 +200,7 @@ export const StreamFormClusters = (props: StreamFormClustersProps) => { }, [searchText, regionFilter, logGenerationFilter, eligibleClusters]); const sortedAndFilteredClusters = useMemo( - () => sortData(orderBy, order)(filteredClusters), + () => sortData(orderBy, order)(filteredClusters), [orderBy, order, filteredClusters] ); diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClustersTableContent.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClustersTableContent.tsx index 5fad7cd8d3d..5fdfacccc4f 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClustersTableContent.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClustersTableContent.tsx @@ -10,13 +10,15 @@ import { TableRow } from 'src/components/TableRow'; import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; import { TableSortCell } from 'src/components/TableSortCell'; -import type { KubernetesCluster } from '@linode/api-v4'; -import type { StreamAndDestinationFormType } from 'src/features/Delivery/Streams/StreamForm/types'; +import type { + ExtendedKubernetesCluster, + StreamAndDestinationFormType, +} from 'src/features/Delivery/Streams/StreamForm/types'; export type OrderByKeys = 'label' | 'region'; interface StreamFormClusterTableContentProps { - clusters: KubernetesCluster[] | undefined; + clusters: ExtendedKubernetesCluster[] | undefined; field: ControllerRenderProps< StreamAndDestinationFormType, 'stream.details.cluster_ids' @@ -101,7 +103,7 @@ export const StreamFormClusterTableContent = ({ clusters.map( ({ label, - region, + regionLabel, id, control_plane: { audit_logs_enabled: logsEnabled }, }) => ( @@ -115,7 +117,7 @@ export const StreamFormClusterTableContent = ({ /> {label} - {region} + {regionLabel} diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/DestinationAkamaiObjectStorageDetailsSummary.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/DestinationAkamaiObjectStorageDetailsSummary.tsx index 532310a2891..bd5530c5b73 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/DestinationAkamaiObjectStorageDetailsSummary.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/DestinationAkamaiObjectStorageDetailsSummary.tsx @@ -1,5 +1,6 @@ import React from 'react'; +import { MASKED_VALUE } from 'src/features/Delivery/Destinations/constants'; import { LabelValue } from 'src/features/Delivery/Shared/LabelValue'; import type { AkamaiObjectStorageDetails } from '@linode/api-v4'; @@ -16,12 +17,12 @@ export const DestinationAkamaiObjectStorageDetailsSummary = ( {!!path && } diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/DestinationCustomHTTPSDetailsSummary.test.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/DestinationCustomHTTPSDetailsSummary.test.tsx index 7b33c465185..e580a3966d7 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/DestinationCustomHTTPSDetailsSummary.test.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/DestinationCustomHTTPSDetailsSummary.test.tsx @@ -29,7 +29,9 @@ describe('DestinationCustomHTTPSDetailsSummary', () => { // Endpoint URL: expect(screen.getByText('https://example.com/')).toBeVisible(); // Username: - expect(screen.getByText('testuser')).toBeVisible(); + expect(screen.getByTestId('username')).toHaveTextContent( + '*****************' + ); // Password: expect(screen.getByTestId('password')).toHaveTextContent( '*****************' @@ -57,7 +59,7 @@ describe('DestinationCustomHTTPSDetailsSummary', () => { expect(screen.queryByTestId('password')).not.toBeInTheDocument(); }); - it('renders client certificate details when provided', () => { + it('renders Client Certificate Authentication details when provided', () => { const details: CustomHTTPSDetails = { authentication: { type: 'none' }, endpoint_url: 'https://example.com/', @@ -72,7 +74,7 @@ describe('DestinationCustomHTTPSDetailsSummary', () => { renderWithTheme(); - expect(screen.getByText('Additional Options')).toBeVisible(); + expect(screen.getByText('Connection Settings')).toBeVisible(); expect(screen.queryByTestId('client-certificate-header')).toBeVisible(); // TLS Hostname: expect(screen.getByText('tls.example.com')).toBeVisible(); @@ -81,7 +83,9 @@ describe('DestinationCustomHTTPSDetailsSummary', () => { // Client Certificate: expect(screen.getByText('client-cert-content')).toBeVisible(); // Client Key: - expect(screen.getByText('private-key-content')).toBeVisible(); + expect(screen.getByTestId('client-key')).toHaveTextContent( + '*****************' + ); }); it('renders content type when provided', () => { @@ -120,7 +124,7 @@ describe('DestinationCustomHTTPSDetailsSummary', () => { expect(screen.getByText('Bearer token123')).toBeVisible(); }); - it('does not render Additional Options section when no optional fields provided', () => { + it('does not render Connection Settings section when no optional fields provided', () => { const details: CustomHTTPSDetails = { authentication: { type: 'none' }, endpoint_url: 'https://example.com/', @@ -129,7 +133,7 @@ describe('DestinationCustomHTTPSDetailsSummary', () => { renderWithTheme(); - expect(screen.queryByText('Additional Options')).not.toBeInTheDocument(); + expect(screen.queryByText('Connection Settings')).not.toBeInTheDocument(); expect(screen.queryByText('HTTPS Headers')).not.toBeInTheDocument(); }); }); diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/DestinationCustomHTTPSDetailsSummary.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/DestinationCustomHTTPSDetailsSummary.tsx index 87600df00dc..c0219401036 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/DestinationCustomHTTPSDetailsSummary.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/DestinationCustomHTTPSDetailsSummary.tsx @@ -1,6 +1,7 @@ import { Divider, Typography } from '@linode/ui'; import React from 'react'; +import { MASKED_VALUE } from 'src/features/Delivery/Destinations/constants'; import { LabelValue } from 'src/features/Delivery/Shared/LabelValue'; import type { CustomHTTPSDetails } from '@linode/api-v4'; @@ -18,18 +19,19 @@ export const DestinationCustomHTTPSDetailsSummary = ( return ( <> - + {authentication.type === 'basic' && ( <> )} @@ -37,7 +39,7 @@ export const DestinationCustomHTTPSDetailsSummary = ( {(!!client_certificate_details || !!content_type || !!custom_headers) && ( <> - Additional Options + Connection Settings {!!client_certificate_details && ( <> @@ -46,23 +48,28 @@ export const DestinationCustomHTTPSDetailsSummary = ( sx={{ mt: 2 }} variant="h3" > - Client Certificate + Client Certificate Authentication )} 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 ed85f82349a..232825b95f1 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 @@ -537,8 +537,9 @@ describe('StreamFormDelivery', () => { flags ); - const authenticationAutocomplete = - screen.getByLabelText('Authentication'); + const authenticationAutocomplete = screen.getByLabelText( + 'Authentication Type' + ); expect(authenticationAutocomplete).toHaveValue('None'); @@ -560,8 +561,9 @@ describe('StreamFormDelivery', () => { ); // Select the "Basic" Authentication option - const authenticationAutocomplete = - screen.getByLabelText('Authentication'); + const authenticationAutocomplete = screen.getByLabelText( + 'Authentication Type' + ); await userEvent.click(authenticationAutocomplete); const basicAuthentication = await screen.findByText('Basic'); await userEvent.click(basicAuthentication); @@ -582,8 +584,9 @@ describe('StreamFormDelivery', () => { ); // Select the "Basic" Authentication option - const authenticationAutocomplete = - screen.getByLabelText('Authentication'); + const authenticationAutocomplete = screen.getByLabelText( + 'Authentication Type' + ); await userEvent.click(authenticationAutocomplete); const basicAuthentication = await screen.findByText('Basic'); await userEvent.click(basicAuthentication); @@ -611,7 +614,7 @@ describe('StreamFormDelivery', () => { expect(endpointUrlInput.getAttribute('value')).toEqual('Test'); }); - describe('Client Certificate fields', () => { + describe('Client Certificate Authentication fields', () => { it('should render TLS Hostname input and allow to type text', async () => { await renderComponentAndAddNewDestinationName( destinationType.CustomHttps, @@ -649,13 +652,13 @@ describe('StreamFormDelivery', () => { expect(clientCertificateInput).toHaveValue('test'); }); - it('should render Client Key input and allow to type text', async () => { + it('should render Client Private Key input and allow to type text', async () => { await renderComponentAndAddNewDestinationName( destinationType.CustomHttps, flags ); - const clientKeyInput = screen.getByLabelText('Client Key'); + const clientKeyInput = screen.getByLabelText('Client Private Key'); await userEvent.type(clientKeyInput, 'test'); expect(clientKeyInput).toHaveValue('test'); 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 3fb88ce8308..0b99c5b24e4 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/StreamFormDelivery.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/StreamFormDelivery.tsx @@ -335,8 +335,15 @@ export const StreamFormDelivery = (props: StreamFormDeliveryProps) => { return ( Delivery - - Set the destination for log delivery. + + Choose the destination where logs will be delivered. Select a + preconfigured destination or create a new one. {isLoading && ( 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 e9a13d58abe..84e1926b7c2 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamEdit.test.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamEdit.test.tsx @@ -11,6 +11,7 @@ import { akamaiObjectStorageDestinationFactory, streamFactory, } from 'src/factories'; +import { MASKED_VALUE } from 'src/features/Delivery/Destinations/constants'; import { StreamEdit } from 'src/features/Delivery/Streams/StreamForm/StreamEdit'; import { makeResourcePage } from 'src/mocks/serverHandlers'; import { http, HttpResponse, server } from 'src/mocks/testServer'; @@ -76,12 +77,10 @@ describe.skip('StreamEdit', () => { // Bucket: expect(screen.getByText('destinations-bucket-name')).toBeVisible(); // Access Key ID: - expect(screen.getByTestId('access-key-id')).toHaveTextContent( - '*****************' - ); + expect(screen.getByTestId('access-key-id')).toHaveTextContent(MASKED_VALUE); // Secret Access Key: expect(screen.getByTestId('secret-access-key')).toHaveTextContent( - '*****************' + MASKED_VALUE ); // Log Path: expect(screen.getByText('file')).toBeVisible(); diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/types.ts b/packages/manager/src/features/Delivery/Streams/StreamForm/types.ts index 232d79b61f4..09c1a6c23d0 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/types.ts +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/types.ts @@ -1,4 +1,8 @@ -import type { CreateStreamPayload, StreamDetailsType } from '@linode/api-v4'; +import type { + CreateStreamPayload, + KubernetesCluster, + StreamDetailsType, +} from '@linode/api-v4'; import type { DestinationFormType } from 'src/features/Delivery/Shared/types'; export interface StreamFromType extends Omit { @@ -9,3 +13,7 @@ export interface StreamAndDestinationFormType { destination: DestinationFormType; stream: StreamFromType; } + +export interface ExtendedKubernetesCluster extends KubernetesCluster { + regionLabel: string; +} diff --git a/packages/manager/src/features/Delivery/Streams/StreamsLanding.tsx b/packages/manager/src/features/Delivery/Streams/StreamsLanding.tsx index cfa7d246829..308e96def73 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamsLanding.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamsLanding.tsx @@ -10,6 +10,7 @@ import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFoot import { Table } from 'src/components/Table'; import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; import { TableSortCell } from 'src/components/TableSortCell'; +import { getStreamPayloadDetails } from 'src/features/Delivery/deliveryUtils'; import { DeliveryTabHeader } from 'src/features/Delivery/Shared/DeliveryTabHeader/DeliveryTabHeader'; import { streamStatusOptions } from 'src/features/Delivery/Shared/types'; import { @@ -74,6 +75,7 @@ export const StreamsLanding = () => { data: streams, isLoading, error, + isFetching, } = useStreamsQuery( { page: pagination.page, @@ -114,6 +116,10 @@ export const StreamsLanding = () => { ); } + if (isLoading) { + return ; + } + if (streams?.results === 0 && !search?.status && !search?.label) { return ; } @@ -137,11 +143,12 @@ export const StreamsLanding = () => { details, label, status, + type, }: Stream) => { updateStream({ id, destinations: destinations.map(({ id: destinationId }) => destinationId), - details, + details: getStreamPayloadDetails(type, details), label, status: status === streamStatus.Active @@ -186,7 +193,7 @@ export const StreamsLanding = () => { selectList={streamStatusOptions} selectValue={search?.status} /> - {isLoading ? ( + {isFetching ? ( ) : ( <> diff --git a/packages/manager/src/features/Delivery/deliveryUtils.test.ts b/packages/manager/src/features/Delivery/deliveryUtils.test.ts index 8345d438544..58648e0b6c5 100644 --- a/packages/manager/src/features/Delivery/deliveryUtils.test.ts +++ b/packages/manager/src/features/Delivery/deliveryUtils.test.ts @@ -206,7 +206,7 @@ describe('delivery utils functions', () => { expect(result.client_certificate_details).toBeUndefined(); }); - it('should omit client_certificate_details when any of its properties is empty', () => { + it('should omit client_certificate_details when any of [client_certificate, client_ca_certificate, client_private_key] properties is empty', () => { const details: CustomHTTPSDetailsExtended = { ...baseCustomHTTPSDetails, client_certificate_details: { @@ -247,6 +247,27 @@ describe('delivery utils functions', () => { ); }); + it('should keep client_certificate_details when all of of [client_certificate, client_ca_certificate, client_private_key] properties have values but tls_hostname is empty', () => { + const details: CustomHTTPSDetailsExtended = { + ...baseCustomHTTPSDetails, + client_certificate_details: { + client_ca_certificate: 'ca-cert', + client_certificate: 'cert', + client_private_key: 'key', + }, + }; + + const result = getDestinationPayloadDetails( + details, + destinationType.CustomHttps + ) as CustomHTTPSDetailsExtended; + + expect(result.client_certificate_details).toBeDefined(); + expect(result.client_certificate_details).toEqual( + details.client_certificate_details + ); + }); + it('should omit both content_type and client_certificate_details when both are empty', () => { const details: CustomHTTPSDetailsExtended = { ...baseCustomHTTPSDetails, @@ -267,6 +288,56 @@ describe('delivery utils functions', () => { expect(result.content_type).toBeUndefined(); expect(result.client_certificate_details).toBeUndefined(); }); + + it('should omit tls_hostname from client_certificate_details when it is an empty string', () => { + const details: CustomHTTPSDetailsExtended = { + ...baseCustomHTTPSDetails, + client_certificate_details: { + client_ca_certificate: 'ca-cert', + client_certificate: 'cert', + client_private_key: 'key', + tls_hostname: '', + }, + }; + + const result = getDestinationPayloadDetails( + details, + destinationType.CustomHttps + ) as CustomHTTPSDetailsExtended; + + expect(result.client_certificate_details).toBeDefined(); + expect(result.client_certificate_details?.tls_hostname).toBeUndefined(); + expect(result.client_certificate_details).toEqual({ + client_ca_certificate: 'ca-cert', + client_certificate: 'cert', + client_private_key: 'key', + }); + }); + + it('should omit tls_hostname from client_certificate_details when it is whitespace', () => { + const details: CustomHTTPSDetailsExtended = { + ...baseCustomHTTPSDetails, + client_certificate_details: { + client_ca_certificate: 'ca-cert', + client_certificate: 'cert', + client_private_key: 'key', + tls_hostname: ' ', + }, + }; + + const result = getDestinationPayloadDetails( + details, + destinationType.CustomHttps + ) as CustomHTTPSDetailsExtended; + + expect(result.client_certificate_details).toBeDefined(); + expect(result.client_certificate_details?.tls_hostname).toBeUndefined(); + expect(result.client_certificate_details).toEqual({ + client_ca_certificate: 'ca-cert', + client_certificate: 'cert', + client_private_key: 'key', + }); + }); }); }); }); diff --git a/packages/manager/src/features/Delivery/deliveryUtils.ts b/packages/manager/src/features/Delivery/deliveryUtils.ts index 8f411c60119..91c0be442dd 100644 --- a/packages/manager/src/features/Delivery/deliveryUtils.ts +++ b/packages/manager/src/features/Delivery/deliveryUtils.ts @@ -110,6 +110,7 @@ export const getDestinationPayloadDetails = ( if (type === destinationType.CustomHttps) { const propsToRemove: any[] = []; const customHTTPSDetails = details as CustomHTTPSDetailsExtended; + let finalCustomHTTPSDetails = customHTTPSDetails; if (!customHTTPSDetails.content_type) { propsToRemove.push('content_type'); @@ -121,20 +122,26 @@ export const getDestinationPayloadDetails = ( certDetails.client_ca_certificate, certDetails.client_certificate, certDetails.client_private_key, - certDetails.tls_hostname, ].some((val) => !val); if (shouldRemoveCertDetails) { propsToRemove.push('client_certificate_details'); + } else if (!certDetails.tls_hostname?.trim()) { + finalCustomHTTPSDetails = { + ...customHTTPSDetails, + client_certificate_details: omitProps(certDetails, ['tls_hostname']), + }; } } if (propsToRemove.length > 0) { return omitProps( - customHTTPSDetails, + finalCustomHTTPSDetails, propsToRemove ) as CustomHTTPSDetailsExtended; } + + return finalCustomHTTPSDetails; } else if ('path' in details && details.path === '') { return omitProps(details, ['path']); } diff --git a/packages/manager/src/features/IAM/Delegations/AccountDelegations.tsx b/packages/manager/src/features/IAM/Delegations/AccountDelegations.tsx index ca54e98572f..b92ed137081 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, Notice, Paper, Stack } from '@linode/ui'; +import { Notice, Paper, Stack } from '@linode/ui'; import { useMediaQuery, useTheme } from '@mui/material'; import { useNavigate, useSearch } from '@tanstack/react-router'; import React from 'react'; @@ -83,10 +83,6 @@ export const AccountDelegations = () => { }); }; - if (isLoading || isPermissionsLoading) { - return ; - } - if (!permissions?.list_all_child_accounts) { return ( @@ -127,7 +123,7 @@ export const AccountDelegations = () => { delegations={childAccountsWithDelegates?.data ?? []} error={error} handleOrderChange={handleOrderChange} - isLoading={isLoading} + isLoading={isLoading || isPermissionsLoading} numCols={numCols} order={order} orderBy={orderBy} diff --git a/packages/manager/src/features/IAM/Delegations/AccountDelegationsTable.tsx b/packages/manager/src/features/IAM/Delegations/AccountDelegationsTable.tsx index 8fb001661c1..3c98de921df 100644 --- a/packages/manager/src/features/IAM/Delegations/AccountDelegationsTable.tsx +++ b/packages/manager/src/features/IAM/Delegations/AccountDelegationsTable.tsx @@ -65,7 +65,7 @@ export const AccountDelegationsTable = ({ - {isLoading && } + {isLoading && } {error && ( )} diff --git a/packages/manager/src/features/IAM/Delegations/AccountDelegationsTableRow.tsx b/packages/manager/src/features/IAM/Delegations/AccountDelegationsTableRow.tsx index 195c2cc85c5..6d8f59e317c 100644 --- a/packages/manager/src/features/IAM/Delegations/AccountDelegationsTableRow.tsx +++ b/packages/manager/src/features/IAM/Delegations/AccountDelegationsTableRow.tsx @@ -6,6 +6,7 @@ import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow/TableRow'; import { usePermissions } from '../hooks/usePermissions'; +import { IAM_PARENT_USERS_PENDO_IDS } from '../Shared/constants'; import { TruncatedList } from '../Shared/TruncatedList'; import { UpdateDelegationsDrawer } from './UpdateDelegationsDrawer'; @@ -126,6 +127,7 @@ export const AccountDelegationsTableRow = ({ delegation, index }: Props) => { { const navigate = useNavigate(); @@ -23,6 +25,7 @@ export const DefaultRolesPanel = () => { + )} + + + {headerProps.description && ( + + {headerProps.description} + + )} + + )} + + + + + {columns.map((col, idx) => { + const cell = col.sortableProps ? ( + + handleOrderChange( + col.sortableProps?.label ?? col.name, + order === 'asc' ? 'desc' : 'asc' + ) + } + sortable + sorted={ + orderBy === col.sortableProps?.label ? order : undefined + } + style={{ ...col.style }} + > + {col.name} + + ) : ( + + {col.name} + + ); + + return col.hidden ? ( + + {cell} + + ) : ( + cell + ); + })} + + + + + {!error && shareGroups.length === 0 && ( + + + ({ + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: theme.spacingFunction(4), + p: `${theme.spacingFunction(24)} ${theme.spacingFunction(32)}`, + width: '100%', + })} + > + + {emptyMessage.main} + {!query && emptyMessage.instruction && ( + + {emptyMessage.instruction} + + )} + + + + )} + {error && query && ( + + + + + + )} + + {shareGroups.map((sharegroup) => ( + + ))} + +
+ +
+ + ); +}; 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 index 6d517924ed3..452d9e695e4 100644 --- a/packages/manager/src/features/Images/ImagesLanding/v2/ShareGroups/ShareGroupsTabs.test.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/v2/ShareGroups/ShareGroupsTabs.test.tsx @@ -3,7 +3,11 @@ import * as React from 'react'; import { renderWithTheme } from 'src/utilities/testHelpers'; -import { ShareGroupsTabs } from './ShareGroupsTabs'; +import { ShareGroupsTabs } from './ShareGroupsLanding'; + +vi.mock('./ShareGroupsView', () => ({ + ShareGroupsView: () =>
Mock Share Groups View
, +})); const queryMocks = vi.hoisted(() => ({ useNavigate: vi.fn(), diff --git a/packages/manager/src/features/Images/ImagesLanding/v2/ShareGroups/ShareGroupsView.test.tsx b/packages/manager/src/features/Images/ImagesLanding/v2/ShareGroups/ShareGroupsView.test.tsx new file mode 100644 index 00000000000..434411e78d5 --- /dev/null +++ b/packages/manager/src/features/Images/ImagesLanding/v2/ShareGroups/ShareGroupsView.test.tsx @@ -0,0 +1,377 @@ +import userEvent from '@testing-library/user-event'; +import * as React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { ShareGroupsView } from './ShareGroupsView'; + +import type { Filter } from '@linode/api-v4'; +import type { ShareGroupsType } from 'src/features/Images/utils'; + +type SearchMock = { query?: string } & Record; + +type ShareGroupsConfigMock = { + buttonProps?: null | { + buttonText: string; + disabledToolTipText: string; + navigateTo: string; + }; + columns: Array<{ name: string; sortableProps: { label: string } }>; + description: string; + docsLink: { href: string; label: string }; + emptyMessage: { instruction: string; main: string }; + eventCategory: string; + orderByDefault: string; + orderDefault: 'asc' | 'desc'; + preferenceKey: string; + title: string; +}; + +const queryMocks = vi.hoisted(() => { + const defaultOwnedConfig = { + title: 'Owned groups', + description: 'Owned groups description', + docsLink: { + href: 'https://example.com/docs', + label: 'Image sharing', + }, + columns: [{ name: 'Group', sortableProps: { label: 'label' } }], + emptyMessage: { + main: 'No Share groups to display', + instruction: 'Create your first share group', + }, + eventCategory: 'shareGroups', + orderByDefault: 'label', + orderDefault: 'asc' as const, + preferenceKey: 'owned-sharegroups', + buttonProps: { + buttonText: 'Create Share Group', + disabledToolTipText: 'You do not have permissions to create share groups', + navigateTo: '/images/share-groups/create', + }, + }; + + return { + defaultOwnedConfig, + filter: {} as Filter, + getAPIFilterFromQuery: vi.fn(), + navigate: vi.fn(), + onSearchWithText: 'new-search', + pagination: { + page: 1, + pageSize: 25, + handlePageChange: vi.fn(), + handlePageSizeChange: vi.fn(), + }, + search: {} as SearchMock, + searchParseError: undefined as undefined | { message: string }, + shareGroupsConfig: { + 'owned-groups': defaultOwnedConfig, + 'joined-groups': { + ...defaultOwnedConfig, + title: 'Joined groups', + buttonProps: null, + }, + 'membership-requests': { + ...defaultOwnedConfig, + title: 'Membership requests', + buttonProps: null, + }, + } as Record, + shareGroupsQueryResult: { + data: { data: [], results: 0 } as + | undefined + | { data: unknown[]; results: number }, + error: null as unknown, + isFetching: false, + isLoading: false, + }, + tableProps: null as unknown, + useNavigate: vi.fn(), + useOrderV2: vi.fn(), + usePaginationV2: vi.fn(), + usePermissions: vi.fn(), + useSearch: vi.fn(), + useShareGroupsQuery: vi.fn(), + }; +}); + +vi.mock('@linode/queries', async () => { + const actual = await vi.importActual('@linode/queries'); + return { + ...actual, + useShareGroupsQuery: queryMocks.useShareGroupsQuery, + }; +}); + +vi.mock('@linode/search', () => ({ + getAPIFilterFromQuery: queryMocks.getAPIFilterFromQuery, +})); + +vi.mock('src/features/IAM/hooks/usePermissions', () => ({ + usePermissions: queryMocks.usePermissions, +})); + +vi.mock('src/hooks/useOrderV2', () => ({ + useOrderV2: queryMocks.useOrderV2, +})); + +vi.mock('src/hooks/usePaginationV2', () => ({ + usePaginationV2: queryMocks.usePaginationV2, +})); + +vi.mock('@tanstack/react-router', async () => { + const actual = await vi.importActual('@tanstack/react-router'); + return { + ...actual, + useNavigate: queryMocks.useNavigate, + useSearch: queryMocks.useSearch, + }; +}); + +vi.mock('@linode/ui', async () => { + const actual = await vi.importActual('@linode/ui'); + return { + ...actual, + CircleProgress: () =>
, + ErrorState: ({ errorText }: { errorText: string }) => ( +
{errorText}
+ ), + }; +}); + +vi.mock('src/components/DocumentTitle', () => ({ + DocumentTitleSegment: ({ segment }: { segment: string }) => ( +
{segment}
+ ), +})); + +vi.mock( + 'src/components/DebouncedSearchTextField/DebouncedSearchTextField', + () => ({ + DebouncedSearchTextField: (props: unknown) => { + const searchProps = props as { + errorText?: string; + onSearch: (query: string) => void; + value?: string; + }; + + return ( +
+
{searchProps.errorText ?? ''}
+
{searchProps.value ?? ''}
+ + +
+ ); + }, + }) +); + +vi.mock('./ShareGroupsTable', () => ({ + ShareGroupsTable: (props: unknown) => { + const tableProps = props as { + headerProps?: { buttonProps?: { onButtonClick: () => void } }; + }; + + queryMocks.tableProps = props; + return ( +
+
table-rendered
+ +
+ ); + }, +})); + +vi.mock('./shareGroupsTabsConfig', () => ({ + SHAREGROUPS_CONFIG: queryMocks.shareGroupsConfig, +})); + +describe('For Owned groups', () => { + beforeEach(() => { + vi.clearAllMocks(); + queryMocks.navigate = vi.fn(); + queryMocks.tableProps = null; + queryMocks.onSearchWithText = 'new-search'; + queryMocks.filter = {}; + queryMocks.searchParseError = undefined; + queryMocks.search = {}; + + queryMocks.shareGroupsConfig['owned-groups'] = { + ...queryMocks.defaultOwnedConfig, + }; + + queryMocks.useNavigate.mockReturnValue(queryMocks.navigate); + queryMocks.useSearch.mockImplementation(() => queryMocks.search); + queryMocks.usePermissions.mockReturnValue({ + data: { create_image: true }, + }); + queryMocks.usePaginationV2.mockReturnValue(queryMocks.pagination); + queryMocks.useOrderV2.mockReturnValue({ + handleOrderChange: vi.fn(), + order: 'asc', + orderBy: 'label', + }); + queryMocks.getAPIFilterFromQuery.mockImplementation(() => ({ + error: queryMocks.searchParseError, + filter: queryMocks.filter, + })); + queryMocks.useShareGroupsQuery.mockImplementation( + () => queryMocks.shareGroupsQueryResult + ); + + queryMocks.shareGroupsQueryResult = { + data: { data: [], results: 0 }, + error: null, + isFetching: false, + isLoading: false, + }; + }); + + it('renders loading state', () => { + queryMocks.shareGroupsQueryResult = { + data: undefined, + error: null, + isFetching: true, + isLoading: true, + }; + + const { getByTestId, queryByTestId } = renderWithTheme( + + ); + + expect(getByTestId('circle-progress')).toBeVisible(); + expect(queryByTestId('table-rendered')).not.toBeInTheDocument(); + }); + + it('renders error state when initial load fails and no search query exists', () => { + queryMocks.shareGroupsQueryResult = { + data: undefined, + error: [{ reason: 'Request failed' }], + isFetching: false, + isLoading: false, + }; + queryMocks.search = { query: undefined }; + + const { getByText, getByTestId, queryByTestId } = renderWithTheme( + + ); + + expect(getByTestId('document-title')).toHaveTextContent('Share groups'); + expect( + getByText( + 'There was an error loading your share groups. Please try again.' + ) + ).toBeVisible(); + expect(queryByTestId('table-rendered')).not.toBeInTheDocument(); + }); + + it('renders search and table with derived props on success', () => { + const handleOrderChange = vi.fn(); + queryMocks.search = { query: 'owned' }; + queryMocks.filter = { label: { '+contains': 'owned' } }; + queryMocks.searchParseError = { message: 'Invalid query syntax' }; + queryMocks.useOrderV2.mockReturnValue({ + handleOrderChange, + order: 'desc', + orderBy: 'created', + }); + queryMocks.shareGroupsQueryResult = { + data: { + data: [{ id: 1, label: 'Group A' }], + results: 1, + }, + error: null, + isFetching: false, + isLoading: false, + }; + + const { getByTestId } = renderWithTheme( + + ); + + expect(getByTestId('table-rendered')).toBeVisible(); + expect(getByTestId('search-error')).toHaveTextContent( + 'Invalid query syntax' + ); + expect(getByTestId('search-value')).toHaveTextContent('owned'); + + expect(queryMocks.useShareGroupsQuery).toHaveBeenCalledWith( + { page: 1, page_size: 25 }, + { + '+order': 'desc', + '+order_by': 'created', + label: { '+contains': 'owned' }, + } + ); + + expect(queryMocks.tableProps).toMatchObject({ + columns: queryMocks.shareGroupsConfig['owned-groups'].columns, + emptyMessage: queryMocks.shareGroupsConfig['owned-groups'].emptyMessage, + eventCategory: queryMocks.shareGroupsConfig['owned-groups'].eventCategory, + handleOrderChange, + order: 'desc', + orderBy: 'created', + pagination: { + page: 1, + pageSize: 25, + count: 1, + }, + query: 'owned', + shareGroups: [{ id: 1, label: 'Group A' }], + }); + }); + + it('navigates on search and resets page while preserving existing search state', async () => { + const user = userEvent.setup(); + queryMocks.search = { query: 'old', region: 'us-east' }; + queryMocks.onSearchWithText = 'new-search'; + + const { getByText } = renderWithTheme( + + ); + + await user.click(getByText('trigger-search')); + + expect(queryMocks.navigate).toHaveBeenCalledTimes(1); + const navigatePayload = queryMocks.navigate.mock.calls[0][0]; + expect(navigatePayload.to).toBe('/images/share-groups/$shareGroupsType'); + expect(navigatePayload.params).toEqual({ + shareGroupsType: 'joined-groups', + }); + + expect( + navigatePayload.search({ query: 'old', page: 3, region: 'us-east' }) + ).toEqual({ + query: 'new-search', + page: undefined, + region: 'us-east', + }); + }); + + it('omits header button props when config has no button config', () => { + queryMocks.shareGroupsConfig['owned-groups'] = { + ...queryMocks.defaultOwnedConfig, + buttonProps: null, + }; + + renderWithTheme(); + + const tableProps = queryMocks.tableProps as { + headerProps: { buttonProps?: unknown }; + }; + + expect(tableProps.headerProps.buttonProps).toBeUndefined(); + }); +}); diff --git a/packages/manager/src/features/Images/ImagesLanding/v2/ShareGroups/ShareGroupsView.tsx b/packages/manager/src/features/Images/ImagesLanding/v2/ShareGroups/ShareGroupsView.tsx new file mode 100644 index 00000000000..b806e090965 --- /dev/null +++ b/packages/manager/src/features/Images/ImagesLanding/v2/ShareGroups/ShareGroupsView.tsx @@ -0,0 +1,165 @@ +import { useProfile, useShareGroupsQuery } from '@linode/queries'; +import { getAPIFilterFromQuery } from '@linode/search'; +import { CircleProgress, ErrorState } from '@linode/ui'; +import { useNavigate, useSearch } from '@tanstack/react-router'; +import React from 'react'; + +import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField/DebouncedSearchTextField'; +import { DocumentTitleSegment } from 'src/components/DocumentTitle'; +import { useOrderV2 } from 'src/hooks/useOrderV2'; +import { usePaginationV2 } from 'src/hooks/usePaginationV2'; + +import { ShareGroupsTable } from './ShareGroupsTable'; +import { SHAREGROUPS_CONFIG } from './shareGroupsTabsConfig'; + +import type { Filter } from '@linode/api-v4'; +import type { ShareGroupsType } from 'src/features/Images/utils'; + +interface Props { + type: ShareGroupsType; +} + +export const ShareGroupsView = (props: Props) => { + const { type } = props; + const config = SHAREGROUPS_CONFIG[type]; + const navigate = useNavigate(); + const search = useSearch({ from: '/images/share-groups' }); + + const { data: profile } = useProfile(); + const isRestrictedUser = profile?.restricted; + + const pagination = usePaginationV2({ + currentRoute: '/images/share-groups/$shareGroupsType', + preferenceKey: config.preferenceKey, + searchParams: (prev) => ({ + ...prev, + query: search.query, + }), + }); + + const { error: searchParseError, filter } = getAPIFilterFromQuery( + search.query, + { + searchableFieldsWithoutOperator: ['label'], + } + ); + + const { + handleOrderChange: handleShareGroupsOrderChange, + order: shareGroupsOrder, + orderBy: shareGroupsOrderBy, + } = useOrderV2({ + initialRoute: { + defaultOrder: { + order: config.orderDefault, + orderBy: config.orderByDefault, + }, + from: '/images/share-groups/$shareGroupsType', + }, + preferenceKey: config.preferenceKey, + }); + + const shareGroupsFilter: Filter = { + ['+order']: shareGroupsOrder, + ['+order_by']: shareGroupsOrderBy, + ...filter, + }; + + const { + data: shareGroups, + error: shareGroupsError, + isFetching: shareGroupsIsFetching, + isLoading: shareGroupsLoading, + } = useShareGroupsQuery( + { page: pagination.page, page_size: pagination.pageSize }, + { + ...shareGroupsFilter, + } + ); + + const onSearch = (query: string) => { + navigate({ + search: (prev) => ({ + ...prev, + page: undefined, + query: query || undefined, + }), + to: '/images/share-groups/$shareGroupsType', + params: { shareGroupsType: type }, + }); + }; + + if (shareGroupsLoading) { + return ; + } + + if (!search.query && shareGroupsError) { + return ( + <> + + + + ); + } + + const tableHeaderProps = { + title: config.title, + buttonProps: config.buttonProps + ? { + buttonText: config.buttonProps.buttonText, + onButtonClick: () => + navigate({ + /* TODO: Implement OnButtonClick logic with follow-up ticket UIE-9410 */ + search: () => ({}), + to: config.buttonProps?.navigateTo ?? '/', + }), + disabled: isRestrictedUser, + tooltipText: isRestrictedUser + ? config.buttonProps.disabledToolTipText + : undefined, + pendoId: config.buttonProps.pendoId, + } + : undefined, + docsLink: config.docsLink, + description: config.description, + }; + + return ( + <> + + + + ); +}; diff --git a/packages/manager/src/features/Images/ImagesLanding/v2/ShareGroups/shareGroupsTabsConfig.tsx b/packages/manager/src/features/Images/ImagesLanding/v2/ShareGroups/shareGroupsTabsConfig.tsx index 7c82202fc28..06ffb28f733 100644 --- a/packages/manager/src/features/Images/ImagesLanding/v2/ShareGroups/shareGroupsTabsConfig.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/v2/ShareGroups/shareGroupsTabsConfig.tsx @@ -1,16 +1,185 @@ +import React from 'react'; + +import type { APIError } from '@linode/api-v4'; +import type { HiddenProps } from '@linode/ui'; import type { ImageSubTab, ShareGroupsType } from 'src/features/Images/utils'; +export interface ShareGroupsViewTableColConfig { + /* Breakpoint to hide the column (e.g., 'smDown', 'mdUp', etc) */ + hidden?: Exclude; + + /* Column name */ + name: string; + + /* Provide sortableProps to enable sorting for this column. */ + sortableProps?: { + /* API field used for sorting this column */ + label: string; + }; + /* Style overrides for this column */ + style?: React.CSSProperties; +} +export interface ShareGroupsTabsConfig { + buttonProps?: { + buttonText: string; + disabledToolTipText?: string; + navigateTo?: string; + pendoId?: string; + }; + columns: ShareGroupsViewTableColConfig[]; + description: React.ReactNode; + docsLink?: { href: string; label?: string; pendoId?: string }; + emptyMessage: { + instruction?: string; + main: string; + }; + error?: APIError[] | null; + eventCategory: string; + orderByDefault: string; + orderDefault: 'asc' | 'desc'; + preferenceKey: string; + searchFieldPendoId?: string; + title: string; +} + export const shareGroupsSubTabs: ImageSubTab[] = [ { type: 'owned-groups', title: 'Owned groups', + pendoId: 'Images Owned-Groups tab', }, { type: 'joined-groups', title: 'Joined groups', + pendoId: 'Images Joined-Groups tab', }, { type: 'membership-requests', title: 'My membership requests', + pendoId: 'Images Membership-Requests tab', + }, +]; + +const OWNED_GROUPS_TABLE_COLUMNS: ShareGroupsViewTableColConfig[] = [ + { name: 'Group', sortableProps: { label: 'label' } }, + { + name: 'Description', + sortableProps: { label: 'description' }, + }, + { + name: '# of members', + }, + { + name: '# of images', + hidden: 'smDown', + }, + { + name: 'Created', + sortableProps: { label: 'created' }, + hidden: 'lgDown', + style: { whiteSpace: 'nowrap' }, }, + { + name: 'Updated', + sortableProps: { label: 'updated' }, + hidden: 'lgDown', + style: { whiteSpace: 'nowrap' }, + }, +]; + +const JOINED_GROUPS_TABLE_COLUMNS: ShareGroupsViewTableColConfig[] = [ + { name: 'Group', sortableProps: { label: 'label' } }, + { name: 'Description', sortableProps: { label: 'description' } }, + { name: 'Membership Status', sortableProps: { label: 'membership_status' } }, + { + name: 'Status Changed', + sortableProps: { label: 'status_changed' }, + hidden: 'lgDown', + }, +]; + +const MEMBERSHIP_REQUESTS_TABLE_COLUMNS: ShareGroupsViewTableColConfig[] = [ + { name: 'Share Group UUID', sortableProps: { label: 'label' } }, + { name: 'Token UUID', sortableProps: { label: 'token_uuid' } }, + { name: 'Status', sortableProps: { label: 'status' } }, + { name: 'Created', sortableProps: { label: 'created' }, hidden: 'mdDown' }, + { name: 'Expiry', sortableProps: { label: 'expiry' }, hidden: 'mdDown' }, ]; + +export const SHAREGROUPS_CONFIG: Record< + ShareGroupsType, + ShareGroupsTabsConfig +> = { + 'owned-groups': { + title: 'Owned groups', + description: ( + <> + These are share groups you own. Other group members can deploy compute + instances from images shared within these groups. +
+ Shared images are not additionally billed on top of existing original + and replicated images. + + ), + docsLink: { + href: `https://techdocs.akamai.com/cloud-computing/docs/image-sharing`, + label: 'Image sharing', + pendoId: 'Images Groups Owned-Docs Link', + }, + columns: OWNED_GROUPS_TABLE_COLUMNS, + emptyMessage: { + main: 'No Share groups to display', + instruction: + 'Click \u2018Create Share Group\u2019 to create your first share group and share your custom images with other accounts.', + }, + eventCategory: 'owned-groups', + orderByDefault: 'label', + orderDefault: 'asc', + preferenceKey: 'owned-groups', + buttonProps: { + buttonText: 'Create Share Group', + navigateTo: '/images/share-groups/create', + disabledToolTipText: 'You do not have permissions to create share groups', + pendoId: 'Images Groups Owned-Create Button', + }, + searchFieldPendoId: 'Images Groups Owned-Search', + }, + 'joined-groups': { + title: 'Joined groups', + description: ( + <> + Manage your share group memberships. Groups you leave or are revoked + from will be removed from this list after one month. + + ), + columns: JOINED_GROUPS_TABLE_COLUMNS, + emptyMessage: { + main: 'No share groups to display', + instruction: + "Go to 'My membership requests' to make a request and join a group", + }, + eventCategory: 'joined-groups', + orderByDefault: 'label', + orderDefault: 'asc', + preferenceKey: 'joined-groups', + }, + 'membership-requests': { + title: 'Membership requests', + description: ( + <> + Manage your membership and track your share group membership requests. + We remove expired or cancelled requests after two weeks. + + ), + columns: MEMBERSHIP_REQUESTS_TABLE_COLUMNS, + emptyMessage: { + main: 'No membership requests to display', + instruction: + "Click 'Request Membership' to create your first membership request", + }, + eventCategory: 'membership-requests', + orderByDefault: 'label', + orderDefault: 'asc', + preferenceKey: '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 index 820293d0319..1a83811194a 100644 --- a/packages/manager/src/features/Images/ImagesLanding/v2/ShareGroups/shareGroupsTabsLazyRoute.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/v2/ShareGroups/shareGroupsTabsLazyRoute.tsx @@ -1,6 +1,6 @@ import { createLazyRoute } from '@tanstack/react-router'; -import { ShareGroupsTabs } from './ShareGroupsTabs'; +import { ShareGroupsTabs } from './ShareGroupsLanding'; export const shareGroupsTabsLazyRoute = createLazyRoute('/images/share-groups')( { diff --git a/packages/manager/src/features/Images/utils.ts b/packages/manager/src/features/Images/utils.ts index 78d1646cb47..c989aa5c21d 100644 --- a/packages/manager/src/features/Images/utils.ts +++ b/packages/manager/src/features/Images/utils.ts @@ -21,6 +21,8 @@ export type ShareGroupsType = export interface ImageSubTab { /** Whether this tab represents a beta feature */ isBeta?: boolean; + /** Pendo ID for the tab, used for analytics tracking */ + pendoId?: string; /** Display title for the tab */ title: string; /** The type this tab represents */ diff --git a/packages/manager/src/features/Linodes/AclpPreferenceToggle.test.tsx b/packages/manager/src/features/Linodes/AclpPreferenceToggle.test.tsx index 281f2cca69b..ac13a1474b9 100644 --- a/packages/manager/src/features/Linodes/AclpPreferenceToggle.test.tsx +++ b/packages/manager/src/features/Linodes/AclpPreferenceToggle.test.tsx @@ -7,23 +7,33 @@ import { renderWithTheme } from 'src/utilities/testHelpers'; import { AclpPreferenceToggle } from './AclpPreferenceToggle'; import { - ALERTS_BETA_MODE_BANNER_TEXT, - ALERTS_BETA_MODE_BUTTON_TEXT, - ALERTS_LEGACY_MODE_BANNER_TEXT, - ALERTS_LEGACY_MODE_BUTTON_TEXT, - METRICS_BETA_MODE_BANNER_TEXT, - METRICS_BETA_MODE_BUTTON_TEXT, - METRICS_LEGACY_MODE_BANNER_TEXT, - METRICS_LEGACY_MODE_BUTTON_TEXT, + ALERTS_ACLP_MODE_BETA_AND_NEW_PHASE_BUTTON_TEXT, + ALERTS_ACLP_MODE_BETA_PHASE_BANNER_TEXT, + ALERTS_ACLP_MODE_NEW_PHASE_BANNER_TEXT, + ALERTS_LEGACY_MODE_BETA_PHASE_BANNER_TEXT, + ALERTS_LEGACY_MODE_BETA_PHASE_BUTTON_TEXT, + ALERTS_LEGACY_MODE_NEW_PHASE_BANNER_TEXT, + ALERTS_LEGACY_MODE_NEW_PHASE_BUTTON_TEXT, + METRICS_ACLP_MODE_BETA_AND_NEW_PHASE_BUTTON_TEXT, + METRICS_ACLP_MODE_BETA_PHASE_BANNER_TEXT, + METRICS_ACLP_MODE_NEW_PHASE_BANNER_TEXT, + METRICS_LEGACY_MODE_BETA_PHASE_BANNER_TEXT, + METRICS_LEGACY_MODE_BETA_PHASE_BUTTON_TEXT, + METRICS_LEGACY_MODE_NEW_PHASE_BANNER_TEXT, + METRICS_LEGACY_MODE_NEW_PHASE_BUTTON_TEXT, } from './constants'; import type { AclpPreferenceToggleType } from './AclpPreferenceToggle'; interface ExpectedAclpPreferenceItem { - betaModeBannertext: string; - betaModeButtonText: string; - legacyModeBannerText: string; - legacyModeButtonText: string; + aclpModeBetaPhaseBannerText: string; + aclpModeBetaPhaseButtonText: string; + aclpModeNewPhaseBannerText: string; + aclpModeNewPhaseButtonText: string; + legacyModeBetaPhaseBannerText: string; + legacyModeBetaPhaseButtonText: string; + legacyModeNewPhaseBannerText: string; + legacyModeNewPhaseButtonText: string; preference: boolean; } @@ -33,17 +43,28 @@ const expectedAclpPreferences: Record< > = { metrics: { preference: true, - legacyModeBannerText: METRICS_LEGACY_MODE_BANNER_TEXT, - betaModeBannertext: METRICS_BETA_MODE_BANNER_TEXT, - legacyModeButtonText: METRICS_LEGACY_MODE_BUTTON_TEXT, - betaModeButtonText: METRICS_BETA_MODE_BUTTON_TEXT, + legacyModeBetaPhaseBannerText: METRICS_LEGACY_MODE_BETA_PHASE_BANNER_TEXT, + legacyModeNewPhaseBannerText: METRICS_LEGACY_MODE_NEW_PHASE_BANNER_TEXT, + legacyModeBetaPhaseButtonText: METRICS_LEGACY_MODE_BETA_PHASE_BUTTON_TEXT, + legacyModeNewPhaseButtonText: METRICS_LEGACY_MODE_NEW_PHASE_BUTTON_TEXT, + aclpModeBetaPhaseBannerText: METRICS_ACLP_MODE_BETA_PHASE_BANNER_TEXT, + aclpModeNewPhaseBannerText: METRICS_ACLP_MODE_NEW_PHASE_BANNER_TEXT, + aclpModeBetaPhaseButtonText: + METRICS_ACLP_MODE_BETA_AND_NEW_PHASE_BUTTON_TEXT, + aclpModeNewPhaseButtonText: + METRICS_ACLP_MODE_BETA_AND_NEW_PHASE_BUTTON_TEXT, }, alerts: { preference: true, - legacyModeBannerText: ALERTS_LEGACY_MODE_BANNER_TEXT, - betaModeBannertext: ALERTS_BETA_MODE_BANNER_TEXT, - legacyModeButtonText: ALERTS_LEGACY_MODE_BUTTON_TEXT, - betaModeButtonText: ALERTS_BETA_MODE_BUTTON_TEXT, + legacyModeBetaPhaseBannerText: ALERTS_LEGACY_MODE_BETA_PHASE_BANNER_TEXT, + legacyModeNewPhaseBannerText: ALERTS_LEGACY_MODE_NEW_PHASE_BANNER_TEXT, + legacyModeBetaPhaseButtonText: ALERTS_LEGACY_MODE_BETA_PHASE_BUTTON_TEXT, + legacyModeNewPhaseButtonText: ALERTS_LEGACY_MODE_NEW_PHASE_BUTTON_TEXT, + aclpModeBetaPhaseBannerText: ALERTS_ACLP_MODE_BETA_PHASE_BANNER_TEXT, + aclpModeNewPhaseBannerText: ALERTS_ACLP_MODE_NEW_PHASE_BANNER_TEXT, + aclpModeBetaPhaseButtonText: + ALERTS_ACLP_MODE_BETA_AND_NEW_PHASE_BUTTON_TEXT, + aclpModeNewPhaseButtonText: ALERTS_ACLP_MODE_BETA_AND_NEW_PHASE_BUTTON_TEXT, }, }; @@ -62,6 +83,25 @@ vi.mock('@linode/queries', async () => { }); describe('AclpPreferenceToggle', () => { + const metricsFlags = { + aclp: { + beta: true, + enabled: true, + new: false, + }, + }; + + const alertingFlags = { + aclpAlerting: { + accountAlertLimit: 10, + accountMetricLimit: 10, + alertDefinitions: true, + beta: true, + notificationChannels: false, + recentActivity: false, + }, + }; + /** * ACLP Preference Toggle tests for Metrics */ @@ -80,178 +120,294 @@ describe('AclpPreferenceToggle', () => { expect(skeleton).toBeInTheDocument(); }); - it('should display the correct legacy mode banner and button text for Metrics when isAclpMetricsBeta preference is disabled', () => { + it('should display the correct legacy mode banner and button text IN BETA phase for Metrics when isAclpMetricsMode preference is disabled', () => { queryMocks.usePreferences.mockReturnValue({ data: false, isLoading: false, }); - renderWithTheme(); + renderWithTheme(, { + flags: metricsFlags, + }); // Check if the banner content and button text is correct in legacy mode const typography = screen.getByTestId('metrics-preference-banner-text'); expect(typography).toHaveTextContent( - expectedAclpPreferences.metrics.legacyModeBannerText + expectedAclpPreferences.metrics.legacyModeBetaPhaseBannerText ); const expectedLegacyModeButtonText = screen.getByText( - expectedAclpPreferences.metrics.legacyModeButtonText + expectedAclpPreferences.metrics.legacyModeBetaPhaseButtonText ); expect(expectedLegacyModeButtonText).toBeInTheDocument(); }); - it('should display the correct beta mode banner and button text for Metrics when isAclpMetricsBeta preference is enabled', () => { + it('should display the correct legacy mode banner and button text IN NEW phase for Metrics when isAclpMetricsMode preference is disabled', () => { queryMocks.usePreferences.mockReturnValue({ - data: expectedAclpPreferences.metrics.preference, + data: false, isLoading: false, }); - renderWithTheme(); + renderWithTheme(, { + flags: { aclp: { ...metricsFlags.aclp, beta: false, new: true } }, + }); - // Check if the banner content and button text is correct in beta mode + // Check if the banner content and button text is correct in legacy mode const typography = screen.getByTestId('metrics-preference-banner-text'); expect(typography).toHaveTextContent( - expectedAclpPreferences.metrics.betaModeBannertext + expectedAclpPreferences.metrics.legacyModeNewPhaseBannerText ); const expectedLegacyModeButtonText = screen.getByText( - expectedAclpPreferences.metrics.betaModeButtonText + expectedAclpPreferences.metrics.legacyModeNewPhaseButtonText ); expect(expectedLegacyModeButtonText).toBeInTheDocument(); }); - it('should update ACLP Metrics preference to beta mode when toggling from legacy mode', async () => { + it('should display the correct ACLP beta phase mode banner and button text for Metrics when isAclpMetricsMode preference is enabled', () => { + queryMocks.usePreferences.mockReturnValue({ + data: expectedAclpPreferences.metrics.preference, + isLoading: false, + }); + + renderWithTheme(, { + flags: metricsFlags, + }); + + // Check if the banner content and button text is correct in ACLP beta mode + const typography = screen.getByTestId('metrics-preference-banner-text'); + expect(typography).toHaveTextContent( + expectedAclpPreferences.metrics.aclpModeBetaPhaseBannerText + ); + + const expectedAclpModeBetaPhaseButtonText = screen.getByText( + expectedAclpPreferences.metrics.aclpModeBetaPhaseButtonText + ); + expect(expectedAclpModeBetaPhaseButtonText).toBeInTheDocument(); + }); + + it('should display the correct ACLP NEW phase mode banner and button text for Metrics when isAclpMetricsMode preference is enabled', () => { + queryMocks.usePreferences.mockReturnValue({ + data: expectedAclpPreferences.metrics.preference, + isLoading: false, + }); + + renderWithTheme(, { + flags: { aclp: { ...metricsFlags.aclp, beta: false, new: true } }, + }); + + // Check if the banner content and button text is correct in ACLP new phase mode + const typography = screen.getByTestId('metrics-preference-banner-text'); + expect(typography).toHaveTextContent( + expectedAclpPreferences.metrics.aclpModeNewPhaseBannerText + ); + + const expectedAclpModeNewPhaseButtonText = screen.getByText( + expectedAclpPreferences.metrics.aclpModeNewPhaseButtonText + ); + expect(expectedAclpModeNewPhaseButtonText).toBeInTheDocument(); + }); + + it('should update ACLP Metrics preference to aclp mode when toggling from legacy mode', async () => { queryMocks.usePreferences.mockReturnValue({ data: false, isLoading: false, }); const mockUpdatePreferences = vi.fn().mockResolvedValue({ - isAclpMetricsBeta: false, + isAclpMetricsMode: false, }); queryMocks.useMutatePreferences.mockReturnValue({ mutateAsync: mockUpdatePreferences, }); - renderWithTheme(); + renderWithTheme(, { + flags: metricsFlags, + }); - // Click the button to switch from legacy to beta + // Click the button to switch from legacy to aclp const button = screen.getByText( - expectedAclpPreferences.metrics.legacyModeButtonText + expectedAclpPreferences.metrics.legacyModeBetaPhaseButtonText ); await userEvent.click(button); expect(mockUpdatePreferences).toHaveBeenCalledWith({ - isAclpMetricsBeta: true, + isAclpMetricsMode: true, }); }); - it('should update ACLP Metrics preference to legacy mode when toggling from beta mode', async () => { + it('should update ACLP Metrics preference to legacy mode when toggling from aclp mode', async () => { queryMocks.usePreferences.mockReturnValue({ data: expectedAclpPreferences.metrics.preference, isLoading: false, }); const mockUpdatePreferences = vi.fn().mockResolvedValue({ - isAclpMetricsBeta: true, + isAclpMetricsMode: true, }); queryMocks.useMutatePreferences.mockReturnValue({ mutateAsync: mockUpdatePreferences, }); - renderWithTheme(); + renderWithTheme(, { + flags: metricsFlags, + }); - // Click the button to switch from beta to legacy + // Click the button to switch from aclp to legacy const button = screen.getByText( - expectedAclpPreferences.metrics.betaModeButtonText + expectedAclpPreferences.metrics.aclpModeBetaPhaseButtonText ); await userEvent.click(button); expect(mockUpdatePreferences).toHaveBeenCalledWith({ - isAclpMetricsBeta: false, + isAclpMetricsMode: false, }); }); /** * ACLP Preference Toggle tests for Alerts */ - it('should display the correct legacy mode banner and button text for Alerts when isAlertsBetaMode is false', () => { + it('should display the correct legacy mode banner and button text IN BETA phase for Alerts when isAclpAlertsMode is false', () => { renderWithTheme( + />, + { flags: alertingFlags } ); // Check if the banner content and button text is correct in legacy mode const typography = screen.getByTestId('alerts-preference-banner-text'); expect(typography).toHaveTextContent( - expectedAclpPreferences.alerts.legacyModeBannerText + expectedAclpPreferences.alerts.legacyModeBetaPhaseBannerText + ); + + const button = screen.getByText( + expectedAclpPreferences.alerts.legacyModeBetaPhaseButtonText + ); + expect(button).toBeInTheDocument(); + }); + + it('should display the correct legacy mode banner and button text IN NEW phase for Alerts when isAclpAlertsMode is false', () => { + renderWithTheme( + , + { + flags: { + aclpAlerting: { + ...alertingFlags.aclpAlerting, + beta: false, + new: true, + }, + }, + } + ); + + // Check if the banner content and button text is correct in legacy mode + const typography = screen.getByTestId('alerts-preference-banner-text'); + expect(typography).toHaveTextContent( + expectedAclpPreferences.alerts.legacyModeNewPhaseBannerText + ); + + const button = screen.getByText( + expectedAclpPreferences.alerts.legacyModeNewPhaseButtonText + ); + expect(button).toBeInTheDocument(); + }); + + it('should display the correct ACLP beta phase mode banner and button text for Alerts when isAclpAlertsMode is true', () => { + renderWithTheme( + , + { flags: alertingFlags } + ); + + // Check if the banner content and button text is correct in aclp beta mode + const typography = screen.getByTestId('alerts-preference-banner-text'); + expect(typography).toHaveTextContent( + expectedAclpPreferences.alerts.aclpModeBetaPhaseBannerText ); const button = screen.getByText( - expectedAclpPreferences.alerts.legacyModeButtonText + expectedAclpPreferences.alerts.aclpModeBetaPhaseButtonText ); expect(button).toBeInTheDocument(); }); - it('should display the correct beta mode banner and button text for Alerts when isAlertsBetaMode is true', () => { + it('should display the correct ACLP NEW phase mode banner and button text for Alerts when isAclpAlertsMode is true', () => { renderWithTheme( + />, + { + flags: { + aclpAlerting: { + ...alertingFlags.aclpAlerting, + beta: false, + new: true, + }, + }, + } ); - // Check if the banner content and button text is correct in beta mode + // Check if the banner content and button text is correct in aclp new mode const typography = screen.getByTestId('alerts-preference-banner-text'); expect(typography).toHaveTextContent( - expectedAclpPreferences.alerts.betaModeBannertext + expectedAclpPreferences.alerts.aclpModeNewPhaseBannerText ); const button = screen.getByText( - expectedAclpPreferences.alerts.betaModeButtonText + expectedAclpPreferences.alerts.aclpModeNewPhaseButtonText ); expect(button).toBeInTheDocument(); }); - it('should call onAlertsModeChange with true when switching from legacy to beta mode', async () => { - const mockSetIsAclpBetaLocal = vi.fn(); + it('should call onAlertsModeChange with true when switching from legacy to aclp mode', async () => { + const mockSetIsAclpModeLocal = vi.fn(); renderWithTheme( + />, + { flags: alertingFlags } ); - // Click the button to switch from legacy to beta + // Click the button to switch from legacy to aclp const button = screen.getByText( - expectedAclpPreferences.alerts.legacyModeButtonText + expectedAclpPreferences.alerts.legacyModeBetaPhaseButtonText ); await userEvent.click(button); - expect(mockSetIsAclpBetaLocal).toHaveBeenCalledWith(true); + expect(mockSetIsAclpModeLocal).toHaveBeenCalledWith(true); }); - it('should call onAlertsModeChange with false when switching from beta to legacy mode', async () => { - const mockSetIsAclpBetaLocal = vi.fn(); + it('should call onAlertsModeChange with false when switching from aclp to legacy mode', async () => { + const mockSetIsAclpModeLocal = vi.fn(); renderWithTheme( + />, + { flags: alertingFlags } ); - // Click the button to switch from beta to legacy + // Click the button to switch from aclp to legacy const button = screen.getByText( - expectedAclpPreferences.alerts.betaModeButtonText + expectedAclpPreferences.alerts.aclpModeBetaPhaseButtonText ); await userEvent.click(button); - expect(mockSetIsAclpBetaLocal).toHaveBeenCalledWith(false); + expect(mockSetIsAclpModeLocal).toHaveBeenCalledWith(false); }); }); diff --git a/packages/manager/src/features/Linodes/AclpPreferenceToggle.tsx b/packages/manager/src/features/Linodes/AclpPreferenceToggle.tsx index 831695dd3db..ee59fa64d9d 100644 --- a/packages/manager/src/features/Linodes/AclpPreferenceToggle.tsx +++ b/packages/manager/src/features/Linodes/AclpPreferenceToggle.tsx @@ -1,19 +1,21 @@ import { useMutatePreferences, usePreferences } from '@linode/queries'; import { Button, Typography } from '@linode/ui'; -import React, { type JSX } from 'react'; +import type { JSX } from 'react'; +import React from 'react'; import { DismissibleBanner } from 'src/components/DismissibleBanner/DismissibleBanner'; import { Skeleton } from 'src/components/Skeleton'; +import { useFlags } from 'src/hooks/useFlags'; export interface AclpPreferenceToggleType { /** * Alerts toggle state. Use only when type is `alerts` */ - isAlertsBetaMode?: boolean; + isAclpAlertsMode?: boolean; /** * Handler for alerts toggle. Use only when type is `alerts` */ - onAlertsModeChange?: (isBeta: boolean) => void; + onAlertsModeChange?: (isAclpMode: boolean) => void; /** * Toggle type: `alerts` or `metrics` */ @@ -21,8 +23,14 @@ export interface AclpPreferenceToggleType { } interface PreferenceConfigItem { - getBannerText: (isBeta: boolean | undefined) => JSX.Element; - getButtonText: (isBeta: boolean | undefined) => string; + getBannerText: ( + isAclpMode: boolean | undefined, + isAclpBeta: boolean | undefined + ) => JSX.Element; + getButtonText: ( + isAclpMode: boolean | undefined, + isAclpBeta: boolean | undefined + ) => string; preferenceKey: string; } @@ -32,62 +40,76 @@ const preferenceConfig: Record< > = { metrics: { preferenceKey: 'metrics-preference', - getButtonText: (isBeta) => - isBeta ? 'Switch to legacy Metrics' : 'Try the Metrics (Beta)', - getBannerText: (isBeta) => - isBeta ? ( + getButtonText: (isAclpMode, isAclpBeta) => { + const aclpText = isAclpBeta + ? 'Try the Metrics (Beta)' + : 'Try the Metrics (New)'; + return isAclpMode ? 'Switch to legacy Metrics' : aclpText; + }, + getBannerText: (isAclpMode, isAclpBeta) => { + const labelInAclp = isAclpBeta ? 'Metrics (Beta)' : 'Metrics (New)'; + const labelOutsideAclp = isAclpBeta ? 'Metrics (Beta)' : 'Metrics'; + + return isAclpMode ? ( - Welcome to Metrics (Beta) with more options and + Welcome to {labelInAclp} with more options and greater flexibility for better data analysis. ) : ( - Try the new Metrics (Beta) with more options and + Try the new {labelOutsideAclp} with more options and greater flexibility for better data analysis. You can switch back to the current view at any time. - ), + ); + }, }, alerts: { preferenceKey: 'alerts-preference', - getButtonText: (isBeta) => - isBeta ? 'Switch to legacy Alerts' : 'Try Alerts (Beta)', - getBannerText: (isBeta) => - isBeta ? ( + getButtonText: (isAclpMode, isAclpBeta) => { + const aclpText = isAclpBeta ? 'Try Alerts (Beta)' : 'Try Alerts (New)'; + return isAclpMode ? 'Switch to legacy Alerts' : aclpText; + }, + getBannerText: (isAclpMode, isAclpBeta) => { + const aclpText = isAclpBeta ? 'Alerts (Beta)' : 'Alerts (New)'; + + return isAclpMode ? ( - Welcome to Alerts (Beta), designed for flexibility - with features like customizable alerts. + Welcome to {aclpText}, designed for flexibility with + features like customizable alerts. ) : ( - Try the Alerts (Beta), featuring new options like + Try the {aclpText}, featuring new options like customizable alerts. You can switch back to legacy Alerts at any time. - ), + ); + }, }, }; /** * - For Alerts, the toggle uses local state, not preferences. We do this because each Linode should manage its own alert mode individually. * - Create Linode: Toggle defaults to false (legacy mode). It's a simple UI toggle with no persistence. - * - Edit Linode: Toggle defaults based on useIsLinodeAclpSubscribed (true if the Linode is already subscribed to ACLP). Still local state - not saved to preferences. * * - For Metrics, we use account-level preferences, since it's a global setting shared across all Linodes. */ export const AclpPreferenceToggle = (props: AclpPreferenceToggleType) => { - const { isAlertsBetaMode, onAlertsModeChange, type } = props; + const { isAclpAlertsMode, onAlertsModeChange, type } = props; + + const { aclpAlerting, aclp } = useFlags(); const config = preferenceConfig[type]; // -------------------- Metrics related logic ------------------------ - const { data: isAclpMetricsBeta, isLoading: isAclpMetricsBetaLoading } = + const { data: isAclpMetricsMode, isLoading: isAclpMetricsModeLoading } = usePreferences((preferences) => { - return preferences?.isAclpMetricsBeta; + return preferences?.isAclpMetricsMode; }, type === 'metrics'); const { mutateAsync: updatePreferences } = useMutatePreferences(); - if (isAclpMetricsBetaLoading) { + if (isAclpMetricsModeLoading) { return ( { } // ------------------------------------------------------------------- - const isBeta = type === 'alerts' ? isAlertsBetaMode : isAclpMetricsBeta; - const handleBetaToggle = () => { + const isAclpMode = type === 'alerts' ? isAclpAlertsMode : isAclpMetricsMode; + + const isAclpModeBeta = type === 'alerts' ? aclpAlerting?.beta : aclp?.beta; + + const handleToggle = () => { if (type === 'alerts' && onAlertsModeChange) { - onAlertsModeChange(!isBeta); + onAlertsModeChange(!isAclpMode); } else { - updatePreferences({ isAclpMetricsBeta: !isBeta }); + updatePreferences({ isAclpMetricsMode: !isAclpMode }); } }; @@ -114,10 +139,10 @@ export const AclpPreferenceToggle = (props: AclpPreferenceToggleType) => { actionButton={ } dismissible={false} @@ -126,7 +151,7 @@ export const AclpPreferenceToggle = (props: AclpPreferenceToggleType) => { variant="info" > - {config.getBannerText(isBeta)} + {config.getBannerText(isAclpMode, isAclpModeBeta)} ); diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Actions.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Actions.tsx index b7469edc159..e524d95e2fc 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/Actions.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/Actions.tsx @@ -19,10 +19,10 @@ import { import type { LinodeCreateFormValues } from './utilities'; interface ActionProps { - isAlertsBetaMode?: boolean; + isAclpAlertsMode?: boolean; } -export const Actions = ({ isAlertsBetaMode }: ActionProps) => { +export const Actions = ({ isAclpAlertsMode }: ActionProps) => { const createType = useGetLinodeCreateType(); const [isAPIAwarenessModalOpen, setIsAPIAwarenessModalOpen] = useState(false); @@ -103,8 +103,8 @@ export const Actions = ({ isAlertsBetaMode }: ActionProps) => { onClose={() => setIsAPIAwarenessModalOpen(false)} payLoad={getLinodeCreatePayload(structuredClone(getValues()), { isShowingNewNetworkingUI: isLinodeInterfacesEnabled, - isAclpIntegration: aclpServices?.linode?.alerts?.enabled, - isAclpAlertsPreferenceBeta: isAlertsBetaMode, + isAclpAlertsEnabled: aclpServices?.linode?.alerts?.enabled, + isAclpAlertsMode, })} /> diff --git a/packages/manager/src/features/Linodes/LinodeCreate/AdditionalOptions/AdditionalOptions.tsx b/packages/manager/src/features/Linodes/LinodeCreate/AdditionalOptions/AdditionalOptions.tsx index d9ecb880558..72b7bb29f84 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/AdditionalOptions/AdditionalOptions.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/AdditionalOptions/AdditionalOptions.tsx @@ -12,13 +12,13 @@ import { MaintenancePolicy } from './MaintenancePolicy'; import type { CreateLinodeRequest } from '@linode/api-v4'; interface AdditionalOptionProps { - isAlertsBetaMode: boolean; - onAlertsModeChange: (isBeta: boolean) => void; + isAclpAlertsMode: boolean; + onAlertsModeChange: (isAclpMode: boolean) => void; } export const AdditionalOptions = ({ onAlertsModeChange, - isAlertsBetaMode, + isAclpAlertsMode, }: AdditionalOptionProps) => { const { aclpServices } = useFlags(); const { isVMHostMaintenanceEnabled } = useVMHostMaintenanceEnabled(); @@ -53,7 +53,7 @@ export const AdditionalOptions = ({ }> {showAlerts && ( )} diff --git a/packages/manager/src/features/Linodes/LinodeCreate/AdditionalOptions/Alerts.tsx b/packages/manager/src/features/Linodes/LinodeCreate/AdditionalOptions/Alerts.tsx index e2af0ce0371..ebfc8550d7f 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/AdditionalOptions/Alerts.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/AdditionalOptions/Alerts.tsx @@ -1,4 +1,5 @@ -import { Accordion, BetaChip } from '@linode/ui'; +import { getFeatureChip } from '@linode/shared'; +import { Accordion } from '@linode/ui'; import * as React from 'react'; import { useController, useFormContext } from 'react-hook-form'; @@ -8,33 +9,34 @@ import { AlertsPanel } from 'src/features/Linodes/LinodesDetail/LinodeAlerts/Ale import { useFlags } from 'src/hooks/useFlags'; import { AclpPreferenceToggle } from '../../AclpPreferenceToggle'; +import { EMPTY_ACLP_ALERTS } from '../utilities'; import type { LinodeCreateFormValues } from '../utilities'; import type { CloudPulseAlertsPayload } from '@linode/api-v4'; interface AlertsProps { - isAlertsBetaMode: boolean; - onAlertsModeChange: (isBeta: boolean) => void; + isAclpAlertsMode: boolean; + onAlertsModeChange: (isAclpMode: boolean) => void; } export const Alerts = ({ onAlertsModeChange, - isAlertsBetaMode, + isAclpAlertsMode, }: AlertsProps) => { - const { aclpServices } = useFlags(); + const { aclpAlerting } = useFlags(); const { control } = useFormContext(); const { field } = useController({ control, name: 'alerts', - defaultValue: { system_alerts: [], user_alerts: [] }, + defaultValue: EMPTY_ACLP_ALERTS, }); const handleToggleAlert = (updatedAlerts: CloudPulseAlertsPayload) => { field.onChange(updatedAlerts); }; - const subHeading = isAlertsBetaMode ? ( + const subHeading = isAclpAlertsMode ? ( <> Receive notifications through System Alerts when metric thresholds are exceeded. After you've created your Linode, you can create and manage @@ -52,30 +54,25 @@ export const Alerts = ({ - ) : null - } + headingChip={isAclpAlertsMode ? getFeatureChip(aclpAlerting ?? {}) : null} subHeading={subHeading} summaryProps={{ sx: { p: 0 } }} > - {aclpServices?.linode?.alerts?.enabled && ( - - )} - {aclpServices?.linode?.alerts?.enabled && isAlertsBetaMode ? ( - // Beta ACLP Alerts View + + {isAclpAlertsMode ? ( + // ACLP Alerts View ) : ( - // Legacy Alerts View (read-only) - + // Legacy Alerts View (read-only with default values) + )} ); diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Summary/Summary.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Summary/Summary.tsx index 01558429a41..7704f16f849 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/Summary/Summary.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/Summary/Summary.tsx @@ -23,10 +23,10 @@ import { getLinodePrice } from './utilities'; import type { LinodeCreateFormValues } from '../utilities'; interface SummaryProps { - isAlertsBetaMode?: boolean; + isAclpAlertsMode?: boolean; } -export const Summary = ({ isAlertsBetaMode }: SummaryProps) => { +export const Summary = ({ isAclpAlertsMode }: SummaryProps) => { const theme = useTheme(); const isSmallScreen = useMediaQuery(theme.breakpoints.down('md')); const { isLinodeInterfacesEnabled } = useIsLinodeInterfacesEnabled(); @@ -112,25 +112,25 @@ export const Summary = ({ isAlertsBetaMode }: SummaryProps) => { ? linodeInterfaces.some((i) => i.firewall_id && i.firewall_id !== -1) : firewallId; - const hasBetaAclpAlertsAssigned = + const hasAclpAlertsAssigned = aclpServices?.linode?.alerts?.enabled && isAclpAlertsSupportedRegionLinode && - isAlertsBetaMode; + isAclpAlertsMode; - const totalBetaAclpAlertsAssignedCount = + const totalAclpAlertsAssignedCount = (alerts?.system_alerts?.length ?? 0) + (alerts?.user_alerts?.length ?? 0); - const betaAclpAlertsAssignedList = [ + const aclpAlertsAssignedList = [ ...(alerts?.system_alerts ?? []), ...(alerts?.user_alerts ?? []), ].join(', '); - const betaAclpAlertsAssignedDetails = - totalBetaAclpAlertsAssignedCount > 0 ? ( + const aclpAlertsAssignedDetails = + totalAclpAlertsAssignedCount > 0 ? ( ) : ( '0' @@ -210,9 +210,9 @@ export const Summary = ({ isAlertsBetaMode }: SummaryProps) => { { item: { title: 'Alerts Assigned', - details: betaAclpAlertsAssignedDetails, + details: aclpAlertsAssignedDetails, }, - show: hasBetaAclpAlertsAssigned, + show: hasAclpAlertsAssigned, }, ]; diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Images.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Images.tsx index 9c2836f0547..e24870c6780 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Images.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Images.tsx @@ -5,7 +5,7 @@ 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 { IMAGE_SELECT_TABLE_LINODE_CREATE_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'; @@ -97,10 +97,10 @@ export const Images = () => { Choose an Image {isPrivateImageSharingEnabled ? ( ) : ( diff --git a/packages/manager/src/features/Linodes/LinodeCreate/index.tsx b/packages/manager/src/features/Linodes/LinodeCreate/index.tsx index a6851e6502f..40d879cc64a 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/index.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/index.tsx @@ -67,6 +67,7 @@ import { UserData } from './UserData/UserData'; import { captureLinodeCreateAnalyticsEvent, defaultValues, + EMPTY_ACLP_ALERTS, getLinodeCreatePayload, useHandleLinodeCreateAnalyticsFormError, } from './utilities'; @@ -93,7 +94,7 @@ export const LinodeCreate = () => { const { aclpServices, linodeCreateBanner } = useFlags(); // In Create flow, alerts always default to 'legacy' mode - const [isAclpAlertsBetaCreateFlow, setIsAclpAlertsBetaCreateFlow] = + const [isAclpAlertsModeCreateFlow, setIsAclpAlertsModeCreateFlow] = React.useState(false); const queryClient = useQueryClient(); @@ -113,6 +114,20 @@ export const LinodeCreate = () => { shouldFocusError: false, // We handle this ourselves with `scrollErrorIntoView` }); + const handleAlertsModeChange = React.useCallback( + (isAclpMode: boolean) => { + // Reset alerts to empty defaults when entering ACLP mode so that + // previously selected alerts don't persist across mode toggles. While in + // legacy mode the alerts field is ignored by the payload builder, so + // there is no need to clear it when switching back to legacy mode. + if (isAclpMode) { + form.setValue('alerts', EMPTY_ACLP_ALERTS); + } + setIsAclpAlertsModeCreateFlow(isAclpMode); + }, + [form] + ); + const navigate = useNavigate(); const { mutateAsync: createLinode } = useCreateLinodeMutation(); const { mutateAsync: cloneLinode } = useCloneLinodeMutation(); @@ -168,8 +183,8 @@ export const LinodeCreate = () => { const payload = getLinodeCreatePayload(values, { isDualStackEnabled, isShowingNewNetworkingUI: isLinodeInterfacesEnabled, - isAclpIntegration: aclpServices?.linode?.alerts?.enabled, - isAclpAlertsPreferenceBeta: isAclpAlertsBetaCreateFlow, + isAclpAlertsEnabled: aclpServices?.linode?.alerts?.enabled, + isAclpAlertsMode: isAclpAlertsModeCreateFlow, }); try { @@ -319,15 +334,15 @@ export const LinodeCreate = () => { )} - + {secureVMNoticesEnabled && } - + diff --git a/packages/manager/src/features/Linodes/LinodeCreate/utilities.ts b/packages/manager/src/features/Linodes/LinodeCreate/utilities.ts index a30cf672123..3a13c6feb75 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/utilities.ts +++ b/packages/manager/src/features/Linodes/LinodeCreate/utilities.ts @@ -25,6 +25,7 @@ import { getDefaultUDFData } from './Tabs/StackScripts/UserDefinedFields/utiliti import type { LinodeCreateInterface } from './Networking/utilities'; import type { AccountSettings, + CloudPulseAlertsPayload, CreateLinodeRequest, FirewallSettings, InterfaceGenerationType, @@ -43,9 +44,18 @@ import type { LinodeCreateSearchParams } from 'src/routes/linodes'; */ const DEFAULT_OS = 'linode/ubuntu24.04'; +/** + * Empty default value for the ACLP alerts form field. + * Used when entering ACLP mode to ensure a clean slate. + */ +export const EMPTY_ACLP_ALERTS: CloudPulseAlertsPayload = { + system_alerts: [], + user_alerts: [], +}; + interface LinodeCreatePayloadOptions { - isAclpAlertsPreferenceBeta?: boolean; - isAclpIntegration?: boolean; + isAclpAlertsEnabled?: boolean; + isAclpAlertsMode?: boolean; isDualStackEnabled?: boolean; isShowingNewNetworkingUI: boolean; } @@ -63,8 +73,8 @@ export const getLinodeCreatePayload = ( ): CreateLinodeRequest => { const { isShowingNewNetworkingUI, - isAclpIntegration, - isAclpAlertsPreferenceBeta, + isAclpAlertsEnabled, + isAclpAlertsMode, isDualStackEnabled, } = options; @@ -75,7 +85,9 @@ export const getLinodeCreatePayload = ( 'linodeInterfaces', ]); - if (!isAclpIntegration || !isAclpAlertsPreferenceBeta) { + const isLegacyAlerts = !isAclpAlertsEnabled || !isAclpAlertsMode; + + if (isLegacyAlerts) { values.alerts = undefined; } diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeAlerts/AlertsPanel.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeAlerts/AlertsPanel.tsx index 45473a8904b..b880eb864ec 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeAlerts/AlertsPanel.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeAlerts/AlertsPanel.tsx @@ -1,26 +1,44 @@ -import { - useLinodeQuery, - useLinodeUpdateMutation, - useTypeQuery, -} from '@linode/queries'; -import { useIsLinodeAclpSubscribed } from '@linode/shared'; +import { useLinodeQuery, useLinodeUpdateMutation } from '@linode/queries'; import { ActionsPanel, Divider, Notice, Paper, Typography } from '@linode/ui'; import { UpdateLinodeAlertsSchema } from '@linode/validation'; import { styled } from '@mui/material/styles'; -import { useFormik } from 'formik'; +import { Formik } from 'formik'; import { useSnackbar } from 'notistack'; import * as React from 'react'; -import { AlertConfirmationDialog } from 'src/features/CloudPulse/Alerts/AlertsLanding/AlertConfirmationDialog'; import { getAPIErrorFor } from 'src/utilities/getAPIErrorFor'; import { AlertSection } from './AlertSection'; +import { getLinodeAlertsInitialValues } from './utilities'; import type { AlertSectionProps } from './AlertSection'; -import type { Linode } from '@linode/api-v4'; +import type { APIError, Linode } from '@linode/api-v4'; +import type { SxProps, Theme } from '@linode/ui'; +import type { FormikProps } from 'formik'; interface Props { + /** + * API error to display + */ + error?: APIError[] | null; + /** + * Formik passed down from LinodeAlerts in unified mode (ACLP flag ON). + * Not needed in standalone or create-flow - those modes manage Formik internally. + */ + formik?: FormikProps; + /** + * Whether ACLP alerting is enabled in the current region + * Combines ACLP flag check and region support + */ + isAclpAlertingInRegionEnabled?: boolean; + /** + * Whether the panel is read-only + */ isReadOnly?: boolean; + /** + * Loading state for save operation + */ + isSaving?: boolean; /** * Optional Linode ID. * - If provided, the Alerts Panel will be in the edit flow mode. @@ -32,78 +50,127 @@ interface Props { * Receives `true` when there are unsaved changes, and `false` when the form is clean. */ onUnsavedChangesUpdate?: (hasUnsavedChanges: boolean) => void; + /** + * Custom sx styles for the Paper wrapper component + */ + paperSx?: SxProps; } +/** + * Handles three rendering modes depending on context: + * - Create flow (no linodeId): read-only, Formik is a no-op + * - Standalone (linodeId, ACLP flag OFF): self-contained with its own Formik and save + * - Unified (linodeId, ACLP flag ON): parent LinodeAlerts owns Formik, passes it down + */ export const AlertsPanel = (props: Props) => { - const { isReadOnly, linodeId } = props; - const { enqueueSnackbar } = useSnackbar(); + const { isAclpAlertingInRegionEnabled, linodeId } = props; - const { data: linode } = useLinodeQuery( - linodeId ?? -1, - linodeId !== undefined - ); + // Create flow: read-only with default values, no submission needed. + if (!linodeId) { + return ( + {}} + > + {(formik) => } + + ); + } + + // Unified Legacy mode: formik comes from the parent. + if (isAclpAlertingInRegionEnabled) { + return ( + + ); + } + + // Standalone Legacy mode: self-contained, owns its own save logic. + return ; +}; +/** + * Used when the ACLP flag is OFF. Manages its own data fetching, mutation, and form state + * so the parent doesn't need to know anything about how the save works. + */ +const AlertsPanelStandalone = (props: Props & { linodeId: number }) => { + const { linodeId, ...rest } = props; + + const { data: linode } = useLinodeQuery(linodeId); + const { enqueueSnackbar } = useSnackbar(); const { error, - isPending, + isPending: isSaving, mutateAsync: updateLinode, - } = useLinodeUpdateMutation(linodeId ?? -1); + } = useLinodeUpdateMutation(linodeId); - const { data: type } = useTypeQuery( - linode?.type ?? '', - Boolean(linode?.type) - ); + const handleSave = async (alerts: Linode['alerts']) => { + await updateLinode({ alerts }) + .then(() => { + enqueueSnackbar( + `Successfully updated alert settings for ${linode?.label}`, + { variant: 'success' } + ); + }) + .catch(() => { + // Error is displayed via the error prop passed to AlertsPanelContent below. + }); + }; - const isBareMetalInstance = type?.class === 'metal'; + const initialValues = getLinodeAlertsInitialValues(linode); - const isLinodeAclpSubscribed = useIsLinodeAclpSubscribed(linodeId, 'beta'); - const [isDialogOpen, setIsDialogOpen] = React.useState(false); + return ( + + {(formik) => ( + + )} + + ); +}; - const isCreateFlow = !linodeId; +interface AlertsPanelContentProps extends Omit { + formik: FormikProps; +} - const initialValues = isCreateFlow - ? { - cpu: 90, - io: 10000, - network_in: 10, - network_out: 10, - transfer_quota: 80, - } - : { - cpu: linode?.alerts.cpu ?? 0, - io: linode?.alerts.io ?? 0, - network_in: linode?.alerts.network_in ?? 0, - network_out: linode?.alerts.network_out ?? 0, - transfer_quota: linode?.alerts.transfer_quota ?? 0, - }; +/** Renders the form fields and save button. Formik is always passed in explicitly. */ +const AlertsPanelContent = (props: AlertsPanelContentProps) => { + const { + error, + formik, + isAclpAlertingInRegionEnabled, + isSaving, + isReadOnly, + linodeId, + paperSx, + } = props; - const formik = useFormik({ - enableReinitialize: true, - initialValues, - validateOnChange: true, - validationSchema: UpdateLinodeAlertsSchema, - async onSubmit({ cpu, io, network_in, network_out, transfer_quota }) { - await updateLinode({ - alerts: { - cpu: isBareMetalInstance ? undefined : cpu, - io, - network_in: isBareMetalInstance ? undefined : network_in, - network_out, - transfer_quota, - }, - }) - .then(() => { - enqueueSnackbar( - `Successfully updated alert settings for ${linode?.label}`, - { variant: 'success' } - ); - }) - .catch(() => {}) - .finally(() => { - setIsDialogOpen(false); - }); - }, - }); + const { data: linode } = useLinodeQuery( + linodeId ?? -1, + linodeId !== undefined + ); + + const isCreateFlow = !linodeId; const hasAPIErrorFor = getAPIErrorFor( { @@ -125,7 +192,6 @@ export const AlertsPanel = (props: Props) => { error: (formik.touched.cpu ? formik.errors.cpu : undefined) || hasAPIErrorFor('alerts.cpu'), - hidden: isBareMetalInstance, onStateChange: ( e: React.ChangeEvent, checked: boolean @@ -161,7 +227,6 @@ export const AlertsPanel = (props: Props) => { error: (formik.touched.io ? formik.errors.io : undefined) || hasAPIErrorFor('alerts.io'), - hidden: isBareMetalInstance, onStateChange: ( e: React.ChangeEvent, checked: boolean @@ -294,14 +359,10 @@ export const AlertsPanel = (props: Props) => { title: 'Transfer Quota', value: formik.values.transfer_quota ?? 0, }, - ].filter((thisAlert) => !thisAlert.hidden); + ]; const handleSaveClick = () => { - if (!isLinodeAclpSubscribed) { - formik.handleSubmit(); - } else { - setIsDialogOpen(true); - } + formik.handleSubmit(); }; React.useEffect(() => { @@ -319,57 +380,45 @@ export const AlertsPanel = (props: Props) => { }, [formik.dirty]); return ( - <> - {/* Save legacy Alerts Confirmation Modal. This modal appears on "Save" only - when user already subscribed to Beta/ACLP Mode and makes changes in the - Legacy mode Interface. */} - setIsDialogOpen(false)} - handleConfirm={() => formik.handleSubmit()} - isLoading={isPending} - isOpen={isDialogOpen && isLinodeAclpSubscribed} - message={ - <> - Are you sure you want to save legacy Alerts? Alerts(Beta){' '} - settings will be disabled and replaced by legacy Alerts settings. - - } - primaryButtonLabel="Confirm" - title="Are you sure you want to save legacy Alerts?" - /> - - isCreateFlow ? { p: 0 } : { pb: theme.spacingFunction(16) } - } - > - {!isCreateFlow && ( - ({ mb: theme.spacingFunction(12) })} - variant="h2" - > - Alerts - - )} - {generalError && {generalError}} - {alertSections.map((alert, idx) => ( - - - {idx !== alertSections.length - 1 ? : null} - - ))} - {!isCreateFlow && ( - - )} - - + + {/* Only show "Alerts" heading in legacy standalone mode (not CreateFlow and ACLP not enabled). + When ACLP is enabled AND region is supported, this component is rendered inside an Accordion which already provides the heading. + In CreateFlow, the heading is not needed. */} + {!isCreateFlow && !isAclpAlertingInRegionEnabled && ( + ({ mb: theme.spacingFunction(12) })} + variant="h2" + > + Alerts + + )} + + {/* Only show this general error in standalone Legacy mode. When ACLP alerting is enabled in the region, + it's displayed in the parent LinodeAlerts component instead */} + {!isAclpAlertingInRegionEnabled && generalError && ( + {generalError} + )} + {alertSections.map((alert, idx) => ( + + + {idx !== alertSections.length - 1 ? : null} + + ))} + + {/* Show save button only in legacy standalone mode (not CreateFlow and ACLP not enabled). + When ACLP is enabled, save functionality is handled by the unified save button in parent LinodeAlerts component. */} + {!isCreateFlow && !isAclpAlertingInRegionEnabled && ( + + )} + ); }; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeAlerts/LinodeAlerts.test.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeAlerts/LinodeAlerts.test.tsx index f7e43abc512..c9af269b0a7 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeAlerts/LinodeAlerts.test.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeAlerts/LinodeAlerts.test.tsx @@ -9,6 +9,7 @@ import { renderWithTheme } from 'src/utilities/testHelpers'; import LinodeAlerts from './LinodeAlerts'; const queryMocks = vi.hoisted(() => ({ + useIsAclpSupportedRegion: vi.fn().mockReturnValue(false), userPermissions: vi.fn(() => ({ data: { update_linode: false, @@ -21,6 +22,41 @@ vi.mock('src/features/IAM/hooks/usePermissions', () => ({ usePermissions: queryMocks.userPermissions, })); +vi.mock('src/features/CloudPulse/Utils/utils', () => ({ + useIsAclpSupportedRegion: queryMocks.useIsAclpSupportedRegion, +})); + +// Keep AlertReusableComponent lightweight in tests - it has its own test coverage. +// Renders a button so tests can simulate ACLP alert changes via onToggleAlert. +// Calls onStatusChange(true) on mount to simulate a successful alerts load. +vi.mock( + 'src/features/CloudPulse/Alerts/ContextualView/AlertReusableComponent', + () => ({ + AlertReusableComponent: ({ + onStatusChange, + onToggleAlert, + }: { + onStatusChange?: (isReady: boolean) => void; + onToggleAlert: (payload: unknown, hasUnsavedChanges: boolean) => void; + }) => { + React.useEffect(() => { + onStatusChange?.(true); + }, [onStatusChange]); + + return ( +
+ +
+ ); + }, + }) +); + vi.mock('@linode/queries', async () => { const actual = await vi.importActual('@linode/queries'); return { @@ -36,8 +72,8 @@ vi.mock('@tanstack/react-router', async () => { }; }); -describe('LinodeAlerts', () => { - it('should render component', async () => { +describe('LinodeAlerts — standalone mode (ACLP flag OFF)', () => { + it('renders the alerts fields', async () => { const { getByText } = renderWithTheme(); expect(getByText('Alerts')).toBeVisible(); @@ -56,9 +92,7 @@ describe('LinodeAlerts', () => { it('should enable "Save" button if the user has update_linode permission', async () => { queryMocks.userPermissions.mockReturnValue({ - data: { - update_linode: true, - }, + data: { update_linode: true }, }); const { getByTestId, getAllByTestId } = renderWithTheme(); @@ -74,3 +108,118 @@ describe('LinodeAlerts', () => { }); }); }); + +describe('LinodeAlerts — unified mode (aclpServices.linode.alerts.enabled + region supported)', () => { + const flags = { + aclpServices: { + linode: { + alerts: { + enabled: true, + beta: false, // "beta" here is irrelevant since we are no longer using this service-specific beta flag + }, + }, + }, + aclpAlerting: { + accountAlertLimit: 10, + accountMetricLimit: 10, + alertDefinitions: false, + beta: true, // relevant for this test suite + notificationChannels: false, + recentActivity: false, + new: false, // relevant for this test suite + }, + }; + + beforeEach(() => { + queryMocks.useIsAclpSupportedRegion.mockReturnValue(true); // ACLP supported region + queryMocks.userPermissions.mockReturnValue({ + data: { update_linode: false }, + }); + }); + + afterEach(() => { + queryMocks.useIsAclpSupportedRegion.mockReturnValue(false); + }); + + it('renders both the Legacy Alerts and Alerts accordions', async () => { + const { getByText } = renderWithTheme(, { flags }); + + expect(getByText('Legacy Alerts')).toBeVisible(); + expect(getByText('Alerts')).toBeVisible(); + }); + + it('renders the ACLP alerts component inside the Alerts accordion', async () => { + const { getByTestId } = renderWithTheme(, { flags }); + + expect(getByTestId('aclp-alerts')).toBeVisible(); + }); + + it('renders the unified Save Alerts button instead of the standalone Save button', async () => { + const { getByTestId, queryByTestId } = renderWithTheme(, { + flags, + }); + + expect(getByTestId('unified-alerts-save')).toBeVisible(); + expect(queryByTestId('alerts-save')).not.toBeInTheDocument(); + }); + + it('disables the unified Save button when there are no unsaved changes', async () => { + const { getByTestId } = renderWithTheme(, { flags }); + + expect(getByTestId('unified-alerts-save')).toHaveAttribute( + 'aria-disabled', + 'true' + ); + }); + + it('enables the unified Save button after editing a legacy alert field', async () => { + queryMocks.userPermissions.mockReturnValue({ + data: { update_linode: true }, + }); + const { getByTestId, getAllByTestId } = renderWithTheme(, { + flags, + }); + + const inputCPU = getAllByTestId('textfield-input')[0]; + const saveBtn = getByTestId('unified-alerts-save'); + + await waitFor(async () => { + await userEvent.type(inputCPU, '20'); + expect(saveBtn).not.toHaveAttribute('aria-disabled', 'true'); + }); + }); + + it('enables the unified Save button when there are unsaved ACLP alert changes', async () => { + const { getByTestId } = renderWithTheme(, { flags }); + + const saveBtn = getByTestId('unified-alerts-save'); + expect(saveBtn).toHaveAttribute('aria-disabled', 'true'); + + await userEvent.click(getByTestId('aclp-toggle')); + + await waitFor(() => { + expect(saveBtn).not.toHaveAttribute('aria-disabled', 'true'); + }); + }); + + it('displays the correct info banner about the ACLP Alerts feature in BETA Phase', async () => { + const { getByTestId } = renderWithTheme(, { flags }); + + expect(getByTestId('notice-info')).toHaveTextContent( + 'Try the Alerts (Beta), featuring new options like customizable alerts. You can keep your legacy alerts and add them to the new Beta Alerts.' + ); + }); + + it('displays the correct info banner about the ACLP Alerts feature in NEW Phase', async () => { + const { getByTestId } = renderWithTheme(, { + flags: { + aclpServices: flags.aclpServices, + aclpAlerting: { ...flags.aclpAlerting, beta: false, new: true }, + }, + }); + + expect(getByTestId('notice-info')).toHaveTextContent( + 'Try Alerts (New) with features like customizable alerts. Legacy and new alerts can be used together.' + ); + }); +}); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeAlerts/LinodeAlerts.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeAlerts/LinodeAlerts.tsx index e4f8ed5734a..193d658ad58 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeAlerts/LinodeAlerts.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeAlerts/LinodeAlerts.tsx @@ -1,25 +1,39 @@ -import { useLinodeQuery } from '@linode/queries'; -import { useIsLinodeAclpSubscribed } from '@linode/shared'; -import { ActionsPanel, Box, Typography } from '@linode/ui'; +import { useLinodeQuery, useLinodeUpdateMutation } from '@linode/queries'; +import { getFeatureChip } from '@linode/shared'; +import { + Accordion, + ActionsPanel, + Box, + Divider, + Notice, + Paper, + Stack, + Typography, +} from '@linode/ui'; +import { scrollErrorIntoViewV2 } from '@linode/utilities'; +import { UpdateLinodeAlertsSchema } from '@linode/validation'; import { useBlocker, useParams } from '@tanstack/react-router'; +import { Formik } from 'formik'; +import { useSnackbar } from 'notistack'; import * as React from 'react'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; +import { DismissibleBanner } from 'src/components/DismissibleBanner/DismissibleBanner'; import { AlertReusableComponent } from 'src/features/CloudPulse/Alerts/ContextualView/AlertReusableComponent'; import { useIsAclpSupportedRegion } from 'src/features/CloudPulse/Utils/utils'; import { usePermissions } from 'src/features/IAM/hooks/usePermissions'; import { useFlags } from 'src/hooks/useFlags'; -import { AclpPreferenceToggle } from '../../AclpPreferenceToggle'; -import { useLinodeDetailContext } from '../LinodesDetailContext'; import { AlertsPanel } from './AlertsPanel'; +import { getLinodeAlertsInitialValues } from './utilities'; + +import type { APIError, CloudPulseAlertsPayload, Linode } from '@linode/api-v4'; const LinodeAlerts = () => { const { linodeId } = useParams({ from: '/linodes/$linodeId' }); const id = Number(linodeId); - const { isAlertsBetaMode } = useLinodeDetailContext(); - const { aclpServices } = useFlags(); + const { aclpServices, aclpAlerting } = useFlags(); const { data: linode } = useLinodeQuery(id); const { data: permissions } = usePermissions('linode', ['update_linode'], id); @@ -29,13 +43,48 @@ const LinodeAlerts = () => { regionId: linode?.region, type: 'alerts', }); - const isLinodeAclpSubscribed = useIsLinodeAclpSubscribed(id, 'beta'); + + const isAclpAlertingInRegionEnabled = + aclpServices?.linode?.alerts?.enabled && isAclpAlertsSupportedRegionLinode; + + const { enqueueSnackbar } = useSnackbar(); + + const { + error: mutationError, + isPending: isUpdatingLinode, + mutateAsync: updateLinode, + } = useLinodeUpdateMutation(id); + + // Note: ACLP alert fields (system_alerts & user_alerts) are intentionally excluded + // from initialValues as they are managed separately within AlertReusableComponent. + const initialValues = getLinodeAlertsInitialValues(linode); const [hasLegacyAlertsUnsavedChanges, setHasLegacyAlertsUnsavedChanges] = React.useState(false); const [hasAclpAlertsUnsavedChanges, setHasAclpAlertsUnsavedChanges] = React.useState(false); + // Store current ACLP alerts payload + const [aclpAlertsPayload, setAclpAlertsPayload] = React.useState< + CloudPulseAlertsPayload | undefined + >(); + + // Track whether ACLP alerts have finished loading without error + const [isAclpAlertsReady, setIsAclpAlertsReady] = + React.useState(false); + + const unifiedAlertsContainerRef = React.useRef(null); + + // Helper to extract general/root errors from API errors array + // Includes errors without a field property, or with field="alerts" (not field-specific like "alerts.cpu") + const getGeneralOrRootError = (errors?: APIError[]) => { + if (!errors) return undefined; + const rootError = errors.find((e) => !e.field || e.field === 'alerts'); + return rootError?.reason; + }; + + const generalOrRootError = getGeneralOrRootError(mutationError ?? undefined); + const { proceed, reset, status } = useBlocker({ enableBeforeUnload: hasLegacyAlertsUnsavedChanges || hasAclpAlertsUnsavedChanges, @@ -71,6 +120,30 @@ const LinodeAlerts = () => { } }, [status, reset]); + // Unified save handler for both legacy and ACLP alerts + const handleUnifiedSave = React.useCallback( + async (legacyAlertsValues: Linode['alerts']) => { + const combinedAlertsPayload: Linode['alerts'] = { + ...legacyAlertsValues, + ...aclpAlertsPayload, + }; + await updateLinode({ alerts: combinedAlertsPayload }) + .then(() => { + enqueueSnackbar('Alert settings have been saved successfully', { + variant: 'success', + }); + setHasLegacyAlertsUnsavedChanges(false); + setHasAclpAlertsUnsavedChanges(false); + }) + .catch((errors) => { + if (errors && unifiedAlertsContainerRef.current) { + scrollErrorIntoViewV2(unifiedAlertsContainerRef); + } + }); + }, + [aclpAlertsPayload, updateLinode, enqueueSnackbar] + ); + return ( <> { - {aclpServices?.linode?.alerts?.enabled && - isAclpAlertsSupportedRegionLinode && ( - + {isAclpAlertingInRegionEnabled && + (aclpAlerting?.beta || aclpAlerting?.new) && ( + + + {aclpAlerting.beta && ( + <> + Try the Alerts (Beta), featuring new + options like customizable alerts. You can keep your legacy + alerts and add them to the new Beta Alerts. + + )} + + {!aclpAlerting.beta && aclpAlerting.new && ( + <> + Try Alerts (New) with features like + customizable alerts. Legacy and new alerts can be used + together. + + )} + + )} - {aclpServices?.linode?.alerts?.enabled && - isAclpAlertsSupportedRegionLinode && - isAlertsBetaMode.get ? ( - // Beta ACLP Alerts View - { - setHasAclpAlertsUnsavedChanges(hasUnsavedChanges ?? false); - }} - serviceType="linode" - /> + {isAclpAlertingInRegionEnabled ? ( + // Unified mode - both Legacy Alerts and ACLP Alerts are displayed with a shared save button. + + {/* Display general mutation error globally for unified save */} + {generalOrRootError && ( + ({ mb: theme.spacingFunction(8) })} + variant="error" + > + {generalOrRootError} + + )} + + {(formik) => ( + }> + {/* Legacy Alerts View when ACLP alerting is enabled */} + + ({ + px: 0, + py: theme.spacingFunction(8), + })} + /> + + + {/* ACLP Alerts View when ACLP alerting is enabled */} + + { + setAclpAlertsPayload(payload); + setHasAclpAlertsUnsavedChanges( + hasUnsavedChanges ?? false + ); + }} + paperSx={(theme) => ({ + px: 0, + py: theme.spacingFunction(16), + })} + serviceType="linode" + /> + + + {/* Unified Save Button */} + formik.handleSubmit(), + }} + sx={{ justifyContent: 'flex-start' }} + /> + + )} + + ) : ( - // Legacy Alerts View + // Standalone mode - only Legacy Alerts are displayed and AlertsPanel manages its own save. { setHasLegacyAlertsUnsavedChanges(hasUnsavedChanges); }} + paperSx={(theme) => ({ pb: theme.spacingFunction(16) })} /> )} diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeAlerts/utilities.ts b/packages/manager/src/features/Linodes/LinodesDetail/LinodeAlerts/utilities.ts new file mode 100644 index 00000000000..4794dc2a861 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeAlerts/utilities.ts @@ -0,0 +1,19 @@ +import type { Linode } from '@linode/api-v4'; + +/** + * Returns Formik-compatible initial values for the legacy alert threshold fields. + * ACLP alert fields (system_alerts & user_alerts) are intentionally excluded - + * they are managed separately within AlertReusableComponent. + */ +export const getLinodeAlertsInitialValues = ( + linode: Linode | undefined +): Pick< + Linode['alerts'], + 'cpu' | 'io' | 'network_in' | 'network_out' | 'transfer_quota' +> => ({ + cpu: linode?.alerts.cpu ?? 0, + io: linode?.alerts.io ?? 0, + network_in: linode?.alerts.network_in ?? 0, + network_out: linode?.alerts.network_out ?? 0, + transfer_quota: linode?.alerts.transfer_quota ?? 0, +}); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeMetrics/LinodeMetrics.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeMetrics/LinodeMetrics.tsx index 92e0cfdbbde..fc7d0c9f168 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeMetrics/LinodeMetrics.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeMetrics/LinodeMetrics.tsx @@ -21,21 +21,19 @@ const LinodeMetrics = () => { }); const { aclpServices } = useFlags(); - const { data: isAclpMetricsPreferenceBeta } = usePreferences( - (preferences) => preferences?.isAclpMetricsBeta + const { data: isAclpMetricsPreference } = usePreferences( + (preferences) => preferences?.isAclpMetricsMode ); + const isAclpMetricsInRegionEnabled = + aclpServices?.linode?.metrics?.enabled && + isAclpMetricsSupportedRegionLinode; const linodeDashboardId = 2; return ( - {aclpServices?.linode?.metrics?.enabled && - isAclpMetricsSupportedRegionLinode && ( - - )} - {aclpServices?.linode?.metrics?.enabled && - isAclpMetricsSupportedRegionLinode && - isAclpMetricsPreferenceBeta ? ( - // Beta ACLP Metrics View + {isAclpMetricsInRegionEnabled && } + {isAclpMetricsInRegionEnabled && isAclpMetricsPreference ? ( + // ACLP Metrics View { return { address: ipAddress, + assigned_entity: null, gateway: ip.gateway, interface_id: ip.interface_id, linode_id: ip.linode_id!, @@ -154,6 +155,8 @@ const ipAddressForVPC = ( region: ip.region, subnet_mask: ip.subnet_mask, type: ipType, + reserved: false, + tags: [], }; }; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/Image.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/Image.tsx index b01f4d42d2b..53b582e89c6 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/Image.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/Image.tsx @@ -1,11 +1,16 @@ import { useStackScriptQuery } from '@linode/queries'; +import { useLocation, useMatch } from '@tanstack/react-router'; import React from 'react'; import { Controller, useFormContext, useWatch } from 'react-hook-form'; +import { IMAGE_SELECT_TABLE_LINODE_REBUILD_PENDO_IDS } from 'src/components/ImageSelect/constants'; import { ImageSelect } from 'src/components/ImageSelect/ImageSelect'; +import { ImageSelectTable } from 'src/components/ImageSelect/ImageSelectTable'; +import { useIsPrivateImageSharingEnabled } from 'src/features/Images/utils'; import type { RebuildLinodeFormValues } from './utils'; import type { Image as ImageType, StackScript } from '@linode/api-v4'; +import type { LinkProps } from '@tanstack/react-router'; interface Props { disabled: boolean; @@ -24,23 +29,44 @@ export const Image = (props: Props) => { Boolean(stackscriptId) ); + const { isPrivateImageSharingEnabled } = useIsPrivateImageSharingEnabled(); + const location = useLocation(); + + const isFromLinodeDetails = + useMatch({ from: '/linodes/$linodeId', shouldThrow: false }) !== null; + return ( ( - field.onChange(value?.id ?? null)} - value={field.value ?? null} - variant="all" - /> - )} + render={({ field, fieldState }) => + isPrivateImageSharingEnabled ? ( + field.onChange(image?.id ?? null)} + pendoIDs={IMAGE_SELECT_TABLE_LINODE_REBUILD_PENDO_IDS} + queryParamsPrefix="images" + selectedImageId={field.value} + /> + ) : ( + field.onChange(value?.id ?? null)} + value={field.value ?? null} + variant="all" + /> + ) + } /> ); }; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/LinodeRebuildForm.test.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/LinodeRebuildForm.test.tsx index 1653da8be44..db1f89c2e40 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/LinodeRebuildForm.test.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/LinodeRebuildForm.test.tsx @@ -2,11 +2,14 @@ import { linodeFactory } from '@linode/utilities'; import userEvent from '@testing-library/user-event'; import React from 'react'; -import { renderWithTheme } from 'src/utilities/testHelpers'; +import { renderWithTheme, resizeScreenSize } from 'src/utilities/testHelpers'; import { LinodeRebuildForm } from './LinodeRebuildForm'; const queryMocks = vi.hoisted(() => ({ + useIsPrivateImageSharingEnabled: vi.fn(() => ({ + isPrivateImageSharingEnabled: false, + })), userPermissions: vi.fn(() => ({ data: { rebuild_linode: false, @@ -18,8 +21,16 @@ 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('LinodeRebuildForm', () => { - it('renders a notice reccomending users add user data when the Linode already uses user data', async () => { + it('renders a notice recommending users add user data when the Linode already uses user data', async () => { const linode = linodeFactory.build({ has_user_data: true }); const { getByText } = renderWithTheme( @@ -43,7 +54,7 @@ describe('LinodeRebuildForm', () => { // Open the "Add User Data" accordion await userEvent.click(getByText('Add User Data')); - // Verify the reccomendation is not present because the Linode does not use metadata currently + // Verify the recommendation is not present because the Linode does not use metadata currently expect( queryByText( 'Adding new user data is recommended as part of the rebuild process.' @@ -104,4 +115,91 @@ describe('LinodeRebuildForm', () => { const rebuildInput = getAllByRole('combobox')[0]; expect(rebuildInput).toBeEnabled(); }); + + it('should not display fields related to the Image select table when isPrivateImageSharingEnabled is false', () => { + const linode = linodeFactory.build(); + + const { queryByPlaceholderText, queryByRole } = renderWithTheme( + + ); + + expect(queryByPlaceholderText('Search images')).not.toBeInTheDocument(); + expect(queryByPlaceholderText('Filter by tag')).not.toBeInTheDocument(); + expect(queryByPlaceholderText('Filter by region')).not.toBeInTheDocument(); + + expect( + queryByRole('columnheader', { name: 'Image' }) + ).not.toBeInTheDocument(); + expect( + queryByRole('columnheader', { name: 'Replicated in' }) + ).not.toBeInTheDocument(); + expect( + queryByRole('columnheader', { name: 'Share Group' }) + ).not.toBeInTheDocument(); + expect( + queryByRole('columnheader', { name: 'Size' }) + ).not.toBeInTheDocument(); + expect( + queryByRole('columnheader', { name: 'Created' }) + ).not.toBeInTheDocument(); + expect( + queryByRole('columnheader', { name: 'Image ID' }) + ).not.toBeInTheDocument(); + }); + + 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 linode = linodeFactory.build(); + + const { getByPlaceholderText } = renderWithTheme( + + ); + + expect(getByPlaceholderText('Search images')).toBeVisible(); + }); + + it('renders the Filter by Tag field', () => { + const linode = linodeFactory.build(); + + const { getByPlaceholderText } = renderWithTheme( + + ); + + expect(getByPlaceholderText('Filter by tag')).toBeVisible(); + }); + + it('renders the Filter by Region field', () => { + const linode = linodeFactory.build(); + + const { getByPlaceholderText } = renderWithTheme( + + ); + + expect(getByPlaceholderText('Filter by region')).toBeVisible(); + }); + + it('renders the table column headers for the Image select table', () => { + const linode = linodeFactory.build(); + + const { getByText } = renderWithTheme( + + ); + + 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/LinodesDetail/LinodesDetailContext.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetailContext.tsx index 017cecc793c..276b0072b6b 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetailContext.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetailContext.tsx @@ -1,17 +1,12 @@ import React from 'react'; export interface LinodeDetailContextType { - isAlertsBetaMode: { - get: boolean; - set: (value: boolean) => void; - }; isBareMetalInstance: boolean; } export const LinodesDetailContext = React.createContext({ isBareMetalInstance: false, - isAlertsBetaMode: { get: false, set: () => {} }, }); export const useLinodeDetailContext = () => diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetailNavigation.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetailNavigation.tsx index b59a6e7b1e8..27d8b49ebc9 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetailNavigation.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetailNavigation.tsx @@ -1,6 +1,6 @@ import { useLinodeQuery, usePreferences, useTypeQuery } from '@linode/queries'; -import { useIsLinodeAclpSubscribed } from '@linode/shared'; -import { BetaChip, CircleProgress, ErrorState } from '@linode/ui'; +import { getFeatureChip } from '@linode/shared'; +import { CircleProgress, ErrorState } from '@linode/ui'; import Grid from '@mui/material/Grid'; import { Outlet, @@ -29,7 +29,7 @@ const LinodesDetailNavigation = () => { const navigate = useNavigate(); const id = Number(linodeId); const { data: linode, error } = useLinodeQuery(id); - const { aclpServices } = useFlags(); + const { aclpServices, aclp } = useFlags(); const { data: type } = useTypeQuery( linode?.type ?? '', @@ -45,29 +45,20 @@ const LinodesDetailNavigation = () => { type: 'metrics', }); - const isAclpAlertsSupportedRegionLinode = useIsAclpSupportedRegion({ - capability: 'Linodes', - regionId: linode?.region, - type: 'alerts', - }); - const { data: isAclpMetricsPreferenceBeta } = usePreferences( - (preferences) => preferences?.isAclpMetricsBeta + const { data: isAclpMetricsPreference } = usePreferences( + (preferences) => preferences?.isAclpMetricsMode ); - // In Edit flow, default alert mode is based on Linode's ACLP subscription status - const isLinodeAclpSubscribed = useIsLinodeAclpSubscribed(linode?.id, 'beta'); - const [isAclpAlertsBetaEditFlow, setIsAclpAlertsBetaEditFlow] = - React.useState(isLinodeAclpSubscribed); + const isAclpMetricsInRegionEnabled = + aclpServices?.linode?.metrics?.enabled && + isAclpMetricsSupportedRegionLinode; const { tabs, handleTabChange, tabIndex } = useTabs([ { chip: - aclpServices?.linode?.metrics?.enabled && - aclpServices?.linode?.metrics?.beta && - isAclpMetricsSupportedRegionLinode && - isAclpMetricsPreferenceBeta ? ( - - ) : null, + isAclpMetricsInRegionEnabled && isAclpMetricsPreference + ? getFeatureChip(aclp ?? {}) + : null, to: '/linodes/$linodeId/metrics', title: 'Metrics', }, @@ -95,13 +86,6 @@ const LinodesDetailNavigation = () => { title: 'Activity Feed', }, { - chip: - aclpServices?.linode?.alerts?.enabled && - aclpServices?.linode?.alerts?.beta && - isAclpAlertsSupportedRegionLinode && - isAclpAlertsBetaEditFlow ? ( - - ) : null, to: '/linodes/$linodeId/alerts', title: 'Alerts', }, @@ -131,10 +115,6 @@ const LinodesDetailNavigation = () => { { }} /> - + { value={searchQuery ?? ''} /> - + updateSearchParam('category', selected?.label) } options={categoryOptions} placeholder="Category" renderOption={renderAutocompleteOption('category')} + slotProps={{ + listbox: { + sx: { + maxHeight: '50vh', + }, + }, + }} textFieldProps={{ hideLabel: true, }} @@ -236,10 +244,11 @@ export const MarketplaceLanding = () => { } /> - + updateSearchParam('type', selected?.label) } diff --git a/packages/manager/src/features/Marketplace/MarketplaceLanding/ProductSelectionCard.tsx b/packages/manager/src/features/Marketplace/MarketplaceLanding/ProductSelectionCard.tsx index 28e44e11c11..10e3423f6d1 100644 --- a/packages/manager/src/features/Marketplace/MarketplaceLanding/ProductSelectionCard.tsx +++ b/packages/manager/src/features/Marketplace/MarketplaceLanding/ProductSelectionCard.tsx @@ -5,6 +5,7 @@ import React from 'react'; import { SelectionCard } from 'src/components/SelectionCard/SelectionCard'; +import { formatTrademarkSymbols } from '../shared'; import { PRODUCT_CARD_GRID_SIZE, PRODUCT_CARD_STYLES } from './styles'; export interface ProductCardData { @@ -71,7 +72,7 @@ export const ProductSelectionCard = React.memo( fontSize: theme.tokens.font.FontSize.Xxxs, // Must come after font })} > - {companyName} + {formatTrademarkSymbols(companyName)} , // Description { }); describe('ContactSalesDrawer', () => { + describe('cleanUpPayload', () => { + const basePayload = { + country_code: 'US', + email: 'user@akamai.com', + name: 'My User', + partner_name: 'Linode', + phone: '5555555555', + phone_country_code: '+1', + product_name: 'Linode Kubernetes Engine', + tc_consent_given: true, + }; + + it('omits blank optional fields and placeholder additional email rows', () => { + const payload = { + ...basePayload, + account_executive_email: ' ', + additional_emails: [' ', ''], + comments: '', + company_name: ' ', + }; + + expect(cleanUpPayload(payload)).toEqual(basePayload); + }); + + it('trims optional string fields and keeps non-empty additional emails', () => { + const payload = { + ...basePayload, + account_executive_email: ' seller@akamai.com ', + additional_emails: [' first@akamai.com ', ''], + comments: ' interested in pricing ', + company_name: ' Example Corp ', + }; + + expect(cleanUpPayload(payload)).toEqual({ + ...basePayload, + account_executive_email: 'seller@akamai.com', + additional_emails: ['first@akamai.com'], + comments: 'interested in pricing', + company_name: 'Example Corp', + }); + }); + }); + it('should render the Contact Sales Drawer with the correct title and description', () => { const { getByText } = renderWithThemeAndHookFormContext({ component: , @@ -83,7 +126,7 @@ describe('ContactSalesDrawer', () => { const noOfEmails = getAllByTestId('domain-transfer-input').length; if (noOfEmails < 2) { const addEmailButton = getByText( - 'Add a second, additional email address' + 'Click to add a second, additional email address' ); expect(addEmailButton).toBeVisible(); } @@ -94,7 +137,9 @@ describe('ContactSalesDrawer', () => { ); - const addEmailButton = getByText('Add a second, additional email address'); + const addEmailButton = getByText( + 'Click to add a second, additional email address' + ); fireEvent.click(addEmailButton); expect(getAllByTestId('domain-transfer-input')).toHaveLength(2); @@ -105,7 +150,9 @@ describe('ContactSalesDrawer', () => { ); - const addEmailButton = getByText('Add a second, additional email address'); + const addEmailButton = getByText( + 'Click to add a second, additional email address' + ); fireEvent.click(addEmailButton); let additionalEmailInputs = queryAllByTestId('domain-transfer-input'); @@ -176,18 +223,17 @@ describe('ContactSalesDrawer', () => { expect(selectedRegion).toHaveValue('United States Of America'); }); - it('shows an error message if a region is not selected on form submission', async () => { - const { getByText, queryByText } = renderWithTheme( + it('shows an error message if a region is not selected on blur', async () => { + const { getByTestId, queryByText } = renderWithTheme( ); - const tc_consentCheckbox = screen - .getByTestId('tc-consent-checkbox') - .querySelector('input') as HTMLInputElement; - fireEvent.click(tc_consentCheckbox); + const regionInput = getByTestId('region-autocomplete').querySelector( + 'input' + ) as HTMLInputElement; - const submitButton = getByText('Submit'); - fireEvent.click(submitButton); + fireEvent.focus(regionInput); + fireEvent.blur(regionInput); await waitFor(() => { expect(queryByText('Please select your region')).toBeVisible(); @@ -276,7 +322,7 @@ describe('ContactSalesDrawer', () => { fireEvent.blur(akamaiEmailInput); await waitFor(() => { - expect(queryByText('Must be an akamai email address.')).toBeVisible(); + expect(queryByText('Must be an Akamai email address.')).toBeVisible(); }); }); @@ -316,7 +362,8 @@ describe('ContactSalesDrawer', () => { fireEvent.click(consentCheckbox); - expect(getByText('Submit')).toBeEnabled(); + // Submit should still be disabled because required fields (region, phone) are not filled + expect(getByText('Submit')).toBeDisabled(); }); it('expands the terms and conditions when the "Show details" button is clicked', async () => { diff --git a/packages/manager/src/features/Marketplace/ProductDetails/ContactSalesDrawer.tsx b/packages/manager/src/features/Marketplace/ProductDetails/ContactSalesDrawer.tsx index d84e49d66a9..e4374681407 100644 --- a/packages/manager/src/features/Marketplace/ProductDetails/ContactSalesDrawer.tsx +++ b/packages/manager/src/features/Marketplace/ProductDetails/ContactSalesDrawer.tsx @@ -11,6 +11,7 @@ import { InputAdornment, LinkButton, Notice, + PlusSignIcon, Stack, TextField, Typography, @@ -69,6 +70,39 @@ interface CountryItem { name: string; } +export const cleanUpPayload = (values: MarketplacePartnerReferralPayload) => { + const cleaned: MarketplacePartnerReferralPayload = { + ...values, + }; + + // trim email input and drop blank rows entirely. + const cleanedAdditionalEmails = cleaned.additional_emails + ?.filter((email) => email?.trim()) + .map((email) => email.trim()); + + if (!cleanedAdditionalEmails?.length) { + delete cleaned.additional_emails; + } else { + cleaned.additional_emails = cleanedAdditionalEmails; + } + + const OPTIONAL_PAYLOAD_STRING_FIELDS: Array< + 'account_executive_email' | 'comments' | 'company_name' + > = ['account_executive_email', 'comments', 'company_name']; + + // Blank optional text inputs should be omitted, while real values are trimmed before submit. + for (const key of OPTIONAL_PAYLOAD_STRING_FIELDS) { + const value = cleaned[key]; + if (typeof value !== 'string' || value.trim() === '') { + delete cleaned[key]; + } else { + cleaned[key] = value.trim(); + } + } + + return cleaned; +}; + export const ContactSalesDrawer = (props: ContactSalesDrawerProps) => { const MAX_ADDITIONAL_EMAILS = 2; const { classes } = useStyles(); @@ -116,7 +150,6 @@ export const ContactSalesDrawer = (props: ContactSalesDrawerProps) => { product_name: productName, phone: '', phone_country_code: '+1', - comments: '', tc_consent_given: false, }, mode: 'onBlur', @@ -124,6 +157,17 @@ export const ContactSalesDrawer = (props: ContactSalesDrawerProps) => { }); const tcConsent = watch('tc_consent_given'); + const countryCode = watch('country_code'); + const phone = watch('phone'); + + const isSubmitDisabled = + isSubmitting || + !tcConsent || + !countryCode || + !phone || + !!errors.country_code || + !!errors.phone || + !!errors.phone_country_code; const dialingCodeFilterOptions = createFilterOptions({ ignoreCase: true, @@ -138,21 +182,15 @@ export const ContactSalesDrawer = (props: ContactSalesDrawerProps) => { const handleFormReset = () => { reset(); setSelectedCountry(null); + setSelectedPhoneCountry(defaultCountry); }; const onSubmit = handleSubmit(async (values) => { try { - const cleanedAdditionalEmails = values.additional_emails?.filter((e) => - e?.trim() - ); - - if (!cleanedAdditionalEmails?.length) { - delete values.additional_emails; - } else { - values.additional_emails = cleanedAdditionalEmails; - } + // Normalize optional form values so the API only receives meaningful user input. + const cleanedValues = cleanUpPayload(values); - await createPartnerReferral(values); + await createPartnerReferral(cleanedValues); enqueueSnackbar( 'Your request has been received by Akamai. After we forward it to the partner, you will receive a confirmation email.', { variant: 'success' } @@ -160,9 +198,23 @@ export const ContactSalesDrawer = (props: ContactSalesDrawerProps) => { handleFormReset(); onClose(); } catch (errors) { - const errorMessage = errors - ? getAPIErrorOrDefault(errors)?.[0].reason - : "Oops! Something went wrong and we couldn't send your contacts. Please try again in a moment, or refresh the page."; + const apiErrors = getAPIErrorOrDefault(errors); + let errorMessage = apiErrors?.[0].reason; + + // The API returns a very specific templated message when the rate limit is exceeded + // e.g: "You can only submit 2 requests in 24 hours and 1 requests per partner product in 30 days." + // Since numbers can change, we identify this message by checking its structure. + if ( + errorMessage?.includes('You can only submit') && + errorMessage?.includes('requests per partner product') + ) { + errorMessage = + 'You have exceeded the limit of the number of times you can submit a referral for this product.'; + } else if (!errorMessage) { + errorMessage = + "Oops! Something went wrong and we couldn't send your contacts. Please try again in a moment, or refresh the page."; + } + setError('root', { message: errorMessage }); } }); @@ -212,7 +264,16 @@ export const ContactSalesDrawer = (props: ContactSalesDrawerProps) => { return ( // Using MultipleIPInput component for additional emails since it allows for easy addition and removal of multiple entries, and it can display individual error messages for each email address. + + Click to add a second, additional email address + + } className={ field.value?.length === MAX_ADDITIONAL_EMAILS ? classes.hideAddEmailButton @@ -238,6 +299,9 @@ export const ContactSalesDrawer = (props: ContactSalesDrawerProps) => { } else { field.onChange(value.map((email) => email.address)); } + if (value.some((email) => !email.address.trim())) { + trigger('additional_emails'); + } }} title="Additional email addresses" tooltip="You can add two additional emails" @@ -265,6 +329,7 @@ export const ContactSalesDrawer = (props: ContactSalesDrawerProps) => { } keepSearchEnabledOnMobile label="Region" + noOptionsText="No regions match your search" onBlur={field.onBlur} onChange={(_event, country) => { setSelectedCountry(country); @@ -318,7 +383,7 @@ export const ContactSalesDrawer = (props: ContactSalesDrawerProps) => { /> - + Phone number (required) @@ -337,6 +402,7 @@ export const ContactSalesDrawer = (props: ContactSalesDrawerProps) => { option.label === value.label } label="Phone Number" + noOptionsText="No country codes match your search" onBlur={field.onBlur} onChange={(_, country) => { setSelectedPhoneCountry(country); @@ -625,11 +691,11 @@ export const ContactSalesDrawer = (props: ContactSalesDrawerProps) => { primaryButtonProps={{ 'data-pendo-id': 'Cloud Marketplace Contact Sales-Submit', label: 'Submit', - disabled: isSubmitting || !tcConsent, + disabled: isSubmitDisabled, type: 'submit', tooltipText: - 'Please agree to share your information with the partner to proceed.', - alwaysShowTooltip: !tcConsent, + 'Please complete all required fields and agree to share your information with the partner to proceed', + alwaysShowTooltip: isSubmitDisabled, }} secondaryButtonProps={{ 'data-pendo-id': 'Cloud Marketplace Contact Sales-Cancel', diff --git a/packages/manager/src/features/Marketplace/ProductDetails/ProductDetails.tsx b/packages/manager/src/features/Marketplace/ProductDetails/ProductDetails.tsx index 4acddfc25ad..66f93cbc7ef 100644 --- a/packages/manager/src/features/Marketplace/ProductDetails/ProductDetails.tsx +++ b/packages/manager/src/features/Marketplace/ProductDetails/ProductDetails.tsx @@ -15,7 +15,11 @@ import { LandingHeader } from 'src/components/LandingHeader'; import { Markdown } from 'src/components/Markdown/Markdown'; import { getProductById } from '../products'; -import { getLogoUrl, marketplaceContainerStyles } from '../shared'; +import { + formatTrademarkSymbols, + getLogoUrl, + marketplaceContainerStyles, +} from '../shared'; import { ContactSalesDrawer } from './ContactSalesDrawer'; import { getProductTabDetails } from './pages'; import { @@ -168,7 +172,7 @@ export const ProductDetails = () => { })} variant="h1" > - {product.name} + {formatTrademarkSymbols(product.name)} {product.partner && ( { })} variant="body1" > - {product.partner.name} + {formatTrademarkSymbols(product.partner.name)} )} @@ -193,7 +197,7 @@ export const ProductDetails = () => { })} variant="body1" > - {product.shortDescription} + {formatTrademarkSymbols(product.shortDescription)} {/* Tags */} diff --git a/packages/manager/src/features/Marketplace/ProductDetails/ProductDetailsTabs.tsx b/packages/manager/src/features/Marketplace/ProductDetails/ProductDetailsTabs.tsx index 302c54ea5d9..a442f62ea2f 100644 --- a/packages/manager/src/features/Marketplace/ProductDetails/ProductDetailsTabs.tsx +++ b/packages/manager/src/features/Marketplace/ProductDetails/ProductDetailsTabs.tsx @@ -38,9 +38,17 @@ const MarkdownContentRenderer = ({ content }: { content: string }) => { return ( diff --git a/packages/manager/src/features/Marketplace/ProductDetails/pages/clouddat.ts b/packages/manager/src/features/Marketplace/ProductDetails/pages/clouddat.ts new file mode 100644 index 00000000000..401c6d8518b --- /dev/null +++ b/packages/manager/src/features/Marketplace/ProductDetails/pages/clouddat.ts @@ -0,0 +1,77 @@ +/** + * Product tab details for slug clouddat. + * + * Content is provided as Markdown strings which are rendered at runtime. + */ + +import type { ProductTabDetails } from '.'; + +const overviewMarkdown = ` +CloudDat® for Akamai is an accelerated file transfer server that lets you upload and download files and objects at gigabits per second from anywhere in the world. CloudDat is purpose-built for moving large data sets as a part of everyday file transfers, cloud ingest projects, cloud migrations, and customer onboarding initiatives. + +CloudDat server software is hosted on an Akamai/Linode compute instance with access to a filesystem or object bucket in the same region. + +CloudDat is not a service: you fully control your instance and storage. We never touch your data, giving you maximum security and control. Setup takes just a few minutes and easy-to-use clients can be downloaded and installed in seconds. + +### Key features + +- **High Performance:** Fastest possible upload from any location across internet, private, and stressed data paths. +- **Cost-Effective:** Simple and predictable pricing with no charges for bandwidth or data size (no per-GB charges) and no lock-in contracts. +- **Security:** CloudDat for Akamai is not a service: you fully control your instance and storage. We never touch your data, giving you maximum security and control. +- **Easy-to-Use:** Setup takes just a few minutes and easy-to-use and deploy clients can be downloaded and installed in seconds. Clients require no installation or administrative privileges: just download and run. It takes mere minutes to install CloudDat's powerful object server on a new or existing compute partner instance. +- **Free Trials:** Obtain free trials of CloudDat for Akamai from Data Expedition, Inc. and see for yourself. + +### Use cases + +**Cloud Migration** + +Transfer bulk data from data-centers or cloud platforms into Akamai storage. Cross-platform CloudDat clients can be deployed for end-user or scripted data transfer. Each CloudDat server deployed in Akamai can receive data at up to three gigabits per second, allowing you to minimize total transfer time. + +**Customer Onboarding** + +New customers and projects often require uploading large amounts of data before work can begin. CloudDat clients are easy to deploy for end-users and automated systems, allowing you to complete this critical stage quickly and begin billable work sooner. + +**Cloud Ingest** + +Many cloud workflows require an ongoing stream of new data. Media post-production, industrial engineering, bioinformatics, and AI model training are just a few examples where cloud processes demand high-bandwidth inputs. + +Ready to try CloudDat® for Akamai for yourself? Contact the Data Expedition, Inc. team to get your Free Trial today. We'll work with you to discuss the specifics of your use case and workflow to recommend the best deployment model for CloudDat® for Akamai to help achieve your accelerated data transfer goals. Simple, easy-to-use, and cost-effective! +`.trim(); + +const documentationMarkdown = ` +| Specification | Details | +| :---- | :---- | +| **Deployment model** | Customer Deployed | +| **Linode services required** | Linux Compute | +| **Supported data storage** | Compute, Block, Object | +| **Client platforms supported** | Linux, Mac, Windows | +| **Security** | AES-128 In Transit, Customer deployed storage | +| **Maximum File/Object size** | 8 Exabytes | +| **Maximum total storage** | Unlimited Bytes / Unlimited Items | +| **Maximum bandwidth** | 3 Gigabits per second per Linode instance | +| **Maximum path latency** | 20,000 milliseconds default (higher with configuration) | +| **Maximum path packet loss** | 50% | + +Full documentation could be found [here](https://www.dataexpedition.com/clouddat/akamai/). + +CloudDat server software is hosted on an Akamai/Linode compute instance with access to a filesystem or object bucket in the same region. CloudDat clients accelerate the data transfer to and from the server, allowing for high performance over the WAN. + +![CloudDat Architecture](/assets/marketplace/clouddat.svg) +`.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 clouddat: ProductTabDetails = { + documentation: documentationMarkdown, + overview: overviewMarkdown, + 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 26c4b79f44d..66443ac020d 100644 --- a/packages/manager/src/features/Marketplace/ProductDetails/pages/index.ts +++ b/packages/manager/src/features/Marketplace/ProductDetails/pages/index.ts @@ -1,11 +1,13 @@ import { apiMetrics } from './api-metrics'; import { cambriaStream } from './cambria-stream'; import { cloudcasa } from './cloudcasa'; +import { clouddat } from './clouddat'; 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 { norskStudio } from './norsk-studio'; import { playback } from './playback'; import { portainer } from './portainer'; import { radSecurityPlatform } from './rad-security-platform'; @@ -34,6 +36,7 @@ export interface ProductTabDetails { const detailsMap: Record = { 'api-metrics': apiMetrics, 'cambria-stream': cambriaStream, + clouddat, cloudcasa, 'dynamic-ad-insertion': dynamicAdInsertion, 'hero-encoder': heroEncoder, @@ -41,6 +44,7 @@ const detailsMap: Record = { 'multiplayer-game-server-hosting-orchestration': multiplayerGameServerHostingOrchestration, myota, + 'norsk-studio': norskStudio, playback, portainer, 'rad-security-platform': radSecurityPlatform, diff --git a/packages/manager/src/features/Marketplace/ProductDetails/pages/norsk-studio.ts b/packages/manager/src/features/Marketplace/ProductDetails/pages/norsk-studio.ts new file mode 100644 index 00000000000..574b4db2dd5 --- /dev/null +++ b/packages/manager/src/features/Marketplace/ProductDetails/pages/norsk-studio.ts @@ -0,0 +1,72 @@ +/** + * Product tab details for slug norsk-studio. + * + * Content is provided as Markdown strings which are rendered at runtime. + */ + +import type { ProductTabDetails } from '.'; + +const overviewMarkdown = ` +Norsk Studio is a live video and audio streaming workflow server with a graphical drag-and-drop UI that delivers the essentials for implementing high-quality streaming workflows. It is a unique drag-and-drop platform for designing, deploying, and controlling broadcast-quality live streaming workflows. Ingest from any source, encode, package, and deliver to any destination - all from a visual interface. + +Norsk Studio empowers you to deliver compelling, custom, fault-tolerant, and monetizable live events and channels with the best quality available in a fraction of the time and cost normally required. + +Ideal for live event producers, broadcasters and media companies, streaming service operators, and video developers and systems integrators. + +* **Broad codec and format support:** Supports all common inputs and outputs, plus direct publishing to YouTube, Twitch, and other social platforms. +* **Wide range of production processors:** Onscreen graphic, browser overlay, picture-in-picture, automatic or operator-controlled source switching, live-to-VOD, ABR ladder, third-party DRM. +* **Flexible deployment:** Spin up directly on Akamai Cloud Compute or in a Docker or Kubernetes environment on your own Akamai Cloud Compute instance. +* **OpenAPI support:** Iterate and make changes programmatically at runtime in our built-in documentation interface or the OpenAPI interface of your choice. +* **Rich monitoring and reporting:** All workflows can be explored in detail using the Norsk Visualizer. Norsk Studio also supports OpenTelemetry and Fluent Bit. +* **Multimodal, multimodel AI support:** Use any major LLM or Norsk Studio's own MCPs to both create and control live production workflows and create custom dashboards. + +### Use cases + +**Live event production** + +Sports, esports, concerts, conferences, and worship services. Multi-camera production with source switching, graphics, and real-time monitoring. + +**Broadcast workflows** + +Build and operate custom streaming services with full ABR ladders, DRM, subtitles, and delivery to CDNs and social platforms. + +**Operator implementation** + +Run thousands of concurrent channels with fault-tolerant infrastructure, automated scaling, and per-event flexibility. + +Want to create and iterate customizable live streaming workflows in a fraction of the time and cost typically required? Get in touch with our team to share your unique requirements and see how straightforward it is to build workflows in Norsk Studio and learn about onboarding support with our Kickstart package. +`.trim(); + +const documentationMarkdown = ` +| Specification | Details | +| :---- | :---- | +| **Deployment model** | Cloud, on-prem, hybrid | +| **Supported inputs** | RTMP, SRT, WebRTC, NDI, SDI/HDMI via DeckLink, SMPTE ST 2110 | +| **Supported outputs** | HLS, DASH, CMAF, WebRTC, RTMP (YouTube, Twitch, LinkedIn Live) | +| **Codec support** | H.264, H.265/HEVC, AV1, AAC, Opus | +| **Latency** | Workflow-dependent: sub-second with WebRTC/SRT; 2\u20136s with low-latency HLS; standard HLS per CDN configuration | +| **AI integration** | Model Context Protocol (MCP) server for AI-assisted workflow control; compatible with OpenAI, Gemini, and Anthropic | +| **Security** | TLS 1.3 in transit; runs within customer-controlled cloud VPC or on-premises infrastructure | +| **License model** | Subscription-based (per channel, or enterprise) | + +![Norsk Platform Architecture](/assets/marketplace/norsk_platform_architecture.svg) + +![Norsk Process Flow](/assets/marketplace/norsk_process_flow.svg) +`.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 norskStudio: ProductTabDetails = { + documentation: documentationMarkdown, + overview: overviewMarkdown, + pricing: pricingMarkdown, + support: supportMarkdown, +}; diff --git a/packages/manager/src/features/Marketplace/ProductDetails/pages/sftpgo.ts b/packages/manager/src/features/Marketplace/ProductDetails/pages/sftpgo.ts index b0c9ce93cee..0449740c30d 100644 --- a/packages/manager/src/features/Marketplace/ProductDetails/pages/sftpgo.ts +++ b/packages/manager/src/features/Marketplace/ProductDetails/pages/sftpgo.ts @@ -7,47 +7,48 @@ import type { ProductTabDetails } from '.'; const overviewMarkdown = ` -SFTPGo provides a fully managed, secure Managed File Transfer (MFT) solution designed for organizations that require professional file exchange without the burden of infrastructure management. Unlike standard shared hosting, SFTPGo delivers a dedicated and isolated installation for every customer, ensuring maximum security and performance. Each environment is automatically deployed in the user's selected region, providing a turnkey solution that is ready to use in minutes with simple, predictable pricing. +SFTPGo is an enterprise-grade Managed File Transfer (MFT) solution designed for organizations that require high-performance file exchange without the overhead of infrastructure management. -The service supports a comprehensive suite of protocols, including SFTP, FTP, FTPS, and WebDAV, complemented by an intuitive WebClient for non-technical users. Every plan includes a dedicated S3-compatible storage quota, yet the platform remains storage-agnostic, allowing you to "Bring Your Own Storage" from providers like Azure Blob, GCS, or S3. Administrators can manage the entire system through a powerful WebAdmin interface, which offers granular access controls, real-time monitoring, and no software limits on the number of users or admins. +By delivering a **single-tenant, isolated architecture** for every customer, SFTPGo ensures dedicated resources, localized **data residency**, and maximum security - entirely free from the performance bottlenecks of multi-tenant environments. Deploy your environment in minutes and transform a passive storage service into an **active data pipeline**. -A primary differentiator is the integrated Event Manager, a powerful automation engine that executes conditional "if-this-then-that" actions based on system activity. Administrators can easily define rules to trigger real-time webhooks, send notifications, or automate complex tasks such as PGP encryption, and automated data retention policies. This transforms a passive storage service into an active data pipeline, seamlessly integrating secure file transfers into your existing business workflows. +### **Key Features** -### **Key features** +* **Multi-Protocol Access:** Secure transfers via SFTP, SCP, FTP, FTPS, and WebDAV, plus an intuitive WebClient for non-technical users. +* **Secure Public Sharing:** Collaborate effortlessly with external partners using unique, web-accessible links. Protect shares with passwords, expiration dates, or email-based authentication (OTP) without requiring account creation. +* **Storage Agnostic (BYOS):** Includes S3-compatible storage, but allows you to "Bring Your Own Storage" from Azure Blob, Google Cloud Storage, or AWS S3 (Compatible). +* **Smart Event Automation:** An integrated engine to trigger webhooks, notifications, and PGP tasks based on file activity, schedules, or Identity provider login events. It streamlines governance with automated lifecycle management - automatically handling inactivity, expirations, and usage limits for both users and public shares - and supports Just-in-Time provisioning from templates immediately after SSO login. +* **Identity & Security:** Native SSO (OpenID Connect), 2FA, and granular RBAC. Integrate with ICAP servers for real-time antivirus scanning and DLP checks. +* **Compliance Ready:** Simplify GDPR and HIPAA audits with comprehensive logs, reporting, and automated data retention policies. +* **No Software Limits:** Scale freely without restrictions on the number of users or administrators. -* **Dedicated Infrastructure:** Ensure maximum security and performance with a fully isolated, dedicated installation and dedicated resources for every customer environment. -* **Regional Data Residency:** Meet strict compliance requirements by deploying your dedicated instance in your preferred geographic region for localized data sovereignty. -* **Hybrid Storage Management:** Simplify data centralization by using the included S3-compatible storage or connecting your own backends like Azure, GCS, and S3. -* **Unified File Access:** Enable secure file exchange via SFTP, FTPS, and WebDAV, or provide non-technical users with an intuitive WebClient. -* **Smart Event Automation:** Accelerate data pipelines with an integrated Event Manager that triggers webhooks, notifications, and PGP encryption or decryption based on real-time file activity. -* **Enterprise-Grade Protection:** Secure sensitive data with encryption at rest, two-factor authentication (2FA), and the flexibility to integrate advanced antivirus or DLP scanning workflows. -* **Advanced Identity Integration:** Streamline user management and secure access with native support for SSO (OpenID Connect). -* **Compliance Readiness:** Accelerate your audit processes with comprehensive logs, reporting, and automated data retention rules tailored for GDPR and HIPAA standards. +### **Use Cases** -### **Use cases** +#### **Data Sovereignty & Compliance** -**Secure Data Sovereignty and Compliance** -Deploy dedicated, isolated instances in specific geographic regions to meet strict GDPR, HIPAA, or local data residency requirements. Utilize built-in audit logs, PGP encryption, and automated data retention rules to ensure that sensitive files are managed and purged according to regulatory standards without manual intervention. +Deploy dedicated instances in specific geographic regions to meet strict local data residency requirements. Use PGP encryption and automated retention rules to ensure sensitive files are managed according to regulatory standards. -**Automated Cloud Data Ingestion** -Streamline business workflows by using the Event Manager to automatically trigger webhooks or move files to cloud storage backends like S3, Google Cloud Storage, or Azure Blob upon upload. This transforms a standard SFTP server into an active data pipeline, allowing your internal systems to react instantly to incoming data from partners or IoT devices. +#### **Automated Cloud Data Ingestion** -**Secure External Partner Collaboration** -Provide non-technical partners with a secure, branded WebClient for browser-based file exchange and link sharing, protected by Single Sign-On (SSO) or Multi-Factor Authentication. Implement Role-Based Access Control (RBAC) to enforce strict data isolation with per-user and per-directory permissions, ensuring collaborators only access authorized files. This granular control allows you to define specific actions (read, write, delete) at the folder level, preventing unauthorized data exposure within your dedicated environment. +Use the Event Manager to trigger real-time webhooks or move files to cloud backends (S3, GCS, Azure) or external SFTP/FTP servers upon upload, download, or schedule. Perfect for reacting instantly to data from partners or IoT devices. -**Hybrid Cloud Storage Gateway** -Use SFTPGo as a unified gateway to access and manage files across different cloud providers using legacy protocols like SFTP, FTP, or WebDAV. By abstracting the underlying storage (S3-compatible, Azure Blob, GCS, other SFTP servers), you can consolidate fragmented data sources into a single, manageable interface for your legacy applications and modern cloud services +#### **Secure Partner Collaboration** -Experience a secure, dedicated MFT environment today with a 10-day free trial included in all plans. Simply select the plan that best fits your needs and choose your preferred deployment region; your isolated instance will be provisioned automatically and ready for use in minutes. If you require a custom architecture, specialized compliance configurations, or would like to see a live demo, our team is available to help you design a proof-of-concept tailored to your specific business workflows. +Provide external partners with a branded WebClient or Public Shares. Enforce strict isolation with per-directory permissions and protect access via SSO or Email OTP, ensuring collaborators see only authorized files. + +#### **Hybrid Cloud Storage Gateway** + +Consolidate fragmented data sources into a single entry point. SFTPGo acts as a unified bridge, allowing legacy applications to interact with modern object storage (S3, Azure, GCS) via standard protocols like SFTP and WebDAV, while providing users with a feature-rich, responsive WebClient. + +Experience a secure, dedicated MFT environment today with a 10-day free trial included in all plans. Simply select your plan and region; your isolated instance will be provisioned automatically. Need a custom architecture or a live demo? Our team is available to help you design a proof-of-concept tailored to your specific workflows. `.trim(); const documentationMarkdown = ` | Specification | Details | | :---- | :---- | | **Deployment Model** | SaaS. Fully managed dedicated instances | -| **Supported Protocols** | SFTP, FTPS, WebDAV, and HTTPS (WebClient) | +| **Supported Protocols** | SFTP, SCP, FTPS, WebDAV, and HTTPS (WebClient) | | **Storage Backends** | Integrated S3-compatible storage plus Azure Blob, GCS, S3, and other SFTP/FTP servers | -| **Authentication** | Multi-factor (2FA), SSO (OpenID Connect/SAML), and LDAP/Active Directory integration | +| **Authentication** | Multi-factor (2FA), SSO (OpenID Connect), and LDAP/Active Directory integration | | **Automation Engine** | Native EventManager (HTTP Hooks, Email, Filesystem actions) | | **Advanced Security** | PGP Encryption and Decryption, ICAP support (Antivirus/DLP), Brute force protection | | **API & DevOps** | Comprehensive REST API and official Terraform Provider | @@ -57,15 +58,24 @@ const documentationMarkdown = ` ![SFTPGo architecture](/assets/marketplace/sftpgo-architecture.jpeg) -### **Dedicated and Automated Managed File Transfer Workflow** +### **Dedicated Managed File Transfer Architecture** + +Every deployment provides a single-tenant, isolated instance to ensure maximum security and performance. The architecture is built on four pillars: -Every customer receives an isolated and dedicated installation for secure and high-performance file management. The architecture offers flexible identity management, featuring built-in authentication with Two-Factor Authentication support or centralized access through Single Sign-On integration. It features a storage-agnostic design that connects to S3-compatible storage, Microsoft Azure Blob, and Google Cloud Storage. An integrated event-driven engine automates notifications and data workflows triggered by file activity, fully controllable through a comprehensive Application Programming Interface. +* **Flexible Identity Management:** Local authentication with MFA, centralized access via SSO (OpenID Connect), or guest access via Email OTP for Public Shares. +* **Storage-Agnostic Design:** Native integration with S3-compatible backends, Microsoft Azure Blob, and Google Cloud Storage. +* **Event-Driven Automation:** An integrated engine to trigger notifications and data workflows, fully controllable via a comprehensive REST API. +* **Security Orchestration:** Support for high-performance protocols and integration with ICAP servers for antivirus and DLP inspection. -### **Automated Data Ingestion and Event-Driven Flow** +### **Data Ingestion & Automation Flow** -The automated file transfer flow begins when a client initiates a connection using the Secure File Transfer Protocol or a secure web browser. Incoming traffic is routed to a dedicated and isolated installation to ensure security and data separation. After the user is authenticated, file operations are processed in real-time within the dedicated environment. +The following steps outline the automated lifecycle of a file within SFTPGo: -Following a successful file upload, the integrated event engine triggers predefined actions - such as email notifications or automated tasks - to streamline business workflows without manual intervention. Simultaneously, data is securely stored on the included S3-compatible backend or a preferred cloud storage provider such as Microsoft Azure Blob or Google Cloud Storage. +* **Secure Connection:** A client connects via SFTP/SCP/FTPS/WebDAV or a branded WebClient. +* **Isolated Processing:** Traffic is routed to your dedicated instance, ensuring complete data separation from other customers. +* **Real-Time Authentication:** The system validates credentials against the internal database or your external identity provider (SSO). +* **Active Event Triggering:** Upon upload, the Event Manager can stream the file to an ICAP server for scanning, execute PGP tasks, or fire webhooks. +* **Secure Persistence:** Files are stored on your chosen backend with encryption at rest. The Event Manager ensures continuous compliance by automatically enforcing per-folder data retention rules and managing the full lifecycle of users and public shares - including inactivity-based deletion, password expiration, and token-limited access. `.trim(); const pricingMarkdown = ` diff --git a/packages/manager/src/features/Marketplace/products.ts b/packages/manager/src/features/Marketplace/products.ts index c5b205ed88f..29f492f5f26 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: ['Networking', 'Storage'], + id: 'clouddat', + name: 'CloudDat\u00AE for Akamai', + partner: { + email: 'akamai@dataexpedition.com', + logoDarkMode: 'data-expedition-dark.svg', + logoLightMode: 'data-expedition-light.svg', + name: 'Data Expedition, Inc.\u00AE', + url: 'https://www.dataexpedition.com/clouddat/akamai/', + }, + shortDescription: + 'CloudDat\u00AE for Akamai is a secure accelerated file transfer server that lets you upload and download files and objects at gigabits per second from anywhere in the world.', + type: { + name: 'Virtual Machines', + }, + }, { categories: ['Kubernetes', 'Enterprise', 'Other Software and APIs'], id: 'cloudcasa', @@ -140,6 +157,31 @@ export const PRODUCTS: Product[] = [ name: 'SaaS & APIs', }, }, + { + categories: [ + 'Data Analytics', + 'Database Management', + 'Data Sources', + 'Enterprise', + 'Kubernetes', + 'Other Software and APIs', + 'Storage', + ], + id: 'norsk-studio', + name: 'Norsk Studio', + partner: { + email: 'sales@norsk.video', + logoDarkMode: 'Norsk-dark.svg', + logoLightMode: 'Norsk-light.svg', + name: 'Norsk', + url: 'https://norsk.video/', + }, + shortDescription: + 'Norsk Studio is a live video and audio streaming workflow server with a graphical drag-and-drop UI that delivers the essentials for implementing high-quality streaming workflows.', + type: { + name: 'SaaS & APIs', + }, + }, { categories: [ 'Data Analytics', @@ -226,7 +268,12 @@ export const PRODUCTS: Product[] = [ }, }, { - categories: ['Storage', 'Other Software and APIs'], + categories: [ + 'Storage', + 'Networking', + 'Enterprise', + 'Other Software and APIs', + ], id: 'sftpgo', name: 'SFTPGo', partner: { @@ -239,7 +286,7 @@ export const PRODUCTS: Product[] = [ shortDescription: 'MFT supporting SFTP, FTP and WebDAV. Features SSO WebClient for user access and WebAdmin for management. Includes S3-compatible storage or connect your own cloud backends', type: { - name: 'SaaS & APIs', + name: 'Kubernetes', }, }, { diff --git a/packages/manager/src/features/Marketplace/shared.ts b/packages/manager/src/features/Marketplace/shared.tsx similarity index 70% rename from packages/manager/src/features/Marketplace/shared.ts rename to packages/manager/src/features/Marketplace/shared.tsx index 3029723c37b..4bb7d6822bd 100644 --- a/packages/manager/src/features/Marketplace/shared.ts +++ b/packages/manager/src/features/Marketplace/shared.tsx @@ -1,3 +1,5 @@ +import React from 'react'; + import { useFlags } from 'src/hooks/useFlags'; import type { SxProps, Theme } from '@mui/material/styles'; @@ -66,6 +68,37 @@ export const useIsMarketplaceV2Enabled = () => { }; }; +/** + * Formats trademark symbols (e.g. ®) in a string as superscript. + * Returns the original string unchanged if no trademark symbols are found. + * When symbols are present, returns a wrapping the text with + * styled elements. Using a single wrapper ensures the result + * behaves as one inline/flex child and avoids layout misalignment + * in flex containers like card headings. + */ +export const formatTrademarkSymbols = ( + text: string +): React.ReactElement | string => { + if (!text.includes('\u00AE')) { + return text; + } + + const parts = text.split('\u00AE'); + + return ( + + {parts.map((part, index) => ( + + {part} + {index < parts.length - 1 && ( + {'\u00AE'} + )} + + ))} + + ); +}; + export const getLogoUrl = (product: Product, theme: Theme) => { const base = '/assets/marketplace/'; return theme.name === 'light' diff --git a/packages/manager/src/features/OneClickApps/oneClickApps.ts b/packages/manager/src/features/OneClickApps/oneClickApps.ts index 0d4ec40f5f2..31c812da060 100644 --- a/packages/manager/src/features/OneClickApps/oneClickApps.ts +++ b/packages/manager/src/features/OneClickApps/oneClickApps.ts @@ -2301,15 +2301,15 @@ export const oneClickApps: Record = { }, 1997012: { alt_description: - 'Lightweight open large language model optimized for efficient AI inference.', - alt_name: 'Open large language model', + 'Lightweight open-source large language model optimized for efficient AI inference.', + alt_name: 'Open-source 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.', + 'Gemma 3 is an open-source 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: [ @@ -2319,20 +2319,20 @@ export const oneClickApps: Record = { }, ], summary: - 'Efficient open large language model for self-hosted AI applications.', + 'Efficient open-source 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', + 'Open-source large language model series for advanced reasoning, coding, and chat.', + alt_name: 'Open-source 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.', + 'Qwen is an open-source 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: [ @@ -2342,7 +2342,52 @@ export const oneClickApps: Record = { }, ], summary: - 'Open large language model for reasoning, coding, and chat applications.', + 'Open-source large language model for reasoning, coding, and chat applications.', website: 'https://qwenlm.github.io/', }, + 2025976: { + alt_description: 'Open-source Large Language Model.', + alt_name: + 'Open-source and light weight reasoning LLM optimized for complex problem solving, code generation, and logical reasoning tasks.', + categories: ['LLM', 'Chat', 'AI'], + colors: { + end: '4D6BFE', + start: '4D6BFE', + }, + description: + 'DeepSeek-R1 is deployed on a cloud compute instance to provide scalable inference for reasoning and code generation tasks. The VM environment hosts the model runtime through vLLM which allows efficient access to the model via Open WebUI chat service.', + isNew: true, + logo_url: 'deepseek.svg', + related_guides: [ + { + href: 'https://www.linode.com/docs/marketplace-docs/guides/deepseek-with-openwebui/', + title: 'Deploy DeepSeek-R1 with Open WebUI', + }, + ], + summary: + 'Open-source Large Language Model for reasoning, coding, and chat applications.', + website: 'https://huggingface.co/deepseek-ai', + }, + 2049320: { + alt_description: 'Autonomous AI agent', + alt_name: + 'Open-source autonomous AI agent that runs locally and executes tasks through a persistent Gateway service', + categories: ['AI', 'AI Agent', 'LLM'], + colors: { + end: 'AF2626', + start: 'D53838', + }, + description: + 'OpenClaw is an open-source AI agent platform that runs locally and executes tasks through a persistent Gateway service. The Gateway connects communication channels, tools, and AI models, allowing the agent to receive messages, perform actions, and automate workflows.', + isNew: true, + logo_url: 'openclaw.svg', + related_guides: [ + { + href: 'https://www.linode.com/docs/marketplace-docs/guides/openclaw/', + title: 'Deploy OpenClaw', + }, + ], + summary: 'Autonomous AI agent.', + website: 'https://openclaw.ai/', + }, }; diff --git a/packages/manager/src/features/OneClickApps/types.ts b/packages/manager/src/features/OneClickApps/types.ts index 388adcfaaea..4f7eaf7560c 100644 --- a/packages/manager/src/features/OneClickApps/types.ts +++ b/packages/manager/src/features/OneClickApps/types.ts @@ -29,6 +29,7 @@ export interface Colors { export type AppCategory = | 'AI' + | 'AI Agent' | 'App Creators' | 'Chat' | 'Control Panels' diff --git a/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLanding.tsx b/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLanding.tsx new file mode 100644 index 00000000000..5b0824ce308 --- /dev/null +++ b/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLanding.tsx @@ -0,0 +1,20 @@ +import { Notice } from '@linode/ui'; +import * as React from 'react'; + +import { LandingHeader } from 'src/components/LandingHeader'; + +export const ReservedIpsLanding = () => { + return ( + <> + + Reserved IPs is coming soon... + + ); +}; diff --git a/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLazyRoute.tsx b/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLazyRoute.tsx new file mode 100644 index 00000000000..856f4936035 --- /dev/null +++ b/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLazyRoute.tsx @@ -0,0 +1,7 @@ +import { createLazyRoute } from '@tanstack/react-router'; + +import { ReservedIpsLanding } from './ReservedIpsLanding'; + +export const reservedIpsLazyRoute = createLazyRoute('/reserved-ips')({ + component: ReservedIpsLanding, +}); diff --git a/packages/manager/src/features/ReservedIps/utils.test.ts b/packages/manager/src/features/ReservedIps/utils.test.ts new file mode 100644 index 00000000000..2c559758305 --- /dev/null +++ b/packages/manager/src/features/ReservedIps/utils.test.ts @@ -0,0 +1,31 @@ +import { renderHook, waitFor } from '@testing-library/react'; + +import { wrapWithTheme } from 'src/utilities/testHelpers'; + +import { useIsReserveIpEnabled } from './utils'; + +describe('useIsReserveIpEnabled', () => { + it('returns true if the feature is enabled', async () => { + const options = { flags: { reserveIp: true } }; + + const { result } = renderHook(() => useIsReserveIpEnabled(), { + wrapper: (ui) => wrapWithTheme(ui, options), + }); + + await waitFor(() => { + expect(result.current.isReserveIpEnabled).toBe(true); + }); + }); + + it('returns false if the feature is NOT enabled', async () => { + const options = { flags: { reserveIp: false } }; + + const { result } = renderHook(() => useIsReserveIpEnabled(), { + wrapper: (ui) => wrapWithTheme(ui, options), + }); + + await waitFor(() => { + expect(result.current.isReserveIpEnabled).toBe(false); + }); + }); +}); diff --git a/packages/manager/src/features/ReservedIps/utils.ts b/packages/manager/src/features/ReservedIps/utils.ts new file mode 100644 index 00000000000..4a22fe7b09b --- /dev/null +++ b/packages/manager/src/features/ReservedIps/utils.ts @@ -0,0 +1,13 @@ +import { useFlags } from 'src/hooks/useFlags'; + +/** + * + * @returns an object that contains boolean property to check whether Reserved IP is enabled or not + */ +export const useIsReserveIpEnabled = () => { + const flags = useFlags(); + + // @TODO ReservedIps: check for customer tag/account capability when it exists + + return { isReserveIpEnabled: flags.reserveIp ?? false }; +}; diff --git a/packages/manager/src/mocks/presets/crud/handlers/delivery.ts b/packages/manager/src/mocks/presets/crud/handlers/delivery.ts index 5b0e39e0a7e..3c96e34726c 100644 --- a/packages/manager/src/mocks/presets/crud/handlers/delivery.ts +++ b/packages/manager/src/mocks/presets/crud/handlers/delivery.ts @@ -230,8 +230,6 @@ export const createDestinations = (mockState: MockState) => [ const payload: CreateDestinationPayload = await request.clone().json(); const { label, type, details } = payload; - const authenticationDetails = (details as CustomHTTPSDetailsExtended) - .authentication?.details; const created = DateTime.now().toISO(); const updated = DateTime.now().toISO(); @@ -258,11 +256,7 @@ export const createDestinations = (mockState: MockState) => [ ...details, authentication: { ...(details as CustomHTTPSDetailsExtended).authentication, - details: authenticationDetails - ? omitProps(authenticationDetails, [ - 'basic_authentication_password', - ]) - : undefined, + details: undefined, }, }, created, diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index 639157a974b..a9e4d003c50 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -1053,20 +1053,25 @@ export const handlers = [ ]; const aclpSupportedRegionLinodes = [ linodeFactory.build({ - label: 'aclp-supported-region-linode-1', + label: 'aclp-supported-region-only-aclp-alerts-linode', region: 'us-iad', id: 1004, }), linodeFactory.build({ - label: 'aclp-supported-region-linode-2', + label: 'aclp-supported-region-only-legacy-alerts-linode', region: 'us-east', id: 1005, }), linodeFactory.build({ - label: 'aclp-supported-region-linode-3', + label: 'aclp-supported-region-no-alerts-linode', region: 'us-iad', id: 1006, }), + linodeFactory.build({ + label: 'aclp-supported-region-both-alerts-linode', + region: 'us-east', + id: 1007, + }), ]; const linodeFirewall = linodeFactory.build({ region: 'ap-west', @@ -1244,13 +1249,11 @@ export const handlers = [ }), ]; const linodeAclpSupportedRegionDetails = [ - /** Whether a Linode is ACLP-subscribed can be determined using the useIsLinodeAclpSubscribed hook. */ - - // 1. Example: ACLP-subscribed Linode in an ACLP-supported region (mock Linode ID: 1004) + // 1. Example: Linode with ACLP alerts in an ACLP-supported region (mock Linode ID: 1004) linodeFactory.build({ id, backups: { enabled: false }, - label: 'aclp-supported-region-linode-1', + label: 'aclp-supported-region-only-aclp-alerts-linode', region: 'us-iad', alerts: { user_alerts: [21, 22, 23, 24, 25], @@ -1262,11 +1265,11 @@ export const handlers = [ transfer_quota: 0, }, }), - // 2. Example: Linode not subscribed to ACLP in an ACLP-supported region (mock Linode ID: 1005) + // 2. Example: Linode with only Legacy Alerts in an ACLP-supported region (mock Linode ID: 1005) linodeFactory.build({ id, backups: { enabled: false }, - label: 'aclp-supported-region-linode-2', + label: 'aclp-supported-region-only-legacy-alerts-linode', region: 'us-east', alerts: { user_alerts: [], @@ -1279,13 +1282,10 @@ export const handlers = [ }, }), // 3. Example: Linode in an ACLP-supported region with NO enabled alerts (mock Linode ID: 1006) - // - Whether this Linode is ACLP-subscribed depends on the ACLP release stage: - // a. Beta stage: NOT subscribed to ACLP - // b. GA stage: Subscribed to ACLP linodeFactory.build({ id, backups: { enabled: false }, - label: 'aclp-supported-region-linode-3', + label: 'aclp-supported-region-no-alerts-linode', region: 'us-iad', alerts: { user_alerts: [], @@ -1297,6 +1297,22 @@ export const handlers = [ transfer_quota: 0, }, }), + // 4. Example: Linode with both ACLP and Legacy Alerts in an ACLP-supported region (mock Linode ID: 1007) + linodeFactory.build({ + id, + backups: { enabled: false }, + label: 'aclp-supported-region-both-alerts-linode', + region: 'us-east', + alerts: { + user_alerts: [21, 22, 23, 24, 25], + system_alerts: [19, 20], + cpu: 90, + io: 90000, + network_in: 0, + network_out: 0, + transfer_quota: 90, + }, + }), ]; const linodeNonMTCPlanInMTCSupportedRegionsDetail = linodeFactory.build({ id, @@ -1332,6 +1348,8 @@ export const handlers = [ return linodeAclpSupportedRegionDetails[1]; case 1006: return linodeAclpSupportedRegionDetails[2]; + case 1007: + return linodeAclpSupportedRegionDetails[3]; default: return linodeDetail; } @@ -3383,13 +3401,14 @@ export const handlers = [ }, service_type: serviceType === 'dbaas' ? 'dbaas' : 'linode', }), - // Mocked 2 alert definitions associated with mock Linode ID '1004' (aclp-supported-region-linode-1) + // Mocked 2 alert definitions associated with mock Linode IDs '1004' and '1007' + // (aclp-supported-region-only-aclp-alerts-linode & aclp-supported-region-both-alerts-linode) ...alertFactory.buildList(2, { rule_criteria: { rules: alertRulesFactory.buildList(2), }, service_type: serviceType === 'dbaas' ? 'dbaas' : 'linode', - entity_ids: ['1004'], + entity_ids: ['1004', '1007'], }), ...alertFactory.buildList(6, { service_type: serviceType === 'dbaas' ? 'dbaas' : 'linode', diff --git a/packages/manager/src/routes/index.tsx b/packages/manager/src/routes/index.tsx index 5565d3459e8..2c3ff935a35 100644 --- a/packages/manager/src/routes/index.tsx +++ b/packages/manager/src/routes/index.tsx @@ -37,6 +37,7 @@ import { objectStorageRouteTree } from './objectStorage'; import { placementGroupsRouteTree } from './placementGroups'; import { profileRouteTree } from './profile'; import { quotasRouteTree } from './quotas'; +import { reservedIpsRouteTree } from './reservedIps'; import { rootRoute } from './root'; import { searchRouteTree } from './search'; import { serviceTransfersRouteTree } from './serviceTransfers'; @@ -88,6 +89,7 @@ export const routeTree = rootRoute.addChildren([ placementGroupsRouteTree, profileRouteTree, quotasRouteTree, + reservedIpsRouteTree, searchRouteTree, serviceTransfersRouteTree, settingsRouteTree, diff --git a/packages/manager/src/routes/reservedIps/index.ts b/packages/manager/src/routes/reservedIps/index.ts new file mode 100644 index 00000000000..076c09269ea --- /dev/null +++ b/packages/manager/src/routes/reservedIps/index.ts @@ -0,0 +1,23 @@ +import { createRoute } from '@tanstack/react-router'; + +import { rootRoute } from '../root'; +import { ReservedIpsRoute } from './reservedIpsRoute'; + +const reservedIpsRoute = createRoute({ + component: ReservedIpsRoute, + getParentRoute: () => rootRoute, + path: 'reserved-ips', +}); + +const reservedIpsIndexRoute = createRoute({ + getParentRoute: () => reservedIpsRoute, + path: '/', +}).lazy(() => + import( + 'src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLazyRoute' + ).then((m) => m.reservedIpsLazyRoute) +); + +export const reservedIpsRouteTree = reservedIpsRoute.addChildren([ + reservedIpsIndexRoute, +]); diff --git a/packages/manager/src/routes/reservedIps/reservedIpsRoute.tsx b/packages/manager/src/routes/reservedIps/reservedIpsRoute.tsx new file mode 100644 index 00000000000..ea097382ac5 --- /dev/null +++ b/packages/manager/src/routes/reservedIps/reservedIpsRoute.tsx @@ -0,0 +1,21 @@ +import { NotFound } from '@linode/ui'; +import { Outlet } from '@tanstack/react-router'; +import React from 'react'; + +import { ProductInformationBanner } from 'src/components/ProductInformationBanner/ProductInformationBanner'; +import { SuspenseLoader } from 'src/components/SuspenseLoader'; +import { useIsReserveIpEnabled } from 'src/features/ReservedIps/utils'; + +export const ReservedIpsRoute = () => { + const { isReserveIpEnabled } = useIsReserveIpEnabled(); + + if (!isReserveIpEnabled) { + return ; + } + return ( + }> + + + + ); +}; diff --git a/packages/queries/.changeset/pr-13489-changed-1773925015927.md b/packages/queries/.changeset/pr-13489-changed-1773925015927.md new file mode 100644 index 00000000000..2c7edbd373d --- /dev/null +++ b/packages/queries/.changeset/pr-13489-changed-1773925015927.md @@ -0,0 +1,5 @@ +--- +"@linode/queries": Changed +--- + +Keep previous data in streams and destinations paginated queries ([#13489](https://github.com/linode/manager/pull/13489)) diff --git a/packages/queries/.changeset/pr-13506-upcoming-features-1774367984030.md b/packages/queries/.changeset/pr-13506-upcoming-features-1774367984030.md new file mode 100644 index 00000000000..5cca521592e --- /dev/null +++ b/packages/queries/.changeset/pr-13506-upcoming-features-1774367984030.md @@ -0,0 +1,5 @@ +--- +"@linode/queries": Upcoming Features +--- + +Implement share groups queries ([#13506](https://github.com/linode/manager/pull/13506)) diff --git a/packages/queries/.changeset/pr-13517-upcoming-features-1774273217324.md b/packages/queries/.changeset/pr-13517-upcoming-features-1774273217324.md new file mode 100644 index 00000000000..6181a4efa73 --- /dev/null +++ b/packages/queries/.changeset/pr-13517-upcoming-features-1774273217324.md @@ -0,0 +1,5 @@ +--- +"@linode/queries": Upcoming Features +--- + +Reserved IPs: Added queries for Reserved IPs ([#13517](https://github.com/linode/manager/pull/13517)) diff --git a/packages/queries/src/delivery/delivery.ts b/packages/queries/src/delivery/delivery.ts index 503947d666b..dec14506c98 100644 --- a/packages/queries/src/delivery/delivery.ts +++ b/packages/queries/src/delivery/delivery.ts @@ -15,6 +15,7 @@ import { profileQueries } from '@linode/queries'; import { getAll } from '@linode/utilities'; import { createQueryKeys } from '@lukemorales/query-key-factory'; import { + keepPreviousData, useInfiniteQuery, useMutation, useQuery, @@ -103,6 +104,7 @@ export const deliveryQueries = createQueryKeys('delivery', { export const useStreamsQuery = (params: Params = {}, filter: Filter = {}) => useQuery, APIError[]>({ ...deliveryQueries.streams._ctx.paginated(params, filter), + placeholderData: keepPreviousData, }); export const useAllStreamsQuery = ( @@ -219,6 +221,7 @@ export const useDestinationsQuery = ( ) => useQuery, APIError[]>({ ...deliveryQueries.destinations._ctx.paginated(params, filter), + placeholderData: keepPreviousData, }); export const useDestinationsInfiniteQuery = ( diff --git a/packages/queries/src/images/index.ts b/packages/queries/src/images/index.ts index 67e4bd303d9..07872769115 100644 --- a/packages/queries/src/images/index.ts +++ b/packages/queries/src/images/index.ts @@ -1 +1,2 @@ export * from './images'; +export * from './sharegroups'; diff --git a/packages/queries/src/images/sharegroups.ts b/packages/queries/src/images/sharegroups.ts new file mode 100644 index 00000000000..e03fc06eb1c --- /dev/null +++ b/packages/queries/src/images/sharegroups.ts @@ -0,0 +1,97 @@ +import { getSharegroup, getSharegroups } from '@linode/api-v4'; +import { getAll } from '@linode/utilities'; +import { createQueryKeys } from '@lukemorales/query-key-factory'; +import { + keepPreviousData, + useInfiniteQuery, + useQuery, +} from '@tanstack/react-query'; + +import type { + APIError, + Filter, + Params, + ResourcePage, + Sharegroup, +} from '@linode/api-v4'; +import type { UseQueryOptions } from '@tanstack/react-query'; + +export const getAllShareGroups = ( + passedParams: Params = {}, + passedFilter: Filter = {}, +) => + getAll((params, filter) => + getSharegroups( + { ...params, ...passedParams }, + { ...filter, ...passedFilter }, + ), + )().then((data) => data.data); + +export const shareGroupsQueries = createQueryKeys('sharegroups', { + sharegroups: { + contextQueries: { + all: (params: Params = {}, filters: Filter = {}) => ({ + queryFn: () => getAllShareGroups(params, filters), + queryKey: [params, filters], + }), + sharegroup: (sharegroupId: string) => ({ + queryFn: () => getSharegroup(sharegroupId), + queryKey: [sharegroupId], + }), + infinite: (filters: Filter) => ({ + queryFn: ({ pageParam }) => + getSharegroups({ page: pageParam as number }, filters), + queryKey: [filters], + }), + paginated: (params: Params, filters: Filter) => ({ + queryFn: () => getSharegroups(params, filters), + queryKey: [params, filters], + }), + }, + queryKey: null, + }, +}); + +export const useShareGroupsQuery = ( + params: Params, + filters: Filter, + options?: Partial, APIError[]>>, +) => + useQuery, APIError[]>({ + ...shareGroupsQueries.sharegroups._ctx.paginated(params, filters), + placeholderData: keepPreviousData, + ...options, + }); + +export const useShareGroupQuery = (sharegroupId: string, enabled = true) => + useQuery({ + ...shareGroupsQueries.sharegroups._ctx.sharegroup(sharegroupId), + enabled, + }); + +export const useAllShareGroupsQuery = ( + params: Params = {}, + filters: Filter = {}, + enabled: true, +) => + useQuery({ + ...shareGroupsQueries.sharegroups._ctx.all(params, filters), + enabled, + }); + +export const useShareGroupsInfiniteQuery = ( + filters: Filter, + enabled: boolean, +) => + useInfiniteQuery, APIError[]>({ + ...shareGroupsQueries.sharegroups._ctx.infinite(filters), + enabled, + getNextPageParam: ({ page, pages }) => { + if (page === pages) { + return undefined; + } + return page + 1; + }, + initialPageParam: 1, + retry: false, + }); diff --git a/packages/queries/src/networking/networking.ts b/packages/queries/src/networking/networking.ts index 4a95a56b4fb..53d304786fe 100644 --- a/packages/queries/src/networking/networking.ts +++ b/packages/queries/src/networking/networking.ts @@ -1,6 +1,15 @@ -import { createIPv6Range, getIPv6RangeInfo } from '@linode/api-v4'; +import { + createIPv6Range, + getIPv6RangeInfo, + getReservedIP, + getReservedIPs, + reserveIP, + unReserveIP, + updateReservedIP, +} from '@linode/api-v4'; import { createQueryKeys } from '@lukemorales/query-key-factory'; import { + keepPreviousData, useMutation, useQueries, useQuery, @@ -19,6 +28,8 @@ import type { IPRange, IPRangeInformation, Params, + ReserveIPPayload, + ResourcePage, } from '@linode/api-v4'; export const networkingQueries = createQueryKeys('networking', { @@ -39,6 +50,14 @@ export const networkingQueries = createQueryKeys('networking', { }, queryKey: null, }, + reservedIPs: (params: Params = {}, filter: Filter = {}) => ({ + queryFn: () => getReservedIPs(params, filter), + queryKey: [params, filter], + }), + reservedIP: (address: string) => ({ + queryFn: () => getReservedIP(address), + queryKey: [address], + }), }); export const useAllIPsQuery = ( @@ -119,3 +138,72 @@ export const useCreateIPv6RangeMutation = () => { }, }); }; + +export const useReservedIPsQuery = ( + params?: Params, + filter?: Filter, + enabled: boolean = true, +) => { + return useQuery, APIError[]>({ + ...networkingQueries.reservedIPs(params, filter), + enabled, + placeholderData: keepPreviousData, + }); +}; + +export const useReservedIPQuery = (address: string, enabled: boolean = true) => + useQuery({ + ...networkingQueries.reservedIP(address), + enabled, + }); + +export const useReserveIPMutation = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: reserveIP, + onSuccess(reservedIP) { + queryClient.invalidateQueries({ + queryKey: networkingQueries.reservedIPs._def, + }); + queryClient.setQueryData( + networkingQueries.reservedIP(reservedIP.address).queryKey, + reservedIP, + ); + }, + }); +}; + +export const useUpdateReservedIPMutation = (address: string) => { + const queryClient = useQueryClient(); + return useMutation< + IPAddress, + APIError[], + { address: string; tags: null | string[] } + >({ + mutationFn: (data) => updateReservedIP(address, data.tags), + onSuccess(reservedIP) { + queryClient.invalidateQueries({ + queryKey: networkingQueries.reservedIPs._def, + }); + queryClient.setQueryData( + networkingQueries.reservedIP(reservedIP.address).queryKey, + reservedIP, + ); + }, + }); +}; + +export const useUnReserveIPMutation = (address: string) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: () => unReserveIP(address), + onSuccess() { + queryClient.invalidateQueries({ + queryKey: networkingQueries.reservedIPs._def, + }); + queryClient.removeQueries({ + queryKey: networkingQueries.reservedIP(address).queryKey, + }); + }, + }); +}; diff --git a/packages/queries/src/networking/requests.ts b/packages/queries/src/networking/requests.ts index c5e36640ffe..df4171b1e32 100644 --- a/packages/queries/src/networking/requests.ts +++ b/packages/queries/src/networking/requests.ts @@ -1,7 +1,13 @@ -import { getIPs, getIPv6Ranges } from '@linode/api-v4'; +import { getIPs, getIPv6Ranges, getReservedIPsTypes } from '@linode/api-v4'; import { getAll } from '@linode/utilities'; -import type { Filter, IPAddress, IPRange, Params } from '@linode/api-v4'; +import type { + Filter, + IPAddress, + IPRange, + Params, + PriceType, +} from '@linode/api-v4'; export const getAllIps = ( passedParams: Params = {}, @@ -21,3 +27,8 @@ export const getAllIPv6Ranges = ( { ...filter, ...passedFilter }, ), )().then((data) => data.data); + +export const getAllReservedIPsTypes = () => + getAll((params) => getReservedIPsTypes(params))().then( + (results) => results.data, + ); diff --git a/packages/shared/.changeset/pr-13455-removed-1772701814738.md b/packages/shared/.changeset/pr-13455-removed-1772701814738.md new file mode 100644 index 00000000000..2be0ba92d50 --- /dev/null +++ b/packages/shared/.changeset/pr-13455-removed-1772701814738.md @@ -0,0 +1,5 @@ +--- +"@linode/shared": Removed +--- + +`useIsLinodeAclpSubscribed` hook from the shared package ([#13455](https://github.com/linode/manager/pull/13455)) diff --git a/packages/shared/.changeset/pr-13509-added-1773840779551.md b/packages/shared/.changeset/pr-13509-added-1773840779551.md new file mode 100644 index 00000000000..6f084aaa955 --- /dev/null +++ b/packages/shared/.changeset/pr-13509-added-1773840779551.md @@ -0,0 +1,5 @@ +--- +"@linode/shared": Added +--- + +New `getFeatureChip` utility to shared package ([#13509](https://github.com/linode/manager/pull/13509)) diff --git a/packages/shared/README.md b/packages/shared/README.md index 33cd2c58897..f650fa2b311 100644 --- a/packages/shared/README.md +++ b/packages/shared/README.md @@ -12,4 +12,8 @@ Interfaces must be documented using the [TSDoc](https://tsdoc.org/) comment stan ## Hooks -The hooks defined in this library are intended to provide functionality that is too complex or not "pure" enough to be placed in `@linode/utilities`. These hooks are used to implement feature-specific logic and are designed for use within React components. \ No newline at end of file +The hooks defined in this library are intended to provide functionality that is too complex or not "pure" enough to be placed in `@linode/utilities`. These hooks are used to implement feature-specific logic and are designed for use within React components. + +## Utilities + +The utilities defined in this library are reusable helpers that are not "pure" enough to be placed in `@linode/utilities`. These utilities may depend on `@linode/ui` and are designed for use within the context of specific features. diff --git a/packages/shared/src/hooks/index.ts b/packages/shared/src/hooks/index.ts index 26f3827740d..5329361f3a1 100644 --- a/packages/shared/src/hooks/index.ts +++ b/packages/shared/src/hooks/index.ts @@ -1,2 +1 @@ export * from './useIsGeckoEnabled'; -export * from './useIsLinodeAclpSubscribed'; diff --git a/packages/shared/src/hooks/useIsLinodeAclpSubscribed.test.ts b/packages/shared/src/hooks/useIsLinodeAclpSubscribed.test.ts deleted file mode 100644 index acb95442383..00000000000 --- a/packages/shared/src/hooks/useIsLinodeAclpSubscribed.test.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { renderHook } from '@testing-library/react'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -import { useIsLinodeAclpSubscribed } from './useIsLinodeAclpSubscribed'; - -const queryMocks = vi.hoisted(() => ({ - useLinodeQuery: vi.fn(), -})); - -vi.mock('@linode/queries', async () => { - const actual = await vi.importActual('@linode/queries'); - return { - ...actual, - useLinodeQuery: queryMocks.useLinodeQuery, - }; -}); - -describe('useIsLinodeAclpSubscribed', () => { - beforeEach(() => { - vi.resetAllMocks(); - }); - - it('returns false when linodeId is undefined', () => { - queryMocks.useLinodeQuery.mockReturnValue({}); - - const { result } = renderHook(() => - useIsLinodeAclpSubscribed(undefined, 'beta'), - ); - - expect(result.current).toBe(false); - }); - - it('returns false when linode data is undefined', () => { - queryMocks.useLinodeQuery.mockReturnValue({ data: undefined }); - - const { result } = renderHook(() => useIsLinodeAclpSubscribed(123, 'beta')); - - expect(result.current).toBe(false); - }); - - it('returns true in GA stage when no alerts exist at all', () => { - queryMocks.useLinodeQuery.mockReturnValue({ - data: { - alerts: { - cpu: 0, - io: 0, - network_in: 0, - network_out: 0, - transfer_quota: 0, - system_alerts: [], - user_alerts: [], - }, - }, - }); - - const { result } = renderHook(() => useIsLinodeAclpSubscribed(123, 'ga')); - - expect(result.current).toBe(true); - }); - - it('returns false in beta stage when no alerts exist at all', () => { - queryMocks.useLinodeQuery.mockReturnValue({ - data: { - alerts: { - cpu: 0, - io: 0, - network_in: 0, - network_out: 0, - transfer_quota: 0, - system_alerts: [], - user_alerts: [], - }, - }, - }); - - const { result } = renderHook(() => useIsLinodeAclpSubscribed(123, 'beta')); - - expect(result.current).toBe(false); - }); - - it('returns false when only legacy alerts exist', () => { - queryMocks.useLinodeQuery.mockReturnValue({ - data: { - alerts: { - cpu: 90, - io: 0, - network_in: 0, - network_out: 0, - transfer_quota: 0, - system_alerts: [], - user_alerts: [], - }, - }, - }); - - const { result } = renderHook(() => useIsLinodeAclpSubscribed(123, 'beta')); - - expect(result.current).toBe(false); - }); - - it('returns true when only ACLP alerts exist', () => { - queryMocks.useLinodeQuery.mockReturnValue({ - data: { - alerts: { - cpu: 0, - io: 0, - network_in: 0, - network_out: 0, - transfer_quota: 0, - system_alerts: [100], - user_alerts: [], - }, - }, - }); - - const { result } = renderHook(() => useIsLinodeAclpSubscribed(123, 'beta')); - - expect(result.current).toBe(true); - }); - - it('returns true when both legacy and ACLP alerts exist', () => { - queryMocks.useLinodeQuery.mockReturnValue({ - data: { - alerts: { - cpu: 90, - io: 0, - network_in: 0, - network_out: 0, - transfer_quota: 0, - system_alerts: [100], - user_alerts: [200], - }, - }, - }); - - const { result } = renderHook(() => useIsLinodeAclpSubscribed(123, 'beta')); - - expect(result.current).toBe(true); - }); -}); diff --git a/packages/shared/src/hooks/useIsLinodeAclpSubscribed.ts b/packages/shared/src/hooks/useIsLinodeAclpSubscribed.ts deleted file mode 100644 index a2667c199e2..00000000000 --- a/packages/shared/src/hooks/useIsLinodeAclpSubscribed.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { useLinodeQuery } from '@linode/queries'; - -type AclpStage = 'beta' | 'ga'; - -/** - * Determines if the linode is subscribed to ACLP or legacy alerts. - * - * ### Cases: - * - Legacy alerts = 0, Beta alerts = [] - * - Show default Legacy UI (disabled) for Beta stage - * - Show default Beta UI (disabled) for GA stage - * - Legacy alerts > 0, Beta alerts = [] - * - Show default Legacy UI (enabled) - * - Legacy alerts = 0, Beta alerts has values (either system, user, or both) - * - Show default Beta UI (enabled) - * - * @param linodeId - The ID of the Linode - * @param stage - The current ACLP stage: 'beta' or 'ga' - * @returns {boolean} `true` if the Linode is subscribed to ACLP, otherwise `false` - */ -export const useIsLinodeAclpSubscribed = ( - linodeId: number | undefined, - stage: AclpStage, -) => { - const { data: linode } = useLinodeQuery( - linodeId ?? -1, - linodeId !== undefined, - ); - - if (!linode) { - return false; - } - - const hasLegacyAlerts = - (linode.alerts.cpu ?? 0) > 0 || - (linode.alerts.io ?? 0) > 0 || - (linode.alerts.network_in ?? 0) > 0 || - (linode.alerts.network_out ?? 0) > 0 || - (linode.alerts.transfer_quota ?? 0) > 0; - - const hasAclpAlerts = - (linode.alerts.system_alerts?.length ?? 0) > 0 || - (linode.alerts.user_alerts?.length ?? 0) > 0; - - // Always subscribed if ACLP alerts exist. For GA stage, default to subscribed if no alerts exist. - return ( - hasAclpAlerts || (!hasAclpAlerts && !hasLegacyAlerts && stage === 'ga') - ); -}; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 497509f7e12..87826e305a7 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,3 +1,5 @@ export * from './components'; export * from './hooks'; + +export * from './utilities'; diff --git a/packages/shared/src/utilities/getFeatureChip.test.tsx b/packages/shared/src/utilities/getFeatureChip.test.tsx new file mode 100644 index 00000000000..1b8af6adc27 --- /dev/null +++ b/packages/shared/src/utilities/getFeatureChip.test.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { describe, expect, it } from 'vitest'; + +import { getFeatureChip } from './getFeatureChip'; +import { renderWithWrappers, ThemeWrapper } from './wrap'; + +describe('getFeatureChip', () => { + it('returns a BetaChip when flag.beta is true', () => { + const chip = getFeatureChip({ beta: true }); + const { getByTestId } = renderWithWrappers(<>{chip}, [ThemeWrapper()]); + expect(getByTestId('betaChip')).toBeVisible(); + }); + + it('returns a NewFeatureChip when flag.new is true', () => { + const chip = getFeatureChip({ new: true }); + const { getByTestId } = renderWithWrappers(<>{chip}, [ThemeWrapper()]); + expect(getByTestId('newFeatureChip')).toBeVisible(); + }); + + it('returns null when neither flag.beta nor flag.new is set', () => { + const chip = getFeatureChip({}); + const { container } = renderWithWrappers(<>{chip}, [ThemeWrapper()]); + expect(container).toBeEmptyDOMElement(); + }); + + it('returns null when both flag.beta and flag.new are false', () => { + const chip = getFeatureChip({ beta: false, new: false }); + const { container } = renderWithWrappers(<>{chip}, [ThemeWrapper()]); + expect(container).toBeEmptyDOMElement(); + }); + + it('prioritizes BetaChip over NewFeatureChip when both flags are true', () => { + const chip = getFeatureChip({ beta: true, new: true }); + const { getByTestId, queryByTestId } = renderWithWrappers(<>{chip}, [ + ThemeWrapper(), + ]); + expect(getByTestId('betaChip')).toBeVisible(); + expect(queryByTestId('newFeatureChip')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/shared/src/utilities/getFeatureChip.tsx b/packages/shared/src/utilities/getFeatureChip.tsx new file mode 100644 index 00000000000..f3233fb0256 --- /dev/null +++ b/packages/shared/src/utilities/getFeatureChip.tsx @@ -0,0 +1,8 @@ +import { BetaChip, NewFeatureChip } from '@linode/ui'; +import React from 'react'; + +export const getFeatureChip = (flag: { beta?: boolean; new?: boolean }) => { + if (flag.beta) return ; + if (flag.new) return ; + return null; +}; diff --git a/packages/shared/src/utilities/index.ts b/packages/shared/src/utilities/index.ts new file mode 100644 index 00000000000..533f0f3c90f --- /dev/null +++ b/packages/shared/src/utilities/index.ts @@ -0,0 +1 @@ +export * from './getFeatureChip'; diff --git a/packages/utilities/.changeset/pr-13509-changed-1774263411585.md b/packages/utilities/.changeset/pr-13509-changed-1774263411585.md new file mode 100644 index 00000000000..7570047d5a1 --- /dev/null +++ b/packages/utilities/.changeset/pr-13509-changed-1774263411585.md @@ -0,0 +1,5 @@ +--- +"@linode/utilities": Changed +--- + +`isAclpMetricsBeta` to `isAclpMetricsMode` in ManagerPreferences ([#13509](https://github.com/linode/manager/pull/13509)) diff --git a/packages/utilities/.changeset/pr-13517-upcoming-features-1774354694066.md b/packages/utilities/.changeset/pr-13517-upcoming-features-1774354694066.md new file mode 100644 index 00000000000..b5f791965be --- /dev/null +++ b/packages/utilities/.changeset/pr-13517-upcoming-features-1774354694066.md @@ -0,0 +1,5 @@ +--- +"@linode/utilities": Upcoming Features +--- + +Add new fields related to Reserved IPs ([#13517](https://github.com/linode/manager/pull/13517)) diff --git a/packages/utilities/src/factories/linodes.ts b/packages/utilities/src/factories/linodes.ts index 3c0d0b6467e..7a66714b019 100644 --- a/packages/utilities/src/factories/linodes.ts +++ b/packages/utilities/src/factories/linodes.ts @@ -70,6 +70,7 @@ export const linodeIPFactory = Factory.Sync.makeFactory({ public: [ { address: '10.11.12.13', + assigned_entity: null, gateway: '10.11.12.13', interface_id: null, linode_id: 1, @@ -79,6 +80,8 @@ export const linodeIPFactory = Factory.Sync.makeFactory({ region: 'us-southeast', subnet_mask: '255.255.255.0', type: 'ipv4', + reserved: false, + tags: [], }, ], reserved: [], @@ -114,6 +117,7 @@ export const linodeIPFactory = Factory.Sync.makeFactory({ ], link_local: { address: '2001:DB8::0000', + assigned_entity: null, gateway: 'fe80::1', interface_id: null, linode_id: 1, @@ -123,9 +127,12 @@ export const linodeIPFactory = Factory.Sync.makeFactory({ region: 'us-southeast', subnet_mask: 'ffff:ffff:ffff:ffff::', type: 'ipv6', + reserved: false, + tags: [], }, slaac: { address: '2001:DB8::0000', + assigned_entity: null, gateway: 'fe80::1', interface_id: null, linode_id: 1, @@ -135,6 +142,8 @@ export const linodeIPFactory = Factory.Sync.makeFactory({ region: 'us-southeast', subnet_mask: 'ffff:ffff:ffff:ffff::', type: 'ipv6', + reserved: false, + tags: [], }, vpc: [ { diff --git a/packages/utilities/src/types/ManagerPreferences.ts b/packages/utilities/src/types/ManagerPreferences.ts index 643d3de0266..e33b1baab58 100644 --- a/packages/utilities/src/types/ManagerPreferences.ts +++ b/packages/utilities/src/types/ManagerPreferences.ts @@ -31,7 +31,7 @@ export type ManagerPreferences = Partial<{ domains_group_by_tag: boolean; firewall_beta_notification: boolean; gst_banner_dismissed: boolean; - isAclpMetricsBeta: boolean; + isAclpMetricsMode: boolean; isTableStripingEnabled: boolean; linode_news_banner_dismissed: boolean; linodes_group_by_tag: boolean; diff --git a/packages/validation/.changeset/pr-13455-upcoming-features-1772701959931.md b/packages/validation/.changeset/pr-13455-upcoming-features-1772701959931.md new file mode 100644 index 00000000000..f63343fe49e --- /dev/null +++ b/packages/validation/.changeset/pr-13455-upcoming-features-1772701959931.md @@ -0,0 +1,5 @@ +--- +"@linode/validation": Upcoming Features +--- + +Simplify `UpdateLinodeAlertsSchema` to support simultaneous legacy and ACLP alerting ([#13455](https://github.com/linode/manager/pull/13455)) diff --git a/packages/validation/.changeset/pr-13507-upcoming-features-1773834596237.md b/packages/validation/.changeset/pr-13507-upcoming-features-1773834596237.md new file mode 100644 index 00000000000..395416aca29 --- /dev/null +++ b/packages/validation/.changeset/pr-13507-upcoming-features-1773834596237.md @@ -0,0 +1,5 @@ +--- +"@linode/validation": Upcoming Features +--- + +Delivery Logs: Custom HTTPS validation messages improvements ([#13507](https://github.com/linode/manager/pull/13507)) diff --git a/packages/validation/.changeset/pr-13517-upcoming-features-1774273168810.md b/packages/validation/.changeset/pr-13517-upcoming-features-1774273168810.md new file mode 100644 index 00000000000..b57a05bafd5 --- /dev/null +++ b/packages/validation/.changeset/pr-13517-upcoming-features-1774273168810.md @@ -0,0 +1,5 @@ +--- +"@linode/validation": Upcoming Features +--- + +Reserved IPs: Updated schemas to handle reserved IPs API changes ([#13517](https://github.com/linode/manager/pull/13517)) diff --git a/packages/validation/src/delivery.schema.ts b/packages/validation/src/delivery.schema.ts index e34e487d81c..530cdfafa85 100644 --- a/packages/validation/src/delivery.schema.ts +++ b/packages/validation/src/delivery.schema.ts @@ -19,16 +19,16 @@ const maxLengthMessage = 'Length must be 255 characters or less.'; const authenticationDetailsSchema = object({ basic_authentication_user: string() .max(maxLength, maxLengthMessage) - .required('Username is required for Basic Authentication.'), + .required('Username is required for Basic authentication.'), basic_authentication_password: string() .max(maxLength, maxLengthMessage) - .required('Password is required for Basic Authentication.'), + .required('Password is required for Basic authentication.'), }); const authenticationSchema = object({ type: string() .oneOf(['basic', 'none']) - .required('Authentication is required.'), + .required('Authentication Type is required.'), details: mixed() .defined() .when('type', { @@ -39,7 +39,7 @@ const authenticationSchema = object({ .nullable() .test( 'null-or-undefined', - 'For none authentication details should be `null` or `undefined`.', + 'Username and password must be empty when authentication type is None.', (value) => !value, ), }) as Schema | undefined>, @@ -100,7 +100,7 @@ const clientCertificateDetailsSchema = object({ this.createError({ path: `${this.path}.client_private_key`, message: - 'Client Key is required when other client certificate details are provided.', + 'Client Private Key is required when other client certificate details are provided.', }), ); } @@ -120,29 +120,29 @@ const forbiddenCustomHeaderNames = [ 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.', + 'Custom Header name cannot be empty or whitespace only.', (value) => hasValue(value), ) .test( 'forbidden-custom-header-name', - 'This header name is not allowed.', + 'This Custom Header name cannot be used.', (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.', + '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 urlRgx = /^(https?:\/\/)?([\w-]+(\.[\w-]+)+)(\/\S*)?$/; const customHTTPSDetailsSchema = object({ authentication: authenticationSchema.required(), @@ -157,7 +157,7 @@ const customHTTPSDetailsSchema = object({ .optional() .test( 'unique-header-names', - 'Custom Header Names must be unique.', + 'Custom Header names must be unique.', function (headers) { if (!headers || headers.length === 0) { return true; @@ -176,7 +176,7 @@ const customHTTPSDetailsSchema = object({ errors.push( this.createError({ path: `${this.path}[${index}].name`, - message: 'Custom Header Name must be unique.', + message: 'Custom Header name must be unique.', }), ); } else { @@ -206,7 +206,7 @@ const akamaiObjectStorageDetailsBaseSchema = object({ .required('Endpoint is required.') .test( 'host-must-match-with-bucket-name-if-provided', - 'Bucket name provided as a part of the endpoint must be the same as the bucket.', + 'Bucket name in the endpoint must match the name in the Bucket field.', (value, ctx) => { if (ctx.parent.bucket_name) { const groups = hostRgx.exec(value)?.groups; @@ -231,7 +231,7 @@ const akamaiObjectStorageDetailsBaseSchema = object({ .max(63, 'Bucket name must be between 3 and 63 characters.') .test( 'bucket-name-same-in-host-if-provided', - 'Bucket must match the bucket name used in the host prefix.', + 'Bucket must match the bucket name in the Endpoint prefix.', (value, ctx) => { if (ctx.parent.host) { const groups = hostRgx.exec(ctx.parent.host)?.groups; diff --git a/packages/validation/src/linodes.schema.ts b/packages/validation/src/linodes.schema.ts index 05e4c460a5b..9d5be6d31aa 100644 --- a/packages/validation/src/linodes.schema.ts +++ b/packages/validation/src/linodes.schema.ts @@ -386,39 +386,15 @@ const DiskEncryptionSchema = string() .oneOf(['enabled', 'disabled']) .notRequired(); -/** - * A number field schema with conditional validation for legacy alert fields. - * @param label - The label used in the required error message. - * @returns A number schema with conditional validation. - */ -const legacyAlertsFieldSchema = ( - label: - | 'CPU Usage' - | 'Disk I/O Rate' - | 'Incoming Traffic' - | 'Outbound Traffic' - | 'Transfer Quota', -) => - // If system_alerts and user_alerts are undefined, then it is legacy alerts context. - // If it is legacy alerts context, then the field is required. - number().when(['system_alerts', 'user_alerts'], { - is: (systemAlerts?: number[], userAlerts?: number[]) => { - return systemAlerts === undefined && userAlerts === undefined; - }, - then: (schema) => schema.required(`${label} is required.`), - otherwise: (schema) => schema.notRequired(), - }); - export const UpdateLinodeAlertsSchema = object({ - // Legacy numeric-threshold alerts. All fields are required to update legacy alerts, but not for ACLP alerts. - cpu: legacyAlertsFieldSchema('CPU Usage') + cpu: number() + .required('CPU Usage is required.') .min(0, 'Must be between 0 and 4800') .max(4800, 'Must be between 0 and 4800'), - network_in: legacyAlertsFieldSchema('Incoming Traffic'), - network_out: legacyAlertsFieldSchema('Outbound Traffic'), - transfer_quota: legacyAlertsFieldSchema('Transfer Quota'), - io: legacyAlertsFieldSchema('Disk I/O Rate'), - // ACLP alerts. All fields are required to update ACLP alerts, but not for legacy alerts. + network_in: number().required('Incoming Traffic is required.'), + network_out: number().required('Outbound Traffic is required.'), + transfer_quota: number().required('Transfer Quota is required.'), + io: number().required('Disk I/O Rate is required.'), system_alerts: array().of(number().defined()).notRequired(), user_alerts: array().of(number().defined()).notRequired(), }); @@ -497,6 +473,7 @@ export const IPAllocationSchema = object({ .required('IP address type (IPv4) is required.') .oneOf(['ipv4'], 'Only IPv4 addresses can be allocated.'), public: boolean().required('Must specify public or private IP address.'), + address: string().optional(), }); export const CreateSnapshotSchema = object({ diff --git a/packages/validation/src/marketplace.schema.ts b/packages/validation/src/marketplace.schema.ts index c5673043b53..5d47df232ed 100644 --- a/packages/validation/src/marketplace.schema.ts +++ b/packages/validation/src/marketplace.schema.ts @@ -37,7 +37,11 @@ export const createPartnerReferralSchema = object({ ), company_name: string().optional(), account_executive_email: string() - .matches(AKAMAI_EMAIL_VALIDATION_REGEX, `Must be an akamai email address.`) + .trim() + .matches(AKAMAI_EMAIL_VALIDATION_REGEX, { + excludeEmptyString: true, + message: 'Must be an Akamai email address.', + }) .optional(), comments: string() .optional() diff --git a/packages/validation/src/networking.schema.ts b/packages/validation/src/networking.schema.ts index 3c78cbf583c..6633586d070 100644 --- a/packages/validation/src/networking.schema.ts +++ b/packages/validation/src/networking.schema.ts @@ -2,6 +2,7 @@ import { array, boolean, number, object, string } from 'yup'; export const updateIPSchema = object().shape({ rdns: string().notRequired().nullable(), + reserved: boolean().notRequired(), }); export const allocateIPSchema = object().shape({ @@ -12,7 +13,27 @@ export const allocateIPSchema = object().shape({ 'Only IPv4 address may be allocated through this endpoint.', ), public: boolean().required(), - linode_id: number().required(), + linode_id: number().when('reserved', { + is: false, + then: (schema) => schema.required(), + otherwise: (schema) => + schema.when('region', { + is: (region: string | undefined) => region === undefined, + then: (schema) => schema.required(), + otherwise: (schema) => schema.notRequired(), + }), + }), + reserved: boolean().notRequired(), + region: string().when('reserved', { + is: false, + then: (schema) => schema.notRequired(), + otherwise: (schema) => + schema.when('linode_id', { + is: (linode_id: number | undefined) => linode_id === undefined, + then: (schema) => schema.required(), + otherwise: (schema) => schema.notRequired(), + }), + }), }); export const assignAddressesSchema = object().shape({ @@ -24,3 +45,8 @@ export const shareAddressesSchema = object().shape({ linode_id: number().required(), ips: array().of(string()), }); + +export const reserveIPSchema = object().shape({ + region: string().required(), + tags: array().of(string().defined()).notRequired(), +}); diff --git a/packages/validation/src/nodebalancers.schema.ts b/packages/validation/src/nodebalancers.schema.ts index 244f2ec5a14..df96ca529f0 100644 --- a/packages/validation/src/nodebalancers.schema.ts +++ b/packages/validation/src/nodebalancers.schema.ts @@ -437,6 +437,8 @@ export const NodeBalancerSchema = object({ message: 'Subnet IDs must be unique', }); }), + + ipv4: string().optional(), }); export const UpdateNodeBalancerSchema = object({ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4d1845a6fd9..9fa1e7438ce 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -124,7 +124,7 @@ importers: version: 9.1.0 tsup: specifier: ^8.4.0 - version: 8.4.0(@swc/core@1.13.5)(jiti@2.4.2)(postcss@8.5.6)(tsx@4.19.3)(typescript@5.9.3)(yaml@2.6.1) + version: 8.4.0(@swc/core@1.13.5)(jiti@2.4.2)(postcss@8.5.8)(tsx@4.19.3)(typescript@5.9.3)(yaml@2.6.1) packages/manager: dependencies: @@ -243,8 +243,8 @@ importers: specifier: ^3.0.0 version: 3.1.0 dompurify: - specifier: ^3.2.4 - version: 3.2.4 + specifier: ^3.3.2 + version: 3.3.2 flag-icons: specifier: ^6.6.5 version: 6.15.0 @@ -264,11 +264,11 @@ importers: specifier: ^1.9.1 version: 1.9.1 jspdf: - specifier: ^4.2.0 - version: 4.2.0 + specifier: ^4.2.1 + version: 4.2.1 jspdf-autotable: specifier: ^5.0.2 - version: 5.0.2(jspdf@4.2.0) + version: 5.0.2(jspdf@4.2.1) 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) @@ -368,7 +368,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.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)) + version: 9.1.17(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.60.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 @@ -452,7 +452,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.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)) + version: 3.2.4(vitest@4.1.1(@types/node@22.18.1)(@vitest/ui@4.0.10(vitest@4.0.10))(jsdom@24.1.3)(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))) '@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) @@ -461,7 +461,7 @@ importers: version: 4.10.2 chai-string: specifier: ^1.5.0 - version: 1.5.0(chai@6.2.1) + version: 1.5.0(chai@6.2.2) concurrently: specifier: ^9.1.0 version: 9.1.0 @@ -530,7 +530,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.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)) + version: 4.5.0(rollup@4.60.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: @@ -614,7 +614,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.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)) + version: 9.0.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.60.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 @@ -638,7 +638,7 @@ importers: 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.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)) + version: 4.5.0(rollup@4.60.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: {} @@ -683,7 +683,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.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)) + version: 9.0.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.60.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 @@ -710,7 +710,7 @@ importers: 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.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)) + version: 4.5.0(rollup@4.60.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: @@ -772,7 +772,7 @@ importers: version: 9.1.0 tsup: specifier: ^8.4.0 - version: 8.4.0(@swc/core@1.13.5)(jiti@2.4.2)(postcss@8.5.6)(tsx@4.19.3)(typescript@5.9.3)(yaml@2.6.1) + version: 8.4.0(@swc/core@1.13.5)(jiti@2.4.2)(postcss@8.5.8)(tsx@4.19.3)(typescript@5.9.3)(yaml@2.6.1) scripts: devDependencies: @@ -798,8 +798,8 @@ importers: specifier: ^14.1.1 version: 14.1.1 simple-git: - specifier: ^3.19.0 - version: 3.27.0 + specifier: ^3.32.3 + version: 3.32.3 tsx: specifier: ^4.19.3 version: 4.19.3 @@ -925,8 +925,8 @@ packages: resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==} engines: {node: '>=6.9.0'} - '@babel/runtime@7.28.6': - resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} + '@babel/runtime@7.29.2': + resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} engines: {node: '>=6.9.0'} '@babel/template@7.27.0': @@ -1089,8 +1089,8 @@ packages: cpu: [ppc64] os: [aix] - '@esbuild/aix-ppc64@0.27.3': - resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} + '@esbuild/aix-ppc64@0.27.4': + resolution: {integrity: sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] @@ -1107,8 +1107,8 @@ packages: cpu: [arm64] os: [android] - '@esbuild/android-arm64@0.27.3': - resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==} + '@esbuild/android-arm64@0.27.4': + resolution: {integrity: sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==} engines: {node: '>=18'} cpu: [arm64] os: [android] @@ -1125,8 +1125,8 @@ packages: cpu: [arm] os: [android] - '@esbuild/android-arm@0.27.3': - resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==} + '@esbuild/android-arm@0.27.4': + resolution: {integrity: sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==} engines: {node: '>=18'} cpu: [arm] os: [android] @@ -1143,8 +1143,8 @@ packages: cpu: [x64] os: [android] - '@esbuild/android-x64@0.27.3': - resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==} + '@esbuild/android-x64@0.27.4': + resolution: {integrity: sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==} engines: {node: '>=18'} cpu: [x64] os: [android] @@ -1161,8 +1161,8 @@ packages: cpu: [arm64] os: [darwin] - '@esbuild/darwin-arm64@0.27.3': - resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==} + '@esbuild/darwin-arm64@0.27.4': + resolution: {integrity: sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] @@ -1179,8 +1179,8 @@ packages: cpu: [x64] os: [darwin] - '@esbuild/darwin-x64@0.27.3': - resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==} + '@esbuild/darwin-x64@0.27.4': + resolution: {integrity: sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==} engines: {node: '>=18'} cpu: [x64] os: [darwin] @@ -1197,8 +1197,8 @@ packages: cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-arm64@0.27.3': - resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==} + '@esbuild/freebsd-arm64@0.27.4': + resolution: {integrity: sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] @@ -1215,8 +1215,8 @@ packages: cpu: [x64] os: [freebsd] - '@esbuild/freebsd-x64@0.27.3': - resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==} + '@esbuild/freebsd-x64@0.27.4': + resolution: {integrity: sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] @@ -1233,8 +1233,8 @@ packages: cpu: [arm64] os: [linux] - '@esbuild/linux-arm64@0.27.3': - resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==} + '@esbuild/linux-arm64@0.27.4': + resolution: {integrity: sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==} engines: {node: '>=18'} cpu: [arm64] os: [linux] @@ -1251,8 +1251,8 @@ packages: cpu: [arm] os: [linux] - '@esbuild/linux-arm@0.27.3': - resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==} + '@esbuild/linux-arm@0.27.4': + resolution: {integrity: sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==} engines: {node: '>=18'} cpu: [arm] os: [linux] @@ -1269,8 +1269,8 @@ packages: cpu: [ia32] os: [linux] - '@esbuild/linux-ia32@0.27.3': - resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==} + '@esbuild/linux-ia32@0.27.4': + resolution: {integrity: sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==} engines: {node: '>=18'} cpu: [ia32] os: [linux] @@ -1287,8 +1287,8 @@ packages: cpu: [loong64] os: [linux] - '@esbuild/linux-loong64@0.27.3': - resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==} + '@esbuild/linux-loong64@0.27.4': + resolution: {integrity: sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==} engines: {node: '>=18'} cpu: [loong64] os: [linux] @@ -1305,8 +1305,8 @@ packages: cpu: [mips64el] os: [linux] - '@esbuild/linux-mips64el@0.27.3': - resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==} + '@esbuild/linux-mips64el@0.27.4': + resolution: {integrity: sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] @@ -1323,8 +1323,8 @@ packages: cpu: [ppc64] os: [linux] - '@esbuild/linux-ppc64@0.27.3': - resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==} + '@esbuild/linux-ppc64@0.27.4': + resolution: {integrity: sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] @@ -1341,8 +1341,8 @@ packages: cpu: [riscv64] os: [linux] - '@esbuild/linux-riscv64@0.27.3': - resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==} + '@esbuild/linux-riscv64@0.27.4': + resolution: {integrity: sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] @@ -1359,8 +1359,8 @@ packages: cpu: [s390x] os: [linux] - '@esbuild/linux-s390x@0.27.3': - resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==} + '@esbuild/linux-s390x@0.27.4': + resolution: {integrity: sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==} engines: {node: '>=18'} cpu: [s390x] os: [linux] @@ -1377,8 +1377,8 @@ packages: cpu: [x64] os: [linux] - '@esbuild/linux-x64@0.27.3': - resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==} + '@esbuild/linux-x64@0.27.4': + resolution: {integrity: sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==} engines: {node: '>=18'} cpu: [x64] os: [linux] @@ -1395,8 +1395,8 @@ packages: cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-arm64@0.27.3': - resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==} + '@esbuild/netbsd-arm64@0.27.4': + resolution: {integrity: sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] @@ -1413,8 +1413,8 @@ packages: cpu: [x64] os: [netbsd] - '@esbuild/netbsd-x64@0.27.3': - resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==} + '@esbuild/netbsd-x64@0.27.4': + resolution: {integrity: sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] @@ -1431,8 +1431,8 @@ packages: cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-arm64@0.27.3': - resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==} + '@esbuild/openbsd-arm64@0.27.4': + resolution: {integrity: sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] @@ -1449,8 +1449,8 @@ packages: cpu: [x64] os: [openbsd] - '@esbuild/openbsd-x64@0.27.3': - resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==} + '@esbuild/openbsd-x64@0.27.4': + resolution: {integrity: sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] @@ -1461,8 +1461,8 @@ packages: cpu: [arm64] os: [openharmony] - '@esbuild/openharmony-arm64@0.27.3': - resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==} + '@esbuild/openharmony-arm64@0.27.4': + resolution: {integrity: sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] @@ -1479,8 +1479,8 @@ packages: cpu: [x64] os: [sunos] - '@esbuild/sunos-x64@0.27.3': - resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==} + '@esbuild/sunos-x64@0.27.4': + resolution: {integrity: sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==} engines: {node: '>=18'} cpu: [x64] os: [sunos] @@ -1497,8 +1497,8 @@ packages: cpu: [arm64] os: [win32] - '@esbuild/win32-arm64@0.27.3': - resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==} + '@esbuild/win32-arm64@0.27.4': + resolution: {integrity: sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==} engines: {node: '>=18'} cpu: [arm64] os: [win32] @@ -1515,8 +1515,8 @@ packages: cpu: [ia32] os: [win32] - '@esbuild/win32-ia32@0.27.3': - resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==} + '@esbuild/win32-ia32@0.27.4': + resolution: {integrity: sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==} engines: {node: '>=18'} cpu: [ia32] os: [win32] @@ -1533,8 +1533,8 @@ packages: cpu: [x64] os: [win32] - '@esbuild/win32-x64@0.27.3': - resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==} + '@esbuild/win32-x64@0.27.4': + resolution: {integrity: sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==} engines: {node: '>=18'} cpu: [x64] os: [win32] @@ -2135,8 +2135,8 @@ packages: cpu: [arm] os: [android] - '@rollup/rollup-android-arm-eabi@4.58.0': - resolution: {integrity: sha512-mr0tmS/4FoVk1cnaeN244A/wjvGDNItZKR8hRhnmCzygyRXYtKF5jVDSIILR1U97CTzAYmbgIj/Dukg62ggG5w==} + '@rollup/rollup-android-arm-eabi@4.60.0': + resolution: {integrity: sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==} cpu: [arm] os: [android] @@ -2150,8 +2150,8 @@ packages: cpu: [arm64] os: [android] - '@rollup/rollup-android-arm64@4.58.0': - resolution: {integrity: sha512-+s++dbp+/RTte62mQD9wLSbiMTV+xr/PeRJEc/sFZFSBRlHPNPVaf5FXlzAL77Mr8FtSfQqCN+I598M8U41ccQ==} + '@rollup/rollup-android-arm64@4.60.0': + resolution: {integrity: sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==} cpu: [arm64] os: [android] @@ -2165,8 +2165,8 @@ packages: cpu: [arm64] os: [darwin] - '@rollup/rollup-darwin-arm64@4.58.0': - resolution: {integrity: sha512-MFWBwTcYs0jZbINQBXHfSrpSQJq3IUOakcKPzfeSznONop14Pxuqa0Kg19GD0rNBMPQI2tFtu3UzapZpH0Uc1Q==} + '@rollup/rollup-darwin-arm64@4.60.0': + resolution: {integrity: sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==} cpu: [arm64] os: [darwin] @@ -2180,8 +2180,8 @@ packages: cpu: [x64] os: [darwin] - '@rollup/rollup-darwin-x64@4.58.0': - resolution: {integrity: sha512-yiKJY7pj9c9JwzuKYLFaDZw5gma3fI9bkPEIyofvVfsPqjCWPglSHdpdwXpKGvDeYDms3Qal8qGMEHZ1M/4Udg==} + '@rollup/rollup-darwin-x64@4.60.0': + resolution: {integrity: sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==} cpu: [x64] os: [darwin] @@ -2195,8 +2195,8 @@ packages: cpu: [arm64] os: [freebsd] - '@rollup/rollup-freebsd-arm64@4.58.0': - resolution: {integrity: sha512-x97kCoBh5MOevpn/CNK9W1x8BEzO238541BGWBc315uOlN0AD/ifZ1msg+ZQB05Ux+VF6EcYqpiagfLJ8U3LvQ==} + '@rollup/rollup-freebsd-arm64@4.60.0': + resolution: {integrity: sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==} cpu: [arm64] os: [freebsd] @@ -2210,8 +2210,8 @@ packages: cpu: [x64] os: [freebsd] - '@rollup/rollup-freebsd-x64@4.58.0': - resolution: {integrity: sha512-Aa8jPoZ6IQAG2eIrcXPpjRcMjROMFxCt1UYPZZtCxRV68WkuSigYtQ/7Zwrcr2IvtNJo7T2JfDXyMLxq5L4Jlg==} + '@rollup/rollup-freebsd-x64@4.60.0': + resolution: {integrity: sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==} cpu: [x64] os: [freebsd] @@ -2225,8 +2225,8 @@ packages: cpu: [arm] os: [linux] - '@rollup/rollup-linux-arm-gnueabihf@4.58.0': - resolution: {integrity: sha512-Ob8YgT5kD/lSIYW2Rcngs5kNB/44Q2RzBSPz9brf2WEtcGR7/f/E9HeHn1wYaAwKBni+bdXEwgHvUd0x12lQSA==} + '@rollup/rollup-linux-arm-gnueabihf@4.60.0': + resolution: {integrity: sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==} cpu: [arm] os: [linux] @@ -2240,8 +2240,8 @@ packages: cpu: [arm] os: [linux] - '@rollup/rollup-linux-arm-musleabihf@4.58.0': - resolution: {integrity: sha512-K+RI5oP1ceqoadvNt1FecL17Qtw/n9BgRSzxif3rTL2QlIu88ccvY+Y9nnHe/cmT5zbH9+bpiJuG1mGHRVwF4Q==} + '@rollup/rollup-linux-arm-musleabihf@4.60.0': + resolution: {integrity: sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==} cpu: [arm] os: [linux] @@ -2255,8 +2255,8 @@ packages: cpu: [arm64] os: [linux] - '@rollup/rollup-linux-arm64-gnu@4.58.0': - resolution: {integrity: sha512-T+17JAsCKUjmbopcKepJjHWHXSjeW7O5PL7lEFaeQmiVyw4kkc5/lyYKzrv6ElWRX/MrEWfPiJWqbTvfIvjM1Q==} + '@rollup/rollup-linux-arm64-gnu@4.60.0': + resolution: {integrity: sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==} cpu: [arm64] os: [linux] @@ -2270,8 +2270,8 @@ packages: cpu: [arm64] os: [linux] - '@rollup/rollup-linux-arm64-musl@4.58.0': - resolution: {integrity: sha512-cCePktb9+6R9itIJdeCFF9txPU7pQeEHB5AbHu/MKsfH/k70ZtOeq1k4YAtBv9Z7mmKI5/wOLYjQ+B9QdxR6LA==} + '@rollup/rollup-linux-arm64-musl@4.60.0': + resolution: {integrity: sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==} cpu: [arm64] os: [linux] @@ -2280,13 +2280,13 @@ packages: cpu: [loong64] os: [linux] - '@rollup/rollup-linux-loong64-gnu@4.58.0': - resolution: {integrity: sha512-iekUaLkfliAsDl4/xSdoCJ1gnnIXvoNz85C8U8+ZxknM5pBStfZjeXgB8lXobDQvvPRCN8FPmmuTtH+z95HTmg==} + '@rollup/rollup-linux-loong64-gnu@4.60.0': + resolution: {integrity: sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==} cpu: [loong64] os: [linux] - '@rollup/rollup-linux-loong64-musl@4.58.0': - resolution: {integrity: sha512-68ofRgJNl/jYJbxFjCKE7IwhbfxOl1muPN4KbIqAIe32lm22KmU7E8OPvyy68HTNkI2iV/c8y2kSPSm2mW/Q9Q==} + '@rollup/rollup-linux-loong64-musl@4.60.0': + resolution: {integrity: sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==} cpu: [loong64] os: [linux] @@ -2305,13 +2305,13 @@ packages: cpu: [ppc64] os: [linux] - '@rollup/rollup-linux-ppc64-gnu@4.58.0': - resolution: {integrity: sha512-dpz8vT0i+JqUKuSNPCP5SYyIV2Lh0sNL1+FhM7eLC457d5B9/BC3kDPp5BBftMmTNsBarcPcoz5UGSsnCiw4XQ==} + '@rollup/rollup-linux-ppc64-gnu@4.60.0': + resolution: {integrity: sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==} cpu: [ppc64] os: [linux] - '@rollup/rollup-linux-ppc64-musl@4.58.0': - resolution: {integrity: sha512-4gdkkf9UJ7tafnweBCR/mk4jf3Jfl0cKX9Np80t5i78kjIH0ZdezUv/JDI2VtruE5lunfACqftJ8dIMGN4oHew==} + '@rollup/rollup-linux-ppc64-musl@4.60.0': + resolution: {integrity: sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==} cpu: [ppc64] os: [linux] @@ -2325,8 +2325,8 @@ packages: cpu: [riscv64] os: [linux] - '@rollup/rollup-linux-riscv64-gnu@4.58.0': - resolution: {integrity: sha512-YFS4vPnOkDTD/JriUeeZurFYoJhPf9GQQEF/v4lltp3mVcBmnsAdjEWhr2cjUCZzZNzxCG0HZOvJU44UGHSdzw==} + '@rollup/rollup-linux-riscv64-gnu@4.60.0': + resolution: {integrity: sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==} cpu: [riscv64] os: [linux] @@ -2340,8 +2340,8 @@ packages: cpu: [riscv64] os: [linux] - '@rollup/rollup-linux-riscv64-musl@4.58.0': - resolution: {integrity: sha512-x2xgZlFne+QVNKV8b4wwaCS8pwq3y14zedZ5DqLzjdRITvreBk//4Knbcvm7+lWmms9V9qFp60MtUd0/t/PXPw==} + '@rollup/rollup-linux-riscv64-musl@4.60.0': + resolution: {integrity: sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==} cpu: [riscv64] os: [linux] @@ -2355,8 +2355,8 @@ packages: cpu: [s390x] os: [linux] - '@rollup/rollup-linux-s390x-gnu@4.58.0': - resolution: {integrity: sha512-jIhrujyn4UnWF8S+DHSkAkDEO3hLX0cjzxJZPLF80xFyzyUIYgSMRcYQ3+uqEoyDD2beGq7Dj7edi8OnJcS/hg==} + '@rollup/rollup-linux-s390x-gnu@4.60.0': + resolution: {integrity: sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==} cpu: [s390x] os: [linux] @@ -2370,8 +2370,8 @@ packages: cpu: [x64] os: [linux] - '@rollup/rollup-linux-x64-gnu@4.58.0': - resolution: {integrity: sha512-+410Srdoh78MKSJxTQ+hZ/Mx+ajd6RjjPwBPNd0R3J9FtL6ZA0GqiiyNjCO9In0IzZkCNrpGymSfn+kgyPQocg==} + '@rollup/rollup-linux-x64-gnu@4.60.0': + resolution: {integrity: sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==} cpu: [x64] os: [linux] @@ -2385,13 +2385,13 @@ packages: cpu: [x64] os: [linux] - '@rollup/rollup-linux-x64-musl@4.58.0': - resolution: {integrity: sha512-ZjMyby5SICi227y1MTR3VYBpFTdZs823Rs/hpakufleBoufoOIB6jtm9FEoxn/cgO7l6PM2rCEl5Kre5vX0QrQ==} + '@rollup/rollup-linux-x64-musl@4.60.0': + resolution: {integrity: sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==} cpu: [x64] os: [linux] - '@rollup/rollup-openbsd-x64@4.58.0': - resolution: {integrity: sha512-ds4iwfYkSQ0k1nb8LTcyXw//ToHOnNTJtceySpL3fa7tc/AsE+UpUFphW126A6fKBGJD5dhRvg8zw1rvoGFxmw==} + '@rollup/rollup-openbsd-x64@4.60.0': + resolution: {integrity: sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==} cpu: [x64] os: [openbsd] @@ -2400,8 +2400,8 @@ packages: cpu: [arm64] os: [openharmony] - '@rollup/rollup-openharmony-arm64@4.58.0': - resolution: {integrity: sha512-fd/zpJniln4ICdPkjWFhZYeY/bpnaN9pGa6ko+5WD38I0tTqk9lXMgXZg09MNdhpARngmxiCg0B0XUamNw/5BQ==} + '@rollup/rollup-openharmony-arm64@4.60.0': + resolution: {integrity: sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==} cpu: [arm64] os: [openharmony] @@ -2415,8 +2415,8 @@ packages: cpu: [arm64] os: [win32] - '@rollup/rollup-win32-arm64-msvc@4.58.0': - resolution: {integrity: sha512-YpG8dUOip7DCz3nr/JUfPbIUo+2d/dy++5bFzgi4ugOGBIox+qMbbqt/JoORwvI/C9Kn2tz6+Bieoqd5+B1CjA==} + '@rollup/rollup-win32-arm64-msvc@4.60.0': + resolution: {integrity: sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==} cpu: [arm64] os: [win32] @@ -2430,8 +2430,8 @@ packages: cpu: [ia32] os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.58.0': - resolution: {integrity: sha512-b9DI8jpFQVh4hIXFr0/+N/TzLdpBIoPzjt0Rt4xJbW3mzguV3mduR9cNgiuFcuL/TeORejJhCWiAXe3E/6PxWA==} + '@rollup/rollup-win32-ia32-msvc@4.60.0': + resolution: {integrity: sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==} cpu: [ia32] os: [win32] @@ -2440,8 +2440,8 @@ packages: cpu: [x64] os: [win32] - '@rollup/rollup-win32-x64-gnu@4.58.0': - resolution: {integrity: sha512-CSrVpmoRJFN06LL9xhkitkwUcTZtIotYAF5p6XOR2zW0Zz5mzb3IPpcoPhB02frzMHFNo1reQ9xSF5fFm3hUsQ==} + '@rollup/rollup-win32-x64-gnu@4.60.0': + resolution: {integrity: sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==} cpu: [x64] os: [win32] @@ -2455,8 +2455,8 @@ packages: cpu: [x64] os: [win32] - '@rollup/rollup-win32-x64-msvc@4.58.0': - resolution: {integrity: sha512-QFsBgQNTnh5K0t/sBsjJLq24YVqEIVkGpfN2VHsnN90soZyhaiA9UUHufcctVNL4ypJY0wrwad0wslx2KJQ1/w==} + '@rollup/rollup-win32-x64-msvc@4.60.0': + resolution: {integrity: sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==} cpu: [x64] os: [win32] @@ -3269,8 +3269,8 @@ packages: '@vitest/expect@4.0.10': resolution: {integrity: sha512-3QkTX/lK39FBNwARCQRSQr0TP9+ywSdxSX+LgbJ2M1WmveXP72anTbnp2yl5fH+dU6SUmBzNMrDHs80G8G2DZg==} - '@vitest/expect@4.0.18': - resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==} + '@vitest/expect@4.1.1': + resolution: {integrity: sha512-xAV0fqBTk44Rn6SjJReEQkHP3RrqbJo6JQ4zZ7/uVOiJZRarBtblzrOfFIZeYUrukp2YD6snZG6IBqhOoHTm+A==} '@vitest/mocker@3.2.4': resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} @@ -3294,11 +3294,11 @@ packages: vite: optional: true - '@vitest/mocker@4.0.18': - resolution: {integrity: sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==} + '@vitest/mocker@4.1.1': + resolution: {integrity: sha512-h3BOylsfsCLPeceuCPAAJ+BvNwSENgJa4hXoXu4im0bs9Lyp4URc4JYK4pWLZ4pG/UQn7AT92K6IByi6rE6g3A==} peerDependencies: msw: ^2.4.9 - vite: ^6.0.0 || ^7.0.0-0 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 peerDependenciesMeta: msw: optional: true @@ -3311,20 +3311,20 @@ packages: '@vitest/pretty-format@4.0.10': resolution: {integrity: sha512-99EQbpa/zuDnvVjthwz5bH9o8iPefoQZ63WV8+bsRJZNw3qQSvSltfut8yu1Jc9mqOYi7pEbsKxYTi/rjaq6PA==} - '@vitest/pretty-format@4.0.18': - resolution: {integrity: sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==} + '@vitest/pretty-format@4.1.1': + resolution: {integrity: sha512-GM+TEQN5WhOygr1lp7skeVjdLPqqWMHsfzXrcHAqZJi/lIVh63H0kaRCY8MDhNWikx19zBUK8ceaLB7X5AH9NQ==} '@vitest/runner@4.0.10': resolution: {integrity: sha512-EXU2iSkKvNwtlL8L8doCpkyclw0mc/t4t9SeOnfOFPyqLmQwuceMPA4zJBa6jw0MKsZYbw7kAn+gl7HxrlB8UQ==} - '@vitest/runner@4.0.18': - resolution: {integrity: sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==} + '@vitest/runner@4.1.1': + resolution: {integrity: sha512-f7+FPy75vN91QGWsITueq0gedwUZy1fLtHOCMeQpjs8jTekAHeKP80zfDEnhrleviLHzVSDXIWuCIOFn3D3f8A==} '@vitest/snapshot@4.0.10': resolution: {integrity: sha512-2N4X2ZZl7kZw0qeGdQ41H0KND96L3qX1RgwuCfy6oUsF2ISGD/HpSbmms+CkIOsQmg2kulwfhJ4CI0asnZlvkg==} - '@vitest/snapshot@4.0.18': - resolution: {integrity: sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==} + '@vitest/snapshot@4.1.1': + resolution: {integrity: sha512-kMVSgcegWV2FibXEx9p9WIKgje58lcTbXgnJixfcg15iK8nzCXhmalL0ZLtTWLW9PH1+1NEDShiFFedB3tEgWg==} '@vitest/spy@3.2.4': resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} @@ -3332,8 +3332,8 @@ packages: '@vitest/spy@4.0.10': resolution: {integrity: sha512-AsY6sVS8OLb96GV5RoG8B6I35GAbNrC49AO+jNRF9YVGb/g9t+hzNm1H6kD0NDp8tt7VJLs6hb7YMkDXqu03iw==} - '@vitest/spy@4.0.18': - resolution: {integrity: sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==} + '@vitest/spy@4.1.1': + resolution: {integrity: sha512-6Ti/KT5OVaiupdIZEuZN7l3CZcR0cxnxt70Z0//3CtwgObwA6jZhmVBA3yrXSVN3gmwjgd7oDNLlsXz526gpRA==} '@vitest/ui@4.0.10': resolution: {integrity: sha512-oWtNM89Np+YsQO3ttT5i1Aer/0xbzQzp66NzuJn/U16bB7MnvSzdLKXgk1kkMLYyKSSzA2ajzqMkYheaE9opuQ==} @@ -3346,8 +3346,8 @@ packages: '@vitest/utils@4.0.10': resolution: {integrity: sha512-kOuqWnEwZNtQxMKg3WmPK1vmhZu9WcoX69iwWjVz+jvKTsF1emzsv3eoPcDr6ykA3qP2bsCQE7CwqfNtAVzsmg==} - '@vitest/utils@4.0.18': - resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==} + '@vitest/utils@4.1.1': + resolution: {integrity: sha512-cNxAlaB3sHoCdL6pj6yyUXv9Gry1NHNg0kFTXdvSIZXLHsqKH7chiWOkwJ5s5+d/oMwcoG9T0bKU38JZWKusrQ==} '@vueless/storybook-dark-mode@9.0.5': resolution: {integrity: sha512-JU0bQe+KHvmg04k2yprzVkM0d8xdKwqFaFuQmO7afIUm//ttroDpfHfPzwLZuTDW9coB5bt2+qMSHZOBbt0w4g==} @@ -3556,9 +3556,9 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - balanced-match@4.0.3: - resolution: {integrity: sha512-1pHv8LX9CpKut1Zp4EXey7Z8OfH11ONNH6Dhi2WDUt31VVZFXZzKwXcysBgqSumFCmR+0dqjMK5v5JiFHzi0+g==} - engines: {node: 20 || >=22} + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} base64-arraybuffer@1.0.2: resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==} @@ -3587,9 +3587,9 @@ packages: brace-expansion@2.0.2: resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} - brace-expansion@5.0.2: - resolution: {integrity: sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==} - engines: {node: 20 || >=22} + brace-expansion@5.0.4: + resolution: {integrity: sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==} + engines: {node: 18 || 20 || >=22} braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} @@ -3694,6 +3694,10 @@ packages: resolution: {integrity: sha512-p4Z49OGG5W/WBCPSS/dH3jQ73kD6tiMmUM+bckNK6Jr5JHMG3k9bg/BvKR8lKmtVBKmOiuVaV2ws8s9oSbwysg==} engines: {node: '>=18'} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + chalk@3.0.0: resolution: {integrity: sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==} engines: {node: '>=8'} @@ -3876,8 +3880,8 @@ packages: copy-to-clipboard@3.3.3: resolution: {integrity: sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==} - core-js@3.48.0: - resolution: {integrity: sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==} + core-js@3.49.0: + resolution: {integrity: sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==} core-util-is@1.0.2: resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==} @@ -4164,11 +4168,9 @@ packages: dom-helpers@5.2.1: resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} - dompurify@3.2.4: - resolution: {integrity: sha512-ysFSFEDVduQpyhzAob/kkuJjf5zWkZD8/A9ywSp1byueyuCfHamrCBa14/Oc2iiB0e51B+NpxSl5gmzn+Ms/mg==} - - dompurify@3.3.1: - resolution: {integrity: sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==} + dompurify@3.3.2: + resolution: {integrity: sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==} + engines: {node: '>=20'} dot-case@3.0.4: resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==} @@ -4243,6 +4245,9 @@ packages: es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-module-lexer@2.0.0: + resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==} + es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -4273,8 +4278,8 @@ packages: engines: {node: '>=18'} hasBin: true - esbuild@0.27.3: - resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} + esbuild@0.27.4: + resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==} engines: {node: '>=18'} hasBin: true @@ -5186,8 +5191,8 @@ packages: peerDependencies: jspdf: ^2 || ^3 - jspdf@4.2.0: - resolution: {integrity: sha512-hR/hnRevAXXlrjeqU5oahOE+Ln9ORJUB5brLHHqH67A+RBQZuFr5GkbI9XQI8OUFSEezKegsi45QRpc4bGj75Q==} + jspdf@4.2.1: + resolution: {integrity: sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==} jsprim@2.0.2: resolution: {integrity: sha512-gqXddjPqQ6G40VdnI6T6yObEC+pDNvyP95wdQhkWkg7crHH3km5qP1FsOXEkzEQwnz6gz5qGTn1c2Y52wP3OyQ==} @@ -5451,8 +5456,8 @@ packages: minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} - minimatch@5.1.6: - resolution: {integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==} + minimatch@5.1.9: + resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} engines: {node: '>=10'} minimatch@9.0.5: @@ -5760,6 +5765,10 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + pidtree@0.6.0: resolution: {integrity: sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==} engines: {node: '>=0.10'} @@ -5799,6 +5808,10 @@ packages: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} + postcss@8.5.8: + resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} + engines: {node: ^10 || ^12 || >=14} + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -6118,8 +6131,8 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true - rollup@4.58.0: - resolution: {integrity: sha512-wbT0mBmWbIvvq8NeEYWWvevvxnOyhKChir47S66WCxw1SXqhw7ssIYejnQEVt7XYQpsj2y8F9PM+Cr3SNEa0gw==} + rollup@4.60.0: + resolution: {integrity: sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true @@ -6249,8 +6262,8 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} - simple-git@3.27.0: - resolution: {integrity: sha512-ivHoFS9Yi9GY49ogc6/YAi3Fl9ROnF4VyubNylgCkA+RVqLaKWnDSzXOVzya8csELIaWaYNutsEuAhZrtOjozA==} + simple-git@3.32.3: + resolution: {integrity: sha512-56a5oxFdWlsGygOXHWrG+xjj5w9ZIt2uQbzqiIGdR/6i5iococ7WQ/bNPzWxCJdEUGUCmyMH0t9zMpRJTaKxmw==} sirv@3.0.2: resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} @@ -6328,6 +6341,9 @@ packages: std-env@3.9.0: resolution: {integrity: sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==} + std-env@4.0.0: + resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==} + storybook@9.1.17: resolution: {integrity: sha512-kfr6kxQAjA96ADlH6FMALJwJ+eM80UqXy106yVHNgdsAP/CdzkkicglRAhZAvUycXK9AeadF6KZ00CWLtVMN4w==} hasBin: true @@ -6504,8 +6520,8 @@ packages: tinyexec@0.3.2: resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} - tinyexec@1.0.2: - resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} + tinyexec@1.0.4: + resolution: {integrity: sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==} engines: {node: '>=18'} tinyglobby@0.2.13: @@ -6524,6 +6540,10 @@ packages: resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} engines: {node: '>=14.0.0'} + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + tinyspy@4.0.4: resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} engines: {node: '>=14.0.0'} @@ -6941,20 +6961,21 @@ packages: jsdom: optional: true - vitest@4.0.18: - resolution: {integrity: sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==} + vitest@4.1.1: + resolution: {integrity: sha512-yF+o4POL41rpAzj5KVILUxm1GCjKnELvaqmU9TLLUbMfDzuN0UpUR9uaDs+mCtjPe+uYPksXDRLQGGPvj1cTmA==} 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.18 - '@vitest/browser-preview': 4.0.18 - '@vitest/browser-webdriverio': 4.0.18 - '@vitest/ui': 4.0.18 + '@vitest/browser-playwright': 4.1.1 + '@vitest/browser-preview': 4.1.1 + '@vitest/browser-webdriverio': 4.1.1 + '@vitest/ui': 4.1.1 happy-dom: '*' jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 peerDependenciesMeta: '@edge-runtime/vm': optional: true @@ -7356,7 +7377,7 @@ snapshots: '@babel/runtime@7.28.4': {} - '@babel/runtime@7.28.6': {} + '@babel/runtime@7.29.2': {} '@babel/template@7.27.0': dependencies: @@ -7566,7 +7587,7 @@ snapshots: '@esbuild/aix-ppc64@0.25.3': optional: true - '@esbuild/aix-ppc64@0.27.3': + '@esbuild/aix-ppc64@0.27.4': optional: true '@esbuild/android-arm64@0.25.12': @@ -7575,7 +7596,7 @@ snapshots: '@esbuild/android-arm64@0.25.3': optional: true - '@esbuild/android-arm64@0.27.3': + '@esbuild/android-arm64@0.27.4': optional: true '@esbuild/android-arm@0.25.12': @@ -7584,7 +7605,7 @@ snapshots: '@esbuild/android-arm@0.25.3': optional: true - '@esbuild/android-arm@0.27.3': + '@esbuild/android-arm@0.27.4': optional: true '@esbuild/android-x64@0.25.12': @@ -7593,7 +7614,7 @@ snapshots: '@esbuild/android-x64@0.25.3': optional: true - '@esbuild/android-x64@0.27.3': + '@esbuild/android-x64@0.27.4': optional: true '@esbuild/darwin-arm64@0.25.12': @@ -7602,7 +7623,7 @@ snapshots: '@esbuild/darwin-arm64@0.25.3': optional: true - '@esbuild/darwin-arm64@0.27.3': + '@esbuild/darwin-arm64@0.27.4': optional: true '@esbuild/darwin-x64@0.25.12': @@ -7611,7 +7632,7 @@ snapshots: '@esbuild/darwin-x64@0.25.3': optional: true - '@esbuild/darwin-x64@0.27.3': + '@esbuild/darwin-x64@0.27.4': optional: true '@esbuild/freebsd-arm64@0.25.12': @@ -7620,7 +7641,7 @@ snapshots: '@esbuild/freebsd-arm64@0.25.3': optional: true - '@esbuild/freebsd-arm64@0.27.3': + '@esbuild/freebsd-arm64@0.27.4': optional: true '@esbuild/freebsd-x64@0.25.12': @@ -7629,7 +7650,7 @@ snapshots: '@esbuild/freebsd-x64@0.25.3': optional: true - '@esbuild/freebsd-x64@0.27.3': + '@esbuild/freebsd-x64@0.27.4': optional: true '@esbuild/linux-arm64@0.25.12': @@ -7638,7 +7659,7 @@ snapshots: '@esbuild/linux-arm64@0.25.3': optional: true - '@esbuild/linux-arm64@0.27.3': + '@esbuild/linux-arm64@0.27.4': optional: true '@esbuild/linux-arm@0.25.12': @@ -7647,7 +7668,7 @@ snapshots: '@esbuild/linux-arm@0.25.3': optional: true - '@esbuild/linux-arm@0.27.3': + '@esbuild/linux-arm@0.27.4': optional: true '@esbuild/linux-ia32@0.25.12': @@ -7656,7 +7677,7 @@ snapshots: '@esbuild/linux-ia32@0.25.3': optional: true - '@esbuild/linux-ia32@0.27.3': + '@esbuild/linux-ia32@0.27.4': optional: true '@esbuild/linux-loong64@0.25.12': @@ -7665,7 +7686,7 @@ snapshots: '@esbuild/linux-loong64@0.25.3': optional: true - '@esbuild/linux-loong64@0.27.3': + '@esbuild/linux-loong64@0.27.4': optional: true '@esbuild/linux-mips64el@0.25.12': @@ -7674,7 +7695,7 @@ snapshots: '@esbuild/linux-mips64el@0.25.3': optional: true - '@esbuild/linux-mips64el@0.27.3': + '@esbuild/linux-mips64el@0.27.4': optional: true '@esbuild/linux-ppc64@0.25.12': @@ -7683,7 +7704,7 @@ snapshots: '@esbuild/linux-ppc64@0.25.3': optional: true - '@esbuild/linux-ppc64@0.27.3': + '@esbuild/linux-ppc64@0.27.4': optional: true '@esbuild/linux-riscv64@0.25.12': @@ -7692,7 +7713,7 @@ snapshots: '@esbuild/linux-riscv64@0.25.3': optional: true - '@esbuild/linux-riscv64@0.27.3': + '@esbuild/linux-riscv64@0.27.4': optional: true '@esbuild/linux-s390x@0.25.12': @@ -7701,7 +7722,7 @@ snapshots: '@esbuild/linux-s390x@0.25.3': optional: true - '@esbuild/linux-s390x@0.27.3': + '@esbuild/linux-s390x@0.27.4': optional: true '@esbuild/linux-x64@0.25.12': @@ -7710,7 +7731,7 @@ snapshots: '@esbuild/linux-x64@0.25.3': optional: true - '@esbuild/linux-x64@0.27.3': + '@esbuild/linux-x64@0.27.4': optional: true '@esbuild/netbsd-arm64@0.25.12': @@ -7719,7 +7740,7 @@ snapshots: '@esbuild/netbsd-arm64@0.25.3': optional: true - '@esbuild/netbsd-arm64@0.27.3': + '@esbuild/netbsd-arm64@0.27.4': optional: true '@esbuild/netbsd-x64@0.25.12': @@ -7728,7 +7749,7 @@ snapshots: '@esbuild/netbsd-x64@0.25.3': optional: true - '@esbuild/netbsd-x64@0.27.3': + '@esbuild/netbsd-x64@0.27.4': optional: true '@esbuild/openbsd-arm64@0.25.12': @@ -7737,7 +7758,7 @@ snapshots: '@esbuild/openbsd-arm64@0.25.3': optional: true - '@esbuild/openbsd-arm64@0.27.3': + '@esbuild/openbsd-arm64@0.27.4': optional: true '@esbuild/openbsd-x64@0.25.12': @@ -7746,13 +7767,13 @@ snapshots: '@esbuild/openbsd-x64@0.25.3': optional: true - '@esbuild/openbsd-x64@0.27.3': + '@esbuild/openbsd-x64@0.27.4': optional: true '@esbuild/openharmony-arm64@0.25.12': optional: true - '@esbuild/openharmony-arm64@0.27.3': + '@esbuild/openharmony-arm64@0.27.4': optional: true '@esbuild/sunos-x64@0.25.12': @@ -7761,7 +7782,7 @@ snapshots: '@esbuild/sunos-x64@0.25.3': optional: true - '@esbuild/sunos-x64@0.27.3': + '@esbuild/sunos-x64@0.27.4': optional: true '@esbuild/win32-arm64@0.25.12': @@ -7770,7 +7791,7 @@ snapshots: '@esbuild/win32-arm64@0.25.3': optional: true - '@esbuild/win32-arm64@0.27.3': + '@esbuild/win32-arm64@0.27.4': optional: true '@esbuild/win32-ia32@0.25.12': @@ -7779,7 +7800,7 @@ snapshots: '@esbuild/win32-ia32@0.25.3': optional: true - '@esbuild/win32-ia32@0.27.3': + '@esbuild/win32-ia32@0.27.4': optional: true '@esbuild/win32-x64@0.25.12': @@ -7788,7 +7809,7 @@ snapshots: '@esbuild/win32-x64@0.25.3': optional: true - '@esbuild/win32-x64@0.27.3': + '@esbuild/win32-x64@0.27.4': optional: true '@eslint-community/eslint-utils@4.4.1(eslint@9.31.0(jiti@2.4.2))': @@ -7806,7 +7827,7 @@ snapshots: '@eslint/config-array@0.21.0': dependencies: '@eslint/object-schema': 2.1.6 - debug: 4.4.0 + debug: 4.4.3(supports-color@8.1.1) minimatch: 3.1.2 transitivePeerDependencies: - supports-color @@ -7820,7 +7841,7 @@ snapshots: '@eslint/eslintrc@3.3.1': dependencies: ajv: 6.12.6 - debug: 4.4.0 + debug: 4.4.3(supports-color@8.1.1) espree: 10.4.0 globals: 14.0.0 ignore: 5.3.2 @@ -8078,7 +8099,7 @@ snapshots: '@kwsites/file-exists@1.1.1': dependencies: - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -8153,7 +8174,7 @@ snapshots: '@mui/private-theming@7.1.0(@types/react@19.1.6)(react@19.1.0)': dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 '@mui/utils': 7.1.0(@types/react@19.1.6)(react@19.1.0) prop-types: 15.8.1 react: 19.1.0 @@ -8162,7 +8183,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.6 + '@babel/runtime': 7.29.2 '@emotion/cache': 11.13.5 '@emotion/serialize': 1.3.3 '@emotion/sheet': 1.4.0 @@ -8197,7 +8218,7 @@ snapshots: '@mui/types@7.4.6(@types/react@19.1.6)': dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 optionalDependencies: '@types/react': 19.1.6 @@ -8346,21 +8367,21 @@ snapshots: '@rolldown/pluginutils@1.0.0-beta.32': {} - '@rollup/pluginutils@5.1.3(rollup@4.58.0)': + '@rollup/pluginutils@5.1.3(rollup@4.60.0)': dependencies: '@types/estree': 1.0.7 estree-walker: 2.0.2 picomatch: 4.0.3 optionalDependencies: - rollup: 4.58.0 + rollup: 4.60.0 - '@rollup/pluginutils@5.2.0(rollup@4.58.0)': + '@rollup/pluginutils@5.2.0(rollup@4.60.0)': dependencies: '@types/estree': 1.0.8 estree-walker: 2.0.2 picomatch: 4.0.3 optionalDependencies: - rollup: 4.58.0 + rollup: 4.60.0 '@rollup/rollup-android-arm-eabi@4.40.1': optional: true @@ -8368,7 +8389,7 @@ snapshots: '@rollup/rollup-android-arm-eabi@4.53.3': optional: true - '@rollup/rollup-android-arm-eabi@4.58.0': + '@rollup/rollup-android-arm-eabi@4.60.0': optional: true '@rollup/rollup-android-arm64@4.40.1': @@ -8377,7 +8398,7 @@ snapshots: '@rollup/rollup-android-arm64@4.53.3': optional: true - '@rollup/rollup-android-arm64@4.58.0': + '@rollup/rollup-android-arm64@4.60.0': optional: true '@rollup/rollup-darwin-arm64@4.40.1': @@ -8386,7 +8407,7 @@ snapshots: '@rollup/rollup-darwin-arm64@4.53.3': optional: true - '@rollup/rollup-darwin-arm64@4.58.0': + '@rollup/rollup-darwin-arm64@4.60.0': optional: true '@rollup/rollup-darwin-x64@4.40.1': @@ -8395,7 +8416,7 @@ snapshots: '@rollup/rollup-darwin-x64@4.53.3': optional: true - '@rollup/rollup-darwin-x64@4.58.0': + '@rollup/rollup-darwin-x64@4.60.0': optional: true '@rollup/rollup-freebsd-arm64@4.40.1': @@ -8404,7 +8425,7 @@ snapshots: '@rollup/rollup-freebsd-arm64@4.53.3': optional: true - '@rollup/rollup-freebsd-arm64@4.58.0': + '@rollup/rollup-freebsd-arm64@4.60.0': optional: true '@rollup/rollup-freebsd-x64@4.40.1': @@ -8413,7 +8434,7 @@ snapshots: '@rollup/rollup-freebsd-x64@4.53.3': optional: true - '@rollup/rollup-freebsd-x64@4.58.0': + '@rollup/rollup-freebsd-x64@4.60.0': optional: true '@rollup/rollup-linux-arm-gnueabihf@4.40.1': @@ -8422,7 +8443,7 @@ snapshots: '@rollup/rollup-linux-arm-gnueabihf@4.53.3': optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.58.0': + '@rollup/rollup-linux-arm-gnueabihf@4.60.0': optional: true '@rollup/rollup-linux-arm-musleabihf@4.40.1': @@ -8431,7 +8452,7 @@ snapshots: '@rollup/rollup-linux-arm-musleabihf@4.53.3': optional: true - '@rollup/rollup-linux-arm-musleabihf@4.58.0': + '@rollup/rollup-linux-arm-musleabihf@4.60.0': optional: true '@rollup/rollup-linux-arm64-gnu@4.40.1': @@ -8440,7 +8461,7 @@ snapshots: '@rollup/rollup-linux-arm64-gnu@4.53.3': optional: true - '@rollup/rollup-linux-arm64-gnu@4.58.0': + '@rollup/rollup-linux-arm64-gnu@4.60.0': optional: true '@rollup/rollup-linux-arm64-musl@4.40.1': @@ -8449,16 +8470,16 @@ snapshots: '@rollup/rollup-linux-arm64-musl@4.53.3': optional: true - '@rollup/rollup-linux-arm64-musl@4.58.0': + '@rollup/rollup-linux-arm64-musl@4.60.0': optional: true '@rollup/rollup-linux-loong64-gnu@4.53.3': optional: true - '@rollup/rollup-linux-loong64-gnu@4.58.0': + '@rollup/rollup-linux-loong64-gnu@4.60.0': optional: true - '@rollup/rollup-linux-loong64-musl@4.58.0': + '@rollup/rollup-linux-loong64-musl@4.60.0': optional: true '@rollup/rollup-linux-loongarch64-gnu@4.40.1': @@ -8470,10 +8491,10 @@ snapshots: '@rollup/rollup-linux-ppc64-gnu@4.53.3': optional: true - '@rollup/rollup-linux-ppc64-gnu@4.58.0': + '@rollup/rollup-linux-ppc64-gnu@4.60.0': optional: true - '@rollup/rollup-linux-ppc64-musl@4.58.0': + '@rollup/rollup-linux-ppc64-musl@4.60.0': optional: true '@rollup/rollup-linux-riscv64-gnu@4.40.1': @@ -8482,7 +8503,7 @@ snapshots: '@rollup/rollup-linux-riscv64-gnu@4.53.3': optional: true - '@rollup/rollup-linux-riscv64-gnu@4.58.0': + '@rollup/rollup-linux-riscv64-gnu@4.60.0': optional: true '@rollup/rollup-linux-riscv64-musl@4.40.1': @@ -8491,7 +8512,7 @@ snapshots: '@rollup/rollup-linux-riscv64-musl@4.53.3': optional: true - '@rollup/rollup-linux-riscv64-musl@4.58.0': + '@rollup/rollup-linux-riscv64-musl@4.60.0': optional: true '@rollup/rollup-linux-s390x-gnu@4.40.1': @@ -8500,7 +8521,7 @@ snapshots: '@rollup/rollup-linux-s390x-gnu@4.53.3': optional: true - '@rollup/rollup-linux-s390x-gnu@4.58.0': + '@rollup/rollup-linux-s390x-gnu@4.60.0': optional: true '@rollup/rollup-linux-x64-gnu@4.40.1': @@ -8509,7 +8530,7 @@ snapshots: '@rollup/rollup-linux-x64-gnu@4.53.3': optional: true - '@rollup/rollup-linux-x64-gnu@4.58.0': + '@rollup/rollup-linux-x64-gnu@4.60.0': optional: true '@rollup/rollup-linux-x64-musl@4.40.1': @@ -8518,16 +8539,16 @@ snapshots: '@rollup/rollup-linux-x64-musl@4.53.3': optional: true - '@rollup/rollup-linux-x64-musl@4.58.0': + '@rollup/rollup-linux-x64-musl@4.60.0': optional: true - '@rollup/rollup-openbsd-x64@4.58.0': + '@rollup/rollup-openbsd-x64@4.60.0': optional: true '@rollup/rollup-openharmony-arm64@4.53.3': optional: true - '@rollup/rollup-openharmony-arm64@4.58.0': + '@rollup/rollup-openharmony-arm64@4.60.0': optional: true '@rollup/rollup-win32-arm64-msvc@4.40.1': @@ -8536,7 +8557,7 @@ snapshots: '@rollup/rollup-win32-arm64-msvc@4.53.3': optional: true - '@rollup/rollup-win32-arm64-msvc@4.58.0': + '@rollup/rollup-win32-arm64-msvc@4.60.0': optional: true '@rollup/rollup-win32-ia32-msvc@4.40.1': @@ -8545,13 +8566,13 @@ snapshots: '@rollup/rollup-win32-ia32-msvc@4.53.3': optional: true - '@rollup/rollup-win32-ia32-msvc@4.58.0': + '@rollup/rollup-win32-ia32-msvc@4.60.0': optional: true '@rollup/rollup-win32-x64-gnu@4.53.3': optional: true - '@rollup/rollup-win32-x64-gnu@4.58.0': + '@rollup/rollup-win32-x64-gnu@4.60.0': optional: true '@rollup/rollup-win32-x64-msvc@4.40.1': @@ -8560,7 +8581,7 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.53.3': optional: true - '@rollup/rollup-win32-x64-msvc@4.58.0': + '@rollup/rollup-win32-x64-msvc@4.60.0': optional: true '@sentry-internal/browser-utils@9.19.0': @@ -8697,10 +8718,10 @@ 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.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))': + '@storybook/react-vite@9.0.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.60.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.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) + '@rollup/pluginutils': 5.1.3(rollup@4.60.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 @@ -8717,10 +8738,10 @@ snapshots: - supports-color - typescript - '@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))': + '@storybook/react-vite@9.1.17(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.60.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.58.0) + '@rollup/pluginutils': 5.2.0(rollup@4.60.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 @@ -9287,7 +9308,7 @@ snapshots: '@typescript-eslint/types': 8.29.0 '@typescript-eslint/typescript-estree': 8.29.0(typescript@5.7.3) '@typescript-eslint/visitor-keys': 8.29.0 - debug: 4.4.0 + debug: 4.4.3(supports-color@8.1.1) eslint: 9.31.0(jiti@2.4.2) typescript: 5.7.3 transitivePeerDependencies: @@ -9344,7 +9365,7 @@ snapshots: '@typescript-eslint/types': 8.38.0 '@typescript-eslint/typescript-estree': 8.38.0(typescript@5.7.3) '@typescript-eslint/utils': 8.38.0(eslint@9.31.0(jiti@2.4.2))(typescript@5.7.3) - debug: 4.4.0 + debug: 4.4.3(supports-color@8.1.1) eslint: 9.31.0(jiti@2.4.2) ts-api-utils: 2.1.0(typescript@5.7.3) typescript: 5.7.3 @@ -9375,7 +9396,7 @@ snapshots: '@typescript-eslint/tsconfig-utils': 8.38.0(typescript@5.7.3) '@typescript-eslint/types': 8.38.0 '@typescript-eslint/visitor-keys': 8.38.0 - debug: 4.4.0 + debug: 4.4.3(supports-color@8.1.1) fast-glob: 3.3.2 is-glob: 4.0.3 minimatch: 9.0.5 @@ -9427,7 +9448,7 @@ snapshots: transitivePeerDependencies: - '@swc/helpers' - '@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))': + '@vitest/coverage-v8@3.2.4(vitest@4.1.1(@types/node@22.18.1)(@vitest/ui@4.0.10(vitest@4.0.10))(jsdom@24.1.3)(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)))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 @@ -9442,7 +9463,7 @@ snapshots: std-env: 3.9.0 test-exclude: 7.0.1 tinyrainbow: 2.0.0 - 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) + vitest: 4.1.1(@types/node@22.18.1)(@vitest/ui@4.0.10(vitest@4.0.10))(jsdom@24.1.3)(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)) transitivePeerDependencies: - supports-color @@ -9463,14 +9484,14 @@ snapshots: chai: 6.2.1 tinyrainbow: 3.0.3 - '@vitest/expect@4.0.18': + '@vitest/expect@4.1.1': dependencies: '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.3 - '@vitest/spy': 4.0.18 - '@vitest/utils': 4.0.18 - chai: 6.2.1 - tinyrainbow: 3.0.3 + '@vitest/spy': 4.1.1 + '@vitest/utils': 4.1.1 + chai: 6.2.2 + tinyrainbow: 3.1.0 '@vitest/mocker@3.2.4(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))': dependencies: @@ -9499,14 +9520,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.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/mocker@4.1.1(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))': dependencies: - '@vitest/spy': 4.0.18 + '@vitest/spy': 4.1.1 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.1(@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) '@vitest/pretty-format@3.2.4': dependencies: @@ -9516,18 +9537,18 @@ snapshots: dependencies: tinyrainbow: 3.0.3 - '@vitest/pretty-format@4.0.18': + '@vitest/pretty-format@4.1.1': dependencies: - tinyrainbow: 3.0.3 + tinyrainbow: 3.1.0 '@vitest/runner@4.0.10': dependencies: '@vitest/utils': 4.0.10 pathe: 2.0.3 - '@vitest/runner@4.0.18': + '@vitest/runner@4.1.1': dependencies: - '@vitest/utils': 4.0.18 + '@vitest/utils': 4.1.1 pathe: 2.0.3 '@vitest/snapshot@4.0.10': @@ -9536,9 +9557,10 @@ snapshots: magic-string: 0.30.21 pathe: 2.0.3 - '@vitest/snapshot@4.0.18': + '@vitest/snapshot@4.1.1': dependencies: - '@vitest/pretty-format': 4.0.18 + '@vitest/pretty-format': 4.1.1 + '@vitest/utils': 4.1.1 magic-string: 0.30.21 pathe: 2.0.3 @@ -9548,7 +9570,7 @@ snapshots: '@vitest/spy@4.0.10': {} - '@vitest/spy@4.0.18': {} + '@vitest/spy@4.1.1': {} '@vitest/ui@4.0.10(vitest@4.0.10)': dependencies: @@ -9572,10 +9594,11 @@ snapshots: '@vitest/pretty-format': 4.0.10 tinyrainbow: 3.0.3 - '@vitest/utils@4.0.18': + '@vitest/utils@4.1.1': dependencies: - '@vitest/pretty-format': 4.0.18 - tinyrainbow: 3.0.3 + '@vitest/pretty-format': 4.1.1 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 '@vueless/storybook-dark-mode@9.0.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: @@ -9606,7 +9629,7 @@ snapshots: agent-base@7.1.1: dependencies: - debug: 4.4.0 + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -9807,13 +9830,13 @@ snapshots: babel-plugin-macros@3.1.0: dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 cosmiconfig: 7.1.0 resolve: 1.22.8 balanced-match@1.0.2: {} - balanced-match@4.0.3: {} + balanced-match@4.0.4: {} base64-arraybuffer@1.0.2: optional: true @@ -9838,9 +9861,9 @@ snapshots: dependencies: balanced-match: 1.0.2 - brace-expansion@5.0.2: + brace-expansion@5.0.4: dependencies: - balanced-match: 4.0.3 + balanced-match: 4.0.4 braces@3.0.3: dependencies: @@ -9922,9 +9945,9 @@ snapshots: canvg@3.0.11: dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 '@types/raf': 3.4.3 - core-js: 3.48.0 + core-js: 3.49.0 raf: 3.4.1 regenerator-runtime: 0.13.11 rgbcolor: 1.0.1 @@ -9945,9 +9968,9 @@ snapshots: adler-32: 1.3.1 crc-32: 1.2.2 - chai-string@1.5.0(chai@6.2.1): + chai-string@1.5.0(chai@6.2.2): dependencies: - chai: 6.2.1 + chai: 6.2.2 chai@5.3.3: dependencies: @@ -9959,6 +9982,8 @@ snapshots: chai@6.2.1: {} + chai@6.2.2: {} + chalk@3.0.0: dependencies: ansi-styles: 4.3.0 @@ -10133,7 +10158,7 @@ snapshots: dependencies: toggle-selection: 1.0.6 - core-js@3.48.0: + core-js@3.49.0: optional: true core-util-is@1.0.2: {} @@ -10430,18 +10455,13 @@ snapshots: dom-helpers@5.2.1: dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 csstype: 3.1.3 - dompurify@3.2.4: + dompurify@3.3.2: 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 @@ -10573,6 +10593,8 @@ snapshots: es-module-lexer@1.7.0: {} + es-module-lexer@2.0.0: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -10658,34 +10680,34 @@ snapshots: '@esbuild/win32-ia32': 0.25.3 '@esbuild/win32-x64': 0.25.3 - esbuild@0.27.3: + esbuild@0.27.4: optionalDependencies: - '@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 + '@esbuild/aix-ppc64': 0.27.4 + '@esbuild/android-arm': 0.27.4 + '@esbuild/android-arm64': 0.27.4 + '@esbuild/android-x64': 0.27.4 + '@esbuild/darwin-arm64': 0.27.4 + '@esbuild/darwin-x64': 0.27.4 + '@esbuild/freebsd-arm64': 0.27.4 + '@esbuild/freebsd-x64': 0.27.4 + '@esbuild/linux-arm': 0.27.4 + '@esbuild/linux-arm64': 0.27.4 + '@esbuild/linux-ia32': 0.27.4 + '@esbuild/linux-loong64': 0.27.4 + '@esbuild/linux-mips64el': 0.27.4 + '@esbuild/linux-ppc64': 0.27.4 + '@esbuild/linux-riscv64': 0.27.4 + '@esbuild/linux-s390x': 0.27.4 + '@esbuild/linux-x64': 0.27.4 + '@esbuild/netbsd-arm64': 0.27.4 + '@esbuild/netbsd-x64': 0.27.4 + '@esbuild/openbsd-arm64': 0.27.4 + '@esbuild/openbsd-x64': 0.27.4 + '@esbuild/openharmony-arm64': 0.27.4 + '@esbuild/sunos-x64': 0.27.4 + '@esbuild/win32-arm64': 0.27.4 + '@esbuild/win32-ia32': 0.27.4 + '@esbuild/win32-x64': 0.27.4 escalade@3.2.0: {} @@ -10977,6 +10999,10 @@ snapshots: optionalDependencies: picomatch: 4.0.3 + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + fflate@0.8.2: {} figures@3.2.0: @@ -11189,7 +11215,7 @@ snapshots: fs.realpath: 1.0.0 inflight: 1.0.6 inherits: 2.0.4 - minimatch: 5.1.6 + minimatch: 5.1.9 once: 1.4.0 global-dirs@3.0.1: @@ -11651,19 +11677,19 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 - jspdf-autotable@5.0.2(jspdf@4.2.0): + jspdf-autotable@5.0.2(jspdf@4.2.1): dependencies: - jspdf: 4.2.0 + jspdf: 4.2.1 - jspdf@4.2.0: + jspdf@4.2.1: dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 fast-png: 6.4.0 fflate: 0.8.2 optionalDependencies: canvg: 3.0.11 - core-js: 3.48.0 - dompurify: 3.3.1 + core-js: 3.49.0 + dompurify: 3.3.2 html2canvas: 1.4.1 jsprim@2.0.2: @@ -11964,9 +11990,9 @@ snapshots: dependencies: brace-expansion: 2.0.2 - minimatch@5.1.6: + minimatch@5.1.9: dependencies: - brace-expansion: 5.0.2 + brace-expansion: 5.0.4 minimatch@9.0.5: dependencies: @@ -12002,7 +12028,7 @@ snapshots: he: 1.2.0 js-yaml: 4.1.1 log-symbols: 4.1.0 - minimatch: 5.1.6 + minimatch: 5.1.9 ms: 2.1.3 serialize-javascript: 6.0.2 strip-json-comments: 3.1.1 @@ -12330,6 +12356,8 @@ snapshots: picomatch@4.0.3: {} + picomatch@4.0.4: {} + pidtree@0.6.0: {} pify@2.3.0: {} @@ -12338,12 +12366,12 @@ snapshots: possible-typed-array-names@1.0.0: {} - postcss-load-config@6.0.1(jiti@2.4.2)(postcss@8.5.6)(tsx@4.19.3)(yaml@2.6.1): + postcss-load-config@6.0.1(jiti@2.4.2)(postcss@8.5.8)(tsx@4.19.3)(yaml@2.6.1): dependencies: lilconfig: 3.1.3 optionalDependencies: jiti: 2.4.2 - postcss: 8.5.6 + postcss: 8.5.8 tsx: 4.19.3 yaml: 2.6.1 @@ -12353,6 +12381,12 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postcss@8.5.8: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + prelude-ls@1.2.1: {} prettier-linter-helpers@1.0.0: @@ -12726,35 +12760,35 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.53.3 fsevents: 2.3.3 - rollup@4.58.0: + rollup@4.60.0: dependencies: '@types/estree': 1.0.8 optionalDependencies: - '@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 + '@rollup/rollup-android-arm-eabi': 4.60.0 + '@rollup/rollup-android-arm64': 4.60.0 + '@rollup/rollup-darwin-arm64': 4.60.0 + '@rollup/rollup-darwin-x64': 4.60.0 + '@rollup/rollup-freebsd-arm64': 4.60.0 + '@rollup/rollup-freebsd-x64': 4.60.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.0 + '@rollup/rollup-linux-arm-musleabihf': 4.60.0 + '@rollup/rollup-linux-arm64-gnu': 4.60.0 + '@rollup/rollup-linux-arm64-musl': 4.60.0 + '@rollup/rollup-linux-loong64-gnu': 4.60.0 + '@rollup/rollup-linux-loong64-musl': 4.60.0 + '@rollup/rollup-linux-ppc64-gnu': 4.60.0 + '@rollup/rollup-linux-ppc64-musl': 4.60.0 + '@rollup/rollup-linux-riscv64-gnu': 4.60.0 + '@rollup/rollup-linux-riscv64-musl': 4.60.0 + '@rollup/rollup-linux-s390x-gnu': 4.60.0 + '@rollup/rollup-linux-x64-gnu': 4.60.0 + '@rollup/rollup-linux-x64-musl': 4.60.0 + '@rollup/rollup-openbsd-x64': 4.60.0 + '@rollup/rollup-openharmony-arm64': 4.60.0 + '@rollup/rollup-win32-arm64-msvc': 4.60.0 + '@rollup/rollup-win32-ia32-msvc': 4.60.0 + '@rollup/rollup-win32-x64-gnu': 4.60.0 + '@rollup/rollup-win32-x64-msvc': 4.60.0 fsevents: 2.3.3 rrweb-cssom@0.7.1: {} @@ -12906,11 +12940,11 @@ snapshots: signal-exit@4.1.0: {} - simple-git@3.27.0: + simple-git@3.32.3: dependencies: '@kwsites/file-exists': 1.1.1 '@kwsites/promise-deferred': 1.1.1 - debug: 4.4.0 + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -12993,6 +13027,8 @@ snapshots: std-env@3.9.0: {} + std-env@4.0.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: '@storybook/global': 5.0.0 @@ -13227,7 +13263,7 @@ snapshots: tinyexec@0.3.2: {} - tinyexec@1.0.2: {} + tinyexec@1.0.4: {} tinyglobby@0.2.13: dependencies: @@ -13243,6 +13279,8 @@ snapshots: tinyrainbow@3.0.3: {} + tinyrainbow@3.1.0: {} + tinyspy@4.0.4: {} tldts-core@6.1.61: {} @@ -13334,7 +13372,7 @@ snapshots: optionalDependencies: '@mui/material': 7.1.0(@emotion/react@11.13.5(@types/react@19.1.6)(react@19.1.0))(@emotion/styled@11.13.5(@emotion/react@11.13.5(@types/react@19.1.6)(react@19.1.0))(@types/react@19.1.6)(react@19.1.0))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - tsup@8.4.0(@swc/core@1.13.5)(jiti@2.4.2)(postcss@8.5.6)(tsx@4.19.3)(typescript@5.9.3)(yaml@2.6.1): + tsup@8.4.0(@swc/core@1.13.5)(jiti@2.4.2)(postcss@8.5.8)(tsx@4.19.3)(typescript@5.9.3)(yaml@2.6.1): dependencies: bundle-require: 5.1.0(esbuild@0.25.3) cac: 6.7.14 @@ -13344,7 +13382,7 @@ snapshots: esbuild: 0.25.3 joycon: 3.1.1 picocolors: 1.1.1 - postcss-load-config: 6.0.1(jiti@2.4.2)(postcss@8.5.6)(tsx@4.19.3)(yaml@2.6.1) + postcss-load-config: 6.0.1(jiti@2.4.2)(postcss@8.5.8)(tsx@4.19.3)(yaml@2.6.1) resolve-from: 5.0.0 rollup: 4.40.1 source-map: 0.8.0-beta.0 @@ -13354,7 +13392,7 @@ snapshots: tree-kill: 1.2.2 optionalDependencies: '@swc/core': 1.13.5 - postcss: 8.5.6 + postcss: 8.5.8 typescript: 5.9.3 transitivePeerDependencies: - jiti @@ -13560,9 +13598,9 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.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)): + vite-plugin-svgr@4.5.0(rollup@4.60.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.58.0) + '@rollup/pluginutils': 5.2.0(rollup@4.60.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) @@ -13571,9 +13609,9 @@ snapshots: - supports-color - typescript - 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)): + vite-plugin-svgr@4.5.0(rollup@4.60.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.58.0) + '@rollup/pluginutils': 5.2.0(rollup@4.60.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.1(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) @@ -13600,11 +13638,11 @@ snapshots: 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.3 - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 - postcss: 8.5.6 - rollup: 4.58.0 + esbuild: 0.27.4 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.8 + rollup: 4.60.0 tinyglobby: 0.2.15 optionalDependencies: '@types/node': 22.18.1 @@ -13655,44 +13693,34 @@ snapshots: - tsx - yaml - 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): + vitest@4.1.1(@types/node@22.18.1)(@vitest/ui@4.0.10(vitest@4.0.10))(jsdom@24.1.3)(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)): dependencies: - '@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 + '@vitest/expect': 4.1.1 + '@vitest/mocker': 4.1.1(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/pretty-format': 4.1.1 + '@vitest/runner': 4.1.1 + '@vitest/snapshot': 4.1.1 + '@vitest/spy': 4.1.1 + '@vitest/utils': 4.1.1 + es-module-lexer: 2.0.0 expect-type: 1.3.0 magic-string: 0.30.21 obug: 2.1.1 pathe: 2.0.3 - picomatch: 4.0.3 - std-env: 3.10.0 + picomatch: 4.0.4 + std-env: 4.0.0 tinybench: 2.9.0 - tinyexec: 1.0.2 + tinyexec: 1.0.4 tinyglobby: 0.2.15 - tinyrainbow: 3.0.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) + tinyrainbow: 3.1.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) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.18.1 '@vitest/ui': 4.0.10(vitest@4.0.10) jsdom: 24.1.3 transitivePeerDependencies: - - jiti - - less - - lightningcss - msw - - sass - - sass-embedded - - stylus - - sugarss - - terser - - tsx - - yaml w3c-xmlserializer@5.0.0: dependencies: diff --git a/scripts/package.json b/scripts/package.json index 8a9ec3dd44b..80867151146 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -19,7 +19,7 @@ "commander": "^6.2.1", "inquirer": "^12.9.4", "junit2json": "^3.1.4", - "simple-git": "^3.19.0", + "simple-git": "^3.32.3", "tsx": "^4.19.3" } }